High-perf streaming JSON (de)serialization, refactor

Major upgrade to AcJsonSerializer/AcJsonDeserializer:
- Add Utf8JsonReader/Writer fast-paths for streaming, allocation-free (de)serialization when reference handling is not needed, matching STJ performance.
- Populate/merge now uses streaming for in-place updates.
- Type metadata caches TypeCode, UnderlyingType, and uses frozen dictionaries for hot property lookup.
- Context pooling reduces allocations and GC pressure.
- Primitive (de)serialization uses TypeCode-based fast paths; improved enum, Guid, DateTime, etc. handling.
- Shared UTF8 buffer pool for efficient encoding/decoding.
- Pre-encoded property names and STJ writer for output.
- Improved validation, error handling, and double-serialization detection.
- Expanded benchmarks: small/medium/large, with/without refs, AyCode vs STJ vs Newtonsoft, grouped by scenario.
- General code modernization: aggressive inlining, ref params, ReferenceEquals, improved naming, and comments.
- Unified IId<T> and collection element detection; consistent $id/$ref handling.

Brings AyCode JSON (de)serializer to near-parity with STJ for non-ref scenarios, while retaining advanced features and improving maintainability and performance.
This commit is contained in:
Loretta 2025-12-12 16:23:54 +01:00
parent ad426feba4
commit a945db9b09
5 changed files with 1742 additions and 683 deletions

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@ using System.Linq.Expressions;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Text; using System.Text;
using System.Text.Json;
using AyCode.Core.Interfaces; using AyCode.Core.Interfaces;
using Newtonsoft.Json; using Newtonsoft.Json;
using static AyCode.Core.Extensions.JsonUtilities; using static AyCode.Core.Extensions.JsonUtilities;
@ -14,92 +15,95 @@ namespace AyCode.Core.Extensions;
/// <summary> /// <summary>
/// High-performance custom JSON serializer optimized for IId&lt;T&gt; reference handling. /// High-performance custom JSON serializer optimized for IId&lt;T&gt; reference handling.
/// Features: /// Uses Utf8JsonWriter for high-performance UTF-8 output (STJ approach).
/// - Single-pass serialization with inline $id/$ref emission
/// - StringBuilder-based output (no intermediate string allocations)
/// - Compiled expression tree property accessors
/// - Smart reference tracking: only emits $id when object is actually referenced later
/// - MaxDepth support for controlling serialization depth
/// </summary> /// </summary>
public static class AcJsonSerializer public static class AcJsonSerializer
{ {
private static readonly ConcurrentDictionary<Type, TypeMetadata> TypeMetadataCache = new(); private static readonly ConcurrentDictionary<Type, TypeMetadata> TypeMetadataCache = new();
// Pre-encoded property names for $id/$ref (STJ optimization)
private static readonly JsonEncodedText IdPropertyEncoded = JsonEncodedText.Encode("$id");
private static readonly JsonEncodedText RefPropertyEncoded = JsonEncodedText.Encode("$ref");
/// <summary> /// <summary>
/// Serialize object to JSON string with default options. /// Serialize object to JSON string with default options.
/// </summary> /// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static string Serialize<T>(T value) => Serialize(value, AcJsonSerializerOptions.Default); public static string Serialize<T>(T value) => Serialize(value, AcJsonSerializerOptions.Default);
/// <summary> /// <summary>
/// Serialize object to JSON string with specified options. /// Serialize object to JSON string with specified options.
/// </summary> /// </summary>
public static string Serialize<T>(T value, AcJsonSerializerOptions options) public static string Serialize<T>(T value, in AcJsonSerializerOptions options)
{ {
if (value == null) return "null"; if (value == null) return "null";
var type = typeof(T); var type = value.GetType();
if (TrySerializePrimitive(value, type, out var primitiveJson)) if (TrySerializePrimitiveRuntime(value, type, out var primitiveJson))
return primitiveJson; return primitiveJson;
var context = new SerializationContext(options); var context = SerializationContextPool.Get(options);
try
if (options.UseReferenceHandling) {
ScanReferences(value, context, 0); if (options.UseReferenceHandling)
ScanReferences(value, context, 0);
context.StartWriting();
WriteValue(value, context, 0); WriteValue(value, context, 0);
return context.GetResult();
return context.GetResult(); }
finally
{
SerializationContextPool.Return(context);
}
} }
private static bool TrySerializePrimitive<T>(T value, Type type, out string json) [MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool TrySerializePrimitiveRuntime(object value, in Type type, out string json)
{ {
var underlyingType = Nullable.GetUnderlyingType(type) ?? type; var underlyingType = Nullable.GetUnderlyingType(type) ?? type;
var typeCode = Type.GetTypeCode(underlyingType);
if (underlyingType == StringType) { json = SerializeString((string)(object)value!); return true; } switch (typeCode)
if (underlyingType == IntType) { json = ((int)(object)value!).ToString(CultureInfo.InvariantCulture); return true; }
if (underlyingType == LongType) { json = ((long)(object)value!).ToString(CultureInfo.InvariantCulture); return true; }
if (underlyingType == BoolType) { json = (bool)(object)value! ? "true" : "false"; return true; }
if (underlyingType == DoubleType)
{ {
var d = (double)(object)value!; case TypeCode.String: json = SerializeString((string)value); return true;
json = double.IsNaN(d) || double.IsInfinity(d) ? "null" : d.ToString("G17", CultureInfo.InvariantCulture); case TypeCode.Int32: json = ((int)value).ToString(CultureInfo.InvariantCulture); return true;
return true; case TypeCode.Int64: json = ((long)value).ToString(CultureInfo.InvariantCulture); return true;
case TypeCode.Boolean: json = (bool)value ? "true" : "false"; return true;
case TypeCode.Double:
var d = (double)value;
json = double.IsNaN(d) || double.IsInfinity(d) ? "null" : d.ToString("G17", CultureInfo.InvariantCulture);
return true;
case TypeCode.Decimal: json = ((decimal)value).ToString(CultureInfo.InvariantCulture); return true;
case TypeCode.Single:
var f = (float)value;
json = float.IsNaN(f) || float.IsInfinity(f) ? "null" : f.ToString("G9", CultureInfo.InvariantCulture);
return true;
case TypeCode.DateTime: json = $"\"{((DateTime)value).ToString("O", CultureInfo.InvariantCulture)}\""; return true;
case TypeCode.Byte: json = ((byte)value).ToString(CultureInfo.InvariantCulture); return true;
case TypeCode.Int16: json = ((short)value).ToString(CultureInfo.InvariantCulture); return true;
case TypeCode.UInt16: json = ((ushort)value).ToString(CultureInfo.InvariantCulture); return true;
case TypeCode.UInt32: json = ((uint)value).ToString(CultureInfo.InvariantCulture); return true;
case TypeCode.UInt64: json = ((ulong)value).ToString(CultureInfo.InvariantCulture); return true;
case TypeCode.SByte: json = ((sbyte)value).ToString(CultureInfo.InvariantCulture); return true;
case TypeCode.Char: json = SerializeString(value.ToString()!); return true;
} }
if (underlyingType == DecimalType) { json = ((decimal)(object)value!).ToString(CultureInfo.InvariantCulture); return true; } if (ReferenceEquals(underlyingType, GuidType)) { json = $"\"{((Guid)value).ToString("D")}\""; return true; }
if (ReferenceEquals(underlyingType, DateTimeOffsetType)) { json = $"\"{((DateTimeOffset)value).ToString("O", CultureInfo.InvariantCulture)}\""; return true; }
if (underlyingType == FloatType) if (ReferenceEquals(underlyingType, TimeSpanType)) { json = $"\"{((TimeSpan)value).ToString("c", CultureInfo.InvariantCulture)}\""; return true; }
{
var f = (float)(object)value!;
json = float.IsNaN(f) || float.IsInfinity(f) ? "null" : f.ToString("G9", CultureInfo.InvariantCulture);
return true;
}
if (underlyingType == DateTimeType) { json = $"\"{((DateTime)(object)value!).ToString("O", CultureInfo.InvariantCulture)}\""; return true; }
if (underlyingType == DateTimeOffsetType) { json = $"\"{((DateTimeOffset)(object)value!).ToString("O", CultureInfo.InvariantCulture)}\""; return true; }
if (underlyingType == GuidType) { json = $"\"{((Guid)(object)value!).ToString("D")}\""; return true; }
if (underlyingType == TimeSpanType) { json = $"\"{((TimeSpan)(object)value!).ToString("c", CultureInfo.InvariantCulture)}\""; return true; }
if (underlyingType.IsEnum) { json = Convert.ToInt32(value).ToString(CultureInfo.InvariantCulture); return true; } if (underlyingType.IsEnum) { json = Convert.ToInt32(value).ToString(CultureInfo.InvariantCulture); return true; }
if (underlyingType == ByteType) { json = ((byte)(object)value!).ToString(CultureInfo.InvariantCulture); return true; }
if (underlyingType == ShortType) { json = ((short)(object)value!).ToString(CultureInfo.InvariantCulture); return true; }
if (underlyingType == UShortType) { json = ((ushort)(object)value!).ToString(CultureInfo.InvariantCulture); return true; }
if (underlyingType == UIntType) { json = ((uint)(object)value!).ToString(CultureInfo.InvariantCulture); return true; }
if (underlyingType == ULongType) { json = ((ulong)(object)value!).ToString(CultureInfo.InvariantCulture); return true; }
if (underlyingType == SByteType) { json = ((sbyte)(object)value!).ToString(CultureInfo.InvariantCulture); return true; }
if (underlyingType == CharType) { json = SerializeString(value!.ToString()!); return true; }
json = ""; json = "";
return false; return false;
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static string SerializeString(string value) private static string SerializeString(string value)
{ {
if (!NeedsEscaping(value)) return $"\"{value}\""; if (!NeedsEscaping(value)) return string.Concat("\"", value, "\"");
var sb = new StringBuilder(value.Length + 2); var sb = new StringBuilder(value.Length + 8);
sb.Append('"'); sb.Append('"');
WriteEscapedString(sb, value); WriteEscapedString(sb, value);
sb.Append('"'); sb.Append('"');
@ -110,16 +114,13 @@ public static class AcJsonSerializer
private static void ScanReferences(object? value, SerializationContext context, int depth) private static void ScanReferences(object? value, SerializationContext context, int depth)
{ {
if (value == null) return; if (value == null || depth > context.MaxDepth) return;
if (depth > context.MaxDepth) return;
var type = value.GetType(); var type = value.GetType();
if (IsPrimitiveOrStringFast(type)) return; if (IsPrimitiveOrStringFast(type)) return;
if (!context.TrackForScanning(value)) return; if (!context.TrackForScanning(value)) return;
if (value is IEnumerable enumerable && type != StringType) if (value is IEnumerable enumerable && !ReferenceEquals(type, StringType))
{ {
foreach (var item in enumerable) foreach (var item in enumerable)
if (item != null) ScanReferences(item, context, depth + 1); if (item != null) ScanReferences(item, context, depth + 1);
@ -127,9 +128,11 @@ public static class AcJsonSerializer
} }
var metadata = GetTypeMetadata(type); var metadata = GetTypeMetadata(type);
foreach (var prop in metadata.Properties) var props = metadata.Properties;
var propCount = props.Length;
for (var i = 0; i < propCount; i++)
{ {
var propValue = prop.GetValue(value); var propValue = props[i].GetValue(value);
if (propValue != null) ScanReferences(propValue, context, depth + 1); if (propValue != null) ScanReferences(propValue, context, depth + 1);
} }
} }
@ -140,133 +143,154 @@ public static class AcJsonSerializer
private static void WriteValue(object? value, SerializationContext context, int depth) private static void WriteValue(object? value, SerializationContext context, int depth)
{ {
if (value == null) { context.WriteNull(); return; } if (value == null) { context.Writer.WriteNullValue(); return; }
var type = value.GetType(); var type = value.GetType();
if (TryWritePrimitive(value, type, context)) return; if (TryWritePrimitive(value, type, context.Writer)) return;
// Check depth limit for complex types if (depth > context.MaxDepth) { context.Writer.WriteNullValue(); return; }
if (depth > context.MaxDepth)
{
context.WriteNull();
return;
}
if (value is IDictionary dictionary) { WriteDictionary(dictionary, context, depth); return; } if (value is IDictionary dictionary) { WriteDictionary(dictionary, context, depth); return; }
if (value is IEnumerable enumerable && type != StringType) { WriteArray(enumerable, context, depth); return; } if (value is IEnumerable enumerable && !ReferenceEquals(type, StringType)) { WriteArray(enumerable, context, depth); return; }
WriteObject(value, type, context, depth); WriteObject(value, type, context, depth);
} }
private static void WriteObject(object value, Type type, SerializationContext context, int depth) [MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void WriteObject(object value, in Type type, SerializationContext context, int depth)
{ {
var writer = context.Writer;
if (context.UseReferenceHandling && context.TryGetExistingRef(value, out var refId)) if (context.UseReferenceHandling && context.TryGetExistingRef(value, out var refId))
{ {
context.WriteRef(refId); writer.WriteStartObject();
writer.WriteString(RefPropertyEncoded, refId);
writer.WriteEndObject();
return; return;
} }
context.WriteObjectStart(); writer.WriteStartObject();
var isFirst = true;
if (context.UseReferenceHandling && context.ShouldWriteId(value, out var id)) if (context.UseReferenceHandling && context.ShouldWriteId(value, out var id))
{ {
context.WritePropertyName("$id", ref isFirst); writer.WriteString(IdPropertyEncoded, id);
context.WriteString(id);
context.MarkAsWritten(value, id); context.MarkAsWritten(value, id);
} }
var metadata = GetTypeMetadata(type); var metadata = GetTypeMetadata(type);
foreach (var prop in metadata.Properties) var props = metadata.Properties;
var propCount = props.Length;
var nextDepth = depth + 1;
for (var i = 0; i < propCount; i++)
{ {
var prop = props[i];
var propValue = prop.GetValue(value); var propValue = prop.GetValue(value);
if (propValue == null) continue; if (propValue == null) continue;
if (IsSerializerDefaultValue(propValue, prop.PropertyType)) continue; if (IsDefaultValueFast(propValue, prop.PropertyTypeCode, prop.PropertyType)) continue;
context.WritePropertyName(prop.JsonName, ref isFirst); writer.WritePropertyName(prop.JsonNameEncoded);
WriteValue(propValue, context, depth + 1); WriteValue(propValue, context, nextDepth);
} }
context.WriteObjectEnd(); writer.WriteEndObject();
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void WriteArray(IEnumerable enumerable, SerializationContext context, int depth) private static void WriteArray(IEnumerable enumerable, SerializationContext context, int depth)
{ {
context.WriteArrayStart(); var writer = context.Writer;
var isFirst = true; writer.WriteStartArray();
var nextDepth = depth + 1;
foreach (var item in enumerable) foreach (var item in enumerable)
{ {
if (!isFirst) context.WriteComma(); WriteValue(item, context, nextDepth);
isFirst = false;
WriteValue(item, context, depth + 1);
} }
context.WriteArrayEnd(); writer.WriteEndArray();
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void WriteDictionary(IDictionary dictionary, SerializationContext context, int depth) private static void WriteDictionary(IDictionary dictionary, SerializationContext context, int depth)
{ {
context.WriteObjectStart(); var writer = context.Writer;
var isFirst = true; writer.WriteStartObject();
var nextDepth = depth + 1;
foreach (DictionaryEntry entry in dictionary) foreach (DictionaryEntry entry in dictionary)
{ {
context.WritePropertyName(entry.Key?.ToString() ?? "", ref isFirst); writer.WritePropertyName(entry.Key?.ToString() ?? "");
WriteValue(entry.Value, context, depth + 1); WriteValue(entry.Value, context, nextDepth);
} }
context.WriteObjectEnd(); writer.WriteEndObject();
} }
private static bool TryWritePrimitive(object value, Type type, SerializationContext context) [MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool TryWritePrimitive(object value, in Type type, Utf8JsonWriter writer)
{ {
var underlyingType = Nullable.GetUnderlyingType(type) ?? type; var underlyingType = Nullable.GetUnderlyingType(type) ?? type;
var typeCode = Type.GetTypeCode(underlyingType);
if (underlyingType == StringType) { context.WriteString((string)value); return true; } switch (typeCode)
if (underlyingType == IntType) { context.WriteInt((int)value); return true; } {
if (underlyingType == LongType) { context.WriteLong((long)value); return true; } case TypeCode.String: writer.WriteStringValue((string)value); return true;
if (underlyingType == BoolType) { context.WriteBool((bool)value); return true; } case TypeCode.Int32: writer.WriteNumberValue((int)value); return true;
if (underlyingType == DoubleType) { context.WriteDouble((double)value); return true; } case TypeCode.Int64: writer.WriteNumberValue((long)value); return true;
if (underlyingType == DecimalType) { context.WriteDecimal((decimal)value); return true; } case TypeCode.Boolean: writer.WriteBooleanValue((bool)value); return true;
if (underlyingType == FloatType) { context.WriteFloat((float)value); return true; } case TypeCode.Double:
if (underlyingType == DateTimeType) { context.WriteDateTime((DateTime)value); return true; } var d = (double)value;
if (underlyingType == DateTimeOffsetType) { context.WriteDateTimeOffset((DateTimeOffset)value); return true; } if (double.IsNaN(d) || double.IsInfinity(d)) writer.WriteNullValue();
if (underlyingType == GuidType) { context.WriteGuid((Guid)value); return true; } else writer.WriteNumberValue(d);
if (underlyingType == TimeSpanType) { context.WriteTimeSpan((TimeSpan)value); return true; } return true;
if (underlyingType.IsEnum) { context.WriteInt(Convert.ToInt32(value)); return true; } case TypeCode.Decimal: writer.WriteNumberValue((decimal)value); return true;
if (underlyingType == ByteType) { context.WriteInt((byte)value); return true; } case TypeCode.Single:
if (underlyingType == ShortType) { context.WriteInt((short)value); return true; } var f = (float)value;
if (underlyingType == UShortType) { context.WriteInt((ushort)value); return true; } if (float.IsNaN(f) || float.IsInfinity(f)) writer.WriteNullValue();
if (underlyingType == UIntType) { context.WriteLong((uint)value); return true; } else writer.WriteNumberValue(f);
if (underlyingType == ULongType) { context.WriteULong((ulong)value); return true; } return true;
if (underlyingType == SByteType) { context.WriteInt((sbyte)value); return true; } case TypeCode.DateTime: writer.WriteStringValue((DateTime)value); return true;
if (underlyingType == CharType) { context.WriteString(value.ToString()!); return true; } case TypeCode.Byte: writer.WriteNumberValue((byte)value); return true;
case TypeCode.Int16: writer.WriteNumberValue((short)value); return true;
case TypeCode.UInt16: writer.WriteNumberValue((ushort)value); return true;
case TypeCode.UInt32: writer.WriteNumberValue((uint)value); return true;
case TypeCode.UInt64: writer.WriteNumberValue((ulong)value); return true;
case TypeCode.SByte: writer.WriteNumberValue((sbyte)value); return true;
case TypeCode.Char: writer.WriteStringValue(value.ToString()); return true;
}
if (ReferenceEquals(underlyingType, GuidType)) { writer.WriteStringValue((Guid)value); return true; }
if (ReferenceEquals(underlyingType, DateTimeOffsetType)) { writer.WriteStringValue((DateTimeOffset)value); return true; }
if (ReferenceEquals(underlyingType, TimeSpanType)) { writer.WriteStringValue(((TimeSpan)value).ToString("c", CultureInfo.InvariantCulture)); return true; }
if (underlyingType.IsEnum) { writer.WriteNumberValue(Convert.ToInt32(value)); return true; }
return false; return false;
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsSerializerDefaultValue(object value, Type propertyType) private static bool IsDefaultValueFast(object value, TypeCode typeCode, in Type propertyType)
{ {
var type = Nullable.GetUnderlyingType(propertyType) ?? propertyType; switch (typeCode)
{
case TypeCode.Int32: return (int)value == 0;
case TypeCode.Int64: return (long)value == 0L;
case TypeCode.Double: return (double)value == 0.0;
case TypeCode.Decimal: return (decimal)value == 0m;
case TypeCode.Single: return (float)value == 0f;
case TypeCode.Byte: return (byte)value == 0;
case TypeCode.Int16: return (short)value == 0;
case TypeCode.UInt16: return (ushort)value == 0;
case TypeCode.UInt32: return (uint)value == 0;
case TypeCode.UInt64: return (ulong)value == 0;
case TypeCode.SByte: return (sbyte)value == 0;
case TypeCode.Boolean: return (bool)value == false;
case TypeCode.String: return string.IsNullOrEmpty((string)value);
}
if (type == IntType) return (int)value == 0; if (propertyType.IsEnum) return Convert.ToInt32(value) == 0;
if (type == LongType) return (long)value == 0L; if (ReferenceEquals(propertyType, GuidType)) return (Guid)value == Guid.Empty;
if (type == DoubleType) return (double)value == 0.0;
if (type == DecimalType) return (decimal)value == 0m;
if (type == FloatType) return (float)value == 0f;
if (type == ByteType) return (byte)value == 0;
if (type == ShortType) return (short)value == 0;
if (type == UShortType) return (ushort)value == 0;
if (type == UIntType) return (uint)value == 0;
if (type == ULongType) return (ulong)value == 0;
if (type == SByteType) return (sbyte)value == 0;
if (type == BoolType) return (bool)value == false;
if (type == StringType) return string.IsNullOrEmpty((string)value);
if (type.IsEnum) return Convert.ToInt32(value) == 0;
if (type == GuidType) return (Guid)value == Guid.Empty;
return false; return false;
} }
@ -275,7 +299,8 @@ public static class AcJsonSerializer
#region Type Metadata #region Type Metadata
private static TypeMetadata GetTypeMetadata(Type type) [MethodImpl(MethodImplOptions.AggressiveInlining)]
private static TypeMetadata GetTypeMetadata(in Type type)
=> TypeMetadataCache.GetOrAdd(type, static t => new TypeMetadata(t)); => TypeMetadataCache.GetOrAdd(type, static t => new TypeMetadata(t));
private sealed class TypeMetadata private sealed class TypeMetadata
@ -295,14 +320,18 @@ public static class AcJsonSerializer
private sealed class PropertyAccessor private sealed class PropertyAccessor
{ {
public string JsonName { get; } public readonly string JsonName;
public Type PropertyType { get; } public readonly JsonEncodedText JsonNameEncoded; // STJ optimization - pre-encoded property name
public readonly Type PropertyType;
public readonly TypeCode PropertyTypeCode;
private readonly Func<object, object?> _getter; private readonly Func<object, object?> _getter;
public PropertyAccessor(PropertyInfo prop) public PropertyAccessor(PropertyInfo prop)
{ {
JsonName = prop.Name; JsonName = prop.Name;
PropertyType = prop.PropertyType; JsonNameEncoded = JsonEncodedText.Encode(prop.Name);
PropertyType = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType;
PropertyTypeCode = Type.GetTypeCode(PropertyType);
_getter = CreateCompiledGetter(prop.DeclaringType!, prop); _getter = CreateCompiledGetter(prop.DeclaringType!, prop);
} }
@ -321,35 +350,89 @@ public static class AcJsonSerializer
#endregion #endregion
#region Context Pool
private static class SerializationContextPool
{
private static readonly ConcurrentQueue<SerializationContext> Pool = new();
private const int MaxPoolSize = 16;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SerializationContext Get(in AcJsonSerializerOptions options)
{
if (Pool.TryDequeue(out var context))
{
context.Reset(options);
return context;
}
return new SerializationContext(options);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Return(SerializationContext context)
{
if (Pool.Count < MaxPoolSize)
{
context.Clear();
Pool.Enqueue(context);
}
}
}
#endregion
#region Serialization Context #region Serialization Context
private sealed class SerializationContext private sealed class SerializationContext : IDisposable
{ {
private readonly StringBuilder _sb = new(4096); private readonly ArrayBufferWriter<byte> _buffer;
private readonly Dictionary<object, int>? _scanOccurrences; public Utf8JsonWriter Writer { get; private set; }
private readonly Dictionary<object, string>? _writtenRefs;
private readonly HashSet<object>? _multiReferenced;
private int _nextId = 1;
private static readonly ArrayPool<char> CharPool = ArrayPool<char>.Shared; private Dictionary<object, int>? _scanOccurrences;
private readonly char[] _numberBuffer = CharPool.Rent(64); private Dictionary<object, string>? _writtenRefs;
private HashSet<object>? _multiReferenced;
private int _nextId;
public bool UseReferenceHandling { get; } public bool UseReferenceHandling { get; private set; }
public byte MaxDepth { get; } public byte MaxDepth { get; private set; }
public SerializationContext(AcJsonSerializerOptions options) private static readonly JsonWriterOptions WriterOptions = new()
{
Indented = false,
SkipValidation = true // Skip validation for performance
};
public SerializationContext(in AcJsonSerializerOptions options)
{
_buffer = new ArrayBufferWriter<byte>(4096);
Writer = new Utf8JsonWriter(_buffer, WriterOptions);
Reset(options);
}
public void Reset(in AcJsonSerializerOptions options)
{ {
UseReferenceHandling = options.UseReferenceHandling; UseReferenceHandling = options.UseReferenceHandling;
MaxDepth = options.MaxDepth; MaxDepth = options.MaxDepth;
_nextId = 1;
if (UseReferenceHandling) if (UseReferenceHandling)
{ {
_scanOccurrences = new Dictionary<object, int>(64, ReferenceEqualityComparer.Instance); _scanOccurrences ??= new Dictionary<object, int>(64, ReferenceEqualityComparer.Instance);
_writtenRefs = new Dictionary<object, string>(32, ReferenceEqualityComparer.Instance); _writtenRefs ??= new Dictionary<object, string>(32, ReferenceEqualityComparer.Instance);
_multiReferenced = new HashSet<object>(32, ReferenceEqualityComparer.Instance); _multiReferenced ??= new HashSet<object>(32, ReferenceEqualityComparer.Instance);
} }
} }
public void Clear()
{
Writer.Reset();
_buffer.Clear();
_scanOccurrences?.Clear();
_writtenRefs?.Clear();
_multiReferenced?.Clear();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TrackForScanning(object obj) public bool TrackForScanning(object obj)
{ {
if (_scanOccurrences == null) return true; if (_scanOccurrences == null) return true;
@ -360,8 +443,7 @@ public static class AcJsonSerializer
return true; return true;
} }
public void StartWriting() { } [MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool ShouldWriteId(object obj, out string id) public bool ShouldWriteId(object obj, out string id)
{ {
if (_multiReferenced != null && _multiReferenced.Contains(obj) && !_writtenRefs!.ContainsKey(obj)) if (_multiReferenced != null && _multiReferenced.Contains(obj) && !_writtenRefs!.ContainsKey(obj))
@ -373,122 +455,26 @@ public static class AcJsonSerializer
return false; return false;
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void MarkAsWritten(object obj, string id) => _writtenRefs![obj] = id; public void MarkAsWritten(object obj, string id) => _writtenRefs![obj] = id;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool TryGetExistingRef(object obj, out string refId) public bool TryGetExistingRef(object obj, out string refId)
{ {
if (_writtenRefs != null) return _writtenRefs.TryGetValue(obj, out refId!); if (_writtenRefs != null) return _writtenRefs.TryGetValue(obj, out refId!);
refId = ""; refId = "";
return false; return false;
} }
public void WriteRef(string refId) { _sb.Append("{\"$ref\":\""); _sb.Append(refId); _sb.Append("\"}"); }
public string GetResult() { CharPool.Return(_numberBuffer); return _sb.ToString(); } public string GetResult()
[MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteNull() => _sb.Append("null");
[MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteObjectStart() => _sb.Append('{');
[MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteObjectEnd() => _sb.Append('}');
[MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteArrayStart() => _sb.Append('[');
[MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteArrayEnd() => _sb.Append(']');
[MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteComma() => _sb.Append(',');
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void WritePropertyName(string name, ref bool isFirst)
{ {
if (!isFirst) _sb.Append(','); Writer.Flush();
isFirst = false; return Encoding.UTF8.GetString(_buffer.WrittenSpan);
_sb.Append('"'); _sb.Append(name); _sb.Append("\":");
} }
public void WriteString(string value) public void Dispose()
{ {
_sb.Append('"'); Writer.Dispose();
if (!NeedsEscaping(value)) _sb.Append(value);
else WriteEscapedString(_sb, value);
_sb.Append('"');
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void WriteInt(int value)
{
if (value.TryFormat(_numberBuffer, out var written)) _sb.Append(_numberBuffer, 0, written);
else _sb.Append(value);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void WriteLong(long value)
{
if (value.TryFormat(_numberBuffer, out var written)) _sb.Append(_numberBuffer, 0, written);
else _sb.Append(value);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void WriteULong(ulong value)
{
if (value.TryFormat(_numberBuffer, out var written)) _sb.Append(_numberBuffer, 0, written);
else _sb.Append(value);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)] public void WriteBool(bool value) => _sb.Append(value ? "true" : "false");
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void WriteDouble(double value)
{
if (double.IsNaN(value) || double.IsInfinity(value)) _sb.Append("null");
else if (value.TryFormat(_numberBuffer, out var written, "G17", CultureInfo.InvariantCulture)) _sb.Append(_numberBuffer, 0, written);
else _sb.Append(value.ToString("G17", CultureInfo.InvariantCulture));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void WriteFloat(float value)
{
if (float.IsNaN(value) || float.IsInfinity(value)) _sb.Append("null");
else if (value.TryFormat(_numberBuffer, out var written, "G9", CultureInfo.InvariantCulture)) _sb.Append(_numberBuffer, 0, written);
else _sb.Append(value.ToString("G9", CultureInfo.InvariantCulture));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void WriteDecimal(decimal value)
{
if (value.TryFormat(_numberBuffer, out var written, provider: CultureInfo.InvariantCulture)) _sb.Append(_numberBuffer, 0, written);
else _sb.Append(value.ToString(CultureInfo.InvariantCulture));
}
public void WriteDateTime(DateTime value)
{
_sb.Append('"');
Span<char> buffer = stackalloc char[33];
if (value.TryFormat(buffer, out var written, "O", CultureInfo.InvariantCulture)) _sb.Append(buffer[..written]);
else _sb.Append(value.ToString("O", CultureInfo.InvariantCulture));
_sb.Append('"');
}
public void WriteDateTimeOffset(DateTimeOffset value)
{
_sb.Append('"');
Span<char> buffer = stackalloc char[33];
if (value.TryFormat(buffer, out var written, "O", CultureInfo.InvariantCulture)) _sb.Append(buffer[..written]);
else _sb.Append(value.ToString("O", CultureInfo.InvariantCulture));
_sb.Append('"');
}
public void WriteGuid(Guid value)
{
_sb.Append('"');
Span<char> buffer = stackalloc char[36];
if (value.TryFormat(buffer, out var written, "D")) _sb.Append(buffer[..written]);
else _sb.Append(value.ToString("D"));
_sb.Append('"');
}
public void WriteTimeSpan(TimeSpan value)
{
_sb.Append('"');
Span<char> buffer = stackalloc char[26];
if (value.TryFormat(buffer, out var written, "c", CultureInfo.InvariantCulture)) _sb.Append(buffer[..written]);
else _sb.Append(value.ToString("c", CultureInfo.InvariantCulture));
_sb.Append('"');
} }
} }

View File

@ -1,3 +1,4 @@
using System.Buffers;
using System.Collections; using System.Collections;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Frozen; using System.Collections.Frozen;
@ -51,6 +52,27 @@ public sealed class AcJsonSerializerOptions
public static AcJsonSerializerOptions WithoutReferenceHandling() => new() { UseReferenceHandling = false }; public static AcJsonSerializerOptions WithoutReferenceHandling() => new() { UseReferenceHandling = false };
} }
/// <summary>
/// Cached result for IId type info lookup.
/// </summary>
public readonly struct IdTypeInfo
{
public readonly bool IsId;
public readonly Type? IdType;
public IdTypeInfo(bool isId, Type? idType)
{
IsId = isId;
IdType = idType;
}
public void Deconstruct(out bool isId, out Type? idType)
{
isId = IsId;
idType = IdType;
}
}
/// <summary> /// <summary>
/// Central utilities for JSON serialization/deserialization. /// Central utilities for JSON serialization/deserialization.
/// Contains shared type caches, primitive type checks, and string utilities. /// Contains shared type caches, primitive type checks, and string utilities.
@ -109,7 +131,7 @@ public static class JsonUtilities
#region Type Caches #region Type Caches
private static readonly ConcurrentDictionary<Type, (bool IsId, Type? IdType)> IdInfoCache = new(); private static readonly ConcurrentDictionary<Type, IdTypeInfo> IdInfoCache = new();
private static readonly ConcurrentDictionary<Type, Type?> CollectionElementCache = new(); private static readonly ConcurrentDictionary<Type, Type?> CollectionElementCache = new();
private static readonly ConcurrentDictionary<Type, bool> IsPrimitiveCache = new(); private static readonly ConcurrentDictionary<Type, bool> IsPrimitiveCache = new();
private static readonly ConcurrentDictionary<Type, bool> IsCollectionCache = new(); private static readonly ConcurrentDictionary<Type, bool> IsCollectionCache = new();
@ -119,6 +141,37 @@ public static class JsonUtilities
#endregion #endregion
#region UTF8 Buffer Pool
/// <summary>
/// Rents a UTF8 byte buffer from the shared pool.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static byte[] RentUtf8Buffer(int minLength)
=> ArrayPool<byte>.Shared.Rent(minLength);
/// <summary>
/// Returns a UTF8 byte buffer to the shared pool.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void ReturnUtf8Buffer(byte[] buffer)
=> ArrayPool<byte>.Shared.Return(buffer);
/// <summary>
/// Converts a JSON string to UTF8 bytes using pooled buffer.
/// Returns the actual byte count written.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static (byte[] buffer, int length) GetUtf8Bytes(string json)
{
var maxByteCount = Encoding.UTF8.GetMaxByteCount(json.Length);
var buffer = RentUtf8Buffer(maxByteCount);
var actualLength = Encoding.UTF8.GetBytes(json, 0, json.Length, buffer, 0);
return (buffer, actualLength);
}
#endregion
#region String Utilities #region String Utilities
/// <summary> /// <summary>
@ -211,6 +264,20 @@ public static class JsonUtilities
return false; return false;
} }
/// <summary>
/// Checks if a span needs JSON escaping.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool NeedsEscaping(ReadOnlySpan<char> value)
{
foreach (var c in value)
{
if (c < 32 || c == '"' || c == '\\')
return true;
}
return false;
}
/// <summary> /// <summary>
/// Escapes a string for JSON output. /// Escapes a string for JSON output.
/// </summary> /// </summary>
@ -238,6 +305,34 @@ public static class JsonUtilities
} }
} }
} }
/// <summary>
/// Escapes a span for JSON output.
/// </summary>
public static void WriteEscapedString(StringBuilder sb, ReadOnlySpan<char> value)
{
foreach (var c in value)
{
switch (c)
{
case '"': sb.Append("\\\""); break;
case '\\': sb.Append("\\\\"); break;
case '\b': sb.Append("\\b"); break;
case '\f': sb.Append("\\f"); break;
case '\n': sb.Append("\\n"); break;
case '\r': sb.Append("\\r"); break;
case '\t': sb.Append("\\t"); break;
default:
if (c < 32)
{
sb.Append("\\u");
sb.Append(((int)c).ToString("X4"));
}
else sb.Append(c);
break;
}
}
}
#endregion #endregion
@ -263,7 +358,7 @@ public static class JsonUtilities
/// Faster primitive check using TypeCode for hot paths. /// Faster primitive check using TypeCode for hot paths.
/// </summary> /// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsPrimitiveOrStringFast(Type type) public static bool IsPrimitiveOrStringFast(in Type type)
{ {
var typeCode = Type.GetTypeCode(type); var typeCode = Type.GetTypeCode(type);
return typeCode switch return typeCode switch
@ -272,7 +367,7 @@ public static class JsonUtilities
TypeCode.Int16 or TypeCode.UInt16 or TypeCode.Int32 or TypeCode.UInt32 or TypeCode.Int16 or TypeCode.UInt16 or TypeCode.Int32 or TypeCode.UInt32 or
TypeCode.Int64 or TypeCode.UInt64 or TypeCode.Single or TypeCode.Double or TypeCode.Int64 or TypeCode.UInt64 or TypeCode.Single or TypeCode.Double or
TypeCode.Decimal or TypeCode.DateTime or TypeCode.String => true, TypeCode.Decimal or TypeCode.DateTime or TypeCode.String => true,
_ => type == GuidType || type == TimeSpanType || type == DateTimeOffsetType || type.IsEnum _ => ReferenceEquals(type, GuidType) || ReferenceEquals(type, TimeSpanType) || ReferenceEquals(type, DateTimeOffsetType) || type.IsEnum
}; };
} }
@ -280,28 +375,28 @@ public static class JsonUtilities
/// Checks if type is a generic collection type (List, IList, ObservableCollection, etc.) /// Checks if type is a generic collection type (List, IList, ObservableCollection, etc.)
/// </summary> /// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsGenericCollectionType(Type type) public static bool IsGenericCollectionType(in Type type)
{ {
return IsCollectionCache.GetOrAdd(type, static t => return IsCollectionCache.GetOrAdd(type, static t =>
{ {
if (t == StringType || t.IsPrimitive) return false; if (ReferenceEquals(t, StringType) || t.IsPrimitive) return false;
if (t.IsArray) return true; if (t.IsArray) return true;
if (t.IsGenericType) if (t.IsGenericType)
{ {
var genericDef = t.GetGenericTypeDefinition(); var genericDef = t.GetGenericTypeDefinition();
if (genericDef == ListGenericType || if (ReferenceEquals(genericDef, ListGenericType) ||
genericDef == IListGenericType || ReferenceEquals(genericDef, IListGenericType) ||
genericDef == typeof(ICollection<>) || genericDef == typeof(ICollection<>) ||
genericDef == IEnumerableGenericType || ReferenceEquals(genericDef, IEnumerableGenericType) ||
genericDef == ObservableCollectionType || ReferenceEquals(genericDef, ObservableCollectionType) ||
genericDef == CollectionType) ReferenceEquals(genericDef, CollectionType))
return true; return true;
} }
foreach (var iface in t.GetInterfaces()) foreach (var iface in t.GetInterfaces())
{ {
if (iface.IsGenericType && iface.GetGenericTypeDefinition() == IListGenericType) if (iface.IsGenericType && ReferenceEquals(iface.GetGenericTypeDefinition(), IListGenericType))
return true; return true;
} }
@ -313,7 +408,7 @@ public static class JsonUtilities
/// Checks if type is a dictionary type. /// Checks if type is a dictionary type.
/// </summary> /// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsDictionaryType(Type type, out Type? keyType, out Type? valueType) public static bool IsDictionaryType(in Type type, out Type? keyType, out Type? valueType)
{ {
keyType = null; keyType = null;
valueType = null; valueType = null;
@ -321,7 +416,7 @@ public static class JsonUtilities
if (!type.IsGenericType) return false; if (!type.IsGenericType) return false;
var genericDef = type.GetGenericTypeDefinition(); var genericDef = type.GetGenericTypeDefinition();
if (genericDef == DictionaryGenericType || genericDef == IDictionaryGenericType) if (ReferenceEquals(genericDef, DictionaryGenericType) || ReferenceEquals(genericDef, IDictionaryGenericType))
{ {
var args = type.GetGenericArguments(); var args = type.GetGenericArguments();
keyType = args[0]; keyType = args[0];
@ -331,7 +426,7 @@ public static class JsonUtilities
foreach (var iface in type.GetInterfaces()) foreach (var iface in type.GetInterfaces())
{ {
if (iface.IsGenericType && iface.GetGenericTypeDefinition() == IDictionaryGenericType) if (iface.IsGenericType && ReferenceEquals(iface.GetGenericTypeDefinition(), IDictionaryGenericType))
{ {
var args = iface.GetGenericArguments(); var args = iface.GetGenericArguments();
keyType = args[0]; keyType = args[0];
@ -347,7 +442,7 @@ public static class JsonUtilities
/// Gets the element type of a collection. /// Gets the element type of a collection.
/// </summary> /// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Type? GetCollectionElementType(Type collectionType) public static Type? GetCollectionElementType(in Type collectionType)
{ {
return CollectionElementCache.GetOrAdd(collectionType, static type => return CollectionElementCache.GetOrAdd(collectionType, static type =>
{ {
@ -357,14 +452,14 @@ public static class JsonUtilities
if (type.IsGenericType) if (type.IsGenericType)
{ {
var genericDef = type.GetGenericTypeDefinition(); var genericDef = type.GetGenericTypeDefinition();
if (genericDef == ListGenericType || genericDef == IListGenericType || if (ReferenceEquals(genericDef, ListGenericType) || ReferenceEquals(genericDef, IListGenericType) ||
genericDef == typeof(ICollection<>) || genericDef == IEnumerableGenericType) genericDef == typeof(ICollection<>) || ReferenceEquals(genericDef, IEnumerableGenericType))
return type.GetGenericArguments()[0]; return type.GetGenericArguments()[0];
} }
foreach (var iface in type.GetInterfaces()) foreach (var iface in type.GetInterfaces())
{ {
if (iface.IsGenericType && iface.GetGenericTypeDefinition() == IListGenericType) if (iface.IsGenericType && ReferenceEquals(iface.GetGenericTypeDefinition(), IListGenericType))
return iface.GetGenericArguments()[0]; return iface.GetGenericArguments()[0];
} }
@ -373,21 +468,21 @@ public static class JsonUtilities
} }
/// <summary> /// <summary>
/// Gets IId info for a type. /// Gets IId info for a type. Returns struct to avoid allocation.
/// </summary> /// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static (bool IsId, Type? IdType) GetIdInfo(Type type) public static IdTypeInfo GetIdInfo(in Type type)
{ {
return IdInfoCache.GetOrAdd(type, static t => return IdInfoCache.GetOrAdd(type, static t =>
{ {
foreach (var iface in t.GetInterfaces()) foreach (var iface in t.GetInterfaces())
{ {
if (!iface.IsGenericType) continue; if (!iface.IsGenericType) continue;
if (iface.GetGenericTypeDefinition() != IIdGenericType) continue; if (!ReferenceEquals(iface.GetGenericTypeDefinition(), IIdGenericType)) continue;
var idType = iface.GetGenericArguments()[0]; var idType = iface.GetGenericArguments()[0];
return (idType.IsValueType, idType); return new IdTypeInfo(idType.IsValueType, idType);
} }
return (false, null); return new IdTypeInfo(false, null);
}); });
} }
@ -406,11 +501,11 @@ public static class JsonUtilities
/// Checks if collection contains primitive elements. /// Checks if collection contains primitive elements.
/// </summary> /// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsPrimitiveElementCollection(Type type) public static bool IsPrimitiveElementCollection(in Type type)
{ {
return IsPrimitiveCollectionCache.GetOrAdd(type, static t => return IsPrimitiveCollectionCache.GetOrAdd(type, static t =>
{ {
if (t == StringType) return false; if (ReferenceEquals(t, StringType)) return false;
Type? elementType = null; Type? elementType = null;
if (t.IsArray) if (t.IsArray)
@ -430,7 +525,7 @@ public static class JsonUtilities
/// Gets or creates a list factory for a given element type. /// Gets or creates a list factory for a given element type.
/// </summary> /// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Func<IList> GetOrCreateListFactory(Type elementType) public static Func<IList> GetOrCreateListFactory(in Type elementType)
{ {
return ListFactoryCache.GetOrAdd(elementType, static t => return ListFactoryCache.GetOrAdd(elementType, static t =>
{ {
@ -445,7 +540,7 @@ public static class JsonUtilities
/// Checks if value is the default value for its type. /// Checks if value is the default value for its type.
/// </summary> /// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsDefaultValue(object id, Type idType) public static bool IsDefaultValue(in object id, in Type idType)
{ {
if (ReferenceEquals(idType, IntType)) return (int)id == 0; if (ReferenceEquals(idType, IntType)) return (int)id == 0;
if (ReferenceEquals(idType, LongType)) return (long)id == 0; if (ReferenceEquals(idType, LongType)) return (long)id == 0;

View File

@ -40,18 +40,18 @@ public sealed class CachedPropertyInfo
if (!ShouldSkip) if (!ShouldSkip)
{ {
var (isId, idType) = GetIdInfo(PropertyType); var idInfo = GetIdInfo(PropertyType);
IsIId = isId; IsIId = idInfo.IsId;
IdType = idType; IdType = idInfo.IdType;
if (!IsIId && typeof(IEnumerable).IsAssignableFrom(PropertyType) && PropertyType != StringType) if (!IsIId && typeof(IEnumerable).IsAssignableFrom(PropertyType) && !ReferenceEquals(PropertyType, StringType))
{ {
CollectionElementType = GetCollectionElementType(PropertyType); CollectionElementType = GetCollectionElementType(PropertyType);
if (CollectionElementType != null) if (CollectionElementType != null)
{ {
var (elemIsId, elemIdType) = GetIdInfo(CollectionElementType); var elemIdInfo = GetIdInfo(CollectionElementType);
IsIIdCollection = elemIsId; IsIIdCollection = elemIdInfo.IsId;
CollectionElementIdType = elemIdType; CollectionElementIdType = elemIdInfo.IdType;
} }
} }
} }
@ -315,7 +315,7 @@ public class UnifiedMergeContractResolver : DefaultContractResolver
var t = property.PropertyType; var t = property.PropertyType;
if (t == null) return property; if (t == null) return property;
var config = GetOrCreatePropertyConfig(member, t); var config = GetOrCreatePropertyConfigRef(member, t);
if (config.IsPrimitiveElementCollection) if (config.IsPrimitiveElementCollection)
{ {
@ -343,7 +343,7 @@ public class UnifiedMergeContractResolver : DefaultContractResolver
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
private static CachedPropertyConfig GetOrCreatePropertyConfig(MemberInfo member, Type propertyType) private static CachedPropertyConfig GetOrCreatePropertyConfigRef(MemberInfo member, Type propertyType)
=> PropertyConfigCache.GetOrAdd(member, _ => CreatePropertyConfig(member, propertyType)); => PropertyConfigCache.GetOrAdd(member, _ => CreatePropertyConfig(member, propertyType));
private static CachedPropertyConfig CreatePropertyConfig(MemberInfo member, Type propertyType) private static CachedPropertyConfig CreatePropertyConfig(MemberInfo member, Type propertyType)
@ -360,11 +360,11 @@ public class UnifiedMergeContractResolver : DefaultContractResolver
config.ElementType = GetCollectionElementType(propertyType); config.ElementType = GetCollectionElementType(propertyType);
if (config.ElementType != null) if (config.ElementType != null)
{ {
var (hasId, elemIdType) = GetIdInfo(config.ElementType); var idInfo = GetIdInfo(config.ElementType);
if (hasId && elemIdType != null) if (idInfo.IsId && idInfo.IdType != null)
{ {
config.IsIdCollection = true; config.IsIdCollection = true;
config.IdType = elemIdType; config.IdType = idInfo.IdType;
} }
config.IsPrimitiveElement = IsPrimitiveOrString(config.ElementType); config.IsPrimitiveElement = IsPrimitiveOrString(config.ElementType);
} }

View File

@ -1,137 +1,339 @@
using AyCode.Core.Extensions; using AyCode.Core.Extensions;
using AyCode.Core.Tests.TestModels; using AyCode.Core.Tests.TestModels;
using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Columns;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Reports;
using Newtonsoft.Json; using Newtonsoft.Json;
using System.Text; using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace AyCode.Core.Benchmarks; namespace AyCode.Core.Benchmarks;
/// <summary> /// <summary>
/// Serialization benchmarks comparing AcJsonSerializer/Deserializer with Newtonsoft.Json. /// Serialization benchmarks comparing AyCode, Newtonsoft.Json, and System.Text.Json.
/// Uses shared TestModels from AyCode.Core.Tests for consistency. /// Tests small, medium, and large data with and without reference handling.
/// </summary> /// </summary>
[MemoryDiagnoser] [MemoryDiagnoser]
[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]
[CategoriesColumn]
public class SerializationBenchmarks public class SerializationBenchmarks
{ {
// Test data - uses shared TestModels // Test data - small, medium, large
private TestOrder _testOrder = null!; private TestOrder _smallOrder = null!;
private TestOrder _mediumOrder = null!;
private TestOrder _largeOrder = null!;
// Pre-serialized JSON for deserialization benchmarks // Pre-serialized JSON for deserialization benchmarks
private string _newtonsoftJson = null!; private string _smallAyCodeJson = null!;
private string _ayCodeJson = null!; private string _smallAyCodeNoRefJson = null!;
private string _smallStjJson = null!;
private string _smallStjNoRefJson = null!;
private string _smallNewtonsoftJson = null!;
private string _mediumAyCodeJson = null!;
private string _mediumAyCodeNoRefJson = null!;
private string _mediumStjJson = null!;
private string _mediumStjNoRefJson = null!;
private string _mediumNewtonsoftJson = null!;
private string _largeAyCodeJson = null!;
private string _largeAyCodeNoRefJson = null!;
private string _largeStjJson = null!;
private string _largeStjNoRefJson = null!;
private string _largeNewtonsoftJson = null!;
// Target objects for Populate benchmarks // STJ options
private TestOrder _populateTarget = null!; private JsonSerializerOptions _stjWithRefs = null!;
private JsonSerializerOptions _stjNoRefs = null!;
// Settings // AyCode options
private JsonSerializerSettings _newtonsoftNoRefSettings = null!; private AcJsonSerializerOptions _ayCodeWithRefs = null!;
private AcJsonSerializerOptions _ayCodeNoRefs = null!;
// Newtonsoft settings
private JsonSerializerSettings _newtonsoftSettings = null!;
[GlobalSetup] [GlobalSetup]
public void Setup() public void Setup()
{ {
// Newtonsoft WITHOUT reference handling (baseline) // Small: ~20 objects (1 item × 1 pallet × 2 measurements × 3 points)
_newtonsoftNoRefSettings = new JsonSerializerSettings _smallOrder = TestDataFactory.CreateBenchmarkOrder(
{ itemCount: 1,
ReferenceLoopHandling = ReferenceLoopHandling.Ignore, palletsPerItem: 1,
NullValueHandling = NullValueHandling.Ignore, measurementsPerPallet: 2,
DefaultValueHandling = DefaultValueHandling.Ignore, pointsPerMeasurement: 3);
Formatting = Formatting.None
};
// Create benchmark data using shared factory // Medium: ~300 objects (3 items × 2 pallets × 2 measurements × 5 points)
// ~1500 objects: 5 items × 4 pallets × 3 measurements × 5 points = 300 points + containers _mediumOrder = TestDataFactory.CreateBenchmarkOrder(
_testOrder = TestDataFactory.CreateBenchmarkOrder( itemCount: 3,
palletsPerItem: 2,
measurementsPerPallet: 2,
pointsPerMeasurement: 5);
// Large: ~1500 objects (5 items × 4 pallets × 3 measurements × 5 points)
_largeOrder = TestDataFactory.CreateBenchmarkOrder(
itemCount: 5, itemCount: 5,
palletsPerItem: 4, palletsPerItem: 4,
measurementsPerPallet: 3, measurementsPerPallet: 3,
pointsPerMeasurement: 5); pointsPerMeasurement: 5);
// Pre-serialize for deserialization benchmarks // STJ options with reference handling
_newtonsoftJson = JsonConvert.SerializeObject(_testOrder, _newtonsoftNoRefSettings); _stjWithRefs = new JsonSerializerOptions
_ayCodeJson = _testOrder.ToJson(); {
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = null,
WriteIndented = false,
ReferenceHandler = ReferenceHandler.Preserve,
MaxDepth = 256
};
// Create target for populate benchmarks // STJ options without reference handling
_populateTarget = new TestOrder(); _stjNoRefs = new JsonSerializerOptions
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = null,
WriteIndented = false,
ReferenceHandler = ReferenceHandler.IgnoreCycles,
MaxDepth = 256
};
// AyCode options
_ayCodeWithRefs = AcJsonSerializerOptions.Default;
_ayCodeNoRefs = AcJsonSerializerOptions.WithoutReferenceHandling();
// Newtonsoft settings
_newtonsoftSettings = new JsonSerializerSettings
{
PreserveReferencesHandling = PreserveReferencesHandling.Objects,
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
NullValueHandling = NullValueHandling.Ignore,
Formatting = Formatting.None
};
// Pre-serialize for deserialization benchmarks
_smallAyCodeJson = AcJsonSerializer.Serialize(_smallOrder, _ayCodeWithRefs);
_smallAyCodeNoRefJson = AcJsonSerializer.Serialize(_smallOrder, _ayCodeNoRefs);
_smallStjJson = JsonSerializer.Serialize(_smallOrder, _stjWithRefs);
_smallStjNoRefJson = JsonSerializer.Serialize(_smallOrder, _stjNoRefs);
_smallNewtonsoftJson = JsonConvert.SerializeObject(_smallOrder, _newtonsoftSettings);
_mediumAyCodeJson = AcJsonSerializer.Serialize(_mediumOrder, _ayCodeWithRefs);
_mediumAyCodeNoRefJson = AcJsonSerializer.Serialize(_mediumOrder, _ayCodeNoRefs);
_mediumStjJson = JsonSerializer.Serialize(_mediumOrder, _stjWithRefs);
_mediumStjNoRefJson = JsonSerializer.Serialize(_mediumOrder, _stjNoRefs);
_mediumNewtonsoftJson = JsonConvert.SerializeObject(_mediumOrder, _newtonsoftSettings);
_largeAyCodeJson = AcJsonSerializer.Serialize(_largeOrder, _ayCodeWithRefs);
_largeAyCodeNoRefJson = AcJsonSerializer.Serialize(_largeOrder, _ayCodeNoRefs);
_largeStjJson = JsonSerializer.Serialize(_largeOrder, _stjWithRefs);
_largeStjNoRefJson = JsonSerializer.Serialize(_largeOrder, _stjNoRefs);
_largeNewtonsoftJson = JsonConvert.SerializeObject(_largeOrder, _newtonsoftSettings);
// Output sizes for comparison // Output sizes for comparison
var newtonsoftBytes = Encoding.UTF8.GetByteCount(_newtonsoftJson);
var ayCodeBytes = Encoding.UTF8.GetByteCount(_ayCodeJson);
Console.WriteLine("=== JSON Size Comparison ==="); Console.WriteLine("=== JSON Size Comparison ===");
Console.WriteLine($"Newtonsoft (skip defaults): {_newtonsoftJson.Length:N0} chars ({newtonsoftBytes:N0} bytes)"); Console.WriteLine($"Small: AyCode(refs)={_smallAyCodeJson.Length:N0}, AyCode(noRef)={_smallAyCodeNoRefJson.Length:N0}, STJ(refs)={_smallStjJson.Length:N0}, STJ(noRef)={_smallStjNoRefJson.Length:N0}");
Console.WriteLine($"AyCode (refs+skip): {_ayCodeJson.Length:N0} chars ({ayCodeBytes:N0} bytes)"); Console.WriteLine($"Medium: AyCode(refs)={_mediumAyCodeJson.Length:N0}, AyCode(noRef)={_mediumAyCodeNoRefJson.Length:N0}, STJ(refs)={_mediumStjJson.Length:N0}, STJ(noRef)={_mediumStjNoRefJson.Length:N0}");
Console.WriteLine($"Size reduction: {(1.0 - (double)ayCodeBytes / newtonsoftBytes) * 100:F1}%"); Console.WriteLine($"Large: AyCode(refs)={_largeAyCodeJson.Length:N0}, AyCode(noRef)={_largeAyCodeNoRefJson.Length:N0}, STJ(refs)={_largeStjJson.Length:N0}, STJ(noRef)={_largeStjNoRefJson.Length:N0}");
} }
#region Serialization Benchmarks #region Serialize Large - With Refs
[Benchmark(Description = "Newtonsoft (no refs)")] [Benchmark(Description = "AyCode Serialize")]
[BenchmarkCategory("Serialize")] [BenchmarkCategory("Serialize-Large-WithRefs")]
public string Serialize_Newtonsoft_NoRefs() public string Serialize_Large_AyCode_WithRefs()
=> JsonConvert.SerializeObject(_testOrder, _newtonsoftNoRefSettings); => AcJsonSerializer.Serialize(_largeOrder, _ayCodeWithRefs);
[Benchmark(Description = "AyCode (with refs)")] [Benchmark(Description = "STJ Serialize", Baseline = true)]
[BenchmarkCategory("Serialize")] [BenchmarkCategory("Serialize-Large-WithRefs")]
public string Serialize_AyCode_WithRefs() public string Serialize_Large_STJ_WithRefs()
=> _testOrder.ToJson(); => JsonSerializer.Serialize(_largeOrder, _stjWithRefs);
[Benchmark(Description = "AcJsonSerializer (custom)")]
[BenchmarkCategory("Serialize")]
public string Serialize_AcJsonSerializer()
=> AcJsonSerializer.Serialize(_testOrder);
#endregion #endregion
#region Deserialization Benchmarks #region Serialize Large - No Refs
[Benchmark(Description = "Newtonsoft (no refs)")] [Benchmark(Description = "AyCode Serialize")]
[BenchmarkCategory("Deserialize")] [BenchmarkCategory("Serialize-Large-NoRefs")]
public TestOrder? Deserialize_Newtonsoft_NoRefs() public string Serialize_Large_AyCode_NoRefs()
=> JsonConvert.DeserializeObject<TestOrder>(_newtonsoftJson, _newtonsoftNoRefSettings); => AcJsonSerializer.Serialize(_largeOrder, _ayCodeNoRefs);
[Benchmark(Description = "AyCode (with refs)")] [Benchmark(Description = "STJ Serialize", Baseline = true)]
[BenchmarkCategory("Deserialize")] [BenchmarkCategory("Serialize-Large-NoRefs")]
public TestOrder? Deserialize_AyCode_WithRefs() public string Serialize_Large_STJ_NoRefs()
=> _ayCodeJson.JsonTo<TestOrder>(); => JsonSerializer.Serialize(_largeOrder, _stjNoRefs);
[Benchmark(Description = "AcJsonDeserializer (custom)")]
[BenchmarkCategory("Deserialize")]
public TestOrder? Deserialize_AcJsonDeserializer()
=> AcJsonDeserializer.Deserialize<TestOrder>(_ayCodeJson);
#endregion #endregion
#region Populate Benchmarks #region Serialize Medium - With Refs
[Benchmark(Description = "AcJsonDeserializer.Populate")] [Benchmark(Description = "AyCode Serialize")]
[BenchmarkCategory("Populate")] [BenchmarkCategory("Serialize-Medium-WithRefs")]
public void Populate_AcJsonDeserializer() public string Serialize_Medium_AyCode_WithRefs()
=> AcJsonSerializer.Serialize(_mediumOrder, _ayCodeWithRefs);
[Benchmark(Description = "STJ Serialize", Baseline = true)]
[BenchmarkCategory("Serialize-Medium-WithRefs")]
public string Serialize_Medium_STJ_WithRefs()
=> JsonSerializer.Serialize(_mediumOrder, _stjWithRefs);
#endregion
#region Serialize Medium - No Refs
[Benchmark(Description = "AyCode Serialize")]
[BenchmarkCategory("Serialize-Medium-NoRefs")]
public string Serialize_Medium_AyCode_NoRefs()
=> AcJsonSerializer.Serialize(_mediumOrder, _ayCodeNoRefs);
[Benchmark(Description = "STJ Serialize", Baseline = true)]
[BenchmarkCategory("Serialize-Medium-NoRefs")]
public string Serialize_Medium_STJ_NoRefs()
=> JsonSerializer.Serialize(_mediumOrder, _stjNoRefs);
#endregion
#region Small Data Deserialization - With Refs
[Benchmark(Description = "AyCode WithRefs")]
[BenchmarkCategory("Deserialize-Small-WithRefs")]
public TestOrder? Deserialize_Small_AyCode_WithRefs()
=> AcJsonDeserializer.Deserialize<TestOrder>(_smallAyCodeJson, _ayCodeWithRefs);
[Benchmark(Description = "STJ WithRefs", Baseline = true)]
[BenchmarkCategory("Deserialize-Small-WithRefs")]
public TestOrder? Deserialize_Small_STJ_WithRefs()
=> JsonSerializer.Deserialize<TestOrder>(_smallStjJson, _stjWithRefs);
#endregion
#region Small Data Deserialization - No Refs
[Benchmark(Description = "AyCode NoRefs")]
[BenchmarkCategory("Deserialize-Small-NoRefs")]
public TestOrder? Deserialize_Small_AyCode_NoRefs()
=> AcJsonDeserializer.Deserialize<TestOrder>(_smallAyCodeNoRefJson, _ayCodeNoRefs);
[Benchmark(Description = "STJ NoRefs", Baseline = true)]
[BenchmarkCategory("Deserialize-Small-NoRefs")]
public TestOrder? Deserialize_Small_STJ_NoRefs()
=> JsonSerializer.Deserialize<TestOrder>(_smallStjNoRefJson, _stjNoRefs);
#endregion
#region Medium Data Deserialization - With Refs
[Benchmark(Description = "AyCode WithRefs")]
[BenchmarkCategory("Deserialize-Medium-WithRefs")]
public TestOrder? Deserialize_Medium_AyCode_WithRefs()
=> AcJsonDeserializer.Deserialize<TestOrder>(_mediumAyCodeJson, _ayCodeWithRefs);
[Benchmark(Description = "STJ WithRefs", Baseline = true)]
[BenchmarkCategory("Deserialize-Medium-WithRefs")]
public TestOrder? Deserialize_Medium_STJ_WithRefs()
=> JsonSerializer.Deserialize<TestOrder>(_mediumStjJson, _stjWithRefs);
#endregion
#region Medium Data Deserialization - No Refs
[Benchmark(Description = "AyCode NoRefs")]
[BenchmarkCategory("Deserialize-Medium-NoRefs")]
public TestOrder? Deserialize_Medium_AyCode_NoRefs()
=> AcJsonDeserializer.Deserialize<TestOrder>(_mediumAyCodeNoRefJson, _ayCodeNoRefs);
[Benchmark(Description = "STJ NoRefs", Baseline = true)]
[BenchmarkCategory("Deserialize-Medium-NoRefs")]
public TestOrder? Deserialize_Medium_STJ_NoRefs()
=> JsonSerializer.Deserialize<TestOrder>(_mediumStjNoRefJson, _stjNoRefs);
#endregion
#region Large Data Deserialization - With Refs
[Benchmark(Description = "AyCode WithRefs")]
[BenchmarkCategory("Deserialize-Large-WithRefs")]
public TestOrder? Deserialize_Large_AyCode_WithRefs()
=> AcJsonDeserializer.Deserialize<TestOrder>(_largeAyCodeJson, _ayCodeWithRefs);
[Benchmark(Description = "STJ WithRefs", Baseline = true)]
[BenchmarkCategory("Deserialize-Large-WithRefs")]
public TestOrder? Deserialize_Large_STJ_WithRefs()
=> JsonSerializer.Deserialize<TestOrder>(_largeStjJson, _stjWithRefs);
#endregion
#region Large Data Deserialization - No Refs
[Benchmark(Description = "AyCode NoRefs")]
[BenchmarkCategory("Deserialize-Large-NoRefs")]
public TestOrder? Deserialize_Large_AyCode_NoRefs()
=> AcJsonDeserializer.Deserialize<TestOrder>(_largeAyCodeNoRefJson, _ayCodeNoRefs);
[Benchmark(Description = "STJ NoRefs", Baseline = true)]
[BenchmarkCategory("Deserialize-Large-NoRefs")]
public TestOrder? Deserialize_Large_STJ_NoRefs()
=> JsonSerializer.Deserialize<TestOrder>(_largeStjNoRefJson, _stjNoRefs);
#endregion
#region Populate Benchmarks - Small
[Benchmark(Description = "AyCode Populate")]
[BenchmarkCategory("Populate-Small")]
public void Populate_Small_AyCode()
{ {
// Create fresh target for each iteration to avoid state pollution
var target = new TestOrder(); var target = new TestOrder();
AcJsonDeserializer.Populate(_ayCodeJson, target); AcJsonDeserializer.Populate(_smallAyCodeJson, target);
} }
[Benchmark(Description = "Newtonsoft PopulateObject")] [Benchmark(Description = "Newtonsoft PopulateObject", Baseline = true)]
[BenchmarkCategory("Populate")] [BenchmarkCategory("Populate-Small")]
public void Populate_Newtonsoft() public void Populate_Small_Newtonsoft()
{ {
// Create fresh target for each iteration to match the other benchmark
var target = new TestOrder(); var target = new TestOrder();
JsonConvert.PopulateObject(_newtonsoftJson, target, _newtonsoftNoRefSettings); JsonConvert.PopulateObject(_smallNewtonsoftJson, target, _newtonsoftSettings);
} }
#endregion #endregion
#region JSON Size Comparison #region Populate Benchmarks - Medium
[Benchmark(Description = "JSON Size - Newtonsoft")] [Benchmark(Description = "AyCode Populate")]
[BenchmarkCategory("Size")] [BenchmarkCategory("Populate-Medium")]
public int JsonSize_Newtonsoft() => _newtonsoftJson.Length; public void Populate_Medium_AyCode()
{
var target = new TestOrder();
AcJsonDeserializer.Populate(_mediumAyCodeJson, target);
}
[Benchmark(Description = "JSON Size - AyCode")] [Benchmark(Description = "Newtonsoft PopulateObject", Baseline = true)]
[BenchmarkCategory("Size")] [BenchmarkCategory("Populate-Medium")]
public int JsonSize_AyCode() => _ayCodeJson.Length; public void Populate_Medium_Newtonsoft()
{
var target = new TestOrder();
JsonConvert.PopulateObject(_mediumNewtonsoftJson, target, _newtonsoftSettings);
}
#endregion
#region Populate Benchmarks - Large
[Benchmark(Description = "AyCode Populate")]
[BenchmarkCategory("Populate-Large")]
public void Populate_Large_AyCode()
{
var target = new TestOrder();
AcJsonDeserializer.Populate(_largeAyCodeJson, target);
}
[Benchmark(Description = "Newtonsoft PopulateObject", Baseline = true)]
[BenchmarkCategory("Populate-Large")]
public void Populate_Large_Newtonsoft()
{
var target = new TestOrder();
JsonConvert.PopulateObject(_largeNewtonsoftJson, target, _newtonsoftSettings);
}
#endregion #endregion
} }