AyCode.Core/AyCode.Core/Compression/BrotliHelper.cs

415 lines
14 KiB
C#

using System.Buffers;
using System.IO.Compression;
using System.Runtime.CompilerServices;
using System.Text;
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 with pooled buffers.
/// </summary>
public static byte[] Compress(string text, CompressionLevel compressionLevel = CompressionLevel.Optimal)
{
if (string.IsNullOrEmpty(text))
return [];
// 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>
[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.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 PooledMemoryStream(outputBuffer);
using (var brotliStream = new BrotliStream(outputStream, compressionLevel, leaveOpen: true))
{
brotliStream.Write(data);
}
return outputStream.ToArray();
}
finally
{
ArrayPool<byte>.Shared.Return(outputBuffer);
}
}
/// <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>
public static string DecompressToString(byte[] compressedData)
{
if (compressedData == null || compressedData.Length == 0)
return string.Empty;
var decompressedBytes = Decompress(compressedData);
return Encoding.UTF8.GetString(decompressedBytes);
}
/// <summary>
/// Decompresses Brotli-compressed data to a byte array.
/// Uses pooled buffers internally for reduced GC pressure.
/// </summary>
public static byte[] Decompress(byte[] compressedData)
{
if (compressedData == null || compressedData.Length == 0)
return [];
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);
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.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsBrotliCompressed(byte[] data)
{
if (data == null || data.Length < 4)
return false;
var firstByte = data[0];
// If it starts with '{' (0x7B) or '[' (0x5B), it's likely uncompressed JSON
if (firstByte == 0x7B || firstByte == 0x5B)
return false;
// Try to decompress - if it fails, it's not Brotli
try
{
using var inputStream = new MemoryStream(data, 0, Math.Min(data.Length, 64), writable: false);
using var brotliStream = new BrotliStream(inputStream, CompressionMode.Decompress);
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
}