buguge - Keep it simple,stupid

知识就是力量,但更重要的,是运用知识的能力why buguge?

导航

聚合系统设计:如何为银行代付通道抽象出公共的代付接口能力

我司业务涉及银行通道的代付资金请求。

从系统实现层面来看,一次银行代付涉及三个角色:

  • 业务系统:上层业务系统,负责发起代付请求;
  • 银行 SDK:我司为每个银行通道封装的接入 SDK,例如 cmb-bank-sdkwxpay-bank-sdkpingan-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;对于招商银行,它需要接收带有 batchNoCmbBankPayRequest

如果你来定义参数类型,你怎么做?

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> {
}

一眼看过去,确实有点长。

而且对于大多数银行来说,它们使用的都是公共请求和公共响应。四个泛型虽然准确,却不断重复着相同的信息。

那么,有没有可能去掉这些泛型?

我们经过了一些讨论和思考。

既然 CmbBankPayRequestBankPayRequest 的子类,那么 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> {
}

它的问题不是设计错误,而是大多数实现类都要重复声明四个公共类型。

尝试取消泛型以后,代码看起来简单了,却无法准确表达特殊银行的请求类型。若改为在方法内部进行强制类型转换,还会把编译期问题推迟到运行期。

最终的方案是加一个抽象类。这样改造以后:

  • 泛型接口继续保证类型安全;
  • 普通银行不再重复声明四个泛型;
  • 特殊银行仍然可以表达自己的请求类型;
  • 实现类不需要进行强制类型转换;
  • 接口的语义没有因为追求简洁而遭到破坏;
  • 大多数开发者也不必直接面对四个泛型参数。

这才是我们真正需要的“简单”。

写在最后

这次讨论源于代码评审中的一个小问题:四个泛型会不会增加程序的理解成本?

从表面上看,它只是一个接口声明有点长的问题。但继续往下思考,就会涉及方法重写、方法重载、多态、泛型以及里氏替换原则。

我们曾经尝试直接去掉泛型。

后来,经编码验证,发现行不通。

最终的解决办法也谈不上多么高深:保留准确的泛型设计,再用抽象类隐藏大多数实现不需要关心的复杂度。

这个过程带给我的思考是:

好的设计,不是简单地让代码变短,而是把必要的复杂度放到正确的位置。

银行之间确实存在请求和响应类型的差异,那么泛型就是对这种差异的准确表达。大多数银行又确实使用相同的公共类型,那么抽象类就可以帮助它们消除重复。

不能因为四个泛型看起来复杂,就丢掉类型安全;也不能因为泛型在技术上正确,就无视代码的阅读成本。

程序设计往往就是这样。

先保证抽象是正确的,再想办法让正确的抽象更容易理解、更加方便使用。

posted on 2026-09-07 09:38  buguge  阅读(16)  评论(0)    收藏  举报