Compare commits

...

3 Commits

Author SHA1 Message Date
Loretta 2f1c00fd5c Remove MessagePack; unify SignalR serialization model
Major refactor to eliminate MessagePack from SignalR messaging.
All serialization now uses explicit binary methods (.ToBinary/.BinaryTo)
and Brotli-compressed JSON, managed via a new SignalRSerializationHelper.
Custom stream classes and MessagePack attributes are removed.
API is now consistent, type-safe, and easier to maintain.
Test code and all message handling updated to use the new model.
2025-12-14 01:45:17 +01:00
Loretta 489ef7486c Optimize JSON/Brotli serialization for zero-allocation
Major performance improvements for SignalR message transport:
- BrotliHelper now uses ArrayPool and stackalloc for zero-allocation compression/decompression; added span/IBufferWriter overloads and pooled buffer support.
- AcJsonDeserializer supports direct deserialization from UTF-8 spans, with fast path for no reference handling.
- SignalResponseDataMessage uses pooled buffers for Brotli decompression and zero-copy deserialization; implements IDisposable for buffer return.
- IdMessage serialization optimized for primitives and Guids to avoid unnecessary allocations.
- Added JsonTo<T> span/byte[] extension methods for zero-allocation deserialization.
- All changes are backward compatible and reduce GC pressure for high-throughput scenarios.
2025-12-13 23:23:16 +01:00
Loretta ac6735ebd8 Unify SignalR response serialization (JSON/Binary/Brotli)
Major refactor: all SignalR responses now use a single unified `SignalResponseDataMessage` type with binary payloads. JSON responses are Brotli-compressed for efficiency. Removed legacy JSON/Binary response types and MessagePack server-to-client serialization. Updated all serialization extensions for zero-allocation binary ops. Refactored client/server/data source/test code to use new message and serialization model. Improved deserialization robustness for primitives. Modernized and streamlined test suite.
2025-12-13 23:01:18 +01:00
23 changed files with 1268 additions and 5335 deletions

View File

@ -0,0 +1,193 @@
using System.Buffers;
using System.IO.Compression;
using System.Runtime.CompilerServices;
using System.Text;
namespace AyCode.Core.Compression;
/// <summary>
/// Brotli compression/decompression helper for SignalR message transport.
/// Optimized for zero-allocation scenarios with pooled buffers.
/// </summary>
public static class BrotliHelper
{
private const int DefaultBufferSize = 4096;
private const int MaxStackAllocSize = 1024;
#region Compression
/// <summary>
/// Compresses a string using Brotli compression with pooled buffers.
/// </summary>
public static byte[] Compress(string text, CompressionLevel compressionLevel = CompressionLevel.Optimal)
{
if (string.IsNullOrEmpty(text))
return [];
// Use stack allocation for small strings, pooled buffer for larger
var maxByteCount = Encoding.UTF8.GetMaxByteCount(text.Length);
if (maxByteCount <= MaxStackAllocSize)
{
Span<byte> utf8Bytes = stackalloc byte[maxByteCount];
var actualLength = Encoding.UTF8.GetBytes(text.AsSpan(), utf8Bytes);
return CompressSpan(utf8Bytes[..actualLength], compressionLevel);
}
var rentedBuffer = ArrayPool<byte>.Shared.Rent(maxByteCount);
try
{
var actualLength = Encoding.UTF8.GetBytes(text.AsSpan(), rentedBuffer);
return CompressSpan(rentedBuffer.AsSpan(0, actualLength), compressionLevel);
}
finally
{
ArrayPool<byte>.Shared.Return(rentedBuffer);
}
}
/// <summary>
/// Compresses a byte array using Brotli compression.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static byte[] Compress(byte[] data, CompressionLevel compressionLevel = CompressionLevel.Optimal)
=> data == null || data.Length == 0 ? [] : CompressSpan(data.AsSpan(), compressionLevel);
/// <summary>
/// Compresses a ReadOnlySpan using Brotli compression with pooled output buffer.
/// </summary>
public static byte[] CompressSpan(ReadOnlySpan<byte> data, CompressionLevel compressionLevel = CompressionLevel.Optimal)
{
if (data.IsEmpty)
return [];
using var outputStream = new MemoryStream();
using (var brotliStream = new BrotliStream(outputStream, compressionLevel, leaveOpen: true))
{
brotliStream.Write(data);
}
return outputStream.ToArray();
}
#endregion
#region Decompression
/// <summary>
/// Decompresses Brotli-compressed data to a string.
/// Consider using Decompress + direct UTF-8 deserialization for better performance.
/// </summary>
public static string DecompressToString(byte[] compressedData)
{
if (compressedData == null || compressedData.Length == 0)
return string.Empty;
var decompressedBytes = Decompress(compressedData);
return Encoding.UTF8.GetString(decompressedBytes);
}
/// <summary>
/// Decompresses Brotli-compressed data to a byte array.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static byte[] Decompress(byte[] compressedData)
{
if (compressedData == null || compressedData.Length == 0)
return [];
return DecompressCore(compressedData);
}
/// <summary>
/// Decompresses Brotli-compressed data from a ReadOnlySpan.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static byte[] DecompressSpan(ReadOnlySpan<byte> compressedData)
{
if (compressedData.IsEmpty)
return [];
return DecompressCore(compressedData.ToArray());
}
private static byte[] DecompressCore(byte[] compressedData)
{
using var inputStream = new MemoryStream(compressedData, writable: false);
using var brotliStream = new BrotliStream(inputStream, CompressionMode.Decompress);
using var outputStream = new MemoryStream();
brotliStream.CopyTo(outputStream);
return outputStream.ToArray();
}
/// <summary>
/// Decompresses to a rented buffer. Caller must return the buffer to ArrayPool.
/// Returns the actual decompressed length.
/// </summary>
public static (byte[] Buffer, int Length) DecompressToRentedBuffer(ReadOnlySpan<byte> compressedData)
{
if (compressedData.IsEmpty)
return ([], 0);
// Estimate decompressed size (typically 3-10x compressed for text)
var estimatedSize = Math.Max(compressedData.Length * 4, DefaultBufferSize);
var outputBuffer = ArrayPool<byte>.Shared.Rent(estimatedSize);
using var inputStream = new MemoryStream(compressedData.ToArray(), writable: false);
using var brotliStream = new BrotliStream(inputStream, CompressionMode.Decompress);
var totalRead = 0;
int bytesRead;
while ((bytesRead = brotliStream.Read(outputBuffer.AsSpan(totalRead))) > 0)
{
totalRead += bytesRead;
// Need larger buffer
if (totalRead >= outputBuffer.Length - DefaultBufferSize)
{
var newBuffer = ArrayPool<byte>.Shared.Rent(outputBuffer.Length * 2);
outputBuffer.AsSpan(0, totalRead).CopyTo(newBuffer);
ArrayPool<byte>.Shared.Return(outputBuffer);
outputBuffer = newBuffer;
}
}
return (outputBuffer, totalRead);
}
#endregion
#region Utility
/// <summary>
/// Checks if the data appears to be Brotli compressed.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsBrotliCompressed(byte[] data)
{
if (data == null || data.Length < 4)
return false;
var firstByte = data[0];
// If it starts with '{' (0x7B) or '[' (0x5B), it's likely uncompressed JSON
if (firstByte == 0x7B || firstByte == 0x5B)
return false;
// Try to decompress - if it fails, it's not Brotli
try
{
using var inputStream = new MemoryStream(data, 0, Math.Min(data.Length, 64), writable: false);
using var brotliStream = new BrotliStream(inputStream, CompressionMode.Decompress);
Span<byte> buffer = stackalloc byte[1];
return brotliStream.Read(buffer) >= 0;
}
catch
{
return false;
}
}
#endregion
}

View File

@ -51,6 +51,115 @@ public static class AcJsonDeserializer
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static T? Deserialize<T>(string json) => Deserialize<T>(json, AcJsonSerializerOptions.Default); public static T? Deserialize<T>(string json) => Deserialize<T>(json, AcJsonSerializerOptions.Default);
/// <summary>
/// Deserialize UTF-8 encoded JSON bytes to a new object of type T with default options.
/// Zero-allocation path when used with Utf8JsonReader.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static T? Deserialize<T>(ReadOnlySpan<byte> utf8Json) => Deserialize<T>(utf8Json, AcJsonSerializerOptions.Default);
/// <summary>
/// Deserialize UTF-8 encoded JSON bytes to a new object of type T with specified options.
/// Zero-allocation path when used with Utf8JsonReader.
/// </summary>
public static T? Deserialize<T>(ReadOnlySpan<byte> utf8Json, in AcJsonSerializerOptions options)
{
if (utf8Json.IsEmpty) return default;
// Check for "null" literal
if (utf8Json.Length == 4 && utf8Json.SequenceEqual("null"u8)) return default;
var targetType = typeof(T);
try
{
// Fast path for no reference handling - use Utf8JsonReader directly (no string allocation)
if (!options.UseReferenceHandling)
{
var reader = new Utf8JsonReader(utf8Json, new JsonReaderOptions { MaxDepth = options.MaxDepth });
if (!reader.Read()) return default;
return (T?)ReadValueFromReader(ref reader, targetType, options.MaxDepth, 0);
}
// Reference handling requires DOM - copy to array for JsonDocument.Parse
var jsonBytes = utf8Json.ToArray();
using var doc = JsonDocument.Parse(jsonBytes);
var context = DeserializationContextPool.Get(options);
try
{
var result = ReadValue(doc.RootElement, targetType, context, 0);
context.ResolveReferences();
return (T?)result;
}
finally
{
DeserializationContextPool.Return(context);
}
}
catch (AcJsonDeserializationException) { throw; }
catch (System.Text.Json.JsonException ex)
{
throw new AcJsonDeserializationException($"Failed to parse JSON for type '{targetType.Name}': {ex.Message}", null, targetType, ex);
}
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
{
throw new AcJsonDeserializationException($"Failed to convert JSON value for type '{targetType.Name}': {ex.Message}", null, targetType, ex);
}
}
/// <summary>
/// Deserialize UTF-8 encoded JSON bytes to specified type with default options.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static object? Deserialize(ReadOnlySpan<byte> utf8Json, Type targetType)
=> Deserialize(utf8Json, targetType, AcJsonSerializerOptions.Default);
/// <summary>
/// Deserialize UTF-8 encoded JSON bytes to specified type with specified options.
/// </summary>
public static object? Deserialize(ReadOnlySpan<byte> utf8Json, in Type targetType, in AcJsonSerializerOptions options)
{
if (utf8Json.IsEmpty) return null;
// Check for "null" literal
if (utf8Json.Length == 4 && utf8Json.SequenceEqual("null"u8)) return null;
try
{
// Fast path for no reference handling
if (!options.UseReferenceHandling)
{
var reader = new Utf8JsonReader(utf8Json, new JsonReaderOptions { MaxDepth = options.MaxDepth });
if (!reader.Read()) return null;
return ReadValueFromReader(ref reader, targetType, options.MaxDepth, 0);
}
// Reference handling requires DOM - copy to array for JsonDocument.Parse
var jsonBytes = utf8Json.ToArray();
using var doc = JsonDocument.Parse(jsonBytes);
var context = DeserializationContextPool.Get(options);
try
{
var result = ReadValue(doc.RootElement, targetType, context, 0);
context.ResolveReferences();
return result;
}
finally
{
DeserializationContextPool.Return(context);
}
}
catch (AcJsonDeserializationException) { throw; }
catch (System.Text.Json.JsonException ex)
{
throw new AcJsonDeserializationException($"Failed to parse JSON for type '{targetType.Name}': {ex.Message}", null, targetType, ex);
}
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
{
throw new AcJsonDeserializationException($"Failed to convert JSON value for type '{targetType.Name}': {ex.Message}", null, targetType, ex);
}
}
/// <summary> /// <summary>
/// Deserialize JSON string to a new object of type T with specified options. /// Deserialize JSON string to a new object of type T with specified options.
/// </summary> /// </summary>
@ -131,7 +240,9 @@ public static class AcJsonDeserializer
return DeserializeWithUtf8ReaderNonGeneric(json, targetType, options.MaxDepth); return DeserializeWithUtf8ReaderNonGeneric(json, targetType, options.MaxDepth);
} }
using var doc = JsonDocument.Parse(json); // Reference handling requires DOM - copy to array for JsonDocument.Parse
var jsonBytes = Encoding.UTF8.GetBytes(json);
using var doc = JsonDocument.Parse(jsonBytes);
var context = DeserializationContextPool.Get(options); var context = DeserializationContextPool.Get(options);
try try
{ {
@ -1323,12 +1434,24 @@ public static class AcJsonDeserializer
case TypeCode.Decimal: result = decimal.Parse(json, CultureInfo.InvariantCulture); return true; case TypeCode.Decimal: result = decimal.Parse(json, CultureInfo.InvariantCulture); return true;
case TypeCode.Single: result = float.Parse(json, CultureInfo.InvariantCulture); return true; case TypeCode.Single: result = float.Parse(json, CultureInfo.InvariantCulture); return true;
case TypeCode.String: case TypeCode.String:
// If already unwrapped (no quotes), return as-is; otherwise parse JSON
if (json.Length == 0 || json[0] != '"')
{
result = json;
return true;
}
using (var doc = JsonDocument.Parse(json)) using (var doc = JsonDocument.Parse(json))
{ {
result = doc.RootElement.GetString(); result = doc.RootElement.GetString();
return true; return true;
} }
case TypeCode.DateTime: case TypeCode.DateTime:
// If already unwrapped (no quotes), parse directly; otherwise use JSON parser
if (json.Length == 0 || json[0] != '"')
{
result = DateTime.Parse(json, CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind);
return true;
}
using (var doc = JsonDocument.Parse(json)) using (var doc = JsonDocument.Parse(json))
{ {
result = doc.RootElement.GetDateTime(); result = doc.RootElement.GetDateTime();
@ -1341,6 +1464,11 @@ public static class AcJsonDeserializer
case TypeCode.UInt64: result = ulong.Parse(json, CultureInfo.InvariantCulture); return true; case TypeCode.UInt64: result = ulong.Parse(json, CultureInfo.InvariantCulture); return true;
case TypeCode.SByte: result = sbyte.Parse(json, CultureInfo.InvariantCulture); return true; case TypeCode.SByte: result = sbyte.Parse(json, CultureInfo.InvariantCulture); return true;
case TypeCode.Char: case TypeCode.Char:
if (json.Length == 0 || json[0] != '"')
{
result = json.Length > 0 ? json[0] : '\0';
return true;
}
using (var doc = JsonDocument.Parse(json)) using (var doc = JsonDocument.Parse(json))
{ {
var s = doc.RootElement.GetString(); var s = doc.RootElement.GetString();
@ -1351,6 +1479,12 @@ public static class AcJsonDeserializer
if (ReferenceEquals(type, GuidType)) if (ReferenceEquals(type, GuidType))
{ {
// If already unwrapped (no quotes), parse directly
if (json.Length == 0 || json[0] != '"')
{
result = Guid.Parse(json);
return true;
}
using var doc = JsonDocument.Parse(json); using var doc = JsonDocument.Parse(json);
result = doc.RootElement.GetGuid(); result = doc.RootElement.GetGuid();
return true; return true;
@ -1358,6 +1492,12 @@ public static class AcJsonDeserializer
if (ReferenceEquals(type, DateTimeOffsetType)) if (ReferenceEquals(type, DateTimeOffsetType))
{ {
// If already unwrapped (no quotes), parse directly
if (json.Length == 0 || json[0] != '"')
{
result = DateTimeOffset.Parse(json, CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind);
return true;
}
using var doc = JsonDocument.Parse(json); using var doc = JsonDocument.Parse(json);
result = doc.RootElement.GetDateTimeOffset(); result = doc.RootElement.GetDateTimeOffset();
return true; return true;
@ -1365,6 +1505,12 @@ public static class AcJsonDeserializer
if (ReferenceEquals(type, TimeSpanType)) if (ReferenceEquals(type, TimeSpanType))
{ {
// If already unwrapped (no quotes), parse directly
if (json.Length == 0 || json[0] != '"')
{
result = TimeSpan.Parse(json, CultureInfo.InvariantCulture);
return true;
}
using var doc = JsonDocument.Parse(json); using var doc = JsonDocument.Parse(json);
result = TimeSpan.Parse(doc.RootElement.GetString()!, CultureInfo.InvariantCulture); result = TimeSpan.Parse(doc.RootElement.GetString()!, CultureInfo.InvariantCulture);
return true; return true;

View File

@ -1,10 +1,10 @@
using System.Collections.Concurrent; using System.Buffers;
using System.Collections.Concurrent;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Runtime.Serialization; using System.Runtime.Serialization;
using System.Text; using System.Text;
using AyCode.Core.Interfaces; using AyCode.Core.Interfaces;
using MessagePack;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Serialization; using Newtonsoft.Json.Serialization;
using static AyCode.Core.Extensions.JsonUtilities; using static AyCode.Core.Extensions.JsonUtilities;
@ -337,6 +337,8 @@ public static class SerializeObjectExtensions
Formatting = Formatting.None, Formatting = Formatting.None,
}; };
#region JSON Serialization
/// <summary> /// <summary>
/// Serialize object to JSON string with default options. /// Serialize object to JSON string with default options.
/// </summary> /// </summary>
@ -378,6 +380,34 @@ public static class SerializeObjectExtensions
return AcJsonDeserializer.Deserialize<T>(json, options); return AcJsonDeserializer.Deserialize<T>(json, options);
} }
/// <summary>
/// Deserialize UTF-8 encoded JSON bytes to object with default options.
/// Zero-allocation path - no string conversion needed.
/// </summary>
public static T? JsonTo<T>(this ReadOnlySpan<byte> utf8Json)
=> AcJsonDeserializer.Deserialize<T>(utf8Json);
/// <summary>
/// Deserialize UTF-8 encoded JSON bytes to object with specified options.
/// Zero-allocation path - no string conversion needed.
/// </summary>
public static T? JsonTo<T>(this ReadOnlySpan<byte> utf8Json, AcJsonSerializerOptions options)
=> AcJsonDeserializer.Deserialize<T>(utf8Json, options);
/// <summary>
/// Deserialize UTF-8 encoded JSON bytes to object with default options.
/// Zero-allocation path - no string conversion needed.
/// </summary>
public static T? JsonTo<T>(this byte[] utf8Json)
=> AcJsonDeserializer.Deserialize<T>(utf8Json.AsSpan());
/// <summary>
/// Deserialize UTF-8 encoded JSON bytes to object with specified options.
/// Zero-allocation path - no string conversion needed.
/// </summary>
public static T? JsonTo<T>(this byte[] utf8Json, AcJsonSerializerOptions options)
=> AcJsonDeserializer.Deserialize<T>(utf8Json.AsSpan(), options);
/// <summary> /// <summary>
/// Deserialize JSON to specified type with default options. /// Deserialize JSON to specified type with default options.
/// </summary> /// </summary>
@ -414,36 +444,9 @@ public static class SerializeObjectExtensions
AcJsonDeserializer.Populate(json, target, options); AcJsonDeserializer.Populate(json, target, options);
} }
/// <summary> #endregion
/// Clone object via JSON serialization with default options.
/// </summary>
public static TDestination? CloneTo<TDestination>(this object? src) where TDestination : class
=> src?.ToJson().JsonTo<TDestination>();
/// <summary>
/// Clone object via JSON serialization with specified options.
/// </summary>
public static TDestination? CloneTo<TDestination>(this object? src, AcJsonSerializerOptions options) where TDestination : class
=> src?.ToJson(options).JsonTo<TDestination>(options);
/// <summary>
/// Copy object properties to target via JSON with default options.
/// </summary>
public static void CopyTo(this object? src, object target)
=> src?.ToJson().JsonTo(target);
/// <summary>
/// Copy object properties to target via JSON with specified options.
/// </summary>
public static void CopyTo(this object? src, object target, AcJsonSerializerOptions options)
=> src?.ToJson(options).JsonTo(target, options);
public static byte[] ToMessagePack(this object message, MessagePackSerializerOptions options)
=> MessagePackSerializer.Serialize(message, options);
public static T MessagePackTo<T>(this byte[] message, MessagePackSerializerOptions options)
=> MessagePackSerializer.Deserialize<T>(message, options);
#region Any (JSON or Binary based on options)
public static object ToAny<T>(this T source, AcSerializerOptions options) public static object ToAny<T>(this T source, AcSerializerOptions options)
{ {
@ -488,37 +491,17 @@ public static class SerializeObjectExtensions
public static void AnyToMerge<T>(this object data, T target, AcSerializerOptions options) where T : class public static void AnyToMerge<T>(this object data, T target, AcSerializerOptions options) where T : class
{ {
if (options.SerializerType == AcSerializerType.Json) if (options.SerializerType == AcSerializerType.Json)
((string)data).JsonTo(target, (AcJsonSerializerOptions)options); // JSON always merges ((string)data).JsonTo(target, (AcJsonSerializerOptions)options);
else else
((byte[])data).BinaryToMerge(target); ((byte[])data).BinaryToMerge(target);
} }
/// <summary> #endregion
/// Clone object via serialization based on options.
/// </summary>
public static T? CloneToAny<T>(this T source, AcSerializerOptions options) where T : class
{
if (options.SerializerType == AcSerializerType.Json)
return source.CloneTo<T>((AcJsonSerializerOptions)options);
return source.BinaryCloneTo();
}
/// <summary> #region Binary Serialization
/// Copy object properties to target via serialization based on options.
/// </summary>
public static void CopyToAny<T>(this T source, T target, AcSerializerOptions options) where T : class
{
if (options.SerializerType == AcSerializerType.Json)
source.CopyTo(target, (AcJsonSerializerOptions)options);
else
source.BinaryCopyTo(target);
}
#region Binary Serialization Extension Methods
/// <summary> /// <summary>
/// Serialize object to binary byte array with default options. /// Serialize object to binary byte array with default options.
/// Significantly faster than JSON, especially for large data in WASM.
/// </summary> /// </summary>
public static byte[] ToBinary<T>(this T source) => AcBinarySerializer.Serialize(source); public static byte[] ToBinary<T>(this T source) => AcBinarySerializer.Serialize(source);
@ -529,29 +512,83 @@ public static class SerializeObjectExtensions
=> AcBinarySerializer.Serialize(source, options); => AcBinarySerializer.Serialize(source, options);
/// <summary> /// <summary>
/// Deserialize binary data to object with default options. /// Serialize object directly to an IBufferWriter for zero-copy scenarios.
/// </summary>
public static void ToBinary<T>(this T source, IBufferWriter<byte> writer)
=> AcBinarySerializer.Serialize(source, writer, AcBinarySerializerOptions.Default);
/// <summary>
/// Serialize object directly to an IBufferWriter with specified options.
/// </summary>
public static void ToBinary<T>(this T source, IBufferWriter<byte> writer, AcBinarySerializerOptions options)
=> AcBinarySerializer.Serialize(source, writer, options);
/// <summary>
/// Get the serialized binary size without allocating the final array.
/// </summary>
public static int GetBinarySize<T>(this T source)
=> AcBinarySerializer.GetSerializedSize(source, AcBinarySerializerOptions.Default);
/// <summary>
/// Get the serialized binary size with specified options.
/// </summary>
public static int GetBinarySize<T>(this T source, AcBinarySerializerOptions options)
=> AcBinarySerializer.GetSerializedSize(source, options);
/// <summary>
/// Deserialize binary data to object.
/// </summary> /// </summary>
public static T? BinaryTo<T>(this byte[] data) public static T? BinaryTo<T>(this byte[] data)
=> AcBinaryDeserializer.Deserialize<T>(data); => AcBinaryDeserializer.Deserialize<T>(data);
/// <summary> /// <summary>
/// Deserialize binary data to object. /// Deserialize binary data from ReadOnlySpan.
/// </summary> /// </summary>
public static T? BinaryTo<T>(this ReadOnlySpan<byte> data) public static T? BinaryTo<T>(this ReadOnlySpan<byte> data)
=> AcBinaryDeserializer.Deserialize<T>(data); => AcBinaryDeserializer.Deserialize<T>(data);
/// <summary>
/// Deserialize binary data from ReadOnlyMemory.
/// </summary>
public static T? BinaryTo<T>(this ReadOnlyMemory<byte> data)
=> AcBinaryDeserializer.Deserialize<T>(data.Span);
/// <summary> /// <summary>
/// Deserialize binary data to specified type. /// Deserialize binary data to specified type.
/// </summary> /// </summary>
public static object? BinaryTo(this byte[] data, Type targetType) public static object? BinaryTo(this byte[] data, Type targetType)
=> AcBinaryDeserializer.Deserialize(data.AsSpan(), targetType); => AcBinaryDeserializer.Deserialize(data.AsSpan(), targetType);
/// <summary>
/// Deserialize binary data from ReadOnlySpan to specified type.
/// </summary>
public static object? BinaryTo(this ReadOnlySpan<byte> data, Type targetType)
=> AcBinaryDeserializer.Deserialize(data, targetType);
/// <summary>
/// Deserialize binary data from ReadOnlyMemory to specified type.
/// </summary>
public static object? BinaryTo(this ReadOnlyMemory<byte> data, Type targetType)
=> AcBinaryDeserializer.Deserialize(data.Span, targetType);
/// <summary> /// <summary>
/// Populate existing object from binary data. /// Populate existing object from binary data.
/// </summary> /// </summary>
public static void BinaryTo<T>(this byte[] data, T target) where T : class public static void BinaryTo<T>(this byte[] data, T target) where T : class
=> AcBinaryDeserializer.Populate(data, target); => AcBinaryDeserializer.Populate(data, target);
/// <summary>
/// Populate existing object from binary ReadOnlySpan.
/// </summary>
public static void BinaryTo<T>(this ReadOnlySpan<byte> data, T target) where T : class
=> AcBinaryDeserializer.Populate(data, target);
/// <summary>
/// Populate existing object from binary ReadOnlyMemory.
/// </summary>
public static void BinaryTo<T>(this ReadOnlyMemory<byte> data, T target) where T : class
=> AcBinaryDeserializer.Populate(data.Span, target);
/// <summary> /// <summary>
/// Populate existing object from binary data with merge semantics for IId collections. /// Populate existing object from binary data with merge semantics for IId collections.
/// </summary> /// </summary>
@ -559,16 +596,46 @@ public static class SerializeObjectExtensions
=> AcBinaryDeserializer.PopulateMerge(data.AsSpan(), target); => AcBinaryDeserializer.PopulateMerge(data.AsSpan(), target);
/// <summary> /// <summary>
/// Clone object via binary serialization (faster than JSON clone). /// Populate existing object from binary ReadOnlySpan with merge semantics.
/// </summary> /// </summary>
public static T? BinaryCloneTo<T>(this T source) where T : class public static void BinaryToMerge<T>(this ReadOnlySpan<byte> data, T target) where T : class
=> source?.ToBinary().BinaryTo<T>(); => AcBinaryDeserializer.PopulateMerge(data, target);
/// <summary> /// <summary>
/// Copy object properties to target via binary serialization. /// Populate existing object from binary ReadOnlyMemory with merge semantics.
/// </summary> /// </summary>
public static void BinaryCopyTo<T>(this T source, T target) where T : class public static void BinaryToMerge<T>(this ReadOnlyMemory<byte> data, T target) where T : class
=> source?.ToBinary().BinaryTo(target); => AcBinaryDeserializer.PopulateMerge(data.Span, target);
#endregion
#region Clone and Copy (Binary-based, zero intermediate allocation)
/// <summary>
/// Clone object via binary serialization (zero intermediate byte[] allocation).
/// Uses ArrayBufferWriter to serialize directly into a buffer, then deserializes from the span.
/// </summary>
public static TDestination? CloneTo<TDestination>(this object? src) where TDestination : class
{
if (src == null) return null;
var buffer = new ArrayBufferWriter<byte>(256);
AcBinarySerializer.Serialize(src, buffer, AcBinarySerializerOptions.Default);
return AcBinaryDeserializer.Deserialize<TDestination>(buffer.WrittenSpan);
}
/// <summary>
/// Copy object properties to target via binary serialization (zero intermediate byte[] allocation).
/// Uses ArrayBufferWriter to serialize directly into a buffer, then populates target from the span.
/// </summary>
public static void CopyTo(this object? src, object target)
{
if (src == null) return;
var buffer = new ArrayBufferWriter<byte>(256);
AcBinarySerializer.Serialize(src, buffer, AcBinarySerializerOptions.Default);
AcBinaryDeserializer.Populate(buffer.WrittenSpan, target);
}
#endregion #endregion
} }

View File

@ -0,0 +1,172 @@
using AyCode.Core.Enums;
using AyCode.Core.Extensions;
using AyCode.Core.Helpers;
using AyCode.Core.Tests.TestModels;
using AyCode.Services.Server.SignalRs;
using AyCode.Services.SignalRs;
namespace AyCode.Services.Server.Tests.SignalRs;
#region DataSource Implementations
public class TestOrderItemListDataSource : AcSignalRDataSource<TestOrderItem, int, List<TestOrderItem>>
{
public TestOrderItemListDataSource(AcSignalRClientBase signalRClient, SignalRCrudTags crudTags)
: base(signalRClient, crudTags) { }
}
public class TestOrderItemObservableDataSource : AcSignalRDataSource<TestOrderItem, int, AcObservableCollection<TestOrderItem>>
{
public TestOrderItemObservableDataSource(AcSignalRClientBase signalRClient, SignalRCrudTags crudTags)
: base(signalRClient, crudTags) { }
}
#endregion
#region Abstract Test Base
/// <summary>
/// Base class for SignalR DataSource tests.
/// Derived classes specify the serializer type and collection type.
/// </summary>
/// <typeparam name="TDataSource">The concrete DataSource type</typeparam>
/// <typeparam name="TIList">The inner list type (List or AcObservableCollection)</typeparam>
public abstract class SignalRDataSourceTestBase<TDataSource, TIList>
where TDataSource : AcSignalRDataSource<TestOrderItem, int, TIList>
where TIList : class, IList<TestOrderItem>
{
protected abstract AcSerializerOptions SerializerOption { get; }
protected abstract TDataSource CreateDataSource(TestableSignalRClient2 client, SignalRCrudTags crudTags);
protected TestLogger _logger = null!;
protected TestableSignalRHub2 _hub = null!;
protected TestableSignalRClient2 _client = null!;
protected TestSignalRService2 _service = null!;
protected SignalRCrudTags _crudTags = null!;
[TestInitialize]
public void Setup()
{
_logger = new TestLogger();
_hub = new TestableSignalRHub2();
_service = new TestSignalRService2();
_client = new TestableSignalRClient2(_hub, _logger);
_hub.SetSerializerType(SerializerOption);
_hub.RegisterService(_service, _client);
_crudTags = new SignalRCrudTags(
TestSignalRTags.DataSourceGetAll,
TestSignalRTags.DataSourceGetItem,
TestSignalRTags.DataSourceAdd,
TestSignalRTags.DataSourceUpdate,
TestSignalRTags.DataSourceRemove
);
}
#region Load Tests
[TestMethod]
public async Task LoadDataSource_ReturnsAllItems()
{
var dataSource = CreateDataSource(_client, _crudTags);
await dataSource.LoadDataSource();
Assert.AreEqual(3, dataSource.Count);
Assert.AreEqual("Product A", dataSource[0].ProductName);
}
[TestMethod]
public async Task LoadItem_ReturnsSingleItem()
{
var dataSource = CreateDataSource(_client, _crudTags);
var result = await dataSource.LoadItem(2);
Assert.IsNotNull(result);
Assert.AreEqual(2, result.Id);
Assert.AreEqual("Product B", result.ProductName);
}
#endregion
#region Add Tests
[TestMethod]
public async Task Add_WithAutoSave_AddsItem()
{
var dataSource = CreateDataSource(_client, _crudTags);
var newItem = new TestOrderItem { Id = 100, ProductName = "New Product", Quantity = 5, UnitPrice = 50m };
var result = await dataSource.Add(newItem, autoSave: true);
Assert.AreEqual(1, dataSource.Count);
Assert.AreEqual("New Product", result.ProductName);
}
[TestMethod]
public void Add_WithoutAutoSave_AddsToTrackingOnly()
{
var dataSource = CreateDataSource(_client, _crudTags);
var newItem = new TestOrderItem { Id = 100, ProductName = "New Product" };
dataSource.Add(newItem);
Assert.AreEqual(1, dataSource.Count);
Assert.AreEqual(1, dataSource.GetTrackingItems().Count);
Assert.AreEqual(TrackingState.Add, dataSource.GetTrackingItems()[0].TrackingState);
}
#endregion
#region SaveChanges Tests
[TestMethod]
public async Task SaveChanges_SavesTrackedItems()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 101, ProductName = "Item 1" });
dataSource.Add(new TestOrderItem { Id = 102, ProductName = "Item 2" });
var unsaved = await dataSource.SaveChanges();
Assert.AreEqual(0, unsaved.Count);
Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
}
[TestMethod]
public async Task SaveChangesAsync_ClearsTracking()
{
var dataSource = CreateDataSource(_client, _crudTags);
dataSource.Add(new TestOrderItem { Id = 103, ProductName = "Item 3" });
await dataSource.SaveChangesAsync();
Assert.AreEqual(0, dataSource.GetTrackingItems().Count);
}
#endregion
}
#endregion
#region DataSources
[TestClass]
public class SignalRDataSourceTests_List : SignalRDataSourceTestBase<TestOrderItemListDataSource, List<TestOrderItem>>
{
protected override AcSerializerOptions SerializerOption => AcBinarySerializerOptions.Default;
protected override TestOrderItemListDataSource CreateDataSource(TestableSignalRClient2 client, SignalRCrudTags crudTags)
=> new(client, crudTags);
}
[TestClass]
public class SignalRDataSourceTests_Observable : SignalRDataSourceTestBase<TestOrderItemObservableDataSource, AcObservableCollection<TestOrderItem>>
{
protected override AcSerializerOptions SerializerOption => AcBinarySerializerOptions.Default;
protected override TestOrderItemObservableDataSource CreateDataSource(TestableSignalRClient2 client, SignalRCrudTags crudTags)
=> new(client, crudTags);
}
#endregion

View File

@ -5,18 +5,39 @@ using MessagePack.Resolvers;
namespace AyCode.Services.Server.Tests.SignalRs; namespace AyCode.Services.Server.Tests.SignalRs;
/// <summary>
/// Captured sent message for assertions.
/// </summary>
public record SentMessage(
int MessageTag,
ISignalRMessage Message,
int? RequestId,
SendTarget Target,
string? TargetId = null)
{
public SignalResponseDataMessage? AsDataResponse => Message as SignalResponseDataMessage;
}
/// <summary>
/// Target of the sent message.
/// </summary>
public enum SendTarget
{
Caller,
Client,
Others,
All,
User,
Group
}
/// <summary> /// <summary>
/// Helper methods for creating SignalR test messages. /// Helper methods for creating SignalR test messages.
/// Uses the production SignalR types for compatibility with the actual client/server code.
/// </summary> /// </summary>
public static class SignalRTestHelper public static class SignalRTestHelper
{ {
private static readonly MessagePackSerializerOptions MessagePackOptions = ContractlessStandardResolver.Options; private static readonly MessagePackSerializerOptions MessagePackOptions = ContractlessStandardResolver.Options;
/// <summary>
/// Creates a MessagePack message for parameters using IdMessage format.
/// Each parameter is serialized directly as JSON (no array wrapping).
/// </summary>
public static byte[] CreatePrimitiveParamsMessage(params object[] values) public static byte[] CreatePrimitiveParamsMessage(params object[] values)
{ {
var idMessage = new IdMessage(values); var idMessage = new IdMessage(values);
@ -24,18 +45,9 @@ public static class SignalRTestHelper
return MessagePackSerializer.Serialize(postMessage, MessagePackOptions); return MessagePackSerializer.Serialize(postMessage, MessagePackOptions);
} }
/// <summary>
/// Creates a MessagePack message for a single primitive parameter.
/// </summary>
public static byte[] CreateSinglePrimitiveMessage<T>(T value) where T : notnull public static byte[] CreateSinglePrimitiveMessage<T>(T value) where T : notnull
{ => CreatePrimitiveParamsMessage(value);
return CreatePrimitiveParamsMessage(value);
}
/// <summary>
/// Creates a MessagePack message for a complex object parameter.
/// Uses PostDataJson pattern for single complex objects.
/// </summary>
public static byte[] CreateComplexObjectMessage<T>(T obj) public static byte[] CreateComplexObjectMessage<T>(T obj)
{ {
var json = obj.ToJson(); var json = obj.ToJson();
@ -43,73 +55,39 @@ public static class SignalRTestHelper
return MessagePackSerializer.Serialize(postMessage, MessagePackOptions); return MessagePackSerializer.Serialize(postMessage, MessagePackOptions);
} }
/// <summary>
/// Creates an empty MessagePack message for parameterless methods.
/// </summary>
public static byte[] CreateEmptyMessage() public static byte[] CreateEmptyMessage()
{ {
var postMessage = new SignalPostJsonDataMessage<object>(); var postMessage = new SignalPostJsonDataMessage<object>();
return MessagePackSerializer.Serialize(postMessage, MessagePackOptions); return MessagePackSerializer.Serialize(postMessage, MessagePackOptions);
} }
/// <summary>
/// Deserialize a SignalResponseJsonMessage from the captured SentMessage.
/// </summary>
public static T? GetResponseData<T>(SentMessage sentMessage) public static T? GetResponseData<T>(SentMessage sentMessage)
{ {
if (sentMessage.Message is SignalResponseJsonMessage jsonResponse && jsonResponse.ResponseData != null) if (sentMessage.Message is SignalResponseDataMessage dataResponse && dataResponse.ResponseData != null)
{ return dataResponse.GetResponseData<T>();
return jsonResponse.ResponseData.JsonTo<T>();
}
if (sentMessage.Message is SignalResponseBinaryMessage binaryResponse && binaryResponse.ResponseData != null)
{
return binaryResponse.ResponseData.BinaryTo<T>();
}
return default; return default;
} }
/// <summary>
/// Gets the response status from either JSON or Binary message.
/// </summary>
private static SignalResponseStatus? GetResponseStatus(ISignalRMessage message)
{
return message switch
{
SignalResponseJsonMessage jsonMsg => jsonMsg.Status,
SignalResponseBinaryMessage binaryMsg => binaryMsg.Status,
_ => null
};
}
/// <summary>
/// Assert that a response was successful.
/// </summary>
public static void AssertSuccessResponse(SentMessage sentMessage, int expectedTag) public static void AssertSuccessResponse(SentMessage sentMessage, int expectedTag)
{ {
var status = GetResponseStatus(sentMessage.Message); if (sentMessage.Message is not SignalResponseDataMessage response)
if (status == null) throw new AssertFailedException($"Response is not SignalResponseDataMessage. Type: {sentMessage.Message.GetType().Name}");
throw new AssertFailedException($"Response is not a valid SignalR response message. Type: {sentMessage.Message.GetType().Name}");
if (status != SignalResponseStatus.Success) if (response.Status != SignalResponseStatus.Success)
throw new AssertFailedException($"Expected Success status but got {status}"); throw new AssertFailedException($"Expected Success status but got {response.Status}");
if (sentMessage.MessageTag != expectedTag) if (sentMessage.MessageTag != expectedTag)
throw new AssertFailedException($"Expected tag {expectedTag} but got {sentMessage.MessageTag}"); throw new AssertFailedException($"Expected tag {expectedTag} but got {sentMessage.MessageTag}");
} }
/// <summary>
/// Assert that a response was an error.
/// </summary>
public static void AssertErrorResponse(SentMessage sentMessage, int expectedTag) public static void AssertErrorResponse(SentMessage sentMessage, int expectedTag)
{ {
var status = GetResponseStatus(sentMessage.Message); if (sentMessage.Message is not SignalResponseDataMessage response)
if (status == null) throw new AssertFailedException($"Response is not SignalResponseDataMessage. Type: {sentMessage.Message.GetType().Name}");
throw new AssertFailedException($"Response is not a valid SignalR response message. Type: {sentMessage.Message.GetType().Name}");
if (status != SignalResponseStatus.Error) if (response.Status != SignalResponseStatus.Error)
throw new AssertFailedException($"Expected Error status but got {status}"); throw new AssertFailedException($"Expected Error status but got {response.Status}");
if (sentMessage.MessageTag != expectedTag) if (sentMessage.MessageTag != expectedTag)
throw new AssertFailedException($"Expected tag {expectedTag} but got {sentMessage.MessageTag}"); throw new AssertFailedException($"Expected tag {expectedTag} but got {sentMessage.MessageTag}");

View File

@ -1,518 +0,0 @@
using AyCode.Core.Tests.TestModels;
using AyCode.Services.SignalRs;
namespace AyCode.Services.Server.Tests.SignalRs;
/// <summary>
/// Test service with SignalR-attributed methods for testing ProcessOnReceiveMessage.
/// Uses shared DTOs from AyCode.Core.Tests.TestModels.
/// </summary>
public class TestSignalRService
{
#region Captured Values for Assertions
// Primitive captures
public bool SingleIntMethodCalled { get; private set; }
public int? ReceivedInt { get; private set; }
public bool TwoIntMethodCalled { get; private set; }
public (int A, int B)? ReceivedTwoInts { get; private set; }
public bool BoolMethodCalled { get; private set; }
public bool? ReceivedBool { get; private set; }
public bool StringMethodCalled { get; private set; }
public string? ReceivedString { get; private set; }
public bool GuidMethodCalled { get; private set; }
public Guid? ReceivedGuid { get; private set; }
public bool EnumMethodCalled { get; private set; }
public TestStatus? ReceivedEnum { get; private set; }
public bool NoParamsMethodCalled { get; private set; }
public bool MultipleTypesMethodCalled { get; private set; }
public (bool, string, int)? ReceivedMultipleTypes { get; private set; }
// Extended primitives
public bool DecimalMethodCalled { get; private set; }
public decimal? ReceivedDecimal { get; private set; }
public bool DateTimeMethodCalled { get; private set; }
public DateTime? ReceivedDateTime { get; private set; }
public bool DoubleMethodCalled { get; private set; }
public double? ReceivedDouble { get; private set; }
public bool LongMethodCalled { get; private set; }
public long? ReceivedLong { get; private set; }
// Complex object captures (using shared DTOs)
public bool TestOrderItemMethodCalled { get; private set; }
public TestOrderItem? ReceivedTestOrderItem { get; private set; }
public bool TestOrderMethodCalled { get; private set; }
public TestOrder? ReceivedTestOrder { get; private set; }
public bool SharedTagMethodCalled { get; private set; }
public SharedTag? ReceivedSharedTag { get; private set; }
// Collection captures
public bool IntArrayMethodCalled { get; private set; }
public int[]? ReceivedIntArray { get; private set; }
public bool GuidArrayMethodCalled { get; private set; }
public Guid[]? ReceivedGuidArray { get; private set; }
public bool StringListMethodCalled { get; private set; }
public List<string>? ReceivedStringList { get; private set; }
public bool TestOrderItemListMethodCalled { get; private set; }
public List<TestOrderItem>? ReceivedTestOrderItemList { get; private set; }
public bool IntListMethodCalled { get; private set; }
public List<int>? ReceivedIntList { get; private set; }
public bool BoolArrayMethodCalled { get; private set; }
public bool[]? ReceivedBoolArray { get; private set; }
public bool MixedWithArrayMethodCalled { get; private set; }
public (bool, int[], string)? ReceivedMixedWithArray { get; private set; }
public bool NestedListMethodCalled { get; private set; }
public List<List<int>>? ReceivedNestedList { get; private set; }
// Extended array captures for comprehensive testing
public bool LongArrayMethodCalled { get; private set; }
public long[]? ReceivedLongArray { get; private set; }
public bool DecimalArrayMethodCalled { get; private set; }
public decimal[]? ReceivedDecimalArray { get; private set; }
public bool DateTimeArrayMethodCalled { get; private set; }
public DateTime[]? ReceivedDateTimeArray { get; private set; }
public bool EnumArrayMethodCalled { get; private set; }
public TestStatus[]? ReceivedEnumArray { get; private set; }
public bool DoubleArrayMethodCalled { get; private set; }
public double[]? ReceivedDoubleArray { get; private set; }
public bool SharedTagArrayMethodCalled { get; private set; }
public SharedTag[]? ReceivedSharedTagArray { get; private set; }
public bool DictionaryMethodCalled { get; private set; }
public Dictionary<string, int>? ReceivedDictionary { get; private set; }
public bool ObjectArrayMethodCalled { get; private set; }
public object[]? ReceivedObjectArray { get; private set; }
// Mixed parameter captures
public bool IntAndDtoMethodCalled { get; private set; }
public (int, TestOrderItem?)? ReceivedIntAndDto { get; private set; }
public bool DtoAndListMethodCalled { get; private set; }
public (TestOrderItem?, List<int>?)? ReceivedDtoAndList { get; private set; }
public bool ThreeComplexParamsMethodCalled { get; private set; }
public (TestOrderItem?, List<string>?, SharedTag?)? ReceivedThreeComplexParams { get; private set; }
public bool FiveParamsMethodCalled { get; private set; }
public (int, string?, bool, Guid, decimal)? ReceivedFiveParams { get; private set; }
#endregion
#region Primitive Parameter Handlers
[SignalR(TestSignalRTags.SingleIntParam)]
public string HandleSingleInt(int value)
{
SingleIntMethodCalled = true;
ReceivedInt = value;
return $"Received: {value}";
}
[SignalR(TestSignalRTags.TwoIntParams)]
public int HandleTwoInts(int a, int b)
{
TwoIntMethodCalled = true;
ReceivedTwoInts = (a, b);
return a + b;
}
[SignalR(TestSignalRTags.BoolParam)]
public bool HandleBool(bool loadRelations)
{
BoolMethodCalled = true;
ReceivedBool = loadRelations;
return loadRelations;
}
[SignalR(TestSignalRTags.StringParam)]
public string HandleString(string text)
{
StringMethodCalled = true;
ReceivedString = text;
return $"Echo: {text}";
}
[SignalR(TestSignalRTags.GuidParam)]
public Guid HandleGuid(Guid id)
{
GuidMethodCalled = true;
ReceivedGuid = id;
return id;
}
[SignalR(TestSignalRTags.EnumParam)]
public TestStatus HandleEnum(TestStatus status)
{
EnumMethodCalled = true;
ReceivedEnum = status;
return status;
}
[SignalR(TestSignalRTags.NoParams)]
public string HandleNoParams()
{
NoParamsMethodCalled = true;
return "OK";
}
[SignalR(TestSignalRTags.MultipleTypesParams)]
public string HandleMultipleTypes(bool flag, string text, int number)
{
MultipleTypesMethodCalled = true;
ReceivedMultipleTypes = (flag, text, number);
return $"{flag}-{text}-{number}";
}
[SignalR(TestSignalRTags.ThrowsException)]
public void HandleThrowsException()
{
throw new InvalidOperationException("Test exception");
}
[SignalR(TestSignalRTags.DecimalParam)]
public decimal HandleDecimal(decimal value)
{
DecimalMethodCalled = true;
ReceivedDecimal = value;
return value * 2;
}
[SignalR(TestSignalRTags.DateTimeParam)]
public DateTime HandleDateTime(DateTime dateTime)
{
DateTimeMethodCalled = true;
ReceivedDateTime = dateTime;
return dateTime;
}
[SignalR(TestSignalRTags.DoubleParam)]
public double HandleDouble(double value)
{
DoubleMethodCalled = true;
ReceivedDouble = value;
return value;
}
[SignalR(TestSignalRTags.LongParam)]
public long HandleLong(long value)
{
LongMethodCalled = true;
ReceivedLong = value;
return value;
}
#endregion
#region Complex Object Handlers (using shared DTOs)
[SignalR(TestSignalRTags.TestOrderItemParam)]
public TestOrderItem HandleTestOrderItem(TestOrderItem item)
{
TestOrderItemMethodCalled = true;
ReceivedTestOrderItem = item;
return new TestOrderItem
{
Id = item.Id,
ProductName = $"Processed: {item.ProductName}",
Quantity = item.Quantity * 2,
UnitPrice = item.UnitPrice
};
}
[SignalR(TestSignalRTags.TestOrderParam)]
public TestOrder HandleTestOrder(TestOrder order)
{
TestOrderMethodCalled = true;
ReceivedTestOrder = order;
return order;
}
[SignalR(TestSignalRTags.SharedTagParam)]
public SharedTag HandleSharedTag(SharedTag tag)
{
SharedTagMethodCalled = true;
ReceivedSharedTag = tag;
return tag;
}
#endregion
#region Collection Parameter Handlers
[SignalR(TestSignalRTags.IntArrayParam)]
public int[] HandleIntArray(int[] values)
{
IntArrayMethodCalled = true;
ReceivedIntArray = values;
return values.Select(x => x * 2).ToArray();
}
[SignalR(TestSignalRTags.GuidArrayParam)]
public Guid[] HandleGuidArray(Guid[] ids)
{
GuidArrayMethodCalled = true;
ReceivedGuidArray = ids;
return ids;
}
[SignalR(TestSignalRTags.StringListParam)]
public List<string> HandleStringList(List<string> items)
{
StringListMethodCalled = true;
ReceivedStringList = items;
return items.Select(x => x.ToUpper()).ToList();
}
[SignalR(TestSignalRTags.TestOrderItemListParam)]
public List<TestOrderItem> HandleTestOrderItemList(List<TestOrderItem> items)
{
TestOrderItemListMethodCalled = true;
ReceivedTestOrderItemList = items;
return items;
}
[SignalR(TestSignalRTags.IntListParam)]
public List<int> HandleIntList(List<int> numbers)
{
IntListMethodCalled = true;
ReceivedIntList = numbers;
return numbers.Select(x => x * 2).ToList();
}
[SignalR(TestSignalRTags.BoolArrayParam)]
public bool[] HandleBoolArray(bool[] flags)
{
BoolArrayMethodCalled = true;
ReceivedBoolArray = flags;
return flags;
}
[SignalR(TestSignalRTags.MixedWithArrayParam)]
public string HandleMixedWithArray(bool flag, int[] numbers, string text)
{
MixedWithArrayMethodCalled = true;
ReceivedMixedWithArray = (flag, numbers, text);
return $"{flag}-[{string.Join(",", numbers)}]-{text}";
}
[SignalR(TestSignalRTags.NestedListParam)]
public List<List<int>> HandleNestedList(List<List<int>> nestedList)
{
NestedListMethodCalled = true;
ReceivedNestedList = nestedList;
return nestedList;
}
#endregion
#region Extended Array Parameter Handlers
[SignalR(TestSignalRTags.LongArrayParam)]
public long[] HandleLongArray(long[] values)
{
LongArrayMethodCalled = true;
ReceivedLongArray = values;
return values;
}
[SignalR(TestSignalRTags.DecimalArrayParam)]
public decimal[] HandleDecimalArray(decimal[] values)
{
DecimalArrayMethodCalled = true;
ReceivedDecimalArray = values;
return values;
}
[SignalR(TestSignalRTags.DateTimeArrayParam)]
public DateTime[] HandleDateTimeArray(DateTime[] values)
{
DateTimeArrayMethodCalled = true;
ReceivedDateTimeArray = values;
return values;
}
[SignalR(TestSignalRTags.EnumArrayParam)]
public TestStatus[] HandleEnumArray(TestStatus[] values)
{
EnumArrayMethodCalled = true;
ReceivedEnumArray = values;
return values;
}
[SignalR(TestSignalRTags.DoubleArrayParam)]
public double[] HandleDoubleArray(double[] values)
{
DoubleArrayMethodCalled = true;
ReceivedDoubleArray = values;
return values;
}
[SignalR(TestSignalRTags.SharedTagArrayParam)]
public SharedTag[] HandleSharedTagArray(SharedTag[] tags)
{
SharedTagArrayMethodCalled = true;
ReceivedSharedTagArray = tags;
return tags;
}
[SignalR(TestSignalRTags.DictionaryParam)]
public Dictionary<string, int> HandleDictionary(Dictionary<string, int> dict)
{
DictionaryMethodCalled = true;
ReceivedDictionary = dict;
return dict;
}
[SignalR(TestSignalRTags.ObjectArrayParam)]
public object[] HandleObjectArray(object[] values)
{
ObjectArrayMethodCalled = true;
ReceivedObjectArray = values;
return values;
}
#endregion
#region Mixed Parameter Handlers
[SignalR(TestSignalRTags.IntAndDtoParam)]
public string HandleIntAndDto(int id, TestOrderItem item)
{
IntAndDtoMethodCalled = true;
ReceivedIntAndDto = (id, item);
return $"{id}-{item?.ProductName}";
}
[SignalR(TestSignalRTags.DtoAndListParam)]
public string HandleDtoAndList(TestOrderItem item, List<int> numbers)
{
DtoAndListMethodCalled = true;
ReceivedDtoAndList = (item, numbers);
return $"{item?.ProductName}-[{string.Join(",", numbers ?? [])}]";
}
[SignalR(TestSignalRTags.ThreeComplexParams)]
public string HandleThreeComplexParams(TestOrderItem item, List<string> tags, SharedTag sharedTag)
{
ThreeComplexParamsMethodCalled = true;
ReceivedThreeComplexParams = (item, tags, sharedTag);
return $"{item?.ProductName}-{tags?.Count}-{sharedTag?.Name}";
}
[SignalR(TestSignalRTags.FiveParams)]
public string HandleFiveParams(int a, string b, bool c, Guid d, decimal e)
{
FiveParamsMethodCalled = true;
ReceivedFiveParams = (a, b, c, d, e);
return $"{a}-{b}-{c}-{d}-{e}";
}
#endregion
public void Reset()
{
// Primitive captures
SingleIntMethodCalled = false;
ReceivedInt = null;
TwoIntMethodCalled = false;
ReceivedTwoInts = null;
BoolMethodCalled = false;
ReceivedBool = null;
StringMethodCalled = false;
ReceivedString = null;
GuidMethodCalled = false;
ReceivedGuid = null;
EnumMethodCalled = false;
ReceivedEnum = null;
NoParamsMethodCalled = false;
MultipleTypesMethodCalled = false;
ReceivedMultipleTypes = null;
DecimalMethodCalled = false;
ReceivedDecimal = null;
DateTimeMethodCalled = false;
ReceivedDateTime = null;
DoubleMethodCalled = false;
ReceivedDouble = null;
LongMethodCalled = false;
ReceivedLong = null;
// Complex object captures
TestOrderItemMethodCalled = false;
ReceivedTestOrderItem = null;
TestOrderMethodCalled = false;
ReceivedTestOrder = null;
SharedTagMethodCalled = false;
ReceivedSharedTag = null;
// Collection captures
IntArrayMethodCalled = false;
ReceivedIntArray = null;
GuidArrayMethodCalled = false;
ReceivedGuidArray = null;
StringListMethodCalled = false;
ReceivedStringList = null;
TestOrderItemListMethodCalled = false;
ReceivedTestOrderItemList = null;
IntListMethodCalled = false;
ReceivedIntList = null;
BoolArrayMethodCalled = false;
ReceivedBoolArray = null;
MixedWithArrayMethodCalled = false;
ReceivedMixedWithArray = null;
NestedListMethodCalled = false;
ReceivedNestedList = null;
// Extended array captures
LongArrayMethodCalled = false;
ReceivedLongArray = null;
DecimalArrayMethodCalled = false;
ReceivedDecimalArray = null;
DateTimeArrayMethodCalled = false;
ReceivedDateTimeArray = null;
EnumArrayMethodCalled = false;
ReceivedEnumArray = null;
DoubleArrayMethodCalled = false;
ReceivedDoubleArray = null;
SharedTagArrayMethodCalled = false;
ReceivedSharedTagArray = null;
DictionaryMethodCalled = false;
ReceivedDictionary = null;
ObjectArrayMethodCalled = false;
ReceivedObjectArray = null;
// Mixed parameter captures
IntAndDtoMethodCalled = false;
ReceivedIntAndDto = null;
DtoAndListMethodCalled = false;
ReceivedDtoAndList = null;
ThreeComplexParamsMethodCalled = false;
ReceivedThreeComplexParams = null;
FiveParamsMethodCalled = false;
ReceivedFiveParams = null;
}
}

View File

@ -428,4 +428,44 @@ public class TestSignalRService2
} }
#endregion #endregion
#region DataSource CRUD Tests
private readonly List<TestOrderItem> _dataSourceItems =
[
new() { Id = 1, ProductName = "Product A", Quantity = 10, UnitPrice = 100m },
new() { Id = 2, ProductName = "Product B", Quantity = 20, UnitPrice = 200m },
new() { Id = 3, ProductName = "Product C", Quantity = 30, UnitPrice = 300m }
];
[SignalR(TestSignalRTags.DataSourceGetAll)]
public List<TestOrderItem> DataSourceGetAll() => _dataSourceItems.ToList();
[SignalR(TestSignalRTags.DataSourceGetItem)]
public TestOrderItem? DataSourceGetItem(int id) => _dataSourceItems.FirstOrDefault(x => x.Id == id);
[SignalR(TestSignalRTags.DataSourceAdd)]
public TestOrderItem DataSourceAdd(TestOrderItem item)
{
_dataSourceItems.Add(item);
return item;
}
[SignalR(TestSignalRTags.DataSourceUpdate)]
public TestOrderItem DataSourceUpdate(TestOrderItem item)
{
var index = _dataSourceItems.FindIndex(x => x.Id == item.Id);
if (index >= 0) _dataSourceItems[index] = item;
return item;
}
[SignalR(TestSignalRTags.DataSourceRemove)]
public TestOrderItem? DataSourceRemove(TestOrderItem item)
{
var existing = _dataSourceItems.FirstOrDefault(x => x.Id == item.Id);
if (existing != null) _dataSourceItems.Remove(existing);
return existing;
}
#endregion
} }

View File

@ -81,4 +81,11 @@ public abstract class TestSignalRTags : AcSignalRTags
public const int PropertyMismatchListParam = 241; public const int PropertyMismatchListParam = 241;
public const int PropertyMismatchNestedParam = 242; public const int PropertyMismatchNestedParam = 242;
public const int PropertyMismatchNestedListParam = 243; public const int PropertyMismatchNestedListParam = 243;
// DataSource CRUD tags
public const int DataSourceGetAll = 300;
public const int DataSourceGetItem = 301;
public const int DataSourceAdd = 302;
public const int DataSourceUpdate = 303;
public const int DataSourceRemove = 304;
} }

View File

@ -4,7 +4,6 @@ using AyCode.Core.Tests.TestModels;
using AyCode.Services.Server.SignalRs; using AyCode.Services.Server.SignalRs;
using AyCode.Services.SignalRs; using AyCode.Services.SignalRs;
using AyCode.Services.Tests.SignalRs; using AyCode.Services.Tests.SignalRs;
using MessagePack.Resolvers;
using Microsoft.AspNetCore.SignalR.Client; using Microsoft.AspNetCore.SignalR.Client;
namespace AyCode.Services.Server.Tests.SignalRs; namespace AyCode.Services.Server.Tests.SignalRs;

View File

@ -1,221 +0,0 @@
using System.Security.Claims;
using AyCode.Core.Extensions;
using AyCode.Core.Tests.TestModels;
using AyCode.Models.Server.DynamicMethods;
using AyCode.Services.Server.SignalRs;
using AyCode.Services.SignalRs;
using Microsoft.Extensions.Configuration;
namespace AyCode.Services.Server.Tests.SignalRs;
/// <summary>
/// Testable SignalR hub that overrides infrastructure dependencies.
/// Enables unit testing without SignalR server or mocks.
/// </summary>
public class TestableSignalRHub : AcWebSignalRHubBase<TestSignalRTags, TestLogger>
{
#region Captured Data for Assertions
/// <summary>
/// Messages sent via ResponseToCaller or SendMessageToClient
/// </summary>
public List<SentMessage> SentMessages { get; } = [];
/// <summary>
/// Whether notFoundCallback was invoked
/// </summary>
public bool WasNotFoundCallbackInvoked { get; private set; }
/// <summary>
/// The tag name passed to notFoundCallback
/// </summary>
public string? NotFoundTagName { get; private set; }
#endregion
#region Test Configuration
/// <summary>
/// Simulated connection ID
/// </summary>
public string TestConnectionId { get; set; } = "test-connection-id";
/// <summary>
/// Simulated user identifier
/// </summary>
public string? TestUserIdentifier { get; set; } = "test-user-id";
/// <summary>
/// Simulated connection aborted state
/// </summary>
public bool TestIsConnectionAborted { get; set; } = false;
/// <summary>
/// Simulated ClaimsPrincipal (optional)
/// </summary>
public ClaimsPrincipal? TestUser { get; set; }
#endregion
public TestableSignalRHub()
: base(new ConfigurationBuilder().Build(), new TestLogger())
{
}
public TestableSignalRHub(IConfiguration configuration, TestLogger logger)
: base(configuration, logger)
{
}
#region Public Test Entry Points
/// <summary>
/// Sets the serializer type for testing (JSON or Binary).
/// </summary>
public void SetSerializerType(AcSerializerType serializerType)
{
SerializerOptions = serializerType == AcSerializerType.Binary
? new AcBinarySerializerOptions()
: new AcJsonSerializerOptions();
}
/// <summary>
/// Register a service with SignalR-attributed methods
/// </summary>
public void RegisterService(object service)
{
DynamicMethodCallModels.Add(new AcDynamicMethodCallModel<SignalRAttribute>(service));
}
/// <summary>
/// Invoke ProcessOnReceiveMessage for testing
/// </summary>
public Task InvokeProcessOnReceiveMessage(int messageTag, byte[]? message, int? requestId = null)
{
return ProcessOnReceiveMessage(messageTag, message, requestId, async tagName =>
{
WasNotFoundCallbackInvoked = true;
NotFoundTagName = tagName;
await Task.CompletedTask;
});
}
/// <summary>
/// Get the logger for assertions
/// </summary>
public new TestLogger Logger => base.Logger;
/// <summary>
/// Reset captured state for next test
/// </summary>
public void Reset()
{
SentMessages.Clear();
WasNotFoundCallbackInvoked = false;
NotFoundTagName = null;
Logger.Clear();
}
#endregion
#region Overridden Context Accessors
protected override string GetConnectionId() => TestConnectionId;
protected override bool IsConnectionAborted() => TestIsConnectionAborted;
protected override string? GetUserIdentifier() => TestUserIdentifier;
protected override ClaimsPrincipal? GetUser() => TestUser;
#endregion
#region Overridden Response Methods (capture messages for testing)
protected override Task ResponseToCaller(int messageTag, ISignalRMessage message, int? requestId)
{
SentMessages.Add(new SentMessage(
MessageTag: messageTag,
Message: message,
RequestId: requestId,
Target: SendTarget.Caller
));
return Task.CompletedTask;
}
protected override Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, ISignalRMessage message, int? requestId = null)
{
SentMessages.Add(new SentMessage(
MessageTag: messageTag,
Message: message,
RequestId: requestId,
Target: SendTarget.Client
));
return Task.CompletedTask;
}
protected override Task SendMessageToOthers(int messageTag, object? content)
{
SentMessages.Add(new SentMessage(
MessageTag: messageTag,
Message: new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content),
RequestId: null,
Target: SendTarget.Others
));
return Task.CompletedTask;
}
protected override Task SendMessageToAll(int messageTag, object? content)
{
SentMessages.Add(new SentMessage(
MessageTag: messageTag,
Message: new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content),
RequestId: null,
Target: SendTarget.All
));
return Task.CompletedTask;
}
protected override Task SendMessageToUserIdInternal(string userId, int messageTag, ISignalRMessage message, int? requestId)
{
SentMessages.Add(new SentMessage(
MessageTag: messageTag,
Message: message,
RequestId: requestId,
Target: SendTarget.User,
TargetId: userId
));
return Task.CompletedTask;
}
#endregion
}
/// <summary>
/// Captured sent message for assertions
/// </summary>
public record SentMessage(
int MessageTag,
ISignalRMessage Message,
int? RequestId,
SendTarget Target,
string? TargetId = null)
{
/// <summary>
/// Get the response as SignalResponseJsonMessage for inspection
/// </summary>
public SignalResponseJsonMessage? AsJsonResponse => Message as SignalResponseJsonMessage;
}
/// <summary>
/// Target of the sent message
/// </summary>
public enum SendTarget
{
Caller,
Client,
Others,
All,
User,
Group
}

