【后端之旅】消息服务中间件 RabbitMQ 篇

264 阅读9分钟

后端接收到大量请求后,由于服务资源有限,服务端并不希望马上处理该请求。要解决这个问题,就可以使用消息服务中间件,就让请求等一会吧。

RabbitMQ 是什么

MQ 是 Message Queue 的缩写,意为消息队列,是一种应用程序对应用程序的通信方式。

RabbitMQ 是 MQ 中的一种,它采用 Erlang 语言开发,并实现了 AMQP 协议,是被广泛使用的开源消息中间件。

RabbitMQ 的特点为性能良好、延时低、拥有友好的管理界面,但缺点是吞吐量较低(只达到万级,而 Kafka 可达十万级甚至百万级)。

RabbitMQ 安装

请参考 RabbitMQ 3.12 安装教程 进行安装即可。

RabbitMQ 使用场景

  • 流量削峰
  • 日志处理
  • 应用解耦
  • 异步处理

AMQP 协议

AMQP 的全程为 Advanced Message Queuing Protocol,即高级消息队列协议。

AMQP.png

在图中,生产者和消费者应当共用一个 connection 单例,这里只是为了方便理解才画成了两个。

Java 操作 RabbitMQ

为了让后面的知识点更容易被初学者理解,让我们先实现一个 “简单的生产与消费” 案例。它们均依赖于 amqp-client-5.1.2.jar

  • 工具类

    import com.rabbitmq.client.*;
    
    public final class RabbitmqUtil {
        static final ConnectionFactory factory;
        static final Connection connection;
    
        static {
            // 创建连接工厂
            factory = new ConnectionFactory();
            // 设置 RabbitMQ 服务地址
            factory.setHost("0.0.0.0");
            // 设置 RabbitMQ 服务端口
            factory.setPort(5672);
            // 设置账号信息: vhost、用户名、密码
            factory.setVirtualHost("/");
            factory.setUsername("guest");
            factory.setPassword("guest");
    
            // 通过工厂创建连接
            try {
                connection = factory.newConnection();
            } catch (IOException e) {
                throw new RuntimeException(e);
            } catch (TimeoutException e) {
                throw new RuntimeException(e);
            }
        }
    
        public static Connection getConnection() {
            return connection;
        }
    }
    
  • 生产者

    import com.rabbitmq.client.*;
    
    public class Producer {
        private final static String QUEUE_NAME = "queue_ego";
    
        public static void main(String[] args) throws IOException, TimeoutException {
            try (
                // 获取 RabbitMQ 连接
                Connection connection = RabbitmqUtil.getConnection();
                // 在连接中创建通道
                Channel channel = connection.createChannel();
            ) {
                /**
                 * 为通道声明并创建队列(如果队列已存在,则直接使用已有队列),各参数的含义:
                 * 1. 队列名称
                 * 2. 是否持久化
                 * 3. 是否为独占模式
                 * 4. 是否自动删除队列中的消息,为 true 则在断开连接后删除消息
                 * 5. 额外参数
                 */
                channel.queueDeclare(QUEUE_NAME, false, false, false, null);
                // 定义消息内容
                String message = "Hello World!";
                /**
                 * 将消息内容发送至队列,各参数的含义:
                 * 1. 交换机名称
                 * 2. 队列名称
                 * 3. BasicProperties 实例
                 * 4. 消息字符数组
                 */
                channel.basicPublish("", QUEUE_NAME, null, message.getBytes());
    
                System.out.println("已发送: " + message);
            }
        }
    }
    
  • 消费者

    import com.rabbitmq.client.*;
    
    public class Consumer {
        private final static String QUEUE_NAME = "queue_ego";
    
        public static void main(String[] args) throws IOException, TimeoutException {
            try (
                // 获取 RabbitMQ 连接
                Connection connection = RabbitmqUtil.getConnection();
                // 在连接中创建通道
                Channel channel = connection.createChannel();
            ) {
                /**
                 * 为通道声明并创建队列(如果队列已存在,则直接使用已有队列),各参数的含义:
                 * 1. 队列名称
                 * 2. 是否持久化
                 * 3. 是否为独占模式
                 * 4. 是否自动删除队列中的消息,为 true 则在断开连接后删除消息
                 * 5. 额外参数
                 */
                channel.queueDeclare(QUEUE_NAME, false, false, false, null);
                // 定义队列的消费者
                DefaultConsumer dc = new DefaultConsumer(channel) {
                    /**
                     * 处理接收到的消息
                     * @param consumerTag 同一个会话,consumerTag 是固定的,即作为会话的名字
                     * @param envelope 可通过该对象获取当前消息的编号、发送的队列、交换机信息
                     * @param properties 随消息一起发送的其他属性
                     * @param body 消息内容
                     * @throws IOException
                     */
                    @Override
                    public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
                        String message = new String(body);
    
                        System.out.println("已接收: " + message);
                    }
                };
                /**
                 * 为消费者创建一个监听器,各参数的含义:
                 * 1. 队列名称
                 * 2. 是否自动确认收到消息,为 false 则需要手动确认
                 * 3. 消费者句柄
                 */
                channel.basicConsume(QUEUE_NAME, true, dc);
                
                // 为了持续监听消息,阻塞当前线程,否则程序会自动结束并退出
                while (true) {
                    Thread.sleep(1000);  
                }
            }
        }
    }
    

我们先运行消费者,再运行生产者,即可查看到生产者生产的 "Hello World!" 被消费者消费了。有了这个基本的生产消费概念,我们就可以进入比较复杂的 RabbitMQ 消息类型 章节了。

RabbitMQ 消息类型

  • hello

    graph LR
       A((P)) --> B[[Queue]]
       B --> C((C))
       style A fill:#A9DEF9
       style B fill:#EDE7B1
       style C fill:#D3F8E2
    

    一个生产者把消息发送给一个队列,该队列只被一个消费者所使用,采用该消息类型。

    代码请查看前面的的 Java 操作 RabbitMQ

  • work

    graph LR
       A((P)) --> B[[Queue]]
       B --> C1(("C₁"))
       B --> C2(("C₂"))
       style A fill:#A9DEF9
       style B fill:#EDE7B1
       style C1 fill:#D3F8E2
       style C2 fill:#D3F8E2
    

    一个生产者把消息发送给一个队列,该队列只被多个消费者所使用,采用该消息类型。

    与 “简单的生产与消费” 相比,消费者需要做以下设置:

    // ...
    
    // 设置消费者预取的消费数量(被预取的消息如果未处理完,则不再从消息队列中获取更多消息)
    channel.basicQos(1);
    
    // ...
    
    DefaultConsumer dc = new DefaultConsumer(channel) {
        @Override
        public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
            // ...
    
            /**
             * 手动返回一个回执确认,参数含义:
             * 1. 需要回执确认的消息编号
             * 2. 是否批量确认
             */
            channel.basicAck(envelope.getDeliveryTag(), false);
        }
    };
    
    // ...
    
    // 不再自动确认收到消息,改为在 handleDelivery 方法中手动确认
    channel.basicConsume(QUEUE_NAME, false, dc);
    
  • fanout

    graph LR
       A((P)) --> X{{X}}
       X --> Q1[["Q₁"]]
       X --> Q2[["Q₂"]]
       Q1 --> C1(("C₁"))
       Q2 --> C2(("C₂"))
       style A fill:#A9DEF9
       style X fill:#FAE0E4
       style Q1 fill:#EDE7B1
       style Q2 fill:#EDE7B1
       style C1 fill:#D3F8E2
       style C2 fill:#D3F8E2
    

    一个生产者把消息发送给交换机,交换机再直接分发给不同的队列,采用该消息类型。

    之前的消息种类对应的代码,消息内容都是生产者直接发送到消息队列上。在该消息种类中的代码,消息内容是由生产者发送到交换机上(由交换机分发给不同的消息队列):

    private final static String EXCHANGE_NAME = "exchange_fanout";
    
    // ...
    
    // 声明交换机
    channel.exchangeDeclare(EXCHANGE_NAME, "fanout");
    
    /**
     * 将消息内容发送至交换机,各参数的含义:
     * 1. 交换机名称
     * 2. 队列名称
     * 3. BasicProperties 实例
     * 4. 消息字符数组
     */
    channel.basicPublish(EXCHANGE_NAME, "", null, message.getBytes());
    
    System.out.println("已发送: " + message);
    

    消费者则需要声明队列,并将队列绑定到交换机上

    // ...
    
    // 声明队列
    channel.queueDeclare(QUEUE_NAME, false, false, false, null);
    
    // 将队列绑定到交换机
    channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, "");
    
    // 设置消费者预取的消费数量
    channel.basicQos(1);
    
    // ...
    
  • direct

    graph LR
       A((P)) --> X{{X}}
       X -- a --> Q1[["Q₁"]]
       X -- a --> Q2[["Q₂"]]
       X -- b --> Q2[["Q₂"]]
       X -- c --> Q2[["Q₂"]]
       Q1 --> C1(("C₁"))
       Q2 --> C2(("C₂"))
       style A fill:#A9DEF9
       style X fill:#FAE0E4
       style Q1 fill:#EDE7B1
       style Q2 fill:#EDE7B1
       style C1 fill:#D3F8E2
       style C2 fill:#D3F8E2
    

    一个生产者把消息发送给交换机,交换机根据消息的 routingKey 分发给不同的队列(一个队列可以指定多个 routingKey,一个 routingKey 可以被多个队列同时指定),采用该消息类型。该消息类型对应的交换机模式又叫路由模式。

    生产者在声明交换机时,使用 "direct" 参数:

    private final static String EXCHANGE_NAME = "exchange_direct";
    
    // ...
    
    // 声明交换机
    channel.exchangeDeclare(EXCHANGE_NAME, "direct");
    
    /**
     * 将消息内容发送至交换机,各参数的含义:
     * 1. 交换机名称
     * 2. 队列名称(或者 队列绑定的路由名称之一)
     * 3. BasicProperties 实例
     * 4. 消息字符数组
     */
    channel.basicPublish(EXCHANGE_NAME, "info", null, message.getBytes());
    
    // ...
    

    消费者则需要声明队列时指定路由名称,并将队列绑定到交换机上

    // ...
    
    // 声明队列
    channel.queueDeclare(QUEUE_NAME, false, false, false, null);
    
    // 将队列绑定到交换机(多个则表示要绑定不同的 routingKey)
    channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, "info");
    channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, "error");
    
    // 设置消费者预取的消费数量
    channel.basicQos(1);
    
    // ...
    
  • topic

    graph LR
       A((P)) --> X{{X}}
       X -- *.a.* --> Q1[["Q₁"]]
       X -- *.*.b --> Q2[["Q₂"]]
       X -- c.# --> Q2[["Q₂"]]
       Q1 --> C1(("C₁"))
       Q2 --> C2(("C₂"))
       style A fill:#A9DEF9
       style X fill:#FAE0E4
       style Q1 fill:#EDE7B1
       style Q2 fill:#EDE7B1
       style C1 fill:#D3F8E2
       style C2 fill:#D3F8E2
    

    一个生产者把消息发送给交换机,交换机根据消息的 topic 分发给不同的队列(一个队列可以指定多个 topic),采用该消息类型。该消息类型对应的交换机模式又叫 topics 模式。

    在 topics 串中,* 代表一个标记符(单词),# 代表多个标记符(单词)用点号分隔。

    生产者在声明交换机时,使用 "topic" 参数:

    private final static String EXCHANGE_NAME = "exchange_topic";
    
    // ...
    
    // 声明交换机
    channel.exchangeDeclare(EXCHANGE_NAME, "topic");
    
    /**
     * 将消息内容发送至交换机,各参数的含义:
     * 1. 交换机名称
     * 2. 队列名称(或者 队列绑定的某个 topic)
     * 3. BasicProperties 实例
     * 4. 消息字符数组
     */
    channel.basicPublish(EXCHANGE_NAME, "cc.123", null, message.getBytes());
    
    // ...
    

    消费者则需要声明队列时指定 topic 名称,并将队列绑定到交换机上

    // ...
    
    // 声明队列
    channel.queueDeclare(QUEUE_NAME, false, false, false, null);
    
    // 将队列绑定到交换机(多个则表示要绑定不同的 topic)
    channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, "cc.*");
    channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, "#.ego");
    
    // 设置消费者预取的消费数量
    channel.basicQos(1);
    
    // ...
    
  • RPC

    graph LR
       A((Client)) -- request --> Q1[[RPC]]
       Q1 --> C((Server))
       C --> Q2[[Reply]]
       Q2 -- reply --> A
       style A fill:#A9DEF9
       style Q1 fill:#EDE7B1
       style Q2 fill:#EDE7B1
       style C fill:#D3F8E2
    

    Client 发起请求时,会携带 reply_tocorrelation_id 参数,其中 reply_to 是 Server 识别 Client 的唯一标识。correlation_id 则是每个请求的唯一标识。

    在该类型下,生产者作为客户端,不再声明交换机,而是创建回调队列:

    /**
     * 类名不再是 Producer,而是 Client
     */
    
    // 定义位于 Server 的队列名
    private final static String RPC_QUEUE_NAME = "rpc_queue";
    
    // ...
    
    // 创建回调队列
    String replyQueue = channel.queueDeclare().getQueue;
    
    // 客户端作为消费者时从回调队列中接收服务端传送的消息
    QueueingConsumer consumer = new QueueingConsumer(channel);
    // 监听回调队列(把 consumer 绑定到回调队列)
    channel.basicConsume(replyQueue, true, consumer);
    
    // 创建带有 correlation_id 的消息属性
    String correlationId = UUID.randomUUID().toString();
    AMQP.BasicProperties basicProperties = new AMQP.BasicProperties()
        .builder()
        .correlationId(correlationId)
        .replyTo(replyQueue)
        .build();
        
    String message = "Hi from client";
    
    /**
     * 将消息内容发送至交换机,各参数的含义:
     * 1. 交换机名称
     * 2. 队列名称
     * 3. BasicProperties 实例
     * 4. 消息字符数组
     */
    channel.basicPublish("", RPC_QUEUE_NAME, basicProperties, message.getBytes());
    
    // ...
    
    // 接收回调消息
    while (true) {
        QueueingConsumer.Delivery delivery = consumer.nextDelivery();
        String receivedCorrelationId = delivery.getProperties().getCorrelationId();
        if (correlationId.equals(receivedCorrelationId)) {
            System.out.println("获得 Server 的回调消息:" + new String(delivery.getBody()));
            break;
        }
    }
    

    在该类型下,消费者作为服务端。

    /**
     * 类名不再是 Consumer,而是 Server
     */
    
    // 定义当前的队列名
    private final static String RPC_QUEUE_NAME = "rpc_queue";
    
    // ...
    
    // 声明队列
    channel.queueDeclare(RPC_QUEUE_NAME, false, false, false, null);
    
    // 设置消费者预取的消费数量
    channel.basicQos(1);
    
    // 服务端作为消费者时从回调队列中接收客户端传送的消息
    QueueingConsumer consumer = new QueueingConsumer(channel);
    // 监听队列(把 consumer 绑定到当前队列上)
    channel.basicConsume(RPC_QUEUE_NAME, false, consumer);
    
    // 接收客户端消息
    while (true) {
        QueueingConsumer.Delivery delivery = consumer.nextDelivery();
        
        System.out.println("获得 Client 的发送消息:" + new String(delivery.getBody()));
        
        // 确认收到消息
        channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
        
        // 取出消息的 correlation_id
        AMQP.BasicProperties properties = delivery.getProperties();
        String correlationId = properties.getCorrelationId();
        
        // 创建具有与接收消息相同的 correlation_id 的消息属性
        AMQP.BasicProperties replyProperties = new AMQP.BasicProperties()
            .builder()
            .correlationId(correlationId)
            .build();
        
        // properties.getReplyTo() 可获得回调队列名(Client 创建的)
        channel.basicPublish("", properties.getReplyTo(), replyProperties, "Good!".getBytes());
    }
    

