07SpringCloud Gateway服务网关

目标

目标

1、服务网关 Gateway

2、ServerWebExchange

3、Gateway使用Bucket4j限流

服务网关Gateway

API 网关是一个服务,是系统的唯一入口。从面向对象设计的角度看,它与外观模式类似。API 网关封装了系统内部架构,为每个客户端提供一个定制的 API 。它可能还具有其它职责,如身份验证、监控、负载均衡、限流、降级与应用检测。

1. Spring Cloud Gateway 介绍

Spring Cloud Gateway 基于 Spring Boot 2,是 Spring Cloud 的全新项目。Gateway 旨在提供一种简单而有效的途径来转发请求,并为它们提供横切关注点。

Spring Cloud Gateway 中最重要的几个概念:

  • 路由 Route:路由是网关最基础的部分,路由信息由一个 ID 、一个目的 URL 、一组断言工厂和一组 Filter 组成。如果路由断言为真,则说明请求的 URL 和配置的路由匹配。
  • 断言 Predicate:Java 8 中的断言函数。Spring Cloud Gateway 中的断言函数输入类型是 Spring 5.0 框架中的 ServerWebExchange 。Spring Cloud Gateway 中的断言函数允许开发者去定义匹配来自 Http Request 中的任何信息,比如请求头和参数等。
  • 过滤器 Filter:一个标准的 Spring Web Filter。Spring Cloud Gateway 中的 Filter 分为两种类型:Gateway Filter 和 Global Filter。过滤器 Filter 将会对请求和响应进行修改处理。

2. 入门案例

作为网关来说,网关最重要的功能就是协议适配和协议转发,协议转发也就是最基本的路由信息转发。

创建项目 gateway-server ,演示 Gateway 的基本路由转发功能,也就是通过 Gateway 的 Path 路由断言工厂实现 url 直接转发。

  1. 引入 Spring Cloud Gateway:Spring Cloud Routing > Gateway

    注意:Gateway 自己使用了 netty 实现了 Web 服务,此处『不需要引入 Spring Web』,如果引入了,反而还会报冲突错误,无法启动。

    <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.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    
  2. 编写主入口程序代码,如下:

@SpringBootApplication
@EnableDiscoveryClient
public class GatewayServerApplication {

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

    /**
     * 配置
     */
    @Bean
    public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
        /**return builder.routes()
            .route(r -> r
                .path("/jd")
                .uri("http://www.jd.com/")
                .id("jd_route")
            ).build();*/
         return builder.routes().route(new Function<PredicateSpec, Route.AsyncBuilder>() {
            @Override
            public Route.AsyncBuilder apply(PredicateSpec predicateSpec) {
                return predicateSpec.path("/jd")
                        .uri("http://www.jd.com/")
                        .id("jd_route");
            }
        }).build();
    }
}

3、application.yml

server:
  port: 9000
spring:
  application:
    name: eureka-gateway

eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:10086/eureka
logging:
  level:
    org:
      springframework:
        cloud:
          gateway: debug

management:
  endpoints:
    web:
      exposure:
        include: '*'

RouteLocator三个实现类

RouteDefinitionRouteLocator 基于路由定义的定位器
CachingRouteLocator 基于缓存的路由定位器
CompositeRouteLocator 基于组合方式的路由定位器

上述代码配置了一个路由规则:当用户输入 /jd 路径时,Gateway 会将请求导向到 [http://www.jd.com]网址。

除了这种编码方式配置外,Gateway 还支持通过项目配置文件配置。例如:

spring:
  application:
    name: gateway-server
  cloud:
    gateway:
      routes:
        - id: 163_route
          uri: http://www.163.com
          predicates:
            - Path=/163

当用户输入 /163 路径时,Gateway 将会导向到 http://www.163.com 网址。

两种配置方式可以同时使用。

另外,Spring Cloud Gateway 提供了一个 gateway actuator ,如果你在项目中引入了 actuator ,并在配置文件中加入如下配置:

management:
  endpoints:
    web:
      exposure:
        include: '*'
        
#表示所有的端点,通过HTTP公开所有的端点

访问网址 http://localhost:8080/actuator/gateway/routes 可看到如下内容

[{
    "predicate":"Paths: [/jd], match trailing slash: true",
    "route_id":"jd_route",
    "filters":[],
    "uri":"http://www.jd.com:80",
    "order":0
},{
    "predicate":"Paths: [/163], match trailing slash: true",
    "route_id":"163_route",
    "filters":[],
    "uri":"http://www.163.com:80/",
    "order":0
}]

3. Gateway 内置 Predicate

Spring Cloud Gateway 是由很多的路由断言工厂组成。当 HTTP Request 请求进入 Spring Cloud Gateway 的时候,网关中的路由断言工厂就会根据配置的路由规则,对 HTTP Request 请求进行断言匹配。匹配成功则进行下一步处理,否则,断言失败直接返回错误信息。

早期的 Gateway 断言的配置是通过代码中的 @Bean 进行配置,后来才推出配置文件配置。

3.1 Path 路由断言

前面 163 的示例中,我们使用的就是 Path 路由断言。

spring:
  cloud:
    gateway:
      routes:
        - id: <id>         # 路由 ID,唯一
          uri: <目标 URL>  # 目标 URI,路由到微服务的地址
          predicates:              
            - Path=<匹配规则> # 支持通配符

uri 也可以写成为微服务的服务名 :

语法 uri: lb://微服务名

例如:

spring:
  cloud:
    gateway:
      routes:
        - id: 商品微服务
          uri: http://127.0.0.1:8080  ###或者写成uri: lb://provider-service
          predicates:
            - Path=/users/**
        - id: 品牌微服务
          uri: http://www.jd.com
          predicates:
            - Path=/xxx/**     

Path 断言不会改变请求的 URI ,即,Gateway 收到的 URI 是什么样的,那么它将请求转给目标服务的时候,URI 仍然是什么。整个过程中只有 IP、端口部分会被『替换』掉 。这和 Zuul 网关默认会截断一段 URI 的行为『刚好相反』。

再重复一遍:Path 断言不会改变请求的 URI ,整个过程中只有 IP、端口部分会被『替换』掉

3.2 其它断言(了解、自学)

  • After 路由断言

    After 路由断言会要求你提供一个 UTC 格式的时间,当 Gateway 接收到的请求时间在配置的 UTC 时间之后,则会成功匹配,予以转发,否则不成功。

    @Bean
    public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
        ZonedDateTime dateTime = LocalDateTime.now().minusHours(1).atZone(ZoneId.systemDefault());
        return builder.routes()
            .route(r -> r.after(dateTime)
                .uri("http://www.jd.com:80/")
                .id("jd_route"))
            .build();
    }
    

    等价的 application.yml 配置:

    spring:
      cloud:
        gateway:
          routes:
            - id: 商品微服务
              uri: lb://provider-service
              predicates:
                - Path=/users/**
            - id: 品牌微服务
              uri: http://www.jd.com
              predicates:       #品牌微服务有2个断言
                - Path=/xxx/**
                - After=2022-07-21T15:33:11.009+08:00[Asia/Shanghai]
    

    说明:当请求/xxx地址时,如果当前系统时间在20227-21之后,则会成功,否则失败

    UTC是根据原子钟来计算时间,而GMT是根据地球的自转和公转来计算时间。UTC是现在用的时间标准,GMT是老的时间计量标准。UTC更加精确,由于现在世界上最精确的原子钟50亿年才会误差1秒,可以说非常精确。

    对于 UTC 的时间格式,你可以使用如下 Java 代码生成:

    String datetime = ZonedDateTime.now().minusHours(1).format(DateTimeFormatter.ISO_DATE_TIME);
    //当前系统时间 减去一个小时
    System.out.println(datetime);
    
  • Before 路由断言

    Before 路由断言和之前的 After 路由断言类似。它会取一个 UTC 时间格式的时间参数,当请求进来的当前时间在配置的时间之前会成功(放行),否则不能成功。

    spring:
      cloud:
        gateway:
          routes:
            - id: 品牌微服务
              uri: http://www.jd.com
              predicates:       #品牌微服务有2个断言
                - Path=/xxx/**
                - Before=2022-07-21T15:33:11.009+08:00[Asia/Shanghai]
    
  • Between 路由断言

    spring:
      cloud:
        gateway:
          routes:
            - id: 品牌微服务
              uri: http://www.jd.com
              predicates:
                - Path=/xxx/**
                - Between=2020-07-21T15:33:11.009+08:00[Asia/Shanghai],2022-07-21T15:33:11.009+08:00[Asia/Shanghai]
    
  • Cookie 路由断言

    Cooke 路由断言会取两个参数:HTTP 请求所携带的 Cookie 的 key 和 value。当请求中携带的 cookie 和 Cookie 路由断言中配置的 cookie 一致时,路由才匹配成功。

    spring:
      cloud:
        gateway:
          routes:
            - id: 品牌微服务
              uri: http://www.jd.com
              predicates:
                - Cookie=username, tom
                - Path=/xxx/**
    

    该功能可以使用 Postman 进行测试。在 postman 中为请求添加携带的 Cookie 有两种方式。

    1. 直接在 Headers 中添加 Cookie和 username=tom
    2. Cookies 功能中使用 Add Cookie 添加
  • Header 路由断言

Header 路由断言用于根据 HTTP 请求的 header 中是否携带所配置的信息与否,来决定是否通过断言。

spring:
  cloud:
    gateway:
      routes:
        - id: 商品微服务
          uri: lb://provider-service
          predicates:
            - Header=token, tom
			- Path=/xxx/** 
  • Method 路由断言

    Method 路由断言会根据路由信息所配置的 method 对请求方式是 GET 或者 POST 等进行断言匹配。

    spring:
      cloud:
        gateway:
          routes:
            - id: 商品微服务
              uri: lb://provider-service
              predicates:
                - Method=GET    #必须是get请求
                - Path=/users/** 
    
  • Query 路由断言

    Query 断言会从请求中获取两个参数,将请求参数和 Query 断言中的配置进行匹配。

    例如,http://localhost:9000/test?username=tom 中的 username=tomr.query(“username”, “tom”) 匹配。

    spring:
      cloud:
        gateway:
          routes:
            - id: 商品微服务
              uri: lb://provider-service
              predicates:
                - Query=username, tom  #必须携带请求参数username,而且值必须是tom,不能是其它的值
                - Path=/users/**  
    
  • 组合使用

    各种 Predicates 同时存在于同一个路由时,请求必须『同时满足所有』的条件才被这个路由匹配。

    spring:
      cloud:
        gateway:
          routes:
            - id: 商品微服务
              uri: lb://provider-service
              order: 0
              predicates:
                - Path=/user/**
                - Method=GET
                - Header=X-Request-Id, \d+
                - Query=name, zhangsan.    #请求参数必须是name=zhangsan.  ”点“ 表示匹配任务一个字符
    

order代表的优先级是从小往大排序的,即数值越小,优先级越高

4. 自定义路由断言

自定义路由断言,就是允许你自定义路由的评判规则。自定义路由断言有几个前提要求:

  1. 自定义的路由断言要继承 AbstractRoutePredicateFactory 类。
  2. 自定义的路由断言按惯例叫作:XxxRoutePredicateFactory ,这样,在未来使用时可直接使用 Xxx 作为其名字引用。当然,你可以通过 name() 方法自定义名字,后续使用时,就使用 name() 返回的字符串。
  3. 每个 RoutePredicateFactory 都会有一个 Config 类与之对应,由于它们常见是 1:1 的关系,所以,通常会将 Config 类定义成 RoutePredicateFactory 内部类的形式。

例如:

@Component
public class XxxRoutePredicateFactory
        extends AbstractRoutePredicateFactory<XxxRoutePredicateFactory.Config> {

    public XxxRoutePredicateFactory() {
        super(Config.class);
    }
    /*@Override
    public String name() {
        return "yyy";
    }*/

    @Override
    public Predicate<ServerWebExchange> apply(Config config) {
      return exchange -> {
            String requestURI = exchange.getRequest().getURI().getPath();
            log.debug("request-uri: {}", requestURI);

            if (requestURI.length() % 2 == 0)
                return true;
            else
                return false;
        };
    }

    public static class Config {
      // 简单情况下,Config 类里可以什么都没有
    }
}

