
写这个项目的起因很简单我被前后端联调折磨够了。做 HermesGateway 这个 0.5 版本的项目不是为了炫技也不是为了追逐“前后端分离”的热门概念而是想认真回答一个问题前后端的耦合到底是怎么产生的能不能用一种比 REST 接口更弹性的方式把它切开。先说结论HermesGateway 是一个夹在浏览器和业务后端之间的通信网关它用cmd和notify两套消息语义代替传统的“请求-响应”直连。前端只管发命令、收通知后端只管收命令、发通知网关在中间负责连接管理、消息路由和生命周期兜底。这个思路对正在做前后端分离项目、不想每次接口调整都拉着两端一起改代码的团队应该会有参考价值。我会把设计考量、核心实现和 0.5 版本踩过的坑都写出来。1. 为什么需要 HermesGateway先在联调地狱里找答案1.1 前后端真正绑死的是什么不是接口名是实现细节很多人以为前后端耦合是“接口路径对不上”“参数命名不统一”这种表面问题实际做过几个中大型项目就会发现真正要命的是前后端共享了太多“实现细节”。举一个很常见的场景。后端提供一个getUserInfo接口返回结构里有id、name、avatar三个字段。前端在三个页面里用了这个接口每个页面都有自己对返回数据的加工逻辑。突然有一天后端为了性能优化把avatar的返回规则改了——原来返回完整 URL现在只返回相对路径。于是前端所有用到avatar的地方都要跟着改。改就改吧问题是很多项目里前端对接口的“使用方式”比接口本身更脆弱。有人直接把返回对象塞进状态管理有人把错误码做成了业务判断依据有人甚至依赖了接口执行顺序。只要后端内部结构调整哪怕 URL 完全没变前端也可能炸一片。还有一种更隐蔽的耦合多个端共享一套后端能力的时候。同一个“创建订单”动作Web 端、小程序端、管理后台可能各自封装了一套调用逻辑三份代码里的重试策略、错误处理、数据映射全都不一样。后端一旦调整了某个参数的含义三个端要分别修一遍联调工作量直接翻三倍。这些问题的核心不是“接口定义得好不好”而是“前端被绑定到了后端的实现方式上”。REST 接口天然就把“操作”拆散在 URL、HTTP 方法、状态码和响应体里前端每次调用都必须理解后端的资源模型。而资源模型恰恰是后端最容易变化的部分。1.2 cmd 和 notify 的核心语义一个发指令一个给结果HermesGateway 想做的事情是让前端不再“调用接口”而是“发出命令”。cmd 这条语义线解决的是“我要做什么”。前端构造一条消息里面包含一个命令名和对应的参数比如createOrder、updateUser、syncCart。这条消息不指向任何 URL不依赖任何 HTTP 方法它只表达业务意图。后端收到 cmd 之后内部怎么处理是后端自己的事。可以用老的 service也可以换新的实现只要对外承诺“收到这个命令我会在合适的时机给你结果”就行。前端不需要知道这个命令背后是查了 MySQL 还是调了别的微服务。notify 这条语义线解决的是“发生了什么”。后端完成一个操作之后向网关发出通知比如order.created、user.updated。网关根据订阅关系把通知推给相关的前端。这套模式其实在 IM、物联网、游戏行业里非常成熟我只是把它搬到了普通 web 业务里。它的核心好处是前后端之间的契约从“数据结构”变成了“业务动作”。只要 cmd 的名字和参数字段不变后端内部随便折腾都影响不到前端只要 notify 的 topic 不变前端内部的渲染逻辑怎么改也影响不到后端。拿生活里的例子类比REST 接口像是你跑到银行柜台对着柜员说“我要办业务”然后柜员给你一堆表格让你填。cmd/notify 更像是你给某个服务号发一条“帮我查一下余额”的指令消息系统回你一条“您的余额是多少”的通知。你不关心后面是哪个柜员处理的你只关心指令发没发出去、结果有没有回来。2. HermesGateway 0.5 的整体设计边界、协议和选型2.1 网关只做信使连接、路由、分发其余一概不碰起名 Hermes 是因为这个网关的定位非常像希腊神话里的信使神只负责传递消息绝不掺和消息内容。0.5 版本里网关只有五个职责连接管理维护所有前端 WebSocket 连接的生命周期包括握手、心跳、断线检测和清理。协议解析把前端发来的 JSON 消息解析成内部统一的Message结构。命令路由把 cmd 消息转给后端的某个 worker 进程去处理不关心 worker 怎么处理。订阅管理维护一张“谁订阅了哪个 topic”的表notify 消息按这张表分发。基础鉴权在连接建立阶段校验前端身份拿到身份信息后放到连接上下文里。除此之外的事情比如说“cmd 对应的业务逻辑是什么”“notify 要不要入库”“一条消息处理失败要不要重试”网关一律不回答。这些逻辑要么放在后端 worker 里要么放在前端 SDK 里不允许长在网关身上。边界清晰带来一个直接的好处网关代码量很小0.5 版本整体不过一千行出头。因为心态上克制出问题时排查范围也小。如果网关也要管业务那它就会退化成另一个“业务耦合点”反而背道而驰。2.2 消息协议cmd、notify、seq、tid 各管一段0.5 版本的消息协议用 JSON 表示核心字段如下{ type: cmd, cmd: createOrder, seq: 1700000000000_12, tid: 9f8c1e2a-b3d4-4e5f-9a6b-7c8d9e0f1a2b, payload: { goodsId: g123, quantity: 2 }, ts: 1700000000000 }type只有两个值cmd和notify。cmd字段写给后端看表示这条消息要触发什么业务动作。seq是前端生成的请求序号用来把“命令”和“结果”配对。tid是整条链路的追踪 ID从命令发出一直带到最终通知排查问题的时候靠它串起全链路。payload是业务参数网关原样透传绝不解析内部结构。notify 消息长这样{ type: notify, topic: order.created, target: { userId: u456 }, payload: { orderId: o789 }, tid: 9f8c1e2a-b3d4-4e5f-9a6b-7c8d9e0f1a2b, ts: 1700000000100 }topic是事件主题target指定推送范围tid必须和触发它的 cmd 消息保持一致。设计协议时我特意保留了seq和tid两个看起来“面向未来”的字段。0.2 版本里没有这两个字段结果出了消息丢失的问题靠日志根本对不上哪条命令对应哪条通知。后来补上的时候发现很多代码已经写死了只能带着兼容逻辑艰难迭代。所以现在强烈建议协议设计第一版就把消息 ID 和链路追踪 ID 放进去别等出了问题再补。2.3 为什么不直接用消息中间件非要自己写个薄网关很多人看到“发布订阅 消息分发”的第一反应是这不是 RabbitMQ / Kafka 的活吗为什么要自己写两个原因。第一是成本。为一个内部项目引入 Kafka意味着要维护集群、处理分区和消费组的概念、升级监控体系。0.5 版本想验证的是“cmd notify 这套语义是否适合当前业务”而不是“能不能跑通一套完整的消息中间件”。用网关先把语义跑通将来真的需要持久化、削峰和跨机房复制时再把存储层替换成消息中间件也不迟。第二是语义不完全一样。消息中间件更擅长“事件广播”生产者发一条事件多个消费者各取所需。但是 cmd 场景要求的是“命令 - 结果”双向交互前端发命令要能等到一个与之配对的结果notify 场景要求“按目标推送”不是所有订阅者都该收到同一条通知而是只有命中 target 的人才该收到。用消息中间件搭这套语义不是不行但要额外引入 RPC 通道、回调队列、路由规则复杂度反而更高。而一个专注连接和路由的薄网关协议自己定行为自己控制发现问题能直接改代码不依赖外部文档。对于个人项目来说可控性比扩展性更重要。3. 核心实现拆解前端怎么发、网关怎么转、后端怎么接3.1 前端 SDKsend 一条命令、订阅一个 notify前端 SDK 是接入成本最高的部分必须做得足够薄。0.5 版本的 TypeScript SDK 核心只有四个方法connect、send、onNotify、disconnect。send方法做三件事生成seq、把消息发到网关、把 Promise 的 resolve 存进一个 Map。后端的处理结果会通过一条带相同seq的 notify 消息回来SDK 收到之后从 Map 里找到对应的 Promise 并 resolve。class HermesClient { private ws: WebSocket | null null; private seq 0; private pending new Mapstring, (payload: any) void(); async connect(url: string, token: string): Promisevoid { this.ws new WebSocket(${url}?token${token}); this.ws.onmessage (event) this.handleMessage(event.data); // 心跳、重连逻辑省略 } send(cmd: string, payload?: any): Promiseany { const messageId ${Date.now()}_${this.seq}; this.ws.send(JSON.stringify({ type: cmd, cmd, seq: messageId, payload, ts: Date.now() })); return new Promise((resolve) { this.pending.set(messageId, resolve); // 这里应该加超时清理0.5 版本用的是 setTimeout delete }); } onNotify(topic: string, handler: (payload: any) void): void { // 维护一个 topic - handler 列表收到 notify 时按 topic 分发 } private handleMessage(raw: string): void { const msg JSON.parse(raw); if (msg.type notify) { if (msg.seq this.pending.has(msg.seq)) { // 这是一条命令结果通知 this.pending.get(msg.seq)!(msg.payload); this.pending.delete(msg.seq); return; } // 这是一条主动推送通知 this.dispatchNotify(msg.topic, msg.payload); } } }这个设计有几个细节值得说。第一pendingMap 是命令和结果配对的唯一依据所以seq必须全局递增、跨重连不重复。0.5 版本用“时间戳 自增序号”保证唯一性。第二notify 消息是否带seq决定了它是“命令结果”还是“主动推送”两者走完全不同的分发逻辑。第三超时清理一定要有。实际运行里后端可能出现长时间不返回的情况不清理的话 Map 会越积越大内存就爆了。onNotify的接口专门给前端业务方订阅“与自己业务相关的事件”。比如下单页面只需要订阅order.created其他页面的通知一概不收从源头避免“全局广播 前端自己过滤”的性能浪费。3.2 网关服务端连接表、订阅表、命令分发网关服务端的核心数据结构我就用了两张表。第一张是连接表connId - ConnectionContext。ConnectionContext里放着 WebSocket 实例、登录用户 ID、订阅主题集合、最近心跳时间。第二张是订阅表topic - SetconnId。前端在connect完成之后SDK 会自动向后端注册自己关心的主题列表网关把它们写进订阅表。notify 消息到达时网关解析topic去订阅表里找出所有该收这条通知的连接逐个发送。// gateway.js 极简核心 const connections new Map(); // connId - context const subscriptions new Map(); // topic - SetconnId function dispatch(client, raw) { const msg JSON.parse(raw); if (msg.type cmd) { // 命令消息匹配后端 worker 队列 enqueueToWorker(msg); return; } if (msg.type notify) { const targetConnections resolveTarget(msg); targetConnections.forEach((connId) { const ctx connections.get(connId); if (ctx ctx.isAlive) { ctx.ws.send(JSON.stringify(msg)); } }); } } function resolveTarget(msg) { if (msg.target msg.target.connId) { // 点对点只发给某个连接 return new Set([msg.target.connId]); } if (msg.target msg.target.userId) { // 按用户查连接表里所有属于该用户的连接 return findConnectionsByUserId(msg.target.userId); } // 默认按 topic 订阅关系分发 return subscriptions.get(msg.topic) || new Set(); }在这个实现里cmd 消息走 worker 队列notify 消息走订阅表。两者的数据流完全分开互不干扰。worker 是独立进程可以通过进程间通信接收 cmd0.5 版本为了简单用了 Node.js 的child_process.fork加 IPC 通道实际上换成线程队列、Redis stream 或者 HTTP 内部转发都行网关对这部分不敏感。值得强调的点是连接表按连接维度管理而不是按用户维度管理。同一个用户可能开着多个标签页每个标签页是独立的 WebSocket 连接。notify 阶段如果按userId推送就要把该用户所有活跃连接的订阅表全部查出来。这个逻辑单独抽成findConnectionsByUserId函数跑起来没问题但要注意连接断开时一定要清理所有相关索引否则会出现“连接早就关了但订阅表里还残留着 connId”的幽灵条目通知发送时才发现连接不存在白白浪费一次查询。3.3 后端适配器逻辑层不感知连接只用处理器和通知后端接入 HermesGateway 需要两个东西一个命令处理器注册表一个通知发送器。处理器注册用注解风格实现。某个类的方法加上CommandHandler(createOrder)注解网关的 worker 收到cmd: createOrder时反射调用这个方法参数从payload反序列化而来返回值统一包装成一条带seq的 notify 消息回给请求方。Component public class OrderHandler { CommandHandler(createOrder) public OrderCreatedDto handleCreateOrder(Payload CreateOrderCommand cmd) { // 业务逻辑比如调 service、写库 // 业务完成之后主动向用户广播事件 gateway.notify(order.created, targetUser(cmd.getUserId()), OrderCreatedDto.from(order)); return new OrderCreatedDto(order.getId()); } }这么设计给后端带来一个明显的好处业务代码里再也不用出现 WebSocket、HTTP Session 这类技术概念了。一个方法收到命令、返回结果最多通过依赖注入拿到一个Gateway对象发通知其他一律不碰。单测只需要 mock 掉Gateway接口业务逻辑不用起服务就能验证。异步场景下这套模式更舒服。比如一个命令要花十秒钟处理后端可以先回一条{status: processing}的通知确认收到等真正处理完再发order.completed。前端那边既可以用 seq 等第一波结果也可以订阅order.completed拿最终状态。整个模型里不存在“谁调用谁”的锁死感两端各自按照自己的节奏前进。4. 0.5 版本踩坑实录消息丢失、粘包、重连风暴4.1 消息丢包和重复从抓包到 seq 与 ack 补偿0.5 版本上线之前我做过一轮自测发现了一个诡异问题某些命令发出去之后前端一直没有收到结果通知。网络明明没问题后端日志也显示处理成功了结果就是到不了前端。后来抓包才发现问题出在后端 worker 进程重启的瞬间。worker 进程重启时会先断开 IPC 通道但网关并不知道这个消息还没处理完还是会往通道里写数据。这时候通道已经不存在了消息直接丢失。后端处理成功了但结果回不来前端卡在 pending 状态。这个问题的修复分两层。第一层是网关侧加 ack 机制。worker 收到 cmd 之后必须回一条内部 ACK网关收到 ACK 才知道这条命令被承接了。如果超过两秒没收到 ACK网关把消息重新放回队列。第二层是前端侧的超时重发。send()里设置五秒超时超时未等到结果就用同一个seq重新发一次。seq在这里起到了幂等键的作用。后端 worker 接收命令之后先查 SEQ 去重表如果已经处理过就直接返回之前的结果不再执行业务逻辑。这样即使前端因为网络抖动重发命令也不会产生重复订单、重复扣款这类严重事故。4.2 一条大 JSON 引发的“半截消息”帧边界设计WebSocket 本身有消息边界理论上不需要像 TCP 那样处理粘包拆包。但实际用过就会发现当消息很大、又经过某些代理中间层时一条逻辑消息经常被拆成多个 WebSocket frame 分批发过来。0.5 版本最早直接在onmessage里对拿到的字符串做JSON.parse结果偶尔报错。排查后发现是前端的某个业务接口把一大段富文本塞进了 payloadJSON 超过几十 KB在某些代理环境下被拆开了。这里要区分两个概念WebSocket frame 是传输层边界而逻辑消息是应用层边界。单靠 transport 层边界保证不了应用层的完整。解决办法比较朴素每条消息开头加一个八字节的长度前缀接收方收到数据后先读长度再按长度读完整消息读不完就放进缓冲区等下一段。# 消息帧格式8字节长度 JSON 正文 000000302a1b {type:cmd,cmd:syncRichText,payload:{...}}实现上不复杂但对消息完整性是硬保障。顺带说一句千万别用换行符做分隔——业务数据里出现换行是再正常不过的事一拆就废。长度前缀虽然原始但可靠。4.3 网关一重启客户端全挂重连风暴的退避方案第一次做重启演练的时候我简直傻眼。网关刚恢复几百个前端页面像听到发令枪一样同时发起重连网关 CPU 瞬间打满很多连接反复握手失败业务方反馈“页面白屏转圈”。问题根源是前端 SDK 的自动重连太“听话”了断开之后立刻重连不成功就再立刻重连。一个网关实例重启等于一次性触发所有客户端的同步重连形成自放大效应。修复方案是指数退避 抖动。每次重连失败后等待时间翻倍加上 0 到 500 毫秒的随机抖动防止重连请求在时间轴上对齐。function getRetryDelay(attempt: number): number { const base Math.min(1000 * Math.pow(2, attempt), 30000); const jitter Math.random() * 500; return Math.floor(base jitter); }第一次重连等 1 秒左右第二次 2 秒第三次 4 秒最多压到 30 秒封顶。抖动让每个客户端的重连时机错开网关重启之后负载曲线平滑很多。另外一个操作上的建议重启网关之前先切流量让老连接自然老化等连接数归零后再启动新实例新旧不叠加。4.4 权限和订阅token 里直接携带授权而不是每个消息都查库0.4 版本之前notify 消息的 target 校验都是网关实时查数据库判断“这条通知是否允许推送给这个用户”。结果每条 notify 都在网关层引发一次数据库查询网关的热路径被 DB 拖死。后来我改了思路鉴权在连接建立时做订阅关系也在连接建立时固定。前端握手时带上 token网关解析 token 得到用户 ID 和订阅主题列表直接写入连接上下文和订阅表。notify 消息到达时网关只查自己的内存表不看数据库。如果用户的权限在长连接期间发生变化有两种处理方式一是让后端发出permission.updated通知网关收到后主动更新连接上下文二是要求前端重新连接。0.5 版本选了后者因为简单可靠权限变更以后用一条通知告诉前端“请重连”前端拿到通知后优雅地重新初始化连接比强行篡改连接状态更容易控制。5. 这套解耦方案适合谁应用边界与下一个版本的设想5.1 适用场景和“强烈不适用”场景HermesGateway 这套模式不是银弹用得对收益很大用得不对就是自找麻烦。适合的场景有三个特征实时性要求高、多端需要同步一致状态、业务接口变更频繁。典型的比如协作办公应用用户 A 改了文档用户 B 的页面要立刻看到再比如数据大屏后端监控数据变了要主动推到所有展示端还有内部管理系统业务逻辑经常调整前端不想跟着接口改来改去。不适合的场景也很明确低频纯查询类需求比如一个用户一年才改一次密码为它引入一条长连接完全不值强事务类流程比如支付掉单牵扯多方对账这种场景对消息确认、补偿机制要求极高0.5 版本这种轻量设计根本承载不了还有团队本身不熟悉消息驱动模型的情况——模型再优雅团队用不明白就会变成新的技术债。5.2 如果继续做 0.6我会先改这三件事给 HermesGateway 排优先级的话0.6 版本应该先做三件事。一是多实例水平扩展。现在的连接表和订阅表都在单进程内存里撑死了也就服务上千个连接。0.6 要把连接表挪到 Redis用 channel 做跨实例 notify 广播才能支撑更大的规模。二是消息轨迹和全链路追踪。0.5 版本已经在两侧打日志但还是散的。0.6 想加一个轨迹收集器把 tid 串起来的前后端事件统一存在文件或者时序数据库里排查问题直接按 tid 拉全链路时间线比现在翻三处日志快太多。三是背压控制。现在如果后端处理跟不上命令速率worker 队列会无限变长内存迟早撑爆。0.6 至少要在网关层做简单的令牌桶限流超阈值直接给前端回一条sys.backpressure通知让前端降速而不是默默堆积。回到开头说的那个“给前后端解耦”的问题我个人在把 0.5 版本跑起来之后最大的体会是两拨人开会对齐的东西明显变少了前端拿到新的 notify 主题不会慌后端重构命令内部实现也不需要发全量公告。最后再分享一个小技巧协议里那些看似多余的字段一定不要省。seq、tid、ts第一版统统放进去哪怕暂时用不到。0.2 版本我嫌字段多删了tid后来追踪一条消息到底是在后端慢了还是网关慢了对着日志找了三个小时。重新加上之后这种问题从小时级变成了分钟级。