じゃんけんアドベントカレンダー の 6 日目です。
前回の時点で MVC に近いかたちにはなったのですが、Controller がめちゃくちゃ重たいです。
他のクラスと役割分担することで、Controller をスッキリさせて行こうと思います。
現時点での Controller の役割
現時点の Controller は
- ユーザの入力の読み込み
- じゃんけんの勝敗判定
- じゃんけんして結果保存するという処理の流れの実現
- データの読み書き
- View の呼び出し
と、たくさんの仕事をしています。
Controller が大きくなりすぎているのは、このように多くの仕事をしていることが原因です。
これ以外にも、表示用の形式変換処理などが Controller で担当されていることもよくあります。
この状態を図で表すと以下のようになります。
※ 自分の 他記事 から流用した図のため、言葉の表現が多少異なる箇所があります
改修の方針
ここからの改修の方針は色々あります。
例えば、
- 「じゃんけんの勝敗判定」を Model に移動する
- 「じゃんけんの勝敗判定」、「じゃんけんして結果保存するという処理の流れの実現」、「データの読み書き」を Service クラスに移動する
- 「データの読み書き」を Dao などのクラスに移動する
といった方針が考えられます。
こういったファットコントローラの改修パターンについては、別記事「「Controller にビジネスロジックを書くな」の対応パターン」にまとめています。
今回採用する方針としては、まず Service クラスを抽出し、次回そこからさらにデータの読み書きを別クラスに抽出し、いわゆる「3 層アーキテクチャ」の状態を目指そうと思います。
Service クラスを抽出した時点では、下図のような構成になります。
Controller の Before / After
上記の構成になるように PlayerService と JankenService を実装したので、Controller の Before / After を比較してみようと思います。
Before
public class JankenController {
// ID は実際のアプリケーションでは認証情報から取得することが想定される
private static final long PLAYER_1_ID = 1;
private static final long PLAYER_2_ID = 2;
// 入力スキャナ
private static final Scanner STDIN_SCANNER = new Scanner(System.in);
private static final String VIEW_RESOURCE_PREFIX = "view/";
// データ保存に関する定義
// JankenEnterpriseEdition/app/../data/ を指す
private static final String DEFAULT_DATA_DIR = System.getProperty("user.dir") + "/../data/";
private static final String DATA_DIR_ENV_VARIABLE = System.getenv("DATA_DIR");
private static final String DATA_DIR = DATA_DIR_ENV_VARIABLE != null ? DATA_DIR_ENV_VARIABLE + "/" : DEFAULT_DATA_DIR;
private static final String PLAYERS_CSV = DATA_DIR + "players.csv";
private static final String JANKENS_CSV = DATA_DIR + "jankens.csv";
private static final String JANKEN_DETAILS_CSV = DATA_DIR + "janken_details.csv";
private static final String CSV_DELIMITER = ",";
public void play() throws IOException {
// プレイヤー名を取得
val player1 = findPlayerById(PLAYER_1_ID);
val player2 = findPlayerById(PLAYER_2_ID);
// プレイヤーの手を取得
val player1Hand = scanHand(player1);
val player2Hand = scanHand(player2);
showHandWithName(player1Hand, player1);
showHandWithName(player2Hand, player2);
// 勝敗判定
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 jankensCsv = new File(JANKENS_CSV);
jankensCsv.createNewFile();
val jankenId = countFileLines(JANKENS_CSV) + 1;
val playedAt = LocalDateTime.now();
val janken = new Janken(jankenId, playedAt);
// じゃんけんを保存
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() + CSV_DELIMITER + playedAtStr);
}
// じゃんけん明細を生成
val jankenDetailsCsv = new File(JANKEN_DETAILS_CSV);
jankenDetailsCsv.createNewFile();
val jankenDetailsCount = countFileLines(JANKEN_DETAILS_CSV);
val jankenDetail1Id = jankenDetailsCount + 1;
val jankenDetail1 = new JankenDetail(jankenDetail1Id, jankenId, PLAYER_1_ID, player1Hand, player1Result);
val jankenDetail2Id = jankenDetailsCount + 2;
val jankenDetail2 = new JankenDetail(jankenDetail2Id, jankenId, PLAYER_2_ID, player2Hand, player2Result);
// じゃんけん明細を保存
try (val fw = new FileWriter(jankenDetailsCsv, true);
val bw = new BufferedWriter(fw);
val pw = new PrintWriter(bw)) {
writeJankenDetail(pw, jankenDetail1);
writeJankenDetail(pw, jankenDetail2);
}
// 勝敗の表示
Player winner = null;
if (player1Result.equals(Result.WIN)) {
winner = player1;
} else if (player2Result.equals(Result.WIN)) {
winner = player2;
}
new View(VIEW_RESOURCE_PREFIX + "result.vm")
.with("winner", winner)
.show();
}
private static Player findPlayerById(long playerId) throws IOException {
try (val stream = Files.lines(Paths.get(PLAYERS_CSV), StandardCharsets.UTF_8)) {
return stream
.map(line -> {
val values = line.split(CSV_DELIMITER);
val id = Long.parseLong(values[0]);
val name = values[1];
return new Player(id, name);
})
// ID で検索
.filter(p -> p.getId() == playerId)
.findFirst()
.orElseThrow(() -> {
throw new IllegalArgumentException("Player not exist. playerId = " + playerId);
});
}
}
private static long countFileLines(String path) throws IOException {
try (val stream = Files.lines(Paths.get(path), StandardCharsets.UTF_8)) {
return stream.count();
}
}
private static Hand scanHand(Player player) {
while (true) {
new View(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 View(VIEW_RESOURCE_PREFIX + "invalid-input.vm")
.with("input", inputStr)
.show();
}
}
}
private static void showHandWithName(Hand hand, Player player) {
new View(VIEW_RESOURCE_PREFIX + "show-hand.vm")
.with("player", player)
.with("hand", hand)
.show();
}
private static void writeJankenDetail(PrintWriter pw,
JankenDetail jankenDetail) {
val line = String.join(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 JankenController {
// ID は実際のアプリケーションでは認証情報から取得することが想定される
private static final long PLAYER_1_ID = 1;
private static final long PLAYER_2_ID = 2;
private static final Scanner STDIN_SCANNER = new Scanner(System.in);
private static final String VIEW_RESOURCE_PREFIX = "view/";
private PlayerService playerService = new PlayerService();
private JankenService jankenService = new JankenService();
public void play() throws IOException {
val player1 = playerService.findPlayerById(PLAYER_1_ID);
val player2 = playerService.findPlayerById(PLAYER_2_ID);
val player1Hand = scanHand(player1);
val player2Hand = scanHand(player2);
showHandWithName(player1Hand, player1);
showHandWithName(player2Hand, player2);
val maybeWinner = jankenService.play(player1, player1Hand, player2, player2Hand);
new View(VIEW_RESOURCE_PREFIX + "result.vm")
.with("winner", maybeWinner.orElse(null))
.show();
}
private static Hand scanHand(Player player) {
while (true) {
new View(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 View(VIEW_RESOURCE_PREFIX + "invalid-input.vm")
.with("input", inputStr)
.show();
}
}
}
private static void showHandWithName(Hand hand, Player player) {
new View(VIEW_RESOURCE_PREFIX + "show-hand.vm")
.with("player", player)
.with("hand", hand)
.show();
}
}
Controller はかなりスッキリしました !!!
行数だけで考えていいものではないですが、もともと 224 行あったのが 76 行と、1/3 になりました。
Controller に残った役割は
- ユーザの入力の読み込み
- View の呼び出し
- 手を読み込んでじゃんけんして結果を表示するという処理の流れの実現
となり、かなり整理されました。
3 つ目の役割である「手を読み込んでじゃんけんして結果を表示するという処理の流れの実現」については後ほど考察します。
JankenService
さて、処理の抽出先である JankenService のコードは以下のようになりました。
public class JankenService {
private static final String JANKENS_CSV = ServiceConfigurations.DATA_DIR + "jankens.csv";
private static final String JANKEN_DETAILS_CSV = ServiceConfigurations.DATA_DIR + "janken_details.csv";
/**
* じゃんけんを実行し、勝者を返します。
*/
public Optional<Player> play(Player player1, Hand player1Hand,
Player player2, Hand player2Hand) throws IOException {
// 勝敗判定
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 jankensCsv = new File(JANKENS_CSV);
jankensCsv.createNewFile();
val jankenId = countFileLines(JANKENS_CSV) + 1;
val playedAt = LocalDateTime.now();
val janken = new Janken(jankenId, playedAt);
// じゃんけんを保存
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);
}
// じゃんけん明細を生成
val jankenDetailsCsv = new File(JANKEN_DETAILS_CSV);
jankenDetailsCsv.createNewFile();
val jankenDetailsCount = countFileLines(JANKEN_DETAILS_CSV);
val jankenDetail1Id = jankenDetailsCount + 1;
val jankenDetail1 = new JankenDetail(jankenDetail1Id, jankenId, player1.getId(), player1Hand, player1Result);
val jankenDetail2Id = jankenDetailsCount + 2;
val jankenDetail2 = new JankenDetail(jankenDetail2Id, jankenId, player2.getId(), player2Hand, player2Result);
// じゃんけん明細を保存
try (val fw = new FileWriter(jankenDetailsCsv, true);
val bw = new BufferedWriter(fw);
val pw = new PrintWriter(bw)) {
writeJankenDetail(pw, jankenDetail1);
writeJankenDetail(pw, jankenDetail2);
}
// 勝者を返却
if (player1Result.equals(Result.WIN)) {
return Optional.of(player1);
} else if (player2Result.equals(Result.WIN)) {
return Optional.of(player2);
} else {
return Optional.empty();
}
}
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);
}
}
読めないことはないですが、正直結構苦しいです。
Controller から
- じゃんけんの勝敗判定
- じゃんけんして結果保存するという処理の流れの実現
- データの読み書き
というたくさんの役割を JankenService に移動したため、今度は JankenService の方がファットになってしまったわけです。
このままでは苦しいので、次回、データの読み書きを別クラスに抽出して整理しようと思います。
「手を読み込んでじゃんけんして結果を表示するという処理の流れの実現」について
※ ここまでと比べて少し難しい話になります
JankenController の違和感
今回の変更後、JankenController に残った役割は
- ユーザの入力の読み込み
- View の呼び出し
- 手を読み込んでじゃんけんして結果を表示するという処理の流れの実現
になったと書きました。
この 3 つ目の「手を読み込んでじゃんけんして結果を表示するという処理の流れの実現」は、本当に Controller の役割なのでしょうか ?
また、JankenController は PlayerService と JankenService という 2 つのサービスを呼び出しています。
Controller が複数の Service を呼び出すのは問題ないのでしょうか ?
Web アプリケーションの場合との比較
Web アプリケーションのサーバサイドでは、1 つの Controller から呼び出す Service は 1 つになることが多いです。
今回それが 2 つになっているのはなぜでしょうか ?
その理由は、「ユーザの操作を踏まえた次の処理の呼び出し」を、Web アプリケーションは View が担っているのに対し、今回のコードでは Controller が担っているからです。
Web アプリケーションにおける処理の流れは下図のようになります。
一方、今回のコードにおける処理の流れは以下のようになります。
この処理の流れの違いが、今回実装した Controller が複数の Service を呼び出していることや、表示後にどんな処理をするかを決める役割まで担っていることを表しています。
対応案
もしもこの状態を解消したい場合、対応案はいくつか考えられます。
具体的には
- 案 1. View から Controller を呼び出せるようにする (Web アプリケーションに近くなる)
- 案 2. Controller を順に呼び出す、シナリオの制御のような層を追加する
- 案 3. コールバックなどの仕組みを使って、Service 側の処理のあるタイミングでユーザの操作を受け付けるようにする
といった方法が考えられます。
この点の改良については少し複雑な話になりそうなので、アドベントカレンダーで予定している一通りのリファクタリング終了後、余裕があれば実施することにしようと思います。
現時点のコード
今回はサービスクラスを導入しました。
現時点のコードの構成を図示すると、以下のようになっています。
コードは GitHub の この時点のコミット を参照ください。
次回のテーマ
ファットコントローラを解消すべくサービスクラスを作成しましたが、今度はサービスクラスがなかなかファットになってしまいました。
その対応として、次回はサービスクラスからデータアクセスを抽出し、「3 層アーキテクチャ」の状態に持っていこうと思います。
それでは、今回の記事はここまでにします。最後まで読んでくださりありがとうございました。
じゃんけんアドベントカレンダー に興味を持ってくださった方は、是非購読お願いします。