Skip to content

第2章 Workspace 与 crate 边界

"Show me your crate graph, and I will tell you your architecture." —— 任何一个 Rust 项目的第一直觉

本章要点

  • sqlx 把一个数据库工具包拆成 7 个 crate,每一条边界都对应一个具体的 Rust 工具链约束或工程决策。
  • proc-macro = true 的 crate 不能被普通库 use——这是 sqlx-macrossqlx-macros-core 必须拆成两份的底层原因;sqlx-cliprepare 子命令就是复用 core 的典型例子。
  • =0.8.6 精确版本锁定贯穿所有跨 crate 依赖,原因是 sqlx-core 在顶层注释里明确声明 不遵守 Semantic Versioningsqlx-core/src/lib.rs:4-8)。
  • feature gate 使用 ?/ 级联语法sqlx[postgres]sqlx-postgres + sqlx-macros?/postgres——只在目标 crate 已启用时才向下传递 feature,避免无谓地拉起额外依赖。
  • Any 驱动是 sqlx 架构里唯一的"运行时多态"特例——用 Box<dyn AnyConnectionBackend> + OnceCell<&'static [AnyDriver]> 的组合,让同一个 AnyConnection 可以根据 URL scheme 在运行时分发到 Postgres / MySQL / SQLite 中的任意一个。
  • 实现一个新驱动所需的最小接口:Database trait 的全部关联类型 + Connection + TransactionManager + Encode/Decode/Type 对主要 Rust 类型——本章末尾给出一张 checklist。

2.1 问题引入:一个 crate 就搞不定吗

打开 ~/.cargo/registry/src/index.crates.io-*/ 目录,你会看到 sqlx 0.8.6 对应 7 个子目录:

sqlx-0.8.6/
sqlx-core-0.8.6/
sqlx-macros-0.8.6/
sqlx-macros-core-0.8.6/
sqlx-mysql-0.8.6/
sqlx-postgres-0.8.6/
sqlx-sqlite-0.8.6/

一个"在 Rust 里用异步的方式访问数据库"的工具包,拆出了 7 个独立发布的 crate。乍看之下这是过度设计——一个单体 crate 配几个 feature gate 不就够了?但仔细看每一条边界,你会发现它们不是架构师在白板上凭空画的。每一条都是 Rust 工具链的硬性约束、发布节奏的工程权衡、或者"想让某种用法成为可能"的实现前提。

这一章就把这 7 个 crate 的边界逐条拆开。读完后,你再看到一个 Rust 项目把一个看似简单的东西拆成多个 crate 时,就能立刻猜到背后的理由属于以下五类之一:

  1. proc-macro = true 的 crate 不能被当作普通库依赖。
  2. 编译时 feature 互斥(比如 runtime、TLS)要求发布单元足够小。
  3. 可选依赖的拉取成本要求"不用的东西不编译"。
  4. 跨 workspace 复用(比如 CLI 工具)要求把算法实现挪出 proc-macro crate。
  5. 上游 API 不稳定的子系统要求单独的版本线。

sqlx 的 7 个 crate 把这五条理由全用上了。

2.2 第一道边界:facade sqlx 与 core sqlx-core

用户代码里 Cargo.toml 写的是:

toml
[dependencies]
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "macros"] }

这里的 sqlx 就是 facade crate——它的 src/lib.rs 一共 174 行,前 50 行全部是 pub use sqlx_core::...。翻开 sqlx-0.8.6/src/lib.rs:12-38

rust
pub use sqlx_core::acquire::Acquire;
pub use sqlx_core::arguments::{Arguments, IntoArguments};
pub use sqlx_core::column::Column;
pub use sqlx_core::column::ColumnIndex;
pub use sqlx_core::connection::{ConnectOptions, Connection};
pub use sqlx_core::database::{self, Database};
pub use sqlx_core::describe::Describe;
pub use sqlx_core::executor::{Execute, Executor};
pub use sqlx_core::from_row::FromRow;
pub use sqlx_core::pool::{self, Pool};
// ... 继续 re-export 直到第 38 行

然后是按 feature re-export 驱动:

rust
#[cfg(feature = "postgres")]
#[doc(inline)]
pub use sqlx_postgres::{
    self as postgres, PgConnection, PgExecutor, PgPool, PgTransaction, Postgres,
};

整个 facade 没有任何业务逻辑——它的唯一价值是把 7 个 crate 的公共 API 压缩到一个统一的导入路径。用户写 use sqlx::{Pool, PgPool, query_as, FromRow} 就能同时拿到 core、postgres、macros 三个 crate 的符号。

为什么不直接让用户 use sqlx_core::Pool + use sqlx_postgres::PgPool?两个原因:

  1. 稳定性边界sqlx crate 承诺遵守 Semantic Versioning——0.8.x 之间 API 保持兼容。但 sqlx-core 明确声明不遵守 SemVer(sqlx-core/src/lib.rs:4-8 原文):

    rust
    //! ### Note: Semver Exempt API
    //! The API of this crate is not meant for general use and does *not* follow Semantic Versioning.
    //! The only crate that follows Semantic Versioning in the project is the `sqlx` crate itself.
    //! If you are building a custom SQLx driver, you should pin an exact version for `sqlx-core` to
    //! avoid breakages

    这条声明的含义是:用户如果直接 use sqlx_core::...,他就进入了"内部 API 使用者"的身份,每个 minor 版本都可能被破坏。facade 的存在就是为了把这层风险封掉。

  2. 单一入口的学习成本。文档、示例、社区回答只需要说 "use sqlx::Pool",不用解释 7 个 crate 各自是什么。

Facade 的代价是轻微的编译开销——多一层 pub use 对增量编译几乎无影响,但对 cargo doc 的合并是必要的(#[doc(inline)] 标注让 sqlx::Pool 的文档直接显示 sqlx_core::pool::Pool 的内容)。

这道边界贯穿所有 Rust 大型项目的常见做法——你会在 tokioserdeaxum 里看到完全一样的 facade + core 模式。《Tokio 源码深度解析》第 2 章讲 tokio crate 自身就是在 tokio-util / tokio-stream / tokio-macros 上的 facade 聚合层,结构和 sqlx 完全一致。

2.2.1 四个工程的 workspace 对照

把 sqlx 和本丛书其他几卷主角的 workspace 放一张表对比:

项目facade crate核心 crate宏 crate 对独立可扩展点
sqlxsqlxsqlx-coresqlx-macros + sqlx-macros-core数据库驱动 crate
tokiotokiotokio 内部 modulestokio-macros + #[tokio::main]tokio-util / tokio-stream
serdeserdeserde 本身serde_derive + serde_derive_internalsdata format crates(serde_json 等)
axumaxumaxum-coreaxum-macrosaxum-extra
hyperhyperhyper 本身(自带协议)无宏hyper-util

几条观察:

  • 只有 sqlx 把驱动拆成跨 crate 的独立发布。tokio 的 I/O 驱动全在 tokio 本身里,serde 的 data format crate(serde_json / serde_yaml)是第三方独立发布但不算"同一个项目的 crate"——sqlx 是唯一一个在一个项目里把主 crate(sqlx)和"可选后端"(sqlx-postgres 等)发布到 crates.io 并强制 =版本 锁定的。
  • 只有 sqlx 的宏 crate 对里,core 那一半是完全独立发布的serde_derive_internals 其实也是独立 crate,但社区极少有人直接用它(它主要是 serde-rs 内部复用);sqlx 则因为 sqlx-cli 这个 binary 要复用同一套逻辑,让 sqlx-macros-core 真正面向"外部复用"做了设计。
  • 所有项目都有 facade + 有核心 crate。这是 Rust 中型以上项目的默认骨架——没有 facade 的项目(比如 hyper-util 直接作为附属 crate 存在,不走 hyper::util::*)是少数。

这张表本身不是结论,它是让你看完本章后可以"拿其它项目做对比参考"的钥匙。本章讲 sqlx 的 crate 拆分策略在 Rust 生态里不是孤例——但 sqlx 是把这条拆分路径走得最彻底的一个。

2.3 proc-macro 的物理约束:为什么 sqlx-macros 要和 sqlx-macros-core

这是本章最重要的一条边界。理解它,你也就理解了 Rust 生态里所有"xxx-macros"和"xxx-macros-core"双 crate 模式的成因。

翻开 sqlx-macros-0.8.6/Cargo.toml,核心只有这几行:

toml
[package]
name = "sqlx-macros"
description = "Macros for SQLx, the rust SQL toolkit. Not intended to be used directly."

[lib]
proc-macro = true

[dependencies]
sqlx-core         = { version = "=0.8.6" }
sqlx-macros-core  = { version = "=0.8.6" }
proc-macro2       = { version = "1.0.79" }
quote             = "1.0.26"
syn               = { version = "2.0.52", features = ["full", "derive", "parsing"] }

注意 [lib] proc-macro = true。这个配置项会让 Cargo 用 --crate-type=proc-macro 编译这个 crate,产物是一个动态库(macOS 上是 .dylib),由 rustc 在编译期加载。proc-macro crate 的产物格式和普通库不同,因此任何非 proc-macro 的 crate 都不能用它作为依赖

这条限制带来一个直接问题:过程宏的实现代码永远无法被其他地方复用sqlx-clicargo sqlx prepare 子命令需要连数据库、执行 DESCRIBE、把结果序列化成离线 JSON——这和 query! 宏在编译期做的事几乎一模一样。但 sqlx-cli 是一个 binary crate,它不能 use sqlx_macros::...

解法是把算法搬到一个不带 proc-macro = true 的 crate 里:

sqlx-macros              (proc-macro = true, 仅 101 行)
  ├── sqlx-macros-core   (普通 crate, 实现 query!、FromRow、Encode 等)
  └── sqlx-core

sqlx-macros/src/lib.rs 的每一个 #[proc_macro] 函数都只做一件事——把参数转交给 sqlx-macros-core

rust
// sqlx-macros-0.8.6/src/lib.rs:7-24
#[cfg(feature = "macros")]
#[proc_macro]
pub fn expand_query(input: TokenStream) -> TokenStream {
    let input = syn::parse_macro_input!(input as query::QueryMacroInput);

    match query::expand_input(input, FOSS_DRIVERS) {
        Ok(ts) => ts.into(),
        Err(e) => {
            if let Some(parse_err) = e.downcast_ref::<syn::Error>() {
                parse_err.to_compile_error().into()
            } else {
                let msg = e.to_string();
                quote!(::std::compile_error!(#msg)).into()
            }
        }
    }
}

真正的 expand_inputsqlx-macros-core-0.8.6/src/query/mod.rs,3585 行代码全部在那边。sqlx-cli 的 Cargo.toml 有 [dependencies] sqlx-macros-core = "0.8.6"——它直接调用同一套 expand_input,只不过把结果写到磁盘上的 JSON 而不是生成 TokenStream。

这张图解释了 sqlx 为什么能同时支持"在线校验"和"离线缓存"两种模式——不是靠 sqlx-macros 自己的 if-else,而是靠 sqlx-macros-core 这层被双方(编译期宏 + CLI)共享。

这条模式在 Rust 生态里非常普遍:serde_derive / serde_derive_internalstokio-macros / tokio-internals(0.1 时代)、thiserror-impl——凡是你看到 xxx-macrosxxx-macros-core / xxx-impl 并列存在的,背后都是同一个 proc-macro 隔离约束。《Serde 元编程》第 3 章对 serde_derive_internals 的分析和本节的动机完全一致——值得对照阅读。

2.3.1 sqlx-macros-core 里的 DatabaseExtFOSS_DRIVERS

sqlx-macros-core 是这套分层里最值得一读的地方。它有两个关键抽象:

DatabaseExt traitsqlx-macros-core/src/database/mod.rs:16):

rust
pub trait DatabaseExt: Database + TypeChecking {
    const DATABASE_PATH: &'static str;
    const ROW_PATH: &'static str;

    fn db_path() -> syn::Path {
        syn::parse_str(Self::DATABASE_PATH).unwrap()
    }

    fn row_path() -> syn::Path {
        syn::parse_str(Self::ROW_PATH).unwrap()
    }

    fn describe_blocking(query: &str, database_url: &str) -> sqlx_core::Result<Describe<Self>>;
}

这个 trait 把"编译期宏能对数据库做的事"抽象出来:

  • DATABASE_PATH:驱动在用户代码里的 canonical 路径,例如 "::sqlx::postgres::Postgres"——宏生成 TokenStream 时要在 try_map(|row: PgRow| { ... }) 的类型位置塞入这个路径。
  • ROW_PATH:对应的 Row 类型路径,例如 "::sqlx::postgres::PgRow"
  • describe_blocking:同步版的 DESCRIBE——在 proc-macro 上下文里必须同步(因为 rustc 不会把 Future 跑到完成),所以每个驱动要提供一个阻塞版本。

注意 DatabaseExt 的超 trait 是 TypeChecking——这是 sqlx-core/src/type_checking.rs 定义的"把数据库类型映射到 Rust 类型字符串"的 trait(例如"OID 23 → i32")。DatabaseExt 继承它,确保编译期能从列描述反推 Rust 类型。

FOSS_DRIVERS 常量sqlx-macros-core/src/lib.rs:45-51):

rust
#[cfg(feature = "macros")]
pub const FOSS_DRIVERS: &[QueryDriver] = &[
    #[cfg(feature = "mysql")]
    QueryDriver::new::<sqlx_mysql::MySql>(),
    #[cfg(feature = "postgres")]
    QueryDriver::new::<sqlx_postgres::Postgres>(),
    #[cfg(feature = "_sqlite")]
    QueryDriver::new::<sqlx_sqlite::Sqlite>(),
];

这个常量就是 sqlx-macros 里 expand_query 的第二个参数。QueryDriver::new::<DB>()const fn——在 const context 把每个驱动的 describe_blocking 函数指针、URL schemes、数据库名收集起来。宏在运行时看一个 SQL 查询,根据 DATABASE_URL 的 scheme 找到对应的 QueryDriver,然后调用它的 describe。

这里要特别注意 FOSS_DRIVERS 这个名字——"Free and Open-Source Software"**——**它意味着有"非 FOSS"版本。launchbadge 确实维护了一个商业的 sqlx-macros-core fork,里面额外注册了 MSSQL、Oracle 等商业数据库的驱动。对外界而言,开源版本就只有这三家 FOSS 驱动能被 query! 宏识别。

这条边界的工程价值:第三方驱动作者(比如给 TiDB 写驱动)如果想让自己的驱动能被 query! 宏识别,只有两条路:

  1. 把 TiDB 驱动提 PR 进 FOSS_DRIVERS(需要通过 launchbadge 的 code review 并保证长期维护)。
  2. 自己 fork sqlx-macros,维护一个平行的 TIDB_DRIVERS 常量——用户就用 tidb_query! 而不是 sqlx::query!

这是 sqlx "编译期连数据库"这条路径的最刺眼代价:宏的驱动集合是一个闭合列表。它没有为第三方驱动留插件接口——不是不能留,而是留了就违反 proc-macro 的"纯编译期 + 有限连接"假设,风险太大。

2.4 驱动 crate 的独立发布:sqlx-postgres / sqlx-mysql / sqlx-sqlite

sqlx 0.6 之前,Postgres / MySQL / SQLite 的协议实现全在 sqlx-core 里靠 feature 切换。0.6 引入 "workspace 拆分" 但仍然在同一仓库;0.7 终于让这三个驱动各自独立发布到 crates.io。

为什么?观察 0.8.6 的代码量分布:

Crate文件数行数
sqlx-core9514 400
sqlx-postgres10819 841
sqlx-mysql729 195
sqlx-sqlite5110 061

三个驱动加起来 39 097 行,是 sqlx-core 的 2.7 倍。把它们塞回 sqlx-core 会带来两个问题:

  1. 可选依赖的爆炸。Postgres 驱动需要 md-5hkdfhmacrand(for SCRAM 认证),MySQL 需要 sha1sha2(for caching_sha2_password),SQLite 需要 libsqlite3-sys(C FFI)和 flume(I/O 线程通道)。把这些全堆到 sqlx-core,即便用 feature 屏蔽,也会让 cargo tree 变得又长又吓人——新手第一反应是"这么重,我真的需要吗?"
  2. 独立演进的节奏。2024 年 Postgres 的 pgvector 类型在 sqlx-postgres 0.7.x 里获得内建支持;同期 sqlx-mysql 没有任何更新。如果驱动都在 sqlx-core 里,"加 pgvector 支持"就必须同步触发 sqlx-core 的版本发布——每一个驱动改动都拖累整个生态。

0.7 之后的 crate 图是:

对用户而言,sqlx = { features = ["postgres"] } 只会拉入 sqlx-postgres;MySQL 和 SQLite 的依赖链完全不出现在你的 Cargo.lock 里。这是"用不到的东西不编译"这条 Rust 生态基本礼节在 sqlx 上的体现。

对驱动作者而言,这条边界让"发布一个新驱动"变成一件干净的事。如果你想给 ClickHouse / TiDB / DuckDB 写 sqlx 驱动,你只需要发一个 sqlx-duckdb crate,依赖 sqlx-core = "=0.8.6",实现 Database trait 及其全部关联类型——就这样。sqlx-core 本身不需要任何改动。这条单向依赖的干净程度,是"可扩展框架"的典型设计签名。

2.4.2 sqlx-cli:workspace 设计的真正受益者

为什么前面反复强调"sqlx-macros-core 要能被 CLI 复用"?打开 sqlx-cli/Cargo.toml 看它的依赖:

toml
[dependencies.sqlx]
workspace = true
default-features = false
features = ["runtime-tokio", "migrate", "any"]

[features]
default = ["postgres", "sqlite", "mysql", "native-tls", "completions", "sqlx-toml"]

mysql            = ["sqlx/mysql"]
postgres         = ["sqlx/postgres"]
sqlite           = ["sqlx/sqlite", "_sqlite"]
sqlite-unbundled = ["sqlx/sqlite-unbundled", "_sqlite"]

rustls     = ["sqlx/tls-rustls"]
native-tls = ["sqlx/tls-native-tls"]

sqlx-cli 作为一个 binary crate,对 workspace 拆分的依赖是实打实的:

  1. 它通过 sqlx/any feature 支持多数据库sqlx migrate run 一个命令要能对 Postgres / MySQL / SQLite 都工作——这正是 §2.6 讨论的 Any 驱动的合理用例。
  2. 它需要调用 prepare 子命令的离线逻辑——这部分代码实际上在 sqlx-macros-corequery::QueryData::save 里(sqlx-macros-core/src/query/data.rs:127-230 左右)。sqlx-cliprepare.rsuse sqlx_macros_core::... 直接调用。这在一个"宏和实现不分离"的设计里是做不到的。
  3. 它通过 feature 级联把驱动选择外推到 CLI 用户:用户 cargo install sqlx-cli --no-default-features --features postgres,rustls,这两个 feature 各自 cascade 到 sqlx/postgressqlx/tls-rustls——驱动的 feature 开关和 CLI 的 feature 开关用一套体系。

所以"把 macros-core 拆出来"这个决策真正的受益者不是 sqlx-macros 自己(它只是不得不这样做,因为 proc-macro 限制),而是 sqlx-cli——它能作为一个 binary crate 直接 import 整套 SQL 校验、离线缓存、描述缓存的实现,无需 fork 或重写。

本书不专门讲 sqlx-cli 的实现,但在第 20 章迁移系统里我们会再次遇到它——因为 cargo sqlx migrate 的命令行参数和 sqlx::migrate::Migrator 是手牵手的。

2.4.1 =0.8.6 精确版本锁定

翻开 sqlx-0.8.6/Cargo.toml 对驱动的依赖声明:

toml
[dependencies.sqlx-core]
version = "=0.8.6"

[dependencies.sqlx-macros]
version = "=0.8.6"
optional = true

[dependencies.sqlx-mysql]
version = "=0.8.6"
optional = true

[dependencies.sqlx-postgres]
version = "=0.8.6"
optional = true

[dependencies.sqlx-sqlite]
version = "=0.8.6"
optional = true

每一个都是 version = "=0.8.6"——前面带 =。这个符号在 Cargo 的版本语义里表示 "only and exactly 0.8.6",而不是通常的 caret 规则 "0.8.x 里的任何向后兼容版本"。

为什么这么严?回到 2.2 节引用的 sqlx-core/src/lib.rs:4-8

The API of this crate is not meant for general use and does not follow Semantic Versioning. [...] If you are building a custom SQLx driver, you should pin an exact version for sqlx-core to avoid breakages.

sqlx-core 的 trait 家族(DatabaseExecutorConnection)在每个 minor 版本都可能有 API 变化——0.7.x 到 0.8 就删掉了 impl Executor for &mut Transaction、改了 Arguments 的签名。这意味着 sqlx-postgres 0.8.6sqlx-core 0.8.5 可能编译不通过。Cargo 的 caret 规则(^0.8.6 允许 0.8.6 ≤ x < 0.9.0)无法区分"0.8.6 和 0.8.5 之间有破坏性变更"这种情况——=0.8.6 就是手动加上的防护栏。

这条做法对第三方驱动作者很重要:你给 ClickHouse 写驱动,Cargo.toml 里必须 sqlx-core = "=0.8.6",不要写 "0.8" 也不要写 "0.8.6"。否则用户一 cargo update,sqlx-core 可能被升到 0.8.7 而 sqlx-core 在那个版本改了某个 trait 方法签名,你的驱动就崩了。第 19 章 Any 驱动时我们还会再次看到这条约束的落地。

2.5 Feature gate 级联:?/ 语法

sqlx 0.8.6 的 feature 数量相当庞大:

  • 数据库postgresmysqlsqlitesqlite-unbundledany
  • 运行时runtime-tokioruntime-async-std(互斥)
  • TLS 后端tls-native-tlstls-rustls-ringtls-rustls-aws-lc-rstls-none
  • 数据类型chronotimebigdecimalrust_decimaluuidjsonipnetipnetworkmac_addressbit-vecbstr
  • 工具macrosderivemigrateoffline

组合起来上百种。它们之间不是独立的——sqlx[chrono] 应该同时在 sqlx-coresqlx-postgressqlx-mysqlsqlx-sqlite 里启用 chrono feature,因为每个驱动都要为 chrono::DateTime 实现 Encode / Decode

Cargo 的标准做法是在上层 feature 的定义里级联到下层 feature:

toml
# sqlx-0.8.6/Cargo.toml
[features]
chrono = [
    "sqlx-core/chrono",
    "sqlx-macros?/chrono",
    "sqlx-mysql?/chrono",
    "sqlx-postgres?/chrono",
    "sqlx-sqlite?/chrono",
]

注意 sqlx-macros?/chrono 这个写法里的 问号 ?。这是 Cargo 1.60(2022 年)之后引入的"weak dependency feature"语法,含义是:

只有在 sqlx-macros 这个 optional dependency 已经被启用时,才激活 sqlx-macros/chrono feature。如果 sqlx-macros 整个没启用,不要因为这条 feature 描述就把它拉起来。

如果没有 ?,写 sqlx-macros/chrono 就等价于同时启用 sqlx-macros 和它的 chrono feature——这会让用户以为"我只是想要 chrono 支持"的时候意外拉入整个 sqlx-macros(及其所有 proc-macro 基础设施,如 synquoteproc-macro2 的重编译)。

?/ 语法是 sqlx feature 系统里被最密集使用的符号。postgresmysqlsqlite 这些 optional driver 几乎全部通过 ?/ 级联 feature:

toml
mysql = [
    "sqlx-mysql",
    "sqlx-macros?/mysql",
]

这条规则读作:"启用 mysql → 拉入 sqlx-mysql(必然),且如果 sqlx-macros 恰好已经被启用(比如通过 macros feature),那再把它的 mysql 子 feature 也打开。"

实际编译时,sqlx-macros 是否启用取决于用户有没有写 features = ["macros"]。如果用户只写 features = ["mysql"],那 sqlx-macros 根本不会被拉入,?/ 就成为空操作。这种"只在邻居在家时敲门"的设计是正常 crate 特性治理的关键——否则 feature 会像瘟疫一样蔓延,一个用户问"为什么我的项目依赖里多了 syn 4.0",答案可能是"因为你启用了 uuid 这个和 syn 毫无关系的类型 feature"。

sqlx-core 内部,feature 更基础:

toml
# sqlx-core-0.8.6/Cargo.toml
[features]
_rt-tokio      = ["tokio", "tokio-stream"]
_rt-async-std  = ["async-std", "async-io"]
any            = []
json           = ["serde", "serde_json"]
migrate        = ["sha2", "crc"]
offline        = ["serde", "either/serde"]

前缀 _ 的 feature 是内部 feature,不在 sqlx facade 层暴露——用户应该通过 runtime-tokio-rustls 这种组合 feature 启用它们,而不是直接写 _rt-tokio。这是 Cargo 的社区约定:前缀下划线表示"私有约束,不稳定,随时可能改"。

2.5.1 运行时抽象:JoinHandle 枚举与延迟检测

_rt-tokio_rt-async-stdsqlx-core 里不仅是"拉起哪个运行时 crate"的开关——它们参与构建了一整套运行时抽象层,让 sqlx 的内部代码可以写一次、在两种运行时下都工作。

核心是 sqlx-core/src/rt/mod.rs:17-24JoinHandle 枚举:

rust
pub enum JoinHandle<T> {
    #[cfg(feature = "_rt-async-std")]
    AsyncStd(async_std::task::JoinHandle<T>),
    #[cfg(feature = "_rt-tokio")]
    Tokio(tokio::task::JoinHandle<T>),
    // `PhantomData<T>` requires `T: Unpin`
    _Phantom(PhantomData<fn() -> T>),
}

每个 arm 都被 #[cfg(feature = "...")] 守护——当两个 runtime feature 都没开的时候,枚举只剩 _Phantom 这一个"绝不构造"的变体。_Phantom 的存在是为了让编译器在两个 runtime 都没启用的情况下仍然能推导类型参数——没有它,JoinHandle<T> 就成了"没有任何 variant 用到 T 的 enum",会被编译器警告甚至拒绝。

rt::spawn 函数(rt/mod.rs:62-78)的路径更有意思:

rust
#[track_caller]
pub fn spawn<F>(fut: F) -> JoinHandle<F::Output>
where F: Future + Send + 'static, F::Output: Send + 'static,
{
    #[cfg(feature = "_rt-tokio")]
    if let Ok(handle) = tokio::runtime::Handle::try_current() {
        return JoinHandle::Tokio(handle.spawn(fut));
    }

    #[cfg(feature = "_rt-async-std")]
    {
        JoinHandle::AsyncStd(async_std::task::spawn(fut))
    }

    #[cfg(not(feature = "_rt-async-std"))]
    missing_rt(fut)
}

注意这里的两层决策

  1. 编译期:#[cfg] 选择哪些分支进入最终二进制。
  2. 运行时:如果同时启用了 _rt-tokio_rt-async-std(技术上允许),sqlx 会用 Handle::try_current() 判断当前 .await 是不是跑在 Tokio 里。如果是,走 Tokio;如果不是,fall through 到 async-std 分支。

这就是 sqlx 能"在 Tokio 项目里也让 async-std 用户能 .await 的代码"的原因——运行时检测用的是 Tokio 的 Handle::try_current,不是编译期的 feature flag。在实际部署里,99% 的项目只启用一个运行时 feature,这段检测在单 feature 下会 fold 成直接调用;但即便真的 both features on,也有一条 Tokio 优先的回退链。

最后看 missing_rtrt/mod.rs:140-145)——这是 sqlx 的"运行时缺失"错误信息:

rust
#[track_caller]
pub fn missing_rt<T>(_unused: T) -> ! {
    if cfg!(feature = "_rt-tokio") {
        panic!("this functionality requires a Tokio context")
    }

    panic!("either the `runtime-async-std` or `runtime-tokio` feature must be enabled")
}

如果你忘记开 runtime feature 就 pool.acquire().await,你会看到这行 panic——#[track_caller] 让 panic 的文件行号定位到调用方,不是这个 helper 函数本身。这种错误信息设计是 sqlx 对"新手第一次 compile 通过但 run 起来 panic"的主动防护。

这条运行时抽象也带来一个代价:每一次 .spawn 都要走 Handle::try_current(),这是一次 thread-local 查询。对于小规模 spawn 来说开销可以忽略,但如果某段代码在热路径上每请求 spawn 数十次(比如每个 Postgres 连接启动时的"pipeline worker"),这层开销会被放大。第 13 章分析 PoolInnerspawn_maintenance_tasks 时会再遇到它。

2.5.2 feature 组合的常见陷阱

结合级联与运行时抽象,用户 Cargo.toml 里常见的三个陷阱值得单独拎出来说:

陷阱 1:忘开 runtime-*

toml
sqlx = { version = "0.8", features = ["postgres", "macros"] }

编译通过。但第一次 pool.acquire().await 时程序 panic "either the runtime-async-std or runtime-tokio feature must be enabled"。因为 sqlx 里数据库 feature 和运行时 feature 是正交维度——没有任何 feature 组合默认启用 runtime。这是刻意设计:sqlx 不想猜你用 Tokio 还是 async-std。

陷阱 2:runtime feature 冲突但能编译

toml
sqlx = { version = "0.8", features = [
    "postgres", "macros",
    "runtime-tokio-rustls",
    "runtime-async-std-rustls",  # 同时开两个
]}

Cargo 不会报错(sqlx 没有用 required-features = ["A", "!B"] 这种互斥声明,因为 Cargo 还不支持)。编译能通过,但运行时 rt::spawn 会按 2.5.1 描述的逻辑优先选 Tokio——如果你其实想要 async-std,行为就悄悄错了。实际处理办法是用户自己在 Cargo.toml 里只选一个。

陷阱 3:default-features = false 之后忘了重开 macros

toml
sqlx = { version = "0.8",
         default-features = false,
         features = ["postgres", "runtime-tokio-rustls"] }

默认的 default = ["any", "macros", "migrate", "json"] 被关掉了,query! 宏不可用。用户看到的错误是 cannot find macro query in this scope——不会直接提示"请开 macros feature"。这也是为什么 sqlx 官方 README 强烈推荐不要关 default features,而是用完整的 features = ["runtime-tokio-rustls", "postgres"] 在默认之上叠加。

2.6 Any 驱动:运行时多态的"叛徒"

读到这里,sqlx 的设计哲学似乎很清楚:一切都是编译期泛型、一切都是类型参数 DB: Database、零运行时分发开销。但 src/any/ 这个目录打破了这个统一——它故意反着来。

2.6.1 动机:runtime 选 DB

考虑这个需求:一个产品需要在"本地测试用 SQLite、生产跑 Postgres"两种场景下工作,运行时通过 DATABASE_URL 的 scheme(sqlite:// vs postgres://)选择。

如果坚持编译期多态,你只有两条路:

  1. 写两份应用代码——main_sqlite.rsmain_postgres.rs,各自用 SqlitePoolPgPool。维护噩梦。
  2. 用泛型 fn main<P: Pool<DB>>()——但是 SqlitePoolPgPool不同的具体类型,没法在运行时选其一赋值给同一个变量。

唯一的出路是运行时多态:把具体驱动类型擦除成一个动态分发的 trait 对象。这就是 Any 驱动存在的理由。

2.6.2 实现:Box<dyn AnyConnectionBackend> + OnceCell

打开 sqlx-core/src/any/connection/mod.rs:26-28

rust
pub struct AnyConnection {
    pub(crate) backend: Box<dyn AnyConnectionBackend>,
}

backend 是一个 trait object——具体是 PgConnectionBackendMySqlConnectionBackend 还是 SqliteConnectionBackend,在运行时由 URL scheme 决定。AnyConnectionBackend 定义在 sqlx-core/src/any/connection/backend.rs:9

rust
pub trait AnyConnectionBackend: std::any::Any + Debug + Send + 'static {
    fn name(&self) -> &str;
    fn close(self: Box<Self>) -> BoxFuture<'static, crate::Result<()>>;
    fn close_hard(self: Box<Self>) -> BoxFuture<'static, crate::Result<()>>;
    fn ping(&mut self) -> BoxFuture<'_, crate::Result<()>>;
    fn begin(&mut self, statement: Option<Cow<'static, str>>) -> BoxFuture<'_, crate::Result<()>>;
    fn commit(&mut self)   -> BoxFuture<'_, crate::Result<()>>;
    fn rollback(&mut self) -> BoxFuture<'_, crate::Result<()>>;
    fn start_rollback(&mut self);
    // ... 一共十几个方法
}

每个方法都用 BoxFuture 而不是 impl Future——因为trait object 不能有泛型方法返回 impl Future,而 BoxFuture = Pin<Box<dyn Future + Send>> 可以。代价是每次 await 都多一次堆分配。这是动态分发在异步代码里的额外税。

2.6.3 驱动注册:install_drivers + OnceCell

AnyConnection 本身不知道 Postgres / MySQL / SQLite 长什么样——它只知道"有一组静态驱动可用"。谁来告诉它这组驱动?install_drivers 函数(sqlx-core/src/any/driver.rs:125-133):

rust
static DRIVERS: OnceCell<&'static [AnyDriver]> = OnceCell::new();

pub fn install_drivers(
    drivers: &'static [AnyDriver],
) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
    DRIVERS
        .set(drivers)
        .map_err(|_| "drivers already installed".into())
}

OnceCellonce_cell crate 的一次性写入原语——第一次 set 成功,第二次返回错误。用户必须在 main() 开始时调一次:

rust
sqlx::any::install_default_drivers();  // 或手动 install_drivers(&[PG_DRIVER, MYSQL_DRIVER])

然后 AnyConnection::connect("postgres://...") 的路径(sqlx-core/src/any/driver.rs:141-154)是:

  1. 解析 URL,拿到 scheme 字符串。
  2. DRIVERS OnceCell 取出已安装的驱动数组。
  3. drivers.iter().find(|d| d.url_schemes.contains(&scheme)) 找到匹配的 AnyDriver
  4. 调用该驱动的 connect 闭包,得到 Box<dyn AnyConnectionBackend>

注意最后一步:同一个 AnyConnection struct 的 backend 字段在不同 URL 下装的是不同的具体类型——这就是"运行时多态"落在 Rust 里的具体形态。

2.6.4 Any 的代价

选 Any 等于接受四件事:

  1. 每次 async 调用多一次堆分配BoxFuture)。
  2. 类型系统信息丢失AnyRow::try_get::<i64, _>(0) 得到的 i64 是通过 ValueRef 动态转换的——如果底层 DB 返回的是 PgValue::Text,而你想要 i64,失败时你只得到一个"conversion error",不像 PgRow::try_get 那样有精确的 OID 信息。
  3. query! 宏和 Any 不兼容query_as!(User, "SELECT ...", ...) 在编译期必须知道一个具体 Database 才能做 DESCRIBE;Any 模式只能用运行时 query_as::<Any, User>(),放弃编译期校验。
  4. 功能子集:每个驱动独有的特性(Postgres 的 LISTEN/NOTIFY、MySQL 的 COM_BINLOG、SQLite 的 pragma)在 Any 层都不可用——Any 只保留"所有驱动都支持"的交集。

所以 Any 的合理用例很窄:跨 DB 的管理工具sqlx-climigrate run 就用 Any,因为它要对任何数据库都能跑)、通用的 SQL 执行器(比如某种 SQL REPL)、允许运行时换 DB 的小型应用。对于业务代码,几乎总是应该用具体驱动。

2.6.5 一次 AnyConnection::connect 的调用路径

把前面的理论用一次真实调用串起来。假设用户调 AnyConnection::connect("postgres://localhost/test"),会发生什么?

整条路径的精彩之处在 步骤 4–5AnyDriver 结构体(sqlx-core/src/any/driver.rs:29-35)持有一个 DebugFn<fn(&AnyConnectOptions) -> BoxFuture<'_, Result<AnyConnection>>> 函数指针。这个函数指针在驱动注册时(通过 declare_driver_with_optional_migrate!)就被 const-initialized:

rust
// sqlx-core/src/any/driver.rs:38-50
impl AnyDriver {
    pub const fn without_migrate<DB: Database>() -> Self
    where
        DB::Connection: AnyConnectionBackend,
        <DB::Connection as Connection>::Options:
            for<'a> TryFrom<&'a AnyConnectOptions, Error = Error>,
    {
        Self {
            name: DB::NAME,
            url_schemes: DB::URL_SCHEMES,
            connect: DebugFn(AnyConnection::connect_with_db::<DB>),
            migrate_database: None,
        }
    }
}

注意这是个 const fn——AnyDriver 值本身可以在 static 位置被创建(于是 OnceCell<&'static [AnyDriver]> 里存的是真正静态存储的数组,不需要堆分配)。connect_with_db::<DB> 这个泛型函数指针在"所有驱动注册完成"那一刻就被 mono-morphize 出 connect_with_db::<Postgres>connect_with_db::<MySql>connect_with_db::<Sqlite> 三个实例,各自存在一个 AnyDriver 常量里。

这种"编译期 monomorphize + 运行时 indirect call"的混合设计是 Any 架构的关键——每次 connect 的分发只是一次数组查找 + 一次函数指针调用(两个小开销),而不是 trait object 的虚表查找(开销稍高)。它代表了 sqlx 对"运行时多态成本"的精打细算。

第 19 章会单独展开 Any 的实现细节——特别是它如何把 PgArguments 编码到 AnyArguments、如何处理"Postgres 有 ARRAY 类型而 MySQL 没有"这种方言差异。

2.7 新增一个驱动需要实现什么:最小 Checklist

把前面的所有边界串起来,我们能画出一张"假设我要给 X 数据库写 sqlx 驱动"的 checklist:

Crate 结构:

  • [ ] 新建 crate sqlx-xxxlib.rs 启用 #![forbid(unsafe_code)](或不启用,视驱动是否需要 FFI)。
  • [ ] Cargo.toml 写 sqlx-core = "=0.8.6"(精确锁版本)。
  • [ ] 定义一个公开的零大小类型 Xxx(例如 pub struct Postgres;)作为 Database trait 的实现锚点。

必须实现的 trait:

  • [ ] Database 及其 11 个关联类型(Connection / Row / Column / TypeInfo / Value / ValueRef<'r> / Arguments<'q> / ArgumentBuffer<'q> / Statement<'q> / QueryResult / TransactionManager)——见 sqlx-core/src/database.rs:72
  • [ ] Connection trait——close / ping / begin / close_hard 等。
  • [ ] TransactionManager trait——begin / commit / rollback / start_rollback——见 sqlx-core/src/transaction.rs:15
  • [ ] Executor trait——通常借助 impl<'c> Executor<'c> for &'c mut XxxConnection 的 blanket 模式。
  • [ ] Row / Column / Value / ValueRef / TypeInfo / Arguments 六个关联类型各自的 trait。
  • [ ] 所有内置类型(i16i32i64f32f64boolStringVec<u8>Option<T>)的 Encode / Decode / Type
  • [ ] 可选:chronouuid 等常用类型的 feature gate 实现。

协议层:

  • [ ] 客户端-服务端线路协议编解码(通常用 bytes crate 做 BufMut / Buf)。
  • [ ] 认证(明文 / SCRAM / caching_sha2 / 等等)。
  • [ ] 预处理 / DESCRIBE / 语句复用缓存(HasStatementCache 标记 trait——见 sqlx-core/src/database.rs:113)。

query! 宏的集成:

  • [ ] 在 sqlx-macros-core/src/database/mod.rsFOSS_DRIVERS 常量里注册自己(这一步只有上游 sqlx 项目能做——第三方驱动要么 fork 要么接受 query! 宏不支持自己)。

Any 集成(可选):

  • [ ] 为 XxxConnection 实现 AnyConnectionBackendsqlx-core/src/any/connection/backend.rs:9)。
  • [ ] 提供 declare_driver_with_optional_migrate!(PG_DRIVER = Postgres) 这样的一行宏调用——见 sqlx-core/src/any/driver.rs:13-24,它会根据 migrate feature 生成 AnyDriver 常量。

看到这张 checklist 的长度你就能理解为什么"为 TiDB 写一个 sqlx 驱动"不是周末项目——sqlx-postgres 用了 108 个文件、近两万行代码才完成这些。但每一条的位置都清晰——不是遍布在一个巨大单体 crate 的各个角落,而是按照 sqlx-core 的 trait 边界组织。这就是 workspace 拆分的工程价值:它把"实现一个新驱动"变成一件有 checklist 的事

2.7.1 workspace 的版本演进

用 git 看 sqlx workspace 结构的演进,能发现几个有意思的切面:

0.5(2021-02)及之前:整个 sqlx 是一个 cratesrc/postgres/src/mysql/src/sqlite/ 是 sqlx 自己的子模块,各种 feature gate (#[cfg(feature = "postgres")]) 遍布代码树。cargo tree 对不用的人还不算太难看,但维护上,一次 Postgres 协议修改和 MySQL 无关的人也要同步被重编译。

0.6(2022-06):引入 sqlx-core 作为内部 workspace 成员——不独立发布,只在同仓库里抽象出去。此时 sqlx crate 还是内部包含所有驱动的主 crate,但 trait 定义挪到了 sqlx-core,为后续拆分铺路。这次重构是"把 Database trait 从 sqlx 本体移到 sqlx-core"——git log --follow sqlx-core/src/database.rs 应该能看到这个 commit。

0.7(2023-07)把驱动拆成独立的 crate 并发布到 crates.iosqlx-postgressqlx-mysqlsqlx-sqlite 成为独立发布单位,sqlx-core 也变成用户可见的 crate(但标注 semver-exempt)。这次拆分也带来了精确版本锁 =0.8.6 规范的定型——因为从这版开始,驱动 crate 的版本号和 core 必须完全同步。

0.8(2024-07):workspace 结构基本稳定,但 crate 之间的 trait 边界做了大改动——如第 1 章讨论的 Executor impl 从 Transaction 上移除。这次变更的根因正是 0.7 的拆分带来的:blanket impl 跨 crate 造成 coherence 冲突的可能性大幅增加,0.8 不得不做一次"trait 合约"的重新划线。

0.8.6(2025-05-19,本书版本):累积了大量小修——主要是 sqlx-postgres 的 pgvector 类型注册、sqlx-sqlite 的 preupdate hook feature、sqlx-macros-core 的错误信息改进。这些改动都在单独的驱动 crate 里完成,不牵动 core——这正是 0.7 拆分的设计目标兑现:改 Postgres 的时候不用发 MySQL 和 SQLite 的新版本。

看完这条演进线,你会发现本章讨论的所有边界不是一次性设计出来的——它们是在一次次发布压力下逐步长出来的。sqlx 的 workspace 不是白板设计的产物;它是一个"持续拆分"演化过程的当前状态。这也是为什么 0.9.0-alpha.1 可能还会继续拆(传闻是把 Any 驱动进一步从 sqlx-core 里独立出去)。

2.7.2 TLS feature 树:另一条正交维度

workspace 拆分之外,sqlx 还有一个值得单拉出来说的维度——TLS 后端。看 facade 里的 TLS 相关 feature:

toml
# sqlx-0.8.6/Cargo.toml(节选)
[features]
tls-native-tls          = ["sqlx-core/_tls-native-tls", ...]
tls-rustls-ring         = ["sqlx-core/_tls-rustls-ring-webpki", ...]
tls-rustls-aws-lc-rs    = ["sqlx-core/_tls-rustls-aws-lc-rs", ...]
tls-none                = ["sqlx-core/_tls-none"]

runtime-tokio-native-tls      = ["runtime-tokio", "tls-native-tls"]
runtime-tokio-rustls          = ["runtime-tokio", "tls-rustls-ring"]
runtime-tokio-rustls-aws-lc-rs= ["runtime-tokio", "tls-rustls-aws-lc-rs"]
runtime-async-std-native-tls  = ["runtime-async-std", "tls-native-tls"]
runtime-async-std-rustls      = ["runtime-async-std", "tls-rustls-ring"]

TLS 和 runtime 是两个正交维度:2 个 runtime × 4 个 TLS = 8 种组合。sqlx 没有把 8 个组合都列成 feature——它只列了 5 个"复合 feature"作为用户入口,因为它们是官方实际测试过的。其它组合技术上可以通过分别指定 runtime-tokio + tls-rustls-aws-lc-rs 拼出来,但没有 shortcut。

sqlx-core 内部对 TLS 的对待和对 runtime 几乎对称:

toml
# sqlx-core-0.8.6/Cargo.toml
[features]
_tls-native-tls              = ["native-tls"]
_tls-none                    = []
_tls-rustls                  = ["rustls"]
_tls-rustls-aws-lc-rs        = ["_tls-rustls", "rustls/aws-lc-rs", "webpki-roots"]
_tls-rustls-ring-native-roots= ["_tls-rustls", "rustls/ring", "rustls-native-certs"]
_tls-rustls-ring-webpki      = ["_tls-rustls", "rustls/ring", "webpki-roots"]

这里有两个细节:

  1. rustls 的 crypto 后端ring vs aws-lc-rs)通过 rustls/aws-lc-rs 这种 dependency feature forwarding 语法直接注入到 rustls 本身的 feature——sqlx-core 不自己实现 crypto 选择逻辑,它只是把用户的选择转发给 rustls。
  2. 证书来源(webpki-roots vs native-certs)同样通过 feature 组合暴露。这让"企业环境里的自签证书"成为可选:_tls-rustls-ring-native-roots 用操作系统的信任链,适合跑在有企业 CA 的内网。

这套 feature 树看上去繁琐,但每一条都对应一个真实的部署差异:

  • 手机 App 打包给 iOS:通常选 native-tls(iOS Security Framework)以获得 App Store 审核友好性。
  • 在 AWS Lambda 上跑:通常选 rustls-aws-lc-rs(FIPS 兼容的 crypto 后端)以满足合规。
  • 企业内网自签证书:通常选 rustls-ring-native-roots(读系统证书)以免维护单独的 webpki 根证书列表。

作为 sqlx 用户,大多数时候不需要在意这么细——runtime-tokio-rustls 是最主流的选择。但知道这套树存在,能让你在部署遇到"TLS 握手失败"类问题时直接定位到 feature 层面。

2.8 本章小结

本章把 sqlx 的 7 个 crate 拆解为五类设计理由,每一类都有具体的代码引用:

  1. Facade 与 core 的分离(§2.2):sqlx-core 不遵守 SemVer(sqlx-core/src/lib.rs:4-8),facade 层提供稳定入口;文档、社区、教程只需教一条 use sqlx::... 路径。
  2. proc-macro 的物理约束(§2.3):proc-macro = true 的 crate 无法被库代码 use,因此必须把算法挪到 sqlx-macros-core;这也让 sqlx-cli 能复用完全相同的 SQL 校验逻辑。
  3. 驱动独立发布(§2.4):每个驱动都是 2–10 MB 的协议实现 + 外部依赖,单体 crate 会让不用它的用户付成本;独立发布也让各驱动的演进节奏解耦。
  4. =0.8.6 精确版本锁(§2.4.1):sqlx-core 是 semver-exempt,驱动必须用 =0.8.6 锁住防止跨 minor 破坏。
  5. feature gate 级联 + ?/ 弱依赖语法(§2.5):避免 feature 像瘟疫一样蔓延到未启用的 optional crate——sqlx-macros?/chrono? 就是一个具体约束。
  6. Any 驱动是唯一运行时多态特例(§2.6):Box<dyn AnyConnectionBackend> + OnceCell<&'static [AnyDriver]> 实现 URL-scheme-based 运行时分发;代价是 BoxFuture 堆分配、类型信息丢失、不兼容 query! 宏。
  7. 新增驱动的 checklist(§2.7):一份完整的 trait 实现清单——Database trait 的 11 个关联类型、六个子 trait、所有基础类型的 Encode/Decode/Type、可选的 Any 集成。

2.8.1 给自己项目的 workspace 决策 checklist

把本章的讨论反向收拢:当你自己设计一个中型 Rust 工程时,哪些信号提示你应该学 sqlx 把项目拆成多 crate?下面这份 checklist 从 sqlx 的每条边界里各萃取一条判据,任意一条命中即应该考虑拆分

  • proc-macro 内的算法要被二进制工具复用吗? 如果是(sqlx-cli 的 prepare 场景),必须xxx-macrosxxx-macros-core——不是 should,是 must,因为 proc-macro 物理约束。
  • 项目有可选的重量级后端吗? 比如 sqlx 的 Postgres 需要 SCRAM 认证库、SQLite 要 C FFI。如果这些后端对"不用它的用户"会显著增加 Cargo.lock 或编译时间,应该拆到独立 crate 用 optional dep 拉入。
  • 项目有"协议无关 + 协议相关"的清晰分层吗? sqlx 的 Database trait / Executor trait 是协议无关的骨架,sqlx-postgres 是协议相关的实现。这条分层是 sqlx-core 能存在的前提——如果你的项目里"协议"和"抽象"混在一起,拆分反而会把它们互相切断。
  • 想让第三方扩展点稳定下来吗? sqlx 让第三方 ClickHouse 驱动作者能以"只依赖 sqlx-core"的方式存在,这需要把扩展接口稳定在一个独立的低频发布 crate 上——拆分是这条路径的前提。
  • 想让不同子系统各自独立发布吗? 最后一条——sqlx-postgres 可以在不发 sqlx 新版本的情况下独立发一个 0.8.x 的补丁版本。你希望你的项目在 release cadence 上有这种灵活度吗?

如果全部都不命中,那单 crate + feature gate 就够了——过早拆分反而带来 =版本 锁定、跨 crate 依赖循环、feature 级联这些成本。sqlx 是因为每一条都命中才长成今天这样,不是因为"拆 crate 是好设计"。

2.9 下一章指路

下一章,我们进入本书第一个"原理级"话题:Database trait 如何用 11 个关联类型把 Row<'r>ValueRef<'r>Arguments<'q> 这些带生命周期的子类型收束成一个泛型参数 DB——这是 sqlx 所有上层 API 能够做到"一套代码支持多 DB"的类型系统基础。

基于 VitePress 构建