10.消息队列集成

本篇文章将详细介绍如何在.NET Aspire中集成RabbitMQ和Azure Service Bus,并实现消息发布订阅模式。同时,文章还会讨论消息队列的配置、消息重试机制以及死信队列的处理方法。

一、RabbitMQ 集成

RabbitMQ 是一个广泛使用的开源消息代理。在 .NET Aspire 中集成 RabbitMQ 非常简单,主要涉及 Hosting(AppHost)和 Client(服务项目)两部分的配置。

1.1 AppHost 配置

首先,需要在 AppHost 项目中安装 Aspire.Hosting.RabbitMQ 包。

bash 复制代码
dotnet add package Aspire.Hosting.RabbitMQ

然后,在 Program.cs 中注册 RabbitMQ 资源:

csharp 复制代码
var builder = DistributedApplication.CreateBuilder(args);

// 添加 RabbitMQ 容器资源
var rabbitmq = builder.AddRabbitMQ("messaging")
                      .WithManagementPlugin(); // 启用管理插件

// 将 RabbitMQ 资源引用添加到服务中
builder.AddProject<Projects.MyApiService>("api")
       .WithReference(rabbitmq);

builder.Build().Run();

在上面的代码中,AddRabbitMQ 方法用于创建 RabbitMQ 容器资源,参数 "messaging" 作为连接名称供客户端识别。WithManagementPlugin 方法用于启用管理插件,提供 Web UI 管理界面,默认地址为 http://localhost:15672,用户名和密码均为 guestWithReference(rabbitmq) 将 RabbitMQ 资源引用添加到服务项目中,Aspire 会自动注入连接信息。

除了基础配置外,Aspire 还支持更多自定义选项,例如将数据持久化到 Docker 卷中或控制容器的生命周期:

csharp 复制代码
var rabbitmq = builder.AddRabbitMQ("messaging")
                      .WithManagementPlugin()
                      .WithDataVolume()  // 持久化数据到卷
                      .WithLifetime(ContainerLifetime.Persistent); // 容器生命周期管理

其中,WithDataVolume() 方法将 RabbitMQ 数据持久化到 Docker 卷中,避免容器重启后数据丢失。WithLifetime() 方法用于控制容器生命周期,在 Persistent 模式下,容器会在应用停止后继续运行。

另外如果需要自定义 RabbitMQ 的端口映射,可以使用 WithEndpoint 方法:

csharp 复制代码
var rabbitmq = builder.AddRabbitMQ("messaging")
                      .WithEndpoint("amqp", endpoint => endpoint.AllocatedEndpoint = 5672)
                      .WithEndpoint("management", endpoint => endpoint.AllocatedEndpoint = 15672);

在代码中,我们用WithEndpoint 方法自定义了 RabbitMQ 的端口映射,例如将 AMQP 服务映射到宿主机的 5672 端口,管理 UI 映射到 15672 端口。

我们还可以通过环境变量进一步配置 RabbitMQ 行为,例如设置默认虚拟主机、用户名和密码等:

csharp 复制代码
var rabbitmq = builder.AddRabbitMQ("messaging")
                      .WithEnvironment("RABBITMQ_DEFAULT_VHOST", "/myapp")
                      .WithEnvironment("RABBITMQ_DEFAULT_USER", "admin")
                      .WithEnvironment("RABBITMQ_DEFAULT_PASS", "secretpassword");

在代码中,我们通过 WithEnvironment 方法设置了 RabbitMQ 的默认虚拟主机、用户名和密码。

1.2 Client 配置

在需要使用 RabbitMQ 的服务项目中(如 MyApiService),首先需要安装 Aspire.RabbitMQ.Client 包。

bash 复制代码
dotnet add package Aspire.RabbitMQ.Client

接着在 Program.cs 中注册 RabbitMQ 客户端:

csharp 复制代码
var builder = WebApplication.CreateBuilder(args);

// 注册 RabbitMQ 客户端,连接名称需与 AppHost 中定义的一致
builder.AddRabbitMQClient("messaging");

var app = builder.Build();

在代码中,AddRabbitMQClient 方法的参数 "messaging" 必须与 AppHost 项目中 AddRabbitMQ 定义的连接名称保持一致,Aspire 会自动从配置系统中获取连接信息并注入到应用中。注册完成后,可以通过依赖注入获取 IConnection 接口来与 RabbitMQ 进行交互。下面是一个完整的消息发送和接收示例:

csharp 复制代码
public class MessagePublisher
{
    private readonly IConnection _connection;

    public MessagePublisher(IConnection connection)
    {
        _connection = connection;
    }

    public void PublishMessage(string message)
    {
        using var channel = _connection.CreateModel();
        
        // 声明队列
        channel.QueueDeclare(
            queue: "orders",
            durable: true,      // 队列持久化
            exclusive: false,
            autoDelete: false,
            arguments: null);

        var body = Encoding.UTF8.GetBytes(message);
        var properties = channel.CreateBasicProperties();
        properties.Persistent = true;  // 消息持久化

        // 发布消息
        channel.BasicPublish(
            exchange: "",
            routingKey: "orders",
            basicProperties: properties,
            body: body);
    }
}

上面的代码我们创建了一个 MessagePublisher 类,它通过依赖注入获取 IConnection 接口,并在 PublishMessage 方法中发布消息到名为 "orders" 的队列。注意,这里使用了 channel.QueueDeclare 方法来声明队列,确保队列存在。同时,我们还设置了消息的持久化属性,以防止消息丢失。

接下来,我们创建一个消息消费者类 MessageConsumer,它继承自 BackgroundService,用于持续监听并处理消息,代码如下:

csharp 复制代码
public class MessageConsumer : BackgroundService
{
    private readonly IConnection _connection;
    private readonly ILogger<MessageConsumer> _logger;

    public MessageConsumer(IConnection connection, ILogger<MessageConsumer> logger)
    {
        _connection = connection;
        _logger = logger;
    }

    protected override Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var channel = _connection.CreateModel();
        
        // 声明队列(确保队列存在)
        channel.QueueDeclare(
            queue: "orders",
            durable: true,
            exclusive: false,
            autoDelete: false,
            arguments: null);

        // 设置预取数量,控制并发消费
        channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false);

        var consumer = new EventingBasicConsumer(channel);
        consumer.Received += (model, ea) =>
        {
            var body = ea.Body.ToArray();
            var message = Encoding.UTF8.GetString(body);
            
            _logger.LogInformation("接收到消息: {Message}", message);
            
            // 处理消息...
            
            // 手动确认消息
            channel.BasicAck(deliveryTag: ea.DeliveryTag, multiple: false);
        };

        // 开始消费,autoAck 设为 false 表示手动确认
        channel.BasicConsume(queue: "orders", autoAck: false, consumer: consumer);

        return Task.CompletedTask;
    }
}

MessageConsumer 类中,我们首先声明了队列,确保其存在。然后通过 BasicQos 方法设置了预取数量,控制并发消费的数量。接下来创建了一个 EventingBasicConsumer 实例,并为其 Received 事件注册了处理函数,在该函数中,我们获取了消息内容并进行了处理,最后通过 BasicAck 方法手动确认消息。

在实际应用中,还可以通过配置文件对 RabbitMQ 客户端进行更细粒度的控制:

json 复制代码
{
  "Aspire": {
    "RabbitMQ": {
      "Client": {
        "ConnectionString": "amqp://localhost:5672",
        "MaxConnectRetryCount": 5,
        "DisableHealthChecks": false,
        "DisableTracing": false
      }
    }
  }
}

其中,MaxConnectRetryCount 设置连接重试次数,DisableHealthChecks 控制是否禁用健康检查,DisableTracing 控制是否禁用分布式追踪。这些配置选项让你可以根据实际需求灵活调整 RabbitMQ 客户端的行为。

二、Azure Service Bus 集成

Azure Service Bus 是 Azure 提供的完全托管的企业级消息代理,它支持队列(Queue)和发布/订阅(Topic/Subscription)两种消息传递模式。相比 RabbitMQ,Service Bus 提供了更强的企业级特性,如消息会话、事务支持、重复检测等。在 .NET Aspire 中集成 Azure Service Bus 同样分为 AppHost 和 Client 两部分配置。

2.1 AppHost 配置

首先,在 AppHost 项目中安装 Aspire.Hosting.Azure.ServiceBus 包:

bash 复制代码
dotnet add package Aspire.Hosting.Azure.ServiceBus

然后在 Program.cs 中注册 Service Bus 资源。我们可以使用模拟器或连接到真实的 Azure 资源:

csharp 复制代码
var builder = DistributedApplication.CreateBuilder(args);

// 添加 Azure Service Bus 资源
var serviceBus = builder.AddAzureServiceBus("messaging");

// 定义队列和主题 Aspire 9.1+ 新 API
var queue = serviceBus.AddServiceBusQueue("orders");
var topic = serviceBus.AddServiceBusTopic("notifications");
var subscription = topic.AddServiceBusSubscription("email-subscription");

// 将资源引用添加到服务中
builder.AddProject<Projects.MyWorkerService>("worker")
    .WithReference(serviceBus);

builder.Build().Run();

在上面的代码中,AddAzureServiceBus 方法用于创建 Service Bus 资源。Aspire 9.1+ 版本提供了新的 API,可以直接在 AppHost 中定义队列和主题,这样可以确保资源的一致性。AddServiceBusQueue 用于创建队列,AddServiceBusTopic 用于创建主题,AddServiceBusSubscription 用于在主题下创建订阅。

对于生产环境,我们可能需要连接到已存在的 Azure Service Bus 命名空间:

csharp 复制代码
var serviceBus = builder.AddAzureServiceBus("messaging")
               .RunAsEmulator(); // 开发环境使用模拟器

// 或者连接到真实的 Azure 资源
var serviceBus = builder.AddAzureServiceBus("messaging")
               .WithConnectionString("Endpoint=sb://...");

代码中的RunAsEmulator() 方法用于在开发环境中使用 Service Bus 模拟器,而 WithConnectionString() 方法用于连接到真实的 Azure Service Bus 命名空间。

2.2 Client 配置

在服务项目中安装 Aspire.Azure.Messaging.ServiceBus 包:

bash 复制代码
dotnet add package Aspire.Azure.Messaging.ServiceBus

Program.cs 中注册 Service Bus 客户端:

csharp 复制代码
var builder = WebApplication.CreateBuilder(args);

// 注册 ServiceBusClient
builder.AddAzureServiceBusClient("messaging");

var app = builder.Build();

注册完成后,可以通过依赖注入获取 ServiceBusClient 来进行消息的发送和接收。下面是一个完整的发送和接收示例:

csharp 复制代码
public class ServiceBusPublisher
{
    private readonly ServiceBusClient _client;

    public ServiceBusPublisher(ServiceBusClient client)
    {
     _client = client;
    }

    public async Task SendMessageAsync(string queueName, string messageContent)
    {
     var sender = _client.CreateSender(queueName);
     
     var message = new ServiceBusMessage(messageContent)
     {
         MessageId = Guid.NewGuid().ToString(),
         ContentType = "application/json",
         TimeToLive = TimeSpan.FromMinutes(30) // 消息生存时间
     };

     // 添加自定义属性
     message.ApplicationProperties.Add("Priority", "High");
     message.ApplicationProperties.Add("Source", "OrderService");

     await sender.SendMessageAsync(message);
    }
}

在上面的代码中,我们创建了一个 ServiceBusPublisher 类,通过依赖注入获取 ServiceBusClient,并在 SendMessageAsync 方法中发送消息到指定的队列。我们设置了消息的 MessageIdContentTypeTimeToLive 属性,同时还添加了自定义应用程序属性。

接下来创建一个消息处理器类 ServiceBusProcessor,用于接收和处理消息:

csharp 复制代码
public class ServiceBusMessageProcessor : BackgroundService
{
    private readonly ServiceBusClient _client;
    private readonly ILogger<ServiceBusMessageProcessor> _logger;
    private ServiceBusProcessor _processor;

