← 与Obsidian同步的博客搭建

与Obsidian同步的博客搭建——Rust后端开发(上)

与Obsidian同步的博客搭建——Rust后端开发(上)

前一篇把整套方案的思路说清楚了,这一篇开始动手写后端。目标先定得朴素一点,不碰路由、不碰页面样式,也不急着把评论、搜索和统计一次写完。我们先把最底层的数据流打通,让 Rust 服务能从 Obsidian Vault 里读出内容,解析成结构化数据,后面网页层直接拿来用。

这一步看起来偏底层,其实决定了后面好不好写。只要扫描器、解析器和监听器的边界清楚,博客、Wiki、Todo 三类内容就都能走同一条管线。对 Rust 初学者来说,这也是一个很好的练手机会,你会同时接触配置读取、结构体建模、文件系统遍历、异步共享状态,还有一点点文本处理。

环境准备

安装 Rust(rustup)

先装 Rust 工具链。官方推荐的方式就是 rustup,它会一起帮你管理 rustccargo 和不同版本的 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!