Просто и на 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<bool>()) {
    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<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;
  • работы с файлами;
  • таймеров;
  • примитивов синхронизации и запуска новых корутин.

Приведённый выше асинхронный подход к решению 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. Если решим, что да, подготовка фреймворка к открытию исходников потребует достаточно больших усилий.

UPD: теперь фреймворк userver (без OpenAPI кодогенерации) доступен в опенсорсе

Другие публикации