    public ServiceBusMessageProcessor(ServiceBusClient client, ILogger<ServiceBusMessageProcessor> logger)
    {
     _client = client;
     _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
     _processor = _client.CreateProcessor("orders", new ServiceBusProcessorOptions
     {
         MaxConcurrentCalls = 10,  // 并发处理数量
         AutoCompleteMessages = false,  // 手动完成消息
         MaxAutoLockRenewalDuration = TimeSpan.FromMinutes(5)  // 自动续租锁
     });

     _processor.ProcessMessageAsync += MessageHandler;
     _processor.ProcessErrorAsync += ErrorHandler;

     await _processor.StartProcessingAsync(stoppingToken);
     
     // 等待取消信号
     await Task.Delay(Timeout.Infinite, stoppingToken);
    }

    private async Task MessageHandler(ProcessMessageEventArgs args)
    {
     try
     {
         var body = args.Message.Body.ToString();
         _logger.LogInformation("接收到消息: {Body}, MessageId: {MessageId}", 
          body, args.Message.MessageId);

         // 处理消息逻辑...

         // 完成消息
         await args.CompleteMessageAsync(args.Message);
     }
     catch (Exception ex)
     {
         _logger.LogError(ex, "处理消息失败");
         // 可以选择放弃消息或将其死信化
         await args.AbandonMessageAsync(args.Message);
     }
    }

    private Task ErrorHandler(ProcessErrorEventArgs args)
    {
     _logger.LogError(args.Exception, "Service Bus 处理错误: {ErrorSource}", args.ErrorSource);
     return Task.CompletedTask;
    }

    public override async Task StopAsync(CancellationToken cancellationToken)
    {
     await _processor.StopProcessingAsync(cancellationToken);
     await _processor.DisposeAsync();
     await base.StopAsync(cancellationToken);
    }
}

ServiceBusMessageProcessor 类中,我们创建了一个 ServiceBusProcessor 实例,并配置了并发处理数量、手动完成消息和自动续租锁等选项。然后注册了消息处理和错误处理的事件处理器。在 MessageHandler 中,我们接收并处理消息,成功处理后调用 CompleteMessageAsync 完成消息,失败时调用 AbandonMessageAsync 放弃消息以便重新投递。

我们也可以通过配置文件对 Service Bus 客户端进行配置:

json 复制代码
{
  "Aspire": {
    "Azure": {
      "Messaging": {
     "ServiceBus": {
       "FullyQualifiedNamespace": "myservicebus.servicebus.windows.net",
       "DisableHealthChecks": false,
       "DisableTracing": false
     }
      }
    }
  }
}

三、消息发布订阅模式

消息发布/订阅模式是一种重要的消息传递模式,它实现了发送者和接收者之间的解耦。发布者无需知道谁将接收消息,订阅者也无需知道消息来自何处。这种模式特别适合需要将同一消息广播给多个消费者的场景,例如事件通知、日志收集、消息分发等。

3.1 RabbitMQ 发布/订阅实现

RabbitMQ 通过 Exchange(交换机)Queue(队列) 的绑定机制实现发布/订阅模式。Exchange 接收生产者发送的消息,然后根据特定规则将消息路由到一个或多个队列。RabbitMQ 支持多种类型的 Exchange:

  • Fanout Exchange: 广播模式,将消息路由到所有绑定的队列
  • Direct Exchange: 直接模式,根据路由键精确匹配
  • Topic Exchange: 主题模式,支持通配符路由
  • Headers Exchange: 头部模式,根据消息头属性路由
3.1.1 使用 Fanout Exchange 广播消息

Fanout Exchange 是最简单的发布/订阅实现,它会将消息发送到所有绑定的队列:

csharp 复制代码
public class RabbitMQPublisher
{
    private readonly IConnection _connection;

    public RabbitMQPublisher(IConnection connection)
    {
        _connection = connection;
    }

    public void PublishToExchange(string exchangeName, string message)
    {
        using var channel = _connection.CreateModel();
        
        // 声明 Fanout Exchange
        channel.ExchangeDeclare(
            exchange: exchangeName, 
            type: ExchangeType.Fanout,
            durable: true,      // Exchange 持久化
            autoDelete: false);

        var body = Encoding.UTF8.GetBytes(message);
        var properties = channel.CreateBasicProperties();
        properties.Persistent = true;

        // 发布消息到 Exchange,routingKey 在 Fanout 模式下被忽略
        channel.BasicPublish(
            exchange: exchangeName, 
            routingKey: "", 
            basicProperties: properties, 
            body: body);
    }
}

在发布者中,我们使用 PublishToExchange 方法将消息发送到名为 "logs" 的 Fanout Exchange。由于 Fanout 模式下 routingKey 被忽略,所有绑定到该 Exchange 的队列都会接收到消息。为了实现广播效果,可以创建多个订阅者来接收消息:

csharp 复制代码
public class RabbitMQSubscriber : BackgroundService
{
    private readonly IConnection _connection;
    private readonly ILogger<RabbitMQSubscriber> _logger;
    private readonly string _queueName;

    public RabbitMQSubscriber(
        IConnection connection, 
        ILogger<RabbitMQSubscriber> logger, 
        string queueName)
    {
        _connection = connection;
        _logger = logger;
        _queueName = queueName;
    }

    protected override Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var channel = _connection.CreateModel();
        
        // 声明 Exchange
        channel.ExchangeDeclare(exchange: "logs", type: ExchangeType.Fanout, durable: true);
        
        // 声明队列(可以是临时队列或持久化队列)
        channel.QueueDeclare(
            queue: _queueName,
            durable: true,
            exclusive: false,
            autoDelete: false);
        
        // 将队列绑定到 Exchange
        channel.QueueBind(queue: _queueName, exchange: "logs", routingKey: "");

        var consumer = new EventingBasicConsumer(channel);
        consumer.Received += (model, ea) =>
        {
            var body = ea.Body.ToArray();
            var message = Encoding.UTF8.GetString(body);
            
            _logger.LogInformation("[{QueueName}] 接收到消息: {Message}", _queueName, message);
            
            // 处理消息...
            
            channel.BasicAck(deliveryTag: ea.DeliveryTag, multiple: false);
        };

        channel.BasicConsume(queue: _queueName, autoAck: false, consumer: consumer);

        return Task.CompletedTask;
    }
}

RabbitMQSubscriber 类中,我们声明了 Fanout Exchange 并创建队列,然后通过 QueueBind 方法将队列绑定到 Exchange。这样,所有绑定到 "logs" Exchange 的队列都会接收到相同的消息副本。在 Program.cs 中注册多个订阅者:

csharp 复制代码
// 订阅者1
builder.Services.AddHostedService(sp => 
    new RabbitMQSubscriber(
        sp.GetRequiredService<IConnection>(),
        sp.GetRequiredService<ILogger<RabbitMQSubscriber>>(),
        "subscriber1-queue"));

// 订阅者2
builder.Services.AddHostedService(sp => 
    new RabbitMQSubscriber(
        sp.GetRequiredService<IConnection>(),
        sp.GetRequiredService<ILogger<RabbitMQSubscriber>>(),
        "subscriber2-queue"));

// 订阅者3
builder.Services.AddHostedService(sp => 
    new RabbitMQSubscriber(
        sp.GetRequiredService<IConnection>(),
        sp.GetRequiredService<ILogger<RabbitMQSubscriber>>(),
        "subscriber3-queue"));

这样,当发布者向 "logs" Exchange 发送消息时,所有三个订阅者都会同时接收到消息。这种模式特别适合需要将同一消息广播给多个服务的场景,例如日志收集、系统监控、事件通知等。

3.1.2 使用 Topic Exchange 实现路由订阅

Topic Exchange 提供了更灵活的路由机制,支持通配符匹配。路由键使用点号分隔,例如 order.createdorder.updated。订阅者可以使用 *(匹配一个单词)和 #(匹配零个或多个单词)进行模式匹配:

csharp 复制代码
public class TopicPublisher
{
    private readonly IConnection _connection;

    public TopicPublisher(IConnection connection)
    {
        _connection = connection;
    }

    public void PublishEvent(string eventType, string message)
    {
        using var channel = _connection.CreateModel();
        
        // 声明 Topic Exchange
        channel.ExchangeDeclare(
            exchange: "events", 
            type: ExchangeType.Topic,
            durable: true);

        var body = Encoding.UTF8.GetBytes(message);
        var properties = channel.CreateBasicProperties();
        properties.Persistent = true;

        // 使用事件类型作为路由键,例如 "order.created"、"user.registered"
        channel.BasicPublish(
            exchange: "events", 
            routingKey: eventType, 
            basicProperties: properties, 
            body: body);
    }
}

在发布者中,我们使用 PublishEvent 方法将消息发送到名为 "events" 的 Topic Exchange。路由键用于标识事件类型,例如 order.createduser.registered。接下来,我们创建支持模式匹配的订阅者:

csharp 复制代码
public class TopicSubscriber : BackgroundService
{
    private readonly IConnection _connection;
    private readonly ILogger<TopicSubscriber> _logger;
    private readonly string _queueName;
    private readonly string[] _routingKeyPatterns;

    public TopicSubscriber(
        IConnection connection, 
        ILogger<TopicSubscriber> logger,
        string queueName,
        params string[] routingKeyPatterns)
    {
        _connection = connection;
        _logger = logger;
        _queueName = queueName;
        _routingKeyPatterns = routingKeyPatterns;
    }

    protected override Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var channel = _connection.CreateModel();
        
        channel.ExchangeDeclare(exchange: "events", type: ExchangeType.Topic, durable: true);
        channel.QueueDeclare(queue: _queueName, durable: true, exclusive: false, autoDelete: false);
        
        // 绑定多个路由键模式
        // 例如: "order.*" 匹配所有订单相关事件
        //      "*.created" 匹配所有创建事件
        //      "order.#" 匹配 order 开头的所有事件
        foreach (var pattern in _routingKeyPatterns)
        {
            channel.QueueBind(queue: _queueName, exchange: "events", routingKey: pattern);
        }

        var consumer = new EventingBasicConsumer(channel);
        consumer.Received += (model, ea) =>
        {
            var body = ea.Body.ToArray();
            var message = Encoding.UTF8.GetString(body);
            var routingKey = ea.RoutingKey;
            
            _logger.LogInformation(
                "[{QueueName}] 接收到事件 {RoutingKey}: {Message}", 
                _queueName, routingKey, message);
            
            // 根据路由键处理不同类型的事件
            ProcessEvent(routingKey, message);
            
            channel.BasicAck(deliveryTag: ea.DeliveryTag, multiple: false);
        };

        channel.BasicConsume(queue: _queueName, autoAck: false, consumer: consumer);

        return Task.CompletedTask;
    }

    private void ProcessEvent(string eventType, string message)
    {
        // 实现具体的事件处理逻辑
    }
}

TopicSubscriber 类中,我们声明了 Topic Exchange 并创建队列,然后通过 QueueBind 方法将队列绑定到多个路由键模式。这样,不同的订阅者可以根据自己的需求选择性地接收消息。例如,一个订单服务可能只关心 order.* 事件,而一个审计服务可能需要接收所有创建、更新和删除事件。最后我们在 Program.cs 中注册多个订阅者:

csharp 复制代码
// 订阅所有订单相关事件
builder.Services.AddHostedService(sp => 
    new TopicSubscriber(
        sp.GetRequiredService<IConnection>(),
        sp.GetRequiredService<ILogger<TopicSubscriber>>(),
        "order-service-queue",
        "order.*"));

// 订阅所有创建事件
builder.Services.AddHostedService(sp => 
    new TopicSubscriber(
        sp.GetRequiredService<IConnection>(),
        sp.GetRequiredService<ILogger<TopicSubscriber>>(),
        "audit-service-queue",
        "*.created", "*.updated", "*.deleted"));

通过这种方式,不同的订阅者可以根据自己的需求选择性地接收消息,实现更细粒度的消息路由和处理。

3.2 Azure Service Bus 发布/订阅实现

Azure Service Bus 使用 Topic(主题)Subscription(订阅) 来实现发布/订阅模式。Topic 类似于 RabbitMQ 的 Exchange,而 Subscription 类似于 Queue。每个 Subscription 可以有自己的过滤规则,只接收符合条件的消息。

3.2.1 基本的主题发布

在 Service Bus 中,发布者将消息发送到 Topic,而订阅者则从 Subscription 接收消息,而不是直接从 Topic 接收,代码如下:

csharp 复制代码
public class ServiceBusTopicPublisher
{
    private readonly ServiceBusClient _client;
    private readonly ILogger<ServiceBusTopicPublisher> _logger;

    public ServiceBusTopicPublisher(ServiceBusClient client, ILogger<ServiceBusTopicPublisher> logger)
    {
        _client = client;
        _logger = logger;
    }

    public async Task PublishEventAsync(string topicName, string eventType, object eventData)
    {
        var sender = _client.CreateSender(topicName);
        
        var messageBody = JsonSerializer.Serialize(eventData);
        var message = new ServiceBusMessage(messageBody)
        {
            MessageId = Guid.NewGuid().ToString(),
            Subject = eventType,  // 使用 Subject 标识事件类型
            ContentType = "application/json",
            TimeToLive = TimeSpan.FromHours(24)
        };

        // 添加自定义属性,可用于订阅筛选
        message.ApplicationProperties.Add("EventType", eventType);
        message.ApplicationProperties.Add("Source", "OrderService");
        message.ApplicationProperties.Add("Timestamp", DateTimeOffset.UtcNow);

        try
        {
            await sender.SendMessageAsync(message);
            _logger.LogInformation("发布事件 {EventType} 到主题 {TopicName}", eventType, topicName);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "发布事件失败: {EventType}", eventType);
            throw;
        }
        finally
        {
            await sender.DisposeAsync();
        }
    }
}

在发布者中,我们使用 PublishEventAsync 方法将消息发送到指定的 Topic。消息的 Subject 属性用于标识事件类型,例如 Order.CreatedPayment.Failed。还可以添加自定义属性,如 EventTypeSourceTimestamp,这些属性可用于订阅者的过滤和处理。

3.2.2 创建带过滤规则的订阅者

Azure Service Bus 支持强大的消息过滤功能,可以基于消息属性进行筛选:

csharp 复制代码
public class ServiceBusTopicSubscriber : BackgroundService
{
    private readonly ServiceBusClient _client;
    private readonly ILogger<ServiceBusTopicSubscriber> _logger;
    private readonly string _topicName;
    private readonly string _subscriptionName;
    private ServiceBusProcessor _processor;

    public ServiceBusTopicSubscriber(
        ServiceBusClient client,
        ILogger<ServiceBusTopicSubscriber> logger,
        string topicName,
        string subscriptionName)
    {
        _client = client;
        _logger = logger;
        _topicName = topicName;
        _subscriptionName = subscriptionName;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _processor = _client.CreateProcessor(_topicName, _subscriptionName, new ServiceBusProcessorOptions
        {
            MaxConcurrentCalls = 5,
            AutoCompleteMessages = false,
            MaxAutoLockRenewalDuration = TimeSpan.FromMinutes(10),
            PrefetchCount = 10  // 预取消息数量
        });

        _processor.ProcessMessageAsync += ProcessMessageAsync;
        _processor.ProcessErrorAsync += ProcessErrorAsync;

        await _processor.StartProcessingAsync(stoppingToken);
        
        _logger.LogInformation("开始处理主题 {Topic} 的订阅 {Subscription}", _topicName, _subscriptionName);
        
        await Task.Delay(Timeout.Infinite, stoppingToken);
    }

    private async Task ProcessMessageAsync(ProcessMessageEventArgs args)
    {
        try
        {
            var body = args.Message.Body.ToString();
            var eventType = args.Message.ApplicationProperties.TryGetValue("EventType", out var type) 
                ? type.ToString() 
                : args.Message.Subject;

            _logger.LogInformation(
                "[{Subscription}] 接收到事件 {EventType}, MessageId: {MessageId}", 
                _subscriptionName, eventType, args.Message.MessageId);

            // 根据事件类型处理消息
            await ProcessEventAsync(eventType, body);

            // 完成消息处理
            await args.CompleteMessageAsync(args.Message);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "处理消息失败: {MessageId}", args.Message.MessageId);
            
            // 可以选择放弃消息(重新投递)或死信化
            await args.AbandonMessageAsync(args.Message);
        }
    }

    private async Task ProcessEventAsync(string eventType, string messageBody)
    {
        // 实现具体的事件处理逻辑
        await Task.CompletedTask;
    }

    private Task ProcessErrorAsync(ProcessErrorEventArgs args)
    {
        _logger.LogError(args.Exception, 
            "Service Bus 错误: {ErrorSource}, Entity: {EntityPath}", 
            args.ErrorSource, args.EntityPath);
        return Task.CompletedTask;
    }

    public override async Task StopAsync(CancellationToken cancellationToken)
    {
        if (_processor != null)
        {
            await _processor.StopProcessingAsync(cancellationToken);
            await _processor.DisposeAsync();
        }
        await base.StopAsync(cancellationToken);
    }
}

ServiceBusTopicSubscriber 类中,我们创建了一个 ServiceBusProcessor 实例,并配置了并发处理数量、手动完成消息和自动续租锁等选项。然后注册了消息处理和错误处理的事件处理器。在 ProcessMessageAsync 方法中,我们接收并处理消息,成功处理后调用 CompleteMessageAsync 完成消息,失败时调用 AbandonMessageAsync 放弃消息以便重新投递。还可以根据消息的 Subject 或自定义属性 EventType 等性地处理不同类型的事件。

3.2.3 使用订阅过滤规则

Azure Service Bus 支持在创建订阅时设置过滤规则,允许订阅者只接收符合特定条件的消息。这种机制可以有效减少不必要的消息传递,提高系统效率。在 Aspire 9.1+ 版本中,虽然可以在 AppHost 中声明订阅,但过滤规则通常需要通过 Azure 门户、Azure CLI 或使用 Azure.Messaging.ServiceBus.Administration SDK 来配置。

我们在 AppHost 中创建带有过滤规则的订阅,例如只接收特定类型的事件。首先在 AppHost 中声明主题和订阅的基本结构:

csharp 复制代码
var builder = DistributedApplication.CreateBuilder(args);

var serviceBus = builder.AddAzureServiceBus("messaging");
var topic = serviceBus.AddServiceBusTopic("events");

// 创建多个具有不同用途的订阅
var orderSubscription = topic.AddServiceBusSubscription("order-subscription");
var prioritySubscription = topic.AddServiceBusSubscription("priority-subscription");
var auditSubscription = topic.AddServiceBusSubscription("audit-subscription");

builder.Build().Run();

然而要为这些订阅添加过滤规则,我们需要使用 Azure.Messaging.ServiceBus.Administration 包。首先在管理项目中安装该包:

bash 复制代码
dotnet add package Azure.Messaging.ServiceBus.Administration

然后创建一个配置类来设置订阅过滤规则:

csharp 复制代码
using Azure.Messaging.ServiceBus.Administration;

public class ServiceBusSubscriptionConfiguration
{
    private readonly ServiceBusAdministrationClient _adminClient;
    private readonly ILogger<ServiceBusSubscriptionConfiguration> _logger;

    public ServiceBusSubscriptionConfiguration(
        string connectionString,
        ILogger<ServiceBusSubscriptionConfiguration> logger)
    {
        _adminClient = new ServiceBusAdministrationClient(connectionString);
        _logger = logger;
    }

    public async Task ConfigureSubscriptionsAsync()
    {
        var topicName = "events";

        // 配置订单订阅 - 只接收订单相关事件
        await CreateSubscriptionWithFilterAsync(
            topicName,
            "order-subscription",
            new SqlRuleFilter("EventType LIKE 'Order%'"),
            "只接收订单相关事件");

        // 配置高优先级订阅 - 只接收高优先级的重要事件
        await CreateSubscriptionWithFilterAsync(
            topicName,
            "priority-subscription",
            new SqlRuleFilter("Priority = 'High' AND (EventType = 'Order.Created' OR EventType = 'Payment.Failed')"),
            "只接收高优先级的订单创建和支付失败事件");

        // 配置审计订阅 - 接收所有创建、更新和删除事件
        await CreateSubscriptionWithFilterAsync(
            topicName,
            "audit-subscription",
            new SqlRuleFilter("EventType LIKE '%.Created' OR EventType LIKE '%.Updated' OR EventType LIKE '%.Deleted'"),
            "接收所有创建、更新和删除事件用于审计");
    }

    private async Task CreateSubscriptionWithFilterAsync(
        string topicName,
        string subscriptionName,
        SqlRuleFilter filter,
        string description)
    {
        try
        {
            // 检查订阅是否存在
            if (!await _adminClient.SubscriptionExistsAsync(topicName, subscriptionName))
            {
                // 创建订阅
                var subscriptionOptions = new CreateSubscriptionOptions(topicName, subscriptionName)
                {
                    MaxDeliveryCount = 10,
                    LockDuration = TimeSpan.FromMinutes(5),
                    DefaultMessageTimeToLive = TimeSpan.FromDays(7),
                    EnableDeadLetteringOnMessageExpiration = true,
                    EnableBatchedOperations = true
                };

                await _adminClient.CreateSubscriptionAsync(subscriptionOptions);
                _logger.LogInformation("创建订阅: {Subscription}", subscriptionName);
            }

            // 删除默认规则($Default)
            try
            {
                await _adminClient.DeleteRuleAsync(topicName, subscriptionName, "$Default");
            }
            catch (ServiceBusException ex) when (ex.Reason == ServiceBusFailureReason.MessagingEntityNotFound)
            {
                // 规则不存在,忽略
            }

            // 创建自定义过滤规则
            var ruleOptions = new CreateRuleOptions
            {
                Name = "CustomFilter",
                Filter = filter
            };

            // 检查规则是否已存在
            if (!await _adminClient.RuleExistsAsync(topicName, subscriptionName, "CustomFilter"))
            {
                await _adminClient.CreateRuleAsync(topicName, subscriptionName, ruleOptions);
                _logger.LogInformation("为订阅 {Subscription} 创建过滤规则: {Description}", 
                    subscriptionName, description);
            }
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "配置订阅 {Subscription} 失败", subscriptionName);
            throw;
        }
    }
}

在上述代码中,我们创建了一个 ServiceBusSubscriptionConfiguration 类,用于配置订阅过滤规则。CreateSubscriptionWithFilterAsync 方法首先检查订阅是否存在,如果不存在则创建订阅。然后删除默认的 $Default 规则,并创建自定义的过滤规则。然后我们在应用启动时调用配置方法:

csharp 复制代码
var builder = WebApplication.CreateBuilder(args);

// 在开发环境中配置订阅(仅在需要时运行)
if (builder.Environment.IsDevelopment())
{
    var connectionString = builder.Configuration.GetConnectionString("messaging");
    var logger = LoggerFactory.Create(b => b.AddConsole())
        .CreateLogger<ServiceBusSubscriptionConfiguration>();
    
    var config = new ServiceBusSubscriptionConfiguration(connectionString, logger);
    await config.ConfigureSubscriptionsAsync();
}

var app = builder.Build();
app.Run();

除了 SQL 过滤器,Azure Service Bus 还支持 Correlation Filter(相关过滤器),它比 SQL 过滤器性能更好,适合基于消息属性的简单匹配:

csharp 复制代码
// 使用相关过滤器
var correlationFilter = new CorrelationRuleFilter
{
    Subject = "Order.Created",
    ApplicationProperties =
    {
        { "Priority", "High" },
        { "Region", "China" }
    }
};

var ruleOptions = new CreateRuleOptions
{
    Name = "CorrelationFilter",
    Filter = correlationFilter
};

await _adminClient.CreateRuleAsync(topicName, subscriptionName, ruleOptions);

相关过滤器通过精确匹配消息的 SubjectContentTypeCorrelationId 等系统属性,以及 ApplicationProperties 中的自定义属性来过滤消息。它的性能优于 SQL 过滤器,特别适合简单的属性匹配场景。

对于更复杂的过滤需求,SQL 过滤器支持丰富的操作符和函数:

csharp 复制代码
// 复杂的 SQL 过滤器示例
var complexFilter = new SqlRuleFilter(@"
    (EventType = 'Order.Created' AND Amount > 1000) OR
    (EventType = 'Payment.Failed' AND RetryCount < 3) OR
    (Priority = 'Critical' AND sys.EnqueuedTimeUtc > '2024-01-01T00:00:00Z')
");

这种灵活的过滤机制使得 Azure Service Bus 能够实现精确的消息路由,每个订阅者只接收与其业务逻辑相关的消息,从而提高系统的整体效率和可维护性。

3.2.4 批量发布消息

对于需要发送大量消息的场景,可以使用批量发送提高性能。批量发送可以减少网络往返次数,提高吞吐量。Azure Service Bus 提供了原生的批量消息发送支持,能够在一次网络调用中发送多条消息,显著降低延迟并提高整体性能。

需要注意的是,批量消息的总大小不能超过 Service Bus 的限制(标准层为 256KB,高级层为 1MB)。如果批次已满,需要先发送当前批次,然后创建新的批次继续添加消息。以下是一个使用 ServiceBusMessageBatch 批量发送消息的示例:

csharp 复制代码
public async Task PublishBatchAsync(string topicName, IEnumerable<object> events)
{
    var sender = _client.CreateSender(topicName);
    
    using var messageBatch = await sender.CreateMessageBatchAsync();
    
    foreach (var eventData in events)
    {
        var messageBody = JsonSerializer.Serialize(eventData);
        var message = new ServiceBusMessage(messageBody)
        {
            MessageId = Guid.NewGuid().ToString(),
            ContentType = "application/json"
        };

        if (!messageBatch.TryAddMessage(message))
        {
            // 如果批次已满,发送当前批次并创建新批次
            await sender.SendMessagesAsync(messageBatch);
            messageBatch.Clear();
            
            // 将消息添加到新批次
            if (!messageBatch.TryAddMessage(message))
            {
                throw new Exception($"消息太大,无法添加到批次中");
            }
        }
    }

    // 发送最后一个批次
    if (messageBatch.Count > 0)
    {
        await sender.SendMessagesAsync(messageBatch);
    }

    await sender.DisposeAsync();
}

在上述代码中,我们使用 ServiceBusMessageBatch 来创建和管理消息批次。通过 TryAddMessage 方法将消息添加到批次中,如果批次已满则发送当前批次并创建新批次。最后发送最后一个未发送的批次。这种方法可以显著提高消息发送的性能,特别是在需要发送大量消息的场景中。

3.3 发布/订阅模式的最佳实践

在实现发布/订阅模式时,需要遵循一些最佳实践以确保系统的可靠性和可维护性。首先是消息幂等性,由于在故障场景下同一消息可能被传递多次,因此必须确保消息处理是幂等的,即多次处理同一消息应该产生相同的结果。其次是消息版本控制,建议在消息中包含版本信息,这样可以更好地处理消息格式的演进,避免因格式变更导致的兼容性问题。第三是合理设置消息的生存时间(TTL),防止过期消息在队列中堆积,占用系统资源。第四是建立完善的监控和告警机制,实时监控订阅的积压情况、消息处理延迟和错误率,及时发现并解决潜在问题。第五是使用结构化日志记录关键信息,包括消息ID、事件类型、处理时间等,这对于问题排查和系统分析至关重要。最后是适当的并发控制,需要根据消息处理的复杂度和资源消耗情况,合理设置并发处理的数量,在吞吐量和资源利用率之间找到平衡点,避免因过高的并发导致系统资源耗尽或处理质量下降。

四、消息队列的配置

.NET Aspire 提供了灵活的配置选项,允许我们通过 appsettings.json 配置文件或代码中的委托方法进行精细化配置。合理的配置可以提高系统的可靠性、性能和可维护性。

4.1 RabbitMQ 配置

RabbitMQ 的配置涵盖连接管理、性能优化、健康检查等多个方面。Aspire 支持多种配置方式,可以根据不同的环境和需求灵活选择。

4.1.1 通过配置文件配置

最常见的配置方式是通过 appsettings.json 文件,这种方式便于在不同环境(开发、测试、生产)之间切换配置:

json 复制代码
{
    "Aspire": {
        "RabbitMQ": {
            "Client": {
                "ConnectionString": "amqp://guest:guest@localhost:5672",
                "MaxConnectRetryCount": 10,
                "ConnectTimeout": 30,
                "DisableHealthChecks": false,
                "DisableTracing": false,
                "DisableMetrics": false
            }
        }
    }
}

在配置中,ConnectionString 用于指定 RabbitMQ 连接字符串,格式为 amqp://用户名:密码@主机:端口/虚拟主机MaxConnectRetryCount 用于设置连接失败时的最大重试次数,默认为 5 次,设置为 0 表示不重试。ConnectTimeout 用于设置连接超时时间(秒),默认为 30 秒。DisableHealthChecks 用于控制是否禁用健康检查,默认为 false,健康检查可以监控 RabbitMQ 连接状态。DisableTracing 用于控制是否禁用分布式追踪,默认为 false,启用追踪可以帮助诊断消息流转问题。DisableMetrics 用于控制是否禁用指标收集,默认为 false,指标数据有助于监控系统性能。

4.1.2 通过代码配置

除了配置文件,还可以在代码中使用委托方法进行配置,这种方式更加灵活,适合需要动态配置的场景:

csharp 复制代码
var builder = WebApplication.CreateBuilder(args);

// 通过委托配置 RabbitMQ 客户端
builder.AddRabbitMQClient("messaging", configureSettings: settings =>
{
        settings.MaxConnectRetryCount = 5;
        settings.ConnectTimeout = 30;
        settings.DisableHealthChecks = false;
        settings.DisableTracing = false;
});

var app = builder.Build();

在上面的代码中,我们通过 configureSettings 委托方法对 RabbitMQ 客户端进行了配置。这些设置会覆盖配置文件中的默认值。我们还可以结合配置文件和代码配置:

csharp 复制代码
builder.AddRabbitMQClient("messaging", configureSettings: settings =>
{
        // 从配置文件读取基础配置
        builder.Configuration.GetSection("Aspire:RabbitMQ:Client").Bind(settings);
        
        // 在代码中覆盖或添加特定配置
        if (builder.Environment.IsProduction())
        {
                settings.MaxConnectRetryCount = 10;
                settings.ConnectTimeout = 60;
        }
        else
        {
                settings.MaxConnectRetryCount = 3;
                settings.DisableHealthChecks = true;
        }
});

上面的代码中,我们首先从配置文件中读取基础配置,然后根据环境(开发、生产)在代码中覆盖或添加特定配置。这种方式可以实现配置的集中管理与灵活控制,适用于复杂多变的系统环境。

4.1.3 高级连接配置

对于需要更细粒度控制的场景,可以直接配置 ConnectionFactory

csharp 复制代码
builder.AddRabbitMQClient("messaging", configureConnectionFactory: factory =>
{
        factory.AutomaticRecoveryEnabled = true;  // 启用自动恢复
        factory.NetworkRecoveryInterval = TimeSpan.FromSeconds(10);  // 恢复间隔
        factory.RequestedHeartbeat = TimeSpan.FromSeconds(60);  // 心跳间隔
        factory.RequestedConnectionTimeout = TimeSpan.FromSeconds(30);  // 连接超时
        factory.SocketReadTimeout = TimeSpan.FromSeconds(30);  // Socket 读取超时
        factory.SocketWriteTimeout = TimeSpan.FromSeconds(30);  // Socket 写入超时
        factory.TopologyRecoveryEnabled = true;  // 启用拓扑恢复(自动重建队列、交换机等)
        factory.DispatchConsumersAsync = true;  // 使用异步消费者
});

这些高级配置选项可以帮助优化连接的可靠性和性能。AutomaticRecoveryEnabled 用于启用自动恢复机制,在连接断开时自动重连,确保系统的高可用性。NetworkRecoveryInterval 设置网络恢复的时间间隔,避免频繁重连造成的资源浪费。RequestedHeartbeat 定义心跳间隔,用于检测连接是否存活,及时发现并处理连接异常。TopologyRecoveryEnabled 在连接恢复后自动重建队列、交换机和绑定关系,保证系统拓扑结构的完整性。DispatchConsumersAsync 使用异步方式分发消息给消费者,提高并发处理能力,特别适合需要高吞吐量的场景。

Azure Service Bus 的配置同样支持配置文件和代码两种方式,并且提供了丰富的选项来优化性能和可靠性。相比 RabbitMQ,Azure Service Bus 作为云原生的托管服务,其配置更加注重云环境下的重试策略、传输协议选择以及与 Azure 生态系统的集成。

4.2.1 通过配置文件配置

appsettings.json 中配置 Azure Service Bus 客户端是最常见的方式,这种声明式配置便于在不同环境之间切换,也有利于配置的集中管理:

json 复制代码
{
    "Aspire": {
        "Azure": {
            "Messaging": {
                "ServiceBus": {
                    "FullyQualifiedNamespace": "myservicebus.servicebus.windows.net",
                    "DisableHealthChecks": false,
                    "DisableTracing": false,
                    "DisableMetrics": false,
                    "RetryOptions": {
                        "Mode": "Exponential",
                        "MaxRetries": 3,
                        "Delay": "00:00:00.800",
                        "MaxDelay": "00:01:00",
                        "TryTimeout": "00:01:00"
                    },
                    "TransportType": "AmqpTcp"
                }
            }
        }
    }
}

在上述配置中,FullyQualifiedNamespace 指定了 Service Bus 命名空间的完全限定域名,这是连接到 Azure Service Bus 的关键信息。DisableHealthChecksDisableTracingDisableMetrics 三个选项分别控制健康检查、分布式追踪和指标收集功能,在开发环境中可以禁用这些功能以简化调试,但在生产环境中强烈建议启用以便监控系统运行状态。

RetryOptions 部分配置了重试策略,这对于处理瞬态故障至关重要。Mode 设置为 Exponential 表示使用指数退避策略,即每次重试的延迟时间会递增,这样可以避免在服务暂时不可用时过度频繁地重试。MaxRetries 设置为 3 表示最多重试 3 次,超过这个次数后操作将失败。Delay 定义了初始延迟时间为 800 毫秒,MaxDelay 限制了最大延迟时间为 1 分钟,确保即使在指数退避的情况下也不会等待过长时间。TryTimeout 设置了单次操作的超时时间为 1 分钟,如果在这个时间内操作未完成则视为超时。

TransportType 指定了传输协议类型,默认使用 AmqpTcp,这是性能最好的选择。如果网络环境中存在防火墙限制或需要通过 HTTP 代理连接,可以选择 AmqpWebSockets,它通过 WebSocket 协议传输 AMQP 消息,可以穿透大多数防火墙。

对于需要使用连接字符串的场景,比如在开发环境中或需要使用共享访问签名进行认证时,可以使用以下配置:

json 复制代码
{
    "Aspire": {
        "Azure": {
            "Messaging": {
                "ServiceBus": {
                    "ConnectionString": "Endpoint=sb://myservicebus.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=...",
                    "DisableHealthChecks": false
                }
            }
        }
    }
}

这种方式将认证信息直接包含在连接字符串中,适合快速开发和测试,但在生产环境中应该使用 Azure 托管身份或 Azure Key Vault 来管理敏感信息。

4.2.2 通过代码配置

除了配置文件,还可以在代码中直接配置 Service Bus 客户端,这种方式提供了更大的灵活性,可以根据运行时条件动态调整配置:

csharp 复制代码
var builder = WebApplication.CreateBuilder(args);

builder.AddAzureServiceBusClient("messaging", configureSettings: settings =>
{
        settings.DisableHealthChecks = false;
        settings.DisableTracing = false;
        
        // 配置重试策略
        settings.RetryOptions = new ServiceBusRetryOptions
        {
                Mode = ServiceBusRetryMode.Exponential,
                MaxRetries = 5,
                Delay = TimeSpan.FromMilliseconds(800),
                MaxDelay = TimeSpan.FromMinutes(1),
                TryTimeout = TimeSpan.FromMinutes(1)
        };
        
        // 配置传输类型
        settings.TransportType = ServiceBusTransportType.AmqpTcp;
});

var app = builder.Build();

在这段代码中,我们通过 configureSettings 委托方法配置了 Service Bus 客户端的各项参数。与配置文件中的设置相比,这里的配置更加直观,可以使用强类型的枚举和时间跨度对象,减少了配置错误的可能性。通过创建 ServiceBusRetryOptions 对象,我们精确控制了重试行为,例如将 MaxRetries 增加到 5 次以提高系统的容错能力。这种代码配置方式的另一个优势是可以根据环境变量或其他运行时条件动态调整配置,比如在生产环境中使用更激进的重试策略,而在开发环境中使用更保守的策略以便快速失败和调试。

4.2.3 高级客户端配置

对于需要更细粒度控制的场景,比如配置网络代理、自定义端点或调整底层 AMQP 连接参数,可以直接配置 ServiceBusClientOptions

csharp 复制代码
builder.AddAzureServiceBusClient("messaging", configureClientOptions: clientOptions =>
{
        // 配置重试策略
        clientOptions.RetryOptions = new ServiceBusRetryOptions
        {
                Mode = ServiceBusRetryMode.Exponential,
                MaxRetries = 5,
                Delay = TimeSpan.FromMilliseconds(800),
                MaxDelay = TimeSpan.FromMinutes(1),
                TryTimeout = TimeSpan.FromMinutes(1)
        };
        
        // 配置传输类型
        clientOptions.TransportType = ServiceBusTransportType.AmqpTcp;
        
        // 配置 Web Socket 代理(仅在使用 AmqpWebSockets 时需要)
        if (clientOptions.TransportType == ServiceBusTransportType.AmqpWebSockets)
        {
                clientOptions.WebProxy = new WebProxy("http://proxy.company.com:8080");
        }
        
        // 配置自定义端点(用于测试或特殊网络环境)
        // clientOptions.CustomEndpointAddress = new Uri("https://custom-endpoint.servicebus.windows.net");
});

这段代码展示了如何对 ServiceBusClientOptions 进行高级配置。首先配置了重试策略,确保在网络不稳定或服务暂时不可用时能够自动重试。然后设置了传输类型为 AmqpTcp,这是性能最优的选择。当检测到传输类型为 AmqpWebSockets 时,代码会配置 Web 代理服务器地址,这在企业网络环境中非常常见,因为许多企业要求所有外部连接必须通过代理服务器。WebProxy 对象允许我们指定代理服务器的地址和端口,如果代理服务器需要认证,还可以设置凭据信息。注释掉的 CustomEndpointAddress 配置项允许我们指定自定义的服务端点,这在进行本地测试、使用模拟器或需要连接到特殊的 Service Bus 实例时非常有用。

4.2.4 Processor 配置

在创建 ServiceBusProcessor 时,可以进行更详细的配置以优化消息处理的性能和可靠性。处理器的配置直接影响消息的接收方式、并发处理能力以及错误恢复机制:

csharp 复制代码
public class ConfigurableServiceBusProcessor : BackgroundService
{
        private readonly ServiceBusClient _client;
        private readonly ILogger<ConfigurableServiceBusProcessor> _logger;
        private ServiceBusProcessor _processor;

        public ConfigurableServiceBusProcessor(
                ServiceBusClient client,
                ILogger<ConfigurableServiceBusProcessor> logger)
        {
                _client = client;
                _logger = logger;
        }

        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
                _processor = _client.CreateProcessor("orders", new ServiceBusProcessorOptions
                {
                        MaxConcurrentCalls = 10,
                        PrefetchCount = 20,
                        AutoCompleteMessages = false,
                        MaxAutoLockRenewalDuration = TimeSpan.FromMinutes(10),
                        ReceiveMode = ServiceBusReceiveMode.PeekLock,
                        SubQueue = SubQueue.None,
                        MaxReceiveWaitTime = TimeSpan.FromSeconds(30)
                });

                _processor.ProcessMessageAsync += ProcessMessageAsync;
                _processor.ProcessErrorAsync += ProcessErrorAsync;

                await _processor.StartProcessingAsync(stoppingToken);
                
                _logger.LogInformation("Service Bus 处理器已启动,队列: orders");
                
                await Task.Delay(Timeout.Infinite, stoppingToken);
        }

        private async Task ProcessMessageAsync(ProcessMessageEventArgs args)
        {
                var body = args.Message.Body.ToString();
                _logger.LogInformation("处理消息: {MessageId}", args.Message.MessageId);
                
                try
                {
                        // 处理消息逻辑
                        await Task.Delay(100); // 模拟处理
                        
                        await args.CompleteMessageAsync(args.Message);
                }
                catch (Exception ex)
                {
                        _logger.LogError(ex, "处理消息失败");
                        await args.AbandonMessageAsync(args.Message);
                }
        }

        private Task ProcessErrorAsync(ProcessErrorEventArgs args)
        {
                _logger.LogError(args.Exception, "Service Bus 错误");
                return Task.CompletedTask;
        }

        public override async Task StopAsync(CancellationToken cancellationToken)
        {
                if (_processor != null)
                {
                        await _processor.StopProcessingAsync(cancellationToken);
                        await _processor.DisposeAsync();
                }
                await base.StopAsync(cancellationToken);
        }
}

在这个完整的处理器实现中,我们创建了一个继承自 BackgroundService 的类来持续处理消息。在 ExecuteAsync 方法中,我们通过 CreateProcessor 方法创建了处理器实例,并传入了详细的配置选项。MaxConcurrentCalls 设置为 10 表示可以同时处理 10 条消息,这个值应该根据消息处理的复杂度和服务器资源来调整,过高可能导致资源耗尽,过低则无法充分利用系统能力。PrefetchCount 设置为 20 表示处理器会预先获取 20 条消息到本地缓存,这可以减少网络往返次数,显著提高吞吐量,但也会占用更多内存。

AutoCompleteMessages 设置为 false 表示需要手动完成消息,这样可以确保只有在消息成功处理后才确认,防止消息丢失。MaxAutoLockRenewalDuration 设置为 10 分钟,这意味着处理器会自动续租消息锁最长 10 分钟,如果消息处理时间可能超过默认的锁定时间,这个设置非常重要。ReceiveMode 设置为 PeekLock 模式,这是推荐的模式,它允许在处理失败时重新投递消息,相对应的 ReceiveAndDelete 模式会在接收消息时立即删除,不支持重试。SubQueue 设置为 None 表示从主队列接收消息,如果需要处理死信队列中的消息,可以设置为 SubQueue.DeadLetterMaxReceiveWaitTime 设置为 30 秒,表示如果队列中没有消息,处理器最多等待 30 秒,这可以减少不必要的轮询开销。

ProcessMessageAsync 方法中,我们实现了具体的消息处理逻辑,使用 try-catch 块捕获异常,确保在处理失败时可以调用 AbandonMessageAsync 放弃消息,让 Service Bus 重新投递。ProcessErrorAsync 方法用于处理处理器级别的错误,比如网络连接问题或认证失败。最后在 StopAsync 方法中,我们确保在应用停止时正确关闭处理器,释放资源。这种完整的实现模式提供了良好的错误处理和资源管理,是生产环境中的最佳实践。

4.3 配置最佳实践

在实际应用中配置消息队列时,应遵循以下最佳实践:

  1. 环境隔离: 为不同的环境(开发、测试、生产)使用独立的配置文件,避免配置混淆
  2. 敏感信息保护: 使用环境变量、Azure Key Vault 或 Secret Manager 存储连接字符串等敏感信息,不要将其硬编码在代码或配置文件中
  3. 合理的重试策略: 根据业务特点设置合适的重试次数和延迟时间,避免过度重试导致资源浪费
  4. 性能优化: 根据消息的大小和处理复杂度,合理设置预取数量和并发处理数量
  5. 监控和诊断: 在生产环境中启用健康检查、分布式追踪和指标收集,以便及时发现和解决问题
  6. 超时设置: 根据网络环境和消息处理时间,合理设置各种超时参数,避免长时间等待或过早超时

使用 User Secrets 存储敏感信息的示例:

bash 复制代码
# 设置开发环境的连接字符串
dotnet user-secrets set "Aspire:RabbitMQ:Client:ConnectionString" "amqp://localhost:5672"

使用 Azure Key Vault 的示例:

csharp 复制代码
var builder = WebApplication.CreateBuilder(args);

// 添加 Azure Key Vault 配置源
if (builder.Environment.IsProduction())
{
        var keyVaultUrl = builder.Configuration["KeyVault:Url"];
        builder.Configuration.AddAzureKeyVault(
                new Uri(keyVaultUrl),
                new DefaultAzureCredential());
}

builder.AddAzureServiceBusClient("messaging");

通过合理的配置管理,可以显著提高系统的可靠性、安全性和可维护性,为构建高质量的分布式应用奠定坚实的基础。

消息处理失败是分布式系统中的常见情况,一个健壮的系统必须具备完善的重试机制来应对网络抖动、服务暂时不可用、资源竞争等瞬态故障。合理的重试策略可以显著提高系统的可靠性和用户体验,避免因临时性问题导致的消息丢失或业务中断。

五、消息重试机制

5.1 连接重试

连接重试是消息队列客户端的基础能力,用于处理网络连接失败或消息代理服务暂时不可用的情况。当连接失败时,客户端会自动尝试重新建立连接,而不是立即抛出异常导致应用崩溃。

5.1.1 RabbitMQ 连接重试

Aspire.RabbitMQ.Client 默认启用了连接重试机制,并且提供了灵活的配置选项。可以通过配置文件或代码来调整重试行为:

json 复制代码
{
    "Aspire": {
        "RabbitMQ": {
            "Client": {
                "ConnectionString": "amqp://localhost:5672",
                "MaxConnectRetryCount": 5,
                "ConnectTimeout": 30
            }
        }
    }
}

在上面的配置中,MaxConnectRetryCount 设置为 5 表示在连接失败后会自动重试最多 5 次。ConnectTimeout 设置为 30 秒,表示单次连接尝试的超时时间。当然,我们也可以在代码中也可以进行配置:

csharp 复制代码
builder.AddRabbitMQClient("messaging", configureSettings: settings =>
{
        settings.MaxConnectRetryCount = 5;  // 最多重试 5 次
        settings.ConnectTimeout = 30;  // 连接超时 30 秒
});

MaxConnectRetryCount 设置为 5 表示在连接失败后会自动重试最多 5 次,如果设置为 0 则表示不重试,直接失败。ConnectTimeout 定义了单次连接尝试的超时时间,如果在指定时间内无法建立连接则视为失败并进入下一次重试。

另外RabbitMQ 客户端还支持更高级的自动恢复配置:

csharp 复制代码
builder.AddRabbitMQClient("messaging", configureConnectionFactory: factory =>
{
        factory.AutomaticRecoveryEnabled = true;  // 启用自动恢复
        factory.NetworkRecoveryInterval = TimeSpan.FromSeconds(10);  // 恢复间隔 10 秒
        factory.TopologyRecoveryEnabled = true;  // 启用拓扑恢复
});

AutomaticRecoveryEnabled 启用后,客户端会在连接断开时自动尝试重新连接。NetworkRecoveryInterval 设置了两次恢复尝试之间的时间间隔,避免过于频繁的重连操作。TopologyRecoveryEnabled 确保在重新连接后自动恢复之前声明的队列、交换机和绑定关系,保持系统拓扑结构的一致性。

