пятница, 3 июля 2009 г.

Разработка RPC приложений с использованием libevent

Разрабатывая клиент-серверные системы часто возникает вопрос, какой протокол выбрать.
Этому вопросу следует уделить достаточно внимания, ведь протокол, новый или уже существующий,
накладывает ряд ограничений и дополнительных трудозатрат на разработку и поддержку системы в будущем.

При выборе протокола следует, как минимум, ответить на следующие вопросы:
— как распределяются задачи между взаимодействующими узлами; Задачи для клиента, задачи для сервера;
— какой объем данных будет минимальный для передачи, а какой максимальный;
— как часто необходимо обмениваться данными;
— какая топология узлов? Будут ли клиент и сервер находиться в одной подсети;
— сколько потребуется времени/ресурсов для расширения протокола и сколько кода это может затронуть;
— как предполагается организовывать защиту трафика;
— критична ли скорость обмена данными и возможно ли масштабировать систему вертикально, например, за счет повышения пропускной способности канала.

Я хотел бы поделиться опытом использования RPC протокола в системе с трехуровневой логической архитектурой.
Решение использовать RPC с бинарным протоколом было следствием следующих факторов:
— вся вычислительная нагрузка приходится на серверную часть (возможно горизонтальное масштабирование);
— размер ответа небольшой, но сильно отличающийся для разных запросов;
— обмениваться данными необходимо достаточно часто и с многих клиентов;
— сервер будет находиться в одной гигабитной подсети с клиентами, поэтому размер пакета играет меньше роли чем время на установку нового соединения;
— расширять функционал сервера прийдется неоднократно;
— нет необходимости в защите трафика;
— поскольку основная часть сервера написана на С, не хотелось путать туда тяжеловесные XML-RPC решения;
— проверенный временем libevent предоставляет удобную и тонкую обвертку над процессом маршалинга и асинхронными вызовами.

О технологии вызова удаленных процедур можно почитать например на citforum.ru [1], так что я не буду вдаваться в теорию, а покажу на примере, как использовать RPC в приложении на C/C++.
Хочу добавить, что данное решение (libevent + RPC) является кроссплатформенным, проверял на FreeBSD, Mac OS X и Windows.

Вот то, что нам необходимо для работы:
1. Компилятор С/С++.
2. Библиотека libevent (1.4.x/2.x).
3. Python (необходим для скрипта event_rpcgen.py).
4. Сам event_rpcgen.py (устанавливается в систему, либо можно взять из дистрибутива).

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

Что характерно для систем реализующих RPC, это процесс упорядочивания структур данных для передачи другому узлу, который может находиться как на локальной системе, так и на удаленной, таким образом, чтоб было возможно восстановить их в полной точности (сохраняя размеры и типы данных). Этот процесс называется маршалингом [2]. Практически любая библиотека реализующая RPC предоставляет средства автоматического маршалинга и демаршалинга. Не исключение и libevent.

Создаем файл, в котором описываются структуры для запросов/ответов, расширение файла должно быть .rpc.

Вот пример моего файла QueryTypes.rpc


struct StatRequest {
optional int uptime = 1;
optional int uname = 2;
}
struct StatReply {
optional string uptime = 1;
optional string uname = 2;
}


Доступны следующие типы данных:
1. struct — структура, аналог struct в C (в конце точка с запятой не ставится).
2. int — аналог uint32_t.
3. string — char *, строка переменной длины.
4. bytes — вектор uint8_t, можно указывать длину, например bytes data[24].
5. array struct[type] — вектор структур type.

Модификатор optional указывает на то, что это не обязательно поле, и его наличие необходимо проверять макросом EVTAG_HAS().

После указания имени поля (поля именуются тегами) указывается его числовой id.

Теперь можно генерировать код, для работы с этими структурами:

$ event_rpcgen.py QueryTypes.rpc

Если все указано без ошибок, можно увидеть примерно следующее:

Reading "QueryTypes.rpc"
Created struct: StatRequest
Added entry: uptime
Added entry: uname
Created struct: StatReply
Added entry: uptime
Added entry: uname
... creating "QueryTypes.gen.h"
... creating "QueryTypes.gen.c"

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

RPC сервер


1. Заголовочные файлы


Нам понадобятся следующие заголовочные файлы:

#include <event.h>
#include <evhttp.h>
#include <evrpc.h>

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

#ifdef __cplusplus
extern "C" {
#endif

#include "QueryTypes.gen.h"

#ifdef __cplusplus
}
#endif


2. Регистрация удаленных процедур


Собственно то, ради чего была эта затея — процедуры для удаленного вызова. Их необходимо зарегистрировать и сгенерировать код для автоматизации рутинных вещей [2].
Все вызовы RPC имеют следующий прототип: ИмяПроцедуры(СтруктураЗапроса, СтруктураОтвета).
Я назвал процедуру GetServerStat:

// Регистрируем
EVRPC_HEADER(GetServerStat, StatRequest, StatReply);
// Генерируем автокод
EVRPC_GENERATE(GetServerStat, StatRequest, StatReply);
Также, необходимо объявить функцию, которая будет обрабатывать вызов процедуры:

void GetServerStatCB(EVRPC_STRUCT(GetServerStat)* rpc, void * arg);


3. Создание слушающего RPC сервера


Поскольку выбрана реализация на базе HTTP сервера, необходимо зарегистрировать сокет, и
инициализировать HTTP мини-сервер. Создание сокета — дело тривиальное, это можно посмотреть
в моем примере.

// Инициализируем базу механизма событий
event_base * serv_base = event_init();
// Создаем HTTP сервер (это, на самом деле, легковесная обвертка над сокетом)
evhttp * http_base = evhttp_new(serv_base);
// и указывем сокет, на каком он будет работать
evhttp_accept_socket(http_base, server_sock);
// Создаем базу для RPC протокола
evrpc_base * rpc_base = evrpc_init(http_base);

После этого, необходимо зарегистрировать все обработчики для всех удаленных процедур:

EVRPC_REGISTER(rpc_base, GetServerStat, StatRequest, StatReply, GetServerStatCB, NULL);

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

event_dispatch();

При выходе, порядок очистки структур следующий:

EVRPC_UNREGISTER(rpc_base, GetServerStat);
evrpc_free(rpc_base);
evhttp_free(http_base);

4. Обработка запросов


Теперь самое интересное — обработка запросов и формирование ответов.
В реализации обработчика выполняются нехитрые стандартные вещи:

void GetServerStatCB(EVRPC_STRUCT(GetServerStat)* rpc, void *arg)
{
// Связывание запроса
StatRequest * request = rpc->request;
// и ответа
StatReply * reply = rpc->reply;
// Проверка полей
if (EVTAG_HAS(request, uptime)) {
// и присвоение значений в ответной структуре
EVTAG_ASSIGN(reply, uptime, data.c_str());
}
// в конце обработчика, если все ОК, даем об этом знать
EVRPC_REQUEST_DONE(rpc);
}

Есть. А теперь клиент.

RPC клиент


1. Заголовки


Заголовки подключаются такие же, как и в серверной части.

2. Регистрация процедур


Процедуры регистрируем так же: EVRPC_HEADER и EVRPC_GENERATE
А вот обработчик результата другой, а именно:

void GotServerStat(struct evrpc_status *status, struct StatRequest *request, struct StatReply *reply, void *arg);

где

status — результат обработки удаленного вызова. Нас интересует случай EVRPC_STATUS_ERR_NONE — значит все Ok,
request и reply — наши структуры,
arg — произвольный аргумент (указывается при регистрации самого обработчика (см. далее).

3. Инициализация RPC



// Событийная база
event_base * base = event_init();
// HTTP здесь нужен только для обработки заголовка HTTP пакета ( этот вызов лишь инициализирует внутренние буферы).
evhttp * http = evhttp_new(base);
// Аналогично с RPC
evrpc_base * rpc = evrpc_init(http);


А теперь, поехали: создаем подключение к RPC серверу

// IP адрес и порт сервера
const char * host = "127.0.0.1";
u_short port = 8090;
evhttp_connection * evcon = evhttp_connection_new(host, port);

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

evrpc_pool * pool = evrpc_pool_new(base);
// Добавляем подключение в пул
evrpc_pool_add_connection(pool, evcon);
// Создаем и инициализируем структуру запроса. Кстати, вызовы StatRequest_new, StatRequest_free,.. - генерируются
// автоматически скриптом event_rpcgen.py
StatRequest * stat_req = StatRequest_new();
EVTAG_ASSIGN(stat_req, uptime, 1); // запрос на получение uptime (флаг 1)
EVTAG_ASSIGN(stat_req, uname, 1); // запрос на получение uname
// Создаем структуру ответа
StatReply * stat_rep = StatReply_new();
// Делаем запрос, указав, что при получении ответа вызвать обработчик GotServerStat
EVRPC_MAKE_REQUEST(GetServerStat, pool, stat_req, stat_rep, GotServerStat, NULL);
// Обрабатываем запрос и ответ
event_dispatch();

В обработчике ответа вызовем функцию выхода из диспетчера, поэтому далее следует код очистки структур.

StatRequest_free(stat_req);
StatReply_free(stat_rep);
evrpc_pool_free(pool);
evrpc_free(rpc);
evhttp_free(http);


4. Обработчик ответа


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

void GotServerStat(struct evrpc_status *status, struct StatRequest *request, struct StatReply *reply, void *arg)
{
// Проверяем статус возврата
if (status->error == EVRPC_STATUS_ERR_NONE) {
// и читаем доступные значения
char * value = NULL;
if (EVTAG_HAS(reply, uptime) && (EVTAG_GET(reply, uptime, &value) != -1)) {
printf("[Reply] uptime: %s\n", value);
}
if (EVTAG_HAS(reply, uname) && (EVTAG_GET(reply, uname, &value) != -1)) {
printf("[Reply] uname: %s\n", value);
}
}
// Можно выходить из цикла диспетчера событий
event_loopexit(NULL);
}

Как видно, здесь нет ничего сложного, внутренняя реализация этой технологии достаточно эффективна, о чем можно судить по результатам тестирования средствами Apachebench [3].

$ ab -c 100 -n 1000 -p post.http.txt -T application/octet-stream http://10.1.3.10:8080/.rpc.GetServerStat
Server Hostname: 10.1.3.10
Server Port: 8080

Document Path: /.rpc.GetServerStat
Document Length: 136 bytes

Concurrency Level: 100
Time taken for tests: 0.067 seconds
Complete requests: 1000
Failed requests: 0
Broken pipe errors: 0
Non-2xx responses: 1058
Total transferred: 283544 bytes
Total POSTed: 250930
HTML transferred: 143888 bytes
Requests per second: 14925.37 [#/sec] (mean)
Time per request: 6.70 [ms] (mean)
Time per request: 0.07 [ms] (mean, across all concurrent requests)
Transfer rate: 4232.00 [Kbytes/sec] received
3745.22 kb/s sent
7977.22 kb/s total

Connnection Times (ms)
min mean[+/-sd] median max
Connect: 0 1 0.4 1 4
Processing: 3 4 0.8 4 8
Waiting: 1 4 0.7 4 7
Total: 3 6 0.9 6 9

Percentage of the requests served within a certain time (ms)
50% 6
66% 6
75% 6
80% 7
90% 7
95% 8
98% 8
99% 9
100% 9 (last request)

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

Скачать работающий пример RPC сервера и клинета (Makefile для UNIX/Max OS) можно отсюда.

Спасибо за внимание и успешной разработки.

___
1. Вызов удаленных процедур (RPC) — http://www.citforum.ru/operating_systems/sos/glava_12.shtml
2. Теоретические основы маршалинга — http://ru.wikipedia.org/wiki/Маршалинг
3. Документация по работе с инструментом Apachebench — http://httpd.apache.org/docs/2.2/programs/ab.html

2 комментария:

Andrew комментирует...

Ссылка мертвая. Не могли бы перезалить?

Андрей Булавинов комментирует...

Вот