BeWithYou

胡搞的技术博客

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

实现一个TCP服务端PHP扩展


实现一个TCP服务端PHP扩展

博客断更2个月了,一是要离开帝都了,最近主要做交接工作,二是懒。

这段时间琢磨写个PHP扩展玩玩。于是想到写一个简单的TCP服务端,能在PHP中方便的实现TCP长连接处理和响应。当然,只是个玩具,以跑通的为目的。

本文记录一下实现过程中的重点。并附上代码的链接。

在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线程做网络收发。

大概流程可以用下面的图描述:

结构图

  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<int, TcpEventServer *> server_map,将simple_serverTcpEventServer绑定起来。

其中作为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,赋值以后将其memcpyMsgBuff的data字段里面。只要在接收端,重新强制转换一下指针类型即可。

主进程使用libevent监听新链接

以前我们的做法都是手动编写一个socket监听的完整流程:

  1. 新建socketfd
  2. 新建socketaddr
  3. bind到socketfd
  4. 监听
  5. accept一个新链接
  6. 交给Reactor线程

但是libevent提供了一个更简单的方法,我们只需要以下几行:

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函数,返回的errnoInvalid Argument。试了很久,发现是消息长度的问题。

Mac控制台下使用命令ipcs -Q,得到:

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
$s = new simple_server(9981, 2, 2);

$s->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
//即可测试

暂时只想到这么多,还有很多要改进的地方。毕竟只是个练习嘛,以跑通为目的 :)


实现过程参考了这篇文章

代码Github地址在这里

回到顶部