Узнайте, как создать HTTP API с помощью Rust

Узнайте, как создать HTTP API с помощью Rust

4 февраля 2023 г.

Когда я начал работать над этим постом, у меня была другая идея: я хотел сравнить опыт разработчиков и производительность Spring Boot и GraalVM с Rust в демонстрационном приложении HTTP API.

К сожалению, у процессора M1 моего MacBook Pro были другие идеи.

https://twitter.com/nicolas_frankel/status/1608847301103214593

Поэтому я изменил свой первоначальный план. Я напишу об опыте разработчиков при разработке вышеупомянутого приложения на Rust по сравнению с тем, к чему я привык в Spring Boot.

Пример приложения

Как и у любого любимого проекта, объем приложения ограничен. Я разработал простой HTTP API Create Read Update Delete (CRUD). Данные хранятся в PostgreSQL.

Когда кто-то проектирует приложение на JVM, первым и единственным дизайнерским решением является выбор фреймворка: пару лет назад это был Spring Boot. В настоящее время выбор в основном заключается между Spring Boot, Quarkus и Micronaut. Во многих случаях все они основаны на одних и тех же базовых библиотеках, например, протоколировании или пулах соединений.

Ржавчина намного моложе; следовательно, экосистема еще не созрела. Для каждой функции нужно точно выбрать, какую библиотеку использовать или реализовать. Хуже того, нужно понимать, что есть такая особенность. Вот те, которые я искал:

* Реактивный доступ к базе данных * Объединение соединений с базой данных * Сопоставление строк структурам * Веб-конечные точки * Сериализация JSON * Конфигурация из разных источников, например,, YAML, переменных среды и т. д.

Веб-фреймворк

Выбор веб-фреймворка является наиболее важным. Должен признаться, я понятия не имел о таких библиотеках. Я осмотрелся и наткнулся на какой веб-фреймворк Rust выбрать в 2022 году. Прочитав пост, я решил сделать вывод и выбрал axum:

<цитата>
  • Направлять запросы обработчикам с помощью API без макросов.
  • Декларативно анализировать запросы с помощью экстракторов.
  • Простая и предсказуемая модель обработки ошибок.
  • Создавайте ответы с минимальным шаблоном.
  • Используйте все преимущества промежуточного ПО, сервисов и утилит Tower и Tower-http.

В частности, последний пункт отличает axum от других фреймворков. axum не имеет собственной промежуточной системы, вместо этого использует tower::Service. Это означает, что axum получает тайм-ауты, трассировку, сжатие, авторизацию и многое другое бесплатно. Это также позволяет вам совместно использовать промежуточное программное обеспечение с приложениями, написанными с использованием Hyper или Tonic.

-- документация по пакету axum

axum использует расположенную ниже библиотеку Tokio асинхронную. Для базового использования требуется два ящика:

[dependencies]
axum = "0.6"
tokio = { version = "1.23", features = ["full"] }

Маршрутизатор axum очень похож на Spring Kotlin Routes DSL:

let app = Router::new()
    .route("/persons", get(get_all))         //1
    .route("/persons/:id", get(get_by_id))   //1//2

async fn get_all() -> Response { ... }
async fn get_by_id(Path(id): Path<Uuid>) -> Response { ... }

  1. Маршрут определяется путем и ссылкой на функцию
  2. Маршрут может иметь параметры пути. axum может выводить параметры и связывать их

Общие объекты

Проблема, часто возникающая в проектах по разработке программного обеспечения, – это совместное использование "объекта" с другими пользователями. Мы давно установили, что есть идеи получше, чем совместное использование глобальных переменных.

Spring Boot (и аналогичные платформы JVM) решает эту проблему с помощью внедрения зависимостей во время выполнения. Объекты создаются фреймворком, сохраняются в контексте и внедряются в другие объекты при запуске приложения. Другие фреймворки внедряют зависимости во время компиляции, например, Dagger 2.

В Rust нет ни среды выполнения, ни объектов. Настраиваемая инъекция зависимостей — это не «вещь». Но мы можем создать переменную и внедрить ее вручную там, где это необходимо. В Rust это проблема из-за владения:

<цитата>

Владение — это набор правил, определяющих, как программа на Rust управляет памятью. Все программы должны управлять тем, как они используют память компьютера во время работы. Некоторые языки имеют сборку мусора, которая регулярно ищет неиспользуемую память во время работы программы; в других языках программист должен явно выделять и освобождать память. В Rust используется третий подход: память управляется через систему владения с набором правил, которые проверяет компилятор. Если какое-либо из правил будет нарушено, программа не будет скомпилирована. Ни одна из функций владения не замедлит работу вашей программы.

-- Что такое право собственности?

axum предоставляет специальную оболочку, экстрактор State, для повторно использовать переменные в разных областях.

struct AppState {                                                  //1
    ...
}

impl AppState {
    fn create() -> Arc<AppState> {                                 //2
        Arc::new(AppState { ... })
    }
}

let app_state = AppState::create();
let app = Router::new()
    .route("/persons", get(get_all))
    .with_state(Arc::clone(&app_state));                           //3

async fn get_all(State(state): State<Arc<AppState>>) -> Response { //4
    ...                                                            //5
}
  1. Создайте struct для совместного использования
  2. Создайте новую struct, обернутую в атомарный подсчет ссылок
  3. Поделитесь ссылкой со всеми функциями маршрутизации, например,, get_all
  4. Передать состояние
  5. Используйте!

Автоматическая сериализация JSON

Современные веб-фреймворки JVM автоматически сериализуют объекты в формате JSON перед отправкой. Хорошо, что axum делает то же самое. Он использует Serde. Во-первых, мы добавляем зависимости крейта serde и serde_json:

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

Затем мы аннотируем нашу struct макросом derive(Serialize):

#[derive(Serialize)]
struct Person {
    first_name: String,
    last_name: String,
}

Наконец, мы возвращаем struct, завернутый в Json, и код состояния HTTP в axum Response.

async fn get_test() -> impl IntoResponse {        //1
    let person = Person {                         //2
        first_name: "John".to_string(),
        last_name: "Doe".to_string()
    };
    (StatusCode::OK, Json(person))                //3
}
  1. Кортеж (StatusCode, Json) автоматически преобразуется в Response
  2. Создайте человека
  3. Вернуть кортеж

Во время выполнения axum автоматически сериализует struct в JSON:

{"first_name":"Jane","last_name":"Doe"}

Доступ к базе данных

Долгое время я использовал базу данных MySQL для своих демонстраций. Но я начал читать много хорошего о PostgreSQL и решил переключиться. Мне нужна была асинхронная библиотека, совместимая с Tokio: это именно то, что делает ящик tokio_postgres.

Проблема с крейтом в том, что он создает прямые соединения с базой данных. Я искал ящик пула соединений и наткнулся на deadpool (sic):

<цитата>

Deadpool — это простой асинхронный пул для соединений и объектов любого типа.

-- Дэдпул

Deadpool предлагает две различные реализации:

* Неуправляемый пул: разработчик имеет полный контроль и ответственность за жизненный цикл объединенных объектов. * Управляемый пул: контейнер создает и повторно использует объекты по мере необходимости

Более специализированные реализации последнего предназначены для различных баз данных или «драйверов», например, Redis и... tokio-postgres. Можно настроить Deadpool напрямую или обратиться к config, который он поддерживает. Последний крейт допускает несколько вариантов конфигурации:

<цитата>

Config организует иерархические или многоуровневые конфигурации для приложений Rust.

Config позволяет установить набор параметров по умолчанию, а затем расширить их, объединив конфигурацию из различных источников:

* Переменные среды * Строковые литералы в известных форматах * Другой экземпляр конфигурации * Файлы: TOML, JSON, YAML, INI, RON, JSON5 и пользовательские файлы, определенные с помощью черты формата. * Ручное, программное переопределение (с помощью метода .set в экземпляре Config)

Дополнительно Конфиг поддерживает:

  • Просмотр в реальном времени и повторное чтение файлов конфигурации
  • Глубокий доступ к объединенной конфигурации с помощью синтаксиса пути
  • Десериализация через сервер конфигурации или любого подмножества, определенного через путь

-- Конфигурация ящика

Для создания базовой конфигурации необходимо создать специальную структуру и использовать крейт:

#[derive(Deserialize)]                                       //1
struct ConfigBuilder {
    postgres: deadpool_postgres::Config,                     //2
}

impl ConfigBuilder {
    async fn from_env() -> Result<Self, ConfigError> {       //3
        Config::builder()
            .add_source(
                Environment::with_prefix("POSTGRES")         //4
                    .separator("_")                          //4
                    .keep_prefix(true)                       //5
                    .try_parsing(true),
            )
            .build()?
            .try_deserialize()
    }
}

let cfg_builder = ConfigBuilder::from_env().await.unwrap();  //6
  1. Макрос Deserialize является обязательным
  2. Поле должно соответствовать префиксу среды, см. ниже
  3. Эта функция является асинхронной и возвращает Result
  4. .
  5. Чтение из переменных среды, имя которых начинается с POSTGRES_
  6. Сохранить префикс в карте конфигурации
  7. Наслаждайтесь!

Обратите внимание, что переменные среды должны соответствовать тому, что ожидает Config Дэдпула. Вот моя конфигурация в Docker Compose:

| Переменная окружения | Значение | |----|----| | POSTGRES_HOST | "postgres" | | POSTGRES_PORT | 5432 | | POSTGRES_USER | "postgres" | | POSTGRES_PASSWORD | "корень" | | POSTGRES_DBNAME | "приложение" |

После инициализации конфигурации мы можем создать пул:

struct AppState {
    pool: Pool,                                                     //1
}

impl AppState {
    async fn create() -> Arc<AppState> {                            //2
        let cfg_builder = ConfigBuilder::from_env().await.unwrap(); //3
        let pool = cfg_builder                                      //4
            .postgres
            .create_pool(
                Some(deadpool_postgres::Runtime::Tokio1),
                tokio_postgres::NoTls,
            )
            .unwrap();
        Arc::new(AppState { pool })                                 //2
    }
}
  1. Оберните пул в собственную структуру
  2. Оберните struct в Arc, чтобы передать ее внутри axum State (см. выше)
  3. Получить конфигурацию
  4. Создать пул

Затем мы можем передать пул функциям маршрутизации:

let app_state = AppState::create().await;                           //1
let app = Router::new()
    .route("/persons", get(get_all))
    .with_state(Arc::clone(&app_state));                            //2

async fn get_all(State(state): State<Arc<AppState>>) -> Response {
    let client = state.pool.get().await.unwrap();                   //3
    let rows = client
        .query("SELECT id, first_name, last_name FROM person", &[]) //4
        .await                                                      //5
        .unwrap();
    //                                                              //6
}
  1. Создайте состояние
  2. Передать состояние функциям маршрутизации
  3. Вывести пул из состояния и вывести клиента из пула
  4. Создайте запрос
  5. Выполнить
  6. Прочитайте строку, чтобы заполнить Response

Последним шагом является реализация преобразования Row в Person. Мы можем сделать это с помощью трейта From.

impl From<&Row> for Person {
    fn from(row: &Row) -> Self {
        let first_name: String = row.get("first_name");
        let last_name: String = row.get("last_name");
        Person {
            first_name,
            last_name,
        }
    }
}

let person = row.into();

Сборка Docker

Последний шаг — создание приложения. Я хочу, чтобы все могли строить, поэтому я использовал Docker. Вот Dockerfile:

FROM --platform=x86_64 rust:1-slim AS build                                  //1

RUN rustup target add x86_64-unknown-linux-musl                              //2
RUN apt update && apt install -y musl-tools musl-dev                         //3

WORKDIR /home

COPY Cargo.toml .
COPY Cargo.lock .
COPY src src

RUN --mount=type=cache,target=/home/.cargo                                  //4
 && cargo build --target x86_64-unknown-linux-musl --release                 //5

FROM scratch                                                                 //6

COPY --from=build /home/target/x86_64-unknown-linux-musl/release/rust /app   //7

CMD ["/app"]
  1. Начните со стандартного образа Rust
  2. Добавить цель musl, чтобы мы могли скомпилировать в Alpine Linux
  3. Установите необходимые зависимости Alpine
  4. Кэшировать зависимости
  5. Сборка для Alpine Linux
  6. Начать с нуля
  7. Добавить ранее созданный двоичный файл

Итоговое изображение весит 7,56 МБ. Мой опыт показывает, что эквивалентный собственный скомпилированный образ GraalVM будет занимать более 100 МБ.

Заключение

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

Что еще более важно, я испытал, что значит разрабатывать приложение без такой среды, как Spring Boot. Вам необходимо знать следующее:

  1. Доступные контейнеры для каждой возможности
  2. Совместимость ящиков
  3. Совместимость версий

И последнее, но не менее важное: документация большинства вышеупомянутых ящиков варьируется от среднего до хорошего. Я нашел, что axum хорош; с другой стороны, мне не удалось правильно использовать Дэдпула с самого начала, и мне пришлось пройти через несколько итераций. Качество документации ящиков Rust отличается от ящика к ящику. В целом, у них есть потенциал, чтобы достичь уровня современных JVM-фреймворков.

Кроме того, демонстрационное приложение было довольно простым. Я предполагаю, что более продвинутые функции могут быть более болезненными.

Полный исходный код для этого поста можно найти на GitHub.

Для дальнейшего чтения:


Первоначально опубликовано здесь


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE