22 декабря 2008 г.

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

При декларации интерфейса IInit мы описали его методы как safecall вместо ожидаемого stdcall.

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

Сейчас настало время поговорить об обработке ошибок. Что будет, если в вызываемом методе возникнет исключение? Средства поддержки языка (будь это Delphi или C++) создадут объект "исключение" (в случае Delphi - наследник от Exception). Это исключение должно будет обработаться на вызывающей стороне. Проблема здесь заключается в том, что, например, вызывающая сторона, написанная в Delphi не знает, как поступать с объектом исключения, возбуждённого из плагина, написанном на C++.

Для того, чтобы безопасно (читай: независимо от языка) передать ошибку между модулями (module) в COM введено простое правило: все методы должны возвращать значение типа HRESULT, показывающее успешность выполнения метода. Если у метода уже есть возвращаемое значение - его надо передавать в последнем out-параметре. Любые исключения, возникающие в методе, необходимо глушить и конвертировать в подходящее значение HRESULT.

Но разве это не обычный подход с кодами ошибок? Хм, ну что-то в этом есть похожего, да. Отличие в том, что "протокол" предусматривает передачу дополнительных параметров вместе с кодом ошибки - подробнее об этом в моей статье.

Что касается Delphi, то она предусматривает удобную обёртку для этих правил. Например, наше объявление метода Init:
procedure Init(const AInitParams: IInterface); safecall;
на самом деле трактуется компилятором как:
function Init(const AInitParams: IInterface): HRESULT; stdcall;
// модель вызова stdcall фиксирована и не может быть изменена
Плюс к этому, сам код метода Init (ну, когда мы дойдём до реализации интерфейса, разумеется) будет завёрнут в невидимый try/except - и все исключения, возникающие в нём будут сконвертированы в возвращаемый HRESULT. Подробнее об этом - опять-таки в статье.

Но это ещё не всё. Вызов метода:
var
  I: IInit;
...
  I.Init(nil);
на самом деле трактуется компилятором как:
CheckAutoResult(I.Init(nil));
Здесь встроенная функция CheckAutoResult возбуждает исключение, если метод Init вернул неудачный код HRESULT.

Иными словами. Если исключение возникает в методе, то при переходе через границу модулей оно автоматически конвертируется в код ошибки, а затем собирается обратно. Автоматически. Безопасно. Совместимо с другими языками. Не нужно писать никакого кода. Короче говоря, safecall - это чрезвычайно удобная штука :D

Что касается совместимости, то вы можете просто всем говорить: мой метод Init - это функция типа stdcall, возвращающая HRESULT (ведь так оно и есть). И любой программист сможет его вызвать в своей среде, на своём языке (хоть и без удобств, но всё же сможет). Но при этом (если он опытный программист) он может сказать: "ага! Да это же safecall!" (ну или аналог safecall в том языке, что он использует). И будет использовать у себя метод Init как safecall - со всеми удобствами.

Далее, на самом деле safecall можно использовать не только с методами, но и с функциями. Что приводит нас... да, к нашей _GetPluginInitInterface. Если вы помните, первая попытка её реализации использовала системный механизм кодов ошибок - весьма ограниченный инструмент. Теперь, когда мы всерьёз подошли к вопросам обработки ошибок, настало время сделать её как следует.

Для этого, нам нужно обратить своё внимание на тот факт, что в этой функции у нас инициализируется сама RTL Delphi в нашем плагине (наша функция _Init). Что это значит? Вспомним, что во-первых, вызов _Init приводит к тому, что будут выполняться секции initialization модулей. А код, который там расположен, может возбуждать исключение. Например:
...

initialization
  if { какое-то условие проверки того, что плагин можно использовать } then
    raise EUnableToUseThisPlugin.Create('Устройство, которым должен управлять этот плагин, не подключено.');

end.
Во-вторых, возбуждение исключения - это создание объекта Delphi. В-третьих, при возникновении исключения при выполнении секций initialization _Init (а вернее, функция Initialize пакета) автоматически вызывает и секции finalization.

Что это значит?

