Thursday, October 8, 2009

INI persistence the RTTI way

This post is based around the IniPersit.pas code that I just released.

I commonly create configuration classes to create a common and easy way to access information stored in INI, Registry, or XML. In these examples I will show how I used the new RTTI and Attributes in Delphi 2010 to provide a new way of creating a configuration class that access information stored in an INI file.

Lets first start off showing how to use the New Unit, then we can pull back the covers and show how it works.


unit ConfigSettings;
interface
uses
IniPersist;

type
TConfigSettings = class (TObject)
private
FConnectString: String;
FLogLevel: Integer;
FLogDirectory: String;
FSettingsFile: String;
public
constructor create;
// Use the IniValue attribute on any property or field
// you want to show up in the INI File.
[IniValue('Database','ConnectString','')]
property ConnectString : String read FConnectString write FConnectString;
[IniValue('Logging','Level','0')]
property LogLevel : Integer read FLogLevel write FLogLevel;
[IniValue('Logging','Directory','')]
property LogDirectory : String read FLogDirectory write FLogDirectory;

property SettingsFile : String read FSettingsFile write FSettingsFile;
procedure Save;
procedure Load;

end;

implementation
uses SysUtils;

{ TApplicationSettings }

constructor TConfigSettings.create;
begin
FSettingsFile := ExtractFilePath(ParamStr(0)) + 'settings.ini';
end;

procedure TConfigSettings.Load;
begin
// This loads the INI File Values into the properties.
TIniPersist.Load(FSettingsFile,Self);
end;

procedure TConfigSettings.Save;
begin
// This saves the properties to the INI
TIniPersist.Save(FSettingsFile,Self);
end;

end.


program Project13;

{$APPTYPE CONSOLE}

uses
SysUtils,
IniPersist,
ConfigSettings;

var
Settings : TConfigSettings;

begin
Settings := TConfigSettings.Create;
try
Settings.ConnectString := '\\127.0.0.1\DB:2032';
Settings.LogLevel := 3;
Settings.LogDirectory := 'C:\Log';
Settings.Save;
finally
Settings.Free;
end;

Settings := TConfigSettings.Create;
try
Settings.Load;
WriteLn(Settings.ConnectString);
Writeln(Settings.LogLevel);
Writeln(Settings.LogDirectory);
finally
Settings.Free;
end;
Readln;
end.

Output:

\\127.0.0.1\DB:2032
3
C:\Log

Resulting INI File:

[Database]
ConnectString=\\127.0.0.1\DB:2032
[Logging]
Level=3
Directory=C:\Log


As you can see by the above code there really is not much too it, if you want a field or a property to be stored in the INI File, you just need to add the IniValue Attribute.

TExampleClass = class (TObject)
private
FConnectString: String;
public
[IniValue('Database','ConnectString')]
property ConnectString : String read FConnectString write FConnectString;
end;

The constructor of the INIValue allows you to specify the Section, Name you the field or Property stored in. It also allows you to specify a default value if the name & section did not exist in the INI File.

IniValueAttribute = class(TCustomAttribute)
private
FName: string;
FDefaultValue: string;
FSection: string;
published
constructor Create(const aSection : String;const aName : string;const aDefaultValue : String = '');
property Section : string read FSection write FSection;
property Name : string read FName write FName;
property DefaultValue : string read FDefaultValue write FDefaultValue;
end;

...

constructor IniValueAttribute.Create(const aSection, aName, aDefaultValue: String);
begin
FSection := aSection;
FName := aName;
FDefaultValue := aDefaultValue;
end;


So the magic is really contained in TIniPersist


TIniPersist = class (TObject)
private
class procedure SetValue(aData : String;var aValue : TValue);
class function GetValue(var aValue : TValue) : String;
class function GetIniAttribute(Obj : TRttiObject) : IniValueAttribute;
public
class procedure Load(FileName : String;obj : TObject);
class procedure Save(FileName : String;obj : TObject);
end;

The load and save methods are nearly identical, so lets take a look at load.

class procedure TIniPersist.Load(FileName: String; obj: TObject);
var
ctx : TRttiContext;
objType : TRttiType;
Field : TRttiField;
Prop : TRttiProperty;
Value : TValue;
IniValue : IniValueAttribute;
Ini : TIniFile;
Data : String;
begin
ctx := TRttiContext.Create;
try
Ini := TIniFile.Create(FileName);
try
objType := ctx.GetType(Obj.ClassInfo);
for Prop in objType.GetProperties do
begin
IniValue := GetIniAttribute(Prop);
if Assigned(IniValue) then
begin
Data := Ini.ReadString(IniValue.Section,IniValue.Name,IniValue.DefaultValue);
Value := Prop.GetValue(Obj);
SetValue(Data,Value);
Prop.SetValue(Obj,Value);
end;
end;
for Field in objType.GetFields do
begin
IniValue := GetIniAttribute(Field);
if Assigned(IniValue) then
begin
Data := Ini.ReadString(IniValue.Section,IniValue.Name,IniValue.DefaultValue);
Value := Field.GetValue(Obj);
SetValue(Data,Value);
Field.SetValue(Obj,Value);
end;
end;
finally
Ini.Free;
end;
finally
ctx.Free;
end;
end;

So you can see we basically loop through all the properties and field check for an Attribute, if it exists we get the current value which sets the TypeInfo in the TValue object. Then we assign the string returned from the INI file into the TValue and call SetValue()

Lets look at the two methods called.

class procedure SetValue(aData : String;var aValue : TValue);
class function GetIniAttribute(Obj : TRttiObject) : IniValueAttribute;

Lets look first at SetValue(). You will see that it depends on the TypeInfo being present in the TValue being passed in. We check the TValue and perform the conversions required to convert the String to the Correct Type before storing it
into the TValue.

class procedure TIniPersist.SetValue(aData: String;var aValue: TValue);
var
I : Integer;
begin
case aValue.Kind of
tkWChar,
tkLString,
tkWString,
tkString,
tkChar,
tkUString : aValue := aData;
tkInteger,
tkInt64 : aValue := StrToInt(aData);
tkFloat : aValue := StrToFloat(aData);
tkEnumeration: aValue := TValue.FromOrdinal(aValue.TypeInfo,GetEnumValue(aValue.TypeInfo,aData));
tkSet: begin
i := StringToSet(aValue.TypeInfo,aData);
TValue.Make(@i, aValue.TypeInfo, aValue);
end;
else raise EIniPersist.Create('Type not Supported');
end;
end;

Now lets take a look at GetIniAttribute() the goal of this method is to check to see if a given TRttimember (Field or Property) has the IniValue attribute, and if it does return it, otherwise return NIL.

class function TIniPersist.GetIniAttribute(Obj: TRttiObject): IniValueAttribute;
var
Attr: TCustomAttribute;
begin
for Attr in Obj.GetAttributes do
begin
if Attr is IniValueAttribute then
begin
exit(IniValueAttribute(Attr)); // Exit with a parameter new in Delphi 2010
end;
end;
result := nil;
end;

So all in all, its really not much code, and it make usage simple. Now granted TIniValue is not all that complex, but this situation could be applied to a variety of other applications.

RTTI Article List

4 comments:

  1. This seems to be a great approach. Thanks!
    JM2C: Storing the ini file in the same directory as the executable is ... risky.
    The "magic" for me is in the line
    [IniValue('Database','ConnectString','')]
    prior to the property definition. Can you give me a hint where to find some more information about this syntax?
    Thanks in advance and regard,
    Klaus!

    ReplyDelete
    Replies
    1. This was only an example you can place the INI File in any directory.
      The Magic is a Delphi Syntax that introduced in Delphi 2010 called Attributes.

      I wrote several articles about the RTTI System and some covered Attributes.
      Please see the RTTI Article list at the bottom of the post.

      A intro article is here:
      http://robstechcorner.blogspot.com/2009/09/using-attributes-and-tcustomattribute.html

      Delete
  2. I really appreciate this code, thank you Robert.

    I altered things a bit because I felt that having one attribute per each value to be stored in the INI file was a bit much. Instead, I made an IniSectionAttribute which only contains a string for the section. Then, when looping through fields I set a string variable "CurrentSection" if I find an IniSection attribute. Default values are handled by the object constructor, and the names associated with values in the INI are just the variable names retrieved from field.Name. I also expanded it to support saving Integer, Int64, and Boolean values to the INI as well.

    This works very well for me, my code is available here: https://github.com/matortheeternal/merge-plugins/blob/master/lib/mte/RttiIni.pas

    ReplyDelete