This article also has a Chinese version.
An HTTP Server internally includes many parts: protocol implementation (h1, h2, compression, etc.), connection state management (keepalive), request distribution, middleware, business logic, and more. Users could implement all of these themselves, however, apart from the business logic, the rest are fairly common capabilities. By decoupling these generic capabilities from the user’s business logic, we arrive at what is known as an HTTP framework.
In the Rust ecosystem, the hyper library already offers a relatively complete implementation of the HTTP protocol. Therefore, building an HTTP framework on top of hyper mainly requires adding capabilities such as routing, shared state, middleware, etc.
This article discusses from the design perspective of an HTTP framework, using the new version of Axum as an example, how to provide rational abstractions and type constraints in Rust’s HTTP frameworks. Levering Rust’s powerful type system, we can write code that is both efficient and correct.
Handler Abstraction
Simple Routing
Usually, our behavior is independent for different Methods on different request Paths.
Dispatching based on Path and Method is one of the universal capabilities that an HTTP framework needs to provide: for example, we need the ability to dispatch GET / and POST /login to different handler functions.
The simplest routing can be implemented using a HashMap. As shown below, the Key is a combination of Path and Method, and the Value is the corresponding handler function. We can represent Path and Method with a simple String, but how should we express the handler function?
1 | HashMap<(Path, Method), Handler> |
First, let’s address the Handler problem, and later we will revisit a more reasonable routing design.
Since we need to store the Handler in a HashMap, we need a fixed type to represent it (otherwise, the size of the HashMap’s Value would be indeterminate). In Rust, we often use Box<dyn Trait>
to unify different concrete types as the same type (which corresponds to a pointer in other languages).
This requires us to define a Trait to describe the processing logic.
The Simplest Solution: Requiring the Handler Function to Implement a Fixed Fn
Since processing logic invariably involves handling an HTTP request and obtaining an HTTP response, a direct approach is to take the HTTP Request struct decoded by hyper as an argument, with the requirement that the function return an HTTP Response.
If a handler internally needs a particular request parameter, then it has to extract it from the HTTP Request on its own, and may need to perform deserialization processes (like Json).
1 | add_route(handler: H, ...) |
A More User-Friendly Interface
The drawback of the previous solution is that it is very inconvenient for the user to extract parameters. A friendlier implementation is for the framework to handle parameter extraction, with the user simply needing to declare what they need in the parameters and describe their business logic.
What we want to achieve is:
1 | async fn index() -> &'static str { |
To implement a unified Trait for these Handlers, we need to solve several issues:
- Ensure that each parameter can be extracted from the HTTP Request.
- Ensure that the function’s return value can be converted into an HTTP Response.
- Support variable-length arguments (different Handlers can have different numbers of parameters).
- Support Async Fn.
Parameter Constraints
We hope to ensure through compile-time checking that these parameters can be extracted from the Request. We can define a Trait:
1 | // framework code |
For instance, for the need to extract parameters from an HTTP Query, users just need to implement code like this:
1 | // user code |
The framework can implement a helper structure Query to extract from the Query parameters.
1 | // framework code |
In addition to using built-in auxiliary structures like Query or Form provided by the framework, users can also implement their own FromRequest.
Through this approach, we decouple request parameter extraction from the business logic and ensure type constraints, guaranteeing that the structure must be implemented on how to extract from the Request. Only then can the function be implemented as a Handler and be registered to the Router.
Return Value Constraints
Return value constraints are similar to parameter constraints; we constrain the return value to be convertible to a Response:
1 | pub trait IntoResponse { |
The framework can provide some implementations:
1 | impl IntoResponse for &'static str {...} |
With the help of IntoResponse, we can support custom structures as user handler returns, decoupling Response construction from business logic.
Supporting Variable-Length Arguments
Some handlers do not need any input parameters (such as the index interface mentioned earlier that returns “hello world”), while others may require one or more parameters extracted from the Request.
So, how can we abstract such a diverse range of functions into a unified form? In Rust, the common practice is to define a Trait and then implement this Trait for functions (Fns) that meet certain conditions. For Fns with varying numbers of parameters, we need to manually implement the Trait for each one.
Since this task is tedious and repetitive, it is often accomplished using macros, making the resulting code block resemble a “trapezoid”.
1 | // from axum |
We require a unified calling interface, which is achieved using macros:
1 | pub trait Handler { |
Ultimately, we still call by directly passing an http::Request
to obtain an http::Response
.
Ownership Issue with Request
In the previous interface, we constrained our request extractors to only have read access to a reference of Request
, because we might need to extract multiple parameters. Here, we can make a small optimization: If there is only one parameter extractor, then pass ownership; otherwise, for the last one, pass ownership, and for the previous ones, pass references.
In Axum, there are two relevant traits: FromRequestParts
and FromRequest
. FromRequestParts
can extract content from the HTTP Request Head (passing a reference), while FromRequest
can extract from both the HTTP Request Head and Body (passing ownership of the entire Request). FromRequest
is very friendly for extractors that need to consume the Body, but it will only be applied to the last extractor.
Supporting Async Fn
With the Handler
Trait from the previous section, we’ve already made it quite friendly to define handlers. However, often our handlers will involve database reads/writes, downstream RPC calls, etc., all of which are network operations that are asynchronous. Thus, handlers inevitably have to be implemented as async fn
.
An Async Fn
is essentially a Fn
. The main difference is that, after the async
syntax is desugared, it turns the defined return value into an anonymous structure that implements Future
. For example:
1 | // before desuger |
Extended Reading: For some foundational knowledge about Rust’s asynchronous system, you can refer to this article series.
To support async fn
implementations of the Handler
Trait, we need to make some modifications:
1 | pub trait Handler { |
Because it involves specific types of Future
, here we actually have two approaches: one is to use type Future: Future<Output = http::Response>
as the associated type for the Trait, another is to directly use BoxFuture
to erase the type.
Which to use mainly depends on whether or not one wants to erase the type, and where they want to perform the type erasure. Generally speaking, in scenarios where nested calls may occur (such as nested tower::Service
), it is best to perform the type erasure at the end. This can minimize the usage of Box
, maximize inlineable code segments, and achieve better performance.
We’ve modified the Trait, and likewise, we need to modify the implementations. Below is a demo example after macro expansion:
1 | // illustrate for macro expand result |
Note: This restriction implies that we must use an asynchronous interface. If we want to implement Handler
for structures that return T: IntoResponse
, there would be an implementation conflict (because both are blanket implementations).
To summarize this section, we have defined the Handler
Trait clearly and automatically implemented it for the user-defined async fn
/ fn
that satisfy certain conditions. Users can use various extractors and generators on the function’s parameters and return values, allowing the handler itself to focus on business logic.
Shared State
Handlers might need access to some external quantities, like an initialized database or a HashMap
for caching. We generally allow users to attach some State
(or Data
) when creating a Route
, and extract this structure in the handler function’s parameters.
Extracting Shared State
We can fully treat shared state as part of the Request
.
This allows us to reuse the previously defined FromRequest
. But how can we differentiate the user-defined type S
that we want to extract—where should it come from? Or what if the S
that the user wants to extract is the same S
we use for extracting request parameters like Query
? If S
is put directly in the parameters, there will be semantic ambiguity.
To solve this ambiguity, we can create a State
type.
1 | pub struct State<S>(pub S); |
After implementing FromRequest
for State
, we then know for certain that this S
is to be extracted from shared state.
Storing Shared State
Following our previous train of thought, since we are treating shared state as part of the Request
, we either place the state somewhere in the Request
, define a new Request
that carries the state, or introduce it through a new parameter.
Storing State Using Request Itself
In Actix and earlier versions of Axum, state storage adopted this approach. The Request
internally has a type map called an extension; we can insert a KV pair directly: key = S
; val = S{...}
. When extracting, we can find the val
we inserted before calling the handler using the S
type as the key.
This seems convenient and easy to implement, but this solution forgoes the opportunity for static type checking. If the State
required in the user handler’s parameters conflicts with the State
attached to the Route
, it can only be handled at runtime. This may cause code that can compile but is guaranteed to fail at runtime.
Passing Through a New Parameter and Adding Type Constraints
Using the Rust type system, we can expose the issues mentioned earlier during compile time.
We can add a generic S
to Handler
, FromRequest
, and Route
, and when adding a route with Route<S>
, we make restrictions:
1 | pub trait Handler<S> { |
We thus can constrain the type of State
on the handler function. Next, we implement the State
helper structure for parameter extraction:
1 | pub struct State<S>(pub S); |
Great success! We can now confidently use shared state without worrying about passing the wrong type and running into problems at runtime. If it compiles, it works.
More User-Friendly Shared State
Just like we discussed extracting from Request
, users might only need a part of Request
or State
. To make it more user-friendly, we can allow users to receive only part of the state. For example:
1 | struct MyShared { |
Similar to FromRequest
, we can define a trait to allow derivation of a sub-structure from a larger one.
1 | // In 'from' style |
Comparison between FromRef and Param Styles
Similar to the relationship between From
and Into
, we can provide these two stylistic traits. Both are equivalent; one simply needs to choose which to use.
Inside Axum, the FromRef
definition is used, while in linkerd2-proxy
we can find the Param
style definition.
Which of these two is better? That depends on personal preference (the standard library advocates for defining From
)~
Note: This doesn’t violate the orphan rule, as although neither FromRef
nor String
are defined by us, because Example
is, FromRef<Example>
isn’t considered a foreign type.
1 | struct Example { |
Or:
1 | struct Example { |
We choose the Into
style Param
trait to implement the functionality of this section:
1 | impl<S, I> FromRequest<S> for State<I> |
Routing
Routing is a system for combining and distributing Handlers.
We introduced the concept of handlers earlier with a simple route and discussed how the Handler
trait constrains and automatically implements Handlers for functions defined by users using macros. But there’s still a distance to go before we arrive at truly user-friendly routing.
Route Lookup and Merging
For route lookup, we can use the matchit
library. With matchit
, it allows us to register Paths and their corresponding Values. When we request a match for a certain path, it returns the Value corresponding to the matching Path to us. Matchit
is implemented based on a radix trie, which, compared to a HashMap
, supports prefix matching and parameter extraction (like /user/:id
).
A simple implementation would be to wrap matchit
for path matching. However, matchit
does not support iterating to get the Paths and Values registered which is a downside. Therefore, to support more features (like merging), we need to maintain our own complete mapping relationship.
In Axum, Handler
is represented as the Endpoint
type. The Router
maintains two pieces of information:
RouteId
→Endpoint
Node
- matchit
Router<RouteId>
for path matching RouteId
→Path
Path
→RouteId
- matchit
When looking up a path, we simply give it to the matchit Router
to find the corresponding RouteId
, then use RouteId
to find the Endpoint
.
When merging routes, suppose we want to merge B into A, we will traverse all routing information in B to get all the (RouteId, Path, Endpoint)
tuples, then try to merge them into A under the same Path (for different Methods with the same Path). If it doesn’t exist, we will create a new one.
For example, when merging /login
and /logout
, we would insert the corresponding Path and RouteId into the matchit Router
. However, when merging GET /login
and POST /login
, there’s no need to modify the matchit Router
, we just merge the corresponding Endpoint
.
Endpoint
Axum defines Endpoint
to support nested Routes and dispatching based on Methods.
1 | enum Endpoint<S, B> { |
Route
1 | pub struct Route<B = Body, E = Infallible>(BoxCloneService<Request<B>, Response, E>); |
Route
is an async fn(Request) -> Result<Response, Error>
, which can be used directly to handle requests. When we do not need to dispatch based on the Method
, or when dispatching has already been completed, we can direct the requests straight to the corresponding Route
for handling.
BoxedIntoRoute
1 | pub(crate) struct BoxedIntoRoute<S, B, E>(Box<dyn ErasedIntoRoute<S, B, E>>); |
BoxedIntoRoute erases the Handler type, so we cannot find any signs related to Handler on its generics, nor any associated types of Handler.
Its State is to be filled in. We can understand BoxedIntoRoute as a Route without an attached State. Once the State is attached, it becomes a real Route, which is what the “Into Route” in the name implies.
MethodRouter
1 | pub struct MethodRouter<S = (), B = Body, E = Infallible> { |
The MethodRouter contains various MethodEndpoints corresponding to different Methods. A MethodEndpoint might correspond to None (not set on the method), or it could be the previously mentioned Route or BoxedIntoRoute.
Routing Composition
Let’s take a look at an example borrowed from axum to see how the Router is actually created:
1 | async fn main() { |
First is
.route("/keys", get(list_keys))
, where the get function encapsulates a function into aBox
, which is then transformed intoMethodRouter<S, B, Infallible>
. This MethodRouter is subsequently inserted intoEndpoint::MethodRouter
via the route method. Since no State is attached at this point, its internal type corresponds to BoxedIntoRoute, which is the type that lacks State.Next, let’s look at
.with_state(Arc::clone(&shared_state))
:
1 | pub fn with_state<S2>(self, state: S) -> Router<S2, B> { ... } |
Here, an S type of state is attached, and the generic type is rewritten as the new type S2 (S2 is unconstrained at this time).
Inside with_state, all BoxedIntoRoute are attached with State and are transformed into the Route type.
- The
.nest
method accepts a prefix and a sub-router. At this point, all that is needed is to insert the sub-router as a NestedRouter. What’s important to note here is that before passing it to the sub-router, the prefix has to be additionally stripped off.
Middleware
Tower Service
Before delving into the routing system, let us first become familiar with the components involved.
Tower is a component for describing general logic within the Rust ecosystem. Tower provides a Service Trait that describes an asynchronous logic which takes an input Request and outputs a Result<Response, Error>
. Essentially, any logic where one input corresponds to one response can be described with this Trait.
1 | pub trait Service<Request> { |
The Service trait not only helps align interfaces between different components, but we can also utilize some auxiliary components from tower to compose logic. Tower provides Layer for decorating a Service, and additionally offers Stack to facilitate nesting between Layers.
1 | pub trait Layer<S> { |
To summarize briefly:
- Service describes a generic logic pattern of async fn (Request) →
Result<Response, Error>
. - Layer acts as a decorator for a Service; it takes a Service and can return a decorated Service.
- Stack is a composition of Layers and also implements the Layer trait itself. When applying a .layer, it will sequentially invoke all the layers contained within.
Since our architecture utilizes Service middleware, and because Route itself implements the Service trait, we can encapsulate the Route by receiving a layer.
For the currently State-lacking BoxedIntoRoute, we first temporarily store the layers, awaiting the filling of the State before we wrap them with the layer.
Server Creation and Usage
By combining the aforementioned routing, Handlers, and middleware, we create a Server. The Server allows users to bind and serve a MakeService:
1 | axum::Server::bind(&addr) |
IntoMakeService<S>
implements Service<T, Response = S, Error = Infallible>
.
Creating a Server
using Server::bind(…).serve(…)
results in a Server
that implements Future
, hence it can be directly awaited. In its Future
implementation, it first accepts connections and utilizes IntoMakeService<S>
to generate an S
, which is the Service
for handling requests (our router
).
Subsequently, the IO (connection) together with the handling Service
is wrapped in a hyper::Connecting
structure, forming a hyper::NewSvcTask
that is spawned.
The reading and writing on the connection, parsing, and other operations are handled by hyper
, which ultimately uses the parsed http::Request
to call our passed-in Service
, that is, Router
.
Router and State Generic Tricks
By this point, the article has essentially analyzed the following:
- How to constrain Handlers.
- How to perform routing lookup and management.
- How to support shared state.
While reading through the code of Axum, a clever use of generics regarding State caught my attention.
If you carefully read the previous sections about shared state, you would find that my approach is like this:
1 | async fn index() -> String { ... } |
In Axum, however, the following method is supported:
1 | let route = Route::new() |
In Axum, it’s possible to chain different Handlers and States in a fluent way. How is this achieved?
The new
method of Router
does not impose direct constraints on S
, implying that S
can be understood as any type (Any). When the route
method is called, the implementation of Handler
might impose constraints on S
(or might not). When calling the with_state
method, the type of S
is determined, at this point:
- It checks if the new
S
satisfies the constraints imposed earlier. - It resets the generic
S
toS2
(thus resetting associations and clearing constraints).
Ultimately, when the router
is being used, into_make_service
is called, and Router
must implement Service
. Both these implementations are for Router<()>
, meaning S
can be ()
.
Let’s consider a few scenarios:
- If a previously registered handler imposed constraints on
S
, butwith_state
was not called: thenS
has constraints (usually not()
since it would be meaningless). If we forget to attachstate
or attach state of a wrong type, compilation will fail. - If a previously registered handler did not impose constraints on
S
, andwith_state
was also not called: thenS
has no constraints and can indeed be()
. Thus, even without callingwith_state
, the code can compile. - If a previously registered handler imposed constraints on
S
, andwith_state
was subsequently called:with_state
will reset the constraints onS
, thereforeS
can be()
and compile. - As in the previous chain-call example: each call to
with_state
clears the previous constraints, and calling.route
afterward may impose new constraints, yet as long as finallywith_state
is called or the last series of.route
do not impose any constraints onS
, thenS
can be()
and the compilation passes.
Through this trick, Axum achieves static type checking for State, and supports not attaching State when it is not needed.
Note: This article was co-authored by @suikammd and myself.