Delphi programming blog
Источник: http://teran.karelia.pro/articles/item_4506.html
 

Настройки программы в 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 части, и мы можем спокойно использовать эти настройки, а компилятор будет вдобавок следить за корректностью нашего кода. На мой взгляд получилось весьма интересно. А теперь давайте рассмотрим все подробности реализации, если, конечно, вам интересно. Посмотрев на код, приведенный выше (описание группы свойств), можно обратить внимание на следующие факты:
  1. Класс имеет атрибут SectionAttribute, очевидно описывающий название секции для группы - [Proxy]
  2. Класс унаследован от базового класса TBaseOptions, в котом описана вся логика работы.
  3. Все свойства имеют атрибуты DefaultValue, очевидно, описывающие значение свойства по умолчанию. Впрочем, их наличие не обязательно.
  4. Атрибут DefaultValueAttribute имеет несколько перегруженных конструкторов для разных типов данных integer/string/boolean
  5. Каждое свойство имеет индекс index. Это ключевой факт. Все свойства должны иметь индекс. Труда это не составляет в общем то.
  6. Каждое свойство имеет методы чтения записи согласно их типу 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&ltT>(): 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. Исходный код прилагается.
Метки:  metaclass  |  generics  |  rtti  |  INI 

Комментарии

Дмитрий
04.02.2012 в 18:43
Весьма интересно)
Только вот Application.Exename не катит)
Наверное в 3-ем апдейте изменили, пойду почитаю правки на сайте.
Дмитрий
04.02.2012 в 18:43
Пардон) Application.Exename в fmx не катит.
ter
05.02.2012 в 13:45
всегда можно использовать стандартный путь - ParamStr(0)
Дмитрий
05.02.2012 в 15:34
Спасибо, не знал)
Иванов Влад
28.06.2013 в 13:38
При компиляции возникала Internal Error в методе SetGenericValue в строке

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 в частности)...
teran
12.07.2013 в 11:19
спасибо (:
с другими форматами будет, думаю, тоже не сложно. идея была именно в том, как удобно работать с самими настройками непосредственно в коде, а то уж как и куда это будет записываться, это дело второе.
- Имя
- e-mail*
- Сайт
вы можете использовать теги [i],[b],[code],[quote]
Дополнительно