pulldown-cmark で 10 MB の Markdown→HTML サービスを Rust で作った

きっかけ

ここ 10 年で関わったバックエンドは、どれも結局どこかで Markdown をレンダリングしていた。イシュー本文、CHANGELOG、リリースノート、ユーザー投稿ドキュメント、インラインヘルプ。各サービスが少しずつ違うやり方で同じ処理をしていて、レンダリングにどれくらい時間がかかっているかは誰も答えられない。

以前のエントリ #133 で PHP 版(Slim 4 + league/commonmark)を作った。良い PHP コードだが、「FPM 経由の PHP が数 KB の Markdown をリクエストごとにパースする」のフロアレイテンシは、ドキュメントサイトなら十分でも全ページの描画パスで叩かれるサービスには合わない。

そこで同じ JSON-in / JSON-out 契約を Rust で実装した。パーサーは pulldown-cmark ——自分が知る限りあらゆるランタイムで最速の CommonMark パーサー——で、バイナリは 10 MB の静的リンク musl Alpine イメージに収まる。

🔗 GitHub: https://github.com/sen-ltd/markdown-render

スクリーンショット

作ったもの

POST /render で Markdown を受け取り、HTML・語数・見出し一覧を JSON で返す axum サービス。POST /render/html は生 HTML を返し、GET /render?text=... はシェルワンライナー向けのショートカット。GET /health でバージョン確認。

POST /render
Content-Type: application/json

{ "markdown": "# Hi\n\n**hey**", "flavor": "commonmark", "safe": true }
200 OK
Content-Type: application/json

{
  "html": "<h1>Hi</h1>\n<p><strong>hey</strong></p>\n",
  "word_count": 2,
  "headings": [{ "level": 1, "text": "Hi", "anchor": "hi" }]
}

技術的なポイント

pull パーサーが速い理由

Markdown パーサーには 2 つの流派がある:

  1. ツリービルダー。ソースを AST にパースし、AST を歩いて HTML を描画。Marked、markdown-it、league/commonmark 等がこの方式。インラインランやリスト項目ごとにノードを割り当てる
  2. プルパーサー。イベントストリーム(Start(Heading), Text, End(Heading) …)を発行し、呼び出し側がオンザフライで消費。中間ツリーがない。pulldown-cmark がこの方式の代表で、cargo doc や mdBook が使っている

Markdown→HTML のケースではプルモデルが圧勝する。ノードのプレ割り当てなし、レンダラーの割り当ては出力 String だけ、パイプライン全体が典型的な入力で L1 キャッシュに収まる。

pub fn render(markdown: &str, opts: RenderOptions) -> Rendered {
    let source = if opts.safe {
        escape_html_angle_brackets(markdown)
    } else {
        markdown.to_string()
    };

    let parser = Parser::new_ext(&source, cmark_opts);

    let mut events: Vec<Event> = Vec::new();
    let mut headings: Vec<Heading> = Vec::new();
    let mut word_count: usize = 0;

    for event in parser {
        // 見出しと語数をイベント走査中に収集
        events.push(event);
    }

    let mut html = String::new();
    pulldown_cmark::html::push_html(&mut html, events.into_iter());

    Rendered { html, word_count, headings }
}

AST も visitor もない。パーサーがイベントを発行し、ヘッダー・語数・HTML の 3 つの出力をワンパスで得る。

見出しスラッグの生成

GitHub の挙動に合わせたスラッグ生成。ASCII 小文字化、非英数字のドロップ、空白をハイフンに置換:

pub fn slugify(heading: &str) -> String {
    let mut out = String::with_capacity(heading.len());
    let mut prev_dash = true;
    for ch in heading.chars() {
        if ch.is_alphanumeric() {
            if ch.is_ascii_uppercase() {
                out.push(ch.to_ascii_lowercase());
            } else {
                out.push(ch);
            }
            prev_dash = false;
        } else if ch == '_' {
            out.push('_');
            prev_dash = false;
        } else if ch.is_whitespace() || ch == '-' {
            if !prev_dash {
                out.push('-');
                prev_dash = true;
            }
        }
    }
    while out.ends_with('-') { out.pop(); }
    out
}
見出し スラッグ
Hello World hello-world
C++ vs. Rust c-vs-rust
こんにちは 世界 こんにちは-世界

ASCII 小文字化のみ(トルコ語 İ は触らない)、アンダースコアは保持(GitHub の実挙動に合わせた)。

ammonia を使わない Safe モード

safe フィールドがデフォルト true<script>alert(1)</script> のような生 HTML を事前にエスケープしてからパーサーに渡す方式を取った:

fn escape_html_angle_brackets(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for ch in s.chars() {
        match ch {
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            other => out.push(other),
        }
    }
    out
}

ammoniahtml5ever(フル HTML5 パーサー)を引き込むが、「生 HTML は一切禁止」というポリシーなら事前エスケープのほうが軽い。トレードオフとして CommonMark の角括弧オートリンク(<https://example.com>)が safe モードで動かなくなるが、[text](url) 形式を使えばよい。

テスト

46 件:ユニット 27 + インテグレーション 14 + スラッグ 5。インテグレーションテストは tower::ServiceExt::oneshot で axum をインプロセス駆動。ソケットもポートも不要でミリ秒で完了する。

#[tokio::test]
async fn render_safe_mode_escapes_script_tag() {
    let app = app::build_app();
    let res = app.oneshot(post_json(
        "/render",
        json!({ "markdown": "<script>alert(1)</script>" }),
    )).await.unwrap();
    let json = body_json(res.into_body()).await;
    assert!(json["html"].as_str().unwrap().contains("&lt;script&gt;"));
}

おわりに

PHP の markdown-api(エントリ #133)と同一のリクエスト/レスポンス形状で、クライアントの接続先を 1 行変えるだけで切り替えられる。同じ契約を異なる言語で実装し、何にどれだけコストがかかるかを測る——それがこのポートフォリオの趣旨だ。

Rust 版は 10 MB の Alpine イメージ、マイクロ秒オーダーのレスポンス。小さくて速い理由を「そう言っているから」ではなく、プルパーサーの構造から説明できるのが、このエントリの価値だと思う。