跳到主要内容
预计阅读 34 分钟

数据库操作与CLI工具 —— 让Rust走进日常开发

上一章我们用内存存储构建了一个Todo API,但数据重启就没了。这一章,我们要给它接上”真正的大脑”——数据库。同时,我们还会用Rust打造一个命令行工具,让你体验Rust在CLI开发领域的统治力。ripgrep、fd、bat——这些比传统Unix工具快几倍的现代命令行工具,都是Rust写的。

📋 开篇自测:你已经知道多少?

  1. 你用过ORM(如Hibernate、Django ORM)吗?SQLx和ORM有什么不同?
  2. 什么是”编译期SQL检查”?为什么它对安全性很重要?
  3. 你用过Python的argparse或Node.js的commander吗?Rust的clap有什么优势?

一、SQLx——异步、安全的数据库操作

1.1 为什么选SQLx?

Rust生态中有多种数据库方案:

类型特点
DieselORM类型安全,编译期检查,但学习曲线陡
SQLxSQL工具库直写SQL,编译期验证,异步原生
SeaORMORM基于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就能安装你的工具。

⚠️ 常见误区

  1. 在生产环境使用unwrap()——CLI工具也不例外。文件可能不存在、JSON可能损坏、磁盘可能满了。用match或?优雅地处理每个可能的错误。
  2. 忘了处理SQL注入——永远使用参数绑定而不是字符串拼接来构建SQL。哪怕你觉得”只有自己用”,安全习惯应该成为本能。
  3. 数据库连接不使用连接池——每次请求都创建新连接的开销很大。SQLx的SqlitePool就是连接池,始终使用它。
  4. CLI参数验证不充分——用户输入什么你无法控制。优先级范围应该验证(1-5),文件路径应该检查是否存在。Clap提供了value_parser来做参数验证。
  5. 不写—help信息——好的CLI工具应该是自文档的。Clap自动从注释和属性生成帮助信息,但你需要写清楚每个参数的用途。

📝 掌握度自测

  1. 数据库基础:SQLx的query_asquery有什么区别?fetch_onefetch_allfetch_optional各返回什么类型?
  2. 安全性:为什么必须使用参数绑定而不是字符串拼接来构建SQL?写一个”危险的”和”安全的”查询作为对比。
  3. CLI设计:给我们的taskr工具增加一个edit子命令,允许修改任务标题。写出Clap结构体的定义。
  4. 连接池:解释数据库连接池的作用。为什么Web服务器中每个请求不应该创建新的数据库连接?
  5. 综合:把我们的taskr CLI工具改为使用SQLite数据库存储(替换JSON文件),写出关键的改动代码。

💡 自我评估

  • 答对5题:你已经能用Rust进行实际项目开发了,包括Web API和CLI工具。
  • 答对3-4题:核心概念已掌握,建议动手完成一个自己的小项目来巩固。
  • 答对0-2题:实战章节的关键是动手。建议把本章的代码完整地敲一遍跑起来,遇到问题就是最好的学习机会。

购买课程解锁全部内容

内存安全 + 零成本抽象:Rust 系统编程实战

¥29.90