PHP
JavaScript
AdventCalendar
laravel
vue.js
OriginalOPENLOGIDay 10

Laravel Horizonのソースから学ぶLaravel + Vue.jsを使ったシンプルなWEBサービスの作り方

OPENLOGI Advent Calendar 2017- Qiita の10日目の記事です。

Advent Calendar 2回目の登場です。前回は MySQL 5.7 の全文検索で不正確な郵便番号に立ち向かう というタイトルで物流に深く関わる郵便番号について書きました。
今回はOPENLOGIでも使っているLaravelに関する記事を書こうと思います。

Laravelで継続的に強化されている機能の一つとしてキュージョブがあります。8月末にリリースされた5.5でもキュージョブのチェーンやレート制限など活発に開発が進んでいます。
[参考]Laravel 5.5 リリースノート

また5.5リリースに先駆けて、今年の7月にLaravelのRedisキューのモニタリングに便利なLaravel Horizonがリリースされました。

OPENLOGIではデータの更新を伴う処理はすべてJobクラスとして実装していて、非同期処理しているものも多いため、個人的にも期待しているプロジェクトです。

Laravel Horizon とは

ドキュメントではこんな紹介がされています。

Laravelで動作するRedisキューのための、美しいダッシュボードとコード駆動による設定を提供します。Horizonにより、ジョブのスループット、ランタイム、実行の失敗など、キューシステムのキーメトリックを簡単に監視できます。

https://readouble.com/laravel/5.5/ja/horizon.html

GitHubのコントリビューターを見てみると、

Contributors.png

Laravelの作者であるtaylorotwellを差し押さえて、themsaidがトップに来ています。themsaidはエジプト在住のLaravel初の専任プログラマー(taylor除く)かつフリーダイバー。最近更新が滞りがちですが、Diving Laravelというサイトでちょっと深めなLaravelの話題を発信しています。

さて、Laravel Horizonの特徴としては、

  • Auto-Balancing
  • Code Driven Configuration
  • Metrics
  • Monitoring
  • Failed Job Management
  • Notifications

などがあり、それぞれの機能についてはtaylorがMediumにまとめていたり、Qiitaでも記事になっているので割愛して、ここでは内部のソースを見てみようと思います。

Laravelのコアコミッターによってフロントサイドまで実装されているプロジェクトですので、Laravel + Vue.jsでのWEBサービスの作り方を学ぶのに適しているはず!

利用しているライブラリ

まずはcomposer.jsonを見てみましたが、特にサービス画面の作り方に関係するようなものは無さそうでした。
ただ、ちょっと興味深いライブラリが使われていたので紹介です。

composer.json抜粋
{
...
    "require": {
        "cakephp/chronos": "^1.0",
...
    },
    "require-dev": {
        "orchestra/testbench": "~3.5",
...
    },
...
}

まずcakephp/chronosです。Laravel本体でもCarbonをどうするかについて議論されていますが、HorizonではChronosを採用しているようです。CarbonはDateTimeに比べたらはるかに扱いやすいのですが、何かとハマりポイントがあるので早くLaravel本体でもなんとかしてほしいですね。

orchestra/testbenchは今回初めて知ったものなんですが、Laravelパッケージ自体のテストをサポートするライブラリです。確かにLaravel本体と組み合わせたインテグレーションテストを1から用意するのは大変そうです。また laravel/framework にもこれを使ったテストソースが作成されていました

いきなり脱線した感がありますが、次はpackage.jsonです。こちらもそんなに言及すべき内容はありませんが、

package.json抜粋
{
...
    "dependencies": {
        "phpunserialize": "1.*",
        "vue-router": "^2.2.0"
...
    },
...
}

vue-router使われています。個人的にBladeとVueをどう組み合わせて使っているかに興味があったんですが、まあやっぱりSPAですね。
そしてphpunserializeはちょっと驚いたので紹介。なぜPHPでserializeしたデータをフロントサイドでunserializeする必要があるのか...
(Jobのpayloadあたりを扱うんだろうな、とは思いつつ)

サーバーサイドのルーティング

次はルーティング周りです。

routes/web.php
<?php

use Illuminate\Support\Facades\Route;

Route::prefix('api')->group(function () {
    // Dashboard Routes...
    Route::get('/stats', 'DashboardStatsController@index')->name('horizon.stats.index');

    // Workload Routes...
    Route::get('/workload', 'WorkloadController@index')->name('horizon.workload.index');

    // Master Supervisor Routes...
    Route::get('/masters', 'MasterSupervisorController@index')->name('horizon.masters.index');

    // Monitoring Routes...
    Route::get('/monitoring', 'MonitoringController@index')->name('horizon.monitoring.index');
    Route::post('/monitoring', 'MonitoringController@store')->name('horizon.monitoring.store');
    Route::get('/monitoring/{tag}', 'MonitoringController@paginate')->name('horizon.monitoring-tag.paginate');
    Route::delete('/monitoring/{tag}', 'MonitoringController@destroy')->name('horizon.monitoring-tag.destroy');

    // Job Metric Routes...
    Route::get('/metrics/jobs', 'JobMetricsController@index')->name('horizon.jobs-metrics.index');
    Route::get('/metrics/jobs/{id}', 'JobMetricsController@show')->name('horizon.jobs-metrics.show');

    // Queue Metric Routes...
    Route::get('/metrics/queues', 'QueueMetricsController@index')->name('horizon.queues-metrics.index');;
    Route::get('/metrics/queues/{id}', 'QueueMetricsController@show')->name('horizon.queues-metrics.show');

    // Job Routes...
    Route::get('/jobs/recent', 'RecentJobsController@index')->name('horizon.recent-jobs.index');
    Route::get('/jobs/failed', 'FailedJobsController@index')->name('horizon.failed-jobs.index');
    Route::get('/jobs/failed/{id}', 'FailedJobsController@show')->name('horizon.failed-jobs.show');
    Route::post('/jobs/retry/{id}', 'RetryController@store')->name('horizon.retry-jobs.show');
});

// Catch-all Route...
Route::get('/{view?}', 'HomeController@index')->where('view', '(.*)')->name('horizon.index');

api のための各種ルートと、その他のルートを HomeController で一括で扱う定義のみです。

HomeController とそこから参照されている app のviewファイルの実装はこれだけ。

src/Http/Controllers/HomeController.php
<?php

namespace Laravel\Horizon\Http\Controllers;

class HomeController extends Controller
{
    /**
     * Single page application catch-all route.
     *
     * @return \Illuminate\Http\Response
     */
    public function index()
    {
        return view('horizon::app');
    }
}
resources/views/app.blade.php
<!doctype>
<html>
    <head>
        <title>Horizon</title>
        <link href="https://fonts.googleapis.com/css?family=Lato:400,700" rel="stylesheet">
        <link rel="stylesheet" type="text/css" href="{{ mix('css/app.css', 'vendor/horizon') }}">
        <link rel="icon" href="/vendor/horizon/img/favicon.png" />
    </head>

    <body>
        <div id="root"></div>

        <div style="height: 0; width: 0; position: absolute; display: none;">
            {!! file_get_contents(public_path('/vendor/horizon/img/sprite.svg')) !!}
        </div>

        <script src="{{ mix('js/app.js', 'vendor/horizon') }}"></script>
    </body>
</html>

フロントサイドはvue-routerにおまかせですね。

フロントサイドのルーティング

resources/assets/js/router.js抜粋
import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router);

export default new Router({
    mode: 'history',
    base: '/horizon/',
    routes: [
        // --- 略 ---
        {
            path: '/recent-jobs',
            name: 'recent-jobs.detail',
            component: require('./pages/RecentJobs/Index.vue'),
        },
        {
            path: '/failed',
            component: require('./pages/Failed/Index.vue'),
        },
        {
            path: '/failed/:jobId',
            name: 'failed.detail',
            component: require('./pages/Failed/Job.vue'),
            props: true,
        },
    ],
})

vue-routerを使用し、各ルートに対応するページのvueファイルは resources/assets/js/pages に置かれています。
pages 以下のソースを見てみます。

RecentJobsディレクトリのソース

RecentJobs ディレクトリをピックアップして見てみます。ここには2ファイルがあります。

  • Index.vue
  • Jobs.vue
resources/assets/js/pages/RecentJobs/Index.vue
<script type="text/ecmascript-6">
    import Layout from '../../layouts/MainLayout.vue'
    import JobTable from './Jobs.vue'

    export default {
        components: {JobTable, Layout},


        /**
         * Prepare the component.
         */
        mounted() {
            document.title = "Horizon - Recent Jobs";
        }
    }
</script>

<template>
    <layout>
        <section class="main-content">
            <div class="card">
                <div class="card-header">Recent Jobs</div>

                <job-table/>
            </div>
        </section>
    </layout>
</template>

こちらは router.js に定義されていたvueファイルです。 <layout>...</layout> で共通のレイアウトを適用しつつ、同一階層に置かれている Job.vue コンポーネントを使っています。

resources/assets/js/pages/RecentJobs/Jobs.vue抜粋
<script type="text/ecmascript-6">
    import Status from '../../components/Status/Status.vue'

    export default {
        components: {Status},
        // --- 略 ---
    }
</script>

<template>
    <div class="table-responsive">
        <loader :yes="loadState"/>

        <p class="text-center m-0 p-5" v-if="!loadState && !jobs.length">
            There aren't any recent jobs.
        </p>

        <table v-if="! loadState && jobs.length" class="table card-table table-hover">
            <thead>
            <tr>
                <!-- 略 -->
                <th>Status</th>
            </tr>
            </thead>

            <tbody>
            <tr v-for="job in jobs">
                <!-- 略 -->
                <td>
                    <status :active="job.status == 'completed'" :pending="job.status == 'reserved' || job.status == 'pending'"/>
                </td>
            </tr>
            </tbody>
        </table>
        <!-- 略 -->
    </div>
</template>

Job.vuerouter.js に定義されておらず Index.vue のみで使われているコンポーネントです。内部で components/Status/Status.vue を利用しています。

フロントサイドのルーティングから追った結果をまとめると、vueファイルは以下のような配置になっているようです。

  • components: ページをまたいで共通で利用するコンポーネントを配置
  • layouts: ページをまたいで共通で適用するレイアウトを配置
  • pages: 各ページのルートファイルとそれぞれのページ固有で使うコンポーネントを配置

vueを使ったプロジェクトとしてよくある構成でしょうか?
Laravel(に限る話ではないですが)の新規プロジェクトでjsファイルをどういうディレクトリ構成でファイルを置くべきか悩みがちなので、個人的には参考になりました。

サーバーサイドの作り

サーバーサイドについてはざっと見るだけにしますが、以下のような特徴があります。

  • POST, DELETEリクエストはJobクラスで処理
  • データの取得・更新処理はすべてRepositoryクラスに実装
  • Event/Listenerを多用

POST, DELETEリクエストはJobクラスで処理

例えば /api/jobs/retry/{id} にPOSTリクエストがあった場合、ControllerのロジックはJobをdispatchするのみです。

src/Http/Controllers/RetryController.php抜粋
public function store($id)
{
    dispatch(new RetryFailedJob($id));
}

更新処理をJobにまとめておくことでControllerの責任が明確になるのと、処理自体の再利用性も高まります。

ちなみにここで例に挙げたAPIは失敗したJobを再実行するためのもので、再実行処理は非同期で実行されます。Laravel Horizonの画面では実行結果をどのように画面に反映しているか興味があったので確認したところ、

resources/assets/js/pages/Failed/Job.vue抜粋
 /**
 * Prepare the component.
 */
mounted() {
    this.loadFailedJob(this.jobId)

    this.interval = setInterval(() => {
        this.reloadRetries();
    }, 3000);
},

こんな感じで単に3秒毎にポーリングしているだけでした。
(いい感じのプッシュ機構とかをちょっと期待していた)

データの取得・更新処理はすべてRepositoryクラスに実装

データの取得・更新処理はRepositoryクラスに実装されており、

src/Repositories/RedisJobRepository.php抜粋
class RedisJobRepository implements JobRepository
{

    // --- 略 ---

    /**
     * Retrieve the jobs with the given IDs.
     *
     * @param  array  $ids
     * @param  mixed  $indexFrom
     * @return \Illuminate\Support\Collection
     */
    public function getJobs(array $ids, $indexFrom = 0)
    {
        $jobs = $this->connection()->pipeline(function ($pipe) use ($ids) {
            foreach ($ids as $id) {
                $pipe->hmget($id, $this->keys);
            }
        });

        return $this->indexJobs(collect($jobs)->filter(function ($job) {
            $job = is_array($job) ? array_values($job) : null;

            return is_array($job) && $job[0] !== null;
        }), $indexFrom);
    }

    // --- 略 ---

}

Controller側ではDIされたRepositoryを使ってデータを扱うだけです。

class FailedJobsController extends Controller
{
    /**
     * The job repository implementation.
     *
     * @var \Laravel\Horizon\Contracts\JobRepository
     */
    public $jobs;

    // --- 略 ---

    /**
     * Create a new controller instance.
     *
     * @param  \Laravel\Horizon\Contracts\JobRepository  $jobs
     * @param  \Laravel\Horizon\Contracts\TagRepository  $tags
     * @return void
     */
    public function __construct(JobRepository $jobs, TagRepository $tags)
    {
        parent::__construct();

        $this->jobs = $jobs;
        $this->tags = $tags;
    }

    // --- 略 ---

    /**
     * Get a failed job instance.
     *
     * @param  string  $id
     * @return mixed
     */
    public function show($id)
    {
        return (array) $this->jobs->getJobs([$id])->map(function ($job) {
            return $this->decode($job);
        })->first();
    }

    // --- 略 ---

}

Laravel HorizonではデータがすべてRedisにあってEloquentを使っていないから、という事情もありそうですが、ContorollerクラスやJobクラス内にQueryBuilderなどのゴリッとした実装があるのは微妙なので、最近オープンロジ社内でもRepositoryクラスを実装することが増えてきています。

Event/Listenerを多用

ジョブの記録を残すため RedisQueue をオーバーライドして、キューがpushやpopされるタイミングでイベント発火 & リスナー側で記録、といった実装がされています。

たとえば、キューが消化されると発火される JobDeleted イベントに対するリスナーは以下のような実装です。

class MarkJobAsComplete
{
    /**
     * The job repository implementation.
     *
     * @var \Laravel\Horizon\Contracts\JobRepository
     */
    public $jobs;

    // --- 略 ---

    /**
     * Create a new listener instance.
     *
     * @param  \Laravel\Horizon\Contracts\JobRepository  $jobs
     * @param  \Laravel\Horizon\Contracts\TagRepository  $tags
     * @return void
     */
    public function __construct(JobRepository $jobs, TagRepository $tags)
    {
        $this->jobs = $jobs;
        $this->tags = $tags;
    }

    /**
     * Handle the event.
     *
     * @param  \Laravel\Horizon\Events\JobDeleted  $event
     * @return void
     */
    public function handle(JobDeleted $event)
    {
        $this->jobs->completed($event->payload, $event->job->hasFailed());

        if (! $event->job->hasFailed() && count($this->tags->monitored($event->payload->tags())) > 0) {
            $this->jobs->remember($event->connectionName, $event->queue, $event->payload);
        }
    }
}

ここでもちゃんとRepositoryクラス使われていますね。

Event/Listenerはとても便利なんですが、リスナーを非同期処理 & イベント発火時にDBトランザクションを使っている場合、注意が必要なのでついでに書きます。

イベント/非同期リスナー
/* Eventクラス */
class HogeCreated
{
    public $hoge;

    public function __construct(Hoge $hoge)
    {
        $this->hoge = $hoge;
    }
}

/* Listernerクラス */
class SendHogeCreated implements ShouldQueue // この記述で非同期実行対象となる
{
    function handle(HogeCreated $event)
    {
        // do something...
    }
}
問題となるイベント発火の例
DB::transaction(function () {
    $hoge = Hoge::create(['field' => 'fuga']);
    event(new HogeCreated($hoge));
    sleep(10);
});

このようにDBトランザクション内でイベントを発火するとcommitが走る前にListenerが動いてしまい、まだ該当のデータが永続化されていないためエラーとなります。

まとめ

つらつらと書いてきましたが、Laravel Horizonのソースには他にもNotificationやプロセスまわりの実装など、シンプルそうに見えながらも色々と参考になる要素がありました。

普段オープンソースの中身を見る時間をあまりとれていないのですが、少なくとも月に2~3回は気になっているプロジェクトを見てみるようにしたいですね。