自以为是不可取-Feign的消息解析机制撞上接口设计特殊情况导致的线上问题定位和解决(一)

/ bug / 0 条评论 / 1279浏览

自以为是不可取-Feign的消息解析机制撞上接口设计特殊情况导致的线上问题定位和解决(一)

ps:阅读这个系列的博客,请大家一定要把三篇文章阅读完整,否则会被带跑偏了(狗头保命)

一.场景回顾

首先说明下基本场景,问题发生在交易服务调用风控服务的过程中,这里交易模块这里我们暂且简称为bank,风控简称为risk,问题是这样的:risk服务的交易风控接口发生了系统异常,全局异常捕获后返回了统一的异常响应数据:

{
"code":1,
"msg":"error",
"data":null
}

bank模块收到数据后,依然照常执行,本次交易正常进行.

情况就是上面这样(当然我只是描述了整个交易流程中的简单的一小部分逻辑),就目前来看,这个调用过程肯定是有问题的,因为风控接口报错了,那么本次交易一定是不会通过的,下面是上述两个接口的详细代码(简洁处理)

bank:

@FeignClient(name="risk")
public interface RiskClient {

    @RequestMapping(value="/api/v1/risk/transaction",method= RequestMethod.POST)
    ResultDto transaction(@RequestBody InParam param);
    
}

risk:

    @PostMapping("/api/v1/risk/transaction")
    @ApiOperation(value = "交易")
    public ResultDto transaction(@RequestBody InParam param){
        return xxxService.doTransaction(param);
    }

这里的ResultDto如下(简写):

@Data
public class ResultDto implements Serializable {

    private static final long serialVersionUID = 2677701967133456961L;

    // 审批结果: 1、订单通过 5、流程拒绝
    private Integer approvalResult;

    // 是否冻结: 1:冻结;0:不冻结
    private Integer freezeCode = 0;


}

另外,bank服务判断风控是否通过的代码是下面这样的

boolean riskStop = resultDto != null && ((null != resultDto.getFreezeCode() && 1 == resultDto.getFreezeCode()) || (null != resultDto.getApprovalResult() && 5 == resultDto.getApprovalResult()));
if(riskStop){
	return "error";
}
doSuccess(...)

二.问题寻找

看了上面的基本介绍,很容易能看出来有很多的问题.我想说的是下面两点:

  1. 最明显的问题,也是最严重的问题,bank中对风控结果的判断处理是有问题的,上面的判断其实也就是说,如果风控返回的数据approvalResult(处理结果)是不通过,或者freezeCode是冻结状态,那么就返回风控拦截了,前面的那些非null判断都只是为了后面的值判断不出现空指针,并没有考虑如果风控返回的ResultDto为null,或者ResultDto中的approvalResult和freezeCode为null的情况.按正常逻辑来看,如果出现这种情况,这里应该也是返回交易失败才对.
  2. 另一个问题可能大家不会认为是一个问题,但是如果我说,我们的整个微服务系统中的服务调用使用的是固定的响应结构,并且上面我也说了risk模块报错后,全局异常返回的结构也是统一的响应体结构,但是,问题就在这里,我们这里的bank和risk模块的调用是直接使用的业务数据模型,没有封装在统一响应体的data中,这一点会让整个过程变得十分特殊.下面我会详细说明.

至于为什么这里接口调用没有用通用的响应来封装,是因为之前这个风控是直接通过jar包形式调用,所以也就是内部方法调用,我来之前代码就是直接使用的业务模型进行交互,之后要求改造为接口调用,起初我使用的是统一响应体进行封装进行改造的,按照项目中统一的接口来开发的,我的接口写好后,发现有几个地方之前有调用这个jar包,所以有几个服务也需要兼容改造下,但由于种种原因(同事觉得麻烦),没有办法,所以就直接让我这边修改下出参(不过说实话,当时我没有考虑会有这种情况,后面我会介绍,我当时坚持要使用统一响应体的原因是,既然都是走http调用,改造成http接口调用,那就应该需要遵循这个项目的微服务接口直接调用的数据交互原则),保持之前的业务出参不变,所以也就有了现在这个接口.

三.问题分析

下面就根据上面的现有代码,分析下这个问题是怎么出现的,为什么风控都挂了,响应了异常数据,但是这笔交易还是通过了.

回到前面的risk返回给bank的数据

{
"code":1,
"msg":"error",
"data":null
}

这里返回这个是正常的,因为全局异常处理返回的,所以和接口中定义的单独一个业务对象不同.

但是往下看,因为项目中服务直接调用使用的feign,这个接口feign的接收参数也就是这个单独的业务对象ResultDto,大致浏览一遍feign的消息接收源码你会发现:

首先在feign的代理类的invoke方法中的return executeAndDecode(template),这个方法之前分析feign源码的时候有看过,这里摘一小段

 if (response.status() >= 200 && response.status() < 300) {
        if (void.class == metadata.returnType()) {
          return null;
        } else {
          return decode(response);
        }
      }

这一段是处理请求成功的逻辑,可以看到最终解析响应数据会来到decode方法,这里feign默认使用的 是org.springframework.cloud.netflix.feign.support.ResponseEntityDecoder, metadata.returnType()返回的就是feign接口定义的返回参数类型.在decode方法中,会选择使用一个消息转换器来处理,这里简单看下decode方法中的一个主要处理逻辑(选择消息处理器)

public T extractData(ClientHttpResponse response) throws IOException {
		MessageBodyClientHttpResponseWrapper responseWrapper = new MessageBodyClientHttpResponseWrapper(response);
//如果响应的数据是空的,那么直接返回null,不进行后面的解析工作,这一步是重点
		if (!responseWrapper.hasMessageBody() || responseWrapper.hasEmptyMessageBody()) {
			return null;
		}
		MediaType contentType = getContentType(responseWrapper);

		for (HttpMessageConverter<?> messageConverter : this.messageConverters) {
			if (messageConverter instanceof GenericHttpMessageConverter) {
				GenericHttpMessageConverter<?> genericMessageConverter =
						(GenericHttpMessageConverter<?>) messageConverter;
				if (genericMessageConverter.canRead(this.responseType, null, contentType)) {
					if (logger.isDebugEnabled()) {
						logger.debug("Reading [" + this.responseType + "] as \"" +
								contentType + "\" using [" + messageConverter + "]");
					}
					return (T) genericMessageConverter.read(this.responseType, null, responseWrapper);
				}
			}

这里使用的是 org.springframework.http.converter.json.MappingJackson2HttpMessageConverter,之后整个读取响应数据进行解析的都是jackson中的模块,其中最重要的代码我贴在这里(不是完整的方法,这里是部分):

@Override
    public Object deserializeFromObject(JsonParser p, DeserializationContext ctxt) throws IOException{
    //这里createUsingDefault内部就是掉用feign接口中定义的返回参数对象的无参构造方法反射得到对象,所以这里bean就是ResultDto,按照前面我们写的ResultDto模型,ResultDto中的freezeCode在无参构造方法创建对象的时候使用的就是初始赋值1,所以这里ResultDto的属性并不都是null
     final Object bean = _valueInstantiator.createUsingDefault(ctxt);
        // [databind#631]: Assign current value, to be accessible by custom deserializers
        p.setCurrentValue(bean);
//下面的while循环就是将解析的接口的响应数据按照字段名填充到bean中,然后将bean返回,但是,这里解析得到的接口的响应数据没有任何字段会对应到bean中(因为全局异常响应的),所以最终的bean还是上面createUsingDefault后得到的bean对象
        if (p.hasTokenId(JsonTokenId.ID_FIELD_NAME)) {
            String propName = p.getCurrentName();
            do {
                p.nextToken();
                SettableBeanProperty prop = _beanProperties.find(propName);
                if (prop != null) { // normal case
                    try {
                        prop.deserializeAndSet(p, ctxt, bean);
                    } catch (Exception e) {
                        wrapAndThrow(e, bean, propName, ctxt);
                    }
                    continue;
                }
                handleUnknownVanilla(p, ctxt, bean, propName);
            } while ((propName = p.nextFieldName()) != null);
        }
        return bean;
    }
    
    }

所以最终bank中得到的risk的响应如下:

{
"approvalResult":null,
"freezeCode":0
}

这样的响应按照上面说的bank服务的风控结果的判断方式来说是通过的,所以就出现了虽然risk模块报错了,但是整个交易流程还是通过了的最终原因。

四.问题总结和解决方式

发生上面的问题的主要原因如下:

  1. bank模块的判断逻辑不正确 ps:其实修改了这第一个问题就可以了
  2. 接口调用的参数传递类型不一致,并且响应的数据无参构造生成的对象中的属性是否冻结默认就是不冻结。

解决办法:

bank的判断逻辑应该修改为:

boolean riskStop = resultDto == null || resultDto.getFreezeCode() == null || resultDto.getApprovalResult() == null || resultDto.getFreezeCode() == 01 || resultDto.getApprovalResult() == 5;
if(riskStop){
	return "error";
}
doSuccess(...)

这样就可以避免了

当然最好全部修改下,接口调用统一,再完善下数据构造。

五.思考

通过这个问题,我知道了feign中解析数据响应的内部逻辑,大家可能会说这是feign的一个bug,我为什么要先new一个默认的数据,其实不然,因为在上面我开始有解释一段代码,如果接口响应的数据就是null,那么feign不会处理的,直接返回null,所以也不会解析,所以我们要先遵循好最基本的,就是如果你定义好了feign的接口调用的出入参,那么就默认表示你已经确定了,这个接口的出入参调用一定是这样的,没有这个前提说啥都说不过去哦。