Rust is Surprisingly Good as a Server Language

Subscribe with RSS

Preface

At some point, I got tired of my old static site generator setup for my blogs and other pages. It was annoying to ssh every time I wanted to make a modification, it was annoying to sftp or sshfs all my images, and so forth. And god forbid, if you ever wanted someone else to write something or make an edit, let me tell you, most people are not particularly happy when you tell him "hey, I'll make you a user on my server, give me your public key so you can ssh in".

I wanted something with a little more dynamism.

So that was the project: a small scope blog, where a few, already trusted users can make, edit, and post new pages in markdown (with a nice markdown editor courtesy of SimpleMDE). Additionally, I want a built in jank verison of imgur so I can satisfy my need to be self sufficient without going crazy.

So while I could whip something up in an afternoon with Django, I could also experiment with other languages. The project is simple enough that I can't imagine being too limited by any language's ecosystem. And I've been itching to write something substansive in Rust...

Which framework?

The biggest framework is probably actix-web. But

  1. When I was scoping out my options months ago, actix-web's maintainer quit with a bunch of drama
  2. At least from what I could tell reading the docs, it seems more suited to APIs rather than servers serving templated HTML
  3. With the above, I wanted this to be a weekend project, not a weekly project, so the more batteries included the better
  4. I really don't want to figure out which async library is considered better. And note that with each async library, comes its own ecosystem of libraries, which only work with that async library, so it's a pretty hard decision to reverse after you made it.

So Rocket it is.

The Good

Something I didn't realize until I started scoping out this project is that on servers... the memory model is actually pretty simple!

Much of your state is just handled by your database. I never actually fought with the borrow checker. I never had to. For the most part, everything had exactly one owner, and exactly one lifetime: the function that's handling the request.

Rocket, too, has a surprising amount of "magic":

#[get("/posts/<slug>/"]
pub fn post_view(slug: String) -> Option<Template> {
    ...
		
    Some(Template::render("/posts/post", hashmap! { "post" => post}))
}

As opposed to Flask's

@app.route("/posts/<string:slug>")
def post_view(slug):
    ...
		
    return render_template("posts/post.html", post=post)

Rust's macro system has really impressed me so far. Not only is there a shocking amount of "just works", but it's all statically typed and compiled.

The closest analogue to Rocket is flask + all the flask adjacent libraries (SQLAlchemy-flask, etc). Rocket, through the power of 3rd party integrations, comes with two template engines (handlebars, and Tera, which is basically Jinja2), database pooling support for quite a few ORMs/DB drivers, and more.

It's still at the point where you have to roll your own auth, though.

While I've heard comparisons to Django/Rails, it doesn't really seem like they're going that direction. Django/Rails purposefully put you, the developer, on the metaphorical rails, dictating best practices from everything from where the files go, to how you update your models and views. Rocket doesn't do that, and I'm not sure it should ever.

I also had, for the most part, the experience that "if it compiles, it works". Most of my runtime errors were in the templates, which incidentally is the only thing that's not statically typed.

I guess that's really what surprised me. For a lot of it, "it just works"! There's not a lot of boilerplate syntax, type inference keeps your functions clean, and I didn't write a single lifetime annotation at any point. My rust server really didn't look that different from my flask server, or my Django server, and honestly it looks cleaner than my Java server. All with no garbage collector or runtime.

The Bad (but not really)

Next, I'll talk about Diesel, which as far as I can see, is the most mature ORM available. While I do have my gripes, it's not really anything "objectively" bad. I suppose it's more on tradeoffs, and Diesel chooses to go light on the magic.

For one, it's annoying to make two structs for each table. You need one to represent the table, and one to insert with (with any autogenerated columns like the primary key removed). For instance, I have

#[derive(Identifiable, Queryable, Associations, PartialEq, Debug, Serialize)]
#[belongs_to(BlogPosts, foreign_key="post_id")]
#[table_name = "tags"]
pub struct Tag {
    id: i32,
    tag_name: String,
    post_id: i32,
}

#[derive(Insertable)]
#[table_name = "tags"]
pub struct InsertTag {
    tag_name: String,
    post_id: i32
}

Additionally, while in some ORMs you write your table models, and the ORM generates your SQL migrations, in Diesel, you write your SQL migrations by hand, and the ORM generates a schema.rs file that contains the mappings. I actually don't mind that one too much.

Diesel also only supports parent-child relationships, and you have to be quite explicit. There's no magic field on your parent, that magically gives you a list of its children. No, you just have to write the query and call it. In some sense it's more like using a slightly fancier query builder.

Dipping down from that level of magic, it's not really a bad thing per se. By being explicit, you prevent users from believing too much in that magic, and shooting themselves in the foot, like N+1 selects.

But I'm not going to say it didn't slow me down quite a bit, either. And to be honest, writing joins was a humongous pain in the ass. Maybe that's how it should be, but maybe that also caused a generation of NoSQL databases. 🤷

The Ugly

Here's how you upload an image in flask

@app.route('/images/upload')
def upload_file():
	files = request.files['file']
	if file and allowed_file(file.filename):
            filename = secure_filename(file.filename)
            file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))

Here's the "simpler" example, while using a third party library in addition from abonader

See the whole thing here

#[post("/upload", data = "<data>")]
// signature requires the request to have a `Content-Type`
fn multipart_upload(cont_type: &ContentType, data: Data) -> Result<Stream<Cursor<Vec<u8>>>, Custom<String>> {
    // this and the next check can be implemented as a request guard but it seems like just
    // more boilerplate than necessary
    if !cont_type.is_form_data() {
        return Err(Custom(
            Status::BadRequest,
            "Content-Type not multipart/form-data".into()
        ));
    }

    let (_, boundary) = cont_type.params().find(|&(k, _)| k == "boundary").ok_or_else(
            || Custom(
                Status::BadRequest,
                "`Content-Type: multipart/form-data` boundary param not provided".into()
            )
        )?;

    match process_upload(boundary, data) {
        Ok(resp) => Ok(Stream::from(Cursor::new(resp))),
        Err(err) => Err(Custom(Status::InternalServerError, err.to_string()))
    }
}

fn process_upload(boundary: &str, data: Data) -> io::Result<Vec<u8>> {
    let mut out = Vec::new();

    // saves all fields, any field longer than 10kB goes to a temporary directory
    // Entries could implement FromData though that would give zero control over
    // how the files are saved; Multipart would be a good impl candidate though
    match Multipart::with_body(data.open(), boundary).save().temp() {
        Full(entries) => process_entries(entries, &mut out)?,
        Partial(partial, reason) => {
            writeln!(out, "Request partially processed: {:?}", reason)?;
            if let Some(field) = partial.partial {
                writeln!(out, "Stopped on field: {:?}", field.source.headers)?;
            }

            process_entries(partial.entries, &mut out)?
        },
        Error(e) => return Err(e),
    }

    Ok(out)
}

Now, to be fair, Rocket is in version 0.4.5. From this github issue, multipart form support is coming in 0.5.0. But it doesn't change the fact that right now, the current libraries are somewhat immature still. They lack some of the edge features, especially for more traditional web servers that serve templated HTML, as opposed to pure API servers, or an SPA.


Rust's errors are quite good, usually. But that's before you get into, well, libraries that try to do a bit more. I ran into some... interesting error messages, mostly from macros in Rocket and Diesel. Take a look at this one, for instance.

the trait bound `(i32, std::string::String, std::string::String, std::string::String, i32, i32, std::string::String, i32, i32): diesel::Queryable<diesel::sql_types::Nullable<(diesel::sql_types::Integer, diesel::sql_types::Text, diesel::sql_types::Text, diesel::sql_types::Text, diesel::sql_types::Integer, diesel::sql_types::Integer, diesel::sql_types::Text, diesel::sql_types::Integer, diesel::sql_types::Integer)>, diesel::sqlite::Sqlite>` is not satisfied

the trait `diesel::Queryable<diesel::sql_types::Nullable<(diesel::sql_types::Integer, diesel::sql_types::Text, diesel::sql_types::Text, diesel::sql_types::Text, diesel::sql_types::Integer, diesel::sql_types::Integer, diesel::sql_types::Text, diesel::sql_types::Integer, diesel::sql_types::Integer)>, diesel::sqlite::Sqlite>` is not implemented for `(i32, std::string::String, std::string::String, std::string::String, i32, i32, std::string::String, i32, i32)`

help: the following implementations were found:
        <(A, B, C, D, E, F, G, H, I) as diesel::Queryable<(SA, SB, SC, SD, SE, SF, SG, SH, SI), __DB>>
note: required because of the requirements on the impl of `diesel::Queryable<diesel::sql_types::Nullable<(diesel::sql_types::Integer, diesel::sql_types::Text, diesel::sql_types::Text, diesel::sql_types::Text, diesel::sql_types::Integer, diesel::sql_types::Integer, diesel::sql_types::Text, diesel::sql_types::Integer, diesel::sql_types::Integer)>, diesel::sqlite::Sqlite>` for `posts::BlogPosts`
note: required because of the requirements on the impl of `diesel::Queryable<((diesel::sql_types::Integer, diesel::sql_types::Text, diesel::sql_types::Integer), diesel::sql_types::Nullable<(diesel::sql_types::Integer, diesel::sql_types::Text, diesel::sql_types::Text, diesel::sql_types::Text, diesel::sql_types::Integer, diesel::sql_types::Integer, diesel::sql_types::Text, diesel::sql_types::Integer, diesel::sql_types::Integer)>), diesel::sqlite::Sqlite>` for `(posts::Tag, posts::BlogPosts)`
note: required because of the requirements on the impl of `diesel::query_dsl::LoadQuery<diesel::SqliteConnection, (posts::Tag, posts::BlogPosts)>` for `diesel::query_builder::SelectStatement<diesel::query_source::joins::JoinOn<diesel::query_source::joins::Join<schema::tags::table, schema::blogposts::table, diesel::query_source::joins::LeftOuter>, diesel::expression::operators::Eq<schema::blogposts::columns::id, schema::tags::columns::post_id>>, diesel::query_builder::select_clause::DefaultSelectClause, diesel::query_builder::distinct_clause::NoDistinctClause, diesel::query_builder::where_clause::WhereClause<diesel::expression::operators::Eq<schema::tags::columns::tag_name, diesel::expression::bound::Bound<diesel::sql_types::Text, &str>>>>`rustc(E0277)
posts.rs(477, 103): the trait `diesel::Queryable<diesel::sql_types::Nullable<(diesel::sql_types::Integer, diesel::sql_types::Text, diesel::sql_types::Text, diesel::sql_types::Text, diesel::sql_types::Integer, diesel::sql_types::Integer, diesel::sql_types::Text, diesel::sql_types::Integer, diesel::sql_types::Integer)>, diesel::sqlite::Sqlite>` is not implemented for `(i32, std::string::String, std::string::String, std::string::String, i32, i32, std::string::String, i32, i32)`

Start scrolling horizontally on the error below. Keeeeeppp goooiiinnnggg. Not what I'd call Google-able.

Reminds me of the ungodly error messages C++ templates would spit out.


At one point I wanted to optionally have month long cookie expiry, since I'm probably going to be using this mostly on my actual personal computers. So I look the corresponding item in the docs for rocket

OK, it wants a... Tm? The heck is a Tm? Let's take a look at the example, then.

Seems good, that's exactly what I want... minus 11 months on the duration, but that's fine.

???

And evidently, the one in std::time is NOT the right one! Alright, I guess I'm adding another library to my node_modules Cargo.toml.

The other library is the right one, BUT

🤦 WHAT WAS IT DEPRECATED FOR!?

And no, the answer that question is either not obvious, or maybe I'm just blind, because I can't see anything with vaguely similar behavior ANYWHERE else.

Man, what's wrong with unix timestamps ;(


Speaking of node_modules, honestly some of the Rust ecosystem reminds me of the NPM. My end release build needs to compile 267 different libraries, before getting to my code, and that's for my simple little blogging engine.

Libraries, own libraries, that own libraries.

I can't help but think that if one of those leaf dependencies is compromised, there's no way I'd realize. But I'm sure it does cut down the build size, though perhaps not the build time, with everything statically linked, to include quite literally just what you need. It's not like C has a lush standard library either. But that's only true so long as all the people who manage libraries try to keep their own dependencies as low as possible.

Conclusion

Would I recommend someone write their next server in Rust? No. The ecosystem is not quite there yet, and most servers are still going to be I/O bound, so the speed gains probably aren't going to matter that much.

BUT, once the ecosystem matures, honestly I think Rust is a great language to write servers in. You get the speed, and the safety, and honestly you don't pay the same price you normally do fighting (or at least thinking of) the borrow checker. Sometimes I really felt like I was writing in mildly more verbose python. It was a lot of fun, and I'm very excited for the much anticipated 0.5.0 release of Rocket.

Though, I'm pretty sure that's going to be a checkout-feature-branch upgrade, not a change-one-line upgrade.

And I'm pretty happy with what I got in the end. A small, cute server that does exactly what it needs to, with no extraneous runtimes running in the background

what it looks like logged in

update: 7/13

CPU graph after some load

CPU GRAPH

Not bad! To be fair, all it has to do is parse some markdown and serve images, but <8% net CPU usage is not bad for the crummy VPS I spun up for this.