用Golang创建一个RabbitMQ生产者实例

769 阅读10分钟

在这个例子中,我们将创建一个 RabbitMQ 生产者来在 Golang 中发布消息。我们将只是发布消息,所以这里没有消费者的参与。在继续之前,让我把一些事情说清楚。发布/订阅和生产者/消费者这两个术语都与消息传递有关,但它们是不同的东西。发布/订阅是一种消息传递模式,由生产者/消费者执行。生产者发布消息,消费者订阅消息。它们都不属于 RabbitMQ。它们是您的应用程序的一部分。只有交换、路由/绑定键和队列是 RabbitMQ 的一部分。

它是做什么的?

这里列出的每一点都在下面的 "注意事项 "部分有相关信息:

  • 连接被命名,以便在 RabbitMQ UI 中更容易识别哪个连接属于哪个应用程序。

  • 在应用程序启动时仅创建一个长期存在的 AMQP 连接,供所有 API 请求/线程使用。

  • 为要使用的每个 API 请求/线程创建专用通道。

  • 队列被声明为 "懒惰的",以防止内存在大流量下被敲打,还有更多。

  • 如果连接意外中断,一旦发生第一次发布,就会通过使用Connection.NotifyClose 监听器(由RabbitMQ.reconnect() 方法处理)或RabbitMQ.Channel() 方法自动创建一个新的连接。然而,如果重新连接过程持续失败,它在第6次尝试后就放弃了。如果连接在应用程序本身中出现了预期的故障,那么什么也不会做。

  • 如果消息没有被传递到交易所或队列中,它会返回500 状态代码。虽然这不太可能,但在默认情况下,如果交付确认超时,它将返回202 状态代码。确认超时被设置为100 millisecond。这不一定是一个错误。它只是告诉您,RabbitMQ 没有设法在一定的时间范围内确认消息的交付。它发生在系统处于非常高的流量下时。

注释

连接(数据竞赛)

尽管不太可能,但只有在同时满足以下两种情况时,RabbitMQ.reconnect() 方法才可能(并非总是)在终端中转出数据竞赛警告:

  • 连接在非常短的时间内被连续关闭(非常不正常)。

  • 应用程序在非常短的时间内连续收到请求(正常)。

然而,如果你只面对第一种情况,你实际上可能有某种架构/网络/服务器层面的问题,而不是应用程序层面的问题,除非你真的把你的应用程序编程得很糟糕这个例子中的设置根本就不应该导致第一种情况的发生。即使您在 RabbitMQ UI 中手动关闭连接,应用程序也会为您建立一个新连接。

通道(内存)

我正在为每个请求/线程提供一个孤立的通道,以防止RabbitMQ.reconnect() 方法中可能出现的数据竞赛问题。然而,这导致了更高的通道流失率(我强烈建议你阅读这一页),但至少它可以防止应用程序宕机,所以你必须以某种方式做出牺牲。此外,每个线程打开和关闭通道既不罕见也不可怕。RabbitMQ 文档中写道*:"对于使用多个线程/进程进行处理的应用程序,为每个线程/进程打开一个新通道并且不在它们之间共享通道是非常常见的",这正是该应用程序正在做的事情。随之而来的是,它使用的内存量真的很小。这一类是所有通道进程在任何时候使用的内存之和。作为证明,如果你在 RabbitMQ 服务器中运行rabbitmq-diagnostics memory_breakdown -q --unit mb 命令,你会看到如下内容。connection_channels 的官方描述是"客户端连接使用的通道越多,该类别使用的内存就越多"*。有关内存使用的更多信息,请阅读《关于内存使用的推理》

allocated_unused: 31.6314 mb (23.3%)
code: 30.1752 mb (22.22%)
other_proc: 24.6923 mb (18.19%)
other_system: 23.9947 mb (17.67%)
plugins: 12.1899 mb (8.98%)
mgmt_db: 6.467 mb (4.76%)
other_ets: 2.9154 mb (2.15%)
atom: 1.5177 mb (1.12%)
binary: 1.324 mb (0.98%)
metrics: 0.3868 mb (0.28%)
connection_other: 0.1771 mb (0.13%)
mnesia: 0.0802 mb (0.06%)
queue_procs: 0.0552 mb (0.04%)
connection_readers: 0.0517 mb (0.04%)
quorum_ets: 0.0474 mb (0.03%)
msg_index: 0.0297 mb (0.02%)
connection_writers: 0.0246 mb (0.02%)
connection_channels: 0.0219 mb (0.02%)
queue_slave_procs: 0.0 mb (0.0%)
quorum_queue_procs: 0.0 mb (0.0%)
reserved_unallocated: 0.0 mb (0.0%)

队列(内存)

我们使用Lazy Queues来避免内存过载。懒惰队列尽可能早地将消息移到磁盘上,只有在消费者要求时才将其加载到RAM中。这个功能有很多好处,所以请阅读文档以了解更多信息。

资源使用情况(测试)

假设你连续发送1000个请求。当这样做的时候,每隔5个请求就会故意关闭连接。在这种情况下,你的应用程序将带来最多2个额外的goroutine(临时),并消耗0.2到2MB的内存,这对系统资源来说是非常轻的。如果你同时发送1000个连续的请求,只有内存使用量上升到3MB,这仍然不算多。记住,这个用量不会是恒定的,所以有时会更少。我在2000个连续请求中几乎没有经历过3MB。

上面的结果与应用程序有关。现在让我们来看看RabbitMQ是如何表现的。我有 100 个用户在 10 秒内连续发送并发请求。RabbitMQ 通道的平均使用量为 1.5MB,最大约为 4MB。仍然非常高效。我强烈建议在 RabbitMQ 服务器中运行rabbitmq-plugins enable rabbitmq_top 命令以启用rabbitmq-top插件,该插件有助于分析消耗最多内存或 CPU 时间的运行时进程。这将在 "管理 "标签下添加 "顶级进程 "菜单项。

重新连接

如果您转到 RabbitMQ UI 并关闭连接以模拟网络错误,您将看到您的应用程序重新连接。日志应该看起来像下面这样。

2020/05/05 20:48:35 CRITICAL: Connection dropped, reconnecting
2020/05/05 20:48:35 INFO: Recovered connected

如果您在您的应用程序中手动关闭它以模拟正常操作,您将看到您的应用程序不会重新连接。日志应该如下所示。

2020/05/05 20:46:34 INFO: Connection dropped normally, will not reconnect
2020/05/05 20:46:34 failed to create 5: failed to open channel: Exception (504) Reason: "channel/connection is not open"

消息传递确认测试

这一部分向你展示了Channel.NotifyPublishChannel.NotifyReturn 如何处理消息发布。您只想在向 RabbitMQ 发布消息后向用户返回一个正确的响应。没有监听器的发布功能并不一定意味着消息已被交付。因此,我们需要等待确认的原因。

确认代码

...
	select {
	case ntf := <-channel.NotifyPublish(make(chan amqp.Confirmation, 1)):
		if !ntf.Ack {
			return errors.New("failed to deliver message to exchange/queue")
		}
	case <-channel.NotifyReturn(make(chan amqp.Return)):
		return errors.New("failed to deliver message to exchange/queue")
	case <-time.After(c.rabbitmq.ChannelNotifyTimeout):
		log.Println("message delivery confirmation to exchange/queue timed out")
	}

	return nil
}

测试案例

下面的列表显示了哪些监听器/处理程序捕获了哪些情况。

# EXCHANGE QUEUE CONSUMER RESPONSE DELAY HANDLER       ERROR                               
1 +        +     +        200      -     NotifyPublish n/a                                 
2 -        +     +        500      -     NotifyPublish server failed to receive the message
3 +        -     +        500      -     NotifyReturn  server failed to receive the message
4 -        -     +        500      -     NotifyPublish server failed to receive the message
5 +        +     -        200      -     NotifyPublish n/a                                 
6 -        +     -        500      -     NotifyPublish server failed to receive the message
7 +        -     -        500      -     NotifyReturn  server failed to receive the message
8 -        -     -        500      -     NotifyReturn  server failed to receive the message

+ The component exists/running.
- The component does not exist or not running.

如果你为两个监听器转储ntf 变量,你会得到以下结果。

# 1
{DeliveryTag:1 Ack:true}

# 2
{DeliveryTag:0 Ack:false}

# 3
{ReplyCode:312 ReplyText:NO_ROUTE Exchange:user RoutingKey:create ContentType:text/plain ContentEncoding: Headers:map[]
DeliveryMode:2 Priority:0 CorrelationId: ReplyTo: Expiration: MessageId:UUID Timestamp:0001-01-01 00:00:00 +0000 UTC
Type: UserId: AppId: Body:[49]}

# 4
{DeliveryTag:0 Ack:false}

# 5
{DeliveryTag:1 Ack:true}

# 6
{DeliveryTag:0 Ack:false}

# 7
{ReplyCode:312 ReplyText:NO_ROUTE Exchange:user RoutingKey:create ContentType:text/plain ContentEncoding: Headers:map[]
DeliveryMode:2 Priority:0 CorrelationId: ReplyTo: Expiration: MessageId:UUID Timestamp:0001-01-01 00:00:00 +0000 UTC
Type: UserId: AppId: Body:[49]}

# 8
{ReplyCode:0 ReplyText: Exchange: RoutingKey: ContentType: ContentEncoding: Headers:map[] DeliveryMode:0 Priority:0
CorrelationId: ReplyTo: Expiration: MessageId: Timestamp:0001-01-01 00:00:00 +0000 UTC Type: UserId: AppId: Body:[]}

RabbiqMQ服务器

如果你愿意,你可以使用下面的编译文件,并用docker-compose up 命令来运行它。然后你可以通过http://0.0.0.0:15672/ 地址访问用户界面,用guest:guest 凭证登录。

version: "3.4"

services:
  rabbit:
    image: "rabbitmq:3.8.3-management-alpine"
    ports:
      - "5672:5672"
      - "15672:15672"
    environment:
      RABBITMQ_ERLANG_COOKIE: "a-secret-hash"
      RABBITMQ_DEFAULT_VHOST: "my_app"
      RABBITMQ_DEFAULT_USER: "inanzzz"
      RABBITMQ_DEFAULT_PASS: "123123"

应用程序

我们现在转向应用程序。首先使用go get github.com/streadway/amqp 来安装 RabbitMQ 客户端。我们的示例使用以下属性。

ExchangeType: direct
ExchangeName: user
RoutingKey: create
QueueName: user_create

我们有一个端点http://localhost:8080/users/create ,它将被使用。如果你愿意,你可以添加更多的端点和它们自己的 RabbitMQ 属性,与上面类似。

结构

├── cmd
│   └── client
│       └── main.go
└── internal
    ├── app
    │   └── app.go
    ├── config
    │   └── config.go
    ├── pkg
    │   ├── http
    │   │   ├── router.go
    │   │   └── server.go
    │   └── rabbitmq
    │       └── rabbitmq.go
    └── user
        ├── amqp.go
        └── create.go

文件

main.go

package main

import (
	"log"

	"github.com/inanzzz/client/internal/app"
	"github.com/inanzzz/client/internal/config"
	"github.com/inanzzz/client/internal/pkg/http"
	"github.com/inanzzz/client/internal/pkg/rabbitmq"
	"github.com/inanzzz/client/internal/user"
)

func main() {
	// Config
	cnf := config.New()
	//

	// RabbitMQ
	rbt := rabbitmq.New(cnf.RabbitMQ)
	if err := rbt.Connect(); err != nil {
		log.Fatalln(err)
	}
	defer rbt.Shutdown()
	//

	// AMQP setup
	userAMQP := user.NewAMQP(cnf.UserAMQP, rbt)
	if err := userAMQP.Setup(); err != nil {
		log.Fatalln(err)
	}
	//

	// HTTP router
	rtr := http.NewRouter()
	rtr.RegisterUsers(rbt)
	//

	// HTTP server
	srv := http.NewServer(cnf.HTTPAddress, rtr)
	//

	// Run
	if err := app.New(srv).Run(); err != nil {
		log.Fatalln(err)
	}
	//
}

app.go

package app

import (
	"net/http"
)

type App struct {
	server *http.Server
}

func New(server *http.Server) App {
	return App{
		server: server,
	}
}

func (a App) Run() error {
	return a.server.ListenAndServe()
}

config.go

package config

import (
	"time"

	"github.com/inanzzz/client/internal/pkg/rabbitmq"
	"github.com/inanzzz/client/internal/user"
)

type Config struct {
	HTTPAddress string
	RabbitMQ    rabbitmq.Config
	UserAMQP    user.AMQPConfig
}

