基于 Laravel + Swoole + Vue 搭建实时在线聊天室(十三):发送文本/表情消息

功能概述

发送消息支持多种格式,包括普通文本、表情、图片等,今天我们来介绍最基本的文本和表情消息(Emoji 表情本质上也是文本消息),发送文本消息需要在最下方文本输入框中输入文字,然后点击发送按钮发送消息内容:

聊天室界面

发送表情(这里仅支持 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 实现中 useridusername 是等价的,而我们这里 useridemail 等价,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。

上一篇: 基于 Laravel + Swoole + Vue 搭建实时在线聊天室(十二):加入和退出聊天室房间功能实现

下一篇: 基于 Laravel + Swoole + Vue 搭建实时在线聊天室(十四):发送图片消息