ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从0到1实现一个IM:WebSocket服务端与客户端核心链路解析

从0到1实现一个IM:WebSocket服务端与客户端核心链路解析 做后端开发的人迟早要碰一次IM不管是自己练手、写个聊天室还是公司要做一个客服系统。我见过太多人一听到“高并发IM”就发怵其实IM最基本的形态非常简单一个服务端、一个客户端加上一条消息流转的通道。这篇东西就是写这个的。我不讲架构不聊分布式只把最基础的IM服务端和客户端从0到1拆开让你明白消息是怎么从A的屏幕跑到B的屏幕上的。适合刚接触网络编程、想自己写个聊天工具的开发者也适合想搞懂IM底层原理、免得以后面试被问到就懵的人。1. 一个最基本的IM到底在做什么1.1 别被“IM”两个字吓住即时通讯IM听起来高大上本质就是“两个人通过网络互相传文字”。它和你在浏览器里访问网页、在App里刷列表没有本质区别只不过IM对“实时性”的要求更高A发一条消息B最好能在几百毫秒内看到。要做到这一点最朴素的模型只有三个角色发送方、接收方、以及中间负责转发的服务端。发送方和接收方都是客户端服务端就像一个邮局。A把信交给邮局邮局根据收件人地址把信送到B手里。如果B当时不在家邮局还得先把信存起来等B回来再给。这就是离线消息。很多初学者一上来就想着怎么做群聊、怎么做加密、怎么做已读回执结果代码写了一大堆连最基本的“单聊消息从A到B”都没跑通。我自己的经验是先做一个能跑通的最简闭环再慢慢加功能。这篇就是用最朴素的方式把服务端和客户端的骨架搭出来。1.2 服务端和客户端的职责边界很多人搞不清哪些逻辑该放服务端哪些该放客户端。其实有一条很简单的分界线凡是需要“全局状态”的必须放服务端凡是只和“当前用户界面”相关的放客户端就行。举个例子在线状态列表就是全局状态。A上线了B应该能看到A在线这个判断必须由服务端来维护因为A和B各自都不知道对方的情况。而聊天记录在界面上怎么展示、气泡靠左还是靠右这是客户端自己的事服务端不需要管。我列了一张表可以帮你快速对齐两者分工职责服务端客户端连接管理维护所有客户端的网络连接负责发起连接、断开连接身份认证验证用户身份、绑定用户ID和连接发起登录请求携带账号信息在线状态记录哪些用户在线展示当前联系人状态消息路由根据消息目标找到对应连接并转发发送消息时带上目标用户ID消息存储保存历史消息、离线消息按需拉取历史记录界面交互无输入框、消息气泡、状态提示这条边界一旦想清楚写代码时就不会什么都往服务端堆也不会什么都往客户端塞。最简单的IM服务端其实只需要做三件事管连接、认身份、转消息。2. 技术选型我为什么选这套组合2.1 通信层WebSocket优先客户端和服务端之间需要一条“实时双向通道”。传统做法是HTTP轮询客户端每隔几秒问一次“有新消息吗”简单但浪费流量而且做不到真正实时。TCP长连接能做但浏览器里的网页应用没法直接裸用TCP需要自己实现协议对新手不友好。所以我推荐用WebSocket。它在浏览器里原生支持服务端这边用Node.js、Java、Go、Python都有很成熟的库。WebSocket是真正的全双工服务端可以主动往客户端推数据客户端也可以随时发数据非常适合IM这种场景。我见过有人问“要不要自己用Socket写一个IM”如果是为了学习底层网络原理那当然可以但如果只想快速把IM跑起来WebSocket是性价比最高的选择。就算你以后要写原生客户端WebSocket协议同样是跨平台的。2.2 状态存储Redis存储在线状态MySQL存用户和消息在线状态这种数据特点是“变化快、量不大、丢了也无所谓”。用户上线了就写一个在线标记掉线了就删掉。这种场景很适合用Redis读写快天然带过期时间配合Redis可视化客户端可以一眼看到当前哪些用户在线。用户资料和聊天记录则是“不能丢”的数据得放到数据库里。初学者用MySQL就足够了建一张用户表、一张消息表逻辑清晰。这里要注意不要把用户在线状态这种东西放到MySQL里去否则每次上下线都要更新数据库数据库压力很大而且没有必要。在我这个最基本版本里存储可以再简化用户数据先写死几个测试账号消息记录暂时不落库只做实时转发。先把链路跑通后面再补持久化。别嫌这个版本“太简陋”能把一条消息从A传到B就是IM的核心能力。2.3 客户端选型先用Web页面跑通再考虑其他端客户端我建议先用Web页面做。原因很简单浏览器自带WebSocket API打开一个HTML文件就能跑不用装Android Studio、不用配Xcode也不需要处理各种系统级网络权限。等Web端跑通之后你再去做桌面客户端或者移动客户端核心逻辑是一样的都是“连接服务端、发协议包、收协议包、渲染界面”。很多热词里提到的“豆包linux客户端”“苹果电脑svn客户端”之类的本质上也都是“客户端”这个概念在不同场景下的具体形态底层网络通信大同小异。所以这篇文章的示例代码会以Web端为主服务端用Node.js写。你只要了解JavaScript就能完整复现。3. 服务端从零开始连接、登录、转发3.1 基础骨架一个能收消息的WebSocket服务先用Node.js搭一个最简服务端依赖用ws这个库。安装命令是npm install ws然后写下面的代码const WebSocket require(ws); const wss new WebSocket.Server({ port: 8080 }); wss.on(connection, (ws) { console.log(新客户端连接); ws.on(message, (data) { console.log(收到消息:, data.toString()); }); ws.on(close, () { console.log(客户端断开); }); });这段代码只是打地基。wss.on(connection)会在每个客户端连上来时触发里面的ws代表当前这个客户端连接。ws.on(message)能收到客户端发来的原始数据。到这里服务端已经能“收消息”了但它不知道这条消息是谁发的、要发给谁。所以下一步是处理登录包。3.2 登录包怎么知道谁在说话WebSocket连接建立后服务端只知道“有个连接进来了”并不知道这背后是哪个用户。所以IM协议的第一步通常是客户端主动发送一个登录包。我习惯用JSON作为消息格式简单、可读、好调试。一个登录包长这样{ type: login, userId: alice, token: test123 }服务端收到后做两件事把userId和当前连接ws绑定在一起保存到一个内存Map里然后告诉客户端“登录成功”。代码可以这样写const clients new Map(); wss.on(connection, (ws) { ws.on(message, (data) { const msg JSON.parse(data.toString()); if (msg.type login) { clients.set(msg.userId, ws); ws.userId msg.userId; ws.send(JSON.stringify({ type: loginSuccess, userId: msg.userId })); } }); });这里有一个细节我在ws对象上额外挂了一个userId属性方便后面断线时知道这个连接属于谁。clients这个Map就是整个IM的核心它记录了“每个用户当前对应的连接”。如果这个Map查不到某个用户说明该用户不在线。3.3 转发逻辑一条消息从A到B的完整路径登录完成之后客户端就可以发消息了。消息协议也可以很简单{ type: chat, from: alice, to: bob, content: 你好 }服务端收到后先从clients里查一下to这个用户有没有在线连接。如果在线就把这条消息原样转发过去如果不在线就先存到“离线消息”列表里后面再讲。核心代码if (msg.type chat) { const targetWs clients.get(msg.to); if (targetWs) { targetWs.send(JSON.stringify(msg)); } else { // 目标不在线可以记一条离线日志 console.log(用户不在线暂存离线消息:, msg.to); } }整个路径就是alice的客户端 - 服务端 - bob的客户端。这里没有经过数据库也没有经过消息队列因为最基本的IM根本用不着那些东西。我在这个环节踩过最大的坑是服务端把消息转发出去之后忘记告诉发送方“发送成功”或“对方离线”。导致客户端以为消息发出去了实际上对方根本没收到。所以你至少要在协议里加一个ack字段或者直接回复发送方一条sendResult消息。3.4 心跳与断线清理防止僵尸连接网络连接是不可靠的客户端可能直接断网、关电脑服务端不一定能立刻感知到。如果不做处理clients这个Map里就会堆积大量“僵尸连接”消息往这些连接上发既发不出去还白白占着内存。解决办法是心跳机制。客户端每隔一段时间发一个心跳包服务端如果超过一定时间没收到某个连接的数据就认为它已经死了主动关闭连接并清理Map。代码可以这样加const HEARTBEAT_TIMEOUT 60000; // 60秒没有消息就断开 function heartbeatCheck(userId, ws) { ws.isAlive true; ws.on(pong, () { ws.isAlive true; }); const timer setInterval(() { if (!ws.isAlive) { clearInterval(timer); clients.delete(userId); ws.terminate(); } else { ws.isAlive false; ws.ping(); } }, HEARTBEAT_TIMEOUT); }这段代码用的是WebSocket自带的ping/pong机制比自定义心跳包更省流量。你在客户端不需要手动回pong浏览器和WebSocket库会自动处理。服务端只要定期检测哪个连接没回pong就把哪个连接清掉。这是整个服务端最容易漏掉的部分。很多人写IM连心跳都不知道要加结果服务端跑个半天连接数越来越多内存也不断往上涨。4. 客户端接入连线、发消息、收消息4.1 连接服务器并完成登录握手服务端写好了客户端这边就简单多了。Web端的WebSocket连接只需要一行代码const ws new WebSocket(ws://localhost:8080);连接是异步的你要在onopen事件里发送登录包。注意一个常见错误很多人把send直接写在new WebSocket后面这时候连接还没建立消息会丢失。const userId alice; ws.onopen () { ws.send(JSON.stringify({ type: login, userId: userId, token: test123 })); };服务端收到登录包后会回一条loginSuccess。你可以在onmessage里处理ws.onmessage (event) { const msg JSON.parse(event.data); if (msg.type loginSuccess) { console.log(登录成功:, msg.userId); } };登录成功之后这个客户端就算正式接入系统了。接下来要做的事情只有两件发消息、收消息。4.2 发送消息把用户输入变成协议包客户端发消息通常由用户操作触发比如点击“发送”按钮或者按回车。发之前要拼接一个协议包然后send出去function sendMessage(toUserId, content) { const msg { type: chat, from: userId, to: toUserId, content: content }; ws.send(JSON.stringify(msg)); }这里有个小坑如果用户输入的内容里有换行、引号、特殊字符直接拼JSON可能会出问题。最稳妥的方式是用JSON.stringify而不是手动拼字符串。发送消息不能只发出去不管。好的客户端会在界面里先显示一条“发送中”的消息等服务端确认后再把状态改成“已发送”。如果服务端返回“对方离线”客户端应该给出提示。这就要求服务端在转发失败时给发送方回一条错误消息你才能在这里做对应逻辑。4.3 接收消息解析响应并渲染到界面收消息是IM客户端最核心的体验。收到消息后先判断消息类型再根据类型做不同处理ws.onmessage (event) { const msg JSON.parse(event.data); if (msg.type chat) { // 渲染一条消息到聊天窗口 renderMessage(msg.from, msg.content); } else if (msg.type system) { // 系统通知比如用户上线/下线 updateUserStatus(msg.userId, msg.online); } };渲染聊天界面时一般会区分“自己发的消息”和“对方发的消息”。最简单的方式是判断msg.from是否等于当前登录用户的ID。等于就是右边气泡不等于就是左边气泡。实际做的时候还会遇到一个体验问题消息列表要通过滚动来加载。新消息进来时要么自动滚到底部要么在底部出现“有新消息”的提示。这个逻辑不复杂但很影响使用感受建议你在一开始就把滚动位置处理好。4.4 断线重连客户端必做的保命手段WebSocket连接一定会断网络切换、服务端重启、代理超时、手机锁屏原因太多了。没有一个自动重连机制的IM客户端是完全没法用的。最简单的重连策略是检测到onclose事件后隔一段时间重新new WebSocket。为了避免服务端刚重启完、立刻重连导致的新一轮崩溃我习惯用“指数退避”function connect() { ws new WebSocket(ws://localhost:8080); ws.onopen () { // 登录逻辑 login(ws); }; ws.onclose () { const delay Math.min(5000, retryCount * 1000); retryCount; setTimeout(connect, delay); }; ws.onmessage (event) { retryCount 0; // 正常消息处理 }; }这里retryCount是重连次数第一次等待1秒第二次2秒最多5秒封顶。每次成功收到消息后就把重试次数清零这样网络恢复时能立刻正常使用不会一直处于“等待重连”的状态。断线重连时还有一个坑重连成功后服务端会认为进来了一个新连接老连接可能还在Map里。所以你的登录包设计一定要能支持“同一用户二次登录时把旧连接踢掉”。服务端要做的就是从Map里取出旧连接并关闭再把新连接放进去。5. 我踩过的坑常见问题与排查技巧5.1 连接总是被莫名断开这是我在实际项目里遇到最多的问题尤其是浏览器环境。原因主要有这么几类浏览器标签页休眠、公司网络代理超时、Nginx或网关的idle超时、以及防火墙对长连接的限制。排查方法很简单先看服务端日志确认断开时有没有收到close事件。如果没有任何日志就断了多半是中间网络设备掐掉的。此时优先做两件事第一把心跳间隔调到30秒第二在Nginx或网关层把代理空闲超时调大到300秒以上。如果是本地开发环境还要注意是不是有多个服务抢占同一个端口。我遇到过好几次服务端明明在跑客户端却连不上一看是另一个进程把8080占了。5.2 消息发出去对方却收不到这个问题要从两端同时排查。我习惯按下面的顺序查排查点检查方法服务端是否收到消息在message回调里打印完整日志目标用户是否在线查clientsMap里有没有目标用户目标用户连接是否绑定正确看登录包里的userId是否和Map的key一致客户端是否收到消息在onmessage里打印所有数据消息协议是否匹配检查type、from、to字段名是否前后端一致其中“字段名不一致”是最隐蔽的坑。我曾经把服务端字段命名为toUser客户端用的是to结果服务端永远找不到目标用户所有消息都进了离线列表。后来我用接口测试工具直接模拟客户端发消息才定位到是字段名的问题。这里要单独说一句调试IM时不要只靠客户端界面。推荐用命令行工具或者接口测试工具直接连WebSocket服务端打上详细日志。否则你很难分清“是客户端没发出去”还是“服务端没转发”。5.3 重复消息和乱序问题重复消息最常见的原因是客户端重连后重新发送了上次没发成功的消息。比如用户点了一次发送网络超时客户端自动重试了一次结果服务端实际收到了两次对方就看到两条一模一样的消息。解决办法是在消息协议里加一个messageId每次发送时生成唯一ID。服务端转发时保留这个ID客户端收到消息时根据ID做去重。最简单的去重方法是在客户端维护一个已经接收到的ID列表重复的ID直接忽略。乱序问题在TCP和WebSocket层面一般不会发生但如果你后面做了多服务端部署或者用了消息队列就可能会乱序。到那时候可以加一个timestamp字段客户端按时间排序后再渲染。不过在最基本的版本里这个可以先不用管。5.4 连接数一多服务端就卡我最初用Node.js写的时候连接数到几百就开始卡查了半天发现是日志打印太多文件描述符也不够用。两个关键点第一连上来的每个WebSocket都会占用一个文件描述符Linux系统默认可能只允许1024个需要调大ulimit第二服务端不要在每个连接上无脑打印日志压测时要分级打印。如果后续要做到“高并发IM”那种级别靠单机内存Map是撑不住的。你需要把在线状态放进Redis用Redis的Pub/Sub做消息广播再用消息队列做削峰。这些是扩展方向但前提是你先把单机版跑扎实。6. 从“最基本”到“能上线”的几条扩展路径6.1 消息落库和离线消息最基本的IM只做实时转发对方不在线消息就丢了。真要能用必须做离线消息。做法很简单服务端在转发失败时把消息存到MySQL的消息表里等目标用户下次登录成功后再查一次离线消息一条条补发过去。这里有个经验离线消息表别只存内容和时间还要存一个is_read字段否则用户客户端重启后会反复拉取同一批消息。补发完成之后记得标记已读。6.2 多服务端节点与会话共享当你有多台IM服务端时clientsMap这个方案就失效了因为用户的连接在A机器消息却发到了B机器。这时候必须把在线状态放到Redis里并且让所有服务端节点监听同一个Redis频道。流程变成A机器在Redis里记录“bob在A机器在线”Alice发消息到B机器时B机器去Redis查bob的节点发现是A机器就把消息发布到Redis频道A机器收到后再转发给bob。这就是最基本的分布式IM路由。理解了单机版再去理解这套方案会轻松很多。6.3 群聊、已读回执和输入状态这些功能都是在基本协议上做加法。群聊就是把消息同时转发给群成员列表已读回执需要消息带ID接收方收到后上报“已读”输入状态则是客户端在用户打字时发一个typing事件服务端转发给对方。每加一个功能协议就要扩展一个type。所以一开始设计协议时一定要保留type字段不要把所有消息都塞进一个类型里否则后面对接的客户端会越写越乱。6.4 给客户端做一层SDK封装Web端直接写在页面里的WebSocket代码用久了会非常难维护。比较好的做法是封装一个简单的SDK把连接、心跳、重连、消息收发都封装成几个方法const im new IMClient({ url: ws://localhost:8080, userId: alice, onMessage: (msg) { /* 渲染 */ } }); im.connect(); im.sendMessage(bob, 你好);这样页面代码只关注界面渲染网络逻辑都收敛到一个模块里。以后要做多端也可以复用同一套协议和SDK设计。我做这个小项目时最深的体会是IM看起来复杂但如果能静下心把最基本的一条消息从A传到B跑通后面所有的高级功能都只是在这个链路上做补充。很多人卡住不是因为IM难而是因为一开始就想着“高并发”“分布式”最后连最基础的发送接收都没调通。先自己动手把服务端和客户端连起来跑通一遍登录、心跳、转发、重连再去谈架构和优化路会顺很多。
返回列表