深入理解Feign的Method has too many Body parameters异常|牛气冲天新年征文

9,375 阅读5分钟

本文的构思来自于我上上周在公司遇到的一个Feign的启动异常。

先说一个小结论:这个异常的出现是因为Feign只允许你在Feign代理类的POST方法上有一个参数。

1. 项目无法启动了?

目前我们开发组一直在使用Feign做微服务调用组件,我猜想别的很多公司应该也是一样,所以考虑到我遇到的这个Method has too many Body parameters大家可能也会遇到,而且谷歌和百度上面都没有对此详细的解释,所以便整理了一下和大家分享。

事情要从上上周说起,我领了一个服务迁移的任务,将此服务转移到新项目之后,我在新项目中引入了两个此服务需要用到的jar包,之后就发现项目无法启动,并给我抛出了一个IllegalStateException异常:

我跟着异常信息的提示看了一下,发现是其中一个jar包里的定义的Feign代理类上的某个方法出了问题,那个方法大概是这样的:

    @PostMapping(value = PLAN_PAGE)
    @ApiOperation(value = "分页查询计划列表", notes = "计划列表")
    JsonResult<Pagination<PlanInfo>> queryPlans(@RequestBody @Validated Query query,
                                                        @PageableParam
                                                        @PageableDefault(sort = "id",
                                                                direction = Sort.Direction.DESC,
                                                                size = 100) Pageable pageable);

乍一看很正常的一个方法,一个带查询条件带分页的Post方法而已。

感觉没有什么异样,随后我就用百度和谷歌搜索了这个异常,得出了两个答案:

  1. Feign中的查询参数需要带上@RequestParam注解,然而我这是一个post请求,所以这个答案PASS。
  2. Feign在同时使用@RequestBody注解和@PageableDefault注解时,需要在项目配置中加入一个注解处理器,

ok,排除了第一个答案之后,就应该是需要按照第二个答案的方式进行配置即可,为了稳妥,我还是问了一下组里的同事,请教一下相关的配置。

很快他就给了我回复,并帮我配置了一番,他的配置大概是这样的:

    public static class ConsultantPageableParamProcessor implements AnnotatedParameterProcessor {

        private static final Class<com.xingren.consultant.consultant.contract.annotation.PageableParam> ANNOTATION =
                com.xingren.consultant.consultant.contract.annotation.PageableParam.class;

        @Override
        public Class<? extends Annotation> getAnnotationType() {
            return ANNOTATION;
        }

        @Override
        public boolean processArgument(AnnotatedParameterContext context, Annotation annotation, Method method) {
            int parameterIndex = context.getParameterIndex();
            MethodMetadata data = context.getMethodMetadata();
            data.queryMapIndex(parameterIndex);
            return true;
        }
    }

定义了一个内部类,并将这个类声明成一个Bean。

通过这段代码也大概可以看出,是针对上面报错方法中的@PageableParam做了一些特殊处理。

他帮我处理了之后,告诉我可以了,我兴冲冲的拉下来代码之后run了起来,不过居然又报错了,只是报在了其他段的代码:

    @PostMapping(value = PLAN_UPDATE)
    @ApiOperation(value = "更新计划对象", notes = "更新计划对象")
    JsonResult<Pagination<PlanInfo>> queryPlans(@RequestBody @Validated PlanForm form,
                                                        @PathVariable("id") Long id);

那段代码大概是上面这样的,针对某个id的对象进行更新。

