欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

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

最编程 2024-05-01 20:43:25
...

这是我参与更文挑战的第 10 天,活动详情查看: 更文挑战

本文正在参加「Java主题月 - Java 开发实战」,详情查看:活动链接

日积月累,水滴石穿 ????

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

导入依赖

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

image-20210606134643243.png

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

image.png

接下来就是编码了。

第一版

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 服务接口: image-20210606180637468.png

这时会出现以下异常:

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]

image-20210606181203604.png

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

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

它是在 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);
}
}

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

image-20210606182303060.png

  • requestBody:请求的主体信息
  • bodyType:主体的类型
  • request:请求的信息,比如:请求地址、请求方式、请求编码

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

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

image-20210608220254970.png

具体逻辑在 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的抽象类中

image-20210606215224433.png

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

public Builder encoder(Encoder encoder) {
      this.encoder = encoder;
      return this;
}

image-20210606215407833.png

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

image-20210606215607521.png 调用 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就会使用我们所配置的,那具体逻辑就会进入到 SpringFormEncoderencoder方法。

image-20210606220634608.png 我们来看看具体源码:

@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

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

有几种写法?

image-20210606235717960.png

能传递多个吗?

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

image-20210608230201694.png

也许你会想能不能这么写,传递多个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 服务控制台打印结果如下:

image-20210609213652442.png

  • 如你对本文有疑问或本文有错误之处,欢迎评论留言指出。如觉得本文对你有所帮助,欢迎点赞和关注。

推荐阅读