20191203のlaravelに関する記事は9件です。

Laravelマイグレーション

マイグレーションでできること

テーブル定義を管理する仕組みのこと。
マイグレーションファイルを作成し、それを実行することでファイル内で定義した内容をもとにデータベーステーブルを作成する。

参照記事
https://www.hypertextcandy.com/how-laravel-migration-works

マイグレーションファイルの作成

php artisan make:migration create_{テーブル名}_table

database/migrationディレクトリに年_月_日_時間_create_{テーブル名}_table.phpというファイルが作成される。
updownという2つのメソッドを持つクラスが存在する。

upメソッドの中にどういうテーブルにするかの詳細を書いていく。
*カラム名・型など

公式ページ参照
https://readouble.com/laravel/5.5/ja/migrations.html

downメソッドはロールバック(マイグレーションを元に戻す機能)の時に実行される。

マイグレーション実行

php artisan migrate

実行することによってデータベースに3つのテーブルが作成される。
①作成したファイルのテーブル
→ファイル内で定義したテーブルが作成される
②migrationsテーブル
→マイグレーションを実行する度に追加されたファイル名が保存されるテーブル
③password_resetテーブル

マイグレーションの変更

php artisan make:migration {マイグレーションファイル名} --table={テーブル名}

{マイグレーションファイル名}の部分はそのままクラス名になるためわかりやすい名前にする
*カラム追加の場合
→add_cloumn_name_users_table

現在あるマイグレーションファイルを編集するのではなく新たに変更を加える内容を記載したファイルを作成し実行する。
例えばカラム名を変更する際はrenameColumnメソッドを使用する。
おそらくそのまま使用しても使えないため変更追加用パッケージをインストールする必要がある。

composer require doctrine/dbal

これをインストールすればカラム名を変更することが可能となる。

ロールバック

php artisan migrate:rollback

downメソッドを実行する
データベースのmigrationsテーブルのバッチ単位で処理される。
ロールバックを行うことによってバージョン管理のようにマイグレーション内容を元に戻すことができる。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Laravel】基本的なルーティングのまとめ

はじめに

現在Laravelについて学習しています。
その学習の履歴として今回はルーティングについてまとめました。
ざっくりとまとめているので、大枠を掴むためや復習のために見ていただけると幸いです。

ルーティングについて

今回はHTTPメソッドgetについてまとめます。
全部で9種類です。
ルーティングは下記の形式です。

Route::get('URL', '処理');

処理がクロージャとアクションの場合に分けて説明します
クロージャとはfunction(引数)のことで無名関数という意味です。
アクションとはMVCのコントローラに含まれるものです。
クロージャの場合は6種類で、アクションの場合は3種類です。

クロージャーの場合

処理がクロージャの場合について解説します

1. URLからテンプレートをレンダリング

URLからテンプレートにレンダリングする場合について解説します。

routes/web.php
Route::get('/', function () {
    return view('welcome');
});

/にアクセスすると
view関数によりresources/views/welcome.blade.phpをレンダリングする

2. URLからHTMLを直書き

レンダリングするだけでなくHTMLのソースコードを直書きすることもできます。

routes/web.php
Route::get('hello', function () {
    return '<html><body><h1>Hello</h1></body></html>';
});

/helloにアクセスすると
<html><body><h1>Hello</h1></body></html>を表示します

3. 返すHTMLを変数として扱う(ヒアドキュメント)

上記の直書きの場合、HTMLが長くなるとソースが見にくくなります。
変数に代入して、見やすくしましょう

routes/web.php
$html = <<<EOF
<html><body><h1>Hello</h1></body></html>
EOF;

Route::get(hello, function ()  use ($html){
    return $html;
});

/helloにアクセスすると、
$htmlを表示します。

ヒアドキュメント
<<<EOF
~~
EOF;

~~の部分を変数に代入できるようにするために、EOFで挟みます。

4. URLにパラメータを挿入する

URLにルートパラメータを記載する方法を説明します。

routes/web.php
Route::get('hello/{msg}', function ($msg) {
$html = <<<EOF
<html>
<body>
<h1>Hello</h1>
<p>{$msg}</p>
</body>
</html>
EOF;
    return $html;
});

ルートパラメータに$msgを指定します。

5. ルートパラメータを2種類にする場合

ルートパラメータを2種類にする場合について説明します。

routes/web.php
Route::get('hello/{msg}/{pass}', function ($msg,$pass) {
});

URLの部分にパラメータを記述し、処理の部分に引数にルートパラメータを設定しましょう。

6. パラメタの入力を任意にする場合

上記の記述方法だとルートパラメータが必須とになります。
そのため、任意のパラメータにする方法を説明します。

routes/web.php
Route::get('hello/{msg?}', function ($msg='no_message') {
});

注意する部分は二つです。
- URL部分のルートパラメータに?をつける。
- 処理の引数の部分に初期値を設定する。

コントローラの場合

まず、コントローラを作成

ターミナル
php artisan make::controller HelloController

次に、コントローラにアクションを追加する

app/Http/Controllers/HelloController.php
public function index(){
    Return <<<EOF
        <html>
        <body>
            <h1>Hello</h1>
            if (count($msg)>0){
                <p>{$msg}</p>
            }
        </body>
        </html>
        EOF;
}

1. ルーティングでアクションを指定する

処理をアクションに指定しましょう

routes/web.php
Route::get(hello, HelloController@index);

/helloにアクセスするとHelloControllerindexアクションに飛びます

2. ルートパラメータの利用

ルートパラメータを設定します。
設定のやり方は上で説明したものと同じです。
入力を任意にするために?をつけています。

routes/web.php
Route::get(hello/{msg?}, HelloController@index);
app/Http/Controllers/HelloController.php
public function index($id= noname, $pass=unknown){}

3. シングルアクションコントローラ

シングルアクションコントローラ(1つのアクションしかないコントローラ)の場合について説明します。

routes/web.php
Route::get{'hello', 'HelloController'}

上記の場合はアクション名の指定はいりません。

app/Http/Controllers/HelloController.php
public function __invoke() {}

コントローラが少しだけ特殊です。
__invoke()が必要です。

以上で基本的なルーティングの説明は終わります。

疑問、気になるところがございましたら、質問、コメントよろしくお願いします!!!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

なぜテストでWithoutMiddlewareするとRoute Model Bindingが無効化されるのか確認してみた

はじめに

Laravel Advent Calendar 2019 - Qiita の 3日目 の記事です。

今回は、Route Model Bindingしているコントローラに関連したテストが落ちる現象で小一時間ハマったのでそのことを記事にしてみました。

Route Model Bindingとは

突然ですが、getパラメータでユーザーからIDを受けとった後、該当するユーザーのEmailを返す例だとこんな感じになるかと思います。

// Route Model Bindingなしバージョン
Route::get('api/users/{id}', function (int $id) {
    $user = Entity\User::find($id);
    return $user->email;
});

Route Model Binding使って書くとこんな感じにかけます。

// Route Model Bindingありバージョン
Route::get('api/users/{user}', function (Entity\User $user) {
    return $user->email;
});

コントローラーの中で該当するユーザーのモデルを取得する処理を書かなくていいのは便利ですよね! :)

公式の説明はこちらになります。
https://laravel.com/docs/6.x/routing#route-model-binding

解決方法

テストコードの中で use WithoutMiddleware が記述されているとMiddlewareが全て無効化されるのですが、そのせいでRoute Model Bindingの機能も無効化されているようでした。下記のように無効化するMiddlewareを絞れば解決しました。

$this->withoutMiddleware([VerifyCsrfToken::class, YourAwesomeMiddleware::class]);

こちらの解決方法は以前にも @gomaaa さんがQiitaにまとめてくださっているので詳しくはそちらをご覧ください。

Route Model Bindingしているルートに対してテストするときのコツ

せっかくなのでちょっとだけ深堀りしてみた

どのmiddlewareがRoute Model Bindingの役割を担っているのか気になったので調べてみました。

Laravelのバージョンはv6.5.1です。

1. /app/Http/Kernel.phpを確認したところ$routeMiddleware の配列内に下記の記述を発見

'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,

こちらはLaravel5.3で追加されたRoute Model Bindingの機能を担うMiddlewareとのこと。こちらのリンクを参考にさせていただきました。[Laravel]ミドルウェアを整理してLaravelを軽くする - Qiita

    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        $this->router->substituteBindings($route = $request->route());

        $this->router->substituteImplicitBindings($route);

        return $next($request);
    }

どうも substituteImplicitBindings() メソッドが怪しい...

2.コードを追っていくと Illuminate\Routing\Router.phpsubstituteImplicitBindingsの実際のコードを発見

    /**
     * Substitute the implicit Eloquent model bindings for the route.
     *
     * @param  \Illuminate\Routing\Route  $route
     * @return void
     *
     * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
     */
    public function substituteImplicitBindings($route)
    {
        ImplicitRouteBinding::resolveForRoute($this->container, $route);
    }

resolveForRoute()の中身

    /**
     * Resolve the implicit route bindings for the given route.
     *
     * @param  \Illuminate\Container\Container  $container
     * @param  \Illuminate\Routing\Route  $route
     * @return void
     *
     * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
     */
    public static function resolveForRoute($container, $route)
    {
        $parameters = $route->parameters();

        foreach ($route->signatureParameters(UrlRoutable::class) as $parameter) {
            if (! $parameterName = static::getParameterName($parameter->name, $parameters)) {
                continue;Container.php
            }

            $parameterValue = $parameters[$parameterName];

            if ($parameterValue instanceof UrlRoutable) {
                continue;
            }

            $instance = $container->make($parameter->getClass()->name);

            if (! $model = $instance->resolveRouteBinding($parameterValue)) {
                throw (new ModelNotFoundException)->setModel(get_class($instance), [$parameterValue]);
            }

            $route->setParameter($parameterName, $model);
        }
    }

$instance = $container->make($parameter->getClass()->name); でLaravelのDIの仕組みを使ってEloquentのモデルを取得した後、

Illuminate/Database/Eloquent/Model.phpresolveRouteBinding()を実行して実際のデータを取得していたのです。

    /**
     * Retrieve the model for a bound value.
     *
     * @param  mixed  $value
     * @return \Illuminate\Database\Eloquent\Model|null
     */
    public function resolveRouteBinding($value)
    {
        return $this->where($this->getRouteKeyName(), $value)->first();
    }

まとめと反省

  • テストでMiddlewareの処理を無効にしたいときは無効にしたいMiddlewareだけを無効にするのが良さそう
  • 助けてもらいながらコードを読んだので今度は一人で読めるようになる

おわりに

最後まで読んでくださりありがとうございました!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

PHPUnitで特定のクラス群だけのテストカバレッジを算出する

この記事はうるる Advent Calendar 2019 3日目の記事です。

はじめに

今回はPHPUnitにおけるカバレッジを、特定のクラスにだけ絞って算出する方法を紹介いたします。

テストカバレッジは、一般的に高ければいいというものでもなければ、低くてもいいというものではありません。
そのサービスにおいて最適な品質保証として、どの程度テストを網羅していればいいかどうかは、サービスにより異なります。

もちろんカバレッジを100%にしてあるに越したことはないですが
開発コストなどを考えると、どうでもいい機能はテストを書きたくないし、その代わりに重要な機能に対してはちゃんとテストコードを書きたい
なんてこともあると思います。

この重要な機能に対してのみのカバレッジを計測したいという思いから
特定のクラス群のカバレッジを計測しましたので、その紹介をしたいと思います。

システム構成

今回使用するアプリケーションは
Laravel5.5を使用しています。
PHPは7.0、PHPUnitは6.5.14です。

通常のCoverage出力

PHPUnitによる一般的なカバレッジ出力は以下です。

$phpunit --coverage-html "./coverage" tests/

この結果から
./coverage が生成され、./coverage/index.html を開くとカバレッジが表示されます。
RyCp0.png
(これは適当に拾って来たサンプルです)

カバレッジレポートの出力

今度はカバレッジレポートも出力してみます。

$phpunit --coverage-html "./coverage" --coverage-clover "./clover.xml" tests/

こうすることで、clover.xmlが生成されます。
スクリーンショット 2019-12-03 14.35.27.png
今回はCircleCI上で実行していたので、パスにcircleciと書いてありますがあまり気にしないでください。

こちらのカバレッジレポートには、どのクラスがどれくらいの大きさで、どれくらいカバレッジしているかという数値が細かく入っています。

そこから一部抜粋し、計算してみます。

<file name="/home/circleci/app/app/Http/Controllers/Admin/UserController.php">
  <class name="App\Http\Controllers\Admin\UserController" namespace="App\Http\Controllers\Admin">
    <metrics complexity="7" methods="5" coveredmethods="4" conditionals="0" coveredconditionals="0" statements="25" coveredstatements="22" elements="30" coveredelements="26"/>
  </class>
  <line num="18" type="method" name="__construct" visibility="public" complexity="1" crap="1" count="1"/>
  <line num="20" type="stmt" count="1"/>
  ...略
  <line num="63" type="stmt" count="0"/>
  <metrics loc="65" ncloc="62" classes="1" methods="5" coveredmethods="4" conditionals="0" coveredconditionals="0" statements="27" coveredstatements="23" elements="32" coveredelements="27"/>
</file>

file > metricsのパラメーターを参照

metrics count covered coverage
methods 5 4 4/5 = 80%
statements(lines) 27 23 23/27 = 85.16%

つまり、このUserControllerのカバレッジはlineベースで85%ほどあるというわけです。

最初にHTMLで出力したものは、このカバレッジレポート値をいい感じにUIに変換したようなイメージで考えると良いでしょう。

特定クラス群のCoverage計算

さてここからが本題です。
PHPUnitでカバレッジを出力すると、必ず全体のLine数やメソッド数をベースにして計算してしまうため
本来押さえておくべきクラスやメソッドのみのカバレッジ(割合)を算出することができません。

そのため、phpを用いて上記のカバレッジレポート(clover.xml)から、本当に欲しい部分のみのカバレッジを算出できる機能を実装します。

クラスの指定

phpに定数配列で直接指定してしまいます。
ここでは、clover.xmlに出力されるnamespaceをそのまま指定してあげます。
(指定したクラスのみを取り出して計算するため、検索トリガーにnamespaceを使います)

const TARGET_CLASSES = [
    'App\Http\Controllers\UserController',
    'App\Http\Controllers\PurcahseController',
    'App\Http\Controllers\LoginController',
];

xmlの読み込み

$xml = simplexml_load_file('/clover.xml'); // 適切なパスを指定

集計

$coverages = [];
$totalCoverage = [
    'title' => '【Total】',
    'methods' => 0,
    'coveredmethods' => 0,
    'statements' => 0,
    'coveredstatements' => 0
];

foreach ($xml->project->package as $package) {
    foreach ($package->file as $file) {
        $class = (string)$file->class['name'];
        if (in_array($class, self::TARGET_CLASSES)) {
            // 設定したクラスに対して、カバレッジを取得する
            // クラスごとにメソッド数ライン数を配列に入れ込んでいく
            $coverages[$class] = [
                'title' => '【' . $class . '】',
                'methods' => (integer)$file->metrics['methods'],
                'coveredmethods' => (integer)$file->metrics['coveredmethods'],
                'statements' => (integer)$file->metrics['statements'],
                'coveredstatements' => (integer)$file->metrics['coveredstatements']
            ];
            // 各クラスのカバレッジ計測
            $coverages[$class]['MethodCoverage'] = round(($coverages[$class]['coveredmethods']/$coverages[$class]['methods'])*100, 2);
            $coverages[$class]['StatementsCoverage'] = round(($coverages[$class]['coveredstatements']/$coverages[$class]['statements'])*100, 2);

            // 全体のカバレッジ集計
            $totalCoverage['methods'] += $coverages[$class]['methods'];
            $totalCoverage['coveredmethods'] += $coverages[$class]['coveredmethods'];
            $totalCoverage['statements'] += $coverages[$class]['statements'];
            $totalCoverage['coveredstatements'] += $coverages[$class]['coveredstatements'];
        }
    }
}

// 各クラスの集計が終わったところでトータルのカバレッジを計算
$totalCoverage['MethodCoverage'] = round(($totalCoverage['coveredmethods']/$totalCoverage['methods'])*100, 2);
$totalCoverage['StatementsCoverage'] = round(($totalCoverage['coveredstatements']/$totalCoverage['statements'])*100, 2);
var_dump($totalCoverage);

array(7) {
  'title' => string(11) "【Total】"
  'methods' => int(95)
  'coveredmethods' => int(91)
  'statements' => int(1190)
  'coveredstatements' => int(1173)
  'MethodCoverage' => double(95.79)
  'StatementsCoverage' => double(98.57)
}

これで、本当に計測したいクラスだけを集めたカバレッジを計測することができました。

おまけ1:CircleCIからカバレッジレポートを取得

私が担当しているサービスでは、前提のテストカバレッジを毎晩夜中にCiecleCIで計測しています。
全体のカバレッジは、CircleCIで出力したhtmlを開くことで参照ができますが
上記のロジックを用いて、特定のクラス群のみのカバレッジを計測する方法をご紹介します。

方法はいたってシンプルで、最初に読み込むxmlの参照先を変えるだけです。

$url = 'https://circleci.com/api/v1.1/project/github/:username/:project/latest/artifacts';
$token = $this->circleCiToken;
$branch = 'develop';
$filter = 'completed';
$artifacts = $this->execCurlCommand($url . '?' . http_build_query(['circle-token' => $token, 'branch' => $branch, 'filter' => $filter]));
$clover = Arr::first($artifacts, function ($file) {
    return $file['path'] === 'build/logs/clover.xml';
});
$xml = simplexml_load_file($clover['url'].'?circle-token='.$this->circleCiToken);

こちらはCircleCIのAPIを参考に実装しています。
https://circleci.com/docs/api/#artifacts-of-the-latest-build

:vcs-type = github
:username/ = CircleCIのユーザーネーム
:project = repogitory名
を入れます。

APITokenは、ドキュメントを参考に生成します。
https://circleci.com/docs/api/#add-an-api-token

artifacts-of-the-latest-buildを実行するとArtifactの中身が全て返って来ます。

var_dump($artifacts);

array(902) {
  [0] =>
  array(4) {
    'path' =>
    string(17) "phpunit/junit.xml"
    'pretty_path' =>
    string(17) "phpunit/junit.xml"
    'node_index' =>
    int(0)
    'url' =>
    string(65) "https://1111-11111111-gh.circle-artifacts.com/0/phpunit/junit.xml"
  }
  [1] =>
  array(4) {
    'path' =>
    string(24) "schemaspy/anomalies.html"
    'pretty_path' =>
    string(24) "schemaspy/anomalies.html"
    'node_index' =>
    int(0)
    'url' =>
    string(72) "https://1111-11111111-gh.circle-artifacts.com/0/schemaspy/anomalies.html"
  }
...
array(4) {
  'path' =>
  string(21) "build/logs/clover.xml"
  'pretty_path' =>
  string(21) "build/logs/clover.xml"
  'node_index' =>
  int(0)
  'url' =>
  string(69) "https://1111-11111111-gh.circle-artifacts.com/0/build/logs/clover.xml"
}
以下省略

ここから、clover.xmlへアクセスするためのurlを取得することで、xmlを取得することが可能です。

おまけ2:Slackへカバレッジを通知

最後に計測したカバレッジをSlackへ通知します。
今回はLaravelを使ってい実装しているため、Slack通知自体はLaravelの基本機能を使用します。
https://readouble.com/laravel/5.5/ja/notifications.html

実際に通知を送るロジックは以下です。

// 各クラスのカバレッジを1つずつSlackへ通知する
collect($coverages)->each(function ($coverage) {
    $slackMessage = (new SlackMessage)
        ->from($this->name)
        ->to($this->channel);

    $this->setAtachmentType($slackMessage, $coverage['MethodCoverage']);
    $slackMessage->attachment(function ($attachment) {
        $attachment
            ->fallback($this->content['title'])
            ->title($this->content['title'])
            ->fields($this->content['message']);
    });
});

ここでのポイントは、setAtachmentTypeメソッドで、slackで表示されるメッセージに色をつけている部分です。

private function setAtachmentType(&$slack, $coverageRate)
{
    switch (true) {
        case ($coverageRate >= 80):
            return self::SUCCESS;
        case ($coverageRate >= 50):
            return self::WARNING;
        default:
            return self::ERROR;
    }

    switch ($this->type) {
        case self::ERROR:
            $slack->error();
            break;
        case self::WARNING:
            $slack->warning();
            break;
        default:
            $slack->success();
            break;
    }
}

カバレッジの数値を判定し、メッセージへ色をつけています。
0~49%なら赤
50~79%ならオレンジ
80~100%なら緑
がつくようにしています。
全部緑ならテンションが上がりますし、気づかぬうちにオレンジや赤になった時でも視覚的に気付きやすくしています。

実際のメッセージはこちらです。
スクリーンショット 2019-12-03 17.55.25.png
スクリーンショット 2019-12-03 17.57.00.png

※色分けをわかりやすくするために、数値は変えています。

まとめ

以上でCircleCIからカバレッジを読み取り、特定のクラス群のカバレッジを計算しSlackへ通知する一連の流れを説明しました。
実際のロジックは部分的に切り取って紹介していますので、実際に実装する際にはアプリケーションロジックに合わせて適宜修正してください。

さらに通知の自動化をさせるために、定期的にバッチなどでこの機能を実行させれば
勝手に計算してSlackで通知してくれるところまでできそうです。

また、カバレッジの推移も取りたい場合はどこかのDBなどに突っ込んだり
それが面倒であればGASを用いてスプレッドシートに突っ込んでもいいかもしれませんね。

ひとまずこれで重要な機能のカバレッジの見える化ができました!

あとがき

Advent Calendar 3日目でした。
明日4日目は Yuuki Noda さんによる記事を乞うご期待!
https://adventar.org/calendars/4548

参考

https://blog.leko.jp/post/how-to-parse-of-coverage-report-with-phpunit/

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Laravelで弐寺のクリアランプマネージャーをつくる

Laravel #2 Advent Calendar 20195日目のの記事です。

概要・動機

beatmania IIDXというゲームが好きです(唐突)

既存のクリアランプマネージャもあるにはあるが、機能が多くて僕には使いこなせません。
(低難度とか普段やらんしフォルダ分けもいらん)

身の丈にあったツールを使いたいので、クリアランプマネージャー兼地力推定サービスを自分用に車輪の再開発します。

前提として、自分はMVCが何ぞやということはゆるふわ理解している程度で、フルスタックのwebフレームワークは初心者です。

できたアプリはこんなかんじ↓
スクリーンショット 2019-12-03 15.30.42.png
(開発期間:2日)

クリアできそうな楽曲も教えてくれます!!(目玉機能)

github

https://github.com/dhaiibfiukkiu/estimator

MVCそれぞれの概要

モデル

正直ココが全てを決める気がする。

アプリの動作としては、
1.indexページに全楽曲の表とクリアした難易度のラジオボタン,submitボタン
2.ログイン後、submitでそのuserとクリアした楽曲を紐付けたclearsデータを挿入。
3.clearsデータに基づいてラジオボタンをchecked。また、地力の推定も行う。

といったことを想定しているので、userテーブル、musicsテーブル、clearsテーブルがいるかなと思った。

本来userとmusicsは多対多の関係であるので、clearsがうまく中間テーブルとして作用してくれる。
つまり、clearsの複合主キーがu_idm_idであるから、新たに主キーidを作る必要は無いと感じた。

このせいで後に苦労する羽目になるのだが(後述)
空白の ERD.png

musicsテーブルのeからfcまではそれぞれのクリア難易度(地力値)です。

値はこちらからパクっ拝借してきました。

単純にページをコピペすると、それぞれの楽曲は改行(\n)で、それぞれの要素はタブ文字(\t)で区切られていたので、こちらをlaravelのシーディングにうまく合う形に加工します。

musicTableSeeder.php
<?php                                                                                                                                   

    use Illuminate\Database\Seeder;
    use App\Music;

    class musicTableSeeder extends Seeder
    {
       /**
        * Run the database seeds.
        *
        * @return void
        */
       public function run()
       {

           $param=[
               'name' => '#MAGiCVLGiRL_TRVP_B3VTZ',
               'e' => '-2.914433',
               'n' => '-2.038145',
               'h' => '-1.965019',
               'exh' => '3.882106',
               'fc' => '8.346122',
           ];
           $musics = new Music;
           $musics->fill($param)->save();
     //(以下略)

目標はこの形です。そのために以下のPythonスクリプトを書きました。

makelist.py
input='''(ここに楽曲のリスト)
'''
input=input.split('\n')

for line in input:
    tmp=line.split('\t')
    if tmp[1]=='Infinity':
        continue
    name=tmp[0].replace("'",r"\'")
    e=tmp[1]
    n=tmp[2]
    h=tmp[3]
    exh=tmp[4]
    fc=tmp[5]
    print('''
        $param=[
            'name' => '{}',
            'e' => '{}',
            'n' => '{}',
            'h' => '{}',
            'exh' => '{}',
            'fc' => '{}',
        ];
        $musics = new Music;
        $musics->fill($param)->save();
    '''.format(name,e,n,h,exh,fc))

あとはこのスクリプトの出力をmusicのseederスクリプトにぶち込んでphp artisan db:seedしてやるだけです。(勿論テーブルのマイグレーションをした後にです)

あと、appディレクトリ以下のMusic,Clearモデルには以下のように記述しておきます。

Music.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Music extends Model
{
    protected $table = 'musics';

    protected $guarded = [
        'm_id',
        'name',
        'e',
        'n',
        'h',
        'exh',
        'fc'
    ];
}
Clear.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Clear extends Model
{
    protected $table = 'clears';
    protected $primaryKey = 'id';

    //仕方ないのでidをサロゲート(代理)キーに

    protected function rules(){
        return [
            'id' => 'integer',
            'm_id' => 'integer',
            'u_id' => 'integer',
            'info' => 'integer'
        ];
    }

    protected $fillable=[
        'id',
        'm_id',
        'u_id',
        'info'
    ];
}

ここに各要素のバリデーションのルールやらレコードの更新設定とかをするわけですね。

トラブル

Clear.phpの中に//仕方ないのでidをサロゲートキーにというコメントがあります。
実は、はじめにClearモデルを作成した際はidカラムは存在せず、m_idとu_idの複合主キーだけでした。

しかし、いざレコードをsaveメソッドで保存しようとすると、エラー。

どうやら複合主キーを使うとsaveメソッドが使えなくなるらしい。なんやそれ。

まあ、メソッドをオーバーライドすればできないこともないらしい。メンドクセー

参考

https://qiita.com/wrbss/items/7245103a5fef88cbdde9
https://github.com/laravel/framework/issues/5517

仕方がないので代理キーとしてidを追加しました。

ビュー

laravelでの開発と銘打っているし見た目にはそこまで拘りません。

resources/viewsフォルダにlayoutsフォルダ、estimatorフォルダを作成し、
layoutsにはおおもとのレイアウト、estimatorではそれを継承した具体的なビューを管理します。

/layouts/estimator.blade.php
<html>
    <head>
        <title>@yield('title')</title>
        <style>
body {font-size:16pt; color:#708090; margin: 5px;}

h1.title{
 position: relative;
  padding: 0.2em 0.5em;
  background: -webkit-linear-gradient(to right, rgb(255, 124, 111), #ffc994);
  background: linear-gradient(to right, rgb(255, 124, 111), #ffc994);
  color: white;
  font-weight: lighter;
  box-shadow: 0 0 4px rgba(0, 0, 0, 0.56);
}
#logout{font-size:20pt; text-align:right; color:#f6f6f6;
    margin:-20px 0px -30px 0px; letter-spacing:-4pt;}
ul{font-size:12pt;}
td{font-size:16pt;}
hr{margin:25px 100px; border-top: 1px dashed #ddd;}
.menutitle{font-size:14pt; font-weight:bold; margin: 0px;}
.content{margin:10px;}
.footer{text-align:right; font-size:10pt; margin:10px;
    border-bottom:solid 1px #ccc; color:#ccc;}
.blink {
  animation: blinkAnimeA 0.1s infinite alternate;
}
@keyframes blinkAnimeA{
   0% { background: #4dffff }
  95% { background: #ffff1a }
 100% { background: #ffff1a }
}
.highlighted{
color: #0000ff;
text-decoration: underline;
}
.blinkchr {
  animation: blinkAnimeB 0.6s infinite alternate;
}
@keyframes blinkAnimeB{
   0% { color: #ff0000 }
  97% { color: rgba(255, 255, 255, 0.99) }
 100% { color: rgba(255, 255, 255, 0.99) }
}
.fixed_btn
{
  font-size: 1.5em;
  width: 30%;
  height: 10%;
  position: fixed;
  bottom: 10px;
  right: 10px;
  padding: 6px 40px;

  display: inline-block;
  padding: 0.5em 1em;
  text-decoration: none;
  background: #668ad8;/*ボタン色*/
  color: #FFF;
  border-bottom: solid 4px #627295;
  border-radius: 3px;
}
.fixed_btn:active{
  /*ボタンを押したとき*/
  -webkit-transform: translateY(4px);
  transform: translateY(4px);/*下に動く*/
  border-bottom: none;/*線を消す*/
}
        </style>
    </head>
    <body>
        <h1 class='title'>@yield('title')</h1>
        <h2>@yield('loginfo')</h2>
        <hr size="1">
        <div class="content">
            @yield('content')
        </div>
        <div class="footer">
            @yield('footer')
        </div>
    </body>
</html>

ここにはめ込むビューが以下

estimator/index.blade.php
@extends('layouts.estimator')
@section('title','地力Estimator')
@section('loginfo')
    @php//@parent
    @endphp
@if(Auth::check())
<p>DJ NAME:{{$user->name}}</p>
<p id='logout'><a href='/logout'>ログアウト</a></p>
@else
    <p>ログインしていません(<a href='/login'>ログイン</a>|<a href='/register'>登録</a>)</p>
@endif
@endsection

@section('content')
<form action='/' method='post'>
<center>
<button type='submit' class='fixed_btn'>record</button>
<!--推定値-->
<h1 class='suiteichi'>
@if($jiriki==-100&&!Auth::check())
推定値を取得するためにはログインしてください
@elseif($jiriki==-100&&Auth::check())
推定値を取得するためには、チェックボックスにチェックを入れたあとに、右下のボタンを押下してください
@else
推定地力:{{$jiriki}}
@endif
</h1>
<table border = '2'>
<tr><th>曲名</th><th>NO PLAY</th><th>EASY</th><th>NORMAL</th><th>HARD</th><th>EX-HARD</th><th>FULLCOMBO</th></tr>
{{csrf_field()}}
<!--<input type='submit' value='Estimate'>-->
@foreach($musics as $item)

@if(!isset($clears))
@php
$check=-1
@endphp
@else
@php
$check=$clears->where('m_id',$item->m_id)->first()->info
@endphp
@endif

    <tr>
        <!--<td>{{$item->name}}</td>-->

        <td
        @if($check==1)
        bgcolor='#98fb98'
        @elseif($check==2)
        bgcolor='#87cefa'
        @elseif($check==3)
        bgcolor='#ff6347'
        @elseif($check==4)
        bgcolor='#ffff00'
        @elseif($check==5)
        class='blink'
        @endif
        >{{$item->name}}</td>

        <td bgcolor='#a9a9a9'>
        <label><input type="radio" name="{{$item->m_id}}" value=0
        checked
        >NULL</input></label>
        </td>

        <td bgcolor='#98fb98'>
        <label
        @if($item->e <= $jiriki)
        class=highlighted
        @endif
        ><input type="radio" name="{{$item->m_id}}" value=1
        @if($check==1)
        checked
        @endif
        >{{round($item->e,7)}}</input></label>
        </td>

        <td bgcolor='#87cefa'>
        <label
        @if($item->n <= $jiriki)
        class=highlighted
        @endif
        ><input type="radio" name="{{$item->m_id}}" value=2
        @if($check==2)
        checked
        @endif
        >{{round($item->n,7)}}</input></label>
        </td></label>

        <td bgcolor='#ff6347'>
        <label
        @if($item->h <= $jiriki)
        class=highlighted
        @endif
        ><input type="radio" name="{{$item->m_id}}" value=3
        @if($check==3)
        checked
        @endif
        >{{round($item->h,7)}}</input></label>
        </td>

        <td bgcolor='#ffff00'>
        <label
        @if($item->exh <= $jiriki)
        class=highlighted
        @endif
        ><input type="radio" name="{{$item->m_id}}" value=4
        @if($check==4)
        checked
        @endif
        >{{round($item->exh,7)}}</input></label>
        </td>

        <td class='blink'>
        <label
        @if($item->fc <= $jiriki)
        class=highlighted
        @endif
        ><input type="radio" name="{{$item->m_id}}" value=5
        @if($check==5)
        checked
        @endif
        >{{round($item->fc,7)}}</input></label>
        </td>

    </tr>
@endforeach
</table>
</center>
</form>
@endsection

@section('footer')
copyright 2019 Okada Hibiki
@endsection

今回は、主要なページが一つだけだったこともあり、ロジックをビュー側に少し任せすぎてしまったかなというのが反省点。

コントローラ

estimatorController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Music;
use App\Clear;
use Illuminate\Support\Facades\Auth;

class estimatorController extends Controller
{
    public function index(Request $request){
        $user = Auth::user();
        $musics = Music::all();

        if(Auth::check()){
            $clears = Clear::where('u_id',$user->id)->get();///getをかかないとダメ!!!
            if(Clear::where('m_id',1)->where('u_id',$user->id)->count()==0){
                foreach($musics as $music){
                    $clear = new Clear;
                    $clear->m_id = $music->m_id;
                    $clear->u_id = $user->id;
                    $clear->info = 0;
                    $clear->save();
                }
            }
        }
        else{
            $clears=null;
        }

        if($request->method()=='POST'){
            if(Auth::check()){
                foreach($musics as $music){
                    $clear = Clear::where('m_id',$music->m_id)->where('u_id',$user->id)->first();
                    $m_id = $music->m_id;
                    $clear->info = (int)$request->$m_id;
                    //$clear->timestamps = false;
                    $clear->save();
                }
            }
            else{
                //
            }
        }

        if(Auth::check()){
            $clears = Clear::where('u_id',$user->id)->get();
            $jiriki = array();
            foreach($clears as $clear){
                switch($clear->info){
                case 0:
                    break;
                case 1:
                    array_push($jiriki,$musics->where('m_id',$clear->m_id)->first()->e);
                    break;
                case 2:
                    array_push($jiriki,$musics->where('m_id',$clear->m_id)->first()->n);
                    break;
                case 3:
                    array_push($jiriki,$musics->where('m_id',$clear->m_id)->first()->h);
                    break;
                case 4:
                    array_push($jiriki,$musics->where('m_id',$clear->m_id)->first()->exh);
                    break;
                case 5:
                    array_push($jiriki,$musics->where('m_id',$clear->m_id)->first()->fc);
                    break;
                }
            }
        }
//ここに推定値の処理
        if(!isset($jiriki)){$jiriki=-100;}
        elseif(count($jiriki)==0){$jiriki=-100;}
        else{
            rsort($jiriki);
            $num=ceil(count($jiriki)*0.3);
            $sum=0;
            for($i=0;$i<$num;$i++){
                $sum+=$jiriki[$i];
            }
            $sum=$sum/$num;
            $jiriki=$sum;
        }

        $param = ['musics' => $musics,'user' => $user,'clears' => $clears,'jiriki' => $jiriki];
        return view('estimator.index',$param);
    }
    public function logout(){
        Auth::logout();
        return redirect()->action('estimatorController@index');
    }
}

注意点としては、モデルの利用の際にuseしなきゃいけないことくらいか。

処理の概要としては、ログインがあるかをチェックして、しているのであればデータベースをチェック。そのユーザのクリア情報があれば取得し、なければNO PLAYとしたレコードをclearsテーブルに挿入。

POSTメソッドでのアクセスであれば、それを元にクリア情報を更新。

さらに、最新のクリア情報から自力を計算します。

この地力の計算が困ったもので、それらしい確率分布を適用しても、地力低めなEASYクリアとかが増えると地力が下がる、という現象がどうしても起こってしまう。

ので、クリアした楽曲の地力上位3割の平均値を推定地力としています。こっちのほうがシンプルでいいし、さっきみたいな不具合も幾らか少なくなるので良い。

Auth

Laravelには標準で認証機能があります。
これがものすごく便利で、php artisan ui vue --authするだけでログイン機能が実装できます。
Laravelのバージョン5ではphp artisan make:authで(読んだ本はこっちだった)
6系ではそれが使えない点に注意してください。

今回のアプリケーションでは、トップページの閲覧にログインは必須ではないが、データの保存や推定値の表示にはログインが必須であるような実装にしました。

そして、標準のAuthを利用すると、/register,/login,/homeといったpathが用意されます。

初期状態だと、/registerで登録、/loginでログイン後、/homeにリダイレクトされる仕様だったのですが、これを/にリダイレクトするよう変更します。

この処理はAuth内のコントローラに記述されています。
/app/Http/Controllers/Auth/以下のRegisterController.php,LoginController.php内の
protected $redirectTo = '/home';
という記述を
protected $redirectTo = '/';
に変更すればOK。

ルーティング

routes/web.php
<?php

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/

Route::get('/', 'estimatorController@index');
Route::post('/','estimatorController@index');
Route::get('/logout','estimatorController@logout');

Auth::routes();

Route::get('/home', 'HomeController@index')->name('home');

ルーティングとはいえどメインは/で動作するestimatorControllerだけなのでシンプルですね。

おわりに

フルスタックのフレームワークを使ったのは初めてですが、簡単に自分にとって実用的なアプリが作れました。

正直今回のアプリならSPAと相性が良さそうなので、気が向いたらそれもつくってみようかな(たぶんやらん)

参考文献

David Skler(2017)初めてのPHP
掌田津耶乃(2017)PHPフレームワーク Laravel入門

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

nginxとlaravelでlog rotateをしてみる

ログローテートで対象のlogファイルをrenameする

logファイルをrotateしたかったか 理由↓↓

サイトのフォーム等で離脱があったり(アナリティクス)とかをlogに書き込み、そのログを1日1回解析するシステムにログファイルを読ませる必要があったため

その際に解析するシステム側には同じlogファイル名を参照するようにしたかったから!!!!!

では コード例から

フレームワークはlaravel、ミドルはnginxを使用してます。

# 対象のログファイル
/var/www/laravel/shared/storage/logs/analyze.log {
su nginx nginx
  # 毎日ローテートする
  daily
  # 30世代分古いログを残す
  rotate 30
  # ログファイルがなくてもエラーを出さない
  missingok
  # ログファイルが空ならローテーションしない
  notifempty
  # ログファイルをコピーし、内容を削除
  copytruncate

  # dateフォーマットを任意のものに変更する
  dateformat %Y-%m-%d

  # ログローテーション後に実行
  postrotate
  /bin/kill -HUP `cat /var/run/syslogd.pid 2> /dev/null` 2> /dev/null || true
  endscript
}

これをプロジェクトソース内でテキストファイルとして持っておけばサーバーに配置された後に

cp analyze.txt /etc/logrotate.d/analyze

でlog rotateに設定完了。

テストしたいときは
logrotate analyze
なり
logrotate -f analyze

de

実行してください。

これで毎日、

analyze.log2019-09-04
analyze.log2019-09-05
analyze.log2019-09-06
analyze.log

といった具合にファイルが作らて、

analyze.logが本日付のファイルで次の日になったら

analyze.log2019-09-07が作成されます。

以上

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

推測せずに計測してLaravelパフォーマンスチューニング

はじめに

Laravelで開発した検索APIを、blackfireというプロファイラーを用いてパフォーマンスチューニングをしたので、その知見を共有します。

https://blackfire.io/

blackfireには有料プランが存在しますが、本記事で扱う内容は全て無料プランの範囲内です。

この記事で扱うこと

  • 僕のプロジェクトを高速化した方法

この記事で扱わないこと

  • blackfireのインストール方法
  • blackfireでの計測方法

→ いずれもドキュメント先人の記事を参照してください。

  • あなたのプロジェクトを高速化する方法

→ 自力で見つけて下さい。本記事はそのヒントとなることを目指しています。

前提条件

  • PHP: 7.3
  • Apache: 2.4
  • MySQL: 5.7
  • Laravel: 5.6.1
  • memcached (本記事内で導入)
  • 検索API
  • ページングなし
  • 検索結果は最大で1,000件程度
  • 検索結果1行ごとに、Laravelのrouteヘルパー関数で生成した、それ自身のURLを含む

1,000件検索

範囲を選択_030.png

画面左上の表示から、1.4sかかっていることが分かります。
画面右のツリーを見て処理が重そうな箇所を探しましょう。

route()

改善前

範囲を選択_032.png

まず、MasterDatumViewModel::document()という関数が気になりました。これは検索結果のViewModelクラスで、検索結果1行ごとのURLを返すメソッドです。ソースコードを下記します。

    public function document(): string
    {
        return route('document', ['masterDatumId' => $this->masterDatum->id()->toInt()]);
    }

この関数は検索結果の数だけ呼び出されます。1,000件検索では1,000回呼び出され、トータルで542msかかっています。これは全体の実行時間の40%弱を占め、そのほとんどがroute()呼び出しによるものです。

1行ごとのURLが変わることは滅多にないので、検索結果のIDをキーに含めてキャッシュしてしまいましょう。

改善後

    public function document(): string
    {
        $key = $this->documentCacheKey();
        $cache = cache()->store();
        return $cache->get($key, function () use ($key, $cache) {
            $route = route('document', ['masterDatumId' => $this->masterDatum->id()->toInt()]);
            $cache->put($key, $route);
            return $route;
        });
    }

    public function documentCacheKey(): string
    {
        $id = $this->masterDatum->id()->toInt();
        return "master_datum:document:route:{$id}";
    }

改善後のソースコードです。キャッシュドライバはmemcachedを使用しました。

範囲を選択_034.png

1.57sと、却って遅くなってしまいました。画面右のツリーを見ると、キャッシュリポジトリを取得するcache()CacheManager::store()の呼び出しがかなりの割合を占めます。

ViewModelクラスですし、インスタンス毎に異なるキャッシュリポジトリを使うことも考えにくいので、ここは思い切ってstatic変数にしてしまいましょう。

再改善後

    public function document(): string
    {
        $key = $this->documentCacheKey();
        // 呼び出し回数が多いため初回呼び出し時にstaticとして保持
        static $cache;
        $cache = $cache ?? cache()->store();
        return $cache->get($key, function () use ($key, $cache) {
            $route = route('document', ['masterDatumId' => $this->masterDatum->id()->toInt()]);
            $cache->put($key, $route);
            return $route;
        });
    }

    public function documentCacheKey(): string
    {
        $id = $this->masterDatum->id()->toInt();
        return "master_datum:document:route:{$id}";
    }

キャッシュリポジトリをstatic変数とし、クラス内で初回のみ取得するよう変更しました。

範囲を選択_035.png

結果は1.1sとなり、0.3sの高速化ができました。

余談

デプロイ時にはキャッシュを忘れずに削除しましょう。

\Illuminate\Database\Eloquent\Builder\get()

改善前

範囲を選択_036.png

次に気になったのは、SearchMasterDataAdapter::findMasterData()という関数です。

これは、入力された検索条件をクエリビルダーに適用し、検索結果をドメインモデルに変換して返すメソッドです。ソースコードを下記します。

    public function findMasterData(SearchQuery $query): iterable
    {
        $builder = $this->eloquentMasterData->newQuery();
        $keyword = $query->keyword();
        $sort = $query->sort();

        if ($keyword !== null) {
            $builder->ofKeyword($keyword);
        }

        foreach ($query->businessCategories() as $businessCategory) {
            $builder->ofBusinessCategory($businessCategory);
        }

        if ($sort !== null) {
            $builder->sort($sort);
        }

        return $builder->get()->map(static function (MasterData $masterDatum) {
            return $masterDatum->toDomainModel();
        });
    }

この中で大きな比率を占めるのが$builder->get()です。

ツリーを見ると、Eloquentが検索結果をMasterDataモデルにhydrateする処理に時間がかかっているようです。
適切にデータがセットされたMasterDataモデルのtoDomainModel()さえ呼べれば用は足りるので、PDOで取得した検索結果をMasterDataモデルにセットしてみましょう。

改善後

    public function findMasterData(SearchQuery $query): iterable
    {
        // 省略

        $pdo = $builder->getConnection()->getPdo();
        $bindings = $builder->getBindings();

        $stmt = $pdo->prepare($builder->toSql());
        $results = $stmt->fetchAll(\PDO::FETCH_ASSOC);

        return \array_map(static function (array $row) {
            static $masterData;
            $masterData = $masterData ?? new MasterData();
            return $masterData->setRawAttributes($row)->toDomainModel();
        }, $stmt->fetchAll(\PDO::FETCH_ASSOC));
    }

まずはクエリビルダーをSQLとパラメータに変換して、PDOで取得します。それをMasterData::setRawAttributes()に渡すことでMasterDataモデルに値をセットし、ドメインモデルに変換します。

範囲を選択_037.png

先程の1.1sから、911msまで高速化できました。

1,000件検索のプログラム改善はここまでとします。

gzip

改善前

次はブラウザでAPI呼び出しを見てみましょう。

範囲を選択_038.png

1,000件ものデータとなるとその容量も小さくなく、433kbとなっています。その転送にはおよそ456msかかっています。
これをWebサーバーで圧縮して、転送速度を改善しましょう。

改善後

.htaccess
<IfModule mod_deflate.c>
    <IfModule mod_filter.c>
        FilterDeclare COMPRESS
        FilterProvider COMPRESS DEFLATE "%{Content_Type} =~ m#^application/json#"
        FilterChain COMPRESS
        FilterProtocol COMPRESS DEFLATE change=yes;byteranges=no
    </IfModule>
</IfModule>

コンテンツの圧縮の方法はここでは扱いませんが、Content-Typeapplication/jsonで始まるレスポンスを圧縮するよう設定しました。
(ちなみにm#^application/jsonの後に$を付けて完全一致にすると、Content-Type: application/json; charset=utf-8のようなケースで圧縮されません。)

範囲を選択_039.png

データ量が73.5kbまで低減しました。転送時間は大差ありませんが、これは開発環境がローカルであることが原因です。
本番環境では転送時間が大きく改善していることを確認できました。

1件検索

次に1件検索時のプロファイルをします。1,000件検索時とは違って、もっとアプリケーションのベース部分の問題が判るはずです。

サードパーティ製ライブラリ

改善前

範囲を選択_042.png

1件検索時の結果は478msとなりました。
この内、およそ50%程度をサードパーティ製ライブラリの処理が占めていました。ソースコードを読むと改善の余地が十分にありそうですが、vendor以下のコードを変更する訳にはいきません。

ライブラリの修正を待つのも一つの手ですが、今回は時間的猶予がありませんでした。そのため、自分で改善したクラスをサービスコンテナに登録する形で対応しました。

改善後

./app/Providers/AspectServiceProvider.php
use App\Aop\AspectManager;
use Illuminate\Foundation\Application;
use Illuminate\Support\ServiceProvider;
use Ytake\LaravelAspect\AnnotationConfiguration;

final class AspectServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->singleton('aspect.manager', static function (Application $app) {
            /** @var AnnotationConfiguration $annotationConfiguration */
            $annotationConfiguration = $app->make(AnnotationConfiguration::class);
            $annotationConfiguration->ignoredAnnotations();

            // ↓自分で作成したクラス
            return new AspectManager($app);
        });
    }

    public function boot()
    {
    }
}

詳細は省きます。重要なのは、サービスコンテナを利用することで既存の処理も置換可能ということです。
Laravelドキュメントのサービスコンテナのページを読んで下さい。
https://readouble.com/laravel/6.x/ja/container.html

範囲を選択_043.png

284msとなり、およそ200msもの改善ができました。

まとめ

  • 1,000件検索では1.4s→911ms。約35%の改善。
  • 1件検索では478ms→284ms。約40%の改善。

本記事で行なった施策が、そのままあなたのプロジェクトに適用できることは稀だと思います。
あなたのプロジェクトに最適な施策を見つけて下さい。

推測するな、計測せよ。
よきパフォーマンスチューニングを。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

GitHub Actions で Laravel + Vue 環境を自動デプロイ

GitHub Actions が正式リリースされましたね。
これで外部サービスを使用しなくても GitHub 単体で CI/CD などのワークフローを自動化できるようになりました。
image.png
https://github.com/features/actions

弊社は社内の GitLab でソースコード管理+ CI/CD していますが、ちょうど担当していた案件の納品先が GitHub ということもあり、Laravel + Vue アプリケーションをビルドし本番サーバにデプロイするところまでを実装してみました。

ゴール

特定のブランチに push すると自動で本番環境にデプロイする

流れ

ワークフローとして実行するタスクはこんな感じです。

  • 特定のブランチ(master)へのプッシュ(or プルリク) をトリガーにワークフローを起動
  • GitHub 上で docker コンテナを起動
    • 以下コンテナ上で
    • git clone (ソースコード取得)
    • composer install (PHP環境構築)
    • npm install (node環境構築)
    • npm run prod (フロントエンドビルド)
  • rsync でコンテナ上で構築したファイルを本番サーバへ転送

秘匿情報をいかにセキュアに管理するか

今回の案件ではフロントエンド(Vue.js)=>バックエンド(Laravel)間の API 通信の認証に Laravel Passport を使用していました。Laravel Passport では認証用のトークンをフロントエンドのコードに含めてビルドする必要があるため、トークンをコンテナ内に持ち込む必要がありますが、他にも DB のパスワードや、AWS のトークンなど、またビルドした成果物を rsync + ssh で本番サーバに同期する際の秘密鍵、などなど、知られてはいけない情報が多々あり、当然これら セキュアな情報を記載したコードをリポジトリにコミット出来ない問題 に直面します。

秘匿情報は Secrets に保存

これを解決するために、GitHub のリポジトリ設定画面には Secrets というメニューが追加されており、セキュアな情報に名前を付けて保存できます。これは GitHub Actions だけに使用する目的で追加された機能で、

Secrets are environment variables that are encrypted and only exposed to selected actions. Secrets are not passed to workflows that are triggered by a pull request from a fork of this repository.

とある通り、情報は暗号化され、選択されたアクションにのみ使用されれます。リポジトリのフォークで継承されることもなく、また、他のコントリビューターからも閲覧されることはありません。(そもそも登録した本人でさえ一度登録した内容は閲覧できません)

Secrets は、GitHub のリポジトリメニューの「Settings」からサイドメニューの「Secrets」より遷移し、「Add a new secret」をクリックして、Name と Value を登録します。

image.png

今回はここに、Laravel Passport の認証用トークン OAUTH_CLIENT_SECRET と rsync で使用する SSH の秘密鍵 SSH_PRIVATE_KEY を登録しました。これらの Name はワークフローの定義時に変数として使用することができるので後述の定義ファイル自体はセキュアな記述となります。

        MIX_OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }}

ワークフローの作成

それではワークフローを作っていきましょう。リポジトリのタブメニューにある Actions をクリックすると予め用意されたワークフローが表示されます。これから作成するワークフローに近いものがあればそれをテンプレートとして使用しても良いですが、今回は使用しません。実は画面から作成しなくても、定義ファイルを直接リポジトリに追加しプッシュすればワークフローとして認識します。

定義ファイルは下記のパスになります。

.github/workflows/main.yml

拡張子からも分かる通り、GitHub Actions ではワークフローを YAML 形式で定義します。

name: 本番サーバへのデプロイ

on:
  push:
    branches:
      - master

jobs:

  deploy:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v1
    - name: バックエンドの準備
      run: composer install
    - name: フロントエンドのビルド
      run: npm install && npm run prod
      env:
        MIX_OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }}
        APP_ENV: "production"
        APP_DEBUG: "false"
        APP_URL: "https://xxxxxx.test"
    - name: 秘密鍵のコピー
      run: echo "$SSH_PRIVATE_KEY" > id_rsa && chmod 600 id_rsa
      env:
        SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
    - name: ファイルをサーバに同期
      run: rsync -rlOtcv --delete --exclude-from=.rsyncignore -e "ssh -i id_rsa -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no" ./ deploy@xxxxxxx.test:/var/www/vhost/xxxxxxxx/

このファイルをpushするだけで指定したブランチに更新があった際にワークフローが開始されます。

ファイルの解説

簡単に定義ファイルの中を見ていきましょう。

name: (ワークフローの名称)

name: 本番サーバへのデプロイ

このワークフローの名前を定義します。GitHub のサイト上から見えるためわかりやすいものにしましょう。日本語も可能です。

on: (アクション起動条件)

on:
  push:
    branches:
      - master

トリガーとなる条件を記述します。ここでは master ブランチに push があった場合の条件を指定しています。

runs-on: (コンテナの指定)

    runs-on: ubuntu-latest

使用するコンテナを指定します。windows や macos も指定できるのでネイティブアプリのビルドなども可能。

steps: (アクションの定義)

    steps:

ここからワークフローをステップごとに定義します。

uses: (既存のアクションの使用)

    - uses: actions/checkout@v1

GitHub Actions は既に用意されたアクションや、GitHub Marketplace に公開されている自作アクションを部分的に使用することができます。ここでは actions/checkout と言うアクションを使用し、リポジトリから最新のソースを clone しています。

バックエンドの準備

    - name: バックエンドの準備
      run: composer install

このステップでは run アクションで composer install コマンドを実行しています。

フロントエンドのビルド

    - name: フロントエンドのビルド
      run: npm install && npm run prod
      env:
        MIX_OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }}
        APP_ENV: "production"
        APP_DEBUG: "false"

ここではフロントエンドのビルドを実行しています。run アクションで npm installnpm run prod を実行し、Vue.js のソースをトランスパイルしています。
また、前述のトークンや環境ごとの依存情報などは、本来 .env に記述しますが、リポジトリには追加したくないため環境変数経由で Secrets の値を渡しています。

秘密鍵のコピー

Secrets に保存した SSH プライベートキーを環境変数経由でファイルに書き出しています。パーミッションも忘れずに。

    - name: 秘密鍵のコピー
      run: echo "$SSH_PRIVATE_KEY" > id_rsa && chmod 600 id_rsa
      env:
        SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}

ファイルをサーバに同期

あとは成果物をサーバに rsync+ssh でコピーするだけです。転送量を節約するため .rsyncignore に不要なファイルを定義し、いくつかのフォルダを除外しています。

    - name: ファイルをサーバに同期
      run: rsync -rlOtcv --delete --exclude-from=.rsyncignore -e "ssh -i id_rsa -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no" ./ deploy@xxxxxxx.test:/var/www/vhost/xxxxxxxx/
.rsyncignore
.env
.git
.github
node_modules/
storage/

いざ実行

ワークフローの実行は on で指定したタイミングでの自動起動なので、master ブランチに git push するだけです。
GitHub の Actions タブをクリックすると実行中のタスクをリアルタイムにチェックできます。
github-actions.gif

まとめ

社内の GitLab では、CI/CD をフル活用しておりユニットテストやLintチェックなどを常に自動化していますが、自社サーバに構築しているので環境を自前で構築する必要があったりスペックに悩まされてきましたが、GitHub Actions ではいとも簡単にできてしまいました。Marketplace での Action も充実してくると思うので今後さらに期待ができますね。非常に楽しみです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

OneSignalで特定のユーザーにWEBプッシュ通知を送る方法(PHP編)

OneSignalは無料で使いはじめることができるプッシュ通知配信サービスです。(WEBプッシュは3万ユーザーまで。モバイルは無制限で)
スクリーンショット 2019-11-26 11.41.10.png
こちらを使うと驚くほどかんたんにプッシュ通知を送れました。今回はLaravelにてOneSignalを使ってWEBブラウザ向け(Chrome、Firefox)にプッシュ通知を配信する方法をまとめます。

OneSignalでアプリを作成

スクリーンショット 2019-11-26 11.43.06.png
Website Push を選択。
スクリーンショット 2019-11-26 11.43.51.png
SITE NAMEとSITE URLを入力。
スクリーンショット 2019-11-26 11.44.22.png
ローカルでテストする場合はSITE URLは「https://localhost 」など適宜変更してつくってください。
DEFAULT ICON URLはプッシュ通知の許可ダイアログででてくるアイコンです。
スクリーンショット 2019-12-03 10.11.41.png

Permission Prompt Setupは、最初にサイトを訪れた時にプッシュ通知の許可確認をとる方法を選べます。

スクリーンショット 2019-12-03 10.12.13.png
Welcome Notificationでは許可された場合に送るプッシュ通知を設定。お礼のメッセージなどを。
ほかにもいろいろ設定はありますが、基本的にはこれだけで「SAVE」でOK。

スクリーンショット 2019-12-03 10.15.28.png
次に、「DOWNLOAD ONESIGNAL SDK FILES」を押してファイルをダウンロードして、下記の2つのファイルをサイト直下にアップしてください。
スクリーンショット 2019-12-03 10.17.30.png
最後に、JavaScriptのコードを埋め込めば完了です。

OneSignalでプッシュ通知を送る

これでプッシュ通知を送れるようになりました。OneSignalの管理画面からMessagesを開いてNEW PUSHをクリック。
スクリーンショット 2019-12-03 10.18.50.png
Send to Subscribed Usersで、購読している人に送れます。
スクリーンショット 2019-12-03 10.20.37.png

個別にプッシュ通知を送りたい

全体に送るものだけじゃなく、特定のユーザーにだけ通知を送りたいケースはよくあります。

アプリ側から下記のようにUserIdをセットすることができます。(下記の例はLaravelのbladeに書いています)

<script>
        var OneSignal = window.OneSignal || [];
        OneSignal.push(function () {
            OneSignal.init({
                appId: "ONESIGNALのAPP_ID",
            });

            @if(isset($loginUser))
            //onesignalにuser_idをセット
            OneSignal.on('subscriptionChange', function (isSubscribed) {
                if (isSubscribed == true) {
                    OneSignal.setExternalUserId('{{ $loginUser->id }}');
                    OneSignal.getExternalUserId().then(function (id) {
                    });
                } else if (isSubscribed == false) {
                    OneSignal.removeExternalUserId();
                }
            });
            @endif
        });
</script>

これでOneSignalのユーザーとアプリ側のユーザーを一致させます。

次にプッシュ通知を送りたいところで下記のようにして個別に送ることができます。headingsはタイトル、contentsは内容をいれます。

SendPush.php
$fields = array(
            'app_id' => ONESIGNALのAPP_ID,
            'include_external_user_ids' => [$user_id],
            'url' => アプリのURL,
            'headings' => array('en' => $title),
            'contents' => array('en' => $body)
);

$fields = json_encode($fields);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, "https://onesignal.com/api/v1/notifications");
curl_setopt($ch, CURLOPT_HTTPHEADER,
            array('Content-Type: application/json; charset=utf-8', 'Authorization: Basic '.ONESIGNALのAPP_ID));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HEADER, false);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $fields);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);

$response = curl_exec($ch);
curl_close($ch);

いかがだったでしょうか。OneSignalがあればこれだけかんたんにプッシュ通知を送ることができるようになります。ブラウザだけでなく、スマホアプリにも対応していますのでWEBもアプリもプッシュ通知はOneSignalさえあれば捗ります。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む