Refactor JSON (de)serialization: options, depth, utilities

Major overhaul of JSON serialization/deserialization:
- Introduce AcJsonSerializerOptions for reference handling and max depth
- Centralize type checks, primitive/collection logic in JsonUtilities
- Add depth limiting to serializer/deserializer (MaxDepth support)
- Make $id/$ref reference handling optional via options
- Unify and simplify public API (ToJson, JsonTo, CloneTo, CopyTo, etc.)
- Improve primitive, enum, and collection handling and caching
- Refactor contract resolver and merge logic to use new utilities
- Remove redundant code, centralize string escaping/unescaping
- Update all tests and benchmarks to use new API and options
- Fix minor bugs and improve error handling and validation

This modernizes and unifies the JSON infrastructure for better performance, flexibility, and maintainability.
This commit is contained in:
Loretta 2025-12-12 11:30:55 +01:00
parent 8e7869b3da
commit ad426feba4
10 changed files with 1460 additions and 2256 deletions

View File

@ -17,8 +17,6 @@ public sealed class JsonExtensionTests
TestDataFactory.ResetIdCounter(); TestDataFactory.ResetIdCounter();
} }
private static JsonSerializerSettings GetMergeSettings() => SerializeObjectExtensions.Options;
#region Deep Hierarchy Tests (5 Levels) #region Deep Hierarchy Tests (5 Levels)
[TestMethod] [TestMethod]
@ -56,7 +54,7 @@ public sealed class JsonExtensionTests
}}"; }}";
// Act // Act
updateJson.JsonTo(order, GetMergeSettings()); updateJson.JsonTo(order);
// Assert: All references preserved // Assert: All references preserved
Assert.AreSame(originalItem, order.Items[0], "Level 2: Item reference must be preserved"); Assert.AreSame(originalItem, order.Items[0], "Level 2: Item reference must be preserved");
@ -101,7 +99,7 @@ public sealed class JsonExtensionTests
}}"; }}";
// Act // Act
updateJson.JsonTo(order, GetMergeSettings()); updateJson.JsonTo(order);
// Assert // Assert
Assert.AreEqual(originalItemCount, order.Items.Count, "Items count should be preserved (KEEP logic)"); Assert.AreEqual(originalItemCount, order.Items.Count, "Items count should be preserved (KEEP logic)");
@ -120,11 +118,8 @@ public sealed class JsonExtensionTests
var sharedTag = TestDataFactory.CreateTag("SharedTag"); var sharedTag = TestDataFactory.CreateTag("SharedTag");
var order = TestDataFactory.CreateOrder(itemCount: 2, sharedTag: sharedTag); var order = TestDataFactory.CreateOrder(itemCount: 2, sharedTag: sharedTag);
var settings = GetMergeSettings();
settings.Formatting = Formatting.Indented;
// Act // Act
var json = order.ToJson(settings); var json = order.ToJson();
Console.WriteLine($"Semantic Reference JSON:\n{json}"); Console.WriteLine($"Semantic Reference JSON:\n{json}");
// Assert // Assert
@ -155,7 +150,7 @@ public sealed class JsonExtensionTests
}"; }";
// Act // Act
updateJson.JsonTo(order, GetMergeSettings()); updateJson.JsonTo(order);
// Assert // Assert
Assert.AreEqual("ORD-UPDATED", order.OrderNumber); Assert.AreEqual("ORD-UPDATED", order.OrderNumber);
@ -174,11 +169,8 @@ public sealed class JsonExtensionTests
var sharedMeta = TestDataFactory.CreateMetadata(withChild: true); var sharedMeta = TestDataFactory.CreateMetadata(withChild: true);
var order = TestDataFactory.CreateOrder(itemCount: 2, sharedMetadata: sharedMeta); var order = TestDataFactory.CreateOrder(itemCount: 2, sharedMetadata: sharedMeta);
var settings = GetMergeSettings();
settings.Formatting = Formatting.Indented;
// Act // Act
var json = order.ToJson(settings); var json = order.ToJson();
Console.WriteLine($"Newtonsoft Reference JSON:\n{json}"); Console.WriteLine($"Newtonsoft Reference JSON:\n{json}");
// Assert // Assert
@ -210,11 +202,8 @@ public sealed class JsonExtensionTests
AuditMetadata = rootMeta AuditMetadata = rootMeta
}; };
var settings = GetMergeSettings();
settings.Formatting = Formatting.Indented;
// Act // Act
var json = order.ToJson(settings); var json = order.ToJson();
// Assert // Assert
Assert.IsTrue(json.Contains("Root")); Assert.IsTrue(json.Contains("Root"));
@ -247,8 +236,7 @@ public sealed class JsonExtensionTests
Items = [new TestOrderItem { Id = 10, ProductName = "A", Tag = sharedTag, ItemMetadata = sharedMeta }] Items = [new TestOrderItem { Id = 10, ProductName = "A", Tag = sharedTag, ItemMetadata = sharedMeta }]
}; };
var settings = GetMergeSettings(); var json = order.ToJson();
var json = order.ToJson(settings);
// Assert // Assert
var refCount = json.Split("\"$ref\"").Length - 1; var refCount = json.Split("\"$ref\"").Length - 1;
@ -279,7 +267,7 @@ public sealed class JsonExtensionTests
}}"; }}";
// Act // Act
order.DeepPopulateWithMerge(updateJson, GetMergeSettings()); order.DeepPopulateWithMerge(updateJson);
// Assert // Assert
Assert.AreNotSame(originalRef, order.NoMergeItems); Assert.AreNotSame(originalRef, order.NoMergeItems);
@ -314,7 +302,7 @@ public sealed class JsonExtensionTests
}"; }";
// Act // Act
order.DeepPopulateWithMerge(updateJson, GetMergeSettings()); order.DeepPopulateWithMerge(updateJson);
// Assert // Assert
Assert.AreEqual(2, order.MetadataList.Count); Assert.AreEqual(2, order.MetadataList.Count);
@ -350,9 +338,9 @@ public sealed class JsonExtensionTests
new { Id = appleId, Name = "Apple", Qty = 7 }, new { Id = appleId, Name = "Apple", Qty = 7 },
new { Id = Guid.NewGuid(), Name = "Banana", Qty = 4 } new { Id = Guid.NewGuid(), Name = "Banana", Qty = 4 }
} }
}.ToJson(GetMergeSettings()); }.ToJson();
json.JsonTo(order, GetMergeSettings()); json.JsonTo(order);
// List reference preserved // List reference preserved
Assert.AreSame(originalItemsRef, order.Items, "List reference must be preserved"); Assert.AreSame(originalItemsRef, order.Items, "List reference must be preserved");
@ -377,8 +365,8 @@ public sealed class JsonExtensionTests
var order = TestDataFactory.CreateOrder(itemCount: 2, palletsPerItem: 2, sharedTag: sharedTag, sharedMetadata: sharedMeta); var order = TestDataFactory.CreateOrder(itemCount: 2, palletsPerItem: 2, sharedTag: sharedTag, sharedMetadata: sharedMeta);
// Act // Act
var json = order.ToJson(GetMergeSettings()); var json = order.ToJson();
var deserialized = json.JsonTo<TestOrder>(GetMergeSettings()); var deserialized = json.JsonTo<TestOrder>();
// Assert // Assert
Assert.IsNotNull(deserialized); Assert.IsNotNull(deserialized);
@ -394,10 +382,9 @@ public sealed class JsonExtensionTests
[TestMethod] [TestMethod]
public void PrimitiveArray_BooleanTrue_RoundTrips() public void PrimitiveArray_BooleanTrue_RoundTrips()
{ {
var settings = GetMergeSettings(); var jsonString = (new[] { true }).ToJson();
var jsonString = (new[] { true }).ToJson(settings);
var result = jsonString.JsonTo(typeof(bool[]), settings) as bool[]; var result = jsonString.JsonTo(typeof(bool[])) as bool[];
Assert.IsNotNull(result); Assert.IsNotNull(result);
Assert.IsTrue(result[0], "Boolean true should deserialize as true!"); Assert.IsTrue(result[0], "Boolean true should deserialize as true!");
@ -406,7 +393,6 @@ public sealed class JsonExtensionTests
[TestMethod] [TestMethod]
public void PrimitiveArray_AllTypes_RoundTrip() public void PrimitiveArray_AllTypes_RoundTrip()
{ {
var settings = GetMergeSettings();
var testCases = new (Type type, object value)[] var testCases = new (Type type, object value)[]
{ {
(typeof(bool), true), (typeof(bool), true),
@ -424,9 +410,9 @@ public sealed class JsonExtensionTests
{ {
var wrapped = Array.CreateInstance(type, 1); var wrapped = Array.CreateInstance(type, 1);
wrapped.SetValue(value, 0); wrapped.SetValue(value, 0);
var json = wrapped.ToJson(settings); var json = wrapped.ToJson();
var result = json.JsonTo(type.MakeArrayType(), settings) as Array; var result = json.JsonTo(type.MakeArrayType()) as Array;
Assert.IsNotNull(result, $"Failed for {type.Name}"); Assert.IsNotNull(result, $"Failed for {type.Name}");
Assert.AreEqual(value, result.GetValue(0), $"Value mismatch for {type.Name}"); Assert.AreEqual(value, result.GetValue(0), $"Value mismatch for {type.Name}");
@ -436,15 +422,14 @@ public sealed class JsonExtensionTests
[TestMethod] [TestMethod]
public void IdMessage_MultipleParameters_SimulateSignalR() public void IdMessage_MultipleParameters_SimulateSignalR()
{ {
var settings = GetMergeSettings();
var @params = new (Type t, object v)[] { (typeof(bool), true), (typeof(string), "filter"), (typeof(int), 100) }; var @params = new (Type t, object v)[] { (typeof(bool), true), (typeof(string), "filter"), (typeof(int), 100) };
foreach (var (type, value) in @params) foreach (var (type, value) in @params)
{ {
var wrapped = Array.CreateInstance(type, 1); var wrapped = Array.CreateInstance(type, 1);
wrapped.SetValue(value, 0); wrapped.SetValue(value, 0);
var json = wrapped.ToJson(settings); var json = wrapped.ToJson();
var arr = json.JsonTo(type.MakeArrayType(), settings) as Array; var arr = json.JsonTo(type.MakeArrayType()) as Array;
Assert.AreEqual(value, arr?.GetValue(0)); Assert.AreEqual(value, arr?.GetValue(0));
} }
} }
@ -574,7 +559,7 @@ public sealed class JsonExtensionTests
}; };
// Act - Serialize with AyCode // Act - Serialize with AyCode
var json = order.ToJson(GetMergeSettings()); var json = order.ToJson();
// Deserialize with native Newtonsoft // Deserialize with native Newtonsoft
var nativeSettings = new JsonSerializerSettings var nativeSettings = new JsonSerializerSettings
@ -600,8 +585,6 @@ public sealed class JsonExtensionTests
[TestMethod] [TestMethod]
public void Populate_RefNode_ShouldSetPropertyToReferencedObject() public void Populate_RefNode_ShouldSetPropertyToReferencedObject()
{ {
// Arrange: Create JSON with $id and $ref
// This simulates a scenario where the same object is referenced multiple times
var json = @"{ var json = @"{
""Id"": 1, ""Id"": 1,
""OrderNumber"": ""ORD-001"", ""OrderNumber"": ""ORD-001"",
@ -612,15 +595,13 @@ public sealed class JsonExtensionTests
var order = new TestOrder { Id = 1, OrderNumber = "OLD" }; var order = new TestOrder { Id = 1, OrderNumber = "OLD" };
// Act // Act
json.JsonTo(order, GetMergeSettings()); json.JsonTo(order);
// Assert // Assert
Assert.IsNotNull(order.PrimaryTag, "PrimaryTag should be set"); Assert.IsNotNull(order.PrimaryTag, "PrimaryTag should be set");
Assert.IsNotNull(order.SecondaryTag, "SecondaryTag should be set from $ref"); Assert.IsNotNull(order.SecondaryTag, "SecondaryTag should be set from $ref");
Assert.AreEqual(100, order.PrimaryTag.Id); Assert.AreEqual(100, order.PrimaryTag.Id);
Assert.AreEqual("SharedTag", order.PrimaryTag.Name); Assert.AreEqual("SharedTag", order.PrimaryTag.Name);
// The key assertion: SecondaryTag should be the SAME object as PrimaryTag (via $ref)
Assert.AreSame(order.PrimaryTag, order.SecondaryTag, Assert.AreSame(order.PrimaryTag, order.SecondaryTag,
"SecondaryTag should reference the same object as PrimaryTag via $ref"); "SecondaryTag should reference the same object as PrimaryTag via $ref");
} }
@ -628,7 +609,6 @@ public sealed class JsonExtensionTests
[TestMethod] [TestMethod]
public void Populate_RefNodeInCollection_ShouldSetPropertyToReferencedObject() public void Populate_RefNodeInCollection_ShouldSetPropertyToReferencedObject()
{ {
// Arrange: Create JSON with shared reference in collection
var json = @"{ var json = @"{
""Id"": 1, ""Id"": 1,
""OrderNumber"": ""ORD-001"", ""OrderNumber"": ""ORD-001"",
@ -642,17 +622,13 @@ public sealed class JsonExtensionTests
var order = new TestOrder { Id = 1, OrderNumber = "OLD", Tags = new List<SharedTag>() }; var order = new TestOrder { Id = 1, OrderNumber = "OLD", Tags = new List<SharedTag>() };
// Act // Act
json.JsonTo(order, GetMergeSettings()); json.JsonTo(order);
// Assert // Assert
Assert.IsNotNull(order.PrimaryTag, "PrimaryTag should be set"); Assert.IsNotNull(order.PrimaryTag, "PrimaryTag should be set");
Assert.AreEqual(2, order.Tags.Count, "Tags should have 2 items"); Assert.AreEqual(2, order.Tags.Count, "Tags should have 2 items");
// First tag should be same as PrimaryTag via $ref
Assert.AreSame(order.PrimaryTag, order.Tags[0], Assert.AreSame(order.PrimaryTag, order.Tags[0],
"Tags[0] should reference the same object as PrimaryTag via $ref"); "Tags[0] should reference the same object as PrimaryTag via $ref");
// Second tag should be different
Assert.AreEqual(200, order.Tags[1].Id); Assert.AreEqual(200, order.Tags[1].Id);
Assert.AreNotSame(order.PrimaryTag, order.Tags[1]); Assert.AreNotSame(order.PrimaryTag, order.Tags[1]);
} }
@ -660,7 +636,6 @@ public sealed class JsonExtensionTests
[TestMethod] [TestMethod]
public void Populate_NestedRefNode_ShouldResolveCorrectly() public void Populate_NestedRefNode_ShouldResolveCorrectly()
{ {
// Arrange: Create JSON with nested $ref
var json = @"{ var json = @"{
""Id"": 1, ""Id"": 1,
""OrderNumber"": ""ORD-001"", ""OrderNumber"": ""ORD-001"",
@ -680,13 +655,11 @@ public sealed class JsonExtensionTests
}; };
// Act // Act
json.JsonTo(order, GetMergeSettings()); json.JsonTo(order);
// Assert // Assert
Assert.IsNotNull(order.Items[0].Tag, "Item's Tag should be set"); Assert.IsNotNull(order.Items[0].Tag, "Item's Tag should be set");
Assert.IsNotNull(order.PrimaryTag, "PrimaryTag should be set from $ref"); Assert.IsNotNull(order.PrimaryTag, "PrimaryTag should be set from $ref");
// PrimaryTag should be same as Items[0].Tag via $ref
Assert.AreSame(order.Items[0].Tag, order.PrimaryTag, Assert.AreSame(order.Items[0].Tag, order.PrimaryTag,
"PrimaryTag should reference the same object as Items[0].Tag via $ref"); "PrimaryTag should reference the same object as Items[0].Tag via $ref");
} }
@ -694,7 +667,6 @@ public sealed class JsonExtensionTests
[TestMethod] [TestMethod]
public void Populate_ForwardRef_ShouldResolveDeferredReference() public void Populate_ForwardRef_ShouldResolveDeferredReference()
{ {
// Arrange: $ref appears BEFORE $id (forward reference)
var json = @"{ var json = @"{
""Id"": 1, ""Id"": 1,
""OrderNumber"": ""ORD-001"", ""OrderNumber"": ""ORD-001"",
@ -705,9 +677,9 @@ public sealed class JsonExtensionTests
var order = new TestOrder { Id = 1, OrderNumber = "OLD" }; var order = new TestOrder { Id = 1, OrderNumber = "OLD" };
// Act // Act
json.JsonTo(order, GetMergeSettings()); json.JsonTo(order);
// Assert - forward reference should be resolved // Assert
Assert.IsNotNull(order.PrimaryTag, "PrimaryTag should be set"); Assert.IsNotNull(order.PrimaryTag, "PrimaryTag should be set");
Assert.IsNotNull(order.SecondaryTag, "SecondaryTag should be set from forward $ref"); Assert.IsNotNull(order.SecondaryTag, "SecondaryTag should be set from forward $ref");
Assert.AreSame(order.PrimaryTag, order.SecondaryTag, Assert.AreSame(order.PrimaryTag, order.SecondaryTag,
@ -717,7 +689,6 @@ public sealed class JsonExtensionTests
[TestMethod] [TestMethod]
public void Populate_MultipleRefsToSameId_AllShouldResolveToSameObject() public void Populate_MultipleRefsToSameId_AllShouldResolveToSameObject()
{ {
// Arrange: Create JSON with multiple $refs pointing to the same $id
var json = @"{ var json = @"{
""Id"": 1, ""Id"": 1,
""OrderNumber"": ""ORD-001"", ""OrderNumber"": ""ORD-001"",
@ -733,9 +704,9 @@ public sealed class JsonExtensionTests
var order = new TestOrder { Id = 1, OrderNumber = "OLD", Tags = new List<SharedTag>() }; var order = new TestOrder { Id = 1, OrderNumber = "OLD", Tags = new List<SharedTag>() };
// Act // Act
json.JsonTo(order, GetMergeSettings()); json.JsonTo(order);
// Assert - all refs should point to the same object // Assert
Assert.IsNotNull(order.PrimaryTag); Assert.IsNotNull(order.PrimaryTag);
Assert.AreSame(order.PrimaryTag, order.SecondaryTag); Assert.AreSame(order.PrimaryTag, order.SecondaryTag);
Assert.AreEqual(3, order.Tags.Count); Assert.AreEqual(3, order.Tags.Count);
@ -747,7 +718,6 @@ public sealed class JsonExtensionTests
[TestMethod] [TestMethod]
public void Populate_DeeplyNestedRef_ShouldResolveAcrossLevels() public void Populate_DeeplyNestedRef_ShouldResolveAcrossLevels()
{ {
// Arrange: Create JSON with $id at deep level (Item.Tag), $ref at root level
var json = @"{ var json = @"{
""Id"": 1, ""Id"": 1,
""OrderNumber"": ""ORD-001"", ""OrderNumber"": ""ORD-001"",
@ -766,7 +736,7 @@ public sealed class JsonExtensionTests
}; };
// Act // Act
json.JsonTo(order, GetMergeSettings()); json.JsonTo(order);
// Assert // Assert
var deepTag = order.Items[0].Tag; var deepTag = order.Items[0].Tag;
@ -779,7 +749,6 @@ public sealed class JsonExtensionTests
[TestMethod] [TestMethod]
public void Populate_RefInNestedObject_ShouldResolveFromParentContext() public void Populate_RefInNestedObject_ShouldResolveFromParentContext()
{ {
// Arrange: $id at root, $ref in nested child
var json = @"{ var json = @"{
""Id"": 1, ""Id"": 1,
""OrderNumber"": ""ORD-001"", ""OrderNumber"": ""ORD-001"",
@ -798,7 +767,7 @@ public sealed class JsonExtensionTests
}; };
// Act // Act
json.JsonTo(order, GetMergeSettings()); json.JsonTo(order);
// Assert // Assert
Assert.IsNotNull(order.PrimaryTag); Assert.IsNotNull(order.PrimaryTag);
@ -810,7 +779,6 @@ public sealed class JsonExtensionTests
[TestMethod] [TestMethod]
public void Deserialize_RefOnly_ShouldCreateDeferredAndResolve() public void Deserialize_RefOnly_ShouldCreateDeferredAndResolve()
{ {
// Arrange: JSON where only $ref exists (forward reference scenario in deserialize)
var json = @"{ var json = @"{
""Id"": 1, ""Id"": 1,
""OrderNumber"": ""ORD-001"", ""OrderNumber"": ""ORD-001"",
@ -831,7 +799,6 @@ public sealed class JsonExtensionTests
[TestMethod] [TestMethod]
public void Deserialize_MultipleIdRefs_ComplexGraph() public void Deserialize_MultipleIdRefs_ComplexGraph()
{ {
// Arrange: Complex object graph with multiple $id/$ref pairs
var json = @"{ var json = @"{
""Id"": 1, ""Id"": 1,
""OrderNumber"": ""ORD-001"", ""OrderNumber"": ""ORD-001"",
@ -855,23 +822,16 @@ public sealed class JsonExtensionTests
// Assert // Assert
Assert.IsNotNull(order); Assert.IsNotNull(order);
Assert.AreEqual(3, order.Tags.Count); Assert.AreEqual(3, order.Tags.Count);
// Verify tag1 references
Assert.AreSame(order.PrimaryTag, order.Tags[0]); Assert.AreSame(order.PrimaryTag, order.Tags[0]);
Assert.AreSame(order.PrimaryTag, order.Tags[2]); Assert.AreSame(order.PrimaryTag, order.Tags[2]);
// Verify tag2 references
Assert.AreSame(order.SecondaryTag, order.Tags[1]); Assert.AreSame(order.SecondaryTag, order.Tags[1]);
Assert.AreSame(order.SecondaryTag, order.Items[0].Tag); Assert.AreSame(order.SecondaryTag, order.Items[0].Tag);
// Verify they are different
Assert.AreNotSame(order.PrimaryTag, order.SecondaryTag); Assert.AreNotSame(order.PrimaryTag, order.SecondaryTag);
} }
[TestMethod] [TestMethod]
public void Populate_RefWithExistingTarget_ShouldOverwriteWithReference() public void Populate_RefWithExistingTarget_ShouldOverwriteWithReference()
{ {
// Arrange: Target has existing value, should be overwritten by $ref
var json = @"{ var json = @"{
""Id"": 1, ""Id"": 1,
""PrimaryTag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""NewTag"" }, ""PrimaryTag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""NewTag"" },
@ -882,13 +842,13 @@ public sealed class JsonExtensionTests
var order = new TestOrder var order = new TestOrder
{ {
Id = 1, Id = 1,
SecondaryTag = existingTag // Pre-existing value SecondaryTag = existingTag
}; };
// Act // Act
json.JsonTo(order, GetMergeSettings()); json.JsonTo(order);
// Assert - SecondaryTag should be overwritten with the $ref reference // Assert
Assert.IsNotNull(order.PrimaryTag); Assert.IsNotNull(order.PrimaryTag);
Assert.AreSame(order.PrimaryTag, order.SecondaryTag, Assert.AreSame(order.PrimaryTag, order.SecondaryTag,
"SecondaryTag should be overwritten with $ref reference"); "SecondaryTag should be overwritten with $ref reference");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,126 @@
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Frozen;
using System.Globalization;
using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Text; using System.Text;
using AyCode.Core.Interfaces;
using Newtonsoft.Json;
namespace AyCode.Core.Extensions; namespace AyCode.Core.Extensions;
internal static class JsonUtilities /// <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>
/// Central utilities for JSON serialization/deserialization.
/// Contains shared type caches, primitive type checks, and string utilities.
/// </summary>
public static class JsonUtilities
{
#region Pre-computed Type Handles
public static readonly Type IntType = typeof(int);
public static readonly Type LongType = typeof(long);
public static readonly Type DoubleType = typeof(double);
public static readonly Type DecimalType = typeof(decimal);
public static readonly Type FloatType = typeof(float);
public static readonly Type StringType = typeof(string);
public static readonly Type DateTimeType = typeof(DateTime);
public static readonly Type GuidType = typeof(Guid);
public static readonly Type BoolType = typeof(bool);
public static readonly Type DateTimeOffsetType = typeof(DateTimeOffset);
public static readonly Type TimeSpanType = typeof(TimeSpan);
public static readonly Type ByteType = typeof(byte);
public static readonly Type ShortType = typeof(short);
public static readonly Type UShortType = typeof(ushort);
public static readonly Type UIntType = typeof(uint);
public static readonly Type ULongType = typeof(ulong);
public static readonly Type SByteType = typeof(sbyte);
public static readonly Type CharType = typeof(char);
#endregion
#region Cached Generic Type Definitions
internal static readonly Type IEnumerableGenericType = typeof(IEnumerable<>);
internal static readonly Type IIdGenericType = typeof(IId<>);
internal static readonly Type NullableGenericType = typeof(Nullable<>);
internal static readonly Type IListGenericType = typeof(IList<>);
internal static readonly Type ListGenericType = typeof(List<>);
internal static readonly Type DictionaryGenericType = typeof(Dictionary<,>);
internal static readonly Type IDictionaryGenericType = typeof(IDictionary<,>);
internal static readonly Type ObservableCollectionType = typeof(System.Collections.ObjectModel.ObservableCollection<>);
internal static readonly Type CollectionType = typeof(System.Collections.ObjectModel.Collection<>);
#endregion
#region Primitive Type Set
private static readonly FrozenSet<Type> PrimitiveTypes = new HashSet<Type>
{
typeof(string), typeof(decimal), typeof(DateTime),
typeof(DateTimeOffset), typeof(Guid), typeof(TimeSpan),
typeof(bool), typeof(byte), typeof(sbyte), typeof(short),
typeof(ushort), typeof(int), typeof(uint), typeof(long),
typeof(ulong), typeof(float), typeof(double), typeof(char)
}.ToFrozenSet();
#endregion
#region Type Caches
private static readonly ConcurrentDictionary<Type, (bool IsId, Type? IdType)> IdInfoCache = new();
private static readonly ConcurrentDictionary<Type, Type?> CollectionElementCache = new();
private static readonly ConcurrentDictionary<Type, bool> IsPrimitiveCache = new();
private static readonly ConcurrentDictionary<Type, bool> IsCollectionCache = new();
private static readonly ConcurrentDictionary<Type, bool> IsPrimitiveCollectionCache = new();
private static readonly ConcurrentDictionary<PropertyInfo, bool> JsonIgnoreCache = new();
private static readonly ConcurrentDictionary<Type, Func<IList>> ListFactoryCache = new();
#endregion
#region String Utilities
/// <summary> /// <summary>
/// Unwraps a JSON string that may be double-quoted (e.g., "\"...\"" -> ...). /// Unwraps a JSON string that may be double-quoted (e.g., "\"...\"" -> ...).
/// Optimized to avoid Regex.Unescape allocation when no escape sequences exist. /// Optimized to avoid Regex.Unescape allocation when no escape sequences exist.
@ -18,23 +134,14 @@ internal static class JsonUtilities
if (span.Length < 2 || span[0] != '"' || span[^1] != '"') if (span.Length < 2 || span[0] != '"' || span[^1] != '"')
return json; return json;
// Extract inner content (without outer quotes)
var inner = span[1..^1]; var inner = span[1..^1];
// Fast path: check if any escape sequences exist
if (!inner.Contains('\\')) if (!inner.Contains('\\'))
{
// No escapes - just return substring (single allocation)
return json.Substring(1, json.Length - 2); return json.Substring(1, json.Length - 2);
}
// Slow path: unescape the string
return UnescapeJsonString(inner); return UnescapeJsonString(inner);
} }
/// <summary>
/// Manual JSON string unescaping - avoids Regex.Unescape overhead.
/// </summary>
private static string UnescapeJsonString(ReadOnlySpan<char> input) private static string UnescapeJsonString(ReadOnlySpan<char> input)
{ {
var sb = new StringBuilder(input.Length); var sb = new StringBuilder(input.Length);
@ -51,54 +158,24 @@ internal static class JsonUtilities
var next = input[i + 1]; var next = input[i + 1];
switch (next) switch (next)
{ {
case '"': case '"': sb.Append('"'); i++; break;
sb.Append('"'); case '\\': sb.Append('\\'); i++; break;
i++; case '/': sb.Append('/'); i++; break;
break; case 'b': sb.Append('\b'); i++; break;
case '\\': case 'f': sb.Append('\f'); i++; break;
sb.Append('\\'); case 'n': sb.Append('\n'); i++; break;
i++; case 'r': sb.Append('\r'); i++; break;
break; case 't': sb.Append('\t'); i++; break;
case '/':
sb.Append('/');
i++;
break;
case 'b':
sb.Append('\b');
i++;
break;
case 'f':
sb.Append('\f');
i++;
break;
case 'n':
sb.Append('\n');
i++;
break;
case 'r':
sb.Append('\r');
i++;
break;
case 't':
sb.Append('\t');
i++;
break;
case 'u' when i + 5 < input.Length: case 'u' when i + 5 < input.Length:
// Unicode escape: \uXXXX
var hex = input.Slice(i + 2, 4); var hex = input.Slice(i + 2, 4);
if (TryParseHex(hex, out var unicode)) if (TryParseHex(hex, out var unicode))
{ {
sb.Append((char)unicode); sb.Append((char)unicode);
i += 5; i += 5;
} }
else else sb.Append(c);
{
sb.Append(c);
}
break;
default:
sb.Append(c);
break; break;
default: sb.Append(c); break;
} }
} }
@ -112,15 +189,275 @@ internal static class JsonUtilities
foreach (var c in hex) foreach (var c in hex)
{ {
value <<= 4; value <<= 4;
if (c >= '0' && c <= '9') if (c >= '0' && c <= '9') value |= c - '0';
value |= c - '0'; else if (c >= 'a' && c <= 'f') value |= c - 'a' + 10;
else if (c >= 'a' && c <= 'f') else if (c >= 'A' && c <= 'F') value |= c - 'A' + 10;
value |= c - 'a' + 10; else return false;
else if (c >= 'A' && c <= 'F')
value |= c - 'A' + 10;
else
return false;
} }
return true; return true;
} }
/// <summary>
/// Checks if a string needs JSON escaping.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool NeedsEscaping(string value)
{
foreach (var c in value)
{
if (c < 32 || c == '"' || c == '\\')
return true;
}
return false;
}
/// <summary>
/// Escapes a string for JSON output.
/// </summary>
public static void WriteEscapedString(StringBuilder sb, string 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
#region Type Checking Methods
/// <summary>
/// Fast primitive check using type code.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsPrimitiveOrString(Type type)
{
return IsPrimitiveCache.GetOrAdd(type, static t =>
{
if (t.IsPrimitive || PrimitiveTypes.Contains(t)) return true;
if (t.IsGenericType && t.GetGenericTypeDefinition() == NullableGenericType)
return IsPrimitiveOrString(t.GetGenericArguments()[0]);
if (t.IsEnum) return true;
return false;
});
}
/// <summary>
/// Faster primitive check using TypeCode for hot paths.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsPrimitiveOrStringFast(Type type)
{
var typeCode = Type.GetTypeCode(type);
return typeCode switch
{
TypeCode.Boolean or TypeCode.Char or TypeCode.SByte or TypeCode.Byte 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.Decimal or TypeCode.DateTime or TypeCode.String => true,
_ => type == GuidType || type == TimeSpanType || type == DateTimeOffsetType || type.IsEnum
};
}
/// <summary>
/// Checks if type is a generic collection type (List, IList, ObservableCollection, etc.)
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsGenericCollectionType(Type type)
{
return IsCollectionCache.GetOrAdd(type, static t =>
{
if (t == StringType || t.IsPrimitive) return false;
if (t.IsArray) return true;
if (t.IsGenericType)
{
var genericDef = t.GetGenericTypeDefinition();
if (genericDef == ListGenericType ||
genericDef == IListGenericType ||
genericDef == typeof(ICollection<>) ||
genericDef == IEnumerableGenericType ||
genericDef == ObservableCollectionType ||
genericDef == CollectionType)
return true;
}
foreach (var iface in t.GetInterfaces())
{
if (iface.IsGenericType && iface.GetGenericTypeDefinition() == IListGenericType)
return true;
}
return typeof(IEnumerable).IsAssignableFrom(t);
});
}
/// <summary>
/// Checks if type is a dictionary type.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsDictionaryType(Type type, out Type? keyType, out Type? valueType)
{
keyType = null;
valueType = null;
if (!type.IsGenericType) return false;
var genericDef = type.GetGenericTypeDefinition();
if (genericDef == DictionaryGenericType || genericDef == IDictionaryGenericType)
{
var args = type.GetGenericArguments();
keyType = args[0];
valueType = args[1];
return true;
}
foreach (var iface in type.GetInterfaces())
{
if (iface.IsGenericType && iface.GetGenericTypeDefinition() == IDictionaryGenericType)
{
var args = iface.GetGenericArguments();
keyType = args[0];
valueType = args[1];
return true;
}
}
return false;
}
/// <summary>
/// Gets the element type of a collection.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Type? GetCollectionElementType(Type collectionType)
{
return CollectionElementCache.GetOrAdd(collectionType, static type =>
{
if (type.IsArray)
return type.GetElementType();
if (type.IsGenericType)
{
var genericDef = type.GetGenericTypeDefinition();
if (genericDef == ListGenericType || genericDef == IListGenericType ||
genericDef == typeof(ICollection<>) || genericDef == IEnumerableGenericType)
return type.GetGenericArguments()[0];
}
foreach (var iface in type.GetInterfaces())
{
if (iface.IsGenericType && iface.GetGenericTypeDefinition() == IListGenericType)
return iface.GetGenericArguments()[0];
}
return typeof(object);
});
}
/// <summary>
/// Gets IId info for a type.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static (bool IsId, Type? IdType) GetIdInfo(Type type)
{
return IdInfoCache.GetOrAdd(type, static t =>
{
foreach (var iface in t.GetInterfaces())
{
if (!iface.IsGenericType) continue;
if (iface.GetGenericTypeDefinition() != IIdGenericType) continue;
var idType = iface.GetGenericArguments()[0];
return (idType.IsValueType, idType);
}
return (false, null);
});
}
/// <summary>
/// Checks if property has JsonIgnore attribute.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool HasJsonIgnoreAttribute(PropertyInfo prop)
{
return JsonIgnoreCache.GetOrAdd(prop, static p =>
Attribute.IsDefined(p, typeof(JsonIgnoreAttribute)) ||
Attribute.IsDefined(p, typeof(System.Text.Json.Serialization.JsonIgnoreAttribute)));
}
/// <summary>
/// Checks if collection contains primitive elements.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsPrimitiveElementCollection(Type type)
{
return IsPrimitiveCollectionCache.GetOrAdd(type, static t =>
{
if (t == StringType) return false;
Type? elementType = null;
if (t.IsArray)
elementType = t.GetElementType();
else if (t.IsGenericType && typeof(IEnumerable).IsAssignableFrom(t))
{
var genericArgs = t.GetGenericArguments();
if (genericArgs.Length == 1) elementType = genericArgs[0];
}
if (elementType == null) return false;
return IsPrimitiveOrString(elementType) || elementType.IsEnum;
});
}
/// <summary>
/// Gets or creates a list factory for a given element type.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Func<IList> GetOrCreateListFactory(Type elementType)
{
return ListFactoryCache.GetOrAdd(elementType, static t =>
{
var listType = ListGenericType.MakeGenericType(t);
var newExpr = System.Linq.Expressions.Expression.New(listType);
var castExpr = System.Linq.Expressions.Expression.Convert(newExpr, typeof(IList));
return System.Linq.Expressions.Expression.Lambda<Func<IList>>(castExpr).Compile();
});
}
/// <summary>
/// Checks if value is the default value for its type.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsDefaultValue(object id, Type idType)
{
if (ReferenceEquals(idType, IntType)) return (int)id == 0;
if (ReferenceEquals(idType, LongType)) return (long)id == 0;
if (ReferenceEquals(idType, GuidType)) return (Guid)id == Guid.Empty;
if (ReferenceEquals(idType, ShortType)) return (short)id == 0;
if (ReferenceEquals(idType, ByteType)) return (byte)id == 0;
if (ReferenceEquals(idType, UIntType)) return (uint)id == 0;
if (ReferenceEquals(idType, ULongType)) return (ulong)id == 0;
if (ReferenceEquals(idType, UShortType)) return (ushort)id == 0;
if (ReferenceEquals(idType, SByteType)) return (sbyte)id == 0;
return false;
}
#endregion
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,13 @@
using AyCode.Core.Interfaces; using System.Collections.Concurrent;
using MessagePack;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using System.Buffers;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Runtime.Serialization; using System.Runtime.Serialization;
using System.Text; using System.Text;
using AyCode.Core.Interfaces;
using MessagePack;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using static AyCode.Core.Extensions.JsonUtilities;
namespace AyCode.Core.Extensions; namespace AyCode.Core.Extensions;
@ -36,8 +35,7 @@ internal static class Base62
value /= 62; value /= 62;
} }
if (isNegative) if (isNegative) buffer[--index] = '-';
buffer[--index] = '-';
return new string(buffer[index..]); return new string(buffer[index..]);
} }
@ -53,7 +51,7 @@ public class HybridReferenceResolver : IReferenceResolver
internal HashSet<string>? _referencedIds; internal HashSet<string>? _referencedIds;
private int _nextNumericId = 1; private int _nextNumericId = 1;
private static readonly ConcurrentDictionary<Type, Func<object, object?>> _idGetterCache = new(); private static readonly ConcurrentDictionary<Type, Func<object, object?>> IdGetterCache = new();
public bool IsForMerge { get; } public bool IsForMerge { get; }
private readonly int _estimatedObjectCount; private readonly int _estimatedObjectCount;
@ -86,13 +84,12 @@ public class HybridReferenceResolver : IReferenceResolver
var objectToId = GetObjectToId(); var objectToId = GetObjectToId();
if (objectToId.TryGetValue(value, out var existingId)) if (objectToId.TryGetValue(value, out var existingId))
{ {
if (!IsForMerge) if (!IsForMerge) ReferencedIds.Add(existingId);
ReferencedIds.Add(existingId);
return existingId; return existingId;
} }
var type = value.GetType(); var type = value.GetType();
var (isId, idType) = TypeCache.GetIdInfo(type); var (isId, idType) = GetIdInfo(type);
string newRef; string newRef;
if (isId && idType != null) if (isId && idType != null)
@ -108,14 +105,10 @@ public class HybridReferenceResolver : IReferenceResolver
newRef = Base62.Encode(semanticId); newRef = Base62.Encode(semanticId);
} }
else else
{
newRef = Base62.Encode(-_nextNumericId++); newRef = Base62.Encode(-_nextNumericId++);
}
} }
else else
{
newRef = Base62.Encode(-_nextNumericId++); newRef = Base62.Encode(-_nextNumericId++);
}
GetIdToObject()[newRef] = value; GetIdToObject()[newRef] = value;
objectToId[value] = newRef; objectToId[value] = newRef;
@ -130,7 +123,7 @@ public class HybridReferenceResolver : IReferenceResolver
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
private static Func<object, object?> GetOrCreateIdGetter(Type type) => private static Func<object, object?> GetOrCreateIdGetter(Type type) =>
_idGetterCache.GetOrAdd(type, static t => IdGetterCache.GetOrAdd(type, static t =>
{ {
var prop = t.GetProperty("Id"); var prop = t.GetProperty("Id");
if (prop == null) return static _ => null; if (prop == null) return static _ => null;
@ -138,44 +131,18 @@ public class HybridReferenceResolver : IReferenceResolver
if (getMethod == null) return static _ => null; if (getMethod == null) return static _ => null;
return obj => getMethod.Invoke(obj, null); return obj => getMethod.Invoke(obj, null);
}); });
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsDefaultValue(object value, Type type)
{
if (type == typeof(int)) return (int)value == 0;
if (type == typeof(long)) return (long)value == 0L;
if (type == typeof(Guid)) return (Guid)value == Guid.Empty;
if (type == typeof(short)) return (short)value == 0;
if (type == typeof(byte)) return (byte)value == 0;
if (type == typeof(uint)) return (uint)value == 0;
if (type == typeof(ulong)) return (ulong)value == 0;
if (type == typeof(ushort)) return (ushort)value == 0;
if (type == typeof(sbyte)) return (sbyte)value == 0;
return false;
}
}
internal sealed class ReferenceEqualityComparer : IEqualityComparer<object>
{
public static readonly ReferenceEqualityComparer Instance = new();
public new bool Equals(object? x, object? y) => ReferenceEquals(x, y);
public int GetHashCode(object obj) => RuntimeHelpers.GetHashCode(obj);
} }
internal static class JsonReferencePostProcessor internal static class JsonReferencePostProcessor
{ {
private const string IdMarker = "\"$id\""; private const string IdMarker = "\"$id\"";
private const string RefMarker = "\"$ref\"";
public static string RemoveUnreferencedIds(string json, HashSet<string>? referencedIds) public static string RemoveUnreferencedIds(string json, HashSet<string>? referencedIds)
{ {
if (!json.Contains(IdMarker)) if (!json.Contains(IdMarker)) return json;
return json; return referencedIds == null || referencedIds.Count == 0
? RemoveAllIdsSpan(json)
if (referencedIds == null || referencedIds.Count == 0) : RemoveUnreferencedIdsSpan(json, referencedIds);
return RemoveAllIdsSpan(json);
return RemoveUnreferencedIdsSpan(json, referencedIds);
} }
private static string RemoveAllIdsSpan(string json) private static string RemoveAllIdsSpan(string json)
@ -189,16 +156,14 @@ internal static class JsonReferencePostProcessor
var idIndex = json.IndexOf(IdMarker, searchStart, StringComparison.Ordinal); var idIndex = json.IndexOf(IdMarker, searchStart, StringComparison.Ordinal);
if (idIndex < 0) break; if (idIndex < 0) break;
if (idIndex > lastCopyEnd) if (idIndex > lastCopyEnd) sb.Append(json, lastCopyEnd, idIndex - lastCopyEnd);
sb.Append(json, lastCopyEnd, idIndex - lastCopyEnd);
var endIndex = SkipIdEntry(json, idIndex); var endIndex = SkipIdEntry(json, idIndex);
lastCopyEnd = endIndex; lastCopyEnd = endIndex;
searchStart = endIndex; searchStart = endIndex;
} }
if (lastCopyEnd < json.Length) if (lastCopyEnd < json.Length) sb.Append(json, lastCopyEnd, json.Length - lastCopyEnd);
sb.Append(json, lastCopyEnd, json.Length - lastCopyEnd);
return sb.Length == json.Length ? json : sb.ToString(); return sb.Length == json.Length ? json : sb.ToString();
} }
@ -225,9 +190,7 @@ internal static class JsonReferencePostProcessor
{ {
valueStart++; valueStart++;
valueEnd = valueStart; valueEnd = valueStart;
while (valueEnd < json.Length && json[valueEnd] != '"') while (valueEnd < json.Length && json[valueEnd] != '"') valueEnd++;
valueEnd++;
idValue = json.Substring(valueStart, valueEnd - valueStart); idValue = json.Substring(valueStart, valueEnd - valueStart);
valueEnd++; valueEnd++;
} }
@ -236,24 +199,17 @@ internal static class JsonReferencePostProcessor
valueEnd++; valueEnd++;
if (idValue != null && referencedIds.Contains(idValue)) if (idValue != null && referencedIds.Contains(idValue))
{
searchStart = valueEnd; searchStart = valueEnd;
}
else else
{ {
if (idIndex > lastCopyEnd) if (idIndex > lastCopyEnd) sb.Append(json, lastCopyEnd, idIndex - lastCopyEnd);
sb.Append(json, lastCopyEnd, idIndex - lastCopyEnd);
lastCopyEnd = valueEnd; lastCopyEnd = valueEnd;
searchStart = valueEnd; searchStart = valueEnd;
} }
} }
if (lastCopyEnd == 0) if (lastCopyEnd == 0) return json;
return json; if (lastCopyEnd < json.Length) sb.Append(json, lastCopyEnd, json.Length - lastCopyEnd);
if (lastCopyEnd < json.Length)
sb.Append(json, lastCopyEnd, json.Length - lastCopyEnd);
return sb.ToString(); return sb.ToString();
} }
@ -262,32 +218,29 @@ internal static class JsonReferencePostProcessor
private static int SkipIdEntry(string json, int idIndex) private static int SkipIdEntry(string json, int idIndex)
{ {
var pos = idIndex + IdMarker.Length; var pos = idIndex + IdMarker.Length;
while (pos < json.Length && (json[pos] == ' ' || json[pos] == ':')) while (pos < json.Length && (json[pos] == ' ' || json[pos] == ':')) pos++;
pos++;
if (pos < json.Length && json[pos] == '"') if (pos < json.Length && json[pos] == '"')
{ {
pos++; pos++;
while (pos < json.Length && json[pos] != '"') while (pos < json.Length && json[pos] != '"') pos++;
pos++; if (pos < json.Length) pos++;
if (pos < json.Length)
pos++;
} }
while (pos < json.Length && (json[pos] == ' ' || json[pos] == ',')) while (pos < json.Length && (json[pos] == ' ' || json[pos] == ',')) pos++;
pos++;
return pos; return pos;
} }
public static HashSet<string> CollectReferencedIds(string json) public static HashSet<string> CollectReferencedIds(string json)
{ {
const string refMarker = "\"$ref\"";
var result = new HashSet<string>(StringComparer.Ordinal); var result = new HashSet<string>(StringComparer.Ordinal);
var searchStart = 0; var searchStart = 0;
while (searchStart < json.Length) while (searchStart < json.Length)
{ {
var refIndex = json.IndexOf(RefMarker, searchStart, StringComparison.Ordinal); var refIndex = json.IndexOf(refMarker, searchStart, StringComparison.Ordinal);
if (refIndex < 0) break; if (refIndex < 0) break;
var valueStart = refIndex + RefMarker.Length; var valueStart = refIndex + refMarker.Length;
while (valueStart < json.Length && (json[valueStart] == ' ' || json[valueStart] == ':')) while (valueStart < json.Length && (json[valueStart] == ' ' || json[valueStart] == ':'))
valueStart++; valueStart++;
@ -295,18 +248,12 @@ internal static class JsonReferencePostProcessor
{ {
valueStart++; valueStart++;
var valueEnd = valueStart; var valueEnd = valueStart;
while (valueEnd < json.Length && json[valueEnd] != '"') while (valueEnd < json.Length && json[valueEnd] != '"') valueEnd++;
valueEnd++; if (valueEnd > valueStart) result.Add(json.Substring(valueStart, valueEnd - valueStart));
if (valueEnd > valueStart)
result.Add(json.Substring(valueStart, valueEnd - valueStart));
searchStart = valueEnd + 1; searchStart = valueEnd + 1;
} }
else else
{
searchStart = valueStart; searchStart = valueStart;
}
} }
return result; return result;
@ -332,20 +279,12 @@ internal sealed class PooledStringWriter : StringWriter
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
{ {
if (!_disposed) if (!_disposed) { _disposed = true; StringBuilderPool.Return(_pooledBuilder); }
{
_disposed = true;
StringBuilderPool.Return(_pooledBuilder);
}
base.Dispose(disposing); base.Dispose(disposing);
} }
} }
internal interface ObjectPool<T> where T : class internal interface ObjectPool<T> where T : class { T Get(); void Return(T obj); }
{
T Get();
void Return(T obj);
}
internal sealed class DefaultObjectPool<T> : ObjectPool<T> where T : class internal sealed class DefaultObjectPool<T> : ObjectPool<T> where T : class
{ {
@ -385,7 +324,6 @@ public static class SerializeObjectExtensions
{ {
private static readonly UnifiedMergeContractResolver SharedContractResolver = new(); private static readonly UnifiedMergeContractResolver SharedContractResolver = new();
private static readonly Dictionary<object, object> EmptyContextDict = new(); private static readonly Dictionary<object, object> EmptyContextDict = new();
private static readonly JsonSerializer CachedSerializer = CreateCachedSerializer();
public static JsonSerializerSettings Options => new() public static JsonSerializerSettings Options => new()
{ {
@ -399,143 +337,113 @@ public static class SerializeObjectExtensions
Formatting = Formatting.None, Formatting = Formatting.None,
}; };
private static JsonSerializer CreateCachedSerializer() => JsonSerializer.Create(new JsonSerializerSettings /// <summary>
{ /// Serialize object to JSON string with default options.
ContractResolver = SharedContractResolver, /// </summary>
PreserveReferencesHandling = PreserveReferencesHandling.Objects, public static string ToJson<T>(this T source)
ReferenceLoopHandling = ReferenceLoopHandling.Ignore, => AcJsonSerializer.Serialize(source);
NullValueHandling = NullValueHandling.Ignore,
MetadataPropertyHandling = MetadataPropertyHandling.ReadAhead,
Formatting = Formatting.None,
});
/// <summary> /// <summary>
/// Serialize object to JSON string using high-performance AcJsonSerializer. /// Serialize object to JSON string with specified options.
/// Uses optimized reference handling with $id/$ref for shared objects.
/// Skips default values (0, false, empty strings, empty collections) to reduce JSON size.
/// </summary> /// </summary>
public static string ToJson<T>(this T source, JsonSerializerSettings? options = null) public static string ToJson<T>(this T source, AcJsonSerializerOptions options)
{ => AcJsonSerializer.Serialize(source, options);
// If custom options are provided, use Newtonsoft for full compatibility
//if (options != null)
//{
// return JsonConvert.SerializeObject(source, options);
//}
// Use our high-performance custom serializer
return AcJsonSerializer.Serialize(source);
// ========================================================================
// OLD IMPLEMENTATION - Newtonsoft with HybridReferenceResolver
// Uncomment below and comment out the AcJsonSerializer.Serialize line above to rollback
// ========================================================================
// var resolver = new HybridReferenceResolver(estimatedObjectCount: 256);
// var serializer = CachedSerializer;
//
// string json;
// using (var sw = PooledStringWriter.Rent())
// {
// var originalResolver = serializer.ReferenceResolver;
// serializer.ReferenceResolver = resolver;
// try
// {
// serializer.Serialize(sw, source);
// json = sw.ToString();
// }
// finally
// {
// serializer.ReferenceResolver = originalResolver;
// }
// }
//
// // Skip post-processing if no $id in output
// if (!json.Contains("\"$id\""))
// return json;
//
// // If we tracked references, use them
// if (resolver._referencedIds?.Count > 0)
// return JsonReferencePostProcessor.RemoveUnreferencedIds(json, resolver._referencedIds);
//
// // No references and no $ref - remove all $id
// if (!json.Contains("\"$ref\""))
// return JsonReferencePostProcessor.RemoveUnreferencedIds(json, null);
//
// // Fallback: scan JSON for $ref values
// var referenced = JsonReferencePostProcessor.CollectReferencedIds(json);
// return JsonReferencePostProcessor.RemoveUnreferencedIds(json, referenced);
}
public static string ToJson<T>(this IQueryable<T> source, JsonSerializerSettings? options = null) where T : class, IAcSerializableToJson public static string ToJson<T>(this IQueryable<T> source) where T : class, IAcSerializableToJson
//=> options != null ? JsonConvert.SerializeObject(source, options) : AcJsonSerializer.Serialize(source); => AcJsonSerializer.Serialize(source);
=> ((object)source).ToJson(options);
public static string ToJson<T>(this IQueryable<T> source, AcJsonSerializerOptions options) where T : class, IAcSerializableToJson
=> AcJsonSerializer.Serialize(source, options);
public static string ToJson<T>(this IEnumerable<T> source, JsonSerializerSettings? options = null) where T : class, IAcSerializableToJson public static string ToJson<T>(this IEnumerable<T> source) where T : class, IAcSerializableToJson
//=> options != null ? JsonConvert.SerializeObject(source, options) : AcJsonSerializer.Serialize(source); => AcJsonSerializer.Serialize(source);
=> ((object)source).ToJson(options);
public static T? JsonTo<T>(this string json, JsonSerializerSettings? options = null) public static string ToJson<T>(this IEnumerable<T> source, AcJsonSerializerOptions options) where T : class, IAcSerializableToJson
=> AcJsonSerializer.Serialize(source, options);
/// <summary>
/// Deserialize JSON to object with default options.
/// </summary>
public static T? JsonTo<T>(this string json)
{ {
json = JsonUtilities.UnwrapJsonString(json); json = UnwrapJsonString(json);
return AcJsonDeserializer.Deserialize<T>(json); return AcJsonDeserializer.Deserialize<T>(json);
// Use our high-performance custom deserializer
// AcJsonDeserializer now supports primitives, enums, and complex types
//if (options == null)
//{
// try
// {
// return AcJsonDeserializer.Deserialize<T>(json);
// }
// catch
// {
// // Fallback to Newtonsoft if custom deserializer fails
// }
//}
//return JsonConvert.DeserializeObject<T>(json, options ?? Options);
} }
public static object? JsonTo(this string json, Type toType, JsonSerializerSettings? options = null) /// <summary>
/// Deserialize JSON to object with specified options.
/// </summary>
public static T? JsonTo<T>(this string json, AcJsonSerializerOptions options)
{ {
json = JsonUtilities.UnwrapJsonString(json); json = UnwrapJsonString(json);
return AcJsonDeserializer.Deserialize<T>(json, options);
}
/// <summary>
/// Deserialize JSON to specified type with default options.
/// </summary>
public static object? JsonTo(this string json, Type toType)
{
json = UnwrapJsonString(json);
return AcJsonDeserializer.Deserialize(json, toType); return AcJsonDeserializer.Deserialize(json, toType);
//// Use our high-performance custom deserializer
//// AcJsonDeserializer now supports primitives, enums, and complex types
//if (options == null)
//{
// try
// {
// return AcJsonDeserializer.Deserialize(json, toType);
// }
// catch
// {
// // Fallback to Newtonsoft if custom deserializer fails
// }
//}
//return JsonConvert.DeserializeObject(json, toType, options ?? Options);
} }
public static void JsonTo(this string json, object target, JsonSerializerSettings? options = null) /// <summary>
/// Deserialize JSON to specified type with specified options.
/// </summary>
public static object? JsonTo(this string json, Type toType, AcJsonSerializerOptions options)
{ {
json = JsonUtilities.UnwrapJsonString(json); json = UnwrapJsonString(json);
return AcJsonDeserializer.Deserialize(json, toType, options);
// Use runtime type instead of compile-time type for Populate }
/// <summary>
/// Populate existing object from JSON with default options.
/// </summary>
public static void JsonTo(this string json, object target)
{
json = UnwrapJsonString(json);
AcJsonDeserializer.Populate(json, target); AcJsonDeserializer.Populate(json, target);
} }
[return: NotNullIfNotNull(nameof(src))] /// <summary>
public static TDestination? CloneTo<TDestination>(this object? src, JsonSerializerSettings? options = null) where TDestination : class /// Populate existing object from JSON with specified options.
/// </summary>
public static void JsonTo(this string json, object target, AcJsonSerializerOptions options)
{
json = UnwrapJsonString(json);
AcJsonDeserializer.Populate(json, target, options);
}
/// <summary>
/// Clone object via JSON serialization with default options.
/// </summary>
public static TDestination? CloneTo<TDestination>(this object? src) where TDestination : class
=> src?.ToJson().JsonTo<TDestination>();
/// <summary>
/// Clone object via JSON serialization with specified options.
/// </summary>
public static TDestination? CloneTo<TDestination>(this object? src, AcJsonSerializerOptions options) where TDestination : class
=> src?.ToJson(options).JsonTo<TDestination>(options); => src?.ToJson(options).JsonTo<TDestination>(options);
public static void CopyTo(this object? src, object target, JsonSerializerSettings? options = null) /// <summary>
/// Copy object properties to target via JSON with default options.
/// </summary>
public static void CopyTo(this object? src, object target)
=> src?.ToJson().JsonTo(target);
/// <summary>
/// Copy object properties to target via JSON with specified options.
/// </summary>
public static void CopyTo(this object? src, object target, AcJsonSerializerOptions options)
=> src?.ToJson(options).JsonTo(target, options); => src?.ToJson(options).JsonTo(target, options);
//public static byte[] ToMessagePack(this object message) => MessagePackSerializer.Serialize(message); public static byte[] ToMessagePack(this object message, MessagePackSerializerOptions options)
public static byte[] ToMessagePack(this object message, MessagePackSerializerOptions options) => MessagePackSerializer.Serialize(message, options); => MessagePackSerializer.Serialize(message, options);
//public static T MessagePackTo<T>(this byte[] message) => MessagePackSerializer.Deserialize<T>(message);
public static T MessagePackTo<T>(this byte[] message, MessagePackSerializerOptions options) => MessagePackSerializer.Deserialize<T>(message, options); public static T MessagePackTo<T>(this byte[] message, MessagePackSerializerOptions options)
=> MessagePackSerializer.Deserialize<T>(message, options);
} }
public class IgnoreAndRenamePropertySerializerContractResolver : DefaultContractResolver public class IgnoreAndRenamePropertySerializerContractResolver : DefaultContractResolver

View File

@ -110,13 +110,13 @@ namespace AyCode.Services.SignalRs
/// <summary> /// <summary>
/// Gets the current connection state. Override in tests. /// Gets the current connection state. Override in tests.
/// </summary> /// </summary>
protected virtual HubConnectionState GetConnectionState() protected virtual HubConnectionState GetConnectionState()
=> HubConnection?.State ?? HubConnectionState.Disconnected; => HubConnection?.State ?? HubConnectionState.Disconnected;
/// <summary> /// <summary>
/// Checks if the connection is connected. Override in tests. /// Checks if the connection is connected. Override in tests.
/// </summary> /// </summary>
protected virtual bool IsConnected() protected virtual bool IsConnected()
=> GetConnectionState() == HubConnectionState.Connected; => GetConnectionState() == HubConnectionState.Connected;
/// <summary> /// <summary>
@ -162,13 +162,13 @@ namespace AyCode.Services.SignalRs
/// <summary> /// <summary>
/// Gets the pending requests dictionary for testing. /// Gets the pending requests dictionary for testing.
/// </summary> /// </summary>
protected ConcurrentDictionary<int, SignalRRequestModel> GetPendingRequests() protected ConcurrentDictionary<int, SignalRRequestModel> GetPendingRequests()
=> _responseByRequestId; => _responseByRequestId;
/// <summary> /// <summary>
/// Clears all pending requests. /// Clears all pending requests.
/// </summary> /// </summary>
protected void ClearPendingRequests() protected void ClearPendingRequests()
=> _responseByRequestId.Clear(); => _responseByRequestId.Clear();
/// <summary> /// <summary>
@ -214,7 +214,7 @@ namespace AyCode.Services.SignalRs
Logger.DebugConditional($"Client SendMessageToServerAsync sending; {nameof(requestId)}: {requestId}; ConnectionState: {GetConnectionState()}; {ConstHelper.NameByValue(TagsName, messageTag)}"); Logger.DebugConditional($"Client SendMessageToServerAsync sending; {nameof(requestId)}: {requestId}; ConnectionState: {GetConnectionState()}; {ConstHelper.NameByValue(TagsName, messageTag)}");
await StartConnection(); await StartConnection();
var msgp = message?.ToMessagePack(ContractlessStandardResolver.Options); var msgp = message?.ToMessagePack(ContractlessStandardResolver.Options);
if (!IsConnected()) if (!IsConnected())
@ -273,13 +273,13 @@ namespace AyCode.Services.SignalRs
private static ISignalRMessage CreatePostMessage<TPostData>(TPostData postData) private static ISignalRMessage CreatePostMessage<TPostData>(TPostData postData)
{ {
var type = typeof(TPostData); var type = typeof(TPostData);
// Primitives, strings, enums, and value types should use IdMessage format // Primitives, strings, enums, and value types should use IdMessage format
if (IsPrimitiveOrStringOrEnum(type)) if (IsPrimitiveOrStringOrEnum(type))
{ {
return new SignalPostJsonDataMessage<IdMessage>(new IdMessage(postData!)); return new SignalPostJsonDataMessage<IdMessage>(new IdMessage(postData!));
} }
// Complex objects use direct serialization // Complex objects use direct serialization
return new SignalPostJsonDataMessage<TPostData>(postData); return new SignalPostJsonDataMessage<TPostData>(postData);
} }
@ -291,9 +291,9 @@ namespace AyCode.Services.SignalRs
/// </summary> /// </summary>
private static bool IsPrimitiveOrStringOrEnum(Type type) private static bool IsPrimitiveOrStringOrEnum(Type type)
{ {
return type == typeof(string) || return type == typeof(string) ||
type.IsEnum || type.IsEnum ||
type.IsValueType || type.IsValueType ||
type == typeof(DateTime); type == typeof(DateTime);
} }
@ -340,8 +340,9 @@ namespace AyCode.Services.SignalRs
if (await TaskHelper.WaitToAsync(() => _responseByRequestId[requestId].ResponseByRequestId != null, TransportSendTimeout, MsDelay, MsFirstDelay) && if (await TaskHelper.WaitToAsync(() => _responseByRequestId[requestId].ResponseByRequestId != null, TransportSendTimeout, MsDelay, MsFirstDelay) &&
_responseByRequestId.TryRemove(requestId, out var obj) && obj.ResponseByRequestId is ISignalResponseMessage<string> responseMessage) _responseByRequestId.TryRemove(requestId, out var obj) && obj.ResponseByRequestId is ISignalResponseMessage<string> responseMessage)
{ {
startTime = obj.RequestDateTime;
SignalRRequestModelPool.Return(obj); SignalRRequestModelPool.Return(obj);
if (responseMessage.Status == SignalResponseStatus.Error || responseMessage.ResponseData == null) if (responseMessage.Status == SignalResponseStatus.Error || responseMessage.ResponseData == null)
{ {
var errorText = $"Client SendMessageToServerAsync<TResponseData> response error; await; tag: {messageTag}; Status: {responseMessage.Status}; ConnectionState: {GetConnectionState()}; requestId: {requestId}"; var errorText = $"Client SendMessageToServerAsync<TResponseData> response error; await; tag: {messageTag}; Status: {responseMessage.Status}; ConnectionState: {GetConnectionState()}; requestId: {requestId}";
@ -355,14 +356,16 @@ namespace AyCode.Services.SignalRs
//return default; //return default;
} }
return responseMessage.ResponseData.JsonTo<TResponse>(); var responseData = responseMessage.ResponseData.JsonTo<TResponse>();
Logger.Info($"Client deserialized response json. Total: {(DateTime.UtcNow.Subtract(startTime)).TotalMilliseconds} ms! requestId: {requestId}; tag: {messageTag} [{ConstHelper.NameByValue(TagsName, messageTag)}]");
return responseData;
} }
Logger.Error($"Client timeout after: {(DateTime.Now - startTime).TotalSeconds} sec! ConnectionState: {GetConnectionState()}; requestId: {requestId}; tag: {messageTag} [{ConstHelper.NameByValue(TagsName, messageTag)}]"); Logger.Error($"Client timeout after: {(DateTime.Now - startTime).TotalSeconds} sec! ConnectionState: {GetConnectionState()}; requestId: {requestId}; tag: {messageTag} [{ConstHelper.NameByValue(TagsName, messageTag)}]");
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.Error($"SendMessageToServerAsync; requestId: {requestId}; ConnectionState: {GetConnectionState()}; {ex.Message}; {ConstHelper.NameByValue(TagsName, messageTag)}", ex); Logger.Error($"Client SendMessageToServerAsync; requestId: {requestId}; ConnectionState: {GetConnectionState()}; {ex.Message}; {ConstHelper.NameByValue(TagsName, messageTag)}", ex);
} }
if (_responseByRequestId.TryRemove(requestId, out var removedModel)) if (_responseByRequestId.TryRemove(requestId, out var removedModel))
@ -375,7 +378,7 @@ namespace AyCode.Services.SignalRs
public virtual Task SendMessageToServerAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback) public virtual Task SendMessageToServerAsync<TResponseData>(int messageTag, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback)
=> SendMessageToServerAsync(messageTag, null, responseCallback); => SendMessageToServerAsync(messageTag, null, responseCallback);
public virtual Task SendMessageToServerAsync<TResponseData>(int messageTag, ISignalRMessage? message, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback) public virtual Task SendMessageToServerAsync<TResponseData>(int messageTag, ISignalRMessage? message, Func<ISignalResponseMessage<TResponseData?>, Task> responseCallback)
{ {
if (messageTag == 0) Logger.Error($"SendMessageToServerAsync; messageTag == 0"); if (messageTag == 0) Logger.Error($"SendMessageToServerAsync; messageTag == 0");
@ -444,7 +447,7 @@ namespace AyCode.Services.SignalRs
{ {
SignalRRequestModelPool.Return(removedModel); SignalRRequestModelPool.Return(removedModel);
} }
// Request-response hibás eset - ne hívjuk meg a MessageReceived-et // Request-response hibás eset - ne hívjuk meg a MessageReceived-et
return Task.CompletedTask; return Task.CompletedTask;
} }

View File

@ -215,17 +215,19 @@ public sealed class SignalResponseMessage<TResponseData> : ISignalResponseMessag
{ {
if (!_isDeserialized) if (!_isDeserialized)
{ {
_responseData = ResponseDataJson != null
? ResponseDataJson.JsonTo<TResponseData>()
: default;
_isDeserialized = true; _isDeserialized = true;
_responseData = ResponseDataJson != null
? ResponseDataJson.JsonTo<TResponseData>()
: default;
} }
return _responseData; return _responseData;
} }
set set
{ {
_responseData = value;
_isDeserialized = true; _isDeserialized = true;
_responseData = value;
ResponseDataJson = value?.ToJson(); ResponseDataJson = value?.ToJson();
} }
} }

View File

@ -27,7 +27,7 @@ public class SignalRRequestModel : IResettable
/// </summary> /// </summary>
public bool TryReset() public bool TryReset()
{ {
RequestDateTime = DateTime.UtcNow; RequestDateTime = default;
ResponseDateTime = default; ResponseDateTime = default;
ResponseByRequestId = null; ResponseByRequestId = null;
return true; return true;

View File

@ -25,7 +25,6 @@ public class SerializationBenchmarks
// Settings // Settings
private JsonSerializerSettings _newtonsoftNoRefSettings = null!; private JsonSerializerSettings _newtonsoftNoRefSettings = null!;
private JsonSerializerSettings _ayCodeSettings = null!;
[GlobalSetup] [GlobalSetup]
public void Setup() public void Setup()
@ -39,9 +38,6 @@ public class SerializationBenchmarks
Formatting = Formatting.None Formatting = Formatting.None
}; };
// AyCode WITH reference handling
_ayCodeSettings = SerializeObjectExtensions.Options;
// Create benchmark data using shared factory // Create benchmark data using shared factory
// ~1500 objects: 5 items × 4 pallets × 3 measurements × 5 points = 300 points + containers // ~1500 objects: 5 items × 4 pallets × 3 measurements × 5 points = 300 points + containers
_testOrder = TestDataFactory.CreateBenchmarkOrder( _testOrder = TestDataFactory.CreateBenchmarkOrder(
@ -52,7 +48,7 @@ public class SerializationBenchmarks
// Pre-serialize for deserialization benchmarks // Pre-serialize for deserialization benchmarks
_newtonsoftJson = JsonConvert.SerializeObject(_testOrder, _newtonsoftNoRefSettings); _newtonsoftJson = JsonConvert.SerializeObject(_testOrder, _newtonsoftNoRefSettings);
_ayCodeJson = _testOrder.ToJson(_ayCodeSettings); _ayCodeJson = _testOrder.ToJson();
// Create target for populate benchmarks // Create target for populate benchmarks
_populateTarget = new TestOrder(); _populateTarget = new TestOrder();
@ -77,7 +73,7 @@ public class SerializationBenchmarks
[Benchmark(Description = "AyCode (with refs)")] [Benchmark(Description = "AyCode (with refs)")]
[BenchmarkCategory("Serialize")] [BenchmarkCategory("Serialize")]
public string Serialize_AyCode_WithRefs() public string Serialize_AyCode_WithRefs()
=> _testOrder.ToJson(_ayCodeSettings); => _testOrder.ToJson();
[Benchmark(Description = "AcJsonSerializer (custom)")] [Benchmark(Description = "AcJsonSerializer (custom)")]
[BenchmarkCategory("Serialize")] [BenchmarkCategory("Serialize")]
@ -96,7 +92,7 @@ public class SerializationBenchmarks
[Benchmark(Description = "AyCode (with refs)")] [Benchmark(Description = "AyCode (with refs)")]
[BenchmarkCategory("Deserialize")] [BenchmarkCategory("Deserialize")]
public TestOrder? Deserialize_AyCode_WithRefs() public TestOrder? Deserialize_AyCode_WithRefs()
=> _ayCodeJson.JsonTo<TestOrder>(_ayCodeSettings); => _ayCodeJson.JsonTo<TestOrder>();
[Benchmark(Description = "AcJsonDeserializer (custom)")] [Benchmark(Description = "AcJsonDeserializer (custom)")]
[BenchmarkCategory("Deserialize")] [BenchmarkCategory("Deserialize")]