ひとつのCSVファイルを指定したファイルサイズ以下になるように切り分ける

バッチからとあるAPIを叩く際にデータをCSVファイルで送るように要求された。
ファイルサイズの最大値をオーバーする場合は2つ (またはそれ以上) に分割する必要があるのでその処理を行う。

# 要件

- ファイルを指定サイズ以下になるように切り分ける
- (ヘッダがある場合) 切り分けたファイルそれぞれにヘッダを付ける
- 切り分けは行単位とする
  - 1行 (+ヘッダ部分) で指定サイズをオーバーするような行がある場合はエラー
- ファイル名は元のファイルに "_1" といった連番を付けたものにする
  - 既に同名のファイル名が存在する場合は連番を進めることで既存のファイルを上書きしないようにする

## 切り分け例

元のファイル `~/sample.txt`
```
#col1,col2,col3
a,b,c
hoge,fuga,foo
1,2,3
4,5,6
```
↓
`~/sample_1.txt`
```
#col1,col2,col3
a,b,c
hoge,fuga,foo
```

`~/sample_2.txt`
```
#col1,col2,col3
1,2,3
4,5,6
```

# 実装内容

使用ライブラリ

- https://mvnrepository.com/artifact/commons-io/commons-io
- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3

```java
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.tuple.Pair;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;

public class FileSizeSplitter {
	public static void main(String[] args) throws IOException {
		var fileSizeSplitter = new FileSizeSplitter();
		Path file = Path.of("~/sample.txt");
        // ヘッダ込みで 500 バイト以内になるように切り分ける例
		List<Path> list = fileSizeSplitter.splitSize(file, 500, Charset.defaultCharset(), "\n", true);
		list.forEach(System.out::println);
	}

	private int idx = 0;

	/**
	 * CSV ファイルを指定したサイズで分割する。
	 * 分割したファイルは 元のファイル名に連番を振った名前 で出力される。
	 * 出力するファイルと同名のファイルが存在する場合は連番をふり直す。
	 *  e.g.1 ) file のファイルサイズ 1.5MB, maxFileSize 1MB
	 *      -> file_1 (1MB) と file_2 (0.5MB) の 2 つのファイルを生成し、そのパスを返す。
	 *  e.g.2 ) file のファイルサイズ 1.5MB, maxFileSize 1MB, file_2 が既に存在する
	 *      -> file_1 (1MB) と file_3 (0.5MB) の 2 つのファイルを生成し、そのパスを返す。
	 *
	 * @param file 分割対象のファイル
	 * @param maxSize 分割後のファイルの最大サイズ
	 * @param charset 文字コード
	 * @param lineSeparator 改行コード
	 * @param hasHeader true ならば先頭行をヘッダ扱いする
	 * @return 分割されたファイル群の Path
	 * @throws IOException
	 * @throws IllegalArgumentException
	 */
	public List<Path> splitSize(Path file, long maxSize, Charset charset, String lineSeparator, boolean hasHeader)
			throws IOException, IllegalArgumentException {
		long maxByteLength = maxByteLength(Files.lines(file, charset), charset);
		int lineSeparatorLength = lineSeparator.getBytes(charset).length;
		if (maxByteLength + lineSeparatorLength > maxSize) {
			throw new IllegalArgumentException("line longer than the maxSize exists.");
		}
		String header = "";
		if (hasHeader) {
			try (Stream<String> s = Files.lines(file, charset)) {
				header = s.findFirst().orElse("") + lineSeparator;
			}
			if (header.getBytes(charset).length + maxByteLength + lineSeparatorLength > maxSize) {
				throw new IllegalArgumentException("(header + line) longer than the maxSize exists.");
			}
		}

		idx = 0;
		List<Path> outputFilePathList = new ArrayList<>();
		// file から ディレクトリパス、ファイル名、拡張子を取得
		Path dir = file.getParent();
		String baseName = FilenameUtils.getBaseName(file.getFileName().toString());
		String extension = FilenameUtils.getExtension(file.getFileName().toString());

		Pair<Path, BufferedWriter> pathWriterPair = createNewPathWriterPair(dir, baseName, extension, charset);
		Path currentOutputFile = pathWriterPair.getLeft();
		BufferedWriter bw = pathWriterPair.getRight();
		outputFilePathList.add(currentOutputFile);
		try (BufferedReader br = Files.newBufferedReader(file, charset)) {
			if (hasHeader) {
				br.readLine();
				bw.write(header);
			}
			String line;
			while ((line = br.readLine()) != null) {
				bw.flush();
				if (Files.size(currentOutputFile) + line.getBytes(charset).length > maxSize) {
					bw.close();
					Pair<Path, BufferedWriter> nextPathWriterPair = createNewPathWriterPair(dir, baseName, extension, charset);
					currentOutputFile = nextPathWriterPair.getLeft();
					bw = nextPathWriterPair.getRight();
					outputFilePathList.add(currentOutputFile);
					if (hasHeader) {
						bw.write(header);
					}
				}
				bw.write(line + lineSeparator);
			}
		} finally {
			if (bw != null) {
				bw.close();
			}
		}
		return outputFilePathList;
	}
	// 一番長い行のバイト数を返す
	private int maxByteLength(Stream<String> s, Charset charset) {
		return s.reduce(0,
				(byteLength, line) -> Math.max(byteLength, line.getBytes(charset).length),
				Integer::sum);
	}
	private Pair<Path, BufferedWriter> createNewPathWriterPair(Path dir, String baseName, String extension, Charset charset) throws IOException {
		Path outputFile;
		do {
			idx++;
			outputFile = Path.of(dir.toString(), baseName + "_" + idx + "." + extension);
		} while (Files.exists(outputFile));
		BufferedWriter bw = Files.newBufferedWriter(outputFile, charset);
		return Pair.of(outputFile, bw);
	}
}
```

今回は BufferedReader / BufferedWriter を利用したが、元のファイルサイズの予想がある程度ついていてメモリに余裕があるのであれば

- 元ファイルの内容を `Files.
readAllLines(Path path, Charset cs)` で読み取る
- ファイルの書き込みを `Files.write(Path path, Iterable<? extends CharSequence> lines, OpenOption... options)` で行う

とすると一括で読み込み / 書き込みできるので速くなりそう。