手把手一起入門 RabbitMQ 的六大使用模式(Java 客戶端)

爲什麼使用 MQ?

在這裏我就不多說了,無非就是削峯、解耦和異步。這裏沒有很多關於 MQ 的理論和概念,只想手把手帶你一起學習 RabbitMQ 的六大使用模式!

一、普通隊列

我們發送消息和接收消息時,只需要直接指定隊列的名字即可。這是最簡單的一種使用場景。

生產者:使用 channel 發送消息時,直接指定 queueName。

public class Send {

    private static final String queueName = "hyf.hello.queue";

    public static void main(String[] args) throws Exception{

        ConnectionFactory factory = ConnectionFactoryUtils.getFactory();
        try (Connection connection = factory.newConnection();
             Channel channel = connection.createChannel()){

            // 是否持久化(默認保存在內存,可以持久化到磁盤)
            boolean durable = false;
            // 是否獨有(此 Connection 獨有,通過其他 Connection 創建的 channel 無法訪問此隊列)
            boolean exclusive = false;
            // 是否自動刪除隊列(隊列沒有消費者時,刪除)
            boolean autoDelete = false;
            channel.queueDeclare(queueName, durable, exclusive, autoDelete, null);

            String message = "Hello world3!";
            // 第一個參數是交換器名字,第二個參數是 routingKey(不使用交換器時,爲隊列名稱),第三個參數是消息屬性(AMQP.BasicProperties),第四個參數是消息
            channel.basicPublish("", queueName, null, message.getBytes());
            System.out.println("發佈成功");
        }
    }
}

注意:使用 try-with-resources ,在程序結束時,我們不用顯式調用 close() 方法來關閉資源。

消費者:也是用 channel 指定 queueName,然後綁定一個交付回調。

public class Receive {

    private static final String queueName = "hyf.hello.queue";

    public static void main(String[] args) throws Exception{

        ConnectionFactory factory = ConnectionFactoryUtils.getFactory();
        Connection connection = factory.newConnection();
        Channel channel = connection.createChannel();

        channel.queueDeclare(queueName, false, false, false, null);
        // 回調(接收 RabbitMQ 服務器發送過來的消息)
        DeliverCallback deliverCallback =  (consumerTag, delivery) -> {
            String message = new String(delivery.getBody(), "UTF-8");
            System.out.println(message);
        };

        channel.basicConsume(queueName, true, deliverCallback, consumerTag -> {});
    }
}

注意:這裏我們可以不用 try-with-resource,因爲消費者需要一直運行着。

關於普通隊列,大家可以理解爲下圖:
在這裏插入圖片描述

二、工作模式(work queues)

普通隊列中,都是一個消費者去消費隊列,而在 work 模式中,是多個消費者同時去消費同一個隊列。

生產者和消費者我們還是可以用回上面的代碼。

1、循環輪詢

默認情況下,RabbitMQ 將按順序將每個消息發送給下一個使用者。平均而言,每個消費者都會收到相同數量的消息。這種分發消息的方式稱爲循環。

這樣會導致一個問題,即使其中一個消費者消費速度很快,已經消費完 RabbitMQ 消息,並且隊列中還有未消費消息(已經分派給其他消費者),那麼他也將在白白等待,RabbitMQ 而不會說將分派的消息回收重新分派給空閒的消費者。

2、自動提交消息 ack

默認情況下,消費者會不定時自動提交 ack,不管消息是否消費成功,而當 RabbitMQ 接收到消費者的 ack 消息後,會將消息添加刪除標識來標識消息已被消費成功。但是這個自動 ack 機制會導致消息丟失和消息重複消費問題。

  • 客戶端還沒消費某條消息,就自動提交了 ack,如果此時客戶端宕機了,那麼會導致這條消息消費失敗;而 RabbitMQ 在接收到 ack 時,也將這條消息標記爲已消費,那麼也無法重新消費了。
  • 客戶端已經消費某條消息,但是還沒自動提交 ack 就宕機了,此時就會導致消息重複消費,因爲 RabbitMQ 沒收到 ack 消息,那麼這條消息沒有被設置爲刪除標識,所以消費者還可以消費此條消息。

