2
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.

【Day 21】CLI アプリケーションにおける Controller の違和感を解消【じゃんけんアドカレ】

Last updated at Posted at 2020-12-21

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


今回は、6 日目の記事 で登場した CLI アプリケーションにおける Controller の違和感を解消できないか検討していきます。

そもそもどんな違和感だったか

そもそもどんな違和感だったか思い出すため、まずは CLI アプリケーションの Controller を見直してみます。

@AllArgsConstructor
public class JankenCLIController {

    // ID は実際のアプリケーションでは認証情報から取得することが想定される
    private static final String PLAYER_1_ID = "1";
    private static final String PLAYER_2_ID = "2";

    private static final Scanner STDIN_SCANNER = new Scanner(System.in);
    private static final String VIEW_RESOURCE_PREFIX = "cli/view/";

    private PlayerApplicationService playerApplicationService;
    private JankenApplicationService jankenApplicationService;

    public void play() {
        val player1 = playerApplicationService.findPlayerById(PLAYER_1_ID).orElseThrow();
        val player2 = playerApplicationService.findPlayerById(PLAYER_2_ID).orElseThrow();

        val player1Hand = scanHand(player1);
        val player2Hand = scanHand(player2);

        showHandWithName(player1Hand, player1);
        showHandWithName(player2Hand, player2);

        val maybeWinner = jankenApplicationService.play(PLAYER_1_ID, player1Hand, PLAYER_2_ID, player2Hand);

        new StandardOutputView(VIEW_RESOURCE_PREFIX + "result.vm")
                .with("winner", maybeWinner.orElse(null))
                .show();
    }

    private static Hand scanHand(Player player) {
        while (true) {
            new StandardOutputView(VIEW_RESOURCE_PREFIX + "scan-prompt.vm")
                    .with("player", player)
                    .with("hands", Hand.values())
                    .show();

            val inputStr = STDIN_SCANNER.nextLine();

            val maybeHand = Arrays.stream(Hand.values())
                    .filter(hand -> {
                        val handValueStr = String.valueOf(hand.getValue());
                        return handValueStr.equals(inputStr);
                    })
                    .findFirst();

            if (maybeHand.isPresent()) {
                return maybeHand.get();
            } else {
                new StandardOutputView(VIEW_RESOURCE_PREFIX + "invalid-input.vm")
                        .with("input", inputStr)
                        .show();
            }
        }
    }

    private static void showHandWithName(Hand hand, Player player) {
        new StandardOutputView(VIEW_RESOURCE_PREFIX + "show-hand.vm")
                .with("player", player)
                .with("hand", hand)
                .show();
    }

}

上記の Controller は

  • ユーザの入力の読み込み
  • View の呼び出し
  • 手を読み込んでじゃんけんして結果を表示するという処理の流れの実現

という 3 つの役割を持っています。

違和感の正体

上記のコードで感じる違和感は、ApplicationService のメソッドを複数回呼び出し、手を読み込んでじゃんけんして結果を表示するという処理の流れを実現することが、Controller 上で実施されていることです。

ユーザの入力を受け取ることや View の指定が Controller の仕事であり、処理の流れを実現するのは Controller の役割ではありません。

この状態から、2 パターンの対応案で違和感を解消できないか検討していきます。

  • 案 1. View から Controller を呼び出せるようにする (Web アプリケーションに近くなる)
  • 案 2. ユースケースの処理の特定のタイミングで入出力できるようにする

案 1. View から Controller を呼び出せるようにする (Web アプリケーションに近くなる)

現状のコードでは、Controller が ApplicationService や View を順に呼び出して処理の流れを実現しています。

https___qiita-image-store.s3.ap-northeast-1.amazonaws.com_0_216010_5eaade2e-0691-abfc-8454-d175cef5e8c6.png

一方、Web アプリケーションでは、View に次に呼び出す Controller の処理が指定されており、以下の図のように処理が進んでいきます。

https___qiita-image-store.s3.ap-northeast-1.amazonaws.com_0_216010_385e1848-bcda-58b9-360f-2b0d19c6b91e.png

CLI アプリケーションで似た流れになるように実装してみようとしてみます。

実装

では、じゃんけんアプリケーションをリファクタリングして、View の処理完了後に次の Controller が呼び出されるようにしていきます。

最初に呼び出される Controller は以下のようになりました。

@AllArgsConstructor
public class JankenEntrypointController implements CLIController {

    private JankenContext ctx;
    private PlayerApplicationService playerApplicationService;
    private JankenApplicationService jankenApplicationService;

    @Override
    public void handle(String input) {
        val playerId = ctx.getPlayer1Id();
        val player = playerApplicationService.findPlayerById(playerId).orElseThrow();

        new StandardOutputView(JankenContext.VIEW_RESOURCE_PREFIX + "scan-prompt.vm")
                .with("player", player)
                .with("hands", Hand.values())
                .next(new PostHandController(ctx, playerApplicationService, jankenApplicationService))
                .show();
    }

}

StandardOutputView の next メソッドで次の Controller を指定しており、ApplicationService を使った処理完了後は次の View を呼ぶだけになっています。

なお、JankenContext クラスは、Controller をまたがったデータの受け渡しに使っている DTO で、Web アプリケーションのセッションのような使い方をしています。1

@AllArgsConstructor
@Getter
@ToString
@EqualsAndHashCode
public class JankenContext {

    static final String VIEW_RESOURCE_PREFIX = "cli/view/";

    private String player1Id;
    private String player2Id;
    private Hand player1Hand;
    private Hand player2Hand;

}

最初に呼ばれる Controller はまあまあシンプルになりましたが、手の入力後に呼ばれる Controller は少し複雑になりました。

@AllArgsConstructor
public class PostHandController implements CLIController {

    private JankenContext ctx;
    private PlayerApplicationService playerApplicationService;
    private JankenApplicationService jankenApplicationService;

    @Override
    public void handle(String input) {
        // 入力が数値でない場合
        if (!NumberUtils.isDigits(input)) {
            showInvalidInputView(input);
            return;
        }

        val inputValue = Integer.valueOf(input);

        // 入力が不正な数値の場合
        if (!Hand.isValid(inputValue)) {
            showInvalidInputView(input);
            return;
        }

        val hand = Hand.of(inputValue);

        if (ctx.getPlayer1Hand() == null) {
            // 1 人目の入力の場合
            val player = playerApplicationService.findPlayerById(ctx.getPlayer2Id()).orElseThrow();

            val newCtx = new JankenContext(ctx.getPlayer1Id(), ctx.getPlayer2Id(), hand, null);
            new StandardOutputView(JankenContext.VIEW_RESOURCE_PREFIX + "scan-prompt.vm")
                    .with("player", player)
                    .with("hands", Hand.values())
                    .next(new PostHandController(newCtx, playerApplicationService, jankenApplicationService))
                    .show();

        } else {
            // 2 人目の入力の場合
            val output = jankenApplicationService.play(
                    ctx.getPlayer1Id(),
                    ctx.getPlayer1Hand(),
                    ctx.getPlayer2Id(),
                    hand);

            new StandardOutputView(JankenContext.VIEW_RESOURCE_PREFIX + "result.vm")
                    .with("player1Name", output.getPlayer1().getName())
                    .with("player1Hand", ctx.getPlayer1Hand())
                    .with("player2Name", output.getPlayer2().getName())
                    .with("player2Hand", hand)
                    .with("winner", output.getMaybeWinner().orElse(null))
                    .show();
        }
    }

    private void showInvalidInputView(String input) {
        val playerId = ctx.getPlayer1Hand() == null ? ctx.getPlayer1Id() : ctx.getPlayer2Id();
        val player = playerApplicationService.findPlayerById(playerId).orElseThrow();

        new StandardOutputView(JankenContext.VIEW_RESOURCE_PREFIX + "invalid-input.vm")
                .with("input", input)
                .with("hands", Hand.values())
                .with("player", player)
                .next(this)
                .show();
    }

}

JankenContext に保持されているデータの状態から、現在何人目までの手を入力したかを判断し、次の手を入力する View を表示するかじゃんけんを実施するかを分岐しています。

分かりやすくなったか

これで View と Controller の構成としては Web アプリケーションに近付きました。
ですが、コードはむしろ分かりにくくなってしまったように感じます
具体的には、View と Controller を全体的に追いかけないと処理の流れが把握できなくなってしまいました

この案はあまり良い手段ではなかったようです。

コード

この実装のコードの全体像は GitHub の こちらのブランチ を参照ください。

案 2. ユースケースの処理の特定のタイミングで入出力できるようにする

次に、現状 JankenCLIController で実現している**「手を読み込んでじゃんけんして結果を表示するという処理の流れ」もユースケースの一種であり、アプリケーション層の役割だと考えて実装を修正してみます**。

その際、入出力したいタイミングでコールバックのように処理を呼び出せるよう、呼び出す処理のインタフェースを InputPort、OutputPort という名前で用意しておきます

実装する構成を図解すると、以下のようになります。

Day21_クラス図_usecaseとscenario追加.png

実装

では、実装を見ていきます。

ユースケースの実現の役割を果たすクラスは PlayJankenUseCase という名前で以下のように実装しました。

@AllArgsConstructor
public class PlayJankenUseCase {

    // ID は実際のアプリケーションでは認証情報から取得することが想定される
    private static final String PLAYER_1_ID = "1";
    private static final String PLAYER_2_ID = "2";

