【Day 9】ファイルから RDB に置き換える【じゃんけんアドカレ】

[じゃんけんアドベントカレンダー](https://qiita.com/advent-calendar/2020/janken) の 9 日目です。

- 初回 ... [【Day 1】とりあえず 1 クラスに全部書く【じゃんけんアドカレ】](https://qiita.com/os1ma/items/630e2d7fb5d744bbdf8a)
- 前回 ... [【Day 8】依存の向きを整理【じゃんけんアドカレ】](https://qiita.com/os1ma/items/b5606029e33c203e0ab9)

---

前回は、サービスクラスの課題の 1 つだった「サービスが CSV に保存するクラスに依存している」を解決し、アプリケーションの構成を整理しました。
今回は、**「トランザクションが実現されていない」という課題を解決するために進めていこうと思います**。

## 現状の課題

現状では、じゃんけんとじゃんけん明細のデータを保存する箇所は以下のようなコードになっています。

```java
        // じゃんけんを保存

        val jankenWithId = jankenCsvDao.insert(janken);

        // じゃんけん明細を生成

        val jankenDetail1 = new JankenDetail(null, jankenWithId.getId(), player1.getId(), player1Hand, player1Result);
        val jankenDetail2 = new JankenDetail(null, jankenWithId.getId(), player2.getId(), player2Hand, player2Result);
        val jankenDetails = List.of(jankenDetail1, jankenDetail2);

        // じゃんけん明細を保存

        jankenDetailCsvDao.insertAll(jankenDetails);
```

このコードではトランザクションが実現されていないため、**じゃんけんを保存してからじゃんけん明細を保存するまでの間にエラーが発生した場合に、じゃんけんだけ保存されてじゃんけん明細が保存されないというデータの不整合を起こしてしまいます**。

この不整合が起こらないようにするため、トランザクションを導入したいです。

## RDB を使いたい

現状の保存先は CSV ファイルです。
CSV ファイルであっても、ファイルに書き込むタイミングの工夫などでトランザクションを実現することはできますが、実装は大変です。
ということで、RDB を導入しようと思います。

RDB として、今回は MySQL を使うことにします。
記事が長くなりそうなので、**今回で MySQL の導入まで実施し、次回トランザクションの実現まで進めようと思います**。

## MySQL の導入

MySQL を開発環境で使いたい場合、コンテナを使うことをオススメします。

**プログラミング言語の実行環境は PC に直接インストールした方が便利なことも多いですが、データベースなどはコンテナを使った方がほぼ確実に楽**です。

Docker Compose を使いたいので、以下の YAML ファイルを記述します。

```yaml
version: '3'
services:
  mysql:
    image: mysql:8.0.22
    ports:
      - 3306:3306
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_USER: user
      MYSQL_PASSWORD: password
      MYSQL_DATABASE: janken
    volumes:
      - ${PWD}/mysql/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d
```

Volume の設定により、./mysql/docker-entrypoint-initdb.d/ 以下に**データベースを初期化するための SQL を置いておくと、自動で実行されるようにしました**。

あとは

```bash
docker-compose up -d
```

で MySQL のコンテナが起動します。

## 実装

ひとまず、OR マッパは使わずに実装していきます。

### 依存関係の追加

build.gradle に MySQL コネクタを追加します。

```build.gradle
dependencies {
    :
    implementation 'mysql:mysql-connector-java:8.0.22'
    :
}
```

### DAO の実装

PlayerMySQLDao、JankenMySQLDao、JankenDetailMySQLDao を実装します。

例えば JankenMySQLDao は以下のようになりました。

```java
public class JankenMySQLDao implements JankenDao {

    private static final String SELECT_WHERE_ID_EQUALS_QUERY = "SELECT id, playedAt FROM jankens WHERE id = ?";
    private static final String COUNT_QUERY = "SELECT COUNT(*) FROM jankens";
    private static final String INSERT_COMMAND = "INSERT INTO jankens (playedAt) VALUES (?)";

    @Override
    public Optional<Janken> findById(long id) {
        try (val conn = DriverManager.getConnection(MySQLDaoConfig.MYSQL_URL,
                MySQLDaoConfig.MYSQL_USER, MySQLDaoConfig.MYSQL_PASSWORD);
             val stmt = conn.prepareStatement(SELECT_WHERE_ID_EQUALS_QUERY)) {

            stmt.setLong(1, id);

            try (val rs = stmt.executeQuery()) {
                return resultSet2Jankens(rs).stream().findFirst();
            }

        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public long count() {
        try (val conn = DriverManager.getConnection(MySQLDaoConfig.MYSQL_URL,
                MySQLDaoConfig.MYSQL_USER, MySQLDaoConfig.MYSQL_PASSWORD);
             val stmt = conn.prepareStatement(COUNT_QUERY)) {

            try (val rs = stmt.executeQuery()) {
                rs.next();
                return rs.getLong(1);
            }

        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public Janken insert(Janken janken) {
        try (val conn = DriverManager.getConnection(MySQLDaoConfig.MYSQL_URL,
                MySQLDaoConfig.MYSQL_USER, MySQLDaoConfig.MYSQL_PASSWORD);
             val stmt = conn.prepareStatement(INSERT_COMMAND, Statement.RETURN_GENERATED_KEYS)) {

            stmt.setTimestamp(1, Timestamp.valueOf(janken.getPlayedAt()));

            stmt.executeUpdate();

            try (val rs = stmt.getGeneratedKeys()) {
                rs.next();
                val id = rs.getLong(1);

                return new Janken(id, janken.getPlayedAt());
            }

        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    private List<Janken> resultSet2Jankens(ResultSet rs) throws SQLException {
        val list = new ArrayList<Janken>();
        while (rs.next()) {
            val janken = resultSet2Janken(rs);
            list.add(janken);
        }
        return list;
    }

    private Janken resultSet2Janken(ResultSet rs) throws SQLException {
        val id = rs.getLong(1);
        val playedAt = rs.getTimestamp(2).toLocalDateTime();

        return new Janken(id, playedAt);
    }

}
```

### ServiceLocator への登録修正

**CsvDao から MySQLDao を使うように変更する際は、ServiceLocator に依存関係を登録している箇所を修正するだけ**です。
コードの色々な箇所を直す必要がなく、非常に簡単です。

```java
public class App {

    public static void main(String[] args) {

        // 依存解決の設定

        ServiceLocator.register(JankenController.class, JankenController.class);

        ServiceLocator.register(PlayerService.class, PlayerService.class);
        ServiceLocator.register(JankenService.class, JankenService.class);

        ServiceLocator.register(PlayerDao.class, PlayerMySQLDao.class);
        ServiceLocator.register(JankenDao.class, JankenMySQLDao.class);
        ServiceLocator.register(JankenDetailDao.class, JankenDetailMySQLDao.class);

        // 実行

        ServiceLocator.resolve(JankenController.class).play();

    }

}
```

**JankenService は DAO のインタフェースを知っているだけで実装は知らないので、一切書き換える必要はありません**。

```java
public class JankenService {

    private JankenDao jankenDao = ServiceLocator.resolve(JankenDao.class);
    private JankenDetailDao jankenDetailDao = ServiceLocator.resolve(JankenDetailDao.class);
    :
```

事前にインタフェースを作成しておいたことで、たったこれだけの作業で CSV から MySQL への移行が完了しました。

## 自動テスト時の MySQL の起動

今回の変更により、**自動テストの実行時に MySQL を起動する必要が出てしまいました**。
**自動テストではモックしてもいいのですが、今回は自動テストの際にも MySQL を起動する**ことにします。

### 方針

自動テストの際に MySQL のコンテナを起動するには

- シェルスクリプトで Docker Compose などを使って起動する
- Testcontainers を使う

といった方法があります。

今回は前者の方針で対応しようと思います。
Testcontainers を使う方法については、以前書いた記事「[Testcontainers で Spring Boot + MyBatis のテスト実行中だけ MySQL のコンテナを起動](https://qiita.com/os1ma/items/f8d3df2c61e16a7c8b20)」を参照ください。

### 実装

ということで、ビルド用のシェルスクリプトを変更し、ビルドの前に MySQL のコンテナを起動するようにしました。

```bash
#!/bin/bash

set -o errexit
set -o nounset
set -o pipefail
set -o xtrace

readonly SCRIPT_DIR="$(cd "$(dirname "$0")"; pwd)"
readonly PROJECT_HOME="${SCRIPT_DIR}/.."

readonly JAR=${PROJECT_HOME}"/app/build/libs/app.jar"
readonly DB_SERVICE='mysql'

#
# 指定したサービスの指定したログの出現回数を取得します
#
get_log_count_in_service() {
  local target_service="$1"
  local grep_condition="$2"

  docker-compose logs "${target_service}" |
    grep "${grep_condition}" |
    wc -l
}

#
# MySQL のコンテナが起動完了したかを返します
#
is_mysql_container_ready() {
  local ready_log_count="$(get_log_count_in_service "${DB_SERVICE}" 'mysqld: ready for connections.')"
  [[ ready_log_count -eq 2 ]]
}

#
# 対象のコンテナでエラーが発生したかを返します
#
error_occurred_in_service() {
  local target_service="$1"

  local error_log_count="$(get_log_count_in_service "${target_service}" 'ERROR')"
  [[ error_log_count -gt 0 ]]
}

#
# MySQL のコンテナの起動を待ちます
#
wait_for_mysql_container_starting() {
  while true; do
    if is_mysql_container_ready; then
      set +o xtrace
      echo '=================================='
      echo "${DB_SERVICE} container is ready !"
      echo '=================================='
      set -o xtrace
      break

    elif error_occurred_in_service "${DB_SERVICE}"; then
      set +o xtrace
      echo '==========================================' >&2
      echo "Error occurred in ${DB_SERVICE} container." >&2
      echo '==========================================' >&2
      set -o xtrace
      break

    else
      # 起動中の場合
      echo 'Waiting for container starting...'
      sleep 1
    fi
  done
}


main() {
  cd "${PROJECT_HOME}"

  # クリーンアップ
  docker-compose down

  # MySQL 起動
  docker-compose up -d
  wait_for_mysql_container_starting

  # ビルド
  "${PROJECT_HOME}/gradlew" \
    clean \
    dependencyCheckAnalyze \
    build

  # JAR の状態での実行をテスト
  export DATA_DIR="${PROJECT_HOME}/data"
  echo -e "0\n0" | java -jar "${JAR}"

  # クリーンアップ
  docker-compose down
}

main "$@"
```

コンテナが起動しても MySQL が接続可能になるまで少し時間がかかるので、それを待つような処理を入れています。
コードは少し長いですが、それほど難しいことはしていません。

## 現時点のコード

今回で MySQL を導入しました。
現時点のコードの構成を図示すると、以下のようになっています。

![Day9_クラス図_mysqldao追加.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/216010/e5820f98-8164-2ec5-cf73-ab0e9bce67e0.png)

コードは GitHub の [この時点のコミット](https://github.com/os1ma/JankenEnterpriseEdition/tree/a479beba1332af02905d03d539740ada0913eab8) を参照ください。

## 次回のテーマ

今回 MySQL を導入しましたが、まだトランザクションは実現されていません。
次回、**ローンパターンを使ってトランザクションを実現**しようと思います。

それでは、今回の記事はここまでにします。最後まで読んでくださりありがとうございました。
[じゃんけんアドベントカレンダー](https://qiita.com/advent-calendar/2020/janken) に興味を持ってくださった方は、是非購読お願いします。

## 次回の記事

[【Day 10】トランザクションを実現【じゃんけんアドカレ】](https://qiita.com/os1ma/items/6025f913a433a735bc43)