
Всем привет! Меня зовут Василий Куликов, я работаю ведущим разработчиком в Техплатформе Екома и Райдтеха Яндекса и последние пять лет разрабатываю фреймворк userver. Это веб‑фреймворк, который позволяет создавать высоконагруженные отказоустойчивые сервисы на С++. Сегодня я расскажу, как написать на нём игрушечный, но рабочий сервис, который реализует функциональность pastebin. Сервис будет выполнять роль бэкенда (и частично фронтенда) сайта, на котором можно запостить текстовую заметку и получить ссылку на неё. Также на этом примере можно увидеть, как пишется сервис на userver и какой API используется для решения повседневных задач бэкенда.
Общая архитектура
Сначала определимся с общей архитектурой сайта. Пусть это будет единственный stateless‑сервис upastebin со своей базой данных Postgres. Тот же upastebin будет отдавать статику (HTML, JS, CSS) из памяти. В более сложном варианте с тяжёлой статикой и динамическими страницами понадобился бы nginx с фронтенд‑фреймворком, но в нашем примере обойдёмся без них.

Определимся с тем, что должно быть реализовано в upastebin. Нам нужен эндпоинт, который будет отдавать страницу с конкретной заметкой. Чтобы не рендерить страницу на стороне бэкенда, мы будем рендерить её в браузере. За счёт этого она будет статичной и не будет отличаться от заметки к заметке.
Также нужна страница для создания новой заметки. Из API‑эндпоинтов нам нужны следующие: для создания заметки, для получения текста заметки и для получения списка последних заметок.
Для работы с базой данных (БД) нужны:
POST /api/v1/posts
— для создания новой заметки;GET /api/v1/posts/{id}
— для получения текста конкретной заметки;GET /api/v1/latest
— для получения списка последних N заметок.
Для отдачи статики:
/{id}
— для отдачи страницы с заметкой;/
— для написания нового поста;/r/*
— для отдачи остальной статики (JS, CSS).
Приготовления
Итак, приступаем к работе. Мы можем начать писать наш проект с нуля, а можем взять за основу готовый шаблон сервиса из списка. И нам вполне подойдёт один из шаблонов. Нажимаем кнопку Use this template и создаём новый проект. Согласно README, клонируем репозиторий:
git clone https://github.com/segoon/upastebin/ && cd upastebin
Для замены имени сервиса pg_service_template
на upastebin
копипастим из README команду переименования:
find . -not -path "./third_party/" -not -path ".git/" -not -path './build_*' -type f | xargs sed -i 's/pg_service_template/upastebin/g'
Теперь нам нужно подключить к проекту userver. Есть несколько способов, как это сделать:
скачать deb‑пакет с GitHub с последним релизом (сейчас есть пакеты для Ubuntu 22.04);
самостоятельно собрать userver и установить его либо через cmake ‑install, либо через cpack + dpkg ‑i;
подключить userver к нашему проекту как отдельную директорию и собирать его как часть проекта;
собрать проект в подготовленном докер‑образе Ubuntu 22.04 с предустановленным userver.
В нашем случае выберем путь отдельной директории:
cd third_party
git clone --depth 1 https://github.com/userver-framework/userver.git
Не забудьте добавить third_party/userver
в gitignore
! Если бы мы разрабатывали несколько сервисов, то более эффективным было бы клонировать userver в свою собственную директорию, а third_party/userver
в каждом сервисе сделать симлинками на настоящий userver.
Теперь мы готовы запустить сборку сервиса командой:
make build-debug
Эта команда автоматически запустит cmake в директории build_debug
и соберёт бинарник сервиса build_debug/upastebin
. Для сборки релиза замените debug на release.
Прогнать тесты (юнит‑тесты gtest
и testsuite
, т. е. тесты уровня сервиса) можно с помощью команды:
make test-debug
Если хочется быстро запустить сервис локально, чтобы подёргать эндпоинты с помощью curl или открыть локальный сайт в браузере, можно воспользоваться следующей командой:
make service-start-debug
Она поднимет инстанс сервиса локально от текущего пользователя и временную базу Postgres. Сервис будет запущен со статическим конфигом, в котором указан порт временного Postgres‑демона. При завершении команды по Ctrl + C сервис, Postgres и остальное окружение будет завершено и подчищено.
Сейчас можем подёргать ручку /ping
, которая проверяет живость сервиса с помощью curl:
$ curl -vs http://localhost:8080/ping
* Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /ping HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.81.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Date: Wed, 22 Jan 2025 08:54:34 UTC
< Content-Type: application/octet-stream
< Server: userver/2.3-rc (20240801002050; rv:ac9b1cd)
< Accept-Encoding: gzip, zstd, identity
< X-YaRequestId: 5036899c44a844408d6a8c2520dc3556
< X-YaTraceId: da1003fad6ef4532a714350ff1c2a3a7
< X-YaSpanId: c48dc23f495a3476
< Connection: keep-alive
< Content-Length: 0
<
* Connection #0 to host localhost left intact
Ожидаемо получаем статус 200, а также видим набор хедеров, добавляемых для отладки и трейсинга (X-Ya*)
.
Отдача статики
Наконец‑то начнём программировать! Добавим несколько эндпоинтов, для каждого из которых нужны секция с описанием в статическом конфиге и код этого эндпоинта (.cpp‑ и.hpp‑файлы).
Начнём с раздачи статики. Любой эндпоинт в userver описывается в виде класса, который унаследован от server::handlers::HttpHandlerBase
. Инстанс класса будет создан при инициализации компонентной системы в процессе старта сервиса.
В конструкторе мы получаем ссылки на зависимости эндпоинта из других компонентов. Для раздачи статики нам нужен components::FsCache, а точнее его клиент fs::FsCacheClient, который на старте рекурсивно вычитывает содержимое директории в память и может очень быстро отдать его по запросу. При этом FsCacheClient регулярно перечитывает содержимое директории, что может оказаться полезным в случае изменений файлов без перезапуска сервиса (при желании можно отключить эту функцию).
В методе HandleRequestThrow()
описывается код обработки HTTP‑запроса. Мы хотим взять параметр из URL, по нему сходить в FsCacheClient и получить содержимое скачиваемого файла. Для корректной работы в браузере нам также нужно выставить корректный Content‑Type, который мы будем определять по статическому отображению «расширение файла — тип контента». Если файл с таким именем не был найден, мы возвращаем код ошибки 404. При желании код ошибки можно сопроводить телом для отображения в браузере, но мы это опустим для простоты.
Пишем хедер:
src/resources.hpp
#pragma once
#include <userver/http/common_headers.hpp>
#include <userver/server/handlers/http_handler_base.hpp>
#include <userver/fs/fs_cache_client.hpp>
namespace upastebin {
class ResourcesHandler final
: public userver::server::handlers::HttpHandlerBase {
public:
// Имя требуется для регистрации компонента, для поиска настроек в статическом конфиге и для поиска со стороны других компонентов
static constexpr std::string_view kName = "handler-resources";
ResourcesHandler(const userver::components::ComponentConfig& config,
const userver::components::ComponentContext& context);
std::string HandleRequestThrow(
const userver::server::http::HttpRequest& request,
userver::server::request::RequestContext&) const override;
private:
const userver::fs::FsCacheClient& fs_client_;
};
} // namespace upastebin
Пишем cpp‑файл:
src/resources.cpp
#include "resources.hpp"
#include <userver/components/component_config.hpp>
#include <userver/components/component_context.hpp>
#include <userver/components/fs_cache.hpp>
namespace upastebin {
namespace {
std::string_view GetContentType(std::string_view extention) {
if (extention == ".js")
return "application/javascript";
else if (extention == ".css")
return "text/css";
else if (extention == ".html")
return "text/html; charset=UTF-8";
else
return "application/octet-stream";
}
} // namespace
ResourcesHandler::ResourcesHandler(
const userver::components::ComponentConfig& config,
const userver::components::ComponentContext& context)
: HttpHandlerBase(config, context),
fs_client_(
context.FindComponent<userver::components::FsCache>("resources-cache")
.GetClient()) {}
std::string ResourcesHandler::HandleRequestThrow(
const userver::server::http::HttpRequest& request,
userver::server::request::RequestContext&) const {
auto subpath = request.GetPathArg("subpath");
// Note: нет нужды валидировать subpath, т.к. FsCacheClient не отдаст файл на отсутствующий путь
auto file_ptr = fs_client_.TryGetFile("/" + subpath);
auto& response = request.GetHttpResponse();
if (file_ptr) {
response.SetContentType(GetContentType(file_ptr->extension));
return file_ptr->data;
} else {
auto& response = request.GetHttpResponse();
response.SetStatus(userver::server::http::HttpStatus::kNotFound);
return {};
}
}
} // namespace upastebin
Конструктор обычно содержит в себе две вещи: вычитывание из ComponentConfig тех параметров, которые указаны в секции компонента в статическом конфиге, и поиск других компонентов через интерфейс ComponentContext. В нашем случае параметров у компонента эндпоинта нет, поэтому мы только ищем компонент FsCache по типу, из него достаём клиента и сохраняем по ссылке в поле класса. После выхода из конструктора компонент готов к работе.
В HandleRequestThrow мы должны сделать всё, чтобы обработать входящий HTTP‑запрос. Каждый запрос обрабатывается в независимой корутине. В процессе обработки запросов может создаваться множество корутин, много больше, чем число потоков ОС. Если в эндпоинте мы ходим в базу или в другие сервисы, то из‑за ожидания корутина может долгое время находиться в состоянии сна. Это состояние не отнимает процессорного времени, но занимает некоторое количество памяти. Если есть риски превышения доступной памяти, можно ограничить число одновременно обрабатываемых запросов (параметр max_requests_in_flight
) или RPS (параметр max_requests_per_second
). Подробнее это описано в документации userver.
Далее мы получаем параметр пути из request.GetPathArg()
. Query‑параметр мы получали бы через GetArg()
. Указатель на файл в памяти получаем через TryGetFile()
, который возвращает shared_ptr
. Если указатель пустой, то мы получаем HttpResponse
из HttpRequest
через GetHttpResponse()
и выставляем статус 404 через SetStatus()
. Если файл найден, мы смотрим на его расширение, исходя из чего выставляем хедер Content‑Type с помощью вызова HttpResponse::SetContentType()
. На этом код эндпоинта готов.
Теперь займёмся конфигом. У каждого компонента есть своя подсекция конфига в yaml‑файле в секции components_manager.components.<component>
. Для эндпоинта там описываются обрабатываемый путь, принимаемые методы, используемый таск‑процессор и другие настройки.
Для большинства ручек подойдёт основной таск‑процессор main-task-processor
, в котором запускаются все корутины по умолчанию. В других таск‑процессорах могут работать корутины, которые способны заблокировать поток ОС (обычно fs-task-processor
), а также специальные корутины, которые должны работать даже при частичном отказе основных таск‑процессоров (обычно stats-task-processor
для отдачи метрик).
configs/static_config.yaml
handler-resources:
path: /r/{subpath}
method: GET
task_processor: main-task-processor
Наш эндпоинт готов. Зарегистрируем его в компонентной системе, добавив в main()
:
Если мы сейчас попробуем запустить сервис, получим ошибку отсутствия компонента resources-cache
. Мы его запросили, но не зарегистрировали в компонентной системе и в статическом конфиге. Исправим ситуацию и добавим компонент в main.cpp
:
component_list.Append<userver::components::FsCache>("resources-cache");
И в статический конфиг:
resources-cache:
dir: /var/www
update-period: 1m
fs-task-processor: fs-task-processor
Теперь сервис стартует.
Отдача /
и /{id}
будет упрощённой версией этого компонента. Вместо выбора файла будет отдаваться статичная страница из кеша.
Сохранение заметки
Напишем эндпоинт для сохранения новой заметки. Это будут файлы src/store.{hpp,cpp}
. Заведём новый класс StoreHandler
, унаследованный от server::handlers::HttpHandlerBase
. Добавим в него поле типа storages::postgres::ClusterPtr
, потому что мы хотим обращаться к Postgres.
Пишем hpp‑файл:
src/store.hpp
#pragma once
#include <userver/server/handlers/http_handler_base.hpp>
#include <userver/storages/postgres/postgres_fwd.hpp>
namespace upastebin {
class StoreHandler final : public userver::server::handlers::HttpHandlerBase {
public:
static constexpr std::string_view kName = "handler-store";
StoreHandler(const userver::components::ComponentConfig& config,
const userver::components::ComponentContext& context);
std::string HandleRequestThrow(
const userver::server::http::HttpRequest& request,
userver::server::request::RequestContext&) const override;
private:
userver::storages::postgres::ClusterPtr pg_;
};
} // namespace upastebin
Заметим, что используется инклуд‑файл postgres_fwd.hpp
, а не postgres.hpp
. Для многих хедеров существует облегчённый вариант с _fwd, который только объявляет типы, но не описывает их. Это позволяет заинклудить гораздо более лёгкий хедер из большого числа пользовательских хедеров. Это может существенно ускорить компиляцию большого проекта.
В конструкторе будем искать компонент userver::components::Postgres
и забирать у него указатель на кластер через GetCluster()
. Через этот указатель мы сможем обращаться к БД.
В HandleRequestThrow()
мы должны сгенерировать новый id
для заметки — для простоты пусть это будет UUID. Из query
забираем параметр author
, из тела забираем текст заметки, из HttpRequest
забираем IP пользователя. После этого делаем запрос к БД на INSERT
. В тексте ответа возвращаем JSON с id заметки.
Пишем cpp‑файл:
src/store.cpp
#include "store.hpp"
#include <userver/components/component_context.hpp>
#include <userver/formats/json/inline.hpp>
#include <userver/http/common_headers.hpp>
#include <userver/server/handlers/exceptions.hpp>
#include <userver/server/handlers/http_handler_base.hpp>
#include <userver/storages/postgres/cluster.hpp>
#include <userver/storages/postgres/component.hpp>
#include <userver/utils/boost_uuid4.hpp>
#include <userver/utils/datetime.hpp>
namespace upastebin {
StoreHandler::StoreHandler(const userver::components::ComponentConfig& config,
const userver::components::ComponentContext& context)
: HttpHandlerBase(config, context),
pg_(context.FindComponent<userver::components::Postgres>("postgres")
.GetCluster()) {}
std::string StoreHandler::HandleRequestThrow(
const userver::server::http::HttpRequest& request,
userver::server::request::RequestContext&) const {
auto uuid = userver::utils::generators::GenerateBoostUuid();
auto author = request.GetArg("author");
auto ip_source = request.GetRemoteAddress().PrimaryAddressString();
auto text = request.RequestBody();
auto created_at = userver::utils::datetime::Now();
pg_->Execute(userver::storages::postgres::ClusterHostType::kMaster,
"INSERT INTO upastebin.texts (uuid, author, ip_source, text, created_at) VALUES "
"($1, $2, $3, $4, $5);",
uuid, author, ip_source, text, created_at);
auto json_response = userver::formats::json::MakeObject("uuid",
userver::utils::ToString(uuid));
return ToString(json_response);
}
} // namespace upastebin
Для вычисления текущего времени мы используем utils::datetime::Now()
вместо std::chrono::system_clock::now()
, так как первый вариант обладает возможностью мокать время в тестах. Это может пригодиться при написании тестов и сверке результатов INSERT. Сам запрос отправляется через Cluster::Execute()
.
Cluster
— абстрактный интерфейс к БД. За ним стоит не одно соединение, а целый пул. Драйвер Postgres самостоятельно поддерживает его в актуальном состоянии, переоткрывая соединения при их недостатке, согласно настройкам статического конфига. В крайнем случае Execute()
синхронно откроет соединение с БД.
Первый аргумент Execute()
— тип хоста в кластере БД. Может быть мастер, синк слейв, обычный слейв, ближайший хост. Для записи в БД нам нужен мастер.
Второй аргумент у Execute()
— сам SQL‑запрос. Следом идут параметры этого запроса. Если он будет выполнен, то Execute()
вернёт ResultSet с результатами. В нашем случае они неинтересны, поэтому результат игнорируется. Если SQL‑запрос завершится по ошибке любого типа — сетевой, серверной (например, конфликт), клиентской (например, синтаксическая ошибка) — Execute()
выкинет исключение. Это исключение мы никак не обрабатываем: код, вызывающий HandleRequestThrow(), по умолчанию ловит все исключения и выставляет код HTTP‑ошибки 500, что для нас вполне подходит. В конце обработки мы создаём JSON‑объект с единственным полем id заметки и возвращаем его из эндпоинта в теле ответа.
Получение заметки
Перейдём к эндпоинту получения заметки по id. Заголовочный файл отличается от от прошлого файла src/store.hpp
только именем класса и значением kName
. В HandleRequestThrow()
нам нужно взять id
из query, сконвертить его в UUID
и сделать запрос к БД. Execute()
возвращает ResultSet
, из которого можно получить единственный элемент и его поле с именем text
. Возвращаем это поле в виде тела ответа. Если ResultSet
оказался пустым, то заметка с таким id
не найдена и мы возвращаем страницу 404.
src/retrieve_by_uuid.cpp
#include "retrieve_by_uuid.hpp"
#include <userver/components/component_context.hpp>
#include <userver/storages/postgres/cluster.hpp>
#include <userver/storages/postgres/component.hpp>
#include <userver/utils/boost_uuid4.hpp>
namespace upastebin {
RetrieveHandler::RetrieveHandler(
const userver::components::ComponentConfig& config,
const userver::components::ComponentContext& context)
: HttpHandlerBase(config, context),
pg_(context.FindComponent<userver::components::Postgres>("postgres")
.GetCluster()) {}
std::string RetrieveHandler::HandleRequestThrow(
const userver::server::http::HttpRequest& request,
userver::server::request::RequestContext&) const {
auto uuid_str = request.GetPathArg("uuid");
auto uuid = userver::utils::BoostUuidFromString(uuid_str);
auto result = pg_->Execute(
userver::storages::postgres::ClusterHostType::kSlave,
"SELECT text FROM upastebin.texts WHERE uuid=($1::TEXT);", uuid);
if (result.Size() == 0) {
request.GetHttpResponse().SetStatusNotFound();
return {};
}
auto row = result.Front();
auto text = row["text"].As<std::string>();
return text;
}
} // namespace upastebin
Список последних заметок
Нам нужна ещё одна ручка для получения набора последних заметок, чтобы вывести их списком на странице. Создаём ещё один эндпоинт — LatestHandler
. Хедер устроен точно так же, как и у предыдущих двух. В HandleRequestThrow()
мы должны сделать SELECT‑запрос к Postgres для получения последних N заметок. Execute()
возвращает нам эти заметки в ResultSet
. Мы можем по нему итерироваться с помощью for-each
.
Сами заметки мы будем отдавать в JSON‑форме. Работа с JSON происходит одним из двух способов. Во‑первых, для доступа read‑only используется formats::json::Value
— лёгкая обёртка над нодой дерева JSON. Во‑вторых, для создания нового JSON value используется билдер formats::json::ValueBuilder
. Разделение требуется для упрощения краевых случаев, в которых происходит присваивание типа x["foo"] = y["bar"]
. Тут может возникнуть путаница: это рекурсивное копирование дерева или копирование ссылки на поддерево? Чтобы не возникало подобных вопросов, типы на чтение и запись разнесены. Результат сборки JSON получает через ExtractValue()
у билдера.
Наша ручка возвращает JSON, из‑за чего её можно было бы отнаследовать от JsonHandlerBase
. В этом случае можно было бы не совершать лишние операции по сериализации JSON в строку. Однако JSON handler и принимает JSON‑запрос, и возвращает JSON‑ответ, а мы лишь возвращаем JSON.

Код src/latest.cpp:
src/latest.cpp
#include "latest.hpp"
#include <userver/components/component_context.hpp>
#include <userver/formats/json/inline.hpp>
#include <userver/formats/json/value_builder.hpp>
#include <userver/storages/postgres/cluster.hpp>
#include <userver/storages/postgres/component.hpp>
#include <userver/utils/boost_uuid4.hpp>
namespace upastebin {
LatestHandler::LatestHandler(
const userver::components::ComponentConfig& config,
const userver::components::ComponentContext& context)
: HttpHandlerBase(config, context),
pg_(context.FindComponent<userver::components::Postgres>("postgres")
.GetCluster()) {}
std::string LatestHandler::HandleRequestThrow(
const userver::server::http::HttpRequest&,
userver::server::request::RequestContext&) const {
auto result = pg_->Execute(
userver::storages::postgres::ClusterHostType::kSlave,
"SELECT author, substring(text for $1::INTEGER) AS text_tr, ip_source "
"FROM "
"upastebin.texts ORDER BY created_at DESC LIMIT $2::INTEGER;",
1000, 10);
userver::formats::json::ValueBuilder response(
userver::formats::common::Type::kArray);
for (const auto& item : result) {
userver::formats::json::ValueBuilder response_item;
response_item["author"] = item["author"].As<std::string>();
response_item["ip"] = item["ip_source"].As<std::string>();
response_item["text"] = item["text_tr"].As<std::string>();
response.PushBack(response_item.ExtractValue());
}
return ToString(
userver::formats::json::MakeObject("items", response.ExtractValue()));
}
} // namespace upastebin
Тесты
Мы написали самые простые эндпоинты для создания и просмотра заметок — давайте теперь напишем на них тесты!
Для userver тесты пишутся на фреймворке yandex‑taxi‑testsuite. Он основан на pytest и предоставляет большой набор фикстур и инструментов для работы с сервисами, моками, базами данных и т. д. Для работы тестов поднимается upastebin в замоканном окружении, в котором поднят свой PostgreSQL.
Чтобы сервис в testsuite‑окружении корректно работал, ему необходима БД с инициализированной схемой данных. Эта схема описывается в файлах postgresql/schemas/*.sql
. Ожидается, что одни и те же файлы используются для инициализации схемы данных и в тестах, и в продакшне. Поэтому схема данных записывается в форме файлов миграций, которые никогда не меняются, но изменения дописываются в новые файлы миграций.
Мы обойдёмся одним файлом миграций db_1.sql
:
postgresql/schemas/db_1.sql
DROP SCHEMA IF EXISTS upastebin CASCADE;
CREATE SCHEMA IF NOT EXISTS upastebin;
CREATE TABLE IF NOT EXISTS upastebin.texts (
uuid TEXT PRIMARY KEY,
author TEXT NOT NULL,
ip_source TEXT NOT NULL,
text TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
Чтобы testsuite работал с локальной директорией со статическими файлами, а не с /var/www-data
, ему необходимо подменить путь к директории в статическом конфиге. Для этого мы должны в файле conftest.py добавить соответствующую фикстуру и прописать её в список USERVER_CONFIG_HOOKS
. В этом списке хранятся имена фикстур, которые меняют статический конфиг. В самой фикстуре мы возвращаем функцию, которая меняет аргумент‑конфиг.
Первым аргументом функции выступает статический конфиг, представленный в форме словаря. В нём мы должны поменять все значения, которые специфичны для testsuite.
tests/conftest.py
USERVER_CONFIG_HOOKS = ['prepare_service_config_resources']
WWW_DATA = pathlib.Path(__file__).parent.parent / 'www-data'
...
@pytest.fixture(scope='session')
def prepare_service_config_resources():
def patch_config(config, config_vars):
components = config['components_manager']['components']
components['resources-cache']['dir'] = str(WWW_DATA)
return patch_config
Теперь, если мы запустим команду из корня репозитория make test-debug
, она отработает без ошибок.
Добавим туда тестов — разберём один из них по косточкам. Добавим исходник pytest‑теста в директорию tests/
с именем с префиксом test_
:
tests/test_crud.py
...
async def test_create_and_retrieve(service_client):
response = await service_client.post(
'/api/v1/posts/', params={'author': 'foo'}, data=TEXT,
)
assert response.status == 200
json_response = response.json()
assert list(json_response) == ['uuid']
uuid = json_response['uuid']
response = await service_client.get(f'/api/v1/posts/{uuid}')
assert response.status == 200
assert response.text == TEXT
response = await service_client.get('/api/v1/latest')
assert response.status == 200
assert response.json() == {
'items': [{'author': 'foo', 'ip': '::ffff:127.0.0.1', 'text': TEXT}],
}
По сути, тест — это функция, которая в pytest‑тесте должна начинаться с test_
и принимать аргументы‑фикстуры. В нашем случае тесту требуется фикстура service_client
, с помощью которой происходит обращение к сервису upastebin
. Мы хотим написать тест, который постит заметку, получает её по айдишнику, а также находит её в списке последних заметок.
GET/POST‑запрос происходит с помощью метода get/post
фикстуры service_client
. Передаём туда HTTP path
, query parameters
в params
, тело запроса в data
. В результате получаем response
, у которого поле status
равняется HTTP status code
, метод json
возвращает словарь или список с распаршенным JSON‑телом ответа, а поле text
содержит plaintext тела ответа.
Проверяем, что ручки сервиса отвечают успешно и возвращают ожидаемые данные.
Если мы теперь запустим команду make test-debug
, то увидим успешно пройденные тесты:
make test-debug
1: collecting... collected 9 items1:1:../tests/test_crud.py::test_404 PASSED [ 11%]
1:../tests/test_crud.py::test_empty_db PASSED [ 22%]
1:../tests/test_crud.py::test_create_and_retrieve PASSED [ 33%]
1:../tests/test_resources.py::test_found[index.html‑text/html; charset=UTF-8] PASSED [ 44%]
1:../tests/test_resources.py::test_found[index.js‑application/javascript] PASSED [ 55%]
1:../tests/test_resources.py::test_not_found[] PASSED [ 66%]
1:../tests/test_resources.py::test_not_found[.] PASSED [ 77%]
1:../tests/test_resources.py::test_not_found[..] PASSED [ 88%]
1:../tests/test_resources.py::test_not_found[missing.html] PASSED [100%]
1:
1: ‑--‑--‑--‑--‑--‑--‑--‑--‑ Service logs ‑--‑--‑--‑--‑--‑--‑--‑--‑
1: — service log file: /home/segoon/projects/upastebin/build_debug/Testing/Temporary/service.log —
1: ============================== 9 passed in 1.68s ===============================
1/1 Test #1: testsuite‑upastebin.............. Passed 1.99 sec
Если вам не хватило информации о фикстурах, то загляните в документацию. Чтобы изучить все существующие фикстуры, вы можете зайти в директорию third_party/userver/testsuite/
для поиска userver‑специфичных фикстур или в build_debug/venv-userver-default/lib/python3.10/site-packages/testsuite/
для поиска базовых фикстур (testsuite по умолчанию скачивается в билд‑директорию). Фикстуры помечены декоратором @pytest.fixture
.
Итак, мы рассмотрели процесс написания простого сервиса на userver. Конечно, с помощью этого фреймворка можно писать гораздо более сложные сервисы, составленные из тысяч и десятков тысяч строк. Если он вам приглянулся и вы хотите продолжить с ним работать, загляните на страницу userver и в телеграм‑чат (есть и отдельный чат на английском).
Исходники примера можно скачать с GitHub.