Сообщений 5    Оценка 246        Оценить  
Система Orphus

Использование COM из DLL незаметно для клиента

Автор: Сергей Холодилов
The RSDN Group

Источник: RSDN Magazine #1-2003
Опубликовано: 06.12.2002
Исправлено: 10.12.2016
Версия текста: 1.1
Задача
Проблемы
Уточнение задачи
Решение
Код
Серверы
Клиент
DLL
Статистика
P.S. Даже Microsoft делает это…
Благодарности

Демонстрационный проект

Однажды, когда я писал DLL, передо мной встала элементарная, на первый взгляд, задача: создать и использовать COM объект. Но оказалось, что это не так-то просто. Основные проблемы заключались в том, что клиент моей DLL ничего не знал о COM, поэтому не собирался его инициализировать, зато имел кучу потоков, каждый из которых периодически вызывал функции DLL. Как мне кажется, после некоторых размышлений, экспериментов (и консультаций в форуме RSDN COM/DCOM/COM+) было найдено общее и достаточно красивое решение, которое и описано в этой статье.

Задача

Необходимо написать DLL, не предъявляющую к клиенту никаких требований, связанных с COM. Подобная необходимость возникает, если уже существует несколько различных клиентов, использующих DLL с подобным интерфейсом, а вам нужно подменить её. Или, если вы пишете plug-in к чему-либо.

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

Проблемы

Уточнение задачи

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

Требуется создать DLL, экспортирующую следующие функции:

      void Init();
void Cleanup();

void SomeFunc(int I);
СОВЕТ

Если в интерфейсе вашей DLL нет функций, подобных Init и Cleanup, их можно вызывать неявно. То есть, если SomeFunc обнаруживает, что Init ещё не была вызвана, она вызывает её сама. А Cleanup можно вызывать из DllMain. При использовании описанной ниже реализации Init вызов Init из DllMain приведёт к взаимной блокировке потоков.

Кроме этого есть COM-компонент CoolServer, реализующий интерфейс ICoolServer, который содержит SomeFunc. Если бы COM был везде инициализирован, и не было бы проблем с апартаментами, нужно было бы реализовать DLL примерно так:

ICoolServer* pCS = NULL;

extern «С» __declspec(dllexport) void Init()
{
  pCS = CoCreateInstance(..);
}

extern «С» __declspec(dllexport) void Cleanup()
{
  pCS->Release()
  pCS = NULL;
}

extern «С» __declspec(dllexport) void SomeFunc(int i)
{
  pCS->SomeFunc(i);
}

То есть нужно использовать только один указатель на интерфейс, полученный при инициализации библиотеки и освобождаемый при очистке. Если можно создавать объект в SomeFunc и там же его уничтожать, то функции Init и Cleanup можно убрать, проблемы с апартаментами тоже исчезнут. Случай, когда экземпляр COM-объекта нужно сохранять между вызовами, более интересен.

Итак, нужно чтобы:

ПРИМЕЧАНИЕ

Деинициализировать COM важно! Возможный сценарий: клиент вызвает функцию из DLL, она инициализирует COM, не чистит за собой, после этого клиент пытается самостоятельно инициализировать COM и обламывается, так как хочет сделать это немного не так (предпочитает STA, а не MTA, или наоборот; эти клиенты такие непредсказуемые...).

Решение

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

СОВЕТ

Дополнительный поток нужен для гарантии существования созданных в нём объектов между вызовами функций. Поэтому, если между вызовами функций DLL вам не нужно хранить указатели на интерфейсы, не стоит возиться с дополнительным потоком, Init-ом и Cleanup-ом, проще создавать и удалять объекты в тех функциях, которые их используют.

В псевдокоде это выглядит так:

      void Init()
{
    // запускает поток InitCleanupThread// ждёт завершения инициализации
}

void Cleanup()
{
    // сообщает, что можно чистить// ждёт завершения очистки
}

DWORD CALLBACK InitCleanupThread(LPVOID)
{
    // инициализирует COM// создаёт объект// каким-то образом делает полученный указатель// на интерфейс доступным из других апартментов// сообщает о завершении инициализации// ждет разрешения всё почистить// освобождает ссылку на объект// деинициализирует COM
}
ПРИМЕЧАНИЕ

Несколько моментов, которые нужно учесть, чтобы это работало:

При ожидании завершения инициализации в функции Init необходимо запустить цикл выборки сообщений. Иначе клиент, который имел неосторожность инициализировать COM и войти в STA до вызова Init повиснет в InitCleanupThread при попытке создать объект.

При создании объекта в InitCleanupThread очень желательно, чтобы потоковая модель объекта позволяла создать его не в главном STA. Иначе, если главный STA уже существует, объект создастся в нём, и, соответственно, при уничтожении главного STA (вызове CoUninitialize соответствующим потоком) объект тоже исчезнет. Обращение по отмаршаленному указателю на интерфейс объекта будет возвращать RPC_E_SERVER_DIED_DNE.

Поток InitCleanupThread должен входить в MTA. Иначе он может случайно оказаться главным STA, в нём будут создаваться объекты клиента, и когда все объекты умрут вслед за потоком (а их методы начнут возвращать RPC_E_SERVER_DIED_DNE), клиент будет очень удивлён. Даже, я бы сказал, ошарашен. Он ведь и о существовании потока не догадывается...

Если по каким-то причинам поток InitCleanupThread всё же входит в STA, то ждать очистки он должен в цикле выборки сообщений.

Для выполнения третьего требования можно поступить так:

1. Попытаться инициализировать COM.

если CoInitialize[Ex] вернула ошибку RPC_E_CHANGED_MODE, то COM уже был инициализирован, просто немного не так.

если CoInitialize[Ex] вернула S_OK, то COM успешно инициализировался.

если CoInitialize[Ex] вернула S_FALSE, то COM уже был инициализирован, причём с теми же параметрами.

если CoInitialize[Ex] вернула что-то другое, произошло что-то ужасное.

2. Получить допустимый для использования в данном апартаменте указатель на нужный интерфейс. Метод получения указателя может зависеть от результата вызова CoInitialize[Ex].

3. Вызвать метод SomeFunc.

4. Если CoInitialize[Ex] вернула S_OK или S_FALSE, деинициализировать COM.

Или так:

1. Попытаться получить допустимый для использования в данном апартменте указатель на нужный интерфейс.

2. Инициализировать COM.

3. Получить допустимый для использования в данном апартаменте указатель на нужный интерфейс.

4. Вызвать метод SomeFunc.

5. Если выполнялся пункт 2, деинициализировать COM.

ПРЕДУПРЕЖДЕНИЕ

Теоретически, этот способ должен работать. Но при тестировании (в Windows 2000 Professional SP2) мною замечены следующие глюки:

Если поток InitCleanupTheard входит в MTA, то, независимо от того, был ли инициализирован COM в текущем потоке, функции CoUnmarshalInterface и IGlobalInterfaceTable::GetInterfaceFromGlobal возвращают мне S_OK. При этом вызов метода SomeFunc работает. Но гарантий, что он будет работать всегда, я не дам... Для чего-то же, наверное, всё-таки нужно инициализировать COM?

Если поток InitCleanupTheard входит в STA (так, конечно, делать нельзя, но мне было интересно...), то IGlobalInterfaceTable::GetInterfaceFromGlobal возвращает E_UNEXPECTED, до тех пор, пока хоть раз не выполнится последовательность CoInitialize[Ex] + IGlobalInterfaceTable::GetInterfaceFromGlobal. После этого она, как положено, будет возвращать CO_E_NOTINITIALIZED.

Для практического применения не рекомендуется.

Получить «допустимый для использования в данном апартаменте указатель на нужный интерфейс» можно тремя путями:

ПРИМЕЧАНИЕ

Применение CoMarshalInterface/CoUnmarshalInterface нуждается в нескольких комментариях:

CoMarshalInterface лучше вызывать с флагом MSHLFLAGS_TABLESTRONG. Потому что с MSHLFLAGS_TABLEWEAK иногда не работает. Честно говоря, пока не разобрался почему...

Перед вызовом CoUnmarshalInterface необходимо отмотать IStream на начало. То есть что-то такое:

LARGE_INTEGER li = {0};

pStream->Seek(li, STREAM_SEEK_SET, NULL);

...

Иначе работать не будет. И, кстати, не надейтесь, что Seek когда-нибудь вернёт CO_E_NOTINITIALIZED, внутри – чисто механическая операция. Хотя проверить, конечно, не вредно...

Последний метод применим только если поток, создавший объект (InitCleanupThread), и поток, желающий этот объект использовать, принадлежат одному апартаменту, то есть оба входят в MTA; первые два метода применимы в любом случае.

Код

В качестве примера я написал два сервера (один – exe, другой – DLL), две DLL (на каждый сервер по одной) и клиента с набором тестов.

Серверы

Оба сервера реализуют следующий интерфейс:

interface ICoolServer : IUnknown
{
	HRESULT SomeFunction();
	HRESULT WorkFunction([in] BSTR str);
};

SomeFunction просто возвращает S_OK, WorkFunction выводит переданную ей строку (сервер в DLL – через printf, сервер в exe – через MessageBox). Первая используется для сравнения быстродействия различных методов вызова, вторая для проверки их принципиальной работоспособности.

Как обычно, серверы нужно зарегистрировать. При этом они оба пытаются зарегистрировать свои библиотеки типов. Оба сервера предполагают, что библиотека называется «iface.tlb» и лежит в том же каталоге, что и исполняемый файл сервера.

Клиент

Клиент, в зависимости от значения в командной строке (цифра от 0 до 6, отсутствие аргументов эквивалентно «0»), выполняет один из тестов. Тест «0» проверяет функциональность, остальные позволяют оценить производительность. Их результаты приведены в разделе «Статистика».

DLL

Отличаются друг от друга только именами экспортируемых функций и GUID-ами создаваемых объектов. Теоретические построения из раздела "Решение" реализованы следующим образом:

Реализованные методы:

ICoolServer* GetPointer_1(bool* init)
{
  // Пытаемся инициализировать COM
  HRESULT hr = CoInitializeEx(0, COINIT_MULTITHREADED);

  if (hr == RPC_E_CHANGED_MODE)
  {
    // Попытка не удалась, COM уже был инициализирован, но по-другому. // Нужен маршалинг
    LARGE_INTEGER li = {0};
    pStr->Seek(li, STREAM_SEEK_SET, NULL); // Стрим – в начало!

    ICoolServer* marsh;
    CoUnmarshalInterface(pStr, __uuidof(ICoolServer), (void**)&marsh); 

    *init = false;
    return marsh;
  }
  elseif (FAILED(hr))
  {
    // Попытка не удалась, причины не известны
    *init = false;
    return NULL;
  }

  // Либо нам удалось инициализировать COM, // либо он уже был инициализирован точно так же.// В любом случае мы в MTA, можно смело использовать глобальный указатель.
  *init = true;
  pCS->AddRef();
  return pCS;
}
ICoolServer* GetPointer_2(bool* init)
{
  LARGE_INTEGER li = {0};
  pStr->Seek(li, STREAM_SEEK_SET, NULL); // Стрим – в начало!

  ICoolServer* marsh;
  // Попытка маршалинга глобального указателя
  HRESULT hr = CoUnmarshalInterface(pStr, __uuidof(ICoolServer), (void**)&marsh);

  if (hr == CO_E_NOTINITIALIZED)
  {
    // Практика показывает, что сюда мы никогда не попадём. :(// Не удалось, так как не инициализирован COM. Ну так инициализируем его.
    CoInitializeEx(0, COINIT_MULTITHREADED);
    
    // Мы в MTA, используем глобальный указатель
    *init = true;
    pCS->AddRef();
    return pCS;
  }
  elseif (FAILED(hr))
  {
    // Непонятная ошибка
    *init = false;
    return NULL;
  }

  // Получилось!
  *init = false;
  return marsh;
}
ICoolServer* GetPointer_3(bool* init)
{
  // Пытаемся инициализировать COM
  HRESULT hr = CoInitializeEx(0, COINIT_MULTITHREADED);

  if (hr == RPC_E_CHANGED_MODE)
  {
    // Попытка не удалась, COM уже был инициализирован, но по-другому. // Нужен маршалинг.
    ICoolServer* marsh;
    pGIT->GetInterfaceFromGlobal(magic, __uuidof(ICoolServer), (void**)&marsh); 

    *init = false;
    return marsh;
  }
  elseif (FAILED(hr))
  {
    // Попытка не удалась, причины не известны
    *init = false;
    return NULL;
  }

  // Либо нам удалось инициализировать COM, // либо он уже был инициализирован точно так же.// В любом случае мы в MTA, можно смело использовать глобальный указатель
  *init = true;
  pCS->AddRef();
  return pCS;
}
ICoolServer* GetPointer_4(bool* init)
{
  ICoolServer* marsh;
  // Попытка маршалинга глобального указателя.
  HRESULT hr = pGIT->GetInterfaceFromGlobal(magic, __uuidof(ICoolServer),
 (void**)&marsh);

  if (hr == CO_E_NOTINITIALIZED)
  {
    // Практика показывает, что и сюда мы никогда не попадём. :(// Не удалось, так как не инициализирован COM. Ну так инициализируем его.
    CoInitializeEx(0, COINIT_MULTITHREADED);
    
    // Мы в MTA, используем глобальный указатель
    *init = true;
    pCS->AddRef();
    return pCS;
  }
  elseif (FAILED(hr))
  {
    // Непонятная ошибка. А вот сюда попасть можем.
    *init = false;
    return NULL;
  }

  // Получилось!
  *init = false;
  return marsh;
}
ПРИМЕЧАНИЕ

Параметр init устанавливается в true, если была вызвана и успешно отработала CoInitializeEx и, следовательно, перед возвратом из DLL необходимо вызвать CoUninitialize.

Статистика

Скорее всего, после того, как вы прочитали, что за каждый вызов придётся платить инициализацией/деинициализацией COM или чем-то подобным, вы задумались над вопросом: сколько это стоит? Меня это тоже интересовало, я поставил несколько экспериментов.

Всё эксперименты проводились следующим образом:

Собственно, результаты:

COM не инициализирован COINIT_APARTMENTTHREADED COINIT_MULTITHREADED
Количество повторов 50000 50000 50000
Напрямую, мс - 0 3114
One, мс 3562 321 3184
Two, мс 4236 330 3966
Three, мс 3435 121 3195
Four, мс 3565 110 3495
Таблица 2. Статистика по inproc-серверу, компонент можно создавать в любом STA.
COM не инициализирован COINIT_APARTMENTTHREADED COINIT_MULTITHREADED
Количество повторов 50000 1000 50000
Напрямую, мс - 0 3025
One, мс 3295 3856 3355
Two, мс 3945 3875 4396
Three, мс 3355 3896 3385
Four, мс 3455 3886 3695
Таблица 3. Статистика по inproc-серверу, компонент можно создавать только в MTA.
COM не инициализирован COINIT_APARTMENTTHREADED COINIT_MULTITHREADED
Количество повторов 100000 1000 300000
Напрямую, мс - 60 10
One, мс 200 4166 200
Two, мс 671 4186 2043
Three, мс 190 4236 151
Four, мс 110 4126 320
Таблица 4. Статистика по outproc-серверу.
COM не инициализирован COINIT_APARTMENTTHREADED COINIT_MULTITHREADED
Количество повторов 10000 1000 10000
Напрямую, мс - 280 831
One, мс 971 4076 892
Two, мс 1102 4066 1221
Three, мс 921 4016 932
Four, мс 891 4015 1051

