TL: DR - используйте Rust вместо C ++ для написания собственных модулей Node.js!

В прошлом году RisingStack столкнулся с шокирующим событием: мы достигли максимальной скорости, которую мог предложить Node.js в то время, в то время как наши серверные расходы взлетели до небес. Чтобы повысить производительность нашего приложения (и снизить наши затраты), мы решили полностью его переписать и перенести нашу систему в другую инфраструктуру, что, разумеется, потребовало больших усилий.

Позже я понял, что вместо этого мы могли бы просто реализовать собственный модуль!

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

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

Проблема со скоростью нашего сервера Node.js

Наша проблема возникла в конце 2016 года, когда мы работали над Trace, нашим продуктом для мониторинга Node.js, который недавно был объединен с Keymetrics в октябре 2017 года.

Как и любой другой технологический стартап того времени, мы запускали наши сервисы на Heroku, чтобы сэкономить некоторые расходы на инфраструктуру и обслуживание. Мы создавали приложение с микросервисной архитектурой, а это означало, что наши сервисы много общались по протоколу HTTP (S).

Здесь возникает сложность: мы хотели безопасно обмениваться данными между службами, но Heroku не предлагала частные сети, поэтому нам пришлось реализовать собственное решение. Поэтому мы рассмотрели несколько решений для аутентификации, и в итоге остановились на http-подписях.

Чтобы объяснить это вкратце; Подписи http основаны на криптографии с открытым ключом. Чтобы создать подпись http, вы берете все части запроса: URL-адрес, тело и заголовки и подписываете их своим закрытым ключом. Затем вы можете передать свой открытый ключ тем, кто будет получать ваши подписанные запросы, чтобы они могли их проверить.

Прошло время, и мы заметили, что загрузка ЦП в большинстве наших процессов http-сервера зашкаливает. Мы заподозрили очевидную причину - если вы занимаетесь криптовалютой, это происходит постоянно.

Однако после серьезного профилирования с помощью v8-profiler мы выяснили, что на самом деле это не криптовалюта! Больше всего процессорного времени отнимал разбор URL. Почему? Потому что для аутентификации нам нужно было проанализировать URL-адрес для проверки подписи запроса.

Чтобы решить эту проблему, мы решили оставить Heroku (что мы хотели сделать и по другим причинам) и создать инфраструктуру Google Cloud с Kubernetes и внутренней сетью вместо оптимизации нашего парсинга URL.

Причина написания этого рассказа / руководства заключается в том, что всего несколько недель назад я понял, что мы могли бы оптимизировать парсинг URL-адресов другим способом - написав собственную библиотеку на Rust.

Наивный разработчик становится нативным - необходимость модуля Rust

Написание нативного кода должно быть не так уж и сложно, верно?

Здесь, в RisingStack, мы всегда говорили, что хотим использовать правильный инструмент для работы. Для этого мы всегда проводим исследования для создания более совершенного программного обеспечения, в том числе, при необходимости, на нативных модулях C ++.

Бесстыдный плагин: я также написал в блоге сообщение о моем путешествии по нативным модулям Node.js. Взгляните!

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

Вернемся к нашей первоначальной проблеме: разбор URL-адреса не должен быть таким сложным, верно? Он содержит протокол, хост, параметры запроса…

(Источник Документация по Node.js)

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

Я подумал, что уж точно я не единственный человек, который хочет анализировать URL-адреса. Браузеры, вероятно, уже решили эту проблему, поэтому я проверил решение хрома: google-url. Хотя эту реализацию можно легко вызвать из Node.js с помощью N-API, у меня есть несколько причин не делать этого:

  • Обновления: когда я просто копирую код из Интернета, я сразу чувствую опасность. Люди делали это в течение долгого времени, и есть так много причин, по которым это не сработало так хорошо ... Просто нет простого способа обновить огромный блок кода, который находится в моем репозитории.
  • Безопасность: человек с небольшим опытом работы с C ++ не может проверить правильность кода, но в конечном итоге нам придется запустить его на наших серверах. У C ++ крутая кривая обучения, и на то, чтобы овладеть им, требуется много времени.
  • Безопасность: мы все слышали о доступном для использования коде C ++, которого я бы предпочел избегать, потому что у меня нет возможности проверить его самостоятельно. Использование хорошо обслуживаемых модулей с открытым исходным кодом дает мне достаточно уверенности, чтобы не беспокоиться о безопасности.

