Init
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user