ООП и паттерны проектирования: практическое применение
Автор Тагир Юмагузин   
26.01.2010 г.
Тагир Юмагузин
Директор института разработки информационных систем
Уфа

В Интернете выложено достаточно большое количество статей на тему применения паттернов (шаблонов) проектирования. К сожалению, примеров кода на Delphi немного, да и сами примеры достаточно упрощенные, и не дают возможность оценить всю мощь использования этой технологии. Предлагаемый цикл статей – попытка показать начинающим программистам пользу применения паттернов проектирования на реальном коммерческом проекте, пошагово решая часто возникающие практические задачи.

Это не обзор паттернов, и не учебник по их применению, это – практические занятия. Надеюсь, их получится несколько.

Занятие 1. Чтение настроек программы.

Постановка задачи: программа при начальном запуске должна прочитать настройки подключения к базе данных и соединиться с ней.

Такую задачу решал, пожалуй, каждый программист, и в любом учебнике программирования Вы легко найдете решение:

procedure ConnectDataBase;
var
  IniFile : TIniFile;
  Section, FileName, Password, UserName : String;
Begin
  IniFile := TIniFile.Create(IniFilePath);
  Section := 'Main';
  with IniFile do begin
    FileName := ReadString(Section, 'FileName', 'Application.gdb');
    Password := ReadString (Section, 'Password', 'masterkey');
    UserName := ReadString (Section, 'UserName', 'SYSDBA'); 
  end;
  MainDatabase.Params.Clear;
  MainDatabase.Params.Add('password='+ Password);
  MainDatabase.Params.Add('user_name='+'UserName')
  MainDatabase.FileName := FileName;
  MainDatabase.Connected := true;
end;

Вроде все легко и просто?

Тогда зададим себе несколько вопросов...

  1. А если у нас несколько подключаемых БД, да еще и с разными вариантами подключения?
  2. А если мы хотим добавить новые параметры настройки без перекомпиляции кода?
  3. А если мы решим использовать для сохранения настроек не Ini-файл, а реестр, или cookie?

И мы видим, что предлагаемое решение нарушает несколько принципов объектно-ориентированного программирования!

Первое нарушение - решение не приспособлено к изменениям требований (а требования к программе меняются всегда!).

Второе нарушение - нарушение принципа «разделения ответственностей» - процедура выполняет одновременно несколько обязанностей: хранит параметры настройки (FileName, Password, UserName), читает их из файла и производит подключение к БД.

Рассмотрим эти вопросы по очереди. Если у нас несколько БД - необходимо хранить отдельно настройки для каждой.Можно в одном ini-файле в разных секциях, но лучше - для каждой БД создать свой файл.

В моей программе - две базы данных, БД проекта, и БД приложения. Соответственно, будет четыре файла -

ApplicationLocalConnection.ini и ApplicationRemoteConnection.ini для приложения, ProjectLocalConnection.ini и ProjectRemoteConnection.ini для проекта.

А чтобы при запуске узнать, какие соединения использовать - еще и главный ini-файл для приложения, тут удобнее использовать название ini-файла, совпадающее с названием приложения. А поскольку одни и те же настройки могут использовать разные программы, для каждой надо создать свой каталог для хранения настроек:

  • Bin (тут лежат сами exe-файлы)
  • Application1.LocalSettings (тут лежат настроечные файлы приложения 1)
  • Application2.LocalSettings (тут лежат настроечные файлы приложения 2)

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

Третий вопрос - сохранения настроек различными способами (в Ini-файле, в реестр, cookie или где-то еще).

Вот тут мы используем один из главных принципов объектно-ориентированного программирования - «инкапсулируй то, что может измениться». У нас может измениться способ чтения-сохранения настроек, так «спрячем» возможные изменения за «стеной» абстракции - создадим абстрактный класс TSettingsReader - «читатель» настроек. Он описывает интерфейс для чтения и сохранения настроек без конкретизации - как это делается.

Второй принцип, который мы применим - принцип «разделения ответственностей». Каждый класс должен выполнять только то, что нужно, и ничего лишнего. И для выполнения разных обязанностей нам необходимо создать 3 класса:

  • TSettingsHolder. Класс для хранения параметров настройки. Он знает - что хранить.
  • TSettingsReader. Класс для чтения и сохранения их. Он знает - как хранить.
  • Класс для подключения к БД. Он знает - как использовать настройки.

