在最近的一次开发过程中有同事说遇到使用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<String,Object> 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<String> upload(@RequestPart("files") MultipartFile[] multipartFile, @RequestParam Map<String,Object> 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"

--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"

--WebAppBoundary

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



springcloud Feign上传文件内存溢出 feign实现文件上传_springcloud上传文件

图-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 {

    @Overridepublic 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")@ConditionalOnMissingBeanpublic 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<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 (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不能上传文件的情况呢???