Интеграция Rust в Next.js: практическое руководство для разработчика

Rust ― один из новейших языков, который находится в центре внимания сообщества разработчиков. Сопоставимый по производительности с C/C++, он вполне пригоден для разнообразных задач и запускается на множестве платформ, в том числе в вебе. Расскажем, как задействовать Rust в веб-проектах и уже известных платформах.

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

При изучении нового языка моя цель как программиста в проектах PHP, Python, Objective C, Swift и JavaScript ― поскорее приступить к написанию производственного кода. Не поймите меня неправильно, основы важны, но реальные проблемы решаются видением и знанием нюансов языка.

Сейчас я работаю с бэкендом и API-интерфейсами, поэтому и цель ― вместо обычного JavaScript/Node.js начать писать новые конечные точки API для проектов на Rust, размещаемых на универсальной платформе Vercel. Развертывая на ней самые разнообразные фреймворки, можно забыть о проблемах управления инфраструктурой приложений и просто сконцентрироваться на функционале.

Рассмотрим различные варианты развертывания кода Rust на Vercel, подробнее остановимся на выбранном мною для интегрирования его в текущие проекты на JavaScript:

  • WebAssembly, или Wasm, на Edge. Пишется код Rust, компилируемый затем в двоичный .wasm и импортируемый, как любой другой пакет/файл, в код JavaScript.
  • Пользовательская среда выполнения Rust. Помимо развертывания Node.js и фреймворков вроде Next.js, в Vercel поддерживаются пользовательские среды выполнения для развертывания машинного кода, скомпилированного из других языков: Go, Python, а также Rust. Часть этих сред сопровождаются Vercel, часть вроде среды выполнения Rust — сообществом. И те, и другие вполне рабочие, раньше мною применялись среды выполнения PHP и Python.
  • Среда выполнения Rust в проекте Next.js. Хотя среда Rust отлично сочетается с Vercel, многие мои проекты написаны на фреймворках вроде Next.js. Чтобы увеличить количество проектов с Rust, я решил интегрировать шаблон, где применяется среда выполнения Rust с Next.js, и таким образом постепенно внедрить функционал для проектов на Rust, заодно каждодневно осваивая этот язык.

Настройка

Начнем с нового проекта create-next-app и покажем необходимые изменения. Следуйте тем же этапам в своем проекте. Вот шаблон в GitHub.

В подготовленную смесь из проекта Next.js версии 12.x, 13.x и 14.x и каталога pages или app начнем помещать Rust. Чтобы применять пользовательские среды выполнения, добавим в проект файл конфигурации vercel.json:

{
"functions": {
"api/**/*.rs": {
"runtime": "[email protected]"
}
}
}

Чтобы запустить локальный сервер для конечных точек API на Rust, командой npm install vercel -D добавим в зависимости этапа разработки интерфейс командной строки vercel. Так в Vercel любые .rs-файлы будут разворачиваться в виде бессерверных функций API со средой выполнения Rust. Если внутри pages/api/ имеется код, оставляем его как есть и переходим к созданию в проекте каталога верхнего уровня api/ для функций среды выполнения Rust.

Чтобы установить Rust на локальный компьютер, воспользуйтесь rustup. В качестве диспетчера пакетов и системы сборки в Rust применяется cargo. Все, что необходимо для сборки и запуска кода на Rust локально, получается одной командой.

Чтобы включить cargo, создаем в корневой папке проекта конфигурационный файл Cargo.toml:

[package]
name = "next-rust"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { version = "1", features = ["macros"] }
serde_json = { version = "1", features = ["raw_value"] }
# Документация: https://docs.rs/vercel_runtime/latest/vercel_runtime
vercel_runtime = { version = "1.1.0" }

# Каждый обработчик указывается как «[[bin]]»
[[bin]]
name = "crab"
path = "api/crab.rs"

Похож на package.json. Добавляем общие метаданные, такие как название и версию, а затем — зависимости для создания конечных точек API.

В Rust зависимости называются «крейтами» и размещаются на crates.io, в качестве асинхронной среды выполнения используется tokio, для синтаксического анализа/преобразования JSON — serde_json, для манипулирования запросами и ответами — vercel_runtime, эквивалент API-интерфейсов Vercel или Next.js.

Чтобы включить весь функционал Rust и получать помощь и контекст при написании кода в VS Code, устанавливаем расширение rust-analyzer.

Теперь создадим первую конечную точку API на Rust и назовем ее api/crab.rs, как написано в конфигурационном файле cargo:

use serde_json::json;
use vercel_runtime::{run, Body, Error, Request, Response, StatusCode};

#[tokio::main]
async fn main() -> Result<(), Error> {
run(handler).await
}

