通过 Feign 上传文件 | Java 开发实践
这是我参与更文挑战的第 10 天,活动详情查看: 更文挑战
本文正在参加「Java主题月 - Java 开发实战」,详情查看:活动链接
日积月累,水滴石穿 ????
近期遇到一个需求需要通过 Feign
传输文件。还以为简简单单,没想到遇到了很多问题。一起跟着笔者来看看吧!
导入依赖
<!--boot 版本为 2.2.2 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
该模块添加了对编码application/x-www-form-urlencoded和multipart/form-data表单的支持。
接下来就是编码了。
第一版
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]
接下来就是源码分析啦!!!
我们可以从控制台打印的堆栈信息得出结论:
它是在 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);
}
}
这个方法有三个参数,内容如下:
- requestBody:请求的主体信息
- bodyType:主体的类型
- request:请求的信息,比如:请求地址、请求方式、请求编码
我们先来确定一个问题,为什么它会进入到SpringEncoder
类里的encode
方法呢?Encoder
下有好几个实现呀,它是在哪里指定实现类的呢?
我们先看一下 Feign
初始化的时候,指定的实现类是 Default
呀,为什么就进入到SpringEncoder
里去了呢?
具体逻辑在 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
的抽象类中
其中有个静态内部类 Builder
,Builder
内有一个encoder
方法。
public Builder encoder(Encoder encoder) {
this.encoder = encoder;
return this;
}
该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
配置类,其中提供一个Encoder
Bean,不过其具体实现为 SpringFormEncoder
。既然我们提供了一个Encoder
Bean,那 SpringBoot
就会使用我们所配置的,那具体逻辑就会进入到 SpringFormEncoder
的encoder
方法。
我们来看看具体源码:
@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 为属性名称,即使你传递多个文件,也会以最后一个文件为主。
也许你会想能不能这么写,传递多个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 开发实践
-
Java 实践:使用 MinIO 实现文件切片和上传的 Spring Boot
-
用 java 实现 MinIO 文件上传,附带视频文件截图,通过 MinIo 将视频封面和视频上传到服务器
-
Java 类加载器的作用 - 简介:类加载器是 Java™ 中一个非常重要的概念。类加载器负责将 Java 类的字节码加载到 Java 虚拟机中。本文首先详细介绍了 Java 类加载器的基本概念,包括代理模型、加载类的具体过程和线程上下文类加载器等。然后介绍了如何开发自己的类加载器,最后介绍了类加载器在 Web 容器和 OSGi™ 中的应用。 类加载器是 Java 语言的一项创新,也是 Java 语言广受欢迎的重要原因之一。它允许将 Java 类动态加载到 Java 虚拟机中并执行。类加载器从 JDK 1.0 开始出现,最初是为了满足 Java Applets 的需求而开发的,Java Applets 需要从远程位置下载 Java 类文件并在浏览器中执行。现在,类加载器已广泛应用于网络容器和 OSGi。一般来说,Java 应用程序的开发人员不需要直接与类加载器交互;Java 虚拟机的默认行为足以应对大多数情况。但是,如果遇到需要与类加载器交互的情况,而您又不太了解类加载器的机制,就很容易花费大量时间调试异常,如 ClassNotFoundException 和 NoClassDefFoundError。本文将详细介绍 Java 的类加载器,帮助读者深入理解 Java 语言中的这一重要概念。下面先介绍一些基本概念。 类加载器的基本概念 顾名思义,类加载器用于将 Java 类加载到 Java 虚拟机中。一般来说,Java 虚拟机以如下方式使用 Java 类:Java 源程序(.java 文件)经 Java 编译器编译后转换为 Java 字节代码(.class 文件)。类加载器负责读取 Java 字节代码并将其转换为 java.lang 实例。每个实例都用来表示一个 Java 类。通过该实例的 newInstance 方法创建该类的对象。实际情况可能更加复杂,例如,Java 字节代码可能是由工具动态生成或通过网络下载的。 基本上,所有类加载器都是 java.lang.ClassLoader 类的实例。下面将详细介绍这个 Java 类。 java.lang.ClassLoader 类简介 java.lang.ClassLoader 类的基本职责是根据给定类的名称为其查找或生成相应的字节码,然后根据这些字节码定义一个 Java 类,即 java.lang.Class 类的实例。除此之外,ClassLoader 还负责加载 Java 应用程序所需的资源,如图像文件和配置文件。不过,本文只讨论它加载类的功能。为了履行加载类的职责,ClassLoader 提供了许多方法,其中比较重要的方法如表 1 所示。下文将详细介绍这些方法。 表 1.与加载类相关的 ClassLoader 方法
-
深度解析:从源理论到完整实践的 Java 文件上传指南