Как видим, каждый класс занимается своим делом и не зависит от других. Более того, все эти классы - абстрактные, а использовать мы будем конкретных наследников!

Внимательный читатель вправе задать вопрос: а где же паттерны проектирования? А они - в реализации использования наших абстрактных классов! Обратимся к классикам - к «банде четырех» (Э.Гамма, Р.Хелм, Р.Джонсон, Дж.Влиссидес. Приемы объектно-ориентированного проектирования)!

Рассмотрим описание паттерна проектирования "Мост" (Bridge).

Применимость: используйте паттерн "Мост", когда:

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

Image 

А это как раз наш случай! И вот как выглядит диаграмма для нашей конкретной реализации паттерна:

Image 

Каковы основные результаты применения паттерна?

  • отделение реализации от интерфейса. Реализации больше не имеет постоянной привязки к интерфейсу. Реализацию абстракции можно конфигурировать во время выполнения. Разделение классов TSettingsHolder и TSettingsReader устраняет зависимость от их реализации.
  • повышение степени расширяемости. Можно расширять независимо иерархии классов TSettingsHolder и TSettingsReader.


Вот теперь - первый пример кода:

Класс TSettingsReader - «читатель» настроек. Описывает абстрактный интерфейс для чтения и сохранения настроек.

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

type TSettingsReader = class
private
  fIniSection: String; 
  fOwnerName: String; 
  fIsReadOnly: Boolean;
  fSourcePath: String;
  fDriveType : Word;
  fDriveTypeDescription : String;
  fIniKeys: TStringList;
  procedure SetSourcePath(const Value: String);
  function CheckKeyName(const KeyName: string): Boolean;
 
protected
  property IniKeys : TStringList read fIniKeys;
public
  class function GetStorageType: String; virtual; abstract;       // В наследниках выдает способ сохранения параметров
  function CheckExists: Boolean; virtual; abstract;              // Проверка наличия файла или ветки реестра
  procedure RegisterIniKey(const KeyName, DefaulValue : String);// Процедура регистрации параметра настройки
  function SectionExists(const Section: string): Boolean; virtual; abstract;// Проверка наличия секции
  property IniSection: String read fIniSection write fIniSection;   // Секция хранения
  property SourcePath: String read fSourcePath write SetSourcePath; // Путь к ini-файлу или ветке реестра
  property IsReadOnly: Boolean read fIsReadOnly; // Доступ к записи файла (программа может быть на CD-Rom)
  property DriveType : Word read fDriveType;     // Тип устройства хранения 
  property DriveTypeDescription: String read fDriveTypeDescription; // Описание устройства хранения 
  property OwnerName: String read fOwnerName write fOwnerName;      // Имя владельца - хранителя
  constructor Create;
  destructor Destroy; override;
 
  function Load:Boolean; virtual;              // Функция загрузки параметров из файла в IniKeys
  function Update:Boolean; virtual; abstract; // Функция сохранения параметров в файле
 
  function GetIniValue(const KeyName : string): string;
  procedure SetIniValue(const KeyName, Value : string);
end;
 
implementation
 
constructor TSettingsReader.Create;
begin
  inherited;
  fIniKeys := TStringList.Create;
  fIniKeys.CaseSensitive := False;
end;
 
destructor TSettingsReader.Destroy;
begin
  IniKeys.Free;
  inherited;
end;
 
function TSettingsReader.CheckKeyName(const KeyName: string): Boolean;
begin
  Result := (IniKeys.IndexOfName(KeyName)>=0);
  if not Result then
  MsgError('['+Self.IniSection+'] '+KeyName+' not registered!', Self.OwnerName+'-GetIniValue');
end;
 
function TSettingsReader.GetIniValue(const KeyName: string): string;
begin
  if CheckKeyName(KeyName) then
  Result := Trim(IniKeys.Values[KeyName])
end;
 
function TSettingsReader.Load: Boolean;
begin
  Result := CheckExists; // В базовом классе - только проверка. В наследниках будет добавлена сама процедура чтения.
end;
 
procedure TSettingsReader.RegisterIniKey(const KeyName, DefaulValue: String);
begin
  IniKeys.Add(KeyName+'='+DefaulValue);
end;
 
