11 января 2012 г.

Разработка системы плагинов, часть 3: активные плагины

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

Оглавление

  1. Что хотим
  2. Меню
  3. Редактор

Что хотим

Здесь и далее я лишь кратко буду озвучивать идею метода - для экономии места и времени, но при этом я предполагаю, что в вашей реальной программе вы будете прорабатывать всё это полностью - как мы делали это в прошлый раз, включая предварительное проектирование, формальное определение контракта и документацию.

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

Пока у нас нет ничего, что плагин мог бы "дёргать", поэтому сейчас никакой полезной работы он (плагин) выполнить не сможет. По этой причине я предлагаю также ввести в программу возможность манипулирования текстовым редактором. Тогда мы могли бы написать плагин, который, скажем, добавлял бы в меню такие пункты: "Преобразовать разделители строк в <br/>", "Обрамить жирный текст в <b></b>" или "Изменить регистр всех символов на ВЕРХНИЙ". В общем, много чего придумать можно.

От меню требуется дать возможность плагинам добавлять туда свои пункты меню, а также модифицировать их (например, отключать или менять заголовок) и реагировать на события (например, щелчки мышью). В принципе, это можно сделать, как и ранее - пусть плагин реализует гипотетический интерфейс IPluginMenuItem. Тогда ядро может это увидеть и создать для плагина пункт меню при его загрузке. Это будет повторение уже известного "пассивного" подхода. Но если в прошлом примере с экспортом/импортом этот подход работал отлично, то здесь уже он не достаточно гибок - ведь плагину может потребоваться более одного пункта меню.

Поэтому я предлагаю использовать другой подход, с обратным вызовом (callback). Вспомните, что при загрузке плагина ядро передаёт ему "себя" - ICore. Плагин волен делать с этой ссылкой всё, что ему угодно, в любой момент времени. Понятно, что сейчас там есть всего один метод - получение версии ядра, но никто же не запрещает нам ввести туда метод вида "а ну-ка, создай для меня новый пункт меню!". Тогда плагин в любой момент времени (но, конечно же, чаще всего - при загрузке) может вызвать этот метод ядра (и даже несколько раз подряд) и создать себе сколько угодно пунктов меню. Это и есть активный подход: уже не ядро командует плагином, а сам плагин дёргает ядро.

С редактором текста программы имеет место быть полностью аналогичная ситуация. Ядро предоставляет плагинами интерфейс для манипуляции редактором текста, так что плагины могут выполнять произвольные операции с текстом.

Управление меню

Итак, нам понадобится, во-первых, интерфейс для создания и удаления пунктов меню, методы которого будет вызывать плагин. Выглядит он очень просто:
type
  IMenuManager = interface
  ['{216082F8-8FE8-4B51-83E5-C8324452AD18}']
    function CreateMenuItem: IMenuItem; safecall;
    procedure DeleteMenuItem(var AItem: IMenuItem); safecall;
  end;
Я поясню выбор var для параметра AItem в методе DeleteMenuItem чуть позднее.

Далее, нам нужен интерфейс для самого пункта меню. Предлагаю такой вариант:
type
  IMenuItem = interface
  ['{B1A0A830-532B-4573-AE19-33EAA4D76096}']
  // private
    function GetCaption: WideString; safecall;
    function GetChecked: BOOL; safecall;
    function GetEnabled: BOOL; safecall;
    function GetHint: WideString; safecall;
    procedure SetCaption(const AValue: WideString); safecall;
    procedure SetChecked(const AValue: BOOL); safecall;
    procedure SetEnabled(const AValue: BOOL); safecall;
    procedure SetHint(const AValue: WideString); safecall;
  // public
    property Caption: WideString read GetCaption write SetCaption;
    property Checked: BOOL read GetChecked write SetChecked;
    property Enabled: BOOL read GetEnabled write SetEnabled;
    property Hint: WideString read GetHint write SetHint;
    procedure RegisterExecuteHandler(const AEventHandler: INotifyEvent); safecall;
  end;
Этот интерфейс позволит нам изменять основные свойства пункта меню. Вы можете добавить что-то ещё или что-то убрать - это ваш выбор. Метод RegisterExecuteHandler служит как замена события OnClick. Я подробнее продемонстрирую его работу позже. INotifyEvent же объявлен так:
type
  INotifyEvent = interface
  ['{80B8A93C-B69B-49BA-BD7C-749629C9D97B}']
    procedure Execute(Sender: IInterface); safecall;
  end;
Это что касается заголовочников. Теперь по реализации.

Давайте начнём с конца. Кто должен реализовывать IMenuItem? Ну, это точно не менеджер плагинов - ведь он один, а пунктов меню много. И это точно не обёртка плагина в менеджере плагинов - их хоть и много, но у нас есть всего одна обёртка для плагина, в то время как один плагин может создать несколько пунктов меню.

Наиболее разумно будет выбрать сам элемент меню для реализации IMenuItem. Делается это очень просто:
type
  TPluginMenuItem = class(TMenuItem, IUnknown, IMenuItem)
  private
    FClickHandler: INotifyEvent;
  protected
    // IMenuItem
    function GetCaption: WideString; safecall;
    function GetChecked: BOOL; safecall;
    function GetEnabled: BOOL; safecall;
    function GetHint: WideString; safecall;
    procedure SetCaption(const AValue: WideString); safecall;
    procedure SetChecked(const AValue: BOOL); safecall;
    procedure SetEnabled(const AValue: BOOL); safecall;
    procedure SetHint(const AValue: WideString); safecall;
    procedure RegisterExecuteHandler(const AEventHandler: INotifyEvent); safecall;
  public
    procedure Click; override;
  end;

