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

Laravelで同時ログイン数制御をする

web系の方だと馴染みないかもしれませんが、SIerだと 同時ログイン数を制御したい という要望って多少はあるのかなと思います。

今日、Laravelで同時ログイン数の制御をしたい という相談を受けてサンプルを作ってみたのでその時のメモです。

前提

  • php: 7.3
  • laravel: 5.8
  • mysql: 5.7

ゴール

以下の要件を満たす処理サンプルを実装する

  • 同時にログイン出来るユーザーは2人まで
  • 判定は先勝ち
    • すでに2人ログインしていたらその2人のいずれかがログアウトするまでログイン出来ない

成果物

今回実装したサンプルはこちらに置いてあります
https://github.com/TsukasaGR/laravel-concurrent-login-control-sample

準備

Laravelだと認証の仕組みを作るまでは爆速で出来ますね。
本題ではないですが、さらっと記載しておきます。

1. Laravelインストール

インストール手順はたくさん情報があるのでコマンドだけ記載します。

# Laravelプロジェクトを作成
composer create-project --prefer-dist laravel/laravel laravel-concurrent-login-control-sample
# 作成したディレクトリに移動
cd laravel-concurrent-login-control-sample
# パッケージインストール
composer install
# env書き換え
# --- DBの設定を変更

2. 認証機能をインストール

認証もコマンド実装するだけですね。
まずは認証機能を入れます。

php artisan make:auth

あとはマイグレーションするだけです。

php artisan migrate

これで完成したのでアクセス確認してみると、

スクリーンショット 2019-03-05 22.49.56.png

無事認証機能が追加されているのが確認出来ると思います。

同時ログイン実装

本題です。

同時ログインの実装方法ですが、今回はDBで制御しようと思います。
なんとかセッションの値使って出来ないかなと思ったんですが無理そうなので素直にDBで。

同時ログイン管理用テーブル作成

まずは同時ログイン管理用のテーブルを作成します。

php artisan make:migration create_logged_in_users_table --create=logged_in_users
database/migrations/xxxx_xx_xx_xxxxxx_create_logged_in_users_table.php
<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateLoggedInUsersTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('logged_in_users', function (Blueprint $table) {
            $table->unsignedBigInteger('user_id');
            $table->timestamps();
            $table->foreign('user_id', 'f_logged_in_users_user_id')
                ->references('id')->on('users')->onDelete('cascade');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('logged_in_users');
    }
}

これでマイグレーションを実行すれば完了です。

php artisan migrate

ログイン後に管理用テーブルにデータを追加

制御の前に、ログインしたら管理用テーブルにデータを追加するようにします。

いくつか方法はあると思いますが、今回はmake:authで作成したログイン機能をそのまま流用したいのでAuth/LoginController.phpを修正します。

ポイントは、

  1. 既存の認証処理はそのまま使い回す
  2. ログイン後、画面に遷移する前にデータを差し込む

の2点です。

まず1点目の使い回しですが、 AuthenticatesUsers.login を使いまわしたいので、Traitのuseを以下のように変更します。

app/Http/Controllers/Auth/LoginController.php
class LoginController extends Controller
{

-   use AuthenticatesUsers;
+   use AuthenticatesUsers {
+       // loginメソッドを自身のコントローラーに持たせたいので元のメソッドに別名を付与する
+       login as _login;
+   }
+
+   // 自身のコントローラー側のメソッドを呼び出すようにする
+   public function login(Request $request)
+   {
+       // 処理は元のまま
+       return $this->_login($request);
+   }

これで使いまわしの準備が出来たので、あとはログイン後にデータを追加するだけです。

モデルを作成して、

php artisan make:model LoggedInUser

使えるようにします。

app/LoggedInUser.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class LoggedInUser extends Model
{
    protected $fillable = ['user_id'];
}

モデルが使えるようになったら先程作成したloginメソッドを修正し、最終的にこのような形にします。

app/Http/Controllers/Auth/LoginController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
use App\LoggedInUser;

class LoginController extends Controller
{
    use AuthenticatesUsers {
        login as _login;
    }

    protected $redirectTo = '/home';

    public function __construct()
    {
        $this->middleware('guest')->except('logout');
    }

    public function login(Request $request)
    {
        // ログイン後と画面遷移の間に処理をはさみたいので返り値を一旦変数に入れる
        $response = $this->_login($request);

        // ログイン管理テーブルにデータを追加
        LoggedInUser::create([
            'user_id' => \Auth::id()
        ]);

        // レスポンスを返す
        return $response;
    }
}

これでログイン後に管理テーブルにデータを追加することが出来ました。

ログアウト後に管理テーブルからデータを削除

こちらもログインと同様、既存の認証処理を使いまわします。
考え方は一緒で、以下の通りとします。

app/Http/Controllers/Auth/LoginController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
use App\LoggedInUser;

class LoginController extends Controller
{
    use AuthenticatesUsers {
        login as _login;
        logout as _logout;
    }

    protected $redirectTo = '/home';

    public function __construct()
    {
        $this->middleware('guest')->except('logout');
    }

    public function login(Request $request)
    {
        // ログイン後と画面遷移の間に処理をはさみたいので返り値を一旦変数に入れる
        $response = $this->_login($request);

        // ログイン管理テーブルにデータを追加
        LoggedInUser::create([
            'user_id' => \Auth::id()
        ]);

        // レスポンスを返す
        return $response;
    }

    public function logout(Request $request)
    {
        // ログアウト前にデータを削除
        LoggedInUser::where('user_id', \Auth::id())->delete();

        // 既存のログアウト処理を実行
        return $this->_logout($request);
    }
}

これでログアウト後に管理テーブルからデータを削除することが出来ました。

同時ログイン数を制御する

最後にログイン数の制御です。
ログインのタイミングで判定出来るようにしたいのでloginに対してFormRequestを作成します。

php artisan make:request LoginRequest

今回は入力値とは関係ないのでクロージャでバリデーションロジックを直接記入します。

app/Http/Requests/LoginRequest.php
<?php

namespace App\Http\Requests;

use App\LoggedInUser;
use Illuminate\Foundation\Http\FormRequest;

class LoginRequest extends FormRequest
{
    public function authorize()
    {
        return \Auth::check();
    }

    public function rules()
    {
        return [
            'email' => [
                function ($attribute, $value, $fail) {
                    $numberOfLoginUser = LoggedInUser::count();
                    $isLoggedIn = LoggedInUser::where('user_id', \Auth::id())->exists();
                    // ログインユーザーが2名いる場合で、自身がまだログインしていなければエラーを返す
                    if ($numberOfLoginUser > 2 && !$isLoggedIn) {
                        return $fail(trans('validation.custom.limit-login')); // エラーメッセージは適宜修正
                    }
                }
            ]
        ];
    }
}

これで完成です。

さいごに

今回はあくまでサンプル実装なので、

  • ユーザー登録時も管理用テーブルにデータを入れる必要がある
  • 同一ユーザーが再度ログインした場合はデータ追加でなく更新する必要がある
  • ログアウトせずにユーザーが放置したことを考慮して定期的にリフレッシュする必要がある

等考慮する点はまだまだありますが、それでもここまで実装できていれば要件決めと決まったものをつらつら書くくらいで事足りるのではないかなと思います。

さくっと考えて試しただけですので、もっと良い方法がありましたらぜひご指摘頂けますと幸いです?

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

Laravelで同時ログイン数を制御をする

web系の方だと馴染みないかもしれませんが、SIerだと 同時ログイン数を制御したい という要望って多少はあるのかなと思います。

今日、Laravelで同時ログイン数の制御をしたい という相談を受けてサンプルを作ってみたのでその時のメモです。

前提

  • php: 7.3
  • laravel: 5.8
  • mysql: 5.7

ゴール

以下の要件を満たす処理サンプルを実装する

  • 同時にログイン出来るユーザーは2人まで
  • 判定は先勝ち
    • すでに2人ログインしていたらその2人のいずれかがログアウトするまでログイン出来ない

成果物

今回実装したサンプルはこちらに置いてあります
https://github.com/TsukasaGR/laravel-concurrent-login-control-sample

準備

Laravelだと認証の仕組みを作るまでは爆速で出来ますね。
本題ではないですが、さらっと記載しておきます。

1. Laravelインストール

インストール手順はたくさん情報があるのでコマンドだけ記載します。

# Laravelプロジェクトを作成
composer create-project --prefer-dist laravel/laravel laravel-concurrent-login-control-sample
# 作成したディレクトリに移動
cd laravel-concurrent-login-control-sample
# パッケージインストール
composer install
# env書き換え
# --- DBの設定を変更

2. 認証機能をインストール

認証もコマンド実装するだけですね。
まずは認証機能を入れます。

php artisan make:auth

あとはマイグレーションするだけです。

php artisan migrate

これで完成したのでアクセス確認してみると、

スクリーンショット 2019-03-05 22.49.56.png

無事認証機能が追加されているのが確認出来ると思います。

同時ログイン実装

本題です。

同時ログインの実装方法ですが、今回はDBで制御しようと思います。
なんとかセッションの値使って出来ないかなと思ったんですが無理そうなので素直にDBで。

同時ログイン管理用テーブル作成

まずは同時ログイン管理用のテーブルを作成します。

php artisan make:migration create_logged_in_users_table --create=logged_in_users
database/migrations/xxxx_xx_xx_xxxxxx_create_logged_in_users_table.php
<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateLoggedInUsersTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('logged_in_users', function (Blueprint $table) {
            $table->unsignedBigInteger('user_id');
            $table->timestamps();
            $table->foreign('user_id', 'f_logged_in_users_user_id')
                ->references('id')->on('users')->onDelete('cascade');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('logged_in_users');
    }
}

これでマイグレーションを実行すれば完了です。

php artisan migrate

ログイン後に管理用テーブルにデータを追加

制御の前に、ログインしたら管理用テーブルにデータを追加するようにします。

いくつか方法はあると思いますが、今回はmake:authで作成したログイン機能をそのまま流用したいのでAuth/LoginController.phpを修正します。

ポイントは、

  1. 既存の認証処理はそのまま使い回す
  2. ログイン後、画面に遷移する前にデータを差し込む

の2点です。

まず1点目の使い回しですが、 AuthenticatesUsers.login を使いまわしたいので、Traitのuseを以下のように変更します。

app/Http/Controllers/Auth/LoginController.php
class LoginController extends Controller
{

-   use AuthenticatesUsers;
+   use AuthenticatesUsers {
+       // loginメソッドを自身のコントローラーに持たせたいので元のメソッドに別名を付与する
+       login as _login;
+   }
+
+   // 自身のコントローラー側のメソッドを呼び出すようにする
+   public function login(Request $request)
+   {
+       // 処理は元のまま
+       return $this->_login($request);
+   }

これで使いまわしの準備が出来たので、あとはログイン後にデータを追加するだけです。

モデルを作成して、

php artisan make:model LoggedInUser

使えるようにします。

app/LoggedInUser.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class LoggedInUser extends Model
{
    protected $fillable = ['user_id'];
}

モデルが使えるようになったら先程作成したloginメソッドを修正し、最終的にこのような形にします。

app/Http/Controllers/Auth/LoginController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
use App\LoggedInUser;

class LoginController extends Controller
{
    use AuthenticatesUsers {
        login as _login;
    }

    protected $redirectTo = '/home';

    public function __construct()
    {
        $this->middleware('guest')->except('logout');
    }

    public function login(Request $request)
    {
        // ログイン後と画面遷移の間に処理をはさみたいので返り値を一旦変数に入れる
        $response = $this->_login($request);

        // ログイン管理テーブルにデータを追加
        LoggedInUser::create([
            'user_id' => \Auth::id()
        ]);

        // レスポンスを返す
        return $response;
    }
}

これでログイン後に管理テーブルにデータを追加することが出来ました。

ログアウト後に管理テーブルからデータを削除

こちらもログインと同様、既存の認証処理を使いまわします。
考え方は一緒で、以下の通りとします。

app/Http/Controllers/Auth/LoginController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
use App\LoggedInUser;

class LoginController extends Controller
{
    use AuthenticatesUsers {
        login as _login;
        logout as _logout;
    }

    protected $redirectTo = '/home';

    public function __construct()
    {
        $this->middleware('guest')->except('logout');
    }

    public function login(Request $request)
    {
        // ログイン後と画面遷移の間に処理をはさみたいので返り値を一旦変数に入れる
        $response = $this->_login($request);

        // ログイン管理テーブルにデータを追加
        LoggedInUser::create([
            'user_id' => \Auth::id()
        ]);

        // レスポンスを返す
        return $response;
    }

    public function logout(Request $request)
    {
        // ログアウト前にデータを削除
        LoggedInUser::where('user_id', \Auth::id())->delete();

        // 既存のログアウト処理を実行
        return $this->_logout($request);
    }
}

これでログアウト後に管理テーブルからデータを削除することが出来ました。

同時ログイン数を制御する

最後にログイン数の制御です。
ログインのタイミングで判定出来るようにしたいのでloginに対してFormRequestを作成します。

php artisan make:request LoginRequest

今回は入力値とは関係ないのでクロージャでバリデーションロジックを直接記入します。

app/Http/Requests/LoginRequest.php
<?php

namespace App\Http\Requests;

use App\LoggedInUser;
use Illuminate\Foundation\Http\FormRequest;

class LoginRequest extends FormRequest
{
    public function authorize()
    {
        return \Auth::check();
    }

    public function rules()
    {
        return [
            'email' => [
                function ($attribute, $value, $fail) {
                    $numberOfLoginUser = LoggedInUser::count();
                    $isLoggedIn = LoggedInUser::where('user_id', \Auth::id())->exists();
                    // ログインユーザーが2名いる場合で、自身がまだログインしていなければエラーを返す
                    if ($numberOfLoginUser > 2 && !$isLoggedIn) {
                        return $fail(trans('validation.custom.limit-login')); // エラーメッセージは適宜修正
                    }
                }
            ]
        ];
    }
}

これで完成です。

さいごに

今回はあくまでサンプル実装なので、

  • ユーザー登録時も管理用テーブルにデータを入れる必要がある
  • 同一ユーザーが再度ログインした場合はデータ追加でなく更新する必要がある
  • ログアウトせずにユーザーが放置したことを考慮して定期的にリフレッシュする必要がある

等考慮する点はまだまだありますが、それでもここまで実装できていれば要件決めと決まったものをつらつら書くくらいで事足りるのではないかなと思います。

さくっと考えて試しただけですので、もっと良い方法がありましたらぜひご指摘頂けますと幸いです?

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

Laravel HomesteadがVBoxHeadlessで重い

状況

作業中Macがやたら重いと思ってアクティビティモニタを確認。
VBoxHeadlessというプロセスがCPUの限界を超して101%もCPUを使用している。
笑えない

解決方法

こちらの方法で解決。

