jXLSを使ったExcelテンプレートをSpring MVCのビューとして利用する方法
jXLSを使ってJSPと同様の方法でExcelファイルを生成する
業務アプリケーションでは好むと好まざるとにかかわらず、Excelファイルの入出力を行う必要がある場合が多くあります。JavaからExcelファイルの読み書きを行うOSSのライブラリーはいくつかありますが、中でもApache POIが有名です。以前は不安定だったり、機能が限られていたりしたところもあるのですが、長い時間をかけて現在ではかなり安定して使えるライブラリーになっています。
ただし、POIの最大の問題点は、提供されているAPIがあまりにも低水準であることです。単純なExcelファイルの帳票を作成するだけでも、多重ループを書きながらセルごとに値を転記するような面倒なコーディングが必要になります。
Excelのテンプレートファイルを作成しておき、そこに値をバインドすることでExcel帳票を生成するようなケースでは、jXLSというライブラリーを利用すると便利です。*1コンセプトは非常にわかりやすく、ちょうどJSPファイルを使って画面テンプレートを定義するのと同じような感覚で、Excelのテンプレートを作成し、そこに動的な値をバインドさせることができるようになっています。
JSPと同じ方法でExcelファイル生成を行う方法を検討する(外部仕様)
Spring MVCでは標準で、POIを使ったExcelファイル生成用のビュークラス(AbstractExcelView)が提供されていますが、このクラスは抽象クラスで、各Excelファイルごとにサブクラスを作成する必要があります。jXLSではJSPと同じプログラミングモデルでExcelファイルを生成できるのですから、できるだけJSPと同様の方法で開発できたらよいですね。ここでは、Spring MVCにちょっとした拡張することで、そういったシームレスな連携を可能にする方法を紹介します。
JSPと同じ方法でExcelを作成するために、個々のテンプレートとなるExcelファイルを以下のようにviewフォルダに配置することにします。
次に、コントローラーで、JSPビューに表示したいのか、Excelビューに表示したいのかを識別するため、@Jxlsというアノテーションを作成し、アクションメソッドをマーク付けできるようにします。また、@Jxlsではfilename属性でダウンロードするファイル名を指定できるようにします。
@Controller @RequestMapping("/jxls") public class JxlsController { @RequestMapping("/department") @Jxls(filename = "部署レポート.xls") public Department department() { return buildSampleDepartment(); } ... }
このようにしたら、通常のdepartment.jspではなくdepartment.xlsのテンプレートがダウンロードさせるようにしたいと思います。
なお、念のため、上記のコントローラの記述方法はCoCを使った簡易記法です。実際には、以下と等価であると考えてください。つまり、コントローラーのメソッドの戻り値でString以外のオブジェクトを返した場合、ビュー名(/jxls/department)はリクエストURLから自動的に決まり、モデルのキー(department)もDepartmentという型から類推されます。
@Controller @RequestMapping("/jxls") public class JxlsController { @RequestMapping("/department") @Jxls(filename = "部署レポート.xls") public String department(Model model) { model.addAttribute("department", buildSampleDepartment()); return "/jxls/department"; } ... }
Spring MVCを拡張する独自のViewとViewResolverの実装を作成する
以上のような動作を実現させるために、以下のことが必要になります。
- Jxlsに基づいてテンプレートExcelからExcelファイルを生成するビュークラスをJxlsViewとして作成する
- @Jxlsというアノテーションがメソッドについている場合にJxlsViewに処理を振り分けるJxlsViewResolverを作成する
まず、このようにSpring MVCを拡張したい場合、最初から提供されているクラスで参考になりそうなものはないか探すのが第一歩です。たとえば、IDEのクラス階層表示機能で、ViewResolverの階層を表示してみると以下のようになります。
ここは、ある程度フレームワーク拡張を行うための勘が必要になるところですが、JSPのビューを処理しているInternalResourceViewResolverやPDFのファイルを処理しているRasperReportViewResolverあたりの実装が参考になるのではないかとあたりをつけます。これらのビューはともにUrlBasedViewResolverという抽象クラスを継承して作成されており、コンテキストルートに格納されたファイルに対するURLから画面を生成させるビューとなっています。そこで、これらのクラスの兄弟クラスとして、JxlsViewResolverを以下のように作成すればよいことがわかります。
public class JxlsViewResolver extends UrlBasedViewResolver { public JxlsViewResolver() { setViewClass(JxlsView.class); } @Override protected Class<JxlsView> requiredViewClass() { return JxlsView.class; } @Override protected boolean canHandle(String viewName, Locale locale) { Method currentHandlerMethod = Controllers.getCurrentHandlerMethod(); return currentHandlerMethod != null && currentHandlerMethod.isAnnotationPresent(Jxls.class) && super.canHandle(viewName, locale); } }
なお、このクラスでは、Spring MVCでコントローラーのリクエストハンドラメソッドのメタ情報を記録する方法 - 達人プログラマーを目指してで紹介したテクニックを使って、コントローラーのメソッドのアノテーションを取得しています。canHandle()メソッドでは、コントローラーに@Jxlsアノテーションが付いている場合のみ、trueを返すことで、それ以外の場合はその他のViewResolverに処理が委譲されるように実装しています。
次に、JxlsViewの実装例を以下に示します。
import java.io.IOException; import java.io.UnsupportedEncodingException; import java.lang.reflect.Method; import java.net.URLEncoder; import java.util.Locale; import java.util.Map; import javax.servlet.ServletOutputStream; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import net.sf.jxls.transformer.XLSTransformer; import org.apache.poi.hssf.usermodel.HSSFWorkbook; import org.apache.poi.poifs.filesystem.POIFSFileSystem; import org.springframework.core.io.Resource; import org.springframework.core.io.support.LocalizedResourceHelper; import org.springframework.web.servlet.support.RequestContextUtils; import org.springframework.web.servlet.view.AbstractUrlBasedView; import com.sun.xml.internal.messaging.saaj.packaging.mime.internet.MimeUtility; public class JxlsView extends AbstractUrlBasedView { private static final String CONTENT_TYPE = "application/vnd.ms-excel"; private static final String EXTENSION = ".xls"; public JxlsView() { setContentType(CONTENT_TYPE); } @Override protected boolean generatesDownloadContent() { return true; } @Override protected final void renderMergedOutputModel(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws Exception { HSSFWorkbook workbook = createWorkbook(request); buildExcelDocument(model, workbook, request, response); setupHeader(model, request, response); doRender(response, workbook); } protected void setupHeader(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws UnsupportedEncodingException { // Set the content type. response.setContentType(getContentType()); setupHeaderForDownloadFilename(model, request, response); } protected void setupHeaderForDownloadFilename(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws UnsupportedEncodingException { Method currentHandlerMethod = Controllers.getCurrentHandlerMethod(); assert currentHandlerMethod != null; Jxls jxls = currentHandlerMethod.getAnnotation(Jxls.class); assert jxls != null; String filename = jxls.filename(); if (model.containsKey(filename)) { filename = model.get(filename).toString(); } response.setHeader("Content-Disposition", "attachment; filename=" + encodeFilename(filename, request)); } // TODO extract a strategy class. private String encodeFilename(String filename, HttpServletRequest request) throws UnsupportedEncodingException { if (request.getHeader("User-Agent").indexOf("MSIE") == -1) { return MimeUtility.encodeWord(filename, "ISO-2022-JP", "B"); // FIXME Japanese encoding is hard coded. } else { // for legacy IE6 return URLEncoder.encode(filename, "UTF-8"); } } private HSSFWorkbook createWorkbook(HttpServletRequest request) throws Exception { HSSFWorkbook workbook; if (getUrl() != null) { workbook = getTemplateSource(getUrl(), request); } else { workbook = new HSSFWorkbook(); logger.debug("Created Excel Workbook from scratch"); } return workbook; } private void doRender(HttpServletResponse response, HSSFWorkbook workbook) throws IOException { // Flush byte array to servlet output stream. ServletOutputStream out = response.getOutputStream(); workbook.write(out); out.flush(); } protected HSSFWorkbook getTemplateSource(String url, HttpServletRequest request) throws Exception { LocalizedResourceHelper helper = new LocalizedResourceHelper(getApplicationContext()); Locale userLocale = RequestContextUtils.getLocale(request); Resource inputFile = helper.findLocalizedResource(url, url.endsWith(EXTENSION) ? "" : EXTENSION, userLocale); POIFSFileSystem fs = new POIFSFileSystem(inputFile.getInputStream()); return new HSSFWorkbook(fs); } protected void buildExcelDocument(Map<String, Object> model, HSSFWorkbook workbook, HttpServletRequest request, HttpServletResponse response) throws Exception { XLSTransformer transformer = new XLSTransformer(); transformer.transformWorkbook(workbook, model); } }
このクラスはAbstractUrlBasedViewを継承する必要があるのですが、Javaでは多重継承やMixinができないため、残念ながらAbstractExcelViewの実装を一部コピペすることで対応しています。このクラスでもっとも大切なポイントはbuildExcelDocument()メソッドの部分で、ここでjXLSを使ってExcelテンプレートに対する値の書き込みを実行しています。
Spring MVCのBean定義ファイルの設定
以上で作成したViewResolverを使うように、Bean定義ファイルに以下を追記します。
<bean class="com.github.ryoasai.spring_jxsl.JxlsViewResolver"> <property name="prefix" value="/WEB-INF/views/" /> <property name="suffix" value=".xls" /> <property name="order" value="1" /> </bean> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/" /> <property name="suffix" value=".jsp" /> <property name="order" value="2" /> </bean>
ここでは、Chain of Responsibilityパターンを使って、先ほど定義したExcel用のビューレゾルバーとJSP用のビューレゾルバーを併用する設定となっています。order属性を指定することがポイントで、この数値が低いほどビューが優先的に解決されるようになります。
なお、以上のサンプルコードを含めて、ソースコードはGitHub - ryoasai/spring-mvc-exts: Some extensions to the Spring MVC web application framework.に公開していますので、どうぞご自由にご利用ください。
*1:似たようなコンセプトのライブラリーとしては、国産のFisshplateというのもあります。私はまだ試していませんが。