与Obsidian同步的博客搭建——Rust后端开发(下)
与Obsidian同步的博客搭建——Rust后端开发(下)
上一篇把 Vault 扫描、Markdown 渲染和缓存结构打好了底。这一篇继续把“能跑”推进到“能长期用”:路由要稳定,模板要可维护,搜索要够快,评论和统计要能落库,RSS 也得补上。我的目标不是做一个通用 CMS,而是做一套适合个人站点的轻后端,所以选型一直围绕一个原则,能少一个服务就少一个服务,能放进 SQLite 就别再拉新组件。
路由设计(main.rs)
先看主路由表,基本上整个站点的能力都在这里展开:
.route("/", get(handlers::home))
.route("/blog", get(handlers::blog::list))
.route("/blog/tag/{tag}", get(handlers::blog::by_tag))
.route("/blog/{category}", get(handlers::blog::category))
.route("/blog/{category}/{slug}", get(handlers::blog::post))
.route("/wiki", get(handlers::wiki::index))
.route("/wiki/{*path}", get(handlers::wiki::page))
.route("/todo", get(handlers::todo::overview))
.route("/api/search", get(handlers::search::query))
.route("/api/comments", post(handlers::comments::submit))
.route("/api/view", post(handlers::stats::record_view))
.route("/api/like", post(handlers::stats::toggle_like))
.route("/feed.xml", get(handlers::feed))
这套路由可以分成三层:
| 路由 | 作用 |
|---|---|
/ |
首页,展示最近文章、知识库入口和待办概览 |
/blog |
博客列表页,带分页 |
/blog/tag/{tag} |
标签归档页 |
/blog/{category} |
分类页,比如 /blog/rust |
/blog/{category}/{slug} |
文章详情页 |
/wiki、/wiki/{*path} |
知识库目录和具体页面 |
/todo |
待办总览 |
/api/search |
全站搜索接口,返回 JSON |
/api/comments |
提交评论 |
/api/view |
记录浏览量 |
/api/like |
点赞和取消点赞 |
/feed.xml |
RSS 订阅输出 |
这里我刻意用了目录型 URL,也就是 /blog/{category}/{slug},而不是 /blog/{slug}。原因有三个。
第一,可读性更好。你只看链接就知道这篇文章属于哪个分类。
第二,能直接映射 Vault 里的目录结构。比如 Obsidian 里文章放在 blog/rust/axum-routing.md,站点 URL 也就自然变成 /blog/rust/axum-routing。同步、扫描、生成链接都是一条线。
第三,避免 slug 全局冲突。技术博客里很容易出现不同分类下都叫 intro、setup、index 的文章。带上 category 以后,不需要为了“全站唯一 slug”额外加约束。
还有一个细节是 /blog/tag/{tag} 要单独挂在固定前缀下,而不是塞进 /blog/{category} 这类动态段里。标签和分类看起来都像“分组”,但语义完全不同。分类通常来自目录,数量稳定;标签来自文章元数据,可能一篇文章挂多个。把两者拆开以后,handler 职责更清楚,URL 也更不容易越长越乱。
共享状态也放在启动阶段一次性挂好:
pub struct AppState {
pub vault: Arc<RwLock<VaultData>>,
pub db: Arc<Mutex<Connection>>,
}
let state = Arc::new(AppState { vault, db });
let app = Router::new()
.route("/blog", get(handlers::blog::list))
.with_state(state);
VaultData 是典型的读多写少,所以用 Arc<RwLock<_>>。文章扫描完成后,大多数 handler 只是读取缓存。SQLite 连接则放在 Arc<Mutex<Connection>> 里,因为 rusqlite::Connection 本身是同步接口,对个人博客这类低并发站点来说,一个连接加互斥锁就够用了,没必要上连接池,更没必要为了这点数据再引入 Postgres。单机部署、备份和迁移,SQLite 都更省心。
Handler 实现
博客部分主要有三个 handler:列表页、分类页、文章页。
列表页的核心工作是分页。你先从 VaultData 里拿到全部已发布文章,按日期倒序,再根据 page 参数切片。分类页跟它差不多,只是多了一层 category == xxx 的过滤。标签页 by_tag 也是同一套路子,先筛选,再复用列表模板。
分页逻辑本身很简单,关键是别把它散在模板里:
let page = query.page.unwrap_or(1).max(1);
let start = (page - 1) * PAGE_SIZE;
let posts = all_posts
.into_iter()
.skip(start)
.take(PAGE_SIZE)
.collect::<Vec<_>>();
文章页则是按 (category, slug) 精确查找,再顺手把浏览量、点赞数和评论一起查出来。一个典型的 handler 大概长这样:
pub async fn post(
State(state): State<Arc<AppState>>,
Path((category, slug)): Path<(String, String)>,
) -> Result<Html<String>, AppError> {
let vault = state.vault.read().await;
let post = vault
.find_post(&category, &slug)
.ok_or(AppError::NotFound)?
.clone();
drop(vault);
let conn = state.db.lock().unwrap();
let view_count = load_view_count(&conn, &post.slug)?;
let like_count = load_like_count(&conn, &post.slug)?;
let comments = load_comments(&conn, &post.slug)?;
let template = BlogPostTemplate {
post,
view_count,
like_count,
comments,
};
Ok(Html(template.render()?))
}
这个写法有几个小点值得注意。
先读 VaultData,拿到文章后马上 clone 出来,然后尽快释放读锁。这样做可以避免后面查数据库时一直占着锁。
数据库查询尽量收敛在几个小函数里,比如 load_view_count、load_comments,这样 handler 本身只保留“取数据,然后渲染”的主流程,读起来清楚很多。
列表页要显示浏览量时,我不会为每篇文章单独查一次数据库,那样很快就会变成 N+1 查询。更合适的办法是先跑一条聚合 SQL:
SELECT post_slug, COUNT(*) AS views
FROM page_views
GROUP BY post_slug;
拿到结果后塞进 HashMap<String, i64>,再和文章列表合并。首页、分类页、标签页都能复用这套统计数据。
Askama 模板系统
页面渲染我用的是 Askama,而不是在 handler 里手写 HTML 字符串。模板目录结构可以保持得很简单:
templates/
base.html
home.html
blog/
list.html
category.html
post.html
wiki/
index.html
page.html
todo/
overview.html
核心思路就是 base.html 负责公共布局,其他页面继承它,只填各自的内容块。比如文章页模板:
#[derive(Template)]
#[template(path = "blog/post.html")]
pub struct BlogPostTemplate {
pub post: BlogPost,
pub view_count: i64,
pub like_count: i64,
pub comments: Vec<Comment>,
}
{% extends "base.html" %}
{% block content %}
<article class="post">
<h1>{{ post.title }}</h1>
<p>{{ post.date }}</p>
{{ post.html|safe }}
</article>
{% endblock %}
Askama 最大的好处不是“写起来像 Jinja”,而是编译时就能做类型检查。假设你在模板里写了 {{ post.summary }},但 BlogPostTemplate 里根本没这个字段,项目在编译阶段就会报错,而不是等上线后用户点进页面才发现 500。对 Rust 项目来说,这种提前暴露问题的体验非常舒服。
另外,Askama 默认会转义普通字符串,所以评论内容这类用户输入不会直接变成 HTML。只有文章正文这种已经过 Markdown 渲染和清洗的内容,我才显式加 |safe。
搜索功能(handlers/search.rs)
搜索这里我选的是 SQLite FTS5,没有上 Tantivy。原因很现实,这个项目的数据量不大,文章、知识库、待办本来就已经在 SQLite 和内存缓存里,FTS5 已经能提供足够好的全文搜索能力。继续引入 Tantivy,意味着你还要维护额外的索引目录、写入流程和同步逻辑,对个人站点来说收益不大。
建索引的时机我放在扫描完成之后。每次 Vault 重新扫描,就把标题和正文纯文本重建进 search_index 虚拟表。下面这段只演示博客文章,wiki 和 todo 也是同样的写法:
fn rebuild_search_index(conn: &Connection, vault: &VaultData) -> rusqlite::Result<()> {
conn.execute("DELETE FROM search_index", [])?;
let mut stmt = conn.prepare(
"INSERT INTO search_index(title, content, path)
VALUES (?1, ?2, ?3)",
)?;
for post in &vault.blog_posts {
stmt.execute(params![post.title, post.plain_text, post.url])?;
}
Ok(())
}
查询 handler 则直接返回 JSON,前端用 htmx 去请求,再把结果片段插到搜索框下方:
#[derive(Serialize)]
struct SearchHit {
title: String,
path: String,
snippet: String,
}
pub async fn query(
State(state): State<Arc<AppState>>,
Query(params): Query<SearchParams>,
) -> Result<Json<Vec<SearchHit>>, AppError> {
let conn = state.db.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT title, path,
snippet(search_index, 1, '<mark>', '</mark>', '...', 24)
FROM search_index
WHERE search_index MATCH ?1
LIMIT 20",
)?;
let rows = stmt.query_map([params.q.as_str()], |row| {
Ok(SearchHit {
title: row.get(0)?,
path: row.get(1)?,
snippet: row.get(2)?,
})
})?;
Ok(Json(rows.collect::<Result<Vec<_>, _>>()?))
}
这样做的好处是接口和页面解耦。搜索结果既可以在博客页用,也可以在知识库页用,后面要不要加高亮、分组、排序,都不用改动路由设计。
FTS5 还有一个很实际的优势,它和正文数据离得足够近。你扫描 Vault 时更新一次缓存,再顺手刷新索引,生命周期非常统一,不会出现“页面已经更新,但搜索还没刷到”的双写不一致问题。对这种内容量不大、更新频率不高的站点,这种简单性比极限性能更值钱。
评论系统(handlers/comments.rs)
评论数据直接落到 SQLite。对个人博客来说,这样已经足够稳定,而且查询非常直接。表结构至少要包含文章标识、作者名、评论内容、创建时间和 ip_hash。我不会直接存原始 IP,而是先做哈希,保留频率控制能力,同时少碰一点敏感数据。
一个最小可用的提交 handler 可以这样写:
pub async fn submit(
State(state): State<Arc<AppState>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Form(form): Form<CommentForm>,
) -> Result<Json<ApiMessage>, AppError> {
let ip_hash = hash_ip(addr.ip());
let conn = state.db.lock().unwrap();
let recent_count: i64 = conn.query_row(
"SELECT COUNT(*) FROM comments
WHERE ip_hash = ?1
AND created_at > datetime('now', '-1 minute')",
[ip_hash.as_str()],
|row| row.get(0),
)?;
if recent_count > 0 {
return Err(AppError::RateLimited);
}
conn.execute(
"INSERT INTO comments(post_slug, author, content, created_at, ip_hash)
VALUES (?1, ?2, ?3, datetime('now'), ?4)",
params![form.post_slug, form.author, form.content, ip_hash],
)?;
Ok(Json(ApiMessage::ok("评论已提交")))
}
这里的反垃圾策略非常朴素,同一 IP 一分钟只允许发一条。它挡不住所有攻击,但对个人站点已经能过滤掉大部分脚本刷评论。后面如果你想再加邮箱校验、审核状态、关键词过滤,也都是在这套结构上往上叠。
评论接口我这里先保持匿名,不接登录系统。原因不是功能做不到,而是成本不划算。个人博客的互动量通常不高,先把输入校验、频率限制和可读的后台数据做好,已经能满足大多数场景。
评论展示时,Askama 会自动转义内容,所以用户输入的 HTML 不会直接执行,这一点也省掉了不少手动处理。
互动统计(handlers/stats.rs)
互动统计分成两块,浏览量和点赞。
浏览量的实现很直接,文章页加载后发一个 POST /api/view,服务端把 post_slug、当前时间、ip_hash 写进 page_views。之所以单独拆成接口,而不是在文章 handler 里顺手加一行 SQL,是因为这样更灵活。后面如果你要做防重复、延迟上报、或者给缓存留空间,都更好改。
pub async fn record_view(
State(state): State<Arc<AppState>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Json(input): Json<PostAction>,
) -> Result<Json<CountResponse>, AppError> {
let ip_hash = hash_ip(addr.ip());
let conn = state.db.lock().unwrap();
conn.execute(
"INSERT INTO page_views(post_slug, viewed_at, ip_hash)
VALUES (?1, datetime('now'), ?2)",
params![input.post_slug, ip_hash],
)?;
let count = load_view_count(&conn, &input.post_slug)?;
Ok(Json(CountResponse { count }))
}
点赞则用 toggle 机制。同一个 client_id 对同一篇文章只能保留一条点赞记录,再点一次就删除。client_id 不靠登录系统,而是前端第一次访问时生成一个 UUID,存进 localStorage,之后每次请求都带上来。
pub async fn toggle_like(
State(state): State<Arc<AppState>>,
Json(input): Json<LikeInput>,
) -> Result<Json<LikeState>, AppError> {
let conn = state.db.lock().unwrap();
let existed: bool = conn.query_row(
"SELECT EXISTS(
SELECT 1 FROM likes
WHERE post_slug = ?1 AND client_id = ?2
)",
params![input.post_slug, input.client_id],
|row| row.get(0),
)?;
if existed {
conn.execute(
"DELETE FROM likes WHERE post_slug = ?1 AND client_id = ?2",
params![input.post_slug, input.client_id],
)?;
} else {
conn.execute(
"INSERT INTO likes(post_slug, client_id, created_at)
VALUES (?1, ?2, datetime('now'))",
params![input.post_slug, input.client_id],
)?;
}
let count = load_like_count(&conn, &input.post_slug)?;
Ok(Json(LikeState { liked: !existed, count }))
}
列表页也显示浏览量,这一点别忘了。因为统计信息已经在数据库里,列表、分类、首页都可以统一查聚合结果,再塞回模板数据。这样用户在文章列表里就能直接看到“哪几篇更常被点开”。
RSS 订阅(handlers/mod.rs)
RSS 很适合放在 handlers/mod.rs 这种总入口里,因为它本质上就是把最近文章重新组织成一份 XML。路由只有一个,/feed.xml。
实现时不用追求复杂,按 RSS 2.0 规范把 channel 和 item 拼出来就行,至少要有标题、链接、发布时间和摘要:
pub async fn feed(
State(state): State<Arc<AppState>>,
) -> Result<impl IntoResponse, AppError> {
let vault = state.vault.read().await;
let items = vault.blog_posts.iter().take(20).map(|post| {
format!(
"<item><title>{}</title><link>{}</link><pubDate>{}</pubDate><description>{}</description></item>",
xml_escape(&post.title),
xml_escape(&post.absolute_url),
post.pub_date_rfc2822(),
xml_escape(&post.summary),
)
}).collect::<String>();
let body = format!(
r#"<?xml version=\"1.0\" encoding=\"UTF-8\"?>
<rss version=\"2.0\"><channel>{}</channel></rss>"#,
items
);
Ok(([(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")], body))
}
只要 XML 转义处理好,常见阅读器都能直接订阅。对博客来说,RSS 依旧很有用,它是最轻的“主动推送”方式。
数据库迁移
到这一步,数据库里已经有四类数据:评论、搜索索引、浏览量、点赞。我的做法是拆成两份迁移脚本:
migrations/001_init.sql,创建comments和search_indexmigrations/002_stats.sql,创建page_views和likes
likes 表我会额外加一个唯一约束或唯一索引,锁住 (post_slug, client_id) 这组组合,避免重复点赞把数据写脏。
启动时自动执行迁移也很简单,因为 SQLite 支持 CREATE TABLE IF NOT EXISTS 和 CREATE VIRTUAL TABLE IF NOT EXISTS。项目启动后先拿到连接,再顺序执行内置 SQL:
fn run_migrations(conn: &Connection) -> rusqlite::Result<()> {
for sql in [
include_str!("../migrations/001_init.sql"),
include_str!("../migrations/002_stats.sql"),
] {
conn.execute_batch(sql)?;
}
Ok(())
}
这个方案没有引入额外迁移框架,但对当前阶段够用了。个人博客的数据库演化速度不快,先把结构控制在可读的 SQL 文件里,出了问题也容易定位。等后面表更多了,再考虑专门的 migration crate 也不晚。
小结
至此后端功能完整——博客、知识库、待办、搜索、评论、统计、RSS 全部就绪。整个服务的核心思路其实一直没变:Axum 负责路由和 handler,Askama 负责安全地输出 HTML,SQLite 同时承担存储、搜索和统计,VaultData 则把 Obsidian 同步过来的内容组织成内存缓存。
这样做的好处是结构很收敛。你不用维护额外搜索服务,也不用单独拉一个数据库实例,站点的功能却已经够完整。下一篇我会开始讲前端美化,重点是页面布局、暗色主题、代码块、图片交互和阅读体验。
Comments (0)
No comments yet. Be the first!