Всем привет. На Хабре есть довольно большое количество примеров реализации gRPC-серверов на GO, чуть в меньшей степени на python, еще меньше - на других языках. Поиск примеров данного проекта для C++ дал мне не так много информации, как хотелось бы. К счастью, очень крутое решение-экземпляр есть на официальном сайте (ссылка). Если вам не хочется читать код и комментарии на английском языке, добро пожаловать под кат.
Теория, а точнее, ее отсутствие
Давайте предположим, что вы знаете базовую теорию о gRPC и в некоторой степени разбираетесь в том, что такое proto-файлы, а также как с помощью утилиты protoc из них сгенерировать исходный код на C++.
Тестовый пример
Сейчас перейдем к примеру, на базе которого будем разбирать по шагам последовательность действий по созданию gRPC-сервера. Пусть у нас есть потребность создать приложение, в некотором смысле напоминающее гастрономическую социальную сеть.
Здесь пользователь может:
зарегистрироваться
подписаться на другого человека
оценить свое посещение в ресторан или кафе по 5-балльной шкале с указанием заказанных блюд и даты посещения
зайти на страницу того, на кого подписался, просмотреть его посещения
Архитектура
Прежде чем приступать к написанию, давайте набросаем примерную архитектуру проекта. Отталкиваться будем от описания функциональных возможностей, приведенного выше.
«Возможность подписаться/оценить посещение/оставить комментарий» — всю эту информацию нужно где‑то хранить. Добавим компонент «storage».
«Зарегистрироваться» — наличие этой функциональной возможности подразумевает реализацию сразу трех важных составляющих: идентификация, аутентификация и авторизация. Но для простоты примера мы их опустим.
Пока мы никак не можем взаимодействовать с приложением, нужна API‑шка. Вот как раз здесь и будет базироваться наша gRPC‑составляющая (помимо всего прочего, для локального взаимодействия с сервером на C++ мы могли бы развернуть unix domain socket, для удаленного — взаимодействовать по принципам REST, так что одним gRPC API‑шка не ограничивается).
Единственное, что осталось — точка входа в ПО, функция main, которую мы поместим в компонент «app».
Обратите внимание: границы взаимодействия компонентов проходят через интерфейсы, в то время как реализация компонента может быть абсолютно любая, лишь бы реализовывала интерфейс.
К gRPC для плюсов
В первую очередь нас интересует gRPC‑API. Углубимся в эту часть архитектуры. Тут следует подумать над proto‑контрактами, на основе которых будем в дальнейшем генерировать C++ код. Отталкиваться будем все от той же описательной части.
Пользователь может зарегистрироваться, значит сделаем rpc Registrate(…) returns (…). Первое троеточие в скобках — то, что rpc‑метод (вызываемый на клиенте) использует в качестве аргумента, второе троеточие — то, что метод возвращает, то есть ответ от сервера. Назовем их ClientRegistrationReq и ClientRegistrationResp соответственно. Дальше следует подумать, чем наполнить содержимое этих двух сообщений. Что обычно указывает человек при регистрации? Электронную почту, имя/фамилия, телефон (по желанию). Что в ответ на такое сообщение может прислать сервер? Статус регистрации (успех или нет) и необязательная описательная часть (например, причина, по которой не удалось осуществить регистрацию). Тогда имеем что‑то вроде этого:
service GrpcTransport {
// Registrate new user
rpc Registrate(ClientRegistrationReq) returns (ClientRegistrationResp) {}
}
// Message for registrate new user
message ClientRegistrationReq {
string electronic_mail = 1;
string name = 2;
string sername = 3;
optional string phone_number = 4;
}
// Response on new user registration
message ClientRegistrationResp {
bool ok = 1;
optional string reason = 2;
}
Совсем немного о том, что в proto-файле происходит. Во-первых, мы объявили сервис GrpcTransport, в рамках которого существуют те или иные rpc-методы, вызываемые клиентом (синтаксис: rpc $MehtodName($Params) returns ($ReturnedVals) {}). Далее мы описываем каждое из сообщений, то есть $Params и $ReturnedVals соответственно. С точки зрения языка программирования их можно воспринимать как структуры с перечисленными полями определенного типа (со всеми типами proto можно ознакомиться здесь). Используемое в примере ключевое слово optional говорит о том, что параметр является необязательным.
Идем дальше. «Подписаться на другого человека». В социальных сетях люди ищут других по имени или же ник-нейму, а его мы не предусмотрели. Но с 100%-ной вероятностью электронная почта - уникальный идентификационный ключ. Конечно, это является приватной информацией, однако для тестового примера поиск другого пользователя по почте вполне подойдет. В ответ от сервера получаем статус, удалось ли подписаться и, если нет, то почему. Итак, наш rpc-метод Subscribe и соответствующие ему сообщения:
// Subscribe
rpc Subscribe(SubscriptionReq) returns (SubscriptionResp) {}
// Message for subscribe to another user
message SubscriptionReq {
string electronic_mail = 1;
}
// Server response about subscription
message SubscriptionResp {
bool ok = 1;
optional string reason = 2;
}
Оценка заведения. Здесь дадим человеку возможность ввести название и адрес заведения, указав список оцененных блюд (map из названия блюда, то есть строки, в числовую целую оценку с максимумом 5). В ответ будем ожидать статус — добавилось посещение или нет с тем же опциональным указанием причины.
// Estimate dishes
rpc EstimateEstablishment(EstimationReq) returns (EstimatonResp) {}
// Message for estimate dishes
message EstimationReq {
string name = 1;
string address = 2;
map<string, int32> dishes = 3;
}
// Server response about dishes estimation
message EstimatonResp {
bool ok = 1;
optional string reason = 2;
}
Осталось заключительное. Зайти на страницу того, на кого подписались, посмотреть список его посещений. Опять-таки, интересующего нас человека идентифицируем по e-mail. В ответ получаем список посещений с оценками и необязательную причину-пояснение, если серверу не удалось информацию передать.
// Subscription estimations
rpc GetSubscriptionEstimations(SubscriptionEstimationsReq) returns (SubscriptionEstimationsResp) {}
// Message for get subscription dishes estimations
message SubscriptionEstimationsReq {
string electronic_mail = 1;
}
// Server response about getting subscription estimations
message SubscriptionEstimationsResp {
bool ok = 1;
optional string reason = 2;
repeated EstimationReq estimations = 3;
}
Тип последнего поля — массив (ключевое слово repeated) messag-ей, придуманных нами же.
Автоматизированная кодогенерация
Итак, proto-файл у нас есть, все наши «контракты» продуманы. Что дальше? Следующий шаг — использование специальной утилиты protoc, которая на базе proto-файлов сгенерирует нам файлы с кодом на C++. Подробное описание того, как установить protoc, можно найти здесь. Для кодогенерации нам потребуется использование двух команд:
protoc -I <path to folder with proto-files>\
--cpp_out=<path where need to place generated cpp messages files>\
<path to proto-file>
protoc -I --grpc_out=<path where need to place cpp services files>\
--plugin=protoc-gen-grpc=`which grpc_cpp_plugin`\
<path to proto-file>
Разберёмся в перечисленных параметрах.
<path to folder with proto files> — путь к папке, где лежат proto-контракты (в нашем случае файл всего один, но их может быть больше)
<path where need to place generated cpp messages files> — путь, куда мы хотим поместить сгенерированные header и cpp-файлы, описывающие messag-ы
<path where need to place cpp services files> — путь, по которому будут лежать сгенерированные header и cpp-файлы, описывающие сервисы и их rpc-методы
<path to proto file> — путь к нашему proto-файлу
Каждый раз руками вводить эти команды в консоли раздражительно и долго. В официальном примере автоматизация делается на этапе сборки проекта с помощью CMake. Мы поступим точно так. Но сначала следует определиться с иерархией файлов и папок нашего проекта. Ниже иерархия:
Ключевое — CMakeLists.txt папки ./lib/api/src/ (относительно корня проекта). В CMake-листе необходимы следующие строки:
# Generate cpp-files due to proto
execute_process(
COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/generate.sh
)
Команда execute_process нужна для запуска bash-скрипта generate.sh с автоматизированной генерацией кода. В самом скрипте мы указываем пути к исходникам proto и те инструкции, которые с ними нужно сделать:
#!/bin/bash
SCRIPT_DIR_PATH=$(cd "$(dirname "$0")" && pwd)
echo $SCRIPT_DIR_PATH
SRC_DIR=$SCRIPT_DIR_PATH/
PROTO_DIR=/$SCRIPT_DIR_PATH/../resource
protoc -I $PROTO_DIR --cpp_out=$SRC_DIR $PROTO_DIR/main.proto
protoc -I $PROTO_DIR --grpc_out=$SRC_DIR --plugin=protoc-gen-grpc=`which grpc_cpp_plugin` $PROTO_DIR/main.proto
Использование сгенерированного кода
В результате генерации кода в новые файлы сохраняются в папку ./lib/api/src/ (относительно корня). Их мы будем использовать для реализации двух простейших публичных функций (запуск и остановка сервера):
namespace api_grpc
{
//! Public function for start gRPC-server
void runServer(const std::string& address, std::shared_ptr<storage::IStorageManager> pStoreManager);
//! Public function for stop gRPC-server
void stopServer();
} // namespace api_grpc
Так, они будут объявлены в файле ./lib/api/include/api/GrpcAPI.h, а определены — в ./lib/api/src/GrpcAPI.cpp:
using api_grpc::ServerGRPC;
using grpc::Server;
using grpc::ServerBuilder;
ServerGRPC* pService = nullptr;
std::unique_ptr<Server> pServer = nullptr;
void api_grpc::runServer(const std::string& address,
std::shared_ptr<storage::IStorageManager> pStoreManager) {
// создаем свой сервис
pService = new ServerGRPC(pStoreManager);
// создаем gRPC-шный server builder
ServerBuilder serverBuilder;
// добавляем порт и специфицируем вид подключения (не защищенное)
serverBuilder.AddListeningPort(address, grpc::InsecureServerCredentials());
// регистрируем наш собственный сервис и запускаем
serverBuilder.RegisterService(pService);
pServer = serverBuilder.BuildAndStart();
std::cout << "Server listening on " << address << std::endl;
// этот метод является блокирующим
pServer->Wait();
}
//! Public function for stop gRPC-server
void api_grpc::stopServer() {
// этот метод завершит блокоирующий Wait()
pServer->Shutdown();
delete pService;
delete(pServer.release());
}
Имплементация завязана на написанном нами классе ServerGRPC, который является наследником GrpcTransport::Service (как раз тот сервис, что мы описали в proto файле и сгенерировали при помощи protoc):
namespace api_grpc
{
//! gRPC-server implementation
class ServerGRPC final : public GrpcTransport::Service {
public:
//! Ctor by default
ServerGRPC() = delete;
//! Constructor
ServerGRPC(std::shared_ptr<storage::IStorageManager> pStoreManager);
//! Destructor
~ServerGRPC();
//! Registrate new user
grpc::Status Registrate(
grpc::ServerContext* context,
const ClientRegistrationReq* request,
ClientRegistrationResp* response
) override;
//! Subscribe to user
grpc::Status Subscribe(
grpc::ServerContext* context,
const SubscriptionReq* request,
SubscriptionResp* response
) override;
//! Estimate dishes
grpc::Status EstimateEstablishment(
grpc::ServerContext* context,
const EstimationReq* request,
EstimatonResp* response
) override;
//! Subscription estimations
grpc::Status GetSubscriptionEstimations(
grpc::ServerContext* context,
const SubscriptionEstimationsReq* request,
SubscriptionEstimationsResp* response
) override;
private:
std::shared_ptr<storage::IStorageManager> pStorageManager_;
};
}
Как видите, все те rpc-методы, что присутствуют в proto-файле, есть и здесь, причем каждый из них помечен override, потому что точно такие же виртуальные методы есть в классе-родителе GrpcTransport::Service.
То, каким смыслом вы наполните эти методы, зависит от вашей фантазии.
Заключение
Какой бизнес-логикой наполнил методы я, вы можете посмотреть на github-е, куда я прикрепил весь код данного проекта (статья уже получилась довольно жирной), а также написал юнит-тесты (использовал GTest), чтобы у вас была возможность подебажиться, дабы лучше понять всю суть. Для полноты тестов сервера нужен и gRPC-клиент, который тоже там есть. Надеюсь, данный материал будет полезен. Ссылка.
Комментарии (7)
chilic
11.07.2024 08:25+2Стоит упомянуть что в статье приводится пример синхронного сервера, который будет понимать по новому потоку при каждом новом подключении.
sashabloodmoon
11.07.2024 08:25Зашел на гитхаб, за столь проработанные тесты отдельное большое спасибо, старательный подход. Но пример становится более интересным тогда, когда он более сложный. Хотелось бы посмотреть и на асинхронный вариант, и на вариант с защищенными кредами.
Keva
11.07.2024 08:25Из опыта работы с gRPC на C++:
они лезут в настройки pthread, после чего любой ВАШ поток при старте хапнет 70 метров памяти вместо 4 (после вызова инициализации gRPC);
сборка проекта будет увлекательным квестом, так как надо будет регулярно переделывать порядок линковки с бесконечными мелкими библиотеками Google;
у вас в проекте появится второй STL - библиотека absl, дублирующая std;
при эксплуатации вы обнаружите функцию GPR_malloc, потому посмотрите ней в глаза до того, как решите использовать это индийское чудо;
ну и изюминка под конец - асинхронные серверы gRPC на C++ при нагрузках в несколько сотен RPS через пять-десять минут перестают обрабатывать запросы, потому что прекращает работать CompletionQueue; как с этим бороться, мы не знаем, как воспроизвести на примере из самого gRPC - знаем точно.
Paulus
11.07.2024 08:25В простых случаях это отлично работает. Но признаться я сдался, когда потребовалось асинхронно пушить сообщения от сервера одному или нескольким из N клиентов. Не хотите такой случай тестами покрыть? :)
Sazonov
Зашел в статью в надежде увидеть полноценный пример на C++. Ибо сам хочу взять грпц для своего пет проекта. А тут увы, разочарование - слегка переделанный типовой туториал, коих навалом в интернете.
Если бы вы покрыли тот вопрос, который упорно обходит Гугл в своих с++ примерах - цены бы вам не было. А это как раз авторизация с аутентификацией.