Заготовка библиотеки для настроек приложения.

Введение

Settings v0.1 - это набросок библиотеки с использованием C++98, предназначенных для доступа к настройкам. Классы предназначены для использования в нетребовательных приложениях, утилитах и набросках, но можно использовать и как основу для более масштабных приложений. Идея разработки в том, чтобы получить достаточно простой и гибкий инструмент получения настроек, простым добавлением пары файлов (.cpp и .h) в свой проект.

Проект представляет собой перенос на C++ (а точнее C с классами), применявшейся мною конфигурационной модели приложений на С. Использование C++98 обосновывается желанием сохранить совместимость с большим числом компиляторов. Однако, за это приходится расплачиваться удобством применения и написанием лишнего кода. Использование C++11 позволило бы расширить возможности классов без дублирования кода.

Опытные разработчики не найдут тут ничего нового, начинающие же могут почерпнуть некоторые идеи. Желающие могут использовать библиотеку как есть или переделать код под себя. Ссылка на скачивание в конце статьи.

Базовый интерфейс

Вся библиотека располагается в пространстве имён ESD::Settings. Но реализация отличается от предложенной в проекте ESD v0.1.

ISettings - чистый интерфейс получения настроек.

class ISettings {
public:
	virtual ~ISettings() {};

	virtual bool Get(const std::string&, std::string&) const = 0;
	virtual bool Set(const std::string&, const std::string&) = 0;
	virtual bool Unset(const std::string&) = 0;
};
Любой класс, предоставляющий доступ к настройкам (получаемым из текстового файла, базы данных, по сети или от другого объекта, не важно), должен иметь данный интерфейс (через обычное или множественное наследование). Наличие этого класса обеспечивает гибкость и бинарную совместимость компонент системы.

Предлагается только методы для работы со строковыми (std::string) значениями. Меньшее количество методов проще развивать, сопровождать и переносить. Вопрос преобразования значений в более удобное представление будет решён дополнительным классами доступа (см. Доступ к значениям других типов).

Значения можно получить (метод Get), установить (метод Set) или удалить (метод Unset). В качестве первого аргумента для всех трёх методов передаётся имя значения. Вторым аргуметном при чтении передаётся ссылка на объект куда поместить результат, а при зписи - ссылка на объект с данными для записи.

Все методы возвращают значение типа bool для сообщения об ошибке поиска/преобразования. Отсутствие параметра в настройках мы считаем обычной ситуацией, чтобы позволить использовать значения по умолчанию. Использование исключений для индикации отсутствия параметра приведёт, в таком случае, к чрезмерным накладным расходам. Однако, исключения не запрещены (предлагается не указывать noexcept и при использовании C++11), для возможности сигнализирования о критических ошибках (как отсутствие памяти). Такая неопределённость порою сбивает, но её можно будет избежать в коде, введя дополнительного заместителя, либо скрывающого все исключения, либо возбуждающего их по желанию пользователя.

Реализация по умолчанию

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

SettingsDummy - "пустышка", просто возвращает указанные результаты выполнения операции, не выполняя ни каких действий.

template 
class SettingsDummy : public ISettings {
public:
	virtual bool Get(const std::string&, std::string&) const { return ReadResult; }
	virtual bool Set(const std::string&, const std::string&) { return WriteResult; }
	virtual bool Unset(const std::string&) { return DeleteResult; }
};
Его можно использовать и в качестве базового, для задания пустой реализации по умолчанию, и в качестве самодостаточных классов, не выполняющих ни каких действий. Шаблонными параметрами задаются возвращаемые методами класса значения.
SettingsDummy rwdFail; // любые операции возвращают ошибку
SettingsDummy rwdIgnore; // все операции игнорируются, ошибка не возвращается
SettingsDummy wdIgnore; // операция чтения вернёт ошибку, запись/удаление будут проигнорированы
SettingsDummy dIgnore; // чтение/запись завершаются ошибкой, удаление игнорируется
Эти классы будут полезны при рассмотрении классов заместителей (proxy).

SettingsMap - простая реализация базового функционала на основе std::map.

class SettingsMap : public ISettings {
public:
	SettingsMap();
	virtual ~SettingsMap();

	virtual bool Get(const std::string&, std::string&) const;
	virtual bool Set(const std::string&, const std::string&);
	virtual bool Unset(const std::string&);

	void Clear();

private:
	std::map values;
};
Реализация позволяет получать, заносить и удалять отдельные значения. Дополнительно имеется метод удаления всех значений (Clear()) не являющийся частью базового интерфейса.

