概要
2015年に投稿した[Thymeleafを使用した入力フォームのサンプルコード] (https://qiita.com/rubytomato@github/items/387d46ea34eb92071065)という記事の改定版です。
Spring Bootが標準サポートするテンプレートエンジンのThymeleafを使用した入力フォームのサンプルコードになります。
ソースコードは[rubytomato/demo-bootstrap4-thymeleaf-spring2] (https://github.com/rubytomato/demo-bootstrap4-thymeleaf-spring2)にあります。
環境
- Windows 10 professional
- Java 1.8.0_162
- Thymeleaf 3.0.9
- Spring Boot 2.0.1
- Bootstrap 4.1
参考
- [Tutorial: Using Thymeleaf] (https://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html)
- [Tutorial: Thymeleaf + Spring] (https://www.thymeleaf.org/doc/tutorials/3.0/thymeleafspring.html)
- [Thymeleaf - GitHub] (https://github.com/thymeleaf)
- [Introduction Bootstrap] (http://getbootstrap.com/docs/4.1/getting-started/introduction/)
- [Components] (http://getbootstrap.com/docs/4.1/components/alerts/)
- [Utilities] (http://getbootstrap.com/docs/4.1/utilities/borders/)
サンプルコード
入力画面
テンプレートファイル
<form action="#" th:action="@{/simple}" th:object="${simpleForm}" method="post">
<!-- text -->
<div class="col-md-9 mb-3">
<label class="control-label" for="singleLineText">Single Line Text <span class="text-muted">(2 - 120)</span></label>
<input type="text" class="form-control" th:field="*{singleLineText}" />
<div th:if="${#fields.hasErrors('singleLineText')}" th:errors="*{singleLineText}" class="help-block">error</div>
</div>
<!-- date type text -->
<div class="col-md-9 mb-3">
<label class="control-label" for="textDate">Date (text type) <span class="text-muted">(yyyy/mm/dd)</span></label>
<input type="datetime" class="form-control" th:field="*{textDate}" placeholder="yyyy/mm/dd" />
<div th:if="${#fields.hasErrors('textDate')}" th:errors="*{textDate}" class="help-block">error</div>
</div>
<!-- date -->
<div class="col-md-9 mb-3">
<label class="control-label" for="date">Date <span class="text-muted">(yyyy/mm/dd)</span></label>
<input type="datetime" class="form-control" th:field="*{date}" placeholder="yyyy/mm/dd" />
<div th:if="${#fields.hasErrors('date')}" th:errors="*{date}" class="help-block">error</div>
</div>
<!-- number type text -->
<div class="col-md-9 mb-3">
<label class="control-label" for="textNum">Number (text type) <span class="text-muted">(0 - 999999999)</span></label>
<input type="number" class="form-control" th:field="*{textNum}" />
<div th:if="${#fields.hasErrors('textNum')}" th:errors="*{textNum}" class="help-block">error</div>
</div>
<!-- number -->
<div class="col-md-9 mb-3">
<label class="control-label" for="num">Number <span class="text-muted">(0 - 999999999)</span></label>
<input type="number" class="form-control" th:field="*{num}" />
<div th:if="${#fields.hasErrors('num')}" th:errors="*{num}" class="help-block">error</div>
</div>
<!-- text area -->
<div class="col-md-9 mb-3">
<label class="control-label" for="multiLineText">Text Area <span class="text-muted">(Optional, 10 - 600)</span></label>
<textarea rows="3" cols="80" class="form-control" th:field="*{multiLineText}"></textarea>
<div th:if="${#fields.hasErrors('multiLineText')}" th:errors="*{multiLineText}" class="help-block">error</div>
</div>
<!-- email -->
<div class="col-md-9 mb-3">
<label class="control-label" for="email">Email <span class="text-muted">(Optional)</span></label>
<input type="email" class="form-control" th:field="*{email}" placeholder="you@example.com" />
<div th:if="${#fields.hasErrors('email')}" th:errors="*{email}" class="help-block">error</div>
</div>
<!-- password -->
<div class="col-md-9 mb-3">
<label class="control-label" for="password">Password <span class="text-muted">(6 - 99)</span></label>
<input type="password" class="form-control" th:field="*{password}" />
<div th:if="${#fields.hasErrors('password')}" th:errors="*{password}" class="help-block">error</div>
</div>
<hr class="mb-4">
<!-- select single -->
<div class="col-md-9 mb-3">
<label class="control-label" for="singleSelect">Single Select <span class="text-muted">(Optional)</span></label>
<select class="custom-select d-block w-100" th:field="*{singleSelect}">
<option value="">---</option>
<option th:each="item : ${selectItems}" th:value="${item.value}" th:text="${item.key}">pulldown</option>
</select>
<div th:if="${#fields.hasErrors('singleSelect')}" th:errors="*{singleSelect}" class="help-block">error</div>
</div>
<!-- select multi -->
<div class="col-md-9 mb-3">
<label class="control-label" for="multiSelects">Multi Select</label>
<select class="form-control" th:field="*{multiSelects}" multiple="multiple" size="4">
<option th:each="item : ${selectItems}" th:value="${item.value}" th:text="${item.key}">pulldown</option>
</select>
<div th:if="${#fields.hasErrors('multiSelects')}" th:errors="*{multiSelects}" class="help-block">error</div>
</div>
<hr class="mb-4">
<!-- checkbox single -->
<div class="col-md-9 mb-3">
<label class="control-label">Single Checkbox</label>
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" th:id="${#ids.seq('singleCheck')}" th:field="*{singleCheck}" value="on" />
<label class="custom-control-label" th:for="${#ids.prev('singleCheck')}">Active</label>
</div>
<div th:if="${#fields.hasErrors('singleCheck')}" th:errors="*{singleCheck}" class="help-block">error</div>
</div>
<!-- checkbox multi -->
<div class="col-md-9 mb-3">
<label class="control-label">Multi Checkbox</label>
<div class="custom-control custom-checkbox" th:each="item : ${checkItems}">
<input type="checkbox" class="custom-control-input" th:value="${item.value}" th:field="*{multiChecks}" />
<label class="custom-control-label" th:for="${#ids.prev('multiChecks')}" th:text="${item.key}"></label>
</div>
<div th:if="${#fields.hasErrors('multiChecks')}" th:errors="*{multiChecks}" class="help-block">error</div>
</div>
<hr class="mb-4">
<!-- radio -->
<div class="col-md-9 mb-3">
<label class="control-label">Radio Button</label>
<div class="custom-control custom-radio" th:each="item : ${radioItems}">
<input type="radio" class="custom-control-input" th:value="${item.value}" th:field="*{radio}" />
<label class="custom-control-label" th:for="${#ids.prev('radio')}" th:text="${item.key}"></label>
</div>
<div th:if="${#fields.hasErrors('radio')}" th:errors="*{radio}" class="help-block">error</div>
</div>
<button class="btn btn-primary btn-lg btn-block" type="submit">confirm</button>
</form>
Thymeleafで入力フォームを組み立てるときにth:field
属性を利用すると便利です。なお、この属性はThymeleafの標準機能ではなくThymeleaf-Spring5で提供されている拡張機能です。
input
th:field
にはフォームBeanのフィールドを指定します。
以下の例のようにid、name、value属性が自動的に設定されます。
<input type="text" th:field="*{singleLineText}" />
<input type="text" id="singleLineText" name="singleLineText" value="">
checkbox
checkboxでもth:field
を利用することができます。ただしid属性は一意になるように#idsを使って上書きします。
フォームBeanに初期値が設定されていればchecked="checked"
が自動的に付加されます。
またチェックの無いチェックボックスを送信しないブラウザの問題を避けるためにThymeleafによって自動的にhiddenフィールドが挿入されます。
Don’t worry about those hidden inputs with name="_features": they are automatically added in order to avoid problems with browsers not sending unchecked checkbox values to the server upon form submission.
<div th:each="item : ${checkItems}">
<input type="checkbox" th:value="${item.value}" th:field="*{multiChecks}" />
<label th:for="${#ids.prev('multiChecks')}" th:text="${item.key}" />
</div>
<div>
<input type="checkbox" id="multiChecks1" value="A" name="multiChecks">
<input type="hidden" name="_multiChecks" value="on">
<label for="multiChecks1">checkbox_A</label>
</div>
<div>
<input type="checkbox" id="multiChecks2" value="B" name="multiChecks">
<input type="hidden" name="_multiChecks" value="on">
<label for="multiChecks2">checkbox_B</label>
</div>
<div>
<input type="checkbox" id="multiChecks3" value="C" name="multiChecks">
<input type="hidden" name="_multiChecks" value="on">
<label for="multiChecks3">checkbox_C</label>
</div>
<div>
<input type="checkbox" id="multiChecks4" value="D" name="multiChecks">
<input type="hidden" name="_multiChecks" value="on">
<label for="multiChecks4">checkbox_D</label>
</div>
<div>
<input type="checkbox" id="multiChecks5" value="E" name="multiChecks">
<input type="hidden" name="_multiChecks" value="on">
<label for="multiChecks5">checkbox_E</label>
</div>
radio
checkboxと同様です。
<div th:each="item : ${radioItems}">
<input type="radio" th:value="${item.value}" th:field="*{radio}" />
<label th:for="${#ids.prev('radio')}" th:text="${item.key}" />
</div>
<div>
<input type="radio" value="A" id="radio1" name="radio">
<label for="radio1">radio_A</label>
</div>
<div>
<input type="radio" value="B" id="radio2" name="radio">
<label for="radio2">radio_B</label>
</div>
<div>
<input type="radio" value="C" id="radio3" name="radio">
<label for="radio3">radio_C</label>
</div>
<div>
<input type="radio" value="D" id="radio4" name="radio">
<label for="radio4">radio_D</label>
</div>
<div>
<input type="radio" value="E" id="radio5" name="radio">
<label for="radio5">radio_E</label>
</div>
select
th:field
属性を利用する場合はselectタグに指定します。checkboxやradioと同様にフォームBeanに初期値が設定されているとselected="selected"
が自動的に付加されます。
<select th:field="*{multiSelects}" multiple="multiple" size="4">
<option th:each="item : ${selectItems}" th:value="${item.value}" th:text="${item.key}" />
</select>
<select multiple="multiple" size="4" id="multiSelects" name="multiSelects">
<option value="A">select_A</option>
<option value="B">select_B</option>
<option value="C">select_C</option>
<option value="D">select_D</option>
<option value="E">select_E</option>
</select>
確認画面
テンプレートファイル
<table class="table table-striped table-bordered" th:object="${simpleForm}">
<thead class="thead-dark">
<tr>
<th scope="col">field</th>
<th scope="col">value</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Single Line Text</th>
<td th:text="*{singleLineText}"></td>
</tr>
<tr>
<th scope="row">Date (text type)</th>
<td th:text="*{textDate}"></td>
</tr>
<tr>
<th scope="row">Date</th>
<td th:text="*{#temporals.format(date, 'yyyy/MM/dd')}"></td>
</tr>
<tr>
<th scope="row">Number (text type)</th>
<td th:text="*{textNum}"></td>
</tr>
<tr>
<th scope="row">Number</th>
<td th:text="*{#numbers.formatInteger(num, 0, 'COMMA')}"></td>
</tr>
<tr>
<th scope="row">Text Area</th>
<td th:utext="*{multiLineText} ?: _">(Not entered)</td>
</tr>
<tr>
<th scope="row">Text Area (new line to br)</th>
<td th:utext="*{multiLineTextNl2br} ?: _">(Not entered)</td>
</tr>
<tr>
<th scope="row">Email</th>
<td th:text="*{email} ?: _">(Not entered)</td>
</tr>
<tr>
<th scope="row">Password</th>
<td th:text="*{password}"></td>
</tr>
<tr>
<th scope="row">Single Select</th>
<td>
<p class="badge badge-primary" th:text="*{singleSelect}"></p>
</td>
</tr>
<tr>
<th scope="row">Multi Select</th>
<td>
<p class="badge badge-primary" th:each="c : *{multiSelects}" th:text="${c}"></p>
</td>
</tr>
<tr>
<th scope="row">Single Checkbox</th>
<td>
<p class="badge badge-primary" th:text="*{singleCheck}"></p>
</td>
</tr>
<tr>
<th scope="row">Multi Checkbox</th>
<td>
<p class="badge badge-primary" th:each="c : *{multiChecks}" th:text="${c}"></p>
</td>
</tr>
<tr>
<th scope="row">Radio Button</th>
<td>
<p class="badge badge-primary" th:text="*{radio}"></p>
</td>
</tr>
</tbody>
</table>
日付のフォーマット
Date型の場合は#dates
を使いますが、LocalDateやLocalDateTime型の場合は#temporals
を使う必要があります。
この機能を使うには以下のライブラリが必要です。
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-java8time</artifactId>
</dependency>
サーバー側コード
フォームクラス
public class SimpleForm implements Serializable {
@NotNull
@Size(min = 2, max = 120)
private String singleLineText;
@NotNull
@Pattern(regexp = "((19|[2-9][0-9])[0-9]{2})/(0[1-9]|1[0-2])/(0[1-9]|[12][0-9]|3[01])")
private String textDate;
@NotNull
@DateTimeFormat(pattern = "yyyy/MM/dd")
private LocalDate date;
@NotNull
@Digits(integer = 9, fraction = 0)
private String textNum;
@NotNull
@Min(0)
@Max(999999999)
private Integer num;
// Optional
@Size(min = 10, max = 600)
private String multiLineText;
// Optional
@Email
private String email;
@NotNull
@Size(min = 6, max = 99)
private String password;
// Optional
@Pattern(regexp = "A|B|C|D|E")
private String singleSelect;
// Optional
@Size(min = 0, max = 5, message = "{custom.validation.constraints.SelectSize.message}")
private String[] multiSelects;
@Pattern(regexp = "on")
private String singleCheck;
@NotNull
@Size(min = 1, max = 5, message = "{custom.validation.constraints.SelectSize.message}")
private String[] multiChecks;
@NotNull
@Pattern(regexp = "A|B|C|D|E")
private String radio;
public String getMultiLineTextNl2br() {
if (this.multiLineText == null || this.multiLineText.length() == 0) {
return null;
}
return this.multiLineText.replaceAll("\n", "<br/>");
}
}
コントローラ
@Controller
@RequestMapping("simple")
public class SimpleFormController {
/**
* selectの表示に使用するアイテム
*/
final static Map<String, String> SELECT_ITEMS =
Collections.unmodifiableMap(new LinkedHashMap<String, String>() {
{
put("select_A", "A");
put("select_B", "B");
put("select_C", "C");
put("select_D", "D");
put("select_E", "E");
}
});
/**
* check boxの表示に使用するアイテム
*/
final static Map<String, String> CHECK_ITEMS =
Collections.unmodifiableMap(new LinkedHashMap<String, String>() {
{
put("checkbox_A", "A");
put("checkbox_B", "B");
put("checkbox_C", "C");
put("checkbox_D", "D");
put("checkbox_E", "E");
}
});
/**
* radio buttonの表示に使用するアイテム
*/
final static Map<String, String> RADIO_ITEMS =
Collections.unmodifiableMap(new LinkedHashMap<String, String>() {
{
put("radio_A", "A");
put("radio_B", "B");
put("radio_C", "C");
put("radio_D", "D");
put("radio_E", "E");
}
});
@GetMapping
public String input(SimpleForm form, Model model) {
model.addAttribute("selectItems", SELECT_ITEMS);
model.addAttribute("checkItems", CHECK_ITEMS);
model.addAttribute("radioItems", RADIO_ITEMS);
// default set
form.setRadio("E");
form.setMultiSelects(new String[]{"A", "B"});
form.setMultiChecks(new String[]{"B", "D"});
return "simpleInput";
}
@PostMapping
public String conform(@Validated @ModelAttribute SimpleForm form, BindingResult result, Model model) {
if (result.hasErrors()) {
model.addAttribute("validationError", "不正な値が入力されました");
return input(form, model);
}
return "simpleConfirm";
}
}
補足
Thymeleaf 3.0
[Thymeleaf 3 ten-minute migration guide] (https://www.thymeleaf.org/doc/articles/thymeleaf3migration.html)
Thymeleaf 3.0は2.0と100%の互換があるということなので2.0のテンプレートファイルをそのまま利用できます。
Your existing Thymeleaf templates are almost 100% compatible with Thymeleaf 3 so you will only have to do a few modifications in your configuration.
Full HTML5 markup support
デフォルトのテンプレートモードがxhtmlからhtmlに変わりました。
Template modes
6つのテンプレートモード(html, xhtml, text, javascript, css, raw)があります。
htmlはバージョン4と5をサポートします。
- markup template
- html 4/5, xhtml
- textual template
- text, javascript, css
- no-op template
- raw
Fragment Expressions
Fragment式が強化されました。
Thymeleaf 3.0ではフラグメントの引数に~{テンプレート名::DOMセレクタ}
という書式でDOMを渡すことができます。
次の例は_header.htmlというテンプレートファイルのheaderフラグメントで、headタグを置き換えています。
headerフラグメントの引数に渡しているのはhello.htmlテンプレートファイルのtitle属性です。
自分自身の属性を渡す場合はテンプレート名を省略してheader(~{::title})
のように記述できます。
<head th:replace="_header::header(~{hello::title})">
<title>thymeleaf simple demonstration</title>
</head>
<head th:fragment="header(title)">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.0/css/bootstrap.min.css" integrity="sha384-9gVQ4dYFwwWSjIDZnLEWnxCjeSWFphJiwGPXr1jddIhOegiu1FwO5qRGvFXOdJZ4" crossorigin="anonymous">
<link href="css/starter-template.css" rel="stylesheet">
<link rel="icon" href="icon/favicon.ico">
<title th:replace="${title}"></title>
</head>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.0/css/bootstrap.min.css" integrity="sha384-9gVQ4dYFwwWSjIDZnLEWnxCjeSWFphJiwGPXr1jddIhOegiu1FwO5qRGvFXOdJZ4" crossorigin="anonymous">
<link href="css/starter-template.css" rel="stylesheet">
<link rel="icon" href="icon/favicon.ico">
<title>thymeleaf simple demonstration</title>
</head>
No-Operation token
_
(アンダースコア)で表すNO-OP(No Operation)トークンが追加されました。
次の例の通り、user.nameがnullの場合spanタグのテキスト("no user authenticated")が出力されます。
<span th:text="${user.name} ?: _">no user authenticated</span>
2.0まではDefault expressions (Elvis operator)を使用していました。
<span th:text="${user.name} ?: 'no user authenticated'"></span>
Decoupled Template Logic
テンプレートファイルからロジックとマークアップを分離させる機能が追加されました。
[[MAJOR FEAT] Decoupled template logic #465] (https://github.com/thymeleaf/thymeleaf/issues/465)
- Spring Boot 2.0で動作確認しようとしましたが、うまく動かなかったので未確認です。
Performance improvements
SpELコンパイルを有効にしてパフォーマンスを向上させることができます。
Spring Bootでは設定ファイルにSpELコンパイルを有効にする設定値が追加されています。(デフォルトはfalse)
spring:
thymeleaf:
enable-spring-el-compiler: true
ログ
開発時にThymeleafのログを出力するようにしておくと便利です。
ログの設定を行うとThymeleafのコンフィグレーションの内容やテンプレートファイルの処理時間などが出力されます。
logging:
level:
org.thymeleaf: DEBUG
org.thymeleaf.TemplateEngine.CONFIG: TRACE
org.thymeleaf.TemplateEngine.TIMER: TRACE
org.thymeleaf.TemplateEngine.cache.TEMPLATE_CACHE: TRACE
例
テンプレートモードがHTMLでDialectが2つ使用されていることがわかります。
またJava8TimeDialectで#temporals
が利用できるようになったことが確認できます。
[nio-9000-exec-1] org.thymeleaf.TemplateEngine : [THYMELEAF] INITIALIZING TEMPLATE ENGINE
[nio-9000-exec-1] o.t.TemplateEngine.cache.TEMPLATE_CACHE : [THYMELEAF][CACHE_INITIALIZE] Initializing cache TEMPLATE_CACHE. Max size: 200. Soft references are used.
[nio-9000-exec-1] org.thymeleaf.TemplateEngine.CONFIG : Initializing Thymeleaf Template engine configuration...
[THYMELEAF] TEMPLATE ENGINE CONFIGURATION:
[THYMELEAF] * Thymeleaf version: 3.0.9.RELEASE (built 2017-11-05T00:10:15+0000)
[THYMELEAF] * Cache Manager implementation: org.thymeleaf.cache.StandardCacheManager
[THYMELEAF] * Template resolvers:
[THYMELEAF] * org.thymeleaf.spring5.templateresolver.SpringResourceTemplateResolver
[THYMELEAF] * Message resolvers:
[THYMELEAF] * org.thymeleaf.spring5.messageresolver.SpringMessageResolver
[THYMELEAF] * Link builders:
[THYMELEAF] * org.thymeleaf.linkbuilder.StandardLinkBuilder
[THYMELEAF] * Dialect [1 of 2]: SpringStandard (org.thymeleaf.spring5.dialect.SpringStandardDialect)
[THYMELEAF] * Prefix: "th"
[THYMELEAF] * Processors for Template Mode: HTML
// 省略
[THYMELEAF] * Dialect [2 of 2]: java8time (org.thymeleaf.extras.java8time.dialect.Java8TimeDialect)
[THYMELEAF] * Expression Objects:
[THYMELEAF] * #temporals
[THYMELEAF] TEMPLATE ENGINE CONFIGURED OK