前回
の続きです。
TemplateEngineProducer
Ozark で Thymeleaf を使えるように org.thymeleaf.TemplateEngine の Producer を作成します。
@Dependent public class DefaultTemplateEngineProducer { @Produces @ViewEngineConfig public TemplateEngine getTemplateEngine(ServletContext context) { ServletContextTemplateResolver resolver = new ServletContextTemplateResolver(context); resolver.setPrefix(ViewEngine.DEFAULT_VIEW_FOLDER); TemplateEngine engine = new TemplateEngine(); engine.setTemplateResolver(resolver); return engine; } }
ViewEngine は ViewEngineBase を継承して以下のように定義します。
@ApplicationScoped public class ThymeleafViewEngine extends ViewEngineBase { @Inject private ServletContext servletContext; @Inject @ViewEngineConfig private TemplateEngine engine; @Override public boolean supports(String view) { return view.endsWith(".html"); } @Override public void processView(ViewEngineContext context) throws ViewEngineException { try { HttpServletRequest request = context.getRequest(); HttpServletResponse response = context.getResponse(); WebContext ctx = new WebContext(request, response, servletContext, request.getLocale()); ctx.setVariables(context.getModels()); engine.process(context.getView(), ctx, response.getWriter()); } catch (IOException e) { throw new ViewEngineException(e); } } }
これで、レスポンス処理に org.thymeleaf.TemplateEngine が使われるようになります。
JAX-RS Application
JavaEE MVC は JAX-RS なので JAX-RS の Application クラスを作成しておきます。
@ApplicationPath("/app") public class PetClinicApplication extends Application { }
Fragments
画面のベースとなる Fragment を作成します。
layout.html として作成します。
<!doctype html> <html th:fragment="layout (template, menu)"> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="shortcut icon" type="image/x-icon" th:href="@{/static/resources/images/favicon.gif}"> <title>PetClinic :: a Java EE demonstration</title> <!--[if lt IE 9]> <script src="https://oss.maxcdn.com/html5shiv/3.7.2/html5shiv.min.js"></script> <script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script> <![endif]--> <link rel="stylesheet" th:href="@{/static/resources/css/petclinic.css}"/> </head> <body> <nav class="navbar navbar-default" role="navigation"> <div class="container"> <div class="navbar-header"> <a class="navbar-brand" th:href="@{/}"><span></span></a> <button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#main-navbar"> <span class="sr-only"><os-p>Toggle navigation</os-p></span> <span class="icon-bar"></span> <span class="icon-bar"></span> <span class="icon-bar"></span> </button> </div> <div class="navbar-collapse collapse" id="main-navbar"> <ul class="nav navbar-nav navbar-right"> <li th:fragment="menuItem (path,active,title,glyph,text)" class="active" th:class="${active==menu ? 'active' : ''}"> <a th:href="@{__${path}__}" th:title="${title}"> <span th:class="'glyphicon glyphicon-'+${glyph}" class="glyphicon glyphicon-home" aria-hidden="true"></span> <span th:text="${text}">Template</span> </a> </li> <li th:replace="::menuItem ('/app','home','home page','home','Home')"> <span class="glyphicon glyphicon-home" aria-hidden="true"></span> <span>Home</span> </li> <li th:replace="::menuItem ('/app/owners/find','owners','find owners','search','Find owners')"> <span class="glyphicon glyphicon-search" aria-hidden="true"></span> <span>Find owners</span> </li> <li th:replace="::menuItem ('/app/vets','vets','veterinarians','th-list','Veterinarians')"> <span class="glyphicon glyphicon-th-list" aria-hidden="true"></span> <span>Veterinarians</span> </li> <li th:replace="::menuItem ('/app/oups','error','trigger a RuntimeException to see how it is handled','warning-sign','Error')"> <span class="glyphicon glyphicon-warning-sign" aria-hidden="true"></span> <span>Error</span> </li> </ul> </div> </div> </nav> <div class="container-fluid"> <div class="container xd-container"> <div th:replace="${template}"/> <br/> <br/> <div class="container"> <div class="row"> </div> </div> </div> </div> <script th:src="@{/webjars/jquery/2.2.4/jquery.min.js}"></script> <script th:src="@{/webjars/jquery-ui/1.11.4/jquery-ui.min.js}"></script> <script th:src="@{/webjars/bootstrap/3.3.6/js/bootstrap.min.js}"></script> </body> </html>
各画面は <div th:replace="${template}"/>
の箇所に挿入されます。
jquery と bootstrap を webjar として読み込みます。
入力フィールドの Fragment も inputField.html として作成します。
<html xmlns:th="http://www.thymeleaf.org"> <body> <form> <th:block th:fragment="input (label, name)"> <div class="form-group" th:with="valid=${message == null ? true : !message.hasError(name)}" th:class="${'form-group' + (valid ? '' : ' has-error')}"> <label class="col-sm-2 control-label" th:text="${label}">Label</label> <div class="col-sm-10" th:with="val=*{__${name}__}"> <input class="form-control" type="text" th:name="${name}" th:value="${val}" /> <span class="glyphicon glyphicon-ok form-control-feedback" aria-hidden="true" th:if="${valid}"></span> <th:block th:if="${!valid}"> <span class="glyphicon glyphicon-remove form-control-feedback" aria-hidden="true"></span> <span class="help-inline" th:text="${message.error(name)}">Error</span> </th:block> </div> </div> </th:block> </form> </body> </html>
th:field
は Thymeleaf の Spring 拡張なので今回は使えませんので、ベタで書きます。
コントローラ
Owner コントローラを作成します。
最初に Owner の検索画面表示部分です。
@Path("owners") @Controller @RequestScoped public class OwnerController { @Inject private Models models; @GET @Path("find") public String initFindForm() { models.put("owner", new Owner()); return "owners/findOwners.html"; } }
空の Owner を作成して View のパスを返却するだけです。
View テンプレート findOwners.html は以下のようになります。
<html xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/layout.html :: layout (~{::body},'owners')}"> <body> <h2>Find Owners</h2> <form th:object="${owner}" th:action="@{/app/owners}" method="get" class="form-horizontal" id="search-owner-form"> <div class="form-group"> <div class="control-group" id="lastName"> <label class="col-sm-2 control-label">Last name </label> <div class="col-sm-10"> <input class="form-control" size="30" maxlength="80" name="lastName" th:value="*{lastName}"/> <span class="help-inline"> <p th:each="err : ${messages}" th:text="${err}">Error</p> </span> </div> </div> </div> <div class="form-group"> <div class="col-sm-offset-2 col-sm-10"> <button type="submit" class="btn btn-default">Find Owner</button> </div> </div> </form> <br /> <a class="btn btn-default" th:href="@{/app/owners/new}">Add Owner</a> </body> </html>
こんな感じになります。
Owner 検索
Find Owner ボタン押下 時の Controller を定義します。
@Path("owners") @Controller @RequestScoped public class OwnerController { @EJB private OwnerRepository repository; @Inject private BindingResult bindingResult; @Inject private Models models; @GET public String processFindForm(@DefaultValue("") @QueryParam("lastName") String lastName) { Collection<Owner> results = repository.findByLastName(lastName); if (results.isEmpty()) { models.put("messages", new String[]{"not found"}); models.put("owner", new Owner()); return "owners/findOwners.html"; } else if (results.size() == 1) { Owner owner = results.iterator().next(); return "redirect:/owners/" + owner.getId(); } else { models.put("selections", results); return "owners/ownersList.html"; } } }
検索結果が得られない場合は元の画面、1件の場合は詳細画面、複数の場合は一覧画面に遷移します。
1件の場合は詳細画面へリダイレクトし、コントローラメソッドは以下のようになります。
@GET @Path("{ownerId}") public String showOwner(@PathParam("ownerId") int ownerId) { models.put("owner", repository.findById(ownerId)); return "owners/ownerDetails.html"; }
View テンプレート ownerDetails.html は以下のようになります。
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/layout.html :: layout (~{::body},'owners')}"> <body> <h2>Owner Information</h2> <table class="table table-striped" th:object="${owner}"> <tr> <th>Name</th> <td><b th:text="*{firstName + ' ' + lastName}"></b></td> </tr> <tr> <th>Address</th> <td th:text="*{address}" /></td> </tr> <tr> <th>City</th> <td th:text="*{city}" /></td> </tr> <tr> <th>Telephone</th> <td th:text="*{telephone}" /></td> </tr> </table> <a th:href="@{/app/owners/{id}/edit(id=${owner.id})}" class="btn btn-default">Edit Owner</a> <a th:href="@{/app/owners/{id}/pets/new(id=${owner.id})}" class="btn btn-default">Add New Pet</a> <br/> <br/> <br/> <h2>Pets and Visits</h2> <table class="table table-striped"> <tr th:each="pet : ${owner.pets}"> <td valign="top"> <dl class="dl-horizontal"> <dt>Name</dt> <dd th:text="${pet.name}" /></dd> <dt>Birth Date</dt> <dd th:text="${#dates.format(pet.birthDate, 'yyyy-MM-dd')}" /></dd> <dt>Type</dt> <dd th:text="${pet.type}" /></dd> </dl> </td> <td valign="top"> <table class="table-condensed"> <thead> <tr> <th>Visit Date</th> <th>Description</th> </tr> </thead> <tr th:each="visit : ${pet.visits}"> <td th:text="${#dates.format(visit.date, 'yyyy-MM-dd')}"></td> <td th:text="${visit.description}"></td> </tr> <tr> <td><a th:href="@{/app/owners/{ownerId}/pets/{petId}/edit(ownerId=${owner.id},petId=${pet.id})}">Edit Pet</a></td> <td><a th:href="@{/app/owners/{ownerId}/pets/{petId}/visits/new(ownerId=${owner.id},petId=${pet.id})}">Add Visit</a></td> </tr> </table> </td> </tr> </table> </body> </html>
画面は以下のようになります。
Owner 編集
Edit Owner 押下時のコントローラメソッドは以下のようになります。
@Path("owners") @Controller @RequestScoped public class OwnerController { private static final String VIEWS_OWNER_CREATE_OR_UPDATE_FORM = "owners/createOrUpdateOwnerForm.html"; @EJB private OwnerRepository repository; @Inject private BindingResult bindingResult; @Inject private Models models; @GET @Path("{ownerId}/edit") public String initUpdateOwnerForm(@PathParam("ownerId") int ownerId) { Owner owner = repository.findById(ownerId); models.put("owner", owner); return VIEWS_OWNER_CREATE_OR_UPDATE_FORM; } @POST @Path("{ownerId}/edit") @ValidateOnExecution(type = ExecutableType.NONE) public String processUpdateOwnerForm(@Valid @BeanParam Owner owner, @PathParam("ownerId") int ownerId) { if (bindingResult.isFailed()) { models.put("owner", owner); models.put("message", Message.of(bindingResult)); return VIEWS_OWNER_CREATE_OR_UPDATE_FORM; } else { owner.setId(ownerId); repository.save(owner); return "redirect:/owners/" + ownerId; } } }
入力フォームは Owner エンティティをそのまま BeanParam として使うので @Valid @BeanParam
で定義しています。
Owner
の各フィールドを @FormParam
でアノテートします。
public class Owner extends Person { @FormParam("address") @Column(name = "address") @NotEmpty private String address; @FormParam("city") @Column(name = "city") @NotEmpty private String city; @FormParam("telephone") @Column(name = "telephone") @NotEmpty @Digits(fraction = 0, integer = 10) private String telephone;
エンティティ定義と混じってゴチャゴチャしてますが、気にしないことにします。
View テンプレート createOrUpdateOwnerForm.html は以下のようになります。
<html xmlns:th="http://www.thymeleaf.org" th:replace="~{fragments/layout.html :: layout (~{::body},'owners')}"> <body> <h2>Owner</h2> <form class="form-horizontal" id="add-owner-form" method="post" th:object="${owner}" > <label th:text="${(bindingResult != null) ? bindingResult.allMessages : ''}"/> <div class="form-group has-feedback"> <input th:replace="~{fragments/inputField.html :: input ('First Name', 'firstName')}" /> <input th:replace="~{fragments/inputField.html :: input ('Last Name', 'lastName')}" /> <input th:replace="~{fragments/inputField.html :: input ('Address', 'address')}" /> <input th:replace="~{fragments/inputField.html :: input ('City', 'city')}" /> <input th:replace="~{fragments/inputField.html :: input ('Telephone', 'telephone')}" /> </div> <div class="form-group"> <div class="col-sm-offset-2 col-sm-10"> <button class="btn btn-default" type="submit" th:with="text=${owner['new']} ? 'Add Owner' : 'Update Owner'" th:text="${text}"> Add Owner </button> </div> </div> </form> </body> </html>
画面は以下のようになります。
Pet や Visit なども同じような流れで作成できます。
使いにくい点
Spring MVC と同じノリで JavaEE MVC を使うことができますが、現状の Ozark(と Jersyeの組み合わせ) では実装しにくいものもあります。
例えば Pet の編集画面は以下のようになります。
日付形式として -
区切りでの入力としています。MVC 1.0 は JAX-RS がベースとなっているため、日付のパースは Jersey に組み込まれている
org.glassfish.jersey.server.internal.inject.ParamConverters.DateProvider
で定義された ParamConverter が使われます。
こんな場合は、以下のように独自の ParamConverterProvider
を作ることで対応します。
@Provider public class AppParamConverterProvider implements ParamConverterProvider { @Override public <T> ParamConverter<T> getConverter(Class<T> rawType, Type genericType, Annotation[] annotations) { // ... } }
しかし Jersye で Provider の優先度が考慮されず、日付形式のコンバータ(組込のコンバータ)を置き換えることが(現時点で)できません(同じような問題が ExceptionMapper で Exception のマッパーを定義した時にも発生します)。
Jackson 使ったりすれば良いと思いますが、今回は BeanParam で文字列で受けて変換を入れました。
Spring では @DateTimeFormat(pattern = "yyyy/MM/dd")
付けておけば済む話ですがね。
あとは、MVC 1.0 のバリデーション処理のサポートが薄いため、何かしらの拡張が必要でしょう。
Spring MVC + Thymeleaf では、#fields
など、かゆいところのサポートはありません。
例えば以下のような項目に並べてエラーメッセージ出すなどは自力でなんとかしなければなりません。
まとめ
Spring Petclinic を JavaEE MVC 1.0 (JSR-371) で作ってみました。
Early Draft 段階なのでこの後で変更が入ると思いますが、Ozark はシンプルで悩む箇所も比較的少なく実装できました。
しかし細かな作り込みをする場合には細々と実装を補足してあげる必要がありそうです。
様子見程度の実装ですが、省略した部分は以下で参照できます。