Spring Cloud 之 Gateway (Greenwich版)

众所周知,netflix OSS 2.0 难产了,上一代的zuul网关虽说不错,但其并不是异步的。所以,Spring团队推出了基于Spring Webflux的全新异步的网关--Spring Cloud Gateway。

本文内容基于Spring Cloud Gateway 2.1.0.GA

来跟着我一步步,探索它的魅力坑吧!

环境搭建

与所有的微服务组件一样,demo总是很简单的,如果你要启用,创建时勾上相关依赖即可。

就像这样:

<dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

其中lombok是我的习惯,你可以选择不添加。

启动类修改为@SpringCloudApplication

@SpringCloudApplication
public class SpringCloudGatewayApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringCloudGatewayApplication.class, args);
    }
}

添加些简单配置(一个路由),跳转到我的博客,直接填写了url。由于只涉及网关,所以我把不必要的eureka关了,但实际开发中需要使用它,并添加ribbon路由lb://<service-name>

spring:
  cloud:
    gateway:
      routes:
        - id: app
          uri: http://www.dnocm.com
          # lb: Ribbon
          # uri: lb://app-name
          predicates:
            - Path=/app/**
          filters:
            # Strip first path,such base
            - StripPrefix=1
            # - RewritePath=/base/,/
eureka:
  client:
    enabled: false

就此完成,启动运行。当你访问localhost:8080/app/**路由时,都会调整至www.dnocm.com/**。这是因为我设置了http一律302跳转至https。所以,这证明我们的网关搭建完成啦!!

Route Predicate Factory

id uri顾名思义,不多说,但predicates是什么呢?predicates做动词有使基于; 使以…为依据; 表明; 阐明; 断言;的意思,简单说,用于表明在那种条件下,该路由配置生效。

官方提供给我了许多的predicates

spring:
  cloud:
    gateway:
      routes:
      - id: example
        uri: http://example.org
        predicates:
        # 匹配在什么时间之后的
        - After=2017-01-20T17:42:47.789-07:00[America/Denver]
        # 匹配在什么时间之前的
        - Before=2017-01-20T17:42:47.789-07:00[America/Denver]
        # 匹配在某段时间的
        - Between=2017-01-20T17:42:47.789-07:00[America/Denver], 2017-01-21T17:42:47.789-07:00[America/Denver]
        # 匹配cookie名称为`chocolate`的值要符合`ch.p`正则.
        - Cookie=chocolate, ch.p
        # 匹配header为`X-Request-Id`的值要符合`\d+`正则.
        - Header=X-Request-Id, \d+
        # 匹配任意符合`**.somehost.org`与`**.anotherhost.org`正则的网址
        - Host=**.somehost.org,**.anotherhost.org
        # Host还支持模版变量,会保存在`ServerWebExchange.getAttributes()`的 ServerWebExchangeUtils.URI_TEMPLATE_VARIABLES_ATTRIBUTE中,以Map形式存储
        - Host={sub}.myhost.org
        # 匹配GET方法
        - Method=GET
        # 路径匹配,与Host一样支持模版变量,存在URI_TEMPLATE_VARIABLES_ATTRIBUTE中。
        - Path=/foo/{segment},/bar/{segment}
        # 匹配存在baz查询参数
        - Query=baz
        # 匹配存在foo且符合`ba.`正则
        - Query=foo, ba.
        # 匹配远程地址
        - RemoteAddr=192.168.1.1/24

官方几乎提供了我们所需的全部功能,这点值得鼓掌👏,然而假如遇到无法满足的情况呢?我们翻阅文档,发现自定义部分是大写的TBD待定ヾ(。`Д´。)。

那么怎么办呢?我们从官方的Predicate Factory看起,去学习。

挑个简单的HeaderRoutePredicateFactory

public class HeaderRoutePredicateFactory extends AbstractRoutePredicateFactory<HeaderRoutePredicateFactory.Config> {

  public static final String HEADER_KEY = "header";
  public static final String REGEXP_KEY = "regexp";

  public HeaderRoutePredicateFactory() {
    super(Config.class);
  }

  @Override
  public List<String> shortcutFieldOrder() {
    return Arrays.asList(HEADER_KEY, REGEXP_KEY);
  }

  @Override
  public Predicate<ServerWebExchange> apply(Config config) {
    boolean hasRegex = !StringUtils.isEmpty(config.regexp);

    return exchange -> {
      List<String> values = exchange.getRequest().getHeaders()
          .getOrDefault(config.header, Collections.emptyList());
      if (values.isEmpty()) {
        return false;
      }
      // values is now guaranteed to not be empty
      if (hasRegex) {
        // check if a header value matches
        return values.stream().anyMatch(value -> value.matches(config.regexp));
      }

      // there is a value and since regexp is empty, we only check existence.
      return true;
    };
  }

  @Validated
  public static class Config {

    @NotEmpty
    private String header;

    private String regexp;

    public String getHeader() {
      return header;
    }

    public Config setHeader(String header) {
      this.header = header;
      return this;
    }

    public String getRegexp() {
      return regexp;
    }

    public Config setRegexp(String regexp) {
      this.regexp = regexp;
      return this;
    }

  }

}

上面的例子,我们可以看出

  • HeaderRoutePredicateFactory的构造方式与继承类视乎是固定的,目的是传递配置类
  • 需要实现Predicate<ServerWebExchange> apply(Consumer<C> consumer)
  • shortcutFieldOrder()似乎是为了配置值与配置类属性对应的
  • 需要定义接受的配置类
  • 查看继承可以发现,它通过NameUtils.normalizeRoutePredicateName(this.getClass())来获取配置文件中的名称

Okay,验证上面的内容,我们重新编写一个NonHeaderRoutePredicateFactory,与head取反。同时配置类属性的交换位置。

public class NonHeaderRoutePredicateFactory extends AbstractRoutePredicateFactory<NonHeaderRoutePredicateFactory.Config> {

  public static final String HEADER_KEY = "header";
  public static final String REGEXP_KEY = "regexp";

  public NonHeaderRoutePredicateFactory() {
    super(Config.class);
  }
  @Override
  public List<String> shortcutFieldOrder() {
    return Arrays.asList(HEADER_KEY, REGEXP_KEY);
  }
  @Override
  public Predicate<ServerWebExchange> apply(Config config) {
    boolean hasRegex = !StringUtils.isEmpty(config.regexp);
    return exchange -> {
      List<String> values = exchange.getRequest().getHeaders().getOrDefault(config.header, Collections.emptyList());
      if (values.isEmpty()) {
        return true;
      }
      if (hasRegex) {
        return values.stream().noneMatch(value -> value.matches(config.regexp));
      }
      return false;
    };
  }
  @Data
  @Validated
  public static class Config {
    private String regexp;
    @NotEmpty
    private String header;
  }
}

配置添加- NonHeader=tt,当存在ttheader时 404 ERROR,不存在时,正常访问。符合推测!

GatewayFilter Factory

除此predicates外,还有filter,用于过滤请求。与predicates一样,Spring官方也提供了需要内置的过滤器。过滤器部分相对于predicates来说难得多,有全局的也有可配置的。甚至一些过滤器不支持通过配置文件来修改。

spring:
  cloud:
    gateway:
      routes:
      - id: example
        uri: http://example.org
        filters:
          # 先介绍简单的路由器
          # 对header的操作(添加,删除,设置),以及保留原始host的header
          - AddRequestHeader=X-Request-Foo, Bar
          - AddResponseHeader=X-Response-Foo, Bar
          - RemoveRequestHeader=X-Request-Foo
          - RemoveResponseHeader=X-Response-Foo
          - RewriteResponseHeader=X-Response-Foo, , password=[^&]+, password=***
          - SetResponseHeader=X-Response-Foo, Bar
          - PreserveHostHeader
          # 对查询参数的过滤
          - AddRequestParameter=foo, bar
          # 对Uri的过滤(path与status)
          - PrefixPath=/mypath
          - RewritePath=/foo/(?<segment>.*), /$\{segment}
          - SetPath=/{segment}
          - StripPrefix=2
          - SetStatus=BAD_REQUEST
          - SetStatus=401
          - RedirectTo=302, http://acme.org
          # 保留session,默认情况下是不保留的
          - SaveSession
          # 设置最大请求数据大小,这里发现一种新写法,理论上predicates中也能使用
          - name: RequestSize
            args:
              maxSize: 5000000
          # 重试次数设置
          - name: Retry
            args:
              retries: 3
              statuses: BAD_GATEWAY
        
          # 断路器的配置
          # 断路器的配置比较复杂,首先指定断路器命令名即可启用断路器(这块我也不熟,需要HystrixCommand的内容)
          - Hystrix=myCommandName
          # 另外我们可以设置些错误发生后的跳转,当然现在仅支持forward:
          - name: Hystrix
            args:
              name: fallbackcmd
              fallbackUri: forward:/incaseoffailureusethis
          # 我们还可以修改错误信息放置的header
          - name: FallbackHeaders
            args:
              executionExceptionTypeHeaderName: Test-Header
              executionExceptionMessageHeaderName: Test-Header
              rootCauseExceptionTypeHeaderName: Test-Header
              rootCauseExceptionMessageHeaderName: Test-Header

           # 另一块比较困难的是速率限制
          # 它由RateLimiter的实现来完成的,默认只支持redis,需要添加`spring-boot-starter-data-redis-reactive`依赖
          # 我们需要提供KeyResolver的实现,因为默认会使用PrincipalNameKeyResolver,在不使用Spring Security的情况下几乎不会用到Principal
          # 除此外,我们也可以提供自定义的RateLimiter,#{@myRateLimiter}是一个SpEL表达式,用于从Spring上下文内读取bean
          - name: RequestRateLimiter
            args:
              redis-rate-limiter.replenishRate: 10
              redis-rate-limiter.burstCapacity: 20
          - name: RequestRateLimiter
            args:
              rate-limiter: "#{@myRateLimiter}"
              key-resolver: "#{@userKeyResolver}"

KeyResolver的实现参考

@Bean
KeyResolver userKeyResolver() {
    return exchange -> Mono.just(exchange.getRequest().getQueryParams().getFirst("user"));
}

除此外还有两个“特别”的过滤器,modifyRequestBody modifyResponseBody他们只能使用在Fluent Java Routes API中。例如:

@Bean
public RouteLocator routes(RouteLocatorBuilder builder) {
    return builder.routes()
        .route("rewrite_request_obj", r -> r.host("*.rewriterequestobj.org")
            .filters(f -> f.prefixPath("/httpbin")
                .modifyRequestBody(String.class, Hello.class, MediaType.APPLICATION_JSON_VALUE,
                    (exchange, s) -> return Mono.just(new Hello(s.toUpperCase())))).uri(uri))
        .build();
}

此外,这两个过滤器目前处在Beta中,不稳定。而且,Spring团队对于Body的处理十分愚蠢,我会在Others章节提及。

对于全局过滤器,目前系统提供的一般都用于支持基础功能。如负载均衡、路由转换、生成Response等等。对于我们来说,需要关心这些全局过滤器的顺序,毕竟他们与上面的过滤器会一同工作。

与predicates类似,filter也提供了自定义的能力,相对于鸡肋的predicate的自定义,filter显得有用的多。也可能因此,它居然有官方文档介绍(在predicate中是TBD)。我们可以使用它来完成权限的鉴定与下发,一个好的方案是,网关与客户端之间通过session保存用户的登录状态,在网关内,微服务间的沟通使用JWT来认证安全信息。那么我们需要由过滤器来完成这些工作,一个例子如下:

@Configuration
public class GenerateJwtGatewayFilterFactory extends AbstractGatewayFilterFactory<GenerateJwtGatewayFilterFactory.Config> {

    @Resource
    private JwtProperties properties;
    @Resource
    private JwtAuthServer jwtAuthServer;

    public GenerateJwtGatewayFilterFactory() {
        super(Config.class);
    }

    @Override
    public String name() {
        return "GenerateJwt";
    }

    @Override
    public GatewayFilter apply(Config config) {

        String[] place = config.getPlace().split(":");

        return (exchange, chain) -> {
            return Mono
                    .defer(() -> {
                        if ("session".equals(place[0])) {
                            return exchange.getSession().map(webSession -> {
                                return webSession.getAttributes().getOrDefault(place[1], "");
                            });
                        }
                        if ("query".equals(place[0])) {
                            String first = exchange.getRequest().getQueryParams().getFirst(place[1]);
                            return Mono.justOrEmpty(first);
                        }
                        if ("form".equals(place[0])) {
                            /*return exchange.getFormData().map(formData -> {
                                String first = formData.getFirst(place[1]);
                                return Optional.ofNullable(first).orElse("");
                            });*/
                            return new DefaultServerRequest(exchange).bodyToMono(new ParameterizedTypeReference<MultiValueMap<String, String>>() {}).map(formData -> {
                                String first = formData.getFirst(place[1]);
                                return Optional.ofNullable(first).orElse("");
                            });
                        }
                        throw new BaimiException("不支持的类型!");
                    })
                    .filter(sub -> !StringUtils.isEmpty(sub))
                    .map(sub -> jwtAuthServer.generate(config.getAudience(), config.getPrefix() + ":" + sub))
                    .map(token -> exchange.getRequest().mutate().header(properties.getHeaderName(), properties.getHeaderPrefix() + token).build())
                    .map(req -> exchange.mutate().request(req).build())
                    .then(chain.filter(exchange));
        };
    }

    @Override
    public List<String> shortcutFieldOrder() {
        return Arrays.asList("place", "audience", "prefix");
    }

    @Data
    static class Config {
        /**
         * 三种类型的位置.
         * - session:<param>
         * - query:<param>
         * - form:<param>
         */
        private String place = "session:user";
        /**
         * 授权对象
         * system: 系统用户
         * wechat: 微信用户
         * etc
         */
        private String audience = "system";
        /**
         * 授权主体标识
         */
        private String prefix = "id";
    }
}

