diff --git a/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs b/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs index d28015e..af811ee 100644 --- a/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs +++ b/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs @@ -1,7 +1,5 @@ using AyCode.Core.Extensions; using AyCode.Services.SignalRs; -using MessagePack; -using MessagePack.Resolvers; namespace AyCode.Services.Server.Tests.SignalRs; @@ -36,13 +34,9 @@ public enum SendTarget /// public static class SignalRTestHelper { - private static readonly MessagePackSerializerOptions MessagePackOptions = ContractlessStandardResolver.Options; - public static byte[] CreatePrimitiveParamsMessage(params object[] values) { - var idMessage = new IdMessage(values); - var postMessage = new SignalPostJsonDataMessage(idMessage); - return MessagePackSerializer.Serialize(postMessage, MessagePackOptions); + return SignalRSerializationHelper.SerializeParametersToBinary(values); } public static byte[] CreateSinglePrimitiveMessage(T value) where T : notnull @@ -50,22 +44,19 @@ public static class SignalRTestHelper public static byte[] CreateComplexObjectMessage(T obj) { - var json = obj.ToJson(); - var postMessage = new SignalPostJsonDataMessage(json); - return MessagePackSerializer.Serialize(postMessage, MessagePackOptions); + return SignalRSerializationHelper.SerializeParametersToBinary(new object[] { obj! }); } public static byte[] CreateEmptyMessage() { - var postMessage = new SignalPostJsonDataMessage(); - return MessagePackSerializer.Serialize(postMessage, MessagePackOptions); + return SignalRSerializationHelper.SerializeParametersToBinary(Array.Empty()); } public static T? GetResponseData(SentMessage sentMessage) { if (sentMessage.Message is SignalResponseDataMessage dataResponse && dataResponse.ResponseData != null) return dataResponse.GetResponseData(); - + return default; } @@ -73,10 +64,10 @@ public static class SignalRTestHelper { if (sentMessage.Message is not SignalResponseDataMessage response) throw new AssertFailedException($"Response is not SignalResponseDataMessage. Type: {sentMessage.Message.GetType().Name}"); - + if (response.Status != SignalResponseStatus.Success) throw new AssertFailedException($"Expected Success status but got {response.Status}"); - + if (sentMessage.MessageTag != expectedTag) throw new AssertFailedException($"Expected tag {expectedTag} but got {sentMessage.MessageTag}"); } @@ -85,10 +76,10 @@ public static class SignalRTestHelper { if (sentMessage.Message is not SignalResponseDataMessage response) throw new AssertFailedException($"Response is not SignalResponseDataMessage. Type: {sentMessage.Message.GetType().Name}"); - + if (response.Status != SignalResponseStatus.Error) throw new AssertFailedException($"Expected Error status but got {response.Status}"); - + if (sentMessage.MessageTag != expectedTag) throw new AssertFailedException($"Expected tag {expectedTag} but got {sentMessage.MessageTag}"); } diff --git a/AyCode.Services.Server/SignalRs/AcSignalRSendToClientService.cs b/AyCode.Services.Server/SignalRs/AcSignalRSendToClientService.cs index 69a37f7..4abe5a5 100644 --- a/AyCode.Services.Server/SignalRs/AcSignalRSendToClientService.cs +++ b/AyCode.Services.Server/SignalRs/AcSignalRSendToClientService.cs @@ -16,7 +16,11 @@ public abstract class AcSignalRSendToClientService(messageTag)}"); await sendTo.OnReceiveMessage(messageTag, null, receiveParams, responseData); diff --git a/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs b/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs index f58839b..493f6bd 100644 --- a/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs +++ b/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs @@ -421,7 +421,8 @@ public abstract class AcWebSignalRHubBase(IConfiguration } /// - /// Deserializes parameters from the message based on method signature. + /// Deserializes parameters from the binary message based on method signature. + /// The message is a binary-serialized object[] from the client. /// Supports optional parameters with default values. /// private static object[]? DeserializeParameters(byte[]? message, AcMethodInfoModel methodInfoModel, string tagName, string methodName) @@ -430,87 +431,15 @@ public abstract class AcWebSignalRHubBase(IConfiguration if (paramInfos is not { Length: > 0 }) return null; - // Handle case where message is null/empty but all parameters have default values if (message is null or { Length: 0 }) { - // Check if all parameters have default values if (paramInfos.All(p => p.HasDefaultValue)) - { - // Return default values for all parameters return paramInfos.Select(p => p.DefaultValue).ToArray(); - } - + throw new ArgumentException($"Message is null or empty but method '{methodName}' requires parameters; {tagName}"); } - var msgBase = SignalRSerializationHelper.DeserializeFromBinary(message); - if (string.IsNullOrEmpty(msgBase?.PostDataJson)) - { - // Check if all parameters have default values - if (paramInfos.All(p => p.HasDefaultValue)) - { - return paramInfos.Select(p => p.DefaultValue).ToArray(); - } - - throw new ArgumentException($"Failed to deserialize message for method '{methodName}'; {tagName}"); - } - - var json = msgBase.PostDataJson; - var paramValues = new object?[paramInfos.Length]; - - // IdMessage format: multiple parameters as JSON array - if (json.Contains("\"Ids\"")) - { - var idMessage = json.JsonTo(); - var providedCount = idMessage?.Ids?.Count ?? 0; - - for (var i = 0; i < paramInfos.Length; i++) - { - var param = paramInfos[i]; - - if (i < providedCount) - { - paramValues[i] = AcJsonDeserializer.Deserialize(idMessage!.Ids![i], param.ParameterType); - } - else if (param.HasDefaultValue) - { - paramValues[i] = param.DefaultValue; - } - else - { - throw new ArgumentException($"Missing required parameter '{param.Name}' for method '{methodName}'; {tagName}"); - } - } - } - else - { - // Single complex object format - paramValues[0] = json.JsonTo(paramInfos[0].ParameterType); - - // Fill remaining parameters with defaults - for (var i = 1; i < paramInfos.Length; i++) - { - var param = paramInfos[i]; - if (param.HasDefaultValue) - { - paramValues[i] = param.DefaultValue; - } - else - { - throw new ArgumentException($"Missing required parameter '{param.Name}' for method '{methodName}'; {tagName}"); - } - } - } - - return paramValues!; - } - - /// - /// 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 SignalRSerializationHelper.DeserializeParametersFromBinary(message, paramInfos); } #endregion @@ -547,7 +476,11 @@ public abstract class AcWebSignalRHubBase(IConfiguration protected virtual async Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, ISignalRMessage message, int? requestId = null) { var responseMessage = (SignalResponseDataMessage)message; - var receiveParams = new SignalReceiveParams { Status = responseMessage.Status }; + var receiveParams = new SignalReceiveParams + { + Status = responseMessage.Status, + DataSerializerType = responseMessage.DataSerializerType + }; var responseData = responseMessage.ResponseData ?? []; var tagName = ConstHelper.NameByValue(messageTag); diff --git a/AyCode.Services.Server/docs/SIGNALR_SERVER.md b/AyCode.Services.Server/docs/SIGNALR_SERVER.md index 30e6252..3d1e8db 100644 --- a/AyCode.Services.Server/docs/SIGNALR_SERVER.md +++ b/AyCode.Services.Server/docs/SIGNALR_SERVER.md @@ -10,14 +10,15 @@ Server-side SignalR hub infrastructure: method dispatch, session management, bro ``` 6. OnReceiveMessage(tag, requestId, receiveParams, data) 7. DynamicMethodRegistry.GetMethodByMessageTag(tag) ← ConcurrentDictionary lookup -8. DeserializeParameters(data): - ├─ DeserializeFromBinary() ← unwrap Binary envelope - ├─ IdMessage format? → parse each Ids[i] as JSON per parameter type - └─ Complex object? → json.JsonTo(paramType) ⚠️ tech debt: JSON parse +8. DeserializeParameters(data, methodInfoModel): + ├─ null/empty + all defaults? → default values array + └─ DeserializeParametersFromBinary(data, paramInfos) + Each param: [INT32 len][AcBinary bytes] → BinaryTo(targetType) + Type-guided: target type from ParameterInfo[] (method signature) 9. MethodInfo.InvokeMethod(instance, params) ← unwraps Task/ValueTask 10. CreateResponseMessage(tag, Success, result) ← Binary serialize payload → byte[] 11. SendMessageToClient(caller, tag, message, requestId): - ├─ Extract receiveParams { Status } + responseData byte[] from message + ├─ Extract receiveParams { Status, DataSerializerType } + responseData byte[] from message └─ caller.OnReceiveMessage(tag, requestId, receiveParams, responseData) (metadata + payload as separate args — no envelope serialization) 12. If SendToOtherClientType != None: diff --git a/AyCode.Services.Tests/SignalRs/PostJsonDataMessageTests.cs b/AyCode.Services.Tests/SignalRs/PostJsonDataMessageTests.cs index 46be20a..1b32396 100644 --- a/AyCode.Services.Tests/SignalRs/PostJsonDataMessageTests.cs +++ b/AyCode.Services.Tests/SignalRs/PostJsonDataMessageTests.cs @@ -1,5 +1,4 @@ -using AyCode.Core.Extensions; -using AyCode.Core.Serializers.Jsons; +using AyCode.Core.Extensions; using AyCode.Services.SignalRs; namespace AyCode.Services.Tests.SignalRs; @@ -8,99 +7,96 @@ namespace AyCode.Services.Tests.SignalRs; public class PostJsonDataMessageTests { [TestMethod] - public void Debug_CreatePostMessage_ForInt() + public void Debug_ParameterBinary_RoundTrip_ForInt() { - var message = CreatePostMessageTest(42); + var parameters = new object[] { 42 }; + Console.WriteLine($"Parameters: [{string.Join(", ", parameters)}]"); - Console.WriteLine($"Message type: {message.GetType().Name}"); - - if (message is SignalPostJsonDataMessage idMsg) - { - Console.WriteLine($"PostDataJson: {idMsg.PostDataJson}"); - Console.WriteLine($"PostData.Ids.Count: {idMsg.PostData.Ids.Count}"); - Console.WriteLine($"PostData.Ids[0]: {idMsg.PostData.Ids[0]}"); - } - - var bytes = message.ToBinary(); + var bytes = SignalRSerializationHelper.SerializeParametersToBinary(parameters); Console.WriteLine($"Binary bytes: {bytes.Length}"); - 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}"); + // Simulate server deserialization with known target types + var paramInfos = typeof(PostJsonDataMessageTests) + .GetMethod(nameof(DummyIntMethod))!.GetParameters(); + var deserialized = SignalRSerializationHelper.DeserializeParametersFromBinary(bytes, paramInfos); - Assert.IsNotNull(deserialized?.PostData); - Assert.AreEqual(1, deserialized.PostData.Ids.Count); + Assert.IsNotNull(deserialized); + Assert.AreEqual(1, deserialized.Length); + Assert.AreEqual(42, deserialized[0]); } [TestMethod] [DataRow(42)] [DataRow("45")] [DataRow(true)] - public void IdMessage_FullRoundTrip_AnyParameter(object testValue) + public void ParameterBinary_FullRoundTrip_AnyParameter(object testValue) { - dynamic GetValueByType(object value) - { - if (value is int valueInt) return valueInt; - if (value is bool valueBool) return valueBool; - if (value is string valueString) return valueString; - - Assert.Fail($"Type of testValue not implemented"); - return null; - } - - Console.WriteLine("=== Step 1: Client creates message ==="); - var idMessage = new IdMessage(GetValueByType(testValue)); - Console.WriteLine($"IdMessage.Ids[0]: '{idMessage.Ids[0]}'"); - - var clientMessage = new SignalPostJsonDataMessage(idMessage); - Console.WriteLine($"Client PostDataJson: '{clientMessage.PostDataJson}'"); + Console.WriteLine("=== Step 1: Client creates object[] ==="); + var parameters = new[] { testValue }; Console.WriteLine("\n=== Step 2: Binary serialization ==="); - var bytes = clientMessage.ToBinary(); + var bytes = SignalRSerializationHelper.SerializeParametersToBinary(parameters); Console.WriteLine($"Binary bytes: {bytes.Length}"); - Console.WriteLine("\n=== Step 3: Server deserializes ==="); - 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 3: Server deserializes with target type ==="); + var paramInfos = new[] { GetParamInfoForType(testValue.GetType()) }; + var serverParams = SignalRSerializationHelper.DeserializeParametersFromBinary(bytes, paramInfos); - Console.WriteLine("\n=== Step 4: Server deserializes parameter ==="); - var paramJson = serverMessage!.PostData.Ids[0]; - Console.WriteLine($"Parameter JSON: '{paramJson}'"); - var paramValue = AcJsonDeserializer.Deserialize(paramJson, testValue.GetType()); - Console.WriteLine($"Deserialized value: {paramValue}"); + Console.WriteLine($"Server params[0]: '{serverParams[0]}' (type: {serverParams[0]?.GetType().Name})"); - Console.WriteLine("\n=== Step 5: Service method returns ==="); - var serviceResult = $"{paramValue}"; + Console.WriteLine("\n=== Step 4: Service method uses parameter ==="); + var serviceResult = $"{serverParams![0]}"; Console.WriteLine($"Service result: '{serviceResult}'"); - 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.ResponseData?.Length ?? 0}"); + Console.WriteLine("\n=== Step 5: Server creates response ==="); + var response = new SignalResponseDataMessage(100, SignalResponseStatus.Success, serviceResult, AyCode.Core.Serializers.Binaries.AcBinarySerializerOptions.Default); - Console.WriteLine("\n=== Step 7: Response Binary ==="); + Console.WriteLine("\n=== Step 6: Response Binary ==="); var responseBytes = response.ToBinary(); - Console.WriteLine($"Response Binary bytes: {responseBytes.Length}"); - Console.WriteLine("\n=== Step 8: Client deserializes response ==="); + Console.WriteLine("\n=== Step 7: Client deserializes response ==="); var clientResponse = responseBytes.BinaryTo(); - Console.WriteLine($"Client Response Status: {clientResponse?.Status}"); - Console.WriteLine("\n=== Step 9: Client deserializes to string ==="); + Console.WriteLine("\n=== Step 8: Client deserializes to string ==="); var finalResult = clientResponse?.GetResponseData(); Console.WriteLine($"Final result: '{finalResult}'"); - Assert.AreEqual(GetValueByType(testValue).ToString(), finalResult); + Assert.AreEqual(testValue.ToString(), finalResult); } - private static ISignalRMessage CreatePostMessageTest(TPostData postData) + [TestMethod] + public void ParameterBinary_MultipleParams_RoundTrip() { - var type = typeof(TPostData); + var guid = Guid.NewGuid(); + var parameters = new object[] { 42, "hello", true, guid }; - if (type == typeof(string) || type.IsEnum || type.IsValueType || type == typeof(DateTime)) - return new SignalPostJsonDataMessage(new IdMessage(postData!)); + var bytes = SignalRSerializationHelper.SerializeParametersToBinary(parameters); - return new SignalPostJsonDataMessage(postData); - } -} \ No newline at end of file + var paramInfos = typeof(PostJsonDataMessageTests) + .GetMethod(nameof(DummyMultiMethod))!.GetParameters(); + var deserialized = SignalRSerializationHelper.DeserializeParametersFromBinary(bytes, paramInfos); + + Assert.IsNotNull(deserialized); + Assert.AreEqual(4, deserialized.Length); + Assert.AreEqual(42, deserialized[0]); + Assert.AreEqual("hello", deserialized[1]); + Assert.AreEqual(true, deserialized[2]); + Assert.AreEqual(guid, deserialized[3]); + } + + // Dummy methods for ParameterInfo extraction + public static void DummyIntMethod(int value) { } + public static void DummyMultiMethod(int a, string b, bool c, Guid d) { } + + private static System.Reflection.ParameterInfo GetParamInfoForType(Type type) + { + // Find a dummy method with the matching parameter type + if (type == typeof(int)) return typeof(PostJsonDataMessageTests).GetMethod(nameof(DummyInt))!.GetParameters()[0]; + if (type == typeof(string)) return typeof(PostJsonDataMessageTests).GetMethod(nameof(DummyString))!.GetParameters()[0]; + if (type == typeof(bool)) return typeof(PostJsonDataMessageTests).GetMethod(nameof(DummyBool))!.GetParameters()[0]; + throw new NotSupportedException($"No dummy method for type {type.Name}"); + } + + public static void DummyInt(int v) { } + public static void DummyString(string v) { } + public static void DummyBool(bool v) { } +} diff --git a/AyCode.Services/SignalRs/AcSignalRClientBase.cs b/AyCode.Services/SignalRs/AcSignalRClientBase.cs index bfeb4d5..d35065c 100644 --- a/AyCode.Services/SignalRs/AcSignalRClientBase.cs +++ b/AyCode.Services/SignalRs/AcSignalRClientBase.cs @@ -134,15 +134,15 @@ namespace AyCode.Services.SignalRs } public virtual Task SendMessageToServerAsync(int messageTag) - => SendMessageToServerAsync(messageTag, null, GetNextRequestId()); + => SendMessageToServerAsync(messageTag, (object[]?)null, GetNextRequestId()); - public virtual async Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, int? requestId) + public virtual async Task SendMessageToServerAsync(int messageTag, object[]? parameters, int? requestId) { Logger.DebugConditional($"Client SendMessageToServerAsync sending; {nameof(requestId)}: {requestId}; ConnectionState: {GetConnectionState()}; {ConstHelper.NameByValue(TagsName, messageTag)}"); await StartConnection(); - var msgBytes = message != null ? SignalRSerializationHelper.SerializeToBinary(message) : null; + var msgBytes = parameters is { Length: > 0 } ? SignalRSerializationHelper.SerializeParametersToBinary(parameters) : null; if (!IsConnected()) { @@ -157,10 +157,10 @@ namespace AyCode.Services.SignalRs #region CRUD public virtual Task PostAsync(int messageTag, object parameter) - => SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage(new IdMessage(parameter)), GetNextRequestId()); + => SendMessageToServerAsync(messageTag, [parameter], GetNextRequestId()); public virtual Task PostAsync(int messageTag, object[] parameters) - => SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage(new IdMessage(parameters)), GetNextRequestId()); + => SendMessageToServerAsync(messageTag, parameters, GetNextRequestId()); public virtual Task GetByIdAsync(int messageTag, object id) => PostAsync(messageTag, id); @@ -172,19 +172,19 @@ namespace AyCode.Services.SignalRs /// 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); + => SendMessageToServerAsync(messageTag, [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); + => SendMessageToServerAsync(messageTag, 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()); + => SendMessageToServerAsync(messageTag, contextParams is { Length: > 0 } ? contextParams : null, GetNextRequestId()); /// /// Gets all data with async callback response. Callback is second parameter. @@ -196,7 +196,7 @@ namespace AyCode.Services.SignalRs /// 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); + => SendMessageToServerAsync(messageTag, contextParams is { Length: > 0 } ? contextParams : null, responseCallback); public virtual async IAsyncEnumerable StreamAllAsync(int messageTag, object[]? contextParams = null, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { @@ -208,8 +208,7 @@ namespace AyCode.Services.SignalRs yield break; } - var message = contextParams == null || contextParams.Length == 0 ? null : new SignalPostJsonDataMessage(new IdMessage(contextParams)); - var msgBytes = message != null ? SignalRSerializationHelper.SerializeToBinary(message) : null; + var msgBytes = contextParams is { Length: > 0 } ? SignalRSerializationHelper.SerializeParametersToBinary(contextParams) : null; var stream = HubConnection.StreamAsync( "OnReceiveStreamMessage", @@ -242,22 +241,22 @@ namespace AyCode.Services.SignalRs } public virtual Task PostDataAsync(int messageTag, TPostData postData) where TPostData : class - => SendMessageToServerAsync(messageTag, CreatePostMessage(postData), GetNextRequestId()); + => SendMessageToServerAsync(messageTag, [postData!], GetNextRequestId()); public virtual Task PostDataAsync(int messageTag, TPostData postData) - => SendMessageToServerAsync(messageTag, CreatePostMessage(postData), GetNextRequestId()); + => SendMessageToServerAsync(messageTag, [postData!], GetNextRequestId()); /// /// Posts data with async callback response. /// public virtual Task PostDataAsync(int messageTag, TPostData postData, Func responseCallback) - => SendMessageToServerAsync(messageTag, CreatePostMessage(postData), responseCallback); + => SendMessageToServerAsync(messageTag, [postData!], responseCallback); /// /// Posts data with typed async callback response. /// public virtual Task PostDataAsync(int messageTag, TPostData postData, Func responseCallback) - => SendMessageToServerAsync(messageTag, CreatePostMessage(postData), responseCallback); + => SendMessageToServerAsync(messageTag, [postData!], responseCallback); /// /// Posts data and invokes callback with response. Fire-and-forget friendly for background saves. @@ -268,7 +267,7 @@ namespace AyCode.Services.SignalRs var requestModel = SignalRRequestModelPool.Get(responseCallback); _responseByRequestId[requestId] = requestModel; - return SendMessageToServerAsync(messageTag, CreatePostMessage(postData), requestId); + return SendMessageToServerAsync(messageTag, [postData!], requestId); } public virtual async IAsyncEnumerable StreamPostDataAsync(int messageTag, TPostData postData, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) @@ -281,8 +280,7 @@ namespace AyCode.Services.SignalRs yield break; } - var message = CreatePostMessage(postData); - var msgBytes = SignalRSerializationHelper.SerializeToBinary(message); + var msgBytes = SignalRSerializationHelper.SerializeParametersToBinary(new object[] { postData! }); var stream = HubConnection.StreamAsync( "OnReceiveStreamMessage", @@ -314,14 +312,6 @@ namespace AyCode.Services.SignalRs } } - private static ISignalRMessage CreatePostMessage(TPostData postData) - { - var type = typeof(TPostData); - if (type == typeof(string) || type.IsEnum || type.IsValueType) - return new SignalPostJsonDataMessage(new IdMessage(postData!)); - return new SignalPostJsonDataMessage(postData); - } - public Task GetAllIntoAsync(List intoList, int messageTag, object[]? contextParams = null, Action? callback = null) where TResponseItem : IEntityGuid { return GetAllAsync>(messageTag, contextParams).ContinueWith(task => @@ -341,24 +331,24 @@ namespace AyCode.Services.SignalRs #endregion public virtual Task SendMessageToServerAsync(int messageTag) - => SendMessageToServerAsync(messageTag, null, GetNextRequestId()); + => SendMessageToServerAsync(messageTag, (object[]?)null, GetNextRequestId()); - public virtual Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message) - => SendMessageToServerAsync(messageTag, message, GetNextRequestId()); + public virtual Task SendMessageToServerAsync(int messageTag, object[]? parameters) + => SendMessageToServerAsync(messageTag, parameters, GetNextRequestId()); /// /// Sends message to server with async callback response. /// - public virtual async Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, Func responseCallback) + public virtual async Task SendMessageToServerAsync(int messageTag, object[]? parameters, Func responseCallback) { var requestId = GetNextRequestId(); var requestModel = SignalRRequestModelPool.Get(responseCallback); _responseByRequestId[requestId] = requestModel; - await SendMessageToServerAsync(messageTag, message, requestId); + await SendMessageToServerAsync(messageTag, parameters, requestId); } - protected virtual async Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, int requestId) + protected virtual async Task SendMessageToServerAsync(int messageTag, object[]? parameters, int requestId) { Logger.DebugConditional($"Client SendMessageToServerAsync; {nameof(requestId)}: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}"); @@ -366,7 +356,7 @@ namespace AyCode.Services.SignalRs var requestModel = SignalRRequestModelPool.Get(); _responseByRequestId[requestId] = requestModel; - await SendMessageToServerAsync(messageTag, message, requestId); + await SendMessageToServerAsync(messageTag, parameters, requestId); try { @@ -441,7 +431,7 @@ namespace AyCode.Services.SignalRs var responseMessage = new SignalResponseDataMessage { Status = receiveParams.Status, - DataSerializerType = AcSerializerType.Binary, + DataSerializerType = receiveParams.DataSerializerType, ResponseData = data }; diff --git a/AyCode.Services/SignalRs/IAcSignalRHubClient.cs b/AyCode.Services/SignalRs/IAcSignalRHubClient.cs index bd84283..97560a8 100644 --- a/AyCode.Services/SignalRs/IAcSignalRHubClient.cs +++ b/AyCode.Services/SignalRs/IAcSignalRHubClient.cs @@ -13,6 +13,7 @@ namespace AyCode.Services.SignalRs; /// Message container for serialized parameter IDs. /// Optimized for common primitive types to avoid full JSON overhead. /// +[Obsolete("Use direct object[] binary serialization instead of IdMessage JSON wrapper.")] public class IdMessage { public List Ids { get; private set; } @@ -61,6 +62,7 @@ public class IdMessage /// /// Message containing JSON-serialized post data. /// +[Obsolete("Use direct object[] binary serialization instead of JSON-in-Binary wrapper.")] public class SignalPostJsonMessage { public string PostDataJson { get; set; } = ""; @@ -72,6 +74,7 @@ public class SignalPostJsonMessage /// /// Generic message containing JSON-serialized post data with typed access. /// +[Obsolete("Use direct object[] binary serialization instead of JSON-in-Binary wrapper.")] public class SignalPostJsonDataMessage : SignalPostJsonMessage, ISignalPostMessage { [JsonIgnore] @@ -98,11 +101,13 @@ public class SignalPostJsonDataMessage : SignalPostJsonMessage, I /// /// Simple message containing post data. /// +[Obsolete("Use direct object[] binary serialization instead of message wrappers.")] public class SignalPostMessage(TPostData postData) : ISignalPostMessage { public TPostData? PostData { get; set; } = postData; } +[Obsolete("Use direct object[] binary serialization instead of message wrappers.")] public interface ISignalPostMessage : ISignalRMessage { TPostData? PostData { get; } @@ -436,5 +441,5 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa public interface IAcSignalRHubClient : IAcSignalRHubBase { - Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, int? requestId); + Task SendMessageToServerAsync(int messageTag, object[]? parameters, int? requestId); } \ No newline at end of file diff --git a/AyCode.Services/SignalRs/ISignalParams.cs b/AyCode.Services/SignalRs/ISignalParams.cs index 7379270..ad3793f 100644 --- a/AyCode.Services/SignalRs/ISignalParams.cs +++ b/AyCode.Services/SignalRs/ISignalParams.cs @@ -1,3 +1,4 @@ +using AyCode.Core.Serializers; using AyCode.Core.Serializers.Attributes; namespace AyCode.Services.SignalRs; @@ -16,4 +17,5 @@ public interface ISignalParams { } public class SignalReceiveParams : ISignalParams { public SignalResponseStatus Status { get; set; } + public AcSerializerType DataSerializerType { get; set; } } diff --git a/AyCode.Services/SignalRs/SignalRSerializationHelper.cs b/AyCode.Services/SignalRs/SignalRSerializationHelper.cs index 5c1bd45..a177572 100644 --- a/AyCode.Services/SignalRs/SignalRSerializationHelper.cs +++ b/AyCode.Services/SignalRs/SignalRSerializationHelper.cs @@ -24,6 +24,7 @@ public static class SignalRSerializationHelper /// Serialize a primitive value to JSON string with minimal overhead. /// Falls back to full JSON serialization for complex types. /// + [Obsolete("Use direct object[] binary serialization instead of JSON-encoded primitives.")] [MethodImpl(MethodImplOptions.AggressiveInlining)] public static string SerializePrimitiveToJson(object value) { @@ -42,6 +43,7 @@ public static class SignalRSerializationHelper /// /// Serialize a Guid to JSON string with pre-allocated buffer. /// + [Obsolete("Use direct object[] binary serialization instead of JSON-encoded Guids.")] [MethodImpl(MethodImplOptions.AggressiveInlining)] public static string SerializeGuidToJson(Guid g) { @@ -96,6 +98,103 @@ public static class SignalRSerializationHelper return data.BinaryTo(); } + /// + /// Serialize method parameters as individually length-prefixed binary segments. + /// Format: [VarUInt count] [for each: INT32 length + AcBinary bytes] + /// This allows the server to deserialize each parameter with its known target type. + /// + public static byte[] SerializeParametersToBinary(object[] parameters, AcBinarySerializerOptions? options = null) + { + var opts = options ?? AcBinarySerializerOptions.Default; + var writer = new ArrayBufferWriter(256); + + // Write parameter count as VarUInt + WriteVarUInt(writer, (uint)parameters.Length); + + // Write each parameter with INT32 length prefix + for (var i = 0; i < parameters.Length; i++) + { + var paramWriter = new ArrayBufferWriter(128); + parameters[i].ToBinary(paramWriter, opts); + + // Write length prefix + var lenSpan = writer.GetSpan(4); + System.Runtime.CompilerServices.Unsafe.WriteUnaligned(ref lenSpan[0], paramWriter.WrittenCount); + writer.Advance(4); + + // Write parameter bytes + var paramSpan = writer.GetSpan(paramWriter.WrittenCount); + paramWriter.WrittenSpan.CopyTo(paramSpan); + writer.Advance(paramWriter.WrittenCount); + } + + return writer.WrittenSpan.ToArray(); + } + + /// + /// Deserialize method parameters from length-prefixed binary format, using target types. + /// + public static object?[] DeserializeParametersFromBinary(byte[] data, System.Reflection.ParameterInfo[] paramInfos) + { + var span = data.AsSpan(); + var pos = 0; + + // Read parameter count + var count = (int)ReadVarUInt(span, ref pos); + var result = new object?[paramInfos.Length]; + + for (var i = 0; i < count && i < paramInfos.Length; i++) + { + // Read length prefix + var len = System.Runtime.CompilerServices.Unsafe.ReadUnaligned(ref System.Runtime.InteropServices.MemoryMarshal.GetReference(span.Slice(pos))); + pos += 4; + + if (len > 0) + { + var paramBytes = span.Slice(pos, len).ToArray(); + result[i] = paramBytes.BinaryTo(paramInfos[i].ParameterType); + pos += len; + } + } + + // Fill remaining with defaults + for (var i = count; i < paramInfos.Length; i++) + { + if (paramInfos[i].HasDefaultValue) + result[i] = paramInfos[i].DefaultValue; + } + + return result; + } + + private static void WriteVarUInt(ArrayBufferWriter writer, uint value) + { + while (value >= 0x80) + { + var span = writer.GetSpan(1); + span[0] = (byte)(value | 0x80); + writer.Advance(1); + value >>= 7; + } + var lastSpan = writer.GetSpan(1); + lastSpan[0] = (byte)value; + writer.Advance(1); + } + + private static uint ReadVarUInt(ReadOnlySpan span, ref int pos) + { + uint value = 0; + var shift = 0; + while (true) + { + var b = span[pos++]; + value |= (uint)(b & 0x7F) << shift; + if ((b & 0x80) == 0) + return value; + shift += 7; + } + } + #endregion #region JSON Serialization with Brotli diff --git a/AyCode.Services/docs/SIGNALR.md b/AyCode.Services/docs/SIGNALR.md index 5e4775d..367df60 100644 --- a/AyCode.Services/docs/SIGNALR.md +++ b/AyCode.Services/docs/SIGNALR.md @@ -58,9 +58,10 @@ public abstract class MyProjectTags : AcSignalRTags **Usage:** ```csharp -var orders = await client.PostAsync>(MyTags.GetOrders, companyId); +var orders = await client.PostAsync>(MyTags.GetOrders, companyId); // single param +var result = await client.PostAsync(MyTags.Query, [companyId, filter]); // object[] params await client.PostDataAsync(MyTags.SaveOrder, order); -await client.PostDataAsync(MyTags.SaveOrder, order, async response => { ... }); // async callback +await client.PostDataAsync(MyTags.SaveOrder, order, async response => { ... }); // async callback ``` CRUD helpers (`PostAsync`, `GetByIdAsync`, `GetAllAsync`, `PostDataAsync`) are generic transport, not tied to DataSource. @@ -80,26 +81,24 @@ Custom `IHubProtocol` (`"acbinary"`), replaces JSON. Zero-copy via `BufferWriter | Field | Type | Purpose | |-------|------|---------| | `Status` | SignalResponseStatus | Success/Error | +| `DataSerializerType` | AcSerializerType | Binary or JsonGZip — tells client how to deserialize response data | `byte[] data` (separate hub argument, protocol fast-path, zero-copy). -`SignalResponseDataMessage` remains as **internal DTO** for callback routing — constructed in-memory from `receiveParams` + `data`, never serialized as envelope on wire. - -Binary (default): `AcBinarySerializer.ToBinary(data)`. JSON fallback: `ToJson` → `GzipHelper.Compress`. +`SignalResponseDataMessage` remains as **internal DTO** for callback routing — constructed in-memory from `receiveParams` + `data`, never serialized as envelope on wire. `GetResponseData()` dispatches on `DataSerializerType`: Binary → `BinaryTo()`, JsonGZip → decompress → `JsonTo()`. ## Request/Response Flow ### Client → Server ``` -1. PostAsync(tag, postData) / PostDataAsync(tag, data, callback) -2. CreatePostMessage(postData): - ├─ Primitives/strings/enums/value types → IdMessage - └─ Complex → SignalPostJsonDataMessage ⚠️ JSON-in-Binary tech debt -3. SerializeToBinary(message) -4. SignalReceiveParams { Status = Success } -5. HubConnection.SendAsync("OnReceiveMessage", tag, requestId, receiveParams, bytes) -6. AcBinaryHubProtocol frames on wire (byte[] via fast-path, receiveParams via AcBinary) +1. PostAsync(tag, param) / PostAsync(tag, params[]) / PostDataAsync(tag, data, callback) +2. SerializeParametersToBinary(object[] parameters): + [VarUInt count] [for each: INT32 length + AcBinary bytes] + Each parameter individually binary-serialized with length prefix. +3. SignalReceiveParams { Status = Success } +4. HubConnection.SendAsync("OnReceiveMessage", tag, requestId, receiveParams, bytes) +5. AcBinaryHubProtocol frames on wire (byte[] via fast-path, receiveParams via AcBinary) ``` ### Server → Client @@ -107,10 +106,11 @@ Binary (default): `AcBinarySerializer.ToBinary(data)`. JSON fallback: `ToJson` ``` OnReceiveMessage(tag, requestId, receiveParams, data) ├─ Construct SignalResponseDataMessage in-memory (no envelope deser): -│ └─ { Status = receiveParams.Status, DataSerializerType = Binary, ResponseData = data } +│ └─ { Status, DataSerializerType, ResponseData } from receiveParams + data ├─ Matching requestId in pending dict: │ ├─ Route: null→sync wait, Action→invoke, Func→await -│ └─ GetResponseData(): Binary→BinaryTo(), JSON→Decompress→Deserialize +│ └─ GetResponseData(): dispatches on DataSerializerType +│ Binary→BinaryTo(), JsonGZip→Decompress→JsonTo() └─ No match (broadcast): └─ abstract MessageReceived(tag, receiveParams, data).Forget() ``` @@ -136,9 +136,20 @@ Request pooling: `SignalRRequestModel` via `SignalRRequestModelPool` (ObjectPool `AcSignalRClientBase.EnableBinaryDiagnostics = true` — hex dump, header parsing, property enumeration. -## Tech Debt +## Parameter Serialization -**JSON-in-Binary:** client→server wraps params in JSON inside binary envelope (`SignalPostJsonDataMessage`). Do NOT fix as side effect — requires coordinated cross-project changes. +Client→server parameters use length-prefixed per-parameter binary format via `SignalRSerializationHelper`: + +``` +SerializeParametersToBinary(object[]): + [VarUInt count] [for each: INT32 length + AcBinary bytes] + +DeserializeParametersFromBinary(byte[], ParameterInfo[]): + Server reads each segment and deserializes with known target type from method signature. + Trailing parameters with defaults are auto-filled. +``` + +This enables type-guided deserialization — each parameter is individually serialized/deserialized with its concrete type, avoiding the `object[]` → dictionary problem of untyped binary deserialization. ## Source Files diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 492fbeb..0a991e2 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -70,14 +70,15 @@ For full architecture see `AyCode.Services/docs/SIGNALR.md`. | Term | Definition | |---|---| -| **OnReceiveMessage** | The single SignalR method for all communication. Signature: `(int messageTag, int? requestId, SignalReceiveParams receiveParams, byte[] data)`. Metadata and payload are separate hub arguments — `byte[]` uses protocol zero-copy fast-path. | -| **SignalReceiveParams** | Lightweight metadata sent alongside message payload as separate hub argument. Contains `Status` (SignalResponseStatus). Implements `ISignalParams`. AcBinary serialized. | +| **OnReceiveMessage** | The single SignalR method for all communication. Signature: `(int messageTag, int? requestId, SignalReceiveParams receiveParams, byte[] data)`. Metadata and payload are separate hub arguments — `byte[]` uses protocol zero-copy fast-path. Client→server: `data` is length-prefixed parameter binary. Server→client: `data` is response payload (format indicated by `receiveParams.DataSerializerType`). | +| **SignalReceiveParams** | Lightweight metadata sent alongside message payload as separate hub argument. Contains `Status` (SignalResponseStatus) and `DataSerializerType` (AcSerializerType — Binary or JsonGZip, tells client how to deserialize response data). Implements `ISignalParams`. AcBinary serialized. | | **Message Tag** | Integer identifier mapping to a method via `[SignalR(tag)]` or `[SignalRSendToClient(tag)]` attributes. | | **DynamicMethodRegistry** | Resolves message tags to `MethodInfo` at runtime. Static `ConcurrentDictionary` cache with lazy scan on miss. | | **SignalRCrudTags** | Sealed class bundling 5 independent tag integers (getAllTag, getItemTag, addTag, updateTag, removeTag) for entity CRUD. See `AyCode.Services.Server/docs/SIGNALR_DATASOURCE.md`. | | **AcBinaryHubProtocol** | Custom `IHubProtocol` replacing SignalR's JSON+Base64 with `AcBinarySerializer`. Protocol name: `"acbinary"`. Uses `BufferWriterBinaryOutput` for zero-copy writes to the SignalR pipe. | | **SignalResponseDataMessage** | Internal DTO for callback routing (not serialized on wire). Constructed in-memory from `receiveParams` + `data`. Supports Binary or JSON+GZip via `GetResponseData()`. | -| **SignalPostJsonDataMessage** | ⚠️ TECH DEBT — request params serialized to JSON inside Binary envelope. Planned for pure Binary replacement. | +| **SignalPostJsonDataMessage** | ⚠️ OBSOLETE — replaced by `SerializeParametersToBinary(object[])`. Legacy: serialized params to JSON inside Binary envelope. | +| **SerializeParametersToBinary** | Length-prefixed per-parameter binary format: `[VarUInt count][INT32 len + AcBinary bytes per param]`. Server deserializes each with target type from method signature via `DeserializeParametersFromBinary`. In `SignalRSerializationHelper`. | | **AcSignalRDataSource** | Generic real-time `IList` with change tracking, CRUD via SignalRCrudTags, binary merge, rollback, sync state. | | **TrackingItem** | Wraps a modified DataSource item with `TrackingState` (Add/Update/Remove) + `OriginalValue` for rollback. | | **SendToClientType** | Enum controlling broadcast scope: None, Others, Caller, All. |