Refactor SignalR param handling: SignalParams replaces old
Major protocol/API change: replace SignalReceiveParams with SignalParams everywhere. SignalParams now carries packed method parameters as a single byte[] and provides SetParameterValues/GetParameterValues for type-safe packing/unpacking. All hub/client interfaces, method signatures, and dispatch logic updated. Legacy parameter serialization helpers removed; all parameter logic is encapsulated in SignalParams. Documentation and tests updated to reflect new wire format and flow. This unifies parameter handling, clarifies the protocol, and enables robust, extensible type-guided serialization. Breaking change.
This commit is contained in:
parent
cdd54d3196
commit
3b7007002a
|
|
@ -212,16 +212,16 @@ public class BenchmarkSignalRClient : AcSignalRClientBase, IAcSignalRHubItemServ
|
|||
public TResponse? GetAllSync<TResponse>(int tag)
|
||||
=> GetAllAsync<TResponse>(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 ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,22 +34,12 @@ public enum SendTarget
|
|||
/// </summary>
|
||||
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>(T value) where T : notnull
|
||||
=> CreatePrimitiveParamsMessage(value);
|
||||
|
||||
public static byte[] CreateComplexObjectMessage<T>(T obj)
|
||||
{
|
||||
return SignalRSerializationHelper.SerializeParametersToBinary(new object[] { obj! });
|
||||
}
|
||||
|
||||
public static byte[] CreateEmptyMessage()
|
||||
{
|
||||
return SignalRSerializationHelper.SerializeParametersToBinary(Array.Empty<object>());
|
||||
var signalParams = new SignalParams { Status = SignalResponseStatus.Success };
|
||||
if (values.Length > 0)
|
||||
signalParams.SetParameterValues(values);
|
||||
return signalParams;
|
||||
}
|
||||
|
||||
public static T? GetResponseData<T>(SentMessage sentMessage)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -16,14 +16,14 @@ public abstract class AcSignalRSendToClientService<TSignalRHub, TSignalRTags, TL
|
|||
protected virtual async Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, object? content)
|
||||
{
|
||||
var responseData = SignalRSerializationHelper.CreateResponseData(content, AcBinarySerializerOptions.Default) ?? [];
|
||||
var receiveParams = new SignalReceiveParams
|
||||
var signalParams = new SignalParams
|
||||
{
|
||||
Status = SignalResponseStatus.Success,
|
||||
DataSerializerType = AyCode.Core.Serializers.AcSerializerType.Binary
|
||||
};
|
||||
|
||||
Logger.Info($"[{responseData.Length / 1024}kb] Server sending to client; {ConstHelper.NameByValue<TSignalRTags>(messageTag)}");
|
||||
await sendTo.OnReceiveMessage(messageTag, null, receiveParams, responseData);
|
||||
await sendTo.OnReceiveMessage(messageTag, null, signalParams, responseData);
|
||||
}
|
||||
|
||||
public virtual Task SendMessageToAllClients(int messageTag, object? content)
|
||||
|
|
|
|||
|
|
@ -64,27 +64,35 @@ public abstract class AcWebSignalRHubBase<TSignalRTags, TLogger>(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<byte[]> OnReceiveStreamMessage(int messageTag, byte[]? messageBytes)
|
||||
{
|
||||
return ProcessOnStreamMessage(messageTag, messageBytes, Context.ConnectionAborted);
|
||||
var parameterBytes = messageBytes is { Length: > 0 }
|
||||
? SignalRSerializationHelper.DeserializeFromBinary<byte[][]>(messageBytes)
|
||||
: null;
|
||||
return ProcessOnStreamMessage(messageTag, parameterBytes, Context.ConnectionAborted);
|
||||
}
|
||||
|
||||
protected virtual async IAsyncEnumerable<byte[]> ProcessOnStreamMessage(int messageTag, byte[]? message, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
protected virtual async IAsyncEnumerable<byte[]> ProcessOnStreamMessage(int messageTag, byte[][]? parameterBytes, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var tagName = ConstHelper.NameByValue<TSignalRTags>(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<TSignalRTags, TLogger>(IConfiguration
|
|||
return null;
|
||||
}
|
||||
|
||||
protected virtual async Task ProcessOnReceiveMessage(int messageTag, byte[]? message, int? requestId, Func<string, Task>? notFoundCallback)
|
||||
protected virtual async Task ProcessOnReceiveMessage(int messageTag, SignalParams signalParams, int? requestId, Func<string, Task>? notFoundCallback)
|
||||
{
|
||||
var tagName = ConstHelper.NameByValue<TSignalRTags>(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<TSignalRTags, TLogger>(IConfiguration
|
|||
/// <summary>
|
||||
/// Finds and invokes the method registered for the given message tag.
|
||||
/// </summary>
|
||||
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<TSignalRTags, TLogger>(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<TSignalRTags, TLogger>(IConfiguration
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static object[]? DeserializeParameters(byte[]? message, AcMethodInfoModel<SignalRAttribute> methodInfoModel, string tagName, string methodName)
|
||||
private static object[]? DeserializeParameters(SignalParams signalParams, AcMethodInfoModel<SignalRAttribute> 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<TSignalRTags, TLogger>(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<TSignalRTags, TLogger>(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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<byte[][]>() (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<int, (Type, AcMethodInfoModel)?> 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<int, (Type, AcMethodInfoModel)?> 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<TSessionItemId, TSessionItem> 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<SignalParams>()!;
|
||||
|
||||
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<SignalResponseDataMessage>();
|
||||
|
||||
Console.WriteLine("\n=== Step 8: Client deserializes to string ===");
|
||||
var finalResult = clientResponse?.GetResponseData<string>();
|
||||
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<SignalParams>()!;
|
||||
|
||||
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<byte[]>(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];
|
||||
|
|
|
|||
|
|
@ -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<int, int?, SignalReceiveParams, byte[]>(nameof(IAcSignalRHubClient.OnReceiveMessage), OnReceiveMessage);
|
||||
_ = HubConnection.On<int, int?, SignalParams, byte[]>(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,7 +210,10 @@ 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<byte[]>(
|
||||
"OnReceiveStreamMessage",
|
||||
|
|
@ -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<byte[]>(
|
||||
"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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -182,7 +182,6 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa
|
|||
/// Deserializes the ResponseData to the specified type.
|
||||
/// Uses cached result for repeated calls.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public T? GetResponseData<T>()
|
||||
{
|
||||
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<byte> span)
|
||||
{
|
||||
uint value = 0;
|
||||
int shift = 0;
|
||||
int bytesRead = 0;
|
||||
var shift = 0;
|
||||
var bytesRead = 0;
|
||||
|
||||
while (bytesRead < span.Length)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 { }
|
||||
|
||||
/// <summary>
|
||||
/// 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[].
|
||||
/// </summary>
|
||||
[AcBinarySerializable]
|
||||
public class SignalReceiveParams : ISignalParams
|
||||
public class SignalParams : ISignalParams
|
||||
{
|
||||
public SignalResponseStatus Status { get; set; }
|
||||
public AcSerializerType DataSerializerType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Wire format: serialized byte[][] as a single byte[].
|
||||
/// AcBinary handles this as byte[] fast-path (zero-copy potential).
|
||||
/// Use SetParameterValues/GetParameterValues for typed access.
|
||||
/// </summary>
|
||||
public byte[]? Parameters { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached deserialized byte[][] from Parameters.
|
||||
/// </summary>
|
||||
private byte[][]? _parameterValues;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public object[]? GetParameterValues(ParameterInfo[] paramInfos)
|
||||
{
|
||||
if (paramInfos is not { Length: > 0 })
|
||||
return null;
|
||||
|
||||
// Deserialize byte[] → byte[][] (cached)
|
||||
_parameterValues ??= Parameters is { Length: > 0 }
|
||||
? Parameters.BinaryTo<byte[][]>()
|
||||
: 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TResponse>()`, 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
|
||||
|
|
|
|||
|
|
@ -98,103 +98,6 @@ public static class SignalRSerializationHelper
|
|||
return data.BinaryTo<T>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static byte[] SerializeParametersToBinary(object[] parameters, AcBinarySerializerOptions? options = null)
|
||||
{
|
||||
var opts = options ?? AcBinarySerializerOptions.Default;
|
||||
var writer = new ArrayBufferWriter<byte>(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<byte>(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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize method parameters from length-prefixed binary format, using target types.
|
||||
/// </summary>
|
||||
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<int>(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<byte> 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<byte> 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
|
||||
|
|
|
|||
|
|
@ -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<T>()` dispatches on `DataSerializerType`: Binary → `BinaryTo<T>()`, JsonGZip → decompress → `JsonTo<T>()`.
|
||||
`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<T>()` dispatches on `DataSerializerType`: Binary → `BinaryTo<T>()`, JsonGZip → decompress → `JsonTo<T>()`.
|
||||
|
||||
## Request/Response Flow
|
||||
|
||||
|
|
@ -93,30 +108,47 @@ Custom `IHubProtocol` (`"acbinary"`), replaces JSON. Zero-copy via `BufferWriter
|
|||
|
||||
```
|
||||
1. PostAsync<T>(tag, param) / PostAsync<T>(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<Task>→await
|
||||
│ └─ GetResponseData<T>(): dispatches on DataSerializerType
|
||||
│ Binary→BinaryTo<T>(), JsonGZip→Decompress→JsonTo<T>()
|
||||
└─ 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<byte[][]>() (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` |
|
||||
|
|
|
|||
|
|
@ -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<T>()`. |
|
||||
| **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<T>()`. |
|
||||
| **SignalPostJsonDataMessage** | OBSOLETE — removed. Legacy: serialized params to JSON inside Binary envelope. |
|
||||
| **AcSignalRDataSource** | Generic real-time `IList<T>` 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. |
|
||||
|
|
|
|||
Loading…
Reference in New Issue