27
24

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

Laravelでヘルパ関数をオーバーライドするいろいろな方法とマイベスト【未定義を例外にするconfig関数を作る編】

Last updated at Posted at 2019-04-09

TL;DR

  • config() が未定義だったら例外を投げるコピペコードです。
  • ヘルパに限らず、サービスコンテナをつついてLaravel標準機能を差し替えるケーススタディ。
  • もともと、ヘルパ関数やファサードは機能拡張・変更しやすい設計になっています。ここがLaravelのすごいところ。解説を試みます。

なにがしたい?

config('const.something.not.defined'); // null ...

config()というヘルパが便利でよく使っているのですが、スペルを間違えてもNULLを返すだけなのでバグに気付けないという、生産効率的にクリティカルな問題があります。

config('const.something.not.defined');  LogicException 定義されてないよ!! 

未定義だったら例外投げてくれませんか!
config() がそのままそういう挙動だったらいいのに!

を実現しようとして、ググっても意外としっくりくる解法が見つからず、これは**穴埋め係するしかない!**と火がついたので、その奮闘記と周辺事情のまとめです。

結論

config() ヘルパを触ろうとせず、Config Repository クラスを拡張して差し替えます。

拡張クラス

下記ファイルを作成してプロジェクトに追加します。
場所はどこでもいいですが Extensions というディレクトリを作って入れておくのが個人的な好み。
中身は、Laravel標準のConfigクラスを継承して、getメソッドをオーバーライドしているだけのクラスです。

app/Extensions/Config/Repository.php
<?php
namespace App\Extensions\Config;

use Illuminate\Config\Repository as BaseRepository;

/**
 * Configが未定義だったら例外を投げるようにした拡張
 */
class Repository extends BaseRepository
{
    public function get($key, $default = null)
    {
        if (is_null($default) && !$this->has($key)) {
            throw new \LogicException("undefined config [$key].");
        }
        return parent::get($key, $default);
    }
}

サービスプロバイダ

下記を書くのはどこでもいいのですが、こういう初期設定的なものはAppServiceProviderに書くのが王道。

app/Providers/AppServiceProvider.php

use App\Extensions\Config\Repository; // これも追記

class AppServiceProvider extends ServiceProvider
{
    //...

