make is.dev make it simple. development.
2024年9月13日

PHPでCSVファイル読み込み

SplFileObjectを使ってCSVファイルを読み込んでみる。

SplFileObjectとは

PHP の Standard PHP Library(SPL) に含まれるクラスで、ファイル操作のためのインターフェイスを提供するもの。

値内に改行も対応しているので、他のCSV関係の関数よりも扱いやすい。
CSVファイルの読み込みにはこれを使うのが良い。

使用環境

・ Amazon Linux 2
・ PHP 8.2.9 ※default_charset = UTF-8 環境

※以下はUTF-8環境を前提に書いているが、そうでない場合は話が変わるので注意。

基本サンプル

行毎に読み込んで、var_dump() 出力するだけのサンプル。

<?php

// インスタンス生成
$fp = new SplFileObject( './sample.csv' );

// CSVとして読み込む事を指定
$fp->setFlags( SplFileObject::READ_CSV );

// エスケープのデフォルトが「\」なので設定解除
$fp->setCsvControl( escape: '' );

// 1行ずつ読み込んで処理する
foreach ( $fp as $line ) {
    var_dump( $line );
}
PHP

対応している文字コードはUTF-8(BOMなし)のみ。

UTF-8(BOMあり)は一見問題ないように見えるが、BOMがデータの一部として付いてくるので少し加工してやる必要がある。

改行コードは、LF、CRLFのどちらも問題ないが、CRは動きがやや怪しい。

// エスケープのデフォルトが「\」なので設定解除
$fp->setCsvControl( escape: '' );
PHP

この部分、設定を変更しないとバックスラッシュ(半角円マーク)がエスケープ記号として扱われてしまうので注意。
設定を変えることで、バックスラッシュがただの文字として扱われる。

PHP7以前だと名前付き引数が使えないので、その場合は以下でOK。

$fp->setCsvControl( ',', '"', '' );
PHP

空行対策

上記例では、途中や末尾の空行もそのまま読み込まれる。
空行を無視したい場合はスキップする処理を追加する。

<?php

// インスタンス生成
$fp = new SplFileObject( './sample.csv' );

// CSVとして読み込む事を指定
$fp->setFlags( SplFileObject::READ_CSV );

// エスケープのデフォルトが「\」なので設定解除
$fp->setCsvControl( escape: '' );

// 1行ずつ読み込んで処理する
foreach ( $fp as $i => $line ) {

    // 必要に応じて空行スキップ
    if ( is_null( $line[0] ) || $line[0] === '' ) {
        continue;
    }

    var_dump( $line );
}
PHP

is_null() では空文字が判定出来ないので空文字を追加している。
末尾の空行はnullになるが、途中行のブランクは空文字が取得されるケースがあるため。

上記では1列目がブランクかどうかだけで判定しているが、データによってはそれではダメ。
なので適宜調整が必要。

以下のように setFlags() の設定で空行を飛ばすやり方もある。

$fp->setFlags(
    SplFileObject::READ_CSV |
    SplFileObject::READ_AHEAD |
    SplFileObject::SKIP_EMPTY |
    SplFileObject::DROP_NEW_LINE
);
PHP

ただこれは、以下の点に注意が必要。

  • UTF-8(BOMあり)を読み取る場合、先頭行が空行だと空行扱いされない(BOMがあるから)
  • PHPのバージョンによって挙動が異なる(古いバージョンを使ってなければ大丈夫とは思われる)
  • 空行の定義が曖昧(該当行に値がなくカンマ(区切り文字)だけがある場合、空行扱いにならない)

UTF-8(BOMあり)対策

BOM有りのファイルを読み込む場合、1行目の先頭にBOMのデータがくっついてくる。
先頭行の個別処理を入れておくことで、BOM有り無しの両方に対応出来る。

<?php

// インスタンス生成
$fp = new SplFileObject( './sample.csv' );

// CSVとして読み込む事を指定
$fp->setFlags( SplFileObject::READ_CSV );

// エスケープのデフォルトが「\」なので設定解除
$fp->setCsvControl( escape: '' );

// 1行ずつ読み込んで処理する
foreach ( $fp as $i => $line ) {

    // 頭にあるBOMを消す
    if ( $i === 0 ) {
        $line[0] = preg_replace( '/^\xEF\xBB\xBF/', '', $line[0] );
    }

    // 必要に応じて空行スキップ
    if ( is_null( $line[0] ) || $line[0] === '' ) {
        continue;
    }

    var_dump( $line );
}
PHP

