SpringCloud 11 —— 网关之Gateway (上) 谓词

Gateway简介

Spring Cloud Gateway是Spring Cloud官方推出的第二代网关框架,取代Zuul网关,Spring Cloud Gateway是在Spring生态系统之上构建的API网关服务,基于Spring 5,Spring Boot 2和 Project Reactor等技术。Spring Cloud Gateway旨在提供一种简单而有效的方式来对API进行路由,以及提供一些强大的过滤器功能, 例如:熔断、限流、重试等。

注意:Spring Cloud Gateway需要Spring Boot和Spring Webflux提供的Netty下运行,不能在传统的Servlet容器中或作为WAR构建时使用

Spring Cloud Gateway 的特性:

  • 基于Spring Framework 5, Project Reactor 和 Spring Boot 2.0 进行构建;
  • 动态路由:能够匹配任何请求属性;
  • 可以对路由指定 Predicate(断言)和 Filter(过滤器);
  • 集成Hystrix的断路器功能;
  • 集成 Spring Cloud 服务发现功能;
  • 易于编写的 Predicate(断言)和 Filter(过滤器);
  • 请求限流功能;
  • 支持路径重写。

工作原理

客户端向 Spring Cloud Gateway 发出请求。如果 Gateway Handler Mapping 中找到与请求相匹配的路由,将其发送到 Gateway Web Handler。Handler 再通过指定的过滤器链来将请求发送到我们实际的服务执行业务逻辑,然后返回。 过滤器之间用虚线分开是因为过滤器可能会在发送代理请求之前(“pre”)或之后(“post”)执行业务逻辑

在没有端口的路由中定义的URI,HTTP和HTTPS URI的默认端口值分别为80和443。

与Zuul 1.*的对比

Zuul构建于 Servlet 2.5,兼容 3.x,使用的是阻塞式API不支持长连接

Spring Cloud Gateway构建于 Spring 5+,基于 Spring Boot 2.x 响应式的、非阻塞式的 API。同时,它支持 websockets,在使用方面也比较简单

毕竟Spring Cloud Gateway是Spring Cloud 现在主力推广的,从最新的Spring Cloud 版本可以看到并没有集成Zuul 2.*

最后在满足公司业务的情况下,用Zuul 1.* 、Spring Cloud Gateway 都是可以的。毕竟很少有公司达到Netflix 日几千亿的访问量。 Zuul 2.* 目前使用的比较少,遇到问题文档比较少,并且Spring Cloud 目前并没有集成,暂时还是先观望吧。

Gateway 的基本使用

谓词

内置的路由谓词工厂
在简单使用这一节我们主要来说一下内置的路由,并介绍如何使用。官方文档

Spring Cloud Gateway内置了一系列的路由谓词工厂,以便我们可以在开发中灵活的使用Gateway进行请求转发。Gateway内置的路由谓词工厂如下表:

路由谓词工厂作用参数
After当且仅当请求时的时间After配置的时间时,才转发该请求一个带有时区的具体时间
Before当且仅当请求时的时间Before配置的时间时,才转发该请求一个带有时区的具体时间
Between当且仅当请求时的时间Between配置的时间段时,才转发该请求一个带有时区的具体时间段
Cookie当且仅当请求时携带的Cookie名称及值与配置的名称及值相符时,才转发该请求Cookie的名称及值,支持使用正则表达式来匹配值
Header当且仅当请求时携带的Header名称及值与配置的名称及值相符时,才转发该请求Header的名称及值,支持使用正则表达式来匹配值
Host当且仅当请求时名为Host的Header的值与配置的值相符时,才转发该请求Host的值,支持配置多个且支持使用通配符
Method当且仅当请求时所使用的HTTP方法与配置的请求方法相符时,才转发该请求HTTP请求方法,例如GET、POST等
Path当且仅当请求时所访问的路径与配置的路径相匹配时,才转发该请求通配符、占位符或具体的接口路径,可以配置多个
Query当且仅当请求时所带有的参数名称与配置的参数名称相符时,才转发该请求参数名称和参数值(非必须),支持使用正则表达式对参数值进行匹配
RemoteAddr当且仅当请求时的IP地址与配置的IP地址相符时,才转发该请求IP地址或IP段
Weight当且仅当配置了权重,并且多余一个的情况时,根据配置的权重才可以调用具体的服务,一般调用的服务是同一个且负载部署的应用。数字,数据越大,被调用的次数越多。

