Практика применения паттернов проектирования

Авторы: Беркович Вадим
Чудин Андрей

Источник: RSDN Magazine #3
Опубликовано: 09.04.2003
Версия текста: 1.0.1
О понятии «паттерн проектирования»
Проектирование архитектуры
Как есть
Как должно быть
Проект
Построение базовой архитектуры проекта
Главная форма и создание модулей через абстрактную фабрику
TAppModule – модуль проекта
Связь между модулями. Медиатор и команда
Собирая части вместе: порядок инициализации приложения
Резюме
Литература

GlobusLib.EXE (1.1 МБ) – библиотека GVCL
appTemplate.zip (90 кБ) – каркас проекта Delphi
patmodel.zip (27 кБ) – диаграммы RationalRose

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

Паттерн, или шаблон, проектирования представляет собой модель взаимодействия классов для решения какой-либо типичной задачи. Их существует множество, и вы также с ними сталкивались. Самый простой пример – это итератор, реализующий перемещение по некоему списку элементов.

Практически во всех проектах можно встретить те или иные паттерны проектирования. Но далеко не часто они обозначены разработчиками. Проект, в котором явно обозначены все использованные паттерны, удобнее для понимания и более управляем. Можно сказать, что описание проекта в терминах паттернов добавляет новые метаданные о проекте. Если мы читаем, что данный класс реализует паттерн "итератор", мы сразу получаем представление об его интерфейсе и роли. Если же изначально весь проект реализован с использованием паттернов, то управление проектом упрощается. Обобщение удачных решений конкретных задач в паттерны и использование их в последующих проектах существенно ускоряет процесс разработки. А код становится более понятным и элегантным, и им можно будет воспользоваться повторно.

Здесь мы рассмотрим опыт применения некоторых шаблонов при проектировании реального приложения. Будет рассмотрено применение шаблонов Абстрактная фабрика (Abstract Factory), Медиатор (Mediator), Команда (Command), Одиночка (Singleton). При этом мы сопоставим предлагаемый подход с распространенным подходом к проектированию архитектуры небольших и средних прикладных программ.

О понятии «паттерн проектирования»

Паттерн проектирования – это описание задачи, которая постоянно возникает в ходе проектирования объектно-ориентированных программ, и принципов ее решения. Причем данное решение может быть использовано повторно. Смысл паттерна – предложить решение задачи в определенных условиях.

Паттерн, в общем случае, состоит из четырех элементов:

  1. Имя – однозначное определение паттерна, говорящее о его назначении.
  2. Задача – условия применения паттерна.
  3. Решение – абстрактное описание решения задачи и модель решения в виде набора связанных классов.
  4. Результат – ожидаемые последствия применения паттерна.

Говоря далее по тексту статьи о применении конкретных паттернов, мы будем кратко характеризовать их именно по такой схеме: Имя – Задача – Решение – Результат. Мы будем исходить из предположения знакомства читателя с описываемыми паттернами. За более подробным описанием абстракций конкретных паттернов рекомендуется обратиться к книгам [1], [2].

Проектирование архитектуры

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

Как есть

На рисунке 1 показана типичная архитектура построения приложения. Как видите, примеры приведены на Delphi. Поэтому следует оговориться, что здесь и далее «модулем» мы будем называть любого наследника TDataModule. При такой архитектуре существует главная форма (независимо от типа интерфейса – MDI или SDI), один или более модуль данных и множество визуальных форм. При этом логика расположена произвольно, как в главной форме, так и в модулях данных и конкретных формах. Область видимости при этом для конкретных модулей ничем не регламентирована, что приводит к избыточной связности.


Рисунок 1. Прямое создание форм. Смешение логики и представления

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

Как должно быть

Вспомним золотые слова: «Модульность – это свойство системы, которая была разложена на внутренне связные, но слабо связанные между собой модули» (Гради Буч) и обратимся к рисунку 2.


Рисунок 2. Создание через фабричный метод. Разделение логики и представления.

