Разработка COM-серверов в среде Windows

Этот курс посвящен созданию компонентов на основе технологии Component Object Model (COM) с использованием C++ и Windows API. Вы изучите архитектуру COM, создание интерфейсов через IDL, управление памятью и регистрацию серверов в системе.

1. Основы архитектуры COM: интерфейс IUnknown, GUID и подсчет ссылок

Основы архитектуры COM: интерфейс IUnknown, GUID и подсчет ссылок

Добро пожаловать в курс «Разработка COM-серверов в среде Windows». Это первая статья, в которой мы заложим фундамент для понимания одной из самых долгоживущих и фундаментальных технологий Microsoft — Component Object Model (COM).

Многие современные технологии, такие как DirectX, Windows Shell, OLE и даже части .NET Framework, базируются на принципах COM. Понимание того, как это работает «под капотом», превратит вас из простого пользователя библиотек в инженера, понимающего глубинные процессы операционной системы.

Что такое COM и зачем он нужен?

Представьте, что вы строите дом. У вас есть кирпичи от одного завода, окна от другого и двери от третьего. Чтобы дом собрался, все эти элементы должны подходить друг к другу по размерам и креплениям. В программировании долгое время существовала проблема: библиотека, написанная на C++, не могла быть просто так использована в программе на Delphi или Visual Basic без сложных «прослоек».

COM (Component Object Model) — это стандарт, который определяет, как объекты взаимодействуют друг с другом. Главная особенность COM заключается в том, что это бинарный стандарт. Это означает, что COM определяет не то, как писать код, а то, как скомпилированный объект должен выглядеть в памяти и как вызывать его функции.

!Иллюстрация того, как разные языки программирования могут взаимодействовать с одним и тем же COM-объектом благодаря единому стандарту.

Благодаря этому стандарту:

  • Клиент (программа) не знает, на каком языке написан сервер (компонент).
  • Компоненты можно обновлять, не перекомпилируя программу-клиент (при соблюдении правил версионности).
  • Объекты могут взаимодействовать даже через границы процессов или по сети (DCOM).
  • Интерфейсы: Контракт взаимодействия

    В мире COM вы никогда не имеете прямого доступа к самому объекту (к его полям данных или внутренней структуре). Всё взаимодействие происходит исключительно через интерфейсы.

    Интерфейс в COM — это контракт. Это группа связанных функций, которые обязуется реализовать компонент. Если объект заявляет, что он поддерживает интерфейс «Рисование», это гарантирует, что у него есть методы для рисования, и они находятся в строго определенных местах памяти.

    Особенности интерфейсов COM:

    * Неизменность: После публикации интерфейса его нельзя менять. Нельзя добавить метод или поменять аргументы. Можно только создать новый интерфейс (например, IDrawing2). * Наследование: Все интерфейсы COM наследуются от базового интерфейса IUnknown (о нем мы поговорим ниже).

    GUID: Уникальные имена во Вселенной

    Как назвать интерфейс так, чтобы это имя не совпало с именем другого интерфейса, созданного программистом на другом конце света? Использовать простые строки типа "IMyInterface" нельзя — вероятность коллизии (совпадения) слишком высока.

    COM решает эту проблему с помощью GUID (Globally Unique Identifier) — глобально уникального идентификатора. В терминологии COM идентификаторы интерфейсов часто называют IID (Interface ID), а идентификаторы классов — CLSID (Class ID).

    GUID представляет собой 128-битное число. Обычно оно записывается в шестнадцатеричном виде, например: {00000000-0000-0000-C000-000000000046}.

    Давайте оценим вероятность совпадения двух случайно сгенерированных GUID. Общее количество возможных комбинаций составляет:

    Где — общее количество уникальных идентификаторов, а — это число два в степени сто двадцать восемь.

    Это число примерно равно:

    Где — количество вариантов, — мантисса числа, а — десять в тридцать восьмой степени.

    Чтобы понять масштаб: если генерировать 1 миллиард GUID в секунду, то потребуется много миллионов лет, чтобы вероятность совпадения стала хоть сколько-то значимой. Поэтому мы считаем GUID уникальными.

    IUnknown: Корень всего

    Каждый COM-интерфейс обязан наследоваться от интерфейса IUnknown. Это «прародитель» всех объектов в COM. Если у вас есть указатель на COM-объект, вы гарантированно имеете доступ к трем методам IUnknown.

    Вот как выглядит определение IUnknown на языке C++:

    Разберем каждый метод подробно.

    1. QueryInterface: Навигация по возможностям

    Метод QueryInterface (часто сокращают до QI) — это механизм, с помощью которого клиент спрашивает у объекта: «А ты умеешь делать вот это?». Или, технически: «Есть ли у тебя указатель на такой-то интерфейс?».

    Вы передаете в этот метод IID (GUID интерфейса), который хотите получить. Если объект поддерживает этот интерфейс, он возвращает указатель на него и сообщает об успехе (S_OK). Если нет — возвращает ошибку (E_NOINTERFACE).

    Это позволяет объектам быть многогранными. Один и тот же объект может поддерживать интерфейс для сохранения данных на диск и интерфейс для отображения на экране.

    2. Управление жизнью объекта: AddRef и Release

    В C++ мы привыкли использовать new и delete. Но в COM клиент не знает, как именно выделена память под объект, и не имеет права её освобождать напрямую. Более того, один объект может использоваться сразу несколькими частями программы.

    Для решения этой проблемы используется подсчет ссылок (Reference Counting).

    * AddRef(): Увеличивает счетчик ссылок на 1. Вызывается, когда создается новая копия указателя на интерфейс. * Release(): Уменьшает счетчик ссылок на 1. Вызывается, когда указатель больше не нужен.

    !Диаграмма, показывающая изменение счетчика ссылок при подключении и отключении клиентов.

    Золотое правило COM: Когда счетчик ссылок достигает нуля, объект обязан удалить себя из памяти.

    Пример логики внутри метода Release:

    Виртуальная таблица (vtable): Бинарная структура

    Почему COM работает между языками? Потому что он опирается на структуру виртуальной таблицы функций (vtable), которая стандартна для большинства компиляторов C++.

    Указатель на интерфейс — это на самом деле указатель на указатель на массив адресов функций.

    Структура в памяти выглядит так:

  • У вас есть указатель на интерфейс (например, pUnknown).
  • Этот указатель указывает на начало области памяти объекта.
  • Первым элементом в объекте лежит указатель на vtable (vptr).
  • vtable — это массив, где лежат адреса функций (QueryInterface, AddRef, Release и другие).
  • Когда вы вызываете метод pUnknown->AddRef(), компилятор генерирует код, который:

  • Идет по указателю pUnknown.
  • Берет vptr.
  • Смотрит второй элемент в массиве vtable (индекс 1, так как AddRef второй в списке после QueryInterface).
  • Переходит по этому адресу и выполняет код.
  • Любой язык, который умеет работать с указателями и вызывать функции по адресу (C, C++, Pascal, Rust, и даже Python через ctypes), может работать с COM.

    Заключение

    Мы разобрали три кита, на которых стоит COM:

  • Интерфейсы отделяют реализацию от использования.
  • GUID обеспечивают глобальную уникальность имен.
  • IUnknown предоставляет базовый механизм навигации (QueryInterface) и управления памятью (AddRef/Release).
  • Понимание этих концепций критически важно. В следующей статье мы перейдем от теории к практике и напишем наш первый простейший COM-сервер, реализовав эти методы вручную.

    2. Проектирование интерфейсов с помощью IDL и компиляция через MIDL

    Проектирование интерфейсов с помощью IDL и компиляция через MIDL

    В предыдущей статье мы разобрали фундамент COM: интерфейс IUnknown, глобально уникальные идентификаторы (GUID) и механизм подсчета ссылок. Мы выяснили, что интерфейс — это контракт. Но как написать этот контракт так, чтобы его поняли и C++ программист, и разработчик на Visual Basic, и скрипт на Python?

    Здесь на сцену выходит IDL (Interface Definition Language) — язык описания интерфейсов. В этой статье мы научимся говорить на этом «эсперанто» мира Windows и узнаем, как превратить текстовое описание в рабочий код с помощью компилятора MIDL.

    Зачем нам нужен отдельный язык IDL?

    Казалось бы, почему нельзя просто написать заголовочный файл на C++ (.h) и использовать его? Проблема кроется в деталях реализации языков программирования:

  • Разные типы данных: То, что в C++ называется int, на разных архитектурах может занимать разное количество байт. В Visual Basic строки хранятся иначе, чем в C.
  • Управление памятью: Кто должен освобождать память под массив, переданный в функцию — вызывающий код или сама функция?
  • Маршалинг: Если ваш COM-объект находится в другом процессе (или на другом компьютере), системе нужно знать, как «упаковать» параметры функции, чтобы передать их по сети. Обычный C++ код не содержит этой информации.
  • IDL решает эти проблемы. Это декларативный язык, который не содержит исполняемого кода. Он описывает только сигнатуры методов и типы данных в формате, независимом от языка реализации.

    !IDL выступает как универсальный чертеж, по которому разные языки строят свои интерфейсы.

    Анатомия IDL-файла

    Файл IDL — это текстовый файл с расширением .idl. Его синтаксис напоминает C++, но с добавлением специальных атрибутов в квадратных скобках [].

    Рассмотрим простейший пример:

    Разберем этот код построчно.

    1. Импорт определений

    Строки import "oaidl.idl"; работают аналогично #include в C++. Они подключают стандартные определения типов COM, такие как IUnknown, BSTR, VARIANT и другие.

    2. Атрибуты

    В IDL всё, что находится в квадратных скобках [] перед ключевым словом, является атрибутами, описывающими это ключевое слово.

    * object: Указывает, что это COM-интерфейс, а не интерфейс в стиле DCE RPC (старый стандарт удаленного вызова процедур). * uuid(...): Тот самый GUID, который делает интерфейс уникальным. * helpstring(...): Строка описания, которую увидят программисты в браузерах объектов (например, в Visual Studio). * pointer_default(unique): Указывает стратегию работы с указателями по умолчанию (важно для оптимизации сетевых вызовов).

    3. Определение интерфейса

    interface IMyCalculator : IUnknown означает, что мы создаем новый интерфейс, который наследуется от IUnknown. Это обязательное требование для всех COM-объектов.

    Направленные атрибуты: [in] и [out]

    Самая важная часть IDL для понимания работы системы — это атрибуты параметров методов. В C++ мы привыкли смотреть на const или ссылки, чтобы понять, меняется ли переменная. В COM мы обязаны явно указать направление передачи данных.

    Это критически важно для маршалинга. Маршалер — это компонент, который пересылает данные между процессами.

    * [in]: Данные передаются от клиента к серверу. Маршалер должен прочитать значение, упаковать его и отправить. Сервер не может менять это значение (или изменения не вернутся обратно). * [out]: Данные передаются от сервера к клиенту. Маршалер на стороне клиента выделяет буфер, но не инициализирует его. Сервер заполняет буфер, и маршалер отправляет данные обратно. * [in, out]: Данные идут в обе стороны. Маршалер отправляет начальное значение на сервер, сервер его меняет, и новое значение возвращается клиенту. * [retval]: Указывает, что этот параметр является логическим возвращаемым значением функции. Это позволяет языкам высокого уровня (C#, VB, скрипты) видеть метод не как HRESULT Add(...), а как long Add(long a, long b).

    Пример метода:

    Здесь мы передаем id внутрь объекта, а объект возвращает нам строку pName. Обратите внимание: почти все методы в COM возвращают HRESULT для отчета об ошибках, а полезные данные возвращаются через указатели с атрибутом [out].

    Компилятор MIDL: От текста к бинарному коду

    Написанный файл .idl сам по себе не компилируется в машинный код вашего приложения. Его обрабатывает специальная утилита от Microsoft — MIDL (Microsoft Interface Definition Language compiler).

    Этот инструмент берет ваш текстовый файл и генерирует несколько файлов на языке C/C++, а также бинарную библиотеку типов.

    !MIDL генерирует весь необходимый связующий код автоматически.

    Что генерирует MIDL?

  • Заголовочный файл (.h): Содержит C++ определения интерфейсов (абстрактные классы с чисто виртуальными функциями). Именно этот файл вы будете подключать (#include) в свой проект C++.
  • Файл идентификаторов (_i.c): Содержит определения всех GUID (IID и CLSID), объявленных в IDL.
  • Библиотека типов (.tlb): Это скомпилированная, бинарная версия вашего IDL. Она нужна для того, чтобы другие языки (Python, VBA, C#) могли узнать, какие методы есть у вашего объекта, не имея исходного кода.
  • Код прокси и стаба (dlldata.c, _p.c): Если вы планируете использовать свой объект между разными процессами или по сети, эти файлы компилируются в отдельную DLL, которая берет на себя всю грязную работу по передаче данных через границы процессов.
  • Библиотека типов (Type Library)

    Библиотека типов (.tlb) — это паспорт вашего COM-сервера. В отличие от C++, где для использования библиотеки нужны .h файлы, в COM достаточно иметь .tlb.

    Когда вы в Visual Studio добавляете ссылку (Add Reference) на COM-компонент, среда разработки читает именно .tlb файл. На его основе она показывает подсказки IntelliSense и проверяет типы данных.

    Типы данных, совместимые с автоматизацией

    Хотя IDL позволяет использовать почти любые типы данных C, для максимальной совместимости (особенно со скриптовыми языками) рекомендуется использовать OLE Automation types.

    Вот основные из них:

    | Тип C++ | Тип IDL | Описание | | :--- | :--- | :--- | | long | long | 32-битное целое число | | double | double | Число с плавающей точкой | | wchar_t* | BSTR | Строка с длиной (Binary String) | | VARIANT | VARIANT | Универсальный контейнер (может хранить число, строку или ссылку) | | short | VARIANT_BOOL | Логический тип (True/False) |

    Использование этих типов гарантирует, что ваш сервер сможет быть вызван из VBScript, PowerShell или Excel макроса.

    Заключение

    Мы узнали, что IDL — это контракт, который должен быть подписан до начала написания кода. Мы используем атрибуты [in] и [out] для управления потоком данных и полагаемся на компилятор MIDL для генерации всей рутинной обвязки C++.

    В следующей статье мы возьмем сгенерированный MIDL заголовочный файл и наконец-то напишем реализацию нашего COM-класса на C++, заставив этот контракт работать.