Appearance
如何编写可维护的上游渠道调用代码
对接一个上游渠道,最开始往往并不难。
准备请求参数,拼接 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 放在一起看时,实际上可以形成一个很有价值的对照矩阵:
| UT | IT | 更值得优先检查什么 |
|---|---|---|
| PASS | PASS | 当前代码与当前上游都符合预期 |
| PASS | FAIL | 当前代码仍兼容已知协议,优先检查上游、网络、认证、证书、环境和当前真实响应 |
| FAIL | PASS | 历史样本可能已经过期,或者本地代码已经不再兼容旧协议,需要判断旧协议是否仍然要求支持 |
| FAIL | FAIL | 不能直接归因,需要同时检查本地代码和真实环境 |
其中最常见、也最容易产生价值的一种情况是:
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 的区别;
- 知道某个错误码代表什么。
真正的问题是:
这些上下文半年以后还剩多少?
因此,我目前更倾向于坚持五条原则:
- 把上游的不确定性收敛在渠道边界内;
- 让代码本身承担文档职责;
- 能离线验证的能力,尽量固化成 UT;
- 用 IT 验证当前真实渠道,而不是重复 UT;
- 不要为了“统一”抹掉真实存在的协议差异。
这些原则最终都服务同一个目标:
让渠道代码在上游变化、依赖升级和人员更替之后,仍然容易理解、容易验证、容易修改。
对我来说,这才是“可维护的上游渠道调用代码”。