与Obsidian同步的博客搭建——Rust后端开发(上)
与Obsidian同步的博客搭建——Rust后端开发(上)
前一篇把整套方案的思路说清楚了,这一篇开始动手写后端。目标先定得朴素一点,不碰路由、不碰页面样式,也不急着把评论、搜索和统计一次写完。我们先把最底层的数据流打通,让 Rust 服务能从 Obsidian Vault 里读出内容,解析成结构化数据,后面网页层直接拿来用。
这一步看起来偏底层,其实决定了后面好不好写。只要扫描器、解析器和监听器的边界清楚,博客、Wiki、Todo 三类内容就都能走同一条管线。对 Rust 初学者来说,这也是一个很好的练手机会,你会同时接触配置读取、结构体建模、文件系统遍历、异步共享状态,还有一点点文本处理。
环境准备
安装 Rust(rustup)
先装 Rust 工具链。官方推荐的方式就是 rustup,它会一起帮你管理 rustc、cargo 和不同版本的 toolchain。装完以后先确认命令可用:
rustc --version
cargo --version
rustup show
如果你是第一次接触 Rust,可以把 cargo 理解成“创建项目、拉依赖、编译运行”的总入口,后面几乎每天都会用到它。这个项目用的是 Rust 2024 edition,所以新建项目后记得确认 Cargo.toml 里是:
edition = "2024"
创建项目:cargo new bedic-web
项目直接从空目录开始:
cargo new bedic-web
cd bedic-web
执行完以后你会得到一个最基础的 Rust 二进制项目,默认带一个 src/main.rs。现在先别急着写业务,先把目录和依赖搭好。这样做的好处是,后面每加一个模块,你都知道它该放在哪里,而不是把所有代码都堆进 main.rs。
项目依赖(Cargo.toml)
这一套博客实际会用到不少 crate,但这一篇只挑核心的讲。先把最关键的依赖放进 Cargo.toml:
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
askama = "0.14"
comrak = "0.37"
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
rusqlite = { version = "0.31", features = ["bundled"] }
notify = "8"
tower-http = { version = "0.6", features = ["fs"] }
它们各自负责的事情可以先记成下面这张表:
| 依赖 | 用途 |
|---|---|
axum = "0.8" |
Web 框架,后面负责路由、状态注入和响应返回 |
tokio |
异步运行时,服务器启动、文件监听和共享状态都靠它 |
askama |
模板引擎,模板在编译期就会检查,写错变量名能早点发现 |
comrak = "0.37" |
把 Markdown 渲染成 HTML |
serde + serde_yaml |
解析 Frontmatter 里的 YAML |
rusqlite |
连接 SQLite,本篇先把数据库层位置留好 |
notify = "8" |
监听 Vault 文件变化,保存后自动重扫 |
tower-http |
静态文件服务,下一篇会接到网页层 |
这里有两个点值得新手特别注意。
第一,askama 的优势不是“功能多”,而是稳。模板变量写错时,它会在编译期直接报错,比运行到线上才发现页面空白省心很多。
第二,tokio 不只是“为了异步而异步”。后面 watcher 要在后台一直监听文件变化,网页请求又要同时读取已经解析好的数据,没有运行时托底,这类代码会很快变乱。
目录结构设计
在开始写模块前,先把结构定下来:
bedic-web/
├── Cargo.toml
├── src/
│ ├── main.rs # 入口、路由、服务器启动
│ ├── config.rs # 配置(Vault路径、端口等)
│ ├── db.rs # 数据库初始化
│ ├── models.rs # 数据模型
│ ├── error.rs # 错误处理
│ └── vault/
│ ├── mod.rs # VaultScanner,扫描Vault目录
│ ├── parser.rs # Markdown解析 + Wikilink处理
│ └── watcher.rs # 文件变更监听(500ms防抖)
├── templates/ # Askama HTML模板
├── static/ # CSS/JS/图片
├── migrations/ # SQL迁移脚本
└── deploy/ # 部署配置
这里的关键想法只有一个,按职责拆,而不是按“功能先后”乱塞。vault/ 目录专门处理 Obsidian 内容读取,models.rs 专门定义数据长什么样,config.rs 只关心配置来源。你后面回头改代码时,会很清楚问题出在哪一层。
配置模块(config.rs)
配置模块的工作不复杂,就是把环境变量读出来,统一变成一个 Config 结构体。这样 main.rs 只需要调一次 Config::from_env(),后面所有模块都拿同一份配置。
use std::{env, path::PathBuf};
#[derive(Debug, Clone)]
pub struct Config {
pub vault_path: PathBuf,
pub port: u16,
}
impl Config {
pub fn from_env() -> Self {
let vault_path = env::var("BED_VAULT_PATH")
.unwrap_or_else(|_| "D:/Bed".to_string());
let port = env::var("BED_PORT")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(3000);
Self {
vault_path: PathBuf::from(vault_path),
port,
}
}
}
为什么不把路径和端口写死在代码里?因为开发环境、服务器环境、本地测试环境很可能都不一样。把它们收进环境变量后,项目迁移会轻松很多,也更符合 Rust 服务常见的配置方式。
数据模型(models.rs)
扫描 Vault 之前,先想清楚“扫出来的数据最后要长什么样”。如果模型先乱了,解析器再精巧也很难收尾。
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Frontmatter {
pub title: Option<String>,
pub date: Option<String>,
pub summary: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
pub draft: Option<bool>,
}
#[derive(Debug, Clone, Serialize)]
pub struct BlogPost {
pub slug: String,
pub title: String,
pub date: String,
pub summary: String,
pub tags: Vec<String>,
pub content: String,
pub category: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct WikiPage {
pub slug: String,
pub title: String,
pub content: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct TodoItem {
pub text: String,
pub done: bool,
pub source: String,
}
#[derive(Debug, Clone, Default)]
pub struct VaultData {
pub blog_posts: Vec<BlogPost>,
pub wiki_pages: Vec<WikiPage>,
pub todo_items: Vec<TodoItem>,
}
这里的 Frontmatter 只保留最常用字段,先别贪多。对初学者来说,模型越朴素,调试越轻松。BlogPost 里的 content 可以直接存渲染后的 HTML,这样后面模板层拿到就能输出。
还有一个项目里很关键的组合是 Arc<RwLock<VaultData>>。Arc 让多个任务共享同一份数据所有权,RwLock 让你同时支持“多读单写”。网页请求大多数时候只读数据,watcher 触发重扫时才写入,所以这个模型很顺手。
Vault 扫描器(vault/mod.rs)
扫描器的职责很明确,遍历 Vault 目录,把不同内容区分开,再交给解析器转换成结构体。这个项目里我们关心三个入口:10-blog/posts/、20-wiki/、40-todo/。
先看整体骨架:
use std::{path::{Path, PathBuf}, sync::Arc};
use tokio::sync::RwLock;
use crate::models::VaultData;
pub struct VaultScanner {
root: PathBuf,
pub data: Arc<RwLock<VaultData>>,
}
impl VaultScanner {
pub fn new(root: PathBuf) -> Self {
Self {
root,
data: Arc::new(RwLock::new(VaultData::default())),
}
}
pub async fn scan_all(&self) -> crate::error::AppResult<()> {
let blog_posts = self.scan_blog_posts(&self.root.join("10-blog/posts"))?;
let wiki_pages = self.scan_wiki_pages(&self.root.join("20-wiki"))?;
let todo_items = self.scan_todo_items(&self.root.join("40-todo"))?;
let mut data = self.data.write().await;
data.blog_posts = blog_posts;
data.wiki_pages = wiki_pages;
data.todo_items = todo_items;
Ok(())
}
}
这段代码里最重要的是“先扫到临时变量,再一次性写回共享状态”。不要一边遍历一边往 RwLock 里塞数据,那样锁持有时间会变长,后面请求一多就容易卡住。
博客文章的扫描逻辑通常会多一步,因为要把子文件夹名当成分类:
fn scan_blog_posts(&self, root: &Path) -> crate::error::AppResult<Vec<BlogPost>> {
let mut posts = Vec::new();
for entry in std::fs::read_dir(root)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let category = entry.file_name().to_string_lossy().to_string();
self.collect_markdown_files(&entry.path(), &mut |path| {
let post = parser::parse_blog_post(path, &category)?;
posts.push(post);
Ok(())
})?;
}
posts.sort_by(|a, b| b.date.cmp(&a.date));
Ok(posts)
}
比如 10-blog/posts/Rust/hello.md,那它的 category 就是 Rust。这么做很自然,也符合 Obsidian 里按目录整理文章的习惯。20-wiki/ 和 40-todo/ 的扫描思路相同,只是最终映射到的结构体不同。
Markdown 解析器(vault/parser.rs)
解析器做三件事,拆 Frontmatter、处理 Wikilink、把 Markdown 渲染成 HTML。顺序最好固定,不然代码会绕。
先拆 Frontmatter:
fn split_frontmatter(source: &str) -> (Option<&str>, &str) {
if let Some(rest) = source.strip_prefix("---\n") {
if let Some(pos) = rest.find("\n---\n") {
let yaml = &rest[..pos];
let body = &rest[pos + 5..];
return (Some(yaml), body);
}
}
(None, source)
}
拿到 YAML 文本后,就能交给 serde_yaml:
let (yaml_text, markdown) = split_frontmatter(&source.replace("\r\n", "\n"));
let frontmatter = yaml_text
.map(serde_yaml::from_str)
.transpose()?
.unwrap_or_default();
这里先把换行统一成 \n,可以少踩很多 Windows 文本文件的坑。transpose() 这类写法刚看会有点绕,它的意思其实很简单,把 Option<Result<T, E>> 转成 Result<Option<T>, E>,这样 ? 就能继续往外抛错。
接着配置 Comrak:
use comrak::{markdown_to_html, Options};
let mut options = Options::default();
options.extension.table = true;
options.extension.footnotes = true;
options.extension.strikethrough = true;
options.extension.tasklist = true;
options.extension.description_lists = true;
let html = markdown_to_html(&markdown_with_links, &options);
这些扩展开启后,Obsidian 里常见的表格、脚注、删除线、任务列表都能正常渲染,网页表现会和你在笔记里看到的更接近。
最后处理 Wikilink。这个项目约定把 [Page Name](/wiki/Page Name) 转成 /wiki/page-name:
fn replace_wikilinks(input: &str) -> String {
let mut output = String::new();
let mut rest = input;
while let Some(start) = rest.find("[") {
output.push_str(&rest[..start]);
let after = &rest[start + 2..];
if let Some(end) = after.find("](/wiki/") {
output.push_str(&rest[..start]);
let after = &rest[start + 2..];
if let Some(end) = after.find(")") {
let page_name = after[..end].trim();
let slug = page_name.to_lowercase().replace(' ', "-");
output.push_str(&format!(
r#"<a href="/wiki/{slug}">{page_name}</a>"#
));
rest = &after[end + 2..];
} else {
output.push_str(&rest[start..]);
return output;
}
}
output.push_str(rest);
output
}
这版实现很基础,但足够实用。后面如果你想支持 [显示文字](/wiki/页面) 这种语法,再单独扩展就行。教程阶段先把主路径跑通,比一次把所有边角都补齐更重要。
文件监听器(vault/watcher.rs)
手动重启服务当然也能看到新内容,但体验太差。既然 Vault 本来就在同步,最自然的做法就是监听目录变化,然后自动重扫。
notify 8 的思路是“底层回调 + 你自己接管后续逻辑”。这里我们再加一层 500ms 防抖,避免 Obsidian 保存文件时触发一串重复事件。
use std::{path::PathBuf, sync::Arc};
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use tokio::{sync::mpsc, time::{sleep, Duration}};
pub struct VaultWatcher {
_watcher: RecommendedWatcher,
}
impl VaultWatcher {
pub fn new(scanner: Arc<VaultScanner>, path: PathBuf) -> notify::Result<Self> {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut watcher = notify::recommended_watcher(move |event| {
let _ = tx.send(event);
})?;
watcher.watch(&path, RecursiveMode::Recursive)?;
tokio::spawn(async move {
while rx.recv().await.is_some() {
loop {
let delay = sleep(Duration::from_millis(500));
tokio::pin!(delay);
tokio::select! {
_ = &mut delay => {
let _ = scanner.scan_all().await;
break;
}
msg = rx.recv() => {
if msg.is_none() {
return;
}
}
}
}
}
});
Ok(Self { _watcher: watcher })
}
}
这里有两个 Rust 细节可以顺手记一下。
RecommendedWatcher要被某个结构体持有,不能让它函数结束就掉出作用域,不然监听会直接失效。- 防抖的意思不是“忽略变化”,而是“等 500ms 内没有新事件,再做一次统一扫描”。这样保存一次文件,就只会触发一次真正的重建。
小结
到这里,这篇的目标就完成了。我们已经把后端最底层的数据层搭起来了,Rust 服务可以从 Obsidian Vault 扫描博客、Wiki 和 Todo,读出 Frontmatter,处理 Wikilink,再把 Markdown 渲染成 HTML,并且在文件变化后自动刷新内存数据。
下一篇就往上走一层,把这些结构化数据接到 Axum 路由和 Askama 模板里,真正渲染成网页。等那一层接好以后,这套“在 Obsidian 里写,保存后网站自动更新”的流程才算完整闭环。
Comments (0)
No comments yet. Be the first!