基于 Swoole 开发实时在线聊天室(十三):发送文本/表情消息
功能概述
发送消息支持多种格式,包括普通文本、表情、图片等,今天我们来介绍最基本的文本和表情消息(Emoji 表情本质上也是文本消息),发送文本消息需要在最下方文本输入框中输入文字,然后点击发送按钮发送消息内容:
发送表情(这里仅支持 Emoji 表情)消息则需要点击表情图标弹出选择框,然后点击选中某个表情,该表情会自动渲染到消息文本框,然后随文本消息一起发送:
前端组件
下面,我们先来看前端组件实现。
消息发送逻辑
在聊天室前端界面组件 resources/js/pages/Chat.vue
中,发送消息对应的底层 JavaScript 代码是这样的,点击发送按钮,会调用这个 submess
方法:
submess() {
// 判断发送信息是否为空
if (this.chatValue !== '') {
if (this.chatValue.length > 200) {
Alert({
content: '请输入100字以内'
});
return;
}
const msg = inHTMLData(this.chatValue); // 防止xss
const obj = {
username: this.userid,
src: this.src,
img: '',
msg,
roomid: this.roomid,
time: new Date(),
api_token: this.auth_token
};
// 传递消息信息
socket.emit('message', obj);
this.chatValue = '';
} else {
Alert({
content: '内容不能为空'
});
}
}
这里面会有一个基本的校验,比如消息内容不能为空,也不能超过100个字符,此外还会对输入信息做过滤处理,避免 XSS 攻击,以上所有流程处理完成后,会初始化消息对象,然后调用如下代码通过 WebSocket 通信发送消息对象:
socket.emit('message', obj);
发送完成后,清空文本框内容。
消息渲染逻辑
消息的渲染逻辑由页面嵌入的子组件 Message
通过数据双向绑定实现:
<Message
v-for="obj in getInfos"
:key="obj._id"
:is-self="obj.userid === userid"
:name="obj.username"
:head="obj.src"
:msg="obj.msg"
:img="obj.img"
:mytime="obj.time"
:container="container"
></Message>
注意到这里我们将 obj.username === userid
替换成了 obj.userid === userid
,因为原来的 VueChat 实现中 userid
和 username
是等价的,而我们这里 userid
与 email
等价,is-self
属性用于在渲染消息时区分是自己发的还是别人发的(自己发的位于右侧,别人发的位于左侧)。
Emoji 表情组件
Emoji 表情选择框对应的实现如下:
<div class="fun-li emoji">
<i class="icon iconfont icon-emoji"></i>
<div class="emoji-content" v-show="getEmoji">
<div class="emoji-tabs">
<div class="emoji-container" ref="emoji">
<div class="emoji-block" :style="{width: Math.ceil(emoji.people.length / 5) * 48 + 'px'}">
<span v-for="(item, index) in emoji.people" :key="index">{{item}}</span>
</div>
<div class="emoji-block" :style="{width: Math.ceil(emoji.nature.length / 5) * 48 + 'px'}">
<span v-for="(item, index) in emoji.nature" :key="index">{{item}}</span>
</div>
<div class="emoji-block" :style="{width: Math.ceil(emoji.items.length / 5) * 48 + 'px'}">
<span v-for="(item, index) in emoji.items" :key="index">{{item}}</span>
</div>
<div class="emoji-block" :style="{width: Math.ceil(emoji.place.length / 5) * 48 + 'px'}">
<span v-for="(item, index) in emoji.place" :key="index">{{item}}</span>
</div>
<div class="emoji-block" :style="{width: Math.ceil(emoji.single.length / 5) * 48 + 'px'}">
<span v-for="(item, index) in emoji.single" :key="index">{{item}}</span>
</div>
</div>
<div class="tab">
<!-- <a href="#hot"><span>热门</span></a>
<a href="#people"><span>人物</span></a> -->
</div>
</div>
</div>
</div>
具体渲染逻辑不是本项目讨论的重点,感兴趣的同学可以自己去翻阅源码。
运行 npm run dev
重新编译前端资源使修改生效。
后端实现
编写 API 资源类
由于消息渲染组件 Message
需要传入消息数据才能进行渲染,而前端消息对象属性和后端 messages
表不能一一对应,所以我们可以编写一个 API 资源类做两者之间数据结构的自动转化。
在此之前,我们先在 Message
模型类中定义其与用户的关联关系:
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Message extends Model
{
public $timestamps = false;
public function user()
{
return $this->belongsTo(User::class);
}
}
然后通过如下 Artisan 命令创建 Message
模型类对应的 API 资源类:
php artisan make:resource MessageResource
该命令生成的对应路径是 app/Http/Resources/MessageResource.php
,编写转化方法 toArray
如下:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class MessageResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array
*/
public function toArray($request)
{
return [
'id' => $this->id,
'userid' => $this->user->email,
'username' => $this->user->name,
'src' => $this->user->avatar,
'msg' => $this->msg,
'img' => $this->img,
'roomid' => $this->room_id,
'time' => $this->created_at
];
}
}
我们转化的目标结构必须与前端消息对象属性字段名保持一致,这样才可以在前端正常渲染后端返回的消息数据。
修改历史聊天记录接口
接下来,我们就可以在之前编写的历史聊天记录接口中应用上述 MessageResource
做返回数据的 JSON 结构自动转化了,打开 app/Http/Controllers/MessageController.php
,修改 history
方法如下:
use App\Http\Resources\MessageResource;
/**
* 获取历史聊天记录
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function history(Request $request)
{
...
// 分页查询消息
$messages = Message::where('room_id', $roomId)->skip($skip)->take($limit)->orderBy('created_at', 'asc')->get();
$messagesData = [];
if ($messages) {
// 基于 API 资源类做 JSON 数据结构的自动转化
$messagesData = MessageResource::collection($messages);
}
// 返回响应信息
return response()->json([
'data' => [
'errno' => 0,
'data' => $messagesData,
'total' => $messageTotal,
'current' => $current
]
]);
}
注:关于 API 资源类的实现原理可以参考相应文档,我们这里只是使用,不做深入介绍。
此时,我们在 messages
表中填充一些测试数据:
重启 Swoole HTTP 服务器,就可以在前端聊天室房间 1 中看到渲染的历史聊天记录了:
你可以通过上下滚动查看所有历史消息。
实现消息发送和广播功能
最后,我们基于 Websocket 实现消息发送和广播功能。
打开后端 Websocket 路由文件 routes/websocket.php
,编写接收消息并广播给聊天室内所有在线用户的实现代码如下:
use App\Message;
use Carbon\Carbon;
WebsocketProxy::on('message', function (WebSocket $websocket, $data) {
if (!empty($data['api_token']) && ($user = User::where('api_token', $data['api_token'])->first())) {
// 获取消息内容
$msg = $data['msg'];
$roomId = intval($data['roomid']);
$time = $data['time'];
// 消息内容或房间号不能为空
if(empty($msg) || empty($roomId)) {
return;
}
// 记录日志
Log::info($user->name . '在房间' . $roomId . '中发布消息: ' . $msg);
// 将消息保存到数据库
$message = new Message();
$message->user_id = $user->id;
$message->room_id = $roomId;
$message->msg = $msg;
$message->img = ''; // 图片字段暂时留空
$message->created_at = Carbon::now();
$message->save();
// 将消息广播给房间内所有用户
$room = Count::$ROOMLIST[$roomId];
$messageData = [
'userid' => $user->email,
'username' => $user->name,
'src' => $user->avatar,
'msg' => $msg,
'img' => '',
'roomid' => $roomId,
'time' => $time
];
$websocket->to($room)->emit('message', $messageData);
// 更新所有用户本房间未读消息数
$userIds = Redis::hgetall('socket_id');
foreach ($userIds as $userId => $socketId) {
// 更新每个用户未读消息数并将其发送给对应在线用户
$result = Count::where('user_id', $userId)->where('room_id', $roomId)->first();
if ($result) {
$result->count += 1;
$result->save();
$rooms[$room] = $result->count;
} else {
// 如果某个用户未读消息数记录不存在,则初始化它
$count = new Count();
$count->user_id = $user->id;
$count->room_id = $roomId;
$count->count = 1;
$count->save();
$rooms[$room] = 1;
}
$websocket->to($socketId)->emit('count', $rooms);
}
} else {
$websocket->emit('login', '登录后才能进入聊天室');
}
});
实现逻辑其实很简单,在确保用户已认证、房间号和消息内容不为空的前提下,获取到客户端发送的文本消息(含 Emoji 表情)后,将其保存到 messages
表,然后将其广播给房间内的所有用户即可,这里我们并没有使用 MessageResource
做数据结构的自动转化,原因是 WebSocket 服务器中拿不到 Illuminate\Http\Request
实例,这会导致 JSON 序列化过程报错。
注:图片发送也是基于这个消息通道,我们下一篇来实现相应的处理代码。
最后,我们还更新了用户未读消息数,将其存储到数据库以及发送给所有在线用户。
测试实时聊天功能
至此,我们就已经完成了所有编码工作,重新启动 Swoole 服务器:
bin/laravels restart
在 Chrome 和 Firefox 浏览器中分别登录不同用户并进入同一个聊天室房间,就可以开始在线实时聊天了:
由于是基于 Websocket 通信,页面不需要刷新即可即时获取对方发送的消息。
下篇教程,我们来介绍图片消息发送的实现。本项目源码已提交到 Github:https://github.com/nonfu/webchat,欢迎 Star。
No Comments