4
1

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

ぼっち(´・ω・`)Advent Calendar 2018

Day 9

API呼び出しが制限でエラーにならないためのjavascriptライブラリを作ってみる

Last updated at Posted at 2018-12-11

ぼっち(´・ω・`) Advent Calendar 2018の9日目用だった記事です

だいぶ遅れが出てきてしまいました。そろそろキツイ(´・ω・`)

はじめに

  • 世の中にはいろいろなAPIが無料公開されていますが、大抵呼び出しに制限がかかっています
    • 1時間あたり何回とか
    • 同時に呼び出していいのは何リクエストまでとか
  • そういうAPI呼び出し制限を守りながらAPI呼び出しするためのjavascriptライブラリを勉強も兼ねて作ってみました
  • 職業プログラマではないので、何かあれば教えて頂ければ幸いです

記事の流れ

  • 作ったライブラリの使用感を説明します
  • 次に似たことができるライブラリの紹介と、ちょっとだけ足りてない部分を説明します
  • 作った中身の説明をします
  • Jestで作ったテストの解説をします

使用感

  • API呼び出しするsdk等にも対応できるように、既存の関数に制限を付与した関数を生成する形にします。具体的には次のような使い方が出来るようにします。

    const throttle = new Throttle({ concurrency: 1 })
    const f = throttle.attach(existingAsyncFunc1)
    const g = throttle.attach(existingAsyncFunc2)
    
    // f,gを非同期で呼びまくるが、API制限を超える分のf,gの実行が待機する
    promise.all([f(arg1, arg2, ...),  g(arg1, arg2, ...), ...])
    
    • existingAsyncFunc1, existingAsyncFunc2: 既存のAPI呼び出しする関数です。非同期です
    • new Throttle({ concurrency: 1 }): 呼び出し制限の設定です。
      • concurrency: 同時に呼び出し可能な関数の数です
    • throttle.attach: 引数に与えた関数に呼び出し制限を付与した新しい関数を返します
    • f, g: existingAsyncFunc1,existingAsyncFunc2と同じ引数・戻り値ですが、API呼び出し制限がかかっています。
      • 同時に呼び出しても、同時実行数等の制限を超える場合は実行が待機します
        • 他の関数の実行が終わり、同時実行数の空きができると待機していた関数が実行されます
    • 上記の場合、同時実行数1の制限があるため、次の挙動になります。(説明しやすいので1にしています)
      • image.png
        • f, gは同時に呼び出されますが、同時実行数1の制限によりfのみ実行されます
        • fの実行が完了したのち、gが実行されます
  • さらに、「最低○○秒は経たないと次の関数呼び出しを実行しない」という制限をかけられるようにします

    • 「1時間あたり何回呼び出せるか」の対応です

      • 例えば1時間あたり5000リクエストなら、1リクエストあたり0.72秒かければいいことになります(1並列の場合)
    • Throttleオブジェクト作成のところで、duraration を指定することで設定します。ミリ秒で指定します。

      • new Throttle({ concurrency: 1, duraration: 1000})
    • もし指定時間より前にAPI呼び出しが終わった場合は次の挙動になります

      • image.png
        • fは実行完了後すぐに制御を返します
        • gは指定時間(duraration)経つまで待機します
    • もし指定時間より後でAPI呼び出しが終わった場合は次の挙動になります

      • image.png
        • fは実行完了後すぐに制御を返します
        • gはすぐに実行されます。durarationはすでに経過しているためです
    • 本当はもっと賢い制御もできそうですが、ぱっと思いつかなかったのでこれでいきます

似てるけどちょっと足りなく感じたライブラリ達

  • p-limit
    • 単一の関数にしか制限をかけられません
  • async-throttle
    • 時間制限がかけられません
  • async-limiter
    • 時間制限がかけられません
  • p-throttle
    • 単一の関数にしか制限をかけられません

ということで、勉強がてら自作しちゃいました

中身について

  • 次のindex.js1つに収まっています
index.js
const Limiter = require('async-limiter');

class Throttle {

    constructor(args) {
        // 初期化
        const { concurrency, duraration } = args || {};
        this.concurrencyQueue = new Limiter({ concurrency: concurrency }); //(d)
        this.duraration = duraration || 0;

        // メソッド呼び出し時にthisが変わることがあるのを防ぐ
        // ref. https://qiita.com/tsin1rou/items/90576b6c00b895478610#class
        this.attach.bind(this);
        this.attachAll.bind(this);
    }

    attach(f) {
        // fと同じ引数を受け取るために、
        // fの引数がどのような引数でもいいように可変長引数で受け取る
        return (async (...x) => { // (a)
            const executor = (resolve, reject) => this.concurrencyQueue.push(  //(d)
                async callBack => {
                    const startTime = performance.now()
                    try {
                        const result = await f(...x) // (b)
                        resolve(result) // (c)
                    }
                    catch (e) {
                        reject(e)
                    }
                    finally {
                        const endTime = performance.now()     
                        const elapsedTime = endTime - startTime
                        const sleepTime = Math.max(this.duraration - elapsedTime, 0) // (e)

                        // asyncでのスリープ処理
                        // ref. https://qiita.com/asa-taka/items/888bc5a1d7f30ee7eda2
                        await new Promise(r => setTimeout(r, sleepTime));
                        // async-limiterに次の処理に行っていいことを伝える
                        callBack();  //(d)
                    }
                }
            )
            // executorの中でresolve/rejectされるまで停止
            return await new Promise(executor); // (c)
        }
        )
    }
}

module.exports = Throttle

attachが闇の魔術っぽいなので解説

引数・戻り値が同じ関数を返す(スプレッド構文)

  • attachは関数を引数にとり、呼び出し制限を付与した関数を返します

    const g = throttle.attach(f)
    
  • としたとき、

    • gfと同じ引数、同じ戻り値の関数です
    • gは内部でfを呼び出します
    • gが内部でfを呼び出すまでの間に、同時実行制限による待機や待ち時間の制御をします
  • これを実現するために

    • attachの生成する関数の引数についてasync (...x) => {というように可変長引数で引数を受け取ります。xは配列になります
      • ソースコードの(a)の部分です
    • await f(...x)として、可変長引数配列として受け取ったxをスプレッド構文で引数展開してfを実行します
      • これでfがどんな引数の関数でも対応できます
      • ソースコードの(b)の部分です
    • gの戻り値はPromiseオブジェクトで、fの戻り値をresolveしたものになります
      • ソースコードの(c)の2箇所です

並列度の制御(async-limiter)

  • async-limiterを使って並列度の制御をしています。このライブラリは次の仕様です
    • 並列度を指定したQueueを作成する
    • 実行したい関数をQueueにpushする
    • ライブラリ側で、並列度に空きがあればQueueから関数を取り出して実行
    • 呼び出された関数にはcallback関数が引数として渡され、それを呼ぶとライブラリが関数実行完了とみなす(並列度に空きが出る)
    • これらはソースコードの(d)のところです

待ち時間の制御(Promiseasync-limitersetTimeout)

  • async-awaitでsleepさせる方法はここを参考にしました。
  • これを、gが制御を返すことになるresolveと、実行待機している関数の呼び出し(async-limitercallback)の間に入れます
    • こうすることで、gはすぐに制御を返しつつ、次の関数の実行は待機するということを実現します
    • ソースコードの(e)のところです

Jestで作ったテスト

  • かなり込み入った仕様なのでテストしないと不安です。Jestでやってみます
// テスト開始からの経過時間を秒で表すための補助関数
const diffSecond = (endMsec, startMsec) =>
    parseInt((endMsec - startMsec) / 1000)

const execWithMesure = async (f, arg, testStartMsec) => {
    const funcStartMsec = await f(arg)
    const funcStartSec = diffSecond(funcStartMsec, testStartMsec)
    const returnControlSec = diffSecond(performance.now(), testStartMsec)
    return { funcStartSec, returnControlSec }
}

const hevyAction = async sleepMsec => {
    const funcStartMsec = performance.now()
    await new Promise(r => setTimeout(r, sleepMsec));
    return funcStartMsec
}

test('call 3 concurency', async () => {
    jest.setTimeout(20000);
    const throttle = new Throttle({ concurrency: 3, duraration: 2000 })
    let testStartMsec = performance.now()

    const f1 = f2 = f3 = f4 = throttle.attach(hevyAction)
    const f5 = f6 = f7 = f8 = throttle.attach(hevyAction)

    const testArray = [
        execWithMesure(f1, 10000, testStartMsec),
        execWithMesure(f2, 1000, testStartMsec),
        execWithMesure(f3, 3000, testStartMsec),
        execWithMesure(f4, 2000, testStartMsec),
        execWithMesure(f5, 1000, testStartMsec),
        execWithMesure(f6, 1000, testStartMsec),
        execWithMesure(f7, 3000, testStartMsec),
        execWithMesure(f8, 2000, testStartMsec)
    ]
    const allResult = await Promise.all(testArray)
    expect(allResult).toEqual([
        { funcStartSec: 0, returnControlSec: 10 },
        { funcStartSec: 0, returnControlSec: 1 },
        { funcStartSec: 0, returnControlSec: 3 },
        { funcStartSec: 2, returnControlSec: 4 },
        { funcStartSec: 3, returnControlSec: 4 },
        { funcStartSec: 4, returnControlSec: 5 },
        { funcStartSec: 5, returnControlSec: 8 },
        { funcStartSec: 6, returnControlSec: 8 }
    ]);
});


  • hevyActionは引数に与えたミリ秒スリープする関数です。API呼び出しの代わりです。テストの都合で呼び出された時間を返します

  • new Throttle({ concurrency: 3, duraration: 2000 }): 3並列、最低待ち時間2秒(2000ms)です

  • f1 - f8 で制限をかた関数を作っています。中身はすべてhevyActionにしています。

  • execWithMesureは関数、引数、テスト開始時間を引数にとります。

    • f1 - f8を関数に受け取る前提です。
    • f1 - f8の返した中のhevyActionが呼び出された時間と、制御が返ってきた時間をオブジェクトで返します。
      • 時間はテスト開始から経過した秒数です
    • 例えばexecWithMesure(f1, 10000, testStartMsec)は10秒かかる関数呼び出しになります
  • 実行時間がかなり長いので、jest.setTimeout(20000);でjestがタイムアウトしないようにします

  • テスト結果(説明していないテストも入ってます)
    image.png

  • テスト結果が表す各関数の実行時間

image.png

  • ちゃんと並列数も、2秒のdurarationも守られています

あとがき

  • コード量は全然大したことないのに、説明が膨大に。しかも伝えづらい
  • 今後書く予定の「wikidataにSPARQLを投げてみた」でこのライブラリを使う予定です。
  • コードはここにもあげていますが、まだコメントとかREADMEとか整備できてないです
  • 正直テストコードはもっとちゃんと整備できた気がしてますが、もう疲れました
4
1
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
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?