LoginSignup
2
0

【PHP】AttributesでオートワイヤリングできるDIコンテナを作る【DIコンテナ】

Last updated at Posted at 2023-06-29

はじめに

???「チカラガ、欲シイカ……?」
選ばれしPHPer「ああ、ほしい! 大切な仲間たちを守るための力が!」
???「ナラバ唱エルガイイ、魂二刻マレタ名ヲ」
選ばれしPHPer「composer require {ここに任意のパッケージ名を指定する}」

こんにちは。はいからといいます。先日ふと、composerと梱包材が似ていることに気付いてしまいました。おかげでちょいちょいタイポしそうになります。みんな呪われてしまえ!!

DI/DIコンテナって何だ?

今回はDIコンテナを習作することが主題です。
社内勉強会の内容を加筆修正したものになっています。

念のため、DIとはなんぞやというところを説明しておくと、あるクラスAが動作するために必要とする別のクラスB=依存性を、どのような形で呼び出すかという類の話です。

仮に、クラスAの中でクラスBをインスタンス化するようにしてしまうと、クラスBがさらにクラスC,Dを必要としていて、クラスCはクラスE,F,Gを必要としている、というようなネストした依存関係になっている場合、それらを順繰りに解決していくような記述が必要になります。別のところでもClassBを使いたい場合、同じ生成処理をそちらにも書き連ねることにもなってしまい、大変煩雑です。

そうではなく、外部でインスタンス化したクラスBのオブジェクトを、クラスAが何らかの形で受け取るようにする方式のことをDIと呼びます。

class ClassA {
    protected ClassB $class_b;

    // こうするのではなく
    public function __construct() {
        $this->class_b = new ClassB(
            new ClassC(
                new ClassE,
                new ClassF,
                new ClassG
            ),
            new ClassD,
        );
    }

    // こうする
    public function __construct(ClassB $class_b) {
        $this->class_b = $class_b;
    }
}

このようにすると、クラスBの生成にまつわる知識をクラスAが知っている必要はなくなり、なんかいい感じにすっきりとコードを書くことができるようになります。

「いやいや、結局はどこかでクラスBをインスタンス化するときに、依存関係を解決して生成処理を記述することになるんだから、問題を別のところへ転嫁しただけじゃないか」と思われるかもしれませんが、そこをよしなに隠蔽してくれるのがDIコンテナという道具なのです。
「DI使うとインタフェース地獄に陥るらしいから使いたくない」と言っていたA氏がインタフェースを使わずにDIで幸せになるまで

まずはただの入れ物を作る

DIコンテナはコンテナなので、まずはコンテナを作ります。この世界、色々な場面でコンテナという言葉が出てきますが、DIコンテナの場合はインスタンス化処理を取りまとめる入れ物を意味します。

PHPの世界にはPSRという便利なものがあるので、これをベースにして作っていきましょう。DIコンテナのインターフェイスはPSR-11として定義されています。中身は見ての通り非常にシンプルです。
PSR-11

ちなみにPSRはPHP Standards Recommendationsの頭字語です。日本語にするとPHP標準勧告などという、いかにも権威的な圧の強い言葉になりますが、PSRはPHPのコアとは無関係なもので、守るべき絶対のルールというわけでもありません。
PSRの誤解

PSRのインターフェイスはcomposerでインストールできます。

composer require psr/container
class Container implements ContainerInterface {
    /**
     * インスタンスの生成処理を溜め込む
     * @var array<string, callable>
     */
    protected array $definitions;

    /**
     * 生成処理を実行して返す
     */
    public function get(string $id): mixed {
        return $this->definitions[$id]();
    }

    public function has(string $id): bool {
        return isset($this->definitions[$id]);
    }

    public function add(string $id, callable $definition): void {
        $this->definitions[$id] = $definition;
    }
}

これでインターフェイスの要求を満たす最低限の処理を実装できました。
ContainerInterfaceに定義されているのはgetとhasだけですが、インスタンスの生成処理をコンテナに追加するためのメソッドとして、addというメソッドも生やしたので、以下のような形で利用できます。

$container = new Container;

// コンテナにClassAの生成処理を登録
$container->add(ClassA::class, fn() => new ClassA);

// ClassAのインスタンスを取得
$class_a = $container->get(ClassA::class);

このままだと同じクラスをgetするたびに、新しくインスタンスを生成することになるので、一度生成したインスタンスは溜め込んで再利用する仕組みにしてみましょう。

class Container implements ContainerInterface {
    /**
     * インスタンスの生成処理を溜め込む
     * @var array<string, callable>
     */
    protected array $definitions;

    /**
     * インスタンス自体を溜め込む
     * @var array<string, object>
     */
    protected array $dependencies;

    public function get(string $id): mixed {
        if (!$this->has($id)) {
            throw New NotFoundException;
        }

        // 生成済みではないが定義済みの場合、生成処理を実行
        if (!isset($this->dependencies[$id]) && isset($this->definitions[$id])) {
            $this->dependencies[$id] = $this->definitions[$id]();
        }

        if (isset($this->dependencies[$id])) {
            return $this->dependencies[$id];
        }
            
        throw New NotFoundException;

    }
}

同じクラスの同一のインスタンスを取得できているかどうかは、以下のように確認できます。
===で比較しているので、元のクラスが同じでも異なるインスタンスなら、var_dumpの結果はfalseになるはずです。==だとtrueになってしまうので気をつけてください。

class ClassA {}

$container = new Container;

// ClassAの生成処理を登録
$container->add(ClassA::class, fn () => new ClassA);

$class_a1 = $container->get(ClassA::class);
$class_a2 = $container->get(ClassA::class);

// 同一のインスタンスならtrue
var_dump($class_a1 === $class_a2);

クラスを自動でインスタンス化させる

事前にコンテナに登録したクラスしか扱えないのでは不便なので、コンテナに含まれていないクラスでも、インスタンス化可能なら勝手に生成してくれるとありがたいですね。

まずresolveというメソッドを追加します。

protected function resolve(string $id): object {
    // IDがクラス文字列でなければ依存解決エラー
    if (!class_exists($id)) {
        throw new NotFoundException;
    }

    $ref_class = new ReflectionClass($id);

    // クラスがインスタンス化不可なら依存解決エラー
    if (!$ref_class->isInstantiable()) {
        throw new NotFoundException;
    }

    return new $id;
}

getメソッドを以下のように変更し、resolveメソッドを呼び出すようにします。

public function get(string $id): mixed {
    // 未登録のIDなら自動解決
    if (!$this->has($id)) {
        $this->dependencies[$id] = $this->resolve($id);
    }

    // 生成済みではないが定義済みの場合、生成処理を実行
    if (!isset($this->dependencies[$id]) && isset($this->definitions[$id])) {
        $this->dependencies[$id] = $this->definitions[$id]();
    }

    return $this->dependencies[$id];
}

これで、コンテナが勝手にインスタンスを生成してくれるようになったので、未登録のクラスのインスタンスを取得することができます。

class ClassA {}

$container = new Container;

// 事前にaddで登録しなくてもClassAのインスタンスを取得できる
$class_a = $container->get(ClassA::class);

オートワイヤリング

インスタンス化可能なクラスは勝手に生成してくれるようになりましたが、ClassAのコンストラクタにClassBなどの引数が設定されている場合、ClassBを生成してからClassAに渡して生成する、というような再帰的な依存解決はできない状態です。

これを実現するには、コンストラクタの引数の型が何であるかをコンテナが知る必要があるわけですが、PHPにはReflectionという機能が備わっていて、これを用いるとクラスなどを地道に解析することができます。

まずgetDependencyというメソッドを追加します。

protected function getDependency(ReflectionParameter $ref_param): mixed
{
    $ref_type = $ref_param->getType();

    // 引数の型が指定されていれば、IDとして依存性を取得
    if ($ref_type instanceof ReflectionNamedType) {
        $id = $ref_type->getName();
        return $this->get($id);
    }

    throw new ContainerException;
}

あわせて、resolveメソッドを以下のように変更してください。

protected function resolve(string $id): object
{
    // IDがクラス文字列でなければ依存解決エラー
    if (!class_exists($id)) {
        throw new ContainerException;
    }

    $ref_class = new ReflectionClass($id);

    // クラスがインスタンス化不可なら依存解決エラー
    if (!$ref_class->isInstantiable()) {
        throw new ContainerException;
    }

    $ref_constructor = $ref_class->getConstructor();

    $params = [];

    // コンストラクタの引数から依存性を判断
    if ($ref_constructor instanceof ReflectionMethod) {
        foreach ($ref_constructor->getParameters() as $ref_param) {
            $param_name = $ref_param->getName();
            $params[$param_name] = $this->getDependency($ref_param);
        }
    }

    return new $id(...$params);
}

これでコンストラクタの引数を解析し、必要な依存性を取り揃えたうえでインスタンス化が実行されます。

// ClassAをインスタンス化するにはClassBが必要
class ClassA {
    public function __construct(protected ClassB $class_b) {
    }
}

// ClassBをインスタンス化するにはClassCとClassDが必要
class ClassB {
    public function __construct(
        protected ClassC $class_c,
        protected ClassD $class_d,
    ) {
    }
}

class ClassC {}
class ClassD {}

$container = new Container;

$class_a = $container->get(ClassA::class);

引数のデフォルト値を参照する

引数の型宣言を参照した依存解決に失敗した場合でも、引数のデフォルト値が設定されていれば、その値を注入するようにしてみましょう。getDependencyを以下のように変更します。

protected function getDependency(ReflectionParameter $ref_param): mixed
{
    $ref_type = $ref_param->getType();

    try {
        // 引数の型が指定されていれば、IDとして依存性を取得
        // stringやintは無視するよう!$ref_type->isBuiltin()で除外
        if ($ref_type instanceof ReflectionNamedType && !$ref_type->isBuiltin()) {
            $id = $ref_type->getName();
            return $this->get($id);
        }

        throw new ContainerException;
    } catch (ContainerException $e) {
        // デフォルト値が設定されていればそれを返す
        if ($ref_param->isDefaultValueAvailable()) {
            return $ref_param->getDefaultValue();
        }

        throw $e;
    }
}

DatetimeImmutableをgetすることで動作を確認してみましょう。DatetimeImmutableは引数が2つあり、どちらもデフォルト値が設定されているので、型による依存解決に失敗してもインスタンス化できるはずです。

$container = new Container;

$container->get(DatetimeImmutable::class);

せっかくデフォルト値があるなら、依存解決に失敗したときはそれを使ってあげるのが自然な気がするので、このような形にしてはみましたが、どうなんでしょうねこれは。ご意見お待ちしてます。

Attributesを用いた依存解決の指定

引数の型として抽象クラスやインターフェイスが指定されている場合、自動で生成することができません。インスタンス化できるものではないので、まあ当然ですね。これを解決しようと思うと、現状では事前にコンテナに登録しておくことになるのですが、すべての依存関係を事前に登録するのは非現実的です。

そこで引数にAttributesを付与することで、依存性を指定できるようにしてみましょう。AttributesはPHP8.0で登場した機能で、クラスやメソッドなどにメタ情報を付与する手段が言語標準で実装されたものです。

今回はInjectという名前のAttributesを作ります。これ自体は非常に単純です。

#[Attribute]
class Inject {
    public function __construct(protected string $id) {
    }

    public function getId(): string
    {
        return $this->id;
    }
}

引数がInject属性を持っているかどうかを判定する処理と、引数が持っているInject属性のインスタンスを取得するメソッドがほしいので、以下のようにContainerクラスに実装します。

protected function hasInjectAttribute(ReflectionParameter $ref_param): bool
{
    return isset($ref_param->getAttributes(Inject::class)[0]);
}

protected function getInjectAttribute(ReflectionParameter $ref_param): Inject
{
    $ref_attrs = $ref_param->getAttributes(Inject::class);

    if ($ref_attrs === []) {
        throw new ContainerException;
    }

    return $ref_attrs[0]->newInstance();
}

必要なものが揃ったので、getDependencyメソッドを以下のように変更します。

protected function getDependency(ReflectionParameter $ref_param): mixed
{
    // Inject属性があれば参照
    if ($this->hasInjectAttribute($ref_param)) {
        $id = $this->getInjectAttribute($ref_param)->getId();
        return $this->get($id);
    }

    $ref_type = $ref_param->getType();

    try {
        // 引数の型が指定されていれば、IDとして依存性を取得
        // stringやintは無視するよう!$ref_type->isBuiltin()で除外
        if ($ref_type instanceof ReflectionNamedType && !$ref_type->isBuiltin()) {
            $id = $ref_type->getName();
            return $this->get($id);
        }

        throw new ContainerException;
    } catch (ContainerException $e) {
        // デフォルト値が設定されていればそれを返す
        if ($ref_param->isDefaultValueAvailable()) {
            return $ref_param->getDefaultValue();
        }

        throw $e;
    }
}

こうすると、以下のような形でInject属性を引数に付与して使うことができます。

class ClassA {
    // 引数の型はClassBInterfaceだが、Injectで指定したClassBが注入されるようになる
    public function __construct(
        #[Inject(ClassB::class)]
        protected ClassBInterface $class_b
    ) {
    }
}

interface ClassBInterface {}

class ClassB implements ClassBInterface {}

あとがき

思っていたより短くまとめることができました。文才しかない。
(説明不足なだけという可能性に満ちているので、折を見て加筆すると思います)。

あんまり言い訳じみたことは言いたくないですが、今回のコードは当然ながら実際の運用に耐えられるものではありませんので、そこはご了承ください。例外処理とか雑の極みですのでね。

ちなみに、DIについての説明で「なんかいい感じにすっきりとコードを書くことができる」と、あからさまにお茶を濁した部分がありましたが、このすっきり感というのは、アプリケーションを構成する各モジュールが、モジュールとしての独立性を担保されることによってもたらされるものです。
モジュールの独立性を担保し、開発者が複雑さから自分自身と大切な仲間たちを守るために有効な考え方のひとつがDIであり、そのための手段がDIコンテナであるということになります。

忌憚なくマサカリいただければ幸いです。それでは。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0