歴史的経緯をスルーして使いこなす Java ファイル入出力処理

# はじめに

歴史的に Java のファイル入出力処理は煩雑だとして悪名が高かった。スクリプト言語陣営がその「生産性」の高さをアピールする際によく攻撃対象となっていたのが Java のファイル入出力処理である。いわく、スクリプト言語なら数行で実現できる処理が Java では数十行になるとか何とか。

時は流れ、2011 年の Java 7 で導入された `java.nio.file` パッケージ (NIO2 File API) や try-with-resources 構文によって、状況は大幅に改善した。今や Java でもスクリプト言語とさほど変わらないコード量でファイル入出力処理が実現できるのである。

だが、「Java ファイル 入出力」と検索して上位にヒットするような記事では、上記の改善が導入される前の API だけを用いた~~クソコード~~不適切なコードばかりが目立つ。そこで本記事では、現時点での初心者が知るべき最もシンプルな Java ファイル入出力処理を、過去の歴史的経緯にとらわれず、体系的かつ簡潔にまとめてみたいと思う。

# 前提知識

## パスの表現

ファイルシステムのパスの表現には `java.nio.file.Path` インタフェース (参考: [Javadoc](https://docs.oracle.com/javase/jp/8/docs/api/java/nio/file/Path.html)) を使用する。Path インスタンスを生成する方法は各種あるが、実際は `java.nio.file.Paths` ユーティリティクラス (参考: [Javadoc](https://docs.oracle.com/javase/jp/8/docs/api/java/nio/file/Paths.html)) を使用して生成することがほとんどである。

```java
Path path = Paths.get("items.csv");
```

パス文字列の指定は相対パスでも絶対パスでも可能だ。

```java
Path path = Paths.get("/etc/passwd");
```

パス区切り文字列のポータビリティに関するお作法を考えると、いちおうこちらの可変長引数を使う方式のほうがお行儀が良い。

```java
Path path = Paths.get("/etc", "passwd");
```

Java 11 からは `Paths.get(String)` メソッドに加えて `Path.of(String)` メソッドが導入された (参考: [Javadoc](https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/nio/file/Path.html#of(java.lang.String,java.lang.String...)))。機能は変わらないが、こちらのほうが同じく Java 11 で導入された `List.of()` `Map.of()` 等との一貫性があり、自然で良い。

```java
Path path = Path.of("items.csv");
```

## ファイル処理の基本

ファイル関連の処理は `Path` インタフェースと `java.nio.file.Files` ユーティリティクラス (参考: [Javadoc](https://docs.oracle.com/javase/jp/8/docs/api/java/nio/file/Files.html)) を組み合わせて行う。例えばこんな感じだ。

```java
List<String> lines = Files.readAllLines(Paths.get("items.csv"));
```

## (参考) java.io.File との関係

Java でファイルを表すクラスとしては、`java.io.File` (参考: [Javadoc](https://docs.oracle.com/javase/jp/8/docs/api/java/io/File.html)) のことを思い浮かべるかも知れない。こちらはより古い方式であり、ほぼ `java.nio.file` パッケージの機能で代替できる。なお、古い API が `java.nio.file.Path` ではなく `java.io.File` しか受け付けない場合は、`Path#toFile()` と `File#toPath()` で相互変換できる。

# テキストファイルの処理

## テキストファイルとは

本記事ではテキスト・バイナリとは何かの詳細には立ち入らず、単に Java から見て `String` 型で読み書きするファイルをテキストファイルと呼ぶ。

## 一括のテキスト読み込み

テキストを読み込む最も単純な方法は `Files.readString(Path)` メソッドである。このメソッドはファイルの全内容を String 型として返す。

```java
String content = Files.readString(Paths.get("items.csv"));
```

テキストの読み書き時には文字セットに注意する必要がある。上記コードでは文字セットを指定していないため、デフォルトの UTF-8 が使用される。以下に文字セットを明示する方式を示す。

```java
String content = Files.readString(Paths.get("items.csv"), StandardCharsets.UTF_8);
```

```java
String content = Files.readString(Paths.get("items.csv"), Charset.forName("MS932"));
```

さて、ここで一つ残念なお知らせがある。上述の `Files.readString(Path)` `Files.readString(Path, Charset)` メソッドは Java 11 以降でしか利用できない。java 7 から使える別の手段を以下に示す。このメソッドはファイルの内容を `List<String>` 型として返す。

```java
List<String> lines = Files.readAllLines(Paths.get("items.csv"), StandardCharsets.UTF_8);
```

## 小分けのテキスト読み込み

ファイルのサイズが小さければ、上記のように一括で読み込んでも問題ない。だが、数十・数百メガバイトを超えるくらいのファイルを処理する機会があるのであれば、こちらの `Stream<String>` で扱う方法を覚えておいたほうが良い。この方式のほうがパフォーマンスが優れている。

```java
try (Stream<String> lines = Files.lines(Paths.get("items.csv"), StandardCharsets.UTF_8)) {
    lines.forEach(line -> System.out.println(line));
} catch (IOException e) {
    throw new UncheckedIOException(e);
}
```

この方式を使う際には、try-with-resources 構文を使ってクローズ忘れを防止すること。他の小分けの読み書きについても同様である。詳しくは[段階的に理解する Java 例外処理](https://qiita.com/ts7i/items/d7f6c1cd5a14e55943d4)を参照。

また、行指向でない小分けのテキスト読み込みが必要な場合は、`Files.newBufferedReader(Path, Charset)` から返される `java.io.BufferedReader` (参考: [Javadoc](https://docs.oracle.com/javase/jp/8/docs/api/java/io/BufferedReader.html)) を使う。改行のない巨大な JSON ファイルを読み込むような場合にはこちらの手段が必要になるだろう。

```java
try (BufferedReader in = Files.newBufferedReader(Paths.get("items.csv"), StandardCharsets.UTF_8)) {
    for (int ch = 0; (ch = in.read()) > 0;) {
        System.out.print((char) ch);
    }
} catch (IOException e) {
    throw new UncheckedIOException(e);
}
```

さらに `java.util.Scanner` (参考: [Javadoc](https://docs.oracle.com/javase/jp/8/docs/api/java/util/Scanner.html)) と組み合わせるとより抽象度の高い API が使える。

```java
try (BufferedReader in = Files.newBufferedReader(Paths.get("items.csv"), StandardCharsets.UTF_8);
        Scanner sc = new Scanner(in)) {
    sc.useDelimiter("(,|\\n)");
    while (sc.hasNext()) {
        System.out.println(sc.next());
    }
} catch (IOException e) {
    throw new UncheckedIOException(e);
}
```

## 一括のテキスト書き込み

テキストを書き込む最も単純な方法は `Files.writeString(Path, CharSequence, OpenOption...)` メソッドである。このメソッドは与えられた `CharSequence` の内容を全てファイルに書き込む。なお、`String` や `StringBuilder` は `CharSequence` の一種である (参考: [Javadoc](https://docs.oracle.com/javase/jp/8/docs/api/java/lang/CharSequence.html))。

```java
String content = "0-0\t0-1\t0-2\n1-0\t1-1\t1-2\n";
Files.writeString(Paths.get("items.tsv"), content);
```

`OpenOption` を設定すれば追記もできる。

```java
String content = "0-0\t0-1\t0-2\n1-0\t1-1\t1-2\n";
Files.writeString(Paths.get("items.tsv"), content, StandardOpenOption.APPEND);
```

読み込み時と同じように、こちらも文字セットを指定できる。

```java
String content = "0-0\t0-1\t0-2\n1-0\t1-1\t1-2\n";
Files.writeString(Paths.get("items.tsv"), content, StandardCharsets.UTF_8);
```

```java
String content = "0-0\t0-1\t0-2\n1-0\t1-1\t1-2\n";
Files.writeString(Paths.get("items.tsv"), content, Charset.forName("MS932"));
```

さて、ここでまた一つ残念なお知らせがある。上述の `Files.writeString(Path, CharSequence, OpenOption...)` `Files.writeString(Path, CharSequence, Charset, OpenOption...)` メソッドは Java 11 以降でしか利用できない。それ以前では小分けに書き込む場合と同じ方法しか使えない。

## 小分けのテキスト書き込み

小分けにテキストを書き込む場合は `Files.newBufferedWriter(Path, Charset, OpenOption...)` から返される `java.io.BufferedWriter` (参考: [Javadoc](https://docs.oracle.com/javase/jp/8/docs/api/java/io/BufferedWriter.html)) を使う。

```java
List<String> lines = new ArrayList<String>();
...
try (BufferedWriter out = Files.newBufferedWriter(Paths.get("items.tsv"), StandardCharsets.UTF_8)) {
    for (String line : lines) {
        out.write(line);
        out.newLine();
    }
} catch (IOException e) {
    throw new UncheckedIOException(e);
}
```

# バイナリファイルの処理

## バイナリファイルとは

本記事では、単に Java から見てバイト列として読み書きするファイルをバイナリファイルと呼ぶ。

## 一括のバイナリ読み込み

バイナリを読み込む最も単純な方法は `Files.readBytes(Path)` メソッドである。このメソッドはファイルの全内容を `byte[]` として返す。

```java
byte[] content = Files.readAllBytes(Paths.get("selfie.jpg"));
```

参考までに、`byte[]` から `String` や `java.io.InputStream` に変換する方法を以下に示す。読み込んだバイナリを渡す先の API の都合次第ではこうした変換が必要になる。

```java
byte[] content = Files.readAllBytes(Paths.get("items.csv"));
String contentAsString = new String(content, StandardCharsets.UTF_8);
```

```java
byte[] content = Files.readAllBytes(Paths.get("selfie.jpg"));
InputStream in = new ByteArrayInputStream(content);
```

## 小分けのバイナリ読み込み

テキストの場合と同様、ファイルサイズが大きい場合に一括の読み込みは推奨されない。代わりに `Files.newInputStream(Path, OpenOption...)` メソッドから返される `java.io.InputStream` (参考: [Javadoc](https://docs.oracle.com/javase/jp/8/docs/api/java/io/InputStream.html)) を使う。取得した `java.io.InputStream` については、自前でループを回して処理するケースより、既存のライブラリに処理させるケースのほうが多いだろう。例として以下のコードでは [Apache POI](https://poi.apache.org/) に渡している。

```java
try (InputStream in = Files.newInputStream(Paths.get("items.xlsx"))) {
    XSSFWorkbook book = new XSSFWorkbook(in);
    ...
} catch (IOException e) {
    throw new UncheckedIOException(e);
}
```

## 一括のバイナリ書き込み

バイナリを書き込む最も単純な方法は、`Files.write(Path, byte[], OpenOption...)` メソッドである。

```java
byte[] content = ...;
Files.write(Paths.get("selfie.jpg"), content);
```

## 小分けのバイナリ書き込み

小分けにバイナリを書き込む場合は `Files.newOutputStream(Path, OpenOption...)` から返される `java.io.OutputStream` (参考: [Javadoc](https://docs.oracle.com/javase/jp/8/docs/api/java/io/OutputStream.html)) を使う。こちらも自前で処理するケースより、既存のライブラリに処理させるケースのほうが多いだろう。例として以下のコードでは [Apache POI](https://poi.apache.org/) に渡している。

```java
Item item = ...;
try (OutputStream out = Files.newOutputStream(Paths.get("items.xlsx"))) {
    XSSFWorkbook book = new XSSFWorkbook();
    ...
    book.write(out);
} catch (IOException e) {
    throw new UncheckedIOException(e);
}
```

# (参考) 昔の煩雑な書き方

以下に NIO2 File API も try-with-resources もなかった時代にありがちだったコードの例を示す。確かにこれであればスクリプト言語陣営に攻撃されても文句は言えない煩雑さである。多段の try-catch になっている部分については、「本来 `BufferedReader` を close すれば下位の `InputStream` も閉じられるが、`BufferedReader` の初期化に失敗した場合はリソースリークが発生しうるために必要で…」とかいった議論があったように記憶している。詳しい話は忘れたし、もう思い出す必要もないのだろう。

```java
String content = null;
InputStream is = null;
try {
    is = new FileInputStream("items.csv");
    BufferedReader br = null;
    try {
        br = new BufferedReader(new InputStreamReader(is, "UTF-8"));
        StringBuilder sb = new StringBuilder();
        String line = null;
        while ((line = br.readLine()) != null) {
            sb.append(line);
            sb.append(System.lineSeparator());
        }
        content = sb.toString();
    } catch (IOException e) {
        throw new RuntimeException(e);
    } finally {
        try {
            if (br != null) {
                br.close();
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
} catch (IOException e) {
    new RuntimeException(e);
} finally {
    try {
        if (is != null) {
            is.close();
        }
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}
```

繰り返しになるが、現在では上記のコードでやろうとしていることは以下で実現できる。

```java
String content = Files.readString(Paths.get("items.csv"));
```