JavaEE8 - MVC 1.0 (Ozark M2) で Thymeleaf を使う

f:id:Naotsugu:20170301230553p:plain

JavaEE8 で仕様検討が進んでいる MVC1.0 (Model-View-Controller API 1.0 - JSR 371) の参照実装である Ozark は既に M2 が出ていて簡単に試すことができます。

テンプレートエンジンも Extension として、Mustache、Freemarker、Velocity、Thymeleaf などが提供されています。

以下の依存を追加すれば JavaEE7 の Glassfish4 で使うことができます。

compile 'org.glassfish.ozark:ozark:1.0.0-m02'

テンプレートエンジンに Thymeleaf を使いたい場合は Extension を追加するだけです。

compile 'org.glassfish.ozark.ext:ozark-thymeleaf:1.0.0-m02'

残念ながら、この Extension は Thymeleaf 2系です。

なお、ここに記載するのはマイルストーンリリース2についてですのでご注意ください。

Thymeleaf の Extension

Thymeleaf の Extension は大したことはやっていません。

Thymeleaf のリゾルバを作り、Thymeleaf のエンジンを返す、以下のようなプロデューサが定義されています。

    @Produces
    @ViewEngineConfig
    public TemplateEngine getTemplateEngine() {
        TemplateResolver resolver = new ServletContextTemplateResolver();
        TemplateEngine engine = new TemplateEngine();
        engine.setTemplateResolver(resolver);
        return engine;
    }

そして、テンプレート処理を行う ViewEngine が以下のように定義されています。

@ApplicationScoped
public class ThymeleafViewEngine extends ViewEngineBase {

    // ...

    @Override
    public void processView(ViewEngineContext context) throws ViewEngineException {
        try {
            HttpServletRequest request = context.getRequest();
            HttpServletResponse response = context.getResponse();
            WebContext ctx = new WebContext(request, response, servletContext, request.getLocale());
            ctx.setVariables(context.getModels());
            engine.process(resolveView(context), ctx, response.getWriter());
        } catch (IOException e) {
            throw new ViewEngineException(e);
        }
    }
}

Thymeleaf の Extension は、大きくはこれだけです。

ViewEngine の選択

Extension として定義された ViewEngine を Ozark がどのように扱っているかを見ると、以下のようになっています。

@ApplicationScoped
public class ViewEngineFinder {

    @Inject
    @Any
    private Instance<ViewEngine> engines;

    private Map<String, ViewEngine> cache = new HashMap<>();

    public ViewEngine find(Viewable viewable) {
        Optional<ViewEngine> engine;
        final String view = viewable.getView();

        engine = Optional.ofNullable(cache.get(view));
        if (!engine.isPresent()) {
            final Set<ViewEngine> candidates = new HashSet<>();
            for (ViewEngine e : engines) {
                if (e.supports(view)) {
                    candidates.add(e);
                }
            }
            engine = candidates.stream().max(
                (e1, e2) -> {
                    // 省略(Priorityの高いエンジンを選ぶ)
                    });
            if (engine.isPresent()) {
                cache.put(view, engine.get());
            }
        }
        return engine.isPresent() ? engine.get() : null;
    }
}

@Inject @Any private Instance<ViewEngine> engines;CDI管理となっている ViewEngine のインスタンスが取れるので、サポートする拡張子の ViewEngine を探して、優先度の高いものを採用しています。

Fragments が上手く動かない

Thymeleaf を使う場合は、通常以下のようにリゾルバにホームとなるディレクトリや拡張子を設定して使うことが多いです。

templateResolver.setPrefix("/WEB-INF/views/");
templateResolver.setSuffix(".html");

しかし、Thymeleaf の Extension では特に設定を行っていないため、webapp のルートがホームとして設定されます。

MVC1.0 のコントローラでは、以下のように foo/createForm.html といったビューのパス文字列を返します。

@Path("foo")
@RequestScoped
public class FooController {

    @Inject
    private Models models;

    @GET
    @Path("new")
    @Controller
    public String initCreationForm() {
        models.put("model", new FooModel);
        return "foo/createForm.html";
    }
}

このパス文字列は、Thymeleaf エンジンで処理する直前で、以下のように resolveView() で解決されます。

engine.process(resolveView(context), ctx, response.getWriter());

resolveView() は何をしているかというと、

protected String resolveView(ViewEngineContext context) {
    final String view = context.getView();
    if (!hasStartingSlash(view)) {        // Relative?
        final String viewFolder = getProperty(context.getConfiguration(), VIEW_FOLDER, DEFAULT_VIEW_FOLDER);
        return ensureEndingSlash(viewFolder) + view;
    }
    return view;
}

Ozark 側で定義された(デフォルトは"/WEB-INF/views/")パスでテンプレートまでのパスを(Thymeleafの知らない所で)作っています。

なので、Thymeleaf 側で Fragments を定義する場合はファイルのパスをフルパスで定義する必要が出てきます。

<html xmlns:th="http://www.thymeleaf.org"
      th:replace="~{/WEB-INF/views/fragments/layout.html :: layout}">

Thymeleaf の Extension は作った方が早い

Thymeleaf 3 系 向けに以下の2クラス作るだけなので早いです。

@Dependent
public class DefaultTemplateEngineProducer {

    @Produces
    @ViewEngineConfig
    public TemplateEngine getTemplateEngine(ServletContext context) {
        ServletContextTemplateResolver resolver = new ServletContextTemplateResolver(context);
        resolver.setPrefix(ViewEngine.DEFAULT_VIEW_FOLDER);
        TemplateEngine engine = new TemplateEngine();
        engine.setTemplateResolver(resolver);
        return engine;
    }
}

resolver.setPrefix(ViewEngine.DEFAULT_VIEW_FOLDER) で Thymeleaf 側に "/WEB-INF/views/" を設定します。 @Dependent は beans.xmlbean-discovery-mode="all" で定義されている場合は不要です(CDI に拾ってもらえればなんでも良い)。

ViewEngine は以下のようにします。

@ApplicationScoped
public class ThymeleafViewEngine extends ViewEngineBase {

    @Inject
    private ServletContext servletContext;

    @Inject
    @ViewEngineConfig
    private TemplateEngine engine;

    @Override
    public boolean supports(String view) {
        return view.endsWith(".html");
    }

    @Override
    public void processView(ViewEngineContext context) throws ViewEngineException {
        try {
            HttpServletRequest request = context.getRequest();
            HttpServletResponse response = context.getResponse();
            WebContext ctx = new WebContext(request, response, servletContext, request.getLocale());
            ctx.setVariables(context.getModels());
            engine.process(context.getView(), ctx, response.getWriter());
        } catch (IOException e) {
            throw new ViewEngineException(e);
        }
    }
}

engine.process() では、パスの解決を Thymeleaf 側に任せます。

これで 以下のように Fragments にフルパス指定しなくて良くなります。

<html xmlns:th="http://www.thymeleaf.org"
      th:replace="~{fragments/layout.html :: layout}">