diff --git a/.github/workflows/aot-audit.yml b/.github/workflows/aot-audit.yml new file mode 100644 index 000000000..aa268a4b2 --- /dev/null +++ b/.github/workflows/aot-audit.yml @@ -0,0 +1,80 @@ +name: Ecng Native AOT audit + +on: + workflow_dispatch: + pull_request: + paths: + - 'common.props' + - 'Common/**' + - 'Serialization/**' + - 'Tests/Common/AotCompatibilityTests.cs' + - 'AotSmoke/**' + - '.github/workflows/aot-audit.yml' + +jobs: + aot: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v7 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v6 + with: + dotnet-version: | + 6.0.x + 10.0.x + + - name: Install native AOT prerequisites + run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev + + - name: Compile legacy net6 target + run: dotnet build Serialization/Serialization.csproj -c Release -f net6.0 + + - name: Analyze Ecng.Common + shell: bash + run: | + set -o pipefail + dotnet build Common/Common.csproj -c Release -f net10.0 \ + -p:EcngAotAudit=true -p:TrimmerSingleWarn=false \ + -bl:common-aot.binlog 2>&1 | tee common-aot.log + + - name: Analyze Ecng.Serialization + shell: bash + run: | + set -o pipefail + dotnet build Serialization/Serialization.csproj -c Release -f net10.0 \ + -p:EcngAotAudit=true -p:TrimmerSingleWarn=false \ + -bl:serialization-aot.binlog 2>&1 | tee serialization-aot.log + + - name: Run compatibility tests + run: > + dotnet test Tests/Tests.csproj -c Release -f net10.0 + --filter 'FullyQualifiedName~AotCompatibilityTests' + --settings Tests/test.runsettings + + - name: Publish native smoke app + shell: bash + run: | + set -o pipefail + dotnet publish AotSmoke/AotSmoke.csproj -f net10.0 -c Release -r linux-x64 \ + -p:EcngAotAudit=true -p:TrimmerSingleWarn=false \ + -bl:publish-aot.binlog -o artifacts/ecng-aot 2>&1 | tee publish-aot.log + + - name: Execute native smoke app + run: ./artifacts/ecng-aot/Ecng.AotSmoke + + - name: Upload audit logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: ecng-aot-diagnostics + path: | + common-aot.log + common-aot.binlog + serialization-aot.log + serialization-aot.binlog + publish-aot.log + publish-aot.binlog + if-no-files-found: warn + retention-days: 14 diff --git a/AotSmoke/AotSmoke.csproj b/AotSmoke/AotSmoke.csproj new file mode 100644 index 000000000..a765b02f8 --- /dev/null +++ b/AotSmoke/AotSmoke.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + true + false + false + false + + + + + + diff --git a/AotSmoke/Program.cs b/AotSmoke/Program.cs new file mode 100644 index 000000000..a8c6ed776 --- /dev/null +++ b/AotSmoke/Program.cs @@ -0,0 +1,130 @@ +namespace Ecng.AotSmoke; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using System.Text.Json.Serialization; + +using Ecng.Common; +using Ecng.Serialization; +using Ecng.Reflection; + +using Newtonsoft.Json; + +internal sealed record QuoteSnapshot(decimal Price, decimal Volume); + +[JsonSerializable(typeof(QuoteSnapshot))] +internal partial class AotJsonContext : JsonSerializerContext +{ +} + +#pragma warning disable CS0618 // Test compatibility with existing persistable settings storage. +internal sealed class StoredQuote : IPersistable +{ + public int Quantity { get; private set; } + public void Load(SettingsStorage storage) => Quantity = storage.GetValue("quantity"); + public void Save(SettingsStorage storage) => storage.Set("quantity", Quantity); +} +#pragma warning restore CS0618 + +internal interface IDiscoveryProbe { } +internal sealed class DiscoveryProbe : IDiscoveryProbe { } + +internal sealed class WireQuoteRow +{ + public decimal Price; + public decimal Volume; +} + +internal sealed class PrivateAotCtor +{ + private PrivateAotCtor() { } + public int Value { get; private set; } + public static PrivateAotCtor Create(int value) => new() { Value = value }; +} + +internal static class Program +{ + private static async Task Main() + { + // Static references ensure these generic instantiations exist in the native binary. + TypeHelper.RegisterConstructedGenericType(typeof(Dictionary)); + var genericType = typeof(Dictionary<,>).Make(typeof(string), typeof(QuoteSnapshot)); + if (genericType != typeof(Dictionary)) + throw new InvalidOperationException("Closed generic type lookup failed."); + + TypeHelper.RegisterFactory(args => new QuoteSnapshot((decimal)args[0], (decimal)args[1])); + var quote = typeof(QuoteSnapshot).CreateInstance(100m, 2m); + if (quote.Price != 100m || quote.Volume != 2m) + throw new InvalidOperationException("Registered factory failed."); + + Converter.RegisterTypeName("ecng.aot.quote"); + if ("ECNG.AOT.QUOTE".To() != typeof(QuoteSnapshot)) + throw new InvalidOperationException("Registered type name failed."); + + var json = quote.ToJson(AotJsonContext.Default.QuoteSnapshot); + var copy = json.FromJson(AotJsonContext.Default.QuoteSnapshot); + if (copy != quote) + throw new InvalidOperationException("Source-generated JSON round trip failed."); + + ReflectionHelper.RegisterImplementation(); + var implementations = typeof(DiscoveryProbe).Assembly + .FindImplementations(showNonPublic: true); + if (!implementations.Contains(typeof(DiscoveryProbe))) + throw new InvalidOperationException("AOT implementation discovery failed."); + + // Existing array allocation and generic construction paths, not only new APIs. + ArrayHelper.RegisterArrayType(); + var typedArray = typeof(QuoteSnapshot).CreateArray(2); + if (typedArray is not QuoteSnapshot[] || typedArray.Length != 2) + throw new InvalidOperationException("Registered array allocation failed."); + + if (typeof(int).CreateArray(3) is not int[] { Length: 3 }) + throw new InvalidOperationException("Standard array factory failed."); + TypeHelper.RegisterConstructedGenericType(typeof(List)); + TypeHelper.RegisterFactory>(_ => new List()); + var listType = typeof(List<>).Make(typeof(int)); + if (listType.CreateInstance() is not List) + throw new InvalidOperationException("Registered list factory failed."); + + FastActivator.RegisterFactory(() => PrivateAotCtor.Create(9)); + if (FastActivator.CreateObject().Value != 9) + throw new InvalidOperationException("FastActivator override failed."); + + TypeHelper.RegisterFactory(_ => new StoredQuote()); + var storage = new SettingsStorage() + .Set("quote", new SettingsStorage().Set("quantity", 7)); + if (storage.GetValue("quote").Quantity != 7) + throw new InvalidOperationException("Existing SettingsStorage factory path failed."); + + // Exercise the existing Ecng JSON collection deserializer with the registered + // List and array factory. This uses the legacy wire format on purpose. + var legacySerializer = new Ecng.Serialization.JsonSerializer>(); + using var stream = new MemoryStream(); + await legacySerializer.SerializeAsync(new List { 1, 2, 3 }, stream, default); + stream.Position = 0; + var restoredList = await legacySerializer.DeserializeAsync(stream, default); + if (restoredList.Count != 3 || restoredList[0] != 1 || restoredList[2] != 3) + throw new InvalidOperationException("Existing collection serializer path failed."); + + // The legacy Newtonsoft field setter must not attempt Reflection.Emit on AOT. + JArrayToObjectConverter.RegisterAotFields( + (typeof(decimal), row => row.Price, (row, value) => row.Price = (decimal)value), + (typeof(decimal), row => row.Volume, (row, value) => row.Volume = (decimal)value)); + using var reader = new JsonTextReader(new StringReader("[101.25,2.5]")); + reader.Read(); + var row = (WireQuoteRow)new JArrayToObjectConverter() + .ReadJson(reader, typeof(WireQuoteRow), null, Newtonsoft.Json.JsonSerializer.CreateDefault()); + if (row.Price != 101.25m || row.Volume != 2.5m) + throw new InvalidOperationException("Legacy JSON-array field setter path failed."); + + var dynamicTypeInfo = (System.Text.Json.Serialization.Metadata.JsonTypeInfo)AotJsonContext.Default.QuoteSnapshot; + var metadataJson = ((object)quote).ToJson(dynamicTypeInfo); + if ((QuoteSnapshot)metadataJson.FromJson(dynamicTypeInfo) != quote) + throw new InvalidOperationException("Non-generic source-generated JSON failed."); + + Console.WriteLine("Ecng Native AOT smoke test passed."); + } +} diff --git a/AotSmoke/README.md b/AotSmoke/README.md new file mode 100644 index 000000000..50fabfc95 --- /dev/null +++ b/AotSmoke/README.md @@ -0,0 +1,36 @@ +# Ecng Native AOT compatibility audit + +The core Ecng libraries keep their normal JIT behavior and NuGet package compatibility. + +## Compile-time diagnostics + +Run the following commands from the repository root: + +```bash +dotnet build Common/Common.csproj -c Release -f net10.0 -p:EcngAotAudit=true +dotnet build Serialization/Serialization.csproj -c Release -f net10.0 -p:EcngAotAudit=true +``` + +This opt-in flag enables AOT, trimming and single-file analyzers on net10.0 builds. +It does not declare that the libraries are globally AOT-compatible. + +## Minimal native smoke application + +```bash +dotnet publish AotSmoke/AotSmoke.csproj -f net10.0 -c Release -r linux-x64 -p:EcngAotAudit=true +./AotSmoke/bin/Release/net10.0/linux-x64/publish/Ecng.AotSmoke +``` + +The smoke app requires Linux Clang and zlib development headers. It demonstrates: + +- Factory registration with `TypeHelper.RegisterFactory` +- Closed generic registration with `TypeHelper.RegisterConstructedGenericType` +- Name-to-type registration with `Converter.RegisterTypeName` +- Source-generated System.Text.Json through `JsonHelper.FromJson/ToJson` overloads + +Old Newtonsoft.Json and dynamic reflection helpers remain available to ordinary JIT applications. +For an AOT process, types must be referenced and registered at build time; arbitrary plugin DLL loading, +dynamic IL compilation and unregistered generic instantiations are not supported by these helpers. + +Successful compilation and a passing smoke test do not establish AOT compatibility +of the full Ecng framework. Review the full IL2xxx/IL3xxx warning log before expanding coverage. diff --git a/Common/ArrayHelper.cs b/Common/ArrayHelper.cs index 7ee3d0a5c..f126387e2 100644 --- a/Common/ArrayHelper.cs +++ b/Common/ArrayHelper.cs @@ -1,10 +1,58 @@ namespace Ecng.Common; +using System.Collections.Concurrent; + /// /// Utility class for common array operations. /// public static class ArrayHelper { + private static readonly ConcurrentDictionary> _registeredArrayFactories = new(); + + static ArrayHelper() + { + // Common arrays exist as closed generic instantiations in the native binary. + // Avoid Array.CreateInstance(Type, int) for standard wire/message value types. + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + RegisterArrayType(); + } + + /// + /// Registers a statically compiled array factory for . + /// This avoids runtime array type construction on Native AOT. + /// + public static void RegisterArrayType() + => _registeredArrayFactories.TryAdd(typeof(T), static length => new T[length]); + + /// + /// Registers a custom statically compiled factory for one-dimensional arrays. + /// Only one factory may be registered for each element type. + /// + public static void RegisterArrayFactory(Func factory) + { + ArgumentNullException.ThrowIfNull(factory); + + if (!_registeredArrayFactories.TryAdd(typeof(T), length => factory(length))) + throw new InvalidOperationException($"An array factory for '{typeof(T)}' is already registered."); + } + /// /// Sets all of the elements in the specified to zero, false, or null, depending on the element type. /// @@ -38,7 +86,14 @@ public static void Clear(this Array array, int index, int count) /// A new array of the specified type and length. public static Array CreateArray(this Type type, int count) { - return Array.CreateInstance(type, count); + ArgumentNullException.ThrowIfNull(type); + + if (count < 0) + throw new ArgumentOutOfRangeException(nameof(count)); + + return _registeredArrayFactories.TryGetValue(type, out var factory) + ? factory(count) + : Array.CreateInstance(type, count); } /// diff --git a/Common/Converter.cs b/Common/Converter.cs index 135fe0549..fc25cc032 100644 --- a/Common/Converter.cs +++ b/Common/Converter.cs @@ -33,6 +33,27 @@ public static class Converter /// public static Func TypeFallback { get; set; } + /// + /// Pre-registers a name for a statically referenced type, bypassing dynamic assembly discovery. + /// This is useful for AOT apps that know their serializable/configuration types at build time. + /// + public static void RegisterTypeName(string name) + => RegisterTypeName(name, typeof(T)); + + /// + /// Pre-registers a name for a known CLR type. + /// + public static void RegisterTypeName(string name, Type type) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("A type name is required.", nameof(name)); + ArgumentNullException.ThrowIfNull(type); + + var key = name.ToLowerInvariant(); + if (!_typeCache.TryAdd(key, type) && _typeCache[key] != type) + throw new InvalidOperationException($"Type name '{name}' is already registered for another type."); + } + /// /// Fallback handler for Enum conversion from string when value is not found. /// Returns the resolved enum value, or null to throw exception. @@ -835,7 +856,7 @@ private static bool ArrayCovariance(Array source, Type destinationType, out obje if (destinationType.IsArray) { var elemType = destinationType.GetElementType(); - var destArr = Array.CreateInstance(elemType, source.Length); + var destArr = elemType.CreateArray(source.Length); for (var i = 0; i < source.Length; i++) destArr.SetValue(source.GetValue(i).To(elemType), i); diff --git a/Common/FastActivator.cs b/Common/FastActivator.cs index 00ac814c4..e93e01336 100644 --- a/Common/FastActivator.cs +++ b/Common/FastActivator.cs @@ -1,6 +1,7 @@ namespace Ecng.Common; using System.Reflection.Emit; +using System.Runtime.CompilerServices; /// /// isn't supported. @@ -27,16 +28,37 @@ public static class FastActivator /// /// Create object handler. /// - public static Func CreateObject { get; } + private static Func _createObject; + + /// Factory used by the fast activator. + public static Func CreateObject => Volatile.Read(ref _createObject); + + /// + /// Replaces the reflection/IL-emit factory with a statically compiled delegate. + /// Use this to support constructors unavailable to Native AOT reflection. + /// + public static void RegisterFactory(Func factory) + { + ArgumentNullException.ThrowIfNull(factory); + Volatile.Write(ref _createObject, factory); + } static FastActivator() { + if (!RuntimeFeature.IsDynamicCodeSupported) + { + // Do not inspect constructors or emit IL in native images. + // RegisterFactory can still supply a precompiled constructor delegate. + _createObject = Activator.CreateInstance; + return; + } + var objType = typeof(T); var cinfo = objType.GetConstructor(BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic, null, Type.EmptyTypes, null); if ((NotSupported || Scope.Current != null) || objType.IsValueType || cinfo is null) { - CreateObject = Activator.CreateInstance; + _createObject = Activator.CreateInstance; } else { @@ -49,14 +71,14 @@ static FastActivator() catch (PlatformNotSupportedException) { NotSupported = true; - CreateObject = Activator.CreateInstance; + _createObject = Activator.CreateInstance; return; } ILGenerator ilGen = dynMethod.GetILGenerator(); ilGen.Emit(OpCodes.Newobj, cinfo); ilGen.Emit(OpCodes.Ret); - CreateObject = (Func)dynMethod.CreateDelegate(typeof(Func)); + _createObject = (Func)dynMethod.CreateDelegate(typeof(Func)); } } } diff --git a/Common/TypeHelper.cs b/Common/TypeHelper.cs index fc823e121..c0c546d87 100644 --- a/Common/TypeHelper.cs +++ b/Common/TypeHelper.cs @@ -1,6 +1,8 @@ namespace Ecng.Common; using System.Dynamic; +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; using System.Security.Cryptography; /// @@ -10,6 +12,70 @@ public static class TypeHelper { private static readonly Type _enumType = typeof(Enum); + // Explicitly registered constructors and closed generic types can be used by + // Native AOT without runtime discovery or generation. The JIT fallback stays intact. + private static readonly ConcurrentDictionary> _registeredFactories = new(); + private sealed class TypeArgumentsComparer : IEqualityComparer + { + public bool Equals(Type[] x, Type[] y) + { + if (ReferenceEquals(x, y)) + return true; + + if (x is null || y is null || x.Length != y.Length) + return false; + + for (var i = 0; i < x.Length; i++) + { + if (x[i] != y[i]) + return false; + } + + return true; + } + + public int GetHashCode(Type[] arguments) + { + var hash = new HashCode(); + foreach (var argument in arguments) + hash.Add(argument); + return hash.ToHashCode(); + } + } + + // Structural type-argument lookup avoids scanning generic candidates and + // allocating GenericTypeArguments arrays on every invocation of Make. + private static readonly ConcurrentDictionary> _registeredGenerics = new(); + + /// + /// Registers an AOT-safe factory for a known type. Registration must happen at startup. + /// + public static void RegisterFactory(Func factory) + { + ArgumentNullException.ThrowIfNull(factory); + + if (!_registeredFactories.TryAdd(typeof(T), args => factory(args))) + throw new InvalidOperationException($"A factory for '{typeof(T)}' is already registered."); + } + + /// + /// Registers a constructed generic type that can be resolved without runtime generic instantiation. + /// + public static void RegisterConstructedGenericType(Type constructedType) + { + ArgumentNullException.ThrowIfNull(constructedType); + + if (!constructedType.IsConstructedGenericType) + throw new ArgumentException("Expected a closed generic type.", nameof(constructedType)); + + var definition = constructedType.GetGenericTypeDefinition(); + + var types = _registeredGenerics.GetOrAdd(definition, + static _ => new ConcurrentDictionary(new TypeArgumentsComparer())); + + types.TryAdd(constructedType.GenericTypeArguments, constructedType); + } + /// /// Creates an instance of the specified type using the given arguments. /// @@ -34,9 +100,11 @@ public static T CreateInstance(this Type type, params object[] args) if (args is null) throw new ArgumentNullException(nameof(args)); - var obj = type is ITypeConstructor ctor - ? ctor.CreateInstance(args) - : Activator.CreateInstance(type, args); + var obj = _registeredFactories.TryGetValue(type, out var factory) + ? factory(args) + : type is ITypeConstructor ctor + ? ctor.CreateInstance(args) + : Activator.CreateInstance(type, args); return obj.To(); } @@ -55,6 +123,15 @@ public static Type Make(this Type type, params Type[] args) if (args is null) throw new ArgumentNullException(nameof(args)); + + if (_registeredGenerics.TryGetValue(type, out var registered) && + registered.TryGetValue(args, out var constructedType)) + return constructedType; + + // Preserve normal JIT behavior, but give a useful diagnostic on Native AOT. + if (!RuntimeFeature.IsDynamicCodeSupported) + throw new NotSupportedException($"Generic instantiation '{type}' is not registered. Call RegisterConstructedGenericType with the closed type before using Make."); + return type.MakeGenericType(args); } diff --git a/Reflection/ReflectionHelper.cs b/Reflection/ReflectionHelper.cs index 8d56ff7c2..366af3e44 100644 --- a/Reflection/ReflectionHelper.cs +++ b/Reflection/ReflectionHelper.cs @@ -42,6 +42,23 @@ public static class ReflectionHelper /// public const BindingFlags AllMembers = AllStaticMembers | AllInstanceMembers; + // Native AOT cannot discover arbitrary implementations by enumerating assemblies. + // Statically referenced registration lets existing discovery callers retain their API. + private static readonly ConcurrentDictionary<(Assembly assembly, Type contract), ConcurrentDictionary> _registeredImplementations = new(); + + /// + /// Registers a statically known implementation for Native AOT type discovery. + /// + public static void RegisterImplementation() + where TImplementation : TContract + { + var implementation = typeof(TImplementation); + var key = (implementation.Assembly, typeof(TContract)); + _registeredImplementations + .GetOrAdd(key, static _ => new ConcurrentDictionary()) + .TryAdd(implementation, 0); + } + #region Public Methods /// @@ -1055,6 +1072,19 @@ public static IEnumerable FindImplementations(this Assembly assembly, b extraFilter ??= t => true; + if (!RuntimeFeature.IsDynamicCodeSupported) + { + if (!_registeredImplementations.TryGetValue((assembly, typeof(T)), out var registered)) + return []; + + return registered.Keys.Where(t => + !t.IsAbstract && !t.IsInterface && + (showNonPublic || t.IsPublic || t.IsNestedPublic) && + (showObsolete || !t.IsObsolete()) && + (showNonBrowsable || t.IsBrowsable()) && + extraFilter(t)); + } + return assembly .GetTypes() .Where(t => t.Is() && !t.IsAbstract && !t.IsInterface && diff --git a/Serialization/JArrayToObjectConverter.cs b/Serialization/JArrayToObjectConverter.cs index 2c777e922..5bf90ac4c 100644 --- a/Serialization/JArrayToObjectConverter.cs +++ b/Serialization/JArrayToObjectConverter.cs @@ -1,6 +1,8 @@ namespace Ecng.Serialization; using System.Reflection.Emit; +using System.Runtime.CompilerServices; +using System.Diagnostics.CodeAnalysis; using Newtonsoft.Json.Linq; @@ -87,11 +89,44 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s /// in declaration order using compiled IL for performance. /// /// The type of the object to convert to. Must have a parameterless constructor. -public class JArrayToObjectConverter : JsonConverter +public class JArrayToObjectConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.NonPublicFields)] T> : JsonConverter where T : new() { + // Native AOT: explicit, statically compiled field accessors and stable wire order. + // JIT callers may continue to use the original reflection/IL-emit path. + private static (Type fieldType, Func getter, Action setter)[] _registeredFields; + + /// + /// Registers field metadata and precompiled accessors in JSON wire order. + /// Use this instead of relying on reflected fields for Native AOT models. + /// Must be called before reading or writing the first payload. + /// + public static void RegisterAotFields(params (Type fieldType, Func getter, Action setter)[] fields) + { + ArgumentNullException.ThrowIfNull(fields); + if (fields.Length == 0) + throw new ArgumentException("At least one field must be registered.", nameof(fields)); + + foreach (var field in fields) + { + ArgumentNullException.ThrowIfNull(field.fieldType); + ArgumentNullException.ThrowIfNull(field.getter); + ArgumentNullException.ThrowIfNull(field.setter); + } + + var snapshot = fields.ToArray(); + if (Interlocked.CompareExchange(ref _registeredFields, snapshot, null) is not null) + throw new InvalidOperationException($"AOT JSON fields for '{typeof(T)}' are already registered."); + } + private static Action CreateFieldSetter(FieldInfo fieldInfo) { + // Reflection.Emit is unavailable in Native AOT. Use retained field metadata + // for the legacy Newtonsoft path instead of failing in the static constructor. + // For reflection-free AOT payloads, use JsonHelper's source-generated overloads. + if (!RuntimeFeature.IsDynamicCodeSupported) + return (target, value) => fieldInfo.SetValue(target, value); + var method = new DynamicMethod( "Set" + fieldInfo.Name, typeof(void), @@ -124,6 +159,15 @@ private static Action CreateFieldSetter(FieldInfo fieldInfo) static JArrayToObjectConverter() { + // Native AOT reflection may not expose MetadataToken, which + // GetJsonFields/OrderByDeclaration depends on. Never evaluate it + // during native static initialization; explicit wire metadata is required. + if (!RuntimeFeature.IsDynamicCodeSupported) + { + _fields = []; + return; + } + _fields = [.. typeof(T) .GetJsonFields() .Select(f => (f, CreateFieldSetter(f)))]; @@ -155,6 +199,24 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist var array = JArray.Load(reader); + var registered = Volatile.Read(ref _registeredFields); + if (registered is not null) + { + if (array.Count < registered.Length) + throw new JsonSerializationException($"JSON array has {array.Count} elements but type '{typeof(T).Name}' requires {registered.Length}."); + + for (var i = 0; i < registered.Length; i++) + { + var field = registered[i]; + field.setter(typed, array[i].ToObject(field.fieldType)); + } + + return existingValue; + } + + if (!RuntimeFeature.IsDynamicCodeSupported) + throw new NotSupportedException($"AOT JSON fields for '{typeof(T)}' are not registered. Call RegisterAotFields before deserializing."); + if (array.Count < _fields.Length) throw new JsonSerializationException($"JSON array has {array.Count} elements but type '{typeof(T).Name}' requires {_fields.Length}."); @@ -190,8 +252,20 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s writer.WriteStartArray(); - foreach (var (f, _) in _fields) - serializer.Serialize(writer, f.GetValue(value)); + var registered = Volatile.Read(ref _registeredFields); + if (registered is not null) + { + foreach (var field in registered) + serializer.Serialize(writer, field.getter((T)value)); + } + else + { + if (!RuntimeFeature.IsDynamicCodeSupported) + throw new NotSupportedException($"AOT JSON fields for '{typeof(T)}' are not registered. Call RegisterAotFields before serializing."); + + foreach (var (f, _) in _fields) + serializer.Serialize(writer, f.GetValue(value)); + } writer.WriteEndArray(); } diff --git a/Serialization/JsonHelper.Aot.cs b/Serialization/JsonHelper.Aot.cs new file mode 100644 index 000000000..a74f40e28 --- /dev/null +++ b/Serialization/JsonHelper.Aot.cs @@ -0,0 +1,50 @@ +namespace Ecng.Serialization; + +using System.Text.Json.Serialization.Metadata; + +/// +/// Source-generated, trimming-safe JSON alternatives to the existing Newtonsoft-based methods. +/// Callers explicitly provide generated metadata for every serialized type. +/// +public static partial class JsonHelper +{ +#if NET7_0_OR_GREATER + /// + /// Deserializes using non-generic, source-generated metadata known at build time. + /// The type information replaces dynamic CLR type inspection. + /// + public static object FromJson(this string json, JsonTypeInfo typeInfo) + { + ArgumentNullException.ThrowIfNull(typeInfo); + return System.Text.Json.JsonSerializer.Deserialize(json, typeInfo); + } + + /// + /// Serializes an object with non-generic, source-generated metadata. + /// + public static string ToJson(this object value, JsonTypeInfo typeInfo) + { + ArgumentNullException.ThrowIfNull(typeInfo); + return System.Text.Json.JsonSerializer.Serialize(value, typeInfo); + } + +#endif + + /// + /// Deserializes with a source-generated JSON contract, without runtime contract discovery. + /// + public static T FromJson(this string json, JsonTypeInfo typeInfo) + { + ArgumentNullException.ThrowIfNull(typeInfo); + return System.Text.Json.JsonSerializer.Deserialize(json, typeInfo); + } + + /// + /// Serializes with a source-generated JSON contract, without runtime contract discovery. + /// + public static string ToJson(this T value, JsonTypeInfo typeInfo) + { + ArgumentNullException.ThrowIfNull(typeInfo); + return System.Text.Json.JsonSerializer.Serialize(value, typeInfo); + } +} diff --git a/Serialization/JsonHelper.cs b/Serialization/JsonHelper.cs index 27f049e43..805f2b634 100644 --- a/Serialization/JsonHelper.cs +++ b/Serialization/JsonHelper.cs @@ -1,6 +1,7 @@ namespace Ecng.Serialization; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using Newtonsoft.Json.Linq; using Newtonsoft.Json.Serialization; @@ -8,7 +9,7 @@ /// /// Provides helper methods for JSON serialization and deserialization. /// -public static class JsonHelper +public static partial class JsonHelper { /// /// Provides a UTF-8 encoding without a byte order mark (BOM). @@ -218,7 +219,7 @@ public static string SkipBom(this string str) return str; } - internal static FieldInfo[] GetJsonFields(this Type type) + internal static FieldInfo[] GetJsonFields([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.NonPublicFields)] this Type type) { return [.. type .GetFields(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance) diff --git a/Serialization/JsonSerializer.cs b/Serialization/JsonSerializer.cs index 0e7d0d0bd..a7342ce46 100644 --- a/Serialization/JsonSerializer.cs +++ b/Serialization/JsonSerializer.cs @@ -343,7 +343,7 @@ private async ValueTask ReadAsync(JsonReader reader, Type type, Cancella var itemType = type.GetItemType(); - var col = (IList)Activator.CreateInstance(typeof(List<>).Make(itemType)); + var col = (IList)typeof(List<>).Make(itemType).CreateInstance(); while (true) { @@ -360,7 +360,7 @@ private async ValueTask ReadAsync(JsonReader reader, Type type, Cancella if (!type.IsArray) return col; - var arr = Array.CreateInstance(itemType, col.Count); + var arr = itemType.CreateArray(col.Count); var idx = 0; foreach (var item in col) diff --git a/Serialization/SettingsStorage.cs b/Serialization/SettingsStorage.cs index e5990a806..64dcbf7bd 100644 --- a/Serialization/SettingsStorage.cs +++ b/Serialization/SettingsStorage.cs @@ -151,7 +151,7 @@ public object GetValue(Type type, string name, object defaultValue = default) var t = type.GetUnderlyingType() ?? type; - var obj = Activator.CreateInstance(t); + var obj = t.CreateInstance(); if (obj is IAsyncPersistable asyncPer) AsyncContext.Run(() => asyncPer.LoadAsync(storage, default)); @@ -174,7 +174,7 @@ public object GetValue(Type type, string name, object defaultValue = default) if (storage is null) return null; - var per = Activator.CreateInstance(elemType); + var per = elemType.CreateInstance(); if (per is IAsyncPersistable asyncPer) AsyncContext.Run(() => asyncPer.LoadAsync(storage, default)); @@ -286,7 +286,7 @@ public async ValueTask GetValueAsync(Type type, string name, object defa private static async ValueTask LoadPersistableAsync(Type type, SettingsStorage storage, CancellationToken cancellationToken) { - var obj = Activator.CreateInstance(type); + var obj = type.CreateInstance(); if (obj is IAsyncPersistable asyncPer) await asyncPer.LoadAsync(storage, cancellationToken).NoWait(); diff --git a/Tests/Common/AotCompatibilityTests.cs b/Tests/Common/AotCompatibilityTests.cs new file mode 100644 index 000000000..7f4f1fbb2 --- /dev/null +++ b/Tests/Common/AotCompatibilityTests.cs @@ -0,0 +1,158 @@ +namespace Ecng.Tests.Common; + +using System.Text.Json.Serialization; + +using Ecng.Serialization; +using Ecng.Reflection; + +[TestClass] +public class AotCompatibilityTests : BaseTestClass +{ + public sealed record SampleOrder(int Quantity, decimal Price); + + private sealed class FactoryProduct(int value) + { + public int Value { get; } = value; + } + + [TestMethod] + public void ExplicitTypeNameCanResolveWithoutReflection() + { + const string name = "ecng.aot.sample-order"; + + Converter.RegisterTypeName(name); + + name.To().AssertEqual(typeof(SampleOrder)); + name.ToUpperInvariant().To().AssertEqual(typeof(SampleOrder)); + // Registering the same mapping twice is idempotent. + Converter.RegisterTypeName(name, typeof(SampleOrder)); + ThrowsExactly(() => Converter.RegisterTypeName(name, typeof(int))); + } + + [TestMethod] + public void ExplicitFactoryIsUsedBeforeActivator() + { + TypeHelper.RegisterFactory(args => new FactoryProduct((int)args[0])); + + var result = typeof(FactoryProduct).CreateInstance(17); + result.Value.AssertEqual(17); + + ThrowsExactly(() => + TypeHelper.RegisterFactory(_ => new FactoryProduct(0))); + } + + [TestMethod] + public void RegisteredClosedGenericTypeCanBeResolved() + { + TypeHelper.RegisterConstructedGenericType(typeof(Dictionary)); + + var result = typeof(Dictionary<,>).Make(typeof(string), typeof(SampleOrder)); + result.AssertEqual(typeof(Dictionary)); + + // Duplicate registration is harmless. + TypeHelper.RegisterConstructedGenericType(typeof(Dictionary)); + ThrowsExactly(() => TypeHelper.RegisterConstructedGenericType(typeof(Dictionary<,>))); + } + + private interface IDiscoveryProbe { } + private sealed class DiscoveryProbe : IDiscoveryProbe { } + + [TestMethod] + public void StaticallyRegisteredImplementationsRemainDiscoverable() + { + ReflectionHelper.RegisterImplementation(); + var implementations = typeof(DiscoveryProbe).Assembly + .FindImplementations(showNonPublic: true); + implementations.Contains(typeof(DiscoveryProbe)).AssertTrue(); + } + + private sealed class ArrayPayload + { + } + + private sealed class PrivateConstructor + { + private PrivateConstructor() { } + public int Value { get; private set; } + public static PrivateConstructor Create(int value) => new() { Value = value }; + } + +#pragma warning disable CS0618 // Verify the existing legacy persistence path. + private sealed class PersistedPayload : IPersistable + { + public int Value { get; private set; } + public void Load(SettingsStorage storage) => Value = storage.GetValue("value"); + public void Save(SettingsStorage storage) => storage.Set("value", Value); + } +#pragma warning restore CS0618 + + [TestMethod] + public void CommonArrayTypesHaveStaticNativeFactories() + { + (typeof(int).CreateArray(4) is int[] { Length: 4 }).AssertTrue(); + (typeof(decimal).CreateArray(2) is decimal[] { Length: 2 }).AssertTrue(); + (typeof(Guid).CreateArray(1) is Guid[] { Length: 1 }).AssertTrue(); + } + + [TestMethod] + public void RegisteredArrayFactoryIsUsedByExistingCreateArray() + { + var callCount = 0; + ArrayHelper.RegisterArrayFactory(length => + { + callCount++; + return new ArrayPayload[length]; + }); + + var result = typeof(ArrayPayload).CreateArray(3); + (result is ArrayPayload[]).AssertTrue(); + result.Length.AssertEqual(3); + callCount.AssertEqual(1); + } + + [TestMethod] + public void ExistingFastActivatorSupportsExplicitFactories() + { + FastActivator.RegisterFactory(() => PrivateConstructor.Create(42)); + FastActivator.CreateObject().Value.AssertEqual(42); + } + + [TestMethod] + public void ExistingSettingsStorageUsesRegisteredFactory() + { + TypeHelper.RegisterFactory(_ => new PersistedPayload()); + + var nested = new SettingsStorage().Set("value", 42); + var storage = new SettingsStorage().Set("payload", nested); + + storage.GetValue("payload").Value.AssertEqual(42); + } + +#if NET7_0_OR_GREATER + [TestMethod] + public void SourceGeneratedNonGenericJsonRoundTrip() + { + var original = new SampleOrder(4, 112.50m); + System.Text.Json.Serialization.Metadata.JsonTypeInfo metadata = TestAotJsonContext.Default.SampleOrder; + var json = ((object)original).ToJson(metadata); + var restored = (SampleOrder)json.FromJson(metadata); + restored.AssertEqual(original); + } + +#endif + + [TestMethod] + public void SourceGeneratedJsonRoundTrip() + { + var order = new SampleOrder(5, 101.25m); + var json = order.ToJson(TestAotJsonContext.Default.SampleOrder); + var restored = json.FromJson(TestAotJsonContext.Default.SampleOrder); + + restored.AssertEqual(order); + } +} + +[JsonSerializable(typeof(AotCompatibilityTests.SampleOrder))] +internal partial class TestAotJsonContext : JsonSerializerContext +{ +} diff --git a/Tests/Serialization/JsonConvertersTests.cs b/Tests/Serialization/JsonConvertersTests.cs index 32ab3143b..4b0f69030 100644 --- a/Tests/Serialization/JsonConvertersTests.cs +++ b/Tests/Serialization/JsonConvertersTests.cs @@ -8,6 +8,38 @@ namespace Ecng.Tests.Serialization; [TestClass] public class JsonConvertersTests : BaseTestClass { + private sealed class AotPriceRow + { + public decimal Price; + public decimal Size; + } + + [TestMethod] + public void RegisteredAotJsonArrayFieldsReadAndWriteInWireOrder() + { + JArrayToObjectConverter.RegisterAotFields( + (typeof(decimal), row => row.Price, (row, v) => row.Price = (decimal)v), + (typeof(decimal), row => row.Size, (row, v) => row.Size = (decimal)v)); + + var converter = new JArrayToObjectConverter(); + using var reader = new JsonTextReader(new StringReader("[101.5,3.25]")); + reader.Read(); + + var row = (AotPriceRow)converter.ReadJson(reader, typeof(AotPriceRow), null, JsonSerializer.CreateDefault()); + row.Price.AssertEqual(101.5m); + row.Size.AssertEqual(3.25m); + + using var writerOutput = new StringWriter(); + using (var writer = new JsonTextWriter(writerOutput)) + { + converter.WriteJson(writer, row, JsonSerializer.CreateDefault()); + writer.Flush(); + } + + writerOutput.ToString().AssertEqual("[101.5,3.25]"); + } + + [TestMethod] public void BoolConverter_ReadWrite() { diff --git a/common.props b/common.props index 7319444fe..bc052aa32 100644 --- a/common.props +++ b/common.props @@ -67,6 +67,14 @@ $(StockSharpTargets) + + + true + true + true + + portable