3
0

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 16

【Day 16】プレゼンテーション層だけ書き換えて Web アプリに【じゃんけんアドカレ】

Last updated at Posted at 2020-12-16

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


前回、Heroku へのデプロイをセットアップしました。
ついに Web アプリ (API) の実装に取り組むことができます。

事前整理

API を実装する前に、コマンドライン関係のクラスを整理しておこうと思います。

プレゼンテーション層の現状のディレクトリ構成は以下のようになっています。

$ tree app/src/main/java/
app/src/main/java/
└── com
    └── example
        └── janken
            ├── App.java
            :
            ├── presentation
            │   ├── controller
            │   │   └── JankenController.java
            │   └── view
            │       └── View.java
            :

presentation 以下に CLI アプリケーションと API の両方を配置するので、presencation.cli と presentation.api の 2 つに分けます

tree app/src/main/java/
app/src/main/java/
└── com
    └── example
        └── janken
            ├── App.java
            :
            ├── presentation
            │   ├── api
            │   └── cli
            │       ├── controller
            │       │   └── JankenController.java
            │       └── view
            │           └── View.java
            :

これに加えて、CLI アプリケーションの main クラスを App から JankenCLIApplication にリネームしました。

実装

API 設計

それでは、API を実装するにあたり、まずはどんな API にするか決めます。

今回は、今までの CLI アプリケーションと同じような、2 人分の手を選択して POST すると「勝敗判定して、結果を保存し、勝者を返却する」という API にします。

リクエストボディは以下のような JSON にしようと思います。

{
    "player1Id": "1",
    "player1Hand": 1,
    "player2Id": "2",
    "player2Hand": 0
}

※ 実際のアプリケーションでは playerId は詐称できないように認証情報から取り出したり、playerId と hand の組を任意長で受け取れるような設計にしたりすると思います。

レスポンスボディは以下のようにします。

{
    "winnerPlayerName": "Alice"
}

winnerPlayerName はアイコの場合は NULL とします。

API 実装

この API を Servlet と Gson で実装すると、以下のようになりました。

@WebServlet("/api/v1/jankens")
public class JankenAPIController extends HttpServlet {

    private JankenApplicationService service = ServiceLocator.resolve(JankenApplicationService.class);

    private Gson gson = new Gson();

    @Override
    protected void doPost(HttpServletRequest request,
                          HttpServletResponse response) throws ServletException, IOException {

        // リクエストボディの読み込み
        val reader = request.getReader();
        val sb = new StringBuilder();
        String line;
        while ((line = reader.readLine()) != null) {
            sb.append(line);
        }
        String requestBodyStr = sb.toString();
        val requestBody = gson.fromJson(requestBodyStr, JankenPostRequestBody.class);

        // ユースケースを実行
        val maybeWinner = service.play(
                requestBody.getPlayer1Id(),
                requestBody.player1Hand(),
                requestBody.getPlayer2Id(),
                requestBody.player2Hand());

        // レスポンスヘッダを設定
        response.setContentType("application/json");

        // レスポンスボディを設定
        val responseBody = JankenPostResponseBody.of(maybeWinner);
        val responseBodyStr = gson.toJson(responseBody);
        val writer = response.getWriter();
        writer.print(responseBodyStr);
        writer.flush();
    }

}

リクエストボディを表すクラスは以下の通りです。

@AllArgsConstructor
@Getter
@EqualsAndHashCode
@ToString
class JankenPostRequestBody {
    private String player1Id;
    private Integer player1Hand;
    private String player2Id;
    private Integer player2Hand;

    Hand player1Hand() {
        return Hand.of(player1Hand);
    }

    Hand player2Hand() {
        return Hand.of(player2Hand);
    }

}

レスポンスボディを表すクラスは以下の通りです。

@AllArgsConstructor
@Getter
@EqualsAndHashCode
@ToString
class JankenPostResponseBody {

    @SuppressWarnings("OptionalUsedAsFieldOrParameterType")
    static JankenPostResponseBody of(Optional<Player> maybeWinner) {
        val winnerPlayerName = maybeWinner.map(Player::getName).orElse(null);
        return new JankenPostResponseBody(winnerPlayerName);
    }

    private String winnerPlayerName;
}

API の設計として「勝敗判定して、結果を保存し、勝者を返却する」というユースケースの流れについては変更しなかったので、ApplicationService 修正は一切不要でした。

なお、プレゼンテーションが切り替わった際に ApplicationService の修正が必要になるかどうかについては、後ほど考えてみようと思います。

リクエストボディ・レスポンスボディ設定処理をユーティリティ化

上記の JankenAPIController の実装のうち、リクエストボディをオブジェクトにマッピングする処理やオブジェクトをレスポンスボディに変換する処理は、他の Controller でも同じコードになってしまうと思います。

それらの処理を APIControllerUtils というクラスに抽出すると、JankenAPIController は以下のようになります。

@WebServlet("/api/v1/jankens")
public class JankenAPIController extends HttpServlet {

    private JankenApplicationService service = ServiceLocator.resolve(JankenApplicationService.class);

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) {

        val requestBody = APIControllerUtils.getRequestBody(request, JankenPostRequestBody.class);

        val maybeWinner = service.play(
                requestBody.getPlayer1Id(),
                requestBody.player1Hand(),
                requestBody.getPlayer2Id(),
                requestBody.player2Hand());

        val responseBody = JankenPostResponseBody.of(maybeWinner);
        APIControllerUtils.setResponseBody(response, responseBody);
    }

}

JankenAPIController の仕事は「リクエストを受け取り、ユースケース (ApplicationService) を実行し、レスポンスを生成して返すこと」だというのが分かりやすくなったと思います。

ServiceLocator への依存登録の変更

ここまでの修正だけでは、ServiceLocator に依存関係が登録されていないため実行時にエラーが発生してしまいます。
そこで、アプリケーション起動時に ServiceLocator が初期化されるようにします。

@WebListener
public class ServiceLocatorInitializationListener implements ServletContextListener {

    public void contextInitialized(ServletContextEvent sce) {

        ServiceLocator.register(TransactionManager.class, JDBCTransactionManager.class);

        ServiceLocator.register(PlayerApplicationService.class, PlayerApplicationService.class);
        ServiceLocator.register(JankenApplicationService.class, JankenApplicationService.class);

        ServiceLocator.register(PlayerRepository.class, PlayerMySQLRepository.class);
        ServiceLocator.register(JankenRepository.class, JankenMySQLRepository.class);

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

    }

}

ここは次回 Spring Framework を導入して DI に切り替えようと思います。

動作確認

これで一通りの実装が完了したので、動作確認してみます。

まず、以下のコマンドでアプリケーションを起動します。

./gradlew clean war
java -jar app/build/server/webapp-runner-*.jar app/build/libs/*.war --port 8080

ここに curl でリクエストを投げると、以下のようになります。

$ curl -X POST -H 'Content-Type: application/json' -d '{"player1Id": "1", "player1Hand": 1, "player2Id": "2", "player2Hand": 0}' localhost:8080/api/v1/jankens
{"winnerPlayerName":"Alice"}

バッチリ動作しているようです。
これで API の実装は完了です。

関連事項の考察

では、API の実装にあたって考えた以下の 3 つについて書いていきます。

  • presentation.api 配下のディレクトリ構成
  • API のレスポンスを「じゃんけん」にするとどうなるか
  • ApplicationService の戻り値はどんな型にするか

presentation.api 配下のディレクトリ構成

まずは presentation.api 配下のディレクトリ構成についてですが、今回は以下のようにしました。

$ tree app/src/main/java/
app/src/main/java/
└── com
    └── example
        └── janken
            :
            ├── presentation
            │   ├── api
            │   │   ├── APIControllerUtils.java
            │   │   ├── health
            │   │   │   ├── HealthAPIController.java
            │   │   │   └── HealthResponseBody.java
            │   │   └── janken
            │   │       ├── JankenAPIController.java
            │   │       ├── JankenPostRequestBody.java
            │   │       └── JankenPostResponseBody.java
            :   :

ここのディレクトリ構成としては、以下の 2 パターンが考えられると思います。

  • presentation.api.controller、presentation.api.request、presentation.api.response のように分割する
  • presentation.api.janken、presentation.api.player のように分割する

私は後者のように、API のまとまりで分割する方が整理しやすいと考えています。

前者のように controller、request、response で分割する場合、API の数が増えると、各ディレクトリ以下にたくさんのクラスが並ぶようになり、見通しが悪くなりやすいです。
また、1 つの API を修正する際に、controller、request、response の全てのパッケージを修正することが多くなります

一方、ある API の Controller、Request、Response のクラスを 1 箇所にまとめておけば、ある API を修正するときはそこだけ見ればよくなります

SOLID 原則の 1 つに「単一責任の原則」というものがあります。

1つのクラスは1つだけの責任を持たなければならない。すなわち、ソフトウェアの仕様の一部分を変更したときには、それにより影響を受ける仕様は、そのクラスの仕様でなければならない。

これは本来クラスの設計に関する考え方ですが、Controller、Request、Response のクラスを同じパッケージにまとめて配置するというのも、近い考え方になります。

API のレスポンスを「じゃんけん」にするとどうなるか

続いて、API のレスポンスを「じゃんけん」にするとどうなるか、ということを考えてみようと思います。

例えばレスポンスボディは以下のようになると考えられます。

{
    "janken": {
        "id": "xxx",
        "playedAt": "2020/12/16 00:00:00"
        "details": [
            {
                "playerId": "1",
                "hand": 1,
                "result": 0,
            },
            {
                "playerId": "2",
                "hand": 0,
                "result": 1,
            }
        ]
    }
}

ここで現状の JankenApplicationService の実装を見直すと、以下のようになっています。

public class JankenApplicationService {
    :
    public Optional<Player> play(String player1Id, Hand player1Hand,
                                 String player2Id, Hand player2Hand) {

        return tm.transactional(tx -> {

            val janken = Janken.play(player1Id, player1Hand, player2Id, player2Hand);

            jankenRepository.save(tx, janken);

            return janken.winnerPlayerId()
                    .map(playerId -> playerRepository.findPlayerById(tx, playerId));
        });

    }

}

JankenApplicationService の play メソッドは勝者だけを返しているため、このメソッドではじゃんけん全体を返す API は実装できません。

そのため、API の設計を「じゃんけん全体を返す」とした場合は、CLI で使っていた ApplicationService のメソッドが流用できなくなります

これは、API が勝者を返す場合とじゃんけん全体を返す場合で、ユースケースの流れが異なるためだと考えています。

API が勝者だけを返す場合、ユースケースの流れは「勝敗判定して、結果を保存し、勝者を返却する」となります。
一方、API がじゃんけん全体を返す場合、ユースケースの流れは「勝敗判定して、結果を保存し、じゃんけん全体を返す」となります。

このユースケースの実現を担当するのが ApplicationService のため、ApplicationService のメソッドが流用できる場合とできない場合が発生してしまうわけです。

このように、実際には UI の設計がユースケースに影響を与える場合が多いため、ApplicationService が UI に影響されることが少なくありません
そんなとき、UI に影響を受けないコアなルールをドメインモデルに持たせていれば、コアなルールについては流量できるようになります

Controller と ApplicationService の分離は不要ではないか

ApplicationService が UI の影響を受けるのであれば、そもそも「Controller と ApplicationService の分離は不要」ではないかと思うかもしれません。

たしかに Controller と ApplicationService は分離しない場合もありますし、採用するフレームワークによってはその方が望ましい場合もあると思います

あえて Controller と ApplicationService を分離するメリットは、

  • ユースケースの流れは ApplicationService に分離した方が、Controller の見通しが良くなる
  • フレームワークや UI と密結合で自動テストしにくい Controller から、テストしやすい部分を ApplicationService に切り出せる

といった点です

ここまで、説明を分かりやすくするために CLI と API で ApplicationService を流用できるか、ということを検討してきましたが、実際には 1 つのプログラムで CLI も API も担うことはほとんどないはずです。
本来実現したいのは、Controller と ApplicationService での役割分担であり、「関心の分離」というものです

ApplicationService の戻り値はどんな型にするか

最後に、ApplicationService はどんな型を戻り値として返すかについて考えてみます。

現在、ApplicationService の play メソッドは Optional<Player> を返しています。

public class JankenApplicationService {
    :
    public Optional<Player> play(String player1Id, Hand player1Hand,
                                 String player2Id, Hand player2Hand) {
        :

ApplicationService の戻り値としては、

  • ドメインモデルの型 (いわゆる DDD の Entity) や、それらを複数まとめた DPO (ドメインペイロードオブジェクト) を返す
  • 戻り値として専用の DTO (例えば xxxOutput や xxxResult クラス) を返す

などの方法があります。

前者の方が、ドメインモデルとして作った型をそのまま戻り値に使えるため、実装コストは低くなります
一方、ドメインモデルをプレゼンテーション層に返してしまうと、プレゼンテーション層でそのメソッドを使ってビジネスロジックを実行されてしまう可能性があります

後者の戻り値専用の型を作って返す方法であれば、プレゼンテーション層がドメイン層のメソッドを呼び出すことはできなくなります

ApplicationService の引数についても同様で、ドメインモデルの型をどこまで引数として使って良いかが議論されることがあります

RequestBody 型、ResponseBody 型

今回作成した RequestBody 型や ResponseBody 型も、上記の DPO や DTO の議論と近い話になります。

ドメインモデルのクラスをそのまま API の RequestBody の型や ResponseBody の型として流用できる場合もありますが、そうするとドメインモデル変更時に API のインタフェースが変わってしまう場合があります

そのような事故を防ぐため、API の RequestBody や ResponseBody については専用の型を設けることをオススメします

このように DTO などを使って分離を進めていくと、いわゆる「クリーンアーキテクチャ」的な構成に近づいていきます。
ですが、代わりにデータの詰め替えが増え、実装コストも大きくなっていくので、どこまでしっかり分離するかは考えものです。

現時点のコード

今回は CLI アプリケーションのプレゼンテーション層だけ書き換えて Web API を実装しました。

現時点の主要なクラスの構成を図示すると、以下のようになっています。

Day16_クラス図_api追加.png

ディレクトリ構成も掲載しておきます。

$ tree app/src/main/java/
app/src/main/java/
└── com
    └── example
        └── janken
            ├── JankenCLIApplication.java
            ├── ServiceLocatorInitializationListener.java
            ├── application
            │   └── service
            │       ├── JankenApplicationService.java
            │       └── PlayerApplicationService.java
            ├── domain
            │   ├── model
            │   │   ├── janken
            │   │   │   ├── Hand.java
            │   │   │   ├── Janken.java
            │   │   │   ├── JankenDetail.java
            │   │   │   ├── JankenRepository.java
            │   │   │   └── Result.java
            │   │   └── player
            │   │       ├── Player.java
            │   │       └── PlayerRepository.java
            │   └── transaction
            │       ├── Transaction.java
            │       └── TransactionManager.java
            ├── infrastructure
            │   ├── csvdao
            │   │   ├── CsvDaoUtils.java
            │   │   ├── JankenCsvDao.java
            │   │   ├── JankenDetailCsvDao.java
            │   │   └── PlayerCsvDao.java
            │   ├── dao
            │   │   ├── JankenDao.java
            │   │   ├── JankenDetailDao.java
            │   │   └── PlayerDao.java
            │   ├── jdbctransaction
            │   │   ├── InsertMapper.java
            │   │   ├── JDBCTransaction.java
            │   │   ├── JDBCTransactionManager.java
            │   │   ├── RowMapper.java
            │   │   ├── SimpleJDBCWrapper.java
            │   │   └── SingleRowMapper.java
            │   ├── mysqldao
            │   │   ├── JankenDetailMySQLDao.java
            │   │   ├── JankenMySQLDao.java
            │   │   └── PlayerMySQLDao.java
            │   └── mysqlrepository
            │       ├── JankenMySQLRepository.java
            │       └── PlayerMySQLRepository.java
            ├── presentation
            │   ├── api
            │   │   ├── APIControllerUtils.java
            │   │   ├── health
            │   │   │   ├── HealthAPIController.java
            │   │   │   └── HealthResponseBody.java
            │   │   └── janken
            │   │       ├── JankenAPIController.java
            │   │       ├── JankenPostRequestBody.java
            │   │       └── JankenPostResponseBody.java
            │   └── cli
            │       ├── controller
            │       │   └── JankenCLIController.java
            │       └── view
            │           └── StandardOutputView.java
            └── registry
                └── ServiceLocator.java

24 directories, 40 files

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

次回のテーマ

ここまで、Servlet と Gson を使った最小構成で API を実装してきました。
次回はついに Spring Framework を導入して、DI を使うようにしようと思います。

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

3
0
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
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?