盒子im官方文档

👋 欢迎来到盒子IM,本文档限时免费开放中…

    • 本文档是开源项目盒子IM的详细介绍文档,涵盖环境搭建、功能设计、原理实现和后期部署等内容
    • 本文档内容主要是针对开源版,但同样适用于商业版
    • 如果文档中有疏漏或者其他建议,请与作者联系

00-前言

今天是2024.08.04,盒子IM3.0版本今天正式上线,过来更新一波文档。

自从去年2.0版本上线以来,盒子IM的关注度有了明显的增长,star数量仅今年就已经增长了1k+,甚至有几位抖音和公众号博主自发地对盒子进行了介绍和推广,让我收获了满满的成就感。

截止到今年3月份之前,本着为爱发电的精神,盒子IM一直都是纯开源且免费的。但是如果一直“发电”,终有被耗干的一天。所以今年3月份开始推出了第一个付费功能-单人音视频通话,6月底又推出了多人音音视频通话,未来还会推出更为完善的商业版。

不过尽管盒子IM未来会一定程度走商业化的路线,但是开源版本的功能也是会一直更新的,并且已经开源的功能也会一直开源下去。

这一年以来,盒子IM一直保持了高强度的更新,同时也几乎耗尽了我几乎所有的业余时间。很多小伙伴的私信和群里的消息,可能没有办法一一回复,还请见谅。

 

01-基础介绍

1. 特点

  • 盒子IM是一个仿微信实现的开源聊天软件,支持内网部署,不依赖任何收费SDK或组件
  • 支持web端和移动端同时在线以及消息同步
  • 后端服务支持集群化部署,具有良好的横向扩展能力
  • 消息推送功能已进行SDK封装,可快速接入企业项目

2. 合适人群

如果您是以下人群之一,那么盒子IM将会非常适合您:

  • 企业中的项目需要开发IM模块,希望快速整合盒子IM的部分功能
  • 对IM系统比较感兴趣,想学习如何独立编写一个优雅且高性能的IM系统

3. 前置技能

尽管作者已经十分努力的降低盒子IM的使用门槛,但是在学习盒子IM前,还是需要您已经掌握以下技能:

后端:Springboot、Mybatis-plus、Netty、Mysql、Redis

前端:Vue、Uniapp

4. 功能展示

d685cf637a20250831133927

02-本地环境搭建

1. 基础环境安装

1.1. 安装GIT、JDK17、IDEA、Maven、HbuilderX(略)

1.2. 安装Mysql8.0

下载地址:https://dev.mysql.com/downloads/installer/

选择8.0.39版本并下载,下载后双击安装即可

d2b5ca33bd20250831134006

1.3. 安装Redis

下载地址:Releases · microsoftarchive/redis · GitHub

ps:微软官方维护的支持 Windows平台的 Redis 安装包只有 Redis2.0 和 Redis3.0 的部分版本,这里我们下载最后更新的3.0.504即可

d2b5ca33bd20250831134024

 

下载后双击安装即可

1.4. 安装MinIO

官网下载minio.exe:

https://www.minio.org.cn/

在minio.exe同级目录打开命令行,执行启动:

.\minio.exe server C:\data\minio

启动成功页面如下,默认密码为minioadmin/minioadmin

d2b5ca33bd20250831134041

1.5. 安装node18

下载地址:https://nodejs.org/download/release/v18.19.0/

选择node-v18.19.0-x64.msi 下载,下载后双击安装即可

d2b5ca33bd20250831134052

2. 本地环境启动

2.1. 启动后端

  • 通过git下载代码:git clone https://gitee.com/bluexsx/box-im.git
  • 将项目导入IDEA,并修改maven配置和JDK17
  • 打开im-platform中的application-dev.yml文件,将数据库和minio的账号以及密码修改为自己的
  • 生成数据库,数据库脚本在/db/im-platform目录
  • 通过IDEA启动im-platform服务和im-server服务

2.2. 启动web端

进入im-web目录,打开命令行窗口执行以下命令

npm install
npm run serve

访问地址: http://localhost:8080/

2.3. 启动桌面端(商业版)

进入im-web目录,打开命令行窗口执行以下命令

npm install
npm run electron:serve

2.4. 启动uniapp端-h5

2.4.1. 安装依赖

进入im-uniapp目录,安装依赖包

npm install
2.4.2. 启动

将im-uniapp目录导入到hbuilderx中,选择选中项目,通过”运行”->”运行到浏览器”->”Chrome”启动

访问地址: http://localhost:5173/h5/#

2.5. 启动uniapp端-微信小程序

2.5.1. 安装依赖

进入im-uniapp目录,安装依赖包

npm install
2.5.2. 修改配置

将.env.js里面的127.0.0.1换成电脑的IP,可以是内网IP

2.5.3. 申请小程序

如果您还没appid,请前往微信公众平台注册小程序,注册成功后,在小程序后台可获得您的appid

d2b5ca33bd20250831134105

将appid配置到项目的manifest.json中:

d2b5ca33bd20250831134113

2.5.4. 微信开发者工具

下载并安装微信开发者工具,安装后打开,选择菜单:设置->代理设置,选择使用系统代理:

d2b5ca33bd20250831134119

切换到安全选项,把服务端口打开:

d2b5ca33bd20250831134129

然后回到builder选择菜单:运行->运行到终端->运行设置,设置安装的微信开发者工具路径

d2b5ca33bd20250831134139

2.5.5. 启动

选择选中项目,通过”运行”->”开发环境-微信小程序”启动,开发者工具将自行启动:

d2b5ca33bd20250831134146

2.6. 启动uniapp端-安卓APP

2.6.1. 安装依赖

进入im-uniapp目录,安装依赖包

npm install
2.6.2. 修改配置

将.env.js里面的127.0.0.1换成电脑的IP,可以是内网IP

2.6.3. 连接手机

用USB线将安卓手机连接到电脑,并在”开发者选中”中打开“USB调试选项”。

2.6.4. 启动

在hubuildx中,选择菜单:运行->运行到手机或模拟器->运行到andriod APP基座:

d2b5ca33bd20250831134155

点击“运行”后,留意手机的安装提示,选择“继续安装”,等待APP启动即可

2.7. 启动uniapp端-IOS APP

流程有点繁琐,可以看看网上这篇博客,作者亲测可正常启动:

https://blog.csdn.net/m0_66504310/article/details/129842809

03-数据库和架构设计

1. 技术选型

后端框架:Springboot、Netty、Mybatis-plus、Jwt

前端框架:Vue、Uniapp、Webrtc

2. 开源组件

组件

是否必须

主要作用

mysql

存储用户、群聊、消息等数据

redis

数据缓存、消息队列

minio

存储文件,如头像、图片、语音、文件

coturn

音视频通话时用于协助打洞和转发

3. 数据库设计

表名

表名(中文)

创建时机

说明

im_user

用户表

用户注册

记录用户基本信息

im_friend

好友表

添加好友

记录好友关系以及好友的昵称、头像

im_private_message

私聊消息表

发送私聊消息

记录与好友之间的聊天消息

im_group

群组表

创建群组

记录群组信息

im_group_member

群组成员表

邀请好友进群聊

记录群组中的成员信息

im_group_message

群聊消息表

发送群聊消息

记录与群聊中的聊天消息

im_sensitive_word

敏感词

后台创建

发送消息时如果匹配敏感词会变成’**’

4. 项目结构

模块

功能

可运行

说明

im-platform

业务平台服务

负责接收前端的http请求,处理盒子IM的所有业务

im-server

消息推送服务

仅实现消息推送,不参与任何业务。其他服务(im-platform)需通过im-client与im-server通信

im-client

消息推送sdk

集成到其他服务(im-platform),使其能够与im-server通信,实现消息推送

im-common

公共包

被所有后端模块引用

im-web

web页面

web端页面

im-uniapp

app页面

移动端页面,包括app、h5、微信小程序

以下是简化后的消息推送流程图,可以大致体现出每个组件之间的关系:

5ed225ea2120250831134446

04-用户登录和鉴权

  1. 1. 方案选择

    • 方案一(session):整合Spring security,同时将session缓存到redis实现集群化管理
    • 方案二(token): 通过jwt生成token,每次请求都携带此token,后端通过拦截器解析token

    两种方案各有优缺点,盒子IM早期采用的是方案一,但是遇到了两个问题:

    • 同一个浏览器无法同时登陆多个账号:第2个账号登录时,会自动顶掉第一个账号的session
    • ws连接校验不容易实现: session是通过http的header中的sessionId实现,因此与http协议是强捆绑关系,并不支持ws协议

    所以最后改用了方案二,也就是使用token的方式

    2. 交互流程

    1.  

      2.1. 获取token

      d2b5ca33bd20250831141340

      主要步骤代码:

      步骤
      类名
      方法
      1,2,3,4
      UserServiceImpl
      login

      2.2. token校验

      1. token的校验通过拦截器实现,对所有接口(除登录等个别接口)进行统一拦截校验:

        d2b5ca33bd20250831141355

        图中只是已请求用户消息接口为例,其他请求亦是如此。主要步骤代码:

        步骤
        类名
        方法
        2
        AuthInterceptor
        preHandle

       

       

      2.3. 刷新token

      accessToken的过期时间只有半个小时,而refreshToken7天才会过期。

      当accessToken过期时,需要调用/refreshToken接口,换取新的token。

    主要步骤代码:
    步骤
    类名
    方法
    1,2,3,4
    UserServiceImpl
    refreshToken
     

    3. 用户无感知刷新token

    上一小节中介绍了当token过期时如何去换取新的token,那么问题来了,程序应该如何感知到token已经过期了呢?

    • 方案一:前端用定时器对token的过期时间进行检测,如果快过期了,则进行token刷新
    • 方案二:通过前端的http拦截器实现,用户发请求时被此拦截器捕获,此时如果发现token过期时,进行刷新token,然后重新发起请求

    显然方案二更为优雅,所以盒子IM采用是方案二。

    主要实现代码(前端):

    目录
    文件名
    方法
    im-web/src/api/
    httpRequest.js
    所有
    im-uniapp/common/
    request.js
    所有

