1
2

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 3 years have passed since last update.

呼び出し回数に制限がある外部サービスAPIの上限緩和方法

Posted at

困っていた事

Vimeo APIを利用して、ライブストリーミング中のhls URLを、アプリケーションで許可した人にのみ配信したくて、
どうもそのようなエンドポイントは存在するものの、1分間内でのAPI実行回数制限があり、
サービスの成長途中でこの上限にぶつかりそうだなぁと思った。
APIドキュメントでは、「利用システム側でええ感じに頻繁にキャッシュしてね」と書いているものの、
取得できるm3u8 URLは有効期限が10秒とかなりタイトなため、キャッシュできるとしても、ブラウザ側であれこれアプリ全体のバンドルリソース読み込んでevalして、実行開始して、、、とかの関係ない時間を考慮すると、キャッシュできる時間はたかが知れてる。というかこれキャッシュで頑張りたくないと思った。

どう解決したか

キャッシュするという考え方を変えた。キャッシュするんじゃなくて、ある一定時間内に大量に視聴URLのリクエストが発生した時、それらのブラウザからのリクエストに対して、Vimeoへのリクエストを1回ですませるようにした。
今回の件でいうと、「動画視聴URLを取得したい」って事なんだけど、つまりエンドユーザーにはこのURLの取得を最大1秒間待ってもらう事として、1秒間以内に同じ動画に対して視聴URL取得のリクエストが発生したら、それらを全て1つのVimeo問い合わせで解決するようにした。
なおかつ、、Vimeoへの問い合わせは非同期処理になるので、その非同期処理中にさらに同じ動画に対するリクエストを検知した時も、すでにリクエスト中の非同期処理の待機中のキューにぶらさげるようにした。
で、この仕組みを思いついた理由は下記パッケージをちょうどリリースしたばかりだったから。

使ったパッケージ

async-combiner

このパッケージ書いた理由は、とあるECサイトで、「月間カレンダー」なる、カレンダー日付毎の在庫状況を表示する機能を作る際、
バックエンドAPIに対して、表示中のカレンダー月毎の在庫量を取得しにいくんだけども、割とこの処理がバックエンドでは重めな処理になるので、ユーザーの元気な操作に対してロジックは遅延させる仕組みを共通化したいって思い始めて、、
ともするとフロントロジック内において、ほぼ同時的に同じ条件のリクエスト(=きっと結果的に同じレスポンスになる)を行っている箇所が多数見られたので、
じゃあ、それを末端の機能で改善するんじゃなく、一つのライブラリでなるべくエレガントに吸収したいなと思った。
たとえば、今はaxiosを通信の仕組みによく使っているけど、これいつまで使うかわかんないので、できるだけここのユーティリティーだけは薄く個別に作っておきたい。

で、今回の件ではこのパッケージを導入して、以下のようなコード変更だけでいけた。

// before
class SomeController {
  @Get('/{room}/playback')
  async getPlaybackUrl(room: string) {
    const urlInfo = await getPlaybackUrl(room);
    if (urlInfo.playbackUrl) {
      this.setStatus(302);
      this.setHeader('location', urlInfo.playbackUrl);
    } else {
      this.setStatus(404);
    }
  }
}

// after
import { Combine } from '@dadajam4/async-combiner';

class SomeController {
  @Get('/{room}/playback')
  getPlaybackUrl(room: string) {
    return this._getPlaybackUrl(room);
  }

  @Combine({
    /**
     * APIの呼び出し回数上限をシステム側で緩和したいので、クライアントからのリクエストが必ず1秒遅延させる
     * その1秒間の間に複数リクエスト飛んできた時は全て1つのプロセスでさばく
     */
    delay: 1000,
  })
  private async _getPlaybackUrl(room: string) {
    const urlInfo = await getPlaybackUrl(room);
    if (urlInfo.playbackUrl) {
      this.setStatus(302);
      this.setHeader('location', urlInfo.playbackUrl);
    } else {
      this.setStatus(404);
    }
  }
}

やった事は実メソッドから、処理ロジックを別メソッドに分離して、Combine デコレータつけて条件を指定しただけ。コードの変更はこれ以外なし。

このパッケージ内部的に何やっているかというと、

  1. メソッド実行を検知した際に、引数の情報を「リクエスト条件オブジェクト」としてパースして保持する(ある程度自由な引数でも良いけど、クラスやFunctionなどjsonとして処理しきれない引数情報には対応できない)
  2. delay設定がされている場合、実処理(↑の例の場合 _getPlaybackUrl )の実行を遅延する。
  3. (1)の結果が確定するまでの間に同じ引数条件の後続リクエストを検知したら待機キューにのっける
  4. (1)の結果がでたら全ての待機キューに結果を応答する

このパッケージはNodeJS&ブラウザ双方で動作するんだけども、特にNode環境でもこの仕組みがマッチしたのは、NodeJSがシングルスレッドで多くのリクエストをさばくアーキだからっていう理由もある。逆にこのNodeJSプロセスを冗長化して複数インスタンスになった時は、このパッケージはそこまではカバーしない。
それでも個々のインスタンスは利用ユーザーが増えればそれに比例して多くのリクエストをさばく事になるので、このやりくちは多いにあるなと思った。
リクエスト毎にインスタンスを作るような言語体型とかでも、状態だけはどこかシングルに持つようにすれば同じやりかたはできるはず。(I/Oのオーバーヘッド考えるの難しいかもだけど)

さいごに

業務の中で「あ、これいるな」って思うちょっとややこい機能をnpmなりなんなりパッケージとして小出しにリリースするようにしておくと、ちゃんと身を救ってくれるなと思いました。

1
2
0

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
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?