猫でもわかるWeb開発・プログラミング

本業エンジニアリングマネージャー。副業Webエンジニア。Web開発のヒントや、副業、日常生活のことを書きます。

レガシーなプロダクトに Laravel を導入する第一歩(Laravel DI と Facade)

f:id:yoshiki_utakata:20201201200014p:plain

はじめに

この記事は、Qiita の Laravel Advent Calendar 2日目の記事です。

qiita.com

昨日は、 @ucan-lab さんでした。

qiita.com

レガシーなプロダクトに Laravel を導入する

僕は最近、あるレガシーなプロダクトに Laravel を導入する、ということをしています。

  • URLエンドポイントごとに php ファイルがあり
  • 依存性の注入が不可能でユニットテストができない

というものになっています。

コードベースも巨大で、スパゲッティ状態なので、まるごと Laravel に載せ替えるのは相当大変です。

URLエンドポイントごとに php ファイルがあるので、例えば、 do_something.php といった php ファイルがあり、中身がこんな感じになっています。

<?php

// なんか色々 require_once とか

function log(string $message) {
    error_log($message, 3, '/tmp/application.log');
}

// なんか色々意味不明な処理

log('なにか残したい情報');

// なにか色々な処理 ...

そこで、とりあえずログを吐くクラスを分割します。

<?php

class Logger
{
    public function __construct(なんかDIしたいクラス $something)
    {
        ...
    }

    public function log($message) {...}
}

do_something.php はこんな感じになります(イメージです)。

<?php

// なんか色々 require_once とか

$logger = LaravelDIResolver::make(App\Log\Logger::class);

// なんか色々意味不明な処理

$logger->info('なにかログ');

// なにか色々な処理 ...

こうして、 Logger がテスタブルになるので、テストをつけます。

徐々に Laravel にコードを移していき、最終的に Router や Controller まで Laravel 化できたら完了です。

Laravel について

Laravel にはエントリーポイントが2つあります。

  • HTTPのエントリーポイント public/index.php
  • コマンドラインのエントリーポイント artisan

artisan ファイルは、 php 拡張子は付いていないが PHP のファイルです。メインの処理部分はこうなっています。

<?php

$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);

$status = $kernel->handle(
    $input = new Symfony\Component\Console\Input\ArgvInput,
    new Symfony\Component\Console\Output\ConsoleOutput
);

$kernel->terminate($input, $status);

exit($status);

Console/Kernel の handle メソッドに、引数と、コンソール出力を渡していおり、最後に exit で、コンソールに終了コードを渡しています。

一方で、 public/index.php は、こうなっています。

<?php

$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);

$response = $kernel->handle(
    $request = Illuminate\Http\Request::capture()
);

$response->send();

$kernel->terminate($request, $response);

Http\Kernel の handle メソッドに、リクエスト内容を渡しています。最後にレスポンスを出力して終了します。

今回は Web のエンドポイントの方だけを考えることにします。

なぜ部分的にでも Laravel に移行していきたいのか

レガシーコードの一部のモジュールを Laravel に移行したい理由は、Laravel の DI 機能と、Facade の機能が使いたいからです。

Laravel の DI 機能

Laravel では、以下のように書くと、勝手にコンストラクタインジェクションしてくれます。

<?php

class Envoronment {
}

class ApiClient {

    // こう書くだけで、 Environment クラスのインスタンスが引数で渡ってくる
    public function __construct(Environment $environment)
    {
        $this->environment = $environment;
    }
}

Laravel の Facade

以下のように書くと、Laravel の config/logging.php にある single の設定に対応する Logger が取得できます。

<?php

$logger = Illuminate\Support\Facades\Log::channel('single');

どうやってレガシーコードから Laravel DI と Facade を使うのか

do_something.php でこのようにします。(Laravel のpublic/index.php を参考に書いています)

<?php

<?php

require_once __DIR__ . '/Laravelのディレクトリ/vendor/autoload.php';

$app = require_once __DIR__ . '/Laravelのディレクトリ/bootstrap/app.php';

$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);

$response = $kernel->handle(
    $request = Illuminate\Http\Request::capture()
);

// DI を解決してインスタンスを生成してくれる
$apiClient = $app->make(ApiClient::class);

// Facade とかもいい感じに解決してくれる
// kernel の handle メソッドを一回呼んでいないと、これを解決してくれない
$logger = Illuminate\Support\Facades\Log::channel('single');

$kernel->handle を呼ぶことで、Laravel の DI 設定や、 Facade の設定が読み込まれて、レガシーコードからも、Laravel DI や、 Facade が使えるようになります。

$response->send(); を呼ぶと Laravel のレスポンスが出力されてしまいますが、このメソッドを呼ばずに、 handle メソッドだけ呼ぶことで、Laravel の Facade などを利用できるようにします。

実際に毎回こんなことはしたくないので、こんなクラスを作りました。

<?php

namespace App;

use Illuminate\Contracts\Http\Kernel;
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;

class LaravelAppForLegacy
{
    /**
     * @var Application
     */
    private static $app;

    /**
     * @return Application
     */
    public static function get(): Application
    {
        // 色んな所からこのメソッドを呼ぶことを想定しており
        // その度に Laravel の初期化をしているとオーバーヘッドがすごそうなので
        // すでに初期化済みであればそれを返すようにする
        if (isset(static::$app)) {
            return static::$app;
        }
        static::$app = require __DIR__ . '/../bootstrap/app.php';

        // Laravel の機能が正常に利用できるように、 kernel の handle を呼ぶ
        // 返ってくるレスポンスは無視する
        $kernel = static::$app->make(Kernel::class);

        // Kernel の handle メソッドを呼ぶと error_reporting の値が書き換えられるので、
        // 事前の値を保持しておき、 handle メソッドを読んだ後にもとに戻す
        // レガシーコードではこれをしないと動かない部分が多数ある
        $prevErrorReportingValue = error_reporting();

        // handle の結果、Body が空の response オブジェクトが返ってくるが
        // 空っぽになるので無視する
        $kernel->handle(
            $request = Request::capture()
        );

        // error_reporting の値をもとに戻す
        error_reporting($prevErrorReportingValue);

        return static::$app;
    }
}

これを do_something.php から使う場合はこうです。

<?php

require_once __DIR__ . '/Laravelのディレクトリ/vendor/autoload.php';

$app = App\LaravelAppForLegacy::get();

$apiClient = $app->make(ApiClient::class);

$logger = Illuminate\Support\Facades\Log::channel('single');

Kernel の中身をちょっと見てみる

Kernel の実装は Illuminate\Foundation\Http\Kernel になります。

handle から sendRequestThroughRouter メソッドが呼ばれ、その中で bootstrap メソッドが呼ばれています。

bootstrap の中では、 $bootstrappers 変数に登録された bootstraper が呼ばれることになります。

実際は handle をしなくても、これで行けるのかも。

<?php

$kernel = static::$app->make(Kernel::class);
$kernel->bootstrap();

以前これで試した時は、Log ファサードがうまく解決されなかった気がするんですが、ファサードの解決は、$bootstrapers に登録されている RegisterFacades がファサードの設定をしてくれるはずなので、なんかできる気もしてきた。

そして今やってみたらできた気がする(最後になんか曖昧ですみません。この記事を書くにあたりもう一度検証してみた結果、bootstrap 呼ぶだけでも動いた気がします)。

まとめ

とりあえずこんな感じにやたら、Laravel外のコードから、Laravel DI等が使えるので、皆さんやってみてください。

「こういう方法のほうが良いよ」とか「Kernelのbootstrap呼ぶだけだとこういう場合にだめ!」とかあれば、コメントやtwitterで教えてくれると嬉しいです。