JAX-RSによるExcel/CSV/PDFファイルダウンロード

プロジェクトでも必ずと言っていいほど「ファイルダウンロード」の要件は挙がります。
今回はExcel/CSV/PDFの3種のダウンロードについて実装方法を考えてみます。

# ファイルダウンロードの設計
## JAX-RSによるファイルのダウンロード
JAX-RSではリクエストMediaTypeに応じてある程度の型であれば型に応じてレスポンスに変換する機能があります。
Excel/CSV/PDFに関してデフォルトでは無いため`javax.ws.rs.ext.MessageBodyWriter`を実装したクラスを作成することでカスタマイズします。

<img width="500px" alt="" src="https://qiita-image-store.s3.amazonaws.com/0/93055/53726ad9-b230-40c8-7364-6178632b68dd.png" />


## 一覧取得の処理を集約する
ダウンロードファイルの種類に合わせて１つずつ一覧取得の実装するとメンテナンスのコストが上がります。
一覧取得の処理１つで ダウンロードファイルの種類すべてをまかなえることが望ましいです。

<img width="350px" alt="" src="https://qiita-image-store.s3.amazonaws.com/0/93055/50b92df2-b61d-7781-5943-f1141e0d5ef0.png" />

実現するために以下のように実装します。

 - Resourceクラスでダウンロード対象エンティティ一覧を取得
 - WriterクラスでリクエストMediaTypeに合わせてダウンロードファイルを作成

## すべての一覧取得をカバーする
プロジェクトでは１つではなく複数の一覧画面を作ることがほとんどかと思います。
一覧画面１つに対してファイル出力の処理を毎回書いていると、またまたメンテナンスのコストが上がることとなります。
つまりすべての一覧画面に対しても対応できることが理想形となります。

<img width="350px" alt="" src="https://qiita-image-store.s3.amazonaws.com/0/93055/97439753-a619-6279-9bcf-8726935a8ff4.png" />

実現するために以下のように実装します。

 - Writerクラスにて ダウンロード対象エンティティを総称型（ジェネリクス）で受ける
 - 総称型（ジェネリクス）で受けたエンティティのプロパティにアクセスするためのクラスを作成する

# 実装
## 一覧の取得
`@Produces`でダウンロードファイルのMediaTypeを追加し、リクエストを受け付けるようにします。
メソッドの返却型では一覧に出力するエンティティ型を指定します。
（なお EntityManagerの作成/検索クエリなどの処理は省略しています。）

```java:JaxRsResource.java
@Path("jaxrs")
public class JaxRsResource {

    @Inject
    protected EntityManager em;

    @GET
    @Produces({
        "text/csv",
        "application/pdf",
        "application/vnd.ms-excel",
        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"})
    public Iterable<HogeEntity> list(...) {
        // ...リクエストパラメータから検索条件を取得してqueryを組み立てる

        // DBより一覧を取得することを想定
        return em.createQuery(query)
                .getResultList();
    }
}
```

## ダウンロード対象のエンティティ
以下サンプル`@ListColumn`のようなアノテーションを作成しておき
ExcelでもCSVでも共通して使える仕掛けをあらかじめ作成しプロパティに付加しておきます。

```java:HogeEntity.java
public class HogeEntity {
	
	@ListColumn(header="ID" , order = 0)
    public String id;
    
	@ListColumn(header="ほげ名称" , order = 2)
    public String name;
    
	@ListColumn(header="ふが日時" , order = 1)
    public LocalDate hugaDate;
    
    // ...
}
```

## CSV
`@Produces`でMediaTypeに対応した”text/csv”を設定し、レスポンスを変換できるようにします。

```java:CsvWriter.java
@Provider
@Produces("text/csv")
public class CsvWriter<E> implements MessageBodyWriter<List<E>> {
    // ...

    @Override
    public void writeTo(List<E> entities, Class<?> type, Type genericType, Annotation[] annotations, MediaType mediaType, MultivaluedMap<String, Object> httpHeaders, OutputStream entityStream) throws IOException, WebApplicationException {
        if (entities.isEmpty()) {
            return;
        }
        try (PrintWriter writer = new PrintWriter(new OutputStreamWriter(entityStream, Charset.forName("Windows-31J")));
                CSVPrinter printer = new CSVPrinter(writer, CSVFormat.RFC4180);) {
            List<String> header = accessors.stream()
                    .map(acc -> /*ヘッダーラベルの取得*/)
                    .collect(Collectors.toList());
            printer.printRecord(header);
            printer.flush();

            for(E entity : entities) {
                List<String> row = accessors.stream()
                        .map(accessor -> /*値の取得*/)
                        .collect(Collectors.toList());
                printer.printRecord(row);
                printer.flush();
            }
        }
    }
    
}
```
CSVダウンロードでは「org.apache.commons.commons-csv:1.1」ライブラリにて実装。
// TODO ジェネリクス型のフィールドアクセスについて別途記載する

## Excel
`@Produces`でMediaTypeに対応した”application/vnd.xxxxx”を設定し、レスポンスを変換できるようにします。

```java:XlsWriter.java
@Provider
@Produces("application/vnd.ms-excel")
public class XlsWriter<E> implements MessageBodyWriter<List<E>>{
    // ...

    @Override
    public void writeTo(List<E> entities, Class<?> type, Type genericType, Annotation[] annotations, MediaType mediaType, MultivaluedMap<String, Object> httpHeaders, OutputStream entityStream) throws IOException, WebApplicationException {
		if (entities.isEmpty()) {
			return;
		}
		HSSFWorkbook book = new HSSFWorkbook();
		Sheet sheet = book.createSheet();
		        
		Row headerRow = CellUtil.getRow(0, sheet);
		IntStream.range(0, accessors.size())
		        .forEach(i -> {
		            CellUtil.createCell(headerRow, i, /*ヘッダーラベルの取得*/);
		        });
		IntStream.range(0, entities.size())
		        .forEach(i -> {
		            Row row = CellUtil.getRow(i + 1, sheet);
		            IntStream.range(0, accessors.size())
		                    .forEach(j -> {
		                        CellUtil.createCell(row, j, /*値の取得*/);
		                    });
		        });
		book.write(entityStream);
    }
}
```

```java:XlsxWriter.java
@Provider
@Produces("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
public class XlsxWriter <E> implements MessageBodyWriter<List<E>> {
    // ...

    @Override
    public void writeTo(List<E> entities, Class<?> type, Type genericType, Annotation[] annotations, MediaType mediaType, MultivaluedMap<String, Object> httpHeaders, OutputStream entityStream) throws IOException, WebApplicationException {
        if (entities.isEmpty()) {
        	return;
        }
        SXSSFWorkbook book = new SXSSFWorkbook();
        Sheet sheet = book.createSheet();
        
        Row headerRow = CellUtil.getRow(0, sheet);
        IntStream.range(0, accessors.size())
                .forEach(i -> {
                    CellUtil.createCell(headerRow, i, /*ヘッダーラベルの取得*/);
                });
        IntStream.range(0, entities.size())
                .forEach(i -> {
                    Row row = CellUtil.getRow(i + 1, sheet);
                    IntStream.range(0, accessors.size())
                            .forEach(j -> {
                                CellUtil.createCell(row, j, /*値の取得*/);
                            });
                });
        book.write(entityStream);
    }
    
}
```
Excelダウンロードでは「org.apache.poi.poi:3.11」ライブラリにて実装。

## PDF
`@Produces`でMediaTypeに対応した”application/pdf”を設定し、レスポンスを変換できるようにします。
PDFのダウンロードは少し面倒です。以下を行う必要があります。

 - ダウンロード対象エンティティごとに別途テンプレートを作成
 - Propertyファイルにエンティティ↔︎PDFテンプレート間のマッピングを保持

```java:PdfWriter.java
@Provider
@Produces("application/pdf")
public class PdfWriter<E> implements MessageBodyWriter<List<E>> {
    private final Properties templatePaths = new Properties();

    private static final String PDF_MAPPING_FILE_NAME = "pdfTemplateMapping.properties";
    
    @PostConstruct
    public void initialize() throws IOException{
        final ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
        
        try (InputStream is = classLoader.getResourceAsStream(PDF_MAPPING_FILE_NAME)) {
            templatePaths.load(is);
        }
    }
        
    // ...

    @Override
    public void writeTo(List<E> entities, Class<?> type, Type genericType, Annotation[] annotations, MediaType mediaType, MultivaluedMap<String, Object> httpHeaders, OutputStream entityStream) throws IOException, WebApplicationException {
        if(entities.isEmpty()){
            return;
        }
        String entityName = entities.get(0).getClass().getName();
        String templatePath = templatePaths.getProperty(entityName);
        
        PdfCreator creator = new PdfCreator(templatePath);
        creator.write(entities, entityStream);
    }
}
```

```java:PdfCreator.java
public class PdfCreator {
    private JasperReport jasperReport;
    
    public PdfCreator(String templatePath) throws IOException, JRException {
        final ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
        
        try (InputStream in = classLoader.getResourceAsStream(templatePath)){
            jasperReport = JasperCompileManager.compileReport(in);
        }
    }

    public void write(Collection entities, OutputStream output) throws IOException, JRException {
        Map<String, Object> params = new HashMap<>();
        JRDataSource dataSource = new JRBeanCollectionDataSource(entities);
        
        JasperPrint jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource);
        JasperExportManager.exportReportToPdfStream(jasperPrint, output);
    }
}
```
PDFダウンロードでは「net.sf.jasperreports.jasperreport:5.6.1」ライブラリ [^1] にて実装。

# まとめ
Writerクラスさえ作ってしまえば よしなにファイルを作成してくれるので

 - エンティティにアノテーションを付加する
 - Resourceクラスの一覧取得メソッドにサポートしたいMediaTypeを追加する

だけでダウンロード処理を大量生産できます。

JAX-RSによるファイルダウンロードは比較的容易に実装できるためコピペして作ってしまいがちかと思います。
今回のように汎用的に作ることで「この一覧もダウンロードしたいナー」の要件に即時対応ができるようになります。そしてテストも比較的容易となるでしょう。

これからJavaEEを使って実装を始める人の助けになればと思います。



[^1]: 商用利用する際にjasperreportのバージョンによってはライセンス問題になることがあります。<a href="http://dev.classmethod.jp/server-side/java/jasperreports-tutorial/">こちら</a>の記事を参考にしてください。