05-接入消息推送(实现聊天功能)

1. 前言

前面已经介绍过,消息推送功能由im-server实现,并且已经封装了sdk(im-client)。

现在我们把消息推送功能看成一个黑盒,暂时不深究它的具体实现,本小节的目标是在im-platform中集成im-client,集成之后,im-platform只需要调用im-client的api,就能够把消息推送到前端

同样,如果您企业中的项目需要集成消息推送功能,也是同样的接入方式,只是将im-platform替换成您的服务

2. im-client相关API介绍

2.1. API列表

API

说明

sendPrivateMessage

推送私聊消息

sendGroupMessage

推送群聊消息

sendSystemMessage

推送系统消息

isOnline

用户是否在线

2.2. 私聊消息API参数

参数

类型

必填

说明

sender

IMUserInfo

发送方的信息,包括发送方的id和终端类型

recvId

Long

接收方id

recvTerminals

List<Integer>

接收者的终端类型,默认全部

sendToSelf

Boolean

是否发送给自己的其他终端,默认true

sendResult

Boolean

是否需要回推发送结果,默认true

data

泛型

消息内容,类型由应用层定义

2.3. 群聊消息API参数

参数

类型

必填

说明

sender

IMUserInfo

发送方的信息,包括发送方的id和终端类型

recvIds

List<Long>

接收者id列表(一般填群成员id,为空则不会推送)

recvTerminals

List<Integer>

接收者终端类型,默认所有类型

sendToSelf

Boolean

是否需要同时推送给自己的其他终端,默认true

sendResult

Boolean

是否需要回推发送结果,默认true

data

泛型

消息内容,类型由应用层定义

2.4. 系统消息API参数

参数

类型

必填

说明

recvIds

List<Long>

接收者id列表(一般填群成员id,为空则不会推送)

recvTerminals

List<Integer>

接收者终端类型,默认所有类型

sendResult

Boolean

是否需要回推发送结果,默认true

data

泛型

消息内容,类型由应用层定义

3. 快速接入

3.1. 后端接入

3.1.1. 在im-platform的pom.xml中引入im-client依赖
<dependency>
  <groupId>com.bx</groupId>
  <artifactId>im-client</artifactId>
  <version>3.0.0</version>
</dependency>

注:上面的依赖包目前并没有上传到公开仓库,需要自行编译并上传到自己的maven私仓,或者直接把代码整合到自己的项目

3.1.2. 消息推送使用了redis充当MQ进行通信,所以要在application.yml中配置redis地址
spring:
  redis:
    host: 127.0.0.1
    port: 6379
3.1.3. 调用IMClient的API进行消息推送

推送私聊消息代码样例(群聊和系统消息也是类似的方式,不再赘述)

@Autowired
private IMClient imClient;

public void sendMessage(){
    IMPrivateMessage<PrivateMessageVO> sendMessage = new IMPrivateMessage<>();
    // 发送方的id和终端类型
    sendMessage.setSender(new IMUserInfo(1L, IMTerminalType.APP.code()));
    // 对方的id
    sendMessage.setRecvId(2L);
    // 推送给对方所有终端
    sendMessage.setRecvTerminals(IMTerminalType.codes());
    // 同时推送给自己的其他类型终端
    sendMessage.setSendToSelf(true);
    // 需要回推发送结果,将在IMListener接收发送结果
    sendMessage.setSendResult(true);
    // 推送的内容
    sendMessage.setData(msgInfo);
    // 推送消息
    imClient.sendPrivateMessage(sendMessage);
}
3.1.4. 监听发送结果

如果需要监听消息推送的结果,需要以下两步:

  • 编写消息监听类,实现MessageListener,并加上@IMListener
  • 发送消息时指定sendResult字段为true

监听器类代码样例:

@Slf4j
@IMListener(type = IMListenerType.PRIVATE_MESSAGE)
public class PrivateMessageListener implements MessageListener {

    @Override
    public void process(IMSendResult<PrivateMessageVO> result){
        PrivateMessageVO messageInfo = result.getData();
        if(result.getCode().equals(IMSendCode.SUCCESS.code())){
            log.info("消息发送成功,消息id:{},发送者:{},接收者:{},终端:{}",messageInfo.getId(),result.getSender().getId(),result.getReceiver().getId(),result.getReceiver().getTerminal());
        }
    }
}

3.2. 前端接入

3.2.1. 将wssocket.js放到im-web中的/api目录

📎wssocket.js

3.2.2. 接收消息

前端代码样例:

import * as wsApi from './api/wssocket';

let wsUrl = 'ws://localhost:8878/im'
let token = "您的token";
wsApi.connect(wsUrl,token);
wsApi.onConnect(() => {
  // 连接打开
  console.log("连接成功");
});
wsApi.onMessage((cmd,msgInfo) => {
  if (cmd == 2) {
    // 异地登录,强制下线
    console.log("您已在其他地方登陆,将被强制下线");
  } else if (cmd == 3) {
    // 私聊消息
    console.log(msgInfo);
  } else if (cmd == 4) {
    // 群聊消息
    console.log(msgInfo);
  }else if (cmd == 5){
    // 系统消息
    console.log(msgInfo);
  }

})
wsApi.onClose((e) => {
  if (e.code != 3000) {
    console.log("意外断开,进行重连");
    wsApi.reconnect(wsUrl,token);
  }else{
    console.log("主动断开");
  }
});

4. 实现聊天功能(私聊+群聊)

当我们的im-platform集成im-client之后,实现私聊和群聊功能就会变得非常简单,通过简单的crud以及调用api即可实现。

以下是聊天相关的业务代码(代码比较简单,小伙伴可以自行阅读):

类名

方法

说明

PrivateMessageServiceImpl

sendMessage

推送私聊消息

GroupMessageServiceImpl

sendMessage

推送群聊消息

PrivateMessageListener

process

监听私聊消息结果,消息发送成功后,状态修改为送达

GroupMessageListener

process

监听群聊消息结果,暂时没实际作用

 

06-消息推送底层分析

1. 前言

上一章节我们已经通过im-client的sendPrivateMessage接口轻松实现了聊天功能,但是现在这个接口的内部实现对我们来说还是一个未知的”黑盒”。

在这一章节中,我们就来打开这个”黑盒”,看看调用sendPrivateMessage之后,具体干了什么事情。

本章节案例以私聊为主,但群聊的流程与私聊基本一致,主要的区别是群聊是发送给多个用户

2. 消息推送

2.1. 交互流程

用户发送私聊消息完整流程:

d2b5ca33bd20250831143600

当消息的发送者和接收者连的不是同一个im-server时,消息是无法直接推送的,所以我们需要设计出能够支持跨节点推送的方案:

  • 利用了redis的list类型实现消息推送,其中key为im:message:private:${serverid},每个key的数据可以看做一个队列,每个im-server根据自身的id只消费属于自己的消息队列
  • 用户发起ws连接时,redis会记录每个用户所连接的serverid
  • 用户发送消息时,im-client将根据接收方的serverid,决定将消息推向响应的队列

图中2~5步都是调用sendPrivateMessage之后的流程,整个流程涉及3个组件:

  • im-client:客户端部分,负责将消息推送至redis,对应图中橙色部分
  • im-server: 服务器部分,负责从redis拉取消息,并通过ws推送给用户,对应图中红色部分
  • redis: 中间角色,充当消息中间件,负责将消息从im-client转发到im-server

2.2. 客户端部分(im-client)

核心流程逻辑说明:

  • 通过redis获取接收方id所绑定的serverid(每个im-server都会生成一个唯一的id)
  • 当serverid存在表示用户在线,反之则说明用户离线
  • 如果用户在线,则将消息上传到redis,等待im-server拉取。注意上传消息的key是通过serverid生成的,也就是说,不同的im-server使用不同的消息队列,这么做是为了支持im-server集群化部署。
  • 如果用户离线,则向应用层回复消息发送失败状态(消息推送结果监听机制下面有介绍)

相关代码

类名

方法

作用

IMSender

sendPrivateMessage

推送私聊消息

IMSender

sendGroupMessage

推送群聊消息

2.3. 服务器部分(im-server)

核心流程逻辑:

  • 循环从redis中拉取私聊消息,然后将消息交由PrivateMessageProcessor处理
  • 通过接收方id取出对方的channel,通过channel将消息推送给用户
  • 无论推送成功还是失败,都会回推消息推送结果

相关代码:

类名

方法

作用

PullPrivateMessageTask

pullMessage

循环从redis中拉取私聊消息

PrivateMessageProcessor

process

处理拉取到的私聊消息

GroupMessageProcessor

process

处理拉取到的群聊消息

3. 消息接收

上一小节中,im-server已经通过ws向前端推送消息了,那么前端是如何跟im-server交互,并接收到消息呢?

3.1. ws交互流程

d2b5ca33bd20250831143637

相关代码(后端):

步骤

方法

说明

1

IMChannelHandler

handlerAdded

用户连接事件,这里只打印日志

2

LoginProcessor

process

登录处理,将用户id和serverId绑定

3

HeartbeatProcessor

process

心跳处理,绑定关系续命

3

IMChannelHandler

userEventTriggered

心跳超时事件,关闭channel

4,5

PrivateMessageProcessor

process

私聊消息发送处理,前面已讲解

4,5

GroupMessageProcessor

process

群聊消息发送处理,前面已讲解

6

IMChannelHandler

handlerRemoved

连接断开事件,移除channel,解除绑定

相关代码(前端):

文件

方法

说明

wssocket.js

全部

前端ws封装

home.vue

init

