【Laravel】APIを高速で連打されても1つのリクエストについて処理が終わるまでアクセス制限をかける

 Laravel は PHP のフレームワークであり web サービスの構築によく使われます。web サービスはよく外部からのリクエストを受け付けて、リクエストを元に処理をします。この処理の際には様々な制約を課すことが多いです。例えば、入力内容の検証であったり、サーバへの負荷軽減のためのアクセス制限であったりです。この制約は web サービスの持つデータの整合性を保つために設けられることが多いです。この制約でまもるべきデータの整合性として「唯一つのホゲホゲ」というものがままあります。データの整合性を保最終的な砦であるデータベースでユニーク制約(場合によっては複合ユニーク)をかけるのもいいですが、データベースのみならず web サービスを動かしているプログラム内であらかじめ防ぎたくもあります。これができるとデータベースのエラーメッセージや単なるサーバーエラーでなく、いい感じのエラーメッセージを返せます。
 唯一つのホゲホゲという状態を破ろうとする外部からの要求でよくあるのは二重登録です。これを簡単に防ごうとすると次のようにできます。例はある投稿に対するあるユーザのいいねの保存処理です。

/**
 * いいねの追加
 * @param string|int $postId
 * @throws Throwable
 * @return JsonResponse
 */
public function storeGood($postId): JsonResponse
{
    // いいね対象の投稿の存在確認 and 取得
    $post = Post::findOrFail($postId);
    // api で認証しているユーザの ID がいいね済みかデータベースから確認
    if (in_array(auth('api')->id(), $post->postGoods->map->member_id->toArray(), false)) {
        return new \Illuminate\Http\JsonResponse('複数のいいねをつけることはできません。', 422);
    }

    // 新しいいいねを保存
    $good = new PostGood();
    $good->post_id = $post->getKey();
    $good->member_id = auth('api')->id();
    $good->save();

    // いいねの総数をデータベースから取得
    $goodCount = PostGood::wherepostId($postId)->count();

    // 成功レスポンスを返します。失敗時は省略。例外をハンドリングすることが多いです。
    return new \Illuminate\Http\JsonResponse(['msg' => 'いいねしました。','goodCount' => $goodCount]);
}

 URL にリクエストを受けてコントローラ中の処理が働いたとき、データベースの中身を確認して二重登録か否か確認して、処理をします。
 一見問題なさそうですが、実は問題があります。外部からのリクエストはこのコントローラのメソッドが処理中であっても飛んできます。そのため、いいねを保存中に新たにリクエストを受け、新たないいねを保存処理が走る可能性があります。マシンのスペックにもよりますがデータベースへの保存処理の完了より速く、リクエストを二連打することは難しくありません。ものによっては手動でも成功する時があります。
 これを検知してプログラム内で「今、別のリクエストで保存中だから、新たなこのリクエストは二重登録で弾く」という処理を行いやすくするには、次のようにできます。

/**
 * いいねの追加
 * @param string|int $postId
 * @throws Throwable
 * @return JsonResponse
 */
public function storeGood($postId): JsonResponse
{
    // キャッシュを使うためのキーを生成。処理メソッドとアクセス元から生成
    $cacheKey = $this->getCacheKey(__CLASS__.__FUNCTION__);
    // いいね対象の投稿の存在確認 and 取得
    $post = Post::findOrFail($postId);
    // api で認証しているユーザの ID がいいね済みかデータベースから確認
    // に加えて処理中であることを示すキャッシュが存在しないことを確認
    if (\Cache::has($cacheKey) || in_array(auth('api')->id(), $post->postGoods->map->member_id->toArray(), false)) {
        return new \Illuminate\Http\JsonResponse('複数のいいねをつけることはできません。', 422);
    }
    // 処理中であることを示すキャッシュを保存
    \Cache::set($cacheKey, 'writing');
    try {
        // 新しいいいねを保存
        $good = new PostGood();
        $good->post_id = $post->getKey();
        $good->member_id = auth('api')->id();
        $good->save();

        // いいねの総数をデータベースから取得
        $goodCount = PostGood::wherepostId($postId)->count();

        // 成功レスポンスを返します。失敗時は省略。例外をハンドリングすることが多いです。
        return new \Illuminate\Http\JsonResponse(['msg' => 'いいねしました。', 'goodCount' => $goodCount]);
    } finally {
        // 処理中であることを示すキャッシュを可能な限り確実に削除
        // PHP 自体が異様な死に方をしない限り try {} finally {} で確実に実行されます
        \Cache::delete($cacheKey);
    }
}

/**
 * Laravel の \Illuminate\Routing\Middleware\ThrottleRequests::resolveRequestSignature を元に作成
 * @param $prefix
 * @return string
 */
protected function getCacheKey($prefix): string
{
    $request = request();
    if ($user = $request->user()) {
        // 認証情報が存在するならばそれを元にハッシュを生成
        return sha1($user->getAuthIdentifier());
    }

    if ($route = $request->route()) {
        // アクセス元情報が存在するならばそれを元にハッシュを生成
        return sha1($route->getDomain().'|'.$request->ip());
    }

    throw new \RuntimeException('リクエストを元にハッシュキーを生成できませんでした。');
}

 Laravel のキャッシュ機能を使います。Laravel のアクセス制限ミドルウェアや Linux でしばしば使われる .lock ファイルの様な感じです。キャッシュ機能にデータベースを使った処理に比べて速い処理方法――例えばファイルなどを用いることでデータベースへのみを用いた検証よりも確実に二重登録を見つけられます。

 ちなみにリクエストのアクセス制限にこだわらず、ユニーク制約を使わずデータベースの整合性を守るのみならば次のようにもできます。

    /**
     * いいねの追加
     * @param string|int $postId
     * @throws Throwable
     * @return JsonResponse
     */
    public function storeGood($postId): JsonResponse
    {
        // いいね対象の投稿の存在確認 and 取得
        $post = Post::findOrFail($postId);
        // api で認証しているユーザの ID がいいね済みかデータベースから確認
        if (in_array(auth('api')->id(), $post->postGoods->map->member_id->toArray(), false)) {
            return new \Illuminate\Http\JsonResponse('複数のいいねをつけることはできません。', 422);
        }

        \DB::transaction(static function() use ($post){
            // 新しいいいねを保存
            $good = new PostGood();
            $good->post_id = $post->getKey();
            $good->member_id = auth('api')->id();
            $good->save();
            // 保存した状態でいいねの数を確認
            $count = PostGood::wherePostId($post->getKey())->whereMemberId(auth('api')->id())->count();
            if($count > 1){
                // もし保存時に該当する投稿と会員のいいねが複数あったならば例外を投げて Laravel のロールバック機能を動かす
                throw new \Illuminate\Http\Exceptions\HttpResponseException(
                    new \Illuminate\Http\JsonResponse('複数のいいねをつけることはできません。', 422)
                );
            }
            // なにもなければ勝手にコミットしてくれます。
        });

        // いいねの総数をデータベースから取得
        $goodCount = PostGood::wherepostId($postId)->count();

        // 成功レスポンスを返します。失敗時は省略。例外をハンドリングすることが多いです。
        return new \Illuminate\Http\JsonResponse(['msg' => 'いいねしました。','goodCount' => $goodCount]);
    }

 複数の入り口から同時にデータベースを書き換えられ、かつ実行される SQL クエリ数が増えることが問題にならない場合は、このようにデータベースの中身を書き換えた後の状態で整合性が守られているか確認してトランザクションをコミットするかロールバックするか決める方法が便利です。Laravel のミドルウェアで先述のキャッシュを使ったアクセス制限処理を細かく書くこともできますが、複数の入り口で同じキャッシュキーを使おうとするとキャッシュキーが散らばるか定数がやたら増えるかで事故の元になりやすいです。

>株式会社シーポイントラボ

株式会社シーポイントラボ

TEL:053-543-9889
営業時間:9:00~18:00(月〜金)
住所:〒432-8003
   静岡県浜松市中央区和地山3-1-7
   浜松イノベーションキューブ 315
※ご来社の際はインターホンで「316」をお呼びください

CTR IMG