I know, Phoenix and Elixir are quite performant and quite fast. But sometimes, it may be necessary to improve a little bit the performances of your app. Rust is the solution in both your back-end and front-end code.
Here I will show you how to do that with very basic examples.
First let's install all required tools for Rust:
# Install rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Install rust-wasm
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
# Install cargo generate
cargo install cargo-generate
I assume you have elixir
(with the mix phx_new
archive) and node
installed.
Add Rust in our back-end code
In Erlang, we have the NIF functions which are callable functions implemented in C/C++ or even Rust directly from our Erlang/Elixir code. Thanks to rustler, making those functions became very easy !
We will start by making a new Phoenix project.
mix phx.new rust_phoenix --live --no-ecto
# if asked, install all deps
cd rust_phoenix
mix phx.server
You shall see the default live page which search in the HexDocs.
We will change that page to make a simple calculator, change the lib/rust_phoenix_web/live/page_live.html.leex
file :
<form phx-change="change" phx-submit="compute">
<input type="number" name="a" value="<%= @a %>" list="results" autocomplete="off"/>
<input type="number" name="b" value="<%= @b %>" list="results" autocomplete="off"/>
<h2>The result is: <%= @result %></h2>
<button type="submit">Compute</button>
</form>
And then let's change the back-end code of our live page in lib/rust_phoenix_web/live/page_live.ex
:
defmodule RustPhoenixWeb.PageLive do
use RustPhoenixWeb, :live_view
@impl true
def mount(_params, _session, socket) do
result = 0
{:ok, assign(socket, a: 0, b: 0, result: result)}
end
@impl true
def handle_event(
"compute",
_,
%{assigns: %{a: a, b: b}} = socket
) do
result = a + b
{:noreply, assign(socket, result: result)}
end
@impl true
def handle_event(
"change",
%{"a" => a, "b" => b},
socket
) do
{a, _} = Integer.parse(a)
{b, _} = Integer.parse(b)
{:noreply, assign(socket, a: a, b: b)}
end
end
Ok, easy. We just have two variables and we will compute the result when clicking on the "Compute" button.
It could be better but we will let it like that. When looking on our browser we see the both input and when typing 1
and 2
and then clicking on the compute button : we got 3
. Awesome !
Let's now add our Rust project to make our computation in native code.
Add the rustler
deps, in our mix.exs
file add:
defp deps do
[
# your deps
{:rustler, "~> 0.21.1"}
]
end
We have to register the rustler
compiler, in our mix.exs
add:
def project do
[
# ...
# I did just add :rustler here
compilers: [:phoenix, :gettext, :rustler] ++ Mix.compilers(),
]
end
And we will generate our awesome NIF project, when asked give the module name, I decided to give RustPhoenix.RustHelloWorld
.
mix rustler.new
And now in native/rustphoenix_rusthelloworld
we got our project. Take a look at native/rustphoenix_rusthelloworld/src/lib.rs
file, here what it looks like:
#[macro_use]
extern crate rustler;
use rustler::{Encoder, Env, Error, Term};
mod atoms {
rustler_atoms! {
atom ok;
}
}
rustler::rustler_export_nifs! {
"Elixir.RustPhoenix.RustHelloWorld",
[
("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))
}
(Add the #[macro_use] extern crate rustler;
at the beginning of the file if it is missing.)
Let's add the glue to use our module in our Elixir project.
First we have to register our crate, in our mix.exs
file add:
def project do
[
# ...
# I did just add :rustler here
rustler_crates: [
rustphoenix_rusthelloworld: [
mode: (if Mix.env() == :prod, do: :release, else: :debug)
]
],
]
end
Then we will create our glue module, create the file lib/rust_phoenix/rust_hello_world.ex
and write :
defmodule RustPhoenix.RustHelloWorld do
use Rustler, otp_app: :rust_phoenix, crate: :rustphoenix_rusthelloworld
def add(_arg1, _arg2), do: :erlang.nif_error(:nif_not_loaded)
end
We did have exported ("add", 2, add)
from our lib.rs
file, so we have to declare the add/2
function in our module. All functions will be override by Rustler when the NIF functions will be correctly loaded.
Then let's change our live page to use our native code, edit lib/rust_phoenix_web/live/page_live.ex
:
defmodule RustPhoenixWeb.PageLive do
use RustPhoenixWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, result} = RustPhoenix.RustHelloWorld.add(0, 0)
{:ok, assign(socket, a: 0, b: 0, result: result)}
end
@impl true
def handle_event(
"compute",
_,
%{assigns: %{a: a, b: b}} = socket
) do
{:ok, result} = RustPhoenix.RustHelloWorld.add(a, b)
{:noreply, assign(socket, result: result)}
end
@impl true
def handle_event(
"change",
%{"a" => a, "b" => b},
socket
) do
{a, _} = Integer.parse(a)
{b, _} = Integer.parse(b)
{:noreply, assign(socket, a: a, b: b)}
end
end
Instead of doing a + b
, we now do RustPhoenix.RustHelloWorld.add(a, b)
.
Now, test, and everything should work !
Add Rust in our front-end code
Let's go to our assets
folder and create a native
folder. Then generate our project and when asked type helloworld
.
cd assets
mkdir native
cargo generate --git https://github.com/rustwasm/wasm-pack-template
In assets/native/helloworld/src/lib.rs
we have our Rust code containing :
mod utils;
use wasm_bindgen::prelude::*;
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
#[wasm_bindgen]
extern {
fn alert(s: &str);
}
#[wasm_bindgen]
pub fn greet() {
alert("Hello, World!");
}
It simply define a greet()
function calling the alert
javascript function.
Now let's compile our project:
wasm-pack build
We should now have a assets/native/helloworld/pkg
directory created with all our glues and generated wasm file.
Now we can add our package in our asset webpack config, so edit assets/package.json
:
"dependencies": {
// ...
"helloworld": "file:./native/helloworld/pkg"
},
And run:
npm install
We now have our Rust WASM code in our assets, ready to be used. The loading of WebAssembly code must be asynchronous and to achieve that, it's simple : let's modify our assets/js/app.js
to add our new code:
import ...
import("helloworld")
.catch(console.error)
.then(module => window.helloworld = module);
Now we can call our code, edit lib/rust_phoenix_web/live/page_live.html.leex
and add:
<button onClick="helloworld.greet()">Say Hello to Rust</button>
We now have to register the application/wasm
MIME type required for our WebAssembly loader since Phoenix don't know it. Let's add it in our config file config/config.exs
:
config :mime, :types, %{"application/wasm" => ["wasm"]}
And to rebuild the MIME deps, run:
mix deps.clean mime --build
Then when clicking on our button, it should pop-out our alert. Awesome !
Conclusion
Here the performances are not quite amaizing, but we can have so much more of course.
Rust is a very good language with high performance and high reliability. Used in both back and client-side code assure a solid foundation. However, we should not abuse of all of that good things, keep using Javascript and Elixir and only use Rust to optimize some heavy part of your code which are too slow.
Improvements
We can easily tell Webpack to watch for changes done in the native projects and recompile them. Take a look here.
I actually don't know how to watch, re-compile and live-reload the Rust NIF functions. Maybe tricking with nodemon
(here) could be a good idea.
Docs and links
- The repository with all the code : github.com/scorsi/rust_phoenix.
- The Rust language main page on rust-lang.org.
- The Rust WASM documentation on rustwasm.github.io, you should specially look here.
- The Rustler documentation on hexdocs.pm.
- Erlang NIF documentation on erlang.org.
- The typical Discord use-case on blog.discord.com.
- A good post on Rust NIF on theguild.nl.
- WebAssembly use-case examples on webassembly.org.
Top comments (1)
I'm trying to do the WASM part and it doesn't work for me. I think it's not compatible with Parcel.