{ TPluginMenuItem }

procedure TPluginMenuItem.Click;
begin
  inherited; // вызываем стандартную обработку

  // Вызываем плагин
  if Enabled and Assigned(FClickHandler) then
    FClickHandler.Execute(Self);
end;

function TPluginMenuItem.GetCaption: WideString;
begin
  Result := Caption;
end;

function TPluginMenuItem.GetChecked: BOOL;
begin
  Result := Checked;
end;

function TPluginMenuItem.GetEnabled: BOOL;
begin
  Result := Enabled;
end;

function TPluginMenuItem.GetHint: WideString;
begin
  Result := Hint;
end;

procedure TPluginMenuItem.SetCaption(const AValue: WideString);
begin
  Caption := AValue;
end;

procedure TPluginMenuItem.SetChecked(const AValue: BOOL);
begin
  Checked := AValue;
end;

procedure TPluginMenuItem.SetEnabled(const AValue: BOOL);
begin
  Enabled := AValue;
end;

procedure TPluginMenuItem.SetHint(const AValue: WideString);
begin
  Hint := AValue;
end;

procedure TPluginMenuItem.RegisterExecuteHandler(const AEventHandler: INotifyEvent);
begin
  FClickHandler := AEventHandler;
end;
Как видите, этот код тривиален - простой переходник TMenuItem <-> IMenuItem.

Далее, эти самые элементы меню кто-то должен создавать. Понятно, что делаться это будет по указке плагина, но внутри программы эти пункты меню должен создавать код программы. Кто это будет? В принципе, вот тут уже этим мог бы заниматься менеджер программы. Но тут есть два "но":
  1. Мы делаем код менеджер плагинов зависимым от нашей программы. Я уже сделал такую "плохую вещь" в прошлый раз - введением свойства FilterIndex. Тогда я пояснил, что это - "плохо", но выбрал этот путь как самый простой. Здесь ситуация аналогична - можно сделать быстро и грязно, а можно... сейчас посмотрим как.
  2. Недостаток два - это то, что менеджер плагинов будет заниматься не своими обязанностями. Какова задача менеджера плагинов? Служить менеджером плагинов, конечно же! Управление меню - это не обслуживание плагинов. Это - обслуживание интерфейса пользователя. А какой класс занимается обслуживанием UI? Конечно же, форма!
Итак, как вы уже поняли - подходящим кандидатом для реализации IMenuManager будет форма (в моём примере - TMainForm):
type
  TMainForm = class(TForm, IMenuManager)
  ...
  protected
    // IMenuManager
    function CreateMenuItem: IMenuItem; safecall;
    procedure DeleteMenuItem(var AItem: IMenuItem); safecall;
  end;

...

implementation

...

function TMainForm.CreateMenuItem: IMenuItem;
var
  MI: TPluginMenuItem;
begin
  MI := TPluginMenuItem.Create(miPlugins);
  Result := MI;
  miPlugins.Add(MI);

  miPlugins.Visible := True;
end;

procedure TMainForm.DeleteMenuItem(var AItem: IMenuItem);

  function GetComponent(var AItem: IMenuItem): TPluginMenuItem;
  var
    Internal: IInterfaceComponentReference;
  begin
    if not Supports(AItem, IInterfaceComponentReference, Internal) then
      Assert(False);
    Result := Internal.GetComponent as TPluginMenuItem;
    Internal := nil;
    AItem := nil;
  end;

begin
  GetComponent(AItem).Free;

  miPlugins.Visible := (miPlugins.Count > 0);
end;

...

end.
Вот тут начинается самое интересное. Видите ли, элемент меню - это компонент (наследник TComponent). Это означает, что он удаляется явно - вызовом деструктора, а вовсе не тогда, когда исчезнет последняя ссылка на него. С другой стороны, использование интерфейсов диктует другую идеологию. Это классическая проблема стыкования двух различных механизмов управления временем жизни: ручного и автоматического на базе подсчёта ссылок. Для этой проблемы есть несколько решений.

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

Метод номер два заключается в введении метода явного удаления - аналога деструктора в интерфейс. Скажем, Delete. Когда этот метод вызывается - пункт меню надо удалить (даже если ссылки на интерфейс ещё не обнулены). После вызова метода пункт меню будет удалён, а интерфейс останется жить, но любое обращение к его методам вызовет ошибку "нет пункта меню". Так что единственное, что с этим интерфейсом можно будет сделать - освободить (уменьшить число ссылок). Этот способ довольно привлекателен, но имеет и минусы: вам нужно два объекта. Один - это пункт меню. Этот объект будет удаляться при вызове метода Delete. Второй объект - это реализатор интерфейса. Он удаляется при уменьшении счётчика ссылок до нуля. Это не может быть тот же самый объект, потому что первый объект нужно удалять по вызову Delete - когда счётчик ссылок ещё не равен нулю.

Я предлагаю компромиссный способ 3, при котором нам понадобится один объект (TPluginMenuItem), но при этом для плагина остаётся возможность "забыть" ссылку без удаления пункта меню. Способ заключается в том, что мы вводим функцию удаления пункта меню в менеджер меню. Функция принимает ссылку на пункт меню, который нужно удалить. При этом ставится условие, что это должна быть последняя ссылка на пункт меню. Тогда: если плагину не нужен пункт меню после его создания - он просто забудет ссылку на него и ничего не произойдёт, пункт меню удалён не будет. Если же плагину в итоге нужно будет удалить пункт меню, он сохраняет ссылку на него, а для удаления он сначала удаляет все свои дополнительные ссылки на пункт меню (если он их создавал), после чего передаёт оставшуюся (последнюю) ссылку в функцию удаления. Функция удаления удаляет пункт меню и ссылку одновременно - таким образом, два механизма управления временем жизни остаются согласованными.

Конечно же, вы можете использовать другой метод. Я подчеркну, что я описываю какой-то способ реализации плагинов. Он не является единственным правильным. Тут можно делать как угодно. Вы проектируете API. Это означает, что будет так, как скажете вы. Это означает, что тут всё зависит от ваших потребностей и фантазии.

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

В рамках же указанного подхода остаётся лишь сделать замечание по реализации этой самой функции удаления пункта меню (DeleteMenuItem). Во-первых, вы уже поняли, почему у её параметра стоит модификатор var, а не что-то другое: чтобы можно было обнулить ссылку. Далее, поскольку мы удаляем объект TMenuItem (с ручным управлением временем жизни), то мы не можем просто обнулить ссылку - это не удалит объект. Нам нужно получить сам объект и вызвать его деструктор (кстати, ссылку надо обнулить до вызова деструктора). Окей, как можно получить ссылку на реализующий объект по интерфейсу? Есть аж четыре способа:
  1. Перебор: перебрать все пункты меню и найти с подходящим интерфейсом.
  2. Грязный хак.
  3. Дополнительный служебный интерфейс, возвращающий Self.
  4. Новые возможности интерфейсов в Delphi 2010 и выше.
Перебор - это долго, хак - это грязно, Delphi 2010 используют не все (к сожалению), поэтому выход - номер 3.

Поскольку я делал этот пример в Delphi XE, то у меня уже есть весь готовый код для этого. Если же вы используете более старую версию Delphi, то вам может понадобится изменить код так:
type
  IInterfaceComponentReference = interface
  ['{41D23E9C-0974-4ED4-BBE8-4375D65E1129}']
    function GetComponent: TObject;
  end;

...

type
  TPluginMenuItem = class(TMenuItem, IUnknown, IInterfaceObjectReference, IMenuItem)
  ...
  protected
    function GetComponent: TObject;
    ... 
  public
    ...
  end;

function TPluginMenuItem.GetComponent: TObject;
begin
  Result := Self;
end;
Код достаточно очевиден: мы ввели скрытый интерфейс для служебных целей. Интерфейс просто выставляет нам Self - что и будет объектом, который реализует интерфейс.

Осталось пояснить, почему я выделил подпрограмму GetComponent в DeleteMenuItem. Вы могли бы подумать, что я сделал это с точки зрения хорошего стиля, но это лишь часть правды. Дело тут в том, что мы должны освободить все ссылки на объект до его удаления. С AItem вопросов нет - это последняя ссылка, нам это должен гарантировать плагин. Но мы работаем с интерфейсами в своём коде. И проблема тут в том, что при этом компилятор может создавать скрытые интерфейсные ссылки, которые освобождаются в момент выхода их из области видимости - т.е. в строчке "end;" этой процедуры. А это - после вызова деструктора. К этой проблеме есть два решения. Первое: просто просмотреть код и убедиться, что скрытых ссылок нет. Дело это не всегда тривиальное и тут можно легко ошибиться - особенно при дальнейшей модификации и поддержке кода. Поэтому я предлагаю более простое решение - вынести весь код по работе с интерфейсами в отдельную подпрограмму, вызываемую до удаления пункта меню. Таким образом, если скрытые ссылки и будут созданы - они будут удалены в момент завершения этой вложенной подпрограммы, что случится до удаления пункта меню.

Так, что касается согласования двух моделей управления временем жизни - с этим всё. Замечу только, что у нас есть ещё форма, которая тоже, вроде бы, управляется вручную, но для неё указанная проблема не возникает - это потому, что форма живёт дольше, чем плагины (я добавил выгрузку плагинов в OnDestroy формы - нет смысла делать иначе, поскольку плагины в нашей программе работают только с UI). Это означает, что время жизни формы больше времени жизни плагинов, они не пересекаются. Т.е. последняя ссылка на форму (менеджер меню) будет удалена до удаления формы - таким образом, никаких проблем, требующих решения, не возникает.

Итак, теперь у нас есть готовый менеджер меню, который позволяет плагинам создавать пункты меню, менять их и реагировать на щелчки в них. Остаётся небольшой вопрос: как плагин получит доступ к менеджеру меню? Действительно: плагин инициализируется передачей ему менеджера плагинов, но никакой ссылки на менеджер меню там нет. Вот эту проблему сейчас нам и нужно решить. Делается это так (файл PluginManager.pas):
type
  IPluginManager = interface
    ...
    procedure RegisterServiceProvider(const AProvider: IUnknown);
  end;

...

implementation

...

type
  TPluginManager = class(TInterfacedObject, IUnknown, IPluginManager, ICore)
  private
    ...
    FProviders: TInterfaceList;
  protected
    ...
    procedure RegisterServiceProvider(const AProvider: IUnknown);
    function QueryInterface(const IID: TGUID; out Obj): HResult; stdcall;
  end;

...

constructor TPluginManager.Create;
begin
  ...
  FProviders := TInterfaceList.Create;
  ...
end;

destructor TPluginManager.Destroy;
begin
  FreeAndNil(FProviders);
  ...
end;

procedure TPluginManager.UnloadAll;
begin
  Finalize(FItems);
  FProviders.Clear;
end;

procedure TPluginManager.RegisterServiceProvider(const AProvider: IUnknown);
begin
  FProviders.Add(AProvider);
end;

function TPluginManager.QueryInterface(const IID: TGUID; out Obj): HResult;
var
  X: Integer;
begin
  Result := inherited QueryInterface(IID, Obj);
  if Failed(Result) then
  begin
    for X := 0 to FProviders.Count - 1 do
    begin
      Result := FProviders[X].QueryInterface(IID, Obj);
      if Succeeded(Result) then
        Break;
    end;
  end;
end;

...

end.
И файл remain.pas:
procedure TMainForm.FormCreate(Sender: TObject);
begin
  SetErrorMode(SetErrorMode(0) or SEM_NOOPENFILEERRORBOX or SEM_FAILCRITICALERRORS);
  Plugins.SetVersion(1);
  Plugins.RegisterServiceProvider(Self); // <- добавлено
  Plugins.LoadPlugins(ExtractFilePath(ParamStr(0)) + 'Plugins', SPluginExt);
  BuildFilterList;

  ...
end;
Если вы посмотрите внимательнее, то увидите, что весь трюк здесь заключается в подмене метода QueryInterface: мы модифицируем его так, чтобы он возвращал не только интерфейсы самого объекта, но и ещё "чьи-то". Если вы помните, то эту технику мы уже использовали: в прошлый раз - подмена QueryInterface в классе TPlugin. Только в последнем примере мы ищем дополнительные интерфейсы не у кого-то конкретного, а в целом списке, позволяя при этом стороннему коду (стороннему - по отношению к менеджеру плагинов) производить регистрацию себя в качестве "поставщика интерфейсов". В итоге у нас получается так, что плагин может запрашивать у менеджера плагинов (ядра) что угодно, но сам менеджер плагинов понятия не имеет, что же спрашивает у него плагин - зато про это имеет понятие программа, которая и регистрирует в менеджере плагинов интерфейсы для раздачи их плагинам.

С основной программой мы на этом закончили. Давайте кратко просуммируем сделанное: мы реализовали менеджер меню, научились согласовывать две модели управления временем жизни и реализовали "правильный" подход к менеджеру плагинов (регистрация функциональности ядра).

Теперь давайте посмотрим на реализацию плагина. Поскольку редактор у нас пока не готов для общения с плагинами, то я пока что покажу простой пример плагина, который работает только с меню: пусть он создаст несколько пунктов меню и покажет, как он умеет с ними обращаться:
library SimpleMenu;

uses
  Windows,
  SysUtils,
  Classes,
  ActiveX,
  AxCtrls,
  PluginAPI in 'PluginAPI\Headers\PluginAPI.pas';

{$R *.res}
{$E .rep}

type
  TPluginMenuItem = class(TObject, IUnknown, INotifyEvent)
  private
    FManager: IMenuManager;
    FItem: IMenuItem;
    FClick: TNotifyEvent;
  protected
    // IUnknown
    function QueryInterface(const IID: TGUID; out Obj): HResult; virtual; stdcall;
    function _AddRef: Integer; stdcall;
    function _Release: Integer; stdcall;
    // INotifyEvent
    procedure Execute(Sender: IInterface); safecall;
  public
    constructor Create(const ACore: ICore);
    destructor Destroy; override;
    property Item: IMenuItem read FItem;

    procedure Click; virtual;
    property OnClick: TNotifyEvent read FClick write FClick;
  end;

  TPlugin = class(TInterfacedObject, IUnknown)
  private
    FCore: ICore;
    FMenuItem1: TPluginMenuItem;
    FMenuItem2: TPluginMenuItem;
    FMenuItem3: TPluginMenuItem;
    procedure MenuItem1Click(Sender: TObject);
    procedure MenuItem2Click(Sender: TObject);
    procedure MenuItem3Click(Sender: TObject);
  public
    constructor Create(const ACore: ICore);
    destructor Destroy; override;
  end;

{ TPlugin }

constructor TPlugin.Create(const ACore: ICore);
begin
  inherited Create;
  FCore := ACore;
  Assert(FCore.Version >= 1);

  FMenuItem1 := TPluginMenuItem.Create(FCore);
  FMenuItem2 := TPluginMenuItem.Create(FCore);
  FMenuItem3 := TPluginMenuItem.Create(FCore);

  FMenuItem1.Item.Caption := 'SimpleMenu: Отмечается галочкой';
  FMenuItem1.Item.Hint := 'Элемент №1 от плагина SimpleMenu';
  FMenuItem1.Item.Checked := True;
  FMenuItem1.OnClick := MenuItem1Click;

  FMenuItem2.Item.Caption := 'SimpleMenu: Показ сообщения';
  FMenuItem2.Item.Hint := 'Элемент №2 от плагина SimpleMenu';
  FMenuItem2.OnClick := MenuItem2Click;

  FMenuItem3.Item.Caption := 'SimpleMenu: Убрать все мои пункты меню';
  FMenuItem3.Item.Hint := 'Элемент №3 от плагина SimpleMenu';
  FMenuItem3.OnClick := MenuItem3Click;
end;

destructor TPlugin.Destroy;
begin
  FreeAndNil(FMenuItem3);
  FreeAndNil(FMenuItem2);
  FreeAndNil(FMenuItem1);
  inherited;
end;

procedure TPlugin.MenuItem1Click(Sender: TObject);
begin
  FMenuItem1.Item.Checked := not FMenuItem1.Item.Checked;
end;

procedure TPlugin.MenuItem2Click(Sender: TObject);
begin
  MessageBox(0, 'Привет от плагина SimpleMenu!', 'SimpleMenu', MB_OK or MB_ICONINFORMATION);
end;

procedure TPlugin.MenuItem3Click(Sender: TObject);
begin
  FreeAndNil(FMenuItem1);
  FreeAndNil(FMenuItem2);
  FMenuItem3.Item.Enabled := False;
end;

{ TPluginMenuItem }

constructor TPluginMenuItem.Create(const ACore: ICore);
begin
  inherited Create;
  if not Supports(ACore, IMenuManager, FManager) then
    Assert(False);

  FItem := FManager.CreateMenuItem;

  FItem.Caption := 'PluginMenuItem';
  FItem.Hint := '';
  FItem.Enabled := True;
  FItem.Checked := False;
  FItem.RegisterExecuteHandler(Self);
end;

destructor TPluginMenuItem.Destroy;
begin
  FManager.DeleteMenuItem(FItem);
  FManager := nil;
  inherited;
end;

procedure TPluginMenuItem.Execute(Sender: IInterface);
begin
  Click;
end;

procedure TPluginMenuItem.Click;
begin
  if FItem.Enabled then
  begin
    if Assigned(FClick) then
      FClick(Self);
  end;
end;

function TPluginMenuItem.QueryInterface(const IID: TGUID; out Obj): HResult;
begin
  if GetInterface(IID, Obj) then
    Result := S_OK
  else
    Result := E_NOINTERFACE;
end;

function TPluginMenuItem._AddRef: Integer;
begin
  Result := -1;   // -1 indicates no reference counting is taking place
end;

function TPluginMenuItem._Release: Integer;
begin
  Result := -1;   // -1 indicates no reference counting is taking place
end;

// _________________________________________________________________

function Init(const ACore: ICore): IUnknown; safecall;
begin
  Result := TPlugin.Create(ACore);
end;

procedure Done; safecall;
begin

end;

exports
  Init name SPluginInitFuncName,
  Done name SPluginDoneFuncName;

end.
Давайте начнём разбор кода. Во-первых, мы ввели вспомогательный класс TPluginMenuItem. Зачем он нужен? Дело в том, что мы хотим создать 3 пункта меню. При этом нам, конечно же, хотелось бы реагировать на щелчки по всем этим пунктам меню - т.е. нам нужно три обработчика событий OnClick. За это событие у нас отвечает интерфейс INotifyEvent с (единственным) методом Execute. С другой стороны, TPlugin - он у нас один, и он не может "реализовывать трижды" один и тот же интерфейс. Поэтому нам надо три объекта. Три TPlugin мы сделать не можем (плагин - он же один), поэтому мы и ввели TPluginMenuItem - это позволяет нам иметь несколько объектов, по одному на каждый пункт меню. Вспомогательный объект берёт на себя реализацию интерфейса, выставляя нам наружу удобный OnClick - прямо как в Delphi. Ну и, конечно же, объект предоставляет методы по управлению пунктом меню. Замечу, что тут я поленился и сделал просто property Item: IMenuItem вместо копирования функциональности - что сэкономило мне код, но привело к не слишком удобному обращению вида FMenuItem2.Item.Caption. И снова я повторюсь, что вы можете делать как угодно - на свой вкус.

Когда у нас есть вспомогательный объект (код которого тривиален), мы уже легко можем создавать пункты меню - работа с ними происходит в точности так же, как вы бы работали с пунктами меню в приложении Delphi без плагинов. Вы создали пункт меню, настроили его и назначили обработчик.

Что ещё хотелось бы тут отметить: возьмите на заметку то, в каком состоянии создаётся пункт меню. Например, такой вопрос: какой заголовок имеет пункт меню после вызова CreateMenuItem? Это - вопрос для фиксации в документации (это я вроде как напоминаю). Вы должны явно это указать. Конечно, вы можете сказать: "состояние пункта меню после создания не определено, вы должны явно инициализировать все его свойства" - что снимет с вас (как разработчика программы) всякую ответственность, но также приведёт к огромному дублированию кода - ведь тогда каждому плагину при создании каждого пункта меню придётся делать, к примеру, Enabled := True (ведь вы же чёрным по белому сказали "после создания - может быть любым"). Поэтому лучше бы, конечно, всё же явно установить состояние и придерживаться его в дальнейшем.

Второй момент - в этом примере я оставил предыдущие наработки. В частности, в программе есть плагины экспорта и импорта, и я приложил старые плагины для работы с RTF. Это показывает широкие возможности системы плагинов - работу с разнотипными плагинами. Кстати, обратите внимание, что у нас, по сути, вообще нет такого понятия "тип плагина". У нас всё упирается в то, какие интерфейсы поддерживает плагин. К примеру, один и тот же плагин теоретически может быть и плагином импорта и плагином экспорта (если только вы явно не запретите такую комбинацию в коде или в документации, как я указывал это в прошлый раз) и при этом же создавать пункты меню (например, для вызова диалога настроек импорта).

Скачать пример.

Управление редактором

Когда мы реализовали меню и подробно обсудили все моменты, у вас не должно возникнуть никаких сложностей с редактором меню. Как позволить плагинам работать с редактором? Реализовать интерфейс, конечно же! Кто должен реализовывать интерфейс? Ну, либо форма, либо сам редактор. А как плагины увидят этот интерфейс? А мы его зарегистрируем в менеджере плагинов.

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

Как будет выглядеть интерфейс? Например, так:
type
  TEditorSearchTypes = DWORD;

const
  esfWholeWord = 0;
  esfMatchCase = 1;

type
  IEditor = interface
  ['{F82E46C3-A744-4137-B9A0-242E50CC0041}']
  // private
    function GetText: WideString; safecall;
    procedure SetText(const AValue: WideString); safecall;
    function GetSelText: WideString; safecall;
    procedure SetSelText(const AValue: WideString); safecall;
    function GetSelStart: Integer; safecall;
    procedure SetSelStart(const AValue: Integer); safecall;
    function GetSelLength: Integer; safecall;
    procedure SetSelLength(const AValue: Integer); safecall;
    function GetModified: BOOL; safecall;
    function GetCanUndo: BOOL; safecall;
    function GetCaretPos: TPoint; safecall;
    procedure SetCaretPos(const AValue: TPoint); safecall;

  // public
    property Text: WideString read GetText write SetText;
    property SelText: WideString read GetSelText write SetSelText;
    property SelStart: Integer read GetSelStart write SetSelStart;
    property SelLength: Integer read GetSelLength write SetSelLength;
    procedure SelectAll; safecall;
    property Modified: BOOL read GetModified;
    property CanUndo: BOOL read GetCanUndo;
    procedure Undo; safecall;
    procedure ClearUndo; safecall;
    procedure Clear; safecall;
    procedure ClearSelection; safecall;
    procedure CopyToClipboard; safecall;
    procedure CutToClipboard; safecall;
    procedure PasteFromClipboard; safecall;
    property CaretPos: TPoint read GetCaretPos write SetCaretPos;
    function FindText(const SearchStr: WideString; const StartPos, Length: Integer; Options: TEditorSearchTypes): Integer; safecall;
  end;
(обсуждение TEditorSearchTypes мне хотелось бы оставить на другой раз)

Как будет выглядеть реализация? Например, так:
type
  TMainForm = class(TForm, IEditor, IMenuManager)
  ...
  protected
    ...
    // IEditor
    function GetText: WideString; safecall;
    procedure SetText(const AValue: WideString); safecall;
    function GetSelText: WideString; safecall;
    procedure SetSelText(const AValue: WideString); safecall;
    function GetSelStart: Integer; safecall;
    procedure SetSelStart(const AValue: Integer); safecall;
    function GetSelLength: Integer; safecall;
    procedure SetSelLength(const AValue: Integer); safecall;
    function GetModified: BOOL; safecall;
    function GetCanUndo: BOOL; safecall;
    function GetCaretPos: TPoint; safecall;
    procedure SetCaretPos(const AValue: TPoint); safecall;
    procedure SelectAll; safecall;
    procedure Undo; safecall;
    procedure ClearUndo; safecall;
    procedure Clear; safecall;
    procedure ClearSelection; safecall;
    procedure CopyToClipboard; safecall;
    procedure CutToClipboard; safecall;
    procedure PasteFromClipboard; safecall;
    function FindText(const SearchStr: WideString; const StartPos, Length: Integer; Options: TEditorSearchTypes): Integer; safecall;
  end;

...

implementation

...

function TMainForm.GetText: WideString;
begin
  Result := Editor.Text;
end;

procedure TMainForm.SetText(const AValue: WideString);
begin
  Editor.Text := AValue;
end;

// Позвольте мне не приводить остальные методы - они однотипны

...

// Единственное исключение:

function TMainForm.FindText(const SearchStr: WideString; const StartPos, Length: Integer; Options: TEditorSearchTypes): Integer;
var
  Opts: TSearchTypes;
begin
  Opts := [];
  if (Options and esfWholeWord) <> 0 then
    Include(Opts, stWholeWord);
  if (Options and esfMatchCase) <> 0 then
    Include(Opts, stMatchCase);
  Result := Editor.FindText(SearchStr, StartPos, Length, Opts);
end;

...

end.
Как будет выглядеть регистрация интерфейса в менеджере плагинов? А никак - она у нас уже есть:
procedure TMainForm.FormCreate(Sender: TObject);
begin
  SetErrorMode(SetErrorMode(0) or SEM_NOOPENFILEERRORBOX or SEM_FAILCRITICALERRORS);
  Plugins.SetVersion(1);
  Plugins.RegisterServiceProvider(Self); // <- тыц-тыц
  Plugins.LoadPlugins(ExtractFilePath(ParamStr(0)) + 'Plugins', SPluginExt);
  BuildFilterList;

  ...
end;
Обратите внимание, что мы регистрируем не конкретный интерфейс, а объект - поставщик интерфейсов. Т.е. единожды зарегистрировав форму, мы отдаём плагинам все её интерфейсы. Т.е. теперь нам достаточно просто реализовывать в форме интерфейсы - и они автоматически будут доступны плагинами.

