entry-header-eye-catch.html
entry-title-container.html

entry-header-author-info.html
Article by

PHPでも乱数をオブジェクトスコープに分離したい

こんにちは、ふじしゃんです。

ファンクションユニットのウェブエンジニアリング部でアルバイトをしています。

今回は、PHP8.2で新しく実装されたRandom\RandomizerとPHP8.2未満のWeb アプリケーションで乱数の生成をオブジェクトスコープに分離する話について書いていきます。

PHP における乱数について

これまでPHPにおいて乱数を用いた実装を行う場合、通常はmt_rand()rand()などの標準関数を使用して生成します。(PHP 7からはrand()ではなくrandom_int()を用いることが推奨されるようになりました)

PHPの乱数生成は関数ベースで提供されています。これらの乱数はプロセス内でグローバルな状態を共有しているため、シードが外部から初期化されることで乱数生成に影響を受ける可能性があり、決して安全とは言えません。

実際、mt_rand()rand()では、実行前にmt_srand()srand()を使用してシードを指定することで、その後の乱数生成に影響を与えることができます。

また、PHP7.1 で修正されましたが mt_rand() を用いて生成された乱数はmt_srand()でシードが行われていない場合に初期シードが Combined LCG というアルゴリズムで生成されていたことから、ある程度推測可能な状態になっていました。

その他にも PHP における乱数生成は落とし穴があり、開発において多くの配慮が必要でした。

詳細はPHP本体の乱数の改善を提案したzeriyoshiさんによる以下の記事を参照してください。 zenn.dev

乱数をテストすることの難しさ

乱数を使用した実装のテストは、同じ入力に対して同じ出力が得られないため困難です。

乱数などの副作用を伴う処理が含まれる実装をテストする際には、乱数生成部分の処理を外部に切り出し、テスト時にテスト用の実装を依存性注入することで、固定値に置き換えたり、シードを指定することで生成される乱数を推測可能なものにすることができます。

PHPの乱数生成は関数として提供されており、グローバルな状態を持っているため、外部からシードを与えることができます。 しかし、グローバルな状態を持った乱数生成器は、開発者の予期しないバグや脆弱性を引き起こす可能性があるため、グローバルな状態を持たないオブジェクトスコープな乱数生成器を使用することで、外部から乱数生成の処理を注入することが求められます。

このような乱数生成器を用いたテストは、テストの安定性を確保するために非常に有用です。例えば、同じ値を返すことが保証された乱数生成器を使用することで、予測可能なテストを行うことができます。また、乱数生成器を外部に切り出すことで、テストの再現性を高め、バグを発見しやすくすることができるようになります。

<?php

use Random\Randomizer;

/**
 * 乱数に依存するサンプル実装
 */
class Example
{
    // $randomizer を DI で注入することで実装を差し替えることが出来る
    public function __construct(private Randomizer $randomizer) {}
    
    public function draw(int $min, int $max): int
    {
        return $this->randomizer->getInt($min, $max);
    }
}
<?php

use Random\Randomizer;
use Random\Engine\Mt19937;

class ExampleTest
{
    public function test(): void
    {
        // テストの際にはシードを固定したエンジンで生成した Randomizer を渡す
        $randomizer = new Randomizer(new Mt19937(1234));

        // 今回は Randomizer を生成しているが実際には DI で解決される
        $sut = new Example($randomizer);
                
        $actual = []
        foreach (range(1, 5) as $_) {
            $actual[] = $sut->draw(1, 100);
        }

        // シードが与えられているため結果は固定
        assert($actual === [76, 72, 7, 66, 17]);
    }
}

PHP8.2でやってきたRandomizer

RandomizerとはPHP 8.2で追加されたオブジェクトスコープな乱数生成器です。

wiki.php.net

PHPの乱数生成器はPHP 8.1までネイティブで実装されたオブジェクトスコープが存在せず、関数としてグローバルな状態を持って提供されていました。

しかし、グローバルな状態を持った乱数生成器は開発者の予期しないバグや脆弱性を引き起こす可能性が高く、扱いにおいて多くの配慮が必要でした。

PHP 8.2でオブジェクトスコープな乱数生成器であるRandomizerが実装されたことで、安全かつテスタブルに乱数を使用した実装が行えるようになったのです。

<?php

// mt_rand & mt_srand の場合
mt_srand(1234);
mt_rand(1, 100); // 76 (シードが与えられているため固定)

mt_srand(1234);
str_shuffle('abc'); // 内部でシードが初期化される関数
mt_rand(1, 100); // 1~100 (シードが初期化されたため可変)

// Randomizer の場合
$randomizer = new Random\Randomizer(new Random\Engine\Mt19937(1234));
$randomizer->getInt(1, 100); // 76 (シードが与えられているため固定)

$randomizer = new Random\Randomizer(new Random\Engine\Mt19937(1234));

str_shuffle('abc');

$randomizer->getInt(1, 100); // 76 (オブジェクトスコープなのでグローバルなシードの初期化に影響されない)

上記のコードではRandomizerのオブジェクトを生成する際に、エンジンのクラスである new Random\Engine\Mt19937() を与えることでmt_rand()mt_srand()と同様のメルセンヌ・ツイスターアルゴリズムを用いた乱数を生成できるようにしています。

このほかにもRandomizerには以下のようなエンジンが用意されています。

<?php

use Random\Engine;
use Random\Randomizer;

// 暗号学的に安全であることが担保されているエンジン
$secureRandomizer = new Randomizer(new Engine\Secure());

// メルセンヌ・ツイスターアルゴリズムを用いたエンジン
$mtRandomizer = new Randomizer(new Engine\Mt19937());

// PCG アルゴリズムを用いたエンジン
$pcgRandomizer = new Randomizer(new Engine\PcgOneseq128XslRr64());

// Xoshiro アルゴリズムを用いたエンジン
$xoshiroRandomizer = new Randomizer(new Engine\Xoshiro256StarStar());

また、shuffleArray()pickArrayKeys() など配列を操作するためのメソッドも用意されています。

www.php.net www.php.net

polyfill-php82とrandom-polyfill

RandomizerはPHP 8.2で新しく実装されました。しかし、すべてのWebアプリケーションがPHP 8.2で開発されているわけではないかもしれません。

これに対応するために、Symfonyなどが新しいPHPバージョンや拡張で提供される機能をpure PHPで再実装したpolyfillと呼ばれるパッケージを公開しています。

github.com github.com

polyfillについてはJavaScriptなどでよく古いブラウザと新しいブラウザで機能を揃えるために用いられることが多いため、用いたことがある開発者も多いと思います。

詳しくは以下の記事を参照してください。

developer.mozilla.org

現在pixivはPHP 8.3の環境で開発しているため、Randomizerのためにpolyfillは必要ありませんがRandomizerに限らずPHP 8.4で新しく追加される array_find() など、仕様が確定してから本番環境に投入されるまでの期間に有用な新機能をいちはやく利用するために積極的に活用しています。

github.com

本稿を書く動機になった乱数生成処理のリファクタリングは、PHP 8.2が本番環境に投入される直前の2023年初旬にマージされ、短期間ですが本番でもpolyfillで問題なく稼動していました。

PHP 8.3のRandomizerについて

PHP 8.2で実装されたRandomizerでも既存のものと比べれば非常に便利なのですが、PHP 8.3でさらに改善され、具体的には以下の3つのメソッドと1つのenumが追加されています。

wiki.php.net

<?php

namespace Random;
 
final class Randomizer {
    // […]
    public function getBytesFromString(string $string, int $length): string {}
    public function nextFloat(): float {}
    public function getFloat(
        float $min,
        float $max,
        IntervalBoundary $boundary = IntervalBoundary::ClosedOpen,
    ): float {}
}
 
enum IntervalBoundary {
    case ClosedOpen;
    case ClosedClosed;
    case OpenClosed;
    case OpenOpen;
}

Randomizer::getBytesFromString() メソッドが追加されたことで、Randomizerを用いてパスワードのような特定の文字種だけから構成されるランダムな文字列を容易に生成できるようになりました。

PHP 8.2まで

<?php

$baseChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
$baseCharsLength = 62;

$randomizer = new Random\Randomizer(new Random\Engine\Secure());

$password = "";

for ($i = 0; $i < 30; $i++) {
    $password .= $baseChars[$randomizer->getInt(0, $baseCharsLength - 1)];
}

echo $password; // 30文字のランダムなパスワード

PHP 8.3から

<?php

$baseChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";

$randomizer =  new Random\Randomizer(new Random\Engine\Secure());

$password = $randomizer->getBytesFromString($baseChars, 30);

echo $password; // 30文字のランダムなパスワード

さいごに

PHPにオブジェクトスコープでとても便利な乱数生成器がやってきました!
もうグローバルな状態を持っている乱数生成器には戻ることができそうにありません。

みなさんも新しくなったPHPのRandom\Randomizerでよい乱数生成ライフを楽しんでください!

ふじしゃん
ファンクションユニットのウェブエンジニアリング部でアルバイトをしています。古のコードを読み解きながらいい感じに趣を感じることと複雑な UI を持つ Web アプリケーションの開発が好きです。