こんにちは。ひさびさにみんな大好きPHPでの話を書きます。
お付き合いお願いします。
目次
Toggleお題
こんなAPIがあったとします。
- 他のチーム、他人が公開している
- 実行後のレスポンスはすぐ返ってくる
- このAPIが向こう側で何をしているか詳しくは知らない
あなたはバッチで気軽にそのAPIをガンガン叩いています。
ところがそんなある日、API管理者から
「そんな無造作に使われると困るので、呼ぶのは秒間○○回までにしてください。」
と無慈悲に言われたとします。
政治的なアレもあり、どうやら呼び出し元が実行回数をコントロールしなくてはならなそうです。
さぁ、どうやって対応しますか?
というお題があった場合、自分だったらどう対応するかなぁと、ふと思い立ったのでその辺を書いてみます。
まず思いついた方法
まず最初に思いついた方法は以下になります。
以下のクラスを用意します。
- APIの実行回数を監視するクラス
- このクラスは監視メソッドを持つ
- 時刻はmicrotimeで扱う
- 自身が最初に呼ばれた時刻及び、呼ばれた回数を保持している
- API呼び出し直前に必ず呼んでもらう
そして、この監視メソッドは以下の処理を行います。
- 最初の命令から閾値となる秒数が過ぎていない
- かつ、監視していた実行数が許可された実行数を超えていたら
閾値 - 最初の計測から今回の処理までの実行間隔
の値でusleep
を挟む
イメージとしては制限を超えそうなら止めるという具合です。
といっても何いってんだこいつ?状態だと思うので、いちおう補足資料を以下に書きます。
フローチャート
フローチャートはこんな感じです。
(すいません、ちょっと見づらいですね)
イメージ
動作イメージを図に表すとこんな感じです。
閾値が1秒、制限時間内許可実行数が5回 (秒間5回の制限) の場合を例にします。
※ 例を極端にしています
ソースとテスト
ソース(sample_1/ExecutionController.php
)と、テスト(sample_1/ExecutionControllerTest.php
)はこうです。
<?php | |
namespace Sample1; | |
/** | |
* 当クラスでは更新命令数を監視し(実際には教えてもらうが) | |
* | |
* - 最初の命令から閾値となる秒数が過ぎていない | |
* - かつ、監視していた実行数が許可された実行数を超えていたら | |
* 閾値 - 最初の計測から今回の処理までの実行間隔 の値で usleep | |
* | |
* をかける処理を行う。 | |
* | |
* 閾値が1秒, 制限時間内許可実行数が10回 (秒間10回の制限) の場合 | |
* - 11回目の監視時点で0.9秒しか立っていない場合、0.1秒のsleepを挟む | |
* - 11回目の監視時点で0.1秒しか立っていない場合、0.9秒のsleepを挟む | |
* - 裏を返すと、1回の処理が0.01秒で終わってしまう処理でも通過することになる | |
* - 秒間10回とは言ったが、0.1秒で10回呼ばれるとは思わなかったとならないように注意が必要 | |
* | |
* 監視対象となる処理が実行される前に notify() を呼んで使う。 | |
* | |
* <code> | |
* // インスタンス生成<br> | |
* $executionController = new ExecutionController(); | |
* | |
* //----- 監視したい処理の前に呼ぶ ----- | |
* | |
* // notify() で通知する<br> | |
* $executionController->notify(); | |
* | |
* // 監視する処理<br> | |
* $target->exec(); | |
* | |
* </code> | |
*/ | |
class ExecutionController | |
{ | |
/** | |
* マイクロ秒単位 | |
* | |
* マイクロ秒とは、1秒の100万分の1 1秒は 1000000 | |
* | |
* @var int | |
*/ | |
const UNIT_OF_MICROSECONDS = 1000000; | |
/** | |
* デフォルトの閾値 xx秒間〇〇回数発行可能の xxの値 | |
* @var int 秒単位 | |
*/ | |
const DEFAULT_THRESHOLD = 1; | |
/** | |
* デフォルトの閾値内(決められた時間)内に許可する実行数 xx秒間〇〇回数発行可能の ○○の値 | |
* @var int | |
*/ | |
const DEFAULT_ALLOW_EXEC_CNT = 100; | |
/** | |
* 閾値 xx秒間〇〇回数発行可能の xxの値 | |
* @var int 秒単位 | |
*/ | |
private $threshold = self::DEFAULT_THRESHOLD; | |
/** | |
* 閾値内(決められた時間)内に許可する実行数 xx秒間〇〇回数発行可能の ○○の値 | |
* @var int | |
*/ | |
private $allowExecCnt = self::DEFAULT_ALLOW_EXEC_CNT; | |
/** | |
* 実行回数 | |
* @var int | |
*/ | |
private $execCnt = 0; | |
/** | |
* 基準時間 | |
* @var string microtime() | |
*/ | |
private $referenceTime = null; | |
/** | |
* 通知 | |
* | |
* 監視させたい処理の前で呼んでください | |
* | |
* @return void | |
*/ | |
public function notify() | |
{ | |
$this->execCnt++; | |
// 基準時間がセットされていない場合、取得/set だけしておいて終了 | |
// 一番最初の呼び出しが該当する | |
if ($this->referenceTime === null) { | |
$this->referenceTime = microtime(); | |
return; | |
} | |
// 経過時間が閾値を超えているかどうか | |
$elapsedTime = $this->elapsedTime(microtime(), $this->referenceTime); | |
$isOver = $elapsedTime >= $this->threshold; | |
/* | |
|---------------------------------------------------------------------- | |
| 許可実行回数を超えていない | |
|---------------------------------------------------------------------- | |
*/ | |
if ($this->execCnt <= $this->allowExecCnt) { | |
// 許可実行回数は超えていないが、経過時間が閾値を超えている | |
if ($isOver === true) { | |
// カウンター系を初期計測後状態にした上で次の通知を待つ | |
$this->changeToStateAfterInitialMeasurement(); | |
} | |
return; | |
} | |
/* | |
|---------------------------------------------------------------------- | |
| 許可実行回数を超えている | |
|---------------------------------------------------------------------- | |
*/ | |
// 許可実行回数を超えていて、経過時間が閾値を超えていない | |
// 閾値秒以下で許可実行回数を超えているパターン | |
if ($isOver === false) { | |
// 閾値 - 最初の計測から今回の処理までの実行間隔 の値で usleep | |
$this->_sleep((int)($this->threshold * self::UNIT_OF_MICROSECONDS - $elapsedTime * self::UNIT_OF_MICROSECONDS)); | |
// カウンター系を初期計測後状態にした上で次の通知を待つ | |
$this->changeToStateAfterInitialMeasurement(); | |
return; | |
} | |
// 許可実行回数を超えているが、既に経過時間が閾値を超えている | |
// カウンター系を初期計測後状態にした上で次の通知を待つ | |
$this->changeToStateAfterInitialMeasurement(); | |
} | |
/** | |
* 経過時間を返す | |
* | |
* 単純に time() timestamp で計算をしてしまうと、例えば<br> | |
* 1) xx時xx分1.9秒のときに基準時間をセット<br> | |
* 2) 次の通知の時にxx時xx分2.0秒だったとする<br> | |
* こういったケースがあった場合、前回からたった0.1秒しか経っていないのに差分が 1(秒) として出てしまう。 | |
* | |
* 上記ケースを防ぐためにmicrotimeでの計算を行う。<br> | |
* そのために当関数はmicrotime()の結果を引数で受け取る。 | |
* | |
* また、microtime(true)での計算を行うと計測精度が落ちるので、<br> | |
* なるべく正確に差分を測るために microtime() の結果から 整数部と小数部を取得し経過時間を計算している。 | |
* @link https://tgws.plus/prog/phpmicrotime/ | |
* | |
* @param string $now microtime()の結果 "msec sec" | |
* @param string $reference microtime()の結果 "msec sec" | |
* @return float | |
*/ | |
private function elapsedTime($now, $reference) | |
{ | |
list($nowMsec, $nowSec) = explode(' ', $now); | |
list($referenceMsec, $referenceSec) = explode(' ', $reference); | |
return ((float)$nowMsec - (float)$referenceMsec) + ((float)$nowSec - (float)$referenceSec); | |
} | |
/** | |
* 内部変数のリセット | |
* | |
* 内部変数の値をこのクラスが持つ定数の値でリセットします | |
* | |
* @return void | |
*/ | |
public function reset() | |
{ | |
$this->threshold = self::DEFAULT_THRESHOLD; | |
$this->allowExecCnt = self::DEFAULT_ALLOW_EXEC_CNT; | |
$this->execCnt = 0; | |
$this->referenceTime = null; | |
} | |
/** | |
* 実行回数 getter | |
* @return int | |
*/ | |
public function getExecCnt() | |
{ | |
return $this->execCnt; | |
} | |
/** | |
* sleep(usleep)実行 | |
* @param int $microSeconds | |
* @return void | |
*/ | |
protected function _sleep($microSeconds) | |
{ | |
usleep($microSeconds); | |
} | |
/** | |
* カウンター系を初期計測後状態にする | |
* | |
* 閾値などを超えた際は初期計測後状態(1回目の通知を受けた状態)にして次の通知を待つ<br> | |
* ここで null や 0 をセットしてしまうと、次の監視対象の処理(実行回数や処理にかかった時間)が無視されてしまう | |
* | |
* // ----- 例 ----- | |
* | |
* // 監視対象処理1<br> | |
* doSomething(); | |
* | |
* // 通知(監視対象処理2の監視)<br> | |
* notify(); // 閾値over | |
* | |
* // ↓ 閾値overの後にこうしてしまうと<br> | |
* 実行回数に 0 <br> | |
* 基準時間に null | |
* | |
* // 監視対象処理2<br> | |
* doSomething(); | |
* | |
* // 通知(監視対象処理3の監視)<br> | |
* notify(); | |
* | |
* // ----- この時点で | |
* | |
* 実行回数 1<br> | |
* ※ 本来であれば監視対象処理2は行われているので 2回目の監視処理開始の通知としてカウントするべき<br> | |
* 基準時間がこの通知の時点になってしまう<br> | |
* ※ 監視対象処理2の処理時間が無視される<br> | |
* となってしまう | |
* | |
* // 監視対象処理3<br> | |
* doSomething(); | |
* | |
* ※ 要は1番最初の計測状態にするということ | |
* | |
* @return void | |
*/ | |
protected function changeToStateAfterInitialMeasurement() | |
{ | |
$this->execCnt = 1; | |
$this->referenceTime = microtime(); | |
} | |
/** | |
* 閾値を上書きして返す | |
* @param int $threshold | |
* @return ExecutionController | |
*/ | |
public function overrideThreshold($threshold) | |
{ | |
$this->threshold = $threshold; | |
return $this; | |
} | |
/** | |
* 閾値内(決められた時間)内に許可する実行数を上書きして返す | |
* @param int $allowExecCnt | |
* @return ExecutionController | |
*/ | |
public function overrideAllowExecCnt($allowExecCnt) | |
{ | |
$this->allowExecCnt = $allowExecCnt; | |
return $this; | |
} | |
} |
<?php | |
namespace Sample1Test; | |
require_once 'ExecutionController.php'; | |
use Sample1\ExecutionController; | |
/** | |
* Test Of Sample1\ExecutionController | |
*/ | |
class ExecutionControllerTest extends \PHPUnit_Framework_TestCase | |
{ | |
/** | |
* チェック対象クラス | |
* | |
* @var ExecutionController | |
*/ | |
protected $target = null; | |
/** | |
* Setup | |
* | |
* {@inheritDoc} | |
*/ | |
protected function setUp() | |
{ | |
parent::setUp(); | |
$this->target = new ExecutionController(); | |
} | |
/** | |
* tearDown | |
* | |
* {@inheritDoc} | |
*/ | |
protected function tearDown() | |
{ | |
parent::tearDown(); | |
} | |
/** | |
* @test | |
*/ | |
public function 通知を受けた際に実行回数がインクリメントされる() | |
{ | |
// 3回通知を行う | |
$this->target->notify(); | |
$this->target->notify(); | |
$this->target->notify(); | |
$expected = 3; | |
$actual = $this->target->getExecCnt(); | |
$this->assertSame($expected, $actual); | |
} | |
/** | |
* @test | |
*/ | |
public function 最初の通知で基準時間が設定されている() | |
{ | |
// インスタンス生成直後はnull | |
$this->assertNull($this->_getByReflection($this->target, 'referenceTime')); | |
// 通知する | |
$this->target->notify(); | |
// 通知した後は何かしらセットされている | |
$this->assertNotNull($this->_getByReflection($this->target, 'referenceTime')); | |
} | |
/** | |
* @test | |
*/ | |
public function 閾値を超えた場合sleepされる() | |
{ | |
$mock = $this->_buildMock(['_sleep']); | |
// 閾値を 1秒 に | |
$mock->overrideThreshold(1); | |
// 閾値内に実行出来る回数を 2 に | |
$mock->overrideAllowExecCnt(2); | |
// 3回通知を受けたら 1回 sleepが走るし、≒(1 - 0.2) 秒 くらいのsleepが走る | |
$mock | |
->expects($this->once()) | |
->method('_sleep') | |
->with($this->callback(function($microSeconds) { | |
return $microSeconds <= 800000 && $microSeconds > 700000; | |
})) | |
; | |
$mock->notify(); | |
$this->_doSomething('usleep', 100000); // 0.1秒待機 | |
$mock->notify(); | |
$this->_doSomething('usleep', 100000); // 0.1秒待機 | |
$mock->notify(); // 1秒以内に閾値を超える回数が実行されるのでsleepが走るはず | |
$this->_doSomething(); // これは3回目の処理 | |
// 実行回数は 1 になっている | |
// 上記3回目の$this->doSomething()を1回目の処理として扱う | |
$this->assertSame(1, $mock->getExecCnt()); | |
} | |
/** | |
* @test | |
*/ | |
public function 閾値を超えた場合sleepされる_複数回() | |
{ | |
$mock = $this->_buildMock(['_sleep']); | |
// 閾値を 1秒 に | |
$mock->overrideThreshold(1); | |
// 閾値内に実行出来る回数を 2 に | |
$mock->overrideAllowExecCnt(2); | |
// 6回通知を受けたら 2回 sleepが走るし、 | |
// 1回目は ≒(1 - 0.2) 秒 くらいのsleep | |
// 2回目は ≒(1 - 0.4) 秒 くらいのsleep | |
// が走る | |
$mock | |
->expects($this->exactly(2)) | |
->method('_sleep') | |
->withConsecutive( | |
[ | |
$this->callback(function($microSeconds) { | |
return $microSeconds <= 800000 && $microSeconds > 700000; | |
}) | |
], | |
[ | |
$this->callback(function($microSeconds) { | |
return $microSeconds <= 600000 && $microSeconds > 500000; | |
}) | |
] | |
) | |
; | |
// ----- assertion開始 | |
$mock->notify(); // 1回目の監視 | |
$this->_doSomething('usleep', 100000); // 1回目の監視対象処理 | |
$mock->notify(); // 2回目の監視 | |
$this->_doSomething('usleep', 100000); // 2回目の監視対象処理 | |
$mock->notify(); // 3回目の監視 1度目のsleep実行 | |
// 続けて実行されたくないので↑でsleep | |
$this->_doSomething('usleep', 200000); // 3回目の監視対象処理(1回目の監視対象処理) | |
// 3回目の監視対象処理は1回目の監視対象処理として扱われている | |
// 1度目のsleep実行の後に実行回数を1としている | |
// つまり3回目の監視は1回目の監視と同様になる | |
$this->assertSame(1, $mock->getExecCnt()); | |
$mock->notify(); // 2回目の監視 | |
$this->_doSomething('usleep', 200000); // 2回目の監視対象処理 | |
$mock->notify(); // 3回目の監視 2度目のsleep実行 | |
// 続けて実行されたくないので↑でsleep | |
$this->_doSomething(); | |
// この時点でカウンターは初期計測後状態になっている | |
$this->assertSame(1, $mock->getExecCnt()); | |
// 1回最後に実行 | |
$mock->notify(); | |
$this->_doSomething(); | |
// 実行回数は 2 になっている | |
$this->assertSame(2, $mock->getExecCnt()); | |
} | |
/** | |
* @test | |
*/ | |
public function 許可実行回数を実行回数は超えていないが最初の実行からの経過時間が閾値を超えている場合カウンター系が初期計測後状態になる() | |
{ | |
// 閾値を 1秒 に | |
$this->target->overrideThreshold(1); | |
// 閾値内に実行出来る回数を 3 に | |
$this->target->overrideAllowExecCnt(3); | |
$this->target->notify(); | |
$this->target->notify(); | |
$this->_doSomething('usleep', 1100000); // 1.1秒待機 | |
// ----- ここまでで最初の計測から 1秒以上 経っている | |
$this->target->notify(); // 3回は実行可能だが経過時間の閾値は過ぎているのでカウンターは初期計測後状態になる | |
// 実行回数は 1 になっている | |
$this->assertSame(1, $this->target->getExecCnt()); | |
} | |
/** | |
* @test | |
*/ | |
public function 許可実行回数を実行回数は超えたが最初の実行からの経過時間が閾値を既に超えている場合カウンター系が初期計測後状態になる() | |
{ | |
$mock = $this->_buildMock(['_sleep']); | |
// 閾値を 1秒 に | |
$mock->overrideThreshold(1); | |
// 閾値内に実行出来る回数を 2 に | |
$mock->overrideAllowExecCnt(2); | |
// sleepは走らないこと | |
$mock->expects($this->never())->method('_sleep'); | |
$mock->notify(); | |
$mock->notify(); | |
$this->_doSomething('usleep', 1100000); // 1.1秒待機 | |
// ----- ここまでで最初の計測から 1秒以上 経っている | |
$mock->notify(); // 3回目の実行で許容実行回数を超えているが既に経過時間の閾値は過ぎているのでsleepは行われないしカウンターも初期計測後状態になる | |
// 実行回数は 1 になっている | |
$this->assertSame(1, $mock->getExecCnt()); | |
} | |
/** | |
* @test | |
*/ | |
public function resetできる() | |
{ | |
// 適当に上書きしておく | |
$this->target->overrideThreshold(99999); | |
$this->target->overrideAllowExecCnt(99999); | |
// 実行回数を増やしておく | |
$this->target->notify(); | |
$this->target->notify(); | |
// reset | |
$this->target->reset(); | |
$this->assertSame( | |
ExecutionController::DEFAULT_THRESHOLD, | |
$this->_getByReflection($this->target, 'threshold') | |
); | |
$this->assertSame( | |
ExecutionController::DEFAULT_ALLOW_EXEC_CNT, | |
$this->_getByReflection($this->target, 'allowExecCnt') | |
); | |
$this->assertSame(0, $this->target->getExecCnt()); | |
} | |
/** | |
* @test | |
*/ | |
public function overrideThreshold_閾値を上書きできる() | |
{ | |
$this->target->overrideThreshold(99); | |
$expected = 99; | |
$actual = $this->_getByReflection($this->target, 'threshold'); | |
$this->assertSame($expected, $actual); | |
} | |
/** | |
* @test | |
*/ | |
public function overrideAllowExecCnt_決められた時間内に実行出来る数を上書きできる() | |
{ | |
$this->target->overrideAllowExecCnt(99); | |
$expected = 99; | |
$actual = $this->_getByReflection($this->target, 'allowExecCnt'); | |
$this->assertSame($expected, $actual); | |
} | |
/* | |
|-------------------------------------------------------------------------- | |
| helper | |
|-------------------------------------------------------------------------- | |
*/ | |
/** | |
* アクセス不能なプロパティの値を取得する | |
* @param mixed $target 対象インスタンス | |
* @param string $propertyName プロパティ名 | |
* @return mixed 取得したプロパティの値 | |
*/ | |
private function _getByReflection($target, $propertyName) | |
{ | |
$refClass = new \ReflectionClass($target); | |
$refProperty = $refClass->getProperty($propertyName); | |
$refProperty->setAccessible(true); | |
return $refProperty->getValue($target); | |
} | |
/** | |
* ExecutionControllerのモック | |
* @param array $methods | |
* @return \PHPUnit_Framework_MockObject_MockObject | |
*/ | |
private function _buildMock(array $methods) | |
{ | |
$mock = $this->getMockBuilder('Sample1\ExecutionController') | |
->disableOriginalConstructor() | |
->setMethods($methods) | |
->getMock(); | |
return $mock; | |
} | |
/** | |
* dummyで何かやる | |
* @param callable $func | |
* @param mixed $arg | |
* @return void | |
*/ | |
private function _doSomething(callable $func=null, $arg=null) | |
{ | |
if ($func === null) { | |
return; | |
} | |
call_user_func($func, $arg); | |
} | |
} |
だが、待ってほしい
これで一見うまくいっているように見えなくもないですが、1つ気をつけないといけない点があります。
閾値が1秒、制限時間内許可実行数が5回 (秒間5回の制限) の場合を例にします。
※ 例を極端にしています
- 6回目の監視時点で0.9秒しか立っていない場合、0.1秒のsleepを挟む
- 6回目の監視時点で0.1秒しか立っていない場合、0.9秒のsleepを挟む
ということになります。
裏を返すと 1回の処理が0.02秒 で終わってしまう処理でも通過することになります。
つまり、「秒間5回までとは言ったが、0.1秒で5回呼ばれるとは思わなかった!!」と言われる可能性もあるという点です。
※ 単位(sなのか、msなのか) によって許容範囲が変わるかもしれないという話です
あかんかもしれないパターンの図
図に表すとこんなイメージです。
処理を間引くパターン
上記の問題があったので、違うパターンを考えてみました。
以下のクラスを用意します。
- APIの実行回数を監視するクラス
- このクラスは監視メソッドを持つ
- 時刻はmicrotimeで扱う
- 自身が最後に呼ばれた時刻を保持している
- API呼び出し直前に必ず呼んでもらう
前述とほぼ一緒で、違うところは最後に呼ばれた時刻を保持することと、呼ばれた回数は保持しないというところです。
そして、この監視メソッドは以下の処理を行います。
- 最初の命令から閾値(マイクロ秒)以下で処理が実行されている場合、
閾値 - 前回の処理から今回の処理までの実行間隔
の値でusleep
を挟む
イメージとしては制限を超えないように処理を間引くという具合です。
フローチャート
フローチャートはこんな感じです。
イメージ
動作イメージを図に表すとこんな感じです。
ソースとテスト
ソース(sample_2/ExecutionController.php
)と、テスト(sample_2/ExecutionControllerTest.php
)はこうです。
<?php | |
namespace Sample2; | |
/** | |
* 当クラスでは更新命令を監視し(実際には教えてもらうが) | |
* | |
* - 最初の命令から閾値(マイクロ秒)以下で処理が実行されている場合、 | |
* 閾値 - 前回の処理から今回の処理までの実行間隔 の値で usleep を挟む | |
* | |
* 処理を行う。 | |
* | |
* 閾値は、制限時間 / 制限時間内許可実行数 で求める。<br> | |
* 1秒間に100回という制約の場合、 1 / 100 なので 0.01 となる。 | |
* | |
* 監視している処理の実行間隔が上記の 0.01秒 以下で終わっている場合、<br> | |
* 1秒間に100回以上の処理が走ってしまう計算になる。 | |
* | |
* その為、前回の処理から今回の処理までの実行間隔が0.01秒 以下で終わっている場合は、<br> | |
* 閾値 - 前回の処理から今回の処理までの実行間隔 の値で usleepをかける。<br> | |
* ※ 閾値が 0.01, 前回の処理から今回の処理までの実行間隔が 0.009 だとしたら 0.001秒 sleep をかける | |
* | |
* 要は "x秒間にy回までの実行回数" にする為、均等に処理が実行されるということになる。<br> | |
* ※ 1秒間に100回という制約の場合、処理間隔が 0.01 秒で収まる ※ベストエフォート | |
* ※ 100回を1秒間で均等に行うというイメージ | |
* | |
* 1秒間に1回という制限を設けた際に、処理が0.01秒で終わるものでも10回監視した場合は<br> | |
* 9秒以上処理に時間がかかるという事になる。 | |
* | |
* インスタンス生成後、監視対象となる処理が実行される前に notify() を呼んで使う。 | |
* | |
* <code> | |
* // インスタンス生成<br> | |
* $executionController = new ExecutionController(); | |
* | |
* //----- 監視したい処理の前に呼ぶ ----- | |
* | |
* // notify() で通知する<br> | |
* $executionController->notify(); | |
* | |
* // 監視する処理<br> | |
* $target->exec(); | |
* </code> | |
*/ | |
class ExecutionController | |
{ | |
/** | |
* マイクロ秒単位 | |
* | |
* マイクロ秒とは、1秒の100万分の1 1秒は 1000000 | |
* | |
* @var int | |
*/ | |
const UNIT_OF_MICROSECONDS = 1000000; | |
/** | |
* デフォルトの制限時間 x秒間約y回までの xの部分 | |
* @var int|float e.g) 1, 0.1 単位は秒 | |
*/ | |
const DEFAULT_DEFINED_TIME_LIMIT = 1; | |
/** | |
* デフォルトの閾値内(決められた時間)内に許可する実行数 x秒間y回数発行可能の yの値 | |
* @var int | |
*/ | |
const DEFAULT_ALLOW_EXEC_CNT = 100; | |
/** | |
* 制限時間 x秒間約y回までの xの部分 | |
* @var int|float e.g) 1, 0.1 単位は秒 | |
*/ | |
private $definedTimeLimit = self::DEFAULT_DEFINED_TIME_LIMIT; | |
/** | |
* 制限時間内許可実行数 xx秒間〇〇回数発行可能の ○○の値 | |
* @var int | |
*/ | |
private $allowExecCnt = self::DEFAULT_ALLOW_EXEC_CNT; | |
/** | |
* 閾値 制限時間 / 制限時間内許可実行数 の値 | |
* @var int|float | |
*/ | |
private $threshold = 0; | |
/** | |
* 基準時間 | |
* @var string microtime() | |
*/ | |
private $referenceTime = null; | |
/** | |
* コンストラクタ | |
* | |
* 監視に必要な値を設定 | |
*/ | |
public function __construct() | |
{ | |
$this->setupForMonitoring(); | |
} | |
/** | |
* 通知 | |
* | |
* 監視させたい処理の前で呼ぶこと | |
* | |
* @return void | |
*/ | |
public function notify() | |
{ | |
// 基準時間がセットされていない場合、取得/set だけしておいて終了 | |
// 一番最初の呼び出しが該当する | |
if ($this->referenceTime === null) { | |
$this->referenceTime = microtime(); | |
return; | |
} | |
// 前回実行時点との実行間隔 | |
$elapsedTime = $this->elapsedTime(microtime(), $this->referenceTime); | |
/* | |
|---------------------------------------------------------------------- | |
| 前回の実行から閾値以下で実行されているかどうか | |
|---------------------------------------------------------------------- | |
| 例えば、1秒間に100回までと決められている場合、監視している処理の実行間隔が | |
| 0.01秒以下で終わっていると1秒間に100回以上実行されてしまうことになる。 | |
| そのため、実行間隔が閾値以下になってるかを判定する。 | |
| | |
| true であれば実行間隔が閾値以下で実行されていることになる | |
*/ | |
$isOverSpeed = $elapsedTime < $this->threshold; | |
// 実行間隔が閾値以下で実行されていた場合、閾値の間隔で処理が実行されるようにsleepを挟む | |
if ($isOverSpeed) { | |
// sleep | |
$this->_sleep((int)(($this->threshold - $elapsedTime) * self::UNIT_OF_MICROSECONDS)); | |
} | |
// 監視が終わったら基準時間を設定し直す | |
$this->referenceTime = microtime(); | |
} | |
/** | |
* sleep(usleep) 実行 | |
* @param int $microSeconds | |
* @return void | |
*/ | |
protected function _sleep($microSeconds) | |
{ | |
usleep($microSeconds); | |
} | |
/** | |
* 経過時間(マイクロ秒単位)を返す | |
* | |
* 当関数は microtime() の結果を引数で受け取る。 | |
* microtime(true)での計算を行うと計測精度が落ちるので、<br> | |
* なるべく正確に差分を測るために microtime() の結果から 整数部と小数部を取得し経過時間を計算している。 | |
* | |
* @link https://tgws.plus/prog/phpmicrotime/ | |
* @param string $now microtime()の結果 "msec sec" | |
* @param string $reference microtime()の結果 "msec sec" | |
* @return float | |
*/ | |
private function elapsedTime($now, $reference) | |
{ | |
list($nowMsec, $nowSec) = explode(' ', $now); | |
list($referenceMsec, $referenceSec) = explode(' ', $reference); | |
return ((float)$nowMsec - (float)$referenceMsec) + ((float)$nowSec - (float)$referenceSec); | |
} | |
/** | |
* 内部変数のリセット | |
* | |
* 内部変数の値をこのクラスが持つ定数の値でリセットします | |
* @return void | |
*/ | |
public function reset() | |
{ | |
$this->definedTimeLimit = self::DEFAULT_DEFINED_TIME_LIMIT; | |
$this->allowExecCnt = self::DEFAULT_ALLOW_EXEC_CNT; | |
$this->threshold = $this->definedTimeLimit / $this->allowExecCnt; | |
$this->referenceTime = null; | |
} | |
/** | |
* 制限時間を上書きして返す | |
* @param int|float $definedTimeLimit | |
* @return ExecutionController | |
*/ | |
public function overrideDefinedTimeLimit($definedTimeLimit) | |
{ | |
$this->definedTimeLimit = $definedTimeLimit; | |
return $this; | |
} | |
/** | |
* 制限時間内許可実行数を上書きして返す | |
* | |
* @param int $allowExecCnt | |
* @return ExecutionController | |
* @throws \InvalidArgumentExcepti | |
*/ | |
public function overrideAllowExecCnt($allowExecCnt) | |
{ | |
if ((int)$allowExecCnt <= 0) { | |
throw new \InvalidArgumentException('制限時間内許可実行数は0より大きい整数を設定してください'); | |
} | |
$this->allowExecCnt = (int)$allowExecCnt; | |
return $this; | |
} | |
/** | |
* 監視に必要な値を設定 | |
* @return ExecutionController | |
*/ | |
public function setupForMonitoring() | |
{ | |
// 閾値の決定 | |
$this->threshold = $this->definedTimeLimit / $this->allowExecCnt; | |
return $this; | |
} | |
} |
<?php | |
namespace Sample2Test; | |
require_once 'ExecutionController.php'; | |
use Sample2\ExecutionController; | |
/** | |
* Test Of Sample2\ExecutionController | |
*/ | |
class ExecutionControllerTest extends \PHPUnit_Framework_TestCase | |
{ | |
/** | |
* チェック対象クラス | |
* | |
* @var ExecutionController | |
*/ | |
protected $target = null; | |
/** | |
* Setup | |
* | |
* {@inheritDoc} | |
*/ | |
protected function setUp() | |
{ | |
parent::setUp(); | |
$this->target = new ExecutionController(); | |
} | |
/** | |
* tearDown | |
* | |
* {@inheritDoc} | |
*/ | |
protected function tearDown() | |
{ | |
parent::tearDown(); | |
} | |
/** | |
* @test | |
*/ | |
public function 最初の通知で基準時間が設定されている() | |
{ | |
// インスタンス生成直後はnull | |
$this->assertNull($this->_getByReflection($this->target, 'referenceTime')); | |
// 通知する | |
$this->target->notify(); | |
// 通知した後は何かしらセットされている | |
$this->assertNotNull($this->_getByReflection($this->target, 'referenceTime')); | |
} | |
/** | |
* @test | |
*/ | |
public function 閾値を超えた場合sleepされる() | |
{ | |
$mock = $this->_buildMock(['_sleep']); | |
// 制限時間を 1秒 に | |
$mock->overrideDefinedTimeLimit(1); | |
// 閾値内に実行出来る回数を 2 に | |
$mock->overrideAllowExecCnt(2); | |
// 再セット 閾値は 0.5 | |
$mock->setupForMonitoring(); | |
// 4回中2回は閾値以下の実行速度で終わるはずなので 2回 sleepが走るし、 | |
// 1回目は ≒(0.5 - 0.1) 秒 くらいのsleep | |
// 2回目は ≒(0.5 - 0.3) 秒 くらいのsleep | |
// が走る | |
$mock | |
->expects($this->exactly(2)) | |
->method('_sleep') | |
->withConsecutive( | |
[ | |
$this->callback(function($microSeconds) { | |
return $microSeconds <= 400000 && $microSeconds > 300000; | |
}) | |
], | |
[ | |
$this->callback(function($microSeconds) { | |
return $microSeconds <= 200000 && $microSeconds > 100000; | |
}) | |
] | |
) | |
; | |
$mock->notify(); | |
$this->_doSomething('usleep', 100000); // 0.1秒待機 | |
$mock->notify(); // 1回目のsleep | |
$this->_doSomething('usleep', 500000); // 0.5秒待機 | |
$mock->notify(); // 閾値以上の時間がかかっているので ここでは _sleepされない | |
$this->_doSomething('usleep', 300000); // 0.3秒待機 | |
$mock->notify(); // 2回目のsleep | |
} | |
/** | |
* @test | |
*/ | |
public function 閾値を超えなければsleepされない() | |
{ | |
$mock = $this->_buildMock(['_sleep']); | |
// 制限時間を 0.01秒 に | |
$mock->overrideDefinedTimeLimit(0.01); | |
// 閾値内に実行出来る回数を 100 に | |
$mock->overrideAllowExecCnt(100); | |
// 再セット 閾値は 0.0001 | |
$mock->setupForMonitoring(); | |
// 閾値以上の秒数で行われるので_sleepは呼ばれない | |
$mock->expects($this->never())->method('_sleep'); | |
$mock->notify(); | |
$this->_doSomething('usleep', 1000); // 0.0001秒待機 | |
$mock->notify(); | |
$this->_doSomething('usleep', 1000); // 0.0001秒待機 | |
$mock->notify(); | |
} | |
/** | |
* @test | |
*/ | |
public function resetできる() | |
{ | |
// 適当に上書きしておく | |
$this->target->overrideDefinedTimeLimit(99999); | |
$this->target->overrideAllowExecCnt(99999); | |
// 実行回数を増やしておく | |
$this->target->notify(); | |
$this->target->notify(); | |
// reset | |
$this->target->reset(); | |
$definedTimeLimit = $this->_getByReflection($this->target, 'definedTimeLimit'); | |
$this->assertSame( | |
ExecutionController::DEFAULT_DEFINED_TIME_LIMIT, | |
$definedTimeLimit | |
); | |
$allowExecCnt = $this->_getByReflection($this->target, 'allowExecCnt'); | |
$this->assertSame( | |
ExecutionController::DEFAULT_ALLOW_EXEC_CNT, | |
$allowExecCnt | |
); | |
$threshold = $this->_getByReflection($this->target, 'threshold'); | |
$this->assertSame( | |
$definedTimeLimit / $allowExecCnt, | |
$threshold | |
); | |
$referenceTime = $this->_getByReflection($this->target, 'referenceTime'); | |
$this->assertNull($referenceTime); | |
} | |
/** | |
* @test | |
*/ | |
public function overrideDefinedTimeLimit_制限時間を上書きできる() | |
{ | |
$this->target->overrideDefinedTimeLimit(99); | |
$expected = 99; | |
$actual = $this->_getByReflection($this->target, 'definedTimeLimit'); | |
$this->assertSame($expected, $actual); | |
} | |
/** | |
* @test | |
*/ | |
public function overrideAllowExecCnt_決められた時間内に実行出来る数を上書きできる() | |
{ | |
$this->target->overrideAllowExecCnt(99); | |
$expected = 99; | |
$actual = $this->_getByReflection($this->target, 'allowExecCnt'); | |
$this->assertSame($expected, $actual); | |
} | |
/** | |
* @test | |
* @expectedException \InvalidArgumentException | |
* @expectedExceptionMessage 制限時間内許可実行数は0より大きい整数を設定してください | |
* @dataProvider overrideAllowExecCntProvider | |
*/ | |
public function overrideAllowExecCnt_1以下の値が設定されたらInvalidArgumentException($arg) | |
{ | |
$this->target->overrideAllowExecCnt($arg); | |
} | |
/** | |
* overrideAllowExecCntProvider | |
*/ | |
public function overrideAllowExecCntProvider() | |
{ | |
return [ | |
[0], | |
[0.00001], | |
[-1], | |
['hoge'] | |
]; | |
} | |
/** | |
* @test | |
*/ | |
public function setupForMonitoring_監視に必要な値を設定できる() | |
{ | |
// 適当に上書きしておく | |
$this->target->overrideDefinedTimeLimit(1); | |
$this->target->overrideAllowExecCnt(2); | |
// 監視に必要な値を設定 | |
$this->target->setupForMonitoring(); | |
// 1 / 2 なので 0.5 | |
$this->assertSame(0.5, $this->_getByReflection($this->target, 'threshold')); | |
} | |
/* | |
|-------------------------------------------------------------------------- | |
| helper | |
|-------------------------------------------------------------------------- | |
*/ | |
/** | |
* アクセス不能なプロパティの値を取得する | |
* @param mixed $target 対象インスタンス | |
* @param string $propertyName プロパティ名 | |
* @return mixed 取得したプロパティの値 | |
*/ | |
private function _getByReflection($target, $propertyName) | |
{ | |
$refClass = new \ReflectionClass($target); | |
$refProperty = $refClass->getProperty($propertyName); | |
$refProperty->setAccessible(true); | |
return $refProperty->getValue($target); | |
} | |
/** | |
* ExecutionControllerのモック | |
* @param array $methods | |
* @return \PHPUnit_Framework_MockObject_MockObject | |
*/ | |
private function _buildMock(array $methods) | |
{ | |
$mock = $this->getMockBuilder('Sample2\ExecutionController') | |
->disableOriginalConstructor() | |
->setMethods($methods) | |
->getMock(); | |
return $mock; | |
} | |
/** | |
* dummyで何かやる | |
* @param callable $func | |
* @param mixed $arg | |
* @return void | |
*/ | |
private function _doSomething(callable $func=null, $arg=null) | |
{ | |
if ($func === null) { | |
return; | |
} | |
call_user_func($func, $arg); | |
} | |
} |
特徴
閾値は、制限時間 / 制限時間内許可実行数
で求める。
1秒間に100回という制約の場合、 1 / 100 なので 0.01 となる。
監視している処理の実行間隔が上記の 0.01秒 以下で終わっている場合、1秒間に100回以上の処理が走ってしまう計算になる為、前回の処理から今回の処理までの実行間隔が0.01秒 以下で終わっている場合は、閾値 - 前回の処理から今回の処理までの実行間隔
の値で usleepを挟む。
※ 閾値が 0.01, 前回の処理から今回の処理までの実行間隔が 0.009 だとしたら 0.001秒 sleep をかける
要は x秒間にy回までの実行回数
にする為、均等に処理が実行されるということになる。
※ 1秒間に100回という制約の場合、処理間隔が 0.01 秒で収まる(ベストエフォート)
※ 100回を1秒間で均等に行うというイメージ
注意点
ただし注意点もあって、1秒間に1回という制限を設けた際に処理が0.01秒で終わるものでも、10回監視した場合は9秒以上処理に時間がかかるという事になります。
※ 例によって極端ですけど
その他
シングルトンは?
色々な場所から呼ぶAPIをコントロールしたい場合、シングルトンになっていると便利かと思います。
が、そんなときがもしあったらTrait
なりを用意してgetInstance()
的なメソッドを用意してあげればよいのではないでしょうか。
総括
というわけで、最初に考えたお題に対して2つの回答を用意してみました。
どれも一長一短あると思うので、
- 処理時間がある程度かかるもの、または、瞬殺で終わってもある程度許容できるものは前者のパターン
- 処理時間が瞬殺かつ、大量に呼ぶ必要があるものは後者のパターン
というように使い分けるのがいいのかなと個人的には思いました。
まぁ、よっぽどのことがない限りは後者のパターンで事足りるのではないかと。
(もちろん、他にいい方法はあると思います)
長くなってしまいましたが、現場からは以上です。
あ、いちおう免責としてこれを使ってなにかあっても責任は負えませんのであしからず。。