はじめに
今回はphpフレームワークで1強になりつつある、Laravelについてのお話です。非常に活発に開発が進んでおり、2019/06現在、laravel5.8が最新バージョンとなっています。ここでは、開発中に少しハマったインスタンス化の仕組みを共有していきます。
私の環境は以下の通りです。
Laravel: 5.5
php: 7.1
また本題に入るにあたって、laravel開発者が知っておくべき基本点を抑えていきます。#1. #2.は基本的な内容ですので、すでにご存知の方は#3まで読み飛ばしてください。
#1. サービスコンテナについて
参考: Service Container
Laravel内ではサービスコンテナと呼ばれるクラス依存解決のための仕組みが提供されています。このサービスコンテナがLaravelで開発を進める上で最も重要といっても過言ではありません。
ここを理解していないと、私のように意図しない挙動に遭遇する可能性があるのでソースコードを読むなり各々確認することをお勧めします。
#1.1 クラス依存解決?
クラス依存解決とは以下の二つを指していると理解して問題ないでしょう。
- どのクラスをインスタンス化するか決定
- 依存を外部から注入
つまり「クラスをインスタンス化し、それを外部から注入する」です。
インスタンスを作成する際に一番簡単な方法は何でしょう。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
クラスを継承したLab
やDachshund
をインスタンス生成時に指定することでWalk
クラスを用いて散歩させることができます。つまり、Walk
クラスの操作対象がDog
に限定されていたものが、外部から指定可能になりました。これが依存度が下がったという意味です。
少し話が広がってしまいましたが、これが「2. 依存を外部から注入」する理由と仕組みです。
「1. どのクラスをインスタンス化するか決定」に関して、Laravelではサービスプロバイダという仕組みを提供しています。詳細は「#2. サービスプロバイダについて」で説明します。
※1. この「コンストラクタから依存するクラスのインスタンスを注入する」という方法はコンストラクタインジェクションと呼ばれる、外部から依存を注入するための代表的な手法です。
※2. また、依存度をさらに下げるためにはクラスという実体よりも、インターフェイスという契約に依存したクラス設計が近年のトレンドのように思いますがここでは割愛します。(この文章の意味が掴めなくても、問題ありません。まずは「依存度が小さい」をイメージできるようにしましょう。)
コラム: なぜ疎結合なコードを書くようになったの?
理由はテストコードを書くことが主流になったからです。テストを一度でも書いたことがある人であれば経験があることですが、テストコードは以下の流れで記述されます。
- テスト条件を設定
- テスト対象を実行
- 実行結果を検証
テストで重要なのは、「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アプリケーション内の全てのserviceがbootstrapされる場所である」と述べられています。
serviceとはクラス(群)から提供される機能と理解しおくと良いでしょう。またbootstrapするというのは、イベントリスナ、ミドルウェア、ルートなどLaravelアプリケーションに必要な全てを起動するということです。また、#1.でお話しした「1. どのクラスをインスタンス化するか決定」するためのロジック登録も行われる場所です。
#2.1 Laravelのライフサイクル
サービスプロバイダ自身の詳細に入る前に、理解しておかなければいけないことはLaravelアプリケーションのライフサイクルです。こちらに関しても(公式ドキュメント)で説明があります。
まとめると、
- ユーザーのリクエストがwebサーバーへ到着
-
public/index.php
を実行 - composerから出力された
vendor/autoload.php
が実行 → クラスマップの情報を取得 - bootstrap/app.phpを実行
-
Illuminate\Foundation\Application
がnew
されインスタンス化 -
Kernel
が生成され、HTTPリクエスト情報からRequest
インスタンスを生成しKernel->handle()
メソッドへ渡し処理 - 実行結果をユーザーへレンスポンス
となります。
重要な部分は5.、6.部分でしょう。まずはIlluminate\Foundation\Application
ですが、こちらはLaravelのアプリケーション全体を司るとても重要なクラスです。またIlluminate\Container\Container
を継承しており、サービスコンテナとしての機能も備えています。さらに、開発を進めて行く上で頻繁に遭遇する$this->app
は大抵、このインスタンスを指すと認識しても問題ありません。
次に6.部分ですが、Httpリクエスト経由でアプリケーションが実行される場合、Kernel
としてインスタンス化されるのはIlluminate\Foundation\Http\Kernel
クラスです。Request
がハンドルされる際に実行されるのがKernel
内のsendRequestThroughRouter()
→bootstrap()
メソッドです。
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つの処理で.env
やconfig/app.php
ファイルがキャッシュを確認しながらロードされていきます。
実際に開発経験がある方はご存知の通り、config/app.php
にはデフォルトでproviders
というキーにlaravelアプリケーションなサービスプロバイダのクラス名が記載されていたり、aliases
にはファサードクラス名が記載されています。
また、各開発者が定義したサービスプロバイダやファサードがあれば、こちらに記載しないと動作しません。(Laravel開発経験が浅いときによくエラー起こしていました;)
これにより5つ目のRegisterProviders
の処理でconfig/app.php
のproviders
に登録された各*ServiceProvider.php
のregister()
メソッドが実行されていくのです。
※この際重要なのはServiceProvider.php
内を$defer = true
にしておくと、このタイミングではregister()
メソッドが実行されない点です。
最後に6つ目のBootProviders
で各*ServiceProvider.php
のboot()
メソッドが実行されます。
まとめ
要するにサービスコンテナに関連する処理のみをまとめると、
「リクエストがミドルウェアやコントローラに届く前に、config/app.php
内のサービスプロバイダを読み込み、register()
(※$defer = true を除く)、boot()
メソッドの順に実行していきその結果をApplication
インスタンスへ保存」
となります。
#2.2 サービスプロバイダの各メソッドの役割
それではサービスプロバイダの実装について見ていきましょう。
<?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
をキーとし、concrete
とshared
を値として保持するということです。
concrete
、shared
それぞれの値は
-
abstract
(抽象)をconcrete
(具象)にする方法 -
$this->app
内でshared
(共有)するかどうか
です。
下記画像は#2.1で説明した\Illuminate\Foundation\Bootstrap\RegisterProviders::class
が実行される前後での$this->app
のbindings
プロパティの変化です。全ての$defer = true
以外のサービスプロバイダが実行されたため、bindings
の値の数が13->89へと増加しています。
#2.2.2 $defer
フラグ+provides
メソッド
こちらの二つはセットで理解しなければいけません。
\Illuminate\Foundation\Bootstrap\RegisterProviders::class
のbootstrap
メソッドで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種類です。
-
app()
ヘルパー関数 -
$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
という引数を渡すものと定義されていました。
<?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\Container
のresolve()
の実装を確認すると明らかです。このメソッドはapp()
ヘルパ関数など依存解決が行われるメソッド関連のコア部分です。
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開発を行っている方は日々サービスコンテナと戯れていることでしょう。意図しない挙動をしている場合は、一度ソースコードに目を通して見るといいかもしれません。
まとめ
シングルトン定義の実装を登録する際にパラメータを受け付けると、実際に依存解決後のインスタンスは共有されない。