Skip to content

Instantly share code, notes, and snippets.

@shimabox
Last active October 4, 2018 23:42
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save shimabox/aed7b475f01572f6d3febec23558b539 to your computer and use it in GitHub Desktop.
Save shimabox/aed7b475f01572f6d3febec23558b539 to your computer and use it in GitHub Desktop.
APIの実行回数を呼び出し元で制限する-サンプル1 @see https://blog.shimabox.net/2018/10/04/restrict_the_execution_count_of_api_by_caller/
<?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);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment