通过 Feign 上传文件|Java 开发实战

近期遇到一个需求需要通过 Feign 传输文件。还以为简简单单,没想到遇到了很多问题。一起跟着笔者来看看吧!

导入依赖

<!--boot 版本为 2.2.2 -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>

该模块添加了对编码 application/x-www-form-urlencodedmultipart/form-data表单的支持。

通过 Feign 上传文件|Java 开发实战

接下来就是编码了。

第一版

A 服务

  • A 服务的 Controller
    @Autowired
    PayFeign payFeign;

    @PostMapping("/uploadFile")
    public void upload(@RequestParam MultipartFile multipartFile,String title){
        payFeign.uploadFile(multipartFile,title);
    }
  • A 服务上的 Feign
@FeignClient(name = "appPay")
public interface PayFeign {

   @PostMapping(value="/api/pay/uploadFile",consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
   void uploadFile(@RequestPart("multipartFile111") MultipartFile multipartFile,
                @RequestParam("title") String title);

}

注意点:

  • Feign里指定consumes的格式为 MULTIPART_FORM_DATA_VALUE,指定请求的提交内容类型。
  • 文件类型可以使用注解 @RequestPart,也可以不写,反正不能使用RequestParam注解。

B 服务

@PostMapping(value="/uploadFile")
void uploadFile(@RequestParam("multipartFile") MultipartFile multipartFile, 
@RequestParam("title") String title){

    System.out.println(multipartFile.getOriginalFilename() + "=====" + title);

}

注意:A服务、B服务 Controller 里的MultipartFile的名称必须一致。至于 A 服务里的 Feign 里的名称可以随便起,当然尽量还是保持一致。

这样子写是没问题的。是可以通过 Feign 传输文件的。但是后面需求发生了变更。B 服务那边接受参数的方式发生了变更,使用了实体类接受参数。因为觉得还是使用之前的传参方式,那如果有四五个参数,甚至更多,会造成代码可读性下降。

第二版

B 服务

增加接口uploadFile2,内容如下:

  @PostMapping(value="/uploadFile2")
    void uploadFile(FileInfoDTO fileInfo){
        System.out.println(fileInfo.getMultipartFile().getOriginalFilename() 
        + "=====" + fileInfo.getTitle());

    }

入参使用 FileInfoDTO接收。FileInfoDTO内容如下:

public class FileInfoDTO {

    private MultipartFile multipartFile;
    private String title;
    // 省略get/set
}

A 服务

A 服务请求也随之发生改变,变化如下:

  • A 服务的 Controller
@PostMapping("/uploadFile2")
public void upload(FileInfo fileInfo){
    payFeign.uploadFile2(fileInfo);
}   
  • A 服务的 Feign
@PostMapping(value="/api/pay/uploadFile2",
        consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
void uploadFile2(FileInfoDTO fileInfo);    

通过 Postman,请求 A 服务接口:

这时会出现以下异常:

feign.codec.EncodeException: Could not write request: no suitable 
HttpMessageConverter found for request type [com.gongj.appuser.dto.FileInfoDTO] 
and content type [multipart/form-data]
通过 Feign 上传文件|Java 开发实战

接下来就是源码分析啦!!!

我们可以从控制台打印的堆栈信息得出结论:

它是在 SpringEncoder类的encode方法出现了异常!那我们一起来看看该方法的内容:

@Override
public void encode(Object requestBody, Type bodyType, RequestTemplate request)
    throws EncodeException {
// template.body(conversionService.convert(object, String.class));
// 请求的主体信息不为 null
if (requestBody != null) {
    // 获得请求的 Class
    Class<?> requestType = requestBody.getClass();
    //获得 Content-Type  也就是我们所指定 consumes 的值
    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);
    }
    // 主体的类型不为 null 并且类型为 MultipartFile
    if (bodyType != null && bodyType.equals(MultipartFile.class)) {
        // Content-Type 为 multipart/form-data
        if (Objects.equals(requestContentType, MediaType.MULTIPART_FORM_DATA)) {
            // 调用 SpringFormEncoder 的 encode 方法
            this.springFormEncoder.encode(requestBody, bodyType, request);
            return;
        }else {
            // 如果主体的类型是 MultipartFile,但Content-Type 不为 multipart/form-data
            // 则抛出异常
            String message = "Content-Type \"" + MediaType.MULTIPART_FORM_DATA
                    + "\" not set for request body of type "
                    + requestBody.getClass().getSimpleName();
            throw new EncodeException(message);
        }
    }
    // 我们请求进入到了这里,进行转换
    // 主体的类型不为 MultipartFile 就通过 HttpMessageConverter 进行类型转换
    for (HttpMessageConverter<?> messageConverter : this.messageConverters
            .getObject().getConverters()) {
        if (messageConverter.canWrite(requestType, requestContentType)) {
            if (log.isDebugEnabled()) {
                if (requestContentType != null) {
                    log.debug("Writing [" + requestBody + "] as \""
                            + requestContentType + "\" using [" + messageConverter
                            + "]");
                }
                else {
                    log.debug("Writing [" + requestBody + "] using ["
                            + messageConverter + "]");
                }

            }

            FeignOutputMessage outputMessage = new FeignOutputMessage(request);
            try {
                @SuppressWarnings("unchecked")
                HttpMessageConverter<Object> copy = (HttpMessageConverter<Object>) messageConverter;
                copy.write(requestBody, requestContentType, outputMessage);
            }
            catch (IOException ex) {
                throw new EncodeException("Error converting request body", ex);
            }
            // 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 [" + requestType.getName() + "]";
    if (requestContentType != null) {
        message += " and content type [" + requestContentType + "]";
    }
    throw new EncodeException(message);
}
}

这个方法有三个参数,内容如下:

通过 Feign 上传文件|Java 开发实战
  • requestBody:请求的主体信息
  • bodyType:主体的类型
  • request:请求的信息,比如:请求地址、请求方式、请求编码

我们先来确定一个问题,为什么它会进入到SpringEncoder类里的encode方法呢?Encoder下有好几个实现呀,它是在哪里指定实现类的呢?

通过 Feign 上传文件|Java 开发实战

我们先看一下 Feign 初始化的时候,指定的实现类是 Default呀,为什么就进入到SpringEncoder里去了呢?

通过 Feign 上传文件|Java 开发实战

具体逻辑在 FeignClientsConfiguration类中,提供了一个 Encoder Bean

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

介绍一下两个注解的含义:

  • ConditionalOnMissingClass:某个 class 在类路径上不存在的时候,则实例化当前 Bean。
  • ConditionalOnMissingBean: 当给定的 bean 不存在时,则实例化当前Bean

那是在哪被赋值的呢?还是在Feign的抽象类中

通过 Feign 上传文件|Java 开发实战

其中有个静态内部类 Builder,Builder内有一个encoder方法。

public Builder encoder(Encoder encoder) {
      this.encoder = encoder;
      return this;
}
通过 Feign 上传文件|Java 开发实战

该encoder方法被两个地方调用。这里看第一个调用点,第二是读取配置文件的值,先忽略掉,这里没有使用配置。

调用 get 方法。到这之后就是根据 Encoder类型去 Spring中寻找 Bean。拿到的值就是在 FeignClientsConfiguration中配置的。讲了这么多,那怎么解决呢!既然SpringEncoder解决不了我们的这种场景,那我们就换一个Encoder就好了。

@Configuration
public class FeignConfig {

//    @Bean
//    public Encoder multipartFormEncoder() {
//        return new SpringFormEncoder();
//    }

    @Autowired
    private ObjectFactory<HttpMessageConverters> messageConverters;

    @Bean
    public Encoder feignFormEncoder() {
        return new SpringFormEncoder(new SpringEncoder(messageConverters));
    }
}

上述两种方式都是可以的,一个是有参,参数指定为SpringEncoder,一个无参,默认为new Encoder.Default()。 提供一个FeignConfig配置类,其中提供一个EncoderBean,不过其具体实现为 SpringFormEncoder。既然我们提供了一个EncoderBean,那 SpringBoot就会使用我们所配置的,那具体逻辑就会进入到 SpringFormEncoder的encoder方法。

通过 Feign 上传文件|Java 开发实战

我们来看看具体源码:

@Override
public void encode (Object object, Type bodyType, RequestTemplate template) 
throws EncodeException {
    // 主体类型为 MultipartFile 数组
    if (bodyType.equals(MultipartFile[].class)) {
      val files = (MultipartFile[]) object;
      val data = new HashMap<String, Object>(files.length, 1.F);
      for (val file : files) {
         // file.getName() 获取的属性名称
        data.put(file.getName(), file);
      }
        // 调用父类方法
      super.encode(data, MAP_STRING_WILDCARD, template);
    } else if (bodyType.equals(MultipartFile.class)) {
        //  主体类型为 MultipartFile
      val file = (MultipartFile) object;
      val data = singletonMap(file.getName(), object);
         // 调用父类方法
      super.encode(data, MAP_STRING_WILDCARD, template);
    } else if (isMultipartFileCollection(object)) {
         //  主体类型为 MultipartFile集合
      val iterable = (Iterable<?>) object;
      val data = new HashMap<String, Object>();
      for (val item : iterable) {
        val file = (MultipartFile) item;
          // file.getName() 获取的属性名称
        data.put(file.getName(), file);
      }
         // 调用父类方法
      super.encode(data, MAP_STRING_WILDCARD, template);
    } else {
      //其他类型  还是调用父类方法
      super.encode(object, bodyType, template);
    }   
}

可以看到支持的格式是有很多种的,但其实都是调用父类的 encode方法,只是传参不同而已。接下来看看父类FormEncoder的代码,

public void encode (Object object, Type bodyType, RequestTemplate template) 
throws EncodeException {
    // Content-Type的值
    String contentTypeValue = getContentTypeValue(template.headers());
    // 进行转换 比如:multipart/form-data 会被转为 MULTIPART
    val contentType = ContentType.of(contentTypeValue);
    if (!processors.containsKey(contentType)) {
      delegate.encode(object, bodyType, template);
      return;
    }

    Map<String, Object> data;
    // 判断 bodyType 的类型是否是 Map
    if (MAP_STRING_WILDCARD.equals(bodyType)) {
      data = (Map<String, Object>) object;
    } else if (isUserPojo(bodyType)) {
     //  判断 bodyType 的名称是否以 class java.开头 如果不是,将类对象转换为 Map
        // 我们也就是属于这种情况。
      data = toMap(object);
    } else {
      delegate.encode(object, bodyType, template);
      return;
    }

    val charset = getCharset(contentTypeValue);
    // 根据不同的 contentType 走不同的流程
    processors.get(contentType).process(template, charset, data);
  }

processors是一个 Map ,它有两个值,分别为

  • MULTIPART:MultipartFormContentProcessor,
  • URLENCODED:UrlencodedFormContentProcessor

上述方式就解决了我们所遇到的问题,而且第一版请求方式与第二版请求方式都是支持的。

有几种写法?

能传递多个吗?

不能,组装 Map 的 key 为属性名称,即使你传递多个文件,也会以最后一个文件为主。

通过 Feign 上传文件|Java 开发实战

也许你会想能不能这么写,传递多个MultipartFile对象。

 @PostMapping(value="/api/pay/uploadFile5",
            consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
void uploadFile5(@RequestPart("multipartFile")MultipartFile multipartFile, 
@RequestPart("multipartFile2")MultipartFile multipartFile2);

对不起,不能,这种写法启动时就会报错:Method has too many Body parameters。

其他情况

还有一种情况就是文件是由 A 服务内部产生的,而不是由外部传入的。我们自己产生的文件类型为 File,而不是MultipartFile类型的,这时候就需要进行转换了。

加入依赖

<dependency>
    <groupId>commons-fileupload</groupId>
    <artifactId>commons-fileupload</artifactId>
    <version>1.3.3</version>
</dependency>

编写工具类

import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.FileItemFactory;
import org.apache.commons.fileupload.disk.DiskFileItemFactory;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.commons.CommonsMultipartFile;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;

public class FileUtil {
public static MultipartFile fileToMultipartFile(File file) {
    //重点  这个名字需要和你对接的MultipartFil的名称一样
    String fieldName = "multipartFile";
    FileItemFactory factory = new DiskFileItemFactory(16, null);
    FileItem item = factory.createItem(fieldName, "multipart/form-data", true, 
    file.getName());
    int bytesRead = 0;
    byte[] buffer = new byte[8192];
    try {
        FileInputStream fis = new FileInputStream(file);
        OutputStream os = item.getOutputStream();
        while ((bytesRead = fis.read(buffer, 0, 8192)) != -1) {
            os.write(buffer, 0, bytesRead);
        }
        os.close();
        fis.close();
    } catch (IOException e) {
        e.printStackTrace();
    }
    return new CommonsMultipartFile(item);
}
}

A 服务增加方法

@PostMapping("/uploadFile5")
public void upload5(){
    File file = new File("D:\\gongj\\log\\product-2021-05-12.log");
    MultipartFile multipartFile = FileUtil.fileToMultipartFile(file);
    payFeign.uploadFile(multipartFile,"上传文件");
}

通过 postman请求 uploadFile5方法,B 服务控制台打印结果如下:

通过 Feign 上传文件|Java 开发实战
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,490评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,581评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,830评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,957评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,974评论 6 393
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,754评论 1 307
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,464评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,357评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,847评论 1 317
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,995评论 3 338
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,137评论 1 351
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,819评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,482评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,023评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,149评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,409评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,086评论 2 355

推荐阅读更多精彩内容