尧图精选

基于cmd+notify的前后端解耦实时通信架构实践

🕒 发布时间:2026/9/28 9:17:40 📁 来源:尧图网络
HermesGateway这个名字听起来有点唬人但它其实是我最近重构一个老旧项目时随手起的内部代号——版本号是0.5意思是核心链路已经跑通、能干活但离生产级还有不少距离。整个设计从头到尾只围绕两个关键词cmd 和 notify。用这两个最朴素的机制我把前后端之间纠缠了大半年的耦合关系彻底拆开了前端不再关心后端内部长什么样后端也不用再为页面状态变化四处打补丁。这篇文章会把我踩过的坑、想清楚的道理、以及落地的代码逻辑完整写出来给同样被前后端分离搞到头大的团队做个参考。如果你现在正处在这种状态——接口越加越多、每次改需求前后端要联动改三处以上、实时数据全靠轮询硬撑那这个思路大概率对你有用。我会先从耦合的根源讲起再说cmd和notify分别是什么、为什么组合在一起能完成解耦最后给出一份可以照着抄的实现方案和排坑记录。内容偏工程实践代码量不多重点在思路。1. 为什么我会想到做HermesGateway传统前后端耦合的痛1.1 耦合的前端代码是怎么长出来的先说一个真实的场景。我年初接手一个数据展示类项目前端有十几个页面后端有几十个接口。表面上看前后端是分离开的——前端部署在一台nginx上后端是独立的Java服务REST接口也画了Swagger文档。但真要改起需求来处处都是绊脚石。问题出在“分离”的颗粒度上。前端页面里到处是$.ajax({ url: /api/data/xxx, ... })每一个url背后都对应后端一个具体的Controller方法。页面加载时调一次接口拿初始数据用户操作时又调几个接口后端状态一旦发生变化前端要么再主动刷接口要么通过短轮询硬问。时间一长前端代码里全是“拉数据”的逻辑而不是“表达业务意图”的逻辑。一个按钮点击之后前端要做的不是告诉后端“我想要完成某件事”而是绞尽脑汁拼接口、对时间戳、处理返回结构。这就在两边之间形成了一种隐性绑定后端接口的入参、出参结构一旦变动前端立刻跟着遭殃前端页面结构调整后端可能也要为同一个数据造出好几个不同粒度的接口。所谓的“分离部署”到最后变成了“分开写代码、一起遭罪”。1.2 我在真实项目里踩过的耦合坑如果你觉得上面说的太抽象我举几个具体踩过的坑。第一个是状态同步问题。后端有个数据看板每5秒刷新一次统计结果前端需要实时展示。最初的做法是前端自己开一个定时器每5秒调用一次统计接口。这个方案看着简单但实际问题很大前端定时器和后端数据生成节奏完全靠“约定”一旦后端某个统计任务耗时超过5秒前端就拿到重复数据如果多个用户同时开看板后端压力直接翻倍。说白了前端在盲猜后端什么时候有新数据。第二个是接口爆炸。同一个实体列表页需要一个字段子集详情页需要另一个字段子集导出功能又要全量字段。后端为了迁就前端一个对象拆出五六个查询接口每个接口返回结构还略有差异。前端维护调用的时候经常分不清该用哪个。第三个是“联动通知”的实现方式。后端某个业务操作完成后需要通知前端刷新两个页面、更新一个图表、弹一个提示。我当时最笨的做法是让前端在几个互不相干的位置反复调用接口去“对账”后来实在受不了才在项目里加了一套基于WebSocket的简陋推送。可一旦每个页面都自己连WebSocket连接管理又变成新的麻烦。1.3 解耦的边界到底是什么这段经历让我反复想一个问题前后端之间到底应该依赖什么才能算“解耦”解耦不等于把代码分成两个目录、两个仓库、两个部署单元。真正的解耦应该是前端不依赖后端的内部实现细节后端也不依赖前端的页面结构两边只通过一个稳定的、契约化的协议进行沟通。REST接口当然也是一种契约但它的粒度受“资源”的约束太强——你是围绕数据资源建模而不是围绕业务意图建模。当你的功能需要跨多个资源、多个状态联动时REST的接口设计就会变得非常别扭。HermesGateway就是在这个思考下冒出来的。我想换一种交互方式前端发送一个“命令”后端执行这个命令执行的结果通过“通知”推回前端。不纠结资源路径、不搞一堆HTTP状态码让前后端在一个更抽象、语义更完整的层面协同。cmd负责表达“我要干什么”notify负责表达“事情办完了/有新数据了”。分清楚这两件事解耦的边界自然就出来了。2. cmd notify 架构的核心设计思路2.1 cmd命令到底是什么我这边的cmd不是指操作系统命令行而是指一种消息模式前端把一次交互动作抽象成一条结构化指令发给网关。指令里包含“要做什么”和“做这件事需要的参数”但完全不包含“这件事后端应该怎么实现”。一条命令消息大概是这个格式{ ver: 1, id: 09f3c1a2-7d8b-4e6f-9a1b-2c3d4e5f6a7b, cmd: order.create, params: { customerId: u_1024, items: [ {sku: sku_01, count: 2} ] }, token: auth_token_xxx, ts: 1735206400000 }核心字段就四个id是请求的唯一标识cmd是命令名params是业务参数token是调用方身份。网关收到之后既不关心消息是从浏览器来还是从客户端来也不关心前端接下来要刷新哪个页面它只负责找到对应的命令处理器把params扔进去然后等结果。这里最容易误解的一点是cmd不是把REST接口换了个名字。REST的语义是“对资源的操作”比如POST /api/orders意思是“在订单集合里创建一个对象”而order.create的语义是“完成一次下单业务”。前者更强调数据操作后者更强调业务动作。下单这个业务里可能涉及创建订单、扣库存、发通知、更新用户积分如果把这些都拆成REST调用前端就要自己编排多个请求状态一致性极难保证。而用cmd后端网关可以把这个命令路由到一个编排服务里由服务端统一完成整个业务。2.2 notify通知到底是什么notify负责解决“后端什么时候有结果、什么时候有新状态”的问题。后端执行完命令或者某个内部事件发生之后网关会主动向相关的前端推送一条通知消息。通知的内容不是大段大段的业务数据堆砌而是一个事件描述附带必要的载荷。一个通知的简单示例{ event: order.created, correlationId: 09f3c1a2-7d8b-4e6f-9a1b-2c3d4e5f6a7b, payload: { orderId: ord_20250101_0001, status: CREATED }, ts: 1735206405120 }我用event字段替代cmd来区分两类消息的方向性correlationId用来把通知和之前发出的某条命令关联起来方便前端对账。这样前端收到通知时既知道“发生了什么事”也知道“这跟我哪条请求相关”。为什么要用notify而不是让前端继续轮询因为轮询本质上是“盲猜”效率低不说体验还差。notify能做到两件事第一状态变化由后端主动发起前端收到的永远是增量事件不需要假装自己什么都知道第二前端可以按需订阅只处理和自己页面相关的事件。一个管理后台里订单模块的页面只关心和订单相关的事件用户模块的页面只关心用户相关的事件订阅关系清晰推送压力也小。2.3 为什么用cmd notify而不是继续堆REST接口很多人会问REST接口用了这么多年大家不都习惯了为什么非要多设计一层网关我的判断依据是你做的是业务系统不是在给资源做管理界面。业务系统里充满了跨实体的动作、需要事务保障的流程、依赖时序的联动。用REST表达这些动作一定会出现“接口往业务流程上硬靠”的情况。订单创建、退款、审核、通过这些动作勉强还能找出资源路径但像“导出三个月内的销售报表并发邮件给老板”这种复杂动作REST的url都不好编更别提参数校验和状态跟踪。cmdnotify是面向动作和事件建模的天然适合表达这类业务。再加上我当时的痛点是解耦。用REST前端和REST接口之间仍然存在“调用依赖”前端得知道url、知道方法、知道返回结构。而用cmdnotify前端和网关之间只有一条协议前端不知道也不关心后端函数叫什么名字、数据库表怎么设计。两边各自演进只要命令名字和事件名字不随便改谁也影响不到谁。这个理解就是“解耦”最落地的地方。3. HermesGateway的核心实现从协议到机制3.1 网关层的整体结构HermesGateway的定位是前后端之间的一个轻量中间层。整体结构分四块接入层、命令路由层、命令执行层、通知广播层。接入层负责维持与前端的长连接。0.5版本我用了WebSocket做传输通道主要因为它在浏览器环境里支持好、双向通信方便。命令路由层维护一张命令注册表cmd名字和处理器函数一一对应它收到一条前端消息后先做消息解析、基础校验协议版本、token然后根据cmd找到处理器把控制权交给执行层。执行层跑完业务逻辑产出结果。结果不会直接以“HTTP响应”的形式返回前端而是被转成事件由通知广播层推送给订阅者。画成结构就是这么一条链前端 → WebSocket → 接入层 → 命令路由层 → 命令执行层 → 业务方法业务方法产出结果 → 通知广播层 → WebSocket → 前端这个链路最关键的设计是命令执行的返回不是响应而是事件。前端发一条order.create命令之后并不阻塞等待一个同步返回而是订阅一个order.created事件。这个转变让人一下子从“请求-响应”思维跳到了“命令-事件”思维。3.2 命令注册表和处理链路命令注册表是HermesGateway的核心数据结构。本质上就是一个映射表命令名 → 处理器函数。我用Python的FastAPI搭后端定义了一个装饰器来注册命令# gateway/registry.py from typing import Callable, Dict CommandHandler Callable[[dict], dict] class CommandRegistry: def __init__(self): self._handlers: Dict[str, CommandHandler] {} def register(self, cmd: str): def decorator(func: CommandHandler): if cmd in self._handlers: raise RuntimeError(fcmd {cmd} already registered) self._handlers[cmd] func return func return decorator def get_handler(self, cmd: str) - CommandHandler: handler self._handlers.get(cmd) if handler is None: raise KeyError(funknown cmd: {cmd}) return handler registry CommandRegistry()然后在具体的service文件里注册命令# services/order_service.py from gateway.registry import registry registry.register(order.create) def create_order(params: dict) - dict: # 这里做真正的业务处理创建订单、扣库存、落库 order_id create_order_record(params) return { orderId: order_id, status: CREATED }网关的核心处理逻辑是这样一个异步循环# gateway/ws_gateway.py async def handle_client_message(ws, raw_message: str): try: message json.loads(raw_message) ver message.get(ver) if ver ! 1: await ws.send_text(build_error_notify(message.get(id), UNSUPPORTED_PROTOCOL)) return cmd message.get(cmd) req_id message.get(id) handler registry.get_handler(cmd) # 在这里可以统一做鉴权、参数校验、链路追踪 await check_auth(message.get(token), cmd) payload await handler(message.get(params) or {}) notify_event { event: cmd_to_event_name(cmd), # order.create - order.created correlationId: req_id, payload: payload, ts: current_millis() } await ws.send_text(json.dumps(notify_event)) except KeyError: await ws.send_text(build_error_notify(message.get(id), UNKNOWN_CMD)) except Exception as e: # 统一异常处理避免业务错误直接暴露给前端 await ws.send_text(build_error_notify(message.get(id), INTERNAL_ERROR, str(e)))这段代码虽然短但已经能看清cmdnotify的核心循环了收到命令、校验、路由、执行、转事件、推送。前端的id字段在回调里变成了correlationId两条消息因此完成了关联。3.3 通知广播的订阅模型如果只是“命令执行完把结果返回给发送方”那本质上还是一个一问一答的过程跟前端调接口没有本质区别。cmdnotify的另一个关键优势在“广播”。HermesGateway里有一个轻量的事件中心维护“事件名 → 订阅者集合”的关系# gateway/event_bus.py import asyncio from typing import Dict, Set, List class EventBus: def __init__(self): self._subscribers: Dict[str, Set[str]] {} self._connections: Dict[str, List] {} def subscribe(self, session_id: str, events: List[str]): for event in events: self._subscribers.setdefault(event, set()).add(session_id) def unsubscribe(self, session_id: str, events: List[str]): for event in events: if session_id in self._subscribers.get(event, set()): self._subscribers[event].remove(session_id) async def publish(self, event: str, payload: dict): for session_id in self._subscribers.get(event, []): ws self._connections.get(session_id) if ws and not ws.closed: await ws.send_text(json.dumps({ event: event, payload: payload, ts: current_millis() }))前端在连接网关之后除了发命令还会先发一条订阅消息告诉网关“我这个页面关心哪些事件”。比如订单页面订阅order.created、order.refunded用户页面订阅user.login_failed、user.updated。这样网关在内部状态变化时就能精准地把通知推给真正需要它的前端而不是全量广播给所有连接。我用一个生活化类比来解释这层设计cmd是客人对服务员喊“点菜”notify是后厨做完菜之后服务员端着菜喊“XX号的菜好了”。客人不用反复跑去后厨问“好了没有”后厨也不用知道每一桌客人分别是谁只需要通过服务员把菜端到对应编号的桌子。网关就是这个服务员。前端通过订阅告诉服务员“我是哪一桌、我关心哪些菜”后端做完菜之后只管往事件中心放路由和投递由网关完成。4. 实操过程从0到0.5版本的搭建记录4.1 最小闭环先行一个命令一个通知做架构最忌讳上来就铺开。HermesGateway的0.5版本我是从一条最小链路开始的前端发一条ping命令后端收到后回一个pong事件。这个闭环看似无聊但把协议格式、连接管理、事件订阅、错误处理都验证了一遍。具体步骤搭一个FastAPI应用挂两个路由一个HTTP GET/health用于健康检查一个WebSocket/ws用于接入前端。初始化CommandRegistry注册ping处理器。初始化EventBus维护连接集合。前端创建一个WebSocket连接先发订阅请求再发ping命令。后端收到ping后生成pong事件推回前端。前端收到pong事件确认全链路可用。这一步最大的价值在联调。我说句实在话很多时候架构讨论得再完美真正动手一联调就会发现各种边界问题字段名对不上、时间格式不统一、消息体里偶尔混进个BOM头导致JSON解析失败。最小闭环能让这些问题提前暴露。4.2 前端消费模型的实现前端的改造比后端复杂因为要处理异步消息驱动的逻辑。原来的代码风格是“点击按钮然后等response回来再操作”现在变成了“点击按钮发命令等到事件回调再操作”。我在前端封装了一个统一的连接层// frontend/gateway-client.js class HermesClient { constructor(url) { this.ws new WebSocket(url); this.requestMap new Map(); // requestId - {resolve, reject, timer} this.eventHandlers new Map(); // eventName - [handler,...] this.pendingSubscribe []; this.ws.onmessage (evt) this._onMessage(evt.data); this.ws.onopen () this._flushSubscribe(); } subscribe(events, handler) { for (const event of events) { if (!this.eventHandlers.has(event)) { this.eventHandlers.set(event, []); } this.eventHandlers.get(event).push(handler); } if (this.ws.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify({ ver: 1, type: subscribe, events: events })); } else { this.pendingSubscribe.push(events); } } sendCommand(cmd, params, timeout 10000) { return new Promise((resolve, reject) { const requestId this._uuid(); const timer setTimeout(() { this.requestMap.delete(requestId); reject(new Error(cmd timeout: cmd)); }, timeout); this.requestMap.set(requestId, { resolve, reject, timer }); this.ws.send(JSON.stringify({ ver: 1, id: requestId, cmd: cmd, params: params || {} })); }); } _onMessage(raw) { const msg JSON.parse(raw); if (msg.correlationId this.requestMap.has(msg.correlationId)) { // 这是某条命令的结果通知走Promise回调 const entry this.requestMap.get(msg.correlationId); clearTimeout(entry.timer); this.requestMap.delete(msg.correlationId); if (msg.status OK) { entry.resolve(msg.payload); } else { entry.reject(new Error(msg.payload?.message || cmd failed)); } return; } // 这是后端主动推送的事件走订阅分发 const handlers this.eventHandlers.get(msg.event) || []; handlers.forEach((handle) handle(msg.payload, msg.event)); } }这样一个客户端封装同时解决了两件事命令的结果通过Promise回传给调用方符合前端写异步逻辑的习惯后端主动推送的事件则通过订阅分发到对应的处理函数。前端业务代码里操作和状态更新被分到了两条路径上彼此不干扰。4.3 逐步补全生产可用能力最小闭环跑通后我逐步往HermesGateway里补了四块能力第一块是错误处理。原先命令执行器里直接抛Python异常前端收到的是一串裸异常信息。后来我定义了统一的错误码体系UNKNOWN_CMD、PARAM_INVALID、UNAUTHORIZED、BIZ_ERROR、INTERNAL_ERROR每条通知里都有status字段表示成功或失败。前端拿状态码做提示不会因为后端的异常信息变化而产生错乱。第二块是超时机制。cmdnotify是异步的理论上后端可能永远不会回消息。我在前端加了一层超时控制默认10秒没收到对应通知就reject并且把requestMap里挂起的条目清理掉防止内存泄漏。后端这边也做了兜底命令执行如果超过15秒主动返回一个超时的错误通知。第三块是幂等控制。网络环境没那么理想WebSocket重连后前端可能会自动重发未确认的命令。如果不做幂等一条“扣库存”的命令就被执行了两次。我的做法是在网关层加了一张请求去重表用id字段作为幂等键同一个id的命令在5分钟内只允许执行一次重复到达直接返回第一次的结果。第四块是断线重连和状态恢复。WebSocket断线之后前端的订阅关系会丢失。我让HermesClient在检测到断线时自动重连并且在onopen之后重新发送最后一次确认成功的订阅列表。对于“断线期间后端产生的事件”我加了一个轻量的事件补偿机制服务端按会话保留最近1分钟的事件游标前端重连时可以带上次收到的最后事件时间戳网关把漏掉的事件补发一遍。表格整理这四块能力能力问题背景实现简单描述错误码统一裸异常没法做前端拦截定义统一错误码通知里带status字段超时控制异步机制下一问不答会挂起前端Promise超时后端执行超时兜底幂等控制重连导致命令重复执行网关按请求id去重5分钟窗口断线恢复事件丢失、订阅关系重置前端重连重订阅服务端事件补偿4.4 实际落地效果与性能观察我把HermesGateway接到项目里跑了两周前后端对比还是很明显的。原来前端为了获取后端状态变化全局有七八个定时器在轮询改造之后定时器全部取消数据更新完全依赖事件推送。网关单机扛了800多个WebSocket连接命令的平均处理耗时在30毫秒以内主要是业务逻辑耗时事件广播的推送延迟小于10毫秒。我也测过一些异常场景比如后端服务重启时正在处理中的命令会失败网关通过错误通知把失败原因推给前端前端的全局loading态能正确消失不会出现“一直转圈”的卡死现象。再比如前端页面切换时订阅关系会动态调整旧页面的订阅退订及时网关不会给不存在的页面继续推数据。5. 踩坑记录与排查经验5.1 常见问题速查表实现过程中我遇到不少问题按排查价值整理成一张速查表你在自己的项目里遇到类似现象可以直接对照。现象可能原因排查和解决办法前端发命令后没有任何通知命令名拼写不一致或命令未注册检查网关日志里是否有UNKNOWN_CMD核对前后端命令名相同命令偶尔执行两次网络重连后自动重试在网关层加幂等去重用请求id做缓存键页面收到不相关的事件推送订阅事件名太宽或退订逻辑遗漏检查前端subscribe/ unsubscribe调用确认事件粒度推送事件延迟很高事件中心同步推送导致阻塞改为异步发布每个连接单独一个发送队列前端内存持续上涨超时未清除回调Map检查Promise超时处理确保reject后删除requestMap条目WebSocket频繁断连网关或代理层空闲超时增加心跳机制网关每30秒发一次ping命令执行时间略长导致前端超时同步处理器比较耗时命令处理器改为协程/异步必要时拆成后台任务返回受理结果5.2 我在调试中发现的几个容易忽略的细节第一个细节是心跳的时机。WebSocket看起来简单但很多代理服务器特别是nginx默认对空闲连接有超时断开策略。如果前端和后端之间长时间没有消息连接会被默默掐断而两边的程序都不会收到报错表现出来就是“过了一阵子推送突然不来了”。解决方式很直接设置一个30秒的定时器网关主动发ws.ping前端收到后自动回pong。这事情其实简单但不做的话排查起来非常难受因为问题不是每次都复现而是随机发生。第二个细节是事件名与命令名的命名规范。我一开始把命令名设计成动词开头事件名直接替换时态比如order.create对应order.created。这个思路在简单场景下没问题但业务复杂之后一个命令可能会触发多个事件。比如下单这个命令可能会触发order.created、inventory.deducted、customer.points_added三个事件。前端如果只订阅order.created另外两个事件对它没有意义但网关还是会推给所有订阅了相关事件名的连接。后来我调整了订阅粒度要求前端的每个订阅都明确写清楚关心的事件名不做隐式“全量通知”。第三个细节是命令结果的封装。0.5版本早期我让后端处理器直接返回业务对象然后网关把整个对象作为payload推给前端。后来发现这样容易泄露后端内部结构比如一个订单对象里带了数据库自增id、内部状态码、甚至冗余关联字段。现在我的做法是每个命令处理器在返回之前都会经过一个to_view的映射方法只暴露前端真正需要的字段。这层防护的意义在于后端再怎么重构数据库只要to_view的输出结构稳定前端就稳定。5.3 和旧REST接口的兼容过渡一个现实问题是让现有项目一天之内完全切换到cmdnotify不现实。我在改造过程中采用的策略是“双轨并行、逐步迁移”。具体做法是网关直接复用旧系统的鉴权体系前端登录后拿到的token同时适用于REST接口和WebSocket连接。新功能一律走cmdnotify协议不要为临时需求再新增REST接口。旧接口仍然保留前端逐步把高频使用的接口迁移到命令模式。网关里做好日志记录每条命令和每条通知都带requestId方便出问题时在日志系统里把全链路串起来。这样过渡的好处是团队不需要做一次“大爆炸式”的重写每个迭代都可以搬运一小块功能验证没问题再继续下一块。0.5版本只覆盖了核心链路剩下的适配器、鉴权细粒度、SDK封装都在后续版本里补齐。5.4 这套方案的边界在哪里最后说点冷静的。cmdnotify不是万能的它适合、也不适合的场景我都摸了一遍。适合的场景是中大型前端应用状态联动多、需要实时推送、业务逻辑复杂。比如数据大屏、管理后台、运维平台、工单系统这些场景对解耦和实时性的要求都很高。不太适合的场景也有。比如一个纯粹的读多写少的展示型页面数据很少变化直接REST接口可能更简单没必要为了用模式而用模式。再比如团队规模很小、前后端互相耦合程度不高引入网关多了一层维护成本性价比也低。另外如果你需要强一致性的同步调用语义比如跨服务调用要求立刻拿到结果并提交事务cmdnotify这种异步模式会让事务边界变得更复杂这时候还是要谨慎。我个人在实际操作中的体会是解耦这件事工具只是辅助真正起作用的是想清楚“前端和后端各自该承担什么职责”。cmdnotify只是把这种职责划分变成了可执行的协议。0.5版本虽然简陋但它证明了“面向命令和事件做交互”这条路是走得通的。下一步我准备把协议定义抽成独立的SDK让前端和后端都通过SDK生成消息减少手写JSON导致的低级错误再往后就是补链路追踪和消息审计让每一次命令的执行链路都可以回溯。如果你也在评估类似的重构建议先花一天把最小闭环跑通再决定要不要全面拥抱。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →