API網(wǎng)關(guān)才是大勢(shì)所趨?SpringCloud Gateway保姆級(jí)入門教程
什么是微服務(wù)網(wǎng)關(guān)
大家好,我是蠻三刀。
SpringCloud Gateway是Spring全家桶中一個(gè)比較新的項(xiàng)目,它到底是啥來(lái)頭呢?Spring社區(qū)是這么介紹它的:
該項(xiàng)目借助Spring WebFlux的能力,打造了一個(gè)API網(wǎng)關(guān)。旨在提供一種簡(jiǎn)單而有效的方法來(lái)作為API服務(wù)的路由,并為它們提供各種增強(qiáng)功能,例如:安全性,監(jiān)控和可伸縮性。
而在真實(shí)的業(yè)務(wù)領(lǐng)域,我們經(jīng)常用SpringCloud Gateway來(lái)做微服務(wù)API網(wǎng)關(guān),如果你不理解微服務(wù)網(wǎng)關(guān)和傳統(tǒng)網(wǎng)關(guān)的區(qū)別,可以閱讀此篇文章 Service Mesh和API Gateway關(guān)系深度探討[1] 來(lái)了解兩者的定位區(qū)別。
以我粗淺的理解,傳統(tǒng)的API網(wǎng)關(guān),往往是獨(dú)立于各個(gè)后端服務(wù),請(qǐng)求先打到獨(dú)立的網(wǎng)關(guān)層,再打到服務(wù)集群。而微服務(wù)網(wǎng)關(guān),將流量從南北走向改為東西走向(見下圖),微服務(wù)網(wǎng)關(guān)和后端服務(wù)是(通常)是在同一個(gè)容器中的,所以這種網(wǎng)關(guān)的用法也有個(gè)別名,叫做Gateway Sidecar。

為啥叫Sidecar,這個(gè)詞應(yīng)該怎么理解呢,吃雞里的三蹦子見過(guò)沒:

摩托車是你的后端服務(wù),而旁邊掛著的額外座椅就是微服務(wù)網(wǎng)關(guān),他是依附于后端服務(wù)的(一般是指兩個(gè)進(jìn)程在同一個(gè)容器中),是不是生動(dòng)形象了一些。
由于本人才疏學(xué)淺,對(duì)于微服務(wù)相關(guān)概念理解上難免會(huì)有偏差。就不在此詳細(xì)講述原理性的文字了。
本文只探討SpringCloud Gateway的入門搭建和實(shí)戰(zhàn)踩坑。 如果小伙伴們對(duì)原理感興趣,可以等后續(xù)原理分析文章。
文章目錄
手把手造一個(gè)網(wǎng)關(guān) 引入pom依賴 編寫yml文件 接口轉(zhuǎn)義問(wèn)題 獲取請(qǐng)求體(Request Body) 踩坑實(shí)戰(zhàn) 獲取客戶端真實(shí)IP 尾綴匹配 總結(jié)
原創(chuàng)不易,歡迎關(guān)注我的技術(shù)公眾號(hào):后端技術(shù)漫談
源代碼
完整項(xiàng)目源代碼已經(jīng)收錄到我的Github:
https://github.com/qqxx6661/springcloud_gateway_demo
手把手造一個(gè)網(wǎng)關(guān)
引入pom依賴
我使用了spring-boot 2.2.5.RELEASE作為parent依賴:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.5.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
在dependencyManagement中,我們需要指定sringcloud的版本,以便保證我們能夠引入我們想要的SpringCloud Gateway版本,所以需要用到dependencyManagement:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Hoxton.SR8</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
最后,是在dependency中引入spring-cloud-starter-gateway:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
如此一來(lái),我們便引入了2.2.5.RELEASE版本的網(wǎng)關(guān):

此外,請(qǐng)檢查一下你的依賴中是否含有spring-boot-starter-web,如果有,請(qǐng)干掉它。因?yàn)槲覀兊腟pringCloud Gateway是一個(gè)netty+webflux實(shí)現(xiàn)的web服務(wù)器,和Springboot Web本身就是沖突的。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
做到這里,實(shí)際上你的項(xiàng)目就已經(jīng)可以啟動(dòng)了,運(yùn)行SpringcloudGatewayApplication,得到結(jié)果如圖:

編寫yml文件
SpringBoot的核心概念是約定優(yōu)先于配置,在以前初學(xué)Spring時(shí),一直不理解這句話的意思,在使用SpringCloud Gateway時(shí),更加深入的理解了這句話。在默認(rèn)情況下,你不需要任何的配置,就能夠運(yùn)行起來(lái)最基本的網(wǎng)關(guān)。針對(duì)你之后特定的需求,再去追加配置。
而SpringCloud Gateway更強(qiáng)大的一點(diǎn)就是內(nèi)置了非常多的默認(rèn)功能實(shí)現(xiàn),你需要的大部分功能,比如在請(qǐng)求中添加一個(gè)header,添加一個(gè)參數(shù),都只需要在yml中引入相應(yīng)的內(nèi)置過(guò)濾器即可。
可以說(shuō),yml是整個(gè)SpringCloud Gateway的靈魂。
一個(gè)網(wǎng)關(guān)最基本的功能,就是配置路由,在這方面,SpringCloud Gateway支持非常多方式。比如:
通過(guò)時(shí)間匹配 通過(guò) Cookie 匹配 通過(guò) Header 屬性匹配 通過(guò) Host 匹配 通過(guò)請(qǐng)求方式匹配 通過(guò)請(qǐng)求路徑匹配 通過(guò)請(qǐng)求參數(shù)匹配 通過(guò)請(qǐng)求 ip 地址進(jìn)行匹配
這些在官網(wǎng)教程中,都有詳細(xì)的介紹,就算你百度下,也會(huì)有很多民間翻譯的入門教程,我就不再贅述了,我只用一個(gè)請(qǐng)求路徑做一個(gè)簡(jiǎn)單的例子。
在公司的項(xiàng)目中,由于有新老兩套后臺(tái)服務(wù),我們使用不同的uri路徑進(jìn)行區(qū)分。
老服務(wù)路徑為:url/api/xxxxxx,服務(wù)端口號(hào)為8001 新服務(wù)路徑為:url/api/v2/xxxxx,服務(wù)端口號(hào)為8002
那么可以直接在yml里面配置:
logging:
level:
org.springframework.cloud.gateway: DEBUG
reactor.netty.http.client: DEBUG
spring:
cloud:
gateway:
default-filters:
- AddRequestHeader=gateway-env, springcloud-gateway
routes:
- id: "server_v2"
uri: "http://127.0.0.1:8002"
predicates:
- Path=/api/v2/**
- id: "server_v1"
uri: "http://127.0.0.1:8001"
predicates:
- Path=/api/**
上面的代碼解釋如下:
logging:由于文章需要,我們打開gateway和netty的Debug模式,可以看清楚請(qǐng)求進(jìn)來(lái)后執(zhí)行的流程,方便后續(xù)說(shuō)明。 default-filters:我們可以方便的使用default-filters,在請(qǐng)求中加入一個(gè)自定義的header,我們加入一個(gè)KV為gateway-env:springcloud-gateway,來(lái)注明我們這個(gè)請(qǐng)求經(jīng)過(guò)了此網(wǎng)關(guān)。這樣做的好處是后續(xù)服務(wù)端也能夠看到。 routes:路由是網(wǎng)關(guān)的重點(diǎn),相信讀者們看代碼也能理解,我配置了兩個(gè)路由,一個(gè)是server_v1的老服務(wù),一個(gè)是server_v2的新服務(wù)。**請(qǐng)注意,一個(gè)請(qǐng)求滿足多個(gè)路由的謂詞條件時(shí),請(qǐng)求只會(huì)被首個(gè)成功匹配的路由轉(zhuǎn)發(fā)。**由于我們老服務(wù)的路由是/xx,所以需要將老服務(wù)放在后面,優(yōu)先匹配詞綴/v2的新服務(wù),不滿足的再匹配到/xx。
來(lái)看一下http://localhost:8080/api/xxxxx的結(jié)果:

來(lái)看一下http://localhost:8080/api/v2/xxxxx的結(jié)果:

可以看到兩個(gè)請(qǐng)求被正確的路由了。由于我們真正并沒有開啟后端服務(wù),所以最后一句error請(qǐng)忽略。
接口轉(zhuǎn)義問(wèn)題
在公司實(shí)際的項(xiàng)目中,我在搭建好網(wǎng)關(guān)后,遇到了一個(gè)接口轉(zhuǎn)義問(wèn)題,相信很多讀者可能也會(huì)碰到,所以在這里我們最好是防患于未然,優(yōu)先處理下。
問(wèn)題是這樣的,很多老項(xiàng)目在url上并沒有進(jìn)行轉(zhuǎn)義,導(dǎo)致會(huì)出現(xiàn)如下接口請(qǐng)求,http://xxxxxxxxx/api/b3d56a6fa19975ba520189f3f55de7f6/140x140.jpg?t=1"
這樣請(qǐng)求過(guò)來(lái),網(wǎng)關(guān)會(huì)報(bào)錯(cuò):
java.lang.IllegalArgumentException: Invalid character '=' for QUERY_PARAM in "http://pic1.ajkimg.com/display/anjuke/b3d56a6fa19975ba520189f3f55de7f6/140x140.jpg?t=1"
在不修改服務(wù)代碼邏輯的前提下,網(wǎng)關(guān)其實(shí)已經(jīng)可以解決這件事情,解決辦法就是升級(jí)到2.1.1.RELEASE以上的版本。
The issue was fixed in version spring-cloud-gateway 2.1.1.RELEASE.
所以我們一開始就是用了高版本2.2.5.RELEASE,避免了這個(gè)問(wèn)題,如果小伙伴發(fā)現(xiàn)之前使用的版本低于 2.1.1.RELEASE,請(qǐng)升級(jí)。
獲取請(qǐng)求體(Request Body)
在網(wǎng)關(guān)的使用中,有時(shí)候會(huì)需要拿到請(qǐng)求body里面的數(shù)據(jù),比如驗(yàn)證簽名,body可能需要參與簽名校驗(yàn)。
但是SpringCloud Gateway由于底層采用了webflux,其請(qǐng)求是流式響應(yīng)的,即 Reactor 編程,要讀取 Request Body 中的請(qǐng)求參數(shù)就沒那么容易了。
網(wǎng)上谷歌了很久,很多解決方案要么是徹底過(guò)時(shí),要么是版本不兼容,好在最后參考了這篇文章,終于有了思路:
https://www.jianshu.com/p/db3b15aec646
首先我們需要將body從請(qǐng)求中拿出來(lái),由于是流式處理,Request的Body是只能讀取一次的,如果直接通過(guò)在Filter中讀取,會(huì)導(dǎo)致后面的服務(wù)無(wú)法讀取數(shù)據(jù)。
SpringCloud Gateway 內(nèi)部提供了一個(gè)斷言工廠類ReadBodyPredicateFactory,這個(gè)類實(shí)現(xiàn)了讀取Request的Body內(nèi)容并放入緩存,我們可以通過(guò)從緩存中獲取body內(nèi)容來(lái)實(shí)現(xiàn)我們的目的。
首先新建一個(gè)CustomReadBodyRoutePredicateFactory類,這里只貼出關(guān)鍵代碼,完整代碼請(qǐng)看可運(yùn)行的Github倉(cāng)庫(kù)[2]:
@Component
public class CustomReadBodyRoutePredicateFactory extends AbstractRoutePredicateFactory<CustomReadBodyRoutePredicateFactory.Config> {
protected static final Log log = LogFactory.getLog(CustomReadBodyRoutePredicateFactory.class);
private List<HttpMessageReader<?>> messageReaders;
@Value("${spring.codec.max-in-memory-size}")
private DataSize maxInMemory;
public CustomReadBodyRoutePredicateFactory() {
super(Config.class);
this.messageReaders = HandlerStrategies.withDefaults().messageReaders();
}
public CustomReadBodyRoutePredicateFactory(List<HttpMessageReader<?>> messageReaders) {
super(Config.class);
this.messageReaders = messageReaders;
}
@PostConstruct
private void overrideMsgReaders() {
this.messageReaders = HandlerStrategies.builder().codecs((c) -> c.defaultCodecs().maxInMemorySize((int) maxInMemory.toBytes())).build().messageReaders();
}
@Override
public AsyncPredicate<ServerWebExchange> applyAsync(Config config) {
return new AsyncPredicate<ServerWebExchange>() {
@Override
public Publisher<Boolean> apply(ServerWebExchange exchange) {
Class inClass = config.getInClass();
Object cachedBody = exchange.getAttribute("cachedRequestBodyObject");
if (cachedBody != null) {
try {
boolean test = config.predicate.test(cachedBody);
exchange.getAttributes().put("read_body_predicate_test_attribute", test);
return Mono.just(test);
} catch (ClassCastException var6) {
if (CustomReadBodyRoutePredicateFactory.log.isDebugEnabled()) {
CustomReadBodyRoutePredicateFactory.log.debug("Predicate test failed because class in predicate does not match the cached body object", var6);
}
return Mono.just(false);
}
} else {
return ServerWebExchangeUtils.cacheRequestBodyAndRequest(exchange, (serverHttpRequest) -> {
return ServerRequest.create(exchange.mutate().request(serverHttpRequest).build(), CustomReadBodyRoutePredicateFactory.this.messageReaders).bodyToMono(inClass).doOnNext((objectValue) -> {
exchange.getAttributes().put("cachedRequestBodyObject", objectValue);
}).map((objectValue) -> {
return config.getPredicate().test(objectValue);
}).thenReturn(true);
});
}
}
@Override
public String toString() {
return String.format("ReadBody: %s", config.getInClass());
}
};
}
@Override
public Predicate<ServerWebExchange> apply(Config config) {
throw new UnsupportedOperationException("ReadBodyPredicateFactory is only async.");
}
}
代碼主要作用:在有body的請(qǐng)求到來(lái)時(shí),將body讀取出來(lái)放到內(nèi)存緩存中。若沒有body,則不作任何操作。
這樣我們便可以在攔截器里使用exchange.getAttribute("cachedRequestBodyObject")得到body體。
對(duì)了,我們還沒有演示一個(gè)filter是如何寫的,在這里就先寫一個(gè)完整的demofilter。
讓我們新建類DemoGatewayFilterFactory:
@Component
public class DemoGatewayFilterFactory extends AbstractGatewayFilterFactory<DemoGatewayFilterFactory.Config> {
private static final String CACHE_REQUEST_BODY_OBJECT_KEY = "cachedRequestBodyObject";
public DemoGatewayFilterFactory() {
super(Config.class);
log.info("Loaded GatewayFilterFactory [DemoFilter]");
}
@Override
public List<String> shortcutFieldOrder() {
return Collections.singletonList("enabled");
}
@Override
public GatewayFilter apply(DemoGatewayFilterFactory.Config config) {
return (exchange, chain) -> {
if (!config.isEnabled()) {
return chain.filter(exchange);
}
log.info("-----DemoGatewayFilterFactory start-----");
ServerHttpRequest request = exchange.getRequest();
log.info("RemoteAddress: [{}]", request.getRemoteAddress());
log.info("Path: [{}]", request.getURI().getPath());
log.info("Method: [{}]", request.getMethod());
log.info("Body: [{}]", (String) exchange.getAttribute(CACHE_REQUEST_BODY_OBJECT_KEY));
log.info("-----DemoGatewayFilterFactory end-----");
return chain.filter(exchange);
};
}
public static class Config {
private boolean enabled;
public Config() {}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
}
}
這個(gè)filter里,我們拿到了新鮮的請(qǐng)求,并且打印出了他的path,method,body等。
我們發(fā)送一個(gè)post請(qǐng)求,body就寫一個(gè)“我是body”,運(yùn)行網(wǎng)關(guān),得到結(jié)果:

是不是非常清晰明了!
你以為這就結(jié)束了嗎?這里有兩個(gè)非常大的坑。
1. body為空時(shí)處理
上面貼出的CustomReadBodyRoutePredicateFactory類其實(shí)已經(jīng)是我修復(fù)過(guò)的代碼,里面有一行.thenReturn(true)是需要加上的。這才能保證當(dāng)body為空時(shí),不會(huì)報(bào)出異常。至于為啥一開始寫的有問(wèn)題,顯然因?yàn)槲彝祽辛?,直接copy網(wǎng)上的代碼了,哈哈哈哈哈。
2. body大小超過(guò)了buffer的最大限制
這個(gè)情況是在公司項(xiàng)目上線后才發(fā)現(xiàn)的,我們的請(qǐng)求里body有時(shí)候會(huì)比較大,但是網(wǎng)關(guān)會(huì)有默認(rèn)大小限制。所以上線后發(fā)現(xiàn)了頻繁的報(bào)錯(cuò):
org.springframework.core.io.buffer.DataBufferLimitException: Exceeded limit on max bytes to buffer : 262144

谷歌后,找到了解決方案,需要在配置中增加了如下配置
spring:
codec:
max-in-memory-size: 5MB
把buffer大小改到了5M。
你以為這就又雙叕結(jié)束了,太天真了,你會(huì)發(fā)現(xiàn)可能沒有生效。
問(wèn)題的根源在這里:我們?cè)趕pring配置了上面的參數(shù),但是我們自定義的攔截器是會(huì)初始化ServerRequest,這個(gè)DefaultServerRequest中的HttpMessageReader會(huì)使用默認(rèn)的262144
所以我們?cè)诖颂幮枰獜腟pring中取出CodecConfigurer, 并將里面的Reader傳給serverRequest。
詳細(xì)的debug過(guò)程可以看這篇參考文獻(xiàn):
http://theclouds.io/tag/spring-gateway/
OK,找到問(wèn)題后,就可以修改我們的代碼,在CustomReadBodyRoutePredicateFactory里,增加:
@Value("${spring.codec.max-in-memory-size}")
private DataSize maxInMemory;
@PostConstruct
private void overrideMsgReaders() {
this.messageReaders = HandlerStrategies.builder().codecs((c) -> c.defaultCodecs().maxInMemorySize((int) maxInMemory.toBytes())).build().messageReaders();
}
這樣每次就會(huì)使用我們的5MB來(lái)作為最大緩存限制了。
依然提醒一下,完整的代碼可以請(qǐng)看可運(yùn)行的Github倉(cāng)庫(kù)[3]
講到這里,入門實(shí)戰(zhàn)就差不多了,你的網(wǎng)關(guān)已經(jīng)可以上線使用了,你要做的就是加上你需要的業(yè)務(wù)功能,比如日志,延簽,統(tǒng)計(jì)等。
踩坑實(shí)戰(zhàn)
獲取客戶端真實(shí)IP
很多時(shí)候,我們的后端服務(wù)會(huì)去通過(guò)host拿到用戶的真實(shí)IP,但是通過(guò)外層反向代理nginx的轉(zhuǎn)發(fā),很可能就需要從header里拿X-Forward-XXX類似這樣的參數(shù),才能拿到真實(shí)IP。
在我們加入了微服務(wù)網(wǎng)關(guān)后,這個(gè)復(fù)雜的鏈路中又增加了一環(huán)。
這不,如果你不做任何設(shè)置,由于你的網(wǎng)關(guān)和后端服務(wù)在同一個(gè)容器中,你的后端服務(wù)很有可能就會(huì)拿到localhost:8080(你的網(wǎng)關(guān)端口)這樣的IP。
這時(shí)候,你需要在yml里配置PreserveHostHeader,這是SpringCloud Gateway自帶的實(shí)現(xiàn):
filters:
- PreserveHostHeader # 防止host被修改為localhost
字面意思,就是將Host的Header保留起來(lái),透?jìng)鹘o后端服務(wù)。
filter里面的源碼貼出來(lái)給大家:
public GatewayFilter apply(Object config) {
return new GatewayFilter() {
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
exchange.getAttributes().put(ServerWebExchangeUtils.PRESERVE_HOST_HEADER_ATTRIBUTE, true);
return chain.filter(exchange);
}
public String toString() {
return GatewayToStringStyler.filterToStringCreator(PreserveHostHeaderGatewayFilterFactory.this).toString();
}
};
}
尾綴匹配
公司的項(xiàng)目中,老的后端倉(cāng)庫(kù)api都以.json結(jié)尾(/api/xxxxxx.json),這就催生了一個(gè)需求,當(dāng)我們對(duì)老接口進(jìn)行了重構(gòu)后,希望其打到我們的新服務(wù),我們就要將.json這個(gè)尾綴切除??梢栽趂ilters里設(shè)置:
filters:
- RewritePath=(?<segment>/?.*).json, $\{segment} # 重構(gòu)接口抹去.json尾綴
這樣就可以實(shí)現(xiàn)打到后端的接口去除了.json后綴。
總結(jié)
本文帶領(lǐng)讀者一步步完成了一個(gè)微服務(wù)網(wǎng)關(guān)的搭建,并且將許多可能隱藏的坑進(jìn)行了解決。最后的成品項(xiàng)目在筆者公司已經(jīng)上線運(yùn)行,并且增加了簽名驗(yàn)證,日志記錄等業(yè)務(wù),每天承擔(dān)百萬(wàn)級(jí)別的請(qǐng)求,是經(jīng)過(guò)實(shí)戰(zhàn)驗(yàn)證過(guò)的項(xiàng)目。
最后再發(fā)一次項(xiàng)目源碼倉(cāng)庫(kù):
https://github.com/qqxx6661/springcloud_gateway_demo
感謝大家的支持,如果文章對(duì)你起到了一丁點(diǎn)幫助,請(qǐng)點(diǎn)贊轉(zhuǎn)發(fā)支持一下!
你們的反饋是我持續(xù)更新的動(dòng)力,謝謝~
關(guān)注我
我是一名奮斗在一線的互聯(lián)網(wǎng)后端開發(fā)工程師。
平時(shí)主要關(guān)注后端開發(fā),數(shù)據(jù)安全,邊緣計(jì)算等方向,歡迎交流。
如果文章對(duì)你有幫助,請(qǐng)各位老板點(diǎn)贊在看轉(zhuǎn)發(fā)支持一下,你的支持對(duì)我非常重要~
參考資料
Service Mesh和API Gateway關(guān)系深度探討: https://www.servicemesher.com/blog/service-mesh-and-api-gateway/
[2]Github倉(cāng)庫(kù): https://github.com/qqxx6661/springcloud_gateway_demo
往期精彩文章:
不用到2038年,MySql的TIMESTAMP就能把我們系統(tǒng)搞崩
暢玩國(guó)服LOL?MacBook M1 Windows虛擬機(jī)體驗(yàn)
Java用戶線程和守護(hù)線程詳細(xì)區(qū)別與分析
