实现商桥客户和客服点对点通信
学习心得
因为所有操作都通过消息内容来辨别操作类型和操作对象, 所以理解起来比较绕, 好在经过西门老师的耐心讲解, 自己也基本能实现了.
待解决的问题: 用户刷新浏览器, 相当于执行下线, 换个马甲再上线的操作, 这样没法使用连接对象id来标识用户. 可以考虑, 使用保存在
cookie
中的客户信息, 如客户id等, 作为两个全局数组的key值来标识用户及其当前链接对象. 两个全局数组再放入高速缓存, 如Redis
中, 问题可解.
1. 实现思路
- 客户端的用户类型分为
客户
和客服
两种, 实例只实现客户
和客服
点对点通信.
- 客户端的用户类型分为
- 客户端发送的消息分为: 当客户端成功创建跟服务器的链接时, 发送的包含客户端类型(客户或客服)的上线消息; 客户端和客户端之间, 通过服务器转发的聊天信息; 客户端下线(断开链接)时, 发送的下线消息.
- 使用通信的数据辨别通信行为. 约定通信的数据数组中包含以下元素(发送时转换成json字符串):
- 1.1
msg
元素, 值为要发送的消息正文. - 1.2
type
元素, 用于区分发送的消息类别; 值为login
时, 表示有新客户端跟服务器成功创建链接时发送的身份信息; 值为msg
时, 表示此次发送的是聊天信息; 值为logout
时, 表示发送消息的客户端将断开链接. - 1.3
from_id
元素, 用户保存发送消息的客户端链接id
, 若为系统发送消息, 则值为system
. - 1.4
sendId
元素, 值为发送消息的客户端希望服务器把消息转发给的目标客户端链接id
. - 1.5
group
元素, 用来区分发送消息的客户端用户类型,member
值表示用户为客户
,admin
值表示用户为客服
. - 1.6
custom_id
元素,客户
成功创建链接时, 系统给跟该用户对接的客服
发送客户上线的消息, 此时from_id
被设置为system
, 所以使用custom_id
元素指定上线的客户
客户端id; 当客户
断开连接时, 系统给跟该用户对接的客服
发送客户下线消息, 此时from_id
被设置为sytem
, 所以使用custom_id
元素指定下线的客户
客户端链接id.
- 客户端链接成功后, 发送上线消息到服务器, 服务器根据消息中的属性值, 执行不同的处理.
- 在链接对象中设置
group
属性, 值等于上线消息中的group
元素值, 标识该链接是客户
还是客服
链接. - 创建两个全局数组, 分别保存
客户
链接和客服
链接. key为链接id, 值为链接对象. - 若是
客服
客户端发来的上线消息, 则把发送消息的客服
链接放入客服
全局数组即可. - 若是
客户
客户端发送来的上线消息- 把发送消息的
客户
链接放入客户
全局数组; - 使用
array_rand()
函数从客服
数组中随机获取一个客服
链接id, 分配给当前客户
. 若当前没有客服
, 则服务器给当前客户
链接的客户端发送消息, 让客户端过一会儿刷新重试. - 若随机到
客服
, 则把客服
id设置到当前客户
的sendId
属性, 然后服务器给随机到的客服
链接的客户端发送消息, 通知该客服
有新客服
上线,客服
客户端前端的客户
列表新增当前客户
项.
- 把发送消息的
客户
客户端发送聊天消息到服务器, 服务器根据消息中的sendId
元素值, 从全局客服
数组中获取对应的客服
链接对象, 若获取不到, 则服务器发送系统消息提示客户
客户端刷新界面重新创建链接来获取客服
链接. 若获取到对象, 则转发消息给该客服
对象.
客服
客户端点击客户
列表中的客户
, 获取其客户
链接id , 组装成消息发送给服务器. 服务器的处理跟客户
客户端发送消息的处理类似.
客户
客户端下线(关闭浏览器/刷新浏览器)时, 给服务器发送下线消息, 服务器通过发送消息的客户
链接id, 将其从客户
全局数组中移除, 并根据消息中的sendId
给负责该客户的客服
链接发送系统消息, 通知该客服
,客户
下线的消息.
客服
客户端下线时, 给服务器发送下线消息, 服务器同样先根据其id从客服
全局数组中移除, 再遍历客户
全局数组, 找到其负责的客户
链接(可以优化的点, 可以在客服
发送的消息中带上其负责的客户
id), 为其重新分配客服
.
2. 代码清单
- 服务端
<?php
use Workerman\Worker;
require_once __DIR__ . '/workerman/Autoloader.php';
// 注意:这里与上个例子不同,使用的是websocket协议
$ws_worker = new Worker("websocket://0.0.0.0:2000");
// 启动4个进程对外提供服务
$ws_worker->count = 4;
/* 创建全局连接数组,key=连接的id,val=连接; 因为存在客户可能刷新浏览器页面, 触发websocket给同一个客户再分配一个新的连接id
* 所以, 一般是用客户保存在数据表中的用户id来做key, 每次刷新, 用户id都跟最新分配的连接关联, 就能解决用户刷新的问题了.
* 用户id从哪来? 可以在登录成功后把用户id保存到cookie中.
* 另一种方案: 把$conns链接数组保存到缓存(如: Redis)中, 同样用用户id做key, 序列化/json格式化的连接做value.
*/
// 客户连接数组
$customerConns = [];
// 客服连接数组
$servicerConns = [];
// 当收到客户端发来的数据后返回hello $data给客户端
$ws_worker->onMessage = function($connection, $data)
{
global $customerConns;
global $servicerConns;
// 分辨用户类型, 只能客户和客服之间通信.
// 判断客户端模拟用户登录状态的标识type, 若值为login标识登录成功. 则把用户的类型(客户/客服)
// 以自定义属性的方式设置到$connection(即连接)中
// json->数组
$data = json_decode($data, true);
if($data['type'] == 'login') {
var_dump($connection->id);
// 给连接动态加入group属性,标识[客户]和[客服]
$connection->group = $data['group'];
// 模拟"登录"的连接,放入到对应连接数组中
if($data['group'] == 'admin') {// admin标识为[客服]
$servicerConns[$connection->id] = $connection;
} else {// member标识为[客户]
$customerConns[$connection->id] = $connection;
// 如果是客户登录, 还需要给他安排一个客服(array_rand()函数随机返回数组元素的key值)
$connection->sendId = array_rand($servicerConns, 1);
// 没有小姐姐可供分配
if(!is_numeric($connection->sendId)) {
$sendInfo['from_id'] = 'system';
$sendInfo['type'] = 'msg';
$sendInfo['msg'] = '暂无客服小姐姐在线, 请稍后刷新重试';
$connection->send(json_encode($sendInfo));
var_dump('暂无客服小姐姐');
return;
}
var_dump('给id为' . $connection->id .'的客户分配的是id为' . $connection->sendId . '的客服小姐姐');
// 通知这位客服, 有新客户进来
$target = $servicerConns[$connection->sendId];
$sendInfo['from_id'] = 'system';
$sendInfo['custom_id'] = $connection->id;
$sendInfo['type'] = 'login';
$sendInfo['msg'] = '有新客户登录, id为:' . $connection->id;
$target->send(json_encode($sendInfo));
}
} else if($data['type'] == 'msg') {// 模拟"发送数据"的连接
// 判断发送数据的连接是[客服]还是[客户]
if($connection->group == 'member') {// 客户
if(!is_numeric($connection->sendId)) {
$sendInfo['from_id'] = 'system';
$sendInfo['type'] = 'msg';
$sendInfo['msg'] = '暂无客服小姐姐在线, 请稍后刷新重试';
$connection->send(json_encode($sendInfo));
var_dump('暂无客服小姐姐');
return;
}
// 获取在客户登录时指定的客服连接
$target = $servicerConns[$connection->sendId];
var_dump('给id为' . $connection->sendId . '的客服小姐姐发信息');
// 把发送消息的连接id设置到要发送的数据中
$sendInfo['from_id'] = $connection->id;
$sendInfo['msg'] = $data['msg'];
// 给要发送消息的对象发送消息
$target->send(json_encode($sendInfo));
} else if($connection->group == 'admin') {// 客服
$customer_id = $data['sendId'];
if(!is_numeric($customer_id)) {
$sendInfo['type'] = 'msg';
$sendInfo['msg'] = '该客户不存在, 可能已下线';
$sendInfo['from_id'] = 'system';
$connection->send(json_encode($sendInfo));
return;
}
// 获取要发送消息的客户连接
if(!isset($customerConns[$customer_id])) {
$sendInfo['type'] = 'msg';
$sendInfo['msg'] = '该客户不存在, 可能已下线!';
$sendInfo['from_id'] = 'system';
$connection->send(json_encode($sendInfo));
return;
}
$target = $customerConns[$customer_id];
$sendInfo['type'] = 'msg';
$sendInfo['msg'] = $data['msg'];
$sendInfo['from_id'] = $connection->id;
$target->send(json_encode($sendInfo));
}
}
// 群发消息
/* global $ws_worker;
foreach($ws_worker->connections as $conn) {
$conn->send('hello');
} */
// 向客户端发送hello $data
// $connection->send('hello ' . $data);
};
// 当有连接断开时
$ws_worker->onClose = function($connection) {
global $servicerConns;
global $customerConns;
if($connection->group == 'admin') {// 客服断开
$unconnId = $connection->id;
// 把断开的客服连接移除出客服连接数组
unset($servicerConns[$connection->id]);
var_dump('客服' . $unconnId . '下线了');
// 客服断开, 需要给该客服负责的客户重新分配新客服
foreach($customerConns as $customerConn) {
if($customerConn->sendId == $unconnId) {// 该客服小姐姐负责的客户
$customerConn->sendId = array_rand($servicerConns, 1);
// 没分配到客服小姐姐, 直接结束分配.
if(!is_numeric($customerConn->sendId)) {
var_dump('当前没有客服小姐姐');
continue;
}
var_dump('重新给客户' . $customerConn->id . '分配新的客服小姐姐:' . $customerConn->sendId);
// 新分配的客服小姐姐的连接
$target = $servicerConns[$customerConn->sendId];
// 给新分配到的客服小姐姐发分配消息
$sendInfo['from_id'] = 'system';
$sendInfo['custom_id'] = $customerConn->id;
$sendInfo['type'] = 'login';
$sendInfo['msg'] = '有新客户登录, id为:' . $customerConn->id;
$target->send(json_encode($sendInfo));
}
}
} else {// 客户断开
// 断开的客户连接id
$unconnId = $connection->id;
var_dump('客户' . $unconnId . '下线了');
// 负责该客户的客服小姐姐id
$servicerId = $connection->sendId;
// 把断开的客户连接移除出客户连接数组
unset($customerConns[$connection->id]);
if(!is_numeric($servicerId)) {// 判断断开的客户是否有客服接待, 没有, 则直接断开客户连接;
return;
}
// 客户断开, 系统给负责该客户的客服发消息, 不必再负责该客户
$target = $servicerConns[$servicerId];
$sendInfo['from_id'] = 'system';
$sendInfo['msg'] = '客户' . $unconnId . '跟你说了声拜拜后, 下线了';
$sendInfo['custom_id'] = $unconnId;
$sendInfo['type'] = 'logout';
$target->send(json_encode($sendInfo));
}
};
// 运行worker
Worker::runAll();
客户
客户端
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>"百度商桥"客户端</title>
<link rel="stylesheet" href="/static/plugin/layui/css/layui.css" media="all">
<script src="/static/plugin/layui/layui.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: #fafafa;
}
.im {
width: 400px;
height: 520px;
background-color: wheat;
margin: 40px auto;
padding: 10px;
}
.im .history {
background-color: white;
border: 1px solid #aaa;
width: 100%;
height: 290px;
padding: 5px;
overflow-y: auto;
}
.im .inputing {
background-color: white;
border: 1px solid #aaa;
width: 100%;
height: 150px;
margin-top: 10px;
padding: 5px;
outline: none;
}
.im .btn-area {
width: 100%;
text-align: right;
margin-top: 10px;
}
.msg-box-friend {
padding: 10px;
width: 75%;
margin: 5px auto 0 5px;
background-color: lightblue;
border: 1px solid #ccc;
border-radius: 5px;
/* overflow-x: wordwrap; */
white-space:normal;
}
.msg-box-me {
padding: 10px;
width: 75%;
margin: 5px 5px 0 auto;
background-color: wheat;
border: 1px solid #ccc;
border-radius: 5px;
/* overflow-x: wordwrap; */
white-space:normal;
}
</style>
</head>
<body>
<div class="im">
<div class="history">
</div>
<!-- contenteditable="true", 这个div就可编辑了 -->
<div class="inputing" contenteditable="true">
</div>
<div class="btn-area">
<span class="layui-btn layui-btn-success" onclick="send()">发送</span>
</div>
</div>
</body>
<script>
layui.use(['layer'], function() {
layer = layui.layer;
});
// 假设服务端ip为127.0.0.1
ws = new WebSocket("ws://127.0.0.1:2000");
/* 当客户端连通服务器端的时候 */
ws.onopen = function() {
// 当客户端连通服务端时, 把当前客户端的用户标识(客户/客服)发给服务端
var data = {};
// js对象的属性可以自定义. type: login, 标识用户行为为声明登录;type: msg, 标识用户行为为发送消息。
data.type = 'login';
// 约定admin表示登录的用户是客户
data.group = 'member';
// 发送JSON格式的数据
ws.send(JSON.stringify(data));
};
ws.onmessage = function(e) {
// alert("收到服务端的消息:" + e.data);
var receive = JSON.parse(e.data);
var str = "<div class='msg-box-friend'>"+receive.from_id+"说: "+receive.msg+"</div>";
$(str).appendTo('.history');
};
function send() {
// 消息框
var str = "<div class='msg-box-me'>我说: "+$('.inputing').html()+"</div>";
var data = {};
// 标识此次发送的数据是发送消息
data.type='msg';
// 标识是从客服端发的
data.group = 'admin'
// 标识私聊的对象id,0标识群发. DEL_客户连接时由系统随机分配客服小姐姐, 不需要手动指定了
// data.sendId = 0;
// 要发送的消息
data.msg = $('.inputing').html();
// 在消息历史中显示发送的消息
$(str).appendTo('.history');
// 发送JSON格式的数据
ws.send(JSON.stringify(data));
$('.inputing').html('');
}
</script>
</html>
客服
客户端
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>"百度商桥"客服端</title>
<link rel="stylesheet" href="/static/plugin/layui/css/layui.css" media="all">
<script src="/static/plugin/layui/layui.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.js"></script>
<style>
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
body {
padding: 10px;
width: 100vw;
height: 100vh;
background-color: #f0f0f0;
display: grid;
gap: 10px;
grid-template-columns: 240px auto;
grid-template-rows: 3fr 2fr;
grid-template-areas:
"customer-list message-list"
"customer-list message-send";
}
.customer-list {
grid-area: customer-list;
background-color: white;
border: 1px solid #ccc;
}
.message-list {
grid-area: message-list;
background-color: white;
overflow-y: auto;
}
.message-send {
display: grid;
grid-auto-rows: auto 40px;
grid-area: message-send;
background-color: white;
padding: 10px;
}
.message-send > .message-input-area {
padding: 5px;
background-color: #fafafa;
border: 1px solid #ccc;
margin-bottom: 10px;
border-radius: 5px;
outline: none;
overflow-y: auto;
}
.message-send > .message-input-area:hover {
box-shadow: 0px 0px 1px #333;
}
.message-send > .btn-area {
width: 100%;
text-align: right;
}
.msg-box-friend {
padding: 10px;
width: 75%;
margin: 5px auto 0 5px;
background-color: lightblue;
border: 1px solid #ccc;
border-radius: 5px;
/* overflow-x: wordwrap; */
white-space:normal;
}
.msg-box-me {
padding: 10px;
width: 75%;
margin: 5px 5px 0 auto;
background-color: wheat;
border: 1px solid #ccc;
border-radius: 5px;
/* overflow-x: wordwrap; */
white-space:normal;
}
.customer-item {
border: 1px solid #e0e0e0;
border-radius: 5px;
margin: 5px;
padding: 5px 10px;
}
.active {
background-color: skyblue;
}
</style>
</head>
<body>
<!-- 客户列表 -->
<div class="customer-list">
</div>
<!-- 消息记录 -->
<div class="message-list">
</div>
<!-- 发送消息 -->
<div class="message-send">
<div class="message-input-area" contenteditable="true">
</div>
<div class="btn-area">
<span class="layui-btn layui-btn-success" onclick="send()">发送</span>
</div>
</div>
</body>
<script>
layui.use(['layer'], function() {
layer = layui.layer;
$ = layui.jquery;
});
// 假设服务端ip为127.0.0.1
ws = new WebSocket("ws://127.0.0.1:2000");
/* 当客户端连通服务器端的时候 */
ws.onopen = function() {
// 当客户端连通服务端时, 把当前客户端的用户标识(客户/客服)发给服务端
var data = {};
// js对象的属性可以自定义. type: login, 标识用户行为为声明登录;type: msg, 标识用户行为为发送消息。
data.type = 'login';
// 约定admin代表登录的用户是客服
data.group = 'admin';
// 发送JSON格式的数据
ws.send(JSON.stringify(data));
};
ws.onmessage = function(e) {
var data = JSON.parse(e.data);
// alert("收到服务端的消息:" + e.data);
var str = "<div class='msg-box-friend'>"+data.from_id+"说: "+data.msg+"</div>";
$(str).appendTo('.message-list');
// 系统发来的消息, 表示有新客户接入, 并分配到当前客服小姐姐
if(data.from_id == 'system' && data.type == 'login') {
var customer = '<div class="customer-item" data-id="'+data.custom_id+'" onclick="talkTo(this)">客户'+data.custom_id+'</div>';
$(customer).appendTo('.customer-list');
} else if(data.from_id == 'system' && data.type == 'logout') {
$('.customer-item[data-id="'+data.custom_id+'"]').remove();
}
};
function talkTo(ele) {
$(ele).siblings().removeClass('active');
$(ele).addClass('active');
}
function send() {
var str = "<div class='msg-box-me'>我说: "+$('.message-input-area').html()+"</div>";
var data = {};
// 标识此次发送的数据是发送消息
data.type='msg';
// 标识是从客服端发的
data.group = 'admin'
// 获取当前选中的客户
var customer_id = $('div[class*=active]').data('id');
if(isNaN(customer_id)) {
return layer.alert('请先选中一个客户, 再发送消息.');
}
data.sendId = customer_id;
// 要发送的消息
data.msg = $('.message-input-area').html();
$(str).appendTo('.message-list');
// 转成json字符串发送。
ws.send(JSON.stringify(data));
$('.message-input-area').html('');
}
</script>
</html>
- 路由, 略.