危険!Bladeのカスタムディレクティブで気をつけるべきこと

さてさて、Laravelの守備範囲が広いことはこのブログでもよく記事で書いていますが、そんなLaravelにもさすがに我々が必要とするもの全てを初期状態で備えているわけではありません。

とはいっても、Laravelがそれでも人気がフレームワークなのは、そのフレキシビリティが理由にあげられるのではないでしょうか。

つまり、足りない機能を独自に拡張することができるように(もしくはしやすいように)してくれているんですね。

そして今回のテーマ、Blade(テンプレート・エンジン)もこの例外ではなく、自分自身がほしいディレクティブを独自に設定することができるようになっています。

ただし、このBladeのカスタム・ディレクティブ。
実は気をつけておかないと思わぬ不具合の原因になることがあります。

そこで今回はどのような場合に不具合が発生する原因になるか、またその対処法はどうすればいいかをまとめてみます。

※ 実行環境: Laravel 5.7

Bladeのディレクティブとは

まずディレクティブとは、Blade内で使える様々な機能をもった命令文のことで、通常は次のようなものがよく使われるのではないでしょうか。

  • @section()
  • @yield()
  • @extends()

などなど。

そして、カスタム・ディレクティブはこのディレクティブを拡張し、独自の命令文を作成することができます。

例えば、@datetime()のようなものです。

これは、初期状態ではLaravelにはないものですが、独自に定義することでBlade内で使えるようになります。

詳しいカスタム・ディレクティブの作り方は必見!Laravel(5.6)のBladeでできること「まとめ」をご覧ください。

どのような場合に不具合が発生する?

先に結論を言うと、実行の度にコンテンツ内容が変更になる場合です。(ただし、この場合でも全ての場合に不具合が発生するわけではないので注意が必要です。)

では、実際にうまくいかない例を見てみましょう。

例えば、バリデーションに失敗したらエラーメッセージを表示するというものです。

// app/Providers/AppServiceProvider.php
// 注意: 不具合がある例です!

public function boot()
{
    Blade::directive('error', function($key) {

        if(session()->has('errors')) {

            $errors = session('errors');

            if($errors->has($key)) {

                return '<div class="alert alert-danger">'. $errors->first($key) .'</div>';

            }

        }

    });
}

そして呼び出す側はこうなります。

@error(name)

コード自体にエラーはありませんので、一見するとうまくいきそうですが、この場合ある問題が発生することになります。それは・・・・・・

「何も表示されない。。。(もしくはエラーが出続ける。。。)」

コードは合っているのになぜこのような現象が起こるのでしょうか??

原因はキャッシュ

Bladeはテンプレートエンジンとして、より高速に表示するため一度実行した部分をショートカットする「キャッシュ」を作成します。

そして、これは今回のカスタム・ディレクティブでも例外ではありません。

つまり、AppServiceProvider.php内に定義したコードが毎回実行されるわけでないのです。

では、これを確認するため次のコードを実行し、キャッシュの中身を見てみましょう。

<html>
<body>
    <!-- エラー表示開始 -->
    @error(email)
    <!-- エラー表示終了 -->
</body>
</html>

storage/framework/viewsフォルダ内に作成されたキャッシュです。

<html>
<body>
    <!-- エラー表示開始 -->
    
    <!-- エラー表示終了 -->
</body>
</html>

@errorディレクティブがあった場所にはなにもコードが書かれていません!

そうです。

つまり、先ほど定義したコードは一番最初のアクセスでキャッシュが作成され、そのあとはこのキャッシュが削除されるまでずっと同じ内容を表示し続けることになるのです。そのため何も表示されない、もしくはエラーが出続けてしまうという現象が発生していたんですね。

では、この現象を回避するためにはどうすればいいでしょうか?

不具合を回避する方法

もしカスタム・ディレクティブのキャッシュ問題を回避したい場合は、まず以下の2点の方法が考えられます。

  • 作成されたキャッシュを削除する
  • キャッシュ自体にPHPコードを埋め込む

ただ、1番目のキャッシュを削除する場合、実際にどれがターゲットとなるキャッシュがわからないため、php artisan view:clearで全てのキャッシュを削除しないといけなくなってしまいます。これはあまり現実的ではありません。

そのため、回避策としては2番めの「キャッシュ自体にPHPコードを埋め込む」方法を紹介します。

では、サンプルとして毎回実行する度に今の時間を表示する@nowというディレクティブ作成して呼び出してみましょう。

Blade::directive('now', function() {

    return '<?php echo now();  ?>';

});

returnする内容にPHPコードが埋め込まれていることに注意してください。
そして次のように呼び出します。

<html>
<body>
    <!-- 時間表示開始 -->
    @now()
    <!-- 時間表示終了 -->
</body>
</html>

では、作成されたキャッシュの中身です。

<html>
<body>
    <!-- 時間表示開始 -->
    <?php echo now();  ?>
    <!-- 時間表示終了 -->
</body>
</html>

今度はコードがうまくキャッシュ内に書き込まれました。
しかも、PHPコードが実行されるようになるので、毎回違った結果を表示することもできるようになっています。

つまり、表現が少し変になってしまいますが、「PHPでPHPコードを作成する」必要があるのです。

不具合を回避する実例

では、回避方法がわかったので、これを踏まえて先ほどの@errorを実行してみましょう。

まずは先ほどと同じようにコードをすべてreturnする例です。

// AppServiceProvider.php
// これは、おすすめしないコードです!

Blade::directive('error', function($key) {

    return '

    <?php

    if(session()->has("errors")) {

        $errors = session("errors");

        if($errors->has("'. $key .'")) {

            $message = $errors->first("'. $key .'");
            echo "<div class=\"alert alert-danger\">". $message ."</div>";

        }

    }

    ?>

    ';

});

この形で実行するとキャッシュは次のようになるため、こちらが思っているとおりに動いてくれることになります。

<html>
</head>
<body>
    <!-- エラー表示開始 -->
    

            <?php

            if(session()->has("errors")) {

                $errors = session("errors");

                if($errors->has("email")) {

                    $message = $errors->first("email");
                    echo "<div class=\"alert alert-danger\">". $message ."</div>";

                }

            }

            ?>

            
    <!-- エラー表示終了 -->
</body>
</html>

ただし、残念ながらこのやり方はあまりおすすめできません。

なぜなら、今回のコードは少し長いため、可読性がとても悪く保守しにくいからです。先ほども言ったように「PHPでPHPコードをつくる」必要があるためこんなことになってしまうんですね。

※ もちろん、コードが1行で書けるぐらい短いものでしたら何も問題はありません。

では、コードが長い場合にはどうするべきかというと「コードの分割」です。

結局、returnする内容に<?php ... ?>が含まれていればいいのですから、例えば、次のように専用のモデルを呼び出す方式へ変えればいいのです。(もしくは独自のヘルパー関数でもいいでしょう)

Blade::directive('error', function($key) {

    return '<?php \App\BladeDirective::error("'. $key .'"); ?>';

});

とてもコードがすっきりしました。
なお、この場合の専用モデルはこうなります。

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class BladeDirective extends Model
{
    static public function error($key) {

        if(session()->has('errors')) {

            $errors = session('errors');

            if($errors->has($key)) {

                echo '<div class="alert alert-danger">'. $errors->first($key) .'</div>';

            }

        }

    }
}

では、キャッシュを確認してみましょう。

<html>
</head>
<body>
    <!-- エラー表示開始 -->
    <?php \App\BladeDirective::error("email"); ?>
    <!-- エラー表示終了 -->
</body>
</html>

PHPコードが埋め込まれ、BladeDirective::error()が実行されるようになっています。

こうすることのもうひとつメリットの1つとして、もしエラー表示の内容を変えたい場合でもいちいちphp artisan view:clearでキャッシュを削除する必要はなくなりますので、より保守がしやすくなります。

おわりに

ということで、今回はいつもとは少し違った記事をお届けしました。

なお、実は以前からこのBladeのカスタム・ディレクティブにはキャッシュによる問題が存在していましたが、改善はされていないようです。

個人的には次のように第3引数にでもboolean値を設定できて、キャッシュするかしないかを制御できればいいと思うのですが、あまりカスタムディレクティブ自体を使う頻度は多くないですし、Laravel開発陣もそこまで重要視していないのかもしれません。

// これは私の希望するコードなので、実際には動きません!

Blade::directive('error', function($key) {

    // 省略

}, false);

(時間ができたらプルリクエストとかしてみようかな??)

ではでは!