Migrating to Actix Web from Rocket for Stability

Actors in Actix, actors in life

I previously wrote an article back in November 2017: Replacing Elasticsearch with Rust and SQLite. In it, I needed to create a few HTTP endpoints that ingested JSON, perform a database lookup, and return JSON. Very simple. No query / path parameters, authentication, authorization, H2, or TLS. Any v0.1 microframework would do (Project repo).

I went with Rocket and I knew what I was getting into back then:

[Rocket] has the best testing story, serde support, and contains minimal boilerplate. The downside is that nightly Rust is required.

I became enamored with succint endpoints (a differentiating feature at the time):

#[post("/search", format = "application/json", data = "<data>")]
fn search(data: Json<Search>) -> Json<SearchResponse> {
    debug!("Search received: {:?}", data.0);
    Json(SearchResponse(vec![
        "blog_hits".to_string(),
        "sites".to_string(),
        "outbound_data".to_string(),
    ]))
}

I didn’t understand how stability was such an important feature. I was familiar with needing new versions of the nightly compiler to stay current with clippy and rustfmt, but it was a blindspot when it came to dependencies.

Six Months Later

It’s been six months since the article. There have been a half dozen updates to Rocket and numerous updates to the nightly compiler. Since I only revisit the project about once every other week, I was always met with a compiler error, as I had most likely updated the nightly compiler in the meantime to grab a new rustfmt or clippy version, and the Rocket version wouldn’t work with that nightly. Syncing Rocket and the nightly compiler to the latest version normally fixed the issue. I’m very thankful that Rocket is maintained to this high degree.

There was a time when the latest Rocket broke on nightly because the nightly version broke ring, a dependency. A combination of cross linking, alpha versions, module paths, and herd mentality led to a temporary impasse. The only solution was to pin the nightly version of Rust with a specific version of Rocket. Everyone worked together and eventually resolved the issue. Still, I find myself wary.

This did have the side effect of me pinning the nightly compiler via rustup override. Though, I came back a couple weeks later to update dependencies (including Rocket) and this time my usual incantation of cargo +nightly build failed. Syncing versions didn’t help. It took me an embarrassingly long amount of time to realize that I needed to either update the nightly pin or unpin.

I don’t want a project where I have to remember its unique setup. I like it when a cargo build or cargo build --all is all I need.

Actix-Web

In these last six months, I have been really impressed with the actix.rs project, specifically actix web. It’s a relatively new project, in fact the first released version on crates.io was shortly before I started working on my project. To me, it has everything Rocket has to offer but it also compiles on stable. As a plus, it integrates with tokio for asynchronous endpoints. I don’t utilize this feature, but it’s nice to know actix is closely tracking the future of scalable Rust networking.

I can’t overstate how similar actix web endpoints resemble Rocket endpoints. The following diff is the migration for the endpoint posted earlier:

- #[post("/search", format = "application/json", data = "<data>")]
  fn search(data: Json<Search>) -> Json<SearchResponse> {
      debug!("Search received: {:?}", data.0);
      Json(SearchResponse(vec![
          "blog_hits".to_string(),
          "sites".to_string(),
          "outbound_data".to_string(),
      ]))
  }

That was too easy. How about an endpoint that takes application state too?

- #[post("/query", format = "application/json", data = "<data>")]
- fn query(data: Json<Query>, opt: State<options::Opt>) -> Result<Json<QueryResponse>, Error> {
+ fn query(data: (Json<Query>, State<options::Opt>)) -> Result<Json<QueryResponse>, Error> {
      // endpoint code 
  }

The only thing that changed in the migration were the function signatures! Hats off to the actix project for reaching the same level of ergonomics as Rocket, making it a painless migration, all the while working on stable Rust.

Really, the only code I wrote for the migration was for creating an App

fn create_app(opts: options:Opt) -> App<options:Opt> {
    App::with_state(opts)
        .middleware(Logger::default())
        .resource("/", |r| r.f(index))
        .resource("/search", |r| r.method(http::Method::POST).with(search))
        .resource("/query", |r| r.method(http::Method::POST).with(query))
}

I completed the migration in under an hour with zero experience with actix and only using the official docs. I kicked myself for not doing this sooner, it was almost too easy.

One hiccup, though: integration testing.

Integration Testing

It took me longer to convert a few tests than to go from zero to migrated with actix. Testing with actix has it’s own documentation section which got me 95% of the way.

I was used to Rocket’s way of testing (example taken from Rocket docs):

let client = Client::new(rocket()).expect("valid rocket instance");
let mut response = client.get("/").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.body_string(), Some("Hello world!".into()));

In the example, it’s immediately obvious how to test for the response’s body contents. Actix lacked this example, so I created it. I won’t bore you with why it took me so long to figure it out, so for posterity, here is the same test but with actix.

let mut srv = TestServer::with_factory( create_app);
let request = srv.client(http::Method::GET, "/").finish().unwrap();
let response = srv.execute(request.send()).unwrap();

assert!(response.status().is_success());
assert_eq!(response.content_type(), "text/plain");

let bytes = srv.execute(response.body()).unwrap();
assert_eq!(str::from_utf8(&bytes).unwrap(), "Hello world!");

Other Thoughts

I’m keeping a Rocket branch alive so I can do some comparisons:

Compile times with Rocket

git checkout rocket
cargo clean
time cargo +nightly build --release -p rrinlog-server

real    2m34.228s
user    9m41.905s
sys     0m11.664s

Compile times with Actix-Web (I compile with same nightly to control for any compiler improvements)

git checkout master
cargo clean
time cargo +nightly build --release -p rrinlog-server

real    3m16.306s
user    12m4.985s
sys     0m16.032s

Binary size:

  • Rocket: 9.5MB (5.3MB strip -x)
  • Actix-Web: 13MB (8.5MB strip -x)
  • Actix-Web (no default features): 12MB (7.6MB strip -x)

So Actix-Web results in a slower compile time and a larger executable. Migrating to actix web didn’t result in wins across the board, but it is a fine trade-off. For curiosity, I ran cargo bloat to see if there were obvious culprits:

 File  .text     Size Name
 9.0%  26.1%   1.2MiB [Unknown]
 6.8%  19.7% 961.4KiB std
 3.0%   8.6% 420.8KiB regex_syntax
 2.9%   8.2% 402.2KiB actix_web
 2.1%   6.1% 299.2KiB regex
34.6% 100.0%   4.8MiB .text section size, the file size is 13.8MiB

Eh, nothing stands out. That’s ok.

Last difference that I felt is that Rocket has its built in Rocket.toml, which I used to change the bound address. This was simple enough to move to a commandline argument.

I’m more than happy with the migration and will recommend anyone who uses Rocket and feels the pain of nightly breakage to use actix web. My intent for this article was not to come across like “X is better than Y framework” as both are exceptional, but rather showcase actix for Rocket users. It’s easy, fast, and stable.

Comments: