AyCode.Core/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs

374 lines
16 KiB
C#

using System.Security.Claims;
using AyCode.Core;
using AyCode.Core.Extensions;
using AyCode.Core.Helpers;
using AyCode.Core.Loggers;
using AyCode.Core.Serializers;
using AyCode.Core.Serializers.Binaries;
using AyCode.Models.Server.DynamicMethods;
using AyCode.Services.SignalRs;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Configuration;
namespace AyCode.Services.Server.SignalRs;
public abstract class AcWebSignalRHubBase<TSignalRTags, TLogger>(IConfiguration configuration, TLogger logger)
: Hub<IAcSignalRHubItemServer>, IAcSignalRHubServer where TSignalRTags : AcSignalRTags where TLogger : AcLoggerBase
{
/// <summary>
/// Registry for dynamic method lookups with lazy initialization.
/// </summary>
protected readonly AcDynamicMethodRegistry<SignalRAttribute> DynamicMethodRegistry = new();
protected TLogger Logger = logger;
protected IConfiguration Configuration = configuration;
// Static init: route protocol & deserializer diagnostics through AcLoggerBase
private static readonly object _diagnosticInitLock = new();
private static bool _diagnosticInitialized;
protected void InitDiagnosticLoggerIfNeeded()
{
if (_diagnosticInitialized) return;
lock (_diagnosticInitLock)
{
if (_diagnosticInitialized) return;
AcBinaryHubProtocol.DiagnosticLogger ??= msg => Logger.Debug(msg);
AcBinaryDeserializer.DiagnosticLogger ??= msg => Logger.Debug(msg);
_diagnosticInitialized = true;
}
}
protected AcSerializerOptions SerializerOptions = new AcBinarySerializerOptions();
/// <summary>
/// Enable diagnostic logging for binary serialization debugging.
/// Set to true to log hex dumps of serialized response data.
/// </summary>
public static bool EnableBinaryDiagnostics { get; set; }
#region Connection Lifecycle
public override async Task OnConnectedAsync()
{
InitDiagnosticLoggerIfNeeded();
Logger.Debug($"Server OnConnectedAsync; ConnectionId: {GetConnectionId()}; UserIdentifier: {GetUserIdentifier()}");
LogContextUserNameAndId();
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var connectionId = GetConnectionId();
var userIdentifier = GetUserIdentifier();
if (exception == null)
Logger.Debug($"Server OnDisconnectedAsync; ConnectionId: {connectionId}; UserIdentifier: {userIdentifier}");
else
Logger.Error($"Server OnDisconnectedAsync; ConnectionId: {connectionId}; UserIdentifier: {userIdentifier}", exception);
LogContextUserNameAndId();
await base.OnDisconnectedAsync(exception);
}
#endregion
#region Message Processing
public virtual Task OnReceiveMessage(int messageTag, int? requestId, SignalParams signalParams, object data)
{
return ProcessOnReceiveMessage(messageTag, signalParams, requestId, null);
}
public virtual IAsyncEnumerable<byte[]> OnReceiveStreamMessage(int messageTag, byte[]? messageBytes)
{
var parameterBytes = messageBytes is { Length: > 0 }
? SignalRSerializationHelper.DeserializeFromBinary<byte[][]>(messageBytes)
: null;
return ProcessOnStreamMessage(messageTag, parameterBytes, Context.ConnectionAborted);
}
protected virtual async IAsyncEnumerable<byte[]> ProcessOnStreamMessage(int messageTag, byte[][]? parameterBytes, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
var tagName = ConstHelper.NameByValue<TSignalRTags>(messageTag);
Logger.Debug($"Server OnReceiveStreamMessage; ConnectionId: {GetConnectionId()}; {tagName}");
try
{
if (AcDomain.IsDeveloperVersion) LogContextUserNameAndId();
// Build SignalParams from raw byte[][] for stream path
var signalParams = new SignalParams { Status = SignalResponseStatus.Success };
if (parameterBytes is { Length: > 0 })
signalParams.Parameters = parameterBytes.ToBinary();
if (TryFindAndInvokeMethod(messageTag, signalParams, tagName, out var responseData))
{
if (responseData == null)
yield break;
var resultType = responseData.GetType();
var elementType = GetAsyncEnumerableElementType(resultType);
if (elementType != null)
{
var typedEnumerable = GetTypedStream(elementType, responseData, messageTag, cancellationToken);
await foreach (var chunk in typedEnumerable.WithCancellation(cancellationToken))
{
yield return chunk;
}
}
else
{
Logger.Warning($"Method '{tagName}' does not return IAsyncEnumerable. Returning normal message as single chunk.");
var responseMessage = CreateStreamResponseMessage(messageTag, SignalResponseStatus.Success, responseData);
yield return SignalRSerializationHelper.SerializeToBinary(responseMessage);
}
}
else
{
Logger.Warning($"Not found dynamic method for the tag! {tagName}");
}
}
finally
{
Logger.Debug($"Server closed OnReceiveStreamMessage; ConnectionId: {GetConnectionId()}; {tagName}");
}
}
private static readonly System.Collections.Concurrent.ConcurrentDictionary<Type, System.Reflection.MethodInfo> _streamMethods = new();
private IAsyncEnumerable<byte[]> GetTypedStream(Type elementType, object responseData, int messageTag, CancellationToken ct)
{
var methodInfo = _streamMethods.GetOrAdd(elementType, type =>
typeof(AcWebSignalRHubBase<TSignalRTags, TLogger>)
.GetMethod(nameof(EnumerateGenericAsync), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.MakeGenericMethod(type));
return (IAsyncEnumerable<byte[]>)methodInfo.Invoke(this, [responseData, messageTag, ct])!;
}
private async IAsyncEnumerable<byte[]> EnumerateGenericAsync<T>(object result, int messageTag, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
var enumerable = (IAsyncEnumerable<T>)result;
await foreach (var item in enumerable.WithCancellation(cancellationToken))
{
if (item is byte[] bytes)
{
yield return bytes;
}
else if (item is ISignalRMessage sigMsg)
{
yield return SignalRSerializationHelper.SerializeToBinary(sigMsg);
}
else
{
var msg = CreateStreamResponseMessage(messageTag, SignalResponseStatus.Success, item);
yield return SignalRSerializationHelper.SerializeToBinary(msg);
}
}
}
private static Type? GetAsyncEnumerableElementType(Type type)
{
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IAsyncEnumerable<>))
return type.GetGenericArguments()[0];
foreach (var intf in type.GetInterfaces())
{
if (intf.IsGenericType && intf.GetGenericTypeDefinition() == typeof(IAsyncEnumerable<>))
return intf.GetGenericArguments()[0];
}
return null;
}
protected virtual async Task ProcessOnReceiveMessage(int messageTag, SignalParams signalParams, int? requestId, Func<string, Task>? notFoundCallback)
{
var tagName = ConstHelper.NameByValue<TSignalRTags>(messageTag);
Logger.Debug($"Server OnReceiveMessage; requestId: {requestId}; ConnectionId: {GetConnectionId()}; {tagName}");
try
{
if (AcDomain.IsDeveloperVersion) LogContextUserNameAndId();
if (TryFindAndInvokeMethod(messageTag, signalParams, tagName, out var responseData))
{
if (Logger.LogLevel <= LogLevel.Debug)
Logger.Debug($"responseData ready ({SerializerOptions.SerializerType})");
await ResponseToCaller(messageTag, SignalResponseStatus.Success, responseData, requestId, signalParams);
return;
}
Logger.Warning($"Not found dynamic method for the tag! {tagName}");
notFoundCallback?.Invoke(tagName);
}
catch (Exception ex)
{
Logger.Error($"Server OnReceiveMessage; {ex.Message}; {tagName}", ex);
}
await ResponseToCaller(messageTag, SignalResponseStatus.Error, null, requestId);
}
/// <summary>
/// Creates a SignalResponseDataMessage for stream path (serialized as wire format blob).
/// Main send path uses SendMessageToClient directly — no wrapper needed.
/// </summary>
protected SignalResponseDataMessage CreateStreamResponseMessage(int messageTag, SignalResponseStatus status, object? responseData)
=> new(messageTag, status, responseData, SerializerOptions);
/// <summary>
/// Finds and invokes the method registered for the given message tag.
/// </summary>
private bool TryFindAndInvokeMethod(int messageTag, SignalParams signalParams, string tagName, out object? responseData)
{
responseData = null;
var result = DynamicMethodRegistry.GetMethodByMessageTag(messageTag);
if (!result.HasValue)
return false;
var (instance, methodInfoModel) = result.Value;
var methodName = $"{instance.GetType().Name}.{methodInfoModel.MethodInfo.Name}";
var paramValues = DeserializeParameters(signalParams, methodInfoModel, tagName, methodName);
Logger.Debug(paramValues == null
? $"Found dynamic method for the tag! method: {methodName}(); {tagName}"
: $"Found dynamic method for the tag! method: {methodName}({string.Join(", ", methodInfoModel.ParamInfos.Select(x => x.Name))}); {tagName}");
responseData = methodInfoModel.MethodInfo.InvokeMethod(instance, paramValues);
if (methodInfoModel.Attribute.SendToOtherClientType != SendToClientType.None)
SendMessageToOthers(methodInfoModel.Attribute.SendToOtherClientTag, responseData).Forget();
return true;
}
/// <summary>
/// Deserializes parameters from SignalParams using GetParameterValues.
/// Validates that required parameters are present.
/// </summary>
private static object[]? DeserializeParameters(SignalParams signalParams, AcMethodInfoModel<SignalRAttribute> methodInfoModel, string tagName, string methodName)
{
var paramInfos = methodInfoModel.ParamInfos;
if (paramInfos is not { Length: > 0 })
return null;
var paramValues = signalParams.GetParameterValues(paramInfos);
if (paramValues is null)
{
if (paramInfos.All(p => p.HasDefaultValue))
return paramInfos.Select(p => p.DefaultValue).ToArray();
throw new ArgumentException($"Message has no parameters but method '{methodName}' requires parameters; {tagName}");
}
// Validate: null in a non-optional parameter slot means it was missing
for (var i = 0; i < paramInfos.Length; i++)
{
if (paramValues[i] is null && !paramInfos[i].HasDefaultValue && paramInfos[i].ParameterType.IsValueType)
throw new ArgumentException($"Method '{methodName}' requires parameter '{paramInfos[i].Name}' (index {i}) but it was not sent; {tagName}");
}
return paramValues;
}
#endregion
#region Response Methods
protected virtual Task ResponseToCallerWithContent(int messageTag, object? content)
=> SendMessageToClient(Clients.Caller, messageTag, SignalResponseStatus.Success, content);
protected virtual Task ResponseToCaller(int messageTag, SignalResponseStatus status, object? responseData, int? requestId, SignalParams? clientSignalParams = null)
=> SendMessageToClient(Clients.Caller, messageTag, status, responseData, requestId, clientSignalParams);
protected virtual Task SendMessageToUserIdWithContent(string userId, int messageTag, object? content)
=> SendMessageToClient(Clients.User(userId), messageTag, SignalResponseStatus.Success, content);
protected virtual Task SendMessageToConnectionIdWithContent(string connectionId, int messageTag, object? content)
=> SendMessageToClient(Clients.Client(connectionId), messageTag, SignalResponseStatus.Success, content);
protected virtual Task SendMessageToOthers(int messageTag, object? content)
=> SendMessageToClient(Clients.Others, messageTag, SignalResponseStatus.Success, content);
protected virtual Task SendMessageToAll(int messageTag, object? content)
=> SendMessageToClient(Clients.All, messageTag, SignalResponseStatus.Success, content);
/// <summary>
/// Sends message to client. Protocol serializes responseData directly to pipe (zero-copy write).
/// clientSignalParams: the original client request's SignalParams (contains IsRawBytesData flag).
/// </summary>
protected virtual async Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag,
SignalResponseStatus status, object? responseData, int? requestId = null, SignalParams? clientSignalParams = null)
{
var isRawBytes = clientSignalParams?.IsRawBytesData == true;
// IsRawBytesData: client wants raw byte[] — pre-serialize here, protocol passes through as-is.
// Single serialize (here) → single deserialize (consumer). No double ser/deser.
if (isRawBytes && responseData != null && responseData is not byte[])
{
// Pass explicit runtime type — the generic ToBinary<T>() overload would infer T = object
// here (since `responseData` is statically `object?`), emitting an object-typed wire payload
// instead of the concrete type. See ACCORE-BIN bug fix 2026-05-26.
responseData = SerializerOptions.SerializerType == AcSerializerType.Binary
? responseData.ToBinary(responseData.GetType())
: AyCode.Core.Compression.GzipHelper.Compress(responseData.ToJson());
}
//responseData = AcBinarySerializer.Serialize(responseData);
// TODO: BWO serialize bug workaround — pre-serialize responseData to byte[] here so protocol uses
// byte[] fast-path, bypassing AcBinarySerializer.Serialize(value, IBufferWriter<byte>, options).
// Set isRawBytes = true so client deserializes via ArrayBinaryInput. Remove when BWO bug is fixed.
var signalParams = new SignalParams
{
Status = status,
DataSerializerType = SerializerOptions.SerializerType,
IsRawBytesData = isRawBytes
};
var tagName = ConstHelper.NameByValue<TSignalRTags>(messageTag);
Logger.Debug($"Server sending message to client; requestId: {requestId}; Aborted: {IsConnectionAborted()}; ConnectionId: {GetConnectionId()}; {tagName}");
await sendTo.OnReceiveMessage(messageTag, requestId, signalParams, responseData!);
Logger.Debug($"Server sent message to client; requestId: {requestId}; ConnectionId: {GetConnectionId()}; {tagName}");
}
#endregion
#region Context Accessor Methods
protected virtual string GetConnectionId() => Context.ConnectionId;
protected virtual bool IsConnectionAborted() => Context.ConnectionAborted.IsCancellationRequested;
protected virtual string? GetUserIdentifier() => Context.UserIdentifier;
protected virtual ClaimsPrincipal? GetUser() => Context.User;
#endregion
#region Logging
protected virtual void LogContextUserNameAndId()
{
var user = GetUser();
if (user == null) return;
var userName = user.Identity?.Name;
Guid.TryParse(user.FindFirstValue(ClaimTypes.NameIdentifier), out var userId);
if (AcDomain.IsDeveloperVersion)
Logger.WarningConditional($"SignalR.Context; userName: {userName}; userId: {userId}");
else
Logger.Debug($"SignalR.Context; userName: {userName}; userId: {userId}");
}
#endregion
}