PHP
laravel

Laravelお役立ちネタ6連発

この記事は Laravel/Vue.js勉強会#3 - connpass のLTネタです。

1〜3だけ新ネタ

  1. マイグレーションファイルを整理整頓して書きたい
  2. Dockerコンテナ上でのテストのマイグレーションが遅すぎてつらい
  3. カウンターキャッシュを出来る限り正確にしたい
  4. ずれない・速いページネーションが欲しい
  5. ルートモデルバインディングでメソッドを共有したい
  6. ユニークキー制約と論理削除を両立させたい

:question: お前誰


  • Qiitaマン
  • 知恵袋マン

  • 新卒で入ったITベンチャーが2ヶ月で🔥買収🔥されて無くなった
  • 今ここの26階で働いてます👆🏢
  • KotlinやRustに憧れつつもやっぱり💕PHP💕🌟JavaScript🌟が好き

  • 最近ツイートの9割が暗号通貨 ₿ 💰
  • Bitcoin Cash is Bitcoin (失笑)

:computer: 今業務でやっている開発

フロントエンド

  • React + Redux + Redux-Saga でSPA作るよ

バックエンド

  • Laravel でAPI作るよ

_人人人人人人人人人_
> Vueは使いません <
 ̄Y^Y^Y^Y^Y^Y^Y^Y ̄


React派なので(失笑)

それではさっそく


1. マイグレーションファイルを整理整頓して書きたい

つらいところ

  • そもそも書くのだるい…
  • ファイル名の日付がごちゃごちゃしてて見にくい…
  • 外部キーが絡み合っていてカオス…

↓ こういう名前の羅列なんとかしろよ
2018_01_22_231514_create_users_table.php


自分はどうやっているかというと

  • マークダウンで書いた仕様書をスクレイピングして自動生成 ✄
  • 全部1970年1月1日に書いたことにする 📅
  • 「カラム定義」「外部キー制約定義」を別ファイルに書く

マークダウンをこんな感じで書く↓

articles

記事を定義します。

...


「カラム定義」だけを書く

(外部キー関係ないインデックスもこっちでOK)

ベース
$table->bigIncrements('id');
$table->unsignedBigInteger('user_id');
$table->unsignedBigInteger('salon_id');
$table->unsignedBigInteger('profile_id');
$table->string('title')->default('')->comment('タイトル');
$table->mediumText('body')->comment('本文');
$table->datetime('created_at')->default(DB::raw('CURRENT_TIMESTAMP'));
$table->datetime('updated_at')->default(DB::raw('CURRENT_TIMESTAMP on update CURRENT_TIMESTAMP'));
$table->datetime('deleted_at')->nullable();

「外部キー制約定義」だけを書く

外部キー制約
$table->foreign('user_id')->references('id')->on('users');
$table->foreign('salon_id')->references('id')->on('salons');
$table->foreign('profile_id')->references('id')->on('profiles');

このMarkdownを †オレオレArtisanコマンド でスクレイピングします!

※ みなさん秘伝のソースを頑張って用意してください


:pencil: 以下のようなファイルを自動生成

記事テーブルの場合

  • 1970_01_01_000000_create_articles_table.php
  • 1970_01_01_000001_constrain_articles_table.php

class CreateArticlesTable extends Migration
{
    protected $table = 'articles';
    protected $comment = '記事を定義します。';

    public function up()
    {
        Schema::create($this->table, function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->unsignedBigInteger('user_id');
            $table->unsignedBigInteger('salon_id');
            $table->unsignedBigInteger('profile_id');
            $table->string('title')->default('')->comment('タイトル');
            $table->mediumText('body')->comment('本文');
            $table->datetime('created_at')->default(DB::raw('CURRENT_TIMESTAMP'));
            $table->datetime('updated_at')->default(DB::raw('CURRENT_TIMESTAMP on update CURRENT_TIMESTAMP'));
            $table->datetime('deleted_at')->nullable();
        });
        DB::statement("ALTER TABLE `$this->table` COMMENT ?", [$this->comment]);
    }

class ConstrainArticlesTable extends Migration
{
    protected $table = 'articles';

    public function up()
    {
        Schema::table($this->table, function (Blueprint $table) {
            $table->foreign('user_id')->references('id')->on('users');
            $table->foreign('salon_id')->references('id')->on('salons');
            $table->foreign('profile_id')->references('id')->on('profiles');
        });
    }

    public function down()
    {
        Schema::table($this->table, function (Blueprint $table) {
            $table->dropForeign(['profile_id']);
            $table->dropForeign(['salon_id']);
            $table->dropForeign(['user_id']);
        });
    }
}

他にもいろいろなテーブルに対して実施

  • 1970_01_01_000000_create_users_table.php
  • 1970_01_01_000000_create_salons_table.php
  • 1970_01_01_000000_create_profiles_table.php
  • 1970_01_01_000001_constrain_users_table.php
  • 1970_01_01_000001_constrain_salons_table.php
  • 1970_01_01_000001_constrain_profiles_table.php

:thinking: 考え方

