记一次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调用是正常的,也就是说只有配置了文件上传 SpringFormEncoderfeign 所在的微服务出问题了(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

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

猜你喜欢

转载自juejin.im/post/7109361128391573512