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;
+ }
}
}