PHP Socket 网络编程

前言

在做PHP开发的过程中,大部分我们都在和http协议打交道,在ISO模型里面,http属于应用层协议,它底层会用到TCP协议。http协议非常简单,它是一个文本协议,一个请求对应一个响应,客户端发起一个请求,服务端响应这个请求。http是一个一问一答的对话,每次请求都得重新建立对话(这里暂不讨论Keep-Alive),如果你想通过一个请求进行多次对话,那就是长连接通信,必须使用TCP或者UDP协议。

互联网运行的基石是建立在一些协议上的,目前而言主要是TCP/IP协议族,大部分协议都是公开开放的,计算机遵循这些协议我们才能通信,当然也有一些私有协议,私有协议只有自己知道如何去解析,相当来说更安全,比如QQ所用的协议就是自己定义的。在ISO模型里面,咱们常用的有http、ftp、ssh、dns等,但是不常用的数不胜数,发明一个协议不难,难的是如何设计的更好用,而且大家都喜欢用。

Socket

Socket并不是一个协议,本质上说Socket是对 TCP/IP 协议的封装,它是一组接口,在设计模式中,Socket 其实就是一个门面(facade)模式,它把复杂的 TCP/IP 协议族隐藏在 Socket 接口后面,对用户来说,一组简单的接口就是全部,让 Socket 去组织数据,以符合指定的协议。

下图展示了Socket在ISO模型里面大概位置:

PHP Socket

虽然PHP的强项是处理文本,一般用来写网页和http接口,但是官方依然提供了Socket扩展,编译PHP时在配置中添加–enable-sockets 配置项来启用,如果使用apt或yum安装,默认情况下是已启用。

官方文档里面列出了大概40个函数,但是常用的也就那几个,跟着文档,咱们一起来学学如何使用,首先声明一下,本人对Socket编程并不熟悉,如有错误的地方,希望大家指出来。

咱们先看一幅图,关于TCP客户端和服务端之间的通信过程,咱们平时写http接口的时候并未做这么多工作,那是客户端给封装好了:

1.服务端代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
set_time_limit(0);

$ip = '127.0.0.1';
$port = 8888;

$sock = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);

socket_bind($sock, $ip, $port);
socket_listen($sock, 4);

echo "Server Started, Listen On $ip:$port\n";

$accept = socket_accept($sock);

socket_write($accept, "Hello World!\n", 8192);

$buf = socket_read($accept, 8192);

echo "Receive Msg: " . $buf . "\n";

socket_close($sock);

简单说一下,为便于演示,所以省略了所有的错误处理代码,可以看到分为create、bind、listen、accept、write\read、close这几步,看上去非常简单!具体参数大家可以看一下文档!在服务端启动之后,当收到一个请求之后,我们首先返回了一个Hello World\n,然后又读取了8192个字节的数据,打印出来!最后关闭连接。

由于这里,咱还没有写客户端,所以暂时使用curl访问一下,运行效果如下:

===>服务端:

===>客户端:

从这个例子里面我们可以看出来,curl发出是一个标准的http请求,实际上它的每一行后面是有\n的,在http协议里面,这几行文本其实是头(header),但是在这个例子里面,对于我们来说,它就是一段文本而已,服务端只是把它的内容打印出来了,并没有去按照http协议去解析。虽然我们返回了Hello World!\n,但是这也并没有按照http协议的格式去做,缺少响应头。我只能说curl比较强大,如果使用浏览器访问的话会失败,提示127.0.0.1 sent an invalid response

但是稍加改造,我们就可以返回一个标准的http响应:

1
2
3
4
5
6
7
$response = "HTTP/1.1 200 OK\r\n";
$response .= "Server: Socket-Http\r\n";
$response .= "Content-Type: text/html\r\n";
$response .= "Content-Length: 13\r\n\r\n";
$response .= "Hello World!\n";

socket_write($accept, $response, 8192);

这时候如果再用浏览器访问,就可以看到 Hello World!了,但是这个服务端目前是一次性的,就是说它只能处理一次请求,然后就结束了,正常的服务端是可以处理多次请求的,很简单,加一个死循环就行了!

只贴一下改动的部分,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
while (true) {
$accept = socket_accept($sock);

$buf = socket_read($accept, 8192);

echo "Receive Msg: " . $buf . "\n";

$response = "HTTP/1.1 200 OK\r\n";
$response .= "Server: Socket-Http\r\n";
$response .= "Content-Type: text/html\r\n";
$response .= "Content-Length: 13\r\n\r\n";
$response .= "Hello World!\n";

socket_write($accept, $response, 8192);

socket_close($accept);
}

摇身一变,就是一个http服务了,使用ab测了一下,并发上万,是不是有点小激动?

然而,之所以这么快是因为逻辑简单,假如你在while里面任何位置加一个 sleep(1) 你就会发现,原来这特么是串行的,一个个执行的,并不是并行,这段脚本一次只能处理一个请求!

解决这个问题方法有很多种,具体可以参考 PHP并发IO编程之路, 看看前半段就行了,后半段是广告!该文章总结了3种方法:最早是采用多进程多线程方式,由于进程线程开销大,这种方式效率最低。后来演进出master-worker模型,也就是类似现在fpm采用的方式。目前最先进的方式就是异步io多路复用,基于epoll实现的。理论上讲C能实现的,PHP都能通过扩展去实现,而且PHP确实提供了相关扩展,其思想和C写的都差不多,然而今天咱不是说高并发编程的,还是接着说Socket吧!

