Статьи Королевства Дельфи

       

Подгружаемые модули (plugins) в Delphi


Раздел Подземелье Магов Трофимов Игорь , дата публикации 01 августа 2000г.

Введение

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

Точнее, я понимал, что многие используют достаточно очевидный метод - обращение к функциям подгружаемой DLL с оговоренными именами. Этот подход кажется очевидным и простым, если задача, возлагаемая на plugin проста. Типичные примеры - вниешние кодеки, разборщики пакетов и т.д.

Однако описанный подход имеет ряд недостатков, зачастую довольно существенных. Я опишу их в следующем разделе.

В то же время меня часто спрашивали, каким образом можно создать удобный механизм plugin'ов и я описывал свой метод. Метод, предлагаемый мною, основан на использовании механизма, которым пользуется сама Delphi IDE - пакеты (packages).

Проблема (недостатки DLL-plugin'ов)

Все используемые модули компилируются в DLL

Представьте, что вам надо сделать подключаемый модуль, который выводит форму с настройками. Как только вы впишете в DLL выражение uses Forms,... модуль Forms, а также все модули, используемые модулем Forms будут прилинкованы к вашей DLL, что катастрофически увеличит ее размер. Представьте теперь, что вам нужно подключать несколько plugin'ов, каждый из которых будет предоставлять форму или вкладку для редактирования параметров. Как писал классик, душераздирающее зрелище...

Модули дублируются

Предыдущий недостаток является количественным, т.е. просто увеличивающим размер проекта. Но из него вытекает качественный недостаток. Рассмотрим его на примере. Пусть вам надо создать подгружаемые разборщики пакетов. Вы определяете абстрактный класс TParser в модуле UParser и хотите, чтобы все разборщики наследовали от него. Но для того, чтобы вы могли описать в DLL потомок от TParser, вы должны включить модуль UParser в список uses для DLL. А для того, чтобы основная программа могла обращаться с TParser и его потомками, в нее также должен быть включен uses UParses,.... Неприятность заключается в том, что эти модули будут находиться в памяти дважды и тот TParser, о котором знает основная программа не совпадает с тем, который знает plugin.


Задача (чего бы нам хотелось)
Все просто. Нам бы хотелось, чтобы основная программа могла без особых ухищрений работать с внешними модулями как с потомками некоторого абстрактного класса и при этом бы не было избыточности кода. При этом желательно, чтобы изменения в основную программу вносить приходилось как можно реже, даже при очень развитой функциональности plugin'а.

Средство (пакеты и функции для работы с ними)
Пакеты появились в третьей версии Delphi. Что такое пакет? Пакет - это набор компилированных модулей, объединенных в один файл. Исходный текст пакета, хранящий я в файлах .dpk содержит только указания на то, какие модули содержит (contains) этот пакет (здесь "содержит" означает также "предоставляет") и на какие другие пакеты он ссылается (requires). При компиляции пакета получается два файла - *.dcp и *.dpl. Первый используется просто как библиотека модулей. Нам же больше интересен второй.



Основной особенностью пакетов является то, что не включают в себя код, которым пользуются. Т.е. если некоторые модули используют большую библиотеку функций и классов, то можно потребовать их наличия, но не включать в пакет. Вы спросите, что же тут нового, ведь обычные модули тоже не включают в .dcu-файл весь используемый код? А нового здесь то, что dpl-пакет является полноправной DLL специального формата (т.е. с оговоренными разработчиками Delphi именами экспортируемых процедур). При загрузке пакета в память автоматически устанавливаются связи с уже загруженными пакетами, а если загружаемый пакет требует наличия еще каких-то пакетов, то загружаются и они. Кроме того, в отличие от обычных модулей, программа, использующая модули из внешнего пакета тоже не обязана включать его код. Таким образом, можно писать EXE-программы размеров в несколько десятков килобайт (естественно, будет требоваться наличие на диске соответствующего пакета, который затем подгрузится).

Функции для работы с пакетами сосредоточены в модуле SysUtils. Нас будут интересовать следующие из них: function LoadPackage(const Name: string): HMODULE; Загружает пакет с заданным именем файла в память, полностью подготавливая его для работы. procedure UnloadPackage(Module: HMODULE); Выгружает заданный пакет из памяти.



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

Минусы
Бесплатный сыр, как известно, бывает только в мышеловках... Поэтому после рассмотрения плюсов стоит рассмотреть и минусы данного подхода. Мы рассмотрим их в порядке возрастания их важности.
  1. В отличие от dll-plugin'ов, вы привязываетесь к Delphi и C++ Builder'у (или это плюс ? :) ).
  2. Конечно, существуют некоторые накладные расходы на обеспечение интерфейса пакета - самый маленький пакет имеет не нулевую длину. Кроме того, умный линкер Delphi не сможет выкинуть лишние процедуры из совместно используемых пакетов - ведь любой метод может быть затребован позже, каким-то другим внешним пакетом. Поэтому возможно увеличение размера суммарного кода программы. Это увеличение практически не заметно, если разделяемый пакет содержит только интерфейс для plugin'а и существенно больше, если необходимо разделить стандартные пакеты VCL. Впрочем, это легко окупается, если plugin'ов много. Кроме того, стандартные пакеты могут использоваться разными программами.
  3. Возможно самый существенный недостаток, вытекающий из предыдущего. Пакет неделим, потому что неизвестно, какие его процедуры понадобятся, поэтому он грузится в память целиком. Даже если вы используете одну единственную функцию из пакета, не вызывающую другие и не ссылающуюся на другие ресурсы пакета, пакет грузится в память целиком. Это, опять таки, не очень заметно, если в пакете только голый интерфейс с небольшим количеством процедур. Но если это несколько стандартных пакетов VCL, то занимаемая программой память может увеличиться очень существенно (на несколько мегабайт). Впрочем, это снова окупается, если вы используете большое количество plugin'ов - если бы они были оформлены в виде dll, то каждая из них содержала бы приличную часть стандартных модулей и они держались бы в памяти одновременно. Фактически, предлагаемый метод является более масштабируемым, т.е. издержки начинают снижаться при увеличении количества plugin'ов.




Метод (что делаем, и что получим)
Предлагемая структура построения пиложения выглядит следующим образом: выполяемый код = Основная программа + Интерфейс plugin'ов + plugin. Все три перечисленные компоненты должны находиться в разных файлах (программа - в EXE, остальное - в пакетах BPL). Программа умеет загружать пакеты в память и обращаться к подгруженным потомкам абстрактного plugin'а. Plugin представляет собой потомок абстрактного класса, объявленного в интерфейсном модуле. Программа и plugin используют модуль интерфейса, но он находится в отдельном пакете и в памяти будет присутствовать в единственном екземпляре.

Остался единственный вопрос - как основная программа получит ссылки на объекты или на классы (class references) нужного типа? Для этого в интерфейсном модуле хранится диспетчер plugin'ов или, в простейшем случае, просто TList, в который каждый модуль заносит ставшие доступными классы. В более развитом случае диспетчер классов может обеспечивать поиск подгруженных классов, являющихся потомками заданного, приоритеты при загрузке, и.т.д.

Ясно, что мы достигли поставленой цели - избыточности кода нет (при условии, что все библиотеки, в том числе и стандартные библиотеки VCL, используются в виде пакетов), написание plugin'а упрощено до предела. Чего можно добиться еще?

А можно добиться еще более интересной вещи. Если мы всю основную програму поместим в пакет, а EXE-файл будет включать в себя только процедуру создания и открытия основной формы, то внешний plugin может получить полный доступ ко всем модулям программы, в том числе и к главной форме. Таким образом мы можем написать plugin, который самостоятельно, без каких-либо усилий со стороны головной программы, поместит свой пункт в главное меню и кнопку на панель инструментов, по команде которых будет вызываться внешний код. Это то, ради чего стоит задуматься над использованием предложенного метода - положив в определенный каталог маленький (в нем только ваш код) plugin, вы добавляете к программе очередную возможность, не перекомпилируя основной программы.



Подгружаемые модули (plugins) в Delphi: Пример 1
Постановка задачи

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

Мы создадим один предопределенный класс, экспортирующий строки в текствый файл и один внешний plugin, содержащий класс, который умеет экспортировать строки....ну, скажем, в HTML. Экспорт в Excel или в БД выведет нас за тонкую границу примера.

Абстрактный класс

Итак, рассмотрим определение абстрактного класса:
unit UExporter; { ============================================= } interface { ============================================= } type TExporter = class public class function ExporterName: string; virtual; abstract; procedure BeginExport; virtual; abstract; procedure ExportNextString(const s:string); virtual; abstract; procedure EndExport; virtual; abstract; end; { ============================================= } implementation { ============================================= } end
Я надеюсь, никто не упрекнет меня за чрезмерное усложнение примера :) . А тех, кто, прочитав этот кусочек кода, закричит громким голосом "Это можно было сделать и в dll !" я отсылаю к размышлениям о размерах dll. Ведь потомки TExporter в методе BeginExport запросто могут выводить форму настройки экспорта.

Менеджер классов

Следующим номером нашей программы будет менеджер загруженных классов. Как я и говорил, это может быть просто TList:
unit UClassManager; { ============================================= } interface { ============================================= } uses Classes; type TClassManager = class(TList); function ClassManager: TClassManager; { ============================================= } implementation { ============================================= } var Manager: TClassManager; function ClassManager: TClassManager; begin Result := Manager; end; { ============================================= } initialization { ============================================= } Manager := TClassManager.Create; { ============================================= } finalization { ============================================= } Manager.Free; end


В этом коде, по моему, пояснять нечего.

Экспорт в простой текстовый файл

Теперь напишем стандартный потомок от TExporter, обеспечивающий вывод строк в обычный текстовый файл.
unit UPlainFileExporter; { ============================================= } interface { ============================================= } uses Classes, UExporter; type TPlainFileExporter = class(TExporter) private F: TextFile; public class function ExporterName: string; override; procedure BeginExport; override; procedure ExportNextString(const s:string); override; procedure EndExport; override; end; { ============================================= } implementation { ============================================= } uses Dialogs, SysUtils, UClassManager;{ TPlainFileExporter } procedure TPlainFileExporter.BeginExport; var OpenDialog : TOpenDialog; begin OpenDialog := TOpenDialog.Create(nil); try if OpenDialog.Execute then begin AssignFile(F,OpenDialog.FileName); Rewrite(F); end else Abort; finally OpenDialog.Free; end; end; procedure TPlainFileExporter.EndExport; begin CloseFile(F); end; class function TPlainFileExporter.ExporterName: string; begin Result := 'Экспорт в текстовый файл'; end; procedure TPlainFileExporter.ExportNextString(const s: string); begin WriteLn(F, s); end; { ============================================= } initialization { ============================================= } ClassManager.Add(TPlainFileExporter); { ============================================= } finalization { ============================================= } ClassManager.Remove(TPlainFileExporter); end
Мы считаем, что коррестность вызова методов BeginExport и EndExport обеспечит головная программа и не задумываемся о возможных неприятностях с открытым файлом. Кроме того, следует отметить, что используется модуль Dialogs, который использует Forms и т.п. И наконец, обратите внимание на разделы initialization и finalization модуля - мы используем возможность Delphi ссылаться на класс, как на объект.

Основная программа

Из основной программы я приведу только несколько методов, иллюстрирующих использование внешних пакетов, а полный текст вы найдете в архиве, прилагаемом к статье.
procedure TMainForm.RefreshPluginList; var i : Integer; begin PluginsBox.Items.Clear; for i := 0 to ClassManager.Count - 1 do PluginsBox.Items.Add(TExporterClass(ClassManager[i]).ExporterName); end;


Эта процедура просматривает список зарегистрированных классов (предполагается, что там только потомки TExporter) и выводит их "читабельные" имена в ListBox.

procedure TMainForm.LoadBtnClick(Sender: TObject); begin PluginModule := LoadPackage(ExtractFilePath(ParamStr(0)) + 'HTMLPluginProject.bpl'); RefreshPluginList; end;
Эта процедура загружает пакет с "зашитым" именем (ну это же просто пример :) ) и запоминает его handle. После чего происходит обновление списка, чтобы вы могли убедиться, что новый класс зарегистрировался.

procedure TMainForm.UnloadBtnClick(Sender: TObject);begin UnloadPackage(PluginModule); RefreshPluginList;end;
Ну тут, я думаю, все ясно.

procedure TMainForm.ExportBtnClick(Sender: TObject); var ExporterClass: TClass; Exporter: TExporter; i: Integer; begin if PluginsBox.ItemIndex < 0 then Exit; ExporterClass := ClassManager[PluginsBox.ItemIndex]; Exporter := TExporter(ExporterClass.Create); try Exporter.BeginExport; try for i := 0 to StringsBox.Lines.Count - 1 do Exporter.ExportNextString(StringsBox.Lines[i]); finally Exporter.EndExport end; finally Exporter.Free; end; end;
Эта процедура производит экспорт строк с помощью зарегистрированного класса plugin'а. Мы пользуемся тем, что нам известен абстрактный класс, так что мы спокойно можем вызывать соответствующие методы. Здесь следует обратить внимание на процесс создания экземпляра класса plugin'а.

Компиляция

Разверните архив в какой-то каталог ( например c:\bebebe :) ) и откройте группу проектов Demo1ProjectGroup.bpg. Использование группы полезно, так как вам часто придется переключаться между основной программой и двумя пакетами - это разные проекты. Я надеюсь, что если вы нажмете "Build All Projects" то все успешно скомпилится.

Поглядев на опции головного проекта, вы увидите, что на страничке Packages указано, какие из используемых пакетов не прилинковывать к exe-файлу. Следует отметить, что даже если вы включите туда только PluginInterfaceProject, то автоматом будут считаться внешними и все, используемые им пакеты - в нашем случае Vcl5.dpl. Зато если вы положите на основную форму какой-то компонент работы с BDE, то пакет VclDB5.bpl может быть прикомпилирован (с оптимизацией, естественно) к EXE-файлу.

Что еще можно сказать? Пожалуй, стоит отметить, что "возня" с пакетами нередко бывает утомительна и чревата "непонятными ошибками" вплоть до зависания Delphi. Однако все они в итоге оказываются следствием неаккуратности разработчика - ведь связывание на этапе выполнения это не простая штука. Поэтому следите, куда вы компилируете пакеты, следите за своевременной перекомпиляцией plugin'ов, если изменился абстрактный класс, следите, чтобы у вас на машине не валялось 10 копий dpl-пакета, потому как вы можете думать, что программа загрузит лежащий там-то и ошибетесь.

Еще. По умолчанию файлы .dcu кладутся вместе с исходниками, а пакеты - в каталог ($DELPHI)\Projects\Bpl. В примере настроки правильные - пакеты создадутся в каталоге исходников. Пожелания, вопросы, благодарности и ругань приму по адресу iamhere@ipc.ru. На все, кроме ругани постараюсь ответить.


Что еще можно сказать? Пожалуй, стоит отметить, что "возня" с пакетами нередко бывает утомительна и чревата "непонятными ошибками" вплоть до зависания Delphi. Однако все они в итоге оказываются следствием неаккуратности разработчика - ведь связывание на этапе выполнения это не простая штука. Поэтому следите, куда вы компилируете пакеты, следите за своевременной перекомпиляцией plugin'ов, если изменился абстрактный класс, следите, чтобы у вас на машине не валялось 10 копий dpl-пакета, потому как вы можете думать, что программа загрузит лежащий там-то и ошибетесь.

Еще. По умолчанию файлы .dcu кладутся вместе с исходниками, а пакеты - в каталог ($DELPHI)\Projects\Bpl. В примере настроки правильные - пакеты создадутся в каталоге исходников. Пожелания, вопросы, благодарности и ругань приму по адресу iamhere@ipc.ru. На все, кроме ругани постараюсь ответить.

Содержание раздела