两条长连接、Protobuf 信封与数据分发

项目 trade-app

  • 长连接
  • WebSocket
  • Socket.IO
  • Protobuf
  • 数据分发
  • 重连

1. 背景与问题

这是一款港股、美股与期货的一站式交易 App。除了普通的 HTTP 接口,客户端还要和两类服务端保持长连接:

  1. 行情服务器:推送快照、盘口、逐笔、K 线、分时、交易状态,频率高、几乎是单向的。
  2. 交易网关:接收登录、查询、下单、撤单等指令,返回应答;同时主推订单、成交、持仓、资金变化和被挤下线。网关后面又分成期货柜台和证券柜台两套。

两边的诉求很不一样,而客户端没有引入 Rx,也没有通用的事件总线。要回答的问题是:

  • 连接分几条、各用什么库;
  • 两边的消息怎么打包,应答怎么对回请求;
  • 连接没准备好时,发出去的消息怎么办;
  • 收到的数据怎样送到「当前该处理它」的那个页面。

三条通道(HTTP 客户端、行情 WebSocket、交易 Socket.IO)从同一套首版骨架:连接、收到数据交给代理、发送数据起步,交易网关一侧再叠加请求 / 应答模型与应答事件分发。下面先讲为什么拆成两条,再按信封、建连、发送、分发的顺序展开。


2. 为什么是两条

三条通道的整体拓扑见项目总览页。从代码看,行情和交易在下面几个维度上都不一样:

维度 行情通道 交易通道
后端 行情服务器,地址和行情专用凭证由账户信息接口下发 交易网关,地址按环境固定
鉴权 连上之后发一条登录消息,带行情凭证 建连时在握手头里带用户凭证,服务端在鉴权事件里回结果
流量 高频主推,丢一帧下一帧会覆盖 低频,请求-应答为主,夹着主推
隔离 不需要 期货、证券两套柜台,用命名空间天然隔离
应答与主推 同一种帧,按类型区分 两个事件名:一个放应答,一个放主推
可靠性 连上、登录、订阅即可 先鉴权再开柜台命名空间,另有账户级心跳和被踢处理

所以行情用轻量的原生 WebSocket 加自定义信封;交易用 Socket.IO,利用它的命名空间和事件名,并强制只走 WebSocket 传输。两边都用 Protobuf。


3. 整体设计

业务分发连接层(单例)
行情连接交易连接行情:当前代理期货 / 证券:各自当前代理交易登录管理 载体属性通知中心 状态广播当前页面市场容器交易侧行情中间层交易数据中心页面基类

连接层只做三件事:建连与保持、按信封解包、把结果投递出去。投递有四种出口:

  1. 当前代理:每条通道一个 weak 代理,页面出现时把自己设成当前代理;
  2. 二次扇出:市场容器、交易侧的行情中间层自己当代理,再转给子页或按业务拆成持仓、委托、成交等回调;
  3. 载体属性:登录、心跳、被踢这类应答,直接赋给交易登录管理器上对应的属性,属性的观察器就是处理器;
  4. 通知:网关连上 / 断开、凭证变化等状态,走全局通知。
行情连接当前代理 weak是否已登录重连计数 / 登录计数心跳定时器发送(类型, 序号, 负载)交易连接鉴权命名空间期货命名空间证券命名空间期货代理 weak证券代理 weak串行处理队列发送(命名空间, 类型, 负载, 请求序号)行情代理收到数据(信封)需要重新订阅()交易登录管理证券登录载体期货登录载体重复登录载体断线载体

4. 关键实现

4.1 信封(摘要)

两条通道都把业务消息再包一层信封:类型决定负载怎么解,序号由应答原样带回,负载是 Protobuf 编码的业务消息。交易信封另带网关返回码,在交易长连接的信封里补上平台与设备标识,用来在多设备场景下只处理本设备的应答和主推。

协议以子模块的形式引入后端的协议仓库,用脚本调用 protoc 生成 Swift 代码;升级时除了重新生成,还要跟着改调用方,比如一次交易协议的大版本升级,以及交易页面与视图模型对新协议的适配。

信封的字段、请求序号怎样生成、应答怎样回到发起方、应答与主推的区别,单独写在交易消息协议一篇。

4.2 行情通道:连上、登录、再订阅

行情连接的生命周期是一串条件:连上之后先登录,登录成功才开心跳,并通知当前代理「需要重新订阅」。

重连(前台、有网络、不在 节流窗口内)连接成功检查周期到了仍未连上,再 重连登录应答成功,或服务端回 「已经登录」登录周期到了仍未成功,再 发一次(有上限)每 10 秒一次心跳断开,停心跳主动正常关闭,不重连
未连接连接中已连接已登录

几处具体做法:

  • 重连节流:距上次发起连接不足「检查周期 × 0.8」直接返回;发起后过一个检查周期再看,没连上就再来一次,次数有上限。
  • 建连超时:建连请求的超时比检查周期短 2 秒,保证检查时这一次已经有结论。
  • 登录也节流:距上次登录请求太近就不发;登录前先检查行情凭证是否快过期,必要时先换新。
  • 心跳:只在登录成功后发,未登录时发心跳没有意义。
  • 前后台:进后台主动断开;回前台计数清零后重连;网络变化时如果还没登录,也清零重连。
  • 收到数据:回调在专用队列上。登录应答和心跳应答在连接层自己消化,其余的整帧交给当前代理;解包失败会记一条错误日志,归类为 Protobuf 解码错误。
行情服务器行情连接当前页面行情服务器行情连接当前页面建连连接成功检查行情凭证是否快过期登录(行情凭证)登录成功开始 10 秒心跳需要重新订阅订阅快照 / K 线 / 分时主推收到数据(整帧信封)

4.3 交易通道:先鉴权,再开柜台

交易通道先连鉴权命名空间,鉴权成功后才初始化期货和证券两个命名空间。

期货 / 证券命名空间鉴权命名空间交易连接用户凭证期货 / 证券命名空间鉴权命名空间交易连接用户凭证alt[鉴权成功][鉴权失败][其他结果]凭证变化通知建连,握手头带凭证等鉴权事件初始化并连接已连接稍等片刻再通知「已建立」记下失败时间,强制续期新凭证到达,重连记录并重连

鉴权结果和用户凭证串在一起:用户凭证一变化就发全局通知,交易连接收到后,有凭证就连,没有就断。鉴权失败时强制续期,续期成功写入新凭证又会触发这个通知,于是自然完成一次重连。为了避免拿着旧凭证反复鉴权,失败后的一小段冷却时间内,没有拿到新凭证的连接请求会被忽略。

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
连上后固定延迟再放行 原因不明的临时方案 等服务端的就绪消息再通知外部
「已经登录」按文案判断 文案一改就失效 和服务端约定独立返回码
心跳应答只清计数 半开连接发现得慢 连续若干次未收到心跳应答就主动重建连接
属性观察器做处理 隐式,难测试 显式事件枚举 + 纯函数处理器,补单元测试
现在 两套重连 / 单代理 / 固定延 迟公共连接状态机多播订阅表就绪消息心跳超时判死更可测、更少重复的长连接 层