Actix to Warp, a rust-y web server story
jcbellido January 05, 2022 [Code] #LillaOst #RustLet's take a look on how one of my backends evolved from a beefy Actix + Diesel stack into a slimmer warp + serde combo. And, just for fun, I'll add a small anecdote about forcing an innocent Web App into synchronous fetch calls and how I ended in such a dark place.
This article is part of a series covering the evolution of LillaOst.
Let's dig in.
Before start, perhaps you'd like a podcast that talks a bit about Yew? It just dropped while I was writing this piece. It's interesting, perhaps not the best ambassador of the library but I still hope you can enjoy it.
Starting point: Actix + Diesel + PostgreSQL
Almost by accident I decided to write a WebApp using only rust. I wanted to try the language and what could be done in the ecosystem1. After a look into Are we web yet? I decided to go ahead and start building on top of Actix + Diesel + PostgreSQL. Because every Web App needs a relational DB for persistence and an ironclad web server, right?
This combination worked perfect for my needs but for one issue: it was overkill. When I started the project I just came out of a .Net heavy environment where an ASP + Entity Framework + MSSQL
combination would've been a reasonable starting point. When working with .Net the tooling is fantastic, the pipeline clear and when you're not trying to do anything too fancy things "just work". That Diesel + Actix + PostgreSQL
stack, at first glance, was "almost like working in .Net but in rust".
The complexity of the stack became a problem after the first working release was in use at home. As every project in contact with end users the feature requests started immediately. In particular the problems began when I tried to expand the scope of the events I was persisting. As you can imagine, and given that it was my first development in Rust, the whole code was not particularly well modularized and testing changes in the underlying models forced me to do the usual ORM dance. Since I didn't bothered to have a proper test environment (I introduced TDD-isms later in my rust journey2) this forced me to have a couple DBs, populating fake data was a PITA, I was forced to keep a SQL configuration of the DB, etc.
To be clear, I'm not implying that Diesel or Actix, or PSQL are in any way bad products. They're not. They're absolutely phenomenal. I simply introduced them too early, before I was remotely ready to keep the discipline and some semblance of reasonable structure in my code. And, more important, before my problem needed industry-grade solutions.
An anecdote about DBs VS. Plain-Old-Files
While working on rewriting the UI in yew I was checking some material by Robert C. Martin:
In one of his adorable ramblings he describes a project (perhaps Fitnesse?) where the developers started a product planning on introducing a DB down the line. But as the project progressed they never see the benefit of installing it. The file based persistence they've been using during de development was doing the job fine.
That made me think: perhaps I've over planned my solution? At that point I was starting to use cargo test
and slowly learning how the suites work in rust. I was using intermediate .JSON files with fake data to check some timestamp details. So, why not to try that route? I had every piece in position: Serde was there, serializing was enforced by Diesel, so why not try to use a old-chunk'o-JSON?
And that's exactly what I did. After some testing on my very humble RaspberryPi3B I verified that it was capable to handle "years" of fake data without struggling too much. The "schema" was dead simple. Making data backups was as stupid as sending a file to git.
I stripped the DB out of my project and I was happily tweaking the code again without thinking too much about it.
From Actix to Warp
Sometimes when you approach some code with a mindset of "How can I simplify this mess?" you start questioning every component. Once Diesel was out of the mix it was time to take a look into Actix. There's nothing wrong with it as far as I can tell. It has good documentation and samples. It seems to be one of the most performant web servers written in rust. It was working perfectly well already. And still, I decided to move to Warp. Honestly my reasons are way fuzzier when compared with removing Diesel. I'll try to summarize my steps a bit.
1. It was the first rust library I tried to use
Just try to imagine the hubris of starting from scratch with a language and decide to:
- Decide to make a Web App on a non-native target.
- Start reading
the book
right at that moment. - The first library you decide to explore is "Actix" but you don't even know what
Tokio
is.
I think I choked with the mix of magical macros, documentation and bizarre syntax. Simply put, I made it work through brute force and luck3. From that point I never felt as if I understood that part of the puzzle. And this was a constant source of uncertainty.
2. Async was becoming a pressing matter
If you read the Bonus
section you'll get an idea of my misadventures trying to make a totally "sync" frontend talk with an async backend. Once I realized that async was unavoidable I decided to start looking deeper in the ecosystem. Tokio was a natural candidate and there I went.
If I understand Actix correctly, it's using Tokio under the hood. But I wanted a more direct contact with it. Using Tokio's components it's possible to build a web server. My goal was to find something "in between these two extremes", a solution that let me dip my toes in Tokio without drowning. And warp seems right there, in that category.
A couple Warp samples and tricks
Let's take a look to how the root of the server looks:
let in_thread_server = new;
let = ;
let routes =
// API
all_persons
.or
.or
.or
.or
// Static content
.or
.or
.or;
let warp_server = spawn;
If we ignore the slightly concerning LocalSet
the code looks quite clear to me. Not a macro in sight. Just "plain rust".
This was the direction I was following. The examples in the repo are well explained and they'll probably cover any need you can have (within reason) But then I started noticing that the compilation times were increasing. Like a lot. Like 8 minutes for an arguably tiny server. Then I found that this issue is known and it has a solution: BoxedFilter
.
Let's check an example. A GET call that answers to api/persons
, that requires a mpsc::channel::sender
and answers with a JSON containing the persons persisted in the server:
This filter actually uses a macro but this one is oh-so-convenient! Point is that after introducing this change in the server the compilation times in my RaspberryPi3B went from days of compiling to ~ 12 minutes. That is arguably a very long time but we're still talking about an ARM32 used for hobby projects. Not bad.
Bonus: Forcing a Web App to fetch synchronously
Once my new server was in place and properly tested it was time to hook the UI to it. Simple stuff, right? Well ... let's talk about the state the Web App code at that point in time. Starting with my Cargo.toml
:
= { = "https://github.com/yewstack/yew?branch=master", = "94b475213aae0ca0c5397c7809a17d23cebea041" }
Yep, you can do that in a Cargo.toml
I don't suggest you do but it's possible. IIRC that rev
corresponds to a very early 0.18.XX
. About this point in time the yew team was about to publish 0.19.3
.
And about how the components where querying data, I had a very handy "God Struct" that exposed functions such as:
So, in summary:
- For my own sanity I needed to stabilize my UI code on some crated version of yew.
- I was assuming that my data was local, in memory and perfectly synchronous.
- I wanted to use the UI with the new backend ASAP.
This situation was no-bueno
.
My frontend and backend were incapable to talk to each other, or at least I was unable to imagine a workaround that let me hook a fully sync UI with an async backend. I tried to find a "rusty" way of using synchronous fetch calls and sidestep the issue. I tried to use reqwest and a couple more approaches without luck.
Then I looked deeper into JavaScript
. My question was: "In a modern browser, is it allowed to wait a fetch completion?". If you go to the documentation in XMLHttpRequest.open, read through the options and ignore every-single-warning, you discover that you can force a browser to execute its queries synchronously4. You also conclude that this is a very bad idea. It was exactly the kind of nasty patch I needed to do a temp release and unblock myself.
wasm_bindgen: Calling JS from WebAssembly
I had a potential solution in JavaScript
that I wanted to call from my webAssembly
. That's something that you can do through wasm_bindgen. At high level you write your js side by side your rust sources:
// Don't use this code. This is bad. This makes Santa sad.
;
And then you expose the functions to rust through the super-duper-magical glue that wasm_bindgen is capable to generate:
use *;
extern "C"
Then, the absolutely stellar trunk does all the packaging required to bring your javascript into your dist
build. It just works. At this point I had an opening to make all my calls synch and simply substitute LocalStorage --> get_string_from_network
or whatever I needed.
After a moment of exultant joy I decided to go into the shower, hug myself and cry for my lost soul.
Program your Web Apps with async in mind. Be gentle to yourself.
1 For more details, check: LillaOst Introduction.
2 There's a lesson for me here. When starting with a new language, check the testing capabilities first.
4 Don't do this in your code. Honestly, don't. This is not the way, I promise.
3 Somehow as I get older and more experienced I become luckier in these matters, but this is beside the point.