LoginSignup
83

More than 5 years have passed since last update.

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

Last updated at Posted at 2014-05-29

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を利用できる環境なら,だいたいはエクステンションの導入も可能かと思います.

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
83