Correcting teacher:Guanhui
Correction status:qualified
Teacher's comments:可以贴出效果图!
因为所有操作都通过消息内容来辨别操作类型和操作对象, 所以理解起来比较绕, 好在经过西门老师的耐心讲解, 自己也基本能实现了.
待解决的问题: 用户刷新浏览器, 相当于执行下线, 换个马甲再上线的操作, 这样没法使用连接对象id来标识用户. 可以考虑, 使用保存在 cookie
中的客户信息, 如客户id等, 作为两个全局数组的key值来标识用户及其当前链接对象. 两个全局数组再放入高速缓存, 如 Redis
中, 问题可解.
客户
和 客服
两种, 实例只实现 客户
和 客服
点对点通信.msg
元素, 值为要发送的消息正文.type
元素, 用于区分发送的消息类别; 值为 login
时, 表示有新客户端跟服务器成功创建链接时发送的身份信息; 值为 msg
时, 表示此次发送的是聊天信息; 值为 logout
时, 表示发送消息的客户端将断开链接.from_id
元素, 用户保存发送消息的客户端 链接id
, 若为系统发送消息, 则值为 system
.sendId
元素, 值为发送消息的客户端希望服务器把消息转发给的目标客户端 链接id
.group
元素, 用来区分发送消息的客户端用户类型, member
值表示用户为 客户
, admin
值表示用户为 客服
.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), 为其重新分配 客服
.
<?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>