Contents

Feign multipart form data support

背景:使用feign传文件给下游服务。

问题1

Current request is not a multipart request

这个异常分析一下,是下游service的spring MVC抛出的。所以理解应该是,feign在调用下游服务时候,没有正确的设置Request headers。

因此需要我们了解feign是怎么调用下游service 以及Request headers怎么设置的。

1、通过IDE debug call stack找到feign顶层调用

spring-cloud-openfeign-core-2.0.0.RELEASE-sources.jar!/org/springframework/cloud/openfeign/FeignClientFactoryBean.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Override
public Object getObject() throws Exception {
	FeignContext context = applicationContext.getBean(FeignContext.class);
	Feign.Builder builder = feign(context);

	if (!StringUtils.hasText(this.url)) {
		String url;
		if (!this.name.startsWith("http")) {
			url = "http://" + this.name;
		}
		else {
			url = this.name;
		}
		url += cleanPath();
		return loadBalance(builder, context, new HardCodedTarget<>(this.type,
				this.name, url));
	}
	if (StringUtils.hasText(this.url) && !this.url.startsWith("http")) {
		this.url = "http://" + this.url;
	}
	String url = this.url + cleanPath();
	Client client = getOptional(context, Client.class);
	if (client != null) {
		if (client instanceof LoadBalancerFeignClient) {
			// not lod balancing because we have a url,
			// but ribbon is on the classpath, so unwrap
			client = ((LoadBalancerFeignClient)client).getDelegate();
		}
		builder.client(client);
	}
	Targeter targeter = get(context, Targeter.class);
	return targeter.target(this, builder, context, new HardCodedTarget<>(
			this.type, this.name, url));
}

进而推算出feign的初始化:

@EnableFeignClients里@Import(FeignClientsRegistrar.class) ->

org/springframework/cloud/openfeign/FeignClientsRegistrar.java#registerBeanDefinitions#registerFeignClients ->

org/springframework/cloud/openfeign/FeignClientFactoryBean.java#getObject ->

feign-core-9.5.1-sources.jar!/feign/Feign.java#target ->

feign-core-9.5.1-sources.jar!/feign/ReflectiveFeign.java#newInstance

其中FeignClientFactoryBean.java#getObject会调用FeignClientsConfiguration.java注入的默认配置如builder/encoder/decoder

org/springframework/cloud/openfeign/FeignClientsConfiguration.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Bean
@Scope("prototype")
@ConditionalOnMissingBean
public Feign.Builder feignBuilder(Retryer retryer) {
	return Feign.builder().retryer(retryer);
}
@Bean
@ConditionalOnMissingBean
public Encoder feignEncoder() {
	return new SpringEncoder(this.messageConverters);
}

2、初始化feign client

feign-core-9.5.1-sources.jar!/feign/ReflectiveFeign.java#newInstance

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public <T> T newInstance(Target<T> target) {
    Map<String, MethodHandler> nameToHandler = targetToHandlersByName.apply(target);
    Map<Method, MethodHandler> methodToHandler = new LinkedHashMap<Method, MethodHandler>();
    List<DefaultMethodHandler> defaultMethodHandlers = new LinkedList<DefaultMethodHandler>();

    for (Method method : target.type().getMethods()) {
      if (method.getDeclaringClass() == Object.class) {
        continue;
      } else if(Util.isDefault(method)) {
        DefaultMethodHandler handler = new DefaultMethodHandler(method);
        defaultMethodHandlers.add(handler);
        methodToHandler.put(method, handler);
      } else {
        methodToHandler.put(method, nameToHandler.get(Feign.configKey(target.type(), method)));
      }
    }
    InvocationHandler handler = factory.create(target, methodToHandler);
    T proxy = (T) Proxy.newProxyInstance(target.type().getClassLoader(), new Class<?>[]{target.type()}, handler);

    for(DefaultMethodHandler defaultMethodHandler : defaultMethodHandlers) {
      defaultMethodHandler.bindTo(proxy);
    }
    return proxy;
  }

这段代码 就是通过reflection获取@FeignClient修饰方法的注解,进而初始化client。所以,我们刚刚遇到的Current request is not a multipart request问题,在这里通过debug发现 生成的nameToHandler里,metadata里 template是没有header配置的,所以在下游调用的时候,并不会加上multipart/form-data这个header。

所以我们怎么通过配置来告诉feign加上这个header呢。继续看源码: feign-core-9.5.1-sources.jar!/feign/ReflectiveFeign.java#ParseHandlersByName里会调用contract.parseAndValidatateMetadata(key.type())获取方法的metadata。

contract调用链是: feign/Contract.java -> org/springframework/cloud/openfeign/support/SpringMvcContract.java

进一步发现org/springframework/cloud/openfeign/support/SpringMvcContract.java#processAnnotationOnMethod

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
protected void processAnnotationOnMethod(MethodMetadata data,
			Annotation methodAnnotation, Method method) {
		if (!RequestMapping.class.isInstance(methodAnnotation) && !methodAnnotation
				.annotationType().isAnnotationPresent(RequestMapping.class)) {
			return;
		}

		RequestMapping methodMapping = findMergedAnnotation(method, RequestMapping.class);
		// HTTP Method
		RequestMethod[] methods = methodMapping.method();
		if (methods.length == 0) {
			methods = new RequestMethod[] { RequestMethod.GET };
		}
		checkOne(method, methods, "method");
		data.template().method(methods[0].name());

		// path
		checkAtMostOne(method, methodMapping.value(), "value");
		if (methodMapping.value().length > 0) {
			String pathValue = emptyToNull(methodMapping.value()[0]);
			if (pathValue != null) {
				pathValue = resolve(pathValue);
				// Append path from @RequestMapping if value is present on method
				if (!pathValue.startsWith("/")
						&& !data.template().toString().endsWith("/")) {
					pathValue = "/" + pathValue;
				}
				data.template().append(pathValue);
			}
		}

		// produces
		parseProduces(data, method, methodMapping);

		// consumes
		parseConsumes(data, method, methodMapping);

		// headers
		parseHeaders(data, method, methodMapping);

		data.indexToExpander(new LinkedHashMap<Integer, Param.Expander>());
	}

它会反射解析method,进行配置。parseConsumes就是根据requestMapping的consumer来进行restTempalte request headers配置。

3、好了到现在知道怎么配置的了,那我们在确认下,feign在call时候 是否用到了这些配置呢。

通过debug进入,feign/ReflectiveFeign.java#FeignInvocationHandler#invoke -> feign/SynchronousMethodHandler.java#invoke

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public Object invoke(Object[] argv) throws Throwable {
    RequestTemplate template = buildTemplateFromArgs.create(argv);
    Retryer retryer = this.retryer.clone();
    while (true) {
      try {
        return executeAndDecode(template);
      } catch (RetryableException e) {
        retryer.continueOrPropagate(e);
        if (logLevel != Logger.Level.NONE) {
          logger.logRetry(metadata.configKey(), logLevel);
        }
        continue;
      }
    }
  }

而这时的SynchronousMethodHandler.java已经buildTemplateFromArgs已经在上面的ReflectiveFeign.java#newInstance创建好了

InvocationHandler handler = factory.create(target, methodToHandler);

所以client发出的request一定是 我们刚刚初始化配置的那个handler里对应的request。

因此,feign在初始化的时候,会把client的相关配置都配置好并缓存起来。如encoder和请求时的handler。

所以,要解决Current request is not a multipart request,需要在@FeignClient声明的方法上@RequestMapping里加入consumer=MediaType.MULTIPART_FORM_DATA_VALUE,显式地告诉feign给Request headers加上multipart/form-data。

加上后,接着会出现第二个问题。

问题2

