PDFで帳票出力となると苦手意識しかありませんでした。
コントローラにView側の事情をたくさん書き込むイメージがあったので。
普段Spring-MVC使いとしてはView側の事情をあまりコントローラに書きたくない。
どうにかして、出力がHTMLであろうがPDFであろうが、表示に必要なデータをModel
に詰め込んでコントローラの役割は終わりにしたい。
ということで
- 手軽にPDFをテンプレート管理できる
- コントローラが汚れない
- Spring-MVCと相性が良い
方法が無いか模索してみました。
PDF出力用ライブラリを使用する
spring-webmvc
にJasperReportsPdfView.java
があったので試してみる。
JasperReports
最初に言っておきますけど、これは解決策としては選びませんでした。
JasperReportsで帳票出力してみたを参考に一応試してみたのですが、次の理由から自分には合っていませんでした。
- データが追加になったらデータアダプタから作ってテンプレートに埋め込む必要がある
- Jaspersoft Studioが重いからEclipseとかと同時に開いているとPCが重い
- ならばとEclipseにJaspersoft StudioのPlugin入れたけどPluginで細かい設定ができない
(何故かJaspersoft Studio単体で表示されるViewがEclipseからだと表示できない) - 細かい設定用のViewから色々値いじったりするのが大変で、直接XML編集しようとしたら思うようにいかない
(勝手に発行される要素ごとのUUIDが邪魔してコピペしにくいし、マークアップ苦手。。)
一応、Bean形式も試してみたけど別プロジェクトのクラスパスが通らなくて断念。(EclipseのPluginだとクラスパス通った)
クラスパス通さなくても良いCSV形式も試したけど、アダプタの生成がめんどくさいから断念。
そしてJasperReportsを諦めた最大の理由が
時間経ったらJasperReportsの使い方完全に忘れた\(^o^)/
・・・別の方法考えよ。
ExcelからPDFに変換
以前、Excelに関してはjXLS@1.xでテンプレート管理した経験があった。
これに関してはダウンロード/アップロードどちらも優秀でExcelへのデータ展開/抽出が簡単に書ける。
Excelだったら良かったのに。。
と考えたら思った。
一回Excelに展開して、それをPDFに変換する方法は無いかと。
JodConverter
見つけた。OpenOfficeかLibreOfficeさえあればあらゆるドキュメントを変換可能らしい。
まぁ、今回はとりあえずExcelをPDFに変換したい。
ということでネットで調べるとLibreOfficeの方が使いやすいということだった、かつ普段CSVファイルのViewerとして使っているのでこちらで試してみることにした。
事前準備
まずは必要なライブラリをpom.xmlに追加する。
JodConverterをMavenセントラルで検索してみる。
すると開発が2.2.1で止まっている。
あれ?ネットでは3.x系のことが書かれていたのに。
公式を見てみると、
These pages are for JODConverter v2.x; for the new v3.0 please see http://code.google.com/p/jodconverter/.
と、3系はこっちを見てね。と書かれていた。
3系を使いたい。
何故かと言うと2系ではopenofficeのサービスはアプリケーションで管理できないからだ。
デーモン化するためのスクリプトとかめんどくさいから書きたくないし、管理対象に入れたくない。
だったら、アプリケーション起動したら同時にopenofficeのサービス起動して、アプリケーション落としたら同時にサービス落としてくれる3系の方が絶対に良い。
サーバでGUIのopenofficeとか絶対に使わないし。
code.google.comで公開されているソースのコミッターのGithubリポジトリを見つけた。
https://github.com/mirkonasato/jodconverter
3.0-beta-4リリースコミットがあったから、そこからローカルリポジトリにインストールしてみる。
$ git clone https://github.com/mirkonasato/jodconverter
$ git reset --hard 5bc0fdc
$ git log --oneline -n 1
5bc0fdc release 3.0-beta-4
$ cd jodconverter-core
$ mvn install -DskipTests=true
これでローカルリポジトリにインストール完了。
他の必要なjarも入ったはず。
で、テストコードのpom.xmlにインストールしたjarを追加。
<dependencies>
<dependency>
<groupId>org.artofsolving.jodconverter</groupId>
<artifactId>jodconverter-core</artifactId>
<version>3.0-beta-4</version>
</dependency>
</dependencies>
テストコードで動作確認してみる。
Excelはこんな感じのを用意。
LibreOfficeで変換するから一応Calcで生成してExcel形式で保存した。
みんなの味方!Excel方眼紙!w!
// これでopenofficeのサービスの起動と停止をやってくれる
@Bean(initMethod = "start", destroyMethod = "stop")
public OfficeManager officeManager() {
return new DefaultOfficeManagerConfiguration().buildOfficeManager()
}
@Bean
public OfficeDocumentConverter converter() {
return new OfficeDocumentConverter(officeManager());
}
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = PdfConvertConfig.java)
public class PdfConvertTest {
@Autowired
private OfficeDocumentConverter converter;
@Test
public void testConvertPdf() {
File excelFile = new File("***.xlsx");
assertThat(excelFile.exists(), is(true)); // 存在する
File pdfFile = new File("***.pdf");
assertThat(pdfFile.exists(), is(false)); // 存在しない
// 変換!
converter.convert(excelFile, pdfFile);
assertThat(pdfFile.exists(), is(true)); // PDFが出来た
}
実行!
したらエラーになった。。
Caused by: java.lang.IllegalStateException: officeHome not set and could not be auto-detected
at org.artofsolving.jodconverter.office.DefaultOfficeManagerConfiguration.buildOfficeManager(DefaultOfficeManagerConfiguration.java:163)
うーん、どうもLibreOfficeがインストールされている場所を自動特定しようとして失敗したみたいですね。
どこを見て何が無いと判断したのだろう。
private File officeHome = OfficeUtils.getDefaultOfficeHome();
とあったのでOfficeUtils.javaを見てみる。
ん? soffice.bin
なんてあるのか?と自分のPCを探ってみる。
$ pwd
/Applications/LibreOffice.app/Contents/MacOS
$ ls
gengal regmerge soffice unoinfo uri-encode
gengal.bin regview ui-previewer unopkg xpdfimport
python senddoc uno urelibs
soffice
はあるから、とりあえずコピって名前変えてみるか。
$ cp soffice soffice.bin
$ ls
gengal regmerge soffice uno urelibs
gengal.bin regview soffice.bin unoinfo uri-encode
python senddoc ui-previewer unopkg xpdfimport
JodConverterはどうもLibreOffice3系を前提に作られているみたい。
その時はsoffice.bin
の名前だったようだ。
ということはこれも開発ストップしているのかな。
ちなみに今のLibreOfficeの最新版は5.2.3。
で実行環境は
$ ./soffice --version
LibreOffice 5.1.4.2 f99d75f39f1c57ebdd7ffc5f42867c12031db97a
ともかく、もう一回実行。
・・・成功!
PDF見てみる。
まぁ、印刷設定とかしてないからこんなもんかな。
LibreOfficeのCalcの方で[書式] -> [ページ]
でヘッダー/フッター外して配置を真ん中にして、縮尺をページの横幅に合わせる。
で、実行するとこんな感じになります。
枠線がうまく表示されない場合は一度解除してから再度引き直せばうまく表示されるみたいです。
PDFの実験はこれで終わり。
jXLS
先程出てきたjXLSを使って次はテンプレートを作りたいと思います。
理想はFreeMarkerのように書けること。
jXLSは以前使っていた1.x系ではなく、現在の2.x系を使います。
実は1.x系はExcel2003フォーマットまでしか対応していないのです。
事前準備
まずは動作確認用のテストコードを作ります。
<dependency>
<groupId>org.jxls</groupId>
<artifactId>jxls</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>org.jxls</groupId>
<artifactId>jxls-poi</artifactId>
<version>1.0.9</version>
</dependency>
@Value
@Builder
public static class Company {
String name;
String address;
String tel;
}
@Value
@Builder
public static class OrderItem {
String name;
Integer price;
Integer quantity;
}
@Test
@SneakyThrows
public void test() {
File templateFile = new File("/****/test_template.xlsx");
File outputFile = new File("/****/test_out.xlsx");
Company orderCompany = Company.builder()
.name("株式会社 zzzzzzzzz")
.build();
Company dealerCompany = Company.builder()
.name("有限会社yyyyy")
.address("東京都品川区xxxxxxxxxx 1-1-1")
.tel("03-aaaa-bbbb")
.build();
List<OrderItem> orderItems = ImmutableList.<OrderItem> builder()
.add(OrderItem.builder()
.name("はじめてのSpring")
.price(3421)
.quantity(18)
.build())
.add(OrderItem.builder()
.name("はじめてのJava")
.price(4123)
.quantity(10)
.build())
.build();
Context context = new Context();
context.putVar("orderCompany", orderCompany);
context.putVar("dealerCompany", dealerCompany);
context.putVar("orderItems", orderItems);
try (InputStream in = new FileInputStream(templateFile);
OutputStream out = new FileOutputStream(outputFile)) {
JxlsHelper.getInstance().processTemplate(in, out, context);
}
}
Excelテンプレートの用意
jXLSの公式サイトを参考に先程のExcelファイルに変数やループのロジックを埋め込んでいきます。
※ 計算式のところはわかりやすいように形式を「文字列」にしていますが、「通貨」にしておきます
※ 「No」列は「${orderItems.indexOf(item)+1}
」としています。 =row()-11
が評価されなかったため
うまくいったー!
と、書いていますが、実は四苦八苦しまして、、
というのもArea情報をうまく読み取ってくれない状況に陥りました。
デバッグしているとどうも、行の終わりを空白行で判断しているみたいで2行目が邪魔してうまくArea情報を判定してくれませんでした。
なので、2行目を消しました。
Spring-MVCへの適用
さて、ここまで来たら簡単。
あとはSpring-MVCに適用させるためにViewResolver
とView
を実装するだけ!
@Slf4j
public class OpenOfficePdfView extends AbstractTemplateView {
/** PDFファイル拡張子 */
public static final String PDF_EXTENTION = ".pdf";
private OfficeDocumentConverter officeDocumentConverter;
public OpenOfficePdfView() {
setContentType("application/pdf");
}
@Override
protected void initServletContext(ServletContext servletContext) {
super.initServletContext(servletContext);
this.officeDocumentConverter = BeanFactoryUtils.beanOfTypeIncludingAncestors(getApplicationContext(),
OfficeDocumentConverter.class);
}
@Override
public boolean checkResource(Locale locale) {
return getTemplateResource(locale).exists();
}
@Override
protected void renderMergedTemplateModel(Map<String, Object> model, HttpServletRequest request,
HttpServletResponse response) throws Exception {
// テンプレート
Resource templateResource = getTemplateResource(RequestContextUtils.getLocale(request));
Resource excelResource = null;
Resource pdfResource = null;
try {
// Excel展開用ファイル
excelResource = createTemporaryResource(getTemplateExtension());
// Modelを展開
processExcelTemplate(model, templateResource, excelResource);
// PDFファイルリソース
pdfResource = createTemporaryResource(PDF_EXTENTION);
// ExcelファイルをPDFに変換
this.officeDocumentConverter.convert(excelResource.getFile(), pdfResource.getFile());
try (InputStream pdfFileInputStream = pdfResource.getInputStream()) {
// PDFファイルをレスポンスにコピー
IOUtils.copy(pdfFileInputStream, response.getOutputStream());
}
} finally {
if (excelResource != null) {
if (FileUtils.deleteQuietly(excelResource.getFile()) == false) {
log.warn("Failed to remove temporary excel file. [resource:[{}]]", excelResource);
}
}
if (pdfResource != null) {
if (FileUtils.deleteQuietly(pdfResource.getFile()) == false) {
log.warn("Failed to remove temporary pdf file. [resource:[{}]]", pdfResource);
}
}
}
}
protected Resource getTemplateResource(Locale locale) {
LocalizedResourceHelper helper = new LocalizedResourceHelper(getApplicationContext());
String extension = getTemplateExtension();
String fileName = StringUtils.removeEnd(getUrl(), extension);
return helper.findLocalizedResource(fileName, extension, locale);
}
protected void processExcelTemplate(Map<String, Object> model, Resource templateResource,
Resource outputResource)
throws IOException {
try (InputStream templateInputStream = templateResource.getInputStream();
OutputStream outputStream = new FileOutputStream(outputResource.getFile())) {
JxlsHelper.getInstance().processTemplate(templateInputStream, outputStream, new Context(model));
}
}
protected String getTemplateExtension() {
return FilenameUtils.getExtension(getUrl());
}
private static FileSystemResource createTemporaryResource(String extention) {
return new FileSystemResource(
FileUtils.getTempDirectoryPath() + File.separator + UUID.randomUUID() + extention);
}
}
public class OpenOfficePdfViewResolver extends AbstractTemplateViewResolver {
public OpenOfficePdfViewResolver() {
setViewClass(requiredViewClass());
}
@Override
protected Class<OpenOfficePdfView> requiredViewClass() {
return OpenOfficePdfView.class;
}
}
@Bean
public OpenOfficePdfViewResolver pdfViewResolver() {
OpenOfficePdfViewResolver viewResolver = new OpenOfficePdfViewResolver();
viewResolver.setPrefix("classpath:templates/");
viewResolver.setSuffix(".xlsx");
viewResolver.setOrder(-1); // 他のViewResolverより優先する
}
// 複数の`ViewResolver`でも大丈夫か確認する。
@Bean
public FreeMarkerViewResolver ftlViewResolver() {
FreeMarkerViewResolver viewResolver = new FreeMarkerViewResolver();
viewResolver.setPrefix("classpath:templates/");
viewResolver.setSuffix(".ftl");
}
@Controller
public class OrderController {
@Autowired
OrderService orderService;
// application/pdf
@GetMapping(path = "/order.pdf", produces = "application/pdf")
public String viewPdf(Model model) {
loadAndBindData(model);
return "order"; // src/main/resources/templates/order.xlsx
}
// text/html
@GetMapping("/order")
public String viewHtml(Model model) {
loadAndBindData(model);
return "order"; // src/main/resources/templates/order.ftl
}
void loadAndBindData(Model model) {
model.addAttribute("orderCompany", orderService.getOrderCompany());
model.addAttribute("dealerCompany", orderService.getDealerCompany());
model.addAttribute("orderItems", orderService.getOrderItems());
}
}
これでPDFがブラウザで表示されます。
/order
は FTLを用意していたら text/html
の形式で表示されます。
コントローラの中身はViewの生成ロジックに依存せず、リクエストを受け付けてデータをセットするだけになりました。
まとめ
今回使用したのは次の通り。
ライブラリ
- jodconverter-core@3.0-beta-4
- jxls@2.3.0
- jxls-poi@1.0.9
- spring-xxx@4.3.3
(実際はspring-boot@1.4.1を使いましたがコード例はbootを意識してません) - lombok@1.16.10
- guava@19.0
- commons-lang3@3.4
- commons-io@2.5
ソフトウェア
- LibreOffice@5.1.4.2
ちなみにLibreOffice5系で作ったテンプレートを4系でコンバートすると枠線が太くなるので要注意です。
ちなみにJasperReportsは現在も開発が継続していて、今回の例もJasperReportsを使用すると、こんな色々ライブラリやソフトウェアを追加する必要は無いです。
一長一短ですね。