März 2016

Band 31, Nummer 3

Moderne Apps – Analysieren von CSV-Dateien in UWP-Apps

Von Frank La La

Das Analysieren einer CSV-Datei (Comma Separated Value, durch Trennzeichen getrennt) scheint auf den ersten Blick nicht schwierig. Doch rasch wird diese Aufgabe immer verzwickter, sobald die Schwachpunkte von CSV-Dateien offenkundig werden. Falls Sie mit diesem Format nicht vertraut sind: in CSV-Dateien werden Daten im Nur-Text-Format gespeichert. Jede Zeile in der Datei stellt einen Datensatz dar. Die Felder jedes Datensatzes sind in der Regel durch ein Komma getrennt, daher der Name.

Entwickler genießen mittlerweile, dass es Standardformate für den Datenaustausch gibt. Das Dateiformat CSV geht zurück zu einer früheren Zeit in der Softwarebranche, und zwar vor JSON und vor XML. Wenngleich es eine RFC (Request for Comments) für CSV-Dateien gibt (bit.ly/1NsQlvw), hat diese keinen offiziellen Status. Darüber hinaus ist diese RFC von 2005, also Jahrzehnte, nachdem CSV-Dateien in den 1970-er Jahren das erste Mal aufgetaucht waren. Die Folge ist, dass es eine gewisse Vielfalt bei CSV-Dateien gibt und die Regeln recht schwammig sind. Die Felder in einer CSV-Datei können beispielsweise durch Tabs, Semikolons bzw. ein beliebiges Zeichen getrennt sein.

In der Praxis ist die Excel-Implementierung des Im- und Exports von CSV-Dateien der eigentliche Standard, der branchenweit und sogar außerhalb des Microsoft-Ökosystems umfassend befolgt wird. Dementsprechend basieren meine Annahmen in diesem Artikel dazu, was eine ordnungsgemäße Analyse und Formatierung auszeichnet, darauf, wie Excel CSV-Dateien im- bzw. exportiert. Wenngleich die meisten CSV-Dateien der Excel-Implementierung folgen, ist dies nicht bei allen Dateien der Fall. Gegen Ende dieser Kolumne stelle ich eine Vorgehensweise für den Umgang mit diesen Fällen vor.

Eine berechtigte Frage lautet: „Warum überhaupt einen Parser für ein jahrzehntealtes Quasiformat auf einer sehr neuen Plattform schreiben?“ Die Antwort ist einfach: Viele Organisationen haben eine Altlast an Datensystemen. Aufgrund der langen Lebensdauer des Dateiformats können nahezu alle dieser älteren Datensysteme Daten in das CSV-Format exportieren. Darüber hinaus ist der Zeit- und Arbeitsaufwand zum Exportieren von Daten in das CSV-Format sehr gering. Dementsprechend befinden sich in den Datenbeständen von größeren Unternehmen und Behörden zahlreiche CSV-formatierte Dateien.

Entwerfen eines CSV-Allzweckparsers

Trotz fehlendem offiziellen Standard zeichnen sich CSV-Dateien meist durch gemeinsame Merkmale aus.

Generell sind CSV-Dateien Nur-Text-Dateien mit einem Datensatz pro Zeile. Die Datensätze sind durch ein Trennzeichen, die aus einem Zeichen bestehen, getrennt und stellen Felder in derselben Reihenfolge dar.

Aus diesen gemeinsamen Merkmalen folgt ein allgemeiner Algorithmus, der aus drei Schritten besteht:

  1. Trennen einer Zeichenfolge beim Zeilentrennzeichen.
  2. Trennen der Zeilen beim Feldtrennzeichen.
  3. Zuweisen jedes Feldwerts zu einer Variablen.

Dies lässt sich relativ einfach implementieren. Der Code in Abbildung 1 analysiert die CSV-Eingabezeichenfolge in „List<Dictionary<string, string>>“.

Abbildung 1: Analysieren der CSV-Eingabezeichenfolge in „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);
  }

Dieser Ansatz funktioniert gut bei einem Beispiel wie den folgenden Abteilungen und ihren Umsatzwerten:

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

Zum Abrufen von Werten aus der Zeichenfolge müssen Sie die Liste durchlaufen und mithilfe des auf null basierenden Feldindexes Werte in das Wörterbuch abrufen. Das Abrufen des Abteilungsfelds ist beispielsweise so einfach wie hier:

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

Wenngleich dies funktioniert, ist der Code nicht so lesbar, wie er sein könnte.

Ein besseres Wörterbuch

Viele CSV-Dateien enthalten eine Kopfzeile für den Feldnamen. Der Parser könnte von Entwicklern einfacher genutzt werden, wenn der Feldname als ein Schlüssel für das Wörterbuch verwendet würde. Da eine bestimmte CSV-Datei ggf. keine Kopfzeile enthält, müssen Sie eine Eigenschaft hinzufügen, um diese Informationen zu vermitteln:

public bool HasHeaderRow { get; set; }

Eine CSV-Beispieldatei mit einer Kopfzeile kann so aussehen:

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

Im Idealfall wäre der CSV-Parser in der Lage, dieses Metadatenelement zu nutzen. Dadurch würde der Code lesbarer. Das Abrufen des Abteilungsfelds sieht dann so aus:

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

Leere Felder

Leere Felder kommen in Datenbeständen üblicherweise vor. In CSV-Dateien wird ein leeres Feld durch ein leeres Feld in einem Datensatz dargestellt. Das Trennzeichen ist dennoch erforderlich. Wenn es z. B. keine Daten des Typs „Employee“ für die Abteilung „East“ gibt, sieht der Datensatz so aus:

East,,8300

Wenn es keine Daten für „Unit Sales“ und „Employee“ gibt, sieht der Datensatz so aus:

East,,

Jede Organisation hat eigene Datenqualitätsstandards. Mitunter wird ein Standardwert in ein leeres Feld eingegeben, damit die CSV-Datei von Benutzern besser gelesen werden kann. Standardwerte sind in der Regel 0 oder NULL für Zahlen und "" oder NULL für Zeichenfolgen.

Flexibel bleiben

Angesichts all der Doppeldeutigkeiten beim CSV-Dateiformat kann der Code nicht mit Annahmen arbeiten. Es gibt keine Garantie, dass das Feldtrennzeichen ein Komma ist, und keine Garantie, dass das Datensatztrennzeichen eine neue Zeile ist.

Dementsprechend sind beide Eigenschaften der „CSVParser“-Klasse:

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

Damit Entwickler diese Komponente besser nutzen können, wünschen sie sich Standardeinstellungen, die in den meisten Fällen gelten:

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

Wenn jemand das Standardtrennzeichen in ein TAB-Zeichen ändern möchte, ist der Code recht einfach:

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

Escapezeichen

Was geschieht, wenn das Feld selbst das Trennzeichen enthält, z. B. ein Komma? Was gilt, wenn beispielsweise anstatt auf den Umsatz nach Region zu verweisen, die Daten die Angaben „Ort“ und „Bundesland“ enthalten? CSV-Dateien behelfen sich in der Regel damit, dass das gesamte Feld in Anführungszeichen gesetzt wird, z. B. so:

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

Der Algorithmus wandelt den einen Feldwert „New York, NY“ in zwei getrennte Felder um deren Werte beim Komma aufgeteilt werden, „Köln“ und „NY“.

In diesem Fall ist das Trennen der Werte von Ort und US-Bundesstaat ggf. nicht von Nachteil, aber durch diese zusätzlichen Anführungszeichen wird der Code weiter überfrachtet. Wenngleich diese hier recht einfach entfernt werden können, lassen sich komplexere Daten ggf. nicht so mühelos bereinigen.

Jetzt wird‘s kompliziert

Das Verwenden von Escapezeichen für Kommas innerhalb von Feldern führt zu einem anderen in Escapezeichen zu setzenden Zeichen: dem Anführungszeichen. Was passiert, wenn die Ursprungsdaten (wie in Abbildung 2 gezeigt) Anführungszeichen enthalten?

Abbildung 2: Ursprungsdaten mit Anführungszeichen

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”

Der unformatierte Text in der CSV-Datei sieht folgendermaßen aus:

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"""

Das eine Anführungszeichen (“) wird mit Escapezeichen zu drei Anführungszeichen (“””), was dem Algorithmus eine interessante Wendung hinzufügt. Die erste begründete Frage, die sich stellt, lautet freilich: Warum sind aus einem Anführungszeichen drei geworden? Wie beim Feld „Office Division“ wird der Inhalt des Felds in Anführungszeichen gesetzt. Um Anführungszeichen mit Escapezeichen zu versehen, die Teil des Inhalts sind, werden sie verdoppelt. Deshalb ändert sich “ in “”.

Dies kann in einem anderen Beispiel (Abbildung 3) ggf. noch besser verdeutlicht werden.

Abbildung 3: Zitatdaten

Zitat
"The only thing we have to fear is fear itself." (Das Einzige was wir zu fürchten haben, ist die Furcht.) – US-Präsident Franklin D. Roosevelt
"Logic will get you from A to B. Imagination will take you everywhere." (Logik bringt dich von A nach B. Deine Vorstellungskraft bringt dich überall hin.) – Albert Einstein

Im CSV-Format werden die Daten in Abbildung 3 wie folgt abgebildet:

Zitat

"""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"

Nun da das Feld in Anführungszeichen gesetzt ist und die einzelnen Anführungszeichen im Inhalt des Felds verdoppelt wurden, wird die ganze Sache ggf. klarer.

Grenzfälle

Wie bereits in der Einleitung erwähnt, befolgen nicht alle Dateien die Excel-Implementierung des CSV-Formats. Das Fehlen einer echten CSV-Spezifikation erschwert das Schreiben des einen Parsers zum Verarbeiten sämtlicher vorhandener CSV-Dateien. Nahezu mit Sicherheit gibt es Grenzfälle, was heißt, dass der Code eine Tür für Interpretation und Anpassung geöffnet lassen muss.

