Spring Cloud Feign实现自定义复杂对象传参

欢迎关注我的github,以后所有文章源码都会陆续更新上去

遇到的困境

现我们服务提供端有如下的根据用户查询条件获取满足条件的用户列表controller接口

@RestController
@RequestMapping("user")
public class UserController {
  @GetMaping("search")
  public List<User> search(User user) {
    // ...
    return list;
  }
}

我们在使用Feign构建远程服务请求客户端的时候,会发现Feign官方版本是不支持GET请求传递自定义的对象,当我们的请求参数很多的时候,我们只能选择以下两种方式:

  • @RequestParam注解方式,这种方式缺点很明显,查询条件越多,feign方法参数越多,而且我们是要求每一个微服务必须提供一个API jar包给其他小组使用的,这样的话User对象完全没法复用,而且纯手写@RequestParam增加了多余的开发量和出错的风险
@FeignClient("user", path = "user")
public interface UserFeign {
  @GetMapping("search")
  public List<User> search(@RequestParam("user_id") int userId, @RequestParam("user_name") String userName, @RequestParam("gender") boolean gender);
}
  • 使用Map传递参数,虽然解决了参数过多的问题,但是一般我们都不建议直接使用Map传递参数,因为没有了强类型约束,编译无法帮你保证程序的正确性和健壮,写错的风险依然存在,更致命的是服务消费端根本无法从这个API看出我到底可以传递哪些参数
Map<String, Object> userMap = new LinkedMultiValueMap();
userMap.put("user_id", 123);
userMap.put("user_name", "codingman1990");

@FeignClient("user", path = "user")
public interface UserFeign {
  @GetMapping("search")
  public List<User> search(Map<String, Object> userMap);
}

如何支持直接传递自定义对象

那么我们希望能有一种方式保持跟controller完全一致只需要传递自定义的对象,既让服务提供端开发人员爽,也让服务消费端开发人员爽,两全其美。既然Feign官方不支持,那我们就自己动手撸源码,自己来实现。

  • AnnotatedParameterProcessor feign方法参数注解处理器,总两个方法:1.获取当前参数注解类型;2.处理当前参数
    image.png

    除开第三个是我们自己的实现类外,其余三个很明显是分别处理@PathVariable,@Header以及@RequestParam注解的,那么我们就可以依葫芦画瓢,再实现一个自己注解处理器
    image.png
  • @RequestObject 首先我们自定义这样一个注解,用于在feign方法上标记自定义对象
    image.png
  • RequestObjectParameterProcessor 自定义识别@RequestObject注解的处理器。这里其实只做了一件事情,告诉context可以作为复杂查询参数对象(可以是Map,@QueryMap,当然这里是我们自定义的@RequestObject)的参数下标,后面读取参数值的时候会用到。标红的1是为了排除基本类型和包装类型参数,它们是不可以作为复杂参数的
    image.png
  • QueryMapEncoder 就只有一个方法把参数对象转换为Map
    image.png
  • RequestObjectQueryMapEncoder 自定义的map转换器。具体实现里面做了很多细节优化:
    1.支持camel转snake
    2.支持Jackson的JsonProperty注解
    3.支持枚举序列化
    4.支持JAVA8时间日期格式化
    5.支持基本类型以及包装类型数组
    6.甚至还把分页参数也兼容进来
    以上细节可以根据自己的实际使用场景取舍,执行完这些动作后,放入Map中返回,等待feign构建request的时候直接使用
/**
 * 把@RequestObject对象编码为查询参数Map对象(MethodMetadata.queryMapIndex是唯一可以自定义对象编码的契机了)
 *
 * @author ty
 */
public class RequestObjectQueryMapEncoder implements QueryMapEncoder {
    private final ConcurrentHashMap<Class<?>, List<Field>> fieldMap = new ConcurrentHashMap<>();
    private final DateTimeFormatter LOCAL_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
    private final DateTimeFormatter LOCAL_DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    /**
     * 专门应对{@link com.epet.microservices.common.web.Page}仅需要输出的属性
     */
    private static final String[] PRESENT_FIELD_NAME = new String[]{"pageSize", "curPage"};
    private static boolean JACKSON_PRESENT;

    static {
        try {
            Class.forName("com.fasterxml.jackson.annotation.JsonProperty");
            JACKSON_PRESENT = true;
        } catch (ClassNotFoundException e) {
            JACKSON_PRESENT = false;
        }
    }

