MoonBit のパッケージ依存関係を 6 通りの図で描いた #

moonbit/ の下には、このブログを動かしている MoonBit のパッケージが 17 個ある。 pkg/ にライブラリ、examples/blog/ にそれを使うアプリケーション、という分け方は Go 版と同じだ。 最近は pkg/http や examples/blog/worker が増えて、どこがどこに依存しているのかを口で説明するのが面倒になってきた。

そこでissue #85で、依存関係を図にしてみることにした。 せっかくなので描き方を 1 つに決めず、mermaid(dagre と ELK)、beautiful-mermaid(ELK)、d2(dagre、ELK、TALA)の 6 通りを並べて比べる。 同じグラフを別のエンジンがどう配置するかは、実際に見るまで分からない。

まず依存関係を取り出す #

MoonBit のツールチェーンには、依存関係を見るためのコマンドが 2 つある。 moon tree と moon ide outline だ。

moon tree #

まず何も付けずに moon tree を moonbit/ で実行する。

$ cd moonbit
$ moon tree
podhmo/html-sandbox@0.1.0 (local /home/po/ghq/github.com/podhmo/html-sandbox/moonbit):
└─ moonbitlang/async -> moonbitlang/async@0.22.5

これはモジュール単位の依存で、このモジュールが使っているのは moonbitlang/async だけだ。 自分たちのパッケージ同士の関係は出てこない。 欲しいのはそちらなので、--package を付ける。

$ moon tree --package

こちらはパッケージを起点に、そのパッケージが import しているパッケージを展開する。 テキストでも読めるが、図を描くには JSON のほうが扱いやすい。 --json を付けると、ノードとエッジがそのまま入った 1 個の JSON になる。

$ moon tree --package --json > /tmp/pkgtree.json

中身はこうなっている(抜粋)。

{
  "version": 2,
  "status": "success",
  "root": [33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49],
  "nodes": [
    {
      "module": "podhmo/html-sandbox",
      "version": "0.1.0",
      "source": { "kind": "local", "path": "/home/po/ghq/github.com/podhmo/html-sandbox/moonbit" },
      "rel": "pkg/el"
    },
    { "module": "moonbitlang/async", "version": "0.22.5", "source": { "kind": "registry" }, "rel": "http" }
  ],
  "edges": [
    { "from": 46, "to": 42, "alias": "el", "kinds": ["source"] }
  ]
}

root がこのモジュールのパッケージで、nodes には依存先のモジュールのパッケージも入る。 edges の kinds が import の種類で、source はコードが必要としている依存、blackbox-test はテストだけが必要としている依存だ。 alias は moon.pkg に書いた @el のような別名で、図には出さないが、どの import かを見分けるのに便利だった。

標準ライブラリは nodes に入らない。 moon tree --package --json は「standard library 以外のパッケージ」を返すと書いてあるので、moonbitlang/core/* は最初からいない。 今回はこのモジュールの中の関係だけを描くので、module が podhmo/html-sandbox のノードだけを残し、外のモジュールへのエッジは external として別に保存した。 examples/blog/compare/cmd が moonbitlang/async の 3 パッケージを使っているのが、その 3 本だ。

moon ide outline #

依存の線だけだと、どのパッケージが大きいのか分からない。 moon ide outline はパッケージのファイルと宣言(関数、型、定数など)を一覧にしてくれる。

$ moon ide outline pkg/picoel
picoel.mbt:
 11 |pub const STYLESHEET_URL = "https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
    |...
 17 |pub fn document(
 18 |  lang : String,
 19 |  head~ : Array[@el.Node],
 20 |  body~ : Array[@el.Node],
 21 | ) -> @el.Node {
    |...

ファイル名の見出しと、行番号付きの宣言が並ぶ。 これをパッケージごとに実行して、宣言の数と pub の数を数えれば、ノードの大きさの目安になる。

ここで 2 つつまずいた。

1 つは、moon ide outline はパッケージがビルドされるターゲットを必要とすること。 pkg/tinymarkdown/js は js ターゲット専用で、デフォルトのターゲットでは「could not find package」と言われる。 --target js を付けると読める。 examples/blog/compare/cmd は native 専用なので --target native だ。 スクリプトでは、デフォルト、wasm-gc、native、js の順に試して、ファイルの見出しが含まれていたら採用するようにした。

もう 1 つは、そのエラーでも終了コードが 0 のときがあること。

$ moon ide outline examples/blog/compare/cmd
Error: could not find package for folder /.../examples/blog/compare/cmd
$ echo $?
0

最初は「成功したのに空の出力」を受け取ってしまい、宣言数が 0 になった。 なので、出力に main.mbt: のような見出しがあるかどうかで判断している。

あと、正規表現で宣言を数えるときは async fn を忘れないほうがいい。 main.mbt は async fn fetch、async fn try_fetch、async fn main の 3 つで、fn だけを見ていると全部取りこぼす。

graph.json #

2 つをまとめて graph.json にした。 moonbit/experiments/pkg-graph/scripts/extract-graph.mjs が moon tree --package --json を呼び、各パッケージに moon ide outline の数字を足す。

{
  "project": "podhmo/html-sandbox",
  "generatedBy": "moon tree --package --json, annotated with moon ide outline",
  "moonVersion": "moon 0.1.20261012 (748b63c 2026-10-12)",
  "packages": [
    {
      "name": "pkg/el",
      "kind": "library",
      "files": 4,
      "testFiles": 2,
      "declarations": 80,
      "public": 71
    }
  ],
  "edges": [
    { "from": "pkg/picoel", "to": "pkg/el", "kinds": ["source"] }
  ],
  "external": [
    { "from": "examples/blog/compare/cmd", "to": "moonbitlang/async/http", "kinds": ["source"] }
  ]
}

kind は moon tree が返すものではなく、こちらの分類だ。 pkg/ を library、examples/blog/ を application、examples/blog/{assets,posts,slops,repodocs} を、embed スクリプトが生成する generated とした。 生成物を手書きのコードと同じ形で描くと、依存の読み方が変わってしまうからだ。

17 パッケージの数字はこうなった。

パッケージ種別ソーステスト宣言pub
examples/blog/assetsgenerated1088
examples/blog/compareapplication1197
examples/blog/compare/cmdapplication1030
examples/blog/postsgenerated1041
examples/blog/previewapplication1111
examples/blog/repodocsgenerated11221
examples/blog/siteapplication7211514
examples/blog/slopsgenerated11181
examples/blog/workerapplication10112
pkg/ellibrary428071
pkg/httplibrary614942
pkg/http/wasmlibrary22187
pkg/http/wasm/integrationlibrary1082
pkg/picoellibrary113735
pkg/tinyfrontmatterlibrary11105
pkg/tinymarkdownlibrary233914
pkg/tinymarkdown/jslibrary11153

エッジは 21 本。 examples/blog/site から出るものが 9 本と多く、pkg/el は 3 本(preview、site、picoel)を受けている。 テストだけの依存は、site から posts、slops、repodocs への 3 本と、tinymarkdown から repodocs への 1 本だった。 図ではこれを破線にして test とラベルを付ける。

6 通りを描く #

同じ graph.json から、mermaid 用と d2 用のソースを生成する。 ノードは 17 個、エッジは 21 本で、向きは左から右。 pkg/ は四角、アプリケーションは角の丸い箱、生成物はシリンダーにした(d2 は全部四角になる)。

最初はカテゴリーごとのサブグラフ(mermaid の subgraph、d2 のコンテナ)で囲んでいたが、dagre だと 3 つのクラスタが斜めに積み重なって 1209x1360 の読みにくい図になった。 エンジンの違いを見るのが目的なので、クラスタはやめて色と形だけで区別している。

mermaid(dagre と ELK) #

mermaid は npm で入れる。 ELK レイアウトは別パッケージになっているので、それも入れる。

$ npm install mermaid@12.2.0 @mermaid-js/layout-elk@1.0.1 puppeteer
$ npx puppeteer browsers install chrome

mermaid はブラウザーで描くライブラリなので、SVG を作るには DOM が要る。 そのために puppeteer で Chrome を動かす。 mermaid-cli(mmdc)もあるのだが、設定ファイルに {"layout":"elk"} を書いても dagre とバイト単位で同じ SVG が出てきた。 mermaid-cli が同梱している mermaid には ELK のローダーが登録されていないようだ。 @mermaid-js/layout-elk を mermaid.registerLayoutLoaders() に渡す必要がある。

なので、mmdc は使わず、小さなページを自分で用意した。

<script type="module">
  import mermaid from "/node_modules/mermaid/dist/mermaid.esm.min.mjs";
  import elkLayouts from "/node_modules/@mermaid-js/layout-elk/dist/mermaid-layout-elk.esm.min.mjs";

  mermaid.registerLayoutLoaders(elkLayouts);

  let n = 0;
  window.renderDiagram = async (code, layout) => {
    mermaid.initialize({
      startOnLoad: false,
      layout,
      theme: "default",
      securityLevel: "loose",
      flowchart: { htmlLabels: true, useMaxWidth: false },
    });
    const { svg } = await mermaid.render(`diagram-${n++}`, code);
    return svg;
  };
</script>

Chrome は file:// からの ES モジュールの import を拒否するので、実験ディレクトリをローカル HTTP サーバーで配る。 puppeteer でそのページを開き、page.evaluate() で window.renderDiagram(code, "elk") を呼んで SVG を受け取る。 useMaxWidth: false にしておくと、SVG に実寸の width と height が入る。

mermaid のソースはこうなった(先頭部分)。

graph LR
  examples_blog_assets[("examples/blog/assets")]
  examples_blog_compare("examples/blog/compare")
  pkg_el["pkg/el"]
  pkg_picoel["pkg/picoel"]
  classDef library fill:#e8f0fe,stroke:#1a73e8,color:#1a3a6b
  classDef application fill:#fff4e5,stroke:#e8710a,color:#7a3b00
  classDef generated fill:#f1f3f4,stroke:#9aa0a6,color:#3c4043,stroke-dasharray:4 3
  class pkg_el,pkg_picoel library
  pkg_picoel --> pkg_el
  examples_blog_site -. "test" .-> examples_blog_posts

ノード ID には / が使えないので、pkg/el を pkg_el に置き換えている。 ラベルには元の名前をそのまま入れた。

beautiful-mermaid #

beautiful-mermaid は mermaid の別実装で、ELK を内部に持っていて、ブラウザーなしで同期に SVG を返す。

$ npm install beautiful-mermaid@1.1.3
import { renderMermaidSVG } from "beautiful-mermaid";

const svg = renderMermaidSVG(code, {
  bg: "#ffffff",
  fg: "#27272a",
  accent: "#1a73e8",
});

同じ mermaid のソースがそのまま読めた。 classDef の色、シリンダー、破線のエッジと test ラベルも保持している。 ブラウザーも Chrome も要らないので、この 6 通りの中で一番手軽だった。

d2 #

d2 はバイナリを入れる。 公式のインストールスクリプトに prefix を渡すと、その下の bin/ に入る。

$ curl -fsSL https://d2lang.com/install.sh | sh -s -- --prefix "$HOME/.local"
$ export PATH="$HOME/.local/bin:$PATH"
$ d2 --version
v0.9.0

d2 0.9 は dagre、ELK、TALA の 3 つのレイアウトエンジンを同梱している。

$ d2 layout
Available layout engines found:

dagre (bundled) - The directed graph layout library Dagre
elk (bundled) - Eclipse Layout Kernel (ELK) with the Layered algorithm.
tala (bundled) - TALA is D2's native layout and edge-routing engine.

ソースは mermaid より素直に書ける。

direction: right

classes: {
  library: { style: { fill: "#e8f0fe"; stroke: "#1a73e8"; font-color: "#1a3a6b" } }
  application: { style: { fill: "#fff4e5"; stroke: "#e8710a"; font-color: "#7a3b00" } }
  generated: { style: { fill: "#f1f3f4"; stroke: "#9aa0a6"; font-color: "#3c4043"; stroke-dash: 4 } }
}

pkg_el: "pkg/el" { class: library }
examples_blog_site: "examples/blog/site" { class: application }

pkg_picoel -> pkg_el
examples_blog_site -> examples_blog_posts: "test" { style.stroke-dash: 3 }

描くのは 1 行で済む。

$ d2 --layout=dagre --pad=40 build/graph.d2 build/d2-dagre.svg
$ d2 --layout=elk   --pad=40 build/graph.d2 build/d2-elk.svg
$ d2 --layout=tala  --pad=40 build/graph.d2 build/d2-tala.svg

この環境でのつまずき #

手元の環境(WSL の Ubuntu 26.04)では、Chrome の準備でいくつか引っかかった。 普通のデスクトップなら npx puppeteer browsers install chrome だけで終わるはずなので、読み飛ばしてよい。

  • npm 11 は postinstall を勝手に実行しない。puppeteer の Chrome ダウンロードは npx puppeteer browsers install chrome で明示的に走らせる。
  • そのダウンロードが unzip を呼ぶらしく、unzip が無いと失敗する。Chrome for Testing の zip を取ってきて Python の zipfile で展開した。
  • 展開した Chrome は libnspr4、libnss3、libasound2t64 を要求した。apt-get download で deb を取り、dpkg-deb -x でローカルに展開して LD_LIBRARY_PATH を通した。
  • zip の展開で実行ビットが落ちるので、chrome_crashpad_handler に chmod +x が要る。

できた図を確かめる #

SVG はテキストなので、開けば何かは分かる。 だが、レイアウトが良いかどうかはやはり目で見ないと分からない。 そこで、描いた SVG を Chrome のページに読み込ませ、SVG 要素だけを PNG に撮って確認した。

const page = await browser.newPage();
await page.setContent(`<body style="margin:0;background:#fff">${svg}</body>`);
const el = await page.$("svg");
await el.screenshot({ path: "build/d2-elk.png" });

このひと手間があると、エッジがノードを突き抜けているとか、ラベルが重なっているといったことがすぐ分かる。 実際、最初のサブグラフ付きの図はこの方法で見て、没にした。

ついでに、各 SVG を読み直してノードとエッジの数を数えるスクリプトも置いた(scripts/measure.mjs)。 mermaid は class="node default ..." と data-et="edge"、d2 は <g class="shape"> と <path class="connection">、beautiful-mermaid は <g class="node"> と <polyline class="edge"> を数える。 どれか 1 つでもノードやエッジを落としていたら失敗するので、レンダラーの取りこぼしに気づける。

結果 #

6 通りの大きさはこうなった。

レンダラーレイアウトキャンバスノードエッジSVG
beautiful-mermaidELK1005x632172119.8 KiB
d2dagre1324x1261172121.9 KiB
d2ELK1736x1044172122.3 KiB
d2TALA917x1323172120.8 KiB
mermaiddagre1085x1105172147.9 KiB
mermaidELK1361x827172146.9 KiB

見比べて分かったこと。

  • mermaid の dagre はほぼ正方形になる。graph LR でも、17 個のノードが少ない列に縦に積まれるためだ。examples/blog/site から出る 9 本のエッジも、それぞれ曲線で散らばる。
  • 同じ mermaid でも ELK にすると横長(1361x827)になり、site から出るエッジを直交する幹線にまとめてから分岐させる。個人的にはこれが一番読みやすかった。
  • d2 の dagre と ELK も同じ傾向で、この 2 つが最も面積が大きい。ELK は site が非常に縦長の箱になる。9 本のエッジが同じ列から出るためだ。
  • TALA は別物だった。direction: right を指定しても縦寄り(917x1323)になるが、直交ルーティングで交差が少なく、依存は追いやすい。
  • beautiful-mermaid は 1005x632 と最も小さい。ELK の結果をそのまま使い、色も形も保つ。フローチャートしか描けないのが制約だが、今回の用途には十分だった。
  • mermaid の SVG は他より約 2 倍大きい。CSS、フォント、マーカーを各ファイルに書き込むためだ。

ノードが 17 個なら、どれも読めないことはない。 違いが出るのはエッジの引き回しと、site の fan-out がキャンバスのどれだけを占めるかだった。 次に数を増やすときは、ELK か TALA を選ぶと思う。

おわりに #

作業は moonbit/experiments/pkg-graph にまとめた。 make graph で graph.json を作り直し、make all で 6 つの SVG を build/ に出す。 d2 と Chrome(puppeteer 経由)が要る以外は、npm install だけで動く。

図そのものは issue #85 のコメントに添付してある。

examples/blog/assetsexamples/blog/compareexamples/blog/compare/cmdexamples/blog/postsexamples/blog/previewexamples/blog/repodocsexamples/blog/siteexamples/blog/slopsexamples/blog/workerpkg/elpkg/httppkg/http/wasmpkg/http/wasm/integrationpkg/picoelpkg/tinyfrontmatterpkg/tinymarkdownpkg/tinymarkdown/js testtesttesttest

MoonBit の依存関係を見るだけなら moon tree --package で足りる。 そこから一歩進んで、パッケージの大きさも一緒に見たいなら moon ide outline を足す。 この 2 つを JSON にしておけば、あとは好きなレンダラーに流し込むだけだ。

---

特別なフォントを埋めこむとmarkdown側のコードブロックの記述が汚れる。