Введение

В этой статье я расскажу об использование библиотеки с открытым исходным кодом yaml-cpp в С++ проектах для сохранения данных в виде YAML-документа. Библиотека имеет лицензию MIT. Вот ссылка на официальный репозиторий.

Итак, данный туториал является логическим продолжением моей другой статьи про чтение YAML-конфигов. В ней мы рассматривали:

  • какие наиболее популярные библиотеки для работы с YAML представлены в экосистеме C++

  • почему для задач работы с конфигам я выбрал библиотеку yaml-cpp

  • как в С++/CMake проекте подключить библиотеку yaml-cpp

  • какой инструментарий предлагает yaml-cpp для организации чтения YAML-документов из файлов и строк и как его использовать

Также в той статье мы разработали небольшой С++/CMake проект (с подробным описанием) в качестве примера реализации чтения YAML-конфигов в С++ приложении.

В этой же статье мы завершим:

  • обзор инструментария, предоставляемого yaml-cpp, для генерации YAML-текста

  • разработку приложения-примера, начатую в прошлой статье, и добавим в нее функционал сохранения/записи настроек в YAML-файл или YAML-строку

Особое внимание будет уделено вопросам форматирования(и представления) выходного YAML-текста, поскольку это влияет на:

  • восприятие конечного YAML-документа человеком

  • на совместимость с другими программами, парсеры которых хуже поддерживают спецификации YAML

При написании данной статьи я использовал версию библиотеки yaml-cpp 0.8.0 и коммит "47cd272". Если использовать версию, помеченную тэгом 0.8.0, то на свежих версиях CMake, скорее всего (а в CMake 4.2.0 точно), не получится собрать yaml-cpp.

При написании данной статьи я исходил из того, что читатель знаком с языком C++, системой сборки проектов CMake и имеет представление о формате представления данных YAML.

Интерфейс yaml-cpp

Чтобы эффективно применять библиотеку yaml-cpp для записи данных в формате YAML имеет смысл разобраться с:

  • особенностями создания объектов YAML::Node и записи данных в них

  • функционалом, предоставляемым типом YAML::Emitter

  • возможностями библиотеки по управлению форматированием выходных YAML документов

Я черпал информацию о создании узлов с официального туториала yaml-cpp, а о возможностях генерации YAML-текста здесь. К сожалению, там продемонстрированы и описаны далеко не все возможности, поэтому мне приходилось и в исходный код проекта заглядывать и в отладчике изучать детали работы с yaml-cpp.

Запись данных в YAML::Node

В статье про чтение YAML-конфигов тип YAML::Node мы рассматривали с точки зрения считывания его содержимого в требуемые типы данных С++, а в этом туториале будем решать обратную задачу.

Объект типа YAML::Node, созданный с помощью конструктора без аргументов, не содержит пока информации о том, какие YAML-объекты он будет хранить. В примере ниже это подробно продемонстрировано:

// Создаем корневой узел
YAML::Node root;

// Он пустой, но существует (создан)
assert(root.IsNull() && root.IsDefined());

// Причем, этот узел не имеет пока типа
// Это не Map, не Sequence и не Scalar
assert(!root.IsMap() && !root.IsSequence() && !root.IsScalar());

// Как только обратимся к узлу по ключу (т.е. при вызове operator[])
// узел root сразу получит тип Map.
// Даже если не присваивать никакого значения.
root["map"];
assert(root.IsMap());

// А вот дочерний узел пока без типа (как и узел root после создания)
assert(!root["map"].IsMap() &&
       !root["map"].IsSequence() &&
       !root["map"].IsScalar());

То есть, тип YAML-узла может измениться в зависимости от контекста взаимодействия с ним. Продолжим:

// Если теперь у дочернего узла вызвать метод push_back(), автоматически
// тип дочернего узла (root["map"]) станет последовательностью
root["map"].push_back("one");
root["map"].push_back("two");
assert(root["map"].IsSequence());

При добавлении к узлу root["map"] значения с помощью метода push_back() ему присвоился тип последовательности. Вот YAML-текст, получившийся на текущий момент:

map:
  - one
  - two

Что будет с типом узла, если теперь к нему обратиться по ключу-строке?

