Главная » Хабрахабр » [Из песочницы] Прогулка по быстрому, безопасному и почти законченному веб-сервису на Rust

[Из песочницы] Прогулка по быстрому, безопасному и почти законченному веб-сервису на Rust

оригинал

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

Программисты делают все возможное для написания и тестирования “happy path”, но человеческий фактор мешает нам видеть проблему со всех сторон и особенно края и углы, которые причиняют наибольшие проблемы пока программа используется. Основная проблема кроется в пограничных условиях.

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

Rust

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

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

Далее мы рассмотрим некоторые идеи, основные библиотеки и структуры Rust.

Основы

Actix похож на то, что вы можете встретить, например, в Erlang, однако он добавляет еще один уровень надежности и скорости, используя систему типов и параллелизма Rust. Я построил свою систему на actix-web, веб-фреймворке, построенном на actix, акторной библиотеке для Rust. К примеру, невозможно, чтобы актор получил сообщение, которое он не сможет обработать во время исполнения, потому как компилятор проверит соответствие типов сообщений.

Программы, созданные для таких тестов, часто искусственно оптимизированы, но теперь среди всех оптимизированных языков уверенно стоит Rust, максимально придвинувшись к таким гигантам как С++ и Java. Возможно вам знакомо имя actix — недавно actix-web пробился к вершине тестов TechEmpower. Вне зависимости от того, как вы относитесь к достоверности бенчмарков actix-web работает быстро.

Rust в топ-10 с Java и C ++ в тестах TechEmpower.
Rust в топ-10 с Java и C ++ в тестах TechEmpower.

Такие функции как HTTP/2, WebSockets, streaming responses, graceful shutdown, HTTPS, поддержка cookie, static files serving и хорошая инфраструктура тестирования доступны сразу. Автор actix-web (и actix) создаёт колоссальный объем кода — проект появился около шести месяцев назад, и он не только уже более функциональный, с лучшими API-интерфейсами, чем веб-фреймворки на других языках с открытым исходным кодом, но более того, функциональней фреймворков, которые финансируются крупными организациями с огромными командами разработчиков. Документация по-прежнему немного неполная, но я еще не столкнулся ни с одной ошибкой.

Diesel и проверка во время компиляции

ORM написан человеком с большим опытом работы, который провел много времени на передовой, работая с Active Record. Я использовал diesel как ORM, чтобы поговорить с Postgres. Он предоставляет мощные функции Postgres, такие как upsert и jsonb прямо в основной библиотеке и обеспечивает, по возможности, мощные механизмы безопасности. Многие из ошибок, присущие более ранним поколениям ORM, были устранены, — например, diesel не делает вид, что диалекты SQL в каждой базе данных одинаковы, не использует специализированный DSL для миграции (вместо этого используется обычный SQL) и он не управляет соединениями с базой на глобальном уровне.

