RustでErlangVM上で動作するWebアプリケーションを開発する

November 02, 2020

こんばんは、さんぽしです

僕は Elixir 好き好き君なのですが、先日以下のような記事を見かけました。

どちらも「Discord は Elixir を使ってるよ〜」という内容の記事なのですが、詳しく読んでいくと 「Rustlerを用いて ErlangVM の NIFs を応用することで一部の処理を Rust で書いている」 とのことでした。

そもそも NIFs って何という詳しい話は以下の公式のページに説明を譲ります 8. NIFs - Erlang

Rustler は Erlang NIFs(Native Implemented Functions)を利用して Elixir(Erlang)の中で Rust の関数をフックできるようにしたライブラリです。

rusterlium/rustler - GitHub

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

  1. NIF で早くなるかは保証できない
  2. 少なくとも dangerous にはなる
  3. 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 分からんの人間です。

今回のソースコードは全て以下のリポジトリに置いてあります。

sanposhiho/rust-to-elixir-phoenix-sample - GitHub

$ mix phx.new rust_phx_sample --no-ecto

このコマンドで Phoenix のプロジェクトが作成されます(rust_phx_sampleはプロジェクト名です) 途中で何か聞かれたらとりあえず Yes にしておきましょう

作成されたプロジェクトのディレクトリに入って以下のコマンドでサーバーを立ち上げます

$ mix phx.server

localhost:4000にアクセスして以下のような画面が表示されればうまいことプロジェクトを作成できています。

PhoenixのTOP

Phoenix は Rails like の MVC なフレームワークです。

とりあえず sample として簡単な json を返す path を作成します

以下のようにsample_controller.exsample_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 が作成できていることがわかります

json返している

ここまでで一旦雑な API サーバーを立てることができました

rustler の導入

mix.exsrustlerの依存を追加します

  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にアクセスすることで以下のように先ほどと同じ結果が帰ってきていることがわかります json返している

これによって Elixir の add の関数を NIF に置き換えて Rust の add を代わりに実行することができました。

もう少し本格的な API サーバーを実装してみる

ここまでで Rustler の雰囲気は掴んでいただけたのではないでしょうか。

「いや足し算の関数置き換えただけで Rust で Web アプリケーションって言えるんか」

そう言われると思ったので、そこでここから API サーバーが行うことの多いであろう、DB へのアクセスを絡めた処理を実際に Rust で実装してみます

先ほどと同様の手順でuser_controller.exuser_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 が最新リリースです。メジャーバージョンが待ち遠しいです。 読んでいただきありがとうございました。

このエントリーをはてなブックマークに追加