🐙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
Проще всего воспользоваться готовым шаблоном сервиса.
- Заходите в репозиторий, нажимаете «Use this template».
- Клонируете к себе полученный репозиторий.
- Если у вас 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
и после небольших доработок радуетесь результату:
Пока писали статью, подумали, что для настройки базы данных требуется многовато шагов. Поэтому сделали сервис-шаблон с PostgreSQL, чтобы можно было взять только что расписанный пример и на его основе делать свои микросервисы с этой базой.
Планы
Разумеется, выход в опенсорс — не разовое мероприятие. Уже сейчас все наши нововведения мы сразу публикуем в публичный репозиторий. Предстоит большая работа по поддержке новых фич и переносу наших процессов разработки на полностью открытый workflow.
Планируем доработки во вспомогательных репозиториях. Например, будем улучшать сервис динамических конфигов и дорабатывать сервис-шаблон под ваши запросы. Также планируем добавлять больше примеров использования, внедрять интересные и полезные фичи (к примеру, приоритизацию таск-процессоров) и ещё больше оптимизаций.
Когда всё очевидные шероховатости будут исправлены, нам предстоит сделать наш первый внешний релиз… и начать новый виток работ уже для него.
Если у вас возникли вопросы — спрашивайте в комментариях, пишите нам в телеграм-чатик или заводите feature request. Узнать больше о возможностях и вариантах использования, познакомиться с документацией и найти множество примеров можно на странице userver.tech.
Автор: Antony Polukhin