12 KiB
SignalR Communication
The transport layer for AyCode real-time communication. Source: AyCode.Services/SignalRs/ (client), AyCode.Services.Server/SignalRs/ (server).
For the change-tracked collection built on top of this transport, see
SIGNALR_DATASOURCE.md.
Design Overview
All communication flows through a single hub method with tag-based dispatch:
Client ──OnReceiveMessage(tag, bytes, requestId)──► Server
Client ◄──OnReceiveMessage(tag, bytes, requestId)── Server
OnReceiveMessage is the only transport channel — all calls go through it. The tag (int) determines which server method to invoke. The DynamicMethodRegistry maps the tag to a concrete [SignalR(tag)]-annotated method at runtime. Projects call any endpoint by specifying the tag; the server dispatches accordingly.
Client side: Server side:
───────────────────── ─────────────────────
AcSignalRClientBase AcWebSignalRHubBase<TTags, TLogger>
├─ HubConnection (WebSocket) ├─ Hub<IAcSignalRHubItemServer>
├─ AcBinaryHubProtocol ├─ DynamicMethodRegistry
├─ Pending request tracking ├─ Parameter deserialization
└─ Response callbacks └─ Broadcast to other clients
Tag System
Tag Definition
AyCode.Core defines only the base class and built-in infrastructure tags:
public class AcSignalRTags
{
public const int None = 0;
public const int PingTag = 90001;
public const int EchoTag = 90002;
}
Consuming projects define their own tags by inheriting AcSignalRTags:
public abstract class MyProjectTags : AcSignalRTags
{
public const int GetOrders = 100;
public const int GetOrderById = 101;
public const int SaveOrder = 102;
// ... project-specific tags
}
Tags are plain int constants. The project decides the numbering. Tags must be unique across all registered services within a hub.
Tag Attributes
Three attribute levels:
// Base: maps an integer tag to a method
[Tag(42)]
// Server method: tag + optional broadcast behavior
[SignalR(messageTag: 100, sendToOtherClientTag: 100, sendToOtherClientType: SendToClientType.Others)]
// messageTag: incoming tag this method handles
// sendToOtherClientTag: tag used when broadcasting result to other clients (can differ from messageTag)
// sendToOtherClientType: who receives the broadcast
// Client receive: marks method for server→client dispatch
[SignalRSendToClient(100)]
SendToClientType: None (no broadcast), Others (all except caller), Caller (response only), All (everyone).
How Projects Use Tags
Projects call the SignalR transport directly — not only through DataSource:
// Direct call: project code calls any endpoint by tag
var orders = await signalRClient.PostAsync<List<Order>>(MyTags.GetOrders, companyId);
var order = await signalRClient.GetByIdAsync<Order>(MyTags.GetOrderById, orderId);
await signalRClient.PostDataAsync(MyTags.SaveOrder, order);
// Async callback (non-blocking)
await signalRClient.PostDataAsync(MyTags.SaveOrder, order, async response => { ... });
// Fire-and-forget
await signalRClient.PostDataAsync(MyTags.SaveOrder, order, response => { ... });
The AcSignalRClientBase CRUD helpers (PostAsync, GetByIdAsync, GetAllAsync, PostDataAsync) are all generic transport methods that work with any tag — they are not tied to DataSource.
Dynamic Method Dispatch
See also: AyCode.Models.Server/DynamicMethods/README.md
Server-Side Lookup
1. OnReceiveMessage(tag=100, bytes, requestId)
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
3. AcMethodInfoModel contains:
├─ 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. Note: AcDynamicMethodCallModel uses a FrozenDictionary per-type cache internally, but the registry's own lookup path does direct method scanning with ConcurrentDictionary caching.
Registration
The hub registers service instances during initialization:
DynamicMethodRegistry.Register(myService); // scans [SignalR(tag)] methods lazily
DynamicMethodRegistry.Register(anotherService);
Reflection runs lazily per tag on first request, then results are cached statically.
Wire Protocol
AcBinaryHubProtocol
Custom IHubProtocol (name: "acbinary"), replaces default JSON. Frame format:
[4 bytes: payload length, little-endian] [1 byte: message type] [payload...]
Message types: Invocation(1), StreamItem(2), Completion(3), Ping(6), Close(7), Ack(8), Sequence(9).
Arguments serialized individually with VarUInt length prefix. Direct write to IBufferWriter via BufferWriterBinaryOutput.
Response Message
SignalResponseDataMessage carries:
| Field | Type | Purpose |
|---|---|---|
MessageTag |
int | Tag identifying the operation |
Status |
SignalResponseStatus | Success/Error |
ResponseData |
byte[] | Binary-serialized payload (or GZip+JSON fallback) |
DataSerializerType |
AcSerializerType | Binary or Json |
Binary mode (default): AcBinarySerializer.ToBinary(data) → raw bytes.
JSON fallback: ToJson(data) → GzipHelper.Compress(json) → bytes.
Request/Response Flow
Client → Server
1. PostAsync<T>(tag, postData) or PostDataAsync(tag, data, callback)
2. CreatePostMessage(postData):
├─ Primitives/strings/enums/value types → IdMessage (JSON array of serialized values)
└─ Complex objects → SignalPostJsonDataMessage<T> ⚠️ tech debt: JSON-in-Binary
3. SerializeToBinary(message) wraps in Binary envelope
4. HubConnection.SendAsync("OnReceiveMessage", tag, bytes, requestId)
5. AcBinaryHubProtocol frames it on the wire
Server Processing
6. OnReceiveMessage(tag, bytes, requestId)
7. DynamicMethodRegistry.GetMethodByMessageTag(tag) ← ConcurrentDictionary lookup
8. DeserializeParameters(bytes):
├─ DeserializeFromBinary<SignalPostJsonMessage>() ← unwrap Binary envelope
├─ IdMessage format? → parse each Ids[i] as JSON per parameter type
└─ Complex object? → json.JsonTo(paramType) ⚠️ tech debt: JSON parse
9. MethodInfo.InvokeMethod(instance, params) ← unwraps Task/ValueTask
10. CreateResponseMessage(tag, Success, result) ← pure Binary serialization
11. ResponseToCaller(tag, message, requestId)
12. If SendToOtherClientType != None:
└─ SendMessageToOthers(sendToOtherClientTag, result) ← uses sendToOtherClientTag, not messageTag
Server → Client
13. Client.OnReceiveMessage(tag, bytes, requestId)
14. Has matching requestId in pending ConcurrentDictionary?
├─ YES (response to own request):
│ 15. DeserializeFromBinary<SignalResponseDataMessage>(bytes)
│ 16. Route based on pending request type:
│ ├─ null (sync wait) → set ResponseByRequestId, WaitToAsync completes
│ ├─ Action<SignalResponseDataMessage> → invoke directly
│ └─ Func<SignalResponseDataMessage, Task> → invoke and await
│ 17. GetResponseData<T>():
│ ├─ Binary: ResponseData.BinaryTo<T>()
│ └─ JSON: GzipDecompress → AcJsonDeserializer.Deserialize<T>()
│
└─ NO (broadcast from another client's action):
18. abstract MessageReceived(tag, bytes).Forget()
└─ Consuming project overrides to handle server push
Broadcast receive: When the server broadcasts to Others/All, receiving clients have no matching requestId. The message falls through to MessageReceived(int messageTag, byte[] messageBytes) — an abstract method the consuming project overrides to handle unsolicited server pushes (e.g., refresh UI, update local state).
Request model pooling: SignalRRequestModel instances are managed via SignalRRequestModelPool (ObjectPool + IResettable) to avoid allocations per request.
Response Patterns
| Pattern | Method | Blocking |
|---|---|---|
| Sync wait | PostAsync<T>(tag, data) |
Yes, polls up to TransportSendTimeout (60s) |
| Async callback | PostDataAsync(tag, data, async msg => {...}) |
No, Func<SignalResponseDataMessage, Task> |
| Fire-and-forget | PostDataAsync(tag, data, msg => {...}) |
No, Action<SignalResponseDataMessage> |
Session Management
AcSessionService<TSessionItem, TSessionItemId> tracks connected clients:
ConcurrentDictionary<TSessionItemId, TSessionItem> Sessions
IAcSessionItem<TSessionItemId> requires SessionId property. Used for targeting messages to specific users/connections.
Broadcast Service
AcSignalRSendToClientService<THub, TTags, TLogger> provides server-push methods:
| Method | Target |
|---|---|
SendMessageToAllClients |
All connected |
SendMessageToConnection(connectionId) |
Single connection |
SendMessageToUser(userId) |
User (all connections) |
SendMessageToUsers(userIds) |
Multiple users |
All messages wrapped in SignalResponseDataMessage → binary serialized → OnReceiveMessage.
Connection Lifecycle
Client configuration:
- Transport: WebSockets only (
SkipNegotiation = true) - Buffer: 30MB transport + 30MB application
- Reconnection:
WithAutomaticReconnect()+WithStatefulReconnect() - Keepalive: 60s interval, 180s server timeout
Hub events:
OnConnectedAsync()— log connectionOnDisconnectedAsync(exception)— log disconnection, cleanup session
Diagnostics
Enable with AcSignalRClientBase.EnableBinaryDiagnostics = true or 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.
Known Technical Debt
JSON-in-Binary request parameters: Client→server requests currently wrap parameters in JSON inside the binary envelope (SignalPostJsonDataMessage). This adds an unnecessary serialization round-trip. Responses are already pure binary. Do NOT attempt to fix as a side effect — requires coordinated changes across all consuming projects.
Key Source Files
| Component | Path |
|---|---|
| Hub base | AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs |
| Client base | AyCode.Services/SignalRs/AcSignalRClientBase.cs |
| Binary protocol | AyCode.Services/SignalRs/AcBinaryHubProtocol.cs |
| Message types | AyCode.Services/SignalRs/IAcSignalRHubClient.cs |
| Serialization | AyCode.Services/SignalRs/SignalRSerializationHelper.cs |
| Tag attributes | AyCode.Services/SignalRs/SignalMessageTagAttribute.cs |
| Base tags | AyCode.Services/SignalRs/AcSignalRTags.cs |
| Session service | AyCode.Services.Server/SignalRs/AcSessionService.cs |
| Broadcast service | AyCode.Services.Server/SignalRs/AcSignalRSendToClientService.cs |
| Dynamic dispatch | AyCode.Models.Server/DynamicMethods/AcDynamicMethodRegistry.cs |