交易消息协议:请求与应答的结构和请求序号
项目 trade-app
1. 背景与问题
App 有两条长连接:行情走原生 WebSocket,交易走 Socket.IO(鉴权、期货、证券三个命名空间),连接本身见两条长连接。这篇只讲连接上跑的消息。
长连接和 HTTP 最大的不同,是没有「一个请求对应一个响应」的天然边界:请求发出去之后,回来的可能是这次的应答,也可能是服务端主动推的数据,还可能是另一台设备那次请求的结果。协议要回答几件事:
- 一帧二进制里,怎样知道它是什么消息、该怎么解;
- 一条应答,怎样知道它是回给哪次请求的;
- 应答和主推怎样区分;
- 失败怎样表达;
- 协议怎样升级而不把两端弄崩。
2. 两层结构:信封包着负载
两条通道都是两层:外层信封是固定结构,内层负载是具体的业务消息,两层都用 Protobuf 编码。
先解信封、再按类型解负载,信封层不需要认识任何业务消息。类型相当于 HTTP 的接口名:行情信封约 70 种类型值,行情协议定义里约 81 个消息,交易(期货网关)约 48 个。
| 行情信封 | 交易信封(期货 / 证券同构) | |
|---|---|---|
| 类型 | 有 | 有,期货、证券各一套枚举 |
| 序号 | 消息 id | 请求序号 |
| 负载 | 二进制 | 二进制 |
| 版本 | 有 | 无(版本在握手头里) |
| 返回结果 | 在负载里 | 信封上有 |
| 平台、设备标识 | 无 | 有 |
| 应答 / 主推 | 靠类型区分 | 两个事件名,再靠类型区分 |
交易网关的请求与应答模型、应答事件的分发都在这一层:网关的每一种应答对应一个处理分支,负载在这里解成具体的消息再往上交。
3. 字段
下面的字段名都是泛化后的叫法,只保留结构。
3.1 信封
在交易信封里补上平台与设备标识以后,同一个账号在多台设备上登录时,收到的应答和主推可以先判断「是不是本机的」。
3.2 负载:请求与应答
几种典型负载的字段(只列和本文有关的):
请求只带业务字段,序号不在负载里,在信封上。这样负载的定义不用关心对号的事,信封也不用关心业务。
4. 请求序号
4.1 怎样生成
交易请求序号由客户端生成:取一个随机 UUID 的哈希值,再取绝对值。它只要求在一次运行里不重复,不要求单调递增,也不和服务端协商。服务端在应答里把它原样带回。
行情的消息 id 用得少:大多数行情请求不需要对号,回来的数据按类型处理就够了。用到它的典型场景是 K 线左滑加载更早的历史:请求带一个专门的「加载更多」消息 id,应答按这个 id 认出来,插到数组前面(见 K 线)。
4.2 用在哪里
序号带回来以后,并不是每条应答都去对号。代码里真正比对序号的地方,集中在登录:
| 场景 | 记下的序号 | 应答回来时 |
|---|---|---|
| 开启生物识别时的静默绑定登录 | 绑定那次登录的序号 | 序号相同:只更新会话号,不走界面分支 |
| 交易首页的生物识别登录 | 这次登录的序号 | 序号相同且密码错误:自动解绑生物识别 |
| 证券重复登录主推 | 绑定那次登录的序号 | 先排除这一次,再比会话号 |
| K 线加载更多 | 「加载更多」消息 id | 认出是历史应答 |
4.3 静默绑定登录
开启生物识别时,App 会用用户输入的交易密码做一次登录。这次登录的应答和普通登录长得一样,不拦截的话就会走进登录成功、改密、二次认证这些界面分支。
绑定登录的应答按请求序号拦下,直接返回,不再往下走。界面层完整的分支见交易会话。
5. 应答怎样回到发起方
5.1 现在的做法:按类型分发
交易应答回来以后,不是按序号找回调,而是按消息类型分发:
登录类的应答赋给登录管理器上的属性(期货登录、证券登录、重复登录、掉线、登出、改密各一个),每个属性的 didSet 就是那一类应答的处理器。Socket 层只管投递,不懂业务。
这种做法下,「这条应答是谁发的」大多不需要知道:资金查询的应答回来,不管是哪次查询触发的,都写进同一个数据中心;页面只看数据中心的最新值。
5.2 「是不是本机发起的」
只按类型分发有一个问题:同一个账号在两台设备上,另一台输错了期货交易密码,网关把「密码错误」的应答也发到了这台,这台就会无端弹出提示。
处理方式是给登录命令记一个「本机是否发起过」的状态:发登录时置上,收到应答时先看这个状态,本机没发过就不提示。它按命令记,不按序号记。
5.3 三种对号方式
| 序号匹配 | 按类型分发 | 回调表 | |
|---|---|---|---|
| 做法 | 发请求时记下序号,应答按序号认 | 按类型交给固定的处理器 | 以序号为键存回调,应答到达时取出 |
| 现在用在哪 | 静默绑定登录、生物识别登录、加载更多 | 绝大多数应答和全部主推 | 未使用 |
| 能区分同类型的两次请求 | 能 | 不能 | 能 |
| 能处理主推 | 不适用 | 能 | 不适用 |
| 超时 | 要另外做 | 没有「哪次请求」的概念 | 天然能挂超时 |
| 代价 | 每个场景各记各的序号 | 最简单,但隐式 | 要管理表的生命周期,防止泄漏 |
| 适合 | 少数要精确对号的请求 | 状态类、数据中心类的应答 | 一次请求一个结果的调用,如下单 |
6. 应答与主推
交易通道上,应答和主推用两个不同的事件名:一个是请求的应答,一个是服务端主动推送;发请求用的是第三个事件名。期货和证券两个命名空间各自一套。
主推没有对应的请求,所以只能按类型处理。行情通道没有事件名这一层,订阅之后的数据都按类型分发。
多设备场景下,应答和主推都先过一遍「是不是本机」:
| 消息 | 先过滤什么 |
|---|---|
| 证券掉线、登出应答 | 平台 + 设备标识 |
| 期货账户断线主推 | 平台 + 设备标识,且「已断线」为真 |
| 证券重复登录主推 | 先排除绑定登录的序号,再比会话号 |
| 证券登录应答 | 取消了证券登录应答上的设备判断 |
7. 返回结果与错误文案
失败的处理分三类:
- 返回码的具体取值这里不列。
- 任何应答里出现「账户未登录」或「会话校验失败」,都按掉线处理;证券还要先比对平台和设备标识。
- 交易错误的文案不写死在客户端:启动时按柜台拉一张错误码对应文案的表,提示时查表。
- 行情登录应答里,「这个客户端已经登录过」没有独立的返回码,用返回文本来认,见下面的坑。
8. 超时、迟到与乱序
长连接上的请求可能遇到三种情况:应答一直不来、超时之后才来、几条应答的顺序和请求顺序不同。现有设计里和它们相关的行为:
| 行为 | 说明 |
|---|---|
| 交易通道没连上时发送 | 触发重连,本次请求直接丢弃,不排队、不补发 |
| 行情登录发出后 | 10 秒后若仍未登录,再发一次登录,有次数上限 |
| 交易登录 | 1 秒的防重入闸门,避免连点产生多次登录 |
| 资金类应答 | 按类型交给数据中心 |
| 连上后立即发消息 | 连接成功后稍等 0.5 秒再通知「已建立」,避免刚连上就发、收不到应答 |
交易指令不排队补发是合理的:下单这类请求晚几秒再发出去,用户看到的价格和意图都可能已经变了。
后面三类问题,需要在协议层有「哪次请求」的概念才能处理:
建议(未做):
- 在途请求表 + 超时。 对需要结果的请求(下单、改单、撤单、登录),发出时以序号为键登记回调和截止时间,到期没有应答就按超时回调,并从表里移除。
- 迟到的应答直接丢弃。 应答到达时在表里找不到序号,说明已经超时或已经处理过,记一条日志后丢弃,不再进业务分支。
- 乱序:只认最新。 同一类查询连续发出时,只接受最后一次的序号;或者在负载里带服务端时间戳,旧于当前数据的不覆盖。
- 超时不等于失败。 下单超时后,订单可能已经在柜台成交。超时回调里不能提示「下单失败」,应该提示「结果未知」,并立即查一次委托来确认。
- 断线时清表。 连接断开时,表里所有在途请求统一按「连接中断」回调,避免回调永远不来。
9. 版本与协议升级
- 协议源文件以 git 子模块引入,跟踪后端协议仓库的开发分支;一个脚本更新子模块并生成代码。同一份协议也给后端的 C++ 服务生成代码,两端的字段定义天然一致。
- 版本信息有两处:行情信封上有版本字段;交易长连接在握手头里带版本。
- 日常的协议更新很频繁,每次都是更新子模块、重新生成。Protobuf 对新增字段向后兼容;真正麻烦的是不兼容的改动,例如一次交易协议的大版本升级,以及交易页面与视图模型对新协议的适配:生成代码本身变化很大,调用方也要跟着改。
10. 踩过的坑
静默绑定登录走进界面分支、另一台设备的「密码错误」弹到这台。 两个都是只按类型分发带来的,处理分别见第 4.3 节和第 5.2 节,界面上的细节见交易会话。
「已经登录」靠文案判断。 行情登录时,服务端用返回文本表示「已经登录过」,客户端按文本匹配。文本一改,这个判断就失效。
刚连上就发,收不到应答。 交易命名空间连上后立即发消息,有时收不到应答。现在的处理是连上后稍等 0.5 秒再通知外部,原因没有查清。
11. 对比与取舍
| 选择 | 好处 | 代价 |
|---|---|---|
| 信封 + 负载两层 Protobuf | 信封层不认识业务;体积小 | 调试时不能直接看明文,要靠日志解码 |
| 序号放在信封上 | 负载不关心对号 | 只有少数场景真的比对 |
| 按类型分发为主 | 简单,主推和应答同一条路 | 同类型的两次请求分不清;没有超时 |
| 应答与主推分事件名 | 一眼能分清是不是请求的结果 | 只在交易通道有,行情通道没有 |
| 序号取 UUID 哈希的绝对值 | 不用维护计数器 | 哈希值每次运行不同,只在一次运行内有意义 |
| 交易请求不排队 | 不会延迟补发交易指令 | 断线瞬间的请求直接丢失,需要页面自己重试 |
12. 可以改进的地方
建议(未做):
| 现状 | 问题 | 建议 |
|---|---|---|
| 应答按类型分发 | 同类型请求分不清,没有超时 | 加在途请求表,见第 8 节 |
| 序号是 UUID 哈希的绝对值 | 理论上可能重复;最小整数取绝对值会溢出 | 改用自增计数,或直接用 UUID 字符串 |
| 「已经登录」按文本判断 | 文本一改就失效 | 和服务端约定独立的返回码 |
| 「本机是否发起」按命令记 | 同一命令并发两次时可能错位 | 用请求序号精确匹配 |
| 版本只在握手和行情信封上 | 交易消息本身不带版本 | 握手时协商协议版本,不兼容时提示升级 |
| 二进制不可读 | 排查问题要先解码 | 调试包里按类型打印解码后的消息摘要 |