LeagueCSVで日本語ヘッダーのCSVファイルを読み込む

  • URLをコピーしました!

こんにちは、バックエンドエンジニアで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からデータを読み込んでデータベースに登録するというものです。
この機能開発にあたり、以下の要件を満たす必要がありました

  1. CSV ファイルのカラム数は変動することがあり、順序も変更することがある
  2. 文字コードは UTF-8, Windows-31J の両方に対応する
  3. ヘッダーは日本語である

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,
        };

https://github.com/thephpleague/csv/blob/b5a446897c81de6a98e7145bb78d268fe9d00e86/src/Reader.php#L176-L182

ヘッダーに重複がある場合、重複した文字列を配列のキーとして使えないので、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,
        };

https://github.com/thephpleague/csv/blob/b5a446897c81de6a98e7145bb78d268fe9d00e86/src/Reader.php#L561-L565

この 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 の書き方を学べることが多々ありました。ライブラリを読むことが趣味の人にもオススメです。

参考にした記事

  • URLをコピーしました!

この記事を書いた人

株式会社PR TIMES 開発本部 バックエンドエンジニア

目次