この処理は、空行スキップよりも前に入れる必要がある。
(BOMのデータがNULLや空文字判定を阻害するので先に取り除く必要がある)

Shift-JISのCSVファイルに対応

Shift-JISのCSVファイルも読み込む場合、読み込みながらエンコードするか、事前にUTF-8に変換したファイルを用意するか、のどちらかになる。

※以下例、UTF-8(BOMなし)とShift-JISに対応しているが、BOMあり対策は省略しているので注意。

まずは読み込みながらエンコードする場合のサンプル。

<?php

// 文字コードを判定して、SJISだったら変換する
function convertEncoding( null|string $v ) : null|string
{
    if ( ( $code = mb_detect_encoding( $v, 'UTF-8,SJIS-win,SJIS' ) ) === 'UTF-8' ) {
        return $v;
    }
    return mb_convert_encoding( $v, 'UTF-8', $code );
}


// インスタンス生成
$fp = new SplFileObject( './sample.csv' );

// CSVとして読み込む事を指定
$fp->setFlags( SplFileObject::READ_CSV );

// エスケープのデフォルトが「\」なので設定解除
$fp->setCsvControl( escape: '' );

// 1行ずつ読み込んで処理する
foreach ( $fp as $line ) {
    $line = array_map( 'convertEncoding', $line );
    var_dump( $line );
}
PHP

値1つ1つに対して文字コードを判定し、必要に応じてUTF-8に変換している。
シンプルに実装出来るが、文字コードの判定・変換を値毎にやっているのでやや処理負荷が高い(遅い)。

次に、事前にUTF-8に変換する方法。

<?php

// 文字コードを判定して、SJISだったら変換する
function convertEncoding( null|string $v ) : null|string
{
    if ( ( $code = mb_detect_encoding( $v, 'UTF-8,SJIS-win,SJIS' ) ) === 'UTF-8' ) {
        return $v;
    }
    return mb_convert_encoding( $v, 'UTF-8', $code );
}


// 文字コードUTF-8のCSVファイルを事前に用意
$filePath = '/tmp/' . uniqid() . '.csv';
file_put_contents( $filePath, convertEncoding( file_get_contents( './sample.csv' ) ) );

// インスタンス生成
$fp = new SplFileObject( $filePath );

// CSVとして読み込む事を指定
$fp->setFlags( SplFileObject::READ_CSV );

// 区切り文字等を設定する
// (エスケープのデフォルトが「\」なので、そこだけなしに変更している)
$fp->setCsvControl( escape: '' );

// 1行ずつ読み込んで処理する
foreach ( $fp as $line ) {
    var_dump( $line );
}
PHP

文字コードの確認・変換を1回で終えており、速い。
ただ、CSVファイルの容量分メモリを使うので、ファイルが大きい場合はそちらが問題になる。

メモリも変換の回数も抑えるには、以下のような形が妥当か。

<?php

// 文字コードを判定して、SJISだったら変換する
function convertEncoding( null|string $v ) : null|string
{
    if ( ( $code = mb_detect_encoding( $v, 'UTF-8,SJIS-win,SJIS' ) ) === 'UTF-8' ) {
        return $v;
    }
    return mb_convert_encoding( $v, 'UTF-8', $code );
}


// 文字コードUTF-8のCSVファイルを事前に用意
$filePath = '/tmp/' . uniqid() . '.csv';

$fpRead = fopen( './sample.csv', 'r' );
$fpWrite = fopen( $filePath, 'w' );

while ( $line = fgets( $fpRead ) ) {
    fwrite( $fpWrite, convertEncoding( $line ) );
}

fclose( $fpRead );
fclose( $fpWrite );


// インスタンス生成
$fp = new SplFileObject( $filePath );

// CSVとして読み込む事を指定
$fp->setFlags( SplFileObject::READ_CSV );

// 区切り文字等を設定する
// (エスケープのデフォルトが「\」なので、そこだけなしに変更している)
$fp->setCsvControl( escape: '' );

// 1行ずつ読み込んで処理する
foreach ( $fp as $line ) {
    var_dump( $line );
}
PHP

文字コードの確認・変換は行毎に行っている。
その分遅くはなるが、値毎にやるよりは格段に速い。

事前変換はSplFileObjectではなくfopen()でやっているが、こちらの方がメモリ使用量が若干少ない。
速度も誤差の範囲ではあるが、若干fopen()の方が速い印象。