View File

@ -5,7 +5,6 @@ using AyCode.Core.Tests.TestModels;
using AyCode.Models.Server.DynamicMethods; using AyCode.Models.Server.DynamicMethods;
using AyCode.Services.Server.SignalRs; using AyCode.Services.Server.SignalRs;
using AyCode.Services.SignalRs; using AyCode.Services.SignalRs;
using MessagePack.Resolvers;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
namespace AyCode.Services.Server.Tests.SignalRs; namespace AyCode.Services.Server.Tests.SignalRs;

View File

@ -275,7 +275,8 @@ namespace AyCode.Services.Server.SignalRs
} }
/// <summary> /// <summary>
/// GetAllMessageTag - Async callback version with optimized JSON handling /// GetAllMessageTag - Async callback version with optimized direct populate.
/// Uses SignalResponseDataMessage to avoid double deserialization.
/// </summary> /// </summary>
public Task LoadDataSourceAsync(bool clearChangeTracking = true) public Task LoadDataSourceAsync(bool clearChangeTracking = true)
{ {
@ -283,32 +284,61 @@ namespace AyCode.Services.Server.SignalRs
throw new ArgumentException($"SignalRCrudTags.GetAllMessageTag == SignalRTags.None"); throw new ArgumentException($"SignalRCrudTags.GetAllMessageTag == SignalRTags.None");
BeginSync(); BeginSync();
return SignalRClient.GetAllAsync<TIList>(SignalRCrudTags.GetAllMessageTag, async result => // Request SignalResponseDataMessage directly to avoid deserializing ResponseData
return SignalRClient.GetAllAsync<SignalResponseDataMessage>(SignalRCrudTags.GetAllMessageTag, GetContextParams())
.ContinueWith(async task =>
{ {
try try
{ {
if (result.Status != SignalResponseStatus.Success || string.IsNullOrEmpty(result.ResponseDataJson)) var response = task.Result;
throw new NullReferenceException($"LoadDataSourceAsync; Status: {result.Status}"); if (response?.Status != SignalResponseStatus.Success || response.ResponseData == null)
throw new NullReferenceException($"LoadDataSourceAsync; Status: {response?.Status}");
await LoadDataSourceFromJson(result.ResponseDataJson, false, false, clearChangeTracking); await LoadDataSourceFromResponseData(response.ResponseData, response.DataSerializerType,
false, false, clearChangeTracking);
} }
finally finally
{ {
EndSync(); EndSync();
} }
}, GetContextParams()); }).Unwrap();
} }
/// <summary> /// <summary>
/// Loads data source directly from JSON string, avoiding double deserialization. /// Loads data source directly from ResponseData byte[], avoiding double deserialization.
/// </summary> /// </summary>
public async Task LoadDataSourceFromJson(string json, bool refreshDataFromDbAsync = false, bool setSourceToWorkingReferenceList = false, bool clearChangeTracking = true) public async Task LoadDataSourceFromResponseData(byte[] responseData, AcSerializerType serializerType,
bool refreshDataFromDbAsync = false, bool setSourceToWorkingReferenceList = false, bool clearChangeTracking = true)
{ {
await _asyncLock.WaitAsync(); await _asyncLock.WaitAsync();
try try
{ {
if (!setSourceToWorkingReferenceList) if (!setSourceToWorkingReferenceList)
{ {
// Direct populate into existing InnerList
if (serializerType == AcSerializerType.Binary)
{
if (InnerList is IAcObservableCollection observable)
{
observable.BeginUpdate();
try
{
responseData.BinaryToMerge(InnerList);
}
finally
{
observable.EndUpdate();
}
}
else
{
responseData.BinaryTo(InnerList);
}
}
else
{
// JSON mode
var json = System.Text.Encoding.UTF8.GetString(responseData);
if (InnerList is IAcObservableCollection observable) if (InnerList is IAcObservableCollection observable)
{ {
observable.PopulateFromJson(json); observable.PopulateFromJson(json);
@ -318,9 +348,16 @@ namespace AyCode.Services.Server.SignalRs
json.JsonTo(InnerList); json.JsonTo(InnerList);
} }
} }
}
else else
{ {
var fromSource = json.JsonTo<TIList>(); // Deserialize to new list and set as reference
TIList? fromSource;
if (serializerType == AcSerializerType.Binary)
fromSource = responseData.BinaryTo<TIList>();
else
fromSource = System.Text.Encoding.UTF8.GetString(responseData).JsonTo<TIList>();
if (fromSource != null) if (fromSource != null)
{ {
ClearUnsafe(clearChangeTracking); ClearUnsafe(clearChangeTracking);
@ -891,7 +928,7 @@ namespace AyCode.Services.Server.SignalRs
private Task<TDataItem> SaveTrackingItemUnsafe(TrackingItem<TDataItem, TId> trackingItem) private Task<TDataItem> SaveTrackingItemUnsafe(TrackingItem<TDataItem, TId> trackingItem)
=> SaveItemUnsafe(trackingItem.CurrentValue, trackingItem.TrackingState); => SaveItemUnsafe(trackingItem.CurrentValue, trackingItem.TrackingState);
private Task SaveTrackingItemUnsafeAsync(TrackingItem<TDataItem, TId> trackingItem) private async Task SaveTrackingItemUnsafeAsync(TrackingItem<TDataItem, TId> trackingItem)
=> SaveItemUnsafeAsync(trackingItem.CurrentValue, trackingItem.TrackingState); => SaveItemUnsafeAsync(trackingItem.CurrentValue, trackingItem.TrackingState);
private Task<TDataItem> SaveItemUnsafe(TDataItem item, TrackingState trackingState) private Task<TDataItem> SaveItemUnsafe(TDataItem item, TrackingState trackingState)
@ -900,19 +937,22 @@ namespace AyCode.Services.Server.SignalRs
if (messageTag == AcSignalRTags.None) if (messageTag == AcSignalRTags.None)
throw new ArgumentException($"SaveItemUnsafe; messageTag == SignalRTags.None"); throw new ArgumentException($"SaveItemUnsafe; messageTag == SignalRTags.None");
return SignalRClient.PostDataAsync(messageTag, item).ContinueWith(x => return SignalRClient.PostDataAsync(messageTag, item).ContinueWith(task =>
{ {
if (x.Result == null) if (task.Result == null)
{ {
if (TryRollbackItem(item.Id, out _)) return item; if (TryRollbackItem(item.Id, out _)) return item;
throw new NullReferenceException($"SaveItemUnsafe; result == null"); throw new NullReferenceException($"SaveItemUnsafe; result == null");
} }
ProcessSavedResponseItem(x.Result, trackingState, item.Id); ProcessSavedResponseItem(task.Result, trackingState, item.Id);
return x.Result; return task.Result;
}); }, TaskScheduler.Default);
} }
/// <summary>
/// Saves item in background (fire-and-forget friendly). Does not block UI.
/// </summary>
private Task SaveItemUnsafeAsync(TDataItem item, TrackingState trackingState) private Task SaveItemUnsafeAsync(TDataItem item, TrackingState trackingState)
{ {
var messageTag = SignalRCrudTags.GetMessageTagByTrackingState(trackingState); var messageTag = SignalRCrudTags.GetMessageTagByTrackingState(trackingState);
@ -920,14 +960,14 @@ namespace AyCode.Services.Server.SignalRs
return SignalRClient.PostDataAsync(messageTag, item, response => return SignalRClient.PostDataAsync(messageTag, item, response =>
{ {
//response.ResponseDataJson
if (response.Status != SignalResponseStatus.Success || response.ResponseData == null) if (response.Status != SignalResponseStatus.Success || response.ResponseData == null)
{ {
if (TryRollbackItem(item.Id, out _)) return Task.CompletedTask; if (TryRollbackItem(item.Id, out _)) return;
throw new NullReferenceException($"SaveItemUnsafeAsync; Status: {response.Status}"); throw new NullReferenceException($"SaveItemUnsafeAsync; Status: {response.Status}");
} }
return ProcessSavedResponseItem(response.ResponseData, trackingState, item.Id); var resultItem = response.GetResponseData<TDataItem>();
ProcessSavedResponseItem(resultItem, trackingState, item.Id);
}); });
} }

View File

@ -1,57 +1,37 @@
using AyCode.Core.Extensions; using AyCode.Core.Extensions;
using AyCode.Core.Helpers; using AyCode.Core.Helpers;
using AyCode.Core.Loggers; using AyCode.Core.Loggers;
using AyCode.Services.Loggers;
using AyCode.Services.SignalRs; using AyCode.Services.SignalRs;
using MessagePack.Resolvers;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace AyCode.Services.Server.SignalRs; namespace AyCode.Services.Server.SignalRs;
public abstract class AcSignalRSendToClientService<TSignalRHub, TSignalRTags, TLogger>(IHubContext<TSignalRHub, IAcSignalRHubItemServer> signalRHub, IAcLoggerBase logger) //: IAcSignalRHubServer public abstract class AcSignalRSendToClientService<TSignalRHub, TSignalRTags, TLogger>(IHubContext<TSignalRHub, IAcSignalRHubItemServer> signalRHub, IAcLoggerBase logger)
where TSignalRHub: Hub<IAcSignalRHubItemServer>, IAcSignalRHubServer where TSignalRTags : AcSignalRTags where TLogger : IAcLoggerBase where TSignalRHub: Hub<IAcSignalRHubItemServer>, IAcSignalRHubServer where TSignalRTags : AcSignalRTags where TLogger : IAcLoggerBase
{ {
protected IAcLoggerBase Logger => logger; protected IAcLoggerBase Logger => logger;
protected virtual async Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, object? content) protected virtual async Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, object? content)
{ {
var jsonContent = new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content); var response = new SignalResponseDataMessage(messageTag, SignalResponseStatus.Success, content, AcJsonSerializerOptions.Default);
await SendMessageToClient(sendTo, messageTag, jsonContent, null); var responseBytes = response.ToBinary();
Logger.Info($"[{responseBytes.Length / 1024}kb] Server sending to client; {ConstHelper.NameByValue<TSignalRTags>(messageTag)}");
await sendTo.OnReceiveMessage(messageTag, responseBytes, null);
} }
protected virtual async Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, ISignalRMessage message, int? requestId = null) public virtual Task SendMessageToAllClients(int messageTag, object? content)
{ => SendMessageToClient(signalRHub.Clients.All, messageTag, content);
var sendingDataMessagePack = message.ToMessagePack(ContractlessStandardResolver.Options);
Logger.Info($"[{(sendingDataMessagePack.Length/1024)}kb] Server sending dataMessagePack to client; {nameof(requestId)}: {requestId}; {ConstHelper.NameByValue<TSignalRTags>(messageTag)}"); public virtual Task SendMessageToConnection(string connectionId, int messageTag, object? content)
//Logger.Info($"[{(responseDataMessagePack.Length/1024)}kb] Server sending dataMessagePack to client; {nameof(requestId)}: {requestId}; ConnectionId: {signalRHub.ConnectionId}; {ConstHelper.NameByValue<TSignalRTags>(messageTag)}"); => SendMessageToClient(signalRHub.Clients.Client(connectionId), messageTag, content);
await sendTo.OnReceiveMessage(messageTag, sendingDataMessagePack, requestId); public virtual Task SendMessageToConnections(IEnumerable<string> connectionIds, int messageTag, object? content)
} => SendMessageToClient(signalRHub.Clients.Clients(connectionIds), messageTag, content);
public virtual async Task SendMessageToAllClients(int messageTag, object? content) public virtual Task SendMessageToUser(string user, int messageTag, object? content)
{ => SendMessageToClient(signalRHub.Clients.User(user), messageTag, content);
await SendMessageToClient(signalRHub.Clients.All, messageTag, content);
}
public virtual async Task SendMessageToConnection(string connectionId, int messageTag, object? content) public virtual Task SendMessageToUsers(IEnumerable<string> users, int messageTag, object? content)
{ => SendMessageToClient(signalRHub.Clients.Users(users), messageTag, content);
await SendMessageToClient(signalRHub.Clients.Client(connectionId), messageTag, content);
}
public virtual async Task SendMessageToConnections(IEnumerable<string> connectionIds, int messageTag, object? content)
{
await SendMessageToClient(signalRHub.Clients.Clients(connectionIds), messageTag, content);
}
public virtual async Task SendMessageToUser(string user, int messageTag, object? content)
{
await SendMessageToClient(signalRHub.Clients.User(user), messageTag, content);
}
public virtual async Task SendMessageToUsers(IEnumerable<string> users, int messageTag, object? content)
{
await SendMessageToClient(signalRHub.Clients.Users(users), messageTag, content);
}
} }

View File

@ -1,12 +1,11 @@
using System.Security.Claims; using System.Buffers;
using System.Security.Claims;
using AyCode.Core; using AyCode.Core;
using AyCode.Core.Extensions; using AyCode.Core.Extensions;
using AyCode.Core.Helpers; using AyCode.Core.Helpers;
using AyCode.Core.Loggers; using AyCode.Core.Loggers;
using AyCode.Models.Server.DynamicMethods; using AyCode.Models.Server.DynamicMethods;
using AyCode.Services.SignalRs; using AyCode.Services.SignalRs;
using MessagePack;
using MessagePack.Resolvers;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
@ -96,29 +95,19 @@ public abstract class AcWebSignalRHubBase<TSignalRTags, TLogger>(IConfiguration
} }
/// <summary> /// <summary>
/// Creates a response message using the configured serializer (JSON or Binary). /// Creates a response message using the configured serializer.
/// </summary> /// </summary>
protected virtual ISignalRMessage CreateResponseMessage(int messageTag, SignalResponseStatus status, object? responseData) protected virtual ISignalRMessage CreateResponseMessage(int messageTag, SignalResponseStatus status, object? responseData)
{ {
if (SerializerOptions.SerializerType == AcSerializerType.Binary) return new SignalResponseDataMessage(messageTag, status, responseData, SerializerOptions);
{
return new SignalResponseBinaryMessage(messageTag, status, responseData, (AcBinarySerializerOptions)SerializerOptions);
}
return new SignalResponseJsonMessage(messageTag, status, responseData);
} }
/// <summary> /// <summary>
/// Gets the size of the response data for logging purposes. /// Gets the size of the response data for logging purposes.
/// </summary> /// </summary>
private int GetResponseSize(ISignalRMessage responseMessage) private static int GetResponseSize(ISignalRMessage responseMessage)
{ {
return responseMessage switch return responseMessage is SignalResponseDataMessage dataMsg ? dataMsg.ResponseData?.Length ?? 0 : 0;
{
SignalResponseJsonMessage jsonMsg => System.Text.Encoding.Unicode.GetByteCount(jsonMsg.ResponseData ?? ""),
SignalResponseBinaryMessage binaryMsg => binaryMsg.ResponseData?.Length ?? 0,
_ => 0
};
} }
/// <summary> /// <summary>
@ -154,68 +143,55 @@ public abstract class AcWebSignalRHubBase<TSignalRTags, TLogger>(IConfiguration
/// <summary> /// <summary>
/// Deserializes parameters from the message based on method signature. /// Deserializes parameters from the message based on method signature.
/// Returns null if no parameters needed, or throws if message is invalid. /// Uses Binary serialization for message wrapper.
/// </summary> /// </summary>
private static object[]? DeserializeParameters(byte[]? message, AcMethodInfoModel<SignalRAttribute> methodInfoModel, string tagName, string methodName) private static object[]? DeserializeParameters(byte[]? message, AcMethodInfoModel<SignalRAttribute> methodInfoModel, string tagName, string methodName)
{ {
if (methodInfoModel.ParamInfos is not { Length: > 0 }) if (methodInfoModel.ParamInfos is not { Length: > 0 })
return null; return null;
// Validate message - required when method has parameters
if (message is null or { Length: 0 }) if (message is null or { Length: 0 })
throw new ArgumentException($"Message is null or empty but method '{methodName}' requires {methodInfoModel.ParamInfos.Length} parameter(s); {tagName}"); throw new ArgumentException($"Message is null or empty but method '{methodName}' requires {methodInfoModel.ParamInfos.Length} parameter(s); {tagName}");
var paramValues = new object[methodInfoModel.ParamInfos.Length]; var paramValues = new object[methodInfoModel.ParamInfos.Length];
var firstParamType = methodInfoModel.ParamInfos[0].ParameterType; var firstParamType = methodInfoModel.ParamInfos[0].ParameterType;
// Use IdMessage format for: multiple params OR primitives/strings/enums/value types // First, try to deserialize as SignalPostJsonMessage to get raw PostDataJson
if (methodInfoModel.ParamInfos.Length > 1 || IsPrimitiveOrStringOrEnum(firstParamType)) var msgBase = SignalRSerializationHelper.DeserializeFromBinary<SignalPostJsonMessage>(message);
if (msgBase?.PostDataJson == null || string.IsNullOrEmpty(msgBase.PostDataJson))
{ {
// Use ContractlessStandardResolver to match client serialization throw new ArgumentException($"Failed to deserialize message for method '{methodName}'; {tagName}");
var msg = message.MessagePackTo<SignalPostJsonDataMessage<IdMessage>>(ContractlessStandardResolver.Options);
for (var i = 0; i < msg.PostData.Ids.Count; i++)
{
var paramType = methodInfoModel.ParamInfos[i].ParameterType;
// Direct JSON deserialization using AcJsonDeserializer (supports primitives)
paramValues[i] = AcJsonDeserializer.Deserialize(msg.PostData.Ids[i], paramType)!;
} }
}
else
{
// Single complex object - try to detect format by checking if it's an IdMessage
var msgJson = message.MessagePackTo<SignalPostJsonDataMessage<object>>(ContractlessStandardResolver.Options);
var json = msgJson.PostDataJson;
// Check if the JSON is an IdMessage format (has "Ids" property) var json = msgBase.PostDataJson;
// Check if it's an IdMessage format (contains "Ids" property)
if (json.Contains("\"Ids\"")) if (json.Contains("\"Ids\""))
{ {
// It's IdMessage format - deserialize as IdMessage and get first Id // Parse as IdMessage - each Id is a JSON string for a parameter
var idMsg = message.MessagePackTo<SignalPostJsonDataMessage<IdMessage>>(ContractlessStandardResolver.Options); var idMessage = json.JsonTo<IdMessage>();
if (idMsg.PostData.Ids.Count > 0) if (idMessage?.Ids != null && idMessage.Ids.Count > 0)
{ {
paramValues[0] = AcJsonDeserializer.Deserialize(idMsg.PostData.Ids[0], firstParamType)!; for (var i = 0; i < idMessage.Ids.Count && i < methodInfoModel.ParamInfos.Length; i++)
{
var paramType = methodInfoModel.ParamInfos[i].ParameterType;
paramValues[i] = AcJsonDeserializer.Deserialize(idMessage.Ids[i], paramType)!;
}
return paramValues; return paramValues;
} }
} }
// Direct complex object format // Single complex object - deserialize directly from PostDataJson
paramValues[0] = json.JsonTo(firstParamType)!; paramValues[0] = json.JsonTo(firstParamType)!;
}
return paramValues; return paramValues;
} }
/// <summary> /// <summary>
/// Determines if a type should use IdMessage format (primitives, strings, enums, value types). /// Determines if a type should use IdMessage format.
/// NOTE: Arrays and collections are NOT included - they use PostDataJson format when sent as single parameter.
/// </summary> /// </summary>
private static bool IsPrimitiveOrStringOrEnum(Type type) private static bool IsPrimitiveOrStringOrEnum(Type type)
{ {
return type == typeof(string) || return type == typeof(string) || type.IsEnum || type.IsValueType || type == typeof(DateTime);
type.IsEnum ||
type.IsValueType ||
type == typeof(DateTime);
} }
#endregion #endregion
@ -246,40 +222,29 @@ public abstract class AcWebSignalRHubBase<TSignalRTags, TLogger>(IConfiguration
protected virtual Task SendMessageToAll(int messageTag, object? content) protected virtual Task SendMessageToAll(int messageTag, object? content)
=> SendMessageToClient(Clients.All, messageTag, CreateResponseMessage(messageTag, SignalResponseStatus.Success, content), null); => SendMessageToClient(Clients.All, messageTag, CreateResponseMessage(messageTag, SignalResponseStatus.Success, content), null);
/// <summary>
/// Sends message to client using Binary serialization.
/// </summary>
protected virtual async Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, ISignalRMessage message, int? requestId = null) protected virtual async Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, ISignalRMessage message, int? requestId = null)
{ {
var responseDataMessagePack = message.ToMessagePack(ContractlessStandardResolver.Options); var responseBytes = SignalRSerializationHelper.SerializeToBinary(message);
var tagName = ConstHelper.NameByValue<TSignalRTags>(messageTag); var tagName = ConstHelper.NameByValue<TSignalRTags>(messageTag);
Logger.Debug($"[{responseDataMessagePack.Length / 1024}kb] Server sending message to client; requestId: {requestId}; Aborted: {IsConnectionAborted()}; ConnectionId: {GetConnectionId()}; {tagName}"); Logger.Debug($"[{responseBytes.Length / 1024}kb] Server sending message to client; requestId: {requestId}; Aborted: {IsConnectionAborted()}; ConnectionId: {GetConnectionId()}; {tagName}");
await sendTo.OnReceiveMessage(messageTag, responseDataMessagePack, requestId); await sendTo.OnReceiveMessage(messageTag, responseBytes, requestId);
Logger.Debug($"Server sent message to client; requestId: {requestId}; ConnectionId: {GetConnectionId()}; {tagName}"); Logger.Debug($"Server sent message to client; requestId: {requestId}; ConnectionId: {GetConnectionId()}; {tagName}");
} }
#endregion #endregion
#region Context Accessor Methods (virtual for testing) #region Context Accessor Methods
/// <summary>
/// Gets the connection ID. Override in tests to avoid Context dependency.
/// </summary>
protected virtual string GetConnectionId() => Context.ConnectionId; protected virtual string GetConnectionId() => Context.ConnectionId;
/// <summary>
/// Gets whether the connection is aborted. Override in tests to avoid Context dependency.
/// </summary>
protected virtual bool IsConnectionAborted() => Context.ConnectionAborted.IsCancellationRequested; protected virtual bool IsConnectionAborted() => Context.ConnectionAborted.IsCancellationRequested;
/// <summary>
/// Gets the user identifier. Override in tests to avoid Context dependency.
/// </summary>
protected virtual string? GetUserIdentifier() => Context.UserIdentifier; protected virtual string? GetUserIdentifier() => Context.UserIdentifier;
/// <summary>
/// Gets the ClaimsPrincipal user. Override in tests to avoid Context dependency.
/// </summary>
protected virtual ClaimsPrincipal? GetUser() => Context.User; protected virtual ClaimsPrincipal? GetUser() => Context.User;
#endregion #endregion

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,5 @@
using AyCode.Core.Extensions; using AyCode.Core.Extensions;
using AyCode.Services.SignalRs; using AyCode.Services.SignalRs;
using MessagePack.Resolvers;
namespace AyCode.Services.Tests.SignalRs; namespace AyCode.Services.Tests.SignalRs;
@ -10,7 +9,6 @@ public class PostJsonDataMessageTests
[TestMethod] [TestMethod]
public void Debug_CreatePostMessage_ForInt() public void Debug_CreatePostMessage_ForInt()
{ {
// Test what CreatePostMessage produces for an int
var message = CreatePostMessageTest(42); var message = CreatePostMessageTest(42);
Console.WriteLine($"Message type: {message.GetType().Name}"); Console.WriteLine($"Message type: {message.GetType().Name}");
@ -22,17 +20,15 @@ public class PostJsonDataMessageTests
Console.WriteLine($"PostData.Ids[0]: {idMsg.PostData.Ids[0]}"); Console.WriteLine($"PostData.Ids[0]: {idMsg.PostData.Ids[0]}");
} }
// Serialize to MessagePack var bytes = message.ToBinary();
var bytes = message.ToMessagePack(ContractlessStandardResolver.Options); Console.WriteLine($"Binary bytes: {bytes.Length}");
Console.WriteLine($"MessagePack bytes: {bytes.Length}");
// Deserialize as server would var deserialized = bytes.BinaryTo<SignalPostJsonDataMessage<IdMessage>>();
var deserialized = bytes.MessagePackTo<SignalPostJsonDataMessage<IdMessage>>(ContractlessStandardResolver.Options); Console.WriteLine($"Deserialized PostDataJson: {deserialized?.PostDataJson}");
Console.WriteLine($"Deserialized PostDataJson: {deserialized.PostDataJson}"); Console.WriteLine($"Deserialized PostData type: {deserialized?.PostData?.GetType().Name}");
Console.WriteLine($"Deserialized PostData type: {deserialized.PostData?.GetType().Name}"); Console.WriteLine($"Deserialized PostData.Ids.Count: {deserialized?.PostData?.Ids.Count}");
Console.WriteLine($"Deserialized PostData.Ids.Count: {deserialized.PostData?.Ids.Count}");
Assert.IsNotNull(deserialized.PostData); Assert.IsNotNull(deserialized?.PostData);
Assert.AreEqual(1, deserialized.PostData.Ids.Count); Assert.AreEqual(1, deserialized.PostData.Ids.Count);
} }
@ -52,78 +48,57 @@ public class PostJsonDataMessageTests
return null; return null;
} }
// Step 1: Client creates message for int parameter (like PostDataAsync<int, string>)
Console.WriteLine("=== Step 1: Client creates message ==="); Console.WriteLine("=== Step 1: Client creates message ===");
var idMessage = new IdMessage(GetValueByType(testValue)); var idMessage = new IdMessage(GetValueByType(testValue));
Console.WriteLine($"IdMessage.Ids[0]: '{idMessage.Ids[0]}'"); Console.WriteLine($"IdMessage.Ids[0]: '{idMessage.Ids[0]}'");
var clientMessage = new SignalPostJsonDataMessage<IdMessage>(idMessage); var clientMessage = new SignalPostJsonDataMessage<IdMessage>(idMessage);
Console.WriteLine($"Client PostDataJson: '{clientMessage.PostDataJson}'"); Console.WriteLine($"Client PostDataJson: '{clientMessage.PostDataJson}'");
// Step 2: Serialize to MessagePack (client sends) Console.WriteLine("\n=== Step 2: Binary serialization ===");
Console.WriteLine("\n=== Step 2: MessagePack serialization ==="); var bytes = clientMessage.ToBinary();
var bytes = clientMessage.ToMessagePack(ContractlessStandardResolver.Options); Console.WriteLine($"Binary bytes: {bytes.Length}");
Console.WriteLine($"MessagePack bytes: {bytes.Length}");
// Step 3: Server deserializes
Console.WriteLine("\n=== Step 3: Server deserializes ==="); Console.WriteLine("\n=== Step 3: Server deserializes ===");
var serverMessage = bytes.MessagePackTo<SignalPostJsonDataMessage<IdMessage>>(ContractlessStandardResolver.Options); var serverMessage = bytes.BinaryTo<SignalPostJsonDataMessage<IdMessage>>();
Console.WriteLine($"Server PostDataJson: '{serverMessage.PostDataJson}'"); Console.WriteLine($"Server PostDataJson: '{serverMessage?.PostDataJson}'");
Console.WriteLine($"Server PostData.Ids.Count: {serverMessage.PostData?.Ids.Count}"); Console.WriteLine($"Server PostData.Ids.Count: {serverMessage?.PostData?.Ids.Count}");
Console.WriteLine($"Server PostData.Ids[0]: '{serverMessage.PostData?.Ids[0]}'"); Console.WriteLine($"Server PostData.Ids[0]: '{serverMessage?.PostData?.Ids[0]}'");
// Step 4: Server deserializes parameter
Console.WriteLine("\n=== Step 4: Server deserializes parameter ==="); Console.WriteLine("\n=== Step 4: Server deserializes parameter ===");
var paramJson = serverMessage.PostData.Ids[0]; var paramJson = serverMessage!.PostData.Ids[0];
Console.WriteLine($"Parameter JSON: '{paramJson}'"); Console.WriteLine($"Parameter JSON: '{paramJson}'");
var paramValue = AcJsonDeserializer.Deserialize(paramJson, testValue.GetType()); var paramValue = AcJsonDeserializer.Deserialize(paramJson, testValue.GetType());
Console.WriteLine($"Deserialized int value: {paramValue}"); Console.WriteLine($"Deserialized value: {paramValue}");
// Step 5: Service method returns string
Console.WriteLine("\n=== Step 5: Service method returns ==="); Console.WriteLine("\n=== Step 5: Service method returns ===");
var serviceResult = $"{paramValue}"; // Like HandleSingleInt does var serviceResult = $"{paramValue}";
Console.WriteLine($"Service result: '{serviceResult}'"); Console.WriteLine($"Service result: '{serviceResult}'");
// Step 6: Server creates response
Console.WriteLine("\n=== Step 6: Server creates response ==="); Console.WriteLine("\n=== Step 6: Server creates response ===");
var response = new SignalResponseJsonMessage(100, SignalResponseStatus.Success, serviceResult); var response = new SignalResponseDataMessage(100, SignalResponseStatus.Success, serviceResult, AcJsonSerializerOptions.Default);
Console.WriteLine($"Response.ResponseData: '{response.ResponseData}'"); Console.WriteLine($"Response created with Binary bytes: {response.ResponseData?.Length ?? 0}");
// Step 7: Serialize response to MessagePack Console.WriteLine("\n=== Step 7: Response Binary ===");
Console.WriteLine("\n=== Step 7: Response MessagePack ==="); var responseBytes = response.ToBinary();
var responseBytes = response.ToMessagePack(ContractlessStandardResolver.Options); Console.WriteLine($"Response Binary bytes: {responseBytes.Length}");
Console.WriteLine($"Response MessagePack bytes: {responseBytes.Length}");
// Step 8: Client deserializes response
Console.WriteLine("\n=== Step 8: Client deserializes response ==="); Console.WriteLine("\n=== Step 8: Client deserializes response ===");
var clientResponse = responseBytes.MessagePackTo<SignalResponseJsonMessage>(ContractlessStandardResolver.Options); var clientResponse = responseBytes.BinaryTo<SignalResponseDataMessage>();
Console.WriteLine($"Client ResponseData: '{clientResponse.ResponseData}'"); Console.WriteLine($"Client Response Status: {clientResponse?.Status}");
// Step 9: Client deserializes to target type (string)
Console.WriteLine("\n=== Step 9: Client deserializes to string ==="); Console.WriteLine("\n=== Step 9: Client deserializes to string ===");
try var finalResult = clientResponse?.GetResponseData<string>();
{
var finalResult = clientResponse.ResponseData.JsonTo<string>();
Console.WriteLine($"Final result: '{finalResult}'"); Console.WriteLine($"Final result: '{finalResult}'");
Assert.AreEqual(GetValueByType(testValue).ToString(), finalResult); Assert.AreEqual(GetValueByType(testValue).ToString(), finalResult);
} }
catch (Exception ex)
{
Console.WriteLine($"ERROR: {ex.Message}");
throw;
}
}
private static ISignalRMessage CreatePostMessageTest<TPostData>(TPostData postData) private static ISignalRMessage CreatePostMessageTest<TPostData>(TPostData postData)
{ {
var type = typeof(TPostData); var type = typeof(TPostData);
if (type == typeof(string) || type.IsEnum || type.IsValueType || type == typeof(DateTime)) if (type == typeof(string) || type.IsEnum || type.IsValueType || type == typeof(DateTime))
{
return new SignalPostJsonDataMessage<IdMessage>(new IdMessage(postData!)); return new SignalPostJsonDataMessage<IdMessage>(new IdMessage(postData!));
}
return new SignalPostJsonDataMessage<TPostData>(postData); return new SignalPostJsonDataMessage<TPostData>(postData);
} }

