コンテナのコンパイルの詳細

このページはコンテナのコンパイルを解きほぐします。どんな段階を経るのか、設定のパラメータがいつ展開されるのか、@service の文字列がいつ本当の参照になるのか、そして拡張の作者が最もよく尋ねる問い、つまりどの段階なら型でサービスを安全に探せるのかを扱います。拡張の作成の、より深い姉妹編です。

ふつうのアプリケーションを書くのに、いえ、ふつうの拡張を書くのにさえ、この内容は必要ありません。しかし拡張がサービスのつながりを調べたり組み替えたりし始めると、タイミングがすべてになります。同じ getByType() の呼び出しが、ある段階では確実な答えを、別の段階では誤解を招く答えを返すのです。このページはその理由を説明するので、自分のコードをどこに置くべきかがいつでも分かるようになります。

2 つの世界: コンパイル時と実行時

まず理解すべき最も重要なことは、Nette のコンテナがリクエストごとに組み立てられるのではないという点です。最適化された PHP のクラスとして一度だけ構築され、そのクラスはディスクに保存され、以降のリクエストは出来上がったファイルを include するだけです。以下で説明するしくみ、つまり拡張、リゾルバ、コードジェネレータは、(再)コンパイルのときにだけ走ります。

これにより世界は、決して同時には存在しない 2 つの表現に分かれます。

  コンパイル時 実行時
存在するもの ContainerBuilder の中の定義(レシピ) Container の中のサービスのインスタンス
主なクラス CompilerContainerBuilderResolverPhpGenerator Container(生成されるクラスの親)
%param%@service まだ変換の途中にあるテキストの印 すでに変換済み/コードに焼き込み済み

生成されるクラスは Nette\DI\Container を継承し、サービスごとに createServiceXxx() メソッドを持ちます。そのパラメータとオートワイヤリングのメタデータはあらかじめ計算されているので、実行時に解決すべきものは何も残っておらず、必要に応じてサービスを生成するだけです。

開発モードでは、設定ファイルや拡張のクラスが変わるたびにコンテナが自動的に再構築されます。どちらも依存関係として追跡されているからです。本番では一度コンパイルされたきりで、二度と確認されません。速さはそこから来ています。

段階のあらまし

コンパイルは Compiler::compile() が取り仕切り、結局は 3 つの段階に落ち着きます。

public function compile(): string
{
	$this->processExtensions();     // 段階 A: スキーマ + loadConfiguration()
	$this->processBeforeCompile();  // 段階 B: resolve + beforeCompile() + complete
	return $this->generateCode();   // 段階 C: コード生成 + afterCompile()
}

全体の見取り図はひとつの考えに収まります。あとの段階ほど多くを知っている、ということです。

  • 段階 A はグラフを定義で満たします。サービスの型はまだ確実には分かりません。型がファクトリの戻り値から来ていて、まだ誰もそれを見ていないことがあるからです。
  • 段階 B はまずすべての型を解決し(resolve)、次に拡張にグラフを組み替えさせ(beforeCompile)、最後に引数をオートワイヤリングします(complete)。
  • 段階 C は出来上がったグラフを PHP に変え、拡張が生成されたコードに触れられるようにします。

知識が増えていくこの流れこそ、同じ操作がある段階では安全で別の段階では当てにならない理由です。このページの残りは、その考えを念頭に段階を辿っていきます。

段階 A: 定義の登録

この段階で Nette は、各拡張の 3 つのメソッド、getConfigSchema()、次に setConfig()、そして loadConfiguration() を呼びます。ただし慎重に制御された順序で呼びます。ここでは順序が本当に重要だからです。

なぜ順序が重要なのか

  • ParametersExtensionExtensionsExtension が最初です。 前者はほかの何よりも先に走って、設定全体で %param% を展開しなければなりません。そうすればほかのすべての拡張は、値が埋まった状態で自分のセクションを受け取れます。後者は extensions: セクションに並べられたさらなる拡張を登録するので、これも残りが処理される前に存在している必要があります。
  • ServicesExtension が最後です。 ですからユーザーの services: セクションが常に最終決定権を持ち、拡張が用意したものを何でも上書きできます。
  • InjectExtension は最後尾に移されます。 その仕事が、ほかのすべての拡張が足した setup を見られるようにするためです。

あなたにとっての要点はこうです。あなたの拡張の loadConfiguration() が走る時点で、パラメータはすでに展開されていますが、ユーザーのサービスはまだそこにありません。この事実ひとつが、以下のタイミングの規則のほとんどを決めています。

services: を定義に変える

ユーザーの services: セクションは、段階 A の最後の手順としてここで定義のオブジェクトに変えられます。NEON の各項目は正規化され(略記が統一され)、その種類が判別され(通常のサービス、ファクトリ、アクセサ、…)、builder に対応する定義が作られます。単純な @name / @Type の引数が参照になるのも、ここが最初の瞬間です。後述をご覧ください。

段階 A の終わりには、すべての定義がそろっています。どの拡張もユーザーも、登録したいものを登録し終えています。それでも絵はまだ鮮明ではありません。

  • 型がファクトリの戻り値から来る定義については、型が解決されていません
  • 引数がオートワイヤリングされていません
  • 一部の @service の参照はまだただの文字列です。

だからこそ、ここで型を使って探すのは当てになりません。詳しくは後述をご覧ください。

パラメータ: %param% はいつ展開されるか

2 つの目玉の問いのひとつです。答えは短く、段階 A のいちばん最初に、設定のツリー全体に対して一度だけです。

ParametersExtension が最初に走り、その最初の仕事のひとつが %param% のプレースホルダーの展開です。まずパラメータ自身の中で(パラメータはほかのパラメータを参照できます)、次に設定の残り全体で展開します。ですから ServicesExtension を含むほかの拡張が自分のセクションを受け取る時点で、プレースホルダーはもう消えています。拡張が扱うのは具体的な値であって、%...% ではありません。

プレースホルダーが文字列の全体である場合、その値はそのまま返されます。配列やオブジェクトも含みます。ですから %mailer% は配列まるごとに展開できます。それ以外の場所では文字列に連結され、ドット記法 %foo.bar% は入れ子の配列の中に届きます。

静的なパラメータと動的なパラメータ

すべての値をコードに焼き込めるわけではありません。環境ごとに異なる値、たとえば環境変数や、リクエストから導かれる baseUrl動的でなければなりません。そうしたパラメータは setDynamicParameterNames() か、スキーマの Expect::...->dynamic() で宣言します。詳しくは動的パラメータをご覧ください。

動的なパラメータは値に置き換えられるのではなく、実行時にそれを読む式に置き換えられます。ですから %env.DB_HOST% は文字列に固定されず、生成されたコンテナの中で実行時に参照されます。それ以外はすべて静的で、コンパイル時に固定されます。「getenv() の値がどの環境でも同じになる」という驚きは、たいていここから来ます。そのパラメータが単に静的だっただけです。

逆の操作がエスケープです。文字どおりの %@ が解釈されないようにするには、二重にします(%%@@)。Nette は自分が注入するパラメータについてこれを自動的に行うので、その値がプレースホルダーや参照と取り違えられることはありません。

参照: @service はいつ参照になるか

2 つめの目玉の問いです。@service の変換は、文字列がどれくらい複雑かに応じて、異なる段階にまたがる何段階かで起こります。これを手で追う必要はめったにありませんが、その手順を知っておくと、一部の参照がほかより早く解決される理由が分かります。

  • 解析(設定の読み込み)。 エンティティとして使われた @service、つまり Foo(@bar) のようにサービスを作るものは、ただちに参照になります。引数として使われた @service は、いまのところただの文字列のままです。引用符で囲まれた @@@ にエスケープされるので、参照ではなく文字どおりのテキストとして扱われます。
  • 段階 A(loadConfiguration)。 定義が処理されるとき、素の @name@Type の引数が Reference オブジェクトになります。ここで捕まるのは単純な形だけで、@service::CONST や大きな式の中の @ はあとに回されます。
  • 段階 B(complete)。 本当に「賢い」変換はここで起こります。@service → 参照、@service::CONSTANT → クラス定数のリテラル、@service::property → そのプロパティの読み取り、@@x → 文字どおりのテキスト @x です。

参照という言葉自体にも、もうひとつの変換が隠れています。Reference名前でも@Namespace\Type)でも指せます。型による参照はまだサービス名ではありません。具体的な名前への解決はオートワイヤリングが行い、それはcomplete の手順、つまりオートワイヤリングの索引が組み上がってからにしか起こりません。これが次の節への橋渡しです。オートワイヤリングによる検索は、索引が整うまで意図的に先送りされているのです。

参照や式になるのは 具体的なサービスに解決されるのは
エンティティ(ファクトリとしての @foo 解析 complete
引数の @foo@Type 段階 A complete
@foo::CONST@foo::prop 段階 B complete
型による参照 @Type 段階 A/B complete(オートワイヤリング)

ContainerBuilder を覗く: いつなら安全か

さて、拡張の作者が最もよく尋ねる問いです。どのメソッドなら型でサービスを探せるのか。 答えは、builder が自分の状態をどう追跡しているかについての単純な規則から導かれます。

型による検索(getByType()getDefinitionByType()findByType())には、サービスのつながりが解決済みであること、つまりすべての型が分かり、オートワイヤリングの索引が組み上がっていることが必要です。ですからこれらを呼んだとき、前回の解決以降にグラフが変わっていれば、builder はその場で分かっている範囲のグラフ全体を解決します。解決そのものの最中は型による検索が禁じられ、NotAllowedDuringResolvingException が投げられます。

タグによる検索(findByTag())にはそうした前提がありません。タグは型に依存しないので、どの段階でも働きます。

段階ごとに見ていきましょう。

  • loadConfiguration()(段階 A)— 型による検索は当てになりません。 グラフは未完成です。あとで走る拡張はまだ自分のサービスを登録していませんし、何よりユーザーの services:(最後に走ります)がまだありません。getByType() の呼び出し自体は動きます。部分的なグラフの早すぎる解決を引き起こすからです。しかし答えは未完成の絵から来ますし、早すぎる解決は無駄な労力です。目安はこうです。loadConfiguration() では定義の登録だけを行い、型で検索しないこと。 findByTag() なら問題ありません。
  • beforeCompile()(段階 B)— 覗くのに適した場所です。 この時点で(ユーザーのものも含めて)すべての定義が存在し、型は解決済みで、オートワイヤリングの索引も組み上がっています。ですから getByType()findByType()findByTag() はどれも確実な答えを返します。引数はまだオートワイヤリングされていません。それはすぐ次の手順(complete)で、すべての beforeCompile() の呼び出しのあとに行われます。ここで定義を変更すると、次の getByType() がグラフを透過的に解決し直すので、編集と問い合わせを自由に交互に行えます。
  • afterCompile()(段階 C)— コードだけ。 builder ではなく、生成されたクラスに対して働きます。グラフは完成しています。ここでは出来上がる PHP の形を整えます。
やりたいこと 段階
サービスを登録する loadConfiguration()
タグで検索して定義を変更する loadConfiguration() または beforeCompile()
で検索する(getByType/findByType beforeCompile()
引数にオートワイヤリングが選んだサービスに依存する コンパイル時には不可。実行時に調べてください
生成されたコードに手を入れる afterCompile()
コンテナの起動後にコードを走らせる 初期化のコード

段階 B の中身: resolve と complete

段階 B は 2 回の走査で、そのあいだに beforeCompile() の呼び出しが挟まれます。

$this->builder->resolve();     // 型を解決し、オートワイヤリングの索引を作ります
foreach ($this->extensions as $extension) {
	$extension->beforeCompile();
}
$this->builder->complete();    // ここではじめて引数がオートワイヤリングされます

resolve() はすべてのサービスの型を決めます。宣言された type から取るか、ファクトリから推し量ります。ファクトリメソッドの戻り値の型、インスタンス化されるクラス、参照が指すサービスからです。そして各型(そのクラスと親、インターフェース)をサービス名に対応づけるオートワイヤリングの索引を作ります。autowired: false と印を付けたサービスは索引から外れ、autowired: [A, B] はそれが見える型を絞ります。大事なのは、resolve が決めるのはであって引数ではないという点です。引数のオートワイヤリングには完成した索引が必要で、それはこの走査のあとにしか存在しません。

complete() で、引数のオートワイヤリングが実際に起こります。各定義について、足りないコンストラクタと setup の引数を、いまや完成した索引でその型を引いて埋めます。型による参照が resolve の最中に未解決のまま残されていたのはこのためです。その検索は、頼れる索引ができてからのこの場所に属するのです。

段階 C: コードの生成

generateCode() は出来上がったグラフを PhpGenerator に渡し、Container を継承しサービスごとに createServiceXxx() メソッドを持つクラスと、あらかじめ計算された aliasestagswiring のメタデータを作らせます。各 Statement は PHP のテキスト(new Foo(...)、メソッドの呼び出し、プロパティへのアクセス)になり、各 Reference$this->getService(...) の呼び出しになります。

そして拡張は、生成されたクラスに対する最後の afterCompile() の機会を得ます。たとえば静的・動的なパラメータのゲッターはここで出力されます。あわせて、リクエストごとに走る初期化のコードを足す機会もあります。

ひとつの図で見る時間の流れ

コンパイル(一度だけ。キャッシュへ)
│
├─ 設定ファイルを読み込む         NEON -> Statement/配列; ファイルの統合
│                                 引用符の中の @ -> @@ ; エンティティ -> Statement
│
▼ Compiler::compile()
│
├─ PHASE A  processExtensions()
│   ├─ ParametersExtension(最初)  ── %param% を設定全体で展開
│   │                                 動的なものは実行時の式へ
│   ├─ ExtensionsExtension(最初)  ── さらなる拡張を登録
│   ├─ ...ほかの拡張...             ── loadConfiguration(): 定義の登録だけ
│   └─ ServicesExtension(最後)    ── services: -> Definition オブジェクト
│                                      @name/@Type -> Reference
│   [グラフは数の上では完成; 型と引数はまだ; 型による検索は当てにならない]
│
├─ PHASE B  processBeforeCompile()
│   ├─ builder.resolve()          ── すべての型を解決; オートワイヤリングの索引を作成
│   │                                [型が整った; 索引が整った]
│   ├─ beforeCompile() 拡張       ── ここでは getByType/findByType/findByTag が安全
│   │                                (引数はまだオートワイヤリングされていない)
│   └─ builder.complete()         ── 引数をオートワイヤリング; 参照の変換を仕上げ
│                                    型による参照 -> サービス名
│
└─ PHASE C  generateCode()
    ├─ PhpGenerator.generate()    ── Statement -> PHP; createServiceXxx() メソッド
    ├─ afterCompile() 拡張        ── コードを調整; パラメータのゲッターを出力
    └─ toString()                 ── 最終的な PHP コード -> キャッシュ

────────────────────────────────────────────────────────────

実行時(リクエストごと)
│
├─ new Container($dynamicParams)
├─ initialize()                   ── 拡張の起動コード(セッション、ヘッダー、検証)
└─ getService()/getByType()       ── あらかじめ計算されたメタデータから遅延生成

よくある誤解

  • loadConfiguration() で型からサービスを引こう」。いけません。グラフは未完成で(ユーザーの services: はあなたのあとに走ります)、getByType() は部分的なグラフの早すぎる解決を引き起こします。beforeCompile() に移してください。findByTag() ならここでも問題ありません。
  • 「パラメータの getenv() の値は環境ごとに違うはずだ」。そのパラメータが動的な場合だけです。そうでなければコンパイル時に焼き込まれ、どこでも同じままです。
  • @Type の参照はもうサービス名だ」。違います。それは型による参照で、具体的な名前への解決は complete の手順でオートワイヤリングが行います。
  • 「拡張が補助ファイルを読んでいるのに、変更が反映されない」。$builder->addDependency($file) で登録してください。さもないとキャッシュはそれを知らず、作り直されません。
  • resolve() の最中に getByType() を呼べる」。いけません。NotAllowedDuringResolvingException を投げます。型による検索は beforeCompile() かそれ以降に属し、解決の最中には決して属しません。
バージョン: 3.x