    @Override
    public Map<String, Object> encode(Object object) {
        if (ClassUtils.isPrimitiveOrWrapper(object.getClass())) {
            throw new EncodeException("@ParamObject can't be primitive or wrapper type");
        }
        Class<?> clazz = object.getClass();
        List<Field> fieldList = fieldMap.computeIfAbsent(clazz, this::fieldList);
        /*List<Field> fieldList = fieldMap.get(clazz);
        if (fieldList == null) {
            fieldList = fieldList(clazz);
            fieldMap.put(clazz, fieldList);
        }*/
        Map<String, Object> map = new HashMap<>(fieldList.size());
        try {
            for (Field field : fieldList) {
                Object fieldObj = field.get(object);
                if (fieldObj == null) {
                    continue;
                }
                Class<?> fieldClazz = field.getType();
                String name;
                // 支持@JsonProperty
                if (JACKSON_PRESENT && field.getDeclaredAnnotation(JsonProperty.class) != null) {
                    name = field.getDeclaredAnnotation(JsonProperty.class).value();
                } else {
                    // 默认camel转snake
                    name = StringUtil.camel2Snake(field.getName());
                }

                // DeserializableEnum特殊处理
                if (DeserializableEnum.class.isAssignableFrom(fieldClazz)) {
                    DeserializableEnum deserializableEnum = (DeserializableEnum) fieldObj;
                    map.put(name, deserializableEnum.getValue());
                }
                // LocalDate
                else if (LocalDate.class.isAssignableFrom(fieldClazz)) {
                    String localDate = LOCAL_DATE_FORMATTER.format((LocalDate) fieldObj);
                    map.put(name, localDate);
                }
                // LocalDateTime
                else if (LocalDateTime.class.isAssignableFrom(fieldClazz)) {
                    String localDateTime = LOCAL_DATE_TIME_FORMATTER.format((LocalDateTime) fieldObj);
                    map.put(name, localDateTime);
                }
                // 基本类型数组
                else if (ClassUtil.isPrimitiveArray(fieldClazz)) {
                    // byte[]
                    if (ClassUtil.isByteArray(fieldClazz)) {
                        map.put(name, StringUtil.join((byte[]) fieldObj, ","));
                    }
                    // char[]
                    else if (ClassUtil.isCharArray(fieldClazz)) {
                        map.put(name, StringUtil.join((char[]) fieldObj, ","));
                    }
                    // short[]
                    else if (ClassUtil.isShortArray(fieldClazz)) {
                        map.put(name, StringUtil.join((short[]) fieldObj, ","));
                    }
                    // int[]
                    else if (ClassUtil.isIntArray(fieldClazz)) {
                        map.put(name, StringUtil.join((int[]) fieldObj, ","));
                    }
                    // float[]
                    else if (ClassUtil.isFloatArray(fieldClazz)) {
                        map.put(name, StringUtil.join((float[]) fieldObj, ","));
                    }
                    // long[]
                    else if (ClassUtil.isLongArray(fieldClazz)) {
                        map.put(name, StringUtil.join((long[]) fieldObj, ","));
                    }
                    // double[]
                    else if (ClassUtil.isDoubleArray(fieldClazz)) {
                        map.put(name, StringUtil.join((double[]) fieldObj, ","));
                    }
                }
                // 基本包装类型数组
                else if (ClassUtil.isPrimitiveWrapperArray(fieldClazz)) {
                    map.put(name, StringUtil.join((Object[]) fieldObj, ","));
                }
                // String[]
                else if (String[].class.isAssignableFrom(fieldClazz)) {
                    map.put(name, StringUtil.join((String[]) fieldObj, ","));
                } else {
                    map.put(name, fieldObj);
                }
            }
            return map;
        } catch (IllegalAccessException e) {
            throw new EncodeException("Fail encode ParamObject into query Map", e);
        }
    }

    private List<Field> fieldList(Class<?> clazz) {
        List<Field> fields = new ArrayList<>();
        for (Field field : clazz.getDeclaredFields()) {
            if (illegalField(field)) {
                fields.add(field);
            }
        }
        // 支持继承的父类属性
        for (Class<?> superClazz : ClassUtils.getAllSuperclasses(clazz)) {
            if (!Object.class.equals(superClazz)) {
                // Page class
                boolean isPage = superClazz.equals(Page.class);
                Arrays.stream(superClazz.getDeclaredFields())
                        .filter(field -> !isPage || (isPage && Arrays.stream(PRESENT_FIELD_NAME).anyMatch(s -> s.equalsIgnoreCase(field.getName()))))
                        .forEach(field -> {
                            if (illegalField(field)) {
                                fields.add(field);
                            }
                        });
                /*for (Field field : superClazz.getDeclaredFields()) {
                    if (illegalField(field)) {
                        fields.add(field);
                    }
                }*/
            }
        }
        return fields;
    }

