
Иметь дело с наследием в виде зоопарка программ, каждая из которых использует конфиги в разных форматах, как минимум, неудобно. Причем как разработчикам, так и пользователям. А с учетом того, что все это хозяйство нужно поддерживать и растить новым функционалом с сопутствующими настройками, можно почувствовать, как боль начинает размножаться.
Но, все меняется. Рабочие и мои личные проекты постепенно начинают обзаводиться поддержкой формата 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:

По выводимым в консоль описаниям ошибок, видно, что старания аккуратно обработать ошибки приносят свои плоды.
Полный исходных код примера доступен по ссылке на 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-файлы.