8 января 2012 г.

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

Первая часть.

См. также Разработка API (контракта) для своей DLL.

В этой части мы рассмотрим реализацию функциональности плагинов.

Для начала я напомню, что в качестве демонстрационного приложения я использовал программу-пример из комплекта демок Delphi - RichEdit Demo. Программа представляет собой простенький текстовый редактор типа WordPad:


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

Оглавление

  1. Спецификация
  2. Формализация
  3. Контракт
  4. Реализация

Спецификация

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

Итак, что же нам можно сделать? В качестве примера я предлагаю разработать плагины импорта/экспорта. Посмотрите: если вы в программе выберите File/Save as или File/Open - вы увидите диалоги выбора файлов с двумя возможными форматами: RTF и TXT. Т.е. либо Rich Text, либо просто текст. Но что если мы хотим сохранять наш файл в HTML? Как насчёт DOC? Ну или хотя бы ODT? И экспорт в PDF был бы полезен... Понятно, что в самой программе мы не можем заранее предусмотреть все возможные форматы файлов - ведь некоторые из форматов могут просто не существовать в момент создания программы, а появиться позже. Поскольку у нас нет машины времени, мы не можем предсказать, какие форматы будут полезны пользователям нашей программы.

Поэтому я предлагаю ввести в программу плагины импорта и экспорта. Каждый плагин будет поддерживать один какой-то формат файлов, которого нет в списке по умолчанию (RTF и TXT). Плагин экспорта должен уметь создать такой файл по RTF тексту. Плагин импорта должен уметь распарсить входной файл и создать по нему RTF (т.е. преобразовать одно в другое).

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

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

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

Давайте введём правило, что готовые плагины экспорта/импорта наша программа будет автоматически загружать при запуске из подпапки Plugins. Для этого у нас уже есть готовая инфраструктура, разработанная в прошлой части. На самом деле, мы там реализовали даже больше чем надо - там есть и выборочная загрузка и т.п.

Окей, что дальше? Тут надо рассуждать так: что должен делать плагин после своей загрузки? Ну, как минимум в диалоге сохранения/загрузки должны появится пункты "сохранить в формат XXX", где XXX - это формат, поддерживаемый плагином. Ага, значит плагин должен сообщить ядру две вещи:
  1. Расширение файла (для использования в фильтре). Например, "htm". Или маску файлов. Например, "*.htm".
  2. Читабельное название формата файлов (для показа в списке выбора файлов). Например, "Гипертекстовые документы (HTM/HTML)".
Отлично, первый шаг есть. Формализовать его кодом мы будем чуть позже. А сейчас будем думать дальше: что ещё потребуется от плагина? Ну, когда его пользователь выберет плагин в диалоге выбора файлов, ядро должно вызвать плагин для экспорта или импорта RTF. Это значит, что плагин экспорта должен предоставлять функцию экспорта, а плагин импорта - функцию импорта. Ядро вызовет эту функцию, когда пользователь нажмёт на кнопку "OK" в диалоге выбора файлов. Соответственно, ядро передаст туда как минимум имя файла, а также... что?

Рассмотрим ситуацию с плагинами экспорта. У нас в программе есть Rich Edit. Нам нужно как-то передать его плагину, чтобы тот сохранил RTF-текст в какой-то свой формат. Как это можно сделать? Ну, можно тупо взять и передать сам Rich Edit. Это будет работать, но это - плохое решение. Почему? Потому что вы выставляете плагину свои внутренности. По идее, плагин занимается преобразованием RTF в другой формат. Нужен ему для этого визуальный редактор? Нет, ему нужен RTF-текст. Или вы можете посмотреть на это с такой стороны: предположим вы делаете консольный конвертер файлов. Будет ли в нём TRichEdit? Нет, откуда? Ведь у консольной программы нет GUI. Т.е. все эти мысли намекают вам, что передача RichEdit-а плагину - не самое удачное решение.

Что же делать? Как нам ещё можно передать данные плагину? Как-как - с использованием потоков данных, конечно же! RichEdit может сохранить своё содержание (RTF текст) в поток данных, а мы затем можем передать этот поток данных плагину. Плагин может прочитать RTF-текст и преобразовать его как душе угодно.

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

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

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

Формализация в коде

Теперь по этому словесному описанию надо создать код.

Предварительно я напомню, что здесь и далее я буду писать заголовочники сразу в виде .pas файлов, но в реальности вам нужно создавать их в редакторе библиотеки типов, если вы хотите распространять их в другие языки. Я подробно разжевал эту мысль в первой части, так что не буду повторять её здесь.

Итак, начнём с информационной части - нам нужно сообщить ядру расширение или маску, а также текстовое описание. Нет проблем:
IPlugin = interface
['{D6B90C88-647D-4265-9052-2EE1BD274979}']
// private
  function GetMask: WideString;
  function GetDescription: WideString;
// public
  property Mask: WideString read GetMask;
  property Description: WideString read GetDescription;
end;
Как видите, эта часть - общая для плагинов обоих типов (и экспорта и импорта).

Далее - плагину экспорта нужна функция экспорта, а плагину импорта - функция импорта. Без проблем - как описали, так и пишем:
IExportPlugin = interface
['{09428378-34BA-4326-8550-BF1CA72FDF53}']
  procedure ExportRTF(const ARTF: IStream; const AFileName: WideString); safecall;
end;

IImportPlugin = interface
['{6C85B093-7AAF-4EF0-B98E-D9DBDE950718}']
  procedure ImportRTF(const AFileName: WideString; const ARTF: IStream); safecall;
end;
Как видите, наши слова из предыдущего раздела практически буквально ложатся на код.

Вот, собственно, и всё. Заголовочники готовы. Когда вы словами описали, чего вы хотите, написать код - дело пяти секунд. Если же вы не вполне представляете себе, чего вы хотите, этот шаг будет нетривиален.

Окончательное формирование контракта

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

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

Возможно, сказанное выше сейчас не очень понятно - что такое я имею в виду? На самом деле, тут ничего сложного нет. Я имею в виду, что вы должны явно написать правила типа таких: "имя файла не должно быть пустой строкой". Вам, как создателю всей архитектуры программы, это может быть очевидно - вы же знаете весь свой код. Но сторонним разработчикам это может быть не очевидно. Что если пустое имя файла в плагине импорта означает создание пустого документа? Хм, об этом вы не подумали. Или, вот, другой пример: если в функцию импорта/экспорта передали поток данных - надо ли его позиционировать на начало перед работой или нужно работать с текущей позиции? Тоже интересный вопрос. А вот и ещё один: имя файла, передаваемое в функцию импорта/экспорта - всегда ли оно абсолютное и полное? Не может ли это быть просто 'MyFileName.htm'? Ведь последнее означает, что функция импорта/экспорта уже больше не может свободно распоряжаться текущим каталогом. И такой вопрос: надо ли плагину экспорта выводить предупреждение о перезаписи файла, если файл-назначение уже существует? Или же предупреждение вынесет ядро?

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

К примеру, в вашей документации к плагинам могла бы быть такая статья:


IExportPlugin.ExportRTF

Экспортирует текст программы в сторонний формат файлов.

Синтаксис
procedure ExportRTF(
  const ARTF: IStream;
  const AFileName: WideString
); safecall;

Параметры
ARTF [входной]
ТипIStream
Поток данных, содержащий RTF-текст из редактора программы. Поток всегда спозиционирован на начало.
AFileName [выходной]
ТипBSTR
Абсолютное имя файла-назначения. Файл может существовать. В этом случае функция должна его перезаписать.

Возвращаемое значение
Нет. Ошибки возвращаются стандартным способом.

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

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

Примеры
Пример использования можно увидеть в статье Пример реализации плагина экспорта.

Требования
Версия ядра
1
Заголовочный файл
PluginAPI.tlb

См. также

(Да, это документация с закосом под MSDN)

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

Так что, как бы вы ни оформляли свою документацию, проследите чтобы в ней было:
  • (Опционально) Краткое описание метода, что он делает. Эта часть нужна, чтобы быстро ухватить суть метода. В принципе, поскольку у вас есть подробное описание (см. ниже), то её можно не писать. Но её наличие позволяет более эффективно работать с документацией.
  • (Опционально) Синтаксис метода (копия из заголовочника). Не обязательно, ведь код можно посмотреть в заголовочниках, но лучше приводить - исключительно для удобства (не надо ещё куда-то лазить, чтобы увидеть синтаксис). Всегда отсутствует в документации, написанной прямо в заголовочниках (по очевидным причинам).
  • (Обязательно) Описание каждого параметра и возвращаемого значения функции. Опишите, какой параметр входной, какой выходной, какие ограничения вы задаёте для параметров, какие значения он может принимать, а какие - не может. Как метод должен реагировать на специальные случаи (если они есть).
  • (Обязательно) Обработка ошибок. Как метод сообщает об ошибках. Какие есть специальные случаи, которые вызывающий может хотеть явно обработать.
  • (Обязательно) Подробное описание метода и его работы. Нужно сказать, когда он вызывается, что он делает, что должен выдавать. Описать типичное использование, краевые случаи и возможные подводные камни.
  • (Опционально) Пример использования или ссылка на него.
  • (Обязательно) Требования - т.е. в какой версии сервера (ядра) появился этот метод и где искать его описание. Понятно, что в первом варианте вашей системы плагинов у вас каждый метод будет присутствовать, начиная с первой версии ядра (просто потому, что другой версии у вас нет). И в этом случае этот раздел можно не писать из-за его очевидности. Но в дальнейшем, когда вы будете расширять свою систему и у вас появятся новые методы, этот раздел всё равно нужно будет добавить.
  • (Опционально) Ссылки на тесно связанные или аналогичные методы, плюс ссылки на обобщающий материал (если есть) или корневой раздел.
Небольшое замечание по поводу входных и выходных параметров. Дело в том, что этот термин может использоваться в двух разных смыслах. Во-первых, есть очевидный смысл: P: T, const P: T - входные параметры, out P: T - выходной параметр, а var P: T - входной-выходной. Это - синтаксический смысл. Он очевидно следует из кода метода. Поэтому лично я считаю, что этот смысл не нужно дублировать ещё и словами в документации (вы ведь привели синтаксис метода, дааа?).

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

Итого, ваш контракт плагинов будет состоять из двух частей: синтаксиса (заголовочники) и семантики (документация).

Реализация

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

В качестве примера сейчас мы напишем несколько плагинов экспорта-импорта, а также научим ядро работать с ними. Поскольку эта статья - про плагины, а не про вопросы работы с RTF-текстом, то я ограничусь лишь простыми, тривиальными примерами. Чтобы было хоть чуточку интересно, я предлагаю уже существующие возможности (импорт/экспорт в RTF и TXT) тоже оформить в виде плагинов. Это позволит мне продемонстрировать работу с плагинами, не отвлекаясь на вопросы преобразования RTF - это сама по себе непростая задача. По идее, если вы захотите развить этот пример и сделать импорт/экспорт в другие форматы, вам придётся выполнить кучу работы. Во-первых, вам нужно прочитать входной документ и распарсить его, создав модель документа в памяти. Модель - это значит что документ будет представлен объектами "документ", "абзац", "строка", "параметры шрифта" и т.п. Это делается, чтобы с документом можно было удобно работать. И при этом вам нужно следить, что вы покрываете все случаи, описанные в спецификации формата, вас ожидают волнительные вопросы работы с кодировками и т.д. Когда преобразование выполнено, то остаётся пройтись по модели документа и выполнить её "сборку" в документ другого формата. В общем, если вас заинтересует развитие этого примера, то в сети можно найти готовый код для парсинга RTF и других форматов. Альтернативно можно попробовать делать преобразование RTF как замену строк - этот подход основывается на том факте, что RTF - это текстовый формат, вроде HTML. Т.е. вы можете получить содержимое в виде строки, а затем серией замен сменить атрибуты RTF на, скажем, тэги HTML. Или вы можете просто использовать уже написанный и готовый код. В этом случае ваша задача по написанию плагина будет заключаться в инкапсуляции готовой библиотеки кода в виде плагина к программе. В любом случае, как видите, всё это достаточно трудоёмкие задачи - и именно поэтому здесь они не рассматриваются.

Итак, начнём с ядра:
procedure TMainForm.FormCreate(Sender: TObject);
begin
  SetErrorMode(SetErrorMode(0) or SEM_NOOPENFILEERRORBOX or SEM_FAILCRITICALERRORS);
  // Загрузка всех плагинов. Подразумевается, что они лежат в под-папке Plugins
  Plugins.LoadPlugins(ExtractFilePath(ParamStr(0)) + 'Plugins');

  ...
end;
Это - загрузка плагинов. Тут ничего особенного нет, все плагины грузятся автоматически и скопом, не различаясь по типу.

В дальнейшем нам потребуется показывать загруженные плагины в диалогах сохранения/открытия, поэтому предлагаю сразу же после загрузки плагинов подготовить диалоги. Мы можем это сделать, потому что список плагинов у нас не меняется. Если бы мы добавили в программу возможность динамически загружать плагины по указке пользователя (замечу, что наша архитектура это позволяет, просто мы не выставляем эту возможность в данном примере), то нам нужно было бы это делать каждый раз перед открытием диалога.
procedure TMainForm.FormCreate(Sender: TObject);
begin
  SetErrorMode(SetErrorMode(0) or SEM_NOOPENFILEERRORBOX or SEM_FAILCRITICALERRORS);
  // Загрузка всех плагинов. Подразумевается, что они лежат в под-папке Plugins
  Plugins.LoadPlugins(ExtractFilePath(ParamStr(0)) + 'Plugins');

  BuildFilterList;

  ...
end;

procedure TMainForm.BuildFilterList;

  function BuildFilter(const AInterface: TGUID): String;
  var
    X, Y: Integer;
    Intf: IInterface;
  begin
    Result := '';
    Y := 0;
    // Отбираем только нужные плагины и заносим их в список
    for X := 0 to Plugins.Count - 1 do
      if Supports(Plugins[X], AInterface, Intf) then
      begin
        Result := Result + Plugins[X].Description + '|' + Plugins[X].Mask + '|';
        Plugins[X].FilterIndex := Y;
        Inc(Y);
        Intf := nil;
      end;
    // Удалили лишний | в конце строки
    SetLength(Result, Length(Result) - 1);
  end;

begin
  OpenDialog.Filter := BuildFilter(IImportPlugin);
  SaveDialog.Filter := BuildFilter(IExportPlugin);

  OpenDialog.FilterIndex := 1;
  SaveDialog.FilterIndex := 1;
end;
Тут мы два раза проходимся по общему списку плагинов, отбирая плагины по типам и занося их в список для диалогов. Я воспользовался той же техникой, что и в прошлый раз - спроецировав плагин на локальный тип данных, так что работа с плагинами ничем не отличается от работы с локальными объектами. Я не буду приводить изменения в коде менеджера плагинов - все изменения делаются по аналогии, как и в прошлый раз. Вы можете посмотреть полный исходный код в примере к статье (см. ниже).

Единственное, на что я хочу обратить момент прямо сейчас - нам нужно как-то связать плагин со строкой в фильтре диалога. Т.е. вот сейчас мы добавили плагин в диалог - это хорошо. А что нам делать потом, когда пользователь прикажет программе сохранить файл именно в этот формат? Как нам найти нужный плагин? По какому признаку их отличать?

Тут есть несколько разных подходов. Давайте я кратенько их опишу.

Подход первый: опознавать плагин по его характеристике. Например, по маске. Или по описанию. Или по тому и другому. Это будет работать, если эта характеристика - уникальна. Плюсы: дополнительно ничего делать не надо, минусы: не всегда такая характеристика есть. В данном случае эта характеристика не уникальна, потому что теоретически никто не мешает нам установить два плагина экспорта в PDF: плагин от Васи и плагин от Пети.

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

Третий, не очень правильный, подход: ввести "обратный идентфикатор" и заставить плагин помнить нужную информацию. Именно этот подход я применил в примере выше. Плюсы: не нужно модифицировать сторонний класс, минусы: зависимость менеджера плагинов от использующего его кода, кроме того, этот метод не всегда применим. Тут надо заметить, что сам плагин (разумеется!) не должен ничего знать про то, как ядро его использует. Какой там у него индекс в диалоге сохранения - не его ума дело, а ядра. Поэтому, конечно же, эту информацию передавать самому плагину нельзя. Но поскольку у нас есть менеджер плагинов и он отслеживает информацию о плагинах (вроде его индекса, описателя библиотеки и имени файла - всех этих вещей на руках у плагина нет), то почему бы не дать ему отслеживать ещё один дополнительный кусочек информации? Именно таким кусочком в моём примере является (новое) свойство FilterIndex.

Дополнительно отмечу четвёртый (тоже не совсем правильный) подход: идентификация плагинов по индексу в менеджере плагинов. Плюсы: идентификатор можно записать в свойства типа Tag, Objects, lpParam и т.п. (т.е. не нужна доработка классов), минусы: эта схема не пригодна при динамической загрузке/выгрузке, т.к. индексы при этом могут меняться.

Ладно, разобрались. Далее, сам экспорт:
procedure TMainForm.FileSaveAs(Sender: TObject);
var
  Plugin: IExportPlugin;
  RTFStream: IStream;
  Stream: TStream;
begin
  if SaveDialog.Execute then
  begin
    if FileExists(SaveDialog.FileName) then
      if MessageDlg(Format(sOverWrite, [SaveDialog.FileName]),
        mtConfirmation, mbYesNoCancel, 0) <> idYes then Exit;

    // Ищем плагин
    if not FindPlugin(SaveDialog.FilterIndex - 1 { у FilterIndex отсчёт с 1, корректируем},
                      IExportPlugin, Plugin) then
      Exit;

    // Готовим данные для экспорта
    Stream := TMemoryStream.Create;
    RTFStream := TStreamAdapter.Create(Stream, soOwned);
    Editor.Lines.SaveToStream(Stream);
    Stream.Position := 0;

    // Вызываем экспорт
    Plugin.ExportRTF(RTFStream, SaveDialog.FileName);

    // Учёт (без изменений)
    SetFileName(SaveDialog.FileName);
    Editor.Modified := False;
    SetModified(False);
  end;
end;
...и импорт:
procedure TMainForm.FileOpen(Sender: TObject);
var
  Stream: TStream;
  RTFStream: IStream;
  Plugin: IImportPlugin;
begin
  CheckFileSave;
  SetFileName(sUntitled);  // <- указали, что это - новый документ
  if OpenDialog.Execute then
  begin
    // Импорт
    Stream := TMemoryStream.Create;
    RTFStream := TStreamAdapter.Create(Stream, soOwned);
    if not FindPlugin(OpenDialog.FilterIndex - 1, IImportPlugin, Plugin) then
      Exit;
    Plugin.ImportRTF(OpenDialog.FileName, RTFStream);

    // Обработка данных импорта
    Stream.Position := 0;
    Editor.Lines.LoadFromStream(Stream);

    // Учёт
    Editor.SetFocus;
    Editor.Modified := False;
    SetModified(False);
    Editor.ReadOnly := ofReadOnly in OpenDialog.Options;
  end;
end;
А также команда "просто Save":
procedure TMainForm.FileSave(Sender: TObject);
var
  Plugin: IExportPlugin;
  RTFStream: IStream;
  Stream: TStream;
begin
  if FFileName = sUntitled then
    FileSaveAs(Sender)
  else
  begin
    // Ищем плагин
    if not FindPlugin(SaveDialog.FilterIndex - 1, IExportPlugin, Plugin) then
      Exit; 

    // Вызываем экспорт
    Stream := TMemoryStream.Create;
    RTFStream := TStreamAdapter.Create(Stream, soOwned);
    Editor.Lines.SaveToStream(Stream);
    Stream.Position := 0;
    Plugin.ExportRTF(RTFStream, FFileName);

    Editor.Modified := False;
    SetModified(False);
  end;
end;
Функция FindPlugin тривиальна (она возвращает запрошенный интерфейс у плагина с заданным индексом фильтра):
function TMainForm.FindPlugin(const AIndex: Integer; const AGUID: TGUID; out Obj): Boolean;
var
  X: Integer;
begin
  Result := False;
  for X := 0 to Plugins.Count - 1 do
    if Plugins[X].FilterIndex = AIndex then
    begin
      Result := Supports(Plugins[X], AGUID, Obj);
      if Result then
        Break;
    end;
end;
Здесь нужно обратить внимание на фундаментальное изменение в поведении программы. Раньше, когда плагинов в программе у нас не было, мы могли загрузить файл и нажать на кнопку "Save". И это действие сохранило бы файл в оригинальном формате. Но когда мы перевели загрузку/сохранение на плагины - мы этого сделать не можем. Потому что у нас сейчас нет информации о связи плагинов импорта и экспорта. И если у нас установлен плагин импорта, скажем, из HTML, то это не значит, что установлен и плагин экспорта в HTML. Иными словами, операция может быть односторонней. Поэтому в данном примере при открытии файла мы помечаем его как ещё не сохранённый - и тогда при сохранении мы покажем диалог выбора имени файла и плагина для экспорта.

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

Я приведу некоторые изменения в менеджере плагинов, которые я сделал для этого примера:
type
  IPlugin = interface
  // protected
    ...
    function GetMask: String;
    function GetDescription: String;
    function GetFilterIndex: Integer;
    procedure SetFilterIndex(const AValue: Integer);
  // public
    ...
    property Mask: String read GetMask;
    property Description: String read GetDescription;

    property FilterIndex: Integer read GetFilterIndex write SetFilterIndex;
  end;

...

  TPlugin = class(TInterfacedObject, IUnknown, IPlugin)
  private
    ...
    FFilterIndex: Integer;
    FPlugin: PluginAPI.IPlugin;
    FInfoRetrieved: Boolean;
    FMask: String;
    FDescription: String;
    procedure GetInfo;
  protected
    ...
    function GetMask: String;
    function GetDescription: String;
    function GetFilterIndex: Integer;
    procedure SetFilterIndex(const AValue: Integer);
    function QueryInterface(const IID: TGUID; out Obj): HResult; stdcall;
  public
    ...
  end;

  ...

procedure TPlugin.GetInfo;
begin
  if FInfoRetrieved then
    Exit;
  FMask := FPlugin.Mask;
  FDescription := FPlugin.Description;
  FInfoRetrieved := True;
end;

function TPlugin.GetFilterIndex: Integer;
begin
  Result := FFilterIndex;
end;

procedure TPlugin.SetFilterIndex(const AValue: Integer);
begin
  FFilterIndex := AValue;
end;

function TPlugin.QueryInterface(const IID: TGUID; out Obj): HResult;
begin
  Result := inherited QueryInterface(IID, Obj);
  if Failed(Result) then
    Result := FPlugin.QueryInterface(IID, Obj);
end;
Почти все изменения я уже обсудил - это и дублирование Mask/Description и новое поле FilterIndex, не передаваемое плагину. Осталось пояснить только код с QueryInterface. Нужен он вот зачем: нашей программе нужен от плагина не только IPlugin (функциональность которого и так выставляется наружу менеджером плагинов), но и IExportPlugin/IImportPlugin. Как программа может их получить? По идее, их нужно запрашивать у интерфейса плагина, который вернула функция инициализации плагина. Этот интерфейс хранится в менеджере плагинов внутри класса TPlugin - в поле FPlugin. Чтобы выставить его наружу есть два варианта: простой и красивый.

Простой вариант выглядит так:
type
  IPlugin = interface
  ...
    function GetIntf: PluginAPI.IPlugin;
  ...
    property Intf: PluginAPI.IPlugin read GetIntf;
  end;

...

  TPlugin = class(TInterfacedObject, IUnknown, IPlugin) 
  private
    FPlugin: PluginAPI.IPlugin;
  ...
    function GetIntf: PluginAPI.IPlugin;
  ...  
  end;

...

function TPlugin.GetIntf: PluginAPI.IPlugin;
begin
  Result := FPlugin;
end;
(да, я знаю: у меня два разных интерфейса называются одним именем; наверное, это не очень удачно, но другого подходящего имени мне в голову не пришло, а они не пересекаются, так что я оставил так, но вы можете использовать разные имена, чтобы не запутаться)

И тогда работа с плагином выглядела бы так:
for X := 0 to Plugins.Count - 1 do
  if Supports(Plugins[X].Intf, IExportPlugin, ExportPlugin) then
    ...
Т.е. получается, что мы ввели лишний уровень косвенности - Intf. Этот способ прост, потому что очевиден: мы просто открыли доступ к внутреннему полю.

