JavaのZipOutputStreamで作成したZipファイルが開けなかった話

2021.03.11

こんにちは。サービスGの金谷です。

JavaでのZip圧縮はZipOutputStreamというクラスを使用すると実現可能ですが、

Springと組み合わせて使用した際に自分の使い方が悪く、上手くZipを作成できないことがあったのでそれについてまとめようと思います。

環境等

❯ java -version
openjdk version "1.8.0_282"
OpenJDK Runtime Environment Corretto-8.282.08.1 (build 1.8.0_282-b08)
OpenJDK 64-Bit Server VM Corretto-8.282.08.1 (build 25.282-b08, mixed mode)

JavaでのZip圧縮の例

こちらの記事で解説されているとおりですが、以下のような形で動きます(相対パス指定だと動きません)

public class ZipSample {
    public static void main(String[] args) {
				// 存在するファイル
        Path path1 = Paths.get("/Users/user/data1.txt");
        Path path2 = Paths.get("/Users/user/data2.txt");

        String out = "/Users/user/data.zip";

        try(
                FileOutputStream fos = new FileOutputStream(out);
                BufferedOutputStream bos = new BufferedOutputStream(fos);
                ZipOutputStream zos = new ZipOutputStream(bos);
        ) {
            byte[] data1 = Files.readAllBytes(path1);
            ZipEntry zip1 = new ZipEntry("data1.txt");
            zos.putNextEntry(zip1);
            zos.write(data1);
            zos.closeEntry();

            byte[] data2 = Files.readAllBytes(path2);
            ZipEntry zip2 = new ZipEntry("data2.txt");
            zos.putNextEntry(zip2);
            zos.write(data2);
            zos.closeEntry();

        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

公式ドキュメントを読む限りではエントリーを書き込む度に closeEntry() を実行してあげるのが良さそうです。

SpringのController内でZip圧縮の例(NGパターン)

上の例を踏まえてSpringにZip圧縮を組み込もうと当初私は以下のように実装しました。

@RestController
public class DemoController {
    @GetMapping("/zip_ng")
    public ResponseEntity<byte[]> ngZip() throws IOException {
        // 適当なデータ
        String file1 = "data1.txt";
        byte[] data1 = "HelloWorld".getBytes();
        String file2 = "data2.txt";
        byte[] data2 = "HelloZip".getBytes();

        HttpHeaders respHeaders = new HttpHeaders();
        respHeaders.setContentType(MediaType.APPLICATION_OCTET_STREAM);
        respHeaders.set(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=data.zip");

        try(
          ByteArrayOutputStream baos = new ByteArrayOutputStream(); 
          ZipOutputStream zos = new ZipOutputStream(baos);
        ) {

            ZipEntry zip1 = new ZipEntry(file1);
            zos.putNextEntry(zip1);
            zos.write(data1);
            zos.closeEntry();

            ZipEntry zip2 = new ZipEntry(file2);
            zos.putNextEntry(zip2);
            zos.write(data2);
            zos.closeEntry();

            byte[] responseData = baos.toByteArray();
            return new ResponseEntity<byte[]>(responseData, respHeaders, HttpStatus.OK);
        }
    }
}

ですがこれでダウンロードしたZipファイルは開けません・・・。

SpringのController内でのZip圧縮の例(OKパターン)

色々悩んだ末、以下のような形で成功しました。

@RestController
public class DemoController {
    @GetMapping("/zip_ok")
    public ResponseEntity<byte[]> okZip() throws IOException {
        // 適当なデータ
        String file1 = "data1.txt";
        byte[] data1 = "HelloWorld".getBytes();
        String file2 = "data2.txt";
        byte[] data2 = "HelloZip".getBytes();

        HttpHeaders respHeaders = new HttpHeaders();
        respHeaders.setContentType(MediaType.APPLICATION_OCTET_STREAM);
        respHeaders.set(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=data.zip");

        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        try(ZipOutputStream zos = new ZipOutputStream(baos)) {

            ZipEntry zip1 = new ZipEntry(file1);
            zos.putNextEntry(zip1);
            zos.write(data1);
            zos.closeEntry();

            ZipEntry zip2 = new ZipEntry(file2);
            zos.putNextEntry(zip2);
            zos.write(data2);
            zos.closeEntry();
        }
        byte[] responseData = baos.toByteArray();
        return new ResponseEntity<byte[]>(responseData, respHeaders, HttpStatus.OK);
    }
}

〇〇OutputStreamを呼び出すときにはcloseする必要があると思いこんでいましたがそうじゃないクラスもありましたね。

もう少しNGパターンとの差分を少なくすると以下のようになります。

@RestController
public class DemoController {

    @GetMapping("/zip_ok")
    public ResponseEntity<byte[]> okZip() throws IOException {
        // 適当なデータ
        String file1 = "data1.txt";
        byte[] data1 = "HelloWorld".getBytes();
        String file2 = "data2.txt";
        byte[] data2 = "HelloZip".getBytes();

        HttpHeaders respHeaders = new HttpHeaders();
        respHeaders.setContentType(MediaType.APPLICATION_OCTET_STREAM);
        respHeaders.set(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=data.zip");

        try(
          ByteArrayOutputStream baos = new ByteArrayOutputStream(); 
          ZipOutputStream zos = new ZipOutputStream(baos);
        ) {

            ZipEntry zip1 = new ZipEntry(file1);
            zos.putNextEntry(zip1);
            zos.write(data1);
            zos.closeEntry();

            ZipEntry zip2 = new ZipEntry(file2);
            zos.putNextEntry(zip2);
            zos.write(data2);
            zos.closeEntry();

            zos.close();  // ここ

            byte[] responseData = baos.toByteArray();
            return new ResponseEntity<byte[]>(responseData, respHeaders, HttpStatus.OK);
        }
    }
}

いずれにせよ大事なポイントとしてはclose()の呼び出しですね。

closeメソッドを呼び出すことで最後にデータが追加されるようです。

上の例はいずれもtry-with-resources文で書いているので、tryスコープから出た際に自動でcloseが実行されるのですが

NGパターンではtryスコープ内でSpringでレスポンスを返した後にcloseメソッドが呼ばれ、データが欠損した状態でダウンロードが始まっていたようです。

Zipの仕様を知らなかったのでハマりました。

ZipOutputStreamを使用する際はcloseのタイミングに注意しましょう。

また、今回はResponseEntityを使用しましたが、HttpServletResponseに直接書き込みを行う方法でも解決するかと思います。

(2つめのパターンについては明示的にcloseを呼び出しているのでtry-with-resources文で書く必要はないですね)