LoginSignup
206
189

More than 3 years have passed since last update.

Laravelサービスコンテナの仕組みとシングルトンの罠

Last updated at Posted at 2019-06-09

はじめに

今回はphpフレームワークで1強になりつつある、Laravelについてのお話です。非常に活発に開発が進んでおり、2019/06現在、laravel5.8が最新バージョンとなっています。ここでは、開発中に少しハマったインスタンス化の仕組みを共有していきます。

私の環境は以下の通りです。
Laravel: 5.5
php: 7.1

また本題に入るにあたって、laravel開発者が知っておくべき基本点を抑えていきます。#1. #2.は基本的な内容ですので、すでにご存知の方は#3まで読み飛ばしてください。

#1. サービスコンテナについて

参考: Service Container
Laravel内ではサービスコンテナと呼ばれるクラス依存解決のための仕組みが提供されています。このサービスコンテナがLaravelで開発を進める上で最も重要といっても過言ではありません。
ここを理解していないと、私のように意図しない挙動に遭遇する可能性があるのでソースコードを読むなり各々確認することをお勧めします。

#1.1 クラス依存解決?

クラス依存解決とは以下の二つを指していると理解して問題ないでしょう。

  1. どのクラスをインスタンス化するか決定
  2. 依存を外部から注入

つまり「クラスをインスタンス化し、それを外部から注入する」です。

インスタンスを作成する際に一番簡単な方法は何でしょう。newを用いることが一番簡単でシンプルではないでしょうか。$dog = new Dog();です。
10年前ではこの書き方でも問題ないでしょう。実際、数え切れないほどたくさん見られます。しかし、現代ではほとんどこの書き方はしません。理由は疎結合なコードを目指すようになったからです。

以下の二つを比べてみましょう。

# ex.1
class Walk
{
    private $dog;

    public function __construct()
    {
        $this->dog = new Dog();
    }
}

#ex.2
class Walk
{
    private $dog;

    public function __construct(Dog $dog)
    {
        $this->dog = $dog;
    }
}

違いはコンストラクタ内でnewを使っているか、コンストラクタの引数にDog型の引数を渡しているかです。この違いはWalk(散歩クラス)のDogへの依存度に差を生みます。ex.1はDogクラスの犬しか散歩させることができませんが、ex.2はDogだけでなくDogクラスを継承したLabDachshundインスタンス生成時に指定することでWalkクラスを用いて散歩させることができます。つまり、Walkクラスの操作対象がDogに限定されていたものが、外部から指定可能になりました。これが依存度が下がったという意味です。

少し話が広がってしまいましたが、これが「2. 依存を外部から注入」する理由と仕組みです。

「1. どのクラスをインスタンス化するか決定」に関して、Laravelではサービスプロバイダという仕組みを提供しています。詳細は「#2. サービスプロバイダについて」で説明します。

※1. この「コンストラクタから依存するクラスのインスタンスを注入する」という方法はコンストラクタインジェクションと呼ばれる、外部から依存を注入するための代表的な手法です。

※2. また、依存度をさらに下げるためにはクラスという実体よりも、インターフェイスという契約に依存したクラス設計が近年のトレンドのように思いますがここでは割愛します。(この文章の意味が掴めなくても、問題ありません。まずは「依存度が小さい」をイメージできるようにしましょう。)


コラム: なぜ疎結合なコードを書くようになったの?

理由はテストコードを書くことが主流になったからです。テストを一度でも書いたことがある人であれば経験があることですが、テストコードは以下の流れで記述されます。

  1. テスト条件を設定
  2. テスト対象を実行
  3. 実行結果を検証

テストで重要なのは、「1.テスト条件を設定」することです。想定可能なあらゆる前提条件を準備し、対象の処理を実行したときの動作が正しく動く保証がなければソフトウェアの品質を担保できないからです。疎結合でないコードはこの前提条件を設定することが、ほぼ不可能です。

また、ソフトウェアは様々な外部アプリケーションと接続して運用されます。DBや外部APIなどが良い例です。テスト実行の度に、これらの外部と接続してしまうと様々な問題を引き起こします。
DBの場合、レコードがテスト実行ごとに更新され、そもそもテスト実行時のデータが毎回異なってしまったり、ブラウザを通して動作確認しているエンジニアのデータを変更してしまったり、たまたま社内のネットワークの都合上DBに接続できないことでテストが失敗してしまったり、、といったことが挙げられます。また、外部APIにいたってはテストごとにアクセスが飛んでしまうと、もはやDoS攻撃と言えるかもしれません。

結合部分をすり替えることができないと、上の問題以外にも「1.テスト条件を設定」する上で非常に問題があります。外部APIをコールする場合、返却値は基本的に外部のシステムに依存しているため、テストで想定するような値を自由自在に変更することはできません。

こういった場合に必要なのが、「実際にはAPIをコールせずにモックする」です。
(※ モックとは「どの引数で何回呼ばれた」かを検証しつつ、返り値を固定することを指します。)
このモックをする場合にもコードが疎結合でなければ、各クラスの依存をコントロールすることが難しくなります。

つまり、テストコードは外部に依存するものを排除していく方針でコードを書く必要があるため、アプリケーションコードが疎結合でなければ難しいのです。


#1.2 サービスコンテナとは

サービスコンテナとは先ほど説明したような依存注入を容易にするための仕組みです。仕組みは「#2. サービスプロバイダについて」で説明しますが、簡単に言うと、アプリケーション内の「全てのインスタンス生成に必要な情報を管理し、生成操作が可能なクラス」のことです。

この機能が提供されることにより、
(1)テスト時にはアプリケーションコードで生成されるインスタンスをテスト用にすり替えるといったことができたり、
(2)あるインスタンスはアプリケーション実行プロセスで一つだけ(シングルトン)にしたいので、2回目の生成時には前回生成したインスタンスを返す
といった操作が可能です。

#2. サービスプロバイダについて

参考: Service Providers
次にサービスプロバイダに関して基本的な内容を抑えていきます。
詳細を確認したい方は、上記の公式サイトやソースコードに目を通してください。

(公式ドキュメントより)

Service providers are the central place of all Laravel application bootstrapping. Your own application, as well as all of Laravel's core services are bootstrapped via service providers.

つまり、「サービスプロバイダとはLaravelアプリケーション内の全てのservicebootstrapされる場所である」と述べられています。

serviceとはクラス(群)から提供される機能と理解しおくと良いでしょう。またbootstrapするというのは、イベントリスナ、ミドルウェア、ルートなどLaravelアプリケーションに必要な全てを起動するということです。また、#1.でお話しした「1. どのクラスをインスタンス化するか決定」するためのロジック登録も行われる場所です。

#2.1 Laravelのライフサイクル

サービスプロバイダ自身の詳細に入る前に、理解しておかなければいけないことはLaravelアプリケーションのライフサイクルです。こちらに関しても(公式ドキュメント)で説明があります。

まとめると、
1. ユーザーのリクエストがwebサーバーへ到着
2. public/index.phpを実行
3. composerから出力されたvendor/autoload.phpが実行 → クラスマップの情報を取得
4. bootstrap/app.phpを実行
5. Illuminate\Foundation\Applicationnewされインスタンス化
6. Kernelが生成され、HTTPリクエスト情報からRequestインスタンスを生成しKernel->handle()メソッドへ渡し処理
7. 実行結果をユーザーへレンスポンス
となります。

重要な部分は5.、6.部分でしょう。まずはIlluminate\Foundation\Applicationですが、こちらはLaravelのアプリケーション全体を司るとても重要なクラスです。またIlluminate\Container\Containerを継承しており、サービスコンテナとしての機能も備えています。さらに、開発を進めて行く上で頻繁に遭遇する$this->appは大抵、このインスタンスを指すと認識しても問題ありません。

次に6.部分ですが、Httpリクエスト経由でアプリケーションが実行される場合、Kernelとしてインスタンス化されるのはIlluminate\Foundation\Http\Kernelクラスです。Requestがハンドルされる際に実行されるのがKernel内のsendRequestThroughRouter()bootstrap()メソッドです。

Illuminate\Foundation\Http\Kernel.php
    protected $bootstrappers = [
        \Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables::class,
        \Illuminate\Foundation\Bootstrap\LoadConfiguration::class,
        \Illuminate\Foundation\Bootstrap\HandleExceptions::class,
        \Illuminate\Foundation\Bootstrap\RegisterFacades::class,
        \Illuminate\Foundation\Bootstrap\RegisterProviders::class,
        \Illuminate\Foundation\Bootstrap\BootProviders::class,
    ];

すると、上の6つのクラス内で定義されるbootstrap()が順番にコールされていきます。
クラス名から想像できるように初めの2つの処理で.envconfig/app.phpファイルがキャッシュを確認しながらロードされていきます。

実際に開発経験がある方はご存知の通り、config/app.phpにはデフォルトでprovidersというキーにlaravelアプリケーションなサービスプロバイダのクラス名が記載されていたり、aliasesにはファサードクラス名が記載されています。

また、各開発者が定義したサービスプロバイダやファサードがあれば、こちらに記載しないと動作しません。(Laravel開発経験が浅いときによくエラー起こしていました;)

これにより5つ目のRegisterProvidersの処理でconfig/app.phpprovidersに登録された各*ServiceProvider.phpregister()メソッドが実行されていくのです。

※この際重要なのはServiceProvider.php内を$defer = trueにしておくと、このタイミングではregister()メソッドが実行されない点です。

最後に6つ目のBootProvidersで各*ServiceProvider.phpboot()メソッドが実行されます。

まとめ

要するにサービスコンテナに関連する処理のみをまとめると、

「リクエストがミドルウェアやコントローラに届く前に、config/app.php内のサービスプロバイダを読み込み、register()(※$defer = true を除く)、boot()メソッドの順に実行していきその結果をApplicationインスタンスへ保存」

となります。

#2.2 サービスプロバイダの各メソッドの役割

それではサービスプロバイダの実装について見ていきましょう。

(公式ドキュメントより)

App\Providers\RiakServiceProvider.php
<?php

namespace App\Providers;

use Riak\Connection;
use Illuminate\Support\ServiceProvider;

class RiakServiceProvider extends ServiceProvider
{
    /**
     * Indicates if loading of the provider is deferred.
     *
     * @var bool
     */
    protected $defer = true; // 必要な時のみロードするように設定

    /**
     * Register the service provider.
     *
     * @return void
     */
    public function register()
    {
        // 'Riak\Connection'というキーでインスタンス生成を依頼されたら
        // $app['config']['riak']をコンストラクタ引数としRiak\Connectionクラスをnewする。
        // => 'Riak\Connection'をIDとして実装方法を登録するイメージ
        $this->app->singleton(Connection::class, function ($app) {
            return new Connection($app['config']['riak']);
        });
    }

    /**
     * Get the services provided by the provider.
     *
     * @return array
     */
    public function provides()
    {
        // $defer = trueの際は必ず定義が必要
        // $this->app->deferredServicesプロパティにRiak\Connectionのインスタンス生成ルールは
        // App\Providers\RiakServiceProviderに定義されていることを保存
        return [Connection::class];
    }
}

通常の開発であれば基本的に上記の例の通り実装していって問題ありません。

#2.2.1 registerメソッド

Illuminate\Support\ServiceProviderを継承しているため、$this->appプロパティへアクセスが可能です。そしてこちらのような各種メソッドを通して依存解決ルール(バインドルール)を登録することが可能です。

上記の例、$this->app->singleton($abstract, $concrete)であると、第1引数の$abstract、第2引数$concreteにクラス名かクロージャを渡すことで、app($abstract);でインスタンスを生成したときに返されるインスタンスを登録することができます。

「登録」とは$this->app->bindingsプロパティに$abstractをキーとし、concretesharedを値として保持するということです。

concretesharedそれぞれの値は
1. abstract(抽象)をconcrete(具象)にする方法
2. $this->app内でshared(共有)するかどうか
です。

下記画像は#2.1で説明した\Illuminate\Foundation\Bootstrap\RegisterProviders::classが実行される前後での$this->appbindingsプロパティの変化です。全ての$defer = true以外のサービスプロバイダが実行されたため、bindingsの値の数が13->89へと増加しています。

Before
before.png
After
after.png

#2.2.2 $deferフラグ+providesメソッド

こちらの二つはセットで理解しなければいけません。
\Illuminate\Foundation\Bootstrap\RegisterProviders::classbootstrapメソッドでbindingsへインスタンス生成ルールを登録していきますが、当然各リクエストごとに必要なものは限られています。アプリケーション全体で最低限必要なもの以外は必要な時に、そのルールを取得できれば問題ありません。

そこでLaravelでは$deferフラグをオンにすることで、後ほどRiak\Connectionの実装方法を取得できる仕組みを提供しています。(これより先lazy loadと呼びます。)
その際、lazy loadであることを$this->app->deferredServicesに登録する必要があるため、providesメソッドを定義し、どのクラスがlazy load対象であるかを保存します。

#2.2.3 bootメソッド

例のコードには含まれていませんが、boot()メソッドを定義することも可能です。
このメソッドは全てのサービスプロバイダに定義されたregisterメソッド実行後に\Illuminate\Foundation\Bootstrap\BootProviders::classによりコールされるため、全ての依存解決ルールを利用することが可能です。

まとめ

  • registerメソッドにより、サービスコンテナにインスタンス化を依頼された際のルールを定義
  • $deferフラグ,providesメソッドにより対象のクラスが必要になったときにのみルールを登録するlazy loadを実現

#3. インスタンス化の方法

ここまで説明してきた内容で、サービスコンテナとサービスプロバイダが何の役割を果たしているかイメージできたと思います。

Laravelではいくつかインスタンス化の手段は備えていますが基本的には以下の2種類です。

  1. app()ヘルパー関数
  2. $this->app(Illuminate\Foundation\Application)インスタンス内のメソッド (※ $this->appはあくまで、コード内でApplicationインスタンスにアクセスできた場合のみです。)

「1. サービスコンテナについて」でnewを使わない〜という話をした通り、Laravel内ではどちらかを使います。これらのメソッドによりサービスコンテナに登録されたルールに基づき、インスタンス生成が行われます。また同時に、シングルトンとして登録されていれば一度インスタンス化されたものがあれば、それを取得できるようになります。($this->app->instancesへ保存されるためです。)

「2.2 サービスプロバイダの各メソッドの役割」で用いた例を利用すると、以下のようになります。※実際はhoge()メソッドは未定義です。

namespace App\Services;

use Riak\Connection;

class ConnectionService
{
    public get()
    {
        $connection1 = app(Connection::class);
        $connection1->hoge(1); // $connection1->hoge = 1をセット
        ...
        $connection2 = app(Connection::class);
        echo $connection2->hoge === 1 ? 'Correct!' : 'Wrong!'; // 'Correct!'
    }
}

#4. シングルトンの落とし穴

ようやく本題です。
今回私が遭遇した問題は、サービスプロバイダでシングルトンとして定義したクラスのインスタンス生成がシングルトンとして機能していなかったという話です。

通常のシングルトンの挙動は「3. インスタンス化の方法」で示した通りです。こちらに関しては問題なく動作します。

私が遭遇したのは以下のようなコードです。
サービスプロバイダは以下のように、実装を決定するには$contextという引数を渡すものと定義されていました。

ExcampleServiceProvider.php
<?php
declare(strict_types = 1);

namespace App\Example;

use Illuminate\Foundation\Application;
use Illuminate\Support\ServiceProvider;

class ExampleServiceProvider extends ServiceProvider
{
    /** @var bool */
    protected $defer = true;

    /**
     * @return void
     */
    public function register(): void
    {
        $this->app->singleton(SingletonInterface::class, function (Application $app, array $context) {
            $className = $context['flag'] ? AtypeSingletonService::class : BtypeSingletonService::class;

            return $app->make($className);
        });
    }

    /**
     * @return array
     */
    public function provides(): array
    {
        return [
           SingletonInterface::class,
        ];
    }
}

そして、以下のようにインスタンスを取得するようコードを書きました。

// $singleton1はAtypeSingletonService型
$singleton1 = app(SingletonInterface::class, ['flag' => true]);

一度上記のようにインスタンス化を行い、2度目は次のように取得を試みました。

// 一度インスタンス化されているため、ラベルだけで取得を試みた
$singleton2 = app(SingletonInterface::class);

すると、こちらのコードはエラーを発生します。
理由は引数を必要とするシングルトンのインスタンス化ルールの場合、contextualであるため、$this->app->instancesへ登録されないからです。つまり、「定義上はシングルトンとして記述可能であるが、実際に依存解決後のインスタンスは共有されない」ということです。

この事実はLaravelのIlluminate\Container\Containerresolve()の実装を確認すると明らかです。このメソッドはapp()ヘルパ関数など依存解決が行われるメソッド関連のコア部分です。

(引用1) (引用2)

Illuminate\Container\Container.php
protected function resolve($abstract, $parameters = [])
{
    // $parametersが存在する時点で$needsContextualBuildがtrueとなる
    $needsContextualBuild = ! empty($parameters) || ! is_null(
        $this->getContextualConcrete($abstract)
    );

    ...

    // シングルトンの場合、$this->isShared($abstract) = true
    // $needsContextualBuild = false
    // $this->instances[$abstract]へ$objectが保存されない
    if ($this->isShared($abstract) && ! $needsContextualBuild) {
        $this->instances[$abstract] = $object;
    }
}

たしかに、「シングルトンをコンテクストにより実装を入れ替える」という実装を行った側の設計にも考慮が欠けていた部分はあるでしょう。しかし$this->app->singleton($abstract, $concrete)の引数$concreteがクロージャを受け付けてしまう限り、このような実装は可能です。

今回はサービスプロバイダ内を$this->app->bind()を使うように修正を加え対応しました。対象がシングルトンである必要もなかったため、特に大きな問題は発生しておりません。

単純にContainer.phpのコードを読む良い機会になりました。Laravel開発を行っている方は日々サービスコンテナと戯れていることでしょう。意図しない挙動をしている場合は、一度ソースコードに目を通して見るといいかもしれません。

まとめ

シングルトン定義の実装を登録する際にパラメータを受け付けると、実際に依存解決後のインスタンスは共有されない。

206
189
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
206
189