磁盘 Markdown 文件加载架构设计与实现

技术随笔

磁盘 Markdown 文件加载架构设计与实现

1. 背景与动机

Blog-SSR 采用"文件即文章"的策略——所有文章以 Markdown 格式存储在 articles/ 目录中,而非传统的关系型数据库。这种方案带来了几个关键优势:

1. 版本控制友好:文章与代码同仓,Git 天然跟踪每一次内容变更,支持 PR 审阅和回滚。

2. 编辑器自由:作者可以使用任意 Markdown 编辑器(VS Code、Typora、Obsidian),无需后台管理界面。

3. 部署简化:数据库只需存储用户、评论等动态数据,读密集型的内容由文件系统承载。

4. 零运维成本:不需要为内容管理搭建后台,不需要数据库迁移脚本维护文章结构。

本文将从底层到上层,完整阐述该 IO 层的设计与实现。


2. 整体架构

┌─────────────────────────────────────────────────────┐
│                   SSR 页面组件                         │
│  BlogList / BlogPost / ...                          │
└──────────────────┬──────────────────────────────────┘
                   │ load_all_articles() / load_article()
                   ▼
┌─────────────────────────────────────────────────────┐
│              ArticleRegistry (全局缓存)               │
│                                                     │
│  ┌─────────────┐   ┌──────────────────────────┐     │
│  │ by_slug:    │   │ by_path:                 │     │
│  │ Vec<Summary>│   │ HashMap<slug, Article>   │     │
│  └──────┬──────┘   └───────────┬──────────────┘     │
│         │                      │                     │
│         └──────────┬───────────┘                     │
│                    ▼                                 │
│         OnceLock<RwLock<Registry>>                   │
└────────────────────┬─────────────────────────────────┘
                     │ rebuild()
                     ▼
┌─────────────────────────────────────────────────────┐
│                    磁盘 IO 层                         │
│                                                     │
│  collect_md_files() → 递归遍历目录                    │
│  parse_article_file() → 读取 + 解析 + 渲染           │
│  ┌──────────┐  ┌──────────────┐  ┌───────────────┐  │
│  │ Front-   │→│ pulldown-   │→│ ArticleWith-   │  │
│  │ matter   │  │ cmark 渲染  │  │ Content       │  │
│  │ 解析     │  │             │  │               │  │
│  └──────────┘  └──────────────┘  └───────────────┘  │
└─────────────────────────────────────────────────────┘

整个 IO 层分为三层:页面组件层(消费数据)、缓存层ArticleRegistry)、磁盘 IO 层(文件扫描、解析、渲染)。


ArticleRegistry id="3--缓存层设计">3. 缓存层设计 ()

3.1 线程安全全局单例

static REGISTRY: OnceLock<RwLock<ArticleRegistry>> = OnceLock::new();

选用 OnceLock<RwLock<...>> 组合的原因:

| 组件 | 作用 | |------|------| | OnceLock | 首次访问时惰性初始化,避免 lazy_static 的宏依赖 | | RwLock | 读多写少的场景,load_article()load_all_articles() 是读操作,refresh() 是写操作 |

3.2 双索引结构

struct ArticleRegistry {
    root: PathBuf,
    by_slug: Vec<ArticleSummary>,           // 全量列表
    by_path: HashMap<String, ArticleWithContent>,  // 单篇索引
}

为什么维护两种数据结构?

  • Vec<ArticleSummary> — 博客列表页需要所有文章的元数据(标题、摘要、标签等),按目录层级排序后直接返回
  • HashMap<String, ArticleWithContent> — 文章详情页只需通过 slug 查单个文章,O(1) 哈希表查询远比遍历目录高效

懒加载策略:两个容器都是空时才触发重建,而非在应用启动时主动加载。

pub fn load_article(slug: &str) -> Option<ArticleWithContent> {
    let reg = registry();
    let mut guard = reg.write().unwrap();
    if guard.by_path.is_empty() {
        let _ = guard.rebuild();  // 首次访问才触发 I/O
    }
    guard.by_path.get(slug).cloned()
}

3.3 重建流程

rebuild() 是整个系统的核心方法,执行一次完整的磁盘扫描到内存索引的转换:

rebuild()
  ├─ 1. resolve_articles_dir()      → 确定 articles 目录路径
  ├─ 2. collect_md_files(dir)       → 递归收集所有 .md 文件
  ├─ 3. files.sort()                → 按路径排序(保证列表顺序)
  ├─ 4. for each file:
  │     ├─ parse_article_file()     → 读取 + 解析 + 渲染
  │     ├─ 成功 → 写入 by_slug / by_path
  │     └─ 失败 → tracing::warn!() 记录,继续处理下一个
  └─ 5. Ok(())

单个文件解析失败不会阻塞整体重建,通过 tracing::warn! 记录日志,便于开发调试。


4. 磁盘 IO 层

4.1 路径解析

fn resolve_articles_dir() -> PathBuf {
    // 开发环境:优先使用 CARGO_MANIFEST_DIR(指向项目根目录)
    if cfg!(debug_assertions) {
        if let Ok(manifest) = std::env::var("CARGO_MANIFEST_DIR") {
            let p = Path::new(&manifest).join("articles");
            if p.exists() { return p; }
        }
    }
    // 生产环境:依次检查 CWD 和可执行文件所在目录
    let candidates = [
        Path::new("articles"),
        &current_exe_path().join("articles"),
    ];
    for c in &candidates {
        if c.exists() { return c.to_path_buf(); }
    }
    PathBuf::from("articles")  // 最后兜底
}

设计要点:Docker 容器中 CARGO_MANIFEST_DIR 不存在,因此必须有运行时探测回退。多阶段构建会将 articles/ 目录 COPY 到镜像的工作目录。

4.2 递归文件收集

fn collect_md_files(dir: &Path) -> Vec<PathBuf> {
    let mut files = Vec::new();
    let Ok(iter) = std::fs::read_dir(dir) else { return files };
    for entry in iter.flatten() {
        let path = entry.path();
        if path.is_dir() {
            files.extend(collect_md_files(&path));
        } else if path.extension().map(|e| e == "md").unwrap_or(false) {
            files.push(path);
        }
    }
    files
}

支持多级子目录组织,例如:

articles/
├── Dioxus SSR 实战教程/       ← 第一层分类
│   ├── 01-入门.md
│   └── 02-进阶.md
└── 技术随笔/                  ← 第二层分类
    └── 01-架构思考.md

自动忽略非 .md 文件和隐藏文件。

4.3 Frontmatter 解析

每篇 Markdown 文件以 --- 分隔的 YAML frontmatter 开头:

---
title: 磁盘 Markdown 文件加载架构设计
tags: [架构, IO, Rust]
---

# 正文从此开始...

解析算法

fn parse_frontmatter(raw: &str) -> (HashMap<String, String>, &str) {
    // 1. 检查是否以 "---\n" 开头
    // 2. 查找第二个 "---" 的位置
    // 3. 提取中间的 key: value 行
    // 4. 用 split_once(':') 分割(而非 find(':'))
    //    → 避免 URL 等含冒号的值被截断
    // 5. key to_lowercase(),value trim()
}

为什么不用 serde_yaml?

避免引入额外的依赖。当前的 key-value 解析足够 cover 博客场景的 frontmatter 需求,且出错时日志可读性更好。

4.4 Slug 生成

slug 是文章中唯一的 URL 标识。支持两种来源:

优先级 1: frontmatter 中显式声明
  ---
  slug: custom-slug
  ---

优先级 2: 自动从文件名派生
  01-项目架构设计.md → "项目架构设计"
  02-SQLx数据库操作.md → "SQLx数据库操作"
fn slug_from_stem(stem: &str) -> String {
    stem.trim_start_matches(|c: char| c.is_ascii_digit() || c == '.' || c == '-')
        .trim_matches('-')
        .to_string()
}

文件名前缀的数字用于控制排序,slug 只取语义部分。

4.5 Markdown 渲染

使用 pulldown-cmark 进行 Markdown→HTML 转换:

let parser = Parser::new(body);
let mut html = String::new();
html::push_html(&mut html, parser);

为什么选择 pulldown-cmark?

| 特性 | pulldown-cmark | comrak | |------|---------------|--------| | 实现语言 | 纯 Rust | Rust 绑定的 C 库 | | 标准合规 | CommonMark 严格 | GFM 扩展 | | 依赖大小 | 零 C 依赖 | 需 libcmark | | 性能 | 极快 | 较快 |

对于博客场景,CommonMark 规范完全满足需求,且无 C 依赖使交叉编译更简单。

渲染在 rebuild() 阶段一次性完成,结果缓存在 by_path 中。页面组件通过 dangerous_inner_html 直接注入:

div { class: "prose prose-lg max-w-none",
    dangerous_inner_html: "{detail.html}"
}

5. 性能分析

5.1 缓存命中路径

首次请求:
  load_article("项目架构设计")
    → 缓存为空,触发 rebuild()
    → 扫描 15 个 .md 文件,解析 frontmatter,渲染 HTML
    → 缓存全部结果 → 返回目标文章
    → 耗时: ~3-8 ms(SSD,15 篇文章)

后续请求:
  load_article("SQLx数据库操作")
    → 缓存命中,HashMap::get O(1)
    → 零 I/O,零分配
    → 耗时: < 0.01 ms

5.2 内存占用

每篇文章缓存两个版本:

| 结构 | 大小(估算) | |------|-------------| | ArticleSummary | ~200 bytes × N | | ArticleWithContent | ~2-10 KB × N(含渲染后的 HTML) |

15 篇文章约占用 150-200 KB 内存,可以忽略不计。

5.3 锁竞争

RwLock 允许多个读者并发,仅 refresh() 时独占写锁。在 SSR 场景下,读操作占 99.9% 以上,几乎不会发生写阻塞。


6. 异常处理策略

整个 IO 层的异常处理遵循"优雅降级"原则:

┌────────────────────────────────────────────┐
│  parse_article_file() → Result<_, Error>    │
├────────────────────────────────────────────┤
│  × 文件不存在 / 无权限          → Io       │
│  × 文件编码异常                → Frontmatter│
│  × 缺少 title 字段             → MissingTitle│
│  × 目录不存在                  → DirNotFound │
└────────────────┬───────────────────────────┘
                 │
                 ▼
         rebuild() 中的处理
         ┌─────────────────────┐
         │ Ok → 加入缓存        │
         │ Err → tracing::warn! │
         │       继续处理下一篇   │
         └─────────────────────┘

关键决策:一篇坏文章不影响其他文章的加载。这比"全部成功或全部失败"的策略更适合内容型站点,因为文章之间是独立的。


7. 扩展性设计

当前架构为未来扩展预留了接口:

热重载(开发环境)

// 可供文件监听器调用
pub fn refresh() -> Result<(), ArticleError> {
    let reg = registry();
    let mut guard = reg.write().unwrap();
    guard.rebuild()
}

配合 notify crate 监听文件变更,可在开发时实现文章修改后自动刷新缓存,无需重启服务。

文章搜索

pub fn search_articles(keyword: &str) -> Vec<ArticleSummary> {
    let articles = load_all_articles();
    articles.into_iter().filter(|a| {
        a.title.contains(keyword)
            || a.tags.iter().any(|t| t.contains(keyword))
    }).collect()
}

基于缓存的 Vec<ArticleSummary> 可以零 I/O 实现内存级全文搜索,对于博客规模(数百篇)绰绰有余。

多数据源合并

ArticleRegistry 未来可以从多个目录源加载数据:

fn rebuild(&mut self) -> Result<(), ArticleError> {
    for dir in &self.roots {    // 支持多个 articles 目录
        collect_and_parse(dir);
    }
}

适用于主题包、插件系统等场景。


8. 总结

本文从设计动机、架构分层、核心实现、性能优化、异常处理、扩展性等角度,完整阐述了 Blog-SSR 项目中磁盘 Markdown 文件加载层的技术实现。

核心设计哲学可以总结为三点:

1. 以缓存换延迟OnceLock<RwLock<ArticleRegistry>> 使得首次访问后的所有文章读取都是纯内存操作,零 I/O 开销。

2. 以局部错误容忍整体可用 — 单篇文章解析失败不影响其他文章,坏文件通过日志暴露而非崩溃阻塞。

3. 以简单满足需求 — 自定义 frontmatter 解析器而非引入 serde_yaml,RwLock 而非更复杂的并发原语,在不失健壮性的前提下保持代码简洁。

这套架构虽然专为博客场景设计,但其"文件即数据 + 全局缓存 + 优雅降级"的模式同样适用于文档站点、知识库、静态生成器等内容型应用。

架构IO缓存SSR设计模式Rust