该方法不需要将多个自定义实体参数pojos封装到单个Pojo或单个Map中,可以保留原有接口结构
最近公司的项目要重构为基于SpringCloud的微服务架构。在服务调用这块采用的是open Feign组件。当中也踩了许许多多的坑。其中的坑之一就是遇到了feign声明的webservice客户端接口不能传递多个实体,只支持单个实体。
不管是百度还是到github和stacksoverflow上都没有找到解决方法。很多文章写着支持,最后采用的基本上都是把多个实体放到一个Map或封装到一个实体里,并且不是在一个统一的地方进行这种转换和解析,每一个方法里都需要这样操作,真是坑爹。那这分明就是不支持好吧,而且放到一个map里并不是一个好方法,服务端控制器定义的参数并不能看出需要接收的参数。并且我们项目中一共有1700多个地方需要修改,工作量实在太大,放弃。
之前对feign并不了解,但是为了解决这个问题,陆陆续续看了许多资料,最后通过自己重写Param.expander,和重写RequestInterceptor里的apply方法解决了这个问题,很是惊喜。但是这个方法其实也并不好。因为我的方法是将实体json序列化后放到Template的queries中。最终传递的时候将会全部直接放到url中提交。同时还需要重写服务者端控制器解析参数的相关方法。一来是改动的类太多,二来是后来发现有一个问题是不能传递太大的对象和文件,毕竟url的长度不能是无限的。
超长的url发送时feign会直接报错。但是起码大部分传递的接口是能调通的,主要的问题是不能传递文件(我测试可以传递个几十k的文件,哈哈。)
于是搜索feign上传文件相关的资料,找到了一位老外写的解决方案。(真是令人哭笑不得,花了很大力气不完美的解决后,发现了完美的解决方案)。不仅支持传递文件、文件数组,而且支持传递多个实体。好吧,所以现将其使用方法分享出来。
import feign.Contract;
import feign.codec.Encoder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @Author Gu Yuxing
* @Create 2019-02-12 18:45
**/
@Configuration
public class FeignConfiguration {
// 启用Fegin自定义注解 如@RequestLine @Param
@Bean
public Contract feignContract(){
return new Contract.Default();
}
//feign 实现多pojo传输与MultipartFile上传 编码器,需配合开启feign自带注解使用
@Bean
public Encoder feignSpringFormEncoder(){
return new FeignSpringFormEncoder();
}
}
import feign.RequestTemplate;
import feign.codec.EncodeException;
import feign.codec.Encoder;
import org.apache.commons.lang3.StringUtils;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpOutputMessage;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.reflect.Type;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
/**
* A custom {@link feign.codec.Encoder} that supports Multipart requests. It uses
* {@link HttpMessageConverter}s like {@link RestTemplate} does.
* feign 实现多pojo传输与MultipartFile上传 编码器,需配合开启feign自带注解使用
* @author Pierantonio Cangianiello
*/
public class FeignSpringFormEncoder implements Encoder {
private final List<HttpMessageConverter<?>> converters = new RestTemplate().getMessageConverters();
public static final Charset UTF_8 = Charset.forName("UTF-8");
public FeignSpringFormEncoder() {
}
/**
* {@inheritDoc }
*/
@Override
public void encode(Object object, Type bodyType, RequestTemplate template) throws EncodeException {
final HttpHeaders multipartHeaders = new HttpHeaders();
final HttpHeaders jsonHeaders = new HttpHeaders();
multipartHeaders.setContentType(MediaType.MULTIPART_FORM_DATA);
jsonHeaders.setContentType(MediaType.APPLICATION_JSON);
if (isFormRequest(bodyType)) {
encodeMultipartFormRequest((Map<Object, ?>) object, multipartHeaders, template);
} else {
encodeRequest(object, jsonHeaders, template);
}
}
/**
* Encodes the request as a multipart form. It can detect a single {@link MultipartFile}, an
* array of {@link MultipartFile}s, or POJOs (that are converted to JSON).
*
* @param formMap
* @param template
* @throws EncodeException
*/
private void encodeMultipartFormRequest(Map<Object, ?> formMap, HttpHeaders multipartHeaders, RequestTemplate template) throws EncodeException {
if (formMap == null) {
throw new EncodeException("Cannot encode request with null form.");
}
LinkedMultiValueMap<Object, Object> map = new LinkedMultiValueMap<>();
for (Entry<Object, ?> entry : formMap.entrySet()) {
Object value = entry.getValue();
if (isMultipartFile(value)) {
map.add(entry.getKey(), encodeMultipartFile((MultipartFile) value));
} else if (isMultipartFileArray(value)) {
encodeMultipartFiles(map, (String)entry.getKey(), Arrays.asList((MultipartFile[]) value));
} else {
map.add(entry.getKey(), encodeJsonObject(value));
}
}
encodeRequest(map, multipartHeaders, template);
}
private boolean isMultipartFile(Object object) {
return object instanceof MultipartFile;
}
private boolean isMultipartFileArray(Object o) {
return o != null && o.getClass().isArray() && MultipartFile.class.isAssignableFrom(o.getClass().getComponentType());
}
/**
* Wraps a single {@link MultipartFile} into a {@link HttpEntity} and sets the
* {@code Content-type} header to {@code application/octet-stream}
*
* @param file
* @return
*/
private HttpEntity<?> encodeMultipartFile(MultipartFile file) {
HttpHeaders filePartHeaders = new HttpHeaders();
filePartHeaders.setContentType(MediaType.APPLICATION_OCTET_STREAM);
try {
Resource multipartFileResource = new MultipartFileResource(file.getOriginalFilename(), file.getSize(), file.getInputStream());
return new HttpEntity<>(multipartFileResource, filePartHeaders);
} catch (IOException ex) {
throw new EncodeException("Cannot encode request.", ex);
}
}
/**
* Fills the request map with {@link HttpEntity}s containing the given {@link MultipartFile}s.
* Sets the {@code Content-type} header to {@code application/octet-stream} for each file.
*
* @param the current request map.
* @param name the name of the array field in the multipart form.
* @param files
*/
private void encodeMultipartFiles(LinkedMultiValueMap<Object, Object> map, String name, List<? extends MultipartFile> files) {
HttpHeaders filePartHeaders = new HttpHeaders();
filePartHeaders.setContentType(MediaType.APPLICATION_OCTET_STREAM);
try {
for (MultipartFile file : files) {
Resource multipartFileResource = new MultipartFileResource(file.getOriginalFilename(), file.getSize(), file.getInputStream());
map.add(name, new HttpEntity<>(multipartFileResource, filePartHeaders));
}
} catch (IOException ex) {
throw new EncodeException("Cannot encode request.", ex);
}
}
/**
* Wraps an object into a {@link HttpEntity} and sets the {@code Content-type} header to
* {@code application/json}
*
* @param o
* @return
*/
private HttpEntity<?> encodeJsonObject(Object o) {
HttpHeaders jsonPartHeaders = new HttpHeaders();
jsonPartHeaders.setContentType(MediaType.APPLICATION_JSON);
return new HttpEntity<>(o, jsonPartHeaders);
}
/**
* Calls the conversion chain actually used by
* {@link org.springframework.web.client.RestTemplate}, filling the body of the request
* template.
*
* @param value
* @param requestHeaders
* @param template
* @throws EncodeException
*/
private void encodeRequest(Object value, HttpHeaders requestHeaders, RequestTemplate template) throws EncodeException {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
HttpOutputMessage dummyRequest = new HttpOutputMessageImpl(outputStream, requestHeaders);
try {
Class<?> requestType = value.getClass();
MediaType requestContentType = requestHeaders.getContentType();
for (HttpMessageConverter<?> messageConverter : converters) {
if (messageConverter.canWrite(requestType, requestContentType)) {
((HttpMessageConverter<Object>) messageConverter).write(
value, requestContentType, dummyRequest);
break;
}
}
} catch (IOException ex) {
throw new EncodeException("Cannot encode request.", ex);
}
HttpHeaders headers = dummyRequest.getHeaders();
if (headers != null) {
for (Entry<String, List<String>> entry : headers.entrySet()) {
template.header(entry.getKey(), entry.getValue());
}
}
/*
we should use a template output stream... this will cause issues if files are too big,
since the whole request will be in memory.
*/
template.body(outputStream.toByteArray(), UTF_8);
}
/**
* Minimal implementation of {@link org.springframework.http.HttpOutputMessage}. It's needed to
* provide the request body output stream to
* {@link org.springframework.http.converter.HttpMessageConverter}s
*/
private class HttpOutputMessageImpl implements HttpOutputMessage {
private final OutputStream body;
private final HttpHeaders headers;
public HttpOutputMessageImpl(OutputStream body, HttpHeaders headers) {
this.body = body;
this.headers = headers;
}
@Override
public OutputStream getBody() throws IOException {
return body;
}
@Override
public HttpHeaders getHeaders() {
return headers;
}
}
/**
* Heuristic check for multipart requests.
*
* @param type
* @return
* @see feign.Types#MAP_STRING_WILDCARD
*/
static boolean isFormRequest(Type type) {
return MAP_STRING_WILDCARD.equals(type);
}
/**
* Dummy resource class. Wraps file content and its original name.
*/
static class MultipartFileResource extends InputStreamResource {
private final String filename;
private final long size;
public MultipartFileResource(String filename, long size, InputStream inputStream) {
super(inputStream);
this.size = size;
this.filename = filename;
}
@Override
public String getFilename() {
return this.filename;
}
@Override
public InputStream getInputStream() throws IOException, IllegalStateException {
return super.getInputStream(); //To change body of generated methods, choose Tools | Templates.
}
@Override
public long contentLength() throws IOException {
return size;
}
}
}
RequestLine注解的格式是
@RequestLine(value = “POST 请求路径”)
请求方式和路径之间须有一个空格。 表单提交的话请求方式只能是post
示例代码
package com.neo.remote;
import com.neo.model.Advertiser;
import com.neo.model.Material;
import feign.Param;
import feign.RequestLine;
import org.springframework.cloud.netflix.feign.FeignClient;
import org.springframework.web.multipart.MultipartFile;
import java.util.Date;
import java.util.List;
import java.util.Map;
/**
* Created by summer on 2017/5/11.
*/
@FeignClient(name= "spring-cloud-producer")
public interface HelloRemote2 {
@RequestLine(value = "POST /hello2")
public String hello2(@Param(value = "name") String name);
@RequestLine(value = "POST /hello3")
public String hello3(
@Param(value = "name") String name,
@Param(value = "number2") Integer number,
@Param(value = "date") Date date,
@Param(value = "advertiser") Advertiser advertiser,
@Param(value = "material") Material material
@Param(value = "materials") List<Material> materials,
@Param(value = "advertiserMap") Map<String, Advertiser> advertiserMap,
@Param(value = "file1") MultipartFile file1,
@Param(value = "files") MultipartFile[] files
);
}
package com.neo;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.context.annotation.Bean;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.List;
@SpringBootApplication
@EnableDiscoveryClient
public class ProducerApplication {
public static void main(String[] args) {
SpringApplication.run(ProducerApplication.class, args);
}
@Bean
public MappingJackson2HttpMessageConverter getMappingJackson2HttpMessageConverter() {
MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter = new MappingJackson2HttpMessageConverter();
//设置日期格式
ObjectMapper objectMapper = new ObjectMapper();
SimpleDateFormat smt = new SimpleDateFormat("yyyy-MM-dd HH:ss");
objectMapper.setDateFormat(smt);
mappingJackson2HttpMessageConverter.setObjectMapper(objectMapper);
//设置中文编码格式
List<MediaType> list = new ArrayList<MediaType>();
list.add(MediaType.APPLICATION_JSON_UTF8);
mappingJackson2HttpMessageConverter.setSupportedMediaTypes(list);
return mappingJackson2HttpMessageConverter;
}
}
然后在生产者服务的控制器端,采用@RequestPart注解接收每一个参数。对于基础类型参数,你也可以使用RequestParam(好像没有必要,统一使用RequestPart注解不就好了)。
package com.neo.controller;
import com.alibaba.fastjson.JSONObject;
import com.neo.model.Advertiser;
import com.neo.model.Material;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
@RestController
public class HelloController {
@RequestMapping("/hello")
public String index(@RequestParam(value = "name")String name) {
return "hello "+name+",this is first messge";
}
@RequestMapping(value = "/hello3")
public String index3(
@RequestPart(value = "name", required = false) String name,
@RequestPart(value = "number", required = false) Integer number,
@RequestPart(value = "date", required = false) Date date,
@RequestPart(value = "advertiser", required = false) Advertiser advertiser,
@RequestPart(value = "material", required = false) Material material,
@RequestPart(value = "materials", required = false) List<Material> materials,
@RequestPart(value = "advertiserMap", required = false) Map<String, Advertiser> advertiserMap,
@RequestPart(value = "file1", required = false) MultipartFile file1,
@RequestPart(value = "files", required = false) MultipartFile[] files
) {
String result = "hello3成功进入生产者 \n";
result += " name: " + name;
result += " number: " + number;
result += " \n ------------ " + date;
result += " \n ------------" + JSONObject.toJSONString(advertiser);
result += " \n ------------ " + material;
result += " \n ------------ " + materials;
result += " \n ------------ " + advertiserMap;
return result;
}
}
其中需要注意的是 Feign默认使用的是SpringMvcContract,(同时也就是使用SpringMVC注解定义FeignClient中的接口)。但是SpringMvcContract的相关实现好像是不会调用这个Encoder。
这个Encoder的原理就是将每个参数json序列化,设置requestHeader为Multipart/form-data,采用表单请求去请求生成者提供的接口。这个方法能够同时发送多个实体文件,以及MultipartFile[]的数组.
不过在引入的过程中也遇到过一个问题。就是在测试的时候,发现快速点多个菜单切换请求时,频繁的发生服务者端的接口接收到的参数都是null,但同一个接口又不是每次都接收不到,所以排查这个问题花了很多时间。以下是一个正常的表单请求报文
表单请求的请求协议报文
content-type:multipart/form-data;charset=UTF-8;boundary=orA82eUg1kRofvTvCh8CjRwizhmJygl
accept:*/*
user-agent:Java/1.8.0_161
host:DESKTOP-A1234:9002
connection:keep-alive
content-length:295
--orA82eUg1kRofvTvCh8CjRwizhmJygl
Content-Disposition: form-data; name="userId"
Content-Type: application/json
1260
--orA82eUg1kRofvTvCh8CjRwizhmJygl
Content-Disposition: form-data; name="operateTime"
Content-Type: application/json
1553226152470
--orA82eUg1kRofvTvCh8CjRwizhmJygl--
将报错时报文复制到postman中调用生产者接口时时候发现能复现这种现象,复制正确的报文就没问题。(注意请求头最后一行之后还要留一个换行才能请求)
最后发现是表单请求的请求头中boundary的值偶尔会与body中的boundary值不一样造成无法解析的。
原因是原本引入的Encoder版本中,RequestHeader被定义为了全局变量。虽然multipartHeaders 被修饰为final。但是final修饰对象时,只是代表对象的引用不能改变,不代表里面的属性不能改变。这个bug已经提交给了github上相关项目,并且已经修复。
最后,放上参考的github地址 https://github.com/pcan/feign-client-test
访客统计
http://s04.flagcounter.com/more/Di1s/