loading...

Improve the performances of your Phoenix app with Rust: in both back and front

scorsi profile image Sylvain Corsini ・6 min read

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

Posted on by:

scorsi profile

Sylvain Corsini

@scorsi

I'm a single dev in a company working on a pretty large project.

Discussion

markdown guide