  1. vagrant haltで仮想マシンを停止
  2. vagrant plugin install vagrant-vbguestでプラグインをインストール
  3. vagrant plugin update vagrant-vbguestでプラグインをアップデート

拡張機能のバージョンの食い違いで遅くなるらしい。
ゲストプラグインをインストールすると治る。

再びvagrant upでマシンを起動し、アクティビティモニタを確認するとVBoxHeadlessのCPU使用率が6%ほどになっていた。
良かった

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

[初心者]Laravelのインストール

# composerのインストール
satounoMacBook-Pro:~ satou$ brew install homebrew/core/composer
==> Downloading https://getcomposer.org/download/1.8.4/composer.phar
######################################################################## 100.0%
?/usr/local/Cellar/composer/1.8.4: 3 files, 1.8MB, built in 2 minutes 36 seconds
# laravelのインストール
satou$ composer global require "laravel/installer"
Changed current directory to /Users/satou/.composer
Using version ^2.0 for laravel/installer
./composer.json has been created
Loading composer repositories with package information
Updating dependencies (including require-dev)
Package operations: 12 installs, 0 updates, 0 removals
  - Installing symfony/process (v4.2.4): Downloading (100%)         
  - Installing symfony/polyfill-ctype (v1.10.0): Downloading (100%)         
  - Installing symfony/filesystem (v4.2.4): Downloading (100%)         
  - Installing symfony/polyfill-mbstring (v1.10.0): Downloading (100%)         
  - Installing symfony/contracts (v1.0.2): Downloading (100%)         
  - Installing symfony/console (v4.2.4): Downloading (100%)         
  - Installing guzzlehttp/promises (v1.3.1): Downloading (100%)         
  - Installing ralouphie/getallheaders (2.0.5): Downloading (100%)         
  - Installing psr/http-message (1.0.1): Downloading (100%)         
  - Installing guzzlehttp/psr7 (1.5.2): Downloading (100%)         
  - Installing guzzlehttp/guzzle (6.3.3): Downloading (100%)         
  - Installing laravel/installer (v2.0.1): Downloading (100%)         
symfony/contracts suggests installing psr/cache (When using the Cache contracts)
symfony/contracts suggests installing psr/container (When using the Service contracts)
symfony/contracts suggests installing symfony/cache-contracts-implementation
symfony/contracts suggests installing symfony/service-contracts-implementation
symfony/contracts suggests installing symfony/translation-contracts-implementation
symfony/console suggests installing psr/log (For using the console logger)
symfony/console suggests installing symfony/event-dispatcher
symfony/console suggests installing symfony/lock
guzzlehttp/guzzle suggests installing psr/log (Required for using the Log middleware)
Writing lock file
Generating autoload files
# パスを通す?いまいち理解してない
vi ~/.bashrc
satounoMacBook-Pro:~ satou$ source ~/.bashrc
# Laravelのバージョン確認
satounoMacBook-Pro:helloapp satou$ php artisan --version
=>Laravel Framework 5.8.2
# 新規アプリケーションの作成 taskappはアプリケーション名
satounoMacBook-Pro:projects satou$ laravel new taskapp
Crafting application...
Loading composer repositories with package information
Installing dependencies (including require-dev) from lock file
Package operations: 74 installs, 0 updates, 0 removals
  - Installing doctrine/inflector (v1.3.0): Loading from cache
  - Installing doctrine/lexer (v1.0.1): Loading from cache
  - Installing dragonmantank/cron-expression (v2.2.0): Loading from ca

~~~~省略〜〜〜

Discovered Package: laravel/tinker
Discovered Package: nesbot/carbon
Discovered Package: nunomaduro/collision
Package manifest generated successfully.
Application ready! Build something amazing.
# マイグレーションファイルの作成
# --create=tasks はオプション。テーブルの名前を指定できる。
satou$ php artisan make:migration create_tasks_table --create=tasks
Created Migration: 2019_03_05_052059_create_tasks_table
# マイグレート実行、テーブルの作成
$php artisan migrate
# モデルの作成
$ php artisan make:model Task
Model created successfully.
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

初心者のLaravel5.8、Herokuデプロイ備忘録

#gitリポジトリの作成
git init
#githubリモートリポジトリの追加
git remote add origin https://github.com/satou-yuuki/taskapp.git
#githubへpush
git push origin master
#herokuのリモートリポジトリの追加?
$ heroku git:remote -a taskapp-laravel
set git remote heroku to https://git.heroku.com/taskapp-laravel.git
#リモートリポジトリの確認
$ git remote
heroku
origin
#herokuへpush
$ git push heroku master
#herokuアドオン postgreSQLの追加 無料版
$ heroku addons:create heroku-postgresql:hobby-dev
#herokuの環境変数確認
$ heroku config
=== taskapp-laravel Config Vars
APP_KEY:      base64:euuylwlZCqo3Ae9z32Le0gZcQO3653b+E1mABIQMuTs=
DATABASE_URL: postgres://sgahvfpoxsthtn:1680d670e04921e66f1047536b43b9c1b8464400fb7c8397b702c38b9dd99fad@ec2-54-83-196-179.compute-1.amazonaws.com:5432/d6etanlh202i2t
satounoMacBook-Pro:taskapp satou$ heroku run php artisan migrate
#環境変数の登録
$ heroku config:set DB_HOST=ec2-54-83-196-179.compute-1.amazonaws.com
Setting DB_HOST and restarting ⬢ taskapp-laravel... done, v17
DB_HOST: ec2-54-83-196-179.compute-1.amazonaws.com

$ heroku config:set DB_USERNAME=sgahvfpoxsthtn
Setting DB_USERNAME and restarting ⬢ taskapp-laravel... done, v18
DB_USERNAME: sgahvfpoxsthtn

$ heroku config:set DB_PASSWORD=1680d670e04921e66f1047536b43b9c1b8464400fb7c8397b702c38b9dd99fad
Setting DB_PASSWORD and restarting ⬢ taskapp-laravel... done, v19
DB_PASSWORD: 1680d670e04921e66f1047536b43b9c1b8464400fb7c8397b702c38b9dd99fad

$ heroku config:set DB_DATABASE=d6etanlh202i2t
Setting DB_DATABASE and restarting ⬢ taskapp-laravel... done, v20
DB_DATABASE: d6etanlh202i2t
#migrarationsテーブルの作成らしい。 必要なかったかも。
$ heroku run "php artisan migrate:install"
Running php artisan migrate:install on ⬢ taskapp-laravel... up, run.3082 (Free)
#マイグレート実行 マイグレーションファイルがマイグレートされる
$ heroku run "php artisan migrate"
Running php artisan migrate on ⬢ taskapp-laravel... up, run.9664 (Free)
**************************************
*     Application In Production!     *
**************************************

 Do you really wish to run this command? (yes/no) [no]:
 > yse //yseでも通るってことはyだけでokぽい

Migrating: 2014_10_12_000000_create_users_table
Migrated:  2014_10_12_000000_create_users_table
Migrating: 2014_10_12_100000_create_password_resets_table
Migrated:  2014_10_12_100000_create_password_resets_table
Migrating: 2019_03_05_052059_create_tasks_table
Migrated:  2019_03_05_052059_create_tasks_table
#ブラウザ立ち上がってアプリが開く
$heroku open
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[初心者]Laravel5.8、Herokuデプロイ備忘録

#gitリポジトリの作成
git init
#githubリモートリポジトリの追加
git remote add origin https://github.com/satou-yuuki/taskapp.git
#githubへpush
git push origin master
#herokuのリモートリポジトリの追加?
$ heroku git:remote -a taskapp-laravel
set git remote heroku to https://git.heroku.com/taskapp-laravel.git
#リモートリポジトリの確認
$ git remote
heroku
origin
#herokuへpush
$ git push heroku master
#herokuアドオン postgreSQLの追加 無料版
$ heroku addons:create heroku-postgresql:hobby-dev
#herokuの環境変数確認
$ heroku config
=== taskapp-laravel Config Vars
APP_KEY:      base64:euuylwlZCqo3Ae9z32Le0gZcQO3653b+E1mABIQMuTs=
DATABASE_URL: postgres://sgahvfpoxsthtn:1680d670e04921e66f1047536b43b9c1b8464400fb7c8397b702c38b9dd99fad@ec2-54-83-196-179.compute-1.amazonaws.com:5432/d6etanlh202i2t
satounoMacBook-Pro:taskapp satou$ heroku run php artisan migrate
#環境変数の登録
$ heroku config:set DB_HOST=ec2-54-83-196-179.compute-1.amazonaws.com
Setting DB_HOST and restarting ⬢ taskapp-laravel... done, v17
DB_HOST: ec2-54-83-196-179.compute-1.amazonaws.com

$ heroku config:set DB_USERNAME=sgahvfpoxsthtn
Setting DB_USERNAME and restarting ⬢ taskapp-laravel... done, v18
DB_USERNAME: sgahvfpoxsthtn

$ heroku config:set DB_PASSWORD=1680d670e04921e66f1047536b43b9c1b8464400fb7c8397b702c38b9dd99fad
Setting DB_PASSWORD and restarting ⬢ taskapp-laravel... done, v19
DB_PASSWORD: 1680d670e04921e66f1047536b43b9c1b8464400fb7c8397b702c38b9dd99fad

$ heroku config:set DB_DATABASE=d6etanlh202i2t
Setting DB_DATABASE and restarting ⬢ taskapp-laravel... done, v20
DB_DATABASE: d6etanlh202i2t
#migrarationsテーブルの作成らしい。 必要なかったかも。
$ heroku run "php artisan migrate:install"
Running php artisan migrate:install on ⬢ taskapp-laravel... up, run.3082 (Free)
#マイグレート実行 マイグレーションファイルがマイグレートされる
$ heroku run "php artisan migrate"
Running php artisan migrate on ⬢ taskapp-laravel... up, run.9664 (Free)
**************************************
*     Application In Production!     *
**************************************

 Do you really wish to run this command? (yes/no) [no]:
 > yse //yseでも通るってことはyだけでokぽい

Migrating: 2014_10_12_000000_create_users_table
Migrated:  2014_10_12_000000_create_users_table
Migrating: 2014_10_12_100000_create_password_resets_table
Migrated:  2014_10_12_100000_create_password_resets_table
Migrating: 2019_03_05_052059_create_tasks_table
Migrated:  2019_03_05_052059_create_tasks_table
#ブラウザ立ち上がってアプリが開く
$heroku open
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Laravel入門者が マイグレーション処理の流れを クソ丁寧に追ったら、追いきれなくなった話

結論から申し上げます

本記事はなにもまとまっておらず、ただただmigrationに係るファイルを一つ一つ追っていったログ です。

本当は↓↓こんな感じの図を交えつつ

image.png

「Laravelのmigration処理を解き明かしたった!」みたいなタイトルで一世を風靡しようと思ったのですが、挫折しました。

せっかく途中まで調べたので恥を忍んで公開しておこうと思います。
悔しいからまた挑戦します!!

ということで、以下ログです。

migrationってなに?

まずはmigrationの本来の意味を調べます。

mi·gra·tion
1. seasonal movement of animals from one region to another.
2. movement from one part of something to another.

ざっくり"場所から場所への移動"と捉えておけば問題ないでしょう。

次に、公式ドキュメントを確認。
https://readouble.com/laravel/5.5/ja/migrations.html

...横文字だらけでわかりづらいのですが、要するにデータベースの定義をLaravelからできるものです。

  1. Migrationファイルを作って
  2. そのファイルの中身に指令を書き込んで
  3. その指令通りにLaravelにデータベースを操作してもらう

この3パターンで完了です!

ということで、それぞれの処理について詳細をみていきましょう。

登場人物

開発者とLaravel、そしてデータベースです。
image.png

migrationファイルを作る

まずは、マイグレーション処理をするために、migrationファイルを作成しましょう。

image.png

マイグレーションファイルを作るためには、php artisanコマンドを使います。Laravel用のコマンドですね。
(artisanは職人という意味らしいです)

ここでは、testというテーブルを作成したいとします。

root@8c8e2d4a121f:/var/www# php artisan make:migration create_tests_table
Created Migration: 2019_03_01_085359_create_tests_table

すると、database/migrations 配下に新しいファイルができているのがわかります。

ファイルの中身をみてみましょう。

2019_03_01_085359_create_tests_table.php
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateTestsTable extends Migration
{
    public function up()
    {
        Schema::create('tests', function (Blueprint $table) {
            $table->increments('id');
            $table->timestamps();
        });
    }

    public function down()
    {
        Schema::dropIfExists('tests');
    }
}

Migrationクラスを継承する形でCreateTestsTableクラスが作成されていることがわかると思います。

中身をみてみると、up()という関数とdown()という関数があることがわかります。

up()メソッドは新規テーブル、カラム、インデックスをデータベースに追加することができます。down()は逆に、up()メソッドの処理を元に戻す機能を記述します。

そして、両メソッド - up()down() - の中身はSchemaファサードが使えます。Schemaとは何か。それを次にみていきます。

回り道: Migration.phpを覗いてみる

では、少し回り道をしてMigrationクラスを覗いてみましょう。

抽象クラスのようですね。
コメントを日本語化して掲載してみます。

※抽象クラスの解説はこちらの記事が詳しいです。
https://laraweb.net/surrounding/1972/

<?php

namespace Illuminate\Database\Migrations;

abstract class Migration
{
    //データベース接続するために使う名前。
    protected $connection;

    //サポートされている場合、トランザクション内でマイグレーションをラップすることを可能にします。
    public $withinTransaction = true;

    //マイグレーション接続の名前を取得します。
    public function getConnection()
    {
        return $this->connection;
    }
}

Migrationクラス自体は抽象メソッド(abstract)ですが、内部のメソッド・プロパティにはabstractが定義されていないので、これら3つはそのままCreateTestsTableクラスに継承されるわけですね。

up()の中身を記述する - Schemaファサード

さて、ではup()メソッドの中身を改めて確認していきましょう。

2019_03_01_085359_create_tests_table.php
public function up()
{
    Schema::create('tests', function (Blueprint $table) {
        $table->increments('id');
        $table->timestamps();
    });
}

ファサードってなんぞ?となると思いますが、ここでファサードの元々の意味をまた確認してみましょう。

fa·cade
the face of a building, especially the principal front that looks onto a street or open space.

建物の正面、という意味です。詳細はこちらの記事がわかりやすかったです。

https://qiita.com/yousan/items/18dab4dbac4a27ce3662

Laravelのファサードとは、建築用語での「建築物 の 正面部分」という意味が、プログラミングでの「ライブラリ(建築物)の呼び出し部分(正面部分)」という意味になるわけですね。

ということで、Schemaファサード(ここではcreate)についてみていきましょう

Schema::create('tests', function (Blueprint $table) {
    $table->increments('id');
    $table->timestamps();
});

::はスコープ定義演算子です。クラスをインスタンス化しなくても、クラス内のプロパティを扱うことができます
http://php.net/manual/ja/language.oop5.paamayim-nekudotayim.php

つまり、Schemaのcreateメソッドを実行して、という意味でそのまんま捉えてOKです。

Schema::create([テーブル名], [Blueprintオブジェクトを受け取るクロージャ関数] )

という形式で書かれています。

回り道:createを探す旅

さて、ここでまた回り道をしてcreateがどこに記述されているかを確認してみましょう。

Schemaクラスを確認してみます。

vendor/laravel/framework/src/Illuminate/Support/Facades/Schema.php

Schema.php
<?php

namespace Illuminate\Support\Facades;

/**
 * @method static \Illuminate\Database\Schema\Builder create(string $table, \Closure $callback)
 * ...
 * @see \Illuminate\Database\Schema\Builder
 */
class Schema extends Facade
{
    /**
     * 接続のために Schema Builder インスタンスを取得する。
     * @return \Illuminate\Database\Schema\Builder
     */
    public static function connection($name)
    {
        return static::$app['db']->connection($name)->getSchemaBuilder();
    }

    /**
     * Schema Builder インスタンスを デフォルトの接続として取得する。
     * @return \Illuminate\Database\Schema\Builder
     */
    protected static function getFacadeAccessor()
    {
        return static::$app['db']->connection()->getSchemaBuilder();
    }
}

...あれ?createがない?
ということで、継承元のFacadeクラスを確認してみるが、ない。

あちこち探してみると、こんな場所にいました。

vendor/laravel/framework/src/Illuminate/Database/Schema/Builder.php

Builder.php
<?php

namespace Illuminate\Database\Schema;

use Closure;
use LogicException;
use Illuminate\Database\Connection;

class Builder
{
    ...
    /**
     * schema上にテーブルを作成する。
     */
    public function create($table, Closure $callback)
    {
        $this->build(tap($this->createBlueprint($table), function ($blueprint) use ($callback) {
            $blueprint->create();

            $callback($blueprint);
        }));
    }
    ...
}

create($table, Closure $callback)となっており、先ほどの定義と一致しますね。

でもBuilderクラスなんてSchemaクラスにはどこにも出てきません。
では、どこで接続するのか?

...それは、後半のお楽しみ。

閑話休題。
Facadeクラスにもconnection()メソッドはありませんでした。
するとどういうことが起こるのか?

Facadeクラス内の__callStaticに注目してください。

vendor/laravel/framework/src/Illuminate/Support/Facades/Facade.php

Facade.php
<?php

namespace Illuminate\Support\Facades;
...

abstract class Facade
{
    ...

    public static function __callStatic($method, $args)
    {
        $instance = static::getFacadeRoot();

        if (! $instance) {
            throw new RuntimeException('A facade root has not been set.');
        }

        return $instance->$method(...$args);
    }
}

__callStaticマジックメソッドと呼ばれるPHPの文法の一種で、アンダーライン2つ(__)から始まる関数名は、ある条件下の時に発動するメソッドです。
http://php.net/manual/ja/language.oop5.magic.php

__callStaticは存在しない静的メソッドが呼ばれたら実行されます。

ここで振り返ってみてください。

Schema::createcreateメソッドを呼び出しているのに、Schemaクラスにも、Facadeクラスにもcreateが存在しません。
...そう、つまり、__callStaticが実行されるわけです!

ということで、__callStaticの中身を見ましょう。

すると、

$instance = static::getFacadeRoot();

という処理を行なっていますね。

ちなみにここでstatic::が使用されていますが、これは遅延静的束縛と呼ばれるものです。
ここでは説明を割愛しますが、こちらのサイトがわかりやすいです。
https://ackintosh.github.io/blog/2013/08/25/late-static-binding/

では、getFacadeRootに関連する場所を探してみましょう。

Facade.php
    /**
     * ファサードの裏にあるルートオブジェクトを取得する
     */
    public static function getFacadeRoot()
    {
        return static::resolveFacadeInstance(static::getFacadeAccessor());
    }

    /**
     * ファサードのルートのインスタンスをコンテナから解決する
     */
    protected static function resolveFacadeInstance($name)
    {
        if (is_object($name)) {
            return $name;
        }

        if (isset(static::$resolvedInstance[$name])) {
            return static::$resolvedInstance[$name];
        }

        return static::$resolvedInstance[$name] = static::$app[$name];
    }

まずはstatic::resolveFacadeInstance(static::getFacadeAccessor())という処理をさせたいわけですね。
ここで着目したいのがgetFacadeAccessor()はどこにあるのか、という話。
これは、はるか昔に書いた、Schemeクラスの中に出てきます。

Schema.php
<?php

namespace Illuminate\Support\Facades;

class Schema extends Facade
{
    protected static function getFacadeAccessor()
    {
        return static::$app['db']->connection()->getSchemaBuilder();
    }
}

$appはFacade側で定義されています(ややこしい...)
再び戻って確認します。
すると、こんな関数が。

Facade.php
    public static function setFacadeApplication($app)
    {
        static::$app = $app;
    }

どうやら、bootstrap中にこのsetFacadeApplicationメソッドを使って値を設定するようです。

https://daylerees.com/laravel-facades/

The first static property contains a reference to the Laravel IoC container. It’s set during the framework bootstrap process using the setFacadeApplication() method found in the full example above.

で、このプロパティはabstract facadeにくっついているから、Facadeクラスを継承する全てのクラスに適用されるらしい。なるほど。

ということで、コメント部にあるように、

Facade.php
    /**
     * The application instance being facaded.
     *
     * @var \Illuminate\Contracts\Foundation\Application
     */
    protected static $app;

という定義で良いようだ。
Illuminate\Contracts\Foundation には Application.phpがある。
そして、これがサービスコンテナの操作をすることができるインスタンスなのだ。
やっとゴールが見えてきた気がするぞ!!

さて、ここで更に寄り道。

        return static::$app['db']->connection()->getSchemaBuilder();

これ、なんで配列になっているか、気になりませんか?
クッソ調べまくって見つけました。
https://stackoverflow.com/questions/50530710/how-laravel-uses-object-as-an-array-in-facade-class?rq=1

PHPにあるArrayAccessインターフェイスを使っているからでした。
http://php.net/manual/ja/class.arrayaccess.php
スッキリした...!

なので、
$app['db']インスタンスを参照ているわけです。

つまり、下記とニアリーイコールです。
DBファサードは Illuminate\Database\DatabaseManager を参照します。

ここのファサードクラス一覧に書いてあります。
https://readouble.com/laravel/5.5/ja/facades.html

ということで、当該のDatabaseManager.phpをみてみましょう

DatabaseManager.php
<?php

namespace Illuminate\Database;

use PDO;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use InvalidArgumentException;
use Illuminate\Database\Connectors\ConnectionFactory;

/**
 * @mixin \Illuminate\Database\Connection
 */
class DatabaseManager implements ConnectionResolverInterface
{
    ...

    /**
     * The application instance.
     *
     * @var \Illuminate\Foundation\Application
     */
    protected $app;

    ...

    /**
     * 新規の DataBaaseManagerインスタンスを作成する
     * @param  \Illuminate\Foundation\Application  $app
     * @param  \Illuminate\Database\Connectors\ConnectionFactory  $factory
     * @return void
     */
    public function __construct($app, ConnectionFactory $factory)
    {
        $this->app = $app;
        $this->factory = $factory;
    }

    ...

    /**
     * Database Connection インスタンス を取得する。
     * @param  string  $name
     * @return \Illuminate\Database\Connection
     */
    public function connection($name = null)
    {
        list($database, $type) = $this->parseConnectionName($name);

        $name = $name ?: $database;

        // If we haven't created this connection, we'll create it based on the config
        // provided in the application. Once we've created the connections we will
        // set the "fetch mode" for PDO which determines the query return types.
        if (! isset($this->connections[$name])) {
            $this->connections[$name] = $this->configure(
                $this->makeConnection($database), $type
            );
        }

        return $this->connections[$name];
    }

    ...

}

先ほど言ったように、DBファサードはインスタンス化されているため、__constructメソッドは実行ずみです。

では、connection()メソッドの内部を深掘りしていきましょう。
すると、PHP弱者には初めましての文法がたくさん...

確認していきます。

list($database, $type) = $this->parseConnectionName($name);

これは、List関数です。右辺の配列を順に左側の変数に代入してくれます。
http://php.net/manual/ja/function.list.php

https://www.sejuku.net/blog/24406
こちらの例がわかりやすいですね。引用します。

$fruits_array = ['りんご', 'オレンジ', 'メロン'];

//listの複数変数を配列から代入
list($apple, $orange, $melon) = $fruits_array;

今回の例では、parseConnectionName($name)で取得した配列の結果を順に代入している、ということのようですね。
parseってことはもともと繋がって入力されたものを分割するんでしょうね。
$nameは今回入力がないため、connectionメソッドのargsデフォルト値である$name = nullが適用されます。

DatabaseManager.php
    protected function parseConnectionName($name)
    {
        $name = $name ?: $this->getDefaultConnection();

        return Str::endsWith($name, ['::read', '::write'])
                            ? explode('::', $name, 2) : [$name, null];
    }

出たな、三項演算子!

?:と書かれると「ん?」となりますが、怖がることはありません。

$name = $name ?: $this->getDefaultConnection();

$name = $name ? $name : $this->getDefaultConnection();

と同義です。

もっというと、

if ( $name ) {
    $name = $name;
} else {
    $name = $this->getDefaultConnection();
}

です。
ということで、今回は前述のように$namenullなので、getDefaultConnection()メソッドが発動します。

DatabaseManager.php
    public function getDefaultConnection()
    {
        return $this->app['config']['database.default'];
    }

appは$appが代入されている箇所があります。つまり、先ほどと同じ、\Illuminate\Foundation\Applicationのインスタンスです。

では、$this->app['config']['database.default']とはなんでしょうか。

またこちらをreferします。
https://readouble.com/laravel/5.5/ja/facades.html

ConfigIlluminate\Config\Repository らしい。
というところまではわかったのですが、どこの値を参照しているかまでは追えず....
StackOverflowにも投げてみようかと思いまうす。
わかるかた、コメントいただけると嬉しいです。

一度立ち戻って、parseConnectionName($name)はコメント通り

Parse the connection into an array of the name and read / write type.

つまり、名前と読み書き権限の配列を返してくれるメソッドだと理解して次に進みましょう。

随分上になってしまったのでconnection()メソッドを再掲します。

connection()
    /**
     * Database Connection インスタンス を取得する。
     * @param  string  $name
     * @return \Illuminate\Database\Connection
     */ 
    public function connection($name = null)
    {
        list($database, $type) = $this->parseConnectionName($name);

        $name = $name ?: $database;

        // If we haven't created this connection, we'll create it based on the config
        // provided in the application. Once we've created the connections we will
        // set the "fetch mode" for PDO which determines the query return types.
        if (! isset($this->connections[$name])) {
            $this->connections[$name] = $this->configure(
                $this->makeConnection($database), $type
            );
        }

        return $this->connections[$name];
    }

parseConnectionName($name)のおかげで、名前読み書き権限$database$typeに格納することができました。
そして、もしconnections[$name]が存在しなかったら、configure()メソッドを実行して、設定をしてくれます。

DatabaseManager.php
    /**
     * DatabaseConnectionインスタンスを準備する
     *
     * @param  \Illuminate\Database\Connection  $connection
     * @param  string  $type
     * @return \Illuminate\Database\Connection
     */
    protected function configure(Connection $connection, $type)
    {
        $connection = $this->setPdoForType($connection, $type);

        // First we'll set the fetch mode and a few other dependencies of the database
        // connection. This method basically just configures and prepares it to get
        // used by the application. Once we're finished we'll return it back out.
        if ($this->app->bound('events')) {
            $connection->setEventDispatcher($this->app['events']);
        }

        // Here we'll set a reconnector callback. This reconnector can be any callable
        // so we will set a Closure to reconnect from this manager with the name of
        // the connection, which will allow us to reconnect from the connections.
        $connection->setReconnector(function ($connection) {
            $this->reconnect($connection->getName());
        });

        return $connection;
    }

そして、注目して欲しいのが戻り値の型。

@return \Illuminate\Database\Connection

となっていますね。

Connectionクラスを確認してみましょう。

vendor/laravel/framework/src/Illuminate/Database/Schema/Connection.php

Connection.php
<?php

namespace Illuminate\Database;
...
use Illuminate\Database\Schema\Builder as SchemaBuilder;
...
class Connection implements ConnectionInterface
{
    ....
    /**
     * 接続のために SchemaBuilderインスタンスを作成する
     *
     * @return \Illuminate\Database\Schema\Builder
     */
    public function getSchemaBuilder()
    {
        if (is_null($this->schemaGrammar)) {
            $this->useDefaultSchemaGrammar();
        }

        return new SchemaBuilder($this);
    }
    ...
}

なんか見覚えのある奴がいませんか?

...そう、'Builder'クラスだ!

覚えていない人のためにもう一度。create()メソッドがいる場所でしたね。

vendor/laravel/framework/src/Illuminate/Database/Schema/Builder.php

Builder.php
<?php

namespace Illuminate\Database\Schema;

use Closure;
use LogicException;
use Illuminate\Database\Connection;

class Builder
{
    ...
    /**
     * schema上にテーブルを作成する。
     */
    public function create($table, Closure $callback)
    {
        $this->build(tap($this->createBlueprint($table), function ($blueprint) use ($callback) {
            $blueprint->create();

            $callback($blueprint);
        }));
    }
    ...
}

このBuilderクラスがConnectionクラスでuse Illuminate\Database\Schema\Builder as SchemaBuilder;として呼び出されています。つまり、SchemaBuilderとして先ほどのBuilderクラスが使われているわけですね。
そして、getSchemaBuilder()というメソッドでSchemaBuilder, すなわち Builderクラスのインスタンスを作成しているんですね!
(ちなみに名前をわざわざSchemaBuilderとしているのは、 ...\ Query \Builder もuseしているためです。こちらをQueryBuilderとして名前を分けています。)

SchemaBuilder($this)

$thisとは、Connectionクラス自身を指します。

では、改めてSchema/Builder.phpの__constructor(クラスがインスタンス化された時に呼び出されるメソッド)を確認しましょう。

Builder.php
    public function __construct(Connection $connection)
    {
        $this->connection = $connection;
        $this->grammar = $connection->getSchemaGrammar();
    }

ここで注目するのはgetSchemaGrammer()です。Builderをインスタンス化するときに、Schemaの文法を取得しろ、と命令しているわけです。
怪しくなってきましたね。

ということで、再びConnectionクラスに戻り、getSchemaGrammer()を確認します。

Connection.php
    public function getSchemaGrammar()
    {
        return $this->schemaGrammar;
    }

このschemaGrammerは setSchemaGrammar()によって定義されます。

Connection.php
    public function setSchemaGrammar(Schema\Grammars\Grammar $grammar)
    {
        $this->schemaGrammar = $grammar;
    }

...頭がごっちゃごちゃになったので一旦ここまでで勘弁してください....

参考

How Laravel Facades Work and How to Use Them Elsewhere
ファサードの概念を掴むのにめっちゃわかりやすい
Laravel Facades
これもファサードの解説
PHPフレームワーク Laravel Webアプリケーション開発 バージョン5.5 LTS対応
サービスコンテナの概要はこの本。

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

LaravelのEloquentでカラムの変更情報を取得

カラムが変更されたか判断する必要があり、動作を確認したメモ。

>>> $group = App\Group::all()->last();
=> App\Group {#49052
     id: 10,
     name: "Aグループ",
     mailaddress: "hoge@example.com",
     prefectures_id: 13,
     city: "千代田区",
     address: "大手町一丁目",
     telephone: "03XXXXXXXX",
     created_at: "2019-01-09 12:09:56",
     updated_at: "2019-01-09 12:09:56",
   }

値を変更してみる

>>> $group->name = "hoge";
=> "hoge"
>>> $group->telephone = "03YYYYYYYY"
=> "03YYYYYYYY"

isDrityメソッドで確認。変更されてるのでtrueですね。

>>> $group->isDirty();
=> true

カラム絞っても確認できます。

>>> $group->isDirty("name");
=> true
>>> $group->isDirty("city");
=> false
>>> $group->isDirty(["name", "city"]);
=> true

変更された内容はgetDirtyメソッドで確認。
変更されたnameとtelephoneの内容が確認できる。

>>> $group->getDirty();
=> [
     "name" => "hoge",
     "telephone" => "03YYYYYYYY",
   ]

変更前の値が知りたい場合にはgetOriginalメソッド

>>> $group->getOriginal();
=> [
     "id" => 10,
     "name" => "Aグループ",
     "mailaddress" => "hoge@example.com",
     "prefectures_id" => 13,
     "city" => "千代田区",
     "address" => "大手町一丁目",
     "telephone" => "03XXXXXXXX",
     "created_at" => "2019-01-09 12:09:56",
     "updated_at" => "2019-01-09 12:09:56",
   ]
>>> $group->getOriginal("name");
=> "Aグループ"

saveしてみる。
isDirtyはfalseを返すようになり、getOriginalで確認しても更新後の値に書き換わっている。

>>> $group->save();
=> true
>>> $group->isDirty();
=> false
>>> $group->getOriginal("name");
=> "hoge"
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Laravel で最低限書いてあると嬉しいテスト

TLDR;

簡単なWEBアプリケーションを書くときに最低限書いておいて良かったと思うテストの紹介
リポジトリは下記
https://github.com/katsuren/laravel-test

前提

Not SPA - JS が絡むものは NodeJS のテストフレームワーク使ったほうがよい
Not SPA でも JS が多い場合は dusk や codeception を使ったほうが良いと思う
Laravel についての知識、下記は知っているものとして解説する
factory、リレーションあたり
feature/unit テストについて

サンプルアプリケーションの概要

CDの管理アプリケーション作る
アーティストとそのアルバム、曲名を管理できるアプリケーション
簡単のためにログインは省く

テスト戦略的なもの

モデルが利用可能なこと、リレーションが正しいことをおさえる
基本的なリソースの CRUD のテストをおさえる
具体的には、各ルーティングがエラーで落ちていないことを確認する
また、各機能画面にてあるべき input があることを確認する (nameで確認)

その他、このテストでは RefreshDatabase を利用するので、.env.testing を用意して別DBでテストしたほうが良い。
さもなくばテストのたびにDBの中身がクリアされる。

M(モデル)のテスト

・マイグレーションを書く(テーブル定義)
・ファクトリーを書く(レコード生成の定義)
・ファクトリーが使えることを確認

これで最低限モデルの定義が間違っていないことが保証される

具体的なファイルは下記のようになる

# database/migrations/2019_03_04_123520_create_artists_table.php
<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateArtistsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('artists', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('name');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('artists');
    }
}
# app/Eloquents/Artist.php
<?php

namespace App\Eloquents;

use Illuminate\Database\Eloquent\Model;

class Artist extends Model
{
    protected $guarded = [
        'id',
    ];
}
# database/factories/ArtistFactory.php
<?php

use App\Eloquents\Artist;
use Illuminate\Support\Str;
use Faker\Generator as Faker;

$factory->define(Artist::class, function (Faker $faker) {
    return [
        'name' => $faker->name,
    ];
});

テストは下記

# tests/Unit/Eloquents/ArtistTest.php
<?php

namespace Tests\Unit\Eloquents;

use App\Eloquents\Artist;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ArtistTest extends TestCase
{
    use RefreshDatabase;

    public function testFactoryable()
    {
        $eloquent = app(Artist::class);
        $this->assertEmpty($eloquent->get()); // 初期状態では空であることを確認
        $entity = factory(Artist::class)->create(); // 先程作ったファクトリーでレコード生成
        $this->assertNotEmpty($eloquent->get()); // 再度getしたら中身が空ではないことを確認し、ファクトリ可能であることを保証
    }
}

M(モデル)のテスト2(リレーション)

アーティストは複数のアルバムを保持することができ、
アルバムはアーティストに所属している、ことを確認する
これをテストすることで、リレーションが保証される。

各モデルは下記のようになる

# app/Eloquents/Artist.php
<?php

class Artist extends Model
{
// 下記追加
    public function albums()
    {
        return $this->hasMany(Album::class);
    }
}
# app/Eloquents/Album.php
<?php

namespace App\Eloquents;

use Illuminate\Database\Eloquent\Model;

class Album extends Model
{
    protected $guarded = [
        'id',
    ];

    public function artist()
    {
        return $this->belongsTo(Artist::class);
    }
}

テストは次のようになる

# tests/Unit/Eloquents/ArtistTest.php
<?php

namespace Tests\Unit\Eloquents;

use App\Eloquents\Album;
use App\Eloquents\Artist;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ArtistTest extends TestCase
{
// 下記のテスト追加
    public function testArtistHasManyAlbums()
    {
        $count = 5;
        $artistEloquent = app(Artist::class);
        $albumEloquent = app(Album::class);
        $artist = factory(Artist::class)->create(); // アーティストを作成
        $albums = factory(Album::class, $count)->create([
            'artist_id' => $artist->id,
        ]); // アーティストに紐づくアルバムレコードを作成 (create の引数に指定するとその値でデータ作成される)
        // refresh() で再度同じレコードを取得しなおし、リレーション先の件数が作成した件数と一致することを確認し、リレーションが問題ないことを保証
        $this->assertEquals($count, count($artist->refresh()->albums));
    }
}
# tests/Unit/Eloquents/AlbumTest.php
<?php

namespace Tests\Unit\Eloquents;

use App\Eloquents\Album;
use App\Eloquents\Artist;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class AlbumTest extends TestCase
{
    use RefreshDatabase;

    public function testFactoryable()
    {
        $eloquent = app(Album::class);
        $this->assertEmpty($eloquent->get());
        $entity = factory(Album::class)->create();
        $this->assertNotEmpty($eloquent->get());
    }

    public function testAlbumBelongsToArtist()
    {
        $albumEloquent = app(Album::class);
        $artistEloquent = app(Artist::class);
        $artist = factory(Artist::class)->create();
        $album = factory(Album::class)->create([
            'artist_id' => $artist->id,
        ]);
        $this->assertNotEmpty($album->artist);
    }
}

VC (ビュー・コントローラー)のテスト

具体的なソースコードは長くなってしまうので概要のリポジトリを参照のこと。
見た目は変わりやすいのでここではテスト対象にしない。(崩れやすいテスト) もし必要な場合は dusk などでスクリーンショット比較などをする。
簡単な CRUD コントローラーで、各ルーティングにアクセスして 4xx または 5xx が出ないことを確実にする。
リソースをそれぞれ簡単に説明すると

index = リソースの一覧表示の画面、検索フォームなどがある場合が多い
show = 指定のリソースの詳細表示画面、
create = 新規作成画面、フォームがあることを確認する
store = 新規作成処理(POST)
edit = リソースの更新設定画面、フォームがあることを確認する
update = 更新処理(PUT)
destroy = 削除処理(DELETE)

となる。
各画面の機能として、input の name="xxx" がすべて表示されることを assert することで必ず input があるということを保証する。
これにより、最低限入力フォームの存在とその処理が保証されることになる。
例えば ArtistsController は下記のような実装だとする

# app/Http/Controllers/ArtistsController.php
<?php

namespace App\Http\Controllers;

use App\Eloquents\Artist;
use App\Http\Requests\ArtistRequest;

class ArtistsController extends Controller
{
    protected $artistEloquent;

