Read OSS

Denoのコードベースを読み解く:アーキテクチャ、クレートマップ、コマンドの実行経路

中級

前提知識

  • Rustの基礎知識(トレイト、ジェネリクス、async/await)
  • Cargoワークスペースの基本的な理解

Denoのコードベースを読み解く:アーキテクチャ、クレートマップ、コマンドの実行経路

DenoはRustで書かれたプロジェクトですが、単一のクレートではありません。5つのトップレベルディレクトリに75以上のクレートが分散したワークスペースです。それぞれのディレクトリには明確な役割があります。リポジトリを初めて開いたとき、ext/libs/ 以下に広がる大量のフォルダに圧倒されるかもしれません。この記事はそのための地図です。ディレクトリ構成、コンパイルを現実的なものに保つための依存レイヤー、そして deno run hello.ts がプロセスの起動からサブコマンドの実行に至るまでの流れを順に追っていきます。最後に、すべてをつなぎ合わせるサービスロケーターパターンも取り上げます。

5つのディレクトリ構成

Denoワークスペース内のすべてのクレートは、ルートの Cargo.toml に定義されています。75以上のメンバークレートは、次の5つのディレクトリに分類されます。

ディレクトリ 役割 主なクレート
cli/ ユーザー向けバイナリ — フラグのパース、サブコマンド、各種ツール deno(バイナリ本体)、cli/libcli/snapshot
runtime/ 全extensionを組み合わせてJavaScriptランタイムを構成 deno_runtimeruntime/permissionsruntime/features
ext/ JSへネイティブ機能を提供するextension群 deno_fsdeno_netdeno_fetchdeno_nodedeno_crypto
libs/ 再利用のために切り出された共有ライブラリ deno_coredeno_opsdeno_resolverdeno_npmserde_v8
tests/ 統合テスト、ベンチマーク、Node.js互換テスト tests/specstests/unittests/napi

CLAUDE.md の開発者ガイドにはこう書かれています。cli/ クレートは「ユーザーが直接触れるインターフェース」、runtime/ はJavaScriptランタイムの組み立て担当、ext/ はJavaScriptにシステムアクセスを提供する、と。

graph TD
    CLI["cli/<br/>User-facing binary<br/>Flag parsing, tools, LSP"]
    RT["runtime/<br/>Runtime assembly<br/>Worker, permissions, bootstrap JS"]
    EXT["ext/<br/>Extensions<br/>fs, net, fetch, node, crypto..."]
    LIBS["libs/<br/>Shared libraries<br/>core, ops, resolver, npm..."]
    TESTS["tests/<br/>specs, unit, integration<br/>Node.js compat, benchmarks"]

    CLI --> RT
    RT --> EXT
    EXT --> LIBS
    CLI --> LIBS
    TESTS -.->|tests| CLI
    
    style CLI fill:#4a9eff,color:#fff
    style RT fill:#ff6b6b,color:#fff
    style EXT fill:#ffd93d,color:#333
    style LIBS fill:#6bcb77,color:#fff
    style TESTS fill:#ccc,color:#333

ヒント: libs/ ディレクトリは比較的新しく、多くのクレートはエンベッダーやスタンドアロンバイナリによる再利用を可能にするため、cli/ から切り出されたものです。複数のレイヤーを経由して再エクスポートされている型を見かけたら、その抽出作業がまだ進行中だと考えてよいでしょう。

クレートの依存レイヤー

レイヤー構造は厳格かつ意図的に設計されています。cliruntime に依存し、runtimeext/* クレートに依存し、ext/* クレートは libs/core に依存します。これは単なる整理術ではなく、コンパイルのファイアウォールとして機能します。cli/ に変更を加えても runtime/ や各extensionは再コンパイルされません。これほど大規模なプロジェクトでインクリメンタルビルドを現実的に行えているのは、この構造のおかげです。

graph BT
    CORE["libs/core<br/>(deno_core)"]
    OPS["libs/ops<br/>(deno_ops proc macros)"]
    RESOLVER["libs/resolver<br/>(deno_resolver)"]
    NPM["libs/npm*<br/>(npm_cache, npm_installer)"]
    FS["ext/fs"]
    NET["ext/net"]
    NODE["ext/node"]
    FETCH["ext/fetch"]
    RUNTIME["runtime"]
    CLI["cli"]

    OPS --> CORE
    FS --> CORE
    NET --> CORE
    FETCH --> CORE
    NODE --> CORE
    NODE --> FS
    NODE --> NET
    RUNTIME --> FS
    RUNTIME --> NET
    RUNTIME --> FETCH
    RUNTIME --> NODE
    CLI --> RUNTIME
    CLI --> RESOLVER
    CLI --> NPM
    RESOLVER --> CORE

注目したいのは、ext/nodeext/fsext/net といった他のextensionに依存している点です。ファイルシステムやネットワークを扱うNode.js APIをポリフィルするために、これらが必要になるからです。この依存関係が生み出す微妙な順序制約については、extensionの登録を扱う第2回の記事で改めて取り上げます。

ワークスペース全体の依存関係は、ルート Cargo.toml[workspace.dependencies] に集約されています。deno_astdeno_graphdeno_lint などの重要な外部クレートはここでバージョンが固定されており、ワークスペース全体で一貫したバージョンが使われます。

プロセスの起動:main()からサブコマンドへ

プロセスのエントリーポイントは意図的にシンプルに保たれています。cli/main.rs は、バイナリをビルドせずにテストを実行できるようにするためだけに存在しています。

pub fn main() {
  deno::main()
}

実際の起動シーケンスは cli/lib.rs にあります。main() 関数は、注意深い順序で初期化処理を進めていきます。

sequenceDiagram
    participant Main as main()
    participant Panic as Panic Hook
    participant Platform as Platform Setup
    participant TLS as TLS Init
    participant Flags as Flag Parsing
    participant V8 as V8 Init
    participant Tokio as Tokio Runtime
    participant Sub as Subcommand

    Main->>Panic: setup_panic_hook()
    Main->>Platform: init_logging, raise_fd_limit
    Main->>Platform: Windows: disable_stdio_inheritance, enable_ansi
    Main->>TLS: rustls default provider
    Main->>Main: maybe_setup_permission_broker()
    Main->>Tokio: create_and_run_current_thread_with_maybe_metrics
    Tokio->>Flags: resolve_flags_and_init(args)
    Flags-->>V8: init_v8(flags)
    V8-->>Sub: run_subcommand(flags)
    Sub-->>Main: exit code

いくつか注目すべき点があります。まず、パニックフックが最初にインストールされます。これはクラッシュ時に、Denoのバージョン、プラットフォーム情報、Issueを報告するためのリンクを含む、ユーザーフレンドリーなエラーレポートを表示するためのものです。V8のfatal errorハンドラーも上書きされており、C++の abort() を呼ぶ代わりにRustのpanicを発生させることで、パニックフックが確実に動作するようになっています。パーミッションブローカー(実験的なIPCベースのパーミッション委譲システム)のセットアップは、Tokioランタイムの起動前に行われます。DENO_PERMISSION_BROKER_PATH 環境変数の読み取りが必要なためです。

Tokioランタイムは create_and_run_current_thread_with_maybe_metrics で生成されます。これはシングルスレッドのランタイムです。V8のアイソレートは Send を実装していないため、Denoは意図的にcurrent-threadエグゼキューターを使用しています。また、V8 11.6で導入されたPKU(Protection Keys for Userspace)機能の制約により、V8自体はアイソレートを生成するすべてのスレッドの親スレッド上で初期化しなければなりません。

サブコマンドのディスパッチとDenoSubcommandEnum

DenoSubcommand enumはDenoが実行できるすべての操作を表しており、執筆時点で36個のvariantがあります。

pub enum DenoSubcommand {
  Add(AddFlags),
  Audit(AuditFlags),
  Bench(BenchFlags),
  Bundle(BundleFlags),
  Cache(CacheFlags),
  Check(CheckFlags),
  Compile(CompileFlags),
  // ... 30 more variants
  Run(RunFlags),
  Serve(ServeFlags),
  Task(TaskFlags),
  Test(TestFlags),
}

run_subcommand() 関数は巨大な match 式で、各variantをそれぞれのハンドラーにディスパッチします。すべてのアームは spawn_subcommand() でラップされています。

fn spawn_subcommand<F: Future<Output = T> + 'static, T: SubcommandOutput>(
  f: F,
) -> JoinHandle<Result<i32, AnyError>> {
  deno_core::unsync::spawn(
    async move { f.map(|r| r.output()).await }.boxed_local(),
  )
}

このパターンには実用的な理由があります。各サブコマンドのブランチが生成するfutureは非常に大きく、Flags 構造体全体と各種サービスオブジェクトをキャプチャします。これらを値としてコールスタックに積み上げると、Windowsのデバッグビルドでスタックオーバーフローを引き起こします。boxed_local() を使ってfutureをタスクとしてspawnすることで、futureはヒープに割り当てられ、スタックを小さく保てます。

flowchart LR
    A[run_subcommand] --> B{match subcommand}
    B -->|Run| C[spawn_subcommand]
    B -->|Test| D[spawn_subcommand]
    B -->|Fmt| E[spawn_subcommand]
    B -->|Lint| F[spawn_subcommand]
    B -->|...32 more| G[spawn_subcommand]
    C --> H[tools::run::run_script]
    D --> I[tools::test::run_tests]
    E --> J[tools::fmt::format]
    F --> K[tools::lint::lint]

Flags構造体と設定のパイプライン

Flags 構造体はマスター設定オブジェクトで、V8フラグからパーミッションの許可設定まで、約50のフィールドを持ちます。

pub struct Flags {
  pub argv: Vec<String>,
  pub subcommand: DenoSubcommand,
  pub frozen_lockfile: Option<bool>,
  pub type_check_mode: TypeCheckMode,
  pub config_flag: ConfigFlag,
  pub node_modules_dir: Option<NodeModulesDirMode>,
  pub permissions: PermissionFlags,
  pub v8_flags: Vec<String>,
  pub code_cache_enabled: bool,
  // ... ~40 more fields
}

CLIフラグは単独で機能するわけではなく、deno.jsonpackage.json の設定とマージされます。CliFactory が生成する CliOptions 構造体がこのマージを担っており、設定ファイルの値よりも明示的なCLIフラグが優先されます。

特に興味深い設計上の決断として、run-to-taskフォールバックがあります。deno run some_name がモジュールを見つけられずに失敗したとき、should_fallback_on_run_error() 関数が、その引数をタスク名として再試行すべきかを判断します。この動作は runサブコマンドのハンドラー で確認できます。「モジュールが見つからない」エラーをキャッチして tools::task::execute_script にフォールバックする仕組みです。これはUXを重視した選択で、npmに慣れたユーザーが deno run test をタスクランナーのように使えることを意識したものです。

ヒント: Flags 内の PermissionFlags 構造体は3段階の設計になっています。パーミッションの種類ごとに allow_*deny_*ignore_* が存在します。ignore_* は比較的新しく、特定のリソースに対するパーミッションプロンプトを抑制するために使います。

CliFactory:サービスロケーターパターン

CliFactory はすべてのピースが集結する場所です。約25個の Deferred<T> 型の遅延シングルトンを保持する CliFactoryServices 構造体を内包しています。

struct CliFactoryServices {
  blob_store: Deferred<Arc<BlobStore>>,
  caches: Deferred<Arc<Caches>>,
  cli_options: Deferred<Arc<CliOptions>>,
  code_cache: Deferred<Arc<CodeCache>>,
  file_fetcher: Deferred<Arc<CliFileFetcher>>,
  module_graph_builder: Deferred<Arc<ModuleGraphBuilder>>,
  type_checker: Deferred<Arc<TypeChecker>>,
  resolver_factory: Deferred<Arc<CliResolverFactory>>,
  // ... ~17 more
}

Deferred<T>OnceCell を薄くラップしたもので、get_or_try_init() と非同期バリアントを提供します。

pub struct Deferred<T>(once_cell::unsync::OnceCell<T>);

impl<T> Deferred<T> {
  pub fn get_or_try_init(
    &self,
    create: impl FnOnce() -> Result<T, AnyError>,
  ) -> Result<&T, AnyError> {
    self.0.get_or_try_init(create)
  }
}

これは依存性注入ではなく、サービスロケーターパターンです。deno fmt が実行されるとき、必要なのはファイルフェッチャーとフォーマッターだけです。型チェッカーやnpmリゾルバー、モジュールグラフビルダーには一切触れません。それらの Deferred セルは未初期化のまま残ります。一方 deno run が実行されると、モジュールグラフビルダーが初期化され、それに連鎖してファイルフェッチャー、リゾルバーファクトリーなどが順次初期化されていきます。

classDiagram
    class CliFactory {
        +flags: Arc~Flags~
        -services: CliFactoryServices
        +from_flags(flags) CliFactory
        +cli_options() Arc~CliOptions~
        +file_fetcher() Arc~CliFileFetcher~
        +module_graph_builder() Arc~ModuleGraphBuilder~
        +type_checker() Arc~TypeChecker~
    }
    class CliFactoryServices {
        blob_store: Deferred~Arc~BlobStore~~
        caches: Deferred~Arc~Caches~~
        cli_options: Deferred~Arc~CliOptions~~
        file_fetcher: Deferred~Arc~CliFileFetcher~~
        module_graph_builder: Deferred~Arc~ModuleGraphBuilder~~
        type_checker: Deferred~Arc~TypeChecker~~
        resolver_factory: Deferred~Arc~CliResolverFactory~~
    }
    class Deferred~T~ {
        -cell: OnceCell~T~
        +get_or_try_init(create) Result~T~
        +get_or_try_init_async(create) Result~T~
    }
    CliFactory *-- CliFactoryServices
    CliFactoryServices *-- "~25" Deferred

sync::OnceCell ではなく unsync::OnceCell を使っているのも意図的な選択です。CliFactory はTokioのcurrent-threadランタイム内の単一スレッドからのみアクセスされるため、同期のオーバーヘッドを完全に排除できます。

次回予告

全体の地図を描きました。5つのレイヤーに整理された75以上のクレート、パニックフックからV8、そしてTokioへと慎重な順序で進む起動シーケンス、36個のvariantを持つenumによるサブコマンドのディスパッチ、そして各コマンドが必要とするものだけをコストとして払う遅延サービスロケーター。次回は libs/core へと降りていきます。#[op2] マクロ、V8スナップショット、そして一連の仕組みをコンポーザブルにする Extension 抽象を通じて、extensionシステムがRustとJavaScriptをどのように橋渡しするかを探っていきましょう。