概要
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
参考
- [Javalin - A simple web framework for Java and Kotlin] (https://javalin.io/)
- [Spark - A micro framework for creating web applications in Kotlin and Java 8 with minimal effort] (http://sparkjava.com/)
- [Embedding Jetty] (https://www.eclipse.org/jetty/documentation/current/embedding-jetty.html)
最小構成でHello World
- 参考チュートリアル : [Setting up Javalin with Maven] (https://javalin.io/tutorials/maven-setup)
プロジェクト構成
通常のMavenプロジェクトです。
[project_root]
|
`--- /src
| |
| `--- /main
| | |
| | `--- /java
| | | |
| | | `--- com.example.demo (package)
| | | |
| | | `--- App.java
| | |
| | `--- /resources
| |
| `--- /test
| |
| `--- /java
| |
| `--- /resources
|
`--- pom.xml
pom.xml
最小構成のJavalinアプリケーションに必要な依存関係はjavalinとslf4j-simpleです。
<?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アプリケーションのコンフィグレーション
- 参考 : [Configuration] (https://javalin.io/documentation#configuration)
Javalin.createメソッドにJavalinConfigクラスのインスタンスを渡すことでカスタマイズができます。
以下のようにJavalinConfigのインスタンスを渡す方法の他に、lambda式で記述する方法もあります。
インスタンス
JavalinConfig config = new JavalinConfig();
// configuration
Javalin
.create(config)
// ... 省略 ...
lambda式
Javalin
.create(config -> {
// configuration
})
// ... 省略 ...
主なコンフィグレーション
コンテキストパスの設定 (デフォルトは"/")
config.contextPath = "/app";
デフォルトのコンテンツタイプの設定 (デフォルトは"text/plain")
config.defaultContentType = "application/json";
ETagの設定 (デフォルトはfalse)
config.autogenerateEtags = true;
リクエストログの出力はJavalinConfig.requestLoggerで設定できます。
第二引数(execTime)はリクエストに掛かった時間(ms)です。
config.requestLogger((ctx, execTime) -> {
LOG.debug("[{}] {} - {} ms.", ctx.fullUrl(), ctx.userAgent(), execTime);
});
開発用コンフィグレーション
開発時に有効にすると便利な機能もあります。
enableDevLogging
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
config.registerPlugin(new RouteOverviewPlugin("/overview"));
このプラグインを登録して http://localhost:7000/overview
にアクセスするとJavalinアプリケーションに追加されているリクエストハンドラの一覧を確認することができます。
エラーレスポンスのカスタマイズ
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というアプリケーション固有の例外がスローされた場合、
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を扱う場合に必要です。
<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でパスを指定します。
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で絶対パスを指定します。なお、ここで指定したパスにアクセスできないとアプリケーション起動時にエラーが発生します。
config.addStaticFiles("C:\\var\\static", Location.EXTERNAL);
htmlファイルで下記のようにパスを指定します。
<img src="/images/sample_1.jpg" />
WebJarsを利用する
pom.xmlの依存関係に利用したいライブラリを追加します。
この例ではjQueryを利用しました。
<dependency>
<groupId>org.webjars</groupId>
<artifactId>jquery</artifactId>
<version>3.4.1</version>
</dependency>
これだけではjQueryは有効になりません。Webjarsをアプリケーションで利用できるようにするためにJavalinConfig.enableWebjarsで有効化します。
config.enableWebjars();
htmlファイルで下記のようにパスを指定します。
<script src="/webjars/jquery/3.4.1/jquery.min.js"></script>
ロギングにlogbackを利用する
ロギングにslf4jとlogbackを利用します。slf4j-api
はすでにJavalinが依存しているので追加するのはlogback-classic
だけになります。
また最初に追加していたslf4j-simple
はコメントアウトします。
<!--
<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
<?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を利用します。
<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>
メッセージリソースの定義
msg.hello = Hello World (default)
msg.hello = Hello World
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を実装したクラスを作ります。
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は以下のとおりです。
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を編集して以下の依存関係を追加します。
- [jOOQ] (https://www.jooq.org/)
- [HikariCP] (https://github.com/brettwooldridge/HikariCP)
- [MySQL Connector/J] (https://dev.mysql.com/doc/connector-j/8.0/en/)
<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
プラグインというデータベーススキーマからモデル(エンティティ)のコードを自動生成する機能があります。
- [Running the code generator with Maven] (https://www.jooq.org/doc/3.11/manual/code-generation/codegen-maven/)
- [Configuration and setup of the generator] (https://www.jooq.org/doc/3.11/manual/code-generation/codegen-configuration/)
- [jooq-codegen-3.11.0.xsd] (https://www.jooq.org/xsd/jooq-codegen-3.11.0.xsd)
<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.jdbcUrl = jdbc:mysql://localhost:3306/sample_db
datasource.userName = test_user
datasource.password = test_user
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)
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;
}
}
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と自動生成したコード(エンティティクラス)を利用してデータベースアクセスを行うサービスクラスの実装例です。
※通常はビジネスロジックを記述することろですが、サンプルコードなのでリポジトリのような実装になってしまっています。
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);
}
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.");
}
}
}
コントローラの実装例
上記のサービスを利用するコントローラの実装例です。
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を編集して以下の依存関係を追加します。
- [JUnit 5] (https://junit.org/junit5/)
- [AssertJ] (https://joel-costigliola.github.io/assertj/)
<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を使用します。
テスト開始前にスキーマを作成、テストメソッド毎にテストデータをロード、テスト終了後にスキーマを削除するという方法でテスト環境を準備します。
テスト用の接続プロパティファイル
#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を作成するコードを実装します。
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を利用するパターンは後述します。)
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;
}
}
}
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
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"
);
}
}