ws应用初始化

4. 推送结果监听机制

4.1. 方案探讨

我们思考一个问题,在im-platform调用了sendPrivateMessage之后,是否一定能将成功消息推送给用户呢?

答案是否定的,例如对方当前不在线,消息即使投递到了im-server也会由于缺少对方的channel而无法推送。

作为一个相对完善的消息推送组件,应该能够提供一种机制:不管消息是否成功发送,都应该告知调用消息推送是否成功。

一般来说,实现有两种方案:

实现方式

同步方式

异步方式

实现思路

接口同步返回结果,发送成功返回true,否则返回false

消息发送后,回推结果消息,将发送结果异步投递到业务层的监听器。

优点

调用简单

调用复杂

缺点

性能较低,实现比较复杂

性能好,同时也易于实现

4.2. 实现流程

盒子IM目前采用的的是异步方式,回推消息结果流程其实与推送消息类似,只是流程”反”过来:

d2b5ca33bd20250831143656

相关代码:

步骤

方法

说明

1

PrivateMessageProcessor

sendResult

向redis回推私聊消息送结果

1

GroupMessageProcessor

sendResult

向redis回推群聊消息送结果

2

PrivateMessageResultResultTask

pullMessage

从redis中定时拉取私聊消息送结果

2

GroupMessageResultResultTask

pullMessage

从redis中定时拉取群聊消息送结果

3

MessageListenerMulticaster

multicast

向业务层的监听器广播消息送结果

07-离线消息和已读未读显示

消息状态消息状态类型维护消息状态是实现离线消息和已读未读功能的关键,参考枚举类型MessageStatus,消息共有4个状态

状态
状态值
状态变更时机
未送达(PENDING)
0
消息入库后默认状态就PENDING
已送达(DELIVERED)
1
消息成功推送到客户端后
已撤回(RECALL)
2
用户撤回消息后
已读(READED)
3
用户看到消息后(点击聊天列表)

消息状态记录私聊消息和群聊消息的状态记录采用的是不同的方案。私聊消息的记录状态相对比较简单,消息表有status字段,直接存数据库中即可。群聊消息可能会存在部分用户已读,部分用户未读的情况,无法通过单一字段记录。考虑到写扩散造成的数据库IO读写压力,这里我选择了通过redis去记录:KEY: im:readed:group:position:{groupId}:{userId}VALUE: 该用户在该群已读的最大消息id记录了用户在某个群的已读的最大消息id后,只要是小于或等于这个id的消息都是已读消息,否则就是未读消息,而不必记录每条消息是否已读。离线消息拉取离线消息离线消息的实现并不复杂,核心思路是前端通过localstorge缓存了已接收消息的最大id,在用户登录后,主动向服务器拉取比这个消息id大的所有消息。前端相关代码:

文件
方法
作用
home.vue
loadPrivateOfflineMessage
向服务器拉取私聊离线消息
home.vue
loadGroupOfflineMessage
向服务器拉取群聊离线消息

后端相关代码:

方法
说明
PrivateMessageServiceImpl
loadOfflineMessage
拉取最近3个月的离线私聊消息
GroupMessageServiceImpl
loadOfflineMessage
拉取最近3个月的离线群聊消息

离线消息被拉取后,消息状态修改为已送达(DELIVERED)状态。已读未读显示已读状态变更用户在页面进行以下之一操作,都会将当前会话的消息变更为已读状态:点击了某个会话列表,进入会话聊天窗口鼠标在聊天窗口滑动回复了对方消息前端相关代码:

文件
方法
作用
ChatBox.vue
readedMessage
向服务器请求变更消息状态为已读状态

后端相关代码:

方法
说明
PrivateMessageServiceImpl
readedMessage
将会话中的所有私聊消息都更改为已读状态

离线时状态变更问题目前消息内容和状态都是缓存到前端的localstorage,这样也带来了一个问题:web端接收到消息后,用户没看到消息就退出了,随后用户在app端登录并看到了消息,此时消息变更为已读状态,但是web端的缓存依然记录为未读状态。为了解决这个问题,在前端加入主动更新状态机制:在用户打开聊天窗口时,主动从服务器拉取最大已读消息id,并将前端缓存中小于这个id的消息更新为已读状态前端相关代码:

文件
方法
作用
ChatBox.vue
loadReaded
向服务器拉取已读最大消息

后端相关代码:

方法
说明
PrivateMessageServiceImpl
getMaxReadedId
获取会话中已读消息的最大id

回执消息(群聊已读未读)方案选择群聊的消息默认不会显示已读人数,只有当用户手动点亮“消息回执”图标后,发送的消息才会显示已读人数。为什么不像私聊消息那样,每条消息都显示已读人数呢?假如在一个500人的群发了100条回执消息,群里的用户每看到一条消息,都会自动发送一条回执信令,那么消息的扩散量就是100*500×500=2500w。显然如果存在过量的回执消息,对系统的消耗是相当大的。最终是参考了企业微信的设计,通过让用户手动开启的方式,可以明显降低回执消息的数量。技术实现群组中存在多个用户,很可能会存在A用户已读,B用户未读的情况,所以群消息的状态,不能像私聊消息一样,简单的用一个消息状态字段存储。方案一:存到数据库中,im_group_message表增加一个readed_user_ids字段,将已读的用户id全部记录到数据到里面。方案二:存到redis中,使用HashMap结构。大KEY: im:readed:group:position:{groupId}, 小KEY: {userId}, 值: 已读的最大消息id根据前面讨论的消息扩散情况,如果采用方案一,每条消息将会对数据库进行500次更新操作,可能会对数据库造成一定压力,所以我们选择了性能更强劲的redis去记录回执消息的已读状态,即方案二。在方案二中,为每个群聊分配一个大KEY,该KEY记录了一个群聊中所有用户的已读消息的位置。假如有一个4人小群,redis记录该群的数据如下:

用户名
用户id
最大已读消息id
张三
2
120
李四
3
200
王五
4
180
老六
5
170

当用户点击了一条id为165消息,想查看已读用户列表时,服务器只需取出这个大KEY的数据,筛出最大已读消息id大于等于165的用户列表,即“李四”、“王五”、“老六”相关代码:

方法
说明
GroupMessageServiceImpl
readedMessage
记录用户在群里已读消息位置
GroupMessageServiceImpl
findReadedUsers
获取某条消息的已读成员列表

08-文件、图片、语音消息

1. 文件上传

1.1. 整合minio

图片、视频、语音、文档本质上都是文件,要实现文件消息的推送,首先我们需要一个文件存储服务器。盒子使用Minio作为文件存储服务。

整合Minio并不复杂,基本上遵循三步走战略即可:导包、配置、调接口,网上也有相当多案例和资料,这里不再重复讲述。

盒子内部对Minio的api作了简单的进一步封装:

方法

说明

MinioUtil

bucketExists

查看存储bucket是否存在

MinioUtil

makeBucket

创建存储bucket

MinioUtil

setBucketPublic

设置bucket权限为public

MinioUtil

upload

文件上传

MinioUtil

remove

文件删除

1.2. 文件上传

文件上传编写了两个接口:

方法

说明

FileService

uploadFile

上传普通文件

FileService

uploadImage

上传图片同时返回原图和缩略图

注:除了在全屏展示大图时使用原图,前端其他所有地方都是展示缩略图

1.3. 极速秒传

小伙伴们是否体验过百度云的极速秒传功能,几G大的文件瞬间就上传完成了,神奇的不得了。

其实实现起来也非常简单,只需要在上传前对文件的内容生成md5值,后端拿到这个md5去数据库(上传文件后需要入库)查询是否已经存在相同的文件,如果已存在,则直接返回文件原来的url,然后客户端给文件打上”极速秒传”标签。

不过这个功能还是很有实际意义的,能够有效减少重复文件带来的带宽和服务器磁盘压力。

盒子目前未实现这块功能,建议小伙伴们可以自行实现

2. 文件、图片、语音消息

我们之前已经实现了发送文字消息接口,现在文件上传接口也有了,实现文件消息就简单了。

我们可以仿照文字消息接口,为文件、图片、语音消息分别编写单独的消息接口。

但盒子采用了更简便的方式:

直接复用原先编写好的的文字消息接口,将文件、图片、语音的url以及其他信息以json的格式存储到content字段中。

文件消息流程如下:

816949941d20250831143954

由于不同类型的文件展示要求不同,content中包含的内容也不一样:

类型

content字段

说明

文件

name

文件名

size

文件大小(字节)

url

文件url

图片

originUrl

原图(大图展示时显示)

thumbUrl

缩略图(默认展示)

语音

duration

语音时长(秒)

url

语音文件url

3. 语音采集

前端的语音采集使用了开源插件js-audio-recorder,感兴趣的小伙伴们可以自行去了解。

前端相关代码:

文件

方法

功能

ChatVoice.vue

全部

录音采集

09-消息可靠性、顺序性和唯一性

1. 前言

相信有不少小伙伴们都在工作中使用过MQ进行消息异步推送了,在学习任何一款开源MQ过程中,都会有一个绕不开的话题,消息的可靠性、顺序性,还有唯一性如何保证?

  • 可靠性:只要消息推送接口调用成功,用户最终一定能够收到消息。
  • 顺序性:保证用户收到的消息跟推送时是一致的,不会发生乱序。
  • 唯一性:也称幂等性。保证用户不会收到重复的消息。

盒子IM作为一款优秀的开源产品(自卖自夸),当然也是要支持这些特性。

2. 可靠性

dfa94eab1420250831144059

从图中可以看到,一条消息从发送方到接收方,需要经过5个环节。

如果步骤1,2失败,接口会抛出异常,用户得知消息发送失败,不属于消息丢失。

如果步骤3失败,一般是redis挂了导致,等redis重启后,会继续从原队列中拉取消息,消息不会丢失。

如果步骤4失败,有两种可能性:

1.用户掉线导致,此时前端的maxId不会更新,待用户重新登陆,或者断线重连成功时,会根据maxId重新拉取之前失败的消息。

2.im-server宕机导致,此时用户的ws也会断开,并不断地尝试重连。当im-server起来后,用户ws会重连成功,然后会根据maxId重新拉取未收到的消息。

如果步骤5失败,原因是消息到了前端,但未处理完成用户就刷新或者退出了页面。这种情况同样会在用户登陆后,会根据maxId重新拉取到这条消息。

对于步骤4,有小伙伴担心出现一种情况,im-server从队列中拉取了2,3,4号消息,2号消息推送失败,3,4号却推送成功了,此时maxId更新为4, 用户将无法收取2号消息?

其实不会有这种情况。因为ws是基于tcp实现的应用层协议,具有可靠性传输的特性。如果2号消息没有推送成功,后面的消息也不会正常推送。

如果是网络拥塞导致推送失败,会等待网络好转时继续推送。

如果是网络连接断开导致推送失败,此时这个channel已经失效,通过此channel推送任何消息都是抛异常。此时前端也会重新发起连接,重连成功后并拉取离线消息,通过新的channel推送消息。

3. 顺序性

消息的有序性分为全局有序局部有序。

全局有序:保证整个系统的所有消息都是有序的,实现比较简单粗暴,通常是将消息放到一个队列里面。但吞吐量比较低,非必要不建议使用。

局部有序:对于IM系统来说,局部有序可以理解为保证单一聊天会话内的消息有序即可。实现方式是在业务层控制相同会话的消息进入相同的队列。局部有序的方案可获得更高的吞吐量。

盒子IM采用的局部有序的方案,即保证单一会话内的消息有序

步骤1,2: 出现:“后发先至”情况,即用户后面发的消息被服务器先处理了。

前端发送消息的请求先放入一个先进先出的队列中,只有当前面的消息发送完成后,才发送下一条消息

步骤3.4: im-server从redis拉取到2,3,4号消息,没有按照顺序推送

1.同一个会话的消息会进入相同的队列,保证了从redis取出的消息是有序的。

2.而且必须取出来的消息完成发送后,才会去拉取下一批消息,保证了不会在多个线程同时推送消息

步骤5:有一种极端情况,当”张三”上线时,拉取id为100-200的离线消息,当推送到150号消息时,“李四”发来了201号消息,此时消息顺序变成100…150,201,151…200

是会有这种情况,所以还需要在客户端做最后一层保证。即根据消息id大小进行判断,如果接受到的消息id<maxId,则需要将消息插入到会话中合适的位置,而不是直接插入到最后

 

4. 唯一性

在一些特殊情况下,可能会造成消息的重复推送。

盒子IM的服务器端并未对消息的唯一性做校验,而是交给了前端进行处理。

前端处理也十分简单:每条消息都携带了唯一的id,接收到消息后,利用id判断消息是否已存在,如果已存在,则丢弃或覆盖即可。

10-性能压测和分析

1. 压测设备环境

本次压测使用的是我个人的开发电脑,核心配置如下:

环境

配置

操作系统

windows10 专业版

CPU

AMD 锐龙R5 3500x 6核

内存

金士顿骇客DDR4 3200hz 16G

磁盘

西部数据SSD 500G

如果条件允许,建议使用专业的linux企业级服务器压测效果更佳!

2.压测前准备

我们本次压测的目标是服务器的性能,所以应避免前端的干扰。

压测时应由一台独立的设备接收消息,或者像作者这样屏蔽掉前端的处理代码,只打印数量

d2b5ca33bd20250831144306

2. 压测私聊接口

2.1. 压测条件

条件

说明

压测工具

jmeter

压测请求

私聊接口(对方web端在线,移动端离线)

压测线程

10

压测数量

10万

2.2. 压测脚本

<?xml version="1.0" encoding="UTF-8"?>
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.5">
  <hashTree>
    <TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="盒子压测" enabled="true">
      <stringProp name="TestPlan.comments"></stringProp>
      <boolProp name="TestPlan.functional_mode">false</boolProp>
      <boolProp name="TestPlan.tearDown_on_shutdown">true</boolProp>
      <boolProp name="TestPlan.serialize_threadgroups">false</boolProp>
      <elementProp name="TestPlan.user_defined_variables" elementType="Arguments" guiclass="ArgumentsPanel" testclass="Arguments" testname="用户定义的变量" enabled="true">
        <collectionProp name="Arguments.arguments"/>
      </elementProp>
      <stringProp name="TestPlan.user_define_classpath"></stringProp>
    </TestPlan>
    <hashTree>
      <ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="线程组" enabled="true">
        <stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
        <elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="循环控制器" enabled="true">
          <boolProp name="LoopController.continue_forever">false</boolProp>
          <stringProp name="LoopController.loops">10000</stringProp>
        </elementProp>
        <stringProp name="ThreadGroup.num_threads">10</stringProp>
        <stringProp name="ThreadGroup.ramp_time">1</stringProp>
        <boolProp name="ThreadGroup.scheduler">false</boolProp>
        <stringProp name="ThreadGroup.duration"></stringProp>
        <stringProp name="ThreadGroup.delay"></stringProp>
        <boolProp name="ThreadGroup.same_user_on_next_iteration">true</boolProp>
      </ThreadGroup>
      <hashTree>
        <HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP信息头管理器" enabled="true">
          <collectionProp name="HeaderManager.headers">
            <elementProp name="" elementType="Header">
              <stringProp name="Header.name">accessToken</stringProp>
              <stringProp name="Header.value">eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiIxIiwiZXhwIjoxNzAzNzcyNjQzLCJpbmZvIjoie1wibmlja05hbWVcIjpcImJsdWVcIixcInRlcm1pbmFsXCI6MCxcInVzZXJJZFwiOjEsXCJ1c2VyTmFtZVwiOlwiYmx1ZVwifSJ9.YNlYqAo-aGVO9G82XbBm4iiyObBL8JCt2CJOWqIHxAc</stringProp>
            </elementProp>
            <elementProp name="" elementType="Header">
              <stringProp name="Header.name">Content-Type</stringProp>
              <stringProp name="Header.value">application/json; charset=UTF-8</stringProp>
            </elementProp>
          </collectionProp>
        </HeaderManager>
        <hashTree/>
        <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="发送私聊消息" enabled="true">
          <boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
          <elementProp name="HTTPsampler.Arguments" elementType="Arguments">
            <collectionProp name="Arguments.arguments">
              <elementProp name="" elementType="HTTPArgument">
                <boolProp name="HTTPArgument.always_encode">false</boolProp>
                <stringProp name="Argument.value">{&quot;content&quot;: &quot;1&quot;, &quot;type&quot;: 0, &quot;recvId&quot;: 2 }</stringProp>
                <stringProp name="Argument.metadata">=</stringProp>
              </elementProp>
            </collectionProp>
          </elementProp>
          <stringProp name="HTTPSampler.domain">192.168.1.5</stringProp>
          <stringProp name="HTTPSampler.port">8888</stringProp>
          <stringProp name="HTTPSampler.protocol">http</stringProp>
          <stringProp name="HTTPSampler.contentEncoding"></stringProp>
          <stringProp name="HTTPSampler.path">/message/private/send</stringProp>
          <stringProp name="HTTPSampler.method">POST</stringProp>
          <boolProp name="HTTPSampler.follow_redirects">true</boolProp>
          <boolProp name="HTTPSampler.auto_redirects">false</boolProp>
          <boolProp name="HTTPSampler.use_keepalive">true</boolProp>
          <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
          <stringProp name="HTTPSampler.embedded_url_re"></stringProp>
          <stringProp name="HTTPSampler.connect_timeout"></stringProp>
          <stringProp name="HTTPSampler.response_timeout"></stringProp>
        </HTTPSamplerProxy>
        <hashTree/>
      </hashTree>
      <ResultCollector guiclass="StatVisualizer" testclass="ResultCollector" testname="聚合报告" enabled="true">
        <boolProp name="ResultCollector.error_logging">false</boolProp>
        <objProp>
          <name>saveConfig</name>
          <value class="SampleSaveConfiguration">
            <time>true</time>
            <latency>true</latency>
            <timestamp>true</timestamp>
            <success>true</success>
            <label>true</label>
            <code>true</code>
            <message>true</message>
            <threadName>true</threadName>
            <dataType>true</dataType>
            <encoding>false</encoding>
            <assertions>true</assertions>
            <subresults>true</subresults>
            <responseData>false</responseData>
            <samplerData>false</samplerData>
            <xml>false</xml>
            <fieldNames>true</fieldNames>
            <responseHeaders>false</responseHeaders>
            <requestHeaders>false</requestHeaders>
            <responseDataOnError>false</responseDataOnError>
            <saveAssertionResultsFailureMessage>true</saveAssertionResultsFailureMessage>
            <assertionsResultsToSave>0</assertionsResultsToSave>
            <bytes>true</bytes>
            <sentBytes>true</sentBytes>
            <url>true</url>
            <threadCounts>true</threadCounts>
            <idleTime>true</idleTime>
            <connectTime>true</connectTime>
          </value>
        </objProp>
        <stringProp name="filename"></stringProp>
      </ResultCollector>
      <hashTree/>
      <ResultCollector guiclass="ViewResultsFullVisualizer" testclass="ResultCollector" testname="查看结果树" enabled="true">
        <boolProp name="ResultCollector.error_logging">false</boolProp>
        <objProp>
          <name>saveConfig</name>
          <value class="SampleSaveConfiguration">
            <time>true</time>
            <latency>true</latency>
            <timestamp>true</timestamp>
            <success>true</success>
            <label>true</label>
            <code>true</code>
            <message>true</message>
            <threadName>true</threadName>
            <dataType>true</dataType>
            <encoding>false</encoding>
            <assertions>true</assertions>
            <subresults>true</subresults>
            <responseData>false</responseData>
            <samplerData>false</samplerData>
            <xml>false</xml>
            <fieldNames>true</fieldNames>
            <responseHeaders>false</responseHeaders>
            <requestHeaders>false</requestHeaders>
            <responseDataOnError>false</responseDataOnError>
            <saveAssertionResultsFailureMessage>true</saveAssertionResultsFailureMessage>
            <assertionsResultsToSave>0</assertionsResultsToSave>
            <bytes>true</bytes>
            <sentBytes>true</sentBytes>
            <url>true</url>
            <threadCounts>true</threadCounts>
            <idleTime>true</idleTime>
            <connectTime>true</connectTime>
          </value>
        </objProp>
        <stringProp name="filename"></stringProp>
      </ResultCollector>
      <hashTree/>
    </hashTree>
  </hashTree>
