同一IPによる連続アクションを一定時間ブロックする

More than 1 year has passed since last update.


APCuを使用する

PHPエクステンションのインストールが必要な点で導入の敷居が上がりますが,パフォーマンスと使いやすさの観点,どちらから見ても最強です.MemcachedやRedisよりもコードが圧倒的に短く済むので,オンメモリの選択肢の中では一番おすすめです.


使用例 (関数を作る必要すらない)

if (apcu_add($_SERVER['REMOTE_ADDR'], 1, 20)) {

echo '何か処理を実行しました';
} else {
echo '20秒以内の連続実行はダメよ!';
}


識別子ごとのファイルを使用する

PHPのエクステンションが導入できない事情があるが,そこそこのパフォーマンスは欲しい,というときにはこの方法がおすすめです.


関数定義 (GCを非同期で行う場合)

<?php

/**
* アクション可能かどうかを論理値で返します.
*
* @param string $id チェックしたい識別子(IPアドレスなど)
* @param int $span 連続アクションをブロックする秒数
* 省略すると60秒が適用される
* @param string $dir ファイルを保存するディレクトリ(書込/実行)
* 省略するとシステムのテンポラリディレクトリを使用
* @param string $prefix ファイル名に付けるプレフィックス
* @param string $suffix ファイル名に付けるサフィックス
* @param float $gc_probability ガベージコレクションを行う確率
* @param bool $clear_cache キャッシュをクリアするかどうか(1アクセスで2回以上実行しない場合は不要)
* @return bool アクション可能かどうか
*/

function validate_span(
$id, $span, $dir = null, $prefix = 'php_span_', $suffix = '.log',
$gc_probability = 0.05, $clear_cache = false)
{
if ($clear_cache) {
clearstatcache();
}
if ($dir === null) {
$dir = sys_get_temp_dir();
}
if (mt_rand() / mt_getrandmax() <= $gc_probability) {
$code = '
sleep(1);
foreach (glob("$argv[1]/$argv[2]*$argv[3]") as $path) {
if (time() - filemtime($path) > $argv[4]) {
unlink($path);
}
}
'
;
$format = DIRECTORY_SEPARATOR === '\\'
? 'start /b php -r %s >&2'
: 'php -r %s >&2 &'
;
$exec = DIRECTORY_SEPARATOR === '\\'
? function ($cmd) { popen($cmd, 'rb'); }
: 'passthru'
;
$exec(sprintf($format, implode(' ', array_map('escapeshellarg', [
$code, $dir, $prefix, $suffix, $span
]))));
}
$id = preg_replace('/[^\w_]/', '-', $id);
$path = "$dir/$prefix$id$suffix";
if (!file_exists($path) || time() - filemtime($path) > $span) {
touch($path);
return true;
}
return false;
}



関数定義 (GCを同期的に行う場合)

<?php

/**
* アクション可能かどうかを論理値で返します.
*
* @param string $id チェックしたい識別子(IPアドレスなど)
* @param int $span 連続アクションをブロックする秒数
* 省略すると60秒が適用される
* @param string $dir ファイルを保存するディレクトリ(書込/実行)
* 省略するとシステムのテンポラリディレクトリを使用
* @param string $prefix ファイル名に付けるプレフィックス
* @param string $suffix ファイル名に付けるサフィックス
* @param float $gc_probability ガベージコレクションを行う確率
* @param bool $clear_cache キャッシュをクリアするかどうか(1アクセスで2回以上実行しない場合は不要)
* @return bool アクション可能かどうか
*/

function validate_span(
$id, $span, $dir = null, $prefix = 'php_span_', $suffix = '.log',
$gc_probability = 0.05, $clear_cache = false)
{
if ($clear_cache) {
clearstatcache();
}
if ($dir === null) {
$dir = sys_get_temp_dir();
}
if (mt_rand() / mt_getrandmax() <= $gc_probability) {
foreach (glob("$dir/$prefix*$suffix") as $path) {
if (time() - filemtime($path) > $span) {
unlink($path);
}
}
}
$id = preg_replace('/[^\w_]/', '-', $id);
$path = "$dir/$prefix$id$suffix";
if (!file_exists($path) || time() - filemtime($path) > $span) {
touch($path);
return true;
}
return false;
}



使用例

if (validate_span($_SERVER['REMOTE_ADDR'], 20, '/tmp', 'iplog_')) {

echo '何か処理を実行しました';
} else {
echo '20秒以内の連続実行はダメよ!';
}

比較的よいパフォーマンスが得られますが,この方法はファイルが大量に作られてしまう点が難点です.そのため,ガベージコレクションは必須になります.

※ PHPのビルトインサーバでは非同期GCするとWebブラウザのインジケータが回りっぱなしになりますが,正式な動作方式であるmod_php,php-cgi,php-fpmではすべて問題無さそうでした.


SQLiteを使用する

パフォーマンスは二の次でいいが,とにかく取り扱いを簡単にしてほしい,という場合にはSQLiteを使う方法がおすすめです.単一のファイルで完結し,PHP標準ビルドで超お手軽に利用できることがアドバンテージです.

(非同期GCの実装は省略)


クラス定義

<?php

class TimeSpanValidator
{
private $pdo;

/**
* コンストラクタ.ファイル生成と同時にテーブルも初期化します.
*
* @param string $filename データベースファイル名
*/

public function __construct($filename)
{
$this->pdo = new \PDO('sqlite:' . $filename, null, null, [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
]);
$this->pdo->exec("
PRAGMA synchronous = 0;
CREATE TABLE IF NOT EXISTS log(
data TEXT UNIQUE,
latest INT
);
"
);
}

/**
* データベースの更新・照合を行い,アクション可能かどうかを論理値で返します.
*
* @param string $data チェックしたいデータ(IPアドレスなど)
* @param int $span 連続アクションをブロックする秒数
* @param float $gc_probability ガベージコレクションを行う確率
* @return bool アクション可能かどうか
*/

public function isValid($data, $span, $gc_probability = 0.05)
{
if (mt_rand() / mt_getrandmax() <= $gc_probability) {
$stmt = $this->pdo->prepare("DELETE FROM log WHERE :latest - latest > :span");
$stmt->bindValue(':latest', time(), PDO::PARAM_INT);
$stmt->bindValue(':span', $span, PDO::PARAM_INT);
$stmt->execute();
}
$stmt = $this->pdo->prepare("
REPLACE INTO log(data, latest)
SELECT :data, :latest
WHERE EXISTS (SELECT * FROM log WHERE data = :data AND :latest - latest > :span)
OR NOT EXISTS (SELECT * FROM log WHERE data = :data);
"
);
$stmt->bindValue(':data', $data);
$stmt->bindValue(':latest', time(), PDO::PARAM_INT);
$stmt->bindValue(':span', $span, PDO::PARAM_INT);
$stmt->execute();
return (bool)$stmt->rowCount();
}
}



使用例

$tsv = new \TimeSpanValidator('/tmp/iplog.db');

if ($tsv->isValid($_SERVER['REMOTE_ADDR'], 20)) {
echo '何か処理を実行しました';
} else {
echo '20秒以内の連続実行はダメよ!';
}

注意点として,書き込みが発生する際に極端に実行が遅くなる点が挙げられます.但し,これはコンストラクタで行っているようにPRAGMA synchronous = 0;「電源遮断時の安全性」を犠牲にすることである程度改善されます.また,

「SQLiteはロックの性能に問題があるからMySQLのほうがいい!」

と思った方,そもそも冒頭に書いたようにAPCuを使いましょう.MySQLを利用できる環境なら,だいたいはエクステンションの導入も可能かと思います.