namespace GFramework.Game.Config; /// /// 提供 YAML 配置文件与 JSON Schema 之间的最小运行时校验能力。 /// 该校验器与当前配置生成器支持的 schema 子集保持一致, /// 以便在配置进入运行时注册表之前就拒绝缺失字段、未知字段和基础类型错误。 /// internal static class YamlConfigSchemaValidator { /// /// 从磁盘加载并解析一个 JSON Schema 文件。 /// /// Schema 文件路径。 /// 取消令牌。 /// 解析后的 schema 模型。 /// 为空时抛出。 /// 当 schema 文件不存在时抛出。 /// 当 schema 内容不符合当前运行时支持的子集时抛出。 internal static async Task LoadAsync( string schemaPath, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(schemaPath)) { throw new ArgumentException("Schema path cannot be null or whitespace.", nameof(schemaPath)); } if (!File.Exists(schemaPath)) { throw new FileNotFoundException($"Schema file '{schemaPath}' was not found.", schemaPath); } string schemaText; try { schemaText = await File.ReadAllTextAsync(schemaPath, cancellationToken); } catch (Exception exception) { throw new InvalidOperationException($"Failed to read schema file '{schemaPath}'.", exception); } try { using var document = JsonDocument.Parse(schemaText); var root = document.RootElement; if (!root.TryGetProperty("type", out var typeElement) || !string.Equals(typeElement.GetString(), "object", StringComparison.Ordinal)) { throw new InvalidOperationException( $"Schema file '{schemaPath}' must declare a root object schema."); } if (!root.TryGetProperty("properties", out var propertiesElement) || propertiesElement.ValueKind != JsonValueKind.Object) { throw new InvalidOperationException( $"Schema file '{schemaPath}' must declare an object-valued 'properties' section."); } var requiredProperties = new HashSet(StringComparer.Ordinal); if (root.TryGetProperty("required", out var requiredElement) && requiredElement.ValueKind == JsonValueKind.Array) { foreach (var item in requiredElement.EnumerateArray()) { cancellationToken.ThrowIfCancellationRequested(); if (item.ValueKind != JsonValueKind.String) { continue; } var propertyName = item.GetString(); if (!string.IsNullOrWhiteSpace(propertyName)) { requiredProperties.Add(propertyName); } } } var properties = new Dictionary(StringComparer.Ordinal); foreach (var property in propertiesElement.EnumerateObject()) { cancellationToken.ThrowIfCancellationRequested(); properties.Add(property.Name, ParseProperty(schemaPath, property)); } var referencedTableNames = properties.Values .Select(static property => property.ReferenceTableName) .Where(static tableName => !string.IsNullOrWhiteSpace(tableName)) .Cast() .Distinct(StringComparer.Ordinal) .ToArray(); return new YamlConfigSchema(schemaPath, properties, requiredProperties, referencedTableNames); } catch (JsonException exception) { throw new InvalidOperationException($"Schema file '{schemaPath}' contains invalid JSON.", exception); } } /// /// 使用已解析的 schema 校验 YAML 文本。 /// /// 已解析的 schema 模型。 /// YAML 文件路径,仅用于诊断信息。 /// YAML 文本内容。 /// 当参数为空时抛出。 /// 当 YAML 内容与 schema 不匹配时抛出。 internal static void Validate( YamlConfigSchema schema, string yamlPath, string yamlText) { ValidateAndCollectReferences(schema, yamlPath, yamlText); } /// /// 使用已解析的 schema 校验 YAML 文本,并提取声明过的跨表引用。 /// 该方法让结构校验与引用采集共享同一份 YAML 解析结果,避免加载器重复解析同一文件。 /// /// 已解析的 schema 模型。 /// YAML 文件路径,仅用于诊断信息。 /// YAML 文本内容。 /// 当前 YAML 文件中声明的跨表引用集合。 /// 当参数为空时抛出。 /// 当 YAML 内容与 schema 不匹配时抛出。 internal static IReadOnlyList ValidateAndCollectReferences( YamlConfigSchema schema, string yamlPath, string yamlText) { ArgumentNullException.ThrowIfNull(schema); ArgumentNullException.ThrowIfNull(yamlPath); ArgumentNullException.ThrowIfNull(yamlText); YamlStream yamlStream = new(); try { using var reader = new StringReader(yamlText); yamlStream.Load(reader); } catch (Exception exception) { throw new InvalidOperationException( $"Config file '{yamlPath}' could not be parsed as YAML before schema validation.", exception); } if (yamlStream.Documents.Count != 1 || yamlStream.Documents[0].RootNode is not YamlMappingNode rootMapping) { throw new InvalidOperationException( $"Config file '{yamlPath}' must contain a single root mapping object."); } var references = new List(); var seenProperties = new HashSet(StringComparer.Ordinal); foreach (var entry in rootMapping.Children) { if (entry.Key is not YamlScalarNode keyNode || string.IsNullOrWhiteSpace(keyNode.Value)) { throw new InvalidOperationException( $"Config file '{yamlPath}' contains a non-scalar or empty top-level property name."); } var propertyName = keyNode.Value; if (!seenProperties.Add(propertyName)) { throw new InvalidOperationException( $"Config file '{yamlPath}' contains duplicate property '{propertyName}'."); } if (!schema.Properties.TryGetValue(propertyName, out var property)) { throw new InvalidOperationException( $"Config file '{yamlPath}' contains unknown property '{propertyName}' that is not declared in schema '{schema.SchemaPath}'."); } ValidateNode(yamlPath, propertyName, entry.Value, property, references); } foreach (var requiredProperty in schema.RequiredProperties) { if (!seenProperties.Contains(requiredProperty)) { throw new InvalidOperationException( $"Config file '{yamlPath}' is missing required property '{requiredProperty}' defined by schema '{schema.SchemaPath}'."); } } return references; } private static YamlConfigSchemaProperty ParseProperty(string schemaPath, JsonProperty property) { if (!property.Value.TryGetProperty("type", out var typeElement) || typeElement.ValueKind != JsonValueKind.String) { throw new InvalidOperationException( $"Property '{property.Name}' in schema file '{schemaPath}' must declare a string 'type'."); } var typeName = typeElement.GetString() ?? string.Empty; var propertyType = typeName switch { "integer" => YamlConfigSchemaPropertyType.Integer, "number" => YamlConfigSchemaPropertyType.Number, "boolean" => YamlConfigSchemaPropertyType.Boolean, "string" => YamlConfigSchemaPropertyType.String, "array" => YamlConfigSchemaPropertyType.Array, _ => throw new InvalidOperationException( $"Property '{property.Name}' in schema file '{schemaPath}' uses unsupported type '{typeName}'.") }; string? referenceTableName = null; if (property.Value.TryGetProperty("x-gframework-ref-table", out var referenceTableElement)) { if (referenceTableElement.ValueKind != JsonValueKind.String) { throw new InvalidOperationException( $"Property '{property.Name}' in schema file '{schemaPath}' must declare a string 'x-gframework-ref-table' value."); } referenceTableName = referenceTableElement.GetString(); if (string.IsNullOrWhiteSpace(referenceTableName)) { throw new InvalidOperationException( $"Property '{property.Name}' in schema file '{schemaPath}' must declare a non-empty 'x-gframework-ref-table' value."); } } if (propertyType != YamlConfigSchemaPropertyType.Array) { EnsureReferenceKeywordIsSupported(schemaPath, property.Name, propertyType, null, referenceTableName); return new YamlConfigSchemaProperty( property.Name, propertyType, null, referenceTableName, ParseEnumValues(schemaPath, property.Name, property.Value, propertyType, "enum"), null); } if (!property.Value.TryGetProperty("items", out var itemsElement) || itemsElement.ValueKind != JsonValueKind.Object || !itemsElement.TryGetProperty("type", out var itemTypeElement) || itemTypeElement.ValueKind != JsonValueKind.String) { throw new InvalidOperationException( $"Array property '{property.Name}' in schema file '{schemaPath}' must declare an item type."); } var itemTypeName = itemTypeElement.GetString() ?? string.Empty; var itemType = itemTypeName switch { "integer" => YamlConfigSchemaPropertyType.Integer, "number" => YamlConfigSchemaPropertyType.Number, "boolean" => YamlConfigSchemaPropertyType.Boolean, "string" => YamlConfigSchemaPropertyType.String, _ => throw new InvalidOperationException( $"Array property '{property.Name}' in schema file '{schemaPath}' uses unsupported item type '{itemTypeName}'.") }; EnsureReferenceKeywordIsSupported(schemaPath, property.Name, propertyType, itemType, referenceTableName); return new YamlConfigSchemaProperty( property.Name, propertyType, itemType, referenceTableName, null, ParseEnumValues(schemaPath, property.Name, itemsElement, itemType, "items.enum")); } private static void EnsureReferenceKeywordIsSupported( string schemaPath, string propertyName, YamlConfigSchemaPropertyType propertyType, YamlConfigSchemaPropertyType? itemType, string? referenceTableName) { if (referenceTableName == null) { return; } if (propertyType == YamlConfigSchemaPropertyType.String || propertyType == YamlConfigSchemaPropertyType.Integer) { return; } if (propertyType == YamlConfigSchemaPropertyType.Array && (itemType == YamlConfigSchemaPropertyType.String || itemType == YamlConfigSchemaPropertyType.Integer)) { return; } throw new InvalidOperationException( $"Property '{propertyName}' in schema file '{schemaPath}' uses 'x-gframework-ref-table', but only string, integer, or arrays of those scalar types can declare cross-table references."); } private static void ValidateNode( string yamlPath, string propertyName, YamlNode node, YamlConfigSchemaProperty property, ICollection references) { if (property.PropertyType == YamlConfigSchemaPropertyType.Array) { if (node is not YamlSequenceNode sequenceNode) { throw new InvalidOperationException( $"Property '{propertyName}' in config file '{yamlPath}' must be an array."); } for (var itemIndex = 0; itemIndex < sequenceNode.Children.Count; itemIndex++) { ValidateScalarNode( yamlPath, propertyName, sequenceNode.Children[itemIndex], property.ItemType!.Value, property.ReferenceTableName, property.ItemAllowedValues, references, isArrayItem: true, itemIndex); } return; } ValidateScalarNode( yamlPath, propertyName, node, property.PropertyType, property.ReferenceTableName, property.AllowedValues, references, isArrayItem: false, itemIndex: null); } private static void ValidateScalarNode( string yamlPath, string propertyName, YamlNode node, YamlConfigSchemaPropertyType expectedType, string? referenceTableName, IReadOnlyCollection? allowedValues, ICollection references, bool isArrayItem, int? itemIndex) { if (node is not YamlScalarNode scalarNode) { var subject = isArrayItem ? $"Array item in property '{propertyName}'" : $"Property '{propertyName}'"; throw new InvalidOperationException( $"{subject} in config file '{yamlPath}' must be a scalar value of type '{GetTypeName(expectedType)}'."); } var value = scalarNode.Value; if (value is null) { var subject = isArrayItem ? $"Array item in property '{propertyName}'" : $"Property '{propertyName}'"; throw new InvalidOperationException( $"{subject} in config file '{yamlPath}' cannot be null when schema type is '{GetTypeName(expectedType)}'."); } var tag = scalarNode.Tag.ToString(); var isValid = expectedType switch { YamlConfigSchemaPropertyType.String => IsStringScalar(tag), YamlConfigSchemaPropertyType.Integer => long.TryParse( value, NumberStyles.Integer, CultureInfo.InvariantCulture, out _), YamlConfigSchemaPropertyType.Number => double.TryParse( value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out _), YamlConfigSchemaPropertyType.Boolean => bool.TryParse(value, out _), _ => false }; if (isValid) { var normalizedValue = NormalizeScalarValue(expectedType, value); if (allowedValues is { Count: > 0 } && !allowedValues.Contains(normalizedValue, StringComparer.Ordinal)) { var enumSubject = isArrayItem ? $"Array item in property '{propertyName}'" : $"Property '{propertyName}'"; throw new InvalidOperationException( $"{enumSubject} in config file '{yamlPath}' must be one of [{string.Join(", ", allowedValues)}], but the current YAML scalar value is '{value}'."); } if (referenceTableName != null) { references.Add( new YamlConfigReferenceUsage( yamlPath, propertyName, itemIndex, normalizedValue, referenceTableName, expectedType)); } return; } var subjectName = isArrayItem ? $"Array item in property '{propertyName}'" : $"Property '{propertyName}'"; throw new InvalidOperationException( $"{subjectName} in config file '{yamlPath}' must be of type '{GetTypeName(expectedType)}', but the current YAML scalar value is '{value}'."); } private static IReadOnlyCollection? ParseEnumValues( string schemaPath, string propertyName, JsonElement element, YamlConfigSchemaPropertyType expectedType, string keywordName) { if (!element.TryGetProperty("enum", out var enumElement)) { return null; } if (enumElement.ValueKind != JsonValueKind.Array) { throw new InvalidOperationException( $"Property '{propertyName}' in schema file '{schemaPath}' must declare '{keywordName}' as an array."); } var allowedValues = new List(); foreach (var item in enumElement.EnumerateArray()) { allowedValues.Add(NormalizeEnumValue(schemaPath, propertyName, keywordName, expectedType, item)); } return allowedValues; } private static string NormalizeEnumValue( string schemaPath, string propertyName, string keywordName, YamlConfigSchemaPropertyType expectedType, JsonElement item) { try { return expectedType switch { YamlConfigSchemaPropertyType.String when item.ValueKind == JsonValueKind.String => item.GetString() ?? string.Empty, YamlConfigSchemaPropertyType.Integer when item.ValueKind == JsonValueKind.Number => item.GetInt64().ToString(CultureInfo.InvariantCulture), YamlConfigSchemaPropertyType.Number when item.ValueKind == JsonValueKind.Number => item.GetDouble().ToString(CultureInfo.InvariantCulture), YamlConfigSchemaPropertyType.Boolean when item.ValueKind == JsonValueKind.True => bool.TrueString.ToLowerInvariant(), YamlConfigSchemaPropertyType.Boolean when item.ValueKind == JsonValueKind.False => bool.FalseString.ToLowerInvariant(), _ => throw new InvalidOperationException() }; } catch { throw new InvalidOperationException( $"Property '{propertyName}' in schema file '{schemaPath}' contains a '{keywordName}' value that is incompatible with schema type '{GetTypeName(expectedType)}'."); } } private static string NormalizeScalarValue(YamlConfigSchemaPropertyType expectedType, string value) { return expectedType switch { YamlConfigSchemaPropertyType.String => value, YamlConfigSchemaPropertyType.Integer => long.Parse( value, NumberStyles.Integer, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture), YamlConfigSchemaPropertyType.Number => double.Parse( value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture), YamlConfigSchemaPropertyType.Boolean => bool.Parse(value).ToString().ToLowerInvariant(), _ => value }; } private static string GetTypeName(YamlConfigSchemaPropertyType type) { return type switch { YamlConfigSchemaPropertyType.Integer => "integer", YamlConfigSchemaPropertyType.Number => "number", YamlConfigSchemaPropertyType.Boolean => "boolean", YamlConfigSchemaPropertyType.String => "string", YamlConfigSchemaPropertyType.Array => "array", _ => type.ToString() }; } private static bool IsStringScalar(string tag) { if (string.IsNullOrWhiteSpace(tag)) { return true; } return !string.Equals(tag, "tag:yaml.org,2002:int", StringComparison.Ordinal) && !string.Equals(tag, "tag:yaml.org,2002:float", StringComparison.Ordinal) && !string.Equals(tag, "tag:yaml.org,2002:bool", StringComparison.Ordinal) && !string.Equals(tag, "tag:yaml.org,2002:null", StringComparison.Ordinal); } } /// /// 表示已解析并可用于运行时校验的 JSON Schema。 /// 该模型只保留当前运行时加载器真正需要的最小信息,以避免在游戏运行时引入完整 schema 引擎。 /// internal sealed class YamlConfigSchema { /// /// 初始化一个可用于运行时校验的 schema 模型。 /// /// Schema 文件路径。 /// Schema 属性定义。 /// 必填属性集合。 /// Schema 声明的目标引用表名称集合。 public YamlConfigSchema( string schemaPath, IReadOnlyDictionary properties, IReadOnlyCollection requiredProperties, IReadOnlyCollection referencedTableNames) { ArgumentNullException.ThrowIfNull(schemaPath); ArgumentNullException.ThrowIfNull(properties); ArgumentNullException.ThrowIfNull(requiredProperties); ArgumentNullException.ThrowIfNull(referencedTableNames); SchemaPath = schemaPath; Properties = properties; RequiredProperties = requiredProperties; ReferencedTableNames = referencedTableNames; } /// /// 获取 schema 文件路径。 /// public string SchemaPath { get; } /// /// 获取按属性名索引的 schema 属性定义。 /// public IReadOnlyDictionary Properties { get; } /// /// 获取 schema 声明的必填属性集合。 /// public IReadOnlyCollection RequiredProperties { get; } /// /// 获取 schema 声明的目标引用表名称集合。 /// 该信息用于热重载时推导受影响的依赖表闭包。 /// public IReadOnlyCollection ReferencedTableNames { get; } } /// /// 表示单个 schema 属性的最小运行时描述。 /// internal sealed class YamlConfigSchemaProperty { /// /// 初始化一个 schema 属性描述。 /// /// 属性名称。 /// 属性类型。 /// 数组元素类型;仅当属性类型为数组时有效。 /// 目标引用表名称;未声明跨表引用时为空。 /// 标量允许值集合;未声明 enum 时为空。 /// 数组元素允许值集合;未声明 items.enum 时为空。 public YamlConfigSchemaProperty( string name, YamlConfigSchemaPropertyType propertyType, YamlConfigSchemaPropertyType? itemType, string? referenceTableName, IReadOnlyCollection? allowedValues, IReadOnlyCollection? itemAllowedValues) { ArgumentNullException.ThrowIfNull(name); Name = name; PropertyType = propertyType; ItemType = itemType; ReferenceTableName = referenceTableName; AllowedValues = allowedValues; ItemAllowedValues = itemAllowedValues; } /// /// 获取属性名称。 /// public string Name { get; } /// /// 获取属性类型。 /// public YamlConfigSchemaPropertyType PropertyType { get; } /// /// 获取数组元素类型;非数组属性时返回空。 /// public YamlConfigSchemaPropertyType? ItemType { get; } /// /// 获取目标引用表名称;未声明跨表引用时返回空。 /// public string? ReferenceTableName { get; } /// /// 获取标量允许值集合;未声明 enum 时返回空。 /// public IReadOnlyCollection? AllowedValues { get; } /// /// 获取数组元素允许值集合;未声明 items.enum 时返回空。 /// public IReadOnlyCollection? ItemAllowedValues { get; } } /// /// 表示单个 YAML 文件中提取出的跨表引用。 /// 该模型保留源文件、字段路径和目标表等诊断信息,以便加载器在批量校验失败时给出可定位的错误。 /// internal sealed class YamlConfigReferenceUsage { /// /// 初始化一个跨表引用使用记录。 /// /// 源 YAML 文件路径。 /// 声明引用的属性名。 /// 数组元素索引;标量属性时为空。 /// YAML 中的原始标量值。 /// 目标配置表名称。 /// 引用值的 schema 标量类型。 public YamlConfigReferenceUsage( string yamlPath, string propertyName, int? itemIndex, string rawValue, string referencedTableName, YamlConfigSchemaPropertyType valueType) { ArgumentNullException.ThrowIfNull(yamlPath); ArgumentNullException.ThrowIfNull(propertyName); ArgumentNullException.ThrowIfNull(rawValue); ArgumentNullException.ThrowIfNull(referencedTableName); YamlPath = yamlPath; PropertyName = propertyName; ItemIndex = itemIndex; RawValue = rawValue; ReferencedTableName = referencedTableName; ValueType = valueType; } /// /// 获取源 YAML 文件路径。 /// public string YamlPath { get; } /// /// 获取声明引用的属性名。 /// public string PropertyName { get; } /// /// 获取数组元素索引;标量属性时返回空。 /// public int? ItemIndex { get; } /// /// 获取 YAML 中的原始标量值。 /// public string RawValue { get; } /// /// 获取目标配置表名称。 /// public string ReferencedTableName { get; } /// /// 获取引用值的 schema 标量类型。 /// public YamlConfigSchemaPropertyType ValueType { get; } /// /// 获取便于诊断显示的字段路径。 /// public string DisplayPath => ItemIndex.HasValue ? $"{PropertyName}[{ItemIndex.Value}]" : PropertyName; } /// /// 表示当前运行时 schema 校验器支持的属性类型。 /// internal enum YamlConfigSchemaPropertyType { /// /// 整数类型。 /// Integer, /// /// 数值类型。 /// Number, /// /// 布尔类型。 /// Boolean, /// /// 字符串类型。 /// String, /// /// 数组类型。 /// Array }