</jmeterTestPlan>

2.3. 压测结果

请求异常

0%

数据丢失

0%

吞吐量

1978qps

压测时设备状态

cpu100%,内存37%,io利用率56%

jmeter压测截图:

d2b5ca33bd20250831144846

d2b5ca33bd20250831144852

以下是压测时设备状态,其im-platform占用26.3%cpu,im-server占用11.2%cpu:

d2b5ca33bd20250831144905

3. 压测群聊接口

3.1. 压测条件

条件

说明

压测工具

jmeter

压测请求

群聊接口(群聊成员10人,其中5人在线,5人离线)

压测线程

10

压测数量

10万

3.2. 压测脚本

<?xml version="1.0" encoding="UTF-8"?>
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.5">
  <hashTree>
    <TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="盒子压测" enabled="true">
      <stringProp name="TestPlan.comments"></stringProp>
      <boolProp name="TestPlan.functional_mode">false</boolProp>
      <boolProp name="TestPlan.tearDown_on_shutdown">true</boolProp>
      <boolProp name="TestPlan.serialize_threadgroups">false</boolProp>
      <elementProp name="TestPlan.user_defined_variables" elementType="Arguments" guiclass="ArgumentsPanel" testclass="Arguments" testname="用户定义的变量" enabled="true">
        <collectionProp name="Arguments.arguments"/>
      </elementProp>
      <stringProp name="TestPlan.user_define_classpath"></stringProp>
    </TestPlan>
    <hashTree>
      <ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="线程组" enabled="true">
        <stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
        <elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="循环控制器" enabled="true">
          <boolProp name="LoopController.continue_forever">false</boolProp>
          <stringProp name="LoopController.loops">10000</stringProp>
        </elementProp>
        <stringProp name="ThreadGroup.num_threads">10</stringProp>
        <stringProp name="ThreadGroup.ramp_time">1</stringProp>
        <boolProp name="ThreadGroup.scheduler">false</boolProp>
        <stringProp name="ThreadGroup.duration"></stringProp>
        <stringProp name="ThreadGroup.delay"></stringProp>
        <boolProp name="ThreadGroup.same_user_on_next_iteration">true</boolProp>
      </ThreadGroup>
      <hashTree>
        <HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP信息头管理器" enabled="true">
          <collectionProp name="HeaderManager.headers">
            <elementProp name="" elementType="Header">
              <stringProp name="Header.name">accessToken</stringProp>
              <stringProp name="Header.value">eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiIxIiwiZXhwIjoxNzAzNzc2NDU3LCJpbmZvIjoie1wibmlja05hbWVcIjpcImJsdWVcIixcInRlcm1pbmFsXCI6MCxcInVzZXJJZFwiOjEsXCJ1c2VyTmFtZVwiOlwiYmx1ZVwifSJ9.AuDlSz-M5FG6hYgOQgnu3DnhdNpaPb6H-XUj9dWvS8g</stringProp>
            </elementProp>
            <elementProp name="" elementType="Header">
              <stringProp name="Header.name">Content-Type</stringProp>
              <stringProp name="Header.value">application/json; charset=UTF-8</stringProp>
            </elementProp>
          </collectionProp>
        </HeaderManager>
        <hashTree/>
        <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="发送群聊消息" enabled="true">
          <boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
          <elementProp name="HTTPsampler.Arguments" elementType="Arguments">
            <collectionProp name="Arguments.arguments">
              <elementProp name="" elementType="HTTPArgument">
                <boolProp name="HTTPArgument.always_encode">false</boolProp>
                <stringProp name="Argument.value">{&quot;content&quot;: &quot;1&quot;, &quot;type&quot;: 0, &quot;groupId&quot;: 4 }</stringProp>
                <stringProp name="Argument.metadata">=</stringProp>
              </elementProp>
            </collectionProp>
          </elementProp>
          <stringProp name="HTTPSampler.domain">192.168.1.5</stringProp>
          <stringProp name="HTTPSampler.port">8888</stringProp>
          <stringProp name="HTTPSampler.protocol">http</stringProp>
          <stringProp name="HTTPSampler.contentEncoding"></stringProp>
          <stringProp name="HTTPSampler.path">/message/group/send</stringProp>
          <stringProp name="HTTPSampler.method">POST</stringProp>
          <boolProp name="HTTPSampler.follow_redirects">true</boolProp>
          <boolProp name="HTTPSampler.auto_redirects">false</boolProp>
          <boolProp name="HTTPSampler.use_keepalive">true</boolProp>
          <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
          <stringProp name="HTTPSampler.embedded_url_re"></stringProp>
          <stringProp name="HTTPSampler.connect_timeout"></stringProp>
          <stringProp name="HTTPSampler.response_timeout"></stringProp>
        </HTTPSamplerProxy>
        <hashTree/>
      </hashTree>
      <ResultCollector guiclass="StatVisualizer" testclass="ResultCollector" testname="聚合报告" enabled="true">
        <boolProp name="ResultCollector.error_logging">false</boolProp>
        <objProp>
          <name>saveConfig</name>
          <value class="SampleSaveConfiguration">
            <time>true</time>
            <latency>true</latency>
            <timestamp>true</timestamp>
            <success>true</success>
            <label>true</label>
            <code>true</code>
            <message>true</message>
            <threadName>true</threadName>
            <dataType>true</dataType>
            <encoding>false</encoding>
            <assertions>true</assertions>
            <subresults>true</subresults>
            <responseData>false</responseData>
            <samplerData>false</samplerData>
            <xml>false</xml>
            <fieldNames>true</fieldNames>
            <responseHeaders>false</responseHeaders>
            <requestHeaders>false</requestHeaders>
            <responseDataOnError>false</responseDataOnError>
            <saveAssertionResultsFailureMessage>true</saveAssertionResultsFailureMessage>
            <assertionsResultsToSave>0</assertionsResultsToSave>
            <bytes>true</bytes>
            <sentBytes>true</sentBytes>
            <url>true</url>
            <threadCounts>true</threadCounts>
            <idleTime>true</idleTime>
            <connectTime>true</connectTime>
          </value>
        </objProp>
        <stringProp name="filename"></stringProp>
      </ResultCollector>
      <hashTree/>
      <ResultCollector guiclass="ViewResultsFullVisualizer" testclass="ResultCollector" testname="查看结果树" enabled="true">
        <boolProp name="ResultCollector.error_logging">false</boolProp>
        <objProp>
          <name>saveConfig</name>
          <value class="SampleSaveConfiguration">
            <time>true</time>
            <latency>true</latency>
            <timestamp>true</timestamp>
            <success>true</success>
            <label>true</label>
            <code>true</code>
            <message>true</message>
            <threadName>true</threadName>
            <dataType>true</dataType>
            <encoding>false</encoding>
            <assertions>true</assertions>
            <subresults>true</subresults>
            <responseData>false</responseData>
            <samplerData>false</samplerData>
            <xml>false</xml>
            <fieldNames>true</fieldNames>
            <responseHeaders>false</responseHeaders>
            <requestHeaders>false</requestHeaders>
            <responseDataOnError>false</responseDataOnError>
            <saveAssertionResultsFailureMessage>true</saveAssertionResultsFailureMessage>
            <assertionsResultsToSave>0</assertionsResultsToSave>
            <bytes>true</bytes>
            <sentBytes>true</sentBytes>
            <url>true</url>
            <threadCounts>true</threadCounts>
            <idleTime>true</idleTime>
            <connectTime>true</connectTime>
          </value>
        </objProp>
        <stringProp name="filename"></stringProp>
      </ResultCollector>
      <hashTree/>
    </hashTree>
  </hashTree>
</jmeterTestPlan>

3.3. 压测结果

请求异常

0%

数据丢失

0%

吞吐量

1546qps

压测时设备状态

cpu100%,内存36%,io利用率58%

jmeter压测截图:

d2b5ca33bd20250831145031

同样收到了全部消息

d2b5ca33bd20250831145042

im-platform进程占用24.7%cpu,im-server占用17.0%:

d2b5ca33bd20250831145053

4. 分析以及优化

4.1. 分析

由上面的结果不难看出, 无论私聊还是群聊,”木桶的短板”都在cpu这里,而且是我们的jvm进程占用了主要的cpu资源。

如何进一步分析是哪里占用这么高的cpu呢,这里推荐使用阿里开源的arthas工具进行分析。

压测时通过arthas执行以下指令,可以得到jvm 30秒的CPU火焰图:

profiler start -d 30

这是我生成的火焰图:

d2b5ca33bd20250831145120

从图中可以看到,在业务代码中,大部分的cpu都消耗在lettuce的调用上面,而lettuce最终调用了Unsafe.park。了解并发编程的同学应该知道,Unsafe.park会让线程阻塞,是需要进行内核上下文切换的,是比较消耗cpu的操作。

也就是说,每访问一次redis,都会产生一次上下文切换。那么发送一条私聊消息,需要访问多少次redis呢?

操作

访问次数

说明

