XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEExtension.NetCore.AutoConfig

【DLL】自动实现配置文件的存储

公开
关注 0 Fork 0 Star 0
返回提交历史

XFEstudio/XFEExtension.NetCore.AutoConfig

AutoConfig: 支持配置结构版本迁移与多实例存储

本次更新引入配置结构版本元数据、逐版本迁移、字段重命名和候选配置验证等高级功能,提升配置文件演进与兼容性。支持每实例自定义 JSON 选项,新增多实例/多租户存储(ProfileStore),完善集合自动绑定。生成器与 API 优化,声明 AOT/裁剪兼容性,补充文档与测试。统一路径拼接,优化代码结构,增强类型安全与可维护性。

aff7233
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

17 个文件 +1127 -58
Modified AutoConfig.Analyzer.Test/UserProfile.cs +0 -2
@@ -11,7 +11,5 @@ internal partial class UserProfile : XFEProfile
11 11 /// 用户信息列表
12 12 /// </summary>
13 13 [ProfileProperty]
14 [ProfilePropertyAddGet("Current.userInfoList.CurrentProfile = Current")]
15 [ProfilePropertyAddGet("return Current.userInfoList")]
16 14 private ProfileList<UserInfo> userInfoList = [];
17 15 }
Modified README.md +73 -4
@@ -94,6 +94,62 @@ partial class SystemProfile : XFEProfile
94 94 }
95 95 ```
96 96
97 ### Schema Versions, Migrations, and Validation
98
99 Override `ProfileSchemaVersion` and register every `N -> N + 1` step in `ConfigureMigrations`. Files without metadata are version `0`. Built-in XFE dictionary, JSON, and XML modes persist version metadata automatically; `RenameProperty` handles generated `InstanceXxx` JSON/XML members as well as XFE dictionary keys.
100
101 ```csharp
102 [AutoLoadProfile]
103 partial class SystemProfile : XFEProfile
104 {
105 [ProfileProperty("DisplayName")]
106 string displayName = "Guest";
107
108 protected override int ProfileSchemaVersion => 2;
109
110 protected override void ConfigureMigrations(ProfileMigrationBuilder migrations) => migrations
111 .RenameProperty(0, "Name", "DisplayName")
112 .Transform(1, context => UpgradeStructure(context.Content));
113
114 protected override ProfileValidationResult ValidateProfile() =>
115 string.IsNullOrWhiteSpace(InstanceDisplayName)
116 ? ProfileValidationResult.Failure("DisplayName is required")
117 : ProfileValidationResult.Success;
118 }
119 ```
120
121 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.
122
123 ### JSON Options and Converters
124
125 Each profile owns a `JsonOptions` instance used by JSON mode and by property values in XFE dictionary mode. Configure it in the constructor:
126
127 ```csharp
128 public SystemProfile()
129 {
130 DefaultProfileOperationMode = ProfileOperationMode.Json;
131 JsonOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
132 JsonOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
133 }
134 ```
135
136 The same options support custom converters, number handling, case-insensitive reads, and a source-generated `JsonTypeInfoResolver`.
137
138 ### Multiple Instances and Tenants
139
140 The generated static API remains the simplest singleton API. Use `ProfileStore<TProfile>` when one configuration type needs independent files or tenants. Its path is a complete file path including the extension:
141
142 ```csharp
143 var tenantA = new ProfileStore<SystemProfile>("profiles/tenant-a.json");
144 var tenantB = new ProfileStore<SystemProfile>("profiles/tenant-b.json");
145
146 tenantA.Update(profile => profile.InstanceDisplayName = "Tenant A");
147 tenantB.Update(profile => profile.InstanceDisplayName = "Tenant B");
148 await Task.WhenAll(tenantA.SaveAsync(), tenantB.SaveAsync());
149 ```
150
151 `Load`, `Save`, `SaveAsync`, `FlushAsync`, `Delete`, `Export`, and `Import` operate only on that store. `Update` and `Read` synchronize instance access, and collections are automatically bound to the correct owning instance.
152
97 153 ### Custom Storage Path and File Extension
98 154
99 155 Use the generated static properties `ProfilePath` and `ProfileExtension` to control where the file is stored:
@@ -187,18 +243,16 @@ partial class SystemProfile : XFEProfile
187 243
188 244 `ProfileList<T>` and `ProfileDictionary<TKey, TValue>` 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:
189 245
246 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.
247
190 248 ```csharp
191 249 [AutoLoadProfile]
192 250 partial class SystemProfile : XFEProfile
193 251 {
194 252 [ProfileProperty]
195 [ProfilePropertyAddGet("Current.nameList.CurrentProfile = Current")]
196 [ProfilePropertyAddGet("return Current.nameList")]
197 253 ProfileList<string> nameList = [];
198 254
199 255 [ProfileProperty]
200 [ProfilePropertyAddGet("Current.nameIdDictionary.CurrentProfile = Current")]
201 [ProfilePropertyAddGet("return Current.nameIdDictionary")]
202 256 ProfileDictionary<string, long> nameIdDictionary = [];
203 257 }
204 258
@@ -324,6 +378,16 @@ string exported = SystemProfile.ExportProfile(); // Export config as a strin
324 378 SystemProfile.ImportProfile(exported); // Import config from a string
325 379 ```
326 380
381 ### Native AOT and Trimming Policy
382
383 This package currently does **not** claim general Native AOT or trimming compatibility (`IsAotCompatible=false`, `IsTrimmable=false`):
384
385 - XML mode uses reflection-based `XmlSerializer` and has no complete Native AOT guarantee.
386 - 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.
387 - 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`.
388
389 This explicit policy prevents the sample or package metadata from implying an AOT guarantee that the selected serializer cannot provide.
390
327 391 ---
328 392
329 393 ## API Reference
@@ -380,6 +444,11 @@ For every `partial` class that inherits `XFEProfile` and uses `[ProfileProperty]
380 444 | `LastSaveException` | `Exception?` | Most recent background save failure |
381 445 | `LastLoadException` | `Exception?` | Most recent load failure for this profile |
382 446 | `ProfileLoadFailed` | `static event` | Raised after a failed load and corrupt-file preservation attempt |
447 | `ProfileSchemaVersion` | protected `int` | Current schema version written to built-in formats |
448 | `LoadedProfileVersion` | `int` | Source version from the last successful load/import |
449 | `JsonOptions` | `JsonSerializerOptions` | Per-instance JSON naming, converter, and metadata options |
450 | `ConfigureMigrations(...)` | virtual hook | Registers sequential schema migrations |
451 | `ValidateProfile()` | virtual hook | Validates a candidate before it replaces the current profile |
383 452
384 453 ---
385 454
Modified README.zh-CN.md +73 -4
@@ -94,6 +94,62 @@ partial class SystemProfile : XFEProfile
94 94 }
95 95 ```
96 96
97 ### 配置版本、迁移与验证
98
99 重写 `ProfileSchemaVersion`,并在 `ConfigureMigrations` 中注册每个 `N -> N + 1` 步骤。没有版本元数据的旧文件视为版本 `0`。XFE 字典、JSON、XML 三种内置格式会自动保存版本;`RenameProperty` 同时处理 XFE 字典键和 JSON/XML 中生成的 `InstanceXxx` 成员。
100
101 ```csharp
102 [AutoLoadProfile]
103 partial class SystemProfile : XFEProfile
104 {
105 [ProfileProperty("DisplayName")]
106 string displayName = "访客";
107
108 protected override int ProfileSchemaVersion => 2;
109
110 protected override void ConfigureMigrations(ProfileMigrationBuilder migrations) => migrations
111 .RenameProperty(0, "Name", "DisplayName")
112 .Transform(1, context => UpgradeStructure(context.Content));
113
114 protected override ProfileValidationResult ValidateProfile() =>
115 string.IsNullOrWhiteSpace(InstanceDisplayName)
116 ? ProfileValidationResult.Failure("DisplayName 不能为空")
117 : ProfileValidationResult.Success;
118 }
119 ```
120
121 加载过程是事务式的:先创建候选实例,再迁移、反序列化和验证,全部成功后才替换 `Current`。验证或迁移失败会记录在 `LastLoadException` 并触发 `ProfileLoadFailed`,原文件和当前实例均保持不变;成功迁移后会请求一次当前版本的自动保存。`Custom` 模式可重写 `ReadCustomProfileVersion` 和 `WriteCustomProfileVersion` 定义自己的版本元数据。
122
123 ### JSON 选项和转换器
124
125 每个配置实例都有独立的 `JsonOptions`,JSON 模式和 XFE 字典中各属性值的 JSON 序列化都会使用它。可在构造函数中配置:
126
127 ```csharp
128 public SystemProfile()
129 {
130 DefaultProfileOperationMode = ProfileOperationMode.Json;
131 JsonOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
132 JsonOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
133 }
134 ```
135
136 同一入口还支持自定义 converter、数字处理、大小写不敏感读取以及源生成的 `JsonTypeInfoResolver`。
137
138 ### 多实例与多租户
139
140 生成的静态 API 仍适合单例配置。同一配置类型需要对应多个租户或文件时,使用 `ProfileStore<TProfile>`;传入的路径是包含扩展名的完整文件路径:
141
142 ```csharp
143 var tenantA = new ProfileStore<SystemProfile>("profiles/tenant-a.json");
144 var tenantB = new ProfileStore<SystemProfile>("profiles/tenant-b.json");
145
146 tenantA.Update(profile => profile.InstanceDisplayName = "租户 A");
147 tenantB.Update(profile => profile.InstanceDisplayName = "租户 B");
148 await Task.WhenAll(tenantA.SaveAsync(), tenantB.SaveAsync());
149 ```
150
151 `Load`、`Save`、`SaveAsync`、`FlushAsync`、`Delete`、`Export`、`Import` 只作用于对应存储;`Update` 和 `Read` 会同步实例访问,集合也会自动绑定到正确实例。
152
97 153 ### 自定义存储路径和文件扩展名
98 154
99 155 通过静态属性 `ProfilePath` 和 `ProfileExtension` 自定义存储位置:
@@ -180,18 +236,16 @@ partial class SystemProfile : XFEProfile
180 236
181 237 `ProfileList<T>` 和 `ProfileDictionary<TKey, TValue>` 在集合发生变更(添加、删除、清空、索引赋值等操作)时会请求合并自动保存;其公开操作和快照枚举均可安全地并发使用:
182 238
239 生成器会在初始化、反序列化和重新赋值时自动绑定集合所属的配置实例,不再需要访问器代码特性或手工设置 `CurrentProfile`。
240
183 241 ```csharp
184 242 [AutoLoadProfile]
185 243 partial class SystemProfile : XFEProfile
186 244 {
187 245 [ProfileProperty]
188 [ProfilePropertyAddGet("Current.nameList.CurrentProfile = Current")]
189 [ProfilePropertyAddGet("return Current.nameList")]
190 246 ProfileList<string> nameList = [];
191 247
192 248 [ProfileProperty]
193 [ProfilePropertyAddGet("Current.nameIdDictionary.CurrentProfile = Current")]
194 [ProfilePropertyAddGet("return Current.nameIdDictionary")]
195 249 ProfileDictionary<string, long> nameIdDictionary = [];
196 250 }
197 251
@@ -317,6 +371,16 @@ string exported = SystemProfile.ExportProfile(); // 将当前配置导出为
317 371 SystemProfile.ImportProfile(exported); // 从字符串导入配置
318 372 ```
319 373
374 ### Native AOT 与裁剪策略
375
376 当前包**不声明**通用 Native AOT 或裁剪兼容(`IsAotCompatible=false`、`IsTrimmable=false`):
377
378 - XML 模式使用基于反射的 `XmlSerializer`,不提供完整 Native AOT 保证。
379 - 默认 JSON 和 XFE 字典操作使用运行时 `Type` 重载,并已标注 `RequiresDynamicCode` / `RequiresUnreferencedCode`。AOT 场景应把包含配置类型及全部属性类型的源生成 `JsonSerializerContext` 设置给 `JsonOptions.TypeInfoResolver`。
380 - 严格 AOT 部署应选择具备完整源生成元数据的 JSON,或在 `Custom` 模式中使用 AOT 安全的序列化器,并对实际应用执行 `dotnet publish -p:PublishAot=true` 验证。
381
382 这项策略避免示例或包元数据暗示所选序列化路径具备尚未实现的 AOT 保证。
383
320 384 ---
321 385
322 386 ## API 参考
@@ -373,6 +437,11 @@ SystemProfile.ImportProfile(exported); // 从字符串导入配置
373 437 | `LastSaveException` | `Exception?` | 最近一次后台保存异常 |
374 438 | `LastLoadException` | `Exception?` | 当前配置最近一次加载异常 |
375 439 | `ProfileLoadFailed` | `static event` | 加载失败并尝试保留损坏文件后触发 |
440 | `ProfileSchemaVersion` | protected `int` | 写入内置格式的当前结构版本 |
441 | `LoadedProfileVersion` | `int` | 最近成功加载/导入时的源版本 |
442 | `JsonOptions` | `JsonSerializerOptions` | 每实例 JSON 命名、converter 和类型元数据选项 |
443 | `ConfigureMigrations(...)` | 虚方法钩子 | 注册连续的结构迁移步骤 |
444 | `ValidateProfile()` | 虚方法钩子 | 候选配置替换当前实例前执行验证 |
376 445
377 446 ---
378 447
Modified XFEExtension.NetCore.AutoConfig.Analyzer/Generator/ProfilePropertyAutoGenerator.cs +27 -5
@@ -20,6 +20,7 @@ public sealed class ProfilePropertyAutoGenerator : IIncrementalGenerator
20 20 private const string ProfilePropertyAddSetAttributeName = "XFEExtension.NetCore.AutoConfig.ProfilePropertyAddSetAttribute";
21 21 private const string AutoLoadProfileAttributeName = "XFEExtension.NetCore.AutoConfig.AutoLoadProfileAttribute";
22 22 private const string ProfilePathAttributeName = "XFEExtension.NetCore.AutoConfig.ProfilePathAttribute";
23 private const string ProfileOwnedCollectionTypeName = "XFEExtension.NetCore.AutoConfig.IProfileOwnedCollection";
23 24
24 25 private static readonly SymbolDisplayFormat DeclarationTypeDisplayFormat = SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions(SymbolDisplayFormat.FullyQualifiedFormat.MiscellaneousOptions | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier);
25 26 private static readonly SymbolDisplayFormat RuntimeTypeDisplayFormat = SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers);
@@ -137,7 +138,7 @@ public sealed class ProfilePropertyAutoGenerator : IIncrementalGenerator
137 138
138 139 var reservedMembers = new[]
139 140 {
140 "__profileInstanceSyncRoot", "__current", "__profilePath", "__profileExtension", "__UpdateCurrentProfilePath",
141 "__profileInstanceSyncRoot", "__current", "__profilePath", "__profileExtension", "__UpdateCurrentProfilePath", "__BindProfileOwnedCollections",
141 142 "Current", "ProfilePath", "ProfileExtension", "Initialize", "LoadProfile", "SaveProfile", "SaveProfileAsync",
142 143 "FlushAsync", "DeleteProfile", "ExportProfile", "ImportProfile"
143 144 };
@@ -248,7 +249,7 @@ public sealed class ProfilePropertyAutoGenerator : IIncrementalGenerator
248 249 builder.AppendLine(" __current.Initialize();");
249 250 if (autoLoad)
250 251 {
251 builder.AppendLine(" if (__current.InstanceLoadProfile() is " + typeName + " loadedProfile)");
252 builder.AppendLine(" if (__current.InstanceLoadProfile(static () => new " + typeName + "()) is " + typeName + " loadedProfile)");
252 253 builder.AppendLine(" __current = loadedProfile;");
253 254 }
254 255 builder.AppendLine(" }");
@@ -293,6 +294,19 @@ public sealed class ProfilePropertyAutoGenerator : IIncrementalGenerator
293 294 builder.Append(" this.PropertySetFuncDictionary[nameof(").Append(propertyName).Append(")] = value => this.").Append(fieldName).Append(" = (").Append(declarationTypeDisplay).AppendLine(")value!;");
294 295 builder.Append(" this.PropertyGetFuncDictionary[nameof(").Append(propertyName).Append(")] = () => this.").Append(fieldName).AppendLine(";");
295 296 }
297 builder.AppendLine(" this.__BindProfileOwnedCollections();");
298 builder.AppendLine(" }");
299 builder.AppendLine();
300 builder.AppendLine(" private void __BindProfileOwnedCollections()");
301 builder.AppendLine(" {");
302 foreach (var field in fields)
303 {
304 if (!IsProfileOwnedCollection(field.Field.Type))
305 continue;
306 var fieldName = EscapeIdentifier(field.Field.Name);
307 builder.Append(" if ((object?)this.").Append(fieldName).Append(" is global::XFEExtension.NetCore.AutoConfig.IProfileOwnedCollection owned_").Append(field.Field.Name).AppendLine(")");
308 builder.Append(" owned_").Append(field.Field.Name).AppendLine(".CurrentProfile = this;");
309 }
296 310 builder.AppendLine(" }");
297 311 builder.AppendLine();
298 312 AppendProfileOperations(builder, typeName);
@@ -325,7 +339,7 @@ public sealed class ProfilePropertyAutoGenerator : IIncrementalGenerator
325 339 builder.AppendLine(" public static void LoadProfile()");
326 340 builder.AppendLine(" {");
327 341 builder.AppendLine(" lock (__profileInstanceSyncRoot)");
328 builder.AppendLine(" if (__current.InstanceLoadProfile() is " + typeName + " loadedProfile) __current = loadedProfile;");
342 builder.AppendLine(" if (__current.InstanceLoadProfile(static () => new " + typeName + "()) is " + typeName + " loadedProfile) __current = loadedProfile;");
329 343 builder.AppendLine(" }");
330 344 builder.AppendLine();
331 345 builder.AppendLine(" public static void SaveProfile()");
@@ -361,7 +375,7 @@ public sealed class ProfilePropertyAutoGenerator : IIncrementalGenerator
361 375 builder.AppendLine(" {");
362 376 builder.AppendLine(" if (profileString is null) throw new global::System.ArgumentNullException(nameof(profileString));");
363 377 builder.AppendLine(" lock (__profileInstanceSyncRoot)");
364 builder.AppendLine(" if (__current.InstanceImportProfile(profileString) is " + typeName + " importedProfile) __current = importedProfile;");
378 builder.AppendLine(" if (__current.InstanceImportProfile(profileString, static () => new " + typeName + "()) is " + typeName + " importedProfile) __current = importedProfile;");
365 379 builder.AppendLine(" }");
366 380 builder.AppendLine();
367 381 }
@@ -403,6 +417,8 @@ public sealed class ProfilePropertyAutoGenerator : IIncrementalGenerator
403 417 builder.Append(" __current.").Append(fieldName).AppendLine(" = value;");
404 418 else
405 419 AppendStatements(builder, field.SetStatements, 16);
420 if (IsProfileOwnedCollection(field.Field.Type))
421 builder.AppendLine(" __current.__BindProfileOwnedCollections();");
406 422 builder.AppendLine(" __current.InstanceRequestSaveProfile();");
407 423 builder.AppendLine(" }");
408 424 builder.AppendLine(" }");
@@ -412,7 +428,10 @@ public sealed class ProfilePropertyAutoGenerator : IIncrementalGenerator
412 428 builder.Append(" public ").Append(typeDisplay).Append(' ').Append(instancePropertyName).AppendLine();
413 429 builder.AppendLine(" {");
414 430 builder.Append(" get { lock (ProfileSyncRoot) return this.").Append(fieldName).AppendLine("; }");
415 builder.Append(" set { lock (ProfileSyncRoot) this.").Append(fieldName).AppendLine(" = value; }");
431 if (IsProfileOwnedCollection(field.Field.Type))
432 builder.Append(" set { lock (ProfileSyncRoot) { this.").Append(fieldName).AppendLine(" = value; this.__BindProfileOwnedCollections(); } }");
433 else
434 builder.Append(" set { lock (ProfileSyncRoot) this.").Append(fieldName).AppendLine(" = value; }");
416 435 builder.AppendLine(" }");
417 436 builder.AppendLine();
418 437 }
@@ -485,6 +504,9 @@ public sealed class ProfilePropertyAutoGenerator : IIncrementalGenerator
485 504 return false;
486 505 }
487 506
507 private static bool IsProfileOwnedCollection(ITypeSymbol type) => type.ToDisplayString() == ProfileOwnedCollectionTypeName
508 || type is INamedTypeSymbol namedType && namedType.AllInterfaces.Any(interfaceType => interfaceType.ToDisplayString() == ProfileOwnedCollectionTypeName);
509
488 510 private static bool IsPartialHook(INamedTypeSymbol type, string name) => type.GetMembers(name).OfType<IMethodSymbol>().Any(method => method.DeclaringSyntaxReferences.Select(static reference => reference.GetSyntax()).OfType<MethodDeclarationSyntax>().Any(static method => method.Modifiers.Any(SyntaxKind.PartialKeyword)));
489 511
490 512 private static bool IsValidGeneratedIdentifier(string identifier) => !string.IsNullOrWhiteSpace(identifier)
Added XFEExtension.NetCore.AutoConfig.Tests/AdvancedPersistenceTests.cs +212 -0
@@ -0,0 +1,212 @@
1 using Xunit;
2
3 namespace XFEExtension.NetCore.AutoConfig.Tests;
4
5 public sealed class AdvancedPersistenceTests
6 {
7 [Fact]
8 public async Task VersionMigrationRenamesFieldAndPersistsCurrentVersion()
9 {
10 var directory = CreateTestDirectory();
11 try
12 {
13 MigratingJsonProfile.ProfilePath = Path.Combine(directory, "migration");
14 var path = MigratingJsonProfile.ProfilePath + ".json";
15 await File.WriteAllTextAsync(path, "{\"InstanceLegacyName\":\"migrated\"}");
16
17 MigratingJsonProfile.LoadProfile();
18
19 Assert.Equal("migrated", MigratingJsonProfile.DisplayName);
20 Assert.Equal(7, MigratingJsonProfile.Revision);
21 Assert.Equal(0, MigratingJsonProfile.Current.LoadedProfileVersion);
22 await MigratingJsonProfile.FlushAsync();
23 var persisted = await File.ReadAllTextAsync(path);
24 Assert.Contains("\"$xfeProfileVersion\":2", persisted);
25 Assert.Contains("InstanceDisplayName", persisted);
26 Assert.DoesNotContain("InstanceLegacyName", persisted);
27 }
28 finally
29 {
30 MigratingJsonProfile.DeleteProfile();
31 DeleteDirectory(directory);
32 }
33 }
34
35 [Fact]
36 public void ValidationFailureRejectsImportAndKeepsCurrentInstance()
37 {
38 MigratingJsonProfile.Current = new MigratingJsonProfile();
39 MigratingJsonProfile.Current.InstanceDisplayName = "accepted";
40 var original = MigratingJsonProfile.Current;
41
42 var exception = Assert.Throws<ProfileValidationException>(() =>
43 MigratingJsonProfile.ImportProfile("{\"$xfeProfileVersion\":2,\"InstanceDisplayName\":\"\"}"));
44
45 Assert.Same(original, MigratingJsonProfile.Current);
46 Assert.Equal("accepted", MigratingJsonProfile.DisplayName);
47 Assert.Contains("DisplayName", exception.ValidationError);
48 }
49
50 [Fact]
51 public async Task ValidationFailureOnLoadKeepsCurrentAndOriginalFile()
52 {
53 var directory = CreateTestDirectory();
54 ProfileLoadFailedEventArgs? observed = null;
55 EventHandler<ProfileLoadFailedEventArgs> handler = (_, args) => observed = args;
56 XFEProfile.ProfileLoadFailed += handler;
57 try
58 {
59 MigratingJsonProfile.Current = new MigratingJsonProfile();
60 MigratingJsonProfile.Current.InstanceDisplayName = "accepted";
61 MigratingJsonProfile.ProfilePath = Path.Combine(directory, "invalid");
62 var path = MigratingJsonProfile.ProfilePath + ".json";
63 await File.WriteAllTextAsync(path, "{\"$xfeProfileVersion\":2,\"InstanceDisplayName\":\"\",\"InstanceRevision\":7}");
64 var original = MigratingJsonProfile.Current;
65
66 MigratingJsonProfile.LoadProfile();
67
68 Assert.Same(original, MigratingJsonProfile.Current);
69 Assert.Equal("accepted", MigratingJsonProfile.DisplayName);
70 Assert.IsType<ProfileValidationException>(original.LastLoadException);
71 Assert.True(File.Exists(path));
72 Assert.NotNull(observed);
73 Assert.Null(observed!.BackupPath);
74 }
75 finally
76 {
77 XFEProfile.ProfileLoadFailed -= handler;
78 MigratingJsonProfile.DeleteProfile();
79 DeleteDirectory(directory);
80 }
81 }
82
83 [Fact]
84 public async Task MigrationFailureKeepsFileAndCurrentDefaults()
85 {
86 var directory = CreateTestDirectory();
87 try
88 {
89 MigratingJsonProfile.Current = new MigratingJsonProfile();
90 MigratingJsonProfile.ProfilePath = Path.Combine(directory, "future");
91 var path = MigratingJsonProfile.ProfilePath + ".json";
92 await File.WriteAllTextAsync(path, "{\"$xfeProfileVersion\":99,\"InstanceDisplayName\":\"future\"}");
93
94 MigratingJsonProfile.LoadProfile();
95
96 Assert.Equal("default", MigratingJsonProfile.DisplayName);
97 Assert.IsType<ProfileMigrationException>(MigratingJsonProfile.Current.LastLoadException);
98 Assert.True(File.Exists(path));
99 Assert.Empty(Directory.GetFiles(directory, "*.corrupt-*"));
100 }
101 finally
102 {
103 MigratingJsonProfile.DeleteProfile();
104 DeleteDirectory(directory);
105 }
106 }
107
108 [Fact]
109 public void AllBuiltInFormatsEmitVersionMetadata()
110 {
111 VersionedXfeProfile.Current.InstanceValue = "xfe";
112 VersionedXmlProfile.Current.InstanceValue = "xml";
113 MigratingJsonProfile.Current.InstanceDisplayName = "json";
114
115 var xfe = VersionedXfeProfile.ExportProfile();
116 var xml = VersionedXmlProfile.ExportProfile();
117 var json = MigratingJsonProfile.ExportProfile();
118 Assert.Contains("$xfeProfileVersion", xfe);
119 Assert.Contains("xfeProfileVersion=\"1\"", xml);
120 Assert.DoesNotContain("<ProfileSchemaVersion>", xml);
121 Assert.Contains("\"$xfeProfileVersion\":2", json);
122 Assert.DoesNotContain("\"ProfileSchemaVersion\"", json);
123
124 VersionedXfeProfile.Current.InstanceValue = "changed";
125 VersionedXmlProfile.Current.InstanceValue = "changed";
126 VersionedXfeProfile.ImportProfile(xfe);
127 VersionedXmlProfile.ImportProfile(xml);
128 Assert.Equal("xfe", VersionedXfeProfile.Value);
129 Assert.Equal("xml", VersionedXmlProfile.Value);
130 }
131
132 [Fact]
133 public void JsonOptionsApplyNamingPolicyAndConvertersOnRoundTrip()
134 {
135 JsonOptionsProfile.Current.InstanceTheme = ProfileTheme.Dark;
136
137 var content = JsonOptionsProfile.ExportProfile();
138 JsonOptionsProfile.Current.InstanceTheme = ProfileTheme.Light;
139 JsonOptionsProfile.ImportProfile(content);
140
141 Assert.Contains("\"instanceTheme\": \"dark\"", content);
142 Assert.Contains(Environment.NewLine, content);
143 Assert.Equal(ProfileTheme.Dark, JsonOptionsProfile.Theme);
144
145 JsonOptionsProfile.ImportProfile("{/* supported comment */\"instanceTheme\":\"light\",}");
146 Assert.Equal(ProfileTheme.Light, JsonOptionsProfile.Theme);
147 }
148
149 [Fact]
150 public async Task ProfileStoreMaintainsIndependentInstancesForSameType()
151 {
152 var directory = CreateTestDirectory();
153 try
154 {
155 var firstPath = Path.Combine(directory, "tenant-a.json");
156 var secondPath = Path.Combine(directory, "tenant-b.json");
157 var first = new ProfileStore<TenantProfile>(firstPath, autoLoad: false);
158 var second = new ProfileStore<TenantProfile>(secondPath, autoLoad: false);
159
160 first.Update(profile =>
161 {
162 profile.InstanceName = "A";
163 profile.InstanceValues.Add("one");
164 });
165 second.Update(profile =>
166 {
167 profile.InstanceName = "B";
168 profile.InstanceValues.Add("two");
169 });
170 await Task.WhenAll(first.SaveAsync(), second.SaveAsync());
171
172 var reloadedFirst = new ProfileStore<TenantProfile>(firstPath);
173 var reloadedSecond = new ProfileStore<TenantProfile>(secondPath);
174 Assert.NotSame(reloadedFirst.Current, reloadedSecond.Current);
175 Assert.Equal("A", reloadedFirst.Read(profile => profile.InstanceName));
176 Assert.Equal("B", reloadedSecond.Read(profile => profile.InstanceName));
177 Assert.Equal(["one"], reloadedFirst.Read(profile => profile.InstanceValues.ToArray()));
178 Assert.Equal(["two"], reloadedSecond.Read(profile => profile.InstanceValues.ToArray()));
179 Assert.Same(reloadedFirst.Current, reloadedFirst.Current.InstanceValues.CurrentProfile);
180 Assert.Same(reloadedSecond.Current, reloadedSecond.Current.InstanceValues.CurrentProfile);
181 }
182 finally
183 {
184 DeleteDirectory(directory);
185 }
186 }
187
188 [Fact]
189 public void GeneratorAutomaticallyBindsProfileCollections()
190 {
191 AutoBindingProfile.Current = new AutoBindingProfile();
192
193 Assert.Same(AutoBindingProfile.Current, AutoBindingProfile.Numbers.CurrentProfile);
194 Assert.Same(AutoBindingProfile.Current, AutoBindingProfile.Lookup.CurrentProfile);
195
196 AutoBindingProfile.Current.InstanceNumbers = [1, 2];
197 Assert.Same(AutoBindingProfile.Current, AutoBindingProfile.Numbers.CurrentProfile);
198 }
199
200 private static string CreateTestDirectory()
201 {
202 var directory = Path.Combine(Path.GetTempPath(), "XFEAutoConfigTests", Guid.NewGuid().ToString("N"));
203 Directory.CreateDirectory(directory);
204 return directory;
205 }
206
207 private static void DeleteDirectory(string directory)
208 {
209 if (Directory.Exists(directory))
210 Directory.Delete(directory, true);
211 }
212 }
Added XFEExtension.NetCore.AutoConfig.Tests/AdvancedProfiles.cs +101 -0
@@ -0,0 +1,101 @@
1 using System.Text.Json;
2 using System.Text.Json.Nodes;
3 using System.Text.Json.Serialization;
4
5 namespace XFEExtension.NetCore.AutoConfig.Tests;
6
7 [AutoLoadProfile(false)]
8 public partial class MigratingJsonProfile : XFEProfile
9 {
10 [ProfileProperty("DisplayName")]
11 private string displayName = "default";
12
13 [ProfileProperty]
14 private int revision;
15
16 public MigratingJsonProfile() => DefaultProfileOperationMode = ProfileOperationMode.Json;
17
18 protected override int ProfileSchemaVersion => 2;
19
20 protected override void ConfigureMigrations(ProfileMigrationBuilder migrations) => migrations
21 .RenameProperty(0, "LegacyName", "DisplayName")
22 .Transform(1, static context =>
23 {
24 var root = JsonNode.Parse(context.Content)?.AsObject() ?? throw new ProfileMigrationException("JSON 根节点无效");
25 root["InstanceRevision"] = 7;
26 return root.ToJsonString(context.JsonOptions);
27 });
28
29 protected override ProfileValidationResult ValidateProfile() => string.IsNullOrWhiteSpace(InstanceDisplayName)
30 ? ProfileValidationResult.Failure("DisplayName 不能为空")
31 : ProfileValidationResult.Success;
32 }
33
34 [AutoLoadProfile(false)]
35 public partial class VersionedXfeProfile : XFEProfile
36 {
37 [ProfileProperty]
38 private string value = "default";
39
40 protected override int ProfileSchemaVersion => 1;
41
42 protected override void ConfigureMigrations(ProfileMigrationBuilder migrations) => migrations.NoOp(0);
43 }
44
45 [AutoLoadProfile(false)]
46 public partial class VersionedXmlProfile : XFEProfile
47 {
48 [ProfileProperty]
49 private string value = "default";
50
51 public VersionedXmlProfile() => DefaultProfileOperationMode = ProfileOperationMode.Xml;
52
53 protected override int ProfileSchemaVersion => 1;
54
55 protected override void ConfigureMigrations(ProfileMigrationBuilder migrations) => migrations.NoOp(0);
56 }
57
58 public enum ProfileTheme
59 {
60 Light,
61 Dark
62 }
63
64 [AutoLoadProfile(false)]
65 public partial class JsonOptionsProfile : XFEProfile
66 {
67 [ProfileProperty]
68 private ProfileTheme theme;
69
70 public JsonOptionsProfile()
71 {
72 DefaultProfileOperationMode = ProfileOperationMode.Json;
73 JsonOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
74 JsonOptions.WriteIndented = true;
75 JsonOptions.AllowTrailingCommas = true;
76 JsonOptions.ReadCommentHandling = JsonCommentHandling.Skip;
77 JsonOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
78 }
79 }
80
81 [AutoLoadProfile(false)]
82 public partial class TenantProfile : XFEProfile
83 {
84 [ProfileProperty]
85 private string name = "default";
86
87 [ProfileProperty]
88 private ProfileList<string> values = [];
89
90 public TenantProfile() => DefaultProfileOperationMode = ProfileOperationMode.Json;
91 }
92
93 [AutoLoadProfile(false)]
94 public partial class AutoBindingProfile : XFEProfile
95 {
96 [ProfileProperty]
97 private ProfileList<int> numbers = [];
98
99 [ProfileProperty]
100 private ProfileDictionary<int, int> lookup = [];
101 }
Modified XFEExtension.NetCore.AutoConfig.Tests/ConcurrentProfile.cs +0 -4
@@ -10,13 +10,9 @@ public partial class ConcurrentProfile : XFEProfile
10 10 private int value;
11 11
12 12 [ProfileProperty]
13 [ProfilePropertyAddGet("Current.numbers.CurrentProfile = Current")]
14 [ProfilePropertyAddGet("return Current.numbers")]
15 13 private ProfileList<int> numbers = [];
16 14
17 15 [ProfileProperty]
18 [ProfilePropertyAddGet("Current.lookup.CurrentProfile = Current")]
19 [ProfilePropertyAddGet("return Current.lookup")]
20 16 private ProfileDictionary<int, int> lookup = [];
21 17
22 18 public ConcurrentProfile()
Modified XFEExtension.NetCore.AutoConfig.Tests/GeneratorAndCodeFixTests.cs +1 -1
@@ -65,7 +65,7 @@ public sealed class GeneratorAndCodeFixTests
65 65 var generatedSource = result.Results.Single().GeneratedSources.Single().SourceText.ToString().Replace("\r\n", "\n");
66 66 var hash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(generatedSource)));
67 67
68 Assert.Equal("158B3F9D56F5E8F8051B9249CC0E7600387F86649E16363526C21F0505A5EDE3", hash);
68 Assert.Equal("CB48140E88B75EA0AC24DFEE1F7332D29AAF7FDD277BC5F46F098FE80BCBE1C4", hash);
69 69 }
70 70
71 71 [Theory]
Added XFEExtension.NetCore.AutoConfig/IProfileOwnedCollection.cs +11 -0
@@ -0,0 +1,11 @@
1 namespace XFEExtension.NetCore.AutoConfig;
2
3 /// <summary>
4 /// 由配置实例拥有、在发生变更时能够请求自动保存的集合。
5 /// 生成器会在初始化和赋值时自动绑定所属配置实例。
6 /// </summary>
7 public interface IProfileOwnedCollection
8 {
9 /// <summary>拥有此集合的配置实例。</summary>
10 XFEProfile? CurrentProfile { get; set; }
11 }
Modified XFEExtension.NetCore.AutoConfig/ProfileDictionary.cs +1 -1
@@ -9,7 +9,7 @@ namespace XFEExtension.NetCore.AutoConfig;
9 9 /// </summary>
10 10 /// <typeparam name="TKey">字典Key泛型</typeparam>
11 11 /// <typeparam name="TValue">字典Value泛型</typeparam>
12 public class ProfileDictionary<TKey, TValue> : ICollection<KeyValuePair<TKey, TValue>>, IEnumerable<KeyValuePair<TKey, TValue>>, IEnumerable, IDictionary<TKey, TValue>, IReadOnlyCollection<KeyValuePair<TKey, TValue>>, IReadOnlyDictionary<TKey, TValue>, ICollection, IDictionary, IDeserializationCallback, ISerializable where TKey : notnull
12 public class ProfileDictionary<TKey, TValue> : ICollection<KeyValuePair<TKey, TValue>>, IEnumerable<KeyValuePair<TKey, TValue>>, IEnumerable, IDictionary<TKey, TValue>, IReadOnlyCollection<KeyValuePair<TKey, TValue>>, IReadOnlyDictionary<TKey, TValue>, ICollection, IDictionary, IDeserializationCallback, ISerializable, IProfileOwnedCollection where TKey : notnull
13 13 {
14 14 private readonly Dictionary<TKey, TValue> _innerDictionary;
15 15 private readonly object _syncRoot = new();
Modified XFEExtension.NetCore.AutoConfig/ProfileList.cs +1 -1
@@ -6,7 +6,7 @@ namespace XFEExtension.NetCore.AutoConfig;
6 6 /// 支持线程安全访问和合并自动保存的配置文件列表
7 7 /// </summary>
8 8 /// <typeparam name="TValue">列表泛型</typeparam>
9 public class ProfileList<TValue> : ICollection<TValue>, IEnumerable<TValue>, IEnumerable, IList<TValue>, IReadOnlyCollection<TValue>, IReadOnlyList<TValue>, ICollection, IList
9 public class ProfileList<TValue> : ICollection<TValue>, IEnumerable<TValue>, IEnumerable, IList<TValue>, IReadOnlyCollection<TValue>, IReadOnlyList<TValue>, ICollection, IList, IProfileOwnedCollection
10 10 {
11 11 private readonly List<TValue> _innerList;
12 12 private readonly object _syncRoot = new();
Added XFEExtension.NetCore.AutoConfig/ProfileMigration.cs +288 -0
@@ -0,0 +1,288 @@
1 using System.Text.Json;
2 using System.Text.Json.Nodes;
3 using System.Xml.Linq;
4 using XFEExtension.NetCore.FormatExtension;
5
6 namespace XFEExtension.NetCore.AutoConfig;
7
8 /// <summary>
9 /// 配置迁移步骤的上下文。
10 /// </summary>
11 public sealed class ProfileMigrationContext
12 {
13 internal ProfileMigrationContext(int fromVersion, ProfileOperationMode operationMode, string content, JsonSerializerOptions jsonOptions)
14 {
15 FromVersion = fromVersion;
16 OperationMode = operationMode;
17 Content = content;
18 JsonOptions = jsonOptions;
19 }
20
21 /// <summary>迁移前版本。</summary>
22 public int FromVersion { get; }
23
24 /// <summary>迁移后的版本。</summary>
25 public int ToVersion => FromVersion + 1;
26
27 /// <summary>当前存储模式。</summary>
28 public ProfileOperationMode OperationMode { get; }
29
30 /// <summary>当前迁移步骤收到的序列化内容。</summary>
31 public string Content { get; }
32
33 /// <summary>配置实例使用的 JSON 选项。</summary>
34 public JsonSerializerOptions JsonOptions { get; }
35 }
36
37 /// <summary>
38 /// 配置迁移注册器。每个步骤负责把版本 N 升级到 N + 1。
39 /// </summary>
40 public sealed class ProfileMigrationBuilder
41 {
42 private readonly Dictionary<int, List<Func<ProfileMigrationContext, string>>> migrations = [];
43
44 /// <summary>
45 /// 注册一个结构转换步骤。
46 /// </summary>
47 public ProfileMigrationBuilder Transform(int fromVersion, Func<ProfileMigrationContext, string> transform)
48 {
49 ArgumentNullException.ThrowIfNull(transform);
50 if (fromVersion < 0)
51 throw new ArgumentOutOfRangeException(nameof(fromVersion));
52 if (!migrations.TryGetValue(fromVersion, out var transforms))
53 {
54 transforms = [];
55 migrations.Add(fromVersion, transforms);
56 }
57 transforms.Add(transform);
58 return this;
59 }
60
61 /// <summary>
62 /// 注册一个无需修改内容的版本升级步骤。
63 /// </summary>
64 public ProfileMigrationBuilder NoOp(int fromVersion) => Transform(fromVersion, static context => context.Content);
65
66 /// <summary>
67 /// 注册跨内置格式的字段重命名步骤。JSON/XML 会同时识别生成的 InstanceXxx 成员名。
68 /// </summary>
69 public ProfileMigrationBuilder RenameProperty(int fromVersion, string oldName, string newName)
70 {
71 ArgumentException.ThrowIfNullOrWhiteSpace(oldName);
72 ArgumentException.ThrowIfNullOrWhiteSpace(newName);
73 return Transform(fromVersion, context => RenameProperty(context, oldName, newName));
74 }
75
76 internal ProfileMigrationResult Apply(int storedVersion, int targetVersion, ProfileOperationMode operationMode, string content, JsonSerializerOptions jsonOptions)
77 {
78 if (storedVersion < 0)
79 throw new ProfileMigrationException($"配置版本不能小于零:{storedVersion}");
80 if (storedVersion > targetVersion)
81 throw new ProfileMigrationException($"配置文件版本 {storedVersion} 高于当前支持版本 {targetVersion},不支持自动降级");
82
83 var currentContent = content;
84 for (var version = storedVersion; version < targetVersion; version++)
85 {
86 if (!migrations.TryGetValue(version, out var transforms) || transforms.Count == 0)
87 throw new ProfileMigrationException($"缺少从版本 {version} 到版本 {version + 1} 的迁移步骤");
88 foreach (var transform in transforms)
89 {
90 var context = new ProfileMigrationContext(version, operationMode, currentContent, jsonOptions);
91 currentContent = transform(context) ?? throw new ProfileMigrationException($"版本 {version} 的迁移步骤返回了 null");
92 }
93 }
94 return new ProfileMigrationResult(currentContent, storedVersion != targetVersion);
95 }
96
97 private static string RenameProperty(ProfileMigrationContext context, string oldName, string newName) => context.OperationMode switch
98 {
99 ProfileOperationMode.XFEDictionary => RenameXfeDictionaryProperty(context.Content, oldName, newName),
100 ProfileOperationMode.Json => RenameJsonProperty(context.Content, oldName, newName, context.JsonOptions),
101 ProfileOperationMode.Xml => RenameXmlProperty(context.Content, oldName, newName),
102 _ => throw new ProfileMigrationException("Custom 存储模式需要通过 Transform 注册自定义字段重命名逻辑")
103 };
104
105 private static string RenameXfeDictionaryProperty(string content, string oldName, string newName)
106 {
107 XFEDictionary source = content;
108 var hasOldName = source.Any(entry => entry.Header == oldName);
109 if (hasOldName && source.Any(entry => entry.Header == newName))
110 throw new ProfileMigrationException($"无法把 XFE 字典字段“{oldName}”重命名为“{newName}”:目标字段已存在");
111 var destination = new XFEDictionary();
112 foreach (var entry in source)
113 destination.Add(entry.Header == oldName ? newName : entry.Header, entry.Content);
114 return destination.ToString();
115 }
116
117 private static string RenameJsonProperty(string content, string oldName, string newName, JsonSerializerOptions jsonOptions)
118 {
119 if (JsonNode.Parse(content, documentOptions: CreateDocumentOptions(jsonOptions)) is not JsonObject root)
120 throw new ProfileMigrationException("JSON 配置根节点必须是对象才能执行字段重命名");
121 var candidates = new[]
122 {
123 (Old: ApplyNamingPolicy(oldName, jsonOptions.PropertyNamingPolicy), New: ApplyNamingPolicy(newName, jsonOptions.PropertyNamingPolicy)),
124 (Old: ApplyNamingPolicy("Instance" + oldName, jsonOptions.PropertyNamingPolicy), New: ApplyNamingPolicy("Instance" + newName, jsonOptions.PropertyNamingPolicy))
125 };
126 foreach (var candidate in candidates)
127 {
128 if (!root.TryGetPropertyValue(candidate.Old, out var value))
129 continue;
130 if (root.ContainsKey(candidate.New))
131 throw new ProfileMigrationException($"无法把 JSON 字段“{candidate.Old}”重命名为“{candidate.New}”:目标字段已存在");
132 root.Remove(candidate.Old);
133 root[candidate.New] = value;
134 break;
135 }
136 return root.ToJsonString(jsonOptions);
137 }
138
139 private static string RenameXmlProperty(string content, string oldName, string newName)
140 {
141 var document = XDocument.Parse(content, LoadOptions.PreserveWhitespace);
142 var root = document.Root ?? throw new ProfileMigrationException("XML 配置缺少根节点");
143 var oldNames = new[] { oldName, "Instance" + oldName };
144 var newNames = new[] { newName, "Instance" + newName };
145 for (var index = 0; index < oldNames.Length; index++)
146 {
147 var element = root.Elements().FirstOrDefault(candidate => candidate.Name.LocalName == oldNames[index]);
148 if (element is null)
149 continue;
150 if (root.Elements().Any(candidate => candidate.Name.LocalName == newNames[index]))
151 throw new ProfileMigrationException($"无法把 XML 字段“{oldNames[index]}”重命名为“{newNames[index]}”:目标字段已存在");
152 element.Name = element.Name.Namespace + newNames[index];
153 break;
154 }
155 return document.ToString(SaveOptions.DisableFormatting);
156 }
157
158 private static string ApplyNamingPolicy(string name, JsonNamingPolicy? namingPolicy) => namingPolicy?.ConvertName(name) ?? name;
159
160 private static JsonDocumentOptions CreateDocumentOptions(JsonSerializerOptions options) => new()
161 {
162 AllowTrailingCommas = options.AllowTrailingCommas,
163 CommentHandling = options.ReadCommentHandling == JsonCommentHandling.Skip ? JsonCommentHandling.Skip : JsonCommentHandling.Disallow,
164 MaxDepth = options.MaxDepth
165 };
166 }
167
168 /// <summary>
169 /// 配置迁移失败。
170 /// </summary>
171 public sealed class ProfileMigrationException : Exception
172 {
173 /// <summary>创建配置迁移异常。</summary>
174 public ProfileMigrationException(string message) : base(message) { }
175
176 /// <summary>创建带内部异常的配置迁移异常。</summary>
177 public ProfileMigrationException(string message, Exception innerException) : base(message, innerException) { }
178 }
179
180 internal readonly record struct ProfileMigrationResult(string Content, bool WasMigrated);
181
182 internal static class ProfileVersionMetadata
183 {
184 private const string VersionPropertyName = "$xfeProfileVersion";
185 private const string XmlVersionAttributeName = "xfeProfileVersion";
186
187 public static (int Version, string Content) ReadAndStrip(ProfileOperationMode operationMode, string content, JsonSerializerOptions jsonOptions) => operationMode switch
188 {
189 ProfileOperationMode.XFEDictionary => ReadAndStripXfeDictionary(content),
190 ProfileOperationMode.Json => ReadAndStripJson(content, jsonOptions),
191 ProfileOperationMode.Xml => ReadAndStripXml(content),
192 _ => (0, content)
193 };
194
195 public static string Write(ProfileOperationMode operationMode, string content, int version, JsonSerializerOptions jsonOptions)
196 {
197 if (version <= 0)
198 return content;
199 return operationMode switch
200 {
201 ProfileOperationMode.XFEDictionary => WriteXfeDictionary(content, version),
202 ProfileOperationMode.Json => WriteJson(content, version, jsonOptions),
203 ProfileOperationMode.Xml => WriteXml(content, version),
204 _ => content
205 };
206 }
207
208 private static (int Version, string Content) ReadAndStripXfeDictionary(string content)
209 {
210 XFEDictionary source = content;
211 var destination = new XFEDictionary();
212 var version = 0;
213 foreach (var entry in source)
214 {
215 if (entry.Header == VersionPropertyName)
216 {
217 if (!int.TryParse(entry.Content, out version))
218 throw new ProfileMigrationException($"无效的配置版本:{entry.Content}");
219 continue;
220 }
221 destination.Add(entry.Header, entry.Content);
222 }
223 return version == 0 && !source.Any(entry => entry.Header == VersionPropertyName)
224 ? (0, content)
225 : (version, destination.ToString());
226 }
227
228 private static string WriteXfeDictionary(string content, int version)
229 {
230 XFEDictionary source = content;
231 var destination = new XFEDictionary();
232 destination.Add(VersionPropertyName, version.ToString(System.Globalization.CultureInfo.InvariantCulture));
233 foreach (var entry in source)
234 if (entry.Header != VersionPropertyName)
235 destination.Add(entry.Header, entry.Content);
236 return destination.ToString();
237 }
238
239 private static (int Version, string Content) ReadAndStripJson(string content, JsonSerializerOptions jsonOptions)
240 {
241 if (JsonNode.Parse(content, documentOptions: CreateDocumentOptions(jsonOptions)) is not JsonObject root)
242 throw new ProfileMigrationException("JSON 配置根节点必须是对象");
243 var version = 0;
244 if (!root.TryGetPropertyValue(VersionPropertyName, out var versionNode))
245 return (0, content);
246 if (versionNode is null || !versionNode.AsValue().TryGetValue<int>(out version))
247 throw new ProfileMigrationException("JSON 配置版本不是有效整数");
248 root.Remove(VersionPropertyName);
249 return (version, root.ToJsonString(jsonOptions));
250 }
251
252 private static string WriteJson(string content, int version, JsonSerializerOptions jsonOptions)
253 {
254 if (JsonNode.Parse(content, documentOptions: CreateDocumentOptions(jsonOptions)) is not JsonObject root)
255 throw new ProfileMigrationException("JSON 配置根节点必须是对象");
256 root[VersionPropertyName] = version;
257 return root.ToJsonString(jsonOptions);
258 }
259
260 private static (int Version, string Content) ReadAndStripXml(string content)
261 {
262 var document = XDocument.Parse(content, LoadOptions.PreserveWhitespace);
263 var root = document.Root ?? throw new ProfileMigrationException("XML 配置缺少根节点");
264 var attribute = root.Attribute(XmlVersionAttributeName);
265 var version = 0;
266 if (attribute is null)
267 return (0, content);
268 if (!int.TryParse(attribute.Value, out version))
269 throw new ProfileMigrationException($"无效的 XML 配置版本:{attribute.Value}");
270 attribute.Remove();
271 return (version, document.ToString(SaveOptions.DisableFormatting));
272 }
273
274 private static string WriteXml(string content, int version)
275 {
276 var document = XDocument.Parse(content, LoadOptions.PreserveWhitespace);
277 var root = document.Root ?? throw new ProfileMigrationException("XML 配置缺少根节点");
278 root.SetAttributeValue(XmlVersionAttributeName, version);
279 return document.ToString(SaveOptions.DisableFormatting);
280 }
281
282 private static JsonDocumentOptions CreateDocumentOptions(JsonSerializerOptions options) => new()
283 {
284 AllowTrailingCommas = options.AllowTrailingCommas,
285 CommentHandling = options.ReadCommentHandling == JsonCommentHandling.Skip ? JsonCommentHandling.Skip : JsonCommentHandling.Disallow,
286 MaxDepth = options.MaxDepth
287 };
288 }
Added XFEExtension.NetCore.AutoConfig/ProfileStore.cs +127 -0
Added XFEExtension.NetCore.AutoConfig/ProfileValidation.cs +37 -0
Modified XFEExtension.NetCore.AutoConfig/XFEExtension.NetCore.AutoConfig.csproj +11 -10
Modified XFEExtension.NetCore.AutoConfig/XFEProfile.cs +128 -26
Modified eng/Test-Package.ps1 +36 -0