Go + SNS + SQS + Localstack 实现消息队列

概述

在现代微服务架构中,消息队列是实现服务间异步通信的重要组件。本文将通过一个完整的 Go 语言实践项目,介绍消息队列的核心概念以及 AWS SNS(Simple Notification Service)和 SQS(Simple Queue Service)的使用方法,并使用 localstack 模拟 AWS 服务。

消息队列基础概念

什么是消息队列?

消息队列是一种应用程序间的通信方法,它允许应用程序通过发送和接收消息来进行通信,并使用队列来存储消息。消息队列的核心特点包括:

  • 异步通信:发送方不需要等待接收方处理完成
  • 解耦:生产者和消费者之间松耦合
  • 可靠性:消息持久化存储,确保不丢失
  • 可扩展性:支持多个消费者并行处理

AWS SNS 与 SQS 简介

AWS SNS (Simple Notification Service)

SNS 是 AWS 提供的发布-订阅消息服务,支持多种订阅类型和基于属性的消息过滤

AWS SQS (Simple Queue Service)

SQS 是 AWS 提供的消息队列服务,提供标准队列和 FIFO 队列,支持消息持久化存储和消息传递。

项目架构设计

我们的项目实现了一个基于 SNS-SQS 的商城消息处理系统,包含订单、支付、库存三种类型的消息的生产和消费,项目架构如下图:

graph TB subgraph "生产者服务" A[订单服务
生产者] B[支付服务
生产者] C[库存服务
生产者] end subgraph "AWS SNS 主题" D[订单主题
sns-order-topic] E[支付主题
sns-payment-topic] F[库存主题
sns-inventory-topic] end subgraph "AWS SQS 队列" G[订单队列
sqs-order-queue] H[支付队列
sqs-payment-queue] I[库存队列
sqs-inventory-queue] end subgraph "消费者服务" J[订单消息
消费者] K[支付消息
消费者] L[库存消息
消费者] end A --> D B --> E C --> F D --> G E --> H F --> I G --> J H --> K I --> L classDef producer fill:#e1f5fe,stroke:#01579b,stroke-width:2px classDef sns fill:#fff3e0,stroke:#e65100,stroke-width:2px classDef sqs fill:#f3e5f5,stroke:#4a148c,stroke-width:2px classDef consumer fill:#e8f5e8,stroke:#1b5e20,stroke-width:2px class A,B,C producer class D,E,F sns class G,H,I sqs class J,K,L consumer

消息类型设计

我们定义了三种主要的消息类型:

go 复制代码
type Message struct {
    subject string  // 消息主题
    content string  // 消息内容
    msgType string  // 消息类型:order, payment, inventory
}

核心代码实现

1. 创建 localstack 容器

我们将在本地创建一个 localstack 的 docker 容器,用于模拟 AWS 服务。创建 docker-compose.yml 文件,代码如下:

yml 复制代码
version: "3.8"

services:
  localstack:
    image: localstack/localstack:latest
    ports:
      - "4566:4566"
    environment:
      - SERVICES=sns,sqs
      - DEFAULT_REGION=us-east-1
      - AWS_ACCESS_KEY_ID=test
      - AWS_SECRET_ACCESS_KEY=test
    # volumes 字段用于将主机目录或文件挂载到容器内,实现数据共享或持久化,常用于需要管理 Docker 的服务。
    # 这里将主机的 /var/run/docker.sock 挂载到容器,允许容器内的进程与主机 Docker 守护进程通信。
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock"

2. 启动 localstack 容器

执行下列命令启动 localstack 容器:

bash 复制代码
# -d 表示以"后台(detached)模式"运行容器,让命令执行后立即返回,容器在后台持续运行。这样终端不会被占用。
docker-compose up -d

3. demo 代码

go 复制代码
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"sync"
	"time"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/credentials"
	"github.com/aws/aws-sdk-go-v2/service/sns"
	snstypes "github.com/aws/aws-sdk-go-v2/service/sns/types"
	"github.com/aws/aws-sdk-go-v2/service/sqs"
	sqstypes "github.com/aws/aws-sdk-go-v2/service/sqs/types"
)

type Message struct {
	subject string
	content string
	msgType string
}

func main() {
	ctx := context.Background()

	// 初始化 AWS 服务
	snsClient, sqsClient := initializeAWSServices(ctx)

	// 定义主题和队列名称
	topicNames := []string{"sns-order-topic", "sns-payment-topic", "sns-inventory-topic"}
	queueNames := []string{"sqs-order-queue", "sqs-payment-queue", "sqs-inventory-queue"}

	// 创建主题并获取 ARN
	// ARN(Amazon Resource Name)可以唯一标识一个主题或队列。
	topicArns, err := createTopics(snsClient, topicNames)
	if err != nil {
		log.Fatalf("创建主题失败: %v", err)
	}

	// 列出所有主题
	if err := listTopics(snsClient); err != nil {
		log.Fatalf("列出主题失败: %v", err)
	}

	// 创建队列并获取 URL 和 ARN
	queueUrls, queueArns, err := createQueuesAndGetArns(sqsClient, queueNames)
	if err != nil {
		log.Fatalf("创建队列失败: %v", err)
	}

	// 订阅队列到主题
	if err := subscribeQueuesToTopics(snsClient, topicArns, queueArns); err != nil {
		log.Fatalf("订阅失败: %v", err)
	}

	// 准备测试消息
	messages := prepareTestMessages()

	// 启动消息处理系统
	startMessageSystem(ctx, snsClient, sqsClient, topicArns, queueUrls, messages)
}

// 初始化 AWS SNS 和 SQS 客户端
func initializeAWSServices(ctx context.Context) (*sns.Client, *sqs.Client) {
	snsClient, err := createSNSClient()
	if err != nil {
		log.Fatalf("创建 SNS 客户端失败: %v", err)
	}

	sqsClient, err := createSQSClient()
	if err != nil {
		log.Fatalf("创建 SQS 客户端失败: %v", err)
	}

	return snsClient, sqsClient
}

// 批量创建 SNS 主题
func createTopics(snsClient *sns.Client, topicNames []string) ([]string, error) {
	var topicArns []string
	for _, name := range topicNames {
		arn, err := createTopic(snsClient, name)
		if err != nil {
			return nil, fmt.Errorf("创建主题 %s 失败: %v", name, err)
		}
		topicArns = append(topicArns, arn)
	}
	return topicArns, nil
}

// 批量创建 SQS 队列并获取 ARN
func createQueuesAndGetArns(sqsClient *sqs.Client, queueNames []string) ([]string, []string, error) {
	var queueUrls, queueArns []string

	for _, name := range queueNames {
		// 创建队列
		queueUrl, err := createSQSQueue(sqsClient, name)
		if err != nil {
			return nil, nil, fmt.Errorf("创建队列 %s 失败: %v", name, err)
		}
		queueUrls = append(queueUrls, queueUrl)

		// 获取队列 ARN
		queueArn, err := getQueueArn(sqsClient, queueUrl)
		if err != nil {
			return nil, nil, fmt.Errorf("获取队列 %s ARN 失败: %v", name, err)
		}
		queueArns = append(queueArns, queueArn)
	}

	return queueUrls, queueArns, nil
}

// 订阅队列到主题
func subscribeQueuesToTopics(snsClient *sns.Client, topicArns, queueArns []string) error {
	for i, topicArn := range topicArns {
		_, err := snsClient.Subscribe(context.TODO(), &sns.SubscribeInput{
			TopicArn: aws.String(topicArn),
			Protocol: aws.String("sqs"),
			Endpoint: aws.String(queueArns[i]),
		})
		if err != nil {
			return fmt.Errorf("订阅队列 %s 到主题 %s 失败: %v", queueArns[i], topicArn, err)
		}
		fmt.Printf("✓ 队列 %s 订阅到主题 %s 成功\n", queueArns[i], topicArn)
	}
	return nil
}

// 准备测试消息
func prepareTestMessages() []Message {
	return []Message{
		// 订单相关消息
		{"订单创建", "用户 #12345 创建了新订单", "order"},
		{"订单创建", "用户 #67890 创建了新订单", "order"},
		{"订单创建", "用户 #12345 取消了新订单", "order"},
		{"订单完成", "订单 #12345 已完成,感谢您的购买!", "order"},
		{"订单完成", "订单 #67890 已完成,感谢您的购买!", "order"},

		// 支付相关消息
		{"支付成功", "订单 #12345 支付成功,金额: $99.99", "payment"},
		{"支付成功", "订单 #67890 支付成功,金额: $49.50", "payment"},
		{"支付失败", "订单 #24680 支付失败,金额: $120.00", "payment"},

		// 库存相关消息
		{"库存更新", "商品 #ABC123 库存减少 1 件", "inventory"},
		{"库存更新", "商品 #XYZ789 库存减少 2 件", "inventory"},
		{"库存更新", "商品 #LMN456 库存减少 5 件", "inventory"},

		// 物流相关消息
		{"物流通知", "订单 #12345 已发货,预计 3 天内到达", "shipping"},
		{"物流通知", "订单 #67890 已发货,预计 2 天内到达", "shipping"},
		{"物流通知", "订单 #24680 已发货,预计 5 天内到达", "shipping"},
	}
}

// 启动消息处理系统
func startMessageSystem(ctx context.Context, snsClient *sns.Client, sqsClient *sqs.Client,
	topicArns, queueUrls []string, messages []Message) {

	var wg sync.WaitGroup
	messageTypes := []string{"order", "payment", "inventory"}

	// 启动消费者
	wg.Add(len(queueUrls))
	for i, queueUrl := range queueUrls {
		consumerName := fmt.Sprintf("%s 消费者", messageTypes[i])
		go receiveMessage(ctx, &wg, sqsClient, queueUrl, consumerName)
	}

	// 启动发布者
	wg.Add(len(topicArns))
	for i, topicArn := range topicArns {
		filteredMessages := filterMessagesByType(messages, messageTypes[i])
		go publishMessages(snsClient, &wg, topicArn, filteredMessages)
	}

	// 等待所有任务完成
	fmt.Println("⏳ 等待所有任务完成...")
	wg.Wait()
	fmt.Println("✓ 程序结束")
}

func filterMessagesByType(messages []Message, msgType string) []Message {
	var filtered []Message
	for _, msg := range messages {
		if msg.msgType == msgType {
			filtered = append(filtered, msg)
		}
	}
	return filtered
}

// 发布消息到 SNS 主题
func publishMessages(snsClient *sns.Client, wg *sync.WaitGroup, topicArn string, messages []Message) {
	defer wg.Done()

	fmt.Printf("📤 开始向主题 %s 发布 %d 条消息\n", topicArn, len(messages))

	for i, msg := range messages {
		_, err := publishMessage(snsClient, topicArn, msg)

		if err != nil {
			log.Printf("❌ 发布消息 %d 失败: %v", i+1, err)
		} else {
			fmt.Printf("✓ 发布消息 %d 成功: %s (类型: %s)\n", i+1, msg.content, msg.msgType)
		}

		time.Sleep(1 * time.Second)
	}

	fmt.Printf("✓ 主题 %s 消息发布完成\n", topicArn)
}

// 创建 SNS 客户端
func createSNSClient() (*sns.Client, error) {
	cfg, err := config.LoadDefaultConfig(context.TODO(),
		config.WithRegion("us-east-1"),
		config.WithEndpointResolverWithOptions(aws.EndpointResolverWithOptionsFunc(
			func(service, region string, options ...interface{}) (aws.Endpoint, error) {
				return aws.Endpoint{
					URL:           "http://localhost:4566",
					SigningRegion: region,
				}, nil
			})),
		config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")),
	)

	if err != nil {
		return nil, err
	}

	return sns.NewFromConfig(cfg), nil
}

// 创建 SNS 主题
func createTopic(snsClient *sns.Client, topicName string) (string, error) {
	ctx := context.TODO()

	topicAttributes := map[string]string{}

	result, err := snsClient.CreateTopic(ctx, &sns.CreateTopicInput{
		Name:       aws.String(topicName),
		Attributes: topicAttributes,
	})

	if err != nil {
		return "", err
	}

	return *result.TopicArn, nil
}