    public function boot()
    {
        // この1行を追加
        $this->app->instance('config', new Repository($this->app->make('config')->all()));

        //...

テスト

BEFORE

$ php artisan tinker
>>> config('const.something.not.defined')
=> null

AFTER

$ php artisan tinker
>>> config('const.something.not.defined')
LogicException with message 'undefined config [const.something.not.defined].'

明示的にNULLと定義されていたり、NULL以外のデフォルト値が与えられているときは通ります。

>>> config('const.something.not.defined',0)
=> 0
>>> config('const.something.not.defined',[])
=> []
>>> config('app.asset_url') # 5.7から追加されたデフォルトでNULLと定義されている設定
=> null

解説

Google先生が教えてくれたやり方

vendor/laravel/framework/src/Illuminate/Foundation/helpers.php
// もともとの config ヘルパ関数
if (! function_exists('config')) {

    function config($key = null, $default = null)
    {
        if (is_null($key)) {
            return app('config');
        }

        if (is_array($key)) {
            return app('config')->set($key);
        }

        return app('config')->get($key, $default);
    }
}

基本方針は、元々のヘルパの定義が「config関数が未定義だったら」となっていることに着目して、これよりも前に function config() 宣言してしまおう!というもので、いくつかのアプローチを教えていただけました。

1. bootstrap.php を書き換える

上記 helper.php を読み込んでいるのは autoload.php なので、それよりも先に書く。
とてもわかりやすいアプローチですね。

bootstrap/autoload.php
// ここに差し込む。わかりやすくfunctionをべた書きしているけど、本気でやるなら include で。
function config(...)
{
//... 
}

// もともとのAutoload宣言
require __DIR__.'/../vendor/autoload.php';

問題点

  • 新しい Laravel からは bootstrap/autoload.php が撤去されてしまったので、public/index.php に書かないとイケない。そこは触りたくない。
  • PHPUnitで読み込まれない。こっちが致命的。むしろテスト時にこそ検出したいし。本番とテストで環境が変わるのは良くない。

2. composerの拡張を入れる

composer require funkjedi/composer-include-files

としてから

composer.json
"extra": {
    "include_files": [
        "app/Http/helpers.php"
    ]
},

と追記する。

問題点

  • これだけのために composer にライブラリを追加??
  • やってみたけど動かなかった……
  • まったく、この手の単機能ライブラリは更新頻度が低くて信頼性が……

3. 別のヘルパ configEx() を書く

最も正しいヘルパ関数の追加方法は、composer.json に定義ファイルを追加することです。
こちらにも解説があります。 Qiita - Laravelのhelper関数を自作する

composer.json
    "autoload": {
        "files": [
            "app/Support/helpers.php"  追記する
        ],
        "psr-4": {
            "App\\": "app/"
        },
        "classmap": [
            "database/seeds",
            "database/factories"
        ]
    },

定義ファイルはどこでもいいのですが、Laravelのソースコード構成に倣ってここにいれるのが好みです。

app/Support/helpers.php
if (!function_exists('configEx')) {

    function configEx($key = null, $default = null)
    {
        // ...
    }
}

問題点

  • この方法だと標準の helper.php の後に読み込まれるのでオーバーライドができない。
  • 新しい名前だと既存のコードを全部書き換えないと。
  • スペルチェックはデバッグ時(開発環境)だけで良い。本番はふつうにconfigと同じ動きを期待するので、新しい名前にしたくない。

Laravel的に正しいアプローチ

と、個人的に信じている「マイベスト」なアプローチ、をご紹介します。

Google先生には「ヘルパ関数をオーバーライドするには?」って聞いておいてスミマセンが、実はそもそも、標準のヘルパ関数をオーバーライドしようとしたのが間違いだったのです。
正しくは **「オーバーライドしなくても、機能を差し替えできる」**です。

注目すべきは、先程サラリと貼り付けた、もともとのヘルパ関数のコードの、ここ。

return app('config');
...
return app('config')->set($key);
...
return app('config')->get($key, $default); 

サービスコンテナです。

サービスコンテナのインスタンスを差し替える

サービスコンテナ講座 第3回 結合 「なんでもツッコんで気軽に取り出す」 にもチラッと書きましたが、サービスコンテナに突っ込まれているインスタンスは、カンタンに差し替えることができます。

// こうしたり
app()->singleton('config', function(){ return new Class; });
// こうしたり
app()->instance('config', new Class);
// するだけで
app('config'); // new Class
// 得られるインスタンスが変わる

この影響範囲はもちろんヘルパ関数内も例外ではなく、上記を実行するとヘルパ関数内で使用するインスタンスが差し替わるので、ヘルパ関数を触ることなく、機能を置き換えることができます

どうやってインスタンスを初期化するか?

ちょっと今回の記事の本筋とは逸れるので詳説は避けますが、ココがソースコードと格闘したりとケースバイケースでコツを要するところ。

今回はConfigといったキーワードで検索します。
初期化方法を探るだけなので、下記を読むとだいたい把握できます。

vendor\laravel\framework\src\Illuminate\Foundation\Bootstrap\LoadConfiguration.php

    public function bootstrap(Application $app)
    {
        // キャッシュがあればそれを読み込んでいる…と。
        $items = [];
        if (file_exists($cached = $app->getCachedConfigPath())) {
            $items = require $cached;
            $loadedFromCache = true;
        }

        // Repositoryのコンストラクタにそのまま与えればいいのね。
        $app->instance('config', $config = new Repository($items));

        // キャッシュがなければファイルから追加していく……と。
        if (!isset($loadedFromCache)) {
            $this->loadConfigurationFiles($app, $config);
        }
vendor\laravel\framework\src\Illuminate\Config\Repository.php
class Repository implements ArrayAccess, ConfigContract
{
    public function __construct(array $items = [])
    {
        // Configデータはまるっとこのitemsプロパティに収まっているっぽい。
        $this->items = $items;
    }

    public function all()
    {
        // それをまるごと取得するのは all() か。わかりやすいな。
        return $this->items;
    }

というわけで、AppServiceProviderに追加した1行を分解して解説を加えるとこうなります。

$default_config_instance = $this->app->make('config');       // 標準のConfigのインスタンスを取得
$all_config_data         = $default_config_instance->all();   // その中身を全部取得
$new_config_instance     = new Repository($all_config_data); // 新しい拡張したインスタンスを生成して全部与える
$this->app->instance('config', $new_config_instance);        // サービスコンテナのインスタンスを置き換え

ヘルパとグローバル関数、ファサードとスタティックメソッドの違い

単なるグローバル関数にしか見えないヘルパ関数。
グローバル関数は、最近のPHP開発においては完全にアンチパターンとされています。

それを大量に用意しているLaravel。
最新のフレームワークなのに、いいんですか?

実はふつうのグローバル関数とヘルパ関数の最大の違いはここ。
中身に「サービスコンテナのインスタンス」を使っていることです。

image.png

グローバル関数は、1回定義してしまうと、アプリケーションのすべての場所で機能が固定化されてしまい、それを置き換えることはカンタンではありません(まさに今回、オーバーライドが困難だったように)。
これは特に、グローバル関数で未完成のモジュールや外部サービスを使っているときにテスト不可能になったり、モジュールとしての切り出しを困難にするのが、アンチパターンと言われている所以です。

そう、問題の本質は**「機能が差し替えできないこと」**です。

でもヘルパ関数は違います。中身はすべてサービスコンテナのインスタンスを呼び出しているだけなので、サービスコンテナをつつくとすぐに、その機能を差し替えできます。
この原則さえ守っていれば、ヘルパとしてグローバル関数を追加しまくっても大丈夫。
こうして作ったグローバル関数は、特にContollerまわりや、Bladeテンプレートなどで活躍してくれています。

ファサードも同じです。
ファサードの本質は、サービスコンテナで機能をカンタンに差し替えできること。
実はヘルパ関数は、ファサードをもっとカンタンにしたものです。やろうとしていることは同じ。
この原則さえ守っていれば、ファサードを追加しまくっても大丈夫。

……どうやって?(・∀・)

感想

第1回 サービスコンテナ 「それは新しい new だった」
第2回 サービスプロバイダ 「シングルトンはたった1行」
第3回 結合 「なんでもツッコんで気軽に取り出す」

とサービスコンテナを弄んできた副作用か、ある日突然、ヘルパとファサードが理解できた……ような気がしたので、今回別の機会にもかかわらず、ヘルパとファサードの使い方や本質について少し触れてみました。結果的に、サービスコンテナ講座 第3.5回 みたいな感じになっていますが、それだけサービスコンテナがすげぇ!と言いたいのです。

とはいえ、この記事に関わらず、私の記事はすべてワタクシ個人の独学と自己流で構成されているので、なにか根本的な思い違いとか、そんなことより誤字脱字とかありましたら、気軽にコメント+編集リクエストをいただけると幸いです😆

次回は今回さらっとしか触れなかったファサードに主軸をおいて、サービスコンテナ講座の続きが書ければ…と妄想しています。ご期待ください!

27
24
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
27
24

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?