Как видно из рисунка 2, главная форма изолирована от множества форм, которые реализуют уровень представления для конкретных бизнес-функций. Она «видит» только множество модулей, на которые разбита вся программа. Сами модули не «видят» друг друга. Они инкапсулируют в себе всю логику создания и вызова форм.

Четко разделяя логику и представления, получаем следующие преимущества:

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

Проект

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

Построение базовой архитектуры проекта

Сразу оговоримся, что мы реализуем описываемую архитектуру на Delphi, и это оставит свой отпечаток на предлагаемом решении. Мы будем строить пример как MDI-приложение. Скелет приложения должен содержать главный модуль (TAppConsole), множество модулей бизнес-логики (наследники TAppModule), один общий модуль данных (TCommonModule) для соединения с БД и размещения общих ресурсов. TAppConsole знает только абстрактный TAppModule, конкретные модули не знают друг о друге, TCommonModule – виден для TAppModule. Абстрагируемся пока от вопросов взаимодействия между модулями. Соответствующая модель классов приведена на рисунке 3.


Рисунок 3. Базовая архитектура проекта и место в ней фабрики модулей.

Главная форма и создание модулей через абстрактную фабрику

ПаттернАбстрактная фабрика
Имя в проектеTModuleFactory
ЗадачаСоздавать конкретные модули проекта. Скрыть от главной формы все конкретные классы модулей проекта.
РешениеСкрыть знание о конкретных классах
РезультатГлавная форма не знает конкретных классов модулей, и ее код остается неизменен при добавлении новых модулей в проект.

На рисунке 3 показана диаграмма классов «скелета» проекта. Главный модуль (TAppConsole) создает множество модулей через абстрактную фабрику TModuleFactory. Кроме этого TAppConsole создает экземпляр TAppConsoleForm – главного окна программы.

ПаттернОдиночка
Имя в проектеTappConsole
ЗадачаСоздать единый класс для построения интерфейса пользователя.
РешениеГлавная форма приложения. Инкапсулирует функции создания модулей системы через абстрактную фабрику и их вызов, содержит логику главной формы.
РезультатГлавная форма не знает конкретных классов модулей, и ее код остается неизменным при добавлении новых модулей в проект.


Рисунок 4. Пример интерфейса, построенного на основе TAppConsole

Для перечисления всех модулей программы используется перечисление TModuleType. TAppConsole пробегается по значениям этого типа TModuleType = (mtPriceList, mtOrders, mtSupplies) и вызывает фабрику для создания конкретного модуля. Затем запрашивает у модуля имя (с помощью функции GetName) и поддерживаемые операции (GetOperations). На основании этих данных строится двухуровневое дерево tvCommands: TTreeView. Элементы первого уровня - имена модулей, второго – поддерживаемые ими операции. При этом свойству Data элементов дерева назначается ссылка на соответствующий модуль. Вместо дерева можно строить и меню или панель инструментов. Таким образом, реализуется решение, похожее на оснастку Microsoft Management Console, только не в рамках всей ОС, а в конкретном приложении. Код создания экземпляров модулей через фабрику модулей приложения приведен в листинге 1.

Листинг 1 - создание экземпляров модулей через theModuleFactory
{ Создает все модули программы и наполняет ими дерево команд консоли }
procedure TAppConsole.CreateModules;
var
  Module: TAppModule;
begin
...
...
 { TModuleType - типы модулей }
  for i := low(TModuleType) to high(TModuleType) do
  begin
   { Создание модуля через фабрику }
    Module := theModuleFactory.CreateModule(i, theMediator);
    theMediator.ModuleList.Add(Module);

   { Добавление модуля в дерево }
    Node := tvCommands.Items.Add(nil, Module.GetName);
    with Node do
    begin
      Data := Module;
      sl := TStringList.Create;
      { Получение списка операций модуля. Добавление их в дерево }
      Module.GetOperations(sl);
      for j := 0 to sl.Count-1 do
        tvCommands.Items.AddChild(Node, sl[j]);
      sl.Free;
    end;
  end;
...
 end;

