CSV-Dateien verarbeiten

(Auszug aus "Reguläre Ausdrücke" von Jeffrey E. F. Friedl)

Das Einlesen und Parsen von CSV-Dateien (Comma Separated Values, durch Komma getrennte Werte) ist mitunter etwas verzwickt. Es scheint manchmal, dass jedes Programm, das CSV-Dateien generiert, sein eigenes CSV-Format definiert.

Ich beginne mit einem Programm, das die Art von CSV-Dateien einliest, die Microsoft Excel erzeugt; wir werden das später für andere Varianten verallgemeinern. (Anmerkung: Die Endversion für Microsoft-CSV-Daten finden Sie unter Die Kunst, reguläre Ausdrücke zu schreiben (unter Aufbrechen von Schleifen bei CSV-Daten); diese berücksichtigt auch die Überlegungen zur Effizienz aus diesem Kapitel.) Das Microsoft-Format ist auch eines der einfacheren. Die durch Kommas getrennten Werte sind entweder »nackt« (stehen einfach zwischen Kommas) oder in Anführungszeichen »eingekleidet«; wenn innerhalb der Anführungszeichen wieder ein Anführungszeichen vorkommt, ist es verdoppelt.

Hier ein Beispiel:

Zehn Tausender,10000, 2710 ,,"10,000","Das warfln ""10 große Scheine"", Baby",10K

Die Zeile enthält sieben Felder:

Zehn Tausender
10000
2710

  (Ein leeres Feld)
10,000
Das war'n "10 große Scheine", Baby
10K

