SpringCloud服务网关快速落地实战

16 篇文章 1 订阅
7 篇文章 0 订阅

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

对外服务的难题



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

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

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

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

一、服务网关在微服务中的应用

1. 对外服务的难题

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

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

  • 访问控制:看你是不是有权限进行访问,拒绝未授权的来访者
  • 引导指路:问清楚你要办的事情,指导你如何到达,找到你想要访问的内容
  • 网关层作为唯一的对外服务,外部请求不直接访问服务层,由网关层承接所有HTTP请求,在实际的应用里,我们也会将网关服务和Nginx一同使用

2.访问控制

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

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

3. 路由规则

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

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

二、第二代网关Gateway

1.Gateway的标签

Gateway的标签

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

2.Gateway可以做什么

Gateway可以做什么

  • 路由寻址
  • 负载均衡
  • 限流
  • 鉴权

Gateway VS Zuul

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

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

三、Gateway快速落地实施体验

  • 创建gateway项目
  • 连接Eureka基于服务发现自动创建路由规则
  • 通过Actuator实现动态路由功能

创建项目并加入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>

创建application启动类

package com.icodingedu.springcloud;

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

@SpringBootApplication
@EnableDiscoveryClient
public class GatewayServerApplication {

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

创建配置文件application

spring: 
  application:
    name: gateway-server
  cloud: 
    gateway: 
      discovery: 
        locator:
          enabled: true
server:
  port: 50080
    
eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:10080/eureka/

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

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

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

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

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

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

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

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

Gateway支持创建动态路由规则

# 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://FEIGN-CLIENT",
    "order": 0
}
# 可以看下新的路由规则是否创建成功
# http://localhost:50080/actuator/gateway/routes
# 可以删除路由规则
# DELETE http://localhost:50080/actuator/gateway/routes/myrouter

# 可以访问我们创建的路由规则
# http://localhost:50080/myrouter-path/sayhello

四、路由功能详解

1.路由组成结构

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

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

2.负载均衡

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

3.路由的工作流程

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

五、断言功能详解

1.Predicate机制

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

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

2.断言的作用阶段

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

3.常用断言介绍

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

4.路径断言

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中注册的服务名称,路由规则可以一次编写多个绑定关系

5.Method断言

这个断言是专门验证HTTP Method的

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

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

6.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

6.Header断言

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

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

7. Cookie断言

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

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

8.时间片验证

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

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

六、实现断言的配置

1.在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
        uri: lb://FEIGN-CLIENT
        predicates:
        - Path=/gavinrouter/**
        filters:
        - StripPrefix=1

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

http://localhost:50080/gavinrouter/sayhello

2. 在Java程序里进行配置

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

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("/gatewatjava/**")
                             .and().method(HttpMethod.GET)
                             .filters(f -> f.stripPrefix(1)
                                            .addResponseHeader("java-param","gateway-config")
                             )
                             .uri("lb://FEIGN-CLIENT")
                ).build();
    }
}
修改后进行访问验证:http://localhost:50080/gatewatjava/sayhello

七、After断言实现网关层秒杀

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

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

import lombok.Builder;
import lombok.Data;

@Data
@Builder
public class Product {

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

在feign-client中创建一个GatewayController

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";
    }
}

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

@Configuration
public class GatewayConfiguration {

    @Bean
    @Order
    public RouteLocator customerRouters(RouteLocatorBuilder builder){
        return builder.routes()
                .route(r -> r.path("/gatewayjava/**")
                             .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))
                             .filters(f -> f.stripPrefix(1))
                             .uri("lb://FEIGN-CLIENT")
                )
                .build();
    }
}

可以精确的定义时间节点

@Configuration
public class GatewayConfiguration {

    @Bean
    @Order
    public RouteLocator customerRouters(RouteLocatorBuilder builder){
        LocalDateTime ldt = LocalDateTime.of(2020,10,24,20,31,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")))
                             .filters(f -> f.stripPrefix(1))
                             .uri("lb://FEIGN-CLIENT")
                )
                .build();
    }
}

八、过滤器原理和生命周期

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

1.过滤器的实现方式

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

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

这里面有两个关键信息

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

2.过滤器的执行阶段

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类型过滤器,则是数字越小越先被执行

3.过滤器示例

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,如果没有StripPreix过滤器,那么转发到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过滤器

他可以把收到特定状态码的请求重定向到一个指定网址

.filters(f -> f.redirect(304,"https://www.baidu.com"))
//Caused by: java.lang.IllegalArgumentException: status must be a 3xx code, but was 404

九、自定义过滤器实现接口计时功能

去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里设置自定义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)90
---------------------------------------------
ns         %     Task name
---------------------------------------------
1775599286  100%  /sayhello

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

@Slf4j
@Component
public class TimerFilter implements GlobalFilter, Ordered

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

10、权限认证方案分析

1. 传统单应用的用户鉴权

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

2. 分布式环境下的解决方案

2.1. 同步session

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

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

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

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

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

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

2.3. Redis解决方案

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

在tomcat层面可以直接使用组件将容器的session放入到redis中,另一个方案可以借助springboot的管理session方式,将session存储进redis中

3. 分布式Session的替代方案

3.1. OAuth 2.0

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

3.2. JWT鉴权

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

鉴权流程

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

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

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

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

十一、实现JWT鉴权

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

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

在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 INCORRECT_PWD = 1000L;
    
    public static final Long USER_NOT_FOUND = 1001L;
    
    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

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 {

    @PostMapping("/login")
    @ResponseBody
    public AuthResponse login(@RequestParam("username") String username,
                              @RequestParam("password") String password);

    @GetMapping("/verify")
    @ResponseBody
    public AuthResponse verify(@RequestParam("token") String token,
                               @RequestParam("username") String username);

    @PostMapping("/refresh")
    @ResponseBody
    public AuthResponse refresh(@RequestParam("refresh") String refreshToken);
}

创建服务实现的auth-service的module,还是放在gateway目录下

导入POM依赖

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

创建启动类application

package com.icodingedu.springcloud;

import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;

@EnableDiscoveryClient
@SpringBootApplication
public class AuthServiceApplication {

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

创建一个service包,建立JwtService类

package com.icodingedu.springcloud.service;

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.icodingedu.springcloud.entity.Account;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.Date;

@Slf4j
@Service
public class JwtService {
    
    //生产环境中应该从外部加密后传入
    private static final String KEY = "you must change it";
    //生产环境中应该从外部加密后传入
    private static final String ISSUER = "gavin";
    //定义过期时间
    private static final long TOKEN_EXP_TIME = 60000;
    //定义传入的参数名
    private static final String USERNAME = "username";

    /**
     * 生成token
     * @param account 账户信息
     * @return token
     */
    public String token(Account account){
        //生成token时间
        Date now = new Date();
        //生成token所要用到的算法
        Algorithm algorithm = Algorithm.HMAC256(KEY);
        
        String token = JWT.create()
                          .withIssuer(ISSUER) //发行方
                          .withIssuedAt(now) //发行时间
                          .withExpiresAt(new Date(now.getTime()+TOKEN_EXP_TIME)) //token过期时间
                          .withClaim(USERNAME,account.getUsername()) //传入发行的username
                          .sign(algorithm); //用前面设置算法签发
        log.info("jwt generated user={}",account.getUsername());
        return token;
    }

    /**
     * 验证token
     * @param token
     * @param username
     * @return
     */
    public boolean verify(String token,String username){
        log.info("verify jwt - user={}",username);
        try{
            //加密解密算法一样
            Algorithm algorithm = Algorithm.HMAC256(KEY);
            //构建一个验证器:验证JWT的内容,是个接口
            JWTVerifier verifier = JWT.require(algorithm)
                    .withIssuer(ISSUER)
                    .withClaim(USERNAME,username)
                    .build();
            //进行验证,没有错误就直接通过
            verifier.verify(token);
            return true;
        }catch(Exception ex){
            log.error("auth failed",ex);
            return false;
        }
    }
}

创建controller包,建立JwtController类

package com.icodingedu.springcloud.controller;

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

import java.util.UUID;

@Slf4j
@RestController
public class JwtController implements AuthService {

    @Autowired
    private JwtService jwtService;

    @Autowired
    private RedisTemplate redisTemplate;

