在前一篇介绍中实现了一个工作队列,它假设队列中的每一个任务都只会被分发到一个工作者进行处理。在本篇中,我们尝试将同一个消息发送给多个消费者进行处理,这就是广为人知的发布/订阅模式。

本篇通过搭建一个日志系统来阐述发布/订阅模式,它包含两部分内容:一个用于产生日志消息的程序,另一个用于接收和打印消息。
在这个日志系统中,每一份接收者程序的拷贝都能收到消息,因此我们可以轻易地使用一个程序将日志写入磁盘,而另一个程序直接在屏幕显示。

本质上来说,当系统收到一个日志处理请求时,会把这个消息广播给所有的接收者。

Exchanges

之前的介绍中,我们都是以队列为中介进行消息的发送和接收,现在将完整的介绍一下RabbitMQ的消息模式。

对前述内容做一个简单总结:

  • 一个producter(生产者)是指用于发送消息的用户程序;
  • 一个queue(队列)是用来存储消息的缓冲区;
  • 一个consumer(消费者)是用来接收消息的用户程序;

RabbitMQ消息模式的核心内容是一个producter永远不会将消息直接发送给队列。

Producter甚至都不知道其产生的消息会被分发到哪一个队列,实际上,producter只会将消息发送给exchange.Exchange很好理解,类似于一个中转站,它就是将从producter中接收到的消息转发给与之绑定的队列。

当Exchange接收到消息后,它是如何来确定对消息进行处理的呢?是将消息发送到指定的一个队列,还是广播到所有队列,或者是直接将其忽略?

这一切都由Exchange定义是的类型(type)来控制。

golang redis 订阅 golang 发布订阅_服务器

RabbitMQ共有四种exchange类型:direct, topic, headers, fanout.我们这里使用的最后一种fanout,现在就让我们来定义一个type,取名logs:

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

这个fanout类型的exchange很简单,顾名思义:它将所接收到的消息广播给所有绑定的队列。这也正是日志系统说要做的工作。

查看exchange
可以通过rabbitmqctl命令来查看所有的exchanges:

sodu rabbitmqctl list_exchanges

命令执行后,会列出一些amq.*名称的exchanges,这是系统默认存在的,我们现在还用不到这些。

默认的exchange


你可能会感到奇怪,前面例子并没有提及生产者只能将消息发送给exchange,为什么程序仍能将消息发送给队列?原因在于我们使用了一个默认的exchange,代码中就是Publish函数的参数使用了空字符串"":

来看看之前的publish代码:

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

使用一个无命名的或默认的exchange,消息将会根据routing_key所指定的参数进行查找,如果存在就会分发到相应的队列。

既然讲到的exchange,那么我们可以使用前面定义的exchange来代替默认值:

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")

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

临时队列

不出意外,你应该还对前面使用过的两个命名队列(hello和task_queue)有印象,在使用命名队列时必须让生产者和消费者都是用同一个名称的队列,否则消息将无法在两者之间进行传递。

但在这里名字不是关心的重点,因为我们的日志系统需要记录所有的消息,而不是其中的一部分。我们比较关心的是消费者程序接收和处理的消息都应该是未处理过的。

为了确保这一点,我们需要两个条件:

首先,无论何时当消费者连接到Rabbit时我们需要一个新的、空的队列,因此就不会存在之前的消息。我们可以通过创建一个随机名字的队列来实现,而更好的方法是:让服务器自己选择一个随机队列给我们。

再者,当我们的消费者程序断开连接时,这个队列要能自动的删除。

在amqp客户端中,当我们将空字符串指定为队列名字时,将会创建一个非持久化的、带有随机命名的队列:

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

当这个函数返回时,RabbitMQ将创建一个带有随机名字的队列,如amq.gen-JzTY20BRgKO-HjmUJj0wLg.

当这个连接被关闭时,队列将会被删除,因为其被定义为独有的(exclusive)。

绑定

golang redis 订阅 golang 发布订阅_消息发送_02

到现在,我们已经创建了一个fanout类型的exchange和一个队列,然而exchange并不知道它要哪个队列是应该被分发消息的。因此,我们需要明确的指定exchange和队列队列之间的关系,这个操作称之为绑定。

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

现在,logs exchange中的消息就会被分发到我们的队列。

查看绑定列表

仍然可以通过命令来查看:

rabbitmqctl list_bindings

完整的例子

golang redis 订阅 golang 发布订阅_日志系统_03

生产者程序看起来跟之前例子区别不大,最重要的不同就是这里将消息发送给名为logs的exchange,而不是直接发送到默认队列。在发送消息时需要提供一个routingKey,但是在fanout类型的exchange中这个值是被忽略的。

那么,emit_log.go的脚本就是:

package main

import (
        "log"
        "os"
        "strings"

        "github.com/streadway/amqp"
)

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

func main() {
	    // 连接RabbitMQ服务器
        conn, err := amqp.Dial("amqp://guest:guest@localhost:5672/")
        failOnError(err, "Failed to connect to RabbitMQ")
        defer conn.Close()
        // 创建一个channel
        ch, err := conn.Channel()
        failOnError(err, "Failed to open a channel")
        defer ch.Close()
        // 声明交换器
        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")

        body := bodyFrom(os.Args)
        err = ch.Publish(
                "logs", // exchange
                "",     // routing key
                false,  // mandatory
                false,  // immediate
                amqp.Publishing{
                        ContentType: "text/plain",
                        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
}

需要注意,我们必须在建立了连接之后才能定义exchange,否则会报错。

如果exchange没有绑定任何一个队列,那么消息将会丢失而没有得到处理,但在这个例子里,这种情况是允许的,如果没有任何一个队列来消费这些消息,那么就直接忽略掉就好。

下面是receive_logs.go的代码:

package main

import (
        "log"

        "github.com/streadway/amqp"
)

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

func main() {
	    // 连接RabbitMQ服务器
        conn, err := amqp.Dial("amqp://guest:guest@localhost:5672/")
        failOnError(err, "Failed to connect to RabbitMQ")
        defer conn.Close()
        // 创建一个channel
        ch, err := conn.Channel()
        failOnError(err, "Failed to open a channel")
        defer ch.Close()
        // 声明交换器
        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")
        // 声明队列
        q, err := ch.QueueDeclare(
                "",    // name
                false, // durable
                false, // delete when unused
                true,  // exclusive
                false, // no-wait
                nil,   // arguments
        )
        failOnError(err, "Failed to declare a queue")
        // 绑定交换器
        err = ch.QueueBind(
                q.Name, // queue name
                "",     // routing key
                "logs", // exchange
                false,
                nil,
        )
        failOnError(err, "Failed to bind a queue")

        msgs, err := ch.Consume(
                q.Name, // queue
                "",     // consumer
                true,   // auto-ack
                false,  // exclusive
                false,  // no-local
                false,  // no-wait
                nil,    // args
        )
        failOnError(err, "Failed to register a consumer")

        forever := make(chan bool)

        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命令可以查看上面代码所创建的绑定关系,如当运行两个receive_logs.go之后,可能会得到如下的结果:

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

其结果是很容易理解的:从logs exchange中的消息会被转发到两个由系统命名的队列中。这也正是我们所期望的。