LoginSignup
10
6

More than 5 years have passed since last update.

カスタムディレクティブのテストがしたいから Laravel Blade に文字列をパースしてもらうメソッドを作った

Last updated at Posted at 2018-07-23

何がしたい?

Laravelの標準テンプレートエンジンBladeさん。
恐れ入りますが、「文字列」をパースしていただけませんでしょうか、と。

$html = \Blade::parse('@foreach([1,2,3] as $val) {{$val}} @endforeach');
echo $html;

このようにしたら

1  2  3 

と、返していただけませんでしょうかーーー、と。

結論

Laravelにそんな機能は実装されておらず、下記のようなメソッドを作成しました。

2019年1月 加筆

@extendやコンポーネントを使う際に、ネスト数カウントが合わずにうまく動かないことがありました。元のVIEWレンダラのrenderメソッドの仕組みをさらに組み込みました。

    public function parseBlade($string, $param = null)
    {
        app(\Illuminate\Contracts\View\Factory::class)
            ->share('errors', app(\Illuminate\Support\MessageBag::class));

        extract(app('view')->getShared(), EXTR_SKIP);
        $__env->incrementRender();

        if ($param) {
            extract($param, EXTR_SKIP);
        }
        unset($param);

        ob_start();
        eval('?>' . app('blade.compiler')->compileString($string));
        $content = ltrim(ob_get_clean());

        $__env->decrementRender();
        $__env->flushStateIfDoneRendering();

        return $content;
    }

例えばTraitにして、テストケースなどに使ってみてください。

tests/ParseBlade.php
<?php
namespace Tests;

trait ParseBlade {

    public function parseBlade($string, $param = null)
    {
        // ...
    }

}
tests/SampleTest.php
<?php
namespace Tests;
// 古いLaravel(我が家のことだ)だと必要かも…。
// include "ParseBlade.php";

class SampleTest extends \TestCase
{
    use ParseBlade; 

    /**
     * @test
     * @return void
     */
    public function publish( )
    {
        $result = $this->parseBlade('@foreach([1,2,3] as $val) {{$val}} @endforeach');

        $this->assertEquals('1  2  3 ', $result);
    }
}

第2引数は、view()の第2引数と同様に、View内で使うための変数名と内容をセットにした配列を与えることができます。

$result = $this->parseBlade(
    '@foreach([1,2,3] as $val) {{$exval}} @endforeach',
    ['exval'=>'Ex Value']
);
echo $result; // Ex Value  Ex Value  Ex Value 

なんのために?

$result = $this->parseBlade( '@mydirective(input)' );

カスタムディレクティブのテストをしたかったんです。それだけのために、テスト専用のBladeテンプレートファイルを実行環境において、コンパイルして一時ファイルを生成してもらってレンダリングして…とするのは無駄が多いなぁと。

けど、そうでもなければ、たとえばテンプレートを単体でレンダリングしてみるとか

$view = view('sample.view', ['exval'=>'Ex Value']);
$html = $view->render();

そのテンプレートを使っているページのURLを

$response = $this->call('GET','real/page/url',$param);

と直接叩いて機能テストしたほうが良いと思います。

#そもそも、Bladeのテストが必要なくらい複雑にしたかったら、別途クラスを作ってふつうにクラス単位のユニットテストをしたほうが良いし。この話はまた次の機会に。

というわけでせっかく作ったBladeパーサーさんの、他の活用方法を募集中です。(`・ω・´)ゞ

解説

と、完全に自己否定しておいてアレですが、これを書く過程でソースコードを解読し、ちょっとプリミティブなことをしているので、そのあたりを書き残しておきます。

Laravelの標準テンプレートエンジンBlade。最大の特徴は、テンプレートファイルをすべてPHPにコンパイルして、PHPとして実行していること。他のテンプレートエンジンが、テンプレートを文字列としてパースして変数を置き換えて…とやっているので用意された構文以外は解釈してくれませんが、BladeはPHPコードを直接埋め込むことができるので機能拡張が手軽でやりたい放題ということ。

こりゃすげぇ、と感心してばかりだった過去。

でもふと気づくわけです。\Blade::parse('@foreach([1,2,3] as $val) {{$val}} @endforeach') が、実装されてるはずがない……と。(必要ないからね)

コンパイル

PHPにコンパイルする、ということは、BladeファイルをPHPに変換してドコかに保存するということしているということです。

保存場所は割とよく知られていて /storage/framework/views。元ファイル名をハッシュ化した暗号のようなファイル名で配置されます。このコンパイルされた暗号PHPよりも元のBlade.phpのほうが古ければ、新しくコンパイルすること無く暗号PHPを使い続けるので、コンパイルのオーバーヘッドは最初の1回だけ。だからそんじょそこらのテンプレートエンジンよりも高速ですよ、と。

というわけで、Bladeのコンパイルメソッドは、入力がファイルパスで、出力もファイルパス。いや、とは言っても、その中に、文字列をパースするメソッドがあるんじゃないですか?

というわけで覗いてみたのがこちら。

Illuminate\View\Compilers\BladeCompiler
    public function compile($path = null)
    {
        if ($path) {
            $this->setPath($path);
        }
        // キャッシュされていない場合「新旧比較」
        if (! is_null($this->cachePath)) {
            // pathで指定されたファイルを読み込んで「元Blade読込」コンパイル ★これ
            $contents = $this->compileString($this->files->get($this->getPath()));
            // コンパイルされたcontentsをCompiledPath「暗号PHP名」に保存する
            $this->files->put($this->getCompiledPath($this->getPath()), $contents);
        }
    }

ありましたありました。この compilerString がBladeコンパイラのコア。こいつにBladeテンプレートを文字列として与えると、PHPコードの文字列を返してくれるようです。

PHPとしてパース

出来上がった暗号PHPを、実際にPHPとして実行しているということは、どういうことですか?
ありました。

Illuminate\View\Engines\PhpEngine

    protected function evaluatePath($__path, $__data)
    {
        $obLevel = ob_get_level();

        ob_start();

        extract($__data, EXTR_SKIP);

        // We'll evaluate the contents of the view inside a try/catch block so we can
        // flush out any stray output that might get out before an error occurs or
        // an exception is thrown. This prevents any partial views from leaking.
        try {
            include $__path; // ★ここです
        } catch (Exception $e) {
            $this->handleViewException($e, $obLevel);
        } catch (Throwable $e) {
            $this->handleViewException(new FatalThrowableError($e), $obLevel);
        }

        return ltrim(ob_get_clean());
    }

おおお。本当に include してる。

この evaluatePath メソッドは「PHPファイル」を参照しているので、これを使おうと思ったら、1行だけだったとしてもファイルにしておく必要があります。

しかたないので、ここを再現します。

幸いメソッドは小さく、スコープ内に存在する変数がとても限られています。

ローカル変数とView内グローバル変数

View内で使える変数には

  1. Laravelが用意している変数 $__env$app
  2. どのViewからでも参照できる変数。$errorshareした変数。
  3. Viewにwithで紐づけたユーザー変数

の3つがあります。

この変数はすべて上記 evaluatePath$__data に仲良く収まっていて、evaluatePathのスコープ内に extract でどばーっと撒いているから、View内で使える。そんな単純な話に見えますが、evaluatePathに撒いた変数が参照できるのは1つのViewファイルだけ。だから例えば @component した子VIEWからは見えません。

もとを辿ると、この $__data に仲良く収めているのがこのLaravelのメソッドです。

Illuminate\View\View
    protected function gatherData()
    {
        $data = array_merge($this->factory->getShared(), $this->data);

        foreach ($data as $key => $value) {
            if ($value instanceof Renderable) {
                $data[$key] = $value->render();
            }
        }

        return $data;
    }

これを見ると、 1. 2. は View\Factory::getShared からもらってくるスタティック変数のようなもの、3. は View\View::$data というインスタンス変数になっていることがわかります。子VIEWでも同じ手続きで子VIEW用の変数が集められるようです。こんな仕組みで、親VIEWと子VIEWの変数スコープが分けられてるんですね。

そこで $errors のようなViewグローバルな変数はあらかじめ share しておき、gatherData で回収してもらうという手順を踏みます。

コンパイラを叩く

最後にさらりと1つだけ。そもそものBladeエンジン=compilerStringしてもらいたいクラスは、Illuminate\View\Compilers\BladeCompiler です。このクラスのインスタンスを取得したい場合はどうするか?

「サービスコンテナを使ってみて!」にも書きましたがサービスコンテナの出番。
こう書きましょうか。

$compiler = app(Illuminate\View\Compilers\BladeCompiler::class);

ええ。これでも大丈夫なのですが、もっとLaravelらしく書くとこうなります。

$compiler = app('blade.compiler');

Laravelのサービスコンテナはクラス名だけではなく、任意の文字列で命名することができ、Bladeコンパイラはその名の通り 'blade.compiler' と命名されてサービスコンテナに収まっています。

あえてこうしたのは、Bladeコンパイラを拡張して置き換えたりした場合にも対応できるように、なのですが、それはまた別の機会にに…。

感想

といったわけで、やりたかったことは単純なことなのに、乗りかかったら思ったよりもずっとディープなところにハマっていき、せっかくだからまとめてみたけど、結果的に「誰得」な記事がまた1つ増えただけという結果です(笑)。こんなところまで知っても仕事の役には経ちませんが、Laravelの深さを共感できたらいいなーというのが1割(自分の知識の固定化に9割くらい)お役に立てば幸いです。

10
6
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
10
6