使用PHP Socket开发Yar TCP服务

laruence 于2020年4月1日发布
本文地址:https ://www.laruence.com/2020/04/01/5726.html

Yar支持HTTP和TCP俩种Transporter,HTTP的是基于CURL,PHP中的Yar就是走的HTTP Transporter,这个大家应该都不陌生,但是基于TCP的,可能大家会用的少一些。

实际上,我6年前也写过一个C的Yar服务器框架,叫做Garthub上的Yar-c,代码地址在Yar-C,它提供了服务启动,worker进程管理,Yar打包协议等。框架,实现了高效的微博白名单等服务,以供PHP端使用Yare Client来调用。

只不过,Yar C需要用C来写Handle,可能对于多余的PHPer来说,会有点有点陌生,那今天我们尝试用PHP来写一个TCP的Server,来介绍下如何实现对Yar RPC协议的处理,这个例子可以方便的结合Swoole等异步PHP框架,实现一个高性能的亚尔TCP服务器。这个过程中,会让大家了解亚尔的RPC通信协议,以及捎带了解下的Socket编程。

我们今天还是用“白名单”服务作为例子,我们提供一个接口,接受RPC客户端的请求,参数是一个用户ID,返回bool,表示是否在白名单:

函数 查询(int  $ id ) : bool ;

首先,我们建立一个文件yar_server,为了方便的直接执行,我们在文件写道:

#!/ bin / env php7
<?php
类 WhiteList {
}

然后,通过chmod a + x给这个文件增加重新的权限。

第一步我们需要处理服务的启动参数处理,接受一个参数S表示要监听的IP和端口,值的格式是host:port,我们使用PHP的getopt函数来处理命令行参数:

类 WhiteList {
    受保护的 $ host;
 
    公共 功能 __construct () {
        $ options  =  getOpt (“ S:” ) ;
        if  (!isset ($ options [ “ S” ])) {
            $ this- > 用法() ;
        }
    }
 
    受保护的 功能 用法() {
        exit (“用法:yar_server -S主机名:端口\ n” );
    }
}

这样,当用户启动yar_server的时候,没有指定S参数,我们就退出,并提示用法。我们还需要另外一个配置,就是指向一个词表文件,词表文件中每一行是一个在白名单中的用户ID,我们用F表示:

类 WhiteList {
    受保护的 $ host;
    受保护的 字典 ;
 
    公共 功能 __construct () {
        $ options  =  getOpt (“ S:F:” ) ;
        if  (!isset ($ options [ “ S” ]) ||  !!setset ($ options [ “ F” ])) {
            $ this- > 用法() ;
        }
        $ this- > host =  $ options [ “ S” ] ;
        $ this- > dicts =  $ options [ “ F” ] ;
    }
 
    受保护的 功能 用法() {
        exit (“用法:yar_server -F path_to_dict -S主机名:端口\ n” );
    }
}

好了,现在启动参数处理完成,当然为了简单,我省去了对输入参数的有效检查。

接下来,我们需要完成俩个函数,第一个是读取-F指定的词表文件,把所有的用户ID读入到一个数组中,因为我们的这个服务会是常驻进行,所以不用担心性能,它只会在启动阶段处理这个词表文件:

受保护的 函数 loadDict () {
     $ this- > ids =  array () ;
 
     $ fp  =  fopen ($ this- > dicts,“ r” ) ;
     while  (!feof ($ fp )) {
          $ line  =  trim (fgets ($ fp )) ;
          如果 ($ line ) {
               $ this- > ids [ $ line ]  =  true ;
          }
     }
     fclose ($ fp ) ;
     回声 “成功加载字典,”,计数($ this- > ids ),“已加载\ n”;
 
     返回 $ this ;
}

因为用户ID是整型,所以我们把它当作Hashtable的键,这样在将来查找的时候,使用isset会非常高效。需要注意的是因为文件处理不是我们今天要讲的重点,也就省去了对文件存在行,意图性,合法性的检查。

好了,接下来是重点了,我们要启动一个IPV4 TCP Socket服务,监听在$ host指定的地方,为了方便大家了解Socket API,我们不采用PHP的Stream系列函数,而是采用PHP直接包装的Socket系列API,首先我们用socket_create创建一个套接字专有:

受保护的 函数 listen () {
     $ socket  =  socket_create ( AF_INET,SOCK_STREAM,SOL_TCP ) ;
     如果 ($ socket  ==  false ) {
          抛出 新 异常(“ socket_create()失败:原因:”  。 socket_strerror (socket_last_error ())) ;
     }
}

然后,我们需要使用socket_bind绑定这个套接字到我们需要监听的地址,并使用socket_listen来监听请求:

受保护的 函数 listen () {
     $ socket  =  socket_create ( AF_INET,SOCK_STREAM,SOL_TCP ) ;
     如果 ($ socket  ==  false ) {
          抛出 新 异常(“ socket_create()失败:原因:”  。 socket_strerror (socket_last_error ())) ;
     }
     list ($ hostname,$ port ) =  爆炸(“:”,$ this- > host ) ;
     如果 (socket_bind ($ socket,$ hostname,$ port ) ==  false ) {
          抛出 新的 异常(“ socket_bind()失败:原因:”  。 socket_strerror (socket_last_error ()));
     }
     如果 (socket_listen ($ socket,64 ) ===  false ) {
          抛出 新的 异常(“ socket_listen()失败:原因:”  。 socket_strerror (socket_last_error ()));
     }
     echo  “在{$ this-> host}处启动Yar_Server \ n按Ctrl + C退出\ n”;
 
     $ this- > socket =  $ socket ;
     返回 $ this ;
}

好了,如果一切没问题,接下来我们就可以socket_accept来监听请求了,默认的socket是分段模式,如果没有请求,进程会一直一直等待,对于高效的服务来说,最好采用非捆绑+ select或者epoll的模式来同时处理多个请求,但是我们的这个示例主要是为了介绍Yar的协议,所以还是采用简单的分段模式。

接下来,我们来编写真正的RPC处理部分,首先我们通过accept接受一个请求,然后读取请求的内容,分析请求头中的Yar RPC Header信息,Yar RPC的协议头定义如下:

typedef  struct _yar_header {  
    uint32_t        id;            //交易编号
    uint16_t        版本;       //协议版本
    uint32_t        magic_num;     //默认值为:0x80DFEC60
    uint32_t        保留;
    未签名的 char   提供程序[ 32 ];  //要求谁
    未签名的 char   令牌[ 32 ];     //请求令牌,用于身份验证
    uint32_t        body_len;      //请求正文len
}
其中,magic_num是为验证验证有效的一个特殊值,合法的Yar RPC请求都会设置这个变量0x80DFEC60(我很想告诉你为啥是这个值,但我真不记得当时我为啥用这个数字了),这个头部是82个字节,可能有同学会问,不对啊一看这个Struct不应该是82啊,那是因为头部申明的时候采用pack模式,也就是不对齐,所以确实是82个字节。
包装(“ H *”,“ 80DFEC60” ) ;

provider是一个字符串,标明了客户端的名字,例如对于Yar扩展的Yar_Client就是“ Yar PHP Cient-xxx”

token在设计的最初是为了做API key验证的,但是后来没用上,因为大部分都是内网应用,可以有多种方法来保证请求来源的合法性。

id是一个唯一请求id,这个是为了排查请求问题的版本,版本为0,或者1,目前我没有升级过协议头,所以这个暂时我们也不用关心,保留的可以使用传递一些请求参数,例如客户端可以说明是否保持连接。

body_len是我们需要关心的,这个指出表明了这次请求,请求体一共多大(不包括Yar协议标题)。

所有的这些数字,都是以网络字节序传递的,我们采用PHP处理二进制流的unpack函数来解析读取进来的二进制流:

受保护的 函数 parseHeader ($ header ) {
   返回 
     解压(“ Nid / nversion / Nmagic_num / Nreserved / A32provider / A32token / Nbody_len”,$ header ) ;
}

这个函数会返回一个上面说到的头部结构体的层叠。

对应的我们也需要使用pack来实现生成Yar Header的方法:

const YAR_MAGIC_NUM =  0x80DFEC60 ;
受保护的 函数 genHeader ($ id,$ len ) {
     $ bin  =  pack (“ NnNNA32A32N”,$ id,0,self :: YAR_MAGIC_NUM,0,“ Yar PHP TCP Server”,“”,$ len ) ;
     返回 $ bin ;
}

如刚才说的,我们需要在接受一个请求以前,验证请求的合法性:

受保护的 函数 validRequest ($ header ) {
     if  ($ header [ “ magic_num” ]  !=  self :: YAR_MAGIC_NUM ) {
          返回 false ;
     }
     返回 true ;
}

所以大概请求的处理整个逻辑框架是:

受保护的 函数 accept () {
     while  ((($ conn  =  socket_accept ($ this- > socket ))) {
          $ buf  =  socket_read ($ conn,self :: HEADER_SIZE,PHP_BINARY_READ ) ; 复制代码
          如果 ($ buf  ===  false ) {
               socket_shutdown ($ conn ) ;
               继续 ;
          }
 
          if  (!$ this- > validHeader ($ header  =  $ this- > parseHeader ($ buf ))) {
               $ output  =  $ this- > response (1,“非法Yar RPC请求” );
               转到响应;
          }
 
          $ buf  =  socket_read ($ conn,$ header [ “ body_len” ],PHP_BINARY_READ ) ;
          如果 ($ buf  ===  false ) {
               $ output  =  $ this- > response (1,“请求主体不足” );
               转到响应;
          }
 
          if  (!$ this- > validPackager ($ buf )) {
               $ output  =  $ this- > response (1,“不受支持的打包程序” );
               转到响应;
          }
 
          $ buf  =  substr ($ buf,8 ) ; / *跳过打包信息的8个字节* /
          $ request  =  $ this- > parseRequest ($ buf ) ;
          如果 ($ request  ==  false ) {
               $ this- > response (1,“格式错误的请求正文” );
               转到响应;
          }
 
          $状态 =  $这- > 手柄($请求,$ RET ) ;
 
          $输出 =  $这- > 响应($状态,$ RET ) ;
回应:
          socket_write ($ conn,$ output,strlen ($ output )) ; 复制代码
 
          socket_shutdown ($ conn ) ; / *关闭写* /
     }
}

现在整体的框架就算完成了,我们需要完成handle,response方法就可以了,handle是要根据用户的请求中的m,来调用指定的方法

保护 功能 句柄($请求,&$ RET ) {
     if  ($ request [ “ m” ]  ==  “ query” ) {
          $ RET  =  $这- > 查询(... $请求[ “P” ]) ;
     }  其他 {
          $ ret  =  “不支持的方法'”  。 $ request [ “ m” ]。 “'”;
          返回 1 ;
     }
     返回 0 ;
}

现在来实现query方法本身,这个会很简单,就检查下id是不是在白名单数组:

受保护的 函数 查询($ id ) {
     返回 isset ($ this- > ids [ $ id ]) ;
}

好了,接下来我们要完成response方法,这个方法是打包一个符合Yar协议的返回体,包括82个字节的头部,8个字节的打包信息,以及序列化后的响应体,我们需要根据状态不同,来选择设置响应体中的r还是e细分:

受保护的 函数 响应($ status,$ ret ) {
     $ body  =  array () ;
 
     $ body [ “ i” ]  =  0 ;
     $ body [ “ s” ]  =  $ status ;
     如果 ($ status  ==  0 ) {
          $ body [ “ r” ]  =  $ ret ;
     }  其他 {
          $ body [ “ e” ]  =  $ ret ;
     }
 
     $ packed  =  序列化($ body ) ;
     $ header  =  $ this- > genHeader (0,strlen ($ packed ) +  8 ) ; 复制代码
 
     返回 $ header  。 str_pad (“ PHP”,8,“ \ 0” ) 。 $包装 ;
}

好了,马上就要大功告成,我们最后完成启动方法和析构函数(关闭套接字):

公共 功能 运行() {
     $ this- > loadDict ()-> 听()-> accept () ;
}
公共 功能 __destruct () {
     如果 ($ this- > socket ) {
          socket_close ($ this- > socket ) ; 复制代码
     }
}

现在一切就绪,我们最后在文件末尾加入:

(新的白名单)-> 运行() ;

在测试之前,我们先准备一个测试词表,例如1到1000的id:

seq 1,1,10000> user_id.dict

然后启动服务,监听在本机的9000端口:

$ ./yar_server -F user_id.dict -S127.0.0.1:9000
成功加载dict,已加载1000
在127.0.0.1:9000启动Yar_Server
按Ctrl + C退出

不错,服务启动成功,然后我们使用Yar扩展来编写客户端(你需要首先安装好Yar扩展),测试下用户id 999和99999的调用效果:

<?php
$ yar  =  新的 Yar_Client (“ tcp://127.0.0.1:9000” ) ;
var_dump ($ yar- > query (“ 999” )) ;
var_dump ($ yar- > 查询(“ 99999” )) ;
?>

和调用HTTP的Yar服务不同,此处我们应该使用tcp://做地址头,表示这是一个TCP的服务。

来,运行一下看看:

php7 client.php
布尔值(true)
布尔值(false)

你也可以尝试故意构造一些错误的可能,称为调用不存在的方法之类的,来看看服务器的反应,这个例子的代码你可以在这里找到。

到这里我就算介绍完了如何采用PHP来编写Yar的TCP服务,大家应该可以很方便的把这个例子修改完善成自己希望的格式,或者嵌入Swoole(可以参考Swoole作者写的:这里)。

还是要再次说明,因为本文的主要目的是为了介绍RPC通讯协议,所以在服务管理这块并没有做的很完善,套接字接受,套接字读/写等都被采用了某种模式,也没有加入超时设计,服务进程也只有一个,这个如果真的想用做实际服务的话,还是需要一些功课的,不过我相信你有兴趣的话,都是可以搞定的。:)

当然,最简单的是,你可以直接使用Yar-C服务框架来编写C Yar TCP服务。

在这里也有一个亚尔-C服务器的例子yar_server用C

以上内容希望帮助到大家,很多PHPer在进阶的时候总会遇到一些问题和瓶颈,业务代码写多了没有方向感,不知道该从那里入手去提升,对此我整理了一些资料,包括但不限于:分布式架构、高可扩展、高性能、高并发、服务器性能调优、TP6,laravel,YII2,Redis,Swoole、Swoft、Kafka、Mysql优化、shell脚本、Docker、微服务、Nginx等多个知识点高级进阶干货需要的可以免费分享给大家需要戳这里PHP进阶架构师>>>视频、面试文档免费获取

或 者关注我每天分享技术文章

来源:https ://www.laruence.com/2020/04/01/5726.html

发布于 2020-05-18 14:58