    @Override
    public AuthResponse login(String username, String password) {
        Account account = Account.builder()
                .username(username)
                .build();

        //TODO 0-这一步需要验证用户名密码,一般是在数据库中,假定已经验证通过了
        //TODO 如果验证失败在这里就要return
        //1-生成token
        String token = jwtService.token(account);
        account.setToken(token);
        //2-这里保存拿到新token的key
        account.setRefreshToken(UUID.randomUUID().toString());
        //3-保存token,把token保存取来在refresh时才知道更新关联哪个token
        redisTemplate.opsForValue().set(account.getRefreshToken(),account);
        //4-返回token
        return AuthResponse.builder()
                .account(account)
                .code(AuthResponseCode.SUCCESS)
                .build();
    }

    @Override
    public AuthResponse verify(String token, String username) {
        boolean flag = jwtService.verify(token, username);

        return AuthResponse.builder()
                .code(flag?AuthResponseCode.SUCCESS:AuthResponseCode.INVALID_TOKEN)
                .build();
    }

    @Override
    public AuthResponse refresh(String refreshToken) {
        //当使用redisTemplate保存对象时,对象必须时一个可被序列化的对象
        Account account = (Account) redisTemplate.opsForValue().get(refreshToken);
        if(account == null){
            return AuthResponse.builder()
                    .code(AuthResponseCode.USER_NOT_FOUND)
                    .build();
        }
        //获取一个新token
        String token = jwtService.token(account);
        account.setToken(token);
        //更新新的refreshToken
        account.setRefreshToken(UUID.randomUUID().toString());
        //将原来的删除
        redisTemplate.delete(refreshToken);
        //添加新的token
        redisTemplate.opsForValue().set(account.getRefreshToken(),account);
        return AuthResponse.builder()
                .account(account)
                .code(AuthResponseCode.SUCCESS)
                .build();
    }
}

设置application配置文件

spring.application.name=auth-service
server.port=50081

eureka.client.serviceUrl.defaultZone=http://localhost:10080/eureka/

spring.redis.host=localhost
spring.redis.database=0
spring.redis.port=6379

info.app.name=auth-service
info.app.description=test

management.security.enabled=false
management.endpoints.web.exposure.include=*
management.endpoint.health.show-details=always

可以启动验证了:eureka-server、auth-service

在PostMan里进行了验证:login、verify、refresh

开启改造gateway-server

POM里引入依赖,增加三个依赖

   <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>3.7.0</version>
        </dependency>
				<!--因为springcloud gateway是基于webflux的,如果需要web则是导入starter-webflux而不是starter-web-->
        <dependency>
            <groupId>com.icodingedu</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.5</version>
        </dependency>

在gateway-server中创建鉴权的service类GatewayAuthService

package com.icodingedu.springcloud.service;

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Service
@Slf4j
public class GatewayAuthService {

    //生产环境中应该从外部加密后传入
    private static final String KEY = "you must change it";
    //生产环境中应该从外部加密后传入
    private static final String ISSUER = "gavin";
    //定义传入的参数名
    private static final String USERNAME = "username";

    /**
     * 验证token
     * @param token
     * @param username
     * @return
     */
    public boolean verify(String token,String username){
        log.info("verify jwt - user={}",username);
        try{
            //加密解密算法一样
            Algorithm algorithm = Algorithm.HMAC256(KEY);
            //构建一个验证器:验证JWT的内容,是个接口
            JWTVerifier verifier = JWT.require(algorithm)
                    .withIssuer(ISSUER)
                    .withClaim(USERNAME,username)
                    .build();
            //进行验证,没有错误就直接通过
            verifier.verify(token);
            return true;
        }catch(Exception ex){
            log.error("auth failed",ex);
            return false;
        }
    }
}

创建一个新的类:AuthFilter

package com.icodingedu.springcloud.filter;

import com.icodingedu.springcloud.entity.AuthResponse;
import com.icodingedu.springcloud.entity.AuthResponseCode;
import com.icodingedu.springcloud.service.GatewayAuthService;
import io.netty.util.internal.StringUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
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.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

@Slf4j
@Component("authFilter")
public class AuthFilter implements GatewayFilter, Ordered {
    
    private static final String AUTH = "Authorization";
    
    private static final String USERNAME = "icodingedu-username";
    
