11 января 2009 г.

Создаём систему плагинов, часть 9

Предыдущая часть.

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

Опознание своих плагинов

Чтобы программе было проще опознавать свои плагины, лучше всего дать им какое-нибудь уникальное расширение. Для этого лезем в свойства проекта и на вкладке "Application" в поле "Target file extension" вписываем, например, "MyAppPlugin" (*) (я думаю, каждый сможет придумать что-то пооригинальнее и более подходящее к названию его программы). Теперь при компиляции у нас будет создаваться не "Project1.dll", а "Project1.MyAppPlugin".

Прикинем, как мы будем искать плагины: для загрузки плагинов в настройках программы можно вставить диалог, где пользователь мог бы ткнуть на плагин на винте рукой и сказать: "Добавить вот этот!".

Также можно ввести папку в каталоге программы (например, "MyApp/Plugins") и автоматом загружать все файлы с расширением ".MyAppPlugin" из этой папки. Правда, если нашей программе важен порядок загрузки плагинов, то способ с папкой уже не пройдёт. Можно скомбинировать эти два способа: автоматом подгружать все плагины из папки "MyApp/Plugins", но при этом дать пользователю ткнуть на плагин вне этой папки.

Хорошо бы также добавить ассоциацию MyAppPlugin-файлов со своей программой. Тогда пользователь мог бы устанавливать плагины дабл-кликом по имени файла. При этом запускается наша программа и копирует плагин к себе (в "MyApp/Plugins"), после чего регистрирует его (если надо). Кстати, вовсе не обязательно, чтобы такой файл был именно библиотекой плагина - это может быть и архив, в котором лежат все необходимые файлы плагина. Посмотрите, как это сделано у Total Commander (окей, строго говоря, там используется расширение zip, но принцип должен быть понятен).

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

Совместимость с другими языками

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

Но одного этого ещё не достаточно. Для достижения нашей цели ещё нужно убедиться, что в методах интерфейсов используются только базовые общепринятые типы данных, аналоги которых наверняка будут в других языках. Например, в Delphi классы, динамические массивы, строки, варианты (в старых версиях) и т.п. "сложные" типы данных являются чисто внутренними типами, не имеющих аналога в других компиляторах (ну, может быть, кроме C++ Builder близких версий).

"Безопасным" минимумом с этой точки зрения являются стандартные типы Windows (взглянуть на таковые можно в разделе Windows Data Types или в модуле Windows.pas), как, например, BOOL (аналог Boolean - основной логический типа в WinAPI; взглянуть на различие этих типов можно в топике "Boolean types" родной справки Delphi), Byte, Char, WideChar, DWord, Integer, Pointer (ну и любые типизированные указатели, в том числе PChar и PWideChar), TGUID, Single, Double, а также массивы и записи из этих типов. Ну и OleVariant, разумеется. Большая часть всех прочих "типов системы" являются просто ссылками на вышеперечисленные. Разумеется, вы не можете передавать между плагинами и ядром никаких там Application, Form и Screen - только их дескрипторы (HWND). Плагин, написанный на MS VC++ не сможет ни сформировать объект типа Form, ни воспользоваться передаваемым ему Application. Совсем другое дело - дескрипторы.

Ещё один важный момент - выравнивание данных в записях в аргументах экспортируемых плагином подпрограмм. Дело в том, что по-умолчанию в настройках компилятора Delphi стоит выравнивание на границу 8-ми байт. При этом при необходимости между полями записи будут вставляться промежутки. Проблема же здесь в том, что настройки проекта могут меняться - будет меняться и выравнивание в записях. В других компиляторах/языках умалчиваемое выравнивание может выполнятся на другую границу или вовсе отсутствовать. Поэтому нужно либо объявлять записи как packed, либо явно указывать выравнивание записи (а ещё лучше - использовать вместо записей интерфейсы). Например, вписав директиву {$A8} или {$A4} в начало заголовочных модулей (а ещё лучше вынеся все директивы в отдельный файл и подключая его во всех модулях).

Вообще говоря, сказанное справедливо также и для массивов, но сейчас во всех версиях Delphi все массивы неявно трактуются как packed. Тем не менее, от явного добавления слова packed к массивам уж точно никто не пострадает.

Аналогичные соображения применимы и к перечислимым типам (enumerated types). По-умолчанию в Delphi размер типа зависит от количества принимаемых значений. Т.е. если их достаточно мало, размер поля записи будет 1 байт, когда значений становится больше и они уже не влезают в байт, то под поле будет отводиться два байта. Разумеется, такое поведение никуда не годится. Поэтому, как и для записей, для перечислимых типов лучше указать размер явно, указав, как и ранее, директиву {$MINENUMSIZE 4}.

Обмен данными переменного размера

Под это определение попадают строки, динамические массивы и т.п (короче говоря, типы данные для которых необходимо выделение памяти). В этой теме достаточно много камней, о которые так любят спотыкаться кучи народа. Начнём с того, почему мы не можем использовать всеми любимый тип String. Тому есть две причины: во-первых, другие компиляторы просто не в курсе, что существует такой тип данных: у них обычно есть свои аналоги, несовместимые с дельфёвскими строками. Во-вторых (и это будет касаться не только строк, но и всех динамических типов данных), совместное использование динамических типов подразумевает выделение и освобождение блоков памяти как в библиотеке, так и в основной программе. Проблемой тут является тот факт, что обычно подпрограммы языка высокого уровня работают с памятью через прослойку: собственный менеджер памяти языка. Я не стану рассматривать причины этого, скажу только, что в библиотеке плагина и в главной программе будут использоваться разные менеджеры памяти (даже, если плагин и программа скомпилирована одним и тем же компилятором). Память, выделенная одним менеджером памяти не может быть освобождена в другом менеджере, хотя бы по той причине, что они не в курсах об управляющих структурах друг друга. В общем, попытка передачи строк и прочих динамических структур "через границу библиотеки" почти наверняка приведёт к Access Violation. Именно об этой проблеме мы заботились, когда выбирали safecall-методы для управления исключениями. На всякий случай (чтобы потом сильно не пинали за укрывательство фактов) я скажу, что обычно в языках программирования высокого уровня существует возможность переключения на специальный тип библиотеки с поддержкой разделяемого менеджера памяти. Именно о этой возможности, кстати, говорит большой комментарий в начале текста проекта новой библиотеки (причём в некоторых версиях Delphi - с ошибкой :) ). Смысл такого решения в том, что если абсолютно все будут использовать один-единственный менеджер памяти в общей DLL (ну или exe-модуле), то условие "кто выделил - тот и освобождает" будет выполняться автоматически.

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

Все эти проблемы решаются одним единственным способом: кто память выделил - тот её и должен освобождать. А вот реализаций этого способа можно напридумывать кучу: в самом Windows API используется не менее трёх различных вариантов (которые я рассматривать здесь не буду). В нашем случае выбор даже и не стоит: у нас же есть интерфейсы, которые автоматически освобождаются именно тем, кто их создал, когда у них обнуляется счётчик ссылок. Иными словами: интерфейсы - это уже готовое решение этих проблем. Конечно, если очень хочется, то можно и использовать решения в стиле WinAPI, но смысл?

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

Да, как уже было сказано, исключение составляют строки. Для них мы используем WideString. Дело в том, что, во-первых, WideString - это Delphi-синоним для системного типа строк BSTR (используемого в COM), а во-вторых, стандарт этого типа специально обговаривает, что для управления памятью таких строк должен использоваться только системный менеджер памяти.

Итого: для строк мы используем WideString, для всего остального - оболочку в виде интерфейса.

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

type
  IMemoryBlock = interface
  ['{9E9CCAF6-E69A-43D6-A161-9B83B3C6FD1A}']
  // private
    function GetMemory: Pointer; safecall;
    function GetSize: DWord; safecall;

  // public
    property Memory: Pointer read GetMemory;
    property Size: DWord read GetSize;
  end;

  TMemoryBlock = class(TInterfacedObject, IMemoryBlock)
  strict private
    FData: Pointer;
    FDataSize: DWord;
  protected
    function GetMemory: Pointer; safecall;
    function GetSize: DWord; safecall;
  public
    constructor Create(const ASize: DWord); reintroduce;
    destructor Destroy; override;
    property Memory: Pointer read GetMemory;
    property Size: DWord read GetSize;
  end;

