Nette DI の拡張の作成

拡張は、DI コンテナのコンパイルに割り込むクラスです。サービスをプログラムから登録し、自分の設定セクションを検証し、ほかが定義したサービスを変更し、さらには生成されるコンテナのコードまで変えられます。このページでは、その書き方、何がいつ起こるのか、そして何に気をつけるべきかを学びます。

拡張は、パッケージが Nette に本来の形で組み込まれるための手段です。すべての nette/* パッケージがそれを使っていますし、あなたのパッケージもそうできます。典型的な拡張は、次のうちひとつ以上を行います。

  • ライブラリを統合する – そのサービスをコンテナに登録し、親しみやすく検証済みの設定セクションを公開します(mail:database: のセクションはここから来ます)
  • 登録を自動化する – services: に並べるのが面倒になるほど似たサービスを、ループや規則にもとづいてまとめて登録します
  • 横断的な変更を加える – ほかが登録したサービスを見つけて補います。たとえば特定のタグを持つすべてのサービスにロガーを結びつけます

日々のアプリケーション開発で拡張が必要になることはめったにありません。クラスの登録と結びつけは、設定の servicesセクションで足ります。設定だけでは足りなくなったときに拡張へ手を伸ばしてください。

拡張は extensions セクションで有効にします。BlogExtension クラスが表す拡張を blog という名前で追加するにはこうします。

extensions:
	blog: BlogExtension

コンストラクタが引数を取るなら、その場で渡します。

extensions:
	blog: BlogExtension(%debugMode%)

コンパイルのしくみ

拡張を自信を持って書くには、ひとつ重要なことを知っておく必要があります。あなたのコードがいつ走るのかです。Nette はリクエストの処理中にサービスを結びつけたりしません。代わりにコンテナをあらかじめコンパイルします。すべての設定ファイルを読み、拡張に仕事をさせ、最適化された PHP のクラスを生成してディスクに保存します。以降のリクエストは、この出来上がったクラスを読み込むだけです。ですから拡張のコードは、コンテナが(再)構築されるときにだけ走り、リクエストごとには走りません。

これには重要な帰結があります。コンパイル中はまだサービスが存在しません。存在するのは定義です。各サービスがどのクラスになるか、どう作るか、そのあと何を呼ぶかを記したレシピです。定義は ContainerBuilderオブジェクトの中にあります。拡張は本質的にスクリプトで書ける設定です。services: セクションで宣言できることは何でも、条件つきで、ループで、あるいはほかが登録したものに反応して、PHP でも組み立てられます。

コンパイルは段階を追って進み、拡張はそのそれぞれに入り込めます。

  1. すべての拡張の設定セクションが検証されます(getConfigSchema()
  2. 各拡張が自分のサービスを登録します(loadConfiguration())。ユーザーの services: セクションは最後に処理されるので、アプリケーションが常に最終決定権を持ちます
  3. すべての定義がそろい、サービスの型が解決されると、拡張はそれらを変更できます(beforeCompile()
  4. コンテナのクラスが生成されます。拡張はまだそのコードを調整でき(afterCompile())、アプリケーションの起動時に走るコードを出力できます(初期化

開発モードでは、設定ファイルや拡張のクラス自体を変えるたびにコンテナが自動的に再コンパイルされます。どちらも依存関係として追跡されているからです。ですからキャッシュを消さずに拡張を開発できます。

各段階で何が起きるのか、パラメータがいつ展開されるのか、@service がいつ参照になるのか、そして型でサービスを探しても安全なのは正確にいつなのかを深く知りたい場合は、コンテナのコンパイルの詳細をご覧ください。

最初の拡張

小さいながら完全な拡張の例です。同じファイルで有効にし、設定します。

extensions:
	blog: BlogExtension

blog:
	postsPerPage: 5

そしてこれがクラスの全体です。

use Nette\Schema\Expect;

class BlogExtension extends Nette\DI\CompilerExtension
{
	public function getConfigSchema(): Nette\Schema\Schema
	{
		return Expect::structure([
			'postsPerPage' => Expect::int(10),
			'allowComments' => Expect::bool(true),
		]);
	}


	public function loadConfiguration(): void
	{
		$builder = $this->getContainerBuilder();

		$builder->addDefinition($this->prefix('articles'))
			->setFactory(Blog\Articles::class, ['postsPerPage' => $this->config->postsPerPage]);

		if ($this->config->allowComments) {
			$builder->addDefinition($this->prefix('comments'))
				->setFactory(Blog\Comments::class);
		}
	}
}

getConfigSchema() は、blog: セクション(拡張を登録したキーにちなんだ名前です)に何を書けるかを、型と既定値も含めて記述します。検証された値は $this->config から使えます。loadConfiguration() ではサービスを登録します。名前に注目してください。$this->prefix('articles')blog.articles を生むので、異なる拡張のサービスが衝突することはありません。

そして最後の数行が、そもそも拡張が存在する理由を示しています。comments サービスはコメントが有効なときにだけ登録されます。ただの設定ファイルでは、こうした判断はできません。

こうして登録されたサービスは、services: に書いた場合とまったく同じように振る舞います。必要になったときに遅延して作られ、Blog\Articles と型宣言されたところにはオートワイヤリングが渡します。

以降の章では、拡張のライフサイクルを詳しく説明し、次に拡張の中で使う ContainerBuilderの API を、最後に知っておく価値のある落とし穴を扱います。

拡張のライフサイクル

拡張は Nette\DI\CompilerExtensionを継承し、getConfigSchema()loadConfiguration()beforeCompile()afterCompile() の 4 つのメソッドのうちいくつかを上書きします。コンパイラはコンパイル中にこの順序でそれらを呼びます。

getConfigSchema(): Nette\Schema\Schema

拡張の設定セクションのスキーマを定義します。おかげで利用者は、検証と分かりやすいエラーメッセージをただで手に入れます。blog: セクションの打ち間違いや型の誤りは、あなたがチェックを 1 行も書かずに、理解しやすいメッセージで報告されます。

スキーマは Schemaライブラリで記述し、型、既定値、許される値などを表せます。

public function getConfigSchema(): Nette\Schema\Schema
{
	return Expect::structure([
		'postsPerPage' => Expect::int(10),
		'storage' => Expect::anyOf('files', 'database')->firstIsDefault(),
	]);
}

検証された設定は stdClass オブジェクトとして $this->config から使えます(スキーマに castTo('array') を足せば配列としても使えます)。

オプションの値がコンパイル時には分からない場合、たとえば環境変数から来る場合は、dynamic() で印を付けてください。たとえば Expect::int()->dynamic() です。詳しくは動的パラメータをご覧ください。

loadConfiguration()

拡張が ContainerBuilderを使って自分のサービスを登録する場所です。

public function loadConfiguration(): void
{
	$builder = $this->getContainerBuilder();
	$builder->addDefinition($this->prefix('articles'))
		->setFactory(Blog\Articles::class);
}

サービスを短い名前でも使えるようにしたいなら、別名を足します。慣習として、これは拡張が通常の名前で登録されている場合にだけ行い、拡張の複数のインスタンスがその名前を奪い合わないようにします。

if ($this->name === 'blog') {
	$builder->addAlias('articles', $this->prefix('articles'));
}

サービスが多い場合は、見慣れた servicesの構文を使って別の NEON ファイルで定義するほうが便利かもしれません。@extension の接頭辞は現在の拡張を指します。

services:
	articles:
		create: MyBlog\ArticlesModel(@connection)

	comments:
		create: MyBlog\CommentsModel(@connection, @extension.articles)

これらの定義は loadDefinitionsFromConfig() で読み込みます。名前には自動的に接頭辞が付き、ファイルは依存関係として追跡されるので、変更すれば再コンパイルが起こります。

public function loadConfiguration(): void
{
	$this->loadDefinitionsFromConfig(
		$this->loadFromFile(__DIR__ . '/services.neon')['services'],
	);
}

beforeCompile()

このメソッドが呼ばれる時点で、builder はすべての定義を持っています。あなたの定義、ほかの拡張の定義、そしてユーザーの設定ファイルの定義です。サービスの型も解決済みなので、型による検索が確実に働きます。この段階は、最終的なサービスのつながりを調べて補うのにうってつけです。

ふつうはタグや型でサービスを探し、見つけた定義を補います。

public function beforeCompile(): void
{
	$builder = $this->getContainerBuilder();

	foreach ($builder->findByTag('logaware') as $name => $attrs) {
		$builder->getDefinition($name)->addSetup('setLogger');
	}
}

setLogger() の呼び出しには明示的な引数がありません。ファクトリの場合と同じく、オートワイヤリングが渡してくれます。

$this->compiler->getExtensions() で取得したほかの登録済みの拡張と協調することもできます。クラスやインターフェースで絞り込むこともできます。

foreach ($this->compiler->getExtensions(FooExtension::class) as $extension) {
	// ...
}

afterCompile (Nette\PhpGenerator\ClassType $class)

最後の段階で、コンテナのクラスが ClassTypeオブジェクト(PHP Generatorライブラリのもの)として生成されます。そこにはサービスごとのファクトリメソッドが入っていて、これからキャッシュに書き込まれます。そのコードはまだ変更できます。

public function afterCompile(Nette\PhpGenerator\ClassType $class): void
{
	$method = $class->getMethod('__construct');
	// ...
}

この段階が必要になることはめったにありません。アプリケーションの起動時に走るコードを足したいなら、代わりに初期化を使ってください。

初期化のコード

ここまでの段階はすべて、コンテナがどう構築されるかに影響します。それに加えて拡張は、コンテナが作られた直後、つまり実行時に走るコードも出力できます。たとえばセッションを始めたり、サービスを起動したりするためです。そのコードは $this->initialization オブジェクトの addBody()メソッドで書き込みます。

public function loadConfiguration(): void
{
	// 'run' タグを持つサービスは、コンテナの起動直後に作らなければなりません
	$builder = $this->getContainerBuilder();
	foreach ($builder->findByTag('run') as $name => $attrs) {
		$this->initialization->addBody('$this->getService(?);', [$name]);
	}
}

Nette 自身も、セッションの自動開始やセキュリティ用の HTTP ヘッダーの送信などに初期化を使っています。そして覚えておいてください。拡張のほかの部分と違い、このコードはリクエストごとに走るので、小さく保ちましょう。

ContainerBuilder

Nette\DI\ContainerBuilderは、拡張がコンパイラと話すためのオブジェクトです。すべてのサービスの定義を保持し、その追加、検索、変更のためのメソッドを提供します。loadConfiguration()beforeCompile() で取得します。

$builder = $this->getContainerBuilder();

サービスの追加

サービスの登録は、NEON ファイルの services: セクションでやることと同じで、それを PHP で書くだけです。設定の各キーには対応するメソッドが定義側にあるので、次の 2 つの書き方は等価です。

services:
	articles:
		create: Blog\Articles(@connection)
		setup:
			- setLogger(@logger)
		tags: [logaware]
$builder->addDefinition($this->prefix('articles'))
	->setFactory(Blog\Articles::class, ['@connection'])
	->addSetup('setLogger', ['@logger'])
	->addTag('logaware');

addDefinition() が返す定義は ServiceDefinitionで、設定のキーに対応するものを提供します。setType()(サービスのクラス)、setFactory()(作り方)、setArguments()addSetup()addTag()setAutowired() です。

addSetup()setup: のリストに対応し、同じ形を受け付けます。メソッドの呼び出し addSetup('setLogger', ['@logger'])、プロパティへの代入 addSetup('$cache', ['@cache'])、ほかのサービスの呼び出し addSetup('@Tracy\Bar::addPanel', [$panel]) です。

通常のサービスのほかに、builder は生成されるファクトリ、アクセサ、ロケーターも登録できます。それぞれに対応する定義の型を返すメソッドがあります。

メソッド 登録するもの
addDefinition() 通常のサービス(ServiceDefinition を返します)
addFactoryDefinition() 生成されるファクトリcreate() メソッドを持つインターフェース)
addAccessorDefinition() 生成されるアクセサget() メソッドを持つインターフェース)
addLocatorDefinition() 複数のファクトリをまとめたマルチファクトリ/ロケーター
addImportedDefinition() 実行時に外部からコンテナへ渡されるサービス
addAlias() 既存のサービスの別名

ファクトリでは、それが作るオブジェクトを getResultDefinition() で設定します。アクセサは代わりに setReference() で既存のサービスを指します。

$builder->addFactoryDefinition($this->prefix('latteFactory'))
	->setImplement(LatteFactory::class)
	->getResultDefinition()
		->setFactory(Latte\Engine::class)
		->addSetup('setStrictTypes', [true]);

addLocatorDefinition()addImportedDefinition() が必要になることはめったにありません。そうしたサービスはふつう、手で書くのではなく NEON の implement: キーや取り込みサービスのキーから来るからです。

サービスの検索と変更

既存の定義を探したり辿ったりするために、builder は次のものを提供します。

メソッド 説明
getDefinition(string $name) 指定した名前の定義(なければ例外を投げます)
hasDefinition(string $name) その名前の定義や別名が存在するか
getDefinitions() すべての定義
removeDefinition(string $name) 定義を取り除きます
getByType(string $type) その型のオートワイヤリング対象のサービス名、またはなければ null
getDefinitionByType(string $type) その型のオートワイヤリング対象の定義
findByType(string $type) その型のすべての定義を 名前 => 定義 の組で返します
findByTag(string $tag) そのタグを持つサービスを 名前 => タグの値 の組で返します
addExcludedClasses(array $types) クラスとインターフェースをオートワイヤリングから除外します

便利な決まり文句が、getByType() でサービスがそもそも存在するかを調べることです。たとえばアプリケーションにロガーがあるときだけ、それに結びつける場合です。

if ($builder->getByType(Psr\Log\LoggerInterface::class)) {
	$builder->getDefinition($this->prefix('articles'))
		->addSetup('setLogger');
}

定義の種類

add*Definition() の各メソッドは、それぞれ違う種類の定義を返します。どれも共通の祖先 Nette\DI\Definitions\Definition を継承しています。

  • ServiceDefinition – 通常のサービス。setType()setFactory()addSetup()addTag()setAutowired() で設定します
  • FactoryDefinition – 生成されるファクトリ。呼ぶたびに新しいオブジェクトを返す create() メソッドを持つインターフェースです
  • AccessorDefinition – 生成されるアクセサ。既存のサービスを返す get() メソッドを持つインターフェースです
  • LocatorDefinition – 複数のファクトリやアクセサをひとつのインターフェースにまとめたマルチファクトリ/ロケーター
  • ImportedDefinition – コンテナが自分で作らず、実行時に外部から受け取るサービス

getDefinition() は、その名前の下にある種類の定義をそのまま返すことを覚えておいてください。生成されるファクトリに出会う可能性があるコードでは、まず型を調べ、作られるオブジェクトを getResultDefinition() で設定してください。

$def = $builder->getDefinition($name);
if ($def instanceof Nette\DI\Definitions\FactoryDefinition) {
	$def = $def->getResultDefinition();
}
$def->addSetup('setLogger');

ヒントと落とし穴

コンパイル時と実行時

最もよくある混乱のもとです。拡張のコードは、アプリケーションがリクエストを処理するときではなく、コンテナがコンパイルされるときに走ります。実際上これは次のことを意味します。

  • 拡張はサービスのインスタンスを扱いません。まだ存在しないからです。new でサービスを作らず、定義を登録してコンテナに作らせてください。
  • すべての設定値は生成されるコードに焼き込まれます。環境によって変わり得る値(パス、getenv() から得るパスワード)は動的と印を付けなければ、コンパイル時に固定されてしまいます。
  • $this->initialization->addBody() に渡す文字列は、今実行されるのではありません。コンテナに書き出される PHP のコードで、リクエストごとに実行されます。

ファイルの依存関係

設定ファイルや拡張のクラスが変わると、コンテナは再コンパイルされます。しかし拡張がほかのファイル、たとえばエンティティの一覧やライブラリの XML 設定を読んでいる場合、コンテナはそれを知る術がありません。そうしたファイルは次のように登録してください。

$builder->addDependency($file);

さもないと、典型的な謎に悩まされます。ファイルを編集したのにアプリケーションは古いままの振る舞いを続け、変更は何かほかの理由でコンテナが再構築されたときにようやく現れるのです。(loadFromFile() で読んだファイルは自動的に追跡されます。)

条件つきの登録

拡張は環境に合わせて振る舞いを変えられます。任意の統合はふつう class_exists() で守ります。

if (class_exists(Symfony\Component\Console\Command\Command::class)) {
	$builder->addDefinition($this->prefix('command'))
		->setFactory(Blog\Console\SitemapCommand::class);
}

そして %debugMode% のような値は、拡張のコンストラクタで渡すのが最善です。

extensions:
	blog: BlogExtension(%debugMode%)
class BlogExtension extends Nette\DI\CompilerExtension
{
	public function __construct(
		private bool $debugMode = false,
	) {}
}

典型的な用途は、開発モードのときだけ Tracy のパネルを登録することです。

複雑な引数

ファクトリや setup の呼び出しの引数が、素の値、クラス名、@service の参照のいずれでもないことがあります。そうした場合のために次のものがあります。

  • new Nette\DI\Definitions\Statement(Blog\Panel::class, [$args]) – その場で作られるオブジェクト。引数として使う「無名のサービス」です
  • new Nette\DI\Definitions\Reference('blog.articles') – サービスへの参照。@name という文字列のオブジェクト版です
  • $builder::literal('PHP_SAPI') – 生成されるコンテナにそのまま挿入される生の PHP コード

例として、Tracy のパネルを登録してみましょう。

$builder->getDefinition($this->prefix('articles'))
	->addSetup('@Tracy\Bar::addPanel', [
		new Nette\DI\Definitions\Statement(Blog\ArticlesPanel::class),
	]);

書き出されるタグと型

メタデータの書き出しは設定で制限でき、コンパイル済みのコンテナがアプリケーションの実際に使うタグとオートワイヤリングの型だけを保つようにできます。あなたの拡張が実行時に $container->findByTag()$container->getByType() でサービスを取得しているなら、その制限が、まさに頼りにしているメタデータを取り除いてしまうかもしれません。

それを防ぐには、常に書き出されるべきタグと型をコンパイラに伝えます。

public function loadConfiguration(): void
{
	// このタグは、書き出しが制限されていても常に書き出されます
	$this->compiler->addExportedTag('event.subscriber');

	// この型は常に getByType() で使えます
	$this->compiler->addExportedType(Nette\Database\Connection::class);
}

どちらのメソッドも書き出されるメタデータに足すだけで、アプリケーションの di › export の設定を上書きすることはありません。ですからアプリケーションが書き出しを一覧に制限しても、拡張が必要とするタグと型は含まれたままです。タグの書き出しを完全に切った場合(tags: false)にだけ、ほかのすべてとともに捨てられます。

バージョン: 3.x