mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-07 00:39:00 +08:00
docs(config): 添加游戏内容配置系统完整文档
- 新增配置系统概述和核心能力介绍 - 添加Schema和YAML配置文件格式示例 - 提供推荐目录结构和接入模板 - 详细说明Generator集成和运行时加载流程 - 介绍VS Code工具和热重载功能 - 添加Godot引擎桥接适配器文档 - 说明运行时校验行为和错误处理机制 - 提供Architecture模块集成模板 - 记录当前限制和未来规划评估
This commit is contained in:
parent
0ea3c0ad9d
commit
411d4cb14a
@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using GFramework.Godot.Config;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GFramework.Godot.Tests.Config;
|
||||
|
||||
/// <summary>
|
||||
/// 验证 Godot YAML 配置表来源描述会拒绝可能逃逸缓存根目录的不安全相对路径。
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class GodotYamlConfigTableSourceTests
|
||||
{
|
||||
/// <summary>
|
||||
/// 验证配置目录路径必须保持为无根、无遍历段的安全相对路径。
|
||||
/// </summary>
|
||||
/// <param name="configRelativePath">待验证的配置目录路径。</param>
|
||||
[TestCase("../outside")]
|
||||
[TestCase("./monster")]
|
||||
[TestCase("monster/../outside")]
|
||||
[TestCase("monster/./child")]
|
||||
[TestCase("/monster")]
|
||||
[TestCase("C:/monster")]
|
||||
[TestCase("res://monster")]
|
||||
[TestCase("user://monster")]
|
||||
public void Constructor_Should_Throw_When_Config_Relative_Path_Is_Not_Safe(string configRelativePath)
|
||||
{
|
||||
var exception = Assert.Throws<ArgumentException>(() =>
|
||||
_ = new GodotYamlConfigTableSource("monster", configRelativePath));
|
||||
|
||||
Assert.That(exception!.ParamName, Is.EqualTo("configRelativePath"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 schema 路径在提供时也必须满足同样的安全相对路径约束。
|
||||
/// </summary>
|
||||
/// <param name="schemaRelativePath">待验证的 schema 路径。</param>
|
||||
[TestCase("../schemas/monster.schema.json")]
|
||||
[TestCase("./schemas/monster.schema.json")]
|
||||
[TestCase("schemas/../monster.schema.json")]
|
||||
[TestCase("schemas/./monster.schema.json")]
|
||||
[TestCase("/schemas/monster.schema.json")]
|
||||
[TestCase("C:/schemas/monster.schema.json")]
|
||||
[TestCase("res://schemas/monster.schema.json")]
|
||||
[TestCase("user://schemas/monster.schema.json")]
|
||||
public void Constructor_Should_Throw_When_Schema_Relative_Path_Is_Not_Safe(string schemaRelativePath)
|
||||
{
|
||||
var exception = Assert.Throws<ArgumentException>(() =>
|
||||
_ = new GodotYamlConfigTableSource("monster", "monster", schemaRelativePath));
|
||||
|
||||
Assert.That(exception!.ParamName, Is.EqualTo("schemaRelativePath"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证合法的相对目录和 schema 路径仍可正常构造元数据对象。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Constructor_Should_Accept_Safe_Relative_Paths()
|
||||
{
|
||||
var source = new GodotYamlConfigTableSource(
|
||||
"monster",
|
||||
"monster/configs",
|
||||
"schemas/monster.schema.json");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(source.TableName, Is.EqualTo("monster"));
|
||||
Assert.That(source.ConfigRelativePath, Is.EqualTo("monster/configs"));
|
||||
Assert.That(source.SchemaRelativePath, Is.EqualTo("schemas/monster.schema.json"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -27,6 +27,25 @@ public sealed class GodotYamlConfigLoader : IConfigLoader
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用指定选项和宿主环境抽象创建一个 Godot YAML 配置加载器。
|
||||
/// </summary>
|
||||
/// <param name="options">加载器初始化选项。</param>
|
||||
/// <param name="environment">
|
||||
/// 封装编辑器探测、Godot 路径全局化、目录枚举与文件读取行为的宿主环境抽象。
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// <paramref name="options" /> 或 <paramref name="environment" /> 为 <see langword="null" /> 时抛出。
|
||||
/// </exception>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// <see cref="GodotYamlConfigLoaderOptions.SourceRootPath" /> 或
|
||||
/// <see cref="GodotYamlConfigLoaderOptions.RuntimeCacheRootPath" /> 为空白字符串时抛出。
|
||||
/// </exception>
|
||||
/// <remarks>
|
||||
/// 该重载用于把与 Godot 引擎强耦合的环境行为收敛到可替换委托中。
|
||||
/// 编辑器态下,<c>res://</c> 可以被全局化后直接交给底层 <see cref="YamlConfigLoader" />;
|
||||
/// 导出态下,则需要先同步到 <c>user://</c> 缓存再切换到普通文件系统路径。
|
||||
/// </remarks>
|
||||
internal GodotYamlConfigLoader(
|
||||
GodotYamlConfigLoaderOptions options,
|
||||
GodotYamlConfigEnvironment environment)
|
||||
@ -376,8 +395,40 @@ public sealed class GodotYamlConfigLoader : IConfigLoader
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 抽象 <see cref="GodotYamlConfigLoader" /> 与具体宿主环境之间的 Godot 路径和文件访问边界。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该抽象存在的原因,是编辑器态与导出态对 <c>res://</c>、<c>user://</c> 的访问方式不同:
|
||||
/// 编辑器态通常可以把 Godot 特殊路径全局化后直接落到普通文件系统,而导出态往往只能通过 Godot API 读取原始文本资源,
|
||||
/// 再把它们复制到运行时缓存目录。<see cref="EnumerateDirectory" /> 在目录不存在或当前环境无法枚举时必须返回
|
||||
/// <see langword="null" />,用来表达“不可访问”而不是抛出未找到异常;<see cref="ReadAllBytes" /> 则应保留底层读取失败异常,
|
||||
/// 交由加载器包装成配置诊断。对于普通文件系统路径,应遵循 <see cref="Directory" /> / <see cref="File" /> 语义;
|
||||
/// 对于 Godot 特殊路径,则应使用引擎提供的路径解析和读取能力。
|
||||
/// </remarks>
|
||||
internal sealed class GodotYamlConfigEnvironment
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化一个可替换的 Godot YAML 配置宿主环境抽象。
|
||||
/// </summary>
|
||||
/// <param name="isEditor">返回当前进程是否处于 Godot 编辑器态的委托。</param>
|
||||
/// <param name="globalizePath">
|
||||
/// 把 Godot 特殊路径转换为普通绝对路径的委托。
|
||||
/// 当前加载器仅会在输入为 <c>res://</c> 或 <c>user://</c> 时调用它,返回值必须为非空绝对路径。
|
||||
/// </param>
|
||||
/// <param name="enumerateDirectory">
|
||||
/// 枚举指定目录直接子项的委托。
|
||||
/// 当目录不存在、无法访问或当前环境无法枚举该路径时,必须返回 <see langword="null" />。
|
||||
/// </param>
|
||||
/// <param name="fileExists">
|
||||
/// 检查指定路径上的文件是否存在的委托。
|
||||
/// 输入既可能是 Godot 特殊路径,也可能是普通绝对路径。
|
||||
/// </param>
|
||||
/// <param name="readAllBytes">
|
||||
/// 读取指定文件完整字节内容的委托。
|
||||
/// 当文件缺失或读取失败时,应抛出底层异常,由加载器统一包装为配置加载诊断。
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentNullException">任一委托参数为 <see langword="null" /> 时抛出。</exception>
|
||||
public GodotYamlConfigEnvironment(
|
||||
Func<bool> isEditor,
|
||||
Func<string, string> globalizePath,
|
||||
@ -392,6 +443,14 @@ internal sealed class GodotYamlConfigEnvironment
|
||||
ReadAllBytes = readAllBytes ?? throw new ArgumentNullException(nameof(readAllBytes));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取默认的 Godot 运行时环境实现。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 默认实现使用 <see cref="OS.HasFeature(string)" /> 检测编辑器态,
|
||||
/// 使用 <see cref="ProjectSettings.GlobalizePath(string)" /> 处理 Godot 特殊路径,
|
||||
/// 并在 Godot 路径与普通路径之间切换对应的枚举和读取 API。
|
||||
/// </remarks>
|
||||
public static GodotYamlConfigEnvironment Default { get; } = new(
|
||||
static () => OS.HasFeature("editor"),
|
||||
static path => ProjectSettings.GlobalizePath(path),
|
||||
@ -399,14 +458,40 @@ internal sealed class GodotYamlConfigEnvironment
|
||||
FileExistsCore,
|
||||
ReadAllBytesCore);
|
||||
|
||||
/// <summary>
|
||||
/// 获取用于判断当前进程是否处于编辑器态的委托。
|
||||
/// </summary>
|
||||
public Func<bool> IsEditor { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取把 Godot 特殊路径转换为普通绝对路径的委托。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 当前加载器只会对 <c>res://</c> 和 <c>user://</c> 路径调用该委托。
|
||||
/// 返回空字符串会被视为无效环境实现,并在后续路径解析阶段触发异常。
|
||||
/// </remarks>
|
||||
public Func<string, string> GlobalizePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取用于枚举目录直接子项的委托。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 当目录不存在、无法访问,或当前环境无法枚举给定路径时,该委托必须返回 <see langword="null" />。
|
||||
/// 返回的集合只应包含当前目录下的直接子项,调用方会自行过滤隐藏项、子目录与非 YAML 文件。
|
||||
/// </remarks>
|
||||
public Func<string, IReadOnlyList<GodotYamlConfigDirectoryEntry>?> EnumerateDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取用于检查文件是否存在的委托。
|
||||
/// </summary>
|
||||
public Func<string, bool> FileExists { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取用于读取文件完整字节内容的委托。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该委托在路径不存在、权限不足或 I/O 失败时应抛出底层异常,以便加载器保留失败原因并生成诊断信息。
|
||||
/// </remarks>
|
||||
public Func<string, byte[]> ReadAllBytes { get; }
|
||||
|
||||
private static IReadOnlyList<GodotYamlConfigDirectoryEntry>? EnumerateDirectoryCore(string path)
|
||||
@ -464,6 +549,34 @@ internal sealed class GodotYamlConfigEnvironment
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct GodotYamlConfigDirectoryEntry(
|
||||
string Name,
|
||||
bool IsDirectory);
|
||||
/// <summary>
|
||||
/// 描述一次目录枚举返回的单个子项。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 该结构只承载目录扫描阶段需要的最小信息。
|
||||
/// <see cref="Name" /> 必须是单个目录项名称,而不是包含父目录的完整路径;
|
||||
/// 对于 Godot 路径和普通路径都遵循相同约定,便于加载器统一做后续拼接与过滤。
|
||||
/// </remarks>
|
||||
internal readonly record struct GodotYamlConfigDirectoryEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化一个目录枚举结果项。
|
||||
/// </summary>
|
||||
/// <param name="name">当前目录项的名称,不包含父目录路径。</param>
|
||||
/// <param name="isDirectory">指示该目录项是否为子目录。</param>
|
||||
public GodotYamlConfigDirectoryEntry(string name, bool isDirectory)
|
||||
{
|
||||
Name = name;
|
||||
IsDirectory = isDirectory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前目录项的名称,不包含父目录路径。
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取一个值,指示当前目录项是否为子目录。
|
||||
/// </summary>
|
||||
public bool IsDirectory { get; }
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
using System.IO;
|
||||
|
||||
namespace GFramework.Godot.Config;
|
||||
|
||||
/// <summary>
|
||||
@ -9,8 +11,18 @@ public sealed class GodotYamlConfigTableSource
|
||||
/// 初始化一个配置表来源描述。
|
||||
/// </summary>
|
||||
/// <param name="tableName">运行时表名称。</param>
|
||||
/// <param name="configRelativePath">相对配置根目录的 YAML 目录。</param>
|
||||
/// <param name="schemaRelativePath">相对配置根目录的 schema 文件路径;未启用 schema 时为空。</param>
|
||||
/// <param name="configRelativePath">
|
||||
/// 相对配置根目录的 YAML 目录。
|
||||
/// 该路径必须保持为无根相对路径,且不能包含 <c>.</c>、<c>..</c>、<c>res://</c>、<c>user://</c> 或磁盘根路径前缀。
|
||||
/// </param>
|
||||
/// <param name="schemaRelativePath">
|
||||
/// 相对配置根目录的 schema 文件路径;未启用 schema 时为空。
|
||||
/// 如果提供,同样必须保持为无根相对路径,且不能包含 <c>.</c>、<c>..</c> 或任何绝对路径前缀。
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// <paramref name="tableName" />、<paramref name="configRelativePath" /> 或 <paramref name="schemaRelativePath" />
|
||||
/// 不满足非空白且安全相对路径的约束时抛出。
|
||||
/// </exception>
|
||||
public GodotYamlConfigTableSource(
|
||||
string tableName,
|
||||
string configRelativePath,
|
||||
@ -27,6 +39,13 @@ public sealed class GodotYamlConfigTableSource
|
||||
nameof(configRelativePath));
|
||||
}
|
||||
|
||||
if (!IsSafeRelativePath(configRelativePath))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Config relative path must be a safe relative path without root segments or traversal markers.",
|
||||
nameof(configRelativePath));
|
||||
}
|
||||
|
||||
if (schemaRelativePath != null && string.IsNullOrWhiteSpace(schemaRelativePath))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
@ -34,6 +53,13 @@ public sealed class GodotYamlConfigTableSource
|
||||
nameof(schemaRelativePath));
|
||||
}
|
||||
|
||||
if (schemaRelativePath != null && !IsSafeRelativePath(schemaRelativePath))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Schema relative path must be a safe relative path without root segments or traversal markers.",
|
||||
nameof(schemaRelativePath));
|
||||
}
|
||||
|
||||
TableName = tableName;
|
||||
ConfigRelativePath = configRelativePath;
|
||||
SchemaRelativePath = schemaRelativePath;
|
||||
@ -46,11 +72,43 @@ public sealed class GodotYamlConfigTableSource
|
||||
|
||||
/// <summary>
|
||||
/// 获取相对配置根目录的 YAML 目录路径。
|
||||
/// 该值始终保持为无根相对路径,不会包含 <c>.</c> 或 <c>..</c> 段。
|
||||
/// </summary>
|
||||
public string ConfigRelativePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取相对配置根目录的 schema 文件路径;未启用 schema 校验时为空。
|
||||
/// 该值在非空时始终保持为无根相对路径,不会包含 <c>.</c> 或 <c>..</c> 段。
|
||||
/// </summary>
|
||||
public string? SchemaRelativePath { get; }
|
||||
|
||||
private static bool IsSafeRelativePath(string path)
|
||||
{
|
||||
var normalizedPath = path.Replace('\\', '/');
|
||||
if (normalizedPath.StartsWith("/", StringComparison.Ordinal) ||
|
||||
normalizedPath.StartsWith("res://", StringComparison.Ordinal) ||
|
||||
normalizedPath.StartsWith("user://", StringComparison.Ordinal) ||
|
||||
Path.IsPathRooted(path) ||
|
||||
HasWindowsDrivePrefix(normalizedPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var segment in normalizedPath.Split('/', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (segment is "." or "..")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HasWindowsDrivePrefix(string path)
|
||||
{
|
||||
return path.Length >= 2 &&
|
||||
char.IsLetter(path[0]) &&
|
||||
path[1] == ':';
|
||||
}
|
||||
}
|
||||
|
||||
@ -96,7 +96,7 @@ GameProject/
|
||||
|
||||
- 必须是 JSON 字符串
|
||||
- 必须是相对路径
|
||||
- 不允许包含 `..` 段
|
||||
- 不允许包含 `.` 或 `..` 段,也不能写成绝对路径
|
||||
- 生成器会把反斜杠标准化为 `/`
|
||||
|
||||
## YAML 示例
|
||||
@ -314,7 +314,7 @@ public sealed class GameConfigHost : IDisposable
|
||||
`GodotYamlConfigLoader` 会按环境自动处理这两条路径:
|
||||
|
||||
- 编辑器态:直接把 `ProjectSettings.GlobalizePath("res://...")` 交给底层 `YamlConfigLoader`
|
||||
- 导出态:把当前注册会访问到的配置目录与 schema 文件同步到 `user://` 缓存,再交给底层 `YamlConfigLoader`
|
||||
- 导出态:会将当前注册会访问到的 YAML 配置目录与 schema 文件同步到 `user://` 缓存,再交给底层 `YamlConfigLoader`
|
||||
|
||||
推荐搭配生成器元数据一起使用,这样项目不需要再自己维护一份重复的配置目录清单:
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user