使用Fegin实现文件上传和源码解读

在最近的一次开发过程中有同事说遇到使用Feign上传文件失败的情况,自己觉得有点奇怪,因为我自己之前记得使用Feign上传文件都是成功的。自己特地上网搜索了一下,确实有一些相关的问题。为了验证自己的猜想我决定自己来好好看一下Feign上传文件到底是怎么一个情况。

1、准备demo

按照老规矩,我们还是通过代码来说明问题,为了省事我使用的还是上次的demo代码,只是增加了一个支持文件上传的接口,demo代码。
上传文件的接口写在service-provider项目中,代码如下:

    private static String PATH_PREFIX = "/home/ypcfly/ypcfly/tmp";

    @PostMapping(value = "/upload",consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public String upload(@RequestParam("files") MultipartFile[] multipartFiles,
                         @RequestParam Map params) {
        log.info(">>>> upload file num={}, params={} <<<<",multipartFiles.length,params.toString());
        for (MultipartFile multipartFile: multipartFiles) {
            log.info(">>>> fileName={} <<<<",multipartFile.getOriginalFilename());
            String fileName = PATH_PREFIX + "/" + multipartFile.getOriginalFilename();
            File file = new File(fileName);
            try {
                FileUtils.copyInputStreamToFile(multipartFile.getInputStream(),file);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return "success";
    }

为了更加充分的验证我决定上传多个文件。另外使用一个Map来接收其他的请求参数。
接着是编写service-consumer中的FeignClient客户端以及调用Feign的接口,代码如下:

@FeignClient(name = "${service.provider.name}",url = "${service.provider.url}",fallback = ProviderClientFallback.class)
public interface ProviderClient {

    @GetMapping("/provider/hello")
    String hello();

    @PostMapping(value = "/file/upload",consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    ResponseEntity upload(@RequestPart("files") MultipartFile[] multipartFile, @RequestParam Map params);
}

需要注意一点的就是Feign上传文件时使用的注解是RequestPart,作用就是声明这个注解声明的参数是一个multipart/form-data请求参数。关于 RequestPartRequestParam之间最主要的区别,就是当方法参数类型不是String或者raw时,RequestParam依赖的类型转换是一个注册的Converter或者PropertyEditor,而RequestPart依赖的是HttpMessageConverter将请求头中的Content-Type考虑进来。RequestParam更倾向与用来标注键-值的属性,RequestPart更倾向与用来标注更复杂的内容,比如JSON、XML。具体的可以看源码中相关的注释。
调用Feign的请求接口如下:

@Slf4j
@RestController
@RequestMapping("/consumer")
public class HelloController {

    private ProviderClient providerClient;

    public HelloController(ProviderClient providerClient) {
        this.providerClient = providerClient;
    }

    @PostMapping("/files")
    public ResponseEntity upload(@RequestParam("files")MultipartFile[] multipartFiles,@RequestParam Map params) {
        log.info(">>>> call feign client upload files start <<<<");
        return providerClient.upload(multipartFiles,params);
    }
}

2、测试

我使用的是idea自带的http工具进行测试,所以我先编写好请求的内容,如下:

POST http://localhost:8080/consumer/files
Accept: */*
Cache-Control: no-cache
Content-Type: multipart/form-data; boundary=WebAppBoundary

--WebAppBoundary
Content-Disposition: form-data; name="files"; filename="demo1.txt"

< /home/ypcfly/ypcfly/redis.txt
--WebAppBoundary
Content-Disposition: form-data; name="param1"
Content-Type: text/plain

upload file demo
--WebAppBoundary
Content-Disposition: form-data; name="param2"
Content-Type: text/plain

55212154454131
--WebAppBoundary
Content-Disposition: form-data; name="files"; filename="demo2.txt"

< /home/ypcfly/ypcfly/Netty.txt
--WebAppBoundary

然后启动项目进行测试,先看日志输出:

图-1.png

根据日志可以看到请求已经到了service-provider,且成功接收到请求的所有参数,说明FeignClient上传文件是没有问题的。而且到指定目录查看也看到了上传的文件成功落地。通过这个简单的demo说明使用Feign上传文件是没有问题的。
我想会不会是我使用的版本比较新的缘由呢?我将Spring Boot降低到2.1.13.RELEASE,Spring Cloud改用Greenwich.SR6。测试依然没有问题,也可能是我版本还是不够低??但是我自己却不想在进行测试了,我决定看下源码,看看Feign到底是如何实现文件上传的。


3、相关源码

根据我在网上看到Feign不能上传文件的相关问题,大部分都是通过配置一个Encoder来实现的。Encoder的作用就是:Encodes an object into an HTTP request body。而且代码注释说的很清楚:Encoder is used when a method parameter has no @Param annotation。也就是说方法中没有@Param注解时才会使用。但是@ParamFeign的注解,我们基本上不会直接使用的,更多时候我们都是使用Spring提供的注解。这就带来第一个问题,是不是使用Spring提供给我们的Feign时,我们都会使用Encoder???毕竟都没有使用@Param
另外通过查看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()));
      }
    }
  }

根据上面代码可以看出,这个实现类是现在过于的简单,只支持Stringbyte[],我想应该会很少使用到默认实现吧。通过查看Encoder实现类,发现它的实现类SpringEncoder、SpringFormEncoder、FormEncoder、PageableSpringEncoder等几种类型。那么在Spring Cloud中集成的Feign会使用那种Encoder呢?通过查看FeignClientsConfiguration配置类,我们发现了相关的代码:

    @Autowired
    private ObjectFactory messageConverters;

    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnMissingClass("org.springframework.data.domain.Pageable")
    public Encoder feignEncoder() {
        return new SpringEncoder(this.messageConverters);
    }

    @Bean
    @ConditionalOnClass(name = "org.springframework.data.domain.Pageable")
    @ConditionalOnMissingBean
    public Encoder feignEncoderPageable() {
        PageableSpringEncoder encoder = new PageableSpringEncoder(
                new SpringEncoder(this.messageConverters));
        if (springDataWebProperties != null) {
            encoder.setPageParameter(
                    springDataWebProperties.getPageable().getPageParameter());
            encoder.setSizeParameter(
                    springDataWebProperties.getPageable().getSizeParameter());
            encoder.setSortParameter(
                    springDataWebProperties.getSort().getSortParameter());
        }
        return encoder;
    }

也就是说没有类Pageable的情况下默认的是SpringEncoder。上面的demo项目中没有引入Spring Boot Data的依赖,所以默认的Encoder实现是SpringEncoder。那么我们来具体看下SpringEncoder的代码。

public class SpringEncoder implements Encoder {

    private static final Log log = LogFactory.getLog(SpringEncoder.class);

    private final SpringFormEncoder springFormEncoder = new SpringFormEncoder();

    private final ObjectFactory messageConverters;

    public SpringEncoder(ObjectFactory messageConverters) {
        this.messageConverters = messageConverters;
    }

     // 其他方法略
    ....

也就是说SpringEncoder对象的内部其实是有一个SpringFormEncoder对象的。而SpringFormEncoder继承了FormEncoder,从而可以支持MultipartFile。所以我们可以说SpringEncoder是默认支持multipart/form-data请求的。我们来具体看下SpringEncoderencode方法,代码如下:

    @Override
    public void encode(Object requestBody, Type bodyType, RequestTemplate request) throws EncodeException {
        // template.body(conversionService.convert(object, String.class));
        if (requestBody != null) {
            Collection 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 (Objects.equals(requestContentType, MediaType.MULTIPART_FORM_DATA)) {
                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");
                }
            }

            for (HttpMessageConverter messageConverter : this.messageConverters
                    .getObject().getConverters()) {
                FeignOutputMessage outputMessage;
                try {
                    if (messageConverter instanceof GenericHttpMessageConverter) {
                        outputMessage = checkAndWrite(requestBody, bodyType,
                                requestContentType,
                                (GenericHttpMessageConverter) messageConverter, request);
                    }
                    else {
                        outputMessage = checkAndWrite(requestBody, requestContentType,
                                messageConverter, request);
                    }
                }
                catch (IOException | HttpMessageConversionException ex) {
                    throw new EncodeException("Error converting request body", ex);
                }
                if (outputMessage != null) {
                    // clear headers
                    request.headers(null);
                    // converters can modify headers, so update the request
                    // with the modified headers
                    request.headers(getHeaders(outputMessage.getHeaders()));

                    // do not use charset for binary data and protobuf
                    Charset charset;
                    if (messageConverter instanceof ByteArrayHttpMessageConverter) {
                        charset = null;
                    }
                    else if (messageConverter instanceof ProtobufHttpMessageConverter
                            && ProtobufHttpMessageConverter.PROTOBUF.isCompatibleWith(
                                    outputMessage.getHeaders().getContentType())) {
                        charset = null;
                    }
                    else {
                        charset = StandardCharsets.UTF_8;
                    }
                    request.body(Request.Body.encoded(
                            outputMessage.getOutputStream().toByteArray(), charset));
                    return;
                }
            }
            String message = "Could not write request: no suitable HttpMessageConverter "
                    + "found for request type [" + requestBody.getClass().getName() + "]";
            if (requestContentType != null) {
                message += " and content type [" + requestContentType + "]";
            }
            throw new EncodeException(message);
        }
    }

通过代码可以发现,方法内部如果发现请求类型是multipart/form-data,会调用SpringFormEncoderencode方法,然后返回,而该方法内无论你请求的是MultipartFile[]还是MultipartFile甚至MultipartFile Collection最终都会被转成一个HashMap,从而继续调用FormEncoderencode方法。
但是通过debug我发现实际情况有一点细微的区别。因为我的请求参数里面有一个Map。所以在SpringEncoderencode方法内,requestBody变量是一个LinkedHashMap,存放的是上传的文件,而bodyType其实是一个Map,也就是说省略了将请求参数封装成一个HashMap的情形,直接调用FormEncoderencode方法,而其内部具体执行代码:

  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)) {
      delegate.encode(object, bodyType, template);
      return;
    }

    Map data;
    if (MAP_STRING_WILDCARD.equals(bodyType)) {
      data = (Map) 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);
  }

因为请求的类型是"multipart/form-data",所具体调用了MultipartFormContentProcessorprocess方法,执行相关的Http请求。

  @Override
  public void process (RequestTemplate template, Charset charset, Map data) throws EncodeException {
    val boundary = Long.toHexString(System.currentTimeMillis());
    val output = new Output(charset);

    for (val entry : data.entrySet()) {
      if (entry == null || entry.getKey() == null || entry.getValue() == null) {
        continue;
      }
      val writer = findApplicableWriter(entry.getValue());
      writer.write(output, boundary, entry.getKey(), entry.getValue());
    }

    output.write("--").write(boundary).write("--").write(CRLF);

    val contentTypeHeaderValue = new StringBuilder()
        .append(getSupportedContentType().getHeader())
        .append("; charset=").append(charset.name())
        .append("; boundary=").append(boundary)
        .toString();

    template.header(CONTENT_TYPE_HEADER, Collections.emptyList()); // reset header
    template.header(CONTENT_TYPE_HEADER, contentTypeHeaderValue);

    // Feign's clients try to determine binary/string content by charset presence
    // so, I set it to null (in spite of availability charset) for backward compatibility.
    val bytes = output.toByteArray();
    val body = Request.Body.encoded(bytes, null);
    template.body(body);

    try {
      output.close();
    } catch (IOException ex) {
      throw new EncodeException("Output closing error", ex);
    }
  }

到这里基本上FeignClient的请求就结束了。因为时间问题源码我没有仔细的阅读,只是根据debug的流程大概看了一下。但是我的疑问还是没有揭开其他人为什么不能使用Feign上传文件呢,难道真的是版本问题吗???如果有哪位小伙伴在实际中遇到了使用Feign上传文件失败请一定告诉我。


4、总结

本次主要从一个实际工作中遇到的问题着手,因为具体的情况我不是特别的清楚,只是同事这么说过而已,而他最终的解决方法和网上相关的问题一样,也通过一个配置类创建了一个SpringFormEncoderbean。但是通过上面的源码我们发现在没有org.springframework.data.domain.Pageable类的前提下,默认的SpringEncoder是支持文件上传的,而且也通过了验证。而且哪怕有Pageable,默认的Encoder变成PageableSpringEncoder,其实通过代码我们可以发现PageableSpringEncoder内部其实保有一个SpringEncoder对象,所以它依然可以实现文件上传的功能。所以到底什么情况下会出现使用Feign不能上传文件的情况呢???

你可能感兴趣的:(使用Fegin实现文件上传和源码解读)