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

# APCuを使用する

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

```php:使用例&#32;(関数を作る必要すらない)
if (apcu_add($_SERVER['REMOTE_ADDR'], 1, 20)) {
    echo '何か処理を実行しました';
} else {
    echo '20秒以内の連続実行はダメよ！';
}
```


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

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

```php:関数定義&#32;(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;
}
```

```php:関数定義&#32;(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;
}
```

```php:使用例
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:クラス定義
<?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();
    }
}
```

```php:使用例
$tsv = new \TimeSpanValidator('/tmp/iplog.db');
if ($tsv->isValid($_SERVER['REMOTE_ADDR'], 20)) {
    echo '何か処理を実行しました';
} else {
    echo '20秒以内の連続実行はダメよ！';
}
```

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

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

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