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により、ジョブのスループット、ランタイム、実行の失敗など、キューシステムのキーメトリックを簡単に監視できます。
GitHubのコントリビューターを見てみると、
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を見てみましたが、特にサービス画面の作り方に関係するようなものは無さそうでした。
ただ、ちょっと興味深いライブラリが使われていたので紹介です。
{
...
"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です。こちらもそんなに言及すべき内容はありませんが、
{
...
"dependencies": {
"phpunserialize": "1.*",
"vue-router": "^2.2.0"
...
},
...
}
vue-router使われています。個人的にBladeとVueをどう組み合わせて使っているかに興味があったんですが、まあやっぱりSPAですね。
そしてphpunserializeはちょっと驚いたので紹介。なぜPHPでserializeしたデータをフロントサイドでunserializeする必要があるのか...
(Jobのpayloadあたりを扱うんだろうな、とは思いつつ)
サーバーサイドのルーティング
次はルーティング周りです。
<?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ファイルの実装はこれだけ。
<?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');
}
}
<!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におまかせですね。
フロントサイドのルーティング
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
<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
コンポーネントを使っています。
<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.vue
はrouter.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するのみです。
public function store($id)
{
dispatch(new RetryFailedJob($id));
}
更新処理をJobにまとめておくことでControllerの責任が明確になるのと、処理自体の再利用性も高まります。
ちなみにここで例に挙げたAPIは失敗したJobを再実行するためのもので、再実行処理は非同期で実行されます。Laravel Horizonの画面では実行結果をどのように画面に反映しているか興味があったので確認したところ、
/**
* Prepare the component.
*/
mounted() {
this.loadFailedJob(this.jobId)
this.interval = setInterval(() => {
this.reloadRetries();
}, 3000);
},
こんな感じで単に3秒毎にポーリングしているだけでした。
(いい感じのプッシュ機構とかをちょっと期待していた)
データの取得・更新処理はすべてRepositoryクラスに実装
データの取得・更新処理はRepositoryクラスに実装されており、
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回は気になっているプロジェクトを見てみるようにしたいですね。