flint>flint blog>2020年>10月>11日>CSVにありがちなこと

CSVにありがちなこと

アプリケーションにおいてな持続的データを扱う場合はデータベース (ほとんどの場合RDB) を使用するのが一般的ですが、その多くは他のアプリケーションとのデータ交換を行うためのインターフェイスとしてCSV: Comma-separated Values (カンマ区切り) と呼ばれるフォーマットでのエクスポート/インポートをサポートしています。

このCSVという形式は (一見すると) 非常に単純で、「カンマ区切り」の呼称が示す通り、各行に個々のレコードに属するフィールドをカンマで区切って並べただけ。 例えば次に示すデータ:

表題 著者 価格
みずほ銀行システム統合、苦闘の19年史 日経コンピュータ 1800
大聖堂・製鉄・水車 ジョゼフ・ギース/フランシス・ギース 1210
彼女は一人で歩くのか? 森博嗣 660

これをCSVで表現すると、メモ帳 (Notepad) でも開くことのできる以下のようなテキストデータとなります:

表題,著者,価格
みずほ銀行システム統合、苦闘の19年史,日経コンピュータ,1800
大聖堂・製鉄・水車,ジョゼフ・ギース/フランシス・ギース,1210
彼女は一人で歩くのか?,森博嗣,660

コンピュータあるいはプログラミングに詳しくない人であっても、一瞥してその内容を見て取ることができるでしょう。

この「単純さ」こそ、CSVが多くのアプリケーションのエクスポータ/インポータとして採用されている最大の理由であるわけですが、それはあくまでも表面的なもの。 このフォーマットを正しく扱うプログラムを作るとなると、なかなかに複雑な処理が要求されることとなり、一筋縄ではいきません。 実際、市販され広く普及している「CSVをサポートしている」ソフトウェアの中にさえ、厳密にはこれを正しく扱えておらず、そのためにデータ交換に際してトラブルを生じるものが数多く存在します。

生兵法は怪我のもと

CSVの表層的な「単純さ」故に、初心者はこれを「簡単な」コードで処理できると考えがち。 先日とある客先よりPHPインタプリタのバージョン更新に伴う移植性検証を依頼されたプログラムでは次のような、ある程度の経験を積んだプログラマなら見た瞬間に頭を抱えてしまうようなコードによるCSV処理が行われていました:

$records = array();

$fp = fopen(INPUT_FILENAME, 'r');

while (!feof($fp)){
    $records[] = explode(',', fgets($fp));
}

fclose($fp);

このコードの何が問題なのかは、次の問題について考えると見えてきます:

フィールド値にデータの区切り文字として使われるカンマを含めたい場合はどうすればよいか?

例えば、このエントリの冒頭のデータのレコード「大聖堂・製鉄・水車」の『著者』フィールドの値は「ジョゼフ・ギース/フランシス・ギース」ですが、この2人の著者名をスラッシュではなくカンマで区切るよう変更するには、下記に示すように二重引用符 (double quotation marks) による「囲み」を用いる必要があります。

大聖堂・製鉄・水車,"ジョゼフ・ギース, フランシス・ギース",1210

ところが、先に挙げたプログラムはこの「囲み」を考慮していないため、上記のテキストデータを処理した結果は次のようになります:

表題 著者 価格 不明なデータ
大聖堂・製鉄・水車 "ジョゼフ・ギース フランシス・ギース" 1210

この二重引用符による「囲み」は、フィールド値にレコードの区切りである改行を含めるためにも使用されます。 以下に、4番目のフィールドとして『解説』を追加した例を示します:

彼女は一人で歩くのか?,森博嗣,660," ウォーカロン。「単独歩行者」と呼ばれる、人工細胞で作られた生命体。人間との差はほどんどなく、容易に違いは判別できない。
 研究者のハギリは、何者かに命を狙われた。心当たりはなかった。彼を保護しに来たウグイによると、ウォーカロンと人間を識別するためのハギリの研究成果が襲撃理由ではないかとのことだが。
 人間性とは命とは何か問いかける、知性が予見する未来の物語。"

3行のテキストとなるため、3つのレコードが含まれるようにも見えますが、最初とその次の改行は二重引用符による「囲み」の中にあるので、レコードの区切りではなく、4番目のフィールドである『解説』の一部と解釈されるべきもの。 すなわち、このテキストを正しく処理した結果は、次に示す単一のレコードとなります:

表題 著者 価格 解説
彼女は一人で歩くのか? 森博嗣 660  ウォーカロン。「単独歩行者」と呼ばれる、人工細胞で作られた生命体。人間との差はほどんどなく、容易に違いは判別できない。
 研究者のハギリは、何者かに命を狙われた。心当たりはなかった。彼を保護しに来たウグイによると、ウォーカロンと人間を識別するためのハギリの研究成果が襲撃理由ではないかとのことだが。
 人間性とは命とは何か問いかける、知性が予見する未来の物語。

ところが、前掲のプログラムは先にも述べた通り「囲み」を考慮していないため、処理の結果は次のような3つのレコードとなります:

表題 著者 価格 解説
彼女は一人で歩くのか? 森博嗣 660 " ウォーカロン。「単独歩行者」と呼ばれる、人工細胞で作られた生命体。人間との差はほどんどなく、容易に違いは判別できない。
 研究者のハギリは、何者かに命を狙われた。心当たりはなかった。彼を保護しに来たウグイによると、ウォーカロンと人間を識別するためのハギリの研究成果が襲撃理由ではないかとのことだが。 N/A N/A N/A
 人間性とは命とは何か問いかける、知性が予見する未来の物語。" N/A N/A N/A

幸いなことに、当該案件では取り扱われる文字列には改行を含むものはなく、カンマもすべて全角文字として扱われているため、問題は顕在化していませんでした。 しかし、将来的には改行や半角カンマを含む文字列が現れないという保証はないため、このプログラムはやはり「バグあり」であると言わざるを得ません。

ここまで見たきたように、二重引用符による「囲み」を正しく認識し、完全なCSV処理を行うにはそれなりに複雑な処理の記述が必要になります。 少なくとも、

  • 1回の fgets で1レコードぶんのデータを読み込むことができる。
  • explode による単純な文字列処理でフィールド値を仕分けすることができる。

という安直な仮定のもとに書かれたプログラムは絶対に「バグあり」のものになります。 完全な処理を行うコードを毎回書き起こすことは現実的には不可能であり、大抵の場合はこれを確実かつ簡易ためのライブラリが提供されています。 PHPであれば、fgetcsv 関数SplFileObject クラスを使用するのが常道かつ正道となるでしょう。

CSVというフォーマットの問題

CSVを処理するプログラムを毎回自力で書き起こすのは、安全性および業務効率の両面においてNGであることに疑い挟む余地はありません。 しかし、そもそも取扱いにそこまで複雑な処理を必要とする「CSVというフォーマット」そのものに問題はないのでしょうか。

個人的には、区切り文字であるカンマおよび改行をデータに含めるための手段として二重引用符による「囲み」を採用してしまったことがCSV最大の失敗だと考えています。 CSVがデータの構成要素となるカンマや改行をエスケープで表現していたならば、現状のそれよりもずっと取り回しの容易なものになっていたはず。 もしも時間を巻き戻してCSVの仕様策定ができるとしたら、二重引用符による「囲み」というアイデアは却下し、たとえばパーセントエンコーディングに基づく以下のようなエスケープを採用するでしょう:

データに含める文字 テキスト上の表現
カンマ %2C
改行 %0D%0A
パーセント記号 %25

このルールに従えば、前節で挙げたような行単位で読み込んだテキストを1レコードに対応させ、フィールドの仕分けを単純な文字列分割で行うような雑なプログラムでもレコードおよびフィールドの境界を間違うことがないからです。 エスケープの解除処理を忘れれば解析後のデータ中にエンコードされたパターンが残ってしまうことにはなりますが、それらはあくまで単一フィールド内での問題であるため容易に対処することが可能です。 さらに言うなら、テキストエディタで開いた場合の可読性のために、「カンマ区切り」ではなく「タブ区切り (TSV: Tab-separated Values)」にしたいところ。

これだけ広く普及しデファクトスタンダードとなってしまったCSVのフォーマットを今更変更するのは不可能であることは言わずもがな。 しかし、業務用アプリケーション開発を手掛けていると、外部データ取り込みなどのために「ローカルな文法」を設計する必要に迫られる機会が往々にしてあるものです。 そうした際には、要求される表現能力を満たすことだけでなく、できるだけ簡潔なプログラムで処理することのできるフォーマットにすることを意識するようにしてみてください。 ただそれだけのことで、あなたのシステムの保守性は随分と向上することでしょう。

関連記事

成田 (手を抜くための手間なら厭わない)
このエントリーをはてなブックマークに追加

コメント

投稿者
URI
メールアドレス
表題
本文