View File

@ -1,30 +0,0 @@
using AyCode.Services.SignalRs;
namespace AyCode.Services.Tests.SignalRs;
/// <summary>
/// SignalR message tags for client testing.
/// </summary>
public static class TestClientTags
{
// Basic operations
public const int Ping = 1;
public const int Echo = 2;
public const int GetStatus = 3;
// CRUD operations
public const int GetById = 10;
public const int GetAll = 11;
public const int Create = 12;
public const int Update = 13;
public const int Delete = 14;
// Complex operations
public const int GetOrderWithItems = 20;
public const int PostOrder = 21;
public const int GetMultipleParams = 22;
// Error scenarios
public const int NotFound = 100;
public const int ServerError = 101;
}

View File

@ -1,231 +0,0 @@
using AyCode.Core;
using AyCode.Core.Extensions;
using AyCode.Core.Tests.TestModels;
using AyCode.Services.SignalRs;
using MessagePack.Resolvers;
using Microsoft.AspNetCore.SignalR.Client;
namespace AyCode.Services.Tests.SignalRs;
/// <summary>
/// Testable SignalR client that allows testing without real HubConnection.
/// </summary>
public class TestableSignalRClient : AcSignalRClientBase
{
private HubConnectionState _connectionState = HubConnectionState.Connected;
private int? _nextRequestIdOverride;
/// <summary>
/// Messages sent to the server (captured for assertions).
/// </summary>
public List<SentClientMessage> SentMessages { get; } = [];
/// <summary>
/// Received messages (captured for assertions).
/// </summary>
public List<ReceivedClientMessage> ReceivedMessages { get; } = [];
public TestableSignalRClient(TestLogger logger) : base(logger)
{
}
#region Override virtual methods for testing
protected override HubConnectionState GetConnectionState() => _connectionState;
protected override bool IsConnected() => _connectionState == HubConnectionState.Connected;
protected override Task StartConnectionInternal()
{
_connectionState = HubConnectionState.Connected;
return Task.CompletedTask;
}
protected override Task StopConnectionInternal()
{
_connectionState = HubConnectionState.Disconnected;
return Task.CompletedTask;
}
protected override ValueTask DisposeConnectionInternal() => ValueTask.CompletedTask;
protected override Task SendToHubAsync(int messageTag, byte[]? messageBytes, int? requestId)
{
SentMessages.Add(new SentClientMessage(messageTag, messageBytes, requestId));
return Task.CompletedTask;
}
protected override int GetNextRequestId()
{
if (_nextRequestIdOverride.HasValue)
{
var id = _nextRequestIdOverride.Value;
_nextRequestIdOverride = id + 1; // Auto-increment for subsequent calls
return id;
}
return AcDomain.NextUniqueInt32;
}
protected override Task MessageReceived(int messageTag, byte[] messageBytes)
{
ReceivedMessages.Add(new ReceivedClientMessage(messageTag, messageBytes));
return Task.CompletedTask;
}
#endregion
#region Public test helpers (wrappers for protected methods)
/// <summary>
/// Sets the simulated connection state.
/// </summary>
public void SetConnectionState(HubConnectionState state) => _connectionState = state;
/// <summary>
/// Sets the next request ID for deterministic testing.
/// Will auto-increment for subsequent calls.
/// </summary>
public void SetNextRequestId(int id) => _nextRequestIdOverride = id;
/// <summary>
/// Gets the pending requests dictionary (public wrapper for testing).
/// </summary>
public new System.Collections.Concurrent.ConcurrentDictionary<int, SignalRRequestModel> GetPendingRequests()
=> base.GetPendingRequests();
/// <summary>
/// Registers a pending request (public wrapper for testing).
/// </summary>
public new void RegisterPendingRequest(int requestId, SignalRRequestModel model)
=> base.RegisterPendingRequest(requestId, model);
/// <summary>
/// Clears pending requests (public wrapper for testing).
/// </summary>
public new void ClearPendingRequests() => base.ClearPendingRequests();
/// <summary>
/// Simulates receiving a response from the server.
/// </summary>
public Task SimulateServerResponse(int requestId, int messageTag, SignalResponseStatus status, object? data = null)
{
var response = new SignalResponseJsonMessage(messageTag, status, data);
var bytes = response.ToMessagePack(ContractlessStandardResolver.Options);
return OnReceiveMessage(messageTag, bytes, requestId);
}
/// <summary>
/// Simulates receiving a success response from the server.
/// </summary>
public Task SimulateSuccessResponse<T>(int requestId, int messageTag, T data)
=> SimulateServerResponse(requestId, messageTag, SignalResponseStatus.Success, data);
/// <summary>
/// Simulates receiving an error response from the server.
/// </summary>
public Task SimulateErrorResponse(int requestId, int messageTag)
=> SimulateServerResponse(requestId, messageTag, SignalResponseStatus.Error);
/// <summary>
/// Gets the last sent message.
/// </summary>
public SentClientMessage? LastSentMessage => SentMessages.LastOrDefault();
/// <summary>
/// Clears all captured messages.
/// </summary>
public void ClearMessages()
{
SentMessages.Clear();
ReceivedMessages.Clear();
}
/// <summary>
/// Invokes OnReceiveMessage directly for testing.
/// </summary>
public Task InvokeOnReceiveMessage(int messageTag, byte[] messageBytes, int? requestId)
=> OnReceiveMessage(messageTag, messageBytes, requestId);
#endregion
}
/// <summary>
/// Represents a message sent from client to server.
/// </summary>
public record SentClientMessage(int MessageTag, byte[]? MessageBytes, int? RequestId)
{
/// <summary>
/// Deserializes the message to IdMessage format.
/// Works with both production SignalPostJsonDataMessage and test SignalRPostMessageDto.
/// </summary>
public IdMessage? AsIdMessage()
{
if (MessageBytes == null) return null;
try
{
// First deserialize to get the PostDataJson string
var msg = MessageBytes.MessagePackTo<SignalPostJsonDataMessage<IdMessage>>(ContractlessStandardResolver.Options);
return msg.PostData;
}
catch
{
// Fallback: try deserializing as raw JSON wrapper
try
{
var rawMsg = MessageBytes.MessagePackTo<SignalPostJsonMessage>(ContractlessStandardResolver.Options);
return rawMsg.PostDataJson?.JsonTo<IdMessage>();
}
catch
{
return null;
}
}
}
/// <summary>
/// Deserializes the message to a specific post data type.
/// </summary>
public T? AsPostData<T>() where T : class
{
if (MessageBytes == null) return null;
try
{
var msg = MessageBytes.MessagePackTo<SignalPostJsonDataMessage<T>>(ContractlessStandardResolver.Options);
return msg.PostData;
}
catch
{
// Fallback: try deserializing as raw JSON wrapper
try
{
var rawMsg = MessageBytes.MessagePackTo<SignalPostJsonMessage>(ContractlessStandardResolver.Options);
return rawMsg.PostDataJson?.JsonTo<T>();
}
catch
{
return null;
}
}
}
}
/// <summary>
/// Represents a message received by the client.
/// </summary>
public record ReceivedClientMessage(int MessageTag, byte[] MessageBytes)
{
/// <summary>
/// Deserializes the message as a response.
/// </summary>
public SignalResponseJsonMessage? AsResponse()
{
try
{
return MessageBytes.MessagePackTo<SignalResponseJsonMessage>(ContractlessStandardResolver.Options);
}
catch
{
return null;
}
}
}

View File

@ -4,12 +4,8 @@ using AyCode.Core.Extensions;
using AyCode.Core.Helpers; using AyCode.Core.Helpers;
using AyCode.Core.Loggers; using AyCode.Core.Loggers;
using AyCode.Interfaces.Entities; using AyCode.Interfaces.Entities;
using MessagePack.Resolvers;
using Microsoft.AspNetCore.Connections;
using Microsoft.AspNetCore.Http.Connections; using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.SignalR.Client; using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.Logging;
using static AyCode.Core.Extensions.JsonUtilities;
namespace AyCode.Services.SignalRs namespace AyCode.Services.SignalRs
{ {
@ -20,7 +16,6 @@ namespace AyCode.Services.SignalRs
protected readonly HubConnection? HubConnection; protected readonly HubConnection? HubConnection;
protected readonly AcLoggerBase Logger; protected readonly AcLoggerBase Logger;
//protected event Action<int, byte[], int?> OnMessageReceived = null!;
protected abstract Task MessageReceived(int messageTag, byte[] messageBytes); protected abstract Task MessageReceived(int messageTag, byte[] messageBytes);
public int MsDelay = 25; public int MsDelay = 25;
@ -30,67 +25,30 @@ namespace AyCode.Services.SignalRs
public int TransportSendTimeout = 60000; public int TransportSendTimeout = 60000;
private const string TagsName = "SignalRTags"; private const string TagsName = "SignalRTags";
/// <summary>
/// Production constructor - creates and starts HubConnection.
/// </summary>
protected AcSignalRClientBase(string fullHubName, AcLoggerBase logger) protected AcSignalRClientBase(string fullHubName, AcLoggerBase logger)
{ {
Logger = logger; Logger = logger;
Logger.Detail(fullHubName); Logger.Detail(fullHubName);
//TODO: HubConnectionBuilder constructor!!! - J.
HubConnection = new HubConnectionBuilder() HubConnection = new HubConnectionBuilder()
//.WithUrl(fullHubName)
.WithUrl(fullHubName, HttpTransportType.WebSockets, .WithUrl(fullHubName, HttpTransportType.WebSockets,
options => options =>
{ {
options.TransportMaxBufferSize = 30_000_000; //Increasing this value allows the client to receive larger messages. default: 65KB; unlimited: 0;; options.TransportMaxBufferSize = 30_000_000;
options.ApplicationMaxBufferSize = 30_000_000; //Increasing this value allows the client to send larger messages. default: 65KB; unlimited: 0; options.ApplicationMaxBufferSize = 30_000_000;
options.CloseTimeout = TimeSpan.FromSeconds(10); //default: 5 sec. options.CloseTimeout = TimeSpan.FromSeconds(10);
options.SkipNegotiation = true; // Skip HTTP negotiation when using WebSockets only options.SkipNegotiation = true;
//options.AccessTokenProvider = null;
//options.HttpMessageHandlerFactory = null;
//options.Headers["CustomData"] = "value";
//options.SkipNegotiation = true;
//options.ClientCertificates = new System.Security.Cryptography.X509Certificates.X509CertificateCollection();
//options.Cookies = new System.Net.CookieContainer();
//options.DefaultTransferFormat = TransferFormat.Text;
//options.Credentials = null;
//options.Proxy = null;
//options.UseDefaultCredentials = true;
//options.WebSocketConfiguration = null;
//options.WebSocketFactory = null;
}) })
//.ConfigureLogging(logging =>
//{
// logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Trace);
// logging.AddConsole();
//})
.WithAutomaticReconnect() .WithAutomaticReconnect()
.WithStatefulReconnect() .WithStatefulReconnect()
.WithKeepAliveInterval(TimeSpan.FromSeconds(60)) .WithKeepAliveInterval(TimeSpan.FromSeconds(60))
.WithServerTimeout(TimeSpan.FromSeconds(180)) .WithServerTimeout(TimeSpan.FromSeconds(180))
//.AddMessagePackProtocol(options => {
// options.SerializerOptions = MessagePackSerializerOptions.Standard
// .WithResolver(MessagePack.Resolvers.StandardResolver.Instance)
// .WithSecurity(MessagePackSecurity.UntrustedData)
// .WithCompression(MessagePackCompression.Lz4Block)
// .WithCompressionMinLength(256);})
.Build(); .Build();
HubConnection.Closed += HubConnection_Closed; HubConnection.Closed += HubConnection_Closed;
_ = HubConnection.On<int, byte[], int?>(nameof(IAcSignalRHubClient.OnReceiveMessage), OnReceiveMessage); _ = HubConnection.On<int, byte[], int?>(nameof(IAcSignalRHubClient.OnReceiveMessage), OnReceiveMessage);
//_ = HubConnection.On<int, int>(nameof(IAcSignalRHubClient.OnRequestMessage), OnRequestMessage);
//HubConnection.StartAsync().Forget();
} }
/// <summary>
/// Test constructor - allows testing without real HubConnection.
/// Override virtual methods to control behavior in tests.
/// </summary>
protected AcSignalRClientBase(AcLoggerBase logger) protected AcSignalRClientBase(AcLoggerBase logger)
{ {
Logger = logger; Logger = logger;
@ -99,96 +57,40 @@ namespace AyCode.Services.SignalRs
private Task HubConnection_Closed(Exception? arg) private Task HubConnection_Closed(Exception? arg)
{ {
if (_responseByRequestId.IsEmpty) Logger.DebugConditional($"Client HubConnection_Closed"); if (_responseByRequestId.IsEmpty) Logger.DebugConditional("Client HubConnection_Closed");
else Logger.Warning($"Client HubConnection_Closed; {nameof(_responseByRequestId)} count: {_responseByRequestId.Count}"); else Logger.Warning($"Client HubConnection_Closed; {nameof(_responseByRequestId)} count: {_responseByRequestId.Count}");
ClearPendingRequests(); ClearPendingRequests();
return Task.CompletedTask; return Task.CompletedTask;
} }
#region Connection State Methods (virtual for testing) #region Connection State Methods
/// <summary>
/// Gets the current connection state. Override in tests.
/// </summary>
protected virtual HubConnectionState GetConnectionState() protected virtual HubConnectionState GetConnectionState()
=> HubConnection?.State ?? HubConnectionState.Disconnected; => HubConnection?.State ?? HubConnectionState.Disconnected;
/// <summary>
/// Checks if the connection is connected. Override in tests.
/// </summary>
protected virtual bool IsConnected() protected virtual bool IsConnected()
=> GetConnectionState() == HubConnectionState.Connected; => GetConnectionState() == HubConnectionState.Connected;
/// <summary>
/// Starts the connection. Override in tests to avoid real connection.
/// </summary>
protected virtual Task StartConnectionInternal() protected virtual Task StartConnectionInternal()
{ => HubConnection?.StartAsync() ?? Task.CompletedTask;
if (HubConnection == null) return Task.CompletedTask;
return HubConnection.StartAsync();
}
/// <summary>
/// Stops the connection. Override in tests.
/// </summary>
protected virtual Task StopConnectionInternal() protected virtual Task StopConnectionInternal()
{ => HubConnection?.StopAsync() ?? Task.CompletedTask;
if (HubConnection == null) return Task.CompletedTask;
return HubConnection.StopAsync();
}
/// <summary>
/// Disposes the connection. Override in tests.
/// </summary>
protected virtual ValueTask DisposeConnectionInternal() protected virtual ValueTask DisposeConnectionInternal()
{ => HubConnection?.DisposeAsync() ?? ValueTask.CompletedTask;
if (HubConnection == null) return ValueTask.CompletedTask;
return HubConnection.DisposeAsync();
}
/// <summary>
/// Sends a message to the server via HubConnection. Override in tests.
/// </summary>
protected virtual Task SendToHubAsync(int messageTag, byte[]? messageBytes, int? requestId) protected virtual Task SendToHubAsync(int messageTag, byte[]? messageBytes, int? requestId)
{ => HubConnection?.SendAsync(nameof(IAcSignalRHubClient.OnReceiveMessage), messageTag, messageBytes, requestId) ?? Task.CompletedTask;
if (HubConnection == null) return Task.CompletedTask;
return HubConnection.SendAsync(nameof(IAcSignalRHubClient.OnReceiveMessage), messageTag, messageBytes, requestId);
}
#endregion #endregion
#region Protected Test Helpers #region Protected Test Helpers
/// <summary> protected ConcurrentDictionary<int, SignalRRequestModel> GetPendingRequests() => _responseByRequestId;
/// Gets the pending requests dictionary for testing. protected void ClearPendingRequests() => _responseByRequestId.Clear();
/// </summary> protected void RegisterPendingRequest(int requestId, SignalRRequestModel model) => _responseByRequestId[requestId] = model;
protected ConcurrentDictionary<int, SignalRRequestModel> GetPendingRequests()
=> _responseByRequestId;
/// <summary>
/// Clears all pending requests.
/// </summary>
protected void ClearPendingRequests()
=> _responseByRequestId.Clear();
/// <summary>
/// Registers a pending request for testing.
/// </summary>
protected void RegisterPendingRequest(int requestId, SignalRRequestModel model)
=> _responseByRequestId[requestId] = model;
/// <summary>
/// Simulates receiving a response for testing.
/// </summary>
protected void SimulateResponse(int requestId, ISignalResponseMessage<string> response)
{
if (_responseByRequestId.TryGetValue(requestId, out var model))
{
model.ResponseByRequestId = response;
model.ResponseDateTime = DateTime.UtcNow;
}
}
#endregion #endregion
@ -216,7 +118,7 @@ namespace AyCode.Services.SignalRs
await StartConnection(); await StartConnection();
var msgp = message?.ToMessagePack(ContractlessStandardResolver.Options); var msgBytes = message != null ? SignalRSerializationHelper.SerializeToBinary(message) : null;
if (!IsConnected()) if (!IsConnected())
{ {
@ -224,109 +126,128 @@ namespace AyCode.Services.SignalRs
return; return;
} }
await SendToHubAsync(messageTag, msgp, requestId); await SendToHubAsync(messageTag, msgBytes, requestId);
} }
#region CRUD #region CRUD
public virtual Task<TResponseData?> PostAsync<TResponseData>(int messageTag, object parameter) //where TResponseData : class
public virtual Task<TResponseData?> PostAsync<TResponseData>(int messageTag, object parameter)
=> SendMessageToServerAsync<TResponseData>(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(parameter)), GetNextRequestId()); => SendMessageToServerAsync<TResponseData>(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(parameter)), GetNextRequestId());
public virtual Task<TResponseData?> PostAsync<TResponseData>(int messageTag, object[] parameters) //where TResponseData : class public virtual Task<TResponseData?> PostAsync<TResponseData>(int messageTag, object[] parameters)
=> SendMessageToServerAsync<TResponseData>(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(parameters)), GetNextRequestId()); => SendMessageToServerAsync<TResponseData>(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(parameters)), GetNextRequestId());
public virtual Task<TResponseData?> GetByIdAsync<TResponseData>(int messageTag, object id) //where TResponseData : class public virtual Task<TResponseData?> GetByIdAsync<TResponseData>(int messageTag, object id)
=> PostAsync<TResponseData?>(messageTag, id); => PostAsync<TResponseData?>(messageTag, id);
public virtual Task GetByIdAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback, object id) public virtual Task<TResponseData?> GetByIdAsync<TResponseData>(int messageTag, object[] ids)
=> SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(id)), responseCallback);
public virtual Task<TResponseData?> GetByIdAsync<TResponseData>(int messageTag, object[] ids) //where TResponseData : class
=> PostAsync<TResponseData?>(messageTag, ids); => PostAsync<TResponseData?>(messageTag, ids);
public virtual Task GetByIdAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback, object[] ids) /// <summary>
/// Gets data by ID with async callback response. Callback is second parameter.
/// </summary>
public virtual Task GetByIdAsync<TResponseData>(int messageTag, Func<SignalResponseDataMessage, Task> responseCallback, object id)
=> SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(id)), responseCallback);
/// <summary>
/// Gets data by IDs with async callback response. Callback is second parameter.
/// </summary>
public virtual Task GetByIdAsync<TResponseData>(int messageTag, Func<SignalResponseDataMessage, Task> responseCallback, object[] ids)
=> SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(ids)), responseCallback); => SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(ids)), responseCallback);
public virtual Task<TResponseData?> GetAllAsync<TResponseData>(int messageTag) //where TResponseData : class public virtual Task<TResponseData?> GetAllAsync<TResponseData>(int messageTag)
=> SendMessageToServerAsync<TResponseData>(messageTag); => SendMessageToServerAsync<TResponseData>(messageTag);
public virtual Task GetAllAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback)
public virtual Task<TResponseData?> GetAllAsync<TResponseData>(int messageTag, object[]? contextParams)
=> SendMessageToServerAsync<TResponseData>(messageTag, contextParams == null || contextParams.Length == 0 ? null : new SignalPostJsonDataMessage<IdMessage>(new IdMessage(contextParams)), GetNextRequestId());
/// <summary>
/// Gets all data with async callback response. Callback is second parameter.
/// </summary>
public virtual Task GetAllAsync<TResponseData>(int messageTag, Func<SignalResponseDataMessage, Task> responseCallback)
=> SendMessageToServerAsync(messageTag, null, responseCallback); => SendMessageToServerAsync(messageTag, null, responseCallback);
public virtual Task GetAllAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback, object[]? contextParams) /// <summary>
=> SendMessageToServerAsync(messageTag, (contextParams == null || contextParams.Length == 0 ? null : new SignalPostJsonDataMessage<IdMessage>(new IdMessage(contextParams))), responseCallback); /// Gets all data with context params and async callback response.
public virtual Task<TResponseData?> GetAllAsync<TResponseData>(int messageTag, object[]? contextParams) //where TResponseData : class /// </summary>
=> SendMessageToServerAsync<TResponseData>(messageTag, contextParams == null || contextParams.Length == 0 ? null : new SignalPostJsonDataMessage<IdMessage>(new IdMessage(contextParams)), GetNextRequestId()); public virtual Task GetAllAsync<TResponseData>(int messageTag, Func<SignalResponseDataMessage, Task> responseCallback, object[]? contextParams)
=> SendMessageToServerAsync(messageTag, contextParams == null || contextParams.Length == 0 ? null : new SignalPostJsonDataMessage<IdMessage>(new IdMessage(contextParams)), responseCallback);
public virtual Task<TPostData?> PostDataAsync<TPostData>(int messageTag, TPostData postData) where TPostData : class public virtual Task<TPostData?> PostDataAsync<TPostData>(int messageTag, TPostData postData) where TPostData : class
=> SendMessageToServerAsync<TPostData>(messageTag, CreatePostMessage(postData), GetNextRequestId()); => SendMessageToServerAsync<TPostData>(messageTag, CreatePostMessage(postData), GetNextRequestId());
public virtual Task<TResponseData?> PostDataAsync<TPostData, TResponseData>(int messageTag, TPostData postData) //where TPostData : class where TResponseData : class
public virtual Task<TResponseData?> PostDataAsync<TPostData, TResponseData>(int messageTag, TPostData postData)
=> SendMessageToServerAsync<TResponseData>(messageTag, CreatePostMessage(postData), GetNextRequestId()); => SendMessageToServerAsync<TResponseData>(messageTag, CreatePostMessage(postData), GetNextRequestId());
public virtual Task PostDataAsync<TPostData>(int messageTag, TPostData postData, Func<ISignalResponseMessage<TPostData?>, Task> responseCallback) //where TPostData : class /// <summary>
=> SendMessageToServerAsync(messageTag, CreatePostMessage(postData), responseCallback); /// Posts data with async callback response.
public virtual Task PostDataAsync<TPostData, TResponseData>(int messageTag, TPostData postData, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback) //where TPostData : class where TResponseData : class /// </summary>
public virtual Task PostDataAsync<TPostData>(int messageTag, TPostData postData, Func<SignalResponseDataMessage, Task> responseCallback)
=> SendMessageToServerAsync(messageTag, CreatePostMessage(postData), responseCallback); => SendMessageToServerAsync(messageTag, CreatePostMessage(postData), responseCallback);
/// <summary> /// <summary>
/// Creates the appropriate message wrapper for the post data. /// Posts data with typed async callback response.
/// Primitives, strings, enums, and value types are wrapped in IdMessage.
/// Complex objects are sent directly in SignalPostJsonDataMessage.
/// </summary> /// </summary>
public virtual Task PostDataAsync<TPostData, TResponseData>(int messageTag, TPostData postData, Func<SignalResponseDataMessage, Task> responseCallback)
=> SendMessageToServerAsync(messageTag, CreatePostMessage(postData), responseCallback);
/// <summary>
/// Posts data and invokes callback with response. Fire-and-forget friendly for background saves.
/// </summary>
public virtual Task PostDataAsync<TPostData>(int messageTag, TPostData postData, Action<SignalResponseDataMessage> responseCallback)
{
var requestId = GetNextRequestId();
var requestModel = SignalRRequestModelPool.Get(responseCallback);
_responseByRequestId[requestId] = requestModel;
return SendMessageToServerAsync(messageTag, CreatePostMessage(postData), requestId);
}
private static ISignalRMessage CreatePostMessage<TPostData>(TPostData postData) private static ISignalRMessage CreatePostMessage<TPostData>(TPostData postData)
{ {
var type = typeof(TPostData); var type = typeof(TPostData);
if (type == typeof(string) || type.IsEnum || type.IsValueType)
// Primitives, strings, enums, and value types should use IdMessage format
if (IsPrimitiveOrStringOrEnum(type))
{
return new SignalPostJsonDataMessage<IdMessage>(new IdMessage(postData!)); return new SignalPostJsonDataMessage<IdMessage>(new IdMessage(postData!));
}
// Complex objects use direct serialization
return new SignalPostJsonDataMessage<TPostData>(postData); return new SignalPostJsonDataMessage<TPostData>(postData);
} }
/// <summary>
/// Determines if a type should use IdMessage format (primitives, strings, enums, value types).
/// Must match the logic in AcWebSignalRHubBase.IsPrimitiveOrStringOrEnum.
/// NOTE: Arrays and collections are NOT included here - they are complex objects for PostDataAsync.
/// </summary>
private static bool IsPrimitiveOrStringOrEnum(Type type)
{
return type == typeof(string) ||
type.IsEnum ||
type.IsValueType ||
type == typeof(DateTime);
}
public Task GetAllIntoAsync<TResponseItem>(List<TResponseItem> intoList, int messageTag, object[]? contextParams = null, Action? callback = null) where TResponseItem : IEntityGuid public Task GetAllIntoAsync<TResponseItem>(List<TResponseItem> intoList, int messageTag, object[]? contextParams = null, Action? callback = null) where TResponseItem : IEntityGuid
{ {
return GetAllAsync<List<TResponseItem>>(messageTag, response => return GetAllAsync<List<TResponseItem>>(messageTag, contextParams).ContinueWith(task =>
{ {
var logText = $"GetAllIntoAsync<{typeof(TResponseItem).Name}>(); status: {response.Status}; dataCount: {response.ResponseData?.Count}; {ConstHelper.NameByValue(TagsName, messageTag)};"; var logText = $"GetAllIntoAsync<{typeof(TResponseItem).Name}>(); dataCount: {task.Result?.Count}; {ConstHelper.NameByValue(TagsName, messageTag)};";
intoList.Clear(); intoList.Clear();
if (task.Result != null)
if (response.Status == SignalResponseStatus.Success && response.ResponseData != null)
{ {
Logger.Debug(logText); Logger.Debug(logText);
intoList.AddRange(response.ResponseData); intoList.AddRange(task.Result);
} }
else Logger.Error(logText); else Logger.Error(logText);
callback?.Invoke(); callback?.Invoke();
return Task.CompletedTask; }, TaskScheduler.Default);
}, contextParams);
} }
#endregion CRUD #endregion
public virtual Task<TResponse?> SendMessageToServerAsync<TResponse>(int messageTag) //where TResponse : class public virtual Task<TResponse?> SendMessageToServerAsync<TResponse>(int messageTag)
=> SendMessageToServerAsync<TResponse>(messageTag, null, GetNextRequestId()); => SendMessageToServerAsync<TResponse>(messageTag, null, GetNextRequestId());
public virtual Task<TResponse?> SendMessageToServerAsync<TResponse>(int messageTag, ISignalRMessage? message) //where TResponse : class public virtual Task<TResponse?> SendMessageToServerAsync<TResponse>(int messageTag, ISignalRMessage? message)
=> SendMessageToServerAsync<TResponse>(messageTag, message, GetNextRequestId()); => SendMessageToServerAsync<TResponse>(messageTag, message, GetNextRequestId());
protected virtual async Task<TResponse?> SendMessageToServerAsync<TResponse>(int messageTag, ISignalRMessage? message, int requestId) //where TResponse : class /// <summary>
/// Sends message to server with async callback response.
/// </summary>
public virtual async Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, Func<SignalResponseDataMessage, Task> responseCallback)
{
var requestId = GetNextRequestId();
var requestModel = SignalRRequestModelPool.Get(responseCallback);
_responseByRequestId[requestId] = requestModel;
await SendMessageToServerAsync(messageTag, message, requestId);
}
protected virtual async Task<TResponse?> SendMessageToServerAsync<TResponse>(int messageTag, ISignalRMessage? message, int requestId)
{ {
Logger.DebugConditional($"Client SendMessageToServerAsync<TResult>; {nameof(requestId)}: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}"); Logger.DebugConditional($"Client SendMessageToServerAsync<TResult>; {nameof(requestId)}: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}");
@ -339,7 +260,7 @@ namespace AyCode.Services.SignalRs
try try
{ {
if (await TaskHelper.WaitToAsync(() => _responseByRequestId[requestId].ResponseByRequestId != null, TransportSendTimeout, MsDelay, MsFirstDelay) && if (await TaskHelper.WaitToAsync(() => _responseByRequestId[requestId].ResponseByRequestId != null, TransportSendTimeout, MsDelay, MsFirstDelay) &&
_responseByRequestId.TryRemove(requestId, out var obj) && obj.ResponseByRequestId is ISignalResponseMessage responseMessage) _responseByRequestId.TryRemove(requestId, out var obj) && obj.ResponseByRequestId is SignalResponseDataMessage responseMessage)
{ {
startTime = obj.RequestDateTime; startTime = obj.RequestDateTime;
SignalRRequestModelPool.Return(obj); SignalRRequestModelPool.Return(obj);
@ -351,21 +272,25 @@ namespace AyCode.Services.SignalRs
return await Task.FromException<TResponse>(new Exception(errorText)); return await Task.FromException<TResponse>(new Exception(errorText));
} }
var responseData = DeserializeResponseData<TResponse>(responseMessage); // Special case: when TResponse is SignalResponseDataMessage, return the message itself
// instead of trying to deserialize ResponseData (which would cause InvalidCastException)
if (typeof(TResponse) == typeof(SignalResponseDataMessage))
{
var serializerType = responseMessage.DataSerializerType == AcSerializerType.Binary ? "Binary" : "JSON";
Logger.Info($"Client returning raw SignalResponseDataMessage ({serializerType}). Total: {(DateTime.UtcNow.Subtract(startTime)).TotalMilliseconds} ms! requestId: {requestId}; tag: {messageTag} [{ConstHelper.NameByValue(TagsName, messageTag)}]");
return (TResponse)(object)responseMessage;
}
var responseData = responseMessage.GetResponseData<TResponse>();
if (responseData == null && responseMessage.Status == SignalResponseStatus.Success) if (responseData == null && responseMessage.Status == SignalResponseStatus.Success)
{ {
// Null response is valid for Success status
Logger.Info($"Client received null response. Total: {(DateTime.UtcNow.Subtract(startTime)).TotalMilliseconds} ms! requestId: {requestId}; tag: {messageTag} [{ConstHelper.NameByValue(TagsName, messageTag)}]"); Logger.Info($"Client received null response. Total: {(DateTime.UtcNow.Subtract(startTime)).TotalMilliseconds} ms! requestId: {requestId}; tag: {messageTag} [{ConstHelper.NameByValue(TagsName, messageTag)}]");
return default; return default;
} }
var serializerType = responseMessage switch var serializerType2 = responseMessage.DataSerializerType == AcSerializerType.Binary ? "Binary" : "JSON";
{ Logger.Info($"Client deserialized response ({serializerType2}). Total: {(DateTime.UtcNow.Subtract(startTime)).TotalMilliseconds} ms! requestId: {requestId}; tag: {messageTag} [{ConstHelper.NameByValue(TagsName, messageTag)}]");
SignalResponseBinaryMessage => "Binary",
_ => "JSON"
};
Logger.Info($"Client deserialized response ({serializerType}). Total: {(DateTime.UtcNow.Subtract(startTime)).TotalMilliseconds} ms! requestId: {requestId}; tag: {messageTag} [{ConstHelper.NameByValue(TagsName, messageTag)}]");
return responseData; return responseData;
} }
@ -377,62 +302,11 @@ namespace AyCode.Services.SignalRs
} }
if (_responseByRequestId.TryRemove(requestId, out var removedModel)) if (_responseByRequestId.TryRemove(requestId, out var removedModel))
{
SignalRRequestModelPool.Return(removedModel); SignalRRequestModelPool.Return(removedModel);
}
return default; return default;
} }
/// <summary>
/// Deserializes response data from either JSON or Binary format.
/// Automatically detects the format based on the response message type.
/// </summary>
private static TResponse? DeserializeResponseData<TResponse>(ISignalResponseMessage responseMessage)
{
return responseMessage switch
{
SignalResponseBinaryMessage binaryMsg when binaryMsg.ResponseData != null
=> binaryMsg.ResponseData.BinaryTo<TResponse>(),
SignalResponseJsonMessage jsonMsg when !string.IsNullOrEmpty(jsonMsg.ResponseData)
=> jsonMsg.ResponseData.JsonTo<TResponse>(),
ISignalResponseMessage<string> stringMsg when !string.IsNullOrEmpty(stringMsg.ResponseData)
=> stringMsg.ResponseData.JsonTo<TResponse>(),
_ => default
};
}
public virtual Task SendMessageToServerAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback)
=> SendMessageToServerAsync(messageTag, null, responseCallback);
public virtual Task SendMessageToServerAsync<TResponseData>(int messageTag, ISignalRMessage? message, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback)
{
if (messageTag == 0) Logger.Error($"SendMessageToServerAsync; messageTag == 0");
var requestId = GetNextRequestId();
var requestModel = SignalRRequestModelPool.Get(new Action<ISignalResponseMessage>(responseMessage =>
{
TResponseData? responseData = default;
if (responseMessage.Status == SignalResponseStatus.Success)
{
responseData = DeserializeResponseData<TResponseData>(responseMessage);
}
else Logger.Error($"Client SendMessageToServerAsync<TResponseData> response error; callback; Status: {responseMessage.Status}; ConnectionState: {GetConnectionState()}; requestId: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}");
responseCallback(new SignalResponseMessage<TResponseData?>(messageTag, responseMessage.Status, responseData));
}));
_responseByRequestId[requestId] = requestModel;
return SendMessageToServerAsync(messageTag, message, requestId);
}
/// <summary>
/// Gets the next unique request ID.
/// </summary>
protected virtual int GetNextRequestId() => AcDomain.NextUniqueInt32; protected virtual int GetNextRequestId() => AcDomain.NextUniqueInt32;
public virtual Task OnReceiveMessage(int messageTag, byte[] messageBytes, int? requestId) public virtual Task OnReceiveMessage(int messageTag, byte[] messageBytes, int? requestId)
@ -443,64 +317,49 @@ namespace AyCode.Services.SignalRs
try try
{ {
if (requestId.HasValue && _responseByRequestId.ContainsKey(requestId.Value)) if (requestId.HasValue && _responseByRequestId.TryGetValue(requestId.Value, out var requestModel))
{ {
var reqId = requestId.Value; var reqId = requestId.Value;
requestModel.ResponseDateTime = DateTime.UtcNow;
Logger.Debug($"[{requestModel.ResponseDateTime.Subtract(requestModel.RequestDateTime).TotalMilliseconds:N0}ms][{messageBytes.Length / 1024}kb]{logText}");
_responseByRequestId[reqId].ResponseDateTime = DateTime.UtcNow; var responseMessage = SignalRSerializationHelper.DeserializeFromBinary<SignalResponseDataMessage>(messageBytes) ?? new SignalResponseDataMessage();
Logger.Debug($"[{_responseByRequestId[reqId].ResponseDateTime.Subtract(_responseByRequestId[reqId].RequestDateTime).TotalMilliseconds:N0}ms][{(messageBytes.Length / 1024)}kb]{logText}");
var responseMessage = DeserializeResponseMessage(messageBytes); switch (requestModel.ResponseByRequestId)
switch (_responseByRequestId[reqId].ResponseByRequestId)
{ {
case null: case null:
_responseByRequestId[reqId].ResponseByRequestId = responseMessage; requestModel.ResponseByRequestId = responseMessage;
return Task.CompletedTask; return Task.CompletedTask;
case Action<ISignalResponseMessage> messageCallback: case Action<SignalResponseDataMessage> actionCallback:
if (_responseByRequestId.TryRemove(reqId, out var callbackModel)) if (_responseByRequestId.TryRemove(reqId, out var actionModel))
{ SignalRRequestModelPool.Return(actionModel);
SignalRRequestModelPool.Return(callbackModel); actionCallback.Invoke(responseMessage);
}
messageCallback.Invoke(responseMessage);
return Task.CompletedTask; return Task.CompletedTask;
// Legacy support for string-based callbacks case Func<SignalResponseDataMessage, Task> funcCallback:
case Action<ISignalResponseMessage<string>> stringCallback when responseMessage is SignalResponseJsonMessage jsonMsg: if (_responseByRequestId.TryRemove(reqId, out var funcModel))
if (_responseByRequestId.TryRemove(reqId, out var legacyModel)) SignalRRequestModelPool.Return(funcModel);
{ return funcCallback.Invoke(responseMessage);
SignalRRequestModelPool.Return(legacyModel);
}
stringCallback.Invoke(jsonMsg);
return Task.CompletedTask;
default: default:
Logger.Error($"Client OnReceiveMessage switch; unknown message type: {_responseByRequestId[reqId].ResponseByRequestId?.GetType().Name}; {ConstHelper.NameByValue(TagsName, messageTag)}"); Logger.Error($"Client OnReceiveMessage switch; unknown message type: {requestModel.ResponseByRequestId?.GetType().Name}; {ConstHelper.NameByValue(TagsName, messageTag)}");
break; break;
} }
if (_responseByRequestId.TryRemove(reqId, out var removedModel)) if (_responseByRequestId.TryRemove(reqId, out var removedModel))
{
SignalRRequestModelPool.Return(removedModel); SignalRRequestModelPool.Return(removedModel);
}
// Request-response hibás eset - ne hívjuk meg a MessageReceived-et
return Task.CompletedTask; return Task.CompletedTask;
} }
// Csak broadcast/notification üzeneteknél hívjuk meg a MessageReceived-et
Logger.Info(logText); Logger.Info(logText);
MessageReceived(messageTag, messageBytes).Forget(); MessageReceived(messageTag, messageBytes).Forget();
} }
catch (Exception ex) catch (Exception ex)
{ {
if (requestId.HasValue && _responseByRequestId.TryRemove(requestId.Value, out var exModel)) if (requestId.HasValue && _responseByRequestId.TryRemove(requestId.Value, out var exModel))
{
SignalRRequestModelPool.Return(exModel); SignalRRequestModelPool.Return(exModel);
}
Logger.Error($"Client OnReceiveMessage; ConnectionState: {GetConnectionState()}; requestId: {requestId}; {ex.Message}; {ConstHelper.NameByValue(TagsName, messageTag)}", ex); Logger.Error($"Client OnReceiveMessage; ConnectionState: {GetConnectionState()}; requestId: {requestId}; {ex.Message}; {ConstHelper.NameByValue(TagsName, messageTag)}", ex);
throw; throw;
@ -508,33 +367,5 @@ namespace AyCode.Services.SignalRs
return Task.CompletedTask; return Task.CompletedTask;
} }
/// <summary>
/// Deserializes a MessagePack response to the appropriate message type (JSON or Binary).
/// Uses DetectSerializerTypeFromBytes to determine the format of the ResponseData.
/// </summary>
protected virtual ISignalResponseMessage DeserializeResponseMessage(byte[] messageBytes)
{
// First, try to deserialize as Binary message to check the ResponseData format
try
{
var binaryMsg = messageBytes.MessagePackTo<SignalResponseBinaryMessage>(ContractlessStandardResolver.Options);
if (binaryMsg.ResponseData != null && binaryMsg.ResponseData.Length > 0)
{
// Use the existing utility to detect if ResponseData is Binary format
if (DetectSerializerTypeFromBytes(binaryMsg.ResponseData) == AcSerializerType.Binary)
{
return binaryMsg;
}
}
}
catch
{
// Failed to deserialize as Binary message
}
// Fall back to JSON format
return messageBytes.MessagePackTo<SignalResponseJsonMessage>(ContractlessStandardResolver.Options);
}
} }
} }

