Skip to content

如何编写可维护的上游渠道调用代码

对接一个上游渠道,最开始往往并不难。

准备请求参数,拼接 URL,发起 HTTP 请求,解析响应,接口就算“调通”了。

真正麻烦的是半年以后。

上游可能调整字段、升级协议版本、替换域名、修改签名算法;我们自己的代码也会升级 JDK、HTTP Client、JSON 库、密码学依赖。原来的开发人员可能已经不再维护这个模块,新接手的人需要重新理解渠道文档、历史报文、签名规则和各种特殊分支。

因此,我判断一段渠道调用代码是否设计得好,并不只看:

今天能不能成功调用。

更重要的是:

以后的人能不能看懂、验证、修改,并且在上游变化时把影响控制在一个有限范围内。

这篇文章讨论的核心只有一个:

如何让上游渠道调用代码长期可维护。

“对开发人员友好、可理解、可验证、可演进”都不是独立目标,而是“可维护”这个目标的具体表现。

我目前比较认可下面五条原则。

1. 把上游的不确定性收敛在渠道边界内

上游渠道最大的特点,是它不受我们控制。

它可能变化:

  • URL;
  • Header;
  • 认证方式;
  • 请求字段;
  • 响应字段;
  • 错误码;
  • 签名规则;
  • 加密方式;
  • 时间格式;
  • 金额单位;
  • API 版本。

如果这些细节直接扩散到业务代码里,那么上游每一次变化都会在系统内部形成较大的修改范围。

例如业务层不应该到处出现:

java
if ("10017".equals(channelResp.getCode())) {
    ...
}

也不应该让 Service 层到处知道:

java
HttpHeaders
RestClient
Authorization
X-Signature
merchantId
channelEndpoint

这些都属于渠道协议的一部分。

更合理的结构是把它们尽量收敛在渠道边界内:

text
业务代码

本地业务接口

渠道适配层
    ├── HTTP 调用
    ├── Request / Response DTO
    ├── 签名 / 验签
    ├── 加密 / 解密
    ├── 错误码转换
    └── 上游协议细节

上游渠道

业务层看到的应该尽量是本地语义,例如:

java
ExchangeRateResult queryExchangeRate(ExchangeRateQuery query);

而不是:

java
ThirdChannelRateResponse requestRate(ThirdChannelRateRequest request);

这里并不是说每个渠道都必须设计一套复杂的 Adapter 或 Domain Model。

重点是:

上游的变化尽量停在渠道边界里,不要让外部协议成为整个业务系统的内部协议。

这样上游改字段、换 URL、升级签名算法时,修改范围才容易控制。

2. 让代码本身承担文档职责

渠道开发很容易产生大量“代码之外的知识”。

例如:

  • 渠道提供的 PDF;
  • Wiki;
  • Word;
  • 邮件;
  • Swagger;
  • 群聊记录;
  • 联调时临时保存的一段 JSON;
  • 某个开发人员脑子里记着的特殊规则。

这些资料当然有价值,但它们都有一个共同的问题:

它们和当前代码是否仍然一致,并没有自动保证。

因此,我倾向于让代码仓库本身保存尽可能多的协议事实。

2.1 保存真实请求和响应,而不是只保存“示例”

如果联调过程中已经拿到了测试环境的真实请求或响应,我一般不会重新手写一份“看起来差不多”的 JSON。

更常见的做法是:

text
测试环境真实报文

必要脱敏

src/test/resources

例如:

text
src/test/resources/
└── lianlian/
    ├── payer/
    │   └── getPayer-resp.txt
    ├── payout/
    │   ├── rate-req.txt
    │   └── rate-resp.txt
    ├── virtualAccount/
    │   ├── fundingTrans-resp.txt
    │   ├── getVirtualAccount-resp.txt
    │   └── queryPageFundingTrans.txt
    └── webhook/
        └── webhooklist.txt

.txt 还是 .json 并不重要。

它们本质上都是文本,能够被当前测试正常读取和解析即可。

真正重要的是:

这些内容来自真实测试环境,是经过必要脱敏后的协议快照。

需要脱敏的内容包括但不限于:

  • Token;
  • API Key;
  • 私钥;
  • 完整卡号;
  • 姓名;
  • 手机号;
  • 用户敏感信息;
  • 其他不应该进入 Git 的真实值。

“代码即文档”不等于把敏感数据提交进代码仓库。真实报文可以进入代码仓库,但敏感信息不能;具体约束见后文“安全是贯穿全文的底线”。

2.2 协议样本应该能够被执行

只把 JSON 放进 resources 还不够。

我更希望它可以被测试代码真正消费。

例如:

java
String text = readResource("lianlian/payout/rate-resp.txt");

ExchangeRateResponse response = JSONUtil.parse(text, ExchangeRateResponse.class);

哪怕这个 UT 的核心只有一行解析:

java
JSONUtil.parse(text, ExchangeRateResponse.class);

它仍然有意义。

因为这个测试持续证明:

当前代码仍然能够处理历史上真实出现过、并且已经确认过的协议样本。

这时:

text
真实协议样本 + 强类型 DTO + UT

就不仅仅是一份“测试数据”。

它们共同组成了一份:

可执行的协议文档。

这比只在 Wiki 里放一段响应 JSON 更可靠,因为代码变化以后,这份“文档”会真正参与验证。

3. 能离线验证的能力,尽量固化成 UT

渠道调用很容易让人产生一种错觉:

既然最终都要调用上游,那就等联调时一起测。

我并不赞成这样做。

渠道调用中有大量能力,本身完全可以离开真实上游进行验证。

例如:

  • 请求序列化;
  • 响应反序列化;
  • 字段转换;
  • 金额处理;
  • 币种转换;
  • 时间格式;
  • URL 参数拼装;
  • 签名原文构造;
  • 签名;
  • 验签;
  • 加密;
  • 解密;
  • Base64;
  • 编码转换。

我的原则是:

能离线验证的,不要留到真实联调时才验证。

3.1 为什么签名、验签、加密、解密尤其需要 UT

上游渠道通常都会涉及某种安全协议。

例如:

text
请求参数
→ 排序
→ 拼接签名原文
→ RSA / HMAC / SHA
→ Base64
→ 放入 Header

响应回来以后又可能需要:

text
响应原文
→ 验签
→ Base64
→ RSA / AES 解密
→ JSON 解析

这些能力往往代码量不大,但一旦出现问题,整个渠道链路都会失效。

而且“应用能正常启动”并不能证明这些能力真的可用。

例如升级:

  • BouncyCastle;
  • JDK;
  • Security Provider;
  • JSON 库;
  • Base64 实现;
  • Docker 基础镜像;

都有可能让签名、验签、加解密出现环境差异。

我曾经遇到过 BouncyCastle 版本冲突,也遇到过 Docker 环境中程序能启动,但实际验签失败的情况。 我曾经遇到过 BouncyCastle 版本冲突,也遇到过一次更典型的问题:Java 业务代码没有变化,只是更换了 Docker 基础镜像,多个第三方渠道接口就同时出现了签名失败。

那次问题后来通过最小复现,最终定位到签名代码使用了 String#getBytes(),把平台默认字符集这个运行环境差异隐式带进了签名算法。

完整排查过程见:

这个案例让我更坚定一件事:

高风险底层能力必须由可重复的 UT 直接证明,而不是用“程序启动成功”间接证明。

3.2 不要只做“自己加密、自己解密”的自洽测试

例如下面这种测试有价值:

java
String encrypted = encrypt(plainText);
String decrypted = decrypt(encrypted);

assertEquals(plainText, decrypted);

但它只能证明:

当前的 encrypt 和 decrypt 彼此兼容。

如果两边同时实现错了,测试仍然可能通过。

更有价值的是保存渠道提供或真实联调确认过的固定样例:

text
固定明文
固定密钥 / 公钥
固定签名原文
固定签名值
固定密文

然后验证:

text
固定输入 → 当前实现 → 已知正确结果

这样更接近真正的协议兼容性测试。

3.3 UT 的另一个价值:可以周期运行

UT 不依赖真实上游,因此除了开发人员本地运行以外,还可以:

  • CI 持续执行;
  • 每日构建执行;
  • 周期任务执行;
  • 依赖升级后执行。

这意味着过去已经确认过的协议能力,可以持续被重新验证。

例如:

text
历史真实响应样本
→ 当前 DTO
→ 当前 JSON 库
→ 每天重新解析一次

只要测试始终通过,就能够持续证明:

我们自己的代码没有悄悄破坏过去已经成立的能力。

关于我为什么把 UT、IT 和 SmokeIT 做成不同执行边界,可以参考:

别把所有测试都叫 Test:我如何用 UT、IT 和 SmokeIT 划分执行边界

本文不再重复展开 Surefire、Failsafe 和测试命名约定。

4. 用 IT 验证当前真实渠道,而不是重复 UT

UT 能解决很多问题,但它不能证明上游今天真的还和过去一样。

