Java: memisahkan string yang dipisah koma tetapi mengabaikan koma dalam tanda kutip


249

Saya memiliki string yang samar-samar seperti ini:

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

yang ingin saya bagi dengan koma - tetapi saya harus mengabaikan koma dalam tanda kutip. Bagaimana saya bisa melakukan ini? Sepertinya pendekatan regexp gagal; Saya kira saya bisa secara manual memindai dan masuk ke mode yang berbeda ketika saya melihat kutipan, tapi alangkah baiknya menggunakan perpustakaan yang sudah ada sebelumnya. ( sunting : Saya kira saya maksud perpustakaan yang sudah menjadi bagian dari JDK atau sudah bagian dari perpustakaan yang umum digunakan seperti Apache Commons.)

string di atas harus dipecah menjadi:

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

catatan: ini BUKAN file CSV, ini adalah string tunggal yang terkandung dalam file dengan struktur keseluruhan yang lebih besar

Jawaban:


435

Mencoba:

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);
        }
    }
}

Keluaran:

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

Dengan kata lain: pisahkan pada koma hanya jika koma itu memiliki nol, atau bahkan jumlah kutipan di depannya .

Atau, sedikit lebih ramah untuk mata:

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);
        }
    }
}

yang menghasilkan sama dengan contoh pertama.

EDIT

Seperti yang disebutkan oleh @MikeFHay dalam komentar:

Saya lebih suka menggunakan Splitter Guava , karena memiliki default lebih waras (lihat diskusi di atas tentang pertandingan kosong yang dipangkas String#split(), jadi saya lakukan:

Splitter.on(Pattern.compile(",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)"))

Menurut RFC 4180: Sec 2.6: "Fields yang berisi line break (CRLF), tanda kutip ganda, dan koma harus dilampirkan dalam tanda kutip ganda." Bab 2.7: "Jika tanda kutip ganda digunakan untuk melampirkan bidang, maka tanda kutip ganda yang muncul di dalam bidang harus diloloskan dengan mendahului dengan tanda kutip ganda" Jadi, jika String line = "equals: =,\"quote: \"\"\",\"comma: ,\"", semua yang perlu Anda lakukan adalah menghapus tanda kutip ganda yang asing karakter.
Paul Hanbury

@ Bart: maksud saya adalah bahwa solusi Anda masih berfungsi, bahkan dengan kutipan yang disematkan
Paul Hanbury

6
@ Alex, yeah, koma yang cocok, tapi pertandingan kosong tidak dalam hasil. Tambahkan -1ke perpecahan metode param: line.split(regex, -1). Lihat: docs.oracle.com/javase/6/docs/api/java/lang/…
Bart Kiers

2
Bagus sekali! Saya lebih suka menggunakan Splitter Guava, karena memiliki default lebih waras (lihat diskusi di atas tentang pertandingan kosong yang dipangkas oleh String # split), jadi saya melakukannya Splitter.on(Pattern.compile(",(?=([^\"]*\"[^\"]*\")*[^\"]*$)")).
MikeFHay

2
PERINGATAN!!!! Regexp ini lambat !!! Ia memiliki O (N ^ 2) perilaku di mana lookahead di setiap koma terlihat sampai akhir string. Menggunakan regexp ini menyebabkan 4x perlambatan dalam pekerjaan Spark besar (misalnya 45 menit -> 3 jam). Alternatif yang lebih cepat adalah sesuatu seperti findAllIn("(?s)(?:\".*?\"|[^\",]*)*")dalam kombinasi dengan langkah postprocessing untuk melewati bidang pertama (selalu kosong) mengikuti setiap bidang yang tidak kosong.
Urban Vagabond

46

Walaupun saya menyukai ekspresi reguler pada umumnya, untuk jenis tokenisasi yang bergantung pada keadaan ini, saya percaya parser sederhana (yang dalam hal ini jauh lebih sederhana daripada kata yang membuatnya terdengar) mungkin merupakan solusi yang lebih bersih, khususnya yang berkaitan dengan pemeliharaan , misalnya:

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;
    }
}

Jika Anda tidak peduli tentang menjaga koma di dalam tanda kutip, Anda dapat menyederhanakan pendekatan ini (tidak ada penanganan indeks awal, tidak ada huruf khusus karakter terakhir ) dengan mengganti koma Anda dalam tanda kutip dengan sesuatu yang lain dan kemudian membaginya dengan tanda koma:

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(","));

Kutipan harus dihapus dari token yang diuraikan, setelah string diuraikan.
Sudhir N

Ditemukan melalui google, bro algoritma yang bagus, sederhana dan mudah diadaptasi, setuju. hal-hal stateful harus dilakukan melalui parser, regex berantakan.
Rudolf Schmidt

2
Ingatlah bahwa jika koma adalah karakter terakhir, itu akan menjadi nilai String item terakhir.
Gabriel Gates

21

3
Panggilan bagus untuk mengetahui bahwa OP sedang mengurai file CSV. Perpustakaan eksternal sangat sesuai untuk tugas ini.
Stefan Kendall

1
Tetapi string adalah string CSV; Anda harus dapat menggunakan api CSV pada string itu secara langsung.
Michael Brewer-Davis

ya, tetapi tugas ini cukup sederhana, dan bagian yang jauh lebih kecil dari aplikasi yang lebih besar, yang saya rasa tidak ingin menarik perpustakaan eksternal lain.
Jason S

7
belum tentu ... keterampilan saya sering memadai, tetapi mereka mendapat manfaat dari diasah.
Jason S

9

