Всем привет. На Хабре есть довольно большое количество примеров реализации 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-файлу

Пример использования protoc
Пример использования protoc

Каждый раз руками вводить эти команды в консоли раздражительно и долго. В официальном примере автоматизация делается на этапе сборки проекта с помощью 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)


  1. Sazonov
    11.07.2024 08:25
    +8

    Зашел в статью в надежде увидеть полноценный пример на C++. Ибо сам хочу взять грпц для своего пет проекта. А тут увы, разочарование - слегка переделанный типовой туториал, коих навалом в интернете.

    Если бы вы покрыли тот вопрос, который упорно обходит Гугл в своих с++ примерах - цены бы вам не было. А это как раз авторизация с аутентификацией.


  1. North_Sky
    11.07.2024 08:25

    Спасибо, реально можно скачать и потыкаться из коробки.


  1. bigger777
    11.07.2024 08:25

    Спасибо, подробная и полезная статья.


  1. chilic
    11.07.2024 08:25
    +2

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


  1. sashabloodmoon
    11.07.2024 08:25

    Зашел на гитхаб, за столь проработанные тесты отдельное большое спасибо, старательный подход. Но пример становится более интересным тогда, когда он более сложный. Хотелось бы посмотреть и на асинхронный вариант, и на вариант с защищенными кредами.


  1. Keva
    11.07.2024 08:25

    Из опыта работы с gRPC на C++:

    • они лезут в настройки pthread, после чего любой ВАШ поток при старте хапнет 70 метров памяти вместо 4 (после вызова инициализации gRPC);

    • сборка проекта будет увлекательным квестом, так как надо будет регулярно переделывать порядок линковки с бесконечными мелкими библиотеками Google;

    • у вас в проекте появится второй STL - библиотека absl, дублирующая std;

    • при эксплуатации вы обнаружите функцию GPR_malloc, потому посмотрите ней в глаза до того, как решите использовать это индийское чудо;

    • ну и изюминка под конец - асинхронные серверы gRPC на C++ при нагрузках в несколько сотен RPS через пять-десять минут перестают обрабатывать запросы, потому что прекращает работать CompletionQueue; как с этим бороться, мы не знаем, как воспроизвести на примере из самого gRPC - знаем точно.


  1. Paulus
    11.07.2024 08:25

    В простых случаях это отлично работает. Но признаться я сдался, когда потребовалось асинхронно пушить сообщения от сервера одному или нескольким из N клиентов. Не хотите такой случай тестами покрыть? :)