3、手動 ack 解決空閒消費者、消息丟失、消息重複消費

消費者:

a. 限制每次讀取消息數量:

我們利用 basicQos() 方法來設置 prefetchCount(預期計數) 爲1,即 限制客戶端每次都只讀取一個消息,只有當這個消息消費完了,才能繼續讀取下一個消息。

b. 手動 ack:

接着我們需要關閉自動提交 ack,並且在消費完消息後,手動提交 ack。只有當 RabbitMQ 收到 ack 消息後,纔會認定這個消息已經消費完了,繼續給消費者推送下一條新消息。

最後看看代碼:

public class Receive1 {

    private static final String queueName = "hyf.work.queue";

    public static void main(String[] args) throws Exception{

        ConnectionFactory factory = ConnectionFactoryUtils.getFactory();
        Connection connection = factory.newConnection();
        Channel channel = connection.createChannel();

        channel.queueDeclare(queueName, false, false, false, null);
        // 每次只讀取一條消息
        channel.basicQos(1);
        DeliverCallback deliverCallback =  (consumerTag, delivery) -> {
            String message = new String(delivery.getBody(), "UTF-8");
            ThreadUtil.sleep(2, TimeUnit.SECONDS);
            System.out.println(message);
            // 是否批量提交
            boolean multiple = false;
            // 手動 ack
            channel.basicAck(delivery.getEnvelope().getDeliveryTag(),multiple);
        };
        // 取消自動 ack
        boolean autoAck = false;
        channel.basicConsume(queueName, autoAck, deliverCallback, consumerTag -> {});
    }
}

總結:只有當我們使用了手動ack 和 prefetchCount = 1 ,工作模式纔算成功啓動。
在這裏插入圖片描述

4、擴展點:如何保證消息不丟失

當發送者發送消息到 RabbitMQ 後,RabbitMQ 會將消息緩存在內存中,而如果此時 RabbitMQ 宕機了,默認情況下,內存中的 queue 和 message 都會全部丟失。

而如果我們需要保證消息不丟失,那麼需要告訴 RabbitMQ 如何做;此時我們需要做的是:將 queue 和 message 都設置爲持久化。

queue 持久化:

private static final String queueName = "hyf.work.queue";

boolean durable = true;
channel.queueDeclare(queueName, durable, false, false, null);

注意:如果一開始 queue 已經定義爲不持久化,那麼我們不能重定義爲持久化;當 RabbitMQ 檢測到 queue 被重定義了,那麼會返回一個錯誤來提示我們。

message 持久化:

private static final String queueName = "hyf.work.queue";

channel.basicPublish("", queueName,
            MessageProperties.PERSISTENT_TEXT_PLAIN,
            message.getBytes());

三、發佈訂閱模式(Publish/Subscribe)

上面的 work queue,每一個消息只能被一個消費者消費。而有些場景,我們需要一個消息可以被多個消費者消費;例如:用戶下了訂單,短信通知模塊需要給用戶發送一個短信通知,庫存模塊需要根據用戶下單信息減去商品的庫存等等,此時我們需要使用發佈訂閱模式。

1、交換器 exchange

要做發佈訂閱模式,我們首先需要使用到交換器,生產者不再直接利用 channel
往 queue 發送消息,而是將消息發送到交換器,讓交換器來決定發送到哪些 queue 中。

RabbitMQ 提供了幾個類型的交換器:directtopicheadersfanout

使用發佈訂閱模式,我們只需要使用 fanout 類型的交換器,fanout 類型的交換器,會將消息發送到所有綁定到此交換器的 queue。

2、生產者發送消息:

利用 channel 聲明交換器:

// 聲明交換器名字和類型
channel.exchangeDeclare(exchangeName,"fanout");

接着我們就可以直接指定交換器進行消息發佈:

// 第二個參數是 queueName/routingKey
channel.basicPublish(exchangeName , "", null, message.getBytes())

完整代碼:

public class Send {

    private static final String exchangeName = "hyf.ps.exchange";