procedure TSettingsReader.SetIniValue(const KeyName, Value: string);
begin
  if CheckKeyName(KeyName) then
  IniKeys.Values[KeyName] := Value;
end;
 
procedure TSettingsReader.SetSourcePath(const Value: String);
begin
  fSourcePath := Value;
  fDriveType := CheckDriveType(Value);
  case fDriveType of
    DRIVE_FIXED : fDriveTypeDescription := 'Fixed drive';
    DRIVE_REMOTE : fDriveTypeDescription := 'Remote (network) drive';
    DRIVE_CDROM : fDriveTypeDescription := 'CD-ROM drive';
    DRIVE_RAMDISK : fDriveTypeDescription := 'RAM disk';
    DRIVE_REMOVABLE : fDriveTypeDescription := 'Removable drive (USB)';
  end;
  fIsReadOnly := (fDriveType = DRIVE_CDROM); //Надо определить, не является-ли диск CD-диском?
end;
 
function CheckDriveType(const FilePath: String): Word;
var
  FileDrive : string;
begin
  FileDrive := ExtractFileDrive(FilePath);
  Result := GetDriveType(PChar(FileDrive)); 
end;
Как видим, класс TSettingsReader содержит две виртуальные функции Load и Update, которые, будучи перекрыты в наследниках, определяют способ чтения и сохранения настроек. В то же время класс имеет всю базовую функциональность для хранения настроек.

Класс TSettingsReader - абстрактный, и конкретная реализация методов перекрыта в наследниках:

  • TFBIniFileReader - «читатель» ini-файла
  • TFBRegistryReader - «читатель» реестра 
  • TFBCookieReader - «читатель» файла cookie
 
Соответствующий экземпляр класса будет создаваться в процессе загрузки программы в зависимости от настроек.

Вот пример реализации класса для чтения из ini-файла (я намеренно убрал все проверки и реакцию на ошибки чтения/записи, чтобы не загромождать листинг, полный код можно получить по ссылке):

type TFBIniFileReader = class (TSettingsReader)
private
  fIniFile: TIniFile;
  function GetIniFile: TIniFile; // "Отложенное" создание Ini-File - когда понадобится!
public
  class function GetStorageType: String; override;
  constructor Create;
  destructor Destroy; override;
  function CheckExists: Boolean; override;
  function SectionExists(const Section: string): Boolean; override;
  function Load:Boolean; override;
  function Update:Boolean; override;
  property IniFile: TIniFile read GetIniFile;
end;
 
implementation;
class function TFBIniFileReader.GetStorageType: String;
begin
  Result := 'IniFile';
end;
 
function TFBIniFileReader.CheckExists: Boolean;
begin
  Result := FileExists(SourcePath);
end;
 
constructor TFBIniFileReader.Create;
begin
  inherited;
end;
 
destructor TFBIniFileReader.Destroy;
begin
  inherited;
  fIniFile.Free;
end;
 
function TFBIniFileReader.Load: Boolean;
//При чтении из ini-файла при отсутствии значений им присваиваются значения по-умолчанию!
var
  i: Integer;
  KeyName , KeyValue, DefaultValue : string;
begin
  Result := inherited Load;
  if not Result then
    Exit;
  try
    with IniFile do begin // Мы обращаемся к свойству IniFile, сам файл будет открыт в момент обращения!
      for i := 0 to IniKeys.Count - 1 do begin
        KeyName := IniKeys.Names[i];
        DefaultValue := IniKeys.ValueFromIndex[i];
        KeyValue := ReadString(IniSection, KeyName, DefaultValue);
        if Trim(KeyValue) = '' then 
          KeyValue := DefaultValue; 
        IniKeys.ValueFromIndex[i] := KeyValue;
      end;
      Result := true;
    end;
    except // ... реакция на ошибки чтения/записи
  end;
end;
 
 
function TFBIniFileReader.Update: Boolean;
var
  i: Integer;
  KeyName : string;
  ActualValue : string;
