chore: initial repository scaffold
- Add Hrynco.RabbitMq class library with RabbitMQ client abstractions - Add Hrynco.RabbitMq.Tests xunit project - ImplicitUsings disabled on all projects Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
artifacts/
|
||||||
|
TestResults/
|
||||||
|
.idea/
|
||||||
|
*.DotSettings.user
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>disable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FluentAssertions" Version="8.2.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||||
|
<PackageReference Include="NSubstitute" Version="5.3.0"/>
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Hrynco.RabbitMq\Hrynco.RabbitMq.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<Solution>
|
||||||
|
<Project Path="Hrynco.RabbitMq.Tests/Hrynco.RabbitMq.Tests.csproj" />
|
||||||
|
<Project Path="Hrynco.RabbitMq/Hrynco.RabbitMq.csproj" />
|
||||||
|
</Solution>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Hrynco.RabbitMq;
|
||||||
|
|
||||||
|
public record CorrelationContext
|
||||||
|
{
|
||||||
|
public required string CorrelationId { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>disable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>Hrynco.RabbitMq</RootNamespace>
|
||||||
|
<PackageId>HrynCo.RabbitMq</PackageId>
|
||||||
|
<Authors>HrynCo</Authors>
|
||||||
|
<Description>RabbitMQ publisher and consumer base for HrynCo applications.</Description>
|
||||||
|
<PackageTags>hrynco rabbitmq messaging</PackageTags>
|
||||||
|
<RepositoryType>git</RepositoryType>
|
||||||
|
<RepositoryUrl>https://gitea.grynco.com.ua/hrynco/hrynco-rabbitmq.git</RepositoryUrl>
|
||||||
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="RabbitMQ.Client" Version="7.2.1"/>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0"/>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="README.md" Pack="true" PackagePath=""/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Hrynco.RabbitMq;
|
||||||
|
|
||||||
|
public interface IRabbitMqMessage<TMessageData>
|
||||||
|
{
|
||||||
|
CorrelationContext CorrelationContext { get; set; }
|
||||||
|
TMessageData Data { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Hrynco.RabbitMq;
|
||||||
|
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
public interface IRabbitMqPublisher
|
||||||
|
{
|
||||||
|
Task PublishAsync<TData>(string queue, IRabbitMqMessage<TData> message, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# HrynCo.RabbitMq
|
||||||
|
|
||||||
|
RabbitMQ publisher and consumer base for HrynCo applications.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- `RabbitMqSettings` — connection settings record (host, port, user, password, virtual host)
|
||||||
|
- `IRabbitMqPublisher` / `RabbitMqPublisher` — publishes JSON-serialized messages to a named queue
|
||||||
|
- `RabbitMqConsumerBase<TMessage, TMessageData>` — abstract background service base for consumers, with retry + dead-letter support
|
||||||
|
- `IRabbitMqMessage<TMessageData>` — message contract interface
|
||||||
|
- `CorrelationContext` — correlation ID carrier
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
This package is intended for reuse through NuGet. The test project is excluded from packing.
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
namespace Hrynco.RabbitMq;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using RabbitMQ.Client;
|
||||||
|
using RabbitMQ.Client.Events;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for RabbitMQ consumers. Handles connection management, manual ack,
|
||||||
|
/// and retry with backoff before dead-lettering.
|
||||||
|
/// Override <see cref="SettingsName"/> to use a named <see cref="RabbitMqSettings"/> instance.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class RabbitMqConsumerBase<TMessage, TMessageData> : BackgroundService
|
||||||
|
where TMessage : class, IRabbitMqMessage<TMessageData>
|
||||||
|
{
|
||||||
|
private readonly IOptionsMonitor<RabbitMqSettings> _options;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
|
private IConnection? _connection;
|
||||||
|
private IChannel? _channel;
|
||||||
|
|
||||||
|
protected RabbitMqConsumerBase(IOptionsMonitor<RabbitMqSettings> options, ILogger logger)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract string QueueName { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Name of the <see cref="RabbitMqSettings"/> instance to use.
|
||||||
|
/// Override to use a named instance when multiple RabbitMQ connections are configured.
|
||||||
|
/// Defaults to <see cref="Options.DefaultName"/> (the unnamed instance).
|
||||||
|
/// </summary>
|
||||||
|
protected virtual string SettingsName => Options.DefaultName;
|
||||||
|
|
||||||
|
protected virtual int MaxRetries => 3;
|
||||||
|
protected virtual TimeSpan RetryDelay => TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
private RabbitMqSettings Settings => _options.Get(SettingsName);
|
||||||
|
|
||||||
|
protected abstract Task HandleMessageAsync(TMessage message, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
await EnsureConnectionAsync(stoppingToken);
|
||||||
|
|
||||||
|
var consumer = new AsyncEventingBasicConsumer(_channel!);
|
||||||
|
|
||||||
|
consumer.ReceivedAsync += async (_, args) =>
|
||||||
|
{
|
||||||
|
await ProcessMessageAsync(args, stoppingToken);
|
||||||
|
};
|
||||||
|
|
||||||
|
await _channel!.BasicConsumeAsync(queue: QueueName, autoAck: false, consumer: consumer,
|
||||||
|
cancellationToken: stoppingToken);
|
||||||
|
|
||||||
|
// Hold until cancellation — consumer events fire on the channel thread
|
||||||
|
await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessMessageAsync(BasicDeliverEventArgs args, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
TMessage? message = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = Encoding.UTF8.GetString(args.Body.ToArray());
|
||||||
|
message = JsonSerializer.Deserialize<TMessage>(json);
|
||||||
|
|
||||||
|
if (message is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Received null message on queue {Queue} — nacking without requeue", QueueName);
|
||||||
|
await _channel!.BasicNackAsync(args.DeliveryTag, multiple: false, requeue: false, cancellationToken: cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to deserialize message on queue {Queue} — nacking without requeue", QueueName);
|
||||||
|
await _channel!.BasicNackAsync(args.DeliveryTag, multiple: false, requeue: false, cancellationToken: cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int attempt = 1; attempt <= MaxRetries; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await HandleMessageAsync(message, cancellationToken);
|
||||||
|
await _channel!.BasicAckAsync(args.DeliveryTag, multiple: false, cancellationToken: cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (attempt < MaxRetries)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"Attempt {Attempt}/{Max} failed for message on queue {Queue} [CorrelationId={CorrelationId}] — retrying in {Delay}s",
|
||||||
|
attempt, MaxRetries, QueueName, message.CorrelationContext?.CorrelationId, RetryDelay.TotalSeconds);
|
||||||
|
|
||||||
|
await Task.Delay(RetryDelay, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex,
|
||||||
|
"All {Max} attempts exhausted for message on queue {Queue} [CorrelationId={CorrelationId}] — nacking without requeue",
|
||||||
|
MaxRetries, QueueName, message.CorrelationContext?.CorrelationId);
|
||||||
|
|
||||||
|
await _channel!.BasicNackAsync(args.DeliveryTag, multiple: false, requeue: false, cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureConnectionAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var s = Settings;
|
||||||
|
var factory = new ConnectionFactory
|
||||||
|
{
|
||||||
|
HostName = s.Host,
|
||||||
|
Port = s.Port,
|
||||||
|
UserName = s.User,
|
||||||
|
Password = s.Password,
|
||||||
|
VirtualHost = s.VirtualHost
|
||||||
|
};
|
||||||
|
|
||||||
|
_connection = await factory.CreateConnectionAsync(cancellationToken);
|
||||||
|
_channel = await _connection.CreateChannelAsync(cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
await _channel.QueueDeclareAsync(QueueName, durable: true, exclusive: false, autoDelete: false,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
await _channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogInformation("RabbitMQ consumer connected to queue {Queue} (settings: {SettingsName})",
|
||||||
|
QueueName, SettingsName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Dispose()
|
||||||
|
{
|
||||||
|
_channel?.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
|
_connection?.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
|
base.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
namespace Hrynco.RabbitMq;
|
||||||
|
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using RabbitMQ.Client;
|
||||||
|
|
||||||
|
public sealed class RabbitMqPublisher : IRabbitMqPublisher
|
||||||
|
{
|
||||||
|
private readonly IOptionsMonitor<RabbitMqSettings> _options;
|
||||||
|
private readonly string _settingsName;
|
||||||
|
private readonly ILogger<RabbitMqPublisher> _logger;
|
||||||
|
|
||||||
|
/// <param name="options">Named options monitor for <see cref="RabbitMqSettings"/>.</param>
|
||||||
|
/// <param name="logger">Logger.</param>
|
||||||
|
/// <param name="settingsName">
|
||||||
|
/// Name of the <see cref="RabbitMqSettings"/> instance to use.
|
||||||
|
/// Defaults to <see cref="Options.DefaultName"/> (i.e. the unnamed instance).
|
||||||
|
/// </param>
|
||||||
|
public RabbitMqPublisher(
|
||||||
|
IOptionsMonitor<RabbitMqSettings> options,
|
||||||
|
ILogger<RabbitMqPublisher> logger,
|
||||||
|
string? settingsName = null)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
_logger = logger;
|
||||||
|
_settingsName = settingsName ?? Options.DefaultName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RabbitMqSettings Settings => _options.Get(_settingsName);
|
||||||
|
|
||||||
|
public async Task PublishAsync<TData>(string queue, IRabbitMqMessage<TData> message, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var factory = BuildFactory(Settings);
|
||||||
|
|
||||||
|
await using var connection = await factory.CreateConnectionAsync(cancellationToken);
|
||||||
|
await using var channel = await connection.CreateChannelAsync(cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
await channel.QueueDeclareAsync(queue, durable: true, exclusive: false, autoDelete: false,
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
var body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(message));
|
||||||
|
|
||||||
|
var props = new BasicProperties
|
||||||
|
{
|
||||||
|
Persistent = true,
|
||||||
|
ContentType = "application/json"
|
||||||
|
};
|
||||||
|
|
||||||
|
await channel.BasicPublishAsync(exchange: "", routingKey: queue, mandatory: false,
|
||||||
|
basicProperties: props, body: body, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogDebug("Published to queue {Queue} [CorrelationId={CorrelationId}]",
|
||||||
|
queue, message.CorrelationContext?.CorrelationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ConnectionFactory BuildFactory(RabbitMqSettings s) => new()
|
||||||
|
{
|
||||||
|
HostName = s.Host,
|
||||||
|
Port = s.Port,
|
||||||
|
UserName = s.User,
|
||||||
|
Password = s.Password,
|
||||||
|
VirtualHost = s.VirtualHost
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Hrynco.RabbitMq;
|
||||||
|
|
||||||
|
public record RabbitMqSettings
|
||||||
|
{
|
||||||
|
public required string Host { get; init; }
|
||||||
|
public required string User { get; init; }
|
||||||
|
public required string Password { get; init; }
|
||||||
|
public int Port { get; init; } = 5672;
|
||||||
|
public string VirtualHost { get; init; } = "/";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user