pub async fn handler(_req: Request) -> Result<Response<Body>, Error> {
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(
json!({ "message": "crab is the best!" }).to_string()
.into(),
)?)
}

Не писали на Rust ни строчки кода, но применяли Vercel или HTTP-сервер на любом языке? Тогда, хоть не на 100%, но разберетесь. Здесь создается функция-обработчик handler, вызываемая для ответа на API-запрос к GET /api/crab. В ответ выдается код состояния 200 OK и возвращается сообщение в формате JSON.

Быстро протестируем его, запустив npx vercel dev. Если создан новый проект Vercel, будет предложено настроить его, просто следуйте инструкциям. По окончании увидите что-то вроде Ready! Available at http://localhost:3000 («Готово. Доступно в http://localhost:3000»).

Открыв в браузере http://localhost:3000/api/crab, увидите ответ:

Ответ от конечной точки API на Rust

Обновите страницу, и ответ вернется почти мгновенно. Ведь, благодаря использованию cargo в vercel dev, файл Rust создается и запускается на лету.

А еще так автоматически обнаруживаются изменения: файл перекомпилируется только при наличии в коде изменений.

Заметили в корневом каталоге target/? Это папка стандартного вывода для двоичных файлов Rust, добавляем ее в файл .gitignore или в .vercelignore, загляните в шаблон в конце статьи.

Немного почистим код и запустим его с остальной частью кодовой базы Next.js. Среда выполнения Rust позаботится о запуске кода Rust при развертывании в Vercel, подкорректируем только команду npm run dev в dev:

{
"name": "next-api-rust",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev & npm run dev:rust",
"dev:rust": "vercel dev --listen 3001",
}
}

Здесь мы запускаем API-интерфейс Rust на другом порту с новым скриптом dev:rust. Затем добавляем ее и в стандартный скрипт dev вместе с запуском next dev. Теперь запускаем все одной командой, как обычно в проектах Next.js.

localhost:3001 для конечных точек Rust отличается от порта localhost:3000 по умолчанию для Next.js, исправляем это, добавляя в next.config.js rewrite:

module.exports = {
rewrites: async () => {
const rewrites = {
afterFiles: [
// применяем здесь любую из имеющихся «rewrite»
],
fallback: []
}

// только «dev», так локальные api-вызовы переправляются
// на маршруты api, которыми используется среда выполнения Rust
if (process.env.NODE_ENV === 'development') {
rewrites.fallback.push({
source: '/api/:path*',
destination: 'http://0.0.0.0:3001/api/:path*'
})
}

return rewrites
}
}

rewrite применяем только в разработке. В Vercel все это разворачивается в одном развертывании, поэтому в rewrite нет необходимости. Снова переходим в http://localhost:3000/api/crab: все так же, хотя запустились на другом сервере dev. При добавлении rewrite в fallback ни одна из имеющихся конечных точек pages/api в разработке тоже гарантированно не переписывается конечными точками API на Rust.

Рассмотрим общие закономерности, применяемые в повседневной разработке API.

Кэширование

В любом пригодном для продакшена API применяется кэширование. При развертывании на Vercel ответы API кэшируются сетью Edge, или сетью доставки содержимого. При этом в ответах применяются стандартные заголовки cache-control и директивы s-maxage.

Конечная точка API кэшируется на один час так:

pub async fn handler(_req: Request) -> Result<Response<Body>, Error> {
Ok(Response::builder()
.header(
"Cache-Control",
format!(
"public, max-age=0, must-revalidate, s-maxage={s_maxage}",
s_maxage = 1 * 60 * 60
),
)
.body(
json!({ "message": "crab is the best!" })
.to_string()
.into(),
)?)
}

Считывание параметров запроса

С помощью vercel_runtime считываются данные входящих запросов. Вот типичные примеры:

// считывание заголовков входящих запросов
let headers = req.headers();
let authHeader = headers.get("authorization");

// синтаксический анализ URL-адреса и пути запроса
let url = Url::parse(&_req.uri().to_string());

// считываем параметры запроса url-адреса
let query_params = url
.query_pairs()
.into_owned()
.collect::<HashMap<String, String>>();
let id = query_params.get("id")

Распространенные утилиты/библиотеки

В проектах покрупнее имеется набор служебных программ, используемых в разных конечных точках API. В конфигурационном Cargo на Rust тоже регистрируются локальные крейты:

[lib]
path = "src/rs/utils.rs"

Я поместил весь общий код Rust в каталог src/rs отдельно от других файлов JavaScript. В этом крейте определяются функции и макросы для переиспользования в конечных точках API на Rust. Эти функции импортируются так же, как из любого другого крейта Rust. А с rust-analyzer или аналогичной языковой интеграцией в IDE — автоматически, стоит только начать вводить название функции.

