こんにちは、バックエンドエンジニアでPHPerの江間(meihei)です。
今回は、 CSV ファイルのリーダーとして LeagueCSV を使用したので、その実際に導入した時の実装方法についてご紹介します。
この記事は meihei GW アドベントカレンダー2日目の記事です。
meihei GW アドベントカレンダーとは?
meiheiがゴールデンウィークの5月1日〜5日までの間に毎日記事を投稿する企画です。勝手にやっています。
1日目 https://developers.prtimes.com/2024/05/01/had-a-good-time-at-phpcon-odawara-2024/
LeagueCSVとは?
LeagueCSV は、CSV の読み書きを抽象化できるシンプルな API を備えた PHP ライブラリです。ストリームフィルタにも対応し、 Shift_JIS などの異なる文字コードの CSV ファイルも扱うことが可能です。
要件と選定理由
数ヶ月前になりますが、私はメディアリストの機能改善の開発をしていました。メディアリストとは、プレスリリースを送付するメディアの連絡先をまとめたものです。
開発(リプレイス)した機能では、CSVからデータを読み込んでデータベースに登録するというものです。
この機能開発にあたり、以下の要件を満たす必要がありました
- CSV ファイルのカラム数は変動することがあり、順序も変更することがある
- 文字コードは UTF-8, Windows-31J の両方に対応する
- ヘッダーは日本語である
1 の理由から、何列目が何のデータなのかを添え字で判定することは困難だったので、ヘッダーの文字列を認識できるライブラリを用いて、データを判別できるようにしました。
2, 3 の理由から、文字コードのエンコーディングに罠が少ないものを選びました。
(参考:【PHPerKaigi2021】PHPでCSVを安心して扱うために)
これらの要件を満たし、メンテナンスが適切に行われていて、そして既存の利用事例があるという理由から、LeagueCSVを選択しました。
CSV リーダーの実装
LeagueCSV は CSV の読み込みと書き込みが可能ですが、今回の要件では読み込みのみが必要だったので、 CSV リーダーとしてラッパーを実装しました。
ライブラリをラップする
まずは、League\Csv\Reader をラップして CSV リーダーとなるクラスを作成します。
アプリケーションからは直接 League\Csv\Reader を呼び出さずに、このクラスを使うことで、ライブラリとの依存関係を疎にすることができます。
use League\Csv\Reader;
class CsvReader
{
private Reader $reader;
private function __construct(Reader $reader) {...}
public static function createFromPath(string $path): self {...}
public function getRowArrayIterator(): Iterator {...}
public function getHeader(): array {...}
}League\Csv\Reader を生成する
League\Csv\Reader のコンストラクタは protected method で、 createFromPath などの static method から呼び出していました。
それを参考に CsvReader クラスも同様に static method として createFromPath を作成しました。
public static function createFromPath(string $path): self
{
$reader = Reader::createFromPath($path);
$reader->setHeaderOffset(0); // この時点ではヘッダーの検証は行われない
return new self($reader);
}setHeaderOffset の呼び出しは後述します。
2パターンの文字コードに対応する
要件上、UTF-8, Windows-31J の両方の文字コードに対応する必要が有りました。
文字コードとは、文字や記号などをコンピュータで扱うために番号を与えた対応規則です。
例えば「メール」という文字列を UTF-8 で表すとE3 83 A1 E3 83 BC E3 83 ABになりますが、 Windows-31J では83 81 81 5B 83 8B となります。
これは CSV とそのリーダーでも問題になります。
例として、以下のような CSV ファイルがあるとします。
ID,メール
1,csv1@example.com
2,csv2@example.testこれを League\Csv\Reader で CSV ファイルを読み込む( default_charset と mbstring.internal_encoding = UTF-8 )
$csv = Reader::createFromPath('./file.csv');
$csv->setHeaderOffset(0); // ここでヘッダー行のカラム名が配列のキーになる
foreach ($csv as $row) {
var_dump($row); // カラム名が「メール」の列の値を取り出す
}CSV ファイルの文字コードが UTF-8 の場合
array(2) {
["ID"]=>
string(1) "1"
["メール"]=>
string(16) "csv1@example.com"
}
array(2) {
["ID"]=>
string(1) "2"
["メール"]=>
string(17) "csv2@example.test"
}CSV ファイルの文字コードが Windows-31J の場合
array(2) {
["ID"]=>
string(1) "1"
["���[��"]=>
string(16) "csv1@example.com"
}
array(2) {
["ID"]=>
string(1) "2"
["���[��"]=>
string(17) "csv2@example.test"
}
このように、何もせずに文字コードが異なるCSVを読み込むと、生成される連想配列のキーの文字列が異なってしまいます。これは後の処理で影響が出てきてしまいます。
回避するために、ファイルの文字コードが Windows-31J (=ソースコード内では CP392 として扱っています)である場合は、入力ストリームに Windows-31J を UTF-8 へ変換するフィルタを追加します。
また、 mbstring 関数でサポートされている encoding パラメータにするために、 Windows-31J と同じ 'CP932' を使っています。
// $few_csv_lines := 数行ファイルを読み込んでいる
$encoding = \mb_detect_encoding($few_csv_lines, ['CP932', 'UTF-8'], true);
if ($encoding === 'CP932') {
\League\Csv\CharsetConverter::addTo($reader, 'CP932', 'UTF-8');
}0行目のヘッダーの検証
LeagueCSV では Reader::setHeaderOffset() を使うことで何行目をヘッダーとするか指定することが出来ます。しかし、このメソッドを呼び出したタイミングでは、セットされた行にヘッダーとなる文字列があるのか?その行をヘッダーとして読み込んで配列のキーとして使う事ができるのか?などの検証が行われないです。これを知らずに実装したので少しハマりました。
実際にライブラリ側でヘッダーが検証されるタイミングは2つあり、Reader::getHeader()とReader::getRecords() になります。
※実際にはもっとありますが、今回のユースケースでは2つでした。
指定された行が空である場合、Reader::getHeader()の内部で呼ばれているReader::setHeader() でSyntaxError が発生します。
return match (true) {
[] === $header,
[null] === $header,
[false] === $header,
[''] === $header && 0 === $offset && null !== $inputBom => throw SyntaxError::dueToHeaderNotFound($offset),
default => $header,
};ヘッダーに重複がある場合、重複した文字列を配列のキーとして使えないので、Reader::getRecords()の内部で呼ばれているReader::computeHeader()でSyntaxError が発生します。
return match (true) {
$header !== array_unique($header) => throw SyntaxError::dueToDuplicateHeaderColumnNames($header),
[] !== array_filter(array_keys($header), fn (string|int $value) => !is_int($value) || $value < 0) => throw new SyntaxError('The header mapper indexes should only contain positive integer or 0.'),
default => $header,
};この 2 つのタイミングは、ライブラリの設計思想によるものかと思います。しかし、今回は 0 行目をヘッダーと見なす仕様としたので、 createFromPath() を呼び出したタイミングでヘッダーの検証も行う実装にしました。
// CsvReader::createFromPath()
try {
// 以下のメソッドでヘッダー内に空文字のヘッダーがあるか検証が行われる。
$headers = $reader->getHeader();
} catch (SyntaxError $e) {
// ヘッダーにからの要素があるのか、BOMだけのヘッダーなのか判別出来ないので、Invalidなヘッダーとしてひとまとめにしている
throw new InvalidHeaderRecordException("The header record of the CSV file is invalid. path: {$path}, message: {$e->getMessage()}");
}
// ヘッダー内に重複があるか検証
// see https://github.com/thephpleague/csv/blob/9.6.2/src/Reader.php#L307-L311
// 将来的には`$exception->duplicateColumnNames()`を使う
if ($headers !== array_unique(array_filter($headers, 'is_string'))) {
throw new DuplicatedHeaderRecordException("The header record of the CSV file is duplicated. path: {$path}");
}困った点
GitHub のPull Request が文字化けする
テストデータで複数の文字コードの CSV ファイルを用意しましたが、GitHub のPull Request 上で文字化けすることがありました。
UTF-8 の同様のデータを用意しているものであればそこまで問題は無かったですが、 Windows-31J だけで準備していたデータは完全に読めなくなってしまっていました。

おわりに
CSV ファイルのリーダーとして LeagueCSV を使用し、ラッパーの実装や文字コード、ヘッダー検証についてご紹介しました。
今のところライブラリ起因の問題は発生しておらず、安定して使えています。
個人的に LeagueCSV はかなり好きです。メンテナンスがしっかり行われているので、ソースコードを読んでモダンな PHP の書き方を学べることが多々ありました。ライブラリを読むことが趣味の人にもオススメです。



