Хабрахабр

Просто и на C++. Основы Userver — фреймворка для написания асинхронных микросервисов

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

Вот так теперь выглядит типичный код микросервиса: Мы решили сделать свой фреймворк, с C++17 и корутинами.

Response View::Handle(Request&& request, const Dependencies& dependencies) psql::Execute(trx, queries::kUpdateRules, request.foo, request.bar); trx.Commit(); return Response200{row["baz"].As<std::string>()};
}

А вот почему это крайне эффективно и быстро — мы расскажем под катом.

Userver — асинхронность

Наша команда состоит не только из матёрых C++ разработчиков: есть и стажёры, и младшие разработчики, и даже люди, не особо привыкшие писать на C++. Поэтому в основе дизайна userver — простота использования. Однако с нашими объёмами данных и нагрузкой мы так же не можем себе позволить неэффективно расходовать ресурсы железа.

Задачу эффективного ожидания ввода-вывода решают через асинхронные методы и callback’и: при асинхронных операциях нет необходимости плодить потоки выполнения, а соответственно, нет и больших накладных расходов на переключение потоков… вот только код достаточно сложно писать и поддерживать: Для микросервисов характерно ожидание ввода-вывода: зачастую ответ микросервиса формируется из нескольких ответов других микросервисов и баз данных.

void View::Handle(Request&& request, const Dependencies& dependencies, Response response) { auto cluster = dependencies.pg->GetCluster(); cluster->Begin(storages::postgres::ClusterHostType::kMaster, [request = std::move(request), response](auto& trx) { const char* statement = "SELECT ok, baz FROM some WHERE id = $1 LIMIT 1"; psql::Execute(trx, statement, request.id, [request = std::move(request), response, trx = std::move(trx)](auto& res) { auto row = res[0]; if (!row["ok"].As<bool>()) { if (LogDebug()) { GetSomeInfoFromDb([id = request.id](auto info) { LOG_DEBUG() << id << " is not OK of " << info; }); } *response = Response400{}; } psql::Execute(trx, queries::kUpdateRules, request.foo, request.bar, [row = std::move(row), trx = std::move(trx), response]() { trx.Commit([row = std::move(row), response]() { *response = Response200{row["baz"].As<std::string>()}; }); }); }); });
}

И тут на помощь приходят stackfull-корутины. Пользователь фреймворка думает, что пишет обычный синхронный код:

auto row = psql::Execute(trx, queries::kGetRules, request.id)[0];

Однако под капотом происходит приблизительно следующее:

  1. формируются и отправляются TCP-пакеты с запросом к базе данных;
  2. приостанавливается выполнение корутины, в которой в данный момент работает функция View::Handle;
  3. ядру ОС мы говорим: "«Помести приостановленную корутину в очередь готовых к выполнению задач, как только от базы данных придёт достаточно TCP-пакетов»;
  4. не дожидаясь предыдущего шага, берём и запускаем другую готовую к выполнению корутину из очереди.

Другими словами, функция из первого примера работает асинхронно и близка к такому коду, использующему C++20 Coroutines:

Response View::Handle(Request&& request, const Dependencies& dependencies) { auto cluster = dependencies.pg->GetCluster(); auto trx = co_await cluster->Begin(storages::postgres::ClusterHostType::kMaster); const char* statement = "SELECT ok, baz FROM some WHERE id = $1 LIMIT 1"; auto row = co_await psql::Execute(trx, statement, request.id)[0]; if (!row["ok"].As<bool>()) { LOG_DEBUG() << request.id << " is not OK of " << co_await GetSomeInfoFromDb(); co_return Response400{"NOT_OK", "Please provide different ID"}; } co_await psql::Execute(trx, queries::kUpdateRules, request.foo, request.bar); co_await trx.Commit(); co_return Response200{row["baz"].As<std::string>()};
}

Вот только пользователю не надо задумываться о co_await и co_return, всё работает «само».

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

На данный момент userver содержит в себе асинхронные драйверы:
* для сокетов ОС;
* http и https (клиент и сервер);
* PostgreSQL;
* MongoDB;
* Redis;
* работы с файлами;
* таймеров;
* примитивов синхронизации и запуска новых корутин.

Но, в отличие от Go, мы не получаем накладных расходов по памяти и CPU от сборщика мусора. Приведённый выше асинхронный подход к решению I/O-bound задач должен быть знаком Go-разработчикам. Разработчики могут пользоваться более богатым языком, с различными контейнерами и высокопроизводительными библиотеками, не страдать от отсутствия константности, RAII или шаблонов.

Userver — компоненты

Разумеется, полноценный фреймворк — это не только корутины. Задачи у разработчиков в Такси крайне разнообразны, и для решения каждой из них требуется свой набор инструментов. Поэтому в userver есть всё необходимое:
* для логирования;
* кеширования;
* работы с различными форматами данных;
* работы с конфигами и обновлением конфигов без перезапуска сервиса;
* распределённых блокировок;
* тестирования;
* авторизации и аутентификации;
* создания и отправки метрик;
* написания REST handlers;
+ кодогенерации и поддержки зависимостей (вынесено в отдельную часть фреймворка).

Userver — кодогенерация

Вернёмся к первой строчке нашего примера и посмотрим, что скрывается за Response и Request:

Response Handle(Request&& request, const Dependencies& dependencies);

С помощью userver вы можете написать любой микросервис, но для наших микросервисов есть требование, что их API должны быть задокументированы (описаны через swagger-схемы).

Например, для Handle из примера swagger-схема может выглядеть вот так:

paths: /some/sample/{bar}: post: description: | Ручка для статьи на Habr. summary: | Ручка, которая что-то делает с базой. parameters: - in: query name: id type: string required: true - in: header name: foo type: string enum: - foo1 - foo2 required: true - in: path name: bar type: string required: true responses: '200': description: OK schema: type: object additionalProperties: false required: - baz properties: baz: type: string '400': $ref: '#/responses/ResponseCommonError'

Ну а раз у разработчика уже есть схема с описанием запросов и ответов, то почему бы на её основе и не сгенерировать эти запросы и ответы? При этом в схеме можно указывать и ссылки на protobuf/flatbuffer/… файлы — кодогенерация из запроса сама всё достанет, провалидирует входные данные согласно схеме и разложит по полям структуры Response. Пользователю остаётся только написать функциональность в метод Handle, не отвлекаясь на boilerplate с разбором запросов и сериализацией ответа.

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

Request req;
req.id = id;
req.foo = foo;
req.bar = bar;
dependencies.sample_client.SomeSampleBarPost(req);

У подобного подхода есть ещё один плюс: всегда актуальная документация. Если разработчик вдруг попытается использовать параметры, которых нет в документации, то он получит ошибку компиляции.

Userver — логирование

Мы любим писать логи. Если логировать лишь самую важную информацию, то будет набегать несколько терабайт логов в час. Поэтому неудивительно, что у нашего логирования есть свои хитрости:
* оно асинхронное (разумеется 🙂 );
* мы умеем логировать в обход медленных std::locale и std::ostream;
* мы умеем переключать уровень логирования на лету (без перезапуска сервиса);
* мы не выполняем пользовательский код, если он нужен только для логирования.

Например, при штатной работе микросервиса уровень логирования будет выставлен в INFO, и всё выражение

LOG_DEBUG() << request.id << " is not OK of " << GetSomeInfoFromDb();

не станет вычисляться. В том числе вызов ресурсоёмкой функции GetSomeInfoFromDb() не произойдёт.

И в этом случае записи «is not OK of» начнут появляться в логах, функция GetSomeInfoFromDb() будет выполняться. Если же вдруг сервис начнёт «чудить», разработчик всегда может сказать работающему сервису: «Логируй в режиме DEBUG».

Вместо итогов

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть