Spring BootでAPI開発環境構築

# はじめに
この記事ではSpringBootでAPIを開発する際の開発環境構築を行います
以下筆者の実行環境と作成する開発環境です

**実行環境**
OS：Ventura 13.6.1
Java：21.0.3

**作成する開発環境**
- API実行環境
アプリケーションはSpringBootで作成し、ORMにはDoma２、DBはMySQL 8.0を使います
またアプリケーション・DBコンテナをDockerで作成します
- テスト実行環境
ユニットテストはJUnit・インテグレーションテストはREST ASSUREDを利用し検証します
またテスト実行にはTestcontainer、テストデータはDatabase Riderを利用します

**作成後にできること**
[http://localhost:8080](http://localhost:8080)へアクセスすると、DBから`Hello World`というメッセージを取得できます

# 目次
1. [DockerでAPI実行環境を用意する](#dockerでapi実行環境を用意する)
1. [DBからデータを取得できるようにする](#dbからデータを取得できるようにする)
1. [テストを作成する](#テストを作成する)
1. [まとめ](#まとめ)
1. [参考文献](#参考文献)

# DockerでAPI実行環境を用意する
#### SpringInitializrでアプリケーションを作成します
DependenciesにはLombok・MySQL Driver・Testcontainers・Spring Webを追加し、テンプレートを作成します
他ライブラリはSpringInitializrでは追加できないので、適宜追加していきます

#### Docker環境を作成します

**Dockerfileを作成**
アプリケーションコードをビルド後にdockerを実行するので、ビルドしたjarファイルをコピー・実行しています
```Dockerfile:Dockerfile
FROM openjdk:21

RUN mkdir /api
WORKDIR /api
COPY ./gradlew /api
COPY ./build.gradle /api
COPY ./settings.gradle /api
COPY ./src /api/src
COPY ./gradle /api/gradle
COPY ./build/libs/spring-project-template-0.0.1-SNAPSHOT.jar /api/build/libs/spring-project-template-0.0.1-SNAPSHOT.jar
CMD java -jar build/libs/spring-project-template-0.0.1-SNAPSHOT.jar
```
**docker-compose.ymlを作成**
networksにてコンテナ同士を接続しています
MySQL環境作成時に`docker-entrypoint-initdb.d/init.sql`が実行されるので、初期データとして`migration/init.sql`を作成し、init.sqlをマウントしています

```yaml:docker-compose.yml
services:
  api:
    build: ./
    container_name: api
    ports:
      - "8080:8080"
    environment:
      - DATASOURCE_URL=jdbc:mysql://db:3306/spring_project_template
      - DATASOURCE_USER=user
      - DATASOURCE_PASSWORD=password
    depends_on:
      - db
    networks:
      - app-net
  db:
    image: mysql:8.0
    volumes:
      - ./migration/init.sql:/docker-entrypoint-initdb.d/init.sql
    ports:
      - "3306:3306"
    environment:
      MYSQL_DATABASE: spring_project_template
      MYSQL_ROOT_PASSWORD: root
      MYSQL_USER: user
      MYSQL_PASSWORD: password
      TZ: Asia/Tokyo
    networks:
      - app-net
networks:
  app-net:
    driver: bridge
```
**DB初期化のmigrationファイルを作成**
MESSAGESスキーマの作成、初期データをinsertしています
```sql:migration/init.sql
SET CHARSET UTF8;
create table MESSAGES
(
    id         int unsigned AUTO_INCREMENT PRIMARY KEY,
    text    varchar(100) not null
);

insert into MESSAGES (id, text) values (1, 'HelloWorld');
```

# DBからデータを取得できるようにする

**ライブラリを導入**
pluginsで記載している`org.domaframework.doma.compile"`を導入しないと、ビルド時にSQLファイル置換エラーが出力されてしまいます

```gradle:build.gradle
plugins {
	id "org.domaframework.doma.compile" version "2.0.0"
}

dependencies {
	implementation 'org.seasar.doma:doma-core:2.60.0'
	implementation 'org.seasar.doma.boot:doma-spring-boot-starter:1.8.0'
	annotationProcessor 'org.seasar.doma:doma-processor:2.60.0'
}
```

**propertiesを設定**
環境によってDBの接続URL・username・passwordを変更したいので、環境変数から取得できるようにします
開発環境では`docker-compose.yml`で指定されています
こうすることでCDKでデプロイした際に、環境変数を設定するだけでDBの接続情報を変更できます
```properties:application.properties
spring.application.name=spring-project-template
spring.datasource.url=${DATASOURCE_URL}
spring.datasource.username=${DATASOURCE_USER}
spring.datasource.password=${DATASOURCE_PASSWORD}
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
server.servlet.encoding.charset=UTF-8
```
**Entityを作成**
MESSAGESテーブルの定義に従いEntityを作成します
@Columnでnameを指定することで、アプリケーション側で扱う名前とDB側のカラム名を別で扱うことができます

詳しくは[公式ドキュメント:エンティティクラス](https://doma.readthedocs.io/en/2.19.2/entity/)をご覧ください

```java:Message.java
@Getter
@Entity(immutable = true)
@AllArgsConstructor
@EqualsAndHashCode
public class Message {
    @Id
    private final long id;
    @Column(name = "text")
    private final String text;
}
```

**Daoの作成**
@Selectを追加することで、`src/main/resources/META-INF/プロジェクト名/dao/Dao名/メソッド名.sql`
のSQLファイルを実行することができます
また、SQLファイル内で`/* */`を囲むことで、Daoで受け取った引数をSQLファイルに渡すことが可能です

詳しくは[公式ドキュメント：SQL](https://doma.readthedocs.io/en/2.19.2/sql/)をご覧ください

```java:MessageDao.java
@Dao
@ConfigAutowireable
public interface MessageDao {
    @Select
    Message selectMessage(int id);
}
```
```sql:selectMessage.sql
select * from MESSAGES where id = /* id */1;
```

**Controllerの作成**
今回はDBからの値を取得できれば良いので、Daoからの値を直接返しています
実際のプロダクトの場合だとServiceやRepositoryが間に経由しますが、簡略化のため省略しています

```java:HelloWorldController.java
@RestController
@RequestMapping("/")
public class HelloWorldController {
    @Resource
    MessageDao messageDao;

    @RequestMapping
    public String getData() {
        return messageDao.selectMessage(1).getText();
    }
}
```

## テストを作成する
**ライブラリを導入**
初期データの用意やDBの期待値を確認するDatabase Riderですが、JUnit4用で作られています
今回は最新版のJUnit5を利用したいので、拡張された`rider-junit5`を導入します

```gradle:build.gradle
dependencies {
	testImplementation "com.github.database-rider:rider-junit5:1.44.0"
	testImplementation 'io.rest-assured:rest-assured'
}
```
**Testcontainersの設定**
Daoのテスト・ControllerのインテグレーションテストのためにDBを用意する必要があります
しかし、わざわざテスト用のDBを用意するのは手間なので、動的にDBコンテナを立ち上げテストをできるようにTestcontainersを設定します

Testcontainersの設定はDaoのテスト・Controllerのテスト両方で使いたいので、TestBaseクラスを作成し、テストはそれを継承して使います

基本的に[公式ドキュメント](https://testcontainers.com/guides/getting-started-with-testcontainers-for-java/?_gl=1*12x44gh*_up*MQ..*_ga*MTEzMDgwNTE0LjE3Mjc0MjU0OTM.*_ga_22TYV8FBPD*MTcyNzQyNTQ5MS4xLjAuMTcyNzQyNTQ5MS4wLjAuMA..)に沿って作成しています

気を付ける点として`registry.add("spring.sql.init.mode", () -> "always");`を設定しています
この設定がないと、DBのスキーマを初期化してくれないので初期データの投入でエラーになってしまいます

```java:TestBase.java

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
@DBRider
public class TestBase {
    @LocalServerPort
    private Integer port;

    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.33")
            .withDatabaseName("spring_project_template")
            .withUsername("user")
            .withPassword("password");

    @BeforeAll
    static void beforeAll() {
        mysql.start();
    }

    @AfterAll
    static void afterAll() {
        mysql.stop();
    }

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mysql::getJdbcUrl);
        registry.add("spring.datasource.username", mysql::getUsername);
        registry.add("spring.datasource.password", mysql::getPassword);
        registry.add("spring.sql.init.mode", () -> "always");
    }

    @BeforeEach
    void setUp() {
        RestAssured.baseURI = "http://localhost:" + port;
    }
}
```
**スキーマの定義ファイルを作成**
Testcontainersはデフォルトで`main/resources/schema.sql`を読みにいってくれるので、スキーマ定義ファイルを作成します

```sql:src/main/resources/schema.sql
SET CHARSET UTF8;
create table MESSAGES
(
    id   int unsigned AUTO_INCREMENT PRIMARY KEY,
    text varchar(100) not null
);
```
**Controllerのテストを作成**
@Datasetを使って初期データを用意します
valueは`test/resources`以下からのパスを記述します

実行の検証にはREST Assuredを利用しています
`get`の引数に確認したいAPIのパスを指定し、`then`以下で返り値の値を検証します

詳しくは[公式ドキュメント](https://rest-assured.io/)を確認してください
```java:HelloWorldControllerTest.java
class HelloWorldControllerTest extends TestBase {
    @Test
    @DataSet(value = "controller/HelloWorldControllerTest/case1.given.yaml")
    void case1() throws Exception {
        given()
                .contentType(ContentType.JSON)
                .when()
                .get("/")
                .then()
                .statusCode(200);
    }
}
```
```yaml:test/resources/controller/HelloWorldControllerTest/case1.given.yaml
MESSAGES:
  - id: 1
    text: "Hello World"
  - id: 2
    text: "Goodbye World"
```

**Daoテストを作成**
Controllerのテストと同様に、@Datasetを使い初期データをセットします
検証にはJUnitのassertを利用しています

```java:MessageDaoTest.java
class MessageDaoTest extends TestBase {
    @Autowired
    private MessageDao messageDao;

    @Test
    @DataSet(value = "dao/MessageDaoTest/selectMessage.given.yaml")
    void selectMessage() {
        Message expected = new Message(1, "Hello World");

        Message actual = messageDao.selectMessage(1);
        assertEquals(expected, actual);
    }
}
```
```yaml:test/resources/dao/MessageDaoTest/selectMessage.given.yaml
MESSAGES:
  - id: 1
    text: "Hello World"
  - id: 2
    text: "Goodbye World"
```

# まとめ
今回はSpringBootでAPIを作成するときの開発環境構築を行いました
テスト環境を含めると大変でしたが、1から構築したためすごく勉強になりました

まだテストの同時実行ができず、ビルドする際にテストが落ちてしまう問題があるため、Testcontainersの設定をもう少し詰めたいと思います

# 参考文献
- [Rest Assured 公式ドキュメント](https://rest-assured.io/)
- [Testcontainers 公式ドキュメント](https://testcontainers.com/?_gl=1*1qmdc9d*_up*MQ..*_ga*Njk5MDczNDIyLjE3Mjc0MjcwNDE.*_ga_22TYV8FBPD*MTcyNzQyNzAzOC4xLjAuMTcyNzQyNzAzOC4wLjAuMA..)
- [Doma 公式ドキュメント](https://doma.readthedocs.io/en/2.19.2/getting-started/)