近期遇到一个需求需要通过 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配置类,其中提供一个EncoderBean,不过其具体实现为 SpringFormEncoder。既然我们提供了一个EncoderBean,那 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 服务控制台打印结果如下: