Java EE 7でもアクションベースMVC! -MVC 1.0への移行を睨んだJersey MVCの活用- #javaee
この記事について
このブログは、Java EE Advent Calendar 2015 - Qiitaの25日目(最終日)です。
昨日はhondaYoshitakaさんの「Java - JAX-RSによるExcel/CSV/PDFファイルダウンロード - Qiita」でした。
MVC 1.0はJava EE 8から
アクションベースMVCと言えば、Java EE 8で導入される「MVC 1.0」ですね。
MVC 1.0の詳細については、今年のGlassFish勉強会の資料をご覧ください。
Java EE 8先取り!MVC 1.0入門 [EDR2対応版] 2015-10-10更新
EE 8は2017年上半期リリース予定ですので、現在のEE 7ではMVC 1.0は使えません。
MVC 1.0の参照実装「Ozark」は既にGitHub・Maven上で公開されていますが、まだ策定途中のため仕様が変更される可能性が多々あり、学習目的以外では使わないほうがよいでしょう。
Jersey MVCならJava EE 7でも使える!
JAX-RSの参照実装「Jersey」には、独自機能「Jersey MVC」があります。
Jersey MVC自体はすでに完成しているフレームワークであり、Java EE 7時代の現在でも使うことができます。
MVC 1.0は、このJersey MVCを参考にして作られていると言われており、実際にも似た部分が多くあります(もちろん、異なる部分もあります)。
Java EE 7の今はJersey MVCで作り、EE 8リリース後にMVC 1.0に移行するという手もアリなのではないでしょうか。
そこで今回は、EE 8でMVC 1.0に移行することを見据えた上で、EE 7とJersey MVCでどのように作るか、ということを考えていきたいと思います。
Jersey MVCは日本語ブログも結構多いのですが、必要な設定などが結構変わっているため、最新情報をまとめるという意味で、この記事を書きました。
注意点
Jersey MVCは、あくまでJerseyの独自機能であり、Java EE 7標準のものではありません。
よって、Java EEのメリットの1つである「サーバーベンダーからのサポート」は対象外になる可能性があります。
サーバーベンダーのサポートポリシーや、ご自分のプロジェクトの事情などを考慮した上で、ご利用ください。
今回の方針
- MVC 1.0の再発明はしない。(MVC 1.0はまだ仕様が確定していないため)
- Jersey MVC自体への修正も加えない。(どこか修正すると修正点が芋づる式に増えてキリがないため)
- Jersey MVCおよびJava EE 7の機能の範囲内で、MVC 1.0への移行コストがなるべく小さくなる実装を模索する。
環境
- Payara Web ML 4.1.1.154
- Jersey 2.22.1
- Maven 3.3.3
- IntelliJ IDEA 15 (NetBeansでもEclipseでもいいです)
- JDK 8u66
比較対象のMVC 1.0は、2015年10月に公開されたEDR2版とします。
完成版のコード
これ以降のコードは、重要な部分のみ抜き出して、一部省略しています。
完全なコードはGitHubにアップしていますので、こちらをご参照ください。
GitHub - MasatoshiTada/JavaEEAdventCalendar2015-JerseyMVC
Mavenプロジェクトの作成
それでは、手順を説明していきます。
Mavenでプロジェクトを作成し、pom.xmlに依存性を追加します。
<properties> <javaee.version>7.0</javaee.version> <jersey.version>2.22.1</jersey.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.glassfish.jersey</groupId> <artifactId>jersey-bom</artifactId> <version>${jersey.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <!-- Java EE 7 Web Profile --> <dependency> <groupId>javax</groupId> <artifactId>javaee-web-api</artifactId> <version>${javaee.version}</version> <scope>provided</scope> </dependency> <!-- Jersey MVC + Jersey MVC JSP --> <dependency> <groupId>org.glassfish.jersey.ext</groupId> <artifactId>jersey-mvc-jsp</artifactId> <scope>provided</scope> </dependency> <!-- JerseyでBean Validationを使う --> <dependency> <groupId>org.glassfish.jersey.ext</groupId> <artifactId>jersey-bean-validation</artifactId> <scope>provided</scope> </dependency> <!-- Jersey MVCでBean Validationを使う --> <dependency> <groupId>org.glassfish.jersey.ext</groupId> <artifactId>jersey-mvc-bean-validation</artifactId> <!-- Payara(GlassFish)に含まれていないので「compile」でWARに含める --> <scope>compile</scope> </dependency> <!-- JSTL --> <dependency> <groupId>org.glassfish.web</groupId> <artifactId>javax.servlet.jsp.jstl</artifactId> <version>1.2.4</version> <scope>provided</scope> </dependency> </dependencies>
ほとんどの依存性はPayaraに含まれているのでprovided
ですが、jersey-mvc-bean-validationのみPayaraに含まれていないのでcompile
にしています。
設定クラスの作成
JAX-RSを有効化するためには、通常javax.ws.rs.Application
クラスのサブクラスを作成しますが、今回はJersey独自のApplication
サブクラスであるResourceConfig
クラスを継承します。
package com.example.rest; import org.glassfish.jersey.server.ResourceConfig; import org.glassfish.jersey.server.mvc.beanvalidation.MvcBeanValidationFeature; import org.glassfish.jersey.server.mvc.jsp.JspMvcFeature; import javax.ws.rs.ApplicationPath; @ApplicationPath("api") public class MyApplication extends ResourceConfig { public MyApplication() { // Jersey MVCの登録、ビューとしてJSPを使う register(JspMvcFeature.class); // Jersey MVCにおけるBean Validationを有効化する register(MvcBeanValidationFeature.class); // JSPファイルを保存するフォルダを指定する property(JspMvcFeature.TEMPLATE_BASE_PATH, "/WEB-INF/views/"); // com.example以下の全パッケージを登録対象にする packages(true, this.getClass().getPackage().getName()); } }
Jersey MVCを利用するには、JspMvcFeature
クラスの登録が必要になります。
Application
クラスを継承しても出来なくはないのですが、全リソースクラスおよび@Provider
が付加されたクラスもすべて登録しなければならず、手間がかかります。
JAX-RSには、もともとAuto Discoveryというリソースクラスなどを自動的に登録する機能があるのですが、1つでもクラスを自前で登録してしまうと、Auto Discoveryが無効になってしまうのです。
ResourceConfig
にはpackage()
という、指定されたパッケージ名内のクラスをすべて登録するメソッドが定義されており、便利です。第1引数をtrue
にすることで、サブパッケージ内のクラスも再帰的に登録します。
コントローラークラスの作成
Jersey MVCでは、リソースメソッドの戻り値をorg.glassfish.jersey.server.mvc.Viewable
することで、リソースメソッドをコントローラーメソッドにすることができます。
package com.example.rest.resource; import com.example.rest.dto.HelloDto; import com.example.rest.exception.MyException; import java.io.IOException; import com.example.rest.exception.MyRuntimeException; import org.glassfish.jersey.server.mvc.ErrorTemplate; import org.glassfish.jersey.server.mvc.Viewable; import javax.enterprise.context.RequestScoped; import javax.inject.Inject; import javax.validation.constraints.Pattern; import javax.validation.constraints.Size; import javax.ws.rs.DefaultValue; import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.QueryParam; @Path("hello") @RequestScoped public class HelloResource { @Inject private HelloDto helloDto; @GET @Path("index") public Viewable index() { return new Viewable("/hello/index.jsp"); } @GET @Path("result") @ErrorTemplate(name = "index") public Viewable result(@QueryParam("name") @DefaultValue("") @Size(message = "{name.size}", min = 1, max = 10) @Pattern(message = "{name.pattern}", regexp = "[a-zA-Z]*") String name) throws Exception { // 例外を起こすサンプル switch (name) { case "null": throw new NullPointerException("NULLPO!"); case "myrun": throw new MyRuntimeException("MyRuntime!"); case "run": throw new RuntimeException("Runtime!"); case "io": throw new IOException("IOE!"); case "myex": throw new MyException("MY EXCEPTION!"); case "ex": throw new Exception("EXCEPTION!"); } // 本来の処理 helloDto.setMessage("Hello, " + name); return new Viewable("/hello/result.jsp"); } }
return new Viewable("/hello/index.jsp");
とすることで、index.jspにフォワードするという意味になります。(拡張子.jsp
は付けなくても動きます)。
相対パスと絶対パス
Viewable
コンストラクタの引数に指定するJSPファイルへのパスは、「/」で始める絶対パスと、「/」で始めない相対パスの2種類があります。
まず、絶対パスと相対パスで共通するのは、前述のJspMvcFeature.TEMPLATE_BASE_PATH
で指定したフォルダ(今回の場合は「/WEB-INF/views/」)を読むということです。
絶対パスの場合、「/WEB-INF/views/」からの絶対パスになります。例えば、戻り値をreturn new Viewable("/hello/index.jsp");
とした場合、フォワード先のJSPは/WEB-INF/views/index.jsp
です。
相対パスの場合、「/WEB-INF/views/リソースクラスのパッケージのパス/リソースクラス名/コントローラーの戻り値」となります。例えば、リソースクラスのパッケージがcom.example.rest.resource
、リソースクラス名がHelloResource
、戻り値がreturn new Viewable("index.jsp");
の場合、フォワード先のJSPは/WEB-INF/views/com/example/rest/resource/HelloResource/index.jsp
です。
MVC 1.0との比較
今のところEDR2の仕様(一部Ozarkの挙動)では、以下のようになっています。
- コントローラーの戻り値は
String
またはjavax.mvc.Viewable
(void
やResponse
も可能) - 拡張子の指定が必須
- 「/」で始める絶対パスの場合、フォワード先のビューは
コンテキストルート/コントローラーの戻り値
(JSRには「/」で始める場合の記述がないため、今のところOzark独自の挙動っぽい) - 「/」で始めない相対パスの場合、デフォルトでは
/WEB-INF/views/コントローラーの戻り値
絶対パス・相対パス共に、Jersey MVCとは微妙に異なります。
こうなると、Jersey MVCでは相対パス・絶対パスのどちらで書いたほうが移行コスト(=プログラム等の修正箇所)が少ないかは、一概には言えない感じがしますね・・・(^^;
そもそも、現在のMVC 1.0のサブフォルダ名まで指定しなければいけないこと自体がカッコよくない気がするなあ。。。
似たような議論がMVC 1.0のメーリングリストでもあったのですが、採用されないまま終わっています。
https://java.net/projects/mvc-spec/lists/users/archive/2015-12/message/6
Jersey MVCでは絶対パス・相対パスのどちらがいいのか、まだ悩み中です・・・。
ビューの作成
前述のフォルダに、JSPファイルを作成します。
index.jsp(入力画面)
<%@ taglib prefix="mytag" uri="http://example.com/myTag" %> <%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head> <title>名前入力画面(絶対パス)</title> <link rel="stylesheet" href="../../css/style.css"> </head> <body> <h1>名前を入力してください</h1> <mytag:errors errorClass="error"/> <form method="get" action="./result"> 名前:<input type="text" name="name"> <input type="submit" value="送信"> </form> <a href="./redirect">リダイレクト</a> </body> </html>
result.jsp(出力画面)
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head> <title>メッセージ表示画面(絶対パス)</title> </head> <body> <c:out value="${hello.message}"/> </body> </html>
JSP以外のビューを使う方法
Jersey MVCでサポートしているビューは、JSP・FreeMarker・Mustacheです。
https://jersey.java.net/documentation/latest/user-guide.html#d0e15182
また、org.glassfish.jersey.server.mvc.spi.TemplateProcessor
インタフェースを実装することで、他のビューを使うことも可能です。
下記の@glory_ofさんのブログの場合、Thymeleafを使っていらっしゃいます。
JerseyMVCとThymeleafを組み合わせる - シュンツのつまづき日記
コントローラーからビューへの値の受け渡し
Jersey MVCで定義されているのは、Viewable
コンストラクタにオブジェクトを渡し、JSPではELでmodel
という名前で参照する方法です。
@GET @Path("result") public Viewable result(@QueryParam("name") @DefaultValue("") String name) throws Exception { helloDto.setMessage("Hello, " + name); return new Viewable("/hello/result.jsp", helloDto); }
<c:out value="${model.message}"/>
しかし、この方法はMVC 1.0には現時点では無く、かつオブジェクトが1つしか渡せないというデメリットがあります。
MVC 1.0にはModels
というマップがあるのですが、これを再発明することは今回の「MVC 1.0の再発明はしない」という方針に反します。
そこで、Jersey MVCとMVC 1.0の両方で使える、CDIビーンを使う方法を紹介します。
まず、DTOクラスを作成し、@Named
と@RequestScoped
を付加します。
package com.example.rest.dto; import javax.enterprise.context.RequestScoped; import javax.inject.Named; @Named("hello") @RequestScoped public class HelloDto { private String message; public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } }
コントローラーでは、このDTOをフィールドインジェクションし、DTOのsetterで値をセットします。
@Inject private HelloDto helloDto; @GET @Path("result") public Viewable result(@QueryParam("name") @DefaultValue("") String name) throws Exception { helloDto.setMessage("Hello, " + name); return new Viewable("/hello/result.jsp"); }
JSPのELでは、@Named
で指定した名前で呼び出します。
<c:out value="${hello.message}"/>
この方法なら、Jersey MVC (というかJava EE 7)でもMVC 1.0でも使えます。
受け渡す値が2つ以上ならば、その数だけDTOクラスを作成することになります。
2016-01-14追記
改めて考えてみたら、CDIビーンを使う方法は微妙な気がしてきました・・・。
データが複数件の場合、Listをフィールドに持つDTOクラスを別途作るのか?例えば、社員DTOと社員リストDTOを両方作るのは微妙です・・・。
データが無かった場合(DBの検索結果が無かったとか)、それを表すフラグフィールドを作るのか?これも何かなあ・・・。
以上のことを考えると、CDIビーンではなく、Viewableコンストラクタに渡す方法がいいかもしれません。
複数の値をビューに渡す場合は、複数の値をHashMapにputして、このマップをViewableコンストラクタに渡せばOKです。
2016-01-27追記
@glory_ofさんから、「複数の値をビューに渡す場合は、それらをラップする大きなDTOクラスを作って、それをViewableコンストラクタ渡す」という方法を教えていただきました。
// @Namedや@RequestScopedなどはつけなくてOK public class LargeDto { private Employee employee; private Department department; ... } // コントローラーメソッド内 LargeDto largeDto = ...; return new Viewable("/hello/result", largeDto);
この方法のメリットは、クラスを作るので型安全やIDEの補完機能が効くことです。
デメリットは、作るクラスが増えて手間が少しかかることですね。でも、そんなに大きな工数はかからないと思います。
上記のMapを使う方法と、メリット・デメリットを比較して、どちらの方法を使うか考えていただければと思います。
バリデーションと例外処理
MVC 1.0では、BindingResult
でバリデーションエラーの有無およびエラーメッセージの表示を行い、例外処理はJAX-RS標準のExceptionMapper
を利用します。
Jersey MVCにはBindingResult
と同様の動きをするものが存在しません。
色々と考えたのですが、ここは素直にJersey MVCで提供されている機能を使いましょう。
@ErrorTemplate
の利用
コントローラーメソッドに@ErrorTemplate
を付加し、バリデーションエラーおよび例外発生時に遷移するビューを指定します。
このビュー名も、コントローラーと同様のルールで相対パスまたは絶対パスで指定します。
@GET @Path("result") @ErrorTemplate(name = "index.jsp") // 相対パス // @ErrorTemplate(name = "/hello/index.jsp") // 絶対パス public Viewable result(@QueryParam("name") @DefaultValue("") @Size(message = "{name.size}", min = 1, max = 10) @Pattern(message = "{name.pattern}", regexp = "[a-zA-Z]*") String name) throws Exception { // 例外を起こすサンプル switch (name) { case "null": throw new NullPointerException("NULLPO!"); case "myrun": throw new MyRuntimeException("MyRuntime!"); case "run": throw new RuntimeException("Runtime!"); case "io": throw new IOException("IOE!"); case "myex": throw new MyException("MY EXCEPTION!"); case "ex": throw new Exception("EXCEPTION!"); } // 本来の処理 helloDto.setMessage("Hello, " + name); // return new Viewable("/hello/result.jsp"); // 絶対パス return new Viewable("result.jsp"); // 相対パス }
今回は、「1文字以上10文字以下でなければならない」「入力文字列は半角英字でなければならない」というルールにしました。
@Size
や@Pattern
は、Java EEのBean Validationで定義されたアノテーションです。
バリデーションエラー時は、javax.validation.ConstraintViolationException
が発生します。
この例外に対応したExceptionMapper
実装クラスが、jersey-mvc-bean-validationに含まれています(org.glassfish.jersey.server.mvc.beanvalidation.ValidationErrorTemplateExceptionMapper
クラス)。
このValidationErrorTemplateExceptionMapper
には、バリデーションエラー発生時に@ErrorTemplate
で指定されたビューにフォワードする処理が記述されています。
ConstraintViolationException
以外の例外がコントローラーメソッド内で発生した場合、jersey-mvcに含まれているorg.glassfish.jersey.server.mvc.internal.ErrorTemplateExceptionMapper
クラスが動き、@ErrorTemplate
で指定されたビューにフォワードします。
エラーメッセージの表示
バリデーションエラー時もその他の例外発生時も、JSPのELではmodel
という名前で参照します。
バリデーションエラー時はList<ValidationError>
、例外発生時はその例外オブジェクトそのものが、model
に格納されます。
これもどうするか非常に悩んだのですが、JSPカスタムタグを作りました。
package com.example.servlet.tag; import org.glassfish.jersey.server.validation.ValidationError; import javax.servlet.jsp.JspException; import javax.servlet.jsp.JspWriter; import javax.servlet.jsp.tagext.SimpleTagSupport; import java.io.IOException; import java.util.List; public class ErrorsHandler extends SimpleTagSupport { private String errorClass; public void setErrorClass(String errorClass) { this.errorClass = errorClass; } @Override public void doTag() throws JspException, IOException { JspWriter out = getJspContext().getOut(); Object model = getJspContext().findAttribute("model"); if (model instanceof Exception) { out.println("<ul class=\"" + errorClass + "\">"); Exception exception = (Exception) model; out.println("<li>"); out.println(exception.getMessage()); out.println("</li>"); out.println("</ul>"); } else if (isValidationErrorList(model)) { out.println("<ul class=\"" + errorClass + "\">"); List<ValidationError> validationErrors = (List<ValidationError>) model; for (ValidationError error : validationErrors) { out.println("<li>"); out.println(error.getMessage()); out.println("</li>"); } out.println("</ul>"); } } private boolean isValidationErrorList(Object model) { if (model instanceof List) { List list = (List) model; if ( ! list.isEmpty()) { Object firstElement = list.get(0); if (firstElement instanceof ValidationError) { return true; } } } return false; } }
<%@ taglib prefix="mytag" uri="http://example.com/myTag" %> <mytag:errors errorClass="error"/>
あんまりカッコよくない実装なので、もっと良い案がありましたら是非コメントください!
例外発生時は別のエラーページに遷移する
ExceptionMapper
実装クラスを作り、Viewable
でエラーページ指定しました。
package com.example.rest.exception.mapper; import org.glassfish.jersey.server.mvc.Viewable; import javax.ws.rs.core.Response; import javax.ws.rs.ext.Provider; @Provider public class ExceptionMapper implements javax.ws.rs.ext.ExceptionMapper<Exception> { @Override public Response toResponse(Exception e) { Viewable viewable = new Viewable("/error/exception", e.getMessage()); return Response.status(Response.Status.BAD_REQUEST) .entity(viewable).build(); } }
// importは省略 @Provider public class MyExceptionMapper implements ExceptionMapper<MyException> { @Override public Response toResponse(MyException e) { Viewable viewable = new Viewable("/error/exception", e.getMessage()); return Response.status(Response.Status.BAD_REQUEST) .entity(viewable).build(); } }
// importは省略 @Provider public class MyRuntimeExceptionMapper implements ExceptionMapper<MyRuntimeException> { @Override public Response toResponse(MyRuntimeException e) { Viewable viewable = new Viewable("/error/exception", e.getMessage()); return Response.status(Response.Status.BAD_REQUEST) .entity(viewable).build(); } }
// importは省略 @Provider public class RuntimeExceptionMapper implements ExceptionMapper<RuntimeException> { @Override public Response toResponse(RuntimeException e) { Viewable viewable = new Viewable("/error/exception", e.getMessage()); return Response.status(Response.Status.BAD_REQUEST) .entity(viewable).build(); } }
で、実行していただくと分かるのですが、IOException
とException
の場合のみ、exception.jspではなく、index.jspに遷移します。
これは、org.glassfish.jersey.server.mvc.internal.ErrorTemplateExceptionMapper
クラスが優先されているようです。
IOException
は自作のExceptionMapper
を作っていないので、当然ErrorTemplateExceptionMapper
クラスが動く形になります。
Exception
は自作のExceptionMapper
を作っていますが、ErrorTemplateExceptionMapper
クラスが優先されるようです。
JAX-RSの仕様やJerseyのドキュメントを読んで確認しましたが、@Priority
で優先度をつけることが出来ないようで、回避のしようが無いっぽいです。
リダイレクト
MVC 1.0だと、コントローラメソッドの戻り値の文字列にredirect:
という接頭辞をつけるだけでリダイレクトになりますが、Jersey MVCにはその機能はありません。
なので、JAX-RS標準の機能を使いましょう。ステータスコードを300番台にして、HTTPレスポンスのlocationヘッダーにリダイレクト先を指定します。
// リダイレクト元 @GET @Path("redirect") public Response redirect(@Context UriInfo uriInfo) throws URISyntaxException { URI location = uriInfo.getBaseUriBuilder() .path(HelloResource.class) .path("redirect2") .build(); return Response.status(Response.Status.FOUND) .location(location).build(); } // リダイレクト先 @GET @Path("redirect2") public Viewable redirect2() { return new Viewable("redirect2.jsp"); }
JAX-RS標準の機能なので、MVC 1.0に移行しても特に修正の必要はないはずです。
Payara以外のAPサーバーでのJersey MVCの利用
ここまでの内容は、Payara(およびGlassFish)、つまりJerseyおよびJersey MVCが内包されたAPサーバー前提で書きました。
Tomcatの場合
pom.xmlでjersey関連の依存性をすべてcompile
にすればできるはずです。
検証ができたら後ほど追記します。
Oracle WebLogic Serverの場合
内包されているJAX-RS実装はJerseyですが、Jersey MVCは内包されていないようです。
ですので、pom.xmlを記述する際は、jersey-mvc-jspのスコープをcompile
にすればOKだと思います。
未検証なので、どなたかWebLogicを使っている方は試してみてください!
WildFly/JBoss/IBM WebSphereの場合
内包されているJAX-RS実装がJerseyではありません(WildFly/JBossはRESTEasy、WebSphereはApache CXF)。
pom.xmlでjersey関連の依存性をすべてcompile
するだけだと、サーバーに内包されている方のJAX-RS実装が動くような気がするので、web.xmlにorg.glassfish.jersey.servlet.ServletContainer
を追加する必要があるかも・・・。
WildFlyで検証しますので、検証ができたら後ほど追記します。
ちなみに、RESTEasyには「HTML Provider」というJersey MVCに似た独自機能が存在します。
機能自体はかなりシンプルですが、WildFly/JBossの場合はこちらを使うのもアリかもしれません。
ただし、こちらはMVC 1.0とは相違点がかなり多いので、その点はご注意ください。
RESTEasyのHTML Providerで遊んでみる - CLOVER
まとめ
- EE 7でアクションベースMVCを使いたい場合は、Jersey MVCを使いましょう!
- Jersey MVCとMVC 1.0の相対パス・絶対パスを理解しよう!
ビューへ値を渡す時は、CDIビーンを使いましょう!Jersey MVCでは素直にViewableコンストラクタに渡しましょう。複数の値を渡す場合は、Mapか大きなDTOを使いましょう。- バリデーションと例外処理は、
@ErrorTemplate
とExceptionMapper
を併用しましょう!
最後に
繰り返しになりますが、Jersey MVCはあくまで独自機能であり、Java EE 7標準の「範囲外」です。
Java EE 7標準の範囲内では、コンポーネントベースのJSFが、唯一のHTMLを返すフレームワークです。
Java EE 7標準にどこまでこだわるか、プロジェクトの事情を考慮して、利用を検討してください。