RMI средствами С++ и boost.preprocessor

Источник: habrahabr

RMI - весьма банальная задача для ЯП, поддерживающих интроспекцию. Но, С++, к сожалению, к ним не относится.

Постановка задачи

1. Предоставить максимально простой синтаксис, чтоб невозможно было допустить ошибку.
2. Идентификация(связывание) процедур должна быть скрыта от пользователя для того, чтоб невозможно было допустить ошибку.
3. Синтаксис не должен накладывать ограничения на используемые С++ типы.
4. Должна присутствовать возможность версионности процедур, но, так, чтоб не ломалась совместимость с уже работающими клиентами.

Касательно четвертого пункта:
К примеру, у нас уже есть две процедуры add/div по одной версии каждой. Мы хотим для add добавить новую версию. Если просто добавить еще одну версию - у нас поплывут ID`ы процедур, о которых знают клиентские программы, собранные до внесения этого изменения.

Выбор инструмента

Т.к. конечный результат предполагается использовать совместно с С++ кодом, вариантов я вижу три:

  • Изобретаем синтаксис и пишем свой кодогенератор.
  • Используем С++ препроцессор.
  • Ищем нечто готовое и допиливаем под себя(если нужно).

Выскажусь о каждом из вариантов соответственно:

  • Зачем дополнительная стадия кодогенерации?
  • Препроцессор я люблю, и часто его использую.
  • Трата времени и сил. И, не понятно, будет ли в этом смысл.

Касательно первого, второго и третьего пунктов требований - препроцессорный вариант подходит.

Итак, выбор сделан - используем препроцессор. И да, разумеется boost.preprocessor.

Немного о препроцессоре

Типы данных С++ препроцессора:

  • arrays: (size, (elems, ....))
  • lists: (a, (b, (c, BOOST_PP_NIL)))
  • sequences: (a)(b)(c)
  • tuples: (a, b, c)

Типов, как видно, более чем достаточно.
Немного подумав, почитав про возможности и ограничения каждого их них, а также учтя желаемую простоту синтаксиса и невозможность допустить ошибку - выбор был сделан в пользу sequences и tuples.

Несколько поясняющих примеров.
(a)(b)(c) - sequence. Тут, мы описали sequence, состоящий из трех элементов.
(a) - также sequence, но состоящий из одного элемента. (внимание!)
(a)(b, c)(d, e, f) - снова sequence, но состоящий из трех tuples. (обратите внимание на первый элемент - уловочка, однако, но это и правда tuple)
(a)(b, c)(d, (e, f)) - опять же sequence, и так же состоящий из трех tuples. Но! Последний tuple состоит их двух элементов: 1) любого элемента, 2) tuple.
И, напоследок, такой пример: (a)(b, c)(d, (e, (f)(g))) - тут уж разберитесь сами ;)
Как видно, все и вправду нереально просто.

Прототипируем синтаксис

После недолгого раздумья, вырисовывается такой синтаксис:
(proc_name0, // имя процедуры (signature_arg0, signature_arg1, signature_argN) // первая версия процедуры (signature_arg0) // вторая версия процедуры ) (proc_name1, // имя процедуры (signature_arg0, signature_arg1) // единственная версия этой процедуры ) (proc_name2, // имя процедуры () // единственная версия этой процедуры (без аргументов) )
Ну… весьма употрибительно, однако.

Некоторые детали реализации

Т.к. одно из требований - версионность процедур, да еще и такая, чтоб не ломалась совместимость с уже существующими клиентами - нам, для идентификации процедур, понадобятся два ID`а. Первый - ID процедуры, второй - ID версии.

Поясню на примере.
Допустим, это описание API нашего сервиса. Допустим, у нас уже есть клиентские программы, использующие этот API.
(proc_name0, // procID=0 (signature_arg0, signature_arg1) // sigID=0 ) (proc_name1, // procID=1 (signature_arg0, signature_arg1) // sigID=0 ) (proc_name2, // procID=2 () // sigID=0 )
Теперь, для proc_name0() нам нужно добавить еще одну версию с другой сигнатурой.
(proc_name0, // procID=0 (signature_arg0, signature_arg1) // sigID=0 (signature_arg0, signature_arg1, signature_arg2) // sigID=1 ) (proc_name1, // procID=1 (signature_arg0, signature_arg1) // sigID=0 ) (proc_name2, // procID=2 () // sigID=0 )
Таким образом, у нас появился новый ID версии процедуры, в то время как прежний остался без изменений.
Было: (0:0), стало: (0:0)(0:1)
Т.е. именно этого мы и пытались добиться. Прежние клиенты как использовали (0:0), так и далее будут использовать эти идентификаторы, не переживая о том, что появились новые версии этих процедур.
Также условимся в том, что все новые процедуры нужно добавлять в конец.

Далее, нам нужно позаботиться о том, чтоб ID`ы автоматически проставлялись на обоих сторонах сервиса. Запросто! - просто используем одну и ту же описанную последовательность дважды, для генерации клиентской и серверной сторон!

Самое время представить, как мы хотим видеть все это в конечном счете:
MACRO( client_invoker, // name of the client invoker implementation class ((registration, // procedure name ((std::string, std::string)) // message : registration key )) ((activation, ((std::string)) // message )) ((login, ((std::string)) // message )) ((logout, ((std::string)) // message )) ((users_online, ((std::vector<std::string>)) // without args )) , server_invoker, // name of the server invoker implementation class ((registration, ((std::string)) // username )) ((activation, ((std::string, std::string, std::string)) // registration key : username : password )) ((login, ((std::string, std::string)) // username : password )) ((logout, (()) // without args )) ((users_online, (()) // without args ((std::string)) // substring )) )
Чтоб не было путаницы в том, кто ведущий, а кто ведомый - условимся так, что процедуры, описываемые на одной из сторон, являются реализациями, находящимися на противоположной стороне. Т.е., к примеру,client_invoker::registration(std::string, std::string) говорит нам о том, что реализация этой процедуры будет находиться на стороне сервера, в то время как интерфейс к этой процедуре будет находиться на стороне клиента, и наоборот.
(двойные круглые скобки мы используем потому, что препроцессор при формировании аргумента для нашего MACRO(), развернет нами описанное API. это можно побороть, но не знаю, нужно ли?..)

Итог

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

Код

(в качестве сериализации используется другой мой проект - YAS)

Как бонус, была добавлена системная процедура yarmi_error() - используется для сообщения противоположной стороне о том, что при попытке произвести вызов произошла ошибка. Посмотрите внимательно, в client_invoker::invoke(), десериализация и вызов обернуты в try{}catch() блок, а в catch() блоках производится вызов yarmi_error(). Таким образом, если при десериализации или вызове процедуры возникнет исключение - оно будет успешно перехвачено catch()блоком, и информация об исключении будет отправлена вызывающей стороне. То же самое будет происходить и в противоположном направлении. Т.е. если сервер вызвал у клиента процедуру, в ходе вызова которой возникло исключение - клиент отправит серверу информацию об ошибке, также дополнительно сообщив ID и версию вызова, в котором возникло исключение. Но, использовать yarmi_error() вы можете и сами, ничто этого не запрещает. Пример: yarmi_error(call_id, version_id, "message");

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

Классы client_invoker и server_invoker принимают два параметра. Первый их низ - класс, в котором реализованы вызываемые процедуры, второй - класс, в котором реализован метод send(yas::shared_buffer buf).
Если у вас один и тот же класс выполняет обе роли, вы можете сделать так:
struct client_session: yarmi::client_base<client_session>, yarmi::client_invoker<client_session> { client_session(boost::asio::io_service &ios) :yarmi::client_base<client_session>(ios, *this) ,yarmi::client_invoker<client_session>(*this, *this) // <<<<<<<<<<<<<<<<<<<<<<<<< {} };
Конечный вариант выглядит так:
struct client_session: yarmi::client_base<client_session>, yarmi::client_invoker<client_session> { client_session(boost::asio::io_service &ios) :yarmi::client_base<client_session>(ios, *this) ,yarmi::client_invoker<client_session>(*this, *this) {} void on_registration(const std::string &msg, const std::string ®key) {} void on_activation(const std::string &msg) {} void on_login(const std::string &msg) {} void on_logout(const std::string &msg) {} void on_users_online(const std::vector<std::string> &users) {} };

Интерфейс к противоположной стороне будет унаследован из предкаyarmi::client_invoker. Т.е., к примеру, будучи в конструкторе нашегоclient_session, вы можете вызвать процедуру registration() следующим образом:
{ registration("niXman"); }
Ответ мы получим в нашу реализацию client_session::on_registration(std::string msg, std::string regkey)
Всё!

Из недоделок нужно отметить следующую:
В именах типов, описывающих процедуры, нельзя использовать запятые, ибо препроцессор не понимает контекста, в котором они используются. Будет исправлено.

В конечном счете, все это вылилось в проект под названием YARMI(Yet Another RMI).
Описанный кодогенератор закодирован в одном файле - yarmi.hpp. В сумме, на реализацию кодогенератора ушел один рабочий день.

Пример использования всего этого дела можно увидеть тут и тут. Первый тестовый проект все еще не завершен, к сожалению.

В дополнение к описанному, на странице проекта вы найдете коды асинхронного многопользовательского однопоточного сервера, и коды клинента.

Вместо заключения

Планы:
1. Генерация нескольких интерфейсов
2. Описать спецификацию (хоть она и проще некуда)
3. Возможность использовать собственный алокатор


Страница сайта http://test.interface.ru
Оригинал находится по адресу http://test.interface.ru/home.asp?artId=35053