Просто и на C++. Основы Userver — фреймворка для написания асинхронных микросервисов
В Яндекс.Такси придерживаются микросервисной архитектуры. С ростом количества микросервисов мы заметили, что разработчики много времени тратят на boilerplate и типичные проблемы, при этом решения не всегда получаются оптимальные.
Мы решили сделать свой фреймворк, с C++17 и корутинами. Вот так теперь выглядит типичный код микросервиса:
Response View::Handle(Request&& request, const Dependencies& dependencies) {
auto cluster = dependencies.pg->GetCluster();
auto trx = cluster->Begin(storages::postgres::ClusterHostType::kMaster);
const char* statement = "SELECT ok, baz FROM some WHERE id = $1 LIMIT 1";
auto row = psql::Execute(trx, statement, request.id)[0];
if (!row['ok'].As()) {
LOG_DEBUG() << request.id << " is not OK of " << GetSomeInfoFromDb();
return Response400();
}
psql::Execute(trx, queries::kUpdateRules, request.foo, request.bar);
trx.Commit();
return Response200{row['baz'].As()};
}
А вот почему это крайне эффективно и быстро — мы расскажем под катом.
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()) {
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()};
});
});
});
});
}
И тут на помощь приходят stackfull-корутины. Пользователь фреймворка думает, что пишет обычный синхронный код:
auto row = psql::Execute(trx, queries::kGetRules, request.id)[0];
Однако под капотом происходит приблизительно следующее:
- формируются и отправляются TCP-пакеты с запросом к базе данных;
- приостанавливается выполнение корутины, в которой в данный момент работает функция View: Handle;
- ядру ОС мы говорим: «Помести приостановленную корутину в очередь готовых к выполнению задач, как только от базы данных придёт достаточно TCP-пакетов»;
- не дожидаясь предыдущего шага, берём и запускаем другую готовую к выполнению корутину из очереди.
Другими словами, функция из первого примера работает асинхронно и близка к такому коду, использующему 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()) {
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()};
}
Вот только пользователю не надо задумываться о co_await и co_return, всё работает «само».
В нашем фреймворке переключение между корутинами происходит быстрее, чем вызов std: this_thread: yield (). Весь микросервис обходится очень малым количеством потоков.
На данный момент userver содержит в себе асинхронные драйверы:
* для сокетов ОС;
* http и https (клиент и сервер);
* PostgreSQL;
* MongoDB;
* Redis;
* работы с файлами;
* таймеров;
* примитивов синхронизации и запуска новых корутин.
Приведённый выше асинхронный подход к решению I/O-bound задач должен быть знаком Go-разработчикам. Но, в отличие от Go, мы не получаем накладных расходов по памяти и CPU от сборщика мусора. Разработчики могут пользоваться более богатым языком, с различными контейнерами и высокопроизводительными библиотеками, не страдать от отсутствия константности, 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 () не произойдёт.
Если же вдруг сервис начнёт «чудить», разработчик всегда может сказать работающему сервису: «Логируй в режиме DEBUG». И в этом случае записи «is not OK of» начнут появляться в логах, функция GetSomeInfoFromDb () будет выполняться.
Вместо итогов
В одной статье невозможно рассказать сразу обо всех особенностях и хитростях. Поэтому мы начали с небольшого введения. Пишите в комментариях, о каких вещах из userver вам было бы интересно узнать и почитать.
Сейчас мы раздумываем, выкладывать ли фреймворк в open source. Если решим, что да, подготовка фреймворка к открытию исходников потребует достаточно больших усилий.