Read OSS

ノードからクエリへ:Redux、LMDB、GraphQLスキーマ構築の仕組み

上級

前提知識

  • 本シリーズの第1〜3回
  • Reduxの基礎知識(store、actions、reducers)
  • GraphQLスキーマの基本概念(types、resolvers、directives)
  • メモリマップドデータベースの基本的な理解

ノードからクエリへ:Redux、LMDB、GraphQLスキーマ構築の仕組み

Gatsbyサイトを流れるあらゆるコンテンツ(Markdownファイル、CMSのエントリ、画像)は、生のデータを型付きでクエリ可能なGraphQLスキーマへと変換するデータレイヤーを経由します。このデータレイヤーはGatsbyアーキテクチャの知的中枢であり、グローバル状態管理のためのRedux、永続的なノードストレージのためのLMDB、スキーマ構築のためのgraphql-composeという三本柱の上に成り立っています。

第2回・第3回で確認したように、ビルドパイプラインと開発用ステートマシンはいずれも同じサービス関数(sourceNodesbuildSchemacreatePages)を呼び出します。本記事では、それらのサービスの内側で何が起きているかを解説します。データがシステムに入ってくる仕組み、格納される仕組み、そこからスキーマが構築される仕組み、そしてクエリが抽出・実行される仕組みを順に見ていきましょう。

中枢神経系としてのRedux

packages/gatsby/src/redux/index.ts にあるRedux storeは、ビルドプロセス全体における唯一の信頼できる情報源です。ページ、ノード、コンポーネント、クエリ、webpackのコンパイルハッシュ、HTMLファイルの状態など、あらゆる情報をここで管理します。

IGatsbyStateの構造

IGatsbyState インターフェースは、stateの完全な形を定義しています。主要なメンバーを抜粋して示します。

graph TD
    subgraph "IGatsbyState"
        nodes["nodes: Map<string, IGatsbyNode>"]
        pages["pages: Map<string, IGatsbyPage>"]
        components["components: Map<string, IGatsbyPageComponent>"]
        schema["schema: GraphQLSchema"]
        queries["queries: { trackedQueries, trackedComponents, ... }"]
        html["html: { trackedHtmlFiles, compilationHashes, ... }"]
        flattenedPlugins["flattenedPlugins: Array<FlattenedPlugin>"]
        config["config: IGatsbyConfig"]
        status["status: { PLUGINS_HASH, LAST_NODE_COUNTER }"]
        jobsV2["jobsV2: { incomplete, complete, jobsByRequest }"]
    end
State Slice 役割
nodes Map<string, IGatsbyNode> システム内のすべてのコンテンツノード
pages Map<string, IGatsbyPage> パスとコンポーネントを持つ登録済みページ
components Map<string, IGatsbyPageComponent> クエリとレンダリングメタデータを持つページテンプレート
schema GraphQLSchema コンパイル済みのGraphQLスキーマ
queries 複合オブジェクト クエリのトラッキング:ダーティフラグ、依存グラフ
html 複合オブジェクト HTMLファイルの状態、コンパイルハッシュ
flattenedPlugins Array 正規のプラグインレジストリ

3階層のアクション

Gatsbyのactionsは、packages/gatsby/src/redux/actions/public.js においてアクセス権限の異なる3つの階層に整理されています。

  1. パブリックアクション (actions/public.js):すべてのプラグインから利用可能——createNodecreatePagecreateRedirectdeleteNode
  2. 制限付きアクション (actions/restricted.ts):特定のAPIからのみ利用可能——createTypesaddThirdPartySchemasetWebpackConfig
  3. 内部アクション (actions/internal.ts):フレームワーク専用——SET_PROGRAMSET_SITE_CONFIGSET_SCHEMA

この階層はAPIランナーによって強制されており、各APIフックに対して適切なaction creatorだけがバインドされます。sourceNodesを実装するプラグインはcreateNodeを受け取りますが、setWebpackConfigは受け取れません。

Storeの設定と永続化

storeは2層のmiddleware(101〜115行目)で構成されています。非同期アクションのためのredux-thunkと、アクションの配列を受け取って個別にdispatchするカスタムのmulti middlewareです。

117〜119行目 では、初期stateを条件付きで読み込んでいます。

export const store: GatsbyReduxStore = configureStore(
  process.env.GATSBY_WORKER_POOL_WORKER ? ({} as IGatsbyState) : readState()
)

ワーカーは空のstateを受け取り(メインプロセスから部分的なstateが渡されます)、メインプロセスはLMDBキャッシュからstateを読み込みます。これによって増分ビルドが実現されます。

mettイベントブリッジ

Reduxとプラグインシステムをつなぐさりげないながらも重要な仕組みがあります。mett モジュールは軽量なイベントエミッタ(mitt を参考にしています)で、プレーンなオブジェクトや配列の代わりに Map<string, Set<Handler>> を使用しています。

redux/index.tsの172〜175行目 では、すべてのReduxアクションがmett経由でブロードキャストされます。

store.subscribe(() => {
  const lastAction = store.getState().lastAction
  emitter.emit(lastAction.type, lastAction)
})

これによってpub/subブリッジが形成され、システムのどこからでも特定のReduxアクションを購読できます。プラグインランナー(packages/gatsby/src/redux/plugin-runner.ts)はこのブリッジを使って、プラグインフックを自動的にトリガーします。

sequenceDiagram
    participant Plugin as Source Plugin
    participant Redux as Redux Store
    participant Mett as mett emitter
    participant Runner as plugin-runner.ts
    participant OnCreate as onCreateNode plugins

    Plugin->>Redux: createNode(fileNode)
    Redux->>Redux: Reduce CREATE_NODE
    Redux->>Mett: emit("CREATE_NODE", action)
    Mett->>Runner: CREATE_NODE handler
    Runner->>Runner: Check: is node.internal.type === "SitePage"?
    Runner->>OnCreate: apiRunnerNode("onCreateNode", { node })

44〜77行目startPluginRunner 関数は、起動時にプラグインを事前フィルタリングします。onCreatePageonCreateNode を実装するプラグインが1つも存在しない場合はemitterリスナーを登録しません。これにより、誰も購読していないイベントの発火コストを回避しています。

ヒント: mettエミッタはワイルドカードリスナーとして * イベント名もサポートしています。開発用ステートマシンのミューテーションリスナーがすべてのノードミューテーションをアクションの種類に関わらずキャプチャできるのは、この仕組みのおかげです。

ノードストレージ:ReduxからLMDBへ

GatsbyはもともとすべてのノードをReduxのインメモリstateに保存していました。しかし大規模なサイト(10万ノード以上)ではこれがギガバイト単位のRAM消費につながりました。その解決策がLMDB——ディスクに裏付けられた永続性を持ちながら、メモリに近い速度での読み込みを実現するメモリマップドB-treeデータベースです。

エントリーポイントは packages/gatsby/src/datastore/datastore.ts の遅延ロードパターンです。

let dataStore: IDataStore

export function getDataStore(): IDataStore {
  if (!dataStore) {
    const { setupLmdbStore } = require(`./lmdb/lmdb-datastore`)
    dataStore = setupLmdbStore()
  }
  return dataStore
}

packages/gatsby/src/datastore/lmdb/lmdb-datastore.ts のLMDB実装では、globalThis.__GATSBY_OPEN_ROOT_LMDBS を使ってrequireコンテキストをまたいでデータベースハンドルを共有します。

function getRootDb(): RootDatabase {
  if (!rootDb) {
    if (!globalThis.__GATSBY_OPEN_ROOT_LMDBS) {
      globalThis.__GATSBY_OPEN_ROOT_LMDBS = new Map()
    }
    rootDb = globalThis.__GATSBY_OPEN_ROOT_LMDBS.get(fullDbPath)
    if (rootDb) return rootDb

    rootDb = open({
      name: `root`,
      path: fullDbPath,
      compression: true,
    })
    globalThis.__GATSBY_OPEN_ROOT_LMDBS.set(fullDbPath, rootDb)
  }
  return rootDb
}

このglobalThisキャッシュは「複数のLMDBインスタンス」問題を防ぎます。同一プロセス内で同じデータベースを2度開くとランダムなエラーが発生しますが、gatsby serve ではエンジンとtrailing-slash middlewareの両方がノードへのアクセスを必要とするため、こうした状況が起こり得るのです。

flowchart TD
    A["createNode() action"] --> B["Redux Reducer"]
    B --> C["LMDB updateNodes"]
    C --> D[".cache/data/datastore"]

    E["getNode(id)"] --> F["LMDB getNode"]
    F --> D

    G["getNodesByType(type)"] --> H["LMDB iterateNodesByType"]
    H --> D

    style D fill:#fff3e0

データベースのパスは、本番環境では .cache/data/datastore、テスト環境では .cache/data/test-datastore-{workerId} がデフォルトになります。これによりJestワーカー間でのテスト分離が保証されます(32〜44行目)。

GraphQLスキーマ:推論とカスタマイズの融合

GatsbyのGraphQLスキーマは2つのフェーズで構築されます。プラグインによる明示的な型定義を扱うカスタマイズフェーズと、ノードデータから型を自動生成する推論フェーズです。これらの処理を統括するのが packages/gatsby/src/schema/index.js です。

フェーズ1:カスタマイズ

customizeSchema サービスの実行中、プラグインは createTypes() を呼び出して明示的なGraphQL型を定義します。これらの型定義は store.getState().schemaCustomization.types に格納されます。組み込み型が先に追加され、次にプラグインの型、最後にユーザーの型が追加されます。これにより、ユーザー定義が最優先されます(schema/index.jsの26〜34行目)。

return [
  ...builtInTypes,
  ...types.filter(type => type.plugin && type.plugin.name !== `default-site-plugin`),
  ...types.filter(type => !type.plugin || type.plugin.name === `default-site-plugin`),
]

フェーズ2:推論

明示的な型が登録されると、buildInferenceMetadata53〜80行目)がすべてのノード型をループして、そのデータを検査し、BUILD_TYPE_METADATA アクションをdispatchして推論エンジンに情報を渡します。続いて packages/gatsby/src/schema/schema.js のスキーマビルダーがgraphql-composeを使って明示的な定義と推論された型をマージします。

スキーマ拡張

packages/gatsby/src/schema/extensions/index.js の拡張システムは、型レベルとフィールドレベルの2段階でdirectiveを提供します。

拡張 レベル 役割
@infer フィールドの自動推論を有効にする(デフォルト)
@dontInfer 推論を無効にし、明示的なフィールドのみ使用
@link フィールド 別ノードへの外部キー関係を作成
@dateformat フィールド 日付フィールドに書式指定引数を追加
@fileByRelativePath フィールド 相対ファイルパスをFileノードに解決
@mimeTypes 型が扱うMIMEタイプを定義
@childOf 親子関係を宣言
flowchart TD
    A["Plugins call createTypes()"] --> B["Explicit type definitions"]
    C["Node data in LMDB"] --> D["Inference engine"]
    B --> E["graphql-compose SchemaComposer"]
    D --> E
    F["Schema extensions<br/>@infer @link @dateformat"] --> E
    E --> G["Final GraphQLSchema"]
    G --> H["Store: SET_SCHEMA"]

ヒント: source pluginを開発していて型のスキーマを完全にコントロールしたい場合は、型定義に @dontInfer を付けましょう。これによりGatsbyがノードデータを解析するのを防ぎ、データの形状が変わっても壊れかねない不要なフィールドが追加されることを回避できます。

クエリパイプライン

スキーマが構築されたら、コンポーネントファイルからクエリを抽出し、コンパイル・バリデーション・実行する必要があります。このパイプラインは query/ ディレクトリ内の複数のファイルにまたがっています。

抽出

packages/gatsby/src/query/query-compiler.js のクエリコンパイラは、BabelベースのFileParserを使ってコンポーネントファイル内のGraphQLタグ付きテンプレートリテラルを検索します。プロジェクトとテーマディレクトリ内のすべてのファイルを対象にします。

const parsedQueries = await parseQueries({
  base: program.directory,
  additional: resolveThemes(
    flattenedPlugins.map(plugin => ({
      themeDir: plugin.pluginFilepath,
    }))
  ),
  addError,
  parentSpan: activity.span,
})

コンパイラは、抽出されたクエリをビルド済みスキーマに対してstandard GraphQL validationルール(14〜29行目でgraphqlパッケージからインポート)を使って検証し、フラグメントを同梱します。GatsbyではフラグメントはグローバルスコープなのでAあるファイルで定義されたフラグメントを任意のクエリで使用できます。

実行

GraphQLRunner クラスは、standardのgraphql execute関数をキャッシュとトレーシングでラップしたものです。コンストラクタ実行時にLocalNodeModel——すべてのGatsbyフィールドリゾルバが受け取るresolverコンテキスト——を生成します。

this.nodeModel = new LocalNodeModel({
  schema,
  schemaComposer: schemaCustomization.composer,
  createPageDependency,
  _rootNodeMap,
  _trackedRootNodes,
})

NodeModel がGatsbyのリゾルバを「賢く」している核心部分です。createPageDependencyを通じて、各クエリがどのノードに依存しているかを追跡し、ソースデータが変更された際に自動的にクエリを無効化できます。

3種類のクエリ

Gatsbyは3種類の異なるクエリを処理します。

  1. ページクエリ:ページコンポーネントで定義され、pageContext変数を受け取る。ページごとに1回実行される。
  2. スタティッククエリuseStaticQuery):どこでも定義可能で、変数なし。結果はJSバンドルに埋め込まれる。
  3. スライスクエリ:スライスコンポーネントで定義(Gatsby 5の機能)。スライスごとに1回実行され、ページ間で共有される。
flowchart LR
    A["Component files"] -->|"Babel parse"| B["FileParser"]
    B --> C["Raw queries + fragments"]
    C -->|"Fragment collocation"| D["Complete queries"]
    D -->|"Validate against schema"| E["Valid queries"]
    E -->|"calculateDirtyQueries"| F["Dirty query IDs"]
    F --> G["GraphQLRunner.execute()"]
    G -->|"Page queries"| H["page-data JSON files"]
    G -->|"Static queries"| I["static-query JSON files"]
    G -->|"Slice queries"| J["slice-data JSON files"]

calculateDirtyQueries のステップが増分ビルドを可能にしています。クエリのハッシュとノードの依存関係を前回のビルドと比較し、実際に再実行が必要なクエリを特定します。1万ページのサイトで変更されたノードが3つだけであれば、クエリの実行時間を数分から数秒に短縮できます。

次回予告

ここまでで、生のコンテンツからノード生成、LMDBへの格納、スキーマ推論、クエリ実行までの一連の流れを追いました。最終回では、これらすべてを外の世界につなぐGatsbyの拡張性に焦点を当てます。プラグインシステム、コンポーネントシャドウイングを持つテーマシステム、SSG/DSG/SSRのページモードシステム、そしてデプロイメントアダプター抽象化について解説します。