Edited at

Laravelお役立ちネタ6連発

More than 1 year has passed since last update.

この記事は 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. ユニークキー制約と論理削除を両立させたい
  • ご清聴ありがとうございました