☰
仿微信IM源码解析:WebSocket长连接与WebRTC音视频通话实现
2026/10/7 6:55:28 网站建设 项目流程

简介:最新仿WX即时聊天源码,是一套可用于学习即时通讯技术的完整前后端项目。代码涵盖单聊、群聊、注册、添加好友、群管理、公告、禁言、消息免打扰、置顶联系人、新消息声音提醒与浏览器通知等常见IM功能,支持发送表情、图片、语音、视频和文件消息,并已打通一对一音视频通话、文件图片在线预览,以及H5/APP打包和简易后台管理,企业模式与社区模式均有所体现。压缩包共326个文件,以107个PHP接口脚本、18个JS逻辑层、HTML/CSS页面为主,另含SQL初始化脚本、TTF/WOFF字体、PNG图片素材等,整体约10.76MB,目录结构便于按模块查阅。目前已有363人学习/下载,适合有一定Web基础、想深入仿微信IM开发或需要部署演练的开发者参考。通过这份源码可以研究已读未读状态、在线状态、群禁言等细节实现,以及音视频通话在Web端与移动端的落地方式。

1. 这套仿WX即时聊天源码,先搞懂它到底能干什么

拿到这份“仿WX即时聊天源码”时,我的第一反应是“又是个套壳 H5 吧”,拆完发现判断错了一半。它不是纯网页包壳,而是前后端分工明确的即时通讯工程:Web 端是仿微信的会话列表、聊天气泡、通讯录和发现页,后端带 WebSocket 长连接、离线消息拉取,以及基于 WebRTC 的视频语音通话。它能解决三件事:你想快速搭一个类似微信聊天界面的应用但不想从零写 UI;你需要一套真能打通音视频的 IM 参考代码,而不只是登录注册页;你正在做毕业设计或课程设计,需要一个能当场演示的完整项目。适合后端想补前端、前端想补 WebRTC 的从业者,也适合急着交项目的学生。拿回去先照第 3 章部署,跑通再读源码,效率最高。

2. 技术架构与选型:WebSocket 长连接 + WebRTC 媒体链路

2.1 为什么是 WebSocket 而不是 HTTP 轮询

聊天消息的本质是“服务端主动找客户端”,HTTP 轮询能做到但代价很大。客户端每隔几秒请求一次,延迟最低也要一个轮询周期,服务器还要扛住大量无效请求。这套源码选了 WebSocket,一次握手之后双向推送,消息延迟压到百毫秒级,这是 IM 场景的基础选型。

WebSocket 连接不是建立就完事,还要解决“看起来连着、实际早断了”的问题。运营商、浏览器、nginx 都会回收空闲连接,所以必须有心跳。我拆这套源码时看到前端维护了一个定时 ping 的循环,服务端收到 ping 回 pong,连续几次没回应就主动重连。心跳间隔通常设 30 秒,太短会打爆服务器,太长感知不到断线。

const HEARTBEAT_INTERVAL = 30000; const RECONNECT_MAX = 5; let ws = null; let heartbeatTimer = null; let reconnectCount = 0; function connect(sessionToken) { ws = new WebSocket(`wss://your-domain/ws?token=${sessionToken}`); ws.onopen = () => { startHeartbeat(); }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'PONG') return; handleMessage(msg); }; ws.onclose = () => { stopHeartbeat(); if (reconnectCount < RECONNECT_MAX) { setTimeout(() => connect(sessionToken), 2000 * reconnectCount); reconnectCount++; } }; } function startHeartbeat() { heartbeatTimer = setInterval(() => { ws.send(JSON.stringify({ type: 'PING' })); }, HEARTBEAT_INTERVAL); }

这段代码有两点值得注意。token 放在 query 里而不是请求头,因为浏览器 WebSocket API 不支持自定义 header,这是常见做法;重连用 2 秒乘重试次数的退避策略,避免几十个客户端同时断网恢复后一瞬间打爆服务端。实际使用时,建议在后端也维护一个“最后心跳时间”,超时 90 秒没有心跳就主动 close,防止僵尸连接占满文件描述符。

2.2 WebRTC 链路:STUN、TURN 与编解码的选择

文字消息走 WebSocket,音视频为什么还要另一条链路?因为媒体流不适合在可靠传输的 TCP 上跑,延迟高且带宽浪费。这套源码用的是 WebRTC,浏览器之间直接传 UDP 流,信令协商走 WebSocket,媒体数据用 DTLS/SRTP 加密。这样分工之后,服务器只转发信令,不转发媒体,单机承载力大幅提升。

