SpringCloud与微服务-第9章 Spring Cloud Stream 消息驱动的微服务

旧文档(3.0.12.RELEASE):
https://docs.spring.io/spring-cloud-stream/docs/3.0.12.RELEASE/reference/html/

最新文档:
https://docs.spring.io/spring-cloud-stream/docs/current/reference/html/spring-cloud-stream.html#spring-cloud-stream-overview-introducing -->


Spring Cloud Stream 是一个用来为微服务应用构建消息驱动能力的框架。它可以基于 Spring Boot 来创建独立的,可用于生产的 Spring 应用程序。

它通过使用 Spring Integration 来连接消息代理中间件以实现消息事件驱动。Spring Cloud Stream 为一些供应商的消息中间件产品提供了个性化的自动化配置实现。


常见的消息中间件有:

  • RabbitMQ
    一个开源的 AMQP 实现, 服务器端用 Erlang 语言编写,支持多种客户端,如:Python、Ruby、.NET、Java、JMS、C、PHP、ActionScript、XMPP、STOMP 等,支持 AJAX、REST、SOAP 等多种通信协议。
  • Kafka
    一个分布式的基于发布/订阅模式的消息队列,主要应用于大数据实时处理领域。
  • ActiveMQ
    一个完全支持 JMS1.1 和 J2EE 1.4 规范的 JMS Provider 实现,尽管 JMS 规范出台已经是很久的事情了,但是 JMS 在当今的 J2EE 应用中间仍然扮演着特殊的地位。
  • RocketMQ
    一个分布式的消息中间件,具有低延迟、高性能、高可靠、亿级并发的特点,适用于大规模分布式系统的高性能场景。经受了阿里双十一的考验,具有丰富的实战经验。

简单地说,Spring Cloud Stream 本质上就是整合了 SpringBoot 和 Spring Integration, 实现了一套轻量级的消息驱动的微服务框架。

Spring Integration 是一个轻量级的消息代理框架,它的主要目的是为了在应用程序之间提供消息发送和接收的功能。


通过使用 Spring Cloud Stream ,可以有效简化开发人员对消息中间件的使用复杂度,让系统开发人员可以有更多的精力关注于核心业务逻辑的处理。

由于 Spring Cloud Stream 基于 Spring Boot 实现,所以它秉承了 Spring Boot 的优点,自动化配置的功能可帮助我们快速上手使用.


早期的版本比如 Spring Cloud Stream 3.0.12.RELEASE, 只支持 RabbitMQ 和 Kafka 两个著名的消息中间件的自动化配置。

最新版本的 Spring Cloud Stream 已经可以支持几乎所有的主流消息中间件,包括:RabbitMQ、Kafka、ActiveMQ、RocketMQ、Redis、Amazon Kinesis 等。


本章节将主要以 RabbitMQ 为例进行 Spring Cloud Stream 的内容讲解。


目标

在本章中,您将学习:

  • Spring Cloud Stream 快速入门
  • 核心概念
    • 绑定器 Binder
    • 发布-订阅模式 Publis-Subscribe Pattern
    • 消费组 Consumer Group
    • 分区 Partitioning
  • 使用详解
  • 绑定器详解
  • 配置详解

Spring Cloud Stream 快速入门

需求:

  1. 本地安装 RabbitMQ。
  2. 构建一个基于 Spring Boot 的微服务应用,
  3. 这个微服务应用将通过使用消息中间件 RabbitMQ 来接收消息并将消息打印到日志中。

RabbitMQ

消息队列 - MQ(message queue)从字面意思上看,本质是个队列,FIFO 先入先出,只不过队列中存放的是 message 而已,还是一种跨进程的通信机制,用于上下游传递消息。在互联网架构中,MQ 是一种非常常见的上下游"逻辑解耦+物理解耦"的消息通信服务。


通过消息队列可以实现应用程序之间的解耦,提高系统的可扩展性和可维护性。消息队列的应用场景非常多,比如异步处理、应用解耦、流量削锋、日志处理、消息通讯、消息广播等。


RabbitMQ 是 MQ 的实现之一, 具体来说它是一个开源的 AMQP 0-9-1(Advanced Message Queuing Protocol) 的实现。

h:14em


消息代理(Broker)从发布者(publishers)(发布消息的应用程序,也称为生产者)接收消息,并将其路由到消费者(处理这些消息的应用程序)。由于 AMQP 是一个网路协议, 所以发布者、代理和消费者可以在不同的进程中运行, 甚至在不同的主机上运行。

消息被发布到交换机(exchange),交换机可比作邮局或邮箱。然后,交换使用称为绑定(binding)的规则将消息副本分发到队列。然后,代理将消息传递给订阅队列的消费者,或者消费者根据需要从队列中获取/拉取消息。


RabbitMQ 有着运行在所有 Erlang 语言所支持的平台之上的潜力,从嵌入式系统到多核心集群还有基于云端的服务器。为了方便学习和演示,我们将在本地的 windows 系统上配置 RabbitMQ。


RabbitMQ 的安装方式,在其官方网站上有详细的介绍,包括如何下载资源,如何配置,如何启动等。为演示方便我们在 windows 系统上进行 RabbitMQ 安装。

官方文档地址: https://www.rabbitmq.com/install-windows.html 。这里我们下载rabbitmq-server-3.11.15.exe, 安装前请确保已经安装了 erlang。

可以从这里了解到你使用的 rabbitmq 需要安装什么 Erlang 版本.
https://www.rabbitmq.com/which-erlang.html

Erlang 安装: https://erlang.org/download/otp_versions_tree.html


在 Windows 安装 RabbitMQ 具体安装步骤如下:

  1. 下载 otp_win64_25.3.2.exe 并双击安装。

  2. 下载 rabbitmq-server-3.11.15.exe 并双击安装。

  3. 启动 RabbitMQ 服务
    rabbitmq_server-3.11.15\sbin>rabbitmq-server.bat start

  4. 激活监控插件
    rabbitmq_server-3.11.15\sbin>rabbitmq-plugins enable rabbitmq_management


安装成功之后,在浏览器中访问:http://127.0.0.1:15672 (RabbitMQ 的默认 UI 端口号是 15672, 默认本地登录账户 guest, 密码也是 guest):

file


之后点击 login 按钮,进入主界面:

w:35em


稍后,我们就可以在 RabbitMQ 主界面进行一些操作,现在让我们准备开始修改我们之前的两个微服务 orderservice 和 userservice,使用 Spring Cloud Stream 模拟一次简单的异步通讯吧。

异步: 一方发送消息,另一方接收消息,发送方不需要等待接收方的响应,而是继续执行后续的操作。


消息生产者

我们使用 userservice 服务作为消息的生产者,模拟一次消息的生产和发送。


1.引入依赖

引入 Spring Cloud Stream 整合 RabbitMQ 的依赖:



    org.springframework.cloud
    spring-cloud-starter-stream-rabbit

注意: 由于 Spring Cloud Stream 依赖于 Spring Boot,所以我们不需要再引入 Spring Boot 的依赖。但是如果需要创建控制器,则需要引入 Spring Boot 的 web 依赖。


2.修改配置文件

对配置文件进行修改,添加 Spring Cloud Stream 与 RabbitMQ 的相关属性,稍后我们会对这些属性进行解读:


spring:
  cloud:
    stream:
      binders: #在此处配置要绑定的rabbitmq的服务信息
        defaultRabbit: #表示定义绑定名称
          type: rabbit #消息组件类型是RabbitMQ
          environment: #rabbitmq的相关的环境配置
            spring:
              rabbitmq:
                host: localhost
                port: 5672 ## rabbitmq的服务器端口
                username: guest
                password: guest
      bindings: #exchange将路由到队列的规则
        output: #这个名字是一个通道的名称
          destination: niitExchage #表示要使用的交换机的名称定义
          content-type: application/json #设置消息类型
          binder: defaultRabbit #设置要绑定的消息服务的具体设置
          group: niit #消息分组

binding 是一个接口,用于声明输入和输出通道 12。每个通道可以绑定到一个外部的消息代理(如 RabbitMQ 或 Kafka)上,通过 Binder 实现来连接。

binders 是一个抽象,用于实现不同类型的消息代理的连接逻辑。Spring Cloud Stream 提供了一些默认的 Binder 实现,如 RabbitMQ 和 Kafka,也可以自定义 Binder 实现。

一个应用可以使用多个 Binder 实现来连接不同类型的消息代理,但是需要在配置文件中指定每个通道使用哪个 Binder 实现。


3.启动类添加@EnableBinding 注解

创建一个消息生产的接口,定义发送消息的方法签名:

@EnableBinding({Source.class})
@MapperScan("com.niit.user.mapper")
@SpringBootApplication
public class UserApplication {

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

}

不是必须


4.实现消息生产服务业务逻辑

创建一个实现类,用来具体实现接口业务逻辑方法:

@EnableBinding(Source.class)
public class UserMessageService {
    @Resource
    private MessageChannel output;

    @Override
    public String send(String msg) {
        Message build = MessageBuilder.withPayload(msg).build();
        boolean sendFlag = output.send(build);
        if (sendFlag) {
            return "消息发送成功: "+msg;
        }
        return "消息发送失败: " + msg;
    }
}

5.创建控制器,对外暴露接口

创建一个 Controller,可以让外部 HTTP 请求访问内部业务,从而向通道中发送消息:

@RestController
@RequestMapping("/message")
public class UserMessageController {
    @Autowired
    private UserMessageService messageService;

    @GetMapping("/rabbitmq/{msg}")
    public String sendMessage(@PathVariable("msg") String msg){
      return   messageService.send(msg);
    }
}

消息消费者

我们使用 orderservice 服务作为消息的消费者,模拟一次消息的接收和消费。


1.引入依赖

引入 Spring Cloud Stream 整合 RabbitMQ 的依赖:



    org.springframework.cloud
    spring-cloud-starter-stream-rabbit

2.修改配置文件

对配置文件进行修改,添加 Spring Cloud Stream 与 RabbitMQ 的相关属性,稍后我们会对这些属性进行解读:


spring:
  cloud:
    stream:
      binders: #在此处配置要绑定的rabbitmq的服务信息
        defaultRabbit: #表示定义的名称,用于与binding整合
          type: rabbit #消息组件类型是RabbitMQ
          environment: #rabbitmq的相关的环境配置
            spring:
              rabbitmq:
                host: localhost
                port: 5672 ## rabbitmq的服务器默认端口
                username: guest
                password: guest
      bindings: #服务的整合处理
        input: #这个名字是一个通道的名称
          destination: niitExchage #表示要使用的交换机的名称定义
          content-type: application/json #设置消息类型
          binder: defaultRabbit #设置要绑定的消息服务的具体设置
          group: niit #消息分组

3.启动类添加@EnableBinding 注解
@EnableBinding({Sink.class})
...
public class OrderApplication {...}

不是必须


4.创建 Java 类,接收并消费消息
@EnableBinding({Sink.class})
public class MessageReceiver {
    @StreamListener(Sink.INPUT) // 注解监听队列, 用于消费者的队列的消息接收
    public void receiveMessage(Message msg){
        System.out.println("order-service接收到的消息是: " + msg.getPayload());
    }
}

核心注解

现在介绍刚才出现的两个 Spring Cloud Stream 核心注解:

  • @EnableBinding: 通过 @EnableBinding (Sink.class) 绑定了 Sink 接口,该接口是 Spring Cloud Stream 中默认实现的对输入消息通道绑定的定义,它的源码如下:

    public interface Sink {
      String INPUT = "input";
    
      @Input("input")
      SubscribableChannel input();
    }

  • @EnableBinding: 通过 @EnableBinding (Source.class) 绑定了 Source 接口,该接口是 Spring Cloud Stream 中默认实现的对输出消息通道绑定的定义,它的源码如下:

    public interface Source {
      String OUTPUT = "output";
    
      @Output("output")
      MessageChannel output();
    }

  • @EnableBinding: 通过 @EnableBinding (Processor.class) 绑定了 Processor接口, 它是一个消息通道的定义,它继承了 Sink 接口,同时还继承了 Source 接口, 作用是既可以作为消息的生产者,也可以作为消息的消费者,它的源码如下:

    public interface Processor extends Source, Sink {}

通过 @Input 注解绑定了一个名为 input 的通道。除了 Sink 之外, Spring Cloud Stream 还默认实现了绑定 output 通道的 Source 接口,还有结合了 Sink 和 Source 的 Processor 接口,实际使用时也可以自己通过 @Input@Output 注解来定义绑定消息通道的接口。

在 Spring Cloud Stream 3.1 版本中,@EnableBinding 注解已被弃用,取而代之的是功能编程模型。


  • @StreamListener :主要定义在方法上,作用是将被修饰的方法注册为消息中间件上数据流的事件监听器,注解中的属性值对应了监听的消息通道名。在上面的例子中,通过@StreamListener (Sink.INPUT) 注解将 receiveMessage 方法注册为 input 消息通道的监听处理器,所以在 RabbitMQ 的控制页面中发布消息的时候, receiveMessage 方法会做出对应的响应动作。

测试

同时启动生产者和消费者

启动 Nacos 服务,然后分别启动 userservice 服务和 orderservice 服务。现在,我们访问: http://localhost:8081/message/rabbitmq/helloworld ,可以在 web 浏览器页面看到消息是否发送成功:

image-20220804095142675


多发送几次请求之后,我们可以在 RabbitMQ 的控制台页面看到消息流量的波动图:

h:14em


此时,我们在 orderservice 服务的控制台上,也可以观察到我们接收的消息:

image-20220804095507894


先启动生产者后启动消费者

上面的测试步骤,还不能直观的体现出异步消息的特性,我们现在模拟现实中的异步情境。我们可以先把消息消费者 orderservice 服务停掉,然后让消息生产者 userservice 服务多生产一些消息,发送到消息队列中,随后再启动消息消费者 orderservice 服务,查看 orderservice 服务的控制台输出日志:


image-20220804100314693


由此我们可以发现,当消费者服务重启之后,可以重新消费之前没有消费的消息。

需要指定消息分组(group)才有消息持久化的效果


通过 Spring Cloud Stream 框架驱动 RabbitMQ 消息中间件,解除了 userservice 和 orderservice 之间的耦合性,并且屏蔽了 RabbitMQ 底层复杂 API 的使用。

通过上一节介绍的快速入门示例,相信同学们对 Spring Cloud Stream 的工作模式己经有了一些基础概念,比如输入、输出通道的绑定,通道消息事件的监听等。在本节中,将详细介绍在 Spring Cloud Stream 中是如何通过定义一些基础概念来对各种不同的消息中间件做抽象的。


官网提供的 Spring Cloud Stream 的模型结构图。从中可以看到, Spring Cloud Stream 构建的应用程序与消息中间件之间是通过绑定器 Binder 相关联的,绑定器对于应用程序而言起到了隔离作用,它使得不同消息中间件的实现细节对应用程序来说是透明的。

bg right fit


所以对于每一个 Spring Cloud Stream 的应用程序来说,它不需要知晓消息中间件的通信细节,它只需知道 Binder 对应程序提供的抽象概念来使用消息中间件来实现业务逻辑即可,而这个抽象概念就是在快速入门中提到的消息通道: Channel 。如图所示,在应用程序和 Binder 之间定义了两条输入通道和三条输出通道来传递消息,而绑定器则是作为这些通道和消息中间件之间的桥梁进行通信。


绑定器

Binder 绑定器是 Spring Cloud Stream 中一个非常重要的概念。

绑定器的作用: 屏蔽底层消息中间件的差异, 降低消息中间件切换成本,统一消息的编程模型。


在没有绑定器这个概念的情况下,Spring Boot 应用要直接在业务代码中与消息中间件进行信息交互的时候,由于各消息中间件构建的初衷不同,所以它们在实现细节上会有较大的差异,这使得实现的消息交互逻辑就会非常笨重。

因为对具体的中间件实现细节有太重的依赖,当中间件有较大的变动升级或是更换中间件的时候,就需要付出非常大的代价来实施, 比如修改业务中的相关代码。


通过定义绑定器作为中间层,完美地实现了应用程序与消息中间件细节之间的隔离。通过向应用程序暴露统一的 Channel 通道,使得应用程序不需要再考虑各种不同的消息中间件的实现。当需要升级消息中间件,或是更换其他消息中间件产品时,要做的就是更换它们对应的 Binder 绑定器而不需要修改任何 Spring Boot 的应用逻辑。


早期 3.x 版本的 Spring Cloud Stream 只为 RabbitMQ 和 Kafka 提供了默认的 Binder 实现,在快速入门的例子中,就使用了 RabbitMQ 的 Binder。

bg right fit


另外, Spring Cloud Stream 还实现了一个专门用于单元测试的 TestSupportBinder, 开发者可以直接使用它来对通道的接收内容进行可靠的测试断言。

如果要使用除了 RabbitMQ 和 Kafka 以外的消息中间件的话,也可以过使用它所提供的扩展 API 来自行实现其他中间件的 Binder.

或者也可以使用较新版本的 Spring Cloud Stream 从而自动实现对其他消息中间件的支持。


相信大家己经发现,在快速入门示例中,使用 application.yml 做了一些属性设置。

当然,我们还可以通过 Spring Boot 应用支持的任何方式来修改这些配置,比如,通过应用程序参数、环境变量、 application.properties 或是 application.yaml 配置文件等。


发布-订阅模式

Spring Cloud Stream 中的消息通信方式遵循了发布 - 订阅模式,当一条消息被投递到消息中间件之后,它会通过共享的 Topic 主题进行广播,消息消费者在订阅的主题中收到它并触发自身的业务逻辑处理。这里所提到的 Topic 主题是 Spring Cloud Stream 中的一个抽象概念,用来代表发布共享消息给消费者的地方。


在不同的消息中间件中, Topic 可能对应不同的概念,比如,在 RabbitMQ 中,它对应 Exchange ,而在 Kakfa 中则对应 Kafka 中 的 Topic 。

在快速入门的示例中,通过 RabbitMQ 的 Channel 发布消息给我们编写的应用程序消费,而实际上 Spring Cloud Stream 应用启动的时候,在 RabbitMQ 的 Exchange 中也创建了一个名为 niitExchange 的交换器,

由于 Binder 的隔离作用,应用程序并无法感知它的存在,应用程序只知道自己指向 Binder 的输入或是输出通道。


在 Exchanges 选项卡中,还能找到名为 niitExchange 的交换器,单击进入可以看到如下图所示的详情页面。


bg fit


可以通过 Exchange 页面的 Publish Message 来发布消息:


bg fit


此时可以发现消费者服务接收到了消息:


bg fit


发布 - 订阅模式

  • 相对于点对点队列实现的消息通信来说, Spring Cloud Stream 采用的发布 - 订阅模式可以有效降低消息生产者与消费者之间的耦合。当需要对同一类消息增加一种处理方式时,只需要增加一个应用程序并将输入通道绑定到既有的 Topic 中就可以实现功能的扩展,而不需要改变原来己经实现的任何内容。

bg right fit


消费组

虽然 Spring Cloud Stream 通过发布 - 订阅模式将消息生产者与消费者做了很好的解耦,基于相同主题的消费者可以轻松地进行扩展,但是这些扩展都是针对不同的应用实例而言的。在现实的微服务架构中,每一个微服务应用为了实现高可用和负载均衡,实际上都会部署多个实例。在很多情况下,消息生产者发送消息给某个具体微服务时,只希望被消费一次。但是同一个应用的多个实例都会接收到消息,这个消息将会被重复消费。


为了解决这个问题,在 Spring Cloud Stream 中提供了消费组的概念。如果在同一个主题上的应用需要启动多个消费者实例的时候,可以通过 spring.cloud.stream.bindings.input.group 属性为应用指定一个组名,这样这个应用的多个实例在接收到消息的时候,只会有一个成员真正收到消息并进行处理。


如下图所示,为 Service-A 和 Service-B 分别启动了两个实例,并且根据服务名进行了分组,这样当消息进入主题之后, Group-A 和 Group-B 都会收到消息的副本,但是在两个组中都只会有一个实例对其进行消费。

bg right fit


默认情况下,当没有为应用指定消费组的时候, Spring Cloud Stream 会为其分配一个独立的匿名消费组。所以,如果同一主题下的所有应用都没有被指定消费组的时候,当有消息发布之后,所有的应用都会对其进行消费,因为它们各自都属于一个独立的组。如果这个消息根据业务要求只需要被消费一次,那么就会出现重复消费的问题

匿名消费者组订阅的消息是不会持久化的, 因此是不可靠的.


也可以为多个消费者指定相同的组, 可以逻辑上看做是一个消费者. 这样可以实现负载均衡和故障转移的效果, 因为每个消息只会被消费者组中的成员消费一次. 具体由哪个消费者消费, 由消息中间件决定.

指定的消费者组订阅的消息会持久化, 因此更加可靠.


消息分区

通过引入消费组的概念,己经能够在多实例的情况下,保障每个消息只被组内的一个实例消费。但是消费组无法控制消息具体被哪个实例消费。也就是说,对于同一条消息,它多次到达之后可能是由不同的实例进行消费的。


但是对于一些业务场景,需要对一些具有相同特征的消息设置每次都被同一个消费实例处理,比如,一些用于监控服务,为了统计某段时间内消息生产者发送的报告内容,监控服务需要在自身聚合这些数据,


那么消息生产者可以为消息在消息的 header 中增加一个固有的特征 ID 来进行分区,使得拥有这些 ID 的消息每次都能被发送到一个特定的实例上实现累计统计的效果,否则这些数据就会分散到各个不同的节点导致监控结果不一致的情况。


而分区概念的引入就是为了解决这样的问题:当生产者将消息数据发送给多个消费者实例时,保证拥有共同特征的消息数据始终是由同一个消费者实例接收和处理。


Spring Cloud Stream 为分区提供了通用的抽象实现,用来在消息中间件的上层实现分区处理,所以它对于消息中间件自身是否实现了消息分区并不关心,这使得 Spring Cloud Stream 为不具备分区功能的消息中间件也增加了分区功能扩展。


小问题 :
什么是 Spring Cloud Stream 中一个非常重要的概念?

  1. Spring 绑定器
  2. Prop 绑定器
  3. Binder 绑定器
    查看答案

    正确答案:3


使用详解

在介绍了 Spring Cloud Steam 的基础结构和核心概念之后,我们来详细地学习一下它所提供的一些核心注解的具体使用方法。


开启绑定功能

在 Spring Cloud Stream 中,需要通过 @EnableBinding 注解来为应用启动消息驱动的功能,该注解在快速入门中己经有了基本的介绍,下面来详细看看它的定义:


@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Configuration
@Import({BindingBeansRegistrar.class, BinderFactoryAutoConfiguration.class})
@EnableIntegration
public @interface EnableBinding {
    Class[] value() default {};
}

从该注解的定义中可以看到,它自身包含了 @Configuration 注解,所以用它注解的类也会成为 Spring 的基本配置类。另外该注解还通过 @Import 加载了 Spring Cloud Stream 运行需要的几个基础配置类。

  • BindingBeansRegistrar :该类是 ImportBeanDefinitionRegistrar 接口的实现,主要是在 Spring 加载 Bean 的时候被调用,用来实现加载更多的 Bean 。由于 BindingBeansRegistrar@EnableBinding 注解的 @Import 所引用,所以在其他配置加载完后,它的实现会被回调来创建其他的 Bean, 而这些 Bean 则从 @EnableBinding 注解的 value 属性定义的类中获取。就如入门实例中定义的 @EnableBinding (Sink.class) ,它在加载用于消息驱动的基础 Bean 之后, 会继续加载 Sink 中定义的具体消息通道绑定。

  • BinderFactoryConfiguration : Binder 工厂的配置,主要用来加载与消息中间件相关的配置信息,比如,它会从应用工程的 META-INF/spring.binders 中 加载针对具体消息中间件相关的配置文件等。


@EnableBinding 注解只有一个唯一的属性: value 。上面己经介绍过,由于该注解 @ImportBindingBeansRegistrar 实现,所以在加载了基础配置内容之后,它会回调来读取 value 中的类,以创建消息通道的绑定。另外,由于 value 是一个 Class 类型的数组,所以可以通过 value 属性一次性指定多个关于消息通道的配置。


绑定消息通道

在 Spring Cloud Steam 中,可以在接口中通过 @Input@Output 注解来定义消息通道,而用于定义绑定消息通道的接口则可以被 @EnableBinding 注解的 value 参数来指定,从而在应用启动的时候实现对定义消息通道的绑定。


bg left w:36em

w:18em


在快速入门的示例中,演示了使用 Sink 接口绑定的消息通道。 Sink 接口是 Spring Cloud Steam 提供的一个默认实现,除此之外还有 SourceProcessor ,可从它们的源码中学习它们的定义方式:


//Sink
public interface Sink {
    String INPUT = "input";

    @Input("input")
    SubscribableChannel input();
}

//Source
public interface Source {
    String OUTPUT = "output";

    @Output("output")
    MessageChannel output();
}

//Processor
public interface Processor extends Source, Sink {
}

从上面的源码中,可以看到, SinkSource 中分别通过 @Input@Output 注解定义了输入通道和输出通道,而 Processor 通过继承 Source 和 Sink 的方式同时定义了一个输入通道和一个输出通道。
bg right fit


另外, @Input@Output 注解都还有一个 value 属性,该属性可以用来设置消息通道的名称,这里 SinkSource 中指定的消息通道名称分别为 inputoutput。如果直接使用这两个注解而没有指定具体的 value 值,将默认使用方法名作为消息通道的名称。


最后,需要注意一点,

  • 当定义输出通道的时候,需要返回 MessageChannel 接口对象,该接口定义了向消息通道发送消息的方法;

  • 而定义输入通道时,需要返回 SubscribableChannel 接口对象,

    • 该接口继承自 MessageChannel 接口,它定义了维护消息通道订阅者的方法。

注入绑定接口

在完成了消息通道绑定的定义之后, Spring Cloud Stream 会为其创建具体的实例,而开发者只需要通过注入的方式来获取这些实例并直接使用即可。举个简单的例子,在快速入门示例中 orderservice 服务己经为 Sink 接口绑定的 input 消息通道实现了具体的消息消费者,下面可以通过注入的方式实现一个消息生成者,向 input 消息通道发送数据。


  1. 创建一个将消息通道"input"作为输出通道的发送消息的接口,具体如下:

    public interface SinkSender {
       @Output(Sink.INPUT)
       MessageChannel output();
    }

  1. orderservice 中 定义的 ReceiveMessage 做一些修改:在@Enablebinding 注解中增加对 SinkSender 接口的指定,使 Spring Cloud Stream 能创建出对应的 Java Bean 的实例。

    @EnableBinding({Sink.class,SinkSender.class})
    public class MessageReceiver {
    
     @StreamListener(Sink.INPUT)
       public void receiveMessage(Message msg){
           System.out.println("order-service接收到的消息是: "+ msg.getPayload());
       }
    
    }

  • 创建 OrderMessageController 控制器类, 通过 @Autowired 注解注入 SinkSender 的实例,并在控制器方法中调用它的发送消息方法。

    @RestController
    @RequestMapping("/message")
    public class OrderMessageController {
    
      @Autowired
      private SinkSender sinkSender;
    
      @RequestMapping("/sinkSender/{msg}")
      public String contextLoads(@PathVariable("msg")String msg){
          boolean send = sinkSender.output().send(MessageBuilder.withPayload(msg).build());
          if (send) {
              return "成功发送消息: " + msg;
          }
          return "消息发送失败: " + msg;
      }
    }
    

  1. 重启 orderservice 服务,在浏览器中访问:http://localhost:8080/message/sinkSender/hello 。如果可以在控制台中找到如下输出内容,表明试验己经成功了,消息被正确地发送回 orderservice 服务的 input 通道中,并被消息消费者输出(等于是自己把消息发送给了自己)。

image-20220804222040951


注入消息通道进行消息发送

由于 Spring Cloud Stream 会根据绑定接口中的 @Input@Output 注解来创建消息通道实例,所以也可以通过直接注入的方式来使用消息通道对象。比如,可以通过下面的示例,注入上面例子中 SinkSender 接口中定义的名为 input 的消息输入通道。


@RestController
@RequestMapping("/message")
@EnableBinding({Sink.class, SinkSender.class})
public class OrderMessageController {
    @Autowired
    private MessageChannel input;

    @RequestMapping("/inputChannel/{msg}")
    public String channelMessage(@PathVariable("msg")String msg){
        boolean send = input.send(MessageBuilder.withPayload(msg).build());
        if (send) {
            return "成功发送消息: " + msg;
        }
        return "消息发送失败: " + msg;
    }

}

上面定义的内容,完成了与之前通过注入绑定接口 SinkSender 方式实现的测试用例相同的操作。因为在通过注入绑定接口实现时, sinkSender.output() 方法实际获得的就是 SinkSender 接口中定义的 MessageChannel 实例,只是在这里直接通过注入的方式来实现了而己。

注入绑定接口 SinkSender这种用法虽然很直接,但是也容易犯错,很多时候在一个微服务应用中可能会创建多个不同名的 MessageChannel 实例,这样通过 @Autowired 注入时,要注意参数命名需要与通道同名才能被正确注入,或者也可以使@Qualifier 注解来特别指定具体实例的名称,该名称需要与定义 MessageChannel 的 @Output 中的 value 参数一致,这样才能被正确注入。


比如下面的例子,在一个接口中定义了两个输出通道,分别命名为 output1output2, 当要使用 通道output1 的时候,可以通过
@Qualifier( "Output1") 来指定这个具体的实例来注入使用。


  • MySource 接口

    //定义通道
    public interface MySource {
      @Output("output1")
      MessageChannel output1();
    
      @Output("output2")
      MessageChannel output2();
    }
  • OutputSender 类

    //注入
    @Component
    public class OutputSender {
      @Autowired
      @Qualifier("output1")
      MessageChannel output;
    ...
    }
    

提示:
@EnableBinding 注解也可以只写在启动类上,Binding 组件及其通道只需要注册一次


注解 @StreamListener 详解

通过入门示例,对于 @StreamListener 注解,应该都己经有了一些基本的认识,通过该注解修饰的方法, Spring Cloud Steam 会将其注册为输入消息通道的监听器。当输入 消息通道中有消息到达的时候,会立即触发该注解修饰方法的处理逻辑对消息进行消费。
@SteamListener 注解都实现了对输入消息通道的监听,并且内置了一系列的消息转换功能,这使得基于 @SteamListener 注解实现的消息处理模型更为简单。


消息转换

大部分情况下,通过消息来对接服务或系统时,消息生产者都会以结构化的字符串形式来发送,比如 JSON 或 XML 。当消息到达的时候,输入通道的监听器需要对该字符串做一定的转化,将 JSON 或 XML 转换成具体的对象,然后再做后续的处理。


可以这样为通道设置绑定消息的类型, 这样传输消息时会对格式进行校验如:

  • cloud.stream.bindings.input.content-type=application/json
  • cloud.stream.bindings.output.content-type=application/json

假设,需要传输一个 User 对象,该对象有 username 和 address 两个字段,这时,如果使用 @SteamListener 注解的话,代码实现将变得非常简单优雅:

@EnableBinding(MySource.class)
public class MessageReceiver {

    /**
     * 监听output2通道,完成自动类型转换
     * @param user
     */
    @StreamListener("output2")
    public void receive(User user) {
        System.out.println("Received: " + user);
    }
}

我们可以在控制器中增加一个方法,使用我们刚刚定义的 output2 通道进行信息发送:

@RestController
@RequestMapping("/message")
public class OrderMessageController {

    @Autowired
    private MySource mySource;

    @RequestMapping("/userInfo/")
    public String output2ChannelMessage(User user){
        boolean send = mySource.output2().send(MessageBuilder.withPayload(JSONUtil.toJsonStr(user)).build());
        if (send) {
            return "成功发送消息: " + JSONUtil.toJsonStr(user);
        }
        return "消息发送失败: " + JSONUtil.toJsonStr(user);
    }
}

我们可以通过之前介绍的小插件: FastRequest,模拟一个 User 信息,然后发送:

image-20220804235921454


控制台上将会显示:

image-20220805000138729


消息反馈

很多时候在处理完输入消息之后,需要反馈一个消息给对方,这时候可以通过 @SendTo 注解来指定返回内容的输出通道。我们对 orderservice 中消息接收类 MessageReceiver 进行修改,在其中一个类上添加@SendTo 注解:

@EnableBinding({Processor.class, SinkSender.class,MySource.class})
public class MessageReceiver {

    @StreamListener(Sink.INPUT)
    @SendTo(Processor.OUTPUT)
    public String receiveMessage(Message msg){
        System.out.println("order-service接收到的消息是: "+msg.getPayload());
        return msg.getPayload();
    }
}

在 userservice 中同样创建一个消息接收类,用来监听来自 orderservice 的消息回执:

@Component
public class UserMessageReceiver {

    @StreamListener(Processor.INPUT)
    public void receiveReturnMessage(Message msg) {
        System.out.println("receiveReturnMessage 接收到的消息回执是: " + msg.getPayload());
    }
}

userservice 是 orderservice 应用中 input 通道的生产者以及 output 通道的消费者。可以在配置文件中将两个应用的通道绑定反向地做一些配置。因为对于 userservice 来说,它的 input 绑定通道实际上是对 output 主题的消费者,而 output 绑定通道实际上是对 input 主题的生产者,


做如下具体配置。指定通道的 destination 来实现两个应用的消息交互:

orderservice 的配置

spring:
  cloud:
    stream:
      binders: #在此处配置要绑定的rabbitmq的服务信息
        defaultRabbit: #表示定义的名称,用于与binding整合
          type: rabbit #消息组件类型是RabbitMQ
          environment: #rabbitmq的相关的环境配置
            spring:
              rabbitmq:
                host: localhost
                port: 5672
                username: guest
                password: guest
      bindings: #服务的整合处理
        input: #这个名字是一个通道的名称
          destination: niitExchange #表示要使用的交换机的名称定义
          content-type: application/json #设置消息类型
          binder: defaultRabbit #设置要绑定的消息服务的具体设置
          group: niit
        output:
          destination: commonExchange
          content-type: application/json
          binder: defaultRabbit
          group: niit

userservice 的配置

spring:
  cloud:
    stream:
      binders:  #在此处配置要绑定的rabbitmq的服务信息
        defaultRabbit:  #表示定义的名称,用于与binding整合
          type: rabbit  #消息组件类型是RabbitMQ
          environment:  #rabbitmq的相关的环境配置
            spring:
              rabbitmq:
                host: localhost
                port: 5672
                username: guest
                password: guest
      bindings: #服务的整合处理
        output:
          destination: niitExchange
          content-type: application/json
          binder: defaultRabbit
          group: niit
        input:
          destination: commonExchange
          content-type: application/json
          binder: defaultRabbit
          group: niit

当 receiveMessage 方法处理消息之后, return 的值将会最终被 UserMessageReceiver 类的 receiveReturnMessage 方法消费。


消费组与消息分区

在“核心概念” 一节中,对消费组和消息分区己经进行了基本的介绍,在这里来详细介绍一下这两个概念的使用方法。


消费组

通常每个服务都不会以单节点的方式运行在生产环境中,当同一个服务启动多个实例的时候,这些实例会绑定到同一个消息通道的目标主题上。默认情况下,当生产者发出一 条消息到绑定通道上,这条消息会产生多个副本被每个消费者实例接收和处理。但是在有些业务场景之下,希望生产者产生的消息只被其中一个实例消费,这个时候就需要为这些消费者设置消费组来实现这样的功能。实现的方式非常简单,只需在服务消费者端设置 spring.cloud.stream.bindings.input.group 属性即可。在上面的消息回执例子中,我们正是基于此种原因,所以设置了 group 的属性。


分别运行 orderservice 和 userservice,其中 userservice 启动多个实例。以消息回执案例进行测试,可以发现,消息的回执会被启动的多个 userservice 实例以轮询的方式进行接收和输出。

image-20220805172532126


消息分区

通过消费组的设置,虽然己经能够在多实例环境下,保证同一消息只被一个消费者实例进行接收和处理,但是,对于一些特殊场景,除了要保证单一实例消费之外,还希望那些具备相同特征的消息都能够被同一个实例进行消费。这时候我们就需要对消息进行分区处理。


在 Spring Cloud Stream 中实现消息分区非常简单,对消费组示例做一些配置修改就能实现,具体如下所示。


  1. 以 orderservice 作为消息生产者,修改其配置文件:

    spring:
     cloud:
       stream:
         binders: #在此处配置要绑定的rabbitmq的服务信息
           defaultRabbit: #表示定义的名称,用于与binding整合
             type: rabbit #消息组件类型是RabbitMQ
             environment: #rabbitmq的相关的环境配置
               spring:
                 rabbitmq:
                   host: localhost
                   port: 5672
                   username: guest
                   password: guest
         bindings: #服务的整合处理
           input: #这个名字是一个通道的名称
             destination: niitExchange #表示要使用的交换机的名称定义
             content-type: application/json #设置消息类型
             binder: defaultRabbit #设置要绑定的消息服务的具体设置
             group: niit
           output:
             destination: commonExchange
             content-type: application/json
             binder: defaultRabbit
             group: niit
             producer:
               partitionKeyExpression: payload
               partitionCount: 3

从上面的配置中,我们可以看到增加了下面这两个参数。

  • spring.cloud.stream.bindings.output.producer.partitionKeyExpression :通过该参数指定了分区键的表达式规则,可以根据实际的输出消息规则配置 SpEL 来生成合适的分区键。
    • payload : 根据类型自动分区
    • header: 设定消息头 ,根据消息头手动设置分区, 如 partitionKeyExpression: headers['partitionKey']表示根据消息头中的 partitionKey 的值来分区
  • spring.cloud.stream.bindings.output.producer.partitionCount :该参数指定了消息分区的数量。我们准备启动三个消费者实例,所以设置为 3 个分区。

  1. 以 userservice 作为消息消费者,修改其配置文件:

    spring:
     cloud:
       stream:
         binders:  #在此处配置要绑定的rabbitmq的服务信息
           defaultRabbit:  #表示定义的名称,用于与binding整合
             type: rabbit  #消息组件类型是RabbitMQ
             environment:  #rabbitmq的相关的环境配置
               spring:
                 rabbitmq:
                   host: localhost
                   port: 5672
                   username: guest
                   password: guest
         bindings: #服务的整合处理
         output:
             destination: niitExchange
             content-type: application/json
             binder: defaultRabbit
             group: niit
           input:
             destination: commonExchange
             content-type: application/json
             binder: defaultRabbit
             group: niit
             partitioned: true
         instance-count: 3
         instance-index: 0

从上面的配置中,可以看到增加了下面这三个参数。

  • spring.cloud.stream.bindings.input.consumer.partitioned :通过该参数开启消费者分区功能。
  • spring.cloud.stream.instanceCount :该参数指定了当前消费者的总实例数量。
  • spring.cloud.stream.instanceIndex :该参数设置当前实例的索引号,从 0 开始 。试验的时候需要启动多个实例,可以通过运行参数来为不同实例设置不同的索引值。

到这里消息分区配置就完成了,可以再次启动这两个应用,同时启动多个消费者。但需要注意的是,要为消费者指定不同的实例索引号,这样当同一个消息被发送给消费组时,可以发现只有一个消费实例在接收和处理这些相同的消息。


消息类型

Spring Cloud Stream 为了让开发者能够在消息中声明它的内容类型,在输出消息中定义了一个默认的头信息:contentType 。对于那些不直接支持头信息的消息中间件, Spring Cloud Stream 提供了自己的实现机制,它会在消息发出前自动将消息包装进它自定义的消息封装格式中,并加入头信息。而对于那些自身就支持头信息的消息中间件, Spring Cloud Stream 构建的服务可以接收并处理来自非 Spring Cloud Stream 构建但包含符合规范头信息的应用程序发出的消息。


Spring Cloud Stream 允许使用 spring.cloud.stream.bindngs.<channelName>.content-type 属性以声明式的配置方式为绑定的输入和输出通道设置消息内容的类型。 此外,原生的消息类型转换器依然可以轻松地用于我们的应用程序。


目前, Spring Cloud Stream 中自带支持了以下几种常用的消息类型转换。

  • JSON 与 POJO 的互相转换。

  • JSON 与 org.springframework.tuple.Tuple 的互相转换。

  • Object 与 byte[] 的互相转换。为了实现远程传输序列化的原始字节,应用程序需要发送 byte 类型的数据,或是通过实现 Java 的序列化接口来转换为字节( Object 对象必须可序列化)。

  • String 与 byte[] 的互相转换。

  • Object 向纯文本的转换: Object 需要实现 toString()方法。


上面所指的 JSON 类型可以表现为一个 byte 类型的数组,也可以是一个包含有效 JSON 内容的字符串。另外, Object 对象可以由 JSON 、 byte 数组或者字符串转换而来,但是在转换为 JSON 的时候总是以字符串的形式返回。


MIME 类型

在 Spring Cloud Stream 中定义的 content-type 属性采用了 Media Type ,即 Internet Media Type (互联网媒体类型),也被称为 MIME 类型,常见的有 application/jsontext/plain;charset=UTF-8, 相信接触过 HTTP 的工程师们对这些类型都不会感到陌生。


MIME 类型对于标示如何转换为 String 或 byte [] 非常有用。并且,还可以使用 MIME 类型格式来表示 Java 类型,只需要使用带有类型参数的一般类型: application/x-java-object

比如,我们可以使用 application/x-java-object;type=java.util.Map 来表示传输的是一个 java.util.Map 对象,或是使用 application/x-java-object;type=com.niit.pojo.User 来表示传输的是一个 com.niit.pojo.User 对象;除此之外,更重要的是,它还提供了自定义的 MIME 类型, 比如通过 application/x-spring-tuple 来指定 Spring 的 Tuple 类型。


在 Spring Cloud Stream 中默认提供了一些可以开箱即用的类型转换器,具体如下表所示。


h:18em


消息类型的转换行为只会在需要进行转换时才被执行,比如,当服务模块产生了一个头信息为 application/json 的 XML 字符串消息, Spring Cloud Stream 是不会将该 XML 字符串转换为 JSON 的,这是因为该模块的输出内容己经是一个字符串类型了,所以它并不会将其做进一步的转换。


另外需要注意的是, Spring Cloud Stream 虽然同时支持输入通道和输出通道的消息类 型转换,但还是推荐开发者尽量在输出通道中做消息转换。因为对于输入通道的消费者来说,当目标是一个 POJO 的时候,使用 @StreamListener 注解是能够支持自动对其进行转换的。


Spring Cloud Stream 除了提供上面这些开箱即用的转换器之外,还支持开发者自定义的消息转换器。这使得用户可以使用任意格式(包括二进制)的数据进行发送和接收,并且将这些数据与特定的 contentType 相关联。在应用启用的时候,Spring Cloud Stream 会将所有 org.springframework.messaging.converter.MessageConverter 接口实现的自定义转换器以及默认实现的那些转换器都加载到消息转换工厂中,以提供给消息处理时使用。


绑定器详解

在“核心概念”一节中,己经简单介绍过 Binder 绑定器的基本概念和作用:它是定义在应用程序与消息中间件之间的抽象层,用来屏蔽消息中间件对应用的复杂性,并提供简单而统一的操作接口给应用程序使用。在本节中,将详细介绍绑定器背后的细节和行为。


绑定器 SPI (了解)

绑定器 SPI 涵盖了一套可插拔的用于连接外部中间件的实现机制,其中包含了许多接口、开箱即用的实现类以及发现策略等内容。其中,最为关键的就是 Binder 接口,它是用来将输入和输出连接到外部中间件的抽象:

package org.springframework.cloud.stream.binder;

public interface Binder {
    Binding bindConsumer(String name, String group, T inboundBindTarget, C consumerProperties);

    Binding bindProducer(String name, T outboundBindTarget, P producerProperties);
}

当应用程序对输入和输出通道进行绑定的时候,实际上就是通过该接口的实现来完成的。

  • 向消息通道发送数据的生产者调用 bindProducer 方法来绑定输出通道时,第一个参数代表了发往消息中间件的目标名称,第二个参数代表了发送消息的本地通道实例,第三个参数是用来创建通道时使用的属性配置 ( 比如分区键的表达式等 ) 。

  • 从消息通道接收数据的消费者调用 bindConsumer 方法来绑定输入通道时,第一个参数代表了接收消息中间件的目标名称,第二个参数代表了消费组的名称 ( 如果多个消费者实例使用相同的组名,则消息将对这些消费者实例实现负载均衡,每个生产者发出的消息只会被组内一个消费者实例接收和处理 ) ,第三个参数代表了接收消息的本地通道实例,第四个参数是用来创建通道时使用的属性配置。


另外,从 Binder 的定义中,还可以知道 Binder 是一个参数化并且可扩展的接口。

  • 对于输入与输出的绑定类型,在 1.0 版本中仅支持 MessageChannel ,但是在接口中通过泛型定义,所以在未来可以对其进行扩展。
  • 对于属性配置也提供了可扩展的定义,可以为特定的 Binder 以类型安全的方式来补充一些特有的属性。

一个典型的 Binder 绑定器实现一般包含以下内容。

  • 一个实现 Binder 接口的类。
  • 一个 Spring 配置加载类,用来创建连接消息中间件的基础结构使用的实例。
  • 一个或多个能够在 classpath 下的 META-INF/spring.binders 路径找到的绑定器定义文件。比如用户可以在 spring-cloud-starter-stream-rabbit 中找到该文件,该文件中存储了当前绑定器要使用的自动化配置类的路径:

image-20220805203723402


绑定器的自动化配置

Spring Cloud Stream 通过绑定器 SPI 的实现将应用程序逻辑上的输入输出通道连接到物理上的消息中间件。消息中间件之间通常都会有或多或少的差异性,所以为了适配不同的消息中间件,需要为它们实现各自独有的绑定器。目前, Spring Cloud Stream 中默认实现了对 RabbitMQ 、 Kafka 的绑定器,在上面的示例中引入的 spring-cloud- starter- stream-rabbit 依赖中就包含了 RabbitMQ 的绑定器 spring-cloud-stream-binder- rabbit 。


默认情况下, Spring Cloud Stream 也遵循 Spring Boot 自动化配置的特性。如果在 classpath 中能够找到单个绑定器的实现,那么 Spring Cloud Stream 会自动加载它。而在 classpath 中引入绑定器的方法也非常简单,只需要在 pom.xml 中增加对应消息中间件的绑定器依赖即可,比如增加 RabbitMQ 的绑定器依赖:


    org.springframework.cloud
    spring-cloud-stream-binder-rabbit

如果使用 Kafka, 则引入:


    org.springframework.cloud
    spring-cloud-stream-binder-kafka

多绑定器配置

当应用程序的 classpath 下存在多个绑定器时, Spring Cloud Stream 在为消息通道做绑定操作时,无法判断应该使用哪个具体的绑定器,所以需要为每个输入或输出通道指定具体的绑定器。在一个应用程序中使用多个绑定器时,往往其中一个绑定器会是主要使用的,而第二个可能是为了适应一些特殊要求(比如性能等原因)。可以先通过设置默认绑定器 来为大部分的通道设置绑定器。比如,使用 RabbitMQ 设置默认绑定器:

spring:
  cloud:
    stream:
      defaultBinder: rabbit

在设置了默认绑定器之后,再为其他一些少数的消息通道单独设置绑定器,比如:

spring:
  cloud:
    stream:
      bindings:
        input:
          binder: kafka

需要注意的是,上面设置参数时用来指定具体绑定器的值并不是消息中间件的名称,而是在每个绑定器实现的

META-INF/spring.binders 文件中定义的标识(一个绑定器实现的标识可以定义多个,以逗号分隔),所以上面配置的 rabbit 和 kafka 分别来自于各自的配置定义,它们的具体内容如下所示:

rabbit:\
org.springframework.cloud.stream.binder.rabbit.config.RabbitServiceAutoConfiguration;
kafka:\
org.springframework.cloud.stream.binder.kafka.config.KafkaBinderConfiguration

另外,当需要在一个应用程序中使用同一类型不同环境的绑定器时,也可以通过配置轻松实现通道绑定。比如,当需要连接两个不同的 RabbitMQ 实例的时候,可以参照如下配置(application.properties 格式):


spring.cloud.stream.bindings.input.binder=rabbit1
spring.cloud.stream.bindings.output.binder=rabbit2

spring.cloud.stream.binders.rabbitl.type=rabbit
spring.cloud.stream.binders.rabbitl.environment.spring.rabbitmq. hos t=l92.168.0.101
spring.cloud.stream.binders.rabbitl.environment.spring.rabbitmq.port=5672
spring.cloud.stream.binders.rabbitl.environment.spring.rabbitmq.username=springcloud
spring.cloud.stream.binders.rabbitl.environment.spring.rabbitmq.password=123456

spring.cloud.stream.binders.rabbit2.type=rabbit
spring.cloud.stream.binders.rabbit2.environment.spring.rabbitmq-host=l92.168.0,102
spring.cloud.stream.binders.rabbit2.environment.spring.rabbitmq.port=5672
spring.cloud.stream.binders.rabbit2.environment.spring.rabbitmq.username=springcloud
spring.cloud.stream.binders.rabbit2.environment.spring.rabbitmq.password=123456

从上面的配置中,可以看到对于输入输出通道指定的绑定器采用了显式别名的配置方式,其中 input 通道的绑定指定了名为 rabbit1 的配置,而 output 通道的绑定指定了名为 rabbit2 的配置。当采用显式配置方式时会自动禁用默认的绑定器配置,所以当定义了显式配置别名后,对于这些绑定器的配置需要通过 spring.cloud.stream.binders.< configurationName> 属性来进行设置。


对于绑定器的配置主要有下面 4 个参数。

  • Spring.cloud.stream.binders.<configurationName>.type 指定了绑定器的类型,可以是 rabbit 、 kafak 或者其他自定义绑定器的标识名,绑定器标识名的定义位于绑定器的 META-INF/spring.binders 文件中。
  • Spring.cloud.stream.binders.<configurationName>.environment 参数可以直接用来设置各绑定器的属性,默认为空。
  • spring.cloud.stream.binders.<configurationName>.inheritEnvironment 参数用来配置当前绑定器是否继承应用程序自身的环境配置,默认为 true 。
  • spring.cloud.stream.binders.<configurationName>.defaultCandidate 参数用来设置当前绑定器配置是否被视为默认绑定器的候选项,默认为 true 。 当需要让当前配置不影响默认配置时,可以将该属性设置为 false

RabbitMQ 与 Kafka 绑定器

在之前的章节中多次提到, Spring Cloud Stream 自身就提供了对 RabbitMQ 和 Kafka 的绑定器实现。由于 RabbitMQ 和 Kafka 自身的实现结构有所不同,理解绑定器实现与消息中间件自有概念之间的对应关系,对于正确使用绑定器和消息中间件会有非常大的帮助。下面就来分别说说 RabbitMQ 与 Kafka 的绑定器是如何使用消息中间件中不同概念来实现消息的生产与消费的。


  • RabbitMQ 绑定器 : 在 RabbitMQ 中,通过 Exchange 交换器来实现 Spring Cloud Stream 的主题概念,所以消息通道的输入输出目标映射了一个具体的 Exchange 交换器。而对于每个消费组,则会为对应的 Exchange 交换器绑定一个 Queue 队列进行消息收发。
  • Kafka 绑定器:由于 Kafka 自身就有 Topic 概念,所以 Spring Cloud Stream 的主题直接釆用了 Kafka 的 Topic 主题概念,每个消费组的通道目标都会直接连接 Kaflca 的主题进行消息收发。

配置详解

在 Spring Cloud Stream 中对绑定通道和绑定器提供了通用的属性配置项,一些绑定器还允许使用附加属性来对消息中间件的一些独有特性进行配置。这些属性的配置可以通过 Spring Boot 支持的任何配置方式来进行,包括使用环境变量、 YAML 或者 properties 配置文件等。


基础配置

下表是 Spring Cloud Stream 应用级别的通用基础属性,这些属性都以 spring.cloud.stream.为前缀。
参数名 说明 默认值
instanceCount 应用程序部署的实例数量,当使用 Kafka 的时候需要设置分区 1
instanceIndex 应用程序实例的索引,该值从 0 开始,最大值设置为-1。当使用分区和 Kafka 的时候使用
dynamicDestinations 动态绑定的目标列表,该列表默认为空,当设置了具体列表之后,只有列表中的目标才能被发现
defaultBinder 默认绑定器配置,在应用程序中有多个绑定器时使用
overrideCloudConnectors 该属性只适用于激活 cloud 配置并且提供了 Spring Cloud Connectors 的应用。当使用默认属性 false 时,绑定器会自动检测合适的服务来绑定(比如,在 Cloud Foundry 中绑定的 RabbitMQ 服务)。当设置为 true 时,绑定器将忽略绑定的服务,而是依赖应用程序中的设置属性来进行绑定和连接

绑定通道配置

对于绑定通道的属性配置,在之前的示例中已经有过一些介绍,这些配置可以在属性文件中通过 spring.cloud.stream.bindings.<channelName>.<property>=<value> 格式的参数来进行设置。其中 <channelName> 代表在绑定接口中定义的通道名称,比如, Sink 中的 input 、Source 中的 output。

由于绑定通道分为输入通道和输出通道,所以在绑定通道的配置中包含了三类面向不同通道类型的配置:通用配置、消费者配置、生产者配置。在下面介绍各具体配置属性时将省略 spring.cloud.stream.bindings.<channelName>. 前缀,但在实际使用的时候记得使用完整的参数名称进行配置。


通用配置
对于绑定通道的通用配置,它们既适用于输入通道,也适用于输出通道,它们通过 spring.cloud.stream.bindings.. 前缀来进行设置,具体可配置的属性如下表所示。
参数名 说明 默认值
destination 该参数用来配置消息通道绑定在消息中间件中的目标名称,比如 RabbitMQ 的 Exchange 或 Kafka 的 Topic。如果配置的绑定通道是一个消费者(输入通道),那么它可以绑定多个目标,这些目标名称通过逗号分隔。如果没有设置该属性,将使用通道名
group 该参数用来设置绑定通道的消费组,该参数主要作用于输入通道,以保证同一消息组中的消息只会有一个消费实例接收和处理 null
contentType 该参数用来设置绑定通道的消息类型 null
binder 当存在多个绑定器时使用该参数来指定当前通道使用哪个具体的绑定器 null

消费者配置
下面这些配置仅对输入通道的绑定有效,它们以 spring.cloud.stream.bindings..consumer. 格式作为前缀。
参数名 说明 默认值
concurrency 输入通道消费者的并发数 1
partitioned 来自消息生产者的数据是否采用了分区 false
headerMode 当设置为 raw 的时候将禁用对消息头的解析,该属性只有在使用不支持消息头功能的中间件时有效,因为 Spring Cloud Stream 默认会解析嵌入的头部信息 embeddedheaders
maxAttempts 对输入通道消息处理的最大重试次数 3
backOffInitialInterval 重试消息处理的初始间隔时间 1000
backOffMaxInterval 重试消息处理的最大间隔时间 10000
backOffMultiplier 重试消息处理时间间隔的递增乘数 2.0

生产者配置
参数名 说明 默认值
partitionKeyExpression 该参数用来配置输出通道数据分区键的 SpEl 表达式,当设置该属性之后,将对当前绑定通道的输出数据进行分区处理。同时,partitionCount 的参数必须大于 1 才能生效。该参数与 partitionKeyExtractorClass 参数互斥,不能同时设置 null
partitionKeyExtractorClass 该参数用来配置分区键提取策略接口 PartitionKeyExtractorStrategy 的实现。当设置该属性之后,将对当前绑定通道的输出数据进行分区处理,同时,partitionCount 的参数必须大于 1 才能生效。该参数与 partitionKeyExpression 参数互斥,不能同时设置 null

参数名 说明 默认值
partitionSelectorClass 该参数用来指定分区选择器接口 PartitionSelectorStrategy 的实现。它与 partitionSelectorExpression 参数互斥,不能同时设置。如果两者都不设置,那么分区选择计算规则为 hashCode(key)%partitionCount,这里的 key 根据 partitionKeyExpression 或 partitionKeyExtractorClass 的配置计算得到 null
partitionSelectorExpression 该参数用来配置自定义分区选择器的 SpEL 表达式。它与 partitionSelectorClass 参数互斥,不能同时设置。如果两者都不设置,那么分区选择计算规则为 hashCode(key)%partitionCount,这里的 key 根据 partitionKeyExpression 或 partitionKeyExtractorClass 的配置计算得到 null
partitionCount 当分区功能开启时,使用该参数来配置消息数据的分区数。如果消息生产者已经配置了分区键的生成策略,那么它的值必须大于 1 1
headerMode 当设置为 raw 的时候将禁用对消息头的解析,该属性只有在使用不支持消息头功能的中间件时有效,因为 Spring Cloud Stream 默认会解析嵌入的头部信息 embeddedheaders

绑定器的配置属性

由于 Spring Cloud Stream 目前只实现了对 RabbitMQ 和 Kafka 的绑定器,所以对于绑定器的属性配置主要是针对这两个中间件。同时因为这两个中间件自身结构的不同,所以会有不同的附加属性来配置各自的一些特殊功能和基础设施。


RabbitMQ 配置

RabbitMQ 绑定器的配置同绑定通道的配置一样,分为三种不同类型:通用配置、消费者配置以及生产者配置。


通用配置

由于 RabbitMQ 绑定器默认使用了 Spring Boot 的 ConnectionFactory, 所以 RabbitMQ 绑定器支持在 Spring Boot 中的配置选项,它们以 spring.rabbitmq. 为前缀。 在之前的示例中,使用了这些配置来指定具体的 RabbitMQ 地址、端口、用户信息等,更多配置可见 Spring Boot 文档中对 RabbitMQ 支持的章节内容,或是通过 spring-boot-starter-amqp 模块中 RabbitProperties 类的源码来查看。


在 Spring Cloud Stream 对 RabbitMQ 实现的绑定器中主要有下面几个属性,它们都以 spring.cloud.stream.rabbit.binder. 为前缀。这些属性可以在 org.springframework.cloud.stream.binder.rabbit.config.RabbitBinderConfigurationProperties 中找到它们。


参数名 说明 默认值
adminAddresses 该参数用来配置 RabbitMQ 管理插件的 URL,当需要配置多个时用逗号分隔。该参数只有在 nodes 参数包含多个时使用,并且这里配置的内容必须在 spring.rabbitmq.addresses 中存在
nodes 该参数用来配置 RabbitMQ 的节点名称,当需要配置多个时用逗号分隔。在配置多个的情况下,可以用来定位队列所在的服务器的地址。这里配置的内容必须在 spring.rabbitmq.addresses 中存在
compressionLevel 绑定通道的压缩级别,它的具体可选值及含义可见 java.util.zip.Deflater 中的定义 1

消费者配置

下面这些配置仅对 RabbitMQ 输入通道的绑定有效,它们以 spring.cloud.stream.rabbit.bindings.<channelName>.consumer. 格式作为前缀。


参数名 说明 默认值
acknowledgeMode 用来设置消息的确认模式,可选配置包含:NONE,MANUAL,AUTO AUTO
autoBindDlq 用来设置是否自动声明 DLQ(Dead-Letter-Queue),并绑定到 DLX(Dead-Letter-Exchange)上 false
durableSubscription 用来设置订阅是否被持久化,该参数仅在 group 被设置的时候有效 true
maxConcurrency 用来设置消费者的最大并发数 1
prefetch 用来设置预取数量,它表示在一次会话中从消息中间件获取的消息数量,该值越大消息处理越快,但是会导致非顺序处理的风险 1
prefix 用来设置统一的目标和队列名称前缀
recoveryInterval 用来设置恢复连接的尝试时间间隔,以毫秒为单位 5000

参数名 说明 默认值
requestRejected 用来设置消息传递失败时候重传 true
requestHeaderPatterns 用来设置需要被传递的请求头信息 [STANDARD_REQUEST_HEADERS,'*']
replyHeaderPatterns 用来设置需要被传递的响应头信息 [STANDARD_REPLY_HEADERS,'*']
republishToDlq 默认情况下,消息在重试也失败之后会被拒绝。如果 DLQ 被配置的时候,RabbitMQ 会将失败的消息路由到 DLQ 中。如果该参数设置为 true,总线会将失败的消息附加一些头信息(包括异常消息,引起失败的跟踪堆栈)之后重新发布到 DLQ 中
transacted 用来设置是否启用 channel-transacted,即是否在消息中使用事务 false
txSize 用来设置 transaction-size 的数量,当 acknowledgeMode 被设置为 AUTO 时,容器会在处理 txSize 个消息之后才开始英达 1

生产者配置
下面这些配置仅对 RabbitMQ 输出通道的绑定有效,它们以 spring.cloud.stream.rabbit.bindings..producer. 格式作为前缀。
参数名 说明 默认值
autoBindQlp 用来设置是否自动声明 DLQ(Dead-Letter-Queue),并绑定到 DLX(Dead-Letter-Exchange)上 false
batchingEnable 是否启用消息批处理 false
batchSize 当批处理开启时,用来设置缓存的批处理消息数量 100
batchBufferLimit 批处理缓存限制 10000
batchTimeout 批处理超时时间 5000
compress 消息发送时是否启用压缩 false
deliveryMode 消息发送模式 PERSISTENT
prefix 用来设置统一的目标前缀
requestHeaderPatterns 用来设置需要被传递的请求头信息 [STANDARD_REQUEST_HEADERS,'*']
replyHeaderPatterns 用来设置需要被传递的响应头信息 [STANDARD_REPLY_HEADERS,'*']

Kafka 配置

Kafka 绑定器的配置在类别上与 RabbitMQ —样,分为三种不同类型:通用配置、消费者配置以及生产者配置。但是由于 RabbitMQ 与 Kafka 自身有一些差异,所以它们的配置也不一样。


通用配置
Spring Cloud Stream 实现的 Kafka 绑定器包含下面这些通用配置,它们都以 spring.cloud.stream.kafka.binder. 为前缀。
参数名 说明 默认值
brokers Kafka 绑定器链接的消息中间件列表。需要配置多个时用逗号分隔,每个地址可以是单独的 host,也可以是 host:port 的形式 localhost
defaultBrokerPort 用来设置默认的消息中间件端口号。当 brokers 中的配置地址没有包含端口信息时,将使用该参数配置的默认端口进行连接 9092
zkNodes kafka 绑定器使用的 Zookeeper 节点列表。需要配置多个时用逗号分隔,每个地址可以是单独的 host,也可以是 host:port 的形式 localhost
defaultZkPort 用来设置默认的 Zookeeper 端口号。当 zkNodes 中的配置地址没有包含端口信息时,将使用该参数配置的默认端口进行连接 2181
headers 用来设置会被传输的自定义头信息
offsetUpdateTimeWindow 用来设置 offset 的更新频率,以毫秒为单位,如果设置为 0 则忽略 10000
offsetUpdateCount 用来设置 offset 以次数表示的更新频率,如果为 0 则忽略,该参数与 offsetUpdateTimeWindow 互斥 0

参数名 说明 默认值
minPartitionCount 该参数仅在设置了 autoCreateTopics 和 autoAdd-Partitions 时生效,用来设置该绑定器所使用主题的全局分区最小数量。如果当生产者的 partitionCount 参数或 instanceCount*concurrency 的值大于该参数配置时,该参数值将被覆盖 1
requiredAcks 用来设置确认消息的数量 1
replicationFactor 当 autoCreateTopics 参数为 true 时候,用来配置自动创建主题的副本数量 1
autoCreateTopics 该参数默认为 true,绑定器会自动地创建新主题。如果设置为 false,那么绑定器使用已经配置的主题,但是在这种情况下,如果需要使用的主题不存在,绑定器会启动失败 true
autoAddPartitions 该参数默认为 false,绑定器会根据已经配置的主题分区来实现,如果目标主题的分区数小于预期值,那么绑定器会启动失败。如果该参数设置为 true,绑定器将在需要的时候自动创建新的分区 false
socketBufferSize 该参数用来设置 Kafka 的 Socket 缓存大小 2097152

消费者配置
下面这些配置仅对 Kafka 输入通道的绑定有效,它们以 spring.Cloud.Stream.kafka.bindings..consumer. 格式作为前缀。
参数名 说明 默认值
bufferSize kafka 批量发送前的缓存数据上限,以字节为单位 16384
sync 该参数用来设置 Kafka 消息生产者的发送模式,默认为 false,即采用 async 配置,允许批量发送数据。当设置为 true 时,将采用 sync 配置,消息将不会被批量发送,而是一条一条地发送 false
batchTimeout 消息生产者批量发送时,为了积累更多发送数据而设置的等待时间。通常情况下,生产者基本不会等待,而是直接发送所有在前一批次发送时积累的消息数据。当我们设置一个非 0 值时,可以以延迟为代价来增加系统的吞吐量 0

活动 9.1

Spring Cloud Stream 的使用


练习问题


  1. 说明以下语句是正确还是错误。
    通过定义绑定器作为中间层,完美地实现了应用程序与消息中间件细节之间的隔离 。
    a. 正确
    b. 错误

    答案

    a 正确


  1. Spring Cloud Stream 中的消息通信方式遵循了什么模式?
    a. 发布-订阅
    b. 发布
    c. 订阅
    d. 订阅-发布

    答案

    a 正确


  1. 在 Spring Cloud Stream 中,需要通什么注解来为应用启动消息驱动的功能?
    a. @EnaBinding
    b. @EnableBinding
    c. @Enableding
    d. @EnableBind

    答案

    b 正确


  1. 应用 Spring Cloud Stream 是基于什么构建起的
    a. Spring Integration
    b. Integration
    c. Spring
    d. Spring Integrat

    答案

    a 正确


小结


在本章中,您学习了:

  • 本地配置安装 RabbitMQ
  • Spring Cloud Stream 的核心概论
    • 绑定器
    • 发布-订阅模式
    • 消费组
  • 对于一些核心注解的使用

    • 开启绑定功能

    • 绑定消息通道

    • 消息生产与消费

    • 消费组与消息分区

    • 消息类型


  • 绑定器背后的细节和行为

    • 绑定器 SPI
    • 自动化配置
    • RabbitMQ 与 Kafka 绑定器
  • 了解 Spring Cloud Stream 中相应的配置

    • 基础配置
    • 绑定通道配置
    • 绑定器配置

谢谢

Views: 16

SpringCloud与微服务-第8章-Nacos分布式配置中心

在微服务架构中,当系统从一个单体应用,被拆分成分布式系统上一个个服务节点后,配置文件也必须跟着迁移(分割)。在系统架构中,配置中心是整个微服务基础架构体系中的一个组件,它的功能看上去并不起眼,无非就是配置的管理和存取,但它是整个微服务架构中不可或缺的一环。


应用程序在启动和运行的时候往往需要读取一些配置信息,配置基本上伴随着应用程序的整个生命周期,应用在启动时通过读取配置来初始化,在运行时根据配置调整行为。同一份程序在不同的环境(开发、测试、生产)、不同的集群(如不同的数据中心)经常需有不同的配置,所以需要有完善的环境、集群配置管理。


Spring Cloud Alibaba Nacos 的一大优势是整合了注册中心、配置中心功能,部署和操作更加直观简单,它简化了架构复杂度,并减轻运维及部署工作。在之前的章节中,我们已经使用 Nacos 作为注册中心,本章节我们将详细介绍 Nacos 配置中心的功能。


目标

在本章中,您将学习:

  • Nacos 配置管理
  • 配置拉取
  • 配置热更新
  • 多环境配置共享
  • Nacos 集群搭建

快速入门

当微服务部署的实例越来越多,达到数十、数百时,逐个修改微服务配置是一件效率非常低的事情,而且很容易出错。我们需要一种统一配置管理方案,可以集中管理所有实例的配置。下面我们就通过一个小案例来感受一下 Spring Cloud Alibaba Nacos 配置中心的强大功能吧。


在 Nacos 中创建配置

我们以 userservice 服务为例,在 Nacos 配置中心创建一份配置文件。进入 Nacos 的管理界面,然后选择配置管理菜单,点击配置列表选项,点击右侧"+"符号,如下图所示:


bg fit


点击之后,填写弹出的表单页,最后点击发布,我们就创建好一份 userservice 服务的配置,如下图所示:


image-20220731211155187


发布成功之后,我们在控制台的配置列表中可以看到新增配置,并且可以进行再次编辑,查看详情,删除等操作。


image-20220731211354245


此时,在 Nacos 配置中心的工作暂时告一段落,我们已经创建好一份 userservice 服务的配置文件,那么怎么样才能让 userservice 服务读取到这份配置呢?


读取 Nacos 中配置

userservice 服务要读取 Nacos 中管理的配置,并且与本地的 application.yml 配置合并,才能完成项目启动。但是此时有一个问题必须解决: 尚未读取 application.yml,又如何得知 nacos 地址呢?


为解决此问题,Spring 引入了一种新的配置文件:bootstrap.yaml 文件,该配置文件的优先级很高,会在 application.yml 之前被读取,流程如下:


image-20220731213028366


首先,我们要给 userservice 引入 Nacos 配置中心的 Maven 依赖:



    com.alibaba.cloud
    spring-cloud-starter-alibaba-nacos-config

其次,在 resources 目录下创建 bootstrap.yml 文件,并进行配置:

spring:
  application:
    name: userservice # 服务名称
  profiles:
    active: dev #开发环境,这里是dev
  cloud:
    nacos:
      server-addr: localhost:8848 # Nacos地址
      config:
        file-extension: yaml # 文件后缀名

此时,同学们会发现,在 bootstrap.yml 中配置的这些信息,正好可以满足我们的需求: 去 nacos 中查找一个名字为 userservice-dev.yaml 文件。这些信息正是${spring.application.name}-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}的值拼接而成。


image-20220731213648989


这也是我们在 nacos 中创建配置文件时的命名规则的含义所在。


测试验证

我们在 userservice 中新增一个控制器类,进行测试:

@Slf4j
@RestController
@RequestMapping("/config")
public class NacosConfigTestController {
    @Value("${myconfig.msg}")
    private String msg;

    @RequestMapping("/msg")
    public String getMsg(){
        return this.msg;
    }
}

修改完成之后,我们重启 userservice 服务,访问: http://localhost:8081/config/msg ,结果如图所示:

image-20220731214102597

我们通过简单的几个步骤,成功的使用 Nacos 配置中心的功能,实现了在 Nacos 控制台对配置文件的简单管理。


现在如果尝试修改 Nacos 配置中心的config.msg的值,然后再次访问 http://localhost:8081/config/msg ,会发现返回的结果并没有发生变化,
要想使新的配置生效仍需重启服务.


配置热更新

如果每次线上修改修改了配置文件,都需要重启服务才能生效,这样的话,就会造成服务的中断,这是我们不希望看到的。那么,有没有一种方式,可以实现在线上修改配置文件,而不需要重启服务呢?

答案是可以的, 我们可以通过为 Nacos 的配置热更新来实现此类需求。

所谓的热更新是指,在 Nacos 中的配置文件变更后,对应的微服务无需重启就可以感知到最新的配置。


所谓的 Nacos 配置热更新,就是指 Nacos 中的线上配置变更后, 无需重启服务就可以感知到最新的配置变化。

在微服务中,可以通过 SpringBoot 的方式进行配置的注入 :

  • 通过 @Value 注解进行注入单个配置
  • 通过 @AutoWired 注解进行注入包含多个配置属性的对象
  • 在需要注入配置的类上添加 @RefreshScope 注解,使得注入的配置可以和线上配置保持同步

使用@Value 注解进行注入

@Value 注入的变量所在类上添加注解@RefreshScope,:

@Slf4j
@RestController
@RequestMapping("/config")
@RefreshScope // 添加此注解, 使得配置文件可以热更新
public class NacosConfigTestController {
    @Value("${myconfig.msg}") // 通过@Value 注解进行注入响应的配置
    private String msg;

    @RequestMapping("/msg")
    public String getMsg(){
        return this.msg;
    }
}

通过查看@RefreshScope 的注解发现,其底层是重新创建了实例,并进行了依赖注入。

/**
 * 添加了@RefreshScope的Bean 可以在运行时刷新
 * 任何使用它们的组件都将在下一次方法调用时获得一个新实例,完全初始化并注入所有依赖项。
 * @author Dave Syer
 */
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Scope("refresh")
@Documented
public @interface RefreshScope {
   ScopedProxyMode proxyMode() default ScopedProxyMode.TARGET_CLASS;
}

使用@Autowire 注解进行注入

@Value 可以进行简单值的属性注入,如果我们想要在 Bean 中注入一个对象,往往使用@Autowire 注解进行注入。针对这种注入方式,我们可以使用@ConfigurationProperties 注解进行配置读取。


我们仍然以myconfig.msg配置为例,使用@ConfigurationProperties 进行配置读取。​ 首先,编写一个 Java Bean:

@Data
@Component
@ConfigurationProperties(prefix = "myconfig")
public class UserNacosConfig {
    private String msg;
    private String chapter;
}

在编写 JavaBean 时,@ConfigurationProperties 注解的 prefix 属性和 Nacos 中配置的属性前缀保持一致,成员变量的名字和 Nacos 中配置的属性的 Key 保持一致。 Nacos 中配置我们修改如下:

w:35em

修改完 Nacos 中配置之后,一定要记得点击发布按钮进行发布。


最后修改 NacosConfigTestController,注入我们编写的 JavaBean:

@Slf4j
@RestController
@RequestMapping("/config")
@RefreshScope
public class NacosConfigTestController {
    @Value("${myconfig.msg}")
    private String msg;

    @Autowired
    private UserNacosConfig userNacosConfig;

    @RequestMapping("/msg")
    public String getMsg() {
        return this.msg;
    }

    @RequestMapping("/message")
    public String getMessage() {
        return userNacosConfig.getMsg() + userNacosConfig.getChapter();
    }
}

重启 userservice 应用,访问:http://localhost:8081/config/message ,结果如图所示:

image-20220801112128380

此时是因为我们重启了服务,所以能够读取到最新的配置,这是可以预知的结果。


现在,我们继续修改配置,而不重启服务,观察是否能够得到最新的配置内容。

最新配置更改如下:

w:35em


点击发布之后,我们不用重启 userservice 服务,直接再次访问:http://localhost:8081/config/message , 结果如图所示:

w:35em

可以看到,我们已经可以实现配置的热更新。


加载指定的 Nacos 配置

配置默认的 GroupID 为 DEFAULT_GROUP, 也可以创建自定义的 GroupID, 但是在加载配置时,需要指定 GroupID。


在 Nacos 配置中心, 创建配置如下:

w:24em


pom.xml 加入依赖

  
    com.alibaba.boot
    nacos-config-spring-boot-starter
    0.2.12
  

启动类加入注解

// 读取 Nacos 配置中心
@NacosPropertySource(dataId = "retryTimes", groupId = "RiskModule",autoRefreshed = true)
@MapperScan("com.niit.user.mapper")
@SpringBootApplication
public class UserApplication {...}

使用时通过@NacosValue 注解进行注入

    @NacosValue(value = "${readRetryTimes}", autoRefreshed = true)
    private String readRetryTimes;
    @NacosValue(value = "${writeRetryTimes}", autoRefreshed = true)
    private String writeRetryTimes;

此外, 也可以通过 Nacos 的 API 进行配置的设置和读取

发布配置
curl -X POST "http://127.0.0.1:8848/nacos/v1/cs/configs?dataId=nacos.cfg.dataId&group=test&content=HelloWorld"

获取配置
curl -X GET "http://127.0.0.1:8848/nacos/v1/cs/configs?dataId=nacos.cfg.dataId&group=test"


注意:

  1. 不是所有的配置都适合放到配置中心,维护起来比较麻烦。
  2. 建议将一些关键参数,需要在运行时进行调整的参数放到 Nacos 配置中心,这些参数一般都是自定义配置信息。

多环境配置

微服务系统中, 及存在共通的配置比如应用的名称。而有些配置在不同的环境下,比如开发环境和生产环境下的微服务的配置是不尽相同的, 比如端口号, 数据库的链接,Nacos 的地址。

此时,我们想尽量减少重复性工作,提升开发和维护的效率,那就需要将这些跨环境的那些相同配置提取出来。我们将共同配置提取到一个地方,就可以做到只用设置一次, 所有环境(比如开发.测试以及生产环境)都会读取该配置,这样就达到了配置共享的目的。


配置读取

在微服务启动的时候,会去 Nacos 中读取多个配置:

  • bootstrap.yml 启动配置:
    会优先加载,用于加载一些系统级别的配置,比如连接到配置中心的配置。
  • ${spring.application.name}.yaml
    例如 userservice.yaml。无论微服务的环境是开发环境也好, 是生产环境也好,${spring.application.name}.yaml这个配置文件都会userservice加载。

在 Nacos 中添加共享配置

我们在 nacos 中添加一个 userservice.yaml 文件:

w:22em

注意: 命名规则是 ${spring.application.name}.yaml ,该共享配置与环境无关。


读取新增配置

userservice 服务中,修改配置类,读取新增的author属性:

@Data
@Component
@ConfigurationProperties(prefix = "myconfig")
public class UserNacosConfig {
    private String chapter;
    private String msg;
    private String author;
}

新增一个控制器方法,以便于进行测试验证:

@Slf4j
@RestController
@RequestMapping("/config")
@RefreshScope
public class NacosConfigTestController {

    @Autowired
    private UserNacosConfig userNacosConfig;
    ...
    @RequestMapping("/author")
    public String getAuthor() {
        return userNacosConfig.getAuthor();
    }
}

多环境测试

使用 dev 环境启动一个 userservice 实例,端口号 8081,再用 test 环境启动另一个 userservice 实例,端口号 8082,然后我们分别访问: http://localhost:8081/config/authorhttp://localhost:8082/config/author:


结果如图所示:

w:28em


我们知道,在 bootstrap.yml 文件中指定了应用读取 Nacos 中的 userservice-dev.yaml 文件,并且 Nacos 中并没有 userservice-test.yaml 配置文件,但是我们访问 8082 端口的 userservice 时,仍然能够读取myconfig.author的值,这说明userservice.yaml文件确实是多环境共享的一个配置文件。


配置优先级

我们现在考虑一个问题,如果多个配置文件中都对某个属性进行了定义,那么微服务究竟应该读取那个配置来使用呢?我们不妨来做一个小小的测验。

在每一个配置文件中都定义一个相同的属性,比如myconfig.bookName属性,如下所示:

  • userservice-dev.yaml 中,bookName 的值是 Spring Cloud Dev
  • userservice.yaml 中,bookName 的值是 Spring Cloud Default
  • 在本地 application-dev.yaml 中,bookName 的值是 Spring Cloud Local。

修改配置类,读取 bookName 属性:

@Data
@Component
@ConfigurationProperties(prefix = "myconfig")
public class UserNacosConfig {
    private String chapter;
    private String msg;
    private String author;
    private String bookName;
}

新增控制器方法,读取myconfig.bookName属性的值:

@Slf4j
@RestController
@RequestMapping("/config")
@RefreshScope
public class NacosConfigTestController {
    @Autowired
    private UserNacosConfig userNacosConfig;
    ...
    @RequestMapping("/bookName")
    public String getBookName() {
        return userNacosConfig.getBookName();
    }
}

在 dev 环境下,重启 userservice,端口号 8081,现在我们访问: http://localhost:8081/config/bookName ,结果如图所示:

w:32em


这说明,Nacos 线上配置的带有环境变量的配置文件的优先级是最高的。那么线上的共享配置和本地配置,哪个优先级更高呢?我们暂时删除 Nacos 线上配置的带有环境变量的配置文件中的属性,重新访问: http://localhost:8081/config/bookName ,结果如图所示:

image-20220801174120879


通过这个小小的测验,对于配置优先级问题,我们总结出如下规律:

  • 线上配置优先于本地配置
  • 线上自定义环境配置优先于线上共享配置

bg right fit


这样的优先级规律,也恰恰符合配置中心的设计原则,即线上可配置优先于本地预配置,自定义个性化配置优先于多环境共享配置。详见 Nacos-config 参考文档


搭建 Nacos 高可用集群


在之前的内容中,我们已经学习了 Nacos 的基本用法。不过我们一直是使用的 nacos 单节点服务,Nacos 单节点模式只适用于线下环境,在企业的生产环境中,我们是不能使用 Nacos 单节点进行业务架构部署的。


单节点对于高可用设计来说是远远不够的,因为单节点一旦出先故障,那么所有依赖于该节点的其他微服务,都会出现问题,这样会导致整个业务系统的大崩溃。所以,我们接下来就开始学习如何搭建 Nacos 集群,构建一个高可用的服务治理体系。


Nacos 集群

一个 Nacos 集群,至少要有三个节点。下方是 Spring Cloud Alibaba Nacos 官方给出的最简易的 Nacos 集群架构图:


image-20220802110040457

其中包含 3 个 nacos 节点,然后一个负载均衡器代理 3 个 Nacos。这里负载均衡器可以使用 nginx。接下来,我们以 windows 系统为例,搭建一个最简单的 Nacos 集群进行学习。


注意:

在实际生产环境中,需要给做反向代理的 nginx 服务器设置一个域名,这样后续如果有服务器迁移,nacos 的客户端也无需更改配置。Nacos 的各个节点应该部署到多个不同服务器,做好容灾和隔离。Mysql 应该搭建一个主从高可用的集群。 本案例以单机 windows 系统为例,模拟三个 Nacos 节点,并且使用单机的 Mysql8.0 以上的版本进行集群搭建的演示。


Nacos 集群搭建步骤如下:

  1. 搭建数据库,初始化数据库表结构
  2. 下载 nacos 安装包
  3. 配置 nacos 节点
  4. 启动 nacos 集群
  5. nginx 反向代理
  6. 修改微服务 Nacos 地址

初始化数据库

Nacos 默认数据存储在内嵌数据库 Derby 中,不属于生产可用的数据库。官方推荐的最佳实践是使用带有主从的高可用数据库集群。这里我们以单点的数据库为例来演示。首先新建一个数据库,命名为 nacos,而后导入 Nacos 的 MySQL 数据库的初始化 SQL 文件.


使用 Nacos 项目官方提供的初始化 sql 文件进行初始化操作, 具体位置在 conf 目录下的mysql-schema.sql文件


h:13em

Nacos 的 MySQL 初始化脚本在线地址
https://github.com/alibaba/nacos/tree/develop/config/src/main/resources/META-INF


在本地 Mysql 中执行成功后,nacos 数据库中将会出现下面的表:

image-20220802111857744


下载 Nacos 安装包

本知识点在本书第三章节已经介绍过,不再赘述,我们以 nacos2.1.0 版本为例,进行搭建。

image-20220802112128986


配置 Nacos 节点

我们在本地 windows 系统上配置三个 Nacos 节点,各个节点的 IP 和端口如下表所示:

节点 IP port
nacos1 127.0.0.1 8858
nacos2 127.0.0.1 8868
nacos3 127.0.0.1 8878

注意: 本地 windows 系统上,如果端口被占用,可以修改端口号,但是要保证三个节点的端口号不一样, 且不要相邻(容易冲突)。


我们先配置好一个 nacos 节点,然后再复制成三个节点。

下面以一个节点操作为例:


1.解压缩

将下载的安装包,解压缩至一个没有中文目录的文件夹,命名为 nacos1,解压完成之后,Nacos 目录如图所示:

image-20220802114728043


-目录说明:

  • bin:启动脚本
  • conf:配置文件

2.修改配置

进入 nacos 的 conf 目录,修改配置文件 cluster.conf.example,重命名为 cluster.conf:

image-20220802114924887


编辑此文件,添加 nacos 集群配置:

192.168.65.1:8858
192.168.65.1:8868
192.168.65.1:8878

192.168.65.1 为本机真实的 IP 地址
真实 ip 可以在启动单机版 nacos 后从日志提示中查看


然后修改 application.properties 文件,进行数据库配置:

image-20220802115229225


属性说明:

  • spring.datasource.platform=mysql: 指定数据库平台为 mysql,Nacos 目前只支持 Mysql。
  • db.num=1 : Mysql 的实例个数,本案例中为 1 个 Mysql 实例。
  • db.url.0: 数据库链接信息,本案例中我们使用的是 Mysql8.0 以上的版本,并且创建了 nacos 数据库,所以链接如上所示。
  • db.user.0: 数据库用户名
  • db.password.0: 数据库密码

接着修改应用的端口号,第一个节点是: 8858,如下图所示:

server.port=8858

3.复制节点

修改完上述配置并保存后,我们将该节点复制两份,命名为 nacos2 和 nacos3,并将 nacos1的端口修改为8858, nacos2 的端口修改为 8868,将 nacos3 的端口修改为 8878。完成之后,nacos 三个节点的集群整体如下所示:


启动 Nacos 集群

在每个 Nacos 节点的 bin 目录下,使用 cmd 命令窗口执行: startup.cmd。 Nacos 的该命令,默认就是以集群方式进行启动。启动成功,最后会打印出日志信息:

image-20220802150700225


Nginx 反向代理

经过以上步骤,我们已经成功搭建好具有三个节点的 Nacos 集群,现在我们使用 Nginx 给该集群配置负载均衡和反向代理。


1.下载 Nginx

下载 Nginx,下载地址:https://nginx.org/en/download.html 我们选择最新的稳定版本。

h:12em


2.解压缩

将安装包解压缩到任意的非中文目录下:

image-20220802153243148


3.添加代理配置

修改 conf/nginx.conf 文件,将下面的配置放在配置文件的 http 节点之内:

upstream nacos-cluster {
  server 127.0.0.1:8858;
    server 127.0.0.1:8868;
    server 127.0.0.1:8878;
}

server {
    listen       8848;
    server_name  localhost;

    location /nacos {
        proxy_pass http://nacos-cluster;
    }
}

该代理配置监听 8848 端口,虚拟路径为nacos


4.启动 Nginx

修改完配置并保存之后,回到 Nginx 的安装目录,双击nginx.exe即可启动。此时,我们可以访问: http://localhost/nacos

bg right fit


通过固定访问方式 http://localhost:8848/nacos 访问集群管理 - 节点列表, 如果看到集群中的三个节点都已经启动成功, 且为 UP 状态, 则说明 Nginx 反向代理配置成功。


微服务配置 Nacos 地址

由于我们现在已经搭建了 Nacos 集群,并且使用 Nginx 做了负载均衡和反向代理,所以各个微服务之前配置的 Nacos 地址肯定无法再使用,微服务中的 Nacos 地址配置要与 Nginx 中保持一致。

以 userservice 服务为例,修改其 bootstrap.yaml 文件即可:


spring:
  application:
    name: userservice
  profiles:
    active: dev #环境
  cloud:
    nacos:
      server-addr: localhost:8848 #nacos集群的NGINX反向代理地址
      config:
        file-extension: yaml #配置文件后缀名

现在我们启动三个 userservice 服务的实例进行测试,启动成功之后,我们可以在 Nacos 控制台看到如下信息:
image-20240320190015616

当我们搭建完成 Nacos 集群之后,在使用的时候几乎完全和我们之前所学的单节点 Nacos 的使用方法一致。


活动 8.1:

Nacos 配置中心使用


练习问题


  1. 当使用@Value 注解进行属性注入时,在需要的类上加上什么注解可以实现动态刷新配置。 ()
    a. @Service
    b. @Component
    c. @RestController
    d. @RefreshScope

    查看答案

    正确答案: d


  1. Nacos 配置中心的 artifactId 是下列的哪一个? ()
    a. spring-cloud-starter-alibaba-nacos-config
    b. spring-cloud-starter-alibaba-nacos-discovery
    c. spring-cloud-starter-Netflix-nacos-config
    d. spring-cloud-starter-alibaba-config-config

    查看答案

    正确答案: a


  1. 下列配置文件,哪个是 userservice 服务的多环境共享配置? ()
    a.userservice-dev.yaml
    b.application-dev.yaml
    c.userservice.yaml
    d.userservice-common.yaml

    查看答案

    正确答案: c


  1. 搭建 Nacos 集群,最少需要几个 Nacos 实例节点?
    a. 1
    b. 2
    c. 3
    d. 4

    查看答案

    正确答案: c


小结

在本章中,您学习了:

  • Nacos 配置中心快速入门

  • 配置热更新

    • 使用@Value 注解进行注入
    • 使用@Autowire 注解进行注入
  • 多环境配置共享

    • 配置优先级
  • 搭建 Nacos 集群

    • 初始化数据库
    • 配置 Nacos 节点
    • 启动 Nacos 集群
    • Nginx 反向代理

Views: 11

SpringSecurity-OAuth2+JWT+SpringCloudGateway实现统一鉴权管理

SpringSecurityJava学习笔记

版权 本文为时间海绵原创文章,转载无需和我联系,但请注明来自博客 https://blog.hzchendou.com

一、SpringSecurity 入门

介绍

SpringSecurity 是Spring 全家桶中的安全框架,为了解决“用户身份认证”、“资源访问鉴权”这两个核心问题,SpringSecurity提供了一整套安全框架,基于安全框架,用户可以自定义身份认证、资源鉴权功能,例如:手机验证码登录、基于RDBC鉴权等,本文章主要介绍如何创建基于SpringSecurity项目。

项目创建

项目源码已上传到Gitee: 地址

项目依赖

基于 SpringBoot 创建SpringSecurity 可以实现开箱即用功能,引入依赖项:

- SpringBoot依赖

 
    org.springframework.boot
    spring-boot-starter-parent
    2.7.0
     
  

- Spring MVC 依赖(搭建基于 http 协议的web项目)


   org.springframework.boot
   spring-boot-starter-web

- Spring Security 依赖


   org.springframework.boot
   spring-boot-starter-security

详细 pom 文件可以参见源码:https://gitee.com/hzchendou/spring-security-demo/blob/lesson1/pom.xml

项目模块

创建简单mvc API,代码如下:

/**
 * hello 访问控制器
 * @Date: 2022-05-23 11:27
 * @since: 1.0
 */
@RequestMapping("/anonymity")
@RestController
public class AnonymityController {

    @RequestMapping("/hello")
    public ResultVO test() {
        return ResultVO.success("hello world");
    }
}

项目启动

自此完成项目配置,基于SpringBoot 自动装配功能可以帮助我们完成大部分配置,引入依赖后会帮助创建一个基础运行框架,配置了一些默认配置项,运行项目后看到如下日志:

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.7.0)

2022-05-23 12:23:13.584  INFO 8538 --- [           main] c.h.b.demo.springsecurity.Application    : Starting Application using Java 1.8.0_211 on hzchendoudeMac-mini.local with PID 8538 (/Users/chendou/repo/hzchendou/learning/springsecurity/target/classes started by chendou in /Users/chendou/repo/hzchendou/learning/springsecurity)
2022-05-23 12:23:13.586  INFO 8538 --- [           main] c.h.b.demo.springsecurity.Application    : No active profile set, falling back to 1 default profile: "default"
2022-05-23 12:23:14.338  INFO 8538 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8080 (http)
2022-05-23 12:23:14.344  INFO 8538 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2022-05-23 12:23:14.344  INFO 8538 --- [           main] org.apache.catalina.core.StandardEngine  : Starting Servlet engine: [Apache Tomcat/9.0.63]
2022-05-23 12:23:14.426  INFO 8538 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2022-05-23 12:23:14.426  INFO 8538 --- [           main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 806 ms
2022-05-23 12:23:14.666  WARN 8538 --- [           main] .s.s.UserDetailsServiceAutoConfiguration : 

Using generated security password: ab60d0d9-a34b-4aee-ad31-e8881672c6a0

This generated password is for development use only. Your security configuration must be updated before running your application in production.

2022-05-23 12:23:14.742  INFO 8538 --- [           main] o.s.s.web.DefaultSecurityFilterChain     : Will secure any request with [org.springframework.security.web.session.DisableEncodeUrlFilter@20eaeaf8, org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@748ac6f3, org.springframework.security.web.context.SecurityContextPersistenceFilter@7affc159, org.springframework.security.web.header.HeaderWriterFilter@72eb6200, org.springframework.security.web.csrf.CsrfFilter@52bf7bf6, org.springframework.security.web.authentication.logout.LogoutFilter@66de00f2, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@163042ea, org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@479b5066, org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@68f6e55d, org.springframework.security.web.authentication.www.BasicAuthenticationFilter@1d8b0500, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@1682c08c, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@3fd05b3e, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@6fff46bf, org.springframework.security.web.session.SessionManagementFilter@76ececd, org.springframework.security.web.access.ExceptionTranslationFilter@67e25252, org.springframework.security.web.access.intercept.FilterSecurityInterceptor@52b46d52]
2022-05-23 12:23:14.783  INFO 8538 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2022-05-23 12:23:14.791  INFO 8538 --- [           main] c.h.b.demo.springsecurity.Application    : Started Application in 1.471 seconds (JVM running for 1.869)

会生成一串用户密码,这是SpringSecurity 帮助学习的默认配置,后续将会讲解,

启动完成在浏览器输入访问地址:http://localhost:8080/anonymity/hello

网页会自动跳转到 http://localhost:8080/login

输入用户名:user

输入密码:在日志中的一串字符串, 这里是 *ab60d0d9-a34b-4aee-ad31-e8881672c6a0*(由程序自动生成,每次生成内容不一样)

登录成功后跳转到指定地址,得到内容如下:

{"code":200,"data":"hello world","message":null}

至此完成SpringSecurity项目搭建,SpringSecurity 提供了默认配置,默认组织匿名访问接口。

总结

  1. SpringSecurity 项目搭建很方便,结合 SpringBoot 进行使用可以快速完成基础框架搭建,同时提供默认配置,不需要任何配置即可完成项目资源保护
  2. SpringSecurity 提供了 用户身份鉴定(用户登录), 以及用户访问权限控制(判断是否拥有权限访问项目接口)

上述内容帮助完成搭建基础项目,当然这样的程序无法满足实际项目需求,我们需要自定义认证(登录方式)以及 鉴权(权限控制)流程,下一篇我们将在此基础上自定义登录方式

特别声明:项目采用最新SpringSecurity版本:5..7.1,版本升级带来了一点新变化,可能与老版本由一点不同,但是核心理念是一致的

二、SpringSecurity 自定义手机验证登录方式

简介

在上一篇文章中,我们介绍了如何搭建一套基于SpringSecuity的项目框架,并且进行了演示,本文将继续扩展项目功能,实现自定义用户登录功能。

项目源码仓库:Gitee

代码分支:lesson2

原理介绍

SpringSecurity 提供了web服务项目相关的安全配置,通常我们使用 Spring MVC进行开发(基于Servlet 容器技术实现,现在 Spring 提供了 WebFlux 技术可以提高系统吞吐量,两者都是基于 HTTP协议开发的web服务,MVC提供的是阻塞I/O,WebFlux 提供非阻塞 I/O),Servlet 容器中提供了两种核心组件:

  1. Filter
  2. Servlet

Filter 简介

Filter 组件可以实现过滤器功能,Http 请求达到时,Filter 优先接收到请求信息,并且可以依据业务逻辑对请求提前进行处理,例如,CORS(浏览器的同源请求策略, 详细信息参见:阮一峰网络日志), 对于非同源的请求,项目方可以按照要求选择拒绝或者接受请求,接口定义如下:

public interface Filter {
    /// 初始化
    public default void init(FilterConfig filterConfig) throws ServletException {}
    /// 过滤
    public void doFilter(ServletRequest request, ServletResponse response,
            FilterChain chain) throws IOException, ServletException;
    /// 销毁
    public default void destroy() {}
}

在 public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException; 方法中可以获取到http 请求信息,并且可以阻断 Http 请求,防止调用实际的业务逻辑代码,例如 实现基于IP黑名单过滤器,发现请求IP 在系统的 IP黑名单中,可以直接返回错误信息阻止请求继续执行。

Servlet 简介

Servlet 组件用于接收Http请求信息,并依据请求信息进行处理,项目的业务逻辑在 Servlet 中进行处理,接口定义如下:

public interface Servlet {
    /// 初始化方法
    public void init(ServletConfig config) throws ServletException;
    /// 获取配置
    public ServletConfig getServletConfig();
    //// 业务处理
    public void service(ServletRequest req, ServletResponse res)
            throws ServletException, IOException;
    /// 获取基础信息
    public String getServletInfo();
    /// 销毁
    public void destroy();
}

其中最主要的是 public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException; 业务逻辑代码在此处进行调用处理(Spring MVC 中的重要组件 DispatcherServlet 是Servlet 子类,通过 service 方法接收并处理 Http 请求)

SpringSecurity原理

通过上述Servlet 技术简单讲解,我们知道Filter主要用于实现过滤功能,这些功能与业务逻辑关系不大,可以在请求进入业务逻辑之前进行拦截处理,保障系统稳定运行,SpringSecurity正是通过一系列“Filter组件”来实现安全过滤功能(在执行业务逻辑之前对Http请求进行身份校验和权限控制),SpringSecurity 中的两个主要功能分别是:

  • 身份校验:对当前发起请求的用户(可能是真实用户,也可能是网络爬虫或者是恶意攻击者)进行身份识别,主要解决你是谁的问题
  • 权限控制:对当前访问资源进行权限控制(管理后台功能只对管理员开发,普通用户无法访问),主要解决你是否有权限访问资源

通过上述两个功能点可以实现系统的访问控制安全,对于不符合要求的请求,直接返回错误信息,阻止不安全的资源访问。本文重点讲解身份校验,权限控制将在后续进行分析。

用户名密码登录分析

在之前文章中使用“user”用户进行了登录,这是SpringSecurity提供的默认用户密码登录实现:"org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter",这是一个Filter 子类,可以实现Filter过滤功能,核心代码如下:

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    /// 默认匹配 POST /login 请求
    private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login",
            "POST");

    public UsernamePasswordAuthenticationFilter() {
        super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
    }

    public UsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
        super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
    }
    //// 在 doFilter 方法中调用该方法实现过滤
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        /// 判断请求方法是否支持
        if (this.postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }
        String username = obtainUsername(request);
        username = (username != null) ? username.trim() : "";
        String password = obtainPassword(request);
        password = (password != null) ? password : "";
        /// 包装成 用户密码Token
        UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
                password);
        // Allow subclasses to set the "details" property
        /// 设置请求信息,这是一些额外的信息,例如用户IP地址等信息,与核心校验逻辑关系不大
        setDetails(request, authRequest);
        //// 调用 AuthenticationManager 进行身份验证,成功返回 Authentication 对象,失败抛出异常
        return this.getAuthenticationManager().authenticate(authRequest);
    }
}

我们来分析一下 attemptAuthentication 方法,执行的逻辑如下:

  1. 判断Http请求是否为用户密码登录请求(主要看请求路径是否为 /login 并且为 POST方法)
  2. 获取请求中的用户名密码信息(登录参数信息)
  3. 委托给AuthenticationManager组件进行身份验证
  4. 返回成功或者是错误信息

可以理解为Filter中并没有承担核心的身份信息校验责任,主要完成校验请求是否为用户名密码请求,如果是提取出相关参数,委托给AuthenticationManager组件校验身份,如果成功返回Authentication对象。这里有几个关键的类:

  • UsernamePasswordAuthenticationToken:保存用户名密码信息(是Authentication的子类)
  • Authentication:代表待验证信息或者是已验证完成后的身份信息(可以是未验证的信息也可以是已验证的身份信息,通过方法boolean isAuthenticated() 返回值判断是为已验证信息)
  • AuthenticationManager:验证管理器负责对待验证信息内容进行验证,验证成功返回身份信息,失败返回错误信息

UsernamePasswordAuthenticationToken和Authentication都是数据模型类,不存在处理逻辑,AuthenticationManager是主要的验证逻辑处理类,在SpringSecurity 中提供了ProviderManager实现类,核心代码如下:

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean{
   /// 身份校验处理器
   private List providers = Collections.emptyList();

   @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        Class toTest = authentication.getClass();
        AuthenticationException lastException = null;
        AuthenticationException parentException = null;
        Authentication result = null;
        Authentication parentResult = null;
        /// 循环使用Provider 来验证身份信息,只要有一个验证通过就算成功
        for (AuthenticationProvider provider : getProviders()) {
            /// 判断Provider是否支持验证Authentication子类类型,例如前面的UsernamepasswordAuthenticationToken
            if (!provider.supports(toTest)) {
                continue;
            }
            try {
                /// 使用具体的验证器进行验证,验证通过返回具体验证信息
                result = provider.authenticate(authentication);
                if (result != null) {
                    break;
                }
            }
            catch (AccountStatusException | InternalAuthenticationServiceException ex) {
                throw ex;
            }
            catch (AuthenticationException ex) {
                lastException = ex;
            }
        }
        /// 如果验证器无法验证,并且存在父级验证器那么使用父级验证器进行验证
        if (result == null && this.parent != null) {
            // Allow the parent to try.
            try {
                parentResult = this.parent.authenticate(authentication);
                result = parentResult;
            }
            catch (ProviderNotFoundException ex) {
            }
            catch (AuthenticationException ex) {
                parentException = ex;
                lastException = ex;
            }
        }
        /// 判断是否存在已验证结果,存在返回验证信息,不存在抛出一样信息
        if (result != null) {
            if (this.eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) {

                ((CredentialsContainer) result).eraseCredentials();
            }

            if (parentResult == null) {
                this.eventPublisher.publishAuthenticationSuccess(result);
            }

            return result;
        }
        throw lastException;
    }
}

在上述代码中最主要的是private List providers = Collections.emptyList();属性信息,ProviderManager委托该属性循环处理Authentication子类对象,直到验证通过或者是全部不通过, AuthenticationProvider接口定义如下:

public interface AuthenticationProvider {

    ///对待验证信息进行验证
    Authentication authenticate(Authentication authentication) throws AuthenticationException;

    //// 判断当前验证器是否支持对该类型验证信息进行校验处理
    boolean supports(Class authentication);

}

SpringSecurity中提供了对UsernamepasswordAuthenticationToken参数验证的AuthenticationProvider子类DaoAuthenticationProvider,相关接口实现如下:

  • 判断是否支持方法
/// 判断待验证参数authentication是否为UsernamePasswordAuthenticationToken类型或者是其子类
public boolean supports(Class authentication) {
    return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
}
  • 身份验证逻辑
@Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        String username = determineUsername(authentication);
        boolean cacheWasUsed = true;
        UserDetails user = this.userCache.getUserFromCache(username);
        if (user == null) {
            cacheWasUsed = false;
            try {
                /// 依据用户名以及参数信息查找用户信息
                user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
            }
            catch (UsernameNotFoundException ex) {
                /// 这里为了隐藏用户不存在错误,会对该错误进行包装,抛出新错误
                if (!this.hideUserNotFoundExceptions) {
                    throw ex;
                }
                throw new BadCredentialsException(this.messages
                        .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
            }
        }
        try {
            //// 信息校验前执行
            this.preAuthenticationChecks.check(user);
            //// 校验用户密码是否正确
            additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
        }
        catch (AuthenticationException ex) {
            if (!cacheWasUsed) {
                throw ex;
            }
            //// 如果使用的是缓存,那么进行绕过缓存再次验证防止缓存信息过期
            cacheWasUsed = false;
            user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
            this.preAuthenticationChecks.check(user);
            additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
        }
        /// 校验结束处理
        this.postAuthenticationChecks.check(user);
        if (!cacheWasUsed) {
            this.userCache.putUserInCache(user);
        }
        Object principalToReturn = user;
        if (this.forcePrincipalAsString) {
            principalToReturn = user.getUsername();
        }
        /// 验证成功返回成功信息
        return createSuccessAuthentication(principalToReturn, authentication, user);
    }

整体流程图如下所示:

img

自定义验证流程

通过上述分析我们可以知道,自定义一个身份验证逻辑需要实现以下三个组件:

  1. 自定义验证参数类型:Authentication
  2. 自定义拦截过滤器:Filter
  3. 自定义特定验证参数类型验证器:AuthenticationProvider

下面我们将实现常用的手机验证码验证登录功能。

自定义验证参数

通过分析UsernamePasswordAuthenticationToken组件,我们知道该Token主要包装验证参数信息,方便后续使用,实现逻辑如下:

public class PhoneCodeAuthenticationToken extends AbstractAuthenticationToken {

    private final Object principal;

    private Object credentials;
    ///未验证参数构造器
    private PhoneCodeAuthenticationToken(String phone, String code) {
        super(null);
        this.principal = phone;
        this.credentials = code;
        /// 设置是否验证:false-未验证,true-已验证
        super.setAuthenticated(false);
    }
    ///已验证参数构造器
    /// authorities代表取得打权限信息
    private PhoneCodeAuthenticationToken(Object principal,
            Collection authorities) {
        super(authorities);
        this.principal = principal;
         /// 设置是否验证:false-未验证,true-已验证
        super.setAuthenticated(true);
    }

    /// 未验证Token
    public PhoneCodeAuthenticationToken unAuthToken(String phone, String code) {
        return new PhoneCodeAuthenticationToken(phone, code);
    }

    ////已验证Token
    public PhoneCodeAuthenticationToken authToken(Object principal,
            Collection authorities) {
        return new PhoneCodeAuthenticationToken(principal, authorities);
    }

    @Override
    public Object getCredentials() {
        return credentials;
    }

    @Override
    public Object getPrincipal() {
        return principal;
    }
}

自定义拦截器

通过分析UsernamePasswordAuthenticationFilter组件,我们知道拦截器主要完成三个功能:

  1. 拦截特定请求
  2. 解析参数
  3. 委托给验证器进行验证处理

手机验证码登录拦截POST/phone/login请求,解析参数,包装成PhoneCodeAuthenticationToken对象,最后委托给AuthenticationManager组件验证,具体代码参见Gitee仓库:地址

运行验证

程序启动后发起POST请求,参数信息:

  • phone:15000000000
  • code:888888

请求成功后返回用户信息(SpringSecurity默认配置会将登录成功请求跳转到 / 路径):

{
    "code": 200,
    "data": {
        "username": "15000000000",
        "phone": "15000000000",
        "roles": [
            "ROLE_USER"
        ]
    },
    "message": null
}

至此完成手机验证码登录功能

我们使用POST请求,SpringSecurity默认提供csrf保护,会拦截 POST请求,因此需要禁用

总结

  • SpringSecurity 使用Servlet容器组件Filter功能进行请求拦截,实现身份校验以及权限控制
  • SpringSecurity 使用AuthenticationManager来实现身份校验功能(实际上你可以在Filter中直接完成身份验证功能,但是这种硬编码方式会增加程序耦合性,后期维护/扩展不方便)
  • SpringSecurity 中的AuthenticationManager委托多个AuthenticationProvider对请求参数进行校验
  • 自定义手机验证码验证流程需要实现三个类:
    • 继承Filter的PhoneCodeAuthenticationFilter, 对手机验证码登录请求进行拦截,并解析处请求参数信息,最后委托给AuthenticationManager进行身份校验
    • 继承Authentication的PhoneCodeAuthenticationToken
    • 登录时存放请求参数信息:手机号和验证码
    • 登录成功后存放用户信息:用户名、手机号、权限等
    • 继承AuthenticationProvider的PhoneCodeAuthenticationProvider,对请求参数进行验证,验证通过返回用户信息

有过SpringSecurity开发经验的同学会发现仓库中的代码使用HttpSecurity进行配置的方式与之前的方式不同,这是SpringSecurity官方在新版中推荐使用的方式,老版本的配置方式将会被遗弃,目前两种方式都可以使用

参考文档

三、SpringSecurity 动态权限访问控制

简介

在先前文章中我们搭建了SpringSecurity项目,并且讲解了自定义登录方式需要做哪些工作,如果你感兴趣可以前往博客阅读文章以及代码,在本文将继续讲解如何实现动态权限控制。

代码仓库:Gitee

代码分支:lesson3

目标

Web项目通常都有前台和后台服务,前台服务面向目标客户,后台服务为项目方提供管理和数据分析服务,因此不同的用户需要赋予不同的角色,例如前台用户角色为USER,后台用户为ADMIN,USER允许访问"/user/hello"接口,ADMIN允许访问"/admin/hello"接口,但是USER不能访问。这是项目必须有的基本功能,同时访问规则也会不断变化,例如: 有一个用户昵称功能,初期只允许会员用户(可以理解为拥有角色VIP的用户)使用,后期产品决定全员都可以使用,这种需求也很常见,如果采用硬编码的方式那么会导致频繁修改代码,测试、发布,增加额外工作量,如果可以动态配置接口访问权限,那么就能减少很多工作量,SpringSecurity框架提供了扩展点,基于这些扩展点可以很方便的实现动态权限控制访问功能,我们再来回顾一下需求:

  • 基于角色进行接口权限控制
  • 访问接口需要的角色可以动态配置

原理分析

通过上一篇文章我们知道SpringSecurity基于Filter实现身份验证和权限控制功能,SpringSecurity提供了实现类FilterSecurityInterceptor对访问路径进行权限控制,核心代码逻辑如下:

public void invoke(FilterInvocation filterInvocation) throws IOException, ServletException {
        ///此处省略无关逻辑
        /// 在这里执行权限控制逻辑
        InterceptorStatusToken token = super.beforeInvocation(filterInvocation);
        try {
            filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
        }
        finally {
            super.finallyInvocation(token);
        }
        super.afterInvocation(token, null);
}

在访问实际业务逻辑之前调用父级方法beforeInvocation进行权限判断,如果权限不符合要求,直接抛出异常阻止访问实际业务逻辑,核心代码如下:

protected InterceptorStatusToken beforeInvocation(Object object) {
        //此处省略无关代码
        //// 这里获取与访问路径相关的权限信息,例如:/user/hello 对应 ROLE_USER 角色,当然一个路径可能对应多个权限
        Collection attributes = this.obtainSecurityMetadataSource().getAttributes(object);
        if (CollectionUtils.isEmpty(attributes)) {
            /// 这里注意如果对应的路径在系统中没有配置权限或者是获取方法没有处理这种请求会导致放行,特别注意
            return null; // no further work post-invocation
        }
        /// 未登录用户直接返回未验证错误
        if (SecurityContextHolder.getContext().getAuthentication() == null) {
            credentialsNotFound(this.messages.getMessage("AbstractSecurityInterceptor.authenticationNotFound",
                    "An Authentication object was not found in the SecurityContext"), object, attributes);
        }
        //// 获取验证信息
        Authentication authenticated = authenticateIfRequired();
        // Attempt authorization
        /// 判断用户是否拥有访问权限
        attemptAuthorization(object, attributes, authenticated);
        /// 这里实现了类似Linux su 命令,将当前用户暂时赋予另外一个用户运行权限,可以先忽略不看
        // Attempt to run as a different user
        Authentication runAs = this.runAsManager.buildRunAs(authenticated, object, attributes);
        if (runAs != null) {
            SecurityContext origCtx = SecurityContextHolder.getContext();
            SecurityContext newCtx = SecurityContextHolder.createEmptyContext();
            newCtx.setAuthentication(runAs);
            SecurityContextHolder.setContext(newCtx);

            if (this.logger.isDebugEnabled()) {
                this.logger.debug(LogMessage.format("Switched to RunAs authentication %s", runAs));
            }
            // need to revert to token.Authenticated post-invocation
            return new InterceptorStatusToken(origCtx, true, attributes, object);
        }
        this.logger.trace("Did not switch RunAs authentication since RunAsManager returned null");
        // no further work post-invocation
        return new InterceptorStatusToken(SecurityContextHolder.getContext(), false, attributes, object);

    }

