API網關才是大勢所趨?SpringCloud Gateway保姆級入門教程


什么是微服務網關

SpringCloud Gateway是Spring全家桶中一個比較新的項目,Spring社區是這么介紹它的:

該項目借助Spring WebFlux的能力,打造了一個API網關。旨在提供一種簡單而有效的方法來作為API服務的路由,並為它們提供各種增強功能,例如:安全性,監控和可伸縮性。

而在真實的業務領域,我們經常用SpringCloud Gateway來做微服務網關,如果你不理解微服務網關和傳統網關的區別,可以閱讀此篇文章 Service Mesh和API Gateway關系深度探討 來了解兩者的定位區別。

以我粗淺的理解,傳統的API網關,往往是獨立於各個后端服務,請求先打到獨立的網關層,再打到服務集群。而微服務網關,將流量從南北走向改為東西走向(見下圖),微服務網關和后端服務是在同一個容器中的,所以也有個別名,叫做Gateway Sidecar。

為啥叫Sidecar,這個詞應該怎么理解呢,吃雞里的三蹦子見過沒:

摩托車是你的后端服務,而旁邊掛着的額外座椅就是微服務網關,他是依附於后端服務的(一般是指兩個進程在同一個容器中),是不是生動形象了一些。

由於本人才疏學淺,對於微服務相關概念理解上難免會有偏差。就不在此詳細講述原理性的文字了。

本文只探討SpringCloud Gateway的入門搭建和實戰踩坑。 如果小伙伴們對原理感興趣,可以等后續原理分析文章。

注:本文網關項目在筆者公司已經上線運行,每天承擔百萬級別的請求,是經過實戰驗證的項目。

文章目錄

  • 手把手造一個網關
    • 引入pom依賴
    • 編寫yml文件
    • 接口轉義問題
    • 獲取請求體(Request Body)
  • 踩坑實戰
    • 獲取客戶端真實IP
    • 尾綴匹配
  • 總結

源代碼

完整項目源代碼已經收錄到我的Github:

https://github.com/qqxx6661/springcloud_gateway_demo

手把手造一個網關

引入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>

如此一來,我們便引入了2.2.5.RELEASE版本的網關:

此外,請檢查一下你的依賴中是否含有spring-boot-starter-web,如果有,請干掉它。因為我們的SpringCloud Gateway是一個netty+webflux實現的web服務器,和Springboot Web本身就是沖突的。

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-web</artifactId>
</dependency>

做到這里,實際上你的項目就已經可以啟動了,運行SpringcloudGatewayApplication,得到結果如圖:

編寫yml文件

SpringBoot的核心概念是約定優先於配置,在以前初學Spring時,一直不理解這句話的意思,在使用SpringCloud Gateway時,更加深入的理解了這句話。在默認情況下,你不需要任何的配置,就能夠運行起來最基本的網關。針對你之后特定的需求,再去追加配置。

而SpringCloud Gateway更強大的一點就是內置了非常多的默認功能實現,你需要的大部分功能,比如在請求中添加一個header,添加一個參數,都只需要在yml中引入相應的內置過濾器即可。

可以說,yml是整個SpringCloud Gateway的靈魂。

一個網關最基本的功能,就是配置路由,在這方面,SpringCloud Gateway支持非常多方式。比如:

  • 通過時間匹配
  • 通過 Cookie 匹配
  • 通過 Header 屬性匹配
  • 通過 Host 匹配
  • 通過請求方式匹配
  • 通過請求路徑匹配
  • 通過請求參數匹配
  • 通過請求 ip 地址進行匹配

這些在官網教程中,都有詳細的介紹,就算你百度下,也會有很多民間翻譯的入門教程,我就不再贅述了,我只用一個請求路徑做一個簡單的例子。

