コドモン Product Team Blog

株式会社コドモンの開発チームで運営しているブログです。エンジニアやPdMメンバーが、プロダクトや技術やチームについて発信します!

PHPで魔法(DIコンテナ)を作って理解した話

こちらは「コドモン Advent Calendar 2024」の 8日目の記事です

qiita.com

こんにちは! プロダクト開発部の岡村 亮太です!

コドモンに入ってからPHPについて考える機会が増え、日に日にPHPへの愛着が深まっています。 そこで、PHPについて何か書いてみたいと思っています!

今回は、開発でよく使われる便利な仕組み「DIコンテナ」について、PHPで 「作ってみた」話を書きます!

まずは下記のコードを見てください。

<?php

class TestRepository {
   public function __construct() {}
}
class TestService {
   public function __construct(private readonly TestRepository $repository) {}
}
class TestController {
   public function __construct(private readonly TestService $service) {}
}

$controller = new TestController(); // ERROR

このコードはエラーになります。なぜなら、TestControllerのコンストラクタがTestServiceを必要としているにもかかわらず、そのインスタンスが渡されていないからです。

エラーを回避するには、以下のように依存するクラスのインスタンスを手動で作成し、それぞれ渡してあげる必要があります。

<?php

$repository = new TestRepository();
$service = new TestService($repository);
$controller = new TestController($service);

普通に考えれば当たり前の手順ですよね。しかし、これが複数のクラスや深い依存関係になると、手動で管理するのが面倒になり、コードが複雑になります。

一方でフレームワーク等によく搭載されている魔法を使うと、こういった依存関係の解決を自動で行ってくれます。

この魔法こそが「DIコンテナ」です。

DIコンテナとは

DIコンテナとは、クラスのインスタンス化を代わりに行い、クラス間の依存関係を自動で解決します。

これにより、開発者は面倒な依存関係の管理から解放され、コードがシンプルで保守性が高くなります。

例えば、TestControllerを作るとき、DIコンテナに「TestControllerを作って!」とお願いするだけで、依存するTestServiceやTestRepositoryも自動で作ってくれます。

まさに魔法です。

私の頭の中のDIコンテナ

なので、DIコンテナを使うと下記のようになります。

<?php

// Laravelを使用した場合
$controller = app()->make(TestController::class);

必要なものの作成や、依存関係の解決をすべてDIコンテナがやってくれています。 便利ですよね!

DIコンテナ作ってみる!

ここから本題です。

DIコンテナって最初は「なんだこの魔法!便利すぎる!」と感動していました。

でも調べてみると、「簡易的なDIコンテナは自作できる」という記事を見つけ、「自分もこの手で魔法を作ってみたい!」と思い、実際にDIコンテナを自作してみることにしました。

<?php

use ReflectionClass;
use Exception;

class Container {
    /**
     * 依存関係を解決済みのクラスをキャッシュしておくための配列
     *
     * @var array<string, object>
     */
    private array $resolved = [];
    /**
     * 循環依存を検出するための配列
     *
     * @var array<string, bool>
     */
    private array $resolving = [];

    /**
     * @param array $container 
     * [key] => [クラス名::class]
     */
    public function __construct(
        private array $container = []
    ) {
    }

    /**
     * @param string $key interface or class 名
     * @param string|object $className クラス名またはobject
     */
    public function set(string $key, string | object $className) {
        $this->container[$key] = $className;
    }

    public function make(string $id) {
        $pathPart = explode('\\', $id);
        $key = end($pathPart);

        // 循環依存チェック
        if (isset($this->resolving[$key])) {
            throw new Exception("循環依存が検出されました: " . $key);
        }

        // 既に解決済みの依存関係を返す
        if (isset($this->resolved[$key])) {
            return $this->resolved[$key];
        }

        $className = $this->container[$key] ?? $id;

        // 対象がオブジェクトの場合、オブジェクトをそのまま返す
        if (is_object($className)) {
            $this->resolved[$key] = $className;
            return $className;
        }

        // クラス名からコンストラクタ情報を取得する
        $ref = new ReflectionClass($className);
        $constructor = $ref->getConstructor();

        // コンストラクタがない場合はそのクラス名をnewして返す
        if ($constructor === null) {
            $instance = new $className();
            $this->resolved[$key] = $instance;
            return $instance;
        }

        // 現在解決中の依存関係としてマーク
        $this->resolving[$key] = true;

        // コンストラクタのパラメータごとに処理をする
        $params = [];
        foreach ($constructor->getParameters() as $param) {
            // コンストラクタのパラメータの型名を取得する
            $type = $param->getType();
            $paramClass = $type?->getName();
            if ($paramClass) {
                $params[] = $this->make($paramClass);
            }
        }

        // インスタンスを生成
        $instance = new $className(...$params);

        // 解決済みの依存関係としてキャッシュ
        $this->resolved[$key] = $instance;

        // 解決中のマークを解除
        unset($this->resolving[$key]);

        return $instance;
    }
}

こちらのサイトを参考に作ってみました!

今回作ったDIコンテナの使い方は下記になります。

<?php

// このキーが呼ばれたらこのクラスを返すことを定義する連想配列を用意
// [キー] => [クラス名]
$definitions = [
    "TestController" => TestController::class
];

// DIコンテナのクラスをnewする(このときに必要な定義を渡す)
$container = new Container($definitions);

// TestControllerを作る
$controller = $container->make("TestController");

TestRepository, TestServiceをnewしなくてもTestControllerが作成できました! 魔法の完成です!

魔法の種明かし

インスタンス化を代わりにやってくれる

ステップ1: 定義の作成(連想配列)

まず最初に、定義 を作成します。この定義には、キー(識別名)とクラス名のペアを持っており、キーを指定するとそのクラスを返すというルールを定義します。

<?php

// このキーが呼ばれたらこのクラスを返すことを定義する連想配列を用意
// [キー] => [クラス名]
$definitions = [
    "TestController" => TestController::class
];

ここでは、キー "TestController" に対して TestController::class(TestController クラス)を紐付けています。つまり、"TestController" というキーを使うと、TestController クラスが返されるということです。

ステップ2: DIコンテナのインスタンス作成

次に、Containerクラスを使ってDIコンテナのインスタンスを作成します。Containerクラスは、依存関係を管理し、必要なクラスをインスタンス化する役割を担います。

<?php

// DIコンテナのクラスをnewする(このときに必要な定義を渡す)
$container = new Container($definitions);

ここでは、ステップ1で作成した$definitions(依存関係の定義)をコンテナに渡して、Container クラスのインスタンスを生成しています。コンテナは、登録されたクラスを管理し、必要に応じてインスタンスを生成するために使用されます。

ステップ3: インスタンスの生成

最後に、make() メソッドを使って、"TestController" クラスのインスタンスを生成しています。

<?php

// TestControllerを作る
$controller = $container->make("TestController");

$container->make("TestController") と呼び出すことで、コンテナは次の処理を行います:

  • "TestController" というキーを探す
  • TestController::class として登録されたクラスを見つける
  • TestController クラスのインスタンスを生成し、そのインスタンスを返す

これで、$controller には TestController のインスタンスが格納されます。

クラス依存関係を勝手に解決

プログラム内でクラスを作成しようとするとき、そのクラスが他のクラスを必要とすることがあります。これを依存関係と呼びます。 例えば、クラスAがクラスBを必要としていた場合、クラスAのインスタンスを作成する際に、クラスBも一緒に作らなければなりません。この依存関係を手動で解決するのは面倒ですよね。でも、PHPのReflectionClassを使うことで、自動で依存関係を解決できます。

ステップ1: コンストラクタの情報を取得する

まず、作成したいクラスのコンストラクタ(初期化処理)の情報を取得します。これを使って、そのクラスがどんな依存関係(引数)を持っているかがわかります。

<?php

// クラス名からコンストラクタ情報を取得する
$ref = new ReflectionClass($className);  // クラス名をReflectionClassに渡す
$constructor = $ref->getConstructor();  // コンストラクタ情報を取得

ここでは、クラス名をもとにそのクラスの詳細情報を取得し、コンストラクタの引数を調べています。コンストラクタに引数があれば、それらが他のクラスを要求している可能性があります。

ステップ2: 引数の依存関係を解決する

コンストラクタに引数がある場合、それぞれの引数がどのクラスを必要としているかを調べ、そのクラスを作成します。これを再帰的に行います。つまり、クラスAのコンストラクタでクラスBが必要なら、まずクラスBを作り、次にそのクラスBのコンストラクタで別のクラスCが必要なら、クラスCを作成するという風に進んでいきます。

<?php

// コンストラクタのパラメータごとに処理をする
$params = [];
foreach ($constructor->getParameters() as $param) {
    // コンストラクタの引数の型名を取得する
    $type = $param->getType();
    $paramClass = $type?->getName();
    if ($paramClass) {
        // 依存しているクラスを再帰的に作成
        $params[] = $this->make($paramClass);
    }
}

この部分では、コンストラクタの引数を一つずつ取り出して、その引数が要求するクラス(型)を自動で作成しています。

ステップ3: クラスのインスタンスを生成する

依存関係がすべて解決したら、最後に元々作りたかったクラスのインスタンスを生成します。このとき、必要な依存クラスを引数として渡します。

<?php

 $instance = new $className(...$params);

これで、依存しているクラスもすべて自動で作成され、目的のクラスのインスタンスが完成します。

まとめ

  • DIコンテナは魔法ではなく、インスタンスの生成や依存関係の解消をしてくれる仕組み
  • 実際に作ってみることで深く理解できる!

DIコンテナがあまりにも便利すぎて、これまで「魔法みたいなものだから」と思って深く考えずに使っていました。でも、実際に自分で作ってみることで、その「魔法」の仕組みがどんな風に動いているのか、まるで謎が解けたような感覚を味わいました。

「魔法だと思っていたものを、自分の手で作って理解する」この体験を通じて、単に便利なツール・機能を使うだけでなく、その背後にあるロジックや動作を深く掘り下げることができ、技術的にも一歩成長できた気がしています!

最後まで読んでいただきありがとうございます!

参考記事

とても丁寧に説明が書いてあり、こんな私でも理解してDIコンテナを作ることができました。