项目中有两个业务逻辑需要Server端主动推送Client端,之前使用了ajax轮询,众所周知,该方式缺点很明显,资源浪费,大量无用请求,消耗大量的宽带内存,查阅资料后遂决定使用WebSocket重构该业务。
介绍:WebSocket 是伴随HTML5发布的基于TCP的一种新的持久化协议,真正实现了全双工通信,详细介绍戳这里:https://mp.weixin.qq.com/s/7aXMdnajINt0C5dcJy2USg
优点:client只需一次握手就可以持久化连接,通信实时且高效,server端可以主动推送等
以下记录了我使用websocket及部署上线(PHP):
建立流程
客户端建立连接时请求报文
GET / HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Host: example.com
Origin: http://example.com
Sec-WebSocket-Key: sN9cRrP/n9NdMgdcy2VJFQ==
Sec-WebSocket-Version: 13
可以看到跟http报文不同的是多了两行
Sec-WebSocket-Key: sN9cRrP/n9NdMgdcy2VJFQ== //是由浏览器随机生成的,提供基本的防护,防止恶意或者无意的连接。
Sec-WebSocket-Version: 13
这两行表示发起websocket连接
服务端收到连接后通过握手算法加密Sec-WebSocket-Key然后拼接返回报文头返回给客户端
加密方法:
- 将 Sec-WebSocket-Key 跟 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 拼接;
- 通过 SHA1 计算出摘要,并转成 base64 字符串。
然后服务端告知客户端握手成功,升级协议完成,至此,持久化连接已建立,后边就是该我们登场了,可以处理我们的业务逻辑,进行主动推送了
详细流程及数据报文戳这里https://segmentfault.com/a/1190000014643900
Socket关键函数简介
socket_create
//创建一个socket套接字 正确时返回一个套接字,失败时返回 FALSE
socket_create ( int $domain , int $type , int $protocol )
参数 $domain
代表可用的协议
- AF_INET IPv4协议
- AF_INET6 很明显这个是IPv6协议
- AF_UNIT 本地通讯协议
参数 $type
是套接字的类型
- SOCK_STREAM 可靠的,顺序的,双全工,基于连接的字节流(TCP协议基于此类型)
- SOCK_DGRAM 支持数据报文,无连接,不可靠,固定最大长度 (UDP协议一般基于这个)
- SOCK_SEQPACKET 顺序化的、可靠的、全双工的、面向连接的、固定最大长度的数据通信
- SOCK_RAW 支持原始网络协议
- SOCK_RDM 支持可靠的数据层,但不保证到达顺序。
参数 $protocol
设定套接字的具体协议
使用函数 getprotobyname()
进行读取,也可以直接使用常量 SOL_TCP(TCP协议) 和 SOL_UDP (UDP协议)
socket_last_error socket_strerror
//用户获取socket的错误信息 返回错误代码(int)
socket_last_error ([ resource $socket ] )
//错误代码通过此方法获取详细文字说明
socket_strerror(int $errno )
以上是socket错误信息的获取函数
socket_bind
//给套接字绑定名字
socket_bind ( resource $socket , string $address [, int $port = 0 ] )
socket_bind函数很好理解,给套接字绑定地址$address
和端口$port
用于访问
socket_listen
//监听套接字连接
socket_listen( resource $socket [, int $backlog= 0 ])
socket_listen则用于监听套接字的连接,第二个参数$backlog
表示并发连接数,如果超过这个连接数将被排队等待处理
socket_select
//检测一个或者多个socket是否可读或者可写,或者有错误产生。返回可操作的socket数目,出错返回FALSE
socket_select ( array &$read , array &$write , array &$except , int $tv_sec [, int $tv_usec = 0 ] )
socket_select 函数用来检查socket连接,可以看到参数$read
就是可以读的,同理$write
就是可以写的,而$except
就是异常,最后$tv_sec
代表超时时间。我们可以用这个函数用来监视连接server端的client端连接,以此来检查client端的状态。
socket_accept
//接收套接字的资源信息,成功返回套接字的信息资源,失败为FALSE。
socket_accept( resource $socket )
socket_accept用来接收socket连接,连接成功后会返回一个新的socket资源用来交互,没有连接,进程将会堵塞,直到有连接进来
socket_recv socket_read
//从连接的套接字接收数据
socket_recv ( resource $socket , string &$buf , int $len , int $flags )
//同样也是读取数据
socket_read ( resource $socket , int $length [, int $type = PHP_BINARY_READ ] )
socket_recv函数有4个参数,参数$socket
是socket_accept返回的新的socket资源,参数$buf
是一个缓冲区,用来存储接收到的数据 $len
则是你打算接收缓冲区数据的长度,$flags
是一个标志,一般设置为0 。
socket_read 函数有2个必须参数,第一个参数$socket
也是socket_accept返回的新socket资源,参数$length
同样也是打算接收数据的长度。
这两个函数都是用来读取数据的,socket_recv 相比 socket_read 的优势在于可以检测socket是否关闭,而且支持多个flags,建议使用socket_recv
socket_write
//向连接的socket写入数据
socket_write ( resource $socket , string $buffer [, int $length = 0 ] )
写数据的函数,第一个参数$socket
为要写入数据的socket套接字,第二个参数$buffer
就是要写入的数据,可选参数则为写入数据的长度
socket_close
//主动关闭socket连接
socket_close( resource $socket )
关键的socket函数大概就是这些,熟悉了之后就可以拿来实践了
Server实现
server = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
// 设置IP和端口重用,在重启服务器后能重新使用此端口;
socket_set_option($this->server, SOL_SOCKET, SO_REUSEADDR, 1);
// 将IP和端口绑定在服务器socket上;
socket_bind($this->server, $host, $port);
//监听端口 第二个参数是并发连接数(超过连接数请求将被堵塞直达队列有请求释放)
socket_listen($this->server, 10);
} catch (\Exception $e) {
//出现异常捕获并写入错误日志
$err_code = socket_last_error();
$err_msg = socket_strerror($err_code);
$this->writeLog('error',['error_init_server',$err_code, $err_msg]);
}
$this->sockets[0] = ['resource' => $this->server];
//写入日志
$this->writeLog('debug',["Socket服务: {$this->server} {$host}:{$port} 正在运行!"]);
//控制台输出
echo "Socket服务: {$this->server} {$host}:{$port} 正在运行!";
}
/**
* 服务入口
*/
public function run(){
//运行服务 一直循环保证有client连接时及时更新连接池
while (true) {
try {
$this->server();
} catch (\Exception $e) {
$this->writeLog('error',['error_server',$e->getCode(),$e->getMessage()]);
}
}
}
/**
* 服务逻辑处理
*/
private function server() {
$write = $except = NULL;
$sockets = array_column($this->sockets, 'resource');
// select 监视函数,参数分别是(监视可读,可写,异常,超时时间),返回可操作数目,出错时返回false;
$readNum = socket_select($sockets, $write, $except, NULL);
if ( $readNum === false ) {
$this->writeLog('error',['sockets_select',socket_strerror(socket_last_error())]);
return;
}
foreach ($sockets as $socket)
{
// 如果可读的是服务器socket ( Client客户端第一次连接Server服务端时 )
if ($this->server == $socket) {
/*
accept函数将会接受socket要来的连接,一旦有一个连接成功,将会返回一个新的socket资源用以交互,如果是一个多个连接的队列,只会处理第一个,如果没有连接的话,进程将会被阻塞,直到连接上
*/
$newClient = socket_accept($this->server);
if ($newClient === false) {
$this->writeLog('error',['socket_accept',socket_strerror($socket)]);
continue;
} else {
//放入连接池
self::connect($newClient);
continue;
}
} else {
// 读取数据
$bytes = @socket_recv($socket, $buffer, 2048, 0);
if ($bytes < 7) {
//关闭客户端
$info = $this->disconnect($socket);
} else {
//判断是否握手
if (!$this->sockets[(int)$socket]['handshake']) {
//给客户端握手
self::handShake($socket, $buffer);
continue;
}
//处理客户端发过来的消息
$info = self::parse($buffer);
}
//处理业务逻辑
$msg = self::processBusiness($socket, $info);
//推送消息
$this->publishMessage($msg);
}
}
}
/**
* 将socket添加到已连接列表,但握手状态留空;
* @param $socket
*/
public function connect($socket) {
socket_getpeername($socket, $ip, $port);
$info = [
'resource' => $socket,
'handshake' => false,
'ip' => $ip,
'port' => $port,
];
$this->sockets[(int)$socket] = $info;
$this->writeLog('debug',array_merge(['socket_connect'], $info));
}
/**
* 关闭客户端连接
* @param $socket
* @return array
*/
private function disconnect($socket) {
socket_close($socket);
unset($this->sockets[(int)$socket]);
$resource['param'] = 'disconnect';
return $resource;
}
/**
* 公共握手算法
* @param $socket
* @param $buffer
* @return bool
*/
public function handShake($socket, $buffer) {
// 获取到客户端的升级密匙
$line_with_key = substr($buffer, strpos($buffer, 'Sec-WebSocket-Key:') + 18);
$key = trim(substr($line_with_key, 0, strpos($line_with_key, "\r\n")));
// 生成升级密匙,并拼接websocket升级头
$upgrade_key = base64_encode(sha1($key . "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", true));// 升级key的算法
$upgrade_message = "HTTP/1.1 101 Switching Protocols\r\n";
$upgrade_message .= "Upgrade: websocket\r\n";
$upgrade_message .= "Sec-WebSocket-Version: 13\r\n";
$upgrade_message .= "Connection: Upgrade\r\n";
$upgrade_message .= "Sec-WebSocket-Accept:" . $upgrade_key . "\r\n\r\n";
socket_write($socket, $upgrade_message, strlen($upgrade_message));// 向socket里写入升级信息
$this->sockets[(int)$socket]['handshake'] = true;
socket_getpeername($socket, $ip, $port);
$this->writeLog('debug',[
'hand_shake',
$socket,
$ip,
$port
]);
// 向客户端发送握手成功消息;
$msg = [
'code' => 1,
'type' => 'hand',
'data' => "连接成功",
];
$msg = $this->build(json_encode($msg));
socket_write($socket, $msg, strlen($msg));
return true;
}
/**
* 解析数据
* @param $buffer
* @return bool|string
*/
private function parse($buffer) {
$decoded = '';
$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;
//这里客户端发送过来的是json数据 所以需要转化为数组
return json_decode($decoded, true);
}
/**
* 将普通信息组装成websocket数据帧
* @param $msg
* @return string
*/
private function build($msg) {
$frame = [];
$frame[0] = '81';
$len = strlen($msg);
if ($len < 126) {
$frame[1] = $len < 16 ? '0' . dechex($len) : dechex($len);
} else if ($len < 65025) {
$s = dechex($len);
$frame[1] = '7e' . str_repeat('0', 4 - strlen($s)) . $s;
} else {
$s = dechex($len);
$frame[1] = '7f' . str_repeat('0', 16 - strlen($s)) . $s;
}
$data = '';
$l = strlen($msg);
for ($i = 0; $i < $l; $i++) {
$data .= dechex(ord($msg{$i}));
}
$frame[2] = $data;
$data = implode('', $frame);
return pack("H*", $data);
}
/**
* 处理业务逻辑
* @param $socket socket字节流
* @param $request 客户端请求参数
* @return string
*/
private function processBusiness($socket, $request) {
$response = [];
$response['type'] = $request['param'];
//根据类型做对应业务处理即可 这里只做实例
switch ($request['param']) {
case 'online':
// 取得在线人数
$response['code'] = 1;
$response['data'] = count($this->sockets) - 1;
break;
case 'disconnect':
//断开连接
$response['code'] = 0;
$response['data'] = count($this->sockets) - 1;
break;
case 'message':
$response['code'] = 1;
$response['data'] = "我是websocket服务端啊";
break;
//更多业务处理请自行编写
default:
$response['type'] = 'close';
$response['code'] = 0;
$response['data'] = "未收到任何请求参数";
}
return $response;
}
/**
* 推送消息
* @param $data
*/
public function publishMessage($msg) {
//在线人数需要一直推送
if($msg['type'] != 'online'){
$online = [
'code'=>1,
'type'=>'online',
'data'=>count($this->sockets) - 1
];
$onlineBuild = $this->build(json_encode($online));
}
//编码消息
$message = $this->build(json_encode($msg));
foreach ($this->sockets as $socket) {
//不用给当前服务器推送
if ($socket['resource'] == $this->server) {
continue;
}
//消息写入客户端 参数(socket套接字,数据信息,数据长度)
socket_write($socket['resource'], $message, strlen($message));
if($msg['type'] != 'online'){ //在线人数推送
socket_write($socket['resource'], $onlineBuild, strlen($onlineBuild));
}
}
}
/**
* 日志处理 如果有需要 请自行修改路径
* @param array $info
*/
private function writeLog(string $type,array $info)
{
$time = date('Y-m-d H:i:s');
array_unshift($info, $time);
$info = array_map('json_encode', $info); //转化为json字符串
switch ($type) {
case 'debug':
file_put_contents('./websocket_debug.log', implode(' | ', $info) . "\r\n", FILE_APPEND);
break;
case 'error':
file_put_contents('./websocket_error.log', implode(' | ', $info) . "\r\n", FILE_APPEND);
break;
}
}
}
Client端
websocket提供一系列的api供我们使用,非常简洁明了,很容易上手开发
//由于项目网站使用了ssl,所以我们选择wss 注:5shop.com不是真实项目地址 这里只做示例
//var ws = new WebScoket('ws://127.0.0.1:9803');
var ws = new WebSocket('wss:///5shop.com/websocket'); //这里使用wss协议而不是ws
ws.onopen = function(){
showMsg("websocket连接成功");
}
ws.onerror = function () {
showMsg("websocket连接失败!");
};
ws.onmessage = function(event){
let data = JSON.parse(event.data);
switch(data.type){
case 'online':
onlineRequest.show(data);
break;
case 'message':
console.log(data);
messageRequest.show(data);
break;
case 'hand': //收到握手信息 获取在线人数及消息
console.log("webSocket:收到信息",event.data);
onlineRequest.send();
messageRequest.send();
break;
case 'disconnect': //断开连接
console.log("有一台主机断开连接!当前主机连接数为"+data.data);
showMsg("当前连接主机数:":data.data.data);
break;
}
}
ws.onclose = function(){
showMsg("WebSocket服务器已关闭");
ws.close();
}
//在线人数
var onlineRequest = {
show : function(data){
if(data.code == 1){
showMsg("当前连接主机数:":data.data.data);
}
},
send : function(){
let msg = {'param':'online'};
sendServerMessage(msg);
}
};
var messageRequest = {
show : function(data){
if(data.code == 1){
showMsg("收到一条消息:":data.data.data);
}
},
send : function(){
let msg = {'param':'message' };
sendServerMessage(msg);
}
}
function showMsg(msg){
var show = document.getElementById("msg");
var msgs = document.createElement("p");
msgs.innerHTML = msg;
show.appendChild(msgs);
show.scrollTop = show.scrollHeight;
}
function sendServerMessage(msg){
let data = JSON.stringify(msg);
console.log(data);
if(ws.readyState == 1){
ws.send(data);
}
}
//刷新或关闭页面时 需要主动关闭websocket 否则server端会报异常而退出
window.onbeforonload = function(){
ws.send('disconnect');
ws.close();
};
启动服务
这里使用cli模式启动服务(也就是win下cmd或powershell)
本项目使用了ThinkPHP框架 可以自定义命令行 这里我们使用提前定义好的命令行来启动(其实也就是执行server类)
namespace app\command;
use app\index\controller\Publish;
use think\console\Command;
class Sub extends Command
{
protected function configure()
{
//命令名称
$this->setName("sub:message")->setDescription("接收订阅消息");
}
public function execute()
{
$server = new Publish('127.0.0.1',9803);
$server->run();
}
}
php think sub:message
可以看到服务已经启动
打开客户端(此处我打开了四个测试又关闭了一个 可以看到每次发生变化时都实时推送)
上线部署
遇到的问题
- 项目使用了ssl协议,使用ws报错
was loaded over HTTPS, but attempted to connect to the insecure WebSocket endpoint 'ws://example.com/'. This request has been blocked; this endpoint must be available over WSS
此处也提示了,需要使用wss,跟ws是一样的,但是使用了wss后依旧连接不上,查阅资料后,发现需要修改nginx配置
location /websocket {
proxy_pass http://127.0.0.1:9803;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
大概意思是将请求 /websocket的连接代理到127.0.0.1:9803 也就是我们的socket服务,并且将连接设置为upgrade
,至此这个问题得以解决。(这个方法并没有使用证书,只是代理到已经启动的服务端上,算是折中的一种解决方案)
端口号和ip可以自行设置,这里ip我设置的本地,端口号数字大一些保证不重复
- 守护进程
命令行启动后,如果不是守护进程的方式,在关闭终端的时候socket服务就会关闭。
php-cli并没有守护进程模式,这里我借助linux下的systemd
来解决这个问题
linux中启动项以前是一直采用init进程,例如:
$ sudo /etc/init.d/apache start
或者
service apache restart
该方式有两个缺点,只能串行启动(也就是必须等待前一个进程启动完才可以继续启动下一个),再者就是启动脚本复杂。所以systemd由此而生,在linux中d就是守护进程的缩写。
首先配置一个启动文件
cd /etc/systemd/system/ #进入系统启动目录
vi websocket.service #建立websocket服务配置文件
文件内容
[Unit]
Description=php daemon for beanstalkd
After=network.target
StartLimitIntervalSec=0
[Service]
Type=simple
Restart=always
RestartSec=1
User=root
ExecStart=/bin/php /home/wwwroot/project/admin/think sub:message //这行是脚本目录请自行更换
[Install]
WantedBy=multi-user.target
/home/wwwroot/project/admin/think sub:message
是项目socket服务路径
然后重新载入配置项
systemctl daemon-reload
载入成功后执行
systemctl start websocket
tips:websocket名字可以自定义,但是要对应配置文件