几个外部接口封装的较佳实践

一个应用|系统不可能孤立存在,即使是企业内部的系统在开发过程中也免不了与三方的系统(资金、短信、聚合数据等)进行对接。我司算是 n 方支付系统,经常需要跟银行、企业(支付宝、微信、中农工建招等)进行对接,在趟了无数坑之后,就有了下面几点自认为比较实用的编码经验,与大家分享和交流。

1、使用泛型+设计模式进行类组织

一般的系统都不可能只存在一个业务,不过对外提供的业务路由方式也就那么几种,URL 路由 或者是 commandCode 路由。URL 路由很典型代表是微信支付,而对手支付宝则采用 commandCode 路由方式。这两家都是行业巨头,由此可见,两种方式并没有孰高孰低,这是不同的实践而已,所以如果换成我们来设计一个系统时,不必过分纠结在此,两者都可做成超大型应用。不过个人比较倾向支付宝的做法。

好的,扯远了,回到本文。现在假设存在以下使用 commandCode 路由的外部应用(后边讲 URL 路由模式),以此为例展开下面讨论:
地址:https://127.0.0.1:1234/openapi/json,接口列表如下:

- **查询(query)**
请求:
{
    "type":"query",
    "mchId":"",
    "outTradeNo":""
}
响应:略

- **支付(pay)**
请求:
{
    "type":"pay",
    "mchId":"",
    "outTradeNo":"",
    "amount":"",
    "userId":""
}
响应:略

- **退款(refund)**
请求:
{
    "type":"refund",
    "mchId":"",
    "oriOutTradeNo":"",
    "amount":""
}
响应:略
复制代码

抽象+封装+继承

分析接口可知,每个接口的请求报文都存在字段 typemchId 所以这里可以很自然地可以联想到 继承。创建一个请求基类 BaseRequest,将公用的字段移到该类下。但不同的业务类型具有不同的 type,如何做到返回不同的值。这里可以采用 抽象基类,将 getType 方法定义为 abstract,具体的逻辑交给继承的子类去实现即可,当真实发生调用时,使用的是复写的方法。下面是一个示例:

public abstract class BaseRequest {
    private String type;
    private String mchId;
    public abstract String getType();
    // 省略剩下的 get、set
}

public class QueryRequest extends BaseRequest {
    private String outTradeNo;
    @Override
    public String getType() {
        return "query";
    }
}
// 省略 PayRequest、RefundRequest 类定义
复制代码

对于响应,由于篇幅,不展开说,做法与 Request 类大致相同。

好的。此时我们已经创建了 BaseRequestBaseResponse 类,数据载体已经有了,现在开始写主要逻辑。我的习惯是把该类命名为 xxxClient(为什么不定义成 xxxManager?后面展开讲)。秉承 面向抽象编程 原则,形参和返回值自然需要使用基类,达到统一调用的目的:

public class xxxClient {
    public BaseResponse execute(BaseRequest request) {
        // 1、签名(如果需要)
        // 2、RequestBean 转 JSON
        // 3、发送请求
        // 4、验签(如果需要)
        // 5、JSON 转 ResponBean
    }
}
复制代码

注释里大致将该方法需要做的事情列了一下,这里省略签名验签的逻辑,每个系统对签名的要求也不一样。重要的是转换这两个步骤。请求时,需要将 RequestBean 转换成对接应用所需要的格式;而响应时,则需要将返回内容转换成内部 ResponBean 示例。

根据上面接口要求格式为 json(后边讲 XML),而且还没有存在嵌套,可以说是非常简单,直接使用现成的 json 库(fastjson、jackson、gson 等)即可。以 fastjson 为例,当需要将 RequestBean 转成 json 字符串时,调用 JSON.toJSONString() 方法即可,如果需要忽略字段或者是字段改名,使用其提供的注解也能很好的完成工作,此处不展开讲。

当需要将返回的报文转为 ResponBean 时,存在一个问题,这里如何知道需要转成哪个 ResponBean?当然可以根据 getType 方法拿到当前的命令码,再根据命令码去找与对应 ResponBean 的 Class 类型。伪代码如下:

private Class<? extends BaseResponse> getTypeByCommandCode(String code) {
    if ("query".equals(code)) {
        return QueryResponse.class;
    }
    else if ("pay".equals(code)) {
        return PayResponse.class;
    }
    // ....
}
复制代码

可以这么实现,但这一大堆的 if else 有点不够优雅不是吗?好的代码应该遵循 开闭原则(open-close),对扩展开放,对修改关闭。

不难发现,ResponseBean 是根据 type 来的,而 type 对于每个 RequestBean 又是唯一的。这样子的话,我们可以像处理 type 值一样来处理 ResponseBean 的 class。同样在 BaseRequest 中定义一个 abstract 方法,用于返回 ResponseBean 的 class,如此就可以省去 if else 的判断。代码如下:

public abstract class BaseRequest {
    private String type;
    private String mchId;
    public abstract String getType();
    // 获取对应 ResponseBean 的 Class
    public abstract Class<? extends BaseResponse> getResponseType();
    // 省略剩下的 get、set
}

public class QueryRequest extends BaseRequest {
    private String outTradeNo;

    // 返回 QueryResponse 的 class
    @Override
    public Class<? extends BaseResponse> getResponseType() {
        return QueryResponse.class;
    }

    @Override
    public String getType() {
        return "query";
    }
}
复制代码

通过 泛型限定getResponseType 方法的返回值范围固定为 BaseResponse 子类的 Class,代码也变得更鲁棒。在 xxxClient 中调用时,就可以这样子:

public class xxxClient {
    public BaseResponse execute(BaseRequest request) {
        // ...省略前面的步骤
        String response = "假设这是三方系统返回内容";
        BaseResponse responseBean = JSON.parseObject(response, request.getResponseType());
        return responseBean;
    }
}
复制代码

加入泛型

代码写到这里,已经做到了统一调用,且遵循开闭原则。客户端 new 不同的 RequestBean,调用 execute 方法,得到返回值 BaseResponse 基类的实例,再通过 强转 得到具体的子类响应示例,进行相应的业务逻辑。如查询的调用方代码大致如下:

public static void main(String[] args) {
    QueryRequest request = new QueryRequest();
    QueryResponse response = (QueryResponse)new xxxClient().execute(request);
    // 做其他的业务逻辑
}
复制代码

你可能会问,强转这方式有点牵强,对调用方不友好,能有更优雅的方式吗?当然是有的,这时候就需要用到 泛型

仔细观察后发现,一个 RequestBean 对应唯一个 ResponseBean, 两者一一对应,所以我们可以引入泛型,用 T 来代替泛型限定符 ?,将其直接定义在类级别上面,而不是方法上面,在定义 RequestBean 的同时确定 BaseResponse 的类型。如下代码所示:

public abstract class BaseRequest<T extends BaseResponse> {
    private String type;
    private String mchId;
    public abstract String getType();
    // 获取对应 ResponseBean 的 Class
    public abstract Class<T> getResponseType();
    // 省略剩下的 get、set
}
复制代码

这时候 QueryRequest 类定义就需要改为这样子:

public class QueryRequest extends BaseRequest<QueryResponse> {
    private String outTradeNo;

    // 返回 QueryResponse 的 class
    @Override
    public Class<QueryResponse> getResponseType() {
        return QueryResponse.class;
    }

    @Override
    public String getType() {
        return "query";
    }
}
复制代码

改完之后会发现 RequestBean 和 ResponseBean 之间联系更加紧密,后续的扩展更加友好。当然这个铺垫并不是为了联系紧密而设的,重要的还是在 xxxClient 这个类,看看如何改造:

public class xxxClient {
    public <T extends BaseResponse> T execute(BaseRequest<T> request) {
        // ...省略前面的步骤
        String response = "假设这是三方系统返回内容";
        T responseBean = JSON.parseObject(response, request.getResponseType());
        return responseBean;
    }
}
复制代码

因为使用 T 来代替了具体的 ResponseBean 子类的类型,所以可以直接将 execute 方法返回值改为 T,同时使用泛型限定符,而相应的 BaseRequest 类上需要带上 T 类型。这时候,调用方代码就无需再做强转这个动作,非常的合理和优雅:

public static void main(String[] args) {
    QueryRequest request = new QueryRequest();
    QueryResponse response = new xxxClient().execute(request);
    // 做其他的业务逻辑
}
复制代码

到这里,该模块的类组织已经基本都定型了,如果需要改造或完善基于这个架子也是非常简便的,比如下面两种情况:

如果是 XML

相比较于 JSON 无非就是另一种形式的展现。只是换一个类库罢了,常用的有 Xstream、dom4j 等等。这里想讲的并不是这个,如果对接应用支持多种格式,此时的代码应该如何组织?

类组织大致还是不变的,变得只是 Bean 转换这一块。所以何不把转换这个动作拎出去也由客户端进行指定。定义转换接口如下:

public interface Converter {
    <A extends BaseRequest<?>> String bean2String(A request);
    <B extends BaseResponse> B string2Bean(String responStr, Class<B> clazz);
}
复制代码

将 Converter 作为 xxxClient#execute 方法的参数传入

public <T extends BaseResponse> T execute(BaseRequest<T> request, Converter converter) {
    String requestStr = converter.bean2String(request);
    String response = "假设这是三方系统返回内容";
    T responseBean = converter.string2Bean(response, request.getResponseType());
    return responseBean;
}
复制代码

对转换这个动作组做了一次抽象,当需要进行格式扩展时,不需要改动 xxxClient#execute 业务逻辑代码,只需要新增一个 Converter 即可,还是开闭原则。

public class XmlConverter implements Converter {
    @Override
    public <A extends BaseRequest<?>> String bean2String(A request) {
        // 这里省略 XmlUtils 具体实现...
        return XmlUtils.toString(request);
    }
    @Override
    public <B extends BaseResponse> B string2Bean(String responStr, Class<B> clazz) {
        // 这里省略 XmlUtils 具体实现...
        return XmlUtils.toBean(responStr, clazz);
    }
}
复制代码

如果是 URL 路由

一开始我们讲到了两种路由形式,前面都是讲使用 commandCode 进行路由,那 URL 路由呢?

有了这个架子以后,这已经是再简单不过的事情了。稍微解读下就是每个业务的 URL 不一样,那我们按照处理 type、BeanResponse class 方式再处理就好了。

public abstract class BaseRequest<T extends BaseResponse> {
    private String type;
    private String mchId;
    private String url;
    // 不同业务具有不同的请求地址
    public abstract String getUrl();
    public abstract String getType();
    // 获取对应 ResponseBean 的 Class
    public abstract Class<T> getResponseType();
    // 省略剩下的 get、set
}

public class QueryRequest extends BaseRequest<QueryResponse> {
    private String outTradeNo;
    @Override
    public String getUrl() {
        return "https://127.0.0.1:1234/openapi/query";
    }
    @Override
    public Class<QueryResponse> getResponseType() {
        return QueryResponse.class;
    }
    @Override
    public String getType() {
        return "query";
    }
}
复制代码

再改造一下 xxxClient 获取 url 的逻辑即可,大致如下:

public <T extends BaseResponse> T execute(BaseRequest<T> request, Converter converter) {
    String requestStr = converter.bean2String(request);
    
    HttpUtils.doPost(request.getUrl(), requestStr, 5, 15);

    String response = "假设这是三方系统返回内容";
    return converter.string2Bean(response, request.getResponseType());
}
复制代码

当然这里只是举这么两个例子。现实的业务会比这个更加复杂,比如签名方式存在多种啊,一个请求对应多种响应啊等等,这些都可以基于这个架子进行扩展延伸。

2、尽量减少对外部的依赖

在我观念里,各个模块的界限已经清晰可见,当你在写与三方系统对接模块时,就不应该依赖持久化层、亦或者是 web 层的类,这也符合 单一职责原则

Spring 的依赖处理

首先就是不能把 Spring 相关的类依赖进来,一旦依赖进来,就会被污染,如果需要将模块移植到不用 Spring 的项目里,该如何处理?是否托管给 Spring 这个动作应该交于上层也就是调用方来决定,按我的设想,此时还会存在一个 xxxManager 类处理与 Spring 的衔接动作,在此 Manager 中亦可以做限流、熔断等处理。

提供的 SDK 依赖处理

还有一些对接应用方会“自作主张”地建议使用提供的 SDK(以 jar 形式)!千万要注意! 能不用就尽量不用。如果你选择相信他们,那么后期的 jar 管理(上传私服)、功能拓展会令你抓狂。举个简单的例子,假设你的项目里将配置文件全部存放在数据库中,可以达到动态刷新的功能,而大多数对接应用提供的 SDK 都会以文件形式存放配置,此时建议选择改 SDK,或者自己重新进行封装。

如果你真的是改不动人家的 SDK,只能妥协,请使用 源码依赖 的方式,而不是 Maven 形式依赖。就是将人家的 SDK 进行反编译后得到 .java 文件,然后拷贝到自己的应用源码目录中。

当然你又会面临一个令人头疼的局面,提供的 .jar 文件里边有上百个类,一个一个反编译岂不是...好在还有这样子的工具 jd-gui,它能支持打开 .jar 同时 save all sources。

3、配置型变量参数化处理

配置型变量,类似于 请求 url、网络请求超时时间,字符编码 等等,这些要对外暴露参数。假设对方应用存在多个环境,一旦在代码里面写死,那程序的灵活性就大大降低了。还有超时时间,这个是应该是由业务决定的,比如查询超时时间、交易超时时间应该是不同的。所以这里建议做成参数形式,由调用者来决定。

4、包装返回值

假设有这种情况,在发送三方系统前签名失败了,该怎么做返回让调用方感知这个错误?确实比较麻烦,不可能保证进入这个方法就能得到三方系统的响应,即使发送过去了,响应为空也是有可能的。针对这些情况我们可以定义一个包装类,如下:

public class ManagerResultWrapper<T> {
    /** 提示信息 */
    private String info;
    /** 成功标识 */
    private boolean success;
    /** 返回的错误码 */
    private String code;
    /** 需要返回的数据 */
    private T data;

    private ManagerResultWrapper(String info, boolean success, String code, T data) {
        this.info = info;
        this.success = success;
        this.code = code;
        this.data = data;
    }

    public static <T> ManagerResultWrapper<T> success(T data) {
        return new ManagerResultWrapper<>("", true, "", data);
    }

    public static <T> ManagerResultWrapper<T> fail(String code, String info) {
        return new ManagerResultWrapper<>(info, false, code, null);
    }
    // 省略 get、set
}
复制代码

可以规定 success 字段用于标识是否成功地发送到银行,code 和 info 字段存放失败时的信息,泛型 T 存放银行响应的实体(上文中定义的 BaseResponse)。具体的返回规则视实际情况自定义即可。

5、异常处理

个人非常不喜欢用异常来进行控制逻辑,但三方系统交互势必涉及到网络 IO,这块怎么都逃不了,必须进行处理。我的习惯是有 IO 异常的地方不捕获,让调用者知道此时有 IO 交互,同时此时也不建议对 IO 异常进行包装和返回,若此时刚好外界对 IO 异常敏感度高(如 ConnectTimeoutExceptionReadTimeoutException 可能导致不同的业务结果),那这里选择包装异常就不合适。

算是编程习惯吧。包装其实也可以,但需要把不同的异常用某个字段标识出来(如:在自定义异常类增加 code 字段),这样子即使调用方有异常细区分需求也能得到满足。

6、输入输出需要有完整日志记录

这点应该是不用多说的,但是还是提一嘴。当真有业务问题和对面怼起来时,日志是强有力的武器,请用日志说话。千万不要担心日志量过大问题,什么年代了,早上 ELK 了不是吗?

7、较为完整的使用示例、单元测试

做一下单元测试吧,一方面能提高你的工单质量,还有一方面为队友考虑。水平参差不齐,队友也许并不了解你的设计,当有使用示例或单元测试时,能提高不少的工作效率。(文档也可以)

还是那句话,能写出机器看得懂的代码不难,写出大家(人)能看懂的代码才是真水平。