这里先给出正确的配置:

不需要额外新增配置编码器 Encoder(网上大部分会让配置一个SpringFormEncoder ,会有隐患问题,下面会详细说明),spring 默认的 FeignClientsConfiguration 中的  PageableSpringEncoder 已经支持文件上传了。

public interface UserService {
    @PostMapping(value = "/user/upload", headers = "content-type=" + MediaType.MULTIPART_FORM_DATA_VALUE)
    String uploadPic(@RequestPart("file") MultipartFile file);
}

场景重现

初始配置

开发框架使用spring cloud 微服务体系,微服务之间调用使用的是feign接口调用,由于前期的一个需求有同事需要使用feign接口实现文件上传,当时可能是因为时间比较急,就在网上沾了一块配置放在项目中,大致代码如下:

@Configuration
public class MultipartConfig {
    @Bean
    public Encoder encoder(){
        return new SpringFormEncoder();
    }
}

@FeignClient(name = "xxx-provider")
public interface UserService {
    @PostMapping(value = "/user/upload",
        produces = MediaType.APPLICATION_JSON_UTF8_VALUE,
        consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    String uploadPic(@RequestPart("file") MultipartFile file);
}

重启项目发现问题解决了,可以实现文件上传了(以上代码存在很大问题,为后期埋雷)。

初步暴露问题

按照上面配置,在后期开发中引入了了新的feign接口微服务,在进行post请求,@requestbody传参时候,发现一直报错:xxx is not a type supported by this encoder.  get请求没有问题

编码器:feign接口本地透明调用需要把java对象进行编码序列化进行http网络传输,所以需要编码器。反之需要解码器

一阶段排查问题

因为考虑到引入了 SpringFormEncoder 这个编码器,于是就从这个类入手查找问题

查看该类的核心源码

public void encode (Object object, Type bodyType, RequestTemplate template) throws EncodeException {
    if (bodyType.equals(MultipartFile[].class)) {
      val files = (MultipartFile[]) object;
      val data = new HashMap<String, Object>(files.length, 1.F);
      for (val file : files) {
        data.put(file.getName(), file);
      }
      super.encode(data, MAP_STRING_WILDCARD, template);
    } else if (bodyType.equals(MultipartFile.class)) {
      val file = (MultipartFile) object;
      val data = singletonMap(file.getName(), object);
      super.encode(data, MAP_STRING_WILDCARD, template);
    } else if (isMultipartFileCollection(object)) {
      val iterable = (Iterable<?>) object;
      val data = new HashMap<String, Object>();
      for (val item : iterable) {
        val file = (MultipartFile) item;
        data.put(file.getName(), file);
      }
      super.encode(data, MAP_STRING_WILDCARD, template);
    } else {
      super.encode(object, bodyType, template);
    }
  }

通过 encode 方法可以看出,如果不是 Multipart 相关操作,直接走父类的 FormEncoder 的 encode 方法:

public void encode (Object object, Type bodyType, RequestTemplate template) throws EncodeException {
    String contentTypeValue = getContentTypeValue(template.headers());
    val contentType = ContentType.of(contentTypeValue);
    if (!processors.containsKey(contentType)) {
      // 项目中没有header的配置 contentTypeValue 是 null的, 代码最终会走到这里,调用父类的encode
      delegate.encode(object, bodyType, template);
      return;
    }

    Map<String, Object> data;
    if (MAP_STRING_WILDCARD.equals(bodyType)) {
      data = (Map<String, Object>) object;
    } else if (isUserPojo(bodyType)) {
      data = toMap(object);
    } else {
      delegate.encode(object, bodyType, template);
      return;
    }

    val charset = getCharset(contentTypeValue);
    processors.get(contentType).process(template, charset, data);
  }

最终会走到顶层Encoder的Default实现:

class Default implements Encoder {