随后我又去谷歌搜索了@RequestBody@PathVariable在Feign中连用的情况,不过这次一无所获,只有一个貌似相关但是没什么用的OpenFeign`s issue

我在网上苦寻无果之后,我又去问了文曲星,但是这次他表示自己也没有碰见过这种情况,这让我有点焦急。

因为这个异常已经让我的迁移进度卡住半天了,可以说是服务迁移未半而中道崩殂,沉思一下之后我决定深入研究一下这个异常的成因,之后再寻求解决办法。

2. 深入源码DEBUG

首先我先找到了抛出异常的代码位置,根据本文最开始的那个图我们可以知道异常抛出的地方出在feign.Util.checkState(Util.java:130)

  public static void checkState(boolean expression,
                                String errorMessageTemplate,
                                Object... errorMessageArgs) {
    if (!expression) {
      throw new IllegalStateException(
          format(errorMessageTemplate, errorMessageArgs));
    }
  }

看完之后你可以发现,这里就是做了一个判断,如果表达式为expression为false,就会抛出这个异常。

得到了这个结论之后,我们只需要找到找到调用它的地方,并想办法将expression改为true就可以了。

随后我来到了调用它的地方,从本文最开始的图中我们可以知道它在feign.Contract$BaseContract.parseAndValidateMetadata(Contract.java:117),下面的代码我删减了一些无用代码同时更容易理解也让篇幅更短一点:

    protected MethodMetadata parseAndValidateMetadata(Class<?> targetType, Method method) {
      // 1️⃣
      MethodMetadata data = new MethodMetadata();
      data.returnType(Types.resolve(targetType, targetType, method.getGenericReturnType()));
      data.configKey(Feign.configKey(targetType, method));

      Class<?>[] parameterTypes = method.getParameterTypes();
      Type[] genericParameterTypes = method.getGenericParameterTypes();

      // 2️⃣
      Annotation[][] parameterAnnotations = method.getParameterAnnotations();
      int count = parameterAnnotations.length;
      for (int i = 0; i < count; i++) {
        boolean isHttpAnnotation = false;
        if (parameterAnnotations[i] != null) {
          // 3️⃣
          isHttpAnnotation = processAnnotationsOnParameter(data, parameterAnnotations[i], i);
        }
        // 4️⃣
        if (parameterTypes[i] == URI.class) {
          data.urlIndex(i);
          // 5️⃣
        } else if (!isHttpAnnotation && parameterTypes[i] != Request.Options.class) {
          checkState(data.formParams().isEmpty(),
              "Body parameters cannot be used with form parameters.");
          checkState(data.bodyIndex() == null, "Method has too many Body parameters: %s", method);
          data.bodyIndex(i);
          data.bodyType(Types.resolve(targetType, targetType, genericParameterTypes[i]));
        }
      }

      return data;
    }

通过这个方法的名字我们可以知道这是对方法在做一些校验,既然是方法校验肯定就是报错的那些方法了,在诶个对Feign代理类上面的方法进行校验的时候,某些方法没有通过校验所以抛出了异常。

我把这段代码大致分为两部分,第1️⃣部分是通过Method对象封装了一个元数据对象,这个元数据对象里面放了这个方法的参数和参数类型,以及返回值类型等等信息。

第2️⃣部分则是我们的重点,它拿到了方法上面的所有注解,并挨个循环对注解做了一些处理,我们可以很明显的看到有个方法叫做processAnnotationsOnParameter,就是这个方法是我们本篇解析的重中之重。

为了凸显它的重点,我在那行代码上面标了一个3️⃣。

好了,那我们可以大致梳理一下整个流程:

  1. Feign通过这个方法对Feign代理类上面的所有方法进行校验。
  2. 通过Method对象封装成一个元数据对象。
  3. 拿到所有注解参数注解,这里注意参数注解是一个二维数组,它代表着一个参数上面可能会有多个注解。
  4. 通过processAnnotationsOnParameter方法来判断此参数上面是否有Http注解(isHttpAnnotation)。
  5. 在4️⃣这里判断方法参数是否是URI类型的参数,这个在我们项目中没有用URI类型当参数的,所以可以直接跳到5️⃣。
  6. 这里的判断依赖前文的isHttpAnnotation,同时可以发现正是在这个判断里面会出现我们今天的主题:"Method has too many Body parameters"。

有了上面的梳理,我们应该知道了我们目前需要深入的就是processAnnotationsOnParameter了,理解了它就能找到出现这个异常的根本原因了。

3. 重要的processAnnotationsOnParameter

点进processAnnotationsOnParameter之后发现它是一个抽象方法,有两个类实现了它的抽象方法,分别是Feign自带的Contract类和Spring实现的SpringMvcContract,因为我们是SpringCloud项目所以这里应该看的是SpringMvcContract

	@Override
	protected boolean processAnnotationsOnParameter(MethodMetadata data,
			Annotation[] annotations, int paramIndex) {
		boolean isHttpAnnotation = false;

		AnnotatedParameterProcessor.AnnotatedParameterContext context = new SimpleAnnotatedParameterContext(
				data, paramIndex);
		Method method = this.processedMethods.get(data.configKey());
		for (Annotation parameterAnnotation : annotations) {
                        // 6️⃣
			AnnotatedParameterProcessor processor = this.annotatedArgumentProcessors
					.get(parameterAnnotation.annotationType());
			if (processor != null) {
				Annotation processParameterAnnotation;
				// synthesize, handling @AliasFor, while falling back to parameter name on
				// missing String #value():
				processParameterAnnotation = synthesizeWithMethodParameterNameAsFallbackValue(
						parameterAnnotation, method, paramIndex);
				isHttpAnnotation |= processor.processArgument(context,
						processParameterAnnotation, method);
			}
		}
		return isHttpAnnotation;
	}

这里的代码乍一看还挺麻烦的,不过我们在前文已经知道了这个方法主要是用来校验方法参数上的注解是否是Http注解,所以我的原则是只看和变量isHttpAnnotation相关的地方。

这里我们主要可以看6️⃣处的代码,用本类的一个成员变量Map拿到一个注解参数处理器,如果找不到则直接返回fasle。

此处如果不DEBUG真的看不出来什么东西,DEBUG之后可以发现这个Key为注解类型的annotatedArgumentProcessors-Map里面有四个元素,这里面的元素都是AnnotatedParameterProcessor的子类:

  1. PathVariableParameterProcessor
  2. RequestHeaderParameterProcessor
  3. RequestParamParameterProcessor
  4. QueryMapParameterProcessor

看这名字就应该明白了吧,这四个类对应了Spring中的四个注解,当我们的参数上的注解不是这四个之一的时候会直接返回false。

如果是这四个注解之一,你可以直接认为它会返回true。

这个时候你就会发现它这个Map里面没有我们常用的@RequestBody,也就是说如果你的方法参数带上了@RequestBody那这里会直接返回false。

通过上文5⃣️处的代码,我们可以知道如果isHttpAnnotation = true,那它绝对不会抛出Method has too many Body parameters,那这里我们就要回过头来看一下isHttpAnnotation = false的时候到底会发生什么?

  1. 我们来看5⃣️处的判断,因为我们基本不用Request.Options.class,所以当isHttpAnnotation = false你可以基本认为它会进入这个else里面。
  2. 接下来直接看方法体的第二个checkState,因为第一个没有抛出异常,我们可以认为它是ok的,在第二个checkState里面它判断了bodyIndex是否为null,通过代码来看bodyIndex是元数据的一个变量,这里判断的结果如果是false则放入checkState后会直接抛出异常。
  3. 接下来看下一步,如果通过了上面的两个校验则会把bodyIndex设置一个值。。。

看到这里,我当时瞬间明白了整个校验流程:

  1. Feign对Feign代理类的所有方法参数进行依次校验。
  2. 如果参数中含有Http注解则放行。
  3. 如果参数中不含有Http注解则往bodyIndex设置一个值。
  4. 如果第一个参数是一个非Http注解,那么会往bodyIndex上设置一个值,如果第二个参数也是一个非Http注解,那么它在走到第二个checkState时会抛出异常,因为这个时候bodyIndex已经有值了,bodyIndex == null的判断会为false。

想通了这点后,我理解了项目中经常出现的那个异常,也就是当在Feign中同时使用@RequestBody注解和@PageableDefault注解时,会抛出异常。

因为在Feign的判断中这两个注解都是非Http注解,Feign只能允许每个方法上有一个参数是非Http注解,不能两个都是。

但是想到这里我又奇怪了,为啥我后来的@RequestBody@PathVariable连用也会抛出异常?按照这里的规则它应该是好的。

带上疑问我又DEBUG了进去,到了出错的那个代理类之后发现annotatedArgumentProcessors-Map里面的四个类变成了两个!!!:

  1. RequestHeaderParameterProcessor
  2. RequestParamParameterProcessor

也就是说@PathVariable没有被正确识别,所以它也被识别成了非Http注解。

4. 两个解决方案

至于为什么会少了两个,我没有深究,我只是知道想解决这个问题大概可以有两种办法:

  1. 重写processAnnotationsOnParameter方法,让它无论发生什么事都返回一个true。
  2. 重新注入SpringMvcContract,并自定义它的annotatedArgumentProcessors-Map,将里面的注解处理类补全即可。

这里贴一下第二种方法的代码:

    @Resource
    private ConversionService feignConversionService;

    public static class RequestBodyParameterProcessor implements AnnotatedParameterProcessor {

        private static final Class<RequestBody> ANNOTATION =
                RequestBody.class;

        @Override
        public Class<? extends Annotation> getAnnotationType() {
            return ANNOTATION;
        }

        @Override
        public boolean processArgument(AnnotatedParameterContext context, Annotation annotation, Method method) {
            int parameterIndex = context.getParameterIndex();
            MethodMetadata data = context.getMethodMetadata();
            data.queryMapIndex(parameterIndex);
            return true;
        }
    }

    @Bean
    @Primary
    @ConditionalOnMissingBean
    public Contract feignContract() {
        // 用于解决偶发性Feign调用异常
        // 方法:将AnnotatedParameterProcessor下所有子类加入list,重写SpringMvcContract
        List<AnnotatedParameterProcessor> parameterProcessors = Lists.newArrayList();
        parameterProcessors.add(new PathVariableParameterProcessor());
        parameterProcessors.add(new RequestHeaderParameterProcessor());
        parameterProcessors.add(new QueryMapParameterProcessor());
        parameterProcessors.add(new RequestParamParameterProcessor());
        parameterProcessors.add(new RequestBodyParameterProcessor());

        return new SpringMvcContract(parameterProcessors, feignConversionService);
    }

Map中我手动放入了原来的四个注解处理器,然后又手动定义了一个RequestBody的注解处理器,这样处理之后就能保证我们的Feign代理类方法可以带一个@RequestBody参数和一个额外参数,同时保证annotatedArgumentProcessors-Map中不会莫名其妙消失几个注解处理器。

同时如果有新的项目,可以在分页参数上不再加@PageableParam,原来的项目中都是定义了一个@PageableParam再定义了一个它的注解处理器用于返回一个true来跳过验证,现在不用了~(这里不太明白的话,再看一下开头部分我同事的解决方式)

后来我又想到了那个Method has too many Body parameters,翻译一下是:方法上有太多的body参数,在网上也看到有人说Feign推荐POST方法上只有一个参数,原来这一切都是Feign的故意为之。


2021-02-28更新

经过我的项目实战,发现第二个方法不可行,这样配置会使Feign认为你带@RequestBody参数的方法是GET类型方法,这会导致它将你的@RequestBody参数全部放到请求URL后面(也就是像GET请求那样拼接参数),所以我还是贴一下第一种方法的解决方式:

    @Bean
    @Primary
    @ConditionalOnMissingBean
    public Contract feignContract() {
        return new FeignContract();
    }

    public static class FeignContract extends SpringMvcContract {
        @Override
        protected boolean processAnnotationsOnParameter(MethodMetadata data, Annotation[] annotations, int paramIndex) {
            return true;
        }
    }

本文就到这里了,有疑问的话可以留言一起讨论~