ぼっち(´・ω・`) 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にしています)
-
-
さらに、「最低○○秒は経たないと次の関数呼び出しを実行しない」という制限をかけられるようにします
-
「1時間あたり何回呼び出せるか」の対応です
- 例えば1時間あたり5000リクエストなら、1リクエストあたり0.72秒かければいいことになります(1並列の場合)
-
Throttleオブジェクト作成のところで、duraration を指定することで設定します。ミリ秒で指定します。
new Throttle({ concurrency: 1, duraration: 1000})
-
もし指定時間より前にAPI呼び出しが終わった場合は次の挙動になります
-
もし指定時間より後でAPI呼び出しが終わった場合は次の挙動になります
-
本当はもっと賢い制御もできそうですが、ぱっと思いつかなかったのでこれでいきます
-
似てるけどちょっと足りなく感じたライブラリ達
-
p-limit
- 単一の関数にしか制限をかけられません
-
async-throttle
- 時間制限がかけられません
-
async-limiter
- 時間制限がかけられません
-
p-throttle
- 単一の関数にしか制限をかけられません
ということで、勉強がてら自作しちゃいました
中身について
- 次の
index.js
1つに収まっています
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)
-
としたとき、
-
g
はf
と同じ引数、同じ戻り値の関数です -
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)のところです
待ち時間の制御(Promise
とasync-limiter
とsetTimeout
)
- async-awaitでsleepさせる方法はここを参考にしました。
- これを、
g
が制御を返すことになるresolve
と、実行待機している関数の呼び出し(async-limiter
のcallback
)の間に入れます- こうすることで、
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がタイムアウトしないようにします -
テスト結果が表す各関数の実行時間
- ちゃんと並列数も、2秒のdurarationも守られています
あとがき
- コード量は全然大したことないのに、説明が膨大に。しかも伝えづらい
- 今後書く予定の「wikidataにSPARQLを投げてみた」でこのライブラリを使う予定です。
- コードはここにもあげていますが、まだコメントとかREADMEとか整備できてないです
- 正直テストコードはもっとちゃんと整備できた気がしてますが、もう疲れました