じゃんけんアドベントカレンダー の 23 日目です。
今回は、12 日目の記事 で出ていた「じゃんけんする」メソッドがまだあまり整理されていないという課題に取り組みます。
いくつかのテクニックを使って、ドメインモデルをブラッシュアップする内容になります。
「じゃんけんする」メソッドのブラッシュアップ
現状
まずは「じゃんけんする」メソッドの現状のコードを見てみます。
@AllArgsConstructor
@Getter
@EqualsAndHashCode
@ToString
public class Janken {
public static Janken play(String player1Id, Hand player1Hand,
String player2Id, Hand player2Hand) {
// 勝敗判定
Result player1Result;
Result player2Result;
if (player1Hand.wins(player2Hand)) {
player1Result = Result.WIN;
player2Result = Result.LOSE;
} else if (player2Hand.wins(player1Hand)) {
player1Result = Result.LOSE;
player2Result = Result.WIN;
} else {
player1Result = Result.DRAW;
player2Result = Result.DRAW;
}
// ID を生成
val jankenId = generateId();
val jankenDetail1Id = generateId();
val jankenDetail2Id = generateId();
// じゃんけん明細を生成
val detail1 = new JankenDetail(jankenDetail1Id, jankenId, player1Id, player1Hand, player1Result);
val detail2 = new JankenDetail(jankenDetail2Id, jankenId, player2Id, player2Hand, player2Result);
// じゃんけんを生成
val playedAt = LocalDateTime.now();
return new Janken(jankenId, playedAt, detail1, detail2);
}
private static String generateId() {
return UUID.randomUUID().toString();
}
:
JankenExecutor クラスの作成
現状この「じゃんけんする」処理は Janken クラスの static メソッドとして実装しています。
ですが、単純なファクトリでもないこの処理を Janken クラスに置いておくと、Janken クラスの役割が増えて分かりにくくなってしまいます。
そこで、このメソッドを新たに作成した JankenExecutor クラスに移動します。
@Component
public class JankenExecutor {
public Janken play(String player1Id, Hand player1Hand,
String player2Id, Hand player2Hand) {
// 勝敗判定
Result player1Result;
Result player2Result;
if (player1Hand.wins(player2Hand)) {
player1Result = Result.WIN;
player2Result = Result.LOSE;
} else if (player2Hand.wins(player1Hand)) {
player1Result = Result.LOSE;
player2Result = Result.WIN;
} else {
player1Result = Result.DRAW;
player2Result = Result.DRAW;
}
// ID を生成
val jankenId = generateId();
val jankenDetail1Id = generateId();
val jankenDetail2Id = generateId();
// じゃんけん明細を生成
val detail1 = new JankenDetail(jankenDetail1Id, jankenId, player1Id, player1Hand, player1Result);
val detail2 = new JankenDetail(jankenDetail2Id, jankenId, player2Id, player2Hand, player2Result);
// じゃんけんを生成
val playedAt = LocalDateTime.now();
return new Janken(jankenId, playedAt, detail1, detail2);
}
private String generateId() {
return UUID.randomUUID().toString();
}
}
ここから、この JankenExecutor クラスをアップデートしていきます。
HandSelection クラスの作成
まず気になる点としては、play メソッドが引数で playerId と hand の組みを複数受け取っていることです。
public Janken play(String player1Id, Hand player1Hand,
String player2Id, Hand player2Hand) {
これは、playerId と hand の組み合わせに何か意味があることを示唆しています。
playerId と hand の組みは「プレイヤーごとの手の選択」を意味すると考えて、HandSelection というクラスに抽出してみます。
@AllArgsConstructor
@Getter
@EqualsAndHashCode
@ToString
public class HandSelection {
private String playerId;
private Hand hand;
public boolean wins(HandSelection other) {
return hand.wins(other.hand);
}
}
すると、JankenExecutor クラスは以下のようになります。
@Component
public class JankenExecutor {
public Janken play(HandSelection handSelection1,
HandSelection handSelection2) {
// 勝敗判定
Result player1Result;
Result player2Result;
if (handSelection1.wins(handSelection2)) {
player1Result = Result.WIN;
player2Result = Result.LOSE;
} else if (handSelection2.wins(handSelection1)) {
player1Result = Result.LOSE;
player2Result = Result.WIN;
} else {
player1Result = Result.DRAW;
player2Result = Result.DRAW;
}
// ID を生成
val jankenId = generateId();
val jankenDetail1Id = generateId();
val jankenDetail2Id = generateId();
// じゃんけん明細を生成
val detail1 = new JankenDetail(jankenDetail1Id, jankenId, handSelection1, player1Result);
val detail2 = new JankenDetail(jankenDetail2Id, jankenId, handSelection2, player2Result);
// じゃんけんを生成
val playedAt = LocalDateTime.now();
return new Janken(jankenId, playedAt, detail1, detail2);
}
private String generateId() {
return UUID.randomUUID().toString();
}
}
このとき同時に、JankenDetail クラスで playerId と hand を持っていた箇所も、HandSelection に置き換えています。
@AllArgsConstructor
@Getter
@EqualsAndHashCode
@ToString
public class JankenDetail {
private String id;
private String jankenId;
private HandSelection handSelection;
private Result result;
:
任意の人数のじゃんけんに変更
もう少し複雑なクラス設計について考えるため、ここでじゃんけんの対戦を任意の人数に変更してみます。
まず、Janken が保持する JankenDetail を List にします。
@AllArgsConstructor
@Getter
@EqualsAndHashCode
@ToString
public class Janken {
private String id;
private LocalDateTime playedAt;
List<JankenDetail> details;
:
また、JankenExecutor が受け取る引数を List<HandSelection> に変更し、メソッドの内部でも任意の人数のじゃんけんを実施するようにします。
@Component
public class JankenExecutor {
public Janken play(List<HandSelection> handSelections) {
val jankenId = generateId();
val details = play(jankenId, handSelections);
val playedAt = LocalDateTime.now();
return new Janken(jankenId, playedAt, details);
}
private String generateId() {
return UUID.randomUUID().toString();
}
private List<JankenDetail> play(String jankenId, List<HandSelection> handSelections) {
// 人数が 1 人以下の場合はじゃんけんはできない
if (handSelections.size() <= 1) {
throw new IllegalArgumentException("handSelections size must not be less than 1. handSelections = " + handSelections);
}
val selectedHands = handSelections.stream()
.map(HandSelection::getHand)
.distinct()
.collect(Collectors.toList());
// 手が 2 種類でない場合はあいこ
if (selectedHands.size() != 2) {
return handSelections.stream()
.map(hs -> new JankenDetail(jankenId, generateId(), hs, Result.DRAW))
.collect(Collectors.toList());
}
// あいこではない場合
val hand1 = selectedHands.get(0);
val hand2 = selectedHands.get(1);
val winningHand = hand1.wins(hand2) ? hand1 : hand2;
return handSelections.stream()
.map(hs -> {
val result = hs.getHand().equals(winningHand) ? Result.WIN : Result.LOSE;
return new JankenDetail(jankenId, generateId(), hs, result);
})
.collect(Collectors.toList());
}
}
これで任意の人数のじゃんけんを扱えるようになりましたが、JankenExecutor の内部がかなり複雑になってしまいました。
ファーストクラスコレクション
JankenExecutor が複雑な理由は、List<Hand> や List<HandSelection> に対する操作がたくさん書かれていることです。
この問題を解決するため、List<Hand> をラップした Hands クラスを作ります。
@AllArgsConstructor
@Getter
@EqualsAndHashCode
@ToString
public class Hands {
private List<Hand> list;
public boolean isDraw() {
return distinctedHandList().size() != 2;
}
public Hand winningHand() {
if (isDraw()) {
throw new NoSuchElementException("Winning hand not exist. list = " + list);
}
val distinctedHandList = distinctedHandList();
val hand1 = distinctedHandList.get(0);
val hand2 = distinctedHandList.get(1);
return hand1.wins(hand2) ? hand1 : hand2;
}
private List<Hand> distinctedHandList() {
return list.stream().distinct().collect(Collectors.toList());
}
}
同様に、List<HandSelection> をラップした HandSelections クラスも作成します。
@AllArgsConstructor
@Getter
@EqualsAndHashCode
@ToString
public class HandSelections {
public static HandSelections of(HandSelection... elements) {
return new HandSelections(List.of(elements));
}
private List<HandSelection> list;
public boolean existEnoughToPlayJanken() {
return list.size() >= 2;
}
public Hands hands() {
val handList = list.stream()
.map(HandSelection::getHand)
.collect(Collectors.toList());
return new Hands(handList);
}
public <T> List<T> map(Function<HandSelection, ? extends T> f) {
return list.stream()
.map(f)
.collect(Collectors.toList());
}
public boolean isDraw() {
return hands().isDraw();
}
public Hand winningHand() {
return hands().winningHand();
}
}
これらを導入することで、JankenExecutor は以下のようになりました。
@Component
public class JankenExecutor {
public Janken play(HandSelections handSelections) {
val jankenId = generateId();
val details = play(jankenId, handSelections);
val playedAt = LocalDateTime.now();
return new Janken(jankenId, playedAt, details);
}
private String generateId() {
return UUID.randomUUID().toString();
}
private List<JankenDetail> play(String jankenId, HandSelections handSelections) {
if (!handSelections.existEnoughToPlayJanken()) {
throw new IllegalArgumentException("handSelections does not have enough elements. handSelections = " + handSelections);
}
if (handSelections.isDraw()) {
return handSelections.map(hs -> new JankenDetail(generateId(), jankenId, hs, Result.DRAW));
} else {
val winningHand = handSelections.winningHand();
return handSelections.map(hs -> {
val result = hs.handEquals(winningHand) ? Result.WIN : Result.LOSE;
return new JankenDetail(generateId(), jankenId, hs, result);
});
}
}
}
もとのコードと比べると、かなりスッキリしたと思います。
今回導入した HandSelections クラスや Hands クラスは、「ファーストクラスコレクション」と呼ばれます。
あるクラスのコレクションに対する処理をファーストクラスコレクションに移動することで、利用する側のコードはシンプルになりますし、コレクションに対する同じ操作を複数箇所で使いまわせるようになります。
これで、「じゃんけんする」処理の実装をかなり整理できました。
あとはドメインモデルをブラッシュアップする中での考察を書いていこうと思います。
ドメインモデルについての考察
HandSelections を List<JankenDetail> に変換する処理は HandSelections に配置しないのか
JankenExecutor では、以下のように勝敗判定を踏まえて HandSelections を List<JankenDetail> に変換しています。
if (handSelections.isDraw()) {
return handSelections.map(hs -> new JankenDetail(generateId(), jankenId, hs, Result.DRAW));
} else {
val winningHand = handSelections.winningHand();
return handSelections.map(hs -> {
val result = hs.handEquals(winningHand) ? Result.WIN : Result.LOSE;
return new JankenDetail(generateId(), jankenId, hs, result);
});
}
この処理は HandSelections クラスのメソッドとして実装することもできます。
その場合、上記のコードは例えば以下のようになります。
val jankenDetails = handSelections.calculateResult(jankenId)
こうすることで、たしかに JankenExecutor クラスのコードはより整理されますが、「HandSelections が JankenDetail に依存し、JankenDetail が HandSelection に依存する」という双方向依存になってしまいます。
この双方向依存を避けるため、HandSelections を List<JankenDetail> に変換する処理は JankenExecutor クラスに書いています。
HandSelection とは何だったのか
今回はメソッドの引数に注目して、ボトムアップ的に HandSelection というクラスを見出しました。
冷静に考えてみると、じゃんけんするときは「手を選ぶ」->「じゃんけんする」という流れで進んでいくはずで、この HandSelection というクラスはそのことを表している概念だとも考えられます。
じゃんけんについて、当初は以下のように整理していたのが、
実は以下のような整理が適切だったというイメージです。
より複雑な構成
今回は「手を選ぶ」->「じゃんけんする」という流れを踏まえたモデルになりましたが、仮にオンラインで対戦するじゃんけんゲームを考えると、例えば
- 対戦用の部屋にエントリーする
- 参加者が揃うのを待つ
- 各自が手を選ぶ
- 全員が手を選んだらじゃんけんする
- 結果を表示する
といった流れになったりすると思います。
その場合は、例えば以下のような構成になるのかもしれません。
データモデル
今回 HandSelection クラスを作成しましたが、データモデルとしては players、jankens、janken_details の 3 テーブルのみのままになっています。
HandSelection を追加する際に、データモデル上にも hand_selections テーブルを追加した方がいいのでしょうか ?
HandSelection と Janken や JankenDetail を別々に永続化する (つまり集約が異なる) なら、テーブルも分割した方が良いと思います。
データモデルとしても保存タイミングが表現されますし、NOT NULL 制約もつけやすくなります。
一方、今回のように HandSelection が Janken や JankenDetail と同時に永続化される場合は、結構悩ましいです。
今後も HandSelection が Janken や JankenDetail と同時に永続化されることが確実なら、あえて janken_details テーブルを設けなくてもいいかもしれません。
ですが、今後のアップデートで、「手の選択のタイミングで一度保存し、全員の手が決まったらじゃんけんする」といった流れになる可能性があるのであれば、テーブルは分割しておいた方がいいかもしれません。
アプリケーションと異なり、データベースのリファクタリングは本番リリース後に無停止で実施するのは非常に大変です。
可能な限り無停止で運用したいシステムの場合は特に、あらかじめテーブルを分割しておくと嬉しいことがあるかもしれません。
この点については、じゃんけんアプリケーションのユースケースなどをもっと明確にしないと決めにくい気もするので、検討はこの程度にしておきます。
次回のテーマ
今回はいくつかの手法を使ってドメインモデルをブラッシュアップしました。
さらにブラッシュアップしたい場合は、例えば DDD の Value Object などのプラクティスが適用できます。
これで、後回しにしていたアプリケーション設計の検討事項に一通り対応できました。
次回は今更ながら開発環境を整備したり、エラーハンドリングなどのアプリケーションの整備を実施しようと思います。
それでは、今回の記事はここまでにします。最後まで読んでくださりありがとうございました。
じゃんけんアドベントカレンダー に興味を持ってくださった方は、是非購読お願いします。
現時点のコード
コードは GitHub の この時点のコミット を参照ください。