Поэтому я бы предпочел более доступный язык с простым в использовании механизмом обновления и современными инструментами: Rust!

Несколько слов о Rust

Rust позволяет нам писать быстрый и эффективный код.

Все проекты на Rust управляются с помощью cargo - воспринимайте это как npm для Rust. Зависимости проекта можно установить с помощью cargo, и есть реестр, полный пакетов, ожидающих вашего использования.

Я нашел библиотеку, которую мы можем использовать в этом примере - rust-url, так что благодарим команду Servo за их работу.

Мы также собираемся использовать Rust FFI! Мы уже обсуждали использование Rust FFI с Node.js в предыдущем блоге два года назад. С тех пор в экосистеме Rust многое изменилось.

У нас есть предположительно рабочая библиотека (rust-url), так что давайте попробуем создать ее!

Как мне создать приложение на Rust?

Следуя инструкциям на https://rustup.rs, мы можем получить работающий rustc компилятор, но все, о чем мы должны сейчас заботиться, это cargo. Я не хочу вдаваться в подробности того, как это работает, поэтому, если вам интересно, ознакомьтесь с нашим предыдущим блогом о Rust.

Создание нового проекта Rust

Создать новый проект на Rust так же просто, как cargo new --lib <projectname>.

Вы можете проверить весь код в моем репозитории примеров https://github.com/peteyy/rust-url-parse

Чтобы использовать имеющуюся у нас библиотеку Rust, мы можем просто указать ее как зависимость в нашем Cargo.toml

[package]
name = "ffi"
version = "1.0.0"
authors = ["Peter Czibik <[email protected]>"]

[dependencies]
url = "1.6"

Нет короткой (встроенной) формы для добавления зависимости, как в случае с npm install - вы должны добавить ее вручную. Однако есть ящик под названием cargo edit, который добавляет аналогичную функциональность.

Ржавчина FFI

Чтобы иметь возможность использовать модули Rust из Node.js, мы можем использовать FFI, предоставляемый Rust. FFI - это краткое название интерфейса внешней функции. Интерфейс внешних функций (FFI) - это механизм, с помощью которого программа, написанная на одном языке программирования, может вызывать подпрограммы или использовать службы, написанные на другом.

Чтобы иметь возможность ссылаться на нашу библиотеку, мы должны добавить две вещи в Cargo.toml

[lib]
crate-type = ["dylib"]
[dependencies]
libc = "0.2"
url = "1.6"

Мы должны объявить нашу библиотеку динамической библиотекой. Файл с расширением .dylib является динамической библиотекой: это библиотека, загружаемая во время выполнения, а не во время компиляции.

Нам также придется связать нашу программу с libc. libc - это стандартная библиотека для языка программирования C, как указано в стандарте ANSI C.

libc crate - это библиотека Rust с собственными привязками к типам и функциям, которые обычно встречаются в различных системах, включая libc. Это позволяет нам использовать типы C из нашего кода Rust, что нам нужно будет сделать, если мы хотим принять или вернуть что-либо из наших функций Rust. :)

Наш код довольно прост - я использую ящик url и libc с ключевым словом extern crate. Чтобы представить это внешнему миру через FFI, важно отметить нашу функцию как pub extern. Наша функция принимает указатель c_char, который представляет типы String, поступающие из Node.js.

Нам нужно отметить нашу конверсию как unsafe. Блок кода с префиксом unsafe используется для разрешения вызова небезопасных функций или разыменования необработанных указателей внутри безопасной функции.

Rust использует тип Option<T> для представления значения, которое может быть пустым. Думайте об этом как о значении, которое может быть null или undefined в вашем JavaScript. Вы можете (и должны) явно проверять каждый раз, когда пытаетесь получить доступ к значению, которое может быть нулевым. В Rust есть несколько способов решить эту проблему, но на этот раз я использую самый простой метод: unwrap, который просто выдаст ошибку (панику в терминах Rust), если значение отсутствует.

Когда разбор URL-адреса завершен, мы должны преобразовать его в CString, который можно передать обратно в JavaScript.

extern crate libc;
extern crate url;
use std::ffi::{CStr,CString};
use url::{Url};
#[no_mangle]
pub extern "C" fn get_query (arg1: *const libc::c_char) -> *const libc::c_char {
    let s1 = unsafe { CStr::from_ptr(arg1) };
    let str1 = s1.to_str().unwrap();
    let parsed_url = Url::parse(
        str1
    ).unwrap();
    CString::new(parsed_url.query().unwrap().as_bytes()).unwrap().into_raw()
}

