网络层封装复盘:三段式解析、错误码归一,以及启动时的「配置门」

项目 social-app

  • 网络层
  • RxSwift
  • Moya
  • 并发
  • 请求合并

1. 背景与问题

这是一款面向日本市场的语音社交 App。iOS 客户端的业务接口有好几百个,分布在动态、语音房、消息、登录、内购等模块里。网络层要解决三件事:

  1. 写起来简单:业务方发一个请求、拿到一个强类型模型,不想每次都处理状态码、业务码和 JSON 解析。
  2. 错误口径统一:网络断开、超时、服务端返回业务错误、登录失效,这些情况在界面上的表现各不相同,必须在一个地方归类。
  3. 请求之前先有配置:App 冷启动时,首页、消息、个人页往往同时发请求,但几乎每个请求都依赖一份「全局配置」:当前走哪个环境、域名名单、资源地址、活动开关等。配置还没回来就发业务请求,可能打错环境,或拿到不完整的数据。

第三点是这篇的重点。最朴素的写法是「每个请求发出前都去拉一次配置」,冷启动时就会出现十几个一模一样的配置请求同时在路上。这里需要的是:不管多少请求同时到达,配置只拉一次,其余的排队等结果。


2. 整体设计

2.1 分层

就绪未就绪
业务模块 动态 语音房 消息 登录按模块拆分的请求方法配置门 配置已就绪吗请求核心 声明式接口 加 响应式封装排队等待配置三段式解析强类型模型统一错误 code 加 message登录失效类错误 统一登出
  • 接口声明层:用 Moya 的声明式接口描述路径、方法、参数,所有业务接口集中在一份枚举里。
  • 请求方法层:每个业务模块一份扩展,对外暴露「一行发请求」的方法。各业务模块在这套底座上按自己的领域加请求方法。
  • 请求核心:RxSwift 封装请求,HandyJSON 做泛型模型映射,超时 30 秒。
  • 配置门:所有非配置请求的必经之路。

2.2 配置门的时序

服务端配置门页面乙页面甲服务端配置门页面乙页面甲发起业务请求还没有配置 回调入队 标记拉取中拉取全局配置发起业务请求已在拉取 只入队 不重复请求配置返回保存配置 刷新域名名单 发出配置就绪通知放行 发出业务请求放行 发出业务请求

这其实就是 Go 里常说的 singleflight:同一个关键请求,在途期间只飞一次,所有等待者共享结果。区别在于它只针对「全局配置」这一个请求,不是对任意同参数请求的通用去重。


3. 关键实现

3.1 三段式解析

服务端响应外层是统一结构:业务码、提示文案、数据体。解析按三段走:

否是否是否是否是
收到响应请求层状态码正常吗归一为 无网络 或 请求失败外层业务码是成功吗是登录失效类错误码吗发出登出通知 一次性去重回调错误码数据体能映射成模型吗回调 解析失败回调 强类型模型

网络类错误(断网、超时、连接失败)统一收成一个「无网络」码,界面只需要判断这一种情况就能决定是弹 toast 还是展示空页面。

3.2 配置门(示意,非原始代码)

func request<T>(_ api: API) -> Single<T> {
    if api.isConfig || ConfigGate.shared.hasConfig {
        return send(api)
    }
    return Single.create { observer in
        onMain {                                   // 等待队列只在主线程读写
            ConfigGate.shared.waitConfig { ok in
                ok ? send(api).subscribe(observer)
                   : observer(.failure(.configUnavailable))
            }
        }
        return Disposables.create()
    }
}

final class ConfigGate {
    private var waiters: [(Bool) -> Void] = []
    private var fetching = false

    func waitConfig(_ cb: @escaping (Bool) -> Void) {
        assertMainThread()
        if hasConfig { cb(true); return }
        waiters.append(cb)
        if fetching { return }                     // 已在拉取:只排队
        fetching = true
        fetchConfig { result in
            if case .success(let c) = result { self.save(c) }
            let ok = result.isSuccess
            self.waiters.forEach { $0(ok) }        // 批量放行或批量失败
            self.waiters.removeAll()
            self.fetching = false
        }
    }
}

几个细节:

  • 用主线程串行化,而不是加锁。等待队列是普通数组,所有读写都切到主线程。回调本来就要回主线程更新界面,所以这是成本最低的串行化方式;代价是调用方必须先切线程,因此对「在非主线程进入配置门」做了异常打点。
  • 失败也要批量放行。配置拉失败时,排队中的请求全部失败,而不是在没有配置的情况下各自偷偷发出去;否则又回到了「打错环境」的问题。
  • 配置就绪后做的事:刷新域名名单(失败最多重试 3 次)、按需下载打招呼素材、发出「配置已就绪」通知。打招呼素材改为进入聊天页再下载,并且改到子线程执行。

3.3 登录失效只登出一次

冷启动时如果会话已经失效,十几个请求会几乎同时收到「登录过期」。如果每个都发登出通知,用户会看到登录页被连续推好几次。做法是账号管理器上挂一个一次性标志:第一次发出登出通知后置位,之后的同类错误直接忽略;登录成功时复位。

3.4 另一类「合并」:批量拉用户资料

消息列表、群成员、语音房观众,都需要把一批用户 ID 换成头像和昵称。这里用的是另一套手段:批量接口加分批并发。

服务端后台队列资料中心会话列表服务端后台队列资料中心会话列表par[最多 2 路并发]需要这些用户的资料过滤掉本地已有的拷贝一份缓存 切到后台队列按每批 100 个切分批量拉取 第 1 批批量拉取 第 2 批返回资料分组等待汇总 单次最多等 10 秒回主线程 刷新列表
场景 每批数量 说明
用户资料、精简资料 100 信号量限制最多 2 路并发
群资料 50 会话列表进入时拉取
会员信息 50 批量查询

这里先拷一份缓存字典再下到后台队列,是为了不在后台线程直接读写主线程上的字典;同时断言「不要在主线程执行」,防止有人在主线程调用后被信号量卡住界面。用户资料走的是同一套分批合并,和配置门不是一个队列。

3.5 公共请求头

字段(概念) 用途
客户端版本(带平台前缀) 服务端按版本做兼容
设备标识、渠道 统计与风控
毫秒时间戳 防重放、排查
会话凭证 鉴权
语言 中文环境下附带 locale

4. 边界与失败处理

4.1 非主线程进入配置门导致的崩溃

线上出现过一类数组并发修改的崩溃,最后加上了「保证配置请求在主线程」的约束。这类问题可以这样处理:

  1. 怎么复现:在冷启动阶段,让一个后台线程的回调(比如某个 SDK 的初始化回调)里发业务请求,同时主线程也在发请求。配合 Thread Sanitizer 跑几次,通常能看到对等待队列的数据竞争报告;不开 TSan 时则表现为偶现的数组越界或野指针,很难稳定复现。
  2. 怎么定位:崩溃栈落在数组的 append 或遍历上,且两个线程栈同时出现配置门的方法。顺着调用方往上找,看哪些入口没有切线程。
  3. 怎么修:入口处统一切回主线程再进入配置门,同时对非主线程调用打点,把「谁没切线程」这件事暴露出来,而不是悄悄兜住。

4.2 其它边界

  • 超时闭包里的悬挂:请求超时闭包中,如果构造请求对象失败,代码直接返回而没有调用完成回调,这个请求会一直挂着,上层既拿不到成功也拿不到失败。这是代码审查时能看出来的问题,正确做法是失败分支也要回调错误。
  • 配置失败:排队的请求全部失败,界面按「无网络」处理,用户下拉刷新会重新触发配置拉取。
  • 配置只拉一次:配置是否需要拉取,只看「内存里有没有这份模型」。好处是简单;代价是长时间驻留后台的进程拿不到新配置。

5. 演进

起步 声明式接口与统一错误码扩张 接口按模块拆分配置门 并发共享一次拉取收敛 配置门只在主线程高频查询 改为分批

底座的形状在这几步里基本没变,说明「一行发请求 + 统一错误码」够用。变化集中在配置门的线程约束和批量拉取。


5.1 把配置门再拆开

一次业务请求先看配置,再看状态码和业务码。

在不在
业务调用配置在吗发请求去排队状态码业务码模型

配置本身只有几个状态,后来的请求不再另开一次拉取。

第一个请求成功并放行一并失败
空拉取中就绪失败
请求是否配置接口路径与参数配置门等待队列是否在拉取错误无网络登录失效业务码

多个请求同时收到登录失效时,只通知一次。

没有有
请求甲失败已通知过?请求乙失败发一次登出忽略下次登录成功后复位

用户资料是另一种合并:按批拉取,不是配置门。

要拉的用户去掉已有缓存按批切开有限并发汇总

从声明式接口到配置门,再到主线程约束和分批,是同一层上的几步,不是换了一套网络库。

起步 声明式接口与统一错误码加上配置门合并并发收紧只在主线程读写队列高频查询改为分批网络层的几步

6. 复盘与可改进之处

做对了的地方

  • 三段式解析加错误码归一,让几百个接口的错误处理只有一套口径。
  • 配置门用最小的代价解决了冷启动的请求风暴,而且失败时宁可全部失败,也不在没有配置的情况下发请求。
  • 登出去重是很小的改动,却直接决定了会话失效时的用户体验。

还可以做得更好

  1. 请求生命周期:订阅结束后仍挂在一个全局的 DisposeBag 上,页面销毁时取消不了请求,订阅对象也会随请求数增长。可以让每个请求跟随调用方的生命周期,或者迁移到 async/await,用 Task 的取消语义。
  2. 配置过期:给配置加版本号或过期时间,回到前台时按需刷新;登出时明确清空。
  3. 通用去重:目前只有配置这一个请求做了 in-flight 合并。可以用「方法 + 路径 + 参数」生成签名,在途期间相同签名共享同一个任务,适用于资料、详情这类读请求。
  4. 超时与失败分支必须回调:所有提前返回都要配一次失败回调,可以用 defer 或统一的完成器保证「恰好回调一次」。
  5. 用 actor 代替主线程串行化:在 Swift Concurrency 下,配置门天然适合写成一个 actor,不再依赖调用方切线程。
现在 主线程串行 全局订阅容器 只合并配置配置门改为 actor请求跟随页面生命周期 可取消读请求按签名做 in-flight 去重配置带版本和过期时间更可控的网络层