DEV Community

Sylvain Corsini
Sylvain Corsini

Posted on

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

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And we will generate our awesome NIF project, when asked give the module name, I decided to give RustPhoenix.RustHelloWorld.

mix rustler.new 
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

(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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!");
}
Enter fullscreen mode Exit fullscreen mode

It simply define a greet() function calling the alert javascript function.
Now let's compile our project:

wasm-pack build
Enter fullscreen mode Exit fullscreen mode

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"
},
Enter fullscreen mode Exit fullscreen mode

And run:

npm install
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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"]}
Enter fullscreen mode Exit fullscreen mode

And to rebuild the MIME deps, run:

mix deps.clean mime --build
Enter fullscreen mode Exit fullscreen mode

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

Top comments (1)

Collapse
 
ryanwinchester profile image
Ryan Winchester

I'm trying to do the WASM part and it doesn't work for me. I think it's not compatible with Parcel.