PHP 使用 Event 事件和 Socket 实现简易的聊天室

1,273 阅读2分钟

由于兼容性、稳定性等方面的问题,libevent 已经不适用于 PHP7,所以我们这里使用 Event 扩展。

根据 PHP 官方文档的说法,Evnet 基于事件的 I/O、时间、信号,有效的安排适用于不同平台的最佳 I/O 通知机制。

要实现聊天室功能,首先需要安装 Event 和 Socket 扩展。Socket 扩展在安装 PHP 时通过在 configure 参数中添加 --enable-sockets 开启;Event 扩展则需要到 pecl 网站下载源码安装包编译安装。

⒈ 使用 Evnet 和 Socket 函数实现单进程的客户端和服务端通信

<?php
    $server = 'tcp://0.0.0.0:8900';

    $socket = stream_socket_server($server, $errno, $errstr);
    stream_set_blocking($socket, false);

    echo '等待客户端连接……' . PHP_EOL;

    function ev_accept($socket, $flag)
    {
        $connection = stream_socket_accept($socket);
        $name = stream_socket_get_name($connection, true);

        while ($connection) {
            $contents = trim(fread($connection, 1024));
            if (strlen($contents)) {
                $info = 'From: ' . $name . PHP_EOL . 'Contents: ' . $contents . PHP_EOL . 'Time: ' . date('Y-m-d H:i:s') . PHP_EOL . PHP_EOL;
                echo $info;
                $response = 'Request: ' . $contents . PHP_EOL . 'Response: OK' . PHP_EOL . 'Time: ' . date('Y-m-d H:i:s') . PHP_EOL;
                stream_socket_sendto($connection, $response);
                //  如果客户端输入为 quit,则断开连接
                if ($contents == 'quit') {
                    echo 'disconnect the client ' . $name . PHP_EOL;
                    stream_socket_sendto($connection, 'close the connection to the server');
                    stream_socket_shutdown($connection, STREAM_SHUT_RDWR);
                }
            }
        }
    }

    $base = new EventBase();

    $event = new Event($base, $socket, EV_READ | EV_PERSIST, 'ev_accept');

    $event->add();

    echo '开始运行……' . PHP_EOL;

    $base->loop();

⒉ 使用 Event 和 EventBufferEvnet 嵌套实现简易聊天室功能

<?php
    $connections = [];

    $server = 'tcp://0.0.0.0:8900';

    $socket = stream_socket_server($server, $errno, $errstr);
    stream_set_blocking($socket, false);

    echo '等待客户端连接……' . PHP_EOL;

    //  发生错误时的回调
    function ev_error($listener)
    {
        $base = $listener->getBase();
        echo '连接错误:[' . EventUtil::getLastSocketErrno() . ']' . EventUtil::getLastSocketError() . PHP_EOL;
        $base->exit();
    }

    //  收到客户端连接时的回调
    function ev_accept($listener, $socket, $address, $ctx)
    {
        global $connections;
        echo '客户端 ' . join(':', $address) . ' 建立连接……' . PHP_EOL;

        $base = $listener->getBase();
        $buffer_event = new EventBufferEvent($base, $socket, EventBufferEvent::OPT_CLOSE_ON_FREE);
        $buffer_event->setCallbacks('cb_read', 'cb_write', 'cb_event');
        $buffer_event->enable(Event::READ | Event::WRITE);

        //  此处必须使用全局变量记录客户端连接,否则客户端会主动断开连接
        $connections[$socket] = [
            'buffer' => $buffer_event,
            'client' => current($address),
            'username' => '',
            'count' => 0,
        ];
    }

    //  可读事件回调
    function cb_read($buffer, $ctx)
    {
        global $connections;
        $key = $buffer->fd;
        $input = trim($buffer->getInput()->pullup(-1));

        if (!$connections[$key]['username'] && $input) {
            $connections[$key]['username'] = $input;
            $buffer->getOutput()->add('输入 quit 退出聊天' . PHP_EOL);
            // 当有新人加入聊天时,通知其他人
            foreach ($connections as $k => $connection) {
                if ($k != $key && $connection['username']) {
                    $connection['buffer']->getOutput()->add('来自 ' . $connections[$key]['client'] . ' 的 ' . $connections[$key]['username'] . ' 加入聊天' . PHP_EOL);
                }
            }
        } elseif (!$connections[$key]['username']) {
            // 如果输入回车,需要重新输入用户名
            $buffer->getOutput()->add('请输入用户名:');
        } elseif ($input == 'quit') {
            //  当用户输入 quit 时,家属聊天,断开连接,并且通知其他人
            if (count($connections) > 1) {
                foreach ($connections  as $k => $connection) {
                    if ($k != $key) {
                        $connection['buffer']->getOutput()->add('来自 ' . $connections[$key]['client'] . ' 的 ' . $connections[$key]['username'] . ' 退出聊天' . PHP_EOL);
                    }
                }
                unset($connections[$key]);
            }
        } elseif ($input) {
            //  将用户输入的信息发送给其他人
            $info = $connections[$key]['username'] . '[' . $connections[$key]['client'] .']:' . $input . PHP_EOL;
            foreach ($connections as $k => $connection) {
                if ($k != $key && $connection['username']) {
                    $connection['buffer']->getOutput()->add($info);
                }
            }
        }

        // 每次读操作完成后,清除缓存中的数据
        $buffer->getInput()->drain($buffer->getInput()->length);
    }

    //  可写事件回调
    function cb_write($buffer, $ctx)
    {
        global $connections;
        $key = $buffer->fd;
        //  客户端建立连接后,首先需要输入用户名
        if (!$connections[$key]['username'] && $connections[$key]['count'] == 0) {
            $buffer->getOutput()->add('请输入用户名:');
            $connections[$key]['count'] = 1;
        }
        // 每次写操作完成后,清除缓存中的数据
        $buffer->getOutput()->drain($buffer->getOutput()->length);
    }

    //  状态发生变化的事件回调
    function cb_event($buffer, $events, $ctx)
    {
        if ($events & EventBufferEvent::ERROR) {
            echo '错误:[' . EventUtil::getLastSocketErrno() . ']' . EventUtil::getLastSocketError() . PHP_EOL;
        }
        if ($events & (EventBufferEvent::EOF | EventBufferEvent::ERROR)) {
            $buffer->free();
        }
    }

    $config = new EventConfig();
    //  使用 epoll 模型服务端有时会抛异常
    $config->avoidMethod('epoll');

    $base = new EventBase($config);

    $listener = new EventListener($base, 'ev_accept', null, EventListener::OPT_CLOSE_ON_FREE | EventListener::OPT_REUSEABLE, -1, $socket);

    $listener->setErrorCallback('ev_error');

    echo '开始运行……' . PHP_EOL;

    $base->dispatch();