LoginSignup
12
21

More than 5 years have passed since last update.

Spring Framework 5.0 WebMVC関連の主な変更点

Last updated at Posted at 2017-05-20

今回は「Spring Framework 5.0 主な変更点」シリーズの第4回で、WebMVC関連の主な変更点(新機能や改善点など)を紹介していきたいと思います。

シリーズ

動作検証バージョン

ベースライン

  • Spring Framework 5.0.0.RC1
  • Spring Boot 2.0.0.M1
  • Apache Tomcat 8.5.15
  • JDK 1.8.0_121
  • Mac

個別

  • Yasson 1.0.0-M2 (JSON-B検証用)
  • Glassfish JSR 374 (JSON Processing) Default Provider 1.1.0-M2 (JSON-B検証用)
  • Reactor 3.1.0.M1 (Reactive Type検証用)
  • Undertow 2.0.0.Alpha2-SNAPSHOT (Servlet 4.0検証用) ※ 2017/5/18時点

今回は動作検証するためにSpring Boot 2.0.0.M1(デフォでSpring Framework 5.0.0.RC1に依存している)を使用しました。以下に検証用プロジェクトを作成した時の手順を簡単に紹介しておきます。

Note:

SPRING INITIALIZRやIDEの機能を使って作ることもできます。

  • プロジェクトの作成
$ curl -s https://start.spring.io/starter.tgz\
  -d name=spring5-web-demo\
  -d artifactId=spring5-web-demo\
  -d dependencies=web\
  -d baseDir=spring5-web-demo\
  -d bootVersion=2.0.0.M1\
  | tar -xzvf -
  • Spring Bootの起動
$ ./mvnw spring-boot:run
/usr/local/apps/spring5-web-demo
[INFO] Scanning for projects...
[INFO] 
[INFO] ------------------------------------------------------------------------
[INFO] Building spring5-web-demo 0.0.1-SNAPSHOT
[INFO] ------------------------------------------------------------------------
[INFO] 
[INFO] >>> spring-boot-maven-plugin:2.0.0.M1:run (default-cli) > test-compile @ spring5-web-demo >>>
[INFO] 
[INFO] --- maven-resources-plugin:2.6:resources (default-resources) @ spring5-web-demo ---
[INFO] Using 'UTF-8' encoding to copy filtered resources.
[INFO] Copying 1 resource
[INFO] Copying 0 resource
[INFO] 
[INFO] --- maven-compiler-plugin:3.1:compile (default-compile) @ spring5-web-demo ---
[INFO] Changes detected - recompiling the module!
[INFO] Compiling 1 source files to /usr/local/apps/spring5-web-demo/target/classes
[INFO] 
[INFO] --- maven-resources-plugin:2.6:testResources (default-testResources) @ spring5-web-demo ---
[INFO] Using 'UTF-8' encoding to copy filtered resources.
[INFO] skip non existing resourceDirectory /usr/local/apps/spring5-web-demo/src/test/resources
[INFO] 
[INFO] --- maven-compiler-plugin:3.1:testCompile (default-testCompile) @ spring5-web-demo ---
[INFO] Changes detected - recompiling the module!
[INFO] Compiling 1 source files to /usr/local/apps/spring5-web-demo/target/test-classes
[INFO] 
[INFO] <<< spring-boot-maven-plugin:2.0.0.M1:run (default-cli) < test-compile @ spring5-web-demo <<<
[INFO] 
[INFO] 
[INFO] --- spring-boot-maven-plugin:2.0.0.M1:run (default-cli) @ spring5-web-demo ---

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::             (v2.0.0.M1)

2017-05-17 01:19:22.357  INFO 26370 --- [           main] c.e.s.Spring5WebDemoApplication          : Starting Spring5WebDemoApplication on xxx with PID 26370 (/usr/local/apps/spring5-web-demo/target/classes started by xxx in /usr/local/apps/spring5-web-demo)
2017-05-17 01:19:22.360  INFO 26370 --- [           main] c.e.s.Spring5WebDemoApplication          : No active profile set, falling back to default profiles: default
2017-05-17 01:19:22.411  INFO 26370 --- [           main] ConfigServletWebServerApplicationContext : Refreshing org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext@25c892fe: startup date [Wed May 17 01:19:22 JST 2017]; root of context hierarchy
2017-05-17 01:19:23.717  INFO 26370 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8080 (http)
2017-05-17 01:19:23.733  INFO 26370 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2017-05-17 01:19:23.735  INFO 26370 --- [           main] org.apache.catalina.core.StandardEngine  : Starting Servlet Engine: Apache Tomcat/8.5.15
2017-05-17 01:19:23.822  INFO 26370 --- [ost-startStop-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2017-05-17 01:19:23.822  INFO 26370 --- [ost-startStop-1] o.s.web.context.ContextLoader            : Root WebApplicationContext: initialization completed in 1414 ms
2017-05-17 01:19:23.956  INFO 26370 --- [ost-startStop-1] o.s.b.w.servlet.ServletRegistrationBean  : Mapping servlet: 'dispatcherServlet' to [/]
2017-05-17 01:19:23.961  INFO 26370 --- [ost-startStop-1] o.s.b.w.servlet.FilterRegistrationBean   : Mapping filter: 'characterEncodingFilter' to: [/*]
2017-05-17 01:19:23.961  INFO 26370 --- [ost-startStop-1] o.s.b.w.servlet.FilterRegistrationBean   : Mapping filter: 'hiddenHttpMethodFilter' to: [/*]
2017-05-17 01:19:23.961  INFO 26370 --- [ost-startStop-1] o.s.b.w.servlet.FilterRegistrationBean   : Mapping filter: 'httpPutFormContentFilter' to: [/*]
2017-05-17 01:19:23.961  INFO 26370 --- [ost-startStop-1] o.s.b.w.servlet.FilterRegistrationBean   : Mapping filter: 'requestContextFilter' to: [/*]
2017-05-17 01:19:24.206  INFO 26370 --- [           main] s.w.s.m.m.a.RequestMappingHandlerAdapter : Looking for @ControllerAdvice: org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext@25c892fe: startup date [Wed May 17 01:19:22 JST 2017]; root of context hierarchy
2017-05-17 01:19:24.292  INFO 26370 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/error]}" onto public org.springframework.http.ResponseEntity<java.util.Map<java.lang.String, java.lang.Object>> org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController.error(javax.servlet.http.HttpServletRequest)
2017-05-17 01:19:24.293  INFO 26370 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/error],produces=[text/html]}" onto public org.springframework.web.servlet.ModelAndView org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController.errorHtml(javax.servlet.http.HttpServletRequest,javax.servlet.http.HttpServletResponse)
2017-05-17 01:19:24.323  INFO 26370 --- [           main] o.s.w.s.handler.SimpleUrlHandlerMapping  : Mapped URL path [/webjars/**] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
2017-05-17 01:19:24.323  INFO 26370 --- [           main] o.s.w.s.handler.SimpleUrlHandlerMapping  : Mapped URL path [/**] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
2017-05-17 01:19:24.370  INFO 26370 --- [           main] o.s.w.s.handler.SimpleUrlHandlerMapping  : Mapped URL path [/**/favicon.ico] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
2017-05-17 01:19:24.533  INFO 26370 --- [           main] o.s.j.e.a.AnnotationMBeanExporter        : Registering beans for JMX exposure on startup
2017-05-17 01:19:24.610  INFO 26370 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http)
2017-05-17 01:19:24.615  INFO 26370 --- [           main] c.e.s.Spring5WebDemoApplication          : Started Spring5WebDemoApplication in 2.996 seconds (JVM running for 6.973)
  • Spring Bootの停止