    public function __construct(Artist $artistEloquent)
    {
        $this->artistEloquent = $artistEloquent;
    }

    public function index()
    {
        $artists = $this->artistEloquent->pimp(request()->input('search', []))->get();
        return view('artists.index')->with([
            'artists' => $artists,
        ]);
    }

    public function show($id)
    {
        $artist = $this->artistEloquent->find($id);
        return view('artists.show')->with([
            'artist' => $artist,
        ]);
    }

    public function create()
    {
        return view('artists.edit')->with([
            'isCreate' => true,
        ]);
    }

    public function store(ArtistRequest $request)
    {
        $artist = $this->artistEloquent->create($request->input('artist'));
        return redirect('/artists/' . $artist->id)->with('flash_message', 'アーティストを作成しました');
    }

    public function edit($id)
    {
        $artist = $this->artistEloquent->find($id);
        return view('artists.edit')->with([
            'isCreate' => false,
            'artist' => $artist,
        ]);
    }

    public function update(ArtistRequest $request, $id)
    {
        $artist = $this->artistEloquent->find($id);
        $artist->fill($request->input('artist'));
        $artist->save();
        return redirect('/artists/' . $id)->with('flash_message', 'アーティストを更新しました');
    }

    public function destroy($id)
    {
        $artist = $this->artistEloquent->find($id);
        $artist->delete();
        return redirect('/artists')->with('flash_message', 'アーティストを削除しました');
    }
}

それに対するテストは下記のように HTTP アクセスで確認する
基本的にHTMLの検査は文字列の検査しかできない、つまりdocumentの要素をリッチに検索するAPIは提供されておらず、その場合はduskを利用する。
簡素に変更がされないものを対象に検査する。具体的には下記の項目。
・200(HTTP OK)が返ること、またはリダイレクト(301 or 302)が返ること。
・フォームが存在する場合はそのアクションURLが存在すること、正しいこと。
・フォームが存在する場合はその項目(name=xxx)すべてが存在すること。

# tests/Feature/ArtistsControllerTest.php
<?php

namespace Tests\Feature;

use App\Eloquents\Artist;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;

class ArtistsControllerTest extends TestCase
{
    use RefreshDatabase;

    public function testCanSeeIndex()
    {
        $response = $this->get('/artists');
        $response->assertOk() // インデックスにアクセスして 200 が返ることを確認
            ->assertSee('action="/artists"') // 検索フォームのアクションがあることを確認
            ->assertSee('name="search[name]"'); // search[name] という検索項目があることを確認
    }

    public function testCanSeeShow()
    {
        $artist = factory(Artist::class)->create();

        $response = $this->get('/artists/' . $artist->id);
        $response->assertOk()
            ->assertSee($artist->name); // 詳細ページでレコード固有の文字列が閲覧できることを確認
    }

    public function testCanCreate()
    {
        $response = $this->get('/artists/create');
        $response->assertOk()
            ->assertSee('action="/artists"') // 作成フォームがあること
            ->assertSee('name="artist[name]"'); // 名前入力フォームがあること

        $name = Str::random(10);
        $response = $this->from('/artists/create')->post('/artists', [
            'artist' => [
                'name' => $name,
            ],
        ]); // アクションURLに対してポストしてリダイレクトされること
        $response->assertRedirect();

        $this->assertDatabaseHas('artists', ['name' => $name]); // DBにデータが作成されていることで新規作成処理を保証する
    }

    public function testCanUpdate()
    {
        $artist = factory(Artist::class)->create();
        $response = $this->get('/artists/' . $artist->id . '/edit');
        $response->assertOk()
            ->assertSee('action="/artists/' . $artist->id . '"') // 編集フォームがあること
            ->assertSee('name="artist[name]"'); // 名前入力フォームがあること

        $name = Str::random(10);
        $response = $this->from('/artists/' . $artist->id . '/edit')->put('/artists/' . $artist->id, [
            'artist' => [
                'name' => $name,
            ],
        ]); // アクションURLに対してポストしてリダイレクトされること
        $response->assertRedirect();

        $this->assertDatabaseHas('artists', ['name' => $name]); // レコードに変更した名前のレコードがあることで編集処理を保証する
    }

    public function testCanDelete()
    {
        $artist = factory(Artist::class)->create();
        $this->assertDatabaseHas('artists', ['id' => $artist->id]); // テスト前にレコードがDBに存在することを確認

        $response = $this->from('/artists')->delete('/artists/' . $artist->id);
        $response->assertRedirect();

        $this->assertDatabaseMissing('artists', ['id' => $artist->id]); // DELETEルートにアクセス後、DBからレコードがなくなっていることで削除処理を保証する
    }
}

最後にまとめ

上記のものではもちろんテストが十分というわけではないが、最低限書いてあると嬉しい。レビューもテスト書かれてないよりはあったほうがいい。
あと、実際のアプリケーションではもちろんこのように単純ではない場合が多いので、必要に応じて書き足す必要がある。というかこのサンプルでも色々足りていないのは承知している。
簡単に書いたつもりだが意外と時間かかった。
しかし、各モデルがしっかり使えるものであることの保証と、各ルーティングにアクセス可能であることが証明できていれば、多少手荒な更新があってもなんとかやっていけるはず。テストどう書いていいかわからない、方針がたたないような初学者の人の助けになればと思う。

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