最初にまとめて全テーブルを作って,後から一気に外部キー制約をつける

  • テーブル作る順番考えなくていいのでラク💖
  • 運用開始されるまではどうせ ./artisan migrate:fresh でテーブル落としまくるしこれで十分☺
  • タイムスタンプとか揃ってたほうがきれい✨ (気分)

※ 運用が開始されたら普通のやり方に切り替えましょう


2. Dockerコンテナ上でのテストのマイグレーションが遅すぎてつらい

Laravelはバージョン5.5から RefreshDatabase トレイトが提供されています。

:man_tone1: 「これをTestCaseuseするだけで勝手にテストごとにリフレッシュ走るよん」


しかし…

:whale2: Docker使ってるとすごく遅い :bomb:

  • setUp() の中でマイグレーションが走る
  • つまりテストメソッド1つ1つに対して走る🔥

:question: SQLiteの:memory:を使う場合はマシになるが

  • MySQLの機能ベットリの場合はそういうわけにもいかない😥
  • ENGINE=MEMORY にするのもなんか大掛かりな気がする

:thinking: こんなアプローチはどうでしょう

  • マイグレーションは全テスト中で1回だけ
    • シーダーは使わない
  • テストで使うデータはテストファイルごとに1回作る
    • 他のテストで作られたデータにはできるだけ干渉しない

:sparkles: 速さと独立性の両立

さて,どう実装しよう…?


PHPUnitのsetUpBeforeClassを使う?

class ArticleTest extends TestCase
{
    private static $salon;

    public static function setUpBeforeClass()
    {
        self::$salon = factory(Salon::class)->create();
    }
}

🔥 💣 まだアプリケーションを使う準備ができていない 💣 🔥

(ファサードとか使おうとするとエラーになるよ)


:sparkles: これをパクれ!

abstract class TestCase extends BaseTestCase
{
    use CreatesApplication;

    private static $initMigration = false;

    public static function setUpOnce(): void
    {
    }

    public function setUp()
    {
        /* 次のページに実装の中身 */
    }
}

    public function setUp(): void
    {
        static $initSetUpOnce = false;
        parent::setUp();

        if (!self::$initMigration) {
            // 最初のテストだけ実行される部分
            Artisan::call('migrate:fresh');
            self::$initMigration = true;
        }

        if (!$initSetUpOnce) {
            // テストファイルごとに実行される部分
            static::setUpOnce();
            $initSetUpOnce = true;
        }
    }

「static変数」と「staticプロパティ」の違い :smile:


もしくはこれだけにして

    public function setUp(): void
    {
        static $initSetUpOnce = false;
        parent::setUp();

        if (!$initSetUpOnce) {
            // テストファイルごとに実行される部分
            static::setUpOnce();
            $initSetUpOnce = true;
        }
    }

phpunit.xml から参照されるブートストラップファイルにこう書く

/** @var Kernel $artisan */
$artisan = (new class() {
    use CreatesApplication;
})->createApplication()[Kernel::class];

// 最初のテストだけ実行される部分
$artisan->call('migrate:fresh');

:smile: あとはこれを使うだけ

class ArticleControllerTest extends TestCase
{
    private static $salon = null;

    public static function setUpOnce(): void
    {
        self::$salon = factory(Salon::class)->create();
    }

    public function testFoo() { /* ... */ }

    public function testBar() { /* ... */ }
}


3. カウンターキャッシュをできる限り正確にしたい

:question: カウンターキャッシュとは

  • 毎回 SELECT COUNT(*) するの地獄だから件数だけメモって持っておこうぜ的な
  • データが追加されたら +1
  • データが削除されたら -1
  • MySQLでもいいけどDynamoDB,MongoDB,Redisとかに入れるほうが望ましい

:smile: ふんふんわかったこれでいいね

class User
{
    public function subscribe(Salon $salon)
    {
        // メンバーに追加
        $this->salons()->syncWithoutDetaching([$salon->id]);
        // 課金
        $this->payments()->make()->associate($salon)->save();
        // カウンターキャッシュ増加
        Redis::incr("salons:{$salon->id}:subscribers_count");
    }
}

:thinking: いやいやトランザクションぐらい張ろうや

class User
{
    public function subscribe(Salon $salon)
    {
        DB::transaction(function () use ($salon) {
            $this->salons()->syncWithoutDetaching([$salon->id]);
            $this->payments()->make()->associate($salon)->save();
            Redis::incr("salons:{$salon->id}:subscribers_count");
        });
    }
}

→ 外側にトランザクションがあったら…?


LaravelのDB::transaction()はネストできるんです

DB::transaction(function () use ($user, $salon) {
    $user->subscribe($salon);
    throw new \Exception('Hahahahahahaha');
});

これだとRedisだけロールバックされなくて不整合になっちゃう…


(参考) もしMySQLだったら?

class User
{
    public function subscribe(Salon $salon)
    {
        DB::transaction(function () use ($salon) {
            $this->salons()->syncWithoutDetaching([$salon->id]);
            $this->payments()->make()->associate($salon)->save();
            ++$salon->subscribers_count;
            $salon->save();
        });
    }
}