在配置文件中,直接使用,更多代码见下面参考中的项目源码。

spring:
  cloud:
    gateway:
      routes:
        - id: example
          uri: http://example.org
          filters:
            - GenerateJwt=form:id,system,id

全局的过滤器也能自定义,像下面一样

@Bean
@Order(1)
public GlobalFilter c() {
    return (exchange, chain) -> {
        log.info("third pre filter");
        return chain.filter(exchange).then(Mono.fromRunnable(() -> {
            log.info("first post filter");
        }));
    };
}

Others

Fluent Java Routes API

关于Java DSL,个人是极度不推荐使用。由于修改后需要重新打包部署。如果由配置文件决定,我们仅需修改配置文件,重新运行即可,程序会更加稳定,因为它仅提供功能给配置文件使用。

Request/Response Body

IllegalStateException 问题范围为 Spring Cloud Gateway 2.0.0 至 2.1.1,1.x 理论上正常但未测试,2.1.2已修复。

关于Body,Spring对于其的操作是,在最初始化阶段,读取Body内容放入Flux流中。之后都是对其操作。详细可以看下AdaptCachedBodyGlobalFilter全局过滤器的源码。

似乎没什么问题是吧,我们就应该在这个操作流内不断的修改Body的内容,直至其被最终消费(转发)。但是当我们在过滤中使用exchange.getRequest().getBody()或者exchange.getFormData()之后,我们期望后续Spring是读取我们所产生的流,然而事实上,它仍然产生调用getBody()获取最初的流。流是线性的,已消费过的不能再次被消费!所以,我们无法方便的使用它达到我们的目的(当然Java DSL内有提供内置的过滤器,但我不推荐Java DSL本身)。

