diff --git a/AyCode.Benchmark/SignalRRoundTripBenchmarks.cs b/AyCode.Benchmark/SignalRRoundTripBenchmarks.cs index 15417bf..7873bb1 100644 --- a/AyCode.Benchmark/SignalRRoundTripBenchmarks.cs +++ b/AyCode.Benchmark/SignalRRoundTripBenchmarks.cs @@ -212,16 +212,16 @@ public class BenchmarkSignalRClient : AcSignalRClientBase, IAcSignalRHubItemServ public TResponse? GetAllSync(int tag) => GetAllAsync(tag).GetAwaiter().GetResult(); - protected override Task MessageReceived(int messageTag, SignalReceiveParams receiveParams, byte[] data) => Task.CompletedTask; + protected override Task MessageReceived(int messageTag, SignalParams signalParams, byte[] data) => Task.CompletedTask; protected override HubConnectionState GetConnectionState() => HubConnectionState.Connected; protected override bool IsConnected() => true; protected override Task StartConnectionInternal() => Task.CompletedTask; protected override Task StopConnectionInternal() => Task.CompletedTask; protected override ValueTask DisposeConnectionInternal() => ValueTask.CompletedTask; - protected override async Task SendToHubAsync(int messageTag, int? requestId, SignalReceiveParams receiveParams, byte[]? messageBytes) + protected override async Task SendToHubAsync(int messageTag, int? requestId, SignalParams signalParams, byte[]? messageBytes) { - await _hub.OnReceiveMessage(messageTag, requestId, receiveParams, messageBytes ?? []); + await _hub.OnReceiveMessage(messageTag, requestId, signalParams, messageBytes ?? []); } } diff --git a/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs b/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs index af811ee..8df7ad9 100644 --- a/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs +++ b/AyCode.Services.Server.Tests/SignalRs/SignalRTestHelper.cs @@ -34,22 +34,12 @@ public enum SendTarget /// public static class SignalRTestHelper { - public static byte[] CreatePrimitiveParamsMessage(params object[] values) + public static SignalParams CreateSignalParams(params object[] values) { - return SignalRSerializationHelper.SerializeParametersToBinary(values); - } - - public static byte[] CreateSinglePrimitiveMessage(T value) where T : notnull - => CreatePrimitiveParamsMessage(value); - - public static byte[] CreateComplexObjectMessage(T obj) - { - return SignalRSerializationHelper.SerializeParametersToBinary(new object[] { obj! }); - } - - public static byte[] CreateEmptyMessage() - { - return SignalRSerializationHelper.SerializeParametersToBinary(Array.Empty()); + var signalParams = new SignalParams { Status = SignalResponseStatus.Success }; + if (values.Length > 0) + signalParams.SetParameterValues(values); + return signalParams; } public static T? GetResponseData(SentMessage sentMessage) diff --git a/AyCode.Services.Server.Tests/SignalRs/TestableSignalRClient2.cs b/AyCode.Services.Server.Tests/SignalRs/TestableSignalRClient2.cs index 946815a..3a4cfb3 100644 --- a/AyCode.Services.Server.Tests/SignalRs/TestableSignalRClient2.cs +++ b/AyCode.Services.Server.Tests/SignalRs/TestableSignalRClient2.cs @@ -29,7 +29,7 @@ public class TestableSignalRClient2 : AcSignalRClientBase, IAcSignalRHubItemServ #region Override virtual methods for testing - protected override async Task MessageReceived(int messageTag, SignalReceiveParams receiveParams, byte[] data) + protected override async Task MessageReceived(int messageTag, SignalParams signalParams, byte[] data) { throw new NotImplementedException(); } @@ -52,9 +52,9 @@ public class TestableSignalRClient2 : AcSignalRClientBase, IAcSignalRHubItemServ protected override ValueTask DisposeConnectionInternal() => ValueTask.CompletedTask; - protected override async Task SendToHubAsync(int messageTag, int? requestId, SignalReceiveParams receiveParams, byte[]? messageBytes) + protected override async Task SendToHubAsync(int messageTag, int? requestId, SignalParams signalParams, byte[]? messageBytes) { - await _signalRHub.OnReceiveMessage(messageTag, requestId, receiveParams, messageBytes ?? []); + await _signalRHub.OnReceiveMessage(messageTag, requestId, signalParams, messageBytes ?? []); } #endregion diff --git a/AyCode.Services.Server/SignalRs/AcSignalRSendToClientService.cs b/AyCode.Services.Server/SignalRs/AcSignalRSendToClientService.cs index 4abe5a5..c6f1ff6 100644 --- a/AyCode.Services.Server/SignalRs/AcSignalRSendToClientService.cs +++ b/AyCode.Services.Server/SignalRs/AcSignalRSendToClientService.cs @@ -16,14 +16,14 @@ public abstract class AcSignalRSendToClientService(messageTag)}"); - await sendTo.OnReceiveMessage(messageTag, null, receiveParams, responseData); + await sendTo.OnReceiveMessage(messageTag, null, signalParams, responseData); } public virtual Task SendMessageToAllClients(int messageTag, object? content) diff --git a/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs b/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs index 493f6bd..ba03323 100644 --- a/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs +++ b/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs @@ -64,27 +64,35 @@ public abstract class AcWebSignalRHubBase(IConfiguration #region Message Processing - public virtual Task OnReceiveMessage(int messageTag, int? requestId, SignalReceiveParams receiveParams, byte[] data) + public virtual Task OnReceiveMessage(int messageTag, int? requestId, SignalParams signalParams, byte[] data) { - return ProcessOnReceiveMessage(messageTag, data, requestId, null); + return ProcessOnReceiveMessage(messageTag, signalParams, requestId, null); } public virtual IAsyncEnumerable OnReceiveStreamMessage(int messageTag, byte[]? messageBytes) { - return ProcessOnStreamMessage(messageTag, messageBytes, Context.ConnectionAborted); + var parameterBytes = messageBytes is { Length: > 0 } + ? SignalRSerializationHelper.DeserializeFromBinary(messageBytes) + : null; + return ProcessOnStreamMessage(messageTag, parameterBytes, Context.ConnectionAborted); } - protected virtual async IAsyncEnumerable ProcessOnStreamMessage(int messageTag, byte[]? message, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + protected virtual async IAsyncEnumerable ProcessOnStreamMessage(int messageTag, byte[][]? parameterBytes, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { var tagName = ConstHelper.NameByValue(messageTag); - - Logger.Debug($"[{message?.Length ?? 0:N0}b] Server OnReceiveStreamMessage; ConnectionId: {GetConnectionId()}; {tagName}"); + + Logger.Debug($"Server OnReceiveStreamMessage; ConnectionId: {GetConnectionId()}; {tagName}"); try { if (AcDomain.IsDeveloperVersion) LogContextUserNameAndId(); - if (TryFindAndInvokeMethod(messageTag, message, tagName, out var responseData)) + // Build SignalParams from raw byte[][] for stream path + var signalParams = new SignalParams { Status = SignalResponseStatus.Success }; + if (parameterBytes is { Length: > 0 }) + signalParams.Parameters = parameterBytes.ToBinary(); + + if (TryFindAndInvokeMethod(messageTag, signalParams, tagName, out var responseData)) { if (responseData == null) yield break; @@ -165,24 +173,17 @@ public abstract class AcWebSignalRHubBase(IConfiguration return null; } - protected virtual async Task ProcessOnReceiveMessage(int messageTag, byte[]? message, int? requestId, Func? notFoundCallback) + protected virtual async Task ProcessOnReceiveMessage(int messageTag, SignalParams signalParams, int? requestId, Func? notFoundCallback) { var tagName = ConstHelper.NameByValue(messageTag); - if (message is { Length: 0 }) - { - Logger.Warning($"message.Length == 0! Server OnReceiveMessage; requestId: {requestId}; ConnectionId: {GetConnectionId()}; {tagName}"); - } - else - { - Logger.Debug($"[{message?.Length:N0}b] Server OnReceiveMessage; requestId: {requestId}; ConnectionId: {GetConnectionId()}; {tagName}"); - } + Logger.Debug($"Server OnReceiveMessage; requestId: {requestId}; ConnectionId: {GetConnectionId()}; {tagName}"); try { if (AcDomain.IsDeveloperVersion) LogContextUserNameAndId(); - if (TryFindAndInvokeMethod(messageTag, message, tagName, out var responseData)) + if (TryFindAndInvokeMethod(messageTag, signalParams, tagName, out var responseData)) { var responseMessage = CreateResponseMessage(messageTag, SignalResponseStatus.Success, responseData); @@ -391,7 +392,7 @@ public abstract class AcWebSignalRHubBase(IConfiguration /// /// Finds and invokes the method registered for the given message tag. /// - private bool TryFindAndInvokeMethod(int messageTag, byte[]? message, string tagName, out object? responseData) + private bool TryFindAndInvokeMethod(int messageTag, SignalParams signalParams, string tagName, out object? responseData) { responseData = null; @@ -401,7 +402,7 @@ public abstract class AcWebSignalRHubBase(IConfiguration var (instance, methodInfoModel) = result.Value; var methodName = $"{instance.GetType().Name}.{methodInfoModel.MethodInfo.Name}"; - var paramValues = DeserializeParameters(message, methodInfoModel, tagName, methodName); + var paramValues = DeserializeParameters(signalParams, methodInfoModel, tagName, methodName); Logger.Debug(paramValues == null ? $"Found dynamic method for the tag! method: {methodName}(); {tagName}" @@ -421,25 +422,33 @@ public abstract class AcWebSignalRHubBase(IConfiguration } /// - /// 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. + /// Deserializes parameters from SignalParams using GetParameterValues. + /// Validates that required parameters are present. /// - private static object[]? DeserializeParameters(byte[]? message, AcMethodInfoModel methodInfoModel, string tagName, string methodName) + private static object[]? DeserializeParameters(SignalParams signalParams, AcMethodInfoModel methodInfoModel, string tagName, string methodName) { var paramInfos = methodInfoModel.ParamInfos; if (paramInfos is not { Length: > 0 }) return null; - if (message is null or { Length: 0 }) + var paramValues = signalParams.GetParameterValues(paramInfos); + + if (paramValues is null) { if (paramInfos.All(p => p.HasDefaultValue)) return paramInfos.Select(p => p.DefaultValue).ToArray(); - throw new ArgumentException($"Message is null or empty but method '{methodName}' requires parameters; {tagName}"); + throw new ArgumentException($"Message has no parameters but method '{methodName}' requires parameters; {tagName}"); } - return SignalRSerializationHelper.DeserializeParametersFromBinary(message, paramInfos); + // Validate: null in a non-optional parameter slot means it was missing + for (var i = 0; i < paramInfos.Length; i++) + { + if (paramValues[i] is null && !paramInfos[i].HasDefaultValue && paramInfos[i].ParameterType.IsValueType) + throw new ArgumentException($"Method '{methodName}' requires parameter '{paramInfos[i].Name}' (index {i}) but it was not sent; {tagName}"); + } + + return paramValues; } #endregion @@ -476,7 +485,7 @@ 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 + var signalParams = new SignalParams { Status = responseMessage.Status, DataSerializerType = responseMessage.DataSerializerType @@ -487,7 +496,7 @@ public abstract class AcWebSignalRHubBase(IConfiguration Logger.Debug($"[{responseData.Length / 1024}kb] Server sending message to client; requestId: {requestId}; Aborted: {IsConnectionAborted()}; ConnectionId: {GetConnectionId()}; {tagName}"); - await sendTo.OnReceiveMessage(messageTag, requestId, receiveParams, responseData); + await sendTo.OnReceiveMessage(messageTag, requestId, signalParams, responseData); Logger.Debug($"Server sent message to client; requestId: {requestId}; ConnectionId: {GetConnectionId()}; {tagName}"); } diff --git a/AyCode.Services.Server/docs/SIGNALR_SERVER.md b/AyCode.Services.Server/docs/SIGNALR_SERVER.md index 3d1e8db..0239e0a 100644 --- a/AyCode.Services.Server/docs/SIGNALR_SERVER.md +++ b/AyCode.Services.Server/docs/SIGNALR_SERVER.md @@ -8,21 +8,23 @@ Server-side SignalR hub infrastructure: method dispatch, session management, bro ## Server Processing ``` -6. OnReceiveMessage(tag, requestId, receiveParams, data) -7. DynamicMethodRegistry.GetMethodByMessageTag(tag) ← ConcurrentDictionary lookup -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, DataSerializerType } + responseData byte[] from message - └─ caller.OnReceiveMessage(tag, requestId, receiveParams, responseData) - (metadata + payload as separate args — no envelope serialization) -12. If SendToOtherClientType != None: - └─ SendMessageToOthers(sendToOtherClientTag, result) ← uses sendToOtherClientTag, not messageTag +6. OnReceiveMessage(tag, requestId, signalParams, data) +7. Extract parameterBytes from signalParams.Parameters +8. DynamicMethodRegistry.GetMethodByMessageTag(tag) <- ConcurrentDictionary lookup +9. signalParams.GetParameterValues(paramInfos): + |- byte[] -> BinaryTo() (cached) + |- Per-element: byte[][i].BinaryTo(paramInfos[i].ParameterType) + |- Trailing defaults auto-filled + |- Hub validates: missing required params throw ArgumentException + '- NOTE: BinaryTo only -- JSON param deserialization not supported (needs JsonTo + project ref) +10. MethodInfo.InvokeMethod(instance, params) <- unwraps Task/ValueTask +11. CreateResponseMessage(tag, Success, result) <- Binary serialize payload -> byte[] +12. SendMessageToClient(caller, tag, message, requestId): + |- Extract signalParams { Status, DataSerializerType } + responseData byte[] from message + '- caller.OnReceiveMessage(tag, requestId, signalParams, responseData) + (metadata + payload as separate args -- no envelope serialization) +13. If SendToOtherClientType != None: + '- SendMessageToOthers(sendToOtherClientTag, result) <- uses sendToOtherClientTag, not messageTag ``` ## Dynamic Method Dispatch @@ -32,22 +34,22 @@ See also: `AyCode.Models.Server/DynamicMethods/README.md` ### Server-Side Lookup ``` -1. OnReceiveMessage(tag=100, requestId, receiveParams, data) +1. OnReceiveMessage(tag=100, requestId, signalParams, data) 2. DynamicMethodRegistry.GetMethodByMessageTag(100) - ├─ Check static ConcurrentDictionary cache - ├─ Hit? → find instance of cached Type from registered instances - ├─ Miss? → scan all registered instances' methods for [SignalR(100)] - │ cache the result (including negative = null) - └─ Return (instance, methodInfoModel) or null + |- Check static ConcurrentDictionary cache + |- Hit? -> find instance of cached Type from registered instances + |- Miss? -> scan all registered instances' methods for [SignalR(100)] + | cache the result (including negative = null) + '- Return (instance, methodInfoModel) or null 3. AcMethodInfoModel contains: - ├─ MethodInfo (the method to invoke) - ├─ SignalRAttribute (tag, sendToOtherClientTag, sendToOtherClientType) - └─ ParamInfos[] (ParameterInfo for deserialization) + |- MethodInfo (the method to invoke) + |- SignalRAttribute (tag, sendToOtherClientTag, sendToOtherClientType) + '- ParamInfos[] (ParameterInfo for deserialization) ``` -The `DynamicMethodRegistry` uses a static `ConcurrentDictionary` for the global tag→method cache. +The `DynamicMethodRegistry` uses a static `ConcurrentDictionary` for the global tag->method cache. ### Registration @@ -81,12 +83,12 @@ ConcurrentDictionary Sessions | `SendMessageToUser(userId)` | User (all connections) | | `SendMessageToUsers(userIds)` | Multiple users | -All messages serialized to `byte[]` payload + `SignalReceiveParams` metadata → sent as separate hub arguments via `OnReceiveMessage` (no envelope wrapping). +All messages serialized to `byte[]` payload + `SignalParams` metadata (Parameters=null for server->client push) -> sent as separate hub arguments via `OnReceiveMessage` (no envelope wrapping). ## Hub Events -- `OnConnectedAsync()` — log connection -- `OnDisconnectedAsync(exception)` — log disconnection, cleanup session +- `OnConnectedAsync()` -- log connection +- `OnDisconnectedAsync(exception)` -- log disconnection, cleanup session ## Diagnostics @@ -94,7 +96,7 @@ Enable with `AcWebSignalRHubBase.EnableBinaryDiagnostics = true`. Logs: hex dump (500 byte sample), header parsing (version, marker), property count + names via VarUInt reading. -`SignalResponseDataMessage.DiagnosticLogger` — per-response logging: target type info, property list, inheritance chain, hex dump. +`SignalResponseDataMessage.DiagnosticLogger` -- per-response logging: target type info, property list, inheritance chain, hex dump. ## Key Source Files diff --git a/AyCode.Services.Tests/SignalRs/PostJsonDataMessageTests.cs b/AyCode.Services.Tests/SignalRs/PostJsonDataMessageTests.cs index 1b32396..0dc0453 100644 --- a/AyCode.Services.Tests/SignalRs/PostJsonDataMessageTests.cs +++ b/AyCode.Services.Tests/SignalRs/PostJsonDataMessageTests.cs @@ -7,21 +7,17 @@ namespace AyCode.Services.Tests.SignalRs; public class PostJsonDataMessageTests { [TestMethod] - public void Debug_ParameterBinary_RoundTrip_ForInt() + public void SignalParams_SetGet_RoundTrip_ForInt() { - var parameters = new object[] { 42 }; - Console.WriteLine($"Parameters: [{string.Join(", ", parameters)}]"); + var signalParams = new SignalParams { Status = SignalResponseStatus.Success }; + signalParams.SetParameterValues([42]); - var bytes = SignalRSerializationHelper.SerializeParametersToBinary(parameters); - Console.WriteLine($"Binary bytes: {bytes.Length}"); - - // Simulate server deserialization with known target types var paramInfos = typeof(PostJsonDataMessageTests) .GetMethod(nameof(DummyIntMethod))!.GetParameters(); - var deserialized = SignalRSerializationHelper.DeserializeParametersFromBinary(bytes, paramInfos); + var deserialized = signalParams.GetParameterValues(paramInfos); Assert.IsNotNull(deserialized); - Assert.AreEqual(1, deserialized.Length); + Assert.AreEqual(1, deserialized!.Length); Assert.AreEqual(42, deserialized[0]); } @@ -29,67 +25,86 @@ public class PostJsonDataMessageTests [DataRow(42)] [DataRow("45")] [DataRow(true)] - public void ParameterBinary_FullRoundTrip_AnyParameter(object testValue) + public void SignalParams_FullRoundTrip_AnyParameter(object testValue) { - Console.WriteLine("=== Step 1: Client creates object[] ==="); - var parameters = new[] { testValue }; + // Client packs via SetParameterValues + var signalParams = new SignalParams { Status = SignalResponseStatus.Success }; + signalParams.SetParameterValues([testValue]); - Console.WriteLine("\n=== Step 2: Binary serialization ==="); - var bytes = SignalRSerializationHelper.SerializeParametersToBinary(parameters); - Console.WriteLine($"Binary bytes: {bytes.Length}"); + // Wire round-trip: SignalParams → byte[] → SignalParams + var wireBytes = signalParams.ToBinary(); + var restored = wireBytes.BinaryTo()!; - Console.WriteLine("\n=== Step 3: Server deserializes with target type ==="); + // Server unpacks via GetParameterValues var paramInfos = new[] { GetParamInfoForType(testValue.GetType()) }; - var serverParams = SignalRSerializationHelper.DeserializeParametersFromBinary(bytes, paramInfos); + var serverParams = restored.GetParameterValues(paramInfos); - Console.WriteLine($"Server params[0]: '{serverParams[0]}' (type: {serverParams[0]?.GetType().Name})"); + Assert.IsNotNull(serverParams); + Assert.AreEqual(testValue, serverParams![0]); - Console.WriteLine("\n=== Step 4: Service method uses parameter ==="); - var serviceResult = $"{serverParams![0]}"; - Console.WriteLine($"Service result: '{serviceResult}'"); - - Console.WriteLine("\n=== Step 5: Server creates response ==="); + // Response round-trip + var serviceResult = $"{serverParams[0]}"; var response = new SignalResponseDataMessage(100, SignalResponseStatus.Success, serviceResult, AyCode.Core.Serializers.Binaries.AcBinarySerializerOptions.Default); - - Console.WriteLine("\n=== Step 6: Response Binary ==="); var responseBytes = response.ToBinary(); - - Console.WriteLine("\n=== Step 7: Client deserializes response ==="); var clientResponse = responseBytes.BinaryTo(); - - Console.WriteLine("\n=== Step 8: Client deserializes to string ==="); var finalResult = clientResponse?.GetResponseData(); - Console.WriteLine($"Final result: '{finalResult}'"); Assert.AreEqual(testValue.ToString(), finalResult); } [TestMethod] - public void ParameterBinary_MultipleParams_RoundTrip() + public void SignalParams_MultipleParams_RoundTrip() { var guid = Guid.NewGuid(); - var parameters = new object[] { 42, "hello", true, guid }; - var bytes = SignalRSerializationHelper.SerializeParametersToBinary(parameters); + var signalParams = new SignalParams { Status = SignalResponseStatus.Success }; + signalParams.SetParameterValues([42, "hello", true, guid]); + + // Wire round-trip + var wireBytes = signalParams.ToBinary(); + var restored = wireBytes.BinaryTo()!; var paramInfos = typeof(PostJsonDataMessageTests) .GetMethod(nameof(DummyMultiMethod))!.GetParameters(); - var deserialized = SignalRSerializationHelper.DeserializeParametersFromBinary(bytes, paramInfos); + var deserialized = restored.GetParameterValues(paramInfos); Assert.IsNotNull(deserialized); - Assert.AreEqual(4, deserialized.Length); + 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]); } + [TestMethod] + public void SignalParams_WireFormat_ParametersIsByteArray() + { + var signalParams = new SignalParams { Status = SignalResponseStatus.Success }; + signalParams.SetParameterValues([42, "hello"]); + + // Parameters property is byte[] (not byte[][]) + Assert.IsNotNull(signalParams.Parameters); + Assert.IsInstanceOfType(signalParams.Parameters); + } + + [TestMethod] + public void SignalParams_NullParameters_ReturnsNull() + { + var signalParams = new SignalParams { Status = SignalResponseStatus.Success }; + + var paramInfos = typeof(PostJsonDataMessageTests) + .GetMethod(nameof(DummyIntMethod))!.GetParameters(); + var result = signalParams.GetParameterValues(paramInfos); + + Assert.IsNull(result); + } + // Dummy methods for ParameterInfo extraction public static void DummyIntMethod(int value) { } public static void DummyMultiMethod(int a, string b, bool c, Guid d) { } + public static void DummyIntStringMethod(int a, string b) { } 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]; diff --git a/AyCode.Services/SignalRs/AcSignalRClientBase.cs b/AyCode.Services/SignalRs/AcSignalRClientBase.cs index d35065c..f9ba340 100644 --- a/AyCode.Services/SignalRs/AcSignalRClientBase.cs +++ b/AyCode.Services/SignalRs/AcSignalRClientBase.cs @@ -21,7 +21,7 @@ namespace AyCode.Services.SignalRs protected readonly HubConnection? HubConnection; protected readonly AcLoggerBase Logger; - protected abstract Task MessageReceived(int messageTag, SignalReceiveParams receiveParams, byte[] data); + protected abstract Task MessageReceived(int messageTag, SignalParams signalParams, byte[] data); public int MsDelay = 25; public int MsFirstDelay = 50; @@ -70,7 +70,7 @@ namespace AyCode.Services.SignalRs HubConnection = hubBuilder.Build(); HubConnection.Closed += HubConnection_Closed; - _ = HubConnection.On(nameof(IAcSignalRHubClient.OnReceiveMessage), OnReceiveMessage); + _ = HubConnection.On(nameof(IAcSignalRHubClient.OnReceiveMessage), OnReceiveMessage); } protected AcSignalRClientBase(AcLoggerBase logger) @@ -105,8 +105,8 @@ namespace AyCode.Services.SignalRs protected virtual ValueTask DisposeConnectionInternal() => HubConnection?.DisposeAsync() ?? ValueTask.CompletedTask; - protected virtual Task SendToHubAsync(int messageTag, int? requestId, SignalReceiveParams receiveParams, byte[]? messageBytes) - => HubConnection?.SendAsync(nameof(IAcSignalRHubClient.OnReceiveMessage), messageTag, requestId, receiveParams, messageBytes) ?? Task.CompletedTask; + protected virtual Task SendToHubAsync(int messageTag, int? requestId, SignalParams signalParams, byte[]? messageBytes) + => HubConnection?.SendAsync(nameof(IAcSignalRHubClient.OnReceiveMessage), messageTag, requestId, signalParams, messageBytes) ?? Task.CompletedTask; #endregion @@ -142,16 +142,18 @@ namespace AyCode.Services.SignalRs await StartConnection(); - var msgBytes = parameters is { Length: > 0 } ? SignalRSerializationHelper.SerializeParametersToBinary(parameters) : null; - if (!IsConnected()) { Logger.Error($"Client SendMessageToServerAsync error! ConnectionState: {GetConnectionState()};"); return; } - var receiveParams = new SignalReceiveParams { Status = SignalResponseStatus.Success }; - await SendToHubAsync(messageTag, requestId, receiveParams, msgBytes); + var signalParams = new SignalParams { Status = SignalResponseStatus.Success }; + + if (parameters is { Length: > 0 }) + signalParams.SetParameterValues(parameters); + + await SendToHubAsync(messageTag, requestId, signalParams, null); } #region CRUD @@ -208,24 +210,27 @@ namespace AyCode.Services.SignalRs yield break; } - var msgBytes = contextParams is { Length: > 0 } ? SignalRSerializationHelper.SerializeParametersToBinary(contextParams) : null; + var msgBytes = contextParams is { Length: > 0 } + ? SignalRSerializationHelper.SerializeToBinary( + contextParams.Select(p => SignalRSerializationHelper.SerializeToBinary(p)).ToArray()) + : null; var stream = HubConnection.StreamAsync( - "OnReceiveStreamMessage", - messageTag, - msgBytes, + "OnReceiveStreamMessage", + messageTag, + msgBytes, cancellationToken); await foreach (var bytes in stream.WithCancellation(cancellationToken)) { if (bytes == null) continue; - + if (typeof(TResponseData) == typeof(byte[])) { yield return (TResponseData)(object)bytes; continue; } - + var responseMessage = SignalRSerializationHelper.DeserializeFromBinary(bytes); if (responseMessage != null) { @@ -280,7 +285,8 @@ namespace AyCode.Services.SignalRs yield break; } - var msgBytes = SignalRSerializationHelper.SerializeParametersToBinary(new object[] { postData! }); + var msgBytes = SignalRSerializationHelper.SerializeToBinary( + new[] { SignalRSerializationHelper.SerializeToBinary(postData!) }); var stream = HubConnection.StreamAsync( "OnReceiveStreamMessage", @@ -410,7 +416,7 @@ namespace AyCode.Services.SignalRs protected virtual int GetNextRequestId() => AcDomain.NextUniqueInt32; - public virtual Task OnReceiveMessage(int messageTag, int? requestId, SignalReceiveParams receiveParams, byte[] data) + public virtual Task OnReceiveMessage(int messageTag, int? requestId, SignalParams signalParams, byte[] data) { var logText = $"Client OnReceiveMessage; {nameof(requestId)}: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}"; @@ -430,8 +436,8 @@ namespace AyCode.Services.SignalRs // No envelope deserialization — construct directly from params + data var responseMessage = new SignalResponseDataMessage { - Status = receiveParams.Status, - DataSerializerType = receiveParams.DataSerializerType, + Status = signalParams.Status, + DataSerializerType = signalParams.DataSerializerType, ResponseData = data }; @@ -464,7 +470,7 @@ namespace AyCode.Services.SignalRs } Logger.Info(logText); - MessageReceived(messageTag, receiveParams, data).Forget(); + MessageReceived(messageTag, signalParams, data).Forget(); } catch (Exception ex) { diff --git a/AyCode.Services/SignalRs/IAcSignalRHubBase.cs b/AyCode.Services/SignalRs/IAcSignalRHubBase.cs index b7de97f..80da6f3 100644 --- a/AyCode.Services/SignalRs/IAcSignalRHubBase.cs +++ b/AyCode.Services/SignalRs/IAcSignalRHubBase.cs @@ -3,5 +3,5 @@ public interface IAcSignalRHubBase { //Task OnRequestMessage(int messageTag, int requestId); - Task OnReceiveMessage(int messageTag, int? requestId, SignalReceiveParams receiveParams, byte[] data); + Task OnReceiveMessage(int messageTag, int? requestId, SignalParams signalParams, byte[] data); } \ No newline at end of file diff --git a/AyCode.Services/SignalRs/IAcSignalRHubClient.cs b/AyCode.Services/SignalRs/IAcSignalRHubClient.cs index 97560a8..61379eb 100644 --- a/AyCode.Services/SignalRs/IAcSignalRHubClient.cs +++ b/AyCode.Services/SignalRs/IAcSignalRHubClient.cs @@ -182,7 +182,6 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa /// Deserializes the ResponseData to the specified type. /// Uses cached result for repeated calls. /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] public T? GetResponseData() { if (_cachedResponseData != null) return (T)_cachedResponseData; @@ -279,7 +278,7 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa } } - private void LogTypeProperties(Type type, string prefix) + private static void LogTypeProperties(Type type, string prefix) { if (DiagnosticLogger == null) return; @@ -289,7 +288,7 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa // Log in declaration order (not alphabetically) to match serialization order DiagnosticLogger($"{prefix} Property Count: {props.Length}"); - for (int i = 0; i < props.Length; i++) + for (var i = 0; i < props.Length; i++) { var p = props[i]; var declaringType = p.DeclaringType?.Name ?? "?"; @@ -298,7 +297,7 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa } } - private void LogBinaryHeader(byte[] data) + private static void LogBinaryHeader(byte[] data) { if (DiagnosticLogger == null || data.Length < 3) return; @@ -320,7 +319,7 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa DiagnosticLogger($"Header Property Count: {propCount}"); - for (int i = 0; i < (int)propCount && pos < data.Length; i++) + for (var i = 0; i < (int)propCount && pos < data.Length; i++) { // Read string length as VarUInt var (strLen, strLenBytes) = ReadVarUIntFromSpan(data.AsSpan(pos)); @@ -343,8 +342,8 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa private static (uint value, int bytesRead) ReadVarUIntFromSpan(ReadOnlySpan span) { uint value = 0; - int shift = 0; - int bytesRead = 0; + var shift = 0; + var bytesRead = 0; while (bytesRead < span.Length) { diff --git a/AyCode.Services/SignalRs/ISignalParams.cs b/AyCode.Services/SignalRs/ISignalParams.cs index ad3793f..6455e0d 100644 --- a/AyCode.Services/SignalRs/ISignalParams.cs +++ b/AyCode.Services/SignalRs/ISignalParams.cs @@ -1,3 +1,5 @@ +using System.Reflection; +using AyCode.Core.Extensions; using AyCode.Core.Serializers; using AyCode.Core.Serializers.Attributes; @@ -9,13 +11,89 @@ namespace AyCode.Services.SignalRs; public interface ISignalParams { } /// -/// Parameters received alongside message data. -/// Travels as a separate SignalR hub argument (small, AcBinary serialized) -/// while the payload byte[] uses the protocol's zero-copy fast-path. +/// SignalR message metadata + optional method parameters. +/// Travels as a separate hub argument (AcBinary serialized). +/// Parameters and data are independent — both can be null or filled in any direction. +/// +/// Parameter serialization follows the PostDataJson pattern: +/// - Wire format: Parameters (byte[]) — AcBinary fast-path +/// - Client: SetParameterValues(object[]) packs object[] → byte[][] → byte[] +/// - Server: GetParameterValues(ParameterInfo[]) unpacks byte[] → byte[][] → object[] +/// The protocol never sees byte[][] — only byte[]. /// [AcBinarySerializable] -public class SignalReceiveParams : ISignalParams +public class SignalParams : ISignalParams { public SignalResponseStatus Status { get; set; } public AcSerializerType DataSerializerType { get; set; } + + /// + /// Wire format: serialized byte[][] as a single byte[]. + /// AcBinary handles this as byte[] fast-path (zero-copy potential). + /// Use SetParameterValues/GetParameterValues for typed access. + /// + public byte[]? Parameters { get; set; } + + /// + /// Cached deserialized byte[][] from Parameters. + /// + private byte[][]? _parameterValues; + + /// + /// Client-side: packs object[] into Parameters (byte[]). + /// Each parameter is individually binary-serialized via ToBinary(). + /// + /// PERF: N× ToBinary() = N× context pool acquire/release + N× ArrayBinaryOutput allocation. + /// For many small primitives (int, bool) this overhead may exceed a single bulk serialization call. + /// Consider a batch fast-path (single context, write all params) if benchmarks confirm regression. + /// + public void SetParameterValues(object[] parameters) + { + // N× pool roundtrip — see PERF note in summary + var paramBytes = new byte[parameters.Length][]; + for (var i = 0; i < parameters.Length; i++) + paramBytes[i] = parameters[i].ToBinary(); + + _parameterValues = paramBytes; + Parameters = paramBytes.ToBinary(); + } + + /// + /// Server-side: unpacks Parameters (byte[]) into typed object[]. + /// Each parameter is deserialized with BinaryTo(targetType) from method signature. + /// Fills trailing optional parameters with defaults, throws for missing required ones. + /// Caches the intermediate byte[][] for repeated access. + /// + /// NOTE: Currently AcBinary only. JSON parameter deserialization not supported + /// (would require dispatching on serializer type + AcJsonSerializer reference). + /// + /// PERF: Non-generic BinaryTo(Type) uses ThreadLocal + ConcurrentDictionary type cache per call. + /// N parameters = N× pool roundtrip + N× type-dispatch lookup. + /// For many small primitives this may be slower than a single bulk deserialization. + /// Consider a batch fast-path (single context, read all params) if benchmarks confirm regression. + /// + public object[]? GetParameterValues(ParameterInfo[] paramInfos) + { + if (paramInfos is not { Length: > 0 }) + return null; + + // Deserialize byte[] → byte[][] (cached) + _parameterValues ??= Parameters is { Length: > 0 } + ? Parameters.BinaryTo() + : null; + + if (_parameterValues is null or { Length: 0 }) + return null; + + // N× pool roundtrip + type-dispatch — see PERF note in summary + var result = new object?[paramInfos.Length]; + for (var i = 0; i < _parameterValues.Length && i < paramInfos.Length; i++) + result[i] = _parameterValues[i].BinaryTo(paramInfos[i].ParameterType); + + // Fill trailing optional parameters with defaults + for (var i = _parameterValues.Length; i < paramInfos.Length; i++) + result[i] = paramInfos[i].HasDefaultValue ? paramInfos[i].DefaultValue : null; + + return result; + } } diff --git a/AyCode.Services/SignalRs/README.md b/AyCode.Services/SignalRs/README.md index 22457ec..b737e01 100644 --- a/AyCode.Services/SignalRs/README.md +++ b/AyCode.Services/SignalRs/README.md @@ -13,13 +13,13 @@ Custom binary SignalR protocol, client infrastructure, message tagging, and seri ### Client - **`AcSignalRClientBase.cs`** — Abstract SignalR client managing `HubConnection`, request/response tracking via pooled `SignalRRequestModel`. Methods: `SendMessageToServerAsync()`, CRUD helpers (Post, Get, GetAll, GetAllInto). Configurable timeouts. - **`IAcSignalRHubClient.cs`** — Client interface + `SignalResponseDataMessage` (sealed, supports JSON/Binary with GZip, caching, diagnostics). -- **`IAcSignalRHubBase.cs`** — Base hub interface: `OnReceiveMessage(int messageTag, int? requestId, SignalReceiveParams receiveParams, byte[] data)`. -- **`ISignalParams.cs`** — `ISignalParams` base interface + `SignalReceiveParams` (Status). Metadata travels as separate hub argument (AcBinary serialized), payload `byte[]` uses protocol fast-path (zero-copy). +- **`IAcSignalRHubBase.cs`** — Base hub interface: `OnReceiveMessage(int messageTag, int? requestId, SignalParams signalParams, byte[] data)`. +- **`ISignalParams.cs`** — `ISignalParams` base interface + `SignalParams` (Status, DataSerializerType, Parameters `byte[][]?`). Metadata travels as separate hub argument (AcBinary serialized), payload `byte[]` uses protocol fast-path (zero-copy). Parameters and data are independent — both nullable in any direction. ### Message Tagging - **`SignalMessageTagAttribute.cs`** — Three attributes: `TagAttribute` (base, int messageTag), `SignalRAttribute` (server method routing + client notification), `SignalRSendToClientAttribute` (client-side receive). - **`AcSignalRTags.cs`** — Static constants: `None`, `PingTag`, `EchoTag`. -- **`SignalRCrudTags.cs`** — Sealed class bundling 5 independent CRUD tag integers. `GetMessageTagByTrackingState()` maps `TrackingState` → tag. See `AyCode.Services.Server/docs/SIGNALR_DATASOURCE.md`. +- **`SignalRCrudTags.cs`** — Sealed class bundling 5 independent CRUD tag integers. `GetMessageTagByTrackingState()` maps `TrackingState` -> tag. See `AyCode.Services.Server/docs/SIGNALR_DATASOURCE.md`. - **`SendToClientType.cs`** — Enum: None, Others, Caller, All. ### Serialization & Pooling diff --git a/AyCode.Services/SignalRs/SignalRSerializationHelper.cs b/AyCode.Services/SignalRs/SignalRSerializationHelper.cs index a177572..882bf0f 100644 --- a/AyCode.Services/SignalRs/SignalRSerializationHelper.cs +++ b/AyCode.Services/SignalRs/SignalRSerializationHelper.cs @@ -98,103 +98,6 @@ 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 367df60..aa34f54 100644 --- a/AyCode.Services/docs/SIGNALR.md +++ b/AyCode.Services/docs/SIGNALR.md @@ -10,12 +10,12 @@ Client-side SignalR transport: custom binary protocol, tag-based dispatch. Sourc Single hub method, tag-based dispatch: ``` -Client ──OnReceiveMessage(tag, requestId, receiveParams, data)──► Server -Client ◄──OnReceiveMessage(tag, requestId, receiveParams, data)── Server +Client ──OnReceiveMessage(tag, requestId, signalParams, data)──► Server +Client ◄──OnReceiveMessage(tag, requestId, signalParams, data)── Server ``` Tag (int) determines server method. All calls go through `OnReceiveMessage`. -Metadata (`SignalReceiveParams`) and payload (`byte[]`) travel as **separate hub arguments** — the `byte[]` uses the protocol's zero-copy fast-path, metadata is AcBinary serialized normally. +Metadata (`SignalParams`) and payload (`byte[]`) travel as **separate hub arguments** — the `byte[]` uses the protocol's zero-copy fast-path, metadata is AcBinary serialized normally. ``` Client: Server: @@ -74,18 +74,33 @@ Custom `IHubProtocol` (`"acbinary"`), replaces JSON. Zero-copy via `BufferWriter > Wire format, argument framing, dual BWO pattern, length prefix patching: `SIGNALR_BINARY_PROTOCOL.md` -### Metadata + Payload Separation +### SignalParams + Payload Separation -`SignalReceiveParams` (separate hub argument, AcBinary serialized): +`SignalParams` (separate hub argument, `[AcBinarySerializable]`): | Field | Type | Purpose | |-------|------|---------| | `Status` | SignalResponseStatus | Success/Error | | `DataSerializerType` | AcSerializerType | Binary or JsonGZip — tells client how to deserialize response data | +| `Parameters` | `byte[]?` | Serialized `byte[][]` as single `byte[]` (protocol fast-path). Null when no parameters. | + +Typed access via methods (PostDataJson pattern): +- **Client**: `SetParameterValues(object[])` — packs each param via `ToBinary()` → `byte[][]` → `byte[]` +- **Server**: `GetParameterValues(ParameterInfo[])` — unpacks `byte[]` → `byte[][]` → per-element `BinaryTo(targetType)` +- Protocol never sees `byte[][]` — only `byte[]`. `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. `GetResponseData()` dispatches on `DataSerializerType`: Binary → `BinaryTo()`, JsonGZip → decompress → `JsonTo()`. +`Parameters` and `data` are **independent** — both can be null or filled in any direction (SignalR is bidirectional). + +| Combination | Parameters | data | Example | +|------------|-----------|------|---------| +| Request | `byte[]` (packed params) | null | client calls server method | +| Response | null | response payload | server returns result | +| Request + data | `byte[]` | `byte[]` | client responds to server with data | +| Signal | null | null | ping, status change, broadcast trigger | + +`SignalResponseDataMessage` remains as **internal DTO** for callback routing — constructed in-memory from `signalParams` + `data`, never serialized as envelope on wire. `GetResponseData()` dispatches on `DataSerializerType`: Binary → `BinaryTo()`, JsonGZip → decompress → `JsonTo()`. ## Request/Response Flow @@ -93,30 +108,47 @@ Custom `IHubProtocol` (`"acbinary"`), replaces JSON. Zero-copy via `BufferWriter ``` 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) +2. signalParams.SetParameterValues(object[]): + Each param ToBinary() → byte[][] → ToBinary() → byte[] (single wire blob) +3. SignalParams { Status = Success, Parameters = byte[] } +4. HubConnection.SendAsync("OnReceiveMessage", tag, requestId, signalParams, null) +5. AcBinaryHubProtocol frames on wire (signalParams via AcBinary, data = null for requests) ``` ### Server → Client ``` -OnReceiveMessage(tag, requestId, receiveParams, data) +OnReceiveMessage(tag, requestId, signalParams, data) ├─ Construct SignalResponseDataMessage in-memory (no envelope deser): -│ └─ { Status, DataSerializerType, ResponseData } from receiveParams + data +│ └─ { Status, DataSerializerType, ResponseData } from signalParams + data ├─ Matching requestId in pending dict: │ ├─ Route: null→sync wait, Action→invoke, Func→await │ └─ GetResponseData(): dispatches on DataSerializerType │ Binary→BinaryTo(), JsonGZip→Decompress→JsonTo() └─ No match (broadcast): - └─ abstract MessageReceived(tag, receiveParams, data).Forget() + └─ abstract MessageReceived(tag, signalParams, data).Forget() ``` Request pooling: `SignalRRequestModel` via `SignalRRequestModelPool` (ObjectPool + IResettable). +## Parameter Deserialization (Server) + +Server calls `signalParams.GetParameterValues(paramInfos)`: + +``` +GetParameterValues(ParameterInfo[]): + 1. byte[] → BinaryTo() (cached in _parameterValues) + 2. For each: byte[][i].BinaryTo(paramInfos[i].ParameterType) + 3. Trailing optional params filled with defaults + Hub validates: missing required params throw ArgumentException. +``` + +Type-guided deserialization — each parameter is individually serialized/deserialized with its concrete type, avoiding the `object[]` → dictionary problem of untyped binary deserialization. + +**Perf concern:** Per-parameter `ToBinary()`/`BinaryTo(Type)` = N× context pool acquire/release + N× type-dispatch (ThreadLocal + ConcurrentDictionary cache). For many small primitives (int, bool, string) the per-call overhead may exceed a single bulk serialization. Complex objects benefit clearly. If benchmarks show regression vs old JSON path, a batch fast-path (single serialization context for all params) should be added. + +**Limitation:** Parameter serialization/deserialization is currently AcBinary only (`ToBinary()`/`BinaryTo()`). JSON support would require dispatching on serializer type in `SignalParams` methods + AcJsonSerializer reference. + ## Response Patterns | Pattern | Method | Blocking | @@ -136,21 +168,6 @@ Request pooling: `SignalRRequestModel` via `SignalRRequestModelPool` (ObjectPool `AcSignalRClientBase.EnableBinaryDiagnostics = true` — hex dump, header parsing, property enumeration. -## Parameter Serialization - -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 | Component | Path | @@ -162,5 +179,5 @@ This enables type-guided deserialization — each parameter is individually seri | CRUD tags | `SignalRs/SignalRCrudTags.cs` | | SendToClientType | `SignalRs/SendToClientType.cs` | | Message types | `SignalRs/IAcSignalRHubClient.cs` | -| Params interface | `SignalRs/ISignalParams.cs` | +| Params class | `SignalRs/ISignalParams.cs` | | Serialization | `SignalRs/SignalRSerializationHelper.cs` | diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 0a991e2..d14f6d7 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -70,15 +70,14 @@ 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. 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. | +| **OnReceiveMessage** | The single SignalR method for all communication. Signature: `(int messageTag, int? requestId, SignalParams signalParams, byte[] data)`. Metadata and payload are separate hub arguments — `byte[]` uses protocol zero-copy fast-path. `SignalParams.Parameters` carries packed method params as `byte[]`. `data` carries response payload. Both are independent and nullable in any direction. | +| **SignalParams** | Metadata sent alongside message payload as separate hub argument. Contains `Status`, `DataSerializerType`, and `Parameters` (`byte[]?` — packed `byte[][]` as single blob, protocol fast-path). Typed access via `SetParameterValues(object[])` / `GetParameterValues(ParameterInfo[])` — PostDataJson pattern. `[AcBinarySerializable]`. Never null — only `Parameters` inside is nullable. | | **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** | ⚠️ 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`. | +| **SignalResponseDataMessage** | Internal DTO for callback routing (not serialized on wire). Constructed in-memory from `signalParams` + `data`. Supports Binary or JSON+GZip via `GetResponseData()`. | +| **SignalPostJsonDataMessage** | OBSOLETE — removed. Legacy: serialized params to JSON inside Binary envelope. | | **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. |