一个应用|系统不可能孤立存在,即使是企业内部的系统在开发过程中也免不了与三方的系统(资金、短信、聚合数据等)进行对接。我司算是 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":""
}
响应:略
复制代码
抽象+封装+继承
分析接口可知,每个接口的请求报文都存在字段 type
、mchId
所以这里可以很自然地可以联想到 继承。创建一个请求基类 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 类大致相同。
好的。此时我们已经创建了 BaseRequest
、BaseResponse
类,数据载体已经有了,现在开始写主要逻辑。我的习惯是把该类命名为 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 异常敏感度高(如 ConnectTimeoutException
、ReadTimeoutException
可能导致不同的业务结果),那这里选择包装异常就不合适。
算是编程习惯吧。包装其实也可以,但需要把不同的异常用某个字段标识出来(如:在自定义异常类增加 code 字段),这样子即使调用方有异常细区分需求也能得到满足。
6、输入输出需要有完整日志记录
这点应该是不用多说的,但是还是提一嘴。当真有业务问题和对面怼起来时,日志是强有力的武器,请用日志说话。千万不要担心日志量过大问题,什么年代了,早上 ELK 了不是吗?
7、较为完整的使用示例、单元测试
做一下单元测试吧,一方面能提高你的工单质量,还有一方面为队友考虑。水平参差不齐,队友也许并不了解你的设计,当有使用示例或单元测试时,能提高不少的工作效率。(文档也可以)
还是那句话,能写出机器看得懂的代码不难,写出大家(人)能看懂的代码才是真水平。
近期评论