Remove MessagePack; unify SignalR serialization model
Major refactor to eliminate MessagePack from SignalR messaging. All serialization now uses explicit binary methods (.ToBinary/.BinaryTo) and Brotli-compressed JSON, managed via a new SignalRSerializationHelper. Custom stream classes and MessagePack attributes are removed. API is now consistent, type-safe, and easier to maintain. Test code and all message handling updated to use the new model.
This commit is contained in:
parent
489ef7486c
commit
2f1c00fd5c
|
|
@ -7,7 +7,6 @@ namespace AyCode.Core.Compression;
|
|||
|
||||
/// <summary>
|
||||
/// Brotli compression/decompression helper for SignalR message transport.
|
||||
/// Used when JSON serializer is configured to reduce payload size.
|
||||
/// Optimized for zero-allocation scenarios with pooled buffers.
|
||||
/// </summary>
|
||||
public static class BrotliHelper
|
||||
|
|
@ -15,6 +14,8 @@ public static class BrotliHelper
|
|||
private const int DefaultBufferSize = 4096;
|
||||
private const int MaxStackAllocSize = 1024;
|
||||
|
||||
#region Compression
|
||||
|
||||
/// <summary>
|
||||
/// Compresses a string using Brotli compression with pooled buffers.
|
||||
/// </summary>
|
||||
|
|
@ -60,41 +61,21 @@ public static class BrotliHelper
|
|||
if (data.IsEmpty)
|
||||
return [];
|
||||
|
||||
// Estimate compressed size (typically 10-30% of original for text)
|
||||
var estimatedSize = Math.Max(data.Length / 2, 64);
|
||||
var outputBuffer = ArrayPool<byte>.Shared.Rent(estimatedSize);
|
||||
|
||||
try
|
||||
using var outputStream = new MemoryStream();
|
||||
using (var brotliStream = new BrotliStream(outputStream, compressionLevel, leaveOpen: true))
|
||||
{
|
||||
using var outputStream = new PooledMemoryStream(outputBuffer);
|
||||
using (var brotliStream = new BrotliStream(outputStream, compressionLevel, leaveOpen: true))
|
||||
{
|
||||
brotliStream.Write(data);
|
||||
}
|
||||
return outputStream.ToArray();
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(outputBuffer);
|
||||
brotliStream.Write(data);
|
||||
}
|
||||
return outputStream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compresses data directly to an IBufferWriter (zero intermediate allocation).
|
||||
/// </summary>
|
||||
public static void CompressTo(ReadOnlySpan<byte> data, IBufferWriter<byte> writer, CompressionLevel compressionLevel = CompressionLevel.Optimal)
|
||||
{
|
||||
if (data.IsEmpty)
|
||||
return;
|
||||
#endregion
|
||||
|
||||
using var outputStream = new BufferWriterStream(writer);
|
||||
using var brotliStream = new BrotliStream(outputStream, compressionLevel, leaveOpen: true);
|
||||
brotliStream.Write(data);
|
||||
}
|
||||
#region Decompression
|
||||
|
||||
/// <summary>
|
||||
/// Decompresses Brotli-compressed data to a string.
|
||||
/// Consider using Decompress + direct UTF-8 JsonTo for better performance.
|
||||
/// Consider using Decompress + direct UTF-8 deserialization for better performance.
|
||||
/// </summary>
|
||||
public static string DecompressToString(byte[] compressedData)
|
||||
{
|
||||
|
|
@ -107,63 +88,36 @@ public static class BrotliHelper
|
|||
|
||||
/// <summary>
|
||||
/// Decompresses Brotli-compressed data to a byte array.
|
||||
/// Uses pooled buffers internally for reduced GC pressure.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static byte[] Decompress(byte[] compressedData)
|
||||
{
|
||||
if (compressedData == null || compressedData.Length == 0)
|
||||
return [];
|
||||
|
||||
return DecompressSpan(compressedData.AsSpan());
|
||||
return DecompressCore(compressedData);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decompresses Brotli-compressed data from a ReadOnlySpan.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static byte[] DecompressSpan(ReadOnlySpan<byte> compressedData)
|
||||
{
|
||||
if (compressedData.IsEmpty)
|
||||
return [];
|
||||
|
||||
// Estimate decompressed size (typically 3-10x compressed for text)
|
||||
var estimatedSize = Math.Max(compressedData.Length * 4, DefaultBufferSize);
|
||||
var outputBuffer = ArrayPool<byte>.Shared.Rent(estimatedSize);
|
||||
|
||||
try
|
||||
{
|
||||
using var inputStream = new ReadOnlySpanStream(compressedData);
|
||||
using var brotliStream = new BrotliStream(inputStream, CompressionMode.Decompress);
|
||||
using var outputStream = new PooledMemoryStream(outputBuffer);
|
||||
|
||||
brotliStream.CopyTo(outputStream);
|
||||
return outputStream.ToArray();
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(outputBuffer);
|
||||
}
|
||||
return DecompressCore(compressedData.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decompresses directly into an ArrayBufferWriter for zero-copy deserialization.
|
||||
/// </summary>
|
||||
public static void DecompressTo(ReadOnlySpan<byte> compressedData, ArrayBufferWriter<byte> writer)
|
||||
private static byte[] DecompressCore(byte[] compressedData)
|
||||
{
|
||||
if (compressedData.IsEmpty)
|
||||
return;
|
||||
|
||||
using var inputStream = new ReadOnlySpanStream(compressedData);
|
||||
using var inputStream = new MemoryStream(compressedData, writable: false);
|
||||
using var brotliStream = new BrotliStream(inputStream, CompressionMode.Decompress);
|
||||
using var outputStream = new MemoryStream();
|
||||
|
||||
// Read in chunks directly to the writer
|
||||
int bytesRead;
|
||||
do
|
||||
{
|
||||
var buffer = writer.GetSpan(DefaultBufferSize);
|
||||
bytesRead = brotliStream.Read(buffer);
|
||||
if (bytesRead > 0)
|
||||
writer.Advance(bytesRead);
|
||||
} while (bytesRead > 0);
|
||||
brotliStream.CopyTo(outputStream);
|
||||
return outputStream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -175,10 +129,11 @@ public static class BrotliHelper
|
|||
if (compressedData.IsEmpty)
|
||||
return ([], 0);
|
||||
|
||||
// Estimate decompressed size (typically 3-10x compressed for text)
|
||||
var estimatedSize = Math.Max(compressedData.Length * 4, DefaultBufferSize);
|
||||
var outputBuffer = ArrayPool<byte>.Shared.Rent(estimatedSize);
|
||||
|
||||
using var inputStream = new ReadOnlySpanStream(compressedData);
|
||||
using var inputStream = new MemoryStream(compressedData.ToArray(), writable: false);
|
||||
using var brotliStream = new BrotliStream(inputStream, CompressionMode.Decompress);
|
||||
|
||||
var totalRead = 0;
|
||||
|
|
@ -189,7 +144,7 @@ public static class BrotliHelper
|
|||
totalRead += bytesRead;
|
||||
|
||||
// Need larger buffer
|
||||
if (totalRead == outputBuffer.Length)
|
||||
if (totalRead >= outputBuffer.Length - DefaultBufferSize)
|
||||
{
|
||||
var newBuffer = ArrayPool<byte>.Shared.Rent(outputBuffer.Length * 2);
|
||||
outputBuffer.AsSpan(0, totalRead).CopyTo(newBuffer);
|
||||
|
|
@ -201,6 +156,10 @@ public static class BrotliHelper
|
|||
return (outputBuffer, totalRead);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Utility
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the data appears to be Brotli compressed.
|
||||
/// </summary>
|
||||
|
|
@ -230,185 +189,5 @@ public static class BrotliHelper
|
|||
}
|
||||
}
|
||||
|
||||
#region Helper Stream Classes
|
||||
|
||||
/// <summary>
|
||||
/// MemoryStream that uses a pre-allocated buffer and can expand using ArrayPool.
|
||||
/// </summary>
|
||||
private sealed class PooledMemoryStream : Stream
|
||||
{
|
||||
private byte[] _buffer;
|
||||
private int _position;
|
||||
private int _length;
|
||||
private bool _ownsBuffer;
|
||||
|
||||
public PooledMemoryStream(byte[] initialBuffer)
|
||||
{
|
||||
_buffer = initialBuffer;
|
||||
_ownsBuffer = false;
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => true;
|
||||
public override bool CanWrite => true;
|
||||
public override long Length => _length;
|
||||
public override long Position { get => _position; set => _position = (int)value; }
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count)
|
||||
=> Write(buffer.AsSpan(offset, count));
|
||||
|
||||
public override void Write(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
EnsureCapacity(_position + buffer.Length);
|
||||
buffer.CopyTo(_buffer.AsSpan(_position));
|
||||
_position += buffer.Length;
|
||||
if (_position > _length) _length = _position;
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
var bytesToRead = Math.Min(count, _length - _position);
|
||||
if (bytesToRead <= 0) return 0;
|
||||
_buffer.AsSpan(_position, bytesToRead).CopyTo(buffer.AsSpan(offset));
|
||||
_position += bytesToRead;
|
||||
return bytesToRead;
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
{
|
||||
_position = origin switch
|
||||
{
|
||||
SeekOrigin.Begin => (int)offset,
|
||||
SeekOrigin.Current => _position + (int)offset,
|
||||
SeekOrigin.End => _length + (int)offset,
|
||||
_ => _position
|
||||
};
|
||||
return _position;
|
||||
}
|
||||
|
||||
public override void SetLength(long value) => _length = (int)value;
|
||||
public override void Flush() { }
|
||||
|
||||
public byte[] ToArray()
|
||||
{
|
||||
var result = new byte[_length];
|
||||
_buffer.AsSpan(0, _length).CopyTo(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void EnsureCapacity(int required)
|
||||
{
|
||||
if (required <= _buffer.Length) return;
|
||||
|
||||
var newSize = Math.Max(_buffer.Length * 2, required);
|
||||
var newBuffer = ArrayPool<byte>.Shared.Rent(newSize);
|
||||
_buffer.AsSpan(0, _length).CopyTo(newBuffer);
|
||||
|
||||
if (_ownsBuffer) ArrayPool<byte>.Shared.Return(_buffer);
|
||||
|
||||
_buffer = newBuffer;
|
||||
_ownsBuffer = true;
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (_ownsBuffer && _buffer != null)
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(_buffer);
|
||||
_buffer = null!;
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-only stream wrapper for ReadOnlySpan.
|
||||
/// </summary>
|
||||
private sealed class ReadOnlySpanStream : Stream
|
||||
{
|
||||
private readonly ReadOnlyMemory<byte> _data;
|
||||
private int _position;
|
||||
|
||||
public ReadOnlySpanStream(ReadOnlySpan<byte> data)
|
||||
{
|
||||
_data = data.ToArray(); // Must copy for stream usage
|
||||
}
|
||||
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => true;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => _data.Length;
|
||||
public override long Position { get => _position; set => _position = (int)value; }
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
var bytesToRead = Math.Min(count, _data.Length - _position);
|
||||
if (bytesToRead <= 0) return 0;
|
||||
_data.Span.Slice(_position, bytesToRead).CopyTo(buffer.AsSpan(offset));
|
||||
_position += bytesToRead;
|
||||
return bytesToRead;
|
||||
}
|
||||
|
||||
public override int Read(Span<byte> buffer)
|
||||
{
|
||||
var bytesToRead = Math.Min(buffer.Length, _data.Length - _position);
|
||||
if (bytesToRead <= 0) return 0;
|
||||
_data.Span.Slice(_position, bytesToRead).CopyTo(buffer);
|
||||
_position += bytesToRead;
|
||||
return bytesToRead;
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
{
|
||||
_position = origin switch
|
||||
{
|
||||
SeekOrigin.Begin => (int)offset,
|
||||
SeekOrigin.Current => _position + (int)offset,
|
||||
SeekOrigin.End => _data.Length + (int)offset,
|
||||
_ => _position
|
||||
};
|
||||
return _position;
|
||||
}
|
||||
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
public override void Flush() { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stream that writes directly to an IBufferWriter.
|
||||
/// </summary>
|
||||
private sealed class BufferWriterStream : Stream
|
||||
{
|
||||
private readonly IBufferWriter<byte> _writer;
|
||||
|
||||
public BufferWriterStream(IBufferWriter<byte> writer) => _writer = writer;
|
||||
|
||||
public override bool CanRead => false;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => true;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count)
|
||||
{
|
||||
var span = _writer.GetSpan(count);
|
||||
buffer.AsSpan(offset, count).CopyTo(span);
|
||||
_writer.Advance(count);
|
||||
}
|
||||
|
||||
public override void Write(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
var span = _writer.GetSpan(buffer.Length);
|
||||
buffer.CopyTo(span);
|
||||
_writer.Advance(buffer.Length);
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Flush() { }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ using System.Runtime.CompilerServices;
|
|||
using System.Runtime.Serialization;
|
||||
using System.Text;
|
||||
using AyCode.Core.Interfaces;
|
||||
using MessagePack;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using static AyCode.Core.Extensions.JsonUtilities;
|
||||
|
|
@ -447,16 +446,6 @@ public static class SerializeObjectExtensions
|
|||
|
||||
#endregion
|
||||
|
||||
#region MessagePack
|
||||
|
||||
public static byte[] ToMessagePack(this object message, MessagePackSerializerOptions options)
|
||||
=> MessagePackSerializer.Serialize(message, options);
|
||||
|
||||
public static T MessagePackTo<T>(this byte[] message, MessagePackSerializerOptions options)
|
||||
=> MessagePackSerializer.Deserialize<T>(message, options);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Any (JSON or Binary based on options)
|
||||
|
||||
public static object ToAny<T>(this T source, AcSerializerOptions options)
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ public static class SignalRTestHelper
|
|||
|
||||
public static T? GetResponseData<T>(SentMessage sentMessage)
|
||||
{
|
||||
if (sentMessage.Message is SignalResponseDataMessage dataResponse && dataResponse.ResponseDataBin != null)
|
||||
if (sentMessage.Message is SignalResponseDataMessage dataResponse && dataResponse.ResponseData != null)
|
||||
return dataResponse.GetResponseData<T>();
|
||||
|
||||
return default;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ using AyCode.Core.Tests.TestModels;
|
|||
using AyCode.Services.Server.SignalRs;
|
||||
using AyCode.Services.SignalRs;
|
||||
using AyCode.Services.Tests.SignalRs;
|
||||
using MessagePack.Resolvers;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
|
||||
namespace AyCode.Services.Server.Tests.SignalRs;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ using AyCode.Core.Tests.TestModels;
|
|||
using AyCode.Models.Server.DynamicMethods;
|
||||
using AyCode.Services.Server.SignalRs;
|
||||
using AyCode.Services.SignalRs;
|
||||
using MessagePack.Resolvers;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace AyCode.Services.Server.Tests.SignalRs;
|
||||
|
|
|
|||
|
|
@ -291,10 +291,10 @@ namespace AyCode.Services.Server.SignalRs
|
|||
try
|
||||
{
|
||||
var response = task.Result;
|
||||
if (response?.Status != SignalResponseStatus.Success || response.ResponseDataBin == null)
|
||||
if (response?.Status != SignalResponseStatus.Success || response.ResponseData == null)
|
||||
throw new NullReferenceException($"LoadDataSourceAsync; Status: {response?.Status}");
|
||||
|
||||
await LoadDataSourceFromResponseData(response.ResponseDataBin, response.DataSerializerType,
|
||||
await LoadDataSourceFromResponseData(response.ResponseData, response.DataSerializerType,
|
||||
false, false, clearChangeTracking);
|
||||
}
|
||||
finally
|
||||
|
|
@ -960,7 +960,7 @@ namespace AyCode.Services.Server.SignalRs
|
|||
|
||||
return SignalRClient.PostDataAsync(messageTag, item, response =>
|
||||
{
|
||||
if (response.Status != SignalResponseStatus.Success || response.ResponseDataBin == null)
|
||||
if (response.Status != SignalResponseStatus.Success || response.ResponseData == null)
|
||||
{
|
||||
if (TryRollbackItem(item.Id, out _)) return;
|
||||
throw new NullReferenceException($"SaveItemUnsafeAsync; Status: {response.Status}");
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ using AyCode.Core.Helpers;
|
|||
using AyCode.Core.Loggers;
|
||||
using AyCode.Models.Server.DynamicMethods;
|
||||
using AyCode.Services.SignalRs;
|
||||
using MessagePack;
|
||||
using MessagePack.Resolvers;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
|
|
@ -98,7 +96,6 @@ public abstract class AcWebSignalRHubBase<TSignalRTags, TLogger>(IConfiguration
|
|||
|
||||
/// <summary>
|
||||
/// Creates a response message using the configured serializer.
|
||||
/// Always creates SignalResponseDataMessage which includes the SerializerType.
|
||||
/// </summary>
|
||||
protected virtual ISignalRMessage CreateResponseMessage(int messageTag, SignalResponseStatus status, object? responseData)
|
||||
{
|
||||
|
|
@ -110,11 +107,7 @@ public abstract class AcWebSignalRHubBase<TSignalRTags, TLogger>(IConfiguration
|
|||
/// </summary>
|
||||
private static int GetResponseSize(ISignalRMessage responseMessage)
|
||||
{
|
||||
return responseMessage switch
|
||||
{
|
||||
SignalResponseDataMessage dataMsg => dataMsg.ResponseDataBin?.Length ?? 0,
|
||||
_ => 0
|
||||
};
|
||||
return responseMessage is SignalResponseDataMessage dataMsg ? dataMsg.ResponseData?.Length ?? 0 : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -150,68 +143,55 @@ public abstract class AcWebSignalRHubBase<TSignalRTags, TLogger>(IConfiguration
|
|||
|
||||
/// <summary>
|
||||
/// Deserializes parameters from the message based on method signature.
|
||||
/// Returns null if no parameters needed, or throws if message is invalid.
|
||||
/// Uses Binary serialization for message wrapper.
|
||||
/// </summary>
|
||||
private static object[]? DeserializeParameters(byte[]? message, AcMethodInfoModel<SignalRAttribute> methodInfoModel, string tagName, string methodName)
|
||||
{
|
||||
if (methodInfoModel.ParamInfos is not { Length: > 0 })
|
||||
return null;
|
||||
|
||||
// Validate message - required when method has parameters
|
||||
if (message is null or { Length: 0 })
|
||||
throw new ArgumentException($"Message is null or empty but method '{methodName}' requires {methodInfoModel.ParamInfos.Length} parameter(s); {tagName}");
|
||||
|
||||
var paramValues = new object[methodInfoModel.ParamInfos.Length];
|
||||
var firstParamType = methodInfoModel.ParamInfos[0].ParameterType;
|
||||
|
||||
// Use IdMessage format for: multiple params OR primitives/strings/enums/value types
|
||||
if (methodInfoModel.ParamInfos.Length > 1 || IsPrimitiveOrStringOrEnum(firstParamType))
|
||||
// First, try to deserialize as SignalPostJsonMessage to get raw PostDataJson
|
||||
var msgBase = SignalRSerializationHelper.DeserializeFromBinary<SignalPostJsonMessage>(message);
|
||||
if (msgBase?.PostDataJson == null || string.IsNullOrEmpty(msgBase.PostDataJson))
|
||||
{
|
||||
// Use ContractlessStandardResolver to match client serialization
|
||||
var msg = message.MessagePackTo<SignalPostJsonDataMessage<IdMessage>>(ContractlessStandardResolver.Options);
|
||||
|
||||
for (var i = 0; i < msg.PostData.Ids.Count; i++)
|
||||
{
|
||||
var paramType = methodInfoModel.ParamInfos[i].ParameterType;
|
||||
// Direct JSON deserialization using AcJsonDeserializer (supports primitives)
|
||||
paramValues[i] = AcJsonDeserializer.Deserialize(msg.PostData.Ids[i], paramType)!;
|
||||
}
|
||||
throw new ArgumentException($"Failed to deserialize message for method '{methodName}'; {tagName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Single complex object - try to detect format by checking if it's an IdMessage
|
||||
var msgJson = message.MessagePackTo<SignalPostJsonDataMessage<object>>(ContractlessStandardResolver.Options);
|
||||
var json = msgJson.PostDataJson;
|
||||
|
||||
// Check if the JSON is an IdMessage format (has "Ids" property)
|
||||
if (json.Contains("\"Ids\""))
|
||||
var json = msgBase.PostDataJson;
|
||||
|
||||
// Check if it's an IdMessage format (contains "Ids" property)
|
||||
if (json.Contains("\"Ids\""))
|
||||
{
|
||||
// Parse as IdMessage - each Id is a JSON string for a parameter
|
||||
var idMessage = json.JsonTo<IdMessage>();
|
||||
if (idMessage?.Ids != null && idMessage.Ids.Count > 0)
|
||||
{
|
||||
// It's IdMessage format - deserialize as IdMessage and get first Id
|
||||
var idMsg = message.MessagePackTo<SignalPostJsonDataMessage<IdMessage>>(ContractlessStandardResolver.Options);
|
||||
if (idMsg.PostData.Ids.Count > 0)
|
||||
for (var i = 0; i < idMessage.Ids.Count && i < methodInfoModel.ParamInfos.Length; i++)
|
||||
{
|
||||
paramValues[0] = AcJsonDeserializer.Deserialize(idMsg.PostData.Ids[0], firstParamType)!;
|
||||
return paramValues;
|
||||
var paramType = methodInfoModel.ParamInfos[i].ParameterType;
|
||||
paramValues[i] = AcJsonDeserializer.Deserialize(idMessage.Ids[i], paramType)!;
|
||||
}
|
||||
return paramValues;
|
||||
}
|
||||
|
||||
// Direct complex object format
|
||||
paramValues[0] = json.JsonTo(firstParamType)!;
|
||||
}
|
||||
|
||||
// Single complex object - deserialize directly from PostDataJson
|
||||
paramValues[0] = json.JsonTo(firstParamType)!;
|
||||
return paramValues;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if a type should use IdMessage format (primitives, strings, enums, value types).
|
||||
/// NOTE: Arrays and collections are NOT included - they use PostDataJson format when sent as single parameter.
|
||||
/// Determines if a type should use IdMessage format.
|
||||
/// </summary>
|
||||
private static bool IsPrimitiveOrStringOrEnum(Type type)
|
||||
{
|
||||
return type == typeof(string) ||
|
||||
type.IsEnum ||
|
||||
type.IsValueType ||
|
||||
type == typeof(DateTime);
|
||||
return type == typeof(string) || type.IsEnum || type.IsValueType || type == typeof(DateTime);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
|
@ -243,15 +223,11 @@ public abstract class AcWebSignalRHubBase<TSignalRTags, TLogger>(IConfiguration
|
|||
=> SendMessageToClient(Clients.All, messageTag, CreateResponseMessage(messageTag, SignalResponseStatus.Success, content), null);
|
||||
|
||||
/// <summary>
|
||||
/// Sends message to client.
|
||||
/// Both Binary and JSON modes use AcBinarySerializer directly with pooled buffer.
|
||||
/// Sends message to client using Binary serialization.
|
||||
/// </summary>
|
||||
protected virtual async Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, ISignalRMessage message, int? requestId = null)
|
||||
{
|
||||
// Use ArrayBufferWriter for zero-copy serialization to pooled buffer
|
||||
var writer = new ArrayBufferWriter<byte>(256);
|
||||
message.ToBinary(writer);
|
||||
var responseBytes = writer.WrittenSpan.ToArray();
|
||||
var responseBytes = SignalRSerializationHelper.SerializeToBinary(message);
|
||||
|
||||
var tagName = ConstHelper.NameByValue<TSignalRTags>(messageTag);
|
||||
|
||||
|
|
@ -264,26 +240,11 @@ public abstract class AcWebSignalRHubBase<TSignalRTags, TLogger>(IConfiguration
|
|||
|
||||
#endregion
|
||||
|
||||
#region Context Accessor Methods (virtual for testing)
|
||||
#region Context Accessor Methods
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection ID. Override in tests to avoid Context dependency.
|
||||
/// </summary>
|
||||
protected virtual string GetConnectionId() => Context.ConnectionId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the connection is aborted. Override in tests to avoid Context dependency.
|
||||
/// </summary>
|
||||
protected virtual bool IsConnectionAborted() => Context.ConnectionAborted.IsCancellationRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user identifier. Override in tests to avoid Context dependency.
|
||||
/// </summary>
|
||||
protected virtual string? GetUserIdentifier() => Context.UserIdentifier;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ClaimsPrincipal user. Override in tests to avoid Context dependency.
|
||||
/// </summary>
|
||||
protected virtual ClaimsPrincipal? GetUser() => Context.User;
|
||||
|
||||
#endregion
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using AyCode.Services.SignalRs;
|
||||
using MessagePack.Resolvers;
|
||||
|
||||
namespace AyCode.Services.Tests.SignalRs;
|
||||
|
||||
|
|
@ -21,15 +20,15 @@ public class PostJsonDataMessageTests
|
|||
Console.WriteLine($"PostData.Ids[0]: {idMsg.PostData.Ids[0]}");
|
||||
}
|
||||
|
||||
var bytes = message.ToMessagePack(ContractlessStandardResolver.Options);
|
||||
Console.WriteLine($"MessagePack bytes: {bytes.Length}");
|
||||
var bytes = message.ToBinary();
|
||||
Console.WriteLine($"Binary bytes: {bytes.Length}");
|
||||
|
||||
var deserialized = bytes.MessagePackTo<SignalPostJsonDataMessage<IdMessage>>(ContractlessStandardResolver.Options);
|
||||
Console.WriteLine($"Deserialized PostDataJson: {deserialized.PostDataJson}");
|
||||
Console.WriteLine($"Deserialized PostData type: {deserialized.PostData?.GetType().Name}");
|
||||
Console.WriteLine($"Deserialized PostData.Ids.Count: {deserialized.PostData?.Ids.Count}");
|
||||
var deserialized = bytes.BinaryTo<SignalPostJsonDataMessage<IdMessage>>();
|
||||
Console.WriteLine($"Deserialized PostDataJson: {deserialized?.PostDataJson}");
|
||||
Console.WriteLine($"Deserialized PostData type: {deserialized?.PostData?.GetType().Name}");
|
||||
Console.WriteLine($"Deserialized PostData.Ids.Count: {deserialized?.PostData?.Ids.Count}");
|
||||
|
||||
Assert.IsNotNull(deserialized.PostData);
|
||||
Assert.IsNotNull(deserialized?.PostData);
|
||||
Assert.AreEqual(1, deserialized.PostData.Ids.Count);
|
||||
}
|
||||
|
||||
|
|
@ -56,18 +55,18 @@ public class PostJsonDataMessageTests
|
|||
var clientMessage = new SignalPostJsonDataMessage<IdMessage>(idMessage);
|
||||
Console.WriteLine($"Client PostDataJson: '{clientMessage.PostDataJson}'");
|
||||
|
||||
Console.WriteLine("\n=== Step 2: MessagePack serialization ===");
|
||||
var bytes = clientMessage.ToMessagePack(ContractlessStandardResolver.Options);
|
||||
Console.WriteLine($"MessagePack bytes: {bytes.Length}");
|
||||
Console.WriteLine("\n=== Step 2: Binary serialization ===");
|
||||
var bytes = clientMessage.ToBinary();
|
||||
Console.WriteLine($"Binary bytes: {bytes.Length}");
|
||||
|
||||
Console.WriteLine("\n=== Step 3: Server deserializes ===");
|
||||
var serverMessage = bytes.MessagePackTo<SignalPostJsonDataMessage<IdMessage>>(ContractlessStandardResolver.Options);
|
||||
Console.WriteLine($"Server PostDataJson: '{serverMessage.PostDataJson}'");
|
||||
Console.WriteLine($"Server PostData.Ids.Count: {serverMessage.PostData?.Ids.Count}");
|
||||
Console.WriteLine($"Server PostData.Ids[0]: '{serverMessage.PostData?.Ids[0]}'");
|
||||
var serverMessage = bytes.BinaryTo<SignalPostJsonDataMessage<IdMessage>>();
|
||||
Console.WriteLine($"Server PostDataJson: '{serverMessage?.PostDataJson}'");
|
||||
Console.WriteLine($"Server PostData.Ids.Count: {serverMessage?.PostData?.Ids.Count}");
|
||||
Console.WriteLine($"Server PostData.Ids[0]: '{serverMessage?.PostData?.Ids[0]}'");
|
||||
|
||||
Console.WriteLine("\n=== Step 4: Server deserializes parameter ===");
|
||||
var paramJson = serverMessage.PostData.Ids[0];
|
||||
var paramJson = serverMessage!.PostData.Ids[0];
|
||||
Console.WriteLine($"Parameter JSON: '{paramJson}'");
|
||||
var paramValue = AcJsonDeserializer.Deserialize(paramJson, testValue.GetType());
|
||||
Console.WriteLine($"Deserialized value: {paramValue}");
|
||||
|
|
@ -78,7 +77,7 @@ public class PostJsonDataMessageTests
|
|||
|
||||
Console.WriteLine("\n=== Step 6: Server creates response ===");
|
||||
var response = new SignalResponseDataMessage(100, SignalResponseStatus.Success, serviceResult, AcJsonSerializerOptions.Default);
|
||||
Console.WriteLine($"Response created with Binary bytes: {response.ResponseDataBin?.Length ?? 0}");
|
||||
Console.WriteLine($"Response created with Binary bytes: {response.ResponseData?.Length ?? 0}");
|
||||
|
||||
Console.WriteLine("\n=== Step 7: Response Binary ===");
|
||||
var responseBytes = response.ToBinary();
|
||||
|
|
|
|||
|
|
@ -1,231 +0,0 @@
|
|||
using AyCode.Core;
|
||||
using AyCode.Core.Extensions;
|
||||
using AyCode.Core.Tests.TestModels;
|
||||
using AyCode.Services.SignalRs;
|
||||
using MessagePack.Resolvers;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
|
||||
namespace AyCode.Services.Tests.SignalRs;
|
||||
|
||||
/// <summary>
|
||||
/// Testable SignalR client that allows testing without real HubConnection.
|
||||
/// </summary>
|
||||
public class TestableSignalRClient : AcSignalRClientBase
|
||||
{
|
||||
private HubConnectionState _connectionState = HubConnectionState.Connected;
|
||||
private int? _nextRequestIdOverride;
|
||||
|
||||
/// <summary>
|
||||
/// Messages sent to the server (captured for assertions).
|
||||
/// </summary>
|
||||
public List<SentClientMessage> SentMessages { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Received messages (captured for assertions).
|
||||
/// </summary>
|
||||
public List<ReceivedClientMessage> ReceivedMessages { get; } = [];
|
||||
|
||||
public TestableSignalRClient(TestLogger logger) : base(logger)
|
||||
{
|
||||
}
|
||||
|
||||
#region Override virtual methods for testing
|
||||
|
||||
protected override HubConnectionState GetConnectionState() => _connectionState;
|
||||
|
||||
protected override bool IsConnected() => _connectionState == HubConnectionState.Connected;
|
||||
|
||||
protected override Task StartConnectionInternal()
|
||||
{
|
||||
_connectionState = HubConnectionState.Connected;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task StopConnectionInternal()
|
||||
{
|
||||
_connectionState = HubConnectionState.Disconnected;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override ValueTask DisposeConnectionInternal() => ValueTask.CompletedTask;
|
||||
|
||||
protected override Task SendToHubAsync(int messageTag, byte[]? messageBytes, int? requestId)
|
||||
{
|
||||
SentMessages.Add(new SentClientMessage(messageTag, messageBytes, requestId));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override int GetNextRequestId()
|
||||
{
|
||||
if (_nextRequestIdOverride.HasValue)
|
||||
{
|
||||
var id = _nextRequestIdOverride.Value;
|
||||
_nextRequestIdOverride = id + 1; // Auto-increment for subsequent calls
|
||||
return id;
|
||||
}
|
||||
return AcDomain.NextUniqueInt32;
|
||||
}
|
||||
|
||||
protected override Task MessageReceived(int messageTag, byte[] messageBytes)
|
||||
{
|
||||
ReceivedMessages.Add(new ReceivedClientMessage(messageTag, messageBytes));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public test helpers (wrappers for protected methods)
|
||||
|
||||
/// <summary>
|
||||
/// Sets the simulated connection state.
|
||||
/// </summary>
|
||||
public void SetConnectionState(HubConnectionState state) => _connectionState = state;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the next request ID for deterministic testing.
|
||||
/// Will auto-increment for subsequent calls.
|
||||
/// </summary>
|
||||
public void SetNextRequestId(int id) => _nextRequestIdOverride = id;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the pending requests dictionary (public wrapper for testing).
|
||||
/// </summary>
|
||||
public new System.Collections.Concurrent.ConcurrentDictionary<int, SignalRRequestModel> GetPendingRequests()
|
||||
=> base.GetPendingRequests();
|
||||
|
||||
/// <summary>
|
||||
/// Registers a pending request (public wrapper for testing).
|
||||
/// </summary>
|
||||
public new void RegisterPendingRequest(int requestId, SignalRRequestModel model)
|
||||
=> base.RegisterPendingRequest(requestId, model);
|
||||
|
||||
/// <summary>
|
||||
/// Clears pending requests (public wrapper for testing).
|
||||
/// </summary>
|
||||
public new void ClearPendingRequests() => base.ClearPendingRequests();
|
||||
|
||||
/// <summary>
|
||||
/// Simulates receiving a response from the server.
|
||||
/// </summary>
|
||||
public Task SimulateServerResponse(int requestId, int messageTag, SignalResponseStatus status, object? data = null)
|
||||
{
|
||||
var response = new SignalResponseDataMessage(messageTag, status, data, AcJsonSerializerOptions.Default);
|
||||
var bytes = response.ToBinary();
|
||||
return OnReceiveMessage(messageTag, bytes, requestId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulates receiving a success response from the server.
|
||||
/// </summary>
|
||||
public Task SimulateSuccessResponse<T>(int requestId, int messageTag, T data)
|
||||
=> SimulateServerResponse(requestId, messageTag, SignalResponseStatus.Success, data);
|
||||
|
||||
/// <summary>
|
||||
/// Simulates receiving an error response from the server.
|
||||
/// </summary>
|
||||
public Task SimulateErrorResponse(int requestId, int messageTag)
|
||||
=> SimulateServerResponse(requestId, messageTag, SignalResponseStatus.Error);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last sent message.
|
||||
/// </summary>
|
||||
public SentClientMessage? LastSentMessage => SentMessages.LastOrDefault();
|
||||
|
||||
/// <summary>
|
||||
/// Clears all captured messages.
|
||||
/// </summary>
|
||||
public void ClearMessages()
|
||||
{
|
||||
SentMessages.Clear();
|
||||
ReceivedMessages.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes OnReceiveMessage directly for testing.
|
||||
/// </summary>
|
||||
public Task InvokeOnReceiveMessage(int messageTag, byte[] messageBytes, int? requestId)
|
||||
=> OnReceiveMessage(messageTag, messageBytes, requestId);
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a message sent from client to server.
|
||||
/// </summary>
|
||||
public record SentClientMessage(int MessageTag, byte[]? MessageBytes, int? RequestId)
|
||||
{
|
||||
/// <summary>
|
||||
/// Deserializes the message to IdMessage format.
|
||||
/// Works with both production SignalPostJsonDataMessage and test SignalRPostMessageDto.
|
||||
/// </summary>
|
||||
public IdMessage? AsIdMessage()
|
||||
{
|
||||
if (MessageBytes == null) return null;
|
||||
try
|
||||
{
|
||||
// First deserialize to get the PostDataJson string
|
||||
var msg = MessageBytes.MessagePackTo<SignalPostJsonDataMessage<IdMessage>>(ContractlessStandardResolver.Options);
|
||||
return msg.PostData;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback: try deserializing as raw JSON wrapper
|
||||
try
|
||||
{
|
||||
var rawMsg = MessageBytes.MessagePackTo<SignalPostJsonMessage>(ContractlessStandardResolver.Options);
|
||||
return rawMsg.PostDataJson?.JsonTo<IdMessage>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes the message to a specific post data type.
|
||||
/// </summary>
|
||||
public T? AsPostData<T>() where T : class
|
||||
{
|
||||
if (MessageBytes == null) return null;
|
||||
try
|
||||
{
|
||||
var msg = MessageBytes.MessagePackTo<SignalPostJsonDataMessage<T>>(ContractlessStandardResolver.Options);
|
||||
return msg.PostData;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback: try deserializing as raw JSON wrapper
|
||||
try
|
||||
{
|
||||
var rawMsg = MessageBytes.MessagePackTo<SignalPostJsonMessage>(ContractlessStandardResolver.Options);
|
||||
return rawMsg.PostDataJson?.JsonTo<T>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a message received by the client.
|
||||
/// </summary>
|
||||
public record ReceivedClientMessage(int MessageTag, byte[] MessageBytes)
|
||||
{
|
||||
/// <summary>
|
||||
/// Deserializes the message as a response.
|
||||
/// </summary>
|
||||
public SignalResponseDataMessage? AsResponse()
|
||||
{
|
||||
try
|
||||
{
|
||||
return MessageBytes.BinaryTo<SignalResponseDataMessage>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@ using AyCode.Core.Extensions;
|
|||
using AyCode.Core.Helpers;
|
||||
using AyCode.Core.Loggers;
|
||||
using AyCode.Interfaces.Entities;
|
||||
using MessagePack.Resolvers;
|
||||
using Microsoft.AspNetCore.Http.Connections;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
|
||||
|
|
@ -119,7 +118,7 @@ namespace AyCode.Services.SignalRs
|
|||
|
||||
await StartConnection();
|
||||
|
||||
var msgp = message?.ToMessagePack(ContractlessStandardResolver.Options);
|
||||
var msgBytes = message != null ? SignalRSerializationHelper.SerializeToBinary(message) : null;
|
||||
|
||||
if (!IsConnected())
|
||||
{
|
||||
|
|
@ -127,7 +126,7 @@ namespace AyCode.Services.SignalRs
|
|||
return;
|
||||
}
|
||||
|
||||
await SendToHubAsync(messageTag, msgp, requestId);
|
||||
await SendToHubAsync(messageTag, msgBytes, requestId);
|
||||
}
|
||||
|
||||
#region CRUD
|
||||
|
|
@ -144,31 +143,61 @@ namespace AyCode.Services.SignalRs
|
|||
public virtual Task<TResponseData?> GetByIdAsync<TResponseData>(int messageTag, object[] ids)
|
||||
=> PostAsync<TResponseData?>(messageTag, ids);
|
||||
|
||||
/// <summary>
|
||||
/// Gets data by ID with async callback response. Callback is second parameter.
|
||||
/// </summary>
|
||||
public virtual Task GetByIdAsync<TResponseData>(int messageTag, Func<SignalResponseDataMessage, Task> responseCallback, object id)
|
||||
=> SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(id)), responseCallback);
|
||||
|
||||
/// <summary>
|
||||
/// Gets data by IDs with async callback response. Callback is second parameter.
|
||||
/// </summary>
|
||||
public virtual Task GetByIdAsync<TResponseData>(int messageTag, Func<SignalResponseDataMessage, Task> responseCallback, object[] ids)
|
||||
=> SendMessageToServerAsync(messageTag, new SignalPostJsonDataMessage<IdMessage>(new IdMessage(ids)), responseCallback);
|
||||
|
||||
public virtual Task<TResponseData?> GetAllAsync<TResponseData>(int messageTag)
|
||||
=> SendMessageToServerAsync<TResponseData>(messageTag);
|
||||
|
||||
public virtual Task<TResponseData?> GetAllAsync<TResponseData>(int messageTag, object[]? contextParams)
|
||||
=> SendMessageToServerAsync<TResponseData>(messageTag, contextParams == null || contextParams.Length == 0 ? null : new SignalPostJsonDataMessage<IdMessage>(new IdMessage(contextParams)), GetNextRequestId());
|
||||
|
||||
/// <summary>
|
||||
/// Gets all data with async callback response. Callback is second parameter.
|
||||
/// </summary>
|
||||
public virtual Task GetAllAsync<TResponseData>(int messageTag, Func<SignalResponseDataMessage, Task> responseCallback)
|
||||
=> SendMessageToServerAsync(messageTag, null, responseCallback);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all data with context params and async callback response.
|
||||
/// </summary>
|
||||
public virtual Task GetAllAsync<TResponseData>(int messageTag, Func<SignalResponseDataMessage, Task> responseCallback, object[]? contextParams)
|
||||
=> SendMessageToServerAsync(messageTag, contextParams == null || contextParams.Length == 0 ? null : new SignalPostJsonDataMessage<IdMessage>(new IdMessage(contextParams)), responseCallback);
|
||||
|
||||
public virtual Task<TPostData?> PostDataAsync<TPostData>(int messageTag, TPostData postData) where TPostData : class
|
||||
=> SendMessageToServerAsync<TPostData>(messageTag, CreatePostMessage(postData), GetNextRequestId());
|
||||
|
||||
public virtual Task<TResponseData?> PostDataAsync<TPostData, TResponseData>(int messageTag, TPostData postData)
|
||||
=> SendMessageToServerAsync<TResponseData>(messageTag, CreatePostMessage(postData), GetNextRequestId());
|
||||
|
||||
/// <summary>
|
||||
/// Posts data with async callback response.
|
||||
/// </summary>
|
||||
public virtual Task PostDataAsync<TPostData>(int messageTag, TPostData postData, Func<SignalResponseDataMessage, Task> responseCallback)
|
||||
=> SendMessageToServerAsync(messageTag, CreatePostMessage(postData), responseCallback);
|
||||
|
||||
/// <summary>
|
||||
/// Posts data with typed async callback response.
|
||||
/// </summary>
|
||||
public virtual Task PostDataAsync<TPostData, TResponseData>(int messageTag, TPostData postData, Func<SignalResponseDataMessage, Task> responseCallback)
|
||||
=> SendMessageToServerAsync(messageTag, CreatePostMessage(postData), responseCallback);
|
||||
|
||||
/// <summary>
|
||||
/// Posts data and invokes callback with response. Fire-and-forget friendly for background saves.
|
||||
/// </summary>
|
||||
public virtual Task PostDataAsync<TPostData>(int messageTag, TPostData postData, Action<SignalResponseDataMessage> responseCallback)
|
||||
{
|
||||
var requestId = GetNextRequestId();
|
||||
var requestModel = SignalRRequestModelPool.Get(new Action<ISignalResponseMessage>(response =>
|
||||
{
|
||||
if (response is SignalResponseDataMessage dataMsg)
|
||||
responseCallback(dataMsg);
|
||||
else
|
||||
Logger.Error($"PostDataAsync callback received unexpected message type: {response.GetType().Name}");
|
||||
}));
|
||||
var requestModel = SignalRRequestModelPool.Get(responseCallback);
|
||||
|
||||
_responseByRequestId[requestId] = requestModel;
|
||||
return SendMessageToServerAsync(messageTag, CreatePostMessage(postData), requestId);
|
||||
|
|
@ -206,6 +235,18 @@ namespace AyCode.Services.SignalRs
|
|||
public virtual Task<TResponse?> SendMessageToServerAsync<TResponse>(int messageTag, ISignalRMessage? message)
|
||||
=> SendMessageToServerAsync<TResponse>(messageTag, message, GetNextRequestId());
|
||||
|
||||
/// <summary>
|
||||
/// Sends message to server with async callback response.
|
||||
/// </summary>
|
||||
public virtual async Task SendMessageToServerAsync(int messageTag, ISignalRMessage? message, Func<SignalResponseDataMessage, Task> responseCallback)
|
||||
{
|
||||
var requestId = GetNextRequestId();
|
||||
var requestModel = SignalRRequestModelPool.Get(responseCallback);
|
||||
|
||||
_responseByRequestId[requestId] = requestModel;
|
||||
await SendMessageToServerAsync(messageTag, message, requestId);
|
||||
}
|
||||
|
||||
protected virtual async Task<TResponse?> SendMessageToServerAsync<TResponse>(int messageTag, ISignalRMessage? message, int requestId)
|
||||
{
|
||||
Logger.DebugConditional($"Client SendMessageToServerAsync<TResult>; {nameof(requestId)}: {requestId}; {ConstHelper.NameByValue(TagsName, messageTag)}");
|
||||
|
|
@ -231,6 +272,15 @@ namespace AyCode.Services.SignalRs
|
|||
return await Task.FromException<TResponse>(new Exception(errorText));
|
||||
}
|
||||
|
||||
// Special case: when TResponse is SignalResponseDataMessage, return the message itself
|
||||
// instead of trying to deserialize ResponseData (which would cause InvalidCastException)
|
||||
if (typeof(TResponse) == typeof(SignalResponseDataMessage))
|
||||
{
|
||||
var serializerType = responseMessage.DataSerializerType == AcSerializerType.Binary ? "Binary" : "JSON";
|
||||
Logger.Info($"Client returning raw SignalResponseDataMessage ({serializerType}). Total: {(DateTime.UtcNow.Subtract(startTime)).TotalMilliseconds} ms! requestId: {requestId}; tag: {messageTag} [{ConstHelper.NameByValue(TagsName, messageTag)}]");
|
||||
return (TResponse)(object)responseMessage;
|
||||
}
|
||||
|
||||
var responseData = responseMessage.GetResponseData<TResponse>();
|
||||
|
||||
if (responseData == null && responseMessage.Status == SignalResponseStatus.Success)
|
||||
|
|
@ -239,8 +289,8 @@ namespace AyCode.Services.SignalRs
|
|||
return default;
|
||||
}
|
||||
|
||||
var serializerType = responseMessage.DataSerializerType == AcSerializerType.Binary ? "Binary" : "JSON";
|
||||
Logger.Info($"Client deserialized response ({serializerType}). Total: {(DateTime.UtcNow.Subtract(startTime)).TotalMilliseconds} ms! requestId: {requestId}; tag: {messageTag} [{ConstHelper.NameByValue(TagsName, messageTag)}]");
|
||||
var serializerType2 = responseMessage.DataSerializerType == AcSerializerType.Binary ? "Binary" : "JSON";
|
||||
Logger.Info($"Client deserialized response ({serializerType2}). Total: {(DateTime.UtcNow.Subtract(startTime)).TotalMilliseconds} ms! requestId: {requestId}; tag: {messageTag} [{ConstHelper.NameByValue(TagsName, messageTag)}]");
|
||||
return responseData;
|
||||
}
|
||||
|
||||
|
|
@ -273,7 +323,7 @@ namespace AyCode.Services.SignalRs
|
|||
requestModel.ResponseDateTime = DateTime.UtcNow;
|
||||
Logger.Debug($"[{requestModel.ResponseDateTime.Subtract(requestModel.RequestDateTime).TotalMilliseconds:N0}ms][{messageBytes.Length / 1024}kb]{logText}");
|
||||
|
||||
var responseMessage = messageBytes.BinaryTo<SignalResponseDataMessage>() ?? new SignalResponseDataMessage();
|
||||
var responseMessage = SignalRSerializationHelper.DeserializeFromBinary<SignalResponseDataMessage>(messageBytes) ?? new SignalResponseDataMessage();
|
||||
|
||||
switch (requestModel.ResponseByRequestId)
|
||||
{
|
||||
|
|
@ -281,12 +331,17 @@ namespace AyCode.Services.SignalRs
|
|||
requestModel.ResponseByRequestId = responseMessage;
|
||||
return Task.CompletedTask;
|
||||
|
||||
case Action<ISignalResponseMessage> messageCallback:
|
||||
if (_responseByRequestId.TryRemove(reqId, out var callbackModel))
|
||||
SignalRRequestModelPool.Return(callbackModel);
|
||||
messageCallback.Invoke(responseMessage);
|
||||
case Action<SignalResponseDataMessage> actionCallback:
|
||||
if (_responseByRequestId.TryRemove(reqId, out var actionModel))
|
||||
SignalRRequestModelPool.Return(actionModel);
|
||||
actionCallback.Invoke(responseMessage);
|
||||
return Task.CompletedTask;
|
||||
|
||||
case Func<SignalResponseDataMessage, Task> funcCallback:
|
||||
if (_responseByRequestId.TryRemove(reqId, out var funcModel))
|
||||
SignalRRequestModelPool.Return(funcModel);
|
||||
return funcCallback.Invoke(responseMessage);
|
||||
|
||||
default:
|
||||
Logger.Error($"Client OnReceiveMessage switch; unknown message type: {requestModel.ResponseByRequestId?.GetType().Name}; {ConstHelper.NameByValue(TagsName, messageTag)}");
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
using AyCode.Core.Extensions;
|
||||
using MessagePack;
|
||||
using AyCode.Core.Interfaces;
|
||||
using AyCode.Core.Compression;
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using JsonIgnoreAttribute = Newtonsoft.Json.JsonIgnoreAttribute;
|
||||
|
|
@ -9,6 +7,10 @@ using STJIgnore = System.Text.Json.Serialization.JsonIgnoreAttribute;
|
|||
|
||||
namespace AyCode.Services.SignalRs;
|
||||
|
||||
/// <summary>
|
||||
/// Message container for serialized parameter IDs.
|
||||
/// Optimized for common primitive types to avoid full JSON overhead.
|
||||
/// </summary>
|
||||
public class IdMessage
|
||||
{
|
||||
public List<string> Ids { get; private set; }
|
||||
|
|
@ -20,30 +22,26 @@ public class IdMessage
|
|||
|
||||
/// <summary>
|
||||
/// Creates IdMessage with multiple parameters serialized directly as JSON.
|
||||
/// Each parameter is serialized independently without array wrapping.
|
||||
/// Use object[] explicitly to pass multiple parameters.
|
||||
/// </summary>
|
||||
public IdMessage(object[] ids)
|
||||
{
|
||||
Ids = new List<string>(ids.Length);
|
||||
for (var i = 0; i < ids.Length; i++)
|
||||
{
|
||||
Ids.Add(SerializeValue(ids[i]));
|
||||
Ids.Add(SignalRSerializationHelper.SerializePrimitiveToJson(ids[i]));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates IdMessage with a single parameter serialized as JSON.
|
||||
/// Collections (List, Array, etc.) are serialized as a single JSON array.
|
||||
/// </summary>
|
||||
public IdMessage(object id)
|
||||
{
|
||||
Ids = new List<string>(1) { SerializeValue(id) };
|
||||
Ids = [SignalRSerializationHelper.SerializePrimitiveToJson(id)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates IdMessage with multiple Guid parameters.
|
||||
/// Each Guid is serialized as a separate Id entry.
|
||||
/// </summary>
|
||||
public IdMessage(IEnumerable<Guid> ids)
|
||||
{
|
||||
|
|
@ -51,63 +49,33 @@ public class IdMessage
|
|||
Ids = new List<string>(idsArray.Length);
|
||||
for (var i = 0; i < idsArray.Length; i++)
|
||||
{
|
||||
Ids.Add(SerializeGuid(idsArray[i]));
|
||||
Ids.Add(SignalRSerializationHelper.SerializeGuidToJson(idsArray[i]));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optimized serialization for common primitive types to avoid full JSON serialization overhead.
|
||||
/// Falls back to full JSON serialization for complex types or strings with special characters.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static string SerializeValue(object value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
int i => i.ToString(),
|
||||
long l => l.ToString(),
|
||||
Guid g => SerializeGuid(g),
|
||||
bool b => b ? "true" : "false",
|
||||
// Strings need proper JSON escaping for special characters
|
||||
string => value.ToJson(),
|
||||
_ => value.ToJson()
|
||||
};
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static string SerializeGuid(Guid g)
|
||||
{
|
||||
// Pre-allocate exact size: 38 chars = 2 quotes + 36 guid chars
|
||||
return string.Create(38, g, static (span, guid) =>
|
||||
{
|
||||
span[0] = '"';
|
||||
guid.TryFormat(span[1..], out _);
|
||||
span[37] = '"';
|
||||
});
|
||||
}
|
||||
|
||||
public override string ToString() => string.Join("; ", Ids);
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
/// <summary>
|
||||
/// Message containing JSON-serialized post data.
|
||||
/// </summary>
|
||||
public class SignalPostJsonMessage
|
||||
{
|
||||
[Key(0)]
|
||||
public string PostDataJson { get; set; } = "";
|
||||
|
||||
public SignalPostJsonMessage() { }
|
||||
protected SignalPostJsonMessage(string postDataJson) => PostDataJson = postDataJson;
|
||||
}
|
||||
|
||||
[MessagePackObject(AllowPrivate = false)]
|
||||
/// <summary>
|
||||
/// Generic message containing JSON-serialized post data with typed access.
|
||||
/// </summary>
|
||||
public class SignalPostJsonDataMessage<TPostDataType> : SignalPostJsonMessage, ISignalPostMessage<TPostDataType>
|
||||
{
|
||||
[IgnoreMember]
|
||||
[JsonIgnore]
|
||||
[STJIgnore]
|
||||
private TPostDataType? _postData;
|
||||
|
||||
[IgnoreMember]
|
||||
[JsonIgnore]
|
||||
[STJIgnore]
|
||||
public TPostDataType PostData
|
||||
|
|
@ -125,10 +93,11 @@ public class SignalPostJsonDataMessage<TPostDataType> : SignalPostJsonMessage, I
|
|||
public SignalPostJsonDataMessage(string postDataJson) : base(postDataJson) { }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
/// <summary>
|
||||
/// Simple message containing post data.
|
||||
/// </summary>
|
||||
public class SignalPostMessage<TPostData>(TPostData postData) : ISignalPostMessage<TPostData>
|
||||
{
|
||||
[Key(0)]
|
||||
public TPostData? PostData { get; set; } = postData;
|
||||
}
|
||||
|
||||
|
|
@ -137,10 +106,11 @@ public interface ISignalPostMessage<TPostData> : ISignalRMessage
|
|||
TPostData? PostData { get; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
/// <summary>
|
||||
/// Message for requesting by Guid ID.
|
||||
/// </summary>
|
||||
public class SignalRequestByIdMessage(Guid id) : ISignalRequestMessage<Guid>, IId<Guid>
|
||||
{
|
||||
[Key(0)]
|
||||
public Guid Id { get; set; } = id;
|
||||
}
|
||||
|
||||
|
|
@ -163,66 +133,6 @@ public enum SignalResponseStatus : byte
|
|||
Success = 5
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signal response message with lazy deserialization support.
|
||||
/// Used for callback-based response handling.
|
||||
/// </summary>
|
||||
[MessagePackObject(AllowPrivate = false)]
|
||||
public sealed class SignalResponseMessage<TResponseData> : ISignalResponseMessage
|
||||
{
|
||||
[IgnoreMember]
|
||||
[JsonIgnore]
|
||||
[STJIgnore]
|
||||
private TResponseData? _responseData;
|
||||
|
||||
[IgnoreMember]
|
||||
[JsonIgnore]
|
||||
[STJIgnore]
|
||||
private bool _isDeserialized;
|
||||
|
||||
[Key(0)]
|
||||
public int MessageTag { get; set; }
|
||||
|
||||
[Key(1)]
|
||||
public SignalResponseStatus Status { get; set; }
|
||||
|
||||
[Key(2)]
|
||||
public string? ResponseDataJson { get; set; }
|
||||
|
||||
[IgnoreMember]
|
||||
[JsonIgnore]
|
||||
[STJIgnore]
|
||||
public TResponseData? ResponseData
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!_isDeserialized)
|
||||
{
|
||||
_isDeserialized = true;
|
||||
_responseData = ResponseDataJson != null ? ResponseDataJson.JsonTo<TResponseData>() : default;
|
||||
}
|
||||
return _responseData;
|
||||
}
|
||||
set
|
||||
{
|
||||
_isDeserialized = true;
|
||||
_responseData = value;
|
||||
ResponseDataJson = value?.ToJson();
|
||||
}
|
||||
}
|
||||
|
||||
public SignalResponseMessage() { }
|
||||
public SignalResponseMessage(int messageTag, SignalResponseStatus status)
|
||||
{
|
||||
MessageTag = messageTag;
|
||||
Status = status;
|
||||
}
|
||||
public SignalResponseMessage(int messageTag, SignalResponseStatus status, TResponseData? responseData) : this(messageTag, status)
|
||||
=> ResponseData = responseData;
|
||||
public SignalResponseMessage(int messageTag, SignalResponseStatus status, string? responseDataJson) : this(messageTag, status)
|
||||
=> ResponseDataJson = responseDataJson;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unified signal response message that supports both JSON and Binary serialization.
|
||||
/// JSON mode uses Brotli compression for reduced payload size.
|
||||
|
|
@ -233,15 +143,13 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa
|
|||
public int MessageTag { get; set; }
|
||||
public SignalResponseStatus Status { get; set; }
|
||||
public AcSerializerType DataSerializerType { get; set; }
|
||||
public byte[]? ResponseDataBin { get; set; }
|
||||
public byte[]? ResponseData { get; set; }
|
||||
|
||||
[JsonIgnore] [STJIgnore] private object? _cachedResponseData;
|
||||
[JsonIgnore] [STJIgnore] private byte[]? _rentedDecompressedBuffer;
|
||||
[JsonIgnore] [STJIgnore] private int _decompressedLength;
|
||||
|
||||
public SignalResponseDataMessage()
|
||||
{
|
||||
}
|
||||
public SignalResponseDataMessage() { }
|
||||
|
||||
public SignalResponseDataMessage(int messageTag, SignalResponseStatus status)
|
||||
{
|
||||
|
|
@ -253,65 +161,23 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa
|
|||
: this(messageTag, status)
|
||||
{
|
||||
DataSerializerType = serializerOptions.SerializerType;
|
||||
if (responseData == null)
|
||||
{
|
||||
ResponseDataBin = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (serializerOptions.SerializerType == AcSerializerType.Binary)
|
||||
{
|
||||
if (responseData is byte[] byteData)
|
||||
{
|
||||
ResponseDataBin = byteData;
|
||||
return;
|
||||
}
|
||||
|
||||
var binaryOptions = serializerOptions as AcBinarySerializerOptions ?? AcBinarySerializerOptions.Default;
|
||||
// Use ArrayBufferWriter for zero-copy serialization
|
||||
var writer = new ArrayBufferWriter<byte>(256);
|
||||
responseData.ToBinary(writer, binaryOptions);
|
||||
ResponseDataBin = writer.WrittenSpan.ToArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
string json;
|
||||
if (responseData is string strData)
|
||||
{
|
||||
var trimmed = strData.AsSpan().Trim();
|
||||
if (trimmed.Length > 1 && (trimmed[0] == '{' || trimmed[0] == '[') && (trimmed[^1] == '}' || trimmed[^1] == ']'))
|
||||
json = strData;
|
||||
else
|
||||
{
|
||||
var jsonOptions = serializerOptions as AcJsonSerializerOptions ?? AcJsonSerializerOptions.Default;
|
||||
json = responseData.ToJson(jsonOptions);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var jsonOptions = serializerOptions as AcJsonSerializerOptions ?? AcJsonSerializerOptions.Default;
|
||||
json = responseData.ToJson(jsonOptions);
|
||||
}
|
||||
|
||||
ResponseDataBin = BrotliHelper.Compress(json);
|
||||
}
|
||||
ResponseData = SignalRSerializationHelper.CreateResponseData(responseData, serializerOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes the ResponseData to the specified type.
|
||||
/// For JSON mode, decompresses Brotli to pooled buffer and deserializes directly (no string allocation).
|
||||
/// Uses cached result for repeated calls.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public T? GetResponseData<T>()
|
||||
{
|
||||
if (_cachedResponseData != null) return (T)_cachedResponseData;
|
||||
if (ResponseDataBin == null) return default;
|
||||
if (ResponseData == null) return default;
|
||||
|
||||
if (DataSerializerType == AcSerializerType.Binary)
|
||||
return (T)(_cachedResponseData = ResponseDataBin.BinaryTo<T>()!);
|
||||
return (T)(_cachedResponseData = ResponseData.BinaryTo<T>()!);
|
||||
|
||||
// Decompress Brotli to pooled buffer and deserialize directly from ReadOnlySpan (no string allocation)
|
||||
// Decompress Brotli to pooled buffer and deserialize directly
|
||||
EnsureDecompressed();
|
||||
var result = AcJsonDeserializer.Deserialize<T>(new ReadOnlySpan<byte>(_rentedDecompressedBuffer, 0, _decompressedLength));
|
||||
_cachedResponseData = result;
|
||||
|
|
@ -323,7 +189,7 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa
|
|||
/// </summary>
|
||||
public ReadOnlySpan<byte> GetDecompressedJsonSpan()
|
||||
{
|
||||
if (ResponseDataBin == null) return ReadOnlySpan<byte>.Empty;
|
||||
if (ResponseData == null) return ReadOnlySpan<byte>.Empty;
|
||||
if (DataSerializerType == AcSerializerType.Binary) return ReadOnlySpan<byte>.Empty;
|
||||
|
||||
EnsureDecompressed();
|
||||
|
|
@ -335,18 +201,11 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposa
|
|||
{
|
||||
if (_rentedDecompressedBuffer != null) return;
|
||||
|
||||
var (buffer, length) = BrotliHelper.DecompressToRentedBuffer(ResponseDataBin.AsSpan());
|
||||
_rentedDecompressedBuffer = buffer;
|
||||
_decompressedLength = length;
|
||||
(_rentedDecompressedBuffer, _decompressedLength) = SignalRSerializationHelper.DecompressToRentedBuffer(ResponseData!);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_rentedDecompressedBuffer != null)
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(_rentedDecompressedBuffer);
|
||||
_rentedDecompressedBuffer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,186 @@
|
|||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using AyCode.Core.Compression;
|
||||
using AyCode.Core.Extensions;
|
||||
|
||||
namespace AyCode.Services.SignalRs;
|
||||
|
||||
/// <summary>
|
||||
/// Centralized helper for SignalR serialization operations.
|
||||
/// Provides optimized primitives for JSON/Binary serialization with pooled buffers.
|
||||
/// </summary>
|
||||
public static class SignalRSerializationHelper
|
||||
{
|
||||
// Pre-boxed boolean values to avoid repeated boxing
|
||||
private static readonly string JsonTrue = "true";
|
||||
private static readonly string JsonFalse = "false";
|
||||
|
||||
#region Primitive JSON Serialization
|
||||
|
||||
/// <summary>
|
||||
/// Serialize a primitive value to JSON string with minimal overhead.
|
||||
/// Falls back to full JSON serialization for complex types.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static string SerializePrimitiveToJson(object value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
int i => i.ToString(),
|
||||
long l => l.ToString(),
|
||||
Guid g => SerializeGuidToJson(g),
|
||||
bool b => b ? JsonTrue : JsonFalse,
|
||||
// Strings need proper JSON escaping for special characters
|
||||
string => value.ToJson(),
|
||||
_ => value.ToJson()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialize a Guid to JSON string with pre-allocated buffer.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static string SerializeGuidToJson(Guid g)
|
||||
{
|
||||
// Pre-allocate exact size: 38 chars = 2 quotes + 36 guid chars
|
||||
return string.Create(38, g, static (span, guid) =>
|
||||
{
|
||||
span[0] = '"';
|
||||
guid.TryFormat(span[1..], out _);
|
||||
span[37] = '"';
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Binary Serialization
|
||||
|
||||
/// <summary>
|
||||
/// Serialize object to binary using pooled ArrayBufferWriter.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static byte[] SerializeToBinary<T>(T value, AcBinarySerializerOptions? options = null)
|
||||
{
|
||||
var writer = new ArrayBufferWriter<byte>(256);
|
||||
value.ToBinary(writer, options ?? AcBinarySerializerOptions.Default);
|
||||
return writer.WrittenSpan.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialize object to binary and write to existing ArrayBufferWriter.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void SerializeToBinary<T>(T value, ArrayBufferWriter<byte> writer, AcBinarySerializerOptions? options = null)
|
||||
{
|
||||
value.ToBinary(writer, options ?? AcBinarySerializerOptions.Default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize binary data to object.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static T? DeserializeFromBinary<T>(byte[] data)
|
||||
{
|
||||
return data.BinaryTo<T>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize binary data from ReadOnlySpan.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static T? DeserializeFromBinary<T>(ReadOnlySpan<byte> data)
|
||||
{
|
||||
return data.BinaryTo<T>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON Serialization with Brotli
|
||||
|
||||
/// <summary>
|
||||
/// Serialize object to JSON and compress with Brotli.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static byte[] SerializeToCompressedJson<T>(T value, AcJsonSerializerOptions? options = null)
|
||||
{
|
||||
var json = value.ToJson(options ?? AcJsonSerializerOptions.Default);
|
||||
return BrotliHelper.Compress(json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decompress Brotli data and deserialize JSON to object.
|
||||
/// Uses pooled buffer for decompression.
|
||||
/// </summary>
|
||||
public static T? DeserializeFromCompressedJson<T>(byte[] compressedData)
|
||||
{
|
||||
var (buffer, length) = BrotliHelper.DecompressToRentedBuffer(compressedData.AsSpan());
|
||||
try
|
||||
{
|
||||
return AcJsonDeserializer.Deserialize<T>(new ReadOnlySpan<byte>(buffer, 0, length));
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decompress Brotli data to rented buffer for direct processing.
|
||||
/// Caller must return buffer to ArrayPool.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static (byte[] Buffer, int Length) DecompressToRentedBuffer(byte[] compressedData)
|
||||
{
|
||||
return BrotliHelper.DecompressToRentedBuffer(compressedData.AsSpan());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Response Data Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Check if string appears to be valid JSON (starts with { or [).
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static bool IsValidJsonString(ReadOnlySpan<char> text)
|
||||
{
|
||||
var trimmed = text.Trim();
|
||||
return trimmed.Length > 1 &&
|
||||
(trimmed[0] == '{' || trimmed[0] == '[') &&
|
||||
(trimmed[^1] == '}' || trimmed[^1] == ']');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create response binary data based on serializer type.
|
||||
/// </summary>
|
||||
public static byte[]? CreateResponseData(object? responseData, AcSerializerOptions serializerOptions)
|
||||
{
|
||||
if (responseData == null)
|
||||
return null;
|
||||
|
||||
if (serializerOptions.SerializerType == AcSerializerType.Binary)
|
||||
{
|
||||
if (responseData is byte[] byteData)
|
||||
return byteData;
|
||||
|
||||
var binaryOptions = serializerOptions as AcBinarySerializerOptions ?? AcBinarySerializerOptions.Default;
|
||||
return SerializeToBinary(responseData, binaryOptions);
|
||||
}
|
||||
|
||||
// JSON mode with Brotli compression
|
||||
string json;
|
||||
if (responseData is string strData && IsValidJsonString(strData.AsSpan()))
|
||||
{
|
||||
json = strData;
|
||||
}
|
||||
else
|
||||
{
|
||||
var jsonOptions = serializerOptions as AcJsonSerializerOptions ?? AcJsonSerializerOptions.Default;
|
||||
json = responseData.ToJson(jsonOptions);
|
||||
}
|
||||
|
||||
return BrotliHelper.Compress(json);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Loading…
Reference in New Issue