RPC своими руками, или макросы наносят ответный удар

Авторы: Andrew Solodovnikov
Alchemy Lab
Mike Kostuyhin
Alchemy Lab

Источник: RSDN Magazine #3-2008
Опубликовано: 28.12.2008
Версия текста: 1.0
Вместо предисловия
Общие требования к абстракции IPC/RPC
Синтаксис объявления интерфейсов (IDL)
Реализуемая модель удаленного взаимодействия
Результаты препроцессирования
Маршаллинг/демаршаллинг параметров
Использование разработанной библиотеки и вспомогательные средства
Основная функциональность
Расширенное использование
Заключение
Ссылки

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

Файлы, прилагаемые к статье: RPCLib.

Вместо предисловия

По-настоящему проблемы начались с выходом Windows Vista. Действительно, данная операционная система содержит множество нововведений, которые касаются как ядра, так и подсистем безопасности, gdi и win32. Множество программ, которые были работоспособны на предыдущих системах линейки NT, оказались практически вне закона. В частности, это касается сервисов. Впрочем, это не стало неожиданностью – Майкрософт уже давно не рекомендует делать так называемые интерактивные сервисы, с выходом же «Висты» лавочку прикрыли окончательно, добавив очередную порцию головной боли системным программистам. Итак, сервисы (как и раньше) выполняются в нулевой терминальной сессии, которая теперь никогда (вернее, почти никогда) не бывает консольной. Соответственно, существующие интерактивные сервисы мгновенно перестали быть таковыми (строго говоря, они не были полноценными интерактивными сервисами и на системах с fast user switching – например, Windows XP).

Итак, теперь нам довольно недвусмысленно заявили: необходимо разделять интерактивный сервис на 2 приложения: одно отвечает за взаимодействие с пользователем, другое является собственно сервисом и выполняет всю полезную работу. Хорошо, если эти 2 части не требуют интенсивного взаимодействия друг с другом, но если это все же требуется – тогда вам придется реализовывать эффективный механизм межпроцессного взаимодействия (IPC).

Что же у нас есть в наличии для этого? Посмотрим:

Производительность IPC обычно определяется латентностью минимального (пустого) вызова и пропускной способностью при передаче больших объемов данных. В приведенном выше списке механизмы IPC расположены именно в порядке усредненной производительности, причем производительность первой тройки различается незначительно (по нашим измерениям, менее чем в 2 раза практически на всем интервале изменения интенсивности вызовов и размеров передаваемых блоков данных). С другой стороны, первая тройка предоставляет пользователю довольно низкоуровневые интерфейсы, пользоваться которыми, по меньшей мере, неудобно, а зачастую и небезопасно.

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

Приведенная выше информация о производительности IPC/RPC-механизмов получена на основе наших собственных тестов и оценок, и никоим образом не претендует на истинность в последней инстанции. Однако полученные нами результаты очень похожи на правду, учитывая уровень абстракции и способ реализации для каждого из механизмов.

Хорошей альтернативой для многих приложений являются механизмы RPC или COM. В предельно упрощенном представлении COM абстрагирует удаленные вызовы при помощи интерфейсов следующим образом:


Рисунок. 1 Абстракция IPC при помощи интерфейсов.

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

Понятно, что за удобство и безопасность надо платить, и стоимость межпроцессного вызова посредством COM довольно высока. По нашим измерениям, латентность вызова пустого метода (с использованием в качестве транспорта LPC) примерно в 5 раз больше, чем в реализации посредством механизмов pipes. Кроме того, дополнительные неудобства вносит необходимость регистрации COM-сервера в реестре, что, на самом деле, не всегда желательно и даже возможно.

ПРИМЕЧАНИЕ

На самом деле COM – это довольно-таки сложная реализация, имеющая массу нюансов. На сегодня proxy и stub в COM по умолчанию используют единую реализацию, выполняющую маршаллинг на базе метаданных, записываемых компилятором MIDL в библиотеку типов. Однако компилятор MIDL поддерживает непосредственную генерацию кода маршаллинга. Это несколько сложнее, но все равно несравнимо проще создания собственной библиотеки маршаллинга. Кроме того, СОМ поддерживает так называемый custom-маршаллинг, позволяющий сериализовать объекты вручную, различные типы транспортных протоколов, а также обеспечивает защиту (которая по умолчанию включена и отнимает некоторое количество времени). Поэтому, чтобы полностью быть уверенным в справедливости выводов автора статьи по поводу СОМ, необходимо проанализировать упомянутые тесты. Велика вероятность того, что при использовании всех возможностей СОМ, скорость будет не так сильно уступать предложенной реализации. Однако данная статья интересна и другими аспектами – это, во-первых, альтернатива СОМ, которая может применяться на платформах, где реализация DCOM отсутствует, а во-вторых, статья демонстрирует работу технологий, подобных СОМ, что само по себе интересно – прим.ред.

Частично – только для dll inproc-серверов – проблема необходимости регистрации COM-сервера в реестре решена начиная с Windows XP.

Безусловно, в подавляющем большинстве случаев использование RPC или COM удовлетворит всем нуждам обычных программных продуктов, однако, что делать, если доступен только абстрактный потоковый интерфейс или, например, требуется обеспечить максимальную эффективность вызовов? Неужели все реализовывать вручную или пользоваться монстрами вроде CORBA? Ну и наконец, не может быть, чтобы мы первыми стали перед такой дилеммой.

И действительно, прямой поиск почти сразу находит одно из возможных решений – RCF. Обсуждать достоинства и недостатки данной библиотеки в этой статье мы не будем, однако следует отметить, что нам не удалось скомпилировать ее ни на одном доступном компиляторе без внесения исправлений. Кроме того, библиотека не предоставляет действительно абстрактный интерфейс к объекту, что видно на примере ее использования в клиентской части. Но, тем не менее, уже это позволяет оценить возможность создания функциональности RPC силами только компилятора C++, обеспечивая при этом приемлемый уровень абстракции, безопасности, удобства использования и производительности.

Общие требования к абстракции IPC/RPC

Основными общими требованиями к механизму IPC являются быстродействие (как латентность, так и пропускная способность), безопасность и эффективность использования (низкий порог «входа» для разработчика, возможность использовать в вызовах пользовательские типы, предоставление гарантированных контрактов для интерфейсов). Очевидно, существующие низкоуровневые механизмы IPC уровня системы в полной мере этим требованиям не удовлетворяют, поэтому их можно использовать в качестве низкоуровневого транспорта, предоставляя пользователю высокоуровневый безопасный и расширяемый интерфейс.

Таким образом, можно сформулировать следующие требования к механизму абстракции IPC:

  1. Механизм должен предоставлять пользователю абстрактный интерфейс на клиентской стороне, оперируя конкретным интерфейсом на серверной – примерно так, как это делают COM или CORBA. Очевидно, что реализация данной функциональности потребует автоматической генерации фабрик объектов и прокси-функции для инкапсуляции вызовов на клиентской и серверной сторонах на основе описания интерфейсов и классов реализации.
  2. Методы интерфейсов не могут генерировать исключения, все ошибочные ситуации должны обрабатываться посредством возврата из метода кода ошибки.
  3. Объявление интерфейса (и совокупности интерфейсов – библиотеки) должно иметь синтаксис, схожий с MIDL – с одной стороны, это привычный для разработчика синтаксис, а с другой, это предоставляет достаточный набор примитивов для описания интерфейсов.
  4. В качестве параметров методов интерфейса могут использоваться как встроенные простые типы, так и типы, определяемые пользователем; необходима возможность самостоятельного определения процедур маршаллинга/демаршаллинга пользователем.
  5. В качестве транспорта может быть использован любой класс, имеющий семантику потоков: операторы >> и << для простых и пользовательских типов, функции write, read (может потребоваться для сериализации бинарных данных) и flush. Реакция транспорта на ошибки ввода/вывода – исключения. В качестве примера таких транспортов можно привести boost::archive, MFC::CArchive и т.п. Ограничениями должны быть, пожалуй, требование не генерировать исключения при разрушении объекта транспорта.
  6. Библиотека не должна накладывать ограничения на протокол и способ реализации обмена информацией – например, синхронность или асинхронность.
  7. Абстракция должна привносить по возможности минимальные накладные расходы по сравнению с «ручным» кодированием поверх предоставляемого транспорта.
  8. Очень желательна реализация всех вышеописанных требований только силами С++ при минимальном использовании дополнительных библиотек и как можно более широкой поддержке компиляторов – как минимум, всей линейки VC, начиная с 6.0.

Проанализировав требования, можно отметить, что реализация требования (3) силами только С++ затруднена (или даже невозможна) ввиду того, что язык не предоставляет механизмов рефлексии для пользовательского типа – например, мы не можем перечислить методы класса и, тем более, определить их типы. Таким образом, у нас есть два варианта – либо использовать внешний препроцессор, который преобразует описание интерфейсов в текст С++, либо использовать препроцессор, встроенный в язык. Ввиду требования (7), использование встроенного препроцессора неожиданно оказывается вполне неплохой альтернативой.

ПРИМЕЧАНИЕ

Мы намеренно привели в эпиграфе мнение одного из (самых) уважаемых в С++-сообществе людей, поскольку сами, отчасти, его разделяем. Однако бывают ситуации, в которых даже самые правильные в общих случаях утверждения не совсем справедливы. Так это или нет в данном случае – увидим далее.

Итак, нам необходимо при помощи препроцессора:

  1. Объявить именованную совокупность интерфейсов (библиотеку – LIBRARY).
  2. Объявить сами интерфейсы (включая имена, идентификаторы, информацию о наследовании и методы).
  3. Объявить методы – наименования, параметры (тип и направление передачи) и тип возвращаемого значения.
  4. Объявить классы реализации интерфейсов.
  5. Сгенерировать на основе указанных объявлений клиентские интерфейсы, фабрику объектов, клиентские и серверные прокси для обеспечения прозрачного вызова, а также функции диспетчеризации вызовов.

Все остальное, похоже, вполне по силам обычному С++.

Синтаксис объявления интерфейсов (IDL)

Посмотрим внимательнее на то, что требуется реализовать. Очевидно, препроцессор С++ не поддерживает переменных в макросах, поэтому реализовать настоящие циклы (что необходимо для перечисления интерфейсов, параметров и методов, а также для генерирования уникальных идентификаторов интерфейсов и методов) только стандартными средствами препроцессира невозможно (в boost::preprocessor есть нечто подобное переменным – слоты (slots), но, на наш взгляд, это довольно некрасивое и очень непрактичное решение, тем более что использовать его для достижения указанных ранее целей не получится). Может, тогда передавать это все как параметры макроса? Но макросы не поддерживают переменного числа параметров. Или поддерживают?

Действительно, напрямую макросы С++ переменного числа параметров не поддерживают (во всяком случае, __VA_ARGS__ всерьез рассматривать не приходится по причине отсутствия переносимости). Однако существуют методы эмуляции переменного числа параметров, например, при помощи последовательностей – boost::preprocessor::seq. В этом случае параметры макроса задаются в виде разделенного скобками списка:

      #define MY_MACRO(params) ...

//  где-то в программе
MY_MACRO((param 1)(param 2) ... (param N))

Данный подход уже использовался в другой нашей библиотеке – Delay Load, и вполне успешно. Используя макросы BOOST_PP_SEQ_*, мы можем перечислять параметры, определять их количество, трансформировать последовательность и многое другое. Платой за эти возможности является не совсем обычный синтаксис объявления последовательности и ограничение на количество элементов. Сейчас это 256 (BOOST_PP_LIMIT_SEQ) – не так и много, но для наших целей вполне подойдет.

Учитывая сказанное выше, синтаксис IDL будет таким:

Синтаксис IDL-RPCLib.
      RPC_LIBRARY
(
  LibraryName,
  LIBARARY_ID,
  RPC_IMPORT(IFS_MACRO)
  RPC_INTERFACE
  (
    IfsName1,
    IfsParent,
    RPC_METHOD(MethodName1, rettype, (param1) ... (paramN))
    ...
    RPC_METHOD(MethodNameM, rettype, (param1) ... (paramN))
  )
  ...
  RPC_INTERFACE
  (
    IfsNameK,
  ...
  ),
  RPC_COCLASS(IfsName1Impl1, IfsName1)
    ...
  RPC_COCLASS(IfsNameKImplP, IfsNameK)
)

Как видно из приведенного выше примера, синтаксис IDL копирует описание MIDL. Перечислим основные директивы, поддерживаемые библиотекой:

RPC_LIBRARY(Name, ID, Interfaces, CoClasses) – объявляет библиотеку с именем Name и идентификатором ID, которая содержит интерфейсы Interfaces и их реализации – CoClasses. Библиотека представляет собой шаблонную структуру с именем Name, которая содержит в себе фабрику клиентских объектов (CreateObject) и функцию диспетчеризации для сервера Dispatch:

      // создание клиентского прокси-объекта 
  MyLibrary<ExceptionFilter>::CreateObject(archive,  
                 RPC_TYPE_REG(IFirstImpl), 
                 &pObj);

// Эта процедура диспетчеризации сообщений сервера вызывается в цикле
  MyLibrary<ExceptionFilter>::template Factory
              <
              Archive,   
               ServerFactory
              >::Dispatch(archive);

Библиотека параметризуется фильтром исключений, преобразующим исключения транспорта и маршаллинга в коды возврата. Безусловно, никто не требует реализовывать сервер и вызывать Dispatch в цикле самостоятельно – для этого разработан набор шаблонов, позволяющий собрать нужный сервер буквально по кирпичикам.

RPC_INTERFACE(Name, Parent, Methods) – объявляет интерфейс Name, имеющий родителя Parent и набор методов Methods. Кроме того, при необходимости генерируется набор клиентских оберток, а также таблица диспетчеризации интерфейсов для сервера.

RPC_METHOD(Name, RetType, Params) – объявляет метод с именем Name, возвращаемым значением RetType и параметрами Params. Для параметров возможно указание направления передачи при помощи директив направления: (RPC_IN), (RPC_OUT), (RPC_INOUT). Например:

      RPC_METHOD(Method1, int, ((RPC_IN)int *) ((RPC_OUT)MyType &))

Также, при необходимости, из данных объявлений генерируются клиентские прокси-функции и таблица диспетчеризации методов для серверной части. Клиентские прокси-функции, способ диспетчеризации вызовов на сервере при помощи таблиц и реализация маршаллинга/демаршаллинга параметров будут рассмотрены ниже отдельно.

RPC_IMPORT(macro) – объявляет в библиотеке интерфейсы, указанные параметром macro. Интерфейс объявляется при помощи одного или нескольких макросов RPC_INTERFACE.

RPC_COCLASS(CoClass, Interface) – для интерфейса Interface регистрирует конкретизацию (класс, реализующий на сервере указанный интерфейс) CoClass.

Вспомогательные типы и макросы:

Реализуемая модель удаленного взаимодействия

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


Рисунок 2 Модель взаимодействия клиента и сервера.

Основные элементы с точки зрения клиента – это фабрика объектов (Factorу), которая создает объекты с интерфейсом, определяемым типом класса реализации (ID) и прокси-объекты, обеспечивающие прозрачность вызовов методов серверных объектов. Таким образом, с точки зрения клиента, существуют два основных типа операций взаимодействия с серверной частью: создание объекта и вызов его метода.

Операция создания объекта изображена на диаграмме ниже:


Рисунок. 3 Создание объекта.

Как видно из диаграммы, целью данной операции является создание клиентского прокси-объекта, который предоставляет заданный интерфейс и содержит в себе всю необходимую информацию для вызова метода серверного объекта. В данном случае это token (уникальный идентификатор экземпляра объекта на сервере) и определяемый неявно идентификатор типа интерфейса (ID) объекта.

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

Следует четко понимать различие между идентификаторами класса реализации (coclass ID, rpclib::RPCCLSID), которые представляют собой идентификаторы реализации интерфейса, и идентификаторами собственно интерфейса (interface ID, rpclib:: RPCIID).

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


Рисунок 4 Вызов метода объекта.

Наиболее сложной задачей при вызове метода является корректная упаковка/распаковка параметров, в связи с чем это будет рассмотрено более подробно в отдельном разделе.

Результаты препроцессирования

В данном разделе представлены результаты раскрытия макросов для приведенного выше абстрактного примера. Это позволит вам понять, во что преобразуется приведенное выше абстрактное описание.

Объявления абстрактных интерфейсов и их идентификаторы

        struct IfsName1: public IfsParent1
{
  rettype Method1(param1, ..., paramN) = 0;
  ...
  rettype MethodM(param1, ..., paramN) = 0;
};
RPC_DECLARE_GUID(IfsName1, 0)
...

Клиентская и серверная фабрики объектов

Клиентская фабрика MyLibrary<ExceptionFilter>::CreateObject предоставляет 3 перегруженных метода, различающихся способом указания идентификаторов кокласса и интерфейса. Серверная фабрика позволяет создать прокси-объект для существующего серверного объекта (CreateClientObject).

Фабрики клиентских прокси-объектов.
        template <class ExceptionFilter>
struct MyLibrary
{
  template <class Archive, class T>
  static  rpclib::RPCRESULT CreateObject(Archive &ar, rpclib::RPCREFCLSID  
    idCoClass, T **ppInterface);
  template <class Archive, class T, class C>
  static rpclib::RPCRESULT CreateObject(Archive &ar, const C &,
    T **ppInterface);

  template <class Archive>
  static rpclib::RPCRESULT CreateObject(Archive &ar,
    rpclib::RPCREFCLSID idCoClass,
    rpclib::RPCREFIID idInterface,
    LPVOID *ppInstance);
  
  template <class Archive>
  staticvoid *CreateClientObject(Archive &ar, rpclib::Token nObject,
    rpclib::RPCREFIID nId);
};

Клиентские прокси-объекты (реализующие требуемые интерфейсы) и прокси-функции, реализующие механизм удаленного вызова

        template <class Archive, class I>
struct IfsName1ClientProxy: public IfsParent1<Archive, I>
{
  rettype Method1(param1, ..., paramN)
  {
    //  (de)marshalling code
  }
  ...
  rettype MethodM(param1, ..., paramN)
  {
    //  (de)marshalling code
  }
};
...

Набор классов реализации и их регистрации

        struct IfsName1Impl;
RPC_DECLARE_GUID(IfsName1Impl, 0)
RPC_REGISTER_COCLASS(IfsName1Impl, IfsName1)
...

Прокси-функции и таблицы диспетчеризации вызовов для серверной части


Рисунок 5 Диспетчеризация удаленного вызова на сервере посредством таблиц.

Диспетчеризация вызова обеспечивается связанными таблицами – таблицей интерфейсов (MyLibraryFactory::dispatch_table) и таблицами методов, создаваемыми для каждого интерфейса (IfsNameХххServerProxy::proxy_table). Серверная часть получает токен, который определяет объект, относительно которого производится вызов метода, а также идентификаторы интерфейса (ObjectID) и метода (MethodID). Данные идентификаторы используются как индексы в соответствующих таблицах для поиска прокси-функции, которая и обеспечивает демаршаллинг параметров, вызов метода, маршаллинг и отправку результата вызова.

Таблицы диспетчеризации и серверные прокси-методы.
        template <class Archive>
struct IfsName1ServerProxy: public IfsParent1<Archive, I>
{
  void Method1ServerProxyId1(rpclib::Token pObject, Archive &ar)
  {
    //  код (де)маршаллинга и вызова метода
  }
  ...
  void MethodMServerProxyIdM(rpclib::Token pObject, Archive &ar)
  {
    //  код (де)маршаллинга и вызова метода
  }
  typedefvoid (*proxy_fun)(rpclib::Token, Archive &);
  static proxy_fun proxy_table[];
};

// таблица методов
template <class Archive>
typename IfsName1ServerProxy <Archive>::proxy_fun 
IfsName1ServerProxy <Archive>::proxy_table[]=
{
  &Method1ServerProxyId1,
  ...
  &MethodMServerProxyIdM
};
...

template <class Archive, class Factory>
struct MyLibraryFactory
{
  staticvoid CreateServerObject(rpclib::Token pObject, Archive &ar);
  staticvoid Dispatch(Archive &ar);
  {
    // код сериализации id и объекта
    ...
    dispatch_table[IFS_ID(id)][METHOD_ID(id)](object, ar);
  }
  typedefvoid (*proxy_fun)(rpclib::Token, Archive &);
  typedef proxy_fun *dispatch_fun;
  static proxy_fun creator;
  static dispatch_fun dispatch_table[];
};

template <class Archive, class Factory>
typename MyLibraryFactory <Archive, Factory>::proxy_fun 
MyLibraryFactory <Archive, Factory>::creator = CreateServerObject;

// таблица интерфейсов
template <class Archive, class Factory>
typename MyLibraryFactory <Archive, Factory>::dispatch_fun 
MyLibraryFactory <Archive, Factory>::dispatch_table[]=
{
  &creator,
  IfsName1ServerProxy<Archive>::proxy_table,
  ...
  IfsNameNServerProxy<Archive>::proxy_table
};

Маршаллинг/демаршаллинг параметров

Было бы по меньшей мере странно, если при работе с типизированными параметрами мы использовали бы только макросы, не уделяя внимания безопасности и строгой типизации, предоставляемой С++. В данном случае нет никаких причин не использовать в качестве основного средства реализации маршаллинга/демаршаллинга параметров стандартные средства метапрограммирования – шаблоны и перегрузки функций.

Основные проблемы, которые нам необходимо решить:

  1. Сериализация/десериализация параметров, как встроенных типов, так и типов, определяемых пользователем.
  2. Определение принятого по умолчанию направления передачи параметра.
  3. Изменение и настройка маршаллинга/демаршаллинга пользователем.

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

      template <typename T, param_direction nDirection, bool bServer>
struct serialize_traits
{
  template <typename Archive>
  staticvoid Store(Archive& ar, const T &a);

  template <typename Archive, class R>
  staticvoid Read(Archive& ar, R& a);
};

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

Диспетчер типов интерфейса сериализации.
      template <typename T, param_direction nDirection, bool bServer>
struct serialize_traits
    : boost::mpl::if_c
    <
    boost::is_reference<T>::value,
    typename impl::reference_type_serializer<T, nDirection, bServer>,
    typename boost::mpl::if_c
      <
      boost::is_pointer<T>::value,
      typename impl::pointer_type_serializer<T, nDirection, bServer>,
      typename impl::common_type_serializer<T, bServer>
      >::type
    >::type
{
};
СОВЕТ

Следует отметить, что в абсолютном большинстве случаев для поддержки своего типа пользователю достаточно определить для архива соответствующие операторы сериализации (<< и >>). При этом автоматически поддерживаются все возможности маршаллинга – различные типы (указатели, ссылки, по значению) и направления передачи.

С определением направления передачи параметра связана интересная проблема – необходимо автоматически определять направление передачи параметра, если оно явно не указано пользователем, иначе – использовать указанное направление. Таким образом, необходимо силами препроцессора обеспечить поддержку необязательных параметров. Предлагаем читателю самостоятельно помедитировать над следующим фрагментом кода, который обеспечивает данную функциональность для макроса RPC_METHOD. В результате подстановки в макрос RPC_PARAM_WITH_DIRECTION(x) выражения, опционально содержащего направление передачи параметра (param, (RPC_IN) param, (RPC_OUT) param или (RPC_INOUT) param) на выходе мы получаем RPC_PARAM_DIRECTION_xxx, param:

      #define RPC_PARAM_RPC_PARAM_DIRECTION            RPC_PARAM_DIRECTION_AUTO,
#define RPC_PARAM_RPC_PARAM_DIRECTION_RPC_IN     RPC_PARAM_DIRECTION_IN
#define RPC_PARAM_RPC_PARAM_DIRECTION_RPC_OUT    RPC_PARAM_DIRECTION_OUT
#define RPC_PARAM_RPC_PARAM_DIRECTION_RPC_INOUT  RPC_PARAM_DIRECTION_INOUT

