Расширение возможностей паттерна Command

Автор: Сергей Гурин
http://gurin.tomsknet.ru

Источник: RSDN Magazine #5-2004
Опубликовано: 02.05.2005
Версия текста: 1.0
Предисловие
Выбор интерфейса команды
Реализация команды
Время жизни команды
Создание команды
Сериализация
Среда выполнения
Собираем всё вместе
Приложение: реализация фабрики классов на Delphi

Предисловие

Для начала стоит привести несколько цитат из книги Эриха Гаммы и др. Приемы объектно-ориентированного проектирования, касающихся паттерна Command. Паттерн «инкапсулирует запрос как объект, позволяя тем самым задавать параметры клиентов для обработки соответствующих запросов, ставить запросы в очередь или протоколировать их, а также поддерживать отмену операций». Паттерн позволяет «отправлять запросы неизвестным объектам, преобразовав сам запрос в объект. Этот объект можно хранить и передавать, как и любой другой. В основе паттерна лежит абстрактный класс Command, в котором объявлен интерфейс для выполнения операций. В простейшей форме этот интерфейс состоит из одной абстрактной операции Execute. Конкретные подклассы Command определяют пару получатель-действие, сохраняя получателя в переменной экземпляра, и реализуют операцию Execute, так чтобы она посылала запрос. У получателя есть информация, необходимая для выполнения запроса». При реализации паттерна нужно решить «насколько умной должна быть команда. На одном полюсе стоит простое определение связи между получателем и действиями, на другом – реализация всего самостоятельно, без обращения за помощью к получателю. ... Где-то посередине между двумя крайностями находятся команды, обладающие достаточной информацией для динамического обнаружения своего получателя».

Первое видимое ограничение паттерна связано с необходимостью указания адреса конкретного получателя. Хотя авторы и упомянули о динамическом поиске получателя как о потенциальной возможности, но больше об этом ничего не говорили. Вот конкретный пример, в котором удобно было бы использовать паттерн, но прямая реализация его невозможна: отправитель запроса и его получатель находятся в различных адресных пространствах. Речь идет о тех случаях, в которых командам требуется пересекать границы приложения или компьютера. Понятно как решать задачу транспортировки объекта команды – для этого существует маршаллинг. Объект сериализуется (преобразуется в последовательность байт), передается получателю и воссоздается в его адресном пространстве. Но как быть с адресом получателя?

Другая важная проблема, которая ограничивает использование паттерна, состоит в том, что семантика операции Execute может быть различна у отправителя и получателя. Поясню это высказывание. Представим такую систему, в которой отправитель и получатель достаточно симметричны и равноправны в смысле взаимодействия. При взаимодействии они могут меняться местами – отправитель одной и той же команды может стать ее получателем, и наоборот. Но при этом, отправитель и получатель могут быть реализованы различным образом. Общее между ними – только способность реагировать на одни и те же команды. Еще пример – сервер посылает одну и ту же команду своим клиентам, но различные клиенты могут по-разному реагировать на эту команду. Трудность здесь в том, что контекст выполнения операции Execute и ее семантика для одной и той же команды может отличаться у отправителя и у получателя (или у различных получателей).

Если рассматривать общий случай, при котором отправитель и получатель (получатели) реализуются на различных языках программирования, то можно использовать некоторый общий промежуточный язык, например IDL, для описания интерфейсов команд, а реализацию классов команд делать для каждого конкретного контекста. В данной статье мы не будем рассматривать общий случай, а ограничимся только достаточно важным частным случаем, когда отправители и получатели реализованы на одном и том же языке в рамках одной и той же платформы.

Выбор интерфейса команды

Если следовать книге «Приемы объектно-ориентированного проектирования», то простейший интерфейс команды будет таким:

Command = classpublicprocedure Execute; virtual; abstract;
end;
ПРИМЕЧАНИЕ

В статье используется язык Delphi. Для C++-программистов будут делаться необходимые пояснения.

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

Command = classpublicprocedure Execute; virtual; abstract;
  procedure Write(const Writer: IWriter); virtual; abstract;
  procedure Read(const Reader: IReader); virtual; abstract;
end;

Для сериализации используем метод Write, с помощью которого конкретная команда будет посылать свои данные в поток, задаваемый интерфейсом IWriter. Для десериализации в интерфейсе команды определен метод Read, с помощью которого конкретная команда получает свои данные из потока IReader в процессе конструирования команды у получателя.

До сих пор трудностей не возникало. Они возникают, когда требуется учесть контекст, в котором будет выполняться команда. Решение, которое предлагается в статье, состоит использовании техники «двойной диспетчеризации». Название техники в данном случае отражает наличие двух вариантных параметров – тип конкретной команды и тип конкретного контекста. То есть, мы должны выполнить неизвестную команду в неизвестном контексте. Есть языки, напрямую поддерживающие двойную диспетчеризацию (такие как CLOS). Большинство языков, включая Delphi, Java и C++, поддерживают только одинарную диспетчеризацию. Одинарная диспетчеризация реализуется во всех указанных языках с помощью виртуальных методов (а точнее, с помощью таблицы виртуальных методов – vtbl). Конкретный класс ссылается на собственную vtbl. Таблица виртуальных методов одномерна и определяет соответствие индекса виртуального метода его конкретному адресу (проще говоря, таблица – это одномерный массив указателей на функции).

Для реализации двойной диспетчеризации нам потребуется добавить еще один уровень косвенности. Для этого изменим сигнатуру метода Execute и добавим аргумент контекста:

Command = classpublicprocedure Execute(const Context: IContext); virtual; abstract;
  procedure Write(const Writer: IWriter); virtual; abstract;
  procedure Read(const Reader: IReader); virtual; abstract;
end;

Интерфейс контекста будет таким:

IContext = interfaceprocedure ExecuteCommand1(cmd: Command1);
  procedure ExecuteCommand2(cmd: Command2);
  ....
  procedure ExecuteCommandN(cmd: CommandN);
end;
ПРИМЕЧАНИЕ

В первом приближении interface языка Delphi соответствует чистому абстрактному C++-классу. Если говорить более точно, то интерфейсы Delphi соответствуют COM-интерфейсам и наследуются от базового интерфейса IUnknown.

Здесь мы видим, что для каждого конкретного класса команды интерфейс IContext содержит конкретный метод. Реализация метода Execute конкретного класса CommandXXX очень проста:

      procedure CommandXXX.Execute(const Context: IContext);
begin
  Context.ExecuteCommandXXX(self);
end;
ПРИМЕЧАНИЕ

Ключевое слово self в Delphi полностью соответствует ключевому слову this в С++. Вместо имен ExecuteCommandXXX в С++ можно использовать перегруженные имена Execute, но вряд ли это увеличит понятность программы.

Получатель (контекст получателя) вызывает виртуальный метод Execute неизвестной ему команды, передавая интерфейс контекста как аргумент (первый шаг диспетчеризации). Команда XXX, в свою очередь, вызывает у неизвестного ей контекста виртуальный метод ExecuteCommandXXX, соответствующий типу данной команды, передавая себя как аргумент (второй шаг диспетчеризации). Двойная диспетчеризация требует обращения к двум vtbl – команды и контекста. Таким образом, мы получили желаемый эффект – при вызове ExecuteCommandXXX происходит связывание конкретного контекста с конкретной командой. Реализация интерфейса IContext у каждого получателя может быть своей. Внимательные читатели, вероятно, заметили сходство с паттерном Visitor, в котором также используется техника двойной диспетчеризации.

Выполнение команды может быть реализовано различными способами, например:

Реализация команды

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

Время жизни команды

Первый вопрос, возникающий при реализации команды: кто будет ответственным за ее уничтожение? Если язык программирования (или среда) имеет систему автоматической сборки мусора (например, Java, .NET), то первый вопрос можно опустить и сразу перейти ко второму. Но в случае языков, в которых отсутствует сборка мусора, вопрос уничтожения объектов обойти нельзя. Если ответственным за уничтожение команд будет отправитель или получатель, то мы столкнемся с рядом трудностей. Первая трудность связана с использованием команд в многопоточном окружении, когда отправитель и получатель работают в различных потоках (threads). Другая трудность возникает в том случае, когда отправитель или получатель сохраняют у себя копию команды (например, для протоколирования) или если команда помещается в очередь (или несколько очередей). Если учесть указанные трудности, то предложенный вариант следует отвергнуть. Возможно, лучшей техникой, не имеющей указанных дефектов, является подсчет ссылок. При использовании техники подсчета ссылок команда сама отвечает за свое уничтожение. Если команда помещается в очередь, или на нее начинает ссылаться еще один объект, должен быть вызван метод AddRef, увеличивающий счетчик ссылок на 1. Если команда извлекается из очереди, или на нее перестает ссылаться некоторый объект, должен быть вызван метод Release, уменьшающий счетчик ссылок на 1. Достижение значения 0 означает, что команда больше никому не нужна, и метод Release вызывает деструктор. Подсчет ссылок – это фундаментальная техника, используемая операционной системой Windows для управления временем жизни системных объектов ядра, COM-объектами, системами сборки мусора и так далее. Особенно явно преимущества подсчета ссылок проявляются в многопоточных приложениях, так как подсчет ссылок изолирует время жизни команд от времени жизни параллельных потоков. После добавления механизма подсчета ссылок у нас получится такое определение команды:

Command = classpublicconstructor Create;
  destructor Destroy; override;

  procedure Execute(const Context: IContext); virtual; abstract;
  procedure Write(const Writer: IWriter); virtual; abstract;
  procedure Read(const Reader: IReader); virtual; abstract;

  function AddRef: Integer;
  function Release: Integer;

private
  RefCount: Integer;
end;

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

        constructor Command.Create;
begininherited Create;
  RefCount := 1;
end;

destructor Command.Destroy;
begininherited Destroy;
end;

function Command.AddRef: Integer;
begin
  result := Windows.InterlockedIncrement(RefCount);
end;

function Command.Release: Integer;
begin
  result := Windows.InterlockedDecrement(RefCount);
  if result = 0 then
    Destroy;
end;

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

ПРИМЕЧАНИЕ

Ключевое слово inherited означает вызов метода базового класса. В Delphi все классы неявно порождаются от TObject.

Создание команды

Второй важный вопрос, интересующий нас при реализации команды: кто будет ответственным за ее создание? На первый взгляд, здесь нет проблем – команду может создавать отправитель простым вызовом конструктора. Сложности возникают, когда нам требуется воссоздать команду у получателя в том случае, если отправитель и получатель находятся в различных адресных пространствах. Традиционное решение этой задачи – фабрика классов, которая строится как паттерн Singleton. Вопрос здесь только в том, как идентифицировать команды. К сожалению, автору не известна универсальная техника, которая была бы одинаково эффективно и удобно применима для любого языка программирования. Поэтому вопрос идентификации команд и их конструирования придется решать для каждого конкретного языка. Общие соображения здесь такие:

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

        function CreateCommand(тип_команды): Command;

Сериализация

Третий вопрос связан с сериализацией команды. Можно, конечно, предложить некоторый универсальный интерфейс вроде:

IWriter = interfaceprocedure WriteByte(Value: Byte);
  procedure WriteWord(Value: Word);
  procedure WriteDWord(Value: DWord);
  procedure WriteDouble(Value: Double);
  procedure WriteString(const Value: string);
  .....
end;

