会議室を背景に、予約の開始と終了の間に一つの時間枠を置いたイラスト

設計・テスト・レビュー

Javaの引数をクラスにまとめる判断|予約の開始・終了時刻を例に

この記事でわかること

Javaのメソッドへ開始・終了時刻を毎回渡している処理を、小さなクラスにまとめます。同じ入力で分割前後を比較し、時刻の成立条件と会議室ごとの予約ルールをどこに置くか判断します。

新人SE
新人SE
予約の時間を計算するたびに開始と終了を渡しています。重なりを調べるメソッドは時刻が4つあって、どれがどの予約か読みにくくなりました。
ポンコツSE
ポンコツSE
開始と終了がセットで動く理由を見ましょう。どの処理でも開始が終了より前なら、その約束を値の置き場所で守れます。

会議室の予約画面に「利用時間」と「既存の予約との重なり」を出すとします。初めは、メソッドへ時刻を二つずつ渡せば動きます。次に、別の画面にも同じ処理を足すことになりました。呼ぶ場所が増え、startとendをいつも正しく組み合わせて渡す必要が出てきます。

対象は、Javaの変数、メソッド、newを読める人です。開始と終了を別々に渡す版を見てから、予約枠としてまとめ、会議室ごとの条件を別の場所に置くところまで試します。

関連記事Java初心者におすすめの最初の1冊|Spring Bootに進むまでの学び方 メソッドやクラスの読み方を先に練習したいとき、最初の本と次へ進む目安を確認できます。 記事を読む

実行サンプル

分割前と後のJavaコード、予約上限を判定するコード、JUnitのテスト、pom.xmlとREADMEを収録。JDK 17とMavenで、展開したディレクトリから mvn clean test を実行できます。

開始・終了の2値に、どの約束があるか決める

この練習は、同じ日付にある会議室の時刻枠だけを扱います。開始・終了はどちらも必須で、開始は終了より前。開始と終了が同じ予約や、終了のほうが早い予約は拒否します。日付をまたぐ予約も扱うなら、値をLocalDateTimeなどに変え、日付を含めて前後関係を判定します。

既存の予約調べる予約期待結果
09:00〜10:0009:00〜10:00重なる
09:00〜10:0009:30〜10:30重なる
09:00〜10:0010:00〜11:00重ならない
09:00〜10:0010:00〜09:00前後関係が逆なので、枠を作れない

10:00ちょうどに前の会議が終われば、次の会議は10:00から始められます。ここでは終了時刻を予約枠に含めない、という約束です。重なりを判定するときに「より前」のisBeforeを使う理由になります。

分割前は、呼ぶたびに開始と終了の組を渡す

次はReservationChecks.javaの全文です。利用時間は2引数、重なりは比較対象も含めて4引数。チェックはすでにprivateメソッドに集約してあるので、判定式を直す場所は一つです。メソッドごとに、受け取った組の検証から処理を始めています。

package example.booking;

import java.time.Duration;
import java.time.LocalTime;

final class ReservationChecks {
    static Duration duration(LocalTime start, LocalTime end) {
        requireSameDaySlot(start, end);
        return Duration.between(start, end);
    }

    static boolean overlaps(LocalTime start, LocalTime end,
                            LocalTime otherStart, LocalTime otherEnd) {
        requireSameDaySlot(start, end);
        requireSameDaySlot(otherStart, otherEnd);
        return start.isBefore(otherEnd) && otherStart.isBefore(end);
    }

    private static void requireSameDaySlot(LocalTime start, LocalTime end) {
        if (start == null || end == null || !start.isBefore(end)) {
            throw new IllegalArgumentException("開始時刻は終了時刻より前にしてください");
        }
    }
}

例えば、申請枠と既存枠を比較する呼び出しでoverlaps(start, otherEnd, otherStart, end)と書いても、引数の型は全てLocalTimeなのでコンパイルは通ります。たとえば09:00〜11:00と10:00〜12:00の終了を逆に渡すと、09:00〜12:00と10:00〜11:00という別の組になります。どちらも開始が終了より前なので、検証を通ります。引数の数だけで決めず、同じ二つの値がいつもセットで渡るか、セットとして検証したい条件があるかを見ます。

TimeSlotを作るときに、開始と終了を検証する

TimeSlot.javaでは、startとendを一つの時刻枠として持ちます。recordのコンストラクタで、二つの値が揃い、開始が終了より前かを確認します。成立しないときは例外になり、その組を後続の処理へ渡すためのTimeSlotは作れません。

package example.booking;

import java.time.Duration;
import java.time.LocalTime;

public record TimeSlot(LocalTime start, LocalTime end) {
    public TimeSlot {
        if (start == null || end == null || !start.isBefore(end)) {
            throw new IllegalArgumentException("開始時刻は終了時刻より前にしてください");
        }
    }

    public Duration duration() {
        return Duration.between(start, end);
    }

    public boolean overlaps(TimeSlot other) {
        return start.isBefore(other.end()) && other.start().isBefore(end);
    }
}

呼び出し側では、申請用と既存予約用のTimeSlotを先に一つずつ作ります。以降はcandidate.duration()で申請枠の長さ、candidate.overlaps(reserved)で既存枠との重なりを確認します。時刻を4つ並べ直す箇所が減るので、変更したい処理と対象の枠を読み分けやすくなります。

組の作成時にどの画面の値を使うかは、呼び出し側の責務です。画面から受け取った文字列なら、呼び出し側で時刻に変換してからTimeSlotを作ります。入力欄に何と表示するかも画面側で決めます。ここに集めるのは、どこから作っても守る開始・終了の条件です。

会議室ごとの上限と既存予約は、RoomScheduleで見る

09:00〜10:30という90分の枠は成立します。ところが、上限60分の会議室では申請できず、上限120分なら空いているときに申請できます。上限や既存予約は、同じ枠でも選ぶ会議室によって変わります。そこでRoomScheduleは会議室の上限と既存の枠を受け取り、canReserveが候補の長さと重なりを確認します。

package example.booking;

import java.time.Duration;
import java.util.List;

final class RoomSchedule {
    private final Duration maxDuration;
    private final List<TimeSlot> reserved;

    RoomSchedule(int maxMinutes, List<TimeSlot> reserved) {
        if (maxMinutes <= 0) {
            throw new IllegalArgumentException("予約上限は1分以上にしてください");
        }
        this.maxDuration = Duration.ofMinutes(maxMinutes);
        this.reserved = List.copyOf(reserved);
    }

    boolean canReserve(TimeSlot candidate) {
        if (candidate.duration().compareTo(maxDuration) > 0) {
            return false;
        }
        return reserved.stream().noneMatch(candidate::overlaps);
    }
}

レビューでは、こう確認できます。「90分の枠は別の会議室でも使うので、TimeSlotの作成時に60分を上限にすると、120分まで使える会議室にも渡せなくなります。上限はRoomScheduleへ渡す設定値にしませんか?」部屋の上限だけ変わったら、その部屋の設定から渡す分数を変えます。開始・終了の前後関係を直す必要はありません。

この例で確認するのは、渡された既存予約に対して空きがあるかまでです。二人が同時に同じ枠を押さえる機能では、予約を確定する時点で競合を防ぐ処理が別に必要です。

同じ入力を使い、変更前後の振る舞いを確認する

TimeSlotTest.javaの9件は、時間の計算、無効な組、重なりを扱います。リファクタリング前後で同じ答えになることを確認しつつ、無効な組を拒否するタイミングだけはメソッド呼び出しからコンストラクタへ早まります。RoomScheduleTest.javaの3件は、部屋ごとの上限、上限ちょうどの枠、既存枠との重なりです。

TimeSlotTest.javaの全文を見る
package example.booking;

import java.time.Duration;
import java.time.LocalTime;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

class TimeSlotTest {
    @Test
    void preservesTheDuration() {
        LocalTime start = LocalTime.parse("09:00");
        LocalTime end = LocalTime.parse("10:00");

        assertEquals(Duration.ofHours(1), new TimeSlot(start, end).duration());
        assertEquals(Duration.ofHours(1), ReservationChecks.duration(start, end));
    }

    @ParameterizedTest
    @CsvSource({
        "10:00, 09:00",
        "09:00, 09:00",
        ", 10:00",
        "09:00, "
    })
    void rejectsInvalidPairsAtConstruction(String startText, String endText) {
        LocalTime start = parseNullable(startText);
        LocalTime end = parseNullable(endText);

        assertThrows(IllegalArgumentException.class, () -> new TimeSlot(start, end));
        assertThrows(IllegalArgumentException.class, () -> ReservationChecks.duration(start, end));
    }

    @ParameterizedTest
    @CsvSource({
        "08:30, 09:00, false",
        "09:30, 10:30, true",
        "10:00, 11:00, false",
        "09:00, 10:00, true"
    })
    void preservesTheOverlapRule(String startText, String endText, boolean expected) {
        LocalTime start = LocalTime.parse("09:00");
        LocalTime end = LocalTime.parse("10:00");
        LocalTime otherStart = LocalTime.parse(startText);
        LocalTime otherEnd = LocalTime.parse(endText);

        assertEquals(expected, new TimeSlot(start, end).overlaps(new TimeSlot(otherStart, otherEnd)));
        assertEquals(expected, ReservationChecks.overlaps(start, end, otherStart, otherEnd));
    }

    private LocalTime parseNullable(String value) {
        return value == null ? null : LocalTime.parse(value);
    }
}
RoomScheduleTest.javaの全文を見る
package example.booking;

import java.time.LocalTime;
import java.util.List;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

class RoomScheduleTest {
    @Test
    void anIdenticalSlotMayPassInOneRoomAndFailInAnother() {
        TimeSlot candidate = new TimeSlot(LocalTime.of(9, 0), LocalTime.of(10, 30));

        assertFalse(new RoomSchedule(60, List.of()).canReserve(candidate));
        assertTrue(new RoomSchedule(120, List.of()).canReserve(candidate));
    }

    @Test
    void exactLimitAndAdjacentReservationAreAllowed() {
        TimeSlot candidate = new TimeSlot(LocalTime.of(9, 0), LocalTime.of(10, 0));
        TimeSlot reserved = new TimeSlot(LocalTime.of(10, 0), LocalTime.of(11, 0));

        assertTrue(new RoomSchedule(60, List.of(reserved)).canReserve(candidate));
    }

    @Test
    void rejectsOverlapEvenWhenTheCandidateFitsTheTimeLimit() {
        TimeSlot candidate = new TimeSlot(LocalTime.of(9, 0), LocalTime.of(10, 0));
        TimeSlot reserved = new TimeSlot(LocalTime.of(9, 30), LocalTime.of(10, 30));

        assertFalse(new RoomSchedule(60, List.of(reserved)).canReserve(candidate));
    }
}
pom.xmlの全文を見る
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>example</groupId>
  <artifactId>java-time-slot-class-design</artifactId>
  <version>1.0.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.12.2</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.14.1</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.3</version>
      </plugin>
    </plugins>
  </build>
</project>

配布ZIPを展開し、pom.xmlのあるディレクトリでmvn clean testを実行すると、12件が成功します。TimeSlot.overlapsで終了と次の開始が同じ場合まで重なりに含めると、隣り合う枠を許可するテストが失敗します。画面を作る前に、この境界を決めておけば変更時に戻り方が分かります。

自分のコードで、まとめる場所を決める

  • 同じ値の組が、複数のメソッドの引数に一緒に出ているか。
  • 二つを組にしたとき、どの呼び出し元でも守る前提を言えるか。
  • その前提と、画面・利用者・会議室によって変わる条件を分けられるか。
  • まとめる前後で同じ結果を返すケースと、エラーを返す位置が変わるケースを区別できるか。

値と処理の置き場所を、別の題材でも練習するなら

クラスを増やす基準を、金額や状態など別の値でも考えたい人には『改訂新版 良いコード/悪いコードで学ぶ設計入門』を紹介します。この例では開始・終了の前後関係を一か所に置きました。次は自分のコードの引数を二つ選び、別々のまま扱う場合とクラスで持つ場合で、次の仕様変更がどこに及ぶかを比べてみてください。

書籍「改訂新版 良いコード/悪いコードで学ぶ設計入門」の表紙
商品画像:Amazon.co.jp
PR Java新人・若手向け

改訂新版 良いコード/悪いコードで学ぶ設計入門

仙塲 大也 (著)

レビューで直される理由を、Javaの具体例から理解する。

命名、責務、クラス設計、不変性などを良い例と悪い例で比較できます。現場のコードレビューと結びつけやすい一冊です。

  • 設計の悪さを具体例から学びたい
  • レビュー指摘の判断軸を増やしたい

当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。

関連記事Javaのクラス設計は何から学ぶ?最初の1冊と「どこに書くか」の考え方 料金と表示の例を読み、次の変更をどこに置くかを考えながら設計の本を選べます。 記事を読む 関連記事Javaのメソッド分割の基準|privateに切るか、別クラスにするか 長くなったメソッドで、privateへ切り出す段階と別クラスにする段階をCSV取込で比べます。 記事を読む

まとめ

開始・終了のようにいつも一緒に渡す値に、共通の成立条件があるなら、その組に名前を付けてクラスへまとめます。どの会議室でも守る時刻の関係はTimeSlot、部屋の上限と既存予約はRoomSchedule。次の変更がどちらの約束を変えるのか決めると、直すコードとテストを選べます。

-設計・テスト・レビュー
-, , ,