SpringBoot 集成 RabbitMQ

首先,在项目根目录下的 pom 文件中引入 amqp 依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

然后在配置模块的资源目录(如 config/src/main/resources)中的配置文件 bootstrap.yml 中加入以下配置:

spring:
    rabbitmq:
        host: localhost
        port: 5672
        virtual-host: /
        username: guest  
        password: guest

如果您使用的配置文件不是 bootstrap.yml,而是 application.properties,则添加以下配置:

spring.rabbitmq.host: localhost
spring.rabbitmq.port: 5672
spring.rabbitmq.virtual-host: /
spring.rabbitmq.username: guest  
spring.rabbitmq.password: guest

接下来就是在生产者代码引入 AmqpTemplate ,消费者代码引入 RabbitListenerRabbitHandler 即可:

  • 生产者

    @Component
    import org.springframework.amqp.core.AmqpTemplate;
    
    public class MessageSender {
        @Autowired
        private AmqpTemplate rabbitmqTemplate;
    
        public void send() {
            String message = "Hello from sender";
    
            this.rabbitmqTemplate.convertAndSend("queue_name", message);
        }
    
        // ...
    }
    
  • 消费者

    import org.springframework.amqp.rabbit.annotation.RabbitListener;
    import org.springframework.amqp.rabbit.annotation.RabbitHandler;
    
    @Component
    @RabbitListener(queues = "queue_name")
    public class MessageReceiver {
        @RabbitHandler
        public void process(String message) {
            System.out.println("已接收: " + message);
        }
    
        // ...
    }
    
  • 测试代码

    @SpringBootTest
    public class RabbitmqTest {
        @Autowired
        private MessageSender messageSender;
        
        @Test
        public void sayHi() throws Exception {
            messageSender.send();
        }
    }
    

小结

本文简单地介绍了 RabbitMQ 是什么、有啥用、如何用。如果您是初学者,那本文应该算个不错的入门教程。至于更复杂的使用、RabbitMQ 的底层原理、搭建 RabbitMQ 集群等进阶知识,请等待未来的文章。