    private PlayJankenInputPort inputPort;
    private PlayJankenOutputPort outputPort;

    private PlayerRepository playerRepository;
    private JankenRepository jankenRepository;

    public void run() {
        val player1 = playerRepository.findPlayerById(PLAYER_1_ID).orElseThrow();
        val player2 = playerRepository.findPlayerById(PLAYER_2_ID).orElseThrow();

        val player1Hand = getHandUntilSuccess(player1);
        val player2Hand = getHandUntilSuccess(player2);

        outputPort.showHandWithName(player1Hand, player1);
        outputPort.showHandWithName(player2Hand, player2);

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

        jankenRepository.save(janken);

        val maybeWinner = janken.winnerPlayerId()
                .map(playerId -> playerRepository.findPlayerById(playerId).orElseThrow());

        outputPort.showResult(maybeWinner);
    }

    private Hand getHandUntilSuccess(Player player) {
        while (true) {
            val maybeHand = getHand(player);

            if (maybeHand.isPresent()) {
                return maybeHand.get();
            }
        }
    }

    private Optional<Hand> getHand(Player player) {
        outputPort.showScanPrompt(player);

        val inputStr = inputPort.getInputStr();

        // 数値ではない場合
        if (!NumberUtils.isDigits(inputStr)) {
            outputPort.showInvalidInput(inputStr);
            return Optional.empty();
        }

        val inputValue = Integer.valueOf(inputStr);

        // 不正な数値の場合
        if (!Hand.isValid(inputValue)) {
            outputPort.showInvalidInput(inputStr);
            return Optional.empty();
        }

        return Optional.of(Hand.of(inputValue));
    }

}

ユースケースの流れの実現がこのクラスの役割になっており、入出力についてはタイミングを指定するだけで、何を使って入出力しているかは知らないようになっています

入出力したいタイミングで呼び出している PlayJankenInputPort と PlayJankenOutputPort は以下のようなインタフェースです。

public interface PlayJankenInputPort {

    String getInputStr();

}
public interface PlayJankenOutputPort {

    void showScanPrompt(Player player);

    void showInvalidInput(String input);

    void showHandWithName(Hand hand, Player player);

    void showResult(Optional<Player> maybeWinner);

}

InputPort と OutputPort の実装クラスはプレゼンテーション層に以下のように実装しました。

public class PlayJankenStandardInputController implements PlayJankenInputPort {

    private static final Scanner STDIN_SCANNER = new Scanner(System.in);

    @Override
    public String getInputStr() {
        return STDIN_SCANNER.nextLine();
    }

}
public class PlayJankenStandardOutputPresenter implements PlayJankenOutputPort {

    private static final String VIEW_RESOURCE_PREFIX = "cli/view/";

    @Override
    public void showScanPrompt(Player player) {
        new StandardOutputView(VIEW_RESOURCE_PREFIX + "scan-prompt.vm")
                .with("player", player)
                .with("hands", Hand.values())
                .show();
    }

    @Override
    public void showInvalidInput(String input) {
        new StandardOutputView(VIEW_RESOURCE_PREFIX + "invalid-input.vm")
                .with("input", input)
                .show();
    }

    public void showHandWithName(Hand hand, Player player) {
        new StandardOutputView(VIEW_RESOURCE_PREFIX + "show-hand.vm")
                .with("player", player)
                .with("hand", hand)
                .show();
    }

    @Override
    public void showResult(Optional<Player> maybeWinner) {
        new StandardOutputView(VIEW_RESOURCE_PREFIX + "result.vm")
                .with("winner", maybeWinner.orElse(null))
                .show();
    }

}

main クラスでは、依存を解決してインスタンス化したユースケースを実行するだけです。

public class JankenCLIApplication {

    public static void main(String[] args) {
            :
            val playJankenUseCase = new PlayJankenUseCase(
                    playJankenController, playJankenPresenter,
                    playerRepository, jankenRepository);

            playJankenUseCase.run();
            :
    }

}

このように、処理の流れの実現を全て Controller から排除することで、Controller はただ入力を受け取るだけの存在になります。

よく言われることですが、Controller はゲームのコントローラのように、ユーザの入力を受け取るだけが本来の役割です。
ゲームでも、入力内容によってどんな処理が行われるかは、コントローラの中で判断されていないはずです。

この変更により、入力に使う Controller が標準入力だろうと何だろうと動作するように、入力方式と処理の流れを分離できました
また、出力先も標準出力なのかなんなのかによらず表示できるようになっています

アーキテクチャ

もう一度構成図を見てみます。

Day21_クラス図_usecaseとscenario追加.png

この構成は、アプリケーション層とドメイン層を中心に、標準入力・標準出力・DB に接続しています
標準入力・標準出力・DB の部分については、アプリケーション層とドメイン層が提供するインタフェースを実装することで、プラガブルに変更可能になっています

ゲームの例で言えば、ゲームをシミュレートするソフトウェアがあり、そこにプラガブルにコントローラや画面・保存先を付け替えできるようなイメージです

この構成は、ヘキサゴナルアーキテクチャやオニオンアーキテクチャと呼ばれるものに近いです
以下の、よく見るクリーンアーキテクチャの構成図ともある程度近いです。

image.png

※ ヘキサゴナルアーキテクチャ、オニオンアーキテクチャ、クリーンアーキテクチャについては、本質的な違いがあまりないと言われることもありますし、具体的なクラス構成を守るよりもコンセプトが重要だと思うので、今回の構成がその中のどれなのかといった議論はこれ以上しません

ApplicationService との共通の処理

PlayJankenUseCase のコードを再度見てみます。

@AllArgsConstructor
public class PlayJankenUseCase {
    :
    private PlayJankenInputPort inputPort;
    private PlayJankenOutputPort outputPort;

    private PlayerRepository playerRepository;
    private JankenRepository jankenRepository;

    public void run() {
        val player1 = playerRepository.findPlayerById(PLAYER_1_ID).orElseThrow();
        val player2 = playerRepository.findPlayerById(PLAYER_2_ID).orElseThrow();
        :
        val janken = Janken.play(player1Id, player1Hand, player2Id, player2Hand);

        jankenRepository.save(janken);

        val maybeWinner = janken.winnerPlayerId()
                .map(playerId -> playerRepository.findPlayerById(playerId).orElseThrow());
        :

現状の PlayJankenUseCase のコードは、一部が API としての実装で使っている ApplicationService と重複しています。

その部分を ApplicationService の呼び出しに置き換え、分かりやすくするためにクラス名を PlayJankenUseCase から PlayJankenScenario と変更すると、以下のようになりました。

@AllArgsConstructor
public class PlayJankenScenario {
    :
    private PlayJankenInputPort inputPort;
    private PlayJankenOutputPort outputPort;

    private PlayerApplicationService playerApplicationService;
    private JankenApplicationService jankenApplicationService;

    public void run() {
        val player1 = playerApplicationService.findPlayerById(PLAYER_1_ID).orElseThrow();
        val player2 = playerApplicationService.findPlayerById(PLAYER_2_ID).orElseThrow();

        val player1Hand = getHandUntilSuccess(player1);
        val player2Hand = getHandUntilSuccess(player2);

        outputPort.showHandWithName(player1Hand, player1);
        outputPort.showHandWithName(player2Hand, player2);

        val maybeWinner = jankenApplicationService.play(PLAYER_1_ID, player1Hand, PLAYER_2_ID, player2Hand);

        outputPort.showResult(maybeWinner);
    }
:

もとの PlayJankenUseCase では、

  • ユーザとの入出力を含む処理の流れの実現
  • ユーザとの入出力を含まない処理の流れの実現

の両方が実施されていました。

この 2 つを Scenario (シナリオ) と ApplicationService という 2 段階に分解してみたイメージです。

Web アプリケーションのアプリケーション層では、処理の途中でのユーザとの入出力は基本的にないので、ApplicationService だけあれば十分です。
Web アプリケーションでのユーザとの入出力を含む処理の流れの実現は、フロントエンドが担当するためです。

一方、CLI アプリケーションでは、ユーザとの入出力を途中で実現しながら処理を進めるため、それを実現するという役割が登場したのです。

なお、この実装のように「ユーザとの入出力を含む処理の流れの実現」と「ユーザとの入出力を含まない処理の流れの実現」を分離する必要は必ずしもなく、UseCase (または ApplicationService) といった名前のクラスにまとめてもいいと思います。
また、「ユーザとの入出力を含む処理の流れの実現」を UseCase (または ApplicationService) クラスに担当させ、「ユーザとの入出力を含まない処理の流れの実現」は DomainService に担当させる、といった構成も考えられるかもしれません。2

この整理は、CLI アプリケーションに限らず、モバイルアプリケーションやゲーム、WebSocket を使った Web アプリケーション等でもヒントになるかもしれません。

コード

この実装の全体像は GitHub の この時点のコミット を参照ください。

また、主なクラスの構成は以下のようになっています。

Day21_クラス図_usecaseとscenario追加.png

次回のテーマ

今回は CLI アプリケーションにおける Controller の違和感について検討しました。

次回は 10 日目の記事 で登場した、Transaction 型のキャストをなくしたい点について考えて見ようと思います。

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

  1. データ設計を修正しない範囲で対応するためにセッションのようなものでデータを扱っていますが、本来は DB で管理した方が適切な可能性も高いです

  2. この記事に記載している Scenario と ApplicationService という分担と命名が変わっただけです

2
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
2
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?