Настройки программы в INI с использованием RTTI#7
Опубликовано 27.01.2012 г. 17:04
На работе реализовал в программе небольшой механизм для работы с настройками. Настройки ранее хранились в INI-файле, и в принципе этого достаточно. Просто хотелось улучшить этот подход. Результат получился увлекательный, имхо. По поводу RTTI и INI в сети можно найти ряд статей, вот, например, статья Роберта Лав в переводе от Александра Божко о сохранении свойств объекта в INI. Моя же задача была немного иной.
Моя задача не в сохранении свойств объекта, а просто в хранении настроек программы. Структура линейная, настройки разделены на секции, и могут быть допустим трех видов - int/bool/string. Как обычно ведется работа с INI: создается экземпляр TIniFile, и с помощью методов, например, ReadInteger/WriteInteger извлекаются нужные значения. Такие методы имеют параметры - названия секций, полей, значения по умолчанию. Когда нам необходимо использовать настройки в различных местах программы это вовсе не удобно. Можно ошибиться в наименовании секции или поля, указать разные значения по умолчанию и т.п. Поэтому хотел сделать небольшой "конфиг-менеджер", по крайней мере такой, чтобы знания о секциях и значения по умолчанию хранились в одном месте. Результат работы однако оказался куда более интересным, чем изначально задумывалось. Сразу скажу про итоги, чтобы вы решили интересно это вам или нет. Для работы с настройками имеется класс, TConfig, работающий с INI-файлом. Класс имеет свойства, можно назвать их группами настроек. Такие группы это тоже классы. У меня, например, это два класса TCommonOptions и TDBOptions. А вот эти группы имеют уже конечные свойства. Именно свойства с точки зрения синтаксиса языка, т.е property. И вот значения этих свойств считываются с INI-файла и записываются в него. При этом имя самого свойства совпадает с тем, как оно сохраняется в файле. Таким образом, исключаются ошибки написания корректности свойств, все проверяется компилятором. Из приведенного выше описания не понятно что же здесь может быть интересного, вроде типичная задача. А интересное, на мой взгляд, вот что: для того чтобы ввести в программу новую группу свойств, например - "Настройки прокси", и добавить в нее пару свойств, например, имя прокси сервера, и порт, мне нужно написать только такой код, и ничего более:
[Section('Proxy')] TProxyOptions = class(TBaseOptions) public [DefaultValue('proxy.exmaple.com')] property Proxy : string index 0 read getStringValue write SetStringValue; [DefaultValue(80)] property Port : integer index 1 read getIntegerValue write SetIntegerValue; end;Кроме этого описания интерфейса более ничего не требуется (не считая того, что сам TConfig должен узнать о наличии TProxyOptions). Никакой implementation части, и мы можем спокойно использовать эти настройки, а компилятор будет вдобавок следить за корректностью нашего кода. На мой взгляд получилось весьма интересно. А теперь давайте рассмотрим все подробности реализации, если, конечно, вам интересно. Посмотрев на код, приведенный выше (описание группы свойств), можно обратить внимание на следующие факты:
- Класс имеет атрибут SectionAttribute, очевидно описывающий название секции для группы - [Proxy]
- Класс унаследован от базового класса TBaseOptions, в котом описана вся логика работы.
- Все свойства имеют атрибуты DefaultValue, очевидно, описывающие значение свойства по умолчанию. Впрочем, их наличие не обязательно.
- Атрибут DefaultValueAttribute имеет несколько перегруженных конструкторов для разных типов данных integer/string/boolean
- Каждое свойство имеет индекс index. Это ключевой факт. Все свойства должны иметь индекс. Труда это не составляет в общем то.
- Каждое свойство имеет методы чтения записи согласно их типу get(String/Integer/Boolean)Value и Set*. Эти методы описаны в базовом классе.
TConfig = class(TObject) strict private class var FIni : TIniFile; FAppPath : string; FProxyOptions : TProxyOptions; var public class constructor Create(); class destructor Destroy(); class property ProxyOptions : TProxyOptions read FProxyOptions; class property AppPath : string read FAppPath; end;Все методы и свойства - класссовые, ибо в принципе на программу одного экземпляра вполне достаточно. Доступны 3 поля. FIni - для непосредственной работы с INI-файлом, FAppPath - имя исполняемого файла (для INI надо лишь поменять расширение, да и вообще хранение пути может быть удобным), и экземпляр наших настроек прокси TProxyOptions. Все переменные инициализируются в классовом конструкторе:
class constructor TConfig.Create(); var filename : string; begin FileName := ChangeFileExt(Application.ExeName, '.INI'); FIni := TIniFile.Create(FileName); FAppPath := ExtractFilePath(ParamStr(0)); FProxyOptions := TProxyOptions.Create(FIni); end;Так что если вы где то используете конфиг, то он уже будет готов к работе. TConfig это всего лишь средство для доступа к группам настроек, и для создания INI-файла. Теперь перейдем к атрибутам. Первое, атрибут - описание названия секции. Я, например, отступаю от правила именования классов начиная с T при описании атрибутов, это более наглядно при работе с ними.
SectionAttribute = class(TCustomAttribute) strict private FSection : string; public constructor Create(SectionName : string); property Section : string read FSection; end;Описывать более здесь нечего, очевидно, что все, что делает конструктор, это сохраняет значение параметра SectionName в FSection. Второй используемый атрибут - значение настройки по умолчанию DefaultValueAttribute (Напомню, что при уже непосредственном использовании атрибутов, написание последней части "Attribute" в имени класса не обязательно).
DefaultValueAttribute = class(TCustomAttribute) strict private FValue : TValue; public constructor Create(aIntValue : integer); overload; constructor Create(aBoolValue : boolean); overload; constructor Create(aStringValue : string); overload; property Value : TValue read FValue; end;Здесь можно заметить, что значение по умолчанию хранится в итоге в поле FValue типа TValue, но при этом нам пришлось сделать 3 перегруженных конструктора для разных типов данных. Причиной этому является то, что нельзя сделать конструктор атрибута с параметром TValue, иначе обошлись бы одним. Реализация конструкторов тоже очевидна: FValue := aXXXvalue. Главное только не попасть в идиотскую ситуацию как эта. А теперь базовый класс - TBaseOptions. Впрочем, он не имеет много кода. Но является основой всего в нашем конфиге.
TBaseOptions = class(TObject) strict protected FCtx : TRttiContext; FIni : TIniFile; FSection : string; function getDefaultAttribute(prop : TRttiProperty) : DefaultValueAttribute; function getGenericValue<T>(index : integer): T; procedure SetGenericValue<T>(index : integer; value : T); function getBooleanValue(index : integer):boolean; virtual; function getIntegerValue(index : integer):integer; virtual; function getStringValue(index : integer):string; virtual; procedure SetBooleanValue(index : integer; value : boolean); virtual; procedure SetIntegerValue(index : integer; value : integer); virtual; procedure SetStringValue(index : integer; value: string); virtual; function getProperty(index : integer) : TRttiProperty; public constructor Create(iniFile : TIniFile); destructor Destroy(); override; end;С виду не совсем маленький, но функционал весьма прост. Для начала у нас имеетются 3 переменных - члена класса: Контекст RTTI - FCtx, для работы с RTTI информацией, FIni для чтения/записи в INI файл, ссылка на который передается в конструкторе, и название секции - его проще вытащить один раз при создании, и больше не трогать. Поэтому конструктор класса выглядит следующим образом:
constructor TBaseOptions.Create(iniFile: TIniFile); var ctx : TRttiContext; attr : TCustomAttribute; t : TRttiType; begin inherited Create(); FIni := iniFile; FCtx := TRttiContext.Create(); t := ctx.GetType(self.ClassType); for attr in t.GetAttributes() do begin if attr is SectionAttribute then begin FSection := SectionAttribute(attr).Section; end; end; end;Ничего особенного здесь нет, сохранении указателя на INI, создание контекста RTTI, нахождение атрибута SectionAttribute у самого себя (вспомнилось, здесь я что то писал про вытаскивание атрибутов для объекта). Если посмотреть на оставшуюся группу, то можно здесь заметить что тут всего две группы методов. get-методы и set-методы. При этом первые двух видов: generic и обычные. Именно в сочетании методов и индексов свойств заложена простота конечных классов без кода реализации. Вспомним, свойства классов могут иметь индекс (index), который передается параметром в get или set метод. Обычно индексы используются для группировки извлечения каких то свойств с помощью одного метода. Справка обычно приводит пример про Left/Top/Width/Height и т.п, либо про извлечение RGB составляющих из цвета и т.п. Мы же будем использовать индексы не по прямому назначению. С их помощью мы, в прямом смысле этого слова, пронумеруем свойства в конечных классах, тогда в get & set методах мы получим номер свойства (настройки) к которой обратился пользователь. По номеру свойства мы можем опередить само свойство и его название. Для свойства мы можем извлечь атрибут со значением по умолчанию. Вот и весь секрет. Получение атрибута свойства по умолчанию ничем не отличается от получения атрибута для класса, разница только в том, что извлекать атрибуты мы будем для экземпляра самого атрибута, а не описания класса. Для этого предназначен метод getDefaultAttribute(prop : TRttiProperty). Типизированные методы getStringValue/getIntegerValue/getBooleanValue и соответствующие set-методы просто вызывают generic-метод с нужным параметризованным типом, например:
function TBaseOptions.getIntegerValue(index: integer): integer; begin result := getGenericValue<integer>(index); end;Параметром метода является индекс свойства, как говорилось выше. Вобще было бы удобно, если бы в качестве get/set методов свойства можно было бы указывать generic-методы. А теперь, что касеатся generic-методов. Рассмотрим метод получения значения параметра:
function TBaseOptions.getGenericValue<T>(index: integer): T; var prop : TRttiProperty; default : DefaultValueAttribute; value : TValue; begin prop := getProperty(index); if not assigned(prop) then raise EConfigException.Create('Undefined property name'); default := getDefaultAttribute(prop); if FIni.ValueExists(FSection, Prop.Name) then begin case prop.PropertyType.TypeKind of tkInteger : value := FIni.ReadInteger(FSection, prop.name, Default.Value.AsInteger); tkString, tkUString : value := FIni.ReadString(FSection, prop.Name, Default.Value.asString); tkEnumeration : value := FIni.ReadInteger(FSection, prop.Name, ord(Default.Value.AsBoolean)) <> 0; end; result := value.AsType<T> end else begin if assigned(default) then result := default.Value.AsType<T> else result := system.Default(T); end; end;Сначала по полученному индексу мы извлекаем свойство - getProeprty(index: integer), это будет просто index-ное свойство из массива getProperties():
function TBaseOptions.getProperty(index: integer): TRttiProperty; var t : TRttiType; props : TArray<TRttiProperty>; begin result := nil; t := FCtx.GetType(self.ClassType); props := t.GetProperties(); if index > Length(props) then raise EArgumentOutOfRangeException.Create(Format('TConfig. Property %d does not exists',[index])); result := props[index]; end;Здесь надо быть немного повнимательней, свойтсва в массив извлекаются в том порядке, как они перечислены в описании класса, поэтому при описании свойств их индексы должны соответствовать их действительной позиции (т.е корректность нумерации свойств). После того как свойство получено, извлекается атрибут значения по умолчанию - default; Затем мы проверяем, есть ли указанное свойство в INI-файле. Имя секции у нас в FSection, а имя свойства в prop.Name. Если свойство отсутствует, мы возвращаем значение по умолчанию (указанное в атрибуте), а если атрибут не указан, то значение по умолчанию для типа System.Default(T). Если же свойство найдено, то в зависимости от типа свойства используя один из методов TIniFile (ReadInteger, ReadString, ReadBoolean) мы его считываем (boolean тип это tkEnumeration). Set-метод в целом имеет похожую логику, только здесь нам не требуется знать значение по умолчанию, а вместо чтения будет проводится запись.
procedure TBaseOptions.SetGenericValue<T>(index: integer; value: T); var prop : TRttiProperty; newValue : TValue; begin prop := getProperty(index); if not assigned(prop) then raise EConfigException.Create('Undefined property name'); newValue := TValue.From(value); case PTypeInfo(TypeInfo(T)).Kind of tkInteger : FIni.WriteInteger(FSection, prop.Name, newValue.AsInteger); tkString, tkUString : Fini.WriteString(FSection, prop.Name, newValue.AsString); tkEnumeration : FIni.WriteBool(FSection, prop.Name, newValue.AsBoolean); end; end;На этом можно сказать и все. Теперь для внедрения поддержки новых свойств программы нам нужно либо описать новую группу свойств с ее настройками, либо расширить имеющиеся, и расширить TConfig для использования этой группы свойств (т.е добавить public-свойство в TConfig). Но в принципе и здесь мы можем не останавливаться, а пойти еще дальше. Загвоздка тут небольшая в том, что при добавлении группы свойств, нам необходимо расширять TConfig. Но можно обойтись и без этого. В принципе в TConfig мы можем завести коллекцию групп свойств. Каждую новую группу понадобится регистрирвоать в TConfig, а получать нужную группу можно через generic-метод. Такой подход в принципе может быть оправдан когда групп свойств ну очень много. Для этого сделаем следующее: Немного видоизменим описание класса TConfig и добавим описание метакласса TBaseOptionsClass;
TBaseOptionsClass = class of TBaseOptions; TConfig = class(TObject) strict private class var FIni : TIniFile; FAppPath : string; FOptions : TObjectList<TBaseOptions>; public class constructor Create(); class destructor Destroy(); class procedure RegisterOptions(OptionsClass : TBaseOptionsClass); class function Section<T:TBaseOptions>() : T; class property AppPath : string read FAppPath; end;Мы удалили член FProxyOptions и вместо него добавили список FOptions. Удалили соответствующиее свойство ProxyOptions : TProxyOptions. Вместо него ввели два метода - register() для регистрации новой группы настроек и Section() для извлечения группы настроек. Первый метод будет получать как параметр метакласс группы, создавать его и добавлять в список. Регистрацию необходимо проводить в Initialize секции модуля где группа будет объявлена.
class procedure TConfig.RegisterOptions(OptionsClass: TBaseOptionsClass); var opt : TBaseOptions; begin opt := OptionsClass.Create(FIni); FOptions.Add(opt); end; .... initialization TConfig.RegisterOptions(TProxyOptions);А второй метод Section<T> будет извлекать нужную группу:
class function TConfig.Section<T>(): T; var opt : TBaseOptions; ti : TRttiType; ctx : TRttiContext; begin ti := ctx.GetType(typeInfo(T)); try for opt in FOptions do begin if opt is ti.AsInstance.MetaclassType then exit(T(opt)); end; finally ctx.Free(); end; raise EConfigException.Create('Unregistered option group' + PTypeInfo(typeinfo(t)).Name); end;Простой текстовый пример:
program iniRttiConfig; {$APPTYPE CONSOLE} uses SysUtils, Config in 'Config.pas'; begin with TConfig.Section<TProxyOptions>() do begin writeln(Proxy); Port := 100; writeln(port); end; end.выведет на экран proxy.example.com и значение 100, при этом в директории программы будет создан одноменный INI-файл:
[Proxy] Port=100И опять же напомню, для чего все это. Если нам, например, необходимо в группу Proxy добавить для сохранения в INI настройку User то нам потребутся всего лишь расширить описание класса TBaseOptions двумя строками вида:
[DefaultValue('anonimous')] property User : string index 2 read getStringValue write SetStringValue;Или даже без указания атрибута, тогда значением по умолчанию будет пустая строка. В общем то с одной стороны это просто отчасти и есть сохранение объекта в INI-файл, как по ссылке в начале статьи. С другой стороны, объекты, которые отвечают за настройки (т.е по сути мы их сериализуем в INI) сами не хранят никаких данных, а лишь являются прослойкой для доступа к INI-файлу, при этом описав группы настроек как классы, мы избегаем ситуации указания их как строк при прямой работе с INI, и корректность всегда проверяется компилятором. Так же можно сказать что при работе с одной группой свойств никакой TConfig нам не нужен. Можно использовать самостоятельную группу свойств, необходимо только создать экземпляр TIniFile. Исходный код прилагается.
04.02.2012 в 18:43
Только вот Application.Exename не катит)
Наверное в 3-ем апдейте изменили, пойду почитаю правки на сайте.
04.02.2012 в 18:43
05.02.2012 в 13:45
05.02.2012 в 15:34
28.06.2013 в 13:38
newValue := TValue.From(value);
ошибка пропала после исправления на
newValue := TValue.From(value);
28.06.2013 в 13:01
_T_ необходимо заменить на на указание generic-типа.
При компиляции возникала Internal Error в методе SetGenericValue в строке
newValue := TValue.From(value);
ошибка пропала после исправления на
newValue := TValue.From_T_(value);
28.06.2013 в 18:50
Нужно будет попробовать с другими форматами файлов (XML в частности)...
12.07.2013 в 11:19
с другими форматами будет, думаю, тоже не сложно. идея была именно в том, как удобно работать с самими настройками непосредственно в коде, а то уж как и куда это будет записываться, это дело второе.