![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() |
GlobusLib.EXE (1.1 МБ) – библиотека GVCL
appTemplate.zip (90 кБ) – каркас проекта Delphi
patmodel.zip (27 кБ) – диаграммы RationalRose
Один из способов борьбы со сложностью – это структуризация
беспорядка в беспорядки меньшего размера.
В ООП этому помогает создание и повсеместное использование
типовых абстракций, или паттернов проектирования.
Паттерн, или шаблон, проектирования представляет собой модель взаимодействия классов для решения какой-либо типичной задачи. Их существует множество, и вы также с ними сталкивались. Самый простой пример – это итератор, реализующий перемещение по некоему списку элементов.
Практически во всех проектах можно встретить те или иные паттерны проектирования. Но далеко не часто они обозначены разработчиками. Проект, в котором явно обозначены все использованные паттерны, удобнее для понимания и более управляем. Можно сказать, что описание проекта в терминах паттернов добавляет новые метаданные о проекте. Если мы читаем, что данный класс реализует паттерн "итератор", мы сразу получаем представление об его интерфейсе и роли. Если же изначально весь проект реализован с использованием паттернов, то управление проектом упрощается. Обобщение удачных решений конкретных задач в паттерны и использование их в последующих проектах существенно ускоряет процесс разработки. А код становится более понятным и элегантным, и им можно будет воспользоваться повторно.
Здесь мы рассмотрим опыт применения некоторых шаблонов при проектировании реального приложения. Будет рассмотрено применение шаблонов Абстрактная фабрика (Abstract Factory), Медиатор (Mediator), Команда (Command), Одиночка (Singleton). При этом мы сопоставим предлагаемый подход с распространенным подходом к проектированию архитектуры небольших и средних прикладных программ.
Паттерн проектирования – это описание задачи, которая постоянно возникает в ходе проектирования объектно-ориентированных программ, и принципов ее решения. Причем данное решение может быть использовано повторно. Смысл паттерна – предложить решение задачи в определенных условиях.
Паттерн, в общем случае, состоит из четырех элементов:
Говоря далее по тексту статьи о применении конкретных паттернов, мы будем кратко характеризовать их именно по такой схеме: Имя – Задача – Решение – Результат. Мы будем исходить из предположения знакомства читателя с описываемыми паттернами. За более подробным описанием абстракций конкретных паттернов рекомендуется обратиться к книгам [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.
{ Создает все модули программы и наполняет ими дерево команд консоли } 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 создает модуль заданного типа и назначает ему медиатор. Медиатор используется для связи между модулями - для передачи команд. О его использовании будет сказано ниже.
{ 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.
Модули проекта являются носителями бизнес-логики в проекте. Это, к примеру, может быть 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 и больше возникают следующие проблемы:
Для решения данных проблем вводится дополнительная сущность – Медиатор (рис. 8). Он реализуется как Singleton.
Взаимодействие между модулями осуществляется созданием нужной команды и отправкой ее через медиатор. Поскольку отправитель не знает класса получателя, и медиатор не знает, кому предназначено сообщение, он вызывает все модули последовательно, т.е. отправляет команду широковещательно всем модулям. Команда представляет собой объект. Получатели распознают тип команды, и обрабатывают только те, что предназначаются им.
Рисунок 8. Взаимосвязь модулей через дополнительную сущность – Медиатор.
В интерфейсе модуля присутствует публичный метод ExecCommand (рис. 6). Именно через него медиатор и осуществляет передачу команд. Пример обработчика команд приведен в листинге 3.
{ обрабочик команд 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). Использовать медиатор для обмена сообщениями, которые содержат ссылку на конкретную команду. |
Результат | Передача сообщений между модулями без прямых вызовов |
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. Структура классов команд и их взаимосвязь с Медиатором.
Примером применения команды может служить загрузка заказа из файла, когда нужно проверить, существует ли в БД клиентов соответствующий отправитель заказа, и если такового нет, предложить пользователю добавить его.
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). По этим данным консоль строит меню и панели управления.
Рассмотренный подход позволяет, на наш взгляд, реализовать достаточно универсальную базовую архитектуру для построения расширяемых приложений. Для создания дополнительного модуля в приведенном проекте всего лишь необходимо:
Низкая связность между модулями и стандартизация интерфейсов модулей позволяет значительно упростить групповую разработку проекта. Для построения прикладного ПО при среднем размере проектов мы рекомендуем попробовать использовать архитектуру, рассмотренную в данной статье. Исходные тексты на Delphi и C++Builder готовой заготовки проекта с подробными комментариями кода можно найти на прилагаемом к журналу CD. Код проекта содержит дополнительную функциональность, не рассмотренную в данной статье. Так, в нем реализован механизм работы с конверторами, позволяющий каждому модулю сохранять и загружать данные (прайс-листы, заказы, накладные) в нужных ему форматах (текст, DBase, XML). Там же вы сможете найти также электронную копию этого материала и соответствующий проект RationalRose.
В качестве дальнейшего развития архитектуры рекомендуется перейти к работе через интерфейсы при вызове между различными уровнями программы. Для построения приложений, которые масштабируются до уровня распределенных систем, следует более четко выстраивать трехуровневую (или даже пятиуровневую) [3] структуру.
![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() |