IK.AM

@making's tech note


Spring WebFlux.fnハンズオン - 8. Spring Bootアプリに変換


本ハンズオンで、次の図のような簡易家計簿のAPIサーバーをSpring WebFlux.fnを使って実装します。 あえてSpring BootもDependency Injectionも使わないシンプルなWebアプリとして実装します。

ハンズオンコンテンツ

  1. はじめに
  2. 簡易家計簿Moneygerプロジェクトの作成
  3. YAVIによるValidationの実装
  4. R2DBCによるデータベースアクセス
  5. Web UIの追加
  6. 例外ハンドリングの改善
  7. 収入APIの実装
  8. Spring Bootアプリに変換 👈
  9. 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.xmlspring-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も同じように修正してください。

修正が終われば全てのテストが成功することを確認してください。


✒️️ Edit  ⏰ History  🗑 Delete