ロストアップデート☠(競合でカウント漏れ)


UPDATE ... SET subscribers_count = subscribers_count + 1
みたいなクエリを投げる場合

class User
{
    public function subscribe(Salon $salon)
    {
        DB::transaction(function () use ($salon) {
            $this->salons()->syncWithoutDetaching([$salon->id]);
            $this->payments()->make()->associate($salon)->save();
            $salon->increment('subscribers_count');
            $salon->refresh()->syncOriginal();
        });
    }
}

💣🔥デッドロックの原因🔥💣


:thinking: じゃーこうする?

class User
{
    public function subscribe(Salon $salon)
    {
        DB::transaction(function () use ($salon) {
            $this->salons()->syncWithoutDetaching([$salon->id]);
            $this->payments()->make()->associate($salon)->save();
        });
        Redis::incr("salons:{$salon->id}:subscribers_count");
        }
}

正解…だけど,このメソッドの呼び出し元にトランザクションがあると… 💣🔥


トランザクション全部終わったん教えてくれや!


💡 教えます

mpyw/laravel-transaction-observer


これ使うとスッキリするよ!

class User
{
    public function subscribe(Salon $salon)
    {
        DB::transaction(function () use ($salon) {
            $this->salons()->syncWithoutDetaching([$salon->id]);
            $this->payments()->make()->associate($salon)->save();
            event(new DelayedCall(function () use ($salon) {
                Redis::incr("salons:{$salon->id}:subscribers_count");
            }));
        });
        }
}

これだけ! (MySQLの例でもOK)


さらにMySQLでのカウンターキャッシュ実装に特化させたやつも作りました↓

mpyw/laravel-delayed-counter-cache

リレーション定義するだけで完結✨


カウンターキャッシュの考え方はメール通知
にも応用できます

(誤カウントより誤送信のほうがどう考えても危ない)


4. ずれない・速いページネーションが欲しい

Article::whereSalonId(1)->orderByDesc('id')->simplePaginate(10);
Article::whereSalonId(1)->orderByDesc('id')->paginate(10);
  • どっちも LIMIT ... OFFSET ... なクエリを吐くので遅い
  • リアルタイム更新なデータだとページズレが発生する

Article::whereSalonId(1)->lampager()
    ->orderByDesc('id')
    ->limit(10)
    ->paginate([
        'id' => $request->input('max_id'),
    ]);
  • WHERE ... LIMIT ... だけなので爆速だしズレない

詳しくはQiitaで 🔥


5. ルートモデルバインディングでメソッドを共有したい

  • GET /articles/1/comments
  • GET /events/1/comments

みたいなエンドポイントで,どちらも CommentController@index を使いたいとき。


ルーティングもコントローラもポリシーも全部スッキリ :smile:

class CommentController extends Controller
{
    public function index(Request $request, Article $article = null, Event $event = null): PaginationResult
    {
        return $commentable->comments()->lampager()
            ->orderByDesc('id')
            ->limit(20)
            ->paginate(['id' => $this->input('max_id')]);
    }
}

詳しくはQiitaで 🔥🔥


6. ユニークキー制約と論理削除を両立させたい

Schema::create('users', function (BluePrint $table) {
    $table->bigIncrements();
    $table->string('screen_name');
    $table->softDeletes();

    // 論理削除されていれば NULL, されていなければ 1 になる生成カラムを定義
    $table->boolean('existence')->nullable()
        ->storedAs('CASE WHEN deleted_at IS NULL THEN 1 ELSE NULL END');

    // screen_name と existence に対して複合ユニークインデックスを張る
    $table->unique(['screen_name', 'existence']);
});

詳しくはQiitaで 🔥🔥🔥


ご清聴ありがとうございました

このスライドは http://qiita.com/mpyw の最新投稿から参照できます

  • _人人人人人人人人人_> Vueは使いません < ̄Y^Y^Y^Y^Y^Y^Y^Y ̄
  • React派なので(失笑)それではさっそく
  • 1. マイグレーションファイルを整理整頓して書きたい
  • 自分はどうやっているかというと
  • :pencil: 以下のようなファイルを自動生成
  • :thinking: 考え方
  • 2. Dockerコンテナ上でのテストのマイグレーションが遅すぎてつらい
  • 3. カウンターキャッシュをできる限り正確にしたい
  • :question: カウンターキャッシュとは
  • :smile: ふんふんわかったこれでいいね
  • :thinking: いやいやトランザクションぐらい張ろうや
  • LaravelのDB::transaction()はネストできるんです
  • (参考) もしMySQLだったら?
  • :thinking: じゃーこうする?
  • トランザクション全部終わったん教えてくれや!
  • 💡 教えます
  • これ使うとスッキリするよ!
  • カウンターキャッシュの考え方はメール通知にも応用できます(誤カウントより誤送信のほうがどう考えても危ない)
  • 4. ずれない・速いページネーションが欲しい
  • 5. ルートモデルバインディングでメソッドを共有したい
  • 6. ユニークキー制約と論理削除を両立させたい
  • ご清聴ありがとうございました