数据库操作与CLI工具 —— 让Rust走进日常开发
上一章我们用内存存储构建了一个Todo API,但数据重启就没了。这一章,我们要给它接上”真正的大脑”——数据库。同时,我们还会用Rust打造一个命令行工具,让你体验Rust在CLI开发领域的统治力。ripgrep、fd、bat——这些比传统Unix工具快几倍的现代命令行工具,都是Rust写的。
📋 开篇自测:你已经知道多少?
- 你用过ORM(如Hibernate、Django ORM)吗?SQLx和ORM有什么不同?
- 什么是”编译期SQL检查”?为什么它对安全性很重要?
- 你用过Python的argparse或Node.js的commander吗?Rust的clap有什么优势?
一、SQLx——异步、安全的数据库操作
1.1 为什么选SQLx?
Rust生态中有多种数据库方案:
| 库 | 类型 | 特点 |
|---|---|---|
| Diesel | ORM | 类型安全,编译期检查,但学习曲线陡 |
| SQLx | SQL工具库 | 直写SQL,编译期验证,异步原生 |
| SeaORM | ORM | 基于SQLx,API友好 |
我们选SQLx,因为它直接写SQL(你不需要学一套ORM的DSL),同时还能在编译期检查你的SQL语句是否正确——类型对不对、表名字段名有没有拼错,全在编译期就能发现。
1.2 项目设置
# Cargo.toml
[dependencies]
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
我们用SQLite作为数据库——无需安装服务器,一个文件就是一个数据库,非常适合学习和小型项目。换成PostgreSQL或MySQL只需改配置——将"sqlite"替换为"postgres"或"mysql",并添加TLS feature(如"tls-native-tls"或"tls-rustls"),因为远程数据库通常需要TLS加密连接。
1.3 创建数据库和表
use sqlx::sqlite::SqlitePool;
async fn setup_database() -> SqlitePool {
let pool = SqlitePool::connect("sqlite:todos.db?mode=rwc")
.await
.expect("无法连接数据库");
sqlx::query(
"CREATE TABLE IF NOT EXISTS todos (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
completed BOOLEAN NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
)
.execute(&pool)
.await
.expect("无法创建表");
pool
}
mode=rwc表示读写模式,如果文件不存在就创建。
1.4 CRUD操作
创建(Create)
use uuid::Uuid;
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
struct Todo {
id: String,
title: String,
description: Option<String>,
completed: bool,
created_at: String,
}
async fn create_todo(pool: &SqlitePool, title: &str, description: Option<&str>) -> Todo {
let id = Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO todos (id, title, description) VALUES (?, ?, ?)"
)
.bind(&id)
.bind(title)
.bind(description)
.execute(pool)
.await
.expect("插入失败");
// 查询回来获取完整数据(包含created_at)
sqlx::query_as::<_, Todo>("SELECT * FROM todos WHERE id = ?")
.bind(&id)
.fetch_one(pool)
.await
.expect("查询失败")
}
#[derive(sqlx::FromRow)]让SQLx自动把数据库行映射到结构体。query_as返回的就是强类型的Todo对象。
查询(Read)
async fn list_todos(pool: &SqlitePool) -> Vec<Todo> {
sqlx::query_as::<_, Todo>("SELECT * FROM todos ORDER BY created_at DESC")
.fetch_all(pool)
.await
.expect("查询失败")
}
async fn find_todo(pool: &SqlitePool, id: &str) -> Option<Todo> {
sqlx::query_as::<_, Todo>("SELECT * FROM todos WHERE id = ?")
.bind(id)
.fetch_optional(pool)
.await
.expect("查询失败")
}
注意fetch_optional——返回Option<Todo>,找不到就是None,完美契合Rust的Option理念。
更新(Update)
async fn toggle_todo(pool: &SqlitePool, id: &str) -> Option<Todo> {
let result = sqlx::query(
"UPDATE todos SET completed = NOT completed WHERE id = ?"
)
.bind(id)
.execute(pool)
.await
.expect("更新失败");
if result.rows_affected() == 0 {
return None;
}
find_todo(pool, id).await
}
删除(Delete)
async fn delete_todo(pool: &SqlitePool, id: &str) -> bool {
let result = sqlx::query("DELETE FROM todos WHERE id = ?")
.bind(id)
.execute(pool)
.await
.expect("删除失败");
result.rows_affected() > 0
}
1.5 参数绑定与SQL注入防护
注意我们总是使用?占位符加.bind(),而不是字符串拼接。这不仅是好习惯,更是安全必需——它彻底杜绝了SQL注入攻击:
// 危险!永远不要这样做!
let query = format!("SELECT * FROM todos WHERE title = '{}'", user_input);
// 安全:使用参数绑定
sqlx::query("SELECT * FROM todos WHERE title = ?")
.bind(user_input)
.fetch_all(pool)
.await?;
🤔 想一想 SQLx的编译期SQL检查需要在编译时连接数据库。这意味着CI/CD环境也需要数据库。你觉得这种设计的利弊是什么?(提示:可以用
sqlx::query!宏启用编译期检查,也可以用sqlx::query函数在运行时检查。)
二、集成到Web API——给上一章的Todo服务接上数据库
2.1 修改应用状态
把内存存储替换为数据库连接池:
use axum::{
extract::{Path, State},
http::StatusCode,
routing::get,
Json, Router,
};
use sqlx::sqlite::SqlitePool;
#[derive(Clone)]
struct AppState {
db: SqlitePool,
}
async fn list_handler(
State(state): State<AppState>,
) -> Result<Json<Vec<Todo>>, StatusCode> {
let todos = sqlx::query_as::<_, Todo>(
"SELECT * FROM todos ORDER BY created_at DESC"
)
.fetch_all(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(todos))
}
async fn create_handler(
State(state): State<AppState>,
Json(input): Json<CreateRequest>,
) -> Result<(StatusCode, Json<Todo>), StatusCode> {
let id = Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO todos (id, title, description) VALUES (?, ?, ?)"
)
.bind(&id)
.bind(&input.title)
.bind(&input.description)
.execute(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let todo = sqlx::query_as::<_, Todo>(
"SELECT * FROM todos WHERE id = ?"
)
.bind(&id)
.fetch_one(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok((StatusCode::CREATED, Json(todo)))
}
#[tokio::main]
async fn main() {
let pool = SqlitePool::connect("sqlite:todos.db?mode=rwc")
.await
.unwrap();
// 建表
sqlx::query(
"CREATE TABLE IF NOT EXISTS todos (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
completed BOOLEAN NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
)
.execute(&pool)
.await
.unwrap();
let state = AppState { db: pool };
let app = Router::new()
.route("/todos", get(list_handler).post(create_handler))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
现在重启服务器后数据不会丢失了!
三、用Clap构建CLI工具——命令行的瑞士军刀
3.1 为什么用Rust写CLI?
Rust编译出的是单个可执行文件——不需要安装Python解释器、不需要Node.js运行时。拷贝一个文件就能用。而且速度极快,启动几乎零延迟。
3.2 Clap简介
Clap是Rust生态中最流行的命令行参数解析库。它支持用derive宏直接从结构体生成参数解析逻辑:
# Cargo.toml
[dependencies]
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "6"
3.3 实战:构建一个任务管理CLI
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "taskr")]
#[command(about = "一个简洁的命令行任务管理工具", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// 添加一个新任务
Add {
/// 任务标题
title: String,
/// 任务优先级 (1-5)
#[arg(short, long, default_value_t = 3)]
priority: u8,
},
/// 列出所有任务
List {
/// 只显示未完成的任务
#[arg(short, long)]
pending: bool,
},
/// 完成一个任务
Done {
/// 任务编号
id: usize,
},
/// 删除一个任务
Remove {
/// 任务编号
id: usize,
},
}
这段代码定义了CLI的完整接口。Clap会自动帮你生成:
--help帮助信息- 参数类型验证
- 子命令支持
- 默认值处理
3.4 实现任务存储
我们用JSON文件来持久化任务:
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize)]
struct Task {
id: usize,
title: String,
priority: u8,
completed: bool,
}
#[derive(Debug, Serialize, Deserialize, Default)]
struct TaskStore {
tasks: Vec<Task>,
next_id: usize,
}
impl TaskStore {
fn file_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".taskr.json")
}
// 简化版:使用 unwrap_or_default 容忍文件不存在或格式错误
fn load() -> Self {
let path = Self::file_path();
if path.exists() {
let content = fs::read_to_string(&path).unwrap_or_default();
serde_json::from_str(&content).unwrap_or_default()
} else {
TaskStore::default()
}
}
fn save(&self) {
let content = serde_json::to_string_pretty(self).unwrap();
fs::write(Self::file_path(), content).unwrap();
}
// 推荐版:返回 Result,让调用方决定如何处理错误
fn load_safe() -> Result<Self, Box<dyn std::error::Error>> {
let path = Self::file_path();
if path.exists() {
let content = fs::read_to_string(&path)?;
let store = serde_json::from_str(&content)?;
Ok(store)
} else {
Ok(TaskStore::default())
}
}
fn save_safe(&self) -> Result<(), Box<dyn std::error::Error>> {
let content = serde_json::to_string_pretty(self)?;
fs::write(Self::file_path(), content)?;
Ok(())
}
fn add(&mut self, title: String, priority: u8) -> &Task {
self.next_id += 1;
let task = Task {
id: self.next_id,
title,
priority,
completed: false,
};
self.tasks.push(task);
self.tasks.last().unwrap()
}
fn complete(&mut self, id: usize) -> Option<&Task> {
if let Some(task) = self.tasks.iter_mut().find(|t| t.id == id) {
task.completed = true;
Some(task)
} else {
None
}
}
fn remove(&mut self, id: usize) -> bool {
let len = self.tasks.len();
self.tasks.retain(|t| t.id != id);
self.tasks.len() < len
}
}
3.5 连接CLI与业务逻辑
fn main() {
let cli = Cli::parse();
let mut store = TaskStore::load();
match cli.command {
Commands::Add { title, priority } => {
let task = store.add(title, priority);
println!(
"已添加任务 #{}: {} (优先级:{})",
task.id, task.title, task.priority
);
store.save();
}
Commands::List { pending } => {
let tasks: Vec<&Task> = if pending {
store.tasks.iter().filter(|t| !t.completed).collect()
} else {
store.tasks.iter().collect()
};
if tasks.is_empty() {
println!("暂无任务。用 'taskr add <标题>' 添加一个吧!");
return;
}
println!("{:<5} {:<6} {:<30} {}", "编号", "状态", "标题", "优先级");
println!("{}", "-".repeat(55));
for task in tasks {
let status = if task.completed { "[完成]" } else { "[待办]" };
let priority_stars = "*".repeat(task.priority as usize);
println!(
"{:<5} {:<6} {:<30} {}",
task.id, status, task.title, priority_stars
);
}
}
Commands::Done { id } => {
match store.complete(id) {
Some(task) => {
println!("已完成任务 #{}: {}", task.id, task.title);
store.save();
}
None => println!("找不到编号为{}的任务", id),
}
}
Commands::Remove { id } => {
if store.remove(id) {
println!("已删除任务 #{}", id);
store.save();
} else {
println!("找不到编号为{}的任务", id);
}
}
}
}
3.6 使用效果
# 编译
cargo build --release
# 使用
./target/release/taskr add "学习Rust的所有权" -p 5
./target/release/taskr add "写一个Web API" -p 4
./target/release/taskr add "整理书桌" -p 2
./target/release/taskr list
./target/release/taskr done 1
./target/release/taskr list --pending
./target/release/taskr remove 3
输出效果:
编号 状态 标题 优先级
-------------------------------------------------------
1 [完成] 学习Rust的所有权 *****
2 [待办] 写一个Web API ****
3 [待办] 整理书桌 **
🤔 想一想 我们的CLI工具用JSON文件存储数据,足够简单。但如果任务数量达到上万条,JSON文件的读写性能就会成为瓶颈。你能想到什么改进方案?(提示:嵌入式数据库如SQLite,或者更轻量的方案如CSV。)
四、打包与发布——让你的工具走向世界
4.1 编译优化
# Cargo.toml
[profile.release]
opt-level = 3 # 最高优化级别
lto = true # 链接时优化
strip = true # 去掉调试符号
codegen-units = 1 # 单编译单元(更好优化)
这些配置能让最终的二进制文件更小、运行更快。
4.2 交叉编译
Rust可以为不同平台编译:
# 添加目标平台
rustup target add x86_64-unknown-linux-gnu
rustup target add x86_64-pc-windows-gnu
# 交叉编译
cargo build --release --target x86_64-unknown-linux-gnu
4.3 发布到crates.io
如果你的工具足够好,可以发布到crates.io让全世界使用:
cargo login # 用你的API token登录
cargo publish
发布后,别人只需cargo install your-tool-name就能安装你的工具。
⚠️ 常见误区
- 在生产环境使用unwrap()——CLI工具也不例外。文件可能不存在、JSON可能损坏、磁盘可能满了。用match或
?优雅地处理每个可能的错误。- 忘了处理SQL注入——永远使用参数绑定而不是字符串拼接来构建SQL。哪怕你觉得”只有自己用”,安全习惯应该成为本能。
- 数据库连接不使用连接池——每次请求都创建新连接的开销很大。SQLx的SqlitePool就是连接池,始终使用它。
- CLI参数验证不充分——用户输入什么你无法控制。优先级范围应该验证(1-5),文件路径应该检查是否存在。Clap提供了
value_parser来做参数验证。- 不写—help信息——好的CLI工具应该是自文档的。Clap自动从注释和属性生成帮助信息,但你需要写清楚每个参数的用途。
📝 掌握度自测
- 数据库基础:SQLx的
query_as和query有什么区别?fetch_one、fetch_all、fetch_optional各返回什么类型? - 安全性:为什么必须使用参数绑定而不是字符串拼接来构建SQL?写一个”危险的”和”安全的”查询作为对比。
- CLI设计:给我们的taskr工具增加一个
edit子命令,允许修改任务标题。写出Clap结构体的定义。 - 连接池:解释数据库连接池的作用。为什么Web服务器中每个请求不应该创建新的数据库连接?
- 综合:把我们的taskr CLI工具改为使用SQLite数据库存储(替换JSON文件),写出关键的改动代码。
💡 自我评估
- 答对5题:你已经能用Rust进行实际项目开发了,包括Web API和CLI工具。
- 答对3-4题:核心概念已掌握,建议动手完成一个自己的小项目来巩固。
- 答对0-2题:实战章节的关键是动手。建议把本章的代码完整地敲一遍跑起来,遇到问题就是最好的学习机会。
购买课程解锁全部内容
内存安全 + 零成本抽象:Rust 系统编程实战
¥29.90