10
9

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.

Javalinを調べたときのメモ

Last updated at Posted at 2019-08-28

概要

Javalinとは

JavalinはJavaおよびKotlin用の軽量Web Frameworkです。[Spark Framework] (http://sparkjava.com/)というWeb Frameworkからforkされ、2017年5月にバージョン0.0.1、2019年8月現在は3.4.1がリリースされています。
以下に主な特徴をGitHubから引用します。(日本語訳はグーグル翻訳です)

https://github.com/tipsy/javalin

Javalin is more of a library than a framework. Some key points:

  • You don't need to extend anything
  • There are no @Annotations
  • There is no reflection
  • There is no other magic; just code.

Javelinは、フレームワークというよりもライブラリです。 いくつかのキーポイント:

  • 何も拡張する必要はありません
  • @Annotationsはありません
  • リフレクションはありません
  • 他にマジックはありません。 コードだけ

この記事の前半は、最小構成で"Hello World"を表示するアプリケーションの実装について、後半は簡単なWebアプリケーションの実装で調べたこと/試したことのメモになります。

環境

  • Windows 10 Professional 1903
  • OpenJDK 12.0.2
  • Javalin 3.4.1
    • Jetty-server 9.4.19
    • Jetty-webapp 9.4.19
  • Intellij IDEA 2019.2

参考

最小構成でHello World

プロジェクト構成

通常のMavenプロジェクトです。

[project_root]
 |
 `--- /src
 |      |
 |      `--- /main
 |      |      |
 |      |      `--- /java
 |      |      |      |
 |      |      |      `--- com.example.demo (package)
 |      |      |            |
 |      |      |            `--- App.java
 |      |      |
 |      |      `--- /resources
 |      |
 |      `--- /test
 |             |
 |             `--- /java
 |             |
 |             `--- /resources
 |
 `--- pom.xml

pom.xml

最小構成のJavalinアプリケーションに必要な依存関係はjavalinとslf4j-simpleです。

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>demo-java12-javalin3</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <name>demo java12 javalin 3.4.1</name>
    <description>Demo project for Javalin 3 with Java12</description>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>12</java.version>
        <maven.clean.version>3.1.0</maven.clean.version>
        <maven.resources.version>3.1.0</maven.resources.version>
        <maven.compiler.version>3.8.1</maven.compiler.version>
        <maven.compiler.source>${java.version}</maven.compiler.source>
        <maven.compiler.target>${java.version}</maven.compiler.target>
        <maven.compiler.showWarnings>true</maven.compiler.showWarnings>
        <maven.jar.version>3.1.1</maven.jar.version>
        <maven.assembly.version>3.1.1</maven.assembly.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>io.javalin</groupId>
            <artifactId>javalin</artifactId>
            <version>3.4.1</version>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-simple</artifactId>
            <version>1.7.26</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-clean-plugin</artifactId>
                <version>${maven.clean.version}</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-resources-plugin</artifactId>
                <version>${maven.resources.version}</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>${maven.compiler.version}</version>
                <configuration>
                    <compilerArgs>
                        <arg>-Xlint:all</arg>
                    </compilerArgs>
                    <release>${java.version}</release>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>${maven.jar.version}</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-assembly-plugin</artifactId>
                <version>${maven.assembly.version}</version>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>single</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <descriptorRefs>
                        <descriptorRef>jar-with-dependencies</descriptorRef>
                    </descriptorRefs>
                    <archive>
                        <manifest>
                            <mainClass>com.example.demo.App</mainClass>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

アプリケーション起動ポイントの実装

Appクラス(クラス名は任意)のmainメソッドに、Javalinアプリケーションの起動ポイントを実装します。

  • (1) : Javalinアプリケーションのインスタンスを初期設定で生成しポート7000で起動します。
    • createメソッドにJavalinConfigクラスのインスタンスを渡すことでアプリケーションをカスタマイズできます。
  • (2) : Javalinアプリケーションに、パス/にGETリクエストハンドラを追加します。
import io.javalin.Javalin;

public class App {
  public static void main(String ... args) {
    // (1)
    Javalin app = Javalin.create().start(7000);
    // (2)
    app.get("/", ctx -> ctx.result("Hello World"));
  }
}

IDEから起動

App.mainメソッドを実行するとJavalinが起動し、以下のようなログがコンソールに出力されます。ログに出力されている通り私の開発環境では起動にかかる時間は1秒未満でした。
http://localhost:7000 にアクセスすると"Hello World"というtext/plainのレスポンスが返ります。

[main] INFO io.javalin.Javalin - 
           __                      __ _
          / /____ _ _   __ ____ _ / /(_)____
     __  / // __ `/| | / // __ `// // // __ \
    / /_/ // /_/ / | |/ // /_/ // // // / / /
    \____/ \__,_/  |___/ \__,_//_//_//_/ /_/

        https://javalin.io/documentation

[main] INFO org.eclipse.jetty.util.log - Logging initialized @827ms to org.eclipse.jetty.util.log.Slf4jLog
[main] INFO io.javalin.Javalin - Starting Javalin ...
[main] INFO io.javalin.Javalin - Listening on http://localhost:7000/
[main] INFO io.javalin.Javalin - Javalin started in 731ms \o/

jarファイルから起動

ビルドするとtargetディレクトリ下にdemo-java12-javalin3-1.0.0-SNAPSHOT-jar-with-dependencies.jarというjarファイルが生成されます。
Javalinアプリケーションは埋め込みJettyを利用しているのでjava -jar <jarファイル>でWebアプリケーションを起動することができます。

> java --version
openjdk 12.0.2 2019-07-16
OpenJDK Runtime Environment (build 12.0.2+10)
OpenJDK 64-Bit Server VM (build 12.0.2+10, mixed mode, sharing)

> java -jar demo-java12-javalin3-1.0.0-SNAPSHOT-jar-with-dependencies.jar

下記はJVMパラメータでデフォルトロケールを変更する場合の例です。

> java -Duser.language=en -Duser.country=US -jar demo-java12-javalin3-1.0.0-SNAPSHOT-jar-with-dependencies.jar

以上で『最小構成でHello World』のアプリケーションの実装は終了です。

Javalinの特徴について

ここからはJavalinで簡単なWebアプリケーションを実装したときのメモになります。

HTTPハンドラの実装の仕方

上記で書いたGETリクエストハンドラ(下記、パターン1)は、パターン2、パターン3のようにも実装することができます。

パターン1

app.get("/", ctx -> ctx.result("Hello World"));

パターン2

※便宜的にAppクラスにメソッドを追加していますが、通常はハンドラクラスを別途実装することになると思います。

public class App {

  void index2(@NotNull Context ctx) {
    ctx.result("Hello World2");
  }

  public static void main(String ... args) {
    Javalin app = Javalin.create().start(7000);
    App handler = new App();
    app.get("/2", handler::index2);
  }

}

パターン3

オフィシャルサイトのチュートリアルではstaticフィールドで記述する例もあります。

public class App {

  static Handler index3 = ctx -> {
    ctx.result("Hello World3");
  };

  public static void main(String ... args) {
    Javalin app = Javalin.create().start(7000);
    app.get("/3", App.index3);
  }

}

HTTPハンドラ

GETの他にPOST、PUT、PATCH、DELETE、HEAD、OPTIONSがあります。

HTTPメソッド シグネチャ
GET public Javalin get(@NotNull String path, @NotNull Handler handler)
POST public Javalin post(@NotNull String path, @NotNull Handler handler)
PUT public Javalin put(@NotNull String path, @NotNull Handler handler)
PATCH public Javalin patch(@NotNull String path, @NotNull Handler handler)
DELTE public Javalin delete(@NotNull String path, @NotNull Handler handler)
HEAD public Javalin head(@NotNull String path, @NotNull Handler handler)
OPTIONS public Javalin options(@NotNull String path, @NotNull Handler handler)

before / afterエンドポイント

HTTPハンドラの前後で実行するbefore / afterというエンドポイントを設定することができます。

エンドポイント シグネチャ
before public Javalin before(@NotNull String path, @NotNull Handler handler)
before public Javalin before(@NotNull Handler handler)
after public Javalin after(@NotNull String path, @NotNull Handler handler)
after public Javalin after(@NotNull Handler handler)

HTTPハンドラのグループ化

エンドポイント HTTPメソッド 機能
/api/v1/users GET ユーザ一覧を取得
/api/v1/user/:id GET ID指定でユーザを取得
/api/v1/user POST ユーザを登録
/api/v1/user/:id DELETE ID指定でユーザを削除
/api/v2/users GET ユーザ一覧を取得
/api/v2/user/:id GET ID指定でユーザを取得
/api/v2/user POST ユーザを登録
/api/v2/user/:id DELETE ID指定でユーザを削除

このようなv1とv2の2系統のエンドポイントがあった場合、以下のようにHTTPハンドラをグループ化して定義できます。

UserController v1Controller = /* version 1 の実装 */
UserController v2Controller = /* version 2 の実装 */

app.routes(() -> {

  // version 1
  ApiBuilder.path("/api/v1", () -> {
    // GET /api/v1/users
    ApiBuilder.get("/users", v1Controller::findAll);
    ApiBuilder.path("/user", () -> {
      // GET /api/v1/user/:id
      ApiBuilder.get(":id", v1Controller::findById);
      // POST /api/v1/user
      ApiBuilder.post(v1Controller::store);
      // DELETE /api/v1/user/:id
      ApiBuilder.delete(":id", v1Controller::remove);
    });
  });

  // version 2
  ApiBuilder.path("/api/v2", () -> {
    // GET /api/v2/users
    ApiBuilder.get("/users", v2Controller::findAll);
    ApiBuilder.path("/user", () -> {
      // GET /api/v2/user/:id
      ApiBuilder.get(":id", v2Controller::findById);
      // POST /api/v2/user
      ApiBuilder.post(v2Controller::store);
      // DELETE /api/v2/user/:id
      ApiBuilder.delete(":id", v2Controller::remove);
    });
  });

});

io.javalin.http.Contextについて

HTTPハンドラメソッドのパラメータio.javalin.http.Contextクラスは、HTTPリクエストおよびレスポンスに必要な機能とデータを提供します。
以下オフィシャルサイトのドキュメントページからの引用です。

https://javalin.io/documentation#context

The Context object provides you with everything you need to handle a http-request. It contains the underlying servlet-request and servlet-response, and a bunch of getters and setters. The getters operate mostly on the request-object, while the setters operate exclusively on the response object.

ContextにはHttpServletRequest、HttpServletResponseのインスタンスを保持するreq、resフィールドもあります。

javax.servlet.http.HttpServletRequest request = ctx.req;
javax.servlet.http.HttpServletResponse response = ctx.res;

リクエストパラメータの受け取り方

クエリパラメータ

クエリストリングで指定されたパラメータを受け取るメソッド

シグネチャ
String queryParam(String key)
String queryParam(String key, String default)
List<String> queryParams(String key)
Map<String, List<String>> queryParamMap()
Validator<T> queryParam(String key, Class<T> clazz)

フォームパラメータ

フォームもクエリパラメータと同様のメソッドがあります。残念ならがらフォーム全体のフィールドを任意のオブジェクトにバインドすることはできないようです。

シグネチャ
String formParam(String key)
String formParam(String key, String default)
List<String> formParams(String key)
Map<String, List<String>> formParamMap()
Validator<T> formParam(String key, Class<T> clazz)

パスパラメータ

/api/user/1のようなパスからその一部をパラメータとして受け取るメソッド

シグネチャ
String pathParam(String key)
Map<String, String> pathParamMap()
Validator<T> pathParam(String key, Class<T> clazz)

リクエストハンドラのパスにコロン(:)を付けた部分がパスパラメータとして識別されます。

app.get("/api/user/:id", ctx -> {
  Long userId = ctx.pathParam("id", Long.class).get();
});

Javalinアプリケーションのコンフィグレーション

Javalin.createメソッドにJavalinConfigクラスのインスタンスを渡すことでカスタマイズができます。
以下のようにJavalinConfigのインスタンスを渡す方法の他に、lambda式で記述する方法もあります。

インスタンス

configuration
JavalinConfig config = new JavalinConfig();
// configuration
Javalin
    .create(config)
// ... 省略 ...

lambda式

configuration
Javalin
    .create(config -> {
        // configuration
    })
// ... 省略 ...

主なコンフィグレーション

コンテキストパスの設定 (デフォルトは"/")

config.contextPath = "/app";

デフォルトのコンテンツタイプの設定 (デフォルトは"text/plain")

config.defaultContentType = "application/json";

ETagの設定 (デフォルトはfalse)

config.autogenerateEtags = true;

リクエストログの出力はJavalinConfig.requestLoggerで設定できます。
第二引数(execTime)はリクエストに掛かった時間(ms)です。

configuration
config.requestLogger((ctx, execTime) -> {
  LOG.debug("[{}] {} - {} ms.", ctx.fullUrl(), ctx.userAgent(), execTime);
});

開発用コンフィグレーション

開発時に有効にすると便利な機能もあります。

enableDevLogging

configuration
config.enableDevLogging();

情報量が多いので常時有効にしておくというよりは、問題が起きたときの調査時に有効化するといった使い方になると思います。

[qtp2092769598-18] INFO io.javalin.Javalin - JAVALIN REQUEST DEBUG LOG:
Request: GET [/overview]
    Matching endpoint-handlers: [GET=/overview]
    Headers: {Cookie=Idea-c29ae3c1=53d6d99f-1169-42ed-8d69-a63cfe80a87a, Accept=text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3, Upgrade-Insecure-Requests=1, Connection=keep-alive, User-Agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.100 Safari/537.36, Sec-Fetch-Site=none, Host=localhost:7000, Sec-Fetch-User=?1, DNT=1, Accept-Encoding=gzip, deflate, br, Accept-Language=ja,en-US;q=0.9,en;q=0.8,en-GB;q=0.7, Sec-Fetch-Mode=navigate}
    Cookies: {Idea-c29ae3c1=53d6d99f-1169-42ed-8d69-a63cfe80a87a}
    Body: 
    QueryString: null
    QueryParams: {}
    FormParams: {}
Response: [200], execution took 26.37 ms
    Headers: {Server=Javalin, Content-Encoding=gzip, Date=Wed, 21 Aug 2019 13:44:46 GMT, Content-Type=text/html}
    Body is gzipped (4464 bytes, not logged)
----------------------------------------------------------------------------------

RouteOverviewPlugin

configuration
config.registerPlugin(new RouteOverviewPlugin("/overview"));

このプラグインを登録して http://localhost:7000/overview にアクセスするとJavalinアプリケーションに追加されているリクエストハンドラの一覧を確認することができます。
overview.png

エラーレスポンスのカスタマイズ

JavalinのHttpResponseExceptionを継承したHTTPステータスに対応する例外クラスが用意されています。

Status 例外クラス メッセージ
302 RedirectResponse Redirected
400 BadRequestResponse Bad request
401 UnauthorizedResponse Unauthorized
403 ForbiddenResponse Forbidden
404 NotFoundResponse Not found
405 MethodNotAllowedResponse Method not allowed
409 ConflictResponse Conflict
410 GoneResponse Gone
500 InternalServerErrorResponse Internal server error
502 BadGatewayResponse Bad gateway
503 ServiceUnavailableResponse Service unavailable
504 GatewayTimeoutResponse Gateway timeout

固有の例外をハンドリングする

アプリケーション固有の例外をハンドリングして任意のエラーレスポンスを返すことができます。
下記のAppServiceExceptionというアプリケーション固有の例外がスローされた場合、

AppServiceException
public class AppServiceException extends RuntimeException {
  public AppServiceException() {
  }
  public AppServiceException(String message) {
    super(message);
  }
  public AppServiceException(String message, Throwable cause) {
    super(message, cause);
  }
}

この例外をハンドリングしてHTTPステータス返すにはJavalinアプリケーションに例外をマッピングします。
この例では500ステータスだけを返します。

app.exception(AppServiceException.class, (e, ctx) -> {
  ctx.status(500);
});

任意のエラーレスポンスメッセージとHTTPステータスを返すには以下のように実装します。
resultメソッドではなくjsonメソッドを使用すればjsonフォーマットのメッセージを返すこともできます。

app.exception(AppServiceException.class, (e, ctx) -> {
  ctx.result("Application Error : " + e.getMessage()).status(500);
});

リクエスト / レスポンスでjsonを扱う

リクエスト / レスポンスでjsonを扱えるように依存関係にjacksonを追加します。
2番目のjackson-datatype-jsr310はJava8 Date and Time APIを扱う場合に必要です。

pom.xml
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.10.0.pr1</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
    <version>2.10.0.pr1</version>
</dependency>

jsonをレスポンスするGETリクエストハンドラを実装します。jsonをレスポンスするにはjsonメソッドを使用します。

public static void main(String ... args) {
  // ... 省略

  // (1)
  ObjectMapper objectMapper = new ObjectMapper();
  objectMapper.findAndRegisterModules();
  objectMapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss"));
  // (2)
  JavalinJackson.configure(objectMapper);

  // (3)
  app.get("/json", ctx -> {
    Map<String, Object> model = new HashMap<>();
    model.put("message", "Hello World");
    model.put("now", LocalDateTime.now());
    //jsonメソッドを使用します。
    ctx.json(model);
  });

  // (4)
  app.post("/json", ctx -> {
    Map<String, Object> model = ctx.bodyAsClass(Map.class);
    // HTTP Status 200を返します。
    ctx.status(200);
  });

}
  • (1) : ObjectMapperのインスタンスをカスタマイズします。
  • (2) : カスタマイズしたインスタンスをJavalinJacksonクラスを使って適用します。
  • (3) : jsonを返すGETリクエストハンドラを追加します。
  • (4) : jsonを受け取るPOSTリクエストハンドラを追加します。

静的ファイルを配信する

クラスパス上に配置したファイルを配信するにはJavalinConfigで設定を行います。

[project_root]
 |
 `--- /src
        |
        `--- /main
               |
               `--- /resources
                      |
                      `--- /public
                             |
                             `--- index.html

上記の場所に配置したindex.htmlを配信するにはJavalinConfig.addStaticFilesでパスを指定します。

configuration
config.addStaticFiles("/public");

『最小構成でHello World』のアプリケーションで追加した/に対するGETリクエストハンドラとパスが被るので、以下のコードをコメントアウトします。

// app.get("/", ctx -> ctx.result("Hello World"));

この状態でJavalinアプリケーションを起動し、http://localhost:7000/index.html または http://localhost:7000にアクセスするとpublic/index.htmlが返されます。

また、クラスパス外にあるディレクトリを扱うこともできます。
例えば以下のようなC:\var\staticディレクトリ以下のファイルを扱いたい場合は、

C:\var
    |
    `--- \static
           |
           `--- \images
                  |
                  `--- sample_1.jpg

JavalinConfig.addStaticFilesで絶対パスを指定します。なお、ここで指定したパスにアクセスできないとアプリケーション起動時にエラーが発生します。

configuration
config.addStaticFiles("C:\\var\\static", Location.EXTERNAL);

htmlファイルで下記のようにパスを指定します。

<img src="/images/sample_1.jpg" />

WebJarsを利用する

pom.xmlの依存関係に利用したいライブラリを追加します。
この例ではjQueryを利用しました。

pom.xml
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>jquery</artifactId>
    <version>3.4.1</version>
</dependency>

これだけではjQueryは有効になりません。Webjarsをアプリケーションで利用できるようにするためにJavalinConfig.enableWebjarsで有効化します。

configuration
config.enableWebjars();

htmlファイルで下記のようにパスを指定します。

<script src="/webjars/jquery/3.4.1/jquery.min.js"></script>

ロギングにlogbackを利用する

ロギングにslf4jとlogbackを利用します。slf4j-apiはすでにJavalinが依存しているので追加するのはlogback-classicだけになります。
また最初に追加していたslf4j-simpleはコメントアウトします。

pom.xml
<!--
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-simple</artifactId>
    <version>1.7.26</version>
</dependency>
-->
<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <version>1.2.3</version>
    <scope>runtime</scope>
    <exclusions>
        <exclusion>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
        </exclusion>
    </exclusions>
</dependency>

logbackの設定ファイルlogback.xmlを以下の場所に作成します。

[project_root]
 |
 `--- /src
        |
        `--- /main
               |
               `--- /resources
                      |
                      `--- logback.xml

logback.xml

logback.xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration status="INFO">
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
            <Pattern>%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n</Pattern>
        </encoder>
    </appender>

    <logger name="com.example.demo" level="debug" additivity="false">
        <appender-ref ref="CONSOLE"/>
    </logger>

    <logger name="org.jooq" level="info" additivity="false">
        <appender-ref ref="CONSOLE"/>
    </logger>
    <logger name="org.thymeleaf" level="info" additivity="false">
        <appender-ref ref="CONSOLE"/>
    </logger>
    <root level="info">
        <appender-ref ref="CONSOLE" />
    </root>
</configuration>

アプリケーションログの出力

ログを出力したいクラスに以下のようにロガーを定義します。

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class App {
  static final Logger LOG = LoggerFactory.getLogger(App.class);

  // ...省略...

}

テンプレートエンジンにThymeleafを利用する

JavalinではVelocity、Freemarker、Thymeleafなどのテンプレートエンジンを簡単に利用できるようになっています。
この例ではThymeleafを利用します。

pom.xml
<dependency>
    <groupId>org.thymeleaf</groupId>
    <artifactId>thymeleaf</artifactId>
    <version>3.0.9.RELEASE</version>
</dependency>

以下の場所にhello_world.htmlというテンプレートファイルを配置します。同じファイル名のプロパティファイルはメッセージリソースです。

[project_root]
 |
 `--- /src
        |
        `--- /main
               |
               `--- /resources
                      |
                      `--- /WEB-INF
                             |
                             `--- /templates
                                   |
                                   `--- hello_world.html
                                   `--- hello_world.properties
                                   `--- hello_world_ja.properties
                                   `--- hello_world_en.properties

hello_world.html

<!doctype html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <script th:src="@{/webjars/jquery/3.4.1/jquery.min.js}"></script>
    <title>Hello World</title>
</head>
<body>
<div>
    <p th:text="${message}">message</p>
    <p th:text="${now}">now</p>
    <p th:text="#{msg.hello}">message</p>
</div>
</body>
</html>

メッセージリソースの定義

hello_world.properties
msg.hello = Hello World (default)
hello_world_en.properties
msg.hello = Hello World
hello_world_ja.properties
msg.hello = ハローワールド

テンプレートエンジンを利用してレスポンスするにはrenderメソッドを使用します。
なお、どのテンプレートエンジンが利用されるかはテンプレートファイルの拡張子で決まります。Thymeleafの場合は.html.tl.thyme.thymeleafが該当します。

app.get("/hello", ctx -> {
  Map<String, Object> model = new HashMap<>();
  model.put("message", "Hello World");
  model.put("now", LocalDateTime.now());
  ctx.render("/WEB-INF/templates/hello_world.html", model);
});

localhost:7000/helloにアクセスするとテンプレートエンジンで描画されたhtmlページが表示されます。

ブラウザのAccept-Languageでメッセージを切り替える

デフォルトでは、アプリケーション起動時のデフォルトロケールでメッセージリソースが決まります。
ある言語のメッセージリソースを利用するには、Javalinアプリケーション起動時のJVMオプションにロケールを指定します。
例えばenのメッセージリソースを利用するには以下のオプションを指定します。

> java -Duser.language=en -Duser.country=US -jar <jarファイル>

Spring BootのようにブラウザのAccept-Languageに対応するメッセージリソースを利用するには、FileRendererを実装したクラスを作ります。

CustomThymeleafRenderer
import io.javalin.http.Context;
import io.javalin.plugin.rendering.FileRenderer;
import org.jetbrains.annotations.NotNull;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.WebContext;
import org.thymeleaf.extras.java8time.dialect.Java8TimeDialect;
import org.thymeleaf.templatemode.TemplateMode;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import org.thymeleaf.templateresolver.ITemplateResolver;

import java.util.Map;
import java.util.concurrent.TimeUnit;

public class CustomThymeleafRenderer implements FileRenderer {

  private final TemplateEngine templateEngine;

  public CustomThymeleafRenderer() {
    templateEngine = templateEngine();
  }

  @Override
  public String render(@NotNull String filePath, @NotNull Map<String, Object> model, @NotNull Context ctx) {
    WebContext context = new WebContext(ctx.req, ctx.res, ctx.req.getServletContext(), ctx.req.getLocale());
    context.setVariables(model);
    return templateEngine.process(filePath, context);
  }

  private TemplateEngine templateEngine() {
    TemplateEngine templateEngine = new TemplateEngine();
    templateEngine.setTemplateResolver(templateResolver());
    templateEngine.addDialect(new Java8TimeDialect());
    return templateEngine;
  }

  private ITemplateResolver templateResolver() {
    ClassLoaderTemplateResolver templateResolver = new ClassLoaderTemplateResolver();
    templateResolver.setPrefix("/WEB-INF/templates/");
    templateResolver.setSuffix(".html");
    templateResolver.setCharacterEncoding("UTF-8");
    templateResolver.setCacheable(true);
    templateResolver.setCacheTTLMs(TimeUnit.MINUTES.toMillis(30));
    templateResolver.setTemplateMode(TemplateMode.HTML);
    return templateResolver;
  }

}

このCustomThymeleafRendererをJavalinアプリケーションに登録します。

JavalinRenderer.register(new CustomThymeleafRenderer(), ".html");

このカスタマイズを行うと、テンプレートファイルのパスを指定する必要は無くなるのでハンドラの実装は以下のようになります。

// ctx.render("/WEB-INF/templates/hello_world.html", model);
ctx.render("hello_world.html", model);

ちなみにデフォルトで登録されているrendererは以下のとおりです。

JavalinRenderer
object JavalinRenderer {

  init {
    register(JavalinVelocity, ".vm", ".vtl")
    register(JavalinFreemarker, ".ftl")
    register(JavalinMustache, ".mustache")
    register(JavalinJtwig, ".jtwig", ".twig", ".html.twig")
    register(JavalinPebble, ".peb", ".pebble")
    register(JavalinThymeleaf, ".html", ".tl", ".thyme", ".thymeleaf")
    register(JavalinCommonmark, ".md", ".markdown")
  }

  // ...省略...

}

データベースを利用する

データベースにMySQL 8.0、ORMにjOOQ、コネクションプールにHikariCPを利用しました。

※2019年8月現在、JavalinにはjOOQに関するプラグインやユーティリティ、コンフィグレーションクラスはないようです。なのでjOOQによるデータベースアクセス周りの実装は自前で行う必要があります。

データベースの準備

Javalinアプリケーションで使用するsample_dbデータベースとtest_userユーザーを管理者権限を持つユーザーで作成します。

CREATE DATABASE IF NOT EXISTS sample_db
  CHARACTER SET = utf8mb4
  COLLATE = utf8mb4_general_ci
;

CREATE USER IF NOT EXISTS 'test_user'@'localhost'
  IDENTIFIED BY 'test_user'
  PASSWORD EXPIRE NEVER
;

GRANT ALL ON sample_db.* TO 'test_user'@'localhost';

次にtest_userユーザーでuserテーブルを作成します。

DROP TABLE IF EXISTS user;
CREATE TABLE user (
  id BIGINT AUTO_INCREMENT                    COMMENT 'ユーザーID',
  nick_name VARCHAR(60) NOT NULL              COMMENT 'ニックネーム',
  sex CHAR(1) NOT NULL                        COMMENT '性別 M:男性 F:女性',
  prefecture_id TINYINT(1) NOT NULL DEFAULT 0 COMMENT '都道府県 0:不明、1:北海道 - 8:九州・沖縄',
  email VARCHAR(120)                          COMMENT 'メールアドレス',
  memo TEXT                                   COMMENT '備考欄',
  create_at DATETIME NOT NULL DEFAULT NOW(),
  update_at DATETIME NOT NULL DEFAULT NOW(),
  PRIMARY KEY (id)
)
ENGINE = INNODB
DEFAULT CHARSET = UTF8MB4
COMMENT = 'ユーザーテーブル';

CREATE INDEX idx_sex on user (sex) USING BTREE;
CREATE INDEX idx_pref on user (prefecture_id) USING BTREE;

依存関係を追加

pom.xmlを編集して以下の依存関係を追加します。

pom.xml
<dependency>
    <groupId>org.jooq</groupId>
    <artifactId>jooq</artifactId>
    <version>3.11.11</version>
</dependency>
<dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
    <version>3.3.1</version>
    <exclusions>
        <exclusion>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.17</version>
    <scope>runtime</scope>
</dependency>

コードの自動生成

jOOQにはjooq-codegen-mavenプラグインというデータベーススキーマからモデル(エンティティ)のコードを自動生成する機能があります。

<plugin>
    <groupId>org.jooq</groupId>
    <artifactId>jooq-codegen-maven</artifactId>
    <version>3.11.11</version>
    <executions>
        <execution>
            <id>jooq-codegen</id>
            <phase>generate-sources</phase>
            <goals>
                <goal>generate</goal>
            </goals>
        </execution>
    </executions>
    <dependencies>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.17</version>
        </dependency>
    </dependencies>
    <configuration>
        <jdbc>
            <url>jdbc:mysql://localhost:3306</url>
            <user>test_user</user>
            <password>test_user</password>
        </jdbc>
        <generator>
            <database>
                <name>org.jooq.meta.mysql.MySQLDatabase</name>
                <includes>.*</includes>
                <inputSchema>sample_db</inputSchema>
                <!-- Configure type overrides for generated fields, attributes, sequences, parameters. -->
                <forcedTypes>
                    <forcedType>
                        <!--Specify the Java type of your custom type. This corresponds to the Converter's <U> type. -->
                        <userType>com.example.demo.converter.Prefecture</userType>
                        <!-- A converter implementation for the {@link #getUserType(). -->
                        <converter>com.example.demo.converter.PrefectureConverter</converter>
                        <!-- regex to match the column name -->
                        <expression>PREFECTURE_ID</expression>
                        <types>.*</types>
                    </forcedType>
                    <forcedType>
                        <userType>com.example.demo.converter.Sex</userType>
                        <converter>com.example.demo.converter.SexConverter</converter>
                        <expression>SEX</expression>
                        <types>.*</types>
                    </forcedType>
                </forcedTypes>
            </database>
            <target>
                <packageName>com.example.demo.model</packageName>
                <directory>src/main/java</directory>
            </target>
            <generate>
                <!-- A flag indicating whether Java 8's java.time types should be used by the source code generator, rather than JDBC's java.sql types. -->
                <javaTimeTypes>true</javaTimeTypes>
                <!-- Generate index information -->
                <indexes>true</indexes>
                <!-- Primary key / foreign key relations should be generated and used. This is a prerequisite for various advanced features -->
                <relations>true</relations>
                <!-- Generate deprecated code for backwards compatibility -->
                <deprecated>false</deprecated>
                <!-- Generate the {@link javax.annotation.Generated} annotation to indicate jOOQ version used for source code -->
                <generatedAnnotation>false</generatedAnnotation>
                <!-- Generate Sequence classes -->
                <sequences>true</sequences>
                <!-- Generate Key classes -->
                <keys>true</keys>
                <!-- Generate Table classes -->
                <tables>true</tables>
                <!-- Generate TableRecord classes -->
                <records>true</records>
                <!-- Generate POJOs -->
                <pojos>true</pojos>
                <!-- Generate basic equals() and hashCode() methods in POJOs -->
                <pojosEqualsAndHashCode>true</pojosEqualsAndHashCode>
                <!-- Generate basic toString() methods in POJOs -->
                <pojosToString>true</pojosToString>
                <!-- Generate serializable POJOs -->
                <serializablePojos>true</serializablePojos>
                <!-- Generated interfaces to be implemented by records and/or POJOs -->
                <interfaces>false</interfaces>
                <!-- Turn off generation of all global object references -->
                <globalObjectReferences>true</globalObjectReferences>
                <!-- Turn off generation of global catalog references -->
                <globalCatalogReferences>true</globalCatalogReferences>
                <!-- Turn off generation of global schema references -->
                <globalSchemaReferences>true</globalSchemaReferences>
                <!-- Turn off generation of global table references -->
                <globalTableReferences>true</globalTableReferences>
                <!-- Turn off generation of global sequence references -->
                <globalSequenceReferences>true</globalSequenceReferences>
                <!-- Turn off generation of global UDT references -->
                <globalUDTReferences>true</globalUDTReferences>
                <!-- Turn off generation of global routine references -->
                <globalRoutineReferences>true</globalRoutineReferences>
                <!-- Turn off generation of global queue references -->
                <globalQueueReferences>true</globalQueueReferences>
                <!-- Turn off generation of global database link references -->
                <globalLinkReferences>true</globalLinkReferences>
                <!-- Turn off generation of global key references -->
                <globalKeyReferences>true</globalKeyReferences>
                <!-- Generate fluent setters in records, POJOs, interfaces -->
                <fluentSetters>true</fluentSetters>
            </generate>
        </generator>
    </configuration>
</plugin>

以下のmvnコマンドでコード生成を行います。(mvn packageでも生成されます)

> mvn jooq-codegen:generate

コードはcom.example.demo.modelパッケージに出力されます。今回の例では以下のコードが生成されました。

[project_root]
 |
 `--- /src
        |
        `--- /main
               |
               `--- /java
                     |
                     `--- com.example.demo.model
                     |      |
                     |      `--- DefaultCatalog.java
                     |      `--- Indexes.java
                     |      `--- Keys.java
                     |      `--- SampleDb.java
                     |      `--- Tables.java
                     |
                     `--- com.example.demo.model.tables
                     |      |
                     |      `--- User.java                          // (1)
                     |
                     `--- com.example.demo.model.tables.records
                     |      |
                     |      `--- UserRecord.java                    // (2)
                     |
                     `--- com.example.demo.model.tables.pojos
                            |
                            `--- User.java                          // (3)
  • (1) : userテーブルに対するエンティティクラスです。
    • jOOQで操作するためのメソッドが備わっています。
  • (2) : userテーブルのカラムに対するエンティティレコードクラスです。
    • jOOQで操作するためのメソッドが備わっています。
  • (3) : userテーブルに対するPojoクラス(オプション)です。この記事ではjOOQで検索したエンティティはPojoへ変換して呼び出し元(コントローラ)へ返すように実装しました。

Javalinアプリケーション側の実装

接続プロパティファイル

接続情報はプロパティファイルで管理します。

datasource.properties
datasource.jdbcUrl = jdbc:mysql://localhost:3306/sample_db
datasource.userName = test_user
datasource.password = test_user
DbPropertyLoader
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.util.Properties;

public class DbPropertyLoader {
  private final Properties prop;

  private static final String JDBC_URL = "datasource.jdbcUrl";
  private static final String USER_NAME = "datasource.userName";
  private static final String PASSWORD = "datasource.password";

  public DbPropertyLoader(String fileName) {
    prop = loadProperties(fileName);
  }

  public String getJdbcUrl() {
    return prop.getProperty(JDBC_URL);
  }

  public String getUserName() {
    return prop.getProperty(USER_NAME);
  }

  public String getPassword() {
    return prop.getProperty(PASSWORD);
  }

  private Properties loadProperties(String fileName) {
    try (InputStream stream = getClass().getClassLoader().getResourceAsStream(fileName)) {
      if (stream == null) {
        throw new IOException("Not Found : " + fileName);
      }
      Properties prop = new Properties();
      prop.load(stream);
      return prop;
    } catch (IOException e) {
      throw new UncheckedIOException(e);
    }
  }

}

DSLContextの作成

jOOQでデータベースアクセスするにはjOOQのDSLContextというクラスのインスタンスが必要です。
以下のようにDSLContextのインスタンスを保持するシングルトンのホルダークラスを実装しました。
なお、DSLContextはスレッドセーフです。

[DSLContext is thread safety?] (https://github.com/jOOQ/jOOQ/issues/7410)

DslConfigure
import org.jooq.Configuration;
import org.jooq.ConnectionProvider;
import org.jooq.DSLContext;
import org.jooq.SQLDialect;
import org.jooq.TransactionProvider;
import org.jooq.conf.Settings;
import org.jooq.impl.DefaultConfiguration;

import javax.sql.DataSource;
import java.util.Optional;

public interface DslConfigure {
  DSLContext getDslContext();

  DataSource getDataSource();
  SQLDialect getSqlDialect();
  Settings getSettings();

  // optional settings
  Optional<ConnectionProvider> getConnectionProvider();
  Optional<TransactionProvider> getTransactionProvider();

  default Configuration configuration() {
    Configuration config = new DefaultConfiguration();
    config
        .set(getSqlDialect())
        .set(getSettings());
    getConnectionProvider().ifPresentOrElse(cp -> {
      config.set(cp);
      getTransactionProvider().ifPresent(tp -> {
        config.set(tp);
      });
    }, () -> {
      config.set(getDataSource());
    });
    return config;
  }
}
DslContextHolder
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.jooq.ConnectionProvider;
import org.jooq.DSLContext;
import org.jooq.SQLDialect;
import org.jooq.TransactionProvider;
import org.jooq.conf.Settings;
import org.jooq.conf.StatementType;
import org.jooq.impl.DSL;
import org.jooq.impl.DataSourceConnectionProvider;
import org.jooq.impl.DefaultTransactionProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.sql.DataSource;
import java.io.Serializable;
import java.util.Optional;

public class DslContextHolder implements DslConfigure, Serializable {
  private static final long serialVersionUID = -8276859195238889686L;
  private static final Logger LOG = LoggerFactory.getLogger(DslContextHolder.class);

  private static DslContextHolder instance;

  private final DSLContext dslContext;
  private final DataSource dataSource;
  private final Settings settings;
  private final ConnectionProvider connectionProvider;
  private final TransactionProvider transactionProvider;

  private DslContextHolder() {
    dataSource = createDataSource();
    settings = createSettings();
    connectionProvider = new DataSourceConnectionProvider(dataSource);
    transactionProvider = new DefaultTransactionProvider(connectionProvider, true);
    // transactionProvider = new ThreadLocalTransactionProvider(connectionProvider, true);
    dslContext = DSL.using(configuration());
  }

  public static DslContextHolder getInstance() {
    if (instance == null) {
      synchronized (DslContextHolder.class) {
        if (instance == null) {
          instance = new DslContextHolder();
          LOG.debug("DSL Context create : {}", instance.getDslContext().toString());
        }
      }
    }
    return instance;
  }

  public static void destroy() {
    if (instance != null) {
      synchronized (DslContextHolder.class) {
        if (instance != null) {
          LOG.debug("DSL Context destroy : {}", instance.getDslContext().toString());
          instance.dslContext.close();
          instance = null;
        }
      }
    }
  }

  DataSource createDataSource() {
    DbPropertyLoader prop = new DbPropertyLoader("datasource.properties");
    HikariConfig config = new HikariConfig();
    config.setJdbcUrl(prop.getJdbcUrl());
    config.setUsername(prop.getUserName());
    config.setPassword(prop.getPassword());
    config.setPoolName("hikari-cp");
    config.setAutoCommit(false);
    config.setConnectionTestQuery("select 1");
    config.setMaximumPoolSize(10);
    DataSource dataSource = new HikariDataSource(config);
    return dataSource;
  }

  Settings createSettings() {
    return new Settings()
        .withStatementType(StatementType.STATIC_STATEMENT)
        .withQueryTimeout(10)
        .withMaxRows(1000)
        .withFetchSize(20)
        .withExecuteLogging(true);
  }

  @Override
  public DSLContext getDslContext() {
    return this.dslContext;
  }

  @Override
  public DataSource getDataSource() {
    return this.dataSource;
  }

  @Override
  public SQLDialect getSqlDialect() {
    return SQLDialect.MYSQL_8_0;
  }

  @Override
  public Settings getSettings() {
    return this.settings;
  }

  @Override
  public Optional<ConnectionProvider> getConnectionProvider() {
    //
    return Optional.ofNullable(this.connectionProvider);
  }

  @Override
  public Optional<TransactionProvider> getTransactionProvider() {
    //
    return Optional.ofNullable(this.transactionProvider);
  }

}

サービスの実装例

jOOQのDSLContextと自動生成したコード(エンティティクラス)を利用してデータベースアクセスを行うサービスクラスの実装例です。
※通常はビジネスロジックを記述することろですが、サンプルコードなのでリポジトリのような実装になってしまっています。

UserService
import com.example.demo.model.tables.pojos.User;

import java.util.List;
import java.util.Optional;

public interface UserService {
  Optional<User> findById(Long id);
  List<User> findByNickName(String nickName);
  List<User> findAll();
  User save(User user);
  void remove(Long id);
}
UserServiceImpl
import com.example.demo.AppServiceException;
import com.example.demo.model.tables.pojos.User;
import com.example.demo.model.tables.records.UserRecord;
import com.example.demo.service.UserService;
import org.jooq.DSLContext;
import org.jooq.Result;
import org.jooq.impl.DSL;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

import static com.example.demo.model.Tables.USER;

public class UserServiceImpl implements UserService {
  private static final Logger LOG = LoggerFactory.getLogger(UserServiceImpl.class);
  private final DSLContext dsl;

  public UserServiceImpl(DSLContext dsl) {
    this.dsl = dsl;
  }

  @Override
  public Optional<User> findById(Long id) {
    LOG.debug("find by Id : {}", id);
    UserRecord record = dsl
        .selectFrom(USER)
        .where(USER.ID.eq(id))
        .fetchOne();
    if (record != null) {
      return Optional.of(record.into(User.class));
    }
    return Optional.empty();
  }

  @Override
  public List<User> findByNickName(String nickName) {
    LOG.debug("find by nickName : {}", nickName);
    Result<UserRecord> result = dsl
        .selectFrom(USER)
        .where(USER.NICK_NAME.contains(nickName))
        .orderBy(USER.NICK_NAME.asc())
        .fetch();
    return result.into(User.class);
  }

  @Override
  public List<User> findAll() {
    LOG.debug("findAll");
    Result<UserRecord> result = dsl
        .selectFrom(USER)
        .orderBy(USER.ID.desc())
        .fetch();
    return result.into(User.class);
  }

  @Override
  public User save(User user) {
    LOG.debug("save : {}", user);
    UserRecord result = dsl.transactionResult(conf -> {
      if (user.getId() == null || user.getId().equals(0L)) {
        return DSL.using(conf)
            .insertInto(USER,
                USER.NICK_NAME,
                USER.SEX,
                USER.PREFECTURE_ID,
                USER.EMAIL,
                USER.MEMO)
            .values(
                user.getNickName(),
                user.getSex(),
                user.getPrefectureId(),
                user.getEmail(),
                user.getMemo())
            .returning()
            .fetchOne();
      } else {
        return DSL.using(conf)
            .update(USER)
            .set(USER.NICK_NAME, user.getNickName())
            .set(USER.SEX, user.getSex())
            .set(USER.PREFECTURE_ID, user.getPrefectureId())
            .set(USER.EMAIL, user.getEmail())
            .set(USER.MEMO, user.getMemo())
            .set(USER.UPDATE_AT, LocalDateTime.now())
            .where(USER.ID.eq(user.getId()))
            .returning()
            .fetchOne();
      }
    });
    LOG.debug("save result : {}", result);
    if (result == null) {
      throw new AppServiceException("[save] Not Found saved user record.");
    }
    return result.into(User.class);
  }

  @Override
  public void remove(Long id) {
    LOG.debug("remove by id : {}", id);
    int result = dsl.transactionResult(conf -> {
      int count = DSL.using(conf)
          .selectCount()
          .from(USER)
          .where(USER.ID.eq(id))
          .execute();
      if (count != 1) {
        return count;
      }
      return DSL.using(conf)
          .deleteFrom(USER)
          .where(USER.ID.eq(id))
          .execute();
    });
    LOG.debug("remove result : {}", result);
    if (result == 0) {
      throw new AppServiceException("[remove] Not Found delete user record.");
    }
  }

}

コントローラの実装例

上記のサービスを利用するコントローラの実装例です。

UserController
import com.example.demo.model.tables.pojos.User;
import com.example.demo.service.UserService;
import io.javalin.Javalin;
import io.javalin.apibuilder.ApiBuilder;
import io.javalin.http.Context;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.List;

public class UserController {
  private static final Logger LOG = LoggerFactory.getLogger(UserController.class);
  private final UserService userService;

  public UserController(UserService userService) {
    this.userService = userService;
  }

  void findById(@NotNull Context ctx) {
    Long id = ctx.pathParam("id", Long.class).get();
    userService.findById(id).ifPresentOrElse(user -> {
      ctx.json(user);
    },() -> {
      ctx.status(404);
    });
  }

  void findAll(@NotNull Context ctx) {
    List<User> users = userService.findAll();
    ctx.json(users);
  }

  void store(@NotNull Context ctx) {
    User user = ctx.bodyAsClass(User.class);
    User storedUser = userService.save(user);
    ctx.json(storedUser);
  }

  void remove(@NotNull Context ctx) {
    Long id = ctx.pathParam("id", Long.class).get();
    userService.remove(id);
    ctx.status(200);
  }

  public void bindEndpoints(@NotNull Javalin app) {
    app.routes(() -> {
      ApiBuilder.path("api/user", () -> {
        // GET /api/user
        ApiBuilder.get(this::findAll);
        // GET /api/user/:id
        ApiBuilder.get(":id", this::findById);
        // POST /api/user
        ApiBuilder.post(this::store);
        // DELETE /api/user/:id
        ApiBuilder.delete(":id", this::remove);
      });
    });
  }

}

ハンドラのバインドはJavalinアプリケーションの起動ポイントで行いました。

public static void main(String... args) {
  Javalin app = Javalin
    .create(config -> {
      // configuration
    })
    .start(7000);

  // ... 省略 ...

  // DSLContextの生成
  final DSLContext dsl = DslContextHolder.getInstance().getDslContext();

  // アプリケーションにハンドラをバインドする
  new UserController(new UserServiceImpl(dsl)).bindEndpoints(app);

  // ... 省略 ...
}

テストコード

テスティングフレームワークにJUnit 5、アサーションにAssertJを利用しました。

依存関係を追加

pom.xmlを編集して以下の依存関係を追加します。

pom.xml
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.5.1</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.13.2</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <!--<version>1.4.199</version>-->
    <version>1.4.193</version>
    <scope>test</scope>
</dependency>

サービスクラスのテスト

サービスクラスのテストは単体テストとして実装します。サービスが依存する外部リソースのデータベースにはH2を使用します。
テスト開始前にスキーマを作成、テストメソッド毎にテストデータをロード、テスト終了後にスキーマを削除するという方法でテスト環境を準備します。

テスト用の接続プロパティファイル

test-datasource.properties
#datasource.driver = org.h2.Driver
datasource.jdbcUrl = jdbc:h2:mem:sample_db;MODE=MySQL;DB_CLOSE_DELAY=-1;
#datasource.jdbcUrl = jdbc:h2:tcp://localhost:9092/./sample_db;MODE=MySQL;DATABASE_TO_UPPER=false
datasource.userName = sa
datasource.password =

テスト用DSLContextの作成

H2用のDSLContextを作成するコードを実装します。

TestDslContextHolder
import com.example.demo.config.DbPropertyLoader;
import com.example.demo.config.DslConfigure;
import org.h2.jdbcx.JdbcDataSource;
import org.jooq.ConnectionProvider;
import org.jooq.DSLContext;
import org.jooq.SQLDialect;
import org.jooq.TransactionProvider;
import org.jooq.conf.Settings;
import org.jooq.conf.StatementType;
import org.jooq.impl.DSL;
import org.jooq.impl.DataSourceConnectionProvider;
import org.jooq.impl.DefaultTransactionProvider;

import javax.sql.DataSource;
import java.io.Serializable;
import java.util.Optional;

public class TestDslContextHolder implements DslConfigure, Serializable {
  private static final long serialVersionUID = -5728144007357037196L;

  private static TestDslContextHolder instance;

  private final DSLContext dslContext;
  private final DataSource dataSource;
  private final Settings settings;
  private final ConnectionProvider connectionProvider;
  private final TransactionProvider transactionProvider;

  public TestDslContextHolder() {
    dataSource = createDataSource();
    settings = createSettings();
    connectionProvider = new DataSourceConnectionProvider(dataSource);
    transactionProvider = new DefaultTransactionProvider(connectionProvider, true);
    // transactionProvider = new ThreadLocalTransactionProvider(connectionProvider, true);
    dslContext = DSL.using(configuration());
  }

  public static TestDslContextHolder getInstance() {
    if (instance == null) {
      synchronized (TestDslContextHolder.class) {
        if (instance == null) {
          instance = new TestDslContextHolder();
        }
      }
    }
    return instance;
  }

  public static void destroy() {
    if (instance != null) {
      synchronized (TestDslContextHolder.class) {
        if (instance != null) {
          instance.dslContext.close();
          instance = null;
        }
      }
    }
  }

  DataSource createDataSource() {
    DbPropertyLoader prop = new DbPropertyLoader("test-datasource.properties");
    JdbcDataSource dataSource = new JdbcDataSource();
    dataSource.setURL(prop.getJdbcUrl());
    dataSource.setUser(prop.getUserName());
    dataSource.setPassword(prop.getPassword());
    return dataSource;
  }

  Settings createSettings() {
    return new Settings()
        .withStatementType(StatementType.STATIC_STATEMENT)
        .withQueryTimeout(10)
        .withMaxRows(1000)
        .withFetchSize(20)
        .withExecuteLogging(true)
        .withDebugInfoOnStackTrace(true)
        .withRenderFormatted(true);
  }

  @Override
  public DSLContext getDslContext() {
    return this.dslContext;
  }

  @Override
  public DataSource getDataSource() {
    return this.dataSource;
  }

  @Override
  public SQLDialect getSqlDialect() {
    return SQLDialect.H2;
  }

  @Override
  public Settings getSettings() {
    return this.settings;
  }

  @Override
  public Optional<ConnectionProvider> getConnectionProvider() {
    //
    return Optional.ofNullable(this.connectionProvider);
  }

  @Override
  public Optional<TransactionProvider> getTransactionProvider() {
    //
    return Optional.ofNullable(this.transactionProvider);
  }

}

サービスのテストクラス
このコードはインメモリのH2を使用するパターンです。(サーバーモードのH2を利用するパターンは後述します。)

TestService
import org.jooq.DSLContext;
import org.jooq.Queries;
import org.jooq.Query;
import org.jooq.Schema;
import org.jooq.Table;
import org.jooq.exception.DataAccessException;
import org.jooq.impl.DSL;

import java.util.List;

public interface TestService {

  DSLContext getDSLContext();
  void setDSLContext(DSLContext dsl);

  default void createSchema(Schema schema) {
    Queries queries = getDSLContext().ddl(schema);
    for (Query query : queries.queries()) {
      getDSLContext().execute(query);
    }
  }

  default void dropSchema(Schema schema) {
    List<Table<?>> tables = schema.getTables();
    for (Table<?> table : tables) {
      getDSLContext().dropTableIfExists(table).execute();
    }
    getDSLContext().dropSchemaIfExists(schema).execute();
  }

  default void cleanUp(List<Table<?>> tables) {
    getDSLContext().transaction(conf -> {
      for (Table table : tables) {
        DSL.using(conf).deleteFrom(table).execute();
      }
    });
  }

  default void testDataRollback() {
    throw new DataAccessException("rollback");
  }

  default void isFailed(RuntimeException e) {
    if (!e.getMessage().equals("rollback")) {
      System.err.println(e);
      throw e;
    }
  }

}
UserServiceImplTests
import com.example.demo.TestDslContextHolder;
import com.example.demo.converter.Prefecture;
import com.example.demo.converter.Sex;
import com.example.demo.model.tables.pojos.User;
import com.example.demo.model.tables.records.UserRecord;
import com.example.demo.service.TestService;
import com.example.demo.service.UserService;
import org.assertj.core.groups.Tuple;
import org.jooq.DSLContext;
import org.jooq.exception.DataAccessException;
import org.jooq.impl.DSL;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;

import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static com.example.demo.model.SampleDb.SAMPLE_DB;
import static com.example.demo.model.Tables.*;

@TestInstance(value = TestInstance.Lifecycle.PER_CLASS)
public class UserServiceImplTests implements TestService {

  private DSLContext dsl;

  @Override
  public DSLContext getDSLContext() {
    return dsl;
  }

  @Override
  public void setDSLContext(DSLContext dsl) {
    this.dsl = dsl;
  }

  @BeforeAll
  public void setupSchema() {
    System.out.println("setup class");
    setDSLContext(TestDslContextHolder.getInstance().getDslContext());
    createSchema(SAMPLE_DB);
  }

  @AfterAll
  public void tearDownSchema() {
    System.out.println("teardown class");
    dropSchema(SAMPLE_DB);
    TestDslContextHolder.destroy();
  }

  @BeforeEach
  public void setup() {
    System.out.println("setup");
  }

  @AfterEach
  public void tearDown() {
    System.out.println("teardown");
    cleanUp(List.of(USER));
  }

  @Test
  public void findById() {
    System.out.println("findById");
    try {
      dsl.transaction(conf -> {
        // setup test data
        DSL.using(conf)
            .insertInto(USER,
                USER.ID,
                USER.NICK_NAME,
                USER.SEX,
                USER.PREFECTURE_ID,
                USER.EMAIL,
                USER.MEMO)
            .values(1L, "nick name AAA", Sex.FEMALE, Prefecture.HOKKAIDO, "test.user_a@example.com", "memo aaa")
            .values(2L, "nick name BBB", Sex.MALE, Prefecture.TOHOKU, "test.user_b@example.com", null)
            .values(3L, "nick name CCC", Sex.FEMALE, Prefecture.KANTOU, "test.user_c@example.com", "memo ccc")
            .execute();

        // exercise
        UserService sut = new UserServiceImpl(DSL.using(conf));
        Optional<User> actual = sut.findById(1L);

        // verify
        assertThat(actual.isPresent()).isTrue();
        actual.ifPresent(user -> {
          assertThat(user)
              .extracting(
                  "id",
                  "nickName",
                  "sex",
                  "prefectureId",
                  "email",
                  "memo")
              .contains(
                  1L,
                  "nick name AAA",
                  Sex.FEMALE,
                  Prefecture.HOKKAIDO,
                  "test.user_a@example.com",
                  "memo aaa");
        });

        testDataRollback();
      });
    } catch (DataAccessException e) {
      isFailed(e);
    }
  }

  @Test
  public void findByNickName() {
    System.out.println("findByNickName");
    try {
      dsl.transaction(conf -> {
        // setup test data
        DSL.using(conf)
            .insertInto(USER,
                USER.ID,
                USER.NICK_NAME,
                USER.SEX,
                USER.PREFECTURE_ID,
                USER.EMAIL,
                USER.MEMO)
            .values(1L, "nick name AAA", Sex.FEMALE, Prefecture.HOKKAIDO, "test.user_a@example.com", "memo aaa")
            .values(2L, "nick name BBB", Sex.MALE, Prefecture.TOHOKU, "test.user_b@example.com", null)
            .values(3L, "nick name CCC", Sex.FEMALE, Prefecture.KANTOU, "test.user_c@example.com", "memo ccc")
            .execute();

        // exercise
        UserService sut = new UserServiceImpl(DSL.using(conf));
        List<User> actual = sut.findByNickName("BBB");

        // verify
        assertThat(actual).hasSize(1);
        assertThat(actual)
            .extracting(
                "id",
                "nickName",
                "sex",
                "prefectureId",
                "email",
                "memo")
            .contains(Tuple.tuple(2L, "nick name BBB", Sex.MALE, Prefecture.TOHOKU, "test.user_b@example.com", null));

        testDataRollback();
      });
    } catch (DataAccessException e) {
      isFailed(e);
    }
  }

  @Test
  public void save() {
    try {
      dsl.transaction(conf -> {
        // exercise
        User testData = new User(null, "nick name DDD", Sex.MALE, Prefecture.KANTOU, "test.user_d@example.com", null, null, null);
        UserService sut = new UserServiceImpl(DSL.using(conf));
        User expected = sut.save(testData);

        // verify
        Optional<User> actual = sut.findById(expected.getId());
        assertThat(actual.isPresent()).isTrue();
        actual.ifPresent(user -> {
          assertThat(user)
              .extracting(
                  "id",
                  "nickName",
                  "sex",
                  "prefectureId",
                  "email",
                  "memo")
              .contains(
                  expected.getId(),
                  expected.getNickName(),
                  expected.getSex(),
                  expected.getPrefectureId(),
                  expected.getEmail(),
                  expected.getMemo());
        });

        testDataRollback();
      });
    } catch (DataAccessException e) {
      isFailed(e);
    }
  }

  @Test
  public void remove() {
    try {
      dsl.transaction(conf -> {
        // setup
        Long userId = 1L;
        DSL.using(conf)
            .insertInto(USER,
                USER.ID,
                USER.NICK_NAME,
                USER.SEX,
                USER.PREFECTURE_ID,
                USER.EMAIL,
                USER.MEMO)
            .values(
                userId,
                "nick name EEE",
                Sex.MALE,
                Prefecture.CHUBU,
                "test.user_e@example.com",
                "memo eee")
            .execute();

        // exercise
        UserService sut = new UserServiceImpl(DSL.using(conf));
        sut.remove(userId);

        // verify
        UserRecord actual = DSL.using(conf)
            .selectFrom(USER)
            .where(USER.ID.eq(userId))
            .fetchOne();
        assertThat(actual).isNull();

        testDataRollback();
      });
    } catch (DataAccessException e) {
      isFailed(e);
    }
  }

}

H2をサーバモードで使用する

サーバーモードではスキーマやテーブル、テストデータは以下の場所に配置したSQLスクリプトを実行して作成します。
テストを実行すると、H2データベースのデータベースファイルがsample_db.mv.dbファイルとして作成されます。

[project_root]
 |
 `--- /h2
        |
        `--- /sql
        |      |
        |      `--- 01_schema.sql     //スキーマを作成する
        |      `--- 02_table.sql      //テーブルを作成する
        |      `--- 03_data.sql       //データを挿入する
        |      `--- 04_table.sql      //テーブルを削除する
        |      `--- 05_schema.sql     //スキーマを削除する
        |
        `--- sample_db.mv.db
TestH2Server
import com.example.demo.config.DbPropertyLoader;
import org.h2.tools.RunScript;
import org.h2.tools.Server;

import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;

public class TestH2Server implements AutoCloseable {

  private final Server server;

  private final String jdbcUrl;
  private final String userName;
  private final String password;

  public TestH2Server() {
    DbPropertyLoader prop = new DbPropertyLoader("test-datasource.properties");
    jdbcUrl = prop.getJdbcUrl();
    userName = prop.getUserName();
    password = prop.getPassword();
    String[] params = new String[] {"-tcp", "-tcpPort", "9092", "-baseDir", "./h2"};
    try {
      server = Server.createTcpServer(params);
    } catch (SQLException e) {
      System.err.println(e.getMessage());
      throw new RuntimeException(e);
    }
  }

  public void start() {
    System.out.println("server start");
    try {
      server.start();
      status();
      Runtime.getRuntime().addShutdownHook(new Thread(() -> shutdown()));
    } catch (SQLException e) {
      throw new RuntimeException(e);
    }
  }

  public void runScript(List<String> scripts) {
    try {
      for (String script : scripts) {
        System.out.println("run script : " + script);
        RunScript.execute(jdbcUrl, userName, password, script, StandardCharsets.UTF_8, false);
      }
    } catch (SQLException e) {
      System.err.println("run script : " + e.getMessage());
      throw new RuntimeException(e);
    }
  }

  public void shutdown() {
    if (server.isRunning(true)) {
      System.out.println("server shutdown");
      server.shutdown();
    }
  }

  public void status() {
    System.out.println(server.getStatus());
  }

  // for test
  public static void main(String ... args) {
    try (TestH2Server h2 = new TestH2Server()) {
      h2.start();
      h2.status();
      h2.runScript(List.of("./h2/sql/01_schema.sql", "./h2/sql/02_table.sql", "./h2/sql/03_data.sql"));
      h2.test();
      h2.runScript(List.of("./h2/sql/04_table.sql", "./h2/sql/05_schema.sql"));
    }
    System.exit(0);
  }

  public void test() {
    System.out.println("test");
    try (Connection conn = DriverManager.getConnection(jdbcUrl, userName, password)) {
      PreparedStatement ps = conn.prepareStatement("select * from sample_db.user");
      ResultSet rs = ps.executeQuery();
      while (rs.next()) {
        System.out.println("id : " + rs.getLong("id"));
        System.out.println("nick_name : " + rs.getString("nick_name"));
        System.out.println("sex : " + rs.getString("sex"));
        System.out.println("prefecture_id : " + rs.getInt("prefecture_id"));
        System.out.println("email : " + rs.getString("email"));
        System.out.println("memo : " + rs.getString("memo"));
        System.out.println("createAt : " + rs.getTimestamp("create_at"));
        System.out.println("updateAt : " + rs.getTimestamp("update_at"));
      }
    } catch (SQLException e) {
      System.err.println("test error : " + e.getMessage());
      throw new RuntimeException(e);
    }
  }

  @Override
  public void close() {
    shutdown();
  }

}

サービスのテストクラスをH2サーバーを利用するように修正します。修正する箇所はsetupSchema、tearDownSchemaメソッドです。

@TestInstance(value = TestInstance.Lifecycle.PER_CLASS)
public class UserServiceImplTests implements TestService {

  private DSLContext dsl;
  private TestH2Server h2;

  private List<String> initScripts = List.of(
      "./h2/sql/01_schema.sql",
      "./h2/sql/02_table.sql"
  );

  private List<String> clearScripts = List.of(
      "./h2/sql/04_table.sql",
      "./h2/sql/05_schema.sql"
  );

  @Override
  public DSLContext getDSLContext() {
    return dsl;
  }

  @Override
  public void setDSLContext(DSLContext dsl) {
    this.dsl = dsl;
  }

  @BeforeAll
  public void setupSchema() {
    System.out.println("setup class");
    h2 = new TestH2Server();
    h2.start();
    h2.runScript(initScripts);
    setDSLContext(TestDslContextHolder.getInstance().getDslContext());
  }

  @AfterAll
  public void tearDownSchema() {
    System.out.println("teardown class");
    TestDslContextHolder.destroy();
    h2.runScript(clearScripts);
    h2.shutdown();
  }

  //... 省略...

}

コントローラクラスのテスト

コントローラクラスのテストは結合テストとして実装します。データベースは実環境と同じMySQLを使用するため、テスト実行時はMySQLサーバにアクセスできる必要があります。

コントローラのテストクラス

import com.example.demo.config.DslContextHolder;
import com.example.demo.config.JacksonConfig;
import com.example.demo.converter.Prefecture;
import com.example.demo.converter.Sex;
import com.example.demo.model.tables.pojos.User;
import com.example.demo.service.impl.UserServiceImpl;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.javalin.Javalin;
import io.javalin.plugin.json.JavalinJackson;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

import static org.assertj.core.api.Assertions.assertThat;

@TestInstance(value = TestInstance.Lifecycle.PER_CLASS)
public class UserControllerTests {

  Javalin app;
  int port;
  ObjectMapper objectMapper;

  @BeforeAll
  public void setup() {
    port = 7000;
    app = Javalin.create().start(port);
    objectMapper = JacksonConfig.getObjectMapper();
    JavalinJackson.configure(objectMapper);
  }

  @AfterAll
  public void tearDown() {
    app.stop();
  }

  @Test
  public void findById() throws Exception {
    // setup
    new UserController(new UserServiceImpl(DslContextHolder.getInstance().getDslContext()))
        .bindEndpoints(app);

    // exercise
    Long userId = 1L;
    String testTargetUrl = String.format("http://localhost:%d/api/user/%d", port, userId);

    HttpClient client = HttpClient.newHttpClient();
    HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create(testTargetUrl))
        .header("Accept", "application/json")
        .GET()
        .build();
    HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());

    // verify
    assertThat(response.statusCode()).isEqualTo(200);
    User actual = objectMapper.readValue(response.body(), User.class);
    assertThat(actual)
        .extracting(
            "id",
            "nickName",
            "sex",
            "prefectureId",
            "email",
            "memo")
        .contains(
            1L,
            "test user 1",
            Sex.MALE,
            Prefecture.HOKKAIDO,
            "test.user1@example.com",
            "memo 1"
        );
  }

}
10
9
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
10
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?