一、实践效果图
二、环境准备
项目架构采用前后端分离模式进行开发,前端使用mpvue,后端使用ThinkPHP开发接口为前端提供业务功能服务。
我在ThinkPHP5.0.22版本中集成了GatewayWorker框架。我选择的集成方式是自己去下载软件包进行解压,也可以选择composer命令集成。首先下载GatewayWorker与GatewayClient,然后在项目根目录下的vendor目录下进行解压:
GatewayWorker官方文档传送门:http://doc2.workerman.net/326107,感兴趣的大佬可以看看。一开始不是很了解GatewayWorker框架,看官方文档:与ThinkPHP等框架结合那一篇的时候就比较分不清楚GatewayWorker与GatewayClient的关系。之后通过一番摸索,大概理解了一番:
接下来,我们先把环境运行起来。首先,修改GatewayWorker解压目录下Applications/YourApp/start_gateway.php文件,将text协议改成Websocket:
之后在GatewayWorker解压目录下找到:start_for_win.bat文件,双击运行:
需要特别注意两个地址,作用我们稍后在代码中会看到:
三、功能开发
对于GatewayWorker框架,主要需要编辑到的文件是:GatewayWorker/Applications/YourApp/Events.php文件
对该文件,在本项目中主要需要关注一个函数:onConnect($client_id),当前端建立websocket与 GatewayWorker相连接成功时,该函数被调用,参数$client_id是 GatewayWorker分配给该客户端的client_id。下面onConnect($client_id)中使用了Gateway API:sendToClient(client_id,message),向指定client_id发送消息。下面代码的作用是将分配到的client_id返回给当前客户端。
我们可以在mpvue中建立websocket连接,看看效果。首先mpvue聊天界面对应的vue文件:chat.vue代码如下:
-
{{item.text}}
发送
该vue的data中定义的与聊天功能相关的变量意义如下:
主要是注意wx.connectSocket中url地址。当连接建立成功时,前端自动触发wx.onSocketOpen函数,该函数代表连接建立成功。当客户端与GatewayWorker成功建立连接时,GatewayWorker的Events.php文件中的onConnect函数自动被调用,并将返回我们自己规定好的消息结构体:
GatewayWorker返回消息时,前端的wx.onSocketMessage()将被自动调用。换句话说就是我们可以在前端的wx.onSocketMessage()中接收GatewayWorker返回的消息。在该函数中,我们可以对返回的数据进行判断,判断type值是否为init,我们用init来代表这条消息是当前用户初次接入GatewayWorker。
对于GatewayWorker框架来说,每个用户与它建立websocket连接的时候,都会被分配到一个client_id。而GatewayWorker就有提供函数用于向指定client_id用户发送消息。但是,在实际应用场景中,我们需要考虑:接收方用户可能不在线、接收方用户可能从未与GatewayWorker建立websocket连接、离线消息需要进行存储,等待用户查看。接下来,我们以实际聊天功能进行分析。
在该聊天功能中,用户每次进入聊天界面,就会开始与GatewayWorker建立websocket连接,在用户退出聊天界面的时候,进行websocket连接关闭操作。所以用户每次进入聊天界面,用户被分配到的client_id都是不一样的,并且发送方发送消息时,接收方并不一定处于在线状态,所以接收方的client_id是未知的。因此我们只能通过用户id来向指定用户发送消息。可以使用Gateway::bindUid(client_id,uid)来实现client_id与用户ID的绑定,使用Gateway::sendToUid($uid,$message)实现向指定用户ID发送消息。
那么我们在哪里使用Gateway::bindUid等函数?
我选择在前端拿到分配的client_id之后,发送到后端Controller层进行client_id与用户id的绑定处理。这个时候还记得我们在Events.php的onConnect()中定义返回的消息结构体吗?
[
'type' => 'init',
'client_id' => $client_id,
'message' => ''
]
这里type='init'就可以作为当前用户是否刚进入聊天界面的判断。
接下来我们来看看后台绑定逻辑是怎么样的。首先,为了能够在controller层中使用Gateway::bindUid等函数,需要集成GatewayClient,才能够在Controller层中使用Gateway API。之前我们已经把GatewayClient集成进来了。在控制器Chat.php中使用的时候记得引入GatewayClient/Gateway.php文件:
use GatewayClient\Gateway;
require_once VENDOR.'GatewayClient/Gateway.php';
之后在控制器Chat.php中编写函数bindUid(),用于绑定client_id与用户id,同时也可读取未读状态的消息。
private $uid;
/**
* 绑定用户id
* @url /chat/init
* @http post
*/
public function bindUid () {
$dataArr = input('post.');
$client_id = $dataArr['client_id'];
/**
* 注释开始
* 此处可选择由前端发送user_id用户ID过来,也可以采用token获取当前用户id
*/
// 根据Token获取uid
$this->uid = Token::getCurrentUid();
// 判断当前用户是否存在
UserService::isUserExist($this->uid);
/**
* 注释结束
* 此处可选择由前端发送user_id用户ID过来,也可以采用token获取当前用户id
*/
// 绑定
Gateway::bindUid($client_id,$this->uid);
/**
* 注释开始
* 获取当前用户未读状态消息,该部分可自己设计
*/
$chat = ChatModel::getChatAndChange($this->uid,1,10);
$result = array_map(function ($item) {
$temp = [
'msg_id' => $item['msg_id'],
'content' => $item['content'],
'is_self' => false,
'other' => $item['receiver_uid'],
'create_time' => $item['create_time']
];
if($item['uid'] == $this->uid) {
$temp['is_self'] = true;
}
return $temp;
},$chat);
/**
* 注释结束
* 获取当前用户未读状态消息,该部分可自己设计
*/
/**
* 返回注释
* 如果不打算获取未读消息,可以直接如下注释返回
*/
//return json(new SuccessMessage(['msg' => '绑定用户成功', 'data' => '']),201);
return json(new SuccessMessage(['msg' => '绑定用户成功', 'data' => $result]),201);
}
对于消息记录以及离线消息的处理,网上提供了几种方案:在数据库里设计张表来存放消息、采用文件形式存放消息。这里我选择自己设计表来存放消息,我个人设计存放消息的数据库表时,主要分了两张,一张是只有:发送方、接收方、最新通信时间、最新通信内容等字段。这张主要是用于显示聊天列表的。还有一张是有:消息ID、发送方、接收方、消息状态(已读、未读)、发送时间、消息内容。这张主要用于显示聊天界面中的聊天信息。具体如何读取可自行设计。
后端绑定后,我们在前端打印一下:
到这里,我们就可以正式开始发送消息啦。关于发送消息有两种方式,一种是通过GatewayWorker框架的onMessage()方法来发送消息。一种是通过controller层提供接口实现发送消息(这种主要利用GatewayClient实现)。这两种方式,我都会在下面展示用法。
首先先看第一种,使用GatewayWorker框架的onMessage()方法。前端方面主要监听聊天界面的发送按钮:
在GatewayWorker框架中,修改onMessage函数,编辑GatewayWorker/Applications/YourApp/Events.php文件:
* @copyright walkor
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
/**
* 用于检测业务代码死循环或者长时间阻塞等问题
* 如果发现业务卡死,可以将下面declare打开(去掉//注释),并执行php start.php reload
* 然后观察一段时间workerman.log看是否有process_timeout异常
*/
//declare(ticks=1);
use \GatewayWorker\Lib\Gateway;
use app\api\service\Token as Token;
/**
* 主逻辑
* 主要是处理 onConnect onMessage onClose 三个方法
* onConnect 和 onClose 如果不需要可以不用实现并删除
*/
class Events
{
/**
* 当客户端连接时触发
* 如果业务不需此回调可以删除onConnect
*
* @param int $client_id 连接id
*/
public static function onConnect($client_id)
{
// 返回数据给当前用户
Gateway::sendToClient($client_id, json_encode([
'type' => 'init',
'client_id' => $client_id,
'message' => ''
]));
}
/**
* 当客户端发来消息时触发
* @param int $client_id 连接id
* @param mixed $message 具体消息
*/
public static function onMessage($client_id, $message)
{
// 对数据进行json解码
$data = json_decode($message, JSON_UNESCAPED_UNICODE);
// 这里采用直接向指定用户ID发送数据
Gateway::sendToUid($message['toUid'],json_encode([
'type' => 'tidings',
'client_id' => $client_id,
'message' => $data['message']
]));
// 为了方便测试,同时将数据也发给自己,方便前端在控制台打印
Gateway::sendToClient($client_id,json_encode([
'type' => 'test',
'client_id' => $client_id,
'message' => $data['message']
]));
}
}
注意,每次修改Events.php文件,都需要重新启动GatewayWorker服务。在前端看看效果:
注意JSON.stringify()会对中文进行unicode编码,解决方式:https://developers.weixin.qq.com/community/develop/doc/0008ea2e650cb86cb987789cb51800。就是对呗编码成unicode的中文,用String()括住。代码如下:
对消息内容中文处理完毕后就可以push到list数组中,在template中进行for循环渲染。
这种方式的缺点是,如果用户不在线时,我需要将消息存至数据库时,我无法在Events.php文件中使用数据库模型(model层的数据模型)。这就使得我无法在onMessage函数中处理接收方用户不在线的情况。这迫使我选择了第二种方式。
将GatewayWorker/Applications/YourApp/Events.php的onMessage函数置空,记得重复GatewayWorker服务:
/**
* 当客户端发来消息时触发
* @param int $client_id 连接id
* @param mixed $message 具体消息
*/
public static function onMessage($client_id, $message)
{
}
在控制器Chat.php中编写函数sendToStore()用于实现向指定用户ID发送数据。代码如下:
/**
* 当前用户向某一商品所有者发起聊天
* @url /chat/send_to_store
* @HTTP POST
* @id 商品ID
*/
public function sendToStore(){
/**
* 注释开始
* 此处可选择由前端发送user_id用户ID过来,也可以采用token获取当前用户id
*/
// 根据Token获取uid
$uid = Token::getCurrentUid();
// 判断当前用户是否存在
UserService::isUserExist($uid);
/**
* 注释结束
* 此处可选择由前端发送user_id用户ID过来,也可以采用token获取当前用户id
*/
// 获取信息
$dataArr = input('post.');
$client_id = $dataArr['client_id']; // 发送方client_id
$receiver = $dataArr['receiver_uid']; // 接收方用户ID
$message = $dataArr['message']; // 发送消息内容
Gateway::$registerAddress = 'www.zwl.com:1238';
// 对方不在线则将消息存储起来,在线返回1,不在线返回0
$isOnline = Gateway::isUidOnline($receiver);
if(!$isOnline){
// 插入等待读取状态的消息
$chat = ChatModel::saveChat($uid,$receiver,$message,0);
} else {
// 插入已经被读取的消息
$chat = ChatModel::saveChat($uid,$receiver,$message,1);
}
//发送消息结构体
$send = json_encode([
'type' => 'tidings',
'client_id' => $client_id,
'message' => $message
],JSON_UNESCAPED_UNICODE);
// 向指定用户发送
Gateway::sendToUid($receiver,$send);
// 用于测试,同时给自己发送一份
Gateway::sendToClient($client_id,$send);
return json(new SuccessMessage(['msg' => '发送消息成功','data' => $receiver]),201);
}
重点关注:
前端对应代码如下:
至此,前端已经可以接收与发送消息了,就差对接收到的消息进行处理与展示部分没有继续写出来。后面会贴全部代码。现在回看GatewayWorker官方文档的几句话。同篇博客算是我对GatewayWorker的一点小理解。
-
{{item.text}}
发送
后端代码(我自己的后台中用了token认证):
goCheck();
$dataArr = $validate->getDataByRule(input('post.'));
$client_id = $dataArr['client_id'];
// 根据Token获取uid
$this->uid = Token::getCurrentUid();
// 判断当前用户是否存在
UserService::isUserExist($this->uid);
// 绑定
Gateway::bindUid($client_id,$this->uid);
// 获取未读状态消息
$chat = ChatModel::getChatAndChange($this->uid,1,10);
$result = array_map(function ($item) {
$temp = [
'msg_id' => $item['msg_id'],
'content' => $item['content'],
'is_self' => false,
'other' => $item['receiver_uid'],
'create_time' => $item['create_time']
];
if($item['uid'] == $this->uid) {
$temp['is_self'] = true;
}
return $temp;
},$chat);
return json(new SuccessMessage(['msg' => '绑定用户成功', 'data' => $result]),201);
}
/**
* 当前用户向某一商品所有者发起聊天
* @url /chat/send_to_store
* @HTTP POST
* @id 商品ID
*/
public function sendToStore(){
$validate = new ChatMessage();
$validate->goCheck();
// 根据Token获取uid
$uid = Token::getCurrentUid();
// 判断当前用户是否存在
UserService::isUserExist($uid);
// 获取信息
$dataArr = $validate->getDataByRule(input('post.'));
$client_id = $dataArr['client_id'];
$receiver = $dataArr['receiver_uid'];
$message = $dataArr['message'];
Gateway::$registerAddress = 'www.zwl.com:1238';
// 对方不在线则将消息存储起来
$isOnline = Gateway::isUidOnline($receiver);
if(!$isOnline){
// 插入等待接收状态的消息
$chat = ChatModel::saveChat($uid,$receiver,$message,0);
} else {
// 插入已经被接收的消息
$chat = ChatModel::saveChat($uid,$receiver,$message,1);
}
//发送消息
$send = json_encode([
'type' => 'tidings',
'client_id' => $client_id,
'message' => $message
],JSON_UNESCAPED_UNICODE);
Gateway::sendToUid($receiver,$send);
return json(new SuccessMessage(['msg' => '发送消息成功','data' => $receiver]),201);
}
}
msg = $params['msg'];
}
if(array_key_exists('data',$params)){
$this->data = $params['data'];
}
}
}
$uid, 'receiver_uid' => $receiver])->find();
if(!$isExist){
$chatListModel->save(['uid' => $uid, 'receiver_uid' => $receiver]);
}else {
$chatListModel->isUpdate()->save(['uid' => $uid, 'receiver_uid' => $receiver]);
}
$chat = self::create(['uid' => $uid, 'receiver_uid' => $receiver, 'content' => $content, 'status' => $status]);
} catch (\Exception $e) {
Db::rollback();
throw new Exception($e);
}
return $chat;
}
/**
* 获取未读消息并设置成已读取
*/
public static function getChatAndChange($id,$pages,$pageNum){
self::$uid = $id;
$chat = self::order('create_time desc')->where(['receiver_uid' => $id])->whereOr(['uid' => $id])->page($pages,$pageNum)->select()->toArray();
$result = array_map(function ($item) {
if($item['receiver_uid'] == self::$uid){
return ['msg_id' => $item['msg_id'], 'status' => 1];
}else{
return [];
}
},$chat);
$chatModel = new Chat();
$chatModel->isUpdate(true)->saveAll($result);
return $chat;
}
}