308 lines
11 KiB
C#
308 lines
11 KiB
C#
using System;
|
|
using Svrnty.CQRS.Events.RabbitMQ.Configuration;
|
|
using Svrnty.CQRS.Events.Abstractions.Subscriptions;
|
|
using Svrnty.CQRS.Events.Abstractions.Configuration;
|
|
using Svrnty.CQRS.Events.Abstractions.EventStore;
|
|
using System.Collections.Generic;
|
|
using Microsoft.Extensions.Logging;
|
|
using RabbitMQ.Client;
|
|
using Svrnty.CQRS.Events.Abstractions;
|
|
|
|
namespace Svrnty.CQRS.Events.RabbitMQ.Configuration;
|
|
|
|
/// <summary>
|
|
/// Manages RabbitMQ topology (exchanges, queues, bindings).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This class is responsible for creating and configuring RabbitMQ topology
|
|
/// based on the stream and subscription configuration.
|
|
/// </remarks>
|
|
public sealed class RabbitMQTopologyManager
|
|
{
|
|
private readonly RabbitMQConfiguration _config;
|
|
private readonly ILogger<RabbitMQTopologyManager> _logger;
|
|
|
|
public RabbitMQTopologyManager(
|
|
RabbitMQConfiguration config,
|
|
ILogger<RabbitMQTopologyManager> logger)
|
|
{
|
|
_config = config ?? throw new ArgumentNullException(nameof(config));
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the exchange name for a stream.
|
|
/// </summary>
|
|
/// <param name="streamName">The stream name.</param>
|
|
/// <returns>The full exchange name.</returns>
|
|
public string GetExchangeName(string streamName)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(streamName))
|
|
throw new ArgumentException("Stream name cannot be null or whitespace.", nameof(streamName));
|
|
|
|
return string.IsNullOrWhiteSpace(_config.ExchangePrefix)
|
|
? streamName
|
|
: $"{_config.ExchangePrefix}.{streamName}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the queue name for a subscription.
|
|
/// </summary>
|
|
/// <param name="subscriptionId">The subscription ID.</param>
|
|
/// <param name="consumerId">The consumer ID (for broadcast mode).</param>
|
|
/// <param name="mode">The subscription mode.</param>
|
|
/// <returns>The queue name.</returns>
|
|
public string GetQueueName(string subscriptionId, string? consumerId, SubscriptionMode mode)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(subscriptionId))
|
|
throw new ArgumentException("Subscription ID cannot be null or whitespace.", nameof(subscriptionId));
|
|
|
|
var queueName = mode switch
|
|
{
|
|
SubscriptionMode.Broadcast when !string.IsNullOrWhiteSpace(consumerId) =>
|
|
// Each consumer gets its own queue
|
|
$"{subscriptionId}.{consumerId}",
|
|
|
|
SubscriptionMode.Exclusive or SubscriptionMode.ConsumerGroup =>
|
|
// All consumers share one queue
|
|
subscriptionId,
|
|
|
|
_ => subscriptionId
|
|
};
|
|
|
|
return string.IsNullOrWhiteSpace(_config.ExchangePrefix)
|
|
? queueName
|
|
: $"{_config.ExchangePrefix}.{queueName}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the routing key for an event based on the configured strategy.
|
|
/// </summary>
|
|
/// <param name="streamName">The stream name.</param>
|
|
/// <param name="event">The event.</param>
|
|
/// <returns>The routing key.</returns>
|
|
public string GetRoutingKey(string streamName, ICorrelatedEvent @event)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(streamName))
|
|
throw new ArgumentException("Stream name cannot be null or whitespace.", nameof(streamName));
|
|
if (@event == null)
|
|
throw new ArgumentNullException(nameof(@event));
|
|
|
|
return _config.DefaultRoutingKeyStrategy switch
|
|
{
|
|
"EventType" => @event.GetType().Name,
|
|
"StreamName" => streamName,
|
|
"Wildcard" => "#",
|
|
_ => @event.GetType().Name
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Declares an exchange for a stream.
|
|
/// </summary>
|
|
/// <param name="channel">The RabbitMQ channel.</param>
|
|
/// <param name="streamName">The stream name.</param>
|
|
public void DeclareExchange(IChannel channel, string streamName)
|
|
{
|
|
if (channel == null)
|
|
throw new ArgumentNullException(nameof(channel));
|
|
if (string.IsNullOrWhiteSpace(streamName))
|
|
throw new ArgumentException("Stream name cannot be null or whitespace.", nameof(streamName));
|
|
|
|
if (!_config.AutoDeclareTopology)
|
|
{
|
|
_logger.LogDebug("Auto-declare topology is disabled, skipping exchange declaration for {StreamName}", streamName);
|
|
return;
|
|
}
|
|
|
|
var exchangeName = GetExchangeName(streamName);
|
|
|
|
try
|
|
{
|
|
channel.ExchangeDeclareAsync(
|
|
exchange: exchangeName,
|
|
type: _config.DefaultExchangeType,
|
|
durable: _config.DurableExchanges,
|
|
autoDelete: false,
|
|
arguments: null).GetAwaiter().GetResult();
|
|
|
|
_logger.LogInformation(
|
|
"Declared exchange {ExchangeName} (type: {ExchangeType}, durable: {Durable})",
|
|
exchangeName,
|
|
_config.DefaultExchangeType,
|
|
_config.DurableExchanges);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to declare exchange {ExchangeName}", exchangeName);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Declares a queue for a subscription.
|
|
/// </summary>
|
|
/// <param name="channel">The RabbitMQ channel.</param>
|
|
/// <param name="subscriptionId">The subscription ID.</param>
|
|
/// <param name="consumerId">The consumer ID (for broadcast mode).</param>
|
|
/// <param name="mode">The subscription mode.</param>
|
|
/// <returns>The declared queue name.</returns>
|
|
public string DeclareQueue(
|
|
IChannel channel,
|
|
string subscriptionId,
|
|
string? consumerId,
|
|
SubscriptionMode mode)
|
|
{
|
|
if (channel == null)
|
|
throw new ArgumentNullException(nameof(channel));
|
|
if (string.IsNullOrWhiteSpace(subscriptionId))
|
|
throw new ArgumentException("Subscription ID cannot be null or whitespace.", nameof(subscriptionId));
|
|
|
|
if (!_config.AutoDeclareTopology)
|
|
{
|
|
var queueName = GetQueueName(subscriptionId, consumerId, mode);
|
|
_logger.LogDebug("Auto-declare topology is disabled, skipping queue declaration for {QueueName}", queueName);
|
|
return queueName;
|
|
}
|
|
|
|
var queue = GetQueueName(subscriptionId, consumerId, mode);
|
|
var arguments = new Dictionary<string, object?>();
|
|
|
|
// Add dead letter exchange if configured
|
|
if (!string.IsNullOrWhiteSpace(_config.DeadLetterExchange))
|
|
{
|
|
arguments["x-dead-letter-exchange"] = _config.DeadLetterExchange;
|
|
}
|
|
|
|
// Add message TTL if configured
|
|
if (_config.MessageTTL.HasValue)
|
|
{
|
|
arguments["x-message-ttl"] = (int)_config.MessageTTL.Value.TotalMilliseconds;
|
|
}
|
|
|
|
// Add max queue length if configured
|
|
if (_config.MaxQueueLength.HasValue)
|
|
{
|
|
arguments["x-max-length"] = _config.MaxQueueLength.Value;
|
|
}
|
|
|
|
try
|
|
{
|
|
channel.QueueDeclareAsync(
|
|
queue: queue,
|
|
durable: _config.DurableQueues,
|
|
exclusive: false,
|
|
autoDelete: mode == SubscriptionMode.Broadcast, // Auto-delete broadcast queues
|
|
arguments: arguments.Count > 0 ? arguments : null).GetAwaiter().GetResult();
|
|
|
|
_logger.LogInformation(
|
|
"Declared queue {QueueName} (durable: {Durable}, mode: {Mode})",
|
|
queue,
|
|
_config.DurableQueues,
|
|
mode);
|
|
|
|
return queue;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to declare queue {QueueName}", queue);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Binds a queue to an exchange with routing keys.
|
|
/// </summary>
|
|
/// <param name="channel">The RabbitMQ channel.</param>
|
|
/// <param name="streamName">The stream name.</param>
|
|
/// <param name="queueName">The queue name.</param>
|
|
/// <param name="routingKeys">The routing keys for binding.</param>
|
|
public void BindQueue(
|
|
IChannel channel,
|
|
string streamName,
|
|
string queueName,
|
|
IEnumerable<string> routingKeys)
|
|
{
|
|
if (channel == null)
|
|
throw new ArgumentNullException(nameof(channel));
|
|
if (string.IsNullOrWhiteSpace(streamName))
|
|
throw new ArgumentException("Stream name cannot be null or whitespace.", nameof(streamName));
|
|
if (string.IsNullOrWhiteSpace(queueName))
|
|
throw new ArgumentException("Queue name cannot be null or whitespace.", nameof(queueName));
|
|
if (routingKeys == null)
|
|
throw new ArgumentNullException(nameof(routingKeys));
|
|
|
|
if (!_config.AutoDeclareTopology)
|
|
{
|
|
_logger.LogDebug("Auto-declare topology is disabled, skipping queue binding for {QueueName}", queueName);
|
|
return;
|
|
}
|
|
|
|
var exchangeName = GetExchangeName(streamName);
|
|
|
|
foreach (var routingKey in routingKeys)
|
|
{
|
|
try
|
|
{
|
|
channel.QueueBindAsync(
|
|
queue: queueName,
|
|
exchange: exchangeName,
|
|
routingKey: routingKey,
|
|
arguments: null).GetAwaiter().GetResult();
|
|
|
|
_logger.LogInformation(
|
|
"Bound queue {QueueName} to exchange {ExchangeName} with routing key {RoutingKey}",
|
|
queueName,
|
|
exchangeName,
|
|
routingKey);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(
|
|
ex,
|
|
"Failed to bind queue {QueueName} to exchange {ExchangeName} with routing key {RoutingKey}",
|
|
queueName,
|
|
exchangeName,
|
|
routingKey);
|
|
throw;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Declares the dead letter exchange if configured.
|
|
/// </summary>
|
|
/// <param name="channel">The RabbitMQ channel.</param>
|
|
public void DeclareDeadLetterExchange(IChannel channel)
|
|
{
|
|
if (channel == null)
|
|
throw new ArgumentNullException(nameof(channel));
|
|
|
|
if (string.IsNullOrWhiteSpace(_config.DeadLetterExchange))
|
|
return;
|
|
|
|
if (!_config.AutoDeclareTopology)
|
|
{
|
|
_logger.LogDebug("Auto-declare topology is disabled, skipping dead letter exchange declaration");
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
channel.ExchangeDeclareAsync(
|
|
exchange: _config.DeadLetterExchange,
|
|
type: "topic",
|
|
durable: true,
|
|
autoDelete: false,
|
|
arguments: null).GetAwaiter().GetResult();
|
|
|
|
_logger.LogInformation("Declared dead letter exchange {ExchangeName}", _config.DeadLetterExchange);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to declare dead letter exchange {ExchangeName}", _config.DeadLetterExchange);
|
|
throw;
|
|
}
|
|
}
|
|
}
|