Appearance
遗留支付系统模块化重构:为什么坚持小步修改、小步发布
一个运行多年的支付系统,渠道接口稳定,业务也能正常执行,为什么还需要重构?
在我维护的留学缴费系统中,问题主要来自代码组织。
随着业务持续增加,一个渠道 Client 逐渐承担了汇率、换汇、付款、付款人等多种职责。日常维护需要在大型类中寻找实现,新功能也容易继续堆积到同一个位置。
当两名开发人员同时处理不同业务时,还可能因为修改同一个大型文件而相互干扰。
我希望通过模块化改造,让不同业务功能拥有明确的代码归属,降低未来的定位、理解和维护成本。
但现实条件并不允许我集中精力完成一场大规模重构。
团队只有我和一名实习生,正常业务开发和生产维护不能停止,重构只能利用零散时间推进。项目也只有测试环境和生产环境,没有独立的预生产环境。
因此,我没有选择一次性重写整个渠道 Client,而是按业务功能逐步迁移,每一批尽量独立验证、独立发布。
最终,渠道 L 完成了模块化迁移和强类型化改造,原来的 ChannelLClient 也已删除。其他渠道的治理仍在继续。
本文关注的重点,是完成这项改造的实施方式:当人力、时间和验证条件都有限时,怎样让重构持续推进,同时不影响正常业务交付。
本文是《一个留学缴费遗留系统的治理:边界、验证与工程判断》的专题文章。工程名称和渠道名称均已匿名化。
一、从实际维护问题出发,而不是先追求目标架构
1.1 旧系统稳定,但代码越来越难定位
渠道 L 是系统长期使用的外部支付渠道之一。
渠道接口相对稳定,调整频率不高。不少方法已经在生产环境运行多年。
这次重构不是为了解决频繁发生的渠道故障,也不是为了提高接口吞吐量。
真正需要改善的是代码组织方式。
原来的 ChannelLClient 集中了汇率、付款、换汇、付款人等不同业务领域的接口。
要修改某项功能,需要先找到对应方法,再识别哪些辅助方法、数据对象和配置与它有关。
随着功能增多,定位和理解代码的成本不断积累。
因此,我希望先明确代码归属,让不同业务功能拥有各自的实现位置。
1.2 大型 Client 也增加了协作成本
过去新增渠道功能时,直接在 ChannelLClient 中添加方法,是最容易采用的方式。
但不同业务集中在一个类中,会让本来无关的开发任务产生代码层面的交叉。
后来,系统新增定时对账接口,同时仍在迁移部分旧渠道功能。
我们两个人分别承担新功能开发和旧代码迁移,按照新的业务组件组织代码,使两项工作可以在不同位置进行。
这是已经观察到的变化。至于 Git 冲突减少了多少,并没有专门统计。
1.3 按职责建立独立组件
重构过程中,我建立了独立渠道模块 project-payment-channel-l,逐步将原来的 Client 拆分为多个按职责组织的组件。
例如:
text
project-payment-channel-l
├── BaseChannelLHttpComponent
├── ChannelLFxHttpComponent
├── ChannelLPayoutHttpComponent
├── ChannelLPayerHttpComponent
├── ChannelLCardHttpComponent
├── ChannelLOtherBankListHttpComponent
└── ...这里只列出部分组件,用于说明拆分方向。
汇率相关功能进入 Fx 组件,付款相关功能进入 Payout 组件,付款人相关功能进入 Payer 组件。
通过这样的拆分,我希望开发和维护时能够从业务职责出发定位代码,而不是先进入一个大型 Client 再寻找方法。
模块化在这里首先要解决的是一个实际问题:
当需要修改某项业务时,能否更容易找到对应代码?
二、先考虑现实限制,再决定重构方式
2.1 最大限制是人力和时间
这次模块化改造是我主动发起的工程治理,并不是公司安排的专项重构任务。
团队只有我和一名实习生。
正常业务开发和生产维护不能停止,重构只能在日常工作中抽时间推进。
有时候今天能迁移一个方法,明天就需要处理其他需求。
如果把整个渠道重构设计成一项必须连续投入数周才能完成的任务,很可能因为其他业务进入而长期无法交付。
因此,我首先要求每批任务足够小。
能够完成一个功能的迁移,就先完成验证和发布。之后有没有时间继续,不应影响当前批次已经完成的成果。
这也是我坚持小步修改、小步发布的最初原因。
2.2 验证条件决定了修改边界
项目只有测试环境和生产环境,没有独立的预生产环境。
部分功能可以在测试环境主动构造业务流程,但并不是所有渠道接口都具备相同的验证条件。
例如,换汇需要真实的入学通知书等证明材料,无法随意构造完整的换汇交易。
此外,渠道方测试环境与生产环境的数据可能存在差异。
因此,重构的批次大小不能只由代码量决定,还要考虑当前功能能够采用什么验证方式。
对于汇率查询,可以主动发起请求并核对结果;对于付款和退款,可以通过产品页面执行业务流程;对于换汇,则需要借助正常订单和渠道调用日志。
这些条件影响了具体的实施选择,也决定了不同业务不能简单套用同一套验证流程。
2.3 分支按交付边界结束,而不是按天数结束
如果建立一个长期重构分支,一次性迁移全部渠道业务,那么正常业务需求持续进入主干时,分支可能积累越来越多的差异。
已经完成的部分也可能因为等待其他功能而无法发布。
我的做法是缩小每批任务范围,让当前改造能够独立交付。
分支没有固定的存续天数。
完成必要验证与发布,就结束当前批次。下一项任务有时间再开始。
这种安排的目的不是提高 Git 操作技巧,而是避免重构长期占用一个无法交付的分支。
重构可以暂停,但已经完成的修改不应该因此长期滞留。
三、先迁移代码,再考虑优化内部实现
3.1 从一个汇率查询方法开始
最初,我选择渠道 L 的汇率查询接口作为迁移入口。
汇率查询属于只读操作,相比付款、换汇等资金相关接口,更容易主动验证。
这一批从该接口对应的一个 Java 方法开始,而不是把整个渠道的接口一次迁移。
我先建立新组件,复制旧方法,补充测试,切换调用,再完成测试环境和生产环境的必要验证。
这样既可以验证新模块的 Maven 依赖、Spring 组件装配和 HTTP 调用,也能将当前影响范围控制在一个较小的业务单元中。
后续批次的规模并不固定:可能是一个方法,也可能是一组能够共同验证的相关方法。
因此,我并不以修改文件数量、Commit 数量或开发天数定义“小步”,而是关注当前功能能否独立验证和交付。
3.2 第一类改造只改变代码归属
迁移时,我先在新模块建立相应的 Component,再复制旧方法。
例如:
text
原有位置
ChannelLClient
└── 汇率查询方法
新的位置
ChannelLFxHttpComponent
└── 汇率查询方法复制时保持原有请求参数、业务判断、异常处理及响应解析逻辑。
当前批次只调整代码归属和调用关系,不顺便优化业务规则,也不同时将 Map 改成强类型 DTO。
如果一次修改同时改变模块依赖、调用路径和数据表达,出现问题时就需要同时检查多个方面。
将不同性质的变化分开,可以缩小当前的验证范围。
3.3 为什么短期保留旧方法?
复制完成后,我没有立即删除旧方法,而是在新旧实现之间添加对应注释,方便必要时直接查找。
这是一次偏保守的预防性选择。
项目没有预生产环境,渠道方测试与生产数据也可能不同。我希望在生产验证期间,能够直接找到原有方法进行对照,而不用先从 Git 历史中恢复代码。
保留旧方法带来两项便利:
- 需要排查时,可以直接比较新旧实现。
- 如确有必要,可以调整调用关系,重新验证并发布,让当前接口恢复使用原方法。
生产版本回滚则由正常部署流程完成,与旧方法是否保留是两个不同问题。
实际上,这次迁移没有出现需要逐行比较新旧实现的异常。
旧方法只是短期保留,具体清理条件在后面的业务验证部分说明。
3.4 为迁移补充 UT/IT
迁移旧渠道功能时,编写测试花费了不少时间。
我会将实际接口请求或响应进行脱敏,保存到:
text
src/test/resources/UT 读取这些样本,验证 JSON 解析、字段映射及相关处理逻辑。
脱敏需要处理能够识别个人、账户及真实交易的信息,同时保留测试需要的数据特征。
例如,金额精度、日期时间格式如果是测试目标,就不能因为脱敏而破坏对应的数据结构。
需要实际渠道接口或运行环境参与的 IT,则根据情况手工执行。
这确实增加了每次迁移的成本,但也逐步留下了可在后续改造中复用的测试资产。
3.5 第二类改造再处理 Map → DTO
原有渠道代码中,部分接口使用 Map<String, Object> 构造请求或处理响应。
例如:
java
Map<String, Object> params = new HashMap<>();
params.put("currency", "USD");
params.put("amount", amount);字符串 Key 缺少明确的编译期约束,IDEA 也无法像追踪 Java 字段一样可靠地追踪它们对应的数据契约。
因此,在方法归位以后,我又逐步开展强类型化改造。
例如:
java
public class FxRequest {
@JsonProperty("from_currency")
private String fromCurrency;
private BigDecimal amount;
// getter / setter
}以上只是用于说明字段映射方式的简化示例,不代表渠道 L 的实际接口字段。
这也是为什么在第一类改造时,我没有顺手调整 Map。
强类型化改变的是数据表达方式,还可能涉及序列化、反序列化、字段映射及类型转换。它需要独立的 UT/IT 和发布验证。
两类改造之间只有局部的先后关系:
同一业务先完成模块归位,再考虑强类型化;不同业务可以穿插推进,没有统一的全局顺序。
例如:
text
业务 A:模块归位 → 验证 → 发布
业务 B:模块归位 → 验证 → 发布
业务 C:模块归位 → 验证 → 发布
业务 B:Map → DTO → 验证 → 发布这只是说明执行顺序可以灵活安排,不代表业务 A、C 最终没有完成强类型化。
实际改造中,渠道 L 最终完成了本轮模块化与强类型化治理,原来的 ChannelLClient 也已经删除。
但每一次具体改造都能够独立交付,不需要等待所有功能同时完成。
四、根据业务特点选择验证方式
不同接口的风险和验证条件不同,不能只凭单个接口调用成功就认定迁移完成。
4.1 汇率查询:可以主动验证
汇率查询属于只读操作,可以主动发起请求,检查返回结果。
生产发布前后,我会分别获取接口 JSON,人工核对字段、类型与结构。
由于汇率等数据会变化,不要求所有数值完全一致。
4.2 下单、付款和退款:在两个环境验证实际业务
对于下单、付款和退款相关功能,我分别在测试环境和生产环境,通过产品页面执行相应业务流程。
生产环境没有专用测试账户。
开发人员使用正常银行 App 完成真实付款,再按业务流程进行退款。
这使验证覆盖了产品页面、订单处理、实际付款及退款等环节,而不只是 HTTP 请求和 JSON 解析。
这种验证需要真实资金参与,也会占用开发人员的操作时间。
4.3 换汇:利用真实订单和生产日志验证
换汇是一个特例。
渠道方要求提供真实的入学通知书等证明材料,因此无法为了测试随意构造换汇交易。
换汇一旦成功就已经成交,渠道方也无法协助直接撤销。
从目前使用的渠道接口来看,换汇表现为自动执行,没有可供开发人员使用的独立人工审核环节。
如果渠道方可以先受理、审核后再成交,就能够在实际成交前增加一次检查机会。但目前没有这样的操作空间。
系统存在持续发生的正常业务订单,因此可以利用实际交易完成生产验证。
生产发布前,我从日志中提取旧实现调用渠道时的请求与响应 JSON,作为对照样本。
发布后,运营人员准备执行正常换汇业务时,会提前通知我。我再从生产日志中找到新实现的请求与响应,和之前的样本进行人工比较。
由于前后是不同真实订单,因此主要比较字段、数据类型、JSON 结构及业务处理结果,不要求金额、汇率、订单编号等动态值一致。
发布后继续观察相关调用日志,确认前后功能表现符合预期。
对这类成功后无法直接撤销的业务,首次生产调用仍然存在无法完全提前排除的风险。
因此,迁移时尤其需要控制改动范围,保持原有换汇逻辑不变。
4.4 验证完成后再清理旧方法
旧方法没有固定的保留天数。
能够主动测试的功能,在完成必要验证后就可以清理。
换汇等受业务条件限制的功能,则需要等待符合条件的真实订单完成验证。
确认迁移后的调用正常后,删除旧方法,清理随下一次正常发布上线。
因此:
- 方法迁移及调用切换,作为独立的改造批次发布。
- 旧代码清理,在完成生产验证后,随正常发布进行。
通过这种方式,原 Client 中的方法逐步迁移并清理,最终整个 ChannelLClient 被删除。
五、小步修改之外,还要控制发布批次
5.1 小步提交不等于小步发布
例如,将十个接口分别提交十次 Git Commit,但最后一次性发布。
生产环境仍然同时接受十项变化。
因此,我更关注的是每批进入生产环境的变更范围,而不只是 Git Commit 的数量。
迁移一个接口或一组相关的小功能,完成测试和验证后就可以发布,再考虑下一批。
这既缩小了问题出现时的排查范围,也避免已完成的工作因为等待其他接口而迟迟无法交付。
5.2 小步发布的实际成本
每一批迁移都可能需要:
- 修改代码及调用关系;
- 补充 UT、手工执行 IT;
- 在测试环境验证业务流程;
- 在生产环境核对接口行为;
- 清理旧代码。
这些步骤增加了测试、构建和发布次数。
有时移动一个方法很快,准备测试和完成验证反而花费更多时间。
但在只有两名开发人员、没有独立测试人员的情况下,一次性修改大量接口,可能需要承担更高的验证和排查成本。
因此,我更愿意接受多次小范围的验证和交付,而不是把改造绑定成一个必须全部完成的大任务。
六、让重构能够中断,也能够低成本恢复
正常业务和生产维护始终优先于自主发起的重构。
如果发生需要紧急处理的生产 BUG,我会先暂停当前迁移,独立修复生产问题。
待修复进入主干,再考虑恢复重构工作。
对于尚未交付的重构分支,可以尝试同步主干修复。如果冲突容易处理,就合并并重新验证。
如果冲突复杂,而当前只修改了少量方法,我会考虑舍弃当前分支,从最新 main 重新实施。
这属于事先考虑的应急方案,并不是本次渠道 L 改造中已经发生过的严重生产事故或实际分支重建案例。
这里的目标是控制未完成工作的损失范围,而不是不惜代价保留当前分支。
七、实际获得了什么,又付出了什么代价?
7.1 新功能与旧代码迁移可以并行
新增定时对账接口时,我们两个人分别开展新功能开发与旧代码迁移。
两项任务可以修改不同业务组件,不必都集中在原来的大型 Client 中。
这是实际发生的协作方式变化。至于 Git 冲突究竟减少了多少,没有进行量化统计。
7.2 新代码有了明确归属
由于渠道原有接口相对稳定,我没有足够多的后续维护案例,可以量化代码定位时间或排障时间减少了多少。
但新功能已经能够按照业务职责进入对应组件,不再默认继续堆积到大型 Client。
这是已经能够观察到的收益。
7.3 测试投入留下了可复用资产
补充 UT/IT 和脱敏请求、响应样本花费了不少时间。
但它们可以继续用于后续的数据对象调整、JSON 解析修改和其他维护工作。
目前整个系统的测试覆盖仍不完整,部分 IT 需要人工执行。
这次改造积累了测试资产,但并没有因此建立一套覆盖全部业务的自动化回归体系。
7.4 一个渠道完成了,整个系统还没有
经过多个批次的修改、验证和发布,渠道 L 的模块化与强类型化改造最终完成,原来的 ChannelLClient 已删除。
其他渠道仍然存在需要治理的历史代码。
这不妨碍渠道 L 已完成的成果正常运行,也不要求所有渠道必须按照相同进度改造。
7.5 这种方式有成本,也有适用条件
本文采用的方法适合当前项目的实际条件:外部接口相对稳定、原有逻辑已长期运行、可以组织必要的业务验证,也能够借助正常订单验证特殊接口。
代价包括反复补充测试、人工核对、多次部署,以及短期重复代码。
如果外部协议频繁变化、原有代码本身存在大量需要同时修正的行为问题,或者缺少有效验证条件,就需要重新评估每批的改动范围和迁移方式。
因此,本文提供的是一种判断和实施思路,而不是要求其他项目照搬相同的组件划分或发布流程。
八、总结:不必一次做完,但每次都要做完
回顾这次渠道改造,我认为最值得总结的不是拆分了多少个组件,而是三个工程判断。
第一,先明确约束条件,再决定改造范围与验证方式。
人力、时间、测试条件和业务风险,共同决定当前能够安全推进多大范围的修改。
第二,将不同性质的变化分开,让每一批都能独立交付。
先完成代码归位,保持原有业务行为;随后独立开展强类型化等内部改造。不同业务可以按需要穿插推进,不必等待全部完成才发布。
第三,为中断留下空间,控制未完成工作的成本。
正常业务开发不会因为重构而停止。任务范围足够小,才能在需要时暂停,并从稳定代码基线继续推进。
渠道 L 最终完成了模块化与强类型化,但它只是一个具体案例。
真正可以借鉴的,是在资源受限时,把长期重构拆成一系列能够独立验证、独立交付的工作,而不是等待一次理想的、完整的重构机会。
这里的“每次都要做完”,不是要求不惜代价完成已经开始的修改,而是要在开始之前,将任务控制在能够独立完成、验证和交付的范围内。如果实际条件变化,也允许暂停或放弃尚未交付的修改。
不必一次做完,但每次都要做完。
关联阅读
- 一个留学缴费遗留系统的治理:边界、验证与工程判断 — 系统整体治理的背景、边界与工程判断。