BeWithYou

胡搞的技术博客

  1. 首页
  2. PHP
  3. 实现一个TCP服务端PHP扩展

实现一个TCP服务端PHP扩展


# 实现一个TCP服务端PHP扩展 > 博客断更2个月了,一是要离开帝都了,最近主要做交接工作,二是懒。 > > 这段时间琢磨写个PHP扩展玩玩。于是想到写一个简单的TCP服务端,能在PHP中方便的实现TCP长连接处理和响应。当然,只是个玩具,以**跑通**的为目的。 > > 本文记录一下实现过程中的重点。并附上代码的链接。 ## 在PHP中的体现 设计扩展,最好先把它在PHP中的体现描述清楚。比如以一个类的形式来描述: ```php /** * Class simple_server * TCP服务端类 */ class simple_server { //监听端口 public $port; //reactor线程数 public $threadNum; //worker进程数 public $workerNum; /** * 有新链接建立 */ private function OnConnect(simple_server $server, $fd, $threadId){} /** * 有链接断开了 */ private function OnClose(simple_server $server, $fd, $threadId){} /** * 接收tcp消息的回调函数 */ private function OnReceive(simple_server $server, $fd, $threadId, $data){} /** * 绑定事件 * $name只接受connect/receive/close 3个字符串 * $cb是回调函数 分别对应上面的3个方法 */ public function on($name, callable $cb){} /** * 发送消息给指定fd的客户端 * $fd是客户端socket的文件描述符 可以从上文中取到 */ public function send($data, $fd){} /** * 开启服务 */ public function start(){} } ``` 我们的这个类非常简单,使用的时候只需要用 `on`方法为Server对象绑定事件,之后`start`开启服务即可。业务在各个回调函数里编写。 ## 结构设计 既然是作为练习,我决定用Reactor模式来实现TCP服务器,并且仿照swoole的设计,使用子进程`worker_process`来做具体的业务处理。 关于Reactor模式,网上有很多详细的讲解,这里就不再做赘述了。我们直接使用libevent来实现Reactor线程。 具体的业务处理是阻塞的,所以我们放到worker进程里去做,不耽误主进程的reactor线程做网络收发。 大概流程可以用下面的图描述: ![结构图](/ueditor/php/upload/image/20170630/1498829444511595.png) 1. Master进程监听指定的端口,等待TCP链接请求 2. 客户端请求与Master进程新建链接 3. Master进程将此链接,通过pipe交给某个Reactor线程去全权处理 4. Reactor线程收到来自客户端的数据,通过消息队列发送给某个Worker进程 5. Worker进程执行用户从PHP层指明的处理方法 6. Worker进程将处理结果,再通过pipe的方式传递给那个Reactor线程 7. Reactor线程讲响应输出给客户端 ## 技术细节 ### PHP扩展层 这里就不讲如何搭建PHP扩展啦。简单记录下几个重要部分。 #### 使用C++开发 纯C写起来太蛋疼了。还是退一步换C++吧。需要修改config.m4文件,添加`PHP_REQUIRE_CXX()`,并指定`PHP_ADD_LIBRARY(stdc++, 1, SIMPLE_SERVER_SHARED_LIBADD)。` 然后在扩展最外层的头文件,用`extern "C"{}` 将`php.h`等头文件包起来。 #### 依赖其他库 比如我们依赖libevent和pthread库,则需要在config.m4中声明依赖库。 ``` PHP_ADD_LIBRARY(event, 1, SIMPLE_SERVER_SHARED_LIBADD) PHP_ADD_LIBRARY(pthread, 1, SIMPLE_SERVER_SHARED_LIBADD) PHP_SUBST(SIMPLE_SERVER_SHARED_LIBADD) ``` #### 从上下文中取得当前server对象 在PHP层调用我们的函数时,可以通过`getThis()`方法知道确定`simple_server`对象。但是在C层面,这个对象应该绑定一个具体处理业务的对象`TcpEventServer。`虽然我们的扩展不在fpm里跑,一般不存在多个server对象。但还是区别清楚比较好。我们可以定义一个全局的`map server_map`,将`simple_server`与`TcpEventServer`绑定起来。 其中作为key的int,可以由`Z_OBJ_HANDLE(*object)`获取。object是我们`simple_server`这个 zval的指针。 ### C++中为对象动态绑定方法 首先声明一个函数类型的变量类型`typedef void(*ServerCbFunc)(ServerCbParam *param);`这样在下文中就可以使用`ServerCbFunc`声明类里的成员函数了。 比如声明`ServerCbFunc OnReceive;`之后编写完回调函数后,将函数指针赋值给对象的成员函数变量即可。 ### 使用匿名管道 我们使用`pipe`函数,得到一个可用的匿名管道。`int fds[2];pipe(fds);`即可,其中`fds[0]`是接收端,`fds[1]`是发送端。将其绑定到子线程中,即可实现Master进程与Reactor线程单向通信。 需要注意的是,pipe只能用于有血缘关系的进程/线程间通信,当然我们的WorkerProcess也可以用父进程的pipe与Reactor线程通信啦!不过需要在`fork`子进程之前,就建立好管道。 ### 使用消息队列 我们使用主进程的pid来生成key,`key_t key = ftok(i2s(m_MasterPid), 'a');`,之后得到可以用的MsgId,`m_MsgId = msgget(key, IPC_CREAT|0666);`。 相比于其他示例程序中,使用文件路径和文件名来生成key,这种可以最大程度避免重复用同一个队列。比如同一个脚本跑两次,就会产生公用队列的问题。 我们知道消息队列使用是,重要入参是一个结构体,前面指明消息类型,后面是数据内容。但是,数据内容其实并不限定于字符串!反正是二进制内容,我们可以将其他类型的数据放在里面。 比如`struct MsgBuff`是我们消息队列用的,还可以再定义一个结构体`struct EventData`,赋值以后将其`memcpy`到`MsgBuff`的data字段里面。只要在接收端,重新强制转换一下指针类型即可。 ### 主进程使用libevent监听新链接 以前我们的做法都是手动编写一个socket监听的完整流程: 1. 新建socketfd 2. 新建socketaddr 3. bind到socketfd 4. 监听 5. accept一个新链接 6. 交给Reactor线程 但是libevent提供了一个更简单的方法,我们只需要以下几行: ```C evconnlistener *listener; sockaddr_in sin; memset(&sin, 0, sizeof(sin)); sin.sin_family = AF_INET; sin.sin_port = htons(m_Port); listener = evconnlistener_new_bind(m_MainBase->base, ListenerEventCb, (void *) this, LEV_OPT_REUSEABLE | LEV_OPT_CLOSE_ON_FREE | LEV_OPT_THREADSAFE, -1, (sockaddr *) &sin, sizeof(sockaddr_in)); if (NULL == listener) ErrorQuit("TCP listen error"); //... something else event_base_dispatch(m_MainBase->base); ``` 其中`m_MainBase->base`是一个`event_base`对象。`ListenerEventCb`是监听到新链接后的回调函数。 ### Mac中消息队列的限制 程序在`CentOS`中测试完后,去Mac下跑却发现消息队列收不到数据。调试时发现,调用`msgsnd`函数,返回的`errno`是`Invalid Argument`。试了很久,发现是消息长度的问题。 Mac控制台下使用命令`ipcs -Q`,得到: ```shell msgmax: 16384 (max characters in a message) msgmni: 40 (# of message queues) msgmnb: 2048 (max characters in a message queue) msgtql: 40 (max # of messages in system) msgssz: 8 (size of a message segment) msgseg: 2048 (# of message segments in system) ``` 然而我们定义的结构体大小已经超过2048了。于是弄小一点就能跑通了。 其实用不到这么大的,这里只是简化了。在数据很大时,肯定不能用消息队列或者管道传递用户数据。而应该把数据放在共享内存或者文件之类的地方。 ### 使用范例 ```php on("connect", function($serv, $fd, $td){ // var_dump($serv, $fd, $td); echo "connected\n"; }); $s->on("close", function($serv, $fd, $td){ //var_dump($serv, $fd, $td); echo "closed\n"; }); //实现将客户端输入的整数+1后输出给客户端 $s->on("receive", function($serv,$fd,$td, $data){ echo "PHP receive:".($data); if(is_numeric(trim($data))){ $serv->send($fd, (intval(trim($data))+1)."\n"); } }); $s->start(); //telnet 127.0.0.1 9981 //即可测试 ``` 暂时只想到这么多,还有很多要改进的地方。毕竟只是个练习嘛,以跑通为目的 :) ------ 实现过程参考了这篇[文章](http://blog.csdn.net/flyingleo1981/article/details/51862857) 代码Github地址在[这里](https://github.com/whahuzhihao/simple_server)
回到顶部