服务器函数

如果你正在创建的不仅仅是一个玩具应用程序,你的代码需要一直运行服务器上:从仅在服务器上运行的数据库读取或写入数据、使用你不想发送到客户端的库运行昂贵的计算、访问需要从服务器而不是客户端调用的 API(出于 CORS 原因或因为你需要存储在服务器上的 API 密钥,而且绝对不应该发送到用户的浏览器)。

传统上,这是通过将服务器和客户端代码分离,并设置诸如 REST API 或 GraphQL API 之类的东西来允许你的客户端获取和修改服务器上的数据来完成的。这很好,但它要求你在多个不同的地方编写和维护你的代码(用于获取的客户端代码,用于运行的服务器端函数),以及创建第三件事来管理,即两者之间的 API 契约。

Leptos 是众多引入服务器函数概念的现代框架之一。服务器函数有两个关键特征:

  1. 服务器函数与你的组件代码位于同一位置,因此你可以按功能组织你的工作,而不是按技术组织。例如,你可能有一个“暗模式”功能,应该在多个会话中保留用户的暗/亮模式首选项,并在服务器端渲染期间应用,这样就不会出现闪烁。这需要一个需要在客户端交互的组件,以及一些需要在服务器上完成的工作(设置一个 cookie,甚至可能将用户存储在数据库中)。传统上,此功能最终可能会分布在代码中的两个不同位置,一个在你的“前端”,一个在你的“后端”。使用服务器函数,你可能只会在一个 dark_mode.rs 中同时编写它们,然后忘记它。
  2. 服务器函数是同构的,即它们可以从服务器或浏览器调用。这是通过为两个平台生成不同的代码来完成的。在服务器上,服务器函数只是运行。在浏览器中,服务器函数的正文被替换为一个存根,该存根实际上向服务器发出一个获取请求,将参数序列化到请求中,并从响应中反序列化返回值。但在任何一端,都可以简单地调用该函数:你可以创建一个将数据写入数据库的 add_todo 函数,并简单地从浏览器中按钮上的点击处理程序中调用它!

使用服务器函数

其实,我挺喜欢这个例子的。它会是什么样子的呢?实际上,它非常简单。

// todo.rs

#[server(AddTodo, "/api")]
pub async fn add_todo(title: String) -> Result<(), ServerFnError> {
    let mut conn = db().await?;

    match sqlx::query("INSERT INTO todos (title, completed) VALUES ($1, false)")
        .bind(title)
        .execute(&mut conn)
        .await
    {
        Ok(_row) => Ok(()),
        Err(e) => Err(ServerFnError::ServerError(e.to_string())),
    }
}

#[component]
pub fn BusyButton() -> impl IntoView {
	view! {
        <button on:click=move |_| {
            spawn_local(async {
                add_todo("So much to do!".to_string()).await;
            });
        }>
            "Add Todo"
        </button>
	}
}

你会立即注意到这里有几件事:

  • 服务器函数可以使用仅限服务器的依赖项,例如 sqlx,并且可以访问仅限服务器的资源,例如我们的数据库。
  • 服务器函数是 async 的。即使它们只在服务器上执行同步工作,函数签名仍然需要是 async 的,因为从浏览器调用它们必须是异步的。
  • 服务器函数返回 Result<T, ServerFnError>。同样,即使它们只在服务器上执行不会失败的工作,也是如此,因为 ServerFnError 的变体包括在发出网络请求的过程中可能出错的各种情况。
  • 服务器函数可以从客户端调用。看看我们的点击处理程序。这段代码会在客户端运行。但它可以调用 add_todo 函数(使用 spawn_local 来运行 Future),就像它是一个普通的异步函数一样:
move |_| {
	spawn_local(async {
		add_todo("So much to do!".to_string()).await;
	});
}
  • 服务器函数是用 fn 定义的顶级函数。与事件监听器、派生信号和 Leptos 中的大多数其他内容不同,它们不是闭包!作为 fn 调用,它们无法访问你的应用程序的响应式状态或任何未作为参数传入的内容。再说一遍,这很有道理:当你向服务器发出请求时,服务器无法访问客户端状态,除非你显式发送它。(否则,我们必须序列化整个响应式系统,并在每次请求时通过网络发送它,这虽然可以为经典 ASP 提供一段时间服务,但这是一个非常糟糕的主意。)
  • 服务器函数的参数和返回值都需要使用 serde 进行序列化。同样,希望这很有道理:虽然函数参数通常不需要序列化,但从浏览器调用服务器函数意味着序列化参数并通过 HTTP 发送它们。

关于定义服务器函数的方式,还有几点需要注意。

  • 服务器函数是通过使用 #[server] 来注释顶级函数来创建的,该函数可以在任何地方定义。
  • 我们为宏提供了一个类型名称。类型名称在内部用作一个容器来保存、序列化和反序列化参数。
  • 我们为宏提供了一个路径。这是我们将在服务器上挂载服务器函数处理程序的路径的前缀。(请参阅 ActixAxum 的示例。)
  • 你需要将 serde 作为依赖项,并启用 derive 功能,以便宏正常工作。你可以使用 cargo add serde --features=derive 轻松地将其添加到 Cargo.toml 中。

服务器函数 URL 前缀

你可以选择定义一个特定的 URL 前缀,用于服务器函数的定义。 这是通过为 #[server] 宏提供一个可选的第二个参数来完成的。 默认情况下,URL 前缀将是 /api,如果未指定。 以下是一些示例:

#[server(AddTodo)]         // 将使用默认的 URL 前缀 `/api`
#[server(AddTodo, "/foo")] // 将使用 URL 前缀 `/foo`

服务器函数编码

默认情况下,服务器函数调用是一个 POST 请求,它将参数作为 URL 编码的表单数据序列化到请求体中。(这意味着服务器函数可以从 HTML 表单中调用,我们将在以后的章节中看到。)但也支持其他几种方法。我们可以选择为 #[server] 宏提供另一个参数来指定备用编码:

#[server(AddTodo, "/api", "Url")]
#[server(AddTodo, "/api", "GetJson")]
#[server(AddTodo, "/api", "Cbor")]
#[server(AddTodo, "/api", "GetCbor")]

这四个选项使用 HTTP 动词和编码方法的不同组合:

名称方法请求响应
Url(默认)POSTURL 编码JSON
GetJsonGETURL 编码JSON
CborPOSTCBORCBOR
GetCborGETURL 编码CBOR

换句话说,你有两个选择:

  • GET 还是 POST?这对浏览器或 CDN 缓存等内容有影响;虽然 POST 请求不应该被缓存,但 GET 请求可以被缓存。
  • 纯文本(使用 URL/表单编码发送的参数,作为 JSON 发送的结果)还是二进制格式(CBOR,编码为 base64 字符串)?

但请记住:Leptos 将为你处理此编码和解码的所有细节。当你使用服务器函数时,它看起来就像调用任何其他异步函数一样!

为什么不用 PUTDELETE?为什么用 URL/表单编码,而不是 JSON?

这些都是合理的问题。许多 Web 都是建立在 REST API 模式之上的,这些模式鼓励使用语义 HTTP 方法(如 DELETE)从数据库中删除项目,并且许多开发人员习惯于以 JSON 格式向 API 发送数据。

我们默认使用带有 URL 编码数据的 POSTGET 的原因是对 <form> 的支持。无论好坏,HTML 表单都不支持 PUTDELETE,也不支持发送 JSON。这意味着,如果你使用除 GETPOST 请求之外的任何带有 URL 编码数据的请求,它只有在 WASM 加载完成后才能工作。正如我们将在后面的章节中看到的那样,这并不总是一个好主意。

支持 CBOR 编码是出于历史原因;早期版本的服务器函数使用一种 URL 编码,该编码不支持嵌套对象(如结构体或向量)作为服务器函数的参数,而 CBOR 则支持。但请注意,CBOR 形式遇到了与 PUTDELETE 或 JSON 相同的问题:如果你的应用程序的 WASM 版本不可用,它们将无法优雅地降级。

服务器函数端点路径

默认情况下,将生成一个唯一的路径。你可以选择定义一个特定的端点路径,用于 URL 中。这是通过为 #[server] 宏提供一个可选的第四个参数来完成的。Leptos 将通过连接 URL 前缀(第二个参数)和端点路径(第四个参数)来生成完整路径。 例如,

#[server(MyServerFnType, "/api", "Url", "hello")]

将在 /api/hello 处生成一个接受 POST 请求的服务器函数端点。

我可以将相同的服务器函数端点路径与多个编码一起使用吗?

不可以。不同的服务器函数必须具有唯一的路径。#[server] 宏会自动生成唯一的路径,但如果你选择手动指定完整路径,则需要小心,因为服务器会按路径查找服务器函数。

关于安全性的重要说明

服务器函数是一项很酷的技术,但记住这一点非常重要。**服务器函数不是魔法;它们是定义公共 API 的语法糖。**服务器函数的_主体_永远不会公开;它只是你的服务器二进制文件的一部分。但是服务器函数是一个公开可访问的 API 端点,它的返回值只是一个 JSON 或类似的 blob。除非它是公开的,或者你已实施了适当的安全程序,否则不要从服务器函数中返回信息。这些程序可能包括对传入请求进行身份验证、确保适当的加密、限制访问速率等等。

将服务器函数与 Leptos 集成

到目前为止,我所说的一切实际上都与框架无关。(事实上,Leptos 服务器函数 crate 也已经集成到 Dioxus 中!)服务器函数只是一种定义类似函数的 RPC 调用的方法,它依赖于 HTTP 请求和 URL 编码等 Web 标准。

但在某种程度上,它们也提供了我们迄今为止故事中最后缺失的原语。因为服务器函数只是一个普通的 Rust 异步函数,所以它与我们之前讨论过的异步 Leptos 原语完美集成 之前。因此,你可以轻松地将你的服务器函数与应用程序的其余部分集成:

  • 创建调用服务器函数以从服务器加载数据的 resource
  • <Suspense/><Transition/> 下读取这些资源,以便在数据加载时启用流式 SSR 和回退状态。
  • 创建调用服务器函数以在服务器上修改数据的 action

本书的最后一节将通过介绍使用渐进增强的 HTML 表单来运行这些服务器操作的模式来使这一点更加具体。

但在接下来的几章中,我们将实际看一下你可能想用你的服务器函数做什么的一些细节,包括与 Actix 和 Axum 服务器框架提供的强大提取器集成的最佳方法。