扩展 P1 配置来源和存档迁移

This commit is contained in:
JSD\13999
2026-05-20 16:16:08 +08:00
parent 1e7ae22ddc
commit 1efe6c9974
16 changed files with 538 additions and 29 deletions

View File

@@ -0,0 +1,31 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace FlowScope.Config
{
public sealed class FileConfigSource : IConfigSource
{
private readonly string _rootPath;
public FileConfigSource(string rootPath, string name = "File")
{
_rootPath = string.IsNullOrWhiteSpace(rootPath)
? throw new ArgumentException("Config root path is required.", nameof(rootPath))
: rootPath;
Name = string.IsNullOrWhiteSpace(name)
? throw new ArgumentException("Config source name is required.", nameof(name))
: name;
}
public string Name { get; }
public Task<string> LoadTextAsync(string fileName, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var path = Path.Combine(_rootPath, fileName);
return Task.Run(() => File.ReadAllText(path), cancellationToken);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8d50cd860ba849a1aa7647deba4e71db
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace FlowScope.Config
{
public sealed class InMemoryConfigSource : IConfigSource
{
private readonly Dictionary<string, string> _files;
public InMemoryConfigSource(
IReadOnlyDictionary<string, string> files,
string name = "InMemory")
{
_files = files != null
? new Dictionary<string, string>(files)
: throw new ArgumentNullException(nameof(files));
Name = string.IsNullOrWhiteSpace(name)
? throw new ArgumentException("Config source name is required.", nameof(name))
: name;
}
public string Name { get; }
public Task<string> LoadTextAsync(string fileName, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (!_files.TryGetValue(fileName, out var text))
{
throw new InvalidOperationException($"Config file is missing: {fileName}");
}
return Task.FromResult(text);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 607d9b20e7f04565929ea5876e5c25e1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,16 @@
using System.Collections.Generic;
using FlowScope.Data;
namespace FlowScope.Config
{
public sealed class JsonConfigParser : IConfigParser
{
public string Format => "json";
public IReadOnlyList<T> ParseRows<T>(string fileName, string text)
where T : class, IConfigRow
{
return ReactivePropertyJsonConverter.DeserializeArray<T>(text);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f60f8a8e66394f26a20ed6f5ef314c63
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,8 +1,8 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using FlowScope.Data;
namespace FlowScope.Config
{
@@ -19,7 +19,19 @@ namespace FlowScope.Config
public static ConfigMapping Mapping<T>(string fileName, Func<CancellationToken, Task<string>> loadTextAsync)
where T : class, IConfigRow
{
return new ConfigMapping(typeof(T), fileName, loadTextAsync);
return Mapping<T>(
fileName,
new DelegateConfigSource(fileName, loadTextAsync),
new JsonConfigParser());
}
public static ConfigMapping Mapping<T>(
string fileName,
IConfigSource source,
IConfigParser parser)
where T : class, IConfigRow
{
return new ConfigMapping(typeof(T), fileName, source, parser);
}
public async Task LoadAllAsync(CancellationToken cancellationToken)
@@ -33,7 +45,7 @@ namespace FlowScope.Config
string json;
try
{
json = await mapping.LoadTextAsync(cancellationToken);
json = await mapping.Source.LoadTextAsync(mapping.FileName, cancellationToken);
}
catch (OperationCanceledException)
{
@@ -41,7 +53,9 @@ namespace FlowScope.Config
}
catch (Exception exception)
{
throw new InvalidOperationException($"Failed to load config file '{mapping.FileName}'.", exception);
throw new InvalidOperationException(
$"Failed to load config file '{mapping.FileName}' from source '{mapping.Source.Name}'.",
exception);
}
try
@@ -84,42 +98,42 @@ namespace FlowScope.Config
public sealed class ConfigMapping
{
private readonly Func<string, object> _parse;
private readonly MethodInfo _parseRowsMethod;
internal ConfigMapping(Type rowType, string fileName, Func<CancellationToken, Task<string>> loadTextAsync)
internal ConfigMapping(
Type rowType,
string fileName,
IConfigSource source,
IConfigParser parser)
{
RowType = rowType ?? throw new ArgumentNullException(nameof(rowType));
FileName = string.IsNullOrWhiteSpace(fileName)
? throw new ArgumentException("Config file name is required.", nameof(fileName))
: fileName;
LoadTextAsync = loadTextAsync ?? throw new ArgumentNullException(nameof(loadTextAsync));
_parse = CreateParser(rowType);
Source = source ?? throw new ArgumentNullException(nameof(source));
Parser = parser ?? throw new ArgumentNullException(nameof(parser));
_parseRowsMethod = typeof(IConfigParser)
.GetMethod(nameof(IConfigParser.ParseRows))
.MakeGenericMethod(rowType);
}
public Type RowType { get; }
public string FileName { get; }
public Func<CancellationToken, Task<string>> LoadTextAsync { get; }
public IConfigSource Source { get; }
public IConfigParser Parser { get; }
internal object Parse(string json) => _parse(json);
private static Func<string, object> CreateParser(Type rowType)
internal object Parse(string json)
{
var method = typeof(ConfigMapping)
.GetMethod(nameof(ParseRows), System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)
.MakeGenericMethod(rowType);
return json => method.Invoke(null, new object[] { json });
}
private static object ParseRows<T>(string json) where T : class, IConfigRow
{
var rows = ReactivePropertyJsonConverter.DeserializeArray<T>(json);
var byId = new Dictionary<int, T>();
foreach (var row in rows)
var rows = (System.Collections.IEnumerable)_parseRowsMethod.Invoke(
Parser,
new object[] { FileName, json });
var byIdType = typeof(Dictionary<,>).MakeGenericType(typeof(int), RowType);
var byId = (System.Collections.IDictionary)Activator.CreateInstance(byIdType);
foreach (IConfigRow row in rows)
{
if (byId.ContainsKey(row.Id))
if (byId.Contains(row.Id))
{
throw new InvalidOperationException($"Duplicate config id {row.Id} for {typeof(T).Name}.");
throw new InvalidOperationException($"Duplicate config id {row.Id} for {RowType.Name}.");
}
byId.Add(row.Id, row);
@@ -128,5 +142,25 @@ namespace FlowScope.Config
return byId;
}
}
private sealed class DelegateConfigSource : IConfigSource
{
private readonly Func<CancellationToken, Task<string>> _loadTextAsync;
public DelegateConfigSource(
string fileName,
Func<CancellationToken, Task<string>> loadTextAsync)
{
Name = fileName;
_loadTextAsync = loadTextAsync ?? throw new ArgumentNullException(nameof(loadTextAsync));
}
public string Name { get; }
public Task<string> LoadTextAsync(string fileName, CancellationToken cancellationToken)
{
return _loadTextAsync(cancellationToken);
}
}
}
}

View File

@@ -12,6 +12,22 @@ namespace FlowScope.Save
public sealed class JsonSaveSerializer : ISaveSerializer
{
private readonly int _currentVersion;
private readonly SaveMigrationRegistry _migrations;
public JsonSaveSerializer(
int currentVersion = 1,
SaveMigrationRegistry migrations = null)
{
if (currentVersion < 1)
{
throw new ArgumentOutOfRangeException(nameof(currentVersion));
}
_currentVersion = currentVersion;
_migrations = migrations ?? new SaveMigrationRegistry();
}
public byte[] Serialize<T>(T data)
{
return Encoding.UTF8.GetBytes(SerializeToString(data));
@@ -29,19 +45,75 @@ namespace FlowScope.Save
public string SerializeToString<T>(T data)
{
return ReactivePropertyJsonConverter.SerializeObject(data);
var payload = ReactivePropertyJsonConverter.SerializeObject(data);
if (_currentVersion <= 1)
{
return payload;
}
return ReactivePropertyJsonConverter.SerializeObject(new SaveEnvelope
{
Version = _currentVersion,
Payload = payload
});
}
public T DeserializeFromString<T>(string json)
{
var instance = (T)Activator.CreateInstance(typeof(T), true);
PopulateFromString(json, instance);
PopulateFromString(ResolvePayload<T>(json), instance);
return instance;
}
public void PopulateFromString<T>(string json, T target)
{
ReactivePropertyJsonConverter.PopulateObject(json, target);
ReactivePropertyJsonConverter.PopulateObject(ResolvePayload<T>(json), target);
}
private string ResolvePayload<T>(string json)
{
if (!TryReadEnvelope(json, out var envelope))
{
return _currentVersion > 1
? _migrations.Migrate(GetMigrationKey<T>(), 1, _currentVersion, json)
: json;
}
if (envelope.Version == _currentVersion)
{
return envelope.Payload;
}
if (envelope.Version > _currentVersion)
{
throw new InvalidOperationException(
$"Save version {envelope.Version} is newer than current version {_currentVersion}.");
}
return _migrations.Migrate(
GetMigrationKey<T>(),
envelope.Version,
_currentVersion,
envelope.Payload);
}
private static bool TryReadEnvelope(string json, out SaveEnvelope envelope)
{
envelope = null;
try
{
envelope = ReactivePropertyJsonConverter.DeserializeObject<SaveEnvelope>(json);
return envelope.Version > 0 && envelope.Payload != null;
}
catch
{
return false;
}
}
private static string GetMigrationKey<T>()
{
return typeof(T).FullName;
}
}
}

View File

@@ -0,0 +1,8 @@
namespace FlowScope.Save
{
internal sealed class SaveEnvelope
{
public int Version { get; set; }
public string Payload { get; set; }
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 67ada4b73d47420db150f34ab12a236a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
namespace FlowScope.Save
{
public sealed class SaveMigrationRegistry
{
private readonly Dictionary<MigrationKey, ISaveMigration> _migrations = new();
public void Register(ISaveMigration migration)
{
if (migration == null)
{
throw new ArgumentNullException(nameof(migration));
}
_migrations[new MigrationKey(migration.Key, migration.FromVersion)] = migration;
}
public string Migrate(
string key,
int fromVersion,
int currentVersion,
string json)
{
var current = fromVersion;
var payload = json;
while (current < currentVersion)
{
if (!_migrations.TryGetValue(new MigrationKey(key, current), out var migration) ||
migration.ToVersion != current + 1)
{
throw new InvalidOperationException(
$"Missing save migration for key '{key}' from version {current}.");
}
payload = migration.Migrate(payload);
current = migration.ToVersion;
}
return payload;
}
private readonly struct MigrationKey : IEquatable<MigrationKey>
{
private readonly string _key;
private readonly int _fromVersion;
public MigrationKey(string key, int fromVersion)
{
_key = key ?? string.Empty;
_fromVersion = fromVersion;
}
public bool Equals(MigrationKey other)
{
return _key == other._key && _fromVersion == other._fromVersion;
}
public override bool Equals(object obj)
{
return obj is MigrationKey other && Equals(other);
}
public override int GetHashCode()
{
unchecked
{
return ((_key != null ? _key.GetHashCode() : 0) * 397) ^ _fromVersion;
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3f2c3dd694c94ed59d08bcdf88d830a7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FlowScope.Config;
using NUnit.Framework;
namespace FlowScope.Tests.EditMode.Config
{
public sealed class ConfigExtensionTests
{
[Test]
public void LoadAllAsync_UsesConfiguredSourceAndParser()
{
var source = new InMemoryConfigSource(
new Dictionary<string, string>
{
["player.json"] = "[{\"id\":7,\"name\":\"Starter\"}]"
});
var provider = new JsonConfigProvider(new[]
{
JsonConfigProvider.Mapping<PlayerConfig>("player.json", source, new JsonConfigParser())
});
provider.LoadAllAsync(CancellationToken.None).GetAwaiter().GetResult();
Assert.That(provider.Get<PlayerConfig>(7).Name, Is.EqualTo("Starter"));
}
[Test]
public void LoadAllAsync_WhenSourceFails_IncludesSourceAndFileName()
{
var source = new InMemoryConfigSource(new Dictionary<string, string>(), "MemoryA");
var provider = new JsonConfigProvider(new[]
{
JsonConfigProvider.Mapping<PlayerConfig>("missing.json", source, new JsonConfigParser())
});
var exception = ThrowsAsync<InvalidOperationException>(
() => provider.LoadAllAsync(CancellationToken.None));
Assert.That(exception.Message, Does.Contain("MemoryA"));
Assert.That(exception.Message, Does.Contain("missing.json"));
}
[Test]
public void LoadAllAsync_WhenParserFindsDuplicateId_IncludesFileName()
{
var source = new InMemoryConfigSource(
new Dictionary<string, string>
{
["player.json"] = "[{\"id\":7,\"name\":\"A\"},{\"id\":7,\"name\":\"B\"}]"
});
var provider = new JsonConfigProvider(new[]
{
JsonConfigProvider.Mapping<PlayerConfig>("player.json", source, new JsonConfigParser())
});
var exception = ThrowsAsync<InvalidOperationException>(
() => provider.LoadAllAsync(CancellationToken.None));
Assert.That(exception.Message, Does.Contain("player.json"));
Assert.That(exception.Message, Does.Contain("7"));
}
private static TException ThrowsAsync<TException>(Func<Task> action)
where TException : Exception
{
try
{
action().GetAwaiter().GetResult();
}
catch (TException exception)
{
return exception;
}
Assert.Fail($"Expected {typeof(TException).Name}.");
return null;
}
private sealed class PlayerConfig : IConfigRow
{
public int id;
public string name;
public int Id => id;
public string Name => name;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 58935522df66446e93252f079376b603
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,69 @@
using System;
using FlowScope.Save;
using NUnit.Framework;
using R3;
namespace FlowScope.Tests.EditMode.Save
{
public sealed class SaveMigrationTests
{
[Test]
public void Deserialize_WhenEnvelopeVersionIsOld_AppliesMigrationChain()
{
var registry = new SaveMigrationRegistry();
registry.Register(new RenameCoinsToGoldMigration());
var serializer = new JsonSaveSerializer(currentVersion: 2, migrations: registry);
var oldPayload = "{\"Coins\":13}";
var envelope = "{\"Version\":1,\"Payload\":\"{\\\"Coins\\\":13}\"}";
var loaded = serializer.DeserializeFromString<PlayerSaveV2>(envelope);
Assert.That(oldPayload, Does.Contain("Coins"));
Assert.That(loaded.Gold.Value, Is.EqualTo(13));
}
[Test]
public void Deserialize_WhenMigrationIsMissing_ThrowsWithKeyAndVersion()
{
var serializer = new JsonSaveSerializer(currentVersion: 2, migrations: new SaveMigrationRegistry());
var envelope = "{\"Version\":1,\"Payload\":\"{\\\"Coins\\\":13}\"}";
var exception = Assert.Throws<InvalidOperationException>(
() => serializer.DeserializeFromString<PlayerSaveV2>(envelope));
Assert.That(exception.Message, Does.Contain(typeof(PlayerSaveV2).FullName));
Assert.That(exception.Message, Does.Contain("1"));
}
[Test]
public void Serialize_WritesEnvelopeWithCurrentVersion()
{
var serializer = new JsonSaveSerializer(currentVersion: 2);
var data = new PlayerSaveV2();
data.Gold.Value = 21;
var json = serializer.SerializeToString(data);
Assert.That(json, Does.Contain("\"Version\":2"));
Assert.That(json, Does.Contain("\"Payload\""));
Assert.That(json, Does.Contain("Gold"));
}
private sealed class RenameCoinsToGoldMigration : ISaveMigration
{
public string Key => typeof(PlayerSaveV2).FullName;
public int FromVersion => 1;
public int ToVersion => 2;
public string Migrate(string json)
{
return json.Replace("Coins", "Gold");
}
}
private sealed class PlayerSaveV2
{
public ReactiveProperty<int> Gold { get; set; } = new();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1e9fca0294774fed9d4532f3f1e5c58b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: