Publish/Subscribe

130 阅读5分钟

原文链接

在上一节 Work Queues 中,我们已经学会如何创建一个工作队列了。工作队列是假定每一条消息只发送给一个消费者的情况下使用的模式,在一条消息需要多个消费者的时候并不适用。比如在电商开发中,存在多个系统都在关心订单的状态,这种情况下需要如何实现?在本教程中,我们要实现和上一节完全不同的模式 -- 发布 & 订阅模式。

我们将使用一个简单的日志系统来模拟,编写 2 个程序,一个用来发送日志消息,一个用来接收消息。

在日志系统中,所有负责接收消息的程序都将会接收到发送出来的消息。启动 2 个消费者,一个消费者会将程序存储到磁盘中,另一个则会将消息打印出来。

所有已经启动消费者,都会收到发送出来的消息

Exchanges

在上一节的中,我们通过队列来发送和接收消息,其实这并不是 RabbitMQ 中完整的消息模型。

回顾下上节中的内容:

  • 消息生产者: 发送消息的用户应用程序
  • 队列: 存储消息的缓存区
  • 消息消费者: 接收消息的用户应用程序

在完整的 RabbitMQ 的消息模型中,消息生产者从不会将消息直接丢到队列中,这一点至关重要。其实在大部分情况下,消息生产者根本不知道消息会被丢到哪一个队列中去。

消息生产者会将消息丢到交换机(exchange)中,交换机只负责两件事: 1. 接收生产者丢过来的消息; 2. 将接收到的消息丢到队列中。交换机必须确切的知道要如何处理接收到的消息: 要将消息丢到哪个队列中?是否需要将消息丢到所有的队列中?还是应该忽略这条消息?这些规则都由交换机的类型决定。

exchanges

交换机主要有 4 种类型: directtopicheadersfanout。本教程将要介绍 fanout

声明交换机

err = ch.ExchangeDeclare(
    "logs",    // name
    "fanout",  // type
    true,      // durable
    false,     // auto-deleted
    false,     // internal
    nil,	   // arguments
)

fanout 类型的交换机非常的简单,它会将同一条消息分发给所有的队列。

查看交换机

可以使用下面的命令来查看 RabbitMQ 服务器上的交换机

sudo rabbitmqctl list_exchanges

执行命令后,可以看到一些名称是 amqp.* 的交换机,还有一个没有名称的交换机。这些都是默认创建的交换机,目前来说,还用不上这些。

默认交换机

在上一个教程中,我们没有创建任何的交换机,但还是可以将消息丢到队列中。这是因为我们使用了那个没有名称的默认交换机("")

err = ch.PublishWithContext(ctx,
    "",     // exchange
    q.Name, // routing key
    false,  // mandatory
    false,  // immediate
    amqp.Publishing{
         ContentType: "text/plain",
         Body:        []byte(body),
    })

这里使用了默认或者是匿名的交换机,消息将路由到具有 routing_key 参数指定的名称(如果存在)的队列

现在我们可以将消息发送到 fanout 交换机上

err = ch.ExchangeDeclare(
  "logs",   // name
  "fanout", // type
  true,     // durable
  false,    // auto-deleted
  false,    // internal
  false,    // no-wait
  nil,      // arguments
)
failOnError(err, "Failed to declare an exchange")

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

body := bodyFrom(os.Args)
err = ch.PublishWithContext(ctx,
  "logs", // exchange
  "",     // routing key
  false,  // mandatory
  false,  // immediate
  amqp.Publishing{
          ContentType: "text/plain",
          Body:        []byte(body),
  })

Temporary queues

在上一个教程中,我们给队列起了两个名字: hellotask_queue。如果我们想要生产者和消费者共享一个队列,那么队列名称是非常重要的。

但本教程中的日志系统却不需要这样做,我们希望所有的消费者都能收到所有的消息,而不是一部分消息。为此需要做两件事:

  1. 每当我们连接到 RabbitMQ 时,都需要一个新的空队列,最好是由 RabbitMQ 选择的随机队列名称
  2. 当消费者断开连击,队列会自动删除

当使用 amqp 框架创建一个名称为空字符串且 non-durable 的队列时,RabbitMQ 就会给队列一个随机的名称。

q, err := ch.QueueDeclare(
  "",    // name
  false, // durable
  false, // delete when unused
  true,  // exclusive
  false, // no-wait
  nil,   // arguments
)

可以检查下 q 的 name,大致长得这个样子: amq.gen-JzTY20BRgKO-HjmUJj0wLg

当链接断掉时,自动生成的队列会被删除,因为声明时队列的 exclusive 属性是 true。更多关于 队列属性

Bindings

bindings

我们已经创建了一个 fanout 交换机和匿名队列。交换机想要将消息加入到队列中还需要进行一个绑定的操作

