Java: dzielenie łańcucha rozdzielanego przecinkami, ale ignorowanie przecinków w cudzysłowach

249

Mam ciąg niejasno taki:

foo,bar,c;qual="baz,blurb",d;junk="quux,syzygy"

że chcę podzielić przecinki - ale muszę zignorować przecinki w cudzysłowie. W jaki sposób mogę to zrobić? Wydaje się, że podejście wyrażenia regularnego kończy się niepowodzeniem; Przypuszczam, że mogę ręcznie zeskanować i przejść do innego trybu, gdy zobaczę cytat, ale dobrze byłoby użyć wcześniej istniejących bibliotek. ( edycja : Myślę, że miałem na myśli biblioteki, które są już częścią JDK lub już są częścią powszechnie używanych bibliotek, takich jak Apache Commons.)

powyższy ciąg powinien zostać podzielony na:

foo
bar
c;qual="baz,blurb"
d;junk="quux,syzygy"

Uwaga: to NIE jest plik CSV, to pojedynczy ciąg znaków zawarty w pliku o większej ogólnej strukturze

Jason S.
źródło

Odpowiedzi:

435

Próbować:

public class Main { 
    public static void main(String[] args) {
        String line = "foo,bar,c;qual=\"baz,blurb\",d;junk=\"quux,syzygy\"";
        String[] tokens = line.split(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)", -1);
        for(String t : tokens) {
            System.out.println("> "+t);
        }
    }
}

Wynik:

> foo
> bar
> c;qual="baz,blurb"
> d;junk="quux,syzygy"

Innymi słowy: podziel przecinek tylko wtedy, gdy przecinek ten ma zero lub parzystą liczbę cudzysłowów przed nim .

Lub nieco bardziej przyjazny dla oczu:

public class Main { 
    public static void main(String[] args) {
        String line = "foo,bar,c;qual=\"baz,blurb\",d;junk=\"quux,syzygy\"";

        String otherThanQuote = " [^\"] ";
        String quotedString = String.format(" \" %s* \" ", otherThanQuote);
        String regex = String.format("(?x) "+ // enable comments, ignore white spaces
                ",                         "+ // match a comma
                "(?=                       "+ // start positive look ahead
                "  (?:                     "+ //   start non-capturing group 1
                "    %s*                   "+ //     match 'otherThanQuote' zero or more times
                "    %s                    "+ //     match 'quotedString'
                "  )*                      "+ //   end group 1 and repeat it zero or more times
                "  %s*                     "+ //   match 'otherThanQuote'
                "  $                       "+ // match the end of the string
                ")                         ", // stop positive look ahead
                otherThanQuote, quotedString, otherThanQuote);

        String[] tokens = line.split(regex, -1);
        for(String t : tokens) {
            System.out.println("> "+t);
        }
    }
}

co daje taki sam jak pierwszy przykład.

EDYTOWAĆ

Jak wspomniano w @MikeFHay w komentarzach:

Wolę używać rozgałęziacza Guavy , ponieważ ma on domyślne ustawienia (patrz dyskusja powyżej na temat przycinania pustych dopasowań String#split(), więc zrobiłem:

Splitter.on(Pattern.compile(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)"))
Bart Kiers
źródło
Zgodnie z RFC 4180: Sec 2.6: „Pola zawierające podział wiersza (CRLF), podwójne cudzysłowy i przecinki powinny być ujęte w cudzysłowy”. Sekcja 2.7: „Jeśli do zamykania pól stosuje się cudzysłowy, to należy wstawić znak podwójnego cudzysłowu występujący w polu, poprzedzając go innym znakiem podwójnego cudzysłowu”. Jeśli więc String line = "equals: =,\"quote: \"\"\",\"comma: ,\""wszystko, co musisz zrobić, to usunąć obce cudzysłowy podwójne postacie.
Paul Hanbury,
@ Bart: Chodzi mi o to, że twoje rozwiązanie nadal działa, nawet z osadzonymi cytatami
Paul Hanbury,
6
@Alex, tak, przecinek jest dopasowany, ale puste dopasowanie nie jest wynikiem. Dodaj -1do podzielonego metody param: line.split(regex, -1). Zobacz: docs.oracle.com/javase/6/docs/api/java/lang/…
Bart Kiers
2
Działa świetnie! Wolę używać Rozgałęźnika Guava, ponieważ ma on domyślne ustawienia (patrz dyskusja powyżej na temat przycinania pustych dopasowań przez String # split), więc zrobiłem to Splitter.on(Pattern.compile(",(?=([^\"]*\"[^\"]*\")*[^\"]*$)")).
MikeFHay
2
OSTRZEŻENIE!!!! To wyrażenie regularne jest wolne !!! Ma zachowanie O (N ^ 2) w tym, że przeglądarka przy każdym przecinku wygląda aż do końca łańcucha. Użycie tego wyrażenia regularnego spowodowało 4x spowolnienie w dużych zadaniach Spark (np. 45 minut -> 3 godziny). Szybszą alternatywą jest coś findAllIn("(?s)(?:\".*?\"|[^\",]*)*")w połączeniu z krokiem przetwarzania końcowego, aby pominąć pierwsze (zawsze puste) pole następujące po każdym niepustym polu.
Urban Vagabond
46

Chociaż ogólnie lubię wyrażenia regularne, dla tego rodzaju tokenizacji zależnej od stanu uważam, że prosty parser (który w tym przypadku jest znacznie prostszy niż to słowo mogłoby zabrzmieć) jest prawdopodobnie czystszym rozwiązaniem, w szczególności w odniesieniu do łatwości konserwacji , np .:

String input = "foo,bar,c;qual=\"baz,blurb\",d;junk=\"quux,syzygy\"";
List<String> result = new ArrayList<String>();
int start = 0;
boolean inQuotes = false;
for (int current = 0; current < input.length(); current++) {
    if (input.charAt(current) == '\"') inQuotes = !inQuotes; // toggle state
    boolean atLastChar = (current == input.length() - 1);
    if(atLastChar) result.add(input.substring(start));
    else if (input.charAt(current) == ',' && !inQuotes) {
        result.add(input.substring(start, current));
        start = current + 1;
    }
}

Jeśli nie zależy ci na zachowaniu przecinków w cudzysłowie, możesz uprościć to podejście (bez obsługi indeksu początkowego, żadnego specjalnego przypadku ostatniego znaku ), zastępując przecinki w cudzysłowach czymś innym, a następnie rozdzielając je przecinkami:

String input = "foo,bar,c;qual=\"baz,blurb\",d;junk=\"quux,syzygy\"";
StringBuilder builder = new StringBuilder(input);
boolean inQuotes = false;
for (int currentIndex = 0; currentIndex < builder.length(); currentIndex++) {
    char currentChar = builder.charAt(currentIndex);
    if (currentChar == '\"') inQuotes = !inQuotes; // toggle state
    if (currentChar == ',' && inQuotes) {
        builder.setCharAt(currentIndex, ';'); // or '♡', and replace later
    }
}
List<String> result = Arrays.asList(builder.toString().split(","));
Fabian Steeg
źródło
Cytaty należy usunąć z przeanalizowanych tokenów po przeanalizowaniu łańcucha.
Sudhir N
Znalezione przez google, miły algorytm bracie, prosty i łatwy do dostosowania, zgadzam się. stanowe rzeczy powinny być wykonywane przez parser, regex to bałagan.
Rudolf Schmidt
2
Pamiętaj, że jeśli przecinek jest ostatnim znakiem, będzie miał wartość ciągu ostatniego elementu.
Gabriel Gates
21

http://sourceforge.net/projects/javacsv/

https://github.com/pupi1985/JavaCSV-Reloaded (rozwidlenie poprzedniej biblioteki, która pozwoli generowanym wyjściom na zakończenie linii Windows, \r\ngdy nie jest uruchomiony Windows)

http://opencsv.sourceforge.net/

CSV API dla Java

Czy możesz polecić bibliotekę Java do odczytu (i ewentualnie zapisu) plików CSV?

Lib Java lub aplikacja do konwersji CSV do pliku XML?

Jonathan Feinberg
źródło
3
Dobra rozmowa z rozpoznaniem, że OP analizował plik CSV. Biblioteka zewnętrzna jest wyjątkowo odpowiednia do tego zadania.
Stefan Kendall
1
Ale ciąg jest ciągiem CSV; powinieneś być w stanie użyć interfejsu API CSV na tym ciągu bezpośrednio.
Michael Brewer-Davis,
tak, ale to zadanie jest wystarczająco proste i znacznie mniejsza część większej aplikacji, że nie mam ochoty pobierać innej biblioteki zewnętrznej.
Jason S,
7
niekoniecznie ... moje umiejętności są często odpowiednie, ale czerpią korzyści z bycia wyostrzonym.
Jason S,
9

Nie zalecałbym odpowiedzi Bartka, w tym konkretnym przypadku znajduję lepsze rozwiązanie do analizowania (jak zaproponował Fabian). Próbowałem rozwiązania regex i własnej implementacji parsowania. Odkryłem, że:

  1. Analiza jest znacznie szybsza niż dzielenie za pomocą wyrażeń regularnych z referencjami wstecznymi - ~ 20 razy szybciej dla krótkich łańcuchów, ~ 40 razy szybciej dla długich łańcuchów.
  2. Regex nie może znaleźć pustego ciągu po ostatnim przecinku. To nie było pierwotne pytanie, to był mój wymóg.

Moje rozwiązanie i test poniżej.

String tested = "foo,bar,c;qual=\"baz,blurb\",d;junk=\"quux,syzygy\",";
long start = System.nanoTime();
String[] tokens = tested.split(",(?=([^\"]*\"[^\"]*\")*[^\"]*$)");
long timeWithSplitting = System.nanoTime() - start;

start = System.nanoTime(); 
List<String> tokensList = new ArrayList<String>();
boolean inQuotes = false;
StringBuilder b = new StringBuilder();
for (char c : tested.toCharArray()) {
    switch (c) {
    case ',':
        if (inQuotes) {
            b.append(c);
        } else {
            tokensList.add(b.toString());
            b = new StringBuilder();
        }
        break;
    case '\"':
        inQuotes = !inQuotes;
    default:
        b.append(c);
    break;
    }
}
tokensList.add(b.toString());
long timeWithParsing = System.nanoTime() - start;

System.out.println(Arrays.toString(tokens));
System.out.println(tokensList.toString());
System.out.printf("Time with splitting:\t%10d\n",timeWithSplitting);
System.out.printf("Time with parsing:\t%10d\n",timeWithParsing);

Oczywiście możesz swobodnie zmienić w tym fragmencie przejście na „else-ifs”, jeśli czujesz się niekomfortowo z powodu jego brzydoty. Zwróć uwagę na brak przerwy po przełączniku z separatorem. Zamiast tego StringBuilder został wybrany na StringBuffer, aby zwiększyć prędkość, w której bezpieczeństwo wątków jest nieistotne.

Marcin Kosiński
źródło
2
Interesujący punkt dotyczący podziału czasu vs parsowania. Jednak stwierdzenie nr 2 jest niedokładne. Jeśli dodasz -1do metody podziału w odpowiedzi Barta, złapiesz puste ciągi (w tym puste ciągi po ostatnim przecinku):line.split(regex, -1)
Peter
+1, ponieważ jest to lepsze rozwiązanie problemu, dla którego szukałem rozwiązania: analizowanie złożonego ciągu parametrów ciała HTTP POST
varontron 30.04.17
2

Spróbuj spojrzeć jak (?!\"),(?!\"). To powinno pasować ,, które nie są otoczone ".

Matthew Sowders
źródło
Całkiem pewne, że złamałoby się to dla listy takiej jak: „foo”, bar, „baz”
Angelo Genovese
1
Myślę, że miałeś na myśli (?<!"),(?!"), ale to nadal nie działa. Biorąc pod uwagę ciąg one,two,"three,four", poprawnie pasuje do przecinka one,two, ale również pasuje do przecinka "three,four"i nie pasuje do jednego two,"three.
Alan Moore
Wygląda na to, że działa idealnie dla mnie, IMHO Myślę, że jest to lepsza odpowiedź, ponieważ jest krótsza i łatwiejsza do zrozumienia
Ordiel
2

Znajdujesz się w irytującym obszarze granicznym, w którym wyrażenia regularne prawie nie wystarczą (jak zauważył Bart, unikanie cytatów utrudniłoby życie), a jednak pełny parser wydaje się przesadą.

Jeśli w najbliższym czasie będziesz potrzebować większej złożoności, poszukałbym biblioteki parserów. Na przykład ten

djna
źródło
2

Byłem niecierpliwy i postanowiłem nie czekać na odpowiedzi ... w celach informacyjnych nie wydaje się to tak trudne, aby zrobić coś takiego (co działa w mojej aplikacji, nie muszę się martwić o ucieczkę cytatów, ponieważ to w cytatach jest ograniczony do kilku form ograniczonych):

final static private Pattern splitSearchPattern = Pattern.compile("[\",]"); 
private List<String> splitByCommasNotInQuotes(String s) {
    if (s == null)
        return Collections.emptyList();

    List<String> list = new ArrayList<String>();
    Matcher m = splitSearchPattern.matcher(s);
    int pos = 0;
    boolean quoteMode = false;
    while (m.find())
    {
        String sep = m.group();
        if ("\"".equals(sep))
        {
            quoteMode = !quoteMode;
        }
        else if (!quoteMode && ",".equals(sep))
        {
            int toPos = m.start(); 
            list.add(s.substring(pos, toPos));
            pos = m.end();
        }
    }
    if (pos < s.length())
        list.add(s.substring(pos));
    return list;
}

(ćwiczenie dla czytelnika: rozciągnij się na obsługę cytowanych cytatów poprzez wyszukiwanie odwrotnych ukośników).

Jason S.
źródło
1

Najprostszym podejściem nie jest dopasowanie ograniczników, tj. Przecinków, ze złożoną dodatkową logiką, aby dopasować to, co jest rzeczywiście zamierzone (dane, które mogą być ciągami cytowanymi), aby wykluczyć fałszywe ograniczniki, ale raczej dopasować zamierzone dane w pierwszej kolejności.

Wzór składa się z dwóch alternatyw, cytowanego ciągu ( "[^"]*"lub ".*?") lub wszystkiego do następnego przecinka ( [^,]+). Aby wesprzeć puste komórki, musimy pozwolić, aby niecytowany element był pusty i zużyć następny przecinek, jeśli taki istnieje, i użyć \\Gkotwicy:

Pattern p = Pattern.compile("\\G\"(.*?)\",?|([^,]*),?");

Wzorzec zawiera również dwie grupy przechwytywania, aby uzyskać albo treść cytowanego ciągu, albo zwykłą treść.

Następnie, w Javie 9, możemy uzyskać tablicę jako

String[] a = p.matcher(input).results()
    .map(m -> m.group(m.start(1)<0? 2: 1))
    .toArray(String[]::new);

podczas gdy starsze wersje Java wymagają pętli

for(Matcher m = p.matcher(input); m.find(); ) {
    String token = m.group(m.start(1)<0? 2: 1);
    System.out.println("found: "+token);
}

Dodanie elementów do Listtablicy lub tablicy pozostawia czytelnikowi akcyzę.

W przypadku języka Java 8 można użyć results()implementacji tej odpowiedzi , aby zrobić to jak rozwiązanie Java 9.

W przypadku zawartości mieszanej z osadzonymi ciągami, jak w pytaniu, możesz po prostu użyć

Pattern p = Pattern.compile("\\G((\"(.*?)\"|[^,])*),?");

Ale potem ciągi są przechowywane w ich cytowanej formie.

Holger
źródło
0

Zamiast używać lookahead i innych zwariowanych wyrażeń regularnych, najpierw wyciągnij cytaty. Oznacza to, że dla każdej grupy cytatów zamień tę grupę na __IDENTIFIER_1inny lub inny wskaźnik i zamapuj tę grupę na mapę ciągu znaków.

Po podzieleniu przecinkiem zamień wszystkie zamapowane identyfikatory na oryginalne wartości ciągu.

Stefan Kendall
źródło
i jak znaleźć grupy cytatów bez zwariowanych wyrażeń regularnych?
Kai Huppmann,
Dla każdego znaku, jeśli znak jest cytatem, znajdź następny cytat i zastąp go grupowaniem. Jeśli nie ma następnej wyceny, gotowe.
Stefan Kendall
0

co z linią jednowierszową za pomocą String.split ()?

String s = "foo,bar,c;qual=\"baz,blurb\",d;junk=\"quux,syzygy\"";
String[] split = s.split( "(?<!\".{0,255}[^\"]),|,(?![^\"].*\")" );
Kaplan
źródło
-1

Zrobiłbym coś takiego:

boolean foundQuote = false;

if(charAtIndex(currentStringIndex) == '"')
{
   foundQuote = true;
}

if(foundQuote == true)
{
   //do nothing
}

else 

{
  string[] split = currentString.split(',');  
}
Woot4Moo
źródło