    public static void main(String[] args) throws IOException, TimeoutException {
        ConnectionFactory factory = ConnectionFactoryUtils.getFactory();
        try (Connection connection = factory.newConnection();
             Channel channel = connection.createChannel()){
            // 聲明 fanout 類型的交換器
            channel.exchangeDeclare(exchangeName,"fanout");
            for (int i = 0; i <= 10; i++){
                String message = "消息"+i;
                // 直接指定交換器進行消息發佈
                channel.basicPublish(exchangeName,"", null, message.getBytes());
            }
        }
    }
}

我們可以發現,我們不再需要指定 queueName,而是直接指定 exchangeName,將消息發送到交換器,由交換器決定發佈到哪些 queue。

3、消費者:queue 與 exchange 建立綁定關係

建立綁定前,我們還是需要先聲明 fanout 類型的交換器,並且命名要和生產者聲明時的名字一致:

channel.exchangeDeclare(exchangeName, "fanout");

接着,將 queue 和 fanout 類型的交換器建立綁定消息,交換器會將消息發送到和它有綁定關係的 queue。

channel.queueBind(queueName, exchangeName, "");

此時,隊列已經和交換器成功建立綁定關係,交換器接收到消息時,會發送到與交換器綁定的所有隊列中。

最後,我們再調用 channel.basicConsume() 進行隊列監聽和 綁定回調,藉此來接收和消費消息:

DeliverCallback deliverCallback = (consumerTag, delivery) -> {
    String message = new String(delivery.getBody(), "UTF-8");
    System.out.println(message);
};
channel.basicConsume(queueName, true, deliverCallback, consumerTag -> { });

完整代碼:

public class Receive1 {

    private static final String exchangeName = "hyf.ps.exchange";
    private static final String queueName = "hyf.ps.queue1";

    public static void main(String[] args) throws IOException, TimeoutException {

        ConnectionFactory factory = ConnectionFactoryUtils.getFactory();
        Connection connection = factory.newConnection();
        Channel channel = connection.createChannel();

        channel.exchangeDeclare(exchangeName,"fanout");
        channel.queueDeclare(queueName,false, false, false, null);
        channel.queueBind(queueName, exchangeName,"");

        DeliverCallback callback = (s, delivery) -> {
            String message = new String(delivery.getBody(), "UTF-8");
            System.out.println(message);
            channel.basicAck(delivery.getEnvelope().getDeliveryTag(),false);
        };

        channel.basicQos(1);
        boolean autoAck = false;
        channel.basicConsume(queueName, autoAck, callback, consumerTag -> {});
    }
}

關於發佈訂閱模式,我們可以理解爲下圖:
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-e3jgCeoJ-1594103658020)(E5914A83FBA74E08865214975E25093B)]

4、發佈丁訂閱模式中使用工作模式

發佈訂閱模式中,我們還是可以繼續使用上面的工作模式(多個消費者訂閱同一個隊列)。因爲在分佈式系統中,一個服務往往有多個實例,例如庫存模塊可以有多個實例,我們利用手動 ack 和 prefetchCount = 1,還是可以讓 fanout 類型交換器的其中一個 queue 進入工作模式。

四、路由模式(routing)

上面的發佈訂閱模式,只要是與 fanout 類型交換器綁定的 queue,都會接收到交換器發佈的消息。而我們現在的場景需要更加靈活消息分配機制。例如:error 隊列只會接收到 error 類型的信息,info 隊列只會接收都 info 類型的信息等等。

那麼我們需要是使用靈活的路由模式,而這種模式還是需要由交換器來完成,但是此時需要使用 direct 類型的交換器來替代 fanout 類型的交換器。

bindingKey 和 routingKey

做到路由模式,不但要使用 direct 類型的交換器,還需要利用 bindingKeyroutingKey 來完成。bindingKey 是消費者端的概念,而 routingKey 是生產者端的概念。

1、bingdingKey

發佈訂閱模式的消費者代碼中,我們可以發現:將 queue 與交換器建立綁定關係的 queueBind() 方法中,第三個參數是空的,其實這就是配置 bindingKey 的地方。當然了,即使第三個參數不爲空,fanout 類型的交換器還是會直接忽略掉的。

channel.queueBind(queueName, exchangeName, "");

例如現在我們的消費者要監聽 error 類型的信息,我們需要聲明 direct 類型的交換器,並且給 queue 綁定值爲 error 的 bindingKey 。

public class ErrorReceive {

    private static final String exchangeName = "hyf.routing.exchange";
    private static final String queueName = "hyf.routing.error.queue";
    private static final String bindingKey = "error";

    public static void main(String[] args) throws Exception{
        ConnectionFactory factory = ConnectionFactoryUtils.getFactory();
        Connection connection = factory.newConnection();
        Channel channel = connection.createChannel();

        // 聲明 exchange 和 queue
        channel.exchangeDeclare(exchangeName, "direct");
        channel.queueDeclare(queueName, false, false, false, null);

        // 進行綁定
        channel.queueBind(queueName, exchangeName, bindingKey);

        DeliverCallback callback = (consumerTag, delivery) -> {
            String message = new String(delivery.getBody(),"utf-8");
            System.out.println("ErrorReceive 接收到" + delivery.getEnvelope().getRoutingKey() + "消息:"+message);
            channel.basicAck(delivery.getEnvelope().getDeliveryTag(),false);
        };

        channel.basicQos(1);
        channel.basicConsume(queueName, false, callback, consumerTag -> {});
    }
}

例如現在我們的消費者2要監聽 info 類型的信息,這也是非常簡單,同樣是上面的代碼,只需要修改 queueName 和 bindingKey 即可。

// ... 省略

private static final String queueName = "hyf.info.queue";
private static final String bindingKey = "info";

// ... 省略

2、queue 綁定多個 bindingKey

上面的 hyf.error.queue 隊列,只綁定了值爲 error 的 bindingKey,如果現在我們不但需要接收 error 類型的信息,還需要 info 類型的信息,那麼我們可以爲 hyf.error.queue 再綁定多一個值爲 info 的 bindingKey。

private static final String bindingKey = "error";
private static final String bindingKey2 = "info";

// 進行綁定
channel.queueBind(queueName, exchangeName, bindingKey);
channel.queueBind(queueName, exchangeName, bindingKey2);

此時,hyf.error.queue 隊列同時綁定了 error 和 info 這兩個 bindingKey,那麼它就能同時接收到 error 類型和 info 類型的信息。

3、routingKey

在發佈訂閱模式中。我們可以看到發佈消息的 basicPublish() 方法的第二參數是空的,而第二個參數其實就是 routingKey。

channel.basicPublish( exchangeName, "", null, message.getBytes());

我們可以發現,在普通隊列和工作模式中,我們都是指定 queueName 去發送消息,而 queueName 在 basicPublish 也是第二個位置。所以,在我們不使用交換器時,routingKey 指定的就是 queueName。而當我們使用交換器時,那麼 routingKey 就有更豐富的含義了,它不再只是簡單直接的 queueName,而是各種各樣的路由含義。

要使得上面綁定了 bindingKey 爲 error 和 info 的 hyf.error.queue 隊列接收到消息,那麼需要消息發送者指定 routingKey 爲 error 或 info ,然後使用 direct 類型的交換器發佈消息。

private static final String exchangeName = "hyf.log.exchange";
private static final String routingKey = "error";
private static final String routingKey2 = "info";

channel.basicPublish(exchangeName, routingKey, null, message.getBytes());
channel.basicPublish(exchangeName, routingKey2, null, message.getBytes());

當執行上面代碼,hyf.error.queue 隊列能收到兩條消息,而 hyf.info.queue 只能收到 routingKey 爲 info 的消息。

即當 queue 綁定的 bindingKey 和發送消息時的 routingKey 完全一致,那麼 queue 就能接收到交換器發送的消息,我們可以理解爲下圖:
在這裏插入圖片描述

五、主題模式(topic)

上面的路由模式雖然能讓我們根據業務更加靈活的去接收指定(多種)類型的消息;但是我們可以發現,如果現在我們想讓消費者接收所有類型的信息,例如 error、info、debug、fail 等消息全部都要接收,那麼就要調用多次 queueBind() 方法給 queue 綁定多個 bindingKey,這就顯得有點麻煩了。

此時我們可以使用主題模式,即使用 topic 類型的交換器,然後利用 *# 這兩個符號來搞定上面的需求。

1、* 和 # 的使用

“*” 表示匹配一個字符,"#" 表示匹配0個或多個字符

2、場景

我們現在有多個 routingKey 的消息,例如用戶登陸信息 user.login.info,訂單信息 order.detail.info,用戶的註冊信息 user.register.info,庫存信息stock.detail.info 等等。

3、消費者

假設消費者1想讀取到所有關於用戶的信息,例如登陸信息和註冊時心,那麼我們可以使用 topic 類型的交換器,並且將 bindingKey 設置爲 user.#

public class UserReceive {
    private static final String exchangeName = "hyf.topic.exchange";
    private static final String bindingKey = "user.#";
    private static final String queueName = "hyf.topic.user.queue";

    @SneakyThrows
    public static void main(String[] args){

        ConnectionFactory factory = ConnectionFactoryUtils.getFactory();
        Connection connection = factory.newConnection();
        Channel channel = connection.createChannel();

        channel.exchangeDeclare(exchangeName, "topic");
        channel.queueDeclare(queueName, false, false, false, null);
        channel.queueBind(queueName, exchangeName, bindingKey);

        DeliverCallback callBack = (consumerTag, delivery) -> {
            String msg = new String(delivery.getBody(), "utf-8");
            System.out.println("接收到一條user消息:"+msg);
            channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
        };
        channel.basicQos(1);
        channel.basicConsume(queueName, false, callBack, consumerTag -> {});
    }
}

假設消費者2 要接收所有上面關於信息的消息,那麼他的 bindingKey 可以設置爲 *.*.info

public class InfoReceive {

    private static final String exchangeName = "hyf.topic.exchange";
    private static final String bindingKey = "*.*.info";
    private static final String queueName = "hyf.topic.info.queue";

    @SneakyThrows
    public static void main(String[] args){

        ConnectionFactory factory = ConnectionFactoryUtils.getFactory();
        Connection connection = factory.newConnection();
        Channel channel = connection.createChannel();

        channel.exchangeDeclare(exchangeName, "topic");
        channel.queueDeclare(queueName, false, false, false, null);
        channel.queueBind(queueName, exchangeName, bindingKey);

        DeliverCallback callback = (consumerTag, delivery) -> {
            String msg = new String(delivery.getBody(), "utf-8");
            System.out.println("接收到一條info消息:"+msg);
            channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
        };

        channel.basicQos(1);
        channel.basicConsume(queueName, false, callback, consumerTag -> {});
    }
}

4、生產者

生產者也需要使用 topic 類型的交換器發送消息。

public class Send {

    private static final String exchangeName = "hyf.topic.exchange";
    private static final String routingkeyByLogin = "user.login.info";
    private static final String routingkeyByRegister = "user.register.info";
    private static final String routingkeyByOrder = "order.detail.info";
    private static final String routingkeyByStock = "stock.detail.info";

    public static void main(String[] args) throws Exception{

        ConnectionFactory factory = ConnectionFactoryUtils.getFactory();
        try (Connection connection = factory.newConnection();
             Channel channel = connection.createChannel()){
            channel.exchangeDeclare(exchangeName, "topic");

            String msg1 = "用戶張三登陸了";
            String msg2 = "新用戶李四註冊了";
            String msg3 = "張三買了一臺iphone12";
            String msg4 = "iphone12庫存減一";

            channel.basicPublish(exchangeName, routingkeyByLogin, null, msg1.getBytes());
            channel.basicPublish(exchangeName, routingkeyByRegister, null, msg2.getBytes());
            channel.basicPublish(exchangeName, routingkeyByOrder, null, msg3.getBytes());
            channel.basicPublish(exchangeName, routingkeyByStock, null, msg4.getBytes());
        }
    }
}

經過上面的代碼發佈消息,消費者1就能讀取到消息 msg1、msg2;而消費者2可以讀取到所有的消息。

關於主題模式,大家可以理解爲下圖:
在這裏插入圖片描述

六、RPC 模式