Такой интерфейс можно достаточно эффективно реализовать для любого языка программирования, но во многих языках и средах уже имеются готовые классы для сериализации, поэтому можно не делать своей реализации, а воспользоваться готовой. Особо стоит лишь отметить отсутствие у команды номера версии, что характерно, например, для сериализуемых объектов библиотеки MFC. Это подчеркивает сходство класса Command с интерфейсом – после опубликования описания класса вносить изменения в его интерфейс нельзя. Приведу цитату из книги Дона Бокса Сущность технологии COM: «Существует, однако, один аспект объекта, который не может изменяться во времени – это его интерфейс. Это связано с тем, что пользователь осуществляет трансляцию с определенной сигнатурой интерфейса класса, и любые изменения в описании интерфейса требуют повторной трансляции клиента для учета этих изменений. Хуже того, изменение описания интерфейса полностью нарушает инкапсуляцию объекта (так как его открытый интерфейс изменился) и может испортить программы всех существующих клиентов. Даже самое безобидное изменение, такое как изменение семантики метода с сохранением его сигнатуры, делает бесполезной всю установленную клиентскую базу. Эта неизменяемость требует стабильной и предсказуемой среды на этапе выполнения».

Для нашего случая стабильность объекта означает стабильность того потока байтов, который получается в результате сериализации. Изменение этого потока неизбежно приведет к ошибке при десериализации команды с другой версией.

СОВЕТ

Для расширения или изменения функциональности создавайте новый класс команды, оставляя существующий класс без изменений. Старые программы (или фрагменты программ) смогут при этом работать без изменений, а новые программы будут использовать расширенные возможности. В реализации новых классов команд (их новых версий) возможно наследование имеющихся версий и, в том числе, их методов Read-Write. Фактически, версию объекта должен определять тип (класс команды).

Среда выполнения

Последний вопрос, который может нас волновать в связи с реализацией команд: каким образом выполняется посылка и получение команд? Книга «Приемы объектно-ориентированного проектирования» дает следующую схему отношений между отправителем и получателем (цитирую):

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

Учитывая эти соображения, можно предложить другую схему взаимодействия отправителя и получателя (получателей). В основе этой схемы лежит использование объекта-посредника. Посредник, фактически, заменяет Invoker’a, расширяя его назначение. В первом приближении интерфейс посредника определим так:

IMedia = interfaceprocedure Send(aCommand: Command; const aReceiver: string);
  function  Receive: Command;
end;

Название интерфейса посредника отражает то, что посредник играет для команд роль среды передачи (media). Для посылки команды отправитель вызывает у посредника метод Send, а для получения команды получатель вызывает метод Receive. В простом частном случае, реализация предложенного интерфейса выполняет ту схему, которая описана в «Приемах объектно-ориентированного проектирования». В более сложном случае среда может транспортировать команды через границы приложения или компьютера (например, с помощью именованных каналов или TCP-сокетов), выполнять синхронизацию параллельных потоков (используя, например, критические секции, рандеву или помещение команды в очередь получателя). Другая возможная функция посредника – хранение списка получателей и транспортировка команды:

а) одному конкретному получателю;

б) группе получателей;

в) всем получателям (широковещательная рассылка).

Конечно, это потребует решения вопроса идентификации (именования) получателей. В приведенном выше интерфейсе посредника получатель идентифицируется строковым именем, но это только один из возможных вариантов идентификации. Если используется групповая посылка, то имя получателя может содержать метасимволы, например, имя «*» означает всех получателей, имя «Dsr*» означает группу получателей, имена которых начинаются с имени группы – префикса «Dsr».

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

IReceiver = interfaceprocedure Accept(aCommand: Command);
end;

Класс получателя должен реализовать оба интерфейса – IReceiver и IContext (команды выполняются в контексте получателя), поэтому метод Accept выглядит очень просто:

        procedure SomeReceiver.Accept(aCommand: Command);
begin
  aCommand.Execute(self);
end;

