# XFEExtension.NetCore.AutoConfig [![NuGet](https://img.shields.io/nuget/v/XFEExtension.NetCore.AutoConfig?label=NuGet&logo=NuGet)](https://www.nuget.org/packages/XFEExtension.NetCore.AutoConfig/) [![NuGet Downloads](https://img.shields.io/nuget/dt/XFEExtension.NetCore.AutoConfig?label=Downloads&logo=NuGet)](https://www.nuget.org/packages/XFEExtension.NetCore.AutoConfig/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.txt) [![.NET](https://img.shields.io/badge/.NET-10.0-512BD4)](https://dotnet.microsoft.com/download) > 📖 English | [简体中文](https://github.com/XFEstudio/XFEExtension.NetCore.AutoConfig/blob/master/README.zh-CN.md) ## Description XFEExtension.NetCore.AutoConfig is a .NET library powered by Roslyn incremental source generators. It automatically generates static properties, load/save methods, and persistence logic for any `partial` class that inherits from `XFEProfile`, eliminating the need to write boilerplate configuration code. ## Getting Started ### Installation ```shell dotnet add package XFEExtension.NetCore.AutoConfig ``` ### Basic Usage Annotate fields with `[ProfileProperty]`. The source generator will create a corresponding static property that automatically saves whenever it is assigned: ```csharp // Define a profile class [AutoLoadProfile] partial class SystemProfile : XFEProfile { [ProfileProperty] string name = string.Empty; [ProfileProperty] int _age; } // Use the profile class Program { static void Main(string[] args) { SystemProfile.Name = "Test"; // Automatically saved on assignment Console.WriteLine(SystemProfile.Name); Console.WriteLine(SystemProfile.Age); // Restored from disk on next run } } ``` > **Note:** Profiles load automatically by default. Use `[AutoLoadProfile(false)]` to create and initialize `Current` without reading the file; it can then be loaded explicitly with `LoadProfile()`. > > Automatic saves are coalesced over a 100 ms window by default. A burst of assignments therefore produces one complete file write instead of one write per property. Call `SaveProfile()` when the current snapshot must be flushed immediately. ### .NET 10 Partial Properties Projects targeting .NET 10 or later with C# 14 can declare static partial properties for the generator to implement. The generated implementation preserves initializers and provides instance persistence storage, synchronization, collection binding, and automatic saving: ```csharp [AutoLoadProfile] partial class SystemProfile : XFEProfile { [ProfileProperty] public static partial string Name { get; set; } = string.Empty; [ProfileProperty] public static partial int Age { get; set; } } SystemProfile.Name = "Test"; Console.WriteLine(SystemProfile.Age); ``` Partial profile properties use the natural name `Xxx`, without an `Instance` prefix, retain the `SystemProfile.Xxx` access pattern, and persist under the same name. The generator also creates `InstanceXxx` as the instance bridge required by serializers; application code does not need to access it. The field syntax remains supported. In a .NET 10+ project using C# 14, analyzer diagnostic `XFE0003` suggests eligible fields and offers the “Convert to .NET 10 partial profile property” code fix. The fix preserves the initializer and existing `SystemProfile.Xxx` calls, updates field references and get/set hook strings, and retargets other field attributes with `field:`. The equivalent manual migration is: ```csharp // Before [ProfileProperty] string name = "Guest"; // .NET 10 / C# 14 [ProfileProperty] public static partial string Name { get; set; } = "Guest"; ``` --- ## Detailed Usage ### Automatic Save Performance and Concurrency Generated properties, `ProfileList`, and `ProfileDictionary` are synchronized for concurrent access. Automatic save requests are handled by one writer per profile and coalesced using `AutoSaveDelay`. Files are committed through a temporary file and atomic replacement, so readers never observe a partially written configuration. The coalescing window can be customized in the profile constructor: ```csharp public SystemProfile() { AutoSaveDelay = TimeSpan.FromMilliseconds(500); } ``` `Current.LastSaveException` exposes the most recent background save failure and is cleared after a successful save. Explicit `SaveProfile()` calls remain synchronous and surface write failures directly. `SaveProfileAsync(CancellationToken)` forces an asynchronous save, while `FlushAsync(CancellationToken)` waits for changes that have already requested automatic saving. Call one of them during application shutdown to avoid losing a pending background save. If an existing profile cannot be loaded, the original file is moved to a unique `.corrupt-*` backup and the initialized defaults remain usable. Inspect `Current.LastLoadException` or subscribe to the global `XFEProfile.ProfileLoadFailed` event for details. ### Changing the Storage Format Set `DefaultProfileOperationMode` inside the instance constructor to switch the storage format. The file extension is updated automatically: ```csharp [AutoLoadProfile] partial class SystemProfile : XFEProfile { [ProfileProperty] string name = string.Empty; [ProfileProperty] int _age; public SystemProfile() { DefaultProfileOperationMode = ProfileOperationMode.Xml; // Switch to XML; extension becomes .xml // Available modes: XFEDictionary (default), Json, Xml, MessagePack, Custom } } ``` ### Schema Versions, Migrations, and Validation Override `ProfileSchemaVersion` and register every `N -> N + 1` step in `ConfigureMigrations`. Files without metadata are version `0`. Built-in XFE dictionary, JSON, XML, and MessagePack modes persist version metadata automatically, and `RenameProperty` handles every built-in format. Use `TransformMessagePack` for custom MessagePack migrations; untouched properties remain binary and are not deserialized during migration. XML element names always use the profile property name (for example, ``) and never include the `Instance` prefix from the serializer bridge. ```csharp [AutoLoadProfile] partial class SystemProfile : XFEProfile { [ProfileProperty("DisplayName")] string displayName = "Guest"; protected override int ProfileSchemaVersion => 2; protected override void ConfigureMigrations(ProfileMigrationBuilder migrations) => migrations .RenameProperty(0, "Name", "DisplayName") .Transform(1, context => UpgradeStructure(context.Content)); protected override ProfileValidationResult ValidateProfile() => string.IsNullOrWhiteSpace(InstanceDisplayName) ? ProfileValidationResult.Failure("DisplayName is required") : ProfileValidationResult.Success; } ``` Loading is transactional: the library creates a candidate instance, migrates and validates it, and replaces `Current` only after all steps succeed. A validation or migration failure is available through `LastLoadException` and `ProfileLoadFailed`, while the original file and current instance are retained. Successfully migrated files request an automatic save in the current version. `Custom` storage can override `ReadCustomProfileVersion` and `WriteCustomProfileVersion` to define its metadata representation. ### JSON Options and Converters Each profile owns a `JsonOptions` instance used by JSON mode and by property values in XFE dictionary mode. Configure it in the constructor: ```csharp public SystemProfile() { DefaultProfileOperationMode = ProfileOperationMode.Json; JsonOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; JsonOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); } ``` The same options support custom converters, number handling, case-insensitive reads, and a source-generated `JsonTypeInfoResolver`. ### Large Object Storage Use the MessagePack binary mode for large nested objects. Its default contractless resolver supports ordinary objects with public properties without requiring MessagePack attributes, and files use the `.mpk` extension: ```csharp public SystemProfile() { DefaultProfileOperationMode = ProfileOperationMode.MessagePack; // Optional for large data with substantial repetition MessagePackOptions = MessagePackOptions.WithCompression( MessagePack.MessagePackCompression.Lz4BlockArray); } ``` File save/load stays binary throughout. For in-memory transfer of a large profile, prefer `ExportProfileBytes()` and `ImportProfileBytes(ReadOnlyMemory)` to avoid Base64 conversion. Existing `ExportProfile()` and `ImportProfile(string)` remain available in MessagePack mode and use Base64 strings. ### Multiple Instances and Tenants The generated static API remains the simplest singleton API. Use `ProfileStore` when one configuration type needs independent files or tenants. Its path is a complete file path including the extension: ```csharp var tenantA = new ProfileStore("profiles/tenant-a.json"); var tenantB = new ProfileStore("profiles/tenant-b.json"); tenantA.Update(profile => profile.InstanceDisplayName = "Tenant A"); tenantB.Update(profile => profile.InstanceDisplayName = "Tenant B"); await Task.WhenAll(tenantA.SaveAsync(), tenantB.SaveAsync()); ``` `Load`, `Save`, `SaveAsync`, `FlushAsync`, `Delete`, `Export`, `ExportBytes`, `Import`, and `ImportBytes` operate only on that store. `Update` and `Read` synchronize instance access, and collections are automatically bound to the correct owning instance. ### Custom Storage Path and File Extension Use the generated static properties `ProfilePath` and `ProfileExtension` to control where the file is stored: ```csharp [AutoLoadProfile] partial class SystemProfile : XFEProfile { [ProfileProperty] string name = string.Empty; [ProfileProperty] int _age; public SystemProfile() { ProfilePath = $"MyPath/MySubPath/{nameof(SystemProfile)}"; // Path without extension ProfileExtension = ".ini"; // Custom file extension } } ``` > `ProfilePath` and `ProfileExtension` are generated static properties and can also be set from outside the class: > ```csharp > SystemProfile.ProfilePath = "custom/path/SystemProfile"; > SystemProfile.ProfileExtension = ".cfg"; > ``` ### Using the `[ProfilePath]` Attribute ```csharp [AutoLoadProfile] [ProfilePath("MyPath/MySubPath/SystemProfile")] partial class SystemProfile : XFEProfile { [ProfileProperty] string name = string.Empty; [ProfileProperty] int _age; } ``` ### Custom Load and Save Operations Set `DefaultProfileOperationMode` to `Custom` and provide your own load/save delegates: ```csharp [AutoLoadProfile] partial class SystemProfile : XFEProfile { [ProfileProperty] string name = string.Empty; [ProfileProperty] int _age; public SystemProfile() { DefaultProfileOperationMode = ProfileOperationMode.Custom; ProfilePath = $"MyPath/MySubPath/{nameof(SystemProfile)}"; ProfileExtension = ".ini"; LoadOperation = MyCustomLoadProfileOperation; SaveOperation = MyCustomSaveProfileOperation; } // Custom load method public static XFEProfile? MyCustomLoadProfileOperation( XFEProfile profileInstance, string profileString, Dictionary propertyInfoDictionary, Dictionary propertySetFuncDictionary) { // Implement custom load logic here return null; } // Custom save method public static string MyCustomSaveProfileOperation( XFEProfile profileInstance, Dictionary propertyInfoDictionary, Dictionary propertyGetFuncDictionary) { // Implement custom save logic here return string.Empty; } } ``` ### Storing Collections with `ProfileList` and `ProfileDictionary` `ProfileList` and `ProfileDictionary` request a coalesced automatic save whenever the collection is modified (add, remove, clear, index assignment, etc.). Their public operations and enumeration snapshots are safe to use concurrently: The generator binds these collection types to their owning profile during initialization, deserialization, and assignment. No accessor-injection attributes or manual `CurrentProfile` assignment are required. ```csharp [AutoLoadProfile] partial class SystemProfile : XFEProfile { [ProfileProperty] ProfileList nameList = []; [ProfileProperty] ProfileDictionary nameIdDictionary = []; } class Program { static void Main(string[] args) { SystemProfile.NameList.Add("Alice"); // Auto-saved on add SystemProfile.NameList.AddRange(["Bob", "Carol"]); // Batch add SystemProfile.NameList.Remove("Bob"); // Auto-saved on remove SystemProfile.NameIdDictionary.Add("Alice", 100L); // Dictionary works the same way } } ``` ### Injecting Code into `get`/`set` Accessors Use `[ProfilePropertyAddGet]` and `[ProfilePropertyAddSet]` to insert code snippets directly into the generated property accessors: ```csharp [AutoLoadProfile] partial class SystemProfile : XFEProfile { [ProfileProperty] [ProfilePropertyAddGet(@"Console.WriteLine(""Getting Name"")")] [ProfilePropertyAddGet("return Current.name")] [ProfilePropertyAddSet(@"Console.WriteLine(""Setting Name"")")] [ProfilePropertyAddSet("Current.name = value")] string name = string.Empty; [ProfileProperty] [ProfilePropertyAddGet(@"Console.WriteLine(""Getting Age"")")] [ProfilePropertyAddGet("return Current._age")] [ProfilePropertyAddSet(@"Console.WriteLine(""Setting Age"")")] [ProfilePropertyAddSet("Current._age = value")] int _age; } ``` > **Note:** When using `[ProfilePropertyAddGet]`, you must handle the full `return` statement yourself in the last `get` snippet. ### Partial Method Hooks The generator creates `static partial void GetXxxProperty()` and `static partial void SetXxxProperty(ref T value)` for each property. Implement them in your own partial class to intercept reads and writes: ```csharp [AutoLoadProfile] partial class SystemProfile : XFEProfile { [ProfileProperty] string name = string.Empty; [ProfileProperty] int _age; static partial void GetNameProperty() { Console.WriteLine("Name was read"); } static partial void SetNameProperty(ref string value) { Console.WriteLine($"Name changing: {Name} -> {value}"); } static partial void GetAgeProperty() { Console.WriteLine("Age was read"); } static partial void SetAgeProperty(ref int value) { value = 1999; // Modify the value before it is stored Console.WriteLine($"Age forced to 1999"); } } ``` ### Default Field Values Assign values directly at the field declaration site: ```csharp [AutoLoadProfile] partial class SystemProfile : XFEProfile { [ProfileProperty] string name = "John Wick"; [ProfileProperty] int _age = 59; } ``` ### XML Documentation Comments XML doc comments placed on a field are automatically propagated to the generated static property: ```csharp [AutoLoadProfile] partial class SystemProfile : XFEProfile { /// /// The user's name. This comment is copied to the generated Name property. /// [ProfileProperty] string name = string.Empty; [ProfileProperty] int _age; } ``` ### Manual Load / Save / Delete / Export / Import The following static methods are generated for every profile class: ```csharp SystemProfile.LoadProfile(); // Load from file SystemProfile.SaveProfile(); // Save to file SystemProfile.DeleteProfile(); // Delete the config file string exported = SystemProfile.ExportProfile(); // Export config as a string SystemProfile.ImportProfile(exported); // Import config from a string ``` ### Native AOT and Trimming Policy This package currently does **not** claim general Native AOT or trimming compatibility (`IsAotCompatible=false`, `IsTrimmable=false`): - XML mode uses reflection-based `XmlSerializer` and has no complete Native AOT guarantee. - The default JSON and XFE dictionary operations use runtime `Type` overloads. They are annotated with `RequiresDynamicCode` / `RequiresUnreferencedCode`; for AOT, set `JsonOptions.TypeInfoResolver` to a source-generated `JsonSerializerContext` containing the profile and all property types. - For strict AOT deployments, prefer JSON with complete source-generated metadata or `Custom` mode with an AOT-safe serializer, and validate the actual application using `dotnet publish -p:PublishAot=true`. This explicit policy prevents the sample or package metadata from implying an AOT guarantee that the selected serializer cannot provide. --- ## API Reference ### Attributes | Attribute | Target | Description | |-----------|--------|-------------| | `[ProfileProperty]` | Field or partial property | Marks the member for code generation. Optionally specify a property name: `[ProfileProperty("CustomName")]` | | `[ProfilePropertyAddGet(code)]` | Field or partial property | Appends a code line to the generated `get` accessor. Supports multiple attributes. | | `[ProfilePropertyAddSet(code)]` | Field or partial property | Appends a code line to the generated `set` accessor. Supports multiple attributes. | | `[AutoLoadProfile(false)]` | Class | Keeps `Current` initialized but disables automatic file loading. | | `[ProfilePath(path)]` | Class | Sets the storage path for the config file. | ### Storage Modes (`ProfileOperationMode`) | Value | Extension | Description | |-------|-----------|-------------| | `XFEDictionary` (default) | `.xpf` | XFE dictionary format | | `Json` | `.json` | JSON serialization | | `Xml` | `.xml` | XML serialization | | `MessagePack` | `.mpk` | Binary serialization for large objects | | `Custom` | custom | User-provided load/save delegates | ### Auto-Generated Static Members For every `partial` class that inherits `XFEProfile` and uses `[ProfileProperty]`, the source generator produces: | Member | Kind | Description | |--------|------|-------------| | `Current` | `static T` | The singleton profile instance | | `ProfilePath` | `static string` | Storage path (without extension) | | `ProfileExtension` | `static string` | File extension (auto-detected when empty) | | `LoadProfile()` | `static void` | Loads config from file | | `SaveProfile()` | `static void` | Immediately saves config to file and waits for completion | | `SaveProfileAsync(CancellationToken)` | `static Task` | Immediately saves config asynchronously | | `FlushAsync(CancellationToken)` | `static Task` | Flushes an already requested automatic save | | `DeleteProfile()` | `static void` | Deletes the config file | | `ExportProfile()` | `static string` | Exports config as a string | | `ExportProfileBytes()` | `static byte[]` | Exports raw config bytes; preferred for MessagePack | | `ImportProfile(string)` | `static void` | Imports config from a string | | `ImportProfileBytes(ReadOnlyMemory)` | `static void` | Imports config directly from bytes | | `Xxx` (field or partial-property mode) | `static T` | Static configuration access point; saves on set | | `InstanceXxx` | `T` (instance) | Generated instance bridge used by serializers | | `GetXxxProperty()` | `static partial void` | Invoked when the property is read | | `SetXxxProperty(ref T)` | `static partial void` | Invoked when the property is written | ### `XFEProfile` Base Class Members | Member | Kind | Description | |--------|------|-------------| | `DefaultProfileOperationMode` | `ProfileOperationMode` | Load/save mode | | `LoadOperation` | `ProfileLoadOperation` | Custom load delegate | | `SaveOperation` | `ProfileSaveOperation` | Custom save delegate | | `ProfilesDefaultPath` | `static string` | Default root directory for all profile files | | `AutoSaveDelay` | `TimeSpan` | Coalescing window for automatic saves (100 ms by default) | | `LastSaveException` | `Exception?` | Most recent background save failure | | `LastLoadException` | `Exception?` | Most recent load failure for this profile | | `ProfileLoadFailed` | `static event` | Raised after a failed load and corrupt-file preservation attempt | | `ProfileSchemaVersion` | protected `int` | Current schema version written to built-in formats | | `LoadedProfileVersion` | `int` | Source version from the last successful load/import | | `JsonOptions` | `JsonSerializerOptions` | Per-instance JSON naming, converter, and metadata options | | `MessagePackOptions` | `MessagePackSerializerOptions` | Per-instance MessagePack resolver, security, and compression options | | `ConfigureMigrations(...)` | virtual hook | Registers sequential schema migrations | | `ValidateProfile()` | virtual hook | Validates a candidate before it replaces the current profile | --- ## License This project is licensed under the [MIT License](LICENSE.txt).