SQLx 源码深度解析

第2章 Workspace 与 crate 边界

作者 杨艺韬 · 11,642 字 · 发布于 · 更新于

七个 crate,每一条边界背后都有一条具体的工具链约束。proc-macro = true 的 crate 只能导出过程宏,里面的普通函数和类型别的 crate 用不了——单是这一条,就决定了 sqlx-macros 必须拆成两份。

本章要点

  • sqlx 把一个数据库工具包拆成 7 个 crate,每一条边界都对应一个具体的 Rust 工具链约束或工程决策。
  • proc-macro = true 的 crate 只能导出过程宏、不能导出普通函数和类型——这是 sqlx-macros 和 sqlx-macros-core 必须拆成两份的底层原因;sqlx-macros-core 因此成为第三方驱动作者可以直接依赖、拼出自己那套 query! 宏的公共 crate。
  • =0.8.6 精确版本锁定贯穿所有跨 crate 依赖,原因是 sqlx-core 在顶层注释里明确声明 不遵守 Semantic Versioning(sqlx-core/src/lib.rs:3-7)。
  • 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 就搞不定吗

按介绍页 checkout v0.8.6 后打开仓库根目录,cat Cargo.toml 能看到 [workspace] members 这张清单:

members = [
    ".",                  # facade crate: sqlx 本体
    "sqlx-core",
    "sqlx-macros",
    "sqlx-macros-core",
    "sqlx-test",          # 测试辅助,不发布给用户用
    "sqlx-cli",           # 命令行工具(binary)
    #    "sqlx-bench",
    "sqlx-mysql",
    "sqlx-postgres",
    "sqlx-sqlite",
    # ... 以及一串 examples/
]

注意 facade 就是根目录本身(.),它的源码在 src/;驱动和宏各占一个子目录。抛开 sqlx-test、sqlx-cli 这两个辅助成员和被注释掉的 sqlx-bench,一个"在 Rust 里用异步的方式访问数据库"的工具包,拆出了 7 个独立发布给库使用者的 crate:sqlx、sqlx-core、sqlx-macros、sqlx-macros-core、sqlx-mysql、sqlx-postgres、sqlx-sqlite。乍看之下这是过度设计——一个单体 crate 配几个 feature gate 不就够了?但仔细看每一条边界,你会发现它们不是架构师在白板上凭空画的。每一条都是 Rust 工具链的硬性约束、改动与编译隔离的工程权衡、或者"想让某种用法成为可能"的实现前提。

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

  1. proc-macro = true 的 crate 只能导出过程宏,其中的函数和类型不能当作普通库来复用。
  2. 编译时 feature 互斥(比如 runtime、TLS)要求发布单元足够小。
  3. 可选依赖的拉取成本要求"不用的东西不编译"。
  4. 跨 crate 复用(比如第三方驱动要接入同一套宏逻辑)要求把算法实现挪出 proc-macro crate。
  5. 上游 API 不稳定的子系统要求和稳定的门面分开,单独声明稳定性承诺。

sqlx 的 7 个 crate 用上了其中四条。第 2 条在 sqlx 身上恰好是反例:0.4–0.6 曾有一个专管运行时适配的 sqlx-rt crate,0.7 把它删了,runtime、TLS 的互斥改由 sqlx-core 内部的 feature 处理(§2.5、§2.7.2)。

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

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

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

这里的 sqlx 就是 facade crate——它的 src/lib.rs 一共 174 行,其中第 12–37 行全部是 pub use sqlx_core::...(夹着两行 #[doc(hidden)])。翻开 sqlx-0.8.6/src/lib.rs:12-37:

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 直到第 37 行

然后是按 feature re-export 驱动:

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

整个 facade 几乎没有业务逻辑(只有 query! 系列的 macro_rules! 外壳、Any 的 install_default_drivers 这类少量胶水)——它的主要价值是把 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:3-7 原文):

    //! ### 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 使用者"的身份,任何一次发版(包括 0.8.x 这样的小版本)都可能被破坏。facade 的存在就是为了把这层风险封掉。

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

Facade 的代价是轻微的编译开销——多一层 pub use 对增量编译几乎无影响,但对 cargo doc 的合并是必要的(驱动 re-export 上的 #[doc(inline)] 标注让 sqlx::postgres 等模块在 sqlx 的文档里直接展开,而不是只留一行指向 sqlx_postgres 的链接)。

这道边界是 Rust 大型项目的常见做法——serde(serde / serde_core)、axum(axum / axum-core)里能看到类似的 facade + core 模式;tokio 则没有单独的 core crate,核心全在 tokio 本体里,周边能力拆到 tokio-util / tokio-stream。《Tokio 源码深度解析》专栏第 1 章在盘点 tokio 这棵树时也会把 tokio-util / tokio-stream 这些同 workspace 成员和 tokio 本体分开数。

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(单个 proc-macro crate) tokio-util / tokio-stream
serde serde serde_core 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)是独立发布但不在 serde 这个 workspace 里——这张表里,sqlx 是唯一一个在同一个 workspace 里把主 crate(sqlx)和"可选后端"(sqlx-postgres 等)都发布到 crates.io 的。=版本 锁定倒不是 sqlx 独有:serde 1.0.228 的 serde 对 serde_core 写的也是 =1.0.228。
  • 只有 sqlx 的宏 crate 对是真正的依赖关系。serde_derive_internals 也独立发布,但 serde_derive 并不依赖它——它的 src 只是指向 serde_derive/src/internals 的符号链接,把同一份源码另发一个包给外部工具用;sqlx 的 sqlx-macros 则直接依赖 sqlx-macros-core、调用它的 expand_input,而 sqlx-macros-core 的 crate 文档明确写给"自定义驱动"的作者看。
  • 所有项目都有一个 facade 作为统一入口,但单拆 core crate 的只有 sqlx、serde、axum。tokio、hyper 的核心就在主 crate 里;hyper 的周边工具 hyper-util 甚至不在 hyper 仓库里,也不经 hyper:: 路径 re-export。

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

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

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

翻开 sqlx-macros/Cargo.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 = { workspace = true, features = ["any"] }
sqlx-macros-core = { workspace = true }

proc-macro2 = { version = "1.0.36", default-features = false }
syn = { version = "2.0.52", default-features = false, features = ["parsing", "proc-macro"] }
quote = { version = "1.0.26", default-features = false }

(workspace = true 会去根 Cargo.toml 的 [workspace.dependencies] 取声明,那里写的是 sqlx-core = { version = "=0.8.6", path = "sqlx-core" }——§2.4.2 会讲这个 = 的意义。)

注意 [lib] proc-macro = true。这个配置项会让 Cargo 用 --crate-type=proc-macro 编译这个 crate,产物是一个动态库(macOS 上是 .dylib),由 rustc 在编译期加载。proc-macro crate 只能导出 #[proc_macro] / #[proc_macro_derive] / #[proc_macro_attribute] 标注的函数,普通函数、类型、trait 一律不能 pub 给别的 crate(rustc 会直接报错)。普通 crate 可以依赖它——sqlx facade 就依赖 sqlx-macros 并 re-export 其中的宏——但拿到的只有宏,拿不到里面的任何算法。

这条限制带来一个直接问题:如果算法只写在 proc-macro crate 里,它永远无法被其他地方复用。而 sqlx 恰恰需要复用——一个给小众数据库写驱动的第三方作者,想让自己的驱动也享受"编译期 DESCRIBE + 离线缓存"这一整套机制,就得能以普通库依赖的形式引用这套实现。sqlx-macros-core 自己的 crate 文档第一行写的就是 "Support crate for SQLx's proc macros",并明确告诉驱动作者要把它和 sqlx-core 一起精确锁版本。

解法是把算法搬到一个不带 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:

// sqlx-macros/src/lib.rs:7-23
#[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/src/query/mod.rs(436 行),整个 sqlx-macros-core/src 是 17 个文件 3585 行——sqlx-macros 那 101 行只是它的一层 #[proc_macro] 外壳。

顺带澄清一个容易望文生义的地方:cargo sqlx prepare 并不链接 sqlx-macros-core。看 sqlx-cli/src/prepare.rs:150 的 run_prepare_step,它做的是设好 SQLX_TMP / SQLX_OFFLINE=false / SQLX_OFFLINE_DIR=<缓存目录> 三个环境变量(同文件 :180-182),再 Command::new(&ctx.cargo)(:176)把你的 crate 重编一遍。CLI 只负责摆好环境、清理目录、比对结果。

JSON 是宏自己在那次编译里写出来的——sqlx-macros-core/src/query/mod.rs:344-346 那段注释写着 "Only save query metadata if SQLX_OFFLINE_DIR is set manually or by cargo sqlx prepare"。