Чтобы собрать этот код на Rust, вы можете использовать команду cargo build --release. Перед компиляцией убедитесь, что вы добавили библиотеку url в список зависимостей в Cargo.toml и для этого проекта!

Мы можем использовать пакет ffi Node.js для создания модуля, представляющего код Rust.

const path = require('path');
const ffi = require('ffi');
const library_name = path.resolve(__dirname, './target/release/libffi');
const api = ffi.Library(library_name, {
  get_query: ['string', ['string']]
});
module.exports = {
  getQuery: api.get_query
};

Соглашение об именах lib*, где * - имя вашей библиотеки, для .dylib файла, который строит cargo build --release.

Отлично; у нас есть рабочий код Rust, который мы вызвали из Node.js! Это работает, но вы уже можете видеть, что нам пришлось выполнить кучу преобразований между типами, что может добавить немного накладных расходов к нашим вызовам функций. Должен быть гораздо лучший способ интегрировать наш код с JavaScript.

Знакомьтесь, Neon

Привязки Rust для написания безопасных и быстрых нативных модулей Node.js.

Neon позволяет нам использовать типы JavaScript в нашем коде Rust. Чтобы создать новый проект Neon, мы можем использовать их собственный cli. Используйте npm install neon-cli --global, чтобы установить его.

neon new <projectname> создаст новый неоновый проект с нулевой конфигурацией.

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

#[macro_use]
extern crate neon;
extern crate url;
use url::{Url};
use neon::vm::{Call, JsResult};
use neon::js::{JsString, JsObject};
fn get_query(call: Call) -> JsResult<JsString> {
    let scope = call.scope;
    let url = call.arguments.require(scope, 0)?.check::<JsString>()?.value();
    let parsed_url = Url::parse(
        &url
    ).unwrap();
    Ok(JsString::new(scope, parsed_url.query().unwrap()).unwrap())
}
register_module!(m, {
    m.export("getQuery", get_query)
});

Эти новые типы, которые мы используем наверху JsString, Call и JsResult, являются оболочками для типов JavaScript, которые позволяют нам подключаться к виртуальной машине JavaScript и выполнять код поверх нее. Scope позволяет нам привязать наши новые переменные к существующим областям действия JavaScript, чтобы наши переменные можно было собирать мусором.

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

Обратите внимание на атрибут #[macro_use], который позволяет нам использовать макрос register_module!, который позволяет нам создавать модули так же, как в Node.js module.exports.

Единственная сложная часть здесь - это доступ к аргументам:

let url = call.arguments.require(scope, 0)?.check::<JsString>()?.value();

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

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

Теперь попробуем их запустить!

Если вы сначала загрузили мой пример, вам нужно перейти в папку ffi и выполнить cargo build --release, а затем в папку neon и (с ранее установленным глобально neon-cli) запустить neon build.

Если вы готовы, вы можете использовать Node.js для создания нового списка URL-адресов с помощью библиотеки faker.

Запустите команду node generateUrls.js, которая поместит urls.json файл в вашу папку, который наши тесты прочитают и попытаются проанализировать. Когда все будет готово, вы можете запустить «тесты» с node urlParser.js. Если все прошло успешно, вы должны увидеть что-то вроде этого:

Этот тест был проведен с 100 URL-адресами (сгенерированными случайным образом), и наше приложение проанализировало их только один раз, чтобы дать результат. Если вы хотите сравнить анализ, увеличьте количество (tryCount в urlParser.js) URL-адресов или количество раз (urlLength в urlGenerator.js).

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

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

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

Реализация модулей Rust в Node.js

Надеюсь, вы также узнали что-то сегодня о реализации модулей Rust в Node.js вместе со мной, и теперь вы можете извлечь выгоду из нового инструмента в своей инструментальной цепочке. Я хотел продемонстрировать, что, хотя это возможно (и весело), ​​это не серебряная пуля, которая решит все проблемы с производительностью.

Помните, что знание Rust может пригодиться в определенных ситуациях.

Если вы хотите, чтобы я говорил на эту тему во время встречи в Rust Hungary, посмотрите это видео!

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

Первоначально опубликовано на blog.risingstack.com 22 ноября 2017 г.