Портабельное программирование с COM

Дата публикации: 2015.12.12

Работа с COM в C# вообще и Visual Studio в частности организована достаточно удобно: нужно просто добавить ссылку на нужный для разработки компонент с помощью IDE, и всё. Среда и компилятор сами смогут извлечь из окружения нужную метаинформацию и сгенерировать обёртки, так что программисту останется только написать корректный код, который использует эти средства. Да, есть определённые тонкости и проблемы, связанные с некорректно описанными метаданными (в частности, сам я сталкивался с некоторыми проблемами при интеграции с MS Project), но в большинстве случаев никаких проблем не возникает.

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

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

Конечно, можно подойти к этому вопросу по-разному, в зависимости от архитектуры системы. Можно изолировать такого рода интеграционные компоненты в отдельные сборки, можно добавлять в проект специальные конфигурации и делать ссылки на COM-компоненты доступными только в этих конфигурациях. В данной статье я предлагаю ещё один возможный вариант взаимодействия с использованием добавленной в .NET 4 возможности работы с COM с использованием ключевого слова dynamic.

Использование COM-библиотеки

Итак, предположим, что у нас есть компонент, реализующий интерфейс IComService, который мы хотим использовать. В первую очередь устанавливаем компонент у себя на машине и пишем код, который этот класс использует. Выглядеть это будет примерно вот так:

IComService instance = new IComService();
instance.HelloWorld();

При разработке я и в дальнейшем рекомендую использовать именно такой код (то есть код, написанный с использованием явной ссылки на COM-библиотеку). Это позволит иметь автодополнение, подсказки от IDE и компилятора, и прочие плюшки, которые мы привыкли ожидать от строго и статически типизированного языка.

Динамическое связывание

Однако понятно, что этот код не скомпилируется на машинах коллег, которые не установили соответствующую COM-библиотеку: ссылка на эту библиотеку в их окружении будет считаться некорректной, и тип IComService, разумеется, там не существует.

Эту проблему можно решить динамическим связыванием с COM-библиотекой. C# начиная с версии 4 предоставляет очень удобные возможности для этого. Для начала посмотрим GUID интересующего нас COM-типа (есть разные способы его узнать, но лично я просто нажимаю F12 в Visual Studio и смотрю значение атрибута [Guid] в открывшемся окошке с декомпилированным кодом).

Затем мы можем во время выполнения приложения получить этот тип, создать его экземпляр через System.Activator, а затем работать с ним динамически (COM-обёртка будет диспетчеризовать все наши вызовы, и в основном она с этим справляется корректно):

const string TypeGuid = "03653ea3-b63b-447b-9d26-fa86e679087b";
Type type = Type.GetTypeFromCLSID(Guid.Parse(TypeGuid));
dynamic instance = Activator.CreateInstance(type);
instance.HelloWorld();

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

Если присмотреться к старому и новому коду, можно обнаружить, что разница — лишь в способах инициализации объекта, а код, который этот объект использует (в примере это только строка instance.HelloWorld();), не отличается для случаев статического и динамического связывания. Поэтому я у себя на проектах ввёл практику условной компиляции такого кода через #define.

Разработчик, у которого на машине этот компонент установлен (и который активно с ним работает), заводит для себя конфигурацию, в которой определён макрос вида COM_LIBRARY_INSTALLED. Код инициализации компонента мы переписываем таким образом:

#if COM_LIBRARY_INSTALLED
    IComService instance = new IComService();
#else
    const string TypeGuid = "03653ea3-b63b-447b-9d26-fa86e679087b";
    Type type = Type.GetTypeFromCLSID(Guid.Parse(TypeGuid));
    dynamic instance = Activator.CreateInstance(type);
#endif

    instance.HelloWorld();

Разработчики, у которых компонент на машине не установлен, собирают проект без этого макроса, и получают вариант с dynamic. А основной разработчик интеграционного кода при этом собирает проект с активным макросом, и поэтому получает все преимущества того, что компонент у него на машине присутствует. Сборочный сервер также собирает проект без макроса, и клиенту (и тестировщикам) мы поставляем версию с dynamic.

Обработчики событий

Есть некоторые случаи, когда код в варианте с dynamic отличается от основного варианта кода, или даже вообще непонятно, как его писать. Одна из причин таких проблем — это события, генерируемые в COM-библиотеке. Такие события обычно представляются обычными CLI-событиями, сопоставленными с типами-делегатами, отдельно сгенерированными внутри COM-обёртки для каждого события. Представим, что у нас в варианте со строгой типизацией скомпилировалась такая конструкция [1]:

instance.DataReceived += () => Console.WriteLine("Data received");

Эта конструкция возможна благодаря синтаксическому сахару, появившемуся, по-моему, в C# 2. Компилятор представляет эту конструкцию в следующем виде [2]:

instance.DataReceived += new DataReceivedEventHandler(() => Console.WriteLine("Data received"));

Здесь DataReceivedEventHandler — это тип-делегат, который сгенерирован внутри COM-обёртки. Само собой, при динамическом связывании у нас этого типа нет (более того, у него даже не указан GUID, так что сослаться на него динамически мы тоже не можем). Эта сущность существует только на стороне обёртки, и нужна только для этого конкретного обработчика. Соответственно, этот код [1] у нас в dynamic-сборке даже не скомпилируется, т.к. компилятор не сможет понять, чего мы от него хотим.

В обычной ситуации проблема может быть решена явным прописыванием имени типа (то есть написанием кода, аналогичного варианту [2]), однако мы же не знаем правильного имени типа, и его не существует! Что же делать?

Честно признаться, какого-то внятного ответа в документации я не нашёл, но эксперимент показал, что можно использовать любой подходящий по типам делегат. В частности, код из примера следует переписать так:

instance.DataReceived += new Action(() => Console.WriteLine("Data received"));

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

Action handler = new Action(() => Console.WriteLine("Data received"));
instance.DataReceived += handler;
// По окончании работы:
instance.DataReceived -= handler;
instance = null;

На сегодня всё. Будьте осторожны при обращении с COM и внимательно тестируйте весь код: ошибки здесь подстерегают на каждом углу!