<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          API網(wǎng)關(guān)才是大勢所趨?SpringCloud Gateway保姆級入門教程

          共 20345字,需瀏覽 41分鐘

           ·

          2021-05-22 18:51

          什么是微服務(wù)網(wǎng)關(guān)

          大家好,我是蠻三刀。

          SpringCloud Gateway是Spring全家桶中一個比較新的項目,它到底是啥來頭呢?Spring社區(qū)是這么介紹它的:

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

          而在真實的業(yè)務(wù)領(lǐng)域,我們經(jīng)常用SpringCloud Gateway來做微服務(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] 來了解兩者的定位區(qū)別。

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

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

          摩托車是你的后端服務(wù),而旁邊掛著的額外座椅就是微服務(wù)網(wǎng)關(guān),他是依附于后端服務(wù)的(一般是指兩個進(jìn)程在同一個容器中),是不是生動形象了一些。

          由于本人才疏學(xué)淺,對于微服務(wù)相關(guān)概念理解上難免會有偏差。就不在此詳細(xì)講述原理性的文字了。

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

          文章目錄

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

          原創(chuàng)不易,歡迎關(guān)注我的技術(shù)公眾號:后端技術(shù)漫談

          源代碼

          完整項目源代碼已經(jīng)收錄到我的Github:

          https://github.com/qqxx6661/springcloud_gateway_demo

          手把手造一個網(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>

          如此一來,我們便引入了2.2.5.RELEASE版本的網(wǎng)關(guān):

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

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

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

          編寫yml文件

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

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

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

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

          • 通過時間匹配
          • 通過 Cookie 匹配
          • 通過 Header 屬性匹配
          • 通過 Host 匹配
          • 通過請求方式匹配
          • 通過請求路徑匹配
          • 通過請求參數(shù)匹配
          • 通過請求 ip 地址進(jìn)行匹配

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

          在公司的項目中,由于有新老兩套后臺服務(wù),我們使用不同的uri路徑進(jìn)行區(qū)分。

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

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

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

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

          接口轉(zhuǎn)義問題

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

          問題是這樣的,很多老項目在url上并沒有進(jìn)行轉(zhuǎn)義,導(dǎo)致會出現(xiàn)如下接口請求,http://xxxxxxxxx/api/b3d56a6fa19975ba520189f3f55de7f6/140x140.jpg?t=1"

          這樣請求過來,網(wǎng)關(guān)會報錯:

          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)其實已經(jīng)可以解決這件事情,解決辦法就是升級到2.1.1.RELEASE以上的版本。

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

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

          獲取請求體(Request Body)

          在網(wǎng)關(guān)的使用中,有時候會需要拿到請求body里面的數(shù)據(jù),比如驗證簽名,body可能需要參與簽名校驗。

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

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

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

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

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

          首先新建一個CustomReadBodyRoutePredicateFactory類,這里只貼出關(guān)鍵代碼,完整代碼請看可運行的Github倉庫[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的請求到來時,將body讀取出來放到內(nèi)存緩存中。若沒有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等。

          我們發(fā)送一個post請求,body就寫一個“我是body”,運行網(wǎng)關(guān),得到結(jié)果:

          是不是非常清晰明了!

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

          1. body為空時處理

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

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

          這個情況是在公司項目上線后才發(fā)現(xiàn)的,我們的請求里body有時候會比較大,但是網(wǎng)關(guān)會有默認(rèn)大小限制。所以上線后發(fā)現(xiàn)了頻繁的報錯:

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

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

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

          把buffer大小改到了5M。

          你以為這就又雙叕結(jié)束了,太天真了,你會發(fā)現(xiàn)可能沒有生效。

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

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

          詳細(xì)的debug過程可以看這篇參考文獻(xiàn):

          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倉庫[3]

          講到這里,入門實戰(zhàn)就差不多了,你的網(wǎng)關(guān)已經(jīng)可以上線使用了,你要做的就是加上你需要的業(yè)務(wù)功能,比如日志,延簽,統(tǒng)計等。

          踩坑實戰(zhàn)

          獲取客戶端真實IP

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

          在我們加入了微服務(wù)網(wǎng)關(guān)后,這個復(fù)雜的鏈路中又增加了一環(huán)。

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

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

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

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

          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結(jié)尾(/api/xxxxxx.json),這就催生了一個需求,當(dāng)我們對老接口進(jìn)行了重構(gòu)后,希望其打到我們的新服務(wù),我們就要將.json這個尾綴切除。可以在filters里設(shè)置:

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

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

          總結(jié)

          本文帶領(lǐng)讀者一步步完成了一個微服務(wù)網(wǎng)關(guān)的搭建,并且將許多可能隱藏的坑進(jìn)行了解決。最后的成品項目在筆者公司已經(jīng)上線運行,并且增加了簽名驗證,日志記錄等業(yè)務(wù),每天承擔(dān)百萬級別的請求,是經(jīng)過實戰(zhàn)驗證過的項目。

          最后再發(fā)一次項目源碼倉庫:

          https://github.com/qqxx6661/springcloud_gateway_demo

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

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

          關(guān)注我

          我是一名奮斗在一線的互聯(lián)網(wǎng)后端開發(fā)工程師。

          平時主要關(guān)注后端開發(fā),數(shù)據(jù)安全,邊緣計算等方向,歡迎交流。

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

          參考資料

          [1]

          Service Mesh和API Gateway關(guān)系深度探討: https://www.servicemesher.com/blog/service-mesh-and-api-gateway/

          [2]

          Github倉庫: https://github.com/qqxx6661/springcloud_gateway_demo


















          往期精彩文章:


          不用到2038年,MySql的TIMESTAMP就能把我們系統(tǒng)搞崩


          暢玩國服LOL?MacBook M1 Windows虛擬機(jī)體驗


          Java用戶線程和守護(hù)線程詳細(xì)區(qū)別與分析


          一枚程序猿的MacBook M1使用體驗


          做個不用上班的程序員是一種怎樣的體驗?



          瀏覽 70
          點贊
          評論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  人人看人人爱视频 | 色网站在线| 亚州免费高清 | 大黑鸡巴视频 | 91日逼视频 |