Рассмотрим подробнее работу абстрактной фабрики TModuleFactory (листинг 2). Она осведомлена о всех конкретных модулях и способна порождать их экземпляры. Ее единственный метод CreateModule создает модуль заданного типа и назначает ему медиатор. Медиатор используется для связи между модулями - для передачи команд. О его использовании будет сказано ниже.

Листинг 2 – работа фабрики модулей TModuleFactory
{ TModuleType - типы модулей }
TModuleType = (mtPriceList, mtOrders, mtSupplies);

function TModuleFactory.CreateModule(ModuleType: TModuleType; 
                                     Mediator: TMediator): TAppModule;
var
  ModuleClass: TModuleClass;
begin
  { выбор класса }
  case ModuleType of
    mtPriceList:
      ModuleClass := TPriceListAppModule;
    mtOrders:
      ModuleClass := TOrdersAppModule;
    mtSupplies:
      ModuleClass := TSuppliesAppModule;
    else
      Assert(false, 'TModuleFactory.CreateModule: '
        + 'не определен класс для создания модуля');
  end;

  { создание модуля по выбранному классу }
  Application.CreateForm(ModuleClass, Result);
  Result.Mediator := Mediator;
end;

Диаграмма последовательности, связывающая приведенные фрагменты кода, приведена на рис. 5. В ней показана последовательность создания конкретного модуля PriceListAppModule.


Рисунок 5. Диаграмма последовательности создания модуля

Таким образом, в рассмотренной схеме главный модуль остается неизменным при добавлении/удалении модулей TAppModule в проекте. Минимальные изменения необходимы только в фабрике модулей. Добавляемый модуль будет автоматически увиден и его интерфейс будет подключен в дерево команд (или главное меню) главной формы. Тем самым достигается эффект слабой связанности (Low Coupling) как между модулями TAppModule и главным модулем TAppConsole. Тут необходимо подробнее рассказать о базовом классе модуля проекта – TAppModule.

TAppModule – модуль проекта

Модули проекта являются носителями бизнес-логики в проекте. Это, к примеру, может быть TOrdersAppModule – логика работы с заказами, TSuppliesAppModule – логика работы с поставками и т.д. Все они наследуются от абстрактного TAppModule, который содержит всю необходимую служебную функциональность (рис.6).

ПаттернНет
Имя в проектеTAppModule
ЗадачаОбеспечить абстрактный интерфейс для работы со всеми модулями
РешениеРеализовать базовый класс для всех модулей (рис. 3.)
РезультатОбеспечивает единый интерфейс для консоли приложения при создании различных модулей (TPriceListAppModule, TOrdersModule, …). Включает ссылку на медиатор для «общения» с остальными модулями. Инкапсулирует свой уровень представления.


рис 6. Абстрактный класс TAppModule – основа всех модулей бизнес-логики проекта

TAppModule владеет ссылкой на медиатор, что позволяет ему обмениваться сообщениями с другими модулями, не зная при этом их конкретной реализации. Такая архитектура делает модуль независимым от всех прочих модулей приложения. При этом каждый модуль сам реализует свой уровень представления (рис.2). Методы GetName и GetOperations позволяют главному модулю TAppConsole получить имя модуля и множество поддерживаемых им функций для формирования интерфейса пользователя TAppConsoleForm. Через метод Execute осуществляется вызов функции модуля с соответствующим порядковым номером. Этот вызов происходит, когда пользователь вызывает пункт меню, соответствующий одной из функций модуля (рис. 4). Метод ExecCommand обеспечивает возможность передачи модулю команды и используется только медиатором, мотивацию существования которого мы и разберем далее.

Связь между модулями. Медиатор и команда

ПаттернМедиатор
Имя в проектеTMediator
ЗадачаОбеспечить способ взаимодействия между модулями
РешениеИспользовать медиатор для обмена сообщениями
РезультатМедиатор «знает» обо всех модулях приложения, и все модули «знают» о Медиаторе (рис. 7). Модули могут «общаться», не «зная» друг друга

В результате анализа требований предметной области возникла необходимость взаимодействия различных модулей друг с другом. Самый простой путь – это реализовать ассоциативную связь «каждый-с-каждым» (рисунок 7).


При небольшом количестве модулей (2-3) такое решение оправдано вследствие простоты исполнения, но при увеличении числа модулей до 5-10 и больше возникают следующие проблемы:

  1. В связи с нарушением принципа слабой связанности (Low coupling) объектов приложения чрезмерно вырастает трудоемкость отслеживания взаимосвязей между модулями.
  2. Возникает сложность при добавлении новых модулей в приложение.
  3. Пропадает читаемость исходного кода.

Для решения данных проблем вводится дополнительная сущность – Медиатор (рис. 8). Он реализуется как Singleton.

Взаимодействие между модулями осуществляется созданием нужной команды и отправкой ее через медиатор. Поскольку отправитель не знает класса получателя, и медиатор не знает, кому предназначено сообщение, он вызывает все модули последовательно, т.е. отправляет команду широковещательно всем модулям. Команда представляет собой объект. Получатели распознают тип команды, и обрабатывают только те, что предназначаются им.


Рисунок 8. Взаимосвязь модулей через дополнительную сущность – Медиатор.

В интерфейсе модуля присутствует публичный метод ExecCommand (рис. 6). Именно через него медиатор и осуществляет передачу команд. Пример обработчика команд приведен в листинге 3.

Листинг 3 - обработчик команд в модуле TOrdersAppModule
{ обрабочик команд TOrdersAppModule }
procedure TOrdersAppModule.ExecCommand(Command: TCustomCommand);
begin
  if Command is TNewOrderCommand then // создать заказ
    OnCommand_NewOrder( TNewOrderCommand(Command) );

  if Command is TNewOrderItemCommand then // добавить позицию заказа
    OnCommand_NewOrderItem( TNewOrderItemCommand(Command) );
 
  if Command is TGetOrderCommand then   // получить данные об активном заказе
    OnCommand_GetOrderCommand( TGetOrderCommand(Command) );
end;

Модуль-отправитель создает объект-команду для модуля-получателя и вызывает метод медиатора SendMessage(TCustomCommand). Медиатор последовательно просматривает все зарегистрированные в нем модули и вызывает их команду ExecCommand(TCustomCommand). Вызываемая процедура Execute поверяет тип команды и или выполняет ее, или отвергает. Команды выполняются в синхронном режиме. Данный подход напоминает паттерн Publisher-Subscriber. На рис. 8 показана структура классов команд и их взаимосвязь с Медиатором.

ПаттернКоманда
Имя в проектеTCustomCommand
ЗадачаОбеспечить синхронный способ передачи сообщений между модулями
РешениеИнкапсулировать необходимые данные в объект команды и создавать наследников для всех типов команд (рис.9). Использовать медиатор для обмена сообщениями, которые содержат ссылку на конкретную команду.
РезультатПередача сообщений между модулями без прямых вызовов
Листинг 4 – Пример классов команд Листинг 4 – Пример классов команд
TCommandResult= (crUndefined, crOk, crCancel, crException, crIgnored);

{ базовая команда }
TCustomCommand = class
public
  CommandResult: TCommandResult; // результат обработки команды
  ExceptionMessage, ExceptionClassName: String; // данные об исключении
end;  

{
TGetClientCommand – обработчик команды по заданному ИНН (INN) 
должен найти клиента в БД, и вернуть его данные и 
TCommandResult = crOK. 
Иначе вернуть TCommandResult = crUndefined
}
TGetClientCommand = class(TCustomOrderCommand)
private
{ секция private и спецификации чтения-записи свойств опущены }
...
public
  property INN: double;
  property CompanyName: string;
  property EMail: string;
  property Address: string;
  property Phone: string;
end;


Рисунок 9. Структура классов команд и их взаимосвязь с Медиатором.

Примером применения команды может служить загрузка заказа из файла, когда нужно проверить, существует ли в БД клиентов соответствующий отправитель заказа, и если такового нет, предложить пользователю добавить его.

Листинг 5. Использование команды для проверки регистрации заказчика.
var
  ...
  GetClientCommand: TGetClientCommand;