在公司的項目中,由於有新老兩套后台服務,我們使用不同的uri路徑進行區分。

  • 老服務路徑為:url/api/xxxxxx,服務端口號為8001
  • 新服務路徑為:url/api/v2/xxxxx,服務端口號為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模式,可以看清楚請求進來后執行的流程,方便后續說明。
  • default-filters:我們可以方便的使用default-filters,在請求中加入一個自定義的header,我們加入一個KV為gateway-env:springcloud-gateway,來注明我們這個請求經過了此網關。這樣做的好處是后續服務端也能夠看到。
  • routes:路由是網關的重點,相信讀者們看代碼也能理解,我配置了兩個路由,一個是server_v1的老服務,一個是server_v2的新服務。請注意,一個請求滿足多個路由的謂詞條件時,請求只會被首個成功匹配的路由轉發。由於我們老服務的路由是/xx,所以需要將老服務放在后面,優先匹配詞綴/v2的新服務,不滿足的再匹配到/xx。

來看一下http://localhost:8080/api/xxxxx的結果:

來看一下http://localhost:8080/api/v2/xxxxx的結果:

可以看到兩個請求被正確的路由了。由於我們真正並沒有開啟后端服務,所以最后一句error請忽略。

接口轉義問題

在公司實際的項目中,我在搭建好網關后,遇到了一個接口轉義問題,相信很多讀者可能也會碰到,所以在這里我們最好是防患於未然,優先處理下。

問題是這樣的,很多老項目在url上並沒有進行轉義,導致會出現如下接口請求,http://xxxxxxxxx/api/b3d56a6fa19975ba520189f3f55de7f6/140x140.jpg?t=1"

這樣請求過來,網關會報錯:

java.lang.IllegalArgumentException: Invalid character '=' for QUERY_PARAM in "http://pic1.ajkimg.com/display/anjuke/b3d56a6fa19975ba520189f3f55de7f6/140x140.jpg?t=1"

在不修改服務代碼邏輯的前提下,網關其實已經可以解決這件事情,解決辦法就是升級到2.1.1.RELEASE以上的版本。

The issue was fixed in version spring-cloud-gateway 2.1.1.RELEASE.

所以我們一開始就是用了高版本2.2.5.RELEASE,避免了這個問題,如果小伙伴發現之前使用的版本低於 2.1.1.RELEASE,請升級。

獲取請求體(Request Body)

在網關的使用中,有時候會需要拿到請求body里面的數據,比如驗證簽名,body可能需要參與簽名校驗。

但是SpringCloud Gateway由於底層采用了webflux,其請求是流式響應的,即 Reactor 編程,要讀取 Request Body 中的請求參數就沒那么容易了。

網上谷歌了很久,很多解決方案要么是徹底過時,要么是版本不兼容,好在最后參考了這篇文章,終於有了思路:

https://www.jianshu.com/p/db3b15aec646

首先我們需要將body從請求中拿出來,由於是流式處理,Request的Body是只能讀取一次的,如果直接通過在Filter中讀取,會導致后面的服務無法讀取數據。

SpringCloud Gateway 內部提供了一個斷言工廠類ReadBodyPredicateFactory,這個類實現了讀取Request的Body內容並放入緩存,我們可以通過從緩存中獲取body內容來實現我們的目的。

首先新建一個CustomReadBodyRoutePredicateFactory類,這里只貼出關鍵代碼,完整代碼請看可運行的Github倉庫

@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的請求到來時,將body讀取出來放到內存緩存中。若沒有body,則不作任何操作。

這樣我們便可以在攔截器里使用exchange.getAttribute("cachedRequestBodyObject")得到body體。

對了,我們還沒有演示一個filter是如何寫的,在這里就先寫一個完整的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;
        }
    }
}

這個filter里,我們拿到了新鮮的請求,並且打印出了他的path,method,body等。

我們發送一個post請求,body就寫一個“我是body”,運行網關,得到結果:

是不是非常清晰明了!

你以為這就結束了嗎?這里有兩個非常大的坑。

1. body為空時處理

上面貼出的CustomReadBodyRoutePredicateFactory類其實已經是我修復過的代碼,里面有一行.thenReturn(true)是需要加上的。這才能保證當body為空時,不會報出異常。至於為啥一開始寫的有問題,顯然因為我偷懶了,直接copy網上的代碼了,哈哈哈哈哈。

2. body大小超過了buffer的最大限制

這個情況是在公司項目上線后才發現的,我們的請求里body有時候會比較大,但是網關會有默認大小限制。所以上線后發現了頻繁的報錯:

org.springframework.core.io.buffer.DataBufferLimitException: Exceeded limit on max bytes to buffer : 262144

谷歌后,找到了解決方案,需要在配置中增加了如下配置

spring: 
  codec:
    max-in-memory-size: 5MB

把buffer大小改到了5M。

你以為這就又雙叕結束了,太天真了,你會發現可能沒有生效。

問題的根源在這里:我們在spring配置了上面的參數,但是我們自定義的攔截器是會初始化ServerRequest,這個DefaultServerRequest中的HttpMessageReader會使用默認的262144

所以我們在此處需要從Spring中取出CodecConfigurer, 並將里面的Reader傳給serverRequest。

詳細的debug過程可以看這篇參考文獻:

http://theclouds.io/tag/spring-gateway/

OK,找到問題后,就可以修改我們的代碼,在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();
}

這樣每次就會使用我們的5MB來作為最大緩存限制了。

依然提醒一下,完整的代碼可以請看可運行的Github倉庫

講到這里,入門實戰就差不多了,你的網關已經可以上線使用了,你要做的就是加上你需要的業務功能,比如日志,延簽,統計等。

踩坑實戰

獲取客戶端真實IP

很多時候,我們的后端服務會去通過host拿到用戶的真實IP,但是通過外層反向代理nginx的轉發,很可能就需要從header里拿X-Forward-XXX類似這樣的參數,才能拿到真實IP。

在我們加入了微服務網關后,這個復雜的鏈路中又增加了一環。

這不,如果你不做任何設置,由於你的網關和后端服務在同一個容器中,你的后端服務很有可能就會拿到localhost:8080(你的網關端口)這樣的IP。

這時候,你需要在yml里配置PreserveHostHeader,這是SpringCloud Gateway自帶的實現:

filters:
  - PreserveHostHeader # 防止host被修改為localhost

字面意思,就是將Host的Header保留起來,透傳給后端服務。

filter里面的源碼貼出來給大家:

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();
        }
    };
}

尾綴匹配

公司的項目中,老的后端倉庫api都以.json結尾(/api/xxxxxx.json),這就催生了一個需求,當我們對老接口進行了重構后,希望其打到我們的新服務,我們就要將.json這個尾綴切除。可以在filters里設置:

filters:
  - RewritePath=(?<segment>/?.*).json, $\{segment} # 重構接口抹去.json尾綴

這樣就可以實現打到后端的接口去除了.json后綴。

總結

本文帶領讀者一步步完成了一個微服務網關的搭建,並且將許多可能隱藏的坑進行了解決。最后的成品項目在筆者公司已經上線運行,並且增加了簽名驗證,日志記錄等業務,每天承擔百萬級別的請求,是經過實戰驗證過的項目。

最后再發一次項目源碼倉庫:

https://github.com/qqxx6661/springcloud_gateway_demo

感謝大家的支持,如果文章對你起到了一丁點幫助,請點贊轉發支持一下!

你們的反饋是我持續更新的動力,謝謝~

參考

https://cloud.tencent.com/developer/article/1449300

https://juejin.cn/post/6844903795973947400#heading-3

https://segmentfault.com/a/1190000016227780

https://cloud.spring.io/spring-cloud-static/Greenwich.SR1/multi/multi__reactor_netty_access_logs.html

https://www.cnblogs.com/savorboard/p/api-gateway.html

https://www.servicemesher.com/blog/service-mesh-and-api-gateway/

https://www.cnblogs.com/hyf-huangyongfei/p/12849406.html

https://www.codercto.com/a/52970.html

https://github.com/spring-cloud/spring-cloud-gateway/issues/1658

https://blog.csdn.net/zhangzhen02/article/details/109082792

關注我

我是一名奮斗在一線的互聯網后端開發工程師。

平時主要關注后端開發,數據安全,邊緣計算等方向,歡迎交流。

各大平台都能找到我

原創文章主要內容

  • 后端開發實戰
  • 后端技術面試
  • 算法題解/數據結構/設計模式
  • 軼聞趣事

個人公眾號:后端技術漫談

個人公眾號:后端技術漫談

如果文章對你有幫助,請各位老板點贊在看轉發支持一下,你的支持對我非常重要~


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM