18.1 这节课解决什么问题
第 17 课的 HistoryStore 还是内存假实现------App 一重启历史全丢。本课把它换成 SQLite 本地数据库,回答三件事:
① rusqlite:怎么连、怎么建表、怎么读写(参数化查询防注入)
② 表结构 & 迁移:schema 版本升级不丢数据
③ 把 sqlite 实现塞回 core:服务层测试一行不改(17 课接口红利的兑现)
同时处理 SQLite 在真实 App 里最常见的"最后 20%":忙锁(database is locked)、连接共享、并发读写。这些坑恰恰是 UniFFI(19-21 课)把 core 暴露给多个 UI 线程后必然撞上的。
💡 为什么本地存储选 SQLite 而不是"写个 JSON 文件"?历史记录会持续增长,需要随机查询单会话/删除/排序 ------JSON 全量读改写在大数据量下又慢又容易坏。SQLite 单文件、零服务、SQL 能力完整,是移动/桌面端本地存储的事实标准。会话与消息的结构化适合表;而"聊天原样快照"之类大对象适合存成 JSON 列/独立文件------两种都会在本课出现。
18.2 rusqlite 起步:打开连接 + 最小读写
toml
# core/Cargo.toml 追加
[dependencies]
rusqlite = { version = "0.32", features = ["bundled"] } # bundled:免系统 SQLite,跨端更省心
chrono 不再需要------时间用 i64 毫秒(第 17 课 now_ms)
rust
// store/sqlite.rs ------ 新的模块
use rusqlite::Connection;
use std::path::Path;
pub struct SqliteStore {
conn: Connection,
}
impl SqliteStore {
/// 打开(或创建)数据库文件
pub fn open(path: impl AsRef<Path>) -> Result<Self, rusqlite::Error> {
let conn = Connection::open(path)?;
conn.pragma_update(None, "journal_mode", "WAL")?; // 见 18.5
conn.pragma_update(None, "foreign_keys", "ON")?;
let store = SqliteStore { conn };
store.migrate()?; // 见 18.3
Ok(store)
}
}
最小读写样例(临时库,:memory: 可用于测试):
rust
use rusqlite::{params, Connection};
fn main() -> rusqlite::Result<()> {
let conn = Connection::open_in_memory()?;
conn.execute("CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT NOT NULL)", [])?;
// 参数化插入(占位符 ?1,值作为参数传入 → 自动防注入)
conn.execute("INSERT INTO kv (k, v) VALUES (?1, ?2)", params!["greeting", "你好"])?;
// 参数化查询
let v: String = conn.query_row(
"SELECT v FROM kv WHERE k = ?1",
["greeting"],
|row| row.get(0),
)?;
println!("{v}");
// 多行读取(迭代 Statement)
let mut stmt = conn.prepare("SELECT k, v FROM kv")?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
for pair in rows {
println!("{:?}", pair?);
}
Ok(())
}
⚠️ 永远用参数化,绝不要字符串拼接 SQL:
rust// ❌ 注入风险 + 引号转义地狱 let sql = format!("INSERT INTO kv VALUES ('{}')", user_input); // ✅ 参数绑定:rusqlite 负责转义,SQL 与数据分离 conn.execute("INSERT INTO kv VALUES (?1)", params![user_input])?;
18.3 表结构与迁移:schema 版本化
18.3.1 建表 DDL
sql
-- sessions:会话表
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, -- "s-<ms>-<rand>"
title TEXT NOT NULL,
created_at_ms INTEGER NOT NULL
);
-- messages:消息表
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
role TEXT NOT NULL, -- 'user' / 'assistant' / 'system'
content TEXT NOT NULL,
created_at_ms INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_session
ON messages (session_id, created_at_ms);
ON DELETE CASCADE:删会话自动连带删消息(17 课 delete_session 就两个动作,其实库层一步到位);- 索引建在
(session_id, created_at_ms)上:查单会话历史只走索引,随数据增长不退化; role用 TEXT 存,读出来再转Role枚举(写入反过来)------不存 SQLite 不认识的 Rust enum。
18.3.2 用 PRAGMA user_version 做轻量迁移
rust
impl SqliteStore {
/// 简单迁移:记录 schema 版本号,逐版升级
fn migrate(&self) -> rusqlite::Result<()> {
let cur: i64 = self.conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
if cur < 1 {
self.conn.execute_batch(
"BEGIN;
CREATE TABLE IF NOT EXISTS sessions (...同 18.3.1...);
CREATE TABLE IF NOT EXISTS messages (...);
CREATE INDEX ...;
PRAGMA user_version = 1;
COMMIT;",
)?;
}
// 将来加字段/加表:if cur < 2 { ... PRAGMA user_version = 2; }
Ok(())
}
}
💡 真实项目可用
rusqlite_migration这类 crate 管理有序迁移脚本;但"用户版本号 + 逐段 execute_batch"的原生写法已经足够支撑本课程规模,且零额外依赖。关键是养成习惯:任何 schema 变更都必须是"新增一段 if 版本号"的迁移,而不是改旧 SQL------否则老用户升级必炸。
18.4 实现 HistoryStore for SqliteStore
rust
use crate::models::{Message, Role, Session};
use crate::store::{HistoryStore, StoreError};
use rusqlite::params;
impl HistoryStore for SqliteStore {
fn create_session(&self, session: &Session) -> Result<(), StoreError> {
self.conn
.execute(
"INSERT INTO sessions (id, title, created_at_ms) VALUES (?1, ?2, ?3)",
params![session.id, session.title, session.created_at_ms],
)
.map_err(map_err)?;
Ok(())
}
fn list_sessions(&self, limit: u32) -> Result<Vec<Session>, StoreError> {
let mut stmt = self
.conn
.prepare("SELECT id, title, created_at_ms FROM sessions ORDER BY created_at_ms DESC LIMIT ?1")
.map_err(map_err)?;
let rows = stmt
.query_map([limit], |row| {
Ok(Session {
id: row.get(0)?,
title: row.get(1)?,
created_at_ms: row.get(2)?,
})
})
.map_err(map_err)?;
rows.collect::<Result<Vec<_>, _>>().map_err(map_err)
}
fn delete_session(&self, session_id: &str) -> Result<(), StoreError> {
self.conn
.execute("DELETE FROM sessions WHERE id = ?1", params![session_id])
.map_err(map_err)?; // messages 靠 CASCADE 一起删
Ok(())
}
fn append_message(&self, msg: &Message) -> Result<(), StoreError> {
let role = role_to_db(&msg.role); // Role → &str
self.conn
.execute(
"INSERT INTO messages (id, session_id, role, content, created_at_ms)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![msg.id, msg.session_id, role, msg.content, msg.created_at_ms],
)
.map_err(map_err)?;
Ok(())
}
fn list_messages(&self, session_id: &str) -> Result<Vec<Message>, StoreError> {
let mut stmt = self
.conn
.prepare(
"SELECT id, session_id, role, content, created_at_ms
FROM messages WHERE session_id = ?1 ORDER BY created_at_ms ASC",
)
.map_err(map_err)?;
let rows = stmt
.query_map([session_id], |row| {
let role: String = row.get(2)?;
Ok(Message {
id: row.get(0)?,
session_id: row.get(1)?,
role: role_from_db(&role),
content: row.get(3)?,
created_at_ms: row.get(4)?,
})
})
.map_err(map_err)?;
rows.collect::<Result<Vec<_>, _>>().map_err(map_err)
}
fn clear_messages(&self, session_id: &str) -> Result<(), StoreError> {
self.conn
.execute("DELETE FROM messages WHERE session_id = ?1", params![session_id])
.map_err(map_err)?;
Ok(())
}
}
/// 把 rusqlite 错误统一转成 StoreError(第 17 课的契约类型)
fn map_err(e: rusqlite::Error) -> StoreError {
match e {
rusqlite::Error::QueryReturnedNoRows => StoreError::NotFound("记录不存在".into()),
other => StoreError::Io(other.to_string()),
}
}
fn role_to_db(role: &Role) -> &'static str {
match role { Role::User => "user", Role::Assistant => "assistant", Role::System => "system" }
}
fn role_from_db(s: &str) -> Role {
match s { "assistant" => Role::Assistant, "system" => Role::System, _ => Role::User }
}
💡 读 DB 行 → Rust struct 的样板循环(
query_map+row.get)在代码里很常见。量大后可考虑serde的 rusqlite 适配(derive直接映射行),但先学会手写版,错误类型和列顺序都由你掌控。
18.5 并发与"database is locked":真实 App 必修课
18.5.1 问题从哪来
第 19 课起,UI 的多个线程/任务可能同时调 append_message(用户连发多条)、list_sessions(刷新列表)、delete_session。SQLite 默认一次只允许一个写者 ,写锁没释放时另一写会报 database is locked。
rusqlite 的 Connection 默认不是 Sync (含内部缓存),直接放进 Box<dyn HistoryStore> 并跨线程调用会编译不过。三种处理姿势:
| 姿势 | 做法 | 适用 |
|---|---|---|
| A. 每操作新开连接 | 线程/调用各自 Connection::open |
演示、低频 |
| B. 进程级单连接 + 外部 Mutex | Mutex<Connection> 串行化所有访问 |
简单可靠(本课采用) |
| C. 连接池 | r2d2_sqlite 之类 |
高并发、多读多写 |
本课采用 B :把 Connection 包在 Mutex 里,天然满足 Sync 且把写冲突变成"排队"(加上 18.2 已开的 WAL 模式,读不阻塞写):
rust
use std::sync::Mutex;
pub struct SqliteStore {
conn: Mutex<Connection>, // 内部串行化;对外仍是 Sync
}
impl SqliteStore {
pub fn open(path: impl AsRef<std::path::Path>) -> Result<Self, rusqlite::Error> {
let conn = Connection::open(path)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
let store = SqliteStore { conn: Mutex::new(conn) };
store.migrate()?;
Ok(store)
}
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Connection>, StoreError> {
self.conn.lock().map_err(|_| StoreError::Io("存储锁中毒".into()))
}
}
然后每个方法开头 let conn = self.lock()?;,后续全部走 conn:
rust
impl HistoryStore for SqliteStore {
fn create_session(&self, session: &Session) -> Result<(), StoreError> {
let conn = self.lock()?;
conn.execute(
"INSERT INTO sessions (id, title, created_at_ms) VALUES (?1, ?2, ?3)",
params![session.id, session.title, session.created_at_ms],
)
.map_err(map_err)?;
Ok(())
}
// ......其余方法同样先 lock()
}
⚠️ 同步方法里用
std::sync::Mutex没问题(不跨.await持有)。永远不要在持锁期间调用 UI/网络------锁粒度 = 一条 SQL。SQLite 单次操作毫秒级,串行化完全可接受。
18.5.2 WAL 模式是什么
ini
journal_mode = WAL(Write-Ahead Logging):
- 写操作先追加到 .wal 文件,再择机合入主库
- 效果:读写不互相阻塞(一个写者 + 多个读者可同时进行)
- App 常见标配;配合 busy_timeout 可进一步缓解极端冲突
rust
// 极端冲突兜底:等锁最长 5 秒而不是立刻报错
conn.busy_timeout(std::time::Duration::from_secs(5))?;
18.6 把真 store 接回 service:17 课测试一行不改
rust
// core 集成测试:sqlite_smoke.rs
use my_ai_core::llm::chat::ChatLlm;
use my_ai_core::models::Message;
use my_ai_core::service::AssistantService;
use my_ai_core::store::{HistoryStore, SqliteStore};
struct FakeLlm; // 同 17.6
#[tokio::test]
async fn sqlite_roundtrip_via_service() {
// 临时文件库,测完自动删
let dir = std::env::temp_dir();
let path = dir.join(format!("ai_test_{}.db", std::process::id()));
let store = SqliteStore::open(&path).unwrap();
let service = AssistantService::new(Box::new(FakeLlm), Box::new(store), "系统提示".into());
let session = service.new_session("持久化测试").await.unwrap();
let mut got = String::new();
service
.ask_stream(&session.id, "存下来了吗", Box::new(|d| got.push_str(d)))
.await
.unwrap();
assert!(got.contains("你好,世界"));
// 重新打开同一个文件(模拟 App 重启):数据还在
let store2 = SqliteStore::open(&path).unwrap();
let sessions = store2.list_sessions(10).unwrap();
assert_eq!(sessions.len(), 1);
let msgs = store2.list_messages(&session.id).unwrap();
assert_eq!(msgs.len(), 2);
std::fs::remove_file(&path).ok();
}
验收瞬间 :同一段 service 测试,把 MemStore::default() 换成 SqliteStore::open(...) 就跑通------17 课设计的接口抽象兑现了。
18.7 📝 动手练习
参考实现放 code/18-sqlite/(写作时同步给出)。
- rusqlite 最小读写 :
:memory:建表/插入/查询/迭代,练参数化与query_map收行。 - 防注入验证 :向 18.2 的插入函数传
x'); DROP TABLE kv; --,确认它只是被当作普通字符串存进去(参数化生效)。 - 完整实现 HistoryStore for SqliteStore:按 18.4 补全全部 6 个方法,并跑通 18.6 的冒烟测试。
- 迁移实验:先建 version=1 的库,再用"version=2:给 messages 加一列 complete INTEGER DEFAULT 1"的迁移脚本升级,验证老数据不丢、新字段可写。
- 忙锁观察 :开两个
SqliteStore指向同一文件,线程 A 在事务里sleep 200ms后提交,线程 B 同时写------先不设 busy_timeout 记录报错,再设 5s 观察排队成功。 - 并发冒烟:8 个 tokio 任务各 append 50 条消息到同一会话,结束后断言总数 = 400(体会 Mutex 串行化 + WAL 的效果)。
- 综合(重点) :给
messages补complete列并在模型加字段,让ask_stream断流时(FakeLlm 抛StreamInterrupted)能落一条complete=false的尾巴消息------为 19 课"流中断可恢复"预演。
验收门禁 :能写出带 ?1 占位 + params! 的最小读写;能说出 WAL 解决了什么、Mutex 解决了什么;能解释为什么 schema 变更必须走版本迁移而不是改旧 SQL。
✅ 本节小结
- rusqlite :
bundled特性免系统依赖;:memory:便于测试;永远参数化 SQL; - schema :sessions/messages 两张表 + 复合索引 +
ON DELETE CASCADE;PRAGMA user_version做增量迁移; - 枚举映射:DB 存 TEXT,行读取时再转回 Rust enum;
- 并发 :
Mutex<Connection>串行化(天然 Sync)、WAL 读写分离、busy_timeout兜底; - 接口红利:换 store 实现,service 测试与调用方零改动;
- 纪律:持锁不做 IO、schema 只加不改、迁移逐版本前进。
下一课预告:第 19 课《UniFFI 导出核心能力》------把 17-18 课的 core 交给各端:UniFFI 生成 Swift/Kotlin/Python 绑定、async 方法导出、复杂类型(Session/Message/enum 错误)映射、以及"如何在壳与 core 之间传回调"。这是实战篇的技术主峰。