WebRTC 最麻烦的不是编解码,是 NAT 穿透。两个用户都在家里路由器后面,各自拿到的 IP 是内网地址,怎么找到对方?STUN 服务器负责帮客户端发现自己映射到公网的地址,找到就能直连;找不到或双方都是对称型 NAT 时,必须走 TURN 中继转发媒体。这是我在部署这套源码时踩过的最深的一个坑,后面第 5 章会细说。先看默认配置:

参数推荐值作用
stunServerstun:stun.l.google.com:19302NAT 穿透发现公网地址,不转发媒体
turnServerturn:your-domain:3478对称 NAT 兜底,需要账号密码
iceTransportPolicyall设成 relay 时强制走中继,一般不用
maxBitrate800kbps 视频 / 32kbps 音频防止弱网拥塞
echoCancellationtrue回声消除,必须开

编解码方面,视频默认走 VP8,兼容性最好;Chrome 和 Firefox 都支持 H.264,但在低端设备上 VP8 的软编性能更稳定。音频固定 Opus,48kHz 采样,带宽占用低。如果要改分辨率,在getUserMedia的 constraints 里调 width 和 height,而不是在编码器里硬调,这是 WebRTC 的流量控制机制决定的——编码器会按网络状况自动降码率,你设的分辨率只是上限。

2.3 源码目录结构与三大模块

拿到压缩包后不要急着运行,先看目录结构。这套工程拆成前后端和 SQL 三块,互相独立,可以分开部署:

wx-im/ ├── app-server/ # 后端服务,Spring Boot 工程 │ ├── src/main/java/ │ │ ├── controller/ # HTTP 接口:登录、好友、拉取消息 │ │ ├── websocket/ # WebSocket 信令网关 │ │ └── service/ # 业务逻辑:离线消息、会话管理 │ ├── src/main/resources/ │ │ └── application.yml # 核心配置文件 │ └── pom.xml ├── app-web/ # Web 聊天端,Vue3 + Vite │ ├── src/ │ │ ├── views/ # 会话、通讯录、聊天、通话页 │ │ └── api/ # 封装 HTTP + WebSocket 客户端 │ └── package.json ├── sql/ │ └── init.sql # 建库建表脚本 └── docs/ ├── deploy.md # 部署文档 └── api.md # 接口说明
目录作用改动频率
app-server登录鉴权、消息存储、信令转发高
app-web仿微信 UI、媒体采集、通话状态机高
sql/init.sql12 张核心表:用户、好友、群组、消息、会话、呼叫记录低
docs/部署步骤与接口文档中

后端没有用单独的 MQ 或推送服务,消息落地直接用数据库表 + Redis 缓存在线状态。这种设计对单机部署友好,两台机器就能跑完整套 IM,不需要引入 Kafka、Zookeeper 之类的基础设施。代价是水平扩展需要自己改造,后面第 6 章会提。

3. 把源码跑起来:从空环境到双端联调的四步

3.1 环境依赖清单

先确认机器上有这些,缺哪个装哪个。我第一次跑的时候卡在 MySQL 字符集上,来回折腾了两小时,提醒各位先看版本。

依赖版本要求用途
JDK1.8+跑后端 Spring Boot
Maven3.6+编译后端
Node.js16+跑前端 Vite 开发服务器
MySQL5.7 或 8.0业务数据落库
Redis5.0+在线状态、token 缓存

MySQL 要额外注意一个坑:建库时字符集必须设成 utf8mb4,不能只设 utf8,否则用户昵称里带个 emoji 直接插入报错Incorrect string value。后面踩坑章节会展开。

3.2 四步部署:导入数据库到双端联调

第一步是导数据库。在项目根目录执行:

mysql -uroot -p < sql/init.sql

执行完可以用show tables;确认。如果报错提示数据库不存在,先手动建库再导:

mysql -uroot -p -e "CREATE DATABASE wx_im DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" mysql -uroot -p wx_im < sql/init.sql

init.sql 里包含 12 张核心表,用户表、好友关系表、群成员表、单聊消息表、群聊消息表、会话列表表、呼叫记录表等。消息表主键自增,同时建了from_user_id + to_user_id + create_time的联合索引,拉聊天记录时能走索引,数据量上去之后不至于全表扫。

第二步改后端配置。打开app-server/src/main/resources/application.yml:

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/wx_im?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your-password redis: host: localhost port: 6379 webrtc: stun-server: stun:stun.l.google.com:19302 turn-server: turn:your-domain:3478 turn-username: your-turn-user turn-password: your-turn-password im: ws-path: /ws token-expire-hours: 72

这三个配置段各管一件事。datasource是数据库连接,注意 URL 里必须带characterEncoding=utf8和serverTimezone,缺了会在插入中文或时间字段时报错。webrtc是媒体协商要用的穿透服务器参数,没有 TURN 服务器可以先只配 STUN,局域网点对点测试没问题,部署到公网就必须要 TURN。im里的token-expire-hours控制登录态有效时间,开发阶段建议调成 720,免得每半天重登一次。

第三步启动后端。在app-server目录执行:

mvn clean package -DskipTests java -jar target/wx-im-server-*.jar

看到日志输出WebSocket server started at ws://0.0.0.0:8080/ws就算启动成功。这一步常见问题是 Maven 下载依赖慢,换阿里云镜像能快不少。

第四步启动前端。在app-web目录执行:

npm install npm run dev

浏览器打开http://localhost:5173,用 init.sql 里预置的测试账号登录。如果没有预置账号,直接注册一个,默认用户表没有开启邮箱验证,注册即用。

3.3 配置参数速查与第一次登录验证

跑通之后别急着改代码,先把配置参数的含义记到自己的笔记里。我整理的速查表如下,后面排查问题时对照看:

参数默认值说明与建议
im.ws-path/wsWebSocket 连接路径,前端代码里写死了,改后端要同步改前端
im.token-expire-hours72token 过期时间,公网部署建议缩短到 24
spring.redislocalhost:6379在线状态存 Redis,Redis 挂了连接会拒
webrtc.stun-serverGoogle 公共 STUN只做穿透探测,不传媒体,可放心使用
webrtc.turn-server需自建没有 TURN 时跨运营商通话大概率失败

第一次登录建议做一次完整冒烟:A 账号给 B 账号发一条文字消息,再发起一次语音通话。文字消息能通说明 WebSocket 链路和数据库读写正常,语音能通说明 WebRTC 信令链路正常。两件事都成了,这套源码才算真正跑起来了,可以进入下一步的代码拆解。

4. 视频语音聊天实现链路:信令、SDP 交换与状态机

4.1 一次通话的六个信令节点

看视频通话功能,不要先看媒体代码,先看信令。信令是两个人的协商语言,这套源码里完整的通话过程是六个节点:

顺序信令类型发送方内容
1CALL主叫 → 被叫携带 roomId、主叫昵称头像
2RINGING被叫 → 主叫通知主叫已在振铃
3OFFER主叫 → 被叫携带 SDP offer
4ANSWER被叫 → 主叫携带 SDP answer
5ICE_CANDIDATE双向持续交换候选地址
6HANGUP任意方挂断并释放资源

roomId 由主叫方生成,推荐用uuid或时间戳 + 随机数。CALL 消息到达被叫端后,被叫界面弹出接听弹窗,这时候媒体链路还没建立,不要提前播放铃声以外的音效。

4.2 服务端信令转发代码拆解

后端在这个流程里只做一件事:把信令从 A 转发给 B。不要在里面写业务逻辑,比如“判断对方是否好友”这类校验应该在 WebSocket 连接建立时完成,否则信令延迟会明显增高。拆解后的核心转发逻辑:

public void handleSignal(String toUserId, SignalMessage msg) { WebSocketSession targetSession = sessionManager.getSession(toUserId); if (targetSession == null) { // 目标不在线,推送离线呼叫通知或直接丢弃 pushOfflineCall(toUserId, msg); return; } synchronized (targetSession) { targetSession.sendMessage(new TextMessage(msg.toJson())); } }

注意synchronized (targetSession)这行,不是玄学,是血泪经验。多个线程同时向同一个 WebSocket 会话 sendMessage,Netty 底层会抛Cannot send message when WebSocketChannel is not open,因为底层 channel 的写操作不是线程安全的。加锁是为了避免并发写同一个 session 导致连接异常关闭。

信令消息统一走 JSON,不要为了省流量用二进制协议,排查问题时 tcpdump 抓包能直接看到内容,这是开发期最大的便利。等上线稳定后再考虑换 Protobuf,第 6 章会提。

4.3 前端发起与应答:getUserMedia 与 RTCPeerConnection

前端是这套源码里最复杂的部分,因为要管理三个异步状态:媒体流采集、ICE 候选收集、远端流渲染。发起通话的核心代码:

async function startCall(toUserId) { const pc = new RTCPeerConnection({ iceServers: [stunServer, turnServer] }); const stream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 }, audio: { echoCancellation: true, noiseSuppression: true } }); stream.getTracks().forEach(track => pc.addTrack(track, stream)); localVideo.srcObject = stream; pc.ontrack = (event) => { remoteVideo.srcObject = event.streams[0]; }; pc.onicecandidate = (event) => { if (event.candidate) { sendSignal({ type: 'ICE_CANDIDATE', candidate: event.candidate }); } }; const offer = await pc.createOffer(); await pc.setLocalDescription(offer); sendSignal({ type: 'OFFER', sdp: pc.localDescription }); }

