Преобразование сложных типов YAML в .NET с помощью YamlDotNet
Когда дело доходит до сериализации и десериализации YAML в .NET, YamlDotNet — это идеальная библиотека, которую на NuGet скачали более 100 миллионов раз. Она также интегрируется в различные проекты Microsoft и .NET, несмотря на отсутствие официальной библиотеки Microsoft YAML для .NET.
В этом блоге мы рассмотрим процесс создания пользовательских сериализаторов и десериализаторов YAML с использованием YamlDotNet. Чтобы проиллюстрировать эти концепции, мы рассмотрим конкретный вариант использования частичного анализа раздела переменных среды Docker Compose.
Вариант использования переменных среды Docker Compose
Docker Compose позволяет определять переменные среды в двух разных форматах. Первый, известный как формат объекта, выглядит следующим образом:
environment:
RACK_ENV: development
SHOW: "true"
SESSION_SECRET:
Этот формат объекта можно напрямую десериализовать в словарь строк. Однако Docker Compose также поддерживает формат массива:
environment:
- RACK_ENV=development
- SHOW=true
- SESSION_SECRET
В отличие от формата объекта, формат массива более сложен для десериализации, поскольку он состоит из массива строк. Если мы хотим последовательно десериализовать оба формата в словарь строк, нам нужно создать собственный сериализатор. Это можно сделать путем реализации интерфейса IYamlTypeConverter
.
Прежде чем приступить к коду, давайте сначала разберемся с тремя типами YAML tokens, которые можно встретить при анализе документа YAML с помощью YamlDotNet:
- Токен
Scalar
представляет наличие скалярного значения. Это может быть строка, число, логическое значение и т. д. - Токены
MappingStart
иMappingEnd
представляют начало и конец объекта YAML — перечисление пар ключ-значение. Обратите внимание, что ключи всегда являются скалярами. Токены SequenceStart
иSequenceEnd
представляют начало и конец массива YAML — перечисление значений.
Вы можете понять, как документы YAML можно анализировать с использованием этих токенов здесь:
# If we were to parse the "myobject" YAML value, we would encounter:
# MappingStart, Scalar (foo), Scalar (bar), MappingEnd
myobject:
foo: bar
# If we were to parse the "myarray" YAML value, we would encounter:
# SequenceStart, Scalar (foo), SequenceEnd
myarray:
- foo
Реализация нашего кастомного IYamlTypeConverter
Имея это в виду, давайте начнем с реализации интерфейса IYamlTypeConverter
. Интерфейс имеет три метода. Первый, Accepts
, используется для определения того, может ли преобразователь обрабатывать данный тип. В нашем случае мы хотим обрабатывать тип EnvironmentVariables
, который я только что создал для представления словаря переменных среды:
public class EnvironmentVariables : Dictionary<string, string>
{
}
public class EnvironmentVariablesTypeConverter : IYamlTypeConverter
{
public bool Accepts(Type type)
{
return type == typeof(EnvironmentVariables);
}
// [...]
}
Второй метод, ReadYaml
, используется для десериализации документа YAML в объект .NET. В нашем сценарии мы стремимся десериализовать объект или массив YAML в EnvironmentVariables
:
public object ReadYaml(IParser parser, Type type)
{
// We'll implement the deserialization logic very soon
return new EnvironmentVariables();
}
Третий метод, WriteYaml
, используется для сериализации объекта .NET обратно в документ YAML. Для наших целей мы сериализуем объект EnvironmentVariables
в формат YAML:
public void WriteYaml(IEmitter emitter, object? value, Type type)
{
// We'll implement the serialization logic very soon
var dict = (EnvironmentVariables)value!;
}
Теперь, когда мы обрисовали структуру нашего пользовательского сериализатора, давайте углубимся в логику десериализации. Учитывая, что схема Docker Compose YAML для переменных среды поддерживает как формат объекта, так и формат строкового массива, мы можем оценить первый токен YAML, чтобы выяснить, отмечает ли он начало объекта YAML (MappingStart
) или массива YAML (SequenceStart
):
public object? ReadYaml(IParser parser, Type type)
{
if (parser.TryConsume<MappingStart>(out _))
{
return ParseMapping(parser); // We're parsing a YAML object
}
if (parser.TryConsume<SequenceStart>(out _))
{
return ParseSequence(parser); // We're parsing a YAML array
}
throw new InvalidOperationException("Expected a YAML object or array");
}
Метод TryConsume
, как следует из названия, пытается использовать токен YAML из документа. Если токен имеет ожидаемый тип, он используется, и метод возвращает значение true
. Если нет, метод возвращает false
, и анализатор не движется вперед. Давайте реализуем два метода синтаксического анализа:
private static EnvironmentVariables ParseMapping(IParser parser)
{
var envvars = new EnvironmentVariables();
// Read all the key-value pairs until we reached the end of the YAML object
while (!parser.Accept<MappingEnd>(out _))
{
var key = parser.Consume<Scalar>();
var value = parser.Consume<Scalar>();
envvars[key.Value] = value.Value;
}
// Consume the mapping end token
parser.MoveNext();
return envvars;
}
// Regex that parses a key-value pair in the array format (e.g. "FOO=BAR" or "FOO")
private static readonly Regex EnvironmentVariableLineRegex = new Regex("^(?<key>[^=]*)(=(?<value>.*))?$", RegexOptions.Compiled);
private static EnvironmentVariables ParseSequence(IParser parser)
{
var envvars = new EnvironmentVariables();
// Read all the array values until we reach the end of the YAML array
while (!parser.Accept<SequenceEnd>(out _))
{
var scalar = parser.Consume<Scalar>();
if (EnvironmentVariableLineRegex.Match(scalar.Value) is { Success: true } match)
{
var key = match.Groups["key"].Value;
var value = match.Groups["value"].Success ? match.Groups["value"].Value : string.Empty;
envvars[key] = value;
}
else
{
throw new InvalidOperationException("Invalid key value mapping: " + scalar.Value);
}
}
// Consume the mapping end token
parser.MoveNext();
return envvars;
}
Имея эти методы, мы можем поддерживать как форматы объектов, так и форматы массивов. Логика десериализации теперь завершена. Далее давайте займемся логикой сериализации. Наша цель — сериализовать объект EnvironmentVariables
в формат YAML. Метод Emit
можно использовать для создания токенов YAML:
private static readonly char[] KeyCharactersThatRequireQuotes = { ' ', '/', '\\', '~', ':', '$', '{', '}' };
public void WriteYaml(IEmitter emitter, object? value, Type type)
{
var envvars = (EnvironmentVariables)value!;
// We start a new YAML object
emitter.Emit(new MappingStart(AnchorName.Empty, TagName.Empty, isImplicit: true, MappingStyle.Block));
foreach (var entry in envvars)
{
// We try to determine if the value needs to be quoted if it contains special characters
var keyScalar = entry.Key.IndexOfAny(KeyCharactersThatRequireQuotes) >= 0
? new Scalar(AnchorName.Empty, TagName.Empty, entry.Key, ScalarStyle.DoubleQuoted, isPlainImplicit: false, isQuotedImplicit: true)
: new Scalar(AnchorName.Empty, TagName.Empty, entry.Key, ScalarStyle.Plain, isPlainImplicit: true, isQuotedImplicit: false);
// Write the key, then the value
emitter.Emit(keyScalar);
emitter.Emit(new Scalar(AnchorName.Empty, TagName.Empty, entry.Value, ScalarStyle.DoubleQuoted, isPlainImplicit: false, isQuotedImplicit: true));
}
// We end the YAML object
emitter.Emit(new MappingEnd());
}
С помощью этих методов мы создали собственный сериализатор и десериализатор YAML для типа EnvironmentVariables
. Впоследствии мы можем использовать его для десериализации документа YAML в объект EnvironmentVariables
:
var envvarTypeConverter = new EnvironmentVariablesTypeConverter();
var deserializer = new DeserializerBuilder()
.WithTypeConverter(envvarTypeConverter)
.IgnoreUnmatchedProperties() // don't throw an exception if there are unknown properties
.Build();
var serializer = new SerializerBuilder()
.WithTypeConverter(envvarTypeConverter)
.DisableAliases() // don't use anchors and aliases (references to identical objects)
.Build();
// Returns:
// environment:
// foo: "bar"
var yamlText = serializer.Serialize(new DockerComposeService
{
Environment = new EnvironmentVariables
{
["foo"] = "bar"
}
});
//
var yamlObj = deserializer.Deserialize<DockerComposeService>(yamlText);