Spring WebFluxを使ったプログラミングスタイルには、Spring MVCと同じAnnotated ControllersとFunctional Endpointsが
ありますが、Annotated Controllersの方しか試していなかったので、そろそろFunctional Endpointsも試してみようかと。
Spring WebFlux / Annotated Controllers
Functional Endpoints?
Functional Endpointsの冒頭部分とOverviewを読むと、こういうことみたいです。
- 軽量なFuncationalプログラミングモデル
- ルーティングとリクエストのハンドリングを行う、契約に基づいたイミュータブルなデザイン
- Annotated Controllersと同じ基盤上で実行される
- ServerRequestを受け取り、Mono
を返すHandlerFunctionでHTTPリクエストを扱う - HTTPリクエストおよびレスポンスに対して、JDK 8フレンドリーでイミュータブルなアクセス方法を提供する
- HandlerFunctionは、RouterFunctionによりマッピングされ、これは@RequestMappingアノテーションと同じ
- RouterFunctions#routeは、多数のビルトインされたデフォルト実装を提供する
要するに、HTTPリクエスト、レスポンスを関数で扱うように実装できます、って話です。それがSpring WebFluxの
Reactiveな基盤(Reactor Netty)上で動きます、と。
Spring Boot側のサンプルコードは、こちら。
https://github.com/spring-projects/spring-boot/tree/master/spring-boot-samples/spring-boot-sample-webflux
まあ、あとはコードを書いていった方が早いと思いますので、さっさと移りましょう。
環境
実行環境は、こちら。
$ java -version openjdk version "1.8.0_171" OpenJDK Runtime Environment (build 1.8.0_171-8u171-b11-0ubuntu0.18.04.1-b11) OpenJDK 64-Bit Server VM (build 25.171-b11, mixed mode) $ mvn -version Apache Maven 3.5.4 (1edded0938998edf8bf061f1ceb3cfdeccf443fe; 2018-06-18T03:33:14+09:00) Maven home: $HOME/.sdkman/candidates/maven/current Java version: 1.8.0_171, vendor: Oracle Corporation, runtime: /usr/lib/jvm/java-8-openjdk-amd64/jre Default locale: ja_JP, platform encoding: UTF-8 OS name: "linux", version: "4.15.0-30-generic", arch: "amd64", family: "unix"
準備
Mavenの定義は、こちら。
<properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <java.version>1.8</java.version> <spring-boot.version>2.0.4.RELEASE</spring-boot.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>${spring-boot.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> <exclusions> <exclusion> <groupId>junit</groupId> <artifactId>junit</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-api</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <version>${spring-boot.version}</version> <executions> <execution> <goals> <goal>repackage</goal> </goals> </execution> </executions> </plugin> <plugin> <artifactId>maven-surefire-plugin</artifactId> <version>2.19.1</version> <dependencies> <dependency> <groupId>org.junit.platform</groupId> <artifactId>junit-platform-surefire-provider</artifactId> <version>1.1.1</version> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-engine</artifactId> <version>5.1.1</version> </dependency> </dependencies> </plugin> </plugins> </build>
Spring WebFlux+Spring Test、テストコードはJUnit 5で動かします。
プログラムの作成
先に書いた通り、Functional Endpointsのスタイルでは、ルーティングの定義と、HTTPリクエスト/レスポンスを扱う
関数の定義が必要です。
ルーティングは、こんな感じで定義していきます。
@Bean RouterFunction<ServerResponse> routes(BookHandler bookHandler) { return RouterFunctions .route(RequestPredicates.GET("/"), request -> ServerResponse.ok().body(BodyInserters.fromObject("Hello Functional Endpoint!!"))) .andRoute(RequestPredicates.PUT("/book/{isbn}"), bookHandler::register) .andRoute(RequestPredicates.DELETE("/book/{isbn}"), bookHandler::delete) .andRoute(RequestPredicates.GET("/book/{isbn}"), bookHandler::find) .andRoute(RequestPredicates.GET("/book"), bookHandler::findAll); }
RouterFunctions#routeを起点にして、RouterFunction#andRouteなど定義を追加しつつ、どのHTTPメソッドとパスに対して、
どういう関数を割り当てるかを書いていきます。
ここで割り当てる関数は、ServerRequestを受け取り、Mono
以下のようにLambda式で書いてもOKです。
return RouterFunctions .route(RequestPredicates.GET("/"), request -> ServerResponse.ok().body(BodyInserters.fromObject("Hello Functional Endpoint!!")))
このサンプルは、このあとで全体のソースコードを書いていくので、そちらでまた…。
データとなるクラス
今回は、書籍データを受け取ったり返したりする、REST APIを書いていくことにします。
そのデータを表すクラスは、こちら。
src/main/java/org/littlewings/spring/functional/Book.java
package org.littlewings.spring.functional; import java.util.Objects; public class Book { String isbn; String title; int price; public static Book create(String isbn, String title, int price) { Book b = new Book(); b.setIsbn(isbn); b.setTitle(title); b.setPrice(price); return b; } /** getter/setterは省略 **/ /** equals/hashCodeも省略 **/ }
Repositoryを書く
このデータを扱う、Repositoryを用意します。Spring Data〜ななにかを使おうかとも思ったのですが、今回は簡単にMapで済ませることにしました。
src/main/java/org/littlewings/spring/functional/BookRepository.java
package org.littlewings.spring.functional; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import javax.annotation.PostConstruct; import org.springframework.stereotype.Repository; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @Repository public class BookRepository { Map<String, Book> books = Collections.synchronizedMap(new LinkedHashMap<>()); @PostConstruct void init() { List<Book> seedData = Arrays.asList( Book.create("978-4798142470", "Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発", 4320), Book.create("978-4777519699", "はじめてのSpring Boot―スプリング・フレームワークで簡単Javaアプリ開発", 2700) ); seedData.forEach(b -> books.put(b.getIsbn(), b)); } public Mono<Book> register(Book book) { books.put(book.getIsbn(), book); return Mono.just(book); } public Mono<Book> delete(String isbn) { return Mono.just(books.remove(isbn)); } public Mono<Book> findByIsbn(String isbn) { return Mono.justOrEmpty(books.get(isbn)); } public Flux<Book> findAll() { return Flux.fromIterable(books.values()); } }
データは、インスタンス構築時に持たせるスタイル。各メソッドの戻り値は、いずれもMono/Fluxとしてあります。
HandlerFunction
では、HandlerFunctionに相当するクラスを書いていきましょう。
※先にも書きましたが、簡単に済ませる場合はクラスを定義せずLambda式で済ませてもOKです
まず、雛形的には以下のように。
src/main/java/org/littlewings/spring/functional/BookHandler.java
package org.littlewings.spring.functional; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @Component public class BookHandler { BookRepository bookRepository; BookHandler(BookRepository bookRepository) { this.bookRepository = bookRepository; } /** あとで **/ }
@Componentアノテーションを付与してクラスを宣言し、今回はDIの確認も兼ねたかったので先ほど作成したRepositoryをコンストラクタインジェクションするように
しています。
で、あとはCRUD的なメソッドを書いていきましょう。
登録、削除。
public Mono<ServerResponse> register(ServerRequest request) { Mono<Book> book = request.bodyToMono(Book.class); return book .flatMap(bookRepository::register) .flatMap(b -> ServerResponse .created(request.uriBuilder().build()) .contentType(MediaType.APPLICATION_JSON) .body(BodyInserters.fromObject(b))); } public Mono<ServerResponse> delete(ServerRequest request) { String isbn = request.pathVariable("isbn"); return bookRepository .delete(isbn) .flatMap(b -> ServerResponse.noContent().build()); }
いずれも、ServerRequestを引数に取り、Mono
プログラミングをしていくことになります。
リクエストの内容は、ServerRequestから取得します。POJOへの変換も、行えるようです。
Mono<Book> book = request.bodyToMono(Book.class); String isbn = request.pathVariable("isbn");
レスポンスは、ServerResponseを使って組み立てます。HTTPボディの内容は、BodyInsertersを使って作ると良いみたいです。
return book .flatMap(bookRepository::register) .flatMap(b -> ServerResponse .created(request.uriBuilder().build()) .contentType(MediaType.APPLICATION_JSON) .body(BodyInserters.fromObject(b)));
1件取得や、全件取得の例は、こちら。
public Mono<ServerResponse> find(ServerRequest request) { String isbn = request.pathVariable("isbn"); return bookRepository .findByIsbn(isbn) .flatMap(b -> ServerResponse .ok() .contentType(MediaType.APPLICATION_JSON) .body(BodyInserters.fromObject(b))) .switchIfEmpty(ServerResponse.notFound().build()); } public Mono<ServerResponse> findAll(ServerRequest request) { Flux<Book> books = bookRepository.findAll(); return ServerResponse .ok() .contentType(MediaType.APPLICATION_JSON) .body(BodyInserters.fromPublisher(books, Book.class)); }
起動クラス(+RouterFunction)
では、最後に起動クラス。このクラスで、ルーティングの設定も行います。
src/main/java/org/littlewings/spring/functional/App.java
package org.littlewings.spring.functional; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.server.RequestPredicates; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerResponse; @SpringBootApplication public class App { public static void main(String... args) { SpringApplication.run(App.class, args); } @Bean RouterFunction<ServerResponse> routes(BookHandler bookHandler) { return RouterFunctions .route(RequestPredicates.GET("/"), request -> ServerResponse.ok().body(BodyInserters.fromObject("Hello Functional Endpoint!!"))) .andRoute(RequestPredicates.PUT("/book/{isbn}"), bookHandler::register) .andRoute(RequestPredicates.DELETE("/book/{isbn}"), bookHandler::delete) .andRoute(RequestPredicates.GET("/book/{isbn}"), bookHandler::find) .andRoute(RequestPredicates.GET("/book"), bookHandler::findAll); } }
ルーティングの定義は、RouterFunctionをBean定義することで行います。
@Bean RouterFunction<ServerResponse> routes(BookHandler bookHandler) { return RouterFunctions .route(RequestPredicates.GET("/"), request -> ServerResponse.ok().body(BodyInserters.fromObject("Hello Functional Endpoint!!"))) .andRoute(RequestPredicates.PUT("/book/{isbn}"), bookHandler::register) .andRoute(RequestPredicates.DELETE("/book/{isbn}"), bookHandler::delete) .andRoute(RequestPredicates.GET("/book/{isbn}"), bookHandler::find) .andRoute(RequestPredicates.GET("/book"), bookHandler::findAll); }
今回は、Hello World的なものを登録しつつ、HandlerFunctionで定義したメソッドをルーティングに紐付けるので作成したHandlerFunctionを
受け取りつつ、登録していきます。
RouterFunctionを複数Bean定義して、合成することもできそうな感じですね。
こんな感じですね。
@Bean RouterFunction<ServerResponse> helloRoutes() { return RouterFunctions .route(RequestPredicates.GET("/"), request -> ServerResponse.ok().body(BodyInserters.fromObject("Hello Functional Endpoint!!"))); } @Bean RouterFunction<ServerResponse> bookRoutes(BookHandler bookHandler) { return RouterFunctions .route(RequestPredicates.PUT("/book/{isbn}"), bookHandler::register) .andRoute(RequestPredicates.DELETE("/book/{isbn}"), bookHandler::delete) .andRoute(RequestPredicates.GET("/book/{isbn}"), bookHandler::find) .andRoute(RequestPredicates.GET("/book"), bookHandler::findAll); }
HandlerFunctionの定義数が多い場合は、こういう感じで分割していくのでしょうかね。
設定
設定は、少しだけ。JSON出力時のインデントだけ、変えておきます。
src/main/resources/application.properties
spring.jackson.serialization.indent_output=true
確認
では、アプリケーションを起動して動作確認してみましょう。
$ mvn spring-boot:run
Nettyが起動します。
2018-08-11 23:19:05.949 INFO 18630 --- [ main] o.s.b.web.embedded.netty.NettyWebServer : Netty started on port(s): 8080
起動したら、curlで確認していってみます。
$ curl -i localhost:8080/ HTTP/1.1 200 OK Content-Type: text/plain;charset=UTF-8 Content-Length: 27 Hello Functional Endpoint!!
全件取得。
$ curl -i localhost:8080/book HTTP/1.1 200 OK transfer-encoding: chunked Content-Type: application/json [ { "isbn" : "978-4798142470", "title" : "Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発", "price" : 4320 }, { "isbn" : "978-4777519699", "title" : "はじめてのSpring Boot―スプリング・フレームワークで簡単Javaアプリ開発", "price" : 2700 } ]
1件取得。
$ curl -i localhost:8080/book/978-4798142470 HTTP/1.1 200 OK Content-Type: application/json Content-Length: 143 { "isbn" : "978-4798142470", "title" : "Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発", "price" : 4320 }
データ登録。
$ curl -i -XPUT -H 'Content-Type: application/json' localhost:8080/book/978-4774182179 -d '{"isbn": "978-4774182179", "title": "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ", "price": 4104}' HTTP/1.1 201 Created Location: http://localhost:8080/book/978-4774182179 Content-Type: application/json Content-Length: 172 { "isbn" : "978-4774182179", "title" : "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ", "price" : 4104 }
削除。
$ curl -i -XDELETE localhost:8080/book/978-4798142470 HTTP/1.1 204 No Content
確認。
$ curl -i localhost:8080/book/978-4798142470 HTTP/1.1 404 Not Found content-length: 0
OKそうです。
テストを書く
それでは、最後にテストを書いてみましょう。
テストには、WebTestClientを使うことにします。
まず最初に、MockServerを使うパターンで。
src/test/java/org/littlewings/spring/functional/MockTest.java package org.littlewings.spring.functional; import java.util.Arrays; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.server.RouterFunction; import reactor.core.publisher.Mono; @ExtendWith(SpringExtension.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) public class MockTest { WebTestClient client; @Autowired RouterFunction<ServerResponse> routerFunction; @Autowired BookRepository bookRepository; @BeforeEach public void setUp() { client = WebTestClient.bindToRouterFunction(routerFunction).configureClient().build(); bookRepository.books.clear(); bookRepository.init(); } @Test public void gettingStarted() { client .get() .uri("/") .exchange() .expectStatus() .isOk() .expectBody(String.class) .isEqualTo("Hello Functional Endpoint!!"); } @Test public void findAll() { client .get() .uri("/book") .accept(MediaType.APPLICATION_JSON) .exchange() .expectStatus() .isOk() .expectBodyList(Book.class) .isEqualTo(Arrays.asList( Book.create("978-4798142470", "Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発", 4320), Book.create("978-4777519699", "はじめてのSpring Boot―スプリング・フレームワークで簡単Javaアプリ開発", 2700) )); } @Test public void findOne() { client .get() .uri("/book/{isbn}", "978-4798142470") .accept(MediaType.APPLICATION_JSON) .exchange() .expectStatus() .isOk() .expectBody(Book.class) .isEqualTo(Book.create("978-4798142470", "Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発", 4320)); } @Test public void register() { client .put() .uri("/book/{isbn}", "978-4774182179") .accept(MediaType.APPLICATION_JSON) .body(Mono.just(Book.create("978-4774182179", "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ", 4104)), Book.class) .exchange() .expectStatus() .isCreated() .expectHeader() .valueEquals("Location", "/book/978-4774182179"); client .get() .uri("/book/{isbn}", "978-4774182179") .accept(MediaType.APPLICATION_JSON) .exchange() .expectStatus() .isOk() .expectBody(Book.class) .isEqualTo(Book.create("978-4774182179", "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ", 4104)); } @Test public void delete() { client .delete() .uri("/book/{isbn}", "978-4798142470") .exchange() .expectStatus() .isNoContent(); client .get() .uri("/book/{isbn}", "978-4798142470") .accept(MediaType.APPLICATION_JSON) .exchange() .expectStatus() .isNotFound(); } }
コードは、JUnit 5を使っています。また、webEnvironmentはMOCKに設定。
@ExtendWith(SpringExtension.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) public class MockTest {
RouterFunctionを@Autowiredして、WebTestClientにバインドします。
WebTestClient client; @Autowired RouterFunction<ServerResponse> routerFunction; @Autowired BookRepository bookRepository; @BeforeEach public void setUp() { client = WebTestClient.bindToRouterFunction(routerFunction).configureClient().build(); bookRepository.books.clear(); bookRepository.init(); }
あと、しょうもないことですが、テストデータを追加したり削除したりするので、今回はテストごとにRepositoryの持つデータを都度初期化…。
テスト自体は、見た目から類推は難しくないのでは、と。
ちなみに、RouterFunctionのBean定義を複数にした場合は、このままだとRouterFunctionが一意に定まらずにインジェクションが失敗するようになるので、
その場合はこんな感じにすればよい?
@Autowired List<RouterFunction<ServerResponse>> routerFunctions; //RouterFunction<ServerResponse> routerFunction; @Autowired BookRepository bookRepository; @BeforeEach public void setUp() { client = WebTestClient .bindToRouterFunction(routerFunctions.stream().reduce(RouterFunction::and).get()) .configureClient() .build();
一応、これで動きましたが…。
続いて、組み込みのサーバーを使う場合。
src/test/java/org/littlewings/spring/functional/EmbeddedServerTest.java
package org.littlewings.spring.functional; import java.util.Arrays; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.web.server.LocalServerPort; import org.springframework.http.MediaType; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.web.reactive.server.WebTestClient; import reactor.core.publisher.Mono; @ExtendWith(SpringExtension.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class EmbeddedServerTest { @Autowired WebTestClient client; @LocalServerPort int localServerPort; @Autowired BookRepository bookRepository; @BeforeEach public void setUp() { bookRepository.books.clear(); bookRepository.init(); } /** テストコードは、Mockの時とほぼ同じ **/ }
Mockサーバーの時との差は、webEnvironmentをRANDOM_PORTにすることと、WebTestClientをDIしていること。
@ExtendWith(SpringExtension.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class EmbeddedServerTest { @Autowired WebTestClient client; @LocalServerPort int localServerPort;
@LocalServerPortも使えるようになります。
あと、テストコードは1箇所だけ変わっていて、Locationヘッダを返しているところがMockだとこうなのですが
client .put() .uri("/book/{isbn}", "978-4774182179") .accept(MediaType.APPLICATION_JSON) .body(Mono.just(Book.create("978-4774182179", "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ", 4104)), Book.class) .exchange() .expectStatus() .isCreated() .expectHeader() .valueEquals("Location", "/book/978-4774182179");
組み込みサーバーだとこうなります。
client .put() .uri("/book/{isbn}", "978-4774182179") .accept(MediaType.APPLICATION_JSON) .body(Mono.just(Book.create("978-4774182179", "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ", 4104)), Book.class) .exchange() .expectStatus() .isCreated() .expectHeader() // ココだけ違う .valueEquals("Location", "http://localhost:" + localServerPort + "/book/978-4774182179");
URIにホスト名やポートが入ります、と。
まとめ
Spring WebFluxの、Functional Endpointsを試してみました。
慣れてないので、だいぶ手こずりました…。Annotated Controllersとどちらが良いか…ですが、せっかくなので、Functional Endpointsの方に慣れていこうかなぁと。
と、ここまで書いた後にセッションってどうなるか試してないなーとか、View使う場合はどうなるんだろうとかいうことに気づきましたが、まあいいや…。
※興味的に、Viewはやらないと思われる…