这段代码里有三个最容易翻车的地方。第一,getUserMedia必须在用户点击事件里调用,浏览器禁止自动播放带声音的媒体流,Chrome 会直接抛NotAllowedError。第二,createOffer之后必须先setLocalDescription再发送,发送的是pc.localDescription而不是offer,两者在大多数情况下一样,但 setLocalDescription 可能修改 SDP 内容。第三,onicecandidate回调触发时间不定,必须在信令消息里带上序号或者依赖 WebSocket 的单通道保序特性,这套源码直接复用同一个 WebSocket,天然避免乱序。

被叫方应答时逻辑对称,但多一步:收到 OFFER 后setRemoteDescription,创建 ANSWER 并setLocalDescription,然后回传。ICE 候选的交换是双向的,任何一方收集到新的 candidate 都要发给对方。

4.4 离线消息、已读回执与消息去重

音视频搞定之后,IM 的另一半是可靠消息。这套源码的消息格式是标准 JSON,字段如下:

{ "type": "TEXT", "messageId": "a3f5c1e2-8b0d-4f1a-9c2e-5d8b7a1e3f2a", "fromUserId": 1001, "toUserId": 1002, "content": "在吗", "timestamp": 1710000000000 }

messageId由客户端生成 UUID,服务端把它作为唯一索引。为什么要这样设计?因为 WebSocket 在弱网下可能重连重发,如果没有 messageId,服务端无法判断是否重复。我在拆解时确认这套源码的做法是:插入消息前先按message_id查一次,已存在直接返回“已接收”,避免重复落库。这是 IM 系统最核心的幂等设计。

离线消息的处理走 HTTP 拉取而不是 WebSocket 推送。用户登录后调用/api/message/pull?afterSeq=1000,服务端查大于这个 seq 的所有消息,批量返回。seq 是消息表自增主键,单聊群聊共用一张表,所以这个接口天然支持增量拉取。已读回执单独发一条READ_RECEIPT信令,接收方更新会话列表里的已读位置,不需要持久化到 Redis,直接更新内存状态就行,刷新页面后从数据库重新拉。

5. 避坑与排查:视频黑屏、频繁断线、消息重复的四份踩坑记录

5.1 视频通话黑屏无画面:先查 ICE 状态

现象:两端都显示已接通,通话计时在走,但双方画面都是黑的,或者只有本地画面没有远端画面。

原因:ICE 协商失败,媒体流根本没建立。看pc.iceConnectionState的值,停在checking超过十秒就是穿透失败。大多数情况是 STUN 配置了但网络环境是对称 NAT,STUN 发现不了可用地址,需要 TURN 中继。

解决:先把iceTransportPolicy临时设成relay,强制走 TURN,如果通了,就确认是穿透问题。在浏览器 Console 输入pc.getStats(),过滤transport类型,看selectedCandidatePair里是srflx还是relay。这一步能定位是 STUN 不够用还是 TURN 根本没配。

5.2 WebSocket 频繁断线:nginx 代理超时与多实例会话漂移

现象:页面开着不动,过一会儿消息发不出去,刷新后恢复,过一会儿又断。

原因:最常见的两个。后端直接暴露端口且没有断线重连,运营商回收空闲连接;或者前面挂了 nginx,默认proxy_read_timeout60 秒,空闲连接直接被掐断。

解决:先加一层心跳,30 秒一次,让连接始终处于活跃状态。再看 nginx 配置:

location /ws { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 300s; proxy_send_timeout 300s; }

