AyCode.Core/AyCode.Services/docs/SIGNALR.md

196 lines
7.8 KiB
Markdown

# SignalR Client
Client-side SignalR transport with custom binary protocol and tag-based dispatch. Source: `SignalRs/` in this project.
> For server-side hub, session, broadcast see [`AyCode.Services.Server/docs/SIGNALR_SERVER.md`](../../AyCode.Services.Server/docs/SIGNALR_SERVER.md).
> For the DataSource collection see [`AyCode.Services.Server/docs/SIGNALR_DATASOURCE.md`](../../AyCode.Services.Server/docs/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**. 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:
```csharp
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`:
```csharp
public abstract class MyProjectTags : AcSignalRTags
{
public const int GetOrders = 100;
public const int GetOrderById = 101;
public const int SaveOrder = 102;
}
```
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:
```csharp
// Base: maps an integer tag to a method
[Tag(42)]
// Server method: tag + optional broadcast behavior
[SignalR(messageTag: 100, sendToOtherClientTag: 100, sendToOtherClientType: SendToClientType.Others)]
// 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:
```csharp
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 CRUD helpers (`PostAsync`, `GetByIdAsync`, `GetAllAsync`, `PostDataAsync`) are all generic transport methods that work with any tag — they are not tied to DataSource.
## 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 → 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.
**Request model pooling:** `SignalRRequestModel` instances are managed via `SignalRRequestModelPool` (ObjectPool<T> + 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>` |
## Connection Lifecycle
**Client configuration:**
- Transport: WebSockets only (`SkipNegotiation = true`)
- Buffer: 30MB transport + 30MB application
- Reconnection: `WithAutomaticReconnect()` + `WithStatefulReconnect()`
- Keepalive: 60s interval, 180s server timeout
## Diagnostics
Enable with `AcSignalRClientBase.EnableBinaryDiagnostics = true`.
Logs: hex dump (500 byte sample), header parsing (version, marker), property count + names via VarUInt reading.
## 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 |
|-----------|------|
| Client base | `SignalRs/AcSignalRClientBase.cs` |
| Binary protocol | `SignalRs/AcBinaryHubProtocol.cs` |
| Tag attributes | `SignalRs/SignalMessageTagAttribute.cs` |
| Base tags | `SignalRs/AcSignalRTags.cs` |
| CRUD tags | `SignalRs/SignalRCrudTags.cs` |
| SendToClientType | `SignalRs/SendToClientType.cs` |
| Message types | `SignalRs/IAcSignalRHubClient.cs` |
| Serialization | `SignalRs/SignalRSerializationHelper.cs` |