想要使用自定义的路由断言只需要这样配置它:

spring:
  cloud:
    gateway:
      routes:
        - id: 商品微服务
          uri: lb://provider-service
          predicates:
            - name: Xxx   #自定义断言名字,  为XxxRoutePredicateFactory 类的前缀Xxx 
            - Path=/users/**  

Config 类的作用是用于指出自定义的 Xxx 路由断言的配置中支持自定义的属性。

为自定义的路由断言添加属性及 getter/setter:

@Data
public static class Config{
   private String name;
   private String password;
}

这样在配置中,你就可以『多』添加两个自定义的属性:

- id: 商品微服务
  uri: lb://provider-service
  predicates:
    - name: Xxx
      args:
        name: 张三丰 # 看这里
        password: 111 # 和这里 
    - Path=/users/**

回头,你可以在你的自定义路由断言的 apply 方法中取到你在配置文件中所填写的值。(因为 apply 方法的参数就是 Config 对象)。

@Override
public Predicate<ServerWebExchange> apply(Config config) {
    return new Predicate<ServerWebExchange>() {
        @Override
        public boolean test(ServerWebExchange serverWebExchange) {
            String path = serverWebExchange.getRequest().getURI().getPath();
            MultiValueMap<String, String> queryParams = serverWebExchange.getRequest().getQueryParams();
            List<String> queryNames = queryParams.get("name");
            List<String> queryPass = queryParams.get("pass");

            String name = config.getName();
            String pass = config.getPassword();
            if(name.equals(queryNames.get(0)) && pass.equals(queryPass.get(0))){
                return true;
            }
            return false;
        }
    };
}

5. Gateway 内置 Filter(了解)

Spring Cloud Gateway 中内置了很多的过滤器,你也可以根据自己的实际需求定制并添加自己的路由过滤器。

路由过滤器允许以某种方式修改请求进来的 HTTP 请求或返回的 HTTP 响应。

5.1 AddRequestHeader 过滤器

AddRequestHeader 过滤器用于对匹配上的请求加上指定的 header 。

在另一个端口(例如 8081)运行一个服务提供者项目,其中代码负责从请求的请求头中获取数据,类似如下:

@RequestMapping("/user/test")
public String header(@RequestHeader(name="jwtToken", required = false) String token,String name,String pass) {
  System.out.println(token);
  return username == null ? "null" : username;
}

.yml 配置文件配置

spring:
  application:
    name: eureka-gateway
  cloud:
    gateway:
      routes:
        - id: route1
          uri: http://localhost:8081/    ##uri: lb://eureka-consumer
          predicates:
            - Path=/user/**  
          filters:
            - AddRequestHeader=jwtToken, aaaaaaaaaaaa

当我们在浏览器输入http://localhost:9000/user/test?username=zhangsan&password=111时,首先网关能匹配到请求url,然后转发给8081,其实是把IP和端口替换掉,则请求url为http://localhost:8081/user/test?username=zhangsan&password=111,在请求8081之前,过滤器帮我们在请求头添加 jwtToken=aaaaaaaaaaaa,

5.2 StripPrefix 过滤器

StripPrefixGatewayFilterFactory 是一个针对请求 url 进行处理的 filter 工厂,用于去除前缀。使用数字表示要截断的路径的数量。

spring:
  cloud:
    gateway:
      routes:
        - id: authentication_route
          uri: http://127.0.0.1:8081
          predicates:
            - Path=/user/**
          filters:
            - StripPrefix=1

8081服务请求方法如下

@RequestMapping("/test")
public String xxx(){
    return "ok";
}

请求url:http://localhost:9000/user/test

则网关转发到8081时,去掉一个前缀,真实url为:http://localhost:8081/test

5.3 RewritePath 过滤器

RewritePath 过滤器可以重写 URI,去掉 URI 中的前缀。例如,下面就是去掉所有 URI 中的 /xxx/yyy/zzz 部分,只留之后的内容,再进行转发。

以上 Java 代码配置等同于 .yml 配置:

spring:
  cloud:
    gateway:
      routes:
        - id: 163_route
          uri: http://localhost:8081
          predicates:
            - Path=/xxx/yyy/zzz/**
          filters:
            - RewritePath=/xxx/yyy/zzz/(?<segment>.*), /$\{segment}

对于请求路径 /xxx/yyy/zzz/hello ,当前的配置在请求到到达前会被重写为 /hello ,

  • 命名分组:(?<name>正则表达式)

    与普通分组一样的功能,并且将匹配的子字符串捕获到一个组名称或编号名称中。在获得匹配结果时,可通过分组名进行获取。

    (?<segment>.*):匹配 0 个或多个任意字符,并将匹配的结果捕获到名称为 segment 的组中。

  • 引用捕获文本:${name}

    将名称为 name 的命名分组所匹配到的文本内容替换到此处。

    $\{segment}:将前面捕获到 segment 中的文本置换到此处,注意,\ 的出现是由于避免 YAML 认为这是一个变量而使用的转义字符。

    如:http://localhost:9000/xxx/yyy/zzz/test 则segment匹配到的内容为test

    最终转发到 http://localhost:8080/test

5.4 其他过滤器

6. Gateway 整合 Eureka 注册中心实现路由

Gateway 整合 Eureka Sever(注册中心)之后,会以微服务的 name(或者url) 和 URI 的对应关系为依据(利用 Path 路由断言),将它特定 URL 的请求转给对应的微服务。

  1. 首先将 Gateway 视作普通的 Eureka Client 进行配置、启动。让其『连上』注册中心,从注册中心拉去各个微服务的信息(网址、端口等)。

    eureka:
      client:
        service-url:
          defaultZone: http://127.0.0.1:10086/eureka
        registry-fetch-interval-seconds: 5
        register-with-eureka: true
    
  2. 配置若干与 Gateway 相关的配置:

    spring.cloud.gateway.discovery.locator.enabled=true 
    spring.cloud.gateway.discovery.locator.lower-case-service-id=true
    logging.level.org.springframework.cloud.gateway=DEBUG
    
    • .locator.enabled

      该配置是 Gateway 与注册中心整合的开关项。必然要赋值为 true 。

    • .locator.lower-case-service-id

      由于在 Eureka Server 上各个微服务的 ID 都是全大写英文,因此默认情况下,(未来在)Gateway 的路由路径中出现的也会是全大写。

      相较而言,大家看全小写英文会更为习惯一些。另外,一旦开启 lower-case,那么就不能用全大写了,而且大小写不能混用

    • logging.level.org.springframework.cloud.gateway

      日志是非必要配置,这里配置成 DEBUG 级别是为了验证 Gateway 自动生成了 Path 断言规则。

先后启动『注册中心』、『服务提供者』和『Gateway』,访问 Gateway,并在访问路径中加上 /服务提供者的标识,例如:/microservice-department/hello,你会发现这个请求会被 Gateway 转给 microservice-department 的 /hello

并且,日志中会有类似如下一条信息:

RouteDefinition matched: ReactiveCompositeDiscoveryClient_EUREKA-CONSUMER

案例:

spring:
  cloud:
    gateway:
      routes:
        - id: 商品微服务
          uri: lb://provider-service
          predicates:
            - Path=/users/**  #Path断言函数

        - id: 品牌微服务
          uri: http://www.jd.com
          predicates:
            - Path=/jd/**  #Path断言函数
      discovery:
        locator:
          enabled: true
          lower-case-service-id: true
server:
  port: 9000
eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:10086/eureka
    registry-fetch-interval-seconds: 5
    register-with-eureka: true

请求:http://localhost:9000/eureka-consumer/test

说明:eureka-consumer 表示服务名 对应 localhost:8081

7. 自定义路由局部 Filter

和自定义路由断言一样,自定义路由有几个前提要求:

  1. 自定义的路由过滤器要继承 AbstractGatewayFilterFactory
  2. 自定义的路由过滤器按惯例叫做:XxxGatewayFilterFactory ,这样,在未来使用时可以使用 Xxx 作为其名字引用。当然,你可以通过 name() 方法自定义名字,

注意:如果没有自定义名字,则自定义过滤器的名字必须叫 名字+GatewayFilterFactory

3.每个 GatewayFilterFactory 都会有一个 Config 类与之对应,由于它们常见是 1:1 的关系,所以,通常会将 Config 类定义成 GatewayFilterFactory 内部类的形式。

@Component
public class XxxGatewayFilterFactory
        extends AbstractGatewayFilterFactory<XxxGatewayFilterFactory.Config> {
    public XxxGatewayFilterFactory() {
        super(Config.class);
    }
    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {
            // 逻辑代码 ...
            if (...) {
                // 流程继续向下,走到下一个过滤器,直至路由目标。
                return chain.filter(exchange);
            } else {
                // 否则流程终止,拒绝路由。
                exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
                return exchange.getResponse().setComplete();
            };
        }
    }
    public static class Config {
        // 简单情况下,Config 类里可以什么都没有
    }
}

上面的过滤器的逻辑结构所实现的功能:当条件成立时,允许路由;否则,直接返回

路由器的所有代码逻辑都是在『路由前』执行,也就是转发的微服务即使没有启动也会执行。当然,这种形式的过滤器的更简单的情况是:执行某些代码,然后始终是放行。
要注意:当自定义多个局部过滤器时,依靠配置文件 -name 来保证执行顺序,如:

filters:
-name: Xxx 先执行 不是按照Order的值来决定,@Order只对全局过滤器起作用
-name: Yyy 后执行

另外如果既有局部过滤器,又有全局过滤器,那么先执行所有的局部过滤器,根据局部过滤器根据配置文件配置的先后顺序(默认也是有一个顺序的,从1开始递增),再执行所有的全局过滤器,全局过滤器的顺序看@Order 的值,值最小先执行

案例:对用户信息进行认证

@Component
public class XxxGatewayFilterFactory extends AbstractGatewayFilterFactory<XxxGatewayFilterFactory.Config> {
    public XxxGatewayFilterFactory() {
        super(Config.class);
    }
    @Override
    public String name() {
        return "yyy";
    }
    @Override
	public GatewayFilter apply(Config config) {
    	return new GatewayFilter() {
        @Override
        public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {

            List<String> token = exchange.getRequest().getHeaders().get("token");
            if (token.get(0).equals("aaa")) {
                return chain.filter(exchange);
            } else {
                // 否则流程终止,拒绝路由。
                //exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
                //return exchange.getResponse().setComplete();
                String jsonStr = "{\"status\":\"-1\", \"msg\":\"token非法\"}";
                byte[] bytes = jsonStr.getBytes(StandardCharsets.UTF_8);
                DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
                return exchange.getResponse().writeWith(Flux.just(buffer));
            }
        }
    };
}

配置文件,注意过滤器重写了名字为 yyy

spring:
  application:
    name: eureka-gateway
  cloud:
    gateway:
      routes:
        - id: 商品微服务
          uri: lb://provider-service
          predicates:
            - Path=/test
          filters:
            - name: yyy
      discovery:
        locator:
          enabled: true
          lower-case-service-id: true

测试:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6vtOBNZp-1686123017108)(https://woniumd.oss-cn-hangzhou.aliyuncs.com/java/chenyun/image-20210610161618099.png)]

还可以获取其它的信息

ServerHttpRequest request = exchange.getRequest();

log.info("{}", request.getMethod());
log.info("{}", request.getURI());
log.info("{}", request.getPath());
log.info("{}", request.getQueryParams());   // Get 请求参数

request.getHeaders().keySet().forEach(key -> {
    log.info("{}: {}", key, request.getHeaders().get(key))
});

8. JSON 形式的错误返回

上述的『拒绝』是以 HTTP 的错误形式返回,即 4xx、5xx 的错误。

有时,我们的返回方案是以 200 形式的『成功』返回,然后再在返回的信息中以自定义的错误码和错误信息的形式告知请求发起者请求失败。

此时,就需要 过滤器『成功』返回 JSON 格式的字符串:

String jsonStr = "{\"status\":\"-1\", \"msg\":\"error\"}";
byte[] bytes = jsonStr.getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
return exchange.getResponse().writeWith(Flux.just(buffer));

9. 获取 Body 中的请求参数(了解、自学)

由于 Gateway 是基于 Spring 5 的 WebFlux 实现的(采用的是 Reactor 编程模式),因此,从请求体中获取参数信息是一件挺麻烦的事情。

有一些简单的方案可以从 Request 的请求体中获取请求参数,不过都有些隐患和缺陷。

最稳妥的方案是模仿 Gateway 中内置的 ModifyRequestBodyGatewayFilterFactory,不过,这个代码写起来很啰嗦。

具体内容可参考这篇文章:Spring Cloud Gateway(读取、修改 Request Body)

不过考虑到 Gateway 只是做请求的『转发』,而不会承担业务责任,因此,是否真的需要在 Gateway 中从请求的 Body 中获取请求数据,这个问题可以斟酌。

10. 过滤器的另一种逻辑形式

有时你对过滤器的运用并非是为了决定是否继续路由,为了在整个流程中『嵌入』额外的代码、逻辑:在路由之前和之后执行某些代码
如果仅仅是在路由至目标微服务之前执行某些代码逻辑,那么 Filter 的形式比较简单:

return (exchange, chain) -> {
    // 逻辑代码 ...
    // 流程继续向下,走到下一个过滤器,直至路由目标。
    return chain.filter(exchange);
}

如果,你想在路由之前和之后(即,目标微服务返回之后)都『嵌入』代码,那么其形式就是:

@Override
public GatewayFilter apply(Config config) {
    return ((exchange, chain) -> {
        log.info("目标微服务【执行前】执行");
        return chain.filter(exchange)
            .then(Mono.fromRunnable(() -> {
                log.info("目标微服务【执行后】执行");
            }));
    });
}

例如,显示一个用于统计微服务调用时长的过滤器以及微服务返回的结果:

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

@Override
public GatewayFilter apply(Config config) {
    return (exchange, chain) -> {
        List<String> token = exchange.getRequest().getHeaders().get("token");
        long startTime = System.currentTimeMillis();
        return chain.filter(exchange).then(Mono.fromRunnable(new Runnable() {
            @Override
            public void run() {
                long endTime = System.currentTimeMillis();
                HttpStatus statusCode = exchange.getResponse().getStatusCode();
                log.info("用了{}",(endTime-startTime));
            }
        }));
    };
}

配置:

spring:
  cloud:
    gateway:
      routes:
        - id: 商品微服务
          uri: lb://provider-service
          predicates:
            - Path=/users/**
          filters:
            - name: yyy

11. 自定义过滤器的参数

和自定义路由断言一样,自定义的过滤器断言可以自定义参数。
定义的形式是写成 Config 类的属性;使用的形式是在配置中使用 args 配置。

filters:
  - name: yyy
    args:
      name: hello
      password: world

12.网关异常处理

问题:项目中使用springcloud-gateway,请求到网关,再路由到微服务时出现微服务未找到异常,我们先通过过滤器放行,对于微服务的结果,通过状态值来进行判定,如收到微服务的500、400、404的错误。对于网关抛出的异常可以定义统一的异常处理器

1、获取微服务相关的异常状态信息

@Component
public class XxxGatewayFilterFactory extends AbstractGatewayFilterFactory<XxxGatewayFilterFactory.Config> {
    public XxxGatewayFilterFactory(){
        super(Config.class);
    }
    @Override
    public GatewayFilter apply(Config config) {
        return new GatewayFilter() {
            @Override
            public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
               List<String> token = exchange.getRequest().getHeaders().get("token");
                if(token.get(0).equals("qqq")){
                    return chain.filter(exchange).then(Mono.fromRunnable(new Runnable() {
                        @Override
                        public void run() {  //这是一个异步操作
                            HttpStatus statusCode = exchange.getResponse().getStatusCode();
                            if (statusCode.value() == 500) {
                                throw new RuntimeException("{\"status\":\"500\", \"msg\":\"服务器内部报错\"}");
                            } else if (statusCode.value() == 404) {
                                throw new RuntimeException("{\"status\":\"404\", \"msg\":\"url地址不合法\"}");
                            }else if (statusCode.value() == 400) {
                                throw new RuntimeException("{\"status\":\"400\", \"msg\":\"请求参数错误\"}");
                            }
                        }
                    }));
                }else{
                    String jsonStr = "{\"status\":\"-1\", \"msg\":\"token无效|token为空\"}";
                    byte[] bytes = jsonStr.getBytes(StandardCharsets.UTF_8);
                    DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
                    return exchange.getResponse().writeWith(Flux.just(buffer));
                }
            }
        };
    }
    public static class Config{}
}

2、定义统一异常处理的相关类,继承ErrorWebExceptionHandler

@Slf4j
@Order(6)
@Component
public class GlobalExceptionConfiguration implements ErrorWebExceptionHandler {
    ObjectMapper objectMapper = new ObjectMapper();
    @Override
    public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
        ServerHttpResponse response = exchange.getResponse();
        //response是服务端对客户端请求的一个响应,其中封装了响应头、状态码、内容(也就是最终要在浏览器上显示的HTML
        // 代码或者其他数据格式)等,服务端在把response提交到客户端之前,会使用一个缓冲区,并向该缓冲区内写入响应头和
        // 状态码,然后将所有内容flush(flush包含两个步骤:先将缓冲区内容发送至客户端,然后将缓冲区清空)。这就标志着该
        // 次响应已经committed(提交)。对于当前页面中已经committed(提交)的response,就不能再使用这个response向缓冲区
        // 写任何东西  (注:以为JSP中,response是一个JSP页面的内置对象,所以同一个页面中的response.XXX()是同一个response的不同方法,只要其中一个已经导致了committed, 那么其它类似方式的调用都会导致 IllegalStateException异常)
        if (response.isCommitted()) {
            return Mono.error(ex);
        }
        response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
        System.out.println(ex);
        return response.writeWith(Mono.fromSupplier(new Supplier<DataBuffer>() {
            @Override
            public DataBuffer get() {
                DataBufferFactory bufferFactory = response.bufferFactory();
                try {
                    return bufferFactory.wrap(objectMapper.writeValueAsBytes(ex.getMessage()));
                } catch (JsonProcessingException e) {
                    log.warn("Error writing response", ex);
                    return bufferFactory.wrap(new byte[0]);
                }
            }
        }));

    }
}

说明:

对于这种微服务的状态捕获的情况,也就是网关应该获取每个微服务错误状态码,所以严格的来说,写在全局过滤器更好

12. 自定义全局 Filter

自定义全局过滤器比局部过滤器要简单,因为它『不需要指定对哪个路由生效,它对所有路由都生效』。

@Order 注解是为了去控制全局过滤器的先后顺序,不是局部的顺序,值越小,优先级越高。

@Component
@Order(0)
public class CustomGlobalFilter implements GlobalFilter{
    
}

案例:如果ip是本机 就不放行

@Component
public class CustomGlobalFilter implements GlobalFilter, Ordered {
    @Override
    public int getOrder() {
        return -100;
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        HttpHeaders headers = exchange.getRequest().getHeaders();
        String host = exchange.getRequest().getURI().getHost();
        System.out.println(host);
        // 此处写死了,演示用,实际中需要采取配置的方式
        if (host.contains("localhost")) {
            ServerHttpResponse response = exchange.getResponse();
            response.setStatusCode(HttpStatus.UNAUTHORIZED);
            String jsonStr = "{\"status\":\"-1\", \"msg\":\"error\"}";
            byte[] bytes = jsonStr.getBytes(StandardCharsets.UTF_8);
            DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
            return response.writeWith(Mono.just(buffer));
        }
        return chain.filter(exchange);
    }
}

案例2:获取微服务的状态信息,如果非200,进行统一的异常处理

@Component
@Order(2)
public class TokenGlobalFilter implements GlobalFilter {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String host = exchange.getRequest().getURI().getHost();
        List<String> tokens = exchange.getRequest().getHeaders().get("token");
        if (tokens != null) {
            if (tokens.get(0).equals("qqq")) {
                return chain.filter(exchange).then(Mono.fromRunnable(new Runnable() {
                    @Override
                    public void run() {
                        HttpStatus statusCode = exchange.getResponse().getStatusCode();
                        if (statusCode.value() == 500) {
                            throw new RuntimeException("{\"status\":\"500\", \"msg\":\"服务器内部报错\"}");
                        } else if (statusCode.value() == 404) {
                            throw new RuntimeException("{\"status\":\"404\", \"msg\":\"url地址不合法\"}");
                        }else if (statusCode.value() == 400) {
                            throw new RuntimeException("{\"status\":\"400\", \"msg\":\"请求参数错误\"}");
                        }
                    }
                }));
            }else{
                String jsonStr = "{\"status\":\"400\", \"msg\":\"token非法\"}";
                byte[] bytes = jsonStr.getBytes(StandardCharsets.UTF_8);
                DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
                return exchange.getResponse().writeWith(Flux.just(buffer));
            }
            
        } else {
            String jsonStr = "{\"status\":\"400\", \"msg\":\"token无效|token为空\"}";
            byte[] bytes = jsonStr.getBytes(StandardCharsets.UTF_8);
            DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
            return exchange.getResponse().writeWith(Flux.just(buffer));
        } 
    }

    public static class Config {
    }
}

定义异常部分在前面的局部异常定义过,这里不再讲解

需要说明的是:如果网关转发的微服务宕机或者没有启动,那么全局过滤器是不会执行的。

13.跨域配置

通过自定义 GatewayFilter 定义过滤器,拦截请求,统一设置请求允许跨域

@Component
public class CrossGatewayFilter implements GlobalFilter, Ordered {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        ServerHttpResponse response = exchange.getResponse();
        HttpHeaders headers = response.getHeaders();
        headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "*");
        headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS,"POST,GET,PUT,DELETE");
        headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
        headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "*");
        headers.add(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS,"*");
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        return -100;
    }
}

采用配置的方式(推荐)

spring:
  application:
    name: hospital-gateway1
  cloud:
    gateway:
      globalcors:
        corsConfigurations:
          '[/**]':
            # 允许携带认证信息
            allow-credentials: true
            # 允许跨域的源(网站域名/ip),设置*为全部
            allowedOrigins: "*"
            # 允许跨域的method, 默认为GET和OPTIONS,设置*为全部
            allowedMethods: "*"
            # 允许跨域请求里的head字段,设置*为全部
            allowedHeaders: "*"
      routes:
        - id: sickroom微服务
          uri: lb://sickroom-service
          predicates:
            - Path=/sickroom/**
          filters:
            - StripPrefix=1

        - id: finance微服务
          uri: lb://finance-service
          predicates:
            - Path=/finance/**
          filters:
            - StripPrefix=1

14.gateway网关的熔断降级

1.添加springcloud的hystrix启动器

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>

2.在gateway网关已经内置了局部的HystrixGatewayFilterFactory过滤器类,直接在要转发的某个微服务上面配置即可

spring:
  application:
    name: spring-service-gateway
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848
    gateway:
      globalcors:
        corsConfigurations:
          '[/**]':
            # 允许携带认证信息
            allow-credentials: true
            # 允许跨域的源(网站域名/ip),设置*为全部
            allowedOrigins: "*"
            # 允许跨域的method, 默认为GET和OPTIONS,设置*为全部
            allowedMethods: "*"
            # 允许跨域请求里的head字段,设置*为全部
            allowedHeaders: "*"
      routes:
        - id: b服务
          uri: lb://spring-service-b
          predicates:
            - Path=/port/**
          filters:
            - name: Xxx
            - name: Hystrix
              args:
                name: fallbackcmd
                fallbackUri: forward:/myfallback
                
#hystrix配置
hystrix:
  command:
    fallbackcmd:
      execution:
        isolation:
          thread:
            #断路器的超时时间ms,默认1000
            timeoutInMilliseconds: 5000
      circuitBreaker:
        #是否启动熔断器,默认为true,false表示不要引入Hystrix。
        enabled: true
        #当在配置时间窗口内达到此数量的失败后,进行短路
        requestVolumeThreshold: 20
        #出错百分比阈值,当达到此阈值后,开始短路。默认50%)
        errorThresholdPercentage: 50%
        #短路多久以后开始尝试是否恢复,默认5s)-单位ms
        sleepWindowInMilliseconds: 30000

3.请求接口定义

在gateway微服务中编写controller请求

@RestController
public class FallBackController {

    @RequestMapping("/myfallback")  //和上面的fallbackUri要对应
    public Map<String,String> myFallback(){

        Map<String,String> map = new HashMap<>();
        map.put("Code","fail");
        map.put("Message","服务暂时不可用,请稍候访问.");
        return map;
    }
}

说明:

1.HystrixGatewayFilterFactory过滤器内置配置类有很多参数,name: fallbackcmd(固定写法)

2.fallbackUri表示熔断后的降级请求地址

3.另外考虑到所有的接口都需要熔断降级,可以配置默认的全局过滤器

spring:
cloud:
	gateway:
	  default-filters:
        - name: Hystrix
          args:
            name: fallbackcmd
            fallbackUri: forward:/myfallback

4.token的校验也应该定义全局的过滤器

5.如果目前微服务正常响应,则不会有问题,如果微服务超时则执行熔断降级逻辑,然后响应,如果微服务直接报错,则熔断器HystrixGatewayFilterFactory不会管,ErrorWebHandlerException不会捕捉到这个异常。如果目标微服务报的异常做了处理,如

return chain.filter(exchange).then(Mono.fromRunnable(new Runnable() {
@SneakyThrows
@Override
public void run() {
HttpStatus statusCode = exchange.getResponse().getStatusCode();
if (statusCode.value() == 500) {
   ErrorStatus error = new ErrorStatus();
   error.setStatus(500);
   error.setMsg("服务器内部报错");
   String errorMsg = new ObjectMapper().writeValueAsString(error);
   throw new RuntimeException(errorMsg); //网关直接抛异常
} else if (statusCode.value() == 404) {
   ErrorStatus error = new ErrorStatus();
   error.setStatus(404);
   error.setMsg("url地址不合法");
   String errorMsg = new ObjectMapper().writeValueAsString(error);
   throw new RuntimeException(errorMsg);
   ......

直接抛异常,则会引发HystrixGatewayFilterFactory的异常,因此ErrorWebHandlerException捕获到的异常就是HystrixGatewayFilterFactory抛出的,而不是我们定义的。需要单独处理。

附录:WebFlux 的 ServerWebExchange

ServerWebExchange 的注释:

ServerWebExchange 是『Spring Reactive Web 世界中』HTTP 请求与响应交互的契约。提供对 HTTP 请求和响应的访问,并公开额外的服务器端处理相关属性和特性,如请求属性。

其实,ServerWebExchange 命名为『服务网络交换器』,存放着重要的请求-响应属性、请求实例和响应实例等等,有点像 Servlet 中的 Context 的角色。

public interface ServerWebExchange {

    // 日志前缀属性的 KEY,值为 org.springframework.web.server.ServerWebExchange.LOG_ID
    // 可以理解为 attributes.set("org.springframework.web.server.ServerWebExchange.LOG_ID", "日志前缀的具体值");
    // 作用是打印日志的时候会拼接这个 KEY 对应的前缀值,默认值为 ""
    String LOG_ID_ATTRIBUTE = ServerWebExchange.class.getName() + ".LOG_ID";
    String getLogPrefix();

    // 获取 ServerHttpRequest 对象
    ServerHttpRequest getRequest();

    // 获取 ServerHttpResponse 对象
    ServerHttpResponse getResponse();
    
    // 返回当前 exchange 的请求属性,返回结果是一个可变的 Map
    Map<String, Object> getAttributes();
    
    // 根据 KEY 获取请求属性
    @Nullable
    default <T> T getAttribute(String name) {
        return (T) getAttributes().get(name);
    }

    // 根据 KEY 获取请求属性,做了非空判断
    @SuppressWarnings("unchecked")
    default <T> T getRequiredAttribute(String name) {
        T value = getAttribute(name);
        Assert.notNull(value, () -> "Required attribute '" + name + "' is missing");
        return value;
    }

     // 根据 KEY 获取请求属性,需要提供默认值
    @SuppressWarnings("unchecked")
    default <T> T getAttributeOrDefault(String name, T defaultValue) {
        return (T) getAttributes().getOrDefault(name, defaultValue);
    } 

    // 返回当前请求的网络会话
    Mono<WebSession> getSession();

    // 返回当前请求的认证用户,如果存在的话
    <T extends Principal> Mono<T> getPrincipal();  

    // 返回请求的表单数据或者一个空的 Map,只有 Content-Type为application/x-www-form-urlencoded 的时候这个方法才会返回一个非空的 Map  --  这个一般是表单数据提交用到
    Mono<MultiValueMap<String, String>> getFormData();   

    // 返回 multipart 请求的 part 数据或者一个空的 Map,只有 Content-Type 为 multipart/form-data 的时候这个方法才会返回一个非空的 Map  --  这个一般是文件上传用到
    Mono<MultiValueMap<String, Part>> getMultipartData();

    // 返回 Spring 的上下文
    @Nullable
    ApplicationContext getApplicationContext();   

    // 这几个方法和 lastModified 属性相关
    boolean isNotModified();
    boolean checkNotModified(Instant lastModified);
    boolean checkNotModified(String etag);
    boolean checkNotModified(@Nullable String etag, Instant lastModified);

    // URL 转换
    String transformUrl(String url);    
  
    // URL 转换映射
    void addUrlTransformer(Function<String, String> transformer); 

    // 注意这个方法,方法名是:改变,这个是修改 ServerWebExchange 属性的方法,返回的是一个 Builder 实例,Builder 是 ServerWebExchange 的内部类
    default Builder mutate() {
         return new DefaultServerWebExchangeBuilder(this);
    }

    // ServerWebExchange 构造器
    interface Builder {      
         
        // 覆盖 ServerHttpRequest
        Builder request(Consumer<ServerHttpRequest.Builder> requestBuilderConsumer);
        Builder request(ServerHttpRequest request);
        
        // 覆盖 ServerHttpResponse
        Builder response(ServerHttpResponse response);
        
        // 覆盖当前请求的认证用户
        Builder principal(Mono<Principal> principalMono);
    
        // 构建新的 ServerWebExchange 实例
        ServerWebExchange build();
    }
}

注意到 ServerWebExchange#mutate 方法,ServerWebExchange 实例可以理解为不可变实例。

如果我们想要修改它,需要通过 ServerWebExchange#mutate 方法生成一个新的实例,后面会修改请求以及响应时会用到,暂时不做介绍。

1. ServerWebExchange 对比 Servlet

这里总结部分我在写代码中遇到的一些不同与相应代替办法

  • request.setAttribute(“key”, "value"); 的替代

    ServerHttpRequest 中并无 Attribute 相关操作,要通过 exchange 来操作。

    exchange.getAttributes().put(“key”, “value”);

  • request.getHeader("test"); 的替代

    request.getHeaders().getFirst(“test”)

    遍历 Header 如下:

HttpHeaders headers = request.getHeaders();
for (Map.Entry<String,List<String>> header:headers.entrySet()) {
    String key = header.getKey();
    List<String> values = header.getValue()}

2. ServerHttpRequest

ServerHttpRequest 实例是用于承载请求相关的属性和请求体,Spring Cloud Gateway 中底层使用 Netty 处理网络请求。

通过追溯源码,可以从 ReactorHttpHandlerAdapter 中得知 ServerWebExchange 实例中持有的 ServerHttpRequest 实例的具体实现是 ReactorServerHttpRequest 。

之所以列出这些实例之间的关系,是因为这样比较容易理清一些隐含的问题,例如:ReactorServerHttpRequest 的父类 AbstractServerHttpRequest 中初始化内部属性 headers 的时候把请求的 HTTP 头部封装为只读的实例

// HttpHeaders 类中的 readOnlyHttpHeaders 方法,其中 ReadOnlyHttpHeaders 屏蔽了所有修改请求头的方法,直接抛出 UnsupportedOperationException
public static HttpHeaders readOnlyHttpHeaders(HttpHeaders headers) {
    Assert.notNull(headers, "HttpHeaders must not be null");
    if (headers instanceof ReadOnlyHttpHeaders) {
        return headers;
    } else {
        return new ReadOnlyHttpHeaders(headers);
    }
}

所有接口:

public interface HttpMessage {

    // 获取请求头,目前的实现中返回的是 ReadOnlyHttpHeaders 实例,只读
    HttpHeaders getHeaders();
}    

public interface ReactiveHttpInputMessage extends HttpMessage {
    
    // 返回请求体的 Flux 封装
    Flux<DataBuffer> getBody();
}

public interface HttpRequest extends HttpMessage {

    // 返回 HTTP 请求方法,解析为 HttpMethod 实例
    @Nullable
    default HttpMethod getMethod() {
        return HttpMethod.resolve(getMethodValue());
    }
    
    // 返回 HTTP 请求方法,字符串
    String getMethodValue();    
    
    // 请求的 URI
    URI getURI();
}    

public interface ServerHttpRequest extends HttpRequest, ReactiveHttpInputMessage {
    
    // 连接的唯一标识或者用于日志处理标识
    String getId();   
    
    // 获取请求路径,封装为 RequestPath 对象
    RequestPath getPath();
    
    // 返回查询参数,是只读的 MultiValueMap 实例
    MultiValueMap<String, String> getQueryParams();

    // 返回 Cookie 集合,是只读的 MultiValueMap 实例
    MultiValueMap<String, HttpCookie> getCookies();  
    
    // 远程服务器地址信息
    @Nullable
    default InetSocketAddress getRemoteAddress() {
       return null;
    }

    // SSL 会话实现的相关信息
    @Nullable
    default SslInfo getSslInfo() {
       return null;
    }  
    
    // 修改请求的方法,返回一个建造器实例 Builder,Builder 是内部类
    default ServerHttpRequest.Builder mutate() {
        return new DefaultServerHttpRequestBuilder(this);
    } 

    interface Builder {

        // 覆盖请求方法
        Builder method(HttpMethod httpMethod);
         
        // 覆盖请求的 URI、请求路径或者上下文,这三者相互有制约关系,具体可以参考 API 注释
        Builder uri(URI uri);
        Builder path(String path);
        Builder contextPath(String contextPath);

        // 覆盖请求头
        Builder header(String key, String value);
        Builder headers(Consumer<HttpHeaders> headersConsumer);
        
        // 覆盖 SslInfo
        Builder sslInfo(SslInfo sslInfo);
        
        // 构建一个新的 ServerHttpRequest 实例
        ServerHttpRequest build();
    }         
}

3. ServerHttpResponse

ServerHttpResponse 实例是用于承载响应相关的属性和响应体,Spring Cloud Gateway 中底层使用 Netty 处理网络请求。

通过追溯源码,可以从 ReactorHttpHandlerAdapter 中得知 ServerWebExchange 实例中持有的 ServerHttpResponse 实例的具体实现是 ReactorServerHttpResponse 。

之所以列出这些实例之间的关系,是因为这样比较容易理清一些隐含的问题,例如:ReactorServerHttpResponse 构造函数初始化实例的时候,存放响应 Header 的是 HttpHeaders 实例,也就是响应 Header 是可以直接修改的。

public ReactorServerHttpResponse(HttpServerResponse response, DataBufferFactory bufferFactory) {
    super(bufferFactory, new HttpHeaders(new NettyHeadersAdapter(response.responseHeaders())));
    Assert.notNull(response, "HttpServerResponse must not be null");
    this.response = response;
}

5.1.所有接口

public interface HttpMessage {
    // 获取响应 Header,目前的实现中返回的是 HttpHeaders 实例,可以直接修改
    HttpHeaders getHeaders();
}  

public interface ReactiveHttpOutputMessage extends HttpMessage {
    
    // 获取 DataBufferFactory 实例,用于包装或者生成数据缓冲区 DataBuffer 实例(创建响应体)
    DataBufferFactory bufferFactory();

    // 注册一个动作,在 HttpOutputMessage 提交之前此动作会进行回调
    void beforeCommit(Supplier<? extends Mono<Void>> action);

    // 判断 HttpOutputMessage 是否已经提交
    boolean isCommitted();
    
    // 写入消息体到 HTTP 协议层
    Mono<Void> writeWith(Publisher<? extends DataBuffer> body);

    // 写入消息体到 HTTP 协议层并且刷新缓冲区
    Mono<Void> writeAndFlushWith(Publisher<? extends Publisher<? extends DataBuffer>> body);
    
    // 指明消息处理已经结束,一般在消息处理结束自动调用此方法,多次调用不会产生副作用
    Mono<Void> setComplete();
}

public interface ServerHttpResponse extends ReactiveHttpOutputMessage {
    
    // 设置响应状态码
    boolean setStatusCode(@Nullable HttpStatus status);
    
    // 获取响应状态码
    @Nullable
    HttpStatus getStatusCode();
    
    // 获取响应 Cookie,封装为 MultiValueMap 实例,可以修改
    MultiValueMap<String, ResponseCookie> getCookies();  
    
    // 添加响应 Cookie
    void addCookie(ResponseCookie cookie);  
}

Bucket4j

<dependency>
    <groupId>com.github.vladimir-bukhtoyarov</groupId>
    <artifactId>bucket4j-core</artifactId>
    <version>4.10.0</version>
</dependency> 

令牌桶』是一种限速算法,与之相对的是『漏桶』。

当进行任务的操作时,消耗一定的令牌,后台以一定的速率生产令牌。在没有令牌的情况下,就阻塞任务,或者拒绝服务。令牌的生产速率,代表了大部分情况下的平均流速。

桶的作用就是存储令牌,消耗的令牌都是从桶中获取。

桶的作用是用来限制流速的峰值,当桶中有额外令牌的时候,实际的流速就会高于限定的令牌生产速率。

为了保证功能的完整,后台必须保证令牌生产,而且是持续服务,不能中断。同时,为了桶功能的正确作用,当桶满了以后,后续生产的令牌会溢出,不会存储到桶内部。

令牌桶和漏桶的区别:

l 漏桶算法能够强行限制数据的传输速率。令牌桶算法能够在限制数据的平均传输速率的同时还允许某种程度的突发传输。需要说明的是:在某些情况下,漏桶算法不能够有效地使用网络资源。因为漏桶的漏出速率是固定的,所以即使网络中没有发生拥塞,漏桶算法也不能使某一个单独的数据流达到端口速率。因此,漏桶算法对于存在突发特性的流量来说缺乏效率。而令牌桶算法则能够满足这些具有突发特性的流量。通常,漏桶算法与令牌桶算法结合起来为网络流量提供更高效的控制。漏桶算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水,当水流入速度过大会直接溢出,可以看出漏桶算法能强行限制数据的传输速率

1. 基本使用

最简单的 bucket4j 的使用需要提供、涵盖以下几个概念:

  1. 桶对象。
  2. 带宽。即,每秒提供多少个 token,以允许操作。
  3. 消费。即,从桶中一次性取走多少个 token 。

代码示例:

// 带宽,也就是每秒能够通过的流量,自动维护令牌生产。 //桶大小是10 ,初始有10个,每秒生产10个
Bandwidth limit = Bandwidth.simple(10, Duration.ofSeconds(1));
// 桶。bucket 是我们操作的入口。桶的大小就是只能放10个,
Bucket bucket = Bucket4j.builder().addLimit(limit).build();
// 尝试消费 n 个令牌,返回布尔值,表示能够消费或者不能够消费。
log.info("{}", bucket.tryConsume(1) ? "do something" : "do nothing")

2. 阻塞式消费

在上面的基础案例中,如果 bucket 中的令牌的数量不够你的当前消费时,.tryConsume 方法会以失败的方式返回。

不过,有时我们希望的效果是等待,等到 bucket 中新增令牌后,再消费,返回。

这种情况下,我们需要使用 .asScheduler 方法。

//桶大小是1,初始有1个令牌,以后每2秒生产一个
Bandwidth limit = Bandwidth.simple(1, Duration.ofSeconds(2));
Bucket bucket = Bucket4j.builder().addLimit(limit).build();
while (true) {
    // 看这里,看这里,看这里。
    bucket.asScheduler().consume(1);  //该方法会阻塞,取不到就等待
    String time = LocalTime.now().format(DateTimeFormatter.ISO_LOCAL_TIME);
    log.info("{}", time);
}

3. 探针

通过创建并使用 ConsumptionProbe 对象,除了可以实现正常的消费功能之外,还可以通过它去查询消费后的桶中的“余额”。

// 探针   桶大小是5,每秒生产5个
Bandwidth limit = Bandwidth.simple(5, Duration.ofSeconds(1));
Bucket bucket = Bucket4j.builder().addLimit(limit).build();
while (true) {
    // 获取探针,消费令牌
    ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
    // 判断【上一步】是否消费成功
    if (probe.isConsumed()) {  //该方法不会 阻塞
        String time = LocalTime.now().format(DateTimeFormatter.ISO_TIME);
        // 查询剩余令牌数量
        log.info("{} 剩余令牌: {}", time, probe.getRemainingTokens());
    } else {
        log.info("waiting...");
        Thread.sleep(500);
    }
} 

4. Refill 和 classic 方法

在之前的例子中,我们使用的都是 Bandwidth.simple 方法,实际上,它相当于是 Bandwidth.classic 方法的简写。

Bandwidth.classic 方法的第二个参数需要一个 Refill 对象,而 Refill 对象就代表着你对桶的填充规则的设定。

// 桶控制。桶容量初始化时默认是满的 ,初始化时 桶有9个,桶的大小也是9
long bucketSize = 9; 
Refill filler = Refill.greedy(2, Duration.ofSeconds(1)); //每秒生产2个令牌
Bandwidth limit = Bandwidth.classic(bucketSize, filler); //桶的初始大小有9个令牌
//桶的大小是9.也就是说以后每次单位时间生产的令牌最多也是9个,例如:上面我们改成每秒生产20个,那么其实最终还是只能装9个,多余的溢出
Bucket bucket = Bucket4j.builder().addLimit(limit).build();
while (true) {
    ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
    if (probe.isConsumed()) {
        log.info("{}: 剩余令牌 {}", LocalTime.now().format(DateTimeFormatter.ISO_TIME), probe.getRemainingTokens());
    } else {
        log.info("waiting...");
        Thread.sleep(2000);
    }
} 

初始化桶有9个令牌,打印8 7 6 5 4 3 2 1 0 ,刚好消费完, 线程休眠2s,2s期间,桶每秒生产2个令牌,2s刚好4个,故睡醒后 打印 3 2 1 0,以此类推

5. 初始化令牌数量

桶的容量』和桶中的『令牌的数量』是两个概念。

默认情况下(上述例子中),在创建桶对象之后,桶都是满的。

不过,你可能不需要这种情况。这时,你需要在创建桶时使用 withInitialTokens 方法指定其中的令牌数量。

long bucketSize = 9;
Refill filler = Refill.greedy(2, Duration.ofSeconds(1)); //每秒2个令牌,每秒生产的令牌不能大于9
// 看这里,看这里,看这里。 初始化时 桶的大小是9,这个时候桶里面的令牌数量是5个,初始化数量不能大于9,
Bandwidth limit = Bandwidth.classic(bucketSize, filler).withInitialTokens(5); //初始化桶的数量是5个
Bucket bucket = Bucket4j.builder().addLimit(limit).build();
while (true) {
    ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
    if (probe.isConsumed()) {
        log.info("{}: 剩余令牌 {}", LocalTime.now().format(DateTimeFormatter.ISO_TIME), probe.getRemainingTokens());
    } else {
        log.info("waiting...");
        Thread.sleep(2000);
    }
} 

6. 非贪婪式创建令牌

在之前的示例中,令牌的创建方式都是贪婪式的。所谓贪婪式,指的就是在每一次的添加令牌的周期中,只要有创建了令牌就开始消费,是非贪婪式就是说必须等一次性等到所有的令牌都创建完成之后才开始消费,不过有时,你可能需要这个添加过程更均匀一些,这种情况下,你就需要使用 Refill.intervally 方法。

不过有时,你可能需要这个添加过程更均匀一些,这种情况下,你就需要使用 Refill.intervally 方法。

long bucketSize = 10;
// Refill filler = Refill.greedy(10, Duration.ofSeconds(1));
Refill filler = Refill.intervally(10, Duration.ofSeconds(1));

Bandwidth limit = Bandwidth.classic(bucketSize, filler).withInitialTokens(10);
Bucket bucket = Bucket4j.builder().addLimit(limit).build();
while (true) {
    // 获取探针
    ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
    // 判断是否能消耗
    if (probe.isConsumed()) {
        String time = LocalTime.now().format(DateTimeFormatter.ISO_TIME);
    // 查询剩余令牌数量
        log.info("{} 剩余令牌: {}", time, probe.getRemainingTokens());
    }
}

Gateway 限流

限流的目的时通过对并发访问接口方法(或对一个时间窗口内的请求)进行限速,一旦达到限制速率则可以拒绝服务。

网关就要通过限流来承担保护后端应用的责任。

1. 限流策略

常见的算法有『令牌桶』和『漏桶』两种方案。

『漏桶』的思路类似于消息队列的工作形式,请求(类比于水)先进入到漏桶的,漏桶以一定的速率出(漏)水。

当水流入的速度过大就会直接在桶中有积攒,一旦积攒量超过漏桶上限,再流入的水就会溢出。

『令牌桶』的思路和漏桶相反。在系统运行期间,系统按照恒定时间间隔定期向桶中加入令牌(Token),如果桶已经满了,就不再增加。

当新的请求来临时,会拿走令牌,有令牌则意味着有资格进行请求,如果没有令牌可能就会阻塞或者拒绝。

2. Gateway 自定义过滤器实现限流

在 Gateway 中实现限流比较简单,只需要编写一个过滤器。

RateLimiter(Guava)、Bucket4j、RateLimitJ 都是基于令牌桶算法实现的限流工具。

Bucket4j 的使用见另一篇笔记《Bucket4j》

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>

<dependency>
    <groupId>com.github.vladimir-bukhtoyarov</groupId>
    <artifactId>bucket4j-core</artifactId>
    <version>4.10.0</version>
</dependency>

编写自定义过滤器 GatewayRateLimitFilter 并实现 GatewayFilter(和 Ordered)接口。

@Component
@Slf4j
public class GatewayRateLimitFilter implements GlobalFilter, Ordered {

    // 如果要启动网关的多个实例,那么就需要将 ip 和桶的键值对信息存到 Redis 中。
    private static final Map<String, Bucket> LOCAL_CACHE = new ConcurrentHashMap<>();
    private int capacity; //桶容量
    private int refillTokens;//  定时添加token的数量  如每秒添加几个token
    private Duration refillDuration ;//添加周期   周期 : 如 每秒

    public GatewayRateLimitFilter(int capacity, int refillTokens, Duration refillDuration) {
        this.capacity = capacity;
        this.refillTokens = refillTokens;
        this.refillDuration = refillDuration;
    }

    public GatewayRateLimitFilter(){
        this(10,1,Duration.ofSeconds(1));
    }
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {

        //获取请求的 主机ip
        //String ip = exchange.getRequest().getRemoteAddress().getAddress().getHostAddress(); 
        String ip = exchange.getRequest().getURI().getHost();  
        String url  = exchange.getRequest().getURI().getPath();  //获取url
        //computeIfAbsent: 如果没有某个key,则添加某个key-value,否则返回该key对应的value
        //对同一客户端的请求,不同的接口请求都做了限流
        Bucket bucket = LOCAL_CACHE.computeIfAbsent(ip+url, new Function<String, Bucket>() {
            @Override
            public Bucket apply(String s) {
                return createNewBucket();
            }
        });
        log.info("IP:{},令牌桶可用的 token 数量:{}",ip,bucket.getAvailableTokens());
        if(bucket.tryConsume(1)){
            return chain.filter(exchange);
        }else{
            exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
            //请求太频繁了  请少稍后再试
            return exchange.getResponse().setComplete();
        }
    }

    //生成一个桶
    private Bucket createNewBucket(){
        //每秒中生产一个token
        Refill refill = Refill.greedy(refillTokens,refillDuration);
        Bandwidth limit = Bandwidth.classic(capacity,refill);
        return Bucket4j.builder().addLimit(limit).build();
    }

    @Override
    public int getOrder() {
        return -100;
    }
}

通过配置文件或代码配置并使用 Filter 。

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

@Bean
public RouteLocator customerRouteLocator(RouteLocatorBuilder builder) {
    String intercept = "/test";  //  不要写成 String intercept = "/test/"
    String target = "http://localhost:8081";
    GatewayFilter filter = new GatewayRateLimitFilter(1, 1, Duration.ofSeconds(10));

    return builder.routes()
            .route(r -> r.path(intercept)
                    .filters(f -> f.filter(filter))
                    .uri(target)
                    .id("rateLimit_route"))
            .build();

}

配置文件:

spring:
  application:
    name: eureka-gateway
  cloud:
    gateway:
      routes:
        - id: 163_route
          uri: http://localhost:8081
          predicates:
            - Path=/test
          filters:
            - name: yyy
      discovery:
        locator:
          enabled: true
          lower-case-service-id: true
server:
  port: 9000
eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:10086/eureka
    registry-fetch-interval-seconds: 5
    register-with-eureka: true

观察运行效果,当可用令牌数量为 0 时,Gateway 中的自定义限流器会开始拒绝放行请求,直接返回 429 状态码

Gateway整合sentinel

网关作为内部系统外的一层屏障, 对内起到一定的保护作用, 限流便是其中之一. 网关层的限流可以简单地针对不同路由进行限流,也可针对业务的接口进行限流,或者根据接口的特征分组限流。

网关限流:https://github.com/alibaba/Sentinel/wiki/%E7%BD%91%E5%85%B3%E9%99%90%E6%B5%81

1.添加依赖

<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-alibaba-sentinel-gateway</artifactId>
</dependency>

<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>

2.添加配置

sentinel:
  transport:
    port: 8179
    dashboard: localhost:8080

3.控制台实现方式

Sentinel 引入了网关流控控制台的支持,用户可以直接在 Sentinel 控制台上查看 API Gateway 实时的 route 和自
定义 API 分组监控,管理网关规则和 API 分组配置。
从 1.6.0 版本开始,Sentinel 提供了 Spring Cloud Gateway 的适配模块,可以提供两种资源维度的限流:
route 维度:即在 Spring 配置文件中配置的路由条目,资源名为对应的 routeId
自定义 API 维度:用户可以利用 Sentinel 提供的 API 来自定义一些 API 分组
自定义异常方式:

1.通过yml

spring:
	cloud:
		sentinel:
          scg:
            fallback:
              mode: response
              response-body: '{"code":403,"mes":"限流了"}'
配置限流

image-20221104112710542

API名称:填写路由名称

Burst Size代表忽然流量暴增时额外增加的请求数

通过api管理可以同时管理多个api

img

访问页面

image-20221104112539558

2.通过GatewayCallbackManager

@Configuration
public class GatewayConfig {
    @PostConstruct
    public void init(){
        BlockRequestHandler blockRequestHandler = new BlockRequestHandler() {
            @Override
            public Mono<ServerResponse> handleRequest(ServerWebExchange exchange, Throwable throwable) {
                return ServerResponse.status(HttpStatus.OK)
                    .contentType(MediaType.APPLICATION_JSON)
                    .body(BodyInserters.fromValue("降级了!"));
            }
        };

        GatewayCallbackManager.setBlockHandler(blockRequestHandler);
    }
}

代码实现方式

用户可以通过 GatewayRuleManager.loadRules(rules) 手动加载网关规则GatewayConfiguration 中添加

@PostConstruct
public void doInit() {
    //初始化网关限流规则
    initGatewayRules();
    //自定义限流异常处理器
    initBlockRequestHandler();
}

private void initGatewayRules() {
    Set<GatewayFlowRule> rules = new HashSet<>();
    //resource:资源名称,可以是网关中的 route 名称或者用户自定义的 API 分组名称。
    //count:限流阈值
    //intervalSec:统计时间窗口,单位是秒,默认是 1 秒。
    rules.add(new GatewayFlowRule("order_route")
              .setCount(2)
              .setIntervalSec(1)
             );
    rules.add(new GatewayFlowRule("user_service_api")
              .setCount(2)
              .setIntervalSec(1)
             );

    // 加载网关规则
    GatewayRuleManager.loadRules(rules);
}

private void initBlockRequestHandler() {
    BlockRequestHandler blockRequestHandler = new BlockRequestHandler() {
        @Override
        public Mono<ServerResponse> handleRequest(ServerWebExchange exchange, Throwable t) {
            HashMap<String, String> result = new HashMap<>();
            result.put("code",String.valueOf(HttpStatus.TOO_MANY_REQUESTS.value()));
            result.put("msg", HttpStatus.TOO_MANY_REQUESTS.getReasonPhrase());

            
                return ServerResponse.status(HttpStatus.TOO_MANY_REQUESTS)
                .contentType(MediaType.APPLICATION_JSON)
                .body(BodyInserters.fromValue(result));
        }
    };
    //设置自定义异常处理器
    GatewayCallbackManager.setBlockHandler(blockRequestHandler);
}
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值