Docurain Cloud Report Service

VS Apache POI

Apache POIとの比較

Apache POIでの帳票作成と
Docurainでの帳票作成の比較

帳票を作成するとき、「Excelをテンプレートとして使用すれば帳票出力機能が簡単に作れるのでは」と考える人は多いと思います。また、ITシステムの現場運用でExcel形式での出力を希望されることも多いと思います。

Excelデータを編集することの出来るJavaライブラリとしてはApache POIが有名です。
ここでは次のような帳票(シンプルな納品書)を、POIで作成した場合とDocurainを使用した場合とで比較してみます。

明細部分はデータに応じて行を自動で増減できるようにします。

01

Finished Products

完成物の比較

テンプレートとして次のようなExcelファイルを用います。
後で埋め込むデータ以外の構造やデザインを予め設定してあります。

Apache POIを用いる場合

ここでは次のようなPOJOオブジェクトを使用することとしました。 (Lombokを使用して、コンストラクタとアクセサを自動生成しています)

※アドブロックなどの拡張機能がインストールされている場合は、以下のコード表示が空欄になる場合があります。

@Data
@AllArgsConstructor
public class InvoiceData {
    private ContactInfo customer;
    private ContactInfo shop;
    private List<OrderLine> orderLineList;
    private LocalDate issueDate;
}
 
@Data
@AllArgsConstructor
public class ContactInfo {
    private String name;
    private String address;
    private String zip;
    private String phone;
}
 
 
@Data
@AllArgsConstructor
public class OrderLine {
    private String productName;
    private int productPrice;
    private int count;
}
 
// 使用するデータ
InvoiceData invoice = new InvoiceData(
        new ContactInfo(
                "田中 太郎",
                "東京都港区X-Y-Z 1-2-3",
                "123-46587",
                "091-2568-4514"
        ),
        new ContactInfo(
                "CYBER SHOP ABC",
                "山形県酒田市A-B-C 1-2-3",
                "598-123654",
                "098-15468-41114"
        ),
        orderLines,
        LocalDate.of(2018, 11, 20)
 );

テンプレートとして次のようなExcelファイルを用います。

後で埋め込むデータ以外の構造やデザインを予め設定してあります。

データを埋め込むコードは以下のとおりです。

ぱっと見ただけでもかなり煩雑な処理が必要であることがわかります。

public static void renderAndSave(String outputPath, InvoiceData data) throws IOException {
    Workbook wb = WorkbookFactory.create(PoiInvoiceRenderer.class.getResourceAsStream("/nouhinsho-poi.xlsx")); // テンプレートExcelデータ
    Sheet sheet = wb.getSheetAt(0);
 
    // 顧客
    cellAt(sheet, 4, 2).setCellValue(data.getCustomer().getName() + " 様");
    cellAt(sheet, 5, 2).setCellValue("〒" + data.getCustomer().getZip());
    cellAt(sheet, 6, 2).setCellValue(data.getCustomer().getAddress());
    cellAt(sheet, 7, 2).setCellValue("TEL: " + data.getCustomer().getPhone());
 
    // 発行日
    cellAt(sheet, 4, 8).setCellValue(Date.from(data.getIssueDate().atStartOfDay(ZoneId.systemDefault()).toInstant()));
 
    // ショップ
    cellAt(sheet, 6, 11).setCellValue(data.getShop().getName());
    cellAt(sheet, 7, 11).setCellValue("〒" + data.getShop().getZip());
    cellAt(sheet, 8, 11).setCellValue(data.getShop().getAddress());
    cellAt(sheet, 9, 11).setCellValue("TEL: " + data.getShop().getPhone());
 
    // 明細
    int rowIdx = 12;
    CellStyle currencyStyle = wb.createCellStyle();
    DataFormat format = wb.createDataFormat();
    currencyStyle.setDataFormat(format.getFormat("¥#,###"));
 
    for(OrderLine line : data.getOrderLineList()) {
        // sheet.shiftRows(rowIdx, sheet.getLastRowNum(), 1, true, true); // bug?
        sheet.createRow(rowIdx);
        Row row = sheet.getRow(rowIdx);
        row.createCell(2, CellType.STRING).setCellValue(line.getProductName());
        row.createCell(8, CellType.NUMERIC).setCellValue(line.getProductPrice());
        row.createCell(9, CellType.NUMERIC).setCellValue(line.getCount());
        row.createCell(10, CellType.FORMULA).setCellFormula("I" + (rowIdx + 1) + "*J" + (rowIdx + 1));
        // style
        cellAt(sheet, rowIdx, 8).setCellStyle(currencyStyle);
        cellAt(sheet, rowIdx, 10).setCellStyle(currencyStyle);
        sheet.addMergedRegion(new CellRangeAddress(rowIdx, rowIdx, 10, 11));
        rowIdx++;
    }
 
    // 合計
    sheet.createRow(rowIdx++);
    Row row = sheet.createRow(rowIdx);
    row.createCell(9, CellType.STRING).setCellValue("合計");
    row.createCell(10, CellType.FORMULA).setCellFormula("SUM(K13:K" + (13 + data.getOrderLineList().size() - 1) + ")");
    sheet.addMergedRegion(new CellRangeAddress(rowIdx, rowIdx, 10, 11));
 
    CellStyle currencyAndBorder = wb.createCellStyle();
    currencyAndBorder.setBorderBottom(BorderStyle.MEDIUM);
    currencyAndBorder.setDataFormat(format.getFormat("¥#,###"));
    cellAt(sheet, rowIdx, 9).setCellStyle(currencyAndBorder);
    cellAt(sheet, rowIdx, 10).setCellStyle(currencyAndBorder);
    row.createCell(11, CellType.STRING);
    cellAt(sheet, rowIdx, 11).setCellStyle(currencyAndBorder);
 
    wb.write(new FileOutputStream(new File(outputPath)));
    wb.close();
}
 
private static Cell cellAt(Sheet sheet, int row, int col) {
    return sheet.getRow(row).getCell(col);
}

Docurainを用いる場合

Docurainを使用する場合は、かなり直感的な記述方法で帳票フォーマットを定義することができます。
テンプレートに入力するデータは任意のJSON形式のものを受け付けることが出来ます。
ここでは、先程のPOJOで定義したデータとほぼ同じ構造のJSONを使用します。

{
    "customer": {
        "name": "田中 太郎",
        "address": "東京都港区X-Y-Z 1-2-3",
        "zip": "123-46587",
        "phone": "091-2568-4514"
    },
    "shop": {
        "name": "CYBER SHOP ABC",
        "address": "山形県酒田市A-B-C 1-2-3",
        "zip": "598-123654",
        "phone": "098-15468-41114"
    },
    "orderLineList": [
        {
            "productName": "128GB Micro SDカード",
            "productPrice": 3800,
            "count": 2
        },
        {
            "productName": "5インチ ガラス液晶フィルム",
            "productPrice": 1200,
            "count": 1
        },
        {
            "productName": "ノンフロン ダストブロワー 2パック",
            "productPrice": 800,
            "count": 1
        }
    ],
    "issueDate": "2018-11-20"
}

データを埋め込むコードは以下のとおりです。

A列にはDocurainで使用するマクロが記述されており、帳票中にはデータを指定するためのプレースホルダが埋め込まれています。それぞれがどんな意味をもっているのかは「今すぐ使ってみる」ページを見て頂ければ分かると思いますが、プログラマであれば見ただけで何を行っているのか理解して頂けるものと思っています。

Docurainで必要なのは、これだけです。JSONとこのようなExcelファイルを作れば、それですべてが整います。

次項より、帳票のそれぞれの部分で詳しく比較を行っていきます。

02

Address Name

住所氏名部分の比較

Apache POI

Workbook wb = WorkbookFactory.create(PoiInvoiceRenderer.class.getResourceAsStream("/nouhinsho-poi.xlsx"));
Sheet sheet = wb.getSheetAt(0);
 
// 顧客
cellAt(sheet, 4, 2).setCellValue(data.getCustomer().getName() + " 様");
cellAt(sheet, 5, 2).setCellValue("〒" + data.getCustomer().getZip());
cellAt(sheet, 6, 2).setCellValue(data.getCustomer().getAddress());
cellAt(sheet, 7, 2).setCellValue("TEL: " + data.getCustomer().getPhone());
 
// 発行日
cellAt(sheet, 4, 8).setCellValue(Date.from(data.getIssueDate().atStartOfDay(ZoneId.systemDefault()).toInstant()));
 
// ショップ
cellAt(sheet, 6, 11).setCellValue(data.getShop().getName());
cellAt(sheet, 7, 11).setCellValue("〒" + data.getShop().getZip());
cellAt(sheet, 8, 11).setCellValue(data.getShop().getAddress());
cellAt(sheet, 9, 11).setCellValue("TEL: " + data.getShop().getPhone());

まず最初にExcelデータを読み込み、シートを開きます。続いてそれぞれのセルに値を入れていきます。必要に応じて先頭や末尾にテキストを加えます。

注意が必要なのは、セルには型があるということです。発行日はExcelの日付書式設定を利用してフォーマットを行っていますが、これを正しく動作させるためにはDate型で設定する必要があります。

さらに、セルはNullであることがありえます。上記のコードが動くのは、元のテンプレートExcelファイルに「xxx」などのテキストが入っており、Nullで無いことが保証されているからです。セルがNullの場合(Excelのセルに何もデータが入っていなかった場合)POI側では一度セルオブジェクトを作る必要があるでしょう。

今回は簡単のために値を埋め込むセルをすべて数値で指定しましたが、これも保守性の観点からは避けるべきでしょう。このやり方は単純にコードを見て何をやっているか分かりにくいですし、後々変更が発生した場合(たとえば行が追加された時)に、どこが影響する範囲なのか判別することが非常に困難です。

Docurain

Docurainでは、プレースホルダを使用して値をはめ込んでいくだけです。プレースホルダの記述方法は単純で、目で見てそれがどんなデータを表現しているのかすぐに理解できます。

「様」や「〒」など、前後に何かテキストを付加したいという場合にも、直感的な記述が可能です。
データ型もJSONの内容を読み取り、自動で判別されます。特に難しいところはありません。

03

Repeated Part

繰り返し部分

Apache POI

// 明細
int rowIdx = 12;
CellStyle currencyStyle = wb.createCellStyle();
DataFormat format = wb.createDataFormat();
currencyStyle.setDataFormat(format.getFormat("¥#,###"));
 
for(OrderLine line : data.getOrderLineList()) {
    // sheet.shiftRows(rowIdx, sheet.getLastRowNum(), 8, true, true); // bug?
    sheet.createRow(rowIdx);
 
    Row row = sheet.getRow(rowIdx);
    row.createCell(2, CellType.STRING).setCellValue(line.getProductName());
    row.createCell(8, CellType.NUMERIC).setCellValue(line.getProductPrice());
    row.createCell(9, CellType.NUMERIC).setCellValue(line.getCount());
    row.createCell(10, CellType.FORMULA).setCellFormula("I" + (rowIdx + 1) + "*J" + (rowIdx + 1));
 
    // style
    cellAt(sheet, rowIdx, 8).setCellStyle(currencyStyle);
    cellAt(sheet, rowIdx, 10).setCellStyle(currencyStyle);
    sheet.addMergedRegion(new CellRangeAddress(rowIdx, rowIdx, 10, 11));
    rowIdx++;
}

POIで繰り返しデータを扱うのは煩雑な手順が必要になります。

最初に定義しているのはセルのスタイルです。我々がExcelで罫線や表示形式をセルに適用しているとき、Excel内部では罫線や表示形式をひとまとめにした新しいスタイルオブジェクトを作成し、各セルはスタイルオブジェクトを参照する構造になっています。ここでは、通貨の書式を適用するためのスタイルをまず作成しています。

続いて明細行を出力したかったのですが、ここで問題に直面しました。ハイライトされた8行目でデータ数分、行を挿入するためにshiftRowsメソッドを呼び出し、それ以降の行を1行ずつずらす処理を行いたかったのですが、これを行うと壊れたExcelデータが出力されます。

POIはこのように、不正なExcelデータを出力してしまうことがよくあります。低レベルなAPIを使用してExcelデータを編集していかなくてはならないので、ライブラリを使用するユーザーにもExcelフォーマット(Office Open Xmlフォーマット)への相応の知識が要求されます。

今回は色々調べましたが結局エラーとなる理由がわからず、仕方が無いので明細行以降を動的に編集することとしました。

15行目では単価と個数から合計金額を計算する計算式を入力しています。Excelを使用している時、このような明細行に同じ計算式を記述していく場合はオートフィル機能を使用すれば一瞬で適用できますが、POIでは手動で記述していく必要があります。このため、行番号から正しい参照セルを計算して記述します。

Docurain

Docurainでは、foreachやifといった制御構文が使用できるので、繰り返し処理も簡潔に記述できます。

このように記述することで、「orderLineList」のデータ数分、繰り返し14行目のフォーマットにデータを適用したものを追加します。

数式に関しては、Docurainでは現在の行を取得するマクロ #ROW を利用して数式を構築します。

04

Sum Total

合計部分

Apache POI

// 合計
sheet.createRow(rowIdx++);
Row row = sheet.createRow(rowIdx);
row.createCell(9, CellType.STRING).setCellValue("合計");
row.createCell(10, CellType.FORMULA).setCellFormula("SUM(K13:K" + (13 + data.getOrderLineList().size() - 1) + ")");
sheet.addMergedRegion(new CellRangeAddress(rowIdx, rowIdx, 10, 11));
 
CellStyle currencyAndBorder = wb.createCellStyle();
currencyAndBorder.setBorderBottom(BorderStyle.MEDIUM);
currencyAndBorder.setDataFormat(format.getFormat("¥#,###"));
cellAt(sheet, rowIdx, 9).setCellStyle(currencyAndBorder);
cellAt(sheet, rowIdx, 10).setCellStyle(currencyAndBorder);
 
row.createCell(11, CellType.STRING);
cellAt(sheet, rowIdx, 11).setCellStyle(currencyAndBorder);

それぞれの明細行から合計を計算します。5行目で計算式を入力していますが、SUM関数で指定する行番号をデータ件数に応じて変更しなければならないので注意が必要です。
8行目以降では、合計金額のセルに書式を設定しつつ、セルの下部に罫線を引く設定をしています。

Docurain

Docurainでは名前付き範囲が使用できます。テンプレート上で明細行の「金額」セルに名前を付けておけば、帳票出力後にそのセルがデータ件数に応じて拡張された名前付きのセル範囲として利用できます。よって、合計金額は SUM(price) で算出できます。(もちろん、行番号で指定することも可能です)

04

Summary

まとめ

「POIで作れば簡単かも」という発想は、やってみると意外と難しいということが分かって頂けたかと思います。

しかも上記のようなコードは将来的にやってくるであろう帳票の変更作業で保守したいとはお世辞にも思えないでしょう。
保守性を考えると、理想的には帳票の生成処理はコードと切り離された状態で存在して欲しいものです。

より発展的な方法としては、Docurainと同じように特殊なプレースホルダを定義してすべてのセルを走査し、当てはまるデータを置換してくというものが考えられます。この実装はそこまで難しいものではないのですが、Docurainが実装しているような繰り返し処理や条件分岐などの制御構文を含めて考えると実装はかなり面倒です。

それ以外にも、帳票で必要になる機能はたくさんあります。この納品書のレコー数が大きな数になった時、「自動で改ページして最後のページにのみ合計金額を表示する」という仕様は珍しいものではありません。POIでもそれらの仕様を実装することは可能ですが、現実的には上記に示したように帳票の種類に特有のハードコーディングは残ってしまい、保守性の問題へとつながるでしょう。

一番負担が大きいのは、これまで述べた殆どすべての作業において、Open XMLやPOIのAPIについての詳しい知識が要求されるという事実です。それらを習得しつつ、独自の帳票エンジンを制作するのは全く生産的ではありません。我々は帳票を作成したいのであって、ExcelのデータフォーマットやPOI APIに関心が有るわけではないのです。

ハードコーディングで少数の帳票を完成させればよい、しかも保守作業は殆ど発生しないか、発生しても簡易なもので済むと確定している場合を除けば、Docurainのような帳票エンジンを使うべきでしょう。