From 09a4604e524e5c46701613e0bc79372d2b2fc6cc Mon Sep 17 00:00:00 2001 From: Loretta Date: Fri, 12 Dec 2025 21:40:48 +0100 Subject: [PATCH] Add binary serialization support for SignalR responses Introduces SignalResponseBinaryMessage for efficient binary serialization of response data alongside existing JSON support. Adds utilities to detect serializer type and updates both server and client logic to handle JSON or binary formats automatically. Refactors response creation, logging, and deserialization for consistency and performance. Updates benchmarks and ensures all MessagePack operations use ContractlessStandardResolver.Options. Improves robustness and backward compatibility in client callback handling. --- .../TestModels/SignalRTestInfrastructure.cs | 7 +- AyCode.Core/Extensions/JsonUtilities.cs | 67 ++++++++++++ .../SignalRs/SignalRTestHelper.cs | 2 +- .../SignalRs/AcWebSignalRHubBase.cs | 46 ++++++-- .../SignalRs/AcSignalRClientBase.cs | 100 ++++++++++++++---- .../SignalRs/IAcSignalRHubClient.cs | 47 ++++++++ .../SignalRCommunicationBenchmarks.cs | 6 +- 7 files changed, 237 insertions(+), 38 deletions(-) diff --git a/AyCode.Core.Tests/TestModels/SignalRTestInfrastructure.cs b/AyCode.Core.Tests/TestModels/SignalRTestInfrastructure.cs index b8a95dc..f0963b1 100644 --- a/AyCode.Core.Tests/TestModels/SignalRTestInfrastructure.cs +++ b/AyCode.Core.Tests/TestModels/SignalRTestInfrastructure.cs @@ -15,11 +15,6 @@ public static class SignalRMessageFactory /// public static readonly MessagePackSerializerOptions ContractlessOptions = ContractlessStandardResolver.Options; - /// - /// Cached MessagePack options for Standard resolver - /// - public static readonly MessagePackSerializerOptions StandardOptions = MessagePackSerializerOptions.Standard; - /// /// Creates a MessagePack message for multiple parameters using IdMessage format. /// Each parameter is serialized directly as JSON. @@ -47,7 +42,7 @@ public static class SignalRMessageFactory { var json = obj.ToJson(); var postMessage = new SignalRPostMessageDto { PostDataJson = json }; - return MessagePackSerializer.Serialize(postMessage, StandardOptions); + return MessagePackSerializer.Serialize(postMessage, ContractlessOptions); } /// diff --git a/AyCode.Core/Extensions/JsonUtilities.cs b/AyCode.Core/Extensions/JsonUtilities.cs index 7251503..87ecaaa 100644 --- a/AyCode.Core/Extensions/JsonUtilities.cs +++ b/AyCode.Core/Extensions/JsonUtilities.cs @@ -297,6 +297,73 @@ public static class JsonUtilities #region Type Checking Methods + /// + /// Detects the serializer type from the response data. + /// Checks the first byte after MessagePack deserialization to determine if it's JSON or Binary format. + /// + /// The response data (string for JSON, byte[] for Binary) + /// The detected serializer type + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static AcSerializerType DetectSerializerType(object? responseData) + { + return responseData switch + { + byte[] => AcSerializerType.Binary, + string => AcSerializerType.Json, + null => AcSerializerType.Json, // Default to JSON for null + _ => AcSerializerType.Json // Default to JSON for unknown types + }; + } + + /// + /// Detects if byte array contains JSON or Binary serialized data. + /// JSON typically starts with '{', '[', '"' or whitespace. + /// Binary format starts with version byte (typically 1) followed by metadata flag. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static AcSerializerType DetectSerializerTypeFromBytes(ReadOnlySpan data) + { + if (data.IsEmpty) return AcSerializerType.Json; + + var firstByte = data[0]; + + // JSON typically starts with: '{' (123), '[' (91), '"' (34), or whitespace (32, 9, 10, 13) + // Also numbers: '0'-'9' (48-57), '-' (45), 't' (116 for true), 'f' (102 for false), 'n' (110 for null) + return firstByte switch + { + (byte)'{' or (byte)'[' or (byte)'"' => AcSerializerType.Json, + (byte)' ' or (byte)'\t' or (byte)'\n' or (byte)'\r' => AcSerializerType.Json, + >= (byte)'0' and <= (byte)'9' => AcSerializerType.Json, + (byte)'-' or (byte)'t' or (byte)'f' or (byte)'n' => AcSerializerType.Json, + // Binary format version 1 with metadata or no-metadata header + 1 when data.Length > 1 && (data[1] == 32 || data[1] == 33) => AcSerializerType.Binary, + _ => AcSerializerType.Binary // Default to Binary for unknown byte patterns + }; + } + + /// + /// Detects serializer type from a string (checks if it looks like JSON). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static AcSerializerType DetectSerializerTypeFromString(string? data) + { + if (string.IsNullOrEmpty(data)) return AcSerializerType.Json; + + var trimmed = data.AsSpan().Trim(); + if (trimmed.IsEmpty) return AcSerializerType.Json; + + var firstChar = trimmed[0]; + + // Valid JSON starts with: '{', '[', '"', number, 't', 'f', 'n' + return firstChar switch + { + '{' or '[' or '"' => AcSerializerType.Json, + >= '0' and <= '9' => AcSerializerType.Json, + '-' or 't' or 'f' or 'n' => AcSerializerType.Json, + _ => AcSerializerType.Binary // Likely Base64 encoded binary + }; + } + /// /// Fast primitive check using type code. /// diff --git a/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs b/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs index d342f64..c6135a4 100644 --- a/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs +++ b/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs @@ -40,7 +40,7 @@ public static class SignalRTestHelper { var json = obj.ToJson(); var postMessage = new SignalPostJsonDataMessage(json); - return MessagePackSerializer.Serialize(postMessage, MessagePackSerializerOptions.Standard); + return MessagePackSerializer.Serialize(postMessage, MessagePackOptions); } /// diff --git a/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs b/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs index 95c1ec0..ea83224 100644 --- a/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs +++ b/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs @@ -72,15 +72,15 @@ public abstract class AcWebSignalRHubBase(IConfiguration if (TryFindAndInvokeMethod(messageTag, message, tagName, out var responseData)) { - var responseDataJson = new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, responseData); + var responseMessage = CreateResponseMessage(messageTag, SignalResponseStatus.Success, responseData); if (Logger.LogLevel <= LogLevel.Debug) { - var responseDataJsonKiloBytes = System.Text.Encoding.Unicode.GetByteCount(responseDataJson.ResponseData ?? "") / 1024; - Logger.Debug($"[{responseDataJsonKiloBytes}kb] responseData serialized to json"); + var responseSize = GetResponseSize(responseMessage); + Logger.Debug($"[{responseSize / 1024}kb] responseData serialized ({SerializerOptions.SerializerType})"); } - await ResponseToCaller(messageTag, responseDataJson, requestId); + await ResponseToCaller(messageTag, responseMessage, requestId); return; } @@ -92,7 +92,33 @@ public abstract class AcWebSignalRHubBase(IConfiguration Logger.Error($"Server OnReceiveMessage; {ex.Message}; {tagName}", ex); } - await ResponseToCaller(messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Error), requestId); + await ResponseToCaller(messageTag, CreateResponseMessage(messageTag, SignalResponseStatus.Error, null), requestId); + } + + /// + /// Creates a response message using the configured serializer (JSON or Binary). + /// + protected virtual ISignalRMessage CreateResponseMessage(int messageTag, SignalResponseStatus status, object? responseData) + { + if (SerializerOptions.SerializerType == AcSerializerType.Binary) + { + return new SignalResponseBinaryMessage(messageTag, status, responseData, (AcBinarySerializerOptions)SerializerOptions); + } + + return new SignalResponseJsonMessage(messageTag, status, responseData); + } + + /// + /// Gets the size of the response data for logging purposes. + /// + private int GetResponseSize(ISignalRMessage responseMessage) + { + return responseMessage switch + { + SignalResponseJsonMessage jsonMsg => System.Text.Encoding.Unicode.GetByteCount(jsonMsg.ResponseData ?? ""), + SignalResponseBinaryMessage binaryMsg => binaryMsg.ResponseData?.Length ?? 0, + _ => 0 + }; } /// @@ -197,28 +223,28 @@ public abstract class AcWebSignalRHubBase(IConfiguration #region Response Methods protected virtual Task ResponseToCallerWithContent(int messageTag, object? content) - => ResponseToCaller(messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null); + => ResponseToCaller(messageTag, CreateResponseMessage(messageTag, SignalResponseStatus.Success, content), null); protected virtual Task ResponseToCaller(int messageTag, ISignalRMessage message, int? requestId) => SendMessageToClient(Clients.Caller, messageTag, message, requestId); protected virtual Task SendMessageToUserIdWithContent(string userId, int messageTag, object? content) - => SendMessageToUserIdInternal(userId, messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null); + => SendMessageToUserIdInternal(userId, messageTag, CreateResponseMessage(messageTag, SignalResponseStatus.Success, content), null); protected virtual Task SendMessageToUserIdInternal(string userId, int messageTag, ISignalRMessage message, int? requestId) => SendMessageToClient(Clients.User(userId), messageTag, message, requestId); protected virtual Task SendMessageToConnectionIdWithContent(string connectionId, int messageTag, object? content) - => SendMessageToConnectionIdInternal(connectionId, messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null); + => SendMessageToConnectionIdInternal(connectionId, messageTag, CreateResponseMessage(messageTag, SignalResponseStatus.Success, content), null); protected virtual Task SendMessageToConnectionIdInternal(string connectionId, int messageTag, ISignalRMessage message, int? requestId) => SendMessageToClient(Clients.Client(connectionId), messageTag, message, requestId); protected virtual Task SendMessageToOthers(int messageTag, object? content) - => SendMessageToClient(Clients.Others, messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null); + => SendMessageToClient(Clients.Others, messageTag, CreateResponseMessage(messageTag, SignalResponseStatus.Success, content), null); protected virtual Task SendMessageToAll(int messageTag, object? content) - => SendMessageToClient(Clients.All, messageTag, new SignalResponseJsonMessage(messageTag, SignalResponseStatus.Success, content), null); + => SendMessageToClient(Clients.All, messageTag, CreateResponseMessage(messageTag, SignalResponseStatus.Success, content), null); protected virtual async Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, ISignalRMessage message, int? requestId = null) { diff --git a/AyCode.Services/SignalRs/AcSignalRClientBase.cs b/AyCode.Services/SignalRs/AcSignalRClientBase.cs index ecef3de..9de4543 100644 --- a/AyCode.Services/SignalRs/AcSignalRClientBase.cs +++ b/AyCode.Services/SignalRs/AcSignalRClientBase.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Http.Connections; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.Logging; +using static AyCode.Core.Extensions.JsonUtilities; namespace AyCode.Services.SignalRs { @@ -338,26 +339,33 @@ namespace AyCode.Services.SignalRs try { if (await TaskHelper.WaitToAsync(() => _responseByRequestId[requestId].ResponseByRequestId != null, TransportSendTimeout, MsDelay, MsFirstDelay) && - _responseByRequestId.TryRemove(requestId, out var obj) && obj.ResponseByRequestId is ISignalResponseMessage responseMessage) + _responseByRequestId.TryRemove(requestId, out var obj) && obj.ResponseByRequestId is ISignalResponseMessage responseMessage) { startTime = obj.RequestDateTime; SignalRRequestModelPool.Return(obj); - if (responseMessage.Status == SignalResponseStatus.Error || responseMessage.ResponseData == null) + if (responseMessage.Status == SignalResponseStatus.Error) { var errorText = $"Client SendMessageToServerAsync response error; await; tag: {messageTag}; Status: {responseMessage.Status}; ConnectionState: {GetConnectionState()}; requestId: {requestId}"; - Logger.Error(errorText); - - //TODO: Ideiglenes, majd a ResponseMessage-et kell visszaadni a Status miatt! - J. return await Task.FromException(new Exception(errorText)); - - //throw new Exception(errorText); - //return default; } - var responseData = responseMessage.ResponseData.JsonTo(); - Logger.Info($"Client deserialized response json. Total: {(DateTime.UtcNow.Subtract(startTime)).TotalMilliseconds} ms! requestId: {requestId}; tag: {messageTag} [{ConstHelper.NameByValue(TagsName, messageTag)}]"); + var responseData = DeserializeResponseData(responseMessage); + + if (responseData == null && responseMessage.Status == SignalResponseStatus.Success) + { + // Null response is valid for Success status + Logger.Info($"Client received null response. Total: {(DateTime.UtcNow.Subtract(startTime)).TotalMilliseconds} ms! requestId: {requestId}; tag: {messageTag} [{ConstHelper.NameByValue(TagsName, messageTag)}]"); + return default; + } + + var serializerType = responseMessage switch + { + SignalResponseBinaryMessage => "Binary", + _ => "JSON" + }; + Logger.Info($"Client deserialized response ({serializerType}). Total: {(DateTime.UtcNow.Subtract(startTime)).TotalMilliseconds} ms! requestId: {requestId}; tag: {messageTag} [{ConstHelper.NameByValue(TagsName, messageTag)}]"); return responseData; } @@ -375,6 +383,27 @@ namespace AyCode.Services.SignalRs return default; } + /// + /// Deserializes response data from either JSON or Binary format. + /// Automatically detects the format based on the response message type. + /// + private static TResponse? DeserializeResponseData(ISignalResponseMessage responseMessage) + { + return responseMessage switch + { + SignalResponseBinaryMessage binaryMsg when binaryMsg.ResponseData != null + => binaryMsg.ResponseData.BinaryTo(), + + SignalResponseJsonMessage jsonMsg when !string.IsNullOrEmpty(jsonMsg.ResponseData) + => jsonMsg.ResponseData.JsonTo(), + + ISignalResponseMessage stringMsg when !string.IsNullOrEmpty(stringMsg.ResponseData) + => stringMsg.ResponseData.JsonTo(), + + _ => default + }; + } + public virtual Task SendMessageToServerAsync(int messageTag, Func, Task> responseCallback) => SendMessageToServerAsync(messageTag, null, responseCallback); @@ -383,13 +412,13 @@ namespace AyCode.Services.SignalRs if (messageTag == 0) Logger.Error($"SendMessageToServerAsync; messageTag == 0"); var requestId = GetNextRequestId(); - var requestModel = SignalRRequestModelPool.Get(new Action>(responseMessage => + var requestModel = SignalRRequestModelPool.Get(new Action(responseMessage => { TResponseData? responseData = default; if (responseMessage.Status == SignalResponseStatus.Success) { - responseData = string.IsNullOrEmpty(responseMessage.ResponseData) ? default : responseMessage.ResponseData.JsonTo(); + responseData = DeserializeResponseData(responseMessage); } else Logger.Error($"Client SendMessageToServerAsync response error; callback; Status: {responseMessage.Status}; ConnectionState: {GetConnectionState()}; requestId: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}"); @@ -421,7 +450,7 @@ namespace AyCode.Services.SignalRs _responseByRequestId[reqId].ResponseDateTime = DateTime.UtcNow; Logger.Debug($"[{_responseByRequestId[reqId].ResponseDateTime.Subtract(_responseByRequestId[reqId].RequestDateTime).TotalMilliseconds:N0}ms][{(messageBytes.Length / 1024)}kb]{logText}"); - var responseMessage = DeserializeResponseMsgPack(messageBytes); + var responseMessage = DeserializeResponseMessage(messageBytes); switch (_responseByRequestId[reqId].ResponseByRequestId) { @@ -429,14 +458,24 @@ namespace AyCode.Services.SignalRs _responseByRequestId[reqId].ResponseByRequestId = responseMessage; return Task.CompletedTask; - case Action> messagePackCallback: + case Action messageCallback: if (_responseByRequestId.TryRemove(reqId, out var callbackModel)) { SignalRRequestModelPool.Return(callbackModel); } - messagePackCallback.Invoke(responseMessage); - return Task.CompletedTask; // ← Callback: NEM hívjuk meg a MessageReceived-et + messageCallback.Invoke(responseMessage); + return Task.CompletedTask; + + // Legacy support for string-based callbacks + case Action> stringCallback when responseMessage is SignalResponseJsonMessage jsonMsg: + if (_responseByRequestId.TryRemove(reqId, out var legacyModel)) + { + SignalRRequestModelPool.Return(legacyModel); + } + + stringCallback.Invoke(jsonMsg); + return Task.CompletedTask; default: Logger.Error($"Client OnReceiveMessage switch; unknown message type: {_responseByRequestId[reqId].ResponseByRequestId?.GetType().Name}; {ConstHelper.NameByValue(TagsName, messageTag)}"); @@ -470,7 +509,32 @@ namespace AyCode.Services.SignalRs return Task.CompletedTask; } - protected virtual SignalResponseJsonMessage DeserializeResponseMsgPack(byte[] messageBytes) - => messageBytes.MessagePackTo(ContractlessStandardResolver.Options); + /// + /// Deserializes a MessagePack response to the appropriate message type (JSON or Binary). + /// First tries to deserialize as Binary, then falls back to JSON if that fails. + /// + protected virtual ISignalResponseMessage DeserializeResponseMessage(byte[] messageBytes) + { + // Try Binary format first (SignalResponseBinaryMessage) + try + { + var binaryMsg = messageBytes.MessagePackTo(ContractlessStandardResolver.Options); + if (binaryMsg.ResponseData != null && binaryMsg.ResponseData.Length > 0) + { + // Verify it's actually binary data by checking the format + if (DetectSerializerTypeFromBytes(binaryMsg.ResponseData) == AcSerializerType.Binary) + { + return binaryMsg; + } + } + } + catch + { + // Not a binary message, try JSON + } + + // Fall back to JSON format + return messageBytes.MessagePackTo(ContractlessStandardResolver.Options); + } } } diff --git a/AyCode.Services/SignalRs/IAcSignalRHubClient.cs b/AyCode.Services/SignalRs/IAcSignalRHubClient.cs index 81ba062..0116745 100644 --- a/AyCode.Services/SignalRs/IAcSignalRHubClient.cs +++ b/AyCode.Services/SignalRs/IAcSignalRHubClient.cs @@ -280,6 +280,53 @@ public enum SignalResponseStatus : byte Success = 5 } +/// +/// Signal response message with binary serialized data. +/// Used when SerializerOptions.SerializerType == Binary for better performance. +/// +[MessagePackObject] +public sealed class SignalResponseBinaryMessage : ISignalResponseMessage +{ + [Key(0)] public int MessageTag { get; set; } + + [Key(1)] public SignalResponseStatus Status { get; set; } + + [Key(2)] public byte[]? ResponseData { get; set; } + + [IgnoreMember] + public string? ResponseDataJson => ResponseData != null ? Convert.ToBase64String(ResponseData) : null; + + public SignalResponseBinaryMessage() { } + + public SignalResponseBinaryMessage(int messageTag, SignalResponseStatus status) + { + Status = status; + MessageTag = messageTag; + } + + public SignalResponseBinaryMessage(int messageTag, SignalResponseStatus status, object? responseData, AcBinarySerializerOptions? options = null) + : this(messageTag, status) + { + if (responseData == null) + { + ResponseData = null; + return; + } + + // If responseData is already a byte array, use it directly + if (responseData is byte[] byteData) + { + ResponseData = byteData; + return; + } + + // Serialize to binary + ResponseData = options != null + ? responseData.ToBinary(options) + : responseData.ToBinary(); + } +} + public interface IAcSignalRHubClient : IAcSignalRHubBase { Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, int? requestId ); diff --git a/BenchmarkSuite1/SignalRCommunicationBenchmarks.cs b/BenchmarkSuite1/SignalRCommunicationBenchmarks.cs index 7b7b038..c9fb329 100644 --- a/BenchmarkSuite1/SignalRCommunicationBenchmarks.cs +++ b/BenchmarkSuite1/SignalRCommunicationBenchmarks.cs @@ -108,7 +108,7 @@ public class SignalRCommunicationBenchmarks [BenchmarkCategory("Server", "Deserialize")] public TestOrderItem Server_DeserializeComplexOrderItem() { - var postMessage = MessagePackSerializer.Deserialize(_complexOrderItemMessage, SignalRMessageFactory.StandardOptions); + var postMessage = MessagePackSerializer.Deserialize(_complexOrderItemMessage, SignalRMessageFactory.ContractlessOptions); return postMessage.PostDataJson!.JsonTo()!; } @@ -116,7 +116,7 @@ public class SignalRCommunicationBenchmarks [BenchmarkCategory("Server", "Deserialize")] public TestOrder Server_DeserializeComplexOrder() { - var postMessage = MessagePackSerializer.Deserialize(_complexOrderMessage, SignalRMessageFactory.StandardOptions); + var postMessage = MessagePackSerializer.Deserialize(_complexOrderMessage, SignalRMessageFactory.ContractlessOptions); return postMessage.PostDataJson!.JsonTo()!; } @@ -175,7 +175,7 @@ public class SignalRCommunicationBenchmarks // Client creates message var requestBytes = SignalRMessageFactory.CreateComplexObjectMessage(_data.TestOrder); // Server deserializes - var postMessage = MessagePackSerializer.Deserialize(requestBytes, SignalRMessageFactory.StandardOptions); + var postMessage = MessagePackSerializer.Deserialize(requestBytes, SignalRMessageFactory.ContractlessOptions); var order = postMessage.PostDataJson!.JsonTo()!; // Server modifies and creates response order.OrderNumber = "PROCESSED-" + order.OrderNumber;