3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

【2019年11月版】QuarkusでThymeleafのネイティブ化に挑戦!→テンプレートでメソッド呼び出しはちょっと待った!

Last updated at Posted at 2019-11-14

今回のネイティブ化は、テンプレートエンジンの Thymeleaf に挑戦です。

GraalVM でどこまでネイティブ化できるかチャレンジ、今回はテンプレートエンジン Thymeleaf のネイティブ化に挑戦です。

と、チャレンジを始めてからそのまんまのページを見つけてしまいました。。。

正直、Resteasy でどうやって Thymeleaf 使えば良いのか見当がつかなかったので有り難かったですw

それでは今回もモリモリの構成でまいりたいと思います!

1. プロジェクトの作成

例によって以下のコマンドでプロジェクト作成と、プラグインてんこ盛りをインストールします。

$ mvn io.quarkus:quarkus-maven-plugin:1.0.0.CR1:create
...
$ cd thymeleaf-quarkus-project
$ mvn quarkus:add-extension -Dextensions="quarkus-resteasy,quarkus-jdbc-postgresql,quarkus-smallrye-metrics,quarkus-smallrye-openapi,quarkus-smallrye-opentracing,quarkus-hibernate-orm-panache,quarkus-resteasy-jsonb"

今回は thymeleaf-quarkus-project という名前で作成してみました。そしてもう好い加減、HelloResource は作成しなくてもいいでしょうね。。。

そして、pom.xmlから依存関係の追加です。

pom.xml
...
    <dependency>
      <groupId>io.opentracing.contrib</groupId>
      <artifactId>opentracing-jdbc</artifactId>
    </dependency>
    <dependency>
      <groupId>com.fasterxml.jackson.datatype</groupId>
      <artifactId>jackson-datatype-jsr310</artifactId>
    </dependency>
    <dependency>
      <groupId>org.jboss.resteasy</groupId>
      <artifactId>resteasy-html</artifactId>
      <scope>compile</scope>
    </dependency>
    <dependency>
      <groupId>org.thymeleaf</groupId>
      <artifactId>thymeleaf</artifactId>
      <version>3.0.11.RELEASE</version>
      <scope>compile</scope>
    </dependency>
    <dependency>
      <groupId>org.thymeleaf.extras</groupId>
      <artifactId>thymeleaf-extras-java8time</artifactId>
      <version>3.0.1.RELEASE</version>
    </dependency>
...

はい、いつものメンツに加えて今回はresteasy-htmlthymeleafを追加してます。
そして今回の台風の目、thymeleaf-extras-java8time を追加いたしました。

Thymeleaf ではそのままでは Java 8 から追加された java.time.* に対応できていないのですね。というわけで、thymeleaf-extras-java8timeを追加いたしました。

2. モデルクラスとREST API インタフェースの作成

それでは実際のコーディングに入っていきます。

2-1. モデルクラス

ここはもう慣れたものでしょう。いつもの通り、Personモデルを定義いたします。

src/main/java/org/acme/quarkus/sample/model/Person.java
package org.acme.quarkus.sample.model;

import javax.persistence.Entity;
import java.time.LocalDate;
import java.util.List;

import io.quarkus.hibernate.orm.panache.PanacheEntity;

import com.fasterxml.jackson.annotation.JsonFormat;

@Entity
public class Person extends PanacheEntity {
  public enum Status {
    Alive, DECEASED
  }
  
  public String name;
  @JsonFormat(pattern = "yyyy-MM-dd")
  public LocalDate birth;
  public Status status;

  public static Person findByName(String name) {
    return find("name", name).firstResult();
  }

  public static List<Person> findAlive() {
    return list("status", Status.Alive);
  }

  public static void deleteStefs() {
    delete("name", "Stef");
  }
}

いつもながら、モデルクラスの記述量の少なさは圧巻ですね。。

2-2. REST API インタフェースの実装

さて、REST API側です。

package org.acme.quarkus.sample;

...
import org.jboss.resteasy.plugins.providers.html.Renderable;

/**
 * PersonResource
 */
@Path("/person")
public class PersonResource {

    ThymeleafRenderer views;

    @Inject
    public PersonResource(ThymeleafRenderer views) {
        this.views = views;
    }

    @POST
    @Transactional
    @Produces(MediaType.APPLICATION_JSON)
    @Counted(name = "performed_create", description = "How many it have been called.")
    @Timed(name = "checksTimer_create", description = "A measure of how long it takes to perform creating person.", unit = MetricUnits.MILLISECONDS)
    public Person create(Person person) {
        person.persist();
        return person;
    }

    @GET
    @Path("/{id}")
    @Transactional
    @Produces(MediaType.TEXT_HTML)
    @Counted(name = "performed_html", description = "How many it have been called.")
    @Timed(name = "checksTimer_html", description = "A measure of how long it takes to perform getting person.", unit = MetricUnits.MILLISECONDS)
    public Renderable getHTML(@PathParam("id") Long id) {
        return views
            .view("person.html")
            .with("p", Person.findById(id));
    }    
}

コンストラクタで、ThymeleafRenderer を受け取るようにしましたが、これは下の手順で作成するThymeleafテンプレートエンジンのラッパーです。

そして今回は create と、getHTML の2つをご用意いたしました。
create はレコード追加用のAPIで、getHTMLが HTML を返すAPIとなります。

getHTMLの中身は Thymeleafというよりラッパークラスでの処理となっておりますので上記でご紹介したサンプルを作ってくれた方の実装スタイルです。大変、美しいですね!

getHTML の戻り値ですが、Renderableを返却すると HTML として返してくれるようです。これは RestEasyHTML の機能ですね。

Rest API の実装はこのように非常にシンプルなものとなっております。

3. Thymeleaf テンプレートエンジンの使用

3-1. エンジンラッパーの実装

それでは本丸、Thymeleaf エンジンのラッパークラスの紹介です。・・・と言っても実装は例のサンプルそのまんまなんですけれども・・・ありがとうございます。

src/main/java/org/acme/quarkus/sample/ThymeleafRenderer.java
package org.acme.quarkus.sample;

...

import org.jboss.resteasy.plugins.providers.html.Renderable;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.WebContext;
import org.thymeleaf.extras.java8time.dialect.Java8TimeDialect;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;

@ApplicationScoped
public class ThymeleafRenderer {

    private TemplateEngine templateEngine = new TemplateEngine() {{
        addDialect(new Java8TimeDialect());
        setTemplateResolver(new ClassLoaderTemplateResolver());
    }};

    public Renderer view(String path) {
        return new Renderer(templateEngine, "META-INF/resources/"+path);
    }

    public static class Renderer implements Renderable {
        private TemplateEngine templateEngine;
        private String path;
        private Map<String, Object> variables;
        public Renderer(TemplateEngine templateEngine, String path) {
            this.templateEngine = templateEngine;
            this.path = path;
            this.variables = new HashMap<>();
        }
        
        public Renderer with(String key, Object variable) {
            this.variables.put(key, variable);
            return this;
        }

        @Override
        public void render(HttpServletRequest request, HttpServletResponse response)
                throws IOException, ServletException, WebApplicationException {
            WebContext context = new WebContext(request, response, request.getServletContext());

            context.setVariables(variables);
            
            templateEngine.process(
                    path, context,
                    new OutputStreamWriter(response.getOutputStream(), StandardCharsets.UTF_8));
        }
    }
}

元の実装と違うのは TemplateEngine の初期化処理オーバーライドで addDialect() を追加しているところですね。ここで例の Java8TimeDialect を追加しております。

あとはそのままですがちょっと解説をいたしますと、内部クラスで Renderable を実装しておりまして、このResteasyから呼ばれるRenderableのrenderメソッドではしっかり、HttpServletRequestHttpServletResponseが渡ってきております。PersonResource#getHTML の方では Servletの処理としっかり切り離されておりまして『テンプレートエンジン、どうすんだよ?』ってところでしたが、こうなれば普通のサーブレットでテンプレートエンジン使うのと全く一緒ですね。contextに設定した値を詰めて、process!! でOKです。

また、ファイルのパスについてですが、ネイティブ化の際にMETA-INF/resources/の中身は自動でインクルードしてくれるのに、パスを指定する場合はちゃんとMETA-INF/resources/をつけないとダメなのね。。。
というわけで、暗黙でMETA-INF/resources/を追加するようにいたしました。

Javaのコーディングは以上です!

3-2. テンプレートHTMLの記述

さて、HTMLの方ですが、Thymeleaf はデザイン時もレイアウトが崩れにくいのが特徴、というわけで無理やり Bootstrap を突っ込んでみました。

src/main/resources/META-INF/resources/person.html
<!doctype html>
<html lang="ja">
  <head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">

    <title>Hello, world!</title>
  </head>
    <h2>Thymeleaf Template Example</h2>
        <ul>
            <li>Id: <span th:text="${p.id}" class="btn btn-primary">仮 ID</span></li>
            <li>Name: <span th:text="${p.name}" class="btn">仮名</span></li>
            <li>Birth: <span th:text="${#temporals.format(p.birth, 'yyyy/MM/dd')}" class="btn btn-primary">1990/01/01</span></li>
            <li>Status: <span th:text="${p.status}" class="btn">Alive</span></li>
        </ul>
    <!-- Optional JavaScript -->
    <!-- jQuery first, then Popper.js, then Bootstrap JS -->
    <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
  </body>
</html>

本質的な部分は、<span th:text="${p.name}"> のように Thymeleaf は独自属性で埋め込む値を指定しておくことで、HTMLとして破綻しないようにしている、という点ですね。

また、<span th:text="${#temporals.format(p.birth, 'yyyy/MM/dd')}"> のように、LocalDate などを使う箇所は#temporalsで定義されたメソッドを使って日付処理を行います。
あとは表示されてからのお楽しみ、ということですね!

3-3. reflectionconfig.json の作成

これは結果的にわかったことでございますが、ネイティブ化に当たってこの手順が必須でした。
mvn quarkus:dev で JVM 上で Thymeleaf を動かすだけであれば上までの手順でOKです。

ですが、テンプレートエンジンが値を評価するというのはコンパイラが知ったこっちゃない事柄なのです。
つまり、テンプレート上で呼び出されるtemporals.formatのメソッド呼び出しなんて、ネイティブ化の時点ではわからないわけで、ネイティブ化の際にはtemporals.formatメソッドなんてバイナリに含まれていないらしいのです。

以下の記事、見つけてよかった。。。

'reflectionconfig.json'というファイルを作成して"temporals.formatが実行時には使用されますよ〜"ということをコンパイラにお知らせしておく必要がありました。

まず、以下のような json を用意します。

src/main/resources/reflectionconfig.json
[
    {
        "name" : "java.time.LocalDate",
        "methods" : [
            { "name" : "format", "parameterTypes" : ["java.time.format.DateTimeFormatter"] }
        ]
    },
    {
        "name" : "org.thymeleaf.extras.java8time.expression.Temporals",
        "methods" : [
            { "name" : "format", "parameterTypes" : ["java.time.temporal.Temporal", "java.lang.String"] }
        ]
    }
]

このようにクラス名とメソッド名、そのシグネチャーをjson形式で書いておきます。DateTimeFormatterを試してみた記憶も残しておきます。

続いてこのファイルをビルド時の-H:ReflectionConfigurationFilesに指定します。

src/main/resources/application.properties
...
quarkus.native.additional-build-args=-H:ReflectionConfigurationFiles=../../src/main/resources/reflectionconfig.json

毎度、わかりにくいですが、Dockerでビルドさせるとmvn の環境変数がうまくハマってくれないので、パスの起点が ./target/xxxx-xxxx-xxxxx-jar/ (/終わりに注目) であるというのが注意点です。
Dockerfile で ENV しちゃってもいいんですけどね〜。。。

今回はこれだけでOKです。

が、つまり・・・Javaのコードには一切出てこないがテンプレート上で呼び出すメソッドに関しては逐一、このjsonにシグネチャー書いてかないとダメってことです。
まぁ、テンプレートで呼び出す処理というのはプログラマーがコーディングするんだから"ついでに設定ファイルにも使うメソッドメモっといてね"というのはしょうがないといえばしょうがないですが…テンプレート側で翻訳入れるとか単位の計算するとか、いわゆるパイプっぽい処理をモリモリしようとするとこれはけっこう厳しいということになりますね。せっかく${...}でガッツリが書けるというのにぃ!

4. ビルド用のコンテナ、DB、Jaeger などなど

ここは特に毎度のことなので割愛したいとおもいます。

github の方で、Dockerfile と docker-compose.yaml をチェックしてください。

5. ネイティブ化

こちらも上記で用意したdocker-compose.yml をビルドでOKです!

$ docker-compose build
...
$ docker-compose up

これ、ビルドに毎度、10分程度かかるのでトライ&エラーもなかなか暇なんすよね・・・

6. 実行!!

それでは早速動作をみてみましょう。
まず、ターミナルの方から curl で jsonをPOSTします。

$ curl -H 'Content-Type:application/json' -d '{"name":"Alice","birth":"2010-10-11","status":"Alive"}' http://localhost:8082/person
{"id":1,"birth":"2010-10-11","name":"Alice","status":"Alive"}

ということで、IDに1が振られて、レコードがちゃんと生成されていることが確認できます。

続いてブラウザからhttp://localhost:8082/person/1を開いてみましょう。
Hello world By Panache.png
はい、ちゃんとLocalDateがyyyy/MM/ddで整形されております。
そして、このボタン。これがBootstrapだ!!

で、quarkusは静的なコンテンツもそのまま表示が可能です。
http://localhost:8082/では src/main/resources/META-INF/resources/index.htmlが表示されています。
というわけで、http://localhost:8082/person.html をブラウザに打ち込んで、すっぴんのperson.htmlを確認してみましょう。
Hello world By HTML.png
た、確かに・・・そのまんまだ。。。いや、そのまんますぎてなんと思わないですが、テンプレートで式が埋め込まれているのにデザインが崩れてないのはすごいことなんですよ?!
今回は非常に簡単な値の埋め込みだけでしたが、すっぴんのJSPやFreeMarkerはまともに表示できないですからね。。。
ブラウザでちゃんとCSSが効いた状態が確認できる、というのは大きいでしょう!!

まとめ

とりあえず、動いてよかったです。いや、無理だと思ってました。。。
ちょっと設定ファイルに追記が必要なのはボトルネックですが、RuntimeErrorは解消できたのでよかったです。

今回の成果物も Github にあげておきました。

日本語フォント付きのPDFboxに続いての2連敗は避けたかったのでホッ。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?