the request was rejected because no multipart boundary was found

这个问题是feign在掉下游服务时候,request header Content-Type里没有加boundary。关于boundary,它主要起到分隔request body。

所以初步断定是,feign methodHandler 没有正确的处理。继续看代码。 feign/ReflectiveFeign.java#newInstance 如下

1
Map<String, MethodHandler> nameToHandler = targetToHandlersByName.apply(target);

进而调用的是 feign/ReflectiveFeign.java#ParseHandlersByName#apply 方法 如下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public Map<String, MethodHandler> apply(Target key) {
  List<MethodMetadata> metadata = contract.parseAndValidatateMetadata(key.type());
  Map<String, MethodHandler> result = new LinkedHashMap<String, MethodHandler>();
  for (MethodMetadata md : metadata) {
    BuildTemplateByResolvingArgs buildTemplate;
    if (!md.formParams().isEmpty() && md.template().bodyTemplate() == null) {
      buildTemplate = new BuildFormEncodedTemplateFromArgs(md, encoder);
    } else if (md.bodyIndex() != null) {
      buildTemplate = new BuildEncodedTemplateFromArgs(md, encoder);
    } else {
      buildTemplate = new BuildTemplateByResolvingArgs(md);
    }
    result.put(md.configKey(),
               factory.create(key, md, buildTemplate, options, decoder, errorDecoder));
  }
  return result;
}

可以看出通过contract获取metadata,进而构造methodHandler。

继续debug到 feign/Contract.java#BaseContract#parseAndValidatateMetadata#parseAndValidateMetadata

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
protected MethodMetadata parseAndValidateMetadata(Class<?> targetType, Method method) {
  ....
  Annotation[][] parameterAnnotations = method.getParameterAnnotations();
  int count = parameterAnnotations.length;
  for (int i = 0; i < count; i++) {
    boolean isHttpAnnotation = false;
    if (parameterAnnotations[i] != null) {
      isHttpAnnotation = processAnnotationsOnParameter(data, parameterAnnotations[i], i);
    }
    if (parameterTypes[i] == URI.class) {
      data.urlIndex(i);
    } else if (!isHttpAnnotation) {
      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]));
    }
  }
  ...
}

看出feign是通过反射获取methodParam的注解,通过!isHttpAnnotation来设置metadata。而feign判断isHttpAnnotation的依据是在 SpringMvcContract.java#processAnnotationsOnParameter

AnnotatedParameterProcessor processor = this.annotatedArgumentProcessors.get(parameterAnnotation.annotationType())

annotatedArgumentProcessors 只有三种requestParam requestHeader pathVariable。所以,我们使用requestParam告诉了feign这是一个httpAnnotation,进而不会设置metadata的body与formparam。所以构造的是没有encoder的template。进而在不会创建boundary。

所以,修改@RequestParam 为 @RequestPart。这样在构造metadata时准确的获取到了bodyType。进而注入methodHandler的encoder是org/springframework/cloud/openfeign/support/SpringEncoder.java。

问题3

上面@RequestPart解决了给methodHandler加入了encoder,但是由于SpringEncoder无法正确转换Request Body。所以抛出了异常

feign.codec.EncodeException: Could not write request: no suitable HttpMessageConverter found for request type [org.springframework.web.multipart.support.StandardMultipartHttpServletRequest$StandardMultipartFile] and content type [multipart/form-data]

这时候,才要用到OpenFeign官方form支持。加入

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@Configuration
public class FeignClientConfiguration {
  private final ObjectFactory<HttpMessageConverters> messageConverters;

  public FeignClientConfiguration(ObjectFactory<HttpMessageConverters> messageConverters) {
    this.messageConverters = messageConverters;
  }

  @Bean
  public Encoder feignFormEncoder() {
    return new SpringFormEncoder(new SpringEncoder(messageConverters));
  }
}

增加form encoder,才能最终完全解决这个问题。