今回のネイティブ化は、テンプレートエンジンの Thymeleaf に挑戦です。
GraalVM でどこまでネイティブ化できるかチャレンジ、今回はテンプレートエンジン Thymeleaf のネイティブ化に挑戦です。
と、チャレンジを始めてからそのまんまのページを見つけてしまいました。。。
- QuarkusでHTMLを返す | ナッツウェル 技術研究室
- 上記の記事の成果物です → quarkus-webui-example
正直、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から依存関係の追加です。
...
<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-html
とthymeleaf
を追加してます。
そして今回の台風の目、thymeleaf-extras-java8time
を追加いたしました。
Thymeleaf ではそのままでは Java 8 から追加された java.time.*
に対応できていないのですね。というわけで、thymeleaf-extras-java8time
を追加いたしました。
2. モデルクラスとREST API インタフェースの作成
それでは実際のコーディングに入っていきます。
2-1. モデルクラス
ここはもう慣れたものでしょう。いつもの通り、Personモデルを定義いたします。
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 エンジンのラッパークラスの紹介です。・・・と言っても実装は例のサンプルそのまんまなんですけれども・・・ありがとうございます。
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メソッドではしっかり、HttpServletRequest
とHttpServletResponse
が渡ってきております。PersonResource#getHTML
の方では Servletの処理としっかり切り離されておりまして『テンプレートエンジン、どうすんだよ?』ってところでしたが、こうなれば普通のサーブレットでテンプレートエンジン使うのと全く一緒ですね。contextに設定した値を詰めて、process
!! でOKです。
また、ファイルのパスについてですが、ネイティブ化の際にMETA-INF/resources/
の中身は自動でインクルードしてくれるのに、パスを指定する場合はちゃんとMETA-INF/resources/
をつけないとダメなのね。。。
というわけで、暗黙でMETA-INF/resources/
を追加するようにいたしました。
Javaのコーディングは以上です!
3-2. テンプレートHTMLの記述
さて、HTMLの方ですが、Thymeleaf はデザイン時もレイアウトが崩れにくいのが特徴、というわけで無理やり Bootstrap を突っ込んでみました。
<!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 を用意します。
[
{
"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
に指定します。
...
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
を開いてみましょう。
はい、ちゃんとLocalDateがyyyy/MM/dd
で整形されております。
そして、このボタン。これがBootstrapだ!!
で、quarkusは静的なコンテンツもそのまま表示が可能です。
http://localhost:8082/
では src/main/resources/META-INF/resources/index.html
が表示されています。
というわけで、http://localhost:8082/person.html
をブラウザに打ち込んで、すっぴんのperson.html
を確認してみましょう。
た、確かに・・・そのまんまだ。。。いや、そのまんますぎてなんと思わないですが、テンプレートで式が埋め込まれているのにデザインが崩れてないのはすごいことなんですよ?!
今回は非常に簡単な値の埋め込みだけでしたが、すっぴんのJSPやFreeMarkerはまともに表示できないですからね。。。
ブラウザでちゃんとCSSが効いた状態が確認できる、というのは大きいでしょう!!
まとめ
とりあえず、動いてよかったです。いや、無理だと思ってました。。。
ちょっと設定ファイルに追記が必要なのはボトルネックですが、RuntimeErrorは解消できたのでよかったです。
今回の成果物も Github にあげておきました。
日本語フォント付きのPDFboxに続いての2連敗は避けたかったのでホッ。