LaravelのFacadeを自前で実装してみる

今回は、SQSLoggerの開発の時に学んだことについて、少し内容を深く掘り下げて行きたいと思います。

LaravelのFacade

LaravelフレームワークにはFacadeという便利な機能があります。config/app.phpにはAliasとして各種Facadeとその短縮形の文字列が登録されています。

'aliases' => [

    'App'       => Illuminate\Support\Facades\App::class,
    'Artisan'   => Illuminate\Support\Facades\Artisan::class,
    'Auth'      => Illuminate\Support\Facades\Auth::class,
    'Blade'     => Illuminate\Support\Facades\Blade::class,
    'Bus'       => Illuminate\Support\Facades\Bus::class,
    'Cache'     => Illuminate\Support\Facades\Cache::class,
    'Config'    => Illuminate\Support\Facades\Config::class,
    'Cookie'    => Illuminate\Support\Facades\Cookie::class,
    'Crypt'     => Illuminate\Support\Facades\Crypt::class,
    'DB'        => Illuminate\Support\Facades\DB::class,
    'Eloquent'  => Illuminate\Database\Eloquent\Model::class,
    'Event'     => Illuminate\Support\Facades\Event::class,
    'File'      => Illuminate\Support\Facades\File::class,
    'Hash'      => Illuminate\Support\Facades\Hash::class,
    'Input'     => Illuminate\Support\Facades\Input::class,
    'Inspiring' => Illuminate\Foundation\Inspiring::class,
    'Lang'      => Illuminate\Support\Facades\Lang::class,
    'Log'       => Illuminate\Support\Facades\Log::class,
    .
    .
    .

Facadeというのは、GoFが提唱したデザインパターンの一つであるFacadeパターンに影響を受けているのだろうと思いますが、これらのクラスも実装としては非常に簡単です。どのぐらい簡単かというと、これだけです。

<?php

namespace Matchingood\SQSLogger\Facades;

use Illuminate\Support\Facades\Facade;

class SQSLogger extends Facade { protected static function getFacadeAccessor() { return 'sqslogger'; } }

ね?簡単でしょう?Facadeクラスを継承してFacadeAccessor()をオーバーライドするだけです。

もちろん、中身の実装はまた別に書くのですが、表面上使うのはこれだけです。

このFacadeに関する解説はネット上だと結構少なく、自分で実装する際にも苦労したので、ここに備忘録のような形で残しておこうと思います。

Facadeの仕組み

一般的に、aliasesに設定したFacadeは、staticなメソッドを持つかのように振る舞います。

SQSLogger::info("info"); SQSLogger::error("error"); SQSLogger::critical("critical");

ですが、上でも見たように、Facadeを継承したSQSLoggerにはinfoやerrorのようなstaticメソッドは定義されていません。これは、aliasesに定義された短縮名を用いてstaticなメソッドを呼んだ場合、callStaticというメソッドが呼ばれるためです。callStaticは、Facadeクラスで定義してあるものを継承したものです。

public static function __callStatic($method, $args)
{
    $instance = static::getFacadeRoot();
    if (! $instance) {
        throw new RuntimeException('A facade root has not been set.');
    }
    switch (count($args)) {
        case 0:
            return $instance->$method();
        case 1:
            return $instance->$method($args[0]);
        case 2:
            return $instance->$method($args[0], $args[1]);
        case 3:
            return $instance->$method($args[0], $args[1], $args[2]);
        case 4:
            return $instance->$method($args[0], $args[1], $args[2], $args[3]);
        default:
            return call_user_func_array([$instance, $method], $args);
    }
}

$methodには呼び出したメソッド名、$argsにはその引数が入ります。ここで、$instanceには、getFacadeRoot()の返り値が入っており、このインスタンスのメソッドを呼ぶ形になっています。

public static function getFacadeRoot()
{
    return static::resolveFacadeInstance(static::getFacadeAccessor());
}

これはresolveFacadeInstanceの返り値を返しますが、引数がgetFacadeAccessor()になっています。これはSQSLoggerクラスでオーバーライドしているので、'sqslogger'が引数ということになります。

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];
}

これは、引数がobjectならそのまま返し、既に解決しているインスタンスについてはそれを返し、そうでない場合は$appから対応する名前のものを返します。このappはLaravelのサービスコンテナに当たるもので、最初に使う場合はここから取り出されることになります。つまり、Facadeを使うためにはこのサービスコンテナに使いたい機能を登録すればいいということになります。

サービスコンテナにインスタンスを登録する

Laravelのサービスコンテナにサービスを登録するには、サービスプロバイダーを使うのがベストです。一般にサービスプロバイダーは以下のように書きます。

<?php

namespace Matchingood\SQSLogger;

use Illuminate\Support\ServiceProvider;

class SQSLoggerServiceProvider extends ServiceProvider { public function register() { $this->app->singleton('sqslogger', function() { return new \Matchingood\SQSLogger\SQSLogger; }); }

public function boot()
{
    $this->publishes([
        __DIR__ . '/../config/sqslogger.php' => config_path('sqslogger.php')
    ]);
}

}

サービスコンテナにインスタンスを登録しているのは文字通りregisterメソッドで、singletonの名の通り、1度だけ依存解決した後、インスタンスを共有します。ここでは、コールバックとして渡している関数は無引数ですが、サービス間に依存関係があるときは、ここにサービスコンテナ自体を渡すことができます。

$this->app->singleton('FooBar', function ($app) { return new FooBar($app['SomethingElse']); });

この例では、FoobarはSomethingElseという別のサービスに依存しています。

bootメソッドは、すべてのサービスが登録された後に呼び出されます。ここでは$this->publishesにライブラリの中にあるconfig/sqslogger.phpファイルと、アプリケーションのconfig/sqslogger.phpのペアが登録してありますが、こうすることで、以下のコマンドを実行した時に、デフォルトの設定ファイルがアプリケーションのconfig/sqslogger.phpにコピーされます。

$ php artisan vendor:publish このようにして書かれたサービスプロバイダーは、config/app.phpのprovidersに登録することで読み込まれるようになります。

使いたいインスタンスメソッドを持つクラスを書く

これは普通に実装するだけです。外から使いたいメソッドはpublicにしておいて、staticにしないことに注意しましょう。

<?php

namespace Matchingood\SQSLogger;

use Illuminate\Http\Request; use Auth; use Log; use Aws\Sqs\SqsClient;

class SQSLogger { private $sqs; private $url;

public function __construct()
{
    if (config('sqslogger.env') === 'prod') {
        $this->sqs = new SqsClient([
            'credentials' => [
                'key' => config('sqslogger.aws.access_key'),
                'secret' => config('sqslogger.aws.access_secret')
            ],
            'version' => config('sqslogger.aws.sqs.version'),
            'region' => config('sqslogger.aws.sqs.region')
        ]);
        $obj = $this->sqs->getQueueUrl([
            'QueueName' => config('sqslogger.aws.sqs.queue_name')
        ]);
        $this->url = $obj['QueueUrl'];
    } else {
        $this->sqs = null;
        $this->url = '';
    }
}

public function access(Request $request, $extraInfo = null)
{
    $info = ['method' => $request->method(), 'accessUrl' => $request->fullUrl()];

    if (!is_null($extraInfo)) {
        $info += $extraInfo;
    }

    $body = $this->createBody('ACCESS', "", $info);

    $this->sendMessage([
        'MessageBody' => $body,
        'QueueUrl' => $this->url
    ]);
}

public function error($message, $extraInfo = null)
{
    $this->sendMessage([
        'MessageBody' => $this->createBody('ERROR', $message, $extraInfo),
        'QueueUrl' => $this->url
    ], 'ERROR');
}

public function info($message, $extraInfo = null)
{
    $this->sendMessage([
        'MessageBody' => $this->createBody('INFO', $message, $extraInfo),
        'QueueUrl' => $this->url
    ], 'INFO');
}

public function critical($message, $extraInfo = null)
{
    $this->sendMessage([
        'MessageBody' => $this->createBody('CRITICAL', $message, $extraInfo),
        'QueueUrl' => $this->url
    ], 'CRITICAL');
}

private function sendMessage($data, $level = 'debug', $extraInfo = null)
{
    if (env('APP_ENV') === 'prod') {
        $this->sqs->sendMessage($data, $extraInfo);
    } elseif ($level === 'ERROR') {
        Log::error($data['MessageBody']);
    } elseif ($level === 'INFO') {
        Log::info($data['MessageBody']);
    } else {
        Log::debug($data['MessageBody']);
    }
}

private function createBody($level, $message, $extraInfo = null)
{
    $id = null;
    if (Auth::check()) {
        $id = auth()->user()->id;
    }

    $body = [
        'level' => $level,
        'time' => date('Y-m-d H:i:s'),
        'userId' => $id,
        'message' => $message
    ];

    if (!is_null($extraInfo)) {
        $body += $extraInfo;
    }

    return json_encode($body, JSON_UNESCAPED_SLASHES);
}

}

あとは、サービスプロバイダーをconfig/app.phpのprovidersに登録します。

'providers' => [

    /*
     * Laravel Framework Service Providers...
     */
    //Illuminate\Foundation\Providers\ArtisanServiceProvider::class,
    Illuminate\Auth\AuthServiceProvider::class,
    Illuminate\Broadcasting\BroadcastServiceProvider::class,
    Illuminate\Bus\BusServiceProvider::class,
    Illuminate\Cache\CacheServiceProvider::class,
    Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,
    //Illuminate\Routing\ControllerServiceProvider::class,
    Illuminate\Cookie\CookieServiceProvider::class,
    Illuminate\Database\DatabaseServiceProvider::class,
    Illuminate\Encryption\EncryptionServiceProvider::class,
    Illuminate\Filesystem\FilesystemServiceProvider::class,
    Illuminate\Foundation\Providers\FoundationServiceProvider::class,
    Illuminate\Hashing\HashServiceProvider::class,
    Illuminate\Mail\MailServiceProvider::class,
    Illuminate\Pagination\PaginationServiceProvider::class,
    Illuminate\Pipeline\PipelineServiceProvider::class,
    Illuminate\Queue\QueueServiceProvider::class,
    Illuminate\Redis\RedisServiceProvider::class,
    Illuminate\Auth\Passwords\PasswordResetServiceProvider::class,
    Illuminate\Session\SessionServiceProvider::class,
    Illuminate\Translation\TranslationServiceProvider::class,
    Illuminate\Validation\ValidationServiceProvider::class,
    Illuminate\View\ViewServiceProvider::class,
    Collective\Html\HtmlServiceProvider::class,

    /*
     * Application Service Providers...
     */
    App\Providers\AppServiceProvider::class,
    App\Providers\EventServiceProvider::class,
    App\Providers\RouteServiceProvider::class,

     // サービスプロバイダーを挿入 Matchingood\SQSLogger\SQSLoggerServiceProvider::class, ],

aliasesにもFacadeクラスを追加します。

'aliases' => [

    'App'       => Illuminate\Support\Facades\App::class,
    'Artisan'   => Illuminate\Support\Facades\Artisan::class,
    'Auth'      => Illuminate\Support\Facades\Auth::class,
    'Blade'     => Illuminate\Support\Facades\Blade::class,
    'Bus'       => Illuminate\Support\Facades\Bus::class,
    'Cache'     => Illuminate\Support\Facades\Cache::class,
    'Config'    => Illuminate\Support\Facades\Config::class,
    'Cookie'    => Illuminate\Support\Facades\Cookie::class,
    .
    .
    .
    'SQSLogger' => Matchingood\SQSLogger\Facades\SQSLogger::class,
]

こうして、他のFacadeと同じようにSQSLoggerがつかえるようになりました。めでたしめでたし。

まとめ

あるフレームワークを使うには、そのフレームワークの仕組みをよく理解することが大事だと思いますが、これを通してLaravelの(ある種隠蔽されている)機能であるFacadeについて理解を深めることが出来ました。とはいえ、この機能の肝は「サービス間の依存関係の解決」にあるように思えるので、次に何かライブラリを書くときはその辺りを意識してみたいと思います。