Laravelお役立ちネタ6連発

この記事は [Laravel/Vue.js勉強会#3 - connpass](https://laravue.connpass.com/event/75495)　のLTネタです。 

1〜3だけ新ネタ

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

---

## :question: お前誰

<img width="600" src="https://qiita-image-store.s3.amazonaws.com/0/25060/2c8fe577-ab97-bde7-2df1-ead904359c5b.png">

---

- Qiitaマン
- 知恵袋マン<br><br>
- 新卒で入ったITベンチャーが**<font color="red">2ヶ月</font>**で🔥買収🔥されて無くなった
- 今ここの26階で働いてます👆🏢
- KotlinやRustに憧れつつもやっぱり**💕PHP💕**と**🌟JavaScript🌟**が好き<br><br>
- 最近ツイートの9割が暗号通貨 ₿ 💰
- Bitcoin Cash is Bitcoin （失笑）

---

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

#### フロントエンド

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

#### バックエンド

- Laravel でAPI作るよ

---

# ＿人人人人人人人人人＿<br>＞　Vueは使いません　＜<br>￣Y^Y^Y^Y^Y^Y^Y^Y￣

---

# React派なので（失笑）<br><br>それではさっそく

---

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

つらいところ

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

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

---

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

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

---

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

> ## articles
>
> 記事を定義します。
>
> ...

---

↓ **<font color="blue">「カラム定義」</font>**だけを書く

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

> ```php:ベース
> $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();
> ```

---

↓ **<font color="red">「外部キー制約定義」</font>**だけを書く

> ```php:外部キー制約
> $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を **<font color="fuchsia">†オレオレArtisanコマンド</font>** でスクレイピングします！

※ みなさん<font color="brown">秘伝のソース</font>を頑張って用意してください<br><img src="https://qiita-image-store.s3.amazonaws.com/0/25060/4bb961e8-c707-a392-7cbf-65cb8766fce2.jpeg" width="300">

---

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

### 記事テーブルの場合

- `1970_01_01_000000_create_articles_table.php`
- `1970_01_01_000001_constrain_articles_table.php`

---

```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]);
    }
```

---

```php
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` でテーブル落としまくるしこれで十分☺
- タイムスタンプとか揃ってたほうがきれい✨ （気分）

<font color="red">※ 運用が開始されたら普通のやり方に切り替えましょう</font>

---

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

Laravelはバージョン5.5から [RefreshDatabase](https://github.com/laravel/framework/blob/5.5/src/Illuminate/Foundation/Testing/RefreshDatabase.php) トレイトが提供されています。

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

---

しかし…

### :whale2: Docker使ってるとすごく遅い :bomb:
  - `setUp()` の中でマイグレーションが走る
  - つまり**テストメソッド1つ1つに対して走る**🔥

### :question: SQLiteの`:memory:`を使う場合はマシになるが
  - MySQLの機能ベットリの場合はそういうわけにもいかない😥
  - `ENGINE=MEMORY` にするのもなんか大掛かりな気がする

----

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

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

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

さて，どう実装しよう…？

---

### PHPUnitの`setUpBeforeClass`を使う？

```php
class ArticleTest extends TestCase
{
    private static $salon;

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

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

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

---

### :sparkles: これをパクれ！

```php
abstract class TestCase extends BaseTestCase
{
    use CreatesApplication;

    private static $initMigration = false;

    public static function setUpOnce(): void
    {
    }

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

---

```php
    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: 

---

もしくはこれだけにして

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

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

---

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

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

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

---

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

```php
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: ふんふんわかったこれでいいね

```php
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: いやいやトランザクションぐらい張ろうや

```php
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()`はネストできるんです**

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

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

---

## （参考） もしMySQLだったら？

```php
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();
        });
    }
}
```

→ **<font color="purple">ロストアップデート☠</font>（競合でカウント漏れ）**

---

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

```php
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();
        });
    }
}
```

→ **<font color="red">💣🔥デッドロックの原因🔥💣</font>**

---


## :thinking: じゃーこうする？

```php
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](https://github.com/mpyw/laravel-transaction-observer)

<img src="https://qiita-image-store.s3.amazonaws.com/0/25060/be996fe6-6b78-7318-eb70-01fc9c2ae7b5.png" width="500">

---

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

```php
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](https://github.com/mpyw/laravel-delayed-counter-cache)

<img src="https://qiita-image-store.s3.amazonaws.com/0/25060/19f8df6e-4832-5081-4068-ad7cab3a3ed2.png" width="500">

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

---

## カウンターキャッシュの考え方は<font color="blue">メール通知</font><br>にも応用できます<br><br>（誤カウントより誤送信のほうがどう考えても危ない）

---

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

```php
Article::whereSalonId(1)->orderByDesc('id')->simplePaginate(10);
Article::whereSalonId(1)->orderByDesc('id')->paginate(10);
```

- どっちも `LIMIT ... OFFSET ...` なクエリを吐くので遅い
- リアルタイム更新なデータだとページズレが発生する

---

```php
Article::whereSalonId(1)->lampager()
    ->orderByDesc('id')
    ->limit(10)
    ->paginate([
        'id' => $request->input('max_id'),
    ]);
```

- `WHERE ... LIMIT ...` だけなので爆速だしズレない

#### 詳しくはQiitaで 🔥

- [OFFSETを使わない高速ページネーションを任意のPHPフレームワークで超簡単に実現する](https://qiita.com/mpyw/items/b94b7d69146777f7a407)

---

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

- `GET /articles/1/comments`
- `GET /events/1/comments`

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

---

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

```php
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で 🔥🔥

- [Route Model Binding と Policy だけで認可処理を完結させるための Resourceful ルーティングの工夫](https://qiita.com/mpyw/items/13b04f80f6355b30215a)
- [ルーティングサービスを継承する](https://qiita.com/mpyw/items/5d6e992fb3c40c63942f)

---

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

```php
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で 🔥🔥🔥

- [MySQLでLaravel標準のSoftDeletesを使った論理削除とユニーク制約を両立させる方法](https://qiita.com/mpyw/items/d3e25860ecaaeb8340ab)

---

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

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