第一阶段:PHP-Socket初学理论
1、你会使用Socket吗?
在WEB开发中,前辈们已经给我们铺了很长的路,让网络间的通信处理简单了许多。
以前同学们听到Socket编程、长连接、TCP/UDP协议等,可能就会觉得这些都是比较高深的编程知识,
但其实只要弄懂其中的工作原理,这些神秘的面纱就会慢慢揭开,编程逻辑就清晰了。
一个生活中的场景可以简单解释Socket的工作流程:
你要打电话给一个朋友,先拨号,朋友听到电话铃声后提起电话,
这时你和你的朋友就建立起了连接,就可以通信了。
等交流结束,任意一方挂断电话,又或者双方同时挂断,既代表结束此次交谈。
也许当初TCP/IP协议,就是按照这个场景来设计的呢。
2、PHP的Socket编程概述
从PHP5.3+后,PHP自带了Socket模块,使得P0HP具备了Socket通信能力,
具体函数说明可以参考官方手册:http://php.net/manual/zh/function.socket-create.php
具体实现跟C语言非常类似,只是少了内存分配和网络字节序转换这种底层操作。
同时,php的pcntl模块和posix模块配合可以实现基本的进程管理、信号处理等操作系统级别的功能。
这里有两个非常关键的函数,pcntl_fork() 和 posix_setsid()。
fork()一个进程,则表示创建了一个运行进程的副本,副本被认为是子进程,而原始进程被认为是父进程。
当fork()运行之后,则可以脱离启动它的进程和终端控制等,也意味着父进程可以自由退出。
pcntl_fork()返回值,-1表示执行失败,0表示在子进程中,大于0表示在父进程中。
setsit(),它首先使新进程成为一个新会话的“领导者”,最后使进程不再控制终端。
这也是成为守护进程最关键一步,这意味着,不会随着终端关闭而强制退出进程。
对于一个不会被中断的常驻进程来说,这是很关键的一步。
最后再进行一次fork(),这一步不是必须的,但通常都会这么做,它最大的意义是防止获得控制终端。
3、什么是进程?
一个正在执行的程序。操作系统中正在运行的程序的一个实例。可以分配给处理器并由处理器执行的一个实体。操作系统中,执行任务的一个唯一标记。
4、Socket和进程的关系
PHP本身是单进程处理请求,而单进程一般只作用于短连接或HTTP协议的WEB开发中,
因为Socket长连接需要一直进程一直在服务器后台打开,所以无法与HTTP协议共用一个进程。
所以Socket开发都需要打开一个新的进程,并让其进入守护进程模式。
如果不让进程进入守护模式,在关闭CMD操作界面,又或者是页面时,该进程就会被操作系统杀死,
无法进入后台继续运行,这样Socket就会失效了。
5、什么是守护进程?
一个守护进程通常被认为是一个不对终端进行控制的后台任务。
它有三个很明显的特征:
在后台运行与启动他的进程脱离无须终端控制
最常见的实现方法:fork() -> setsid() -> ork()
6、PHP-Scoket的开发步骤
第二阶段:PHP-Socket编程
7、PHP与Socket相关的函数大全
下面我们先来认识下,原生PHP中,与Socket相关的函数都有哪些:
socket_accept() 接受一个Socket连接socket_bind() 把socket绑定在一个IP地址和端口上socket_clear_error() 清除socket的错误或者最后的错误代码socket_close() 关闭一个socket资源socket_connect() 开始一个socket连接socket_create_listen() 在指定端口打开一个socket监听socket_create_pair() 产生一对没有区别的socket到一个数组里socket_create() 产生一个socket,相当于产生一个socket的数据结构socket_get_option() 获取socket选项socket_getpeername() 获取远程类似主机的ip地址socket_getsockname() 获取本地socket的ip地址socket_iovec_add() 添加一个新的向量到一个分散/聚合的数组socket_iovec_alloc() 这个函数创建一个能够发送接收读写的iovec数据结构socket_iovec_delete() 删除一个已经分配的iovecsocket_iovec_fetch() 返回指定的iovec资源的数据socket_iovec_free() 释放一个iovec资源socket_iovec_set() 设置iovec的数据新值socket_last_error() 获取当前socket的最后错误代码socket_listen() 监听由指定socket的所有连接socket_read() 读取指定长度的数据socket_readv() 读取从分散/聚合数组过来的数据socket_recv() 从socket里结束数据到缓存socket_recvfrom() 接受数据从指定的socket,如果没有指定则默认当前socketsocket_recvmsg() 从iovec里接受消息socket_select() 多路选择socket_send() 这个函数发送数据到已连接的socketsocket_sendmsg() 发送消息到socketsocket_sendto() 发送消息到指定地址的socketsocket_set_block() 在socket里设置为块模式socket_set_nonblock() socket里设置为非块模式socket_set_option() 设置socket选项socket_shutdown() 这个函数允许你关闭读、写、或者指定的socketsocket_strerror() 返回指定错误号的详细错误socket_write() 写数据到socket缓存socket_writev() 写数据到分散/聚合数组
8、简单的Socket通讯演示
Socket编程除了需要编写服务端的代码外,还需要编写客户端的代码,下面我们运用Linux服务器,
来编写一个连接Socket服务器的简单案例:
1、我们先在服务器的/var/www/下,随便挑个目录新建一个server.php文件,充当于服务端代码:
注意代码里涉及到服务器开放端口,如果你是用的阿里云服务器,请到服务器对应的安全组中开启端口号。
在Linux中,输入netstat -lntp可以查看已经被使用的端口号,如果你的端口已经被占用,就应该更换一个未被占用的。
<?php/*+-------------------------------* @socket通信整个过程+-------------------------------* @socket_create 生成一个socket连接* @socket_bind 把socket绑定在一个IP地址和端口上* @socket_listen 监听由指定socket的所有连接* @socket_accept 接受一个Socket连接* @socket_read 读取指定长度的数据* @socket_write 写入数据到socket缓存* @socket_close 关闭一个socket资源+--------------------------------*/# 设置为永久执行,直到程序执行完成set_time_limit(0);# 你的服务器(私有)IP地址$ip = '';# 填写一个端口,作为你的Socket的传输端口$port = ;# ① 生成一个socket连接 - 具体参数参考官方手册if (($socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP)) < 0) {echo "socket_create() 失败的原因是:".socket_strerror($sock)."\n";}# ② 把socket绑定在一个IP地址和端口上if (($ret = socket_bind($socket, $ip, $port)) < 0) {echo "socket_bind() 失败的原因是:".socket_strerror($ret)."\n";}# ③ 监听由指定socket的所有连接if (($ret = socket_listen($socket, 4)) < 0) {echo "socket_listen() 失败的原因是:".socket_strerror($ret)."\n";}# 历史Socket请求数,一般用于清除缓存$count = 0;# 死循环一直执行程序do {# 接收一个Socket连接请求if (($msgsock = socket_accept($socket)) < 0) {echo "socket_accept() failed: reason: " . socket_strerror($msgsock) . "\n";break;} else {# 握手成功# 最多只接收5个请求,然后就关闭连接if($count > 5){break;};# 发送内容给客户端$msg ="测试成功!\n";socket_write($msgsock, $msg, strlen($msg));echo "测试成功了啊\n";$buf = socket_read($msgsock, 8192);$talkback = "收到的信息:$buf\n";echo $talkback;$count++;}socket_close($msgsock);} while (true);socket_close($socket);
2、我们再在服务器的/var/www/下,随便挑个目录新建一个client.php文件,充当于客户端代码:
<?php/*+-------------------------------* @socket通信整个过程+-------------------------------* @socket_create 生成一个socket连接* @socket_connect 开始请求一个socket连接* @socket_write 写入数据到socket缓存* @socket_read 读取指定长度的数据* @socket_close 关闭一个socket资源+--------------------------------*/# 设置为永久执行,直到程序执行完成set_time_limit(0);# 你的服务器(私有)IP地址$ip = '';# 填写一个端口,作为你的Socket的传输端口$port = ;error_reporting(E_ALL);# ① 生成一个socket连接 - 具体参数参考官方手册if (($socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP)) < 0) {die ("socket_create() 失败的原因是:".socket_strerror($sock)."\n");} else {echo "OK.\n";}echo "试图连接 '$ip' 端口 '$port'...\n";# ② 开始请求一个socket连接if (($result = socket_connect($socket, $ip, $port)) < 0) {die("socket_connect() failed.\nReason: ($result) " . socket_strerror($result) . "\n");} else {echo "连接成功\n";}$str = "小黄牛!\r\n";$str .= "你真帅!\r\n";$content = '';# ③ 发送内容到服务端if (!socket_write($socket, $str, strlen($str))) {die("socket_write() failed: reason: " . socket_strerror($socket) . "\n");} else {echo "发送到服务器信息成功!\n";echo "发送的内容为:$str\n";}# ④ 读取服务器回复给我们的信息while ($content = socket_read($socket, 8192)) {echo "接收服务器回传信息成功!\n";echo "接受的内容为:".$content."\n";}echo "关闭SOCKET...\n";socket_close($socket);echo "关闭OK\n";
3、下面打开我们的Xshell,连接上服务器,不过这里有一个到注意的点,就是你要用Xshell打开两个标签,
一个先输入命令php /var/www/demo/server.php,启动服务端Socket端口,而且这个标签不能关闭,要一直开着,因为现在我们还没为这个端口开启守护进程,端口关闭,Socket就关了。
运行之后,可以看到下面的效果:
4、这时候我们再到第二个标签中,输入命令php /var/www/demo/client.php,执行客户端代码,向服务端发送Socket请求,运行之后,可以看到下面的效果:
会发现,信息发送成功啦!
5、最后我们再切换回第一个标签,发现服务端也成功接收到请求了:
9、服务端广播消息
可能有的同学会有疑问,如何Socket只是单纯的一对一用于接收客户端的请求消息,那不是很没用?
实际上,Socket除了可以一对一接收请求之外,他还可以操控同一个通道(端口)内的所有客户端成员的身份凭证,
例如:对所有成员推送消息、强制中断其中一个成员的Socket连接等。
在实际开发中,PHP使用原生Socket组件作为服务端通讯驱动就已经很勉强了,需要使用到死循环才能让Socket一直保持开启的状态。
而如果也使用PHP+Socket来充当客户端代码是很不现实的,因为我们没办法让客户端进入死循环,那么Socket就没办法长时间的开启,等待服务端推送消息给我们。
在Web网页中,JS组件提供了一个WebSocket类,它主要用于代替后端Socket进行客户端的通讯操作,极其方便,不过该JS类必须要在IE9+以上的浏览器中才有用,低版本的浏览器是用不了的。
1、下面我们先来封装一个服务端操作的class类,命名为:SocketServer.php:
<?phpclass SocketServer {/*** Socket实例*/private $socket;/*** 保存读的套接字*/private $readGroup = [];/*** 保存写的套接字*/private $writeGroup = [];/*** tcp协议中用于加密的字符串*/private $mcrypt_key = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';private $test = false;/*** 初始化实例* @param String $host ip地址* @param int $port 端口* @param int $backlog 最大连接数*/public function __construct($host = '你的IP地址', $port = '你的端口号', $backlog = 100){$this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP) or die('socket创建失败');socket_bind($this->socket, $host,$port);socket_listen($this->socket, $backlog);$this->readGroup[] = $this->socket;}/*** 死循环让Socket持续保活*/public function start() {while (true) {# 下面这3个参数,必须要先由参数赋值,因为从php5.3+后开始# socket_select的参数变成了一个引用,虽然没办法直接输入null或使用成员属性$socketArr = $this->readGroup;$writeGroup= $this->writeGroup;$e = NULL;# 阻塞,直到捕获到变化if (false !== socket_select($socketArr, $writeGroup, $e, null)) {# 遍历读的套接数组foreach ($socketArr as $socket) {# 如果是当前服务器的监听连接if($this->socket == $socket) {# 接收一个Socket连接$client = socket_accept($this->socket);# 添加客户端套接字$this->add_client($client);} else {# 获取客户端发送来的信息$msg = socket_read($socket, 1024);/*如果检测到客户端发送的是握手协议,则向客户端发送握手协议ps:这样检测虽然不严谨,但能应对大部分客户端掉线,非正常关闭的情况,将就下*/if(empty($msg)) {continue;} else if (preg_match('/Sec-WebSocket-Key: (.*)\r\n/', $msg, $matches)) {# 握手请求$upgrade = $this->createHandShake($matches[1]);socket_write($socket, $upgrade, strlen($upgrade));} else {# 接收到了客户端传递过来的Socket消息# 解码客户端发送的消息$client_info = $this->decodeMsg($msg);# 向其他客户端进行广播$this->send_to_other($client_info,$socket);}}}}}}/*** 保存一个客户端的套接字* @param resource $client 客户端的套接字*/private function add_client($client) {$this->readGroup[] = $client;}/*** 发送内容到指定客户端* @param resource $socket 客户端套接字* @param String $data 要发送的消息*/private function send_to_one($socket, $data) {# 先对信息进行编码处理$data = $this->encodeMsg($data);socket_write($socket, $data);}/*** 使用循环语句,将内容广播到所有客户端* @param String $data 要发送的消息*/private function send_to_all($data) {$writeGroup = $this->readGroup;# 最开始的Socket链接,肯定是服务器自身,所以我们需要先移除unset($writeGroup[0]);# 处理内容编码$data = $this->encodeMsg($data);# 循环广播foreach ($writeGroup as $socket) {socket_write($socket, $data);}}/*** 广播至除发送消息外的客户端* @param String $data* @param resource $client*/private function send_to_other($data, $client) {$writeGroup = $this->readGroup;# 最开始的Socket链接,肯定是服务器自身,所以我们需要先移除unset($writeGroup[0]);# 处理内容编码$data = $this->encodeMsg($data);# 循环广播foreach ($writeGroup as $socket) {# 如果是客户端自己,则移除if ($socket != $client) {socket_write($socket, $data);}}}/*** 计算返回客户端的握手协议* @param String $msg 客户端发送过来的协议* @return String $upgrade 返回给客户端的协议*/private function createHandShake($client_key) {$key = base64_encode(sha1($client_key.$this->mcrypt_key, true));$upgrade = "HTTP/1.1 101 Switching Protocol\r\n" ."Upgrade: websocket\r\n" ."Connection: Upgrade\r\n" ."Sec-WebSocket-Accept: " . $key . "\r\n\r\n"; // 结尾一定要两个\r\n\r\nreturn $upgrade;}/*** 解码客户端发送过来的信息* @param binary $buffer 客户端传来的信息* @return String $decoded 解码后的字符串*/private function decodeMsg($buffer) {$len = $masks = $data = $decoded = null;$len = ord($buffer[1]) & 127;if ($len === 126) {$masks = substr($buffer, 4, 4);$data = substr($buffer, 8);} else if ($len === 127) {$masks = substr($buffer, 10, 4);$data = substr($buffer, 14);} else {$masks = substr($buffer, 2, 4);$data = substr($buffer, 6);}for ($index = 0; $index < strlen($data); $index++) {$decoded .= $data[$index] ^ $masks[$index % 4];}return $decoded;}/*** 发送到客户端前进行编码* @param string $msg 发送到客户端的内容*/private function encodeMsg($msg) {$head = str_split($msg, 125);if (count($head) == 1) {return "\x81" . chr(strlen($head[0])) . $head[0];}$info = "";foreach ($head as $value) {$info .= "\x81" . chr(strlen($value)) . $value;}return $info;}}
然后再新建一个start.php文件,这个文件是实例化Server类的,用于开启服务端Socket:
<?php# 引入Socket封装类require 'SocketServer.php';$socketServer = new SocketServer();$socketServer->start();
这两个文件新建完成后,我们就可以使用linux命令,跟之前的案例一样,先触发这个index.php文件,让服务端启动保活。
最后再新建client.html文件,编写WebSocket代码,代替PHP充当客户端:
<!DOCTYPE html><html><meta http-equiv="Content-type" content="text/html;charset=utf-8"><head><title>客户端</title><script src="https://www.junphp.com/api/kefu/js/JQuery v1.7.2.js" type="text/javascript"></script><script>// 打开一个Socket连接var ws = new WebSocket('wss://blog.junphp.com/wss/');ws.onopen = function(){console.log("握手成功,打开socket连接了。。。");};// 接收服务端广播的消息通知ws.onmessage = function(e){var template = $("#other_template").html();var temp = $(template);temp.find(".info").children("span").text(e.data);$("#main").append(temp);};// 接收Socket断开时的消息通知ws.onclose = function(){console.log("断开socket连接了。。。");};// 接收Socket连接失败时的异常通知ws.onerror = function(e){console.log("ERROR:" + e.data);};// 向服务端发送消息sendMessage = function(){var data = $("#msg").val();ws.send(data);$("#msg").val("");//根据模板生成自己消息视图var template = $("#self_template").html();var temp =$(template);temp.find(".info").children("span").text(data);$("#main").append(temp);}</script><!--系统广播的内容--><script id="other_template" type="text/template"><div class="show_box"><div class="user box-left">别人说的话:</div><div class="info info_other box-left"><span></span><div class="angle-left info_other"></div></div></div></script><!--发送方内容--><script id="self_template" type="text/template"><div class="show_box"><div class="user box-right">我的发送的:</div><div class="info info_self box-right"><span></span><div class="angle-right info_self"></div></div></div></script></head><body><div id="client"><div id="head"></div><div id="main"></div><div id="msg_box"><input id="msg" type="text" placeholder="随便说两句吧..."><button id="send">发送</button></div></div><script>// 点击发送内容$("#send").click(function(){sendMessage();});// 回车发送内容$("#msg").on("keypress",function(e){if(e.keyCode == 13){sendMessage();}});</script></body>
这时候我们用两个浏览器,记住是两个浏览器又不是两个标签,访问这个client.html文件,然后再里面输入内容进行调试,就能发现两边都能够进行简单聊天了。
如果你的服务器是nginx的驱动的环境,就需要进行一下反向代理配置,具体看老师的博客:https://blog.junphp.com/details/131.jsp。
如果是nginx,又是https的域名,就要看这篇了:https://blog.junphp.com/details/132.jsp。
10、简单了解一下 WebSocket
现在,很多网站为了实现推送技术,所用的技术都是轮询。
轮询是在特定的的时间间隔(如每1秒),由浏览器对服务器发出HTTP请求,然后由服务器返回最新的数据给客户端的浏览器。
这种传统的模式带来很明显的缺点,即浏览器需要不断的向服务器发出请求,
然而HTTP请求可能包含较长的头部,其中真正有效的数据可能只是很小的一部分,显然这样会浪费很多的带宽等资源。
在这种情况下,HTML5定义了WebSocket协议,能更好的节省服务器资源和带宽,并且能够更实时地进行通讯。
WebSocket是一种在单个 TCP 连接上进行全双工通讯的协议。
使得客户端和服务器之间的数据交互变得更加简单,允许服务端主动向客户端推送数据。
在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
简单点说,WebSocket 就是减小客户端与服务器端建立连接的次数,减小系统资源开销,只需要一次 HTTP 握手,
整个通讯过程是建立在一次连接/状态中,也就避免了HTTP的非状态性,服务端会一直与客户端保持连接,直到你关闭请求,
同时由原本的客户端主动询问,转换为服务器有信息的时候推送。
当然,它还能做实时通信、更好的二进制支持、支持扩展、更好的压缩效果等这些优点。
11、ws 和 wss 又是什么鬼?
Websocket使用 ws 或 wss 的统一资源标志符,类似于 HTTP 或 HTTPS ,
其中 wss 表示在 TLS 之上的 Websocket ,相当于 HTTPS 了。
默认情况下,Websocket 的 ws 协议使用 80 端口;
运行在TLS之上时,wss 协议默认使用 443 端口。
其实说白了,wss 就是 ws 基于 SSL 的安全传输,与 HTTPS 一样的道理。
12、如何开启Socket守护进程
在实际开发中,我们不可能时时挂着Xshell打开服务端进程的,而如果我们关闭Xshell,那么就表示php的死循环关闭了,进程就会自动销毁,这时候客户端的Socket就会自动断开。
而Linux给我们提供了一种应对这种情况下,非常好用的解决方案,就是在服务端在开启Socket进程之前,先设置一个父进程,并将其设置为守护进程,最后再设置了一个子进程挂载Socket端口,这样就算我们关闭Xshell,这个进程也会一直在后台保活,就不会自动关闭。
这也是Socket编程最关键的一步。
下面我以:8、简单的Socket通讯演示中的代码为例,将其中server.php文件的代码修改为以下:
<?php/*+-------------------------------* @socket通信整个过程+-------------------------------* @socket_create 生成一个socket连接* @socket_bind 把socket绑定在一个IP地址和端口上* @socket_listen 监听由指定socket的所有连接* @socket_accept 接受一个Socket连接* @socket_read 读取指定长度的数据* @socket_write 写入数据到socket缓存* @socket_close 关闭一个socket资源+--------------------------------*/# 设置为永久执行,直到程序执行完成set_time_limit(0);/*** 假设,这是你服务端启动程序*/function server() {# 你的服务器(私有)IP地址$ip = '172.18.77.92';# 填写一个端口,作为你的Socket的传输端口$port = 1935;# ① 生成一个socket连接 - 具体参数参考官方手册if (($socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP)) < 0) {echo "socket_create() 失败的原因是:".socket_strerror($sock)."\n";}# ② 把socket绑定在一个IP地址和端口上if (($ret = socket_bind($socket, $ip, $port)) < 0) {echo "socket_bind() 失败的原因是:".socket_strerror($ret)."\n";}# ③ 监听由指定socket的所有连接if (($ret = socket_listen($socket, 4)) < 0) {echo "socket_listen() 失败的原因是:".socket_strerror($ret)."\n";}# 历史Socket请求数,一般用于清除缓存$count = 0;# 死循环一直执行程序do {# 接收一个Socket连接请求if (($msgsock = socket_accept($socket)) < 0) {echo "socket_accept() failed: reason: " . socket_strerror($msgsock) . "\n";break;} else {# 握手成功# 最多只接收5个请求,然后就关闭连接if($count > 5){break;};# 发送内容给客户端$msg ="测试成功!\n";socket_write($msgsock, $msg, strlen($msg));echo "测试成功了啊\n";$buf = socket_read($msgsock, 8192);$talkback = "收到的信息:$buf\n";echo $talkback;$count++;}socket_close($msgsock);} while (true);socket_close($socket);}/*** 这是开启守护进程的程序*/function run_server () {# ① fork 一个子进程号$pid1 = pcntl_fork();# 为0,表示在子进程执行线程内if ($pid1 == 0) {# ② setsid 将当前进程转换为守护进程posix_setsid();# ③ fork 再新建一个子进程号,就会挂载在父守护进程中$pid2 = pcntl_fork();if ($pid2 == 0) {server();} else {# 防止获得控制终端exit;}} else {# 挂起父进程,防止出现僵尸进程pcntl_wait($status);}}# 启动服务端Socket,同时开启守护进程run_server();
然后,我们关闭所有Xshell,然后重新打开一个Xshell标签,
先netstat -lntp一遍端口号,看看有没有被占用,
有的话则使用kill -9 端口对应的PID结束进程。
然后再使用php /var/www/demo/server.php,启动服务端Socket程序,并开启守护进程。
这时候你会发现,跟之前的启动不一样,这一次我们的Xshell并没有进入循环等待,而是退回到正常状态了:
这时候我们直接在Xshell里输入php /var/www/demo/client.php就可以进行调试啦。
基于这一个demo,同学们可以自己发散思维,实现复杂的聊天室功能,例如:腾讯的TIM等。
