Java+Springboot+thymeleaf
■概要
1.SpringフレームワークでModelインターフェースで受けた値(フィールド)を
HTML(ビュー)側に簡易実装するための仕組み(テンプレートエンジン)
■準備(宣言)
htmlタグに以下を記述する
//urlは特に意味はなくuri(一意)であることを示しているだけ
※これをしなくても動作はするが、STSで認識されないので宣言をしていく
※xmlnsはxml name spaceの略
■留意事項
・thymeleafは入出力を担当するテンプレートツール。
・基本的にはif分岐やリスト操作など、論理演算は内部で済ませ、必要なモノは加工済みの状態で用意しておく。
・viewから送ることができる値はStringのみ。例えば以下のようにオブジェクトを送ろうとしても、Stringとして扱われるため受け取ることができない。
<input type="hidden" name="attrname" th:value="${obj}">
【ビューへの出力】
~Controller~
@GetMapping("/client") public String createInfo( Model model) { Object = new object; String sample = "sample" model.addAttribute("example", sample); model.addAttribute("obj", obj); return "client"; }
~view~
<p th:text="${example}"></p> //exampleはmodelで設定した属性名を指定
オブジェクトのフィールド(プロパティ)を出力する場合
<p th:text="${obj.attribute}"></p>
または
<div th:object="obj"> <p th:text="*{attribute}"></p> </div>
【基本】
文字操作
■文字連結(リテラル置換・パイプライン構文)
https://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html#literal-substitutions
以下の2つは同義
<span th:text="|Welcome to our application, ${user.name}!|"> <span th:text="'Welcome to our application, ' + ${user.name} + '!'">
配列・リストの扱い
■参考:https://www.thymeleaf.org/doc/tutorials/2.1/usingthymeleaf_ja.html#%E3%83%9E%E3%83%83%E3%83%97
■値の取り出し
RowMapperなどでDBのリストを作成して出力する場合 :th:each
<tr th:each="inquiry : ${inquiries}" th:object="${inquiry}"> //変数名inquiries : ${ModelにセットされているListオブジェクト} //紐づいてinquiryもmodelに登録される? <td th:text="*{name}">名前</td> <td th:text="*{age}">年齢</td> <td th:text="*{gender}">性別</td> <td><a th:href="@{|/inquiry/*{id}|}">詳細</a></td> //後述 </tr>
■リストのn番目の要素を取得
例)List
※PlanのフィールドでplanNameがあれば以下のように取得できる。
<input type="text" th:value="${plans.get(1).planName}">
■リストを区切る
th:text="${#strings.listJoin(productList.![id],',')}"
productListのid項目を「,」で区切って並べる
例) productList[x].id -> 「100」「150」「200」
text="100,150,200"
■リストオブジェクトユーティルメソッド
th:if="${#lists.size(listname)}>1" //『lists』であることに注意 th:if="!${#lists.isEmpty(listname)}"
繰り返し処理 th:each
modelにStringの文字列を格納したリスト"areaNameData"が登録されているとき
リストの文字列一覧を出力したい場合
<span th:each="a : ${areaNameData}"> //areaNameDataの中身を一つずつaに代入 ※aはmodelに格納される? <span th:text="${a}"></span> //aはStringなのでth:textで呼び出し </span>
・if条件で出しわけたい場合はth:blockでくくる
<th:block th:each="genremap : ${categoriesGenresClientsMap.get('ヒップホップ系')}" > <tr th:if="${#lists.size(genremap.getValue())} != 0"> <td th:text="${genremap.getKey()}"></td> <td><span th:text="${#lists.size(genremap.getValue())}"></span>校</td> <td><input type="checkbox" name="genreNames" th:value="${genremap.getKey()}"></td> </tr> </th:block>
■改行やdivなど、ブロック単位で出力する :th:block
空のタグが発生しないのでとても便利
<div th:object="${inquiry}"> <th:block th:each="line : *{contents.split('\n')}"> //split():Stringクラスメソッド。 //inquiryオブジェクトのcontentsを(\n)で分割し //変数lineにリストにして格納 //lineがStringでなくオブジェクトだと.split()は使用できない <span th:text="${line}"></span><br> //その場合は<br>などで調整するしか </th:block> </div>
マップMapの扱い
■th:eachのネスト
https://teratail.com/questions/5133
■mapのメソッド
${map.get(key)} //キーから値を取得 ${#maps.size(mapName)} //マップの要素数 ${#maps.isEmpty(map)} //空かどうか //キーや値がマップに含まれているかどうかをチェック ${#maps.containsKey(map, key)} ${#maps.containsAllKeys(map, keys)} ${#maps.containsValue(map, value)} ${#maps.containsAllValues(map, value)}
■実装例
~Java~
Map<Integer, String> SampleMap( Model model) { Map<Integer, String> sampleMapA = new HashMap<>(); sampleMapA.put(1, "sample"); sampleMapA.put(2, "sample2"); Map<Integer, String> sampleMapB = new HashMap<>(); sampleMapB.put(1, "サンプル"); sampleMapB.put(2, "サンプル2"); model.addAttribute("sampleMapA", sampleMapA); model.addAttribute("sampleMapB", sampleMapB); }
~HTML~
//例1
<div th:each="smA : ${sampleMapA}"> <p th:text="${smA.getKey()}"></p> //キーを表示:1, 2 <p th:text="${smA.getValue()}"></p> //値を表示:"sample", "sample2 </div> //例2 <div th:each="smA : ${sampleMapA}"> <div th:each="smB : ${sampleMapB}"> <p th:if="${smA.getKey()} == ${smB.getKey()}" th:text="${smA.getValue()}"></p> //キーが一致したときにsampleMapAの値を表示 </div> </div> //例3 <div th:each="smA, smAStat : ${sampleMapA}"> <p th:if="${smAStat} == ${#maps.size(sampleMapA)}" th:text="${smA.getValue()}"></p> //sampleMapAのStat(何番目に出力されたか)が、 //sampleMapAの要素数(2)と一致したときに値を表示 </div>
例3のように、MapやListはStat、つまりMapやListが今何番目の要素を表示しているか?といっオブジェクトの情報を使用してロジックを組むこともできる。
■ListをMapにネストする
~Java~
Map<String, List<String>> areaNameWithClientName; //Map<エリア名, List<エリアに属するクライアント名一覧>>
~HTML~
<div th:each="a : ${areaNameWithClientName}"> <h3 th:text="${a.getKey()}"></h3> //Mapは.getKey()でキー名を取得できる <table> <tr th:each="b : ${a.getValue()}"> //Mapは.getValue()で値を取得できる //MapのvalueはList<>になっているのでさらにth:each <td th:text="${b}"></td> </tr> </table> </div>
■th:eachの中でlabel id for
<span th:each="var : arr"> <input type="checkbox"th:id="${#ids.seq('id_sample')}" th:value="${var.key}" /> <label th:for="${#ids.prev('id_sample')}" th:text="${var.value}" /> </span>
#ids.prevは#ids.seqを参照して設定される
th:fieldを設定した場合は自動的に#ids.seqが行われるため以下のように書くことができる
<span th:each="var : ${CL_ROLE}"> <input type="checkbox"th:field="*{rols}" th:value="${var.key}" /> <label th:for="${#ids.prev('rols')}" th:text="${var.value}" /> </span>
URL・リンク処理
実際のURLはドメインが先頭に入るため、th:href=@{●ULR●}でくくって渡してあげることでthymeleafが補完してくれる。
中に記述するURLはControllerのGetMapで定義しているパスをそのまま記述するものと考えてよさそう。
参考:
https://qiita.com/rubytomato@github/items/ac65c2203d16d1a1bbd7
https://ts0818.hatenablog.com/entry/2017/10/09/144626 //GETではREST形式?
■クエリ形式とREST形式がある。 ※★GETで渡す場合はREST形式が必須??
・クエリ形式:URLパラメータ(『?』の後)として渡す
<a href="/user/profile" th:href="@{/user/profile(id=${'ab123'},role=${'admin'})}">profile</a> →<a href="/user/profile?id=ab123&role=admin">profile</a>
・REST形式:URL文字列として渡す
<a href="index.html" th:href="@{/user/profile/{id}/{seq}(id=${'abc'},seq=${'1000'})}">profile</a> →<a href="/user/profile/abc/1000">profile</a>
※前半に{変数名}を指定し、後半の()内で変数に値を代入するテンプレートリテラル
固定値でなく変数を使用する場合はクオーテーションは不要
例:変数abc id=${abc} //id=${‘abc’} ではない
■idごとのリンクを作る :th:href ※上記サンプルコード参照
<td><a th:href="@{|/inquiry/*{id}|}">詳細</a></td> //『|』はパイプライン構文と呼ばれるもの
@{ }の中の文字列であっても${ }や*{ }のように先頭の記号は必要になる。
if分岐
・"${条件式}" 条件式がtrueの場合に表示される
・正反対の挙動を実装したい場合はth:unlessに置き換えるだけで実装できる
<div th:if="${#lists.isEmpty(planDetails)}">※プランが登録されていません</div> <p th:if="${client.plan} == null">プランが登録されていません</p> <p th:unless="${client.plan} == null">プランが登録されています</p>
・演算子
以上:ge、 以下:le、 より多きい:gt、 より小さい: lt
等しい:eq、 等しくない:neq
以外:not、
且つ:and、 または:or
<div th:if="${person.intValue ge 20 and person.intValue lt 40}"><img src="/images.png" /></div> https://arakan-pgm-ai.hatenablog.com/entry/2017/03/14/224244
・演算、リスト、文字列操作
th:if="${#strings.isEmpty(str)}" //strが空、nullの場合にtrueになる。事前にtrim()されるので空白もtrue。 th:unless="*{#strings.isEmpty(appeal)}" //★使いやすい!appealが空でなければ表示 th:if="${a} == ${b}" //stringの比較など
参考:https://qiita.com/55beagle/items/2208cea678dc948b715c
※th:if以外にこんな書き方ができる
https://www.thymeleaf.org/doc/tutorials/2.1/usingthymeleaf_ja.html#%E7%B9%B0%E3%82%8A%E8%BF%94%E3%81%97%E3%82%B9%E3%83%86%E3%83%BC%E3%82%BF%E3%82%B9%E3%81%AE%E4%BF%9D%E6%8C%81
6.2
th:text="${prod.inStock}? #{true} : #{false}"
演算
th:text="|${#aggregates.sum(productList.![price])}円(+税)|" //productListのprice項目を合計する(springの記法) https://qiita.com/55beagle/items/2208cea678dc948b715c
【formの処理】
■th:action
formの送信先を指定するaction属性は、静的に指定しても良いのだが、Springbootでは送信先のURLは可変になることが多いため、基本的には以下のように指定する。
■作成(create)と編集(edit)を同じformで管理したい場合のベストプラクティス
以下のように指定する
<form th:action="@{''}" method="post" th:object="${client}"> <input type="hidden" th:field="*{id}"> //※1 …
th:action="@{''}"(自分自身)を指定することで@GetMappingされてきた値でそのままPost送信することができる。(検討th:action=”@{|path|}”)
すなわち、現在のURLがhttps://appName/client/1/createであるとき、th:action=”@{‘’}”でpostすると、action=”/client/{clientId}/create”と読み替えられるため、以下のPostMappingでキャッチできる。
@PostMapping("/client/{clientId}/create") public String createClient() { return "client/form"; }
ただし、※1に記したth:field="*{id}"を渡してあげる必要があるので注意すること
・同じページから相対パスでリクエストを出しわけたい場合
1つのページでcreate、edit、削除を管理したいことがある。その場合は以下のように指定すれば相対パスとなる。
th:action="@{form}"
現在のURLがhttps://appName/client/1/planであるとき、上記はaction=”/client/{clientId}/form"に読み替えられる。※現在のURLの最後のplanがformに置き換わっている点に注意
ただし、このときリクエスト自体は送られるが、遷移するURLが変わってしまう。
例えば、エラー等でページをreturnすると以下のような遷移となり、同じページであるにもかかわらずURLが変わるため、その後のリクエストが正常に送られなくなってしまう。
//元パス http://page/form.html //処理成功※redirect http://page/form.html //エラー時※return http://page/form/create.html
・パラメータを送るなどして対応することもできるが、JavaではSPAに対応しておらず、推奨できないと思われる。
作成ページと、編集削除ページは分ける。
※削除は基本的にredirectなので、作成ページまたは編集ページと一緒に実装してもOK
■チェックボックスの表示 ※詳しくはSpringDBマニュアルで
formに出すのは簡単だが、ビューに出力するのみの場合方法がない。
disabled属性を指定する方法もあるが、
th:ifでチェックボックスのture、falseによってメッセージなどを出しわける方法がよさそう。
<div th:object="${client}"> <div th:each="a : ${areaList}"> //選択肢のリストを呼び出し出力 <input type="checkbox" th:text="${a}" //選択肢がテキストで表示される name="areaName" //modelのareaNameフィールドに値を登録する th:value="${a}" //選択肢をそのままvalueとして設定 //valueは配列としてpostされる //valueを選択肢の名前と別にしたい場合はListではなくMapにする th:checked="${#lists.contains(areaNames, a)}" //表示の際、すでに選択されているものにはチェックを付ける ※後述 > </div> </div>
・th:checked="${#lists.contains(areaNames, a)}"について
リストareaNamesにaが含まれる場合Trueを返す
■ドロップダウンリスト(プルダウン) :select, option
<select name="sampleList"> <option th:each="a : ${sampleList}" th:value="${a}" th:text="${a}" th:selected="${sampleList} == 'defaultValue'" ></option> </select>
・デフォルト値の指定 optionを手動で1行追加するだけ
<select name="sampleList"> <option value="選択してください">選択してください</option> //デフォルト値を追加 <option th:each="a : ${sampleList}" th:value="${a}" th:text="${a}" th:selected="${sampleList} == 'defaultValue'" ></option> </select>
■input type=”time” th:field=”*{time}”
この値はStringとして送られる。modelでデータをTimeなどに指定しているとエラーになる。
これは、Stringをオブジェクトのプロパティとして登録する際にmodelがTime型になっているという型の不一致の扱いとなるため。
type=”text”とすれば良いのだが、type=”time”でルーレットのUIが利用できるので使用したい。
コンバーターを自力で設置するか、アプリケーション内でTime型に書き換える必要がある。
アプリケーション内で書き換える場合は、HTML側ではth:fieldではなくnameで値を取得し、アプリケーション内でキャストする。
どちらも型変換までしてTime型のメリットを享受できるかというと疑問。
modelもString型で処理をしてしまった方が無難。もし、登録した時間を集計したり、Time型のメソッドを使用する場合には型の変換を検討する。
値の受け取り
@PostMapping("/form") public String example(@RequestParam String genreName) { //@RequestParamで受け取るが、引数名とパラメータ属性名が同じなら省略可? }
※ただし、@PathVariable("id")を使用する場合、@ModelAttributeが効かないので、引数にModel.modelを追加、
model.addAttributeして明示的にMVCモデルにセットする必要がある。
・実装
<form action="/form" method="post" th:object="${obj}"> //model名を指定する <input type="text" th:field="*{●属性名●}"> //modelのどのフィールドに登録するか指定する name属性は指定しなくてOK //th:field="${obj.●属性名●}"と同義 //name="●属性名●" th:value="${formClass.●属性名●}"とも同義 </form>
この場合、出力すると以下に置き換わる
<input type="text" id=●属性名● name="●属性名●" value="">
エラー処理 例外処理
@Validでエラーをキャッチしたもの(@Dataのエラー)をthymelefで出力する
エラーメッセージは出力する箇所を
メッセージの内容は@Dataの該当オブジェクトで指定する
例)@NotBlank(message="値を入力してください")
・実装 ※th:object="${obj}"でオブジェクトを指定しているブロックの配下であること
<div> <span th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></span> //"obj"の'name'フィールドで設定しているエラー(@NotBlankなど)が出力される。 //メッセージを指定している場合はそのメッセージが出力される。 </div>
※springbootのクラスは以下
class="form-text text-muted"
【レイアウト】
テンプレートを階層(レイヤー)化、または部品(フラグメント)化し、共通部分をまとめることで保守性を高めることができる。
※親テンプレートでオブジェクトを使用している場合に、子テンプレートでどのオブジェクトを参照しているのか見えなくなるため、あまり複雑な構造にしすぎないこと
■参考:
https://macchinetta.github.io/server-guideline-thymeleaf/current/ja/ArchitectureInDetail/WebApplicationDetail/TemplateLayout.html
~部品(フラグメント)化する場合~
■部品となる子テンプレートでは、部品にしたい箇所をth:fragmentで括る。
<html xmlns:th="http://www.thymeleaf.org/" > ~~~ <div th:fragment="top-image" th:remove="tag"> //部品 </div>
※必要なのは部品のみでそれをくくっているdivは必要ないケースがほとんど。
『th:remove="tag"』を付与することで外枠のdivタグは反映されない。
■親テンプレートではth:replaceで呼び出す。
<div th:replace="~{client/fragment/top-image :: top-image}"></div>
※”~{●親テンプレートのパス● :: ●fragment名●}”
親テンプレートのパスとは、templateフォルダ以下の絶対パス
~階層(レイヤー)化する場合~
fragmentのみだと親テンプレートでth:objectを使っていた場合に子テンプレートでもth:objectを呼び出さないといけない。
insertとかreplaceを使用してできるかな…。
レイアウトを使用する場合は親テンプレートにも子テンプレートにも以下を記述し、thymeleafのテンプレート機能を使うことを明示する。
<html xmlns:th="http://www.thymeleaf.org/" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" >
子テンプレートでは、親テンプレートをのパスを指定する。
例えば親テンプレートのパスがlayoutフォルダ直下の『common.html』である場合は以下のようになる。
※波カッコの前に~が入っていることに注意
<html xmlns:th="http://www.thymeleaf.org/" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/common}" //追記 >
親テンプレートでは、子テンプレートを埋め込みたい位置に以下のように記述する。
<div layout:fragment="contents"></div>
子テンプレートでも同様の値を使用し、親要素に反映させたい内容を記述する。
<div layout:fragment="contents"> //親テンプレートに反映させたい内容を記述 </div>
Thymeleaf Layout Dialectモジュールによる共通画面作成............................................................
■導入
Spring Bootのスターターには入っていないため設定を行う
・手順
pom.xmlを開く
依存関係タブ>追加
グループId: nz.net.ultraq.thymeleaf
アーティファクトId: thymeleaf-layout-dialect
pom.xmlを保存
※アプリケーションを実行中の場合は
設定を反映させるために再実行すること
■共通ページの用意
templateフォルダ直下にlayoutフォルダを追加
layoutフォルダ内にcommon.htmlを作成 (これが共通レイアウトになる)
※index.htmlにメタデータの記述をしてしまわないように注意
あくまでcommon.htmlで設定をするため、BootStrapなどのメタデータはこちらに記述する。
index.htmlも、このcommon.htmlを継承して画面を表示する。
・htmlタグにネームスペースを追記
・必要に応じメタ情報でスタイルシートを適用
※BootStrapの設定などはここで記述する
・個別コンテンツを埋め込むカ所を指定する
<html xmlns:th="http://www.thymeleaf.org/" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" > <head> <meta charset="UTF-8"> <!-- 必要に応じメタ情報を追記 --> <meta name="viewport" content="width=device-width, initianl-scale=1, shrink-to-fit=no"> <link rel="stylesheet" href="/css/bootstrap.min.css"> <title>共通レイアウト</title> </head> <body> <h1 class="m-3">スクール管理ボード</h1> <nav class="my-3" style="background: azure;">●ナビゲーションバー●</nav> <!-- 個別コンテンツを埋め込む場所を指定 --> <div class="container" layout:fragment="contents"></div> </body> </html>
■個別ページに共通レイアウトを呼び出す
例)index.htmlに反映させる場合
※共通レイアウトで指定したcssも反映される
<!-- ネームスペースを追加 &使用する共通レイアウトを指定 --> <html xmlns:th="http://www.thymeleaf.org/" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/common}" > <head> <meta charset="UTF-8"> <title>スクール管理ボード</title> </head> <body> <!-- 個別コンテンツここから --> <div layout:fragment="contents"> ●コンテンツ● </div> <!-- 個別コンテンツここまで --> </body> </html>
【appendix】
■th:hrefでidのパラメータ(URL)がおかしくなる(文字化け?)
>『|』で囲むこと。上記の文字連結を参照
<a th:href="@{|/client/*{id}/edit|}">
■チェックボックスの値をnullで送信するとエラーになる
MissingServletRequestParameterException
>@RequestParamで値を送信していたが、このアノテーションは必須がデフォルトになっている。
以下を指定
@RequestParam(required = false)
■Thymeleafのファイルはどこ?
>Maven 依存関係
■#について
https://teratail.com/questions/4676
#{...} :
読み込んであるプロパティでprop.nameのようにアクセスすることが可能
■条件に応じて読み取り専用にする
>|??|
th:readonly="${if条件}"
th:disabled="${if条件}"
|
例)
th:disabled="*{username != null && username != ''}" //空または何か値があると入力不可(nullのみ許容)
※hasErrors()でreturnすると値がnullから空文字に変わってしまうため上記の条件式を書くことがあった。
【エラー対応】
■layout: fragmentで部品化したコンテンツで、個別にjsファイルを読み込みたいが読み込まない。
>bodyの直前で読み込むのが一般的だが、部品化されるのはlayout: fragmentのdiv内要素のみ。そのためdiv内でjsファイルを読み込む必要がある
<body> <div layout:fragment="contents" th:object="${client}"> ~コンテンツ~ <script type="text/javascript" src="/js/zipsearch.js"></script> //読み込まれる </div> <script type="text/javascript" src="/js/zipsearch.js"></script> //読み込まれない </body>
■th:action=@”{‘’}”が動作しないときのチェックポイント
まず、Controllerがpostをキャッチしているか確認
・していない場合
form送信後のURLを確認し、MappingしているURLと一致するかを調査する
・している場合
returnで返しているURLが合っているか確認する。
良くある間違い return “redirect:form” //相対パスになっている。”redirect:/form”とする