Теперь сам плагин:
library HTMLConvert;

uses
  Windows,
  SysUtils,
  Classes,
  ActiveX,
  AxCtrls,
  PluginAPI in 'PluginAPI\Headers\PluginAPI.pas';

{$R *.res}
{$E .rep}

type
  TPlugin = class(TInterfacedObject, IUnknown, IDestroyNotify, INotifyEvent)
  private
    FCore: ICore;
    FMenuItem: IMenuItem;
    procedure Delete; safecall;
    procedure Execute(Sender: IInterface); safecall;
  public
    constructor Create(const ACore: ICore);
    destructor Destroy; override;
  end;

{ TPlugin }

constructor TPlugin.Create(const ACore: ICore);
var
  Manager: IMenuManager;
begin
  inherited Create;
  FCore := ACore;
  Assert(FCore.Version >= 1);

  if Supports(FCore, IMenuManager, Manager) then
  begin
    FMenuItem := Manager.CreateMenuItem;
    FMenuItem.Caption := 'Вставить дату';
    FMenuItem.Hint := 'Вставляет в документ текущую дату и время в местном формате';
    FMenuItem.Enabled := True;
    FMenuItem.Checked := False;
    FMenuItem.RegisterExecuteHandler(Self);
  end;
end;

procedure TPlugin.Delete;
var
  Manager: IMenuManager;
begin
  if Assigned(FMenuItem) and
     Supports(FCore, IMenuManager, Manager) then
    Manager.DeleteMenuItem(FMenuItem);
end;

destructor TPlugin.Destroy;
begin
  Delete;
  inherited;
end;

procedure TPlugin.Execute(Sender: IInterface);
var
  Editor: IEditor;
begin
  if not Supports(FCore, IEditor, Editor) then
    Exit;

  Editor.SelText := DateTimeToStr(Now);
end;

// _________________________________________________________________

function Init(const ACore: ICore): IUnknown; safecall;
begin
  Result := TPlugin.Create(ACore);
end;

procedure Done; safecall;
begin

end;

exports
  Init name SPluginInitFuncName,
  Done name SPluginDoneFuncName;

end.
Этот пример ещё проще предыдущего: у нас всего один пункт меню, а значит нам не нужен вспомогательный объект. Реакцию на щелчок может взять на себя сам TPlugin.

Единственный интересный момент тут - новый интерфейс IDestroyNotify:
type
  IDestroyNotify = interface
  ['{B50C8ABE-A10A-4BD9-AA17-0311326FE1A6}']
    procedure Delete; safecall;
  end;
Я думаю, вы узнали его - я говорил про этот метод выше, хотя и выбрал в тот раз другой способ. Как я говорил, этот способ используется для разрешения проблемы циклических ссылок.

Упражнение: где в этом примере циклическая ссылка? Зачем понадобился интерфейс IDestroyNotify в этом примере? И почему мы обошлись без него в предыдущем плагине? В чём отличие двух ситуаций?

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

Сам же интерфейс описывается тривиально. Хочу лишь напомнить про установленные нами в первой части правила. При создании интерфейсов нужно не забывать добавлять safecall, нужно не забывать изменять типы данных (String на WideString, Boolean на BOOL и т.п.) - т.е. никаких Delphi-типов у вас быть не должно.

И в заключение я хотел бы обсудить ещё вот какой вопрос. В последнем примере я создал интерфейс IEditor, где ввёл всего несколько методов для управления редактором. Это означает, что возможности плагинов ограничиваются возможностями интерфейса, которого вы им выдаёте. Т.е. если вы хотите иметь мощные плагины - вы должны дать им много интерфейсов с кучей методов, которые выставят наружу внутренности вашей программы. Чем больше вы сделаете интерфейсов, тем мощнее будут плагины. В примере я схалявил, реализовав всего несколько методов для редактора. В частности, я не создал ни одного метода для управления атрибутами текста (жирный, курсив и т.п.).

Плюсы этого подхода: вы явно контролируете то, что будет доступно плагинам. Кроме того, вид для плагинов не обязан отражать реальную структуру вашей программы (к примеру, в нашем примере менеджер меню и редактор реализовывались формой, хотя по факту это TMainMenu и TRichEdit - разные объекты). Минусы: вам нужно вручную писать код-переходник. И чем мощнее вы хотите сделать возможности плагинов, тем больше кода вам придётся писать.

И если ваша программа обладает очень сложным пользовательским интерфейсом, а вы хотите дать плагинам полный доступ к нему, плюс к тому же работа с пользовательским интерфейсом программы будет основной задачей для плагинов - то полноценные плагины будут не самой удачной архитектурой. Я говорил про это во вступлении к первой части. Более удачным выбором будут скрипты.

А если вы всё же используете плагины в виде библиотек, а не скрипты, то помимо указанного способа (проектирование интерфейсов и написание кода-обёртки) есть ещё один способ: можно дать плагинам прямой доступ к какому-то элементу программы. К примеру, для нашего редактора это может быть либо описатель (свойство Handle), либо уже готовый системный интерфейс IRichEditOle. Вы можете передать плагину одну из этих вещей, а плагин сможет работать с ней, используя системный API. Разумеется, вы не можете передавать плагину сам объект TRichEdit, потому что это - класс языка Delphi, он не "кросс-языковен" (чёрт, как тут иначе сказать?).