这里不再重复展开 UT / IT 的命名和 Maven 执行边界,只讨论它们在渠道调用场景中的职责分工。

例如:

text
src/test/resources/rate-resp.txt
→ 当前代码
→ 解析成功

只能说明:

我们仍然兼容这份已知协议样本。

它不能证明:

上游测试环境今天返回的仍然是这套协议。

因此,同一个渠道组件可以同时存在:

text
ExchangeRateApiClientUT
ExchangeRateApiClientIT

例如 UT:

text
src/test/resources/rate-resp.txt
→ JSON 解析
→ ExchangeRateResponse

而 IT:

text
真实 HTTP 请求
→ 上游测试环境
→ 当前真实响应
→ ExchangeRateResponse

两者都可能验证 JSON → 强类型对象,但它们回答的是不同问题。

4.1 UT 验证“我们有没有改坏”

UT 更适合回答:

当前代码是否仍然兼容我们已经确认过的协议?

例如 DTO 重构以后,如果历史协议样本突然无法解析,那么问题很可能发生在我方代码。

4.2 IT 验证“上游现在是不是还这样”

IT 更适合回答:

测试环境里的上游,现在是否仍然按照我们理解的方式工作?

UT 和 IT 放在一起看时,实际上可以形成一个很有价值的对照矩阵:

UTIT更值得优先检查什么
PASSPASS当前代码与当前上游都符合预期
PASSFAIL当前代码仍兼容已知协议,优先检查上游、网络、认证、证书、环境和当前真实响应
FAILPASS历史样本可能已经过期,或者本地代码已经不再兼容旧协议,需要判断旧协议是否仍然要求支持
FAILFAIL不能直接归因,需要同时检查本地代码和真实环境

其中最常见、也最容易产生价值的一种情况是:

text
UT PASS
IT FAIL

这至少能够说明:

当前代码仍然能够处理我们已经确认过的历史协议样本。

接下来就可以优先检查:

  • 上游是否修改了协议;
  • 测试环境是否异常;
  • 域名或证书是否变化;
  • 认证信息是否失效;
  • 网络是否异常;
  • 当前真实响应是否已经偏离历史样本。

反过来,如果出现:

text
UT FAIL
IT PASS

也不能简单理解成“代码坏了”。

这有可能意味着:

  • 本地重构破坏了对旧协议的兼容;
  • 历史样本已经不再代表当前有效协议;
  • 上游已经完成合法升级,而我们仍然在维护一个不再需要支持的旧样本。

因此,UT 和 IT 的价值不仅是“多测一层”,而是提供两组不同来源的证据:

一组代表我们已经确认过的历史协议,一组代表上游当前真实行为。

这两组证据放在一起,才能更有把握地判断变化发生在哪里。

4.3 IT 更适合按需执行

真实 IT 通常依赖:

  • 网络;
  • 测试环境;
  • 测试账号;
  • 上游服务;
  • 限流;
  • 当前数据状态。

因此它和 UT 的运行方式也不同。

在我的实践里:

UT 适合自动、频繁、周期执行;IT 更多用于开发、联调、排障和发布前的人工按需验证。

这也是为什么我不希望把所有测试都混成一个 *Test

5. 不要为了“统一”抹掉真实存在的协议差异

渠道代码很容易出现另一个问题:过度追求统一。

例如上游同时存在:

text
v2
v3

很多时候最直观的想法是:

能不能全部抽象成一个 Component、一个 Request、一个 Response?

如果两个版本真的高度一致,当然可以复用。

但如果协议本身已经发生明显变化,那么为了“代码看起来统一”而强行合并,往往会产生:

  • 大量 nullable 字段;
  • if (version == ...)
  • 不同版本共用一个越来越大的 DTO;
  • 某些字段只在 v2 有;
  • 某些字段只在 v3 有;
  • 签名规则分支;
  • URL 分支;
  • 错误码分支。

最终,表面上只有一个类,实际复杂度并没有消失,只是被隐藏了。

如果外部世界真实存在:

text
pingpong/
├── v2/
│   ├── vo/
│   └── PingpongV2HttpComponent
└── v3/
    ├── vo/
    └── PingpongV3HttpComponent

那么让这种差异直接存在于代码结构中,反而更容易理解。

这里的原则是:

真实存在的外部差异,应该在代码中可见。

公共能力当然可以向下抽取,例如:

text
pingpong/
├── common/
├── v2/
└── v3/

但只抽真正稳定、真正相同的部分。

不要为了减少几个类,就把两套不同协议重新揉成一套复杂模型。

好的可扩展性并不是:

所有渠道和所有版本看起来都一样。

