消息总线接口解耦

消息总线接口解耦:IMessageBus 设计与实现

基于项目 RabbitMQ 链路重构实践


一、为什么要解耦消息总线

1.1 问题场景

在项目中,RabbitMQ 链路的 Publisher 和 Consumer 直接依赖 RabbitMQ.Client 库:

// ❌ 耦合写法:业务代码直接依赖 RabbitMQ.Client
using RabbitMQ.Client;

public class RabbitMqTelemetryPublisher
{
    private IConnection? _connection;
    private IChannel? _channel;

    public async Task PublishAsync(PlayloadData data)
    {
        ConnectionFactory factory = new ConnectionFactory
        {
            HostName = "localhost",
            Port = 5672,
            UserName = "guest",
            Password = "guest"
        };
        _connection = await factory.CreateConnectionAsync();
        _channel = await _connection.CreateChannelAsync();
        await _channel.QueueDeclareAsync("telemetry.input", durable: true);
        await _channel.BasicPublishAsync("", "telemetry.input", body: body);
    }
}

问题

  • 业务逻辑与 RabbitMQ.Client API 绑死
  • 换 Kafka/EventHub 需要重写整个 Publisher/Consumer
  • 单元测试时无法 Mock 消息总线
  • 连接管理、重试、序列化等横切关注点散落在业务代码中

1.2 解耦目标

之前: Publisher/Consumer ──→ RabbitMQ.Client (强耦合)
之后: Publisher/Consumer ──→ IMessageBus ──→ RabbitMqMessageBus ──→ RabbitMQ.Client
                                    ↑
                              将来可替换为 KafkaMessageBus / EventHubMessageBus

二、接口设计:IMessageBus

2.1 核心原则

原则 说明
接口隔离 只暴露业务需要的方法,不泄露传输层细节
单一职责 只管消息收发,不管业务处理
可测试性 方法签名简单,易于 Mock
传输无关 不出现 RabbitMQ/Kafka 等具体类型

2.2 接口定义

namespace Admin.NET.Core.TimeSeriesStorage.RabbitMq;

/// <summary>
/// 消息总线抽象(解耦具体 MQ 实现)
/// </summary>
public interface IMessageBus : IDisposable
{
    /// <summary>
    /// 发布消息到指定队列
    /// </summary>
    /// <param name="queue">队列名</param>
    /// <param name="body">消息体(序列化后的字节数组)</param>
    /// <param name="persistent">是否持久化(Broker 重启后保留)</param>
    Task PublishAsync(string queue, byte[] body, bool persistent = true);

    /// <summary>
    /// 订阅指定队列,每收到一条消息触发回调
    /// </summary>
    /// <param name="queue">队列名</param>
    /// <param name="onMessage">回调:(deliveryTag, body) => Task</param>
    /// <param name="prefetchCount">预取数量(0=不限)</param>
    Task SubscribeAsync(string queue, Func<ulong, ReadOnlyMemory<byte>, Task> onMessage, ushort prefetchCount = 0);

    /// <summary>
    /// 确认消息(手动 ACK)
    /// </summary>
    Task AckAsync(ulong deliveryTag);

    /// <summary>
    /// 拒绝消息(手动 NACK/Reject)
    /// </summary>
    /// <param name="requeue">是否重新入队</param>
    Task RejectAsync(ulong deliveryTag, bool requeue = false);
}

2.3 设计决策解析

为什么用 byte[] 而不是 stringobject

  • MQ 传输层天然是字节流
  • 避免接口内部做序列化(序列化策略由调用方决定)
  • BasicPublishAsync 底层也是 byte[]

为什么用 Func<ulong, ReadOnlyMemory<byte>, Task> 而不是自定义事件参数?

  • ulong deliveryTag 是 AMQP 标准概念,几乎所有 MQ 都有
  • ReadOnlyMemory<byte> 零拷贝,性能好
  • Task 支持异步回调,不阻塞消费线程

为什么 SubscribeAsync 不返回 IDisposable

  • 订阅生命周期由 IHostedService 管理(StartAsync/StopAsync)
  • 避免回调地狱式的资源释放

三、RabbitMQ 实现:RabbitMqMessageBus

3.1 完整实现

using Microsoft.Extensions.Logging;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;

namespace Admin.NET.Core.TimeSeriesStorage.RabbitMq;

public class RabbitMqMessageBus : IMessageBus
{
    private readonly ILogger<RabbitMqMessageBus> _logger;
    private readonly RabbitMqTelemetryOptions _options;
    private readonly SemaphoreSlim _publishGate = new(1, 1);

    private IConnection? _connection;
    private IChannel? _publishChannel;
    private IChannel? _consumeChannel;

    public RabbitMqMessageBus(ILogger<RabbitMqMessageBus> logger)
    {
        _logger = logger;
        _options = App.GetConfig<RabbitMqTelemetryOptions>("RabbitMqTelemetry")
                   ?? new RabbitMqTelemetryOptions();
    }

    // ─── 发布 ───────────────────────────────────────────────

    public async Task PublishAsync(string queue, byte[] body, bool persistent = true)
    {
        IChannel channel = await EnsurePublishChannelAsync(queue);
        var props = new BasicProperties { Persistent = persistent };
        await channel.BasicPublishAsync("", queue, false, props, body, default);
    }

    // ─── 订阅 ───────────────────────────────────────────────

    public async Task SubscribeAsync(string queue,
        Func<ulong, ReadOnlyMemory<byte>, Task> onMessage, ushort prefetchCount = 0)
    {
        _consumeChannel = await EnsureConsumeChannelAsync(queue, prefetchCount);

        var consumer = new AsyncEventingBasicConsumer(_consumeChannel);
        consumer.ReceivedAsync += async (_, args) =>
        {
            try
            {
                await onMessage(args.DeliveryTag, args.Body);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "消息处理回调异常: {0}", ex.Message);
            }
        };

        await _consumeChannel.BasicConsumeAsync(
            queue, autoAck: false, consumerTag: "", noLocal: false,
            exclusive: false, arguments: null, consumer: consumer,
            cancellationToken: default);
    }

    // ─── ACK / Reject ───────────────────────────────────────

    public async Task AckAsync(ulong deliveryTag)
    {
        IChannel? ch = _consumeChannel;
        if (ch != null && ch.IsOpen)
            await ch.BasicAckAsync(deliveryTag, false, default);
    }

    public async Task RejectAsync(ulong deliveryTag, bool requeue = false)
    {
        IChannel? ch = _consumeChannel;
        if (ch != null && ch.IsOpen)
            await ch.BasicRejectAsync(deliveryTag, requeue, default);
    }

    // ─── 内部:连接/通道管理 ────────────────────────────────

    private async Task<IChannel> EnsurePublishChannelAsync(string queue)
    {
        if (_publishChannel != null && _connection?.IsOpen == true && _publishChannel.IsOpen)
            return _publishChannel;

        await EnsureConnectionAsync();
        _publishChannel = await _connection!.CreateChannelAsync(default);
        await _publishChannel.QueueDeclareAsync(queue, durable: true,
            exclusive: false, autoDelete: false, arguments: null);
        return _publishChannel;
    }

    private async Task<IChannel> EnsureConsumeChannelAsync(string queue, ushort prefetchCount)
    {
        if (_consumeChannel != null && _connection?.IsOpen == true && _consumeChannel.IsOpen)
            return _consumeChannel;

        await EnsureConnectionAsync();
        _consumeChannel = await _connection!.CreateChannelAsync(default);
        await _consumeChannel.QueueDeclareAsync(queue, durable: true,
            exclusive: false, autoDelete: false, arguments: null);
        if (prefetchCount > 0)
            await _consumeChannel.BasicQosAsync(0, prefetchCount, false, default);
        return _consumeChannel;
    }

    private async Task EnsureConnectionAsync()
    {
        if (_connection != null && _connection.IsOpen) return;
        ResetConnection();

        var factory = new ConnectionFactory
        {
            HostName = _options.HostName,
            Port = _options.Port,
            UserName = _options.UserName,
            Password = _options.Password,
            VirtualHost = string.IsNullOrWhiteSpace(_options.VirtualHost)
                ? "/" : _options.VirtualHost,
            AutomaticRecoveryEnabled = true
        };
        _connection = await factory.CreateConnectionAsync(default);
    }

    private void ResetConnection()
    {
        try { _publishChannel?.Dispose(); } catch { }
        try { _consumeChannel?.Dispose(); } catch { }
        try { _connection?.Dispose(); } catch { }
        _publishChannel = null;
        _consumeChannel = null;
        _connection = null;
    }

    public void Dispose()
    {
        _publishGate.Dispose();
        ResetConnection();
    }
}

3.2 关键设计点

发布/消费分离的 Channel

private IConnection? _connection;      // 共享一个连接
private IChannel? _publishChannel;     // 发布专用通道
private IChannel? _consumeChannel;     // 消费专用通道

为什么分开?

  • AMQP 的 channel 是轻量级的(复用 TCP 连接)
  • 发布和消费的 QoS 设置不同(消费需要 prefetch,发布不需要)
  • 避免 channel 竞争导致的消息丢失

自动重连

AutomaticRecoveryEnabled = true  // RabbitMQ.Client 内置自动重连

配合 EnsureConnectionAsync 中的 null/open 检查,断线后自动重建连接和通道。

线程安全

private readonly SemaphoreSlim _publishGate = new(1, 1);

public async Task PublishAsync(...)
{
    await _publishGate.WaitAsync();
    try { ... }
    finally { _publishGate.Release(); }
}

SemaphoreSlim 保证同一时刻只有一个发布操作在使用 channel,避免并发写入导致 AMQP 协议错误。


四、业务代码重构

4.1 Publisher 重构

Before(耦合):

using RabbitMQ.Client;

public class RabbitMqTelemetryPublisher : ISingleton, IDisposable
{
    private IConnection? _connection;
    private IChannel? _channel;

    public async Task PublishAsync(PlayloadData data)
    {
        await _gate.WaitAsync();
        try
        {
            IChannel channel = await EnsureChannelAsync();
            byte[] body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(data));
            BasicProperties props = new BasicProperties { Persistent = true };
            await channel.BasicPublishAsync("", _options.InputQueue, false, props, body, default);
        }
        catch (Exception ex) { ResetConnection(); /* ... */ }
        finally { _gate.Release(); }
    }

    private async Task<IChannel> EnsureChannelAsync() { /* 50+ 行连接管理 */ }
    private void ResetConnection() { /* 20+ 行资源释放 */ }
}

After(解耦):

// 移除了 using RabbitMQ.Client

public class RabbitMqTelemetryPublisher : ISingleton, IDisposable
{
    private readonly IMessageBus _messageBus;

    public RabbitMqTelemetryPublisher(ILogger<RabbitMqTelemetryPublisher> logger, IMessageBus messageBus)
    {
        _messageBus = messageBus;
        // ...
    }

    public async Task PublishAsync(PlayloadData data)
    {
        if (!_enable || data == null) return;
        try
        {
            byte[] body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(data));
            await _messageBus.PublishAsync(_options.InputQueue, body, persistent: true);
        }
        catch (Exception ex) { _logger.LogError(ex, "..."); }
    }

    public void Dispose() { }  // 生命周期由 DI 管理
}

对比:

指标 Before After
代码行数 ~90 行 ~35 行
RabbitMQ 依赖 using RabbitMQ.Client
连接管理 手动 50+ 行 零(RabbitMqMessageBus 内部处理)
可测试性 无法 Mock 可 Mock IMessageBus

4.2 Consumer 重构

Before(耦合 + 轮询):

using RabbitMQ.Client;
using RabbitMQ.Client.Events;

public class RabbitMqTelemetryConsumer : IHostedService
{
    private IConnection? _connection;
    private IChannel? _channel;

    private async Task ConsumeLoopAsync()
    {
        while (!_cts.IsCancellationRequested)
        {
            await EnsureChannelAsync();        // 手动连接管理
            await Task.Delay(flushIntervalMs);
            await FlushBatchAsync();
        }
    }

    private async Task EnsureChannelAsync() { /* 60+ 行 */ }
    private void ResetConnection() { /* 20+ 行 */ }

    // FlushBatchAsync 中直接操作 channel
    await ch.BasicAckAsync(tag, default);
    await ch.BasicRejectAsync(tag, false, default);
}

After(解耦 + 回调驱动):

// 移除了 using RabbitMQ.Client / RabbitMQ.Client.Events

public class RabbitMqTelemetryConsumer : IHostedService
{
    private readonly IMessageBus _messageBus;

    public Task StartAsync(CancellationToken ct)
    {
        // 订阅模式:消息到来时触发回调,不再轮询
        _consumeTask = _messageBus.SubscribeAsync(
            _options.StoreQueue,
            OnMessageReceivedAsync,       // 回调
            (ushort)Math.Min(_batchSize, 65535));
        return Task.CompletedTask;
    }

    private async Task OnMessageReceivedAsync(ulong deliveryTag, ReadOnlyMemory<byte> body)
    {
        PlayloadData? data = ParseTelemetry(body);
        if (data == null)
        {
            await _messageBus.RejectAsync(deliveryTag);  // 通过接口操作
            return;
        }
        // ... 批量逻辑
    }

    private async Task FlushBatchAsync()
    {
        // ...
        foreach (var (tag, _) in batch)
        {
            if (success)
                await _messageBus.AckAsync(tag);       // 通过接口操作
            else
                await _messageBus.RejectAsync(tag);
        }
    }
}

对比:

指标 Before After
消费模式 轮询(定时器 + EnsureChannel) 回调驱动(SubscribeAsync)
连接管理 手动 80+ 行
Channel 操作 直接 ch.BasicAckAsync _messageBus.AckAsync
代码行数 ~180 行 ~120 行

五、DI 注册

5.1 注册代码

// Startup.cs
using Admin.NET.Core.TimeSeriesStorage.RabbitMq;

services.AddSingleton<IMessageBus, RabbitMqMessageBus>();
services.AddHostedService<RabbitMqTelemetryConsumer>();

5.2 为什么是 Singleton?

生命周期 原因
IMessageBus → Singleton 共享一个 TCP 连接 + 两个 channel(publish/consume)
RabbitMqTelemetryPublisher → Singleton ISingleton(Furion 自动注册)
RabbitMqTelemetryConsumer → HostedService IHostedService 框架管理

如果注册为 Scoped/Transient 会怎样?

  • 每次请求创建新连接 → RabbitMQ 连接数爆炸
  • Channel 竞争 → AMQP 协议错误
  • 内存泄漏 → IDisposable 不会被正确调用

六、RabbitMQ 核心概念回顾

6.1 Connection vs Channel

Application
    │
    ▼
┌──────────┐
│ Connection │ ← TCP 连接(重量级,创建成本高)
└──────────┘
    │
    ├──▶ Channel 1 (Publish)  ← 轻量级,AMQP 复用
    ├──▶ Channel 2 (Consume)
    └──▶ Channel 3 (Admin)
  • Connection = TCP 连接,创建/销毁成本高
  • Channel = 虚拟连接,轻量级,可大量创建
  • 一个 Connection 可承载数千个 Channel
  • Channel 之间互不影响(独立 QoS、独立 ACK)

6.2 消息持久化三要素

// 1. 队列声明为 durable
await channel.QueueDeclareAsync("queue", durable: true, ...);

// 2. 消息属性设置 persistent
var props = new BasicProperties { Persistent = true };

// 3. 发布时使用持久化属性
await channel.BasicPublishAsync("", "queue", false, props, body);

缺一不可

  • 只有队列 durable → 队列在,消息丢了
  • 只有消息 persistent → 消息在,队列丢了
  • 两者都设置 → Broker 重启后消息仍在

6.3 手动 ACK vs 自动 ACK

// 自动 ACK:消息发出即确认(可能丢失)
await channel.BasicConsumeAsync(queue, autoAck: true, ...);

// 手动 ACK:业务处理完再确认(可靠)
await channel.BasicConsumeAsync(queue, autoAck: false, ...);
// 处理成功:
await channel.BasicAckAsync(deliveryTag, false);
// 处理失败:
await channel.BasicRejectAsync(deliveryTag, requeue: true);
模式 优点 缺点
autoAck=true 简单,吞吐高 消费失败则消息丢失
autoAck=false 可靠,失败可重试 需手动管理 ACK

6.4 Prefetch(QoS)

// 一次最多预取 100 条,处理完再给新的
await channel.BasicQosAsync(0, prefetchCount: 100, false);

作用:防止快消费者饿死慢消费者。如果不限制,MQ 会一次性把所有消息推给最快的那个消费者。


七、扩展:将来换 Kafka 怎么做

7.1 新增 Kafka 实现

using Confluent.Kafka;

public class KafkaMessageBus : IMessageBus
{
    private readonly IProducer<string, byte[]> _producer;
    private readonly IConsumer<string, byte[]> _consumer;

    public KafkaMessageBus(KafkaOptions options)
    {
        var producerConfig = new ProducerConfig
        {
            BootstrapServers = options.BootstrapServers
        };
        _producer = new ProducerBuilder<string, byte[]>(producerConfig).Build();

        var consumerConfig = new ConsumerConfig
        {
            BootstrapServers = options.BootstrapServers,
            GroupId = options.GroupId,
            AutoOffsetReset = AutoOffsetReset.Latest
        };
        _consumer = new ConsumerBuilder<string, byte[]>(consumerConfig).Build();
    }

    public async Task PublishAsync(string topic, byte[] body, bool persistent = true)
    {
        await _producer.ProduceAsync(topic, new Message<string, byte[]>
        {
            Key = Guid.NewGuid().ToString(),
            Value = body
        });
    }

    public Task SubscribeAsync(string topic,
        Func<ulong, ReadOnlyMemory<byte>, Task> onMessage, ushort prefetchCount = 0)
    {
        // Kafka 的订阅模型不同(consumer group + offset)
        // 需要适配层将 Kafka offset 映射为 deliveryTag
        // ...
    }

    public Task AckAsync(ulong deliveryTag)
    {
        // Kafka 通过 commit offset 实现 ACK
        // 需要维护 deliveryTag → offset 映射
        return Task.CompletedTask;
    }

    public Task RejectAsync(ulong deliveryTag, bool requeue = false)
    {
        // Kafka 没有 reject 概念,通过 offset 管理实现
        return Task.CompletedTask;
    }
}

7.2 切换只需改注册

// 切换到 Kafka:
services.AddSingleton<IMessageBus, KafkaMessageBus>();

// 切换到 RabbitMQ:
services.AddSingleton<IMessageBus, RabbitMqMessageBus>();

Publisher 和 Consumer 零改动


八、单元测试示例

8.1 Mock IMessageBus

public class MockMessageBus : IMessageBus
{
    public List<(string Queue, byte[] Body)> Published { get; } = new();
    public Func<ulong, ReadOnlyMemory<byte>, Task>? OnReceived { get; set; }

    public Task PublishAsync(string queue, byte[] body, bool persistent = true)
    {
        Published.Add((queue, body));
        return Task.CompletedTask;
    }

    public Task SubscribeAsync(string queue,
        Func<ulong, ReadOnlyMemory<byte>, Task> onMessage, ushort prefetchCount = 0)
    {
        OnReceived = onMessage;
        return Task.CompletedTask;
    }

    public Task AckAsync(ulong deliveryTag) => Task.CompletedTask;
    public Task RejectAsync(ulong deliveryTag, bool requeue = false) => Task.CompletedTask;
    public void Dispose() { }
}

8.2 测试 Publisher

[Fact]
public async Task PublishAsync_ShouldSendToInputQueue()
{
    var mockBus = new MockMessageBus();
    var publisher = new RabbitMqTelemetryPublisher(
        NullLogger<RabbitMqTelemetryPublisher>.Instance, mockBus);

    var data = new PlayloadData { DeviceId = Guid.NewGuid(), MacNo = "M001" };
    await publisher.PublishAsync(data);

    Assert.Single(mockBus.Published);
    Assert.Equal("telemetry.nodered.input", mockBus.Published[0].Queue);
}

8.3 测试 Consumer

[Fact]
public async Task Consumer_FlushBatch_ShouldAckOnSuccess()
{
    var mockBus = new MockMessageBus();
    var consumer = new RabbitMqTelemetryConsumer(
        NullLogger<RabbitMqTelemetryConsumer>.Instance, mockBus);

    // 模拟收到消息
    var body = JsonSerializer.SerializeToUtf8Bytes(new PlayloadData
    {
        DeviceId = Guid.NewGuid(),
        MsgBody = new Dictionary<string, object> { { "temp", 25.5 } }
    });

    await mockBus.OnReceived!.Invoke(1UL, body);
    // ... 触发 flush
}

九、设计模式总结

模式 在本项目中的应用
策略模式 IMessageBus 定义策略,RabbitMqMessageBus / KafkaMessageBus 是具体策略
依赖倒置 高层模块(Publisher/Consumer)不依赖低层模块(RabbitMQ.Client),都依赖抽象(IMessageBus)
接口隔离 IMessageBus 只有 4 个方法,不暴露 ConnectionFactory/Channel 等实现细节
单一职责 RabbitMqMessageBus 只管连接+收发,Publisher 只管序列化+发布,Consumer 只管解析+批量入库
工厂模式 EnsureConnectionAsync / EnsurePublishChannelAsync 按需创建资源

十、常见问题

Q1: 为什么不用抽象工厂创建 Channel?

A: 本场景 Channel 生命周期跟 MessageBus 一致(Singleton),不需要工厂。如果 Channel 需要按请求创建(如每个 HTTP 请求独立 Channel),才需要工厂模式。

Q2: Publish 和 Subscribe 共享一个 Channel 行不行?

A: 技术上可以,但不推荐。不同操作的 QoS 设置会互相影响,且一个 channel 异常会导致所有操作中断。分开更安全。

Q3: IMessageBus 是否需要支持 Request/Reply 模式?

A: 当前场景是 Fire-and-Forget(发布即忘),不需要。如果将来需要 RPC 模式,可以在接口中扩展 Task<T> RequestAsync<T>(string queue, byte[] body, TimeSpan timeout) 方法。

Q4: 如何处理消息序列化?

A: IMessageBus 传输 byte[],序列化由调用方决定。Publisher 用 System.Text.Json,Consumer 用 System.Text.Json + 自定义解析。如果需要切换序列化框架(如 MessagePack),改调用方即可,不改 MessageBus。


posted @ 2026-08-27 11:18  古月秋筠  阅读(3)  评论(0)    收藏  举报