はじめに ~ 「じゃんけんアドベントカレンダー」について ~
今年もアドベントカレンダーの季節がやってきました。
毎年「全部俺アドベントカレンダー」や「一人アドベントカレンダー」といったものを見かけ、自分もやってみたいなと思っていたので、今年はそれに挑戦します。
テーマとしては、「じゃんけんプログラムを全力で実装していく」というものです。
じゃんけんプログラムといえば誰もが一度は実装したことがあるものであり、オブジェクト指向プログラミングの練習題材などに使われることもあります。
とはいえ、どこかで見たことがあるじゃんけんプログラムを再発明しても面白くありません。
そこでこのアドベントカレンダーでは、「オブジェクト指向でじゃんけんプログラムを書くとどうなるか」という方向ではなく、どちらかというと「MVC や 3 層アーキテクチャといった、アプリケーションアーキテクチャを整えていく」ことや「CI や自動デプロイなどを整えていく」ことを中心に書いていきます。
まだまだアドベントカレンダー後半で何をやるか未定だったりしますが、面白い企画になるようがんばっていきます。
読んでくださる方に楽しんでいただければと思います。
前提
今回は第 1 回の記事なので、記事の最初でどんな環境を使うかなどをまとめておきます。
環境
じゃんけんプログラムを作るにあたっては、以下の環境で始めます。
- Java 11
- Gradle v6.7
- フレームワークやライブラリは適宜導入
プログラミング言語としては Java を使いますが、Java に依存した内容はなるべく少なめにする予定です。
ソースコード管理
ソースコードは GitHub で公開していきます。
リポジトリ名は悩みましたが、FizzBuzz を盛大に実装した FizzBuzzEnterpriseEdition をリスペクトして、JankenEnterpriseEdition としました。
仕様・設計
コードの解説に入る前に、初期の仕様や設計も簡単にまとめておきます。
初期の仕様
アドベントカレンダーを進める中で機能追加していきますが、まずは以下の仕様で実装します。
- CLI プログラム
- ユーザはプレイヤー 1 とプレイヤー 2 の 2 人分の手を入力する
- プレイヤーが不正な入力をした場合、再入力させる
- 1 回戦だけ遊べればよい
- あいこでもそこで終了する
- じゃんけんの結果は CSV ファイルに保存する
データモデル
じゃんけんの結果をファイルに保存するにあたり、ひとまず以下のようなデータモデルで進めます。
じゃんけん明細に「手」と「結果」があるのは違和感がなくもないですが、ひとまずこれで進めます。
これが原因でうまくいかない箇所が出てくるかもしれないので、そうなったらリファクタリングします。
挙動イメージ
まずは 0, 1, 2 のいずれかで手を選択するよう促されます。
STONE: 0
PAPER: 1
SCISSORS: 2
Please select Alice hand:
2 人分の手を選択すると、選んだ手と勝敗が表示されます。
Alice selected PAPER.
Bob selected STONE.
Alice win !!!
アイコでもそこで終了します。
Alice selected SCISSORS.
Bob selected SCISSORS.
DRAW !!!
不正な入力の場合、再入力を促されます。
STONE: 0
PAPER: 1
SCISSORS: 2
Please select Alice hand:
Invalid input: 4
STONE: 0
PAPER: 1
SCISSORS: 2
Please select Alice hand:
実装
さて、それでは実装に移ります。
当初、この記事では main メソッドに全部のコードを書いたじゃんけんプログラムを用意しようと思っていたのですが、さすがに書いていて辛かったので、いくつかの工夫を入れた状態のコードを用意しました。
コードの全量は長くなるので、GitHub の こちら、またはこの記事の最後を参照ください。
では、今回入れた以下の 3 つの工夫について簡単に解説して、初日の記事は終了にしようと思います。
- マジックナンバーを private static final なフィールドに切り出す
- 画面に表示する文言を private static final なフィールドに切り出す
- ほとんど同じ処理が繰り返し登場する箇所は private static メソッドに抽出する
マジックナンバーを private static final なフィールドに切り出す
private static final int STONE_NUM = 0;
private static final int PAPER_NUM = 1;
private static final int SCISSORS_NUM = 2;
「グーが 0、パーが 1、チョキが 2」といった値は、private static final なフィールドに切り出しました。
今回は "グー"、"チョキ"、"パー" という文字列も private static final なフィールドで定義するため、数値には _NUM というサフィックスを付けています。
そういった事情がなければ
private static final int STONE = 0;
private static final int PAPER = 1;
private static final int SCISSORS = 2;
でもいいでしょう。むしろこちらの方が望ましいという意見が多いと思います。
このように「グーが 0、パーが 1、チョキが 2」を定数として定義したことによって、ソースコードの中から if (player1Hand == 0)
といった、意図が一見して分からない数値との比較がなくなりました。
この工夫はいわゆる「マジックナンバーを使わない」というもので、書籍で言えば『リーダブルコード』などで解説されています。
※ グー、チョキー、パーなどは本当なら enum で定義すべきです。今後のリファクタリングで導入します。
画面に表示する文言を private static final なフィールドに切り出す
private static final String LINE_SEPARATOR = System.getProperty("line.separator");
private static final String SCAN_PROMPT_MESSAGE_FORMAT = String.join(LINE_SEPARATOR,
STONE_STR + ": " + STONE_NUM,
PAPER_STR + ": " + PAPER_NUM,
SCISSORS_STR + ": " + SCISSORS_NUM,
"Please select {0} hand:");
private static final String INVALID_INPUT_MESSAGE_FORMAT = "Invalid input: {0}" + LINE_SEPARATOR;
private static final String SHOW_HAND_MESSAGE_FORMAT = "{0} selected {1}";
private static final String WINNING_MESSAGE_FORMAT = "{0} win !!!";
private static final String DRAW_MESSAGE = "DRAW !!!";
画面に表示する文言も private static final なフィールドに切り出しました。
本当なら prifate static final なフィールドではなく、他のファイルに切り出したりする方がちゃんとしていると思いますが、それは今後の課題としておきます。
例外のメッセージも切り出せという意見もあるかもしれませんが、今回は例外のメッセージは対象外とさせていただきました。
なお、改行コードは OS によって異なるため、System.getProperty("line.separator")
で取得して使うようにしました。
ほとんど同じ処理が繰り返し登場する箇所は private static メソッドに抽出する
private static String findPlayerNameById(int playerId) throws IOException {
try (Stream<String> stream = Files.lines(Paths.get(PLAYERS_CSV), StandardCharsets.UTF_8)) {
return stream
// ID で検索
.filter(line -> {
String[] values = line.split(",");
int id = Integer.parseInt(values[0]);
return id == playerId;
})
// 名前のみに変換
.map(line -> {
String[] values = line.split(",");
return values[1];
})
.findFirst()
.orElseThrow(() -> {
throw new IllegalArgumentException("Player not exist. playerId = " + playerId);
});
}
}
この記事ではもともと main メソッドに全部の処理を書くつもりでいたのですが、流石にほぼ同じ処理が複数回登場するのは辛かったので、private static メソッドに抽出させていただきました。
プログラミングに慣れるまでは、ほぼ同じコードが登場してもコピペで済ませてしまうことが多いのではないでしょうか。
そういった場面でサボらずメソッド (関数) に処理を抽出したりしていくことで、将来の自分にとって嬉しいプログラムになっていきます。
実際には似たコードを必ずしも共通化すべきではない場合も多々ありますが、まずは似たコードは共通化することを身につけて、次のステップとして適切な場面を見極められるようになればいいのではないかと思います。
次回のテーマ
簡単ではありましたが、今回の記事はここまでにします。
さて、このコードをリファクタリングしていくにあたり、どこから手をつけるかは色々な意見があると思います。
- 「グー、チョキ、パー」の定数を enum に置き換える
- Player クラスを作る
- ファイル入出力を他のクラスに移動する
などなど色々想像できますが、「まずやるべきことは自動テストを書くことだ」と、天から声が聞こえてきました。
そこで次回は JUnit を使った自動テストを書いてみようと思います。
それでは、今回の記事はここまでにします。最後まで読んでくださりありがとうございました。
じゃんけんアドベントカレンダー に興味を持ってくださった方は、是非購読お願いします。
次回の記事
【Day 2】リファクタリングするなら自動テスト【じゃんけんアドカレ】
現時点のコード
現時点のコードは GitHub の この時点のコミット を参照ください。
まだ 1 ファイルなので、今回はこちらにも貼っておきます。
package com.example.janken;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.text.MessageFormat;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Scanner;
import java.util.stream.Stream;
public class App {
// ID は実際のアプリケーションでは認証情報から取得することが想定される
private static final int PLAYER_1_ID = 1;
private static final int PLAYER_2_ID = 2;
private static final int STONE_NUM = 0;
private static final int PAPER_NUM = 1;
private static final int SCISSORS_NUM = 2;
private static final String STONE_STR = "STONE";
private static final String PAPER_STR = "PAPER";
private static final String SCISSORS_STR = "SCISSORS";
private static final int WIN = 0;
private static final int LOSE = 1;
private static final int DRAW = 2;
// 表示するメッセージの形式定義
private static final String LINE_SEPARATOR = System.getProperty("line.separator");
private static final String SCAN_PROMPT_MESSAGE_FORMAT = String.join(LINE_SEPARATOR,
STONE_STR + ": " + STONE_NUM,
PAPER_STR + ": " + PAPER_NUM,
SCISSORS_STR + ": " + SCISSORS_NUM,
"Please select {0} hand:");
private static final String INVALID_INPUT_MESSAGE_FORMAT = "Invalid input: {0}" + LINE_SEPARATOR;
private static final String SHOW_HAND_MESSAGE_FORMAT = "{0} selected {1}";
private static final String WINNING_MESSAGE_FORMAT = "{0} win !!!";
private static final String DRAW_MESSAGE = "DRAW !!!";
// 入力スキャナ
private static final Scanner STDIN_SCANNER = new Scanner(System.in);
// データ保存に関する定義
// 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 static void main(String[] args) throws IOException {
// プレイヤー名を取得
String player1Name = findPlayerNameById(PLAYER_1_ID);
String player2Name = findPlayerNameById(PLAYER_2_ID);
// プレイヤーの手を取得
int player1Hand = scanHand(player1Name);
int player2Hand = scanHand(player2Name);
showHandWithName(player1Hand, player1Name);
showHandWithName(player2Hand, player2Name);
// 勝敗判定
int player1Result;
int player2Result;
if (player1Hand == STONE_NUM) {
// プレイヤーがグーの場合
if (player2Hand == STONE_NUM) {
player1Result = DRAW;
player2Result = DRAW;
} else if (player2Hand == PAPER_NUM) {
player1Result = LOSE;
player2Result = WIN;
} else {
player1Result = WIN;
player2Result = LOSE;
}
} else if (player1Hand == PAPER_NUM) {
// プレイヤーがパーの場合
if (player2Hand == STONE_NUM) {
player1Result = WIN;
player2Result = LOSE;
} else if (player2Hand == PAPER_NUM) {
player1Result = DRAW;
player2Result = DRAW;
} else {
player1Result = LOSE;
player2Result = WIN;
}
} else {
// プレイヤーがチョキの場合
if (player2Hand == STONE_NUM) {
player1Result = LOSE;
player2Result = WIN;
} else if (player2Hand == PAPER_NUM) {
player1Result = WIN;
player2Result = LOSE;
} else {
player1Result = DRAW;
player2Result = DRAW;
}
}
// 結果を保存
File jankensCsv = new File(JANKENS_CSV);
jankensCsv.createNewFile();
long jankenId = countFileLines(JANKENS_CSV) + 1;
LocalDateTime playedAt = LocalDateTime.now();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd hh:mm:ss");
String playedAtStr = formatter.format(playedAt);
try (FileWriter fw = new FileWriter(jankensCsv, true);
BufferedWriter bw = new BufferedWriter(fw);
PrintWriter pw = new PrintWriter(bw)) {
pw.println(jankenId + CSV_DELIMITER + playedAtStr);
}
File jankenDetailsCsv = new File(JANKEN_DETAILS_CSV);
jankenDetailsCsv.createNewFile();
long jankenDetailsCount = countFileLines(JANKEN_DETAILS_CSV);
try (FileWriter fw = new FileWriter(jankenDetailsCsv, true);
BufferedWriter bw = new BufferedWriter(fw);
PrintWriter pw = new PrintWriter(bw)) {
long jankenDetail1Id = jankenDetailsCount + 1;
writeJankenDetail(pw, jankenDetail1Id, jankenId, PLAYER_1_ID, player1Hand, player1Result);
long jankenDetail2Id = jankenDetailsCount + 2;
writeJankenDetail(pw, jankenDetail2Id, jankenId, PLAYER_2_ID, player2Hand, player2Result);
}
// 勝敗の表示
String resultMessage;
if (player1Result == WIN) {
resultMessage = MessageFormat.format(WINNING_MESSAGE_FORMAT, player1Name);
} else if (player2Result == WIN) {
resultMessage = MessageFormat.format(WINNING_MESSAGE_FORMAT, player2Name);
} else {
resultMessage = DRAW_MESSAGE;
}
System.out.println(resultMessage);
}
private static String findPlayerNameById(int playerId) throws IOException {
try (Stream<String> stream = Files.lines(Paths.get(PLAYERS_CSV), StandardCharsets.UTF_8)) {
return stream
// ID で検索
.filter(line -> {
String[] values = line.split(CSV_DELIMITER);
int id = Integer.parseInt(values[0]);
return id == playerId;
})
// 名前のみに変換
.map(line -> {
String[] values = line.split(CSV_DELIMITER);
return values[1];
})
.findFirst()
.orElseThrow(() -> {
throw new IllegalArgumentException("Player not exist. playerId = " + playerId);
});
}
}
private static long countFileLines(String path) throws IOException {
try (Stream<String> stream = Files.lines(Paths.get(path), StandardCharsets.UTF_8)) {
return stream.count();
}
}
private static int scanHand(String playerName) {
while (true) {
System.out.println(MessageFormat.format(SCAN_PROMPT_MESSAGE_FORMAT, playerName));
String inputStr = STDIN_SCANNER.nextLine();
// 有効な文字列だけ受け付ける
if (inputStr.equals(String.valueOf(STONE_NUM))
|| inputStr.equals(String.valueOf(PAPER_NUM))
|| inputStr.equals(String.valueOf(SCISSORS_NUM))) {
return Integer.parseInt(inputStr);
} else {
System.out.println(MessageFormat.format(INVALID_INPUT_MESSAGE_FORMAT, inputStr));
}
}
}
private static void showHandWithName(int hand, String name) {
String handStr;
if (hand == STONE_NUM) {
handStr = STONE_STR;
} else if (hand == PAPER_NUM) {
handStr = PAPER_STR;
} else {
handStr = SCISSORS_STR;
}
System.out.println(MessageFormat.format(SHOW_HAND_MESSAGE_FORMAT, name, handStr));
}
private static void writeJankenDetail(PrintWriter pw,
long jankenDetailId,
long jankenId,
int playerId,
int playerHand,
int playerResult) {
String line = String.join(CSV_DELIMITER,
String.valueOf(jankenDetailId),
String.valueOf(jankenId),
String.valueOf(playerId),
String.valueOf(playerHand),
String.valueOf(playerResult));
pw.println(line);
}
}