对此,我们有两种方案解决这个问题

  1. 处理完成后的流放入Request/Response中,以便其后续的消费
  2. 修改getBody()的行为,缓存body内容,且每次生成新的流支持后续操作

由于Request/Response对应的Builder不支持放入Body,所有,方案一每次都需要重新构建Body解码器,就像modifyRequestBody做的一样。。。在不需要修改Body的内容的前提(大部分都是这样的)下,方案二我们可以写成通用的Factory,在适当的位置添加即可,显得更加可操作。

下面是一个filter,用于支持RequestBody的缓存:

@Configuration
public class CacheRequestGatewayFilterFactory extends AbstractGatewayFilterFactory<CacheRequestGatewayFilterFactory.Config> {

    public CacheRequestGatewayFilterFactory() {
        super(Config.class);
    }

    @Override
    public String name() {
        return "CacheRequest";
    }

    @Override
    public GatewayFilter apply(Config config) {
        CacheRequestGatewayFilter cacheRequestGatewayFilter = new CacheRequestGatewayFilter();
        Integer order = config.getOrder();
        if (order == null) {
            return cacheRequestGatewayFilter;
        }
        return new OrderedGatewayFilter(cacheRequestGatewayFilter, order);
    }

    public static class CacheRequestGatewayFilter implements GatewayFilter {
        @Override
        public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {

            //GET DELETE 不过滤
            HttpMethod method = exchange.getRequest().getMethod();
            if (method == null || method.matches("GET") || method.matches("DELETE")) {
                return chain.filter(exchange);
            }

            return DataBufferUtils.join(exchange.getRequest().getBody())
                    .map(dataBuffer -> {
                        byte[] bytes = new byte[dataBuffer.readableByteCount()];
                        dataBuffer.read(bytes);
                        DataBufferUtils.release(dataBuffer);
                        return bytes;
                    })
                    .defaultIfEmpty(new byte[0])
                    .flatMap(bytes -> {
                        DataBufferFactory dataBufferFactory = exchange.getResponse().bufferFactory();
                        ServerHttpRequestDecorator decorator = new ServerHttpRequestDecorator(exchange.getRequest()) {
                            @Override
                            public Flux<DataBuffer> getBody() {
                                if (bytes.length > 0) {
                                    return Flux.just(dataBufferFactory.wrap(bytes));
                                }
                                return Flux.empty();
                            }
                        };
                        return chain.filter(exchange.mutate().request(decorator).build());
                    });
        }
    }

    @Override
    public List<String> shortcutFieldOrder() {
        return Collections.singletonList("order");
    }

    @Data
    static class Config {
        private Integer order;
    }

}

配置文件添加CacheRequest,用于添加过滤器(如果不加,从form中读取数据是会报错的)

spring:
  cloud:
    gateway:
      routes:
        - id: jwt
          uri: lb://app-name
          predicates:
            - Path=/jwt/**
          filters:
            - CacheRequest
            - GenerateJwt=form:id,system,id

当然,exchange.getFormData()的问题没有解决,需要对Body操作,请使用exchange.getRequest().getBody()

在下方 issues:946 提了简化操作的建议,然后官方添加了相关Cache方法,然后发现不使用这个方法也不出问题。。。问题原因就是AdaptCachedBodyGlobalFilter对body解码器的封装,默认情况下2.1.2中不做处理,所以好了。。。

参考

Post author: Mr.J
Post link: https://www.dnocm.com/articles/beechnut/spring-cloud-gateway/
Copyright Notice: All articles in this blog are licensed under BY-NC-SA unless stating additionally.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容