複雑なExcel出力の課題とEasyExcelによる解決
最近のプロジェクトでは、ウェブページに表示される請求書データをExcelファイルとして出力する要件がありました。当初、HTMLコンテンツをBlob形式で取得し、それを.xlsファイルとしてクライアントサイドで生成する方法を検討しました。この方法は、.xlsファイルがHTMLテキストを解釈する特性を利用したもので、本質的にはHTMLファイルを.xlsとして保存するのと同様です。
しかし、このアプローチにはいくつかの問題が浮上しました。特に、請求書に含まれるロゴ画像がサーバー上に配置されており、クライアントサイドで生成された.xlsファイルが画像パスにアクセスする際に、証明書認証の問題が発生しました。海外の拠点ではVPN接続を試しても画像が正常に表示されず、Office 365が.xlsファイルを開く際にサーバーの証明書認証を適切に行えないことが原因であると判明しました。
この証明書問題を解決できないため、サーバーサイドでExcelファイルを生成するアプローチに切り替えました。そこで採用したのが、JavaでExcelを扱うための高機能ライブラリであるEasyExcelです。
EasyExcelによる複雑なExcelレイアウトの構築
EasyExcelは、公式ドキュメントや提供されているサンプルを見ると、主にシンプルな表形式のデータ出力に特化しているように見えます。しかし、私たちの要件は、ロゴ、会社情報、請求書タイトル、明細、そして動的に変更されるヘッダーを持つ製品リストなど、複数のセクションから構成される複雑なレイアウトのExcel帳票を生成することでした。
EasyExcelはこのような複雑なレイアウトを直接サポートしているわけではありませんが、既存のExcelファイルをテンプレートとして利用し、そこにデータを「追記」していくwithTemplate()機能があります。この機能を利用することで、複雑なExcel帳票を複数のシンプルなパーツに分解し、それらを段階的に結合していくというアプローチが可能になります。
この手法では、複雑なExcelを構成する各セクション(例: ロゴ、会社情報、請求書タイトル、明細行など)を個別のデータモデル(POJOクラス)として定義し、それぞれを一時的なExcelファイルとして出力します。そして、次のセクションを出力する際に、直前の出力結果をテンプレートとして指定することで、コンテンツを下方へ追記していくことができます。最終的には、全てのセクションが結合された一つのExcelファイルが完成します。
プロジェクト構成と依存関係
本記事のサンプルコードはSpring Bootプロジェクトで構成されています。pom.xmlに必要な依存関係は以下の通りです。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>complex-excel-export</artifactId>
<version>1.0-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<properties>
<java.version>17</java.version>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<easyexcel.version>3.3.3</easyexcel.version>
<lombok.version>1.18.30</lombok.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- EasyExcelライブラリ -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>${easyexcel.version}</version>
</dependency>
<!-- Lombokでボイラープレートコードを削減 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
<!-- テスト用 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
データモデルの定義
複雑なExcel帳票を構成する各セクションに対応するため、以下のデータモデルクラスを定義します。それぞれのクラスは、EasyExcelのアノテーション(例: @ContentRowHeight, @ColumnWidth, @ContentLoopMerge)を利用して、特定のスタイルやマージ設定を適用します。
ロゴデータ (LogoData.java)
package com.example.model;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.alibaba.excel.annotation.write.style.ContentRowHeight;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.InputStream;
@Data
@AllArgsConstructor
@NoArgsConstructor
@ContentRowHeight(80) // 行の高さ
@ColumnWidth(40) // 列の幅
public class LogoData {
// ロゴ画像用InputStream
@ExcelProperty(value = "ロゴ", converter = ImageConverter.class) // ImageConverterは別途定義が必要
private InputStream logoImage;
}
注意: 画像を直接Excelに埋め込むには、InputStreamを扱うカスタムコンバーターが必要です。EasyExcelの最新バージョンでは組み込みの画像サポートが強化されていますが、シンプルな例としてInputStreamを使っています。実際には、ImageDataオブジェクトを使用する方が推奨されます。
会社情報データ (CompanyInfoData.java)
package com.example.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CompanyInfoData {
private String infoLine; // 会社情報の一行
}
請求書タイトルデータ (InvoiceTitleData.java)
package com.example.model;
import com.alibaba.excel.annotation.write.style.ContentLoopMerge;
import com.alibaba.excel.annotation.write.style.ContentStyle;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import static com.alibaba.excel.enums.poi.HorizontalAlignmentEnum.CENTER;
@Data
@AllArgsConstructor
@NoArgsConstructor
@ContentStyle(horizontalAlignment = CENTER) // 内容を中央揃え
public class InvoiceTitleData {
// 6列を結合するタイトル
@ContentLoopMerge(columnExtend = 6)
private String invoiceTitle;
}
請求書詳細データ (InvoiceInfoData.java)
package com.example.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class InvoiceInfoData {
private String label1;
private String value1;
private String label2;
private String value2;
}
製品リストデータ (ProductListData.java)
package com.example.model;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.alibaba.excel.annotation.write.style.ContentStyle;
import com.alibaba.excel.enums.BooleanEnum;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import static com.alibaba.excel.enums.poi.HorizontalAlignmentEnum.CENTER;
@Data
@AllArgsConstructor
@NoArgsConstructor
@ContentStyle(horizontalAlignment = CENTER, wrapped = BooleanEnum.FALSE)
@ColumnWidth(30)
public class ProductListData {
@ExcelProperty("項番")
private String itemNumber;
@ExcelProperty("製品名")
private String productName;
@ExcelProperty("説明")
private String description;
@ExcelProperty("数量")
private String quantity;
@ExcelProperty("単価") // ヘッダーは動的に変更されるため、この値はオーバーライドされる
private String unitPrice;
@ExcelProperty("合計") // ヘッダーは動的に変更されるため、この値はオーバーライドされる
private String amount;
}
Excel生成ロジックの実装
実際のExcel生成処理は、ExcelGeneratorServiceクラスにカプセル化します。このサービスでは、一時ファイルを生成し、各セクションを順番に書き込み、withTemplate()で連結していきます。また、製品リストのヘッダーはhead()メソッドを使用して動的に設定します。
ExcelGeneratorService.java
package com.example.service;
import com.alibaba.excel.EasyExcel;
import com.example.model.*;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
@Service
public class ExcelGeneratorService {
/**
* 複雑なExcel請求書を段階的に生成します。
* 各セクションを一時ファイルに書き込み、withTemplate()で連結します。
*
* @return 生成された最終Excelファイル
* @throws IOException ファイル操作エラー
*/
public File generateComplexInvoiceExcel() throws IOException {
// 一時ファイルのパスを生成
Path tempPath1 = Files.createTempFile("invoice_part1_", ".xlsx");
Path tempPath2 = Files.createTempFile("invoice_part2_", ".xlsx");
Path tempPath3 = Files.createTempFile("invoice_part3_", ".xlsx");
Path tempPath4 = Files.createTempFile("invoice_part4_", ".xlsx");
Path finalTempPath = Files.createTempFile("invoice_final_", ".xlsx");
try {
// セクション1: ロゴの挿入
// resources/logo.pngから画像を読み込む
try (InputStream logoStream = getClass().getClassLoader().getResourceAsStream("logo.png")) {
if (logoStream == null) {
throw new IOException("ロゴファイルが見つかりません: logo.png");
}
LogoData logoData = new LogoData(logoStream);
List<LogoData> logoList = List.of(logoData); // Java 9+
EasyExcel.write(tempPath1.toFile(), LogoData.class)
.sheet()
.useDefaultStyle(false)
.needHead(false)
.doWrite(logoList);
}
// セクション2: 会社情報の挿入
List<CompanyInfoData> companyInfoList = List.of(
new CompanyInfoData("ABC株式会社"),
new CompanyInfoData("東京都千代田区1-2-3"),
new CompanyInfoData("TEL: 03-XXXX-XXXX")
);
EasyExcel.write(tempPath2.toFile(), CompanyInfoData.class)
.withTemplate(tempPath1.toFile()) // 前のファイルをテンプレートとして使用
.sheet()
.useDefaultStyle(false)
.needHead(false)
.doWrite(companyInfoList);
// セクション3: 請求書タイトルの挿入
List<InvoiceTitleData> titleList = List.of(new InvoiceTitleData("請 求 書"));
EasyExcel.write(tempPath3.toFile(), InvoiceTitleData.class)
.withTemplate(tempPath2.toFile())
.sheet()
.useDefaultStyle(false)
.needHead(false)
.doWrite(titleList);
// セクション4: 請求書詳細情報の挿入
List<InvoiceInfoData> invoiceDetails = List.of(
new InvoiceInfoData("請求日:", "2023/10/26", "請求番号:", "INV-001"),
new InvoiceInfoData("支払期限:", "2023/11/26", "顧客ID:", "CUST-005"),
new InvoiceInfoData("担当者:", "山田太郎", "", "")
);
EasyExcel.write(tempPath4.toFile(), InvoiceInfoData.class)
.withTemplate(tempPath3.toFile())
.sheet()
.useDefaultStyle(false)
.needHead(false)
.doWrite(invoiceDetails);
// セクション5: 動的ヘッダーを持つ製品リストの挿入
// 動的に設定するヘッダーリスト
List<List<String>> dynamicHeaders = new ArrayList<>();
dynamicHeaders.add(List.of("項番"));
dynamicHeaders.add(List.of("商品名"));
dynamicHeaders.add(List.of("説明"));
dynamicHeaders.add(List.of("数量"));
dynamicHeaders.add(List.of("単価 (円)")); // 動的に変更されるヘッダー
dynamicHeaders.add(List.of("金額 (円)")); // 動的に変更されるヘッダー
List<ProductListData> productItems = List.of(
new ProductListData("1", "ソフトウェアライセンス", "年間契約", "1", "100,000", "100,000"),
new ProductListData("2", "サポートサービス", "月額", "3", "10,000", "30,000"),
new ProductListData("3", "コンサルティング", "時間", "5", "15,000", "75,000")
);
EasyExcel.write(finalTempPath.toFile(), ProductListData.class)
.head(dynamicHeaders) // 動的ヘッダーを設定
.withTemplate(tempPath4.toFile())
.sheet()
.useDefaultStyle(true) // データ行にデフォルトスタイルを適用
.needHead(true) // ヘッダー行を出力
.doWrite(productItems);
return finalTempPath.toFile();
} finally {
// 中間ファイルをクリーンアップ
Files.deleteIfExists(tempPath1);
Files.deleteIfExists(tempPath2);
Files.deleteIfExists(tempPath3);
Files.deleteIfExists(tempPath4);
}
}
}
ロゴ画像について:resourcesディレクトリにlogo.pngファイルを配置してください。getClass().getClassLoader().getResourceAsStream("logo.png")でその画像ファイルを取得しています。
Web APIでのExcelダウンロード
最後に、生成したExcelファイルをクライアントにダウンロードさせるためのSpring Boot RESTコントローラを作成します。コントローラはExcelGeneratorServiceを呼び出し、生成されたファイルをHTTPレスポンスとしてストリーミングします。
ExcelDownloadController.java
package com.example.controller;
import com.example.service.ExcelGeneratorService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/excel")
public class ExcelDownloadController {
@Autowired
private ExcelGeneratorService excelGeneratorService;
@GetMapping("/download-invoice")
public ResponseEntity<byte[]> downloadComplexInvoice() throws IOException {
File excelFile = null;
try {
// サービスからExcelファイルを生成
excelFile = excelGeneratorService.generateComplexInvoiceExcel();
String filename = "請求書_" + System.currentTimeMillis() + ".xlsx";
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8.toString()).replace("+", "%20");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
headers.setContentDispositionFormData("attachment", encodedFilename);
headers.setContentLength(excelFile.length());
// ファイルの内容をバイト配列として読み込む
try (InputStream is = new FileInputStream(excelFile)) {
byte[] fileContent = is.readAllBytes();
return new ResponseEntity<>(fileContent, headers, HttpStatus.OK);
}
} finally {
// ダウンロード後、一時ファイルを削除
if (excelFile != null && excelFile.exists()) {
excelFile.delete();
}
}
}
}
補足
- EasyExcelの現在のバージョンでは、エンティティクラスのプロパティとして
Object型を直接サポートしていません。これは、各セルデータが特定の型(String, Numberなど)に厳密にマッピングされるためです。そのため、複雑なレイアウトを表現するには、本記事で示したように複数の専用データモデルクラスを作成し、それぞれを独立したセクションとして扱う必要があります。 - 本記事のコードでは一時ファイルを
Files.createTempFileで生成していますが、これはアプリケーションが停止すると自動的に削除される一時ファイルシステム上に作成されます。本番環境で長期間保存する必要がある場合や、特定のディレクトリに保存したい場合は、ファイルパスの管理方法を適宜調整してください。