{ TMemoryBlock }

constructor TMemoryBlock.Create(const ASize: DWord);
begin
  inherited Create;
  GetMem(FData, ASize);
  FDataSize := ASize;
end;

destructor TMemoryBlock.Destroy;
begin
  FDataSize := 0;
  if Assigned(FData) then
    FreeMem(FData);
  FData := nil;
  inherited;
end;

function TMemoryBlock.GetMemory: Pointer;
begin
  Result := FData;
end;

function TMemoryBlock.GetSize: DWord;
begin
  Result := FDataSize;
end;

Читать далее.

(*) К сожалению, в D2007/D2009 задать расширение больше трёх символов невозможно: см. QC Report №64554 (в других версиях я не проверял). Поэтому, если вы хотите расширение "по-уникальнее", то вам придётся вручную переименовывать файлы плагинов после сборки проекта. К счастью, в последних версиях Delphi можно задать команды, выполняемые после сборки. В предыдущих версиях для этого можно использовать какой-нибудь эксперт (навскидку такое точно есть в EurekaLog).

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

  1. Уррра, ты вернулся с продолжением! =))))))

    По поводу того что в программе и в dll-ках используются разные менеджеры памяти. Ты не мог объяснить поподробнее о том, что меняется при включении в проект ShareMem, FastShareMem, FastMM? Я пишу сейчас с позиции чайника, который слышал, что если, например, добавить в DLL-ку uses FastShareMem то можно передавать тот же тип string не заботясь о выделении/освобождении. Может даже отдельным постом.

    п.с. Спасибо за материал!

    ОтветитьУдалить
  2. http://gunsmoker.blogspot.com/2009/01/blog-post.html
    ;)

    ОтветитьУдалить
  3. Спасибо! Очень познавательный пост про использование менеджеров памяти.

    Хотя, как "чайник", я так и не понял, что будет если проект работает с подключенными ShareMem, FastShareMem, FastMM. И почему подключение ShareMem потребует таскать с собой дополнительную dll-ку, а FastShareMem работает и без неё, а FastMM вообще позволяет обойтись без первых двух. Но это наверное во мне говорит лень и нежелание читать документацию и разбирать исходники вышеупомянутых библиотек. (blush)

    ОтветитьУдалить
  4. >>> я так и не понял, что будет если проект работает с подключенными ShareMem, FastShareMem, FastMM
    Что, действительно непонятно написано? Я про это говорил рисунками и несколькими абзацами текста под ними.

    >>> почему подключение ShareMem потребует таскать с собой дополнительную dll-ку, а FastShareMem работает и без неё
    Примерно по той же причине, почему гвозди забивают молотком, а шурупы завинчивают отвёрткой - это две разные реализации одной идеи. Об этом ещё сказано в (**)

    >>> FastMM вообще позволяет обойтись без первых двух
    Просто у него эта функциональность не вынесена в отдельный модуль, вот и всё.

    ОтветитьУдалить
  5. >>Что, действительно непонятно написано? Я про это говорил рисунками и несколькими абзацами текста под ними.

    Написано понятно. =) Видимо "чайнику" во мне хотелось бы услышать ответы и на те вопросы, которые я сам, как и любой чайник, ленится формулировать. Сорри.

    Например такие:
    1) Что будет, если проект будет скомпилирован с использованием только FastMM. Одна Dll-ка будет использовать ShareMem, другая FastShareMem, а третья, например, вообще будет написана на другом языке. (как пример)
    2) Будет ли это работать без ошибок, если правило "кто память выделил, тот её и освобождает" будет соблюдаться на 100%
    3) Что случится, если это правило не будет соблюдаться.

    Но это мелочи на самом деле. Не обращай особого внимания. Спасибо за то, что ты делаешь.

    ОтветитьУдалить
  6. Не, критика - это хорошо. По ней я могу поправить пост.

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

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

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

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

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

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