Spring Petclinic を JavaEE MVC 1.0 (JSR-371) で作る 〜 その2 〜


f:id:Naotsugu:20170405210338p:plain

前回

etc9.hatenablog.com

の続きです。

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>

こんな感じになります。

f:id:Naotsugu:20170410211623p:plain

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>

画面は以下のようになります。

f:id:Naotsugu:20170410212448p:plain

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>

画面は以下のようになります。

f:id:Naotsugu:20170410235933p:plain

Pet や Visit なども同じような流れで作成できます。

使いにくい点

Spring MVC と同じノリで JavaEE MVC を使うことができますが、現状の Ozark(と Jersyeの組み合わせ) では実装しにくいものもあります。

例えば Pet の編集画面は以下のようになります。

f:id:Naotsugu:20170410232948p:plain

日付形式として - 区切りでの入力としています。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 など、かゆいところのサポートはありません。

例えば以下のような項目に並べてエラーメッセージ出すなどは自力でなんとかしなければなりません。

f:id:Naotsugu:20170411000103p:plain

まとめ

Spring Petclinic を JavaEE MVC 1.0 (JSR-371) で作ってみました。

Early Draft 段階なのでこの後で変更が入ると思いますが、Ozark はシンプルで悩む箇所も比較的少なく実装できました。

しかし細かな作り込みをする場合には細々と実装を補足してあげる必要がありそうです。

様子見程度の実装ですが、省略した部分は以下で参照できます。

github.com