// Если ранее мы обращались к root["map"] как к массиву (вызывали
// push_back()), а теперь обратимся с требованием создать еще два
// дочерних узла (bool и number)
root["map"]["bool"] = true;
root["map"]["number"] = "123";

// То тип root["map"] автоматически расширится с Sequence до Map
// поскольку мы стали добавлять в последовательность уже пары
// типа (key : value), а не отдельные элементы-значения
assert(root["map"].IsMap());

YAML-текст, соответствующий коду, станет таким:

map:
  0: one
  1: two
  bool: true
  number: 123

Помимо того, что сменился тип c Sequence на Map, к элементам, теперь уже бывшей последовательности, автоматически добавились ключи в виде индексов (начиная со значения 0), чтобы словарь был синтаксически корректным.

Еще один пример, для демонстрации преобразования типа YAML-узла с последовательности на словарь:

YAML::Node seq_map_node;

// Присвоен тип Sequence
seq_map_node[0] = 0;
assert(seq_map_node.IsSequence()); // [0]

// Пока еще тип Sequence
seq_map_node.push_back(1); // [0, 1]
assert(seq_map_node.IsSequence());

// Обращение к элементу по индексу seq_map_node.size() = 2
// пока еще Sequence 
seq_map_node[seq_map_node.size()] = 2; // [0, 1, 2]
assert(seq_map_node.IsSequence());

// Обратились к элементу по индексу [seq_map_node.size() + 1] = [4], 
// то уже получится Map
seq_map_node[seq_map_node.size() + 1] = 3; // {0: 0, 1: 1, 2: 2, 4: 3}
assert(seq_map_node.IsMap());

Узел всегда создается при обращении по ключу и, при этом, если ключ:

  • не целочисленный, то узел станет ассоциативным массивом (Map)

  • целочисленный, то при:

    • IntegerKey >= 0 && IntegerKey <= Node.size() - тип Sequence узлу присвоится при первом обращении и сохранится при последующих

    • IntegerKey > Node.size() - тип узла преобразуется на Map

Конечно же, yaml-cpp предоставляет возможность создавать YAML::Node и с помощью обычного конструктора, принимающего С++ типы данных в качестве аргумента:

// Примеры с созданием узлов с содержимым типа Scalar
YAML::Node str_node("String");
assert(str_node.IsScalar());
assert(str_node.as<std::string>() == "String");

YAML::Node int_node(101);
assert(int_node.IsScalar());
assert(int_node.as<int>() == 101);

// Создаем узел Sequence из std::vector
auto vec = std::vector<int>({1,2,3});
YAML::Node sec_node(vec);
assert(sec_node.IsSequence());
assert(sec_node.as<std::vector<int>>() == vec);

// Создаем узел Map из std::map
auto map = std::map<int, char>({{1, 'a'}, {2, 'b'}});
YAML::Node map_node(map);
assert(map_node.IsMap());
assert(map_node.as<decltype(map)>() == map);

Также узел можно заполнить и из YAML-текста, однако, если в нем будут содержаться ошибки, то нужно быть готовым поймать исключение:

YAML::Node root;
root["seq"] = YAML::Load("['a','b','c']"); // Из YAML-текста получаем Sequence-узел
root["map"] = YAML::Load("{x: X, y: Y, z: Z}"); // Из YAML-текста получаем Map-узел

// Важно, что при таком создание узлов, в них сохранится кроме значений 
// еще и стиль (однострочный или многострочный), в которым были 
// представлены входные данные

assert(root["seq"].IsSequence());
assert(root["map"].IsMap());

assert(root["seq"].size() == 3);
assert(root["map"].size() == 3);

assert(root["seq"][0].as<char>() == 'a');
assert(root["map"]["x"].as<std::string>() == "X");

Код выше соответствует такому YAML-тексту:

seq: [a, b, c]
map: {x: X, y: Y, z: Z}

А вот пример как создать подстановку с помощью YAML-псевдонима (*) и YAML-якоря(&):

YAML::Node root;
root["seq"] = YAML::Load("['a','b','c']");     
root["map"] = YAML::Load("{x: X, y: Y, z: Z}");
root["alias"] = root["seq"];

Результат:

 seq: &1 [a, b, c]
 map: {x: X, y: Y, z: Z}
 alias: *1

Видно, что при создании нового узла путем присваивания ему уже существующего (в этом же YAML-дереве) узла автоматически создается YAML-подстановка (ссылка и якорь).

