Используйте веб-фреймворк axum

Это руководство научит вас создавать мощный GraphQL API с помощью Rust. Вы будете использовать Rust и несколько известных библиотек для создания HTTP-сервера, добавления поддержки GraphQL и даже реализации небольшого API. После этого вы узнаете, как подготовить этот сервис к производству, добавив трассировку и метрики, а затем поместите все в контейнер Docker, готовый к развертыванию.

В наши дни GraphQL является жизненно важной технологией API. Facebook, Netflix, Spotify, Shopify и многие другие известные технологические компании создают и поддерживают такой API. Особенно федеративные API-интерфейсы GraphQL (это API-интерфейсы со шлюзом API и множеством отдельных микросервисов, составляющих один большой суперграф) обеспечивают большую гибкость в таблице. Команды могут работать независимо друг от друга, внося свой вклад в единую поверхность API, которая позволяет клиентам легко использовать любые внутренние функции, которые им могут понадобиться, в одной конечной точке, при этом будучи достаточно гибкими, чтобы запрашивать только те данные, которые им действительно нужны.

Велика вероятность, что однажды вы столкнетесь с задачей реализации GraphQL API. Возможно, вы уже создаете API-интерфейсы GraphQL и ищете более интересные способы их создания. В любом из этих случаев это руководство для вас.

(Небольшое примечание: если вы застряли в этом руководстве, вы можете найти полный код здесь.)

О Rust и GraphQL

ГрафQL

GraphQL — это язык запросов и обработки данных с открытым исходным кодом для API и среда выполнения для выполнения запросов с существующими данными. Первоначально он был разработан в Facebook в 2012 году и публично выпущен в 2016 году.

В настоящее время многие компании, такие как Netflix, Spotify, Shopify и другие, создают API-интерфейсы GraphQL, потому что эта технология обеспечивает большую гибкость для клиентов, чем традиционные API-интерфейсы REST. Клиенты могут указать, какие данные они хотят получить, и только те данные, которые запрашивает клиент, когда-либо возвращаются из конечной точки API. Большие двоичные объекты JSON с множеством ненужных полей остались в прошлом благодаря GraphQL.

Ржавчина

Rust — это статически типизированный язык системного программирования, который компилируется в машинный код. Его скорость во время выполнения часто находится на одном уровне с C и C++, обеспечивая при этом современные языковые конструкции и богатую и зрелую экосистему. Благодаря продуманным инструментам и быстрорастущей экосистеме, Rust — отличный кандидат, которого следует учитывать, когда вы думаете о новом языке программирования для изучения или создания ваших систем.

Подготовка

В этом руководстве предполагается, что вы используете терминал в стиле Unix. Если вы работаете в Windows, посмотрите Подсистема Windows для Linux. Большая часть этого руководства должна работать, если вы решите остаться на Windows. Вам нужно будет соответствующим образом настроить пути (например, Windows использует \ вместо / в качестве разделителя пути).

Если у вас еще не установлен Rust, вы можете использовать rustup как самый быстрый способ установить Rust и все необходимые инструменты, следуя этой статье.

Как только rustup установлен или у вас уже есть полный набор инструментов, убедитесь, что вы используете как минимум версию Rust 1.65.0.

Если вы не уверены, какая версия у вас установлена, вы можете использовать следующие команды:

❯ rustup show
Default host: x86_64-apple-darwin
rustup home:  /Users/oliverjumpertz/.rustup
installed toolchains
--------------------
stable-x86_64-apple-darwin (default)
1.59.0-x86_64-apple-darwin
1.60.0-x86_64-apple-darwin
1.61.0-x86_64-apple-darwin
1.62.1-x86_64-apple-darwin
1.64.0-x86_64-apple-darwin
1.65.0-x86_64-apple-darwin
active toolchain
----------------
1.65.0-x86_64-apple-darwin (overridden by '/Users/oliverjumpertz/projects/axum-graphql/rust-toolchain.toml')
rustc 1.65.0 (897e37553 2022-11-02)

# OR
❯ rustc --version
rustc 1.65.0 (897e37553 2022-11-02)

Последнее, что вам нужно, это установка Докера. Убедитесь, что у вас установлен либо Docker Desktop (для Mac и Windows), либо сам Docker (для Linux).

Настройка проекта

Пришло время начать создавать свой GraphQL API с помощью Rust. Откройте свое любимое терминальное приложение и перейдите в папку, в которой вы хотите разместить свой проект.

Создайте новую папку для своего проекта и перейдите прямо в нее:

❯ mkdir axum-graphql
❯ cd axum-graphql

Следующее, что вам нужно сделать, это использовать cargo для создания нового проекта Rust:

❯ cargo init
Created binary (application) package

Cargo автоматически создает бинарный проект и инициализирует для вас репозиторий git. Теперь папка вашего проекта должна выглядеть так:

| Cargo.toml
| .git/
| .gitignore
| src/
  | main.rs

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

Если у каждого разработчика установлена ​​своя версия Rust, вы можете быстро столкнуться с ошибками, которые возникают, например, только в определенных версиях. Чтобы обойти это, у Cargo есть поддержка для закрепления версии Rust, на которой вы хотите построить свой проект.

Создайте новый файл и назовите его rust-toolchain.toml. Затем вставьте следующее содержимое:

[toolchain]
channel = "1.65.0"

Строка channel = "1.65.0" сообщает Cargo, что всякий раз, когда вы запускаете команду cargo, вы хотите, чтобы Cargo гарантировал, что вы используете версию Rust 1.65.0. Если версия не активирована или не установлена, она автоматически активируется или устанавливается для вас.

Следующее, что вам нужно сделать, это обеспечить хотя бы некоторый уровень качества кода. К счастью, Rust очень рано решил быть самоуверенным. Это означает, что официально поддерживается только один стиль кода. Этот стиль кода полностью реализован в rustfmt, официальном форматере Rust.

Вы можете установить rustfmt вручную, но вам придется сообщить всем другим разработчикам, с которыми вы потенциально работаете, что им также необходимо установить этот компонент. Вот почему rust-toolchain.toml поддерживает так называемые компоненты (инструменты, которые могут выполнять множество операций, например форматировать ваш код).

Добавьте следующую строку components = [ "rustfmt" ] к вашему rust-toolchain.toml, теперь весь файл должен выглядеть так:

[toolchain]
channel = "1.65.0"
components = ["rustfmt"]

С добавлением rustfmt теперь вы можете автоматически форматировать свой код. Однако, прежде чем вы сможете продолжить, имеет смысл поместить инструмент в файл конфигурации, с которым он может работать. Создайте новый файл с именем .rustfmt.toml в своем проекте и добавьте следующее содержимое:

edition = "2021"
newline_style = "Unix"

На данный момент это говорит rustfmt только о двух вещах:

  • Версия Rust, которую вы используете, — 2021.
  • Новые строки должны быть отформатированы в стиле Unix.

Это становится интересным, как только вы хотите построить внутри контейнера Docker, но сами работаете, например, в Windows.

Если вы хотите увидеть, какие другие параметры конфигурации у вас есть, вы можете посмотреть полный список настроек здесь.

Последнее, что вам сейчас нужно, это линтер. В большинстве языков программирования есть способы делать что-то определенным образом (обычно называемым идиоматическим), и они также обычно имеют общие ловушки. Ржавчина ничем не отличается. Clippy — это линтер Rust, содержащий более 550 правил линтинга. Этого достаточно, чтобы избежать распространенных ошибок и сделать код понятным для других разработчиков Rust.

Добавьте еще одну запись в компоненты вашего rust-toolchain.toml под названием "clippy", в результате чего файл должен выглядеть следующим образом:

[toolchain]
channel = "1.65.0"
components = ["rustfmt", "clippy"]

Clippy, как и rustfmt, поддерживает конфигурационный файл, в котором вы можете изменить настройки для определенных линтов. Создайте еще один файл с именем .clippy.toml внутри вашего проекта и добавьте следующее содержимое:

cognitive-complexity-threshold = 30

Строка выше указывает clippy разрешить когнитивную сложность 30 для любого метода, который он анализирует (чем сложнее метод, тем труднее его читать, но и компилятору сложнее его оптимизировать). Настроек, конечно же, гораздо больше, и вы можете посмотреть полный список доступных настроек линтера здесь.

Когда все установлено и настроено, теперь у вас есть две команды, которые помогут вам анализировать и форматировать ваш код.

cargo fmt — all просматривает ваш код и форматирует все в соответствии с официальным руководством по стилю и правилами, которые вы настраиваете в .rustfmt.toml.

cargo clippy — all — tests позаботится о анализе вашего кода.

Это все, что вам нужно в качестве основы для вашего GraphQL API с Rust. Наконец пришло время перейти к фактической реализации.

Создание веб-сервера

В настоящее время существует множество реализаций веб-серверов для Rust, и часто бывает сложно выбрать правильную. Вы будете использовать axum для своего GraphQL API, чтобы упростить задачу. В настоящее время он считается лучшим выбором для любого нового проекта (независимо от того, нужен ли вам HTTP, REST или GraphQL API) в сообществе.

axum — это веб-фреймворк, созданный и поддерживаемый теми же разработчиками, которые уже создают и поддерживают tokio (старейшую асинхронную среду выполнения Rust). Он быстрый, простой в использовании и идеально интегрируется в экосистему tokio, что делает его хорошим выбором для проектов любого размера.

Первым шагом является добавление двух зависимостей в ваш проект. Откройте Cargo.toml и добавьте следующие зависимости:

  • аксум
  • Токио

Теперь ваш Cargo.toml должен выглядеть так:

[package]
edition = "2021"
name = "axum-graphql"
version = "0.1.0"

[dependencies]
axum = "0.5.17"
tokio = {version = "1.18.2", features = ["full"]}

Теперь откройте src/main.rs и замените его содержимое следующим кодом:

use axum::{Router, Server};

#[tokio::main] // (1)
async fn main() {
    let app = Router::new(); // (2)

    Server::bind(&"0.0.0.0:8000".parse().unwrap()) // (3)
        .serve(app.into_make_service())
        .await
        .unwrap();
}

Давайте кратко рассмотрим, что делает этот код:

(1): #[tokio::main] — это макрос, который абстрагирует базовую логику настройки запуска пула потоков и настройки tokio.

(2): Router — это способ axum маршрутизировать запросы. Позже вы добавите сюда маршруты, чтобы сопоставить различные конечные точки с функциями.

(3): Сервер — это фактическая реализация HTTP-сервера axum. Он берет маршрутизатор и вызывает его для каждого входящего запроса, который он получает.

Это базовая реализация веб-сервера, которая не предоставляет никаких методов, которые вы можете вызывать удаленно, но является идеальной основой для добавления дополнительных конечных точек. Говоря о конечных точках, вам, вероятно, следует добавить ту, которая вам понадобится (например, если ваш сервис каким-то образом будет работать внутри контейнера в Kubernetes).

Откройте Cargo.toml еще раз и добавьте serde к своим зависимостям следующим образом:

[package]
edition = "2021"
name = "axum-graphql"
version = "0.1.0"

[dependencies]
axum = "0.5.17"
tokio = {version = "1.18.2", features = ["full"]}
serde = {version = "1.0.147", features = ["derive"]}

serde — это выбор по умолчанию для любого приложения Rust, которое хочет иметь дело с сериализацией и десериализацией. Поскольку ваша конечная точка будет отвечать в формате JSON, вам понадобится этот ящик, а его функция «получения» позволит вам добавить полную поддержку JSON для любой из ваших структур с помощью макроса derive.

Теперь, когда serde является зависимостью вашего проекта, создайте новую папку src/routes и добавьте в нее файл mod.rs. Откройте файл и добавьте следующее содержимое:

use axum::{http::StatusCode, response::IntoResponse, Json};
use serde::Serialize;

#[derive(Serialize)] // (1)
struct Health { // (2)
    healthy: bool
}

pub(crate) async fn health() -> impl IntoResponse { // (3)
    let health = Health {
        healthy: true
    };

    (StatusCode::OK, Json(health)) // (4)
}

Вот еще один быстрый взгляд на то, что происходит в приведенном выше коде:

(1): derive(Serialize) автоматически реализует всю логику для вас, так что приведенную ниже структуру можно сериализовать (в данном случае в JSON).

(2): Здоровье — это просто базовая структура для этого конкретного варианта использования. Он содержит одно свойство, и все.

(3): Это метод конечной точки, который скоро станет доступным. Ему не нужны параметры, и он возвращает IntoResponse аксума, трейт, который помечает структуры, которые могут быть сериализованы в аксум ответа, который может понимать и возвращать вашим пользователям.

(4): Этот ответ кортежа является одним из нескольких способов вернуть ответы в axum. В данном случае это всего лишь код состояния HTTP в сочетании с сериализованной версией вашей структуры Health.

Затем вам нужно зарегистрировать метод конечной точки, чтобы axum мог его подобрать и вызывать всякий раз, когда кто-то делает HTTP-запрос по определенному пути. В этом случае метод будет доступен по пути /health.

Откройте src/main.rs и зарегистрируйте новый метод следующим образом:

use crate::routes::health;
use axum::{routing::get, Router, Server};

mod routes;

