Thumbnail image

サービスコンテナによるDI

概要

はじめに

こんにちは、株式会社 Life Arc System の 開発担当の mzn です。

Laravel フレームワークは、主要な機能の一つとして、サービスコンテナによる 「依存性の注入(Dependency Injection、略して DI)」 をサポートしています。

本投稿では、DI とは何か、及び、Laravel のサービスコンテナについて説明させていただきます。

対象読者

本投稿は、以下の知識を有した方を対象として執筆しています。

  • PHP の基本文法を理解、把握している方
  • オブジェクト指向を理解、把握している方
  • Laravel のサービスコンテナに興味がある方

動作環境

本投稿の内容は、以下の環境にて確認しています。

  • PHP 8.x
  • Laravel 10.x

依存性の注入(DI)とは

新規にクラスを定義する際に、他のクラスを使用するケースが多々あるかと思います。
他のクラスを使用するために、具体的には、以下のようなやり方が考えられます。

  • 使用したいクラスを新規クラスをプロパティとして持たせる
  • 使用したいクラスを新規クラスの任意のメソッド内で変数として使用する

他のクラスを使用するに当たって、インスタンスのライフサイクルを考慮しておく必要があります。
インスタンスのライフサイクルとは、具体的に以下のような事項です。

  • インスタンス化のタイミング
  • インスタンスの共有有無
  • インスタンスの寿命

なぜなら、インスタンスのライフサイクルを考慮しないと、適切ではない方法で他のクラスを利用してしまうかもしれない1ためです。

上記のように、あるクラスや処理が別のクラスを必要としている関係を 「依存性がある」 と呼びます。
依存性がある場合には、あるクラスや処理自身の目的以外に、依存しているクラスの利用方法を決定しなければいけません。
依存しているクラスの利用方法を決定することが、依存性の解決です。

さらに、依存先のクラスが複雑であったり、数が多い場合などは依存性の解決のためのソースコードの量が肥大化してしまいます。 依存性の解決のために労力がかかり、クラスや処理自身の目的に集中できなくなってしまうことも考えられます。

では、依存関係を持つクラスや処理が、本来の目的に集中するためには、どのような実装方法を取るのが適切なのでしょうか? 上記の問題の解決策として、依存性の解決の責務をクラスや処理の外に切り出し、外部で行う方法があります。
そして、依存性の解決の結果をインスタンスとしてコンストラクタや処理の引数から流し込むことで、依存関係を持つクラスや処理が、余計な責務から解放されます。

このような設計パターンが 依存性の注入(Dependency Injection) と呼ばれます。
本設計パターンを適用することで以下のメリットが考えられます。

  • 依存性の注入を受けるクラスや処理から見て、依存性の解決の方法が抽象化され、一貫した操作で依存しているクラスのインスタンスを使用できるようになる
  • 切り出された依存性の解決は、切り出した元のクラスや処理にとらわれず、実装の場所が自由になる。
    そのため、依存性の解決の実装を一か所にまとめ、管理することによって、共通化や形式的な手続きとすることが可能になる。
  • 以下の点より、ユニットテストが容易になる。
    • テスト対象のクラスにとっては、引数で受け取るまでに依存先のインスタンス化が完了していれば問題ない。 そのため、依存先のインスタンスのみ可変とすることで、テストコードの共通化を行うことができる。
    • 依存先のクラスのインスタンス化の方法が自由なため、テストの条件の設定が行いやすい。
    • テスト対象の引数がインターフェイスや抽象クラスの場合、依存先のクラスが未実装でも、ダミーのクラスでテスト可能。

一方で、DI を行うことにより、以下のようなデメリットが考えられます。

  • DI は依存性の解決の実装が自由になる反面、無秩序になりがち。
    依存性の解決のための処理レイヤーの追加や、DI コンテナの導入等を併せて行い、DI 利用のための枠組みを準備するのが良いと思われるが、導入コストや学習コストもかかる。
  • 依存性解決のためのクラス数やファイル数の肥大化。

Laravel を含め、多くのフレームワークでは、集約的に依存性の解決方法を管理し、DI を行う機能として、DI コンテナが実装されています。
Laravel では DI コンテナの機能は、サービスコンテナの名称で提供されています。

Laravel のサービスコンテナ

Laravel のサービスコンテナの機能はIlluminate\Foundation\Applicationクラスのインスタンスから呼び出すことが可能です。
具体的に以下のいずれかの方法によりアクセスできます。

  • Appファザードからスコープ解決演算子を介して、直接メソッドを呼び出す。
  • 引数なしで、app()ヘルパの戻り値から取得。
  • サービスプロバイダ内の$appプロパティからアクセス。

また、サービスコンテナの機能は 「結合」「依存性の解決」「DI」 の 3 つのステップで構成されています2
いずれの機能も、主に、Illuminate\Foundation\Applicationクラスの基底クラスである、Illuminate\Container\Containerクラスで実装されています。

結合

結合は依存性の解決の準備のため、主に以下の内容をサービスコンテナに登録する操作です。
※結合についてのコード例は全てサービスプロバイダ内であるものとします。

1. インターフェイスに対して、実装クラスを紐づけます

use Illuminate\Support\ServiceProvider;

interface HogeInterface { /**/ }
class Hoge implements HogeInterface { /**/ }

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // インターフェイスと実装クラスの紐づけ
        $this->app->bind(HogeInterface::class, Hoge::class);
    }
}

2. あるクラスに対して、コールバックにより任意のインスタンス化方法を登録します。

use Illuminate\Support\ServiceProvider;
// Illuminate\Foundation\Application のインターフェイス
use Illuminate\Contracts\Foundation\Application;

interface TomorrowInterface { /**/ }
class MyDate implements TomorrowInterface
{
    function __construct(\DateTime $date) { /**/ }
}

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // インターフェイスやクラスに対して、任意のインスタンス化手順を登録
        // コールバック引数にはサービスコンテナが渡される
        $this->app->bind(TomorrowInterface::class, function(Application $app) {
            $date = new \DateTime("now");
            $tomorrow = $date->add(new \DateInterval("P1D"));
            return new MyDate($tomorrow);
        });
    }
}

3. サービスコンテナの外部でインスタンス化したものや、シングルトンとするなど、インスタンス共有の設定をします。

use Illuminate\Support\ServiceProvider;

interface SomeRepositoryInterface { /**/ }
class SomeRepository implements SomeRepositoryInterface { /**/ }
interface SomeServiceInterface { /**/ }
class SomeService implements SomeServiceInterface
{
    function __construct(private SomeRepositoryInterface $repository) {}
}

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // インターフェイスやクラスに対して、任意のインスタンス化手順を登録
        // コールバック引数にはサービスコンテナが渡される
        $this->app->singleton(SomeRepositoryInterface::class, SomeRepository::class);
        // // singleton メソッドの中では bindメソッド が呼ばれており、以下が等価
        // // 第三引数でインスタンス共有の有無を指定可能
        // $this->app->bind(SomeRepositoryInterface::class, SomeRepository::class, true);

        // インスタンスを直接結合
        $someService = new SomeService($this->app->make(SomeRepositoryInterface::class));
        $this->app->instance(SomeServiceInterface::class, $someService);
    }
}

依存性の解決

依存性の解決は指定されたインターフェイスやクラスのインスタンス化を実施します。
指定されたインターフェイスやクラスについて、結合が登録されていた場合は、登録内容に従ってインスタンス化を実施し、結合の登録がなければ実装クラスが指定されたものとして、直接インスタンス化を試行します。

サービスコンテナから依存性の解決を行う場合、makeメソッドを呼び出します。

// サービスプロバイダ等で行った結合の内容は隠蔽され、
// インスタンスの取得方法をある程度、統一可能
$hoge = App::make(HogeInterface::class);
$tomorrow = App::make(TomorrowInterface::class);
$service = App::make(SomeServiceInterface::class);

// 結合を行っていないクラス名を直接指定した場合もインスタンス化可能。
// さらに、new によるインスタンス化とは異なり、コンストラクタ引数に対して、
// 後述するコンストラクタインジェクションを試行するため、
// 引数を指定しなくてよいこともある
class Fuga
{
    function __construct(
        private SomeServiceInterface $service,
        private TomorrowInterface $tomorrow,
    ) {}
}
$fuga = App::make(Fuga::class);

なお、依存性の解決の処理はmakeメソッドの中で呼び出される、Illuminate\Container\Container::resolveメソッドにおいて実装されています。
resolveメソッドの中では主に以下の手順が行われており、結合による登録内容を紐解いていく処理であることがうかがえます。

  1. インスタンスやシングルトンの結合により、共有のインスタンスが存在する場合そのインスタンスを返却
  2. インスタンス化が可能な場合、Illuminate\Container\Container::buildメソッドを呼び出してインスタンス生成の上返却
  3. 別名やインターフェイス等でインスタンス化できない場合、makeメソッドの呼び出しを経て、インスタンス化が可能なクラス名や結合が得られるまで、再帰的にresolveメソッドの呼び出しを行う

下記ソースはIlluminate\Container\Containerクラスより、依存性の解決に関する主な処理を抽出したものになります。

// resolve の呼び出しのみ行っている
public function make($abstract, array $parameters = [])
{
    return $this->resolve($abstract, $parameters);
}

protected function resolve($abstract, $parameters = [], $raiseEvents = true)
{
    //...

    // 共有のインスタンスが存在しているとき、そのインスタンスを返す
    if (isset($this->instances[$abstract]) && ! $needsContextualBuild) {
        return $this->instances[$abstract];
    }

    // よりインスタンス化に近い、具体的なクラス名/コールバックを取得する
    if (is_null($concrete)) {
        $concrete = $this->getConcrete($abstract);
    }

    // インスタンス化が可能かを判定
    if ($this->isBuildable($concrete, $abstract)) {
        // インスタンス化実施
        $object = $this->build($concrete);
    } else {
        // make の呼び出しを経て、インスタンスの返却が行われるまで、再帰的に resolve を呼び出す
        $object = $this->make($concrete);
    }

    // singleton 等により結合が登録されていた場合、初回にインスタンス化したものを共有のインスタンスとして登録
    if ($this->isShared($abstract) && ! $needsContextualBuild) {
        $this->instances[$abstract] = $object;
    }

    //...

    return $object;
}

サービスコンテナによる DI

サービスコンテナにおける DI はコンストラクタインジェクションとメソッドインジェクションの 2 種類があります。
どちらの DI もコンストラクタやメソッドの仮引数へ型指定(タイプヒンティング)を行うことで、自動的に適切なインスタンスを作成して、引数に渡してくれます。

1. コンストラクタインジェクション

サービスコンテナのコンストラクタインジェクションは、インスタンス化を行う際にコンストラクタの引数へ依存先のインスタンスを渡すことで、DI を行います。
以下いずれかの状況により、インスタンス化を行おうとしているクラスのコンストラクタに対して行われます。

  1. サービスコンテナのmakeメソッドにより、インスタンス化を行おうとしているとき
  2. コンストラクタインジェクションとして流し込むための引数をインスタンス化しているとき
  3. メソッドインジェクションとして流し込むための引数をインスタンス化しているとき

特に、Laravel はフレームワークの実装の中でもサービスコンテナによる DI を活用しているので、能動的にサービスコンテナのmakeメソッドを呼び出さなくても、コンストラクタインジェクションが行われる場所は多々あります。

例えば、以下のような場所が該当します。

  • Controller や Controller のコンストラクタ引数
  • Middleware や Middleware のコンストラクタ引数
  • ルーティングで URL に紐づけられたアクションの引数

2. メソッドインジェクション

サービスコンテナのメソッドインジェクションは、メソッドの引数へ依存先のインスタンスを渡すことで、DI を行います。
サービスコンテナのcallメソッドによりメソッドや関数の呼び出しを行ったときに行われます。 また、コンストラクタインジェクションと同様に、フレームワークの実装の中でも活用されており、以下のような場所のメソッドや関数に対してもメソッドインジェクションは行われます。

  1. ルーティングで URL にアクションとして紐づけたメソッドや関数、コールバック
  2. サービスプロバイダのbootメソッド
  3. カスタム例外のreportメソッド3

なお、サービスコンテナのcallメソッドについては、通常の関数呼び出しではなく、コールバックの実行や Template Method パターンを適用している時のように、処理の流れは決定しているが、一部の処理を外部から渡したいような場合には有効と考えられます。
何もしなければ、処理を渡す側にとって以下のような制約がついてしまいますが、callメソッドによるメソッドインジェクションを利用することでいずれも解消されます。

  • 実行側が名前付き引数を利用している場合4、仮引数の名前を固定しなければいけない
  • 実行側が名前付き引数を利用していない場合、仮引数の順番を固定しなければいけない
  • 仮引数の型指定や数は、実行側が渡す引数と一致していなければいけない

3. サービスコンテナの DI の実装

サービスコンテナにおける DI では、コンストラクタやメソッドの仮引数の型指定を参照することで、どのような方法でインスタンス化を行うか判定しています。

そのためには、コンストラクタやメソッドが持つ仮引数を列挙したうえで、それぞれの型指定の情報を取得しなければいけません。
しかし、定義済みのコンストラクタやメソッドに対して、外部から処理を差し込む方法はありません。
仮に処理を差し込むことが可能だとしても、仮引数は定義上のもので実際のインスタンスではなく、型指定がインターフェイスや基底クラスであった場合は、PHP 組み込みのget_class関数を使用しても実際のインスタンスの型名しか取得できません。

では、どのようにして仮引数の情報を取得するのでしょうか。

PHP では、クラスやインターフェイス、関数などの静的な情報を実行時に取得するための、リフレクション API があります。 リフレクション API を利用することで、コンストラクタやメソッドの仮引数を列挙し、それぞれの型指定を文字列として取得することで、実行時の処理で静的な情報を利用することができます。

サービスコンテナもリフレクション API を利用することで、仮引数の型指定を取得し、どのようなインスタンスを引数として渡すか決定しています。
具体的には、リフレクション API に含まれる、ReflectionClassReflectionMethodReflectionParameterの 3 つのクラスを利用しています。

  • ReflectionClass … クラス名やインスタンスから、クラスの情報を取得する
  • ReflectionMethod … コンストラクタ等のマジックメソッドを含む、メソッドの情報を取得する
  • ReflectionParameter … 関数やメソッドの仮引数の情報を取得する

それでは、サービスコンテナのコンストラクタインジェクションに関する実際のソースを見ながら、どのように DI を行うインスタンスを決定しているか確認してみましょう。
コンストラクタインジェクションはIlluminate\Container\Container::buildメソッドで行われています。

※下記ソースはIlluminate\Container\Containerクラスより引用・編集しています。

まず、インスタンス化を行う方法がコールバックだった場合は、コールバックの実行結果をそのままインスタンスとして返却します。
そうでなければ、クラス名であるとしてReflectionClassのインスタンスを生成します。

public function build($concrete)
{
    if ($concrete instanceof Closure) {
        return $concrete($this, $this->getLastParameterOverride());
    }

    try {
        $reflector = new ReflectionClass($concrete);
    } catch (ReflectionException $e) {
        throw new BindingResolutionException("Target class [$concrete] does not exist.", 0, $e);
    }

    //...
}

さらに、$reflectorに格納されたReflectionClassのインスタンスより、ReflectionMethod型のインスタンスである、コンストラクタの情報をします。
コンストラクタが未定義の場合は、そのまま new によりインスタンス化を行い、定義済みであれば、getParametersメソッド呼び出しにより全仮引数の情報をReflectionParameter型インスタンスの配列として取得します。
詳細は後述しますが、$this->resolveDependencies($dependencies)により、各仮引数全てに対して型指定から DI を行うインスタンスを生成し、その結果をnewInstanceArgsメソッドへ渡すことで、コンストラクタインジェクションによるインスタンス化が行われます。

$this->buildStackは サービスコンテナによる依存性の解決における、一連のインスタンス化の状況をスタックとして積むことにより、エラー発生時にインスタンス化の状況を例外メッセージに付与します。

public function build($concrete)
{
    //...

    $this->buildStack[] = $concrete;

    $constructor = $reflector->getConstructor();

    // コンストラクタが定義済みか判定し、未定義の場合は new によりインスタンス化を行う
    if (is_null($constructor)) {
        array_pop($this->buildStack);

        // この時点の $concrete にはクラス名が文字列として格納されている
        return new $concrete;
    }

    $dependencies = $constructor->getParameters();

    try {
        // $concrete が依存しているもの(コンストラクタ引数)のインスタンス化を先に行う
        $instances = $this->resolveDependencies($dependencies);
    } catch (BindingResolutionException $e) {
        array_pop($this->buildStack);

        throw $e;
    }

    array_pop($this->buildStack);

    // ここで DI が行われる。
    // new による コンストラクタ呼び出しとは異なり、
    // newInstanceArgs はコンストラクタの引数を配列としてとして指定する
    return $reflector->newInstanceArgs($instances);
}

buildメソッドの中で呼び出しが行われている、resolveDependenciesメソッドでは、DI で流し込むためのコンストラクタ引数のインスタンス化を行います。
配列として受け取った、コンストラクタの仮引数をひとつずつ処理していくため、解決可能な依存対象の数には制限がありません。 また、コンストラクタの仮引数がクラス型の場合はresolveClassメソッド内で、makeメソッドやresolveメソッドを呼び出すことで、再帰的に依存性の解決が行われます。 そのため、依存関係がネストしていても、ネストしているものまで含めて依存性の解決が行われたうえで、DI されます。

protected function resolveDependencies(array $dependencies)
{
    $results = [];

    foreach ($dependencies as $dependency) {
        //...

        // ここで PHP の組み込み型(コールバックも含む)か判定。
        // 組み込み型であればデフォルトの値を取得し、
        // そうでなければ resolveClass メソッド内で、make メソッドや resolve メソッドが呼ばれる
        $result = is_null(Util::getParameterClassName($dependency))
                        ? $this->resolvePrimitive($dependency)
                        : $this->resolveClass($dependency);

        // ...
    }

    return $results;
}

protected function resolveClass(ReflectionParameter $parameter)
{
    try {
        // isVariadic は可変長引数リストかどうかの判定。
        // resolveVariadicClass メソッドの中でも引数リストの要素数に関わらず、
        // 全ての要素に make メソッドや resolve メソッドが適用される実装となっている。
        return $parameter->isVariadic()
                    ? $this->resolveVariadicClass($parameter)
                    : $this->make(Util::getParameterClassName($parameter));
    }

    //...
}

以上の実装によって、サービスコンテナは、ネストしている間接的な依存関係や、複数の依存先も含めて、すべての依存関係を自動的に解決してくれます。

下記のソース例では、全ての依存関係が自動的に解決され、DI が行われることを確認しています。

// 以下のように、A は B に、B は C に依存している状況において、
// A は C に間接的に依存していると言える。
// さらに、A は B だけでなく D にも依存しており、依存先が複数存在する。
class A {
    function __construct(public B $b, public D $d) { }
}
class B {
    function __construct(public C $c) { }
}
class C {}
class D {}

$a = App::make(A::class);

// 全ての依存対象がインスタンス化されているか確認。
// 結果が真であることで、依存先が複数あったり、
// 間接的な依存関係があっても、全て解決されることがわかる。
var_dump($a->b->c instanceof C); // bool(true)
var_dump($a->d instanceof D); // bool(true)

おわりに

以上で、DI、 及び、Laravel のサービスコンテナについての説明を終わらせていただきます。

私自身、普段は Laravel のサービスコンテナの実装を意識せず、利用していましたが、今回の執筆にあたって、Laravel の内部処理をコードリーディングすることで、具体的な Laravel の内部構造を、より深く理解できたと感じています。
今回は、Laravel のサービスコンテナに注力しましたが、他にも Laravel の内部処理について興味がある部分がありますので、別の機会に、また記事を執筆できたら、と考えています。


  1. 例えば、インスタンスを共有する方法で機能を利用するものとして、アプリケーションの配置場所やデータベースの接続情報など、環境情報を管理する機能を考えてみます。
    上記のような環境情報はプログラムの実行中に、1 回だけ取得の上、共有すればよいでしょう。

    では、インスタンスを共有するという点に着目し、どこからでもアクセス可能なグローバル変数に格納する形の実装はどうでしょうか。 上記の実装では機能の利用先で、勝手な情報の上書きや、変数の削除が行われてしまう可能性があります。
    別の場所でそのグローバル変数を参照した際に、変数自体が存在しなかったり、意図しない内容が格納されているの可能性があり、適切ではない方法で機能を利用している状況と言えます。

    インスタンスの変更や削除が可能な場所を限定し、格納されている情報の取得のみ可能な実装にする必要があるでしょう。 ここでの環境情報の管理機能は、インスタンス化や破棄のタイミングまでライフサイクルを考慮しなければいけません。 ↩︎

  2. 特定の状況においては結合を行わなくても、依存性の解決が可能な場合があります。 「設定なしの依存解決」より ↩︎

  3. Laravel のエラー処理より、「Reportable/Renderable 例外」において言及されています。 ↩︎

  4. PHP 8.0.0 以降に実装された機能(名前付き引数)のため、それ以前の PHP バージョンでは利用できません。 ↩︎

Related Posts