Actix web describes itself as
a small, pragmatic, and extremely fast rust web framework.
The README has an example to start with so let’s create a new Rust project.
$ cargo new web_app Created binary (application) `web_app` package $ cd web_app
First we need these dependencies in our Cargo.toml
:
[dependencies] actix-web = "2" actix-rt = "1"
(did you know there’s a utility called “cargo-edit” which lets you add dependencies from the command line? It lets you do cargo add serde
, for example, which will add the serde crate to your dependencies in Cargo.toml
. Check it out here https://github.com/killercup/cargo-edit)
In main.rs
let’s paste the snippet from the example in the README.
use actix_web::{get, web, App, HttpServer, Responder}; #[get("/{id}/{name}/index.html")] async fn index(info: web::Path<(u32, String)>) -> impl Responder { let (id, name) = info.into_inner(); format!("Hello {}! id:{}", name, id) } #[actix_rt::main] async fn main() -> std::io::Result<()> { HttpServer::new(|| App::new().service(index)) .bind("127.0.0.1:8080")? .run() .await }
Cool. We can run it with cargo run
and curl our index endpoint:
$ curl http://localhost:8080/1/foo/index.html Hello foo! id:1%
Looks good.
Let’s look at main
. We declare the function with the async
keyword to create an asynchronous function. The value returned by an async function is a Future
which represents an asynchronous value.
Nothing “happens” when we create a Future unless we run it. To run a Future we use an executor. For our purposes think of an executor as a “runner”. Notice the line above the function declaration #[actix_rt::main]
? Rust lets you specify which runtime you’d like to use for executing Futures. In this example, we’re using the actix_rt::main
macro to tell Rust we’d like our async function to be run on the actix system. There are lots of different runtimes available and you can even define your own. The actix_rt one is a specific implementation which runs everything on the current thread.
Ok so our main
function is asynchronous and it’ll run on the actix_rt runtime. Our main
returns a std::io::Result<()>
. The Result
type in general represents either success (Ok
) or failure (Err
). There’s actually a std::result::Result
type, but we’re using a version specialized for I/O operations. We’re not going to go into the details of that here.
Moving on to the body of our function, we call the HttpServer::new
function. This type signature of this new
function is pub fn new(factory: F) -> Self
. In Rust, there’s a clear distinction between pure data types (structs) and implemention or behavior of those types. In other words, unlike in, say, Java, we don’t have some class with a bunch of fields and then methods defined on that class. We have a struct and then an impl. Consider this example:
struct Greeter { name: String, salutation: String, } impl Greeter { fn greet(&self) { println!("{}, {}!", self.salutation, self.name); } }
We can use that like this:
fn main() { let greeter = Greeter { salutation: "hello".to_string(), name: "levi".to_string(), }; Greeter::greet(&greeter); }
Why do I bring this up? Because the HttpServer::new
that we used is a function defined in the HttpServer
impl. The HttpServer struct is parameterized on four types like so:
pub struct HttpServer<F, I, S, B> where F: Fn() -> I + Send + Clone + 'static, I: IntoServiceFactory<S, Request>, S: ServiceFactory<Request, Config = AppConfig>, S::Error: Into<Error>, S::InitError: fmt::Debug, S::Response: Into<Response<B>>, B: MessageBody, { pub(super) factory: F, config: Arc<Mutex<Config>>, backlog: u32, sockets: Vec<Socket>, builder: ServerBuilder, #[allow(clippy::type_complexity)] on_connect_fn: Option<Arc<dyn Fn(&dyn Any, &mut Extensions) + Send + Sync>>, _phantom: PhantomData<(S, B)>, }
Think of the <F, I, S, B> part like generics in Java. We use those type parameters to make a function generic over some type(s).
Rust lets us add constraints to the type parameters. We use +
if we need multiple bounds. It’s like <T extends B1 & B2 & B3>
in Java-land. One way to add these bounds is with the where
keyword. So this whole thing:
where F: Fn() -> I + Send + Clone + 'static, I: IntoServiceFactory<S, Request>, S: ServiceFactory<Request, Config = AppConfig>, S::Error: Into<Error>, S::InitError: fmt::Debug, S::Response: Into<Response<B>>, B: MessageBody,
are the constraints on types params F, I, S and B. Let’s look at the first one for F
F: Fn() -> I + Send + Clone + 'static
Basically, this says F
must be an instance of Fn
(there’s a trait called Fn
, for more on Fn check out this answer on stackoverflow) which takes no args and returns something which is an I
as well as a Send
and Clone
. Let’s ignore the last constraint (the 'static
). So roughly speaking, whatever F
is, F
must be a function which takes no arguments and returns something that and returns an I
. F
must also be an instance of the Send
and Clone
traits.
Did we satisfy these constraints by using || App::new().service(index)
?
Well, what the hell does that line mean? This is the syntax for a closure in Rust. Here’s an excerpt from the Rust book which shows the various valid syntaxes for closures.
fn add_one_v1 (x: u32) -> u32 { x + 1 } let add_one_v2 = |x: u32| -> u32 { x + 1 }; let add_one_v3 = |x| { x + 1 }; let add_one_v4 = |x| x + 1 ;
Actually that first line is a regular function definition included so you can compare closures to the usual function def.
Back to that F
we wanted to supply to HttpServer::new
. Again, we passed
|| App::new().service(index)
The ||
clues us in that this is closure syntax. The empty pipes means it takes no arguments which makes sense because the type constraint on F
said Fn() -> I
, namely that it’s a function which takes no arguments and returns an I
, etc.
What’s the I
in our case? It’s whatever the type of App::new().service(index)
is. In this case it’s an instance of actix-web’s App
struct. This actually satisfies another one of the type constraints, namely that I
must implement IntoServiceFactory
I: IntoServiceFactory<S>,
which is a “trait for types that can be converted to a ServiceFactory
“. If we look around the actix-web source, we’ll see that that App
does indeed implement IntoServiceFactory
impl<T, B> IntoServiceFactory<AppInit<T, B>> for App<T, B>
// etc, etc ....
Point is, the types can get kind of crazy, but I recommend taking it slow, start by just getting the gist of it and later get a more rigorous understanding of the types and the constraints as needed.
So we’ve created an HttpServer
using the new
function. Now we can call bind
which sets up our server to listen on the provided port. actix-web is using something along the lines of the builder pattern here so the chained method calls keep returning the HttpServer. More accurately, the server wrapped in an io::Result
(either Ok
or Err
).
That’s where that ?
question mark comes in. We can’t call run
on an io::Result
. We want a server, but we might not have one because one of our chained calls might’ve returned an Err
. So we need to deal with the fact that we’re actually working with a Result
type here and not merely a server. For more on this ?
operator, read all three sections on Error Handling in the Rust Book https://doc.rust-lang.org/book/ch09-00-error-handling.html
The question basically says: “ok, I acknowledge this could be Ok
or an Err
. If it was an Ok
, unwrap it and gimme the inner value (the server). If it was an Err
, just propagate and return the error. (This will bring to mind monads for Haskellers and Scalaites (?), such as when using Maybe
or Option
).
It’s the same exact thing as explicitly pattern mathing on the success or failure. In fact, we can rewrite our server startup with pattern matching:
#[actix_rt::main] async fn main() -> std::io::Result<()> { let server = HttpServer::new(|| App::new().service(index)); let hopefully_bind = server.bind("127.0.0.1:8080"); match hopefully_bind { Ok(bound) => bound.run().await, Err(e) => Err(e), } }
There’s a lot to unpack here, I just wanted to give a sense for how I try to make sense of things in Rust in a way that at least lets me get some stuff done.