Production-ready microservice in Rust: 4. Webserver

Posted 2023-05-21 14:50:00 by sanyi ‐ 6 min read

Starting an asynchronous webserver based on Axum

We will implement the fundamentals of a webservice in this article. The service will be based on the tokio async runtime and the axum web application framework.

First, add the new dependencies to shelter_main/Cargo.toml:

[dependencies]
anyhow = "1"
clap = "4"
dotenv = "0.15"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
config = "0.13"
tokio = { version = "1", features = ["full"] }
axum = { version = "0.6" }

Run cargo build to download and compile the new dependencies.

We already have a dummy serve CLI subcommand in commands/serve.rs, now we have to start the tokio runtime there:

use axum::Router;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};

...

pub fn handle(matches: &ArgMatches, _settings: &Settings) -> anyhow::Result<()> {
    if let Some(matches) = matches.subcommand_matches("serve") {
        let port: u16 = *matches.get_one("port").unwrap_or(&8080);

        start_tokio(port, settings)?;
    }

    Ok(())
}

fn start_tokio(port: u16, _settings: &Settings) -> anyhow::Result<()> {
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(async move {
            let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), port);
            let routes = Router::new();

            axum::Server::bind(&addr)
                .serve(routes.into_make_service())
                .await?;

            Ok::<(), anyhow::Error>(())
        })?;

    std::process::exit(0);
}

The start_tokio function creates a new tokio multi-threaded runtime, then start an axum server on 0.0.0.0:<port>.

The routes configuration is completely empty for now, we will add some routes shortly.

Now build and run the application:

$ cargo build
$ ./target/debug/shelter_main serve

The program is not too chatty, we will add some logging later. You can verify that it listens on port 8080 using netstat:

$ netstat -ln | grep 8080
tcp        0      0 0.0.0.0:8080            0.0.0.0:*               LISTEN     

Use Ctrl+C to stop the server.

Now it's time to add some routes to our axum server, but first we have to think about the route structure a bit. APIs are usually versioned, so it's a good practice to start the urls with a /v1/ prefix for the first version. There is a good chance that later API versions will only change a small part of the endpoints so it's probably wise to not bind endpoint implementations strictly to versions but prepare for a more flexible structure. One possible setup:

src
  api
    handlers
      mod.rs
      hello.rs
  mod.rs
  v1.rs

Where src/api/mod.rs builds the whole configuration by nesting all versions:

use axum::Router;

mod handlers;
mod v1;

pub fn configure() -> Router {
    Router::new().nest("/v1", v1::configure())
}

Then src/api/v1.rs builds the v1 configuration:

use super::handlers;
use axum::routing::get;
use axum::Router;

pub fn configure() -> Router {
    Router::new().route("/hello", get(handlers::hello::hello))
}

Finally src/api/handlers/hello.rs contains our single hello world endpoint:

use axum::http::StatusCode;

pub async fn hello() -> Result<String, StatusCode> {
    Ok("\nHello world!\n\n".to_string())
}

We also need the src/api/handlers/mod.rs to add hello.rs to the build:

pub mod hello;

And include pub mod api in src/lib.rs. Now modify the start_tokio method to use our routes:

fn start_tokio(port: u16, _settings: &Settings) -> anyhow::Result<()> {
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(async move {
            let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), port);

            let routes = crate::api::configure();

            axum::Server::bind(&addr)
                .serve(routes.into_make_service())
                .await?;

            Ok::<(), anyhow::Error>(())
        })?;

    std::process::exit(0);
}

Run cargo build and ./target/debug/shelter_main serve again, and test the application using curl:

$ curl -v http://127.0.0.1:8080/v1/hello
*   Trying 127.0.0.1:8080...
* Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0)
> GET /v1/hello HTTP/1.1
> Host: 127.0.0.1:8080
> User-Agent: curl/7.81.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< content-type: text/plain; charset=utf-8
< content-length: 15
< date: Sun, 21 May 2023 11:47:23 GMT
< 

Hello world!

* Connection #0 to host 127.0.0.1 left intact

Now it's time to add some logging/tracing to our application. Add these crates to shelter_main/Cargo.toml:

tracing = { version = "0.1", features = ["log"] }
tracing-log = { version = "0.1" }
tracing-subscriber = { version = "0.2", features = ["registry", "env-filter"] }
tower-http = { version = "0.3.5", features = ["trace"] }

We have to initialize tracing in main.rs:

use clap::{Arg, Command};
use dotenv::dotenv;
use shelter_main::commands;
use shelter_main::settings;
use tracing::level_filters::LevelFilter;
use tracing::Level;
use tracing_subscriber::{layer::SubscriberExt, Registry};

pub fn main() -> anyhow::Result<()> {
    dotenv().ok();

    ...

    let settings = settings::Settings::new(config_location, "SHELTER")?;

    let subscriber = Registry::default()
        .with(LevelFilter::from_level(Level::DEBUG))
        .with(tracing_subscriber::fmt::Layer::default().with_writer(std::io::stdout));

    tracing::subscriber::set_global_default(subscriber).expect("Failed to set subscriber");

    commands::handle(&matches, &settings)?;

    Ok(())
}

We use a simple configuration for now: log everything on loglevel DEBUG and above to the standard output.

To enable logging in axum, add a new layer to its configuration in the start_tokio function:

use crate::settings::Settings;
use clap::{value_parser, Arg, ArgMatches, Command};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use tower_http::trace::TraceLayer;

...

fn start_tokio(port: u16, _settings: &Settings) -> anyhow::Result<()> {
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(async move {
            let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), port);

            let routes = crate::api::configure()
                .layer(TraceLayer::new_for_http());

            tracing::info!("starting axum on port {}", port);

            axum::Server::bind(&addr)
                .serve(routes.into_make_service())
                .await?;

            Ok::<(), anyhow::Error>(())
        })?;

    std::process::exit(0);
}

We also add an info level message about starting axum.

Build and start our server again and test it with curl the same way as earlier. Now you will see some output, something like this:

May 21 14:24:07.567  INFO shelter_main::commands::serve: starting axum on port 8080
May 21 14:24:10.192 DEBUG hyper::proto::h1::io: parsed 3 headers
May 21 14:24:10.192 DEBUG hyper::proto::h1::conn: incoming body is empty
May 21 14:24:10.192 DEBUG request{method=GET uri=/v1/hello version=HTTP/1.1}: tower_http::trace::on_request: started processing request
May 21 14:24:10.192 DEBUG request{method=GET uri=/v1/hello version=HTTP/1.1}: tower_http::trace::on_response: finished processing request latency=0 ms status=200
May 21 14:24:10.192 DEBUG hyper::proto::h1::io: flushed 132 bytes
May 21 14:24:10.192 DEBUG hyper::proto::h1::conn: read eof

You can find the sample code on GitHub

Next article ยป

Tags:
rust microservice dog-shelter axum