返回提交历史
Modified
AutoConfig.Analyzer.Test/UserProfile.cs
+0
-2
Modified
README.md
+73
-4
Modified
README.zh-CN.md
+73
-4
Modified
XFEExtension.NetCore.AutoConfig.Analyzer/Generator/ProfilePropertyAutoGenerator.cs
+27
-5
Added
XFEExtension.NetCore.AutoConfig.Tests/AdvancedPersistenceTests.cs
+212
-0
Added
XFEExtension.NetCore.AutoConfig.Tests/AdvancedProfiles.cs
+101
-0
Modified
XFEExtension.NetCore.AutoConfig.Tests/ConcurrentProfile.cs
+0
-4
Modified
XFEExtension.NetCore.AutoConfig.Tests/GeneratorAndCodeFixTests.cs
+1
-1
Added
XFEExtension.NetCore.AutoConfig/IProfileOwnedCollection.cs
+11
-0
Modified
XFEExtension.NetCore.AutoConfig/ProfileDictionary.cs
+1
-1
Modified
XFEExtension.NetCore.AutoConfig/ProfileList.cs
+1
-1
Added
XFEExtension.NetCore.AutoConfig/ProfileMigration.cs
+288
-0
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
XFEstudio/XFEExtension.NetCore.AutoConfig
AutoConfig: 支持配置结构版本迁移与多实例存储
本次更新引入配置结构版本元数据、逐版本迁移、字段重命名和候选配置验证等高级功能,提升配置文件演进与兼容性。支持每实例自定义 JSON 选项,新增多实例/多租户存储(ProfileStore),完善集合自动绑定。生成器与 API 优化,声明 AOT/裁剪兼容性,补充文档与测试。统一路径拼接,优化代码结构,增强类型安全与可维护性。
aff7233
代码差异
17 个文件
+1127
-58
@@ -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
}
@@ -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
@@ -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
@@ -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)
@@ -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
}
@@ -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
}
@@ -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()
@@ -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]
@@ -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
}
@@ -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();
@@ -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();
@@ -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
}