两条长连接、Protobuf 信封与数据分发
项目 trade-app
1. 背景与问题
这是一款港股、美股与期货的一站式交易 App。除了普通的 HTTP 接口,客户端还要和两类服务端保持长连接:
- 行情服务器:推送快照、盘口、逐笔、K 线、分时、交易状态,频率高、几乎是单向的。
- 交易网关:接收登录、查询、下单、撤单等指令,返回应答;同时主推订单、成交、持仓、资金变化和被挤下线。网关后面又分成期货柜台和证券柜台两套。
两边的诉求很不一样,而客户端没有引入 Rx,也没有通用的事件总线。要回答的问题是:
- 连接分几条、各用什么库;
- 两边的消息怎么打包,应答怎么对回请求;
- 连接没准备好时,发出去的消息怎么办;
- 收到的数据怎样送到「当前该处理它」的那个页面。
三条通道(HTTP 客户端、行情 WebSocket、交易 Socket.IO)从同一套首版骨架:连接、收到数据交给代理、发送数据起步,交易网关一侧再叠加请求 / 应答模型与应答事件分发。下面先讲为什么拆成两条,再按信封、建连、发送、分发的顺序展开。
2. 为什么是两条
三条通道的整体拓扑见项目总览页。从代码看,行情和交易在下面几个维度上都不一样:
| 维度 | 行情通道 | 交易通道 |
|---|---|---|
| 后端 | 行情服务器,地址和行情专用凭证由账户信息接口下发 | 交易网关,地址按环境固定 |
| 鉴权 | 连上之后发一条登录消息,带行情凭证 | 建连时在握手头里带用户凭证,服务端在鉴权事件里回结果 |
| 流量 | 高频主推,丢一帧下一帧会覆盖 | 低频,请求-应答为主,夹着主推 |
| 隔离 | 不需要 | 期货、证券两套柜台,用命名空间天然隔离 |
| 应答与主推 | 同一种帧,按类型区分 | 两个事件名:一个放应答,一个放主推 |
| 可靠性 | 连上、登录、订阅即可 | 先鉴权再开柜台命名空间,另有账户级心跳和被踢处理 |
所以行情用轻量的原生 WebSocket 加自定义信封;交易用 Socket.IO,利用它的命名空间和事件名,并强制只走 WebSocket 传输。两边都用 Protobuf。
3. 整体设计
连接层只做三件事:建连与保持、按信封解包、把结果投递出去。投递有四种出口:
- 当前代理:每条通道一个 weak 代理,页面出现时把自己设成当前代理;
- 二次扇出:市场容器、交易侧的行情中间层自己当代理,再转给子页或按业务拆成持仓、委托、成交等回调;
- 载体属性:登录、心跳、被踢这类应答,直接赋给交易登录管理器上对应的属性,属性的观察器就是处理器;
- 通知:网关连上 / 断开、凭证变化等状态,走全局通知。
4. 关键实现
4.1 信封(摘要)
两条通道都把业务消息再包一层信封:类型决定负载怎么解,序号由应答原样带回,负载是 Protobuf 编码的业务消息。交易信封另带网关返回码,在交易长连接的信封里补上平台与设备标识,用来在多设备场景下只处理本设备的应答和主推。
协议以子模块的形式引入后端的协议仓库,用脚本调用 protoc 生成 Swift 代码;升级时除了重新生成,还要跟着改调用方,比如一次交易协议的大版本升级,以及交易页面与视图模型对新协议的适配。
信封的字段、请求序号怎样生成、应答怎样回到发起方、应答与主推的区别,单独写在交易消息协议一篇。
4.2 行情通道:连上、登录、再订阅
行情连接的生命周期是一串条件:连上之后先登录,登录成功才开心跳,并通知当前代理「需要重新订阅」。
几处具体做法:
- 重连节流:距上次发起连接不足「检查周期 × 0.8」直接返回;发起后过一个检查周期再看,没连上就再来一次,次数有上限。
- 建连超时:建连请求的超时比检查周期短 2 秒,保证检查时这一次已经有结论。
- 登录也节流:距上次登录请求太近就不发;登录前先检查行情凭证是否快过期,必要时先换新。
- 心跳:只在登录成功后发,未登录时发心跳没有意义。
- 前后台:进后台主动断开;回前台计数清零后重连;网络变化时如果还没登录,也清零重连。
- 收到数据:回调在专用队列上。登录应答和心跳应答在连接层自己消化,其余的整帧交给当前代理;解包失败会记一条错误日志,归类为 Protobuf 解码错误。
4.3 交易通道:先鉴权,再开柜台
交易通道先连鉴权命名空间,鉴权成功后才初始化期货和证券两个命名空间。
鉴权结果和用户凭证串在一起:用户凭证一变化就发全局通知,交易连接收到后,有凭证就连,没有就断。鉴权失败时强制续期,续期成功写入新凭证又会触发这个通知,于是自然完成一次重连。为了避免拿着旧凭证反复鉴权,失败后的一小段冷却时间内,没有拿到新凭证的连接请求会被忽略。
Socket.IO 本身也有重连(无限次,等待时间在 2 到 6 秒之间),应用层在它之上又加了一层:每个命名空间一个连接检查定时器;回前台之后、网络变化之后,各延迟几秒再检查一次,没连上才强制重连。所有回调都放在一条私有串行队列上处理,库的处理队列也指向它。
4.4 发送:没准备好就丢,不排队
两条通道对「没准备好」的处理不一样。
行情消息丢了没关系:登录成功后会通知页面重新订阅。交易指令则故意不缓存:一条下单或撤单如果在网络恢复后才补发出去,用户看到的价格和意图可能早已变了。没发出去就让上层按失败处理,比延迟补发更安全。
4.5 数据分发:没有总线时怎么送到页面
两边收到数据之后,都按消息类型投递。交易侧是两个分发函数,期货和证券各一个,按消息类型把应答和主推投递给登录管理、数据中心或当前代理;交易网关十几种应答事件的判断就集中在这两个函数里。
// 示意,非原始代码
func dispatchFutures(_ carrier: TradeCarrier) {
switch carrier.type {
case .loginRsp: loginManager.futuresLoginCarrier = carrier // 属性观察器里处理
case .pushAccountDisconnect: loginManager.futuresOffLineCarrier = carrier
case .heartbeatRsp: unansweredHeartbeats = 0
default: futuresDelegate?.didReceive(carrier) // 资金、持仓、订单等
}
if carrier.ret.code == .accountNotLogin || carrier.ret.code == .verifySessionFailed {
loginManager.futuresOffLine() // 任何应答都可能表明已掉线
}
}
行情侧只有一个当前代理。页面在出现时调用「切换代理」把自己设上,消失时自己退订。这样同一帧不会被多个页面同时处理,代价是同一时刻只有一个消费者,所以需要二次扇出:
| 投递方式 | 用在哪 | 优点 | 代价 |
|---|---|---|---|
| 单个 weak 代理 + 切换 | 行情、期货、证券 | 简单,没有多订阅者的生命周期问题 | 同一时刻一个消费者;切换时机不对就收不到 |
| 容器 / 中间层扇出 | 市场页、交易侧行情 | 一个代理服务多个子页 | 扇出逻辑散在各处 |
| 载体属性观察器 | 交易登录、被踢、改密 | 连接层不懂业务,只管赋值 | 处理是隐式的,难测试 |
| 全局通知 | 网关状态、凭证变化 | 多个页面都能收到 | 名字是字符串,没有类型约束 |
4.6 页面基类统一感知网关状态
交易首页、证券主页、期货主页、期货资金详情都需要在网络或交易网关断开时提示用户,在恢复时重新查数据。最早每个页面各自注册通知。后来改成在导航栏页面基类里统一监听网络与网关状态通知,子页面只覆写「断开了」「恢复了」两个回调,并配了一个显示网络与交易连接状态的提示视图,默认注册、默认移除通知;交易首页在交易长连接恢复通讯后重新查询数据。
「通讯已建立」这个通知不是在命名空间连上的瞬间发的,而是延迟一小段时间才发,原因见下一节。
5. 踩过的坑
连上就发,收不到应答。 期货、证券命名空间刚报告「已连接」就发查询,应答有时收不到。现在的处理是连上之后延迟约半秒再通知外部「通讯已建立」,代码注释里写明了这是临时方案、原因没有查清。这类「连接已建立但服务端还没准备好」的问题,更稳的做法是由服务端回一个就绪消息,客户端等它再放行。
「已经登录」靠文案判断。 行情登录应答里,「这个客户端已经登录过」不是一个独立的返回码,而是用返回消息的文本来认。服务端改一个字,客户端就会把它当成登录失败。
拿旧凭证反复鉴权。 交易通道鉴权失败后会强制续期,但续期回来之前,前后台切换、网络变化等触发点还会不断发起重连,每次都带着旧凭证。冷却时间就是为了挡住这段时间里的重连。
心跳应答只清了计数。 交易侧收到心跳应答只是把「未应答次数」清零,代码里没有看到用这个计数判定连接已死。所以「连接看起来还在、其实已经断了」的半开状态,只能等库自己发现。这一条在网络优化一篇里展开。
单代理要靠约定守住。 页面在出现时抢代理、消失时退订。两个页面的出现与消失一旦交错(比如带动画的返回),先消失的页面就可能把后出现页面需要的订阅退掉。说明文档里约定:多子页的容器由父控制器统一退订。这是约定而不是机制,新页面不遵守就会出现「收不到推送」。
6. 对比与取舍
| 问题 | 另一种做法 | 采用的做法 | 原因 |
|---|---|---|---|
| 两类后端 | 一条连接多路复用 | 两条连接,两种库 | 后端、凭证、流量模型都不同;交易侧要命名空间隔离柜台 |
| 消息格式 | JSON | Protobuf 加一层信封 | 行情体量大;协议和后端共用一份定义 |
| 未就绪时发送 | 统一排队,恢复后补发 | 行情等重新订阅,交易直接丢弃 | 交易指令延迟补发有风险 |
| 分发 | 引入 Rx 或事件总线 | 单代理 + 扇出 + 属性观察器 + 通知 | 依赖少、心智简单;代价是扇出分散 |
| 重连 | 只靠库内重连 | 库内重连 + 应用层检查 | 库不知道「鉴权失败要先换凭证」这类业务条件 |
| 网关状态提示 | 每个页面自己注册 | 页面基类统一监听 | 少写重复代码,也不会漏移除 |
7. 可以改进的地方
下面每一条都是方向,不是已经做完的改动。
| 现在的做法 | 问题 | 改进方向 |
|---|---|---|
| 两条连接各写一套重连、节流、检查 | 逻辑相似但不共用,常量各自维护 | 抽一个公共的连接状态机:未连接 / 连接中 / 鉴权中 / 就绪 / 退避 |
| 单个 weak 代理 | 同一时刻只有一个消费者 | 按主题的多播订阅表(弱引用集合),或用 Combine 的 Subject |
| 连上后固定延迟再放行 | 原因不明的临时方案 | 等服务端的就绪消息再通知外部 |
| 「已经登录」按文案判断 | 文案一改就失效 | 和服务端约定独立返回码 |
| 心跳应答只清计数 | 半开连接发现得慢 | 连续若干次未收到心跳应答就主动重建连接 |
| 属性观察器做处理 | 隐式,难测试 | 显式事件枚举 + 纯函数处理器,补单元测试 |