8.4 KiB
SignalR Architecture
This is the core communication layer of the AyCode framework. The dispatch mechanism is not self-evident — read this document before modifying SignalR-related code.
Overview
AyCode uses a single-method, tag-based RPC dispatch pattern over SignalR instead of the standard Hub method-per-endpoint approach.
Both client→server and server→client communication go through one method:
OnReceiveMessage(int messageTag, byte[] messageBytes, int? requestId)
The messageTag integer determines which method to invoke. The messageBytes contain the serialized parameters (Binary envelope). The optional requestId enables request/response correlation.
Why Not Standard SignalR Hub Methods?
Standard SignalR hubs expose one C# method per endpoint. This approach:
- Requires maintaining proxy interfaces on both sides
- Makes it hard to dynamically register/unregister endpoints
- Adds friction for CRUD-heavy data sources (5 methods × N entities)
The tag-based approach:
- Single transport method — no interface synchronization needed
- Dynamic dispatch — methods discovered via reflection + attributes at startup
- CRUD bundles —
SignalRCrudTagsmaps 5 tags per entity automatically - Binary protocol —
AcBinaryHubProtocolreplaces JSON+Base64 withAcBinarySerializer
Message Flow
Client → Server (Request)
1. Client calls PostAsync<TResponse>(tag, postData)
└─ AcSignalRClientBase.cs
2. CreatePostMessage() wraps postData:
├─ IdMessage — for primitive IDs (Guid, int, string)
└─ SignalPostJsonDataMessage<T> — for complex objects
└─ PostData setter → PostDataJson = _postData.ToJson() ⚠️ TECH DEBT
└─ Result serialized to Binary via SignalRSerializationHelper.SerializeToBinary()
3. SendMessageToServerAsync(tag, binaryBytes, requestId)
└─ HubConnection.InvokeAsync("OnReceiveMessage", tag, bytes, reqId)
4. AcBinaryHubProtocol encodes as raw binary frame (not JSON+Base64)
Server Processing
5. AcWebSignalRHubBase.OnReceiveMessage(tag, bytes, requestId)
6. ProcessOnReceiveMessage():
├─ TryFindAndInvokeMethod(tag) via DynamicMethodRegistry
│ ├─ Static ConcurrentDictionary cache (per tag)
│ └─ FrozenDictionary per-instance-type (built once, immutable)
│
├─ DeserializeParameters(methodInfo, bytes):
│ ├─ DeserializeFromBinary<SignalPostJsonMessage>() — unwrap Binary envelope
│ ├─ Extract PostDataJson string ⚠️ TECH DEBT
│ └─ AcJsonDeserializer.Deserialize() per parameter ⚠️ TECH DEBT
│
└─ Invoke method with deserialized parameters
Server → Client (Response)
7. Method returns result
8. CreateResponseMessage(tag, result, serializerOptions):
└─ SignalResponseDataMessage serializes result:
├─ Binary mode: AcBinarySerializer directly (no JSON step)
└─ JSON mode: JSON + GZip compression
9. ResponseToCaller(responseMessage)
└─ Clients.Caller.OnReceiveMessage(tag, bytes, requestId)
Client Response Processing
10. AcSignalRClientBase.OnReceiveMessage(tag, bytes, requestId)
├─ Lookup pending request by requestId
├─ DeserializeFromBinary<SignalResponseDataMessage>()
└─ Complete TaskCompletionSource → caller gets TResponse
11. SignalResponseDataMessage.GetResponseData<T>():
├─ Binary: ResponseData.BinaryTo<T>() — direct, no JSON
└─ JSON: GZip decompress → AcJsonDeserializer (pooled buffer)
⚠️ Technical Debt: JSON-in-Binary Parameter Serialization
Status: Temporary — planned for replacement with pure Binary serialization.
Current behavior: Request parameters (client→server) go through a JSON→Binary→JSON round-trip:
| Step | Location | What happens |
|---|---|---|
| 1 | SignalPostJsonDataMessage<T> setter |
PostDataJson = _postData.ToJson() — object → JSON string |
| 2 | SignalRSerializationHelper.SerializeToBinary() |
JSON string wrapped in Binary envelope |
| 3 | AcWebSignalRHubBase.DeserializeParameters() |
Binary → SignalPostJsonMessage → PostDataJson → JSON parse per param |
Why it exists: The Binary serializer was added after the SignalR layer was built. Responses were migrated to pure Binary, but request parameters still use the original JSON path.
Impact: Extra serialization overhead on every client→server request. Responses are already pure Binary (no JSON overhead).
Planned fix: Replace SignalPostJsonDataMessage<T> with direct Binary parameter serialization, matching how responses already work.
Key files involved:
AyCode.Services/SignalRs/IAcSignalRHubClient.cs—SignalPostJsonDataMessage<T>.PostDatasetter (line ~89)AyCode.Services/SignalRs/AcSignalRClientBase.cs—CreatePostMessage()(line ~220)AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs—DeserializeParameters()(line ~340)
Rule: Do NOT attempt to fix the JSON-in-Binary pattern as a side effect of other work. It requires coordinated changes across client, server, and all consuming projects.
Tag System
Tag Attributes
// Base: associates an integer tag with a method
[Tag(42)]
// Server method: tag + optional client notification behavior
[SignalR(42, SendToClientType.Others)]
// Client receive: marks method for server→client dispatch
[SignalRSendToClient(42)]
Built-in Tags
| Tag | Constant | Purpose |
|---|---|---|
| 0 | AcSignalRTags.None |
No tag / unset |
| 90001 | AcSignalRTags.PingTag |
Keep-alive ping |
| 90002 | AcSignalRTags.EchoTag |
Echo test |
CRUD Tag Bundles
SignalRCrudTags maps 5 sequential tags per entity:
var tags = new SignalRCrudTags(300); // base tag
// tags.GetAll = 300
// tags.GetItem = 301
// tags.AddTag = 302
// tags.UpdateTag = 303
// tags.RemoveTag = 304
GetMessageTagByTrackingState() maps TrackingState → appropriate tag for automatic CRUD dispatch.
Dynamic Method Registry
See also: AyCode.Models.Server/DynamicMethods/README.md
Lookup Flow
1. OnReceiveMessage(tag=42, ...)
2. DynamicMethodRegistry.TryFindMethod(tag)
├─ Check static ConcurrentDictionary<int, AcMethodInfoModel> cache
├─ Miss? Scan registered instances:
│ └─ AcDynamicMethodCallModel.GetMethodByTag(tag)
│ └─ FrozenDictionary<int, AcMethodInfoModel> (built once per type)
└─ Cache result for future lookups
3. AcMethodInfoModel contains:
├─ MethodInfo (reflection)
├─ TagAttribute (tag value, metadata)
└─ ParamInfos[] (parameter types for deserialization)
Caching Strategy
- Per-type:
FrozenDictionarybuilt once viaConcurrentDictionary.GetOrAdd()— immutable after creation - Global:
ConcurrentDictionary<int, AcMethodInfoModel>— populated lazily per tag - Instance resolution: Registry tracks which instance owns which tag for invocation
DataSource Pattern
AcSignalRDataSource<T, TId, TIList> provides a server-side IList<T> with:
- Change tracking:
ChangeTracking<T, TId>wraps items inTrackingItem<T, TId>(Added/Modified/Deleted/Unchanged) - SignalR CRUD: Uses
SignalRCrudTagsfor automatic tag mapping - Binary merge:
BinaryToMerge()deserializes and applies changes to the local list - Load from response:
LoadDataSourceFromResponseData()populates fromSignalResponseDataMessage
Binary Hub Protocol
AcBinaryHubProtocol (IHubProtocol) replaces SignalR's default JSON protocol:
- All 9 SignalR message types handled (Invocation, StreamItem, Completion, Ping, Close, CancelInvocation, StreamInvocation, Ack, Sequence)
- Inner
SpanReaderref struct for zero-allocation parsing TransferFormat.Binary— no Base64 encoding needed- Protocol name:
"acbinary"
Testing Infrastructure
Test classes bypass real SignalR connections:
| Class | Purpose |
|---|---|
TestableSignalRClient2 |
Client that calls hub directly (no network) |
TestableSignalRHub2 |
Hub that processes messages without real HubCallerContext |
TestSignalRTags |
100+ tag constants for test scenarios |
TestSignalRService2 |
[SignalR(tag)] attributed test methods |
Test data flows through the full serialization pipeline (Binary envelope, tag dispatch, parameter deserialization) — only the network transport is bypassed.