Класс не выбрасывает исключений, кроме ошибки выделения памяти. А определив (define) при компиляции SETTINGSMAP_SAFESET, можно гарантировать и отсутствии пустых значений в наборе, при возникновении исключений в процессе добавления значений.

Для загрузки значений из текстового файла со строками вида ключ = значение предназначена пара статических методов PlainKVFile::Load.

class PlainKVFile {
	static bool Load(ISettings*, std::istream&);
	static bool Load(ISettings*, const std::string&);
};
Данный класс следовало бы реализовать отнаследовашись от SettingsMap. Свести методы класса к вызову статических реализаций не составляет проблемы. Но выделение функционала в статические методы требуется, в угоду возможносте использования совместно с классами заместителей.

Пробельные символы по краям ключей и значений обрезаются (trim). Строки, не содержащие символа = (равно) или не имеющие до него печатных символов (непробельных) так же игнорируются. Это добавляет простора в форматировании файла настроек, но не позволяет использовать пробельные символы в начале и конце значений. Строки, начинающиеся с символа # считаются комментариями и игнорируются. Помимо возможности комментирования строк конфигурации это так же позволяет использовать файл настроек в качестве запускаемого в *NIX системах.

#!/home/usr/myprogramm
key  = value
key2 = 2.345
#key3 = commented out value
key3 = another value

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

Заместители (proxy)

В настройках зачастую находятся сгруппированные данные (настройки подключения, отображения, управления и прочее). Для выделения таких групп можно ключам добавить групповой префикс. Но этот префикс должен быть так же указан и в коде загрузки настроек. Но что делать, если для одного и того же типа надо задать разные префиксы? Например, когда надо подключиться одновременно к двум серверам.

Объекты класса GroupProxy призваны выступать заместителями оригинальных объектов настроек, перенаправляя все запросы настроек в одну группу.

class GroupProxy : public ISettings {
public:
	GroupProxy(ISettings* settings_, const std::string& group_ = "", const std::string& separator_ = ".");
	...
};
Объект-источник настроек, имя группы и строка разделителя задаются в конструкторе. В качестве разделителя по умолчанию используется символ точки. Имя группы и строка разделителя могут быть изменены в дальнейшем методами void GroupName(const std::string&) и void GroupSeparator(const std::string&) соответственно. А получить их текущие значения можно методами std::string GroupSeparator() const и void GroupSeparator(const std::string&). Для задания и получения оригинального объекта настроек предназначены методы ISettings* Provider() const и void Provider(ISettings* settings_).

Сам класс GroupProxy является наследником ISettings и может использоваться везде, где ожидается последний. В том числе можно выстроить цепочку из вложенных групп. Или использовать его совместно с другими заместителями для получения сложных иерархий взаимодействия.

Методы получения и установки источника (объект настроек, имя группы, строка разделителя) являются несколько избыточными с точки зрения некоторых подходов в программировании. Так, повторное использование переменных для разных целей не рекомендуется в большинстве руководств. Однако, я не вижу причин запрещать использовать в цикле один раз сконструированный объект, просто подменяя ему название группы. Да, этот класс довольно простой, и его создание/разрушение не занимает много ресурсов, но реализация данных методов практически ничего не стоит, а в отдельных случаях позволяет избежать излишней дефрагментации памяти. Главный же выигрыш в возможности динамически, во время выполнения изменить источник данных (например, вместо текстового файла начать использовать базу данных), не меняя все уже установленные ссылки на данный объект настроек (для ситуации, когда объект настроек живёт в течении всей жизни приложения, а не только во время загрузки приложения).

Класс WriteProxy предназначен для защиты исходного источника данных от записи, с предоставлением интерфейса для записи.

class WriteProxy : public ISettings {
public:
	WriteProxy(const ISettings* settings_ = 0, ISettings* cache_ = 0);
	...
};
Класс оперирует двумя объектами с интерфейсом ISettings - источник данных и кэш. Методами const ISettings* Provider() const и void Provider(const ISettings* settings_) можно получать и устанавливать исходный источник данных. Вспомогательный кэш возвращается и устанавливается методами ISettings* Cache() const { return cache; } и void Cache(ISettings* cache_) соответственно. Прочитать данные можно из обоих, при этом значения из кэша перекрывают значения из источника данных. А операции записи и удаления производятся только с кэшем. Класс WriteProxy является наследником ISettings и может использоваться везде, где ожидается последний.

Казалось бы, существует модификатор const (более того - он используется), зачем городить отдельную сущность? Попробую объяснить почему, несмотря на свою простоту, WriteProxy приносит много пользы.

Без заместителя не обойтись, если наш источник поддерживает только операции чтения, а нам по каким-либо причинам требуется возможность записи. Причины бывают разные, как подгрузка дополнительных настроек, так и необходимость смены одних настроек в зависимости от других. При этом в качестве кэша следует установить полноценный объект-хранилище (например, SettingsMap).

С помощью WriteProxy можно установить и "глобальные" значения по умолчанию, которые не могут быть удалены или изменены. Для этого в качестве источника данных можно задать объект, содержащий значения по умолчанию, а сам источник изменяемых данных устанавливается в качестве кэша.

Можно разрешить только чтение из источника данных, сохранив интерфейс записи. Операции записи при этом могут завершаться ошибкой или игнорироваться, при установке соответствующего объекта в качестве кэша (см. SettingsDummy).

Можно динамически подменять оригинальный источник данных, не меняя уже установленные ссылки (как и у GroupProxy). Для этого поле источника данных остается пустым, а подменяемый источник данных устанавливается в качестве кэша.

В довершении, данный класс позволяет легко реализовать CachedProxy.

У данной реализации есть недостаток. Класс-пользователь логично будет предполагает, что после удачной операции удаления, данного параметра не будет в конфигурации. Но удалится только параметр из кэша, а в оригинальном источнике данных он останется, и последующая операция чтения вернёт установленное значение. Такая неожиданность может привести к столь же неожиданному поведению класса.

CachedProxy - помимо защиты источника данных от записи позволяет сохранять ранее полученные данные в локальный источник. Реализован как наследник WriteProxy и переопределяет только метод получения данных.

class CachedProxy : public WriteProxy {
public:
	CachedProxy(const ISettings* settings_, ISettings* cache_);
	~CachedProxy();

	virtual bool Get(const std::string&, std::string&) const;
};

Предположим, что одни и те же настройки нам требуется получать по много раз в нескольких местах, а оригинальный источник данных располагается на удалённом компьютере. Протокол TCP на самом деле очень медленный, и постоянно запрашивать одни и те же данные по сети слишком накладно. Гораздо лучше сохранять ранее полученные данные в локальном кэше. Так же следует поступить и если поиск в оригинальном источнике данных происходит достаточно долго (например, когда данных очень много, а нам нужна их малая часть). Для этого и предназначен CachedProxy. После получения данных из медленного источника он сохраняет их в быстром локальном кэше, и все последующие запросы этих значений будут проходить быстрее.

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

Доступ к значениям других типов

Интерфейс ISettings позволяет получать только текстовые данные. Значит дл получения целочисленных, вещественных и других типов требуется их преобразовывать из текста. Можно возложить задачу преобразования на пользователя. Но для удобства в библиотеку добавлено несколько классов с преобразованием к наиболее популярным типам.

Getter - класс для получения значений из объекта с интерфейсом ISettings и преобразования его в один из базовых типов.

class Getter {
public:
	Getter(const ISettings* settings_);

	bool Get(const std::string&, bool) const;
	int Get(const std::string&, int) const;
	long int Get(const std::string&, long int) const;
	unsigned long int Get(const std::string&, unsigned long int) const;
	double Get(const std::string&, double) const;
	std::string Get(const std::string&, const std::string&) const;
	std::string Get(const std::string&, const char*) const;

	template 
	T Get(const std::string& key) const;

	const ISettings* Provider() const;
	void Provider(const ISettings* settings_);

	...
};
Это класс не реализует интерфейс ISettings, так как является пользователем данных, а не источником (с точки зрения модели).

Перегрeженный метод Get позволяет возвращать значения типов bool, int, long int, unsigned long int, double, std::string. Имя параметра передается первым аргументом метода Get. Второй аргумент задаёт значение по умолчанию - значение, возвращаемое, если параметра с таким именем нет, или его преобразование к желаемому типу не удалось. Однако, при возникновении исключения при чтении в объекте источника, оно будет передано наверх. Тип возвращаемого значения (и разрешение перегрузки) зависит от типа значения по умолчанию. Исключением является версия std::string Get(const std::string&, const char*) const. Её необходимость обоснована приведением типов в C++, когда строковый литерал "string", интерпретируется как указатель, ближайшим преобразованием для которого является bool, а не const std::string&.

Setter - класс для преобразования и установки значений базовых типов в объект с интерфейсом ISettings.

class Setter {
public:
	Setter(ISettings* settings_);

	bool Set(const std::string&, bool) const;
	bool Set(const std::string&, int) const;
	bool Set(const std::string&, long int) const;
	bool Set(const std::string&, unsigned long int) const;
	bool Set(const std::string&, double) const;
	bool Set(const std::string&, const std::string&) const;
	bool Set(const std::string&, const char*) const;

	bool Unset(const std::string&) const;

	inline ISettings* Provider() const { return settings; }
	inline void Provider(ISettings* settings_) { settings = settings_; }

	...
};
Это класс не реализует интерфейс ISettings, так как является пользователем данных, а не источником (с точки зрения модели).

Перегруженный метод Set позволяет устанавливать значения типов bool, int, long int, unsigned long int, double, std::string. Имя параметра передается первым аргументом метода Get. Второй аргумент задаёт устанавливаемое значение соответствующего типа. При ошибке преобразования или записи возвращается значение false. Однако, при возникновении исключения в объекте источника, оно будет передано наверх.

Accessor - класс получения и установки значений базовых типов у объекта с интерфейсом ISettings. Реализован двойным наследованием от классов Getter и Setter и объединяет их функционал в одном объекте.

Пример

Пример использования классов:

#include 
#include 

using namespace std;
using namespace ESD::Settings;

void foo(const ISettings& settings) {
	Getter cfg(&settings);
	cout << "int: " << cfg.Get("intval", 0) << std::endl;
	cout << "double: " << cfg.Get("dval") << std::endl;
	cout << "string: " << cfg.Get("strval", "default") << std::endl;
}

void bar(ISettings& settings) {
	Accessor acc(&settings);
	cfg.Unset("int");
	cfg.Set("dval", 12.345f);
	cfg.Set("string", "bar");
}

void main() {
	SettingsMap map1;	// ключи: intval, dval, strval
	GroupProxy proxy1(&map1, "proxy1");	// ключи: proxy1.intval, proxy1.dval, proxy1.strval
	GroupProxy proxy2(&map1, "proxy2");	// ключи: proxy2.intval, proxy2.dval, proxy2.strval
	SettingsMap map2;	// ключи: intval, dval, strval
	WriteProxy writeproxy2(&proxy2, &map2);

	PlainKVFile::Load(&map1, "main.cfg");	// Файл конфигурации
	PlainKVFile::Load(&map1, "extra.cfg");	// Дополнительной файл конфигурации
	PlainKVFile::Load(&proxy2, "sub.cfg");	// Можно загрузить файл и в качестве подгруппы

	foo(map1);
	foo(map2);
	foo(proxy1);
	foo(proxy2);

	bar(proxy1);
	bar(writeproxy2);

	foo(map1);
	foo(map2);
	foo(proxy1);
	foo(proxy2);
}

Возможности улучшения

Все классы принимают указатели на ISettings. Однако, можно в аргументах функций использовать и ссылки. В качестве "нулевых" указателей, в таком случае, можно использовать класс SettingsDummy. Это позволит из кода заместителей исключить проверку указателя на нуль. Вдаваться в детали предсказания переходов и сброса конвейера процессора, при использовании проверок, нет смысла, ввиду неоптимальности всей остальной реализации.

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

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

В PlainKVFile::Load можно добавить обработку директивы include. Реализация предельно проста, рекурсивной вызов того же метода.

Кроме SettingsMap можно создать класс для хранения одного единственного значения. Потребность в таком классе есть в проекте ESD v0.1. Получим более оптимальное хранение (для одного параметра не надо хранить массивы) и лучшую скорость.

В закрытый интерфейс SettingsMap можно добавить функции обхода коллекции, для реализации сохранения через PlainKVFile::Save. Это легче, чем дорабатывать все класса заместителей.

Стоит ли производить все эти доработки? Я считаю, что нет, пока в них не возникнет необходимость. Текущей функциональности достаточно для большинства простых задач. А при решении сложных, стоит вначале подумать о целесообразности использования данной модели. Ведь есть множество более функциональных альтернатив, может стоит применить их?

Скачать

Исходный код settings01.zip (5 кБ).
Проверка работы test01.zip (60 кБ)
Лицензия BSD (без гарантий, с возможностью коммерческого использования, не выдавать мой код за написанный вами).

Навигация по разделу