5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

じゃんけんAdvent Calendar 2020

Day 7

【Day 7】データアクセスを分離して 3 層にする【じゃんけんアドカレ】

Last updated at Posted at 2020-12-06

じゃんけんアドベントカレンダー の 7 日目です。


前回サービスクラスを抽出したところ、サービスクラスが非常にファットになってしまいました。
そこで今回は、サービスクラスからデータアクセスに関する処理を DAO (Data Access Object) に抽出してみます。

DAO パターンについて

DAO パターンは、Wikipedia の Data Access Object のページで

Data Access Object(DAO)とは、ある種のデータベースや永続性機構の抽象化されたインタフェースを提供するオブジェクトであり、データベースの詳細を開示することなく特定の操作を提供する。

とされており、具体的にどう実装するかは曖昧な点もあるようです。

今回は、「テーブルごとに作成する、データの読み書きを担当するクラス」のようなイメージで作成します。
これは Table Data Gateway と呼ばれるパターンと同じものです。

データアクセスはよく Repository というパターンで実装されますが、テーブルと 1 対 1 という前提で作成するものは Repository パターンではないので、今回は Repository という単語を避けました。1
Repository については後日導入します。

参考

DAO の実装

実際に DAO を実装しました。
以下のように、検索して DTO を受け取ったり、DTO を引数にとって保存したりするクラスにしました。

public class JankenCsvDao {

    private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss");
    private static final String JANKENS_CSV = CsvDaoUtils.DATA_DIR + "jankens.csv";

    public Optional<Janken> findById(long id) {
        try (val stream = Files.lines(Paths.get(JANKENS_CSV), StandardCharsets.UTF_8)) {
            return stream.map(this::line2Janken)
                    .filter(j -> j.getId() == id)
                    .findFirst();
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    public long count() {
        return CsvDaoUtils.countFileLines(JANKENS_CSV);
    }

    @SuppressWarnings("ResultOfMethodCallIgnored")
    public Janken insert(Janken janken) {
        val jankensCsv = new File(JANKENS_CSV);

        try (val fw = new FileWriter(jankensCsv, true);
             val bw = new BufferedWriter(fw);
             val pw = new PrintWriter(bw)) {

            // ファイルが存在しない場合に備えて作成
            jankensCsv.createNewFile();

            val jankenId = CsvDaoUtils.countFileLines(JANKENS_CSV) + 1;
            val jankenWithId = new Janken(jankenId, janken.getPlayedAt());

            val line = janken2Line(jankenWithId);
            pw.println(line);

            return jankenWithId;
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    private Janken line2Janken(String line) {
        val values = line.split(CsvDaoUtils.CSV_DELIMITER);
        val jankenId = Long.valueOf(values[0]);
        val playedAt = LocalDateTime.parse(values[1], DATE_TIME_FORMATTER);

        return new Janken(jankenId, playedAt);
    }

    private String janken2Line(Janken janken) {
        val playedAtStr = DATE_TIME_FORMATTER.format(janken.getPlayedAt());

        return janken.getId() + CsvDaoUtils.CSV_DELIMITER + playedAtStr;
    }

}

※ findById や count といったメソッドは、テストコードから利用しています。

サービスクラスの Before / After

サービスクラスがファットなのが解消されたか、Before / After を見比べてみます。

Before

public class JankenService {
    :
    public Optional<Player> play(Player player1, Hand player1Hand,
                                 Player player2, Hand player2Hand) throws IOException {
        :
        // じゃんけんを保存

        try (val fw = new FileWriter(jankensCsv, true);
             val bw = new BufferedWriter(fw);
             val pw = new PrintWriter(bw)) {

            val formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd hh:mm:ss");
            val playedAtStr = formatter.format(janken.getPlayedAt());
            pw.println(janken.getId() + ServiceConfigurations.CSV_DELIMITER + playedAtStr);
        }
        :
        // じゃんけん明細を保存

        try (val fw = new FileWriter(jankenDetailsCsv, true);
             val bw = new BufferedWriter(fw);
             val pw = new PrintWriter(bw)) {

            writeJankenDetail(pw, jankenDetail1);
            writeJankenDetail(pw, jankenDetail2);
        }
        :
    }

    private static long countFileLines(String path) throws IOException {
        try (val stream = Files.lines(Paths.get(path), StandardCharsets.UTF_8)) {
            return stream.count();
        }
    }

    private static void writeJankenDetail(PrintWriter pw,
                                          JankenDetail jankenDetail) {
        val line = String.join(ServiceConfigurations.CSV_DELIMITER,
                String.valueOf(jankenDetail.getId()),
                String.valueOf(jankenDetail.getJankenId()),
                String.valueOf(jankenDetail.getPlayerId()),
                String.valueOf(jankenDetail.getHand().getValue()),
                String.valueOf(jankenDetail.getResult().getValue()));
        pw.println(line);
    }

}

After

public class JankenService {
    :
    public Optional<Player> play(Player player1, Hand player1Hand,
                                 Player player2, Hand player2Hand) throws IOException {
        :
        // じゃんけんを保存

        val jankenWithId = jankenCsvDao.insert(janken);
        :
        // じゃんけん明細を保存

        jankenDetailCsvDao.insertAll(jankenDetails);
        :
    }

}

データの保存周りが DAO のメソッドを 1 行呼び出すだけになり、非常に単純化されました。

3 層アーキテクチャの完成

データアクセスの抽出により、アプリケーション設計の最も基本パターンである「3 層アーキテクチャ」の要素がそろいました
パッケージ構成をリファクタリングして、3 層アーキテクチャであることを表現するようにします。

$ tree app/src/main/java/com/example/janken/
app/src/main/java/com/example/janken/
├── App.java
├── businesslogic
│   └── service
│       ├── JankenService.java
│       └── PlayerService.java
├── dataaccess
│   ├── csvdao
│   │   ├── CsvDaoUtils.java
│   │   ├── JankenCsvDao.java
│   │   ├── JankenDetailCsvDao.java
│   │   └── PlayerCsvDao.java
│   └── model
│       ├── Hand.java
│       ├── Janken.java
│       ├── JankenDetail.java
│       ├── Player.java
│       └── Result.java
├── framework
│   └── View.java
└── presentation
    └── controller
        └── JankenController.java

8 directories, 14 files

これを図示すると、以下のようになります。

Day7_クラス図_dao追加 (3).png

※ 今さらですが、矢印は依存の向きです。

Model は他の層だと考える方もいらっしゃるかもしれませんが、Model をプレゼンテーション層やビジネスロジック層に移動すると、層と層で相互依存や循環依存が発生してしまいます。
現状の構成のまま相互依存や循環依存を避けるためには、データアクセス層に配置することになります。

サービスクラスの課題点

ここで、サービスクラスのコードの全体像を改めて見てみます。

public class JankenService {

    private JankenCsvDao jankenCsvDao = new JankenCsvDao();
    private JankenDetailCsvDao jankenDetailCsvDao = new JankenDetailCsvDao();

    /**
     * じゃんけんを実行し、勝者を返します。
     */
    public Optional<Player> play(Player player1, Hand player1Hand,
                                 Player player2, Hand player2Hand) {

        // 勝敗判定

        Result player1Result;
        Result player2Result;
        if (player1Hand.equals(Hand.STONE)) {
            // プレイヤーがグーの場合

            if (player2Hand.equals(Hand.STONE)) {
                player1Result = Result.DRAW;
                player2Result = Result.DRAW;
            } else if (player2Hand.equals(Hand.PAPER)) {
                player1Result = Result.LOSE;
                player2Result = Result.WIN;
            } else {
                player1Result = Result.WIN;
                player2Result = Result.LOSE;
            }

        } else if (player1Hand.equals(Hand.PAPER)) {
            // プレイヤーがパーの場合

            if (player2Hand.equals(Hand.STONE)) {
                player1Result = Result.WIN;
                player2Result = Result.LOSE;
            } else if (player2Hand.equals(Hand.PAPER)) {
                player1Result = Result.DRAW;
                player2Result = Result.DRAW;
            } else {
                player1Result = Result.LOSE;
                player2Result = Result.WIN;
            }

        } else {
            // プレイヤーがチョキの場合

            if (player2Hand.equals(Hand.STONE)) {
                player1Result = Result.LOSE;
                player2Result = Result.WIN;
            } else if (player2Hand.equals(Hand.PAPER)) {
                player1Result = Result.WIN;
                player2Result = Result.LOSE;
            } else {
                player1Result = Result.DRAW;
                player2Result = Result.DRAW;
            }
        }

        // じゃんけんを生成

        val playedAt = LocalDateTime.now();
        val janken = new Janken(null, playedAt);

        // じゃんけんを保存

        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);

        // 勝者を返却

        if (player1Result.equals(Result.WIN)) {
            return Optional.of(player1);
        } else if (player2Result.equals(Result.WIN)) {
            return Optional.of(player2);
        } else {
            return Optional.empty();
        }
    }

}

ファットコントローラだったころと比べるとかなり改善しましたが、このコードにはまだまだ課題が多いです。

具体的には、

  • サービスが CSV に保存するクラスに依存している
  • トランザクションが実現されていない
  • まだまだサービスがファット
  • ID の採番問題

といった課題があります。
それぞれ簡単に説明していきます。

サービスが CSV に保存するクラスに依存している

public class JankenService {

    private JankenCsvDao jankenCsvDao = new JankenCsvDao();
    private JankenDetailCsvDao jankenDetailCsvDao = new JankenDetailCsvDao();
    :

現状の JankenService は JankenCsvDao と JankenDetailCsvDao という、データの保存先が CSV である DAO に直接依存しています。
このままでは、もしも保存先をデータベースに変更したくなった場合、JankenService のコードも修正する必要があります。
また、JankenService の自動テストを実行する際に CSV への保存をモックするのも難しいです。

本来的には、サービスのコアであるビジネスロジックが、技術的な詳細であるデータアクセスに依存するべきではありません

トランザクションが実現されていない

        // じゃんけんを保存

        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);

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

この不整合を解決するため、トランザクション処理を導入したいです。

まだまだサービスがファット

今回のリファクタリングにより、JankenService の役割は

  • じゃんけんの勝敗判定
  • じゃんけんして結果保存するという処理の流れの実現

の 2 つだけになりました。

しかし、JankenService はまだ結構ファットなままです。

これはビジネスロジックがいわゆる「トランザクションスクリプトパターン」で実装されていることが原因です。
トランザクションスクリプトパターンでは

  • じゃんけんの勝敗判定
  • じゃんけんして結果保存するという処理の流れの実現

を両方ともサービスクラスが担うことになるため、サービスがファットになりがちです。

一方、「ドメインモデルパターン」を採用すると、モデルが「じゃんけんの勝敗判定」を担うようになるため、サービスクラスの役割は「じゃんけんして結果保存するという処理の流れの実現」だけになります

このリファクタリングも後日実施します。

ビジネスロジックにも種類があることや実装方法がいくつかあることについては、以前書いた記事「「ビジネスロジック」とは何か、どう実装するのか」を参照ください。

ID の採番問題

        // じゃんけんを生成

        val playedAt = LocalDateTime.now();
        val janken = new Janken(null, playedAt);

        // じゃんけんを保存

        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);

実は今回実装した DAO では、データベースの自動採番機能で ID を採番する機能を使う場合に似せるため、DAO の中で ID を採番しています。
そうすると、上記のコードのように new Janken(null, playedAt) のようにして ID が NULL の状態で DTO を作成し、保存後に ID を取得する必要があります。

これでもダメではないのですが、Janken クラス内の id が NULL かどうかを意識しながらコードを書く必要が出てくるため、できれば避けたいです。
この点もゆくゆく解決したいと思います。

次回のテーマ

今回で、よく見る「3 層 + MVC + トランザクションスクリプト」のような実装になりました。

しかし、先ほどあげたように

  • サービスが CSV に保存するクラスに依存している
  • トランザクションが実現されていない
  • まだまだサービスがファット
  • ID の採番問題

といったたくさんの課題があります。
どれから解決していくかは色々なパターンが考えられますが、まずは「サービスが CSV に保存するクラスに依存している」という点から対応していこうと思います

それでは、今回の記事はここまでにします。最後まで読んでくださりありがとうございました。
じゃんけんアドベントカレンダー に興味を持ってくださった方は、是非購読お願いします。

次回の記事

【Day 8】依存の向きを整理【じゃんけんアドカレ】

現時点のコード

コードは GitHub の この時点のコミット を参照ください。

  1. Repository が結果的にテーブルと 1 対 1 になることはあります

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?