做一个「每日一诗」的小功能时发现:想在自己的项目里用上古诗词数据,意外地麻烦——数据要自己爬、繁简混杂、中文搜索难搞。于是干脆用 Rust 写了一个完整的古诗词 REST API,最终收录约 13.3 万首唐诗宋词,单二进制部署,开源出来。

这篇文章记录整个构建过程和几个值得一聊的技术点。


为什么做

目标用户是做诗词类产品的开发者:每日推送的小程序、语文教育 App、诗词壁纸、古风网站、聊天机器人语料……

他们面对的痛点很现实:

  • 没数据。全唐诗、全宋词散落在各种爬取仓库里,格式不一,还要自己清洗
  • 中文搜索难做。LIKE '%关键词%' 在 13 万行上全表扫描,数据量上来后很慢
  • 繁简混杂。开源数据集大多是繁体,直接展示给用户不合适
  • 部署重。为一个读多写少的语料库上 MySQL/Redis,性价比很低

最终的技术选择:Rust + Actix Web + SQLite,单二进制 + 内嵌数据库,几十 MB 内存就能跑。


架构

分层很常规,但每一层都有明确职责:

1
2
3
4
5
6
客户端
→ 中间件层(CORS / gzip / 限流 / API Key 认证)
→ Handler 层(参数校验、统一响应封装)
→ Repository 层(SQL,QueryBuilder 动态拼接)
→ SQLite(含 FTS5 全文索引)
↘ 内存缓存(moka,每日一诗 / 详情查询)

所有接口返回统一结构,错误时 code 为 HTTP 状态码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"code": 0,
"message": "ok",
"data": {
"id": 1,
"title": "静夜思",
"author": "李白",
"dynasty": "唐",
"category": "诗",
"content": ["床前明月光", "疑是地上霜", "举头望明月", "低头思故乡"],
"tags": ["思乡", "月"],
"translation": "明亮的月光洒在床前……"
}
}

接口一共 8 个:列表(分页+筛选+搜索)、随机、每日一诗、详情、朝代分布、作者分布、标签分布,外加 Swagger 文档自动生成。


关键实现

1. 中文全文搜索:SQLite FTS5 trigram

这是整个项目最值得做的一个优化。

最初搜索是五个字段的 LIKE 拼接,13 万行时每次查询都是全表扫描。SQLite 自带 FTS5 全文索引,其中 trigram 分词器按 3 个字符切分,对中文这种没有空格的语言特别合适——不需要任何中文分词库:

1
2
3
4
5
6
7
8
9
10
11
12
13
CREATE VIRTUAL TABLE poems_fts USING fts5(
title, author, dynasty, content, tags,
content = 'poems',
content_rowid = 'id',
tokenize = 'trigram'
);

-- 外部内容表需要用触发器手动同步
CREATE TRIGGER poems_fts_ai AFTER INSERT ON poems BEGIN
INSERT INTO poems_fts(rowid, title, author, dynasty, content, tags)
VALUES (new.id, new.title, new.author, new.dynasty, new.content, new.tags);
END;
-- DELETE / UPDATE 触发器同理……

查询时按关键词长度路由:≥3 个字符走索引,更短的子串 trigram 无法命中,自动退回 LIKE:

1
2
3
4
5
6
7
8
if keyword.chars().count() >= 3 {
// FTS5 短语匹配,双引号转义防注入
qb.push("id IN (SELECT rowid FROM poems_fts WHERE poems_fts MATCH ")
.push_bind(format!("\"{}\"", keyword.replace('"', "\"\"")))
.push(")");
} else {
// 退回 LIKE 模糊匹配
}

效果:搜「明月几时有」直接命中苏轼的《水调歌头》,毫秒级返回。

2. 13 万首语料的导入与去重

数据来自开源数据集 chinese-poetry(MIT 协议)。写了一个独立的导入工具 src/bin/import.rs,处理三件事:

繁转简。数据集原文是繁体,用纯 Rust 的 ferrous-opencc(内置 OpenCC 字典,无需外部文件)在导入时统一转换:

1
2
let opencc = OpenCC::from_config(BuiltinConfig::T2s)?;
let title = opencc.convert(&poem.title);

去重。用「标题|作者|内容」的 FNV-1a 哈希作为 ext_key,加唯一索引 + INSERT OR IGNORE,导入工具可以安全重复执行:

1
CREATE UNIQUE INDEX IF NOT EXISTS idx_poems_ext_key ON poems (ext_key);

两层数据结构。这是个刻意的设计:

层级数量译文标签用途
精选集116 首有有每日一诗、网页演示
全量语料13.3 万首无无搜索、随机、列表

数据集本身只有原文没有译文,所以「每日一诗」如果从全库随机,很可能推一首残缺、没译文的诗。它改为只从精选集(ext_key IS NULL)里按日期确定性选取,保证每天推的都是带译文的经典名篇。

3. 限流器:滑动窗口 + 两个容易漏的点

限流用内存里的 HashMap<IpAddr, VecDeque<Instant>> 做滑动窗口。第一版有两个真实问题:

内存泄漏。初版只在某个 IP 再次访问时清理它的旧记录,那些只访问一次就消失的 IP,条目永远留在 HashMap 里,长期运行内存只增不减。修复为按窗口周期全局清扫:

1
2
3
4
5
6
7
fn sweep_if_due(&self, guard: &mut HashMap<IpAddr, VecDeque<Instant>>, now: Instant) {
// 每过一个窗口时长,清扫一次
guard.retain(|_, queue| {
queue.back()
.is_some_and(|t| now.saturating_duration_since(*t) <= self.window)
});
}

反向代理后误伤所有人。部署在 Nginx 后面时,peer_addr 拿到的永远是 Nginx 的 IP,限流会把所有用户当成一个人。加了 TRUST_PROXY 开关,开启后从 X-Forwarded-For 取最左侧一跳作为真实客户端 IP。

另外 API Key 的比较改成了常量时间比较,避免通过响应耗时差异推测密钥。

4. 其他「为别人调用」准备的细节

  • CORS 全开 + gzip 压缩:浏览器前端可以直接 fetch,不用自己搭代理;大响应体自动压缩
  • 响应缓存:每日一诗和按 ID 查询走 moka 内存缓存,同一天的请求基本不碰数据库
  • OpenAPI 文档:utoipa 自动生成,内置 Swagger UI

踩过的坑

utoipa 的 $ref 解析失败。Swagger 页面报 Could not resolve pointer: /components/schemas/crate.models.ErrorResponse。原因是在 #[utoipa::path] 宏里写了完整路径 body = crate::models::ErrorResponse,utoipa 会按字面量生成引用名,和组件注册的短名字 ErrorResponse 对不上。改成先 use 导入再写短名字即可。

数据集下载。直连 GitHub 超时,jsDelivr CDN 又因为仓库超过 50MB 拒绝了 master 分支;最终唐诗宋诗走 jsDelivr 的历史 tag,宋词走 GitHub 代理。更坑的是新版宋词文件格式变了——标题字段从 title 变成了词牌名 rhythmic,导入工具得兼容两种 schema:

1
2
3
4
5
6
7
struct RawPoem {
#[serde(default)]
title: Option<String>,
#[serde(default)]
rhythmic: Option<String>, // 新版宋词的词牌名
// ...
}

效果

最终入库数据:

  • 唐诗 57,600 首、宋诗 54,779 首、宋词 21,071 首、元曲等 5 首
  • 合计约 13.3 万首,全部简体、去重
  • 精选集 116 首全部带现代译文和标签(译文是逐首对照原文补写的)

工程状态:10 个单元/集成测试全过,cargo clippy -D warnings 零警告,CI 跑 fmt + clippy + test。

部署形态就是一个二进制加一个 SQLite 文件:cargo build --release 后上传,HOST=0.0.0.0 ./poem_api 即可运行,前面挂个 Nginx/Caddy 做 HTTPS。


最后

这个项目的初衷很朴素:把「找诗词数据」这件重复的事一次性解决干净。SQLite 的 FTS5 让中文搜索不需要引入任何重型依赖,Rust 让最终产物省心到「一个二进制丢到服务器上就能跑」。

项目已开源(MIT),代码里有完整的中文注释,也适合作为 Rust Web 分层、中间件、SQLx 的学习样例。

仓库地址:https://github.com/Jay-R-J/poetry_API


更新时间: 2026-09-06
作者: Jay