APCuを使用する
PHPエクステンションのインストールが必要な点で導入の敷居が上がりますが,パフォーマンスと使いやすさの観点,どちらから見ても最強です.MemcachedやRedisよりもコードが圧倒的に短く済むので,オンメモリの選択肢の中では一番おすすめです.
if (apcu_add($_SERVER['REMOTE_ADDR'], 1, 20)) {
echo '何か処理を実行しました';
} else {
echo '20秒以内の連続実行はダメよ!';
}
識別子ごとのファイルを使用する
PHPのエクステンションが導入できない事情があるが,そこそこのパフォーマンスは欲しい,というときにはこの方法がおすすめです.
<?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;
}
<?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を利用できる環境なら,だいたいはエクステンションの導入も可能かと思います.