Переменные среды́

Доступ ко всем переменным среды́ получается из локального файла .env или из развертывания Vercel, рекомендуется удобный крейт dotenv.

Обработка ошибок

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

pub fn throw_error(
message: &str,
error: Option<Error>,
status_code: StatusCode,
) -> Result<Response<Body>, Error> {
if let Some(error) = error {
eprintln!("error: {error}");
}

Ok(Response::builder()
.status(status_code)
.header("Content-Type", "application/json")
.body(
json!({ "message": message })
.to_string()
.into(),
)?)
}

Им принимаются сообщение, ошибка, код состояния HTTP, которые затем регистрируются, и возвращается ответ с сообщением об ошибке и указанным кодом состояния. Да и не так много найдется макросов для распространенных случаев выбрасывания общих ошибок 500:

#[macro_export]
macro_rules! throw_error {
($message:expr, $error:expr) => {
throw_error($message, $error, StatusCode::INTERNAL_SERVER_ERROR)
};
($message:expr) => {
throw_error($message, None, StatusCode::INTERNAL_SERVER_ERROR)
};
($message:expr, $error:expr, $status_code:expr) => {
throw_error($message, $error, $status_code)
};
}

Различные методы запроса

Вот доступные для поддержки методы запроса: POST , PUT, DELETE. Каждой функцией API обрабатываются разные методы запроса. Чтобы проверить, какие методы принимаются конечной точкой API, и ограничить их, сначала для каждого из методов запроса определим отдельную функцию, например для метода POST:

fn route_post(_req: Request) -> Result<Response<Body>, Error> {
// здесь выполняется стандартный «Response::builder()»,
// и возвращается ответ
}

Затем в обработчике проверяем корректные функции и сопоставляем с методами запроса:

pub async fn handler(_req: Request) -> Result<Response<Body>, Error> {
let response = match _req.method().to_owned() {
Method::POST => route_post(_req),
_ => {
// макрос «throw_error» в действии
return Ok(throw_error!(
"method not allowed",
None,
StatusCode::METHOD_NOT_ALLOWED
)?);
}
};

// затем здесь, как обычно, возвращается ответ
}

Взаимодействие с другими службами

Конечные точки API взаимодействуют с другими службами по различным протоколам, обычно по HTTP. Рекомендую крейт reqwest, хорошо сочетаемый с другими пакетами. В ссылке на шаблон проекта показаны примеры.

Холодная загрузка и запуск

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

Исходя из результатов тестирования, скомпилированные бессерверные функции в Vercel со средой выполнения Rust меньше функций Node.js с аналогичным объемом кода, но больше функций Edge:

В Vercel холодные загрузки по размеру располагаются между средами выполнения Node.js и Edge

Измеренная мною скорость холодной загрузки варьируется в пределах 500–1000 мс. При обращении с теплым/готовым экземпляром скорость отменная. Если проектом получается много запросов вместе с кэшированием, никаких проблем с реализованными в Rust конечными точками API не предвидится.

Месяцами применяю эту настройку в таких проектах, как этот, где API полностью запускается в среде выполнения Rust, а JavaScript/React применяются только для HTML-отрисовки страниц.

В отличие от Node.js, на Rust проект сильно продвинулся в базовом извлечении данных и синтаксическом анализе. Например, настройка среды выполнения «безголового» браузера при ограничении бессерверной функции в 50 Мб перестает быть проблемой на Rust, поскольку размер меньше.

Главное — подобрать правильный инструмент. Любой, кто работал над долгосрочным проектом в продакшене, знает: установка на повсеместное применение rewrite нежизнеспособна. Поэтому частичная интеграция такого рода и использование различных технологий/языков в одном проекте идеальны, потому как способны сосуществовать и при правильном их выборе совершенствовать проект.

Проблемы

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

Одна из проблем — иногда работа vercel dev, где запускаются функции среды выполнения Rust, просто аварийно завершалась без видимых причин. Например, при добавлении через cargo конкретных зависимостей требовался перезапуск vercel dev. Иногда при добавлении в конфигурационный Cargo нового файла или библиотеки приходилось также перезапускать команду dev или завершать процесс node, которым запускается сервер. Хотя Rust — среда выполнения сообщества, признаваемая Vercel, в будущем они, надеюсь, добавят ее как официальную.

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

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

Дополнительные материалы

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Ante Barić: Integrating Rust into Next.js: How-To Developer Guide

Предыдущая статья10 продвинутых приемов JavaScript для опытных разработчиков
Следующая статьяОбучение и развертывание пользовательской модели Detectron2 для обнаружения объектов в PDF-документах. Часть 1: обучение