Rust 中的 Magic function params

在使用 rust 的 web 框架 axum 时,会看到这种神奇的写法,通过给路由函数的参数指定不同的数据类型,对应的路由函数就可以直接使用请求体里的相关数据了:

let router = Router::new()
        .route("/:user_id", get(path_handler))
        .route("/json", post(json_handler));

// 访问 url path
async fn path_handler(Path(user_id): Path<u32>) {}
// 访问请求体的 json 格式数据
async fn json_handler(Json(payload): Json<serde_json::Value>) {}

以及在之前研究 tauri 时,也碰到过类似的写法:

struct MyState(String);

#[tauri::command]
fn my_custom_command(state: tauri::State<MyState>) {
  assert_eq!(state.0 == "some state value", true);
}

这种可以随意传入且不需要考虑参数个数和位置的写法,也不禁让我想起了 nodejs 的http 服务端框架 nestjs 中频繁用到的依赖注入(DI / Dependency-Injection)模式,他们都是通过给函数参数指定要用到的依赖的数据类型,于是便能使用这个依赖了。不过 rust 里的写法看着并未涉及到依赖项的声明和依赖之间的引用定义等,两者像又不完全像。

那么这中写法到底是怎么实现的呢?带着强烈的好奇心和求知欲,和笔者一起探索一番吧。

常规写法

先让我们看看一个最“正常”和直观的写法是什么样的,我们以实现一个 http 服务为例(这里不会写到 http 的相关处理,仅是为了协助探索 magic function params 的实现原理):

/// 定义一个 http 服务器
struct FakeHttpServer {}

/// 定义 http 请求体
struct Request {
    url: String,
    host: String,
    query: HashMap<String, String>,
}

/// 为请求体实现一个默认值
impl Default for Request {
    fn default() -> Self {
        Self {
            url: "https://demo.com".into(),
            host: "127.0.0.1".into(),
            query: HashMap::default(),
        }
    }
}

impl FakeHttpServer {
    /// 构造一个服务器
    fn new() -> Self { Self {} }
    /// 监听端口
    fn listen(&self, port: usize) {}
    /// 注册路由
    fn route<F>(&self, method: &str, path: &str, handler: F)
    where
        // 路由处理函数:接收一个请求体,返回一个字符串响应
        F: Fn(Request) -> String,
    {
      // 处理请求
      handler(Request::default());
    }
}

fn main() {
    let server = FakeHttpServer::new();

    server.route("GET", "/empty_handler", handle_empty);
    server.route("GET", "/url_handler", handle_url);
    server.route("POST", "/host_handler", handle_host);

    server.listen(8080);
}

fn handle_empty(req: Request) -> String {
    "hello".into()
}
fn handle_url(req: Request) -> String {
    format!("url: {}", req.url)
}
fn handle_host(req: Request) -> String {
    format!("host: {}", req.host)
}

上述便是一个从阅读角度来说十分清晰明了的 http 服务器代码,其中路由处理函数通过读取 Request 的内部字段来获取所需的请求信息,比如路径、请求参数、请求头等。

Magic 写法

现在我们将其变成魔法参数的写法,即函数参数不再是 Request,而直接是我们想要的内容:

fn handle_empty() -> String {}
fn handle_url(url: String)  -> String {}
fn handle_url(host: String) -> String {}

上述的写法存在了这么一个问题, urlhost 都是 String 类型,从编译器角度来说如何知道开发者想要的是 url 还是 host 呢?基于变量名来区分显然是不合适的,因为命名总是因人而异的,rust 作为一个强类型语言,自然从类型的角度区分更为合理,我们可以将 String 封装为不同的 struct,因为没有其他属性,因此直接定义成 unit struct:

struct Url(String);
struct Host(String);

现在 rust 就可以明确知道我们需要的是 url 还是 host 了,将上述函数改写后如下:

fn handle_empty() -> String {
  "hello".into()
}
fn handle_url(url: Url) {
    format!("url: {}", url.0)
}
fn handle_host(host: Host) {
    format!("host: {}", host.0)
}

借助于 rust 强大的 pattern matching,可以进一步改写成:

fn handle_empty() -> String {
  "hello".into()
}
fn handle_url(Url(url): Url) {
    format!("url: {}", url)
}
fn handle_host(Host(host): Host) {
    format!("host: {}", host)
}

在修改之后,不出所料的编译报错了,因为改写后的路由函数和 Fn(Request) -> String 类型已经不匹配了,那么如何让 route() 函数接受这种参数类型和个数都不确定的类型呢?那便是使用 trait 来“抹平”差异:

在 rust 中,我们是可以为不同签名的函数实现 trait 的,知道这一点,那我们只需要让传入的路由函数都实现这个 Handler trait:

trait Handler {
    fn handle(self, ctx: &Request);
}

impl FakeHttpServer {
    // ...

    fn route<F>(&self, method: &str, path: &str, handler: F)
    where
        // 更新 route 的函数签名
        F: Handler,
    {
        // 更新调用
        handler.handle(Request::default());
    }
}

而针对路由函数的参数,则需要让每个参数都实现这个 FromRequest trait:

trait FromRequest {
    fn from_context(ctx: &Request) -> Self;
}

/// 从 request 中提取 url
impl FromRequest for Url {
    fn from_context(ctx: &Request) -> Self {
        Self(ctx.url.clone())
    }
}

/// 从 request 中提取 host
impl FromRequest for Host {
    fn from_context(ctx: &Request) -> Self {
        Self("Fake".into())
    }
}

从上述的 FromRequest 实现可以发现,其实我们的路由函数不论要获取什么字段,其实都是从 Request 里进行提取,因此像 UrlHost 这种类型在 rust 的那些开源框架中被称作了"提取器(extractor)"。

现在看下我们的路由函数如何改动,上述中我们的路由函数有两种签名,一个是不需要参数的,一个是需要传入一个参数的,现在我们为他们分别实现 Handler trait:

/// 针对无参的函数
impl<F> Handler for F
where
    F: Fn() -> String,
{
    fn handle(self, ctx: &Request) {
        self();
    }
}

/// 针对仅一个参数的函数
impl<F, A1> Handler for F
where
    F: Fn(A1) -> String,
    A1: FromRequest,
{
    fn handle(self, ctx: &Request) {
        self(A1::from_context(&ctx));
    }
}

上述写完后编译器会有一个报错:“error: the type parameter A1 is not constrained by the impl trait, self type, or predicates”,针对此问题我们需要给 Handler 添加一个不会实际使用的泛型参数,下面是修正后的内容:

trait Handler<A> {
    fn handle(self, ctx: &Request);
}

impl FakeHttpServer {
    // ...

    fn route<F, A>(&self, method: &str, path: &str, handler: F)
    where
        F: Handler<A>,
    {}
}

impl<F> Handler<()> for F
where
    F: Fn() -> String,
{
    fn handle(self, ctx: &Request) {
        self();
    }
}

impl<F, A1> Handler<A1> for F
where
    F: Fn(A1) -> String,
    A1: FromRequest,
{
    fn handle(self, ctx: &Request) {
        self(A1::from_context(&ctx));
    }
}

至此我们便算是完成了一个 demo 版本的 magic function params 了。读者们可能会问如果我要传入两个、三个、更多个参数呢,是不是要分班实现对应参数个数的 Handler

没错是的,但是不要慌,无论是支持几个参数,会发现代码逻辑都是相似的,于是自然会想到 使用 macro_rules 来减少重复代码的编写。若是对 macro_rules 了解不多,建议先前往官方文档阅读。现在我们来实现这个宏:

macro_rules! impl_handlers {
    ($arg1: ident $(, $( $args: ident ),*)?) => {
        impl<F, $arg1, $( $( $args, )*)?> Handler<($arg1$(, $( $args, )*)?)> for F
        where
            F: Fn($arg1, $( $( $args, )*)?) -> String,
            $arg1: FromRequest,
            $( $( $args: FromRequest, )* )?
        {
            fn handle(self, ctx: Request) {
                self($arg1::from_context(&ctx), $( $( $args::from_context(&ctx), )*)?);
            }
        }
    };
}

impl_handlers!(A1);
impl_handlers!(A1, A2);
impl_handlers!(A1, A2, A3);
impl_handlers!(A1, A2, A3, A4);
// impl_handlers!(A1, A2, A3, A4, ...);

然后记得移除原代码中的 impl<F, A1> Handler<A1> for F ..impl<F, A1, A2> Handler<(A1, A2)> for F.. 避免重复实现。那么现在就可以潇洒使用 magic params 了:

/// 肆意添加参数,肆意放置参数位置
fn handle_a_long_params(
    Url(url): Url,
    Host(host): Host,
    Url(url2): Url,
    Host(host2): Host,
) -> String {
    "long".into()
}

小结

因为本文中只处理了 url 和 host,所以来来回回就是在用这俩,实际当中则应当为所有常用的数据实现 FromRequest trait,比如请求头、请求体等。

本文中的实现仅是 demo 版本,因为还没有兼容处理比如报错、多线程等场景,并且由于没有实际编写 http 逻辑来具体使用路由函数,所以很多潜在场景并未考虑到,但是,核心思路便是如此了,完整的实现可以阅读现有的开源项目,比如 submillsecond、axum、actix-web 等。

在阅读这些源码时笔者内心感慨:这写的都是啥,为什么什么也看不懂(哭笑.gif)。因为这些开源库封装的太完善了,我们以 1 到 0 的思路去阅读显然会十分吃力,总之想要完全掌握某个东西,还需放平心态一步步地从 0 到 1 前行,共勉。


一些资料