さまざまなCSVファイルに対応する
違いは、入力の入り口で吸収する
今までの例では、chompとsplitを使って、非常に単純な――言い方を代えると、とても行儀のよいCSVを前提としてコードを書いてきました。ところが、実際に業務で見かけるCSVファイルには、chompとsplitだけでは対処のできない、複雑な形式のCSVも存在します。ここでは、そのようなCSVをどのように取り扱うべきなのかを見ていきます。
その前に、形式の異なるCSVを扱うための基本姿勢を、明確にしておきます。何度も出てきているように、PerlでCSVを取り扱う基本形として、以下のコードを利用してきました。
open(IN, 'data.csv'); while(<IN>){ chomp; my @cols = split(/,/, $_); # ... # メインとなる処理を書く # ... } close(IN);
このコードにおいて、@colsにデータを代入するまでが前処理、それ以後が本処理となります。前処理が終わった時点で、@colsにはCSVファイルの各カラムの文字列表現が入っていることが期待されます。
例えば、この後説明するExcel方式のCSVファイルの場合、「MESSAGE01」「Hello, world!」「English」という3つの文字列カラムを含む行は、以下のように記述されます。
MESSAGE01,"Hello, world!",English
この場合、前処理が終わった後の@colsは、CSVファイルを作る前に意図したような、以下の内容となっているべきです。真ん中のカラムのダブルクォートが消えていることと、このカラムはカンマがあるのに分割されていないことに気をつけて下さい。特に、ダブルクォートは単なる列の区切り用の文字であって、@cols配列には含まれるべきではありません。
$cols[0] => MESSAGE01 $cols[1] => Hello, world! $cols[2] => English'
このポリシーを守らずに$cols[1]に「"Hello, world!"」などのダブルクォート付きの文字列が入れてしまうと、メインとなる処理で絶えずこのダブルクォートを意識しながらロジックを書く必要があり、混乱のもととなってしまいます。
フォーマットの違うCSVを扱う場合には、@colsへ値を代入する前にその違いを完全に吸収すべきです。もっと具体的に言えば、open、chomp、splitの3つの作業の合間で、違いを吸収するべきであるということになります。
以下で紹介する内容は、すべてこの前処理に関する説明です。複雑なCSVファイルを相手にする場合でも、前処理が適切に行われさえすれば、その後の処理は今までに紹介したような方法で同じように処理できます。
Excel方式のCSVフォーマット
純粋にカンマで区切るというルールだけのCSVだと、カンマを含む値をデータにできないのでとても不便です。そのため、通常CSVフォーマットと言えばExcelが出力する形式のCSVが多く使われます。Excelフォーマットのルールは、以下の通りです。
- カンマを含むデータを記述するときは、全体をダブルクォートでくくる
- ダブルクォート内でダブルクォートを表すには、ダブルクォートを二つ並べて記述する
このフォーマットを厳密に取り扱うのは、実はそんなに簡単ではありません。もちろん、自分でコーディングすることはできますが、本稿で扱うような作業用のスクリプトを考えた場合には、このような複雑な実装を毎回考えながら行うのは非効率的であり、あまりお薦めできません。
この問題に関して、前回の記事で弾さんからトラックバックを頂きました(フォローありがとうございます)。弾さんのご指摘のとおり、CPANのモジュールを利用するのがいいでしょう。「Text::CSV_XS」をインストールし、以下のように、parse()メソッドに行を渡して解析させ、その後にfields()メソッドを呼ぶという二段構えで利用します。
use Text::CSV_XS; my $csv = new Text::CSV_XS({binary => 1}); open(IN, 'data.csv'); while(<IN>){ # CSV_XSを利用し、カラムを分解する $csv->parse($_) || die "parse失敗: " . $csv->error_input() . "\n"; my @cols = $csv->fields(); # ... # メインとなる処理を書く # ... } close(IN);
日本語を扱う場合は、コンストラクタでbinaryオプションを指定しておきます。これを指定しなければ、多バイト文字が検出された時点でエラーとなってしまいます。本稿で想定しているような集計処理では、ほとんどの場合日本語を含むデータでしょうから、binaryオプションは常時ONにすると覚えても問題はないかと思います。
また、CPANが利用できない場合には、大崎博基さんの書かれている「Perlメモ」が大変参考になります。こちらから「CSV形式の行から値のリストを取り出す」や「値に改行コードを含むCSV形式を扱う」の項目を参考にして、splitの代替サブルーチンを自分で定義するといいでしょう。筆者は、こちらの方法をよく利用しています。
改行コードの違い
chomp関数は、LF(\r)しか取り除いてくれません。そのため、CRLF(\r\n)を改行コードとして持つWindows上のファイルを、Unix系OS上のPerlで扱うと問題が生じます。特に、行末のカラムにID項目があるとこの制約に引っかかることがあります。
例えば、「012345,AAA,BBB,CCC」のような行と「XXX,YYY,012345」を「012345」をキーとしてマッチさせようとする時、後者の文字コードがCRLFでPerlの実行環境がLFだった場合に、末尾のキー項目はchompをしただけでは"012345\r"という7バイトの文字列になってしまうため、前者の"012345"とeqで比較をしても等しくなりません。
012345,AAA,BBB,CCC\r\n ↓chomp + split 「012345」「AAA」「BBB」「CCC\r」 XXX,YYY,012345\r\n ↓chomp + split 「XXX」「YYY」「012345\r」
この問題に関して、前項で紹介したText::CSV_XSを利用する場合は、デフォルトでLFとCRLFのどちらかを改行文字として扱ってくれるので、特に改行コードについて考慮する必要はありません。
Text::CSV_XSが利用できない場合には、chompの代わりに以下のような正規表現で代用ができます。「s/(置換対象となるパターン)/(置換文字列)/」は置換を表します。ここでは置換後の文字列を指定していないので、削除するということになります。
open(IN, 'data.csv'); while(<IN>){ s/\r?\n$//; # chompの代わりの正規表現 my @cols = split(/,/, $_); # ... # メインとなる処理を書く # ... } close(IN);
また、正規表現において「?」は0個、または1個という意味です。また、最後の「$」は変数ではなく行末を表します。よって、「行末の、\rが1個と\nが1個(CRLF)、または\rが0個と\nが1個(LF)を、空文字列で置き換える」という意味の正規表現になります。
なお、Windows上のActivePerlにおいては、ファイルの読み込み時に「\r\n」は「\n」に置換されます。そのため、CRLFで書かれたファイルであっても<IN>で読み込んだ後には行末がLFで終わっているため、chompだけで行末の改行文字を正しく削除することができます。
マルチバイト文字の扱い
最近の環境ですと、CSVファイルでもさまざまな文字コードが使われていることがあると思いますが、Perlは日本語のようなマルチバイトコードの文字列でも、バイナリデータとみなして処理を進めてくれます。よって、入力と出力の文字コードが同じでよい場合(特にOSのデフォルトエンコーディングで処理する場合)には、特に気をつけなくとも問題なく処理させることができます。
しかし、Unix系環境で作成された日本語のCSVファイルを、Windows上で集計をしたいといったように、複数環境をまたがる場合には注意が必要です。集計処理自体はほとんどの場合は問題なく処理できるでしょうが、出力の際にDOSプロンプトやメモ帳では文字化けをしてしまって見れない場合があります。このように、入力と出力で文字コードを変更しなければならない場合は、「Encode.pm」を利用するとよいでしょう。5.8以降のPerlを利用している場合は、追加のモジュールをインストールしなくても標準でこの機能を利用することができます。
Encode.pmを完璧に使いこなすには、UTF-8 flagやPerlIOの知識が必須となりますが、自信のない方でもfrom_to関数を利用することで、簡単に文字コードを変換することができます。
use Encode; open(IN, 'data.csv'); while(<IN>){ chomp; # Shift_JISをUTF-8へ変換する Encode::from_to($_, 'Windows-31J', 'UTF-8'); my @cols = split(/,/, $_); # ... # メインとなる処理を書く # ... } close(IN);
Encode.pmは、Perlのバージョンが5.8よりも古い場合は残念ながら利用できません。この場合は、CPANから「Jcode.pm」をインストールして利用して下さい。コードはEncode.pmを使った場合とほとんど一緒ですが、from_to関数とconvert関数では引き数の渡し方とその順序が多少違うことに気をつけて下さい。
use Jcode; open(IN, 'data.csv'); while(<IN>){ chomp; # Shift_JISをUTF-8へ変換する Jcode::convert(\$_, 'utf8', 'sjis'); my @cols = split(/,/, $_); # ... # メインとなる処理を書く # ... } close(IN);
他にも、文字コードに注意を払わねばならない場合があります。それは、ソースコード内とCSVの双方に日本語を含む場合です。例えば、Shift JISで書かれたCSVをEUC-JPで記述されたPerlで処理する場合、if($cols[0] eq '山田太郎')のような記述をしても、うまく引っかかってくれません。これは、$cols[0]がShift JISで、'山田太郎'がEUC-JPとなっているため、バイナリレベルで見ると別々のデータとなってしまうからです。このような場合は、入力文字列にEncode.pmなどを使い、ソースコードとCSVの文字コードを同じに揃える必要があります。
まとめ
Perlを使ったデータ処理に関して、今回は以下の事柄を説明しました。
- Perlで複数のCSVファイルをJOINすることができる
- JOINするには配列ではなく、ハッシュをインデックスとして利用する
- 2つのファイルに含まれるID項目の差分を得る方法
- 一言にCSVと言っても、形式や改行コードが異なる場合がある
- Text::CSV_XSにより、Excel形式のCSVを読み込むことができる
- Encode.pmかJcode.pmで文字コードの変換ができる
本稿で紹介しているのはあくまでもTipsであり、実際に業務でCSVを集計する場合にはこれらの内容を上手に組み合わせて、目的の処理を作る必要があります。処理を考える場合は、自分が「手」でその作業を行った場合の手順を考えて、それをPerlで記述するのが手っ取り早い方法です。
Perlは人間がそれをするよりも遥かに高速に作業を行ってくれます。あなたのもう一つの「手」として、きっと多いに役に立ってくれることでしょう。
参考資料
- Excel形式のCSVについて
- 404 Blog Not Found 『perl - CSVはText::CSV(_XS)?で』 小飼弾 著、2007年2月16日
- 『Perlメモ』大崎博基 著
- CPANモジュールについて
