4
2

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 3 years have passed since last update.

サービスコンテナをつついて標準Facadeを差し替えたら思わぬところでドハマリした話

Last updated at Posted at 2020-03-27

TL;DR

  • サービスコンテナをつついて、標準 Facade の機能を変更する実例です。
  • Facadeはサービスコンテナにアクセスするのでサービスコンテナのオブジェクトを差し替えればファサードも差し替わる。
  • ただしFacade は独自にキャッシュするのでタイミングによってはそれをクリアする必要がある。

なにがしたい?

サービスコンテナの解説記事で「標準機能をそっくり置き換えできる」と書いたことがあって、「ヘルパ関数の置き換え」は記事にしていましたが、今回はその Facade 編です。

テーマは 「テストのときだけトランザクションを実行しないDBファサード」

ことの発端は、テストで使っているトレイト DatabaseTransactions と、テスト対象内の DB::beginTransaction()DB::commit() が衝突を起こしてうまくテストが実行できなかったこと。それ自体は未だ原因がわかっていないのですが諦めて(笑)「いいや、Facadeだし、テスト中だけ Transaction をスルーするオブジェクト作って差し替えよう」と考えた次第です。

ちなみにファサードではなくヘルパ関数 db()->beginTransaction() だったり、DBオブジェクトを受け取って操作 $db->beginTransaction() だったりしても同様に差し替えできます。
が、ファサードに限って、さらにひと工夫必要だという話。

結論

ポイントは「※」のところにある キャッシュクリア です。
これは Facade だけに必要なお作法。

tests/SampleTest.php
use Illuminate\Support\Facades\Facade;
use TestCase;

class SampleTest extends TestCase
{
    use \Illuminate\Foundation\Testing\DatabaseTransactions;

    public function setup()
    {
        parent::setup();

        // サービスコンテナに入っている DatabaseManager を取得する
        $this->defaultDb = $this->app->make('db');

        // 別の DatabaseManager をツッコむ
        $mockDb = new DBConnectionWithoutTransaction($this->defaultDb);
        $this->app->instance('db', $mockDb);

        // Facade 内にキャッシュされたインスタンスを消しておく ※
        // 引数は ↑ のサービスコンテナのラベルと同じ
        Facade::clearResolvedInstance('db');
    }

    public function tearDown()
    {
        // 保存しておいたデフォルトに戻す
        $this->app->instance('db', $this->defaultDb);

        // Facade 内にキャッシュされたインスタンスを消しておく ※
        Facade::clearResolvedInstance('db');

        parent::tearDown();
    }

以下蛇足。

  • Facade::clearResolvedInstanceDB::clearResolvedInstance に置き換え可能です。こうしたほうが余計な use ...\Facade; を追加する手間が省けますが、clearResolvedInstance しなきゃいけないのは DB の機能要件ではなく Facade の特性なので、Facadeと明記して実行すべきかなぁと思ってそうしました。
  • $this->defaultDb は protected で宣言して保護すべきですが、テストクラスなので外からのアクセスとか考えなくていいかなぁと思って省きました。サンプルコードだし。納品するコードにはちゃんと書きますよ!(汗

解説

サービスコンテナに入っているオブジェクトを置き換える

Facade を作る記事でもチラッと書きました が、Facadeの中身はサービスコンテナに納められているオブジェクトなので、そのサービスコンテナのオブジェクトを置き換えると機能の差し替えができます。

よく使う置き換えのパターンは、代替オブジェクトを作って、instance メソッドで渡す方法。

$mockDb = new DBConnectionWithoutTransaction($this->defaultDb);
$this->app->instance('db', $mockDb);

configヘルパを置き換えたとき も同じ方法。

app/Providers/AppServiceProvider.php
$this->app->instance('config', new Repository($this->app->make('config')->all()));

その他の差し替え方法について詳しくは「サービスコンテナ講座 第3回 結合編」に書いていますが、今回のケースでは他の方法はあまり使わないかな。

代替オブジェクトをうまいこと作る方法は、経験と勘(笑)。
今回作ったものは、記事後方で解説しています。

サービスコンテナのラベルを調べる

さらっと 'db' というキーワードが出てきましたが、これはサービスコンテナに入っているDBサービスオブジェクトのラベルです(勝手にラベルと呼んでいますが正式名称は「abstract 抽象」)。これを調べるには Facade のソースファイルを開いて、getFacadeAccessor メソッドを確認します。

vendor\laravel\framework\src\Illuminate\Support\Facades\DB.php
namespace Illuminate\Support\Facades;

class DB extends Facade
{
    protected static function getFacadeAccessor()
    {
        return 'db'; // ← これ
    }
}

getFacadeAccessor が返しているのは、そのまんま、サービスコンテナのラベルです。
実際にアプリケーションの適当な場所でこのようにすると

$db = app('db');

データベースオブジェクトのインスタンスが得られるので、ファサードのメソッドを全く同じように使うことができます。

$db->beginTransaction();
$db->getQueryLog();

置き換えるタイミングと注意点

さて話を戻しまして、先程の「サービスコンテナのインスタンスを置き換えるコード」はどこに書くべきでしょうか。

$this->app->instance('db', $mockDb);

アプリケーション全体で差し替えた機能を使いたい場合は、最も良いのは、AppServiceProvider.phpregister() メソッドです。ただしこの時点では他のサービスクラスは初期化(実体化)されていないので、今回のケースのように「すでに初期化されたサービス」を使うには、boot() メソッドに書く必要があります(registerでも動くことがあります。registerよりも前に初期化されるサービスもあるので)。

でも今回はさらに用途が限定されています。
テストの特定メソッドの実行中だけ 差し替えたい。

そのまま考えると、そのテストの実行の直前と直後、setup()メソッドで良い気がします。

tests/SampleTest.php
    public function setup()
    {
        parent::setup();

        $this->defaultDb = $this->app->make('db');
        $mockDb = new DBConnectionWithoutTransaction($this->defaultDb);
        $this->app->instance('db', $mockDb); // ここ
    }

    public function tearDown()
    {
        $this->app->instance('db', $this->defaultDb);

        parent::tearDown();
    }

はい。問題ありません。ほとんどのケースではこれで動きます。

ただ、僕はこれをやって、コケました。壮大にドハマリしました。

問題は、Facadeだけの特別な性質にあります。

それは「Facade で使うインスタンスは、すべて自動的にシングルトンになる」という機能。
つまり内部キャッシュです。

Facade は初回実行時にインスタンスをキャッシュする

わかりやすく書くとこのようなイメージです。

$defaultDb = $this->app->make('db'); // デフォルトインスタンス
$mockDb = new DBConnectionWithoutTransaction($defaultDb); // 代替インスタンス
$this->app->instance('db', $mockDb); // 差し替える

DB::beginTransaction(); // $mockDB->beginTransaction() と等価

サービスコンテナが差し替わると、Facadeで使うインスタンスも差し替わります。
けど、このようにすると

DB::enableQueryLog(); // 差し替え前に1回使っている

$defaultDb = $this->app->make('db'); // デフォルトインスタンス
$mockDb = new DBConnectionWithoutTransaction($defaultDb); // 代替インスタンス
$this->app->instance('db', $mockDb); // 差し替える

DB::beginTransaction(); // $defaultDb ->beginTransaction() と等価

差し替わりません。あれ?おかしいな…。

DB::enableQueryLog(); // 差し替え前に1回使っている

$defaultDb = $this->app->make('db'); // デフォルトインスタンス
$mockDb = new DBConnectionWithoutTransaction($defaultDb); // 代替インスタンス
$this->app->instance('db', $mockDb); // 差し替える

DB::beginTransaction(); // $defaultDb ->beginTransaction() と等価

$witch = $this->app->make('db'); // $mockDb が返ってくる。差し替わっている

サービスコンテナの中身はちゃんと差し替わっています。
ポイントは最初の DB::enableQueryLog(); です。
ファサードのソースコードを見ると、

vendor\laravel\framework\src\Illuminate\Support\Facades\Facade.php
    protected static function resolveFacadeInstance($name)
    {
        if (is_object($name)) {
            return $name;
        }
        // すでにキャッシュしていればそれを返す
        if (isset(static::$resolvedInstance[$name])) {
            return static::$resolvedInstance[$name];
        }
        // サービスコンテナからインスタンスを取ってきてキャッシュする
        return static::$resolvedInstance[$name] = static::$app[$name];
    }

このように、サービスコンテナから取得したインスタンスを独自にキャッシュしているんですね。
というわけで、サービスコンテナを差し替えたとき、それよりも前に1回でも同じFacadeを使っているなら、それをクリアする必要があります。

DB::enableQueryLog(); // ここで $defaultDb がキャッシュされる

$defaultDb = $this->app->make('db'); // デフォルトインスタンス
$mockDb = new DBConnectionWithoutTransaction($defaultDb); // 代替インスタンス
$this->app->instance('db', $mockDb); // 差し替える
Facade::clearResolvedInstance('db'); // キャッシュをクリアする

DB::beginTransaction(); // $mockDb->beginTransaction() と等価。差し替わっている。

おまけ DBConnectionWithoutTransaction の中身

ばばばーっと作ったものなのであまりお見せするようなものではありませんが、参考までに。
今回のように「とりあえず必要なメソッドだけラフに置き換えたい」という場合は、このように、

  • 元のクラスの interface を引き継ぐ
  • コンストラクタで元のオブジェクトを受け取れるようにして、メンバ変数にストック(ラッパクラスにする)
  • interface にあるメソッドを実装し、中身はすべて、メンバにストックした元のオブジェクトをコールする。
  • それ以外のメソッドも呼ばれるかもしれないので __call を実装して同様にスルーパス。
  • 置き換えが必要なメソッドを書く。

といった感じにすると、手軽にうまく差し替えできるかと思います。

#テストのときにしか使わないので tests に配置しましたが、ご利用の際はご自由に(^^)

tests\DBConnectionWithoutTransaction.php
namespace Tests;

use Closure;
use Illuminate\Database\ConnectionResolverInterface;

/**
 * トランザクションを抑制したデータベースサービス
 */
class DBConnectionWithoutTransaction implements ConnectionResolverInterface
{
    private $base;

    public function __construct(ConnectionResolverInterface $manager)
    {
        $this->base = $manager;
    }

    public function connection($name = null)
    {
        return $this->base->connection($name);
    }

    public function getDefaultConnection()
    {
        return $this->base->getDefaultConnection();
    }

    public function setDefaultConnection($name)
    {
        return $this->base->setDefaultConnection($name);
    }

    public function __call($method, $parameters)
    {
        return $this->base->$method(...$parameters);
    }

    public function transaction(Closure $callback, $attempts = 1)
    {
        return;
    }

    public function beginTransaction()
    {
        return;
    }

    public function commit()
    {
        return;
    }

    public function rollBack()
    {
        return;
    }

    public function transactionLevel()
    {
        return;
    }
}

感想

以前、サービスコンテナの解説記事で「標準機能をそっくり置き換えできる」と書いたことがあって、実際にそれはとてもカンタンで、余裕でFacadeを置き換えしようとしたら ドハマリ したので、勢いのまま筆を執らせていただきました。何か問題に詰まって検索してたどり着くような記事ではないので、読み物としてサラッと流して、頭の片隅に「こんな記事あったなぁ」と覚えておいていただけると、もしものときに役に立つかもしれません……。

いやー、しかしサービスコンテナはそこそこわかっているつもりでしたが、ただの「つもり」でした。なかなか奥が深い。勉強になりました。

しかし最近は Facade なんて使ってんじゃねぇよ!という風潮もあるようで、それはそれでとても確かなのですが、濫用しなければ、そのすっきりとした記法、作るのがカンタン、AppServiceProviderがカオス化しにくい、「自動シングルトン」や「モック化」といった独自の機能などにはそれなりのメリットがあるんじゃないかと思うので、見捨てないで使ってやってほしいなぁと思っています(いちばんのメリットは「記法がスッキリ」だと思いますけどね。僕はわりと好きです)。

コンストラクタインジェクション(テストしやすく長期メンテナンス性が高い)
use App\MyServices\SampleService;

class MyAppUsecase
{
    protected $sampleService;

    public function __construct(SampleService $sampleService)
    {
        $this->sampleService = $sampleService;
    }

    public function execUsecase()
    {
        $this->sampleService->exec();
    }
Facade(スッキリ)
use App\MyFacades\SampleServiceFacade;

class MyAppUsecase
{
    public function execUsecase()
    {
        SampleServiceFacade::exec();
    }

他にもこんな記事を書いています

Laravelのちょっとマニアックな視点から、誰も書かない記事を書いています(笑
合わせてご覧いただけると幸いです(^^)

ファサードを作る

今回と似た内容の「ヘルパ関数編」

サービスコンテナ講座

4
2
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
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?