This commit is contained in:
2026-07-19 12:13:14 +02:00
commit 6337e63a77
46 changed files with 4411 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
<PackageReference Include="MQTTnet" Version="5.1.0.1559" />
</ItemGroup>
</Project>
+32
View File
@@ -0,0 +1,32 @@
namespace FinlyticCore.Models;
/// <summary>
/// Represents the network and security settings used to connect to the central MQTT broker.
/// </summary>
public class MqttConfiguration
{
/// <summary>
/// Gets or sets the host address or IP of the MQTT broker.
/// </summary>
public string Host { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the port number for the connection. Defaults to 1883.
/// </summary>
public int Port { get; set; } = 1883;
/// <summary>
/// Gets or sets the unique client identifier used when registering with the broker.
/// </summary>
public string ClientId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the username for authentication (optional).
/// </summary>
public string? Username { get; set; }
/// <summary>
/// Gets or sets the password for authentication (optional).
/// </summary>
public string? Password { get; set; }
}
+65
View File
@@ -0,0 +1,65 @@
using FinlyticCore.Dtos.Assets;
using FinlyticCore.Entities.Assets;
namespace FinlyticCore.Util;
public static class AssetMapper
{
/// <summary>
/// Mappt eine AssetEntity (Datenbank) sicher auf ein zyklusfreies AssetDto (MQTT Payload).
/// </summary>
public static AssetDto ToDto(this AssetEntity entity)
{
// 1. Tags zyklusfrei mappen
var dtoTags = entity.Tags.Select(t => new TagDto
{
Id = t.Id,
Name = t.Name,
Type = t.Type
}).ToList();
// 2. Polymorphes Mapping basierend auf dem Laufzeittyp
return entity switch
{
StockEntity stock => new StockDto
{
Isin = stock.Isin, Name = stock.Name, Type = stock.Type, InstrumentCategory = stock.InstrumentCategory, HasCfd = stock.HasCfd, ImageId = stock.ImageId, UpdateAt = stock.UpdateAt, LastUpdatedAt = stock.LastUpdatedAt, Tags = dtoTags,
DerivativeProductCategories = stock.DerivativeProductCategories
},
EtfEntity etf => new EtfDto
{
Isin = etf.Isin, Name = etf.Name, Type = etf.Type, InstrumentCategory = etf.InstrumentCategory, HasCfd = etf.HasCfd, ImageId = etf.ImageId, UpdateAt = etf.UpdateAt, LastUpdatedAt = etf.LastUpdatedAt, Tags = dtoTags,
DerivativeProductCategories = etf.DerivativeProductCategories, EtfDescription = etf.EtfDescription, MappedEtfIndexName = etf.MappedEtfIndexName, Subtitle = etf.Subtitle, SearchSubtitle = etf.SearchSubtitle
},
CryptoEntity crypto => new CryptoDto
{
Isin = crypto.Isin, Name = crypto.Name, Type = crypto.Type, InstrumentCategory = crypto.InstrumentCategory, HasCfd = crypto.HasCfd, ImageId = crypto.ImageId, UpdateAt = crypto.UpdateAt, LastUpdatedAt = crypto.LastUpdatedAt, Tags = dtoTags,
Subtitle = crypto.Subtitle, SearchSubtitle = crypto.SearchSubtitle
},
BondEntity bond => new BondDto
{
Isin = bond.Isin, Name = bond.Name, Type = bond.Type, InstrumentCategory = bond.InstrumentCategory, HasCfd = bond.HasCfd, ImageId = bond.ImageId, UpdateAt = bond.UpdateAt, LastUpdatedAt = bond.LastUpdatedAt, Tags = dtoTags,
BondIssuerName = bond.BondIssuerName, SearchSubtitle = bond.SearchSubtitle
},
DerivativeEntity deriv => new DerivativeDto
{
Isin = deriv.Isin, Name = deriv.Name, Type = deriv.Type, InstrumentCategory = deriv.InstrumentCategory, HasCfd = deriv.HasCfd, ImageId = deriv.ImageId, UpdateAt = deriv.UpdateAt, LastUpdatedAt = deriv.LastUpdatedAt, Tags = dtoTags,
DerivativeProductCategories = deriv.DerivativeProductCategories, UnderlyingIsin = deriv.UnderlyingIsin
},
SyntheticEntity synth => new SyntheticDto
{
Isin = synth.Isin, Name = synth.Name, Type = synth.Type, InstrumentCategory = synth.InstrumentCategory, HasCfd = synth.HasCfd, ImageId = synth.ImageId, UpdateAt = synth.UpdateAt, LastUpdatedAt = synth.LastUpdatedAt, Tags = dtoTags,
DerivativeProductCategories = synth.DerivativeProductCategories
},
_ => throw new NotSupportedException($"Mapping for type {entity.GetType().Name} is not supported.")
};
}
/// <summary>
/// Mappt direkt eine ganze Liste von AssetEntities.
/// </summary>
public static List<AssetDto> ToDtoList(this IEnumerable<AssetEntity> entities)
{
return entities.Select(e => e.ToDto()).ToList();
}
}
+307
View File
@@ -0,0 +1,307 @@
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 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).
/// </summary>
public abstract class ManagedMqttClient : IDisposable
{
private readonly ILogger<ManagedMqttClient> _logger;
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)
{
_logger = logger;
_mqttClient = new MqttClientFactory().CreateMqttClient();
_mqttClient.ApplicationMessageReceivedAsync += HandleIncomingMessageAsync;
_mqttClient.DisconnectedAsync += HandleDisconnectAsync;
}
/// <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();
_logger.LogInformation("Connecting to MQTT broker at {Host}:{Port}...", config.Host, config.Port);
try
{
await _mqttClient.ConnectAsync(options, _cts.Token);
_logger.LogInformation("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
});
_logger.LogInformation("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);
_logger.LogDebug("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.
/// </summary>
public Task PublishAsync<T>(string topic, T data, bool retain = false)
{
var jsonOptions = new JsonSerializerOptions
{
ReferenceHandler = ReferenceHandler.IgnoreCycles
};
var json = JsonSerializer.Serialize(data, jsonOptions);
return PublishAsync(topic, json, retain);
}
/// <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>
/// <typeparam name="TResponse">The expected strongly-typed object type of the reply.</typeparam>
/// <typeparam name="TRequest">The type of the payload being transmitted.</typeparam>
/// <param name="channel">The target sub-channel or service name (e.g., "sentix", "assets").</param>
/// <param name="requestData">The object that will be serialized to JSON and sent.</param>
/// <param name="timeout">Optional. Maximum time to wait before returning null. Defaults to 10 seconds.</param>
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);
_logger.LogDebug("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(10);
var rawJsonResult = await tcs.Task.WaitAsync(effectiveTimeout);
if (typeof(TResponse) == typeof(string))
{
return rawJsonResult 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 async Task HandleIncomingMessageAsync(MqttApplicationMessageReceivedEventArgs e)
{
try
{
var topic = e.ApplicationMessage.Topic;
var payload = Encoding.UTF8.GetString(e.ApplicationMessage.Payload);
// 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);
return; // Sinks the message, avoiding triggering OnMessageReceivedAsync for active RPC handles
}
}
}
// Regular Pub/Sub message propagation
await OnMessageReceivedAsync(topic, payload);
}
catch (Exception ex)
{
OnError(ex);
}
}
private async Task HandleDisconnectAsync(MqttClientDisconnectedEventArgs e)
{
// Prevent trigger during deliberate connection shutdowns
if (_cts == null || _cts.IsCancellationRequested)
return;
_logger.LogWarning("Lost connection to MQTT broker (Reason: {Reason}). Initiating auto-reconnect loop in 5 seconds...", e.Reason);
try
{
await Task.Delay(TimeSpan.FromSeconds(5), _cts.Token);
await _mqttClient.ReconnectAsync(_cts.Token);
if (_mqttClient.IsConnected)
{
_logger.LogInformation("MQTT client reconnected successfully.");
await OnConnectedAsync();
}
}
catch (OperationCanceledException) { /* Expected swallow on application shutdown */ }
catch (Exception ex)
{
_logger.LogError(ex, "Reconnection attempt to the MQTT broker failed.");
}
}
/// <summary>
/// Fired automatically whenever a connection or reconnection is successfully established.
/// Ideal place to trigger <see cref="SubscribeAsync"/> operations.
/// </summary>
protected abstract Task OnConnectedAsync();
/// <summary>
/// Fired whenever a new message lands on a registered subscription channel.
/// </summary>
/// <param name="topic">The specific topic where the message was broadcasted.</param>
/// <param name="payload">The deserialized UTF-8 payload string.</param>
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);
}
}
+234
View File
@@ -0,0 +1,234 @@
namespace FinlyticAssets.Util;
using System;
using System.IO;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
public abstract class ManagedWebSocket : IDisposable
{
private ClientWebSocket? _webSocket;
private CancellationTokenSource? _cts;
private Task? _receiveTask;
private Task? _keepAliveTask;
/// <summary>
/// Gets or sets the interval for keep-alive messages.
/// If set to null, the periodic timer is not started and no life messages are sent.
/// </summary>
public TimeSpan? KeepAliveInterval { get; set; }
/// <summary>
/// Gets a value indicating whether the WebSocket is currently open.
/// </summary>
public bool IsConnected => _webSocket?.State == WebSocketState.Open;
/// <summary>
/// Gets the current state of the WebSocket connection.
/// </summary>
public WebSocketState State => _webSocket?.State ?? WebSocketState.None;
/// <summary>
/// Connects to the specified WebSocket URI and starts the background tasks for receiving and keep-alive.
/// </summary>
/// <param name="uri">The URI of the WebSocket server to connect to.</param>
/// <param name="keepAliveInterval">Optional. Sets or overrides the keep-alive interval for this connection.</param>
public async Task ConnectAsync(string uri, TimeSpan? keepAliveInterval = null)
{
if (IsConnected)
throw new InvalidOperationException("WebSocket is already connected.");
if (keepAliveInterval.HasValue)
{
KeepAliveInterval = keepAliveInterval;
}
_webSocket = new ClientWebSocket();
_cts = new CancellationTokenSource();
await _webSocket.ConnectAsync(new Uri(uri), _cts.Token);
_receiveTask = ReceiveLoopAsync(_cts.Token);
if (KeepAliveInterval.HasValue && KeepAliveInterval.Value > TimeSpan.Zero)
{
_keepAliveTask = KeepAliveLoopAsync(_cts.Token);
}
}
/// <summary>
/// Closes the WebSocket connection gracefully and cancels all running background tasks.
/// </summary>
public async Task DisconnectAsync()
{
if (_webSocket != null)
{
if (_cts != null)
{
await _cts.CancelAsync();
}
var tasksToWait = new List<Task>();
if (_receiveTask != null) tasksToWait.Add(_receiveTask);
if (_keepAliveTask != null) tasksToWait.Add(_keepAliveTask);
if (tasksToWait.Count > 0)
{
try
{
await Task.WhenAll(tasksToWait);
}
catch (OperationCanceledException) { /* Ignore */ }
catch { /* Ignore */ }
}
if (_webSocket.State == WebSocketState.Open)
{
try
{
await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Connection closed by client", CancellationToken.None);
}
catch { /* Ignore exceptions during closure */ }
}
_webSocket.Dispose();
_webSocket = null;
_receiveTask = null;
_keepAliveTask = null;
}
}
/// <summary>
/// Sends a raw string message over the WebSocket connection.
/// </summary>
/// <param name="message">The text message to send.</param>
public async Task SendAsync(string message)
{
if (!IsConnected || _webSocket == null || _cts == null)
throw new InvalidOperationException("WebSocket is not connected.");
var buffer = Encoding.UTF8.GetBytes(message);
await _webSocket.SendAsync(new ArraySegment<byte>(buffer), WebSocketMessageType.Text, true, _cts.Token);
}
/// <summary>
/// Serializes the given generic object to JSON and sends it over the WebSocket connection.
/// </summary>
/// <typeparam name="T">The type of the object to send.</typeparam>
/// <param name="data">The object to serialize and send.</param>
public Task SendAsync<T>(T data)
{
var json = JsonSerializer.Serialize(data);
return SendAsync(json);
}
private async Task ReceiveLoopAsync(CancellationToken cancellationToken)
{
var buffer = new byte[8192];
try
{
while (_webSocket?.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
{
using var ms = new MemoryStream();
WebSocketReceiveResult result;
do
{
result = await _webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationToken);
ms.Write(buffer, 0, result.Count);
}
while (!result.EndOfMessage);
if (result.MessageType == WebSocketMessageType.Close)
{
await DisconnectAsync();
break;
}
if (result.MessageType == WebSocketMessageType.Text)
{
var message = Encoding.UTF8.GetString(ms.ToArray());
if (!IsKeepAliveMessage(message))
{
OnMessageReceived(message);
}
}
}
}
catch (OperationCanceledException) { /* Expected behavior when cancellation is requested */ }
catch (Exception ex)
{
OnError(ex);
}
}
private async Task KeepAliveLoopAsync(CancellationToken cancellationToken)
{
if (!KeepAliveInterval.HasValue) return;
using var timer = new PeriodicTimer(KeepAliveInterval.Value);
try
{
while (await timer.WaitForNextTickAsync(cancellationToken))
{
if (IsConnected)
{
await SendLifeMessageAsync();
}
}
}
catch (OperationCanceledException) { /* Expected behavior when cancellation is requested */ }
}
/// <summary>
/// Triggered when a new text message is received that is not filtered as a keep-alive message.
/// </summary>
/// <param name="message">The received message content.</param>
protected abstract void OnMessageReceived(string message);
/// <summary>
/// Triggered periodically based on the KeepAliveInterval to send a life message or ping to the server.
/// Override this method in the derived class if you want to send actual keep-alive payloads.
/// </summary>
protected virtual Task SendLifeMessageAsync()
{
// Default implementation does nothing.
return Task.CompletedTask;
}
/// <summary>
/// Determines whether the incoming message is a keep-alive response (e.g., a "pong").
/// If true is returned, the message is filtered out and OnMessageReceived is not triggered.
/// </summary>
/// <param name="message">The incoming text message.</param>
/// <returns>True if the message should be filtered; otherwise, false.</returns>
protected virtual bool IsKeepAliveMessage(string message)
{
// Default implementation does not filter any messages.
return false;
}
/// <summary>
/// Optionally overridable method to handle exceptions that occur within the receive loop.
/// </summary>
/// <param name="ex">The exception that was caught.</param>
protected virtual void OnError(Exception ex)
{
// Default implementation is empty. Can be overridden in the derived class.
}
/// <summary>
/// Disposes the WebSocket connection and releases all associated resources.
/// </summary>
public void Dispose()
{
DisconnectAsync().Wait();
_cts?.Dispose();
}
}