黄埔班第62节:SpringCloud Gateway 服务网关

2020/04/12

1. 服务网关在微服务中的应用

对外服务的难题

微服务架构的应用系统的体系非常庞大的,光是需要独立部署的基础组件就有注册中心(eureka)、配置中心(config)、服务总线bus、Turbine异常聚合和监控大盘、调用链追踪和链路聚合,还用kafka和MQ之类的消息中间件,加上按照业务域拆分的微服务组件和模块,一个小的系统非常轻松的就能弄出20多个module,会有这么多的部署包,也要考虑高可用。

我们前面都是采用localhost加端口的方式直接访问,如果这些服务一并都要提供给外部用户访问那该怎么处理?开发在各个页面给不同的请求配置URL和端口号,但是一大堆的URL在我们的前端进行配置,需要我们开发人员自己手动配置一套路由表,因此就需要引入一套机制来降低路由表的维护成本。

还有一个问题就是安全性,需要进行安全验证,如果让没有提供对外服务的接口都自己实现一定非常浪费资源,这个时候就需要一个中间件统一来进行安全处理并对外进行数据的安全验证。

我们如何对外提供服务、还能管好路由规则,并做好访问控制,在这样的背景下,API网关应运而生,他就像一个传达室的角色,接待所有来访的请求。

微服务的传达室

在计算机领域,有一个设计理论:任何问题都可以通过引入一个中间件来解决。如果一个不够那就两个

我们去到别的公司办事,第一道关就是传达室/前台,它们会做两件事

  • 访问控制:看你是不是有权限进行访问,拒绝未授权的来访者
  • 引导指路:问清楚你要办的事情,指导你如何到达,找到你想要访问的内容

引入网关层后,我们的服务架构就变成如下的样子:

网关层作为唯一的对外服务,外部请求不直接访问服务层,由网关层承接所有HTTP请求,在实际的应用里,我们也会将网关服务和Nginx一同使用,Nginx作为服务器层面,gateway作为业务服务层面。

访问控制

访问控制住要包含两个方面,具体实现不是由网关层提供的,但是网关作为一个载体承载了两个任务

  • 拦截请求:有的接口需要登录用户才能范围,对于这类接口,网关成可以检查访问请求中是否携带令牌等身份信息,比如HTTP Header中的Authorization或token属性,如果没有携带令牌,则说明没有登录,这时候可以返回403
  • 鉴权:对于有携带令牌的服务,我们需要验证令牌的真假,否则用户可以通过伪造令牌进行通信,对于令牌过期或失效的服务要进行拒绝

路由规则

路由规则包含两个方面,分别是URL映射和服务寻址

  • URL映射:在大多数情况下,客户端访问的HTTP URL往往不是我们在conroller里配置的真实路径而是虚拟路由地址,比如客户端可以发起请求/password/update来修改密码,但后台没有这个服务,这个时候就需要网关层做一个路由规则,来访问URL映射真的服务路径
  • 服务寻址:URL映射好了之后,网关层就需要找到可以提供服务的服务器地址,对于服务集群的话,还需要实现负载均衡策略(在springcloud中gateway是借助eureka服务发现机制实现服务寻址的,负载均衡依靠的Ribbon)

2. 第二代网关Gateway

介绍

Gateway的标签

  • Gateway是spring官方主推的组件
  • 底层是基于Netty构建(快)
  • 由spring开源社区直接贡献开源力量

Gateway可以做什么

  • 路由寻址
  • 负载均衡
  • 限流,nginx可以限流
  • 鉴权

Gateway VS Zuul

# Gateway Zuul 1.x Zuul 2.x
靠谱性 官方背书支持 开创者,曾经靠谱 一直跳票,终于发布了
性能 Netty 同步阻塞,性能慢 Netty
QPS 超30000 20000左右 20000-30000
Spring Cloud 已整合到组件库 已整合到组件库 暂无整合到组件库的计划,但可以引用
长连接(keepalive) 支持 不支持 支持
编程体验 略复杂(习惯就好) 同步模型,比较简单 略复杂
调试&链路追踪 异步模型,略复杂 同步方式,比较容易 异步模型,略复杂

综上对比分析,新项目果断选择Gateway

快速落地实施体验

  • 创建gateway项目
  • 连接Eureka基于服务发现自动创建路由规则
  • 通过Actuator实现动态路由功能,即服务在运行的时候,可以创建新的路由规则

1、创建springboot项目并加入POM依赖

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</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-gateway</artifactId>
  </dependency>
  <!-- 目前暂时用不上,后面进行限流时使用 -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
  </dependency>
</dependencies>

2、创建application启动类

package com.icodingedu.springcloud;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;

@SpringBootApplication(exclude = {RedisReactiveAutoConfiguration.class}) // 先排除redis自动配置,后面使用redis限流要去掉exclude
@EnableDiscoveryClient // 连接Eureka基于服务发现自动创建路由规则
public class GatewayServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(GatewayServerApplication.class,args);
    }
}

3、创建配置文件application

spring: 
  application:
    name: gateway-server
  cloud: 
    gateway: 
      discovery: 
        locator:
          enabled: true # 服务发现功能打开
server:
  port: 50080    
eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:20001/eureka/
management:
  security:
    enabled: false
  endpoints:
    web:
      exposure:
        include: "*"
  endpoint:
    health:
      show-details: always

启动eureka-server,启动eureka-client(启动两个实例),启动gateway-server

可以通过 http://localhost:50080/actuator/gateway/routes 查看routes规则,打开后会发现根据eureka注册的两个服务节点:EUREKA-CLIENT、GATEWAY-SERVER

每个节点都有一个断言:predicate,一个过滤器:filters

现在就可以通过gateway做服务访问了:http://localhost:50080/EUREKA-CLIENT/sayhello 访问注册到eureka的服务了,并且如果该服务有多个节点就会轮询访问

注意这里的FEIGN-CLIENT目前必须时大写:如果是小写就404了,是按照eureka中的服务名进行访问的

如果网关访问不想用大写,可以修改gateway-server的yaml配置支持小写访问

spring:
  application:
    name: gateway-server
  cloud:
    gateway:
      discovery:
        locator:
          enabled: true
          lower-case-service-id: true #设置路由服务名可以全小写,但设置后就不支持大写了
          
# http://localhost:50080/feign-client/sayhello 

Gateway支持创建动态路由规则

# 1、POST 动态创建和修改路由规则
# 创建地址:http://localhost:50080/actuator/gateway/routes/myrouter
{
    "predicates": [
        {
            "name": "Path",
            "args": {
                "_genkey_0": "/myrouter-path/**"
            }
        }
    ],
    "filters": [
        {
            "name": "StripPrefix",
            "args": {
                "_genkey_0": "1"
            }
        }
    ],
    "uri": "lb://EUREKA-CLIENT",
    "order": 0
}
# 可以看下新的路由规则是否创建成功
# http://localhost:50080/actuator/gateway/routes

# 2、可以删除路由规则
# DELETE http://localhost:50080/actuator/gateway/routes/myrouter
# 可以访问我们创建的路由规则
# http://localhost:50080/myrouter-path/sayhello

POST 动态创建规则后,访问http://localhost:50080/actuator/gateway/routes

访问myroute-path

将myrouter-path修改为myroutes

再访问http://localhost:50080/actuator/gateway/routes

myrouter-path已修改为myrouters,注意看 route_id是 myroute

删除路由规则

发起DELETE请求

删除路由会有一定时间的延迟,所以是先路由规则,再停服务,访问路由表http://localhost:50080/actuator/gateway/routes

myroute的路由规则已删除了。

源码:./Jacob-study-demo/springcloud-learn/gateway

路由功能详解

路由组成结构

Gateway中可以定义很多个Route,一个Route就是一套包含完整转发规则的路由,主要有三部分组成

image-20201021213241132

  • 断言集合 predicate:断言是路由器处理的第一个环节,他是路由的匹配规则,他决定了一个网络请求是否可以匹配给当前路径来处理,之所以他是一个集合是因为我们可以个一个路由添加多个断言,当每个断言都配置成功了才算是过了路由这一关
  • 过滤器集合 filters:如果请求通过了前面断言的匹配,表明被路由正式接手了,结下来就需要经过过滤器了,比如说权限验证,如果验证不通过就设置为Status Code为403并中断操作
  • URI:服务地址,如果请求顺利通过过滤器的处理,那就要走到最后一步了,也就是转发请求(URI是统一资源标识符)

负载均衡

对最后一步寻址来说,如果采用基于Eureka的服务发现机制,那么Gateway的转发过程中可以采用服务注册后的名字来访问,后台会借助Ribbon实现负载均衡(可以为某个服务指定具体的负载均衡策略),配置方式如下:

lb://FEIGN-SERVER,lb就是代表Ribbon作为LoadBalancer

路由的工作流程

image-20201021214543388

  • Predicate Handler(断言):其实就是路由规则判断,首先获取所有的路由(配置的routes全集),然后依次循环每个Route,把应该请求与Route中配置的所有断言进行匹配,如果当前Route所有断言都验证通过,Predicate Handler就选定当前的路由,这个模式典型的责任链模式
  • Filter Handler;在前一步选中路由后,在具体处理过程中,不仅当前Route中定义的过滤器会生效,我们在项目中添加的全局过滤器(Global Filter)也会一同参与,Pre Filter和Post Filter是指过滤器的作用阶段
  • 寻址:这一步将会把请求转发到URI指定的地址,在发送请求之前,所有Pre类型的过滤器都将被执行,而Post过滤器会在调用请求返回之后起作用

3. 断言功能详解

Predicate断言机制

Predicate是Java8中引入的一个新功能,和我们平时写单元测试的时候Assertion差不多,Predicate是接收一个判断条件,返回一个ture或false的布尔值结果,告知调用方判断结果。也可以通过and、or和negative(非)三个操作符多个Predicate串联在一块共同判断

Predicate其实就是我们和Gateway对接的数据暗号,比如要求你的Request中必须带有某个指定的参数叫name,对应的值必须是一个指定的人名(Gavin),如果Request中没有包含name,或者名字不是Gavin,断言就失败了,只有标示和值都一样的情况下才会通过

断言的作用阶段

一个请求在抵达网关层后,首先就要进行断言匹配,在满足所有断言之后才会进入Filter阶段

常用断言介绍

Gateway提供了十多种内置断言,介绍一些常用的

1、路径断言

Path断言是最常用的一个断言请求,几乎所有的路由都要用到

.route(r -> r.path("/gateway/**")
						 .uri("lb://FEIGN-SERVICE-PROVIDER/")
)
.route(r -> r.path("/baidu")
						 .uri("http://www.baidu.com")
)

Path断言的使用非常简单,就像我们在Controller中配置@RequestPath的方式一样,在Path断言中填入一段URL匹配规则,当实际请求的URL和断言中的规则相匹配的时候,就下发到该路由中URI指定地址,这个地址可以是一个具体的HTTP地址,也可以是一个Eureka中注册的服务名称,路由规则可以一次编写多个绑定关系

2、Method断言

这个断言是专门验证HTTP Method

.route(r -> r.path("/gateway/**")
						 .and().method(HttpMethod.GET)
						 .uri("lb://FEIGN-SERVICE-PROVIDER/")
)

将Path断言通过一个and连接符和method关联起来,我们如果访问/gateway/sample并且method是GET时才会适配上面的路由规则

3、RequestParam匹配

请求断言也是在业务中经常使用的,他会从ServerHttpRequest中的Parameters列表中查询指定的属性,

.route(r -> r.path("/gateway/**")
						 .and().method(HttpMethod.GET)
						 .and().query("name","test")
						 .and().query("age")
						 .uri("lb://FEIGN-SERVICE-PROVIDER/")
)
  • 属性名验证,如query(“age”),此时断言只会验证QueryParameters列表中是否包含了一个叫age的属性,并不会验证他的值
  • 属性值验证,如query(“name”,”test”),它不仅会验证name属性是否存在,还会验证他的值是不是和断言相匹配,当前断言会验证参数中name的属性值是不是test

4、Header断言

header断言是检查头信息里是否携带了相关属性或令牌token

.route(r -> r.path("/gateway/**")
						 .and().header("Authorization")
						 .uri("lb://FEIGN-SERVICE-PROVIDER/")
)

5、Cookie断言

cookie验证的是cookie中保存的信息,cookie断言和上面介绍的几种断言方式都大同小异,唯一不同的是他必须连同属性值一起验证,不能单独只验证属性是否存在

.route(r -> r.path("/gateway/**")
						 .and().cookie("name","test")
						 .uri("lb://FEIGN-SERVICE-PROVIDER/")
)

6、时间片验证

时间匹配有三种模式,分别是Before、After和Between,这些断言指定了在什么时间范围内路由才会生效,场景:秒杀活动

.route(r -> r.path("/gateway/**")
						 .and().before(ZonedDateTime.now().plusMinutes(1))
						 .uri("lb://FEIGN-SERVICE-PROVIDER/")
)

4. 实现断言的配置

在yaml里进行配置

去到gateway-server项目的yaml配置文件里进行配置

# 新的配置routes和discovery是平级的
# id是这个断言的唯一标识
# uri是如果匹配上所有断言,请求将转发到服务应用
# StripPrefix相当于把 localhost:50080/gavinrouter/sayhello替换成 FEIGN-CLIENT/sayhello
spring:
  application:
    name: gateway-server
  cloud:
    gateway:
      discovery:
        locator:
          enabled: true
          lower-case-service-id: true
      routes:
      - id: feignclient
        predicates:
        - Path=/gavinrouter/**
        filters:
        - StripPrefix=1
        uri: lb://FEIGN-CLIENT # 服务名,对应注册到注册中心的服务应用名

配置完成后可以通过下面的路径访问

http://localhost:50080/gavinrouter/sayhello

访问 : http://localhost:50080/actuator/gateway/routes,看已生效的路由

在Java程序里进行配置

创建一个config包,在里面创建GatewayConfiguration

package com.icodingedu.springcloud.config;

import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;

@Configuration
public class GatewayConfiguration {

    @Bean
    @Order
    public RouteLocator customerRouters(RouteLocatorBuilder builder){
        return builder.routes()
                .route(r -> r.path("/gavinjava/**")
                             .and().method(HttpMethod.GET)
                       			.and().header("name") // 请求头必须带有name参数
                             .filters(f -> f.stripPrefix(1)
                                            .addResponseHeader("java-param","gateway-config")
                             )
                             .uri("lb://FEIGN-CLIENT")
                ).build();
    }
}

在前面yaml配置了断言的情况下,也同时配置了java的断言配置,是否两者都生效了?启动项目

访问 http://localhost:50080/actuator/gateway/routes

发现两个断言路由都有了

修改后(请求头必须带有name参数的限制去掉)进行访问验证:http://localhost:50080/gavinjava/sayhello

通过gavinjava访问,注意请求头要增加一个name参数

发现在response增加了我们在java配置了的参数java-param = gateway-config

5. After断言实现网关层定时秒杀

gateway调用的是feign-client的业务,我们就要到feign-client里创建一个controller实现相应的功能

这里面使用的product需要提前在feign-client-intf中定义好

package com.icodingedu.springcloud.pojo;

import lombok.Builder;
import lombok.Data;

@Data
@Builder
public class Product {

    private Long productId;
    private String description;
    private Long stock;
}

在feign-client中创建一个GatewayController

package com.icodingedu.springcloud.controller;

import com.icodingedu.springcloud.pojo.Product;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@RestController
@Slf4j
@RequestMapping("gateway")
public class GatewayController {

    //Product要在feign-client-intf里提前定义好
    public static final Map<Long, Product> items = new ConcurrentHashMap<>();

    @GetMapping("detail")
    public Product getProduct(Long pid){
        //如果第一次没有就先创建一个
        if(!items.containsKey(pid)){
            Product product = Product.builder().productId(pid)
                    .description("new arrival")
                    .stock(100L).build();
            items.putIfAbsent(pid,product);
        }
        return items.get(pid);
    }

    @GetMapping("placeOrder")
    public String buy(Long pid){
        Product product = items.get(pid);
        if(product==null){
            return "Product Not Found";
        }else if(product.getStock()<=0L){
            return "Sold Out";
        }
        //如果是单体应用,即便是集群也可以保留这个代码,集群解决需要用到分布式锁将控制放到中心节点即可
        synchronized (product){
            if(product.getStock()<=0L){
                return "Sold Out";
            }
            product.setStock(product.getStock()-1);
        }
        return "Order Placed";
    }
}

第3步,回到Gateway-server项目里,按照时间顺延的方式做断言定义

@Configuration
public class GatewayConfiguration {
    @Bean
    @Order
    public RouteLocator customerRouters(RouteLocatorBuilder builder){
        return builder.routes()
                .route(r -> r.path("/gavinjava/**")
                             .and().method(HttpMethod.POST)
                             .and().query("name","gavin")
                             .filters(f -> f.stripPrefix(1)
                                            .addResponseHeader("java-param","gateway-config")
                             )
                             .uri("lb://FEIGN-CLIENT")
                )
                .route(r -> r.path("/secondkill/**")
                             .and().after(ZonedDateTime.now().plusSeconds(30)) // 30秒后才生效
                             .filters(f -> f.stripPrefix(1))
                             .uri("lb://FEIGN-CLIENT")
                )
                .build();
    }
}

启动feign-client项目,先通过detail接口添加一个商品

然后重启Gateway-server项目,立即访问localhost:50080/secondkill/gateway/detail?pid=1001

会报404,30秒后,我们再访问,链接才生效

可以精确的定义时间节点

@Configuration
public class GatewayConfiguration {

    @Bean
    @Order
    public RouteLocator customerRouters(RouteLocatorBuilder builder){
        LocalDateTime ldt = LocalDateTime.of(2020,10,24,20,31,10);
      	LocalDateTime ldt2 = LocalDateTime.of(2020,10,24,20,32,10);
        return builder.routes()
                .route(r -> r.path("/gavinjava/**")
                             .and().method(HttpMethod.POST)
                             .and().query("name","gavin")
                             .filters(f -> f.stripPrefix(1)
                                            .addResponseHeader("java-param","gateway-config")
                             )
                             .uri("lb://FEIGN-CLIENT")
                )
                .route(r -> r.path("/secondkill/**")
                             //.and().after(ZonedDateTime.of(ldt, ZoneId.of("Asia/Shanghai")))
                       .and().between(ZonedDateTime.of(ldt,ZoneId.of("Asia/Shanghai")),ZonedDateTime.of(ldt2,ZoneId.of("Asia/Shanghai")))
                             .filters(f -> f.stripPrefix(1).redirect("304","https://www.icodingedu.com/"))
                             .uri("lb://FEIGN-CLIENT")
                )
                .build();
    }
}

上面定义了上海时区,2020年10月24日20时31分10秒后,该链接才生效。

也可以使用between定义在某个时间段内,该链接才生效

6. 过滤器原理和生命周期

所有的开源框架实现过滤器的模式都是大同小异的,通过一种类似责任链的方式,传统的职责链模式中的事件会传递指直到有一个处理对象接手,而过滤器和传统的职责链有点不同,所有过滤器都要进行过滤和处理,一路走到底,直到被最后一个过滤器处理完成。

过滤器的实现方式

在Gateway中实现一个过滤器非常简单,只要实现GatewayFilter接口的默认方法就好了

public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);

这里面有两个关键信息

  • ServerWebExchange:这是Spring封装的HTTP request-response的交互协议,从中我们可以获取request和resposne中的各种请求参数,也可以向其中添加内容
  • GatewayFilterChain:他是过滤器的调用链,在方法结束的时候我们需要将exchange对象传入调用链中的下一个对象

过滤器的执行阶段

Gateway是通过Filter中的代码来实现类似Pre和Post的效果的

Pre和Post是指代当前过滤器的执行阶段,Pre是在下一个过滤器之前被执行,Post是在过滤器执行后再执行。我们在Gateway Filter中也可以同时定义Pre和Post执行逻辑

Pre类型Post类型一个在过滤器执行前一个在执行后

过滤器可以排顺序的

在Gateway中可以实现 org.springframework.core.Ordered接口,来指定过滤器的执行顺序,通过实现getOrder方法

public int getOrder(){
  return 0;
}
// Pre类型的过滤器来说,数字越大表示优先级越高,也就越早被执行。但对于Post类型过滤器,则是数字越小越先被执行

过滤器示例

Header过滤器**

这个系列有很多组过滤器,可以将信息加入到指定Header和减少Header的值

.filters(f -> f.addResponseHeader("name","gateway-server"))
//相当于向header中添加一个name属性,对应的值是gateway-server

StripPrefix过滤器

这是个比较常用的过滤器,他的作用是去掉部分URL路径

.route(r -> r.path("/gateway-test/**")
						 .filters(f -> f.stripPrefix(1))
       			 .uri("lb://FEIFN-SERVICE/")
)
//假如HTTP请求访问的是/gateway-test/sample/update,如果没有StripPrefix过滤器,那么转发到FEIGN-SERVIC服务的访问路径也是一样的//FEIGN-SERVICE/gateway-test/sample/update,如果添加了这个过滤器,gateway就会根据stripPrefix(1)中的配置截取URL的路径,比如这里设置的是1,那么就去掉一个前缀,最终发送给后台服务的路径就变成//FEIGN-SERVICE/sample/update

PrefixPath过滤器

他和StripPrefix的作用是完全相反的,会在请求路径的前面加入前缀

.route(r -> r.path("/gateway-test/**")
						 .filters(f -> f.prefixPath("go"))
       			 .uri("lb://FEIGN-SERVICE/")
)
//假如我们访问的路径是/gateway-test/sample,如果使用这个过滤器就会变成//FEIGN-SERVICE/go/gateway-test/sample

RedirectTo过滤器

他可以把收到特定状态码的请求重定向到一个指定网址,必须是3xx的httpStatus

.filters(f -> f.redirect(304,"https://www.baidu.com"))
//Caused by: java.lang.IllegalArgumentException: status must be a 3xx code, but was 404
 // 遇到3xx的返回状态,跳转到baidu

7. 自定义过滤器实现接口计时功能

当前路由的过滤器

在gateway-server项目组进行修改,创建一个filter的package,自定义过滤器

package com.icodingedu.springcloud.filter;

import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.core.Ordered;
import org.springframework.stereotype.Component;
import org.springframework.util.StopWatch;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

@Slf4j
@Component
public class TimerFilter implements GatewayFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        //给接口计时并能输出log
        StopWatch timer = new StopWatch();
        //开始计时,
        timer.start(exchange.getRequest().getURI().getRawPath());
        //我们也可以对调用链进行加工,手工放入请求参数
        exchange.getAttributes().put("requestTimeBegin",System.currentTimeMillis());
        return chain.filter(exchange).then(
            //这里就是执行完过滤进行调用的地方
            Mono.fromRunnable(() -> {
                timer.stop();;
                log.info(timer.prettyPrint());
            })
        );
    }

    @Override
    public int getOrder() {
        return 0;
    }
}

去到GatewayConfiguration里,注入timerFilter,设置自定义filter

@Configuration
public class GatewayConfiguration {

    @Autowired
    private TimerFilter timerFilter;

    @Bean
    @Order
    public RouteLocator customerRouters(RouteLocatorBuilder builder){
        LocalDateTime ldt = LocalDateTime.of(2020,10,24,21,05,10);
        return builder.routes()
                .route(r -> r.path("/gavinjava/**")
                             .and().method(HttpMethod.GET)
                             .filters(f -> f.stripPrefix(1)
                                            .addResponseHeader("java-param","gateway-config")
                                            .filter(timerFilter)
                             )
                             .uri("lb://FEIGN-CLIENT")
                )
                .route(r -> r.path("/secondkill/**")
                             .and().after(ZonedDateTime.of(ldt, ZoneId.of("Asia/Shanghai")))
                             .filters(f -> f.stripPrefix(1))
                             .uri("lb://FEIGN-CLIENT/")
                )
                .build();
    }
}

测试后结果如下

# 这里的百分比指的是这个接口执行的时间占整个执行链路的百分比
# 1秒=1000000000(ns)9个0
---------------------------------------------
ns         %     Task name
---------------------------------------------
1775599286  100%  /sayhello

全局过滤器

上面定义的是针对具体的route的filter,我们也可以定义一个全局的filter直接应用在所有的route上,只需要把filter的继承修改下即可,所有route就可以自动加载了,无需调用

@Slf4j
@Component
public class TimerFilter implements GlobalFilter, Ordered

将原来config中引用的timeFilter都去掉即可

8. 权限认证方案分析

传统单体应用的用户鉴权

使用session保存登录状态,通过存放的key-value来进行鉴权,对于一台机器无法同步session到其他机器的时候,我们的问题就来了,如何进行服务应用的鉴权

分布式环境下的解决方案

同步session

session复制是最容易先想到的解决方案,可以将一台机器中的session复制到集群中其他的机器里,比如Tomcat中也有内置的session的同步方案,但是这并不是一个非常优雅的解决方案,他会带来以下两个问题

  • Timing问题:同步需要花费一定的时间,我们无法保证session同步的及时性,也就是说,当用户发起两个请求分别落在不同的机器上的时候,前一个请求写入session的信息可能还没有同步到所有的机器,后一个请求就已经开始执行业务逻辑了,这就会引起脏读和幻读
  • 数据冗余:所有的服务器都需要保存一份session的全集,这就产生了大量的冗余数据

反向代理:绑定IP或一致性hash

这个方案是在Nginx网关层来做的,我们可以指定某些ip请求落在某个指定的机器上,这样一来session始终只会存在同一个机器上,不过相比前一种session复制的方法来说,绑定IP的方式更明显缺陷如下:

  • 负载均衡:在绑定IP的情况下无法在网关层应用负载均衡策略的,而且某个服务器出现故障会对指定IP的来访用户产生较大的影响,对网关层来讲这种路由规则的配置也比较麻烦
  • IP变更:很多运营商的IP时不时就会进行切换,这就会导致更换IP后的请求被路由到不同的服务节点处理,这样一来就读不到前面设置的session信息了

为了解决第二个问题,可以通过一致性hash路由的方式来做,比如根据用户ID做hash,不同的hash值落在不同的机器上,保证足够均衡的分配,这样也就避免了IP切换的问题,但依然无法解决第一点里提到的负载均衡的问题

Redis解决方案

通过将session中心化,从服务器的存储上转移到redis中

  • 在tomcat层面可以直接使用组件将容器的session放入到redis中

  • 借助springboot的管理session方式,将session存储进redis中

分布式Session的替代方案

OAuth 2.0

OAuth 2.0是一个开放授权标准协议,它允许第三方应用访问该用户在某服务的特定私有资源,但不提供账号密码信息给第三方应用

JWT鉴权

JWT也是一种基于Token的鉴权机制,他的基本思想是通过用户名+密码换取一个Access Token

鉴权流程

1、用户名+密码访问鉴权服务

  • 验证通过:服务器返回一个Access Token给客户端,并将Token保存在服务端某个地方用于后面的访问控制(可以保存在数据库里也可以保存在Redis中)
  • 验证失败:不生成Token

2、客户端使用令牌访问资源,服务器验证令牌有效性

  • 令牌错误或过期:拦截请求,让客户端重新申请令牌
  • 令牌正确:允许放行

9. 实现JWT鉴权

通过以下几步完成鉴权操作

  • 创建auth-service(登录、鉴权服务)
  • 添加JwtService类实现token创建和验证
  • 网关层集成auth-service(添加AuthFilter到网关层,如果没有登录则返回403)

auth-service-api模块

在gateway目录下创建一个auth-service-api的module模块

添加pom依赖

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

创建一个entity包,创建一个账户实体对象

package com.icodingedu.springcloud.entity;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Account implements Serializable {

    private String username;

    private String token;

    //当token接近失效的时候可以用refreshToken生成一个新的token
    private String refreshToken;
}

在entity包下面创建一个AuthResponseCode类,定义一些错误状态码

package com.icodingedu.springcloud.entity;

public class AuthResponseCode {
	/**
	 * 成功
	 */
	public static final Long SUCCESS = 1L;

	/**
	 * 无效密码
	 */
	public static final Long INCORRENT_PWD = 1000L;

	/**
	 * 用户不存在
	 */
	public static final Long USER_NOT_FOUND = 1001L;

	/**
	 * 无效TOKEN
	 */
	public static final Long INVALID_TOKEN = 1002L;
}

在entity包下创建一个AuthResponse处理结果类

package com.icodingedu.springcloud.entity;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthResponse {
	/**
	 * 账号
	 */
	private Account account;

	/**
	 * 响应结果码
	 */
	private Long code;
}

创建一个service包在里面创建接口AuthService,可feign调用的用户接口

package com.icodingedu.springcloud.service;

import com.icodingedu.springcloud.entity.AuthResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

@FeignClient("auth-service")
public interface AuthService {
  /**
	 * 登录
	 * @param username
	 * @param password
	 * @return
	 */
	@PostMapping("/login")
	@ResponseBody
	AuthResponse login(@RequestParam("username") String username,
	                   @RequestParam("password") String password);

	/**
	 * 认证
	 * @param token
	 * @param username
	 * @return
	 */
	@GetMapping("/verify")
	@ResponseBody
	AuthResponse verify(@RequestParam("token") String token,
	                           @RequestParam("username") String username);

	/**
	 * 刷新token
	 * @param refreshToken
	 * @return
	 */
	@PostMapping("/refresh")
	@ResponseBody
	AuthResponse refresh(@RequestParam("refresh") String refreshToken);
}

auth-service模块

gateway目录下创建一个服务实现auth-service的module模块

coding老师的jwt讲解笔记:/icoding-edu/2020/05/06/icoding-note-031.html