Выводы делайте сами.

ПРИМЕЧАНИЕ

Ну, немного я помогу :) Данные в колонках «COINIT_APARTMENTTHREADED» и «COINIT_MULTITHREADED» были получены примерно так:

CoInitializeEx(…);

xx_Init()

То есть COM инициализировался до вызова xx_Init. Это объясняет странные результаты в колонке «COINIT_APARTMENTTHREADED» первой таблицы. В этом случае основной поток инициализировал COM первым, вследствии чего вошёл в главный STA, и объект CoolServer создался в нём. Поэтому последующие вызовы SomeFunction проходили напрямую.

P.S. Даже Microsoft делает это…

Вы никогда не задумывались над вопросом, как реализована функция ShellExecute[Ex]? С одной стороны, она не требует предварительной инициализации COM, а с другой, явно его использует… Я вот не задумывался. А оказывается, в ней применяется похожий подход.

Если в Windows 2000 Professional SP2 залезть WinDbg-ом в ShellExecuteExW, можно увидеть примерно следующее (99% «деталей» я опустил для большей ясности):

SHELL32!ShellExecuteExW:

Регистры – в стек.

7831bc75 e8e739feff       call    SHELL32!SHCoInitialize (782ff661)

Проверка корректности переданного указателя (кстати, даже если выясняется, что указатель некорректен, функция пытается записать в поле hInstApp код ошибки).

Анализ поля fMask, различные пороверки в зависимости от его значения. Заканчивается одним из следующих вариантов:
  call SHELL32!ShellExecuteNormal (7831bd28)
  call SHELL32!_ShellExecPidl (78343366)
  call dwordptr [SHELL32!_imp__SetLastError (782f18dc)]
 
Обработка возможных ошибок.

7831bcea e8d039feff       call    SHELL32!CoUninitialize (782ff6bf)

Регистры - из стека.

А функция SHCoInitialize выглядит так:

SHELL32!SHCoInitialize:
782ff661 56           push esi
782ff662 8b35acf62f78 mov  esi,[SHELL32!_imp__CoInitializeEx (782ff6ac)]
782ff668 6a06         push 0x6    COINIT_APARTMENTTHREADED +
                                  COINIT_DISABLE_OLE1DDE
782ff66a 6a00         push 0x0 
782ff66c ffd6         call esi    Вызов CoInitializeEx
782ff66e 85c0         test eax,eax
Если функция вернула значение меньше 0, т.е. ошибку
782ff670 0f8c24211000 jl   SHELL32!SHCoInitialize+0x11 (7840179a)   -----    
782ff676 5e           pop  esi                             <------------|----
782ff677 c3           ret                                               |   |
                                                                        |   |
Разный код…                                                             |   |
                                                                        |   |
7840179a 6a04         push 0x4    COINIT_MULTITHREADED +       <---------   |
                                  COINIT_DISABLE_OLE1DDE                    |
7840179c 6a00         push 0x0                                              |
7840179e ffd6         call esi    Вызов CoInitializeEx с новыми параметрами |
784017a0 e9d1deefff   jmp  SHELL32!SHCoInitialize+0x17 (782ff676)    --------

То есть, если ей не удалось инициализировать COM с параметром COINIT_APARTMENTTHREADED, она пытается инициализировать его с COINIT_MULTITHREADED. Если не произойдёт ничего неожиданного, то после выполнения этой функции можно:

ShellExecuteExW так и поступает.

Благодарности

Во время написания кода, послужившего основой и для этого текста и для привёдённого примера, мне очень помог Vi2 (Виктор Шарахов). Большое ему спасибо. Второе спасибо – Павлу Блудову, обратившему моё внимание на функцию ShellExecute[Ex].


Эта статья опубликована в журнале RSDN Magazine #1-2003. Информацию о журнале можно найти здесь
    Сообщений 5    Оценка 246        Оценить