ПРОГРАММИРОВАНИЕ    НА    V I S U A L   C + +
РАССЫЛКА САЙТА       
RSDN.RU  

    Выпуск No. 87 от 13 апреля 2003 г.
   
Подписчиков: 20767 

РАССЫЛКА ЯВЛЯЕТСЯ ЧАСТЬЮ ПРОЕКТА RSDN , НА САЙТЕ КОТОРОГО ВСЕГДА МОЖНО НАЙТИ ВСЮ НЕОБХОДИМУЮ РАЗРАБОТЧИКУ ИНФОРМАЦИЮ, СТАТЬИ, ФОРУМЫ, РЕСУРСЫ, ПОЛНЫЙ АРХИВ ПРЕДЫДУЩИХ ВЫПУСКОВ РАССЫЛКИ И МНОГОЕ ДРУГОЕ.

Здравствуйте!

Темой сегодняшнего выпуска рассылки будет использование так называемых паттернов проектирования. О том, что это такое и как это использовать, рассказывается и показывается на конкретном примере ниже, а я хочу просто сделать одну оговорку: хотя примеры в статье представлены для среды Delphi (Object Pascal), основной упор делается на сами концепции проектирования, которые можно использовать и в С++, и во многих других объектно-ориентированных языках. Так что я думаю, эта статья будет без сомнения полезна читающим рассылку программистам на С++.  Уверен также, что понять приведенные в статье примеры им не составит особого труда. 


 CТАТЬЯ

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

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

Источник: RSDN Magazine #3

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, главная форма изолирована от множества форм, которые реализуют уровень представления для конкретных бизнес-функций. Она «видит» только множество модулей, на которые разбита вся программа. Сами модули не «видят» друг друга. Они инкапсулируют в себе всю логику создания и вызова форм.

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

  • Четкое функциональное и предметное разделение модулей программы.
  • Разделение логики и представления делает структуру понятней.
  • Разгрузка модулей данных.
  • Снимается избыточная связность, повышается зацепление (шаблон распределения обязанностей «High Cohesion» [2]).

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

Проект

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

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

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

Рисунок 3 

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

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

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

Рисунок 4

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

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

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


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 г.


Ведущий рассылки: Алекс Jenter   jenter@rsdn.ru
Публикуемые в рассылке материалы принадлежат сайту RSDN.

| Предыдущие выпуски     | Статистика рассылки