JDKのZip APIを使った複数ファイルのダウンロード on Spring Boot

今携わっている案件で、複数ファイル（実際はDBに保持しているBLOBデータ）を１回のAPI呼び出しでダウンロードしたい！！という要望があって、思いついた案は・・・マルチパートとJDK標準のZIP APIでした。マルチパートレスポンスはクライアント系のライブラリがほとんでサポートしていない感じだったのでボツにし、JDK標準のZip API（`ZipOutputStream` / `ZipInputStream`）を使うことにしました。
そんなもんで、本日はJDK標準のZip APIを使った複数ファイルのダウンロードの実装例を紹介したいと思います。

ちなみに・・・Spring Boot使っているので、サンプルもSpring Bootアプリケーションです。

# 動作検証バージョン

* Spring Boot 1.3.5.RELEASE
* Java SE 8
* Mac

# 検証コード

* https://github.com/kazuki43zoo/qiita-materials/tree/master/spring-mvc/zip-demo

# プロジェクト作成

[SPRING INITIALIZR](https://start.spring.io/)にて、Artifactを「zip-demo」、Dependenciesに「Web」を指定してプロジェクトをダウンロードし、任意の場所に解凍します。今回は、Apache Http Clientを使ってダウンロードを行うので・・・Apache Http Clientを依存リブラリに追加します。

```xml:pom.xml
<dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpmime</artifactId>
</dependency>
```

今回紹介するサンプルでは、アップロードしたファイルをZipファイルとしてダウンロードする構成にするため、`org.apache.httpcomponents:httpmime`を依存ライブラリに追加しています。なお、`org.apache.httpcomponents:httpclient`は推移的依存性によって解決されるので、ここでは明示的に指定しません。


# Handlerメソッドの作成

本来はControllerクラスを作成すべきですが、ここでは起動クラスに直接Handlerメソッドを追加しちゃいます。

```java:src/main/java/com/example/ZipDemoApplication.java
package com.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.util.StreamUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartResolver;
import org.springframework.web.multipart.support.StandardServletMultipartResolver;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Paths;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

@Controller // @Controllerを追加する
@SpringBootApplication
public class ZipDemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(ZipDemoApplication.class, args);
    }

    @Bean // ファイルアップロード機能を有効化する
    public MultipartResolver multipartResolver() {
        return new StandardServletMultipartResolver();
    }

    @RequestMapping(path = "/files") // Handlerメソッドを追加する
    public void zipDownload(@RequestParam List<MultipartFile> files, HttpServletResponse response) throws IOException {

        response.setHeader(HttpHeaders.CONTENT_TYPE,
                MediaType.APPLICATION_OCTET_STREAM_VALUE);
        response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
                "attachment; filename=download.zip");

        try (ZipOutputStream zipOutputStream = new ZipOutputStream(response.getOutputStream())) {
            for (MultipartFile file : files) {
                String fileName = Paths.get(file.getOriginalFilename()).getFileName().toString();
                try (InputStream input = file.getInputStream()) {
                    zipOutputStream.putNextEntry(new ZipEntry(fileName));
                    StreamUtils.copy(input, zipOutputStream); // write per 4KB
                }
            }
        }

    }

}
```

上記のAPIを呼び出す際に指定するファイルを作成します。

```text:data/a.txt
aaaaa
```
```text:data/b.txt
bbbbb
```

# curlを利用したAPIの呼び出し

APIを呼び出す前にSpring Bootアプリケーションを起動します。

```text
./mvnw spring-boot:run
...
2016-06-23 06:03:24.080  INFO 75579 --- [           main] o.s.j.e.a.AnnotationMBeanExporter        : Registering beans for JMX exposure on startup
2016-06-23 06:03:24.135  INFO 75579 --- [           main] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat started on port(s): 8080 (http)
2016-06-23 06:03:24.141  INFO 75579 --- [           main] com.example.ZipDemoApplication           : Started ZipDemoApplication in 1.699 seconds (JVM running for 3.812)
```

Spring Bootアプリケーションが正常に起動したら、curlコマンドを使ってアップロードしたファイルをZipファイルとしてダウンロードします。


```text
$ curl -X POST -F files=@data/a.txt -F files=@data/b.txt -O http://localhost:8080/files
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   568    0   236  100   332   7772  10934 --:--:-- --:--:-- --:--:-- 11066
```

filesという名前のファイルがダウンロードされているので、Zipファイル内のエントリーを確認します。

```text
$ jar -tvf files
     5 Thu Jun 23 05:59:22 JST 2016 a.txt
     5 Thu Jun 23 05:59:22 JST 2016 b.txt 
```

アップロードしたファイルがダウンンロードできていそうですね :v: 

# クライアントライブラリを利用したAPIの呼び出し

ここでは、Springの`RestTemplate`とApache Http Clientを使用してAPIにアクセスしてみます。今回のサンプルでは、SPRING INITIALIZRからダウンロードしたプロジェクトにあらかじめ同封されているテストケースクラスを書き換えます。

```java:src/main/test/com/example/ZipDemoApplicationTests.java
package com.example;

import org.apache.http.HttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.mime.MultipartEntityBuilder;
import org.apache.http.entity.mime.content.FileBody;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.boot.test.WebIntegrationTest;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StreamUtils;
import org.springframework.web.client.RestTemplate;

import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

import static org.hamcrest.core.Is.is;
import static org.junit.Assert.assertThat;

@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = ZipDemoApplication.class)
@WebIntegrationTest(randomPort = true) // Junit内で空きポートを使って組み込みTomcatを起動
public class ZipDemoApplicationTests {

    @Value("http://localhost:${local.server.port}/files") // 組み込みTomcat上のURLをインジェクション
    private String url;

    /**
     * Springが提供するRestTemplateを使用した時のダウンロード例
     */
    @Test
    public void zipDownloadUsingSpringRestTemplate() throws IOException {

        RestTemplate restOperations = new RestTemplate();
        MultiValueMap<String, Object> form = new LinkedMultiValueMap<>();
        form.add("files", new FileSystemResource("data/a.txt"));
        form.add("files", new FileSystemResource("data/b.txt"));
        RequestEntity<MultiValueMap<String, Object>> requestEntity =
                RequestEntity.post(URI.create(url)).body(form);

        // ★★★ この実装だとByteArrayInputStreamとして扱われるので大量データを扱う場合は注意が必要！！ ★★★
        ResponseEntity<Resource> responseEntity = restOperations.exchange(requestEntity, Resource.class);

        try (ZipInputStream zipInputStream = new ZipInputStream(responseEntity.getBody().getInputStream())) {
            {
                ZipEntry entry = zipInputStream.getNextEntry();
                assertThat(entry.getName(), is("a.txt"));
                assertThat(StreamUtils.copyToString(zipInputStream, StandardCharsets.UTF_8), is("aaaaa"));
            }
            {
                ZipEntry entry = zipInputStream.getNextEntry();
                assertThat(entry.getName(), is("b.txt"));
                assertThat(StreamUtils.copyToString(zipInputStream, StandardCharsets.UTF_8), is("bbbbb"));
            }
           assertNull(zipInputStream.getNextEntry());
        }
    }


    /**
     * Apache Http Clientが提供するJava APIを使用した時のダウンロード例
     */
    @Test
    public void zipDownloadUsingApacheHttpClient() throws IOException {

        try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) {

            HttpPost request = new HttpPost(url);
            request.setEntity(MultipartEntityBuilder.create()
                    .addPart("files", new FileBody(new File("data/a.txt")))
                    .addPart("files", new FileBody(new File("data/b.txt")))
                    .build());

            HttpResponse response = httpClient.execute(request);

            try (ZipInputStream zipInputStream = new ZipInputStream(response.getEntity().getContent())) {
                {
                    ZipEntry entry = zipInputStream.getNextEntry();
                    assertThat(entry.getName(), is("a.txt"));
                    assertThat(StreamUtils.copyToString(zipInputStream, StandardCharsets.UTF_8), is("aaaaa"));
                }
                {
                    ZipEntry entry = zipInputStream.getNextEntry();
                    assertThat(entry.getName(), is("b.txt"));
                    assertThat(StreamUtils.copyToString(zipInputStream, StandardCharsets.UTF_8), is("bbbbb"));
                }
                assertNull(zipInputStream.getNextEntry());
            }

        }

    }

}
```

このテストケースを実行すると、組み込みTomcat上のAPIを呼び出し、アップロードしたファイルをZipファイルとしてダウンロードできます。

# まとめ

サーバーもクライアントも簡単に実装できることがわかったかと思います。サンプルコード内にも記載しましたが、`RestTemplate`を利用して容量が大きいデータを扱う場合、サンプルコードで紹介している実装を使うとメモリがやばいことになる可能性が高いので注意してください。容量が大きいデータをダウンロードする場合は、[こちら](http://terasolunaorg.github.io/guideline/5.1.0.RELEASE/ja/ArchitectureInDetail/RestClient.html#restclienthowtousefiledownload)のサンプルを参考にアプリケーション要件にあった実装にしましょう。

# 参考サイト

* http://www.ne.jp/asahi/hishidama/home/tech/java/zip.html
* http://terasolunaorg.github.io/guideline/5.1.0.RELEASE/ja/ArchitectureInDetail/RestClient.html#restclienthowtousefiledownload