#define RPC_PARAM_DIRECTION(x) PPC_CAT(RPC_PARAM_DIRECTION_, x),
#define RPC_PARAM_WITH_DIRECTION(x) PPC_CAT(RPC_PARAM_, RPC_PARAM_DIRECTION x)

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

Детализация интерфейса сериализации
      namespace parameters
{
  namespace impl
  {
    template <class T>
    struct xxx_type_traits
    {
      typedef some_type type;
    };

    template <class T>
    struct yyy_xxx_type_serializer_client
    {
      template <typename Archive>
      staticvoid Store(Archive& ar, const T &a);
      template <typename Archive>
      staticvoid Read(Archive& ar, T &a);
    };

    template <class T>
    struct yyy_xxx_type_serializer_server
    {
      template <typename Archive>
      staticvoid Store(Archive& ar, const xxx_type_traits<T>::type &a);
      template <typename Archive>
      staticvoid Read(Archive& ar, xxx_type_traits<T>::type &a);
    };

Интерфейсы xxx_type_traits (где xxx – наименование типа, например, pointer) используются для задания storage-типа на серверной стороне. Необходимость данных интерфейсов обуславливается следующими соображениями:

  1. Очевидно, нам нужно иметь временное хранилище, которое содержит в себе требуемые для вызова метода данные.
  2. Тип данного хранилища необязательно должен совпадать с типом самих данных (например, передача в качестве параметра С-style строки).
  3. Для некоторых типов (например, хендлов) необходимо производить дополнительные действия (например, очистку и/или копирование).

Таким образом, специализируя данные интерфейсы, пользователь определяет хранилище для своего типа при вызове метода на серверной стороне.

Интерфейсы yyy_xxx_serializer_client/yyy_xxx_serializer_server (где yyy – направление передачи параметра, xxx – класс типа параметра) используются для специализации сериализации параметров на серверной и клиентской сторонах. Существуют подобные интерфейсы и для более общего уровня специализации, без учета направления передачи параметров – их можно увидеть в коде диспетчера типа интерфейсов сериализации.

На самом деле, существует целый класс пользовательских типов, для которых необходимо определить правила передачи их как параметров – это сами интерфейсы. Действительно, очень желательно уметь работать с указателями на интерфейсы: передавать их на серверную сторону, возвращать из методов интерфейсы к существующим серверным объектам. Для решения данной задачи при определении интерфейса прозрачно для пользователя задаются соответствующие специализации при помощи макроса RPC_DECLARE_IFS_PARAMETERS_SPECIALIZATION(ifs_type). Поскольку данные определения в некоторых случаях могут снизить скорость компиляции, есть возможность их отключения путем определения макроса RPC_NO_IFS_PARAMETERS_SPECIALIZATION.

Подведем итоги обсуждения интерфейса сериализации параметров. Пользователь имеет следующие возможности специализации сериализации для своего типа:

  1. Переопределить операторы сериализации для архива. Этот способ подходит для большинства С++-типов, причем при этом автоматически поддерживаются не только параметры, предаваемые по значению, но и по ссылке, и указатели, а также направление передачи параметра.
  2. Специализировать трейты типа для серверного хранилища параметра.
  3. Специализировать сериализацию для конкретных направлений передачи параметров и типов.

Если направление передачи параметров не указано явно, оно определяется автоматически в соответствии со следующими правилами:

Тип параметра Модификатор Направление передачи
Значение RPC_IN
Ссылка RPC_INOUT
Const RPC_IN
Указатель RPC_INOUT
Const RPC_IN

Таким образом, единственным не описанным моментом сериализации параметров из приведенного ранее списка осталось обеспечение надежности передачи параметров. К сожалению, данному вопросу в текущей реализации интерфейса сериализации уделено недостаточно внимания. Вероятно, наиболее правильным (и, одновременно, накладным) решением является использование семантики транзакций для параметров на стороне клиента – т.е. принимать параметры во временное хранилище, затем, по приему всех параметров, актуализировать параметры временными значениями. Безусловно, очень желательно в таком случае иметь возможность указать для параметра «ненужность» использования столь дорогостоящей реализации – так называемая семантика «zero copy». Все это – предмет дальнейших исследований.

Использование разработанной библиотеки и вспомогательные средства

Основная функциональность

Отлично, скажет (не)терпеливый читатель, но как же мне все это использовать в своих проектах? На самом деле, все очень (да, именно, очень) просто. Рассмотрим пример, в котором у нас будет 2 серверных объекта, реализующих простейшие интерфейсы – IFirst и ISecond (производный от IFirst).

Для начала объявим интерфейсы (IFirst и ISecond), которые будут использоваться нами для RPC-вызовов. Это описание может (и, в большинстве случае, должно) находиться в отдельном файле:

Описание библиотеки интерфейсов.
        // mylibrary.h – included by client and server sides
RPC_LIBRARY
(
  MyLibrary,  // имя библиотеки
  0,    // id библиотеки
  RPC_IMPORT(RPC_UNKNOWN)  // импорт определения IRPCUnknown
  RPC_INTERFACE
  (
    IFirst, // имя интерфейса
    IRPCUnknown, // имя базового интерфейса
    RPC_METHOD(method1, rpclib::RPCRESULT, (int))
  )
  RPC_INTERFACE
  (
    ISecond, // имя интерфейса
    IFirst, // имя базового интерфейса
    RPC_METHOD(method2, rpclib::RPCRESULT, (constchar *))
  ),
  RPC_COCLASS(IFirstImpl, IFirst) // coclass IFirstImpl
  RPC_COCLASS(ISecondImpl, ISecond) // coclass ISecondImpl
)

В описании указаны 2 типа серверных объектов, реализующих данные интерфейсы – ISecondImpl и IFirstImpl, соответственно. Вот как выглядит их примерная реализация:

Прмер реализации серверных объектов.
        // серверная сторона – часть 1
        
struct IFirstImpl:public rpclib::std_coclass<IFirst>
{
  virtual rpclib::RPCRESULT method1(int p)
  {
    std::cout << " IFirstImpl::method1 called, p = " << p << std::endl;
    return RPC_S_OK;
  }
};

struct ISecondImpl:public rpclib::std_coclass<ISecond>
{
  virtual rpclib::RPCRESULT method1(int p)
  {
    std::cout << " ISecondImpl::method1 called, p = " << p << std::endl;
    return RPC_S_OK;
  }
  virtual rpclib::RPCRESULT method2(constchar *szStr)
  {
    std::cout << " ISecondImpl::method2 called, szStr = " << szStr << std::endl;
    return RPC_S_OK;
  }
};

// создание фабрики MyLibrary 
RPC_BEGIN_FACTORY(MyLibraryObjectFactory)
  RPC_OBJECT_ENTRY(IFirstImpl, IFirst)
  RPC_OBJECT_ENTRY(ISecondImpl, ISecond)
RPC_END_FACTORY()

Базовым интерфейсом является IRCPUnknown, имеющий единственный метод Release, который уничтожает серверный объект. Тем не менее, библиотека не требует использования именно этой базы, пользователь может реализовать свою собственную иерархию интерфейсов.

Теперь необходимо реализовать сервер, который будет обслуживать удаленные вызовы. Дабы упростить решение данной задачи, был разработан набор вспомогательных классов:

  1. mtlib::SynchronousServer<Server, Worker> – класс синхронного сервера, тип сервера задается параметром Server, каждое подключение выполняется в отдельном потоке и описывается классом Worker, основная работа потока клиента выполняется в методе Worker::Run().
  2. mtlib::ThreadedServer<Server> – декоратор для синхронного сервера, позволяет запускать отдельный поток для ожидания подключений.
  3. rpclib::StdDispatcher<Factory, Client> – стандартный рабочий поток библиотеки для синхронного сервера, класс Factory определяет серверную фабрику, которая используется в рабочем потоке для создания объектов, а также для диспетчеризации удаленных вызовов посредством функции Factory::Dispatch(Archive &). Client задает тип объектов рабочего потока и обязан предоставлять потоковые интерфейсы, совместимые с базовой реализацией сервера и оберткой рабочего потока.
  4. rpclib::CommonExceptionFilter – стандартный фильтр исключений, на любое исключение возвращающий значение RPC_E_NETWORK_ERROR. Это значение будет возвращено из метода интерфейса в случае ошибки транспорта.
  5. PipeServer – сервер подключений на основе pipes.

Используя данные классы, реализация простейшего RPC сервера выглядит следующим образом:

Пример RPC сервера.
        // серверная сторона – часть 2
        mtlib::ThreadedServer
    <
    mtlib::SynchronousServer
      <
      PipeServer,
      rpclib::StdDispatcher
        <
        MyLibrary<MfcExceptionFilter>::template Factory
          <
          bidi::CBiDiArchive,
          MyLibraryObjectFactory
          >,
        CFileContainer
        >
      >
    >   theServer;
if (theServer.Create("\\\\.\\pipe\\mynamedpipe"))
{
  if (theServer.Start())
  {
    //  ожидание выхода
    getch();
    theServer.Stop();
    WaitForSingleObject(theServer.GetStopHandle(), INFINITE);
  }
  theServer.Close();
}

В качестве транспорта (архива) в данном примере используется двунаправленный фасад MFC::CArchive – CBiDiArchive и, поскольку исключения MFC требуют дополнительных действий для правильной обработки, в качестве фильтра исключений используется MfcExceptionFilter; в качестве хранителя потока используется декоратор класса CFile – CFileContainer. Код данных классов приведен в прилагаемых к статье примерах.

Фактически, это все – реализация серверной части закончена.

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

Пример RPC клиента.
        // Создаем канал (pipe) и подключаемся к серверу
bidi::CNamedPipe file("\\\\.\\pipe\\mynamedpipe", 
  CFile::modeReadWrite | CFile::shareDenyNone);
// Используем двунаправленный фасад MFC::CArchive как транспорт
bidi::CBiDiArchive ar(file);

// Получаем proxy интерфейсов. Используем умный указатель для IFirst
rpclib::CRpcPtr<IFirst> pFirst;
ISecond *pSecond;

MyLibrary<MfcExceptionFilter>::CreateObject(ar, RPC_TYPE_REG(IFirstImpl), &pFirst);
MyLibrary<MfcExceptionFilter>::CreateObject(ar, RPC_TYPE_REG(ISecondImpl), &pSecond);
pFirst->method1(0xbadbeef);
pFirst->method1(0xdeadbee);
pSecond->method2("Hello, world!");

Собственно, это все, что надо сделать на клиенте! Как и в COM/outproc, pFirst и pSecond в данном случае представляют собой указатели на прокси-объекты. Вызывая методы прокси-объектов, мы прозрачно для клиента вызываем методы серверных объектов.

Расширенное использование

Нетрудно видеть, что при необходимости возможна реализация полноценных inproc/outproc серверов, прозрачных для клиента. Для этого функциональность MyLibrary<MfcExceptionFilter>::CreateObject необходимо вынести в отдельные библиотеки, которые в случае inproc-реализации будут создавать объекты при помощи MyLibraryObjectFactory, получая при этом указатель непосредственно на объект, а для outproc-реализации использовать приведенный выше пример, сохраняя контекст, например, в TLS, или используя для этого отдельный параметр в CreateObject:

Inproc сервер.
rpclib::RPCRESULT WINAPI RPCInitialize(LPVOID *pContext)
{
  *pContext = NULL;
  return RPC_S_OK;
}

rpclib::RPCRESULT WINAPI RPCCreateObject(LPVOID pContext, rpclib::RPCREFCLSID idCoClass, rpclib::RPCREFIID, LPVOID *ppInstance)
{
  ASSERT(pContext == NULL);
  *ppInstance = MyLibraryObjectFactory::CreateObject(idCoClass);
  return *ppInstance ? RPC_S_OK : RPC_E_FAIL;
}

void WINAPI RPCUninitialize(LPVOID pContext)
{
  ASSERT(pContext == NULL);
}
Outproc сервер, клиентский прокси-модуль.
rpclib::RPCRESULT WINAPI RPCInitialize(LPVOID *pContext)
{
  {
// Execute host process, if need
  }
  try
  {
  // create context and connect to the server
    *pContext = new CMyContext();
  }
  catch (CException *pEx)
  {
    pEx->Delete();
    RPCUninitialize(NULL);
    return RPC_E_FAIL;
  }
  return RPC_S_OK;
}

rpclib::RPCRESULT WINAPI RPCCreateObject(LPVOID pContext, rpclib::RPCREFCLSID idCoClass, rpclib::RPCREFIID idIfs, LPVOID *ppInstance)
{
  return MyLibrary<MfcExceptionFilter>::CreateObject(((CMyContext *)pContext)->m_archive, idCoClass, idIfs, ppInstance);
}

void WINAPI RPCUninitialize(LPVOID pContext)
{
  delete (CMyContext *)pContext;
  {
// destroy host process, if need
  }
}

Заключение

В данной статье мы рассмотрели базовые концепции организации удаленного взаимодействия, а также привели возможную реализацию библиотеки удаленного взаимодействия на С++ RCPLib, позволяющую организовать удаленное взаимодействие при минимальном «ручном» кодировании. Безусловно, библиотека находится в начальной стадии своего развития, особенно в части маршаллинга параметров, явного выделения протокола в отдельную сущность, реализации распределенной сборки мусора – фактически, архитектура библиотеки в целом все еще требует довольно значительной доработки. Тем не менее, уже сейчас RPCLib используется в одном из наших продуктов, где требуется интенсивное взаимодействие клиента и сервера, и обеспечивает неплохое соотношение производительности и стоимости разработки. Еще один несомненный плюс библиотеки – поддержка практически всей актуальной линейки компиляторов VC, начиная с 6.0 и заканчивая 9.0, а ввиду использования в библиотеке максимально простых конструкций С++ есть основания надеяться на работоспособность и на других распространенных компиляторах.

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

Ссылки

  1. Эндрю Таненбаум, Маартен ван Стен. Распределенные системы: принципы и парадигмы. Питер, 2003. – 877с.
  2. www.zeroc.com. ICE – фреймворк для разработки кроссплатформенных распределенных приложений.
  3. www.cse.wustl.edu/~schmidt/TAO.html. Известная реализация CORBA.
  4. omniorb.sourceforge.net/. Еще одна реализация CORBA, на наш взгляд, имеющая ряд преймуществ перед TAO.
  5. www.boost.org/doc.


Эта статья опубликована в журнале RSDN Magazine #3-2008. Информацию о журнале можно найти здесь