mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-07 00:39:00 +08:00
refactor(game): 收敛版本迁移链执行器
- 新增 internal 迁移执行器,统一 settings 与 save 的链式版本校验 - 修复 SettingsModel 重复注册、缺链回填与目标版本判定的迁移约束 - 补充 Persistence 与 SettingsModel 定向测试,并更新迁移文档和 ai-plan 跟踪
This commit is contained in:
parent
31ca8cc963
commit
88de1235ae
@ -188,6 +188,39 @@ public class PersistenceTests
|
||||
Assert.That(exception!.Message, Does.Contain("from version 2"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证迁移器声明的目标版本必须与返回数据上的实际版本一致,避免错误迁移结果被静默接受。
|
||||
/// </summary>
|
||||
/// <returns>表示异步测试完成的任务。</returns>
|
||||
/// <exception cref="InvalidOperationException">当迁移器返回的版本与声明目标版本不一致时抛出。</exception>
|
||||
[Test]
|
||||
public async Task SaveRepository_LoadAsync_Should_Throw_When_Migration_Result_Version_Does_Not_Match_Declaration()
|
||||
{
|
||||
var root = CreateTempRoot();
|
||||
using var storage = new FileStorage(root, new JsonSerializer());
|
||||
var config = new SaveConfiguration
|
||||
{
|
||||
SaveRoot = "saves",
|
||||
SaveSlotPrefix = "slot_",
|
||||
SaveFileName = "save"
|
||||
};
|
||||
|
||||
var writer = new SaveRepository<TestVersionedSaveData>(storage, config);
|
||||
await writer.SaveAsync(1, new TestVersionedSaveData
|
||||
{
|
||||
Name = "legacy",
|
||||
Level = 3,
|
||||
Experience = 0,
|
||||
Version = 1
|
||||
});
|
||||
|
||||
var repository = new SaveRepository<TestVersionedSaveData>(storage, config)
|
||||
.RegisterMigration(new TestSaveMigrationV1ToV2ReturningV3());
|
||||
|
||||
var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await repository.LoadAsync(1));
|
||||
Assert.That(exception!.Message, Does.Contain("declared target version 2"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证统一设置仓库能够保存、重新加载并批量读取已注册的设置数据。
|
||||
/// </summary>
|
||||
@ -707,6 +740,25 @@ public class PersistenceTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSaveMigrationV1ToV2ReturningV3 : ISaveMigration<TestVersionedSaveData>
|
||||
{
|
||||
public int FromVersion => 1;
|
||||
|
||||
public int ToVersion => 2;
|
||||
|
||||
public TestVersionedSaveData Migrate(TestVersionedSaveData oldData)
|
||||
{
|
||||
return new TestVersionedSaveData
|
||||
{
|
||||
Name = oldData.Name,
|
||||
Level = oldData.Level,
|
||||
Experience = oldData.Experience,
|
||||
Version = 3,
|
||||
LastModified = oldData.LastModified
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestNonVersionedMigration : ISaveMigration<TestSaveData>
|
||||
{
|
||||
public int FromVersion => 1;
|
||||
|
||||
@ -47,6 +47,7 @@ public sealed class SettingsModelTests
|
||||
await model.InitializeAsync();
|
||||
Assert.That(model.GetData<TestSettingsData>().Version, Is.EqualTo(1));
|
||||
|
||||
model.GetData<TestSettingsData>().Version = 2;
|
||||
model.RegisterMigration(new TestSettingsMigration());
|
||||
|
||||
repository.Stored["TestSettingsData"] = new TestSettingsData
|
||||
@ -65,6 +66,49 @@ public sealed class SettingsModelTests
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RegisterMigration_Should_Reject_Duplicate_FromVersion_For_Same_SettingsType()
|
||||
{
|
||||
var locationProvider = new TestDataLocationProvider();
|
||||
var repository = new FakeSettingsDataRepository();
|
||||
var model = new SettingsModel<FakeSettingsDataRepository>(locationProvider, repository);
|
||||
|
||||
model.RegisterMigration(new TestSettingsMigration());
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => model.RegisterMigration(new TestSettingsMigration()));
|
||||
|
||||
Assert.That(exception!.Message, Does.Contain("Duplicate settings migration registration"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task InitializeAsync_Should_Keep_Current_Instance_When_Migration_Chain_Is_Incomplete()
|
||||
{
|
||||
var locationProvider = new TestDataLocationProvider();
|
||||
var repository = new FakeSettingsDataRepository();
|
||||
var model = new SettingsModel<FakeSettingsDataRepository>(locationProvider, repository);
|
||||
((IContextAware)model).SetContext(new Mock<IArchitectureContext>(MockBehavior.Loose).Object);
|
||||
|
||||
_ = model.GetData<TestLatestSettingsData>();
|
||||
((IInitializable)model).Initialize();
|
||||
|
||||
repository.Stored["TestLatestSettingsData"] = new TestLatestSettingsData
|
||||
{
|
||||
Version = 1,
|
||||
Value = "legacy"
|
||||
};
|
||||
|
||||
model.RegisterMigration(new TestLatestSettingsMigrationV1ToV2());
|
||||
|
||||
await model.InitializeAsync();
|
||||
|
||||
var current = model.GetData<TestLatestSettingsData>();
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(current.Version, Is.EqualTo(3));
|
||||
Assert.That(current.Value, Is.EqualTo("default-v3"));
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class TestSettingsData : ISettingsData
|
||||
{
|
||||
public string Value { get; set; } = "default";
|
||||
@ -110,6 +154,51 @@ public sealed class SettingsModelTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestLatestSettingsData : ISettingsData
|
||||
{
|
||||
public string Value { get; set; } = "default-v3";
|
||||
|
||||
public int Version { get; set; } = 3;
|
||||
|
||||
public DateTime LastModified { get; } = DateTime.UtcNow;
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
Value = "default-v3";
|
||||
Version = 3;
|
||||
}
|
||||
|
||||
public void LoadFrom(ISettingsData source)
|
||||
{
|
||||
if (source is not TestLatestSettingsData data)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Value = data.Value;
|
||||
Version = data.Version;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestLatestSettingsMigrationV1ToV2 : ISettingsMigration
|
||||
{
|
||||
public Type SettingsType => typeof(TestLatestSettingsData);
|
||||
|
||||
public int FromVersion => 1;
|
||||
|
||||
public int ToVersion => 2;
|
||||
|
||||
public ISettingsSection Migrate(ISettingsSection oldData)
|
||||
{
|
||||
var data = (TestLatestSettingsData)oldData;
|
||||
return new TestLatestSettingsData
|
||||
{
|
||||
Version = 2,
|
||||
Value = $"{data.Value}-migrated"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSettingsDataRepository : ISettingsDataRepository
|
||||
{
|
||||
public Dictionary<string, Type> RegisteredTypes { get; } = new(StringComparer.Ordinal);
|
||||
@ -178,4 +267,4 @@ public sealed class SettingsModelTests
|
||||
|
||||
public IReadOnlyDictionary<string, string>? Metadata => null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ using System.Threading.Tasks;
|
||||
using GFramework.Core.Abstractions.Storage;
|
||||
using GFramework.Core.Utility;
|
||||
using GFramework.Game.Abstractions.Data;
|
||||
using GFramework.Game.Internal;
|
||||
using GFramework.Game.Storage;
|
||||
|
||||
namespace GFramework.Game.Data;
|
||||
@ -69,12 +70,12 @@ public class SaveRepository<TSaveData> : AbstractContextUtility, ISaveRepository
|
||||
ArgumentNullException.ThrowIfNull(migration);
|
||||
EnsureVersionedSaveType();
|
||||
|
||||
if (migration.ToVersion <= migration.FromVersion)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Migration for {typeof(TSaveData).Name} must advance the version number.",
|
||||
nameof(migration));
|
||||
}
|
||||
VersionedMigrationRunner.ValidateForwardOnlyRegistration(
|
||||
typeof(TSaveData).Name,
|
||||
"Save migration",
|
||||
migration.FromVersion,
|
||||
migration.ToVersion,
|
||||
nameof(migration));
|
||||
|
||||
lock (_migrationsLock)
|
||||
{
|
||||
@ -227,57 +228,24 @@ public class SaveRepository<TSaveData> : AbstractContextUtility, ISaveRepository
|
||||
|
||||
EnsureVersionedSaveType();
|
||||
|
||||
var migrated = data;
|
||||
|
||||
// 迁移链按“当前版本 -> 下一个已注册迁移器”推进;任何缺口都表示运行时无法安全解释旧存档。
|
||||
// 读取迁移表时使用同一把锁,保证并发注册不会让加载线程看到不一致的链路状态。
|
||||
while (currentVersion < targetVersion)
|
||||
{
|
||||
ISaveMigration<TSaveData>? migration;
|
||||
lock (_migrationsLock)
|
||||
var migrated = VersionedMigrationRunner.MigrateToTargetVersion(
|
||||
data,
|
||||
targetVersion,
|
||||
static saveData => ((IVersionedData)saveData).Version,
|
||||
fromVersion =>
|
||||
{
|
||||
_migrations.TryGetValue(currentVersion, out migration);
|
||||
}
|
||||
|
||||
if (migration is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"No save migration is registered for {typeof(TSaveData).Name} from version {currentVersion}.");
|
||||
}
|
||||
|
||||
migrated = migration.Migrate(migrated) ??
|
||||
throw new InvalidOperationException(
|
||||
$"Save migration for {typeof(TSaveData).Name} from version {currentVersion} returned null.");
|
||||
|
||||
if (migrated is not IVersionedData migratedVersionedData)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Save migration for {typeof(TSaveData).Name} must return data implementing {nameof(IVersionedData)}.");
|
||||
}
|
||||
|
||||
// 显式校验迁移器声明与实际结果,避免版本号不前进导致死循环或把旧数据错误回写为“已升级”。
|
||||
if (migration.ToVersion != migratedVersionedData.Version)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Save migration for {typeof(TSaveData).Name} declared target version {migration.ToVersion} " +
|
||||
$"but returned version {migratedVersionedData.Version}.");
|
||||
}
|
||||
|
||||
if (migratedVersionedData.Version <= currentVersion)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Save migration for {typeof(TSaveData).Name} must advance beyond version {currentVersion}.");
|
||||
}
|
||||
|
||||
if (migratedVersionedData.Version > targetVersion)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Save migration for {typeof(TSaveData).Name} produced version {migratedVersionedData.Version}, " +
|
||||
$"which exceeds the current runtime version {targetVersion}.");
|
||||
}
|
||||
|
||||
currentVersion = migratedVersionedData.Version;
|
||||
}
|
||||
lock (_migrationsLock)
|
||||
{
|
||||
_migrations.TryGetValue(fromVersion, out var migration);
|
||||
return migration;
|
||||
}
|
||||
},
|
||||
static migration => migration.ToVersion,
|
||||
static (migration, currentData) => migration.Migrate(currentData),
|
||||
$"{typeof(TSaveData).Name} in slot {slot}",
|
||||
"save migration");
|
||||
|
||||
await storage.WriteAsync(_config.SaveFileName, migrated);
|
||||
return migrated;
|
||||
|
||||
142
GFramework.Game/Internal/VersionedMigrationRunner.cs
Normal file
142
GFramework.Game/Internal/VersionedMigrationRunner.cs
Normal file
@ -0,0 +1,142 @@
|
||||
// Copyright (c) 2026 GeWuYou
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
namespace GFramework.Game.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// 提供版本化数据迁移链的共享执行逻辑。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该运行器只负责“按版本号推进”的公共约束,包括:
|
||||
/// 前向注册校验、缺失链路失败、声明目标版本与实际结果版本一致性,以及避免版本回退或死循环。
|
||||
/// 它不关心具体存储、日志、回写或异常吞吐策略;这些由调用方负责。
|
||||
/// </remarks>
|
||||
internal static class VersionedMigrationRunner
|
||||
{
|
||||
/// <summary>
|
||||
/// 校验迁移注册是否表示一次有效的前向升级。
|
||||
/// </summary>
|
||||
/// <param name="subjectName">迁移所作用的主体名称,例如设置类型或存档类型。</param>
|
||||
/// <param name="migrationKind">用于异常消息的迁移类别名称。</param>
|
||||
/// <param name="fromVersion">源版本。</param>
|
||||
/// <param name="toVersion">目标版本。</param>
|
||||
/// <param name="paramName">异常中要使用的参数名。</param>
|
||||
/// <exception cref="ArgumentException">目标版本不大于源版本时抛出。</exception>
|
||||
internal static void ValidateForwardOnlyRegistration(
|
||||
string subjectName,
|
||||
string migrationKind,
|
||||
int fromVersion,
|
||||
int toVersion,
|
||||
string paramName)
|
||||
{
|
||||
if (toVersion <= fromVersion)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"{migrationKind} for {subjectName} must advance the version number.",
|
||||
paramName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按目标运行时版本执行连续迁移。
|
||||
/// </summary>
|
||||
/// <typeparam name="TData">迁移数据类型。</typeparam>
|
||||
/// <typeparam name="TMigration">迁移描述类型。</typeparam>
|
||||
/// <param name="data">原始加载的数据。</param>
|
||||
/// <param name="targetVersion">当前运行时支持的目标版本。</param>
|
||||
/// <param name="getVersion">从数据对象提取版本号的委托。</param>
|
||||
/// <param name="resolveMigration">根据当前版本查找下一步迁移器的委托。</param>
|
||||
/// <param name="getToVersion">从迁移器提取声明目标版本的委托。</param>
|
||||
/// <param name="applyMigration">执行单步迁移的委托。</param>
|
||||
/// <param name="subjectName">迁移主体名称,用于异常消息。</param>
|
||||
/// <param name="migrationKind">迁移类别名称,用于异常消息。</param>
|
||||
/// <returns>迁移到目标版本后的数据;如果已经是最新版本,则返回原对象。</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// 数据版本高于当前运行时、迁移链缺失、迁移器返回 <see langword="null" />、
|
||||
/// 迁移结果版本与声明不一致、版本未前进或超出目标版本时抛出。
|
||||
/// </exception>
|
||||
internal static TData MigrateToTargetVersion<TData, TMigration>(
|
||||
TData data,
|
||||
int targetVersion,
|
||||
Func<TData, int> getVersion,
|
||||
Func<int, TMigration?> resolveMigration,
|
||||
Func<TMigration, int> getToVersion,
|
||||
Func<TMigration, TData, TData> applyMigration,
|
||||
string subjectName,
|
||||
string migrationKind)
|
||||
where TData : class
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
ArgumentNullException.ThrowIfNull(getVersion);
|
||||
ArgumentNullException.ThrowIfNull(resolveMigration);
|
||||
ArgumentNullException.ThrowIfNull(getToVersion);
|
||||
ArgumentNullException.ThrowIfNull(applyMigration);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(subjectName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(migrationKind);
|
||||
|
||||
var currentVersion = getVersion(data);
|
||||
if (currentVersion > targetVersion)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{subjectName} is version {currentVersion}, which is newer than the current runtime version {targetVersion}.");
|
||||
}
|
||||
|
||||
if (currentVersion == targetVersion)
|
||||
{
|
||||
return data;
|
||||
}
|
||||
|
||||
var current = data;
|
||||
|
||||
while (currentVersion < targetVersion)
|
||||
{
|
||||
var migration = resolveMigration(currentVersion);
|
||||
if (migration is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"No {migrationKind} is registered for {subjectName} from version {currentVersion}.");
|
||||
}
|
||||
|
||||
current = applyMigration(migration, current)
|
||||
?? throw new InvalidOperationException(
|
||||
$"{migrationKind} for {subjectName} from version {currentVersion} returned null.");
|
||||
|
||||
var migratedVersion = getVersion(current);
|
||||
var declaredTargetVersion = getToVersion(migration);
|
||||
|
||||
if (declaredTargetVersion != migratedVersion)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{migrationKind} for {subjectName} declared target version {declaredTargetVersion} " +
|
||||
$"but returned version {migratedVersion}.");
|
||||
}
|
||||
|
||||
if (migratedVersion <= currentVersion)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{migrationKind} for {subjectName} must advance beyond version {currentVersion}.");
|
||||
}
|
||||
|
||||
if (migratedVersion > targetVersion)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{migrationKind} for {subjectName} produced version {migratedVersion}, " +
|
||||
$"which exceeds the current runtime version {targetVersion}.");
|
||||
}
|
||||
|
||||
currentVersion = migratedVersion;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ using GFramework.Core.Logging;
|
||||
using GFramework.Core.Model;
|
||||
using GFramework.Game.Abstractions.Data;
|
||||
using GFramework.Game.Abstractions.Setting;
|
||||
using GFramework.Game.Internal;
|
||||
using GFramework.Game.Setting.Events;
|
||||
|
||||
namespace GFramework.Game.Setting;
|
||||
@ -116,7 +117,21 @@ public class SettingsModel<TRepository>(IDataLocationProvider? locationProvider,
|
||||
/// </returns>
|
||||
public ISettingsModel RegisterMigration(ISettingsMigration migration)
|
||||
{
|
||||
_migrations[(migration.SettingsType, migration.FromVersion)] = migration;
|
||||
ArgumentNullException.ThrowIfNull(migration);
|
||||
|
||||
VersionedMigrationRunner.ValidateForwardOnlyRegistration(
|
||||
migration.SettingsType.Name,
|
||||
"Settings migration",
|
||||
migration.FromVersion,
|
||||
migration.ToVersion,
|
||||
nameof(migration));
|
||||
|
||||
if (!_migrations.TryAdd((migration.SettingsType, migration.FromVersion), migration))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Duplicate settings migration registration for {migration.SettingsType.Name} from version {migration.FromVersion}.");
|
||||
}
|
||||
|
||||
_migrationCache.TryRemove(migration.SettingsType, out _);
|
||||
return this;
|
||||
}
|
||||
@ -156,7 +171,7 @@ public class SettingsModel<TRepository>(IDataLocationProvider? locationProvider,
|
||||
if (raw is not ISettingsData loaded)
|
||||
continue;
|
||||
|
||||
var migrated = MigrateIfNeeded(loaded);
|
||||
var migrated = MigrateIfNeeded(loaded, data);
|
||||
|
||||
// 回填(不替换实例)
|
||||
data.LoadFrom(migrated);
|
||||
@ -277,14 +292,9 @@ public class SettingsModel<TRepository>(IDataLocationProvider? locationProvider,
|
||||
_repository.RegisterDataType(location, type);
|
||||
}
|
||||
|
||||
private ISettingsData MigrateIfNeeded(ISettingsData data)
|
||||
private ISettingsData MigrateIfNeeded(ISettingsData data, ISettingsData latestData)
|
||||
{
|
||||
if (data is not IVersionedData versioned)
|
||||
return data;
|
||||
|
||||
var type = data.GetType();
|
||||
var current = data;
|
||||
|
||||
if (!_migrationCache.TryGetValue(type, out var versionMap))
|
||||
{
|
||||
versionMap = _migrations
|
||||
@ -294,12 +304,42 @@ public class SettingsModel<TRepository>(IDataLocationProvider? locationProvider,
|
||||
_migrationCache[type] = versionMap;
|
||||
}
|
||||
|
||||
while (versionMap.TryGetValue(versioned.Version, out var migration))
|
||||
return VersionedMigrationRunner.MigrateToTargetVersion(
|
||||
data,
|
||||
latestData.Version,
|
||||
static settings => settings.Version,
|
||||
fromVersion => versionMap.TryGetValue(fromVersion, out var migration) ? migration : null,
|
||||
static migration => migration.ToVersion,
|
||||
static (migration, current) => ApplySettingsMigration(migration, current),
|
||||
$"{type.Name} settings",
|
||||
"settings migration");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行单步设置迁移,并验证迁移结果仍然属于已注册的设置类型。
|
||||
/// </summary>
|
||||
/// <param name="migration">要执行的迁移器。</param>
|
||||
/// <param name="currentData">当前版本的数据。</param>
|
||||
/// <returns>迁移后的设置数据。</returns>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// 迁移结果不实现 <see cref="ISettingsData" />,或返回了与声明设置类型不兼容的数据时抛出。
|
||||
/// </exception>
|
||||
private static ISettingsData ApplySettingsMigration(ISettingsMigration migration, ISettingsData currentData)
|
||||
{
|
||||
var fromVersion = currentData.Version;
|
||||
var migrated = migration.Migrate(currentData);
|
||||
if (migrated is not ISettingsData migratedData)
|
||||
{
|
||||
current = (ISettingsData)migration.Migrate(current);
|
||||
versioned = current;
|
||||
throw new InvalidOperationException(
|
||||
$"Settings migration for {migration.SettingsType.Name} from version {fromVersion} must return {nameof(ISettingsData)}.");
|
||||
}
|
||||
|
||||
return current;
|
||||
if (!migration.SettingsType.IsInstanceOfType(migratedData))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Settings migration for {migration.SettingsType.Name} from version {fromVersion} returned incompatible data type {migratedData.GetType().Name}.");
|
||||
}
|
||||
|
||||
return migratedData;
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,14 +13,15 @@
|
||||
- 已将根目录 legacy `local-plan/settings-persistence-serialization-tracking.md` 迁入
|
||||
`ai-plan/public/data-repository-persistence/`
|
||||
- 第一轮 settings / persistence / serialization 修复、测试与文档同步已完成,并收入主题内 `archive/`
|
||||
- 当前正在补齐 `JsonSerializer` 的配置生命周期、只读约束与线程安全说明
|
||||
- 下一轮需要继续评估迁移模型统一抽象与 codec / persistence pipeline 边界
|
||||
- 已完成 `SettingsModel` / `SaveRepository<T>` 共享迁移执行器收敛与契约补强
|
||||
- 下一轮需要继续评估 codec / persistence pipeline 边界
|
||||
|
||||
## 当前状态摘要
|
||||
|
||||
- 高优先级实现、测试与文档对齐已在本主题历史阶段完成,当前 active 入口主要保留后续 design/backlog 恢复点
|
||||
- 当前分支 `feat/data-repository-persistence` 已在 `ai-plan/public/README.md` 建立 topic 映射
|
||||
- 旧单文件不再同时承担 todo 与 trace 角色,后续恢复统一从本 topic 的 active tracking / trace 进入
|
||||
- `SettingsModel` 与 `SaveRepository<T>` 的版本迁移链现在共用同一个 internal runner;继续沿这条线扩展时应优先复用而不是再复制链式迁移逻辑
|
||||
|
||||
## 当前活跃事实
|
||||
|
||||
@ -28,12 +29,14 @@
|
||||
- 详细历史已拆分迁入主题内 `archive/`,active tracking / trace 只保留当前恢复点、风险与下一步
|
||||
- 历史已验证结果包括 `GFramework.Game.Tests` 的定向与全量通过,以及 `docs/zh-CN/game/*` 的同步更新
|
||||
- `GFramework.Game.Serializer.JsonSerializer` 当前直接暴露活动中的 `JsonSerializerSettings` 与 converters 集合,配置不会被复制
|
||||
- `docs/zh-CN/game/serialization.md` 现有“序列化器本身线程安全”表述与源码契约不一致,需要在本轮修正
|
||||
- `GFramework.Game.Internal.VersionedMigrationRunner` 已统一前向迁移注册校验、缺链失败、声明版本一致性与非递增防护
|
||||
- `SettingsModel` 现在以当前内存设置实例的 `Version` 作为目标运行时版本;若迁移失败则保留当前实例并记录错误日志
|
||||
- `SaveRepository<T>` 继续在 `LoadAsync(slot)` 期间迁移并回写,但其核心链式校验已与设置迁移共用同一实现
|
||||
|
||||
## 当前风险
|
||||
|
||||
- 迁移模型分叉风险:`SettingsModel`、`DataRepository` 与 `SaveRepository<T>` 的版本演进机制仍可能继续分叉
|
||||
- 缓解措施:在新增更多 persistence feature 前,先评估能否抽出统一的 migration abstraction
|
||||
- codec / persistence pipeline 边界风险:压缩、加密、元数据与备份策略还散落在仓库与存储语义之间
|
||||
- 缓解措施:下一轮先梳理现有 `Serializer` / `Storage` / `Repository` 的责任边界,再决定是否需要新的 pipeline abstraction
|
||||
- Active 入口回膨胀风险:若后续把实现细节继续堆回 active 文档,会重新退化成旧 `local-plan`
|
||||
- 缓解措施:后续阶段完成并验证后,继续迁入本 topic 的 `archive/`
|
||||
|
||||
@ -48,10 +51,12 @@
|
||||
- active 跟踪文件已按 `ai-plan` 治理规则精简为当前恢复入口
|
||||
- 已补充 `JsonSerializer` XML docs、文档示例与最小契约测试
|
||||
- `dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~JsonSerializerTests"` 已通过(9/9)
|
||||
- 已完成 `VersionedMigrationRunner` 抽取,并让 `SettingsModel` / `SaveRepository<T>` 共用链式迁移校验
|
||||
- `dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~SettingsModelTests|FullyQualifiedName~PersistenceTests"` 已通过(20/20)
|
||||
- 本次定向验证过程中出现的 analyzer warning 来自仓库既有代码,不属于本轮新增问题
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 再评估设置 / 通用仓库 / 存档仓库的迁移模型是否要统一抽象
|
||||
2. 最后评估压缩 / 加密 / 元数据策略是否应落入更明确的 codec / persistence pipeline
|
||||
1. 评估压缩 / 加密 / 元数据策略是否应落入更明确的 codec / persistence pipeline
|
||||
2. 梳理 `Serializer`、`Storage`、`DataRepositoryOptions` 与统一文件仓库之间的扩展点重叠
|
||||
3. 若进入下一轮实现,先确定是否需要新的 dedicated recovery point 以避免 RP-001 active 入口继续膨胀
|
||||
|
||||
@ -45,3 +45,33 @@
|
||||
1. `dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~JsonSerializerTests"` 已通过(9/9)
|
||||
2. 验证过程中出现的 analyzer warning 为仓库既有 warning,未在本轮扩大
|
||||
3. 下一步回到 migration abstraction 与 codec / persistence pipeline 的后续评估
|
||||
|
||||
### 阶段:迁移执行器统一收敛(RP-001)
|
||||
|
||||
- 对 `SettingsModel`、`DataRepository`、`UnifiedSettingsDataRepository`、`SaveRepository<T>` 的实现进行并排核对后确认:
|
||||
- `DataRepository` 与 `UnifiedSettingsDataRepository` 不直接承担按版本号推进的迁移链
|
||||
- 实际重复点只在 `SettingsModel` 与 `SaveRepository<T>` 的“版本迁移链执行与校验”逻辑
|
||||
- 决定不新增 public migration abstraction,而是抽出 internal `VersionedMigrationRunner`
|
||||
- 统一前向注册校验
|
||||
- 统一缺链失败
|
||||
- 统一声明目标版本与实际结果版本一致性校验
|
||||
- 统一非递增 / 超目标版本防护
|
||||
- `SettingsModel` 本轮额外补强:
|
||||
- 拒绝同一设置类型同一 `FromVersion` 的重复注册
|
||||
- 以当前内存设置实例的 `Version` 作为目标运行时版本
|
||||
- 迁移失败时保持当前实例不被旧数据覆盖,并继续记录错误日志
|
||||
- `SaveRepository<T>` 改为复用同一个 internal runner,但保留“加载成功后自动回写升级结果”的现有仓库语义
|
||||
- 同步更新 `docs/zh-CN/game/setting.md` 与 `docs/zh-CN/game/data.md`,补迁移链约束说明
|
||||
- 新增 / 更新测试:
|
||||
- `SettingsModelTests`:重复注册拒绝、不完整链路保持当前实例、缓存失效场景
|
||||
- `PersistenceTests`:迁移结果版本与声明版本不一致时显式失败
|
||||
|
||||
### 验证
|
||||
|
||||
1. `dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~SettingsModelTests|FullyQualifiedName~PersistenceTests"` 已通过(20/20)
|
||||
2. 过程中出现的 analyzer warning 来自仓库既有项,未在本轮扩大
|
||||
|
||||
### 下一步
|
||||
|
||||
1. 进入 codec / persistence pipeline 边界评估
|
||||
2. 重点查看压缩、加密、元数据、备份是否仍然跨越 `Serializer` / `Storage` / `Repository` 多层分散
|
||||
|
||||
@ -299,7 +299,9 @@ public partial class AutoSaveController : IController
|
||||
- `TSaveData` 需要实现 `IVersionedData`
|
||||
- 仓库以 `new TSaveData().Version` 作为当前运行时目标版本
|
||||
- 每个迁移器负责一个 `FromVersion -> ToVersion` 跳转
|
||||
- 同一个 `FromVersion` 只能注册一个迁移器,且 `ToVersion` 必须严格大于 `FromVersion`
|
||||
- 加载时仓库会按链路连续执行迁移,并在成功后自动回写升级后的存档
|
||||
- 迁移器返回数据上的 `Version` 必须与声明的 `ToVersion` 一致
|
||||
- 如果缺少中间迁移器,或者读到了比当前运行时更高的版本,`LoadAsync` 会抛出异常,避免静默加载错误数据
|
||||
|
||||
```csharp
|
||||
|
||||
@ -181,6 +181,14 @@ public interface ISettingsMigration
|
||||
|
||||
当 `InitializeAsync()` 读取到旧版本设置时,会按已注册迁移链逐步升级,再通过 `LoadFrom` 回填到当前实例。
|
||||
|
||||
迁移规则如下:
|
||||
|
||||
- 同一个设置类型的同一个 `FromVersion` 只能注册一个迁移器
|
||||
- `ToVersion` 必须严格大于 `FromVersion`
|
||||
- `InitializeAsync()` 会以当前运行时代码里该设置实例的 `Version` 作为目标版本
|
||||
- 如果迁移链缺口、迁移结果类型不兼容、迁移结果版本与声明不一致,或者读取到比当前运行时更高的版本,当前设置节不会覆盖内存中的最新实例,并会记录错误日志
|
||||
- 与 `SaveRepository<TSaveData>` 不同,设置初始化阶段会跳过失败的设置节并继续处理其他设置节,而不是把异常继续向外抛出
|
||||
|
||||
## 依赖项
|
||||
|
||||
要让设置系统完整工作,通常需要准备:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user