func New() Config {
	var cnf Config

	cnf.HTTPAddress = ":8080"

	cnf.RabbitMQ.Schema = "amqp"
	cnf.RabbitMQ.Username = "inanzzz"
	cnf.RabbitMQ.Password = "123123"
	cnf.RabbitMQ.Host = "0.0.0.0"
	cnf.RabbitMQ.Port = "5672"
	cnf.RabbitMQ.Vhost = "my_app"
	cnf.RabbitMQ.ConnectionName = "MY_APP"
	cnf.RabbitMQ.ChannelNotifyTimeout = 100 * time.Millisecond
	cnf.RabbitMQ.Reconnect.Interval = 500 * time.Millisecond
	cnf.RabbitMQ.Reconnect.MaxAttempt = 7200

	cnf.UserAMQP.Create.ExchangeName = "user"
	cnf.UserAMQP.Create.ExchangeType = "direct"
	cnf.UserAMQP.Create.RoutingKey = "create"
	cnf.UserAMQP.Create.QueueName = "user_create"

	return cnf
}

router.go

package http

import (
	"net/http"

	"github.com/inanzzz/client/internal/pkg/rabbitmq"
	"github.com/inanzzz/client/internal/user"
)

type Router struct {
	*http.ServeMux
}

func NewRouter() *Router {
	return &Router{http.NewServeMux()}
}

func (r *Router) RegisterUsers(rabbitmq *rabbitmq.RabbitMQ) {
	create := user.NewCreate(rabbitmq)

	r.HandleFunc("/users/create", create.Handle)
}

服务器.go

package http

import (
	"net/http"
)

func NewServer(address string, router *Router) *http.Server {
	return &http.Server{
		Addr:    address,
		Handler: router,
	}
}

rabbitmq.go

package rabbitmq

import (
	"fmt"
	"log"
	"sync"
	"time"

	"github.com/pkg/errors"
	"github.com/streadway/amqp"
)

type Config struct {
	Schema               string
	Username             string
	Password             string
	Host                 string
	Port                 string
	Vhost                string
	ConnectionName       string
	ChannelNotifyTimeout time.Duration
	Reconnect            struct {
		Interval     time.Duration
		MaxAttempt   int
	}
}

type RabbitMQ struct {
	mux                  sync.RWMutex
	config               Config
	dialConfig           amqp.Config
	connection           *amqp.Connection
	ChannelNotifyTimeout time.Duration
}

func New(config Config) *RabbitMQ {
	return &RabbitMQ{
		config:               config,
		dialConfig:           amqp.Config{Properties: amqp.Table{"connection_name": config.ConnectionName}},
		ChannelNotifyTimeout: config.ChannelNotifyTimeout,
	}
}

// Connect creates a new connection. Use once at application
// startup.
func (r *RabbitMQ) Connect() error {
	con, err := amqp.DialConfig(fmt.Sprintf(
		"%s://%s:%s@%s:%s/%s",
		r.config.Schema,
		r.config.Username,
		r.config.Password,
		r.config.Host,
		r.config.Port,
		r.config.Vhost,
	), r.dialConfig)
	if err != nil {
		return err
	}

	r.connection = con

	go r.reconnect()

	return nil
}

// Channel returns a new `*amqp.Channel` instance. You must
// call `defer channel.Close()` as soon as you obtain one.
// Sometimes the connection might be closed unintentionally so
// as a graceful handling, try to connect only once.
func (r *RabbitMQ) Channel() (*amqp.Channel, error) {
	if r.connection == nil {
		if err := r.Connect(); err != nil {
			return nil, errors.New("connection is not open")
		}
	}

	channel, err := r.connection.Channel()
	if err != nil {
		return nil, err
	}

	return channel, nil
}

// Connection exposes the essentials of the current connection.
// You should not normally use this but it is there for special
// use cases.
func (r *RabbitMQ) Connection() *amqp.Connection {
	return r.connection
}

// Shutdown triggers a normal shutdown. Use this when you wish
// to shutdown your current connection or if you are shutting
// down the application.
func (r *RabbitMQ) Shutdown() error {
	if r.connection != nil {
		return r.connection.Close()
	}

	return nil
}