判断对方是否我的好友

1

作为mysql缓存,压测时基本都会命中缓存

判断对方是否在线

2

web端+移动端分别判断一次

推送消息

2

一推一拉,共2次

回推消息结果

2

一推一拉,共2次

总共

7

 

按照上面压测的结果,qps接近2000,意味着1秒中需要访问2000×7=14000次redis,cpu上下文也要切换14000次!

4.2. 优化建议

这里有小伙伴可能会问:既然lettuce这么消耗cpu,换成其他redis客户端(如jedis、redission)能解决问题吗?

不行,其他客户端一样会有这个问题,原理都是一样的。

事实上只要是通过网络访问远程服务器,且是同步返回结果的sdk,都需要进行阻塞等待,比如jdbc访问mysql,只是mysql请求量比较小,没有暴露问题而已。

其实代码层面的优化空间已经不大了,如果小伙伴们对性能要求比较高,这里给出我的两个优化建议:

  • 采用MQ代替redis推送消息,MQ可以实现异步发送,订阅拉取消息,不需要阻塞等待。个人推荐RocketMQ,其次Kakfa和RabbitMQ。
  • 直接化身氪金大佬,升级硬件设备(纵向扩展)或者进行多机部署(横向扩展)

5. 总结

5.1. 如何估算qps

不少小伙伴都问过我这样一个问题:

如果我们要搭建一个可以支撑100万用户的IM系统,qps应该达到多少才能稳定运行呢?

对于这个问题,我在这里统一谈谈我个人的见解。

首先,我们需要先要对自己系统用户状态进行一个大致的估算,假设以下是估算的结果:

最大同时在线人数占比

30%

正在发送消息人数占比

20%

平均发一条消息耗时

15s

可以计算出: qps = 1000000 x 0.3 x 0.2 / 15 = 4000

但是系统还有其他业务,且系统应留有一定的冗余度应对突发流量,在计算的结果乘以一个系数,比如乘以2,即压测要求达到8000qps,基本能保证系统问题运行

6. 最后说明

1.以上是作者使用自己开发电脑压测的结果,在不同的环境下压测的结果可能完全不一样,小伙伴们应该根据自身服务器环境的压测结果进行精准优化。

2.最近发现有些小伙伴直接用作者线上环境进行压测,还请各位不要这样做,因为作者购买的只是一个轻量级服务器,无法承受太高的压力,而且会产生很多垃圾数据。

为满足各位的求知欲,我自己对线上环境简单压测了一遍,结果如下:

服务器

华为轻量级云服务器

配置

4核8G

QPS

约1800

如果小伙伴们还是要坚持用作者的环境进行压测,en…我也没招,请自觉v作者¥500作为数据清理的费用。

11-实时视频聊天

1. 前言

视频聊天功能是基于webrtc实现,对webrtc不熟悉的小伙伴可以先去了解一下相关的web api.

基于webrtc实现的视频聊天需要保证以下几点:

    • 用户的浏览器需要支持webrtc
    • 服务器需要部署ssl证书,否则浏览器不允许用户访问摄像头和麦克风
    • 如果需要跨网段视频聊天,则需要在具有公网IP的服务器搭建一个coturn

目前盒子IM的音视频只开源了web端的单人版,如需升级,可联系作者付费购买:付费-商业版源码

2. webrtc介绍

WebRTC(全称 Web Real-Time Communication),即网页实时通信。 是一个支持网页浏览器进行实时语音对话或视频对话的技术方案。

作者对webrtc了解其实也十分有限,当时看了几篇博客就开干了,甚至上面这句描述也是网上抄的。

以下是我们用到的webrtc相关的web api:

api

作用

调用方

createOffer

生成本地SDP信息

发起方

setLocalDescription

设置本地SDP信息

双方

setRemoteDescription

设置对方SDP信息

双方

createAnswer

生成本地SDP信息

应答方

addIceCandidate

将候选信息发送给ICE

双方

还有相关的事件:

事件类型

触发时机

目的

onicecandidate

收集到候选信息时触发

将候选信息发送给对方(必须)

oniceconnectionstatechange

连接状态发生变更时触发

连接成功时,开始记录通话时间

ontrack

远程媒体流添加到本地对等连接时触发

获取视频流显示到前端video标签

3. 呼叫流程

以下是张三(发起方)呼叫李四(应答方)的时序图:

d2b5ca33bd20250831145355

整个过程过程看起来很复杂,其实就干了两个事情: 交换通信双方的sdp信息(1~10)和候选信息(11~16)

1.发起方调用createOffer获得本地sdp信息

2.发起方调用setLocalDescription把上一步得到的sdp信息设置到本地

3.发起方请求im-platform的/call接口,向应答方发起呼叫,并携带发起方的sdp信息

4.im-platform通过消息推送api将发起方的sdp信息推送到应答方

5.应答方接受到呼叫邀请,并得到发起方的sdp信息,调用setRemoteDescription将发起方的sdp信息设置到本地

6.应答方调用createAnswer获得本地的sdp信息

7.应答方调用setLocalDescription把上一步得到的sdp信息设置到本地

8.应答方请求im-platform的/accept接口,向发起方表示接受呼叫邀请,并携带应答方的sdp信息

9.im-platform通过消息推送api将应答方的sdp信息推送到发起方

10.发起方调用setRemoteDescription将应答方的sdp设置到本地。

至此,双方sdp信息完成交换。

11.发起方调用im-platform的/candiate接口,并携带候选信息

12.im-platform通过消息推送api将发起方的候选信息推送到应答方

13.应答方调用addIceCandidate将候选信息发送给ICE服务器

14.应答方调用im-platform的/candiate接口,并携带候选信息

15.im-platform通过消息推送api将应答方的候选信息推送到发起方

16.发起方调用addIceCandidate将候选信息发送给ICE服务器

11~13是发起方向应答方发送候选信息,14~16则是反过来,应答方向发起方发发送候选信息。注意候选信息的交换双方是同时进行的,而且每次呼叫会发送多个候选信息。

当双方的所有的候选信息都交换完成,webrtc底层会进行检测,选择一个最优候选线路进行视频数据通信。

前端相关代码:

文件

方法

说明

ChatPrivateVideo.vue

所有

视频对话窗口

ChatVideoAcceptor.vue

所有

被呼叫时右下角弹窗

后端相关代码:

方法

说明

WebrtcServiceImpl

所有

视频聊天相关接口

12-emo动态表情

1. 前言

占个坑,以后补充

13-群聊@功能

1. 前言

占个坑,以后补充

14-生产环境部署

说明

本文档使用单台服务器进行部署,配置如下:

系统

centos7.6

内网IP

192.168.43.10

外网IP

42.194.187.243

域名

www.boxim.online

  • 本文档中所涉及的IP和域名,请替换成自己的
  • 本文档的所涉及的密码均已脱敏(语雀文档官方要求,否则无法发布),建议各位搭建时,自行重新生成每个组件的密码
  • 本文档采用的是命令行的方式进行部署,如果希望使用宝塔部署,可以参考这位小伙伴写的文档:https://blog.ovim.cn/1.html

1. 准备工作

1.1. 服务器

购买(云)服务器,并开放以下端口:

端口

协议

端口占用组件

80

tcp

nginx

443

tcp

nginx

3306

tcp

mysql

6379

tcp

redis

9001

tcp

minio

9002

tcp

minio

3478

tcp/udp

coturn

5349

tcp

coturn

40000-65535

udp

coturn

注意:腾讯云服务器开放范围端口是用”,”分隔,所以最后一条开放端口应配置:40000,65535

1.2. ssl证书

注:非必须,如果需要使用语音和视频功能,则必须要有ssl证书,可以选购买或自签的方式获取证书

1.2.1. 购买证书

可以直接选择向云厂商购买ssl证书,然后将证书放置/opt/ssl目录下,将私钥命名为server.key,证书命名为server.crt

1.2.2. 自签证书(浏览器会有警告)

自签证书流程:

# 进入/opt/ssl目录
mkdir /opt/ssl
cd /opt/ssl
# 安装依赖包
yum install -y openssl openssl-devel
# 生成私钥,需要输入两遍相同的密码
openssl genrsa -des3 -out server.key 2048
# 生成CSR,需输入相关信息,乱填也行
openssl req -new -key server.key -out server.csr
# 取消私钥当中的密码,需输入刚才的密码
openssl rsa -in server.key -out server.key
# 生成SSL证书
openssl x509 -req -days 3650 -in server.csr -signkey server.key -out server.crt

1.3. 域名

注:非必须,但如果要上线微信小程序,则必须要有域名

域名可向云厂商购买,大陆的服务器需要进行域名备案才可正常使用。

2. 环境安装

2.1. 安装必要工具

yum install net-tools
yum -y install vim*
yum install lrzsz
yum install wget
yum install unzip
yum install libevent-devel

2.2. 安装jdk17

2.2.1. 安装
mkdir /usr/local/data
cd /usr/local/data
wget https://mirrors.tuna.tsinghua.edu.cn/Adoptium/17/jdk/x64/linux/OpenJDK17U-jdk_x64_linux_hotspot_17.0.13_11.tar.gz
tar -xvf OpenJDK17U-jdk_x64_linux_hotspot_17.0.13_11.tar.gz -C /opt/
mv /opt/jdk-17.0.13+11 /opt/jdk17
2.2.2. 配置环境变量

在/etc/profile最下方添加:

export JAVA_HOME=/opt/jdk17
export CLASSPATH=.:$JAVA_HOME/lib/tools.jar:$JAVA_HOME/lib/dt.jar
export PATH=$JAVA_HOME/bin:$PATH
2.2.3. 刷新环境变量
source /etc/profile
java -version

2.3. 安装mysql8.0

