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;