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

Последнее обновление: 11 декабря 2005

В статье описывается расширение паттерна Command, которое позволяет командам выполняться в различных контекстах и пересекать границы приложения и компьютера.

Предисловие

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

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

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

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

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

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

Command = class
public
  procedure Execute; virtual; abstract;
end;

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

Command = class
public
  procedure 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 = class
public
  procedure Execute(const Context: IContext); virtual; abstract;
  procedure Write(const Writer: IWriter); virtual; abstract;
  procedure Read(const Reader: IReader); virtual; abstract;
end;

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

IContext = interface
  procedure ExecuteCommand1(cmd: Command1);
  procedure ExecuteCommand2(cmd: Command2);
  ....
  procedure ExecuteCommandN(cmd: CommandN);
end;

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

procedure CommandXXX.Execute(const Context: IContext);
begin
  Context.ExecuteCommandXXX(self);
end;

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

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

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

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

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

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

Command = class
public
  constructor 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;
begin
  inherited Create;
  RefCount := 1;
end;

destructor Command.Destroy;
begin
  inherited Destroy;
end;

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

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

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

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

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

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

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

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

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

IWriter = interface
  procedure 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 = interface
  procedure Send(aCommand: Command; const aReceiver: string);
  function  Receive: Command;
end;

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

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

IReceiver = interface
  procedure 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
  // интерфейс IReceiver
  procedure Accept(aCommand: Command);

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

Диаграмма взаимодействий

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

Приложение: реализация фабрики классов на 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 <> nil then
  cmd := cls.Create
else
  raise Exception.Create(....); // команда не найдена

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

var
  CommandFactory: TCommandFactory;

procedure RegisterCommands(aCommands: array of TCommandClass);
var
  i: Integer;
begin
  if not Assigned(CommandFactory) then
    CommandFactory := TCommandFactory.Create;
  for i := Low(aCommands) to High(aCommands) do
    CommandFactory.RegisterCommand(aCommands[i]);
end;

Функция FindCommand вызывает соответствующий метод фабрики команд:

function FindCommand(const aName: ShortString): TCommandClass;
var
  slot: PSlot;
begin
  slot := CommandFactory.FindSlot(aName);
  if Assigned(slot) then
    result := slot^.FClass
  else
    result := nil; // класс с таким именем не зарегистрирован
end;

Для достижения высокой скорости поиска, фабрика команд реализована на основе хеш-таблицы. Полный код фабрики команд:

type
  PSlot = ^TSlot;              // тип указателя на слот
  TSlot = record               // слот данных команды
    FClass:     TCommandClass; // метакласс команды
    FCollision: PSlot;         // следующий слот при хеш-коллизии
  end;

  TCommandFactory = class
  public
    constructor Create;
    destructor  Destroy; override;

  private
    FSlots:     array of TSlot; // массив слотов команд
    FHashTable: array of PSlot; // хеш-таблица, ячейка указывает на слот
    FCount:     Integer;        // общее число команд

    function  HashCode(const aName: ShortString): Integer;
    function  CalcHashTableSize: Integer;
    procedure Grow;
    function  FindSlot(aHash: Integer; 
              const aName: ShortString): PSlot; overload;
    function  FindSlot(const aName: ShortString): PSlot; overload;

    procedure RegisterCommand(aCommand: TCommandClass);
  end;

constructor TCommandFactory.Create;
begin
  inherited Create;
  // стартовое число регистрируемых команд - 256
  SetLength(FSlots, 256);
  SetLength(FHashTable, CalcHashTableSize);
end;

destructor TCommandFactory.Destroy;
begin
  // уничтожаем массив слотов и хеш-таблицу
  FSlots     := nil;
  FHashTable := nil;
  inherited Destroy;
end;

// Используется ELF-алгоритм Вайнбергера
function TCommandFactory.HashCode(const aName: ShortString): Integer;
var
  i: Integer;
  g: LongInt;
begin
  result := 0;
  for i := 1 to Length(aName) do begin
    result := (result shl 4) + Ord(aName[i]);
    g      := result and $F0000000;
    if g <> 0 then
      result := (result xor (g shr 24)) xor g;
  end;
end;

// Для ускорения доступа и уменьшения числа коллизий поддерживаем
// достаточно разреженную хеш-таблицу. Число элементов таблицы
// делаем почти в два раза большим (а точнее, в 1.71 раз), чем
// число команд
function TCommandFactory.CalcHashTableSize: Integer;
begin
  result := (Length(FSlots) * 171) div 100;
end;

procedure TCommandFactory.Grow;
var
  i, hashIndex: Integer;
  slot:         PSlot;
begin
  // увеличиваем размер массива слотов и хеш-таблицу
  SetLength(FSlots, Length(FSlots) * 2);
  SetLength(FHashTable, CalcHashTableSize);
  // пересоздаем хеш-таблицу
  for i := Low(FHashTable) to High(FHashTable) do
    FHashTable[i] := nil;
  for i := 0 to Pred(FCount) do begin
    slot      := @FSlots[i];
    hashIndex := HashCode(slot^.FClass.ClassName) mod Length(FHashTable);
    slot^.FCollision      := FHashTable[hashIndex];
    FHashTable[hashIndex] := slot;
  end;
end;

function TCommandFactory.FindSlot(aHash: Integer; 
  const aName: ShortString): PSlot;
begin
  result := FHashTable[aHash mod Length(FHashTable)];
  while Assigned(result) do begin
    if result^.FClass.ClassName = aName then
      break
    else
      result := result^.FCollision;
  end;
end;

function TCommandFactory.FindSlot(const aName: ShortString): PSlot;
begin
  result := FindSlot(HashCode(aName), aName);
end;

procedure TCommandFactory.RegisterCommand(aCommand: TCommandClass);
var
  hash:      Integer;
  slot:      PSlot;
  hashIndex: Integer;
begin
  if FCount >= Length(FSlots) then
    Grow;
  hash := HashCode(aCommand.ClassName);
  slot := FindSlot(hash, aCommand.ClassName);
  if not Assigned(slot) then begin
    slot := @FSlots[FCount];
    Inc(FCount);
    // заполняем слот и добавляем его в хеш-таблицу
    hashIndex        := hash mod Length(FHashTable);
    slot^.FClass     := aCommand;
    slot^.FCollision := FHashTable[hashIndex];
    FHashTable[hashIndex] := slot;
  end;
  // else игнорируем повторное добавление класса,
  // хотя можно вывести диагностику
end;

initialization
  if not Assigned(CommandFactory) then
    CommandFactory := TCommandFactory.Create;

finalization
  CommandFactory.Free;

Статья написана для LinkRSDN

Download

Downloaddelphiclasregister.zip - Исходные коды фабрики классов (3K).