
Иметь дело с наследием в виде зоопарка программ, каждая из которых использует конфиги в разных форматах, как минимум, неудобно. Причем как разработчикам, так и пользователям. А с учетом того, что все это хозяйство нужно поддерживать и растить новым функционалом с сопутствующими настройками, можно почувствовать, как боль начинает размножаться.
Но, все меняется. Рабочие и мои личные проекты постепенно начинают обзаводиться поддержкой формата 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: value1n"
"---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 mapn"
" number: 10n"
" bool: onn"
" string: strn"
" 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: socks5n"
" port: 1080n"
" - protocol: http(s)n"
" port: 2080n"
"logging:n"
" level: debugn"
" folder: './log'n"
"mtls_auth:n"
" enabled: onn"
" tls:n"
" versions: [1.2, 1.3]n"
" certificates:n"
" ca_cert: ca.pemn"
" server_cert: server-cert.pemn"
" private_key: server-key.pemn"
};
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-файлы.
Автор: almageir