Um diese Zeile zu parsen, benötigen wir einen Ausdruck, der die zwei Typen von Werten erkennt. Die »nackten« Werte sind einfach – sie dürfen alles außer Anführungszeichen und Kommas enthalten; das erkennen wir mit ˹[^",]+˼.

Die Werte in Anführungszeichen dürfen Kommas, Leerzeichen und überhaupt alles außer Anführungszeichen enthalten. Sie dürfen außerdem zwei Anführungszeichen hintereinander enthalten, die für ein einzelnes Anführungszeichen im eigentlichen Wert stehen.

Ein Feld mit Anführungszeichen können wir nach diesem Rezept durch eine beliebige Anzahl von ˹[^"]|""˼ innerhalb von ˹"..."˼ erkennen, das ergibt ˹"(?:[^"]|"")*"˼. (Mit atomaren Gruppen, mit ˹(?>...)˼ statt ˹(?:...)˼, wäre das noch effizienter. Mehr dazu unter Die Kunst, reguläre Ausdrücke zu schreiben, unter Atomare Gruppen und possessive Quantoren verwenden.)

Zusammengesetzt ergibt sich die Regex ˹[^",]+|"(?:[^"]|"")*"˼, die auf ein einzelnes Feld passt. Das ist schon wieder so unübersichtlich, dass ich besser den Modus »Freie Form« verwende (siehe Der Modus »Freie Form«):

[^",]+                    # Entweder ein Feld ohne Kommas und Anführungszeichen ...
|                         # ... oder ...
                          # ... ein Feld in Anführungszeichen:
"                         #       Öffnendes Anführungszeichen
  (?: [^"] | "" )*        #         Anführungszeichen nur verdoppelt erlaubt
"                         #       Schließendes Anführungszeichen

Wir können diese Regex wiederholt auf eine CSV-Zeile anwenden, aber wenn wir wirklich Werte herausholen wollen, müssen wir wissen, welche der Alternativen gepasst hat. Wenn es ein Feld in Anführungszeichen war, müssen wir die äußeren Anführungszeichen entfernen und die inneren, verdoppelten durch einzelne ersetzen.

Ich kann mir dazu zwei Ansätze vorstellen. Beim ersten betrachten wir das erste Zeichen: Wenn es ein Anführungszeichen ist, entfernen wir das erste und das letzte Zeichen (die äußeren Anführungszeichen) und ersetzen die inneren ›""‹ durch ›"‹. Das ist recht einfach, aber mit einfangenden Klammern geht es noch einfacher. Nach dem Matching untersuchen wir die eingefangenen Texte und sehen dann, welche Alternative gepasst hat:

( [^",]+ )                # Entweder ein Feld ohne Kommas und Anführungszeichen ...
|                         # ... oder ...
                          # ... ein Feld in Anführungszeichen:
"                         #       Öffnendes Anführungszeichen
  ( (?: [^"] | "" )* )    #         Anführungszeichen nur verdoppelt erlaubt
"                         #       Schließendes Anführungszeichen

Wenn wir feststellen, dass das erste Klammerpaar einen Text eingefangen hat, verwenden wir diesen als Wert. Wenn es das zweite war, müssen wir noch eventuell vorhandene ›""‹ durch ›"‹ ersetzen.

Das ergibt ein Perl-Programm, das ich später (nach etwas Debugging) auch in Java und in VB.NET umgeschrieben vorstelle (eine Version in PHP finden Sie in PHP unter CSV-Dateien mit PHP verarbeiten). Wir nehmen an, dass die CSV-Zeile in der Variablen $zeile vorliegt und dass das Newline am Ende schon entfernt wurde (das Newline gehört schließlich nicht zum letzten Wert in der Zeile).

while ($zeile =~ m{
           ( [^",]+ )                # Ein Feld ohne Kommas und Anführungszeichen ...
           |                         # ... oder ...
                                     # ... ein Feld in Anführungszeichen:
           "                         #   Öffnendes Anführungszeichen
             ( (?: [^"] | "" )* )    #     Anführungszeichen nur verdoppelt erlaubt
           "                         #   Schließendes Anführungszeichen
        }gx)
{
   if (defined $1) {
       $feld = $1;
   } else {
       $feld = $2;
       $feld =~ s/""/"/g;
   }
   print "[$feld]"; # Feld für Debugging-Zwecke ausgeben.
     .. . # Wert in $feld kann jetzt weiterverarbeitet werden.
}

Mit unseren Testdaten ergibt sich:

[ZehnTausender][10000][2710][10,000][Daswar'n"10großeScheine",Baby][10K]

Das sieht schon ganz gut aus, aber leider bekommen wir gar nichts für das leere vierte Feld. Wenn das Programm beispielsweise die Felder in ein Array abspeichert, wollen wir für das fünfte Array-Element »10,000« erhalten. Das funktioniert nur, wenn wir auch leere Felder erkennen und entsprechend leere Array-Elemente abspeichern.

Wir könnten ja einfach ˹[^",]+˼ durch ˹[^",]*˼ ersetzen, so passt die erste Alternative auch auf leere Felder. Das scheint klar auf der Hand zu liegen, aber funktioniert das auch?

Probieren wir es aus. Wir erhalten:

[ZehnTausender][][10000][][2710][][][][10,000][][][Daswar'n"...",Baby][][10K][]

Hoppla. Wir haben irgendwie eine Menge zusätzlicher leerer Felder bekommen! Nun ja, wenn man sich das recht überlegt, sollten wir nicht zu sehr überrascht sein. Mit ˹(...)*˼ ist auch ein Treffer möglich, der auf den Nullstring passt. Das ist ganz in unserem Sinne, wenn tatsächlich ein leeres Feld vorkommt; aber nachdem das erste Feld erkannt wurde, beginnt die Regex-Maschine bei der zweiten Iteration an der Position ›ZehnTausender▵,10000...‹. Nichts in der Regex passt auf das Komma, aber mit dem Stern ist nun auch ein leerer String ein erfolgreicher Treffer, der genau an der aktuellen Position passt. Im Prinzip könnte dieser leere String unendlich oft gefunden werden, aber das Getriebe der Regex-Maschine ist so eingerichtet, dass es nach einem erfolgreichen, aber leeren Treffer ein Zeichen weiterschaltet (siehe unter Ende der letzten Mustersuche oder Anfang der aktuellen?). Daher bekommen wir zwischen allen wirklichen Treffern einen leeren Treffer, einen zusätzlichen leeren Treffer vor jedem Feld mit Anführungszeichen und einen leeren Treffer am Ende des Strings.

Das Getriebe ausschalten

Das Problem rührt daher, dass wir uns darauf verlassen haben, dass uns das Getriebe nach jeder Iteration des /g über das Komma hinweghilft. Wir können das Problem lösen, indem wir diese Aufgabe selbst übernehmen und das Getriebe gar nicht benutzen. Es gibt hier zwei Möglichkeiten:

  • Wir suchen in der Regex explizit nach den Kommas. In diesem Fall gehört das Erkennen des Kommas eigentlich zum Erkennen des Feldes, und wir durchqueren den String komplett, wir erkennen jedes Zeichen.
  • Wir könnten bei jedem Feld testen, ob wir an einer Stelle sind, wo ein Feld überhaupt anfangen kann. Felder beginnen entweder am Anfang des Strings oder nach einem Komma.

Oder noch besser – wir können beide Ansätze kombinieren. Den ersten Ansatz können wir so umsetzen, dass jedes Feld außer dem ersten mit einem Komma beginnen muss. Wir könnten auch umgekehrt vorgehen und fordern, dass jedes Feld außer dem letzten mit einem Komma enden muss. Wir können dazu vor unsere Regex ein ˹^|,˼ setzen (oder ein ˹$|,˼ dahinter) und die erforderlichen Klammern hinzufügen.

Wir versuchen es mit ˹^|,˼ und erhalten:

(?:^|,)
(?:
      ( [^",]* )                # Entweder ein Feld ohne Kommas und Anführungszeichen ...
   |                            # ... oder ...
                                # ... ein Feld in Anführungszeichen:
      "                         #       Öffnendes Anführungszeichen
        ( (?: [^"] | "" )* )    #         Anführungszeichen nur verdoppelt erlaubt
      "                         #       Schließendes Anführungszeichen
)

Das sollte nun wirklich funktionieren, aber wenn wir diese Änderung in unser Programm einbauen, erhalten wir:

[ZehnTausender][10000][2710][][][000][][Baby][10K]

Erwartet hatten wir aber:

[ZehnTausender][10000][2710][][10,000][Daswar'n"10großeScheine",Baby][10K]

Warum hat es denn diesmal nicht funktioniert? Gibt es jetzt ein Problem bei den Feldern, die in Anführungszeichen eingeschlossen sind? Nein, das Problem tritt schon vorher auf. Sie erinnern sich an die Lehre, die wir aus dem Beispiel aus Ausnutzen der geordneten Alternation gezogen haben: Wenn mehrere Alternativen auf den gleichen Text passen können, muss man sich die Reihenfolge der Alternativen genau überlegen. Für die erste Alternative, ˹[^",]*˼, ist auch ein leerer String ein erfolgreicher Treffer, daher wird die zweite Alternative nie verwendet, es sei denn, etwas anderes, das später in der Regex auftritt, erzwingt dies. Bei unserer Regex kommt aber nach den Alternativen gar nichts, daher wird die zweite Alternative niemals verwendet!

Man muss sich das noch einmal durch den Kopf gehen lassen. Na dann, probieren wir es noch einmal mit vertauschten Alternativen:

(?:^|,)
(?:                             # Entweder ein Feld in Anführungszeichen ...
      "                         #       Öffnendes Anführungszeichen
        ( (?: [^"] | "" )* )    #         Anführungszeichen nur verdoppelt erlaubt
      "                         #       Schließendes Anführungszeichen
   |                            # ... oder ...
      ( [^",]+ )                # ... ein ›nacktes‹ Feld ohne Kommas und Anführungszeichen.
)

Es funktioniert! Nun ja, mit unseren Testdaten funktioniert es tatsächlich. Schlägt unser Verfahren vielleicht mit anderen Daten fehl? Sicher schadet es nichts, das Programm mit weiteren Daten zu testen. Dieser Abschnitt heißt aber »Das Getriebe ausschalten«, und mit dem Metazeichen ˹\G˼ können wir sicherstellen, dass jede Iteration des /g genau da beginnt, wo die letzte geendet hat. Wir vermuten, dass dies ohnehin der Fall ist, weil wir die Regex so konstruiert haben, dass sie beim Durcharbeiten des Strings jedes Zeichen erkennt. Wenn wir am Anfang der Regex ein ˹\G˼ einfügen, verbieten wir alle Treffer, für die das Getriebe weiterschalten muss. Wir nehmen an, dass dieser Fall gar nicht auftritt, aber wenn er vorkommt, wird der Fehler dafür umso offensichtlicher. Hätten wir das bei der vorherigen, unkorrekten Regex getan, hätten wir statt

[ZehnTausender][10000][2710][][][000][][Baby][10K]

das ebenso falsche

[ZehnTausender][10000][2710][][]

erhalten, und wir hätten sofort gemerkt, dass etwas faul ist.


CSV-Dateien mit Java parsen

Hier ist das CSV-Beispiel mit dem java.util.regex-Package von Sun. In diesem Beispiel geht es um Klarheit und gute Lesbarkeit; eine effizientere Version finden Sie unter Java (unter Daten im CSV-Format verarbeiten).

import java.util.regex.*;
   Pattern regex = Pattern.compile(
   "\\G(?:^|,)                                              \n"+
   "(?:                                                     \n"+
   "   # Entweder ein Feld in Anführungszeichen ...         \n"+
   "   \"  # Öffnendes Anführungszeichen                    \n"+
   "    (   (?: [^\"]++ | \"\" )*+   )                      \n"+
   "   \"  # Schließendes Anführungszeichen                 \n"+
   " #  ... oder ...                                        \n"+
   " |                                                      \n"+
   "   # ... ein Feld ohne Kommas und Anführungszeichen ... \n"+
   "   ( [^\",]* )                                          \n"+
   " )                       \n", Pattern.COMMENTS);
Pattern AnfzeichenRegex = Pattern.compile("\"\"");
   // Aus dem CSV-String in ’Zeile’ alle Felder extrahieren ...
Matcher m = regex.matcher(Zeile);
while (m.find())
{
    String Feld;
    if (m.group(1) != null) {
        Feld = AnfzeichenRegex.matcher(m.group(1)).replaceAll("\"");
    } else {
        Feld = m.group(2);
    }
    // Wert in ’Feld’ kann jetzt weiterverarbeitet werden.
    System.out.println("[" + Feld + "]");
}

Ein anderer Ansatz

Am Anfang dieses Abschnitts wurden zwei Ansätze beschrieben, damit unsere Unterausdrücke mit den Feldern der CSV-Daten im Takt bleiben. Beim zweiten Ansatz wird sichergestellt, dass ein Treffer nur an einer Stelle beginnen darf, an der auch ein Feld beginnt. Oberflächlich betrachtet, ist das das Gleiche, wie wenn wir vor unsere Regex ein ˹^|˼, setzen, nur benutzen wir hier ein Lookbehind-Konstrukt, nämlich ˹(?<=^|,)˼.

Leider unterstützen viele Programme, wenn sie überhaupt ein Lookbehind kennen, nur das Lookbehind mit Strings fester Länge (siehe Lookahead, Lookbehind), dann funktioniert dieser Ansatz nicht. Wenn es nur um die Strings fester Länge geht, könnte man ˹(?<=^|,)˼ durch ˹(?:^|(?<=,))˼ ersetzen, aber das ist schon unnötig kompliziert. Außerdem müssen wir uns dann wieder auf das Getriebe verlassen, das das Komma nach jedem Feld überspringen muss. Wenn an anderer Stelle ein Fehler auftritt, beginnt ein Matching fälschlicherweise bei einer Position wie ›..."10,▵000"...‹. Im Ganzen erscheint der erste Ansatz sicherer.

Wir können diesen Ansatz doch noch retten – wir verlangen, dass ein Feld vor einem Komma (oder vor dem Ende des Strings) enden muss. Wenn wir ˹(?=$|,)˼ an unsere Regex anhängen, sind wir ganz sicher, dass sie nur Felder findet, die an einer solchen Position enden. In der Praxis verzichtet man wohl eher darauf, aber es ist gut zu wissen, dass man das gleiche Ziel mit mehreren Methoden erreichen kann.

Eine effizientere Regex

Erst unter Die Kunst, reguläre Ausdrücke zu schreiben beschäftigen wir uns wirklich mit Effizienzüberlegungen. Dennoch möchte ich hier darauf hinweisen, dass man diese Regex mit atomaren Gruppen wesentlich verbessern kann (siehe Atomare Klammern). Falls unterstützt, kann man den Unterausdruck ˹(?:[^"]|"")*˼, der die Werte in den Feldern mit Anführungszeichen erkennt, durch ˹(?>[^"]+|"")*˼ ersetzen. In der VB.NET-Version im folgenden Kasten wird das so gemacht.

Wenn possessive Quantoren unterstützt werden (siehe Possessive Quantoren), wie im Java-Regex-Package von Sun, kann man mit diesen eine äquivalente Formulierung benutzen (siehe den oberen Kasten mit der Java-Version).

Die Überlegungen dahinter werden unter Die Kunst, reguläre Ausdrücke zu schreiben diskutiert und führen zu einer noch effizienteren Version, die unter Aufbrechen der Schleife bei CSV-Dateien angegeben ist.


CSV-Dateien mit VB.NET verarbeiten

Imports System.Text.RegularExpressions
   Dim FeldRegex as Regex = New Regex( _
       "(?:^|,)                                                    " & _
       "(?:                                                        " & _
       "   (?# Entweder ein Feld in Anführungszeichen ... )        " & _
       "   ""  (?# Öffnendes Anführungszeichen )                   " & _
       "    (   (?> [^""]+ | """" )*   )                           " & _
       "   ""  (?# Schließendes Anführungszeichen )                " & _
       " (?# ... oder ...)                                         " & _
       " |                                                         " & _
       "   (?# ... ein Feld ohne Kommas und Anführungszeichen. )   " & _
       "   ( [^"",]* )                                             " & _
       " )", RegexOptions.IgnorePatternWhitespace)

Dim AnfzRegex as Regex = New Regex("""""") 'Ein String mit zwei Anführungszeichen.
   .
   . 
   .
Dim FeldMatch as Match = FeldRegex.Match(Zeile)
While FeldMatch.Success
   Dim Feld as String
   If FeldMatch.Groups(1).Success
     Feld = AnfzRegex.Replace(FeldMatch.Groups(1).Value, """")
   Else
     Feld = FeldMatch.Groups(2).Value
   End If
   
Console.WriteLine("[" & Feld & "]")
   ' Wert in ’Feld’ kann jetzt weiterverarbeitet werden.

   FeldMatch = FeldMatch.NextMatch
End While

Andere CSV-Formate

Das CSV-Format von Microsoft kommt häufig vor, weil es eben von Microsoft ist, aber bei anderen Programmen gibt es leichte Unterschiede. Mir sind schon etliche Variationen untergekommen:

  • Manche benutzen ein anderes Trennzeichen, zum Beispiel ›;‹ oder das Tabulatorzeichen. (Man kann sich dann fragen, ob man das immer noch CSV, ›Comma Separated Values‹, nennen soll.)
  • Manchmal ist nach dem Trennzeichen Whitespace erlaubt, der nicht zum Wert des Feldes gehört.
  • Oft werden die inneren Anführungszeichen nicht durch Verdopplung dargestellt, sondern durch einen vorangestellten Backslash geschützt (also ›\"‹ statt ›""‹ innerhalb von Anführungszeichen). Meist bedeutet das auch, dass ein Backslash vor allen anderen Zeichen ignoriert wird.

Diese Änderungen sind leicht zu implementieren. Für die erste ersetzt man jedes Komma in der Regex durch das entsprechende Trennzeichen; für die zweite hängt man an das erste Trennzeichen ein ˹\s*˼ an, die Regex beginnt also mit ˹(?:^|,\s*.

Für die dritte Änderung können wir auf Code zurückgreifen, den wir schon früher entwickelt haben (siehe unter Geschützte Anführungszeichen in Strings in Anführungszeichen zulassen), und ˹[^"]+|""˼ durch ˹[^"\\]+|\\˼. ersetzen. Dann müssen wir natürlich auch das folgende s/""/"/g durch das allgemeinere s/\\(.)/$1/g oder durch eine äquivalente Formulierung in der benutzten Programmiersprache ersetzen.

  

<< zurück vor >>

 

 

 

Tipp der data2type-Redaktion:
Zum Thema Reguläre Ausdrücke bieten wir auch folgende Schulungen zur Vertiefung und professionellen Fortbildung an:
   

Copyright der deutschen Ausgabe © 2008 by O’Reilly Verlag GmbH & Co. KG
Für Ihren privaten Gebrauch dürfen Sie die Online-Version ausdrucken.
Ansonsten unterliegt dieses Kapitel aus dem Buch "Reguläre Ausdrücke" denselben Bestimmungen, wie die gebundene Ausgabe: Das Werk einschließlich aller seiner Teile ist urheberrechtlich geschützt. Alle Rechte vorbehalten einschließlich der Vervielfältigung, Übersetzung, Mikroverfilmung sowie Einspeicherung und Verarbeitung in elektronischen Systemen.

O’Reilly Verlag GmbH & Co. KG, Balthasarstr. 81, 50670 Köln