次の方法で共有



March 2016

Volume 31 Number 3

最新のアプリ - UWP アプリでの CSV ファイルの解析

Frank La La

コンマ区切り値 (CSV) ファイルの解析は一見簡単に思えます。しかし、すぐに、やっかいな点が明らかになり、作業は複雑になっていきます。念のために記載しておきますが、CSV ファイルはデータをプレーンなテキストで保存します。ファイル内の各行が 1 レコードを構成します。各レコードには通常、コンマで区切られたフィールドがあります。これが名前の由来です。

最近の開発者が利用するデータ変換形式にはさまざまな標準があります。CSV ファイル「形式」と言えば、JSON 以前、XML 以前の初期のソフトウェア業界が思い起こされます。CSV ファイルにも RFC (Request for Comments、bit.ly/1NsQlvw、英語) はありますが、公式のものではありません。さらに、この RFCは、1970 年代に CSV ファイルが登場してから何年も経た 2005 年に作成されたものです。そのため、CSV ファイルにはかなり多くのバリエーションが存在し、その規則は明確ではありません。たとえば、フィールドがタブやセミコロンなどの文字で区切られた CSV ファイルもあります。

現実に目を向けると、Excel には CSV インポートとエクスポートが標準装備されており、マイクロソフトのエコシステム以外でも、この実装が事実上の標準としてソフトウェア業界で幅広く利用されています。そこで、今回は、「正しい」と見なされる解析と形式は Excel の CSV ファイルのインポートとエクスポート方法に基づくと仮定して話を進めます。大半の CSV ファイルは Excel の実装に適合しますが、すべてではありません。今回は、このようなあいまいさに対処する方策を見ていきます。

「最新のプラットフォームで、数十年も前のような形式のパーサーを作成するのはなぜですか」というのは、もっともな質問です。答えは簡単です。以前と変わらないデータ システムを利用している組織が多いためです。このファイル形式の寿命は長いため、長い間運用され続けているデータ システムの大半が CSV へのエクスポートに対応しています。また、CSV へのエクスポートには、時間や手間がほとんどかかりません。そのため、大企業や行政機関のデータ セットには CSV 形式のファイルが多数存在しています。

汎用 CSV パーサーの設計

公式標準は存在しませんが、CSV ファイルにはほぼ共通の特徴があります。

一般的な CSV ファイルは、プレーン テキストで、1 行に 1 つのレコードを含み、各行には区切り記号によって分割される複数のフィールドがあります。区切り記号は 1 文字で、フィールドの順序は決まっています。

こうした共通の特徴によって、汎用アルゴリズムのアウトラインが決まります。つまり、アルゴリズムは次の 3 つのステップから成り立ちます。

  1. 行の区切り記号で文字列を分割する。
  2. フィールドの区切り記号で各行を分割する。
  3. 各フィールド値を変数に代入する。

これを実装するのは簡単です。図 1 のコードは、CSV の入力文字列を解析し、List<Dictionary<string, string>> に格納しています。

図 1 CSV の入力文字列を解析して List<Dictionary<string,string>> に格納

var parsedResult = new List<Dictionary<string, string>>();
var records = RawText.Split(this.LineDelimiter);
foreach (var record in records)
  {
    var fields = record.Split(this.Delimiter);
    var recordItem = new Dictionary<string, string>();
    var i = 0;
    foreach (var field in fields)
    {
      recordItem.Add(i.ToString(), field);
      i++;
    }
    parsedResult.Add(recordItem);
  }

以下のような地域と売上の例に使用すると、この解析方法の効果がわかります。

East, 73, 8300
South, 42, 3000
West, 35, 4250
Mid-West, 18, 1200

文字列から値を取得するには、List を反復し、ゼロから始まるフィールド インデックスを使用して Dictionary の値を取り出します。たとえば、地域のフィールドの取得は、次のような簡単なコードになります。

foreach (var record in parsedData)
{
  string fieldOffice = record["0"];
}

しかし、このしくみはコードがあまり読みやすくはありません。

辞書の強化

多くの CSV ファイルには、フィールド名用のヘッダー行が含まれています。パーサーが辞書のキーとしてフィールド名を使用すれば、開発者にとってわかりやすくなります。しかし、CSV ファイルの中にはヘッダー行が含まれていないものもあります。その場合は、この情報を保持するプロパティを追加します。

public bool HasHeaderRow { get; set; }

たとえば、ヘッダー行があるサンプル CSV ファイルは次のようになっています。

Office Division, Employees, Unit Sales
East, 73, 8300
South, 42, 3000
West, 35, 4250
Mid-West, 18, 1200

CSV パーサーがメタデータのこの部分をうまく活用できるのが理想です。そうすれば、コードが読みやすくなります。地域フィールドは次のように取得します。

foreach (var record in parsedData)
{
  string fieldOffice = record["Office Division"];
}

空白のフィールド

データ セットに空白のフィールドが含まれていることはよくあります。CSV ファイルでは、レコード内に空のフィールドを用意することで、空白のフィールドを表現します。空のフィールドにも区切り文字は必要です。たとえば、East オフィスの Employee データがない場合、レコードは次のようになります。

East,,8300

Employee データだけでなく Unit Sales データもなければ次のようになります。

East,,

どの組織にも、独自のデータ品質標準があります。人間が読みやすくなるように、空白のフィールドに既定値を設定する場合もあります。通常、既定値は、数値の場合 0 または NULL になり、文字列の場合 "" または NULL になります。

柔軟性の確保

CSV ファイル形式にまつわるあいまいさをすべて取り込もうとすると、コードでは何も想定できなくなります。フィールドの区切り記号がコンマになっている保証もなければ、レコードの区切り記号が改行になっている保証もありません。

必然的に、どちらも CSVParser クラスのプロパティになります。

public char Delimiter { get; set; }
public char LineDelimiter { get; set; }

このコンポーネントを開発者にとって使いやすくするには、ほとんどのケースに該当するような既定値を設定します。

private const char DEFAULT_DELIMITER = ',';
private const char DEFAULT_LINE_DELIMITER = '\n';

既定の区切り記号をタブ文字に変更するのも実に簡単です。

CsvParser csvParser = new CsvParser();
csvParser.Delimiter = '\t';

エスケープ文字

フィールド自体にコンマなどの区切り記号が含まれるとしたら、どうすればよいでしょう。たとえば、地域別の売上への参照ではなく、都市名と州記号が含まれるとします。通常、CSV ファイルではフィールド全体を引用符で囲んでこれに対処します。

Office Division, Employees, Unit Sales
"New York, NY", 73, 8300
"Richmond, VA", 42, 3000
"San Jose, CA", 35, 4250
"Chicago, IL", 18, 1200

ここまでのアルゴリズムでは、1 つのフィールド値 "New York, NY" に含まれるコンマを区切り記号と考え、"New York" と "NY" という値の 2 つのフィールドに分離します。

この場合、都市名と州記号が分離されても大きな問題ではありませんが、データに余計な引用符が含まれることになります。この引用符は簡単に取り除くことができますが、データが複雑になると、簡単にはいきません。

複雑化

上記のようにフィールド内のコンマをエスケープする方法では、別の文字へのエスケープを導入することになります。今回の例では引用符です。たとえば、図 2 に示すように、オリジナルのデータに引用符が含まれているとどうなるでしょう。

図 2 引用符を含むデータ

Office Division Employees  Unit Sales Office Motto
New York, NY 73 8300 “We sell great products”
Richmond, VA 42 3000 “Try it and you'll want to buy it”
San Jose, CA 35 4250 “Powering Silicon Valley!”
Chicago, IL 18 1200 “Great products at great value”

CSV ファイル自体に含まれるテキストは次のようになります。

Office Division, Employees, Unit Sales, Office Motto
"New York, NY",73,8300,"""We sell great products"""
"Richmond, VA",42,3000,"""Try it and you'll want to buy it"""
"San Jose, CA",35,4250,"""Powering Silicon Valley!"""
"Chicago, IL",18,1200,"""Great products at great value"""

1 つの引用符 (") をエスケープすると引用符が 3 つ (""") になり、アルゴリズムにねじれが生じます。当然、「どうして 1 つの引用符が 3 つになってしまうのか」という疑問が浮かびます。Office Division フィールドと同様、そのフィールドのコンテンツは引用符に囲まれることになります。コンテンツに含まれる引用符をエスケープするために、もう 1 つ引用符が必要になります。そのため、" は "" になります。

もう 1 つの例 (図 3) を見ると、もっと明確になります。

図 3 ことわざのデータ

Quote
"The only thing we have to fear is fear itself."-President Roosevelt
"Logic will get you from A to B. Imagination will take you everywhere."-Albert Einstein

図 3 のデータは、CSV では次のように表現されます。

Quote

"""The only thing we have to fear is fear itself."" -President Roosevelt"
"""Logic will get you from A to B. Imagination will take you everywhere."" -Albert Einstein"

フィールド全体が引用符に囲まれ、フィールドのコンテンツに含まれる個々の引用符が 2 つの引用符に置き換えられています。

エッジ ケース

冒頭で触れたように、Excel の実装にすべての CSV ファイルが適合するわけではありません。CSV に本当の意味での仕様が存在しないために、既存のすべての CSV ファイルを処理する 1 つのパーサーを作成するのは困難です。エッジ ケースが間違いなく存在するため、コードには解釈やカスタマイズの余地を残しておかなければなりません。

困ったときの制御の反転

CSV 形式の標準があいまいなため、想定されるすべてのケースに対応する包括的なパーサーを作成するのは現実的ではありません。アプリの特定のニーズに合ったパーサーを作成するほうが合理的です。制御の反転を利用すると、特定のニーズに応じて解析エンジンをカスタマイズできるようになります。

これを実現するには、レコードの抽出とフィールドの抽出という解析の核になる 2 つの関数の外枠となるインターフェイスを作成します。ここでは、IParserEngine インターフェイスを非同期にしています。これによって、このコンポーネントを使用するアプリは、CSV ファイルがどんなに大きくなっても応答性を維持できるようになります。

public interface IParserEngine
{
  IAsyncOperation<IList<string>> ExtractRecords(char lineDelimiter, string csvText);
  IAsyncOperation<IList<string>> ExtractFields(char delimiter, char quote,
    string csvLine);
}

CSVParser クラスに以下のプロパティを追加します。

public IParserEngine ParserEngine { get; private set; }

次に、既定のパーサーを使用するか、独自のパーサーを挿入するか、開発者に選択肢を提供します。これを簡単にするには、コンストラクターをオーバーロードします。

public CsvParser()
{
  InitializeFields();
  this.ParserEngine = new ParserEngines.DefaultParserEngine();
}
public CsvParser(IParserEngine parserEngine)        
{
  InitializeFields();
  this.ParserEngine = parserEngine;
}

これで、CSVParser クラスが基本インフラストラクチャを提供するようになりますが、実際の解析ロジックは IParserEngine インターフェイスに組み込みます。開発者の利便性のため、今回、大部分の CSV ファイルを処理できる DefaultParserEngine を作成しました。そこでは、最も直面する可能性が高いシナリオを考慮しています。

読者への課題

今回の例では、CSV ファイルを処理する際に開発者が直面するであろう大部分のシナリオを考慮に入れています。ただし、CSV 形式にあいまいさという性質があるため、すべてのケースに対応する汎用パーサーを作成するのは現実的ではありません。すべてのケースやエッジ ケースを考慮すると膨大なコストがかかり、パフォーマンスに影響し複雑さも増します。

CSV ファイルは DefaultParserEngine では処理できない言わば「未開の地」と考えられます。そのため、依存関係の挿入パターンが最適になります。開発者がきわめて少ないエッジ ケースを処理できるパーサーを必要としていたり、パフォーマンスの高い処理を作成する場合に、このパターンを利用できます。そうすれば、使用するコードを変更しなくても、解析エンジンを交換できます。

このプロジェクトのコードは、bit.ly/1To1IVI (英語) から入手できます。

まとめ

XML や JSON が登場しても、CSV ファイルがいまだに利用され続け、現在でもよく使用されるデータ交換形式の 1 つになっています。CSV ファイルには同じような特徴が多くても、共通の仕様や標準はなく、どのファイルにも適合するとはかぎりません。このため、CSV ファイルの解析は簡単ではありません。

選択肢があるなら、ほとんどの開発者はおそらくソリューションから CSV ファイルを除外するでしょう。しかし、企業や行政機関のこれまでのデータ セットに CSV ファイルが広く存在するため、CSV ファイルの除外は多くのシナリオで選択肢にはなり得ません。

ユニバーサル Windows プラットフォーム (UWP) アプリ向けの CSV パーサーにはニーズがあり、現実の CSV パーサーは柔軟かつ堅牢でなくてはいけません。今回は、そのような柔軟性を提供する依存関係の挿入について、実際的な例を示しました。今回の説明と関連コードのターゲットは UWP アプリですが、その考え方やコードは Microsoft Azure や Windows デスクトップ開発など、C# を実行できる他のプラットフォームにも流用できます。


Frank La Vigne は、Microsoft Technology and Civic Engagement チームのテクノロジ エバンジェリストで、より良いコミュニティを形成するためにユーザーによるテクノロジの活用を支援しています。FranksWorld.com (英語) に定期的にブログ記事を投稿し、Frank’s World TV (youtube.com/FranksWorldTV (英語)) という YouTube チャンネルを主催しています。

この記事のレビューに協力してくれた技術スタッフの Rachel Appel に心より感謝いたします。