记一次feign文件上传配置引起的 “xx is not a type supported by this encoder.” 错误
一、场景重现开发框架使用
这里先给出正确的配置:
不需要额外新增配置编码器 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
更多推荐
所有评论(0)