2.3.1. 安装
# 下载 MySQL yum库
wget https://dev.mysql.com/get/mysql80-community-release-el7-3.noarch.rpm
# 配置MySQL的yum库
sudo rpm -Uvh mysql80-community-release-el7-3.noarch.rpm
# 安装社区版
yum -y install mysql-community-server --nogpgcheck
# 启动mysql服务
systemctl start mysqld.service
# 开机自启动mysql服务
systemctl enable mysqld.service
2.3.2. 修改密码
# 在日志文件中查找password关键字,得到默认密码
grep "password" /var/log/mysqld.log
# 用默认密码登录
mysql -uroot -p
# 修改密码
ALTER USER 'root'@'localhost' IDENTIFIED BY 'naQR****Gv2j7PX_';
2.3.3. 开启远程访问
CREATE USER 'im'@'%' IDENTIFIED BY 'naQR****Gv2j7PX_';
GRANT ALL PRIVILEGES ON *.* TO 'im'@'%' WITH GRANT OPTION;
flush privileges;
2.3.4. 执行初始化脚本

在mysql中新建一个名为im_platform的数据库,然后执行db目录下的im-platform.sql

2.4. 安装redis6.2

2.4.1. 下载并安装
yum install sqlite libsqlite3-dev
yum install gcc-c++ -y
cd /usr/local/data
wget http://download.redis.io/releases/redis-6.2.14.tar.gz
tar -xf redis-6.2.14.tar.gz
cd redis-6.2.14
make
make install PREFIX=/opt/redis
cd /opt/redis
mkdir conf
cp /usr/local/data/redis-6.2.14/redis.conf ./conf
2.4.2. 修改配置
# 以守护进程的方式运行
daemonize yes
# 支持远程访问
bind 0.0.0.0
# 关闭保护模式
protected-mode no
# 连接密码
requirepass PmEpfRjpBnTN6CgW
2.4.3. 安装服务

/etc/systemd/system下创建redis.service

[Unit]
Description=redis-server
After=network.target
 
[Service]
Type=forking
ExecStart=/opt/redis/bin/redis-server /opt/redis/conf/redis.conf
PrivateTmp=true
 
[Install]
WantedBy=multi-user.target
2.4.4. 启动
# 启动
systemctl start redis.service
# 开机启动
systemctl enable redis.service

2.5. 安装minio

2.5.1. 下载并安装
cd /usr/local/data
wget https://dl.minio.org.cn/server/minio/release/linux-amd64/archive/minio.RELEASE.2024-07-26T20-48-21Z
mv minio.RELEASE.2024-07-26T20-48-21Z  minio
chmod +x minio
mkdir /opt/minio
cp minio /opt/minio/
2.5.2. 创建配置文件

在/opt/minio下创建minio.conf:

#数据存放目录
MINIO_VOLUMES="/data/minio"
#端口号设置
MINIO_OPTS="--console-address :9002 --address :9001"
#用户名
MINIO_ROOT_USER="admin"
#密码
MINIO_ROOT_PASSWORD="3fBSt****uD77D6"
2.5.3. 安装服务

/etc/systemd/system下创建minio.service

[Unit]
Description=MinIO
Documentation=https://docs.min.io
Wants=network-online.targetmsts
After=network-online.target
#minio文件具体位置
AssertFileIsExecutable=/opt/minio/minio
[Service]
# User and group 用户 组
User=root
Group=root
#创建的配置文件 minio.conf
EnvironmentFile=/opt/minio/minio.conf
ExecStart=/opt/minio/minio server $MINIO_OPTS $MINIO_VOLUMES
# Let systemd restart this service always
Restart=always
# Specifies the maximum file descriptor number that can be opened by this process
LimitNOFILE=65536
# Disable timeout logic and wait until process is stopped
TimeoutStopSec=infinity
SendSIGKILL=no
[Install]
WantedBy=multi-user.target
2.5.4. 启动服务
# 启动
systemctl start minio.service
# 开机启动
systemctl enable minio.service

2.6. 安装nginx

2.6.1. 下载并安装

下载地址:https://nginx.org/en/download.html,下载1.22.1版本,拷贝至服务器的/usr/local/data目录

acd /usr/local/data
yum install gcc-c++ 
yum install -y pcre pcre-devel
yum install -y zlib zlib-devel
yum install -y openssl openssl-devel
tar -zxvf nginx-1.22.1.tar.gz 
cd nginx-1.22.1
./configure --prefix=/opt/nginx --with-http_stub_status_module --with-http_ssl_module
make
make install
2.6.2. 修改配置
/opt/nginx/conf/nginx.conf
#user  nobody;
worker_processes  1;

#error_log  logs/error.log;
#error_log  logs/error.log  notice;
#error_log  logs/error.log  info;
#pid        logs/nginx.pid;
events {
  worker_connections  1024;
}

http {
  include       mime.types;
  default_type  application/octet-stream;
  client_max_body_size  50M;
  sendfile        on;
  keepalive_timeout  65;
  server {
    listen 443 ssl;
    server_name www.boxim.online;
    root html;
    index index.html index.htm;
    ssl_certificate /opt/ssl//server.crt;
    ssl_certificate_key /opt/ssl/server.key;
    ssl_session_timeout 5m;
    ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4;
    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
    ssl_prefer_server_ciphers on;   

    location / {
      root   im-web;
      index  index.html index.htm;
    }

    location /h5 {
      alias   im-h5;
      index  index.html index.htm;
    }
    
    location /admin {
      alias   im-admin;
      index  index.html index.htm;
    	try_files $uri $uri/ /admin/index.html;
    }

    location /api/ {
   	  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  	  proxy_set_header Host $host;	     
      proxy_pass http://127.0.0.1:8888;
      rewrite "^/api/(.*)$" /$1 break;
    }
    
    location /adm/api/ {
   	  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  	  proxy_set_header Host $host;	       
      proxy_pass http://127.0.0.1:8889;
      rewrite "^/adm/api/(.*)$" /$1 break;
    }
    
    location /file/ {
      proxy_pass http://127.0.0.1:9001;
      rewrite "^/file/(.*)$" /$1 break;
    }

    location = /im {
      proxy_pass        http://127.0.0.1:8878;
      proxy_set_header  Host $host;
      proxy_set_header  X-Real-IP  $remote_addr;
      proxy_set_header  X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header  X-Forwarded-Proto   $scheme;
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection "upgrade";
    }        
  }

  server {
    listen       80;
    server_name  www.boxim.online;
    rewrite ^(.*)$ https://$host$1 permanent;
  }
}
2.6.3. 安装服务

/etc/systemd/system下创建nginx.service

[Unit]
Description=nginx service
After=network.target 
   
[Service] 
Type=forking 
ExecStart=/opt/nginx/sbin/nginx
ExecReload=/opt/nginx/sbin/nginx -s reload
ExecStop=/opt/nginx/sbin/nginx -s quit
PrivateTmp=true 
   
[Install] 
WantedBy=multi-user.target
2.6.4. 启动服务
# 启动
systemctl start nginx.service
# 开机启动
systemctl enable nginx.service

2.7. 安装coturn(视频聊天)

说明:

  • 如果不需要音视频通话功能,无需部署coturn
  • 如果在外网使用通话功能,必须部署coturn,且服务器必须要有公网ip
  • 如果是在内网使用,且所有用户都在同一网段,无需部署coturn
  • 如果是在内网使用,且不止一个网段,这种情况理论上只要选择一个所有用户都能访问到的服务器部署coturn即可,建议直接和nginx部署在同一台服务器(这种情况作者未测试过,但理论上是没问题的)
2.7.1. 下载

下载并拷贝至服务器的/usr/local/data目录

2.7.2. 编译安装
cd /usr/local/data
tar -zxvf coturn-4.5.1.1.tar.gz
cd coturn-4.5.1.1
./configure  --prefix=/opt/coturn
make
make install
2.7.3. 创建用户
cd /opt/coturn/bin
./turnadmin -k -u admin -r www.boxim.online -p UrHH****nFvBTMV
2.7.4. 生成证书
mkdir /opt/coturn/ssl
openssl dhparam -out /opt/coturn/ssl/dhparams.pem 2048
openssl req -x509 -newkey rsa:2048 -keyout /opt/coturn/ssl/turn_server_pkey.pem -out /opt/coturn/ssl/turn_server_cert.pem -days 99999 -nodes
2.7.5. 修改配置
/opt/coturn/etc/turnserver.conf
# 网卡名(ip addr命令查看自己的网卡名)
relay-device=eth0
# 密码
cli-password=UrHH****nFvBTMV
# 内网IP
listening-ip=192.168.43.10
listening-port=3478
tls-listening-port=5349
# 内网IP
relay-ip=192.168.43.10
relay-threads=500
# 外网IP
external-ip=42.194.187.243
# 设置用户名和密码,跟上面创建的用户的账号密码一致
user=admin:UrHH****nFvBTMV
# udp端口范围
min-port=40000
max-port=65535
# 外网IP绑定的域名
realm=www.boxim.online
# 打开密码验证
lt-cred-mech
# 证书
cert=/opt/coturn/ssl/turn_server_cert.pem
pkey=/opt/coturn/ssl/turn_server_pkey.pem
dh-file=/opt/coturn/ssl/dhparams.pem
2.7.6. 启动
/opt/coturn/bin/turnserver -v -r www.boxim.online:3478 -a -o -c /opt/coturn/etc/turnserver.conf
2.7.7. 验证

检测生效网站:点这里,出现done提示表示成功

d2b5ca33bd20250831150535

3. 部署服务

3.1. 部署后端服务

3.1.1. 修改im-platfrom配置文件

im-platfrom的resource目录修改application-prod.yml文件

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/im_platform?useSSL=false&useUnicode=true&characterEncoding=utf-8&allowPublicKeyRetrieval=true
    username: im
    password: naQR****Gv2j7PX_
  data:
    redis:
      host: localhost
      port: 6379
      password: PmEpfRj****N6CgW

