Spring Cloud Feign實現(xiàn)自定義復雜對象傳參

歡迎關注我的github睹耐,以后所有文章源碼都會陸續(xù)更新上去

遇到的困境

現(xiàn)我們服務提供端有如下的根據(jù)用戶查詢條件獲取滿足條件的用戶列表controller接口

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

我們在使用Feign構建遠程服務請求客戶端的時候匈辱,會發(fā)現(xiàn)Feign官方版本是不支持GET請求傳遞自定義的對象愉粤,當我們的請求參數(shù)很多的時候,我們只能選擇以下兩種方式:

  • @RequestParam注解方式泼掠,這種方式缺點很明顯怔软,查詢條件越多,feign方法參數(shù)越多择镇,而且我們是要求每一個微服務必須提供一個API jar包給其他小組使用的挡逼,這樣的話User對象完全沒法復用,而且純手寫@RequestParam增加了多余的開發(fā)量和出錯的風險
@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傳遞參數(shù)腻豌,雖然解決了參數(shù)過多的問題家坎,但是一般我們都不建議直接使用Map傳遞參數(shù),因為沒有了強類型約束吝梅,編譯無法幫你保證程序的正確性和健壯虱疏,寫錯的風險依然存在,更致命的是服務消費端根本無法從這個API看出我到底可以傳遞哪些參數(shù)
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完全一致只需要傳遞自定義的對象憔涉,既讓服務提供端開發(fā)人員爽订框,也讓服務消費端開發(fā)人員爽,兩全其美兜叨。既然Feign官方不支持穿扳,那我們就自己動手擼源碼,自己來實現(xiàn)国旷。

  • AnnotatedParameterProcessor feign方法參數(shù)注解處理器矛物,總兩個方法:1.獲取當前參數(shù)注解類型;2.處理當前參數(shù)
    image.png

    除開第三個是我們自己的實現(xiàn)類外跪但,其余三個很明顯是分別處理@PathVariable,@Header以及@RequestParam注解的履羞,那么我們就可以依葫蘆畫瓢,再實現(xiàn)一個自己注解處理器
    image.png
  • @RequestObject 首先我們自定義這樣一個注解屡久,用于在feign方法上標記自定義對象
    image.png
  • RequestObjectParameterProcessor 自定義識別@RequestObject注解的處理器忆首。這里其實只做了一件事情,告訴context可以作為復雜查詢參數(shù)對象(可以是Map,@QueryMap被环,當然這里是我們自定義的@RequestObject)的參數(shù)下標糙及,后面讀取參數(shù)值的時候會用到。標紅的1是為了排除基本類型和包裝類型參數(shù)筛欢,它們是不可以作為復雜參數(shù)的
    image.png
  • QueryMapEncoder 就只有一個方法把參數(shù)對象轉(zhuǎn)換為Map
    image.png
  • RequestObjectQueryMapEncoder 自定義的map轉(zhuǎn)換器浸锨。具體實現(xiàn)里面做了很多細節(jié)優(yōu)化:
    1.支持camel轉(zhuǎn)snake
    2.支持Jackson的JsonProperty注解
    3.支持枚舉序列化
    4.支持JAVA8時間日期格式化
    5.支持基本類型以及包裝類型數(shù)組
    6.甚至還把分頁參數(shù)也兼容進來
    以上細節(jié)可以根據(jù)自己的實際使用場景取舍,執(zhí)行完這些動作后版姑,放入Map中返回柱搜,等待feign構建request的時候直接使用
/**
 * 把@RequestObject對象編碼為查詢參數(shù)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轉(zhuǎn)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);
                }
                // 基本類型數(shù)組
                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, ","));
                    }
                }
                // 基本包裝類型數(shù)組
                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();
        // 暫時只能支持一層屬性編碼,所以必須是基礎類型或者包裝類型,基礎類型或者包裝類型數(shù)組,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 處理器和轉(zhuǎn)換器都寫好了,我們現(xiàn)在需要覆蓋feign默認的配置(查看FeignClientsConfiguration源碼即可理解)剥险,轉(zhuǎn)而使用我們自定義的聪蘸。兩個目的:
    1.使用feign.request.object屬性可以開啟關閉,默認開啟
    2.覆蓋默認的SpringMvcContract表制,內(nèi)部增加RequestObjectParameterProcessor
    3.覆蓋默認Feign.Builder宇姚,使用我們自定義的RequestObjectQueryMapEncoder
/**
 * 為支持復雜對象類型查詢參數(shù)自動配置類
 *
 * @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());
        // 新增的處理復雜對象類型查詢參數(shù)
        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用法,方法參數(shù)變少了夫凸,User對象復用了浑劳,對服務提供端和消費端都更方便了

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

后續(xù)

最近在調(diào)研spring cloud版本升級,發(fā)現(xiàn)新版的Feign也支持了自定義對象傳參夭拌,實現(xiàn)方式大同小異

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

    個人覺得新版雖然官方支持了魔熏,但是功能卻是很弱,他只是簡單的反射獲取屬性名稱和值鸽扁,像我們前面提到的枚舉蒜绽,日期,camel轉(zhuǎn)snake等業(yè)務場景無法滿足桶现。只要能夠理解實現(xiàn)原理躲雅,其實實現(xiàn)自己的方案搭配自己的內(nèi)部框架使用起來會更方便和強大。
最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末骡和,一起剝皮案震驚了整個濱河市相赁,隨后出現(xiàn)的幾起案子相寇,更是在濱河造成了極大的恐慌,老刑警劉巖钮科,帶你破解...
    沈念sama閱讀 219,490評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件唤衫,死亡現(xiàn)場離奇詭異,居然都是意外死亡绵脯,警方通過查閱死者的電腦和手機佳励,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,581評論 3 395
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來蛆挫,“玉大人赃承,你說我怎么就攤上這事°睬郑” “怎么了瞧剖?”我有些...
    開封第一講書人閱讀 165,830評論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長畜挨。 經(jīng)常有香客問我筒繁,道長,這世上最難降的妖魔是什么巴元? 我笑而不...
    開封第一講書人閱讀 58,957評論 1 295
  • 正文 為了忘掉前任毡咏,我火速辦了婚禮,結(jié)果婚禮上逮刨,老公的妹妹穿的比我還像新娘呕缭。我一直安慰自己,他們只是感情好修己,可當我...
    茶點故事閱讀 67,974評論 6 393
  • 文/花漫 我一把揭開白布恢总。 她就那樣靜靜地躺著,像睡著了一般睬愤。 火紅的嫁衣襯著肌膚如雪片仿。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,754評論 1 307
  • 那天尤辱,我揣著相機與錄音砂豌,去河邊找鬼。 笑死光督,一個胖子當著我的面吹牛阳距,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播结借,決...
    沈念sama閱讀 40,464評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼筐摘,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起咖熟,我...
    開封第一講書人閱讀 39,357評論 0 276
  • 序言:老撾萬榮一對情侶失蹤圃酵,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后球恤,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體辜昵,經(jīng)...
    沈念sama閱讀 45,847評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡荸镊,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,995評論 3 338
  • 正文 我和宋清朗相戀三年咽斧,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片躬存。...
    茶點故事閱讀 40,137評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡张惹,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出岭洲,到底是詐尸還是另有隱情宛逗,我是刑警寧澤,帶...
    沈念sama閱讀 35,819評論 5 346
  • 正文 年R本政府宣布盾剩,位于F島的核電站雷激,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏告私。R本人自食惡果不足惜屎暇,卻給世界環(huán)境...
    茶點故事閱讀 41,482評論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望驻粟。 院中可真熱鬧根悼,春花似錦、人聲如沸蜀撑。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,023評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽酷麦。三九已至矿卑,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間沃饶,已是汗流浹背母廷。 一陣腳步聲響...
    開封第一講書人閱讀 33,149評論 1 272
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留绍坝,地道東北人徘意。 一個月前我還...
    沈念sama閱讀 48,409評論 3 373
  • 正文 我出身青樓,卻偏偏與公主長得像轩褐,于是被迫代替她去往敵國和親椎咧。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 45,086評論 2 355