Delphi и COMИсточник: "КомпьютерПресс", №5-2001 Анатолий Тенцер
COM (Component Object Model) - модель объектных компонентов - одна из основных технологий, на которых основывается Windows. Более того, все новые технологии в Windows (Shell, Scripting, поддержка HTML и т.п.) реализуют свои API именно в виде COM-интерфейсов. Таким образом, в настоящее время профессиональное программирование требует понимания модели COM и умения с ней работать. В этой главе мы рассмотрим основные понятия COM и особенности их поддержки в Delphi. Ключевым моментом, на котором основана модель COM, является понятие интерфейса. Не имея четкого понимания того, что такое интерфейс, успешное программирование COM-объектов невозможно. Интерфейс, образно говоря, является «контрактом» между программистом и компилятором. Программист обязуется реализовать все методы, описанные в интерфейсе, и следовать требованиям, предъявляемым к реализации некоторых их них. Компилятор обязуется создать в программе внутренние структуры, позволяющие обращаться к методам этого интерфейса из любого поддерживающего те же соглашения средства программирования. Таким образом, COM является языково-независимой технологией и может использоваться в качестве «клея», соединяющего программы, написанные на разных языках. Объявление интерфейса включает в себя описание методов и их параметров, но не включает их реализации. Кроме того, в объявлении может указываться идентификатор интерфейса - уникальное 16-байтовое число, сгенерированное по специальным правилам, гарантирующим его статистическую уникальность (GUID - Global Unique Identifier). Интерфейсы могут наследоваться. Наследование интерфейсов - это декларация, указывающая, что унаследованный интерфейс должен включать в себя все методы предка. Таким образом, необходимо понимать следующее:
Это гарантирует, что новые интерфейсы никогда не будут конфликтовать со старыми. В случае необходимости расширения функциональности вы должны определить новый интерфейс, возможно являющийся наследником старого, и реализовать дополнительные методы в нем. Реализация интерфейса - это код, который реализует эти методы. При этом, за несколькими исключениями, не накладывается никаких ограничений на то, каким образом будет выглядеть реализация. Физически реализация представляет собой массив указателей на методы, адрес которого и используется в клиенте для доступа к COM-объекту. Любая реализация интерфейса имеет метод QueryInterface, позволяющий запросить ссылку на конкретный интерфейс из числа реализуемых. Автоматическое управление памятью и подсчет ссылок Кроме предоставления независимого от языка программирования доступа к методам объектов, COM реализует автоматическое управление памятью для COM-объектов. Оно основано на идее подсчета ссылок на объект. Любой клиент, желающий использовать COM-объект после его создания, должен вызвать заранее предопределенный метод, который увеличивает внутренний счетчик ссылок на объект на единицу. По завершении использования объекта клиент вызывает другой его метод, уменьшающий значение этого же счетчика. По достижении счетчиком ссылок нулевого значения COM-объект автоматически удаляет себя из памяти. Такая модель позволяет клиентам не вдаваться в подробности реализации объекта, а объекту - обслуживать несколько клиентов и корректно очистить память по завершении работы с последним из них. Для поддержки интерфейсов Delphi расширяет синтаксис языка Pascal дополнительными ключевыми словами. Объявление интерфейса в Delphi реализуется ключевым словом interface:
Для генерации нового значения GUID в IDE Delphi служит сочетание клавиш Ctrl+Shift+G. Базовым интерфейсом в модели COM является IUnknown. Любой интерфейс наследуется от IUnknown и обязан реализовать объявленные в нем методы. IUnknown объявлен в модуле System.pas следующим образом:
Рассмотрим назначение методов IUnknown более подробно. Последние два метода предназначены для реализации механизма подсчета ссылок.
Эта функция должна увеличить счетчик ссылок на интерфейс на единицу и вернуть новое значение счетчика.
Данная функция должна уменьшить счетчик ссылок на интерфейс на единицу и вернуть новое значение счетчика. По достижении счетчиком нулевого значения она должна освободить память, занятую реализацией интерфейса. Первый метод позволяет получить ссылку на реализуемый классом интерфейс. function QueryInterface(const IID: TGUID; out Obj): HResult; stdcall;
В противном случае - функция возвращает код ошибки E_NOINTERFACE. В принципе, конкретная реализация может наполнить эти методы какой-либо другой, отличающейся от стандартной функциональностью, однако в этом случае интерфейс будет несовместим с моделью COM, поэтому делать этого не рекомендуется. В модуле System.pas объявлен класс TInterfacedObject, реализующий IUnknown и его методы. Рекомендуется использовать этот класс для создания реализаций своих интерфейсов. Кроме того, поддержка интерфейсов реализована в базовом классе TObject. Он имеет метод
Если класс реализует запрошенный интерфейс, то функция:
В противном случае - функция возвращает FALSE. Таким образом, имеется возможность запросить у любого класса Delphi реализуемый им интерфейс. Подробнее использование этой функции рассмотрено ниже. Реализацией интерфейса в Delphi всегда выступает класс. Для этого в объявлении класса необходимо указать, какие интерфейсы он реализует. type Класс TMyClass реализует интерфейсы IMyInterface и IDropTarget. Необходимо понимать, что реализация классом нескольких интерфейсов не означает множественного наследования и вообще наследования класса от интерфейса. Указание интерфейсов в описании класса означает только то, что в данном классе реализованы все эти интерфейсы. Класс должен иметь методы, точно соответствующие по именам и спискам параметров всем методам всех объявленных в его заголовке интерфейсов. Рассмотрим более подробный пример.
Здесь класс TTest реализует интерфейс ITest. Рассмотрим использование интерфейса из программы.
Поскольку данный код выглядит довольно странно, остановимся на нем подробнее. Во-первых, оператор присваивания при приведении типа данных к интерфейсу неявно вызывает метод _AddRef. При этом количество ссылок на интерфейс увеличивается на единицу. Во-вторых, код не содержит никаких попыток освободить память, выделенную под объект TTest. Тем не менее, если выполнить эту программу, на экран будет выведено сообщение о том, что деструктор был вызван. Это происходит потому, что при выходе переменной, ссылающейся на интерфейс, за область видимости (либо при присвоении ей другого значения) компилятор Delphi генерирует код для вызова метода _Release, информируя реализацию о том, что ссылка на нее больше не нужна.
Так, следующий код приведет к ошибке в момент выхода из функции:
Если вы хотите уничтожить реализацию интерфейса немедленно, не дожидаясь выхода переменной за область видимости, - просто присвойте ей значение NIL:
Обратите особое внимание, что вызовы методов интерфейса IUnknown осуществляются Delphi неявно и автоматически. Поэтому не вызывайте методы интерфейса IUnknown самостоятельно. Это может нарушить нормальную работу автоматического подсчета ссылок и привести к неосвобождению памяти либо к нарушениям защиты памяти при работе с интерфейсами. Во избежание этого необходимо просто помнить следующее.
В рассмотренных примерах код для получения интерфейса у класса генерировался (с проверкой типов) на этапе компиляции. Если класс не реализует требуемого интерфейса, то программа не откомпилируется. Однако существует возможность запросить интерфейс и во время выполнения программы. Для этого служит оператор as, который вызывает QueryInterface и, в случае успеха, возвращает ссылку на полученный интерфейс. В противном случае генерируется исключение. Например, следующий код будет успешно откомпилирован, но при выполнении вызовет ошибку «Interface not supported»:
В то же время код
будет успешно компилироваться и выполняться. Реализация интерфейсов (расширенное рассмотрение) Рассмотрим вопросы реализации интерфейсов подробнее. Объявим два интерфейса:
Теперь создадим класс, который будет реализовывать оба этих интерфейса:
Как видно, класс не может содержать сразу два метода Beep. Поэтому Delphi предоставляет способ для разрешения конфликтов имен, позволяя явно указать, какой метод класса будет служить реализацией соответствующего метода интерфейса. Если реализация методов TTest2.Beep1 и TTest2.Beep2 идентична, то можно не создавать два разных метода, а объявить класс следующим образом:
При реализации классов, поддерживающих множество интерфейсов и много методов, может оказаться удобным делегировать реализацию некоторых из них дочерним классам. Рассмотрим пример класса, реализующего два интерфейса:
Для делегирования реализации интерфейса другому классу служит ключевое слово implements.
Такой подход позволяет разбить реализацию сложного класса на несколько простых, что упрощает программирование и повышает модульность программы. Обращаться к полученному классу можно точно так же, как и к любому классу, реализующему интерфейсы:
В базовом классе VCL TComponent имеется полный набор методов, позволяющих реализовать интерфейс IUnknown, хотя сам класс данный интерфейс не реализует. Это позволяет наследникам TComponent реализовывать интерфейсы, не заботясь о реализации IUnknown. Однако методы TComponent._AddRef и TComponent._Release на этапе выполнения программы не реализуют механизм подсчета ссылок, и, следовательно, для классов-наследников TComponent, реализующих интерфейсы, не действует автоматическое управление памятью. Это позволяет запрашивать у них интерфейсы, не опасаясь, что объект будет удален из памяти при выходе переменной за область видимости. Таким образом, следующий код совершенно корректен и безопасен:
Этот код проверяет наличие у всех форм в приложении возможности реализации интерфейса IGetData и в случае, если форма реализует этот интерфейс, вызывает его метод. Использование интерфейсов внутри программы Рассмотренное выше поведение TComponent позволяет строить просто и без потери строгой типизации, связывать компоненты приложения, а также единообразно вызывать их методы, не требуя, чтобы компоненты были унаследованы от общего предка. Достаточно лишь реализовать в компоненте интерфейс, а в вызывающей программе проверить его наличие. В качестве примера рассмотрим MDI-приложение, имеющее много различных форм и единую панель инструментов. Предположим, что на этой панели инструментов имеются команды «Сохранить», «Загрузить» и «Очистить», однако каждое из окон реагирует на эти команды по-разному. Создадим модуль с объявлениями интерфейсов:
Интерфейс IToolBarCommands описывает набор методов, которые должны реализовать формы, поддерживающие работу с панелью кнопок. Метод SupportedCommands возвращает список поддерживаемых формой команд. Создадим три дочерние формы - Form2, Form3 и Form4 - и установим им свойство FormStyle = fsMDIChild. Form2 умеет выполнять все три команды:
Form3 умеет выполнять только команду Clear:
И наконец, Form4 вообще не реализует интерфейс IToolBarCommands и не откликается ни на одну команду. На главной форме приложения поместим ActionList и создадим три компонента TAction. Кроме того, разместим на ней TToolBar и назначим ее кнопкам соответствующие TAction.
Наиболее интересен метод ActionList1Update, в котором проверяются поддерживаемые активной формой команды и настраивается интерфейс главной формы. Если нет активной дочерней формы либо она не поддерживает интерфейс IToolBarCommands, все команды запрещаются, в противном случае - разрешаются только поддерживаемые формой команды.
При активизации команд проверяется наличие активной дочерней формы, у нее запрашивается интерфейс IToolBarCommands и вызывается соответствующий ему метод:
Работа программы представлена на рисунке. Того же эффекта можно добиться и другими методами (например, унаследовав все дочерние формы от единого предка либо обмениваясь с ними сообщениями), однако эти методы имеют ряд существенных недостатков. Так, при обмене сообщениями мы теряем строгую типизацию и вынуждены передавать параметры через целые числа, а при визуальном наследовании мы привязываем себя к родительскому классу, что не всегда удобно. К тому же можно определить множество интерфейсов и реализовывать в каждой из дочерних форм лишь необходимые, а в случае наследования все формы должны будут реализовывать все общие методы. Использование интерфейсов для реализации Plug-In Еще более удобно использовать интерфейсы для реализации модулей расширения программы (Plug-In). Как правило, такой модуль экспортирует ряд известных главной программе методов, которые могут быть из него вызваны. В то же время зачастую ему необходимо обращаться к каким-либо функциям вызывающей программы. И то и другое легко реализуется при помощи интерфейсов. В качестве примера реализуем несложную программу, использующую Plug-In для загрузки данных. Объявим интерфейсы модуля расширения и внутреннего API программы.
Этот модуль должен использоваться как в Plug-In, так и в основной программе и гарантирует использование ими идентичных интерфейсов. Plug-In представляет собой DLL, экспортирующую функцию CreateFilter, возвращающую ссылку на интерфейс ILoadFilter. Главный модуль сначала должен вызвать метод Init, передав в него имя файла и ссылку на интерфейс внутреннего API, а затем вызывать метод GetNextLine до тех пор, пока он не вернет FALSE. Рассмотрим код модуля расширения:
Метод Init выполняет две задачи: сохраняет ссылку на интерфейс API главного модуля для дальнейшего использования и пытается открыть файл с данными. Если файл открыт успешно - выставляется внутренний флаг InitSuccess.
Метод GetNextLine считывает следующую строку данных и возвращает либо TRUE, если это удалось, либо FALSE - в случае окончания файла. Кроме того, при помощи API, предоставляемого главным модулем, данный метод информирует пользователя о ходе загрузки.
В деструкторе мы обнуляем ссылку на API главного модуля, уничтожая его, и закрываем файл.
Эта функция создает экземпляр класса, реализующего интерфейс ILoadFilter. Ссылок на экземпляр сохранять не нужно, он будет освобожден автоматически.
Теперь полученную DLL можно использовать из основной программы.
Класс TAPI реализует API, предоставляемый модулю расширения. Функция ShowMessage выводит сообщения модуля в Status Bar главной формы приложения.
Получаем имя модуля с фильтром для выбранного расширения файла. Описания модулей хранятся в файле plugins.ini в секции Filters в виде строк формата:
Функция найдена, создаем экземпляр фильтра и инициализируем его. Поскольку внутренний API реализован тоже как интерфейс - нет необходимости сохранять ссылку на него.
Загружаем данные при помощи созданного фильтра:
Перед выгрузкой DLL из памяти необходимо обязательно освободить ссылку на интерфейс Plug-In, иначе это произойдет по выходе из функции и вызовет Access Violation.
Выгружаем DLL и обновляем TMemo:
Таким образом, реализовать модули расширения для загрузки данных из форматов, отличающихся от текстового, и единообразно работать с ними несложно. Преимущества данного способа становятся особенно очевидными при реализации сложных модулей расширения, интерфейс с которыми состоит из многих методов.
COM-сервер, структура и использование Модель COM предоставляет возможность создания многократно используемых компонентов, независимых от языка программирования. Такие компоненты называются COM-серверами и представляют собой исполняемые файлы (EXE) или динамические библиотеки (DLL), специальным образом оформленные для обеспечения возможности их универсального вызова из любой программы, написанной на поддерживающем COM языке программирования. При этом COM-сервер может выполняться как в адресном пространстве вызывающей программы (In-Process-сервер), так и в виде самостоятельного процесса (Out-Of-Process-сервер) или даже на другом компьютере (Distributed COM). COM автоматически разрешает вопросы, связанные с передачей параметров (Marshalling) и согласованием потоковых моделей клиента и сервера. Далее будут рассмотрены некоторые архитектурные вопросы, знание которых необходимо для работы с COM. COM-сервер - это специальным образом оформленное и зарегистрированное приложение, которое позволяет клиентам запрашивать у себя создание реализованных в нем объектов. Сервер может быть выполнен в виде либо динамической библиотеки, либо исполняемого файла. Такой сервер всегда выполняется в адресном пространстве активизировавшего его приложения (In-Process). За счет этого, как правило, снижаются накладные расходы на вызов методов сервера. В то же время такой сервер менее надежен, поскольку его память не защищена от ошибок в вызывающем приложении. Кроме того, он не может выполняться на удаленной машине без исполнимого модуля-посредника, способного создать процесс, в который может быть загружена DLL. Примером такого модуля может служить Microsoft Transaction Server. Сервер в виде исполнимого файла Этот сервер представляет собой обычный исполнимый файл Windows, в котором реализована возможность создания COM-объектов по запросу других приложений. Примером такого сервера может служить пакет Microsoft Office, приложения которого являются COM-серверами. COM реализует механизм автоматического поиска серверов по запросу клиента. Каждый COM-объект имеет уникальный идентификатор, Class Identifier (CLSID). Windows ведет в реестре базу данных зарегистрированных объектов, индексированную при помощи CLSID. Она расположена в ветке реестра HKEY_CLASSES_ROOT\CLSID. Для каждого сервера прописывается информация, необходимая для нахождения и загрузки его модуля. Таким образом, клиентское приложение не должно беспокоиться о поиске сервера: достаточно зарегистрировать его на компьютере - и COM автоматически найдет и загрузит нужный модуль. Кроме того, объект может зарегистрировать свое «дружественное» имя, или Programmatic Identifier (PROGID). Обычно оно формируется как комбинация имени сервера и имени объекта, например Word.Application. Это имя содержит ссылку на CLSID объекта. Когда он создается с использованием PROGID, COM просто берет связанное с ним значение CLSID и получает из него всю необходимую информацию. Серверы в виде исполняемых файлов автоматически регистрируются при первом запуске программы на компьютере. Для регистрации серверов DLL служит программа Regsvr32, поставляемая в составе Windows, либо TRegSvr из поставки DELPHI. Windows - многозадачная и многопоточная среда с вытесняющей многозадачностью. Применительно к COM это означает, что клиент и сервер могут оказаться в различных процессах или потоках приложения, что к серверу могут обращаться множество клиентов, причем в непредсказуемые моменты времени. Технология COM решает эту проблему при помощи концепции «комнат» (Apartments), в которых и выполняются COM-клиенты и COM-серверы. «Комнаты» бывают однопоточные (Single Threaded Apartment, STA) и многопоточные (Multiple Threaded Apartment, MTA). При создании однопоточной «комнаты» COM неявно создает окно и при вызове любого метода COM-сервера в этой «комнате» посылает данному окну сообщение при помощи функции PostMessage. Таким образом, организуется очередь вызовов методов, каждый из которых обрабатывается только после того, как будут обработаны все предшествующие вызовы. Основные достоинства однопоточной «комнаты»:
В то же время, если приложение создало несколько потоков, в каждом из которых имеется STA, при доступе к глобальным разделяемым данным они должны использовать синхронизацию, например при помощи критических секций. Недостатки STA напрямую вытекают из ее реализации:
Тем не менее STA, как правило, является наиболее подходящим выбором для реализации COM-сервера. Использовать MTA есть смысл только в том случае, если STA не подходит для конкретного сервера. Многопоточная «комната» не реализует автоматический сервис по синхронизации и не имеет его ограничений. Внутри нее может быть создано сколько угодно потоков и объектов, причем ни один из объектов не привязан к какому-то конкретному потоку. Это означает, что любой метод объекта может быть вызван в любом из потоков в MTA. В то же самое время в другом потоке может быть вызван любой другой (либо тот же самый) метод COM-объекта по запросу другого клиента. COM автоматически ведет пул потоков внутри MTA, при вызове со стороны клиента находит свободный поток и в нем вызывает метод требуемого объекта. Таким образом, даже если выполняется метод, требующий длительного времени, то для другого клиента он может быть вызван без задержки в другом потоке. Очевидно, что COM-сервер, работающий в MTA, обладает потенциально более высокими быстродействием и доступностью для клиентов, однако он значительно сложнее в разработке, поскольку даже локальные данные объектов не защищены от одновременного доступа и требуют синхронизации. Передача интерфейсов и параметров Таким образом, клиент и сервер COM могут выполняться как в одной «комнате», так и в разных, расположенных в различных процессах или даже на разных компьютерах. Возникает вопрос: как же клиент может вызывать методы сервера, если они находятся (в общем случае) в другом адресном пространстве? Эту работу берет на себя COM. Для доступа к серверу в другой «комнате» клиент должен запросить у COM создание в своей «комнате» представителя, реализующего запрошенный интерфейс. Такой представитель в терминах COM называется proxy и представляет собой объект, экспортирующий запрошенный интерфейс. Одновременно COM создает в «комнате» сервера объект-заглушку (stub), принимающий вызовы от proxy и транслирующий их в вызовы сервера. Таким образом, клиент в своей «комнате» может рассматривать proxy в качестве сервера и работать с ним так, как если бы сервер был создан в его «комнате». В то же время сервер может рассматривать stub как расположенного с ним в одной «комнате» клиента. Всю работу по организации взаимодействия proxy и stub берет на себя COM. При вызове со стороны клиента proxy получает от него параметры, упаковывает их во внутреннюю структуру и передает в «комнату» сервера. Stub получает параметры, распаковывает их и производит вызов метода сервера. Аналогично осуществляется передача параметров обратно. Этот процесс называется Marshalling. При этом «комнаты» клиента и сервера могут иметь разные потоковые модели и физически находиться где угодно. Разумеется, по сравнению с вызовом сервера в своей «комнате» такой вызов требует значительных накладных расходов, однако это единственный способ обеспечить корректную работу любых клиентов и серверов. Если необходимо избежать накладных расходов, сервер надо создавать в той же «комнате», где расположен клиент. Для обеспечения возможности корректного создания proxy в клиентской «комнате» COM должен узнать «устройство» сервера. Сделать это можно несколькими способами:
Каким же образом клиенты и серверы COM могут создавать «комнаты» в соответствии со своими требованиями? Для этого они должны соблюдать одно правило: каждый поток, желающий использовать COM, должен создать «комнату» при помощи вызова функции CoInitializeEx. Она объявлена в модуле ActiveX.pas следующим образом:
Параметр pvReserved зарезервирован для будущего использования и должен быть равен NIL, а параметр coInit определяет потоковую модель создаваемой комнаты. Он может принимать следующие значения:
Функция возвращает S_OK в случае успешного создания «комнаты». По завершении работы с COM (или перед завершением работы) поток должен уничтожить «комнату» при помощи вызова процедуры CoUninitialize, также описанной в модуле ActiveX:
Каждый вызов CoInitializeEx должен иметь соответствующий вызов CoUninitialize, то есть, используя COM в приложении, необходимо вызвать CoInitializeEx до первого использования функций COM и CoUninitialize перед завершением работы приложения. VCL реализует автоматическую инициализацию COM при использовании модуля ComObj. По умолчанию создается STA. При желании необходимость использовать другую потоковую модель следует установить флаг инициализации COM до оператора Application.Initialize:
Если COM используется в потоке, то эти функции должны быть вызваны в методе Execute:
Инициализация COM необходима и для вызова любых функций Windows API, связанных с COM, за исключением CoGetMalloc, CoTaskMemAlloc, CoTaskMemFree и CoTaskMemReAlloc. Отдельного обсуждения заслуживает инициализация потоковой модели COM для сервера, расположенного в DLL. Дело в том, что DLL может быть загружена любым потоком, который уже ранее создал свою «комнату». Поэтому сервер в DLL не может сам инициализировать требуемую ему потоковую модель. Вместо этого сервер при регистрации прописывает в реестре параметр ThreadingModel, который и указывает, в какой потоковой модели способен работать данный сервер. При создании сервера COM анализирует значение этого параметра и потоковой модели «комнаты» запросившего создание сервера потока и при необходимости создает для сервера «комнату» с требуемой потоковой моделью. Параметр ThreadingModel может принимать следующие значения:
Если этот параметр не задан, сервер имеет потоковую модель Single. В этом случае он создается в Primary STA (то есть в STA потока, который первым вызвал CoInitialize), даже если создание сервера запрошено из потока, имеющего свою отдельную STA. Для активации COM-сервера клиент должен вызвать функцию CreateComObject, описанную в модуле ComObj.pas:
Функция получает в качестве параметра CLSID требуемого объекта и возвращает ссылку на его интерфейс IUnknown. Далее клиент может запросить требуемый интерфейс и работать с ним:
Что же делает COM при запросе на создание сервера?
По завершении работы с COM-объектом клиент освобождает ссылку на него (что приводит к вызову метода Release). В этот момент COM-сервер проверяет, есть ли еще ссылки на созданные им объекты. Если все объекты освобождены, то COM-сервер завершает свою работу. В случае если он реализован в виде DLL, он должен экспортировать функцию DllCanUnloadNow, которая вызывается COM по таймеру или при вызове функции API CoFreeUnusedLibraries. Если все объекты из этой DLL освобождены, она выгружается из памяти. Вся работа по созданию и регистрации «фабрики объектов» и экспорту соответствующих функций из DLL в Delphi уже реализована в составе стандартных библиотек, и создание COM-сервера в действительности является очень простой задачей. Для создания COM-сервера Delphi предоставляет широкий набор мастеров, автоматизирующих выполнение рутинных задач и позволяющих программисту сконцентрироваться на реализации функциональности. Мастера доступны при помощи команды меню File->New, на закладке ActiveX. Чтобы сделать COM-сервером EXE-файл, необходимо просто добавить в него модуль с COM-объектом. Для создания COM-сервера в виде DLL потребуется сначала создать библиотеку, оформленную с учетом требований COM. Это делается при помощи мастера ActiveX Library. При его выборе будет создан новый проект, реализующий DLL, и сгенерирован следующий код:
Созданная DLL экспортирует функции, необходимые для работы COM, при этом можно не отвлекаться на рутинную работу и сразу приступить к реализации COM-сервера. Для этого выберите мастер «COM-объект». От заполнения полей этой формы зависит реализация создаваемого COM-объекта:
Для поддержки OleAutomation-маршалинга необходимо:
Такой сервер, если он не реализует интерфейс IMarshall, может работать лишь в одной «комнате» с клиентом, поэтому его следует использовать только для In-Process-серверов с потоковой моделью, идентичной клиенту. При создании сервера, не включающего библиотеку типов, необходимо указать мастеру реализуемые им интерфейсы. Укажем имя интерфейса ITest. По завершении работы мастера будет создан следующий модуль:
Рассмотрим сгенерированный код подробнее. Особый интерес представляет секция Initialization. В ней создается экземпляр «фабрики объектов» - COM-сервера, реализующего интерфейс IClassFactory2. К нему COM будет обращаться для создания экземпляра объекта Test. VCL автоматически выполняет всю рутинную работу по взаимодействию с COM. Для реализации сервера требуется написать интерфейсный модуль с описанием реализуемого интерфейса. Кроме того, вынесем в него описание константы Class_Test и добавим его в строку uses модуля Unit1:
Этот модуль содержит всю необходимую информацию для работы сервера и должен использоваться при компиляции клиента. Дополним код COM-объекта реализацией методов реализуемого интерфейса:
Откомпилировав проект, мы получим файл Project1.dll. Последним шагом является регистрация COM-сервера. Введем в командной строке «regsvr32 project1.dll». Если все было проделано правильно, на экране должно появиться сообщение об успешной регистрации: «DllRegisterServer in Project1.dll succeeded». Теперь можно приступать к написанию клиента. Для этого создадим новый проект, добавим в модуль с его главной формой строку uses TestInterface и напишем следующий код:
Как видно из этого примера, создание и использование COM-сервера не сложнее, чем работа с обычными классами Delphi. Сервер без библиотеки типов является хорошим выбором для реализации COM-серверов, используемых внутри проекта, поскольку для его работы нужен интерфейсный модуль. При передаче сервера другим разработчикам вам придется передать им этот модуль и при необходимости перевести его на другой язык (например, С). Библиотека типов - это специальный двоичный ресурс, описывающий интерфейсы и методы, реализуемые COM-сервером. Кроме наличия библиотеки типов сервер должен поддерживать интерфейс IProvideClassInfo. В Delphi такой сервер реализуется путем наследования его от TTypedComObject. Для этого оставьте флажок Include Type Library в мастере создания COM-объекта включенным. Создадим COM-сервер в виде EXE (разумеется, он может быть также создан и виде DLL). Сначала создадим новый проект - File-New Application, а затем добавим в него COM-объект. Если не отключать флажок Include Type Library, то мастер создаст уже не один, а два модуля. Первый из них напоминает созданный ранее.
Наиболее интересна строка: uses … Project1_TLB. Это автоматически сгенерированный интерфейсный модуль к нашему COM-объекту (аналогично TestInterface.pas в предыдущем примере). Он содержит описание всех необходимых для работы с сервером интерфейсов. В отличие от предыдущего примера, вам не придется редактировать его вручную. Для этого Delphi откроет редактор библиотеки типов: Это специализированный редактор для описания интерфейсов COM-объектов. Вы должны описать все требуемые интерфейсы, методы и т.п. в этом редакторе, после чего можно нажать кнопку «Обновить» - и изменения будут автоматически внесены во все требуемые модули. Вам останется лишь дописать реализацию методов. Добавим описание нового метода. Для этого щелкнем правой кнопкой мыши на интерфейсе ITest и выберем из контекстного меню опцию New->Method. Введем имя метода - ShowIt. На закладке Parameters зададим параметр S и тип BSTR. После этого нажмем кнопку «обновить» и посмотрим, что произошло с исходными текстами нашей программы. В модуле Project1_TLB в описании интерфейса ITest1 появился метод ShowIt:
А в модуле Unit1:
Нам остается лишь написать реализацию метода:
Для регистрации сервера достаточно один раз запустить его на компьютере клиента. Перейдем к написанию приложения-клиента. При наличии модуля Project_TLB оно ничем не будет отличаться от предыдущего примера. Более интересен случай, когда мы имеем только исполняемый файл с сервером. Зарегистрируем этот сервер и выберем в меню Delphi IDE команду Project -> Import Type Library. В открывшемся окне найдем строку с описанием библиотеки типов требуемого сервера. Если включен флажок Generate Component Wrappers, то в импортированный модуль будет добавлен код для создания компонента Delphi, который можно поместить на форму - и он автоматически создаст требуемый COM-сервер и позволит обращаться к его методам. В противном случае будет сгенерирован модуль, содержащий описание всех имеющихся в библиотеке типов интерфейсов. Далее необходимо определить, что вы собираетесь сделать с выбранной библиотекой:
Таким образом, для распространения и использования сервера не требуется ничего, кроме его исполнимого модуля. Но это не самое главное. Гораздо более важно, что вы можете импортировать и использовать в своей программе любой из имеющихся на компьютере COM-серверов. Естественно, что при передаче своей программы клиенту вы должны установить на его компьютере соответствующий COM-сервер. Для примера используем в своем приложении процессор регулярных выражений VBScript. Импортируем библиотеку типов Microsoft VBScript Regular Expressions. При этом будет создан файл VBScript_RegExp_TLB.pas. Создадим форму и добавим следующий код для проверки вхождения текста, содержащегося в компоненте Edit1, в текст, содержащийся в компоненте Edit2:
Это все! Мы получили в своем приложении поддержку регулярных выражений - такую же, как и та, что включена в скриптовые языки Microsoft (VBScript и JScript). Создание Plug-In в виде COM-сервера Попробуем теперь реализовать Plug-In к своей программе в виде COM-сервера и сравним код, полученный в этом случае, с кодом, полученным при «ручном» программировании. Вначале создадим модуль с описанием интерфейсов:
Обратите внимание, что метод ILoadFilter.Init больше не получает ссылки на внутренний API программы - он будет реализован в виде COM-объекта. Создадим DLL c COM-сервером, реализующим ILoadFilter. Для этого создадим новую ActiveX-библиотекуи добавим в нее COM-объект TLoadFilter. Установим ThreadingModel в Single, поскольку использование сервера в потоках не предусмотрено. После этого реализуем методы интерфейса ILoadFilter:
Деструктор и метод GetNextLine аналогичны предыдущему примеру:
Метод Init имеет существенное различие - теперь ссылку на внутренний API программы мы получаем при помощи COM. Это освобождает нас от необходимости передавать ссылку в модуль расширения.
В конце модуля находится код, автоматически сгенерированный Delphi для создания фабрики объектов:
Компилируем DLL и регистрируем ее при помощи regsvr32. Поскольку программа может поддерживать множество различных фильтров, организуем их подключение через INI-файл следующего вида:
Параметром строки служит CLSID сервера, реализующего фильтр. В нашем случае это содержание константы Class_LoadFilter. Для подключения дополнительных фильтров необходимо создать DLL с сервером, реализующим ILoadFilter, зарегистрировать ее в системе и добавить CLSID сервера в INI-файл. Теперь можно приступить к написанию программы-клиента. Она аналогична используемой в предыдущем примере. Добавим в нее COM-сервер, реализующий внутренний API. За исключением кода, сгенерированного COM, этот объект полностью аналогичен объекту, приведенному ранее. Константу Class_TAPI вынесем в модуль PluginInterface, чтобы сделать ее доступной для модулей расширения:
Теперь все готово к реализации функциональности клиента. В целях экономии места приведем лишь метод LoadData:
Очевидно, что код метода стал гораздо более коротким и читабельным. COM взял на себя всю черновую работу по поиску, загрузке и и выгрузке DLL, поиску и созданию объектов.
Автоматическая регистрация серверов из своей программы Удобно в своей программе автоматически регистрировать все необходимые серверы. Это можно сделать при помощи следующей процедуры:
В процедуре осуществляется дополнительная проверка наличия на диске файла с зарегистрированным сервером. Если файл не найден по указанному в реестре месту, данные о регистрации удаляются и предпринимается попытка зарегистрировать сервер заново. Такая проверка очень полезна при переносе DLL с сервером в другую папку на диске. Стандарт COM основан на едином для всех поддерживающих его языков формате таблицы, описывающей ссылки на методы объекта, реализующего интерфейс. Однако вызов методов при помощи этой таблицы доступен только для компилирующих языков программирования. В то же время очень удобно было бы иметь доступ к разнообразным возможностям, предоставляемым COM из интерпретирующих языков, таких как VBScript. Для поддержки этих языков была разработана технология OLE Automation, позволяющая приложениям делать свою функциональность доступной для гораздо большего числа клиентов. Automation базируется на COM и является его подмножеством, однако накладывает на COM-серверы ряд дополнительных требований.
Кроме того, Automation-серверы могут поддерживать ряд интерфейсов, позволяющих получать информацию о методах, обрабатывать ошибки и т.п. Все необходимые интерфейсы реализуются VCL Delphi автоматически. Центральным элементом технологии OLE Automation является интерфейс IDispatch. Ключевыми методами этого интерфейса являются методы GetIdsOfNames и Invoke, позволяющие клиенту запросить у сервера, поддерживает ли он метод с указанным именем, а затем, если метод поддерживается, - вызвать его. Подробно реализация и работа IDispatch будет рассмотрена в статье, посвященной работе с Microsoft Scripting Control, здесь же мы лишь вкратце опишем основной алгоритм вызова методов при помощи IDispatch. Когда клиенту требуется вызвать метод, он вызывает GetIdsOfNames, передавая ему имя запрошенного метода. Если сервер поддерживает такой метод, он возвращает его идентификатор - целое число, уникальное для каждого метода. После этого клиент упаковывает параметры в массив переменных типа OleVariant и вызывает Invoke, передавая ему массив параметров и идентификатор метода. Таким образом, все, что должен знать клиент, - это строковое имя метода. Такой алгоритм позволяет работать с наследниками IDispatch из скриптовых языков. Методы GetTypeInfo и GetTypeInfoCount являются вспомогательными и обеспечивают поддержку библиотеки типов объекта. Реализация методов GetIdsOfNames и Invoke, предоставляемая COM по умолчанию, базируется на библиотеке типов объекта. Поддержка IDispatch, тип данных Variant Delphi имеет встроенную поддержку работы в качестве клиента Automation. Тип данных Variant может содержать ссылку на интерфейс IDispatch и использоваться для вызова его методов.
Приведенный выше код весьма необычен и заслуживает детального рассмотрения.
Все это непривычно и выглядит довольно странно. На самом деле ничего странного нет. Компилятор Delphi просто запоминает в коде программы строковые описания обращений к серверу автоматизации, а на этапе выполнения передает их его интерфейсу IDispatch, который и производит синтаксический разбор и выполнение. Исправим третью строку функции на:
Программа успешно откомпилируется, однако при попытке ее выполнить выдаст сообщение об ошибке, а именно, что метод «Left1» не поддерживается сервером автоматизации. Такое обращение к серверу называется поздним связыванием, что означает, что связывание имен свойств и методов объекта с их кодом происходит не на этапе компиляции, а на этапе выполнения программы. Достоинства позднего связывания очевидны: не требуется библиотека типов, написание несложных программ упрощается. Столь же очевидны и недостатки: не производится контроль вызовов и передаваемых параметров на этапе компиляции, работа осуществляется несколько медленнее, чем при раннем связывании.
Таким образом, главным преимуществом раннего связывания является строгий контроль типов на этапе компиляции. Для разрешения проблемы нестрогого контроля типов COM предлагает несколько дополнительных возможностей. Диспинтерфейс (Dispinterface) - это декларация методов, доступных через интерфейс IDispatch. Диспинтерфейс объявляется следующим образом:
Сами методы могут физически и не существовать (например, они реализуются динамически в Invoke). Рассмотрим использование диспинтерфейса на простом примере. Объявим диспинтерфейс объекта InternetExplorer и используем его в своей программе:
Эта программа успешно компилируется и работает, несмотря на то, что в интерфейсе объявлено только одно из множества имеющихся свойств и методов. Это возможно благодаря тому, что Delphi не вызывает методы диспинтерфейса напрямую и поэтому не требует полного описания всех методов в правильном порядке. При вызове метода диспинтерфейса Delphi просто вызывает метод Invoke соответствующего интерфейса IDispatch, передавая ему идентификатор метода, указанный в dispid. В результате программисту становятся доступны возможность строгого контроля типов при вызове методов IDispatch и вызов методов, реализованных в IDispatch, без формирования сложных структур данных для вызова Invoke. Необходимо лишь описать (или импортировать из библиотеки типов сервера) описание диспинтерфейса. В описании диспинтерфейса допустимо использовать только OLE-совместимые типы данных. Идея двойных интерфейсов очень проста. Сервер реализует одновременно некоторый интерфейс, оформленный по стандартам COM (VTable), идиспинтерфейс, доступный через IDispatch. При этом интерфейс VTable должен быть унаследован от IDispatch и иметь идентичный с диспинтерфейсом набор методов. Такое оформление сервера позволяет клиентам работать с ним наиболее удобным для каждого клиента образом. Клиенты, использующие VTable, вызывают методы интерфейса напрямую, а клиенты, использующие позднее связывание, - через методы IDispatch. Большинство OLE-серверов реализуют двойной интерфейс. Чтобы создать при помощи Delphi сервер, совместимый с OLE Automation, необходимо включить в свое приложение Automation Object. Мастер для его создания запускается при выборе пункта главного меню Delphi File -> New и пиктограммы Automation Object со страницы репозитария ActiveX. В поле CoClassName вводится имя создаваемого объекта. Поля Instancing и Threading Model аналогичны рассмотренным выше при создании COM-сервера. Наибольший интерес представляет флаг Generate Event Support code. В случае если он задан, генерируется дополнительный код, позволяющий серверу реализовать интерфейс событий. Этот интерфейс описывает события, которые может генерировать сервер. Клиент может зарегистрировать себя в качестве подписчика на эти события и получать уведомления о них. Для того чтобы понять механизм этого процесса, отвлечемся от создания ActiveX-сервера и рассмотрим событийную модель COM. При возникновении события в COM-сервере, которое он должен передать клиенту, сервер должен вызвать какой-либо из методов клиента. Фактически в этот момент клиент с сервером меняются местами. Обращение к клиенту осуществляется при помощи стандартных механизмов COM. Основная идея заключается в том, что сервер, генерирующий события, декларирует интерфейс их обработчика. Клиент, подписывающийся на события, должен реализовать этот интерфейс (то есть фактически должен включать в себя COM-объект, реализующий интерфейс). Кроме того, сервер должен реализовать стандартные интерфейсы COM, позволяющие зарегистрировать на нем обработчик событий. Используя эти интерфейсы, клиент регистрирует на сервере интерфейс обработчика событий, позволяя серверу вызывать свои методы. Рассмотрим основные интерфейсы, используемые в этом процессе.
Этот интерфейс должен реализовываться каждым COM-объектом, который позволяет подключаться к своим событиям. Ключевой метод FindConnectionPoint получает GUID интерфейса-обработчика и возвращает указатель на соответствующую этому обработчику «точку подключения». Такой подход дает возможность серверу иметь несколько интерфейсов для обработки событий, а клиентам подключаться к ним по мере необходимости. В случае успеха метод возвращает S_OK, в случае неудачи - код ошибки. Точка подключения также представляет собой интерфейс:
Ключевые методы этого интерфейса - Advise и Unadvise.
Этот метод регистрирует на сервере клиентский интерфейс обработчика событий, который передается в параметре unkSink. Метод возвращает dwCookie - идентификатор подключения, который должен использоваться при отключении обработчика событий. Начиная с этого момента сервер при возникновении события вызывает методы переданного ему интерфейса-обработчика.
Метод Unadvise отключает обработчик от сервера. Теперь, когда мы имеем представление, как COM реализует обработчики событий, можно продолжить работу над нашим сервером. Создание Automation- сервера (продолжение) Если флаг Generate Event Support code включен, то Delphi автоматически добавляет в библиотеку типов сервера интерфейс IXXXEvents, где XXX - имя Automation объекта. В этот интерфейс необходимо добавить методы, которые должен реализовать обработчик событий вашего сервера. Создадим интерфейс обработчика событий с методом TestEvent и метод FireEvent интерфейса IAutoTest. В сгенерированном файле с реализацией сервера добавим код для вызова обработчика события в метод FilreEvent
Здесь FEvents - автоматически добавленный Delphi в код сервера интерфейс IAutoTestEvents. Компилируем и регистрируем сервер аналогично любому другому COM-серверу. Теперь его можно использовать из любого Automation-клиента, например из скрипта на Web-странице:
Здесь в качестве Clsid элемента OBJECT необходимо указать содержание константы CLASS_AutoTest из файла Project1_TLB, сгенерированного Delphi. Загрузив эту страницу в Internet Explorer, вы получите сообщение при загрузке страницы. Создание обработчика событий COM Для лучшего понимания механизма обработки событий COM создадим программу, обрабатывающую события от нашего сервера. Для этого создадим проект с одной формой и добавим в него объект, реализующий интерфейс IAutoTestEvents. Этот объект реализуется в виде Automation Object. После этого в редакторе библиотеки типов необходимо произвести следующие действия.
Нажимаем кнопку «Обновить» и в сгенерированном модуле пишем код обработчика события:
Обработчик готов, теперь в проект надо добавить код для его использования. Добавляем к классу формы поля для хранения необходимых данных - ссылки на экземпляр обработчика событий, экземпляр объекта, точку подключения и идентификатор подключения:
При создании формы создаем COM-сервер AutoTest и COM-объект обработчика событий:
Запрашиваем у COM-сервера интерфейс IConnectionPointContainer:
Получаем ссылку на «точку подключения»:
и регистрируем в ней свой обработчик:
По окончании работы отключаем обработчик:
Теперь можно вызвать метод объекта и убедиться, что обработчик реагирует на события в нем:
Хорошая новость: проделывать все эти сложные манипуляции не обязательно. Мы сделали это в основном для демонстрации механизмов работы COM. Можно пойти другим, более простым путем. Для этого вы можете просто импортировать библиотеку типов сервера, поддерживающего события, и в мастере импорта библиотеки типов нажать кнопку Install. После этого на закладку ActiveX палитры компонентов будет помещен компонент для работы с этим сервером, который можно просто положить на форму. При этом сгенерированный компонент Delphi будет иметь обработчики событий для всех событий, объявленных в COM-объекте. Остается только написать для них свой код - всю работу по созданию объекта-обработчика, подключению к серверу и трансляции его событий в события компонента VCL Delphi возьмет на себя. |