Java 8 Stream APIで二次元配列をcsv形式に変換する

Java 8のStream APIを使って、二次元配列をcsv形式に変換してファイルに保存する方法です。

# 文字列配列のcsv化

まずは文字列を単純にカンマ(",")で区切る例。こんな感じの2次元配列があったとします。

```java
String arrays[][] = {
    { "aaa", "bbb", "ccc", "ddd", "eee" },
    { "abc", "def", "hij", "klm", "opq" },
    { "AAA", "BBB", "CCC", "DDD", "EEE" }
};
```

これを、

```text
aaa,bbb,ccc,ddd,eee
abc,def,hij,klm,opq
AAA,BBB,CCC,DDD,EEE
```

のような形にして出力します。

### Stream APIを使ってListに変換する

まず、一行ずつカンマ区切りの文字列に変換して、Listに格納していきます。StringのListにしておくとファイル出力が楽だからです。一行ずつ個別に処理すればいいので、arraysをArrays.stream()でストリーム化して次のように処理します。

```java
// convert each array[] to csv strings, then store to a List
List<String> list = Arrays.stream(arrays)
　　　　　　　　　　　　　　　　.map(line -> String.join(",",line))
　　　　　　　　　　　　　　　　.collect(Collectors.toList());
```

各行はStringの配列なので、[String.join()](https://docs.oracle.com/javase/jp/8/docs/api/java/lang/String.html#join-java.lang.CharSequence-java.lang.CharSequence...-)メソッドを使えば簡単に結合できます。String.join()はJava 8で追加されたメソッドで、デリミタとString配列を渡すと、配列の中身を指定されたデリミタを使って結合して返してくれます。

終端操作は、collect()を使ってtoList()でリスト化しています。

### Listをファイルに出力する

StringのListオブジェクトをファイルに書き込むには、Java 7から追加されたFiles.write()メソッドを使うと便利です。

```java
// save to a file on current dir
try {
  
  Files.write(Paths.get(System.getProperty("user.dir"),"out.csv"), list, StandardOpenOption.CREATE);
} catch (IOException e) {
    e.printStackTrace();
}
```

[Files.write()](https://docs.oracle.com/javase/jp/8/docs/api/java/nio/file/Files.html#write-java.nio.file.Path-java.lang.Iterable-java.nio.file.OpenOption...-)の第１引数には保存先ファイルのPathオブジェクトを、第２引数には保存したい内容を保持したIteratableオブジェクトを渡します。第２引数の定義はIterable<? extends CharSequence>なので、Iteratableの中身はStringやStringBufferなどのCharSequenceの実装クラスのオブジェクトでなければいけません。

### ファイルからデータを読み込んで配列に格納する

次に、ファイルから読み込んで二次元配列に保持する例です。こちらも行ごとに処理すればいいので、[Files.lines()](https://docs.oracle.com/javase/jp/8/docs/api/java/nio/file/Files.html#lines-java.nio.file.Path-)メソッドを使って読み込むと便利です。Files.lines()は、指定されたファイルから全ての行をストリームとして読み込むメソッドです。戻り値はStream<String>です。

```java
// read from csv file
try (Stream<String> stream = Files.lines(Paths.get(System.getProperty("user.dir"),"out.csv"))) {
    // read each line
    String data[][] = stream.map(line -> line.split(","))
            .map(line -> Arrays.stream(line)
                    .map(String::new)
                    .toArray(String[]::new))
            .toArray(String[][]::new);
} catch (IOException e) {
    e.printStackTrace();
}
```

Files.lines()で読み込んだデータはすでにストリーム化されているので、あとは","でsplitして配列に変換する処理を、各行ごとに実行すればOKです。Streamを二重に使っていますが、内側が各行を処理している部分で、結果はString[]のStreamになります。外側が全体を処理する部分で、map()した段階でString[]のストリームになっているので、toArray()で二次元配列化できます。

なおこの例のほかに、[Files.readAllLines()](https://docs.oracle.com/javase/jp/8/docs/api/java/nio/file/Files.html#readAllLines-java.nio.file.Path-)メソッドを使って一旦全データをListに読み込んでから処理するのもありだと思います。

# プリミティブ型の二次元配列のcsv化

続いて、プリミティブ型の二次元配列について考えます。次のような配列があったとします。

```java
int arrays[][] = {
    { 11, 12, 13, 14, 15 },
    { 21, 22, 23, 24, 25 },
    { 31, 32, 33, 34, 35 }
};
```

これを

```text
11,12,13,14,15
21,22,23,24,25
31,32,33,34,35
```

のような形でファイルに保存します。

### Stream APIを使ってListに変換する

これも、Stream APIを使ってStringのListに変換するのですが、カンマ区切りの文字列を作るのにString.join()メソッドが使えないため、この部分の処理を自分で書く必要があります。したがって、map()の中が次のような感じになります。

```java
// convert each array[] to csv strings, then store to a List
List<String> list = Arrays.stream(arrays)
        .map(line -> Arrays.stream(line)
                .mapToObj(String::valueOf)
                .collect(Collectors.joining(",")))
        .collect(Collectors.toList());
```

処理する対象はString[]なので、これをStreamにしてから、それぞれの要素をmapToObj()でStringオブジェクトに変換します。そして終端操作でCollectors.joining()を使い、全要素を","で結合しています。

### Listをファイルに出力する

ファイルへの出力は、String配列の場合と同様です。

### ファイルからデータを読み込んで配列に格納する

Files.lines()メソッドでStreamとして読み込むところまでは文字列の場合と同様ですが、今回はint配列として格納したいので、読み込んだ各要素をストリーム処理でmapToInt()を使ってint型に変換した上で配列化します。

```java
try (Stream<String> stream = Files.lines(Paths.get(System.getProperty("user.dir"),"out2.csv"))) {
    // read each line
    int data[][] = stream.map(line -> line.split(","))
            .map(line -> Arrays.stream(line)
                    .mapToInt(Integer::parseInt)
                    .toArray())
            .toArray(int[][]::new);
    } catch (IOException e) {
        e.printStackTrace();
}
```

# エスケープ文字を含んだcsvを扱う

最初の例は、文字列を単に","で区切っただけの単純なcsv形式でしたが、このままだと文字列中にダブルクォートやカンマが含まれている場合に正しく処理できません。そこで、各フィールドをダブルクォートで囲う形式のcsvにも対応できるプログラムを考えて見ます。

csvを扱うためのライブラリはいろいろありますが、ここでは自力でトライしてみます。とはいえ真っ当にパースするのは大変なので、正規表現を使って各フィールドの文字列を取り出します。そこでcsvの仕様を単純化して、次のようなルールを想定して処理することにします。

- ダブルクォートのないフィールドは空白なども含めて文字列として扱う
- ダブルクォートに囲まれた部分のカンマは文字として扱う
- ダブルクォートに囲まれた内部でのダブルクォートは許容しない。ただし、'\'でエスケープされている場合を除く
- ダブルクォートの外側に文字に文字があるようなケースは許容しない

例として、次のような２次元配列を考えます。

```java
String[][] arrays = {
        { "Dog" , "Cat"   , ""         , "Turtle", ""     , ""    },
        { "hoge", "pi yo" , " fuga "   , "  foo" , "bar  ", "bow" },
        { "hoge", " pi yo", " fuga "   , "foo "  , "bar " , ""    },
        { "hoge", "pi yo" , "fu\" ga", "foo"   , "bar " , "bow" },
        { "    ", "pi yo" , "fu,ga ", "foo"   , " bar ", ""    }
};
```

これを

```text
"Dog","Cat","","Turtle","",""
"hoge","pi yo"," fuga ","  foo","bar  ","bow"
"hoge"," pi yo"," fuga ","foo ","bar ",""
"hoge","pi yo","fu\" ga","foo","bar ","bow"
"    ","pi yo","fu,ga ","foo"," bar ",""
```

ような形でファイルに出力します。

### Stream APIを使ってListに変換する

例によってStreamを使ってカンマ区切りの文字列に変換してListに格納します。ただし、今回はカンマで結合する前に各文字列の両端にダブルクォートを付加します。また、文字列中のダブルクォートは"\"でエスケープして出力されるようにします。

```java
// convert each array[] to csv strings, then store to a List
List<String> list = Arrays.stream(arrays)
        .map(line -> Arrays.stream(line)
                .map(str -> str.replaceAll("\\\"", "\\\\\""))
                .map(str -> "\"" + str + "\"")
                .collect(Collectors.joining(",")))
        .collect(Collectors.toList());
```

### Listをファイルに出力する

ファイルへの出力は、これまでのケースと同様です。

### 各フィールド文字列にマッチする正規表現を考える

データを読み込む際には、先述したcsvのルールを考慮する必要があります。今回は、各フィールドがダブルクォートで囲まれていないケースと、囲まれているケースをそれぞれ考える必要があります。

ダブルクォートで囲まれていない場合は、カンマからカンマ（または行頭からカンマ、カンマから行末）までの全体をフィールドの文字列として扱います。

ダブルクォートで囲まれている場合は、囲まれた部分をフィールドの文字列として扱います。その中にカンマがある場合はカンマも文字列の一部とします。ダブルクォートはエスケープされている場合のみ文字として扱います。

以上の条件を踏まえて、各フィールドの文字列にマッチする正規表現を考えます。まず、エスケープ文字を考慮しないケースを考えてみます。各フィールドは、カンマからカンマ、または行頭からカンマ、カンマから行末なので、これは先読みと後読みを使うことで検出できます。

```text
(?<=^|,)hogehoge(?=$|,)
```

hogehogeの部分は、ダブルクォートに囲まれていないケースと、囲まれているケースに分けて考えることができます。囲まれていないケースは「[^",]*」、囲まれているケースは「"[^"]*"」のような正規表現で表すことができます。前者は","を許容しないのに対して、後者は許容しているのがポイントです。これを合わせて書くと次のようになります。

```text
(?:[^",]*|"[^"]*")
```

ここに先ほどの先読みと後読みを足すと次のようになります。

```text
(?<=^|,)(?:[^",]*|"[^"]*")(?=$|,)
```

さて、"\"によるエスケープを考慮する場合、エスケープ文字の次にはどんな文字が来てもいいので、さきほどの「[^",]*」の部分は「(?:\\.|[^\\",])*」のようになります。同様に「"[^"]*"」の方は「"(?:\\.|[^\\"])*"」のようになります。以上を考慮すると、最終的な正規表現は次のように書くことができます。

```text
(?<=^|,)(?:(?:\\.|[^\\",])*|"(?:\\.|[^\\"])*")(?=$|,)
```

### ファイルからデータを読み込んで配列に格納する

この正規表現を使えば、ファイルからデータを読み込む際に、各フィールドの値を個別に取り出すことができます。

```java
// Regex expression that matches with csv fields.
String regex = "(?<=^|,)(?:(?:\\\\.|[^\\\\\",])*|\"(?:\\\\.|[^\\\\\"])*\")(?=$|,)";
Pattern pattern = Pattern.compile(regex);

// open a file
try (Stream<String> stream = Files.lines(Paths.get(System.getProperty("user.dir"),"out3.csv"))) {
    // read each line
    String data[][] = stream
            .map(line -> {
                    Matcher matcher = pattern.matcher(line);
                    List<String> aList = new ArrayList<>();
                    while (matcher.find()) {
                    
  
  aList.add(matcher.group().replaceFirst("^\"","").replaceFirst("\"$",""));
                    }
                            return aList;
                })
            .map(line -> line.stream().toArray(String[]::new))
            .toArray(String[][]::new);
} catch (IOException e) {
    e.printStackTrace();
}
```

まず正規表現のPatternオブジェクトを作ってコンパイルします。ファイルからStreamとしてデータを取得して行ごとに処理するのはこれまでと同様です。各行を処理する部分で、Matcherを使ってマッチした文字列を取り出していきます。なおこの例では、マッチしない（ルールに従っていない）フィールドは無視されます。

マッチした文字列は一旦Listに格納しますが、その際に前後のダブルクォートを除去しておきます。最後にtoArray()で配列化するのもこれまでと同様です。

正規表現の部分の処理を工夫すれば、もっと複雑な条件の読み込みにも対応できると思います。