こんばんは、さんぽしです
僕は Elixir 好き好き君なのですが、先日以下のような記事を見かけました。
- Using Rust to Scale Elixir for 11 Million Concurrent Users
- Real time communication at scale with Elixir at Discord
どちらも「Discord は Elixir を使ってるよ〜」という内容の記事なのですが、詳しく読んでいくと 「Rustlerを用いて ErlangVM の NIFs を応用することで一部の処理を Rust で書いている」 とのことでした。
そもそも NIFs って何という詳しい話は以下の公式のページに説明を譲ります 8. NIFs - Erlang
Rustler は Erlang NIFs(Native Implemented Functions)を利用して Elixir(Erlang)の中で Rust の関数をフックできるようにしたライブラリです。
Erlang/Elixir の中で Rust を呼べると何が嬉しいか
Rust 開発者側から見ると ErlangVM の恩恵を受けることができる点が一番大きなメリットです。(それはそうという感じですね)
Elixir は、低レイテンシで分散型のフォールトトレラントシステムや、Web や組み込みシステムの領域で成功を収めている、Erlang VM を利用します。 https://elixir-lang.jp/
ものすごく堅牢と言われる ErlangVM のこれらの強みを Rust でまるっといただくことができます。
現状、ある程度の環境が整っていて ErlangVM を扱うことができる言語は Erlang, Elixir がありますが、Web アプリケーションの開発となると Elixir 一択と言ってもいいでしょう。
そんな中で ErlangVM 上のアプリケーションの開発の選択肢として Rust も入ってくるのはコミュニティにとってもかなり良いことだと感じます(Rust を beam にコンパイルできるという話ではないので Elixir, Erlang とは完全に別の種別になります。今後 Rustler 等が発展し開発者から見れば Rust のみで開発を行い ErlangVM を扱えるという環境になる未来はあるのかもですが、Rust 単体で ErlangVM を扱えるという話ではないです)
そして Elixir 側から見た NIFs を利用して Rust を使用するメリットとしては(これは NIFs 自体のメリットとも言えますが)、実行速度の向上が言われています。
しかし、Erlang の”The Seven Myths of Erlang Performance”として、以下が挙げられています 2.7 Myth: A NIF Always Speeds Up Your Program
- NIF で早くなるかは保証できない
- 少なくとも dangerous にはなる
- NIF の関数を呼ぶこと自体や、戻り値や引数をチェックすることの小さなオーバーヘッドがあるから細々とした関数をチマチマ呼ぶとむしろ遅くなったりするかも
なるほど、という感じですね。
1.に関しては(結局は 3 と合わせて NIFs の扱い方なんだろうとは思いますが)前述の Discord の記事も然り、実行速度は早くなるという見方が大勢な気がします。
2 に関しては以下の記事が分かりやすかったです
Writing Rust NIFs for your Elixir code with the Rustler package
(引用と和訳)
the fastest way being with a Native Implemented Function (NIF) whose API expects them to be written in C. But speaking frankly, the last time I worked with C involved a lengthy debugging session that boiled down to the lack of type safety, so I’d rather not have to repeat that experience. It’s for this reason that Rust is such a compelling language. It has a robust type system with type inference, pattern matching, and many more features. That and it has a C compatible ABI.
最も速い方法はネイティブ実装関数(NIF)で、その API は C 言語で書かれていることを期待しています。しかし率直に言って、前回 C 言語を使って作業したときは、型の安全性がないことが原因で長時間のデバッグセッションが必要でした。だからこそ、Rust は魅力的な言語なのです。型推論、パターンマッチング、その他多くの機能を備えた堅牢な型システムを持っています。また、C 互換の ABI を持っています。
This is where the Rustler project comes in. In its own words it provides a safe bridge for writing Erlang NIFs. One of its safety guarantees is catching panics before they reach the C code. One of the nice things about Rust is that if the code compiles, you can be reasonably sure you won’t run into a wide range of memory safety related bugs, among others.
ここで Rustler プロジェクトの出番です。Rustler の言葉を借りれば、Erlang の NIF を書くための安全なブリッジを提供します。その安全性の保証の一つは、パニックが C 言語のコードに到達する前にキャッチすることです。Rust の良いところの一つは、コードがコンパイルされた場合、特にメモリ安全性に関連した広範囲のバグに遭遇しないことを合理的に保証できることです。
(引用と和訳ここまで)
NIFs の「クラッシュした際に Erlang VM に対する影響がやばい」という諸刃の剣の諸刃の部分(?)を Rust のメモリ安全性を利用して、安全面を担保することができます。
実際に Rustler を使ってみる
長い前置きはここまでにして、Rustler を実際に用いてみて開発の流れを確認してみましょう。今回は簡単な API サーバーを作成してみます。
Elixir のデファクトな Web フレームワークである Phoenix を利用します。
Elixir や Phoenix、Rust 自体のインストール方法は公式に説明を譲り、省略します。
elixir - Install Phoenix - Installation Rust - Install Rust
この記事の対象読者は全人類です。Elixir/Phoenix に精通していない人、Rust 分からんって人にもわかるように割と一歩一歩解説していきます。 また、筆者は Rust 歴 0 日なのでそもそも Rust 分からんの人間です。
今回のソースコードは全て以下のリポジトリに置いてあります。
$ mix phx.new rust_phx_sample --no-ecto
このコマンドで Phoenix のプロジェクトが作成されます(rust_phx_sample
はプロジェクト名です)
途中で何か聞かれたらとりあえず Yes にしておきましょう
作成されたプロジェクトのディレクトリに入って以下のコマンドでサーバーを立ち上げます
$ mix phx.server
localhost:4000
にアクセスして以下のような画面が表示されればうまいことプロジェクトを作成できています。
Phoenix は Rails like の MVC なフレームワークです。
とりあえず sample として簡単な json を返す path を作成します
以下のようにsample_controller.ex
とsample_view.ex
を作成します
defmodule RustPhxSampleWeb.SampleController do
use RustPhxSampleWeb, :controller
def sample(conn, _params) do
num = add(1, 2)
render(conn, "sample.json", number: num)
end
def add(num1, num2) do
num1 + num2
end
end
defmodule RustPhxSampleWeb.SampleView do
use RustPhxSampleWeb, :view
def render("sample.json", %{number: num}) do
%{number: num}
end
end
router.ex
を編集して routing を追加します
scope "/", RustPhxSampleWeb do
pipe_through :browser
get "/", PageController, :index
get "/sample", SampleController, :sample
end
これによって/sample
にアクセスすると以下のように雑な APi が作成できていることがわかります
ここまでで一旦雑な API サーバーを立てることができました
rustler の導入
mix.exs
にrustler
の依存を追加します
defp deps do
[
{:phoenix, "~> 1.5.6"},
{:phoenix_html, "~> 2.11"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_dashboard, "~> 0.3 or ~> 0.2.9"},
{:telemetry_metrics, "~> 0.4"},
{:telemetry_poller, "~> 0.4"},
{:gettext, "~> 0.11"},
{:jason, "~> 1.0"},
{:plug_cowboy, "~> 2.0"},
{:rustler, "~> 0.21.0"} #add
]
end
$ mix deps.get
これでrustler
を導入できました
mix rustler.new
で新しい NIFs 用のプロジェクトを作成します
$ mix rustler.new
This is the name of the Elixir module the NIF module will be registered to.
Module name > RustPhxSampleWeb.SampleController
This is the name used for the generated Rust crate. The default is most likely fine.
Library name (rustphxsampleweb_samplecontroller) >
* creating native/rustphxsampleweb_samplecontroller/.cargo/config
* creating native/rustphxsampleweb_samplecontroller/README.md
* creating native/rustphxsampleweb_samplecontroller/Cargo.toml
* creating native/rustphxsampleweb_samplecontroller/src/lib.rs
Ready to go! See /Users/kenseinakada/workspace/rust_phx_sample/native/rustphxsampleweb_samplecontroller/README.md for further instructions.
作成された Rust のテンプレートファイルを覗いてみると以下のようになっています
use rustler::{Encoder, Env, Error, Term};
mod atoms {
rustler_atoms! {
atom ok;
//atom error;
//atom __true__ = "true";
//atom __false__ = "false";
}
}
rustler::rustler_export_nifs! {
"Elixir.RustPhxSampleWeb.SampleController",
[
("add", 2, add)
],
None
}
fn add<'a>(env: Env<'a>, args: &[Term<'a>]) -> Result<Term<'a>, Error> {
let num1: i64 = args[0].decode()?;
let num2: i64 = args[1].decode()?;
Ok((atoms::ok(), num1 + num2).encode(env))
}
Elixir 側の add と実装が合うように以下のように修正します
use rustler::{Encoder, Env, Error, Term};
rustler::rustler_export_nifs! {
"Elixir.RustPhxSampleWeb.SampleController",
[
("add", 2, add)
],
None
}
fn add<'a>(env: Env<'a>, args: &[Term<'a>]) -> Result<Term<'a>, Error> {
let num1: i64 = args[0].decode()?;
let num2: i64 = args[1].decode()?;
Ok((num1 + num2).encode(env))
}
Elixir 側の add で Rust の add を呼び出すように以下のように変更を加えます
defmodule RustPhxSampleWeb.SampleController do
use RustPhxSampleWeb, :controller
use Rustler, otp_app: :rust_phx_sample, crate: :rustphxsampleweb_samplecontroller
def sample(conn, _params) do
num = add(1, 2)
render(conn, "sample.json", number: num)
end
def add(_a, _b), do: exit(:nif_not_loaded)
end
また、mix.exs
を再び編集し、Elixir のコンパイル時に Rust のコードも一緒にコンパイルされるように設定します
def project do
[
app: :rust_phx_sample,
version: "0.1.0",
elixir: "~> 1.7",
elixirc_paths: elixirc_paths(Mix.env()),
compilers: [:phoenix, :gettext, :rustler] ++ Mix.compilers(), #rustlerの追加
rustler_crates: rustler_crates(), #追加
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps()
]
end
defp rustler_crates() do
[rustphxsampleweb_samplecontroller: [
path: "native/rustphxsampleweb_samplecontroller",
mode: rustc_mode(Mix.env)
]]
end
defp rustc_mode(:prod), do: :release
defp rustc_mode(_), do: :debug
これによって Phoenix のサーバーを立ち上げ直すことで Rust のコンパイルも実行されます
$ mix phx.server
同様に/sample
にアクセスすることで以下のように先ほどと同じ結果が帰ってきていることがわかります
これによって Elixir の add の関数を NIF に置き換えて Rust の add を代わりに実行することができました。
もう少し本格的な API サーバーを実装してみる
ここまでで Rustler の雰囲気は掴んでいただけたのではないでしょうか。
「いや足し算の関数置き換えただけで Rust で Web アプリケーションって言えるんか」
そう言われると思ったので、そこでここから API サーバーが行うことの多いであろう、DB へのアクセスを絡めた処理を実際に Rust で実装してみます
先ほどと同様の手順でuser_controller.ex
、user_view.ex
を作成し、router.ex
に route を追加します
defmodule RustPhxSampleWeb.UserController do
use RustPhxSampleWeb, :controller
use Rustler, otp_app: :rust_phx_sample, crate: :rustphxsampleweb_usercontroller
def create(conn, %{"user" => %{"name" => name, "age" => age}}) do
{:ok, {id, name, age}} = create_user(name, age)
user = %{
id: id,
name: name,
age: age,
}
render(conn, "user.json", user: user)
end
def create_user(_name, _age), do: exit(:nif_not_loaded)
end
defmodule RustPhxSampleWeb.UserView do
use RustPhxSampleWeb, :view
def render("user.json", %{user: user}) do
%{user: user}
end
end
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_flash
# plug :protect_from_forgery # セキュリティ上ものすごく良くないがCSRFToken Errorを回避するのがめんどくさいので今回は無効化
plug :put_secure_browser_headers
end
#..(中略)
post "/user", UserController, :create
同様の手順で新しい NIFs 用のプロジェクトを作成します
$ mix rustler.new
This is the name of the Elixir module the NIF module will be registered to.
Module name > RustPhxSampleWeb.UserController
This is the name used for the generated Rust crate. The default is most likely fine.
Library name (rustphxsampleweb_usercontroller) >
* creating native/rustphxsampleweb_usercontroller/.cargo/config
* creating native/rustphxsampleweb_usercontroller/README.md
* creating native/rustphxsampleweb_usercontroller/Cargo.toml
* creating native/rustphxsampleweb_usercontroller/src/lib.rs
Ready to go! See /Users/kenseinakada/workspace/rust_phx_sample/native/rustphxsampleweb_usercontroller/README.md for further instructions.
mix.exs
にも作成し NIFs 用のプロジェクトを登録します
defp rustler_crates() do
[rustphxsampleweb_samplecontroller: [
path: "native/rustphxsampleweb_samplecontroller",
mode: rustc_mode(Mix.env)
],
rustphxsampleweb_usercontroller: [ # add
path: "native/rustphxsampleweb_usercontroller",
mode: rustc_mode(Mix.env)
]
]
end
そして作成されたnative/rustphxsampleweb_usercontroller/src/lib.rs
に DB に User を格納する処理を書いていきます。
DB クライアントライブラリにはDiesel
を使用しました。
Rust の実際のコードは長いので載せませんが、以下に置いてあります。
sanposhiho/rust-to-elixir-phoenix-sample:native/rustphxsampleweb_usercontroller/src
実際にmix phx.server
をしてサーバーを立てて、curl で API を叩いてみます。
$ curl -X POST -H "Content-Type: application/json" -d '{"user":{"name":"taro", "age":14}}' localhost:4000/user
{"user":{"age":1,"id":1,"name":"taro"}}
$ curl -X POST -H "Content-Type: application/json" -d '{"user":{"name":"miho", "age":12}}' localhost:4000/user
{"user":{"age":1,"id":2,"name":"miho"}}
しっかりレスポンスが返ってきました。
今回は Read の API を立てていないので直接 DB を覗きに行くと
diesel_demo=> select * from users;
id | name | age
----+------+-----
1 | taro | 14
2 | miho | 12
(2 rows)
ちゃんと Write されていることがわかります
Rustler を使ってみて
Rustler は Rust 側の関数と Elixir 側の関数を紐付けるだけでかなり Rust 側に処理を任せることができることがわかりました。 Phoenix にルーティングとレスポンスの構築のみを任せて内部の処理を全て Rust に置き換えるようなことも実現が可能そうな感じがします。まあ、冒頭でもお話ししたように NIFs は適材適所な面も大きいため、全てを Rust に置き換えれればハッピーということもないのかもしれません。
やはり Elixir/Phoenix 側のコードを少なからずいじる必要もあるので、Rust だけを知っている人が Elixir/Phoenix を利用して ErlangVM 上で Rust を動作させるということは現状少しハードルがある感じがします。 この辺をうまく吸収し、Rust だけで ErlangVM 上で動作するアプリケーションを作成できるようになったら面白いかもしれませんね。
Rustler は現在 v0.21.0 が最新リリースです。メジャーバージョンが待ち遠しいです。 読んでいただきありがとうございました。