美文网首页课外拓展
使用Fegin实现文件上传和源码解读

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

作者: 非典型_程序员 | 来源:发表于2020-09-12 17:03 被阅读0次

    在最近的一次开发过程中有同事说遇到使用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<String> upload(@RequestParam("files")MultipartFile[] multipartFiles,@RequestParam Map<String,Object> 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<HttpMessageConverters> 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<HttpMessageConverters> messageConverters;
    
        public SpringEncoder(ObjectFactory<HttpMessageConverters> 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<String,Object>。所以在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<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);
      }
    

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

      @Override
      public void process (RequestTemplate template, Charset charset, Map<String, Object> 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.<String>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实现文件上传和源码解读

        本文链接:https://www.haomeiwen.com/subject/gviwektx.html