mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-04-03 04:14:30 +08:00
feat(config): 添加YAML配置加载器支持
- 新增YamlConfigLoader类支持基于目录的YAML配置加载 - 添加对.yaml和.yml文件格式的自动识别和解析 - 实现异步加载任务支持取消令牌 - 集成YamlDotNet库进行YAML反序列化处理 - 支持驼峰命名约定和忽略未匹配属性 - 实现配置表注册的链式API设计 - 添加详细的加载过程异常处理和错误信息 - 提供完整的单元测试覆盖各种加载场景 - 更新项目依赖添加YamlDotNet包引用16.3.0版本
This commit is contained in:
parent
c0aa8ba70e
commit
5fa12dcd37
202
GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
Normal file
202
GFramework.Game.Tests/Config/YamlConfigLoaderTests.cs
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
using System.IO;
|
||||||
|
using GFramework.Game.Config;
|
||||||
|
|
||||||
|
namespace GFramework.Game.Tests.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证 YAML 配置加载器的目录扫描与注册行为。
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture]
|
||||||
|
public class YamlConfigLoaderTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 为每个测试创建独立临时目录,避免文件系统状态互相污染。
|
||||||
|
/// </summary>
|
||||||
|
[SetUp]
|
||||||
|
public void SetUp()
|
||||||
|
{
|
||||||
|
_rootPath = Path.Combine(Path.GetTempPath(), "GFramework.ConfigTests", Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(_rootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 清理测试期间创建的临时目录。
|
||||||
|
/// </summary>
|
||||||
|
[TearDown]
|
||||||
|
public void TearDown()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_rootPath))
|
||||||
|
{
|
||||||
|
Directory.Delete(_rootPath, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string _rootPath = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证加载器能够扫描 YAML 文件并将结果写入注册表。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public async Task LoadAsync_Should_Register_Table_From_Yaml_Files()
|
||||||
|
{
|
||||||
|
CreateConfigFile(
|
||||||
|
"monster/slime.yaml",
|
||||||
|
"""
|
||||||
|
id: 1
|
||||||
|
name: Slime
|
||||||
|
hp: 10
|
||||||
|
""");
|
||||||
|
CreateConfigFile(
|
||||||
|
"monster/goblin.yml",
|
||||||
|
"""
|
||||||
|
id: 2
|
||||||
|
name: Goblin
|
||||||
|
hp: 30
|
||||||
|
""");
|
||||||
|
|
||||||
|
var loader = new YamlConfigLoader(_rootPath)
|
||||||
|
.RegisterTable<int, MonsterConfigStub>("monster", "monster", static config => config.Id);
|
||||||
|
var registry = new ConfigRegistry();
|
||||||
|
|
||||||
|
await loader.LoadAsync(registry);
|
||||||
|
|
||||||
|
var table = registry.GetTable<int, MonsterConfigStub>("monster");
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(table.Count, Is.EqualTo(2));
|
||||||
|
Assert.That(table.Get(1).Name, Is.EqualTo("Slime"));
|
||||||
|
Assert.That(table.Get(2).Hp, Is.EqualTo(30));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证注册的配置目录不存在时会抛出清晰错误。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void LoadAsync_Should_Throw_When_Config_Directory_Does_Not_Exist()
|
||||||
|
{
|
||||||
|
var loader = new YamlConfigLoader(_rootPath)
|
||||||
|
.RegisterTable<int, MonsterConfigStub>("monster", "monster", static config => config.Id);
|
||||||
|
var registry = new ConfigRegistry();
|
||||||
|
|
||||||
|
var exception = Assert.ThrowsAsync<DirectoryNotFoundException>(async () => await loader.LoadAsync(registry));
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(exception, Is.Not.Null);
|
||||||
|
Assert.That(exception!.Message, Does.Contain("monster"));
|
||||||
|
Assert.That(registry.Count, Is.EqualTo(0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证某个配置表加载失败时,注册表不会留下部分成功的中间状态。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void LoadAsync_Should_Not_Mutate_Registry_When_A_Later_Table_Fails()
|
||||||
|
{
|
||||||
|
CreateConfigFile(
|
||||||
|
"monster/slime.yaml",
|
||||||
|
"""
|
||||||
|
id: 1
|
||||||
|
name: Slime
|
||||||
|
hp: 10
|
||||||
|
""");
|
||||||
|
|
||||||
|
var registry = new ConfigRegistry();
|
||||||
|
registry.RegisterTable(
|
||||||
|
"existing",
|
||||||
|
new InMemoryConfigTable<int, ExistingConfigStub>(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new ExistingConfigStub(100, "Original")
|
||||||
|
},
|
||||||
|
static config => config.Id));
|
||||||
|
|
||||||
|
var loader = new YamlConfigLoader(_rootPath)
|
||||||
|
.RegisterTable<int, MonsterConfigStub>("monster", "monster", static config => config.Id)
|
||||||
|
.RegisterTable<int, MonsterConfigStub>("broken", "broken", static config => config.Id);
|
||||||
|
|
||||||
|
Assert.ThrowsAsync<DirectoryNotFoundException>(async () => await loader.LoadAsync(registry));
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(registry.Count, Is.EqualTo(1));
|
||||||
|
Assert.That(registry.HasTable("monster"), Is.False);
|
||||||
|
Assert.That(registry.GetTable<int, ExistingConfigStub>("existing").Get(100).Name, Is.EqualTo("Original"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证非法 YAML 会被包装成带文件路径的反序列化错误。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void LoadAsync_Should_Throw_With_File_Path_When_Yaml_Is_Invalid()
|
||||||
|
{
|
||||||
|
CreateConfigFile(
|
||||||
|
"monster/slime.yaml",
|
||||||
|
"""
|
||||||
|
id: [1
|
||||||
|
name: Slime
|
||||||
|
""");
|
||||||
|
|
||||||
|
var loader = new YamlConfigLoader(_rootPath)
|
||||||
|
.RegisterTable<int, MonsterConfigStub>("monster", "monster", static config => config.Id);
|
||||||
|
var registry = new ConfigRegistry();
|
||||||
|
|
||||||
|
var exception = Assert.ThrowsAsync<InvalidOperationException>(async () => await loader.LoadAsync(registry));
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(exception, Is.Not.Null);
|
||||||
|
Assert.That(exception!.Message, Does.Contain("slime.yaml"));
|
||||||
|
Assert.That(registry.Count, Is.EqualTo(0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建测试用配置文件。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="relativePath">相对根目录的文件路径。</param>
|
||||||
|
/// <param name="content">文件内容。</param>
|
||||||
|
private void CreateConfigFile(string relativePath, string content)
|
||||||
|
{
|
||||||
|
var fullPath = Path.Combine(_rootPath, relativePath.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
var directory = Path.GetDirectoryName(fullPath);
|
||||||
|
if (!string.IsNullOrEmpty(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.WriteAllText(fullPath, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用于 YAML 加载测试的最小怪物配置类型。
|
||||||
|
/// </summary>
|
||||||
|
private sealed class MonsterConfigStub
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置主键。
|
||||||
|
/// </summary>
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置生命值。
|
||||||
|
/// </summary>
|
||||||
|
public int Hp { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用于验证注册表一致性的现有配置类型。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Id">配置主键。</param>
|
||||||
|
/// <param name="Name">配置名称。</param>
|
||||||
|
private sealed record ExistingConfigStub(int Id, string Name);
|
||||||
|
}
|
||||||
269
GFramework.Game/Config/YamlConfigLoader.cs
Normal file
269
GFramework.Game/Config/YamlConfigLoader.cs
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
using System.IO;
|
||||||
|
using GFramework.Game.Abstractions.Config;
|
||||||
|
using YamlDotNet.Serialization;
|
||||||
|
using YamlDotNet.Serialization.NamingConventions;
|
||||||
|
|
||||||
|
namespace GFramework.Game.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 基于文件目录的 YAML 配置加载器。
|
||||||
|
/// 该实现用于 Runtime MVP 的文本配置接入阶段,通过显式注册表定义描述要加载的配置域,
|
||||||
|
/// 再在一次加载流程中统一解析并写入配置注册表。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class YamlConfigLoader : IConfigLoader
|
||||||
|
{
|
||||||
|
private const string RootPathCannotBeNullOrWhiteSpaceMessage = "Root path cannot be null or whitespace.";
|
||||||
|
private const string TableNameCannotBeNullOrWhiteSpaceMessage = "Table name cannot be null or whitespace.";
|
||||||
|
private const string RelativePathCannotBeNullOrWhiteSpaceMessage = "Relative path cannot be null or whitespace.";
|
||||||
|
|
||||||
|
private readonly IDeserializer _deserializer;
|
||||||
|
private readonly List<IYamlTableRegistration> _registrations = new();
|
||||||
|
private readonly string _rootPath;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 使用指定配置根目录创建 YAML 配置加载器。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="rootPath">配置根目录。</param>
|
||||||
|
/// <exception cref="ArgumentException">当 <paramref name="rootPath" /> 为空时抛出。</exception>
|
||||||
|
public YamlConfigLoader(string rootPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(rootPath))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(RootPathCannotBeNullOrWhiteSpaceMessage, nameof(rootPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
_rootPath = rootPath;
|
||||||
|
_deserializer = new DeserializerBuilder()
|
||||||
|
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||||
|
.IgnoreUnmatchedProperties()
|
||||||
|
.Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取配置根目录。
|
||||||
|
/// </summary>
|
||||||
|
public string RootPath => _rootPath;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前已注册的配置表定义数量。
|
||||||
|
/// </summary>
|
||||||
|
public int RegistrationCount => _registrations.Count;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task LoadAsync(IConfigRegistry registry, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(registry);
|
||||||
|
|
||||||
|
var loadedTables = new List<(string name, IConfigTable table)>(_registrations.Count);
|
||||||
|
|
||||||
|
foreach (var registration in _registrations)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
loadedTables.Add(await registration.LoadAsync(_rootPath, _deserializer, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅当本轮所有配置表都成功加载后才写入注册表,避免暴露部分成功的中间状态。
|
||||||
|
foreach (var (name, table) in loadedTables)
|
||||||
|
{
|
||||||
|
RegistrationDispatcher.Register(registry, name, table);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 注册一个 YAML 配置表定义。
|
||||||
|
/// 主键提取逻辑由调用方显式提供,以避免在 Runtime MVP 阶段引入额外特性或约定推断。
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TKey">配置主键类型。</typeparam>
|
||||||
|
/// <typeparam name="TValue">配置值类型。</typeparam>
|
||||||
|
/// <param name="tableName">配置表名称。</param>
|
||||||
|
/// <param name="relativePath">相对配置根目录的子目录。</param>
|
||||||
|
/// <param name="keySelector">配置项主键提取器。</param>
|
||||||
|
/// <param name="comparer">可选主键比较器。</param>
|
||||||
|
/// <returns>当前加载器实例,以便链式注册。</returns>
|
||||||
|
public YamlConfigLoader RegisterTable<TKey, TValue>(
|
||||||
|
string tableName,
|
||||||
|
string relativePath,
|
||||||
|
Func<TValue, TKey> keySelector,
|
||||||
|
IEqualityComparer<TKey>? comparer = null)
|
||||||
|
where TKey : notnull
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tableName))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(TableNameCannotBeNullOrWhiteSpaceMessage, nameof(tableName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(relativePath))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(RelativePathCannotBeNullOrWhiteSpaceMessage, nameof(relativePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
ArgumentNullException.ThrowIfNull(keySelector);
|
||||||
|
|
||||||
|
_registrations.Add(new YamlTableRegistration<TKey, TValue>(tableName, relativePath, keySelector, comparer));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 负责在非泛型配置表与泛型注册表方法之间做分派。
|
||||||
|
/// 该静态助手将运行时反射局部封装在加载器内部,避免向外暴露弱类型注册 API。
|
||||||
|
/// </summary>
|
||||||
|
private static class RegistrationDispatcher
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 将强类型配置表写入注册表。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="registry">目标配置注册表。</param>
|
||||||
|
/// <param name="name">配置表名称。</param>
|
||||||
|
/// <param name="table">已加载的配置表实例。</param>
|
||||||
|
/// <exception cref="InvalidOperationException">当传入表未实现强类型配置表契约时抛出。</exception>
|
||||||
|
public static void Register(IConfigRegistry registry, string name, IConfigTable table)
|
||||||
|
{
|
||||||
|
var tableInterface = table.GetType()
|
||||||
|
.GetInterfaces()
|
||||||
|
.FirstOrDefault(static type =>
|
||||||
|
type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IConfigTable<,>));
|
||||||
|
|
||||||
|
if (tableInterface == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Loaded config table '{name}' does not implement '{typeof(IConfigTable<,>).Name}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var genericArguments = tableInterface.GetGenericArguments();
|
||||||
|
var method = typeof(IConfigRegistry)
|
||||||
|
.GetMethod(nameof(IConfigRegistry.RegisterTable))!
|
||||||
|
.MakeGenericMethod(genericArguments[0], genericArguments[1]);
|
||||||
|
|
||||||
|
method.Invoke(registry, new object[] { name, table });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 定义 YAML 配置表注册项的统一内部契约。
|
||||||
|
/// </summary>
|
||||||
|
private interface IYamlTableRegistration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 从指定根目录加载配置表。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="rootPath">配置根目录。</param>
|
||||||
|
/// <param name="deserializer">YAML 反序列化器。</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
|
/// <returns>已加载的配置表名称与配置表实例。</returns>
|
||||||
|
Task<(string name, IConfigTable table)> LoadAsync(
|
||||||
|
string rootPath,
|
||||||
|
IDeserializer deserializer,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// YAML 配置表注册项。
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TKey">配置主键类型。</typeparam>
|
||||||
|
/// <typeparam name="TValue">配置项值类型。</typeparam>
|
||||||
|
private sealed class YamlTableRegistration<TKey, TValue> : IYamlTableRegistration
|
||||||
|
where TKey : notnull
|
||||||
|
{
|
||||||
|
private readonly IEqualityComparer<TKey>? _comparer;
|
||||||
|
private readonly Func<TValue, TKey> _keySelector;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化 YAML 配置表注册项。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">配置表名称。</param>
|
||||||
|
/// <param name="relativePath">相对配置根目录的子目录。</param>
|
||||||
|
/// <param name="keySelector">配置项主键提取器。</param>
|
||||||
|
/// <param name="comparer">可选主键比较器。</param>
|
||||||
|
public YamlTableRegistration(
|
||||||
|
string name,
|
||||||
|
string relativePath,
|
||||||
|
Func<TValue, TKey> keySelector,
|
||||||
|
IEqualityComparer<TKey>? comparer)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
RelativePath = relativePath;
|
||||||
|
_keySelector = keySelector;
|
||||||
|
_comparer = comparer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取配置表名称。
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取相对配置根目录的子目录。
|
||||||
|
/// </summary>
|
||||||
|
public string RelativePath { get; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<(string name, IConfigTable table)> LoadAsync(
|
||||||
|
string rootPath,
|
||||||
|
IDeserializer deserializer,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var directoryPath = Path.Combine(rootPath, RelativePath);
|
||||||
|
if (!Directory.Exists(directoryPath))
|
||||||
|
{
|
||||||
|
throw new DirectoryNotFoundException(
|
||||||
|
$"Config directory '{directoryPath}' was not found for table '{Name}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var values = new List<TValue>();
|
||||||
|
var files = Directory
|
||||||
|
.EnumerateFiles(directoryPath, "*.*", SearchOption.TopDirectoryOnly)
|
||||||
|
.Where(static path =>
|
||||||
|
path.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
path.EndsWith(".yml", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderBy(static path => path, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
string yaml;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
yaml = await File.ReadAllTextAsync(file, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Failed to read config file '{file}' for table '{Name}'.",
|
||||||
|
exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var value = deserializer.Deserialize<TValue>(yaml);
|
||||||
|
|
||||||
|
if (value == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("YAML content was deserialized to null.");
|
||||||
|
}
|
||||||
|
|
||||||
|
values.Add(value);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Failed to deserialize config file '{file}' for table '{Name}' as '{typeof(TValue).Name}'.",
|
||||||
|
exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var table = new InMemoryConfigTable<TKey, TValue>(values, _keySelector, _comparer);
|
||||||
|
return (Name, table);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Failed to build config table '{Name}' from directory '{directoryPath}'.",
|
||||||
|
exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,5 +14,6 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4"/>
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4"/>
|
||||||
|
<PackageReference Include="YamlDotNet" Version="16.3.0"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user