本ハンズオンで、次の図のような簡易家計簿のAPIサーバーをSpring WebFlux.fnを使って実装します。 あえてSpring BootもDependency Injectionも使わないシンプルなWebアプリとして実装します。
ハンズオンコンテンツ
- はじめに
- 簡易家計簿Moneygerプロジェクトの作成
- YAVIによるValidationの実装
- R2DBCによるデータベースアクセス
- Web UIの追加
- 例外ハンドリングの改善
- 収入APIの実装
- Spring Bootアプリに変換 👈
- GraalVMのSubstrateVMでNative Imageにコンパイル
ここまで、Spring BootやDependency Injectionを敢えて使わず実装してきました。Spring Bootを使わなくても(Small Footprintな)アプリは作れるということを示す目的だったのですが、 Spring Boot Actuatorやこのハンズオンで扱わなかった機能をSpring Bootを使わずに実装していくのは効率的ではありません。 今後、Moneygerを開発し続けていくのであればSpring Boot Wayに乗った方が無難でしょう。
この章ではこれまで作ったSpring Bootアプリに変換します。
目次
pom.xmlの更新
pom.xml
にspring-boot-starter-parent
を設定します。
<description>...</description>
<!-- ここから -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.0.BUILD-SNAPSHOT</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<!-- ここまで -->
<properties>
<!-- ... -->
</properties>
代わりに
<dependenciesManagement>
の次の箇所を削除します。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
また、<properties>
内の<spring-boot.version>
も削除してください。
次に、<dependencies>
内の、
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webflux</artifactId>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
</dependency>
<dependency>
<groupId>io.projectreactor.netty</groupId>
<artifactId>reactor-netty</artifactId>
<exclusions>
<exclusion>
<groupId>io.netty</groupId>
<artifactId>netty-transport-native-epoll</artifactId>
</exclusion>
<exclusion>
<groupId>io.netty</groupId>
<artifactId>netty-transport-native-unix-common</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
を
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
に置換します。
HandlerとRepositoryへアノテーション付与
ExpenditureHandler
およびIncomeHandler
に@Component
アノテーションを付与します。
import org.springframework.stereotype.Component;
// ...
@Component
public class ExpenditureHandler {
// ...
}
R2dbcExpenditureRepository
およびR2dbcIncomeRepository
に@Repository
アノテーションを付与します。
import org.springframework.stereotype.Repository;
// ...
@Repository
public class R2dbcExpenditureRepository implements ExpenditureRepository {
// ...
}
App.javaのSpring Boot Application化およびConfigクラスの作成
App.ava
に全てのConfigurationを定義しましたが、Spring Boot対応に伴い、App.java
は次のコードだけにします。
// ...
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class App {
public static void main(String[] args) throws Exception {
SpringApplication.run(App.class, args);
}
}
Configurationは必要なものだけcom.example.config
パッケージ配下に定義します。
src/main/java/com/example/config/RouteConfig.java
src/main/java/com/example/config/R2dbcConfig.java
の2つのファイルを作成してください。
RouteConfigの作成
RouteConfig
に次の内容を記述してください。
package com.example.config;
import com.example.expenditure.ExpenditureHandler;
import com.example.income.IncomeHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
@Configuration
public class RouteConfig {
@Bean
public RouterFunction<ServerResponse> routes(ExpenditureHandler expenditureHandler, IncomeHandler incomeHandler) {
return expenditureHandler.routes()
.and(incomeHandler.routes());
}
}
収入APIを作成していない場合は
package com.example.config;
import com.example.expenditure.ExpenditureHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
@Configuration
public class RouteConfig {
@Bean
public RouterFunction<ServerResponse> routes(ExpenditureHandler expenditureHandler) {
return expenditureHandler.routes();
}
}
で良いです。
App.staticRoutes()
と同等の機能はSpring Bootより提供されるため削除しました。
またApp.handlerStrategies()
も同じく削除します。
com.example.error.ErrorResponseExceptionHandler
も使わないので削除してください。
R2dbcConfigの作成
R2dbcConfig
に次の内容を記述してください。
package com.example.config;
import io.r2dbc.pool.ConnectionPool;
import io.r2dbc.pool.ConnectionPoolConfiguration;
import io.r2dbc.spi.ConnectionFactories;
import io.r2dbc.spi.ConnectionFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.r2dbc.connectionfactory.R2dbcTransactionManager;
import org.springframework.data.r2dbc.core.DatabaseClient;
import org.springframework.transaction.reactive.TransactionalOperator;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.Optional;
@Configuration
public class R2dbcConfig {
@Bean
public TransactionalOperator transactionalOperator(ConnectionFactory connectionFactory) {
return TransactionalOperator.create(new R2dbcTransactionManager(connectionFactory));
}
@Bean
public DatabaseClient databaseClient(ConnectionFactory connectionFactory) {
final DatabaseClient databaseClient = DatabaseClient.builder()
.connectionFactory(connectionFactory)
.build();
initializeDatabase(connectionFactory.getMetadata().getName(), databaseClient).subscribe();
return databaseClient;
}
@Bean
public ConnectionFactory connectionFactory() {
// postgresql://username:password@hostname:5432/dbname
String databaseUrl = Optional.ofNullable(System.getenv("DATABASE_URL")).orElse("h2:file:///./target/demo?options=DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE");
return ConnectionFactories.get("r2dbc:" + databaseUrl);
}
@Bean
public ConnectionPool connectionPool(ConnectionFactory connectionFactory) {
return new ConnectionPool(ConnectionPoolConfiguration.builder(connectionFactory)
.initialSize(4)
.maxSize(4)
.maxIdleTime(Duration.ofSeconds(3))
.validationQuery("SELECT 1")
.build());
}
public static Mono<Void> initializeDatabase(String name, DatabaseClient databaseClient) {
if ("H2".equals(name)) {
return databaseClient.execute("CREATE TABLE IF NOT EXISTS expenditure (expenditure_id INT PRIMARY KEY AUTO_INCREMENT, expenditure_name VARCHAR(255), unit_price INT NOT NULL, quantity INT NOT NULL, expenditure_date DATE NOT NULL)")
.then()
.then(databaseClient.execute("CREATE TABLE IF NOT EXISTS income (income_id INT PRIMARY KEY AUTO_INCREMENT, income_name VARCHAR(255), amount INT NOT NULL, income_date DATE NOT NULL)")
.then());
} else if ("PostgreSQL".equals(name)) {
return databaseClient.execute("CREATE TABLE IF NOT EXISTS expenditure (expenditure_id SERIAL PRIMARY KEY, expenditure_name VARCHAR(255), unit_price INT NOT NULL, quantity INT NOT NULL, expenditure_date DATE NOT NULL)")
.then()
.then(databaseClient.execute("CREATE TABLE IF NOT EXISTS income (income_id SERIAL PRIMARY KEY, income_name VARCHAR(255), amount INT NOT NULL, income_date DATE NOT NULL)")
.then());
}
return Mono.error(new IllegalStateException(name + " is not supported."));
}
}
収入APIを作成していない場合は
package com.example.config;
import io.r2dbc.pool.ConnectionPool;
import io.r2dbc.pool.ConnectionPoolConfiguration;
import io.r2dbc.spi.ConnectionFactories;
import io.r2dbc.spi.ConnectionFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.r2dbc.connectionfactory.R2dbcTransactionManager;
import org.springframework.data.r2dbc.core.DatabaseClient;
import org.springframework.transaction.reactive.TransactionalOperator;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.Optional;
@Configuration
public class R2dbcConfig {
@Bean
public TransactionalOperator transactionalOperator(ConnectionFactory connectionFactory) {
return TransactionalOperator.create(new R2dbcTransactionManager(connectionFactory));
}
@Bean
public DatabaseClient databaseClient(ConnectionFactory connectionFactory) {
final DatabaseClient databaseClient = DatabaseClient.builder()
.connectionFactory(connectionFactory)
.build();
initializeDatabase(connectionFactory.getMetadata().getName(), databaseClient).subscribe();
return databaseClient;
}
@Bean
public ConnectionFactory connectionFactory() {
// postgresql://username:password@hostname:5432/dbname
String databaseUrl = Optional.ofNullable(System.getenv("DATABASE_URL")).orElse("h2:file:///./target/demo?options=DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE");
return ConnectionFactories.get("r2dbc:" + databaseUrl);
}
@Bean
public ConnectionPool connectionPool(ConnectionFactory connectionFactory) {
return new ConnectionPool(ConnectionPoolConfiguration.builder(connectionFactory)
.initialSize(4)
.maxSize(4)
.maxIdleTime(Duration.ofSeconds(3))
.validationQuery("SELECT 1")
.build());
}
public static Mono<Void> initializeDatabase(String name, DatabaseClient databaseClient) {
if ("H2".equals(name)) {
return databaseClient.execute("CREATE TABLE IF NOT EXISTS expenditure (expenditure_id INT PRIMARY KEY AUTO_INCREMENT, expenditure_name VARCHAR(255), unit_price INT NOT NULL, quantity INT NOT NULL, expenditure_date DATE NOT NULL)")
.then();
} else if ("PostgreSQL".equals(name)) {
return databaseClient.execute("CREATE TABLE IF NOT EXISTS expenditure (expenditure_id SERIAL PRIMARY KEY, expenditure_name VARCHAR(255), unit_price INT NOT NULL, quantity INT NOT NULL, expenditure_date DATE NOT NULL)")
.then();
}
return Mono.error(new IllegalStateException(name + " is not supported."));
}
}
で良いです。
この変更に伴い、
R2dbcExpenditureRepositoryTest.java
およびR2dbcIncomeRepositoryTest.java
の
App.initializeDatabase("H2", this.databaseClient).block();
を
R2dbcConfig.initializeDatabase("H2", this.databaseClient).block();
に変更してください。
なお、Spring Boot R2DBC Starterは現在開発中で、今後はSpring Bootに取り込まれるので、R2dbcConfig
のBean定義は不要になるでしょう。
テストコードの修正
これまでテストコード内でApp.handlerStrategies()
を設定することで、アプリケーション側のWebFlux基盤の設定とテスト側のWebFlux基盤の設定を同一にしていました。
Spring Boot対応に伴い、アプリケーション側のWebFlux基盤はSpring Bootより提供されるため、テスト側もこれに合わせます。
org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest
アノテーションとWebTestClient.bindToApplicationContext
メソッドを使うことでこれを実現します。
ExpenditureHandlerTest
を次のように修正してください。
// ...
// ここから追加
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Primary;
import org.springframework.web.reactive.function.server.RouterFunction;
// ここまで追加
// ...
// ここから追加
@WebFluxTest
@Import(ExpenditureHandler.class)
// ここまで追加
class ExpenditureHandlerTest {
// ここから追加
@Configuration
static class Config {
@Bean
public RouterFunction<?> routes(ExpenditureHandler expenditureHandler) {
return expenditureHandler.routes();
}
@Bean
@Primary
public ExpenditureRepository expenditureRepository() {
return new InMemoryExpenditureRepository();
}
}
@Autowired
private ApplicationContext applicationContext;
// ここまで追加
}
private InMemoryExpenditureRepository expenditureRepository = new InMemoryExpenditureRepository();
はBean定義したものを使うので
@Autowired
private InMemoryExpenditureRepository expenditureRepository;
次のコードは不要なので削除してください。
private ExpenditureHandler expenditureHandler = new Expenditure(this.expenditureRepository);
最後にWebTestClient
の作成方法を変更します。次のコードを
this.testClient = WebTestClient.bindToRouterFunction(this.expenditureHandler.routes())
.handlerStrategies(App.handlerStrategies())
// ...
次の内容に変更してください。
this.testClient = WebTestClient.bindToApplicationContext(this.applicationContext)
// ...
以上で修正は終了です。IncomeHandlerTest
も同じように修正してください。
修正が終われば全てのテストが成功することを確認してください。