Skip to content

第2章 Service trait:async fn(Req) -> Res 的协议无关抽象

2.1 先看一眼"答案"

这一章我们要拆开 Rust 异步服务生态里最重要的十行代码。为了避免把"工程史诗"讲得太虚,先把代码摆出来:

rust
// tower-service/src/lib.rs:322-367
pub trait Service<Request> {
    type Response;
    type Error;
    type Future: Future<Output = Result<Self::Response, Self::Error>>;

    fn poll_ready(&mut self, cx: &mut Context<'_>)
        -> Poll<Result<(), Self::Error>>;

    #[must_use = "futures do nothing unless you `.await` or poll them"]
    fn call(&mut self, req: Request) -> Self::Future;
}

就这些。

没有 default 方法,没有隐藏的 supertrait,没有 Send/Sync 的强制绑定,甚至没有 async 关键字——就一个 trait、三个关联类型、两个方法。但在这不到 400 字节的源码背后,栖身着整整一代 Rust 异步中间件的共识。

Tonic、Axum、Warp、Linkerd、Tower、reqwest、tower-http、twirp-rust、aws-smithy——全部建立在这十行之上。你每写一行 .await,都在间接地通过这几个符号。

那我们的任务就清楚了:把这十行一个字一个字读懂

2.2 为什么是三个关联类型

先看三个关联类型:

rust
type Response;
type Error;
type Future: Future<Output = Result<Self::Response, Self::Error>>;

为什么不是泛型参数?为什么不是 Box<dyn Future>?为什么不是 async fn?这三个问题每一个都值得展开。

2.2.1 关联类型 vs 泛型参数

对比一下两个写法:

rust
// 现实:Tower 用关联类型
pub trait Service<Request> {
    type Response;
    type Error;
    type Future: Future<Output = Result<Self::Response, Self::Error>>;
}

// 另一种可能性:全部泛型
pub trait Service<Request, Response, Error, Future: ...> {
}

差别:关联类型对某个具体的 impl唯一的——一个 Timeout<S> 只能有一个 Response 类型;而泛型参数允许一个类型对不同的 <Response, Error> 有多份 impl

对一个 Service 来说,关联类型是正确的选择:给定 MyService,它只会产出一种 Response。如果它能产出多种,那说明其实是两个服务。用关联类型迫使 API 使用者选择具体的响应类型,而不是让调用方自己推导。

这还带来一个语义收益:bound 可以直接写。你会经常在 Tower 生态里看到这样的 where 子句:

rust
S: Service<Req, Response = Response<Incoming>>

读起来就是"S 是一个接受 Req、返回 Response<Incoming> 的服务"。如果 Response 是泛型参数,你就得写 S: Service<Req, SomeResp> 再另外约束 SomeResp = Response<Incoming>——丑得多。

Request 之所以是泛型参数而不是关联类型,是因为一个服务可以同时实现多种请求类型。比如一个 Redis 客户端可以同时 impl Service<GetCmd>impl Service<SetCmd>,用同一个连接处理两种命令。)

2.2.2 type Future vs Pin<Box<dyn Future>>

type Future 是一个关联类型:在编译期决定每个 Service 具体的 Future 形状。另一个常见的做法是:

rust
// 另一个可能性:用 trait object
pub trait Service<Request> {
    fn call(&mut self, req: Request)
        -> Pin<Box<dyn Future<Output = Result<Response, Error>> + Send>>;
}

后者每一次 call 都会在堆上分配一个 Box、绕一层 vtable 查找。前者在编译期就知道具体类型——单态化之后连一次间接跳转都没有。

代价当然是:类型签名会非常复杂。看一下 tower::Timeout<S> 真实的 Future 类型:

rust
// tower/src/timeout/future.rs 简化版
pin_project! {
    pub struct ResponseFuture<T> {
        #[pin]
        response: T,
        #[pin]
        sleep: tokio::time::Sleep,
    }
}

当你把 Timeout<Retry<RateLimit<MyService>>> 堆起来,最终的 Future 类型是层层嵌套的结构体。编译期无忧、运行时零开销;代价是类型签名能绕地球三圈。Tower 为此在 utility 里提供了 BoxServiceBoxCloneService 当逃生舱——要类型擦除的时候显式装盒。

这是一个经典的 Rust 风格:"默认零成本,显式付费":不想看巨型类型?好,Box::new(service) as Box<dyn Service>——然后你就要付一次虚方法调用的代价。

2.2.3 为什么不是 async fn

到 2026 年,Rust 已经支持 trait 里的 async fn。一个很自然的问题是:既然 Rust 现在有了 async fn in trait(AFIT),Tower 为什么还不把 Service::call 改成 async fn call

答案分两层。

第一层,向后兼容Service 这个 trait 被整个 Rust 生态上万个 crate 依赖,tower-service 这个 crate 从 2016 年发布以来 API 没有发生过 breaking change。改一个方法签名会让所有依赖方同时升级——代价巨大。

第二层,async fn in trait 还没到"完全替代 type Future"的程度。到 2026 年,AFIT 仍有以下工程限制:

  • 没法写显式 Send/Sync boundasync fn foo(&self) 返回的 future 到底 SendSend?这取决于 &self 指向的数据。如果你在一个 tower-http 中间件里想要"这个 Service 的 Future 必须 Send(因为要跨线程调度)",你没法优雅地在 trait 上直接表达(社区现在用 trait_variant 之类 workaround)。这个细节在第 13 章对 hyper 1.0 的 Service 设计讨论里会再出现。
  • Future 类型无法命名。当你写 async fn call(&self, req: Request) -> Response,返回的 Future 类型没有公共名字;如果你在中间件里想改写这个 Future(比如包一层 timeout),就得把它包进 Box<dyn Future>,又回到堆分配。
  • 大部分下游项目还没有准备好。Axum 的 handler 抽象、tonic 的 server codegen、tower-http 的中间件都建立在 type Future 可命名的假设上。

所以现状是:Tower 的稳定版保持 type Future,同时 hyper 1.0 的 hyper::Service(第 13 章)已经开始尝试用 type Future + &self 做一个更现代的组合。这是一场未完成的迁移,Tower 大概率会在 1.0 以后跟进。

2.3 poll_ready 的灵魂

rust
fn poll_ready(&mut self, cx: &mut Context<'_>)
    -> Poll<Result<(), Self::Error>>;

这是 Tower 全书最容易被初学者跳过、最值得花时间读懂的方法。

2.3.1 它在问什么

表面看,poll_ready 在问:"这个服务准备好接受一个请求了吗?"更准确的语义是:

如果我现在调用 call(req),这个服务能立即接收这个请求,而不需要拒绝排队吗?

关键字是"立即"。call 总是同步返回一个 Future(它只是构造 future,不 await)——所以"立即"和 Future 会不会很快完成无关。poll_ready 问的是"call 能不能成功发出"。

2.3.2 为什么不在 call 里判断

既然中间件要能判断"满不满",为什么不在 call 里面写?比如:

rust
// 错误的设计
async fn call(&mut self, req: Request) -> Result<Response, Error> {
    if self.is_full() {
        return Err(Overloaded);
    }
    ...
}

问题在于:这没法传递背压。假设你在写一个连接池 client:

  • 你有 10 个空闲连接;
  • 调用方 spawn 100 个 task 同时调用 call
  • 最早的 10 个拿到连接,接下来 90 个被你拒绝;
  • 被拒绝的 90 个要么重试(变成 retry storm),要么放弃(用户请求失败)。

理想的行为是:让调用方等待,直到有连接可用再调用 call。这种"等待容量"的能力,必须发生在 call 之前,不是之后。这就是 poll_ready 存在的意义:

rust
// 正确的协议
loop {
    // 异步等待服务准备就绪
    futures::future::poll_fn(|cx| svc.poll_ready(cx)).await?;
    // 获得许可之后再 call
    let response = svc.call(req).await?;
    break response;
}

或者更惯用的写法:svc.ready().await?.call(req).await?readyServiceExt 上的便利方法)。

这里的关键在于:poll_ready 返回 Poll::Pending 时会注册 waker,意味着调用方可以把当前 task 挂起,等服务有余量了自己唤醒。这是一个纯粹的异步背压信号,没有任何阻塞、没有任何轮询、没有 retry storm

