76
68

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Laravelお役立ちネタ6連発

Last updated at Posted at 2018-01-23
1 / 47

この記事は 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 の最新投稿から参照できます

76
68
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
76
68

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?