Плюсы такого подхода: ничего не надо писать, просто передайте описатель. Минусы: вы не сможете контролировать плагин. К примеру, плагин сможет просто скрыть редактор или вовсе его удалить. Потому что у него есть описатель редактора, а значит - полный и неконтролируемый доступ. С другой стороны, с подходом из примера выше плагин не сможет сделать это (скрытие или удаление редактора) - потому что мы не дали ему методов. Второй минус - сильная зависимость от реализации программы. К примеру, вот выставили мы плагинам наш редактор, который сейчас у нас - RichEdit. А в следующей версии программы мы поняли, что нам мало RichEdit-а. Поэтому мы заменили его на custom-контрол с собственной прорисовкой. Если мы используем свои интерфейсы - нет проблем, плагины даже не заметят подмены. А если мы передаём описатель редактора плагинам, то... опс, (старый) плагин ожидает увидеть RichEdit, а получает что-то непонятное.

Скачать пример.

На следующий раз у нас остаётся взаимодействие плагинов друг с другом, пользовательский интерфейс в плагинах, а также обработка ошибок и отладка.

15 комментариев :

  1. А будет ли рассмотрен вопрос, о передаче событий программы в плагин? Допустим, где-то, что-то крякнуло и плагин автоматом на это реагирует...

    Очень интересует этот вопрос, в плане поддержки c++

    ОтветитьУдалить
  2. так же интересен вопрос, когда несколько плагинов реагируют на одно и тоже событие ядра(вып. разные действия и без вызова ошибок).

    ОтветитьУдалить
  3. Здравствуйте Александр.

    Когда будет 4яа статья этой серии?

    PS
    События без проблем реализуются через использование интерфейсов. Почитайте в интернете, все вопросы отпадут сами собой...

    ОтветитьУдалить
  4. Я пишу в свободное время, по мере возможности.

    ОтветитьУдалить
  5. >>> А будет ли рассмотрен вопрос, о передаче событий программы в плагин? Допустим, где-то, что-то крякнуло и плагин автоматом на это реагирует...

    Не понял, в чём, собственно, вопрос. Я показывал это ещё в прошлой части, где плагины вызывались по событию (открытие или сохранение файла).

    ОтветитьУдалить
  6. "Кросс-языковен"... М-да... Может, хотя бы тогда "кросс-язычен", если уж "языкозависимый" вас чем-то не устроил?

    ОтветитьУдалить
  7. а где "метод номер один" ? :-)

    ОтветитьУдалить
  8. Метод номер 1 = "удалять пункт меню по удалению последней ссылки на него".

    ОтветитьУдалить
  9. Тип BOOL отсутвует в стандартных типах при создании библиотек типов. Есть тип VARIANT_BOOL, который при генерации заголовочников становиться WordBool. Есть возможность добавить тип BOOL в файл *.tlb?

    ОтветитьУдалить
  10. Или использовать Integer?

    ОтветитьУдалить
  11. VARIANT_BOOL пришёл из Visual Basic и был выбран в качестве логического типа для COM.

    VARIANT_BOOL - это обычный логический тип. Я не понял, чем он вам не понравился? Да,это будет WordBool в Delphi.

    ОтветитьУдалить
  12. Спасибо за ответ. Теперь понятно как с ним работь.
    Смутил он меня 2-мя байтами и его поддержка в других ЯП, есть ли, да и по тексту везде BOOL.

    А тут еще один момент TPoint в раньше был с полями int теперь с longint. Его описание может тоже в заголовочник включить.

    Не смог найти как в заголовочнике tlb добавить новый тип TEditorSearchTypes и константы.

    ОтветитьУдалить
  13. Я в первой части сказал, что далее я буду писать код без оглядки на TLB, т.к. иначе больно много работы, а я ленюсь.

    Про константы у меня запланирован материал ещё. Если кратко - лучше делать явно числом и флаги комбинировать.

    ОтветитьУдалить
  14. Как указать тип параметра процедуры "DeleteMenuItem" как var? В библиотеке типов параметр всегда const.

    ОтветитьУдалить
    Ответы
    1. В модификаторах укажите [in, out] (вместо [in]). Если хотите результат для safecall функции - сделайте последний параметр [out, RetVal].

      Если указывается out, то параметр должен передаваться по ссылке (указателю). В нотации библиотеки типов это указывается также как в C - звёздочкой после имени типа. Например, long* [in, out] приведёт к var Param: Integer.

      Ну и не надо забывать, что в Delphi объекты и интерфейсы уже являются указателями. Т.е. IMenuItem* в библиотеке типов - это равно IMenuItem в смысле Delphi. А надо ещё добавить указатель для out - т.е. будет IMenuItem**.

      Удалить

Можно использовать некоторые HTML-теги, например:

<b>Жирный</b>
<i>Курсив</i>
<a href="http://www.example.com/">Ссылка</a>

Вам необязательно регистрироваться для комментирования - для этого просто выберите из списка "Анонимный" (для анонимного комментария) или "Имя/URL" (для указания вашего имени и (опционально) ссылки на сайт). Все прочие варианты потребуют от вас входа в вашу учётку.

Пожалуйста, по возможности используйте "Имя/URL" вместо "Анонимный". URL можно просто не указывать.

Ваше сообщение может быть помечено как спам спам-фильтром - не волнуйтесь, оно появится после проверки администратором.

Примечание. Отправлять комментарии могут только участники этого блога.