[Из песочницы] Прогулка по быстрому, безопасному и почти законченному веб-сервису на Rust
оригинал
В течение многих лет у меня было стойкое недоверие к интерпретируемым языкам. Они быстрые и работать с ними приятно, но хороши они только для работы на небольших системах, если же у вас быстрорастущий проект их привлекательность быстро улетучивается. Создание большого приложения на Ruby или JavaScript (или множестве других языков) — это бесконечный сизифов труд — вы решаете одну проблему только для того, чтобы другая тут же скатилась на вас с горы. И совершенно неважно сколько тестов вы напишете или насколько хороша ваша команда, любая новая разработка создаст мириаду ошибок, исправление которых займет месяцы или годы.
Основная проблема кроется в пограничных условиях. Программисты делают все возможное для написания и тестирования «happy path», но человеческий фактор мешает нам видеть проблему со всех сторон и особенно края и углы, которые причиняют наибольшие проблемы пока программа используется.
Ограничения, такие как компилятор и проницательная система типов, — это инструменты, которые помогают нам определять эти условия. Во всех языках есть спектр разрешимости, и я четко убежден, что чем больше времени потрачено на написание приложения по правилам языка, тем меньше времени уйдет на устранение проблем.
Rust
Если возможно построение надежных систем с языками программирования с ограничениями, то что же о языках с самыми строгими ограничениями? Я направился в самый дальний конец спектра и создал веб-сервис на Rust крайне непопулярном за его бескомпромиссный компилятор.
Rust пока ещё новый и относительно редко используемый язык. Изучать его было непросто — множество правил системы типов, и правила владения и заимствования, но несмотря на трудности, опыт был интересным, главное Rust работает. Я сталкиваюсь с меньшим количеством забытых пограничных условий и ошибок времени исполнения, а рефакторинг больше не вызывает ужаса.
Далее мы рассмотрим некоторые идеи, основные библиотеки и структуры Rust.
Основы
Я построил свою систему на actix-web, веб-фреймворке, построенном на actix, акторной библиотеке для Rust. Actix похож на то, что вы можете встретить, например, в Erlang, однако он добавляет еще один уровень надежности и скорости, используя систему типов и параллелизма Rust. К примеру, невозможно, чтобы актор получил сообщение, которое он не сможет обработать во время исполнения, потому как компилятор проверит соответствие типов сообщений.
Возможно вам знакомо имя actix — недавно actix-web пробился к вершине тестов TechEmpower. Программы, созданные для таких тестов, часто искусственно оптимизированы, но теперь среди всех оптимизированных языков уверенно стоит Rust, максимально придвинувшись к таким гигантам как С++ и Java. Вне зависимости от того, как вы относитесь к достоверности бенчмарков actix-web работает быстро.
Rust в топ-10 с Java и C ++ в тестах TechEmpower.
Автор actix-web (и actix) создаёт колоссальный объем кода — проект появился около шести месяцев назад, и он не только уже более функциональный, с лучшими API-интерфейсами, чем веб-фреймворки на других языках с открытым исходным кодом, но более того, функциональней фреймворков, которые финансируются крупными организациями с огромными командами разработчиков. Такие функции как HTTP/2, WebSockets, streaming responses, graceful shutdown, HTTPS, поддержка cookie, static files serving и хорошая инфраструктура тестирования доступны сразу. Документация по-прежнему немного неполная, но я еще не столкнулся ни с одной ошибкой.
Diesel и проверка во время компиляции
Я использовал diesel как ORM, чтобы поговорить с Postgres. ORM написан человеком с большим опытом работы, который провел много времени на передовой, работая с Active Record. Многие из ошибок, присущие более ранним поколениям ORM, были устранены, — например, diesel
не делает вид, что диалекты SQL в каждой базе данных одинаковы, не использует специализированный DSL для миграции (вместо этого используется обычный SQL) и он не управляет соединениями с базой на глобальном уровне. Он предоставляет мощные функции Postgres, такие как upsert
и jsonb
прямо в основной библиотеке и обеспечивает, по возможности, мощные механизмы безопасности.
Большинство запросов к базе данных написаны с использованием diesel«ных типов DSL. Если я неправильно использую поле, пробую вставить кортеж в неправильную таблицу или даже создать невозможное соединение, компилятор тут же выдаст сообщение об ошибке. Вот типичная операция (в этом случае Postgres INSERT INTO ... ON CONFLICT
… или «upsert»):
time_helpers::log_timed(&log.new(o!("step" => "upsert_episodes")), |_log| {
Ok(diesel::insert_into(schema::episode::table)
.values(ins_episodes)
.on_conflict((schema::episode::podcast_id, schema::episode::guid))
.do_update()
.set((
schema::episode::description.eq(excluded(schema::episode::description)),
schema::episode::explicit.eq(excluded(schema::episode::explicit)),
schema::episode::link_url.eq(excluded(schema::episode::link_url)),
schema::episode::media_type.eq(excluded(schema::episode::media_type)),
schema::episode::media_url.eq(excluded(schema::episode::media_url)),
schema::episode::podcast_id.eq(excluded(schema::episode::podcast_id)),
schema::episode::published_at.eq(excluded(schema::episode::published_at)),
schema::episode::title.eq(excluded(schema::episode::title)),
))
.get_results(self.conn)
.chain_err(|| "Error upserting podcast episodes")?)
})
Более сложный SQL сложно создать с помощью DSL, но, к счастью, есть отличная альтернатива в виде встроенного include_str! макро. Он включает содержимое файла во время компиляции, и мы можем передать их в diesel для привязки и заполнения параметрами:
diesel::sql_query(include_str!("../sql/cleaner_directory_search.sql"))
.bind::(DIRECTORY_SEARCH_DELETE_HORIZON)
.bind::(DELETE_LIMIT)
.get_result::(conn)
.chain_err(|| "Error deleting directory search content batch")
Запрос находится в собственном файле .sql:
WITH expired AS (
SELECT id
FROM directory_search
WHERE retrieved_at < NOW() - $1::interval
LIMIT $2
),
deleted_batch AS (
DELETE FROM directory_search
WHERE id IN (
SELECT id
FROM expired
)
RETURNING id
)
SELECT COUNT(*)
FROM deleted_batch;
Мы не можем делать проверку SQL во время компиляции с таким подходом, но с другой стороны получаем прямой доступ к исходному синтаксису SQL и отличную подсветку синтаксиса в вашем любимом редакторе.
Быстрая (но не самая быстрая) модель параллелизма
actix-web
работает поверх tokio, быстрой библиотеки обработки асинхронных событий, которая является краеугольным камнем асинхронной работы Rust. При запуске HTTP-сервера, actix-web
создает определенное количество рабочих потоков, равное количеству логических ядер на сервере, каждый в собственной системном потоке и с собственным реактором tokio
.
Обработчики HTTP запросов могут быть написаны различными способами. Например, обработчик, синхронно возвращающий данные:
fn index(req: HttpRequest) -> Bytes {
...
}
Этот обработчик блокирует реактор tokio
до тех пор, пока не вернёт результат, что подходит в ситуациях, когда не требуется никаких дополнительных блокирующих вызовов. Например, рендеринг статического контента из памяти или ответ на проверку состояния приложения.
Также можно написать обработчик, который возвращает future
. Это позволит нам объединить ряд асинхронных вызовов, чтобы гарантировать, что реактор никогда не будет заблокирован.
fn index(req: HttpRequest) -> Box> {
...
}
Примерами этого может быть операция с файлом, который мы читаем с диска (блокирование ввода-вывода, хотя и минимально), или ожидание ответа из нашей базы данных. Ожидая результата future, реактор tokio
будет обрабатывать другие запросы.
Пример модели параллелизма с actix-web.
Синхронные акторы
Поддержка futures
в Rust
широко распространена, но не универсальна. Примечательно, что diesel не поддерживает асинхронные операции, поэтому все его операции будут блокироваться.
При использовании diesel
, непосредственно из обработчика actix-web
, заблокирует реактор tokio
и прекратит обработку запросов до завершения блокирующей операции.
К счастью, у actix
есть отличное решение этой проблемы в виде синхронных акторов. Акторы выполняют синхронную обработку сообщений во время работы и поэтому каждому присваивается собственный выделенный поток ОС. SyncArbiter
позволяет легко запускать нескольких копий актора одного типа, каждый из которых работает с общей очередью сообщений, что делает возможным работу со всеми акторами одновременно (см. Ниже как addr
):
// Start 3 `DbExecutor` actors, each with its own database
// connection, and each in its own thread
let addr = SyncArbiter::start(3, || {
DbExecutor(SqliteConnection::establish("test.db").unwrap())
});
Хотя операции внутри синхронного актора блокируются другим акторам в системе, такими как HTTP-обработчики, не обязательно ждать завершения какой-либо из них — они получают future
, которое представляет результат сообщения, в то время как они выполняют другую работу.
В моей реализации быстрые вычисления, как обработка параметров запроса и рендеринга контента, выполняются внутри обработчиков, и синхронные акторы никогда не активизируются, если они не нужны. Когда ответ требует операций с базой данных, сообщение отправляется синхронному актору, а реактор tokio
обслуживает другой трафик, ожидая, когда future будет завершено. Когда это происходи, он создаёт http ответ с результатами и отправляет его обратно ожидающему клиенту.
Управление подключением
На первый взгляд, введение синхронных акторов в систему может показаться недостатком, поскольку они ограничивают параллелизм системы. Однако эти ограничения также могут быть преимуществом. Одна из первых проблем масштабирования, с которой вы, вероятно, столкнетесь в Postgres, — это ограничения на максимальное количество одновременных подключений. Даже самые большие базы на Heroku или GCP (Cloud Cloud Platform) дают максимум 500 подключений, а в меньших базах ограничения и того ниже (моя небольшая база на GCP имеет ограничения в 25 соединений). Большие системы, использующие функции работы с соединениями фреймворка (например, Rails и многие другие) используют такие решения как PgBouncer, чтобы решить эту проблему.
Указание количества синхронных акторов по умолчанию также подразумевает максимальное количество соединений, которые будет использоваться службой, что приводит к идеальному контролю использования соединений.
Соединения используются только в том случае, если требуется синхронный актор.
Я написал своих синхронных акторов, чтобы использовать отдельные соединения из пула соединений (r2d2) только когда работа начинается и освобождать их после завершения. Когда служба находится в режиме ожидания, запуска или отключения она не использует соединения. Сравните это со многими веб-фреймворками, где система открывает соединение с базой данных как только рабочий процесс запустился, и держит его открытым, пока рабочий поток не остановится. Этот подход требует ~2x соединений для изящных перезапусков, потому что все рабочие процессы устанавливают соединение и удерживают его даже в процессе завершения.
Эргономичное преимущество синхронного кода
Синхронные операции выполняются не так быстро, как чисто асинхронный подход, но их преимущество в простоте использования. Приятно, что futures
бывают быстрыми, но написание их соответсвующим образом требует много времени, а ошибки компилятора, которые они генерируют — это кошмар, который требует много времени на настройку и исправление.
Написание синхронного кода быстрее и проще, и я лично согласен смириться с условно неоптимальной скоростью выполнения, если это означает, что я могу быстрее реализовать основную бизнес логику.
Медленно, но только относительно «очень, очень быстрого»
Может это звучит немного пренебрежительно для характеристик исполнения этой модели, но имейте в виду, что она медленная только по сравнению с чисто асинхронным стеком (т. е. futures). Это по-прежнему концептуально звуковая параллельная модель с реальным параллелизмом и по сравнению с любыми другими фреймворками и языками программирования она очень, очень быстрая. Я работаю на Ruby на своей основной работе и по сравнению с без-потоковой моделью (обычной для Ruby, потому как GIL ограничивает производительность потоков), эта модель на порядок лучше и эффективней в плане использования памяти.
В конце концов, ваша база данных будет узким местом, а синхронная модель акторов поддерживает именно такой параллелизм, одновременно обеспечивая максимальную пропускную способность для любых действий, которым не нужен доступ к базе данных.
Обработка ошибок
Как и любая хорошая программа Rust, API почти повсеместно возвращают тип Result
. Futures
используют свою версию Result
содержащую либо успешный результат, либо ошибку.
Я использую error_chain для определения своих ошибок. Большинство из них являются внутренними, но я определил определенную группу с прямой целью:
error_chain!{
errors {
//
// User errors
//
BadRequest(message: String) {
description("Bad request"),
display("Bad request: {}", message),
}
}
}
Когда ошибка должен быть передана пользователю, я обязательно сопоставляю его с одним из моих типов ошибок:
Params::build(log, &request).map_err(|e|
ErrorKind::BadRequest(e.to_string()).into()
)
После ожидания ответа от синхронного актора или после попытки создания успешного HTTP ответа я обрабатываю ошибки и отправляю ответ пользователю. Реализация оказалась довольно элегантной (обратите внимание, что в композиции Future::then
отличается от and_then тем, что она обрабатывает и успех и провала, получая Result
, в отличие от and_then
который обрабатывает только успешное завершение):
let message = server::Message::new(&log, params);
// Send message to synchronous actor
sync_addr
.send(message)
.and_then(move |actor_response| {
// Transform actor response to HTTP response
}
.then(|res: Result|
server::transform_user_error(res, render_user_error)
)
.responder()
Ошибки, не предназначенные для пользователя, логируются, а actix-web
возвращает их как 500 Internal server error
(хотя я, вероятно, в какой-то момент добавлю в нее собственный визуализатор).
Вот transform_user_error
. Функция render
абстрагирует обработку ошибок, поэтому мы можем повторно использовать эту функцию в разных API, которая отображает ответы JSON, и веб-сервер, который отображает HTML.
pub fn transform_user_error(res: Result, render: F) -> Result
where
F: FnOnce(StatusCode, String) -> Result,
{
match res {
Err(e @ Error(ErrorKind::BadRequest(_), _)) => {
// `format!` activates the `Display` traits and shows our error's `display`
// definition
render(StatusCode::BAD_REQUEST, format!("{}", e))
}
r => r,
}
}
Middleware
Как веб-фреймворки на многих языках, actix-web
поддерживает middleware
. Вот простой пример, который инициализирует logger для каждого запроса и устанавливает его в расширение запроса (совокупность состояний запроса, которая будет работать до тех пор, пока выполняется запрос):
pub mod log_initializer {
pub struct Middleware;
pub struct Extension(pub Logger);
impl actix_web::middleware::Middleware for Middleware {
fn start(&self, req: &mut HttpRequest) -> actix_web::Result {
let log = req.state().log().clone();
req.extensions().insert(Extension(log));
Ok(Started::Done)
}
fn response(
&self,
_req: &mut HttpRequest,
resp: HttpResponse,
) -> actix_web::Result {
Ok(Response::Done(resp))
}
}
/// Shorthand for getting a usable `Logger` out of a request.
pub fn log(req: &mut HttpRequest) -> Logger {
req.extensions().get::().unwrap().0.clone()
}
}
Особенностью является то, что middleware
привязывается к типу вместо строки (как, к примеру, Rack в Ruby). Это не только помогает проверять тип во время компиляции таким образом, что вы не сможете ошибочно вести ключ, но также дает middleware
возможность контролировать свою модульность. Если бы мы хотели скрыть middleware
, мы могли бы удалить pub
из Extension, чтобы он стал закрытым. Любые другие модули не смогли бы получать доступ к этим данным из-за проверки видимости компилятором.
Асинхронность до самого конца
Подобно обработчикам запросов, middleware
может быть асинхронным, возвращая future вместо Result. Это позволит, например, реализовать middleware
, ограничивающий скорость передачи, который использовал бы Redis таким образом, чтобы не блокировать другие обработчики. Я по-моему уже упоминал, что actix-web
довольно быстрый?
HTTP-тестирование
Документация actix-web
описывает несколько рекомендаций для методологий тестирования вашего кода. Я остановился на серии модульных тестов, которые используют TestServerBuilder
чтобы создать маленькое приложение, содержащее единственный обработчик, и затем выполнить запрос против него. Это хороший компромисс, потому как, несмотря на минимальные тесты, они используют полный стек HTTP, и из-за чего они становятся быстрыми и законченными:
#[test]
fn test_handler_graphql_get() {
let bootstrap = TestBootstrap::new();
let mut server = bootstrap.server_builder.start(|app| {
app.middleware(middleware::log_initializer::Middleware)
.handler(handler_graphql_get)
});
let req = server
.client(
Method::GET,
format!("/?query={}", test_helpers::url_encode(b"{podcast{id}}")).as_str(),
)
.finish()
.unwrap();
let resp = server.execute(req.send()).unwrap();
assert_eq!(StatusCode::OK, resp.status());
let value = test_helpers::read_body_json(resp);
// The `json!` macro is really cool:
assert_eq!(json!({"data": {"podcast": []}}), value);
}
Я активно использую serde_json
(стандартную библиотеку кодирования и декодирования Rust) json!
макрос, используется в последней строке коде выше. Если вы посмотрите внимательно, вы заметите, что встроенный JSON не является строкой — json!
Что позволяет мне записыват фактическую JSON-нотацию прямо в мой код, который будет проверен и преобразован в действительную структуру Rust компилятором. Это самый элегантный подход к тестированию ответов HTTP JSON, которые я когда-либо видел в других языках программирования.
Резюме: Является ли Rust будущим надежных систем?
Было бы справедливо сказать, что я мог бы написать такой же сервис на Ruby в 10 раз быстрее, чем на Rust. Часть этого времени ушла на обучение, часть на укрощение строптивого компилятора, которое иногда превращается в долгий и разочаровывающий процесс. Тем не менее, снова и снова сталкиваясь с этим последним препятствием, я запускал свою программу, испытывая эйфорию от того, что она работает именно так, как я хочу. Сравните это с интерпретируемыми языками, когда вам может быть удасться запустить программу с 15 попытки, но даже тогда краевые условия поти сто процентов будут неверными. Rust также позволяет вносить большие изменения — для меня нередко реорганизовать тысячу строк за раз, а потом еще раз и даже после этого программа отлично работает. Любой, кто видел большую программу на интерпретируемом языке в production, знает, что вносить изменения можно только небольшими частями, в противном случае вы сильно рискуете. Стоит ли вам написать свой следующий веб-сервис на Rust? Я пока не знаю, однако вам однозначно стоит обратить на него внимание.