2.客户端代码

之前的例子里面我们使用的是curl访问的,也可以使用浏览器或者telnet,这些工具都可以算作是客户端,客户端也可以自己实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
set_time_limit(0);

$port = 8888;
$ip = '127.0.0.1';

$sock = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);

echo "Connecting $ip:$port\n";

socket_connect($sock, $ip, $port);

$input = "Hello World Socket";

socket_write($sock, $input, strlen($input));

$out = socket_read($sock, 8192);

echo "Receive Msg: $out\n";

socket_close($sock);

这段代码同样省略了错误处理代码,可以看到第一步都是create,但是第二步变成connect,然后是read\write、最后close。

具体运行效果这里不再展示,和curl访问没多大区别,但是这个客户端也是一次性的,执行完了就结束!

实例

接下来,我们来写一个基于TCP通信的应用,这个应用非常简单,就是加减乘除!

(1)服务端代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<?php
set_time_limit(0);

$ip = '127.0.0.1';
$port = 8888;

$sock = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);

socket_bind($sock, $ip, $port);

socket_listen($sock, 4);

echo "Server Started, Listen On $ip:$port\n";

while (true) {
$accept = socket_accept($sock);

$buf = socket_read($accept, 8192);

echo "Receive Msg: " . $buf . "\n";

$params = json_decode($buf, true);
$m = $params['m'];
$a = $params['a'];
$b = $params['b'];

switch ($m) {
case '+';
$response = $a + $b;
break;
case '-';
$response = $a - $b;
break;
case '*';
$response = $a * $b;
break;
case '/';
$response = $a / $b;
break;
default:
$response = $a + $b;
}

socket_write($accept, $response."\n", 8192);

socket_close($accept);
}

(2)客户端代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php
set_time_limit(0);

$port = 8888;
$ip = '127.0.0.1';

$sock = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);

echo "Connecting $ip:$port\n";

socket_connect($sock, $ip, $port);

$input = json_encode([
'a' => 15,
'b' => 10,
'm' => '+'
]);

socket_write($sock, $input, strlen($input));

$out = socket_read($sock, 8192);

echo "Receive Msg: $out\n";

socket_close($sock);

在这些代码里面,我按照自己的需求定义了一个“协议”,我把需要运算的数和方式通过一个json数组传输,约定了一个格式,这个协议只有我自己清楚,所以只有我才知道怎么调用。服务端在接受到参数之后,通过运算得出结果,然后把结果返回给客户端。

但是这个例子还有问题,客户端依然是一次性的,参数都被硬编码在代码里面,不够灵活,最关键是没有用到TCP长连接的特性,我们每次计算都得重新发起请求、重新建立连接,实际上,我需要的是一次连接,多次对话,也就是进行多次计算!

目前为止,这些演示代码都没有复用连接,因为在服务端最后我close了这个连接,这意味着每次都是一个新的请求,如果是http服务的话尚且可以用一下,如何去实现一个TCP长连接呢?

IO多路复用之select

select系统调用的目的是在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写和异常事件,虽然这个方式也比较低效,但是不妨了解一下,通过这种方式我们可以复用连接,完整的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
<?php
set_time_limit(0);

$ip = '127.0.0.1';
$port = 8888;

$sock = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);

socket_bind($sock, $ip, $port);

socket_listen($sock, 4);

echo "Server Started, Listen On $ip:$port\n";

socket_set_nonblock($sock);

$clients = [];

while (true) {
$rs = array_merge([$sock], $clients);
$ws = [];
$es = [];

//监听文件描述符变动
$ready = socket_select($rs, $ws, $es, 3);
if (!$ready) {
continue;
}

if (in_array($sock, $rs)) {
$clients[] = socket_accept($sock);
$key = array_search($sock, $rs);
unset($rs[$key]);
}

foreach ($rs as $client) {
$input = socket_read($client, 8096);
if ($input == null) {
$key = array_search($client, $clients);
unset($clients[$key]);
continue;
}
echo "input: " . $input;

//解析参数,计算结果
preg_match("/(\d+)(\W)(\d+)/", $input, $params);
if (count($params) === 4) {
$a = intval($params[1]);
$b = intval($params[3]);
$m = $params[2];
} else {
continue;
}

switch ($m) {
case '+';
$result = $a + $b;
break;
case '-';
$result = $a - $b;
break;
case '*';
$result = $a * $b;
break;
case '/';
$result = $a / $b;
break;
default:
$result = $a + $b;
}

$output = "output: $result\n";
echo $output;
socket_write($client, $output, strlen($output));
}
}

然后我使用了telnet连接服务端进行操作,运行效果如下,一个基于TCP长连接的网络版简易计算器:

在这个例子,传参的“协议”稍微有点变化,只是为了更方便在telnet里面交互,但是很容易理解。这里面最关键是定义了一个全局变量用来存储连接资源描述符,然后通过select去监听变化,最后遍历整个数组,读取\写入数据!

总结

通过上面的简单介绍,希望大家都对PHP Socket编程有一些了解和认识,其实作为Web开发来说,很少会用到裸TCP去连接,大部分时候都是使用基于TCP的http协议,只有涉及到一些对响应速度要求非常高的应用,比如说游戏、实时通信、物联网才会用到,如果真的用到,不妨尝试一下Workman、Swoole这些成熟的框架!