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 +}