spring cloud stream详解

2,417 阅读9分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情

需要的依赖

  <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-stream</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-stream-binder-rabbit</artifactId>
    </dependency>

项目中添加了上述依赖后,项目会自动连接到可用的消息中间件,并自动识别项目中的java.util.function.Function为消息的消费者和生产者。

Stream通过把Function、Consumer和Supplier的Bean自动识别为消费者,如:

    @Bean
    public Consumer<Person> log() {
        return person -> {
            System.out.println("Received: " + person);
        };
    }

当类路径存在cloud stream组件时,就会自动把这三个函数识别为消费者

方法名log用来配置等,如log-in-0。

应用模型

通过官网的这张图,Stream的位置和作用很清晰也很明确,就是一个MQ中间件的上层抽象:

  • 通过Binder绑定实际的中间件,如RabbitMQ、Kafka
  • 通过inputs实现消息读取和消费
  • 通过outputs实现消息发送和生产

Binder的抽象

Binder主要用于实现与实际MQ中间件进行绑定,系统默认提供了对于RabbitMQ和KafkaMQ的实现,同时也提供了一个集成测试功能。

既然是抽象的,除了系统提供的,我们也可以自己去实现(下文介绍)。

通过yaml或properties配置,可以灵活的配置外部目标映射(如Kafka的Topics或Rabbit的Exchange)和消息输入输出处理方式,比如通过配置

spring.cloud.stream.bindings.input.destination = raw-sensor-data,可以让处理程序在Kafka的raw-sensor-data Topic 或Rabbit的raw-sensor-data Exchange中读取消息.

Binder是应用程序和队列中间件的链接桥梁,类似RabbitMQ中的Exchange,通过以下配置:

spring.cloud.stream.bindings.<bindingName>.destination=myX

其中就是函数式编程的函数,myX就是交换器的名称。

如果类路径存在多个中间件,如Kafka和Rabbit,需要使用配置明确指明使用哪个:

spring.cloud.stream.defaultBinder=rabbit

也可以把输入和输出分开设定:

spring.cloud.stream.bindings.input.binder=kafka

spring.cloud.stream.bindings.output.binder=rabbit

消费者分组 Partitioning

如果消费者不是共享消息,而是互斥,则使用分组功能。每一个消费者都可以使用 spring.cloud.stream.bindings.<bindingName>.group指定组名。

组内每一个消费者都会收到消息,但是每次只有一个消费者去消费,分组的作用类似Kafka中的分组或Rabbit中的队列。

spring.cloud.stream.bindings.<bindingName>.group=myQ

其中myQ就是队列的名称。

原理

分配在一个组内的消费者,会订阅到一个相同的队列,所以消息会随机发送到组内某一个消费者去消费(不是广播)。

默认每一个消费者都是一个组,也就是stream在绑定时,会生成N个队列,每个队列一个消费者,所以消息会被所有消费者消费。

最好每次都声明一个组,这样即使没有消费者,数据也不会丢失,等有消费者时立即消费。

消费类型

有两种消费类型;

  • 消息驱动,也叫异步消息
  • 轮询,也是同步消息,要控制消息的处理速率,就可以使用同步消息

2.0之前只支持异步消息

持久化

消费者订阅消息默认是持久的。

只要组内绑定了消费者,该组就开始接受消息。

分区

在分组时,组内消费者会随机消费消息,但是为了保证消息都发送给同一个消费者,那么就可以使用分区。

需要同时配置客户端和服务端才可以支持分区功能

编程模型

  • Destination Binders: 负责提供与外部消息传递系统集成的组件
  • Bindings: 外部消息传递系统和应用程序之间的桥梁提供了消息的生产者和消费者(由目的地绑定器创建)
  • Message: 生产者和消费者用于与目的地绑定器(以及通过外部消息传递系统的其他应用程序)通信的规范数据结构

Bindings

如前所述,绑定提供了外部消息传递系统(如队列、主题等)与应用程序提供的生产者和消费者之间的桥梁,看一个实例:

@SpringBootApplication
public class SampleApplication {

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

	@Bean
	public Function<String, String> uppercase() {
	    return value -> {
	        System.out.println("Received: " + value);
	        return value.toUpperCase();
	    };
	}
}

当上下文存在SpringCloudStream和自动化配置时,Supplier、Function和Consumer 会被识别为消息处理程序,不需要其他特殊的配置。上文中uppercase被识别为 消息消费者 和消息消费者(因为是Function类型),首先接收消息,然后打印到控制台日志,最后转化为大写后再发布出去。

Binding是业务逻辑和消息中间件之间的一个桥梁,每一个binding都有一个默认的名字,并且通过配置文件可以修改该名字。

如上文spring.cloud.stream.bindings.input.destination = raw-sensor-data,其中 input 段 就是binding的名称,该名字默认由相关机制派生的,当然也可以自定义。

函数式binding命名规则

@SpringBootApplication
public class SampleApplication {

	@Bean
	public Function<String, String> uppercase() {
	    return value -> value.toUpperCase();
	}
}

Function有一个输入和输出,命名约束如下:

  • 输入命名规则:-in-,如 uppercase-in-0
  • 输出命名规则:-out-,如uppercase-out-0
  • in和out是function的类型,即表示input还是output,程序接收来自in的消息,程序发送消息到out
  • 是输入或输出的binding的索引,对于单输入输出函数,默认是 0

配置文件如下:

spring:
  cloud:
    stream:
      bindings:
        uppercase-in-0:
          group: uppercase_group_in # 类似RabbitMQ的Queue
          destination: uppercase_queue_in # 类似RabbitMQ的Exchange
        uppercase-out-0:
          group: uppercase_group_out # 类似RabbitMQ的Queue
          destination: uppercase_queue_out # 类似RabbitMQ的Exchange
      binders:
        defaultRabbit:
          type: rabbit
          environment:
            spring:
              rabbitmq:
                host: localhost
                port: 5672

消息发送到交换器uppercase_queue_in后,消息被路由到队列uppercase_group_in中,uppercase会自动消费该队列中的数据;

同时uppercase转化为大写之后,再次发送到交互器你uppercase_group_out,并被路由到队列uppercase_queue_out中。

可以把uppercase-in-0改为好记的名字(比如改为input),实际中并不建议使用:

spring.cloud.stream.function.bindings.uppercase-in-0=input

以后配置时就可以使用 input 了,如:

spring.cloud.stream.bindings.input.destination=uppercase_queue_in

显示创建绑定

上文中,in和out都是根据函数式编程的函数名称自动生成,但如果不定义函数,那就不会生成交换器和队列等,如何不定义函数也能生成呢?

使用spring.cloud.stream.input-bindings可以显示创建绑定,比如:

spring.cloud.stream.input-bindings=fooin;barin,虽然没有fooin和barin函数,但是同样会生成<bindingName>=fooin-in-0<bindingName>=barin-in-0两个。

生产和消费消息

函数式编程

即Supplier/Function/Consumer三个自动识别的功能

  • Supplier:消息生产者,因为是生产者,所以不需要订阅,所以需要其他的触发机制,如被动或主动推送
  • Consumer:消息消费者
  • Function:既是消息生产者,也是消息消费者,即先消费,然后再发送出去

上文说,函数式编程会自动识别,但是有时候不希望自动识别,因为可能这三个函数会用于其他用途,并不是用于MQ,可以使用改配置禁用自动发现:

spring.cloud.stream.function.autodetect=false

采用自动识别时,如果程序中只有一个函数式Bean,自动识别没问题,但是存在多个函数编程的Bean时,需要使用以下配置指定哪个Bean去绑定到外部目标(如队列):

spring.cloud.function.definition

实际上,只有一个时,也建议显示配置该项,以免出现问题

多个Function如何顺序调用

定义两个Function,然后使用配置:spring.cloud.function.definition=func_01|func_02,这样会生成两个分组:func_01function_02-out-0func_01function_02-in-0,发送消息后,会首先执行func_01,然后再执行func_02

使用管道 | 函数,可以把很多复杂的逻辑,拆解成简单的逻辑,然后连接起来,这样方便维护和测试。

Supplier介绍

作为消息生产者,不需要订阅消息队列,但需要有个机制触发消息发送,它支持两种:主动和被动

该消息生产者的触发机制比较特殊,默认框架会每隔1s自动触发一次,例如:

@SpringBootApplication
public static class SupplierConfiguration {

	@Bean
	public Supplier<String> stringSupplier() {
		return () -> "Hello from Supplier";
	}
}

默认会平均每秒打印一个消息。

有时候只需要调用一次,如果采用响应式编程,框架默认只会执行一次,如:

@SpringBootApplication
public static class SupplierConfiguration {

    @Bean
    public Supplier<Flux<String>> stringSupplier() {
        return () -> Flux.fromStream(Stream.generate(new Supplier<String>() {
            @Override
            public String get() {
                try {
                    Thread.sleep(1000);
                    return "Hello from Supplier";
                } catch (Exception e) {
                    // ignore
                }
            }
        })).subscribeOn(Schedulers.elastic()).share();
    }
}

框架自动识别编程风格,然后决定是循环执行还是只执行一次。

但是,如果采用了响应式编程后,还是希望循环执行,可以使用注解@PollableBean,如下:

@SpringBootApplication
public static class SupplierConfiguration {

	@PollableBean
	public Supplier<Flux<String>> stringSupplier() {
		return () -> Flux.just("hello", "bye");
	}
}

该注解告诉框架,虽然这里采用了响应式编程,但是还是要循环执行。

如何控制Supplier的执行频率等?

参考:org.springframework.boot.autoconfigure.integration.IntegrationProperties.Poller,该注解用于统一设置项目中所有的bindings。

3.2 版本之前使用 spring.cloud.stream.poller,目前已弃用

针对微服务,每个服务中一个配置是可以的,但如果一个项目中有多个需要配置的suppliers,需要单独设置每一个的轮询频率,采用以下方式:

spring.cloud.stream.bindings.supply-out-0.producer.poller.fixed-delay=2000

向output输出任意的数据

数据来源各种各样,接口接收到数据后,如何把这类数据输出到output?有两种方式:

下例子使用StreamBridge,该功能可以把任何消息发送到绑定的外部消息中间件中 :

@SpringBootApplication
@RestController
public class WebSourceApplication {

	public static void main(String[] args) {
		SpringApplication.run(WebSourceApplication.class, "--spring.cloud.stream.source=toStream");
	}

	@Autowired
	private StreamBridge streamBridge;

    @GetMapping("/ts")
    public void delegateToStream(@RequestParam String body){
        System.out.println(body);
        this.bridge.send("toStream-out-0",body);
    }
}

StreamBridge用于把接收的消息,推送到stream的group(也就是RabbitMq的Exchange)。

其中 "toStream-out-0"就是分组(exchange)的名字,但是不会生成队列,如果要消费需要首先定义消费者(Consumer),并绑定到相同的分组:

log-in-0:
	group: toStream-out-0
  destination: toStream-out-0

如果想提前声明绑定,可以使用spring.cloud.stream.source=toStream(会默认生成toStream-out-0的分组和队列) ,这样创建Consumer时直接绑定到toStream-out-0就可以了,但目前已弃用。

消息拦截器

    @Bean
    @GlobalChannelInterceptor(patterns = "*")
    public ChannelInterceptor customInterceptor() {
        return new ChannelInterceptor() {
            @Override
            public Message<?> preSend(Message<?> message, MessageChannel channel) {
                System.out.println(message.getPayload());
                return message;
            }
        };
    }

@GlobalChannelInterceptor(patterns = "*")正则表达式=*,表示所有的StreamBridge在发送前,都会被拦在这个方法内。

如果不定义全局的,只拦截toStream-out-0队列的消息,可以使用下方正则表达式:

    @Bean
    @GlobalChannelInterceptor(patterns = "toStream-*")
    public ChannelInterceptor customInterceptor() {
        return new ChannelInterceptor() {
            @Override
            public Message<?> preSend(Message<?> message, MessageChannel channel) {
                System.out.println(message.getPayload());
                return message;
            }
        };
    }