Конструирование YAML-документа с помощью YAML::Node рассмотрели, переходим теперь к возможностям формирования YAML-текста.

Генерация YAML с использованием возможностей форматирования YAML::Node

Для генерации YAML-текста библиотека yaml-cpp предоставляет класс YAML::Emitter. Интерфейса работы с объектами этого класса очень похож на таковой потока вывода std::ostream.

YAML::Emitter оснащен богатым набором функций:

  • перегрузки оператора << для поддерживаемых типов (для пользовательских мы можем написать свои)

  • метод c_str() для получения содержимого потока вывода в виде строки

  • метод good() для получения состояния потока вывода

  • группы методов для локальных и глобальных настроек форматирования

В предыдущем подразделе мы разобрали основные способы формирования YAML-документа с помощью дерева из YAML::Node. Вот как можно теперь получить для готового дерева соответствующее YAML-представление:

YAML::Emitter out;

YAML::Node node;
node["root"]["map"]["k1"] = "v1";
node["root"]["map"]["k2"] = "v2";
node["root"]["seq"] = std::vector<int>({1,2,3,4});

// Можно сразу направить YAML::Node в объект YAML::Emitter
out << node;

// Убедимся, что все ок
assert(out.good());

// Как и у std::string у YAML::Emitter есть метод c_str()
std::cout << out.c_str() << std::endl;

Такой код выведет на экран следующий текст:

root:
  map:
    k1: v1
    k2: v2
  seq:
    - 1
    - 2
    - 3
    - 4

Но что, если требуется выводить последовательность и ассоциативный массив в однострочном формате? Хорошая новость в том, что на это можно повлиять как на уровне объектов YAML::Node (метод SetStyle()), так и с помощью YAML::Emitter.
YAML::Node::SetStyle() позволяет задать оба стиля форматирования:

  • Block (блочный)

  • Flow (однострочный)

Пример форматирования только для root["map"] и root["seq"] из кода выше:

YAML::Node node;
node["root"]["map"]["k1"] = "v1";
node["root"]["map"]["k2"] = "v2";
node["root"]["seq"] = std::vector<int>({1,2,3,4});

// Задаем однострочный формат представления для узлов seq и map
node["root"]["seq"].SetStyle(YAML::EmitterStyle::Flow);
node["root"]["map"].SetStyle(YAML::EmitterStyle::Flow);
YAML::Emitter out;
out << node;
std::cout << out.c_str() << std::endl;

результат:

root:
  map: {k1: v1, k2: v2}
  seq: [1, 2, 3, 4]

А можно было для корневого узла root задать однострочный стиль:

YAML::Node node;
node["root"]["map"]["k1"] = "v1";
node["root"]["map"]["k2"] = "v2";
node["root"]["seq"] = std::vector<int>({1,2,3,4});

// Задаем однострочный формат представления для всей иерархии узлов
node.SetStyle(YAML::EmitterStyle::Flow);
YAML::Emitter out;
out << node;
std::cout << out.c_str() << std::endl;

на выходе получим весь документ в однострочном формате:

{root: {map: {k1: v1, k2: v2}, seq: [1, 2, 3, 4]}}

Стиль форматирования, установленный для узла, распространяется на сам узел и все его дочерние узлы. На этом возможности по форматированию выходного YAML-текста с помощью YAML::Node исчерпаны.

Код генерации YAML на основе этого подхода получается лаконичным и ясным, но возможности форматирования ограничены только настройками стиля.

Генерация YAML с использованием возможностей форматирования YAML::Emitter

Второй способ генерации YAML-текста заключается в использовании возможностей связки YAML::Emitter и манипуляторов типа YAML::EMITTER_MANIP.

Вот основные из манипуляторов:

  • BeginDoc/EndDoc - для работы с многодокументным YAML-текстом

  • BeginMap/EndMap - задают начало и конец ассоциативного массива

  • BeginSec/EndSeq - задают начало и конец последовательности

  • Key/Value - задают ключ и значение

и вспомогательные:

  • Auto/Flow/Block - управление стилем форматирования

  • Anchor/Alias - работа с якорями и ссылками

  • Comment - запись комментариев

  • Hex/Dec/Oct - представление целых чисел

  • SingleQuoted/DoubleQuoted/Literal - представление строк

Это далеко не все и в документации (ее особо и нет, только примеры) они не описаны (во всяком случае, я не смог найти). Лучше всего, при необходимости, заглянуть в файл исходного кода yaml-cpp/emittermanip.h и поэкспериментировать с ними.

Важно. По умолчанию, YAML::Emitter формирует выходной YAML-текст в кодировке UTF-8. Этим можно управлять с помощью метода SetOutputCharset().

Манипуляторы распространяют свое действие от узла, на котором их включили и далее вниз по иерархии. Например, если мы применили манипулятор Flow перед BeginMap, то однострочный стиль завершит свое действие только после соответствующего вызова EndMap.

Код с использованием этой связки (YAML::Emitter и манипуляторов) выглядит так:

YAML::Emitter out;

// Корневой Map для создания в нем вложенного словаря root
out << YAML::BeginMap;
    // Сам узел root
    out << YAML::Key << "root";
    out << YAML::Value;
        // Узел root тоже будет типа Map
        out << YAML::BeginMap;
            // Создаем узел seq для хранения Sequence root["seq"]
            out << YAML::Key << "seq";
            out << YAML::Value;
                // Задаем однострочный стиль узла-хранителя Sequence
                out << YAML::Flow;
                // Начало последовательности
                out << YAML::BeginSeq;
                // Заполняем последовательность значениями
                    out << 1 << 2 << 3;
                // Конец последовательности
                out << YAML::EndSeq;
            // Второй дочерний узел root["number"]
            out << YAML::Key << "number";
            out << YAML::Value << "101";
        // Завершаем Map вложенный в root
        out << YAML::EndMap;
// Завершаем root Map
out << YAML::EndMap;

std::cout << out.c_str() << std::endl;

Результат кода в консоли будет:

root:
  seq: [1, 2, 3]
  number: 101

Для облегчения восприятия кода я использовал табуляцию, но на мой взгляд, этот способ генерации YAML не так так элегантен, как подход с YAML::Node. А если в чем-то проиграли, значит в чем-то должны и выиграть. И вот какие возможности теперь у нас появились:

YAML::Emitter out;

// Устанавливаем размер отступов
out.SetIndent(8);
// Контролируем стиль заполнения булевый полей
// on/off yes/no true/false
out.SetBoolFormat(YAML::EMITTER_MANIP::YesNoBool);
out << YAML::Comment("Начало документа");
out << YAML::BeginMap;
    out << YAML::Key << "root";
    out << YAML::Anchor("rootAlias");
    out << YAML::Value;
        out << YAML::BeginMap;
            // Создаем узел root["bool"]
            out << YAML::Key << "bool";
            out << YAML::Value << true;
            // Второй дочерний узел root["float"]
            out << YAML::Key << "float";
            // Задаем точность при печати чисел с плавающей точкой
            out << YAML::Value << YAML::DoublePrecision(3) << 3.14;
            out << YAML::Key << "hex";
            // Пишем в шестнадцатеричном виде число
            out << YAML::Value << YAML::EMITTER_MANIP::Hex << 255;
        // Завершаем Map вложенный в root
        out << YAML::EndMap;
    out << YAML::Key << "copy";
    out << YAML::Alias("rootAlias");
// Завершаем root Map
out << YAML::EndMap;
out << YAML::EMITTER_MANIP::Newline;
out << YAML::Comment("Конец документа");

И в результате получили YAML с :

  • комментариями

  • именованными якорем и ссылкой

  • заданным размером отступов 8

  • "yes/no" для булевого поля

  • шестнадцатеричным представлением целого числа (255)

# Начало документа
root:   &rootAlias
        bool:   yes
        float:  3.14
        hex:    0xff
copy:   *rootAlias
# Конец документа

Важно. Тем, кому потребуется выводить в YAML числа с плавающей точкой, нужно иметь в виду, что если явно не задать точность, как в примере выше, то можно на выходе получить текст вида "3.1400000000000001". При считывании такого значения с помощью yaml-cpp проблем не будет, но лучше такие моменты контролировать.

Еще одна очень полезная возможность заключается в том, что мы можем предоставить перегрузку operator << для своей структуры данных:

struct Person {
    std::string full_name;
    int age;
};

YAML::Emitter& operator << (YAML::Emitter& out, const Person& p) {
    out << YAML::Flow;
    out << YAML::BeginMap;
        out << "full_name" << p.full_name;
        out << "age" << p.age;
    out << YAML::EndMap;
    return out;
}

void check_overload() {
    Person p{"Иван Иванович", 37};
    YAML::Emitter out;
    out << p;
    std::cout << out.c_str();
}

// Будет сформировано
// {full_name: Иван Иванович, age: 37}

Последний пример, демонстрирует работу с манипуляторами BeginDoc/EndDoc для многодокументного вывода и DoubleQuoted/SingleQuoted для управления кавычками:

YAML::Emitter out;

out << YAML::BeginDoc << YAML::BeginMap;
out << YAML::Key << "doc1_key";
out << YAML::Value << YAML::DoubleQuoted << "double qouted text";
out << YAML::Comment("doc 1");
out << YAML::EndMap << YAML::EndDoc;

out << YAML::BeginDoc << YAML::BeginMap;
out << YAML::Key << "doc2_key";
// Пропускаем YAML::Value, и тоже будет работать аналогично блоку с doc1_key выше.
out << YAML::SingleQuoted << "single qouted text";
out << YAML::Comment("doc 2");
out << YAML::EndMap << YAML::EndDoc;

std::cout << out.c_str() << std::endl;

Выходной YAML-текст:

---
doc1_key: "double qouted text"  # doc 1
...
---
doc2_key: 'single qouted text'  # doc 2
...

В примере кода выше (строки 11-12), следует отметить, что после YAML::Key можно явно не указывать YAML::Value. YAML::Emitter, по умолчанию, после манипулятора YAML::Key ожидает, что следующая порция данных будет относиться к значению.
Более того, можно и YAML::Key пропускать. В пределах YAML::BeginMap/YAML::EndMap YAML::Emitter ожидает ввода пар ключ/значение. Однако, в таком коде потом легко и самому запутаться и тем, кто будет его поддерживать.

Важно. Библиотека yaml-cpp поддерживает спецификацию YAML 1.2, поэтому при генерации YAML-документов, которые в дальнейшем могут считывать другие программы, нужно иметь в виду, что далеко не все парсеры поддерживают эту спецификацию.

В итоге YAML::Emitter и манипуляторы дают возможность гибко настраивать формат выходного YAML-текста, а платой за это будет более многословный код.

Микс из обоих подходов

Мы рассмотрели оба подхода для генерации YAML-текста по отдельности, но их можно комбинировать, учитывая преимущества и недостатки каждого.

YAML::Emitter out;

YAML::Node primes(std::vector<int>{2,3,5,7,11});
primes.SetStyle(YAML::EmitterStyle::Flow);

out.SetDoublePrecision(3);  // Задаем точность вывода чисел с плавающей
out << YAML::BeginMap;
out << "math";
out << YAML::BeginMap;
// YAML::Emitter ожидает пары Key/Value, тут все корректно
out << "pi" << 3.14;        
out << "e" << 2.718;
// Пишем уже заполенный последовательностью YAML::Node
out << "primes" << primes;
out << YAML::EndMap;
out << YAML::EndMap;

assert(out.good());

std::cout << out.c_str() << std::endl;

Код выведет ожидаемый YAML-текст:

math:
  pi: 3.14
  e: 2.72
  primes: [2, 3, 5, 7, 11]

Комбинируя оба подхода по генерации YAML-текста, можно добиться оптимального баланса между простотой реализации на С++ и качеством форматирования выходного YAML-документа.

Важно. При использовании смешанного режима нужно учитывать, что настройки форматирования для YAML::Emitter не будут распространяться на YAML::Node. А настройки форматирования, YAML::Node, как мы помним, позволяют управлять только стилем вывода (Flow, Block).

Обработка ошибок

При записи данных в объекты YAML::Emitter легко допустить ошибки, например, перепутать порядок порядок открывающих/закрывающих манипуляторов. В случае ошибки, объект YAML::Emitter перестанет воспринимать очередные попытки записи данных.

YAML::Emitter предоставляет два метода для контроля ошибок:

  • good() - проверяет состояние потока YAML

  • GetLastError() - возвращает описание ошибки

При ошибках записи данных в YAML::Node могут генерироваться исключения. Вот код с примерами получения информации о некоторых ошибках:

// Узлу присвоится тип Scalar
YAML::Node root(100);

try {
    // Обраще��ие c узлом типа Scalar как с Map выбросит исключение
    root["key"] = 200;
} catch (const YAML::Exception& ex) {
    // Будет выведено "operator[] call on a scalar (key: "key")"
    std::cerr << ex.what() << std::endl;
}

YAML::Emitter out;
assert(out.good());

out << YAML::EndMap; // Вызов EndMap без предварительного BeginMap
assert(!out.good());

// В err_str запишется "unexpected end map token"
std::string err_str = out.GetLastError();

К этому моменту, надеюсь, у меня получилось дать представление об основных функциональных возможностях библиотеки yaml-cpp по части генерации YAML-документов.

Реализация сохранения С++ структуры с конфигом в YAML-файл

В практической части прошлой статьи мы разрабатывали простое С++/CMake приложение yaml-demo, которое считывало YAML-конфиг из файла(или строки) в соответствующую С++ структуру для хранения этих настроек. А в этой статье завершим разработку yaml-demo, добавив возможность сохранять настройки программы в YAML-формате. Ссылка на исходный код всего проекта будет приведена в конце статьи.

Итак, нам нужно С++ структуру с настройками программы:

struct ServerSettings {
    std::string proto;
    std::uint16_t port{0};
};

struct LogSettings {
    std::string level;
    std::string folder;
};

struct MtlsSettings {
    bool use_mtls_auth{false};
    std::vector<float> versions;
    std::string ca_cert;
    std::string server_cert;
    std::string server_key;
};

struct DemoConfig {
    std::vector<ServerSettings> servers;
    LogSettings logs;
    MtlsSettings mtls;
};

Сохранить в YAML-файл конфигурации в следующем виде:

servers:
    - protocol: socks5
      port: 1080
    - protocol: http(s)
      port: 2080

logging:
    level: debug
    folder: './log'

mtls_auth:
    enabled: on
    tls:
        versions: [1.2, 1.3]
    certificates:
        ca_cert: ca.pem
        server_cert: server-cert.pem
        private_key: server-key.pem

Также добавлю еще требование, чтоб выходной YAML посимвольно соответствовал представленному выше тексту.

Начнем с того, что напишем по одной свободной функции для формирования YAML-строки для каждого из разделов конфига.
В проекте это файл yaml_demo/lib/yaml_config/src/demo_config_manager.cpp.

servers:

/* Запись секции servers
servers:
    - protocol: socks5
      port: 1080
    - protocol: http(s)
      port: 2080
*/
void write_to_yaml_stream(YAML::Emitter& out, 
                          const std::vector<ServerSettings>& servers) 
{
    using namespace YAML;
    out << Key << "servers";
    out.SetLocalIndent(2);
    out << BeginSeq;
        for (const auto& [proto, port] : servers)
            out << BeginMap << "protocol" << proto << "port" << port << EndMap;
    out << EndSeq;
    out << Newline << Newline;
}

logging:

/* Запись секции logging
logging:
    level: debug
    folder: './log'
*/
void write_to_yaml_stream(YAML::Emitter& out, const LogSettings& logs)
{
    using namespace YAML;
    out << Key << "logging" << BeginMap;
        out << "level" << logs.level;
        out << "folder" << SingleQuoted << logs.folder;
        out << EndMap;
    out << Newline << Newline;
}

mtls_auth:

/* Запись секции mtls_auth
mtls_auth:
    enabled: on
    tls:
        versions: [1.2, 1.3]
    certificates:
        ca_cert: ca.pem
        server_cert: server-cert.pem
        private_key: server-key.pem
*/
void write_to_yaml_stream(YAML::Emitter& out, const MtlsSettings& mtls)
{
    using namespace YAML;
    out << Key << "mtls_auth" << BeginMap;
        // OnOffBool - включает запись on|off вместо true|false
        out << "enabled" << OnOffBool << mtls.use_mtls_auth;
        out << "tls";
        out << BeginMap << "versions" << Flow << FloatPrecision(3);
            // Автоматически сработает перегрузка для std::vector<float>
            // Поэтому можно явно не указывать BeginSec|EndSeq
            out << mtls.versions;
        out << EndMap;
        out << "certificates" << BeginMap;
            out << "ca_cert" << mtls.ca_cert;
            out << "server_cert" << mtls.server_cert;
            out << "private_key" << mtls.server_key;
        out << EndMap;
    out << EndMap;
    out << Newline;
}

Все эти функции получают на вход ссылку на объект YAML::Emitter и ссылку на структуру с настройками для дальнейшей записи в корень YAML-документа своей части настроек.

Функция to_string() создает корневой Map YAML::документа, задает глобальные настройки для YAML::Emitter и делегирует заполнение основных разделов конфига описанным выше функциям.

// Функция из структуры с конфигом формирует и возвращает строку
// с YAML представлением конфига
std::string to_string(const DemoConfig& cfg)
{
    YAML::Emitter out;
    out.SetIndent(4); // Задаем отступы 4 символа

    out << YAML::BeginMap;
    write_to_yaml_stream(out, cfg.servers);
    write_to_yaml_stream(out, cfg.logs);
    write_to_yaml_stream(out, cfg.mtls);
    out << YAML::EndMap;

    // Если были какие-либо ошибки при записи, информируем пользователя
    if (!out.good())
        throw std::runtime_error(out.GetLastError());

    return out.c_str();
}

Функции, которые мы к этому моменту написали могут генерировать исключения, поэтому поместим их вызовы в другую функцию save_to_yaml(), которая будет отвечать за перехват и обработку исключений:

// Формирует для пользователя доступную информацию об ошибке
template <typename T>
std::string gen_save_err_msg(std::string_view source, const T& ex)
{
    std::ostringstream oss;
    oss << "An error occurred while saving settings to [" << source << "]\n";
    oss << "Error details: " << ex.what();
    return oss.str();
};

// В source передается информация о том куда записать YAML-документ
// Это будет использовано при выводе описания ошибки (gen_save_err_msg)
// Внутри лямбды save_yaml_fn будет происходить вызов to_string(),
// который сгенерирует для DemoConfig соответсвующий YAML-текст
template <typename F>
bool save_to_yaml(std::string_view source, F save_yaml_fn)
{
    try {
        return save_yaml_fn();
    }
    catch (const YAML::Exception& ex) {
        std::cerr << gen_save_err_msg(source, ex);
    }
    catch (const std::exception& ex) {
        std::cerr << gen_save_err_msg(source, ex);
    }
    catch (...) {
        std::cerr << gen_save_err_msg(source, std::runtime_error{"Unknown error"});
    }

    return false;
}

save_yaml_fn (19 строка) - это лямбда функция, которая определена в методах класса DemoConfigManager. Она инкапсулирует логику сохранения результирующего YAML-документа из объекта YAML::Emitter в файл или строку и описана ниже:

bool DemoConfigManager::save_to_file(const std::string& path, const DemoConfig& cfg)
{
    auto save_yaml_fn = [&path, &cfg]() {
        std::ofstream ofs(path, std::ios::trunc);
        if (!ofs.is_open())
            throw std::runtime_error{"File open error"};
        ofs << to_string(cfg);
        if (!ofs.good())
            throw std::runtime_error{"File write error"};
        return true;
    };

    return save_to_yaml(path, save_yaml_fn);
}

bool DemoConfigManager::save_to_string(std::string& out_str, const DemoConfig& cfg)
{
    auto save_yaml_fn = [&out_str, &cfg]() {
        out_str = to_string(cfg);
        return true;
    };

    return save_to_yaml(out_str, save_yaml_fn);
}

Сам класс DemoConfigManager отвечает за предоставление интерфейса работы с конфигом приложению yaml-demo (yaml_demo/lib/yaml_config/include/yaml_config/demo_config_manager.h):

class DemoConfigManager {
public:
    bool load_from_file(const std::string& path, DemoConfig& cfg);
    bool load_from_string(const std::string& str, DemoConfig& cfg);

    bool is_loaded() const { return is_loaded_; }

    // Сохранение в YAML-файл
    bool save_to_file(const std::string& path, const DemoConfig& cfg);
    // Сохранение в YAML-строку
    bool save_to_string(std::string& out_str, const DemoConfig& cfg);

private:
    bool is_loaded_{false};
};

Наконец, применение DemoConfigManager в основном приложении (yaml_demo/src/main.cpp):

#include <yaml_config/demo_config.h>
#include <yaml_config/demo_config_manager.h>

#include <iostream>

const std::string g_yaml_cfg_string{
    "servers:\n"
    "    - protocol: socks5\n"
    "      port: 1080\n"
    "    - protocol: http(s)\n"
    "      port: 2080\n\n"
    "logging:\n"
    "    level: debug\n"
    "    folder: './log'\n\n"
    "mtls_auth:\n"
    "    enabled: on\n"
    "    tls:\n"
    "        versions: [1.2, 1.3]\n"
    "    certificates:\n"
    "        ca_cert: ca.pem\n"
    "        server_cert: server-cert.pem\n"
    "        private_key: server-key.pem\n"
};
const std::string g_yaml_cfg_file_path{"config.yml"};
const std::string g_yaml_cfg_out_file_path{"config_out.yml"};

int main()
{
    demo::DemoConfigManager cfg;

    // Чтение YAML-настроек из строки
    demo::DemoConfig settingsFromString;
    std::cout << "=== Read from string ===\n";
    cfg.load_from_string(g_yaml_cfg_string, settingsFromString);
    if (cfg.is_loaded())
        std::cout << settingsFromString << std::endl;

    // Чтение YAML-настроек из файла
    demo::DemoConfig settingsFromFile;
    std::cout << "=== Read from file ===\n";
    cfg.load_from_file(g_yaml_cfg_file_path, settingsFromFile);
    if (cfg.is_loaded())
        std::cout << settingsFromFile << std::endl;

    std::cout << std::endl;

    // Запись YAML-настроек в файл
    if (cfg.save_to_file(g_yaml_cfg_out_file_path, settingsFromString))
        std::cout << "Saving config to " 
                  << g_yaml_cfg_out_file_path << " was successful";
  
    std::cout << std::endl;

    // Запись YAML-настроек в строку
    std::string yaml_cfg_string;
    if (cfg.save_to_string(yaml_cfg_string, settingsFromString))
        std::cout << "Saving config to yaml string was successful";
    std::cout << std::endl;

    // Сравниваем сформированную строку с эталоном
    if (yaml_cfg_string == g_yaml_cfg_string)
        std::cout << "The resulting YAML string is equivalent to the original one.";

    // Выводим результат сохранения конфига в YAML-строку
    std::cout << yaml_cfg_string;

    return 0;
}

После запуска yaml-demo выполняет следующие действия:

  • считывает в С++ структуру настройки из строки и из файла config.yml

  • сохраняет настройки из С++ структуры в строку и в файл config_out.yml

  • промежуточные результаты выводит в стандартный поток вывода

Часть вывода программы:

Результат выполнения программы yaml_demo
Результат выполнения программы yaml_demo

Как видно по результату работы программы, на экран выводится точно такой же YAML-текст, который был изначально считан из конфигурационного файла config.yml.

Используя возможности форматирования yaml-cpp, мы сформировали не просто корректный YAML-документ, но и добились того, чтоб он выглядел как нам нужно (отступы, блочный/однострочный стили, кавычки, точность данных с плавающей запятой, вывод булевого поля в режиме on/off).

Полный исходных код примера доступен по ссылке на GitHub.

Работоспособность проверена на:

  • Debian 13 (Qt Creator 16.0.1, CMake 3.31.6, GCC 14.2.0, Ninja 1.12.1)

  • Manjaro 26.0.0 (Qt Creator 18.0.1, CMake 4.2.1, GCC 15.2.1, Ninja 1.13.2)

  • Windows 11 (Visual Studio 2022 Community Edition, CMake 3.36.1, Ninja 1.11.1)

Заключение

В этой статье были продемонстрированы возможности библиотеки yaml-cpp для решения задач генерации YAML-документов. В практической части туториала мы завершили разработку учебного приложения yaml-demo (начатую в прошлой статье), добавив в него возможность сохранения конфигурации в YAML-файл или YAML-строку.

Также я поделился нюансами, с которыми сталкивался сам при работе с yaml-cpp. Надеюсь, статья будет полезной и позволит тем, кому эта тема интересна, сэкономить время при решении подобных задач.

Буду рад конструктивной критике и обязательно ее учту при подготовке новых статей.