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

Но, все меняется. Рабочие и мои личные проекты постепенно начинают обзаводиться поддержкой формата YAML. Мне не удалось найти на Хабре статей о библиотеке yaml-cpp, поэтому я постараюсь частично это исправить.

В этой статье я поделюсь опытом как:

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

  • надежно и удобно организовать чтение конфига в C++ структуру

  • избежать потенциальных ошибок

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

Введение

В этом туториале я буду исходить из того, что читатель знаком с С++ и CMake. О формате YAML можно получить представление на вики. Также на Хабре есть довольно много отличных статей:

Почему YAML

Программы, в которые мне приходилось добавлять поддержку YAML, использовали до этого преимущественно такой набор: INI, JSON, XML, TOML. Также, некоторые из приложений читали свои конфиги из довольно специфичных форматов. У каждого формата есть свои плюсы и минусы, однако мой выбор в пользу YAML был обусловлен преимущественно следующими факторами:

  • выразительность, структурированность и удобство правки в текстовом редакторе (особенно с подсветкой синтаксиса)

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

  • возможность оставлять комментарии к настройкам

  • формат должен быть достаточно популярным.

Помимо С++, в работе активно использовались Docker Compose и Java Spring Boot 3, поэтому YAML уже был хорошо знаком команде. В итоге этот формат оказался удобным и всех устроил.

Почему библиотека YAML-CPP

В 2022 году выбирать было из чего, например, rapidyaml(ryml), libyaml или yaml-cpp. Все с лицензией MIT. Вот что мы учитывали, когда определялись с библиотекой:

  • удобство встраивания в С++ код

  • полнота поддержки формата (на будущее, пусть лучше будет, чем нет)

  • удобство встраивания в CMake проект (у нас все такие)

  • кроссплатформенность (Windows/Linux)

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

  • библиотека должна быть стабильной и развивающейся.

При решении задач по передаче/парсингу или записи больших объемов данных в формате YAML, возможно лучше смотреть в сторону rapidyaml или libyaml, но для наших задач вполне подошла и библиотека yaml-cpp (версия на момент написания статьи 0.8.0). В ней, кстати, заявляется поддержка спецификации YAML 1.2.

Кратко об интерфейсе yaml-cpp

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

Вся работа происходит с классом YAML::Node. Именно через объекты этого типа можно получить доступ ко всему содержимому, которое парсер библиотеки распознает и считает из файла (или из памяти). 

Замечу также, что это содержимое будет представлено в памяти в виде YAML-документа с древовидной структурой.


Парсер YAML предоставляет несколько методов для чтения входных данных:

YAML_CPP_API Node Load(const std::string& input);
YAML_CPP_API Node Load(const char* input);
YAML_CPP_API Node Load(std::istream& input);
YAML_CPP_API Node LoadFile(const std::string& filename);

Код загрузки YAML-файла будет выглядеть так:

YAML::Node config = YAML::LoadFile("config.yaml");

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

YAML::Node config = YAML::Load("key: value");

Также есть и аналогичные методы LoadAll() и LoadAllFromFile() для чтения многодокументных YAML-файлов:

void check_multi_document_yaml() {
    using std::vector;
    using YAML::Node;

    vector<Node> docs = YAML::LoadAll(
        "---\n"
        "doc1: value1\n"
        "---\n"
        "doc2: value2");

    assert(docs.size() == 2);
    assert(docs[0]["doc1"].as<std::string>() == "value1");
    assert(docs[1]["doc2"].as<std::string>() == "value2");
}

Для получения содержимого YAML::Node по ключу существует перегрузка оператора для доступа по индексу (operator[]):

 template <typename Key>
 Node operator[](const Key& key);

Очень коротко, в виде одной функции, продемонстрирую основные операции и проверки, поддерживаемые YAML::Node:

void check_node()
{
    const std::string yamlInput{
        "root:\n"
        "# comment before map\n"
        "    number: 10\n"
        "    bool: on\n"
        "    string: str\n"
        "    array: [1,2,3]\n"
        "    map: { a: 1, b: '2' }"
    };

    YAML::Node rootNode = YAML::Load(yamlInput);

    const YAML::Node& number_node = rootNode["root"]["number"];

    // Работаем с числом
    assert(!number_node.IsNull());
    assert(number_node.IsDefined());
    assert(number_node.IsScalar());
    assert(!number_node.IsSequence());
    assert(number_node.as<int>() == 10);

    // Работа с булевым значением true
    const YAML::Node& bool_node = rootNode["root"]["bool"];
    assert(!bool_node.IsNull());
    assert(bool_node.IsDefined());
    assert(bool_node.IsScalar() && !bool_node.IsSequence());
    assert(bool_node.as<bool>() == true);

    // Работа со строковым значением
    const YAML::Node& string_node = rootNode["root"]["string"];
    assert(!string_node.IsNull());
    assert(string_node.IsDefined());
    assert(string_node.IsScalar() && !string_node.IsSequence());
    assert(string_node.as<std::string>() == "str");

    // Работа с массивом значений
    const YAML::Node& array_node = rootNode["root"]["array"];
    assert(!array_node.IsNull());
    assert(array_node.IsDefined());
    assert(array_node.IsSequence() && !array_node.IsScalar());

    std::vector<int> vec;
    for (const auto& it : array_node)
        vec.push_back(it.as<int>());

    assert(vec == std::vector<int>({1, 2, 3}));

    // Но можно было считать массив и сразу
    std::vector<int> vec2 = array_node.as<std::vector<int>>();

    assert(vec2 == std::vector<int>({1, 2, 3}));

    // Работа со словарем
    const YAML::Node& map_node = rootNode["root"]["map"];
    assert(!map_node.IsNull());
    assert(map_node.IsDefined());
    assert(map_node.IsMap() &&
           !map_node.IsScalar() &&
           !map_node.IsSequence());

    // Проверка значений словаря по ключу.
    assert(map_node["a"].as<std::string>() == "1");

    // Если строка представляет собой корректное число,
    // то можно и сразу получить число
    assert(map_node["b"].as<int>() == 2);
}

Шаблонный метод as<T>() очень мощный и удобный, позволяет даже получать содержимое в виде контейнеров C++ STL или задавать значения по умолчанию:

void check_node_as() {
    // Получение значения по умолчанию
    YAML::Node default_node = YAML::Load("number: ");
    assert(default_node.as<int>(10) == 10);

    // Получение std::vector<int>
    YAML::Node array_node = YAML::Load("[1, 2, 3]");
    auto array_value = array_node.as<std::vector<int>>();
    assert(array_value.size() == 3);
    assert(array_value[1] == 2);

    // Получение std::map<std::string, int>
    YAML::Node map_node = YAML::Load("{a: 1, b: 2, c: 3}");
    auto map_value = map_node.as<std::map<std::string, int>>();
    assert(map_value.size() == 3);
    assert(map_value["c"] == 3);
}

Теперь тонкости, связанные с методами IsDefined() и IsNull():

void check_node_methods() {
    // Пустой узел
    YAML::Node node;

    // Он определен (существует)
    assert(node.IsDefined());

    // Но не содержит значения
    assert(node.IsNull());

    // null_value = "null"
    auto null_value = node.as<std::string>();

    // Узел с ключем "value" пока не определен (не существует)
    assert(!node["value"].IsDefined());

    node["value"] = "";

    // not_null_value = ""
    auto not_null_value = node["value"];

    // Теперь узел с ключом "value" определен (существует)
    assert(node["value"].IsDefined());

    // Теперь узел содержит значение - пустая строка
    assert(!node.IsNull());
}

Также небольшой кусок кода про получение информации об ошибках:

void check_load() {
    // Тут все ок, на входе корректный Yaml
    assert(YAML::Load("root:\n  number: 10"));

    // Тут будет исключение YAML::BadFile
    assert(YAML::LoadFile(""));

    // Тут будет YAML::ParserException, на входе формат yaml нарушен
    assert(YAML::Load("\troot:\n  number: 10"));

    {
        YAML::Node root_node = YAML::Load("root:\n  bool: 1");

        // А здесь будет выброшено исключение YAML::Exception (bad conversion)
        // ошибка конвертации из числового типа в булевый тип
        assert(root_node["bool"].as<bool>() == true);
    }
}

Еще одна очень удобная для задач сериализации/десериализации возможность yaml-cpp заключается в том, библиотека предоставляет объявление обобщенного шаблона YAML::convert:

namespace YAML {
template <typename T>
struct convert;
}  // namespace YAML

Чтобы этим воспользоваться нужно предоставить для пользовательского типа данных полную специализацию с учетом того, что:

  • функция encode() отвечает за запись из пользовательского типа в YAML::Node

  • функция decode() отвечает за чтение из YAML::Node в пользовательский тип

Пример ниже иллюстрирует эту возможность:

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

namespace YAML {
    template<>
    struct convert<Person> {
        static Node encode(const Person& person) {
            Node node;
            node["full_name"] = person.full_name;
            node["age"] = person.age;
            return node;
        }

        // При возврате false будет выброшено исключение BadConversion
        static bool decode(const Node& node, Person& person) {
            if (!node.IsMap() || node.size() != 2)
                return false;

            person.full_name = node["full_name"].as<std::string>();
            person.age = node["age"].as<int>();
            return true;
        }
    };
}

void check_convert() {
    // "person: {full_name: 'Иван Иванов', age: 30}"
    Person person{"Иван Иванов", 30};

    YAML::Node node;
    node["person"] = person;

    Person samePerson = node["person"].as<Person>();
    assert(samePerson.age == 30);
    assert(samePerson.full_name == "Иван Иванов");
}

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

Разработка приложения

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

  • поддержка Linux и Windows (С++/CMake в помощь)

  • входные данные - YAML-файл(или YAML-строка) с настройками

  • программа должна считать настройки в C++ структуру

  • контроль возможных ошибок

Представим, что конфиг имеет следующий вид:

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

Конфиг немного синтетический, но в нем мы будем иметь дело:

  • с булевыми значениями

  • словарями

  • числами (с плавающей точкой и целыми)

  • строками

  • последовательностями (массивами)

  • вложенными узлами

И требуется его считать в такую структуру С++:

namespace demo {
    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_config

  • и самого приложения yaml_demo

Файловая структура проекта такая:

yaml_demo
├── lib
│   └── yaml_config
│       ├── include
│       │   └── yaml_config
│       │       ├── demo_config.h
│       │       └── demo_config_manager.h
│       ├── src
|       |   ├── demo_config.cpp 
│       │   └── demo_config_manager.cpp
│       └── CMakeLists.txt
├── src
│   ├── CMakeLists.txt
│   └── main.cpp
├── CMakeLists.txt
├── CMakePresets.json
└── config.yml

Корневой CMakeLists.txt будет выглядеть так:

cmake_minimum_required(VERSION 3.21)

project ("yaml_demo")

# Глобальные требования к компилятору
set(CMAKE_CXX_STANDARD          17 )
set(CMAKE_CXX_EXTENSIONS        OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON )

# В Windows + MS Visual Studio
if(MSVC)
    # Включение статической линковки
    set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
    # Для удобной отладки
    set(CMAKE_VS_JUST_MY_CODE_DEBUGGING "$<$<CONFIG:Debug>:ON>")
endif()

# Настройка опций для библиотеки yaml-cpp
option(YAML_CPP_BUILD_TESTS off)
option(YAML_BUILD_SHARED_LIBS off)
option(YAML_CPP_INSTALL off)
option(YAML_CPP_BUILD_TOOLS off)

# Загрузка yaml-cpp с официального репозитория на GitHub
include(FetchContent)

FetchContent_Declare(
    yaml-cpp
    GIT_REPOSITORY https://github.com/jbeder/yaml-cpp.git
    GIT_TAG 47cd272
)
FetchContent_MakeAvailable(yaml-cpp)

# Добавление библиотеки, в которой реализовано чтение yaml-конфига
add_subdirectory(lib/yaml_config)

# Добавление основного demo-приложения
add_subdirectory(src)

Здесь я использовал механизм CMake FetchContent. Также можно было бы добавить yaml-cpp к проекту, например, используя Git Submodules. Отмечу еще, что использую коммит 47cd272, поскольку в нем в CMake-файлах библиотеки устранено предупреждение CMake Deprecation Warning(cmake_minimum_required).

yaml_demo/src/CMakeLists.txt

add_executable(yaml_demo)

target_sources(yaml_demo
    PRIVATE
        "${CMAKE_CURRENT_SOURCE_DIR}/main.cpp"
)

target_link_libraries(yaml_demo PRIVATE yaml_config)

# Копирование config.yml в директорию сборки к исполняемому файлу
# для удобства отладки и экспериментов
configure_file(
    "${CMAKE_SOURCE_DIR}/config.yml"
    "${CMAKE_CURRENT_BINARY_DIR}/config.yml"
    COPYONLY
)

yaml_demo/lib/yaml_config/CMakeLists.txt

# Библиотека с функционалом работы с yaml-конфигом
add_library(yaml_config STATIC)

target_include_directories(yaml_config
    PUBLIC
        "$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>"
        "$<INSTALL_INTERFACE:include>"
)

target_sources(yaml_config
    PUBLIC
        "${CMAKE_CURRENT_SOURCE_DIR}/include/yaml_config/demo_config_manager.h"
        "${CMAKE_CURRENT_SOURCE_DIR}/include/yaml_config/demo_config.h"
    PRIVATE
        "${CMAKE_CURRENT_SOURCE_DIR}/src/common_yaml.h"
        "${CMAKE_CURRENT_SOURCE_DIR}/src/demo_config_manager.cpp"
        "${CMAKE_CURRENT_SOURCE_DIR}/src/demo_config.cpp"
)

target_link_libraries(yaml_config PRIVATE yaml-cpp)

Проект библиотеки

Отслеживание текущего YAML-пути

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

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

mtls_auth:
    enabled: on
    tls:
        #versions: [1.2, 1.3]

Если будет отсутствовать ожидаемый узел versions, то в описании ошибки будет присутствовать YAML-путь вида: /mtls_auth/tls/versions. Для больших или сложных файлов, это поможет быстро сориентироваться где в конфиге находится проблемная часть.

Итак, сам класс-помощник (PathTracker):

// Вспомогательный класс
// Поддерживает текущий путь в Yaml-документе
// В случае ошибки, будет содержать актуальный путь к месту ошибки
class PathTracker {
public:
    PathTracker(const std::string& delim) : delim_{delim} {}

    // Нужно вызывать по мере "погружения" в Yaml-дерево
    void enter(const std::string& node_name) { path_.push(node_name); }

    // Нужно вызывать по мере "всплытия" в Yaml-дереве
    void leave() { path_.pop(); }

    // Альтернатива вызову подряд leave() и enter()
    void leave_and_enter(std::string_view node_name) { path_.top() = node_name; }

    // Возвращает имя текущего узла (без полного пути)
    const std::string& current_node() const { return path_.top(); }

    // Из внутреннего стека создает и возвращает текущий путь в виде строки
    std::string const path() {
        // Получение копии стека (чтоб не нарушить инварианты класса)
        auto path = path_;

        // Опустошение стека в вектор
        std::vector<std::string> vec;
        vec.reserve(path.size());
        while (!path.empty()) {
            vec.push_back(path.top());
            path.pop();
        }

        // Восстановление порядка узлов в Yaml-пути
        std::reverse(vec.begin(), vec.end());

        // Лямбдя для склеивания названий узлов в yaml-путь
        auto acc_fn = [&](const std::string& acc, const std::string& str) {
            return acc + (str.empty() ? "" : delim_ + str);
        };

        return std::accumulate(vec.begin(), vec.end(), std::string(), acc_fn);
    }

private:
    // Символ разделителя в пути, важно, чтобы не был ключевым словом YAML
    std::string delim_;

    // Стек отлично подходит для организации поддержки текущего пути в Yaml-дереве
    std::stack<std::string> path_;
};

Работа с PathTracker заключается в вызове методов:

  • enter() при переходе от корня документа на уровень ниже

  • leave() при переходе на уровень выше к корню документа

  • path(), чтобы предоставить полный текущий YAML-путь в обработчике исключений.

Теперь рассмотрим вспомогательные функции, которые сильно сократят объем кода на проверки и получение значений от объектов YAML::Node. Они будут разбиты на две группы:

  • форматирование сообщений об ошибке и генерация исключений

  • проверка узлов YAML-документа

Все эти функции определены в безымянном пространстве имен в файле demo_config_manager.cpp.

Формирование сообщений об ошибке и генерация исключений

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

// Вспомогательная функция
// Формирует в виде строки данные о месте ошибки
std::string mark_to_string(const YAML::Mark& mark) {
    if (!mark.is_null()) {
        std::ostringstream oss;
        oss << "(line: " << std::to_string(mark.line) << ", ";
        oss << "column: " << std::to_string(mark.column) << ", ";
        oss << "position: " << std::to_string(mark.pos) << ")";
        return oss.str();
    }
    return {};
}

// Генерирует исключение об отсутствии узла
// Обогащает сообщение об ошибки информацией о месте ошибки (если доступно)
void throw_field_not_found(std::string_view field, YAML::Mark mark)
{
    std::ostringstream oss;
    oss << "Required <" << field << "> field not found"
        << mark_to_string(mark);
    throw std::runtime_error(oss.str());
}

// Генерирует исключение об отсутствии ненулевого значения у узла
// Обогащает сообщение об ошибки информацией о месте ошибки (если доступно)
void throw_field_empty(std::string_view field, YAML::Mark mark)
{
    std::ostringstream oss;
    oss << "Required <" << field << "> field is empty"
        << mark_to_string(mark);
    throw std::runtime_error(oss.str());
}

// Генерирует исключение об отсутствии узла
void throw_node_not_found(std::string_view path) {
    std::ostringstream oss;
    oss << "Required section: [" << path << "] does not exists";
    throw std::runtime_error(oss.str());
}

// Генерирует исключение об отсутствии ненулевого значения у узла
void throw_node_empty(std::string_view path) {
    std::ostringstream oss;
    oss << "Required section: [" << path << "] is empty";
    throw std::runtime_error(oss.str());
}

Здесь gen_err_msg() формирует и возвращает строку с информацией об ошибке.

А функции с префиксом throw_ генерируют исключения в случае несоответствия ожидаемых данных полученным. 

Проверка узлов YAML-документа

Представленный ниже набор вспомогательных функций использует аргумент типа PathTracker для актуализации текущего YAML-пути.

// Функция проверяет существует ли узел и содержит ли он ненулевое значение
// Если обе проверки не проходят, то генерируется соответствующее исключение
void check_node(const YAML::Node& parent, 
                PathTracker& path, 
                const std::string& node_name)
{
    path.enter(node_name);

    const auto& node = parent[node_name];

    if (!node.IsDefined())
        throw_node_not_found(node_name);

    if (node.IsNull())
        throw_node_empty(node_name);

    path.leave();
}

// Функция проверяет существует ли узел и содержит ли он ненулевое значение.
// Если обе проверки не прошли, то генерируется соответствующее исключение.
// При успешности проверок, попытка считать значение узла в поле field.
template <typename T>
void check_and_get(const YAML::Node& root, 
                   PathTracker& path, 
                   const std::string& node_name, T& field)
{
    path.enter(node_name);

    const auto& node = root[node_name];

    if (!node.IsDefined())
        throw_field_not_found(path.current_node(), root.Mark());

    if (node.IsNull())
        throw_field_empty(path.current_node(), root.Mark());

    field = node.as<T>();

    path.leave();
}

// Функция проверки одного и более узлов Yaml-дерева перед обработкой 
// их значений
void check_nodes(const YAML::Node& root,
                 PathTracker& path,
                 const std::vector<std::string>& sections)
{
    for (const auto& section : sections)
        check_node(root, path, section);
}

Функция check_node() позволяет проверить наличие у родительского узла дочернего узла с предоставленным в качестве аргумента именем. В случае неудачи, будет сформировано и выброшено исключение с описанием проблемы.

Функция check_and_get() выполнит те же проверки что и check_node(), но в случае успеха попытается заполнить предоставленное по ссылке поле field значением из узла.

Стоит обратить внимание на метод Mark() объекта типа YAML::Node. Этот метод, возвращает структуру YAML::Mark, содержащую строку, столбец и позицию места возникновения ошибки. Данные эти не всегда точны и не во всех случаях yaml-cpp их заполняет.

Функция check_nodes() аналогична check_node(), но имеет дело со списком узлов.

Обработка самого конфига

Теперь приступим к получения данных из файла конфигурации с использованием описанных выше вспомогательных функций. 

Класс для работы с конфигом объявлен в файле demo_config_manager.h:

#ifndef DEMO_CONFIG_MANAGER_H
#define DEMO_CONFIG_MANAGER_H

#include <string>

namespace demo {
    struct DemoConfig;

    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_; }

    private:
        bool is_loaded_{false};
    };
}

#endif // DEMO_CONFIG_MANAGER_H

Он позволяет загружать YAML-конфиг как из файла, так и из строки. Вот определения соответствующих функций:

bool DemoConfigManager::load_from_file(const std::string& path, DemoConfig& cfg)
{
    return is_loaded_ = load_node(path, cfg, 
        [&path]() { return YAML::LoadFile(path); });
}

bool DemoConfigManager::load_from_string(const std::string& str, DemoConfig& cfg)
{
    return is_loaded_ = load_node(str, cfg, [&str]() { return YAML::Load(str); });
}

Обе функции делегируют основную работу свободной функции load_node(), которая запустит полученную лямбду, обработает ее результат и доверит заполнение полей С++ структуры DemoConfig функции load_from_yaml_node()

Вот реализация load_node() в файле demo_config_manager.cpp:

template <typename F>
bool load_node(std::string_view source, demo::DemoConfig& cfg, F load_yaml_fn)
{
    // Названия узлов конфига этого примера не содержат символ '/' 
    // Поэтому можно его задать в качестве разделителя в Yaml-пути.
    PathTracker path("/");

    try {
        const YAML::Node root_node = load_yaml_fn();

        demo::DemoConfig settings{};
        load_from_yaml_node(root_node, path, settings);
        cfg = settings;

        return true;
    }
    catch (const YAML::BadFile& ex) {
        std::cerr << gen_err_msg(source, path.path(), ex);
    }
    catch (const YAML::ParserException& ex) {
        std::cerr << gen_err_msg(source, path.path(), ex);
    }
    catch (const YAML::BadConversion& ex) {
        std::cerr << gen_err_msg(source, path.path(), ex);
    }
    catch (const YAML::Exception& ex) {
        std::cerr << gen_err_msg(source, path.path(), ex);
    }
    catch (const std::exception& ex) {
        std::cerr << gen_err_msg(source, path.path(), ex);
    }
    catch (...) {
        std::cerr << gen_err_msg(source, path.path(), 
                                 std::runtime_error{"Unknown error"});
    }

    std::cerr << std::endl;

    return false;
}

Здесь блоку обработки исключений уделено максимальное внимание, чтобы в случае ошибки, предоставить пользователю информацию как о самой ошибке, так и о месте ее обнаружения.

Я обычно придерживаюсь подхода, при котором сначала проверяю наличие крупных секций конфига, без которых его нет смысла вообще разбирать. А потом уже получаю сами настройки из каждой такой секции. Этот подход демонстрируется в реализации load_from_yaml_node():

// Обработка конфига начиная с корня yaml-дерева /
bool load_from_yaml_node(const YAML::Node& root, PathTracker& path, DemoConfig& cfg)
{
    check_nodes(root, path, {"servers", "logging", "mtls_auth"});

    read_servers_section(root["servers"], path, cfg);
    read_logging_section(root["logging"], path, cfg);
    read_mtls_auth_section(root["mtls_auth"], path, cfg);

    return true;
}

Здесь сначала функция check_nodes() проверяет наличие необходимых секций конфига (в противном случае, она выбросит исключение, которое будет обработано) и далее последовательно вызываются функции, заполняющие соответствующие структуры данных С++, значениями:

  • read_server_section()

  • read_logging_section()

// Обработка секции конфига /servers
void read_servers_section(const YAML::Node& root, PathTracker& path, DemoConfig& cfg)
{
    path.enter("servers");
    for (const auto& node : root) {
        ServerSettings settings;
        check_and_get(node, path, "protocol", settings.proto);
        check_and_get(node, path, "port", settings.port);
        cfg.servers.emplace_back(std::move(settings));
    }
    path.leave();
}

// Обработка секции конфига /logging
void read_logging_section(const YAML::Node& root, PathTracker& path, DemoConfig& cfg)
{
    path.enter("logging");
    check_and_get(root, path, "level", cfg.logs.level);
    check_and_get(root, path, "folder", cfg.logs.folder);
    path.leave();
}

Последний блок функций - обработка секции mtls_auth:

// Обработка секции конфига /mtls_auth/tls/versions
void read_mtls_auth_tls(const YAML::Node& root,
                        PathTracker& path, 
                        DemoConfig& cfg)
{
    path.enter("tls");
    check_and_get(root, path, "versions", cfg.mtls.versions);
    path.leave();
}

// Обработка секции конфига /mtls_auth/certificates
void read_mtls_auth_certificates(const YAML::Node& root, 
                                 PathTracker& path, 
                                 DemoConfig& cfg)
{
    path.enter("certificates");
    check_and_get(root, path, "ca_cert", cfg.mtls.ca_cert);
    check_and_get(root, path, "server_cert", cfg.mtls.server_cert);
    check_and_get(root, path, "private_key", cfg.mtls.server_key);
    path.leave();
}

// Обработка секции конфига /mtls_auth
void read_mtls_auth_section(const YAML::Node& root, 
                            PathTracker& path, 
                            DemoConfig& cfg)
{
    path.enter("mtls_auth");
    check_nodes(root, path, {"enabled"});
    check_and_get(root, path, "enabled", cfg.mtls.use_mtls_auth);

    if (cfg.mtls.use_mtls_auth) {
        check_nodes(root, path, {"tls", "certificates"});
        read_mtls_auth_tls(root["tls"], path, cfg);
        read_mtls_auth_certificates(root["certificates"], path, cfg);
    }
}

Применение разработанной библиотеки

Теперь продемонстрирую использование получившейся выше библиотеки в приложении yaml_demo.

Файл yaml_demo/src/main.cpp

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

#include <iostream>
#include <cassert>

const std::string g_yaml_cfg_string{
    "servers:\n"
    "    - protocol: socks5\n"
    "      port: 1080\n"
    "    - protocol: http(s)\n"
    "      port: 2080\n"
    "logging:\n"
    "    level: debug\n"
    "    folder: './log'\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"};

int main()
{
    demo::DemoConfigManager cfg;

    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;

    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;

    return 0;
}

Здесь показано как используя разработанный класс DemoConfigManager:

  • считать YAML-конфигурацию из файла

  • считать YAML-конфигурацию из строки

  • проверить что конфигурация успешно считана

Результат выполнения программы при корректном конфиге выглядит так:

Вывод программы в случае успешной загрузки конфига
Вывод программы в случае успешной загрузки конфига

А вот результат при отсутствии пути /mtls_auth/enabled:

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

Или, например, в последовательности с допустимыми версиями протокола TLS задано некорректное значение "1a.2" вместо "1.2" в подразделе конфига /mtls_auth/tsl/versions:

Вывод программы при некорректном значении допустимых версий протокола TLS
Вывод программы при некорректном значении допустимых версий протокола TLS

По выводимым в консоль описаниям ошибок, видно, что старания аккуратно обработать ошибки приносят свои плоды.

Полный исходных код примера доступен по ссылке на 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-конфигурации в С++ программе. Основное внимание уделено ключевым возможностям библиотеки yaml-cpp при работе с входными данными и обработке ошибок.

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

Буду рад любой конструктивной критике и обязательно ее учту при подготовке следующей статьи-туториала о том, как сохранять конфиги в YAML-файлы.

Комментарии (0)