[PHP] ファイルオープンモードに関するマニュアルの記述は間違っている

  • 31
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

間違った記述

fopen 関数のマニュアルは、完全に間違っているとも言い難いが、 「極めて不親切」 であるとは言える。その記述が以下のものである。

ss (2013-10-31 at 01.56.32).png

実際には、ファイルポインタを 先頭 に置いているとしか思えない挙動をする。

$fp = fopen('new_file.txt', 'w');
fwrite($fp, 'abcde');
fclose($fp);
$fp = fopen('new_file.txt', 'a');
echo ftell($fp);
fclose($fp);

としてみると、 ftell 関数の返り値は 0 となっていることが分かる。マニュアルを見ると、

ss (2013-10-31 at 03.30.22).png

確かに 未定義 であるとされているため、この場合は誤りでは無いのかもしれない。しかし、 a+ モードは読み出しも可能なストリームでもあるのに、未定義で片づけてしまうのは間違っているのではないかと思う。混乱を招く要因にもなり得るだろう。

内部構造を意識した正しい認識

早見表

x x+ は PHP4.3.2、 c c+ はPHP5.2.6以降でのみサポートされている。

ss (2013-10-31 at 03.40.00).png

内部構造を意識し、 a+ モードのことも考慮すると、書き込み時にファイルポインタを終端に移動するという認識が一番適切であろう。

「仮NULLバイト」の導入

説明のために 「仮NULLバイト」 という言葉を導入する。これはバッファの範囲内と範囲外で区別する。以下、加味されるものを 、加味されないものを × で示す。

ss (2013-11-01 at 11.05.04).png

※ 外部バッファとは、読み出された後の値 ( stream_get_contents 関数などの返り値 ) のことを意味する。

fseek 関数 について

fseek 関数のマニュアルを見ると、

一般的に、ファイルの終端より先の位置に移動することも許されています。

と書かれているので、ここでは便宜的に

ss (2013-11-01 at 11.24.17).png

という5バイトのファイル data.txt があれば、先ほど提示した「範囲外の仮NULLバイト」を用いて

ss (2013-11-01 at 11.17.54).png

と表記する。

$fp = fopen('data.txt', 'c');
fseek($fp, 7);

とすると、

ss (2013-11-01 at 11.26.03).png

この位置に移動することが出来る。更に

fwrite($fp, 'f');

として、ここから1バイト以上の書き込みを行うと…

一般的なモードでの挙動

その位置より左側にある「範囲外の仮NULLバイト」の実体化が起こる。

ss (2013-11-01 at 11.56.38).png

a a+ モードでの挙動

そのまま追記される。

ss (2013-11-01 at 11.57.15).png

ftruncate 関数について

この関数はファイルポインタを指定したバイト数に丸める関数である。

概要

  • ポインタの位置は変更されない
  • 超過している部分は切り捨てられる。
  • 不足している部分は実体のNULLバイトで埋められる。

ftruncate 関数コール直後に fwrite 関数をコールしてしまうと、ポインタの位置が変更されていないが故に、そこからの書き込みとなり、当然そこまではNULLバイトで埋められてしまう。下記に例を示す。

$fp = fopen('data.txt', 'c');
fseek($fp, 2);
ftruncate($fp, 0);
fwrite($fp, 'f');
fseek($fp, 0);

としたときに

ss (2013-11-01 at 11.17.54).png

がどうなるかを下に示す。

一般的なモードでの挙動

ss (2013-11-01 at 11.54.16).png

NULLバイトで先頭が埋められるのを回避したければ、 fwrite 関数のコール直前または直後に rewind 関数を実行することにより回避できる。

a a+ モードでの挙動

ss (2013-11-01 at 11.46.51).png

最後の fseek 関数が先頭の仮NULLバイトを無視していることに注意。

fseek関数の非常にややこしい挙動

最初の表においてfseek関数は「範囲内の仮NULLバイト」の存在を加味しないとしたが、実際には現在位置より左側にある「範囲内の仮NULLバイト」の個数分だけ指定値に加算することで差を打ち消しているとみられる。 SEEK_SET フラグ(デフォルト)のときはこれで最初に述べたような自然な挙動になるが、 SEEK_CUR フラグを指定したときにかなり気持ち悪い結果を引き起こす。具体例を下に挙げる。

ss (2013-11-02 at 12.26.37).png

このとき、

fseek($fp, 1, SEEK_SET); // 1+2=3

fseek($fp, -4, SEEK_CUR); // -4+2=-2

は、どちらも

ss (2013-11-02 at 12.35.40).png

となる。

その他

テキストモード(t)とバイナリモード(b)

マニュアルより引用。

ss (2013-10-31 at 06.27.06).png

本記事にて r r+ などとしてきたが、実際に使うときは rb r+b のようにすべきであろう。

file_get_contents / file_put_contents の是非

これらは、ファイルの読み出し/書き込みに関わる一連の処理を簡略化することが出来る非常に有用な関数であるが・・・

まず最初に file_put_contents 関数について触れる。この関数のマニュアルを見ると

ss (2013-10-31 at 05.01.23).png

