web 开发一直是 PHP 的主战场, 也是 PHP 最为被世人所熟知的一面. 其实只要你愿意去发掘, PHP 除了做网页在许多其他方面也是小能手.
本文简要介绍 PHP 的 Socket 编程.
准备知识
在开始之前, 希望你已经知道网络编程中的一些基本概念. 比如 OSI 七层模型, TCP/IP 四层模型; TCP 中的三次握手, 四次挥手等. 这些概念是网络编程的理论基础, 实践中不一定用得到, 但能让你把握整体脉络, 更快的定位编程中出现的问题.
再说一下 Socket. 我们常说的网络编程就是指 Socket 编程, 它既指代实现了 TCP/IP 协议簇的一套网络编程 API, 也指代一个客户端与服务器的连接. socket 是插座 / 接口的意思, 计算机中常翻译成 "套接字". 实际中可以简单的认为网络编程与 Socket 编程等价, 一个 tcp 连接的说法等价于一个 socket.
PHP 中的 API
PHP 中有以 socket 开头的一套函数 API 用于 Socket 编程, PHP5 引入 "流" 的抽象概念后, 以 stream 开头的一套 API 也可以用于网络编程. 两者的主要区别是:
流是 PHP 中的核心概念, 所以 stream 开头的函数总是可用; sockets 是 PHP 的一个拓展, 虽然大部分情况下都默认启用;
socket 系列函数相对底层, 而 stream 系列函数是高层的抽象.
如果你想体验原味 Socket 编程, 用 socket 开头的 API 比较适合; 否则建议使用流函数. 有关流的知识, 请参考本人之前的博文: PHP 回顾之流 https://tlanyan.me/php-review-stream/ .
接下来我们用流函数实现一个简单的 TCP 客户端和服务端.
客户端
客户端网络编程可以归结为简单的三步:
连接服务端(connect);
收发消息(receive/send);
关闭连接(close).
下面是客户端的代码, 发送 10 条消息到服务端:
- // client.php
- $host = "127.0.0.1";
- $port = 8000;
- $socket = @stream_socket_client("tcp://{$host}:{$port}", $errno, $errMsg);
- if ($socket === false) {
- throw new \RuntimeException("unable to create socket:" . $errMsg);
- }
- fwrite(STDOUT, "success connect to server: [{$host}:{$port}]...\n");
- foreach (range(1, 10) as $i) {
- if ($i % 5 === 0) {
- $method = "broadcast";
- } else {
- $method = "echo";
- }
- $args = [sprintf("The %dth greeting", $i)];
- $message = json_encode([
- "method" => $method,
- "args" => $args,
- ]);
- fwrite(STDOUT, "\nsend to server: $message\n");
- $len = @fwrite($socket, $message);
- if ($len === 0) {
- fwrite(STDOUT, "socket closed\n");
- break;
- }
- $msg = @fread($socket, 4096);
- if ($msg) {
- fwrite(STDOUT, "receive server: $msg\n");
- }
- elseif (feof($socket)) {
- fwrite(STDOUT, "socket closed\n");
- break;
- }
- sleep(2);
- }
- fwrite(STDOUT, "close connnection...\n");
- fclose($socket);
客户端已经搞定, 接下来看服务端.
服务端
服务端编程也很简单, 四步搞定:
监听端口(listen);
接受新连接(accept);
收发网络消息(receive/send);
循环第二步和第三步(loop).
由于服务端一般是长时间运行, 除非重启或进程被杀死, 极少会主动关闭服务. 另外服务端一般需要长时间运行, 所以应当运行在 CLI 模式下(短连的客户端代码可以在 web 中使用, 例如代替 CURL 获取网页内容, 连接 redis/MQ 等).
我们简单的将收到的消息返回客户端(Echo 服务器):
- // server.php
- $port = 8000;
- $socket = @stream_socket_server("tcp://0.0.0.0:$port", $errno, $errMsg);
- if ($socket === false) {
- throw new \RuntimeException("fail to listen on port: {$port}!");
- }
- fwrite(STDOUT, "socket server listen on port: {$port}" . PHP_EOL);
- while (true) {
- $client = @stream_socket_accept($socket);
- if ($client == false) {
- continue;
- }
- fwrite(STDOUT, "client:" . (int)$client . "connnected.\n");
- @fwrite($client, "Welcome aboard!\n");
- while (true) {
- $msg = @fread($client, 4096);
- if ($msg) {
- fwrite(STDOUT, "\nreceive client: $msg\n");
- // echo
- @fwrite($client, $msg);
- } elseif (feof($client)) {
- fwrite(STDOUT, "client:" . (int)$client . "disconnnect!\n");
- fclose($client);
- break;
- }
- }
- }
先启动服务端脚本: php server.php, 然后打开新的窗口启动客户端: php client.php. 可以看到消息被正确的发送和接收. 客户端退出后, 可多次重新运行客户端脚本查看效果.
并发
同时运行两个或以上客户端, 会发现第二个起卡住, 前面的客户端退出后才继续运行. 回顾服务端代码, 可以看到 accept 一个客户端后, 服务端就专心为其服务, 直到断开才服务下一个.
同时服务多个客户端, 这才是我们期望的. 默认情况下 socket 处于阻塞模式, 无数据时 fread 函数会一直等待, 导致程序不能抽身服务其他客户端. 要同时服务多个客户端, 第一步是设置非阻塞模式, 第二步是更改轮询方式. 流函数中的
stream_set_blocking
和 stream_select 两个函数是我们想要的.
将服务端的代码更改如下:
- // server.php
- <?php
- $port = 8000;
- $socket = @stream_socket_server("tcp://0.0.0.0:$port", $errno, $errMsg);
- if ($socket === false) {
- throw new \RuntimeException("fail to listen on port: {$port}!");
- }
- fwrite(STDOUT, "socket server listen on port: {$port}" . PHP_EOL);
- stream_set_blocking($socket, false);
- $clients = [];
- $changed = [];
- while (true) {
- checkMessage();
- fwrite(STDOUT, "\nnew read message\n");
- accept();
- handleMessage();
- }
- function checkMessage() {
- global $socket, $changed, $clients;
- $changed = array_merge([$socket], $clients);
- $write = null;
- $except = null;
- stream_select($changed, $write, $except, null);
- }
- function accept() {
- global $socket, $changed, $clients;
- if (!in_array($socket, $changed)) {
- return;
- }
- while ($client = @stream_socket_accept($socket, 0)) {
- $clients[] = $client;
- fwrite(STDOUT, "client:" . (int)$client . "connnected.\n");
- fwrite($client, "welcome aboard!");
- stream_set_blocking($client, false);
- $key = array_search($client, $changed);
- unset($changed[$key]);
- }
- }
- function handleMessage() {
- global $changed, $clients;
- foreach ($changed as $key => $client) {
- while (true) {
- $msg = @fread($client, 4096);
- if ($msg) {
- fwrite(STDOUT, "receive client" . (int)$client . "message: $msg\n");
- $json = json_decode($msg, true);
- if ($json) {
- $method = $json["method"];
- if ($method === 'echo') {
- @fwrite($client, $msg);
- } else {
- foreach ($clients as $cl) {
- @fwrite($cl, "message from" . (int)$client . ": $msg");
- }
- }
- }
- } else {
- if (feof($client)) {
- fwrite(STDOUT, "\nclient" . (int)$client . "closed.\n");
- fclose($client);
- $key = array_search($client, $clients);
- unset($clients[$key]);
- }
- break;
- }
- }
- }
- }
然后启动服务端: php server.php, 再同时启动多个客户端, 或者用多个进程同时发送消息(需安装 pcntl 拓展):
- // client.php
- for ($index = 0; $index < 10; ++ $index) {
- $pid = pcntl_fork();
- if ($pid < 0) {
- fwrite(STDERR, "fail to fork!\n");
- exit;
- }
- if ($pid === 0) {
- connectServer(); // connectServer 就是上文中 client.php 中的代码
- exit;
- }
- }
- // 父进程先退出, 不会出现僵尸进程, 忽略孤儿进程的处理
启动客户端后, 可以看到服务端正确的同时处理多个客户端, 这正是我们期待的.
缺憾
上述代码实现了客户端和可并发的服务端, 作为演示基本够用. 如果要投入到实践中使用, 至少有以下方面的不足:
多进程 / 多线程 / 协程缺失, 除处理网络消息外, 不能 (难) 做其他逻辑业务;
没有协议解析, 会导致多条信息合并成一条读取(或者一条信息被拆成多条);
select 低效且有并发连接数目限制, 客户端量大时需要 poll/epoll 等技术;
每个方面展开来说至少都是一篇长文. 本文目的是简要介绍 PHP 中的 Socket 编程, 行文到此已经达到目的. 由于网络协议十分繁杂, 想深入网络编程请参阅更多权威文档.
总结
本文基于 PHP5 引入的流简要介绍了 PHP 中的 Socket 编程, 并给出了一个简单并发服务器的实现. 文中代码仅做演示用, 在生产环境中, 请使用成熟的网络框架 / 库.
参考
- http://php.net/manual/en/book.sockets.php
- http://www.unixguide.net/network/socketfaq/
- http://php.net/manual/en/book.stream.php
来源: https://juejin.im/post/5b2dc6aae51d4558c5393463