504
425

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

テコテックAdvent Calendar 2018

Day 3

Laravelで始める依存性の注入(DI)

Last updated at Posted at 2018-12-02

「依存性の注入」(Dependency injection) をご存知でしょうか。あるクラスが依存している別のオブジェクトを外部から渡すことで、クラス間の依存度を下げる設計パターンです。
Laravelには サービスコンテナ と呼ばれる機能が備わっており、依存性注入を簡単に扱うことができます。本記事で架空のコードのリファクタリングを通して、Laravelにおけるサービスコンテナと依存性注入の仕組みを理解しましょう。

サンプルユースケース

「あるURLにアクセスすると、ダイスを2回振って出た目を合計した数値を返す」

まずは動くように作る

手始めに、とにかく動くコードを書いてみましょう。

class DiceController extends Controller
{
    public function rollDouble()
    {
        return mt_rand(1, 6) + mt_rand(1, 6);
    }
}

すばらしい。 あとはちょいとルーティングの設定をしてあげれば、「6面ダイスを2回振った結果を返すページ」のできあがりです。動いていることは正義ですね。

実際、要件や開発スケジュール次第では、これが最適解となる場合もあるでしょう。しかし、複雑な要件が含まれるシステムであれば、もう少し 変化に強い 作りにしておきたいところですね。少しずつリファクタリングしていきましょう。

クラス化する

mt_rand(1, 6)という命令は、「6面ダイスを1回振る」という動作をコードで表現したものです。しかし、このままだとその意図が正確に伝わるとは思えません。そこで、オブジェクト指向らしく、「6面ダイス」をクラス化し、「1回振る」という動作をメソッドとして表現してみます。

class Dice
{
    public function roll(): int
    {
        return mt_rand(1, 6);
    }
}

コントローラは以下のように変更します。Diceのインスタンスを生成し、roll()メソッドを呼ぶことでダイスを振ります。

    public function rollDouble()
    {
        // ダイスをインスタンス化
        $dice = new Dice();

        return $dice->roll() + $dice->roll();
    }

名前が付いたことで説明的になり、「ダイスを2回振っている」というのがわかりやすくなりましたね。

さらに、コントローラから6という数字が消えました。例えばダイスを振る処理があらゆる場所にある状態で、「ダイスを6面では無く12面にしたい」という仕様変更が来たとしても、1か所修正するだけで対応できるようになりました。1

いい感じです。

ロジックをテストしたい

ところで、この「ダイスを2回振って合計した値を返す」というロジック、本当にちゃんと動いているんでしょうか。この程度のコード量であればパッと見でも「正しい」と判断することができますが、実際のシステムではより多くのコードが絡み合って動いているので、すべて目視で確認するのはほとんどの場合において不可能と思われます。

そこで ユニットテスト を活用します。テストコードでロジックが正しいことを自動的にテストしましょう。
ただし、困ったことに、今回のロジックは簡単にテストすることができません。実行する度に結果が異なるので、「ダイスを2回振って合計した値を返す」というロジックが正しく動いていることを確認しようがないのです。

イカサマダイスを作る

なぜ毎回結果が違うのかと言えば、それはランダムな値を返す「普通のダイス」を使っているからです。つまり、何度振っても同じ値が出る イカサマダイス を使えば、テストができるようになるんじゃないでしょうか。

さっそく作ってみましょう。

// イカサマダイス
class LoadedDice
{
    public function roll(): int
    {
        return 6; // 6しか出ない!
    }
}

使い方はDiceと同じで、インスタンス化してroll()を呼ぶだけです。ただしイカサマダイスの場合、roll()の結果が必ず6になります。
これをコントローラに組み込んでみましょう。

    public function rollDouble()
    {
        $dice = new LoadedDice(); // イカサマダイスに置き換えた

        return $dice->roll() + $dice->roll();
    }

実行すると、必ず12が返ってくるようになりました。6しか出ないダイスを2回振ってるから当然ですね。

環境によって切り替える

今度はテスト以外の環境でも12が返るようになってしまいました。これではゲームにならないので、環境によってダイスのインスタンスを切り替えるようにしてみます。

    public function rollDouble()
    {
        //テスト中はイカサマダイスを、それ以外は普通のダイスをインスタンス化
        if (App::environment('testing')) {
            $dice = new LoadedDice();
        } else {
            $dice = new Dice();
        }

        return $dice->roll() + $dice->roll();
    }

良さそうです。
LaravelではApp::environment('testing')の結果で、環境の判定が行えます。前述のように記述すれば、テスト環境では12が、その他の環境では6面ダイスを2回振った結果が返ってくるようになります。

この状態であれば、ユニットテストを書くことができますね。このメソッドを呼んだときに、ちゃんと12が返ってくることを確かめれば良いわけです。

インスタンス化をコントローラの外で行う

ようやく、依存性注入の話に入ります。
説明のために、一度コントローラを元の状態に戻します。

    public function rollDouble()
    {
        $dice = new Dice();

        return $dice->roll() + $dice->roll();
    }

これを以下のように書き換えます。

    public function rollDouble(Dice $dice) // 引数のタイプヒントにDiceを指定
    {
        return $dice->roll() + $dice->roll();
    }

注目は引数の部分です。コントローラがDice $diceという引数を取るようになり、本体からはDiceのインスタンス化のコードが消えてしまいました。インスタンス化の処理はまだどこにも書いてません。しかし、 これでも同じ結果になります。

Laravelにおいて、ルートに紐づいたコントローラに対し、クラス名が タイプヒント された引数を指定すると、そのクラスのインスタンスを自動的に生成し、引数として渡してくれるようになります。
今回の場合はDiceクラスがタイプヒントされているので、Diceクラスのインスタンスを$diceとして渡してくれます。

Laravelに組み込まれた サービスコンテナ が担う機能の一つで、 依存性の注入 の手助けをしてくれます。コントローラが 依存 しているDiceのインスタンスを、外部から 注入(インジェクション) してくれるのです。

コントローラなら、コンストラクタでインジェクションすることもできます。

class DiceController extends Controller
{
    private $dice

    public function __construct(Dice $dice) // ←インジェクション!
    {
        //プロパティとして持っておく
        $this->dice = $dice;
    }

    public function rollDouble()
    {
        return $this->dice->roll() + $this->dice->roll();
    }
}

これを コンストラクタインジェクション と呼びます。
対して、メソッドでインジェクションする方法は メソッドインジェクション と呼びます。

コマンドやミドルウェアなど、Laravelのスキームに組み込まれたクラスはだいたいコンストラクタでインジェクションできます。覚えておきましょう。

共通部分を切り出す

DiceクラスとLoadedDiceクラスは、返す値こそ違いますが、「roll()というメソッドで整数値を返す」という大枠のつくりは一致しています。
次の手順の準備として、これをインターフェースとして切り出しておきます。

// 「振れるダイス」インターフェース
interface RollableDice
{
    public function roll(): int; // 「引数無しのrollメソッドが整数値を返す」と定義
}
// 普通のダイス
class Dice implements RollableDice
{
    public function roll(): int
    {
        return mt_rand(1, 6);
    }
}
// イカサマダイス
class LoadedDice implements RollableDice
{
    public function roll(): int
    {
        return 6;
    }
}

結合設定

サービスコンテナがインスタンスをインジェクションする際のロジックは、自分で定義しなおすこともできます。これを 結合 といいます。

結合の定義はServiceProviderに記述します。AppServiceProviderでもいいし、数が増えてきたら独自のServiceProviderを作成して分割してもいいと思います。

\App\Providers\AppServiceProvider
    public function register()
    {
        //第一引数:結合のキー(インジェクションしたいクラスの「完全修飾名」)
        //第二引数:インスタンスを返すコールバック関数
        $this->app->bind(Dice::class, function ($app) {
            return new Dice();
        });
    }

上記のように定義すると 「シンプルな結合」 となり、インジェクションする度に第2引数の関数が実行され、別のインスタンスが作られるようになります。これは、結合の設定をしない場合と同じ動作です。

一方、以下のように定義すると、インスタンスを シングルトン として生成してくれるようになります。最初に呼ばれたときにインスタンスが1つ作られ、以降はそれが使いまわされるようになります。

\App\Providers\AppServiceProvider
    public function register()
    {
        // bindではなくsingletonメソッドを使うと、インスタンスは一度だけ作られる
        $this->app->singleton(Dice::class, function ($app) {
            return new Dice();
        });
    }

インターフェースに依存させる

先ほどの例ではDice::class、つまりDiceクラスの完全修飾名を結合のキーとして登録していました。この結合のキーは、実は文字列であれば何でも良いのですが、自動的にインジェクションさせるには「タイプヒントに指定できるクラスの完全修飾名」である必要があります。

タイプヒントに指定できれば良いということは、インターフェースや抽象クラスを結合のキーにすることもできます。先ほど作ったRollableDiceインターフェースに対し、Diceクラスを結合してみましょう。

\App\Providers\AppServiceProvider
    public function register()
    {
        //結合するクラス名をインターフェースにし、Diceのインスタンスを返す
        $this->app->singleton(RollableDice::class, function ($app) {
            return new Dice();
        });
    }

そして、コントローラのタイプヒントをRollableDiceに変更します。

    public function rollDouble(RollableDice $dice) // ←タイプヒントをDiceからRollableDiceに変更
    {
        return $dice->roll() + $dice->roll();
    }

実行してみると、タイプヒントはインターフェースですが、インジェクションされるのはDiceなので、正しく動作します。23結合キーからインスタンスを特定することを、 依存関係を解決する と呼びます。

ここで、先ほど作ったイカサマダイスに切り替える処理をどこに書くのかという問題が発生しますが、とりあえず結合処理のところに移動しておきましょう。

\App\Providers\AppServiceProvider
    public function register()
    {
        $this->app->singleton(RollableDice::class, function ($app) {
            if (App::environment('testing')) {
                return new LoadedDice();
            }
            return new Dice();
        });
    }

これで、テスト環境ではLoadedDiceが、その他の環境ではDiceがインジェクションされるようになりました。

依存度を確認

コントローラ内でインスタンス化していたころのコードと見比べてみましょう。

    public function rollDouble()
    {
        if (App::environment('testing')) {
            $dice = new LoadedDice();
        } else {
            $dice = new Dice();
        }

        return $dice->roll() + $dice->roll();
    }
    public function rollDouble(RollableDice $dice)
    {
        return $dice->roll() + $dice->roll();
    }

旧コードの場合、コントローラはDiceクラスとLoadedDiceクラスの生成処理を、両方知っていなければなりませんでした。ダイスの種類が増えたり、コンストラクタに引数が必要になった場合、都度この処理を書き換えなければなりません。これらの2つのクラスに 依存 しているということです。このメソッドの役割は「ダイスを2回振って合計を返す」だけなのに、 余計なことを知りすぎています。

一方、新コードでは、RollableDiceというインターフェース(振れるダイスであるという抽象的な概念)にだけ依存するようになりました。DiceLoadedDiceを生成するのにどういう手順が必要か、このメソッドが知る必要がなくなったわけです。RollableDiceインターフェースは、これを実装しているクラスは「振れるダイス」であるということを保証します。ダイスを2回振って結果を返すだけなら、 「振れるダイス」ということだけ保証できていればそれで十分 なのです。

※より深く知りたい場合は「依存関係逆転の原則」「抽象に依存する」などで調べてみてください。4

補足情報

以下のトピックは、依存性の注入とはあまり関係のない話ですが、Laravelの機能などに関する補足です。

スタブをインジェクションする

今回はDIの説明のためにイカサマダイスクラスを別に作りましたが、イカサマダイスをテストの時にしか使わないのであれば、テストコード内でスタブを作って結合設定をオーバーライドしたほうがラクです。

$this->mock(RollableDice::class, function ($mock) {
    $mock->shouldReceive('roll')->andReturn(6);
});

参考:https://readouble.com/laravel/7.x/ja/mocking.html

テストケースクラスでこのように記述すれば、テストの際には必ず6を返すスタブオブジェクトを、RollableDiceで結合された依存を解決する際に渡してくれるようになります。

依存するオブジェクトを取り出すその他の方法

メソッドやコンストラクタでインジェクションする以外にも、結合されたインスタンスを取得する方法は用意されています。

Illuminate\Foundation\Applicationのインスタンス5にアクセスできる場所であれば、make()メソッドが使えます。

$dice = $this->app->make(RollableDice::class);

Illuminate\Foundation\Application にアクセスできなくても、resolve()ヘルパを利用すれば、気軽に依存解決することもできます。

$dice = resolve(RollableDice::class);

ただし、これらの方法は使うべきではありません。
これらを使うともはやそれは「依存性の注入」ではなく、「サービスロケータ」と呼ばれる別のパターンとなります。(参考:やはりあなた方のDependency Injectionはまちがっている。
サービスロケータはアンチパターンであるとされています。特定のクラスへの依存がなくなる代わりに、あらゆる場所でサービスコンテナへの依存が発生してしまうからです。

後述のように結合設定中に別のインスタンスを渡す必要があるような、ごく限られた状況でのみ使うようにしましょう。

多重インジェクション

インジェクションされるクラスもまた別のクラスの処理に依存している場合、インジェクションを多重で行うことがありえます。

例えば、もし乱数生成にmt_randではなく、別のロジックを使いたい、となったとします。
その場合は、コントローラと同じように、Diceにもメソッドインジェクションで別のインスタンスを注入しましょう。

// DiceをMarvelousRandToolクラスに依存させる
// ※MarvelousRandToolの実装は省略
class Dice implements RollableDice
{
    private $randTool;

    public function __construct(MarvelousRandTool $randTool)
    {
        $this->randTool = $randTool;
    }

    public function roll(): int
    {
        return $this->randTool->rand(1, 12);
    }
}

結合設定を省略している場合、Diceを依存解決すると、Diceがコンストラクタでインジェクションしているクラスも自動的に依存解決されて、必要なインスタンスが注入されます。
結合設定を自前で定義している場合は、コンストラクタに渡すインスタンスをちゃんと定義してあげる必要があります。

\App\Providers\AppServiceProvider
    public function register()
    {
        $this->app->singleton(RollableDice::class, function ($app) {
            // 乱数生成機もサービスコンテナから取得する
            return new Dice($app->make(MarvelousRandTool::class));
        });
    }

MarvelousRandToolのインスタンスも、サービスコンテナを使って依存解決していることに注目しましょう。結合設定は別の場所で行うことで、ここでもお互いの依存度を下げることができます。

終わりに

依存性注入自体はLaravelに関係なく、Laravel以外でも使える設計上のパターンの一つですが、Laravelのサービスコンテナを利用すると便利に使えることが分かっていただけたでしょうか。
今回の記事のように少しずつリファクタリングしながら徐々に導入すると、利点や特徴もわかってくると思います。
皆さんもぜひ使ってみてください。

  1. そんな仕様変更あるか?という点は置いといてください。あくまで例です!

  2. 結合定義を書かないと、インターフェースをインスタンス化しようとしてエラーになります。

  3. 結合のキーになっているインターフェースを実装していないクラスを結合しても、タイプヒントの型と合っていないオブジェクトを渡そうとして、これまたエラーになります。

  4. 筆者はうまく説明できる自信が無いので丸投げ

  5. 必ず1つ生成されているアプリケーションクラスです

504
425
5

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
504
425

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?