这个设计来自 Finagle 的经验——Carl Lerche 把它翻译到 Rust 类型系统里。可以理解成:poll_ready 是"许可发放",call 是"使用许可"。两步协议让容量管理和业务调用在时间上分离。

2.3.3 poll_ready 的几条铁律

Tower 文档里关于 poll_ready 的约束读起来像法律条文,每一条都是血的教训。摘录一下(源码注释在 tower-service/src/lib.rs:332-367):

  1. "Once poll_ready returns Poll::Ready(Ok(())), a request may be dispatched"——一旦报告就绪,你必须能接一个请求。
  2. "Until a request is dispatched, repeated calls to poll_ready must return either Poll::Ready(Ok(())) or Poll::Ready(Err(_))"——就绪状态是持久的,不会自动失效(除非出错)。
  3. "poll_ready may reserve shared resources"——如果你 poll_ready 返回 Ready,你可能已经预扣了某些资源(比如信号量许可、连接池里的一条连接)。
  4. "It is critical for implementations to not assume that call will always be invoked"——调用方可能在 poll_ready 之后不调用 call 就把 Service 扔了。你必须在 Drop 时释放预扣资源。
  5. "Implementations are permitted to panic if call is invoked without obtaining Poll::Ready(Ok(()))"——没 poll_readycall,后果自负。

第 5 条看起来很夸张,但它是整个协议的强制约束。你在 RateLimit::poll_ready 里耗费许可,在 call 里假设许可已发。如果有人不遵守协议直接 call,许可会被透支,程序就错了——panic! 是最坏但合理的反应。

第 3 条的"预扣语义"引申出第 4 条的"要释放资源"。合在一起告诉你:poll_readycall 是一对"开 - 闭"事务,你必须保证这对事务在所有代码路径(包括异常、drop)上都被正确处理。

2.3.4 真实场景:Buffer 中间件

为了把 poll_ready 的协议落到具体代码,我们看一眼 tower::Buffer——一个给任意 Service 加排队能力的中间件。

rust
// tower/src/buffer/service.rs 概念摘录
impl<T, Request> Service<Request> for Buffer<T, Request>
where T: Service<Request> + Send + 'static, ...
{
    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<...> {
        // 问 mpsc channel 有没有位置
        self.tx.poll_ready(cx).map_err(...)
    }

    fn call(&mut self, request: Request) -> Self::Future {
        // 把 (request, response_tx) 塞进 channel
        self.tx.send(...).unwrap();
        // 等 response_tx
        ResponseFuture { ... }
    }
}

Buffer 自己就是一个 poll_readycall 协议的典型:

  • poll_ready 只检查**"我能不能往 channel 里塞一个请求"**(即 channel 有没有空位);
  • 真正的业务逻辑(把请求送到真正的 inner service)发生在 channel 的消费端,由一个独立的 task 处理。
  • 如果 channel 满,poll_ready 返回 Pending 并注册 waker,调用方会被挂起直到有空位。

这是把背压传递给调用方的最小例子。第 6 章会完整读 Buffer 的源码。

2.4 call 的边界条件

rust
#[must_use = "futures do nothing unless you `.await` or poll them"]
fn call(&mut self, req: Request) -> Self::Future;

三件事值得注意。

2.4.1 #[must_use] 标注

返回值是一个 Future——如果你不 await 或者不 poll,什么都不会发生。在 Rust 里,futures are lazy#[must_use] 让编译器在你丢弃返回值时发出警告,避免"我 call 了怎么没反应"这种 bug。

这不是 Tower 独创的,整个 Rust async 生态都在用——tokio::spawn 返回的 JoinHandlefutures::future::ready 返回的 Ready——每一个都有 #[must_use]

2.4.2 &mut self 的坚持

tower::Service::call 签名里的 &mut self 是一个长期争议点。

&mut self 的意义是:同一时间只能有一个 call 在进行。你不能拿到一个 &mut MyService 然后并发地调用两次 call——borrow checker 会拦。如果你想"一个服务同时处理多个并发请求",你有两条路:

  1. Clonelet mut s1 = svc.clone(); 然后让每个并发任务持有自己的 clone。这是最常见的做法——Axum 的 RouterClone
  2. Arc + Mutex:共享一个 Service,用锁串行化访问。罕见。

&mut self 的"隐式串行化"带来了一个副作用:它强制 call 持有者有独占权,这让一些场景很自然。比如你在 call 里修改 service 内部的统计计数,不用 AtomicU64、不用 Mutex,直接写 self.count += 1 就行——因为 &mut self 保证你是唯一的 writer。

&mut self 也有代价:和 Rust 的 async fn 配合糟糕。如果你想写

rust
async fn call(&mut self, req: Request) -> Result<Response, Error> {
    let lock = self.some_lock.lock().await;  // 持锁跨越 await 点
    // ...
}

这里的 &mut self 会跨越 .await 存活——这让 borrow checker 和异步调度器难以协作。实践中 Tower 的中间件往往在 call不持有 &mut selfcall 只是同步构造一个 future,真正的 await 都发生在返回的 future 里(future 的状态由 pin_project! 管理,不涉及 self 的借用)。

这就是为什么 Tower 的中间件源码有那么多"构造 future"的模式——每个中间件定义自己的 type Futurecall 只做"初始化 future 的字段",所有 await 都在 future 的 poll 里发生。详见下一节的 Timeout 例子。

2.4.3 hyper 选择不用 &mut self

剧透一下第 13 章:hyper 1.0 的 Service trait 故意不用 &mut self

rust
// hyper/src/service/service.rs:32-57
pub trait Service<Request> {
    type Response;
    type Error;
    type Future: Future<Output = Result<Self::Response, Self::Error>>;

    fn call(&self, req: Request) -> Self::Future;  // &self,不是 &mut self
}

原因是 HTTP/2 的多路复用:在同一个 Connection<T, S> 上可能同时处理几十个 stream,每个 stream 都要调用同一个 Servicecall&mut self 会把这些调用强制串行化——整个 HTTP/2 的多路复用变得毫无意义。&self 让并发调用在类型系统层面被允许,对 HTTP/2 server 至关重要。

这个选择的代价是:你不能在 call 里修改 self。所有状态必须通过 Arc<Mutex> 或 atomic 共享。hyper::Servicetower::Service 之间这个"一个字符的区别",就是第 13 章的全部故事。

2.5 从零手写一个 Service

理论讲够了,我们实际手写一个 Service 把上面的协议串起来。目标:一个 HelloService,收到 http::Request<Incoming>,返回 http::Response<Full<Bytes>>

rust
use std::future::{Ready, ready};
use std::task::{Context, Poll};
use tower::Service;
use http::{Request, Response};
use http_body_util::Full;
use bytes::Bytes;
use hyper::body::Incoming;

#[derive(Clone)]
struct HelloService;

impl Service<Request<Incoming>> for HelloService {
    type Response = Response<Full<Bytes>>;
    type Error = std::convert::Infallible;
    type Future = Ready<Result<Self::Response, Self::Error>>;

    fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        Poll::Ready(Ok(()))  // 没有容量限制,永远就绪
    }

    fn call(&mut self, _req: Request<Incoming>) -> Self::Future {
        ready(Ok(Response::new(Full::new(Bytes::from("Hello")))))
    }
}

这段代码能跑起来,一个完整的 Tower Service 就完成了。注意几个实现细节:

  • type Future = Ready<...>:没有实际的异步工作,用 futures::future::ready 构造一个"立即完成"的 future。它是一个 struct Ready<T> 的具体类型,不走 heap——单态化后零开销。
  • poll_ready 永远返回 Ready:我们不做任何容量限制。任何并发调用都能立刻被接。
  • call 返回 future,不是 await:严格遵守协议——call 只构造,不等待。

把这个 Service 用在 hyper 上需要一步"桥接"(因为 hyper 1.x 的 Service trait 和 tower 的不一样,详见第 13 / 19 章),这里先不展开。

2.6 一个典型的中间件:Timeout 如何实现

现在我们读一段真实的 Tower 中间件源码:tower::Timeout。完整源码在 tower/src/timeout/mod.rsfuture.rs,版本 0.5.3。

2.6.1 结构定义

rust
// tower/src/timeout/mod.rs 关键片段
pub struct Timeout<T> {
    inner: T,
    timeout: Duration,
}

impl<T> Timeout<T> {
    pub const fn new(inner: T, timeout: Duration) -> Self {
        Timeout { inner, timeout }
    }
}

Timeout<T> 是一个 wrapper——持有被包裹的 Service T 和超时时长。T 是一个泛型参数,意味着 Timeout 不绑定任何具体协议:

rust
// 用在 HTTP 服务端
Timeout<HelloService>

// 用在 gRPC 客户端
Timeout<tonic::client::Grpc<Channel>>

// 用在 Redis 客户端
Timeout<redis::MultiplexedConnection>  // 如果它实现了 Service

2.6.2 Service impl

rust
impl<S, Request> Service<Request> for Timeout<S>
where
    S: Service<Request>,
    S::Error: Into<BoxError>,
{
    type Response = S::Response;
    type Error = BoxError;
    type Future = ResponseFuture<S::Future>;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        match self.inner.poll_ready(cx) {
            Poll::Pending => Poll::Pending,
            Poll::Ready(r) => Poll::Ready(r.map_err(Into::into)),
        }
    }

    fn call(&mut self, request: Request) -> Self::Future {
        let response = self.inner.call(request);
        let sleep = tokio::time::sleep(self.timeout);
        ResponseFuture::new(response, sleep)
    }
}

关键细节:

  1. Response = S::Response:响应类型不变——Timeout 不修改响应的形状,只可能在时间上拦截。
  2. Error = BoxError:错误类型被"擦除"成 Box<dyn Error + Send + Sync + 'static>。原因是 Timeout 自身可能产生 Elapsed 错误,又要把内层 S::Error 透传。两种不同类型的错误要合并成一种,最通用的做法是装盒。
  3. Future = ResponseFuture<S::Future>:这里是 Tower 的另一个典型模式——每个中间件定义自己的 Future 类型。不是 Pin<Box<dyn Future>>,不是 impl Future,而是一个命名的 struct。这是为了和稳定 trait + 关联类型配合(第 2.2.3 节讨论过)。
  4. poll_ready 透传:Timeout 本身不限制容量,直接把内层 S::poll_ready 的结果转发。map_err(Into::into) 是为了把 S::Error 装箱。
  5. call 里构造 future:构造一个 ResponseFuture,把内层响应 future 和 sleep future 绑在一起。注意:tokio::time::sleep 这一行不 await,它只是返回一个 Sleep future。

2.6.3 ResponseFuture 的 poll

rust
// tower/src/timeout/future.rs
pin_project! {
    pub struct ResponseFuture<T> {
        #[pin] response: T,
        #[pin] sleep: Sleep,
    }
}

impl<F, T, E> Future for ResponseFuture<F>
where F: Future<Output = Result<T, E>>, E: Into<BoxError>,
{
    type Output = Result<T, BoxError>;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        let this = self.project();

        // 1. 先 poll 业务 future
        match this.response.poll(cx) {
            Poll::Ready(v) => return Poll::Ready(v.map_err(Into::into)),
            Poll::Pending => {}
        }

        // 2. 业务没就绪,poll sleep
        match this.sleep.poll(cx) {
            Poll::Ready(_) => Poll::Ready(Err(Box::new(Elapsed(())))),
            Poll::Pending => Poll::Pending,
        }
    }
}

整个超时逻辑就这么几行:

  1. 先 poll 内层响应 future——如果业务已完成,立刻返回。
  2. 业务没完成,poll sleep——如果超时到了,返回 Elapsed 错误;否则挂起。

pin_project! 是一个关键细节——它让 ResponseFuture 安全地处理内部 !Unpin 的字段(Sleep!Unpin 的)。原理在卷三《Rust 编译器与运行时揭秘》第 10 章(Pin / Waker / Future)里讲过。

这段代码的美在于:没有一个 async/await,没有任何堆分配,没有任何运行时类型查询。整个 Timeout 就是 trait impl + 状态机,单态化后生成的机器码就是"手写一个 select(response, sleep)"。

2.7 Service 为什么能跨协议

到这里你应该能理解一个核心问题:为什么 Tower 的中间件能跨协议

答案是:中间件只看 Service<Request>,不关心 Request 是什么

我们来看 Timeout 的约束:

rust
impl<S, Request> Service<Request> for Timeout<S>
where S: Service<Request>, S::Error: Into<BoxError>,
{ ... }

整个 Timeout 的代码里,你找不到任何一处对 RequestResponse 具体类型的假设。它只需要:

  • 内层有一个 Service<Request> 能处理这个请求;
  • 内层的 Error 能被转成 BoxError

这是一个完全通用的 impl。因此你既可以这样用:

rust
// HTTP 服务端
let svc: Timeout<AxumRouter> = Timeout::new(router, Duration::from_secs(30));

也可以这样:

rust
// gRPC 客户端
let client: Timeout<GrpcClient> = Timeout::new(grpc, Duration::from_secs(5));

或这样:

rust
// 纯业务 Service
let handler: Timeout<HelloService> = Timeout::new(hello, Duration::from_millis(100));

这是 Service trait 最强的一个特性:它消除了"协议"这个维度。只要你能把一次请求-响应交互装进 call(Request) -> Future<Response> 这个形状,Tower 生态所有的中间件都是免费的。

反过来,这也给中间件作者提了一个要求:不要假设 Request 类型。不要写 req.headers()——那只对 HTTP 成立。不要假设 Clone——要就显式 bound。不要假设 Send——需要时再加。越是少假设,中间件的适用面越广。tower-http crate 存在的意义就是:那些确实需要 HTTP 特性的中间件(比如 CORS、Compression、Authz)被单独放一个 crate,保证 tower 核心 crate 是"协议无关"的纯抽象。

2.8 与 Serde 的 Serializer 对照

读过卷四《Serde 元编程》第 3 章(Serializer trait)的读者,会注意到 ServiceSerializer 之间一个深层的相似:

  • Serializer 解决 "M 种数据结构 × N 种格式" 的组合爆炸——把矩阵从 M×N 拆成 M+N。
  • Service 解决 "M 种协议 × N 种中间件" 的组合爆炸——同样把矩阵从 M×N 拆成 M+N。

两者的共同设计哲学是:用一个中立的 trait 定义"接触面",让两边独立演进。Serializer 是数据结构和格式的接触面,Service 是协议和中间件的接触面。任何一边的改变都不影响另一边。

更深一层:这两个 trait 都提供了"关联类型 + 泛型参数"的混合形式。Serializer 有 type Oktype Errortype SerializeSeq 等一系列关联类型,配合泛型的 serialize_i32 等方法;Service 有 type Responsetype Errortype Future 三个关联类型,配合泛型的 Request。这是 Rust trait 设计里的一个成熟模式:关联类型定义"这个实现会产出什么",泛型参数定义"这个实现接受什么"

每当你在 Rust 生态里看到一个 trait 使用这种"关联类型 + 泛型"混搭,大概率它也在解决某个 M×N 问题。这是 Rust 生态几大核心抽象(Serde、Tower、Futures)共享的工程美学。

2.9 小结:落到你键盘上

我们本章做了什么:

  1. Service trait 的十行源码逐字读了一遍——三个关联类型、两个方法、每一个的含义和边界条件。
  2. 深入 poll_ready 的设计哲学——它为什么存在、它和 call 之间的事务语义、它如何传递背压。
  3. 对比了 &mut self&self 的选择——Tower 为什么坚持 &mut self,hyper 1.0 为什么又选了 &self(预告第 13 章)。
  4. 手写了一个最小的 HelloService,读了真实的 tower::Timeout 源码。
  5. 理解了"Service 为什么能跨协议"——中间件只看 trait,不看具体 Request。

下一步落到你键盘上的三件事:

  • 克隆 tower 源码后读一遍 tower-service/src/lib.rs:整个文件 400 行,大部分是文档,代码部分一页就看完。你要把它从头到尾读一遍,不跳过。
  • cargo expand:找一个用了 async fnService::call 写法,展开看生成的状态机。你会看到 impl Future for ...GenFut { ... } 就是编译器为你的 async fn 生成的关联类型。
  • 手写一个 LoggingService:接受任意 Service<Request>,在 call 里打印 request、await 之后打印 response。看看你能不能在没有 Box<dyn Future> 的情况下写出来——type Future = impl Future<...> 在 Rust 2024 edition 已经稳定,可以用。

下一章,我们讲 Layer trait 和 ServiceBuilder——它们是怎样把"一个洋葱从外到内正向堆起来"的类型魔法。

基于 VitePress 构建