mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-07 00:39:00 +08:00
docs(config): 添加游戏内容配置系统完整文档
- 新增面向静态游戏内容的 AI-First 配表方案介绍 - 详细说明 YAML 作为配置源文件和 JSON Schema 结构描述功能 - 提供推荐目录结构和 Schema 示例配置 - 添加 VS Code 插件工具支持说明 - 包含 Godot 文本配置桥接使用指南 - 提供运行时读取和热重载模板示例 - 说明生成器接入约定和运行时校验行为 - 添加开发期热重载和工具支持详细说明 - 创建 Godot 测试项目配置文件 - 实现 GodotYamlConfigLoader 配置加载适配层
This commit is contained in:
parent
39e3ecfe46
commit
40f5fd34b7
286
GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs
Normal file
286
GFramework.Godot.Tests/Config/GodotYamlConfigLoaderTests.cs
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using GFramework.Godot.Config;
|
||||||
|
|
||||||
|
namespace GFramework.Godot.Tests.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证 Godot YAML 配置加载器能够在编辑器态直读项目目录,并在导出态同步运行时缓存。
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture]
|
||||||
|
public sealed class GodotYamlConfigLoaderTests
|
||||||
|
{
|
||||||
|
private string _resourceRoot = null!;
|
||||||
|
private string _testRoot = null!;
|
||||||
|
private string _userRoot = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 为每个测试准备独立的资源根目录与用户目录。
|
||||||
|
/// </summary>
|
||||||
|
[SetUp]
|
||||||
|
public void SetUp()
|
||||||
|
{
|
||||||
|
_testRoot = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"GFramework.GodotYamlConfigLoaderTests",
|
||||||
|
Guid.NewGuid().ToString("N"));
|
||||||
|
_resourceRoot = Path.Combine(_testRoot, "res-root");
|
||||||
|
_userRoot = Path.Combine(_testRoot, "user-root");
|
||||||
|
Directory.CreateDirectory(_resourceRoot);
|
||||||
|
Directory.CreateDirectory(_userRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 清理测试期间创建的临时目录。
|
||||||
|
/// </summary>
|
||||||
|
[TearDown]
|
||||||
|
public void TearDown()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_testRoot))
|
||||||
|
{
|
||||||
|
Directory.Delete(_testRoot, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证导出态会把注册过的 YAML 与 schema 文本同步到运行时缓存,再交给底层加载器。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task LoadAsync_Should_Copy_Registered_Text_Assets_Into_Runtime_Cache_When_Source_Is_Res_Path()
|
||||||
|
{
|
||||||
|
CreateMonsterFiles(_resourceRoot);
|
||||||
|
|
||||||
|
var loader = CreateLoader(isEditor: false);
|
||||||
|
var registry = new ConfigRegistry();
|
||||||
|
|
||||||
|
await loader.LoadAsync(registry);
|
||||||
|
|
||||||
|
var table = registry.GetTable<int, MonsterConfigStub>("monster");
|
||||||
|
var cacheRoot = Path.Combine(_userRoot, "config_cache");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(loader.CanEnableHotReload, Is.False);
|
||||||
|
Assert.That(loader.LoaderRootPath, Is.EqualTo(cacheRoot));
|
||||||
|
Assert.That(table.Count, Is.EqualTo(2));
|
||||||
|
Assert.That(table.Get(1).Name, Is.EqualTo("Slime"));
|
||||||
|
Assert.That(File.Exists(Path.Combine(cacheRoot, "monster", "slime.yaml")), Is.True);
|
||||||
|
Assert.That(File.Exists(Path.Combine(cacheRoot, "monster", "goblin.yml")), Is.True);
|
||||||
|
Assert.That(File.Exists(Path.Combine(cacheRoot, "schemas", "monster.schema.json")), Is.True);
|
||||||
|
Assert.That(File.Exists(Path.Combine(cacheRoot, "monster", "notes.txt")), Is.False);
|
||||||
|
Assert.That(Directory.Exists(Path.Combine(cacheRoot, "monster", "nested")), Is.False);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证编辑器态会直接使用全局化后的项目目录,而不会额外创建运行时缓存副本。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task LoadAsync_Should_Use_Globalized_Res_Directory_Directly_When_Running_In_Editor()
|
||||||
|
{
|
||||||
|
CreateMonsterFiles(_resourceRoot);
|
||||||
|
|
||||||
|
var loader = CreateLoader(isEditor: true);
|
||||||
|
var registry = new ConfigRegistry();
|
||||||
|
|
||||||
|
await loader.LoadAsync(registry);
|
||||||
|
|
||||||
|
var table = registry.GetTable<int, MonsterConfigStub>("monster");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(loader.CanEnableHotReload, Is.True);
|
||||||
|
Assert.That(loader.LoaderRootPath, Is.EqualTo(_resourceRoot));
|
||||||
|
Assert.That(table.Count, Is.EqualTo(2));
|
||||||
|
Assert.That(table.Get(2).Hp, Is.EqualTo(30));
|
||||||
|
Assert.That(Directory.Exists(Path.Combine(_userRoot, "config_cache")), Is.False);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证当实例必须依赖运行时缓存时,不允许再直接启用底层文件热重载。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void EnableHotReload_Should_Throw_When_Source_Root_Cannot_Be_Used_Directly()
|
||||||
|
{
|
||||||
|
var loader = CreateLoader(isEditor: false);
|
||||||
|
|
||||||
|
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
loader.EnableHotReload(new ConfigRegistry()));
|
||||||
|
|
||||||
|
Assert.That(exception!.Message, Does.Contain("Hot reload"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建一个基于临时目录映射的 Godot YAML 配置加载器。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="isEditor">是否模拟编辑器环境。</param>
|
||||||
|
/// <returns>已配置好的加载器实例。</returns>
|
||||||
|
private GodotYamlConfigLoader CreateLoader(bool isEditor)
|
||||||
|
{
|
||||||
|
return new GodotYamlConfigLoader(
|
||||||
|
new GodotYamlConfigLoaderOptions
|
||||||
|
{
|
||||||
|
SourceRootPath = "res://",
|
||||||
|
RuntimeCacheRootPath = "user://config_cache",
|
||||||
|
TableSources =
|
||||||
|
[
|
||||||
|
new GodotYamlConfigTableSource(
|
||||||
|
"monster",
|
||||||
|
"monster",
|
||||||
|
"schemas/monster.schema.json")
|
||||||
|
],
|
||||||
|
ConfigureLoader = static loader =>
|
||||||
|
loader.RegisterTable<int, MonsterConfigStub>(
|
||||||
|
"monster",
|
||||||
|
"monster",
|
||||||
|
"schemas/monster.schema.json",
|
||||||
|
static config => config.Id)
|
||||||
|
},
|
||||||
|
CreateEnvironment(isEditor));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建一个把 <c>res://</c> 与 <c>user://</c> 映射到临时目录的测试环境。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="isEditor">是否模拟编辑器环境。</param>
|
||||||
|
/// <returns>测试专用环境对象。</returns>
|
||||||
|
private GodotYamlConfigEnvironment CreateEnvironment(bool isEditor)
|
||||||
|
{
|
||||||
|
return new GodotYamlConfigEnvironment(
|
||||||
|
() => isEditor,
|
||||||
|
path => MapGodotPath(path),
|
||||||
|
path =>
|
||||||
|
{
|
||||||
|
var absolutePath = MapGodotPath(path);
|
||||||
|
if (!Directory.Exists(absolutePath))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Directory
|
||||||
|
.EnumerateFileSystemEntries(absolutePath, "*", SearchOption.TopDirectoryOnly)
|
||||||
|
.Select(static entryPath => new GodotYamlConfigDirectoryEntry(
|
||||||
|
Path.GetFileName(entryPath),
|
||||||
|
Directory.Exists(entryPath)))
|
||||||
|
.ToArray();
|
||||||
|
},
|
||||||
|
path => File.Exists(MapGodotPath(path)),
|
||||||
|
path => File.ReadAllBytes(MapGodotPath(path)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建一组最小可运行的 monster YAML 与 schema 文件。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="rootPath">目标根目录。</param>
|
||||||
|
private static void CreateMonsterFiles(string rootPath)
|
||||||
|
{
|
||||||
|
WriteFile(
|
||||||
|
rootPath,
|
||||||
|
"schemas/monster.schema.json",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "name", "hp"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"hp": { "type": "integer" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
WriteFile(
|
||||||
|
rootPath,
|
||||||
|
"monster/slime.yaml",
|
||||||
|
"""
|
||||||
|
id: 1
|
||||||
|
name: Slime
|
||||||
|
hp: 10
|
||||||
|
""");
|
||||||
|
WriteFile(
|
||||||
|
rootPath,
|
||||||
|
"monster/goblin.yml",
|
||||||
|
"""
|
||||||
|
id: 2
|
||||||
|
name: Goblin
|
||||||
|
hp: 30
|
||||||
|
""");
|
||||||
|
WriteFile(
|
||||||
|
rootPath,
|
||||||
|
"monster/notes.txt",
|
||||||
|
"ignored");
|
||||||
|
WriteFile(
|
||||||
|
rootPath,
|
||||||
|
"monster/nested/ghost.yaml",
|
||||||
|
"""
|
||||||
|
id: 3
|
||||||
|
name: Ghost
|
||||||
|
hp: 99
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把逻辑相对路径写入指定根目录。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="rootPath">目标根目录。</param>
|
||||||
|
/// <param name="relativePath">相对文件路径。</param>
|
||||||
|
/// <param name="content">文件内容。</param>
|
||||||
|
private static void WriteFile(string rootPath, string relativePath, string content)
|
||||||
|
{
|
||||||
|
var fullPath = Path.Combine(rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
var directoryPath = Path.GetDirectoryName(fullPath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(directoryPath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directoryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.WriteAllText(fullPath, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将测试中的 Godot 路径映射到本地临时目录。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Godot 路径或普通路径。</param>
|
||||||
|
/// <returns>映射后的绝对路径。</returns>
|
||||||
|
private string MapGodotPath(string path)
|
||||||
|
{
|
||||||
|
if (path.StartsWith("res://", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return Path.Combine(
|
||||||
|
_resourceRoot,
|
||||||
|
path["res://".Length..].Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.StartsWith("user://", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return Path.Combine(
|
||||||
|
_userRoot,
|
||||||
|
path["user://".Length..].Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最小 monster 配置桩类型。
|
||||||
|
/// </summary>
|
||||||
|
private sealed class MonsterConfigStub
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 主键。
|
||||||
|
/// </summary>
|
||||||
|
public int Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 生命值。
|
||||||
|
/// </summary>
|
||||||
|
public int Hp { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,6 +18,8 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\GFramework.Godot\GFramework.Godot.csproj"/>
|
<ProjectReference Include="..\GFramework.Godot\GFramework.Godot.csproj"/>
|
||||||
|
<ProjectReference Include="..\GFramework.Game.Abstractions\GFramework.Game.Abstractions.csproj"/>
|
||||||
|
<ProjectReference Include="..\GFramework.Core.Abstractions\GFramework.Core.Abstractions.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
468
GFramework.Godot/Config/GodotYamlConfigLoader.cs
Normal file
468
GFramework.Godot/Config/GodotYamlConfigLoader.cs
Normal file
@ -0,0 +1,468 @@
|
|||||||
|
using System.IO;
|
||||||
|
using GFramework.Core.Abstractions.Events;
|
||||||
|
using GFramework.Game.Abstractions.Config;
|
||||||
|
using GFramework.Game.Config;
|
||||||
|
using GFramework.Godot.Extensions;
|
||||||
|
|
||||||
|
namespace GFramework.Godot.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 为 Godot 运行时提供 YAML 配置加载适配层。
|
||||||
|
/// 编辑器态优先直接把项目目录交给 <see cref="YamlConfigLoader" />,
|
||||||
|
/// 导出态则把显式声明的 YAML 与 schema 文本同步到运行时缓存目录后再加载。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GodotYamlConfigLoader : IConfigLoader
|
||||||
|
{
|
||||||
|
private readonly GodotYamlConfigEnvironment _environment;
|
||||||
|
private readonly YamlConfigLoader _loader;
|
||||||
|
private readonly GodotYamlConfigLoaderOptions _options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 使用指定选项创建一个 Godot YAML 配置加载器。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">加载器初始化选项。</param>
|
||||||
|
public GodotYamlConfigLoader(GodotYamlConfigLoaderOptions options)
|
||||||
|
: this(options, GodotYamlConfigEnvironment.Default)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal GodotYamlConfigLoader(
|
||||||
|
GodotYamlConfigLoaderOptions options,
|
||||||
|
GodotYamlConfigEnvironment environment)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
ArgumentNullException.ThrowIfNull(environment);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(options.SourceRootPath))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("SourceRootPath cannot be null or whitespace.", nameof(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(options.RuntimeCacheRootPath))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("RuntimeCacheRootPath cannot be null or whitespace.", nameof(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
_options = options;
|
||||||
|
_environment = environment;
|
||||||
|
LoaderRootPath = ResolveLoaderRootPath();
|
||||||
|
_loader = new YamlConfigLoader(LoaderRootPath);
|
||||||
|
options.ConfigureLoader?.Invoke(_loader);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取配置源根目录。
|
||||||
|
/// </summary>
|
||||||
|
public string SourceRootPath => _options.SourceRootPath;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取运行时缓存根目录。
|
||||||
|
/// </summary>
|
||||||
|
public string RuntimeCacheRootPath => _options.RuntimeCacheRootPath;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取底层 <see cref="YamlConfigLoader" /> 实际使用的普通文件系统根目录。
|
||||||
|
/// </summary>
|
||||||
|
public string LoaderRootPath { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取底层 <see cref="YamlConfigLoader" /> 实例。
|
||||||
|
/// 调用方可继续在该实例上追加注册表定义或读取注册数量。
|
||||||
|
/// </summary>
|
||||||
|
public YamlConfigLoader Loader => _loader;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取一个值,指示当前实例是否可直接针对源目录启用热重载。
|
||||||
|
/// </summary>
|
||||||
|
public bool CanEnableHotReload => UsesSourceDirectoryDirectly(SourceRootPath);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task LoadAsync(IConfigRegistry registry, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(registry);
|
||||||
|
|
||||||
|
if (!CanEnableHotReload)
|
||||||
|
{
|
||||||
|
SynchronizeRuntimeCache(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _loader.LoadAsync(registry, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 在当前环境允许的情况下启用底层 YAML 热重载。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="registry">要被热重载更新的配置注册表。</param>
|
||||||
|
/// <param name="options">热重载选项;为空时使用默认值。</param>
|
||||||
|
/// <returns>用于停止监听的注销句柄。</returns>
|
||||||
|
public IUnRegister EnableHotReload(
|
||||||
|
IConfigRegistry registry,
|
||||||
|
YamlConfigHotReloadOptions? options = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(registry);
|
||||||
|
|
||||||
|
if (!CanEnableHotReload)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Hot reload is only available when the source root can be accessed as a normal filesystem directory.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return _loader.EnableHotReload(registry, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveLoaderRootPath()
|
||||||
|
{
|
||||||
|
if (UsesSourceDirectoryDirectly(SourceRootPath))
|
||||||
|
{
|
||||||
|
return EnsureAbsolutePath(SourceRootPath, nameof(GodotYamlConfigLoaderOptions.SourceRootPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
return EnsureAbsolutePath(RuntimeCacheRootPath, nameof(GodotYamlConfigLoaderOptions.RuntimeCacheRootPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool UsesSourceDirectoryDirectly(string sourceRootPath)
|
||||||
|
{
|
||||||
|
if (!sourceRootPath.IsGodotPath())
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceRootPath.IsUserPath())
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sourceRootPath.IsResPath() && _environment.IsEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SynchronizeRuntimeCache(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (var group in _options.TableSources
|
||||||
|
.GroupBy(static source => NormalizeRelativePath(source.ConfigRelativePath),
|
||||||
|
StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var representative = group.First();
|
||||||
|
var sourceDirectoryPath = CombinePath(SourceRootPath, representative.ConfigRelativePath);
|
||||||
|
var targetDirectoryPath = CombineAbsolutePath(LoaderRootPath, representative.ConfigRelativePath);
|
||||||
|
|
||||||
|
ResetDirectory(targetDirectoryPath);
|
||||||
|
CopyYamlFilesInDirectory(
|
||||||
|
representative.TableName,
|
||||||
|
sourceDirectoryPath,
|
||||||
|
targetDirectoryPath,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var group in _options.TableSources
|
||||||
|
.Where(static source => !string.IsNullOrEmpty(source.SchemaRelativePath))
|
||||||
|
.GroupBy(static source => NormalizeRelativePath(source.SchemaRelativePath!),
|
||||||
|
StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var representative = group.First();
|
||||||
|
var sourceSchemaPath = CombinePath(SourceRootPath, representative.SchemaRelativePath!);
|
||||||
|
var targetSchemaPath = CombineAbsolutePath(LoaderRootPath, representative.SchemaRelativePath!);
|
||||||
|
|
||||||
|
CopySingleFile(
|
||||||
|
representative.TableName,
|
||||||
|
sourceSchemaPath,
|
||||||
|
targetSchemaPath,
|
||||||
|
ConfigLoadFailureKind.SchemaFileNotFound,
|
||||||
|
ConfigLoadFailureKind.SchemaReadFailed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CopyYamlFilesInDirectory(
|
||||||
|
string tableName,
|
||||||
|
string sourceDirectoryPath,
|
||||||
|
string targetDirectoryPath,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entries = _environment.EnumerateDirectory(sourceDirectoryPath);
|
||||||
|
if (entries == null)
|
||||||
|
{
|
||||||
|
throw CreateConfigLoadException(
|
||||||
|
ConfigLoadFailureKind.ConfigDirectoryNotFound,
|
||||||
|
tableName,
|
||||||
|
$"Config directory '{DescribePath(sourceDirectoryPath)}' was not found while preparing the Godot runtime cache.",
|
||||||
|
configDirectoryPath: DescribePath(sourceDirectoryPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(targetDirectoryPath);
|
||||||
|
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (entry.IsDirectory || entry.Name is "." or ".." || entry.Name.StartsWith(".", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsYamlFile(entry.Name))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceFilePath = CombinePath(sourceDirectoryPath, entry.Name);
|
||||||
|
var targetFilePath = Path.Combine(targetDirectoryPath, entry.Name);
|
||||||
|
CopySingleFile(
|
||||||
|
tableName,
|
||||||
|
sourceFilePath,
|
||||||
|
targetFilePath,
|
||||||
|
ConfigLoadFailureKind.ConfigFileReadFailed,
|
||||||
|
ConfigLoadFailureKind.ConfigFileReadFailed,
|
||||||
|
configDirectoryPath: DescribePath(sourceDirectoryPath),
|
||||||
|
yamlPath: DescribePath(sourceFilePath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CopySingleFile(
|
||||||
|
string tableName,
|
||||||
|
string sourceFilePath,
|
||||||
|
string targetAbsolutePath,
|
||||||
|
ConfigLoadFailureKind missingFailureKind,
|
||||||
|
ConfigLoadFailureKind readFailureKind,
|
||||||
|
string? configDirectoryPath = null,
|
||||||
|
string? yamlPath = null)
|
||||||
|
{
|
||||||
|
if (!_environment.FileExists(sourceFilePath))
|
||||||
|
{
|
||||||
|
var missingMessage = missingFailureKind == ConfigLoadFailureKind.SchemaFileNotFound
|
||||||
|
? $"Schema file '{DescribePath(sourceFilePath)}' was not found while preparing the Godot runtime cache."
|
||||||
|
: $"Config file '{DescribePath(sourceFilePath)}' was not found while preparing the Godot runtime cache.";
|
||||||
|
|
||||||
|
throw CreateConfigLoadException(
|
||||||
|
missingFailureKind,
|
||||||
|
tableName,
|
||||||
|
missingMessage,
|
||||||
|
configDirectoryPath: configDirectoryPath,
|
||||||
|
yamlPath: missingFailureKind == ConfigLoadFailureKind.SchemaFileNotFound
|
||||||
|
? null
|
||||||
|
: yamlPath ?? DescribePath(sourceFilePath),
|
||||||
|
schemaPath: missingFailureKind == ConfigLoadFailureKind.SchemaFileNotFound
|
||||||
|
? DescribePath(sourceFilePath)
|
||||||
|
: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parentDirectory = Path.GetDirectoryName(targetAbsolutePath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(parentDirectory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(parentDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.WriteAllBytes(targetAbsolutePath, _environment.ReadAllBytes(sourceFilePath));
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
var readMessage = readFailureKind == ConfigLoadFailureKind.SchemaReadFailed
|
||||||
|
? $"Failed to copy schema file '{DescribePath(sourceFilePath)}' into the Godot runtime cache."
|
||||||
|
: $"Failed to copy config file '{DescribePath(sourceFilePath)}' into the Godot runtime cache.";
|
||||||
|
|
||||||
|
throw CreateConfigLoadException(
|
||||||
|
readFailureKind,
|
||||||
|
tableName,
|
||||||
|
readMessage,
|
||||||
|
configDirectoryPath: configDirectoryPath,
|
||||||
|
yamlPath: readFailureKind == ConfigLoadFailureKind.SchemaReadFailed
|
||||||
|
? null
|
||||||
|
: yamlPath ?? DescribePath(sourceFilePath),
|
||||||
|
schemaPath: readFailureKind == ConfigLoadFailureKind.SchemaReadFailed
|
||||||
|
? DescribePath(sourceFilePath)
|
||||||
|
: null,
|
||||||
|
innerException: exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetDirectory(string directoryPath)
|
||||||
|
{
|
||||||
|
if (Directory.Exists(directoryPath))
|
||||||
|
{
|
||||||
|
Directory.Delete(directoryPath, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(directoryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string EnsureAbsolutePath(string path, string optionName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Path cannot be null or whitespace.", optionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.IsGodotPath())
|
||||||
|
{
|
||||||
|
var absolutePath = _environment.GlobalizePath(path);
|
||||||
|
if (string.IsNullOrWhiteSpace(absolutePath))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Path option '{optionName}' resolved to an empty absolute path. Value='{path}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return absolutePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.GetFullPath(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string DescribePath(string path)
|
||||||
|
{
|
||||||
|
if (path.IsGodotPath())
|
||||||
|
{
|
||||||
|
var absolutePath = _environment.GlobalizePath(path);
|
||||||
|
return string.IsNullOrWhiteSpace(absolutePath) ? path : absolutePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.GetFullPath(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CombinePath(string rootPath, string relativePath)
|
||||||
|
{
|
||||||
|
var normalizedRelativePath = NormalizeRelativePath(relativePath);
|
||||||
|
if (rootPath.IsGodotPath())
|
||||||
|
{
|
||||||
|
if (rootPath.EndsWith("://", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return $"{rootPath}{normalizedRelativePath}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{rootPath.TrimEnd('/')}/{normalizedRelativePath}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(rootPath, normalizedRelativePath.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CombineAbsolutePath(string rootPath, string relativePath)
|
||||||
|
{
|
||||||
|
return Path.Combine(rootPath, NormalizeRelativePath(relativePath).Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeRelativePath(string relativePath)
|
||||||
|
{
|
||||||
|
return relativePath.Replace('\\', '/').TrimStart('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsYamlFile(string fileName)
|
||||||
|
{
|
||||||
|
return fileName.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
fileName.EndsWith(".yml", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ConfigLoadException CreateConfigLoadException(
|
||||||
|
ConfigLoadFailureKind failureKind,
|
||||||
|
string tableName,
|
||||||
|
string message,
|
||||||
|
string? configDirectoryPath = null,
|
||||||
|
string? yamlPath = null,
|
||||||
|
string? schemaPath = null,
|
||||||
|
Exception? innerException = null)
|
||||||
|
{
|
||||||
|
return new ConfigLoadException(
|
||||||
|
new ConfigLoadDiagnostic(
|
||||||
|
failureKind,
|
||||||
|
tableName,
|
||||||
|
configDirectoryPath: configDirectoryPath,
|
||||||
|
yamlPath: yamlPath,
|
||||||
|
schemaPath: schemaPath),
|
||||||
|
message,
|
||||||
|
innerException);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class GodotYamlConfigEnvironment
|
||||||
|
{
|
||||||
|
public GodotYamlConfigEnvironment(
|
||||||
|
Func<bool> isEditor,
|
||||||
|
Func<string, string> globalizePath,
|
||||||
|
Func<string, IReadOnlyList<GodotYamlConfigDirectoryEntry>?> enumerateDirectory,
|
||||||
|
Func<string, bool> fileExists,
|
||||||
|
Func<string, byte[]> readAllBytes)
|
||||||
|
{
|
||||||
|
IsEditor = isEditor ?? throw new ArgumentNullException(nameof(isEditor));
|
||||||
|
GlobalizePath = globalizePath ?? throw new ArgumentNullException(nameof(globalizePath));
|
||||||
|
EnumerateDirectory = enumerateDirectory ?? throw new ArgumentNullException(nameof(enumerateDirectory));
|
||||||
|
FileExists = fileExists ?? throw new ArgumentNullException(nameof(fileExists));
|
||||||
|
ReadAllBytes = readAllBytes ?? throw new ArgumentNullException(nameof(readAllBytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GodotYamlConfigEnvironment Default { get; } = new(
|
||||||
|
static () => OS.HasFeature("editor"),
|
||||||
|
static path => ProjectSettings.GlobalizePath(path),
|
||||||
|
EnumerateDirectoryCore,
|
||||||
|
FileExistsCore,
|
||||||
|
ReadAllBytesCore);
|
||||||
|
|
||||||
|
public Func<bool> IsEditor { get; }
|
||||||
|
|
||||||
|
public Func<string, string> GlobalizePath { get; }
|
||||||
|
|
||||||
|
public Func<string, IReadOnlyList<GodotYamlConfigDirectoryEntry>?> EnumerateDirectory { get; }
|
||||||
|
|
||||||
|
public Func<string, bool> FileExists { get; }
|
||||||
|
|
||||||
|
public Func<string, byte[]> ReadAllBytes { get; }
|
||||||
|
|
||||||
|
private static IReadOnlyList<GodotYamlConfigDirectoryEntry>? EnumerateDirectoryCore(string path)
|
||||||
|
{
|
||||||
|
if (!path.IsGodotPath())
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(path))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Directory
|
||||||
|
.EnumerateFileSystemEntries(path, "*", SearchOption.TopDirectoryOnly)
|
||||||
|
.Select(static entryPath => new GodotYamlConfigDirectoryEntry(
|
||||||
|
Path.GetFileName(entryPath),
|
||||||
|
Directory.Exists(entryPath)))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var directory = DirAccess.Open(path);
|
||||||
|
if (directory == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries = new List<GodotYamlConfigDirectoryEntry>();
|
||||||
|
directory.ListDirBegin();
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var name = directory.GetNext();
|
||||||
|
if (string.IsNullOrEmpty(name))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.Add(new GodotYamlConfigDirectoryEntry(name, directory.CurrentIsDir()));
|
||||||
|
}
|
||||||
|
|
||||||
|
directory.ListDirEnd();
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool FileExistsCore(string path)
|
||||||
|
{
|
||||||
|
return path.IsGodotPath()
|
||||||
|
? FileAccess.FileExists(path)
|
||||||
|
: File.Exists(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] ReadAllBytesCore(string path)
|
||||||
|
{
|
||||||
|
return path.IsGodotPath()
|
||||||
|
? FileAccess.GetFileAsBytes(path)
|
||||||
|
: File.ReadAllBytes(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct GodotYamlConfigDirectoryEntry(
|
||||||
|
string Name,
|
||||||
|
bool IsDirectory);
|
||||||
36
GFramework.Godot/Config/GodotYamlConfigLoaderOptions.cs
Normal file
36
GFramework.Godot/Config/GodotYamlConfigLoaderOptions.cs
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
using GFramework.Game.Config;
|
||||||
|
|
||||||
|
namespace GFramework.Godot.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 描述 Godot YAML 配置加载器的初始化约定。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GodotYamlConfigLoaderOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置配置源根目录。
|
||||||
|
/// 默认值为 <c>res://</c>,表示从项目资源路径读取 YAML 与 schema 文本。
|
||||||
|
/// </summary>
|
||||||
|
public string SourceRootPath { get; init; } = "res://";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置运行时缓存根目录。
|
||||||
|
/// 当 <see cref="SourceRootPath" /> 在当前环境下无法直接映射为普通文件系统目录时,
|
||||||
|
/// 加载器会先把所需文本资产复制到这里,再交给底层 <see cref="YamlConfigLoader" />。
|
||||||
|
/// </summary>
|
||||||
|
public string RuntimeCacheRootPath { get; init; } = "user://config_cache";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置本次启动会访问到的配置表来源描述。
|
||||||
|
/// Godot 导出态无法假设任意文本目录都可被枚举,因此调用方应显式提供参与本轮加载的配置目录与 schema 文件。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyCollection<GodotYamlConfigTableSource> TableSources { get; init; } =
|
||||||
|
Array.Empty<GodotYamlConfigTableSource>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置用于配置底层 <see cref="YamlConfigLoader" /> 的回调。
|
||||||
|
/// 调用方通常应在这里调用生成器产出的 <c>RegisterAllGeneratedConfigTables()</c>,
|
||||||
|
/// 或显式注册当前场景所需的手写表定义。
|
||||||
|
/// </summary>
|
||||||
|
public Action<YamlConfigLoader>? ConfigureLoader { get; init; }
|
||||||
|
}
|
||||||
56
GFramework.Godot/Config/GodotYamlConfigTableSource.cs
Normal file
56
GFramework.Godot/Config/GodotYamlConfigTableSource.cs
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
namespace GFramework.Godot.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 描述一个 Godot YAML 配置表在资源目录中的来源信息。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GodotYamlConfigTableSource
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化一个配置表来源描述。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tableName">运行时表名称。</param>
|
||||||
|
/// <param name="configRelativePath">相对配置根目录的 YAML 目录。</param>
|
||||||
|
/// <param name="schemaRelativePath">相对配置根目录的 schema 文件路径;未启用 schema 时为空。</param>
|
||||||
|
public GodotYamlConfigTableSource(
|
||||||
|
string tableName,
|
||||||
|
string configRelativePath,
|
||||||
|
string? schemaRelativePath = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tableName))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Table name cannot be null or whitespace.", nameof(tableName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(configRelativePath))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Config relative path cannot be null or whitespace.",
|
||||||
|
nameof(configRelativePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schemaRelativePath != null && string.IsNullOrWhiteSpace(schemaRelativePath))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"Schema relative path cannot be empty or whitespace when provided.",
|
||||||
|
nameof(schemaRelativePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
TableName = tableName;
|
||||||
|
ConfigRelativePath = configRelativePath;
|
||||||
|
SchemaRelativePath = schemaRelativePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取运行时表名称。
|
||||||
|
/// </summary>
|
||||||
|
public string TableName { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取相对配置根目录的 YAML 目录路径。
|
||||||
|
/// </summary>
|
||||||
|
public string ConfigRelativePath { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取相对配置根目录的 schema 文件路径;未启用 schema 校验时为空。
|
||||||
|
/// </summary>
|
||||||
|
public string? SchemaRelativePath { get; }
|
||||||
|
}
|
||||||
3
GFramework.Godot/Properties/AssemblyInfo.cs
Normal file
3
GFramework.Godot/Properties/AssemblyInfo.cs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("GFramework.Godot.Tests")]
|
||||||
@ -299,6 +299,63 @@ public sealed class GameConfigHost : IDisposable
|
|||||||
- `InitializeAsync()` 只在首次加载完整成功后才公开运行时状态,避免半初始化对象泄漏到业务层
|
- `InitializeAsync()` 只在首次加载完整成功后才公开运行时状态,避免半初始化对象泄漏到业务层
|
||||||
- 热重载既可以在初始化时自动启用,也可以在初次加载后显式调用 `StartHotReload(...)`
|
- 热重载既可以在初始化时自动启用,也可以在初次加载后显式调用 `StartHotReload(...)`
|
||||||
|
|
||||||
|
### Godot 文本配置桥接
|
||||||
|
|
||||||
|
如果你的项目运行在 Godot,并且 YAML / schema 文本来自 `res://` 下的原始资源文件,推荐优先使用
|
||||||
|
`GFramework.Godot.Config.GodotYamlConfigLoader`,而不是在项目侧手写一层
|
||||||
|
“`res://` 遍历 + `user://` 缓存 + `YamlConfigLoader`”桥接代码。
|
||||||
|
|
||||||
|
原因很简单:
|
||||||
|
|
||||||
|
- `YamlConfigLoader` 需要普通文件系统根目录
|
||||||
|
- Godot 编辑器内的 `res://` 可以全局化到项目目录
|
||||||
|
- Godot 导出后若仍读取原始文本资产,通常需要先把显式声明的 YAML / schema 文件同步到运行时缓存目录
|
||||||
|
|
||||||
|
`GodotYamlConfigLoader` 会按环境自动处理这两条路径:
|
||||||
|
|
||||||
|
- 编辑器态:直接把 `ProjectSettings.GlobalizePath("res://...")` 交给底层 `YamlConfigLoader`
|
||||||
|
- 导出态:把当前注册会访问到的配置目录与 schema 文件同步到 `user://` 缓存,再交给底层 `YamlConfigLoader`
|
||||||
|
|
||||||
|
推荐搭配生成器元数据一起使用,这样项目不需要再自己维护一份重复的配置目录清单:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using GFramework.Game.Abstractions.Config;
|
||||||
|
using GFramework.Game.Config;
|
||||||
|
using GFramework.Game.Config.Generated;
|
||||||
|
using GFramework.Godot.Config;
|
||||||
|
|
||||||
|
var registrationOptions = new GeneratedConfigRegistrationOptions
|
||||||
|
{
|
||||||
|
IncludedConfigDomains = new[] { "gameplay", "ui" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var tableSources = GeneratedConfigCatalog
|
||||||
|
.GetTablesForRegistration(registrationOptions)
|
||||||
|
.Select(static metadata => new GodotYamlConfigTableSource(
|
||||||
|
metadata.TableName,
|
||||||
|
metadata.ConfigRelativePath,
|
||||||
|
metadata.SchemaRelativePath))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var loader = new GodotYamlConfigLoader(
|
||||||
|
new GodotYamlConfigLoaderOptions
|
||||||
|
{
|
||||||
|
SourceRootPath = "res://",
|
||||||
|
RuntimeCacheRootPath = "user://config_cache",
|
||||||
|
TableSources = tableSources,
|
||||||
|
ConfigureLoader = yamlLoader => yamlLoader.RegisterAllGeneratedConfigTables(registrationOptions)
|
||||||
|
});
|
||||||
|
|
||||||
|
var registry = new ConfigRegistry();
|
||||||
|
await loader.LoadAsync(registry);
|
||||||
|
```
|
||||||
|
|
||||||
|
使用这条路径时,还需要注意两点:
|
||||||
|
|
||||||
|
- 导出预设必须显式包含 `.yaml`、`.yml`、`.json`、`.schema.json` 等原始文本资产;否则导出包里根本没有这些文件,任何加载器都无法读取
|
||||||
|
- 只有当源根目录可直接映射到普通文件系统目录时,`EnableHotReload(...)` 才可用;如果当前实例依赖 `user://`
|
||||||
|
缓存,热重载会被拒绝,而不是制造“监听了缓存目录却不反映真实源目录”的假象
|
||||||
|
|
||||||
### 运行时读取模板
|
### 运行时读取模板
|
||||||
|
|
||||||
推荐不要在业务代码里直接散落字符串表名查询,而是统一依赖生成的强类型入口:
|
推荐不要在业务代码里直接散落字符串表名查询,而是统一依赖生成的强类型入口:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user