// 列出所有 SNS 主题
func listTopics(snsClient *sns.Client) error {
	ctx := context.TODO()

	result, err := snsClient.ListTopics(ctx, &sns.ListTopicsInput{})
	if err != nil {
		return err
	}

	fmt.Println("📋 主题列表:")
	for i, topic := range result.Topics {
		fmt.Printf("  %d. %s\n", i+1, *topic.TopicArn)
	}
	return nil
}

// 发布单条消息到 SNS 主题
func publishMessage(snsClient *sns.Client, topicArn string, message Message) (string, error) {
	ctx := context.TODO()

	result, err := snsClient.Publish(ctx, &sns.PublishInput{
		TopicArn: aws.String(topicArn),
		Message:  aws.String(message.content),
		Subject:  aws.String(message.subject),
		MessageAttributes: map[string]snstypes.MessageAttributeValue{
			"MessageType": {
				DataType:    aws.String("String"),
				StringValue: aws.String(message.msgType),
			},
		},
	})

	if err != nil {
		return "", err
	}

	return *result.MessageId, nil
}

// 创建 SQS 客户端
func createSQSClient() (*sqs.Client, error) {
	cfg, err := config.LoadDefaultConfig(context.TODO(),
		config.WithRegion("us-east-1"),
		config.WithEndpointResolverWithOptions(aws.EndpointResolverWithOptionsFunc(
			func(service, region string, options ...interface{}) (aws.Endpoint, error) {
				return aws.Endpoint{
					URL:           "http://localhost:4566",
					SigningRegion: region,
				}, nil
			})),
		config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")),
	)

	if err != nil {
		return nil, err
	}

	return sqs.NewFromConfig(cfg), nil
}

// 创建 SQS 队列
func createSQSQueue(sqsClient *sqs.Client, queueName string) (string, error) {
	ctx := context.TODO()

	result, err := sqsClient.CreateQueue(ctx, &sqs.CreateQueueInput{
		QueueName: aws.String(queueName),
	})

	if err != nil {
		return "", err
	}

	return *result.QueueUrl, nil

}

// 获取 SQS 队列的 ARN
func getQueueArn(sqsClient *sqs.Client, queueUrl string) (string, error) {
	ctx := context.TODO()

	attributesOutput, err := sqsClient.GetQueueAttributes(ctx, &sqs.GetQueueAttributesInput{
		QueueUrl: aws.String(queueUrl),
		AttributeNames: []sqstypes.QueueAttributeName{
			sqstypes.QueueAttributeNameQueueArn,
		},
	})

	if err != nil {
		return "", err
	}

	queueArn, ok := attributesOutput.Attributes[string(sqstypes.QueueAttributeNameQueueArn)]
	if !ok {
		return "", fmt.Errorf("未能获取队列 ARN 属性")
	}

	return queueArn, nil
}

// 从 SQS 队列接收并处理消息
func receiveMessage(ctx context.Context, wg *sync.WaitGroup, sqsClient *sqs.Client, sqsQueueUrl string, routineName string) {
	defer wg.Done()

	fmt.Printf("🎧 %s 开始监听队列: %s\n", routineName, sqsQueueUrl)

	// 设置超时和空消息计数,避免无限等待
	timeout := time.After(60 * time.Second)
	emptyCount := 0
	maxEmptyCount := 10 // 连续10次没有消息就退出

	for {
		select {
		case <-ctx.Done():
			fmt.Printf("🛑 %s 停止监听\n", routineName)
			return
		case <-timeout:
			fmt.Printf("⏰ %s 超时退出\n", routineName)
			return
		default:
			result, err := sqsClient.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
				QueueUrl:            aws.String(sqsQueueUrl),
				MaxNumberOfMessages: 10,
				WaitTimeSeconds:     1,
			})
			if err != nil {
				log.Printf("❌ %s 接收消息失败:%v", routineName, err)
				continue
			}

			if len(result.Messages) == 0 {
				emptyCount++
				if emptyCount >= maxEmptyCount {
					fmt.Printf("📭 %s 连续 %d 次没有收到消息,退出监听\n", routineName, maxEmptyCount)
					return
				}
				continue
			}

			// 重置空消息计数
			emptyCount = 0
			fmt.Printf("📨 %s 收到 %d 条消息\n", routineName, len(result.Messages))

			for _, message := range result.Messages {
				var bodyMap map[string]interface{}
				if err := json.Unmarshal([]byte(*message.Body), &bodyMap); err == nil {
					fmt.Printf("📋 %s 收到消息:\n", routineName)
					for k, v := range bodyMap {
						fmt.Printf("  %s: %v\n", k, v)
					}
				} else {
					fmt.Printf("📄 %s 消息内容: %s\n", routineName, *message.Body)
				}

				// 删除已处理的消息
				_, err := sqsClient.DeleteMessage(ctx, &sqs.DeleteMessageInput{
					QueueUrl:      aws.String(sqsQueueUrl),
					ReceiptHandle: message.ReceiptHandle,
				})
				if err != nil {
					log.Printf("❌ %s 删除消息失败:%v", routineName, err)
				} else {
					fmt.Printf("✓ %s 消息已删除\n", routineName)
				}
			}
		}
	}
}

运行项目

bash 复制代码
go run main.go

看到如下的结果则说明项目运行符合预期。

bash 复制代码
✅ order 消费者 消息已删除
✅ 发布消息 5 成功: order 消息2, 订单 #67890 已完成,感谢您的购买! (类型: order)
📨 order 消费者 收到 1 条消息
🔥 order 消费者 收到消息:
  Type: Notification
  MessageId: 7e64b6d8-5243-4423-a178-62c09370319d
  TopicArn: arn:aws:sns:us-east-1:000000000000:sns-order-topic
  Message: order 消息2, 订单 #67890 已完成,感谢您的购买!
  Timestamp: 2025-10-09T04:34:29.290Z
  UnsubscribeURL: http://localhost.localstack.cloud:4566/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-1:000000000000:sns-order-topic:517ef0a3-6b93-4f9b-bf50-d35283df3abd
  Subject: 订单完成
  SignatureVersion: 1
  MessageAttributes: map[MessageType:map[Type:String Value:order]]
  Signature: Fxbtz/YXXR1OwIYnFxMXKhkKAaakTSKjM1zcEwKHOwyCWPNpWlCtSHH0T6iAQU6+XCDD+RJLmS6dOI/Eh8YH7FC8mXyEuB92csY+XHFRGJbP2n0ljL1T+G10DiVNa8/uRUJPr+xnf9yBeJCeCWN6aghxTrzjcrGWx7v+dYfWhdJciOirC6rusXkmj+N0YmdlIrAu/txtOVZGiI6TkOLBwkki89Q8FFU5/l/lpepdfuUnPlfThbqYfz4TpFmxE4KkJL4hQRSlEn7d1kzvPrwrqles8+ZTDhgx0xm2AZbk+To6gNA2QbEogJHANwYUgoZJP1pBuZdcgZBEBB0Tp82oJw==
  SigningCertURL: http://localhost.localstack.cloud:4566/_aws/sns/SimpleNotificationService-6c6f63616c737461636b69736e696365.pem
✅ order 消费者 消息已删除
相关推荐
jserTang7 小时前
Cursor Plan Mode:AI 终于知道先想后做了
前端·后端·cursor
12344527 小时前
令牌桶算法简单实现及思考
后端
SimonKing7 小时前
SpringBoot集成:5分钟实现HTML转PDF功能
java·后端·程序员
Asthenia04127 小时前
技术复盘:从 Interceptor 到 Filter —— 正确修改 HTTP Request 字段的探索之路
后端
JaguarJack8 小时前
别再用 PHP 动态方法调用了!三个坑让你代码难以维护
后端·php
mudtools8 小时前
打造.NET平台的Lombok:实现构造函数注入、日志注入、构造者模式代码生成等功能
后端·.net
江上月5138 小时前
django与vue3的对接流程详解(下)
后端·python·django
Cikiss8 小时前
图解 bulkProcessor(调度器 + bulkAsync() + Semaphore)
java·分布式·后端·elasticsearch·搜索引擎
Mintopia8 小时前
Next.js 与 Serverless 架构思维:无状态的优雅与冷启动的温柔
前端·后端·全栈