View File

@ -1,13 +1,16 @@
using AyCode.Core.Extensions; using AyCode.Core.Extensions;
using MessagePack;
using Newtonsoft.Json.Linq;
using System.Text.RegularExpressions;
using AyCode.Core.Interfaces; using AyCode.Core.Interfaces;
using System.Collections.Generic; using System.Buffers;
using System.Linq.Expressions; using System.Runtime.CompilerServices;
using JsonIgnoreAttribute = Newtonsoft.Json.JsonIgnoreAttribute;
using STJIgnore = System.Text.Json.Serialization.JsonIgnoreAttribute;
namespace AyCode.Services.SignalRs; namespace AyCode.Services.SignalRs;
/// <summary>
/// Message container for serialized parameter IDs.
/// Optimized for common primitive types to avoid full JSON overhead.
/// </summary>
public class IdMessage public class IdMessage
{ {
public List<string> Ids { get; private set; } public List<string> Ids { get; private set; }
@ -19,74 +22,65 @@ public class IdMessage
/// <summary> /// <summary>
/// Creates IdMessage with multiple parameters serialized directly as JSON. /// Creates IdMessage with multiple parameters serialized directly as JSON.
/// Each parameter is serialized independently without array wrapping.
/// Use object[] explicitly to pass multiple parameters.
/// </summary> /// </summary>
public IdMessage(object[] ids) public IdMessage(object[] ids)
{ {
// Pre-allocate capacity to avoid list resizing
Ids = new List<string>(ids.Length); Ids = new List<string>(ids.Length);
for (var i = 0; i < ids.Length; i++) for (var i = 0; i < ids.Length; i++)
{ {
Ids.Add(ids[i].ToJson()); Ids.Add(SignalRSerializationHelper.SerializePrimitiveToJson(ids[i]));
} }
} }
/// <summary> /// <summary>
/// Creates IdMessage with a single parameter serialized as JSON. /// Creates IdMessage with a single parameter serialized as JSON.
/// Collections (List, Array, etc.) are serialized as a single JSON array.
/// </summary> /// </summary>
public IdMessage(object id) public IdMessage(object id)
{ {
// Pre-allocate for single item Ids = [SignalRSerializationHelper.SerializePrimitiveToJson(id)];
Ids = new List<string>(1) { id.ToJson() };
} }
/// <summary> /// <summary>
/// Creates IdMessage with multiple Guid parameters. /// Creates IdMessage with multiple Guid parameters.
/// Each Guid is serialized as a separate Id entry.
/// </summary> /// </summary>
public IdMessage(IEnumerable<Guid> ids) public IdMessage(IEnumerable<Guid> ids)
{ {
// Materialize to array once to get count and avoid multiple enumeration
var idsArray = ids as Guid[] ?? ids.ToArray(); var idsArray = ids as Guid[] ?? ids.ToArray();
Ids = new List<string>(idsArray.Length); Ids = new List<string>(idsArray.Length);
for (var i = 0; i < idsArray.Length; i++) for (var i = 0; i < idsArray.Length; i++)
{ {
Ids.Add(idsArray[i].ToJson()); Ids.Add(SignalRSerializationHelper.SerializeGuidToJson(idsArray[i]));
} }
} }
public override string ToString() public override string ToString() => string.Join("; ", Ids);
{
return string.Join("; ", Ids);
}
} }
[MessagePackObject] /// <summary>
/// Message containing JSON-serialized post data.
/// </summary>
public class SignalPostJsonMessage public class SignalPostJsonMessage
{ {
[Key(0)]
public string PostDataJson { get; set; } = ""; public string PostDataJson { get; set; } = "";
public SignalPostJsonMessage() public SignalPostJsonMessage() { }
{}
protected SignalPostJsonMessage(string postDataJson) => PostDataJson = postDataJson; protected SignalPostJsonMessage(string postDataJson) => PostDataJson = postDataJson;
} }
[MessagePackObject(AllowPrivate = false)] /// <summary>
public class SignalPostJsonDataMessage<TPostDataType> : SignalPostJsonMessage, ISignalPostMessage<TPostDataType> //where TPostDataType : class /// Generic message containing JSON-serialized post data with typed access.
/// </summary>
public class SignalPostJsonDataMessage<TPostDataType> : SignalPostJsonMessage, ISignalPostMessage<TPostDataType>
{ {
[IgnoreMember] [JsonIgnore]
[STJIgnore]
private TPostDataType? _postData; private TPostDataType? _postData;
[IgnoreMember] [JsonIgnore]
[STJIgnore]
public TPostDataType PostData public TPostDataType PostData
{ {
get get => _postData ??= PostDataJson.JsonTo<TPostDataType>()!;
{
return _postData ??= PostDataJson.JsonTo<TPostDataType>()!;
}
private init private init
{ {
_postData = value; _postData = value;
@ -94,17 +88,16 @@ public class SignalPostJsonDataMessage<TPostDataType> : SignalPostJsonMessage, I
} }
} }
public SignalPostJsonDataMessage() : base() public SignalPostJsonDataMessage() : base() { }
{}
public SignalPostJsonDataMessage(TPostDataType postData) => PostData = postData; public SignalPostJsonDataMessage(TPostDataType postData) => PostData = postData;
public SignalPostJsonDataMessage(string postDataJson) : base(postDataJson) public SignalPostJsonDataMessage(string postDataJson) : base(postDataJson) { }
{}
} }
[MessagePackObject] /// <summary>
/// Simple message containing post data.
/// </summary>
public class SignalPostMessage<TPostData>(TPostData postData) : ISignalPostMessage<TPostData> public class SignalPostMessage<TPostData>(TPostData postData) : ISignalPostMessage<TPostData>
{ {
[Key(0)]
public TPostData? PostData { get; set; } = postData; public TPostData? PostData { get; set; } = postData;
} }
@ -113,10 +106,11 @@ public interface ISignalPostMessage<TPostData> : ISignalRMessage
TPostData? PostData { get; } TPostData? PostData { get; }
} }
[MessagePackObject] /// <summary>
/// Message for requesting by Guid ID.
/// </summary>
public class SignalRequestByIdMessage(Guid id) : ISignalRequestMessage<Guid>, IId<Guid> public class SignalRequestByIdMessage(Guid id) : ISignalRequestMessage<Guid>, IId<Guid>
{ {
[Key(0)]
public Guid Id { get; set; } = id; public Guid Id { get; set; } = id;
} }
@ -125,148 +119,7 @@ public interface ISignalRequestMessage<TRequestId> : ISignalRMessage
TRequestId Id { get; set; } TRequestId Id { get; set; }
} }
public interface ISignalRMessage public interface ISignalRMessage { }
{ }
[MessagePackObject]
public sealed class SignalResponseJsonMessage : ISignalResponseMessage<string>
{
[Key(0)] public int MessageTag { get; set; }
[Key(1)] public SignalResponseStatus Status { get; set; }
[Key(2)] public string? ResponseData { get; set; } = null;
[IgnoreMember]
public string? ResponseDataJson => ResponseData;
public SignalResponseJsonMessage(){}
public SignalResponseJsonMessage(int messageTag, SignalResponseStatus status)
{
Status = status;
MessageTag = messageTag;
}
/// <summary>
/// Creates a response with the given data serialized as JSON.
/// If responseData is already a JSON string (starts with { or [), it will be used directly.
/// All other data types are serialized to JSON format.
/// </summary>
public SignalResponseJsonMessage(int messageTag, SignalResponseStatus status, object? responseData) : this(messageTag, status)
{
if (responseData == null)
{
ResponseData = null;
return;
}
// If responseData is already a JSON string, use it directly
if (responseData is string strData)
{
var trimmed = strData.Trim();
if (trimmed.Length > 1 && (trimmed[0] == '{' || trimmed[0] == '[') && (trimmed[^1] == '}' || trimmed[^1] == ']'))
{
// Already JSON - use directly without re-serialization
ResponseData = strData;
return;
}
}
// Serialize to JSON
ResponseData = responseData.ToJson();
}
}
/// <summary>
/// Signal response message with lazy deserialization support.
/// ResponseData is only deserialized on first access and cached.
/// Use ResponseDataJson for direct JSON access without deserialization.
/// </summary>
[MessagePackObject(AllowPrivate = false)]
public sealed class SignalResponseMessage<TResponseData> : ISignalResponseMessage<TResponseData>
{
[IgnoreMember]
private TResponseData? _responseData;
[IgnoreMember]
private bool _isDeserialized;
[Key(0)]
public int MessageTag { get; set; }
[Key(1)]
public SignalResponseStatus Status { get; set; }
/// <summary>
/// Raw JSON string. Use this for direct JSON access without triggering deserialization.
/// </summary>
[Key(2)]
public string? ResponseDataJson { get; set; }
/// <summary>
/// Deserialized response data. Lazy-loaded on first access.
/// </summary>
[IgnoreMember]
public TResponseData? ResponseData
{
get
{
if (!_isDeserialized)
{
_isDeserialized = true;
_responseData = ResponseDataJson != null
? ResponseDataJson.JsonTo<TResponseData>()
: default;
}
return _responseData;
}
set
{
_isDeserialized = true;
_responseData = value;
ResponseDataJson = value?.ToJson();
}
}
public SignalResponseMessage()
{
}
public SignalResponseMessage(int messageTag, SignalResponseStatus status)
{
MessageTag = messageTag;
Status = status;
}
public SignalResponseMessage(int messageTag, SignalResponseStatus status, TResponseData? responseData)
: this(messageTag, status)
{
ResponseData = responseData;
}
public SignalResponseMessage(int messageTag, SignalResponseStatus status, string? responseDataJson)
: this(messageTag, status)
{
ResponseDataJson = responseDataJson;
}
}
public interface ISignalResponseMessage<TResponseData> : ISignalResponseMessage
{
/// <summary>
/// Deserialized response data. May trigger lazy deserialization.
/// </summary>
TResponseData? ResponseData { get; set; }
/// <summary>
/// Raw JSON string for direct access without deserialization.
/// </summary>
string? ResponseDataJson { get; }
}
public interface ISignalResponseMessage : ISignalRMessage public interface ISignalResponseMessage : ISignalRMessage
{ {
@ -281,54 +134,82 @@ public enum SignalResponseStatus : byte
} }
/// <summary> /// <summary>
/// Signal response message with binary serialized data. /// Unified signal response message that supports both JSON and Binary serialization.
/// Used when SerializerOptions.SerializerType == Binary for better performance. /// JSON mode uses Brotli compression for reduced payload size.
/// Optimized: uses pooled buffers for decompression, zero-copy deserialization path.
/// </summary> /// </summary>
[MessagePackObject] public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposable
public sealed class SignalResponseBinaryMessage : ISignalResponseMessage<byte[]>
{ {
[Key(0)] public int MessageTag { get; set; } public int MessageTag { get; set; }
public SignalResponseStatus Status { get; set; }
public AcSerializerType DataSerializerType { get; set; }
public byte[]? ResponseData { get; set; }
[Key(1)] public SignalResponseStatus Status { get; set; } [JsonIgnore] [STJIgnore] private object? _cachedResponseData;
[JsonIgnore] [STJIgnore] private byte[]? _rentedDecompressedBuffer;
[JsonIgnore] [STJIgnore] private int _decompressedLength;
[Key(2)] public byte[]? ResponseData { get; set; } public SignalResponseDataMessage() { }
[IgnoreMember] public SignalResponseDataMessage(int messageTag, SignalResponseStatus status)
public string? ResponseDataJson => ResponseData != null ? Convert.ToBase64String(ResponseData) : null;
public SignalResponseBinaryMessage() { }
public SignalResponseBinaryMessage(int messageTag, SignalResponseStatus status)
{ {
Status = status;
MessageTag = messageTag; MessageTag = messageTag;
Status = status;
} }
public SignalResponseBinaryMessage(int messageTag, SignalResponseStatus status, object? responseData, AcBinarySerializerOptions? options = null) public SignalResponseDataMessage(int messageTag, SignalResponseStatus status, object? responseData, AcSerializerOptions serializerOptions)
: this(messageTag, status) : this(messageTag, status)
{ {
if (responseData == null) DataSerializerType = serializerOptions.SerializerType;
{ ResponseData = SignalRSerializationHelper.CreateResponseData(responseData, serializerOptions);
ResponseData = null;
return;
} }
// If responseData is already a byte array, use it directly /// <summary>
if (responseData is byte[] byteData) /// Deserializes the ResponseData to the specified type.
/// Uses cached result for repeated calls.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public T? GetResponseData<T>()
{ {
ResponseData = byteData; if (_cachedResponseData != null) return (T)_cachedResponseData;
return; if (ResponseData == null) return default;
if (DataSerializerType == AcSerializerType.Binary)
return (T)(_cachedResponseData = ResponseData.BinaryTo<T>()!);
// Decompress Brotli to pooled buffer and deserialize directly
EnsureDecompressed();
var result = AcJsonDeserializer.Deserialize<T>(new ReadOnlySpan<byte>(_rentedDecompressedBuffer, 0, _decompressedLength));
_cachedResponseData = result;
return result;
} }
// Serialize to binary /// <summary>
ResponseData = options != null /// Gets the decompressed JSON bytes as a ReadOnlySpan for direct processing.
? responseData.ToBinary(options) /// </summary>
: responseData.ToBinary(); public ReadOnlySpan<byte> GetDecompressedJsonSpan()
{
if (ResponseData == null) return ReadOnlySpan<byte>.Empty;
if (DataSerializerType == AcSerializerType.Binary) return ReadOnlySpan<byte>.Empty;
EnsureDecompressed();
return _rentedDecompressedBuffer.AsSpan(0, _decompressedLength);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void EnsureDecompressed()
{
if (_rentedDecompressedBuffer != null) return;
(_rentedDecompressedBuffer, _decompressedLength) = SignalRSerializationHelper.DecompressToRentedBuffer(ResponseData!);
}
public void Dispose()
{
} }
} }
public interface IAcSignalRHubClient : IAcSignalRHubBase public interface IAcSignalRHubClient : IAcSignalRHubBase
{ {
Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, int? requestId ); Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, int? requestId);
//Task SendRequestToServerAsync(int messageTag, int requestId);
} }

View File

@ -0,0 +1,186 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using AyCode.Core.Compression;
using AyCode.Core.Extensions;
namespace AyCode.Services.SignalRs;
/// <summary>
/// Centralized helper for SignalR serialization operations.
/// Provides optimized primitives for JSON/Binary serialization with pooled buffers.
/// </summary>
public static class SignalRSerializationHelper
{
// Pre-boxed boolean values to avoid repeated boxing
private static readonly string JsonTrue = "true";
private static readonly string JsonFalse = "false";
#region Primitive JSON Serialization
/// <summary>
/// Serialize a primitive value to JSON string with minimal overhead.
/// Falls back to full JSON serialization for complex types.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static string SerializePrimitiveToJson(object value)
{
return value switch
{
int i => i.ToString(),
long l => l.ToString(),
Guid g => SerializeGuidToJson(g),
bool b => b ? JsonTrue : JsonFalse,
// Strings need proper JSON escaping for special characters
string => value.ToJson(),
_ => value.ToJson()
};
}
/// <summary>
/// Serialize a Guid to JSON string with pre-allocated buffer.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static string SerializeGuidToJson(Guid g)
{
// Pre-allocate exact size: 38 chars = 2 quotes + 36 guid chars
return string.Create(38, g, static (span, guid) =>
{
span[0] = '"';
guid.TryFormat(span[1..], out _);
span[37] = '"';
});
}
#endregion
#region Binary Serialization
/// <summary>
/// Serialize object to binary using pooled ArrayBufferWriter.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static byte[] SerializeToBinary<T>(T value, AcBinarySerializerOptions? options = null)
{
var writer = new ArrayBufferWriter<byte>(256);
value.ToBinary(writer, options ?? AcBinarySerializerOptions.Default);
return writer.WrittenSpan.ToArray();
}
/// <summary>
/// Serialize object to binary and write to existing ArrayBufferWriter.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void SerializeToBinary<T>(T value, ArrayBufferWriter<byte> writer, AcBinarySerializerOptions? options = null)
{
value.ToBinary(writer, options ?? AcBinarySerializerOptions.Default);
}
/// <summary>
/// Deserialize binary data to object.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static T? DeserializeFromBinary<T>(byte[] data)
{
return data.BinaryTo<T>();
}
/// <summary>
/// Deserialize binary data from ReadOnlySpan.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static T? DeserializeFromBinary<T>(ReadOnlySpan<byte> data)
{
return data.BinaryTo<T>();
}
#endregion
#region JSON Serialization with Brotli
/// <summary>
/// Serialize object to JSON and compress with Brotli.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static byte[] SerializeToCompressedJson<T>(T value, AcJsonSerializerOptions? options = null)
{
var json = value.ToJson(options ?? AcJsonSerializerOptions.Default);
return BrotliHelper.Compress(json);
}
/// <summary>
/// Decompress Brotli data and deserialize JSON to object.
/// Uses pooled buffer for decompression.
/// </summary>
public static T? DeserializeFromCompressedJson<T>(byte[] compressedData)
{
var (buffer, length) = BrotliHelper.DecompressToRentedBuffer(compressedData.AsSpan());
try
{
return AcJsonDeserializer.Deserialize<T>(new ReadOnlySpan<byte>(buffer, 0, length));
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
/// <summary>
/// Decompress Brotli data to rented buffer for direct processing.
/// Caller must return buffer to ArrayPool.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static (byte[] Buffer, int Length) DecompressToRentedBuffer(byte[] compressedData)
{
return BrotliHelper.DecompressToRentedBuffer(compressedData.AsSpan());
}
#endregion
#region Response Data Helpers
/// <summary>
/// Check if string appears to be valid JSON (starts with { or [).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsValidJsonString(ReadOnlySpan<char> text)
{
var trimmed = text.Trim();
return trimmed.Length > 1 &&
(trimmed[0] == '{' || trimmed[0] == '[') &&
(trimmed[^1] == '}' || trimmed[^1] == ']');
}
/// <summary>
/// Create response binary data based on serializer type.
/// </summary>
public static byte[]? CreateResponseData(object? responseData, AcSerializerOptions serializerOptions)
{
if (responseData == null)
return null;
if (serializerOptions.SerializerType == AcSerializerType.Binary)
{
if (responseData is byte[] byteData)
return byteData;
var binaryOptions = serializerOptions as AcBinarySerializerOptions ?? AcBinarySerializerOptions.Default;
return SerializeToBinary(responseData, binaryOptions);
}
// JSON mode with Brotli compression
string json;
if (responseData is string strData && IsValidJsonString(strData.AsSpan()))
{
json = strData;
}
else
{
var jsonOptions = serializerOptions as AcJsonSerializerOptions ?? AcJsonSerializerOptions.Default;
json = responseData.ToJson(jsonOptions);
}
return BrotliHelper.Compress(json);
}
#endregion
}