    @Autowired
    private GatewayAuthService authService;


    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        log.info("Auth Start");
        ServerHttpRequest request = exchange.getRequest();
        HttpHeaders headers = request.getHeaders();
        String token = headers.getFirst(AUTH);
        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();
        }

        AuthResponse resp = AuthResponse.builder()
                .code(authService.verify(token,username)?AuthResponseCode.SUCCESS:AuthResponseCode.INVALID_TOKEN)
                .build();
        if(resp.getCode() != 1L){
            log.error("invalid token");
            response.setStatusCode(HttpStatus.FORBIDDEN);
            return response.setComplete();
        }
        //TODO 将用户信息再次存放在请求的header中传递给下游业务
        ServerHttpRequest.Builder mutate = request.mutate();
        mutate.header(USERNAME, username);
        ServerHttpRequest buildRequest = mutate.build();
        
        //TODO 如果响应中需要放数据,也可以放在response的header中
        response.setStatusCode(HttpStatus.OK);
        response.getHeaders().add("icoding-user",username);
        
        return chain.filter(
                exchange.mutate()
                        .request(buildRequest)
                        .response(response)
                        .build());
    }

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

将AuthFilter注入到configuration中

@Configuration
public class GatewayConfiguration {

    @Autowired
    private AuthFilter authFilter;

    @Bean
    @Order
    public RouteLocator customerRouters(RouteLocatorBuilder builder){
        LocalDateTime ldt = LocalDateTime.of(2020,10,24,21,30,10);
        return builder.routes()
                .route(r -> r.path("/gavinjava/**")
                             .and().method(HttpMethod.GET)
                             .filters(f -> f.stripPrefix(1)
                                            .addResponseHeader("java-param","gateway-config")
                                            .filter(authFilter)
                             )
                             .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();
    }
}

启动服务进行验证:eureka-server、auth-service、feign-client、gateway-server

十二、实现网关层限流

创建一个限流的配置类RedisLimiterConfiguration

package com.icodingedu.springcloud.config;

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;

@Configuration
public class RedisLimiterConfiguration {

    //我们这里根据用户请求IP地址进行限流
    @Bean
    @Primary //一个系统不止一个KeyResolver
    public KeyResolver remoteAddressKeyResolver(){
        return exchange -> Mono.just(
            exchange.getRequest()
                    .getRemoteAddress()
                    .getAddress()
                    .getHostAddress()
        );
    }

    @Bean("redisLimiterUser")
    @Primary
    public RedisRateLimiter redisRateLimiterUser(){
        //这里相当于一个令牌桶,我们也可以自己创建一个限流脚本
        //defaultReplenishRate:限流桶速率,每秒10个
        //defaultBurstCapacity:桶的容量,60
        return new RedisRateLimiter(10,60);
    }

    @Bean("redisLimiterProduct")
    public RedisRateLimiter redisRateLimiterProduct(){
        //桶的容量100,创建令牌的速度是每秒20
        return new RedisRateLimiter(20,100);
    }
}

配置application.yaml的redis信息

spring:
  application:
    name: gateway-server
  cloud:
    gateway:
      discovery:
        locator:
          enabled: true
          lower-case-service-id: true
  redis:
    host: localhost
    port: 6379
    database: 0
  main:
    allow-bean-definition-overriding: true

使用的时候需要在GatewayConfiguration中进行配置加入RedisLimiter的配置

	@Configuration
	public class GatewayConfiguration {
	
	    @Autowired
	    private KeyResolver hostNameResolver;
	
	    @Autowired
	    @Qualifier("redisLimiterUser")
	    private RateLimiter rateLimiter;
	
	    @Autowired
	    private AuthFilter authFilter;
	
	    @Bean
	    @Order
	    public RouteLocator customerRouters(RouteLocatorBuilder builder){
	        LocalDateTime ldt = LocalDateTime.of(2020,10,24,21,30,10);
	        return builder.routes()
	                .route(r -> r.path("/gavinjava/**")
	                             .and().method(HttpMethod.GET)
	                             .filters(f -> f.stripPrefix(1)
	                                            .addResponseHeader("java-param","gateway-config")
	                                            .filter(authFilter)
	                                            .requestRateLimiter(
	                                                    c ->{
	                                                        c.setKeyResolver(hostNameResolver);
	                                                        c.setRateLimiter(rateLimiter);
	                                                        c.setStatusCode(HttpStatus.BAD_GATEWAY);
	                                            })
	                             )
	                             .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();
	    }
	}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值