Saya tidak akan menyarankan jawaban regex dari Bart, saya menemukan solusi parsing lebih baik dalam kasus khusus ini (seperti yang diusulkan Fabian). Saya telah mencoba solusi regex dan implementasi parsing sendiri. Saya telah menemukan bahwa:

  1. Parsing jauh lebih cepat daripada pemisahan dengan regex dengan backreferences - ~ 20 kali lebih cepat untuk string pendek, ~ 40 kali lebih cepat untuk string panjang.
  2. Regex gagal menemukan string kosong setelah koma terakhir. Namun itu bukan pertanyaan awal, itu adalah persyaratan saya.

Solusi dan tes saya di bawah ini.

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);

Tentu saja Anda bebas untuk beralih ke lain-jika dalam potongan ini jika Anda merasa tidak nyaman dengan kejelekannya. Perhatikan kemudian kurangnya istirahat setelah beralih dengan pemisah. StringBuilder dipilih sebagai ganti untuk StringBuffer dengan desain untuk meningkatkan kecepatan, di mana keamanan benang tidak relevan.


2
Poin menarik mengenai pemisahan waktu vs penguraian. Namun, pernyataan # 2 tidak akurat. Jika Anda menambahkan -1metode split pada jawaban Bart, Anda akan menangkap string kosong (termasuk string kosong setelah koma terakhir):line.split(regex, -1)
Peter

+1 karena ini adalah solusi yang lebih baik untuk masalah yang saya cari solusinya: parsing string parameter tubuh POST HTTP kompleks
varontron

2

Coba lookaround seperti (?!\"),(?!\"). Ini harus cocok dengan ,yang tidak dikelilingi oleh ".


Cukup yakin itu akan pecah untuk daftar seperti: "foo", bar, "baz"
Angelo Genovese

1
Saya pikir Anda maksud (?<!"),(?!"), tetapi itu masih tidak akan berhasil. Diberikan string one,two,"three,four", itu dengan benar cocok dengan koma one,two, tetapi juga cocok dengan koma "three,four", dan gagal mencocokkan satu dengan two,"three.
Alan Moore

Kelihatannya bekerja dengan sempurna bagi saya, IMHO saya pikir ini adalah jawaban yang lebih baik karena lebih pendek dan lebih mudah dipahami
Ordiel

2

Anda berada di area perbatasan yang menjengkelkan di mana regexps hampir tidak akan melakukan (seperti yang telah ditunjukkan oleh Bart, lolos dari kutipan akan membuat hidup jadi sulit), namun parser besar tampaknya seperti terlalu banyak pembunuhan.

Jika Anda cenderung membutuhkan kompleksitas yang lebih besar dalam waktu dekat, saya akan mencari parser library. Misalnya yang ini


2

Saya tidak sabar dan memilih untuk tidak menunggu jawaban ... untuk referensi itu tidak terlihat sulit untuk melakukan hal seperti ini (yang berfungsi untuk aplikasi saya, saya tidak perlu khawatir tentang pelolosan kutipan, seperti hal-hal dalam tanda kutip terbatas pada beberapa bentuk terbatas):

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;
}

(latihan untuk pembaca: memperluas penanganan kutipan lolos dengan mencari backslash juga.)


1

Pendekatan yang paling sederhana adalah tidak mencocokkan pembatas, yaitu koma, dengan logika tambahan yang kompleks untuk mencocokkan apa yang sebenarnya dimaksudkan (data yang mungkin dikutip string), hanya untuk mengecualikan pembatas palsu, melainkan mencocokkan data yang dimaksud di tempat pertama.

Pola terdiri dari dua alternatif, string yang dikutip ( "[^"]*"atau ".*?") atau semuanya hingga koma berikutnya ( [^,]+). Untuk mendukung sel kosong, kami harus mengizinkan item yang tidak dikutip menjadi kosong dan menggunakan koma berikutnya, jika ada, dan menggunakan \\Gjangkar:

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

Pola ini juga berisi dua grup penangkap untuk mendapatkan, konten string yang dikutip atau konten biasa.

Kemudian, dengan Java 9, kita bisa mendapatkan array sebagai

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

sedangkan versi Java yang lebih lama membutuhkan loop seperti

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

Menambahkan item ke Listatau array dibiarkan sebagai cukai untuk pembaca.

Untuk Java 8, Anda dapat menggunakan results()implementasi dari jawaban ini , untuk melakukannya seperti solusi Java 9.

Untuk konten campuran dengan string yang disematkan, seperti dalam pertanyaan, Anda dapat menggunakannya

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

Tapi kemudian, string disimpan dalam bentuk yang dikutip.


0

Daripada menggunakan lookahead dan regex gila lainnya, cukup tarik keluar tanda kutip terlebih dahulu. Yaitu, untuk setiap pengelompokan kutipan, ganti pengelompokan itu dengan __IDENTIFIER_1atau beberapa indikator lainnya, dan petakan peta itu ke peta string, string.

Setelah Anda pisah pada koma, ganti semua pengidentifikasi yang dipetakan dengan nilai string asli.


dan bagaimana menemukan pengelompokan kutipan tanpa regex gila?
Kai Huppmann

Untuk setiap karakter, jika karakter adalah kutipan, temukan kutipan berikutnya dan ganti dengan pengelompokan. Jika tidak ada kutipan selanjutnya, selesai.
Stefan Kendall

0

bagaimana dengan one-liner menggunakan String.split ()?

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

-1

Saya akan melakukan sesuatu seperti ini:

boolean foundQuote = false;

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

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

else 

{
  string[] split = currentString.split(',');  
}
Dengan menggunakan situs kami, Anda mengakui telah membaca dan memahami Kebijakan Cookie dan Kebijakan Privasi kami.
Licensed under cc by-sa 3.0 with attribution required.