而是:

新增一个渠道、一个版本或一种协议变化时,开发人员能够明确知道新代码应该放在哪里,并且不会破坏已有实现。

这也是“可维护”的一个重要表现。

5.1 协议版本和历史样本也要有退役机制

可维护并不意味着“历史越多越好”。

如果只不断新增:

  • v2;
  • v3;
  • v4;
  • 历史请求样本;
  • 历史响应样本;
  • 对应 UT;

而从来不清理,那么几年以后,代码仓库本身就会重新变成一种负担。

因此,我不会把“历史协议样本”理解成永久真理。

它更准确的含义是:

只要某个协议仍然需要被系统支持,它的样本和测试就应该继续存在。

当一个旧版本已经明确满足以下条件时,就可以考虑退役:

  • 上游已经正式下线该版本;
  • 当前系统已经没有真实流量继续使用;
  • 不再需要兼容历史请求或响应;
  • 回滚方案也不再依赖该版本;
  • 相关业务方已经确认完成迁移。

退役时,最好一起检查并清理:

text
旧版本 Component
旧 Request / Response DTO
旧协议样本
旧版本 UT
旧版本 IT
仅为旧协议存在的兼容分支

这样“协议版本显式存在”才不会演变成“协议历史永久堆积”。

如果某段历史本身具有排障或复盘价值,可以在 Git commit、变更记录或独立文档中保留简要说明,而不必为了保存历史而让已经失效的代码继续参与当前构建。

所以,第五条原则完整地说其实包含两面:

真实存在的协议差异要显式保留;已经确认退出真实世界的协议差异,也应该及时退役。

这也是可维护的一部分。

安全是贯穿全文的底线

前面的五条原则主要讨论可维护性。

但渠道代码通常会涉及:

  • 密钥;
  • Token;
  • 证书;
  • 用户信息;
  • 卡号;
  • 签名;
  • 加密报文;
  • 真实请求和响应。

因此安全不应该只是某一节里的附加建议,而应该是所有实践的前提。

例如“代码即文档”不能理解成:

把测试环境完整响应原样提交到 Git。

正确做法是:

保留协议结构和真实形态,同时删除或替换不应该长期保存的敏感值。

同样,日志也不应该为了“方便排障”直接打印:

  • 私钥;
  • 完整 Token;
  • 完整卡号;
  • 密码;
  • 未脱敏敏感报文。

可维护和安全不是互斥目标。

一份只有原作者知道密钥在哪里、哪些日志能打印、哪些样本不能提交的代码,本身也谈不上真正可维护。

最终我希望渠道代码达到什么状态

如果半年后另一个开发人员接手一个渠道,我希望他不需要先花几个小时翻邮件和聊天记录。

理想情况下,他应该可以从代码仓库中直接看到:

text
渠道入口
Request / Response DTO
协议版本
真实脱敏报文样本
UT
IT
签名 / 验签
加密 / 解密

想知道上游曾经真实返回过什么,可以打开 src/test/resources

想知道当前代码还能不能处理历史协议,可以运行 UT。

想知道测试环境现在还通不通,可以运行 IT。

想知道 v2 和 v3 有什么区别,可以直接从 package 和 DTO 中看到。

想升级 BouncyCastle、JDK 或 JSON 库,可以先跑一遍已有的协议 UT,而不是等联调失败以后再猜。

这时,代码就不只是“调用渠道的实现”。

它同时还是:

  • 协议说明;
  • 历史样本;
  • 可执行验证;
  • 演进记录。

这也是我理解的“代码即文档”。

结语

上游渠道调用最容易写成“能用就行”的代码。

因为第一次联调时,开发人员往往掌握完整上下文:

  • 知道渠道文档在哪;
  • 知道这段 JSON 从哪里来的;
  • 知道为什么有这个特殊字段;
  • 知道为什么这里需要 Base64 两次;
  • 知道 v2 和 v3 的区别;
  • 知道某个错误码代表什么。

真正的问题是:

这些上下文半年以后还剩多少?

因此,我目前更倾向于坚持五条原则:

  1. 把上游的不确定性收敛在渠道边界内;
  2. 让代码本身承担文档职责;
  3. 能离线验证的能力,尽量固化成 UT;
  4. 用 IT 验证当前真实渠道,而不是重复 UT;
  5. 不要为了“统一”抹掉真实存在的协议差异。

这些原则最终都服务同一个目标:

让渠道代码在上游变化、依赖升级和人员更替之后,仍然容易理解、容易验证、容易修改。

对我来说,这才是“可维护的上游渠道调用代码”。