正常用 MQ 都是用來做異步化,但是有些場景卻需要同步。即當我們使用 channel 發送消息後,我們需要同步等待消費者對消息消費後的結果。

RPC 模式主要是利用 replyQueue 和 correlationId 來完成。

1、客戶端

客戶端往 requestQueue 發送消息時需要設置 replyQueue,之後我們需要給 replyQueue 綁定一個 DeliverCallback。

爲了保證客戶端是同步阻塞等待結果,所以我們在 DeliverCallback 的 handle 方法裏面,將結果放進阻塞隊列(例如 ArrayBlockingQueue);在代碼的最後調用阻塞隊列的 take() 方法在獲取結果。

public class Client {

    private static final String replyQueueName = "hyf.rpc.reply.queue";
    private static final String requestQueueName = "hyf.rpc.request.queue";

    public static void main(String[] args) throws Exception{

        ConnectionFactory factory = ConnectionFactoryUtils.getFactory();
        Connection connection = factory.newConnection();
        Channel channel = connection.createChannel();

        channel.queueDeclare(replyQueueName, false, false, false, null);
        // 阻塞隊列
        final BlockingQueue<String> responseQueue = new ArrayBlockingQueue<>(1);

        final String corrId = UUID.randomUUID().toString();
        AMQP.BasicProperties properties = new AMQP.BasicProperties.Builder()
                .replyTo(replyQueueName)
                .correlationId(corrId)
                .build();
        String msg = "客戶端消息";
        channel.basicPublish("", requestQueueName, properties, msg.getBytes());


        String ctag = channel.basicConsume(replyQueueName, true, (consumeTag,delivery) -> {
            if (delivery.getProperties().getCorrelationId().equals(corrId)) {
                responseQueue.offer(new String(delivery.getBody(), "UTF-8"));
            }
        }, consumeTag -> {});

        String result = responseQueue.take();
        System.out.println(result);
        // 取消訂閱
        channel.basicCancel(ctag);
    }
}

通過上面代碼,我們應該可以留意到 correlationId 的意義是什麼。利用 correlationId ,我們可以判斷當前從 replyQueue 獲取的響應消息是否是我們發出的消息消費後的結果,如果不是我們可以直接忽略掉,保證只會獲取 correlationId 一致的結果。

2、服務端

服務端在 DeilverCallback 的 handle() 方法裏讀取 requestQueue 裏面的消息消費後,在手動 ack(關閉了自動 ack)前,需要先拿到消息的 replyQueue,然後往 replyQueue 裏面發送消息消費後的結果,當然了,還要記得設置回消息的 correlatinId,最後記得手動 ack。

public class Server {

    private static final String requestQueueName = "hyf.rpc.request.queue";

    public static void main(String[] args) throws Exception {

        ConnectionFactory factory = ConnectionFactoryUtils.getFactory();
        Connection connection = factory.newConnection();
        Channel channel = connection.createChannel();

        channel.queueDeclare(requestQueueName, false, false, false, null);
        DeliverCallback callback = (consumerTag, delivery) -> {
            String msg = new String(delivery.getBody(), "utf-8");
            // 處理消息
            String reponse = handleMsg(msg);
            // 將消息的 correlationId 傳回去
            AMQP.BasicProperties replyProps = new AMQP.BasicProperties
                    .Builder()
                    .correlationId(delivery.getProperties().getCorrelationId())
                    .build();
            channel.basicPublish("", delivery.getProperties().getReplyTo(), replyProps, reponse.getBytes());
            channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);
        };

        channel.basicQos(1);
        channel.basicConsume(requestQueueName, false, callback, consumeTag -> {});

    }

    private static String handleMsg(String msg){
        return msg + "已經被處理了";
    }
}

關於 RPC 模式,大家可以理解爲下圖:
在這裏插入圖片描述

七、總結

到此,關於 RabbitMQ 的六大使用模式已經介紹完畢。當然了,這些都是入門級別的 demo,如果大家還是有啥不明白的,可以到我的 github 上去看看,完整的代碼都放在:MQ Demo。後續,我將會繼續深入學習 RabbitMQ 的 Java Client,學習如何優化客戶端的使用性能。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章