AyCode.Core/docs/SIGNALR_ARCHITECTURE.md

8.4 KiB
Raw Blame History

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 bundlesSignalRCrudTags maps 5 tags per entity automatically
  • Binary protocolAcBinaryHubProtocol 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 → SignalPostJsonMessagePostDataJson → 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.csSignalPostJsonDataMessage<T>.PostData setter (line ~89)
  • AyCode.Services/SignalRs/AcSignalRClientBase.csCreatePostMessage() (line ~220)
  • AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.csDeserializeParameters() (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: 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.