  @Override
  public void encode(Object object, Type bodyType, RequestTemplate template) {
    if (bodyType == String.class) {
      template.body(object.toString());
    } else if (bodyType == byte[].class) {
      template.body((byte[]) object, null);
    } else if (object != null) {
    // 在这里抛出异常了
      throw new EncodeException(
          format("%s is not a type supported by this encoder.", object.getClass()));
    }
  }
}

到这里问题原因查到了,就是加了文件上传配置后全局使用 SpringFormEncoder 造成post @requestbody 请求没有合适的编码器。

初步解决

文件上传的配置不能全局生效,只在当前feign生效就可以了,于是有了下面配置。

@FeignClient(name = "xxx-provider",configuration = MultipartConfig.class)
public interface UserService {
    @PostMapping(value = "/user/upload",
        produces = MediaType.APPLICATION_JSON_UTF8_VALUE,
        consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    String uploadPic(@RequestPart("file") MultipartFile file);

    public static class MultipartConfig {
        @Bean
        public Encoder encoder(){
            return new SpringFormEncoder();
          }
    }
}

按照更新后的配置,可以解决不同的feign interface 配置隔离,不影响其他feign接口的编码器。原以为到这里问题已经解决了,可以高枕无忧了,其实还存在一个大的隐患。

问题再次暴露

后期开发中在原来的文件上传feign所在的微服务中,新增了一个feign接口 post方法调用,保存一些信息,在调用中发现又出现了 xxx is not a type supported by this encoder. 问题。

二阶段排查问题

有了上面的过程,再次出现编码器问题,考虑只是在原来的微服务中新增了一个feign接口而已,而这时候其他微服务的feign调用是正常的,也就是说只有配置了文件上传 SpringFormEncoder 的feign 所在的微服务出问题了(feign接口单独抽离了jar包,对应项目中 xx-resource-api jar)。

此时心中想法:MultipartConfig 的配置是在具体的 UserService feign 接口中引入的,应该只对当前接口生效啊,怎么还能影响到当前微服务另外的feign接口那? 

事实根据源码调试,这个配置确实影响到当前微服务 xxx-provider 的其他feign接口了,新加的feign接口也使用了SpringFormEncoder这个编码器。

梳理一下问题和猜想:

@FeignClient(name = "xxx-provider",configuration = MultipartConfig.class) 指定的配置,会在所有name = "xxx-provider" 的feign中生效,共享配置,但不同的name也就是不同的微服务,不会相互影响。

源码分析原因并验证猜想

feign接口调用是基于JDK动态代理实现的,核心类 :FeignClientFactoryBean 基于spring的FactoryBean 这里不做分析,不熟悉的可以单独了解FactoryBean概念。

既然是基于FactoryBean,那摩 getObject() 就是核心方法

@Override
	public Object getObject() {
		return getTarget();
	}

	/**
	 * @param <T> the target type of the Feign client
	 * @return a {@link Feign} client created with the specified data and the context
	 * information
	 */
	<T> T getTarget() {
		FeignContext context = beanFactory != null
				? beanFactory.getBean(FeignContext.class)
				: applicationContext.getBean(FeignContext.class);
		Feign.Builder builder = feign(context);

		if (!StringUtils.hasText(url)) {
			if (url != null && LOG.isWarnEnabled()) {
				LOG.warn(
						"The provided URL is empty. Will try picking an instance via load-balancing.");
			}
			else if (LOG.isDebugEnabled()) {
				LOG.debug("URL not provided. Will use LoadBalancer.");
			}
			if (!name.startsWith("http")) {
				url = "http://" + name;
			}
			else {
				url = name;
			}
			url += cleanPath();
			return (T) loadBalance(builder, context,
					new HardCodedTarget<>(type, name, url));
		}
		.....省略....
        .............
		return (T) targeter.target(this, builder, context,
				new HardCodedTarget<>(type, name, url));
	}

这里只对当前问题涉及的源码进行分析,如下:

	protected Feign.Builder feign(FeignContext context) {
		FeignLoggerFactory loggerFactory = get(context, FeignLoggerFactory.class);
		Logger logger = loggerFactory.create(type);

		// @formatter:off
		Feign.Builder builder = get(context, Feign.Builder.class)
				// required values
				.logger(logger)
                // 编码器相关
				.encoder(get(context, Encoder.class))
				.decoder(get(context, Decoder.class))
				.contract(get(context, Contract.class));
		// @formatter:on

		configureFeign(context, builder);
		applyBuildCustomizers(context, builder);

		return builder;
	}

 重点分析 get(context, Encoder.class) 方法

.encoder(get(context, Encoder.class))

//进入
	protected <T> T get(FeignContext context, Class<T> type) {
		T instance = context.getInstance(contextId, type);
		if (instance == null) {
			throw new IllegalStateException(
					"No bean found of type " + type + " for " + contextId);
		}
		return instance;
	}

//进入 context.getInstance(contextId, type); 也就是 NamedContextFactory 的 getInstance

	public <T> T getInstance(String name, Class<T> type) {
        // 出现了一个至关重要的类 AnnotationConfigApplicationContext 
		AnnotationConfigApplicationContext context = getContext(name);
		try {
			return context.getBean(type);
		}
		catch (NoSuchBeanDefinitionException e) {
			// ignore
		}
		return null;
	}

上面看到出现了一个 AnnotationConfigApplicationContext  类,熟悉spring 容器源码的同学会脸前一亮这里为什么不是springboot的web容器,而是注解配置容器类那?

继续点进去getContext方法

	protected AnnotationConfigApplicationContext getContext(String name) {
		if (!this.contexts.containsKey(name)) {
			synchronized (this.contexts) {
				if (!this.contexts.containsKey(name)) {
					this.contexts.put(name, createContext(name));
				}
			}
		}
		return this.contexts.get(name);
	}

看到这个方法会对context进行缓存,没有命中缓存就调用createContext方法,从命名中可以看出是创建一个新的IOC容器,containsKey 也就是当前的容器ID,是微服务对应的名字,@FeignClient(name = "xxx-provider",configuration = MultipartConfig.class) 也就是 xxx-provider。

下面是创建容器并关联父容器的代码,父容器就是当前springboot的主web容器即我们项目中controller、 service、dao对象存储的容器。

protected AnnotationConfigApplicationContext createContext(String name) {
		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
		if (this.configurations.containsKey(name)) {
			for (Class<?> configuration : this.configurations.get(name)
					.getConfiguration()) {
				context.register(configuration);
			}
		}
		for (Map.Entry<String, C> entry : this.configurations.entrySet()) {
			if (entry.getKey().startsWith("default.")) {
				for (Class<?> configuration : entry.getValue().getConfiguration()) {
					context.register(configuration);
				}
			}
		}
		context.register(PropertyPlaceholderAutoConfiguration.class,
				this.defaultConfigType);
		context.getEnvironment().getPropertySources().addFirst(new MapPropertySource(
				this.propertySourceName,
				Collections.<String, Object>singletonMap(this.propertyName, name)));
		if (this.parent != null) {
			// Uses Environment from parent as well as beans
			context.setParent(this.parent);
			// jdk11 issue
			// https://github.com/spring-cloud/spring-cloud-netflix/issues/3101
			context.setClassLoader(this.parent.getClassLoader());
		}
		context.setDisplayName(generateDisplayName(name));
		context.refresh();
		return context;
	}

经过上面源码的阅读得出结论:所有name相同的@FeignClient 对应的接口会创建一个AnnotationConfigApplicationContext 容器与web容器通过子父容器关联,feign接口代理对象是保存在当前容器内的。也就证实了上面的猜想@FeignClient(name = "xxx-provider",configuration = MultipartConfig.class) 指定的配置,会在所有name = "xxx-provider" 的feign中生效,共享配置,但不同的name也就是不同的微服务,不会相互影响。

继续寻找“同一微服务文件上传和普通接口并存”的解决办法

回过头思考:文件上传这种问题springcloud默认就没考虑到吗?不可能吧?咱们通过源码认真看一下默认的编码器 SpringEncoder。

PageableSpringEncoder 是对SpringEncoder的一个装饰增强,内部调用的还是SpringEncoder,我们这里直接看SpringEncoder就行了。

SpringEncoder的encode方法如下所示:果然可以看出对 MultipartType 文件上传做过配置了,isMultipartType 判断逻辑,request.headers().get(HttpEncoding.CONTENT_TYPE);

public void encode(Object requestBody, Type bodyType, RequestTemplate request)
			throws EncodeException {
		// template.body(conversionService.convert(object, String.class));
		if (requestBody != null) {
			Collection<String> contentTypes = request.headers()
					.get(HttpEncoding.CONTENT_TYPE);

			MediaType requestContentType = null;
			if (contentTypes != null && !contentTypes.isEmpty()) {
				String type = contentTypes.iterator().next();
				requestContentType = MediaType.valueOf(type);
			}

			if (isMultipartType(requestContentType)) {
				this.springFormEncoder.encode(requestBody, bodyType, request);
				return;
			}
			else {
				if (bodyType == MultipartFile.class) {
					log.warn(
							"For MultipartFile to be handled correctly, the 'consumes' parameter of @RequestMapping "
									+ "should be specified as MediaType.MULTIPART_FORM_DATA_VALUE");
				}
			}
			encodeWithMessageConverter(requestBody, bodyType, request,
					requestContentType);
		}
	}


    

	private boolean isMultipartType(MediaType requestContentType) {
		return Arrays.asList(MediaType.MULTIPART_FORM_DATA, MediaType.MULTIPART_MIXED,
				MediaType.MULTIPART_RELATED).contains(requestContentType);
	}

得出结论:只需要在请求头里面加上文件上传的CONTENT_TYPE即可:

conten-type=multipart/form-data

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