2026/10/7 6:55:32

仿微信IM源码解析:WebSocket长连接与WebRTC音视频通话实现

仿微信IM源码解析:WebSocket长连接与WebRTC音视频通话实现 简介最新仿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 的软编性能更稳定。音频固定 Opus48kHz 采样带宽占用低。如果要改分辨率在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 BootMaven3.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.sqlinit.sql 里包含 12 张核心表用户表、好友关系表、群成员表、单聊消息表、群聊消息表、会话列表表、呼叫记录表等。消息表主键自增同时建了from_user_id to_user_id create_time的联合索引拉聊天记录时能走索引数据量上去之后不至于全表扫。第二步改后端配置。打开app-server/src/main/resources/application.ymlserver: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/wx_im?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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 里必须带characterEncodingutf8和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 过期时间公网部署建议缩短到 24spring.redislocalhost:6379在线状态存 RedisRedis 挂了连接会拒webrtc.stun-serverGoogle 公共 STUN只做穿透探测不传媒体可放心使用webrtc.turn-server需自建没有 TURN 时跨运营商通话大概率失败第一次登录建议做一次完整冒烟A 账号给 B 账号发一条文字消息再发起一次语音通话。文字消息能通说明 WebSocket 链路和数据库读写正常语音能通说明 WebRTC 信令链路正常。两件事都成了这套源码才算真正跑起来了可以进入下一步的代码拆解。4. 视频语音聊天实现链路信令、SDP 交换与状态机4.1 一次通话的六个信令节点看视频通话功能不要先看媒体代码先看信令。信令是两个人的协商语言这套源码里完整的通话过程是六个节点顺序信令类型发送方内容1CALL主叫 → 被叫携带 roomId、主叫昵称头像2RINGING被叫 → 主叫通知主叫已在振铃3OFFER主叫 → 被叫携带 SDP offer4ANSWER被叫 → 主叫携带 SDP answer5ICE_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 会话 sendMessageNetty 底层会抛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?afterSeq1000服务端查大于这个 seq 的所有消息批量返回。seq 是消息表自增主键单聊群聊共用一张表所以这个接口天然支持增量拉取。已读回执单独发一条READ_RECEIPT信令接收方更新会话列表里的已读位置不需要持久化到 Redis直接更新内存状态就行刷新页面后从数据库重新拉。5. 避坑与排查视频黑屏、频繁断线、消息重复的四份踩坑记录5.1 视频通话黑屏无画面先查 ICE 状态现象两端都显示已接通通话计时在走但双方画面都是黑的或者只有本地画面没有远端画面。原因ICE 协商失败媒体流根本没建立。看pc.iceConnectionState的值停在checking超过十秒就是穿透失败。大多数情况是 STUN 配置了但网络环境是对称 NATSTUN 发现不了可用地址需要 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第二次重连落在实例 BB 没有 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 项目都强制走一遍“文字 → 图片 → 语音通话 → 视频通话 → 挂断”五步回归任何一步卡住都绝不进入下一阶段开发。希望帮到你也欢迎你把复现过程中的新坑带回来交流。本文还有配套的精品资源点击获取