Spring Cloud Gateway 自定義 ReadBodyPredicateFactory 實現動態路由

在互網企業當中網關的重要性我就不再贅述了,相信大家都比較清楚。我們公司網關採用的是 Spring Cloud Gateway。並且是通過自定義 RouteLocator 來實現動態路由的。路由規則是請求參數裏面的 bizType,比如接收 JSON 格式體的請求對象並且業務方請求的是創建支付訂單接口,下面就是業務方需要傳遞的參數:

{
"bizType" : "createOrder",
.... 其它業務參數
}

下面就是讀取 requestBody 裏面的主動參數,然後解析請求對象裏面的 bizType,來決定它的路由地址:

由於歷史原因,網關不僅需要 application/json 這種 Json 格式 MediaType 的請求對象,還需要支持 MediaType 爲application/x-www-form-urlencoded 這種請求。而網關之前的處理方式比較粗暴,當有請求來臨的時候因爲有可能是 application/x-www-form-urlencoded所以直接 URLDecoder :

將請求中特殊字符轉義

   public static String requestDecode(String requestBody){
try {
return URLDecoder.decode(convertStringForAdd(requestBody), "UTF-8");
} catch (UnsupportedEncodingException e) {
log.error("requestBody decode error: {}", e);
}
return requestBody;
}

這種處理方式導致的問題就是如果 JSON 請求參數裏面帶有 % 就會報以下錯誤:


針對這種問題其實有兩種處理方式:

  • 把對象進行轉換成 JSON,如果轉換成功就OK,否則就先 UrlEncode,然後再用 & 分離 Key/value。

  • 還有一種方式就是在進行讀取 requestBody 之前獲取到它的 MediaType

上面兩種方式當然就第二種方式更加優雅。下面我們來想一想如何在讀取 requestBody 之前獲取到 Http 請求的 MediaType 的。

當我們在路由調用 readBody 的時候其實就是調用下面的方法:

org.springframework.cloud.gateway.route.builder.PredicateSpec#readBody

public <T> BooleanSpec readBody(Class<T> inClass, Predicate<T> predicate) {
return asyncPredicate(getBean(ReadBodyPredicateFactory.class)
.applyAsync(c -> c.setPredicate(inClass, predicate)));
}

Spring Cloud 中 ReadBodyPredicateFactory 的實現方式如下:

public class ReadBodyPredicateFactory
extends AbstractRoutePredicateFactory<ReadBodyPredicateFactory.Config> {
...

@Override
@SuppressWarnings("unchecked")
public AsyncPredicate<ServerWebExchange> applyAsync(Config config) {
return exchange -> {
Class inClass = config.getInClass();

Object cachedBody = exchange.getAttribute(CACHE_REQUEST_BODY_OBJECT_KEY);
Mono<?> modifiedBody;
// We can only read the body from the request once, once that happens if we
// try to read the body again an exception will be thrown. The below if/else
// caches the body object as a request attribute in the ServerWebExchange
// so if this filter is run more than once (due to more than one route
// using it) we do not try to read the request body multiple times
if (cachedBody != null) {
try {
boolean test = config.predicate.test(cachedBody);
exchange.getAttributes().put(TEST_ATTRIBUTE, test);
return Mono.just(test);
}
catch (ClassCastException e) {
if (log.isDebugEnabled()) {
log.debug("Predicate test failed because class in predicate "
+ "does not match the cached body object", e);
}
}
return Mono.just(false);
}
else {
return ServerWebExchangeUtils.cacheRequestBodyAndRequest(exchange,
(serverHttpRequest) -> ServerRequest
.create(exchange.mutate().request(serverHttpRequest)
.build(), messageReaders)
.bodyToMono(inClass)
.doOnNext(objectValue -> exchange.getAttributes()
.put(CACHE_REQUEST_BODY_OBJECT_KEY, objectValue))
.map(objectValue -> config.getPredicate()
.test(objectValue)));
}
};
}
...
}

我們可以看到這裏使用了對象 ServerWebExchange,而這個對象就是 Spring webflux 定義的 Http 請求對象。上面的代碼邏輯是判斷 exchange 中的屬性中是否包含屬性爲 cachedRequestBodyObject 的 requestBody 對象,如果不包含就解析並添加cachedRequestBodyObject 到 exchange。在這裏可以看到我們對 ReadBodyPredicateFactory 對象並不可以擴展,所以唯一的方式就是繼承這個類,因爲在讀取 MediaType 的時候參數只有 requestBody:String,所以我們只有通過 ThreadLocal 來進行參數傳遞。在真正 PredicateSpec#readBody獲取到 MediaType,就可以很好的解析 requestBody。下面就是具體的代碼實現:

1、GatewayContext.java

GatewayContext 定義網關上下文,保存 MediaType 用於 readBody 時解析。

GatewayContext.java

@Getter
@Setter
public class GatewayContext {

private MediaType mediaType;

}

2、GatewayContextHolder.java

GatewayContextHolder 通過 ThreadLocal 傳遞 GatewayContext ,在請求對象解析時使用。

GatewayContextHolder.java

public class GatewayContextHolder {

private static Logger logger = LoggerFactory.getLogger(GatewayContextHolder.class);

private static ThreadLocal<GatewayContext> tl = new ThreadLocal<>();

public static GatewayContext get() {
if (tl.get() == null) {
logger.error("gateway context not exist");
throw new RuntimeException("gateway context is null");
}
return tl.get();
}

public static void set(GatewayContext sc) {
if (tl.get() != null) {
logger.error("gateway context not null");
tl.remove();
}
tl.set(sc);
}

public static void cleanUp() {
try {
if (tl.get() != null) {
tl.remove();
}
} catch (Exception e) {
logger.error(e.getMessage(), e);
}
}

}

3、CustomReadBodyPredicateFactory.java

CustomReadBodyPredicateFactory 繼承 ReadBodyPredicateFactory ,在原有解析 requestBody 的情況下,添加獲取 MediaType 的邏輯。

CustomReadBodyPredicateFactory.java

public class CustomReadBodyPredicateFactory extends ReadBodyPredicateFactory {

protected static final Log log = LogFactory.getLog(CustomReadBodyPredicateFactory.class);

private static final String TEST_ATTRIBUTE = "read_body_predicate_test_attribute";

private static final String CACHE_REQUEST_BODY_OBJECT_KEY = "cachedRequestBodyObject";

private static final List<HttpMessageReader<?>> messageReaders = HandlerStrategies
.withDefaults().messageReaders();

public CustomReadBodyPredicateFactory() {
super();
}

@Override
public AsyncPredicate<ServerWebExchange> applyAsync(ReadBodyPredicateFactory.Config config) {
return exchange -> {
Class inClass = config.getInClass();

Object cachedBody = exchange.getAttribute(CACHE_REQUEST_BODY_OBJECT_KEY);

// 獲取 MediaType
MediaType mediaType = exchange.getRequest().getHeaders().getContentType();
GatewayContext context = new GatewayContext();
context.setMediaType(mediaType);
GatewayContextHolder.set(context);
// We can only read the body from the request once, once that happens if we
// try to read the body again an exception will be thrown. The below if/else
// caches the body object as a request attribute in the ServerWebExchange
// so if this filter is run more than once (due to more than one route
// using it) we do not try to read the request body multiple times
if (cachedBody != null) {
try {
boolean test = config.getPredicate().test(cachedBody);
exchange.getAttributes().put(TEST_ATTRIBUTE, test);
return Mono.just(test);
}
catch (ClassCastException e) {
if (log.isDebugEnabled()) {
log.debug("Predicate test failed because class in predicate "
+ "does not match the cached body object", e);
}
}
return Mono.just(false);
}
else {
return ServerWebExchangeUtils.cacheRequestBodyAndRequest(exchange,
(serverHttpRequest) -> ServerRequest
.create(exchange.mutate().request(serverHttpRequest)
.build(), messageReaders)
.bodyToMono(inClass)
.doOnNext(objectValue -> exchange.getAttributes()
.put(CACHE_REQUEST_BODY_OBJECT_KEY, objectValue))
.map(objectValue -> config.getPredicate()
.test(objectValue)));
}
};
}

}

4、GatewayBeanFactoryPostProcessor.java

通過 Spring framework 的 BeanDefinitionRegistryPostProcessor 擴展在實例化對象之前,把 readBody 的原有操作類ReadBodyPredicateFactory 刪除,替換成我們自定義類 CustomReadBodyPredicateFactory

GatewayBeanFactoryPostProcessor.java

@Component
public class GatewayBeanFactoryPostProcessor implements BeanDefinitionRegistryPostProcessor {

@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
// do nothing
}

@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException {
registry.removeBeanDefinition("readBodyPredicateFactory");
BeanDefinition beanDefinition = BeanDefinitionBuilder.rootBeanDefinition(CustomReadBodyPredicateFactory.class)
.setScope(BeanDefinition.SCOPE_SINGLETON)
.setRole(BeanDefinition.ROLE_SUPPORT)
.getBeanDefinition();
registry.registerBeanDefinition("readBodyPredicateFactory", beanDefinition);
}

}

下面就是修改後的自定義路由規則。

public RouteLocatorBuilder.Builder route(RouteLocatorBuilder.Builder builder) {
return builder.route(r -> r.readBody(String.class, requestBody -> {
MediaType mediaType = GatewayContextHolder.get().getMediaType();
// 通過 mediaType 解析 requestBody 然後從解析後的對象獲取路由規則
...
);
}

後面就不會報之前的異常了。

各大互聯網企業Java面試題彙總,如何成功拿到百度的offer

阿里面試官:HashMap中的8和6的關係(1)

Spring反射+策略模式Demo

Java之Redis隊列+Websocket+定時器實現跑馬燈實時刷新

深入理解JVM垃圾收集機制,下次面試你準備好了嗎

JAVA架構師成功拿到阿里P7offer,全靠這份2020最新面試題

大專程序員面試了25家公司,總結出來的痛苦經驗!

程序員的十個升職的好習慣





交流/資源分享

OMG關注它



點亮 ,告訴大家你也在看 


本文分享自微信公衆號 - Java高級架構師(java968)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章