自建一套即时通讯,最先容易做错的是边界:把注册登录、文件上传、WebSocket 推送和运营查询放进同一个进程。流量形态完全不同。本文按 CaoIM 现在的实现,记录一种拆法,以及一条消息实际怎么走完。
CaoIM 是一套可以自己部署的 IM。服务端是 Java 17、Spring Boot,库是 MySQL。客户端是 Flutter,同一套代码覆盖 Android、iOS、Windows、macOS 和 Web。
进程怎么切
| 模块 | 职责 | 默认端口 | 库 |
|---|---|---|---|
| im-server | 好友、群、历史、已读、离线同步、一对一通话信令、WebSocket | 8080,上下文 /api |
cao_im_db |
| app-server | 注册、登录、资料、文件上传 | 8081 | cao_im_app_server |
| im-admin | 运营后台 API 和 Vue 页面 | API 8082,页面开发端口 5173 | 管理员表在 cao_im_db |
| push-server | 用户不在线时的系统通知 | 8083 | 设备表在 cao_im_db |
im-server 再分成 im-core 和 im-boot。前者是实体、服务和 Feign 契约,后者是可执行进程。app-server 和 im-admin 依赖 im-core,通过 Feign 调消息服务,请求头带 X-Internal-Token。这个内部令牌三边必须一致。客户端不依赖这个 JAR,只访问 HTTP 和 WebSocket。
后台不直接写 im_message 这类业务表。用户启停、解散群、消息检索、敏感词和系统配置,都走 im-server 的 /api/admin/**。管理员自己的账号、角色、菜单和审计在 admin_* 表,由后台服务维护。
两套令牌
客户端日常登录走 app-server,不走消息服务的登录接口。登录成功返回:
-
token:访问 app-server,改资料、上传文件 -
imToken、imRefreshToken:im-server 签发,用来建立长连接
两套 JWT 的密钥各自独立。消息服务上的 /user/login 仍然保留,给直连消息服务的场景,不是客户端的主路径。
公网部署时,不建议把长期的 imToken 放在 WebSocket 地址里。客户端先带 Authorization 调用 GET /api/ws-ticket,换一张 60 秒、只能使用一次的票据,再连接 ?ticket=。票据放在 Redis。旧客户端若仍使用 ?token=,服务端目前会放行并打日志,便于过渡。
一条消息的路径
文本和表情由 SDK 经 WebSocket 发给 im-server。图片、语音、文件、短视频先上传到 app-server,消息体里只保存 URL。服务端写入 MySQL 之后:
-
给发送方回执,表示服务端已接受
-
接收方在线,立刻从他所在的那根连接推下去
-
接收方不在线,等他上线后按离线接口拉取
-
若打开了 push-server,再尝试一条系统通知
接收方把消息展示出来之后,再上报送达回执和已读回执。私聊回执落到「已读」,群回执落到「已读人数」。当前正在查看的会话不再累计未读。
消息类型与服务端约定为:0 文本,1 图片,2 文件,3 语音,4 视频,99 系统提示。
上行帧的 type 包括 private、group、recall_message、read_receipt、delivery_ack,以及拉离线、群成员变更。心跳是 ping,服务端回 pong。
文本帧大致如下:
{ "type": "private", "toId": 123, "content": "Hello", "msgType": 0 }
文件不走这根连接。app-server 的单文件上限默认 50MB,短视频按这个上限,普通文件在客户端侧限制为 10MB。存储可以是本地目录,也可以是 S3 兼容存储(MinIO、阿里云 OSS、腾讯云 COS)。file.domain 必须是接收方设备能够访问的地址。写成 127.0.0.1 时,只有跑服务的那台机器能打开图片。
在线状态在进程内,多节点要粘滞
单机时,在线会话放在 im-server 进程里。连在 A 进程上的用户,B 进程推不进去。一台机器开发可以不启 Redis,配置里把 caoim.require-redis-for-connect 设为 false。
多节点时,在线状态、节点间投递和通话状态放到 Redis,每个进程有自己的 node-id。客户端先请求 /api/route,再直连返回的那台 wsUrl,避免连接被负载均衡打到另一台机器上。Nginx 只适合做 HTTP 入口;WebSocket 若必须经过 Nginx,要打开 Upgrade,并把读超时拉长。
仓库里的 k6 脚本用来看长连接保持、私聊吞吐、群扇出和断线补拉,那是压测场景,不是对外承诺的容量数字。群扇出仍然会打到在线成员的连接上,500 人的群和 5 万人的群不是同一件事。
历史消息可以按天龄归档到 Mongo,热数据仍留在 MySQL。归档是可选项,不开启时查询路径不变。
落库加密不是端到端
配置了消息密钥之后,im_message.content 在写入前用 AES-GCM 加密,读出后解密,密文以 enc:v1: 开头。密钥为空则保持明文,旧数据可以继续读。
加密发生在落库这一跳。推送、接口返回和敏感词看到的仍是明文,否则后台检索和关键词过滤会失效。因此这不是端到端加密,持有密钥的服务端看得到正文。正文加密之后不能再对内容做 SQL LIKE,搜索要先按会话、用户、时间缩小范围,解密后再过滤。收藏和附件文件目前仍是明文。
传输层在公网应走 TLS:账号和文件一个域名,消息和 wss 另一个域名,反代到本机的 8081 和 8080。管理端和 push-server 不必对公网开放。
通话只转发信令
一对一呼叫的上行类型是 call_invite、call_accept、call_reject、call_cancel、call_hangup、call_signal。video: true 表示视频。服务端转发 SDP 和 ICE,并写通话记录,不转发媒体。
媒体由客户端用 WebRTC 建立。仓库里的示例只配置了公共 STUN(stun:stun.l.google.com:19302)。同一局域网一般能通。对称 NAT 下跨网部署经常没有声音和画面,需要单独的 TURN,本仓库不附带。群通话没有实现。对方不在线时,呼叫直接结束,不会靠离线推送把通话拉起。
push-server 默认 delivery: log,通知只写入 push_log,手机不会响。要真正下发,需要自己接厂商通道。同一用户、同一业务在两分钟内只投递一次。推送失败不影响消息已经发送成功。
本地跑起来
环境是 JDK 17、Maven 3.6、MySQL 5.7(utf8mb4)。系统里若默认还是 JDK 8,这套代码编译不过。
mysql -u root -p < im-server/sql/schema.sql
mysql -u root -p < app-server/sql/schema.sql
cd im-server
mvn clean install -DskipTests
cd im-boot
mvn spring-boot:run
另开终端启动 app-server。已有旧库时不要重复执行会删表的脚本,按 sql/migrate_*.sql 补字段。至少改掉数据库密码、两边的 jwt.secret,以及三份必须相同的内部令牌。
启动后可以用这些地址确认进程:
| 地址 | 用途 |
|---|---|
http://localhost:8080/api/health/check |
消息服务健康检查 |
http://localhost:8080/api/swagger-ui/index.html |
消息服务接口 |
http://localhost:8081/swagger-ui/index.html |
登录与上传接口 |
生产环境关掉或限制 Swagger。/api/debug/** 会返回在线用户,不要对公网开放。
还没做的部分
语音转文字没有。语音文件已经在 app-server,识别如果要加,应放在服务端调用本机模型,不要打进客户端安装包。支付、小程序、视频号不在这个仓库的范围内。
源码在 GitHub、Gitee 的 cao-im 组织,入口仓库是 im-server,其余模块从 README 的仓库表可以找到。个人学习和非商用可以直接使用。协议不是 MIT:商用同样不收费,但需要先取得授权。未授权不要用于公司产品和向客户交付。