导入POM依赖

<dependencies>
  <!--redis用来存放生成token与refresh token的关系-->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
  </dependency>
  <dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.7.0</version>
  </dependency>
  <dependency>
    <groupId>com.icodingedu</groupId>
    <artifactId>auth-service-api</artifactId>
    <version>${project.version}</version>
  </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>
</dependencies>

创建启动类AuthServiceApplication

@SpringBootApplication
@EnableDiscoveryClient
public class AuthServiceApplication {
	public static void main(String[] args) {
		new SpringApplicationBuilder(AuthServiceApplication.class).web(WebApplicationType.SERVLET).run(args);
	}
}

创建一个service包,建立JwtService类

@Slf4j
@Service
public class JwtService {
	// 密钥,生产环境中应该从外部加密后获取,通过配置中心加密后获取,解密得到
	private static final String SERCET = "you must change it";
	// 发行人,生产环境中应该从外部加密后获取,通过配置中心加密后获取,解密得到
	private static final String ISSUER = "icodingedu";
	// 过期时间,60分钟
	private static final long EXPIRE = 1000*60*60;
	// 自包含的数据
	private static final String USERNAME = "username";


	/**
	 * 生成token
	 * @param account
	 * @return
	 */
	public String getToken(Account account){
		Date now = new Date();
		// 签名加密
		Algorithm algorithm = Algorithm.HMAC256(SERCET);
		String token = JWT.create().withSubject("member-user")
				.withIssuer(ISSUER) // 解密的时候如果不知道发行方也无法解密
				.withIssuedAt(now)
				.withExpiresAt(new Date(now.getTime() + EXPIRE)) // 过期时间
 				.withClaim(USERNAME,account.getUserName()) // 自包含的数据
				.sign(algorithm); // 签名
		log.info("jwt generated username={}",account.getUserName());
		return token;
	}