err = ch.QueueBind(
  q.Name, // queue name
  "",     // routing key
  "logs", // exchange
  false,
  nil,
)

现在,logs的交换机就可以将消息加入到队列中了

查看 bindings

可以使用这个命令查看目前交换机和队列的绑定关系

rabbitmqctl list_bindings

Putting it all together

发送程序基本上没有太大的改动,主要是将使用 logs替换了原来的匿名交换机。另外还需要提供一个 routingKey,不过因为logsfanout 类型的交换机,所以设置了也会被忽略😒

// emit_log.go

package main

import (
	"context"
	"log"
	"os"
	"strings"
	"time"
  
	amqp "github.com/rabbitmq/amqp091-go"
)

func failOnError(err error, msg string) {
	if err != nil {
		log.Panicf("%s: %s", msg, err)
	}
}

func main() {
	// 1. 建立连接
	conn, err := amqp.Dial("amqp://admin:admin@192.168.2.14:5672/")
	failOnError(err, "Failed to connect to RabbitMQ")
	defer conn.Close()

	// 2. 打开 channel
	ch, err := conn.Channel()
	failOnError(err, "Failed to open a channel")
	defer ch.Close()

	// 3. 声明交换机
	err = ch.ExchangeDeclare(
		"logs",
		"fanout",
		true,
		false,
		false,
		false,
		nil)
	failOnError(err, "Failed to declare an exchange")

	// 4. 创建上下文
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	// 5. 构造消息
	body := bodyFrom(os.Args)
	err = ch.PublishWithContext(ctx,
		"logs",
		"",
		false,
		false,
		amqp.Publishing{
			ContentType: "text/pain",
			Body:        []byte(body),
		},
	)
	failOnError(err, "Failed to publish a message")
	log.Printf(" [x] Sent %s", body)
}

func bodyFrom(args []string) string {
	var s string
	if len(args) < 2 || os.Args[1] == " " {
		s = "hello"
	} else {
		s = strings.Join(args[1:], " ")
	}
	return s
}

(emit_log.go source)

在与 RabbitMQ 建立连接后,声明了一个交换机。这一步是必须的,RabbitMQ不允许使用未声明的交换机。

如果交换机没有绑定队列的话,消息是会丢失的。但在这个教程中,这是合理的,如果没有消费者(交换机没有绑定队列),那么消息丢失就丢失吧

// receive_logs.go

package main

import (
	"log"

	amqp "github.com/rabbitmq/amqp091-go"
)

func failOnError(err error, msg string) {
	if err != nil {
		log.Panicf("%s: %s", msg, err)
	}
}

func main() {
	// 1. 建立连接
	conn, err := amqp.Dial("amqp://guest:guest@localhost:5672/")
	failOnError(err, "Failed to connect to RabbitMQ")
	defer conn.Close()

	// 2. 打开 channel
	ch, err := conn.Channel()
	failOnError(err, "Failed to open a channel")
	defer ch.Close()

	// 3. 声明交换机
	err = ch.ExchangeDeclare(
		"logs",
		"fanout",
		true,
		false,
		false,
		false,
		nil)
	failOnError(err, "Failed to declare an exchange")

	// 4. 声明队列
	q, err := ch.QueueDeclare(
		"",
		false,
		false,
		false,
		false,
		nil)
	failOnError(err, "Failed to declare a queue")

	// 5. 绑定队列
	err = ch.QueueBind(
		q.Name,
		"",
		"logs",
		false,
		nil)
	failOnError(err, "Failed to bind a queue")

	// 6. 注册消费者
	msgs, err := ch.Consume(
		q.Name,
		"",
		true,
		false,
		false,
		false,
		nil,
	)
	failOnError(err, "Failed to register a consumer")

	// 阻塞线程
	var forever chan struct{}
	go func() {
		for d := range msgs {
			log.Printf(" [x] %s", d.Body)
		}
	}()
	log.Printf(" [*] waiting for logs. To exit press CTRL+C")
	<-forever
}

如果你想将日志保存到文件中,使用下面命令启动程序

go run receive_logs.go > logs_from_rabbit.log

只想在控制台输出的话,可以用

go run receive_logs.go

启动发送消息程序

go run emit_log.go

使用 rabbitmqctl list_bindings 命令可以查看 RabbitMQ 中绑定情况,启用 2 个消费者会看到如下所示:

sudo rabbitmqctl list_bindings
# => Listing bindings ...
# => logs    exchange        amq.gen-JzTY20BRgKO-HjmUJj0wLg  queue           []
# => logs    exchange        amq.gen-vso0PVvyiRIL2WoV3i48Yg  queue           []
# => ...done.

可以看到 logs 绑定的队列名称确实是 RabbitMQ 自动给予的。