ターミナル上で「Ctrl + C」!!

WebMVC関連の変更点

Spring Framework 5.0では、WEB機能に対して以下のような変更が行われています。

項番 変更内容
1 Spring Framework提供のサーブレットフィルタがServlet 3.1のAPI仕様準拠の実装になります。(実はどう準拠したのかわかってない・・・:sweat_smile:) [詳細へ:arrow_right:]
2 Spring MVCのHandlerメソッドの引数として、Java EE 8の構成要素であるServlet 4.0で追加されるPushBuilder(HTTP/2のServer Pushを行うためのAPI)を受け取れるようになります。 [詳細へ:arrow_right:]
3 Servlet 3.0でサポートされたファイルアップロード機能のサイズ超過エラーが発生した際に、MaxUploadSizeExceededException(MultipartExceptionのサブ例外)がスローされるようになります。 [詳細へ:arrow_right:]

Note: ただし、エラーメッセージに特定の単語("size""exceed")が含まれているか否かで判定しているため、アプリケーションサーバの実装によっては従来どおりMultipartExceptionがスローされる可能性があるという点は意識しておいた方がよいでしょう。
4 統一的なメディアタイプ解決の仕組み(MediaTypeFactoryクラス)が追加されます。 [詳細へ:arrow_right:]

Note: この対応に伴いJAF(JavaBeans Activation framework)依存のコードが排除される。
5 イミュータブルオブジェクトへのデータバインディングがサポートされます。 [詳細へ:arrow_right:]
6 Jackson 2.9がサポート対象になります。 [詳細へ:arrow_right:]
7 Java EE 8の構成要素であるJSON Bindingがサポート対象になります。

Note: これはJacksonおよびGSONの代替として使用することができます。 [詳細へ:arrow_right:]
8 Google Protobuf 3.xがサポート対象になります。 [詳細へ:arrow_right:]
9 Reactive Programing Model(後述するSpring WebFlux)のサポートに伴い、Reactor 3.1のクラス(Flux, Mono)、RxJava 1.3と2.1のクラス(Observable, Sigle, Flowableなど)をHandlerメソッドの返り値として扱うことができるようになります。 [詳細へ:arrow_right:]
10 AntPathMatcherの代替として、ParsingPathMatcherが追加されます。 [詳細へ:arrow_right:]

Note: Spring WebFlux関連のコンポーネントでは、ParsingPathMatcherがデフォルトで使用されます。
11 @ExceptionHandlerメソッドの引数として、RedirectAttributesを受け取れるようになります。(=RedirectAttributesを介してリダイレクト先とデータを連携することができるようになります) [詳細へ:arrow_right:]
12 ResponseStatusExceptionが追加され、任意のステータスコードとリーズンフレーズを指定して例外を生成・スローすることで、HTTPステータスを制御できるようになります。 [詳細へ:arrow_right:]

Note: ResponseStatusExceptionはSpring WebFlux向けに作成したみたいですが、Spring MVCでも使えるように対応されています。
13 ScriptTemplateView(JSR-223のスクリプトエンジンを使用したView実装)にて、「メッセージの国際化」および「テンプレートのフラグメント化」を実現するために必要となるオブジェクト(Locale,MessageSourceなど)がテンプレートエンジン側に引き渡されるようになります。 [詳細へ:arrow_right:]

Note: 「国際化」や「テンプレートをフラグメント化」するための実装自体はSpringからは提供されないので、Springのテストケースなどを参考に開発者が実装する必要があります。

サーブレットフィルタの実装がServlet 3.1のAPI仕様に準拠される? :thumbsup:

[SPR-14467] : どうやら純粋に(サーブレットフィルタ限定ではなく)全体的にServlet 3.1のAPI仕様ベースの実装になるよ(=Servlet 3.0上での動作はサポート外)!ということらしいです。これに関連し、Spring Framework 4.3まではServlet 2.5環境との互換性を保つためのコードが実装されていましたが、Spring Framework 5.0からはServlet 2.5環境のサポートは一切打ち切られます(SPR-13189)。

HandlerメソッドでServlet 4.0のPushBuilderが受け取れる :thumbsup:

[SPR-12674] Spring MVCのHandlerメソッドの引数として、Java EE 8の構成要素であるServlet 4.0で追加されるPushBuilder(HTTP/2のServer Pushを行うためのAPI)を受け取れるようになります。

簡単なサンプルを実装して、実際にHTTP/2のServer Pushを試してみましょう。
まず、cssファイルとcssファイルにlinkをもつHTMLファイルを作ります。

src/main/resources/static/style.css
body {
    background-color: azure;
}
src/main/resources/static/hello.html
<html>
<head>
    <link rel="stylesheet" type="text/css" href="style.css"/> <!-- ★★★ cssファイルへのリンク -->
</head>
<body>
<h1>Hello World !!</h1>
</body>
</html>

つぎに、Controllerを作ります。

package com.example.spring5webdemo;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

import javax.servlet.http.PushBuilder;

@Controller
public class PushBuilderController {

    @GetMapping("/push-builder")
    String get(PushBuilder builder) { // ★★★ 引数にPushBuilderを宣言
        builder.path("/style.css").push(); // ★★★ cssファイルをpush対象のパスに設定してpush
        return "forward:/hello.html"; // HTMLに遷移するためのView名を返却
    }

}

「さ〜サーバーを起動して確認しましょう!」と言いたいところですが、そもそもServlet 4.0のAPI(PushBuilder)がみつからないのでコンパイルできないと思います。なので・・・Servlet 4.0に対応しているアプリケーションサーバを使うようにpom.xmlファイルを修正したいと思います。最初はTomcat 9.0で試そうと思ったのですが、Undertowの方がHTTP/2化するのが簡単そうだったので、ここではServlet 4.0に対応しているUndertow 2.0(開発中のバージョン)を使用して動作確認を行います。

Note:

Undertow 2.0.0.Alpha1がMaven Centralにリリースされていますが、このバージョンは残念ながら利用できません。理由は、Servlet 4.0のAPI仕様が途中でかわり、その変更内容が取り込まれいないためです。なので・・・本エントリーでは、GitHubからソースコードを取得してUndertow 2.0.0.Alpha2-SNAPSHOTをローカルリポジトリへインストールします。

$ git clone https://github.com/undertow-io/undertow.git
$ cd undertow
$ mvn -U install -DskipTests=true

pom.xmlファイルを修正し、Tomcatの代わりにUndertow 2.0.0.Alpha2-SNAPSHOTを使うようにします。

pom.xml
<!-- ... -->

<properties>
  <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
  <java.version>1.8</java.version>
  <undertow.version>2.0.0.Alpha2-SNAPSHOT</undertow.version> <!-- ★★★ Servlet 4.0サポートのバージョンを指定(ローカルリポジトリにインストールしたバージョン) -->
</properties>

<!-- ... -->

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <!-- ★★★ Tomcatを除外 -->
    <exclusions>
      <exclusion>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-tomcat</artifactId>
      </exclusion>
    </exclusions>
  </dependency>

  <!-- ... -->

  <!-- ★★★ Undertowを追加 -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-undertow</artifactId>
  </dependency>

  <!-- ★★★ alpn-bootを追加 -->
  <!-- 
    ALPN (Application Layer Protocol Negotiation)の実装ライブラリをローカルリポジトリへダウンロードするための設定。
    ダウンロードしたファイルはアプリケーション実行時の起動オプション(-Xbootclasspath)に指定。
  -->
  <dependency>
    <groupId>org.mortbay.jetty.alpn</groupId>
    <artifactId>alpn-boot</artifactId>
    <version>8.1.11.v20170118</version>
    <scope>provided</scope>
  </dependency>

  <!-- ... -->

</dependencies>

<!-- ... -->

<build>
  <plugins>
    <plugin>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-maven-plugin</artifactId>
      <configuration>
        <!-- ★★★ ALPNの実装ライブラリを起動オプション(-Xbootclasspath)に指定 -->
        <jvmArguments>
          -Xbootclasspath/p:${settings.localRepository}/org/mortbay/jetty/alpn/alpn-boot/8.1.11.v20170118/alpn-boot-8.1.11.v20170118.jar
        </jvmArguments>
      </configuration>
    </plugin>
  </plugins>
</build>

<!-- ... -->

HTTP/2で通信を行うためには、サーバーとブラウザの間の通信をHTTPSによるSSL/TLS通信にする必要があるため、自己署名証明書を格納したキーストアを作成します。

$ keytool -genkey -storetype PKCS12 -keyalg RSA -keysize 2048 -keystore src/main/resources/keystore.p12 -validity 3650
キーストアのパスワードを入力してください:  
新規パスワードを再入力してください: 
姓名は何ですか。
 [Unknown]:  Sample
組織単位名は何ですか。
 [Unknown]:  Sample
組織名は何ですか。
 [Unknown]:  Sample
都市名または地域名は何ですか。
 [Unknown]:  Sample
都道府県名または州名は何ですか。
 [Unknown]:  Sample
この単位に該当する2文字の国コードは何ですか。
 [Unknown]:  JP
CN=Sample, OU=Sample, O=Sample, L=Sample, ST=Sample, C=JPでよろしいですか。
 [いいえ]:  Y

作成した自己署名証明書が格納されているキーストアをUndertowに設定し、サーバをHTTPS化します。

src/main/resources/application.properties
server.ssl.key-store=classpath:keystore.p12
server.ssl.key-store-password=password

最後に、以下のようなBean定義を追加すればUndertowのHTTP/2対応は完了です。

// ...
import io.undertow.UndertowOptions;
import org.springframework.boot.web.embedded.undertow.UndertowServletWebServerFactory;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
// ...

@SpringBootApplication
public class Spring5WebDemoApplication {
    // ...
    @Bean
    public WebServerFactoryCustomizer<UndertowServletWebServerFactory> webServerFactoryCustomizer() {
        return undertow -> {
            undertow.addBuilderCustomizers(builder -> builder.setServerOption(UndertowOptions.ENABLE_HTTP2, true));
        };
    }
}

Spring Bootを起動してUndertowがHTTP/2化されていることを確認しましょう。

$ ./mvnw spring-boot:run
...
2017-05-18 09:02:40.807  INFO 60554 --- [           main] o.s.b.w.e.u.UndertowServletWebServer     : Undertow started on port(s) 8080 (https)
2017-05-18 09:02:40.813  INFO 60554 --- [           main] c.e.s.Spring5WebDemoApplication          : Started Spring5WebDemoApplication in 2.538 seconds (JVM running for 3.086)
$ curl -s -D - --http2 -k https://localhost:8080/
HTTP/2.0 404
content-type:application/json;charset=UTF-8
date:Thu, 18 May 2017 00:07:16 GMT

{"timestamp":"2017-05-18T00:07:15.967+0000","status":404,"error":"Not Found","message":"Not Found","path":"/"}

Note:
MacのcURLコマンドがHTTP/2対応されていなかったので、以下のコマンドを実行してHTTP/2対応されたcURLをインストールしました。

$ brew reinstall curl -- --with-nghttp2
$ brew link curl --force
$ hash -r

HTTP/2化できていることが確認できたので、Chromeを使用して実際にHTTP/2のServer Pushを行うパスへアクセスしてみます。

spring50-push-builder.png

少しみずらいですが・・・ 「/style.css」のInitiatorが「Push /push-builder」になっていることが確認できます。

Warning:

ただ・・・2回目以降はServer Pushが有効になっていないような動きになっている・・・+サーバログに以下のようなログがでている・・・ので、何かが間違っているのかもしれません :cry:

サーバーログ
2017-05-18 11:02:02.813 ERROR 60871 --- [  XNIO-2 task-9] io.undertow                              : UT005085: Connection io.undertow.server.protocol.http2.Http2ServerConnection@35f2c3a7 for exchange HttpServerExchange{ GET /style.css request {accept=[text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8], accept-language=[en-US,en;q=0.8,ja;q=0.6], cache-control=[max-age=0], :authority=[localhost:8080], accept-encoding=[gzip, deflate, sdch, br], :path=[/style.css], user-agent=[Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.133 Safari/537.36], :scheme=[https], :method=[GET], Referer=[https://localhost:8080/push-builder], upgrade-insecure-requests=[1], Host=[localhost:8080]} response {Last-Modified=[Wed, 17 May 2017 19:36:35 GMT], Cache-Control=[no-store], Content-Length=[37], Content-Type=[text/css], Accept-Ranges=[bytes], Date=[Thu, 18 May 2017 02:02:02 GMT], :status=[200]}} was not closed cleanly, forcibly closing connection

Servlet 3.0のファイルアップロード機能でMaxUploadSizeExceededExceptionがスローされるようになる :thumbsup:

[SPR-9294] : Servlet 3.0でサポートされたファイルアップロード機能のサイズ超過エラーが発生した際に、MaxUploadSizeExceededException(MultipartExceptionのサブ例外)がスローされるようになります。
ただし、エラーメッセージに特定の単語("size""exceed")が含まれているか否かで判定しているため、アプリケーションサーバの実装によっては従来どおりMultipartExceptionがスローされる可能性があるという点は意識しておいた方がよいと思います。

Note:

Spring FrameworkはCommons FileUploadとの連携部品を提供しており、Commons FileUploadでサイズ超過が発生した時はMaxUploadSizeExceededExceptionがスローされていました。

では、実際にサイズ超過エラーを発生させてみましょう。ファイルアップロード画面をつくるのは面倒なので・・・本エントリーではcURLコマンドを使ってファイルをアップロードします。

まず、アップロードファイルを受け取るControllerを作成してSpring Bootを起動します。

package com.example.spring5webdemo;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
public class UploadRestController {
    @PostMapping("/upload")
    String upload(MultipartFile file) {
        return file.getOriginalFilename() + " is uploaded !";
    }
}

つぎに、以下のように1MB以内のファイル(最大サイズより小さいファイル)をアップロードしてみます。

$ pwd
/usr/local/apps/spring5-web-demo
$ curl -s -D - -X POST -F file=@pom.xml http://localhost:8080/upload
HTTP/1.1 100 

HTTP/1.1 200 
Content-Type: text/plain;charset=UTF-8
Content-Length: 21
Date: Tue, 16 May 2017 16:56:48 GMT

pom.xml is uploaded !

こんどは、1MB以上のファイルをアップロードしてみます。 ちなみに・・・1MB以上のファイル(target/spring5-web-demo-0.0.1-SNAPSHOT.jar)は./mvnw packageで作成できます。

$ ./mvnw package
...
$ curl -s -D - -X POST -F file=@target/spring5-web-demo-0.0.1-SNAPSHOT.jar http://localhost:8080/upload
HTTP/1.1 100 

HTTP/1.1 500 
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Tue, 16 May 2017 16:59:21 GMT
Connection: close

{"timestamp":"2017-05-16T16:59:21.255+0000","status":500,"error":"Internal Server Error","message":"Maximum upload size exceeded; nested exception is java.lang.IllegalStateException: org.apache.tomcat.util.http.fileupload.FileUploadBase$SizeLimitExceededException: the request was rejected because its size (14620179) exceeds the configured maximum (10485760)","path":"/upload"}

何やらサイズ超過エラーになりましたが、MaxUploadSizeExceededExceptionが発生したかわからないので、サーバのログの見てみましょう。

サーバログ(コンソール)
...
2017-05-17 01:59:21.246 ERROR 26570 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.web.multipart.MaxUploadSizeExceededException: Maximum upload size exceeded; nested exception is java.lang.IllegalStateException: org.apache.tomcat.util.http.fileupload.FileUploadBase$SizeLimitExceededException: the request was rejected because its size (14620179) exceeds the configured maximum (10485760)] with root cause

org.apache.tomcat.util.http.fileupload.FileUploadBase$SizeLimitExceededException: the request was rejected because its size (14620179) exceeds the configured maximum (10485760)
        at org.apache.tomcat.util.http.fileupload.FileUploadBase$FileItemIteratorImpl.<init>(FileUploadBase.java:811) ~[tomcat-embed-core-8.5.15.jar:8.5.15]
...

ログメッセージをみるとMaxUploadSizeExceededExceptionが発生していそうなので、こんどはMaxUploadSizeExceededExceptionの例外ハンドリンング処理を追加してみます。

package com.example.spring5webdemo;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.multipart.MultipartFile;

@RestController
public class UploadRestController {
    @PostMapping("/upload")
    String upload(MultipartFile file) {
        return file.getOriginalFilename() + " is uploaded !";
    }
    @ExceptionHandler(MaxUploadSizeExceededException.class) // ★★★ 例外ハンドリングの追加
    @ResponseStatus(HttpStatus.PAYLOAD_TOO_LARGE)
    String handleMaxUploadSizeExceededException() {
        return "Upload file size is too large.";
    }
}

さらに・・・マルチパートリクエストの解析をHandlerメソッドの決定後に行う(遅延解析する)ようにします。こうすることで、@ExceptionHandlerメソッドでファイルアップロード関連の例外をハンドリングできるようになります。

src/main/resources/application.properties(マルチパートリクエストの遅延解析を有効にする)
spring.servlet.multipart.resolve-lazily=true

Spring Bootを再起動して再度1MB以上のファイルをアップロードしてみてください。

$ curl -s -D - -X POST -F file=@target/spring5-web-demo-0.0.1-SNAPSHOT.jar http://localhost:8080/upload
HTTP/1.1 100 

HTTP/1.1 413 
Content-Type: text/plain;charset=UTF-8
Content-Length: 30
Date: Tue, 16 May 2017 17:01:38 GMT
Connection: close

Upload file size is too large.

Note:

ちなみに・・・Spring Bootを使うとデフォルトでServlet 3.0のファイルアップロード機能が有効になっており、ファイル単位の最大サイズは1M、リクエスト全体の最大サイズは10Mに制限されています。なお、最大サイズの変更は、application.propertiesで変更することができます。

src/main/resources/application.properties
# ファイル単位の最大サイズ
spring.servlet.multipart.max-file-size=2MB
# リクエストの最大サイズ
spring.servlet.multipart.max-request-size=20MB

統一的なメディアタイプ解決の仕組みがサポートされる :thumbsup:

[SPR-14484][SPR-14908] : 統一的なメディアタイプ解決の仕組み(MediaTypeFactoryクラス)が追加され、JAF(JavaBeans Activation framework)依存のコードが排除されます。新しいクラスの中では、spring-webの中に格納されている/org/springframework/http/mime.types(メディアタイプと拡張子のマッピングファイル)の定義にしたがってメディアタイプを解決します。

たとえば、以下のようなHandlerメソッドを作成して、pom.xmlファイルをアップロードすると・・・

@PostMapping("/upload")
String upload(MultipartFile file) {
    MediaTypeFactory.getMediaTypes(file.getOriginalFilename())
        .forEach(System.out::println);
    return file.getOriginalFilename() + " is uploaded !";
}

コンソールには、以下のメディアタイプが出力されます。

application/xml

さらに、プロジェクト内に/org/springframework/http/mime.typesを作成(コピー)することで、spring-web提供のマッピング定義をカスタマイズすることもできます。

src/main/resources/org/springframework/http/mime.types
...(省略)
text/xml    xml

再度pom.xmlファイルをアップロードすると・・・コンソールには、以下のメディアタイプが出力されます。

application/xml
text/xml

イミュータブルオブジェクトへデータバインディングできる :thumbsup:

[SPR-15199] : イミュータブルオブジェクトへのリクエストパラメータのデータバインディングができるようになります。コンストラクタ引数にパラメータ名の情報が存在する場合(コンパイルオプションに-dまたは-parametersを付与している場合)は、コンストラクタ引数のパラメータ名とリクエストパラメータ名が一致するリクエストパラメータ値がバインドされます。

Note:

コンストラクタが複数ある場合は、従来通りデフォルトコンストラクタが呼び出されます。また、コンストラクタ引数にパラメータ名の情報を残さない場合は、Java Beansから提供されている@java.beans.ConstructorPropertiesを使用してコンストラクタ引数とリクエストパラメータをマッピングすることができます。

では、イミュータブルなクラスを作成し、そのクラスをHandlerメソッドの引数に指定してみます。

package com.example.spring5webdemo;

import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.constraints.NotNull;
import java.time.LocalDate;

@RestController
public class ImmutableObjectRestController {

    @GetMapping("/immutable")
    Query search(@Validated Query query) {
        return query;
    }

    public static class Query {

        @NotNull
        private final String name;
        private final String mail;
        private final String tel;
        private final LocalDate baseDate;

        public Query(String name, String mail, String tel,
                @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate baseDate) {
            this.name = name;
            this.mail = mail;
            this.tel = tel;
            this.baseDate = baseDate;
        }

        public String getName() {
            return name;
        }

        public String getMail() {
            return mail;
        }

        public String getTel() {
            return tel;
        }

        public LocalDate getBaseDate() {
            return baseDate;
        }

    }

}

デフォルトの状態だと、LocalDateをJSONにすると可読性が非常に悪いので・・・それっぽくフォーマットされた値が出力されるようにしておきましょう。

pom.xml
<dependency>
  <groupId>com.fasterxml.jackson.datatype</groupId>
  <artifactId>jackson-datatype-jsr310</artifactId> <!-- JSR-310のクラスのシリアライズ・デシリアライズをサポートするモジュールを追加 -->
</dependency>
src/main/resources/application.properties
spring.jackson.serialization.write-dates-as-timestamps=false

まずは、すべて正常な値を指定してリクエストを送ってみます。

$ curl -s -D - http://localhost:8080/immutable?name=kazuki43zoo\&mail=kazuki43zoo@gmail.com\&tel=09012345678\&baseDate=2017-08-01
HTTP/1.1 200 
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Tue, 16 May 2017 17:02:24 GMT

{"name":"kazuki43zoo","mail":"kazuki43zoo@gmail.com","tel":"09012345678","baseDate":"2017-08-01"}

お〜ちゃんとバインドされてますね。
次は、nameを未指定にしてバリデーションが行われるか確認してみます。

$ curl -s -D - http://localhost:8080/immutable?mail=kazuki43zoo@gmail.com\&tel=09012345678\&baseDate=2017-08-01
HTTP/1.1 400 
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Tue, 16 May 2017 17:04:25 GMT
Connection: close

{"timestamp":"2017-05-16T17:04:25.515+0000","status":400,"error":"Bad Request","errors":[{"codes":["NotNull.query.name","NotNull.name","NotNull.java.lang.String","NotNull"],"arguments":[{"codes":["query.name","name"],"arguments":null,"defaultMessage":"name","code":"name"}],"defaultMessage":"may not be null","objectName":"query","field":"name","rejectedValue":null,"bindingFailure":false,"code":"NotNull"}],"message":"Validation failed for object='query'. Error count: 1","path":"/immutable"}

こちらもちゃんと動いているようです。
最後に、baseDate(LocalDate)に不正な日付を指定して、バインドエラーになるか確認してみます。

$ curl -s -D - http://localhost:8080/immutable?name=kazuki43zoo\&mail=kazuki43zoo@gmail.com\&tel=09012345678\&baseDate=2017-08-32
HTTP/1.1 400 
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Tue, 16 May 2017 17:05:10 GMT
Connection: close

{"timestamp":"2017-05-16T17:05:10.161+0000","status":400,"error":"Bad Request","message":"Failed to convert value of type 'java.lang.String[]' to required type 'java.time.LocalDate'; nested exception is org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.String] to type [@org.springframework.format.annotation.DateTimeFormat java.time.LocalDate] for value '2017-08-32'; nested exception is java.lang.IllegalArgumentException: Parse attempt failed for value [2017-08-32]","path":"/immutable"}

こちらもちゃんと動いているようですね。

Warning:

以下のようにしてバインドエラーおよびバリデーションエラー時のエラー情報をBindingResultとして受け取るスタイルに変更してみたところ・・・バリデーションエラーは受け取ることができましたが、バインドエラー時は例外が発生して受け取ることができませんでした。(これ仕様なのかな!?・・・)

@GetMapping("/immutable")
Query search(@Validated Query query, BindingResult bindingResult) {
  // ...
  return query;
}

仕様なのかバグなのかよくわからなかったので・・・Spring JIRAにIssue(SPR-15542)をあげておきました。

Jackson 2.9がサポートされる :thumbsup:

[SPR-14925] : どうやらJackson 2.9がSpring Framework 5.0上での必須バージョンになる模様です。Jackson 2.9は現時点(2017/5/14)では正式リリースされていませんが、Spring Framework 5.0が正式リリースころにはリリースされる計画のようです。
Spring Frameworkの変更内容をみると・・・Jackson 2.9から、サーバ側の実装(POJOの実装)が問題で発生するエラーの場合はInvalidDefinitionExceptionがスローされるようになるため、その例外が発生した時にクライアントエラー(400 Bad Request)ではなくサーバエラー(500 Internal Server Error)になるように修正したみたいです。

JSR-367 JSON Bindingがサポートされる :thumbsup:

[SPR-14923] : Java EE 8の構成要素であるJSON Bindingがサポートされ、JacksonおよびGSONの代替として使用することができます。

まず、Spring Boot(spring-boot-starter-web)はJacksonに依存しているので、JSON-Bを使う場合はJacksonを依存ライブラリから取り除いて、JSON-B用のライブラリを追加する必要があります。

pom.xml
<dependencies>
  <!-- ... -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
      <exclusion>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId> <!-- Jacksonを除外 -->
      </exclusion>
    </exclusions>
  </dependency>

  <!-- ... -->

  <!-- 本エントリーでは公式の参照実装であるYasson(+JSON-P実装)を追加 -->
  <dependency>
    <groupId>org.eclipse</groupId>
    <artifactId>yasson</artifactId>
    <version>1.0.0-M2</version>
  </dependency>
  <dependency>
    <groupId>org.glassfish</groupId>
    <artifactId>javax.json</artifactId>
    <version>1.1.0-M2</version>
  </dependency>
</dependencies>

<!-- ... -->
<repositories>
  <!-- ... -->
  <!-- yassonのリリースリポジトリを追加(マイルストーンバージョンを取得できるようにするため) -->
  <repository>
    <id>yasson-releases</id>
    <name>Yasson Release repository</name>
    <url>https://repo.eclipse.org/content/repositories/yasson-releases/</url>
  </repository>
</repositories>

次に、Controllerクラスを作成します。Controllerクラスの作成は特別注意する点はありませんが、イミュータブルなクラスへのバインディングはサポートしていない模様です。

package com.example.spring5webdemo;

import java.time.LocalDate;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class JsonbRestController {

    @PostMapping("/jsonb")
    Resource post(@RequestBody Resource resource) {
        return resource;
    }

    public static class Resource {

        private String name;
        private String mail;
        private String tel;
        private LocalDate baseDate;

        public String getName() {
            return name;
        }

        public void setName(String name) {
            this.name = name;
        }

        public String getMail() {
            return mail;
        }

        public void setMail(String mail) {
            this.mail = mail;
        }

        public String getTel() {
            return tel;
        }

        public void setTel(String tel) {
            this.tel = tel;
        }

        public LocalDate getBaseDate() {
            return baseDate;
        }

        public void setBaseDate(LocalDate baseDate) {
            this.baseDate = baseDate;
        }

    }

}

作成したエンドポイントにアクセスすると、リクエストしたJSONがJavaオブジェクトに変換(デシリアライズ)され、そのオブジェクトがJSONに変換(シリアライズ)されたことが確認できます。

$ curl -s -D - -X POST -H "Content-Type:application/json" -d '{"name":"kazuki43zoo","mail":"kazuki43zoo@gmail.com","tel":"09012345678","baseDate":"2017-08-01"}' http://localhost:8080/jsonb
HTTP/1.1 200 
Content-Type: application/json;charset=UTF-8
Content-Length: 97
Date: Tue, 16 May 2017 17:08:40 GMT

{"baseDate":"2017-08-01","mail":"kazuki43zoo@gmail.com","name":"kazuki43zoo","tel":"09012345678"}

JSON-BはHTTPクライアント用のクラスであるRestTemplateでも利用できます。また、Spring Bootから提供されているテスト用のHTTPクライアント(TestRestTemplate)でもJSON-Bを使うことができます。

package com.example.spring5webdemo;

import java.time.LocalDate;

import org.assertj.core.api.Assertions;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.web.client.RestOperations;
import org.springframework.web.client.RestTemplate;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) // ★★★ テスト実行時に空きポートを使ってサーバを起動する
public class JsonbTests {

    @LocalServerPort private int port; // ★★★ サーバの起動ポートがインジェクションされる

    @Autowired TestRestTemplate testRestTemplate; // ★★★ サーバの起動ポートにアクセスするためのHTTPクライアントがインジェクションされる

    @Test
    public void testUsingRestTemplate() {
        LocalDate now = LocalDate.now();

        RestOperations restOperations = new RestTemplate(); // ★★★ デフォルトの状態でJSON-Bとの連携コンポーネントが登録される
        Resource request = new Resource();
        request.setName("kazuki43zoo");
        request.setMail("kazuki43zoo@gmail.com");
        request.setTel("09012345678");
        request.setBaseDate(now);

        Resource response = restOperations.postForObject("http://localhost:" + port + "/jsonb", request, Resource.class);

        Assertions.assertThat(response.getName()).isEqualTo("kazuki43zoo");
        Assertions.assertThat(response.getMail()).isEqualTo("kazuki43zoo@gmail.com");
        Assertions.assertThat(response.getTel()).isEqualTo("09012345678");
        Assertions.assertThat(response.getBaseDate()).isEqualTo(now);

    }

    @Test
    public void testUsingTestRestTemplate() {
        LocalDate now = LocalDate.now();

        Resource request = new Resource();
        request.setName("kazuki43zoo");
        request.setMail("kazuki43zoo@gmail.com");
        request.setTel("09012345678");
        request.setBaseDate(now);

        Resource response = testRestTemplate.postForObject("/jsonb", request, Resource.class); // ★★★ テスト用のHTTPクライアントを使うと、URLの指定はパスからの指定でOK

        Assertions.assertThat(response.getName()).isEqualTo("kazuki43zoo");
        Assertions.assertThat(response.getMail()).isEqualTo("kazuki43zoo@gmail.com");
        Assertions.assertThat(response.getTel()).isEqualTo("09012345678");
        Assertions.assertThat(response.getBaseDate()).isEqualTo(now);

    }

    public static class Resource {

        private String name;
        private String mail;
        private String tel;
        private LocalDate baseDate;

        public String getName() {
            return name;
        }

        public void setName(String name) {
            this.name = name;
        }

        public String getMail() {
            return mail;
        }

        public void setMail(String mail) {
            this.mail = mail;
        }

        public String getTel() {
            return tel;
        }

        public void setTel(String tel) {
            this.tel = tel;
        }

        public LocalDate getBaseDate() {
            return baseDate;
        }

        public void setBaseDate(LocalDate baseDate) {
            this.baseDate = baseDate;
        }

    }

}

Google Protobuf 3.xがサポートされる :thumbsup:

[SPR-13589] : ProtobufHttpMessageConverterにて、Google Protobuf 3.xがサポート対象になります。どうやらデフォルト(com.google.protobuf:protobuf-java)でサポートされるフォーマットが「application/x-protobuf」と「text/plain」だけになり、その他のフォーマット(「application/json」「application/xml」「text/html」)については別途ライブラリ(「com.google.protobuf:protobuf-java-util(公式)」や「com.googlecode.protobuf-java-format:protobuf-java-format(3rdパーティー製)」)の追加が必要になるみたいです。

HandlerメソッドでReactor 3.1とRxJava 1.3/2.1のクラスを返却できる :thumbsup:

[SPR-15365] : Reactive Programing Model(後述するSpring WebFlux)のサポートに関連し、Spring MVCでもReactor 3.1のクラス(Flux, Mono)、RxJava 1.3と2.1のクラス(Observable, Sigle, Flowableなど)をHandlerメソッドの返り値として扱うことができるようになります。Spring MVCは、これらのクラス(以降「Reactiveクラス」とする)が返却された場合は、Spring MVCの非同期処理(Servlet 3.0からサポートされた非同期処理との連携機能)を利用して処理を行う仕掛けになっています。

以前投稿した「Spring MVC(+Spring Boot)上での非同期リクエストを理解する」で紹介した通り、Spring MVCの非同期処理は以下の2つの方式にわかれます。

  • 非同期実行が終了してからHTTPレスポンスを開始する方式
  • 非同期実行の処理中にHTTPレスポンスを開始する方式

どちらの方式が使われるかは、返却するReactiveクラスの種類とメディアタイプの組み合わせによって変わります。具体的には「Spring Frameworkのリファレンス -Async Requests with Reactive Types- 」をご覧ください。

では、実際にReactiveクラスを返却してみましょう。
本エントリーではSpring WebFluxが利用しているReactorのクラスを使うため、まずReactorを依存ライブラリに追加します。

<dependency>
  <groupId>io.projectreactor</groupId>
  <artifactId>reactor-core</artifactId>
  <version>3.1.0.M1</version>
</dependency>

次に、ReactorのMonoFluxを返却するHandlerメソッドを実装します。

package com.example.spring5webdemo;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;

import java.time.Duration;
import java.util.Random;

@RestController
public class ReactiveRestController {

    @GetMapping("/reactor/mono")
    Mono<Resource> getMono() {
        // subscribeされたらidに乱数を設定したResourceを返却する
        // Subscriberの処理が別スレッドになるようにsubscribeOnを呼び出しておく
        //    -> このサンプルコードの処理だと別スレッドで行う必要はありませんが、
        //    -> 実際には「重たい処理=CPUバウンドな処理」を行うことを想定している
        return Mono.fromSupplier(() -> new Resource(new Random().nextInt()))
                .subscribeOn(Schedulers.parallel());
    }

    @GetMapping("/reactor/flux")
    Flux<Resource> getFlux() {
        // subscribeされたらidに0〜4を設定したResourceを0.5秒毎に返却する
        // intervalを使うとSubscriberの処理は別スレッドで行われる
        return Flux.from(Flux.range(0, 5)).zipWith(Flux.interval(Duration.ofMillis(500)))
                .map(tuple -> new Resource(tuple.getT1()));
    }

    static class Resource {
        private final int id;
        private Resource(int id) {
            this.id = id;
        }
        public int getId() {
            return id;
        }
    }

}

最後に、作成したHandlerメソッドにアクセスしてみましょう。

Mono+json
$ curl -s -D - http://localhost:8080/reactor/mono
HTTP/1.1 200 
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Tue, 16 May 2017 17:10:34 GMT

{"id":129024480}
Flux+event-stream
$ curl -s -D - http://localhost:8080/reactor/flux -H Accept:text/event-stream
HTTP/1.1 200 
Content-Type: text/event-stream;charset=UTF-8
Transfer-Encoding: chunked
Date: Tue, 16 May 2017 17:10:55 GMT

data:{"id":0}

data:{"id":1}

data:{"id":2}

data:{"id":3}

data:{"id":4}

Flux+stream+json
$ curl -s -D - http://localhost:8080/reactor/flux -H Accept:application/stream+json
HTTP/1.1 200 
Content-Type: application/stream+json
Transfer-Encoding: chunked
Date: Tue, 16 May 2017 17:11:22 GMT

{"id":0}
{"id":1}
{"id":2}
{"id":3}
{"id":4}

ちなみに・・・Fluxapplication/jsonで返却すると・・・

$ curl -s -D - http://localhost:8080/reactor/flux -H Accept:application/json
HTTP/1.1 200 
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Tue, 16 May 2017 17:11:48 GMT

[{"id":0},{"id":1},{"id":2},{"id":3},{"id":4}]

となり、すべてのデータがSubscriberに渡された後(約2.5秒後)にJSONがレスポンスされます。

ParsingPathMatcherが追加される :thumbsup:

[SPR-14544] : AntPathMatcherの代替として、ParsingPathMatcherが追加されます。Spring MVCではAntPathMatcherがデフォルトで利用されますが、Spring WebFlux関連のコンポーネントはParsingPathMatcherがデフォルトで使われる模様です。
ちゃんと見てないですが・・・ :sweat_smile: AntPathMatcherで表現できていたことはParsingPathMatcherでも表現可能のようで、ParsingPathMatcherの方は新たに「{*変数名}」という表現がサポートされています。

以下のサンプルを例に説明すると・・・パスマッチング仕様としては「/users/**」と同じなのですが、「{*変数名}」を使うと「/**」の部分を変数として扱うことができます。なお、「/users/{*userPaths}.json」のように「{*変数名}」の後に何かしらの文字を指定することはできません。

package com.example.spring5webdemo;

import org.assertj.core.api.Assertions;
import org.junit.Test;
import org.springframework.util.PathMatcher;
import org.springframework.web.util.ParsingPathMatcher;

public class PathMatcherTests {

    @Test
    public void parsingPathMatcher() {
        PathMatcher pathMatcher = new ParsingPathMatcher();
        String pattern = "/users/{*userPaths}";
        {
            String path = "/users/kazuki43zoo/name";
            Assertions.assertThat(pathMatcher.match(pattern, path));
            Assertions.assertThat(pathMatcher.extractUriTemplateVariables(pattern, path))
                .containsEntry("userPaths", "/kazuki43zoo/name");
        }
        {
            String path = "/users/kazuki43zoo";
            Assertions.assertThat(pathMatcher.match(pattern, path));
            Assertions.assertThat(pathMatcher.extractUriTemplateVariables(pattern, path))
                .containsEntry("userPaths", "/kazuki43zoo");
        }
        {
            String path = "/users/";
            Assertions.assertThat(pathMatcher.match(pattern, path));
            Assertions.assertThat(pathMatcher.extractUriTemplateVariables(pattern, path))
                .containsEntry("userPaths", "/");
        }
        {
            String path = "/users";
            Assertions.assertThat(pathMatcher.match(pattern, path));
            Assertions.assertThat(pathMatcher.extractUriTemplateVariables(pattern, path))
                .containsEntry("userPaths", "");
        }
    }

}

ParsingPathMatcherをSpring MVCのリクエストマッチング処理で利用したい場合は、以下のようなBean定義を行うことで実現することができます。

@Bean
public WebMvcConfigurer webMvcConfigurer() {
    return new WebMvcConfigurer() {
        @Override
        public void configurePathMatch(PathMatchConfigurer configurer) {
            // ★★★ ParsingPathMatcherを適用
            configurer.setPathMatcher(new ParsingPathMatcher());
            // ★★★ useSuffixPatternMatchを無効化
            // ★★★ useSuffixPatternMatchが有効になっていると、リクエストマッチング処理時にパターンの末尾に「`.*`」が付与されパターン解析時にエラーが発生してしまう
            configurer.setUseSuffixPatternMatch(false);
        }
    };
}
package com.example.spring5webdemo;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class PathMatcherRestController {

    @GetMapping("/path-matcher/{*paths}") // ★★★ {*変数名}を使用して、変数値をメソッド引数として受け取る
    String get(@PathVariable String paths) {
        return paths;
    }

}
$ curl -s -D - http://localhost:8080/path-matcher/a/b/c
HTTP/1.1 200 
Content-Type: text/plain;charset=UTF-8
Content-Length: 6
Date: Tue, 16 May 2017 19:41:41 GMT

/a/b/c

Note:

ちなみに・・・デフォルトでParsingPathMatcherが適用されるSpring WebFluxで「{*変数名}」を使ったらリクエストマッピング処理でエラーになりました・・・:disappointed_relieved:
{*変数名}」使用時に「useSuffixPatternMatch=false」にしないとダメなのはなんとなくバグ?っぽい気もするので・・・Spring JIRA(SPR-15558)を作成しました。

@ExceptionHandlerメソッドでRedirectAttributesが受け取れる :thumbsup:

[SPR-14651] : @ExceptionHandlerメソッドの引数としてRedirectAttributesを受け取れるようになり、RedirectAttributesを介してリダイレクト先とデータを連携することができるようになります。

Note:

IssueをみるとSpring Framework 4.3にもバックポートされてますね・・・。このIssueのことじゃないのかな・・・

本来なら画面を作成して確認した方がよいのですが・・・画面つくるの面倒なのでcURL使って確認します。

package com.example.spring5webdemo;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

@Controller
public class RedirectAttributesController {

    @GetMapping("/redirect/{id}")
    @ResponseBody
    String getIdWithMessage(@PathVariable int id, @ModelAttribute("message") String message) { // RedirectAttributesに設定した値がメソッドの引数として取得できる
        return id + " : " + message;
    }

    @GetMapping("/redirect")
    String get() {
        throw new MyException("error.", 100);
    }

    @ExceptionHandler
    String handleMyException(MyException e, RedirectAttributes redirectAttributes) {
        redirectAttributes.addFlashAttribute("message", e.getMessage()); // ★★★ Flushスコープにメッセージを設定
        redirectAttributes.addAttribute("id", e.getId()); // ★★★ リダイレクトパスのパス変数「{id}」に埋め込む値を設定
        return "redirect:/redirect/{id}";
    }

    private static class MyException extends RuntimeException {
        private final int id;
        public MyException(String message, int id) {
            super(message);
            this.id = id;
        }
        public int getId() {
            return id;
        }
    }

}
$ curl -s -D - -L http://localhost:8080/redirect
HTTP/1.1 302 
Set-Cookie: JSESSIONID=13743780747DC2A73F038F2E0DECFDDD; Path=/; HttpOnly
Location: http://localhost:8080/redirect/100;jsessionid=13743780747DC2A73F038F2E0DECFDDD
Content-Language: ja-JP
Content-Length: 0
Date: Tue, 16 May 2017 21:46:50 GMT

HTTP/1.1 200 
Content-Type: text/plain;charset=UTF-8
Content-Length: 12
Date: Tue, 16 May 2017 21:46:50 GMT

100 : error.

ResponseStatusExceptionが追加される :thumbsup:

[SPR-14895] : Spring WebFlux向けに作成されたResponseStatusExceptionがSpring MVCでも使えるようになり、ステータスコードとリーズンフレーズを指定して例外を生成・スローすることで、応答するHTTPステータスを決めることができるようになります。

package com.example.spring5webdemo;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

@RestController
public class ResponseStatusExceptionRestController {

    @GetMapping("/response-status")
    void get() {
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "request invalid."); // ★★★ 任意のステータスコードとリーズンフレーズを指定して例外をスロー
    }

}
$ curl -s -D - -L http://localhost:8080/response-status
HTTP/1.1 400 
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Tue, 16 May 2017 22:34:21 GMT
Connection: close

{"timestamp":"2017-05-16T22:34:21.909+0000","status":400,"error":"Bad Request","message":"request invalid.","path":"/response-status"}

ScriptTemplateViewで「国際化」「テンプレートのフラグメント化」に必要なオブジェクトが連携されるようになる :thumbsup:

[SPR-15064] : ScriptTemplateView(JSR-223のスクリプトエンジンを使用したView実装)にて、「メッセージの国際化」および「テンプレートのフラグメント化」を実現するために必要となるオブジェクト(Locale,MessageSourceなど)がテンプレートエンジン側に引き渡されるようになります。
「国際化」や「テンプレートをフラグメント化」するための実装自体はSpringからは提供されません。

本エントリーでは、スクリプトエンジンとしてJava SE提供の「nashorn」を、テンプレートエンジンとして「mustache.js」を使用して「メッセージの国際化」および「テンプレートのフラグメント化」を実現してみます。

まずは、ScriptTemplateViewを使うためのコンフィギュレーションを行います。

pom.xml(mustachejsのインストール)
<dependency>
  <groupId>org.webjars.bower</groupId>
  <artifactId>mustache</artifactId>
  <version>2.3.0</version> <!-- 執筆時点の最新バージョン -->
</dependency>
package com.example.spring5webdemo;

import org.springframework.boot.autoconfigure.web.servlet.WebMvcProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewResolverRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.view.script.ScriptTemplateConfigurer;

import java.util.Optional;

@Configuration
public class ScriptTemplateViewConfig implements WebMvcConfigurer {

    private final WebMvcProperties properties;

    public ScriptTemplateViewConfig(WebMvcProperties properties) {
        this.properties = properties;
    }

    @Override
    public void configureViewResolvers(ViewResolverRegistry registry) {
        WebMvcProperties.View view = properties.getView();
        registry.scriptTemplate()
                .prefix(Optional.ofNullable(view.getPrefix()).orElse("classpath:/templates/"))
                .suffix(Optional.ofNullable(view.getSuffix()).orElse(".html"));
    }

    @Bean
    ScriptTemplateConfigurer ScriptTemplateConfigurer() {
        ScriptTemplateConfigurer configurer = new ScriptTemplateConfigurer();
        configurer.setEngineName("nashorn");
        configurer.setScripts("/META-INF/resources/webjars/mustache/2.3.0/mustache.js", "/META-INF/js/render.js");
        configurer.setRenderFunction("render");
        return configurer;
    }
}

Mustache標準のレンダリング処理をカスタマイズするためのJavaScriptファイルを作成します。
カスタマイズという表現にしましたが、実際はカスタム関数をモデルに追加して、Mustacheのrender関数を呼び出しているだけです。

/main/resources/META-INF/js/render.js
function render(template, model, renderingContext) {
    model["message"] = function () { // ★★★ 「メッセージの国際化」用のカスタム関数を追加
        return function (code, render) {
            var messageSource = renderingContext.applicationContext.getBean("messageSource");
            var locale = renderingContext.locale;
            return render(messageSource.getMessage(code.trim(), null, locale));
        }
    };
    model["include"] = function () { // ★★★ 「テンプレートのフラグメント化」用のカスタム関数を追加
        return function (viewName, render) {
            return render(renderingContext.templateLoader.apply("templates/" + viewName.trim() + ".html"));
        }
    };
    return Mustache.render(template, model, renderingContext);
}

レンダリングメソッドの第3引数にRenderingContextのインスタンスが渡されるので、そこから必要なオブジェクトを取得して処理を行います。

では、テンプレートファイルとControllerを作って実際に動かしてみます。

src/main/resources/templates/script-template/home.html
<html>
<head>
    <title>{{title}}</title>
</head>
<body>

<p>{{body}}</p>

<p>{{#message}}welcome{{/message}}</p> <!-- ★★★ カスタム関数を利用してメッセージを出力 -->

{{#include}}script-template/footer{{/include}} <!-- ★★★ カスタム関数を利用してフラグメントをインクルード -->

</body>
</html>
src/main/resources/templates/script-template/footer.html(フラグメント)
<footer>
    Copyright (c) 2017 kazuki43zoo@com
</footer>
package com.example.spring5webdemo;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

import javax.servlet.http.HttpServletResponse;

@Controller
public class ScriptTemplateController {

    @GetMapping("/script-template")
    String home(Model model, HttpServletResponse response) {

        model.addAttribute("title", "Sample title")
            .addAttribute("body", "Sample body");

        return "script-template/home";
    }

}

あと、メッセージ定義ファイルの作成も必要です。

src/main/resources/messages.properties
welcome=こんにちは!
src/main/resources/messages_en.properties
welcome=Hello!

Spring Bootを起動して、http://localhost:8080/script-template にアクセスすると・・・・

$ curl -s -D - http://localhost:8080/script-template
HTTP/1.1 200 
Content-Type: text/html;charset=UTF-8
Content-Language: ja-JP
Content-Length: 183
Date: Sat, 20 May 2017 01:51:53 GMT

<html>
<head>
    <title>Sample title</title>
</head>
<body>

<p>Sample body</p>

<p>こんにちは!</p>

<footer>
    Copyright (c) 2017 kazuki43zoo.com
</footer>

</body>
</html>

Accept-Languageen(英語)にして再度アクセスすると・・・

$ curl -s -D - -H Accept-Language:en http://localhost:8080/script-template
HTTP/1.1 200 
Content-Type: text/html;charset=UTF-8
Content-Language: en
Content-Length: 171
Date: Sat, 20 May 2017 01:53:46 GMT

<html>
<head>
    <title>Sample title</title>
</head>
<body>

<p>Sample body</p>

<p>Hello!</p>

<footer>
    Copyright (c) 2017 kazuki43zoo.com
</footer>

</body>
</html>

といった感じでレスポンスされ、「メッセージの国際化」「フラグメントのインクルード」が行われたことが確認できます。

まとめ

今回は、WebMVC関連の主な変更点を紹介しました。Spring Framework 5.0からReactive Programing ModelのWebフレームワーク(Spring WebFlux)が追加され、そちらに注目が集まってますが・・・Spring MVCもまだまだ現役です(とりあえず・・・お仕事ではSpring MVCを使うことの方が圧倒的に多いだろうな〜と思うし :sweat_smile:)。
次回は、「Test関連の主な変更点」を紹介する予定です。

12
21
5

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
21