	/**
	 * 验证token
	 */
	public boolean verifyToken(String token,String username){
		log.info("verify jwt user={}",username);
		try{
			// 签名加密
			Algorithm algorithm = Algorithm.HMAC256(SERCET);
			// 构建验证器
			JWTVerifier verifier = JWT.require(algorithm).withIssuer(ISSUER).withClaim(USERNAME, username).build();
			// 验证,没有错误就直接通过
			verifier.verify(token);
			return true;
		}catch (Exception e){
			log.error("verify fail",e);
			return false;
		}
	}
  
  	/**
	 * 获取token中的用户名
	 */
	public String getUsername(String token){
		// 签名加密
		Algorithm algorithm = Algorithm.HMAC256(SERCET);
		// 构建验证器
		JWTVerifier verifier = JWT.require(algorithm).withIssuer(ISSUER).build();
		Claim claim = verifier.verify(token).getClaim(USERNAME);
		return  claim.asString();
	}
}

创建controller包,建立JwtController类

import com.icodingedu.springcloud.pojo.Account;
import com.icodingedu.springcloud.pojo.AuthResponse;
import com.icodingedu.springcloud.pojo.AuthResponseCode;
import com.icodingedu.springcloud.service.AuthService;
import com.icodingedu.springcloud.service.JwtService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.RestController;

import java.util.Objects;
import java.util.UUID;

@RestController
public class JwtController implements AuthService {
	@Autowired
	RedisTemplate redisTemplate;
	@Autowired
	JwtService jwtService;


	@Override
	public AuthResponse login(String username, String password) {
		Account account = Account.builder().userName(username).build();
		// TODO 从数据库根据用户名查询密码进行比较验证,这里假设验证通过
		// 1、生成token
		String token = jwtService.getToken(account);
		account.setToken(token);
		// 刷新token
		account.setRefreshToken(UUID.randomUUID().toString());
		// 2、保存refresh-token与token的关系
		redisTemplate.opsForValue().set(account.getRefreshToken(),account);

		return AuthResponse.builder().account(account).code(AuthResponseCode.SUCCESS).build();
	}

	@Override
	public AuthResponse verify(String token, String username) {
		boolean flag = jwtService.verifyToken(token,username);
		return AuthResponse.builder().code(flag?AuthResponseCode.SUCCESS:AuthResponseCode.INVALID_TOKEN).build();
	}

	@Override
	public AuthResponse refresh(String refreshToken) {
		Account account = (Account) redisTemplate.opsForValue().get(refreshToken);
		if(Objects.isNull(account)){
			return AuthResponse.builder().code(AuthResponseCode.USER_NOT_FOUND).build();
		}
		// 1、生成一个新的token
		String token = jwtService.getToken(account);
		account.setToken(token);
		// 2、更新refreshToken
		account.setRefreshToken(UUID.randomUUID().toString());
		// 3、删除redis上的旧值
		redisTemplate.delete(refreshToken);
		// 4、保存refresh-token与token的关系
		redisTemplate.opsForValue().set(account.getRefreshToken(),account);

		return AuthResponse.builder().account(account).code(AuthResponseCode.SUCCESS).build();
	}
}

配置application.yaml

spring:
  application:
    name: auth-service
  redis:
    port: 6379
    host: localhost
    database: 0  
server:
  port: 50001
eureka:
  client:
    service-url:
      defaultZone: http://localhost:20001/eureka/
  instance:
    lease-renewal-interval-in-seconds: 5 # 心跳间隔
    lease-expiration-duration-in-seconds: 30 # 没有心跳返回的淘汰时间30秒

management:
  security:
    enabled: false
  endpoints:
    web:
      exposure:
        include: "*"
  endpoint:
    health:
      show-details: always

测试

启动eureka-server、auth-service,启动redis服务,使用ApiPost测试

登录

使用refreshToken=‘004cc7f1-5bb2-4726-86f4-4940f4ce9648’刷新token

返回新的token和refresh-token了

10. Gateway整合JWT

项目Gateway-server,导入依赖,记得要排除spring-boot-starter-web的依赖

<dependency>
  <groupId>com.icodingedu.springcloud</groupId>
  <artifactId>auth-service-api</artifactId>
  <version>${project.version}</version>
  <exclusions>
    <exclusion>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </exclusion>
  </exclusions>
</dependency>
<dependency>
  <groupId>org.apache.commons</groupId>
  <artifactId>commons-lang3</artifactId>
  <version>3.7</version>
</dependency>

第一步,创建权限验证过滤器AuthFilter

@Component                                                                                                  
@Slf4j                                                                                                      
public class AuthFilter implements GatewayFilter, Ordered {                                                 
	private static final String AUTH="Authorization";                                                       
	private static final String USERNAME = "icodingedu-username";                                           
                                                                                                            
	@Autowired                                                                                              
	AuthService authService;                                                                                
                                                                                    
	@Override                                                                                               
	public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {                        
		log.info("Auth start");                                                                             
		ServerHttpRequest request = exchange.getRequest();                                                  
		HttpHeaders headers = request.getHeaders();                                                         
		// 获取请求头上的toen                                                                                      
		String token = headers.getFirst(AUTH);                                                              
		// 获取请求头上的username                                                                                  
		String username = headers.getFirst(USERNAME);                                                       
		                                                                                                    
		// 响应返回                                                                                             
		ServerHttpResponse response = exchange.getResponse();                                               
		if(StringUtils.isBlank(token)){                                                                     
			log.error("token not found");                                                                   
			response.setStatusCode(HttpStatus.UNAUTHORIZED);                                                
			return response.setComplete();                                                                  
		}                                                                                                   
		// 验证token                                                                                          
		AuthResponse res = authService.verify(token, username);                                             
		if(res.getCode() != AuthResponseCode.SUCCESS){                                                      
			log.error("invalid token");                                                                     
		    response.setStatusCode(HttpStatus.FORBIDDEN);                                                   
		    return response.setComplete();                                                                  
		}                                                                                                   
		// 将用户信息再次放到请求的header中传递给下游                                                                 
		ServerHttpRequest.Builder mutate = request.mutate();                                                
		mutate.header(USERNAME,username);                                                                   
		ServerHttpRequest buildRequest = mutate.build();                                                    
                                                                                                            
		// 如果响应中要存放数据,可以放到response的header中                                                           
		response.setStatusCode(HttpStatus.OK);                                                              
		response.getHeaders().add("icodingedu-user",username);                                              
                                                                                                            
		// 传递给下一个路由过滤器                                                                                   
		return chain.filter(exchange.mutate().request(buildRequest).response(response).build());            
	}                                                                                                       
                                                                                                            
