【PHP】ファイルを上書きせずに生成して書き込む

  • 2023年5月16日
  • 2023年5月16日
  • PHP

 ファイルへの書き込み処理、データベースへの保存処理ではしばしば制約を設ける必要がある場合があります。例えば、既に同名の何かが存在するならば書き込みや保存を行ってはいけない、という制約です。この制約を守るためによく次の様なソースコードが書かれますが、これは誤りです。

// レースコンディション発生のアンチパターン
if(同名のデータが存在するならば true){
    throw new Error('データが既に存在します');
}
// 書き込み、保存処理

 このコードの場合、複数のプロセスが同時に進むとよろしくない事態を招きます。これは例えば次の様な順番で処理が進む場合です。

  1. プロセスAがif文を評価。同名のデータが存在しないことを確認
  2. プロセスBがif文を評価。同名のデータが存在しないことを確認
  3. プロセスAが書き込み、保存を開始
  4. プロセスBがプロセスAと同名の対象に書き込み、保存を開始

 いわゆるレースコンディションです。これの対策としてよく使われるのがロックです。ロックは対象のデータに鍵をかけて他プロセス等の外部からアクセスできない様にするイメージの機能です。これを用いると先ほどのコードと処理の流れは次の様になります。

// レースコンディション発生のアンチパターン
if(同名のデータが存在するならば true){
    throw new Error('データが既に存在します');
}
// データをロック
if(ロックできていないならば true){
    // ロックできるまで待つ手もあります
    throw new Error('ロックできませんでした');
}

// 書き込み、保存処理
  1. プロセスAがif文を評価。同名のデータが存在しないことを確認
  2. プロセスBがif文を評価。同名のデータが存在しないことを確認
  3. プロセスAがデータをロック
  4. プロセスBがデータをロックしようとするが、プロセスAが既にロックしているので失敗
  5. プロセスAが書き込み、保存を開始
  6. プロセスAがロックを解除
  7. プロセスBがデータをロック
  8. プロセスBが書き込み、保存を開始
  9. プロセスBがロックを解除

 ここではデータをロックするという言い回しをしましたが、占有権や所有権をイメージして「ロックを取得する」「ロックを捨てる」という言い回しがされることもあります。

 PHP のファイルの書き込みでロックを用いる場合の例が次です。

<?php

// ファイルのパスを指定します。このファイルへの書き込みは、同時に行われる可能性があるため、
// ロックメカニズムを使用してデータの整合性を保証します
$filename = __DIR__ . "/tmp.txt";

// 'c' モードでファイルを開きます。このモードは、ファイルが存在しない場合は新規作成し、
// 存在する場合は既存の内容を消去せずに開くことができます
$file = fopen($filename, 'c');
if($file === false) {
    // ファイルが開けない場合は、エラーメッセージを表示して処理を終了します
    die('ファイルが開けませんでした。');
}

// 非ブロッキングモードで排他的ロック (書き込みロック) を試みます
// ロックが取得できない場合は、他のプロセスがファイルを使用中であると判断し、
// データの整合性を保つためにエラーメッセージを表示して処理を終了します
$lock = flock($file, LOCK_EX | LOCK_NB);
if($lock === false) {
    die('ファイルをロックできませんでした。');
}

// ロックが取得できたら、ファイルを空ファイルにします
ftruncate($file,0);

// ファイルに書き込みを行います
fwrite($file, "Hello, World!");

// 書き込みが完了したら、ファイルを閉じます。これにより、他のプロセスがファイルにアクセスできるようになります
fclose($file);

 ちなみに PHP は fclose したり PHP プロセスが終了したりすれば自動でいい感じにファイルについての諸々を後始末してくれるので、そのあたりは大分楽です。実際に大きなプログラムの一部としてこれを組み込むのであれば、次の様にした方が扱いが楽です。

<?php

/**
 * ファイルを作成し、そのファイルにロックをかける関数です。
 * ロックを使用するのは、他のプロセスが同時にアクセスすることを防ぐためです。
 * @param  string  $filename
 * @param  bool    $nonblock true ならばロックできない時点で例外をスローします。false ならばロックできるまで待ちます。
 * @return resource
 */
function fcreateWithLock(string $filename, bool $nonblock = true)
{
    // ファイルを開きます。'c' モードは、ファイルが存在しない場合に新しいファイルを作成します。
    $file = fopen($filename, 'c');
    if($file === false) {
        // ファイルが開けない場合、例外をスローします。
        throw new \RuntimeException('ファイルを開けませんでした。');
    }
    // ファイルに排他的なロックをかけます。排他的なロックを使用するのは、同時に他のプロセスが書き込みを行うことを防ぐためです。
    $lock = flock($file, $nonblock ? LOCK_EX | LOCK_NB : LOCK_EX);
    if($lock === false) {
        // ロックがかけられない場合、例外をスローします。
        throw new \RuntimeException('ファイルをロックできませんでした。');
    }
    // ファイルの内容を全て削除します。これにより、新規作成と同等の状態になります。
    ftruncate($file, 0);
    return $file;
}

// tmp.txtに"Hello, World!"と書き込みます。排他的なロックを使用することで、他のプロセスが同時に書き込むことを防いでいます。
$file = fcreateWithLock($filename = __DIR__ . "/tmp.txt");
fwrite($file, "Hello, World!");
// fclose するとロックが解除されるので、他所から書き込まれると困る処理はこの時点であらかじめしておく
// fclose してロックを解除
fclose($file);
/**
 * ファイルに追記し、そのファイルにロックをかける関数です。
 * ロックを使用するのは、他のプロセスが同時にアクセスすることを防ぐためです。
 * @param  string  $filename
 * @param  bool    $nonblock true ならばロックできない時点で例外をスローします。false ならばロックできるまで待ちます。
 * @return resource
 */
function fopenForAppendWithLock(string $filename, bool $nonblock = true)
{
    // ファイルを開きます。'a' モードは、ファイルが存在する場合はその末尾にデータを追加します。
    $file = fopen($filename, 'a');
    if($file === false) {
        // ファイルが開けない場合、例外をスローします。
        throw new \RuntimeException('ファイルを開けませんでした。');
    }
    // ファイルに排他的なロックをかけます。排他的なロックを使用するのは、同時に他のプロセスが書き込みを行うことを防ぐためです。
    $lock = flock($file, $nonblock ? LOCK_EX | LOCK_NB : LOCK_EX);
    if($lock === false) {
        // ロックがかけられない場合、例外をスローします。
        throw new \RuntimeException('ファイルをロックできませんでした。');
    }
    return $file;
}

// tmp.txtに"Hello, World!"と追記します。排他的なロックを使用することで、他のプロセスが同時に書き込むことを防いでいます。
$file = fopenForAppendWithLock($filename = __DIR__ . "/tmp.txt");
fwrite($file, "\nHello, World!");
// fclose するとロックが解除されるので、他所から書き込まれると困る処理はこの時点であらかじめしておく
// fclose してロックを解除
fclose($file);

 こんな感じでロックを使うとファイルへの書き込みが意図通りに一つずつ行われます。

 余談ですが、ファイルが既にある場合はエラーになるファイル作成をするのみならば次で十分です。

<?php
$filename = __DIR__ . "/tmp.txt";
$file = fopen($filename, 'x');
if($file === false) {
    die('ファイルが既に存在するか、開けませんでした。');
}

 x を使うとファイルが既に存在する場合には fopen が失敗となり、 E_WARNING レベルのエラーが発行されます。
PHP: fopen – Manual

 またロックをかけるプロセスの中でのみ取り扱うファイルならば(人間が読める名前などで永続化する必要がないのであれば)ユニークな一時ファイルとして扱った方がより楽であり、次の様に言語としての PHP がユニークを担保してくれる一時ファイルを利用することもできます。

<?php
$file = tmpfile();
fwrite($file, "Hello, World!");

もしファイルの名前ないしパスを自由に決められるのであれば、ユニークなIDをパスに含めることでファイルの重複――上書きが起きる確率を実質0にできます。これはUUIDであったり、十分な長さを持った random_bytes 関数をベースにした文字列であったり様々です。時系列順の値になるULIDというものもあります。ユニークなIDの作り方も意外と落とし穴があるので多く使われているライブラリ等を使うのが無難です。
PHP: random_bytes – Manual

>株式会社シーポイントラボ

株式会社シーポイントラボ

TEL:053-543-9889
営業時間:9:00~18:00(月〜金)
住所:〒432-8003
   静岡県浜松市中央区和地山3-1-7
   浜松イノベーションキューブ 315
※ご来社の際はインターホンで「316」をお呼びください

CTR IMG