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 {}
上述的写法存在了这么一个问题, url
和 host
都是 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
里进行提取,因此像 Url
、Host
这种类型在 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 前行,共勉。
一些资料
- https://www.reddit.com/r/rust/comments/11iz4k6/rust_state_management_pattern_like_tauri_axum_bevy/ 此帖子内涉及的两篇文章