    private boolean illegalField(Field field) {
        Class<?> fieldType = field.getType();
        // 暂时只能支持一层属性编码,所以必须是基础类型或者包装类型,基础类型或者包装类型数组,String,String[],DeserializableEnum类型
        // 2019-3-8 fix:新增JAVA8 LocalDate和LocalDateTime支持
        if (ClassUtils.isPrimitiveOrWrapper(fieldType)
                || ClassUtil.isPrimitiveOrWrapperArray(fieldType)
                || String.class.isAssignableFrom(fieldType) || String[].class.isAssignableFrom(fieldType)
                || DeserializableEnum.class.isAssignableFrom(fieldType)
                || LocalDateTime.class.isAssignableFrom(fieldType) || LocalDate.class.isAssignableFrom(fieldType)
                // 2019-4-15 fix:新增BigDecimal和BigInteger支持
                || BigDecimal.class.isAssignableFrom(fieldType) || BigInteger.class.isAssignableFrom(fieldType)) {
            if (!field.isAccessible()) {
                field.setAccessible(true);
            }
            return true;
        }
        return false;
    }
}
  • FeignRequestObjectAutoConfiguration 处理器和转换器都写好了,我们现在需要覆盖feign默认的配置(查看FeignClientsConfiguration源码即可理解),转而使用我们自定义的。两个目的:
    1.使用feign.request.object属性可以开启关闭,默认开启
    2.覆盖默认的SpringMvcContract,内部增加RequestObjectParameterProcessor
    3.覆盖默认Feign.Builder,使用我们自定义的RequestObjectQueryMapEncoder
/**
 * 为支持复杂对象类型查询参数自动配置类
 *
 * @author ty
 */
@Configuration
@ConditionalOnClass(Feign.class)
@ConditionalOnProperty(prefix = "feign.request", name = "object", havingValue = "true", matchIfMissing = true)
public class FeignRequestObjectAutoConfiguration {
    /**
     * 覆盖FeignClientsConfiguration默认
     */
    @Bean
    public Contract feignContract(ConversionService feignConversionService) {
        List<AnnotatedParameterProcessor> annotatedArgumentResolvers = new ArrayList<>();
        annotatedArgumentResolvers.add(new PathVariableParameterProcessor());
        annotatedArgumentResolvers.add(new RequestParamParameterProcessor());
        annotatedArgumentResolvers.add(new RequestHeaderParameterProcessor());
        // 新增的处理复杂对象类型查询参数
        annotatedArgumentResolvers.add(new RequestObjectParameterProcessor());
        return new SpringMvcContract(annotatedArgumentResolvers, feignConversionService);
    }

    /**
     * 覆盖FeignClientsConfiguration默认
     */
    @Configuration
    @ConditionalOnClass({HystrixCommand.class, HystrixFeign.class})
    protected static class HystrixFeignConfiguration {
        @Bean
        @Scope("prototype")
        @ConditionalOnProperty(name = "feign.hystrix.enabled")
        public Feign.Builder feignHystrixBuilder() {
            HystrixFeign.Builder builder = HystrixFeign.builder();
            builder.queryMapEncoder(new RequestObjectQueryMapEncoder());
            return builder;
        }
    }
}
  • spring.factories 开启自动配置
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.epet.microservices.common.feign.FeignRequestObjectAutoConfiguration

使用

对比之前的@RequestParam和Map用法,方法参数变少了,User对象复用了,对服务提供端和消费端都更方便了

@FeignClient("user", path = "user")
public interface UserFeign {
  @GetMapping("search")
  public List<User> search(@RequestObject User user);
}

后续

最近在调研spring cloud版本升级,发现新版的Feign也支持了自定义对象传参,实现方式大同小异

  • @SpringQueryMap 等同于我们的@RequestObject
    image.png
  • QueryMapParameterProcessor 等同于我们的RequestObjectParameterProcessor
    image.png
  • FieldQueryMapEncoder和BeanQueryMapEncoder 等同于我们的RequestObjectQueryMapEncoder
    image.png

    个人觉得新版虽然官方支持了,但是功能却是很弱,他只是简单的反射获取属性名称和值,像我们前面提到的枚举,日期,camel转snake等业务场景无法满足。只要能够理解实现原理,其实实现自己的方案搭配自己的内部框架使用起来会更方便和强大。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,084评论 6 503
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,623评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,450评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,322评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,370评论 6 390
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,274评论 1 300
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,126评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,980评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,414评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,599评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,773评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,470评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,080评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,713评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,852评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,865评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,689评论 2 354