在最近的一次开发过程中有同事说遇到使用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
请求参数。关于 RequestPart
和RequestParam
之间最主要的区别,就是当方法参数类型不是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
然后启动项目进行测试,先看日志输出:
根据日志可以看到请求已经到了
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
注解时才会使用。但是@Param
是Feign
的注解,我们基本上不会直接使用的,更多时候我们都是使用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()));
}
}
}
根据上面代码可以看出,这个实现类是现在过于的简单,只支持String
和byte[]
,我想应该会很少使用到默认实现吧。通过查看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
请求的。我们来具体看下SpringEncoder
的encode
方法,代码如下:
@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
,会调用SpringFormEncoder
的encode
方法,然后返回,而该方法内无论你请求的是MultipartFile[]
还是MultipartFile
甚至MultipartFile Collection
最终都会被转成一个HashMap
,从而继续调用FormEncoder
的encode
方法。
但是通过debug我发现实际情况有一点细微的区别。因为我的请求参数里面有一个Map<String,Object>。所以在SpringEncoder
的encode
方法内,requestBody
变量是一个LinkedHashMap
,存放的是上传的文件,而bodyType
其实是一个Map
,也就是说省略了将请求参数封装成一个HashMap
的情形,直接调用FormEncoder
的encode
方法,而其内部具体执行代码:
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"
,所具体调用了MultipartFormContentProcessor
的process
方法,执行相关的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、总结
本次主要从一个实际工作中遇到的问题着手,因为具体的情况我不是特别的清楚,只是同事这么说过而已,而他最终的解决方法和网上相关的问题一样,也通过一个配置类创建了一个SpringFormEncoder
bean。但是通过上面的源码我们发现在没有org.springframework.data.domain.Pageable
类的前提下,默认的SpringEncoder
是支持文件上传的,而且也通过了验证。而且哪怕有Pageable
,默认的Encoder
变成PageableSpringEncoder
,其实通过代码我们可以发现PageableSpringEncoder
内部其实保有一个SpringEncoder
对象,所以它依然可以实现文件上传的功能。所以到底什么情况下会出现使用Feign不能上传文件的情况呢???
网友评论