218 lines
8.4 KiB
Markdown
218 lines
8.4 KiB
Markdown
# 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** — `SignalRCrudTags` maps 5 tags per entity automatically
|
||
- **Binary protocol** — `AcBinaryHubProtocol` replaces JSON+Base64 with `AcBinarySerializer`
|
||
|
||
## 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>.PostData` setter (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
|
||
|
||
```csharp
|
||
// 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:
|
||
|
||
```csharp
|
||
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`](../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:** `FrozenDictionary` built once via `ConcurrentDictionary.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 in `TrackingItem<T, TId>` (Added/Modified/Deleted/Unchanged)
|
||
- **SignalR CRUD:** Uses `SignalRCrudTags` for automatic tag mapping
|
||
- **Binary merge:** `BinaryToMerge()` deserializes and applies changes to the local list
|
||
- **Load from response:** `LoadDataSourceFromResponseData()` populates from `SignalResponseDataMessage`
|
||
|
||
## 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 `SpanReader` ref 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.
|