proxy_set_header Upgrade和Connection "upgrade"这两行不能少,少了 WebSocket 握手直接失败。多实例部署时还要注意另一个隐藏问题:负载均衡默认轮询,第一次连接落在实例 A,第二次重连落在实例 B,B 没有 A 的 session 状态,表现为“连接成功但消息收不到”。解决方式是 nginx 开ip_hash或者在后端引入 Redis 共享 session。

5.3 消息发两次:messageId 去重与唯一索引

现象:A 给 B 发一条消息,B 看到两条一模一样的,刷新后其中一条消失。

原因:客户端发送超时自动重试,服务端没有按 messageId 去重,同一条消息被插入了两次。刷新后另一条消失,是因为拉取逻辑按 seq 增量拉,重复消息只在当时推送窗口出现。

解决:确认消息表对message_id建了唯一索引:

ALTER TABLE im_message ADD UNIQUE INDEX uk_message_id (message_id);

服务端插入前捕获DuplicateKeyException,捕获后直接返回成功,不要给客户端报错。这是幂等设计的标准姿势。客户端侧,每次发送时重新生成 messageId,但重发时必须复用同一个 messageId,否则服务端无法识别为同一条消息。

5.4 打电话半分钟自动掉线:未释放的媒体资源与信令状态失配

现象:通话到第 20 秒左右自己挂断,没有 HANGUP 信令,本地显示“通话结束”。

原因:大概率是通话状态机没对齐。一端因网络波动被动断开,另一端不知道,媒体流还在跑,但信令层已经判定超时。这套源码默认在收到 ICE 连接状态变为disconnected后 15 秒内没有恢复就主动挂断,这是保护机制,但 15 秒太短,跨网段弱网环境经常误判。

解决:把 ICE 断线检测的超时从 15 秒调到 30 秒,并增加 ICE 重启动逻辑:

pc.oniceconnectionstatechange = () => { if (pc.iceConnectionState === 'disconnected') { setTimeout(() => { if (pc.iceConnectionState === 'disconnected') { pc.restartIce(); } }, 3000); } };

restartIce()会重新触发 ICE 收集流程,很多时候能让连接在 NAT 映射过期前恢复。另外提醒一点,通话结束时记得关闭所有 track,不然麦克风和摄像头指示灯会一直亮着,这在演示现场很尴尬。

6. 复现之后的验证方法与进阶扩展:把 demo 变成可交付的项目

先做一轮验证,确认这套源码不是“能跑”而是“能用”。我每次部署完,会开两台设备,一台电脑一台手机,执行四条命令式的检查:第一,pc.getStats()里RTT稳定在 100ms 以下,packetLoss为 0,说明链路可靠;第二,用 Chrome DevTools 的 Network 面板模拟弱网,把下行带宽限制到 500kbps,文字消息必须秒达,视频会自动降清晰度而不是卡死;第三,杀掉后端进程再拉起,看前端能否在两秒内自动重连并补齐断线期间的消息;第四条不是命令,是操作:让 A 给 B 连发 50 条消息,逐条核对顺序与内容,全部一致才能算消息通道及格。

进阶改造方向有三个。最容易做的是给信令通道加一层保护,把 WebSocket 地址从 ws 换成 wss,在 nginx 层做 TLS 终止,代码几乎不用动,但安全性提升一个量级。其次是给消息体加字段级加密,推荐对content做 AES-GCM 加密,密钥通过 WebSocket 首次握手时交换,这样即使数据库泄露,明文消息也不会直接暴露。最后是把信令协议从 JSON 换成 Protobuf,字段编码后消息体积能缩小到原来的五分之一,网络差的环境下感知明显,但这需要前后端同时改,成本高,更适合作为上线后的优化项。

屏幕共享是 ROI 最高的功能扩展。在startCall里加一个分支,媒体源从getUserMedia换成getDisplayMedia,其余信令逻辑完全复用,一个下午就能做完。注意切换时要把原来的摄像头 track 停掉,否则画面会同时出现两个视频源。

这套源码的价值在于把 WebSocket 长连接、离线消息、WebRTC 信令协商这几条链路完整串通了,踩过的坑我都记在第 5 章。从那以后,我每次部署完 IM 项目,都强制走一遍“文字 → 图片 → 语音通话 → 视频通话 → 挂断”五步回归,任何一步卡住都绝不进入下一阶段开发。希望帮到你,也欢迎你把复现过程中的新坑带回来交流。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询