该方法不需要将多个自定义实体参数pojos封装到单个Pojo或单个Map中,可以保留原有接口结构

请注意:该方案主要的适用场景是针对原有同一项目不同service层代码拆分成服务提供方和消费方的情况。若是新写的项目,按官方的用意是不建议使用多个pojo实体的。

   最近公司的项目要重构为基于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;
        }

    }

}

然后在feignClient接口中使用@RequestLine标记请求路径,使用@Param注解标记每一个请求参数

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

Flag Counter
访客统计
http://s04.flagcounter.com/more/Di1s/

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