begin
  ...
  { Проверить наличие клиента в базе данных }
  GetClientCommand.INN := ‘1234567890’;
  theMediator.SendMessage(GetClientCommand);
  case GetClientCommand.CommandResult of
    crUndefined:
      if MessageDlg(
        'Не могу загрузить данные прайслиста. Поставщик неизвестен: '
        + GetClientCommand.INN + '. Зарегистрировать поставщика? ',
        mtConfirmation, [mbOKCancel], 0) = mrOK 
      then 
        { Регистрируем клиента в базе данных через соответствующую команду };
    crException:
      Exception.Raise('Не могу загрузить данные прайслиста. Ошибка: ' 
        + GetClientCommand.ExceptMessage);
  end; { end case }
  ...
end;

Эта команда будет последовательно отправлена медиатором всем модулям, но обработчик для нее существует только в модуле клиентов TClientAppModule. Поскольку вся логика работы с клиентами скрыта в TclientAppModule, то он самостоятельно решает, как обрабатывать каждую команду. В данном случае, он произведет поиск в БД клиентов. Можно изменить логику обработки этой команды и совместить в ней запрос на поиск клиента по ИНН и автоматический запуск мастера регистрации нового клиента в случаи неудачи поиска. Тогда код команды упростится, и отдельная команда для создания клиента не понадобится, но тем самым мы потеряем отдельную команду для проверки существования клиента. Какой вариант лучше – решается, исходя из требований к конкретной функциональности разрабатываемой программы.

Собирая части вместе: порядок инициализации приложения

При запуске приложения сначала создается AppConsole (Singleton), которая в свою очередь создает theMediator (Singleton) и theModuleFactory (Singleton). Затем в цикле создаются модули, посредством вызова функции CreateModule, в которой один из параметров имеет тип модуля (TModuleType), а второй – это ссылка на Медиатор. Перечисление TModuleType = (mtPriceList, mtClients,…) необходимо для фабрики модулей, которая должна знать, когда и какой модуль передавать. Ссылку на Медиатор фабрика классов передает вновь созданному модулю для обеспечения передачи сообщений. Тем самым достигается эффект слабой связанности (Low Coupling) между модулями. Затем консоль от вновь созданного модуля (Module) получает его имя (GetName) и поддерживаемые операции (GetOperations). По этим данным консоль строит меню и панели управления.

Резюме

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

  1. Добавить соответствующее значение в перечисление TModuleType;
  2. Наследовать модуль от TAppModule и перегрузить операции GetName, GetOperations, Execute;
  3. Добавить в фабрику классов TModuleFactory код создания модуля.

Низкая связность между модулями и стандартизация интерфейсов модулей позволяет значительно упростить групповую разработку проекта. Для построения прикладного ПО при среднем размере проектов мы рекомендуем попробовать использовать архитектуру, рассмотренную в данной статье. Исходные тексты на Delphi и C++Builder готовой заготовки проекта с подробными комментариями кода можно найти на прилагаемом к журналу CD. Код проекта содержит дополнительную функциональность, не рассмотренную в данной статье. Так, в нем реализован механизм работы с конверторами, позволяющий каждому модулю сохранять и загружать данные (прайс-листы, заказы, накладные) в нужных ему форматах (текст, DBase, XML). Там же вы сможете найти также электронную копию этого материала и соответствующий проект RationalRose.

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

Литература

  1. Приемы ООП. Паттерны проектирования / Э.Гамма и др. СПб.: Питер, 2001 г.
  2. Применение UML и шаблонов проектирования / Введение в объектно-ориентированный анализ, проектирование и унифицированный процесс UP : Пер. с анг. Вильямс, 2002 г.
  3. Шаблоны проектирования / А. Шаллоуей, Дж.Р. Тротт. М.:Вильямс, 2002 г.
  4. Стен Санблэд, Пер Санблэд Разработка масштабируемых приложений для Microsoft Windows. Русская Редакция, 2002 г.


Эта статья опубликована в журнале RSDN Magazine #3. Информацию о журнале можно найти здесь