5.1.2 Azure Service Bus 连接重试

Azure Service Bus SDK 内置了强大的重试策略,默认使用指数退避算法自动处理瞬态故障。这意味着每次重试的延迟时间会递增,避免在服务繁忙时加剧负载压力:

json 复制代码
{
    "Aspire": {
        "Azure": {
            "Messaging": {
                "ServiceBus": {
                    "FullyQualifiedNamespace": "myservicebus.servicebus.windows.net",
                    "RetryOptions": {
                        "Mode": "Exponential",
                        "MaxRetries": 3,
                        "Delay": "00:00:00.800",
                        "MaxDelay": "00:01:00",
                        "TryTimeout": "00:01:00"
                    }
                }
            }
        }
    }
}

在配置中,RetryOptions 部分配置了重试策略。Mode 设置为 Exponential 表示使用指数退避算法,每次重试的延迟时间会按指数增长。MaxRetries 设置为 3 表示在连接失败后会自动重试最多 3 次。Delay 定义了初始延迟时间为 800 毫秒,MaxDelay 限制了最大延迟时间为 1 分钟,确保即使在指数退避的情况下也不会等待过长时间。TryTimeout 设置了单次操作的超时时间为 1 分钟,如果在这个时间内操作未完成则视为失败并进入下一次重试。我们也可以在代码中配置重试策略:

csharp 复制代码
builder.AddAzureServiceBusClient("messaging", configureSettings: settings =>
{
        settings.RetryOptions = new ServiceBusRetryOptions
        {
                Mode = ServiceBusRetryMode.Exponential,  // 指数退避模式
                MaxRetries = 3,  // 最多重试 3 次
                Delay = TimeSpan.FromMilliseconds(800),  // 初始延迟 800 毫秒
                MaxDelay = TimeSpan.FromMinutes(1),  // 最大延迟 1 分钟
                TryTimeout = TimeSpan.FromMinutes(1)  // 单次尝试超时 1 分钟
        };
});

Mode 设置为 Exponential 表示使用指数退避策略,每次重试的延迟时间会按指数增长。例如,如果初始延迟为 800 毫秒,第一次重试会等待约 800 毫秒,第二次约 1.6 秒,第三次约 3.2 秒,但不会超过 MaxDelay 设置的 1 分钟。除了指数模式,还可以选择 Fixed 固定延迟模式,每次重试都使用相同的延迟时间。TryTimeout 限制了单次操作的最大执行时间,如果在这个时间内操作未完成则视为失败并进入重试流程。

这种内置的重试机制可以自动处理大多数瞬态故障,例如网络抖动、服务限流、临时性服务不可用等,显著提高了系统的容错能力。

5.2 消息处理重试

除了连接重试,消息处理失败后的重试机制同样重要。当消费者在处理消息时遇到异常,系统需要决定是立即重试、延迟重试还是放弃消息。不同的消息队列提供了不同的重试机制和配置选项。

5.2.1 Azure Service Bus 消息处理重试

Azure Service Bus 的 ServiceBusProcessor 具有内置的消息级别重试逻辑。当 ProcessMessageAsync 方法抛出异常时,消息不会立即被删除,而是会被自动放弃并重新投递到队列中,供其他消费者或同一消费者稍后处理:

csharp 复制代码
public class ServiceBusMessageProcessor : BackgroundService
{
        private readonly ServiceBusClient _client;
        private readonly ILogger<ServiceBusMessageProcessor> _logger;
        private ServiceBusProcessor _processor;

        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
                _processor = _client.CreateProcessor("orders", new ServiceBusProcessorOptions
                {
                        MaxConcurrentCalls = 10,
                        AutoCompleteMessages = false,  // 手动完成消息
                        MaxAutoLockRenewalDuration = TimeSpan.FromMinutes(5)
                });

                _processor.ProcessMessageAsync += ProcessMessageAsync;
                _processor.ProcessErrorAsync += ProcessErrorAsync;

                await _processor.StartProcessingAsync(stoppingToken);
                await Task.Delay(Timeout.Infinite, stoppingToken);
        }

        private async Task ProcessMessageAsync(ProcessMessageEventArgs args)
        {
                try
                {
                        var body = args.Message.Body.ToString();
                        var deliveryCount = args.Message.DeliveryCount;
                        
                        _logger.LogInformation(
                                "处理消息: {MessageId}, 投递次数: {DeliveryCount}", 
                                args.Message.MessageId, 
                                deliveryCount);

                        // 模拟可能失败的业务逻辑
                        await ProcessBusinessLogicAsync(body);

                        // 处理成功,完成消息
                        await args.CompleteMessageAsync(args.Message);
                        
                        _logger.LogInformation("消息处理成功: {MessageId}", args.Message.MessageId);
                }
                catch (Exception ex)
                {
                        _logger.LogError(ex, "处理消息失败: {MessageId}", args.Message.MessageId);
                        
                        // 放弃消息,Service Bus 会自动重新投递
                        // 当投递次数达到 MaxDeliveryCount 时,消息会进入死信队列
                        await args.AbandonMessageAsync(args.Message);
                }
        }

        private async Task ProcessBusinessLogicAsync(string messageBody)
        {
                // 实现具体的业务逻辑
                await Task.CompletedTask;
        }

        private Task ProcessErrorAsync(ProcessErrorEventArgs args)
        {
                _logger.LogError(args.Exception, "Service Bus 处理错误: {ErrorSource}", args.ErrorSource);
                return Task.CompletedTask;
        }
}

在这个代码中,我们通过 args.Message.DeliveryCount 属性获取消息的投递次数,这个值会在每次重新投递时递增。当处理失败时,调用 AbandonMessageAsync 方法放弃消息,Service Bus 会自动将消息重新放回队列。消息会持续重试,直到投递次数达到队列配置的 MaxDeliveryCount(默认为 10 次),此时消息会自动进入死信队列。

我们进行更精细的配置,例如可以在队列级别配置 MaxDeliveryCount

csharp 复制代码
// 在 AppHost 中配置队列属性(需要使用 Azure.Messaging.ServiceBus.Administration)
var queueOptions = new CreateQueueOptions("orders")
{
        MaxDeliveryCount = 5,  // 最多投递 5 次
        LockDuration = TimeSpan.FromMinutes(5),  // 消息锁定时间
        DefaultMessageTimeToLive = TimeSpan.FromDays(7)  // 消息生存时间
};

通过设置 MaxDeliveryCount 为 5,消息在处理失败后会重试最多 5 次,如果仍然失败,则会被自动移动到死信队列中。死信队列可以用于后续的错误分析和处理,确保消息不会无限重试导致系统资源耗尽。

5.2.2 RabbitMQ 消息处理重试

RabbitMQ 本身不提供自动的消息级别重试机制,需要通过应用层逻辑或结合死信交换机(DLX)来实现。最简单的方式是在消息处理失败时使用 BasicNackBasicReject 拒绝消息,并设置 requeue 参数为 true 让 RabbitMQ 重新投递:

csharp 复制代码
public class RabbitMQMessageConsumer : BackgroundService
{
        private readonly IConnection _connection;
        private readonly ILogger<RabbitMQMessageConsumer> _logger;

        protected override Task ExecuteAsync(CancellationToken stoppingToken)
        {
                var channel = _connection.CreateModel();
                
                channel.QueueDeclare(
                        queue: "orders",
                        durable: true,
                        exclusive: false,
                        autoDelete: false);

                channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false);

                var consumer = new EventingBasicConsumer(channel);
                consumer.Received += (model, ea) =>
                {
                        try
                        {
                                var body = ea.Body.ToArray();
                                var message = Encoding.UTF8.GetString(body);
                                
                                _logger.LogInformation("接收到消息: {Message}", message);

                                // 处理消息
                                ProcessMessage(message);

                                // 确认消息
                                channel.BasicAck(deliveryTag: ea.DeliveryTag, multiple: false);
                        }
                        catch (Exception ex)
                        {
                                _logger.LogError(ex, "处理消息失败");
                                
                                // 拒绝消息并重新入队
                                // requeue: true 表示重新投递,false 表示丢弃
                                channel.BasicNack(deliveryTag: ea.DeliveryTag, multiple: false, requeue: true);
                        }
                };

                channel.BasicConsume(queue: "orders", autoAck: false, consumer: consumer);

                return Task.CompletedTask;
        }

        private void ProcessMessage(string message)
        {
                // 实现具体的消息处理逻辑
        }
}

然而,简单的 requeue 机制存在问题:如果消息处理持续失败,会导致消息无限循环重试,阻塞队列。更好的做法是结合延迟队列和死信交换机实现有限次数的重试:

csharp 复制代码
public void SetupRetryQueue(IModel channel)
{
        // 主队列配置
        var mainQueueArgs = new Dictionary<string, object>
        {
                { "x-dead-letter-exchange", "retry-exchange" },  // 死信交换机
                { "x-dead-letter-routing-key", "retry" }  // 死信路由键
        };
        channel.QueueDeclare("orders", durable: true, exclusive: false, autoDelete: false, arguments: mainQueueArgs);

        // 重试交换机
        channel.ExchangeDeclare("retry-exchange", ExchangeType.Direct, durable: true);

        // 重试队列(带延迟)
        var retryQueueArgs = new Dictionary<string, object>
        {
                { "x-message-ttl", 5000 },  // 消息在重试队列中存活 5 秒
                { "x-dead-letter-exchange", "" },  // 过期后回到默认交换机
                { "x-dead-letter-routing-key", "orders" }  // 路由回主队列
        };
        channel.QueueDeclare("orders-retry", durable: true, exclusive: false, autoDelete: false, arguments: retryQueueArgs);

        // 绑定重试队列到重试交换机
        channel.QueueBind("orders-retry", "retry-exchange", "retry");
}

这种配置实现了延迟重试机制:当消息处理失败时,使用 BasicReject 拒绝消息并设置 requeue: false,消息会进入重试队列。在重试队列中,消息会等待 5 秒(TTL),然后自动过期并通过死信机制重新路由回主队列,实现延迟重试。

5.2.3 使用 Polly 实现高级重试策略

对于更复杂的重试需求,可以结合 Polly 库实现更灵活的重试策略,例如指数退避、断路器模式等。首先安装 Polly 包:

bash 复制代码
dotnet add package Polly

然后在消息处理中使用 Polly 策略:

csharp 复制代码
using Polly;
using Polly.Retry;

public class ResilientMessageProcessor : BackgroundService
{
        private readonly IConnection _connection;
        private readonly ILogger<ResilientMessageProcessor> _logger;
        private readonly AsyncRetryPolicy _retryPolicy;

        public ResilientMessageProcessor(IConnection connection, ILogger<ResilientMessageProcessor> logger)
        {
                _connection = connection;
                _logger = logger;

                // 配置重试策略:最多重试 3 次,每次延迟递增
                _retryPolicy = Policy
                        .Handle<Exception>()
                        .WaitAndRetryAsync(
                                retryCount: 3,
                                sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
                                onRetry: (exception, timeSpan, retryCount, context) =>
                                {
                                        _logger.LogWarning(
                                                "重试第 {RetryCount} 次,延迟 {Delay} 秒。异常: {Exception}",
                                                retryCount,
                                                timeSpan.TotalSeconds,
                                                exception.Message);
                                });
        }

        protected override Task ExecuteAsync(CancellationToken stoppingToken)
        {
                var channel = _connection.CreateModel();
                channel.QueueDeclare("orders", durable: true, exclusive: false, autoDelete: false);
                channel.BasicQos(0, 1, false);

                var consumer = new EventingBasicConsumer(channel);
                consumer.Received += async (model, ea) =>
                {
                        try
                        {
                                var body = ea.Body.ToArray();
                                var message = Encoding.UTF8.GetString(body);

                                // 使用 Polly 重试策略处理消息
                                await _retryPolicy.ExecuteAsync(async () =>
                                {
                                        await ProcessMessageAsync(message);
                                });

                                // 处理成功,确认消息
                                channel.BasicAck(ea.DeliveryTag, false);
                        }
                        catch (Exception ex)
                        {
                                _logger.LogError(ex, "消息处理最终失败,已达到最大重试次数");
                                
                                // 拒绝消息,不重新入队(可以配置死信队列处理)
                                channel.BasicReject(ea.DeliveryTag, requeue: false);
                        }
                };

                channel.BasicConsume("orders", autoAck: false, consumer: consumer);

                return Task.CompletedTask;
        }

        private async Task ProcessMessageAsync(string message)
        {
                // 实现可能失败的业务逻辑
                _logger.LogInformation("处理消息: {Message}", message);
                await Task.CompletedTask;
        }
}

在代码中,我们使用 Polly 的 WaitAndRetryAsync 策略配置了指数退避重试。retryCount: 3 表示最多重试 3 次,sleepDurationProvider 使用指数函数计算延迟时间,第一次重试延迟 2 秒,第二次 4 秒,第三次 8 秒。onRetry 回调函数在每次重试前执行,用于记录日志。如果所有重试都失败,最终会在外层 catch 块中捕获异常,并使用 BasicReject 拒绝消息。

通过结合消息队列的原生重试机制和 Polly 等高级库,可以构建出既灵活又可靠的消息处理系统,有效应对各种故障场景。

六、死信队列处理

当消息处理多次重试失败后,为了避免故障消息阻塞队列、影响正常消息的处理,通常需要将这些问题消息移动到死信队列(Dead Letter Queue,DLQ)。死信队列是一个特殊的队列,用于存储无法成功处理的消息,便于后续分析、修复和重新处理。

6.1 Azure Service Bus 死信队列

Azure Service Bus 原生支持死信队列机制,每个队列和订阅都有一个关联的死信队列。当满足以下条件之一时,消息会自动进入死信队列:

  1. 超过最大投递次数 : 消息的投递次数(DeliveryCount)超过队列配置的 MaxDeliveryCount(默认为 10 次)
  2. 消息过期 : 消息的 TimeToLive 到期且队列启用了 EnableDeadLetteringOnMessageExpiration
  3. 手动死信化 : 应用程序显式调用 DeadLetterMessageAsync 方法
6.1.1 自动死信化

当消息处理持续失败,Service Bus 会在达到最大投递次数后自动将消息移动到死信队列。在处理消息时,可以通过 DeliveryCount 属性获取投递次数,以便在接近限制时采取特殊措施:

csharp 复制代码
private async Task ProcessMessageAsync(ProcessMessageEventArgs args)
{
    try
    {
        var body = args.Message.Body.ToString();
        var deliveryCount = args.Message.DeliveryCount;
        
        _logger.LogInformation(
            "处理消息: {MessageId}, 投递次数: {DeliveryCount}/{MaxDeliveryCount}", 
            args.Message.MessageId, 
            deliveryCount,
            10);  // MaxDeliveryCount 默认为 10

        // 如果接近最大重试次数,可以采取特殊措施
        if (deliveryCount >= 8)
        {
            _logger.LogWarning(
                "消息 {MessageId} 即将达到最大投递次数,进行特殊处理",
                args.Message.MessageId);
            
            // 可以尝试降级处理或记录详细错误信息
            await TryAlternativeProcessingAsync(body);
        }
        else
        {
            await ProcessBusinessLogicAsync(body);
        }

        await args.CompleteMessageAsync(args.Message);
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "处理消息失败: {MessageId}", args.Message.MessageId);
        
        // 放弃消息,让 Service Bus 重新投递
        // 当达到 MaxDeliveryCount 时自动进入死信队列
        await args.AbandonMessageAsync(args.Message);
    }
}

在接近最大重试次数时,可以采取一些措施,例如尝试降级处理、记录详细的错误信息或通知系统管理员。如果消息仍然无法处理,最终会进入死信队列。

6.1.2 手动死信化

除了自动死信化,还可以在代码中根据业务逻辑显式地将消息移动到死信队列。这种方式适用于检测到消息格式错误、业务规则验证失败等明确无法处理的情况:

csharp 复制代码
private async Task ProcessMessageAsync(ProcessMessageEventArgs args)
{
    try
    {
        var body = args.Message.Body.ToString();
        
        // 尝试反序列化消息
        OrderMessage order;
        try
        {
            order = JsonSerializer.Deserialize<OrderMessage>(body);
        }
        catch (JsonException ex)
        {
            _logger.LogError(ex, "消息格式错误,无法反序列化: {MessageId}", args.Message.MessageId);
            
            // 消息格式错误,无法处理,直接死信化
            await args.DeadLetterMessageAsync(
                args.Message,
                deadLetterReason: "InvalidMessageFormat",
                deadLetterErrorDescription: $"JSON 反序列化失败: {ex.Message}");
            return;
        }

        // 业务规则验证
        if (order.Amount <= 0)
        {
            _logger.LogWarning("订单金额无效: {MessageId}, Amount: {Amount}", 
                args.Message.MessageId, order.Amount);
            
            // 业务规则验证失败,死信化并记录原因
            await args.DeadLetterMessageAsync(
                args.Message,
                deadLetterReason: "InvalidBusinessData",
                deadLetterErrorDescription: $"订单金额必须大于 0,实际值: {order.Amount}");
            return;
        }

        // 正常处理消息
        await ProcessOrderAsync(order);
        await args.CompleteMessageAsync(args.Message);
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "处理消息时发生未预期的错误: {MessageId}", args.Message.MessageId);
        
        // 未预期的错误,放弃消息让其重试
        await args.AbandonMessageAsync(args.Message);
    }
}

在手动死信化时,可以提供 deadLetterReasondeadLetterErrorDescription 参数,这些信息会随消息一起存储在死信队列中,便于后续分析和处理。deadLetterReason 应该是简短的分类标识,如 "InvalidMessageFormat"、"BusinessRuleViolation" 等,而 deadLetterErrorDescription 可以包含更详细的错误信息。

6.1.3 处理死信队列中的消息

死信队列中的消息需要被定期检查和处理。可以创建专门的死信队列处理器来分析和修复这些消息:

csharp 复制代码
public class DeadLetterQueueProcessor : BackgroundService
{
    private readonly ServiceBusClient _client;
    private readonly ILogger<DeadLetterQueueProcessor> _logger;
    private ServiceBusProcessor _processor;

    public DeadLetterQueueProcessor(
        ServiceBusClient client,
        ILogger<DeadLetterQueueProcessor> logger)
    {
        _client = client;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // 创建死信队列处理器,使用 SubQueue.DeadLetter 指定死信队列
        _processor = _client.CreateProcessor(
            queueName: "orders",
            new ServiceBusProcessorOptions
            {
                SubQueue = SubQueue.DeadLetter,  // 处理死信队列
                MaxConcurrentCalls = 1,  // 串行处理以便仔细分析
                AutoCompleteMessages = false
            });

        _processor.ProcessMessageAsync += ProcessDeadLetterMessageAsync;
        _processor.ProcessErrorAsync += ProcessErrorAsync;

        await _processor.StartProcessingAsync(stoppingToken);
        
        _logger.LogInformation("死信队列处理器已启动,队列: orders/$DeadLetterQueue");
        
        await Task.Delay(Timeout.Infinite, stoppingToken);
    }

    private async Task ProcessDeadLetterMessageAsync(ProcessMessageEventArgs args)
    {
        var message = args.Message;
        var body = message.Body.ToString();
        
        // 获取死信化原因
        var deadLetterReason = message.DeadLetterReason ?? "Unknown";
        var deadLetterDescription = message.DeadLetterErrorDescription ?? "No description";
        var deliveryCount = message.DeliveryCount;

        _logger.LogWarning(
            "处理死信消息: {MessageId}, 原因: {Reason}, 描述: {Description}, 投递次数: {DeliveryCount}",
            message.MessageId,
            deadLetterReason,
            deadLetterDescription,
            deliveryCount);

        try
        {
            // 根据死信原因采取不同的处理策略
            switch (deadLetterReason)
            {
                case "InvalidMessageFormat":
                    // 尝试修复消息格式并重新发送
                    await TryFixAndRequeueAsync(body, message);
                    break;

                case "InvalidBusinessData":
                    // 记录到数据库供人工审核
                    await LogForManualReviewAsync(message, body, deadLetterDescription);
                    break;

                case "MaxDeliveryCountExceeded":
                    // 分析为什么处理失败
                    await AnalyzeProcessingFailureAsync(message, body);
                    break;

                default:
                    // 未知原因,记录详细信息
                    _logger.LogError(
                        "未知的死信原因: {Reason}, MessageId: {MessageId}, Body: {Body}",
                        deadLetterReason,
                        message.MessageId,
                        body);
                    await LogUnknownDeadLetterAsync(message, body);
                    break;
            }

            // 完成死信消息(从死信队列中移除)
            await args.CompleteMessageAsync(message);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "处理死信消息失败: {MessageId}", message.MessageId);
            
            // 放弃死信消息,让它留在死信队列中
            await args.AbandonMessageAsync(message);
        }
    }

    private async Task TryFixAndRequeueAsync(string body, ServiceBusReceivedMessage originalMessage)
    {
        // 实现消息修复逻辑,例如补全缺失字段
        _logger.LogInformation("尝试修复并重新发送消息: {MessageId}", originalMessage.MessageId);
        
        // 如果修复成功,重新发送到原队列
        var sender = _client.CreateSender("orders");
        var newMessage = new ServiceBusMessage(body)
        {
            MessageId = Guid.NewGuid().ToString(),
            CorrelationId = originalMessage.MessageId  // 保留关联 ID
        };
        await sender.SendMessageAsync(newMessage);
        
        _logger.LogInformation("消息已修复并重新发送: {OriginalMessageId} -> {NewMessageId}",
            originalMessage.MessageId, newMessage.MessageId);
    }

    private async Task LogForManualReviewAsync(
        ServiceBusReceivedMessage message,
        string body,
        string description)
    {
        // 将消息保存到数据库供人工审核
        _logger.LogWarning(
            "消息需要人工审核: {MessageId}, 原因: {Description}",
            message.MessageId,
            description);
        
        // 实现数据库保存逻辑
        await Task.CompletedTask;
    }

    private async Task AnalyzeProcessingFailureAsync(
        ServiceBusReceivedMessage message,
        string body)
    {
        // 分析消息为什么多次处理失败
        _logger.LogError(
            "消息超过最大投递次数: {MessageId}, 投递次数: {DeliveryCount}",
            message.MessageId,
            message.DeliveryCount);
        
        // 可以记录到监控系统或发送告警
        await Task.CompletedTask;
    }

    private async Task LogUnknownDeadLetterAsync(
        ServiceBusReceivedMessage message,
        string body)
    {
        // 记录未知类型的死信消息
        _logger.LogError("未知的死信消息: {MessageId}, Body: {Body}", message.MessageId, body);
        await Task.CompletedTask;
    }

    private Task ProcessErrorAsync(ProcessErrorEventArgs args)
    {
        _logger.LogError(args.Exception, "死信队列处理器错误: {ErrorSource}", args.ErrorSource);
        return Task.CompletedTask;
    }

    public override async Task StopAsync(CancellationToken cancellationToken)
    {
        if (_processor != null)
        {
            await _processor.StopProcessingAsync(cancellationToken);
            await _processor.DisposeAsync();
        }
        await base.StopAsync(cancellationToken);
    }
}

在死信队列处理器中,我们通过 SubQueue.DeadLetter 参数指定处理死信队列。处理器会根据 DeadLetterReason 采取不同的处理策略:对于格式错误的消息,尝试修复后重新发送;对于业务规则验证失败的消息,记录到数据库供人工审核;对于超过最大投递次数的消息,进行详细分析以找出根本原因。处理完成后调用 CompleteMessageAsync 将消息从死信队列中移除,如果处理失败则调用 AbandonMessageAsync 让消息保留在死信队列中。

6.2 RabbitMQ 死信队列