Steuerungsumkehr als Hilfsnahme

Angesichts des schwammigen Standards des CSV-Formats ist es unpraktisch, einen umfassenden Parser für alle vorstellbaren Fälle zu schreiben. Besser ist es, einen Parser zu entwickeln, der die besonderen Anforderungen einer App erfüllt. Mithilfe der Steuerungsumkehr (Inversion of Control, IoC) können Sie ein Analysemodul an eine bestimmte Anforderung anpassen.

Zu diesem Zweck erstelle ich eine Schnittstelle zum Herausstellen der beiden Hauptaufgaben der Analyse: Extrahieren von Datensätzen und Extrahieren von Feldern. Ich habe mich entschieden, die „IParserEngine“-Schnittstelle asynchron zu gestalten. Dies stellt sicher, dass alle Apps, die diese Komponente nutzen, reaktionsfähig bleiben, und zwar unabhängig von der Größe der CSV-Datei:

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

Dann füge ich der „CSVParser“-Klasse die folgende Eigenschaft hinzu:

public IParserEngine ParserEngine { get; private set; }

Anschließend überlasse ich Entwicklern die Wahl: den Standardparser verwenden oder einen eigenen einschleusen. Zur Vereinfachung überlade ich den Konstruktor:

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

Die „CSVParser“-Klasse stellt nun die Grundinfrastruktur bereit, doch die tatsächliche Analyselogik ist in der „IParserEngine“-Schnittstelle enthalten. Um es Entwicklern einfacher zu machen, habe ich die „DefaultParserEngine“ erstellt, die die meisten CSV-Dateien verarbeiten kann. Ich habe dafür die wahrscheinlichsten Szenarien berücksichtigt, auf die Entwickler treffen werden.

Herausforderung für die Leser

Ich habe die wahrscheinlichsten Szenarien für CSV-Dateien berücksichtigt, auf die Entwickler stoßen werden. Doch aufgrund der Schwammigkeit des CSV-Formats ist das Entwickeln eines universellen Parsers für alle Fälle ein Ding der Unmöglichkeit. Das Berücksichtigen sämtlicher Variationen und Grenzfälle würde für eine Zunahme von Kosten und Komplexität sorgen und zudem die Leistung beeinträchtigen.

Ich bin sicher, dass es „da draußen“ CSV-Dateien gibt, die die „DefaultParserEngine“ nicht verarbeiten kann. Deshalb bietet sich hier die Abhängigkeitsinjektion (Dependency Injection) unbedingt an. Falls Entwickler einen Parser brauchen, der mit einem extremem Grenzfall zurechtkommt, oder etwas mit mehr Leistungsfähigkeit entwickeln wollen, sind sie herzlich dazu eingeladen. Parsermodule können ausgetauscht werden, ohne dass der Code geändert werden muss, der sie nutzt.

Der Code für dieses Projekt steht unter bit.ly/1To1IVI zur Verfügung.

Zusammenfassung

CSV-Dateien sind ein Überbleibsel aus vergangenen Zeiten, werden aber trotz der Weiterentwicklungen bei XML und JSON noch immer zum Datenaustausch verwendet. Für CSV-Dateien fehlt eine gemeinsame Spezifikation oder ein Standard. Während sie häufig gemeinsame Merkmale haben, heißt dies nicht, dass diese in allen Dateien an derselbe Stelle sind. Aus diesem Grund ist die Analyse von CSV-Dateien keine leichte Sache.

Vor die Wahl gestellt würden die meisten Entwickler CSV-Dateien wohl aus ihren Lösungen ausschließen. Doch aufgrund ihrer weiten Verbreitung in den Datenbeständen von Großunternehmen und Behörden ist dies in vielen Fällen unmöglich.

Deshalb besteht ein Bedarf an einem praxistauglichen CSV-Parser für UWP-Apps (Universelle Windows-Plattform), der flexibel und zuverlässig sein muss. Nebenbei habe ich hier eine praktische Anwendung von Abhängigkeitsinjektion (Dependency Injection) demonstriert, die diese Flexibilität ermöglicht. Wenngleich diese Kolumne und der dazugehörige Code für UWP-Apps gedacht sind, eignen sich Konzept und Code auch für andere Plattformen, auf denen C# ausgeführt werden kann, wie z. B. Microsoft Azure, oder für die Windows-Desktopentwicklung.


Frank La Vigne* ist ein IT-Experte im Microsoft Technology and Civic Engagement-Team. Sein Ziel ist es, Benutzern bei der Nutzung von IT-Technologie zu helfen, um für eine bessere Erfahrung zu sorgen. Auf FranksWorld.com führt er einen Blog, sein YouTube-Kanal heißt Frank’s World TV (youtube.com/FranksWorldTV).*

Unser Dank gilt der folgenden technischen Expertin für die Durchsicht dieses Artikels: Rachel Appel