diff --git a/AyCode.Core/Compression/BrotliHelper.cs b/AyCode.Core/Compression/BrotliHelper.cs
index 5f8403d..2a0dd0b 100644
--- a/AyCode.Core/Compression/BrotliHelper.cs
+++ b/AyCode.Core/Compression/BrotliHelper.cs
@@ -7,7 +7,6 @@ namespace AyCode.Core.Compression;
///
/// Brotli compression/decompression helper for SignalR message transport.
-/// Used when JSON serializer is configured to reduce payload size.
/// Optimized for zero-allocation scenarios with pooled buffers.
///
public static class BrotliHelper
@@ -15,6 +14,8 @@ public static class BrotliHelper
private const int DefaultBufferSize = 4096;
private const int MaxStackAllocSize = 1024;
+ #region Compression
+
///
/// Compresses a string using Brotli compression with pooled buffers.
///
@@ -60,41 +61,21 @@ public static class BrotliHelper
if (data.IsEmpty)
return [];
- // Estimate compressed size (typically 10-30% of original for text)
- var estimatedSize = Math.Max(data.Length / 2, 64);
- var outputBuffer = ArrayPool.Shared.Rent(estimatedSize);
-
- try
+ using var outputStream = new MemoryStream();
+ using (var brotliStream = new BrotliStream(outputStream, compressionLevel, leaveOpen: true))
{
- using var outputStream = new PooledMemoryStream(outputBuffer);
- using (var brotliStream = new BrotliStream(outputStream, compressionLevel, leaveOpen: true))
- {
- brotliStream.Write(data);
- }
- return outputStream.ToArray();
- }
- finally
- {
- ArrayPool.Shared.Return(outputBuffer);
+ brotliStream.Write(data);
}
+ return outputStream.ToArray();
}
- ///
- /// Compresses data directly to an IBufferWriter (zero intermediate allocation).
- ///
- public static void CompressTo(ReadOnlySpan data, IBufferWriter writer, CompressionLevel compressionLevel = CompressionLevel.Optimal)
- {
- if (data.IsEmpty)
- return;
+ #endregion
- using var outputStream = new BufferWriterStream(writer);
- using var brotliStream = new BrotliStream(outputStream, compressionLevel, leaveOpen: true);
- brotliStream.Write(data);
- }
+ #region Decompression
///
/// Decompresses Brotli-compressed data to a string.
- /// Consider using Decompress + direct UTF-8 JsonTo for better performance.
+ /// Consider using Decompress + direct UTF-8 deserialization for better performance.
///
public static string DecompressToString(byte[] compressedData)
{
@@ -107,63 +88,36 @@ public static class BrotliHelper
///
/// Decompresses Brotli-compressed data to a byte array.
- /// Uses pooled buffers internally for reduced GC pressure.
///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static byte[] Decompress(byte[] compressedData)
{
if (compressedData == null || compressedData.Length == 0)
return [];
- return DecompressSpan(compressedData.AsSpan());
+ return DecompressCore(compressedData);
}
///
/// Decompresses Brotli-compressed data from a ReadOnlySpan.
///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static byte[] DecompressSpan(ReadOnlySpan compressedData)
{
if (compressedData.IsEmpty)
return [];
- // Estimate decompressed size (typically 3-10x compressed for text)
- var estimatedSize = Math.Max(compressedData.Length * 4, DefaultBufferSize);
- var outputBuffer = ArrayPool.Shared.Rent(estimatedSize);
-
- try
- {
- using var inputStream = new ReadOnlySpanStream(compressedData);
- using var brotliStream = new BrotliStream(inputStream, CompressionMode.Decompress);
- using var outputStream = new PooledMemoryStream(outputBuffer);
-
- brotliStream.CopyTo(outputStream);
- return outputStream.ToArray();
- }
- finally
- {
- ArrayPool.Shared.Return(outputBuffer);
- }
+ return DecompressCore(compressedData.ToArray());
}
- ///
- /// Decompresses directly into an ArrayBufferWriter for zero-copy deserialization.
- ///
- public static void DecompressTo(ReadOnlySpan compressedData, ArrayBufferWriter writer)
+ private static byte[] DecompressCore(byte[] compressedData)
{
- if (compressedData.IsEmpty)
- return;
-
- using var inputStream = new ReadOnlySpanStream(compressedData);
+ using var inputStream = new MemoryStream(compressedData, writable: false);
using var brotliStream = new BrotliStream(inputStream, CompressionMode.Decompress);
+ using var outputStream = new MemoryStream();
- // Read in chunks directly to the writer
- int bytesRead;
- do
- {
- var buffer = writer.GetSpan(DefaultBufferSize);
- bytesRead = brotliStream.Read(buffer);
- if (bytesRead > 0)
- writer.Advance(bytesRead);
- } while (bytesRead > 0);
+ brotliStream.CopyTo(outputStream);
+ return outputStream.ToArray();
}
///
@@ -175,10 +129,11 @@ public static class BrotliHelper
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.Shared.Rent(estimatedSize);
- using var inputStream = new ReadOnlySpanStream(compressedData);
+ using var inputStream = new MemoryStream(compressedData.ToArray(), writable: false);
using var brotliStream = new BrotliStream(inputStream, CompressionMode.Decompress);
var totalRead = 0;
@@ -189,7 +144,7 @@ public static class BrotliHelper
totalRead += bytesRead;
// Need larger buffer
- if (totalRead == outputBuffer.Length)
+ if (totalRead >= outputBuffer.Length - DefaultBufferSize)
{
var newBuffer = ArrayPool.Shared.Rent(outputBuffer.Length * 2);
outputBuffer.AsSpan(0, totalRead).CopyTo(newBuffer);
@@ -201,6 +156,10 @@ public static class BrotliHelper
return (outputBuffer, totalRead);
}
+ #endregion
+
+ #region Utility
+
///
/// Checks if the data appears to be Brotli compressed.
///
@@ -230,185 +189,5 @@ public static class BrotliHelper
}
}
- #region Helper Stream Classes
-
- ///
- /// MemoryStream that uses a pre-allocated buffer and can expand using ArrayPool.
- ///
- private sealed class PooledMemoryStream : Stream
- {
- private byte[] _buffer;
- private int _position;
- private int _length;
- private bool _ownsBuffer;
-
- public PooledMemoryStream(byte[] initialBuffer)
- {
- _buffer = initialBuffer;
- _ownsBuffer = false;
- }
-
- public override bool CanRead => true;
- public override bool CanSeek => true;
- public override bool CanWrite => true;
- public override long Length => _length;
- public override long Position { get => _position; set => _position = (int)value; }
-
- public override void Write(byte[] buffer, int offset, int count)
- => Write(buffer.AsSpan(offset, count));
-
- public override void Write(ReadOnlySpan buffer)
- {
- EnsureCapacity(_position + buffer.Length);
- buffer.CopyTo(_buffer.AsSpan(_position));
- _position += buffer.Length;
- if (_position > _length) _length = _position;
- }
-
- public override int Read(byte[] buffer, int offset, int count)
- {
- var bytesToRead = Math.Min(count, _length - _position);
- if (bytesToRead <= 0) return 0;
- _buffer.AsSpan(_position, bytesToRead).CopyTo(buffer.AsSpan(offset));
- _position += bytesToRead;
- return bytesToRead;
- }
-
- public override long Seek(long offset, SeekOrigin origin)
- {
- _position = origin switch
- {
- SeekOrigin.Begin => (int)offset,
- SeekOrigin.Current => _position + (int)offset,
- SeekOrigin.End => _length + (int)offset,
- _ => _position
- };
- return _position;
- }
-
- public override void SetLength(long value) => _length = (int)value;
- public override void Flush() { }
-
- public byte[] ToArray()
- {
- var result = new byte[_length];
- _buffer.AsSpan(0, _length).CopyTo(result);
- return result;
- }
-
- private void EnsureCapacity(int required)
- {
- if (required <= _buffer.Length) return;
-
- var newSize = Math.Max(_buffer.Length * 2, required);
- var newBuffer = ArrayPool.Shared.Rent(newSize);
- _buffer.AsSpan(0, _length).CopyTo(newBuffer);
-
- if (_ownsBuffer) ArrayPool.Shared.Return(_buffer);
-
- _buffer = newBuffer;
- _ownsBuffer = true;
- }
-
- protected override void Dispose(bool disposing)
- {
- if (_ownsBuffer && _buffer != null)
- {
- ArrayPool.Shared.Return(_buffer);
- _buffer = null!;
- }
- base.Dispose(disposing);
- }
- }
-
- ///
- /// Read-only stream wrapper for ReadOnlySpan.
- ///
- private sealed class ReadOnlySpanStream : Stream
- {
- private readonly ReadOnlyMemory _data;
- private int _position;
-
- public ReadOnlySpanStream(ReadOnlySpan data)
- {
- _data = data.ToArray(); // Must copy for stream usage
- }
-
- public override bool CanRead => true;
- public override bool CanSeek => true;
- public override bool CanWrite => false;
- public override long Length => _data.Length;
- public override long Position { get => _position; set => _position = (int)value; }
-
- public override int Read(byte[] buffer, int offset, int count)
- {
- var bytesToRead = Math.Min(count, _data.Length - _position);
- if (bytesToRead <= 0) return 0;
- _data.Span.Slice(_position, bytesToRead).CopyTo(buffer.AsSpan(offset));
- _position += bytesToRead;
- return bytesToRead;
- }
-
- public override int Read(Span buffer)
- {
- var bytesToRead = Math.Min(buffer.Length, _data.Length - _position);
- if (bytesToRead <= 0) return 0;
- _data.Span.Slice(_position, bytesToRead).CopyTo(buffer);
- _position += bytesToRead;
- return bytesToRead;
- }
-
- public override long Seek(long offset, SeekOrigin origin)
- {
- _position = origin switch
- {
- SeekOrigin.Begin => (int)offset,
- SeekOrigin.Current => _position + (int)offset,
- SeekOrigin.End => _data.Length + (int)offset,
- _ => _position
- };
- return _position;
- }
-
- public override void SetLength(long value) => throw new NotSupportedException();
- public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
- public override void Flush() { }
- }
-
- ///
- /// Stream that writes directly to an IBufferWriter.
- ///
- private sealed class BufferWriterStream : Stream
- {
- private readonly IBufferWriter _writer;
-
- public BufferWriterStream(IBufferWriter writer) => _writer = writer;
-
- public override bool CanRead => false;
- public override bool CanSeek => false;
- public override bool CanWrite => true;
- public override long Length => throw new NotSupportedException();
- public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
-
- public override void Write(byte[] buffer, int offset, int count)
- {
- var span = _writer.GetSpan(count);
- buffer.AsSpan(offset, count).CopyTo(span);
- _writer.Advance(count);
- }
-
- public override void Write(ReadOnlySpan buffer)
- {
- var span = _writer.GetSpan(buffer.Length);
- buffer.CopyTo(span);
- _writer.Advance(buffer.Length);
- }
-
- public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
- public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
- public override void SetLength(long value) => throw new NotSupportedException();
- public override void Flush() { }
- }
-
#endregion
}
diff --git a/AyCode.Core/Extensions/SerializeObjectExtensions.cs b/AyCode.Core/Extensions/SerializeObjectExtensions.cs
index dd84143..631ff75 100644
--- a/AyCode.Core/Extensions/SerializeObjectExtensions.cs
+++ b/AyCode.Core/Extensions/SerializeObjectExtensions.cs
@@ -5,7 +5,6 @@ using System.Runtime.CompilerServices;
using System.Runtime.Serialization;
using System.Text;
using AyCode.Core.Interfaces;
-using MessagePack;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using static AyCode.Core.Extensions.JsonUtilities;
@@ -447,16 +446,6 @@ public static class SerializeObjectExtensions
#endregion
- #region MessagePack
-
- public static byte[] ToMessagePack(this object message, MessagePackSerializerOptions options)
- => MessagePackSerializer.Serialize(message, options);
-
- public static T MessagePackTo(this byte[] message, MessagePackSerializerOptions options)
- => MessagePackSerializer.Deserialize(message, options);
-
- #endregion
-
#region Any (JSON or Binary based on options)
public static object ToAny(this T source, AcSerializerOptions options)
diff --git a/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs b/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs
index 733af7c..d28015e 100644
--- a/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs
+++ b/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs
@@ -63,7 +63,7 @@ public static class SignalRTestHelper
public static T? GetResponseData(SentMessage sentMessage)
{
- if (sentMessage.Message is SignalResponseDataMessage dataResponse && dataResponse.ResponseDataBin != null)
+ if (sentMessage.Message is SignalResponseDataMessage dataResponse && dataResponse.ResponseData != null)
return dataResponse.GetResponseData();
return default;
diff --git a/AyCode.Services.Server.Tests/SignalRs/TestableSignalRClient2.cs b/AyCode.Services.Server.Tests/SignalRs/TestableSignalRClient2.cs
index 16a64f9..5077e51 100644
--- a/AyCode.Services.Server.Tests/SignalRs/TestableSignalRClient2.cs
+++ b/AyCode.Services.Server.Tests/SignalRs/TestableSignalRClient2.cs
@@ -4,7 +4,6 @@ using AyCode.Core.Tests.TestModels;
using AyCode.Services.Server.SignalRs;
using AyCode.Services.SignalRs;
using AyCode.Services.Tests.SignalRs;
-using MessagePack.Resolvers;
using Microsoft.AspNetCore.SignalR.Client;
namespace AyCode.Services.Server.Tests.SignalRs;
diff --git a/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub2.cs b/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub2.cs
index 4ef74c4..24861ef 100644
--- a/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub2.cs
+++ b/AyCode.Services.Server.Tests/SignalRs/TestableSignalRHub2.cs
@@ -5,7 +5,6 @@ using AyCode.Core.Tests.TestModels;
using AyCode.Models.Server.DynamicMethods;
using AyCode.Services.Server.SignalRs;
using AyCode.Services.SignalRs;
-using MessagePack.Resolvers;
using Microsoft.Extensions.Configuration;
namespace AyCode.Services.Server.Tests.SignalRs;
diff --git a/AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs b/AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs
index ec0f642..04f8f1f 100644
--- a/AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs
+++ b/AyCode.Services.Server/SignalRs/AcSignalRDataSource.cs
@@ -291,10 +291,10 @@ namespace AyCode.Services.Server.SignalRs
try
{
var response = task.Result;
- if (response?.Status != SignalResponseStatus.Success || response.ResponseDataBin == null)
+ if (response?.Status != SignalResponseStatus.Success || response.ResponseData == null)
throw new NullReferenceException($"LoadDataSourceAsync; Status: {response?.Status}");
- await LoadDataSourceFromResponseData(response.ResponseDataBin, response.DataSerializerType,
+ await LoadDataSourceFromResponseData(response.ResponseData, response.DataSerializerType,
false, false, clearChangeTracking);
}
finally
@@ -960,7 +960,7 @@ namespace AyCode.Services.Server.SignalRs
return SignalRClient.PostDataAsync(messageTag, item, response =>
{
- if (response.Status != SignalResponseStatus.Success || response.ResponseDataBin == null)
+ if (response.Status != SignalResponseStatus.Success || response.ResponseData == null)
{
if (TryRollbackItem(item.Id, out _)) return;
throw new NullReferenceException($"SaveItemUnsafeAsync; Status: {response.Status}");
diff --git a/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs b/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs
index 4b58db3..687d9ce 100644
--- a/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs
+++ b/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs
@@ -6,8 +6,6 @@ using AyCode.Core.Helpers;
using AyCode.Core.Loggers;
using AyCode.Models.Server.DynamicMethods;
using AyCode.Services.SignalRs;
-using MessagePack;
-using MessagePack.Resolvers;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Configuration;
@@ -98,7 +96,6 @@ public abstract class AcWebSignalRHubBase(IConfiguration
///
/// Creates a response message using the configured serializer.
- /// Always creates SignalResponseDataMessage which includes the SerializerType.
///
protected virtual ISignalRMessage CreateResponseMessage(int messageTag, SignalResponseStatus status, object? responseData)
{
@@ -110,11 +107,7 @@ public abstract class AcWebSignalRHubBase(IConfiguration
///
private static int GetResponseSize(ISignalRMessage responseMessage)
{
- return responseMessage switch
- {
- SignalResponseDataMessage dataMsg => dataMsg.ResponseDataBin?.Length ?? 0,
- _ => 0
- };
+ return responseMessage is SignalResponseDataMessage dataMsg ? dataMsg.ResponseData?.Length ?? 0 : 0;
}
///
@@ -150,68 +143,55 @@ public abstract class AcWebSignalRHubBase(IConfiguration
///
/// 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.
///
private static object[]? DeserializeParameters(byte[]? message, AcMethodInfoModel methodInfoModel, string tagName, string methodName)
{
if (methodInfoModel.ParamInfos is not { Length: > 0 })
return null;
- // Validate message - required when method has parameters
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}");
var paramValues = new object[methodInfoModel.ParamInfos.Length];
var firstParamType = methodInfoModel.ParamInfos[0].ParameterType;
- // Use IdMessage format for: multiple params OR primitives/strings/enums/value types
- if (methodInfoModel.ParamInfos.Length > 1 || IsPrimitiveOrStringOrEnum(firstParamType))
+ // First, try to deserialize as SignalPostJsonMessage to get raw PostDataJson
+ var msgBase = SignalRSerializationHelper.DeserializeFromBinary(message);
+ if (msgBase?.PostDataJson == null || string.IsNullOrEmpty(msgBase.PostDataJson))
{
- // Use ContractlessStandardResolver to match client serialization
- var msg = message.MessagePackTo>(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)!;
- }
+ throw new ArgumentException($"Failed to deserialize message for method '{methodName}'; {tagName}");
}
- else
+
+ var json = msgBase.PostDataJson;
+
+ // Check if it's an IdMessage format (contains "Ids" property)
+ if (json.Contains("\"Ids\""))
{
- // Single complex object - try to detect format by checking if it's an IdMessage
- var msgJson = message.MessagePackTo>(ContractlessStandardResolver.Options);
- var json = msgJson.PostDataJson;
-
- // Check if the JSON is an IdMessage format (has "Ids" property)
- if (json.Contains("\"Ids\""))
+ // Parse as IdMessage - each Id is a JSON string for a parameter
+ var idMessage = json.JsonTo();
+ if (idMessage?.Ids != null && idMessage.Ids.Count > 0)
{
- // It's IdMessage format - deserialize as IdMessage and get first Id
- var idMsg = message.MessagePackTo>(ContractlessStandardResolver.Options);
- if (idMsg.PostData.Ids.Count > 0)
+ for (var i = 0; i < idMessage.Ids.Count && i < methodInfoModel.ParamInfos.Length; i++)
{
- paramValues[0] = AcJsonDeserializer.Deserialize(idMsg.PostData.Ids[0], firstParamType)!;
- return paramValues;
+ var paramType = methodInfoModel.ParamInfos[i].ParameterType;
+ paramValues[i] = AcJsonDeserializer.Deserialize(idMessage.Ids[i], paramType)!;
}
+ return paramValues;
}
-
- // Direct complex object format
- paramValues[0] = json.JsonTo(firstParamType)!;
}
+ // Single complex object - deserialize directly from PostDataJson
+ paramValues[0] = json.JsonTo(firstParamType)!;
return paramValues;
}
///
- /// Determines if a type should use IdMessage format (primitives, strings, enums, value types).
- /// NOTE: Arrays and collections are NOT included - they use PostDataJson format when sent as single parameter.
+ /// Determines if a type should use IdMessage format.
///
private static bool IsPrimitiveOrStringOrEnum(Type type)
{
- return type == typeof(string) ||
- type.IsEnum ||
- type.IsValueType ||
- type == typeof(DateTime);
+ return type == typeof(string) || type.IsEnum || type.IsValueType || type == typeof(DateTime);
}
#endregion
@@ -243,15 +223,11 @@ public abstract class AcWebSignalRHubBase(IConfiguration
=> SendMessageToClient(Clients.All, messageTag, CreateResponseMessage(messageTag, SignalResponseStatus.Success, content), null);
///
- /// Sends message to client.
- /// Both Binary and JSON modes use AcBinarySerializer directly with pooled buffer.
+ /// Sends message to client using Binary serialization.
///
protected virtual async Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, ISignalRMessage message, int? requestId = null)
{
- // Use ArrayBufferWriter for zero-copy serialization to pooled buffer
- var writer = new ArrayBufferWriter(256);
- message.ToBinary(writer);
- var responseBytes = writer.WrittenSpan.ToArray();
+ var responseBytes = SignalRSerializationHelper.SerializeToBinary(message);
var tagName = ConstHelper.NameByValue(messageTag);
@@ -264,26 +240,11 @@ public abstract class AcWebSignalRHubBase(IConfiguration
#endregion
- #region Context Accessor Methods (virtual for testing)
+ #region Context Accessor Methods
- ///
- /// Gets the connection ID. Override in tests to avoid Context dependency.
- ///
protected virtual string GetConnectionId() => Context.ConnectionId;
-
- ///
- /// Gets whether the connection is aborted. Override in tests to avoid Context dependency.
- ///
protected virtual bool IsConnectionAborted() => Context.ConnectionAborted.IsCancellationRequested;
-
- ///
- /// Gets the user identifier. Override in tests to avoid Context dependency.
- ///
protected virtual string? GetUserIdentifier() => Context.UserIdentifier;
-
- ///
- /// Gets the ClaimsPrincipal user. Override in tests to avoid Context dependency.
- ///
protected virtual ClaimsPrincipal? GetUser() => Context.User;
#endregion
diff --git a/AyCode.Services.Tests/SignalRs/PostJsonDataMessageTests.cs b/AyCode.Services.Tests/SignalRs/PostJsonDataMessageTests.cs
index d36553c..4900265 100644
--- a/AyCode.Services.Tests/SignalRs/PostJsonDataMessageTests.cs
+++ b/AyCode.Services.Tests/SignalRs/PostJsonDataMessageTests.cs
@@ -1,6 +1,5 @@
using AyCode.Core.Extensions;
using AyCode.Services.SignalRs;
-using MessagePack.Resolvers;
namespace AyCode.Services.Tests.SignalRs;
@@ -21,15 +20,15 @@ public class PostJsonDataMessageTests
Console.WriteLine($"PostData.Ids[0]: {idMsg.PostData.Ids[0]}");
}
- var bytes = message.ToMessagePack(ContractlessStandardResolver.Options);
- Console.WriteLine($"MessagePack bytes: {bytes.Length}");
+ var bytes = message.ToBinary();
+ Console.WriteLine($"Binary bytes: {bytes.Length}");
- var deserialized = bytes.MessagePackTo>(ContractlessStandardResolver.Options);
- Console.WriteLine($"Deserialized PostDataJson: {deserialized.PostDataJson}");
- Console.WriteLine($"Deserialized PostData type: {deserialized.PostData?.GetType().Name}");
- Console.WriteLine($"Deserialized PostData.Ids.Count: {deserialized.PostData?.Ids.Count}");
+ var deserialized = bytes.BinaryTo>();
+ Console.WriteLine($"Deserialized PostDataJson: {deserialized?.PostDataJson}");
+ Console.WriteLine($"Deserialized PostData type: {deserialized?.PostData?.GetType().Name}");
+ 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);
}
@@ -56,18 +55,18 @@ public class PostJsonDataMessageTests
var clientMessage = new SignalPostJsonDataMessage(idMessage);
Console.WriteLine($"Client PostDataJson: '{clientMessage.PostDataJson}'");
- Console.WriteLine("\n=== Step 2: MessagePack serialization ===");
- var bytes = clientMessage.ToMessagePack(ContractlessStandardResolver.Options);
- Console.WriteLine($"MessagePack bytes: {bytes.Length}");
+ Console.WriteLine("\n=== Step 2: Binary serialization ===");
+ var bytes = clientMessage.ToBinary();
+ Console.WriteLine($"Binary bytes: {bytes.Length}");
Console.WriteLine("\n=== Step 3: Server deserializes ===");
- var serverMessage = bytes.MessagePackTo>(ContractlessStandardResolver.Options);
- Console.WriteLine($"Server PostDataJson: '{serverMessage.PostDataJson}'");
- Console.WriteLine($"Server PostData.Ids.Count: {serverMessage.PostData?.Ids.Count}");
- Console.WriteLine($"Server PostData.Ids[0]: '{serverMessage.PostData?.Ids[0]}'");
+ var serverMessage = bytes.BinaryTo>();
+ Console.WriteLine($"Server PostDataJson: '{serverMessage?.PostDataJson}'");
+ Console.WriteLine($"Server PostData.Ids.Count: {serverMessage?.PostData?.Ids.Count}");
+ Console.WriteLine($"Server PostData.Ids[0]: '{serverMessage?.PostData?.Ids[0]}'");
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}'");
var paramValue = AcJsonDeserializer.Deserialize(paramJson, testValue.GetType());
Console.WriteLine($"Deserialized value: {paramValue}");
@@ -78,7 +77,7 @@ public class PostJsonDataMessageTests
Console.WriteLine("\n=== Step 6: Server creates response ===");
var response = new SignalResponseDataMessage(100, SignalResponseStatus.Success, serviceResult, AcJsonSerializerOptions.Default);
- Console.WriteLine($"Response created with Binary bytes: {response.ResponseDataBin?.Length ?? 0}");
+ Console.WriteLine($"Response created with Binary bytes: {response.ResponseData?.Length ?? 0}");
Console.WriteLine("\n=== Step 7: Response Binary ===");
var responseBytes = response.ToBinary();
diff --git a/AyCode.Services.Tests/SignalRs/TestableSignalRClient.cs b/AyCode.Services.Tests/SignalRs/TestableSignalRClient.cs
deleted file mode 100644
index 707b5a8..0000000
--- a/AyCode.Services.Tests/SignalRs/TestableSignalRClient.cs
+++ /dev/null
@@ -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;
-
-///
-/// Testable SignalR client that allows testing without real HubConnection.
-///
-public class TestableSignalRClient : AcSignalRClientBase
-{
- private HubConnectionState _connectionState = HubConnectionState.Connected;
- private int? _nextRequestIdOverride;
-
- ///
- /// Messages sent to the server (captured for assertions).
- ///
- public List SentMessages { get; } = [];
-
- ///
- /// Received messages (captured for assertions).
- ///
- public List 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)
-
- ///
- /// Sets the simulated connection state.
- ///
- public void SetConnectionState(HubConnectionState state) => _connectionState = state;
-
- ///
- /// Sets the next request ID for deterministic testing.
- /// Will auto-increment for subsequent calls.
- ///
- public void SetNextRequestId(int id) => _nextRequestIdOverride = id;
-
- ///
- /// Gets the pending requests dictionary (public wrapper for testing).
- ///
- public new System.Collections.Concurrent.ConcurrentDictionary GetPendingRequests()
- => base.GetPendingRequests();
-
- ///
- /// Registers a pending request (public wrapper for testing).
- ///
- public new void RegisterPendingRequest(int requestId, SignalRRequestModel model)
- => base.RegisterPendingRequest(requestId, model);
-
- ///
- /// Clears pending requests (public wrapper for testing).
- ///
- public new void ClearPendingRequests() => base.ClearPendingRequests();
-
- ///
- /// Simulates receiving a response from the server.
- ///
- public Task SimulateServerResponse(int requestId, int messageTag, SignalResponseStatus status, object? data = null)
- {
- var response = new SignalResponseDataMessage(messageTag, status, data, AcJsonSerializerOptions.Default);
- var bytes = response.ToBinary();
- return OnReceiveMessage(messageTag, bytes, requestId);
- }
-
- ///
- /// Simulates receiving a success response from the server.
- ///
- public Task SimulateSuccessResponse(int requestId, int messageTag, T data)
- => SimulateServerResponse(requestId, messageTag, SignalResponseStatus.Success, data);
-
- ///
- /// Simulates receiving an error response from the server.
- ///
- public Task SimulateErrorResponse(int requestId, int messageTag)
- => SimulateServerResponse(requestId, messageTag, SignalResponseStatus.Error);
-
- ///
- /// Gets the last sent message.
- ///
- public SentClientMessage? LastSentMessage => SentMessages.LastOrDefault();
-
- ///
- /// Clears all captured messages.
- ///
- public void ClearMessages()
- {
- SentMessages.Clear();
- ReceivedMessages.Clear();
- }
-
- ///
- /// Invokes OnReceiveMessage directly for testing.
- ///
- public Task InvokeOnReceiveMessage(int messageTag, byte[] messageBytes, int? requestId)
- => OnReceiveMessage(messageTag, messageBytes, requestId);
-
- #endregion
-}
-
-///
-/// Represents a message sent from client to server.
-///
-public record SentClientMessage(int MessageTag, byte[]? MessageBytes, int? RequestId)
-{
- ///
- /// Deserializes the message to IdMessage format.
- /// Works with both production SignalPostJsonDataMessage and test SignalRPostMessageDto.
- ///
- public IdMessage? AsIdMessage()
- {
- if (MessageBytes == null) return null;
- try
- {
- // First deserialize to get the PostDataJson string
- var msg = MessageBytes.MessagePackTo>(ContractlessStandardResolver.Options);
- return msg.PostData;
- }
- catch
- {
- // Fallback: try deserializing as raw JSON wrapper
- try
- {
- var rawMsg = MessageBytes.MessagePackTo(ContractlessStandardResolver.Options);
- return rawMsg.PostDataJson?.JsonTo();
- }
- catch
- {
- return null;
- }
- }
- }
-
- ///
- /// Deserializes the message to a specific post data type.
- ///
- public T? AsPostData() where T : class
- {
- if (MessageBytes == null) return null;
- try
- {
- var msg = MessageBytes.MessagePackTo>(ContractlessStandardResolver.Options);
- return msg.PostData;
- }
- catch
- {
- // Fallback: try deserializing as raw JSON wrapper
- try
- {
- var rawMsg = MessageBytes.MessagePackTo(ContractlessStandardResolver.Options);
- return rawMsg.PostDataJson?.JsonTo();
- }
- catch
- {
- return null;
- }
- }
- }
-}
-
-///
-/// Represents a message received by the client.
-///
-public record ReceivedClientMessage(int MessageTag, byte[] MessageBytes)
-{
- ///
- /// Deserializes the message as a response.
- ///
- public SignalResponseDataMessage? AsResponse()
- {
- try
- {
- return MessageBytes.BinaryTo();
- }
- catch
- {
- return null;
- }
- }
-}
diff --git a/AyCode.Services/SignalRs/AcSignalRClientBase.cs b/AyCode.Services/SignalRs/AcSignalRClientBase.cs
index efbf6fb..ab453af 100644
--- a/AyCode.Services/SignalRs/AcSignalRClientBase.cs
+++ b/AyCode.Services/SignalRs/AcSignalRClientBase.cs
@@ -4,7 +4,6 @@ using AyCode.Core.Extensions;
using AyCode.Core.Helpers;
using AyCode.Core.Loggers;
using AyCode.Interfaces.Entities;
-using MessagePack.Resolvers;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.SignalR.Client;
@@ -119,7 +118,7 @@ namespace AyCode.Services.SignalRs
await StartConnection();
- var msgp = message?.ToMessagePack(ContractlessStandardResolver.Options);
+ var msgBytes = message != null ? SignalRSerializationHelper.SerializeToBinary(message) : null;
if (!IsConnected())
{
@@ -127,7 +126,7 @@ namespace AyCode.Services.SignalRs
return;
}
- await SendToHubAsync(messageTag, msgp, requestId);
+ await SendToHubAsync(messageTag, msgBytes, requestId);
}
#region CRUD
@@ -144,31 +143,61 @@ namespace AyCode.Services.SignalRs
public virtual Task GetByIdAsync(int messageTag, object[] ids)
=> PostAsync(messageTag, ids);
+ ///
+ /// Gets data by ID with async callback response. Callback is second parameter.
+ ///
+ public virtual Task GetByIdAsync(int messageTag, Func responseCallback, object id)
+ => SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage(new IdMessage(id)), responseCallback);
+
+ ///
+ /// Gets data by IDs with async callback response. Callback is second parameter.
+ ///
+ public virtual Task GetByIdAsync(int messageTag, Func responseCallback, object[] ids)
+ => SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage(new IdMessage(ids)), responseCallback);
+
public virtual Task GetAllAsync(int messageTag)
=> SendMessageToServerAsync(messageTag);
public virtual Task GetAllAsync(int messageTag, object[]? contextParams)
=> SendMessageToServerAsync(messageTag, contextParams == null || contextParams.Length == 0 ? null : new SignalPostJsonDataMessage(new IdMessage(contextParams)), GetNextRequestId());
+ ///
+ /// Gets all data with async callback response. Callback is second parameter.
+ ///
+ public virtual Task GetAllAsync(int messageTag, Func responseCallback)
+ => SendMessageToServerAsync(messageTag, null, responseCallback);
+
+ ///
+ /// Gets all data with context params and async callback response.
+ ///
+ public virtual Task GetAllAsync(int messageTag, Func responseCallback, object[]? contextParams)
+ => SendMessageToServerAsync(messageTag, contextParams == null || contextParams.Length == 0 ? null : new SignalPostJsonDataMessage(new IdMessage(contextParams)), responseCallback);
+
public virtual Task PostDataAsync(int messageTag, TPostData postData) where TPostData : class
=> SendMessageToServerAsync(messageTag, CreatePostMessage(postData), GetNextRequestId());
public virtual Task PostDataAsync(int messageTag, TPostData postData)
=> SendMessageToServerAsync(messageTag, CreatePostMessage(postData), GetNextRequestId());
+ ///
+ /// Posts data with async callback response.
+ ///
+ public virtual Task PostDataAsync(int messageTag, TPostData postData, Func responseCallback)
+ => SendMessageToServerAsync(messageTag, CreatePostMessage(postData), responseCallback);
+
+ ///
+ /// Posts data with typed async callback response.
+ ///
+ public virtual Task PostDataAsync(int messageTag, TPostData postData, Func responseCallback)
+ => SendMessageToServerAsync(messageTag, CreatePostMessage(postData), responseCallback);
+
///
/// Posts data and invokes callback with response. Fire-and-forget friendly for background saves.
///
public virtual Task PostDataAsync(int messageTag, TPostData postData, Action responseCallback)
{
var requestId = GetNextRequestId();
- var requestModel = SignalRRequestModelPool.Get(new Action(response =>
- {
- if (response is SignalResponseDataMessage dataMsg)
- responseCallback(dataMsg);
- else
- Logger.Error($"PostDataAsync callback received unexpected message type: {response.GetType().Name}");
- }));
+ var requestModel = SignalRRequestModelPool.Get(responseCallback);
_responseByRequestId[requestId] = requestModel;
return SendMessageToServerAsync(messageTag, CreatePostMessage(postData), requestId);
@@ -206,6 +235,18 @@ namespace AyCode.Services.SignalRs
public virtual Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message)
=> SendMessageToServerAsync(messageTag, message, GetNextRequestId());
+ ///
+ /// Sends message to server with async callback response.
+ ///
+ public virtual async Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, Func responseCallback)
+ {
+ var requestId = GetNextRequestId();
+ var requestModel = SignalRRequestModelPool.Get(responseCallback);
+
+ _responseByRequestId[requestId] = requestModel;
+ await SendMessageToServerAsync(messageTag, message, requestId);
+ }
+
protected virtual async Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, int requestId)
{
Logger.DebugConditional($"Client SendMessageToServerAsync; {nameof(requestId)}: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}");
@@ -231,6 +272,15 @@ namespace AyCode.Services.SignalRs
return await Task.FromException(new Exception(errorText));
}
+ // 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();
if (responseData == null && responseMessage.Status == SignalResponseStatus.Success)
@@ -239,8 +289,8 @@ namespace AyCode.Services.SignalRs
return default;
}
- var serializerType = responseMessage.DataSerializerType == AcSerializerType.Binary ? "Binary" : "JSON";
- Logger.Info($"Client deserialized response ({serializerType}). Total: {(DateTime.UtcNow.Subtract(startTime)).TotalMilliseconds} ms! requestId: {requestId}; tag: {messageTag} [{ConstHelper.NameByValue(TagsName, messageTag)}]");
+ 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)}]");
return responseData;
}
@@ -273,7 +323,7 @@ namespace AyCode.Services.SignalRs
requestModel.ResponseDateTime = DateTime.UtcNow;
Logger.Debug($"[{requestModel.ResponseDateTime.Subtract(requestModel.RequestDateTime).TotalMilliseconds:N0}ms][{messageBytes.Length / 1024}kb]{logText}");
- var responseMessage = messageBytes.BinaryTo() ?? new SignalResponseDataMessage();
+ var responseMessage = SignalRSerializationHelper.DeserializeFromBinary(messageBytes) ?? new SignalResponseDataMessage();
switch (requestModel.ResponseByRequestId)
{
@@ -281,12 +331,17 @@ namespace AyCode.Services.SignalRs
requestModel.ResponseByRequestId = responseMessage;
return Task.CompletedTask;
- case Action messageCallback:
- if (_responseByRequestId.TryRemove(reqId, out var callbackModel))
- SignalRRequestModelPool.Return(callbackModel);
- messageCallback.Invoke(responseMessage);
+ case Action actionCallback:
+ if (_responseByRequestId.TryRemove(reqId, out var actionModel))
+ SignalRRequestModelPool.Return(actionModel);
+ actionCallback.Invoke(responseMessage);
return Task.CompletedTask;
+ case Func funcCallback:
+ if (_responseByRequestId.TryRemove(reqId, out var funcModel))
+ SignalRRequestModelPool.Return(funcModel);
+ return funcCallback.Invoke(responseMessage);
+
default:
Logger.Error($"Client OnReceiveMessage switch; unknown message type: {requestModel.ResponseByRequestId?.GetType().Name}; {ConstHelper.NameByValue(TagsName, messageTag)}");
break;
diff --git a/AyCode.Services/SignalRs/IAcSignalRHubClient.cs b/AyCode.Services/SignalRs/IAcSignalRHubClient.cs
index 53805b4..64e734b 100644
--- a/AyCode.Services/SignalRs/IAcSignalRHubClient.cs
+++ b/AyCode.Services/SignalRs/IAcSignalRHubClient.cs
@@ -1,7 +1,5 @@
using AyCode.Core.Extensions;
-using MessagePack;
using AyCode.Core.Interfaces;
-using AyCode.Core.Compression;
using System.Buffers;
using System.Runtime.CompilerServices;
using JsonIgnoreAttribute = Newtonsoft.Json.JsonIgnoreAttribute;
@@ -9,6 +7,10 @@ using STJIgnore = System.Text.Json.Serialization.JsonIgnoreAttribute;
namespace AyCode.Services.SignalRs;
+///
+/// Message container for serialized parameter IDs.
+/// Optimized for common primitive types to avoid full JSON overhead.
+///
public class IdMessage
{
public List Ids { get; private set; }
@@ -20,30 +22,26 @@ public class IdMessage
///
/// Creates IdMessage with multiple parameters serialized directly as JSON.
- /// Each parameter is serialized independently without array wrapping.
- /// Use object[] explicitly to pass multiple parameters.
///
public IdMessage(object[] ids)
{
Ids = new List(ids.Length);
for (var i = 0; i < ids.Length; i++)
{
- Ids.Add(SerializeValue(ids[i]));
+ Ids.Add(SignalRSerializationHelper.SerializePrimitiveToJson(ids[i]));
}
}
///
/// Creates IdMessage with a single parameter serialized as JSON.
- /// Collections (List, Array, etc.) are serialized as a single JSON array.
///
public IdMessage(object id)
{
- Ids = new List(1) { SerializeValue(id) };
+ Ids = [SignalRSerializationHelper.SerializePrimitiveToJson(id)];
}
///
/// Creates IdMessage with multiple Guid parameters.
- /// Each Guid is serialized as a separate Id entry.
///
public IdMessage(IEnumerable ids)
{
@@ -51,63 +49,33 @@ public class IdMessage
Ids = new List(idsArray.Length);
for (var i = 0; i < idsArray.Length; i++)
{
- Ids.Add(SerializeGuid(idsArray[i]));
+ Ids.Add(SignalRSerializationHelper.SerializeGuidToJson(idsArray[i]));
}
}
- ///
- /// Optimized serialization for common primitive types to avoid full JSON serialization overhead.
- /// Falls back to full JSON serialization for complex types or strings with special characters.
- ///
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static string SerializeValue(object value)
- {
- return value switch
- {
- int i => i.ToString(),
- long l => l.ToString(),
- Guid g => SerializeGuid(g),
- bool b => b ? "true" : "false",
- // Strings need proper JSON escaping for special characters
- string => value.ToJson(),
- _ => value.ToJson()
- };
- }
-
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static string SerializeGuid(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] = '"';
- });
- }
-
public override string ToString() => string.Join("; ", Ids);
}
-[MessagePackObject]
+///
+/// Message containing JSON-serialized post data.
+///
public class SignalPostJsonMessage
{
- [Key(0)]
public string PostDataJson { get; set; } = "";
public SignalPostJsonMessage() { }
protected SignalPostJsonMessage(string postDataJson) => PostDataJson = postDataJson;
}
-[MessagePackObject(AllowPrivate = false)]
+///
+/// Generic message containing JSON-serialized post data with typed access.
+///
public class SignalPostJsonDataMessage : SignalPostJsonMessage, ISignalPostMessage
{
- [IgnoreMember]
[JsonIgnore]
[STJIgnore]
private TPostDataType? _postData;
- [IgnoreMember]
[JsonIgnore]
[STJIgnore]
public TPostDataType PostData
@@ -125,10 +93,11 @@ public class SignalPostJsonDataMessage : SignalPostJsonMessage, I
public SignalPostJsonDataMessage(string postDataJson) : base(postDataJson) { }
}
-[MessagePackObject]
+///
+/// Simple message containing post data.
+///
public class SignalPostMessage(TPostData postData) : ISignalPostMessage
{
- [Key(0)]
public TPostData? PostData { get; set; } = postData;
}
@@ -137,10 +106,11 @@ public interface ISignalPostMessage : ISignalRMessage
TPostData? PostData { get; }
}
-[MessagePackObject]
+///
+/// Message for requesting by Guid ID.
+///
public class SignalRequestByIdMessage(Guid id) : ISignalRequestMessage, IId
{
- [Key(0)]
public Guid Id { get; set; } = id;
}
@@ -163,66 +133,6 @@ public enum SignalResponseStatus : byte
Success = 5
}
-///
-/// Signal response message with lazy deserialization support.
-/// Used for callback-based response handling.
-///
-[MessagePackObject(AllowPrivate = false)]
-public sealed class SignalResponseMessage : ISignalResponseMessage
-{
- [IgnoreMember]
- [JsonIgnore]
- [STJIgnore]
- private TResponseData? _responseData;
-
- [IgnoreMember]
- [JsonIgnore]
- [STJIgnore]
- private bool _isDeserialized;
-
- [Key(0)]
- public int MessageTag { get; set; }
-
- [Key(1)]
- public SignalResponseStatus Status { get; set; }
-
- [Key(2)]
- public string? ResponseDataJson { get; set; }
-
- [IgnoreMember]
- [JsonIgnore]
- [STJIgnore]
- public TResponseData? ResponseData
- {
- get
- {
- if (!_isDeserialized)
- {
- _isDeserialized = true;
- _responseData = ResponseDataJson != null ? ResponseDataJson.JsonTo() : 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;
-}
-
///
/// Unified signal response message that supports both JSON and Binary serialization.
/// JSON mode uses Brotli compression for reduced payload size.
@@ -233,15 +143,13 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa
public int MessageTag { get; set; }
public SignalResponseStatus Status { get; set; }
public AcSerializerType DataSerializerType { get; set; }
- public byte[]? ResponseDataBin { get; set; }
+ public byte[]? ResponseData { get; set; }
[JsonIgnore] [STJIgnore] private object? _cachedResponseData;
[JsonIgnore] [STJIgnore] private byte[]? _rentedDecompressedBuffer;
[JsonIgnore] [STJIgnore] private int _decompressedLength;
- public SignalResponseDataMessage()
- {
- }
+ public SignalResponseDataMessage() { }
public SignalResponseDataMessage(int messageTag, SignalResponseStatus status)
{
@@ -253,65 +161,23 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa
: this(messageTag, status)
{
DataSerializerType = serializerOptions.SerializerType;
- if (responseData == null)
- {
- ResponseDataBin = null;
- return;
- }
-
- if (serializerOptions.SerializerType == AcSerializerType.Binary)
- {
- if (responseData is byte[] byteData)
- {
- ResponseDataBin = byteData;
- return;
- }
-
- var binaryOptions = serializerOptions as AcBinarySerializerOptions ?? AcBinarySerializerOptions.Default;
- // Use ArrayBufferWriter for zero-copy serialization
- var writer = new ArrayBufferWriter(256);
- responseData.ToBinary(writer, binaryOptions);
- ResponseDataBin = writer.WrittenSpan.ToArray();
- }
- else
- {
- string json;
- if (responseData is string strData)
- {
- var trimmed = strData.AsSpan().Trim();
- if (trimmed.Length > 1 && (trimmed[0] == '{' || trimmed[0] == '[') && (trimmed[^1] == '}' || trimmed[^1] == ']'))
- json = strData;
- else
- {
- var jsonOptions = serializerOptions as AcJsonSerializerOptions ?? AcJsonSerializerOptions.Default;
- json = responseData.ToJson(jsonOptions);
- }
- }
- else
- {
- var jsonOptions = serializerOptions as AcJsonSerializerOptions ?? AcJsonSerializerOptions.Default;
- json = responseData.ToJson(jsonOptions);
- }
-
- ResponseDataBin = BrotliHelper.Compress(json);
- }
+ ResponseData = SignalRSerializationHelper.CreateResponseData(responseData, serializerOptions);
}
///
/// Deserializes the ResponseData to the specified type.
- /// For JSON mode, decompresses Brotli to pooled buffer and deserializes directly (no string allocation).
/// Uses cached result for repeated calls.
///
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public T? GetResponseData()
{
if (_cachedResponseData != null) return (T)_cachedResponseData;
- if (ResponseDataBin == null) return default;
+ if (ResponseData == null) return default;
if (DataSerializerType == AcSerializerType.Binary)
- return (T)(_cachedResponseData = ResponseDataBin.BinaryTo()!);
+ return (T)(_cachedResponseData = ResponseData.BinaryTo()!);
- // Decompress Brotli to pooled buffer and deserialize directly from ReadOnlySpan (no string allocation)
+ // Decompress Brotli to pooled buffer and deserialize directly
EnsureDecompressed();
var result = AcJsonDeserializer.Deserialize(new ReadOnlySpan(_rentedDecompressedBuffer, 0, _decompressedLength));
_cachedResponseData = result;
@@ -323,7 +189,7 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa
///
public ReadOnlySpan GetDecompressedJsonSpan()
{
- if (ResponseDataBin == null) return ReadOnlySpan.Empty;
+ if (ResponseData == null) return ReadOnlySpan.Empty;
if (DataSerializerType == AcSerializerType.Binary) return ReadOnlySpan.Empty;
EnsureDecompressed();
@@ -335,18 +201,11 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa
{
if (_rentedDecompressedBuffer != null) return;
- var (buffer, length) = BrotliHelper.DecompressToRentedBuffer(ResponseDataBin.AsSpan());
- _rentedDecompressedBuffer = buffer;
- _decompressedLength = length;
+ (_rentedDecompressedBuffer, _decompressedLength) = SignalRSerializationHelper.DecompressToRentedBuffer(ResponseData!);
}
public void Dispose()
{
- if (_rentedDecompressedBuffer != null)
- {
- ArrayPool.Shared.Return(_rentedDecompressedBuffer);
- _rentedDecompressedBuffer = null;
- }
}
}
diff --git a/AyCode.Services/SignalRs/SignalRSerializationHelper.cs b/AyCode.Services/SignalRs/SignalRSerializationHelper.cs
new file mode 100644
index 0000000..6f7287f
--- /dev/null
+++ b/AyCode.Services/SignalRs/SignalRSerializationHelper.cs
@@ -0,0 +1,186 @@
+using System.Buffers;
+using System.Runtime.CompilerServices;
+using AyCode.Core.Compression;
+using AyCode.Core.Extensions;
+
+namespace AyCode.Services.SignalRs;
+
+///
+/// Centralized helper for SignalR serialization operations.
+/// Provides optimized primitives for JSON/Binary serialization with pooled buffers.
+///
+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
+
+ ///
+ /// Serialize a primitive value to JSON string with minimal overhead.
+ /// Falls back to full JSON serialization for complex types.
+ ///
+ [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()
+ };
+ }
+
+ ///
+ /// Serialize a Guid to JSON string with pre-allocated buffer.
+ ///
+ [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
+
+ ///
+ /// Serialize object to binary using pooled ArrayBufferWriter.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static byte[] SerializeToBinary(T value, AcBinarySerializerOptions? options = null)
+ {
+ var writer = new ArrayBufferWriter(256);
+ value.ToBinary(writer, options ?? AcBinarySerializerOptions.Default);
+ return writer.WrittenSpan.ToArray();
+ }
+
+ ///
+ /// Serialize object to binary and write to existing ArrayBufferWriter.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void SerializeToBinary(T value, ArrayBufferWriter writer, AcBinarySerializerOptions? options = null)
+ {
+ value.ToBinary(writer, options ?? AcBinarySerializerOptions.Default);
+ }
+
+ ///
+ /// Deserialize binary data to object.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static T? DeserializeFromBinary(byte[] data)
+ {
+ return data.BinaryTo();
+ }
+
+ ///
+ /// Deserialize binary data from ReadOnlySpan.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static T? DeserializeFromBinary(ReadOnlySpan data)
+ {
+ return data.BinaryTo();
+ }
+
+ #endregion
+
+ #region JSON Serialization with Brotli
+
+ ///
+ /// Serialize object to JSON and compress with Brotli.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static byte[] SerializeToCompressedJson(T value, AcJsonSerializerOptions? options = null)
+ {
+ var json = value.ToJson(options ?? AcJsonSerializerOptions.Default);
+ return BrotliHelper.Compress(json);
+ }
+
+ ///
+ /// Decompress Brotli data and deserialize JSON to object.
+ /// Uses pooled buffer for decompression.
+ ///
+ public static T? DeserializeFromCompressedJson(byte[] compressedData)
+ {
+ var (buffer, length) = BrotliHelper.DecompressToRentedBuffer(compressedData.AsSpan());
+ try
+ {
+ return AcJsonDeserializer.Deserialize(new ReadOnlySpan(buffer, 0, length));
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+
+ ///
+ /// Decompress Brotli data to rented buffer for direct processing.
+ /// Caller must return buffer to ArrayPool.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static (byte[] Buffer, int Length) DecompressToRentedBuffer(byte[] compressedData)
+ {
+ return BrotliHelper.DecompressToRentedBuffer(compressedData.AsSpan());
+ }
+
+ #endregion
+
+ #region Response Data Helpers
+
+ ///
+ /// Check if string appears to be valid JSON (starts with { or [).
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static bool IsValidJsonString(ReadOnlySpan text)
+ {
+ var trimmed = text.Trim();
+ return trimmed.Length > 1 &&
+ (trimmed[0] == '{' || trimmed[0] == '[') &&
+ (trimmed[^1] == '}' || trimmed[^1] == ']');
+ }
+
+ ///
+ /// Create response binary data based on serializer type.
+ ///
+ 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
+}