#[tokio::main]
async fn main() {
    let app = Router::new().route("/health", get(health));

    Server::bind(&"0.0.0.0:8000".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

route("/health", get(health)) сообщает axum, что любые запросы HTTP GET к /health должны вызывать ваш метод health. Вы можете попробовать это, запустив свой сервис через Cargo и используя curl или другой инструмент для быстрой отправки тестового запроса:

❯ cargo run
   Compiling axum-graphql v0.1.0 (/Users/oliverjumpertz/projects/axum-graphql)
    Finished dev [unoptimized + debuginfo] target(s) in 1.13s
     Running `target/debug/axum-graphql`

❯ curl http://localhost:8000/health   
{"healthy":true}

Если у вас нет под рукой такого инструмента, как завиток, не расстраивайтесь. Просто откройте браузер и перейдите к http://localhost:8000/health, который должен выглядеть примерно так:

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

Добавление GraphQL

В настоящее время у вас есть базовый веб-сервер. Теперь пришло время добавить к нему возможности GraphQL.

Концептуально GraphQL (через HTTP) — это не что иное, как дополнительный процессор, расположенный поверх обычной конечной точки HTTP. Он ожидает, что запрос (и переменные) будет отправлен через HTTP-запрос POST (или GET с параметрами запроса), передает эти данные обработчику запросов, а затем возвращает ответ пользователю. Вот почему добавить GraphQL на любой веб-сервер Rust относительно просто.

В этом руководстве вы будете использовать async-graphql. Это крейт Rust, который поддерживает последнюю спецификацию GraphQL и дополнительно поддерживает Apollo Federation (включая v2).

Откройте Cargo.toml еще раз и добавьте еще две зависимости:

  • async-graphql
    — async-graphql добавляет поддержку GraphQL в ваш сервис Rust.
  • async-graphql-axum
    — интеграционный ящик, который поставляется со всем, что нужно async-graphql для совместной работы с axum.

Теперь ваш Cargo.toml должен выглядеть так:

[package]
edition = "2021"
name = "axum-graphql"
version = "0.1.0"

[dependencies]
async-graphql = "4.0.16"
async-graphql-axum = "4.0.16"
axum = "0.5.17"
serde = {version = "1.0.147", features = ["derive"]}
tokio = {version = "1.18.2", features = ["full"]}

Теперь, когда у вас есть async-graphql в вашем проекте, вы можете начать добавлять поддержку GraphQL в свой сервис.

Для работы службы GraphQL требуется некоторая схема и модель. В случае с async-graphql вы можете использовать подход «сначала код» к проектированию схемы. Это означает, что вы создаете схему внутри своего кода, добавляете макросы, и крейт правильно отображает все для вас.

Создайте новую папку src/model и добавьте в нее файл mod.rs. Затем добавьте следующий контент (в качестве первой модели/схемы):

use async_graphql::{Context, Object, Schema};
use async_graphql::{EmptyMutation, EmptySubscription};

pub(crate) type ServiceSchema = Schema<QueryRoot, EmptyMutation, EmptySubscription>;

pub(crate) struct QueryRoot; // (1)

#[Object] // (2)
impl QueryRoot { // (3)
    async fn hello(&self, _ctx: &Context<'_>) -> &'static str { // (4)
        "Hello world"
    }
}

Давайте быстро рассмотрим, что делает этот код:

(1): это объект Query в вашей схеме. Это корень всех запросов, которые пользователи могут использовать к вашим услугам.

(2): Макрос Object связывает вашу структуру Rust вместе с базовой логикой фреймворка async-graphql.

(3): Реализация QueryRoot содержит все запросы, поддерживаемые вашей службой.

(4): hello — ваш первый запрос. Пока он просто возвращает статическую строку.

Пришло время создать функции-обработчики для обработки входящих запросов GraphQL. Откройте src/routes/mod.rs и добавьте следующий код:

use crate::model::ServiceSchema;
use async_graphql::http::{playground_source, GraphQLPlaygroundConfig};
use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
use axum::{
    extract::Extension,
    http::StatusCode,
    response::{Html, IntoResponse},
    Json,
};
use serde::Serialize;

#[derive(Serialize)]
struct Health {
    healthy: bool,
}

pub(crate) async fn health() -> impl IntoResponse {
    let health = Health { healthy: true };

    (StatusCode::OK, Json(health))
}

// Your two new functions start here
pub(crate) async fn graphql_playground() -> impl IntoResponse {
    Html(playground_source( // (1)
        GraphQLPlaygroundConfig::new("/").subscription_endpoint("/ws"),
    ))
}

pub(crate) async fn graphql_handler(
    req: GraphQLRequest,
    Extension(schema): Extension<ServiceSchema>, // (2)
) -> GraphQLResponse {
    schema.execute(req.into_inner()).await.into() // (3)
}

Вы медленно приближаетесь к завершению базовой поддержки GraphQL для вашего сервиса Rust, но сначала вам нужно понять несколько новых вещей:

(1): async-graphql поставляется с полной реализацией игровой площадки GraphQL. К счастью, вы можете назвать это функцией и обернуть ее в хелпер axum Html, который позаботится о правильном возврате всего.

(2): функция-обработчик graphql получает как запрос, так и, что еще более важно, экземпляр разработанной и реализованной вами схемы. Extension — это специальный помощник от axum, который позволяет вам добавлять данные и другие контекстно-зависимые вещи в ваши функции-обработчики.

(3): помните, я говорил вам, что GraphQL через HTTP концептуально представляет собой не что иное, как конечную точку API со специальным процессором? Это именно то, что происходит здесь. Фактическая логика реализована в schema.execute(...) и, таким образом, это единственный вызов, который вам нужно выполнить здесь.

Теперь, когда у вас есть эти новые обработчики, пришло время их интегрировать. Снова откройте src/main.rs и зарегистрируйте игровую площадку и обработчик следующим образом:

use crate::model::QueryRoot;
use crate::routes::{graphql_handler, graphql_playground, health};
use async_graphql::{EmptyMutation, EmptySubscription, Schema};
use axum::{extract::Extension, routing::get, Router, Server};

mod model;
mod routes;

#[tokio::main]
async fn main() {
    // You now need to build your schema
    let schema = Schema::build(QueryRoot, EmptyMutation, EmptySubscription).finish();

    let app = Router::new()
        // Both routes are registered here
        .route("/", get(graphql_playground).post(graphql_handler))
        .route("/health", get(health))
        // You need to make the schema available to your route handlers
        .layer(Extension(schema)); // (1)

    Server::bind(&"0.0.0.0:8000".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

В приведенном выше коде есть только одна вещь, которую вам нужно понять:

(1): вам нужна скомпилированная схема, доступная в вашей конечной точке. Предоставляя axum слой, вы делаете именно это. Схема, которую вы создали несколькими строками выше, теперь передается в axum, поэтому вы можете получить к ней доступ в своем обработчике GraphQL.

Пришло время проверить, работает ли ваш GraphQL API. Запустите службу с cargo run, откройте браузер и перейдите к http://localhost:8000. Вы должны быть представлены с видом, подобным этому:

Попробуйте запустить ранее реализованный запрос, чтобы проверить, работает ли ваша реализация. Введите запрос с левой стороны и нажмите кнопку воспроизведения:

Если ваша служба успешно возвращает ответ, который вы видите на изображении выше, вы завершили настройку базового API GraphQL с Rust. Затем вы позаботитесь о наблюдаемости вашего API, добавив метрики в свой сервис.

Добавление показателей

Большинство микросервисов работают в контейнерах Docker, которые в настоящее время развернуты в Kubernetes. Обычно кто-то должен посмотреть, как эти службы работают при их развертывании. Метрики — это один из способов сделать ваш сервис заметным.

Выдавая определенные ключевые метрики, вы получаете лучшее представление о том, как работает ваш GraphQL API и с какими проблемами он может столкнуться при выполнении и обработке запросов. Такой сервис, как Prometheus, регулярно очищает конечную точку, которая предоставляет метрики, и эти метрики затем можно использовать для создания графиков в таких инструментах, как Grafana.

Снова откройте Cargo.toml и добавьте две новые зависимости:

  • metrics — предоставляет фасад для сбора метрик. Он заботится о сборе метрик и их внутреннем хранении.
  • metrics-exporter-prometheus — этот крейт добавляет в ваш сервис возможности, совместимые с Prometheus.

В целом файл теперь должен выглядеть так:

[package]
edition = "2021"
name = "axum-graphql"
version = "0.1.0"

[dependencies]
async-graphql = "4.0.16"
async-graphql-axum = "4.0.16"
axum = "0.5.17"
metrics = "0.20.1"
metrics-exporter-prometheus = "0.11.0"
serde = {version = "1.0.147", features = ["derive"]}
tokio = {version = "1.18.2", features = ["full"]}

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

Это означает, что вы как минимум один раз должны настроить реестр метрик и интегрировать его в axum. Кроме того, вы также хотите записать некоторые показатели, поэтому вы также добавите некоторые из самых основных показателей:

  • Подсчет количества запросов к вашему API
  • Запись того, сколько времени на самом деле занимает каждый запрос

Создайте новую папку src/observability и добавьте файл metrics.rs. Затем добавьте следующий код:

use axum::{extract::MatchedPath, http::Request, middleware::Next, response::IntoResponse};
use metrics_exporter_prometheus::{Matcher, PrometheusBuilder, PrometheusHandle};
use std::time::Instant;

const REQUEST_DURATION_METRIC_NAME: &str = "http_requests_duration_seconds";

pub(crate) fn create_prometheus_recorder() -> PrometheusHandle { // (1)
    const EXPONENTIAL_SECONDS: &[f64] = &[
        0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
    ];

    PrometheusBuilder::new()
        .set_buckets_for_metric( // (2)
            Matcher::Full(REQUEST_DURATION_METRIC_NAME.to_string()),
            EXPONENTIAL_SECONDS,
        )
        .unwrap_or_else(|_| {
            panic!(
                "Could not initialize the bucket for '{}'",
                REQUEST_DURATION_METRIC_NAME
            )
        })
        .install_recorder()
        .expect("Could not install the Prometheus recorder")
}

pub(crate) async fn track_metrics<B>(req: Request<B>, next: Next<B>) -> impl IntoResponse { // (3)
    let start = Instant::now();
    let path = if let Some(matched_path) = req.extensions().get::<MatchedPath>() {
        matched_path.as_str().to_owned()
    } else {
        req.uri().path().to_owned()
    };
    let method = req.method().clone();

    let response = next.run(req).await;

    let latency = start.elapsed().as_secs_f64();
    let status = response.status().as_u16().to_string();

    let labels = [
        ("method", method.to_string()),
        ("path", path),
        ("status", status),
    ];

    metrics::increment_counter!("http_requests_total", &labels); // (4)
    metrics::histogram!(REQUEST_DURATION_METRIC_NAME, latency, &labels);

    response
}

Чтобы дать вам лучшее представление о том, что именно происходит в приведенном выше коде, давайте еще раз внимательно рассмотрим:

(1): create_prometheus_recorder — это простая функция, которая возвращает PrometheusHandle. Последний является конструкцией ящика метрик. Объясняется простыми словами: это способ доступа к реальному регистратору, реестру, в котором хранятся все записываемые вами показатели.

(2): Ведро — это конструкция, необходимая для так называемых гистограмм. Вы можете представить это как настоящие ведра, поставленные рядом друг с другом. Всякий раз, когда вы записываете новую запись для гистограммы, она автоматически сортируется по корзине, в которую она помещается. Это означает, что вы не получаете точных значений. Каждое значение сортируется по наименьшему сегменту, в который оно помещается.

(3): Задача этой функции — записывать продолжительность запроса к вашему сервису. Через секунду вы зарегистрируете его как промежуточное ПО для axum. Всякий раз, когда запрос попадает в ваш API, этот код запускается, собирает продолжительность запроса, а также увеличивает счетчик, чтобы вы могли отслеживать, сколько запросов ваш API уже обслужил.

(4): Эти макросы немного облегчают работу с метриками. Обычно вам нужно получить экземпляр реестра метрик и добавить в него метрики или извлечь из него существующие метрики. Благодаря этим макросам вам нужно сделать только один вызов, и вам не нужно беспокоиться о дополнительных деталях реализации.

Прежде чем вы сможете использовать этот подмодуль, вам нужно создать еще один новый файл src/observability/mod.rs и добавить следующее содержимое:

pub(crate) mod metrics;

Чтобы интегрировать только что созданный модуль в ваш сервис, вам нужно внести еще несколько изменений в файл main.rs. Откройте файл и добавьте следующий код, чтобы весь файл выглядел следующим образом:

use crate::model::QueryRoot;
use crate::observability::metrics::{create_prometheus_recorder, track_metrics};
use crate::routes::{graphql_handler, graphql_playground, health};
use async_graphql::{EmptyMutation, EmptySubscription, Schema};
use axum::{extract::Extension, middleware, routing::get, Router, Server};
use std::future::ready;

mod model;
mod observability;
mod routes;

#[tokio::main]
async fn main() {
    let schema = Schema::build(QueryRoot, EmptyMutation, EmptySubscription).finish();

    let prometheus_recorder = create_prometheus_recorder();

    let app = Router::new()
        .route("/", get(graphql_playground).post(graphql_handler))
        .route("/health", get(health))
        .route("/metrics", get(move || ready(prometheus_recorder.render()))) // (1)
        .route_layer(middleware::from_fn(track_metrics)) // (2)
        .layer(Extension(schema));

    Server::bind(&"0.0.0.0:8000".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

Вот еще несколько пояснений:

(1): Эта строка выглядит немного неуклюжей, но, в конце концов, она всего лишь связывает ваш рекордер с HTTP-запросом GET по адресу /metrics.

(2): Это интегрирует вашу функцию отслеживания в качестве промежуточного программного обеспечения axum.

Чтобы проверить, успешно ли вы интегрировали метрики в свою службу, запустите службу с помощью cargo run, снова откройте браузер, перейдите к http://localhost:8000 и выполните несколько запросов. После этого перейдите к http://localhost:8000/metrics, и вы должны увидеть несколько возвращенных метрик, как показано ниже:

Отныне вы можете использовать макросы ящика метрик для регистрации новых метрик или добавления к существующим. Что и где вы отслеживаете, полностью зависит от вас. Помните, что зачастую лучше добавить новую метрику и снова удалить ее, если вы понимаете, что она вам не нужна, чем оставаться вслепую.

Добавление трассировки

Трассировка — это еще один способ улучшить наблюдаемость вашего GraphQL API. Трассировка обычно отслеживает выполнение путей кода, помещая вокруг них некоторый контекст, называемый интервалами. В этих промежутках могут происходить определенные события. Затем все эти события прикрепляются к соответствующему диапазону. Эти промежутки, в свою очередь, присоединяются к своему родительскому промежутку вплоть до корневого промежутка. Если вы знаете идентификатор корневого диапазона, вы можете легко следить за любым запросом или выполнением кода.

Jaeger — это комплексное решение для распределенной трассировки. У него есть собственный интерфейс и несколько вспомогательных сервисов, которые помогают собирать или получать трассировки микросервисами. В настоящее время он основан на OpenTelemetry, стандарте распределенной трассировки.

Существуют реализации OpenTelemetry для большинства языков, и Rust ничем не отличается. Снова откройте Cargo.toml и добавьте следующие зависимости:

  • трассировка — реализует фасад (например, метрики) и предоставляет макросы, которые упрощают создание трассировок.
  • tracing-opentelemetry — добавляет поддержку OpenTelemetry.
  • tracing-subscriber — этот крейт состоит из утилит, упрощающих интеграцию нескольких выходных каналов, таких как Jaeger или ведение журнала на стандартный вывод.
  • opentelemetry — этот крейт реализует стандарт OpenTelemetry для клиентов.
  • opentelemetry-jaeger — добавляет интероперабельность для Jaeger.
  • dotenv — этот ящик не имеет особого отношения к отслеживанию самого себя, но постоянное присутствие агента Jaeger или сборщика может помешать локальному развитию. Вы будете использовать dotenv для быстрого включения или отключения трассировки.

После того, как вы добавили зависимости, ваш Cargo.toml должен выглядеть так:

[package]
edition = "2021"
name = "axum-graphql"
version = "0.1.0"

[dependencies]
async-graphql = "4.0.16"
async-graphql-axum = "4.0.16"
axum = "0.5.17"
dotenv = "0.15.0"
metrics = "0.20.1"
metrics-exporter-prometheus = "0.11.0"
opentelemetry = {version = "0.18.0", features = ["rt-tokio"]}
opentelemetry-jaeger = {version = "0.17.0", features = ["rt-tokio"]}
serde = {version = "1.0.147", features = ["derive"]}
tokio = {version = "1.18.2", features = ["full"]}
tracing = "0.1.37"
tracing-opentelemetry = "0.18.0"
tracing-subscriber = {version = "0.3.16", features = ["std", "env-filter"]}

Теперь, когда у вас есть еще один набор зависимостей в вашем проекте, пришло время вернуться к коду. Вам нужно создать экземпляр трассировщика, который можно использовать для отправки событий трассировки. В этом случае вы создадите конвейер агента jaeger (обычно это sidecar, работающий рядом с вашим сервисным модулем в Kubernetes), который отправляет эти события агенту. Он поставляется в виде компоновщика, который возвращает Tracer в конце, который вы можете использовать позже.

Создайте новый файл в src/observability с именем tracing.rs и добавьте следующий код:

use opentelemetry::sdk::trace::{self, Sampler};
use opentelemetry::{
    global, runtime::Tokio, sdk::propagation::TraceContextPropagator, sdk::trace::Tracer,
};
use std::env;

struct JaegerConfig { // (1)
    jaeger_agent_host: String,
    jaeger_agent_port: String,
    jaeger_tracing_service_name: String,
}

pub fn create_tracer_from_env() -> Option<Tracer> {
    let jaeger_enabled: bool = env::var("JAEGER_ENABLED")
        .unwrap_or_else(|_| "false".into())
        .parse()
        .unwrap();

    if jaeger_enabled {
        let config = get_jaeger_config_from_env();
        Some(init_tracer(config))
    } else {
        None
    }
}

fn init_tracer(config: JaegerConfig) -> Tracer {
    global::set_text_map_propagator(TraceContextPropagator::new()); // (2)
    opentelemetry_jaeger::new_agent_pipeline() // (3)
        .with_endpoint(format!(
            "{}:{}",
            config.jaeger_agent_host, config.jaeger_agent_port
        ))
        .with_auto_split_batch(true)
        .with_service_name(config.jaeger_tracing_service_name)
        .with_trace_config(trace::config().with_sampler(Sampler::AlwaysOn))
        .install_batch(Tokio)
        .expect("pipeline install error")
}

fn get_jaeger_config_from_env() -> JaegerConfig {
    JaegerConfig {
        jaeger_agent_host: env::var("JAEGER_AGENT_HOST").unwrap_or_else(|_| "localhost".into()),
        jaeger_agent_port: env::var("JAEGER_AGENT_PORT").unwrap_or_else(|_| "6831".into()),
        jaeger_tracing_service_name: env::var("TRACING_SERVICE_NAME")
            .unwrap_or_else(|_| "axum-graphql".into()),
    }
}

Это снова немного больше кода, поэтому давайте быстро пробежимся по его наиболее важным частям:

(1): это всего лишь вспомогательная структура для хранения свойств, необходимых для настройки трассировщика. В данном случае это способ установить хост и порт агента, а также имя, под которым регистрируются его трассировки (значений по умолчанию обычно достаточно, когда ваш сервис работает в Kubernetes с подключенным сайдкаром Jaeger).

(2): трассировки могут распространяться между несколькими службами. Эта строка как раз гарантирует, что трассировка распространяется заголовком traceparent (W3C Trace Context Propagator. Подробнее об этом можно прочитать здесь).

(3): Конвейер — это фактическая основная логика для создания трассировщика. В этом случае так называемый конвейер агента ожидает отправки трассировки агенту Jaeger. Он настроен с несколькими дополнительными настройками, такими как установка явного имени службы (чтобы найти следы вашей службы в пользовательском интерфейсе Jaeger) и настройка Sampler на постоянное включение, что означает, что ни один диапазон не отбрасывается, и все сохраняется.

Затем вам нужно добавить только что созданный модуль в src/observability/mod.rs следующим образом:

pub(crate) mod metrics;
pub(crate) mod tracing;

Теперь, когда ваш новый модуль доступен, пришло время интегрировать все в ваш сервис. Откройте src/main.rs еще раз и добавьте следующий код:

use crate::model::QueryRoot;
use crate::observability::metrics::{create_prometheus_recorder, track_metrics};
use crate::observability::tracing::create_tracer_from_env;
use crate::routes::{graphql_handler, graphql_playground, health};
use async_graphql::{EmptyMutation, EmptySubscription, Schema};
use axum::{extract::Extension, middleware, routing::get, Router, Server};
use dotenv::dotenv;
use std::future::ready;
use tracing::info;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::Registry;

mod model;
mod observability;
mod routes;

#[tokio::main]
async fn main() {
    // You need to add this
    dotenv().ok(); // (1)

    let schema = Schema::build(QueryRoot, EmptyMutation, EmptySubscription).finish();

    let prometheus_recorder = create_prometheus_recorder();

    // and you need to add the code below to initialize tracing correctly
    let registry = Registry::default().with(tracing_subscriber::fmt::layer().pretty()); // (2)

    match create_tracer_from_env() { // (3)
        Some(tracer) => registry
            .with(tracing_opentelemetry::layer().with_tracer(tracer))
            .try_init()
            .expect("Failed to register tracer with registry"),
        None => registry
            .try_init()
            .expect("Failed to register tracer with registry"),
    }

    info!("Service starting"); // (4)

    let app = Router::new()
        .route("/", get(graphql_playground).post(graphql_handler))
        .route("/health", get(health))
        .route("/metrics", get(move || ready(prometheus_recorder.render())))
        .route_layer(middleware::from_fn(track_metrics))
        .layer(Extension(schema));

    Server::bind(&"0.0.0.0:8000".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

Есть новый код, который нужно снова понять, поэтому давайте пройдемся по важным частям:

(1): Это все, что вам нужно сделать, чтобы настроить dotenv. Вы создадите небольшой файл dotenv за несколько секунд, чтобы быстро включить или отключить экспортер Jaeger при локальной разработке.

(2): Реестр — это способ регистрации так называемых слоев трассировки. В этом случае добавляется трассировщик, который ведет журнал на стандартный вывод. Это гарантирует, что у вас всегда будут сообщения журнала для целей отладки.

(3): Экспорт Jaeger можно отключить. Поэтому вам нужно проверить, есть ли трассировщик для вас, чтобы зарегистрироваться дополнительно. Если нет, служба запускается путем регистрации на стандартный вывод. Если Jaeger включен, регистрируются как ведение журнала stdout, так и экспорт в агент Jaeger.

(4): Этот макрос является одним из способов регистрации или отслеживания определенных событий.

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

Вернитесь в src/routes/mod.rs и измените код на следующий:

use crate::model::ServiceSchema;
use async_graphql::http::{playground_source, GraphQLPlaygroundConfig};
use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
use axum::{
    extract::Extension,
    http::StatusCode,
    response::{Html, IntoResponse},
    Json,
};
use opentelemetry::trace::TraceContextExt;
use serde::Serialize;
use tracing::{info, span, Instrument, Level};
use tracing_opentelemetry::OpenTelemetrySpanExt;

#[derive(Serialize)]
struct Health {
    healthy: bool,
}

pub(crate) async fn health() -> impl IntoResponse {
    let health = Health { healthy: true };

    (StatusCode::OK, Json(health))
}

pub(crate) async fn graphql_playground() -> impl IntoResponse {
    Html(playground_source(
        GraphQLPlaygroundConfig::new("/").subscription_endpoint("/ws"),
    ))
}

pub(crate) async fn graphql_handler(
    req: GraphQLRequest,
    Extension(schema): Extension<ServiceSchema>,
) -> GraphQLResponse {
    // Your newly refactored and improved graphql_handler starts here
    let span = span!(Level::INFO, "graphql_execution"); // (1)

    info!("Processing GraphQL request");

    let response = async move { schema.execute(req.into_inner()).await } // (2)
        .instrument(span.clone())
        .await;

    info!("Processing GraphQL request finished");

    response
        .extension( // (3)
            "traceId",
            async_graphql::Value::String(format!(
                "{}",
                span.context().span().span_context().trace_id()
            )),
        )
        .into()
}

Давайте посмотрим, что происходит в наиболее важных частях кода выше:

(1): Span — это, так сказать, контекст. В пределах диапазона все события и последующие диапазоны присоединяются к этому диапазону. Это помогает создавать блоки событий, которые связаны друг с другом семантически и технически.

(2): Чтобы понять, зачем нужен этот асинхронный блок, вам нужно понять, как работают асинхронные функции. В любой момент выполнения асинхронная функция может остановить свое выполнение. Время жизни пролета связано со временем жизни его охранника. Когда вы входите в пролет, возвращается охранник. Когда этот охранник сбрасывается, диапазон закрывается. Когда выполнение асинхронной функции останавливается, защита не сбрасывается; таким образом, другой код будет генерировать трассировки в пределах этого диапазона. Вот почему вам нужно оснастить асинхронный блок диапазоном.

(3): Иметь промежутки и трассировки — это здорово, но также неплохо возвращать идентификатор трассировки, чтобы пользователи могли дать вам что-то, что вы можете найти, если кто-то столкнется с ошибкой. Вот почему идентификатор трассировки помещается в расширения ответа GraphQL.

Помните, что мы можем отключить трассировку Jaeger? По умолчанию уже установлено значение false, но если вы хотите включить его локально, вы должны запустить службу с переменной среды, для которой установлено значение true. Лучше использовать файл .env, который можно быстро открыть, настроить значения, а затем снова запустить службу.

Быстро добавьте новый файл .env в корень вашего проекта и добавьте следующий контент, который гарантирует, что ваш экспортер jaeger включен (не забудьте добавить .env к вашему .gitignore. Вы не хотите, чтобы секреты просачивались в общедоступный репозиторий git !):

JAEGER_ENABLED=true

Вы почти закончили. Осталось сделать только одну последнюю вещь, но давайте сначала посмотрим на процесс трассировки во время выполнения (упрощенно):

  • Ваш код испускает следы
  • Эти следы собираются
  • Собирается больше следов
  • Следы регулярно отправляются агенту

Это означает, что между сбором трассировок и закрытием службы проходит короткий промежуток времени, когда некоторые трассировки могут быть потеряны. Это особенно актуально, когда ваш GraphQL API работает в Kubernetes, где модули могут быть перенесены в любое время. К счастью, у axum есть ловушка для выключения, а в OpenTelemetry есть функция, которая явно запускает отключение вашего трассировщика, который отправляет любые оставшиеся следы, прежде чем они будут потеряны.

Откройте src/main.rs и добавьте следующий код, чтобы ваша служба и трассировки корректно закрывались:

use crate::model::QueryRoot;
use crate::observability::metrics::{create_prometheus_recorder, track_metrics};
use crate::observability::tracing::create_tracer_from_env;
use crate::routes::{graphql_handler, graphql_playground, health};
use async_graphql::{EmptyMutation, EmptySubscription, Schema};
use axum::{extract::Extension, middleware, routing::get, Router, Server};
use dotenv::dotenv;
use std::future::ready;
use tokio::signal;
use tracing::info;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::Registry;

mod model;
mod observability;
mod routes;

// A new shutdown signal handler
async fn shutdown_signal() { // (1)
    let ctrl_c = async {
        signal::ctrl_c()
            .await
            .expect("failed to install Ctrl+C handler");
    };

    #[cfg(unix)]
    let terminate = async {
        signal::unix::signal(signal::unix::SignalKind::terminate())
            .expect("failed to install signal handler")
            .recv()
            .await;
    };

    #[cfg(not(unix))]
    let terminate = std::future::pending::<()>();

    tokio::select! {
        _ = ctrl_c => {},
        _ = terminate => {},
    }

    opentelemetry::global::shutdown_tracer_provider();
}

#[tokio::main]
async fn main() {
    dotenv().ok();

    let schema = Schema::build(QueryRoot, EmptyMutation, EmptySubscription).finish();

    let prometheus_recorder = create_prometheus_recorder();

    let registry = Registry::default().with(tracing_subscriber::fmt::layer().pretty());

    match create_tracer_from_env() {
        Some(tracer) => registry
            .with(tracing_opentelemetry::layer().with_tracer(tracer))
            .try_init()
            .expect("Failed to register tracer with registry"),
        None => registry
            .try_init()
            .expect("Failed to register tracer with registry"),
    }

    info!("Service starting");

    let app = Router::new()
        .route("/", get(graphql_playground).post(graphql_handler))
        .route("/health", get(health))
        .route("/metrics", get(move || ready(prometheus_recorder.render())))
        .route_layer(middleware::from_fn(track_metrics))
        .layer(Extension(schema));

    Server::bind(&"0.0.0.0:8000".parse().unwrap())
        .serve(app.into_make_service())
        // You need to register the signal handler here
        .with_graceful_shutdown(shutdown_signal()) // (2)
        .await
        .unwrap();
}

Давайте еще раз взглянем на важные строки кода выше:

(1): Эта функция делает ровно две вещи: сначала она ожидает одного из двух возможных сигналов выключения, и как только она его получает, она инициирует выключение системы трассировки.

(2): with_graceful_shutdown получает будущее. Если это будущее разрешится, служба будет закрыта. Это как раз тот случай, когда функция получает сигнал завершения.

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

Откройте терминал и выполните следующую команду:

❯ docker run -d --name jaeger \
  -e COLLECTOR_ZIPKIN_HTTP_PORT=9411 \
  -p 5775:5775/udp \
  -p 6831:6831/udp \
  -p 6832:6832/udp \
  -p 5778:5778 \
  -p 16686:16686 \
  -p 14268:14268 \
  -p 9411:9411 \
  jaegertracing/all-in-one:1.6

Это запускает док-контейнер с агентом Jaeger, самим Jaeger, интерфейсом и кое-чем еще.

Затем запустите службу с cargo run и выполните несколько запросов через GraphQL Playground. После этого откройте новую вкладку браузера и перейдите к http://localhost:16686/search. Выберите axum-graphql (или как-то еще, что вы назвали своей службой) из раскрывающегося списка слева и нажмите «Найти следы». Это должно представить вам такое представление:

Если то, что вы видите, похоже на изображение выше, поздравляю. Базовая настройка завершена, осталось сделать только одно: контейнеризировать GraphQL API с помощью Rust.

Контейнеризация вашего GraphQL API

В настоящее время большинство сервисов контейнеризированы, и ваш GraphQL API не должен быть исключением. Пришло время создать контейнер с вашим сервисом, который вы можете развернуть практически везде.

Первое, что вы должны создать, это файл .dockerignore. Это гарантирует, что демон Docker не подберет ненужные файлы. Ваша целевая папка, например, бесполезна, если вы сами не работаете на машине с Linux.

Перетащите следующий контент в свой .dockerignore:

target/
.git/
.env

Следующее, что вам нужно, это Dockerfile. Создайте его и добавьте следующие строки:

FROM --platform=linux/amd64 lukemathwalker/cargo-chef:latest-rust-1.65.0 AS chef # (1)

WORKDIR /app

FROM --platform=linux/amd64 chef AS planner

COPY . .

RUN cargo chef prepare --recipe-path recipe.json

FROM --platform=linux/amd64 chef AS builder

COPY --from=planner /app/recipe.json recipe.json

RUN cargo chef cook --release --recipe-path recipe.json

COPY . .

RUN cargo build --release

FROM debian:bookworm-slim

RUN mkdir -p /app

RUN groupadd -g 999 appuser && \ # (2)
    useradd -r -u 999 -g appuser appuser

USER appuser

COPY --from=builder /app/target/release/axum-graphql /app

WORKDIR /app

ENV JAEGER_ENABLED=true

EXPOSE 8000

ENTRYPOINT ["./axum-graphql"]

Это уже. Есть только две вещи, которые, вероятно, нуждаются в специальном объяснении:

(1): cargo-chef — это инструмент, который упрощает использование системы слоев Docker в ваших интересах. Это значительно ускоряет процесс создания вашего контейнера. Вы можете узнать больше о Cargo-Chef здесь.

(2): Никогда не рекомендуется запускать какое-либо программное обеспечение внутри контейнера с правами root. Новый пользователь создается быстро и избавляет вас (скорее всего) от определенных атак.

Теперь вы можете создать свой контейнер с помощью docker build -t axum-graphql:latest . и быстро запустить его с помощью docker run -p 8000:8000 axum-graphql:latest. Затем снова откройте браузер и перейдите к http://localhost:8000. Вы должны увидеть работающую игровую площадку и сможете отправить несколько запросов.

Если все работает, все готово. Поздравляем!

Краткое резюме

Пришло время быстро подвести итог тому, чего вы уже достигли.

У вас есть:

  • Настройте новый проект Rust
  • Добавлено форматирование и линтинг
  • Создан базовый веб-сервер
  • Сверху добавлен довольно простой, но работающий API GraphQL.
  • Интегрировали метрики в ваш сервис и зарегистрировали (и собрали) некоторые из наиболее важных
  • Добавлена ​​трассировка сверху и создан корневой диапазон
  • Контейнеризируйте ваше приложение, чтобы вы могли развернуть его практически в любом месте

Это большое достижение. Поздравляем еще раз!

Что дальше?

С этого момента вы можете свободно экспериментировать со своим новым GraphQL API. Если у вас есть какие-либо планы относительно API, который вы хотите реализовать, вы можете посмотреть книгу async-graphql. Он отвечает на большинство вопросов, которые могут у вас возникнуть при реализации более продвинутого GraphQL API с помощью Rust. Есть еще немало вещей, которые вы можете узнать о GraphQL и Rust.

Если вы хотите сделать еще один шаг, создайте диаграмму Helm и попробуйте развернуть свой сервис в реальном кластере Kubernetes (или в локальной версии, такой как minikube). Вы увидите, что это еще одна сложная задача, которая многому вас учит.