与 Azure Service Bus 不同,RabbitMQ 没有内置的死信队列概念,但可以通过 Dead Letter Exchange (DLX) 机制实现类似的功能。DLX 是一个普通的交换机,当消息被拒绝、过期或队列达到长度限制时,消息会被路由到 DLX,进而到达死信队列。

6.2.1 配置死信交换机

要实现死信队列,需要在声明主队列时指定 x-dead-letter-exchange 参数,并可选地指定 x-dead-letter-routing-key

csharp 复制代码
public class RabbitMQDeadLetterConfiguration
{
    private readonly IConnection _connection;
    private readonly ILogger<RabbitMQDeadLetterConfiguration> _logger;

    public RabbitMQDeadLetterConfiguration(
        IConnection connection,
        ILogger<RabbitMQDeadLetterConfiguration> logger)
    {
        _connection = connection;
        _logger = logger;
    }

    public void ConfigureDeadLetterQueues()
    {
        using var channel = _connection.CreateModel();

        // 1. 声明死信交换机
        channel.ExchangeDeclare(
            exchange: "orders-dlx",
            type: ExchangeType.Direct,
            durable: true,
            autoDelete: false);

        // 2. 声明死信队列
        channel.QueueDeclare(
            queue: "orders-dead-letter",
            durable: true,
            exclusive: false,
            autoDelete: false,
            arguments: null);

        // 3. 将死信队列绑定到死信交换机
        channel.QueueBind(
            queue: "orders-dead-letter",
            exchange: "orders-dlx",
            routingKey: "dead-letter");

        // 4. 声明主队列,配置死信交换机
        var mainQueueArgs = new Dictionary<string, object>
        {
            { "x-dead-letter-exchange", "orders-dlx" },  // 指定死信交换机
            { "x-dead-letter-routing-key", "dead-letter" },  // 死信路由键
            { "x-message-ttl", 300000 },  // 可选:消息 TTL(5 分钟)
            { "x-max-length", 10000 }  // 可选:队列最大长度
        };

        channel.QueueDeclare(
            queue: "orders",
            durable: true,
            exclusive: false,
            autoDelete: false,
            arguments: mainQueueArgs);

        _logger.LogInformation("RabbitMQ 死信队列配置完成");
    }
}

在这个配置中,我们首先声明了死信交换机 orders-dlx 和死信队列 orders-dead-letter,并将它们绑定在一起。然后在声明主队列 orders 时,通过 x-dead-letter-exchange 参数指定了死信交换机,通过 x-dead-letter-routing-key 指定了路由键。当消息被拒绝、过期或队列满时,消息会自动路由到死信交换机,进而进入死信队列。

6.2.2 触发死信化的场景

RabbitMQ 中有三种情况会导致消息进入死信队列:

  1. 消息被拒绝 : 使用 BasicRejectBasicNack 拒绝消息,且 requeue 参数为 false
  2. 消息过期: 消息的 TTL 到期(可以在队列级别或消息级别设置)
  3. 队列长度超限: 队列达到最大长度限制,最早的消息会被死信化

下面是一个示例,展示如何在处理失败时将消息拒绝并发送到死信队列:

csharp 复制代码
public class RabbitMQMessageConsumerWithDLQ : BackgroundService
{
    private readonly IConnection _connection;
    private readonly ILogger<RabbitMQMessageConsumerWithDLQ> _logger;
    private const int MaxRetryCount = 3;

    public RabbitMQMessageConsumerWithDLQ(
        IConnection connection,
        ILogger<RabbitMQMessageConsumerWithDLQ> logger)
    {
        _connection = connection;
        _logger = logger;
    }

    protected override Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var channel = _connection.CreateModel();
        channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false);

        var consumer = new EventingBasicConsumer(channel);
        consumer.Received += (model, ea) =>
        {
            var body = ea.Body.ToArray();
            var message = Encoding.UTF8.GetString(body);
            
            // 从消息头中获取重试次数
            var retryCount = 0;
            if (ea.BasicProperties.Headers != null &&
                ea.BasicProperties.Headers.ContainsKey("x-retry-count"))
            {
                retryCount = Convert.ToInt32(ea.BasicProperties.Headers["x-retry-count"]);
            }

            _logger.LogInformation(
                "接收到消息: {Message}, 重试次数: {RetryCount}/{MaxRetryCount}",
                message,
                retryCount,
                MaxRetryCount);

            try
            {
                // 处理消息
                ProcessMessage(message);

                // 处理成功,确认消息
                channel.BasicAck(deliveryTag: ea.DeliveryTag, multiple: false);
                _logger.LogInformation("消息处理成功: {Message}", message);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "处理消息失败: {Message}, 重试次数: {RetryCount}", 
                    message, retryCount);

                if (retryCount < MaxRetryCount)
                {
                    // 还未达到最大重试次数,重新入队并增加重试计数
                    var properties = channel.CreateBasicProperties();
                    properties.Headers = new Dictionary<string, object>
                    {
                        { "x-retry-count", retryCount + 1 }
                    };
                    properties.Persistent = true;

                    // 发布回队列(带更新的重试计数)
                    channel.BasicPublish(
                        exchange: "",
                        routingKey: "orders",
                        basicProperties: properties,
                        body: body);

                    // 确认原消息(从队列中移除)
                    channel.BasicAck(deliveryTag: ea.DeliveryTag, multiple: false);
                    
                    _logger.LogInformation("消息重新入队,重试次数: {RetryCount}", retryCount + 1);
                }
                else
                {
                    // 达到最大重试次数,拒绝消息并发送到死信队列
                    _logger.LogError(
                        "消息达到最大重试次数,发送到死信队列: {Message}",
                        message);
                    
                    // requeue: false 表示不重新入队,消息会进入死信队列
                    channel.BasicReject(deliveryTag: ea.DeliveryTag, requeue: false);
                }
            }
        };

        channel.BasicConsume(queue: "orders", autoAck: false, consumer: consumer);

        return Task.CompletedTask;
    }

    private void ProcessMessage(string message)
    {
        // 实现具体的消息处理逻辑
        // 可能抛出异常
    }
}

在这个实现中,我们在消息头中维护了一个 x-retry-count 字段来跟踪重试次数。每次处理失败时,如果重试次数未达到上限,就将消息重新发布到队列并增加重试计数。如果达到上限,则使用 BasicReject 拒绝消息且 requeue 设为 false,这样消息就会进入死信队列。

6.2.3 处理死信队列中的消息

与 Azure Service Bus 类似,需要创建专门的处理器来处理死信队列中的消息:

csharp 复制代码
public class RabbitMQDeadLetterProcessor : BackgroundService
{
    private readonly IConnection _connection;
    private readonly ILogger<RabbitMQDeadLetterProcessor> _logger;

    public RabbitMQDeadLetterProcessor(
        IConnection connection,
        ILogger<RabbitMQDeadLetterProcessor> logger)
    {
        _connection = connection;
        _logger = logger;
    }

    protected override Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var channel = _connection.CreateModel();
        channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false);

        var consumer = new EventingBasicConsumer(channel);
        consumer.Received += (model, ea) =>
        {
            var body = ea.Body.ToArray();
            var message = Encoding.UTF8.GetString(body);
            
            // 获取原始队列信息(从消息头中)
            var originalQueue = "unknown";
            if (ea.BasicProperties.Headers != null &&
                ea.BasicProperties.Headers.ContainsKey("x-first-death-queue"))
            {
                originalQueue = Encoding.UTF8.GetString(
                    (byte[])ea.BasicProperties.Headers["x-first-death-queue"]);
            }

            _logger.LogWarning(
                "处理死信消息: {Message}, 原始队列: {OriginalQueue}",
                message,
                originalQueue);

            try
            {
                // 分析死信原因并采取相应措施
                AnalyzeAndHandleDeadLetter(message, ea.BasicProperties);

                // 完成处理,确认消息
                channel.BasicAck(deliveryTag: ea.DeliveryTag, multiple: false);
                
                _logger.LogInformation("死信消息处理完成: {Message}", message);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "处理死信消息失败: {Message}", message);
                
                // 拒绝消息,让它留在死信队列中
                channel.BasicReject(deliveryTag: ea.DeliveryTag, requeue: false);
            }
        };

        channel.BasicConsume(queue: "orders-dead-letter", autoAck: false, consumer: consumer);

        _logger.LogInformation("死信队列处理器已启动,队列: orders-dead-letter");

        return Task.CompletedTask;
    }

    private void AnalyzeAndHandleDeadLetter(string message, IBasicProperties properties)
    {
        // 从消息头中获取死信原因
        if (properties.Headers != null)
        {
            if (properties.Headers.ContainsKey("x-first-death-reason"))
            {
                var reason = Encoding.UTF8.GetString(
                    (byte[])properties.Headers["x-first-death-reason"]);
                
                _logger.LogWarning("死信原因: {Reason}", reason);

                switch (reason)
                {
                    case "rejected":
                        _logger.LogWarning("消息被拒绝(超过重试次数)");
                        // 记录到数据库供人工处理
                        break;

                    case "expired":
                        _logger.LogWarning("消息过期");
                        // 根据业务需求决定是否需要处理
                        break;

                    case "maxlen":
                        _logger.LogWarning("队列长度超限");
                        // 可能需要扩展队列或增加消费者
                        break;
                }
            }
        }

        // 实现具体的死信处理逻辑,例如:
        // - 将消息保存到数据库供人工审核
        // - 发送告警通知
        // - 尝试修复并重新发送
        // - 记录到日志系统
    }
}

在死信队列处理器中,RabbitMQ 会在消息头中自动添加一些有用的信息,如 x-first-death-reason(死信原因)、x-first-death-queue(原始队列名)、x-first-death-exchange(原始交换机)等,这些信息有助于我们分析和处理死信消息。

6.3 死信队列最佳实践

无论使用哪种消息队列,处理死信队列时都应遵循以下最佳实践。首先要建立完善的监控和告警机制,当死信队列中的消息数量超过阈值时及时通知相关人员。其次在死信化消息时,应尽可能保留详细的错误信息、重试次数、时间戳等,便于后续分析。第三要定期审查死信队列中的消息,分析共同的失败模式,从根本上解决问题。第四应根据死信原因进行分类处理,对于格式错误可以尝试自动修复,对于业务规则问题需要人工介入。第五当某类死信消息频繁出现时,应设置告警以便快速响应。第六要为死信队列设置合理的大小限制,避免占用过多存储空间。最后对于长期无法处理的死信消息,可以考虑归档到冷存储或删除,避免无限期堆积。

通过合理配置和处理死信队列,可以确保系统在面对各种异常情况时仍能保持稳定运行,不会因为个别问题消息而影响整体的消息处理流程。死信队列不仅是错误处理的最后一道防线,也是系统问题诊断和改进的重要数据来源。

七、总结

.NET Aspire 极大地简化了 RabbitMQ 和 Azure Service Bus 的集成过程。通过标准化的 Hosting 和 Client 库,开发者可以轻松地在应用中添加消息队列功能。无论是本地开发还是云端部署,Aspire 都提供了一致的体验。掌握发布订阅模式、配置管理、重试机制和死信队列处理,是构建高可靠分布式系统的关键。

相关推荐
残花月伴2 小时前
天机学堂-day4(高并发优化方案)
java·spring boot·后端
tonydf3 小时前
在Blazor项目里构造一个覆盖面广泛的权限组件
后端
阿杰AJie3 小时前
Docker 常用镜像启动参数对照表
后端
码上研社3 小时前
Maven配置阿里云镜像
java·后端
资源站shanxueit或com3 小时前
基于C#的通信过程与协议实操需要
后端
一 乐3 小时前
办公系统|基于springboot + vueOA办公管理系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端·spring
Tony Bai3 小时前
Go 1.26 新特性前瞻:从 Green Tea GC 到语法糖 new(expr),性能与体验的双重进化
开发语言·后端·golang
资源站shanxueit或com3 小时前
Python入门教程:从零到实战的保姆级指南(避坑大全) 原创
后端