minio:
  endpoint: http://127.0.0.1:9001 #内网地址
  domain: https://www.boxim.online/file  #外网访问地址
  accessKey: admin
  secretKey: 3fBSt****uD77D6
  bucketName: box-im
  imagePath: image
  filePath: file
  videoPath: video

webrtc:
  max-channel: 9 # 多人通话最大通道数量,最大不能超过16,建议值:4,9,16
  iceServers:   #coturn配置
    - urls: stun:www.boxim.online:3478
      username: admin
      credential: UrHH****nFvBTMV
    - urls: turn:www.boxim.online:3478
      username: admin
      credential: UrHH****nFvBTMV
3.1.2. 修改im-server配置文件

im-server的resource目录修改application-prod.yml文件

spring:
  data:
    redis:
      host: 127.0.0.1
      port: 6379
      password: PmEpfRj****N6CgW
3.1.3. 编译

进入盒子IM代码根目录,执行

mvn clean package -Dmaven.test.skip=true

将生成的im-platform.jar和im-server.jar拷贝到服务器的/data/boxim目录

3.1.4. 启动
cd /data/boxim
# 启动im-platform
nohup java -jar im-platform.jar  --spring.profiles.active=prod &
# 启动im-server
nohup java -jar im-server.jar  --spring.profiles.active=prod &

3.2. 部署web端

修改im-web的配置文件.env.production (主要是将www.boxim.online换成自己的域名或ip)

ENV = 'production'
# app名称
VUE_APP_NAME = "盒子IM"
# 接口地址
VUE_APP_BASE_API  = 'https://www.boxim.online/api'
# ws地址
VUE_APP_WS_URL = 'wss://www.boxim.online/im'

这里注意:如有您没有部署ssl证书,https需要改成http,wss需要改成ws,很多小伙伴都栽在这里

在im-web目录进入命令行,执行打包命令:

npm install
npm run build

将生成的dist目录压缩,拷贝至服务器的/opt/nginx目录,执行命令:

unzip dist.zip
mv dist im-web
rm -rf dist.zip
systemctl restart nginx.service

web部署完成,访问地址:

https://www.boxim.online

3.3. 部署h5端

修改im-uiapp的.env.js,将ENV变量修改为“PROD”,并将url中的www.boxim.online换成自己的域名或IP:

const ENV = "PROD";
const UNI_APP = {}
if(ENV=="DEV"){
	...
}
if(ENV=="PROD"){
	UNI_APP.BASE_URL = "https://www.boxim.online/api";
	UNI_APP.WS_URL = "wss://www.boxim.online/im";
}
export default UNI_APP

通过hbuilderx打开im-uniapp目录,选择项目,然后选择菜单:发行->网站PC-web或H5

d2b5ca33bd20250831150800

输入域名,点击“发行”:

d2b5ca33bd20250831150811

将导出的h5目录(新版的hbuilderx也可能是web目录)压缩,拷贝至服务器的/opt/nginx目录并解压,修改包名为im-h5

h5部署完成,访问地址:

https://www.boxim.online/h5

3.4. 部署微信小程序

注:打包小程序前需安装注册账号并获取appid

同样的方式修改im-uiapp的.env.js,可参考H5打包

通过hbuilderx打开im-uniapp目录,选择项目,然后选择菜单:发行->自定义发行->开发环境-微信小程序,填入您的appid后,点击”发行按钮”:

d2b5ca33bd20250831150824

点击”上传”,填写版本号和备注信息后,然后上传

d2b5ca33bd20250831150835

登录微信公众平台,提交审核:

d2b5ca33bd20250831150846

提交后等待审核通过,然后发布审核版本即可。

注:从作者发布小程序的经历来看,小程序的审核不通过的概率非常高,但是理由却非常不固定,所以千万不要一次不通过就放弃,失败后进行申诉或者多提交几次,说不定就过了。

3.5. 打包安卓APP

同样的方式修改im-uiapp的.env.js,可参考H5打包

打包安卓APP前需要先生成证书,打开cmd执行:

keytool -genkey -alias boxim -keyalg RSA -keysize 2048 -validity 36500 -keystore boxim.keystore

d2b5ca33bd20250831150912

输入密码和相关的信息后,会在当前目录生成证书文件boxim.keystore,然后在hubilderx选择菜单 发行->原生APP-云打包

d2b5ca33bd20250831150923

按图中填写信息,完成后右下角点击”打包”,打包生成后,控制台会打印apk下载链接:

d2b5ca33bd20250831150935

通过链接下载,然后后发布到应用市场即可

3.6. 打包IOS APP(略)

4. 部署后台管理

4.1. 部署后台服务

4.1.1. 下载代码

下载后台管理代码,开源版地址:https://gitee.com/bluexsx/box-im-admin

4.1.2. 创建数据库

在mysql中新建一个名为im_admin的数据库,然后执行代码中db目录下的im-admin.sql

4.1.3. 修改im-admin配置文件

在ruoyi-admin包里面的application-prod.yml

spring:
  data:
    redis:
      host: localhost
      port: 6379
      password: PmEpfRj****N6CgW
  datasource:
    dynamic:
      primary: admin
      datasource:
        admin:
          driver-class-name: com.mysql.cj.jdbc.Driver
          url: jdbc:mysql://localhost:3306/im_admin?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true
          username: im
          password: naQR****Gv2j7PX_
        platform:
          driver-class-name: com.mysql.cj.jdbc.Driver
          url: jdbc:mysql://localhost:3306/im_platform?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true
          username: im
          password: naQR****Gv2j7PX_

minio:
  endpoint: http://127.0.0.1:9001 #内网地址
  domain: https://www.boxim.online/file  #外网访问地址
  accessKey: admin
  secretKey: 3fBSt****uD77D6
  bucketName: box-im
  imagePath: image
  filePath: file
  videoPath: video
4.1.4. 编译

进入盒子IM管理端代码根目录,执行

mvn clean package -Dmaven.test.skip=true

将生成的im-admin.jar拷贝到服务器的/data/boxim目录

4.1.5. 启动
cd /data/boxim
# 启动im-platform
nohup java -jar im-admin.jar  --spring.profiles.active=prod &

4.2. 部署后台web端

修改im-admin-ui里面的.env.production,主要是替换VITE_APP_BASE_API的域名:

# 生产环境
VITE_APP_BASE_API = 'https://www.boxim.online/adm/api'

在im-admin-ui目录进入命令行,执行打包命令:

npm install
npm run build

将生成的dist目录压缩,拷贝至服务器的/opt/nginx目录,执行命令:

unzip dist.zip
mv dist im-admin
rm -rf dist.zip
systemctl restart nginx.service

后台web部署完成,访问地址:

https://www.boxim.online/admin

默认管理员账号: admin/admin123

15-常见问题

1. ws无法连接是什么原因?

    • 未修改配置。需修改前端的VUE_APP_WS_URL变量为im-server的服务地址
    • ws url前缀不正确。不带ssl证书时前缀是”ws://”,带ssl证书是”wss://”
    • 网站是https,但是用了ws协议(应该是用wss)

2. 视频呼叫时报错是什么原因?

    • 服务器未配置ssl证书。未配置证书的网站,浏览器将阻止用户访问摄像头和麦克风
    • 浏览器不支持webrtc
    • 未搭建coturn。跨网段视频聊天需要搭建coturn,并暴露到公网

3. 生产部署时出现跨域问题如何解决?

将前端页面、后端端口、minio文件都通过nginx的同一个端口进行代理,这样可以避免跨域。可以参考作者线上的nginx配置:📎nginx.conf

4.为什么不使用MQ,而是用redis充当消息队列

    • redis本身性能不弱,单线程能达到10w qps, 尤其在redis6.2版本之后,支持批量拉取消息。从原理上来说,其实跟一些MQ已经差不多,本质都是短轮训的模式
    • 引入MQ之后,依然不能舍弃redis(需要用来做缓存、分布式锁等),多一个组件,则多一份负担,且主流的MQ(kafak、rocketmq、rabbitmq)均不是轻量级组件,把有限的资源让给redis, 未必不会有会有更好的效果。
    • 本项目是一个开源项目,需要考虑普适性。如果盒子IM使用的是rocketmq,如果客户的原有系统用的是rabbitmq,想集成盒子IM的功能,需要同时部署多个MQ。即使用的是相同的MQ,也可能是不同版本。而redis这类组件,大部分项目都会使用,且redis的sdk版本兼容性相当不错。

并不是复杂的架构就是好架构,也不是所有的项目都是大型项目,很多时候,架构越复杂,意味着消耗的资源就却多,排查问题的难度就越大.

5.im-server如何集群化部署

    • 可以通过nginx代理,只是代理的是ws协议。
    • 这个是作者在本地测试im-server集群的nginx配置文件,通过监听nginx的9999端口,代理到两个im-server,各位可以自行参考:📎nginx.conf

6.为什么发消息不走ws,而是走http协议

    • 两种方案均是可行的,走ws的方案系统优势是消耗资源少、传输时延低,但是架构设计难度大,不利于后期做分布式扩展
    • 两种方案均需要有配套的架构设计,应在开发初期就敲定,不可以随意切换
    • http作为短连接协议, 请求时的确会消耗更多资源,但是对于整个系统而言,消耗的资源占比不会很高,也不会成为整个系统的瓶颈, 即便换成ws,对系统的整体的吞吐量也几乎不会有明显的提升

7.用户多了之后特别卡,发送消息延迟很厉害是什么原因

这个是之前帮老板排查的一个问题,后来发现他的所有服务连接mysql、redis、minio,全都是走的公网ip…

系统内部的服务通信都应该走内网ip,这个应该是很基础的常识,但是已经遇到好几个老板都是用了公网ip,所以这里专门写出来说明一下。

 

© 版权声明
THE END
喜欢就支持一下吧
点赞15 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容