Appearance
第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-macros和sqlx-macros-core必须拆成两份的底层原因;sqlx-cli的prepare子命令就是复用 core 的典型例子。=0.8.6精确版本锁定贯穿所有跨 crate 依赖,原因是sqlx-core在顶层注释里明确声明 不遵守 Semantic Versioning(sqlx-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 中的任意一个。 - 实现一个新驱动所需的最小接口:
Databasetrait 的全部关联类型 +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 时,就能立刻猜到背后的理由属于以下五类之一:
proc-macro = true的 crate 不能被当作普通库依赖。- 编译时 feature 互斥(比如 runtime、TLS)要求发布单元足够小。
- 可选依赖的拉取成本要求"不用的东西不编译"。
- 跨 workspace 复用(比如 CLI 工具)要求把算法实现挪出 proc-macro crate。
- 上游 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?两个原因:
稳定性边界。
sqlxcrate 承诺遵守 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 的存在就是为了把这层风险封掉。单一入口的学习成本。文档、示例、社区回答只需要说 "
use sqlx::Pool",不用解释 7 个 crate 各自是什么。
Facade 的代价是轻微的编译开销——多一层 pub use 对增量编译几乎无影响,但对 cargo doc 的合并是必要的(#[doc(inline)] 标注让 sqlx::Pool 的文档直接显示 sqlx_core::pool::Pool 的内容)。
这道边界贯穿所有 Rust 大型项目的常见做法——你会在 tokio、serde、axum 里看到完全一样的 facade + core 模式。《Tokio 源码深度解析》第 2 章讲 tokio crate 自身就是在 tokio-util / tokio-stream / tokio-macros 上的 facade 聚合层,结构和 sqlx 完全一致。
2.2.1 四个工程的 workspace 对照
把 sqlx 和本丛书其他几卷主角的 workspace 放一张表对比:
| 项目 | facade crate | 核心 crate | 宏 crate 对 | 独立可扩展点 |
|---|---|---|---|---|
| sqlx | sqlx | sqlx-core | sqlx-macros + sqlx-macros-core | 数据库驱动 crate |
| tokio | tokio | tokio 内部 modules | tokio-macros + #[tokio::main] | tokio-util / tokio-stream |
| serde | serde | serde 本身 | serde_derive + serde_derive_internals | data format crates(serde_json 等) |
| axum | axum | axum-core | axum-macros | axum-extra |
| hyper | hyper | hyper 本身(自带协议) | 无宏 | 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-cli 的 cargo 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-coresqlx-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_input 在 sqlx-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_internals、tokio-macros / tokio-internals(0.1 时代)、thiserror-impl——凡是你看到 xxx-macros 和 xxx-macros-core / xxx-impl 并列存在的,背后都是同一个 proc-macro 隔离约束。《Serde 元编程》第 3 章对 serde_derive_internals 的分析和本节的动机完全一致——值得对照阅读。
2.3.1 sqlx-macros-core 里的 DatabaseExt 与 FOSS_DRIVERS
sqlx-macros-core 是这套分层里最值得一读的地方。它有两个关键抽象:
DatabaseExt trait(sqlx-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! 宏识别,只有两条路:
- 把 TiDB 驱动提 PR 进
FOSS_DRIVERS(需要通过 launchbadge 的 code review 并保证长期维护)。 - 自己 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-core | 95 | 14 400 |
sqlx-postgres | 108 | 19 841 |
sqlx-mysql | 72 | 9 195 |
sqlx-sqlite | 51 | 10 061 |
三个驱动加起来 39 097 行,是 sqlx-core 的 2.7 倍。把它们塞回 sqlx-core 会带来两个问题:
- 可选依赖的爆炸。Postgres 驱动需要
md-5、hkdf、hmac、rand(for SCRAM 认证),MySQL 需要sha1、sha2(for caching_sha2_password),SQLite 需要libsqlite3-sys(C FFI)和flume(I/O 线程通道)。把这些全堆到sqlx-core,即便用 feature 屏蔽,也会让cargo tree变得又长又吓人——新手第一反应是"这么重,我真的需要吗?" - 独立演进的节奏。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 拆分的依赖是实打实的:
- 它通过
sqlx/anyfeature 支持多数据库。sqlx migrate run一个命令要能对 Postgres / MySQL / SQLite 都工作——这正是 §2.6 讨论的 Any 驱动的合理用例。 - 它需要调用
prepare子命令的离线逻辑——这部分代码实际上在sqlx-macros-core的query::QueryData::save里(sqlx-macros-core/src/query/data.rs:127-230左右)。sqlx-cli的prepare.rs会use sqlx_macros_core::...直接调用。这在一个"宏和实现不分离"的设计里是做不到的。 - 它通过 feature 级联把驱动选择外推到 CLI 用户:用户
cargo install sqlx-cli --no-default-features --features postgres,rustls,这两个 feature 各自 cascade 到sqlx/postgres和sqlx/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-coreto avoid breakages.
sqlx-core 的 trait 家族(Database、Executor、Connection)在每个 minor 版本都可能有 API 变化——0.7.x 到 0.8 就删掉了 impl Executor for &mut Transaction、改了 Arguments 的签名。这意味着 sqlx-postgres 0.8.6 和 sqlx-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 数量相当庞大:
- 数据库:
postgres、mysql、sqlite、sqlite-unbundled、any - 运行时:
runtime-tokio、runtime-async-std(互斥) - TLS 后端:
tls-native-tls、tls-rustls-ring、tls-rustls-aws-lc-rs、tls-none - 数据类型:
chrono、time、bigdecimal、rust_decimal、uuid、json、ipnet、ipnetwork、mac_address、bit-vec、bstr - 工具:
macros、derive、migrate、offline
组合起来上百种。它们之间不是独立的——sqlx[chrono] 应该同时在 sqlx-core、sqlx-postgres、sqlx-mysql、sqlx-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/chronofeature。如果sqlx-macros整个没启用,不要因为这条 feature 描述就把它拉起来。
如果没有 ?,写 sqlx-macros/chrono 就等价于同时启用 sqlx-macros 和它的 chrono feature——这会让用户以为"我只是想要 chrono 支持"的时候意外拉入整个 sqlx-macros(及其所有 proc-macro 基础设施,如 syn、quote、proc-macro2 的重编译)。
?/ 语法是 sqlx feature 系统里被最密集使用的符号。postgres、mysql、sqlite 这些 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-std 在 sqlx-core 里不仅是"拉起哪个运行时 crate"的开关——它们参与构建了一整套运行时抽象层,让 sqlx 的内部代码可以写一次、在两种运行时下都工作。
核心是 sqlx-core/src/rt/mod.rs:17-24 的 JoinHandle 枚举:
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)
}注意这里的两层决策:
- 编译期:
#[cfg]选择哪些分支进入最终二进制。 - 运行时:如果同时启用了
_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_rt(rt/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 章分析 PoolInner 的 spawn_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://)选择。
如果坚持编译期多态,你只有两条路:
- 写两份应用代码——
main_sqlite.rs和main_postgres.rs,各自用SqlitePool或PgPool。维护噩梦。 - 用泛型
fn main<P: Pool<DB>>()——但是SqlitePool和PgPool是不同的具体类型,没法在运行时选其一赋值给同一个变量。
唯一的出路是运行时多态:把具体驱动类型擦除成一个动态分发的 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——具体是 PgConnectionBackend、MySqlConnectionBackend 还是 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())
}OnceCell 是 once_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)是:
- 解析 URL,拿到 scheme 字符串。
- 从
DRIVERSOnceCell 取出已安装的驱动数组。 drivers.iter().find(|d| d.url_schemes.contains(&scheme))找到匹配的AnyDriver。- 调用该驱动的
connect闭包,得到Box<dyn AnyConnectionBackend>。
注意最后一步:同一个 AnyConnection struct 的 backend 字段在不同 URL 下装的是不同的具体类型——这就是"运行时多态"落在 Rust 里的具体形态。
2.6.4 Any 的代价
选 Any 等于接受四件事:
- 每次 async 调用多一次堆分配(
BoxFuture)。 - 类型系统信息丢失:
AnyRow::try_get::<i64, _>(0)得到的i64是通过ValueRef动态转换的——如果底层 DB 返回的是PgValue::Text,而你想要i64,失败时你只得到一个"conversion error",不像PgRow::try_get那样有精确的 OID 信息。 query!宏和 Any 不兼容:query_as!(User, "SELECT ...", ...)在编译期必须知道一个具体Database才能做 DESCRIBE;Any 模式只能用运行时query_as::<Any, User>(),放弃编译期校验。- 功能子集:每个驱动独有的特性(Postgres 的 LISTEN/NOTIFY、MySQL 的 COM_BINLOG、SQLite 的
pragma)在 Any 层都不可用——Any 只保留"所有驱动都支持"的交集。
所以 Any 的合理用例很窄:跨 DB 的管理工具(sqlx-cli 的 migrate run 就用 Any,因为它要对任何数据库都能跑)、通用的 SQL 执行器(比如某种 SQL REPL)、允许运行时换 DB 的小型应用。对于业务代码,几乎总是应该用具体驱动。
2.6.5 一次 AnyConnection::connect 的调用路径
把前面的理论用一次真实调用串起来。假设用户调 AnyConnection::connect("postgres://localhost/test"),会发生什么?
整条路径的精彩之处在 步骤 4–5:AnyDriver 结构体(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-xxx,lib.rs启用#。 - [ ] Cargo.toml 写
sqlx-core = "=0.8.6"(精确锁版本)。 - [ ] 定义一个公开的零大小类型
Xxx(例如pub struct Postgres;)作为Databasetrait 的实现锚点。
必须实现的 trait:
- [ ]
Database及其 11 个关联类型(Connection/Row/Column/TypeInfo/Value/ValueRef<'r>/Arguments<'q>/ArgumentBuffer<'q>/Statement<'q>/QueryResult/TransactionManager)——见sqlx-core/src/database.rs:72。 - [ ]
Connectiontrait——close/ping/begin/close_hard等。 - [ ]
TransactionManagertrait——begin/commit/rollback/start_rollback——见sqlx-core/src/transaction.rs:15。 - [ ]
Executortrait——通常借助impl<'c> Executor<'c> for &'c mut XxxConnection的 blanket 模式。 - [ ]
Row/Column/Value/ValueRef/TypeInfo/Arguments六个关联类型各自的 trait。 - [ ] 所有内置类型(
i16、i32、i64、f32、f64、bool、String、Vec<u8>、Option<T>)的Encode/Decode/Type。 - [ ] 可选:
chrono、uuid等常用类型的 feature gate 实现。
协议层:
- [ ] 客户端-服务端线路协议编解码(通常用
bytescrate 做 BufMut / Buf)。 - [ ] 认证(明文 / SCRAM / caching_sha2 / 等等)。
- [ ] 预处理 /
DESCRIBE/ 语句复用缓存(HasStatementCache标记 trait——见sqlx-core/src/database.rs:113)。
和 query! 宏的集成:
- [ ] 在
sqlx-macros-core/src/database/mod.rs的FOSS_DRIVERS常量里注册自己(这一步只有上游 sqlx 项目能做——第三方驱动要么 fork 要么接受query!宏不支持自己)。
Any 集成(可选):
- [ ] 为
XxxConnection实现AnyConnectionBackend(sqlx-core/src/any/connection/backend.rs:9)。 - [ ] 提供
declare_driver_with_optional_migrate!(PG_DRIVER = Postgres)这样的一行宏调用——见sqlx-core/src/any/driver.rs:13-24,它会根据migratefeature 生成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 是一个 crate。src/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.io。sqlx-postgres、sqlx-mysql、sqlx-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"]这里有两个细节:
- rustls 的 crypto 后端(
ringvsaws-lc-rs)通过rustls/aws-lc-rs这种 dependency feature forwarding 语法直接注入到rustls本身的 feature——sqlx-core不自己实现 crypto 选择逻辑,它只是把用户的选择转发给 rustls。 - 证书来源(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 拆解为五类设计理由,每一类都有具体的代码引用:
- Facade 与 core 的分离(§2.2):
sqlx-core不遵守 SemVer(sqlx-core/src/lib.rs:4-8),facade 层提供稳定入口;文档、社区、教程只需教一条use sqlx::...路径。 - proc-macro 的物理约束(§2.3):
proc-macro = true的 crate 无法被库代码 use,因此必须把算法挪到sqlx-macros-core;这也让sqlx-cli能复用完全相同的 SQL 校验逻辑。 - 驱动独立发布(§2.4):每个驱动都是 2–10 MB 的协议实现 + 外部依赖,单体 crate 会让不用它的用户付成本;独立发布也让各驱动的演进节奏解耦。
=0.8.6精确版本锁(§2.4.1):sqlx-core是 semver-exempt,驱动必须用=0.8.6锁住防止跨 minor 破坏。- feature gate 级联 +
?/弱依赖语法(§2.5):避免 feature 像瘟疫一样蔓延到未启用的 optional crate——sqlx-macros?/chrono的?就是一个具体约束。 - Any 驱动是唯一运行时多态特例(§2.6):
Box<dyn AnyConnectionBackend>+OnceCell<&'static [AnyDriver]>实现 URL-scheme-based 运行时分发;代价是 BoxFuture 堆分配、类型信息丢失、不兼容query!宏。 - 新增驱动的 checklist(§2.7):一份完整的 trait 实现清单——
Databasetrait 的 11 个关联类型、六个子 trait、所有基础类型的Encode/Decode/Type、可选的 Any 集成。
2.8.1 给自己项目的 workspace 决策 checklist
把本章的讨论反向收拢:当你自己设计一个中型 Rust 工程时,哪些信号提示你应该学 sqlx 把项目拆成多 crate?下面这份 checklist 从 sqlx 的每条边界里各萃取一条判据,任意一条命中即应该考虑拆分:
- proc-macro 内的算法要被二进制工具复用吗? 如果是(sqlx-cli 的
prepare场景),必须 拆xxx-macros和xxx-macros-core——不是 should,是 must,因为 proc-macro 物理约束。 - 项目有可选的重量级后端吗? 比如 sqlx 的 Postgres 需要 SCRAM 认证库、SQLite 要 C FFI。如果这些后端对"不用它的用户"会显著增加
Cargo.lock或编译时间,应该拆到独立 crate 用 optional dep 拉入。 - 项目有"协议无关 + 协议相关"的清晰分层吗? sqlx 的
Databasetrait /Executortrait 是协议无关的骨架,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"的类型系统基础。