Но есть и другой способ, более красивый - именно его я и использовал. Смысл его заключается в том, чтобы разрешить получать интерфейсы плагина не через плагин, а через его обёртку в менеджере плагинов. Именно этим занимается переопределённый метод QueryInterface. Логика у него чрезвычайно простая: сперва вызовем унаследованную реализацию. Нашла она запрашиваемый интерфейс? Если да - то выходим. В этой ситуации кто-то запросил интерфейс оболочки плагина. Если же унаследованная реализация ничего не нашла, то это означает, что оболочка плагина этот интерфейс не реализует. Но тогда, быть может, нас спрашивают не о нашем интерфейсе, а интерфейсе плагина? Тогда мы вызовем запрос интерфейса у плагина.

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

С таким подходом мы можем писать код вида:
for X := 0 to Plugins.Count - 1 do
  if Supports(Plugins[X], IExportPlugin, ExportPlugin) then
    ...
Обратите внимание, что к этому моменту обёртка плагина для нас вообще ничем не отличается от самого плагина, с точки зрения вызывающего нет никакой разницы. Со стороны это выглядит так, словно у плагина каким-то "волшебным" образом появились свойства, которых у него на самом деле нет (Index, FileName, FilterIndex и т.п.).

Кстати говоря, в таком простом примере можно было бы вообще объединить IPlugin с IImportPlugin и IExportPlugin, но я специально сделал два интерфейса, чтобы показать как можно запрашивать у плагина разную функциональность, а также чтобы лучше подчеркнуть логику разработки: "нам что-то от плагина надо? - создай интерфейс для этого!"

Давайте теперь перейдём к плагинам. Начнём с плагинов импорта/экспорта для RTF. Они тривиальны, потому что нам не нужно выполнять преобразования потоков данных, а нужно просто тупо скопировать их.

Плагин экспорта:
library ExportRTF;

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

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

type
  TPlugin = class(TInterfacedObject, IUnknown, IPlugin, IExportPlugin)
  private
    FCore: ICore;
  protected
    // IPlugin
    function GetMask: WideString; safecall;
    function GetDescription: WideString; safecall;

    // IExportPlugin
    procedure ExportRTF(const ARTF: IStream; const AFileName: WideString); safecall;
  public
    constructor Create(const ACore: ICore);
  end;

{ TPlugin }

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

function TPlugin.GetMask: WideString;
begin
  Result := '*.rtf';
end;

function TPlugin.GetDescription: WideString;
begin
  Result := 'Rich Text (RTF)';
end;

procedure TPlugin.ExportRTF(const ARTF: IStream; const AFileName: WideString);
var
  FS: TFileStream;
  RTFStream: TOleStream;
begin
  FS := TFileStream.Create(AFileName, fmCreate or fmShareExclusive);
  try
    RTFStream := TOleStream.Create(ARTF);
    try
      FS.CopyFrom(RTFStream, 0);
    finally
      FreeAndNil(RTFStream);
    end;
  finally
    FreeAndNil(FS);
  end;
end;

// _________________________________________________________________

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

procedure Done; safecall;
begin

end;

exports
  Init name SPluginInitFuncName,
  Done name SPluginDoneFuncName;

end.
И плагин импорта:
library ImportRTF;

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

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

type
  TPlugin = class(TInterfacedObject, IUnknown, IPlugin, IImportPlugin)
  private
    FCore: ICore;
  protected
    // IPlugin
    function GetMask: WideString; safecall;
    function GetDescription: WideString; safecall;

    // IImportPlugin
    procedure ImportRTF(const AFileName: WideString; const ARTF: IStream); safecall;
  public
    constructor Create(const ACore: ICore);
  end;

{ TPlugin }

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

function TPlugin.GetMask: WideString;
begin
  Result := '*.rtf';
end;

function TPlugin.GetDescription: WideString;
begin
  Result := 'Rich Text (RTF)';
end;

procedure TPlugin.ImportRTF(const AFileName: WideString; const ARTF: IStream); safecall;
var
  RTFStream: TOleStream;
  FS: TFileStream;
begin
  RTFStream := TOleStream.Create(ARTF);
  try
    FS := TFileStream.Create(AFileName, fmOpenRead or fmShareDenyWrite);
    try
      RTFStream.CopyFrom(FS, 0);
    finally
      FreeAndNil(FS);
    end;
  finally
    FreeAndNil(RTFStream);
  end;
end;

// _________________________________________________________________

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

procedure Done; safecall;
begin

end;

exports
  Init name SPluginInitFuncName,
  Done name SPluginDoneFuncName;

end.
Как видите, они тривиальны - но лишь потому, что нам не нужно выполнять преобразование данных. Зато на этом простом примере видно, как нужно делать плагины. А если у вас возникнет желание сделать экспорт в HTML, PDF и т.п. - что ж, словами я описал этот процесс выше.

Кстати говоря, обратите внимание, что при выбранной схеме один и тот же плагин может быть одновременно и плагином импорта и плагином экспорта - для этого ему всего лишь нужно реализовывать два интерфейса одновременно (и IImportPlugin и IExportPlugin). А ещё надо заметить, что если разрешить такую ситуацию, то наш подход с FilterIndex работать не будет - т.к. один плагин будет зарегистрирован в обоих диалогах (и поэтому нам нужно будет иметь два свойства-индекса, а не одно). Так что этот момент - ещё один вопрос для фиксации в документации. Сделать можно и так и этак - это как вы захотите. Но что бы вы ни выбрали - этот выбор, главное, надо закрепить в документации.

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

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

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

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

  1. По поводу передачи файлов через IStream. Этот интерфейс описан в ObjIdl.h, но, по-видимому, не предназначен для импорта [ссылка], т.е. его нет в списке доступных типов в редакторе библиотеки типов. Там описан способ решения, но тогда придется таскать повсюду свою библиотеку типов, единственная цель которой — экспорт IStream, и регистрировать её. Да и ссылаться полученный код будет, если я правильно понял, на наше объявление IStream. Либо передавать в параметре IUnknown, а потом приводить его в IStream, что тоже по-моему не очень. Либо изобретать что-то своё.

    ОтветитьУдалить
  2. Хм, про это я не знал.

    Действительно, IStream нет в стандартных библиотеках типов, потому что он не предназначен для маршалинга. Гм, мы не используем маршалинг, так что иметь его хотелось бы...

    Как я это вижу, правильное решение здесь - включить в наш IDL файл файл objidl.idl (в нём находится определение IStream). Проблема тут в том, что в редакторе библиотеки типов Delphi такое не сделать (или я не нашёл?).

    Нормальное (но не слишком удобное решение) с редактором Delphi - использовать IUnknown вместо IStream. Разработчик плагинов после импорта TLB может просто подключить (уже существующий) objidl.h и выполнить запрос IStream у IUnknown (о том, что это надо сделать, будет написано в документации). Это проще всего, но немного неудобно в использовании со стороны плагина (две лишние строчки кода на каждый параметр типа IStream).

    Ну или включить IStream в свою библиотеку типов, т.е. переописать его самому. Необязательно, кстати, делать это в отдельной библиотеке типов, можно и в основной. Тут есть два варианта - ввести новый тип или полностью продублировать старый (вместе с GUID). Как-то у меня есть сомнения в правильности последнего. Наверняка это будет работать, но ведь TLB планируется использовать для генерации заголовочников. Поэтому у нас появится новый что-то-там.h, где будет определение IStream. Получится, что у нас будет два файла (стандартный objidl.h и что-то-там.h), где определён IStream. Если же пойти по первому пути, то придётся написать свой TOleStream и свой TStreamAdapter (чтобы они работали с новым IMyStream). Это же нужно будет сделать и со стороны плагина. Так что вроде как с этим подходом в целом получается существенно больше проблем, чем с первыми двумя.

    Что здесь лучше всего сделать - я без понятия. Буду благодарен, если кто-то поделится своими мыслями/опытом.

    Я бы рекомендовал для начала использовать подход с IUnknown. Две строчки лишнего кода - не большая плата за уже написанный стандартный код. А в дальнейшем надо посмотреть на возможность подключения objidl.idl.

    ОтветитьУдалить
  3. Посмотрел примеры TLB от Microsoft - в них IStream вполне себе используется. Например, в imapi2.tlb. Только он не импортируется, а объявлен прямо в этой же библиотеке типов. Исходник этой библиотеки типов (imapi2.idl) содержит строку

    import "objidl.idl";

    Т.е. в "нашу" библиотеку типов импортируется файл objidl.idl. Таким образом, получается, что это - правильное решение.

    Правда, я без понятия, как это сделать с редактором библиотек типов Delphi.

    Поэтому предлагаю попробовать сделать такой финт ушами: библиотеки типов типа imapi2.tlb (или других, где есть определение IStream) - они стандартные. Т.е. они уже зарегистрированы в системе. И в них есть нужное нам определение. Поэтому, когда мы создали в Delphi библиотеку типов, можно перейти на вкладку "Uses", щёлкнуть по списку правой кнопкой мыши и выбрать пункт меню "Show all type libraries", после чего отыскать в списке стандартную библиотеку типов с IStream (например, "Microsoft IMAP2 Base Functionality") и установить напротив неё галочку. После этой нехитрой операции во всех полях выбора типов появится IStream* и другие типы, определённые в выбранной нами библиотеке типов.

    Список стандартных библиотек типов с определением IStream:

    cordebug.tlb
    imapi2.tlb
    imapi2fs.tlb
    metahost.tlb
    mscoree.tlb
    msvidctl.tlb
    SearchAPI.tlb
    tuner.tlb
    wdstptmgmt.tlb

    ОтветитьУдалить
  4. Исследовал такой вариант: я скомпилировал IDL файл с подключенным objidl.idl в TLB файл.

    IStreamTLB.idl:

    import "objidl.idl";
    [
    uuid(81DF4D4E-C82D-49FF-B9ED-EA3E93175479),
    version(1.0),
    helpstring("IStream declaration")
    ]
    library IStreamTLB
    {
    importlib("stdole32.tlb");
    importlib("stdole2.tlb");

    [
    object,
    uuid(30014161-581B-462C-B110-D99BCD789F99),
    pointer_default(unique),
    helpstring("Dummy interface")
    ]
    interface IMyDummyStream : IUnknown
    {
    HRESULT UseIStream( [in] IStream* Stream
    );
    }

    };

    Build.bat:
    @echo off
    call "c:\Program Files\Microsoft Visual Studio 10.0\VC\bin\vcvars32.bat"
    midl "C:\IStreamTLB.Idl" /out C:\ /h "TestHeader.hpp"

    Получил IStreamTLB.tlb. Что интересно - в заголовочнике была верно указана ссылка на первоисточник (objidl.h).

    Далее я зарегистрировал этот файл в системе (я воспользовался Component/Import Component/Import Type Library/Add - это автоматически зарегистрирует библиотеку типов, после чего мастер импорта можно закрыть (понятно, что для регистрации IDE должна быть запущена под администратором). Альтернативно, можно использовать утилиты regtlib.exe или regtlibv12.exe от Microsoft-а.

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

    Однако, если потом эту библиотеку типом импортировать в Delphi - она создаст заголовочник без ссылки на существующее объявление IStream, а вместо этого добавит новое определение. В связи с чем мы получим проблемы из-за несовместимости типов IStream из стандартного модуля и нашего заголовочника. Причём это же происходит и с Microsoft-скими библиотеками типов при импорте их в Delphi.

    Понятно, что автосгенерированные заголовочники можно легко подправить: вписать в uses модуль ActiveX, а дублирующееся объявление IStream удалить. Но... это будет ручная обработка, которой я и хотел избежать.

    ОтветитьУдалить
  5. К стати об TOleStream. Одно упоминание Vcl.AxCtrls.pas в котором он описан, раздувает размер библиотеки на метр с лишним. Пришлось вырезать в отдельный файл, благо совсем немного. А то получалось, что при размере хоста в 400 КБ, плагин весил 1,5 МБ.

    ОтветитьУдалить
  6. Я бы предложил выкинуть IStream - он слишком мощный, большинство реализаций все равно не смогут поддерживать этот интерфейс в полном объеме.

    ОтветитьУдалить
  7. Явный доступ к плагинам - таки не айс. Если будет нужда перераспределить обязанности плагинов, использовать в одних плагинах другие, управлять порядком загрузки оных - лучше использовать IServiceProvider.
    В этом случае неважно, кто реализует функционал: ядро, плагин или мок - клиентский код останется прежним.

    ОтветитьУдалить
  8. Поясню, почему был выбран IStream - потому что это хоть какой-то стандарт.

    Например, в Delphi есть готовые классы-переходники. Подозреваю, что аналогичные вещи есть и в других языках. Т.е. если использовать IStream - то вам придётся писать меньше кода, поскольку код уже написан.

    Но никто не запрещает не использовать IStream. Вполне можно было бы создать свой интерфейс по аналогии (скажем, гипотетический IDataStream), и по аналогии же написать переходники к TStream.

    Плюсы: нет проблем с библиотекой типов, т.к. интерфейс свой. Минусы: писать больше кода (как разработчику программы, так и разработчикам плагинов).

    Так что выбирайте любой подход, какой больше нравится.

    > лучше использовать IServiceProvider

    Не уверен, что понял мысль.

    ОтветитьУдалить
  9. По поводу IStream и TStream. Когда-то напоролся на такой "подводный камень". Дело в том что на TStreamAdapter-е не реализован метод IStream.Clone. На практике этот метод довольно часто используется сторонними модулями (особенно написанными на других языках), когда IStream нужно хранить некоторое время на стороне модуля т.к. позволяет работать со своим seek без дублирования ресурса.
    Почему-то очень часто проверку HResult на Clone опускают :( В результате подсовываешь адаптер - и получаешь эксепшн.

    А самое печальное во всем этом то, что нельзя реализовать свой TStreamAdapter с IStream.Clone, в силу неправильно заложенной архитектуры (видимо не задумались когда-то разработчики делфи, а потом переделывать не стали).
    Клонирование нужно делать на уровне каждого вида ресурса (TFileStream/TMemoryStream/TКакойтоStream).
    Текущая архитектура скрывает сам ресурс (хендл, указатель памяти и т.п.) внутри самого TStream наследника, а значит и не возможно нормальное клонирование. Решение - выносить ресурс отдельно с подсчетом ссылок.

    Поэтому прежде чем использовать повсеместно стандартные TStream наследники и TStreamAdapter нужно хорошенько подумать понадобится ли вам Clone, и если да - то реализовывать свои TStream наследники, а так же TStreamAdapter.

    Я помнится хорошенько так попал, когда 80% кода было написано :)

    ОтветитьУдалить
  10. Окей, будем считать так: в статье взят IStream, чтобы было меньше писать кода, а у себя - пишите свой аналог. :)

    ОтветитьУдалить

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

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

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

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

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

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