Вызов aCommand.Execute является первой фазой двойной диспетчеризации, после чего команда вызывает конкретный метод у данного получателя. В результате получатель косвенно вызывает свой собственный виртуальный метод, и аргументом этого вызова будет полученная команда.

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

Собираем всё вместе

В результате проведенного анализа мы получили следующие сущности:

Если собрать все определения вместе, то мы получим один-единственный модуль, который не зависит от конкретики отправителей и получателей и может быть включен в модули (единицы компиляции) всех отправителей и получателей, что обеспечивает 100% идентичность команд для различных модулей. В противном случае нам бы потребовалось многократно реализовывать один и тот же набор команд для каждого конкретного отправителя-получателя. Среда приложения должна реализовать интерфейсы IReader-IWriter, IMedia и CreateCommand. Каждый получатель должен предоставлять свою собственную реализацию интерфейсов IReceiver и IContext:

SomeReceiver = class(BaseReceiver, IReceiver, IContext)
public// интерфейс IReceiverprocedure Accept(aCommand: Command);

  // интерфейс IContextprocedure ExecuteCommand1(cmd: Command1);
  procedure ExecuteCommand2(cmd: Command2);
  ....
  procedure ExecuteCommandN(cmd: CommandN);
end;


Рисунок 1.

На рисунке 1 показана диаграмма взаимодействий участников. Рассмотрим их отношения подробнее:

Приложение: реализация фабрики классов на Delphi

Для того, чтобы пояснить принципы реализации фабрики классов команд, нужно отметить одну важную особенность Delphi (в отличие от С++). В этом языке существует понятие метакласса, причем объект класса можно создавать, вызывая конструктор для метакласса. Кроме того, метакласс имеет метод ClassName, который возвращает строковое имя класса. Идентификация команд состоит в следующем:

Фабрика команд представлена двумя глобальными функциями – RegisterCommands и FindCommand, назначение которых понятно из их названий. Процедура RegisterCommands принимает в качестве аргумента открытый массив метаклассов и вызывается до начала выполнения программы (в секции initialization). Например, если у нас есть классы конкретных команд Command1, Command2, ... CommandN, то их регистрация будет выглядеть так:

      initialization
  RegisterCommands([Command1, Command2, .... CommandN]);

Поиск команды выполняется глобальной функцией FindCommand, которая принимает в качестве аргумента строковое имя класса и возвращает указатель на метакласс. Если метакласс команды не найден, то функция возвращает nil. Для создания экземпляра команды нужно вызвать конструктор класса, используя ссылку на метакласс, например,

      var
  cmd: TCommand;
  cls: TCommandClass; // базовый метакласс для всех команд

cls := FindCommand(Name);
if cls <> nilthen
  cmd := cls.Create
elseraise Exception.Create(....); // команда не найдена

Объект фабрики классов представляет собой скрытый объект (паттерн Singleton), который создается либо перед выполнением программы, либо при вызове функции RegisterCommands. Такая неопределенность обусловлена неопределенным порядком загрузки и инициализации модулей программы. Для достижения высокой скорости поиска, фабрика команд реализована с использованием хеш-таблицы.

Код фабрики команд
      interface

      procedure RegisterCommands(aCommands: arrayof TCommandClass);
  function  FindCommand(const aName: String): TCommandClass;

implementation
uses
  IniFiles;

var
  CommandFactory: TStringHash;

procedure CreateCommandFactory;
beginifnot Assigned(CommandFactory) then
    CommandFactory := TStringHash.Create;
end;

procedure RegisterCommands(aCommands: arrayof TCommandClass);
var
  i: Integer;
begin
  CreateCommandFactory;
  for i := Low(aCommands) to High(aCommands) do
    CommandFactory.Add(aCommands[i].ClassName, Integer(aCommands[i]));
end;

function FindCommand(const aName: String): TCommandClass;
begin
  result := TCommandClass(CommandFactory.ValueOf(aName));
end;

initialization
  CreateCommandFactory;

finalization
  CommandFactory.Free;


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