Spring Cloud入门教程(九):基于消息驱动开发(Stream)

Spring Cloud入门教程系列:

  • Spring Cloud入门教程(一):服务治理(Eureka)
  • Spring Cloud入门教程(二):客户端负载均衡(Ribbon)
  • Spring Cloud入门教程(三):声明式服务调用(Feign)
  • Spring Cloud入门教程(四):微服务容错保护(Hystrix)
  • Spring Cloud入门教程(五):API服务网关(Zuul) 上
  • Spring Cloud入门教程(六):API服务网关(Zuul) 下
  • Spring Cloud入门教程(七):分布式链路跟踪(Sleuth)
  • Spring Cloud入门教程(八):统一配置中心(Config)

本人和同事撰写的《Spring Cloud微服务架构开发实战》一书也在京东、当当等书店上架,大家可以点击这里前往购买,多谢大家支持和捧场!

Spring Cloud入门教程(九):基于消息驱动开发(Stream)_第1张图片
Spring Cloud微服务架构开发实战.png


基于消息驱动的开发几乎成了微服务架构下必备开发方式之一。这是因为,第一原来传统单体架构开发中的接口调用开发已经在微服务架构下不存在;第二微服务架构的开发要求降低各微服务直接的依赖耦合,一旦我们在某个微服务中直接调用另外一个微服务,那么这两个微服务就会通过依赖产生了强耦合;第三微服务的自治原则也强烈要求各微服务之间不能够互相调用。因此,在微服务架构开发中基于消息驱动的开发成为了一种必然趋势。

让我们来看一下示例工程中的一个场景:

  • Mall-Web微服务要求能够实现自治,尽量降低对商品微服务(Procuct-Service)的依赖;
  • Mall-Web微服务为了能够保障服务的效率,开发小组决定对商品数据进行缓存,这样只需要第一次加载的时候远程调用商品微服务,当用户下次在请求该商品的时候就可以从缓存中获取,从而提升了服务效率(至于使用内存方式还是Redis来实现缓存,这个由你决定)。

如果按照上面的场景进行实现,在大部分情况下系统都可以稳定工作,一旦商品进行修改那该怎么办,我们总不至于在商品微服务中再去调用Mall-Web微服务吧,这样岂不是耦合的更紧密了。嗯,是的,这个时候就可以让消息出动了。

通过引入消息,我们示例工程的系统架构将变为下图所示:

Spring Cloud入门教程(九):基于消息驱动开发(Stream)_第2张图片
示例工程架构图

基于上面这个架构图我们看一下基于消息如何来实现。之前如果你使用过消息中间件应该对开发基于消息应用的难度心有戚戚然,不过当我们使用Spring Cloud时,已经为我们的开发提供了一套非常不错的组件 -- Stream。

1. 实现消息驱动开发

接下来的改造将分为下面三步:

  1. 安装Kafka服务器;
  2. 改造商品微服务,实现商品消息的发送;
  3. 改造Mall-Web微服务,实现商品消息的监听。

1.1 安装Kafka服务器

我们接下来的示例会使用Kafka作为消息中间件,一方面是Kafka消息中间件非常轻便和高效,另外一方面自己非常喜欢使用Kafka中间件。如果你不想使用Kafka那么可以自行完成于RabbitMQ的对接,而具体实现的业务代码则不需要进行任何改动。

如何安装运行kafka服务器,这里就不再详细描述,网上及官方都有非常不错的文档,比如,官方文档。

1.2 改造商品微服务

1.2.1 增加对Stream的依赖

和之前一样,首先我们需要在项目中引入对Stream的依赖:


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

1.2.2 修改application.properties文件

# =====================================================================================================================
# == stream / kafka                                                                                                  ==
# =====================================================================================================================
spring.cloud.stream.bindings.output.destination=twostepsfromjava-cloud-producttopic
spring.cloud.stream.bindings.output.content-type=application/json
spring.cloud.stream.kafka.binder.brokers=localhost
spring.cloud.stream.kafka.binder.defaultBrokerPort=9092
spring.cloud.stream.kafka.binder.zkNodes=localhost

这里我们主要是设置kafka服务器的地址,及发送商品消息是发送到Kafka的哪个主题(Topic),这里设置为twostepsfromjava-cloud-producttopic。不过,这些我们都可以不用配置,如果不配置,那么Stream就会根据默认配置来连接Kafka服务器及创建相应的主题。不过如果不进行配置的首要前提是你在安装Kakfa服务器时没有做端口的更改,而且Kafka服务器和商品微服务在同一台服务器上。

1.2.3 构建商品消息

当商品配置变更时,如:修改、删除等,就需要构建一个商品消息,然后就可以将该消息通过Kafka发送给相应监听的微服务进行处理。因此,所要构建的商品消息代码如下:

package io.twostepsfromjava.cloud.product.mq;

import com.google.common.base.MoreObjects;

/**
 * 商品消息
 *
 * @author CD826([email protected])
 * @since 1.0.0
 */
public class ProductMsg {
    /** 消息类型:更新商品,值为: {@value} */
    public static final String MA_UPDATE = "update";
    /** 消息类型:删除商品,值为: {@value} */
    public static final String MA_DELETE = "delete";

    // ========================================================================
    // fields =================================================================
    private String action;
    private String itemCode;

    // ========================================================================
    // constructor ============================================================
    public ProductMsg() {  }

    public ProductMsg(String action, String itemCode) {
        this.action = action;
        this.itemCode = itemCode;
    }

    @Override
    public String toString() {
        return MoreObjects.toStringHelper(this)
                .add("action", this.getAction())
                .add("itemCode", this.getItemCode()).toString();
    }

    // ==================================================================
    // setter/getter ====================================================
    public String getAction() {
        return action;
    }
    public void setAction(String action) {
        this.action = action;
    }

    public String getItemCode() {
        return itemCode;
    }
    public void setItemCode(String itemCode) {
        this.itemCode = itemCode;
    }
}

商品消息非常简单,仅包含了两个字段:actionitemCode。所代表的意义如下:

  • action: 表示本次消息是什么消息,比如商品更新消息还是商品删除消息;
  • itemCode: 所变更或删除商品的货号(或者商品的ID)。

可能看到这里你会奇怪,为何商品消息仅包含这两个字段,够后续使用么。一般对于消息有这两个字段已经足够了,但是在正式生产环境中我们还会再增加一下其它字段,这里就不讲了。此外,一般当监听方监听到该消息之后就可以根据消息类型及商品货号来进行相关处理。比如后面Mall-Web微服务就会根据商品货号通过远程请求商品微服务来重新加载商品信息。

1.2.4 实现消息发送

当商品微服务中用户对商品进行了变更或删除时就需要构建上面的商品消息并发送,相应的代码如下:

package io.twostepsfromjava.cloud.product.service;

import io.twostepsfromjava.cloud.product.dto.ProductDto;
import io.twostepsfromjava.cloud.product.mq.ProductMsg;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.stream.messaging.Source;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;


/**
 * 商品服务
 *
 * @author CD826([email protected])
 * @since 1.0.0
 */
@Service
public class ProductService {
    protected Logger logger = LoggerFactory.getLogger(ProductService.class);

    private Source source;
    private List productList;

    @Autowired
    public ProductService(Source source) {
        this.source = source;
        this.productList = this.buildProducts();
    }

    /**
     * 获取商品列表
     * @return
     */
    public List findAll() {
        return this.productList;
    }

    /**
     * 根据ItemCode获取
     * @param itemCode
     * @return
     */
    public ProductDto findOne(String itemCode) {
        for (ProductDto productDto : this.productList) {
            if (productDto.getItemCode().equalsIgnoreCase(itemCode))
                return productDto;
        }

        return null;
    }

    /**
     * 保存或更新商品信息
     * @param productDto
     * @return
     */
    public ProductDto save(ProductDto productDto) {
        // TODO: 实现商品保存处理
        for (ProductDto sourceProductDto : this.productList) {
            if (sourceProductDto.getItemCode().equalsIgnoreCase(productDto.getItemCode())) {
                sourceProductDto.setName(sourceProductDto.getName() + "-new");
                sourceProductDto.setPrice(sourceProductDto.getPrice() + 100);
                productDto = sourceProductDto;
                break;
            }
        }

        // 发送商品消息
        this.sendMsg(ProductMsg.MA_UPDATE, productDto.getItemCode());

        return productDto;
    }
    
