From 489ef7486c10416f179d64eafab7b69974a0a8be Mon Sep 17 00:00:00 2001 From: Loretta Date: Sat, 13 Dec 2025 23:23:16 +0100 Subject: [PATCH] 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 span/byte[] extension methods for zero-allocation deserialization. - All changes are backward compatible and reduce GC pressure for high-throughput scenarios. --- AyCode.Core/Compression/BrotliHelper.cs | 368 ++++++++++++++++-- AyCode.Core/Extensions/AcJsonDeserializer.cs | 113 +++++- .../Extensions/SerializeObjectExtensions.cs | 28 ++ .../SignalRs/AcWebSignalRHubBase.cs | 8 +- .../SignalRs/IAcSignalRHubClient.cs | 98 ++++- 5 files changed, 564 insertions(+), 51 deletions(-) diff --git a/AyCode.Core/Compression/BrotliHelper.cs b/AyCode.Core/Compression/BrotliHelper.cs index 3739023..5f8403d 100644 --- a/AyCode.Core/Compression/BrotliHelper.cs +++ b/AyCode.Core/Compression/BrotliHelper.cs @@ -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; /// /// 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. /// public static class BrotliHelper { + private const int DefaultBufferSize = 4096; + private const int MaxStackAllocSize = 1024; + /// - /// Compresses a string using Brotli compression. + /// Compresses a string using Brotli compression with pooled buffers. /// - /// The text to compress. - /// Compression level (default: Optimal). - /// Compressed byte array. 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 utf8Bytes = stackalloc byte[maxByteCount]; + var actualLength = Encoding.UTF8.GetBytes(text.AsSpan(), utf8Bytes); + return CompressSpan(utf8Bytes[..actualLength], compressionLevel); + } + + var rentedBuffer = ArrayPool.Shared.Rent(maxByteCount); + try + { + var actualLength = Encoding.UTF8.GetBytes(text.AsSpan(), rentedBuffer); + return CompressSpan(rentedBuffer.AsSpan(0, actualLength), compressionLevel); + } + finally + { + ArrayPool.Shared.Return(rentedBuffer); + } } /// /// Compresses a byte array using Brotli compression. /// - /// The data to compress. - /// Compression level (default: Optimal). - /// Compressed byte array. + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static byte[] Compress(byte[] data, CompressionLevel compressionLevel = CompressionLevel.Optimal) + => data == null || data.Length == 0 ? [] : CompressSpan(data.AsSpan(), compressionLevel); + + /// + /// Compresses a ReadOnlySpan using Brotli compression with pooled output buffer. + /// + public static byte[] CompressSpan(ReadOnlySpan 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.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.Shared.Return(outputBuffer); + } + } - return outputStream.ToArray(); + /// + /// Compresses data directly to an IBufferWriter (zero intermediate allocation). + /// + public static void CompressTo(ReadOnlySpan data, IBufferWriter 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); } /// /// Decompresses Brotli-compressed data to a string. + /// Consider using Decompress + direct UTF-8 JsonTo for better performance. /// - /// The compressed data. - /// Decompressed string. public static string DecompressToString(byte[] compressedData) { if (compressedData == null || compressedData.Length == 0) @@ -60,36 +107,109 @@ public static class BrotliHelper /// /// Decompresses Brotli-compressed data to a byte array. + /// Uses pooled buffers internally for reduced GC pressure. /// - /// The compressed data. - /// Decompressed byte array. 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()); + } + + /// + /// Decompresses Brotli-compressed data from a ReadOnlySpan. + /// + public static byte[] DecompressSpan(ReadOnlySpan 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.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.Shared.Return(outputBuffer); + } + } + + /// + /// Decompresses directly into an ArrayBufferWriter for zero-copy deserialization. + /// + public static void DecompressTo(ReadOnlySpan compressedData, ArrayBufferWriter 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); + } + + /// + /// Decompresses to a rented buffer. Caller must return the buffer to ArrayPool. + /// Returns the actual decompressed length. + /// + public static (byte[] Buffer, int Length) DecompressToRentedBuffer(ReadOnlySpan compressedData) + { + if (compressedData.IsEmpty) + return ([], 0); + + var estimatedSize = Math.Max(compressedData.Length * 4, DefaultBufferSize); + var outputBuffer = ArrayPool.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.Shared.Rent(outputBuffer.Length * 2); + outputBuffer.AsSpan(0, totalRead).CopyTo(newBuffer); + ArrayPool.Shared.Return(outputBuffer); + outputBuffer = newBuffer; + } + } + + return (outputBuffer, totalRead); } /// /// Checks if the data appears to be Brotli compressed. - /// Brotli doesn't have a magic number, so we use a heuristic approach. /// - /// The data to check. - /// True if the data might be Brotli compressed. + [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 buffer = stackalloc byte[1]; + return brotliStream.Read(buffer) >= 0; } catch { return false; } } + + #region Helper Stream Classes + + /// + /// MemoryStream that uses a pre-allocated buffer and can expand using ArrayPool. + /// + 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 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.Shared.Rent(newSize); + _buffer.AsSpan(0, _length).CopyTo(newBuffer); + + if (_ownsBuffer) ArrayPool.Shared.Return(_buffer); + + _buffer = newBuffer; + _ownsBuffer = true; + } + + protected override void Dispose(bool disposing) + { + if (_ownsBuffer && _buffer != null) + { + ArrayPool.Shared.Return(_buffer); + _buffer = null!; + } + base.Dispose(disposing); + } + } + + /// + /// Read-only stream wrapper for ReadOnlySpan. + /// + private sealed class ReadOnlySpanStream : Stream + { + private readonly ReadOnlyMemory _data; + private int _position; + + public ReadOnlySpanStream(ReadOnlySpan 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 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() { } + } + + /// + /// Stream that writes directly to an IBufferWriter. + /// + private sealed class BufferWriterStream : Stream + { + private readonly IBufferWriter _writer; + + public BufferWriterStream(IBufferWriter 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 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 } diff --git a/AyCode.Core/Extensions/AcJsonDeserializer.cs b/AyCode.Core/Extensions/AcJsonDeserializer.cs index e16d203..327ebe2 100644 --- a/AyCode.Core/Extensions/AcJsonDeserializer.cs +++ b/AyCode.Core/Extensions/AcJsonDeserializer.cs @@ -51,6 +51,115 @@ public static class AcJsonDeserializer [MethodImpl(MethodImplOptions.AggressiveInlining)] public static T? Deserialize(string json) => Deserialize(json, AcJsonSerializerOptions.Default); + /// + /// Deserialize UTF-8 encoded JSON bytes to a new object of type T with default options. + /// Zero-allocation path when used with Utf8JsonReader. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T? Deserialize(ReadOnlySpan utf8Json) => Deserialize(utf8Json, AcJsonSerializerOptions.Default); + + /// + /// Deserialize UTF-8 encoded JSON bytes to a new object of type T with specified options. + /// Zero-allocation path when used with Utf8JsonReader. + /// + public static T? Deserialize(ReadOnlySpan 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); + } + } + + /// + /// Deserialize UTF-8 encoded JSON bytes to specified type with default options. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static object? Deserialize(ReadOnlySpan utf8Json, Type targetType) + => Deserialize(utf8Json, targetType, AcJsonSerializerOptions.Default); + + /// + /// Deserialize UTF-8 encoded JSON bytes to specified type with specified options. + /// + public static object? Deserialize(ReadOnlySpan 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); + } + } + /// /// Deserialize JSON string to a new object of type T with specified options. /// @@ -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 { diff --git a/AyCode.Core/Extensions/SerializeObjectExtensions.cs b/AyCode.Core/Extensions/SerializeObjectExtensions.cs index 3be6eee..dd84143 100644 --- a/AyCode.Core/Extensions/SerializeObjectExtensions.cs +++ b/AyCode.Core/Extensions/SerializeObjectExtensions.cs @@ -381,6 +381,34 @@ public static class SerializeObjectExtensions return AcJsonDeserializer.Deserialize(json, options); } + /// + /// Deserialize UTF-8 encoded JSON bytes to object with default options. + /// Zero-allocation path - no string conversion needed. + /// + public static T? JsonTo(this ReadOnlySpan utf8Json) + => AcJsonDeserializer.Deserialize(utf8Json); + + /// + /// Deserialize UTF-8 encoded JSON bytes to object with specified options. + /// Zero-allocation path - no string conversion needed. + /// + public static T? JsonTo(this ReadOnlySpan utf8Json, AcJsonSerializerOptions options) + => AcJsonDeserializer.Deserialize(utf8Json, options); + + /// + /// Deserialize UTF-8 encoded JSON bytes to object with default options. + /// Zero-allocation path - no string conversion needed. + /// + public static T? JsonTo(this byte[] utf8Json) + => AcJsonDeserializer.Deserialize(utf8Json.AsSpan()); + + /// + /// Deserialize UTF-8 encoded JSON bytes to object with specified options. + /// Zero-allocation path - no string conversion needed. + /// + public static T? JsonTo(this byte[] utf8Json, AcJsonSerializerOptions options) + => AcJsonDeserializer.Deserialize(utf8Json.AsSpan(), options); + /// /// Deserialize JSON to specified type with default options. /// diff --git a/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs b/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs index c7abc99..4b58db3 100644 --- a/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs +++ b/AyCode.Services.Server/SignalRs/AcWebSignalRHubBase.cs @@ -244,12 +244,14 @@ public abstract class AcWebSignalRHubBase(IConfiguration /// /// 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. /// 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(256); + message.ToBinary(writer); + var responseBytes = writer.WrittenSpan.ToArray(); var tagName = ConstHelper.NameByValue(messageTag); diff --git a/AyCode.Services/SignalRs/IAcSignalRHubClient.cs b/AyCode.Services/SignalRs/IAcSignalRHubClient.cs index bdac7e5..53805b4 100644 --- a/AyCode.Services/SignalRs/IAcSignalRHubClient.cs +++ b/AyCode.Services/SignalRs/IAcSignalRHubClient.cs @@ -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(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 /// public IdMessage(object id) { - Ids = new List(1) { id.ToJson() }; + Ids = new List(1) { SerializeValue(id) }; } /// @@ -49,10 +51,41 @@ public class IdMessage Ids = new List(idsArray.Length); for (var i = 0; i < idsArray.Length; i++) { - Ids.Add(idsArray[i].ToJson()); + Ids.Add(SerializeGuid(idsArray[i])); } } + /// + /// 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. + /// + [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 : ISignalResponseMessag /// /// 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. /// -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(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 /// /// 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. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public T? GetResponseData() { if (_cachedResponseData != null) return (T)_cachedResponseData; if (ResponseDataBin == null) return default; - if (DataSerializerType == AcSerializerType.Binary) return (T)(_cachedResponseData = ResponseDataBin.BinaryTo()); + if (DataSerializerType == AcSerializerType.Binary) + return (T)(_cachedResponseData = ResponseDataBin.BinaryTo()!); - _cachedJson ??= BrotliHelper.DecompressToString(ResponseDataBin); + // Decompress Brotli to pooled buffer and deserialize directly from ReadOnlySpan (no string allocation) + EnsureDecompressed(); + var result = AcJsonDeserializer.Deserialize(new ReadOnlySpan(_rentedDecompressedBuffer, 0, _decompressedLength)); + _cachedResponseData = result; + return result; + } - return (T)(_cachedResponseData = _cachedJson.JsonTo()); + /// + /// Gets the decompressed JSON bytes as a ReadOnlySpan for direct processing. + /// + public ReadOnlySpan GetDecompressedJsonSpan() + { + if (ResponseDataBin == null) return ReadOnlySpan.Empty; + if (DataSerializerType == AcSerializerType.Binary) return ReadOnlySpan.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.Shared.Return(_rentedDecompressedBuffer); + _rentedDecompressedBuffer = null; + } } }