Это значит, что либо после выхода из _Init мы получаем полностью инициализированную RTL или вообще не инициализированную RTL + активное исключение. Вопрос: как же мы будем освобождать объект Delphi, представляющий собой исключение, если RTL не активна (а значит не работает и менеджер памяти)?

Прежде чем ответить на этот вопрос, можно ещё почитать об обработке исключений вообще (позже здесь будут ссылки на мою статью). В частности, нас интересует тот факт, что в модуле SysUtils есть такие строчки:
...

procedure ExceptHandler(ExceptObject: TObject; ExceptAddr: Pointer); far;
begin
  ShowException(ExceptObject, ExceptAddr);
  Halt(1);
end;

...

procedure DoneExceptions;
begin
  ...

  if (ExceptObject <> nil) and not (ExceptObject is EAbort) then
    ExceptHandler(ExceptObject, ExceptAddr);
end;

...

finalization
...
  DoneExceptions;

end;
Если говорить словами, то модуль SysUtils гарантирует, что при выгрузке модуля не останется необработанных исключений. Как он это делает? Показывая сообщение об ошибке и вызывая Halt (а следовательно и ExitProcess).

Не самое подходящее нам поведение, не так ли? Мы грузим плагин, там возникает исключение, SysUtils плагина грохает нам весь процесс.

Что нам нужно сделать? Ну, не допустить вызова ExceptHandler из секции finalization модуля SysUtils. Вспомним, что мы можем добавить секцию finalization и в свой модуль! А в ней мы можем вызвать AquireExceptionObject (это очистит ExceptObject и условие в DoneExceptions станет ложным).

Это работает так: в секции initialization одного из модулей плагина возбуждается исключение, _Init/Initialize начинают вызовы секций finalization модулей, доходит очередь до нашего модуля - он сбрасывает исключение вызовом AquireExceptionObject (заодно освобождая объект и устанавливая признак "ошибка"), доходит очередь до SysUtils - он завершается как обычно, затем идёт модуль System - в нём закрывается менеджер памяти (*).

Именно поэтому мы освобождаем объект в finalization своего модуля - потом такой возможности уже не будет, ведь менеджер памяти будет закрыт до выхода из _Init.

К нашему счастью, мы можем использовать try/except/finally и без поддержки RTL языка. Эти слова - просто интерфейс к системному механизму SEH и он работает вне зависимости от RTL (ну, строго говоря, не совсем так, но для первого приближения сойдёт).

Так, с инициализацией вроде понятно: мы не должны выпустить исключение за блок кода от инициализации RTL до её завершения. Что касается штатного завершения работы RTL, то тут тоже есть особенности. Предполагается, что завершение работы будет происходить в методе Done интерфейса IInit. Казалось бы, всё хорошо, Done - это safecall метод, гарантирующий красивую обработку исключений. Однако, нужно осознать тот факт, что в момент вызова метода Done у нас имеется как минимум один живой объект из плагина - это объект, реализующий интерфейс IInit. Это значит, что мы не можем в момент вызова Done вызывать нашу _Done. Ведь тогда завершит работу менеджер памяти, но у нас остаётся не освобождённый объект.

Но эта ситуация не является неразрешимой. Здесь нужно воспользоваться тем фактом, что перед выгрузкой плагина все интерфейсные ссылки будут освобождены. Иными словами, в конце работа происходит примерно так:
...
  Intf.Done;
  Intf := nil;       // <- это последняя ссылка, здесь идёт вызов _Release (**) 
  FreeLibrary(LibHandle);
Иными словами: вызов _Done следует помещать в _Release объекта, реализующего IInit.

Исключения, возникающие (потенциально) в секциях finalization модулей при таком штатном выходе, обрабатываются точно так же, как и в случае с _Init: секция finalization в нашем модуле очищает исключение до того, как будет финализирован модуль System.

Уф. Ну вроде всё.

Получается при этом примерно следующее (приведены только части, ответственные за обработку ошибок при запуске/финализации плагина):
var
  WasError: Boolean;

// Блокирует Halt в SysUtils.ExceptHandler и 
// предотвращает утечку памяти на объект исключения
procedure DoneExceptions;
begin
  // Есть исключение!
  if ExceptObject <> nil then
  begin
    // Исключение сейчас будет удалено, а тут поставим признак ошибки
    WasError := True;

    // Показывает сообщение об ошибке - ровно как и стандартный SysUtils.ExceptHandler
    ShowException(ExceptObject, ExceptAddr);
    // <- здесь: в SysUtils стоял Halt, мы его заменяем на:

    // Освобождаем объект исключения и одновременно чистим ExceptObject
    TObject(AcquireExceptionObject).Free;
    ReleaseExceptionObject;
  end;
end;

// Точка входа в плагин. 
// Из-за ручной обработки исключений мы не можем здесь воспользоваться магией
// safecall, поэтому записываем функцию в альтернативной форме
function GetPluginEntryPoint(const AParams: IInterface; out Intf: IInit): HRESULT; stdcall;
// вместо function(const AInitParams: IInterface): IInit; safecall;
begin
  WasError := False;
  Intf := nil;
  if not ModuleIsPackage then
  begin
    Result := HResultFromWin32(ERROR_INVALID_MODULETYPE);
    Exit;
  end;
  TInit.FParams := AParams;
  try
    // Вызываем инициализацию пакета
    _Init;
  except 
    // в случае исключения 'Initialize' в _Init вызывает секции finalization
    // модулей => наша DoneException в этом модуле обработает исключение - 
    // покажет сообщение и удалит объект.

    WasError := True;
    // Очищается ссылка на объект (удалён в DoneExceptions) - предотвращает двойное удаление объекта
    AcquireExceptionObject;
    ReleaseExceptionObject;
  end;
  // Было ли исключение в _Init?
  if WasError then
  begin
    WasError := False;
    Result := HResultFromWin32(ERROR_DLL_INIT_FAILED);
    Exit;
  end;

  // Пакет был успешно инициализирован. RTL сейчас полностью работает.
  try
    // Создаём реализацию интерфейса
    TInit.FParams := nil;
    // ... здесь могут быть ещё действия ...
    Intf := TInit.Create;
    // ... здесь могут быть ещё действия ...
    Result := 0;
  except 
    // Поскольку RTL полностью работает - мы используем обычную обработку исключений
    ShowException(ExceptObject, ExceptAddr);
    Result := HResultFromWin32(ERROR_DLL_INIT_FAILED); 
    // но исключение не должно убежать - поэтому конвертируем его в код ошибки
  end;

  // Была ошибка при работе после _Init?
  if Failed(Result) then
  begin
    // Если интерфейс уже был создан - _Done вызовется при его освобождении, 
    // а если нет - то надо вызвать явно
    if Intf = nil then
      _Done
    else
      Intf := nil;           // Здесь _Done будет вызвано из _Release
    Result := HResultFromWin32(ERROR_DLL_INIT_FAILED);
  end;
end;

...

function TInit._Release: Integer;
begin
  Result := InterlockedDecrement(FRefCount);
  if Result = 0 then
  begin
    // Удаление реализации IInit
    try
      try
        Destroy;          // Удаляем сам объект типа TInit
      finally
        _Done;            // Финализируем пакет 
        // (все интерфейсы должны быть удалены к этому моменту)
      end;
    except 
      // _Done вызывает секции finalization => 
      // в случае исключения наш DoneException всё подчистит.

      // Очищаем ссылку на объект исключения, 
      // препятствуя повторному вызову деструктора
      AcquireExceptionObject;
      ReleaseExceptionObject;
    end;
    // Сразу после выхода отсюда в сервере будет вызвана FreeLibrary
  end;
end;

...

initialization
  // ничего не делает

finalization
  DoneExceptions; // чистим исключения

end.

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

Примечания:
(*) Упражнение: почему мы можем говорить именно о таком порядке выполнения секций finalization модулей? Кто и как гарантирует нам порядок их выполнения?

(**) Упражнение: почему после IInit.Done остаётся только одна ссылка? Не может ли быть так, что сперва освободится IInit, а потом - какой-то ещё интерфейс плагина, который сервер использовал в своей работе? Если такое возможно, то наша схема не будет работать (почему?)!

Комментариев нет :

Отправить комментарий

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

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

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

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

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