begin
  Result := True;
  if IsReadOnly then begin
    MsgError('Путь: '+Self.SourcePath+#10+'Ошибка - '+ExtractFileDrive(Self.SourcePath)+
    ' ('+Self.DriveTypeDescription+') - только для чтения!', 'Невозможно записать ini-файл');
    Exit;
  end;
  BackUpFile(Self.SourcePath);
  try
    with IniFile do begin
      for i := 0 to IniKeys.Count - 1 do begin
        KeyName := IniKeys.Names[i];
        ActualValue := IniKeys.ValueFromIndex[i];
        WriteString(IniSection, KeyName, ActualValue);
      end;
      UpdateFile;
    end;
    except //... реакция на ошибки чтения/записи
  end;
end;
 
function TFBIniFileReader.GetIniFile: TIniFile; // "Отложенное" создание Ini-File - когда понадобится!
begin
  if not Assigned(fIniFile) then
    try
      fIniFile := TIniFile.Create(Self.SourcePath);
    except //... реакция на ошибки чтения/записи
  end;
  Result:=fIniFile;
end;
Опишем класс TCustomSettingsHolder, который содержит абстрактный интерфейс для хранителя настроек.
type TCustomSettingsHolder = class
private
  fSettingsReader: TSettingsReader;
  procedure SetSettingsReader(const Value: TSettingsReader);
public
  class function GetConnectionType: String;virtual;abstract;//Метод класса, выдает описание типа соединения c БД
  class function GetDescription : String;virtual;abstract;//Метод класса, выдает описание подключенной БД
  property SettingsReader: TSettingsReader read fSettingsReader write SetSettingsReader; //Читатель настроек
end;
 
implementation
procedure TCustomSettingsHolder.SetSettingsReader (const Value: TSettingsReader);
//Как видим, хранитель настроек ссылается на абстрактный класс читателя настроек, что позволяет при инициализации 
//приложения назначить конкретного наследника класса!
begin
  fSettingsReader := Value;
  fSettingsReader.OwnerName := Self.GetDescription;//Чтобы хранитель мог при ошибке сообщить, из какого соединения 
                                                   //он вызван
end;
Класс TFBSettingsHolder (наследник TCustomSettingsHolder)реализует функциональность по чтению и сохранению настроек.
type TFBSettingsHolder = class(TCustomSettingsHolder)
public
// Начальная инициализация - создание параметров настроек.Функция должна вызываться после установки SettingsReader!
  procedure Initialize; virtual; abstract;
  // Чтение настроек из файла.
  function ReadParameters:Boolean;virtual;
end;
От него унаследованы классы TMainConnection, TApplicationLocalConnection, TApplicationRemoteConnection, TProjectLocalConnection, TProjectRemoteConnection, которые отличаются реализацией процедуры инициализации.

Для примера приводятся только процедуры для настроек самого приложения и локального подключения к БД:

 

procedure TMainConnection.Initialize;
<p> </p>begin
  with Self.SettingsReader do begin
    IniSection:='Main';
    RegisterIniKey('SettingsStorage', 'ini'); // Способ хранения настроек
    RegisterIniKey('Application', 'ApplicationLocalConnection'); // Способ подключения к БД приложения
    RegisterIniKey('Project', 'ProjectLocalConnection'); // Способ подключения к БД проекта
    RegisterIniKey('RootDir', 'C:\Program Files\FastBase'); // Корневой каталог программы
    RegisterIniKey('TempDir', 'C:\Program Files\FastBase\TMP');
    RegisterIniKey('LogDir', 'C:\Program Files\FastBase\Log');
    RegisterIniKey('WebServer', 'WebServerLocalConnection');
    RegisterIniKey('RememberUser', 'no');
    RegisterIniKey('Login', 'no');
    RegisterIniKey('Category', 'no'); 
  end;
end;
 
procedure TApplicationLocalConnection.Initialize;
begin
  with Self.SettingsReader do begin
    IniSection:='ApplicationLocalConnection';
    RegisterIniKey('WorkDirectory', 'c:\Program Files\FastBase\DB');
    RegisterIniKey('FileName', 'Application.gdb');
    RegisterIniKey('Password', 'masterkey');
    RegisterIniKey('UserName', 'SYSDBA');
    RegisterIniKey('ServerName', 'localhost');
  end;
end;
 
Как видим, параметры настроек для разных классов соединения различаются, причем легко добавить либо изменить их без внесения правок в отлаженный код. Чтение настроек производится вызовом функции GetIniValue(IniKey).

На следующем занятии мы рассмотрим применение паттерна «Абстрактная фабрика».

Последнее обновление ( 27.01.2010 г. )