Jersey MVCでThymeleafを使おう!

この記事について

JJUG CCC 2016 Spring「ネクストStruts/Seasr2としてのJava EEアクションベースMVC入門」の補足記事です。

Jersey MVCで、JSPの代わりのビューとしてThymeleafを使う方法を解説します。

Jersey MVCの基本については、下記の記事を参照してください。

Java EE 7でもアクションベースMVC! -MVC 1.0への移行を睨んだJersey MVCの活用- #javaee - Java EE 事始め!

環境

  • Payara Web ML 4.1.1.162
  • Thymeleaf 2.1.4.RELEASE

ソースコード

jjug-action-based-mvc/jjug-jersey-mvc at master · MasatoshiTada/jjug-action-based-mvc · GitHub

TemplateProcessorクラスを作る

Jersey MVCで公式にサポートされているビューはJSP/Freemarker/Mustacheの3種類です。

それ以外のビューを使うには、Jersey MVCで提供されているTemplateProcessorインタフェースを実装する必要があります。

Jersey MVCには、TemplateProcessorを実装したAbstractTemplateProcessorという抽象クラスがありますので、これを継承して作成します。

クラスの作成、リクエストとレスポンスの取得

@Provider
public class ThymeleafTemplateProcessor extends AbstractTemplateProcessor<String> {

    @Inject
    private javax.inject.Provider<Ref<HttpServletRequest>> requestProviderRef;
    @Inject
    private javax.inject.Provider<Ref<HttpServletResponse>> responseProviderRef;
    // 後に続く

まず、AbstractTemplateProcessorを継承し、クラスには@Providerを付加します。

次に、フィールドインジェクションでHttpServletRequestHttpServletResponseを取得するのですが、@Providerが付加されたクラスはJAX-RSの仕様では、デフォルトでシングルトンと定められています。

なので、直接HttpServletRequestHttpServletResponseをインジェクションすると、スレッドセーフでない可能性があると考えました。

僕はスレッドセーフか否かといったあたりはあまり詳しくないので、直接取得でも大丈夫な可能性もありますが・・・。

そこで、Jersey MVCJSPTemplateProcessorである下記のクラスを参考に作りました。

jersey/JspTemplateProcessor.java at 2.x · jersey/jersey · GitHub

これで本当にスレッドセーフになっているのかは、僕には判断がつかないので、この点はご自分で判断してください。

TemplateEngineの作成

    // 続き
    private TemplateEngine templateEngine;

    @Inject
    public ThymeleafTemplateProcessor(Configuration config, ServletContext servletContext) {
        super(config, servletContext, "html", "html");
        TemplateResolver templateResolver = new ServletContextTemplateResolver();
        templateResolver.setPrefix((String) config.getProperty(MvcFeature.TEMPLATE_BASE_PATH));
        // setSuffix()は指定しない
        templateEngine = new TemplateEngine();
        templateEngine.setTemplateResolver(templateResolver);
    }
    // 後に続く

フィールドにTemplateEngine(Thymeleafのクラス)を宣言し、コンストラクタ内で初期化します。

ポイントは、setSuffix()という、通常は拡張子を指定するメソッドを呼んでいないことです。

これは、MVC 1.0との互換性のために、コントローラーメソッド拡張子を指定したいからです。

メソッドのオーバーライド

    // 続き
    @Override
    protected String resolve(String templatePath, Reader reader) throws Exception {
        return templatePath;
    }

    @Override
    public void writeTo(String templateReference, Viewable viewable, MediaType mediaType,
            MultivaluedMap<String, Object> httpHeaders, OutputStream out) throws IOException {
        HttpServletRequest httpServletRequest = requestProviderRef.get().get();
        HttpServletResponse httpServletResponse = responseProviderRef.get().get();
        httpServletResponse.setCharacterEncoding("UTF-8");
        WebContext webContext = new WebContext(
                httpServletRequest, httpServletResponse, 
                super.getServletContext(), httpServletRequest.getLocale());
        Object model = viewable.getModel();
        if (model instanceof Map) {
            Map<String, Object> variables = (Map<String, Object>) model;
            webContext.setVariables(variables);
        } else {
            Map<String, Object> variables = new HashMap<>();
            variables.put("model", model);
            webContext.setVariables(variables);
        }
        try (Writer writer = new OutputStreamWriter(out)) {
            templateEngine.process(viewable.getTemplateName(), webContext, writer);
        }
    }
}

resolve()は、引数のtemplatePathをそのまま返せばOKです。

writeTo()が、最終的にレスポンスを書き込むメソッドです。

webContext.setVariables()は、ビューに渡す値をマップ形式で指定します。

コントローラーメソッドViewableに渡された値は、viewable.getModel()で取得できます。

この値がMapだった場合は、それを直接webContext.setVariables()に渡し、そうでない場合はJersey MVCデフォルトのmodelという名前でMapにputしています。

こうすることで、ビューから値を参照するときに、${model.employee.id}のようにmodelを付けなくてもいいようにしています。

writeTo()の最後では、templateEngine.process()を呼んでいます。これが、Thymeleafのレスポンスを書き出しています。

自作Viewableクラスの作成

これはThymeleafを使う上では必須ではないのですが、MVC 1.0との差異をなるべく吸収するために作りました。

前述のブログの通り、Jersey MVCには「/」で始める絶対パスと「/」で始めない相対パスがあり、それぞれビューの保存フォルダが異なります。

ビューの保存フォルダがMVC 1.0とほぼ同じなのは絶対パスですが、MVC 1.0(というか現在のOzark)では「/」で始めるとコンテキストルートからのパスになってしまいます。

なので、「/」で始めない相対パスで指定しつつ、かつビューの保存フォルダが絶対パスになるように、Viewableのサブクラスを自作しました。

public class ThymeleafViewable extends Viewable {

    public ThymeleafViewable(String templateName) throws IllegalArgumentException {
        super(templateName);
    }

    public ThymeleafViewable(String templateName, Map<String, Object> models) throws IllegalArgumentException {
        super(templateName, models);
    }

    @Override
    public Map<String, Object> getModel() {
        return (Map<String, Object>) super.getModel();
    }

    @Override
    public String getTemplateName() {
        String templateName = super.getTemplateName();
        if (templateName.startsWith("/") == false) {
            return "/" + templateName;
        } else {
            return templateName;
        }
    }

    @Override
    public boolean isTemplateNameAbsolute() {
        return true;
    }
}

コードを読んでいただければ大体わかると思いますが、getTemplateName()が必ずスラッシュで始まるパス(=絶対パス)を返すようにし、かつisTemplateNameAbsolute()trueを返して、Jersey MVC絶対パスと認識させます。

また、コンストラクタはビューのパスとMapを引数で受け取るようにします。

コントローラーメソッド

    @GET
    @Path("index")
    public ThymeleafViewable index() throws Exception {
        return new ThymeleafViewable("employee/index.html");
    }
    @GET
    @Path("result")
    public ThymeleafViewable result(@BeanParam EmployeeIdForm form) throws Exception {
        Integer id = Integer.valueOf(form.getId());
        Employee employee = employeeService.findByEmpId(id).orElse(null);
        HashMap<String, Object> models = new HashMap<>();
        models.put("employee", employee);
        return new ThymeleafViewable("employee/result.html", models);
    }

パスは「/」で始めない、MVC 1.0と同じ形式にしています。

また、拡張子「.html」を明示的に指定しています。これもMVC 1.0と同じです。

ビュー

<p th:if="${employee == null}">該当する社員はいませんでした。</p>
<table border="1" th:unless="${employee == null}">
    <tr><th>社員ID</th><th>氏名</th><th>入社年月日</th><th>部署ID</th><th>部署名</th></tr>
    <tr th:object="${employee}">
        <td th:text="*{empId}">99999</td>
        <td th:text="*{name}">Taro Yamada</td>
        <td th:text="*{joinedDate}">2020-01-01</td>
        <td th:text="*{department.deptId}">99</td>
        <td th:text="*{department.name}">Admin</td>
    </tr>
</table>

modelを指定せず、直接employeeで値を参照していることが分かります。

これにより、Jersey MVCからMVC 1.0に変更した際に、ビューを全く変更しなくて済むようにしています。

まとめ

TemplateProcessorと自作Viewableにより、極力MVC 1.0と同じ感覚で、ビューとコントローラーを実装できるようにしました。

参考にしたWebサイト

@bufferingsさんのブログ。

jersey-thymeleaf using ViewProcessor - Mitsuyuki.Shiiba

@backpaper0さんのコード。

sealion/ThymeleafProvider.java at master · backpaper0/sealion · GitHub