プロジェクト

全般

プロフィール

Spring Web Application Tutorial

書籍「Spring徹底入門 第2版」の14章 チュートリアルで作成する「会議室予約システム」の内容を実践した技術メモです。

会議室予約システム概要

RDBMSをストレージとするWebアプリケーションです。Spring Frameworkで構成します。
開発には、Spring Boot と Spring MVCを主とし、データベースアクセスにはSpring JPA、画面は Thymeleaf、認証にはSpring Securityを使います。

環境

書籍より新しいバージョンを使います。また、IDEは書籍では Eclipse STSですが、本記事では IntelliJ IDEA Ultimatedを使います。

MacBook

  • OS: macOS 26
  • Java: JDK 25 (Liberica JDK 25)
  • IDE: IntelliJ IDEA Ultimated
  • RDBMS: PostgreSQL 18

PostgreSQL 18の設定

書籍記載にあるデータベース・ユーザーを作成します。

チュートリアル実践

プロジェクト作成

会議室予約システムの開発プロジェクトを作成します。

  • IntelliJ IDEAで[ファイル]メニュー > [新規] > [プロジェクト] で「新規プロジェクト」ウィンドウを開く
  • 左ペインで[Spring Boot]を選択、右ペインで、[名前]にmrsを記入、[言語]にJavaを選択、[型]にGradle-Kotlinを選択、グループはcom.torutk.spring、アーティファクトはmrsを記載、名前とパッケージ名は自動で生成されます。JDKは25を選択、Javaは25、パッケージ化はJarを選択

  • [次へ]をクリックし、使用するSpring Frameworkのコンポーネントにチェックを付ける

ここで使用すると設定したコンポーネントは、Spring Data JPA, PostgreSQL Driver, Tymeleaf, Spring Webです。
書籍のチュートリアルでは、この後、Spring ValidationとSpring Securityの依存を追加していきます。

Spring Boot 3.5.7を指定した場合、依存関係を辿ると次が使われます。

  • Spring Data JPA 3.5.5
  • Spring Web 6.2.12
  • Spring Validation 8.0.3
  • Spring Security 6.5.6

パッケージディレクトリの作成

会議室予約システムのアプリケーションは次のパッケージ構成を取ります。

  • ドメイン層(domain)
    • モデル(model)
    • リポジトリ(repository)
      • reservation
      • room
      • user
    • サービス(service)
      • reservation
      • room
      • user
  • アプリケーション層(app)
    • ログイン(login)
    • 予約(reservation)
    • 会議室(room)

生成された雛形のビルド

Spring JPAを利用するプロジェクトを作成し、生成された雛形のままビルドをすると、エラーになってしまいます。

Failed to determine a suitable driver class

アプリケーション設定

使用するデータベースの種類、接続先の設定をアプリケーション設定ファイルに記述します。

  • src/main/resources/application.properties
spring.jpa.database=postgresql
spring.datasource.url=jdbc:postgresql://localhost:5432/mrs
spring.datasource.username=mrs
spring.datasource.password=mrs
spring.jpa.hibernate.ddl-auto=validate // ②
spring.jpa.properties.hibernate.format_sql=true
spring.sql.init.encoding=UTF-8
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE

書籍のチュートリアルでは上述のようにデータベース接続名、ユーザー名、パスワードをアプリケーション設定ファイルに記述しています。実践では、設定ファイルはリポジトリに登録され関係者なら参照できるので、セキュリティ上ユーザー名/パスワードは別な手段で提供するべききでしょう。また、接続先は環境によって変わるので、外部(環境変数や他の手段)から提供するのがベターでしょう。

  • ② JPA(Hibernate)では、JavaのエンティティクラスからDDLを生成しデータベースにテーブルを作成する機能がありますが、validateを指定するとその機能を抑制します。このサンプルでは、後の手順でテーブル作成のSQLファイルを用意して実行します。validateはエンティティクラスとテーブル構造の矛盾があるかを検証する指定となります。

データベースの構成

PostgreSQLに会議室予約システムのデータベースを構築します。

ユーザーとデータベースの作成

今回インストールしたPostgreSQLは、デフォルトでは同一ホストのユーザーからの接続をtrustで受け入れるので、psqlコマンドで同一マシンから接続してユーザーとデータベースを作成します。

~$ psql -d postgres
[postgres=# CREATE USER mrs PASSWORD 'mrs' ;
CREATE ROLE
[postgres=# CREATE DATABASE mrs WITH OWNER=mrs;
CREATE DATABASE
[postgres=# 

  • ユーザーを作成するSQLは、書籍では CREATE ROLE mrs LOGIN ... ですが、CREATE USER と同じ(エイリアス)なのでここでは後者を使用して作成しました。
  • ユーザーのパスワード指定時、書籍では ENCRYPTED PASSWORD 'md58608...7ce8' と指定しています。しかし PostgreSQL 10頃から以降はパスワードがハッシュ化され保存されるのでENCRYPTEDは意味をなさないとあります。パスワード文字列に先頭 md5 を指定し続いてパスワードのmd5ハッシュ化文字列(32桁)を指定すると、そのまま保存されます。
  • ユーザーの作成時、書籍ではオプション NOSUPERUSER, INHERIT, NOCREATEDB, NOCREATEROLE, NOREPLICATION を指定していますが、これらは未指定時のデフォルトなので上述のコマンド実行例では省略しました。
  • データベースの作成時、書籍ではオプション ENCODING、TABLESPACE、LC_COLLATE、LC_TYPE、CONNECTION LIMIT を指定していますが、これらは デフォルト(template1 データベースと同じ)であるので上述では省略しました。

テーブル設計

ER図

erDiagram meeting_room ||--|{ reservable_room : 日別予約管理 reservable_room ||--|{ reservation : 予約 usr ||--|{ reservation : 予約 meeting_room { int room_id FK "serial" varchar(255) room_name } reservable_room { date reserved_date PK int room_id PK, FK } reservation { int reservation_id PK "serial" time start_time time end_time date reserved_date FK int room_id FK varchar(255) user_id FK } usr { varchar(255) user_id PK varchar(255) first_name varchar(255) last_name varchar(255) password varchar(255) role_name }

テーブル作成

usrテーブル
CREATE TABLE usr (
    user_id VARCHAR(255) PRIMARY KEY,
    first_name VARCHAR(255) NOT NULL,
    last_name VARCHAR(255) NOT NULL,
    password VARCHAR(255) NOT NULL,
    role_name VARCHAR(255) NOT NULL,
);

PRIMARY KEYを指定すると、NOT NULL制約も自動で付与されます。

meeting_roomテーブル
CREATE TABLE meeting_room (
    room_id SERIAL PRIMARY KEY,
    room_name VARCHAR(255) NOT NULL
);
reservable_roomテーブル
CREATE TABLE reservable_room (
    reserved_date DATE NOT NULL,
    room_id INT4 NOT NULL REFERENCES meeting_room (room_id),
    PRIMARY KEY (reserved_date, room_id)
);

プライマリキーが複合キーの場合は、2つ以上のカラムにPRIMARY KEYを指定することができないため、カラムとは別に制約の定義の一つとしてプライマリキーを指定します。

reservationテーブル
CREATE TABLE reservation (
    reservation_id SERIAL PRIMARY KEY,
    end_time TIME NOT NULL,
    reserved_date DATE NOT NULL,
    room_id INT4 NOT NULL,
    user_id VARCHAR(255) NOT NULL REFERENCES usr (user_id),
    FOREIGN KEY (reserved_date, room_id) REFERENCES reservable_room (reserved_date, room_id)
);

reservable_room は複合キーを主キーとしているので、reservationテーブルの個別のカラムに外部キーを指定することができず、FOREIGN KEYでまとめて指定します。

DDLスクリプト

CREATE TABLEの記述をしたschema.sqlファイルを、src/main/resources/下に保存します。

初期データの作成

データベースにテーブル構造を作成した後、サンプルデータをテーブルにインサートします。

DMLスクリプト

src/main/resources/下に、サンプルデータをインサートする data.sql ファイルを作成します。

INSERT INTO meeting_room (room_name) VALUES
    ('新木場'), ('辰巳'), ('豊洲'), ('月島'),
    ('新富町'), ('銀座一丁目'), ('有楽町');

INSERT INTO reservable_room (reserved_date, room_id) VALUES
    (CURRENT_DATE, 1),
    (CURRENT_DATE + 1, 1),
    (CURRENT_DATE - 1, 1),
    (CURRENT_DATE, 7),
    (CURRENT_DATE + 1, 7),
    (CURRENT_DATE - 1, 7);

INSERT INTO usr (user_id, first_name, last_name, password, role_name) VALUES
    ('taro-yamada', '太郎', '山田', '$2b$12$gDUhukLrHkEctqDCXaRZVeCkOYxxqQYIlRiRreV8XkdYziT1X0Vdu', 'USER'),
    ('alfa', 'Alfa', 'phonetic', '$2b$12$gDUhukLrHkEctqDCXaRZVeCkOYxxqQYIlRiRreV8XkdYziT1X0Vdu', 'USER'),
    ('bravo', 'Bravo', 'phonetic', '$2b$12$gDUhukLrHkEctqDCXaRZVeCkOYxxqQYIlRiRreV8XkdYziT1X0Vdu', 'USER'),
    ('charlie', 'Charlie', 'phonetic', '$2b$12$gDUhukLrHkEctqDCXaRZVeCkOYxxqQYIlRiRreV8XkdYziT1X0Vdu', 'ADMIN');

書籍のサンプルデータは、実行した日とその前後1日を期間とする予約情報です。
データベースにパスワードを格納するときは、セキュリティ上の理由から、平文ではなくパスワードのハッシュ値を格納します。ここでハッシュ値のアルゴリズムには、同じパスワード文字列から同じハッシュ値が生成されないもので、ハッシュ計算に時間を要するものを使用するとより安全です。
書籍では、パスワードカラムにはbcryptハッシュコードを入れています(Spring Securityのデフォルト)。

bcryptのハッシュ値を取得する方法(Python)

Spring Bootプロジェクト(Webアプリケーション)に、コマンドライン用のクラスを追加しGradleでビルド実行するのがうまくいかなかったので、pythonで実行しました。

work$ python3 -m venv venv
work$ . venv/bin/activate
(venv)work$ pip install bcrypt
  :
(venv)work$ python
>>> import bcrypt
>>> hash = bcrypt.hashpw(b"demo", bcrypt.getsatl())
>>> hash
b'$2b$12$gDUhukLrHkEctqDCXaRZVeCkOYxxqQYIlRiRreV8XkdYziT1X0Vdu'

起動時にSQLスクリプトを実行

application.propertiesに、次の設定を追加すると、SpringBootアプリケーションを起動するたびにテーブル作成としてschema.sqlファイルを実行し、データ設定としてdata.sqlファイルを実行します。

spring.sql.init.mode=always

エンティティクラスの作成

JPAのエンティティクラスを作成します。RDBMSのテーブルに対応するエンティティクラスを定義します。

No. テーブル名 エンティティクラス名
1 usr User
2 meeting_room MeetingRoom
3 reservable_room ReservableRoom
4 reservation Reservation

また、usrテーブルのrole_nameは varchar(255)型ですがJavaの列挙型に対応付けたいので、enum型のRoleNameを作成します。
reservationテーブルの主キーは複合キーなので、複合キーを表すJavaのクラス ReservableRoomIdを作成します。

No. データベースの要素 Javaのクラス
1 usr.roleName enum RoleName
2 reservationテーブルのPRIMARY KEY ReservableRoomId

SpringBoot・Hibernateでの命名規約

Spring Boot のドキュメントによると、Hibernateを使用したSpring Data JPAでは命名規約として CamelCaseToUnderscoresNamingStrategyを使用するとあります。
https://spring.pleiades.io/spring-boot/how-to/data-access.html#howto.data-access.configure-hibernate-naming-strategy

例は次です。
  • User -> user
  • ReservableRoom -> reservable_room

この命名規約に従わない場合、独自のNamingStrategyを作成するか、エンティティクラスの実装で個別にアノテーションでマッピング名を記述するこかになります。

サンプルでは、この命名規約から外れているUserクラスにアノテーションでテーブル名を指定しています。

エンティティクラスの実装

Userクラス
package com.torutk.spring.mrs.domain.model;

import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

import java.io.Serializable;

@Entity
@Table(name = "usr")
public class User implements Serializable {
    @Id
    private String userId;
    private String password;
    private String firstName;
    private String lastName;
    @Enumerated(EnumType.STRING)
    private RoleName roleName;

    public String getUserId() {
        return userId;
    }

    public void setUserId(String userId) {
        this.userId = userId;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public String getFirstName() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }

    public RoleName getRoleName() {
        return roleName;
    }

    public void setRoleName(RoleName roleName) {
        this.roleName = roleName;
    }
}
  • エンティティクラスには、@Entityアノテーションを付与
  • デフォルトのマッピング名(Userクラス->user)とデータベースのテーブル名usrが一致しないので、@Tableアノテーションでテーブル名を指定
    チュートリアル上わざと違う場合を例示していると思われる
  • フィールド名は、データベースのカラム名と暗黙のマッピングができる名前を指定
  • データベースのrole_nameカラムは文字列(varchar)だが、Javaでは列挙型で扱いたいので@Enumeratedアノテーションをつけて型を列挙型(RoleName)としている
  • エンティティクラスは、デフォルトコンストラクタとカラムに対応するフィールドのgetter/setterを定義
RoleNameクラス(列挙型)
package com.torutk.spring.mrs.domain.model;

public enum RoleName {
    ADMIN, USER
}
MeetingRoomクラス
package com.torutk.spring.mrs.domain.model;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

import java.io.Serializable;

@Entity
public class MeetingRoom implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer roomId;
    private String roomName;

    public Integer getRoomId() {
        return roomId;
    }

    public void setRoomId(Integer roomId) {
        this.roomId = roomId;
    }

    public String getRoomName() {
        return roomName;
    }

    public void setRoomName(String roomName) {
        this.roomName = roomName;
    }
}

  • エンティティクラス名からデフォルトのマッピング規則(MeetingRoom -> meeting_room)でテーブル meeting_room に対応する
  • 主キーのカラムに対応するフィールド roomId には、主キーを示す@Idアノテーションと、主キーを生成する方法を指定する@GeneratedValueアノテーション(IDENTITY)でデータベースに任せる生成をすることを示す
ReservableRoomクラス
package com.torutk.spring.mrs.domain.model;

import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapsId;

import java.io.Serializable;

@Entity
public class ReservableRoom implements Serializable {
    @EmbeddedId
    private ReservableRoomId reservableRoomId;

    @ManyToOne
    @JoinColumn(name = "room_id", insertable = false, updatable = false)
    @MapsId("roomId")
    private MeetingRoom meetingRoom;

    public ReservableRoom(ReservableRoomId reservableRoomId) {
        this.reservableRoomId = reservableRoomId;
    }

    public ReservableRoom() {
    }

    public ReservableRoomId getReservableRoomId() {
        return reservableRoomId;
    }

    public void setReservableRoomId(ReservableRoomId reservableRoomId) {
        this.reservableRoomId = reservableRoomId;
    }

    public MeetingRoom getMeetingRoom() {
        return meetingRoom;
    }

    public void setMeetingRoom(MeetingRoom meetingRoom) {
        this.meetingRoom = meetingRoom;
    }
}

  • 複合キーを使用するので、複合キーを表すクラス ReservableRoomIdを定義しておき、この型のフィールドで@EmbeddedIdを付与
  • MeetingRoomエンティティと1対多の関係を持つので、フィールドにMeetingRoom型の変数を定義、@ManyToOneアノテーションを付与、@MapsIdを付与し、外部参照で関連に使用するroom_idから取得したMeetingRoomを保持
  • room_idはこのエンティティからは変更不可とする(@JoinColumnアノテーションで、insertableとupdatableをともにfalse)
ReservableRoomIdクラス(複合キー)

ReservableRoomエンティティは複合キーによるテーブルを表すので、複合キーを表現するクラスを定義します。

エンティティクラスより長い実装となっていますが、オブジェクト同士の等値を判定する hashCodeとequalsメソッドを実装している他は、2つのフィールドを持つ一般的なクラスの定義です。

package com.torutk.spring.mrs.domain.model;

import jakarta.persistence.Embeddable;

import java.io.Serializable;
import java.time.LocalDate;

@Embeddable
public class ReservableRoomId implements Serializable {

    private Integer roomId;
    private LocalDate reservedDate;

    public ReservableRoomId(Integer roomId, LocalDate reservedDate) {
        this.roomId = roomId;
        this.reservedDate = reservedDate;
    }

    public ReservableRoomId() {
    }

    @Override
    public int hashCode() {
        final int prime = 31;
        int result = 1;
        result = prime * result + ((reservedDate == null) ? 0 : reservedDate.hashCode());
        result = prime * result + ((roomId == null) ? 0 : roomId.hashCode());
        return result;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj == null) {
            return false;
        }
        if (getClass() != obj.getClass()) {
            return false;
        }
        ReservableRoomId other = (ReservableRoomId) obj;
        if (reservedDate == null) {
            if (other.reservedDate != null) {
                return false;
            }
        }  else if (!reservedDate.equals(other.reservedDate)) {
            return false;
        }
        if (roomId == null) {
            if (other.roomId != null) {
                return false;
            }
        } else if (!roomId.equals(other.roomId)) {
            return false;
        }
        return true;
    }

    public Integer getRoomId() {
        return roomId;
    }

    public void setRoomId(Integer roomId) {
        this.roomId = roomId;
    }

    public LocalDate getReservedDate() {
        return reservedDate;
    }

    public void setReservedDate(LocalDate reservedDate) {
        this.reservedDate = reservedDate;
    }
}

equalsメソッドのより簡潔な実装

2つのフィールドの値が等しければ、equalsの結果をtrueとする実装は、java.util.Objectsクラスのequalsメソッドを利用すると、より簡潔に記述できます。

    @Override
    public boolean equals(Object o) {
        if (o == null || getClass() != o.getClass()) return false;
        ReservableRoomId that = (ReservableRoomId) o;
        return Objects.equals(roomId, that.roomId) && Objects.equals(reservedDate, that.reservedDate);
    }

Objects#equalsメソッドは2つの引数を取り、2つの引数がnull同士の場合を含めて等しいときはtrueを返します。

hashCodeメソッドのより簡潔な実装

2つのフィールドのhashCode()の値からhashCodeを生成します。java.util.Objectsクラスのhashメソッドを利用すると、簡潔に記述できます。

    @Override
    public int hashCode() {
        return Objects.hash(roomId, reservedDate);
    }
Reservationクラス
package com.torutk.spring.mrs.domain.model;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinColumns;
import jakarta.persistence.ManyToOne;

import java.io.Serializable;
import java.time.LocalTime;

@Entity
public class Reservation implements Serializable {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer reservationId;
    private LocalTime startTime;
    private LocalTime endTime;

    @ManyToOne
    @JoinColumns({@JoinColumn(name = "reserved_date"),
            @JoinColumn(name = "room_id") })
    private ReservableRoom reservableRoom;

    @ManyToOne
    @JoinColumn(name = "user_id")
    private User user;

    public Integer getReservationId() {
        return reservationId;
    }

    public void setReservationId(Integer reservationId) {
        this.reservationId = reservationId;
    }

    public LocalTime getStartTime() {
        return startTime;
    }

    public void setStartTime(LocalTime startTime) {
        this.startTime = startTime;
    }

    public LocalTime getEndTime() {
        return endTime;
    }

    public void setEndTime(LocalTime endTime) {
        this.endTime = endTime;
    }

    public ReservableRoom getReservableRoom() {
        return reservableRoom;
    }

    public void setReservableRoom(ReservableRoom reservableRoom) {
        this.reservableRoom = reservableRoom;
    }

    public User getUser() {
        return user;
    }

    public void setUser(User user) {
        this.user = user;
    }
}

アプリケーションの構造

リポジトリ及びサービスの定義

Spring Data JPAにおいて、JPAのEntity Managerをラップしてデータアクセスを簡潔にするため、エンティティ毎にRepositoryインタフェースを作成します。Spring Data JPAは、各Repositoryインタフェースの定義からProxyを作成しDIコンテナにBeanとして登録し、アプリケーション(サービスクラス)はProxyをDIして利用します。

リポジトリインタフェースにCRUD操作を実現するメソッドを定義します。2つの方法があります。

  • @QueryアノテーションにJPQL文を記述し、メソッドに付与
  • メソッド名からクエリを生成

メソッド名からの生成は、命名規約に基づいてメソッド名を定義します。次のパターンに該当するメソッドをリポジトリインタフェースに定義すると、メソッド名からJPQLのSELECT文が生成されます。By以降はSELECT文のWHERE句に該当するエンティティのプロパティ名を指定します。複数条件の指定も、And、Orその他をメソッド名に指定することで可能です。

  • find‥By
  • read‥By
  • query‥By
  • count‥By
  • get‥By
  • search‥By
  • stream‥By

コントローラの定義

Spring MVCでコントローラの定義を記述します。

ビューの定義

Thymeleafを用いてHTMLテンプレートでビューを定義します。

  • src/main/resources/templates/ ディレクトリ下に、ビュー名(例 room/listRooms)+ .html でファイルを保管
  • Thymeleaf用のタグは 名前空間 th を使うため、htmlタグでxmlns:thを定義
  • テンプレートを直接Webブラウザで開いて表示したときに、仮の表示を要素に記述、th属性に変換ロジックを記述
    例)<title th:text="${#temporals.format(date, 'yyyy/M/d')}の会議室">2025/11/22の会議室</title>

会議室一覧表示

次の Webインタフェースを持つ会議室一覧機能を作成します。

HTTPメソッド リクエストパス 内容 ハンドラメソッド View名
GET /rooms 今日の予約可能会議室一覧 RoomsController#listRooms() room/listRooms
GET /rooms/{date} 指定した日付の予約可能会議室一覧 RoomsController#listRooms(LocalDate) room/listRooms

リポジトリクラス

package com.torutk.spring.mrs.domain.repository.room;

import com.torutk.spring.mrs.domain.model.ReservableRoom;
import com.torutk.spring.mrs.domain.model.ReservableRoomId;
import org.springframework.data.jpa.repository.JpaRepository;

import java.time.LocalDate;
import java.util.List;

public interface ReservableRoomRepository extends JpaRepository<ReservableRoom, ReservableRoomId> {
    List<ReservableRoom> findByReservableRoomId_ReservedDateOrderByReservableRoomId_RoomIdAsc(
            LocalDate reservedDate
    );
}
  • JpaRepositoryインタフェースを継承した、エンティティ ReservableRoom専用のリポジトリを定義
    型パラメータにはエンティティクラスと主キーの型を指定
  • アプリケーション機能から使用する指定日付に予約可能な会議室一覧取得するメソッドを定義
    • メソッド名からJPQLのクエリ(SELECT文)を生成
    • findByで検索を、ReservableRoomId_ReservableDate でReservableRoomIdのreservableDateプロパティを条件とした検索を実施、これは引数で指定したLocalData型のreservedDateを使用
    • RoomIdAscで、検索結果合致したReservableRoomを roomIdの昇順に並べる

サービスクラス

package com.torutk.spring.mrs.domain.service.room;

import com.torutk.spring.mrs.domain.model.ReservableRoom;
import com.torutk.spring.mrs.domain.repository.room.ReservableRoomRepository;
import jakarta.transaction.Transactional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.time.LocalDate;
import java.util.List;

@Service
@Transactional
public class RoomService {

    @Autowired
    ReservableRoomRepository reservableRoomRepository;

    public List<ReservableRoom> findReservableRooms(LocalDate date) {
        return reservableRoomRepository.findByReservableRoomId_ReservedDateOrderByReservableRoomId_RoomIdAsc(date);
    }
}

コントローラークラス

会議室一覧を表示する、Spring MVCのコントローラークラスを記述します。

package com.torutk.spring.mrs.app.room;

import com.torutk.spring.mrs.domain.model.ReservableRoom;
import com.torutk.spring.mrs.domain.service.room.RoomService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

import java.time.LocalDate;
import java.util.List;

@Controller
@RequestMapping("rooms")
public class RoomController {
    @Autowired
    private RoomService roomService;

    @RequestMapping(method = RequestMethod.GET)
    String listRooms(Model model) {
        LocalDate today = LocalDate.now();
        List<ReservableRoom> rooms = roomService.findReservableRooms(today);
        model.addAttribute("date", today);
        model.addAttribute("rooms", rooms);
        return "room/listRooms";
    }

    @RequestMapping(path = "{date}", method = RequestMethod.GET)
    String listRooms(@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) @PathVariable("date") LocalDate date, Model model) {
        List<ReservableRoom> rooms = roomService.findReservableRooms(date);
        model.addAttribute("rooms", rooms);
        return "room/listRooms";
    }
}

  • HTTP GETメソッドで /rooms にアクセスされると listRooms(Model)メソッドが実行され、/rooms/{date} にアクセスされると listRooms(LocalDate, Model) メソッドが実行されます。
    サービスから条件にあうReservableRoomを取得し、ビューに渡します。

ビュー

Thymeleafを使うHTMLテンプレートを記述します。
src/main/resources/templates/ ディレクトリ下に、ビュー名(例 room/listRooms)+ .html でファイルを保管します。

  • src/main/resources/templates/room/listRooms.html
<!DOCTYPE html>
<html lang="ja" 
      xmlns="http://www.w3.org/1999/xhtml" 
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title th:text="|${#temporals.format(date, 'yyyy/M/d')}の会議室|">2025/11/22の会議室</title>
</head>
<body>
<h3>会議室</h3>
<a th:href="@{'/rooms/' + ${date.minusDays(1)}}">前日</a>
<span th:text="|${#temporals.format(date, 'yyyy/M/d')}の会議室|">2025/11/22の会議室</span>
<a th:href="@{'/rooms/' + ${date.plusDays(1)}}">翌日</a>

<ul>
    <li th:each="room: ${rooms}">
        <a th:href="@{'/reservations/' + ${date} + '/' + ${room.meetingRoom.roomId}}" 
           th:text="${room.meetingRoom.roomName}"></a>
    </li>
</ul>
</body>
</html>

会議室予約機能

次のWebインタフェースを持つ会議室予約機能を作成します。

HTTPメソッド リクエストパス 内容 ハンドラメソッド View名
GET /reservations/{date}/{roomId} 指定した会議室の予約画面 ReservationsController#reserveForm reservation/reserveForm
POST /reservations/{date}/{roomId} 指定した会議室の予約処理 ReservationsController#reserve 1.へリダイレクト
POST /reservations/{date}/{roomId}?cancel 指定した会議室の予約取り消し処理 ReservationsController#cancel 1. へリダイレクト

リポジトリクラス

ReservationRepository
package com.torutk.spring.mrs.domain.repository.reservation;

import com.torutk.spring.mrs.domain.model.ReservableRoomId;
import com.torutk.spring.mrs.domain.model.Reservation;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface ReservationRepository extends JpaRepository<Reservation, Integer> {
    List<Reservation> findByReservableRoom_ReservableRoomIdOrderByStartTimeAsc(ReservableRoomId reservableRoomId);
}
  • JpaRepositoryを継承し、型パラメータにはエンティティクラスReservationとその主キーの型を指定
  • 指定したReservableRoomIdから、関連するReservableRoom.reservableRoomIdを取り出し、その予約情報をstart_timeの昇順に並べて返却するクエリをメソッド名から生成
MeetingRoomRepository
package com.torutk.spring.mrs.domain.repository.room;

import com.torutk.spring.mrs.domain.model.MeetingRoom;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MeetingRoomRepository extends JpaRepository<MeetingRoom, Integer> {

}

サービスクラス

package com.torutk.spring.mrs.domain.service.reservation;

import com.torutk.spring.mrs.domain.model.ReservableRoom;
import com.torutk.spring.mrs.domain.model.ReservableRoomId;
import com.torutk.spring.mrs.domain.model.Reservation;
import com.torutk.spring.mrs.domain.repository.reservation.ReservationRepository;
import com.torutk.spring.mrs.domain.repository.room.ReservableRoomRepository;
import jakarta.transaction.Transactional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@Transactional
public class ReservationService {
    @Autowired
    ReservationRepository reservationRepository;
    @Autowired
    ReservableRoomRepository reservableRoomRepository;

    public List<Reservation> findReservations(ReservableRoomId reservableRoomId) {
        return reservationRepository.findByReservableRoom_ReservableRoomIdOrderByStartTimeAsc(reservableRoomId);
    }

    public Reservation reserve(Reservation reservation) {
        ReservableRoomId reservableRoomId = reservation.getReservableRoom().getReservableRoomId();
        // 対象の部屋が予約可能か?
        ReservableRoom reservable = reservableRoomRepository.findById(reservableRoomId).orElse(null);
        if (reservable == null) {
            throw new UnavailableReservationException("入力の日付・部屋の組み合わせは予約できません。");
        }
        // 重複チェック
        boolean overlap = reservationRepository.findByReservableRoom_ReservableRoomIdOrderByStartTimeAsc(reservableRoomId)
                .stream()
                .anyMatch(x -> x.overlap(reservation));
        if (overlap) {
            throw new AlreadyReservedException("入力の時間帯はすでに予約済みです。");
        }
        // 予約情報の登録
        reservationRepository.save(reservation);
        return reservation;
    }

    public void cacnel(Integer reservationId, User requestUser) {
        Reservation reservation = reservationRepository.findById(reservationId).orElse(null);
        if (reservation == null) {
            // 対象のreservationが存在しない場合の処理
            // 本来は例外処理を実装するが、本書では省略
            System.out.println("=== reservation not found ===");
        }
        if (RoleName.ADMIN != requestUser.getRoleName() && !Objects.equals(reservation.getUser().getUserId(), requestUser.getUserId())) {
            throw new IllegalStateException("要求されたキャンセルは許可できません。");
        }
        reservationRepository.delete(reservation);
    }
}

このサービスクラス ReservationService内から重複チェックで呼び出す Reservationクラスのoverlapメソッドを追加します。

@Entity
public class Reservation implements Serializable {

    :

    public boolean overlap(Reservation target) {
        if (!Objects.equals(reservableRoom.getReservableRoomId(), target.getReservableRoom().getReservableRoomId())) {
            return false;
        }
        if (startTime.equals(target.getStartTime()) && endTime.equals(target.getEndTime())) {
            return true;
        }
        return target.endTime.isAfter(startTime) && target.endTime.isAfter(target.startTime);
    }

RoomServiceクラスに追加実装

public class RoomService {

    @Autowired
    MeetingRoomRepository meetingRoomRepository;
    @Autowired
    ReservableRoomRepository reservableRoomRepository;

    public MeetingRoom findMeetingRoom(Integer roomId) {
        MeetingRoom meetingRoom = meetingRoomRepository.findById(roomId).orElse(null);
        if (meetingRoom == null) {
            // 対象のmeeting roomが存在しない場合の処理
            // 本来は例外処理を実装するが、本書では省略
            System.out.println("=== meeting room not found ===");
        }
        return meetingRoom;
    }
    :

例外クラスの定義

UnavailableReservationException
package com.torutk.spring.mrs.domain.service.reservation;

public class UnavailableReservationException extends RuntimeException {
    public UnavailableReservationException(String message) {
        super(message);
    }
}
AlreadyReservedException
package com.torutk.spring.mrs.domain.service.reservation;

public class AlreadyReservedException extends RuntimeException {
    public AlreadyReservedException(String message) {
        super(message);
    }
}

コントローラー

ブラウザ(クライアント)とコントローラー間で受け渡すフォームクラスを定義します。

package com.torutk.spring.mrs.app.reservation;

import java.io.Serializable;
import java.time.LocalTime;

import jakarta.validation.constraints.NotNull;
import org.springframework.format.annotation.DateTimeFormat;

public class ReservationForm implements Serializable {
    @NotNull(message = "必須です")
    @DateTimeFormat(pattern = "HH:mm")
    private LocalTime startTime;

    @NotNull(message = "必須です")
    @DateTimeFormat(pattern = "HH:mm")
    private LocalTime endTime;

    public LocalTime getStartTime() {
        return startTime;
    }

    public void setStartTime(LocalTime startTime) {
        this.startTime = startTime;
    }

    public LocalTime getEndTime() {
        return endTime;
    }

    public void setEndTime(LocalTime endTime) {
        this.endTime = endTime;
    }
}
  • @NotNull を使用するため、ビルド定義(build.gradle.kts)に追加の依存性を記述
    implementation("org.springframework.boot:spring-boot-starter-validation")

予約コントローラーを実装します。

package com.torutk.spring.mrs.app.reservation;

import com.torutk.spring.mrs.domain.model.MeetingRoom;
import com.torutk.spring.mrs.domain.model.ReservableRoomId;
import com.torutk.spring.mrs.domain.model.Reservation;
import com.torutk.spring.mrs.domain.model.RoleName;
import com.torutk.spring.mrs.domain.model.User;
import com.torutk.spring.mrs.domain.service.reservation.ReservationService;
import com.torutk.spring.mrs.domain.service.room.RoomService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

@Controller
@RequestMapping("reservations/{date}/{roomId}")
public class ReservationsController {
    @Autowired
    RoomService roomService;
    @Autowired
    ReservationService reservationService;

    @ModelAttribute
    ReservationForm setUpForm() {
        ReservationForm form = new ReservationForm();
        // デフォルト値
        form.setStartTime(LocalTime.of(9, 0));
        form.setEndTime(LocalTime.of(10, 0));
        return form;
    }

    @RequestMapping(method = RequestMethod.GET)
    String reserveForm(
            @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) @PathVariable("date") LocalDate date,
            @PathVariable("roomId") Integer roomId,
            Model model
    ) {
        MeetingRoom meetingRoom = roomService.findMeetingRoom(roomId);
        ReservableRoomId reservableRoomId = new ReservableRoomId(roomId, date);
        List<Reservation> reservations = reservationService.findReservations(reservableRoomId);

        List<LocalTime> timeList = Stream.iterate(LocalTime.of(0, 0), t -> t.plusMinutes(30))
                .limit(24 * 2)
                .collect(Collectors.toList());

        model.addAttribute("room", meetingRoom);
        model.addAttribute("reservations", reservations);
        model.addAttribute("timeList", timeList);
        model.addAttribute("user", dummyUser());
        return "reservation/reserveForm";
    }

    private User dummyUser() {
        User user = new User();
        user.setUserId("taro-yamada");
        user.setFirstName("太郎");
        user.setLastName("山田");
        user.setRoleName(RoleName.USER);
        return user;
    }

    @RequestMapping(method = RequestMethod.POST)
    String reserve(
            @Validated ReservationForm form,
            BindingResult bindingResult,
            @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) @PathVariable("date") LocalDate date,
            @PathVariable("roomId") Integer roomId,
            Model model
    ) {
        if (bindingResult.hasErrors()) {
            return reserveForm(date, roomId, model);
        }

        ReservableRoom reservableRoom = new ReservableRoom(new ReservableRoomId(roomId, date));
        Reservation reservation = new Reservation();
        reservation.setStartTime(form.getStartTime());
        reservation.setEndTime(form.getEndTime());
        reservation.setReservableRoom(reservableRoom);
        reservation.setUser(dummyUser());

        try {
            reservationService.reserve(reservation);
        } catch (UnavailableReservationException | AlreadyReservedException e) {
            model.addAttribute("error", e.getMessage());
            return reserveForm(date, roomId, model);
        }
        return "redirect:/reservations/{date}/{roomId}";
    }

    @RequestMapping(method = RequestMethod.POST, params = "cancel")
    String cancel(
            @RequestParam("reservationId") Integer reservationId,
            @PathVariable("roomId") Integer roomId,
            @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) @PathVariable("date") LocalDate date,
            Model model
    ) {
        User user = dummyUser();
        try {
            reservationService.cancel(reservationId, user);
        } catch (IllegalStateException e) {
            model.addAttribute("error", e.getMessage());
            return reserveForm(date, roomId, model);
        }
        return "redirect:/reservations/{date}/{roomId}";
    }
}

ビュー

予約フォームと既存の予約一覧

<!DOCTYPE html>
<html lang="ja" 
    xmlns="http://www.w3.org/1999/xhtml" 
    xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title th:text="|${#temporals.format(date, 'yyyy/M/d')}の${room.roomName}|">2025/11/22の豊洲</title>
</head>
<body>
<div>
    <a th:href="@{'/rooms/' + ${date}}">会議室一覧へ</a>
</div>

<p style="color: red" th:if="${error != null}" th:text="${error}"></p>

<form th:object="${reservationForm}" 
      th:action="@{'/reservations/' + ${date} + '/' + ${roomId}}" method="post">
    会議室: <span th:text="${room.roomName}">豊洲</span>
    <br/>
    予約者名: <span th:text="${user.lastName + ' ' + user.firstName}">山田 太郎</span>
    <br/>
    日付: <span th:text="${#temporals.format(date, 'yyyy/M/d')}">2025/11/22</span>
    <br/>
    時間帯:
    <select th:field="*{startTime}">
        <option th:each="time : ${timeList}" th:text="${time}" th:value="${time}">09:00</option>
    </select>
    <span th:if="${#fields.hasErrors('startTime')}" th:errors="*{startTime}" style="color:red">error!</span>
    -
    <select th:field="*{endTime}">
        <option th:each="time : ${timeList}" th:text="${time}" th:value="${time}">10:00</option>
    </select>
    <span th:if="${#fields.hasErrors('endTime')}" th:errors="*{endTime}" style="color:red">error!</span>
    <br/>
    <button>予約</button>
</form>

<table>
    <tr>
        <th>時間帯</th>
        <th>予約者</th>
        <th>操作</th>
    </tr>
    <tr th:each="reservation : ${reservations}">
        <td>
            <span th:text="${reservation.startTime}">09:00</span>
            -
            <span th:text="${reservation.endTime}">10:00</span>
        </td>
        <td>
            <span th:text="${reservation.user.lastName}">山田</span>
            <span th:text="${reservation.user.firstName}">太郎</span>
        </td>
        <td>
            <form th:action="@{'/reservations/' + ${date} + '/' + ${roomId}}" method="post" 
            th:if="${user.userId == reservation.user.userId}">
                <input type="hidden" name="reservationId" th:value="${reservation.reservationId}"/>
                <input type="submit" name="cancel" value="取消"/>
            </form>
        </td>
    </tr>

</table>
</body>
</html>

ログイン機能

次のWebインタフェースを持つログイン機能を作成します。

HTTPメソッド リクエストパス 内容 ハンドラメソッド View名
GET /loginForm ログイン画面 LoginController#loginForm login/loginForm
GET /logon ログイン処理 Spring Securityに委譲 -

Spring Securityを使うために、ビルド定義(build.gradle.kts)に追加の依存性を記述

    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6")

1つ目は、Spring Security ライブラリの依存定義です。2つ目は、ThymeleafとSpring Securityの連携ライブラリで、認証有無、権限有無でHTML要素の表示有無を制御、ユーザー情報の表示ができるようになります。

認証ユーザー取得処理

ReservationUserDetailsクラス

Spring Security の UserDetails インタフェースを実装し、エンティティ User を内包したクラス ReservationUserDetailsクラスを作成します。

package com.torutk.spring.mrs.domain.service.user;

import com.torutk.spring.mrs.domain.model.User;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;

public class ReservationUserDetails implements UserDetails {
    private final User user;

    public ReservationUserDetails(User user) {
        this.user = user;
    }

    public User getUser() {
        return user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return AuthorityUtils.createAuthorityList("ROLE_" + this.user.getRoleName().name());
    }

    @Override
    public String getPassword() {
        return this.user.getPassword();
    }

    @Override
    public String getUsername() {
        return this.user.getUserId();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

リポジトリクラス

UserRepositoryインタフェース

User エンティティのCRUD操作を行う UserRepositoryインタフェースを作成します。

package com.torutk.spring.mrs.domain.repository.user;

import com.torutk.spring.mrs.domain.model.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, String> {
}

サービスクラス

ReservationUserDetailsServiceクラス
package com.torutk.spring.mrs.domain.service.user;

import com.torutk.spring.mrs.domain.model.User;
import com.torutk.spring.mrs.domain.repository.user.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class ReservationUserDetailsService implements UserDetailsService {
    @Autowired
    UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findById(username)
                .orElseThrow(() -> new UsernameNotFoundException(username + " is not found."));
        return new ReservationUserDetails(user);
    }
}

コントローラー

package com.torutk.spring.mrs.app.login;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
public class LoginController {
    @RequestMapping("loginForm")
    String loginForm() {
        return "login/loginForm";
    }
}

ビュー

  • src/main/resources/templates/login/loginForm.html
<!DOCTYPE html>
<html lang="ja" 
      xmlns="http://www.w3.org/1999/xhtml" 
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>ログインフォーム</title>
</head>
<body>
<h3>ログインフォーム</h3>

<p th:if="${param.error}">
    Error!
</p>
<form th:action="@{/login}" method="POST">
    <table>
        <tr>
            <td><label for="username">User:</label></td>
            <td><input type="text" id="username" name="username" value="alfa" /></td>
        </tr>
        <tr>
            <td><label for="password">Password:</label></td>
            <td><input type="password" id="password" name="password" value="demo"/></td>
        </tr>
        <tr>
            <td>&nbsp;</td>
            <td><button type="submit">ログイン</button></td>
        </tr>
    </table>
</form>
</body>
</html>

Securityコンフィグレーション

package com.torutk.spring.mrs;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class WebSecurityConfig {

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(authz -> authz
                .requestMatchers("/js/**", "/css/**").permitAll()
                .anyRequest().authenticated()
        ).formLogin(login -> login
                .loginPage("/loginForm")
                .loginProcessingUrl("/login")
                .usernameParameter("username")
                .passwordParameter("password")
                .defaultSuccessUrl("/rooms", true)
                .failureUrl("/loginForm?error=true").permitAll());
        return http.build();
    }
}

学習メモ

Spring Boot プロジェクトの構成

プロジェクトの作成

Spring Boot アプリケーションを作成するために、ビルドツール Gradle または Maven を使ったプロジェクトを作成します。
作成は、Webサービス Spring Initializr を利用します。

IntelliJ IDEA Ultimate版はプロジェクト作成メニューから Spring Initializr を使ったSpring Bootアプリケーションのプロジェクトを作成することができます。Community版を使う場合は、WebブラウザでSpring Initializrにアクセスしプロジェクトの雛形をzipファイルでダウンロードし、作業ディレクトリに展開してから IDEでディレクトリを開きます。

依存ライブラリ

Spring Bootの依存ライブラリは、以下から使用バージョンを辿ると一覧が表示されます。
https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-dependencies

Spring Security でログイン認証

本チュートリアルでは、認証・認可情報をデータベースに格納し、アプリケーションにアクセスした際ログインしていない場合はログインページを表示しブラウザのフォームでユーザー・パスワードを入力して認証します。
データベースに格納するパスワードは、安全上平文ではなくハッシュ化した文字列を格納します。

Thymeleafをテンプレートに使う場合、依存ライブラリとしてSpring Security に加えて、thymeleaf-extras-springsecurity6 を使用します。

依存ライブラリ

Spring Security と thymeleaf-extras-springsecurity6 を利用するGradleの記述例です。

implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6")

thymeleaf-extras-springsecurity6 の末尾の6は、Spring Security 6.x に対応したライブラリを意味しています。

認証方式は、Form認証とBasic認証が有効となり、どちらが使われるかはコンテンツ交渉(Acceptヘッダー)によって決定されます。
WebブラウザからのアクセスではForm認証、REST APIではBasic認証となります。

設定


7日前に更新