Если я неправильно использую поле, пробую вставить кортеж в неправильную таблицу или даже создать невозможное соединение, компилятор тут же выдаст сообщение об ошибке. Большинство запросов к базе данных написаны с использованием diesel’ных типов DSL. ON CONFLICT… или «upsert»): Вот типичная операция (в этом случае Postgres INSERT INTO ...

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::<Text, _>(DIRECTORY_SEARCH_DELETE_HORIZON) .bind::<BigInt, _>(DELETE_LIMIT) .get_result::<DeleteResults>(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 и отличную подсветку синтаксиса в вашем любимом редакторе.

Быстрая (но не самая быстрая) модель параллелизма

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

Например, обработчик, синхронно возвращающий данные: Обработчики HTTP запросов могут быть написаны различными способами.

fn index(req: HttpRequest) -> Bytes { ...
}

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

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

fn index(req: HttpRequest) -> Box<Future<Item=HttpResponse, Error=Error>> { ...
}

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

Пример модели параллелизма с actix-web.
Пример модели параллелизма с actix-web.

Синхронные акторы

Примечательно, что diesel не поддерживает асинхронные операции, поэтому все его операции будут блокироваться. Поддержка futures в Rust широко распространена, но не универсальна.

При использовании diesel, непосредственно из обработчика actix-web, заблокирует реактор tokio и прекратит обработку запросов до завершения блокирующей операции.

Акторы выполняют синхронную обработку сообщений во время работы и поэтому каждому присваивается собственный выделенный поток ОС. К счастью, у actix есть отличное решение этой проблемы в виде синхронных акторов. Ниже как addr): SyncArbiter позволяет легко запускать нескольких копий актора одного типа, каждый из которых работает с общей очередью сообщений, что делает возможным работу со всеми акторами одновременно (см.

// 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 ответ с результатами и отправляет его обратно ожидающему клиенту.

Управление подключением

Однако эти ограничения также могут быть преимуществом. На первый взгляд, введение синхронных акторов в систему может показаться недостатком, поскольку они ограничивают параллелизм системы. Даже самые большие базы на Heroku или GCP (Cloud Cloud Platform) дают максимум 500 подключений, а в меньших базах ограничения и того ниже (моя небольшая база на GCP имеет ограничения в 25 соединений). Одна из первых проблем масштабирования, с которой вы, вероятно, столкнетесь в Postgres, — это ограничения на максимальное количество одновременных подключений. Большие системы, использующие функции работы с соединениями фреймворка (например, Rails и многие другие) используют такие решения как PgBouncer, чтобы решить эту проблему.

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

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

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

Когда служба находится в режиме ожидания, запуска или отключения она не использует соединения. Я написал своих синхронных акторов, чтобы использовать отдельные соединения из пула соединений (r2d2) только когда работа начинается и освобождать их после завершения. Этот подход требует ~2x соединений для изящных перезапусков, потому что все рабочие процессы устанавливают соединение и удерживают его даже в процессе завершения. Сравните это со многими веб-фреймворками, где система открывает соединение с базой данных как только рабочий процесс запустился, и держит его открытым, пока рабочий поток не остановится.

Эргономичное преимущество синхронного кода

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

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

Медленно, но только относительно «очень, очень быстрого»

е. Может это звучит немного пренебрежительно для характеристик исполнения этой модели но имейте в виду, что она медленная только по сравнению с чисто асинхронным стеком (т. Это по-прежнему концептуально корректная модель с реальным параллелизмом и по сравнению с любыми другими фреймворками и языками программирования она очень, очень быстрая. futures). Я работаю на Ruby на своей основной работе и по сравнению с без-потоковой моделью (обычной для Ruby, потому как GIL ограничивает производительность потоков), эта модель на порядок лучше и эффективней в плане использования памяти.

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

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

Futures используют свою версию Result содержащую либо успешный результат, либо ошибку.
Я использую error_chain для определения своих ошибок. Как и любая хорошая программа Rust, API почти повсеместно возвращают тип Result. Большинство из них являются внутренними, но я определил определенную группу с прямой целью:

error_chain!", message), } }
}

Когда ошибка должен быть передана пользователю, я обязательно сопоставляю его с одним из моих типов ошибок:

Params::build(log, &request).map_err(|e| ErrorKind::BadRequest(e.to_string()).into()
)

Реализация оказалась довольно элегантной (обратите внимание, что в композиции Future::then отличается от and_then тем, что она обрабатывает и успех и провала, получая Result, в отличие от and_then который обрабатывает только успешное завершение): После ожидания ответа от синхронного актора или после попытки создания успешного HTTP ответа я обрабатываю ошибки и отправляю ответ пользователю.

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<HttpResponse>| server::transform_user_error(res, render_user_error) ) .responder()

Ошибки, не предназначенные для пользователя, логируются, а actix-web возвращает их как 500 Internal server error (хотя я, вероятно, в какой-то момент добавлю в нее собственный визуализатор).

Функция render абстрагирует обработку ошибок, поэтому мы можем повторно использовать эту функцию в разных API, которая отображает ответы JSON, и веб-сервер, который отображает HTML. Вот transform_user_error.