这里有两个重点内容:

  1. 通过this.obtainSecurityMetadataSource().getAttributes(object);方法来获取访问所需要的权限信息
  2. 通过attemptAuthorization(object, attributes, authenticated)方法对访问所需的权限以及用户身份信息进行决策,判断是否允许访问

路径权限分析

上述的this.obtainSecurityMetadataSource()方法返回SecurityMetadataSource类型对象,该接口核心代码如下:

public interface SecurityMetadataSource extends AopInfrastructureBean {
    /// 依据object获取权限信息,我们可以把ConfigAttribute理解为String类型,在我们系统中可以理解保存着角色信息,例如ROLE_USER
    Collection getAttributes(Object object) throws IllegalArgumentException;
    ///获取系统中配置的所有权限信息,用于后续验证器判断是否支持该类型决策
    Collection getAllConfigAttributes();
    /// object 类型,用于判断SecurityMetadataSource支持解析的object类型
    boolean supports(Class clazz);
}

可以看出SecurityMetadataSource的主要作用是给出当前访问需要哪些权限,方便后续判断,可以理解为一个数据源,用来获取访问权限列表

访问权限控制分析

这里我们需要重点查看方法attemptAuthorization(object, attributes, authenticated);包含对用户访问控制权限进行判断,核心代码如下:

private void attemptAuthorization(Object object, Collection attributes,
            Authentication authenticated) {
        try {
            /// 委托accessDecisionManager进行决策判断
            this.accessDecisionManager.decide(authenticated, object, attributes);
        }
        catch (AccessDeniedException ex) {
            /// 异常请求直接向上抛出异常信息
            throw ex;
        }
}

这个方法很简单,就是委托accessDecisionManager来进行访问决策,我们来看一下这个接口的核心代码:

public interface AccessDecisionManager {
    //// 对访问进行决策,判断是否有权限
    void decide(Authentication authentication, Object object, Collection configAttributes)
            throws AccessDeniedException, InsufficientAuthenticationException;
    /// 查看访问控制器是否支持该类型决策
    boolean supports(ConfigAttribute attribute);
    /// 查看访问控制器是否支持特定类型,这个类型就是 上面方法中object对应的类型
    boolean supports(Class clazz);
}

接口也很简单,有点类似AuthenticationProvider接口,调用decide方法,如果允许访问那么不进行任何处理,如果不允许访问就抛出异常信息。

代码实现梳理分析

上述核心逻辑很简单,但是实现逻辑有点绕,不要紧我们画个流程图再来梳理一遍(觉得绕主要是不相关代码对理解造成了困扰,还有可能就是被这种俄罗斯套娃形式绕晕了)

img

通过上述分析我们可以发现,权限控制需要两个核心功能:

  1. 访问路径所需要的权限(实现接口SecurityMetadataSource)
  2. 依据用户权限、路径所需权限进行决策判断是否允许访问(实现接口AccessDecisionManager)

完成上述功能后,将这些功能组装成FilterSecurityInterceptor类型对象,然后放置到SpringSecurity过滤链中实现过滤功能

代码实现

直接动手实现动态权限控制

实现路径权限获取

这里为了更加贴近实际项目,将提供一个RoleService作为数据源,实现代码如下:

@Service
public class RoleService {
    public List roles = new ArrayList<>();
    private Map> urlRoleMaps = new HashMap<>();
    @PostConstruct
    public void init() {
        /// 初始化数据
        roles.addAll(SecurityConfig.createList("ROLE_USER", "ROLE_ADMIN", "ROLE_VIP"));
        urlRoleMaps.put("/", SecurityConfig.createList("ROLE_USER", "ROLE_ADMIN"));
        urlRoleMaps.put("/user/hello", SecurityConfig.createList("ROLE_USER", "ROLE_ADMIN"));
        urlRoleMaps.put("/user/nickname", SecurityConfig.createList("ROLE_VIP"));
        urlRoleMaps.put("/admin/hello", SecurityConfig.createList("ROLE_ADMIN"));
    }
    /// 获取所有角色信息
    public Collection getAllRoles() {
        return Collections.unmodifiableList(roles);
    }
    ///依据请求路径查询所需权限
    public Collection getRoleByPath(String path) {
        Collection roles = urlRoleMaps.get(path);
        if (roles == null) {
            return Collections.EMPTY_LIST;
        }
        return Collections.unmodifiableCollection(roles);
    }
}

代码很简单,就是初始化数据,提供路径与权限对应的数据服务,在实际项目中通常从数据库中获取这些信息。

下面编写RolePermissionMetadataSource接口的实现类,代码如下:

/// FilterInvocationSecurityMetadataSource 是SecurityMetadataSource的子接口,实际上就是 SecurityMetadataSource,没有扩展任何方法
public class RolePermissionMetadataSource implements FilterInvocationSecurityMetadataSource {
    @Autowired
    private RoleService roleService;
    @Override
    public Collection getAttributes(Object object)
            throws IllegalArgumentException {
        FilterInvocation invocation = (FilterInvocation) object;
        String url = invocation.getRequestUrl();
        /// 通过请求路径获取访问路径所需的权限列表
        Collection roles = roleService.getRoleByPath(url);
        if (roles != null && roles.size() > 0) {
            return roles;
        }
        //没有匹配上的资源,禁止访问,设置不存在的访问权限
        // 通过之前的分析知道,如果这里返回空,将会直接放行,运行登录用户访问,这是有风险的
        return SecurityConfig.createList(RoleEnums.ROLE_REFUSE.name());
    }
    @Override
    public Collection getAllConfigAttributes() {
        return roleService.getAllRoles();
    }
    @Override
    public boolean supports(Class clazz) {
        return FilterInvocation.class.isAssignableFrom(clazz);
    }
}

实现路径访问控制决策类

继承接口AccessDecisionManager,核心代码如下:

public class PathAccessDecisionManager implements AccessDecisionManager {
    ///拒绝访问权限名称
    private static final String BASE_REFUSE_NAME = RoleEnums.ROLE_REFUSE.name();
    @Override
    public void decide(Authentication authentication, Object object,
            Collection configAttributes)
            throws AccessDeniedException, InsufficientAuthenticationException {
        Iterator iterator = configAttributes.iterator();
        //进行权限匹配,如果用户拥有资源权限那么进行放行操作
        while (iterator.hasNext()) {
            ConfigAttribute ca = iterator.next();
            // 当前请求需要的权限
            String needRole = ca.getAttribute();
            if (RoleEnums.ROLE_ANONYMOUS.name().equalsIgnoreCase(needRole)) {
                return;
            }
            if (BASE_REFUSE_NAME.equalsIgnoreCase(needRole)) {
                if (authentication instanceof AnonymousAuthenticationToken) {
                    //匿名用户
                    throw new AccessDeniedException("资源信息不存在");
                } else {
                    //登录用户
                    throw new AccessDeniedException("权限不足!");
                }
            }
            // 当前用户所具有的权限
            Collection authorities = authentication.getAuthorities();
            for (GrantedAuthority authority : authorities) {
                if (authority.getAuthority().equalsIgnoreCase(needRole)) {
                    return;
                }
            }
        }
        //如果当前请求没有验证,返回未验证异常
        if (authentication instanceof AnonymousAuthenticationToken) {
            throw new AccessDeniedException("用户未登录");
        }
        throw new AccessDeniedException("权限不足!");
    }
    @Override
    public boolean supports(ConfigAttribute attribute) {
        return true;
    }
    @Override
    public boolean supports(Class clazz) {
        return FilterInvocation.class.isAssignableFrom(clazz);
    }
}

组装Filter

我们将上述实现类与FilterSecurityInterceptor进行组装,实现权限动态过滤:

///动态权限控制 Filter, 默认会拦截所有请求进行权限判断
private FilterSecurityInterceptor filterSecurityInterceptor() {
    FilterSecurityInterceptor interceptor = new FilterSecurityInterceptor();
    /// 由于包含Spring Bean,因此需要注入实现,而不是直接new
    interceptor.setSecurityMetadataSource(rolePermissionMetadataSource);
    interceptor.setAccessDecisionManager(new PathAccessDecisionManager());
    return interceptor;
}
///加入到SpringSecurity过滤链中
httpSecurity.addFilterBefore(roleAuthFilter, FilterSecurityInterceptor.class);

运行验证

我们代码里创建了三个用户:

  1. 15000000000, 拥有:ROLE_USER
  2. 15666666666, 拥有:ROLE_USER、ROLE_VIP
  3. 15888888888, 拥有:ROLE_USER、ROLE_ADMIN

程序运行完成后使用15000000000进行手机验证码登录:

- POST http://localhost:8080/phone/login?phone=15000000000&code=888888

- 返回:

{
    "code": 200,
    "data": {
        "username": "15000000000",
        "phone": "15000000000",
        "roles": [
            "ROLE_USER"
        ]
    },
    "message": null
}

可以看到拥有ROLE_USER权限,那么我们访问 http://localhost:8080/user/hello, 返回:

{
    "code": 200,
    "data": "Hello User",
    "message": null
}

访问http://localhost:8080/admin/hello, 返回:

{
    "code": 400,
    "message": "请求受限"
}

我们看到以上结果符合预期,同理可以使用15888888888用户进行同样的访问操作,在这里我们就不做过多介绍,大家有兴趣可以下载代码自行运行测试,文章开头有代码地址。

如果要修改路径对应的权限,那么只要修改RoleService中的数据即可实现权限动态配置。

总结

通过上述文章分析,我们已经完成权限动态配置,当然运行中展现的JSON数据是配置了对应处理器处理的结果,细节处理请前往代码仓库下载源码自行查看。

为了完成动态权限我们需要完成三个步骤,实现两个接口,步骤如下:

  1. 实现路径权限数据访问接口(实现SecurityMetadataSource)
  2. 实现访问控制决策接口(实现AccessDecisionManager)
  3. 组装Filter并加入到过滤链中(FilterSecurityInterceptor)

熟练掌握上述步骤,实现动态权限控制将不再是难题。

在SpringSecurity 中不仅提供了FilterSecurityInterceptor实现类来对访问进行权限控制,在SpringSecyrity 5.4版本中还提供了AuthorizationFilter实现类来实现相同功能,具体实现方式自行前往仓库进行查看

参考文档

四、SpringSecurity OAuth2统一授权服务

代码

代码仓库:地址

代码分支:lesson4

简介

在先前文章中我们实战演练了在SpringBoot单体应用中使用SpringSecurity开发自定义登录流程以及动态权限控制,有兴趣的同学可以前往博客阅读SpringSecurity相关文章(所有代码都已上传到Gitee仓库, 每篇文章都有一个专属分支)。随着业务的扩张,单体应用无法满足业务需求,微服务是当前大型商业服务的主流架构,在Java领域中,SpringCloud 全家桶是微服务主流开发框架,Spring Cloud Alibaba 是在Spring Cloud 的基础上进行扩展,目的是为了更好的搭配使用Alibaba生态中的微服务组件(Nacos、Sentinel、Seata等),具体内容可查看文档

微服务框架如下所示:

img

上图中的服务集群代表具体业务服务,微服务下的权限控制是为了实现服务集群的安全访问,每个服务集群包含1~N个服务,如果每个服务都定义一套安全策略,那么后期维护将会是一个大工程,因此需要统一安全策略,实现全局安全访问。

OAuth2

OAuth是一个关于授权(authorization)的开放网络标准,2.0版本在全世界得到广泛应用,网上有很多讲解OAuth2的文档,如果你对OAuth没有概念,可以查看往期文章:理解OAuth2.0OAuth2.0的一个简单解释OAuth2.0的四种调用方式

假设我们是一家公司,公司内部有以下业务部门:

  • 微信:提供聊天、账户系统,同时对外提供账户授权登录服务
  • 电子支付:提供在线支付服务
  • 王者荣耀:提供王者荣耀游戏服务

img

为了实现各个业务系统之间相互调用,需要一套授权系统,对内外系统提供统一的授权访问服务,OAuth协议能够满足上述要求,只需要一套授权服务就能同时满足内外系统的调用要求。

SpringSecurity OAuth

OAuth 涉及四个角色:

  • 用户:实际拥有资源所有权的使用者,例如:张三
  • 客户端:提供应用功能的程序客户端,可以是APP形式、web形式,例如:时间海绵博客()
  • 授权服务:实现OAuth2授权协议,对外提供授权服务,例如:微信开发平台
  • 资源服务:对外提供资源访问服务,例如:我们使用微信的微信扫码登录时为网站提供用户信息的微信服务

下面以微信登录为例:

img

传统的模式中,我们默认web客户端是可信任的,所以没有用户授权的过程,可以访问任何数据。在OAuth协议中,客户端默认是不可信任的,需要进行授权处理。无论是内部客户端还是外部客户端都需要得到授权服务器的认证,但是内部和外部客户端可以使用不同的授权认证方式(比如最严格的授权码方式和最简单的客户端方式)。

授权服务器

使用 spring-security-oauth2 搭建授权服务器,对外提供以下功能:

  • /oauth/authorize 获取授权码
  • /oauth/token 提供客户端(client_credentials)、简化(implicit)、密码(password)、刷新Token(refresh_token),授权码(authorization_code)方式获取access_token
  • /oauth/check_token 依据access_token获取授权信息

同时需要管理客户端信息,客户端信息包含以下属性:

  • clientId 客户端Id
  • clientSecret 客户端密码
  • resourceId 资源Id,用于指定可以访问哪些资源服务
  • authorizedGrantTypes 授权模式,客户端(client_credentials)、简化(implicit)、密码(password)、授权码(authorization_code)
  • scopes 授权范围,可以指定客户端访问权限,这个在协议中没有明确指明作用,各个授权服务可以基于业务自行处理
  • authorities 权限,客户端授权模式下需要配置,其它模式下可以不配置
  • redirectUri 授权结果跳转地址

在先前的文章中提到了UserDetailsService用于查询用户信息,在OAuth中需要提供ClientDetailsService来查询客户端信息,代码如下:

public class AuthClientDetailService implements ClientDetailsService {
    private ClientDetailsService clientDetailsService;
    public AuthClientDetailService() {
        InMemoryClientDetailsServiceBuilder builder = new InMemoryClientDetailsServiceBuilder();
        builder.withClient("blog")// client_id
                .secret(new BCryptPasswordEncoder().encode("blog"))
                .resourceIds("blog", "resource")
                .authorizedGrantTypes("authorization_code", "password", "client_credentials", "implicit", "refresh_token")// 该client允许的授权类型 authorization_code,password,refresh_token,implicit,client_credentials
                .scopes("all", "user")// 允许的授权范围
                .autoApprove(false) //加上验证回调地址
                .authorities("blog")
                .accessTokenValiditySeconds(60 * 60 * 2) // 令牌默认有效期2小时
                .refreshTokenValiditySeconds(60 * 60 * 24 * 3) // 刷新令牌默认有效期3天
                .redirectUris("https://blog.hzchendou.com");
        try {
            this.clientDetailsService = builder.build();
        } catch (Exception e) {
            System.exit(-1);
        }
    }
    @Override
    public ClientDetails loadClientByClientId(String clientId) throws ClientRegistrationException {
        return this.clientDetailsService.loadClientByClientId(clientId);
    }
}

同时需要引入AuthorizationServerConfigurer对授权服务进行配置,代码如下:

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    private AuthClientDetailService authClientDetailService;
    @Autowired
    private AuthenticationManager authenticationManager;
    /**
     * 配置客户端信息
     *
     * @param clients
     * @throws Exception
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.withClientDetails(authClientDetailService);
    }
    /**
     * 配置OAuth token相关配置
     *
     * @param endpoints
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints
                .authenticationManager(authenticationManager)/// 用于密码模式验证时需要提供客户端身份验证
                .reuseRefreshTokens(false);
    }
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.authenticationEntryPoint(new AuthAuthenticationEntryPoint());
        security.tokenKeyAccess("permitAll()")//oauth/token_key是公开
                .checkTokenAccess("permitAll()");//oauth/check_token公开
        /// 配置使用 客户端id 和密码的方式进行登录(使用明文传输,不推荐) 
        AuthClientCredentialsTokenEndpointFilter endpointFilter = new AuthClientCredentialsTokenEndpointFilter(security);
        endpointFilter.afterPropertiesSet();
        endpointFilter.setAuthenticationEntryPoint(new AuthAuthenticationEntryPoint());
        // 客户端认证之前的过滤器
        security.addTokenEndpointAuthenticationFilter(endpointFilter);
    }
}

使用@EnableAuthorizationServer注解配置会自动配置AuthorizationEndpoint、TokenEndpoint、CheckTokenEndpoint接口,提供OAuth授权服务。

至此完成授权服务搭建

资源服务器搭建

资源服务器也是使用spring-security-oauth2进行搭建,通过上面的介绍,我们知道资源服务器需要识别access_token来获取用户授权的信息内容,配置信息如下:

@EnableResourceServer
@Configuration
public class SpringSecurityResourceServerConfig extends ResourceServerConfigurerAdapter {
    public static final String RESOURCE_ID = "resource";
    @Autowired
    TokenStore tokenStore;
    @Override
    public void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.formLogin().disable();
        httpSecurity.exceptionHandling()
                .accessDeniedHandler(new PathAccessDeniedHandler())
                .authenticationEntryPoint(new AuthAuthenticationEntryPoint());
        httpSecurity.authorizeRequests()
                .antMatchers("/admin/**").hasAuthority("admin")
                .antMatchers("/user/**").hasAuthority("user")
                .and().csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) {
        //当前资源服务 id,用于校验授权信息是否能够访问
        resources.resourceId(RESOURCE_ID)
                .tokenStore(tokenStore)
                /// 设置Token服务,用于识别access_token授权信息
                .tokenServices(tokenService())//验证令牌的服务
                .stateless(true);
    }

    //资源服务令牌解析服务
    @Bean
    public ResourceServerTokenServices tokenService() {
        //使用远程服务请求授权服务器校验token,必须指定校验token 的url、client_id,client_secret
        RemoteTokenServices service = new RemoteTokenServices();
        service.setCheckTokenEndpointUrl("http://localhost:8081/oauth/check_token");
        service.setClientId("blog");
        service.setClientSecret("blog");
        return service;
    }
}

通过配置@EnableResourceServer注解,将会在Filter过滤链中添加OAuth2AuthenticationProcessingFilter过滤器拦截请求,依据access_token解析授权信息, 查看请求头是否有Authorization属性,该属性代表access_token信息。

OAuth服务验证

启动授权服务以及资源服务,访问 POST http://localhost:8081/oauth/token,请求参数如下所示(使用密码模式访问):

client_id:blog
client_secret:blog
grant_type:password
username:admin
password:admin

返回结果:

{
    "access_token": "db9898be-2aef-4f86-9486-b3735db6403e",
    "token_type": "bearer",
    "refresh_token": "44d9aeff-8839-4a9b-b6ea-1ea6310bab5e",
    "expires_in": 6971,
    "scope": "all user"
}

启动资源服务,访问 GET http://localhost:8082/admin/hello,请求头信息如下

Authorization:Bearer db9898be-2aef-4f86-9486-b3735db6403e

返回信息如下:

{
    "code": 200,
    "data": "Hello Admin"
}

说明访问成功

总结

SpringSecurity中的功能都是通过组装Filter链来完成特定功能实现,

  • SpringSecurity OAuth授权服务需要提供对外服务,因此还提供了AuthorizationEndpoint、TokenEndpoint、CheckTokenEndpoint等接口模块,当然你也可以用自己的实现来替换这些服务。
  • SpringSecurity OAuth 资源服务提供了OAuth2AuthenticationProcessingFilter过滤器来解析access_token授权信息,获得权限信息,后续的权限验证流程与先前的SpringSecurity单体应用是一致的

参考文档

五、SpringSecurity OAuth2扩展自定义授权模式

代码

代码仓库:地址

代码分支:lesson5

简介

在上一篇文章中,我们使用SpringSecurity OAuth2搭建了一套授权服务,对业务系统进行统一授权管理。OAuth提供了四种授权方式:

  • 授权码模式(authorization_code)
  • 简化模式(implicit)
  • 客户端(client_credentials)
  • 密码(password)

在实际业务中上述四种模式不能满足所有要求,例如业务系统接入了短信验证码登录方式,需要进行扩展满足业务需求

手机验证码登录

原理分析

SpringSecurity OAuth在使用@EnableAuthorizationServer注解会自动装配TokenEndpoint对象,这个对象会提供一个POST /oauth/token接口,我们以密码授权模式分析调用流程,如下所示:

img

用户在时间海绵博客发起用户名密码登录请求,时间海绵博客服务器端接收到请求后,调用OAuth协议中的密码授权模式发送请求到OAuth授权服务器,请求信息如下:

client_id:blog
client_secret:blog
grant_type:password
username:admin
password:admin

在ClientCredentialsTokenEndpointFilter过滤器中对客户端信息(client_id和client_secret)进行校验,授权成功后才能访问TokenEndpoint接口(这里要注意,对于OAuth授权服务器来说,过滤链主要完成对客户端信息的校验,用户信息在TokenEndpoint中进行校验,这是因为不同的授权模式关注的用户信息类型不同,需要具体问题具体分析)。

在TokenEndpoint中依据验证的客户端信息以及请求的授权模式进行对比,校验客户端是否有权限进行特定类型授权请求,校验通过后委托给TokenGranter组件进行具体授权模式校验,如上图所示,ResourceOwnerPasswordTokenGranter负责对密码授权模式请求校验,SpringSecurity OAuth还提供了以下实现来校验授权请求:

  • AuthorizationCodeTokenGranter负责校验授权码模式(authorization_code)请求
  • ClientCredentialsTokenGranter负责校验客户端模式(client_credentials)请求
  • ImplicitTokenGranter负责校验简化模式(implicit)请求
  • ResourceOwnerPasswordTokenGranter负责校验密码模式(password)请求

TokenGranter校验成功将返回AccessToken信息,后续客户端可以使用AccessToken信息获取到授权用户信息完成对应操作。

通过上述可以知道,如果要扩展实现短信验证码模式,需要自定义实现TokenGranter组件来校验手机验证码授权请求,TokenGranter定义如下所示:

public interface TokenGranter {
    //// 对授权类型以及请求参数进行处理,如果成功则返回AccessToken信息
    OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest);
}

手机验证码模式代码实现

实现一个继承ToeknGranter接口的类,通过分析已有的TokenGranter子类,我们可以很容易实现,定义一个SmsCodeTokenGranter类,代码实现如下所示:

public class SmsCodeTokenGranter extends AbstractTokenGranter {

    /// 授权模式类型,需要与请求字段grant_type的值相等才会进入处理
    private static final String GRANT_TYPE = "sms_code";
    /// 验证手机与验证码信息是否匹配,这里只是简单的进行匹配处理,判断是否是否为15000000000,验证码是否为:888888
    private final PhoneSmsCodeService phoneSmsCodeService;

    public SmsCodeTokenGranter(AuthorizationServerTokenServices tokenServices,
            ClientDetailsService clientDetailsService,
            OAuth2RequestFactory requestFactory, PhoneSmsCodeService phoneSmsCodeService
    ) {
        super(tokenServices, clientDetailsService, requestFactory, GRANT_TYPE);
        this.phoneSmsCodeService = phoneSmsCodeService;
    }

    @Override
    protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {

        Map parameters = new LinkedHashMap(tokenRequest.getRequestParameters());

        String mobile = parameters.get("mobile"); // 手机号
        String code = parameters.get("code"); // 短信验证码
        ///对参数进行基础校验
        if (StringUtils.isEmpty(mobile) || StringUtils.isEmpty(code)) {
            throw new InvalidGrantException("授权请求参数异常");
        }
        /// 校验手机验证码是否符合要求
        if (!phoneSmsCodeService.checkSmsCode(mobile, code)) {
            throw new InvalidGrantException("授权请求参数异常");
        }

        /// 这里为了简单直接硬编码写入用户信息,通常需要在数据库中取出用户相关信息
        List roles = new ArrayList<>();
        roles.add( new SimpleGrantedAuthority("user"));
        User user = new User(mobile, "", roles);
        /// 授权模式
        UsernamePasswordAuthenticationToken userAuth = new UsernamePasswordAuthenticationToken(user, null, roles);
        userAuth.setAuthenticated(true);
        OAuth2Request storedOAuth2Request = this.getRequestFactory()
                .createOAuth2Request(client, tokenRequest);
        return new OAuth2Authentication(storedOAuth2Request, userAuth);
    }
}
继承AbstractTokenGranter抽象类(该类继承了TokenGranter接口),我们只需要关注核心逻辑"验证手机验证码信息是否正确",校验正确将返回一个OAuth2Authentication对象,这个对象包含了用户信息以及OAuth2请求信息。
完成这一步后,我们需要将SmsCodeTokenGranter装配到TokenEndpoint组件中对sms_code授权类型进行校验处理,配置逻辑如下:
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints
                .authenticationManager(authenticationManager)/// 用于密码模式验证时需要提供客户端身份验证信息
                .reuseRefreshTokens(false);
        //// 取出系统中的四种模式
        List granters = new ArrayList<>(Arrays.asList(endpoints.getTokenGranter()));
        /// 添加手机验证码的授权模式
        granters.add(new SmsCodeTokenGranter(endpoints.getTokenServices(), endpoints.getClientDetailsService(), endpoints.getOAuth2RequestFactory(), phoneSmsCodeService));
        /// 这是一个组装模式,实现了TokenGranter接口,循环调用List中的TokenGranter组件进行校验处理,直到返回验证成功信息或者是异常信息
        CompositeTokenGranter compositeTokenGranter = new CompositeTokenGranter(granters);
        endpoints.tokenGranter(compositeTokenGranter);
    }

上述在AuthorizationServerConfigurerAdapter配置类中进行,具体参见代码。

运行校验

需要注意,这是一个新的授权模式,因此需要先授权客户端拥有手机短信验证模式请求权限,配置客户端的authorizedGrantTypes属性包含sms_code权限(这一步参见代码)。

启动服务,发送手机验证码授权请求POST /oauth/token,请求参数:

client_id:blog
client_secret:blog
grant_type:sms_code
mobile:15000000000
code:888888

返回参数信息:

{
    "access_token": "cf4243c1-085e-4f82-b733-03fb38c90a7c",
    "token_type": "bearer",
    "refresh_token": "01474dd1-c5c5-436b-98b1-8f146cde2391",
    "expires_in": 7199,
    "scope": "all user"
}

手机短信验证码授权模式验证成功,具体代码参见代码仓库。

总结

  • 扩展自定义授权模式,需要继承TokenGranter接口,实现具体校验逻辑
  • 将实现的自定义授权TokenGraner类装配到TokenEndpoint组件中
  • 特别注意需要将新的授权模式grantType信息授权给指定的客户端,不然客户端无法发送自定义授权模式请求,例如本案例中的sms_code请求

参考文档

六、SpringSecurity OAuth2 + SpringCloud Gateway实现统一鉴权管理

代码

代码仓库:地址

代码分支:lesson6

简介

在先前文章中,我们使用SpringSecurity OAuth2搭建了一套基于OAuth2协议的授权系统,并扩展了手机验证码授权模式。在微服务架构下,网关承担着流量入口的角色,所有的请求都要先经过网关,然后由网关负责转发到具体的服务,因此可以在网关实现统一鉴权,网关对请求中的权限进行鉴定,然后将权限信息转发到具体的资源服务,在资源服务中只需要简单校验请求中的权限信息即可(查看信息是否有效),整体流程如下所示:

img

统一鉴权

SpringCloud Gateway网关

我们在上一篇的基础上引入网关服务,在这里使用SpringCloud Gateway组件进行搭建,引入依赖:


  org.springframework.cloud
  spring-cloud-starter-gateway

网关在OAuth2授权协议中承担着资源服务的角色,对请求进行身份鉴定和访问权限控制,身份鉴定需要访问OAuth2授权服务,因此需要引入OAuth2资源服务以及客户端依赖:


  org.springframework.security
  spring-security-oauth2-resource-server



  org.springframework.boot
  spring-boot-starter-oauth2-client

通过之前的文章,我们可以知道SpringSecurity 通过组装一系列的Filter来完成身份验证和权限访问控制功能,但是SpringCloud Gateway使用了新技术框架Reactive Stack(响应式编程),在Spring中提供了Spring WebFlux模块支持响应式编程,传统的Spring MVC都是基于阻塞I/O编程,而Spring WebFlux是基于非阻塞I/O,我们不再这里讨论这两个的区别,只需要知道WebFlux特别适合I/O密集型性应用,网关就是典型的I/O密集应用(网络I/O处理频繁)。SpringSecurity对WebFlux提供了支持,在WebFlux中WebFilter组件承担着与Filter相似的功能。

我们在先前的应用中通过HttpSecurity组件来组装SpringSecurity功能,在这里要使用新的组件ServerHttpSecurity来组装SpringSecurity功能,配置如下所示:

///启用WebFlux下的SpringSecurity配置
@EnableWebFluxSecurity
public class ResourceServerConfig {
    //// 访问权限验证
    @Autowired
    AuthManagerHandler authManagerHandler;
    //// 无权限访问处理器
    @Autowired
    AccessDeniedHandler accessDeniedHandler;
    /// 登录信息失效处理器
    @Autowired
    LoginLoseHandler loginLoseHandler;
    ////访问白名单,对白名单路径可以实现匿名访问
    @Autowired
    private WhiteUrlProperties whiteUrlProperties;

    @Bean
    public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
        http.oauth2ResourceServer()
                /// 这里配置对令牌的校验,从OAuth2授权服务中获取令牌对应的授权信息
                .opaqueToken()
                ///令牌校验地址,用于校验令牌是否有效,已经令牌对应的授权信息
                .introspectionUri("http://localhost:8081/oauth/check_token")
                //// 客户端信息
                .introspectionClientCredentials("blog", "blog")
                .and()
                .accessDeniedHandler(accessDeniedHandler)
                .authenticationEntryPoint(loginLoseHandler)
                .and().authorizeExchange()
                .pathMatchers(HttpMethod.OPTIONS).permitAll() //o
                .pathMatchers("/**").access(authManagerHandler)
                .anyExchange().authenticated()
                .and()
                .addFilterBefore(securityGlobalFilter(whiteUrlProperties), SecurityWebFiltersOrder.FIRST)
                .cors().disable().csrf().disable();
        return http.build();
    }
    /// 该过滤器实现将获取到的授权信息转发到下游服务中,方便后续校验
    public WebFilter securityGlobalFilter(WhiteUrlProperties properties) {
        return new SecurityGlobalFilter(properties);
    }

}

网关路由配置以及其它细节信息可以前往代码仓库进行查看,在此不做过多解释。

资源服务器

资源服务器也需要做一些调整,不需要对请求进行严格的访问控制,只需要校验网关传递的授权信息即可,然后将授权信息放入到SecurityContext中方便后续处理,同时需要注意在资源服务中还是使用Spring MVC框架进行处理(Spring WebFlux可以提高系统吞吐量,但是也会增加编程难度,例如原先的线程变量将不适用,具体需要考量整体编程人员掌握的技术栈来做决定)。

这里的资源服务器不再依赖OAuth授权服务,因此可以移除@EnableResourceServer配置(不直接参与权限控制,只需要校验上游传递的授权信息是否有效即可),同时增加对上游SpringCloud Gateway传递的授权信息进行解析处理,增加自定义SecurityAuthTokenFilter组件:

public class SecurityAuthTokenFilter extends OncePerRequestFilter {
    private static final String AUTH_TOKEN_NAME = "token";
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {
        String token = request.getHeader(AUTH_TOKEN_NAME);
        if (StringUtils.isEmpty(token)) {
            /// 继续处理
            filterChain.doFilter(request, response);
            return;
        }
        ///....省略处理细节,具体前往代码仓库进行查看
        //// 创建自定义的Authentication对象,必须申明为已授权,也就是isAuthenticated()方法返回为true
        BlogAuthentication authentication = new BlogAuthentication(userId, clientId, authorities);
        //....省略处理细节,具体前往代码仓库进行查看
        /// 将授权信息放入到SecurityContext中,方便后续使用
        SecurityContextHolder.getContext().setAuthentication(authentication);
        filterChain.doFilter(request, response);
    }
}

运行验证

分别运行Gateway网关服务、OAuth授权服务、Resource资源服务

  • Gateway 8080端口
  • OAuth 8081端口
  • Resource 8082端口

授权登录

使用密码模式进行授权登录,发送请求POST http://localhost:8080/blog-oauth/oauth/token,请求参数

client_id:blog
client_secret:blog
grant_type:password
username:admin
password:admin

返回结果:

{
    "access_token": "4aace702-cc9d-4a92-b507-9b65f192a65f",
    "token_type": "bearer",
    "refresh_token": "a50a6cff-97b0-4d0f-b3d2-e0fdcee6f142",
    "expires_in": 5591,
    "scope": "all user"
}

资源访问

使用得到的access_token访问资源服务器中的/admin/hello接口,发送请求GET http://localhost:8080/blog-resource/admin/hello,请求头中携带参数

Authorization:Bearer 4aace702-cc9d-4a92-b507-9b65f192a65f

返回结果:

{
    "code": 200,
    "data": "Hello Admin"
}
访问其他权限的接口,发送请求GET http://localhost:8080/blog-resource/user/hello,请求头中携带参数:
Authorization:Bearer 4aace702-cc9d-4a92-b507-9b65f192a65f

返回结果:

{
    "code": 400,
    "message": "无权限访问"
}

至此得到期望的访问结果,实现了统一权限控制

总结

  • SpringCloud Gateway使用WebFlux技术进行开发
  • SpringSecurity提供了@EnableWebFluxSecurity来支持WebFlux
  • SpringSecurity使用ReactiveSecurityContextHolder.getContext()来实现SecurityContextHolder功能

参考文档

七、SpringSecurity OAuth2 + JWT + SpringCloud Gateway实现统一鉴权管理

代码

代码仓库:地址

代码分支: lesson7

简介

在上一篇文章中,我们使用SpringSecurity OAuth2 + SpringCloud Gateway搭建了一套符合微服务架构的授权系统,在Gateway网关实现统一身份鉴定、访问权限控制,同时将授权信息下发到下游业务服务中,下游业务服务只需要关注核心业务逻辑。上述架构依赖于auth授权服务器,每一次业务请求都需要使用access_token请求auth授权服务器来获取用户授权信息,如果access_token自带授权信息,那么网关只需要鉴别access_token有效信息,这将会降低系统对auth授权服务器的依赖,JWT(JSON Web Token)将是很好的选择。

JWT

我们这里不详细介绍JWT,有兴趣的同学可以查看阮一峰老师的文章:JWT入门教程。JWT定义了一种数据结构,它由三部分组成:

  • Header,头部,定义了签名算法,令牌类型
  • Payload,负载,是一个JSON对象,包含实际应用中使用的数据,例如用户名,用户角色,注意这部分内容是不加密的,因此不能包含保密信息
  • Signature,签名,用于验证JWT是否有效,防止信息内容篡改

JWT内容是不加密的,可以使用在线工具解码信息,查看内容。例如有一个JWT格式Token:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzb3VyY2UiLCJibG9nIl0sImV4X3VzZXJuYW1lIjoiYWRtaW4iLCJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCIsInVzZXIiXSwiZXhwIjoxNjU0NTk0MTc2LCJhdXRob3JpdGllcyI6WyJhZG1pbiJdLCJqdGkiOiI2NTdjZmU0Yi05ZDBlLTRhNTUtYjJjOS1iZWE3MTA2YWJkYWIiLCJjbGllbnRfaWQiOiJibG9nIn0.CGQTlvCCwGWIuJBy_qNeX2YBEYYTy6W1FPXOll75P1jdEyvi_TDiTLE4AO2Fa9vtgdWKrtywgGi4kFWZw8mcRFmhVfl9ehdoPcN5Hmdnz-ybJuLWh0i1k0xqg6MsZryTR1wAweEggZkHsIdCZfOw-yPZFTKuhAgVL4d-12Uthb4

在线解析后得到信息如下:

img

项目改造优化

优化分析

在先前文章中,我们将授权信息保存在auth授权服务器中,客户端需要通过请求auth授权服务器来获取授权信息,如果使用JWT,并且在JWT中保存相关授权信息,那么可以直接解析JWT就可以获取授权信息(需要验证JWT是有有效)。

在先前的项目中,我们使用SpringSecurity OAuth2默认配置来创建access_token,实际上SpringSecurity OAuth2提供了对JWT格式access_token支持,我们需要更改access_token的生成方式,因此需要修改auth授权服务中的Token生成方式,同时需要对Gateway网关服务中的Token解析方式进行修改

生成JWT格式Token

使用SpringSecurity OAuth2时,如果没有配置TokenService对象,将会默认使用DefaultTokenServices组件来管理access_token, 使用UUID.randomUUID().toString()方式生成access_token和refresh_token,因此token中不包含任何信息。我们需要配置新的TokenService对象来生成JWT格式Token。

SpringSecurity OAuth2提供了以下组件来生成JWT格式Token:

  • JwtTokenStore实现了TokenStore接口,用来管理access_token和refresh_token
  • JwtAccessTokenConverter实现了TokenEnhancer, AccessTokenConverter接口,可以生成JWT格式token

JWT签名算法我们选用安全性更高的非对称加密算法:RSA(在代码auth/src/test/java/com/hzchendou/blog/demo/RSAKeyTest中提供生成RSA Key方法),配置TokenService:

@Bean
public TokenStore tokenStore() {
   return new JwtTokenStore(accessTokenConverter());
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
  JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
  converter.setKeyPair(keyPair()); //非对称秘钥,具体参见代码
  return converter;
}

@Bean
public AuthorizationServerTokenServices tokenService() {
   DefaultTokenServices service = new DefaultTokenServices();
   service.setClientDetailsService(authClientDetailService);
   service.setSupportRefreshToken(true);
   service.setTokenStore(tokenStore);
   //令牌增强
   TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
   List tokenEnhancers = new ArrayList<>();
   tokenEnhancers.add(tokenEnhancer);
   tokenEnhancers.add(accessTokenConverter);
   tokenEnhancerChain.setTokenEnhancers(tokenEnhancers);

   service.setTokenEnhancer(tokenEnhancerChain);
   service.setAccessTokenValiditySeconds(60 * 60 * 2); // 令牌默认有效期2小时
   service.setRefreshTokenValiditySeconds(60 * 60 * 24 * 3); // 刷新令牌默认有效期3天
   return service;
}

将TokenService配置到OAuth服务中:

endpoints.tokenServices(tokenService());//令牌管理服务

解析JWT格式Token

在网关中需要配置JWT格式解析器,使用JwtAuthenticationConverter来解析JWT中的字段:

@Bean
public Converter> jwtAuthenticationConverter() {
  JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
  jwtGrantedAuthoritiesConverter.setAuthorityPrefix("");
  jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName("authorities");
  JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
  jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
  return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter);
}

因为使用RSA签名算法,因此在Gateway中需要配置RSA公钥来验证Token有效性, 有多种方式可以配置JWT解析验证器来验证JWT的有效性:

方法一、SpringSecurity OAuth2提供的方式:

配置public key信息来验证JWT有效性,在配置文件中配置(配置获取公约的接口地址):

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: http://localhost:8081/key/public-key /// 这里需要在oauth授权服务器中配置接口

@RestController
public class KeyController {
    @Autowired
    KeyPair keyPair;
    //获取公钥
    @GetMapping("/key/public-key")
    public Map getPublicKey() {
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAKey key = new RSAKey.Builder(publicKey).build();
        return new JWKSet(key).toJSONObject();
    }
}

方法二、直接配置PublicKey方式(直接将公钥写入到配置文件中进行读取):

我们这里直接将public key 配置到gateway网关中:

jwt:
  rsa:
    publickey: MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCEfoWyfxqYz6j6tczCoELJfCwxpC+iHox7YEvz6slxNworp+CQAC86qt4Rx14lijoufiBMol0/mAABlG1lv3K1LOgQGcwueZDY5nk0uabOWv787moVbQHRTQoAwMIeSDPQ3SgSoEFyHM6Jj/We7XUpAyQEXKk9AabAvywEk2u9ewIDAQAB

然后手动创建JwtDecoder:

@Slf4j
@Configuration
public class TokenConfig {
    @Value("${jwt.rsa.publickey}")
    private String publicKey;
    public RSAPublicKey rsaPublicKey() {
        try {
            return (RSAPublicKey)RSAUtils.decodePublicKey(publicKey);
        } catch (Exception ex) {
            log.error("生成 KeyPair 失败", ex);
            System.exit(-1);
            return null;
        }
    }
    @Bean
    public NimbusReactiveJwtDecoder nimbusReactiveJwtDecoder() {
        return NimbusReactiveJwtDecoder.withPublicKey(rsaPublicKey())
                .signatureAlgorithm(SignatureAlgorithm.from("RS256")).build();
    }
}

还有一步需要配置,JWT Token解析后的类型是JwtAuthenticationToken,因此需要修改SecurityGlobalFilter中ReactiveSecurityContextHolder.getContext()方法返回的authentication类型(具体参见代码)

运行校验

分别运行auth、resource、gateway服务:

  • gateway - 8080
  • auth - 8081
  • resource - 8082

发起OAuth2密码授权请求: POST http://localhost:8080/blog-oauth/oauth/token,请求参数

client_id:blog
client_secret:blog
grant_type:password
username:admin
password:admin

请求结果:

{
    "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzb3VyY2UiLCJibG9nIl0sImV4X3VzZXJuYW1lIjoiYWRtaW4iLCJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCIsInVzZXIiXSwiZXhwIjoxNjU0NTk4MzY3LCJhdXRob3JpdGllcyI6WyJhZG1pbiJdLCJqdGkiOiIyMmVlOTU4My00Y2U5LTRmNzEtOGI4MS02YjViNzdmYWNlYWEiLCJjbGllbnRfaWQiOiJibG9nIn0.atWwzwpCK1ycjf3-EkPUYs4DMqO7rGPIMwMjHKS3FrTKRjMW5DHkQjtilG2EB8qGNBlwQJo0xAnQ_RNMzOjVojGxyb-TUPCubqODnmnYhuee0ho2TurDT5YzfO-Ypkv2SDqEm6Kw38m-oV_93NofGtKNJD1or2kwdoZe6kn4qgw",
    "token_type": "bearer",
    "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzb3VyY2UiLCJibG9nIl0sImV4X3VzZXJuYW1lIjoiYWRtaW4iLCJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCIsInVzZXIiXSwiYXRpIjoiMjJlZTk1ODMtNGNlOS00ZjcxLThiODEtNmI1Yjc3ZmFjZWFhIiwiZXhwIjoxNjU0ODUwMzY3LCJhdXRob3JpdGllcyI6WyJhZG1pbiJdLCJqdGkiOiJjMzZhNDQ2Mi00ZmE3LTQ2OGUtODNiMS1iYzk4MDFjMzBjMWEiLCJjbGllbnRfaWQiOiJibG9nIn0.IyBeBQMjU-KYGIvvlQTrTkEtrPmTjLZIl1oFvyK0vytOlOFaE4Q5tMOLf1lt1UaBpmi2Tz4ElQSc6EMYX_OKmbyEHSidYxseUr8gE5MVM1raqOPCnR0Dyn7okQ0NvArOB9JuxLTXSa3NoSM3OxRQm2sUS55e6FKpifZ2q7xgGnY",
    "expires_in": 7199,
    "scope": "all user",
    "ex_username": "admin",
    "jti": "22ee9583-4ce9-4f71-8b81-6b5b77faceaa"
}

发起资源服务器请求:POST http://localhost:8080/blog-resource/admin/hello,请求头携带token参数

Authorization:Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzb3VyY2UiLCJibG9nIl0sImV4X3VzZXJuYW1lIjoiYWRtaW4iLCJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCIsInVzZXIiXSwiZXhwIjoxNjU0NTk2OTQyLCJhdXRob3JpdGllcyI6WyJhZG1pbiJdLCJqdGkiOiJkOWRhZjhmYS1jOTY4LTQ2YzQtODMyMi1kOTQxNGU3YWZhY2UiLCJjbGllbnRfaWQiOiJibG9nIn0.Jkz_Tlk1W7opXspihyDkp1VFcIXu3ebfVPkshjFcKpktPmqkUIA4D2aWF5A13fq5QGUIDQKf89rVeGHaFfer657J7kqaax2qNT6yuNgmQAu4C8VQkG01VLDsOa-m9xaZnqR_--Af-Z7FbwpZNOT2pBuyP4M3efnMmGhRQQjB4hQ

返回结果:

{
    "code": 200,
    "data": "Hello Admin"
}

结果符合预期,到此完成JWT + SpringSecurity OAuth2 + SpringCloud Gateway 统一权限访问控制功能

总结

  • JWT自带用户信息,只需要验证Token有效性
  • OAuth授权服务添加JWT TokenService返回JWT格式token
  • Gateway网关服务添加JWTAuthenticationConverter解析JWT信息,同时添加NimbusReactiveJwtDecoder对JWT有效性进行验证

参考文档

Views: 365

SpringCloud与微服务-第7章-API 网关服务 Spring Cloud Gateway

API 网关服务: Spring Cloud Gateway

API 网关是一个更为智能的应用服务器,它的定义类似于面向对象设计模式中的 Façade (门面) 模式,它的存在就像是整个微服务架构系统的门面一样,所有的外部客户端访问都需要经过它来进行调度和过滤。它除了要实现请求路由、负载均衡、校验过滤等功能之外,还需要更多能力,比如于服务治理框架的结合、请求转发时的熔断机制、服务的聚合等一系列高级功能。

门面设计模式, 也叫外观模式, 是一种结构型设计模式, 它为子系统中的一组接口提供一个一致的界面, 此模式定义了一个高层接口, 这个接口使得这一子系统更加容易使用。比如 sl4j,它是一个日志门面,它的实现有 log4j,logback 等。


目标

在本章中,您将学习:

  • 介绍网关的作用和相关产品
  • GateWay 网关的快速入门
  • 路由详解
  • 过滤器详解
  • 动态路由
  • 动态过滤器

API 网关作用

对于整个微服务来说如果将每一个微服务的接口直接暴露给用户是错误的做法,这里主要体现出三个问题:

  1. 服务将所有 API 接口对外直接暴露给用户端,这本身就是不安全和不可控的,用户可能越权访问不属于它的功能,例如普通的用户去访问管理员的高级功能。

  2. 后台服务可能采用不同的通信方式,如服务 A 采用 RESTful 通信,服务 B 采用 RPC 通信,不同的接入方式让用户端接入困难。尤其是 App 端接入 RPC 过程更为复杂。

  3. 在服务访问前很难做到统一的前置处理,如服务访问前需要对用户进行鉴权,这就必须将鉴权代码分散到每个服务模块中,随着服务数量增加代码将难以维护。


bg fit


为了解决以上问题,API 网关应运而生,加入网关后应用架构变为下图所示。


bg fit


引入 API 网关后,在用户端与微服务之间建立了一道屏障,通过 API 网关为微服务访问提供了统一的访问入口,所有用户端的请求被 API 网关拦截并在此基础上可以实现额外功能,例如:

  • 针对所有请求进行统一鉴权、熔断、限流、日志等前置处理,让微服务专注自己的业务。

  • 统一调用风格,通常 API 网关对外提供 RESTful 风格 URL 接口。用户传入请求后,由 API 网关负责转换为后端服务需要的 RESTful、RPC、WebService 等方式,这样便大幅度简化用户的接入难度。

  • 更好的安全性,在通过 API 网关鉴权后,可以控制不同角色用户访问后端服务的权利,实现了服务更细粒度的权限控制。


API 网关是用户端访问 API 的唯一入口,从用户的角度来说只需关注 API 网关暴露哪些接口,至于后端服务的处理细节,用户是不需要知道的。从这方面讲,微服务架构通过引入 API 网关,将用户端与微服务的具体实现进行了解耦。


API 网关技术简介

以上便是 API 网关的作用,那 API 网关有哪些产品呢?

目前,市面上有两种网关组件比较流行。下面,我们将对这两种组件做一个简单的了解。


Spring Cloud Zuul

Spring Cloud Zuul 是 Spring Cloud Netflix 子项目的核心组件之一,可以作为微服务架构中的 API 网关使用,具有动态路由、过滤、压力测试、监控、弹性伸缩和安全等功能 ,为微服务架构中的服务提供了统一的访问入口。Zuul 和 Ribbon 以及 Eureka 相结合,可以实现智能路由和负载均衡的功能,可以将流量按照某种策略分发到集群中的多个实例。

zuul 本质上是一个 web servlet 应用,基于 JavaEE Servlet 技术栈,使用阻塞 API,处理的是 http 请求,没有提供异步支持,不支持任何长连接,比如 websocket。


bg fit


好景不长,后来 Netflix 内部产生分歧,Netflix 官方宣布 Zuul 停止维护,这让 Spring 机构也必须转型。于是 Spring Cloud 团队决定开发自己的第二代 API 网关产品:Spring Cloud Gateway。


Spring Cloud Gateway

与 Zuul 是“别人家的孩子”不同,Spring Cloud Gateway 是 Spring 自己开发的新一代 API 网关产品。它基于 NIO 异步处理,摒弃了 Zuul 基于 Servlet 同步通信的设计,因此拥有更好的性能。同时,Spring Cloud Gateway 对配置进行了进一步精简,比 Zuul 更加简单实用。


Spring Cloud Gateway 是基于 Spring 5.0 、Spring boot 2.0 和 Project Reactor,为微服务提供一个简单有效的网关 API 路由接口。

它作为 Spring Cloud 生态系统的网关,目标是为了代替 Zuul,Spring Cloud Gateway 是基于 webFlux 框架实现的,而 WebFlux 框架底层则使用了高性能的 Reactor 模式通信框架 Netty。
Spring Cloud Gateway 提供统一的路由方式,基于 Filter 链的方式提供了网关基本的功能,例如:安全,监控/指标,和限流。


技术对比

  1. Gateway 对比 Zuul 多依赖了 spring-webflux,内部实现了限流、负载均衡等,扩展性也更强。Zuul 内部没有实现限流、负载均衡等。

  2. Zuul 仅支持同步,Gateway 支持异步。

  3. Gateway 线程开销少,支持各种长连接、websocket,spring 官方支持,但运维复杂,Zuul 编程模型简单,开发调试运维简单,有线程数限制,延迟堵塞会耗尽线程连接资源。

  4. Zuul 1.x,是一个基于阻塞 io 的 API Gateway。Zuul 已经发布了 Zuul 2.x,基于 Netty,也是非阻塞的,支持长连接,但 Spring Cloud 暂时还没有整合计划。Spring Cloud 官方更加推荐的网关组件是 Gateway。


本章重点介绍 Spring Cloud Gateway 的使用方法、配置属性等。下面我们将通过一个快速入门案例,了解网关是如何在我们的项目中发挥作用的。


Spring Cloud Gateway 的关键特征:

  • 基于 JDK 8+ 开发;
  • 基于 Spring Framework 5 + Project Reactor + Spring Boot 2.0 构建;
  • 支持动态路由,能够匹配任何请求属性上的路由;
  • 支持基于 HTTP 请求的路由匹配(Path、Method、Header、Host 等);
  • 过滤器可以修改 HTTP 请求和 HTTP 响应(增加/修改 Header、增加/修改请求参数、改写请求 Path 等等)

当下 Spring Cloud Gateway 已然是 Spring Cloud 体系上 API 网关标准组件。Spring Cloud Gateway 十分优秀,Spring Cloud Alibaba 也默认选用该组件作为网关产品,下面我们就通过实例讲解 Spring Cloud Gateway 的使用办法。


GateWay 快速入门

介绍了一下关于网关服务的概念和作用,在这一节中,不妨用实际的示例来直观的体验一下 Spring Cloud Gateway 是如何使用和运作,并应用到微服务架构中去的。在搭建完网关基础服务之后,我们的整体项目架构将如图所示:


h:16em


构建网关

首先,在实现各种 API 网关服务的高级功能之前,我们需要做一些准备工作,比如,构建起最基本的 API 网关服务,并且搭建几个用于路由和过滤使用的微服务应用等。对于微服务应用,我们可以直接使用之前章节实现的 order-service 和 user-service 。


虽然之前我们一直将 order-service 视为消费者,但是在 Nacos 的服务注册与发现体系中,每个服务既是提供者也是消费者,所以 order-service 实质上也是一个服务提供者。之前我们访问的 http://localhost:8080/consumer/order/ 等一系列接口就是它提供的服务。读者也可以使用自己实现的微服务应用,因为这部分不是本章的重点, 任何微服务应用都可以被用来进行后续的试验。这里,详细介绍一下 API 网关服务的构建过程。


1.引入依赖

创建一个基础的 Spring Boot 工程,命名为 gateway-server,并在 pom.xml 中引入相关依赖,具体如下:



    org.springframework.cloud
    spring-cloud-starter-gateway



    com.alibaba.cloud
    spring-cloud-starter-alibaba-nacos-discovery

引入 Nacos 的服务发现依赖,可以结合 Spring Cloud Gateway 实现服务的拉取和负载均衡。对于 Nacos 的服务发现依赖,可以通过査看它的依赖内容了解到,它还包含了下面这些网关服务需要的重要依赖:

image-20220728114039071

  • spring-cloud-starter-hystrix :该依赖用来在网关服务中实现对微服务转发时候的保护机制,通过线程隔离和断路器,防止微服务的故障引发 API 网关资源无法释放,从而影响其他应用的对外服务。
  • spring-cloud-starter-ribbon :该依赖用来实现在网关服务进行路由转发时候的客户端负载均衡以及请求重试。

2.创建主启动类
@SpringBootApplication
public class GatewayApplication {
    public static void main(String[] args) {
        SpringApplication.run(GatewayApplication.class,args);
    }
}

3.配置文件

创建 application.yml 配置文件,并进行如下配置:


server:
  port: 10010
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: localhost:8848
    gateway:
      routes:
        - id: user-service
          uri: http://localhost:9020
          predicates:
            - Path=/user/**
        - id: order-service
          uri: http://localhost:9010
          predicates:
            - Path=/order/**

完成上面的工作后,通过 Gateway 实现的 API 网关服务就构建完毕了。


4.测试

在上面的配置文件中,我们定义了两组路由规则,现在我们以其中的 order-service 为例,进行访问测试。 首先要启动 Nacos 服务,然后分别启动 gateway-server,order-service,user-service。

启动成功之后,访问: http://localhost:10010/order/101


image-20220728114819131

可以看到,我们访问的是网关路径,但实际上提供请求处理功能的是 order-service 服务。


路由请求

在入门案例中,我们在配置文件中进行了一些简单配置,就可以实现网关的路由功能,那么这些配置起到什么作用呢?


传统路由方式

在以往,我们发送 HTTP 请求至少需要知道服务端的 IP 和端口,这样我们就会固定的将请求路由到该服务端的具体 IP 和端口上。如下所示:


server:
  port: 10010
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: localhost:8848
    gateway:
      routes:
        - id: user-service
          uri: http://127.0.0.1:8081 #路由的目标地址 http就是固定地址
          predicates:
            - Path=/user/**

该配置定义了发往 API 网关服务的请求中,所有符合 http://GATEWAYTHOST:10010/user/** 规则的访问都将被路由转发到http://127.0.0.1:8081/user/** 地址上,也就是说,当访问 http://127.0.0.1:10010/user/** 的时候, API 网关服务会将该请求路由到 http://127.0.0.1:8081/user提供的微服务接口上。其中,配置属性 gateway.routes 下的 id 部分为路由的名字可以任意定义,下面将要介绍的面向服务的映射方式也是如此。


面向服务的路由(添加负载均衡功能)

前面讲的 uri 的配置方式,需要运维人员手动维护各个路由 path 与 url 的关系, 并且当某个服务的实例发生变化时,还需要手动修改路由配置,这样的方式显然不够友好。


为了解决这个问题, Spring Cloud Gateway 实现了与 Spring Cloud Nacos 和 Ribbon 的无缝整合:

  • 可以让路由的 path 不是映射具体的 url, 而是让它映射到某个具体的服务,而具体的 url 则交给 Nacos 的服务发现机制去自动维护,称这类路由为面向服务的路由。
  • 并且,面向服务的路由还可以实现负载均衡的功能,这样就可以让 API 网关服务在转发请求时,将请求均匀的分发到多个服务实例上,从而实现对微服务的负载均衡。

在 Gateway 中使用服务路由也同样简单,我们在入门案例中采用的就是面向服务的路由。

server:
  port: 10010 #网关端口
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: localhost:8848 #nacos 地址
    gateway:
      #网关路由配置
      routes:
        #路由id,自定义,只要唯一即可
        - id: user-service
          #路由的目标地址  lb就是负载均衡,后面跟着服务名称
          uri: lb://userservice
          #路由断言,也就是判断请求是否符合路由规则的条件
          predicates:
            #按照路径匹配,只要以/user/开头就符合要求 将会被路由到该组配置下的userservice服务中处理
            - Path=/user/**
        #可以自定义配置多组路由id,只要唯一即可
        - id: order-service
          uri: lb://orderservice
          predicates:
            - Path=/order/**

针对之前准备的两个微服务应用 order-service 和 user-service, 在上面的配置中分别定义了两个名为 order-service 和 user-service 的路由来映射它们。另外,通过指定 Nacos Server 服务注册中心的位置,除了将自己注册成服务之外,同时也让 Gateway 能够获取 order-service 和 user-service 服务的实例清单,以实现 path 映射服务,再从服务中挑选实例来进行请求转发的完整路由机制。


在完成了上面的服务路由配置之后,可以将 nacos-server,order-service 和 user-service 以及这里用 Spring Cloud Gateway 构建的 gateway-server 都启动起来,并且我们可以启动多个 order-service 和 user-service 的实例。启动完毕,在 nacos-server 的信息面板中,可以看到,除了 order-service 和 user-service 之外,多了一个网关服务 gateway,并且 order-service 和 user-service 的实例不止一个。


image-20220728210322861


通过面向服务的路由配置方式,不需要再为各个路由维护微服务应用的具体实例的位置,而是通过简单的 path 与 serviceld 的映射组合,使得维护工作变得非常简单。这完全归功于 Spring Cloud Alibaba Nacos 的服务发现机制,它使得 API 网关服务可以自动化完成服务实例清单的维护,完美地解决了对路由映射实例的维护问题。


开启动态路由

在上面的案例中,我们已经实现了基于 Nacos 服务发现机制的面向服务的路由配置,但是这种路由配置方式还是有一个问题,那就是当我们新增了一个微服务应用时,需要手动去配置一个新的路由,这样的话,就不是很符合微服务的敏捷开发理念了。


为了解决这个问题, Spring Cloud Gateway 提供了动态路由的功能,它可以让我们在不重启网关服务的情况下,实现路由的动态刷新。在 Spring Cloud Gateway 中,动态路由的配置信息是存储在内存中的,所以我们可以通过 Spring Cloud Gateway 提供的 API 来实现动态路由的刷新。


动态路由默认是关闭的, 如果需要开启, 修改配置:

server:
  port: 10010 #网关端口
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: localhost:8848 #nacos 地址
    gateway:
      discovery:
        #网关路由配置
        locator:
          #是否开启动态路由
          enabled: true
        #统一使用小写的微服务ID
        lower-case-service-id: true

在上面的配置中,我们通过 locator.enabled 属性来开启动态路由的功能,然后通过 lower-case-service-id 属性来统一使用小写的微服务 ID。这样,当我们新增了一个微服务应用时,只需要在 Nacos Server 中注册一个新的服务,然后在网关服务中调用 Spring Cloud Gateway 提供的 API,就可以实现动态路由的刷新。

比如, userservice 中有一个接口/user/{id}, 我们可以通过调用网关服务的动态路由进行调用 http://localhost:10010/userservice/user/1 , 非常方便。


动态路由也有一些限制, 比如需要调用的微服务必须能通过服务发现机制访问, 无法定制路由的规则 ,必须暴露所有的微服务接口等。

Gateway 也可以通过 Actuator API 接口实现动态路由的添加和删除, 这里不作介绍。


在 Spring Cloud Gateway 中,动态路由的刷新是通过 Spring Cloud Gateway 提供的 RouteLocator 接口来实现的。在 RouteLocator 接口中,有一个 refresh() 方法,它可以用来刷新路由信息。在 Spring Cloud Gateway 中,RouteLocator 接口的实现类有两个,一个是 PropertiesRouteLocator,另一个是 DiscoveryClientRouteLocator。其中, PropertiesRouteLocator 是通过配置文件来实现路由信息的加载,而 DiscoveryClientRouteLocator 是通过服务发现机制来实现路由信息的加载。


路由详解

在快速入门 一节的请求路由示例中,对 Spring Cloud Gateway 中的两类路由功能己经做了简单的使用介绍。在本节中,将进一步详细介绍关于 Spring Cloud Gateway 的路由功能,以方便用户更好地理解和使用它。


断言工厂

Spring Cloud Gateway 将路由作为 Spring WebFlux HandlerMapping 基础设施的一部分进行匹配。Spring Cloud Gateway 包括许多内置的路由断言工厂。所有这些断言都匹配 HTTP 请求的不同属性。您可以将多个路由断言工厂与逻辑和语句组合在一起。

在上一节快速入门中,同学们或许已经注意到配置文件有一个关键词:predicates ,这个就是路由断言工厂的配置标识。我们在此基础上进行路由配置。


spring:
  application:
    name: gateway
  cloud:
        gateway:
      routes:
        - id: user-service
          uri: lb://userservice
          predicates:
            - Path=/user/**

我们在配置文件中写的断言规则只是字符串,这些字符串会被 Predicate Factory 读取并处理,转变为路由判断的条件,例如 Path=/user/\*\*是按照路径匹配,这个规则是由 org.springframework.cloud.gateway.handler.predicate.PathRoutePredicateFactory 类来处理的。

该配置的含义是,当请求路径以/user开头,那么 Gateway 网关就会把请求路由到 userservice 对应的实例上进行请求处理。如果请求路径不匹配该配置,则会寻找其他的配置项进行匹配。 假如最后所有的匹配项都不满足,网关将会响应一个包含 HTTP 404 状态码的响应信息。


PathRoutePredicateFactory 是我们在实际生产中最常用的一种断言工厂,除了 PathRoutePredicateFactory 之外,像这样的断言工厂在 SpringCloudGateway 还有十几个:


名称 说明 示例
After 是某个时间点后的请求 - After=2037-01-20T17:42:47.789-07:00[America/Denver]
Before 是某个时间点之前的请求 - Before=2031-04-13T15:14:47.433+08:00[Asia/Shanghai]
Between 是某两个时间点之前的请求 - Between=2037-01-20T17:42:47.789-07:00[America/Denver], 2037-01-21T17:42:47.789-07:00[America/Denver]
Cookie 请求必须包含某些 cookie - Cookie=chocolate, ch.p
Header 请求必须包含某些 header - Header=X-Request-Id, \d+
Host 请求 host(域名)必须包含 - Host= .somehost.org,.anotherhost.org
Method 请求方式必须是指定方式 - Method=GET,POST
Path 请求路径必须符合指定规则 - Path=/red/{segment},/blue/**
Query 请求参数必须包含指定参数 - Query=name, Jack 或者 - Query=name
RemoteAddr 请求者的 ip 必须是指定范围 - RemoteAddr=192.168.1.1/24

在 Spring Cloud 的官方文档上,对于断言工厂的用法有详细的介绍,具体可以参考如下网址: https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#gateway-request-predicates-factories


现在,我们以 After 为例,对入门案例进行改造。在配置文件中,我们做如下改动:


server:
  port: 10010 #网关端口
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: localhost:8848 #nacos 地址
    gateway:
      #网关路由配置
      routes:
        #路由id,自定义,只要唯一即可
        - id: user-service
          #路由的目标地址  lb就是负载均衡,后面跟着服务名称
          uri: lb://userservice
          #路由断言,也就是判断请求是否符合路由规则的条件
          predicates:
            #按照路径匹配,只要以/user/开头就符合要求 将会被路由到该组配置下的userservice服务中处理
            - Path=/user/**
        #可以自定义配置多组路由id,只要唯一即可
        - id: order-service
          uri: lb://orderservice
          predicates:
            - Path=/order/**
            - After=2030-01-20T17:42:47.789-07:00[Asia/Shanghai]

我们在配置文件中添加了一条时间信息的配置,该配置表示访问/order/**的请求,同时还要满足另一个条件,那就是该请求的当前时间必须在规定的日期之后。只有同时满足路径信息和时间信息的规则,请求才会被路由到 orderservice 进行处理。


像入门案例一样,做好启动服务的准备工作之后,我们访问:http://localhost:10010/order/101, 如果在整个配置中找不到合适的路由匹配,那将如下所示:

image-20220729124406464


然后我们将 After 所指定的时间改为一个可以被断言通过的时间,比如:

- After=2020-01-20T17:42:47.789-07:00[Asia/Shanghai]

重启网关服务,再次访问:http://localhost:10010/order/101,可以看到如下结果:

image-20220729125454080

由此可以看出,After 所配置的规则,可以控制请求访问是否被允许。目前最新版本的断言工厂一共有十二种,用法大致类似,具体的使用案例我们可以在官方文档中轻松找到,此处不再一一赘述。


过滤器详解

在本章一开始的技术简介中,我们了解到 Spring Cloud Gateway 基于 Filter 链的方式提供了网关基本的功能,例如:安全,监控/指标,和限流。在本节中,将对 Gateway 的请求过滤器功能做进一步的介绍和总结。


路由过滤器

GatewayFilter 是网关中提供的一种过滤器,可以对进入网关的请求和微服务返回的响应做处理。在快速入门小节,当我们的请求被网关接受到之后,该请求就会立刻被路由到相应的微服务端吗? 其实并不是的,在请求被真正路由到微服务之前,其实还会经理一系列的过滤器的过滤,最后才会到达微服务。


bg right fit

客户端向 Spring Cloud Gateway 发出请求。如果网关处理程序映射确定请求与路由匹配,则将其发送到网关 Web 处理程序。

此处理程序通过特定于请求的过滤器链运行请求。过滤器用虚线划分的原因是过滤器可以在发送代理请求之前和之后运行逻辑。执行所有“预”过滤器逻辑。然后发出代理请求。发出代理请求后,将运行“发布”过滤器逻辑。


路由过滤器允许以某种方式修改传入的 HTTP 请求或传出的 HTTP 响应。路由过滤器的范围是特定的路由。Spring Cloud Gateway 包含许多内置的 GatewayFilter 工厂。

目前最新版本的 Spring Cloud Gateway 的官方文档,一共有 33 种路由过滤器,以后可能还有更多路由过滤器加入进来。同学们可以访问该文档: https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#gatewayfilter-factories 进行查看和学习。


如此之多的过滤器,我们不必每一个都进行学习。我们学会其中比较常用的一些过滤器的用法即可,至于其他的过滤器,往往在我们具体使用到它们的时候再去查看具体的官方文档。下面我们列举部分的路由过滤器:

名称 说明
AddRequestHeader 给当前请求添加一个请求头
RemoveRequestHeader 移除请求中的一个请求头
AddResponseHeader 给响应结果中添加一个响应头
RemoveResponseHeader 从响应结果中移除有一个响应头
RequestRateLimiter 限制请求的流量

AddRequestHeader

从名字上可以轻易看出来这个过滤器的功能,顾名思义是给当前请求添加一个请求头。现在,我们完成下面这个需求:

给所有进入userservice的请求添加一个请求头:Description=NIIT is freaking awesome!

完成这个需求非常简单,我们只需要修改 gateway 服务的 application.yml 文件,添加路由过滤即可:

server:
  port: 10010
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: localhost:8848
    gateway:
      routes:
        - id: user-service
          uri: lb://userservice
          predicates:
            - Path=/user/**
          filters:
            - AddRequestHeader=Description,NIIT is freaking awesome!

当前过滤器写在 userservice 路由下,因此仅仅对访问 userservice 的请求有效,而对于访问 orderservice 的请求则无效。AddRequestHeader 的值由两部分组成,并以英文逗号分隔。逗号左边是请求头的 name,而右边则是该请求头所对应的具体的信息。

现在,我们在 userservice 中使用 Spring MVC 的@RequestHeader 注解,接收 Description 请求头的值。


@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserService userService;

    @GetMapping("/{id}")
    public User queryById(@PathVariable("id") String id
            ,@RequestHeader(value = "Description",required = false)String description) {
        System.out.println("接受到的请求头Description的值是: " + description);
        return userService.queryById(id);
    }
}

重启 Gateway 和 userservice 之后,我们访问:http://localhost:10010/user/1,可以在 IDEA 的控制台看到如下信息:

image-20220729172346325


其余三十余种路由过滤器,其用法与上述例子非常类似,因此我们不再进行一一赘述。具体的使用方式,可以进行查阅官方文档进行学习使用。


默认过滤器

上述例子是只针对某个具体的微服务生效的路由过滤器的配置方式,但是我们如果想要对所有的微服务添加同样的过滤器,那应该怎么办呢? 最容易想到的办法就是在每组微服务的路由下面加上相同的配置。这样做虽然可以实现需求,但是缺点也很明显,不但工作量大,而且容易出错,在后续的维护中也会增加更多的工作量。


Spring Cloud Gateway 的默认过滤器,正是为了解决这个问题而设计的。如果要对所有的路由都生效,则可以将过滤器工厂写到 default 下。格式如下:

spring:
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: lb://userservice
          predicates:
            - Path=/user/**
      default-filters: # 默认过滤项
        - AddRequestHeader=Description,NIIT is freaking awesome!

注意:default-filters 的层级与 routes 层级相同,可以对所有的路由都生效。


全局过滤器

上一节学习的路由过滤器,Spring Cloud Gateway 官方文档目前一共提供了 33 种,但每一种过滤器的作用都是固定的。如果我们希望拦截请求,做自己的业务逻辑则没办法实现。那么如果我们想自定义过滤器的实现逻辑,有什么办法呢? 我们可以实现 GlobalFilter 接口,编写自己的业务逻辑。


全局过滤器作用

全局过滤器的作用与 GatewayFilter 的作用一样,也是处理一切进入网关的请求和微服务响应。区别在于 GatewayFilter 通过配置定义,处理逻辑是固定的,而 GlobalFilter 的逻辑需要自己写代码实现。


public interface GlobalFilter {
    /**
     *  处理当前请求,有必要的话通过 GatewayFilterChain将请求交给下一个过滤器处理
     *
     * @param exchange 请求上下文,里面可以获取Request、Response等信息
     * @param chain 用来把请求委托给下一个过滤器
     * @return  Mono 返回标示当前过滤器业务结束
     */
    Mono filter(ServerWebExchange exchange, GatewayFilterChain chain);
}

我们可以实现 GlobalFilter 接口,在 filter 函数中编写自定义逻辑,实现下列功能:

  • 登录状态判断
  • 权限校验
  • 请求限流等

自定义全局过滤器

我们可以使用全局过滤器实现自定义业务逻辑,现在就让我们完成一个小案例感受一下吧。首先,明确一下我们的需求:

需求:定义全局过滤器,拦截请求,判断请求的参数是否满足下面条件:
    1.参数中是否有auth
    2.auth参数值是否为niit
必须同时满足才放行,否则拦截

接下来,我们就动手实践一下吧。

创建一个类,实现 GlobalFilter 接口:


@Order(-1)
@Component
public class MyAuthFilter implements GlobalFilter {
    @Override
    public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        //获取请求参数
        MultiValueMap queryParams = exchange.getRequest().getQueryParams();
        //获取auth参数
        String auth = queryParams.getFirst("auth");
        //判断是否符合要求
        if (auth != null) {
            if ("niit".equals(auth)) {
                //放行,交给过滤器链上的其他过滤器执行
                return chain.filter(exchange);
            }
        }
        //设置状态码
        exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
        //拦截请求
        return exchange.getResponse().setComplete();
    }
}

编写完成之后,我们重启网关,重新访问:http://localhost:10010/order/101,结果如图所示:

image-20220730100835237


由于请求中没有 auth=niit 的这样一个参数,无法通过自定义过滤器的逻辑校验。我们给请求中添加参数,访问:http://localhost:10010/order/101?auth=niit ,结果如图所示,可以再次正常访问:

image-20220730101200580


通过这个简单的案例,我们学会了如何自定义实现过滤器逻辑。当然,我们这里的案例是最简单的一种使用,在实际生产中肯定会有更为复杂的需求,但是基本原理我们掌握了,那么复杂的需求我们只需一步一步拆分实现即可。


过滤器链执行顺序

在上一节的自定义实现过滤器小案例中,细心的同学可能已经注意到我们在编写实现代码的时候,MyAuthFilter 类上除了@Component 这个注解外,还有一个注解@Order(-1)。 这个注解有什么用呢? order,顾名思义是顺序的意思,这个注解的作用正是为了给过滤器进行排序的。下面我们来探究一下过滤器的执行顺序。


我们现在一共接触了三大类过滤器: 当前路由的过滤器(三十多种)、DefaultFilter(默认过滤器)、GlobalFilter(全局过滤器)。网关接受请求并确定路由后,会将当前路由过滤器和 DefaultFilterGlobalFilter,合并到一个过滤器链(集合)中,排序后依次执行每个过滤器,在默认情况下,三类过滤器的执行顺序如下:

h:10em


我们知道,每一类的过滤器,在整个的过滤器链上可能都不止一个,那么它们具体的排序规则又是什么呢? 我们先从最容易理解的全局过滤器开始。

我们可以在自定义的全局过滤器上添加@Order 注解进行排序,这个很好理解。


/**
@Order 定义注释组件的排序顺序。该值是可选的,表示 Ordered 接口中定义的顺序值。
较低的值具有较高的优先级。默认值为 Ordered.LOWEST_PRECEDENCE,表示最低优先级(输给任何其他指定的order值)。
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD})
@Documented
public @interface Order {
   /**
    * The order value.
    * 

Default is {@link Ordered#LOWEST_PRECEDENCE}. * @see Ordered#getOrder() */ int value() default Ordered.LOWEST_PRECEDENCE; }


通过源码注释,我们得知如下结论:

  • @Order 注解的值越小,优先级越高,默认是最低优先级。

我们可以给自定义全局过滤器添加@Order 注解进行排序,那么默认过滤器和路由过滤器的排序顺序又是怎么样的呢?目前,我们只能对其进行配置,而无法手动添加 order 的值,这两类过滤器的顺序由 Spring 框架帮我们指定。每一类的过滤器的 order 值,默认是按照在配置文件中的声明顺序从 1 递增。


现在我们对 Gateway 服务的配置文件进行改动,增加一些过滤器,然后我们通过 IDEA 的断点调试功能来观察它们的执行顺序:


server:
  port: 10010
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: localhost:8848
    gateway:
      routes:
        - id: user-service
          uri: lb://userservice
          predicates:
            - Path=/user/**
          filters:
            - AddRequestHeader=Description,userservice AddRequestHeader filter has done!
        - id: order-service
          uri: lb://orderservice
          predicates:
            - Path=/order/**
            - After=2020-01-20T17:42:47.789-07:00[Asia/Shanghai]
          filters:
            - AddRequestHeader=Description,orderservice AddRequestHeader filter has done!
      default-filters:
        - AddRequestHeader=Description,NIIT is freaking awesome!
        - AddRequestParameter=Hello, World!
        - AddRequestHeader=Description, NIIT is amazing good!

配置文件做如上改动之后,我们在 AddRequestParameterGatewayFilterFactory 类的 filter 函数处打上断点,以 Debug 模式启动 Gateway 并重新访问:http://localhost:10010/order/101?auth=niit


image-20220730120155887


仔细观察之后,最终可以得出如下结论:

  • 路由过滤器和 defaultFilterorder 值由 Spring 指定,默认是按照声明顺序从 1 递增。
  • 当这两类过滤器的 order 值一样时,会按照 defaultFilter -> 路由过滤器的顺序执行。
  • 自定义全局过滤器总是在这两类过滤器执行之后再执行,多个自定义全局过滤器的执行顺序由其@Order 注解的值进行排序,@Order 注解的值越小,越先执行。

跨域配置

在实际生产中,网关不可避免的会接收到跨域 AJAX 请求,如果不做处理,浏览器会默认将跨域 AJAX 请求进行拦截。我们之前在学习 Servlet 的时候已经知道可以使用 CORS 的解决方案进行解决,而 Gateway 是基于 WebFlux 构建的网关服务,此前针对与 Servlet 体系所学习的方法不一定适用。但是我们即使没有学过 WebFlux 也不用担心,Gateway 底层已经将跨域问题的处理逻辑实现好了,我们只用进行简单配置即可。


跨域问题

跨域: 协议://域名:端口任何一个出现不一致都会导致跨域,主要包括:

  • 协议不同, 例如 http://xxxhttps://xxx
  • 域名不同: 例如 www.baidu.comwww.taobao.org
  • 域名相同,端口不同:localhost:8080localhost:8081

跨域问题:浏览器禁止请求的发起者与服务端发生跨域 ajax 请求,当出现跨域请求时浏览器会拦截该请求的问题。

因为跨域导致的报错示例: Access to XMLHttpRequest at 'http://localhost:10010/order/101?auth=niit' from origin 'http://localhost:8090' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.


解决方案

修改 Gateway 服务的 application.yml 文件:


server:
  port: 10010
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr: localhost:8848
    gateway:
      routes:
        ...
      default-filters:
        ...
      globalcors: # 全局的跨域处理
        add-to-simple-url-handler-mapping: true # 解决options请求被拦截问题
        corsConfigurations:
          '[/**]': #匹配哪些请求
            allowedOrigins: # 允许哪些网站的跨域请求
              - "http://localhost:8090"
              - "http://www.niit.com"
            allowedMethods: # 允许的跨域ajax的请求方式
              - "GET"
              - "POST"
              - "DELETE"
              - "PUT"
              - "OPTIONS"
            allowedHeaders: "*" # 允许在请求中携带的头信息
            allowCredentials: true # 是否允许携带cookie
            maxAge: 360000 # 这次跨域检测的有效期 (秒)

Gateway 实现灰度发布

灰度发布是指在黑与白之间的过渡阶段,我们可以将灰度发布理解为一种渐进式发布,即将新版本的服务逐步引入到生产环境中,而不是一次性将新版本的服务全部引入到生产环境中。灰度发布的好处在于,当新版本的服务出现问题时,我们可以快速的回滚到旧版本的服务,而不会影响到整个系统的稳定性。


灰度发布的实现

灰度发布的实现方式有很多种,例如: 通过 Nginx 的反向代理实现灰度发布、通过 DNS 的负载均衡实现灰度发布、通过服务网关实现灰度发布等。而我们在这里主要介绍通过服务网关实现灰度发布的方式。


灰度发布的实现原理

灰度发布的实现原理是:通过服务网关对请求进行拦截,然后根据请求的特征信息(例如:请求的 IP 地址、请求的参数、请求的 Header 等)将请求转发到不同的服务实例中。例如: 发布新版本时, 谨慎起见, 为了提高发布质量, 降低风险, 减少全量发布带来的服务中断影响, 在老版本代码持续提供的基础上, 只切换一小部分流量到部署新版本代码的机器上测试, 来实现灰度发布.


灰度发布的实现步骤

分别在 Nacos 上运行老代码账户微服务(使用端口 3333), 新代码账户微服务,(使用端口 5555), 网关项目(使用端口 8080)


网关项目的配置如下:

server:
  port: 8080
spring:
  cloud:
    gateway:
      routes:
        - id: oldVersion_Route
          uri: http://localhost:3333/getAccount/{id}
          predicates:
            - Path=/getAccount/{id}
            - Weight=accountGroup, 9
        - id: newVersion_Route
          uri: http://localhost:5555/getAccount/{id}
          predicates:
            - Path=/getAccount/{id}
            - Weight=accountGroup, 1

这里是通过分组权重的设置实现流量控制的, 其中组 accountGroup 总权重为 10, 其中 oldVersion_Route 占 9, newVersion_Route 占 1.

测试:

http://localhost:8080/getAccount/123
->In Old Version, account Info, id is:123 (90%几率)
->In New Version, account Info, id is:123 (10%几率)

因此平均每 10 次请求 getAccount, 会有一次使用新 Api.


Gateway 添加 Hystrix 支持

网关项目添加 hystrix 依赖 spring-cloud-starter-netflix-hystrix 依赖,开启 hystrix 支持:

        
            org.springframework.cloud
            spring-cloud-starter-netflix-hystrix
        

配置实例

hystrix.command.fallbackcmd.execution.isolation.thread.timeoutInMilliseconds: 5000
spring:
  cloud:
    gateway:
      discovery:
        locator:
          enabled: true
      routes:
      - id: employee-service
        uri: lb://employee-service
        predicates:
        - Path=/employee/**
        filters:
        - RewritePath=/employee/(?.*), /$\{path}
        - name: Hystrix
          args:
            name: fallbackcmd
            fallbackUri: forward:/defaultfallback

  • 首先 filter 里头配置了 name 为Hystrix的 filter,实际是对应HystrixGatewayFilterFactory
  • 然后指定了 hystrix filter 的名称name,及fallbackUri,注意 fallbackUri 要以forward开头, 表示如果发生降级则转发到指定的 uri 上
  • 最后通过hystrix.command.fallbackcmd.execution.isolation.thread.timeoutInMilliseconds指定该 command 的超时时间

RewritePath filter 的作用是将请求的路径重写,这里是将/employee/xxx重写为/xxx,这样就可以将请求转发到 employee-service 服务的/xxx路径上.


fallback 示例:

@RestController
@RequestMapping("/defaultfallback")
public class FallbackController {

    @RequestMapping("")
    public String fallback(){
        return "服务器繁忙,请稍后再试!";
    }
}

总结:

spring cloud gateway 集成 hystrix,分为如下几步:

  1. 添加 spring-cloud-starter-netflix-hystrix 依赖
  2. 在对应 route 的 filter 添加 name 为 Hystrix 的 filter,同时指定 hystrix command 的名称,及其 fallbackUri(可选)
  3. 指定该 hystrix command 的超时时间等。

Hystrix GatewayFilter Factory - HystrixGatewayFilterFactory


活动 7.1

使用 Gateway 网关+Nacos 实现微服务统一调用


练习问题


  1. Spring Cloud Gateway 不支持异步。
    a. 错误
    b. 正确

    查看答案

    a


  1. Spring Cloud Gateway 内置了许多 Route Predicate 工厂。这些 PredicateFactory 都与 HTTP 请求的不同属性匹配。多个 RoutePredicateFactory 可以进行组合。
    a. 错误
    b. 正确

    查看答案

    b


  1. Spring Cloud Gateway 中不包含以下哪类过滤器?

    a.路由过滤器
    b.服务过滤器
    c.默认过滤器
    d.全局过滤器

    查看答案

    b


  1. 浏览器禁止请求的发起者与服务端发生跨域 ajax 请求,当出现跨域请求时浏览器不会拦截该请求。

    a. 错误
    b. 正确

    查看答案

    a


小结

在本章中,您学习了:

  • 使用 Spring Cloud Gateway 构建起最基本的 API 网关服务。
  • 为网关服务添加请求路由的功能。
  • 添加相应的请求过滤。
  • 断言工厂
  • 过滤器
  • 路由过滤器
  • 默认过滤器
  • 全局过滤器
  • 跨域问题处理

Views: 43

SpringCloud与微服务-第6章-声明式服务调用(OpenFeign)

Spring Cloud OpenFeign 基于 Netflix Feign 实现,整合了 Spring Cloud Ribbon 与 Spring Cloud Hystrix ,除了提供这两者的强大功能之外,它还提供了一种声明式的 Web 服务客户端定义方式,让我们可以像使用本地方法一样来进行远程服务调用。

快速入门

通过一个简单的示例来展现 Spring Cloud Feign 在服务客户端定义上所带来的便利。

下面的示例将继续使用之前我们实现的 order-service 服务,这里我们会通过 Spring Cloud Feign 提供的声明式服务绑定功能来实现对该服务接口的调用。

配置代码

1.引入依赖

orderservice 服务的 pom.xml 文件中,引入如下依赖:



    org.springframework.cloud
    spring-cloud-starter-openfeign

openfeig 依赖中已然包含了 ribbon 的依赖


2.启用 Feign

在 orderservice 的启动类上,添加注解@EnableFeignClients

@MapperScan("com.niit.order.mapper")
@SpringBootApplication
@EnableFeignClients
public class OrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}

3.定义服务接口

定义一个接口,通过@FeignClient 注解指定服务名来绑定服务,然后再使用 Spring MVC 的注解来绑定具体该服务提供的 REST 接口。

@FeignClient("userservice")
public interface UserClient {
    /**
     * 根据ID获取用户信息
     */
    @GetMapping("/user/{id}")
    User findUserById(@PathVariable("id") String id);
}

注意:
注解中的服务名不区分大小写,所以使用 USERSERVICEuserservice 都 是可以的。

注意接口的访问路径要完整, 如果匹配的路径不对, 会报 404 错误.

4.创建 Controller

创建一个 Controller 来实现对 Feign 客户端的调用。使用 @Autowired 直接注入上面定义的 UserClient 实例,并在 helloConsumer 函数中调用这个绑定了 userservice 服务接口的客户端来向该服务发起接口的调用。


@RestController
@RequestMapping("/feign")
public class FeignDemoController {
    @Autowired
    private OrderService orderService;
    @Autowired
    private UserClient userClient;

    @GetMapping("/order/{orderId}")
    public Order queryOrderByUserId(@PathVariable("orderId") Long orderId) {
        // 根据id查询订单并返回
        Order order = orderService.queryOrderById(orderId);
        // 使用 Feign 封装的客户端
        User forObject = userClient.findUserById(order.getUserId()+"");
        //封装返回的数据
        order.setUser(forObject);
        return order;
    }
}

测试验证

如之前验证 Ribbon 客户端负载均衡一样,我们先启动服务注册中心 Nacos 以及 orderservice 服务和多个 userservice 服务。此时我们在 Nacos 信息面板中可看到 如下内容:


image-20220725233238207


发送几次 GET 请求到 http://localhost:8080/feign/order/{orderId}, 可以得到如 之前 Ribbon 实现时一样的效果,正确返回了 userservice 的数据。

image-20220725233533961


并且根据控制台的输出,我们可以看到 Feign 实现的消费者,依然是利用 Ribbon 维护了针对 userservice 的服务列表信息,并且通过轮询实现了客户端负载均衡。而与 Ribbon 不同的是,通过 Feign 我们只需定义服务绑定接口,以声明式的方法,优雅而简单地实现了服务调用。

Feign 使用起来看起来更像是一个本地接口的实现,而不是一个远程服务的调用。这样的设计使得我们在使用 Feign 的时候,可以像调用本地方法一样调用远程服务。

小问题 :
RestTemplate 己经实现了对 __ 请求的封装处理,形成了一套模板化的调用方法?

  1. FTP
  2. HTTP
  3. TCP
  4. IP
答案
2. HTTP

参数绑定

在本节中,我们将详细介绍 Feign 中对几种不同形式参数的绑定方法。


扩展一下服务提供方 orderservice 。增加下面这些接口定义,其中包含:

  1. 带有 Request 参数的请求
  2. 带有 Header 信息的请求
  3. 带有 RequestBody 的请求并且请求响应体中是一个对象的请求。

@RestController
@RequestMapping("/provider/feign")
public class FeignDemoController {
    /* request参数的请求 */
    @GetMapping(value = "/request")
    public String hello (@RequestParam("name") String name) {
        return "Hello" + name;
    }
    /* header信息的请求 */
    @GetMapping(value = "/header" )
    public User hello(@RequestHeader String name, @RequestHeader String address) {
        User user = new User();
        user.setId(IdUtil.simpleUUID());
        user.setUsername(name);
        user.setAddress(address);
        return user;
    }
    /* requestbody的请求 */
    @PostMapping(value = "/body")
    public String hello(@RequestBody User user) {
        return "Hello, " + user .getUsername() + ", " + user .getAddress();
    }
}

实体类 User 的定义如下:

@Data
public class User {
    private String id;
    private String username;
    private String address;
}

需要注意的是,这里必须要有 User 的默认构造函数。不然, Spring Cloud Feign 根据 JSON 字符串转换 User 对象时会抛出异常。我们使用 Lombok 插件的 @Data 注解, 可以避免编写标准 java bean 的繁琐步骤,同时提供相应的默认构造。


在完成了对 orderservice 的改造之后,下面我们开始在快速入门示例的 UserClient 接口中实现这些新增的请求的绑定。


@FeignClient("userservice")
public interface UserClient {
    /**
     * 根据ID获取用户信息
     * @param id
     * @return
     */
    @GetMapping("/user/{id}")
    User findUserById(@PathVariable("id") String id);

    /**
     * request参数的请求
     * @param name
     * @return
     */
    @GetMapping(value = "/provider/feign/request")
    public String hello (@RequestParam("name") String name);

    /**
     * header信息的请求
     * @param name
     * @param address
     * @return
     */
    @GetMapping(value = "/provider/feign/header" )
    public User hello(@RequestHeader String name, @RequestHeader String address);

    /**
     * requestbody的请求
     * @param user
     * @return
     */
    @PostMapping(value = "/provider/feign/body")
    public String hello(@RequestBody User user) ;
}

最后,在 Controller 中新增方法,来对本节新增的声明接口进行调用,修改后的完整代码如下所示:


@RestController
@RequestMapping("/feign")
public class FeignConsumerController {
    @Autowired
    private OrderService orderService;
    @Autowired
    private UserClient userClient;

    @GetMapping("/order/{orderId}")
    public Order queryOrderByUserId(@PathVariable("orderId") Long orderId) {
        // 根据id查询订单并返回
        Order order = orderService.queryOrderById(orderId);
       // 使用Feign 封装的客户端
        User forObject = userClient.findUserById(order.getUserId()+"");
        //封装返回的数据
        order.setUser(forObject);
        return order;
    }

    @GetMapping("/order/request")
    public String requestParamDemo(@RequestParam String param){
        return userClient.requestParam(param);
    }

    @GetMapping("/order/header")
    public User headerInfo(@RequestHeader String name,@RequestHeader String address){
        return userClient.headerInfo(name,address);
    }

    @GetMapping("/order/body")
    public String bodyInfo(@RequestBody User user){
        return userClient.bodyInfo(user);
    }

}

测试验证

在完成上述改造之后,启动服务注册中心 Nacos、order-service 服务以及 user-service 服务 。通过浏览器访问 order-service 的 FeignConsumerController 的 requestParamDemo 方法, 触发新增接口的调用。最终,我们会获得如下输出,代表接口绑定和调用成功:

image-20220726111444264


其他两种参数绑定的调用,我们可以使用 IDEA 的一款小插件FastRequest进行测试:(FastRequest 是收费插件, 可以用免费插件 RestfulTool 代替)

添加请求头:

image-20220726112008604


点击发送并查看响应结果:

image-20220726112113576


同样的, RequestBody 的参数也可以添加请求体,点击发送按钮进行测试,结果如下:

image-20220726111906304

实践方案

我们几乎完全可以从服务提供方的 Controller 中依靠复制操作,构建出相应的服务客户端绑定接口, 然后添加@FeignClient 注解即可。使用的时候,直接注入到 Controller 中即可。

这部分内容是否可以得到进一步的抽象呢?在 Spring Cloud Feign 中,针对该问题我们通常有两种解决方案,以进一步减少编码量。

  1. 通过继承的方式实现接口定义的共享
  2. 创建独立的 Api 模块, 通过依赖的方式实现接口定义的共享

继承方案

一样的代码可以通过继承来共享:

  1. 定义业务相关接口,利用接口定义,并基于 SpringMVC 注解做声明。

    • 注意定义 MappingUrl 时,需要使用绝对路径,否则会出现 404 错误。
  2. Feign 客户端和 Controller 都继承该接口。

    • 这样接口实际只需要定义一次,就可以在 Feign 客户端和 Controller 中使用。免去了复制操作。

image-20220726151709040

图示中, UserClient 继承了 UserApi 接口。这样,我们就可以在 UserClient 中直接使用 UserApi 中定义的接口,而不需要再次定义。在 Controller 中, 也可以直接注入 UserClient,并调用其中的方法。


使用 Spring Cloud Feign 继承特性的优点很明显,可以将接口的定义从 Controller 中剥离,实现在构建期的接口绑定,从而有效减少服务客户端的绑定配置。这么做虽然可以很方便地实现接口定义和依赖 的共享,不用再复制粘贴接口进行绑定.

但是这样通过共享接口的做法使用不当的话会带来副作用。


由于接口在构建期间就建立起了依赖,所以微服务之间的耦合度会变得很高。因此接口变动就会对项目构建造成影响。

可能服务提供方修改了一个接口定义,那么会直接导致客户端工程的构建失败。所以,如果开发团队通过此方法来实现接口共享的话,建议在开发评审期间严格遵守面向对象的开闭原则,尽可能地做好前后版本的兼容,防止牵一发而动全身的后果,增加团队不必要的维护工作量。


基于以上原因,官方并不推荐在项目中使用继承方案,而是更推荐我们由服务提供方新建立一个 API 服务,提供给所有的消费者使用。接下来,我们将会详细讲解这种实践方案。


独立模块

将 Feign 的 Client 抽取为独立的微服务模块,并且把接口有关的 POJO、默认的 Feign 配置都放到这个微服务模块中,提供给所有消费者微服务项目使用。例如,将 UserClient、User、Feign 的默认配置都抽取到一个 feign-api 包中,所有微服务引用该依赖包,即可直接使用。


下面是构建 feign-api 的具体步骤:

w:35em


1.创建 module

首先创建一个 module,命名为 feign-api:

image-20220726175321856


2.引入依赖

在 feign-api 中然后引入 feign 的 starter 依赖


    org.springframework.cloud
    spring-cloud-starter-openfeign

然后,order-service 中编写的 UserClient、User 都复制到 feign-api 项目中。

image-20220726175513116


通过 maven 的 install 对项目进行构建和安装,将 feign-api 安装到本地仓库中。


3.使用 feign-api

在 order-service 的 pom 文件中从本地 maven 仓库中引入 feign-api 的依赖:


    com.niit.demo
    feign-api
    1.0

修改 order-service 中的所有与上述三个组件有关的导包部分,改成导入 feign-api 中的包


4.修改启动类

由于 feign-api 与 order-service 项目的包路径并不完全一致,为了解决 spring boot 工程默认包扫描策略导致无法注入问题,需要在启动类上声明所用的 feign 客户端的列表。 修改 order-service 的启动类,补充@EnabledFeignClients 的属性内容。

@MapperScan("com.niit.order.mapper")
@SpringBootApplication
@EnableCircuitBreaker //启用断路器功能
@ServletComponentScan(basePackages = {"com.niit.order.filter"})
@EnableFeignClients(clients = {UserClient.class}) //解决包扫描无法注入问题
public class OrderApplication {

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

5.启动测试

现在,像上一小节参数绑定中的案例一样,依次启动服务注册中心 Nacos,order-service 与 user-service 服务进行测试。测试结果如下:

image-20220726180548601


添加 Ribbon 配置

由于 Spring Cloud Feign 的客户端负载均衡是通过 Spring Cloud Ribbon 实现的,所以我们可以直接通过配置 Ribbon 客户端的方式来自定义各个服务客户端调用的参数。那么我们 如何在使用 Spring Cloud Feign 的工程中使用 Ribbon 的配置呢?


全局 Ribbon 配置

全局配置的方法非常简单,我们可以直接在配置文件中设置:

ribbon:
  NFLoadBalancerRuleClassName: com.alibaba.cloud.nacos.ribbon.NacosRule #负载均衡规则的全类名
  ConnectTimeout: 250 #请求连接的超时时间
  ReadTimeout: 1000 #请求处理的超时时间
  OkToRetryOnAllOperations: true #对所有操作请求都进行重试
  MaxAutoRetriesNextServer: 2 #切换实例的重试次数
  MaxAutoRetries: 1 #对当前实例的重试次数。

对具体服务进行 Ribbon 配置

在配置文件中, 除了针对所有服务进行全局配置, 还可以针对特定的服务进行个性化配置.


在定义 Feign 客户端的时候,我们使用了 @FeignClient 注解。在初始化 过程中,Spring Cloud Feign 会根据该注解的 name 属性或 value 属性指定的服务名,自动创建一个同名的 Ribbon 客户端。

例如使用 @FeignClient(value ="userservice")来创建 Feign 客户端的时候,同时也创建了一个名为 userservice 的 Ribbon 客户端。


既然如此,我们就可以使用 @FeignClient 注解中指定的服务提供者名称来设置对应的 Ribbon 参数,比如:

userservice: # 需要远程访问的服务名
  ribbon:
    NFLoadBalancerRuleClassName: com.alibaba.cloud.nacos.ribbon.NacosRule #负载均衡规则的全类名
    ConnectTimeout: 250 #请求连接的超时时间
    ReadTimeout: 1000 #请求处理的超时时间
    OkToRetryOnAllOperations: true #对所有操作请求都进行重试
    MaxAutoRetriesNextServer: 2 #切换实例的重试次数
    MaxAutoRetries: 1 #对当前实例的重试次数。

添加 Ribbon 重试机制

在 Spring Cloud Feign 中默认实现了请求的重试机制,而上面我们对于 orderservice 客户端的配置内容就是对于请求超时以及重试机制配置的详情,具体内 容可参考第 4 章最后一节关于 Spring Cloud Ribbon 重试机制的介绍。

这里需要注意一点,Ribbon 的超时与 Hystrix 的超时是两个概念。为了让上述实现有效,我们需要让 Hystrix 的超时时间大于 Ribbon 的超时时间,否则 Hystrix 命令超时后,该命令直接熔断,重试机制就没有任何意义了。


hystrix 的超时时间配置如下:

hystrix:
  command:
    default:
      execution:
        isolation:
          thread:
            timeoutInMilliseconds: 3000 #Hystrix 超时时间

Ribbon 的超时时间配置如下:

ribbon:
  ConnectTimeout: 250 #请求连接的超时时间
  ReadTimeout: 1000 #请求处理的超时时间

添加 Hystrix 配置

在 Spring Cloud Feign 中,除了引入了用于客户端负载均衡的 Spring Cloud Ribbon 之外,还引入了服务保护与容错的工具 Hystrix 。

默认情况Spring Cloud Feign 是关闭Hystrix支持的, 因此需要手动启用Hystrix。开启Hystrix后所有 Feign 客户端的方法都会封装成为 Hystrix 命令中进行服务保护。


在对 Hystrix 进行配置之前,我们需要确认 feign.hystrix.enabled 参数需要先设置为 true, 否则默认会关闭 Feign 客户端的 Hystrix 支持。

feign:
  hystrix:
    enabled: true # 启用ystrix功能

默认 feign.hystrix.enabled=false, Hystrix 支持是关闭的

使用 hystrix.command.default.execution.timeout.enabled=false 关闭超时熔断


添加 Hystrix 全局配置

对于 Hystrix 的全局配置同 Spring Cloud Ribbon 的全局配置一样,直接使用它的默认配置前缀 hystrix.command.default 就可以进行设置,比如设置全局的超时时间:

# 断路器的超时时间需要大于Ribbon的超时时间,不然不会触发重试。
hystrix:
  command:
    default:
      execution:
        isolation:
          thread:
            timeoutInMilliseconds: 5000

局部禁用 Hystrix

如果全局启用 Hystrix 支持,但是想要针对某个服务客户端关闭 Hystrix 支持时,可以通过使用 @Scope("prototype") 注解为指定的客户端配置 Feign.Builder 实例.

详细实现步骤如下所示。


1.构建一个配置类
/**
 * 针对指定的Feign客户端禁用Hystrix的配置
 */
@Configuration
public class UserClientConfig {

    @Bean
    @Scope("prototype")
    public Feign.Builder feignBuilder() {
        return Feign.builder();
    }
}

2.引入配置到 Feign 客户端

在 UserClient 的@FeignClient 注解中,通过 configuration 参数引入上面实现的配置。

@FeignClient(value = "userservice",configuration = UserClientConfig.class)
public interface UserClient {
  ......
}

这样,就可以针对指定的 Feign 客户端来关闭 Hystrix 支持了。

原理是通过 @Scope("prototype") 注解为每个客户端创建一个新的实例,而此实例不受 Hystrix 配置的影响。


通过 Hystrix 命令的键值进行配置

对于 Hystrix 命令的配置,在实际应用时往往也会根据实际业务情况制定出不同的配置方案。配置方法也跟传统的 Hystrix 命令的参数配置相似,采用 hystrix.command.<commandKey> 作为前缀。而 <commandKey> 默认情况下会采用 Feign 客户端中的方法名作为标识,所以,针对上一节介绍的重试机制中对 /showAppInfo 接口的熔断超时时间的配置可以通过其方法名作为 <commandKey> 来进行配置.


具体如下:

# 断路器的超时时间需要大于Ribbon的超时时间,不然不会触发重试。
hystrix:
  showAppInfo: # @FeignClient中的方法名(Hystrix命令的键值)
    default:
      execution:
        isolation:
          thread:
            timeoutInMilliseconds: 5000

在使用指定命令配置的时候,需要注意,由于方法名很有可能重复,这个时候相同方法名的 Hystrix 配置会共用,所以在进行方法定义与配置的时候需要做好一定的规划。


服务降级配置

Hystrix 提供的服务降级是服务容错的重要功能, Spring Cloud Feign 提供了一种简单的定义方式,可以优雅的完成服务降级的配置。下面我们在之前创建的 feign-api 工程中进行改造。

  • 服务降级逻辑的实现只需要为 Feign 客户端的定义接口编写一个具体的接口实现类。 比如为 UserClient 接口实现一个服务降级类 UserClientFallback ,其中每个重写方法的实现逻辑都可以用来定义相应的服务降级逻辑,具体如下:

/**
 * 服务降级实现类, 用于定义服务降级逻辑
 * 该类需要实现Feign客户端接口拿到所有需要配置降级的方法
 */
public class UserClientFallback implements UserClient {
    @Override
    public User findUserById(String id) {
        User user = new User();
        user.setUsername("findUserById 调用失败");
        user.setId(UUID.randomUUID().toString());
        return user;
    }

    @Override
    public String requestParam(String name) {
        return "requestParam 调用失败";
    }

    @Override
    public User headerInfo(String name, String address) {
        User user = new User();
        user.setUsername("headerInfo 调用失败");
        user.setId(UUID.randomUUID().toString());
        return user;
    }

    @Override
    public String bodyInfo(User user) {
        return "bodyInfo 调用失败";
    }
}

  • 在服务绑定接口 UserClient 中,通过 @FeignClient 注解的 fallback 属性来指定对应的服务降级实现类。
@FeignClient(value = "userservice",fallback = UserClientFallback.class)
public interface UserClient {
   ...
}

  • 在 order-service 服务的启动类上使用@Bean 注解声明服务降级类,解决 UserClientFallback 实例无法找到异常

    @MapperScan("com.niit.order.mapper")
    @SpringBootApplication
    @EnableCircuitBreaker //启用断路器功能
    @ServletComponentScan(basePackages = {"com.niit.order.filter"})
    @EnableFeignClients(clients = {UserClient.class}) //解决包扫描无法注入问题
    public class OrderApplication {
    
      public static void main(String[] args) {
          SpringApplication.run(OrderApplication.class, args);
      }
    
      @Bean
      public UserClientFallback userClientFallback(){
          return new UserClientFallback();
      }
    
    }

上面的做法,是使用了 feign-api 提供的默认的统一服务降级配置类, 对每个接口都配置了对应的服务降级逻辑.

如果,order-service 想自定义某个服务降级逻辑, 只需在 order-service 中创建一个新的类,继承 UserClientFallback,重写其方法, 注入到 spring 容器里即可:


创建新的自定义服务降级类:

public class MyUserClientFallback extends UserClientFallback {

    @Override
    public String requestParam(String name) {
        return "orderservice服务的自定义服务降级方法被触发了...";
    }
}

重新注入到 Spring 容器:

@MapperScan("com.niit.order.mapper")
@SpringBootApplication
@EnableCircuitBreaker //启用断路器功能
@ServletComponentScan(basePackages = {"com.niit.order.filter"})
@EnableFeignClients(clients = {UserClient.class}) //解决包扫描无法注入问题
public class OrderApplication {

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

    //注入order-service自定义的 MyUserClientFallback服务降级类
    @Bean
    public UserClientFallback userClientFallback(){
        return new MyUserClientFallback();
    }
}

测试验证

下面我们来验证一下服务降级逻辑的实现。启动服务注册中心 Nacos 和 order-service, 但是不启动 user-service 服务。发送 GET 请求到 http://localhost:8080/feign/order/request?param=world, 因为 user-service 服务没有启动,会直接触发服务降级.

如果没有降级发生, 检查order-service是否在springboot应用配置文件中启用hystrix:

feign:
  hystrix:
    enabled: true

注意 springcloud2020.0.1之后的版本改为 feign.circuitbreaker.enabled=true


正如我们在 MyUserClientFallback 类中实现的内容,每一个服务接口的断路器实际就是实现类中的重写函数的实现。


小问题:

在 Spring Cloud Feign 中,可以通过 _____ 来关闭 Hystrix 功能?

  1. feign.hystrix.enabled=false
  2. feign.hystrix.hystrixCommand=false
  3. feign.hystrix.disabled=false
  4. feign.hystrix.hystrix=false
答案
1. feign.hystrix.enabled=false

其他配置


请求压缩

Feign 是通过 http 调用的,那么就牵扯到一个数据大小的问题。如果不经过压缩就发送请求、获取响应,那么会因为流量过大导致浪费流量,这时就需要使用数据压缩,将大流量压缩成小流量。

Spring Cloud Feign 支持对请求与响应进行 GZIP 压缩,以减少通信过程中的性能损耗。


配置请求压缩支持, 找到需要调用 feign 的服务消费者 order-service 模块配置文件中添加如下配置,即可开启:

feign:
  compression:
    request:
      enabled: true # 开启请求压缩
    response:
      enabled: true # 开启响应压缩

同时,我们也可以对请求的数据类型,以及触发压缩的大小下限进行设置:

feign:
  compression:
    request:
      enabled: true # 开启请求压缩
      mime-types: text/html,application/xml,application/json # 设置压缩的数据类型
      min-request-size: 2048 # 设置触发压缩的大小下限

值得注意的是,上面的数据类型、压缩大小下限均为默认值。


日志配置

Spring Cloud Feign 在构建服务客户端时,会为每一个客户端都创建一个 feign. Logger 实例,我们可以利用该日志对象的 DEBUG 模式来帮助分析 Feign 的请求细节。

对于 Feign 的 Logger 级别主要有下面 4 类,可根据实际需要进行调整使用。

  • NONE :不记录任何信息。
  • BASIC :仅记录请求方法、 URL 以及响应状态码和执行时间。
  • HEADERS :除了记录 BASIC 级别的信息之外,还会记录请求和响应的头信息。
  • FULL :记录所有请求与响应的明细,包括头信息、请求体、元数据等

配置 Feign 日志有两种方式:

  • 配置文件方式
  • Java 配置类方式

日志配置可以配置全局生效,也可以配置针对某个微服务生效,下面我们详细介绍两种方式是如何开启日志配置。


配置文件方式

通过修改服务消费方的 application.yml 文件,配置 Spring Cloud Feign 的日志级别。


全局 FeignClient 配置:

feign:
  client:
    config:
      metaDataClient:
        connect-timeout: 3000
        read-timeout: 3000
      default: #default 代表全局配置
        loggerLevel: FULL

针对实例的 FeignClient 配置:

feign:
  client:
    config:
      metaDataClient:
        connect-timeout: 3000
        read-timeout: 3000
      userservice: #服务名称 针对某个微服务的配置
        loggerLevel: BASIC

我们以 FULL 级别的日志配置为例,发送一次请求测试,查看控制台日志输出,可以看到日志信息非常完整:

image-20220727163823119


通过 Java 配置类方式配置 FeignClient

需要先声明一个 Bean,用来定义日志的级别。

public class UserClientConfig {
    @Bean
    public Logger.Level feignLoggerLevel(){
        return Logger.Level.FULL;
    }
}

使用 Java 配置类进行全局 FeignClient 配置

全局配置,则需要在服务消费方的主启动类上进行配置指定:

@MapperScan("com.niit.order.mapper")
@SpringBootApplication
@EnableCircuitBreaker //启用断路器功能
@ServletComponentScan(basePackages = {"com.niit.order.filter"})
//解决包扫描无法注入, 指定feign的全局配置属性
@EnableFeignClients(clients = {UserClient.class},defaultConfiguration = UserClientConfig.class)
public class OrderApplication {

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

使用 Java 配置类进行局部 FeignClient 配置

局部配置,则放在相应的 feign 客户端的 @FeignClient 这个注解中:

@FeignClient(value = "userservice",configuration = UserClientConfig.class)
public interface UserClient {
    ...
}

值得注意的是,当使用配置文件和使用 Java 代码两种方式同时存在的时候,配置文件的优先级更高。当然,我们不推荐同时配置两种方式,只保留一种方式即可。


活动 6.1:

使用 Feign 实现服务远程调用


练习问题

  1. 在服务绑定接口 HelloService 中,通过 @FeignClient 注解的 _____ 属性来指定对应的服务降级实现类。
    a. fallback
    b. back
    c. fullback
    d. fall

    查看答案

    fallback


  1. 对于 Hystrix 的全局配置同 Spring Cloud Ribbon 的全局配置一样,直接使用它的默认配置前缀 _____ 就可以进行设置
    a. hystrix.command.index
    b. hystrix.command.default
    c. hystrix.default.command
    d. hystrix.index.command

    查看答案

    hystrix.command.default


  1. Ribbon 的超时与 Hystrix 的超时是两个概念。为了让上述实现有效,我们需要让 Hystrix 的超时时间 _____ Ribbon 的超时时间?
    a. 小于
    b. 等于
    c. 大于
    d. 不等于

    查看答案

    大于


  1. 我们在使用 Spring Cloud Ribbon 时,通常都会利用它对 _____ 的请求拦截来实现对依赖服务的接口调用。
    a. FeignTemplate
    b. SpringTemplate
    c. CloudTemplate
    d. RestTemplate

    查看答案

    RestTemplate


小结

在本章中,您学习了:

  • 我们在使用 Spring Cloud Ribbon 时,通常都会利用它对 RestTemplate 的请求拦截来实现对依赖服务的接口调用,而 RestTemplate 己经实现了对 HTTP 请求的封装处理,形成了一套模板化的调用方法。
  • 在 Spring Cloud Feign 的实现下,我们只需创建一个接口并用注解的方式来配置它,即可完成对服务提供方的接口绑定,简化了在使用 Spring Cloud Ribbon 时自行封装服务调用客户端的开发量。
  • Spring Cloud Feign 具备可插拔的注解支持, 包括 Feign 注解和 JAX-RS 注解。同时,为了适应 Spring 的广大用户,它在 NetflixFeign 的基础上扩展了对 Spring MVC 的注解支持。

  • 由于 Spring Cloud Feign 的客户端负载均衡是通过 Spring Cloud Ribbon 实现的,所以我们可以直接通过配置 Ribbon 客户端的方式来自定义各个服务客户端调用的参数。
  • 在 Spring Cloud Feign 中,除了引入了用于客户端负载均衡的 Spring Cloud Ribbon 之外,还引入了服务保护与容错的工具 Hystrix。
  • 在对 Hystrix 进行配置之前,我们需要确认 feign.hystrix.enabled 参数没有被设置为 false, 否则该参数设置会关闭 Feign 客户端的 Hystrix 支持。
  • Hystrix 提供的服务降级是服务容错的重要功能。

Index