	@Override                                                                                               
	public int getOrder() {                                                                                 
		return 0;                                                                                           
	}                                                                                                       
}                                                                                                                                                                                                                       

第二步,因为要通过Feign调用auth-service服务,所以启动类要开启注解@EnableFeignClients

@SpringBootApplication(exclude = {RedisReactiveAutoConfiguration.class})
@EnableDiscoveryClient
@EnableFeignClients
public class GatewayServerApplication {
	public static void main(String[] args) {
		SpringApplication.run(GatewayServerApplication.class);
	}
}

第三步,GatewayConfiguration里,指定路由使用过滤器AuthFilter

启动eureka-server、eureka-client、gateway-server、auth-service

访问http://localhost:50080/actuator/gateway/routes,看当前生效的路由

访问eureka-client的sayhello请求

报500内部错误

修改AuthFilter使用ribbon的方式调用auth-service服务的验证token的请求,代码片段如下

//	@Autowired
//	AuthService authService;

	@Autowired
	RestTemplate restTemplate;
... 
// 验证token                                                                                           
// AuthResponse res = authService.verify(token, username);                                           
String path = String.format("http://auth-service/verify?token=%s&username=%s",token,username);        
AuthResponse res = restTemplate.getForObject(path,AuthResponse.class);     
if(res.getCode() != AuthResponseCode.SUCCESS){                                                      
			log.error("invalid token");                                                                     
		    response.setStatusCode(HttpStatus.FORBIDDEN);                                                   
		    return response.setComplete();                                                                  
		}     
...

需要在启动类GatewayServerApplication,注入restTemplate的bean到spring 容器

@SpringBootApplication(exclude = {RedisReactiveAutoConfiguration.class})
@EnableDiscoveryClient
@EnableFeignClients
public class GatewayServerApplication {
	@Bean
	@LoadBalanced // 支持负载均衡的功能
	public RestTemplate restTemplate(){
		return new RestTemplate();
	}

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

重新启动gateway-server

再次访问eureka-client的sayhello请求

成功了,报401没有权限访问,我们来看后台输出,打印了Auth start ,token not found 的日志内容

接下来,我们访问登录请求来获取token,这个需要启动redis,因为我们把refresh-token与登录用户的绑定关系放到redis上了

然后,我们带上token和username再次访问sayhello

可以看到成功访问到了eureka-client服务的sayhello请求。

注意,我们设置了token的有效时间上60分钟,过期后再访问sayhello请求,我们在AuthFilter 设置了验证token失败,打印 invalid token。

如果要在gateway-sever服务本地验证token,那么需要将auth-service服务的JwtService类放到gateway-sever服务就可以了

11. 实现服务网关层统一异常返回

这里我们讲一下第二种方式,会使用到代理模式和装饰器模式(对feign调用请求的返回接口进行封装/装饰),要理解装饰模式的结构。

第一步,在项目gateway-server,新建接口BodyHackerFunction继承BiFunction函数式接口

import org.reactivestreams.Publisher;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.server.reactive.ServerHttpResponse;
import reactor.core.publisher.Mono;
import java.util.function.BiFunction;

/**
 * @Author jude
 * @Date 2022/5/16 7:58 PM
 * @Version 1.0
 */
public interface BodyHackerFunction extends BiFunction<ServerHttpResponse, Publisher<? extends DataBuffer>, Mono<Void>> {
}

第二步,新建具体装饰类BodyHackerHttpResponseDecorator

import org.reactivestreams.Publisher;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
import reactor.core.publisher.Mono;

/**
 * @Author jude
 * @Date 2022/5/16 11:17 PM
 * @Version 1.0
 * ServerHttpResponseDecorator 是装饰基类,
 * 这里写具体装饰类,一定要记住装饰模式的结构
 */
public class BodyHackerHttpResponseDecorator extends ServerHttpResponseDecorator {
	/**
	 * 负责具体写入body内容的代理类
	 */
	private BodyHackerFunction delegate = null;

	public BodyHackerHttpResponseDecorator(BodyHackerFunction bodyHandler, ServerHttpResponse delegate){
		super(delegate);
		this.delegate = bodyHandler;
	}

	@Override
	public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
		return this.delegate.apply(getDelegate(),body);
	}
}

第三步,新建过滤器ErrorFiltter

import com.icodingedu.springcloud.tool.BodyHackerFunction;
import com.icodingedu.springcloud.tool.BodyHackerHttpResponseDecorator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

/**
 * @Author jude
 * @Date 2022/5/17 12:51 PM
 * @Version 1.0
 */
@Slf4j
@Component
public class ErrorFilter implements GatewayFilter, Ordered {
	@Override
	public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
		ServerHttpRequest request = exchange.getRequest();
		// 声明Function函数式接口
		BodyHackerFunction delegate = (resp,body)->
			Flux.from(body).flatMap(orgBody->{
				// 原始的responseBody,即业务服务返回的responseBody
				byte[] orgContent = new byte[orgBody.readableByteCount()];
				orgBody.read(orgContent);
				String content = new String(orgContent);
				log.info("original content{}",content);

				// 如果是500,则替换为json字符串返回
      HttpHeaders headers = resp.getHeaders();
				if(resp.getStatusCode().value() == 500){
					content = String.format("{\"status\":%d,\"path\":\"%s\"}",resp.getStatusCode().value(),request.getPath().value());
          headers.setContentType(MediaType.APPLICATION_JSON);
				}
				// 告知客户端body的长度,如果不设置,客户端会一直等待
				headers.setContentLength(content.length());

				return resp.writeWith(Flux.just(content).map(c->resp.bufferFactory().wrap(c.getBytes())));
			}).then();

		// 具体装饰类返回,它继承装饰基类ServerHttpResponseDecorator也是接口ServerHttpResponse的实现类
		BodyHackerHttpResponseDecorator responseDecorator = new BodyHackerHttpResponseDecorator(delegate,exchange.getResponse());
		return chain.filter(exchange.mutate().response(responseDecorator).build()   ) ;
	}

	@Override
	public int getOrder() {
		// 更高的优先级执行。WHRITE_RESPONSE_FILTER的执行顺序是-1,这里-2比它更优先执行
		return -2;
	}
}

第四步,GatewayConfiguration 给路由添加过滤器

@Configuration
public class GatewayConfiguration {
//	@Autowired
//	TimerFilter timerFilter;
	@Autowired
	AuthFilter authFilter;
	
	@Autowired
	ErrorFilter errorFilter;

	@Bean
	@Order
	public RouteLocator customerRouters(RouteLocatorBuilder builder){
		LocalDateTime ldt = LocalDateTime.of(2022,4,28,22,55,10);
		LocalDateTime ldt2 = LocalDateTime.of(2022,4,28,22,56,10);

		return builder.routes().route(r-> r.path("/gavinjava/**")
				.and().method(HttpMethod.GET)
				.and().header("name")
				.filters(f->f.stripPrefix(1).addResponseHeader("java-param","gateway-config")
					.filter(errorFilter)
					//.filter(authFilter)
					//.filter(timerFilter)
				)
				.uri("lb://EUREKA-CLIENT")
		).route(r->r.path("/secondkill/**")
				.and().method(HttpMethod.GET)
				//.and().after(ZonedDateTime.now().plusSeconds(60))
				//.and().after(ZonedDateTime.of(ldt, ZoneId.of("Asia/Shanghai")))
				.and().between(ZonedDateTime.of(ldt,ZoneId.of("Asia/Shanghai")),ZonedDateTime.of(ldt2,ZoneId.of("Asia/Shanghai")))
				.filters(f->f.stripPrefix(1).redirect("304","https://www.icodingedu.com/"))
				.uri("lb://FEIGN-CLIENT")).build();
	}
}

修改一下eureka-client服务的controller层

@RestController
public class HelloController {
	@Value("${server.port}")
	private String port;
  
	@GetMapping("/sayhello")
	public String sayHello(){
		return "my port is "+port;
	}
	
	@GetMapping("/valid")
	public String valid(){
		int i =1/0;
		return "valid";
	}
}

启动eureka-server、gateway-server、eureka-client服务

没有添加ErrorFilter过滤器时,访问,响应的500错误页面

GatewayConfiguration 给路由添加过滤器ErrorFilter后,访问,响应的500,json格式返回

12. 实现服务网关层限流

限流可以看SpringCloud Aibaba-Sentinel流量卫兵,在业务应用层进行限流。

7种限流方式

下面介绍gateway基于redis实现限流,它是使用令牌桶算法的

在gateway-server创建配置类RedisLimiterConfiguration

import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.cloud.gateway.filter.ratelimit.RedisRateLimiter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import reactor.core.publisher.Mono;

/**
 * @Author jude
 * @Date 2022/5/18 12:49 PM
 * @Version 1.0
 */
@Configuration
public class RedisLimiterConfiguration {
	// ID 限流的业务标识
	// 根据用户请求IP地址进行限流
	@Bean  // 注入spring 容器
	@Primary
	public KeyResolver remoteAddressKeyResolver(){
		return exchange -> Mono.just(exchange.getRequest().getRemoteAddress().getAddress().getHostAddress());
	}
 
	@Bean("redisLimiterUser")
  @Primary
	public RedisRateLimiter redisRateLimiterUser(){
		// 第一个参数defaultReplensihRate 生成redis令牌的速率,这里每秒1个 
		// 第二个参数defaultBurstCapacity 令牌桶的容量,这里定义可容纳2个令牌 
		return n ew RedisRateLimiter(1,2);
	}

	@Bean("redisLimiterProduct")
	public RedisRateLimiter redisRateLimiterProduct(){
		// 第一个参数defaultReplensihRate 生成redis令牌的速率,这里每秒10个 
		// 第二个参数defaultBurstCapacity 令牌桶的容量,这里定义可容纳30个令牌 
		return new RedisRateLimiter(20,60);
	}
}

application.ymal 配置redis,前面已导入redis依赖

spring:
   redis:
    port: 6379
    host: localhost
    database: 0
   main:
    allow-bean-definition-overriding: true # 允许bean重复定义

修改GatewayConfiguration,添加限流过滤

@Configuration
public class GatewayConfiguration {
//	@Autowired
//	TimerFilter timerFilter;

	@Autowired
	AuthFilter authFilter;

	@Autowired
	ErrorFilter errorFilter;

	@Autowired
	KeyResolver hostNameResolver;

	@Autowired
	@Qualifier("redisLimiterUser")
	RedisRateLimiter redisLimiterUser;

	@Bean
	@Order
	public RouteLocator customerRouters(RouteLocatorBuilder builder){
		LocalDateTime ldt = LocalDateTime.of(2022,4,28,22,55,10);
		LocalDateTime ldt2 = LocalDateTime.of(2022,4,28,22,56,10);

		return builder.routes().route(r-> r.path("/gavinjava/**")
				.and().method(HttpMethod.GET)
				//.and().header("name")
				.filters(f->f.stripPrefix(1).addResponseHeader("java-param","gateway-config")
					.filter(errorFilter).requestRateLimiter(c->{
						c.setKeyResolver(hostNameResolver);
						c.setRateLimiter(redisLimiterUser);
					})
					//.filter(authFilter)
					//.filter(timerFilter)
				)
				.uri("lb://EUREKA-CLIENT")
		).route(r->r.path("/secondkill/**")
				.and().method(HttpMethod.GET)				.and().between(ZonedDateTime.of(ldt,ZoneId.of("Asia/Shanghai")),ZonedDateTime.of(ldt2,ZoneId.of("Asia/Shanghai")))
				.filters(f->f.stripPrefix(1).redirect("304","https://www.icodingedu.com/"))
				.uri("lb://FEIGN-CLIENT")).build();
	}
}

启动eureka-server、eureka-client、gateway-server,启动redis,使用Apipost测试,

正常访问

当我在1秒内点击多次请求,就会报429响应状态返回,限流成功

Post Directory