聚合系统设计:如何为银行代付通道抽象出公共的代付接口能力
我司业务涉及银行通道的代付资金请求。
从系统实现层面来看,一次银行代付涉及三个角色:
- 业务系统:上层业务系统,负责发起代付请求;
- 银行 SDK:我司为每个银行通道封装的接入 SDK,例如
cmb-bank-sdk、wxpay-bank-sdk、pingan-bank-sdk; - 银行侧系统:银行提供的 RestAPI 或银行前置机。
整个请求链路并不复杂:
上层业务系统 → 银行 SDK → 银行侧系统
上层业务系统依赖具体的银行 SDK,通过 SDK 完成报文组装、签名、加密和通信,最终将交易数据上送到银行侧。
随着接入的银行越来越多,我们正在做一项系统重构:把银行代付请求抽象成一套公共能力,再由各个银行 SDK 分别实现。
听起来,这是一个很常规的接口抽象。
actually,真正开始设计以后,还是遇到了一个挺有意思的问题。
从请求对象说起
请求银行代付时,入参通常包括:
- 交易流水号;
- 付款账号;
- 收款人信息;
- 付款金额;
- 交易备注。
于是,我们先定义了一个通用的代付请求模型:
@Data
public class BankPayRequest {
private String transactionNo;
private String payerAccount;
private String payeeName;
private String payeeAccount;
private BigDecimal amount;
private String remark;
}
对于大多数银行,这些字段已经够用了。
但总有一些特殊通道,会额外要求批次号、appId 等参数。
比如招商银行要求传入批次号,那么可以利用 OOP 的继承特性,定义一个招商银行专用的请求对象:
@Data
public class CmbBankPayRequest
extends BankPayRequest {
private String batchNo;
}
公共参数放在父类,银行特有参数放在子类。
到这里,似乎没有什么问题。
接下来,我们需要抽象代付接口:
public interface BankPay {
pay(...);
payQuery(...);
}
接口包含两个核心能力:
- 发起代付;
- 查询代付结果。
现在问题来了:
pay() 方法的参数应该是什么类型?
对于平安银行,它只需要接收普通的 BankPayRequest;对于招商银行,它需要接收带有 batchNo 的 CmbBankPayRequest。
如果你来定义参数类型,你怎么做?
thinking....
使用泛型,似乎顺理成章
考虑到 CmbBankPayRequest 继承了 BankPayRequest,我们首先想到的是使用泛型:
public interface BankPay<
PayReq extends BankPayRequest> {
void pay(PayReq request);
}
对于大多数银行,直接使用公共请求类型:
public class PingAnBankPay
implements BankPay<BankPayRequest> {
@Override
public void pay(BankPayRequest request) {
// 调用平安银行代付接口
}
}
对于招商银行,则使用招商银行专属的请求类型:
public class CmbBankPay
implements BankPay<CmbBankPayRequest> {
@Override
public void pay(CmbBankPayRequest request) {
String batchNo = request.getBatchNo();
// 调用招商银行代付接口
}
}
这样一来,不同银行可以接收不同的请求类型,而且这些类型关系都能在编译期得到检查。
完整的接口是下面这样:
public interface BankPay<
PayReq extends BankPayRequest,
PayRes extends BankPayResponse,
QueryReq extends BankPayQueryRequest,
QueryRes extends BankPayQueryResponse> {
PayRes pay(PayReq request);
QueryRes payQuery(QueryReq request);
}
从技术上看,这个方案没有问题。
不同银行可以根据自己的需要,分别指定代付请求、代付响应、查询请求和查询响应的具体类型。
问题解决了,可以提交代码了?
先别急着点头~~~~
代码评审时,一个“小瑕疵”出现了
在代码评审时,有同学认为4个泛型会增加程序理解的复杂度,算是个小瑕疵。
比如,一个普通的平安银行实现类,需要写成这样:
public class PingAnBankPay implements BankPay<
BankPayRequest,
BankPayResponse,
BankPayQueryRequest,
BankPayQueryResponse> {
}
一眼看过去,确实有点长。
而且对于大多数银行来说,它们使用的都是公共请求和公共响应。四个泛型虽然准确,却不断重复着相同的信息。
那么,有没有可能去掉这些泛型?
我们经过了一些讨论和思考。
既然 CmbBankPayRequest 是 BankPayRequest 的子类,那么 BankPay 接口能不能直接使用公共基类?
public interface BankPay {
BankPayResponse pay(
BankPayRequest request);
BankPayQueryResponse payQuery(
BankPayQueryRequest request);
}
普通银行照常实现:
public class PingAnBankPay implements BankPay {
@Override
public BankPayResponse pay(
BankPayRequest request) {
return new BankPayResponse();
}
@Override
public BankPayQueryResponse payQuery(
BankPayQueryRequest request) {
return new BankPayQueryResponse();
}
}
对于招商银行,则把 pay() 的参数修改成更具体的 CmbBankPayRequest:
public class CmbBankPay implements BankPay {
public BankPayResponse pay(
CmbBankPayRequest request) {
return new BankPayResponse();
}
@Override
public BankPayQueryResponse payQuery(
BankPayQueryRequest request) {
return new BankPayQueryResponse();
}
}
看上去似乎挺合理。
接口使用公共父类,具体实现使用更加具体的子类。泛型没有了,类声明也清爽了。
如果你来评审这个改动方案,你觉得如何?
(先别急着点头~~~~)
后来,经编码验证,发现行不通。
编译器直接报错:
CmbBankPay不是抽象的,并且未覆盖BankPay中的抽象方法 pay(BankPayRequest)
what?
CmbBankPayRequest 明明继承了 BankPayRequest,为什么不能算作重写?
它不是重写,而是重载
原因其实很简单。
接口定义的方法是:
BankPayResponse pay(BankPayRequest request);
而招商银行实现类定义的方法是:
BankPayResponse pay(CmbBankPayRequest request);
虽然两个方法的名字相同,但是参数类型不同。
因此,后者并没有重写前者,而是定义了一个新的重载方法。接口中的 pay(BankPayRequest) 仍然没有被实现。
可是,Java 为什么不允许实现类把参数改成更具体的类型呢?
经 AI 查证,AI 的一个解释瞬间让我明白了原因:
如果允许实现类修改接口方法的入参类型,就会违背里氏替换原则,也就是 LSP。
我们来看一段代码。
接口 BankPay 对外承诺,它可以处理任意 BankPayRequest:
public interface BankPay {
BankPayResponse pay(
BankPayRequest request);
}
按照多态原则,我们可以使用接口类型接收招商银行的实现:
BankPay bankPay = new CmbBankPay();
既然它是一个 BankPay,调用方自然可以传入普通的 BankPayRequest:
BankPayRequest request =
new BankPayRequest();
bankPay.pay(request);
但如果 CmbBankPay 只实现了下面的方法:
pay(CmbBankPayRequest request);
它就无法处理普通的 BankPayRequest。
接口说“我可以处理所有银行代付请求”,实现类却说“我只能处理招商银行代付请求”。
这就麻烦了。
子类无法替换父类,多态自然也就不再可靠。
所以,Java 不允许重写方法缩小入参类型。实现类必须完整兑现接口作出的承诺。
这也解释了为什么泛型方案可以成立:
BankPay<CmbBankPayRequest>
它不是在实现接口时偷偷缩小参数范围,而是在定义这个具体接口类型时,就明确告诉调用方:
我处理的就是
CmbBankPayRequest。
问题又回到了四个泛型
到这里,我们已经可以得到两个结论:
第一,使用四个泛型,类型设计没有问题;
第二,去掉泛型以后,特殊银行又无法通过重写方法来接收自己的请求类型。
难道只能接受那一长串泛型参数吗?
其实,如果对 OOP 和 Java 技术掌握得不够牢,这里还是有一些技术难度的。 —— 既要理解继承和多态的边界,又要理解方法重写、方法重载以及泛型约束之间的关系。尤其是为什么实现类不能缩小接口方法的参数类型,并不是一眼就能看明白的。
但是,只要我们愿意继续思考,愿意针对现有方案做优化,办法总还是比困难多。
我们可以保留泛型接口,同时使用抽象类封装大多数银行重复的泛型声明。
首先,泛型接口保持不变:
public interface BankPay<
PayReq extends BankPayRequest,
PayRes extends BankPayResponse,
QueryReq extends BankPayQueryRequest,
QueryRes extends BankPayQueryResponse> {
PayRes pay(PayReq request);
QueryRes payQuery(QueryReq request);
}
然后,为使用公共请求和响应类型的银行定义一个抽象类:
public abstract class AbstractBankPay
implements BankPay<
BankPayRequest,
BankPayResponse,
BankPayQueryRequest,
BankPayQueryResponse> {
}
对于大多数银行,例如平安银行,只需要继承这个抽象类AbstractBankPay。类声明一下子变得简单了:
public class PingAnBankPay
extends AbstractBankPay {
}
而对于招商银行这样的特殊通道,仍然直接实现泛型接口BankPay。
就这么简单。
改造前后有什么不同
最初的完整泛型方案是:
public class PingAnBankPay implements BankPay<
BankPayRequest,
BankPayResponse,
BankPayQueryRequest,
BankPayQueryResponse> {
}
它的问题不是设计错误,而是大多数实现类都要重复声明四个公共类型。
尝试取消泛型以后,代码看起来简单了,却无法准确表达特殊银行的请求类型。若改为在方法内部进行强制类型转换,还会把编译期问题推迟到运行期。
最终的方案是加一个抽象类。这样改造以后:
- 泛型接口继续保证类型安全;
- 普通银行不再重复声明四个泛型;
- 特殊银行仍然可以表达自己的请求类型;
- 实现类不需要进行强制类型转换;
- 接口的语义没有因为追求简洁而遭到破坏;
- 大多数开发者也不必直接面对四个泛型参数。
这才是我们真正需要的“简单”。
写在最后
这次讨论源于代码评审中的一个小问题:四个泛型会不会增加程序的理解成本?
从表面上看,它只是一个接口声明有点长的问题。但继续往下思考,就会涉及方法重写、方法重载、多态、泛型以及里氏替换原则。
我们曾经尝试直接去掉泛型。
后来,经编码验证,发现行不通。
最终的解决办法也谈不上多么高深:保留准确的泛型设计,再用抽象类隐藏大多数实现不需要关心的复杂度。
这个过程带给我的思考是:
好的设计,不是简单地让代码变短,而是把必要的复杂度放到正确的位置。
银行之间确实存在请求和响应类型的差异,那么泛型就是对这种差异的准确表达。大多数银行又确实使用相同的公共类型,那么抽象类就可以帮助它们消除重复。
不能因为四个泛型看起来复杂,就丢掉类型安全;也不能因为泛型在技术上正确,就无视代码的阅读成本。
程序设计往往就是这样。
先保证抽象是正确的,再想办法让正确的抽象更容易理解、更加方便使用。
当看到一些不好的代码时,会发现我还算优秀;当看到优秀的代码时,也才意识到持续学习的重要!--buguge
本文来自博客园,转载请注明原文链接:https://www.cnblogs.com/buguge/p/22811767
浙公网安备 33010602011771号