Optimize JSON/Brotli serialization for zero-allocation
Major performance improvements for SignalR message transport: - BrotliHelper now uses ArrayPool and stackalloc for zero-allocation compression/decompression; added span/IBufferWriter overloads and pooled buffer support. - AcJsonDeserializer supports direct deserialization from UTF-8 spans, with fast path for no reference handling. - SignalResponseDataMessage uses pooled buffers for Brotli decompression and zero-copy deserialization; implements IDisposable for buffer return. - IdMessage serialization optimized for primitives and Guids to avoid unnecessary allocations. - Added JsonTo<T> span/byte[] extension methods for zero-allocation deserialization. - All changes are backward compatible and reduce GC pressure for high-throughput scenarios.
This commit is contained in:
parent
ac6735ebd8
commit
489ef7486c
|
|
@ -1,4 +1,6 @@
|
|||
using System.Buffers;
|
||||
using System.IO.Compression;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
|
||||
namespace AyCode.Core.Compression;
|
||||
|
|
@ -6,49 +8,94 @@ 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
|
||||
{
|
||||
private const int DefaultBufferSize = 4096;
|
||||
private const int MaxStackAllocSize = 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Compresses a string using Brotli compression.
|
||||
/// Compresses a string using Brotli compression with pooled buffers.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to compress.</param>
|
||||
/// <param name="compressionLevel">Compression level (default: Optimal).</param>
|
||||
/// <returns>Compressed byte array.</returns>
|
||||
public static byte[] Compress(string text, CompressionLevel compressionLevel = CompressionLevel.Optimal)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return [];
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(text);
|
||||
return Compress(bytes, compressionLevel);
|
||||
// Use stack allocation for small strings, pooled buffer for larger
|
||||
var maxByteCount = Encoding.UTF8.GetMaxByteCount(text.Length);
|
||||
|
||||
if (maxByteCount <= MaxStackAllocSize)
|
||||
{
|
||||
Span<byte> utf8Bytes = stackalloc byte[maxByteCount];
|
||||
var actualLength = Encoding.UTF8.GetBytes(text.AsSpan(), utf8Bytes);
|
||||
return CompressSpan(utf8Bytes[..actualLength], compressionLevel);
|
||||
}
|
||||
|
||||
var rentedBuffer = ArrayPool<byte>.Shared.Rent(maxByteCount);
|
||||
try
|
||||
{
|
||||
var actualLength = Encoding.UTF8.GetBytes(text.AsSpan(), rentedBuffer);
|
||||
return CompressSpan(rentedBuffer.AsSpan(0, actualLength), compressionLevel);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(rentedBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compresses a byte array using Brotli compression.
|
||||
/// </summary>
|
||||
/// <param name="data">The data to compress.</param>
|
||||
/// <param name="compressionLevel">Compression level (default: Optimal).</param>
|
||||
/// <returns>Compressed byte array.</returns>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static byte[] Compress(byte[] data, CompressionLevel compressionLevel = CompressionLevel.Optimal)
|
||||
=> data == null || data.Length == 0 ? [] : CompressSpan(data.AsSpan(), compressionLevel);
|
||||
|
||||
/// <summary>
|
||||
/// Compresses a ReadOnlySpan using Brotli compression with pooled output buffer.
|
||||
/// </summary>
|
||||
public static byte[] CompressSpan(ReadOnlySpan<byte> data, CompressionLevel compressionLevel = CompressionLevel.Optimal)
|
||||
{
|
||||
if (data == null || data.Length == 0)
|
||||
if (data.IsEmpty)
|
||||
return [];
|
||||
|
||||
using var outputStream = new MemoryStream();
|
||||
using (var brotliStream = new BrotliStream(outputStream, compressionLevel, leaveOpen: true))
|
||||
// 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
|
||||
{
|
||||
brotliStream.Write(data, 0, data.Length);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
using var outputStream = new BufferWriterStream(writer);
|
||||
using var brotliStream = new BrotliStream(outputStream, compressionLevel, leaveOpen: true);
|
||||
brotliStream.Write(data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decompresses Brotli-compressed data to a string.
|
||||
/// Consider using Decompress + direct UTF-8 JsonTo for better performance.
|
||||
/// </summary>
|
||||
/// <param name="compressedData">The compressed data.</param>
|
||||
/// <returns>Decompressed string.</returns>
|
||||
public static string DecompressToString(byte[] compressedData)
|
||||
{
|
||||
if (compressedData == null || compressedData.Length == 0)
|
||||
|
|
@ -60,36 +107,109 @@ public static class BrotliHelper
|
|||
|
||||
/// <summary>
|
||||
/// Decompresses Brotli-compressed data to a byte array.
|
||||
/// Uses pooled buffers internally for reduced GC pressure.
|
||||
/// </summary>
|
||||
/// <param name="compressedData">The compressed data.</param>
|
||||
/// <returns>Decompressed byte array.</returns>
|
||||
public static byte[] Decompress(byte[] compressedData)
|
||||
{
|
||||
if (compressedData == null || compressedData.Length == 0)
|
||||
return [];
|
||||
|
||||
using var inputStream = new MemoryStream(compressedData);
|
||||
using var brotliStream = new BrotliStream(inputStream, CompressionMode.Decompress);
|
||||
using var outputStream = new MemoryStream();
|
||||
return DecompressSpan(compressedData.AsSpan());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decompresses Brotli-compressed data from a ReadOnlySpan.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
brotliStream.CopyTo(outputStream);
|
||||
return outputStream.ToArray();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decompresses directly into an ArrayBufferWriter for zero-copy deserialization.
|
||||
/// </summary>
|
||||
public static void DecompressTo(ReadOnlySpan<byte> compressedData, ArrayBufferWriter<byte> writer)
|
||||
{
|
||||
if (compressedData.IsEmpty)
|
||||
return;
|
||||
|
||||
using var inputStream = new ReadOnlySpanStream(compressedData);
|
||||
using var brotliStream = new BrotliStream(inputStream, CompressionMode.Decompress);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decompresses to a rented buffer. Caller must return the buffer to ArrayPool.
|
||||
/// Returns the actual decompressed length.
|
||||
/// </summary>
|
||||
public static (byte[] Buffer, int Length) DecompressToRentedBuffer(ReadOnlySpan<byte> compressedData)
|
||||
{
|
||||
if (compressedData.IsEmpty)
|
||||
return ([], 0);
|
||||
|
||||
var estimatedSize = Math.Max(compressedData.Length * 4, DefaultBufferSize);
|
||||
var outputBuffer = ArrayPool<byte>.Shared.Rent(estimatedSize);
|
||||
|
||||
using var inputStream = new ReadOnlySpanStream(compressedData);
|
||||
using var brotliStream = new BrotliStream(inputStream, CompressionMode.Decompress);
|
||||
|
||||
var totalRead = 0;
|
||||
int bytesRead;
|
||||
|
||||
while ((bytesRead = brotliStream.Read(outputBuffer.AsSpan(totalRead))) > 0)
|
||||
{
|
||||
totalRead += bytesRead;
|
||||
|
||||
// Need larger buffer
|
||||
if (totalRead == outputBuffer.Length)
|
||||
{
|
||||
var newBuffer = ArrayPool<byte>.Shared.Rent(outputBuffer.Length * 2);
|
||||
outputBuffer.AsSpan(0, totalRead).CopyTo(newBuffer);
|
||||
ArrayPool<byte>.Shared.Return(outputBuffer);
|
||||
outputBuffer = newBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
return (outputBuffer, totalRead);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the data appears to be Brotli compressed.
|
||||
/// Brotli doesn't have a magic number, so we use a heuristic approach.
|
||||
/// </summary>
|
||||
/// <param name="data">The data to check.</param>
|
||||
/// <returns>True if the data might be Brotli compressed.</returns>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static bool IsBrotliCompressed(byte[] data)
|
||||
{
|
||||
if (data == null || data.Length < 4)
|
||||
return false;
|
||||
|
||||
// Brotli doesn't have a magic header like gzip (0x1F 0x8B)
|
||||
// We check if it's NOT valid UTF-8 JSON (starts with { or [)
|
||||
// and try to decompress
|
||||
var firstByte = data[0];
|
||||
|
||||
// If it starts with '{' (0x7B) or '[' (0x5B), it's likely uncompressed JSON
|
||||
|
|
@ -99,16 +219,196 @@ public static class BrotliHelper
|
|||
// Try to decompress - if it fails, it's not Brotli
|
||||
try
|
||||
{
|
||||
using var inputStream = new MemoryStream(data);
|
||||
using var inputStream = new MemoryStream(data, 0, Math.Min(data.Length, 64), writable: false);
|
||||
using var brotliStream = new BrotliStream(inputStream, CompressionMode.Decompress);
|
||||
|
||||
// Try to read first byte
|
||||
var buffer = new byte[1];
|
||||
return brotliStream.Read(buffer, 0, 1) >= 0;
|
||||
Span<byte> buffer = stackalloc byte[1];
|
||||
return brotliStream.Read(buffer) >= 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,115 @@ public static class AcJsonDeserializer
|
|||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static T? Deserialize<T>(string json) => Deserialize<T>(json, AcJsonSerializerOptions.Default);
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize UTF-8 encoded JSON bytes to a new object of type T with default options.
|
||||
/// Zero-allocation path when used with Utf8JsonReader.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static T? Deserialize<T>(ReadOnlySpan<byte> utf8Json) => Deserialize<T>(utf8Json, AcJsonSerializerOptions.Default);
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize UTF-8 encoded JSON bytes to a new object of type T with specified options.
|
||||
/// Zero-allocation path when used with Utf8JsonReader.
|
||||
/// </summary>
|
||||
public static T? Deserialize<T>(ReadOnlySpan<byte> utf8Json, in AcJsonSerializerOptions options)
|
||||
{
|
||||
if (utf8Json.IsEmpty) return default;
|
||||
|
||||
// Check for "null" literal
|
||||
if (utf8Json.Length == 4 && utf8Json.SequenceEqual("null"u8)) return default;
|
||||
|
||||
var targetType = typeof(T);
|
||||
|
||||
try
|
||||
{
|
||||
// Fast path for no reference handling - use Utf8JsonReader directly (no string allocation)
|
||||
if (!options.UseReferenceHandling)
|
||||
{
|
||||
var reader = new Utf8JsonReader(utf8Json, new JsonReaderOptions { MaxDepth = options.MaxDepth });
|
||||
if (!reader.Read()) return default;
|
||||
return (T?)ReadValueFromReader(ref reader, targetType, options.MaxDepth, 0);
|
||||
}
|
||||
|
||||
// Reference handling requires DOM - copy to array for JsonDocument.Parse
|
||||
var jsonBytes = utf8Json.ToArray();
|
||||
using var doc = JsonDocument.Parse(jsonBytes);
|
||||
var context = DeserializationContextPool.Get(options);
|
||||
try
|
||||
{
|
||||
var result = ReadValue(doc.RootElement, targetType, context, 0);
|
||||
context.ResolveReferences();
|
||||
return (T?)result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeserializationContextPool.Return(context);
|
||||
}
|
||||
}
|
||||
catch (AcJsonDeserializationException) { throw; }
|
||||
catch (System.Text.Json.JsonException ex)
|
||||
{
|
||||
throw new AcJsonDeserializationException($"Failed to parse JSON for type '{targetType.Name}': {ex.Message}", null, targetType, ex);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
|
||||
{
|
||||
throw new AcJsonDeserializationException($"Failed to convert JSON value for type '{targetType.Name}': {ex.Message}", null, targetType, ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize UTF-8 encoded JSON bytes to specified type with default options.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static object? Deserialize(ReadOnlySpan<byte> utf8Json, Type targetType)
|
||||
=> Deserialize(utf8Json, targetType, AcJsonSerializerOptions.Default);
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize UTF-8 encoded JSON bytes to specified type with specified options.
|
||||
/// </summary>
|
||||
public static object? Deserialize(ReadOnlySpan<byte> utf8Json, in Type targetType, in AcJsonSerializerOptions options)
|
||||
{
|
||||
if (utf8Json.IsEmpty) return null;
|
||||
|
||||
// Check for "null" literal
|
||||
if (utf8Json.Length == 4 && utf8Json.SequenceEqual("null"u8)) return null;
|
||||
|
||||
try
|
||||
{
|
||||
// Fast path for no reference handling
|
||||
if (!options.UseReferenceHandling)
|
||||
{
|
||||
var reader = new Utf8JsonReader(utf8Json, new JsonReaderOptions { MaxDepth = options.MaxDepth });
|
||||
if (!reader.Read()) return null;
|
||||
return ReadValueFromReader(ref reader, targetType, options.MaxDepth, 0);
|
||||
}
|
||||
|
||||
// Reference handling requires DOM - copy to array for JsonDocument.Parse
|
||||
var jsonBytes = utf8Json.ToArray();
|
||||
using var doc = JsonDocument.Parse(jsonBytes);
|
||||
var context = DeserializationContextPool.Get(options);
|
||||
try
|
||||
{
|
||||
var result = ReadValue(doc.RootElement, targetType, context, 0);
|
||||
context.ResolveReferences();
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeserializationContextPool.Return(context);
|
||||
}
|
||||
}
|
||||
catch (AcJsonDeserializationException) { throw; }
|
||||
catch (System.Text.Json.JsonException ex)
|
||||
{
|
||||
throw new AcJsonDeserializationException($"Failed to parse JSON for type '{targetType.Name}': {ex.Message}", null, targetType, ex);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
|
||||
{
|
||||
throw new AcJsonDeserializationException($"Failed to convert JSON value for type '{targetType.Name}': {ex.Message}", null, targetType, ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize JSON string to a new object of type T with specified options.
|
||||
/// </summary>
|
||||
|
|
@ -131,7 +240,9 @@ public static class AcJsonDeserializer
|
|||
return DeserializeWithUtf8ReaderNonGeneric(json, targetType, options.MaxDepth);
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
// Reference handling requires DOM - copy to array for JsonDocument.Parse
|
||||
var jsonBytes = Encoding.UTF8.GetBytes(json);
|
||||
using var doc = JsonDocument.Parse(jsonBytes);
|
||||
var context = DeserializationContextPool.Get(options);
|
||||
try
|
||||
{
|
||||
|
|
|
|||
|
|
@ -381,6 +381,34 @@ public static class SerializeObjectExtensions
|
|||
return AcJsonDeserializer.Deserialize<T>(json, options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize UTF-8 encoded JSON bytes to object with default options.
|
||||
/// Zero-allocation path - no string conversion needed.
|
||||
/// </summary>
|
||||
public static T? JsonTo<T>(this ReadOnlySpan<byte> utf8Json)
|
||||
=> AcJsonDeserializer.Deserialize<T>(utf8Json);
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize UTF-8 encoded JSON bytes to object with specified options.
|
||||
/// Zero-allocation path - no string conversion needed.
|
||||
/// </summary>
|
||||
public static T? JsonTo<T>(this ReadOnlySpan<byte> utf8Json, AcJsonSerializerOptions options)
|
||||
=> AcJsonDeserializer.Deserialize<T>(utf8Json, options);
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize UTF-8 encoded JSON bytes to object with default options.
|
||||
/// Zero-allocation path - no string conversion needed.
|
||||
/// </summary>
|
||||
public static T? JsonTo<T>(this byte[] utf8Json)
|
||||
=> AcJsonDeserializer.Deserialize<T>(utf8Json.AsSpan());
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize UTF-8 encoded JSON bytes to object with specified options.
|
||||
/// Zero-allocation path - no string conversion needed.
|
||||
/// </summary>
|
||||
public static T? JsonTo<T>(this byte[] utf8Json, AcJsonSerializerOptions options)
|
||||
=> AcJsonDeserializer.Deserialize<T>(utf8Json.AsSpan(), options);
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize JSON to specified type with default options.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -244,12 +244,14 @@ public abstract class AcWebSignalRHubBase<TSignalRTags, TLogger>(IConfiguration
|
|||
|
||||
/// <summary>
|
||||
/// Sends message to client.
|
||||
/// Both Binary and JSON modes use AcBinarySerializer directly (no MessagePack wrapper).
|
||||
/// Both Binary and JSON modes use AcBinarySerializer directly with pooled buffer.
|
||||
/// </summary>
|
||||
protected virtual async Task SendMessageToClient(IAcSignalRHubItemServer sendTo, int messageTag, ISignalRMessage message, int? requestId = null)
|
||||
{
|
||||
// Both modes use AcBinarySerializer - unified serialization
|
||||
var responseBytes = message.ToBinary();
|
||||
// Use ArrayBufferWriter for zero-copy serialization to pooled buffer
|
||||
var writer = new ArrayBufferWriter<byte>(256);
|
||||
message.ToBinary(writer);
|
||||
var responseBytes = writer.WrittenSpan.ToArray();
|
||||
|
||||
var tagName = ConstHelper.NameByValue<TSignalRTags>(messageTag);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
using MessagePack;
|
||||
using AyCode.Core.Interfaces;
|
||||
using AyCode.Core.Compression;
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using JsonIgnoreAttribute = Newtonsoft.Json.JsonIgnoreAttribute;
|
||||
using STJIgnore = System.Text.Json.Serialization.JsonIgnoreAttribute;
|
||||
|
||||
|
|
@ -26,7 +28,7 @@ public class IdMessage
|
|||
Ids = new List<string>(ids.Length);
|
||||
for (var i = 0; i < ids.Length; i++)
|
||||
{
|
||||
Ids.Add(ids[i].ToJson());
|
||||
Ids.Add(SerializeValue(ids[i]));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -36,7 +38,7 @@ public class IdMessage
|
|||
/// </summary>
|
||||
public IdMessage(object id)
|
||||
{
|
||||
Ids = new List<string>(1) { id.ToJson() };
|
||||
Ids = new List<string>(1) { SerializeValue(id) };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -49,10 +51,41 @@ public class IdMessage
|
|||
Ids = new List<string>(idsArray.Length);
|
||||
for (var i = 0; i < idsArray.Length; i++)
|
||||
{
|
||||
Ids.Add(idsArray[i].ToJson());
|
||||
Ids.Add(SerializeGuid(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);
|
||||
}
|
||||
|
||||
|
|
@ -193,18 +226,18 @@ public sealed class SignalResponseMessage<TResponseData> : ISignalResponseMessag
|
|||
/// <summary>
|
||||
/// Unified signal response message that supports both JSON and Binary serialization.
|
||||
/// JSON mode uses Brotli compression for reduced payload size.
|
||||
/// Optimized: decompression is performed only once and cached.
|
||||
/// Optimized: uses pooled buffers for decompression, zero-copy deserialization path.
|
||||
/// </summary>
|
||||
public sealed class SignalResponseDataMessage : ISignalResponseMessage
|
||||
public sealed class SignalResponseDataMessage : ISignalResponseMessage, IDisposable
|
||||
{
|
||||
public int MessageTag { get; set; }
|
||||
public SignalResponseStatus Status { get; set; }
|
||||
public AcSerializerType DataSerializerType { get; set; }
|
||||
public byte[]? ResponseDataBin { get; set; }
|
||||
|
||||
[JsonIgnore] [STJIgnore] private string? _cachedJson;
|
||||
|
||||
[JsonIgnore] [STJIgnore] private object? _cachedResponseData;
|
||||
[JsonIgnore] [STJIgnore] private byte[]? _rentedDecompressedBuffer;
|
||||
[JsonIgnore] [STJIgnore] private int _decompressedLength;
|
||||
|
||||
public SignalResponseDataMessage()
|
||||
{
|
||||
|
|
@ -235,14 +268,17 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage
|
|||
}
|
||||
|
||||
var binaryOptions = serializerOptions as AcBinarySerializerOptions ?? AcBinarySerializerOptions.Default;
|
||||
ResponseDataBin = responseData.ToBinary(binaryOptions);
|
||||
// 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.Trim();
|
||||
var trimmed = strData.AsSpan().Trim();
|
||||
if (trimmed.Length > 1 && (trimmed[0] == '{' || trimmed[0] == '[') && (trimmed[^1] == '}' || trimmed[^1] == ']'))
|
||||
json = strData;
|
||||
else
|
||||
|
|
@ -263,18 +299,54 @@ public sealed class SignalResponseDataMessage : ISignalResponseMessage
|
|||
|
||||
/// <summary>
|
||||
/// Deserializes the ResponseData to the specified type.
|
||||
/// Uses cached decompressed JSON for repeated calls.
|
||||
/// 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 (DataSerializerType == AcSerializerType.Binary) return (T)(_cachedResponseData = ResponseDataBin.BinaryTo<T>());
|
||||
if (DataSerializerType == AcSerializerType.Binary)
|
||||
return (T)(_cachedResponseData = ResponseDataBin.BinaryTo<T>()!);
|
||||
|
||||
_cachedJson ??= BrotliHelper.DecompressToString(ResponseDataBin);
|
||||
// Decompress Brotli to pooled buffer and deserialize directly from ReadOnlySpan (no string allocation)
|
||||
EnsureDecompressed();
|
||||
var result = AcJsonDeserializer.Deserialize<T>(new ReadOnlySpan<byte>(_rentedDecompressedBuffer, 0, _decompressedLength));
|
||||
_cachedResponseData = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
return (T)(_cachedResponseData = _cachedJson.JsonTo<T>());
|
||||
/// <summary>
|
||||
/// Gets the decompressed JSON bytes as a ReadOnlySpan for direct processing.
|
||||
/// </summary>
|
||||
public ReadOnlySpan<byte> GetDecompressedJsonSpan()
|
||||
{
|
||||
if (ResponseDataBin == null) return ReadOnlySpan<byte>.Empty;
|
||||
if (DataSerializerType == AcSerializerType.Binary) return ReadOnlySpan<byte>.Empty;
|
||||
|
||||
EnsureDecompressed();
|
||||
return _rentedDecompressedBuffer.AsSpan(0, _decompressedLength);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void EnsureDecompressed()
|
||||
{
|
||||
if (_rentedDecompressedBuffer != null) return;
|
||||
|
||||
var (buffer, length) = BrotliHelper.DecompressToRentedBuffer(ResponseDataBin.AsSpan());
|
||||
_rentedDecompressedBuffer = buffer;
|
||||
_decompressedLength = length;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_rentedDecompressedBuffer != null)
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(_rentedDecompressedBuffer);
|
||||
_rentedDecompressedBuffer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue