369 lines
12 KiB
C#
369 lines
12 KiB
C#
using EonaCat.Json;
|
|
using EonaCat.Json.Linq;
|
|
using EonaCat.Connections.Models;
|
|
using System.Collections.Concurrent;
|
|
using System.Text;
|
|
using System.Timers;
|
|
|
|
namespace EonaCat.Connections.Processors
|
|
{
|
|
public sealed class JsonDataProcessor<TData> : IDisposable
|
|
{
|
|
public int MaxAllowedBufferSize = 20 * 1024 * 1024;
|
|
public int MaxMessagesPerBatch = 200;
|
|
private const int MaxCleanupRemovalsPerTick = 50;
|
|
|
|
private readonly ConcurrentDictionary<string, BufferEntry> _buffers = new();
|
|
private readonly System.Timers.Timer _cleanupTimer;
|
|
private readonly TimeSpan _clientBufferTimeout = TimeSpan.FromMinutes(5);
|
|
private bool _isDisposed;
|
|
|
|
public string ClientName { get; }
|
|
|
|
private sealed class BufferEntry
|
|
{
|
|
public readonly StringBuilder Buffer = new();
|
|
public DateTime LastUsed = DateTime.UtcNow;
|
|
public readonly object SyncRoot = new();
|
|
|
|
public void Clear(bool shrink = false)
|
|
{
|
|
Buffer.Clear();
|
|
if (shrink && Buffer.Capacity > 1024)
|
|
{
|
|
Buffer.Capacity = 1024;
|
|
}
|
|
}
|
|
}
|
|
|
|
public event EventHandler<ProcessedMessage<TData>>? OnProcessMessage;
|
|
|
|
public event EventHandler<ProcessedTextMessage>? OnProcessTextMessage;
|
|
|
|
public event EventHandler<Exception>? OnMessageError;
|
|
|
|
public event EventHandler<Exception>? OnError;
|
|
|
|
public JsonDataProcessor()
|
|
{
|
|
ClientName = Guid.NewGuid().ToString();
|
|
|
|
_cleanupTimer = new System.Timers.Timer(Math.Max(5000, _clientBufferTimeout.TotalMilliseconds / 5))
|
|
{
|
|
AutoReset = true
|
|
};
|
|
_cleanupTimer.Elapsed += CleanupInactiveClients;
|
|
_cleanupTimer.Start();
|
|
}
|
|
|
|
public void Process(DataReceivedEventArgs e, string? currentClientName = null)
|
|
{
|
|
ThrowIfDisposed();
|
|
if (e == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
string endpoint = e.RemoteEndPoint?.ToString();
|
|
string dataString = e.IsBinary ? Encoding.UTF8.GetString(e.Data ?? Array.Empty<byte>()) : e.StringData;
|
|
if (string.IsNullOrWhiteSpace(dataString))
|
|
{
|
|
return;
|
|
}
|
|
|
|
string client = e.Nickname ?? currentClientName ?? ClientName;
|
|
ProcessInternal(dataString.Trim(), client, endpoint);
|
|
}
|
|
|
|
public void Process(string jsonString, string? currentClientName = null, string? endpoint = null)
|
|
{
|
|
ThrowIfDisposed();
|
|
if (string.IsNullOrWhiteSpace(jsonString))
|
|
{
|
|
return;
|
|
}
|
|
|
|
string client = currentClientName ?? ClientName;
|
|
ProcessInternal(jsonString.Trim(), client, endpoint);
|
|
}
|
|
|
|
private void ProcessInternal(string jsonString, string clientName, string? clientEndpoint)
|
|
{
|
|
var bufferEntry = _buffers.GetOrAdd(clientName, _ => new BufferEntry());
|
|
var pendingJson = new List<string>();
|
|
var pendingText = new List<string>();
|
|
|
|
lock (bufferEntry.SyncRoot)
|
|
{
|
|
// Check for buffer overflow
|
|
if (bufferEntry.Buffer.Length > MaxAllowedBufferSize)
|
|
{
|
|
OnError?.Invoke(this, new Exception($"Buffer overflow ({MaxAllowedBufferSize} bytes) for client {clientName} ({clientEndpoint})."));
|
|
bufferEntry.Clear(shrink: true);
|
|
}
|
|
|
|
bufferEntry.Buffer.Append(jsonString);
|
|
bufferEntry.LastUsed = DateTime.UtcNow;
|
|
|
|
int processedCount = 0;
|
|
|
|
while (processedCount < MaxMessagesPerBatch &&
|
|
JsonDataProcessorHelper.TryExtractCompleteJson(bufferEntry.Buffer, out string[] json, out string[] nonJsonText))
|
|
{
|
|
// No more messages
|
|
if ((json == null || json.Length == 0) && (nonJsonText == null || nonJsonText.Length == 0))
|
|
{
|
|
break;
|
|
}
|
|
|
|
if (json != null && json.Length > 0)
|
|
{
|
|
foreach (var jsonMessage in json)
|
|
{
|
|
pendingJson.Add(jsonMessage);
|
|
processedCount++;
|
|
if (processedCount >= MaxMessagesPerBatch)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (nonJsonText != null && nonJsonText.Length > 0)
|
|
{
|
|
foreach (var textMessage in nonJsonText)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(textMessage))
|
|
{
|
|
pendingText.Add(textMessage);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cleanup buffer if needed
|
|
if (bufferEntry.Buffer.Capacity > MaxAllowedBufferSize / 2)
|
|
{
|
|
bufferEntry.Clear(shrink: true);
|
|
}
|
|
|
|
if (bufferEntry.Buffer.Length > 0 && !ContainsJsonStructure(bufferEntry.Buffer))
|
|
{
|
|
string leftover = bufferEntry.Buffer.ToString();
|
|
bufferEntry.Clear(shrink: true);
|
|
if (!string.IsNullOrWhiteSpace(leftover))
|
|
{
|
|
pendingText.Add(leftover);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (pendingText.Count > 0)
|
|
{
|
|
foreach (var textMessage in pendingText)
|
|
{
|
|
try
|
|
{
|
|
OnProcessTextMessage?.Invoke(this, new ProcessedTextMessage { Text = textMessage, ClientName = clientName, ClientEndpoint = clientEndpoint });
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
OnError?.Invoke(this, new Exception($"ProcessTextMessage handler threw for client {clientName} ({clientEndpoint}).", exception));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (pendingJson.Count > 0)
|
|
{
|
|
foreach (var jsonMessage in pendingJson)
|
|
{
|
|
ProcessDataReceived(jsonMessage, clientName, clientEndpoint);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void ProcessDataReceived(string data, string clientName, string? clientEndpoint)
|
|
{
|
|
ThrowIfDisposed();
|
|
if (string.IsNullOrWhiteSpace(data))
|
|
{
|
|
return;
|
|
}
|
|
|
|
bool looksLikeJson = data.Length > 1 &&
|
|
((data[0] == '{' && data[data.Length - 1] == '}') ||
|
|
(data[0] == '[' && data[data.Length - 1] == ']'));
|
|
|
|
if (!looksLikeJson)
|
|
{
|
|
try
|
|
{
|
|
OnProcessTextMessage?.Invoke(this, new ProcessedTextMessage { Text = data, ClientName = clientName, ClientEndpoint = clientEndpoint });
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
OnError?.Invoke(this, new Exception($"ProcessTextMessage handler threw for client {clientName} ({clientEndpoint}).", exception));
|
|
}
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
if (data.Contains("Exception", StringComparison.OrdinalIgnoreCase) ||
|
|
data.Contains("Error", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
GetError(data);
|
|
}
|
|
|
|
var messages = JsonHelper.ToObjects<TData>(data);
|
|
if (messages != null)
|
|
{
|
|
foreach (var message in messages)
|
|
{
|
|
try
|
|
{
|
|
OnProcessMessage?.Invoke(this, new ProcessedMessage<TData> { Data = message, RawData = data, ClientName = clientName, ClientEndpoint = clientEndpoint });
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
OnError?.Invoke(this, new Exception($"ProcessMessage handler threw for client {clientName} ({clientEndpoint}).", exception));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
OnError?.Invoke(this, new Exception($"Failed to process JSON message from {clientName} ({clientEndpoint}).", exception));
|
|
}
|
|
}
|
|
|
|
private void GetError(string data)
|
|
{
|
|
try
|
|
{
|
|
var jsonObject = JObject.Parse(data);
|
|
var exceptionToken = jsonObject.SelectToken("Exception");
|
|
if (exceptionToken != null && exceptionToken.Type != JTokenType.Null)
|
|
{
|
|
var extracted = JsonHelper.ExtractException(data);
|
|
if (extracted != null)
|
|
{
|
|
OnMessageError?.Invoke(this, new Exception(extracted.Message));
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Do nothing
|
|
}
|
|
}
|
|
|
|
private static bool ContainsJsonStructure(StringBuilder buffer)
|
|
{
|
|
for (int i = 0; i < buffer.Length; i++)
|
|
{
|
|
char c = buffer[i];
|
|
if (c == '{' || c == '[')
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private void CleanupInactiveClients(object? sender, ElapsedEventArgs e)
|
|
{
|
|
if (_isDisposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
DateTime now = DateTime.UtcNow;
|
|
var keysToRemove = new List<string>(capacity: 128);
|
|
|
|
foreach (var kvp in _buffers)
|
|
{
|
|
if (now - kvp.Value.LastUsed > _clientBufferTimeout)
|
|
{
|
|
keysToRemove.Add(kvp.Key);
|
|
if (keysToRemove.Count >= MaxCleanupRemovalsPerTick)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove the selected keys
|
|
foreach (var key in keysToRemove)
|
|
{
|
|
if (_buffers.TryRemove(key, out var removed))
|
|
{
|
|
lock (removed.SyncRoot)
|
|
{
|
|
removed.Clear(shrink: true);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public void RemoveClient(string clientName)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(clientName))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_buffers.TryRemove(clientName, out var removed))
|
|
{
|
|
lock (removed.SyncRoot)
|
|
{
|
|
removed.Clear(shrink: true);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void ThrowIfDisposed()
|
|
{
|
|
if (_isDisposed)
|
|
{
|
|
throw new ObjectDisposedException(nameof(JsonDataProcessor<TData>));
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_isDisposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_isDisposed = true;
|
|
|
|
_cleanupTimer.Elapsed -= CleanupInactiveClients;
|
|
_cleanupTimer.Stop();
|
|
_cleanupTimer.Dispose();
|
|
|
|
foreach (var entry in _buffers.Values)
|
|
{
|
|
lock (entry.SyncRoot)
|
|
{
|
|
entry.Clear(shrink: true);
|
|
}
|
|
}
|
|
|
|
_buffers.Clear();
|
|
OnProcessMessage = null;
|
|
OnProcessTextMessage = null;
|
|
OnMessageError = null;
|
|
OnError = null;
|
|
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
}
|
|
|
|
internal static class StringExtensions
|
|
{
|
|
internal static bool Contains(this string? source, string toCheck, StringComparison comp) =>
|
|
source?.IndexOf(toCheck, comp) >= 0;
|
|
}
|
|
} |