Files
Finlytic/FinlyticCore/Util/ManagedMqttClient.cs
T

397 lines
14 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Models;
using FinlyticCore.Models.Settings;
using FinlyticCore.Services;
using Microsoft.Extensions.Logging;
using MQTTnet;
namespace FinlyticCore.Util;
/// <summary>
/// An abstract, resilient MQTT client wrapper designed for microservice architectures.
/// Handles automatic reconnection, structured JSON publishing, thread-safe subscription management, and synchronous Request-Reply (RPC).
/// Supports channel-controlled logging via <see cref="CoreSettingKeys.MqttChannel"/>.
/// </summary>
public abstract class ManagedMqttClient : IDisposable
{
private readonly ILogger<ManagedMqttClient> _logger;
private readonly ISettingsService? _settingsService;
private readonly IFinlyticLogger<ManagedMqttClient>? _finlyticLogger;
private readonly IMqttClient _mqttClient;
private CancellationTokenSource? _cts;
// Tracks pending RPC requests waiting for a specific correlation ID reply
private readonly ConcurrentDictionary<string, TaskCompletionSource<string>> _pendingRequests = new();
/// <summary>
/// Gets a value indicating whether the client is currently connected to the MQTT broker.
/// </summary>
public bool IsConnected => _mqttClient.IsConnected;
protected ManagedMqttClient(
ILogger<ManagedMqttClient> logger,
ISettingsService? settingsService = null,
IFinlyticLogger<ManagedMqttClient>? finlyticLogger = null)
{
_logger = logger;
_settingsService = settingsService;
_finlyticLogger = finlyticLogger;
_mqttClient = new MqttClientFactory().CreateMqttClient();
_mqttClient.ApplicationMessageReceivedAsync += HandleIncomingMessageAsync;
_mqttClient.DisconnectedAsync += HandleDisconnectAsync;
}
private async Task LogMqttInfoAsync(string message, params object[] args)
{
if (_finlyticLogger != null)
{
await _finlyticLogger.LogInfoAsync(CoreSettingKeys.MqttChannel, message, args);
}
else if (_settingsService != null)
{
if (await _settingsService.GetSettingAsync(CoreSettingKeys.MqttChannel))
{
_logger.LogInformation(message, args);
}
}
else
{
_logger.LogInformation(message, args);
}
}
private async Task LogMqttDebugAsync(string message, params object[] args)
{
if (_finlyticLogger != null)
{
await _finlyticLogger.LogDebugAsync(CoreSettingKeys.MqttChannel, message, args);
}
else if (_settingsService != null)
{
if (await _settingsService.GetSettingAsync(CoreSettingKeys.MqttChannel))
{
_logger.LogDebug(message, args);
}
}
else
{
_logger.LogDebug(message, args);
}
}
/// <summary>
/// Establishes a connection to the MQTT broker and initializes the background auto-reconnection loop.
/// </summary>
/// <param name="config">The network and credential configuration options for the broker.</param>
public async Task ConnectAsync(MqttConfiguration config)
{
if (IsConnected)
throw new InvalidOperationException("MQTT client is already connected.");
_cts = new CancellationTokenSource();
var optionsBuilder = new MqttClientOptionsBuilder()
.WithTcpServer(config.Host, config.Port)
.WithClientId(config.ClientId)
.WithCleanSession();
if (!string.IsNullOrWhiteSpace(config.Username))
{
optionsBuilder.WithCredentials(config.Username, config.Password);
}
var options = optionsBuilder.Build();
await LogMqttInfoAsync("Connecting to MQTT broker at {Host}:{Port}...", config.Host, config.Port);
try
{
await _mqttClient.ConnectAsync(options, _cts.Token);
await LogMqttInfoAsync("Successfully connected to MQTT broker.");
await OnConnectedAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to establish initial connection to MQTT broker. Reconnection loop will handle recovery.");
}
}
/// <summary>
/// Gracefully disconnects from the broker and stops all ongoing background loops.
/// </summary>
public async Task DisconnectAsync()
{
if (_cts != null)
{
await _cts.CancelAsync();
}
if (_mqttClient.IsConnected)
{
try
{
await _mqttClient.DisconnectAsync(new MqttClientDisconnectOptions
{
Reason = MqttClientDisconnectOptionsReason.NormalDisconnection
});
await LogMqttInfoAsync("MQTT connection gracefully closed.");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "An error occurred while disconnecting from the MQTT broker.");
}
}
}
/// <summary>
/// Subscribes to a specific MQTT topic filter.
/// </summary>
/// <param name="topic">The topic pattern or wildcard to subscribe to.</param>
/// <param name="noLocal">If set to <c>true</c>, the broker will not forward messages published by this client back to itself.</param>
protected async Task SubscribeAsync(string topic, bool noLocal = false)
{
if (!IsConnected)
{
_logger.LogWarning("Subscription to topic '{Topic}' delayed: Client is currently offline.", topic);
return;
}
var filterBuilder = new MqttTopicFilterBuilder().WithTopic(topic);
if (noLocal)
{
filterBuilder.WithNoLocal();
}
var subscribeOptions = new MqttClientFactory().CreateSubscribeOptionsBuilder()
.WithTopicFilter(filterBuilder.Build())
.Build();
await _mqttClient.SubscribeAsync(subscribeOptions, CancellationToken.None);
await LogMqttDebugAsync("Successfully subscribed to topic: {Topic} (NoLocal: {NoLocal})", topic, noLocal);
}
/// <summary>
/// Publishes a raw string message payload to the specified topic.
/// </summary>
public async Task PublishAsync(string topic, string payload, bool retain = false)
{
if (!IsConnected)
throw new InvalidOperationException("Cannot publish message: MQTT client is offline.");
var message = new MqttApplicationMessageBuilder()
.WithTopic(topic)
.WithPayload(payload)
.WithQualityOfServiceLevel(MQTTnet.Protocol.MqttQualityOfServiceLevel.AtLeastOnce)
.WithRetainFlag(retain)
.Build();
await _mqttClient.PublishAsync(message, CancellationToken.None);
}
/// <summary>
/// Serializes a generic object into a structured JSON string and publishes it to the specified topic.
/// Utilizes .NET 8 JSON Source Generators for zero-reflection overhead, with reflection fallback for unregistered types.
/// </summary>
public Task PublishAsync<T>(string topic, T data, bool retain = false)
{
byte[] jsonBytes;
var typeInfo = FinlyticJsonSerializerContext.Default.GetTypeInfo(typeof(T))
?? (data != null ? FinlyticJsonSerializerContext.Default.GetTypeInfo(data.GetType()) : null);
if (typeInfo != null)
{
jsonBytes = JsonSerializer.SerializeToUtf8Bytes(data, typeInfo);
}
else
{
jsonBytes = JsonSerializer.SerializeToUtf8Bytes(data);
}
if (!IsConnected)
throw new InvalidOperationException("Cannot publish message: MQTT client is offline.");
var message = new MqttApplicationMessageBuilder()
.WithTopic(topic)
.WithPayload(jsonBytes)
.WithQualityOfServiceLevel(MQTTnet.Protocol.MqttQualityOfServiceLevel.AtLeastOnce)
.WithRetainFlag(retain)
.Build();
return _mqttClient.PublishAsync(message, CancellationToken.None);
}
/// <summary>
/// Sends a parameterless request to an RPC channel and asynchronously blocks until a matching response arrives.
/// </summary>
public Task<TResponse?> SendRpcRequestAsync<TResponse>(
string channel,
TimeSpan? timeout = null)
where TResponse : class
{
return SendRpcRequestAsync<TResponse, string>(channel, string.Empty, timeout);
}
/// <summary>
/// Sends a generic request payload to an RPC channel and asynchronously blocks until a matching response arrives.
/// Uses the topic conventions: <c>services/request/{channel}/{correlationId}</c> and <c>services/response/{channel}/{correlationId}</c>.
/// </summary>
public async Task<TResponse?> SendRpcRequestAsync<TResponse, TRequest>(
string channel,
TRequest requestData,
TimeSpan? timeout = null)
where TResponse : class
where TRequest : class
{
if (!IsConnected)
throw new InvalidOperationException("Cannot execute RPC request: MQTT client is offline.");
// 1. Generate a unique Correlation ID for this specific transaction
string correlationId = Guid.NewGuid().ToString("N");
var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
_pendingRequests.TryAdd(correlationId, tcs);
string requestTopic = $"services/request/{channel}/{correlationId}";
// 2. Serialize and dispatch via the existing JSON helper
await PublishAsync(requestTopic, requestData);
await LogMqttInfoAsync("RPC request published to '{Topic}' [CorrelationId: {Id}]", requestTopic, correlationId);
try
{
// 3. Block asynchronously until the response loop resolves the token
var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(25);
var rawJsonResult = await tcs.Task.WaitAsync(effectiveTimeout);
if (typeof(TResponse) == typeof(string))
{
return rawJsonResult as TResponse;
}
var respTypeInfo = FinlyticJsonSerializerContext.Default.GetTypeInfo(typeof(TResponse));
if (respTypeInfo != null)
{
return JsonSerializer.Deserialize(rawJsonResult, respTypeInfo) as TResponse;
}
return JsonSerializer.Deserialize<TResponse>(rawJsonResult);
}
catch (TimeoutException)
{
_logger.LogWarning("RPC request timed out on channel '{Channel}' [CorrelationId: {Id}]", channel, correlationId);
return null;
}
finally
{
// Always clean up the dictionary to prevent memory leaks
_pendingRequests.TryRemove(correlationId, out _);
}
}
private Task HandleIncomingMessageAsync(MqttApplicationMessageReceivedEventArgs e)
{
_ = Task.Run(async () =>
{
try
{
var topic = e.ApplicationMessage.Topic;
var payload = Encoding.UTF8.GetString(e.ApplicationMessage.Payload);
await LogMqttDebugAsync("MQTT message received on topic '{Topic}', length={Length}", topic, payload?.Length ?? 0);
// Intercept message if it belongs to the RPC response convention
if (topic.StartsWith("services/response/"))
{
var lastSlashIndex = topic.LastIndexOf('/');
if (lastSlashIndex != -1)
{
string correlationId = topic[(lastSlashIndex + 1)..];
if (_pendingRequests.TryRemove(correlationId, out var tcs))
{
tcs.SetResult(payload ?? string.Empty);
return; // Sinks the message, avoiding triggering OnMessageReceivedAsync for active RPC handles
}
}
}
// Regular Pub/Sub message propagation
await OnMessageReceivedAsync(topic, payload ?? string.Empty);
}
catch (Exception ex)
{
OnError(ex);
}
});
return Task.CompletedTask;
}
private async Task HandleDisconnectAsync(MqttClientDisconnectedEventArgs e)
{
if (_cts == null || _cts.IsCancellationRequested)
return;
_logger.LogWarning("Lost connection to MQTT broker (Reason: {Reason}). Initiating auto-reconnect loop...", e.Reason);
int attempt = 0;
while (_cts != null && !_cts.IsCancellationRequested)
{
attempt++;
var delaySeconds = Math.Min(5 * Math.Pow(2, attempt - 1), 60); // 5s, 10s, 20s, 40s, 60s max
try
{
await LogMqttInfoAsync("Reconnect attempt {Attempt} in {Delay}s...", attempt, delaySeconds);
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), _cts.Token);
await _mqttClient.ReconnectAsync(_cts.Token);
if (_mqttClient.IsConnected)
{
await LogMqttInfoAsync("MQTT client reconnected successfully after {Attempt} attempt(s).", attempt);
await OnConnectedAsync();
return;
}
}
catch (OperationCanceledException) { return; }
catch (Exception ex)
{
_logger.LogWarning(ex, "Reconnect attempt {Attempt} to the MQTT broker failed.", attempt);
}
}
}
/// <summary>
/// Fired automatically whenever a connection or reconnection is successfully established.
/// </summary>
protected abstract Task OnConnectedAsync();
/// <summary>
/// Fired whenever a new message lands on a registered subscription channel.
/// </summary>
protected abstract Task OnMessageReceivedAsync(string topic, string payload);
/// <summary>
/// Virtual fallback method to catch and handle processing level exceptions inside the incoming pipeline.
/// </summary>
protected virtual void OnError(Exception ex)
{
_logger.LogError(ex, "An unhandled exception occurred within the ManagedMqttClient messaging pipeline.");
}
public void Dispose()
{
DisconnectAsync().GetAwaiter().GetResult();
_cts?.Dispose();
_mqttClient.Dispose();
GC.SuppressFinalize(this);
}
}