Spring Cloud Feign 如何使用物件引數

呆煒發表於2022-02-22

概述

Spring Cloud Feign 用於微服務的封裝,通過介面代理的實現方式讓微服務呼叫變得簡單,讓微服務的使用上如同本地服務。但是它在傳參方面不是很完美。在使用 Feign 代理 GET 請求時,對於簡單引數(基本型別、包裝器、字串)的使用上沒有困難,但是在使用物件傳參時卻無法自動的將物件包含的欄位解析出來。

如果你沒耐心看完,直接跳到最後一個標題跟著操作就行了。

@RequestBody

物件傳參是很常見的操作,雖然可以通過一個個引數傳遞來替代,但是那樣就太麻煩了,所以必須解決這個問題。

我在網上看到有人用 @RequestBody 來註解物件引數,我在嘗試後發現確實可用。這個方案實際使用 body 體裝了引數(使用的是 GET 請求),但是這個方案有些問題:

  1. 註解需要在 consumer 和 provider 兩邊都有,這造成了麻煩
  2. 使用介面測試工具 Postman 無法跑通微服務,後來發現是因為 body 體的格式選擇不正確,這個格式不是通常的表單或者路徑拼接,而是 GraphQL。我沒有研究過這種格式應該如何填寫引數,但是 Postman 上並沒有給出像表單那樣方便的格式,這對於測試是很不利的。

@SpringQueryMap

於是我繼續尋找答案,發現可以使用 @SpringQueryMap 僅新增在 consumer 的引數上就能自動對 Map 型別引數編碼再拼接到 URL 上。而我用的高版本的 Feign,可以直接把物件編碼。

可是正當我以為得到正解時,卻發現還是有問題:

我明明在 Date 型別的欄位上加上了 @DateTimeFormat(pattern = "yyyy-MM-dd"),卻沒有生效,他用自己的方式進行了編碼(或者說序列化),而且官方確實沒有提供這種格式化方式。

又一番找尋後發現了一位大佬自己實現了一個註解轉換替代 @SpringQueryMap,並實現了豐富的格式化功能 ORZ(原文連結:Spring Cloud Feign實現自定義複雜物件傳參),只能說佩服佩服。但是我沒有那樣的技術,又不太想複製貼上他那一大堆的程式碼,因為出了問題也不好改,所以我還是想堅持最大限度地使用框架,最小限度的給框架填坑。

QueryMapEncoder

終於功夫不費有心人,我發現了 Feign 預留的自定義編碼器介面 QueryMapEncoder,框架提供了兩個實現:

  • FieldQueryMapEncoder
  • BeanQueryMapEncoder

雖然這兩個實現不能滿足我的要求,但是隻要稍加修改寫一個自己的實現類就行了,於是我在 FieldQueryMapEncoder 的基礎上修改,僅僅新增了一個方法,小改了一個方法就實現了功能。

原理:Feign 其實還是用 Map<String, Object> 進行的編碼,編碼方式也很簡單,String 是 key,Object 是 value。最開始的方式就是用 Object 的 toString() 方法把引數編碼,這也是為什麼 Date 欄位會變成一個預設的時間格式,因為 toString() 根本和 @DateTimeFormat 沒有關係。而高版本使用編碼器實現了物件傳參,實際實際上是通過簡單的反射獲取物件的後設資料,再放到 Map 中。

上面的原理都能從 @DateTimeFormat 的註釋和編碼器的原始碼中得到答案。

我們要做的就是自定義一個編碼器,實現在後設資料放入 Map 之前根據需要把欄位變成我們想要的字串。下面是我實現的程式碼,供參考:

package com.example.billmanagerfront.config.encoder;

import java.lang.reflect.Field;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TimeZone;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;

import org.springframework.format.annotation.DateTimeFormat;

import feign.Param;
import feign.QueryMapEncoder;
import feign.codec.EncodeException;

public class PowerfulQueryMapEncoder implements QueryMapEncoder {
    private final Map<Class<?>, ObjectParamMetadata> classToMetadata = new ConcurrentHashMap<>();

    @Override
    public Map<String, Object> encode(Object object) throws EncodeException {
        ObjectParamMetadata metadata = classToMetadata.computeIfAbsent(object.getClass(),
                ObjectParamMetadata::parseObjectType);

        return metadata.objectFields.stream()
                .map(field -> this.FieldValuePair(object, field))
                .filter(fieldObjectPair -> fieldObjectPair.right.isPresent())
                .collect(Collectors.toMap(this::fieldName, this::fieldObject));

    }

    private String fieldName(Pair<Field, Optional<Object>> pair) {
        Param alias = pair.left.getAnnotation(Param.class);
        return alias != null ? alias.value() : pair.left.getName();
    }

    // 可擴充套件為策略模式,支援更多的格式轉換
    private Object fieldObject(Pair<Field, Optional<Object>> pair) {
        Object fieldObject = pair.right.get();
        DateTimeFormat dateTimeFormat = pair.left.getAnnotation(DateTimeFormat.class);
        if (dateTimeFormat != null) {
            DateFormat format = new SimpleDateFormat(dateTimeFormat.pattern());
            format.setTimeZone(TimeZone.getTimeZone("GMT+8")); // TODO: 最好不要寫死時區
            fieldObject = format.format(fieldObject);
        } else {

        }
        return fieldObject;
    }

    private Pair<Field, Optional<Object>> FieldValuePair(Object object, Field field) {
        try {
            return Pair.pair(field, Optional.ofNullable(field.get(object)));
        } catch (IllegalAccessException e) {
            throw new EncodeException("Failure encoding object into query map", e);
        }
    }

    private static class ObjectParamMetadata {

        private final List<Field> objectFields;

        private ObjectParamMetadata(List<Field> objectFields) {
            this.objectFields = Collections.unmodifiableList(objectFields);
        }

        private static ObjectParamMetadata parseObjectType(Class<?> type) {
            List<Field> allFields = new ArrayList<Field>();

            for (Class<?> currentClass = type; currentClass != null; currentClass = currentClass.getSuperclass()) {
                Collections.addAll(allFields, currentClass.getDeclaredFields());
            }

            return new ObjectParamMetadata(allFields.stream()
                    .filter(field -> !field.isSynthetic())
                    .peek(field -> field.setAccessible(true))
                    .collect(Collectors.toList()));
        }
    }

    private static class Pair<T, U> {
        private Pair(T left, U right) {
            this.right = right;
            this.left = left;
        }

        public final T left;
        public final U right;

        public static <T, U> Pair<T, U> pair(T left, U right) {
            return new Pair<>(left, right);
        }

    }
}

加註釋的方法,就是我後新增進去的。encode 方法的最後一行稍微修改了一下,引用了我加的方法,其他都是直接借鑑過來的(本來我想更偷懶,直接繼承一下子,但是它用了私有的內部類導致我只能全部複製貼上了)。

解決方案

  1. 不用引入其他的 Feign 依賴,保證有下面這個就行(看網上其他方法還要引入特定依賴,要對應版本號,挺麻煩的)
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
  1. 編寫上面那樣的類,你可以直接複製過去改個包名就行,如果還需要除了 Date 以外的格式化,請看註釋和文章分析。其中我對日期的格式化,直接使用了 @DateTimeFormat 提供的模式,和 Spring 保持了一致。
  2. 編寫一個 Feign 配置類,將剛自定義的編碼器註冊進去。細節我就不多說了:
package com.example.billmanagerfront.config;

import com.example.billmanagerfront.config.encoder.PowerfulQueryMapEncoder;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import feign.Feign;
import feign.Retryer;

@Configuration
public class FeignConfig {
    @Bean
    public Feign.Builder feignBuilder() {
        return Feign.builder()
                .queryMapEncoder(new PowerfulQueryMapEncoder())
                .retryer(Retryer.NEVER_RETRY);
    }
}
  1. Feign 代理介面中宣告使用這個配置類,細節不談
package com.example.billmanagerfront.client;

import java.util.List;

import com.example.billmanagerfront.config.FeignConfig;
import com.example.billmanagerfront.pojo.Bill;
import com.example.billmanagerfront.pojo.BillType;

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.cloud.openfeign.SpringQueryMap;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

@FeignClient(name = "BILL-MANAGER", path = "bill", configuration = FeignConfig.class)
public interface BillClient {
    @GetMapping("list")
    List<Bill> list(@SpringQueryMap(true) Bill b);

    @GetMapping("type")
    List<BillType> type();

    @DeleteMapping("delete/{id}")
    public String delete(@PathVariable("id") Long id);
}

尊重原創,轉載請標明出處。

應該就這些了。

相關文章