// reconnect reconnects to server if the connection or a channel
// is closed unexpectedly. Normal shutdown is ignored. It tries
// maximum of 7200 times and sleeps half a second in between
// each try which equals to 1 hour.
func (r *RabbitMQ) reconnect() {
WATCH:

	conErr := <-r.connection.NotifyClose(make(chan *amqp.Error))
	if conErr != nil {
		log.Println("CRITICAL: Connection dropped, reconnecting")

		var err error

		for i := 1; i <= r.config.Reconnect.MaxAttempt; i++ {
			r.mux.RLock()
			r.connection, err = amqp.DialConfig(fmt.Sprintf(
				"%s://%s:%s@%s:%s/%s",
				r.config.Schema,
				r.config.Username,
				r.config.Password,
				r.config.Host,
				r.config.Port,
				r.config.Vhost,
			), r.dialConfig)
			r.mux.RUnlock()

			if err == nil {
				log.Println("INFO: Reconnected")

				goto WATCH
			}

			time.Sleep(r.config.Reconnect.Interval)
		}

		log.Println(errors.Wrap(err, "CRITICAL: Failed to reconnect"))
	} else {
		log.Println("INFO: Connection dropped normally, will not reconnect")
	}
}

amqp.go

package user

import (
	"github.com/inanzzz/client/internal/pkg/rabbitmq"
	"github.com/pkg/errors"
	"github.com/streadway/amqp"
)

type AMQPConfig struct {
	Create struct {
		ExchangeName string
		ExchangeType string
		RoutingKey   string
		QueueName    string
	}
}

type AMQP struct {
	config   AMQPConfig
	rabbitmq *rabbitmq.RabbitMQ
}

func NewAMQP(config AMQPConfig, rabbitmq *rabbitmq.RabbitMQ) AMQP {
	return AMQP{
		config:   config,
		rabbitmq: rabbitmq,
	}
}

func (a AMQP) Setup() error {
	channel, err := a.rabbitmq.Channel()
	if err != nil {
		return errors.Wrap(err, "failed to open channel")
	}
	defer channel.Close()

	if err := a.declareCreate(channel); err != nil {
		return err
	}

	return nil
}

func (a AMQP) declareCreate(channel *amqp.Channel) error {
	if err := channel.ExchangeDeclare(
		a.config.Create.ExchangeName,
		a.config.Create.ExchangeType,
		true,
		false,
		false,
		false,
		nil,
	); err != nil {
		return errors.Wrap(err, "failed to declare exchange")
	}

	if _, err := channel.QueueDeclare(
		a.config.Create.QueueName,
		true,
		false,
		false,
		false,
		amqp.Table{"x-queue-mode": "lazy"},
	); err != nil {
		return errors.Wrap(err, "failed to declare queue")
	}

	if err := channel.QueueBind(
		a.config.Create.QueueName,
		a.config.Create.RoutingKey,
		a.config.Create.ExchangeName,
		false,
		nil,
	); err != nil {
		return errors.Wrap(err, "failed to bind queue")
	}

	return nil
}

创建.go

package user

import (
	"fmt"
	"log"
	"net/http"
	"time"

	"github.com/inanzzz/client/internal/pkg/rabbitmq"
	"github.com/pkg/errors"
	"github.com/streadway/amqp"
)

type Create struct {
	rabbitmq *rabbitmq.RabbitMQ
}

func NewCreate(rabbitmq *rabbitmq.RabbitMQ) Create {
	return Create{
		rabbitmq: rabbitmq,
	}
}

func (c Create) Handle(w http.ResponseWriter, r *http.Request) {
	id := r.Header.Get("ID")

	if err := c.publish(id); err != nil {
		log.Println(errors.Wrap(err, fmt.Sprintf("failed to create %s", id)))
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	log.Println("created", id)
	w.WriteHeader(http.StatusAccepted)
}

func (c Create) publish(message string) error {
	channel, err := c.rabbitmq.Channel()
	if err != nil {
		return errors.Wrap(err, "failed to open channel")
	}
	defer channel.Close()

	if err := channel.Confirm(false); err != nil {
		return errors.Wrap(err, "failed to put channel in confirmation mode")
	}

	if err := channel.Publish(
		"user",
		"create",
		true,
		false,
		amqp.Publishing{
			DeliveryMode: amqp.Persistent,
			MessageId:    "A-UNIQUE-ID",
			ContentType:  "text/plain",
			Body:         []byte(message),
		},
	); err != nil {
		return errors.Wrap(err, "failed to publish message")
	}

	select {
	case ntf := <-channel.NotifyPublish(make(chan amqp.Confirmation, 1)):
		if !ntf.Ack {
			return errors.New("failed to deliver message to exchange/queue")
		}
	case <-channel.NotifyReturn(make(chan amqp.Return)):
		return errors.New("failed to deliver message to exchange/queue")
	case <-time.After(c.rabbitmq.ChannelNotifyTimeout):
		log.Println("message delivery confirmation to exchange/queue timed out")
	}

	return nil
}