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