AyCode.Core/docs/SIGNALR_ARCHITECTURE.md

218 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.