一,WebSocket 简介
WebSocket 是 HTML5 开始提供的一种在单个 TCP 连接上进行全双工通讯的协议。WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。
在 WebSocket API 中,浏览器和服务器只需要完成一次握手,浏览器和服务器之间就可以创建一个持久性的连接,两者之间就直接可以数据互相传送。
现在,很多网站为了实现推送技术,所用的技术都是 Ajax 轮询。轮询是在特定的的时间间隔(如每1秒),由浏览器对服务器发出HTTP请求,然后由服务器返回最新的数据给客户端的浏览器。然而HTTP请求可能包含较长的头部,其中真正有效的数据可能只是很小的一部分,显然这样会浪费很多的带宽等资源。
二,WebSocket API 简介
浏览器通过 JavaScript 向服务器发出建立 WebSocket 连接的请求,连接建立以后,客户端和服务器端就可以通过 TCP 连接直接交换数据。
当你获取 Web Socket 连接后,你可以通过 send() 方法来向服务器发送数据,并通过 onmessage 事件来接收服务器返回的数据。
Web Socket API:
var Socket = new WebSocket(url, [protocol] );
以上代码中的第一个参数 url, 指定连接的 URL。第二个参数 protocol 是可选的,指定了可接受的子协议。
Socket.readyState 属性
只读属性 readyState 表示连接状态,可以是以下值:
0 - 表示连接尚未建立。
1 - 表示连接已建立,可以进行通信。
2 - 表示连接正在进行关闭。
3 - 表示连接已经关闭或者连接不能打开。
Socket.bufferedAmount 属性
只读属性 bufferedAmount 已被 send() 放入正在队列中等待传输,但是还没有发出的 UTF-8 文本字节数。
WebSocket 事件
以下是 WebSocket 对象的相关事件。假定我们使用了以上代码创建了 Socket 对象:
事件 事件处理程序 描述
open Socket.onopen 连接建立时触发
message Socket.onmessage 客户端接收服务端数据时触发
error Socket.onerror 通信发生错误时触发
close Socket.onclose 连接关闭时触发
WebSocket 方法
以下是 WebSocket 对象的相关方法。假定我们使用了以上代码创建了 Socket 对象:
Socket.send() 使用连接发送数据
Socket.close() 关闭连接
更多:https://developer.mozilla.org/zh-CN/docs/Web/API/WebSocket
三,使用 Workerman 实现 Web Socket 服务端
3.1 Workerman 简介
Workerman是一款纯PHP开发的开源高性能的PHP socket 服务框架。它不是一个MVC框架,而是一个更底层更通用的socket服务框架,你可以用它开发tcp代理、梯子代理、做游戏服务器、邮件服务器、ftp服务器、甚至开发一个php版本的redis、php版本的数据库、php版本的nginx、php版本的php-fpm等等。Workerman可以说是PHP领域的一次创新,让开发者彻底摆脱了PHP只能做WEB的束缚。
useWorkerman\Worker;
require_once__DIR__ . '/Workerman/Autoloader.php';
$ws_worker = newWorker("websocket://0.0.0.0:2000");
$ws_worker->count = 4; // 启动4个进程对外提供服务// 当收到客户端发来的数据后返回hello $data给客户端$ws_worker->onMessage = function($connection, $data){
$connection->send('hello ' . $data);
};
// 运行workerWorker::runAll();
3.2 客户端与Workerman建立连接事触发onConnect
当客户端与Workerman建立连接时(TCP三次握手完成后)触发的回调函数。每个连接只会触发一次onConnect回调。
注意:onConnect事件仅仅代表客户端与Workerman完成了TCP三次握手,这时客户端还没有发来任何数据,此时除了通过$connection->getRemoteIp()获得对方ip,没有其他可以鉴别客户端的数据或者信息,所以在onConnect事件里无法确认对方是谁。要想知道对方是谁,需要客户端发送鉴权数据,例如某个token或者用户名密码之类,在onMessage回调里做鉴权。
3.3 长链接必须加心跳
长连接应用必须加心跳,否则连接可能由于长时间未通讯被路由节点强行断开。 建议客户端发送心跳间隔小于60秒,比如55秒。
心跳作用主要有两个:
1、客户端定时给服务端发送点数据,防止连接由于长时间没有通讯而被某些节点的防火墙关闭导致连接断开的情况。
2、服务端可以通过心跳来判断客户端是否在线,如果客户端在规定时间内没有发来任何数据,就认为客户端下线。这样可以检测到客户端由于极端情况(断电、断网等)下线的事件。
onMessage = function($connection, $msg) {
// 给connection临时设置一个lastMessageTime属性,用来记录上次收到消息的时间
$connection->lastMessageTime = time();
// 其它业务逻辑...
};
// 进程启动后设置一个每秒运行一次的定时器$worker->onWorkerStart = function($worker) {
Timer::add(1, function()use($worker){
$time_now = time();
foreach($worker->connections as$connection) {
// 有可能该connection还没收到过消息,则lastMessageTime设置为当前时间
if (empty($connection->lastMessageTime)) {
$connection->lastMessageTime = $time_now;
continue;
}
// 上次通讯时间间隔大于心跳间隔,则认为客户端已经下线,关闭连接
if ($time_now - $connection->lastMessageTime > HEARTBEAT_TIME) {
$connection->close();
}
}
});
};
Worker::runAll();
心跳 http://doc.workerman.net/faq/heartbeat.html
wss http://doc.workerman.net/faq/secure-websocket-server.html
四,WorkerMan中如何向某个特定客户端发送数据
useWorkerman\Worker;
require_once__DIR__ . '/Workerman/Autoloader.php';
// 初始化一个worker容器,监听1234端口$worker = newWorker('websocket://workerman.net:1234');
// ====这里进程数必须必须必须设置为1====$worker->count = 1;
// 新增加一个属性,用来保存uid到connection的映射(uid是用户id或者客户端唯一标识)$worker->uidConnections = array();
// 当有客户端发来消息时执行的回调函数$worker->onMessage = function($connection, $data)
{
global$worker;
// 判断当前客户端是否已经验证,即是否设置了uid
if(!isset($connection->uid))
{
// 没验证的话把第一个包当做uid(这里为了方便演示,没做真正的验证)
$connection->uid = $data;
/* 保存uid到connection的映射,这样可以方便的通过uid查找connection,
* 实现针对特定uid推送数据
*/
$worker->uidConnections[$connection->uid] = $connection;
return$connection->send('login success, your uid is ' . $connection->uid);
}
sendMessageByUid($recv_uid, $message);
};
// 当有客户端连接断开时$worker->onClose = function($connection)
{
global$worker;
if(isset($connection->uid))
{
// 连接断开时删除映射
unset($worker->uidConnections[$connection->uid]);
}
};
// 针对uid推送数据functionsendMessageByUid($uid, $message)
{
global$worker;
if(isset($worker->uidConnections[$uid]))
{
$connection = $worker->uidConnections[$uid];
$connection->send($message);
}
}
// 运行所有的worker(其实当前只定义了一个)Worker::runAll();
以上例子可以针对uid推送,虽然是单进程,但是支持个10W在线是没问题的。
注意这个例子只能单进程,也就是$worker->count 必须是1。要支持多进程或者服务器集群的话需要Channel组件完成进程间通讯,开发也非常简单。
参考:http://doc.workerman.net/faq/send-data-to-client.html
五,设置自己发送的数据格式
不少东西都受到环信即时通讯云的启发。
5.1 客户端A发布信息给客户端B,请求流程如下:
1,客户端A发送请求给服务器
2,服务器返回信息给客户端A3,服务端发送信息给客户端B
发送给服务端的信息:
{
messageType: "privateText",
message:"见到你很高兴",
messageId:"uuid"
from:"12",
to:"13",
}
收到服务端的返回信息:
{
messageType: "privateText",
message:"见到你很高兴",
messageId:"uuid"
from:"12",
to:"13",
statusCode:200,
statusMessage:"消息发送成功",
statusData:""
}
messageType 类型
privateText 发送文本消息
recallMessage 撤回消息
addToBlackList 拉黑聊天对象
recallMessage 收到消息撤回回执
deliveredMessage 收到消息送达客户端回执
statusCode 类型
返回值200,表示消息发送成功
返回值400,表示 massage 结构错误
返回值401,表示未授权[无token、token错误、token过期]
返回值403,表示被限流
5.2 权鉴与分配连接
functiononMessage($connection, $data)
{
/*
* 检查用户发送来的信息
*/
$data = json_decode($data, true);
// 验证请求中的 token 字段
if( !isset($data['token']) ){
$response['statusCode'] = 401;
$response['statusMessage'] = "缺少token参数";
$response['statusData'] = "";
return$connection->send(json_encode($data, JSON_UNESCAPED_UNICODE));
}
// 检查 tp_user_token 中是否存在
$res = Db::name("user_token")->where('token',$data['token'])->find();
if(!$res || $res['expire_time']send(json_encode($data, JSON_UNESCAPED_UNICODE));
}
$data['from'] = $res['user_id'].'-'.$res['device_type'];
$data['to'] = $data['to'].'-'.$res['device_type'];
/**
* 检查 from 连接ID
*/
if(!isset($connection->uid)){
$connection->uid = $data['from'];
// $this->worker->uidConnections 用来保存uid到connection的映射
$this->worker->uidConnections[$connection->uid] = $connection;
// $connection->send('login success, your uid is ' . $connection->uid);
// 检查是否有离线消息需要接收
// $this->sendOfflineMessage($connection, $data);
}
/**
* 判断消息类型,进行处理
*/
if($data['messageType'] == "privateText"){
return$this->sendTextMessage($connection, $data);
} else {
$data['statusCode'] = 400;
$data['statusMessage'] = "未知的请求类型";
$data['statusData'] = "";
return$connection->send(json_encode($data, JSON_UNESCAPED_UNICODE));
}
}
因为我的每个token都是由user_id和设备类型(macOS,pc, ios, android...)决定的,刚好这样做等于,一个用户PC登录只能接收别人PC发来的信息,macOS登录只能接收别人macOS发来的信息。
5.3 登录时接收离线消息
functionsendOfflineMessage($connection, $user_id){
$message_list = Db::name('chat_log')
->where('to_user_id', $user_id)
->where('has_sent', 0)
->order('create_time asc')
->select();
foreach ($message_listas$key => $msg) {
// 组装待发送信息
$data['messageType'] = $msg['message_type'];
$data['message'] = $msg['message'];
$data['messageId'] = $msg['message_id'];
$data['from'] = $msg['from_user_id'];
$data['to'] = $msg['to_user_id'];
$data['statusCode'] = 200;
$data['statusMessage'] = "";
$data['statusData'] = "";
$data['createTime'] = $msg['create_time'];
// 发送消息
$connection->send(json_encode($data,JSON_UNESCAPED_UNICODE) );
// 修改消息 has_sent 标志为 sent
Db::name('chat_log')
->where("to_user_id", $data['to'])
->where('message_id', $data['messageId'])
->update(['has_sent'=>1]);
}
}
参考: