Яндекс выложил в опенсорс бету фреймворка userver

в 7:55, , рубрики: c++, c++17, coroutine, coroutines, dynamic changes, github, json, logs, mongo, mongodb, open source, postgres, postgresql, python, python3, redis, sql, synchronization, userver, yaml, Yandex, Блог компании Яндекс, высокая производительность, открытый код
Сегодня мы анонсируем выход в опенсорс фреймворка userver для создания высоконагруженных приложений. Для нас это важный способ поделиться опытом в разработке микросервисов, который мы накопили. Вот ссылка на GitHub-репозиторий c исходным кодом, документацией, примерами, шаблоном для создания своих сервисов (с настроенным CI, сборкой и тестовым окружением) и сервисом динамических конфигов. Всё это опубликовано под лицензией Apache 2.0.

Яндекс выложил в опенсорс бету фреймворка userver - 1

🐙userver позволяет быстро создавать эффективные микросервисы на языке C++ и уже много лет активно используется в Яндекс Go, Еде, Лавке, Доставке, Маркете, финтехе и других проектах. Вот из каких требований мы исходили в процессе разработки:

  • Простота. Стажёр или студент, приходя к нам, может уже через неделю написать и отправить в продакшен новый микросервис.
  • Надёжность. Многие ошибки, в том числе и связанные с многопоточностью, можно поймать на этапе компиляции. Кроме того, фреймворк даёт подсказки по исправлению проблем.
  • Полнота. В userver есть всё необходимое для тестирования, работы с разными базами данных, кеширования, логирования, трейсинга, распределённых блокировок, работы с JSON, BSON, YAML, изменения параметров сервиса на лету и так далее.

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

Как всё началось

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

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

Минусы монолитной архитектуры на этом не заканчиваются. Вот несколько других:

  • Объединение всего кода внутри монолита. При большой кодовой базе сборка и тесты могут занимать часы, а выкатка — целый день.
  • Тесное взаимодействие разных частей кода. Нужно тратить много сил на ревью кода, чтобы интерфейсы разных частей монолита не превратились в «кашу».
  • Хрупкость. Изменение в одном модуле может сломать другой модуль.
  • Размытые зоны ответственности. В процессе разработки многие части кода обобщаются, начинают использоваться разными командами — и это хорошо. Но в результате непонятно, кто отвечает за полученный модуль — первые авторы; те, кто внёс больше всего правок; или те, кто активнее всего пользуется модулем в своём коде.

Для множества небольших команд в Такси намного лучше подходила микросервисная архитектура.

Начало разработки userver

Переход с монолита на микросервсы должен быть максимально простым для разработчиков. В новом решении необходимо иметь возможность переиспользовать старый, проверенный временем C++-код — и разработчиков C++. То есть нужен C++-фреймворк. Язык хорош ещё и тем, что не зависит от одного вендора/компании, является статически типизированным и одним из самых эффективных языков программирования.

Также для микросервисной архитектуры характерно ожидание ввода-вывода. Требовалось учесть это в новом фреймворке и выстроить его внутреннюю архитектуру максимально эффективно для IO-bound-задач.

Так началась разработка корутинного движка userver. Корутины позволили асинхронно, эффективно по CPU работать с операционной системой — и в то же время сохранили простоту написания кода.

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

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

Основные достоинства фреймворка — это простота использования, эффективность, полнота функциональности и отлаженность на масштабах Яндекса.

Конечно, IO-bound-приложения не редки и на рынке предоставлено множество известных и зрелых продуктов. Есть отдельный язык программирования Go, ориентированный на написание таких приложений. Но это одновременно и минус. Если у вас кодовая база на C++ или основная разработка на C++, то внедрять новый язык будет трудозатратно, а имеющийся код придётся переписывать или адаптировать к использованию из другого языка.

Есть узкоспециализированные фреймворки, в том числе и на C++, для написания микросервисов. Однако их функциональности порой не хватает. Например, чтобы организовать функциональное тестирование сервиса и мокать обращения к другим микросервисам, вам придётся разрабатывать собственную инфраструктуру.

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

userver — проверенное многими сервисами решение. Фреймворк выдерживает огромный поток запросов и при этом обладает богатой функциональностью для разработки, диагностики, мониторинга, трейсинга, отладки и экспериментов.

Опыт пользователей

Лавка

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

Сейчас userver — это основной фреймворк разработки бэкенда Лавки.

Еда

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

Доставка

У Доставки, помимо микросервисов на userver, были и микросервисы на собственном фреймворке C++. Но один из старых сервисов не всегда работал стабильно, а порой ему и вовсе становилось плохо. В качестве эксперимента решили переписать его на userver. Проблемы изчезли, производительность подросла.

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

Go

С появлением первых версий userver начали потихоньку создавать часть новой функциональности на нём. По мере добавления возможностей во фреймворк всё больше новых функций писали на нём. А там дошли руки и до откалывания кусков от монолита с последующим переносом на userver.

Бóльшая часть изначального монолитного кода достаточно быстро превратилась в множество микросервисов. Казалось, процесс должен остановиться: от монолита откололи достаточно много функциональности, чтобы считать его микросервисом. Но внезапно победило удобство. userver оброс новыми полезными свойствами, упростилось тестирование. Старый монолит стал уступать в возможностях. В итоге перенесли на общий фреймворк userver и ключевую часть монолита.

Выход в опенсорс

Нам показалось хорошей идеей поделиться с миром своим фреймворком, чтобы он нашёл применение в новых полезных направлениях.

Но внезапно ситуация оказалась намного интереснее. Когда мы постарались тихо и незаметно выложить исходники на Гитхаб, у нас ничего не вышло. Уже через пару часов разработчики заметили исходники и стали активно экспериментировать. В итоге в первые же недели нам принесли пару пул-реквестов на поддержку новых платформ, идей для оптимизаций (некоторые мы внедрили в день появления идеи) и много пожеланий по расширению функциональности. Всё это до анонса.

То есть и нам предлагают интересные вещи, и мы полезны проектам за пределами Яндекса.

Что значит статус «бета»

Мы специально используем пометку «бета», чтобы подчеркнуть — фреймворк сейчас находится в процессе переезда на открытую разработку:

  • Ещё не все наши внутренние CI-проверки доступны снаружи.
  • Заапстримлены ещё не все интеграции с инструментами, принятыми за пределами Яндекса.
  • Нужно больше примеров для документации, так как нет возможности подглядеть решение из внутренних сервисов Яндекса.

Можно ли использовать userver в продакшене уже сейчас?

Мы годами применяем userver для сотен своих высоконагруженных высокодоступных сервисов. Экспериментируйте, пробуйте, насколько вам подходят текущие возможности. Если чего-то не хватает — пишите нам в телеграм-чатик или заводите feature request.

Как попробовать userver

Проще всего воспользоваться готовым шаблоном сервиса.

  1. Заходите в репозиторий, нажимаете «Use this template».
  2. Клонируете к себе полученный репозиторий.
  3. Если у вас POSIX платформа (Linux или macOS), можно разрабатываться локально. Устанавливаете зависимости, как написано в документации. Пример для Ubuntu 22.04:
    sudo apt install $(cat scripts/docs/en/deps/ubuntu-22.04.md | tr 'n' ' ')
    git config --global --add safe.directory $(pwd)/third_party/clickhouse-cpp

    Затем проверяете, что всё работает, через make test-debug.

    Если у вас неподдерживаемая в данный момент платформа или вы просто предпочитаете Docker — проверьте, что всё работает, через make docker-test-debug.

Итого, у вас на руках работающий микросервис, который в ответ на запросы в эндпоинт /hello приветствует пользователя.

Делаете git push в свой репозиторий, и GitHub CI сам запустит все тесты для C++ и Python.

В полученном сервисе имеется несколько файлов:

  • src/hello.cpp — весь код эндпоинта /hello.
  • src/hello_test.cpp — пример юнит-теста.
  • src/hello_benchmark.cpp — пример бенчмарка.
  • tests/test_basic.py — функциональные тесты сервиса. Поднимается весь сервис, задаются запросы в эндпоинт, проверяется ответ. Можно добавлять и другие файлы *.py с новыми тестами, они автоматически подхватятся при запуске тестов.
  • CMakeLists.txt — CMake-файл сборки.
  • Makefile — вспомогательный файл, чтобы одной командой запускать тесты, сборки, форматирования кода и так далее.
  • .github/workflows/ — CI-файлы для сборки, установки и тестирования кода.

Пишем свой микросервис

Давайте добавим к нашему микросервису из прошлого раздела базу данных, например PostgreSQL. Сделаем так, чтобы эндпоинт запоминал людей, которые к нему пришли, а уже знакомых приветствовал иначе.

Для этого в его класс в src/hello.cpp добавляете соединение с кластером PostgreSQL:

userver::storages::postgres::ClusterPtr pg_cluster_;

И инициализируете это соединение из конструктора эндпоинта:

  Hello(const userver::components::ComponentConfig& config,
        const userver::components::ComponentContext& component_context)
      : HttpHandlerBase(config, component_context),
        pg_cluster_(
            component_context
                .FindComponent<userver::components::Postgres>("postgres-db-1")
                .GetCluster()) {}

Добавляете необходимую конфигурацию для базы данных в статический конфиг configs/static_config.yaml.in:

        postgres-db-1:
            dbconnection: $dbconnection
            blocking_task_processor: fs-task-processor
            dns_resolver: async

        dns-client:
            fs-task-processor: fs-task-processor

И регистрируете необходимые для старта компоненты:

void AppendHello(userver::components::ComponentList& component_list) {
  component_list.Append<Hello>();
  component_list.Append<userver::components::Postgres>("postgres-db-1");
  component_list.Append<userver::clients::dns::Component>();
}

Базу данных подключили. Можно приступать к написанию логики приложения всё в том же src/hello.cpp:

  std::string HandleRequestThrow(
      const userver::server::http::HttpRequest& request,
      userver::server::request::RequestContext&) const override {
    const auto& name = request.GetArg("name");

    auto user_type = UserType::kFirstTime;
    if (!name.empty()) {
      auto result = pg_cluster_->Execute(
          userver::storages::postgres::ClusterHostType::kMaster,
          "INSERT INTO hello_schema.users(name, count) VALUES($1, 1) "
          "ON CONFLICT (name) "
          "DO UPDATE SET count = users.count + 1 "
          "RETURNING users.count",
          name);

      if (result.AsSingleRow<int>() > 1) {
        user_type = UserType::kKnown;
      }
    }

    return service_template::SayHelloTo(name, user_type);
  }

Код SayHelloTo:

std::string SayHelloTo(std::string_view name, UserType type) {
  if (name.empty()) {
    name = "unknown user";
  }

  switch (type) {
    case UserType::kFirstTime:
      return fmt::format("Hello, {}!n", name);
    case UserType::kKnown:
      return fmt::format("Hi again, {}!n", name);
  }

  UASSERT(false);
}

Всё, можно приступать к написанию функциональных тестов в tests/test_basic.py:

async def test_db_updates(service_client):
    response = await service_client.post('/v1/hello', params={'name': 'World'})
    assert response.status == 200
    assert response.text == 'Hello, World!n'

    response = await service_client.post('/v1/hello', params={'name': 'World'})
    assert response.status == 200
    assert response.text == 'Hi again, World!n'

    response = await service_client.post('/v1/hello', params={'name': 'World'})
    assert response.status == 200
    assert response.text == 'Hi again, World!n'

Также нужно задать схему базы данных в postgresql/schemas/db-1.sql:

DROP SCHEMA IF EXISTS hello_schema CASCADE;

CREATE SCHEMA IF NOT EXISTS hello_schema;

CREATE TABLE IF NOT EXISTS hello_schema.users (
    name TEXT PRIMARY KEY,
    count INTEGER DEFAULT(1)
);

И начальные параметры динамического конфига в configs/dynamic_config_fallback.json:

  "POSTGRES_CONNECTION_POOL_SETTINGS": {
    "postgres-db-1": {
      "max_pool_size": 15,
      "max_queue_size": 200,
      "min_pool_size": 8
    }
  },
  "POSTGRES_DEFAULT_COMMAND_CONTROL": {
    "network_timeout_ms": 750,
    "statement_timeout_ms": 500
  },
  "POSTGRES_HANDLERS_COMMAND_CONTROL": {
    "/v1/hello": {
      "POST": {
        "network_timeout_ms": 500,
        "statement_timeout_ms": 250
      }
    }
  },
  "POSTGRES_QUERIES_COMMAND_CONTROL": {},
  "POSTGRES_STATEMENT_METRICS_SETTINGS": {
    "postgres-db-1": {
      "max_statement_metrics": 5
    }
  }

Запускаете make test-debug и после небольших доработок радуетесь результату:

Яндекс выложил в опенсорс бету фреймворка userver - 2

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

Планы

Разумеется, выход в опенсорс — не разовое мероприятие. Уже сейчас все наши нововведения мы сразу публикуем в публичный репозиторий. Предстоит большая работа по поддержке новых фич и переносу наших процессов разработки на полностью открытый workflow.

Планируем доработки во вспомогательных репозиториях. Например, будем улучшать сервис динамических конфигов и дорабатывать сервис-шаблон под ваши запросы. Также планируем добавлять больше примеров использования, внедрять интересные и полезные фичи (к примеру, приоритизацию таск-процессоров) и ещё больше оптимизаций.

Когда всё очевидные шероховатости будут исправлены, нам предстоит сделать наш первый внешний релиз… и начать новый виток работ уже для него.

Если у вас возникли вопросы — спрашивайте в комментариях, пишите нам в телеграм-чатик или заводите feature request. Узнать больше о возможностях и вариантах использования, познакомиться с документацией и найти множество примеров можно на странице userver.tech.

Автор: Antony Polukhin

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js