pub fn transform_user_error<F>(res: Result<HttpResponse>, render: F) -> Result<HttpResponse>
where F: FnOnce(StatusCode, String) -> Result<HttpResponse>,
{ 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

Вот простой пример, который инициализирует logger для каждого запроса и устанавливает его в расширение запроса (совокупность состояний запроса, которая будет работать до тех пор, пока выполняется запрос): Как веб-фреймворки на многих языках, actix-web поддерживает middleware.

pub mod log_initializer { pub struct Middleware; pub struct Extension(pub Logger); impl<S: server::State> actix_web::middleware::Middleware<S> for Middleware { fn start(&self, req: &mut HttpRequest<S>) -> actix_web::Result<Started> { let log = req.state().log().clone(); req.extensions().insert(Extension(log)); Ok(Started::Done) } fn response( &self, _req: &mut HttpRequest<S>, resp: HttpResponse, ) -> actix_web::Result<Response> { Ok(Response::Done(resp)) } } /// Shorthand for getting a usable `Logger` out of a request. pub fn log<S: server::State>(req: &mut HttpRequest<S>) -> Logger { req.extensions().get::<Extension>().unwrap().0.clone() }
}

Это не только помогает проверять тип во время компиляции таким образом, что вы не сможете ошибочно вести ключ, но также дает middleware возможность контролировать свою модульность. Особенностью является то, что middleware привязывается к типу вместо строки (как, к примеру, Rack в Ruby). Любые другие модули не смогли бы получать доступ к этим данным из-за проверки видимости компилятором. Если бы мы хотели скрыть middleware, мы могли бы удалить pub из Extension, чтобы он стал закрытым.

Асинхронность до самого конца

Это позволит, например, реализовать middleware, ограничивающий скорость передачи, который использовал бы Redis таким образом, чтобы не блокировать другие обработчики. Подобно обработчикам запросов, middleware может быть асинхронным, возвращая future вместо Result. Я по-моему уже упоминал, что actix-web довольно быстрый?

HTTP-тестирование

Я остановился на серии модульных тестов, которые используют TestServerBuilder чтобы создать маленькое приложение, содержащее единственный обработчик, и затем выполнить запрос против него. Документация actix-web описывает несколько рекомендаций для методологий тестирования вашего кода. Это хороший компромисс, потому как, несмотря на минимальные тесты, они используют полный стек 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);
}

Если вы посмотрите внимательно, вы заметите, что встроенный JSON не является строкой — json! Что позволяет мне записыват фактическую JSON-нотацию прямо в мой код, который будет проверен и преобразован в действительную структуру Rust компилятором. Я активно использую serde_json (стандартную библиотеку кодирования и декодирования Rust) json! макрос, используется в последней строке коде выше. Это самый элегантный подход к тестированию ответов HTTP JSON, которые я когда-либо видел в других языках программирования.

Резюме: Является ли Rust будущим надежных систем?

Часть этого времени ушла на обучение, часть на укрощение строптивого компилятора, которое иногда превращается в долгий и разочаровывающий процесс. Было бы справедливо сказать, что я мог бы написать такой же сервис на Ruby в 10 раз быстрее, чем на Rust. Сравните это с интерпретируемыми языками, когда вам может быть удасться запустить программу с 15 попытки, но даже тогда краевые условия поти сто процентов будут неверными. Тем не менее, снова и снова сталкиваясь с этим последним препятствием, я запускал свою программу, испытывая эйфорию от того, что она работает именно так, как я хочу. Любой, кто видел большую программу на интерпретируемом языке в production, знает, что вносить изменения можно только небольшими частями, в противном случае вы сильно рискуете. Rust также позволяет вносить большие изменения — для меня нередко реорганизовать тысячу строк за раз, а потом еще раз и даже после этого программа отлично работает. Я пока не знаю, однако вам однозначно стоит обратить на него внимание. Стоит ли вам написать свой следующий веб-сервис на Rust?


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Android Things перефокусируется на умные колонки и дисплеи

Тогда заявлялось, что эта платформа поддерживает разработку под Android, создатели собирались постепенно добавлять новые устройства, включая ntel Joule 570x, NXP Argon i. В конце 2016 года корпорация Google представила обновленную платформу для интернета вещей, которая получила название Android Things. MX6UL ...

Важные изменения в работе CTE в PostgreSQL 12

WITH w AS NOT MATERIALIZED ( SELECT * FROM very_very_big_table ) SELECT * FROM w AS w1 JOIN w AS w2 ON w1.key = w2.ref WHERE w2.key = 123; Сегодня в репозиторий PostgreSQL упал комит, позволяющий управлять поведением обработки подзапросов ...