    /**
     * 具体消息发送的实现
     * @param msgAction 消息类型
     * @param itemCode 商品货号
     */
    protected void sendMsg(String msgAction, String itemCode) {
        ProductMsg productMsg = new ProductMsg(msgAction, itemCode);
        this.logger.debug("发送商品消息:{} ", productMsg);

        // 发送消息
        this.source.output().send(MessageBuilder.withPayload(productMsg).build());
    }

    protected List buildProducts() {
        List products = new ArrayList<>();
        products.add(new ProductDto("item-1", "测试商品-1", "TwoStepsFromJava", 100));
        products.add(new ProductDto("item-2", "测试商品-2", "TwoStepsFromJava", 200));
        products.add(new ProductDto("item-3", "测试商品-3", "TwoStepsFromJava", 300));
        products.add(new ProductDto("item-4", "测试商品-4", "TwoStepsFromJava", 400));
        products.add(new ProductDto("item-5", "测试商品-5", "TwoStepsFromJava", 500));
        products.add(new ProductDto("item-6", "测试商品-6", "TwoStepsFromJava", 600));
        return products;
    }
}

为了能够有演示效果,我将原来直接写在端点中代码移到一个单独的Service中。

消息的发送非常简单,我们只需要调用source.output().send()方法就可以发送消息了。这里你可能会有点迷惑,source是什么鬼,哪里蹦出来的。不着急,现在你只需要明白这个是Spring Cloud Stream提供的一个抽象消息发送接口,通过该接口中的output()就可以获取一个消息发送通道,然后就可以往该通道中send()消息就可以了。具体的原理我们后面再细聊。

1.2.5 增加消息发送测试端点

我们需要新增一个端点用来模拟用户保存/更新商品信息。在上面的代码可以知道,当我们保存/更新商品信息时就会发送商品变更消息,因此新的端点只需要调用该方法即可,具体代码如下:

@RestController
@RequestMapping("/products")
public class ProductEndpoint {
    protected Logger logger = LoggerFactory.getLogger(ProductEndpoint.class);

    @Autowired
    ProductService productService;
    // 省略了其它不相干代码
    
    // TODO: 该端点仅仅是用来测试消息发送,并不包含任何业务逻辑处理
    @RequestMapping(value = "/{itemCode}", method = RequestMethod.POST)
    public ProductDto save(@PathVariable String itemCode) {
        ProductDto productDto = this.productService.findOne(itemCode);
        if (null != productDto) {
            this.productService.save(productDto);
        }
        return productDto;
    }
}

1.2.6 绑定消息通道

最后,我们要在微服务启动的时候需要去绑定Kafka消息中间件,实现代码如下:

package io.twostepsfromjava.cloud;


import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.stream.annotation.EnableBinding;
import org.springframework.cloud.stream.messaging.Source;

/**
 * TwoStepsFromJava Cloud -- Product Service 服务器
 *
 * @author CD826([email protected])
 * @since 1.0.0
 */
@EnableDiscoveryClient
@EnableBinding(Source.class)
@SpringBootApplication
public class Application {

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

}

只需要在应用引导类中添加一个@EnableBinding(Source.class)注解即可。

Ok,到这里我们已经实现了商品微服务的消息发送,下面让我们完成Mall-Web微服务中消息的监听。

1.3 改造Mall-Web微服务,实现消息监听

1.3.1 增加对Stream的依赖

这个和商品微服务一样,就不重复了。

1.3.2 修改application.properties文件

# =====================================================================================================================
# == stream / kafka                                                                                                  ==
# =====================================================================================================================
spring.cloud.stream.bindings.input.destination=twostepsfromjava-cloud-producttopic
spring.cloud.stream.bindings.input.content-type=application/json
spring.cloud.stream.bindings.input.group=mallWebGroup
spring.cloud.stream.kafka.binder.brokers=localhost
spring.cloud.stream.kafka.binder.defaultBrokerPort=9092
spring.cloud.stream.kafka.binder.zkNodes=localhost

这个配置和商品微服务类似,不过我们需要把之前output更改为了input,表示这里配置的是消息输入通道。

此外,我们还在最后增加了一个group属性的配置,具体该属性表示什么意思我们也是在后面进行讲解。

1.3.3 拷贝ProductMsg到本项目

因为,微服务的自治原则,因此这里你需要将ProductMsg拷贝到Mall-Web工程中。

1.3.4 实现具体监听处理

通过Stream进行监听处理,我们只需要在相应的监听方法中增加@StreamListener注解即可,具体所实现的监听代码如下:

package io.twostepsfromjava.cloud.web.mall.mq;

import io.twostepsfromjava.cloud.web.mall.dto.ProductDto;
import io.twostepsfromjava.cloud.web.mall.service.ProductService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.stream.annotation.EnableBinding;
import org.springframework.cloud.stream.annotation.StreamListener;
import org.springframework.cloud.stream.messaging.Sink;

/**
 * 商品消息监听器
 *
 * @author CD826([email protected])
 * @since 1.0.0
 */
@EnableBinding(Sink.class)
public class ProductMsgListener {
    protected Logger logger = LoggerFactory.getLogger(this.getClass());

    @Autowired
    protected ProductService productService;

    @StreamListener(Sink.INPUT)
    public void onProductMsg(ProductMsg productMsg) {
        if (ProductMsg.MA_UPDATE.equalsIgnoreCase(productMsg.getAction())) {
            this.logger.debug("收到商品变更消息,商品货号: {}", productMsg.getItemCode());
            // 重新获取该商品信息
            ProductDto productDto = this.productService.loadByItemCode(productMsg.getItemCode());
            if (null != productDto)
                this.logger.debug("重新获取到的商品信息为:{}", productDto);
            else
                this.logger.debug("货号为:{} 的商品不存在", productMsg.getItemCode());
        } else if (ProductMsg.MA_DELETE.equalsIgnoreCase(productMsg.getAction())) {
            this.logger.debug("收到商品删除消息,所要删除商品货号为: {}", productMsg.getItemCode());
        } else {
            this.logger.debug("收到未知商品消息: {}", productMsg);
        }
    }
}

这里代码非常简单实现了一个商品消息监听方法onProductMsg,并在该方法中根据消息类型进行不同的处理。

同样,对于Sink是什么鬼这里也先不细讲,你只需要明白这是一个Spring Cloud Stream提供的一个抽象消息监听接口,当在@StreamListener注解中添加了该接口类名之后,Stream就会向Kafka添加一个消息订阅,所订阅的消息主题就是我们在配置文件指定的twostepsfromjava-cloud-producttopic,当主题中有消息时,Stream就会将该主题中的消息反序列化为ProductMsg,然后执行具体的消息监听方法。

到此,消息监听也就全部实现了。

1.4 启动测试

按照先后次序分别启动:

  1. Kafka服务器;
  2. 服务治理服务器: Service-discovery;
  3. 商品微服务: Product-Service;
  4. Mall-Web微服务。

然后,使用Postman按照下图访问消息测试端点: http://localhost:2100/products/item-2:

Spring Cloud入门教程(九):基于消息驱动开发(Stream)_第3张图片
使用Postman访问

在商品微服务的控制台,可以看到类似下面输出:

Spring Cloud入门教程(九):基于消息驱动开发(Stream)_第4张图片
商品微服务控制台输出

从输出日志中可以看到商品变更消息已经发送到消息中间件。如果这个时候我们查看Mall-Web微服务的控制台,可以看到下图的输出:

Spring Cloud入门教程(九):基于消息驱动开发(Stream)_第5张图片
Mall-Web微服务控制台输出

从日志输出中可以看到Mall-Web微服务已经能够正确接收到商品变更消息,然后重新请求了并获取到了该商品的最新信息。

2. Stream原理浅析

从Spring Cloud Stream核心原理上来说,Stream提供了一个与消息中间件进行消息收发的抽象层,这个也是Spring所擅长的。通过该抽象层剥离了业务中消息收发与实际所使用中间件直接的耦合,从而使得我们可以轻松与各种消息中间件对接,也可以很简单的就可以实现所使用的消息中间件的更换。这点和我们使用ORM框架一样,可以平滑的在多种数据库之间进行切换。

2.1. Stream应用模型

从这个应用开发上来说,Stream提供了下述模型:

Spring Cloud入门教程(九):基于消息驱动开发(Stream)_第6张图片
Stream Application Mode

在该模型图上有如下几个核心概念:

  • Source: 当需要发送消息时,我们就需要通过Source,Source将会把我们所要发送的消息(POJO对象)进行序列化(默认转换成JSON格式字符串),然后将这些数据发送到Channel中;
  • Sink: 当我们需要监听消息时就需要通过Sink来,Sink负责从消息通道中获取消息,并将消息反序列化成消息对象(POJO对象),然后交给具体的消息监听处理进行业务处理;
  • Channel: 消息通道是Stream的抽象之一。通常我们向消息中间件发送消息或者监听消息时需要指定主题(Topic)/消息队列名称,但这样一旦我们需要变更主题名称的时候需要修改消息发送或者消息监听的代码,但是通过Channel抽象,我们的业务代码只需要对Channel就可以了,具体这个Channel对应的是那个主题,就可以在配置文件中来指定,这样当主题变更的时候我们就不用对代码做任何修改,从而实现了与具体消息中间件的解耦;
  • Binder: Stream中另外一个抽象层。通过不同的Binder可以实现与不同消息中间件的整合,比如上面的示例我们所使用的就是针对Kafka的Binder,通过Binder提供统一的消息收发接口,从而使得我们可以根据实际需要部署不同的消息中间件,或者根据实际生产中所部署的消息中间件来调整我们的配置。

2.2. Stream应用原理

从上面我们了解了Stream的应用模型,消息发送逻辑及流程我们也清晰了。那么我们在实际消息发送和监听时又是怎么操作的呢?

在使用上Stream提供了下面三个注解:

  • @Input: 创建一个消息输入通道,用于消息监听;
  • @Output: 创建一个消息输出通道,用于消息发送;
  • @EnableBinding: 建立与消息通道的绑定。

我们在使用时可以通过@Input@Output创建多个通道,使用这两个注解创建通道非常简单,你只需要将他们分别注解到接口的相应方法上即可,而不需要具体来实现该注解。当启动Stream框架时,就会根据这两个注解通过动态代码生成技术生成相应的实现,并注入到Spring应用上下文中,这样我们就可以在代码中直接使用。

2.2.1 Output注解

对于@Output注解来说,所注解的方法的返回值必须是MessageChannelMessageChannel也就是具体消息发送的通道。比如下面的代码:

public interface ProductSource {
    @Output
    MessageChannel hotProducts();

    @Output
    MessageChannel selectedProducts();
}

这样,我们就可以通过ProductSource所创建的消息通道来发送消息了。

2.2.2 Input注解

对于@Input注解来说,所注解的方法的返回值必须是SubscribableChannelSubscribableChannel也就是消息监听的通道。比如下面的代码:

public interface ProductSink {
    @Input
    SubscribableChannel productOrders();
}

这样,我们就可以通过ProductSink所创建的消息通道来监听消息了。

2.2.3 关于Input、Output的开箱即用

或许你有点迷糊,之前我们在代码中使用了SourceSink,那么这两个类和上面的注解什么关系呢?让我们来看一下这两个接口的源码:

// Source源码
public interface Source {

  String OUTPUT = "output";

  @Output(Source.OUTPUT)
  MessageChannel output();

}

// Sink源码
public interface Sink {

  String INPUT = "input";

  @Input(Sink.INPUT)
  SubscribableChannel input();

}

是不是有点恍然大悟呀,@Input@Output是Stream核心应用的注解,而SourceSink只不过是Stream为我们所提供开箱即用的两个接口而已,有没有这两个接口我们都可以正常使用Stream。

此外,Stream还提供了一个开箱即用的接口Processor,源码如下:

public interface Processor extends Source, Sink {
}

也就是说Processor只不过是同时可以作为消息发送和消息监听,这种接口在我们开发消息管道类型应用时会非常有用。

2.2.4 自定义消息通道名称

前面,我们讲了消息通道是Stream的一个抽象,通过该抽象可以避免与消息中间件具体的主题耦合,那么到底是怎么一回事呢?从SourceSink源码中可以看到,所注解的@Output@Input注解中都有一个参数,分别为outputinput,这个时候你再观察一下我们之前的配置:

# 商品微服务中的配置
spring.cloud.stream.bindings.output.destination=twostepsfromjava-cloud-producttopic
spring.cloud.stream.bindings.output.content-type=application/json

# Mall-Web中的配置
spring.cloud.stream.bindings.input.destination=twostepsfromjava-cloud-producttopic
spring.cloud.stream.bindings.input.content-type=application/json
spring.cloud.stream.bindings.input.group=mallWebGroup

从配置中可以看到destination属性的配置,分别指定了outputinout也就是Stream中所使用的消息通道名称。因此,我们可以通过这两个注解来分别设置消息通道的名称,比如:

public interface ProductProcessor {
    @Output("pmsoutput")
    MessageChannel productOutput();

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

这样,当我们使用ProductProcessor接口来实现消息发送和监听的时就需要在配置文件中配置如下:

# 消息发送
spring.cloud.stream.bindings.pmsoutput.destination=twostepsfromjava-cloud-producttopic
spring.cloud.stream.bindings.pmsoutput.content-type=application/json

# 消息监听
spring.cloud.stream.bindings.pmsinput.destination=twostepsfromjava-cloud-producttopic
spring.cloud.stream.bindings.pmsinput.content-type=application/json
spring.cloud.stream.bindings.pmsinput.group=mallWebGroup

2.2.5 绑定

既然,消息发送通道和监听通道都创建好了,那么将它们对接到具体的消息中间件就可以完成消息的发送和监听功能了,而@EnableBinding注解就是用来实现该功能。具体使用方式如下:

// 实现发送的绑定
@EnableBinding(Source.class)
public class Application {

}

// 实现监听的绑定
@EnableBinding(Sink.class)
public class ProductMsgListener {

}

需要说明的是,@EnableBinding可以同时绑定多个接口,如下:

@EnableBinding(value={ProductSource.class, ProductSink.class})

2.2.6 直接使用通道

前面我们消息发送的代码如下:

protected void sendMsg(String msgAction, String itemCode) {
    ProductMsg productMsg = new ProductMsg(msgAction, itemCode);
    this.logger.debug("发送商品消息:{} ", productMsg);
    
    // 发送消息
    this.source.output().send(MessageBuilder.withPayload(productMsg).build());
}

获取你在想既然@Output所提供的MessageChannel才是最终消息发送时使用的,那么我们是否可以直接使用呢?的确这个是可以的,上面的代码我们可以更改成如下:

@Service
public class ProductService {
    protected Logger logger = LoggerFactory.getLogger(ProductService.class);

    private MessageChannel output;
    private List productList;

    @Autowired
    public ProductService(MessageChannel output) {
        this.output = output;
        this.productList = this.buildProducts();
    }
    
     // 省略了其它代码
     
    /**
     * 具体消息发送的实现
     * @param msgAction 消息类型
     * @param itemCode 商品货号
     */
    protected void sendMsg(String msgAction, String itemCode) {
        ProductMsg productMsg = new ProductMsg(msgAction, itemCode);
        this.logger.debug("发送商品消息:{} ", productMsg);

        // 发送消息
        this.output.send(MessageBuilder.withPayload(productMsg).build());
    }
}

默认Stream所创建的MessageChannelBean的Id为方法名称,但是如果我们在@Output注解中增加了名称定义,如果:

public interface ProductSource {
    @Output("pmsoutput")
    MessageChannel output();
}

那么这个时候Stream会使用pmsoutput作为Bean的Id,而我们的代码也需要为如下:

@Autowired
public ProductService(@Qualifier("pmsoutput") MessageChannel output) {
    this.output = output;
    this.productList = this.buildProducts();
}

你可以到这里下载本篇的代码。

你可能感兴趣的:(Spring Cloud入门教程(九):基于消息驱动开发(Stream))