Refactor: new high-performance binary serializer/deserializer

Major overhaul of binary serialization:
- Rewrote AcBinarySerializer as a static, optimized, feature-rich serializer with VarInt encoding, string interning, property name tables, reference handling, and optional metadata.
- Added AcBinaryDeserializer with matching features, including merge/populate support and robust error handling.
- Introduced AcBinarySerializerOptions and AcSerializerOptions base class for unified serializer configuration (JSON/binary).
- Added generic extension methods for "any" serialization/deserialization based on options.
- Updated tests and benchmarks for new APIs; fixed null byte code and added DateTimeKind test.
- Fixed namespace typos and improved code style and documentation.
This commit is contained in:
Loretta 2025-12-12 21:03:39 +01:00
parent b9e83e2ef8
commit 2147d981db
9 changed files with 2804 additions and 52 deletions

View File

@ -1,7 +1,7 @@
using AyCode.Core.Extensions; using AyCode.Core.Extensions;
using AyCode.Core.Tests.TestModels; using AyCode.Core.Tests.TestModels;
namespace AyCode.Core.Tests.Serialization; namespace AyCode.Core.Tests.serialization;
[TestClass] [TestClass]
public class AcBinarySerializerTests public class AcBinarySerializerTests
@ -13,7 +13,7 @@ public class AcBinarySerializerTests
{ {
var result = AcBinarySerializer.Serialize<object?>(null); var result = AcBinarySerializer.Serialize<object?>(null);
Assert.AreEqual(1, result.Length); Assert.AreEqual(1, result.Length);
Assert.AreEqual((byte)32, result[0]); // BinaryTypeCode.Null = 32 Assert.AreEqual((byte)0, result[0]); // BinaryTypeCode.Null = 0
} }
[TestMethod] [TestMethod]
@ -70,6 +70,20 @@ public class AcBinarySerializerTests
Assert.AreEqual(value, result); Assert.AreEqual(value, result);
} }
[TestMethod]
[DataRow(DateTimeKind.Unspecified)]
[DataRow(DateTimeKind.Utc)]
[DataRow(DateTimeKind.Local)]
public void Serialize_DateTime_PreservesKind(DateTimeKind kind)
{
var value = new DateTime(2024, 12, 25, 10, 30, 45, kind);
var binary = AcBinarySerializer.Serialize(value);
var result = AcBinaryDeserializer.Deserialize<DateTime>(binary);
Assert.AreEqual(value.Ticks, result.Ticks);
Assert.AreEqual(value.Kind, result.Kind);
}
[TestMethod] [TestMethod]
public void Serialize_Guid_RoundTrip() public void Serialize_Guid_RoundTrip()
{ {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,179 @@
using System.Runtime.CompilerServices;
namespace AyCode.Core.Extensions;
/// <summary>
/// Options for AcBinarySerializer and AcBinaryDeserializer.
/// Optimized for speed and memory efficiency over raw size.
/// </summary>
public sealed class AcBinarySerializerOptions : AcSerializerOptions
{
public override AcSerializerType SerializerType { get; init; } = AcSerializerType.Binary;
/// <summary>
/// Current binary format version. Incremented when breaking changes are made.
/// </summary>
public const byte FormatVersion = 1;
/// <summary>
/// Default options instance with metadata and string interning enabled.
/// </summary>
public static readonly AcBinarySerializerOptions Default = new();
/// <summary>
/// Options optimized for maximum speed (no metadata, no interning).
/// Use when deserializer knows the exact type structure.
/// </summary>
public static readonly AcBinarySerializerOptions FastMode = new()
{
UseMetadata = false,
UseStringInterning = false,
UseReferenceHandling = false
};
/// <summary>
/// Options for shallow serialization (root level only).
/// </summary>
public static readonly AcBinarySerializerOptions ShallowCopy = new()
{
MaxDepth = 0,
UseReferenceHandling = false
};
/// <summary>
/// Whether to include metadata header with property names.
/// When enabled, property names are stored once and referenced by index.
/// Improves deserialization speed and allows schema evolution.
/// Default: true
/// </summary>
public bool UseMetadata { get; init; } = true;
/// <summary>
/// Whether to intern repeated strings.
/// When enabled, duplicate strings are stored once and referenced by index.
/// Reduces size and memory for objects with many repeated string values.
/// Default: true
/// </summary>
public bool UseStringInterning { get; init; } = true;
/// <summary>
/// Minimum string length to consider for interning.
/// Shorter strings are written inline to avoid overhead.
/// Default: 4 (strings shorter than 4 chars are not interned)
/// </summary>
public byte MinStringInternLength { get; init; } = 4;
/// <summary>
/// Initial capacity for serialization buffer.
/// Default: 4096 bytes
/// </summary>
public int InitialBufferCapacity { get; init; } = 4096;
/// <summary>
/// Creates options with specified max depth.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static AcBinarySerializerOptions WithMaxDepth(byte maxDepth) => new() { MaxDepth = maxDepth };
/// <summary>
/// Creates options without reference handling.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static AcBinarySerializerOptions WithoutReferenceHandling() => new() { UseReferenceHandling = false };
/// <summary>
/// Creates options without metadata (faster but less flexible).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static AcBinarySerializerOptions WithoutMetadata() => new() { UseMetadata = false };
}
/// <summary>
/// Binary type codes for serialization.
/// Designed for fast switch dispatch and compact storage.
/// Lower 5 bits = type code (0-31)
/// Upper 3 bits = flags (interned, reference, has-type-info)
/// </summary>
internal static class BinaryTypeCode
{
// Primitive types (0-15)
public const byte Null = 0;
public const byte True = 1;
public const byte False = 2;
public const byte Int8 = 3;
public const byte UInt8 = 4;
public const byte Int16 = 5;
public const byte UInt16 = 6;
public const byte Int32 = 7;
public const byte UInt32 = 8;
public const byte Int64 = 9;
public const byte UInt64 = 10;
public const byte Float32 = 11;
public const byte Float64 = 12;
public const byte Decimal = 13;
public const byte Char = 14;
// String types (16-19)
public const byte String = 16; // Inline UTF8 string
public const byte StringInterned = 17; // Reference to interned string by index
public const byte StringEmpty = 18; // Empty string marker
// Date/Time types (20-23)
public const byte DateTime = 20;
public const byte DateTimeOffset = 21;
public const byte TimeSpan = 22;
public const byte Guid = 23;
// Enum (24)
public const byte Enum = 24;
// Complex types (25-31)
public const byte Object = 25; // Start of object
public const byte ObjectEnd = 26; // End of object marker
public const byte ObjectRef = 27; // Reference to previously serialized object
public const byte Array = 28; // Start of array/list
public const byte Dictionary = 29; // Start of dictionary
public const byte ByteArray = 30; // Optimized byte[] storage
// Special markers (32+, for header/meta)
public const byte MetadataHeader = 32; // Binary has metadata section
public const byte NoMetadataHeader = 33; // Binary has no metadata
// Compact integer variants (for VarInt optimization)
public const byte Int32Tiny = 64; // -16 to 111 stored in single byte (value = code - 64 - 16)
public const byte Int32TinyMax = 191; // Upper bound for tiny int
/// <summary>
/// Check if type code represents a reference (string or object).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsReference(byte code) => code == StringInterned || code == ObjectRef;
/// <summary>
/// Check if type code is a tiny int (single byte int32 encoding).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsTinyInt(byte code) => code >= Int32Tiny && code <= Int32TinyMax;
/// <summary>
/// Decode tiny int value from type code.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int DecodeTinyInt(byte code) => code - Int32Tiny - 16;
/// <summary>
/// Encode small int value (-16 to 111) as type code.
/// Returns true if value fits in tiny encoding.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool TryEncodeTinyInt(int value, out byte code)
{
if (value >= -16 && value <= 111)
{
code = (byte)(value + 16 + Int32Tiny);
return true;
}
code = 0;
return false;
}
}

View File

@ -0,0 +1,54 @@
namespace AyCode.Core.Extensions;
public enum AcSerializerType : byte
{
Json = 0,
Binary = 1,
}
public abstract class AcSerializerOptions
{
public abstract AcSerializerType SerializerType { get; init; }
/// <summary>
/// Whether to use $id/$ref reference handling for circular references.
/// Default: true
/// </summary>
public bool UseReferenceHandling { get; init; } = true;
/// <summary>
/// Maximum depth for serialization/deserialization.
/// 0 = root level only (primitives of root object)
/// 1 = root + first level of nested objects/collections
/// byte.MaxValue (255) = effectively unlimited
/// Default: byte.MaxValue
/// </summary>
public byte MaxDepth { get; init; } = byte.MaxValue;
}
/// <summary>
/// Options for AcJsonSerializer and AcJsonDeserializer.
/// </summary>
public sealed class AcJsonSerializerOptions : AcSerializerOptions
{
public override AcSerializerType SerializerType { get; init; } = AcSerializerType.Json;
/// <summary>
/// Default options instance with reference handling enabled and max depth.
/// </summary>
public static readonly AcJsonSerializerOptions Default = new();
/// <summary>
/// Options for shallow serialization (root level only, no references).
/// </summary>
public static readonly AcJsonSerializerOptions ShallowCopy = new() { MaxDepth = 0, UseReferenceHandling = false };
/// <summary>
/// Creates options with specified max depth.
/// </summary>
public static AcJsonSerializerOptions WithMaxDepth(byte maxDepth) => new() { MaxDepth = maxDepth };
/// <summary>
/// Creates options without reference handling.
/// </summary>
public static AcJsonSerializerOptions WithoutReferenceHandling() => new() { UseReferenceHandling = false };
}

View File

@ -11,47 +11,6 @@ using Newtonsoft.Json;
namespace AyCode.Core.Extensions; namespace AyCode.Core.Extensions;
/// <summary>
/// Options for AcJsonSerializer and AcJsonDeserializer.
/// </summary>
public sealed class AcJsonSerializerOptions
{
/// <summary>
/// Default options instance with reference handling enabled and max depth.
/// </summary>
public static readonly AcJsonSerializerOptions Default = new();
/// <summary>
/// Options for shallow serialization (root level only, no references).
/// </summary>
public static readonly AcJsonSerializerOptions ShallowCopy = new() { MaxDepth = 0, UseReferenceHandling = false };
/// <summary>
/// Whether to use $id/$ref reference handling for circular references.
/// Default: true
/// </summary>
public bool UseReferenceHandling { get; init; } = true;
/// <summary>
/// Maximum depth for serialization/deserialization.
/// 0 = root level only (primitives of root object)
/// 1 = root + first level of nested objects/collections
/// byte.MaxValue (255) = effectively unlimited
/// Default: byte.MaxValue
/// </summary>
public byte MaxDepth { get; init; } = byte.MaxValue;
/// <summary>
/// Creates options with specified max depth.
/// </summary>
public static AcJsonSerializerOptions WithMaxDepth(byte maxDepth) => new() { MaxDepth = maxDepth };
/// <summary>
/// Creates options without reference handling.
/// </summary>
public static AcJsonSerializerOptions WithoutReferenceHandling() => new() { UseReferenceHandling = false };
}
/// <summary> /// <summary>
/// Cached result for IId type info lookup. /// Cached result for IId type info lookup.
/// </summary> /// </summary>

View File

@ -340,8 +340,7 @@ public static class SerializeObjectExtensions
/// <summary> /// <summary>
/// Serialize object to JSON string with default options. /// Serialize object to JSON string with default options.
/// </summary> /// </summary>
public static string ToJson<T>(this T source) public static string ToJson<T>(this T source) => AcJsonSerializer.Serialize(source);
=> AcJsonSerializer.Serialize(source);
/// <summary> /// <summary>
/// Serialize object to JSON string with specified options. /// Serialize object to JSON string with specified options.
@ -445,14 +444,83 @@ public static class SerializeObjectExtensions
public static T MessagePackTo<T>(this byte[] message, MessagePackSerializerOptions options) public static T MessagePackTo<T>(this byte[] message, MessagePackSerializerOptions options)
=> MessagePackSerializer.Deserialize<T>(message, options); => MessagePackSerializer.Deserialize<T>(message, options);
public static object ToAny<T>(this T source, AcSerializerOptions options)
{
if (options.SerializerType == AcSerializerType.Json) return ToJson(source, (AcJsonSerializerOptions)options);
return ToBinary(source, (AcBinarySerializerOptions)options);
}
/// <summary>
/// Deserialize data (JSON string or binary byte[]) to object based on options.
/// </summary>
public static T? AnyTo<T>(this object data, AcSerializerOptions options)
{
if (options.SerializerType == AcSerializerType.Json)
return ((string)data).JsonTo<T>((AcJsonSerializerOptions)options);
return ((byte[])data).BinaryTo<T>();
}
/// <summary>
/// Deserialize data to specified type based on options.
/// </summary>
public static object? AnyTo(this object data, Type targetType, AcSerializerOptions options)
{
if (options.SerializerType == AcSerializerType.Json)
return ((string)data).JsonTo(targetType, (AcJsonSerializerOptions)options);
return ((byte[])data).BinaryTo(targetType);
}
/// <summary>
/// Populate existing object from data based on options.
/// </summary>
public static void AnyTo<T>(this object data, T target, AcSerializerOptions options) where T : class
{
if (options.SerializerType == AcSerializerType.Json)
((string)data).JsonTo(target, (AcJsonSerializerOptions)options);
else
((byte[])data).BinaryTo(target);
}
/// <summary>
/// Populate existing object with merge semantics based on options.
/// </summary>
public static void AnyToMerge<T>(this object data, T target, AcSerializerOptions options) where T : class
{
if (options.SerializerType == AcSerializerType.Json)
((string)data).JsonTo(target, (AcJsonSerializerOptions)options); // JSON always merges
else
((byte[])data).BinaryToMerge(target);
}
/// <summary>
/// Clone object via serialization based on options.
/// </summary>
public static T? CloneToAny<T>(this T source, AcSerializerOptions options) where T : class
{
if (options.SerializerType == AcSerializerType.Json)
return source.CloneTo<T>((AcJsonSerializerOptions)options);
return source.BinaryCloneTo();
}
/// <summary>
/// Copy object properties to target via serialization based on options.
/// </summary>
public static void CopyToAny<T>(this T source, T target, AcSerializerOptions options) where T : class
{
if (options.SerializerType == AcSerializerType.Json)
source.CopyTo(target, (AcJsonSerializerOptions)options);
else
source.BinaryCopyTo(target);
}
#region Binary Serialization Extension Methods #region Binary Serialization Extension Methods
/// <summary> /// <summary>
/// Serialize object to binary byte array with default options. /// Serialize object to binary byte array with default options.
/// Significantly faster than JSON, especially for large data in WASM. /// Significantly faster than JSON, especially for large data in WASM.
/// </summary> /// </summary>
public static byte[] ToBinary<T>(this T source) public static byte[] ToBinary<T>(this T source) => AcBinarySerializer.Serialize(source);
=> AcBinarySerializer.Serialize(source);
/// <summary> /// <summary>
/// Serialize object to binary byte array with specified options. /// Serialize object to binary byte array with specified options.

View File

@ -17,7 +17,9 @@ public abstract class AcWebSignalRHubBase<TSignalRTags, TLogger>(IConfiguration
{ {
protected readonly List<AcDynamicMethodCallModel<SignalRAttribute>> DynamicMethodCallModels = []; protected readonly List<AcDynamicMethodCallModel<SignalRAttribute>> DynamicMethodCallModels = [];
protected TLogger Logger = logger; protected TLogger Logger = logger;
protected IConfiguration Configuration = configuration; protected IConfiguration Configuration = configuration;
protected AcSerializerOptions SerializerOptions = new AcBinarySerializerOptions();
#region Connection Lifecycle #region Connection Lifecycle

View File

@ -153,7 +153,7 @@ namespace BenchmarkSuite1
pointsPerMeasurement: 5); pointsPerMeasurement: 5);
var binaryWithRef = AcBinarySerializer.Serialize(order, AcBinarySerializerOptions.Default); var binaryWithRef = AcBinarySerializer.Serialize(order, AcBinarySerializerOptions.Default);
var binaryNoRef = AcBinarySerializer.Serialize(order, AcBinarySerializerOptions.WithoutReferenceHandling); var binaryNoRef = AcBinarySerializer.Serialize(order, AcBinarySerializerOptions.WithoutReferenceHandling());
var json = AcJsonSerializer.Serialize(order, AcJsonSerializerOptions.WithoutReferenceHandling()); var json = AcJsonSerializer.Serialize(order, AcJsonSerializerOptions.WithoutReferenceHandling());
var jsonBytes = Encoding.UTF8.GetByteCount(json); var jsonBytes = Encoding.UTF8.GetByteCount(json);