注意:当predicates配置项只配置了一个Predicate且没有配置Path时,Path的默认值为/**

使用以下代码可以打印带有时区的当前时间,然后再自行修改成特定时间即可:

System.out.println(ZonedDateTime.now());

下面我们针对每个谓词进行分析
所有的谓词和过滤器是可以根据需求整合在一起使用的。

谓词 After

下面我们开始第一个谓词After ,该谓词后面需要拼接一个参数,即日期时间。该谓词匹配在指定日期时间之后发生的请求。匹配不到会返回404

为了演示方便,我们新建一个项目模块cloud-gateways-gateway-simple,并且为了使后面其他问题也使用这个项目,我们使用多profile不同配置构建项目,每个profile 是一种谓词类型。

导入依赖(Spring Cloud Gateway 是使用 netty+webflux 实现因此不需要再引入 spring-boot-starter-web包,这里我们引入Eureka,是为了直接通过Eureka 获取注册服务并发送请求。)

    <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>
    </dependencies>

启动类

@EnableDiscoveryClient
@SpringBootApplication
public class GatewaySimpleApplication {
    public static void main(String[] args) {
        SpringApplication.run(GatewaySimpleApplication.class, args);
    }
}

应用配置

为了方便,我们使用多profile的形式来配置应用,对于多profile不了解的话,可以去看一下spring和springboot基础。这里我们配置了端口和服务名以及当前使用的profile和注册中心。对于Spring Cloud 网关我们配置了在2020年5月5号14:20:14.455之后才可以访问的路由,请求路由的时候回拿当前时间和配置的时间做对比。成功的话我们会路由到ribbon-server服务上去。

使用以下代码可以打印带有时区的当前时间,然后再自行修改成特定时间即可:

System.out.println(ZonedDateTime.now());
server:
  port: 8699
spring:
  application:
    name: gateways-gateway
  profiles:
    active: after_route
eureka:
  client:
    service-url:
      defaultZone: http://xunfei:12345@127.0.0.1:8761/eureka/
---
spring:
  cloud:
    gateway:
      routes:
        - id: after_route #我们自定义的路由 ID,保持唯一
          uri: lb://ribbon-server #目标服务地址
          predicates: #路由条件,支持多个参数。
            - After=2020-05-05T14:20:14.455+08:00[Asia/Shanghai]
  profiles: after_route #过滤规则,支持多个参数,可以和predicates共用。

启动项目,访问127.0.0.1:8699/user/1

然后我们修改配置把时间配置加大,设置到2021年:

          predicates: #路由条件,支持多个参数。
            - After=2021-05-05T14:20:14.455+08:00[Asia/Shanghai]

再次启动访问。发现返回404,因为网关没有转发请求:

AfterRoutePredicateFactory 源码分析

@Override
public Predicate<ServerWebExchange> apply(Config config) {
   return new GatewayPredicate() {
      @Override
      public boolean test(ServerWebExchange serverWebExchange) {
         final ZonedDateTime now = ZonedDateTime.now();
         return now.isAfter(config.getDatetime());
      }

      @Override
      public String toString() {
         return String.format("After: %s", config.getDatetime());
      }
   };
}

可以看到仅仅是获取当前时间,然后和配置的时间作比较,之后交给NettyRoutingFilter执行

NettyRoutingFilter 源码

@Override
@SuppressWarnings("Duplicates")
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
   URI requestUrl = exchange.getRequiredAttribute(GATEWAY_REQUEST_URL_ATTR);

   String scheme = requestUrl.getScheme();
   if (isAlreadyRouted(exchange)
         || (!"http".equals(scheme) && !"https".equals(scheme))) {
      return chain.filter(exchange);
   }
   setAlreadyRouted(exchange);

   ServerHttpRequest request = exchange.getRequest();

   final HttpMethod method = HttpMethod.valueOf(request.getMethodValue());
   final String url = requestUrl.toASCIIString();

   HttpHeaders filtered = filterRequest(getHeadersFilters(), exchange);

   final DefaultHttpHeaders httpHeaders = new DefaultHttpHeaders();
   filtered.forEach(httpHeaders::set);

   boolean preserveHost = exchange
         .getAttributeOrDefault(PRESERVE_HOST_HEADER_ATTRIBUTE, false);

   Flux<HttpClientResponse> responseFlux = this.httpClient.headers(headers -> {
      headers.add(httpHeaders);
      if (preserveHost) {
         String host = request.getHeaders().getFirst(HttpHeaders.HOST);
         headers.add(HttpHeaders.HOST, host);
      }
      else {
         // let Netty set it based on hostname
         headers.remove(HttpHeaders.HOST);
      }
   }).request(method).uri(url).send((req, nettyOutbound) -> {
      if (log.isTraceEnabled()) {
         nettyOutbound.withConnection(connection -> log.trace(
               "outbound route: " + connection.channel().id().asShortText()
                     + ", inbound: " + exchange.getLogPrefix()));
      }
      return nettyOutbound.send(request.getBody()
            .map(dataBuffer -> ((NettyDataBuffer) dataBuffer).getNativeBuffer()));
   }).responseConnection((res, connection) -> {

      // Defer committing the response until all route filters have run
      // Put client response as ServerWebExchange attribute and write
      // response later NettyWriteResponseFilter
      exchange.getAttributes().put(CLIENT_RESPONSE_ATTR, res);
      exchange.getAttributes().put(CLIENT_RESPONSE_CONN_ATTR, connection);

      ServerHttpResponse response = exchange.getResponse();
      // put headers and status so filters can modify the response
      HttpHeaders headers = new HttpHeaders();

      res.responseHeaders()
            .forEach(entry -> headers.add(entry.getKey(), entry.getValue()));

      String contentTypeValue = headers.getFirst(HttpHeaders.CONTENT_TYPE);
      if (StringUtils.hasLength(contentTypeValue)) {
         exchange.getAttributes().put(ORIGINAL_RESPONSE_CONTENT_TYPE_ATTR,
               contentTypeValue);
      }

      HttpStatus status = HttpStatus.resolve(res.status().code());
      if (status != null) {
         response.setStatusCode(status);
      }
      else if (response instanceof AbstractServerHttpResponse) {
         // https://jira.spring.io/browse/SPR-16748
         ((AbstractServerHttpResponse) response)
               .setStatusCodeValue(res.status().code());
      }
      else {
         // TODO: log warning here, not throw error?
         throw new IllegalStateException("Unable to set status code on response: "
               + res.status().code() + ", " + response.getClass());
      }

      // make sure headers filters run after setting status so it is
      // available in response
      HttpHeaders filteredResponseHeaders = HttpHeadersFilter
            .filter(getHeadersFilters(), headers, exchange, Type.RESPONSE);

      if (!filteredResponseHeaders.containsKey(HttpHeaders.TRANSFER_ENCODING)
            && filteredResponseHeaders.containsKey(HttpHeaders.CONTENT_LENGTH)) {
         // It is not valid to have both the transfer-encoding header and
         // the content-length header.
         // Remove the transfer-encoding header in the response if the
         // content-length header is present.
         response.getHeaders().remove(HttpHeaders.TRANSFER_ENCODING);
      }

      exchange.getAttributes().put(CLIENT_RESPONSE_HEADER_NAMES,
            filteredResponseHeaders.keySet());

      response.getHeaders().putAll(filteredResponseHeaders);

      return Mono.just(res);
   });

   if (properties.getResponseTimeout() != null) {
      responseFlux = responseFlux.timeout(properties.getResponseTimeout(),
            Mono.error(new TimeoutException("Response took longer than timeout: "
                  + properties.getResponseTimeout())))
            .onErrorMap(TimeoutException.class,
                  th -> new ResponseStatusException(HttpStatus.GATEWAY_TIMEOUT,
                        th.getMessage(), th));
   }

   return responseFlux.then(chain.filter(exchange));
}

所有的谓词、过滤器等配置信息初始化完成之后,最终都会通过这个方法发请求。
如果已经是路由过的,直接执行
如果没有路由过,先设置为已路由
获取header 、url 等请求的数据,并且判断是否是Host 保留,不保留就会清空Host 主机头
之后便是利用初始化后的一些信息发起http请求,并返回结果

谓词 Before

因为我们用的profile所以只需要在原有配置上添加,然后修改active配置即可

server:
  port: 8699
spring:
  application:
    name: gateways-gateway
  profiles:
    active: before_route
eureka:
  client:
    service-url:
      defaultZone: http://xunfei:12345@127.0.0.1:8761/eureka/
---
spring:
  cloud:
    gateway:
      routes:
        - id: after_route #我们自定义的路由 ID,保持唯一
          uri: lb://ribbon-server #目标服务地址
          predicates: #路由条件,支持多个参数。
            - After=2020-05-05T14:20:14.455+08:00[Asia/Shanghai]
  profiles: after_route #过滤规则,支持多个参数,可以和predicates共用。

---
spring:
  cloud:
    gateway:
      routes:
        - id: before_route #我们自定义的路由 ID,保持唯一
          uri: lb://ribbon-server #目标服务地址
          predicates:
            - Before=2090-05-05T14:20:14.455+08:00[Asia/Shanghai]
  profiles: before_route

上面的配置除了下面一段新加的before配置其他基本一样,唯一的变化是把 profiles.active换成了我们用的before_route。

启动项目测试,在2090年之前访问成功,然后修改配置为2009年发现和前面一样网关没有转发,返回404。

谓词 Between

添加修改配置

---
spring:
  cloud:
    gateway:
      routes:
        - id: between_route
          uri: lb://ribbon-server
          predicates:
            - Between=2020-05-01T14:30:11.965+08:00[Asia/Shanghai],2091-01-08T18:30:11.965+08:00[Asia/Shanghai]
  profiles: between_route

同样我们启动测试,然后修改时间再次测试

如果在请求的时候没有设置网关需要的Cookie值,网关将不会转发并返回404,只有Cookie值匹配才会正确转发

修改配置

---
spring:
  cloud:
    gateway:
      routes:
        - id: cookie_route
          uri: lb://ribbon-server
          predicates:
            - Cookie=hello, world
  profiles: cookie_route

同样我们启动测试

如果请求去掉cookie或者cookie值错误就会返回404

谓词 Header

上节我们讲解了Cookie的值校验,下面我们来进行Header 中的值校验,比如我们在系统上面设置一个请求必须代码一个固定的值,否则不允许访问系统。下面我们来演示一下利用Spring Cloud Gateway来校验Header 的值信息。

添加修改配置

---
spring:
  cloud:
    gateway:
      routes:
        - id: header_route
          uri: lb://ribbon-server
          predicates:
            - Header=hello, springcloud
  profiles: header_route

同样启动测试访问

i

谓词 Host

网关的谓词校验不止可以设置Cookie、Header ,还可以匹配校验Host ,对应用也更安全。

修改配置
这里配置的域名支持二级域名,可以是www.zhouning.group。也可以省略3w,如zhouning.group

---
spring:
  cloud:
    gateway:
      routes:
        - id: host_route
          uri: lb://ribbon-server
          predicates:
            - Host=**.zhouning.group
  profiles: host_route

修改测试请求头的Host必须正确,否则会404

谓词 Method

修改配置

---
spring:
  cloud:
    gateway:
      routes:
        - id: method_route
          uri: lb://ribbon-server
          predicates:
            - Method=POST
  profiles: method_route

再次通过get请求访问会返回404,因为网关只转发post请求,如果用post会返回405请求类型错误表明网关已经转发只是服务端需要的是post。

谓词 Path

Path的使用方式: 输入两个参数:SpringPathMatcher表达式列表、【可选】matchOptionalTrailingSeparator标识

---
spring:
  cloud:
    gateway:
      routes:
        - id: host_path_route
          uri: lb://ribbon-server
          predicates:
            - Path=/user/{segment},/manager/{segment}
  profiles: host_path_route

启动测试,可以在服务端添加其他请求处理,但只有/user/和/manager/请求路径会转发。

谓词 Query

查询路由谓词工厂采用两个参数:required param和optional regexp

修改配置

---
spring:
  cloud:
    gateway:
      routes:
        - id: query_route
          uri: lb://ribbon-server
          predicates:
            - Query=xunfei
  profiles: query_route

启动项目,参数必须加上xunfei否则不会转发.

谓词 Weight

重路由谓词工厂采用两个参数:group和weight。权重是按组计算的

---
spring:
  cloud:
    gateway:
      routes:
        - id: weight_heigh
          uri: lb://ribbon-server
          predicates:
            - Weight=group1, 8
        - id: weight_low
          uri: lb://ribbon-server-2 #修用我们前面的并行启动改配置,换一个服务名启动ribbon-server
          predicates:
            - Weight=group1, 2
  profiles: weight_route

临时修改ribbon-server的应用名为ribbon-server-2,然后并行启动。同时运行ribbon-server和ribbon-server-2.

ribbon-server配置临时修改,注意测试完改回去保证后面的同步。

# ribbon-server配置临时修改,注意测试完改回去保证后面的同步。
server:
  port: 8775
spring:
  application:
    name: ribbon-server-2
eureka:
  client:
    service-url:
      defaultZone: http://xunfei:12345@127.0.0.1:8761/eureka

访问测试

多访问请求几次,会发现服务提供地址不同。也就是说网关转发的目标地址是变化的。其出现的频率,取决于配置的group后面的权重。这里ribbon-server和ribbon-server-2的权重比率是80%和20%。

image.png

更新时间:2020-05-06 10:12:34

本文由 寻非 创作,如果您觉得本文不错,请随意赞赏
采用 知识共享署名4.0 国际许可协议进行许可
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名
原文链接:https://www.zhouning.group/archives/springcloud11网关之gateway
最后更新:2020-05-06 10:12:34

评论

Your browser is out of date!

Update your browser to view this website correctly. Update my browser now

×