第3引数に排他ロックフラグ LOCK_EX を指定できるようであるが、この関数はC言語レベルで実際には下記のような実装になっている。 「php-5.2.5以前のfile_put_contentsではLOCK_EXによる排他ロックは動かない(5.2.6でFix)」 より引用。

PHP_FUNCTION(file_put_contents)
{
  // (省略:初期化)

    if (flags & PHP_FILE_APPEND) {
        mode[0] = 'a';
    } else if (flags & LOCK_EX) {
        /* check to make sure we are dealing with a regular file */
        if (php_memnstr(filename, "://", sizeof("://") - 1, filename + filename_len)) {
            if (strncasecmp(filename, "file://", sizeof("file://") - 1)) {
                php_error_docref(NULL TSRMLS_CC, E_WARNING, "Exclusive locks may only be set for regular files");
                RETURN_FALSE;
            }
        }
        mode[0] = 'c';
    }
    mode[2] = '\0';

    stream = php_stream_open_wrapper_ex(filename, mode, ((flags & PHP_FILE_USE_INCLUDE_PATH) ? USE_PATH : 0) | REPORT_ERRORS, NULL, context);
    if (stream == NULL) {
        RETURN_FALSE;
    }

    if (flags & LOCK_EX && (!php_stream_supports_lock(stream) || php_stream_lock(stream, LOCK_EX))) {
        php_stream_close(stream);
        php_error_docref(NULL TSRMLS_CC, E_WARNING, "Exclusive locks are not supported for this stream");
        RETURN_FALSE;
    }

    if (mode[0] == 'c') {
        php_stream_truncate_set_size(stream, 0);
    }

  // (省略:たぶん書き込み処理)
}

c モードでオープンを行っているので、ロックが取得できなくてもファイルのデータが失われてしまうことは無い。ところがバージョン5.2.5以前のソースを見ると…

PHP_FUNCTION(file_put_contents)
{
  // (省略)
    stream = php_stream_open_wrapper_ex(filename, (flags & PHP_FILE_APPEND) ? "ab" : "wb", 
            ((flags & PHP_FILE_USE_INCLUDE_PATH) ? USE_PATH : 0) | ENFORCE_SAFE_MODE | REPORT_ERRORS, NULL, context);
  // (省略)
}

w モードでのオープンを行っているので、オープンには成功したがロックには失敗した場合にデータが失われてしまう。もしPHPバージョン5.2.5以下を使う必要があるならば、下記のような関数を実装して使うのが賢明である。 このバージョンでは c モードを使えないため、一見不便に思えるかもしれないが、 a モードで対応することができ、更にこの「仮NULLバイト」の性質を利用すれば rewind 関数のコールを省略することさえ出来てしまう。

function safe_file_put_contents($filename, $data, $flags = 0, $context = null) {
    $args = array(
        $filename,
        'ab',
        $flags & FILE_USE_INCLUDE_PATH,
    );
    if (func_num_args() > 3) {
        $args[] = $context;
    }
    if (!$fp = call_user_func_array('fopen', $args)) {
        return false;
    }
    if ($flags & LOCK_EX && !flock($fp, LOCK_EX)) {
        fclose($fp);
        return false;
    }
    if (!($flags & FILE_APPEND)) {
        ftruncate($fp, 0);
    }
    $ret = fwrite($fp, is_array($data) ? implode('', $data) : $data);
    flock($fp, LOCK_UN);
    fclose($fp);
    return $ret;
}

次に file_get_contents 関数について触れるが、この関数はそもそもファイルロックをサポートしてない。書き込み時の 排他ロック(LOCK_EX) に比べれば 読み込み時の 共有ロック(LOCK_SH) の重要性は低いかもしれないが、ロックをかけないと読み出し中に他のプロセスによる書き込みが行われた際、想定外の挙動を示す恐れがあるため、 ユーザーが書き換え可能なデータにアクセスが集中する場合 、ロックは行っておいた方が無難である。

また、掲示板等のスクリプトで、読み出しと書き込みを順次行う場合について触れる。

読み出しのみでファイルオープン(失敗したら中止)
↓
共有ロック(失敗したら中止)
↓
読み出し
↓
ロック解除
↓
ファイルクローズ
↓
<ブランク>
↓
[ if (.....): ]
↓
書き込みのみでファイルオープン(失敗したら中止)
↓
排他ロック(失敗したら中止)
↓
書き込み
↓
ロック解除
↓
ファイルクローズ
↓
[ endif; ]

ファイルロックをきちんと行っていたとしても、 <ブランク>他のプロセスに書き込み割り込みされて困る場合には、ファイルオープン回数を1回にする工夫をしなければならない。下記のように実装するのが理想である。

読み出し/書き込みでファイルオープン(失敗したら中止)
↓
共有ロック(失敗したら中止)
↓
読み出し
↓
[ if (.....): ]
↓
排他ロックに切り替える(失敗したら中止)
↓
書き込み
↓
[ endif; ]
↓
ロック解除
↓
ファイルクローズ

さまざまなファイル読み出し関数

ss (2013-10-31 at 07.00.01).png

echo file_get_contents($filename);

よりは

readfile($filename);

の方がパフォーマンスはいい。

関連

[シリアル版] ページング処理を採り入れた掲示板サンプル