2 декабря 2008 г.

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

Примечание 12.01.2011: эта серия не была закончена. То, что было написано, говорит скорее не про плагины вообще, а представляет собой глубокий "заныр" в детали DLL и BPL. Если вас интересуют именно плагины, то вместо этой серии почитайте вот эту серию постов.

Окей, я решил, наконец, написать какую-нибудь программку. Может быть Shareware. Может быть нет. Не знаю. Есть только пока несколько задумок.

Хочется сделать что-нибудь красивое в D2009 с применением всех тех новых возможностей Delphi.

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

Для тех, кто мало знаком с темой плагинов, я быстренько пробежался по DK и нашёл вот такие статьи (сверху-вниз - от самого навороченного до голых DLL): Давайте посмотрим, какие варианты у нас есть:

1. COM-объекты (2-я статья). По-первой прикидке вроде и неплохое решение, но уж как-то кажется оно мне тяжеловесным. Да и не силён я в COM, а тут же всякие нюансы нужно учитывать. И кроме того, мне не нравится идея отложенной выгрузки библиотеки, а не по требованию (да, я в курсе про проблемы явной выгрузки, но...). Посему: этот способ подождёт до лучших времён.

2. Голые DLL (5-я статья). Однозначно нет, т.к. недостатков здесь полно, а преимуществ нет вообще. К примеру, это проблемы с DLLMain. Обычное решение таких проблем выглядит так: не выполнять никакой работы в DLLMain (читай: в секциях initialization/finalization модулей, а также в begin/end dpr-файла), а выполнять её только по запросу. Первым действием после загрузки библиотеки плагина должен идти вызов функции инициализации, а первым действием перед выгрузкой - вызов функции освобождения. К сожалению, никакими средствами эту схему в Delphi для DLL вы не реализуете.

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

Но самое главное в плагинах в виде DLL - это контроль ошибок. Вы замучаетесь использовать здесь коды ошибок. Давно на голом WinAPI не писали? Будете делать плагины в виде чистых DLL - придётся вспомнить. А как же авторы плагинов, которые пишут в Delphi и слабо знакомы с WinAPI? Ну, обойдутся или придётся учиться.

3. Пакеты Delphi (4-я статья). В чистом виде - также однозначно нет. Потому что это явная привязка к Delphi. А плагины должны быть независимы от языка. Кроме того, здесь имеются серьёзные проблемы с сопровождением - при некоторых модификациях нужно полностью пересобирать все проекты.

4. ...и снова пакеты Delphi (нет статьи - можно считать улучшенным способом "голых DLL"). Дело в том, что пакет в Delphi - это просто обычная DLL, но с некоторыми достаточно интересными свойствами. Откинув те из них, что имеют отношение чисто к Delphi, мы увидим, что пакет - это DLL, у которой код инициализации/финализации в DLLMain сведён к минимуму, а весь реальный код повешен на две специальные функции: Initialize и Finalize. Каждую из этих функций можно вызвать после загрузки и до выгрузки, соответственно, таким образом, полностью избавляясь от проблем с DLLMain. Во, это то, что нам нужно. Если отбросить специфичную для Delphi шелуху, то пакет будет представлять собой DLL, в которой нет проблем с DLLMain. Заметим, что простое использование пакетов вовсе не привязывает нас к Delphi. Это будет зависеть от реализации (см. далее).

5. ...пакеты как DLL + интерфейсы ("почти" третья статья). Окей, в предыдущем пункте мы избавились от проблемы с DLLMain. Осталась самая малость: как реализовывать интерфейс между ядром и плагинами. Итак, у нас есть варианты:
  • Функции. Хотя здесь нет проблем с межязыковой совместимостью, но всё же этот способ никуда не годится: фактически этим возвращаются все проблемы голых DLL, только что без проблемы с DLLMain.
  • Объекты. Никуда не годится. Привязка к Delphi, со всеми вытекающими - как и при чистых пакетах.
  • Интерфейсы. Во. Это уже что-то. Во-первых, здесь нет привязки к языку - интерфейсы доступны почти в любом языке под Win32 (а где их нет - то это исключение, подтверждающее правило; впрочем, я таких языков не знаю). Во-вторых, интерфейс позволяет работать по ООП со всеми преимуществами. Меньше и проблемы с импортом/экспортом. А самое главное, что интерфейсы позволяют (как и объекты) использовать для контроля ошибок исключения - через safecall-методы.

    Для тех, кто вообще слабо знаком с интерфейсами, можно почитать Использование интерфейсов или Урок 4. Сервер, кокласс, интерфейс и Урок 5. Интерфейс IUnknown (с небольшим уклоном в COM). Вопрос производительности.

Вот это наш вариант!

Итак, для себя я получил, что идеальный вариант был бы плагины в виде пакетов, которые используются как простые DLL, а интерфейс ядро-плагины построен на базе интерфейсов. Что необходимо для того, чтобы эта система была бы независима от языка? Да всего-то самую малость: не передавать по интерфейсам никакой информации, специфичной для Delphi или конкретной версии компилятора - объекты (особенно Application и Screen), строки типа AnsiString/String/UnicodeString и т.п.

Причём, при желании, эта схема может быть (опционально и даже частями) легко дополнена до схемы "чисто пакеты + интерфейсы".

Например, главное приложение и входящие в базовую поставку плагины могут быть скомпилированы с использованием run-time пакетов Delphi (т.е. разделять между собой базовые пакеты Delphi - rtl, vcl и т.п.). При этом суммарный размер приложения уменьшается за счёт повторного использования кода, находящегося в стандартных пакетах (либо же можно собрать свой собственный пакет run-time поддержки).

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

Тогда сторонние плагины легко могут быть написаны как "пакеты в виде DLL + интерфейсы".

Как выглядят такие "пакеты в виде DLL" в других языках? По-большому счёту это будет просто DLL. Если язык позволяет собирать DLL без проблем с DLLMain - ну и супер, это идеально. Если же нет - ну что-ж, нужно просто аккуратнее писать плагин и не усугублять ситуацию (работают же как-то плагины в виде DLL с проблемами в DLLMain - и ничего).

По-большому счёту, даже в Delphi можно собрать плагин как DLL-проект, а не пакет - и встроить его в эту систему. Лишь бы интерфейс у DLL совпадал. И всё будет работать на отлично - если только вы не будете перегружать секции initialization/finalization кодом, а будете использовать специально введённые функции инициализации.

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

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

  1. Интересная статья.

    А ты не мог рассказать поподробнее о проблемах с контролем ошибок в DLL?
    А разве нельзя в Delphi вообще ничего не писать в DllMain?

    ОтветитьУдалить
  2. >>> А ты не мог рассказать поподробнее о проблемах с контролем ошибок в DLL?
    На DK готовится к публикации моя статья по обработке ошибок. Я добавлю сюда ссылки после её выхода.
    Вкратце я уже сказал: невозможность использование исключений - это зло. Это низкий уровень. Это чертовски неудобно.
    А исключения - это только safecall. А safecall - это только объекты или интерфейсы.
    Использование safecall позволяет писать плагин так, как если бы он не был внешней отдельной частью главного приложения, а был бы в него встроен. Т.е. не нужно специально оборачивать каждую функцию в оболочку для обработки ошибок и не нужно специально вписывать обработку ошибок после каждого вызова функции плагина.

    >>> А разве нельзя в Delphi вообще ничего не писать в DllMain?
    Нет, нельзя. Конкретно ты, как разработчик плагина можешь в своём коде вовсе не использовать секции initialization/finalization и begin/end в dpr-файле.
    Но.
    Стандартные модули уже используют initialization/finalization. Например, System - это как минимум менеджер памяти и поддержка исключений.
    Иными словами, создать DLL с почти пустой DLLMain нельзя - там всегда будет код. Вот пакет - другое дело.
    Конечно, ничего особо страшного в стандартном коде нет. Проблемы начнуться, когда в плагине потребуется использовать большую и сложную сторонюю библиотеку. В которой весьма сильно используются initialization/finalization - ну вот случилось так, что писали её в расчёте на использование в главном приложении (как вариант - в пакете), но не в DLL (там её даже не тестировали).
    Конечно, можно плюнуть на все эти проблемы и сказать: всё - DLL. У кого с этим проблемы - это ваши проблемы. Но ведь хочется сделать красиво?

    ОтветитьУдалить
  3. > Вкратце я уже сказал: невозможность использование исключений - это зло.

    Как это невозможность, можно с этого места поподробнее? Не понимаю. У меня, например, есть код: Dll и приложение, написанны на Дельфи. Приложение вызывает функцию из Dll, и если при выплнении этой функции в Dll возникает Exception, то в приложение этот exception замечательно ловится.

    > Но ведь хочется сделать красиво?
    В последнее время пришёл к выводу, что простой и понятный код, для меня важнее красивого но сложного. (blush)

    ОтветитьУдалить
  4. p.s. Функции вызываются с директивой stdcall.

    p.p.s. Когда статья выйдет, запости пожалуйста. С удовольствием почитаю.

    ОтветитьУдалить
  5. >>> то в приложение этот exception замечательно ловится
    Да ловится оно по той простой причине, что и приложение и библиотека написаны в Delphi!
    Это легко сообразить самому.
    Исключение в Delphi представляется объектом класса Exception (или наследником). При возбуждении исключения с ним ассоциируется объект. За удаление объекта ответственнен тот, кто ловит исключение.
    Внимание, вопрос:
    - Если приложение написано на C++ (MSVS), а исключение было возбуждено в DLL (Delphi), то как программа на C++ получит доступ к объекту Delphi? А как она его удалит?
    - Как вы собираетесь в своей программе, написанной на Delphi, ловить исключения C++? Что вы знаете о C++ классах исключений? А как вы будете удалять объект C++?
    - Если плагин написан в Delphi 2009, а главное приложение - в Delphi 2007, будет ли работать ваша схема? Как приложение получит доступ к данным UnicodeString?
    - Как в общем случае (заранее не зная, на чём написан модуль - на C++, D2007 или D2009) вы определите правильный способ обхождения с исключением? А как вы узнаете, какой модуль возбудил исключение?

    Бонус-вопрос:
    - Почему исключения работают даже, если не используется ShareMem или его аналог? Как приложению удаётся удалять объект, созданный в чужом модуле, доступа к менеджеру которого у главного приложения нет. Подсказка: рассмотреть обязательную виртуальность деструктора.

    Как видите, исключения можно безопасно использовать только в одном случае: если все модули используют одну и ту же версию RTL - т.е. ровно при тех же условиях, что и родные пакеты Delphi, со всеми вытекающими последствиями (привязка к версии и т.п.)...

    ОтветитьУдалить
  6. >>> В последнее время пришёл к выводу, что простой и понятный код, для меня важнее красивого но сложного.
    А никто не заставляет использовать именно пакеты. Хотите DLL - пишите DLL. Они же взаимозаменяемые. DLL-плагин будет полностью работоспособен при этой схеме.
    Я ровно об этом и говорю: пакет - это не более чем обычная DLL, с вынесенным отдельно кодом инициализации.
    Суть в том, чтобы реализовать систему плагинов так, чтобы она допускала любой из этих двух стилей.

    ОтветитьУдалить
  7. > Да ловится оно по той простой причине, что и приложение и библиотека написаны в Delphi!
    К моему стыду, до меня только сейчас дошло, что exception-ы это фича Дельфей, а не винды. Попытался вспомнить как Api сообщает об ошибках, смог вспомнить только результат вызова функции и GetLastError.

    Интересные вопросы, я даже не задумывался о том, что как оно будет работать если собрать DLL с другой версией RTL.
    Мой ответ на все эти вопросы: не знаю.

    ОтветитьУдалить
  8. >>> Попытался вспомнить как Api сообщает об ошибках, смог вспомнить только результат вызова функции и GetLastError
    Если говорить очень грубо, то исключение в Windows - это запись типа TExceptionRecord (посмотрите в Windows.pas или в SysUtils.pas - другой, "более Дельфовый" вид). Оно характеризуется кодом (число) и некоторым количеством целочисленных параметров.
    Код исключения - это либо один из стандартных кодов (например, EXCEPTION_ACCESS_VIOLATION - см. Windows.pas) или один из пользовательских кодов.
    Библиотека поддержки каждого языка обычно включает в себя удобную оболочку вокруг системного механизма исключений. Для этого она резервирует под себя несколько пользовательских кодов исключений (например, Delphi - cDelphiException = $0EEDFADE и др. - см. System.pas). Объект языка, представляющий собой исключение, ассоциируется с системным исключением с помощью указания указателя на объект в одном из параметров исключения.

    >>> Интересные вопросы, я даже не задумывался о том, что как оно будет работать если собрать DLL с другой версией RTL.
    Если когда-нибудь использовали Total Commander с кучей плагинов, то могли видеть это в действии.
    Total Commander написан на Delphi 2. Плагины к нему чаще всего пишутся на современных версиях Delphi. Проблема в том, что в D2 и, скажем, D7 используют разные коды исключений Delphi. Поэтому, если человек, писавший плагин к TC не был аккуратен и не заключал каждую свою функцию в try/except, то может быть такая ситуация, когда исключение убегает из плагина.
    TC не может опознать это исключение как исключение Delphi (у них же разные коды), поэтому для него это - EExternalException. Информация об исключении сидит в ExceptionRecord - просто набор ничего не говорящих цифр.
    Всё, что TC может сделать - это показать сообщение об ошибке (точный текст я не помню): "В приложении возникла ошибка EEDFADE. Продолжить выполнение программы?".
    Далее - либо выход, либо продолжение работы с утечкой ресурсов (объект исключения-то никто не освобождал) и возможными проблемами в дальнейшем.

    Кстати, поскольку TC написан на D2, то он не может использовать интерфейсы - они появились только в D3.

    ОтветитьУдалить
  9. Пост обновлён: добавлены ссылки на материал про DllMain.

    ОтветитьУдалить
  10. >>> А разве нельзя в Delphi вообще ничего не писать в DllMain?

    http://www.delphikingdom.ru/asp/answer.asp?IDAnswer=68224 - а вот и пример про DllMain.

    ОтветитьУдалить
  11. Хм. Я за чистые dll тк не хочу ограничивать свои приложения только одной средой.
    По поводу "утомительного составления модулей импорта/экспорта, их корректной синхронизхации при модификации", мне пришлась по душе идея оконной процедуры Windows, те dll редко имеет больше одной процедуры.

    ОтветитьУдалить
  12. Пост обновлён: добавлены ссылки на статью

    http://www.delphikingdom.ru/asp/viewitem.asp?catalogid=1392

    ОтветитьУдалить
  13. Добавил пожелание про нормальный DllMain для DLL на uservoice - можно голосовать ;)

    ОтветитьУдалить
  14. Проголосовал всем и оставшимися голосами.

    ОтветитьУдалить
  15. > Первым действием после загрузки библиотеки
    > плагина должен идти вызов функции
    > инициализации, а первым действием перед
    > выгрузкой - вызов функции освобождения. К
    > сожалению, никакими средствами эту схему в
    > Delphi для DLL вы не реализуете.

    Реализуется очень просто и вполне легально - в begin-end определяется своя DLLMain, а в ней все делается через dwReason.

    ОтветитьУдалить
  16. >>> Реализуется очень просто и вполне легально
    Читайте внимательнее.

    Секции initialization модулей выполняются (из DLLMain) до того, как ваш код получает управление.

    ОтветитьУдалить
  17. > Примечание 12.01.2011: эта серия не была закончена.
    Серия будет завершена?

    ОтветитьУдалить
  18. Таковых планов пока не имею.

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

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

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

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

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

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