この記事は Laravel/Vue.js勉強会#3 - connpass のLTネタです。
1〜3だけ新ネタ
- マイグレーションファイルを整理整頓して書きたい
- Dockerコンテナ上でのテストのマイグレーションが遅すぎてつらい
- カウンターキャッシュを出来る限り正確にしたい
- ずれない・速いページネーションが欲しい
- ルートモデルバインディングでメソッドを共有したい
- ユニークキー制約と論理削除を両立させたい
お前誰
- Qiitaマン
- 知恵袋マン
- 新卒で入ったITベンチャーが**2ヶ月**で🔥買収🔥されて無くなった
- 今ここの26階で働いてます👆🏢
- KotlinやRustに憧れつつもやっぱり💕PHP💕と🌟JavaScript🌟が好き
- 最近ツイートの9割が暗号通貨 ₿ 💰
- Bitcoin Cash is Bitcoin (失笑)
今業務でやっている開発
フロントエンド
- 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コマンド でスクレイピングします!
以下のようなファイルを自動生成
記事テーブルの場合
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
考え方
最初にまとめて全テーブルを作って,後から一気に外部キー制約をつける
- テーブル作る順番考えなくていいのでラク💖
- 運用開始されるまではどうせ
./artisan migrate:fresh
でテーブル落としまくるしこれで十分☺ - タイムスタンプとか揃ってたほうがきれい✨ (気分)
※ 運用が開始されたら普通のやり方に切り替えましょう
2. Dockerコンテナ上でのテストのマイグレーションが遅すぎてつらい
Laravelはバージョン5.5から RefreshDatabase トレイトが提供されています。
「これをTestCase
でuse
するだけで勝手にテストごとにリフレッシュ走るよん」
しかし…
Docker使ってるとすごく遅い
-
setUp()
の中でマイグレーションが走る - つまりテストメソッド1つ1つに対して走る🔥
SQLiteの:memory:
を使う場合はマシになるが
- MySQLの機能ベットリの場合はそういうわけにもいかない😥
-
ENGINE=MEMORY
にするのもなんか大掛かりな気がする
こんなアプローチはどうでしょう
- マイグレーションは全テスト中で1回だけ
- シーダーは使わない
- テストで使うデータはテストファイルごとに1回作る
- 他のテストで作られたデータにはできるだけ干渉しない
速さと独立性の両立
さて,どう実装しよう…?
PHPUnitのsetUpBeforeClass
を使う?
class ArticleTest extends TestCase
{
private static $salon;
public static function setUpBeforeClass()
{
self::$salon = factory(Salon::class)->create();
}
}
🔥 💣 まだアプリケーションを使う準備ができていない 💣 🔥
(ファサードとか使おうとするとエラーになるよ)
これをパクれ!
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プロパティ」の違い
もしくはこれだけにして
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');
あとはこれを使うだけ
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. カウンターキャッシュをできる限り正確にしたい
カウンターキャッシュとは
- 毎回
SELECT COUNT(*)
するの地獄だから件数だけメモって持っておこうぜ的な - データが追加されたら +1
- データが削除されたら -1
- MySQLでもいいけどDynamoDB,MongoDB,Redisとかに入れるほうが望ましい
ふんふんわかったこれでいいね
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");
}
}
いやいやトランザクションぐらい張ろうや
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();
});
}
}
→ 💣🔥デッドロックの原因🔥💣
じゃーこうする?
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
を使いたいとき。
ルーティングもコントローラもポリシーも全部スッキリ
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 の最新投稿から参照できます