graph TD
    USER[用户代码<br/>cargo check]
    CLI[sqlx-cli<br/>cargo sqlx prepare]
    MAC[sqlx-macros<br/>proc-macro = true]
    MACCORE[sqlx-macros-core<br/>普通 crate]
    DB[(真实数据库)]
    CACHE[.sqlx/ 缓存]
    USER --> MAC
    CLI -->|设 SQLX_OFFLINE_DIR<br/>再调 cargo check| USER
    MAC -->|委托| MACCORE
    MACCORE -->|DESCRIBE| DB
    MACCORE -->|写/读| CACHE

这张图解释了 sqlx 为什么能同时支持"在线校验"和"离线缓存"两种模式——两条路径都从 sqlx-macros-core 走,区别只在于环境变量把它导向真实数据库还是 .sqlx/ 目录。cargo sqlx prepare 不是另起炉灶,它只是把你的项目在"写缓存"模式下重编一遍。

这条模式在 Rust 生态里非常普遍:serde_derive / serde_derive_internals、thiserror / thiserror-impl——凡是你看到 xxx-macros 和 xxx-macros-core / xxx-impl 并列存在的,背后都是同一个 proc-macro 隔离约束。《Serde 元编程》专栏第 5 章分析 serde 的 crate 拆分时,第一条理由就是这条"proc-macro crate 只能导出宏"的约束——值得对照阅读。

2.3.1 sqlx-macros-core 里的 DatabaseExt 与 FOSS_DRIVERS

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

DatabaseExt trait(sqlx-macros-core/src/database/mod.rs:16):

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(由 impl_database_ext! 用 stringify! 生成,sqlx-macros-core/src/database/impls.rs:8)——宏生成 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-52):

#[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>()(sqlx-macros-core/src/query/mod.rs:34)是 const fn——在 const context 把每个驱动的数据库名、URL schemes 和展开函数指针 expand_with::<DB> 收集起来。宏展开时,根据 DATABASE_URL 的 scheme(离线模式下则是缓存里记录的数据库名)找到对应的 QueryDriver,调用它的 expand——在线模式下再由它走 describe_blocking。

这里要特别注意 FOSS_DRIVERS 这个名字——"Free and Open-Source Software"——它给"非 FOSS 驱动"留了位置。上游在 CHANGELOG 0.7.0 里公开说明:MSSQL 驱动被移出源码树,计划以 "SQLx Pro" 商业形态从头重写回来,同时提供其它专有数据库的驱动。开源版本的 FOSS_DRIVERS 就只有这三家能被 query! 宏识别。

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

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

这是 sqlx "编译期连数据库"这条路径的最刺眼代价:宏的驱动集合是一个闭合列表。它没有为第三方驱动留注册接口——FOSS_DRIVERS 在 sqlx-macros-core 编译时就定死了,下游 crate 没有办法往 sqlx::query! 用的这张表里追加自己的驱动。

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

一直到 0.6,Postgres / MySQL / SQLite(以及当时还在的 MSSQL)的协议实现都是 sqlx-core 里的子模块——git ls-tree v0.6.0 sqlx-core/src/ 能看到 postgres / mysql / sqlite / mssql 四个目录并排躺着,靠 feature 切换。直到 0.7 的 #2039,这三个驱动才被拆出来、各自独立发布到 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 会带来两个问题:

  1. 可选依赖的爆炸。Postgres 驱动需要 md-5(MD5 密码认证)、hmac、rand(SCRAM 认证)和 hkdf(advisory lock 的键派生),MySQL 需要 sha1、sha2、rsa(mysql_native_password / caching_sha2_password 认证),SQLite 需要 libsqlite3-sys(C FFI)和 flume(I/O 线程通道)。把这些全堆到 sqlx-core,即便用 feature 屏蔽,也会让 cargo tree 变得又长又吓人——新手第一反应是"这么重,我真的需要吗?"
  2. 改动的隔离。翻 0.8.0 的 CHANGELOG 就能看到大量只影响单个驱动的条目:Postgres 加 cube 类型(#3188)、加 HSTORE(#3343),SQLite 改 explain 图的可空性推断(#3064),MySQL 加 MySqlTime(#3154)。驱动拆出去之后,这些改动只落在各自的 crate 里,不碰 sqlx-core 的代码,也不会让只用 MySQL 的用户重编 Postgres 的协议实现。注意这不等于各自独立发版:所有 in-tree crate 共用 [workspace.package] 里同一个版本号、彼此用 = 锁定(§2.4.2),sqlx-macros-core 的 crate 文档也要求驱动与 sqlx-core "make releases in lockstep"——每次发版都是 7 个 crate 一起发。

0.7 之后的 crate 图是:

graph LR
    CORE[sqlx-core<br/>trait 家族]
    PG[sqlx-postgres]
    MY[sqlx-mysql]
    LT[sqlx-sqlite]
    SQLX[sqlx facade]
    PG --> CORE
    MY --> CORE
    LT --> CORE
    SQLX --> CORE
    SQLX -.optional.-> PG
    SQLX -.optional.-> MY
    SQLX -.optional.-> LT

对用户而言,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.1 sqlx-cli:只依赖 facade 的那个成员

sqlx-cli 是 workspace 里除 examples/ 下各示例之外唯一的 binary 成员,也是最能说明"facade + any feature"价值的地方。打开 sqlx-cli/Cargo.toml 看它的依赖:

# sqlx-cli/Cargo.toml(节选)
[dependencies]
sqlx = { workspace = true, default-features = false, features = [
    "runtime-tokio",
    "migrate",
    "any",
] }

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

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

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

注意这份依赖表里没有 sqlx-core、sqlx-macros-core 或任何驱动 crate——sqlx-cli 只依赖 facade,sqlx-core 和驱动 crate 都经由 facade 的 feature 级联拉进来;sqlx-macros-core 则根本不在它的依赖图里(它没开 macros feature,整个 workspace 里只有 sqlx-macros 依赖 sqlx-macros-core)。

sqlx-cli 作为一个 binary crate,对 workspace 拆分的依赖体现在三处:

  1. 它通过 sqlx/any feature 支持多数据库。sqlx migrate run 一个命令要能对 Postgres / MySQL / SQLite 都工作——这正是 §2.6 讨论的 Any 驱动的合理用例。
  2. prepare 子命令靠"重编一遍你的 crate"实现,而不是自己去连数据库:run_prepare_step(sqlx-cli/src/prepare.rs:150)设好 SQLX_TMP / SQLX_OFFLINE=false / SQLX_OFFLINE_DIR,再 cargo check,让宏把 JSON 落盘(sqlx-macros-core/src/query/data.rs:153 的 QueryData::save_in)。这个设计的好处是:CLI 和宏永远不会对同一条 SQL 得出不同结论,因为它们就是同一份代码在同一次编译里跑的。
  3. 它通过 feature 级联把驱动选择外推到 CLI 用户:用户 cargo install sqlx-cli --no-default-features --features postgres,rustls,这两个 feature 各自 cascade 到 sqlx/postgres 和 sqlx/runtime-tokio-rustls——驱动的 feature 开关和 CLI 的 feature 开关用一套体系。

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

2.4.2 =0.8.6 精确版本锁定

翻开根 Cargo.toml 的 [workspace.dependencies]——所有 in-tree crate 的版本都在这里统一声明,各成员的 Cargo.toml 只写 workspace = true 去引用它:

[workspace.dependencies]
# Core Crates
sqlx-core = { version = "=0.8.6", path = "sqlx-core" }
sqlx-macros-core = { version = "=0.8.6", path = "sqlx-macros-core" }
sqlx-macros = { version = "=0.8.6", path = "sqlx-macros" }

# Driver crates
sqlx-mysql = { version = "=0.8.6", path = "sqlx-mysql" }
sqlx-postgres = { version = "=0.8.6", path = "sqlx-postgres" }
sqlx-sqlite = { version = "=0.8.6", path = "sqlx-sqlite" }

# Facade crate (for reference from sqlx-cli)
sqlx = { version = "=0.8.6", path = ".", default-features = false }

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

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

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 家族(Database、Executor、Connection)在任何一次发版都可能有 API 变化——大版本之间就不用说了,0.6 到 0.7 删掉了 Transaction 与 PoolConnection 的 Executor impl,0.7 到 0.8 又用泛型关联类型替掉了 HasValueRef / HasArguments / HasStatement、让 Encode 与 Arguments::add 改为返回 Result;就连 0.8.x 的小版本里也有:0.8.4 给 TransactionManager 加了一个没有默认实现的 get_transaction_depth,第三方驱动不补上就编译不过。这意味着 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 方法签名,你的驱动就崩了。

2.5 Feature gate 级联:?/ 语法

sqlx 0.8.6 的 feature 数量相当庞大:

  • 数据库:postgres、mysql、sqlite、sqlite-unbundled、any
  • 运行时:runtime-tokio、runtime-async-std(可以同时开,运行时优先 Tokio,见 §2.5.1)
  • TLS 后端:tls-native-tls、tls-rustls-ring-webpki、tls-rustls-ring-native-roots、tls-rustls-aws-lc-rs、tls-none(另有 tls-rustls / tls-rustls-ring 两个向后兼容别名)
  • 数据类型:chrono、time、bigdecimal、rust_decimal、uuid、json、ipnet、ipnetwork、mac_address、bit-vec、bstr
  • 工具:macros、derive、migrate(注意 offline 不是 facade 的 feature,它只存在于 sqlx-core/Cargo.toml:32,由根 Cargo.toml:169 的 sqlx-core = { workspace = true, features = ["offline", "migrate"] } 无条件开启——在 sqlx 上写 features = ["offline"] 会被 Cargo 直接拒绝)

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

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

# sqlx 根 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 基础设施,如 syn、quote、proc-macro2 的重编译)。

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

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",答案可能是"因为你启用了 uuid 这个和 syn 毫无关系的类型 feature"。

在 sqlx-core 内部,feature 更基础:

# sqlx-core/Cargo.toml
[features]
migrate = ["sha2", "crc"]

any = []

json = ["serde", "serde_json"]

# for conditional compilation
_rt-async-std = ["async-std", "async-io"]
_rt-tokio = ["tokio", "tokio-stream"]

# support offline/decoupled building (enables serialization of `Describe`)
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 枚举:

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",会被编译器直接拒绝(E0392:类型参数未被使用)。

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

#[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 的 Handle::try_current,不是编译期的 feature flag。注意即便只开了 _rt-tokio,这次检测也不会被编译掉:不在 Tokio 上下文里调用时,会落到 missing_rt,panic "this functionality requires a Tokio context"。两个 feature 都开时,则是一条 Tokio 优先、async-std 兜底的回退链。

最后看 missing_rt(sqlx-core/src/rt/mod.rs:137-144)——这是 sqlx 的"运行时缺失"错误信息:

#[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 内部,例如实测没开 runtime 时报在 sqlx-core/src/pool/inner.rs)。这种错误信息设计是 sqlx 对"新手第一次 compile 通过但 run 起来 panic"的主动防护。

这条运行时抽象也带来一个代价:每一次 .spawn 都要走 Handle::try_current(),这是一次 thread-local 查询。对于小规模 spawn 来说开销可以忽略,但如果某段代码在热路径上每请求 spawn 数十次,这层开销会被放大。第 14 章的 spawn_maintenance_tasks 和 PoolConnection 的 Drop 归还都经由 rt::spawn,会再遇到它。

2.5.2 feature 组合的常见陷阱

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

陷阱 1:忘开 runtime-*

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

编译通过(前提是没用在线模式的 query!——宏展开时同样需要运行时,会在编译期就报同一条错误)。但一创建连接池(PgPool::connect(...) 构造 PoolInner 时)程序就 panic:either the `runtime-async-std` or `runtime-tokio` feature must be enabled(以 SQLite 内存库、sqlx 0.8.6 实跑确认)。因为 sqlx 里数据库 feature 和运行时 feature 是正交维度——没有任何 feature 组合默认启用 runtime。这是刻意设计:sqlx 不想猜你用 Tokio 还是 async-std。

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

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

Cargo 不会报错(Cargo 没有声明"互斥 feature"的机制,sqlx 也没有用 compile_error! 自行拦截)。编译能通过,两套运行时的依赖都会被编进来;运行时 rt::spawn 按 2.5.1 描述的逻辑,处在 Tokio 上下文里就用 Tokio,否则落到 async-std——哪个生效取决于调用时所在的上下文,而不是你在 Cargo.toml 里的本意。实际处理办法是用户自己在 Cargo.toml 里只选一个。

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

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

默认的 default = ["any", "macros", "migrate", "json"] 被关掉了,query! 宏不可用。写 sqlx::query!(...) 时,rustc 1.96.0 报的是 error[E0433]: cannot find `query` in `sqlx` ,还会提示 "sqlx::query is not a macro, but a function, try to remove !"(实测)——不会直接提示"请开 macros feature",照着提示去掉 ! 反而把编译期校验悄悄丢了。关掉 default features 后,记得把 macros 显式加回来。

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

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

2.6.1 动机:runtime 选 DB

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

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

  1. 写两份应用代码——main_sqlite.rs 和 main_postgres.rs,各自用 SqlitePool 或 PgPool。维护噩梦。
  2. 用泛型 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:

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

backend 是一个 trait object,运行时装的是哪个由 URL scheme 决定。注意这里没有一层 XxxConnectionBackend 包装类型——AnyConnectionBackend 直接实现在三个驱动自己的连接类型上:impl AnyConnectionBackend for PgConnection(sqlx-postgres/src/any.rs:26)、for MySqlConnection(sqlx-mysql/src/any.rs:24)、for SqliteConnection(sqlx-sqlite/src/any.rs:26)。也就是说 Any 驱动擦除的是已有的连接类型,没有为此新造一层壳。AnyConnectionBackend 定义在 sqlx-core/src/any/connection/backend.rs:9:

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);
    // ... 一共 20 个方法
}

每个异步方法都用 BoxFuture(流式的 fetch_many 用 BoxStream)而不是 impl Future——因为方法返回 impl Future(即 trait 里的 async fn)的 trait 不能做成 trait object,而 BoxFuture = Pin<Box<dyn Future + Send>> 可以。代价是每次调用都多一次堆分配。这是动态分发在异步代码里的额外税。

2.6.3 驱动注册:install_drivers + OnceCell

AnyConnection 本身不知道 Postgres / MySQL / SQLite 长什么样——它只知道"有一组静态驱动可用"。谁来告诉它这组驱动?install_drivers 函数(sqlx-core/src/any/driver.rs:125-131,它写入的 DRIVERS 静态量在同文件 :12):

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() 开始时调一次:

sqlx::any::install_default_drivers();  // 或手动 install_drivers(&[sqlx::postgres::any::DRIVER, sqlx::mysql::any::DRIVER])

然后 AnyConnection::connect("postgres://...") 解析出 AnyConnectOptions,落到 AnyConnection::connect(sqlx-core/src/any/connection/mod.rs:36-41),先用 from_url(sqlx-core/src/any/driver.rs:137-150)选驱动:

  1. 解析 URL,拿到 scheme 字符串。
  2. 从 DRIVERS OnceCell 取出已安装的驱动数组。
  3. drivers.iter().find(|d| d.url_schemes.contains(&scheme)) 找到匹配的 AnyDriver。
  4. 调用该驱动的 connect 函数指针(即 connect_with_db::<DB>),它连上具体数据库,再把连接装箱成 AnyConnection { backend: Box::new(conn) }。

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

2.6.4 Any 的代价

选 Any 等于接受四件事:

  1. 每次 async 调用多一次堆分配(BoxFuture)。
  2. 类型系统信息丢失:AnyRow::try_get::<i64, _>(0) 得到的 i64 是通过 ValueRef 动态转换的——如果这一列在 Any 层被归成 AnyValueKind::Text,而你想要 i64,失败时报错里只有 AnyTypeInfo 这一层粗粒度的类型名,不像 PgRow::try_get 那样带着 Postgres 原生的类型信息。
  3. query! 宏和 Any 不兼容:query_as!(User, "SELECT ...", ...) 在编译期必须知道一个具体 Database 才能做 DESCRIBE;Any 模式只能用运行时 query_as::<Any, User>(),放弃编译期校验。
  4. 功能子集:每个驱动独有的特性(Postgres 的 LISTEN/NOTIFY 与 COPY、SQLite 的 pragma 与 update hook)在 Any 层都不可用——Any 只保留"所有驱动都支持"的交集。

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

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

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

sequenceDiagram
    participant U as 用户
    participant AC as AnyConnection
    participant DRV as DRIVERS<br/>OnceCell
    participant PG as sqlx-postgres<br/>的 AnyDriver
    participant CONN as PgConnection
    U->>AC: connect("postgres://localhost/test")
    AC->>AC: Url::parse → scheme = "postgres"
    AC->>DRV: drivers.get()
    DRV-->>AC: &'static [AnyDriver]
    AC->>AC: drivers.iter().find(d.url_schemes.contains("postgres"))
    AC->>PG: driver.connect(&any_opts)
    PG->>PG: PgConnectOptions::try_from(&any_opts)?
    PG->>CONN: PgConnection::connect_with(&opts).await
    CONN-->>PG: PgConnection
    PG-->>AC: Box<dyn AnyConnectionBackend>
    AC-->>U: AnyConnection { backend }

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

// sqlx-core/src/any/driver.rs:36-49
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> 这个泛型函数在编译期就被单态化(monomorphize)出 connect_with_db::<Postgres>、connect_with_db::<MySql>、connect_with_db::<Sqlite> 三个实例,各自以函数指针的形式存在一个 AnyDriver 常量里;运行时的 install_drivers 只是把这些常量组成的数组登记进 DRIVERS。

这种"编译期 monomorphize + 运行时 indirect call"的混合设计是 Any 架构的关键——选驱动这一步只是一次数组线性查找 + 一次函数指针调用,连上之后的每个操作才经由 Box<dyn AnyConnectionBackend> 的虚表分发。

第 19 章会单独展开 Any 的实现细节——特别是它如何把 AnyArguments 转成 PgArguments、以及它只覆盖三家类型的交集(比如不支持 Postgres 的 ARRAY)。

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

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

Crate 结构:

  • 新建 crate sqlx-xxx,lib.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,再加一个 0.8.4 才新增的 get_transaction_depth(0.8.0–0.8.3 都没有它,见第 12 章 §12.18 的版本轴)(sqlx-core/src/transaction.rs:49),五个方法一个都不能少——见 sqlx-core/src/transaction.rs:15-50。
  • Executor trait——通常借助 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 实现。

协议层:

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

和 query! 宏的集成:

  • 为自己的 Database 实现 sqlx-macros-core 的 DatabaseExt(sqlx-macros-core/src/database/mod.rs:16)和 sqlx-core 的 TypeChecking(sqlx-core/src/type_checking.rs:136 的 impl_type_checking! 宏可以代劳大半)。
  • 让 query! 认识自己:sqlx::query! 用的驱动表是 sqlx-macros-core/src/lib.rs:45 那个写死的 FOSS_DRIVERS,要么把驱动提 PR 进上游,要么自己发一个 proc-macro crate、拿自己的 QueryDriver 数组去调同一个 expand_input——后者正是 sqlx-macros-core 作为公开 crate 存在的意义。

Any 集成(可选):

  • 为 XxxConnection 实现 AnyConnectionBackend(sqlx-core/src/any/connection/backend.rs:9)。
  • 提供 sqlx_core::declare_driver_with_optional_migrate!(DRIVER = Postgres) 这样的一行宏调用——见 sqlx-core/src/any/driver.rs:14-25,它会根据 migrate feature 生成 AnyDriver 常量(三个 in-tree 驱动分别在自己的 any 模块里调它,例如 sqlx-postgres/src/any.rs:24)。

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

2.7.1 workspace 的版本演进

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

0.3(2020-03)到 0.6(2022-06):workspace 早就存在了——git ls-tree v0.3.0 就能看到 sqlx-core / sqlx-macros / sqlx-test 和根 crate 并排;0.4 又加进 sqlx-cli 和 sqlx-rt(一层薄薄的 runtime 适配 crate)。但驱动全在 sqlx-core 里面:git ls-tree v0.6.0 sqlx-core/src/ 列出的是 postgres / mysql / sqlite / mssql 四个并排的目录,靠 #[cfg(feature = "postgres")] 切换。维护上的代价是,一次 Postgres 协议修改会让所有人重编译 sqlx-core。

0.7(2023-06):把驱动拆成独立的 crate 并发布到 crates.io(#2039)。sqlx-postgres、sqlx-mysql、sqlx-sqlite 成为独立发布单位,sqlx-macros-core 同时诞生,sqlx-rt 被删掉,MSSQL 被移出源码树。同一个 PR 里还删掉了 Transaction / PoolConnection 的 Executor impl——这不是巧合:blanket impl 会因缺少惰性归一化撑爆 trait 求解,驱动 crate 自己写又撞孤儿规则,拆分逼着它做一次"trait 合约"的重新划线(见第 1 章)。精确版本锁 =0.x.y 的规范也是从这版定型的。

0.8(2024-07):workspace 结构基本稳定,改动落在 trait 形状上——HasValueRef / HasArguments / HasStatement 三个辅助 trait 被 Database 的泛型关联类型吸收(#2973),Encode 改为返回 Result(#3126)。

0.8.6(2025-05-19,本专栏版本):只有 9 个 PR 的小版本——CLI 帮助文本着色、非持久化语句改用未命名 statement、宏不再改写环境变量、修 sqlx-sqlite 的 docs.rs 构建。这些改动大多落在单独的 crate 里,不牵动 core 的代码——这正是 0.7 拆分带来的改动隔离。不过发版仍是 lockstep 的:这一版 7 个 crate 一起升到了 0.8.6。

看完这条演进线,你会发现本章讨论的所有边界不是一次性设计出来的——它们是在一次次发布压力下逐步长出来的。sqlx 的 workspace 不是白板设计的产物;它是一个"持续拆分"演化过程的当前状态。上游此后已经打出了 v0.9.0 标签(2026-05-21),本专栏不涵盖。

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

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

# sqlx 根 Cargo.toml(节选)
[features]
tls-native-tls = ["sqlx-core/_tls-native-tls", "sqlx-macros?/_tls-native-tls"]
tls-rustls = ["tls-rustls-ring"] # For backwards compatibility
tls-rustls-aws-lc-rs = ["sqlx-core/_tls-rustls-aws-lc-rs", "sqlx-macros?/_tls-rustls-aws-lc-rs"]
tls-rustls-ring = ["tls-rustls-ring-webpki"] # For backwards compatibility
tls-rustls-ring-webpki = ["sqlx-core/_tls-rustls-ring-webpki", "sqlx-macros?/_tls-rustls-ring-webpki"]
tls-rustls-ring-native-roots = ["sqlx-core/_tls-rustls-ring-native-roots", "sqlx-macros?/_tls-rustls-ring-native-roots"]

# No-op feature used by the workflows to compile without TLS enabled. Not meant for general use.
tls-none = []

# Legacy Runtime + TLS features
runtime-async-std-native-tls = ["runtime-async-std", "tls-native-tls"]
runtime-async-std-rustls     = ["runtime-async-std", "tls-rustls-ring"]
runtime-tokio-native-tls     = ["runtime-tokio", "tls-native-tls"]
runtime-tokio-rustls         = ["runtime-tokio", "tls-rustls-ring"]

TLS 和 runtime 是两个正交维度:2 个 runtime × 4 个真实 TLS 后端(native-tls、rustls-ring-webpki、rustls-ring-native-roots、rustls-aws-lc-rs)= 8 种组合。sqlx 只把其中 4 个列成"复合 feature",而且源码注释里明写着 # Legacy Runtime + TLS features——它们是历史入口,其余组合都通过分别指定 runtime-tokio + tls-rustls-aws-lc-rs 拼出来,没有 shortcut。另外注意 tls-none = [] 是个空 feature(注释写的是 "No-op feature used by the workflows"),它不转发给 sqlx-core/_tls-none,只是让 CI 能在"不写任何 TLS feature"的组合下过编译。

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

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

这里有两个细节:

  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 树看上去繁琐,但每一条都对应一个真实的部署差异:

  • 想复用操作系统自带的 TLS 栈(macOS/iOS 的 Security Framework、Windows 的 SChannel):选 tls-native-tls,代价是引入 C 依赖、交叉编译更麻烦。
  • 想要纯 Rust、可复现的构建:选 rustls 系;aws-lc-rs 与 ring 的差别只在底层 crypto 实现,sqlx-core 不做选择,直接把它转发成 rustls/aws-lc-rs 或 rustls/ring。
  • 企业内网自签证书:选 tls-rustls-ring-native-roots(走 rustls-native-certs 读系统证书库),而不是内置的 webpki-roots 公共根列表。

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

2.8 本章小结

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

  1. Facade 与 core 的分离(§2.2):sqlx-core 不遵守 SemVer(sqlx-core/src/lib.rs:3-7),facade 层提供稳定入口;文档、社区、教程只需教一条 use sqlx::... 路径。
  2. proc-macro 的物理约束(§2.3):proc-macro = true 的 crate 只能导出过程宏,其中的算法无法被库代码调用,因此必须把算法挪到 sqlx-macros-core;这也让第三方驱动作者能以普通依赖的形式复用整套编译期 DESCRIBE 与离线缓存实现。
  3. 驱动独立发布(§2.4):每个驱动都是上万行协议实现外加一串专属依赖(PG 的 hkdf/hmac/md-5/rand、MySQL 的 sha1/sha2/rsa、SQLite 的 libsqlite3-sys/flume),单体 crate 会让不用它的用户付成本;拆分也让各驱动的改动彼此隔离(发版仍是 7 个 crate lockstep)。
  4. =0.8.6 精确版本锁(§2.4.2):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 让第三方驱动作者接入 query! 机制的场景),必须 拆 xxx-macros 和 xxx-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 自己并没有这么做:7 个 crate 共用一个版本号、用 = 互相锁定,每次一起发版,它从拆分里拿到的是代码与编译单元的隔离,而不是发版节奏的解耦。你的项目若真想让子系统各自发版,就得放弃 = 锁定、认真维护 crate 之间的 semver 契约。

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

2.9 下一章指路

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