72 KiB
部署管理機能 実装手順書(研修生用)
この手順書は、README.md の課題「部署テーブルの CRUD 機能追加」を、一歩ずつ進められるようにした道しるべです。
- コードは 「写して終わり」にならないよう、考えてほしい部分を
/* ★考える */で空けてあります。空欄は、既存の社員機能(お手本)を読めば埋められます。 - 各ステップの最後に 「できたかの確認」 があります。確認できるまで次へ進まないでください。
- 詰まったら、先に「詰まったとき」(第10章) を読み、それでも解決しない場合は 30分 悩んだ時点で相談してください。相談するときは「やりたいこと/期待した結果/実際の結果(エラー全文)/試したこと/自分の考え」をそろえてください。
目次
- 全体像(何を作るのか・進め方)
- 準備(環境・DB・起動確認)
- 実装前に読むもの(読解ワーク)
- 作業の基本ルール(ファイルの置き場所・名前)
- 第1段階:検索
- 第2段階:削除
- 第3段階:更新(ここまでが提出可能ライン)
- 第4段階:新規登録(チャレンジ)
- 第5段階:アクセス制御アノテーション(チャレンジその2)
- テストの進め方(テスト仕様書の使い方)
- 詰まったとき(エラー別の調べ方)
- 提出前チェックリスト
0. 全体像
0.1 作るもの
管理者が「部署」を 検索・削除・更新・新規登録 できる画面です。すでにある「社員」の機能と同じ作りで作ります。
| 段階 | 内容 | 到達目標 |
|---|---|---|
| 1 | 検索 | メニューから部署検索画面を開き、部署ID(完全一致)・部署名(部分一致)で検索できる |
| 2 | 削除 | 検索結果の各行から削除できる |
| 3 | 更新 | 検索結果の各行から部署名を更新できる(提出可能ライン) |
| 4 | 新規登録 | 入力 → 確認 → 登録 → 完了 の3画面で登録できる |
| 5 | アクセス制御 | 未ログイン・一般ユーザーは部署画面に入れない |
0.2 プログラムの構造(これだけは覚える)
画面でボタンを押してから、DBに届くまでの流れです。
[ブラウザ] ボタン押下
│ ① POST で送信(例:/selectDepartment)
▼
[Controller] presentation/controller/…Controller.java
│ 受け取る・どの画面に行くか決める・メッセージを用意する
│ ② Form ⇄ Model に詰め替え
▼
[Service] domain/service/…(インタフェース) と impl/…Impl.java(実装)
│ 業務の処理(検索する・削除する、など)
▼
[Repository] infra/repository/…Repository.java ←すでに用意済み
▼
[MySQL] department テーブル
▲
└ 結果は逆向きに戻り、Controller が [HTML(Thymeleaf)] に渡して画面になる
| 部品 | 役割 | 部署で使うもの |
|---|---|---|
| Form | 画面とControllerの間で値を運ぶ箱 | DepartmentForm(既存)、他は自作 |
| Model | 業務で使うデータ | Department、SearchConditionDepartment(既存) |
| Entity | DBの1行(infra層専用) | DepartmentEntity(既存) |
0.3 すでにあるもの・作るもの
| すでにある(触らない) | 自分で作る |
|---|---|
DepartmentEntity、DepartmentRepository |
Form 3つ |
Department、SearchConditionDepartment |
Service:削除・更新(IF+実装)、登録(実装のみ) |
DepartmentForm |
Controller 7つ |
SearchDepartment(検索サービス) |
HTML 5つ |
RegisterDepartment(インタフェースだけ) |
menu.html にリンク、定数の追記 |
アノテーション @SessionCheck @PermissionCheck |
0.4 進め方のコツ
- お手本(社員機能)を必ず動かして、コードを読んでから書く。
- 一度に全部書かない。1ファイルできたらビルド・起動して確認する。
- エラーは一番下の
Caused byから読む(第10章)。 - 変更は1つずつ。直らなかったら元に戻して別の仮説を試す。
- 第1〜4段階では
@PermissionCheck/@SessionCheckは付けない(第5段階で付けます)。
1. 準備
1.1 必要なもの
| 項目 | 内容 | 確認方法 |
|---|---|---|
| JDK 17 | 17以外は不可(JDK 25 では Lombok が動かずビルドエラーになります) | コマンドで java -version |
| MySQL 8 | 起動していること | 接続できる |
| Eclipse | README の手順でプロジェクトをインポート済み | 赤い×が出ていない |
1.2 DBを初期状態にする(README「課題に取りかかる前に」)
- 未作成なら MySQL で DB とユーザーを作る。
設計書/start-db-ddl-dml.sqlの先頭に コメントアウトされた 次の3行があります。必要ならコメントを外して実行します。CREATE DATABASE rezodb; CREATE USER 'rezouser'@'localhost' IDENTIFIED BY 'Rezo_0000'; GRANT ALL ON rezodb.* TO 'rezouser'@'localhost'; - A5:SQL Mk-2 等で
設計書/start-db-ddl-dml.sqlを全部実行する。- このファイルの文字コードは Shift-JIS です。UTF-8のエディタで開くと文字化けします(A5M2 から実行してください)。
- 確認:
departmentに6件、employeeに10件。
| id_department | nm_department |
|---|---|
| 10 | 役員 |
| 11 | 人事・総務部 |
| 12 | 経理部 |
| 13 | システム開発事業部 |
| 14 | 教育事業部 |
| 15 | 営業企画部 |
テーブルが無いまま起動すると、ログインの時点で
Table 'rezodb.employee' doesn't existのエラーになります。
1.3 ビルドと起動
README のとおり、Eclipse で プロジェクトを右クリック → 実行 → maven clean → maven install。またはプロジェクト直下の build.cmd。起動は run.cmd(または Eclipse からアプリを実行)。
ブラウザで http://localhost:8080/ を開き、ログインできることを確認します。
| 種類 | メールアドレス | パスワード | ログイン後 |
|---|---|---|---|
| 管理者 | yamada@hoge.jp |
aaa |
メニュー画面 |
| 一般 | jtaka@hoge.jp |
bbb |
個人情報更新画面 |
1.4 できたかの確認
- 管理者でログインし、メニューが表示された
- 「社員検索」「社員登録」を一通り操作できた(お手本の動きを体験する)
2. 実装前に読むもの(読解ワーク)
書き始める前に必ず次を読み、下の「課題理解シート」を埋めてください(README にも「何を作るか・どこまで作るか理解してから作業を始める」とあります)。
README.md、ヒント.md設計書/【管理者】部署情報検索更新削除機能-詳細設計書.xlsx設計書/【管理者】部署情報登録機能-詳細設計書.xlsx設計書/【管理者】メニュー機能-詳細設計書.xlsxテスト仕様書/単体テスト仕様書.xlsxの「部署管理テスト仕様書」シート- 既存の社員機能のコード(第3章の表の「お手本」)
課題理解シート(ノート等に書く)
1. 作るもの(1行):
2. 画面の一覧(画面名/何をする画面か):
3. 画面ごとのボタンと、押したときの動き:
4. 画面に出すメッセージ(文言/どんなとき/どう出す):
5. 使うテーブルと項目(型・桁数):
6. 作るファイルの一覧と、それぞれのお手本にする既存ファイル:
7. 分からないこと:
設計書を読むときのポイント
- 「画面項目定義」=画面にある部品の一覧。「イベント処理」=ボタンを押したときの処理手順。
- メッセージの文言は一字一句そのまま使う(テスト仕様書で確認されます)。
- 設計書には、社員機能からのコピー由来と思われる誤記がいくつかあります(例:部署の設計書に「社員登録画面」「社員マスタに登録」など)。おかしいと思った所はメモして質問してください。
- テーブル定義書と実際のDDLで、部署名の桁数が食い違っています(定義書は50、DDLは
VARCHAR(10))。第7章で実際に確認します。気づいたらメモしておいてください。
メッセージ一覧(設計書・テスト仕様書より)
| 場面 | 文言 | 出し方 |
|---|---|---|
| 検索結果0件 | 検索結果はありません。 |
画面内の文字 |
| 削除ボタン | 本当に削除しますか? |
確認ダイアログ |
| 削除:対象IDが不明 | 削除対象のIDが不明です。 |
エラー表示 |
| 削除:社員が所属している | 1名以上が所属している部署は削除出来ません。 |
エラー表示 |
| 削除成功 | 削除しました。 |
アラート |
| 更新ボタン | 本当によろしいですか? |
確認ダイアログ |
| 更新:対象がない | 更新対象のデータが見つかりません。 |
エラー表示 |
| 更新:同名あり/変更なし | 既に同名の部署が存在するか、値が変更されていません。 |
エラー表示 |
| 更新:空欄 | 項目が空欄です。 |
エラー表示 |
| 更新成功 | 更新が完了しました。 |
アラート |
| 登録:部署名が不正 | 部署名が不正です。 |
エラー表示 |
| 登録:同名あり | 既に同名の部署が存在します。 |
エラー表示 |
| 登録成功 | 部署登録が完了しました |
完了画面の見出し |
3. 作業の基本ルール
3.1 ファイルの置き場所
ベースは src/main/java/com/example/rhizome/ です(以下 …/ と略します)。
| 種類 | 置き場所 | 作るファイル |
|---|---|---|
| Form | …/app/presentation/form/ |
SearchConditionDepartmentForm、UpdateDepartmentForm、InsertDepartmentForm |
| Controller | …/app/presentation/controller/ |
SelectDepartmentController、DeleteDepartmentController、UpdateDepartmentController、UpdateDepartmentResultController、InsertDepartmentInputController、InsertDepartmentConfirmController、InsertDepartmentController |
| Service(IF) | …/app/domain/service/ |
DeleteDepartment、UpdateDepartment |
| Service(実装) | …/app/domain/service/impl/ |
DeleteDepartmentImpl、UpdateDepartmentImpl、RegisterDepartmentImpl |
| 定数 | …/app/presentation/controller/pageproperty/ |
既存の TransitionTargetPageNameKeyword、PageReturnAttributeKeyword に追記 |
| HTML | src/main/resources/templates/ |
selectDepartment、updateDepartmentInput、insertDepartmentInput、insertDepartmentConfirm、insertDepartmentResult(拡張子 .html)、既存 menu.html に追記 |
パッケージの注意:すべて
com.example.rhizomeの配下に置きます。外に置くと Spring が見つけられず、起動時にNoSuchBeanDefinitionExceptionになります。
3.2 お手本(社員機能)との対応
| 作るもの | お手本 |
|---|---|
SelectDepartmentController |
SelectEmployeeController |
SearchConditionDepartmentForm |
SearchConditionEmployeeForm |
selectDepartment.html |
selectEmployee.html |
DeleteDepartmentController、DeleteDepartment(Impl) |
DeleteEmployeeController、DeleteEmployee(Impl) |
UpdateDepartmentController / …ResultController |
UpdateEmployeeController / UpdateEmployeeResultController |
UpdateDepartmentForm、updateDepartmentInput.html |
UpdateEmployeeInfoForm、updateEmployeeInput.html |
UpdateDepartment(Impl) |
UpdateEmployee(Impl) |
InsertDepartment…Controller、InsertDepartmentForm、insertDepartment…html |
InsertEmployee…Controller、InsertEmployeeForm、insertEmployee…html |
RegisterDepartmentImpl |
RegisterEmployeeImpl |
お手本は「構造を学ぶ」ために使います。 名前を置換してコピーするだけでは、部署に不要な処理(部署のプルダウン、パスワード等)が残って動かなかったり、意味が分からないままになります。各行が何をしているか説明できる状態で写してください。
3.3 コードの書き方の約束(既存に合わせる)
- 変数・フィールド名:部署ID=
idDepartment、部署名=nmDepartment - クラスは
@Controller/@Service/ Lombok の@Data等を、お手本と同じように使う - 画面名・URL・画面に渡すキーは文字列を直書きせず定数にする(
TransitionTargetPageNameKeyword/PageReturnAttributeKeyword) - Controller の処理は、お手本と同様に
try { … } catch (Exception e) { … }で囲む
4. 第1段階:検索
4.0 ゴールと完成の定義
メニューの「部署検索」から部署検索画面に行き、部署ID=完全一致/部署名=部分一致 で検索できる。検索後も入力した条件が画面に残る。0件なら 検索結果はありません。。
→ 単体テスト #1〜#8 が期待どおりになれば完成。
完成後の流れ:
menu.html「部署検索」 ──POST /selectDepartment──▶ SelectDepartmentController
└──▶ selectDepartment.html(検索条件+結果)
ステップ 1-1:お手本を動かして、コードで追う
- 社員検索を実行する。
SelectEmployeeControllerのselectメソッドの最初の行にブレークポイントを置いて、デバッグ実行し、ステップ実行で追う。- 次の質問に自分の言葉で答える(答えられなければコードを読み直す)。
- Form に値が入るのは、どの時点・誰の仕事?
selectメソッドが2つあるのはなぜ?(片方は引数に検索条件の Form を持つ、片方は持たない。この違いは第2段階で効いてきます)- 検索サービスは最終的にどこでSQLを作っている?(
EmployeeInfoRepositoryのSpecification)
できたかの確認:ホワイトボードやノートに、§0.2 のような流れ図を自分で描ける。
ステップ 1-2:定数を追加する
TransitionTargetPageNameKeyword.java には、すでに // Department と書かれた空の枠があります。ここに追記します。
- HTML名:
templatesのファイル名(拡張子なし)。先頭に/は付けない。 - URL:Controller のURL。先頭に
/を付ける。
// Department
// HTML
public static final String SELECT_DEPARTMENT_HTML = "selectDepartment";
// Controller
public static final String SELECT_DEPARTMENT = "/selectDepartment";
menu.htmlにある既存のJavaScript(selectDepartmentSubmit())は、selectDepartmentという名前に送信します。上の定数はそれに合わせています。
できたかの確認:ビルドが通る(まだ画面は動かなくてよい)。
ステップ 1-3:検索条件の Form を作る
…/presentation/form/SearchConditionDepartmentForm.java
package com.example.rhizome.app.presentation.form;
import com.example.rhizome.app.domain.model.SearchConditionDepartment;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 画面から渡ってきた部署の検索条件をまとめ、再度画面に表示する為のクラス
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class SearchConditionDepartmentForm {
/** 部署ID */
private Integer idDepartment;
/** 部署名 */
private String nmDepartment;
/**
* Form から business 層の SearchConditionDepartment に変換する
*/
public static SearchConditionDepartment convertTo(SearchConditionDepartmentForm form) {
return new SearchConditionDepartment(/* ★考える:Form の2つの値を渡す */);
}
}
考えるポイント:
- 部署IDが
intではなくIntegerなのはなぜ?(未入力=nullを表したい) @NoArgsConstructorが無いと何が困る?(画面の値を自動で詰める時に、空のインスタンスが必要)
できたかの確認:ビルドが通る。「Form/Model/Entity の違い」を3行で説明できる。
ステップ 1-4:検索サービスの使い方を調べる(コードは書かない)
SearchDepartment(インタフェース)には3つのメソッドがあります。
| メソッド | 動き |
|---|---|
selectAll() |
全件取得 |
searchBy(条件) |
部署ID=完全一致、部署名=実質完全一致 |
searchByWithLike(条件) |
部署ID=完全一致、部署名=部分一致 |
searchByは内部でlikeを使っていますが、%を付けないので完全一致と同じです。メソッド名に惑わされず、SearchDepartmentImplとDepartmentRepositoryを読んで確認してください。
課題:今回の仕様「部署名は部分一致」を満たすのはどちらか。実際に両方試して(部署名に 営業 と入れて)確認し、理由をノートに書く。
ステップ 1-5:検索 Controller を作る
…/presentation/controller/SelectDepartmentController.java(お手本:SelectEmployeeController)
package com.example.rhizome.app.presentation.controller;
import com.example.rhizome.app.domain.service.SearchDepartment;
import com.example.rhizome.app.presentation.controller.pageproperty.PageReturnAttributeKeyword;
import com.example.rhizome.app.presentation.controller.pageproperty.TransitionTargetPageNameKeyword;
import com.example.rhizome.app.presentation.form.DepartmentForm;
import com.example.rhizome.app.presentation.form.SearchConditionDepartmentForm;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import javax.servlet.http.HttpSession;
import java.util.logging.Level;
import java.util.logging.Logger;
@Controller
public class SelectDepartmentController {
private final static Logger LOGGER = Logger.getLogger(SelectDepartmentController.class.getName());
private final static String SEVERE_ERROR_MESSAGE = "エラーが発生しました!";
HttpSession httpSession;
SearchDepartment searchDepartment;
@Autowired
public SelectDepartmentController(HttpSession httpSession, SearchDepartment searchDepartment) {
this.httpSession = httpSession;
this.searchDepartment = searchDepartment;
}
/** 空の検索条件Formを、この Controller の画面用に用意する */
@ModelAttribute
public SearchConditionDepartmentForm searchConditionDepartmentForm() {
return new SearchConditionDepartmentForm();
}
@RequestMapping(value = TransitionTargetPageNameKeyword.SELECT_DEPARTMENT, method = RequestMethod.POST)
public String select(SearchConditionDepartmentForm searchConditionDepartmentForm, Model model) {
try {
// ★考える:①検索サービスを呼ぶ(Form→Model変換 → 部分一致のメソッド)
// ②結果(List<Department>)を DepartmentForm.convertFrom(...) で画面用に変換
// ③ model に PageReturnAttributeKeyword.DEPARTMENT_FORM_LIST という名前で載せる
return TransitionTargetPageNameKeyword.SELECT_DEPARTMENT_HTML;
} catch (Exception e) {
httpSession.invalidate();
e.printStackTrace();
LOGGER.log(Level.SEVERE, SEVERE_ERROR_MESSAGE, e);
return TransitionTargetPageNameKeyword.ERROR_HTML;
}
}
}
知っておくこと:
@ModelAttributeのメソッドが返した Form は、searchConditionDepartmentForm(クラス名の先頭小文字)という名前で画面に渡ります。これは この Controller クラスの中だけで有効です。select(SearchConditionDepartmentForm form, …)の引数 Form は、自動的に同じ名前で画面に渡されます。だから検索後も入力値が画面に残ります。- メニューからの遷移はパラメータ無しのPOSTです。条件が
nullや空だと全件が返ります(Specificationで条件が外れる)。
できたかの確認:ビルドが通る。
ステップ 1-6:検索画面 HTML を作る
src/main/resources/templates/selectDepartment.html(お手本:selectEmployee.html)
お手本の次の部分はそのまま使えます:<head>(CSS・JS読み込み)、見出しとログイン者名・ログアウトボタン、メッセージ表示、「メニューに戻る」リンク。
骨格(★ の所を、お手本を見て完成させます):
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
<script th:src="@{/js/common.js}"></script>
<link rel="stylesheet" th:href="@{/css/all.css}">
<link rel=stylesheet th:href="@{/css/style.css}" type="text/css">
<title>部署検索</title>
</head>
<body>
<h1>
部署検索条件入力
<div class="rightSection">
<span class="loginInfo">ログイン者名:<span th:text="${session.loginUser.nmEmployee}"></span></span>
<button class="logoutButton" onclick="logoutSubmit()">ログアウト</button>
</div>
</h1>
<form action="selectDepartment" method="post" th:object="${searchConditionDepartmentForm}">
<div class="msgBox error" th:if="${messageError} != null">
<span class="error" th:text="${messageError.message}"></span>
</div>
<table>
<tr>
<th class="header">部署ID</th>
<td><input placeholder="部署ID" th:field="*{idDepartment}" type="number"/></td>
</tr>
<tr>
<th class="header">部署名</th>
<td><!-- ★部署名の入力欄(type="text")を作る --></td>
</tr>
<tr>
<td class="btnArea" colspan="2"><input type="submit" value="検索"/></td>
<td class="btnArea" colspan="2"><button onclick="clearInput()">クリア</button></td>
</tr>
</table>
</form>
<a href="showMenu">メニューに戻る</a>
<h1><span>検索結果</span></h1>
<!-- ★検索結果が1件以上:表(部署ID・部署名)を th:each で繰り返す。変数は departmentFormList -->
<!-- ★0件:「検索結果はありません。」を表示 -->
</body>
<script type="text/javascript">
function clearInput() {
document.getElementById('idDepartment').value = '';
// ★部署名の入力欄も空にする(id は th:field が自動で作る)
}
</script>
</html>
ヒント:
th:if="${departmentFormList != null and departmentFormList.size() > 0}"で「1件以上」を判定できます(お手本参照)。th:each="departmentForm : ${departmentFormList}"とth:text="${departmentForm.idDepartment}"。th:field="*{idDepartment}"が何を作るかは、ブラウザで右クリック →「ページのソースを表示」で確認できます(idnamevalueが作られる)。
考えるポイント(クリアボタン):<button> は type を省略すると送信ボタンになります。onclick で値を空にしたあとにフォームが送信されます。つまり「クリア=条件を消して全件検索」になります(テスト #35 は「クリア後に全件表示」を期待)。値を消すだけにしたい場合は type="button" を付けます。どちらがテスト仕様に合うか、自分で判断して理由をノートに書いてください。
ステップ 1-7:メニューにリンクを追加
menu.html の <table class="menuClass"> に、「社員登録」の行の次へ追加します。JavaScript の関数(selectDepartmentSubmit())は、すでにファイル下部にあります。
<tr>
<td><a class="menuClass" href="javascript:selectDepartmentSubmit()"><i class="fas fa-search"></i> 部署検索 </a></td>
<td class="description">登録済みの部署一覧表示と</br>更新、削除を行います。</td>
</tr>
なぜ <a href> で直接飛ばず、JavaScriptでフォームを作って送っているのか考えてください(Controller が POST 専用だから。リンクは GET で送られるので 405 になる)。
できたかの確認:再ビルド・再起動 → 管理者ログイン → メニュー →「部署検索」で全件が表示される。
ステップ 1-8:動作確認(単体テスト #1〜#8)
| # | 操作 | 期待 |
|---|---|---|
| 1 | yamada@hoge.jp でログイン |
メニューに部署検索が出る |
| 2 | 部署検索を押す | 検索条件と全件の結果 |
| 3 | 右上のログアウト | ログイン画面 |
| 4 | クリア → 部署ID 10 → 検索 |
役員 のみ。入力した 10 が残る |
| 5 | クリア → 部署ID 1000 → 検索 |
検索結果はありません。+条件が残る |
| 6 | クリア → 部署名 営業 → 検索 |
営業企画部 |
| 7 | クリア → 部署名 開発 → 検索 |
システム開発事業部 |
| 8 | 「メニューに戻る」 | メニュー画面 |
※更新・削除ボタンは第2・3段階で追加するので、今は表示されなくて構いません(テスト #2 のボタン確認は後で実施)。
4.1 第1段階の振り返り(説明できればOK)
- 画面→Controller→Service→Repository の流れを説明できる
- 部分一致/完全一致をどのメソッドで実現し、なぜそれを選んだか説明できる
@ModelAttributeが何のためにあるか説明できる
5. 第2段階:削除
5.0 ゴールと完成の定義
検索結果の各行の「削除」→ 本当に削除しますか? → OK で削除。その部署に所属する社員が1人でもいれば削除できずエラー。削除後は一覧(全件)に戻る。
→ 単体テスト #32〜#35。
ステップ 2-1:処理の手順をフローチャートにする
設計書「イベント処理・部署検索・削除」の⑥を読み、次を埋めてください。
削除ボタン押下(部署IDを送信)
├ 部署IDが無い/その部署が存在しない → ???(メッセージ)→ 一覧へ
├ その部署に所属する社員が1人以上 → ???(メッセージ)→ 一覧へ
└ 削除する → ???(メッセージ)→ 一覧へ
考えるポイント:
- DBに外部キー制約が無い(DDLに
FOREIGN KEYが無い)ので、所属社員がいても DB は削除を止めてくれません。アプリ側で確認する必要があります。 - 「存在しないID」はあり得ます(別の画面で削除済み、ブラウザの戻る→再送信 など)。
ステップ 2-2:削除サービスを作る
① インタフェース …/domain/service/DeleteDepartment.java
package com.example.rhizome.app.domain.service;
public interface DeleteDepartment {
void deleteDepartment(Integer idDepartment);
}
② 実装 …/domain/service/impl/DeleteDepartmentImpl.java(お手本:DeleteEmployeeImpl)
package com.example.rhizome.app.domain.service.impl;
import com.example.rhizome.app.domain.service.DeleteDepartment;
import com.example.rhizome.app.infra.repository.DepartmentRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
public class DeleteDepartmentImpl implements DeleteDepartment {
DepartmentRepository departmentRepository;
@Autowired
public DeleteDepartmentImpl(DepartmentRepository departmentRepository) {
this.departmentRepository = departmentRepository;
}
public void deleteDepartment(Integer idDepartment) {
// ★考える:Repository のどのメソッドで「IDを指定して削除」できる?
// (お手本の DeleteEmployeeImpl を見る)
}
}
注意:
@Serviceを付け忘れると、Controller で使うとき起動時エラー(No qualifying bean…)になります。
ステップ 2-3:削除 Controller を作る
…/presentation/controller/DeleteDepartmentController.java(お手本:DeleteEmployeeController)
まず、定数を追加します。
// TransitionTargetPageNameKeyword の Department(Controller) の枠に追記
public static final String DELETE_DEPARTMENT = "/deleteDepartment";
骨格:
@Controller
public class DeleteDepartmentController {
private final static Logger LOGGER = Logger.getLogger(DeleteDepartmentController.class.getName());
private final static String SEVERE_ERROR_MESSAGE = "エラーが発生しました!";
HttpSession httpSession;
DeleteDepartment deleteDepartment;
SearchDepartment searchDepartment;
SearchEmployee searchEmployee; // 所属社員の確認に使う(既存)
@Autowired
public DeleteDepartmentController(HttpSession httpSession, DeleteDepartment deleteDepartment,
SearchDepartment searchDepartment, SearchEmployee searchEmployee) {
this.httpSession = httpSession;
this.deleteDepartment = deleteDepartment;
this.searchDepartment = searchDepartment;
this.searchEmployee = searchEmployee;
}
@RequestMapping(value = TransitionTargetPageNameKeyword.DELETE_DEPARTMENT, method = RequestMethod.POST)
public String deleteDepartment(@RequestParam(value = "idDepartment", required = false) Integer idDepartment, Model model) {
final String UNKNOWN_ID = "削除対象のIDが不明です。";
final String HAS_EMPLOYEE = "1名以上が所属している部署は削除出来ません。";
final String DELETE_COMPLETE = "削除しました。";
try {
// ★① idDepartment が null、または searchDepartment.searchBy(new SearchConditionDepartment(idDepartment, null)) が空
// → MESSAGE_ERROR に UNKNOWN_ID を載せて一覧へ
// ★② searchEmployee.searchEmployeeByIdDepartment(idDepartment) が空でない
// → MESSAGE_ERROR に HAS_EMPLOYEE を載せて一覧へ
// ★③ deleteDepartment.deleteDepartment(idDepartment) → MESSAGE_INFO に DELETE_COMPLETE を載せて一覧へ
return "一覧へ戻る(ステップ 2-5 で決める)";
} catch (Exception e) {
httpSession.invalidate();
e.printStackTrace();
LOGGER.log(Level.SEVERE, SEVERE_ERROR_MESSAGE, e);
return TransitionTargetPageNameKeyword.ERROR_HTML;
}
}
}
メッセージの載せ方(お手本のとおり):
model.addAttribute(PageReturnAttributeKeyword.MESSAGE_ERROR, new MessageForm(UNKNOWN_ID));
**確認の順序は「存在 → 所属社員 → 削除」**です(削除してから確認しても意味がありません)。
ステップ 2-4:「削除後にどの画面へ戻るか」を考える(重要)
課題:削除後は検索一覧(全件)を表示したい。でも、return "selectDepartment";(画面名で返す)だと画面が壊れます。なぜでしょう?
調べるヒント:
- 検索画面の
th:object="${searchConditionDepartmentForm}"は、どの Controller が用意している? @ModelAttributeはどのクラスで有効だった?(ステップ 1-5)
→ そこで、検索 Controller に処理を引き継ぐ forward: を使います(お手本の DeleteEmployeeController を参照)。
return "forward:" + TransitionTargetPageNameKeyword.【どの定数?】;
ここに罠があります:forward: は元のリクエストのパラメータを引き継ぎます。削除ボタンは idDepartment=10 を送っているので、これを /selectDepartment に forward すると、「部署ID=10 で検索」が実行されてしまいます(実際に確認済みの動作です)。
解決方法は、お手本が SelectEmployeeController に**2つ目の select(引数に Form を持たない版)**を持っている理由と同じです。部署でも、パラメータを一切使わず全件を返す、もう1つの入口を作ります。
- 定数を追加:
public static final String SELECT_DEPARTMENT_FORM_EMPTY = "/selectDepartmentAll"; SelectDepartmentControllerに2つ目のメソッドを追加:@RequestMapping(value = TransitionTargetPageNameKeyword.SELECT_DEPARTMENT_FORM_EMPTY, method = RequestMethod.POST) public String select(Model model) { try { // ★全件取得 → DepartmentForm に変換 → DEPARTMENT_FORM_LIST で model へ return TransitionTargetPageNameKeyword.SELECT_DEPARTMENT_HTML; } catch (Exception e) { httpSession.invalidate(); e.printStackTrace(); LOGGER.log(Level.SEVERE, SEVERE_ERROR_MESSAGE, e); return TransitionTargetPageNameKeyword.ERROR_HTML; } }- 削除 Controller の戻り先を
forward:/selectDepartmentAll(定数SELECT_DEPARTMENT_FORM_EMPTYを使う)にする。
なぜ forward: ならメッセージが画面に出るのか:model に入れた値は request の属性として公開され、forward でも同じリクエストなので残るためです。
ステップ 2-5:一覧にメッセージ表示と削除ボタンを足す
selectDepartment.html に追加します。
① 情報メッセージ(アラート):お手本の selectEmployee.html の次の部分を、同じように入れる(</h1> の「検索結果」見出しの後あたり)。
<th:block th:if="${messageInfo != null}">
<script th:inline="javascript">
alert([[${messageInfo.message}]]);
</script>
</th:block>
② 結果テーブルに「操作」列と削除ボタン:
<th style="width: 150px;">操作</th> <!-- thead に追加 -->
<td> <!-- tbody の各行に追加 -->
<form action="deleteDepartment" method="post" onsubmit="return confirm('本当に削除しますか?');"
style="display: inline;">
<input name="idDepartment" th:value="${departmentForm.idDepartment}" type="hidden">
<input type="submit" value="削除">
</form>
</td>
考えるポイント:
- なぜ
<a>ではなく<form method="post">?(削除のような「データを変える操作」を、URLを開くだけで実行される GET にしない) hiddenのname="idDepartment"と、Controller の@RequestParam("idDepartment")は一致している必要がある。
ステップ 2-6:動作確認
| # | 操作 | 期待 |
|---|---|---|
| 32 | 部署名テストで検索 → 対象の削除を押す(※テスト用の部署が無ければ、第4段階の後にやるか、DBに直接 INSERT INTO department (nm_department) VALUES ('テスト部署2'); を1行追加) |
本当に削除しますか? |
| 33 | 〃 → キャンセル | 何も起きない |
| 34 | 〃 → OK | 削除され 削除しました。 |
| 35 | クリア(全件)→ 役員 の削除 → OK |
1名以上が所属している部署は削除出来ません。 |
追加で自分で確認(異常系):
- ブラウザの開発者ツール(F12)で hidden の値を
999に書き換えて削除 →「削除対象のIDが不明です。」 - 削除直後にブラウザの「戻る」→ 再送信 → エラー画面にならず、メッセージが出る
5.1 第2段階の振り返り
- なぜ所属社員の確認をアプリで行う必要があるか説明できる
- なぜ戻り先を
forward:の全件用エンドポイントにしたか(パラメータ引き継ぎ)説明できる - 削除を POST にする理由を説明できる
6. 第3段階:更新(提出可能ライン)
6.0 ゴールと完成の定義
一覧の「更新」→ 更新画面(部署IDは変更不可)→ 部署名を変更 →「本当によろしいですか?」→ 成功時 更新が完了しました。 で一覧へ。
→ 単体テスト #9〜#20。
ステップ 3-1:画面と処理を分解する
[一覧]「更新」押下 ─ POST /showUpdateDepartment(idDepartment)
├ 対象が存在しない → 「更新対象のデータが見つかりません。」→ 一覧
└ 存在する → [更新画面] に現在の値を表示(部署IDは変更不可)
[更新画面]「更新」押下 → 確認ダイアログ → POST /updateDepartmentResult
├ 部署名が空欄 → 「項目が空欄です。」→ 更新画面
├ 同じ名前の部署がある(自分と同じ名前=変更なし も含む)
│ → 「既に同名の部署が存在するか、値が変更されていません。」→ 更新画面
├ 更新対象が存在しない → 「更新対象のデータが見つかりません。」→ 一覧
└ 更新 → 「更新が完了しました。」(アラート) → 一覧
ステップ 3-2:定数を追加
// TransitionTargetPageNameKeyword
public static final String UPDATE_DEPARTMENT_INPUT_HTML = "updateDepartmentInput"; // HTML
public static final String SHOW_UPDATE_DEPARTMENT = "/showUpdateDepartment"; // Controller
public static final String UPDATE_DEPARTMENT_RESULT = "/updateDepartmentResult";
// PageReturnAttributeKeyword
public static final String UPDATE_DEPARTMENT_FORM = "updateDepartmentForm";
ステップ 3-3:更新 Form
…/presentation/form/UpdateDepartmentForm.java
package com.example.rhizome.app.presentation.form;
import com.example.rhizome.app.domain.model.Department;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UpdateDepartmentForm {
private Integer idDepartment;
private String nmDepartment;
/** Model(Department) → Form */
public static UpdateDepartmentForm convertFrom(Department department) {
return new UpdateDepartmentForm(department.getIdDepartment(), department.getNmDepartment());
}
/** Form → Model(Department) */
public static Department convertTo(UpdateDepartmentForm form) {
return new Department(form.getIdDepartment(), form.getNmDepartment());
}
/** 必須チェック。true:OK/false:部署ID か部署名が空 */
public boolean hasRequiredValue() {
// ★考える:idDepartment が null でなく、nmDepartment が null でも空白だけでもない
return false;
}
}
Stringの空チェックにはisBlank()(空白だけも空扱い)が便利です。
ステップ 3-4:更新サービス
…/domain/service/UpdateDepartment.java
package com.example.rhizome.app.domain.service;
import com.example.rhizome.app.domain.model.Department;
public interface UpdateDepartment {
void updateDepartment(Department department);
}
…/domain/service/impl/UpdateDepartmentImpl.java
@Service
@Transactional
public class UpdateDepartmentImpl implements UpdateDepartment {
DepartmentRepository departmentRepository;
@Autowired
public UpdateDepartmentImpl(DepartmentRepository departmentRepository) {
this.departmentRepository = departmentRepository;
}
public void updateDepartment(Department department) {
// ★考える:Model を Entity に変換(DepartmentEntity.convertFrom)して、Repository の save に渡す
}
}
import 文は削除サービスを参考に自分で書いてください(IDE の「インポートの整理」が使えます)。
重要な落とし穴:
saveは**IDがDBに無い(または null)だと更新ではなく新規追加(INSERT)**をします。後のステップで「存在確認してから呼ぶ」ようにします。
ステップ 3-5:更新画面を表示する Controller
…/presentation/controller/UpdateDepartmentController.java(お手本:UpdateEmployeeController)
@Controller
public class UpdateDepartmentController {
// LOGGER、SEVERE_ERROR_MESSAGE、httpSession、searchDepartment、コンストラクタ は他と同じ作り
@RequestMapping(value = TransitionTargetPageNameKeyword.SHOW_UPDATE_DEPARTMENT, method = RequestMethod.POST)
public String showUpdateDepartment(@RequestParam("idDepartment") Integer idDepartment, Model model) {
final String UPDATE_TARGET_ID_NOT_FOUND = "更新対象のデータが見つかりません。";
try {
// ★① searchDepartment.searchBy(new SearchConditionDepartment(idDepartment, null)) で対象を検索
// ★② 結果が空なら MESSAGE_ERROR を載せて "forward:" + SELECT_DEPARTMENT_FORM_EMPTY
// ★③ あれば UpdateDepartmentForm.convertFrom(結果の先頭) を
// UPDATE_DEPARTMENT_FORM という名前で model に載せ、UPDATE_DEPARTMENT_INPUT_HTML を返す
return "…";
} catch (Exception e) {
// 他と同じ
}
}
}
ステップ 3-6:更新画面 HTML
updateDepartmentInput.html(お手本:updateEmployeeInput.html)
<!-- head・見出し・ログイン者名・ログアウトは他の画面と同じ。title は「部署情報更新」 -->
<form action="updateDepartmentResult"
method="post"
onsubmit="return confirm('本当によろしいですか?');"
th:object="${updateDepartmentForm}">
<div class="msgBox error" th:if="${messageError != null}">
<span class="error" th:text="${messageError.message}"></span>
</div>
<table>
<tr>
<th class="header">部署ID</th>
<td>
<input class="readOnly" readonly="readonly" th:field="*{idDepartment}" type="text"/>
</td>
</tr>
<tr>
<th class="header">部署名</th>
<td>
<input maxlength="50" placeholder="部署名" required="required"
th:field="*{nmDepartment}" type="text"/>
</td>
</tr>
<tr>
<td class="btnArea" colspan="2"><input type="submit" value="更新"/></td>
</tr>
</table>
</form>
<a href="javascript:selectDepartmentSubmit()">一覧に戻る</a>
<script type="text/javascript">
function selectDepartmentSubmit() {
const form = document.createElement('form');
form.setAttribute('method', 'post');
form.setAttribute('action', 'selectDepartment');
document.body.appendChild(form);
form.submit();
}
</script>
考えるポイント:
readonlyとdisabledの違い:readonlyは編集不可だが送信される。disabledは送信されない。部署IDをdisabledにすると ID がnullで届き、更新が新規追加になってしまいます。開発者ツール(F12)の Network タブで、実際に送られる値を比べてみてください。- 「一覧に戻る」は、検索の Controller が POST 専用なのでメニューと同じ方法(JavaScriptでフォームを作って送信)にしています。
actionはupdateDepartmentResult(先頭に/を付けない相対パス)。
ステップ 3-7:更新を実行する Controller(最大の山場)
…/presentation/controller/UpdateDepartmentResultController.java(お手本:UpdateEmployeeResultController)
@Controller
public class UpdateDepartmentResultController {
// LOGGER、SEVERE_ERROR_MESSAGE、httpSession、
// searchDepartment(SearchDepartment)、updateDepartment(UpdateDepartment) とコンストラクタ
@RequestMapping(value = TransitionTargetPageNameKeyword.UPDATE_DEPARTMENT_RESULT, method = RequestMethod.POST)
public String departmentUpdateResult(@ModelAttribute UpdateDepartmentForm updateDepartmentForm, Model model) {
final String NOTHING = "項目が空欄です。";
final String DUPLICATION = "既に同名の部署が存在するか、値が変更されていません。";
final String NO_TARGET = "更新対象のデータが見つかりません。";
final String UPDATE_COMPLETE = "更新が完了しました。";
try {
// ★ここに「チェック→更新」を、下の【チェックの順序】どおりに書く
return "…";
} catch (Exception e) {
// 他と同じ
}
}
}
【チェックの順序】(順番が大切です。理由も考えてください)
| 順 | チェック | NGの場合 | 戻り先 |
|---|---|---|---|
| 1 | 必須(hasRequiredValue()) |
NOTHING |
更新画面(UPDATE_DEPARTMENT_INPUT_HTML) |
| 2 | 更新後の部署名が既に存在する(searchBy(new SearchConditionDepartment(null, 更新後の部署名)) が空でない) |
DUPLICATION |
更新画面 |
| 3 | 更新対象IDが存在する(searchBy(new SearchConditionDepartment(ID, null)) が空でない) |
NO_TARGET |
一覧(forward) |
| 4 | 更新を実行 → MESSAGE_INFO に UPDATE_COMPLETE |
一覧(forward) |
なぜ必須チェックが先か:部署名が空のまま「同名検索」を行うと、条件が無視されて全件ヒットしてしまい、「同名の部署が存在する」と誤判定されるからです。(設計書は重複確認→必須確認の順に書かれていますが、この順で実装すると上記の問題が出ます。)
「変更なし」も重複エラーになる理由:変更しない(自分と同じ名前)で検索すると、自分自身がヒットします。そのため、同名あり・変更なしを1つの判定でまとめて捕まえられます(設計書のメッセージが1つにまとまっている理由)。
エラー時に更新画面へ戻すときは、入力済みの値を残すために、次の2つを載せます:
model.addAttribute(PageReturnAttributeKeyword.MESSAGE_ERROR, new MessageForm(DUPLICATION));
model.addAttribute(PageReturnAttributeKeyword.UPDATE_DEPARTMENT_FORM, updateDepartmentForm);
return TransitionTargetPageNameKeyword.UPDATE_DEPARTMENT_INPUT_HTML;
ブラウザの required があるのに、サーバー側でも必須チェックする理由:ブラウザの検証は、開発者ツールで簡単に回避できるためです。
ステップ 3-8:一覧に「更新」ボタンを追加
selectDepartment.html の操作列(削除ボタンと同じ <td> の中)に追加:
<form action="showUpdateDepartment" method="post" style="display: inline;">
<input name="idDepartment" th:value="${departmentForm.idDepartment}" type="hidden">
<input type="submit" value="更新">
</form>
ステップ 3-9:動作確認(単体テスト #9〜#20)
| # | 操作 | 期待 |
|---|---|---|
| 9 | 部署ID 10 の「更新」 |
部署ID変更不可で 10/役員 |
| 10 | 何も変えずに更新 → OK | 既に同名の部署が存在するか、値が変更されていません。 |
| 12 | 部署名を 役員改 にして更新 → OK |
更新が完了しました。 → 一覧に反映 |
| 14 | 部署名を 人事・総務部 にして更新 → OK |
同名エラー |
| 15 | 「一覧に戻る」 | 変更されていない一覧 |
| 17 | 部署名を空欄で更新 | ブラウザの「このフィールドを入力してください。」(画面遷移なし) |
| 18 | 「一覧に戻る」 | 検索画面 |
| 19 | 更新画面で右上のログアウト | ログイン画面 |
| 20 | 部署名を 役員 に戻して更新 |
更新が完了しました。 |
追加で自分で確認(異常系):
- F12 で
requiredを削除して空欄のまま送信 →「項目が空欄です。」 - F12 で ID を
999に書き換えて送信 →「更新対象のデータが見つかりません。」(DBに新しい行が増えていないこと)
6.1 結合テスト(部署更新)
結合テスト仕様書の「ログインから部署検索、部署更新まで」:部署名を 役員 → 役員改 に変更し、社員検索画面で、もともと 役員 だった社員の所属部署が 役員改 と表示される。(社員テーブルは部署名ではなく部署IDだけを持ち、表示するときに部署テーブルと結合しているためです。)
※手順3の「社員ID 10」は「部署ID 10」の誤記と思われます。
6.2 ここまでが提出可能ライン — 振り返り
- 単体テスト #1〜#20、#32〜#35 が期待どおり
- 更新の検証順序の理由を説明できる
saveが新規追加になる条件を説明できるreadonlyとdisabledの違いを説明できる
7. 第4段階:新規登録(チャレンジ)
7.0 ゴールと完成の定義
メニュー「部署登録」→ 入力 → 登録確認 → 登録 → 完了画面(部署登録が完了しました)。確認画面の「登録内容の編集」で、値を保ったまま入力画面に戻れる。
→ 単体テスト #21〜#31、結合テストの登録シナリオ。
画面フロー:
[入力] 部署ID(自動で設定されます)/部署名 ──「登録確認」──▶ POST /insertDepartmentConfirm
[確認] すべて変更不可 ──「登録」──▶ POST /insertDepartment ──▶ [完了]
──「登録内容の編集」──▶ POST /insertDepartmentInput(値を保持)
[完了]「続けて登録する」──▶ POST /insertDepartmentInput(空)/「メニューに戻る」
ステップ 4-1:定数を追加
// TransitionTargetPageNameKeyword
public static final String INSERT_DEPARTMENT_INPUT_HTML = "insertDepartmentInput";
public static final String INSERT_DEPARTMENT_CONFIRM_HTML = "insertDepartmentConfirm";
public static final String INSERT_DEPARTMENT_RESULT_HTML = "insertDepartmentResult";
public static final String INSERT_DEPARTMENT_INPUT = "/insertDepartmentInput";
public static final String INSERT_DEPARTMENT_CONFIRM = "/insertDepartmentConfirm";
public static final String INSERT_DEPARTMENT = "/insertDepartment";
// PageReturnAttributeKeyword
public static final String INSERT_DEPARTMENT_FORM = "insertDepartmentForm";
ステップ 4-2:登録 Form
…/presentation/form/InsertDepartmentForm.java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class InsertDepartmentForm {
private String nmDepartment;
/** Form → Model。部署IDは自動採番なので null を渡す */
public static Department convertTo(InsertDepartmentForm form) {
return new Department(null, form.getNmDepartment());
}
/** 入力チェック。エラーのあった項目名のリストを返す(なければ空のリスト) */
public List<String> validateParameter() {
List<String> error = new ArrayList<>();
// ★部署名が null または空白だけなら error.add("部署名");
return error;
}
}
InsertEmployeeForm.validateParameter() が書き方のお手本です(java.util.List と ArrayList の import も忘れずに)。
ステップ 4-3:登録サービス(起動エラーを体験・解決する)
RegisterDepartment(インタフェース)はすでにありますが、実装クラスがありません。この状態で Controller に使うと、アプリの起動に失敗し、次のようなエラーが出ます。
No qualifying bean of type '…RegisterDepartment' available
考える:このエラーは何を伝えている?(Spring が RegisterDepartment を実装した @Service を探したが無かった)
…/domain/service/impl/RegisterDepartmentImpl.java(お手本:RegisterEmployeeImpl)
@Service
@Transactional
public class RegisterDepartmentImpl implements RegisterDepartment {
DepartmentRepository departmentRepository;
@Autowired
public RegisterDepartmentImpl(DepartmentRepository departmentRepository) {
this.departmentRepository = departmentRepository;
}
public void registerDepartment(Department department) {
// ★考える:Entity に変換して save。ID が null のとき save は何をする?(自動採番して追加)
}
}
ステップ 4-4:3つの Controller
共通の約束:@ModelAttribute で空の InsertDepartmentForm を用意し(insertDepartmentForm() メソッド)、各メソッドの引数に InsertDepartmentForm を取ります。
① InsertDepartmentInputController(/insertDepartmentInput)
@RequestMapping(value = TransitionTargetPageNameKeyword.INSERT_DEPARTMENT_INPUT, method = RequestMethod.POST)
public String insertInput(InsertDepartmentForm insertDepartmentForm, Model model) {
model.addAttribute(PageReturnAttributeKeyword.INSERT_DEPARTMENT_FORM, insertDepartmentForm);
return TransitionTargetPageNameKeyword.INSERT_DEPARTMENT_INPUT_HTML;
}
なぜ引数の Form を model に載せ直す?(確認画面の「登録内容の編集」から戻ったとき、入力済みの値を残すため。メニューから来たときは空の Form)
② InsertDepartmentConfirmController(/insertDepartmentConfirm)
| 順 | チェック | NGのとき |
|---|---|---|
| 1 | insertDepartmentForm.validateParameter() が空でない |
メッセージ String.join(", ", error) + "が不正です。"(=部署名が不正です。)→ 入力画面 |
| 2 | 同名の部署がある(searchBy(new SearchConditionDepartment(null, 部署名)) が空でない) |
既に同名の部署が存在します。 → 入力画面 |
| 3 | OK | 確認画面 INSERT_DEPARTMENT_CONFIRM_HTML |
エラー時も OK 時も、INSERT_DEPARTMENT_FORM で Form を model に載せます(値を画面に残すため)。コンストラクタは HttpSession と SearchDepartment を受け取ります。
③ InsertDepartmentController(/insertDepartment)
①と同じチェック(入力・重複)をもう一度行い、問題なければ registerDepartment.registerDepartment(InsertDepartmentForm.convertTo(form)) を呼んで、INSERT_DEPARTMENT_RESULT_HTML を返します。コンストラクタは HttpSession、RegisterDepartment、SearchDepartment。
考える:確認画面でチェック済みなのに、なぜ登録時に再チェック?(確認画面を通らずに直接送られる/確認と登録の間に別の人が同名を登録することがある)。完了画面でブラウザを更新(F5)すると、もう一度送信されます。再チェックが無いと二重登録になってしまいます。
ステップ 4-5:3つの HTML
お手本の insertEmployeeInput.html / insertEmployeeConfirm.html / insertEmployeeResult.html を見ながら作ります。部署に不要な項目(社員名・パスワード・権限・所属部署など)はすべて取り除いてください。
insertDepartmentInput.html(要点)
<form action="insertDepartmentConfirm" method="post" th:object="${insertDepartmentForm}">
<!-- エラーメッセージ表示(他の画面と同じ) -->
<table>
<tr>
<th class="header">部署ID</th>
<td><input class="readOnly" readonly="readonly" type="text" value="自動で設定されます"/></td>
</tr>
<tr>
<th class="header">部署名</th>
<td><input maxlength="50" placeholder="部署名" required="required"
th:field="*{nmDepartment}" type="text"/></td>
</tr>
<tr><td class="btnArea" colspan="2"><input type="submit" value="登録確認"/></td></tr>
</table>
</form>
<a href="showMenu">メニューに戻る</a>
insertDepartmentConfirm.html(要点)
<form action="insertDepartment" id="insertForm" method="post" th:object="${insertDepartmentForm}">
<table>
<tr>
<th class="header">部署ID</th>
<td><input class="readOnly" readonly="readonly" type="text" value="自動で設定されます"/></td>
</tr>
<tr>
<th class="header">部署名</th>
<td><input class="readOnly" name="nmDepartment" readonly="readonly"
th:value="*{nmDepartment}" type="text"/></td>
</tr>
<tr><td class="btnArea" colspan="2"><input type="submit" value="登録"/></td></tr>
</table>
<a href="javascript:onClickEdit()">登録内容の編集</a> | <a href="showMenu">メニューに戻る</a>
</form>
<script type="text/javascript">
function onClickEdit() {
const form = document.getElementById('insertForm');
form.setAttribute('method', 'post');
form.setAttribute('action', 'insertDepartmentInput');
form.submit();
}
</script>
name="nmDepartment" が付いているので、画面の値がそのまま次の POST で送られます。「登録内容の編集」は、同じフォームの送信先だけを insertDepartmentInput に切り替えているので、値が入力画面に戻ります。
insertDepartmentResult.html(要点)
<h2>部署登録が完了しました</h2>
<a href="javascript:insertSubmit()">続けて登録する</a> | <a href="showMenu">メニューに戻る</a>
<script type="text/javascript">
function insertSubmit() {
const form = document.createElement('form');
form.setAttribute('method', 'post');
form.setAttribute('action', 'insertDepartmentInput');
document.body.appendChild(form);
form.submit();
}
</script>
(<head> は他の画面と同じ内容にしてください。)
ステップ 4-6:メニューにリンク追加
<tr>
<td><a class="menuClass" href="javascript:insertDepartmentSubmit()"><i class="fas fa-plus"></i> 部署登録 </a></td>
<td class="description">新規で部署追加を行います。</td>
</tr>
ステップ 4-7:部署名の桁数を確認する(重要)
実験:部署名に 11文字以上(例:ABCDEFGHIJK)を入れて登録してみてください。
- 期待されるのは、設計書の「1〜50文字」で登録できること。
- しかし、DDL は
nm_department VARCHAR(10)のため、MySQL のエラー(Data too long for column 'nm_department')になり、エラー画面に移動してログアウト状態になります(Controller のcatchがセッションを破棄するため)。
これは設計書と実際のDBの食い違いです。あなたの実装のミスではありません。README の「問題が出そうな場合は、必ずルールにしたがって適切な相手に相談してください」に従い、講師に報告して判断を仰いでください。報告のしかた(例):
「部署名に11文字以上を入れると登録でDBエラーになります。テーブル定義書は
varchar(50)、DDL はVARCHAR(10)です。再現手順は…、ログの該当行は…。どちらに合わせればよいでしょうか」
(講師から DDL の変更や maxlength の指示があれば、それに従ってください。)
ステップ 4-8:動作確認(単体テスト #21〜#31)
| # | 操作 | 期待 |
|---|---|---|
| 21 | メニュー「部署登録」 | 部署IDは「自動で設定されます」、部署名は空欄 |
| 22, 23 | メニューに戻る/ログアウト | それぞれ遷移する |
| 24 | テスト部署1 → 登録確認 |
確認画面に表示される |
| 25 | 「登録内容の編集」 | 入力画面に テスト部署1 が残っている |
| 26 | 役員 → 登録確認 |
既に同名の部署が存在します。 |
| 27 | 確認画面で「メニューに戻る」 | メニュー画面 |
| 28 | テスト部署1 → 登録確認 →「登録」 |
部署登録が完了しました |
| 29 | 「続けて登録する」 | 入力画面(空) |
| 30 | テスト部署1 → 登録確認 |
同名エラー |
| 31 | テスト部署2 を登録 → 完了 → メニューに戻る |
登録できる |
7.1 結合テスト
結合テスト仕様書の部署関連シナリオ(部署の削除と登録、部署を使った社員登録、社員の異動後の部署削除)を実施します。
※結合テスト1件目の「sugi@hoge.jp でログイン」は、sugi が一般ユーザーのため社員登録ができません。管理者(yamada@hoge.jp)で実施してください。
7.2 第4段階の振り返り
- 確認画面から「登録内容の編集」で値が戻る仕組みを説明できる
- 登録時に再チェックする理由を説明できる
- 起動エラー(
No qualifying bean)の意味を説明できる
8. 第5段階:アクセス制御アノテーション(チャレンジその2)
8.0 ゴール
第1〜4段階のすべての Controller メソッドに既存の @SessionCheck と @PermissionCheck を付け、未ログインや一般ユーザーが部署画面に入れないようにします。
ステップ 5-1:既存の仕組みを読み解く(README:機能を解析する)
次の順に読み、それぞれ自分の言葉でノートに説明を書いてください。
aop/aspect/SessionCheck.javaとPermissionCheck.java:アノテーションの定義(@Target(METHOD)、@Retention(RUNTIME)の意味)aop/aspect/SessionCheckAspect.java:@Pointcut、@Before。ログイン情報がセッションに無いとどうなる?aop/aspect/PermissionCheckAspect.java:リクエストのURLを何と比べている?(TransitionTargetPageNameKeywordのgetAdminPageList()/getUserPageList())aop/Handler/GlobalExceptionHandler.java:投げられた例外を受けて、どの画面に何を表示している?- 既存の
MenuController:アノテーションがどこに付いている?
README の注意「アノテーションをコピペしない」 の意味:アノテーションのクラス自体を別の場所にコピーして使うと、チェックが一切動作しません(エラーも出ません)。
SessionCheckAspectの@Pointcutは「com.example.rhizome.aop.aspect.SessionCheckというクラスのアノテーション」を指定しているためです。必ず既存のものをimportして使ってください。
ステップ 5-2:付与する
全 Controller の @RequestMapping メソッドに、お手本(社員機能)と同じように2つを付けます(検索の2つの select メソッドも両方)。
import com.example.rhizome.aop.aspect.PermissionCheck;
import com.example.rhizome.aop.aspect.SessionCheck;
@PermissionCheck
@SessionCheck
@RequestMapping(value = …, method = RequestMethod.POST)
public String …
ステップ 5-3:URLを許可リストに登録する
付けた直後に管理者で操作してみてください。ログイン画面に戻され「不正な遷移です。」と表示されるはずです。
原因を自分で調べる:コンソールのどの例外?(InsufficientPermissionException)それを投げているのは?(PermissionCheckAspect)何と何を比較している?
対処:TransitionTargetPageNameKeyword.getAdminPageList() の Set.of(…) に、部署の Controller の URL をすべて追加します。
SELECT_DEPARTMENT,
SELECT_DEPARTMENT_FORM_EMPTY,
DELETE_DEPARTMENT,
SHOW_UPDATE_DEPARTMENT,
UPDATE_DEPARTMENT_RESULT,
INSERT_DEPARTMENT_INPUT,
INSERT_DEPARTMENT_CONFIRM,
INSERT_DEPARTMENT
特に重要:
forward:で到達するSELECT_DEPARTMENT_FORM_EMPTY(/selectDepartmentAll)も必要です。forward:で呼ばれた先でも権限チェックが動き、そのときのURLは forward 先のURL になるためです。登録を忘れると、削除や更新はDBに反映されるのに、画面には「不正な遷移です。」が出ます。
ステップ 5-4:3パターンで確認する
| パターン | 操作 | 期待 |
|---|---|---|
| 未ログイン | ログインせずに POST /selectDepartment を送る |
ログイン画面+不正な遷移です。 |
| 一般ユーザー | jtaka@hoge.jp/bbb でログインし、POST /selectDepartment を送る |
同上 |
| 管理者 | yamada@hoge.jp で部署の全機能を操作 |
すべて正常 |
POST を直接送る方法(Windows は curl.exe):
curl -s -c cj.txt -b cj.txt -X POST http://localhost:8080/doLogin -d "mailAddress=jtaka@hoge.jp&password=bbb"
curl -s -c cj.txt -b cj.txt -X POST http://localhost:8080/selectDepartment
対照実験:いったんアノテーションを外すと、一般ユーザーでも部署画面が見えてしまうことを確認し、付けるとブロックされることを確認してください。「メニューにリンクを出さなければ十分」ではない理由を説明できるようにします(URLを直接送れば画面に入れてしまうため)。
8.1 第5段階の振り返り
- AOP の仕組み(アノテーション → Aspect → 例外 → ControllerAdvice)を図で説明できる
- 許可リストに forward 先のURLも必要な理由を説明できる
- 3パターンの確認結果を記録した
9. テストの進め方(テスト仕様書の使い方)
テスト仕様書/単体テスト仕様書.xlsxの「部署管理テスト仕様書」を、各段階の終わりに、該当する項番を上から順に実施します。- 検索:#1〜#8/削除:#32〜#35/更新:#9〜#20/登録:#21〜#31
- 実施日と結果(OK/NG)、気づいたことを備考欄に記入します。
- NG は原因を調べて修正し、最初からやり直します(一部だけ直して終わりにしない)。
- テスト順の注意:#10・#12・#14・#17・#20 は、前の項番の結果(部署名が
役員改になっている等)を前提にしています。上から順に実施してください。やり直すときはDBを初期状態に戻します(§1.2)。 テスト仕様書の「表示データ」シートは社員用のデータで、部署の期待値がそのまま書かれていません。期待される結果は、§1.2 の初期データとテスト項目の文言から判断します(分からなければ質問してください)。
Maven のビルド(
mvn install)では、プロジェクトの設定上、JUnit のテストは実行されません。テスト仕様書の確認は画面を操作する手動テストで行います。
10. 詰まったとき
10.1 エラーの読み方(5ステップ)
- 症状を言葉にする:何をして、どうなった?
- Eclipse のコンソールを開き、赤い文字のうち、一番下の
Caused by:の行を探す。 - 自分のクラス名(
com.example.rhizome…)が出ている最初の行を見る(その行番号が原因の手がかり)。 - ブラウザの F12 → Network タブで、送信先URLとパラメータを確認する。
- Controller の先頭にブレークポイントを置いてデバッグ実行し、値を確認する。
Controller の
catchはすべて、セッションを破棄してエラー画面を出す作りです。画面が「エラー」になったら、コンソールのスタックトレースを必ず見てください。
10.2 症状別の早見表
| 症状 | 考えられる原因 | 確認すること |
|---|---|---|
| ビルドで「シンボルを見つけられません getXxx()」が大量に出る | JDK が17ではない/Lombok が効いていない | java -version、Eclipse の JRE 設定 |
起動時 Table 'rezodb.employee' doesn't exist |
DDLを実行していない | DB のテーブルを確認 |
起動時 Access denied / Unknown database |
DB・ユーザー未作成 | §1.2、application.properties |
起動時 NoSuchBeanDefinitionException / UnsatisfiedDependencyException |
@Service の付け忘れ/実装クラスが無い/パッケージの置き場所 |
例外の最後の Caused by に書かれた型 |
画面が エラー になる |
Controller 内で例外が発生(握りつぶされている) | コンソールの Caused by |
| 画面に「不正なリクエストです。」+ 405 | POSTのURLを GET で呼んだ(<a href> で直接遷移した等) |
F12 Network のメソッド |
| 404 Not Found | URLの綴り違い/先頭の / の有無 |
Network のURLと @RequestMapping の値を並べる |
Error resolving template [xxx] |
返した画面名と templates のファイル名が不一致 |
戻り値の文字列とファイル名 |
Neither BindingResult nor plain target object for bean name 'xxxForm' |
th:object の Form が model に無い(@ModelAttribute が無い/別の Controller から画面名で返した) |
その Form を誰が model に載せているか |
Exception evaluating SpringEL expression |
HTML 内の項目名の綴り違い | Form のフィールド名と *{…} |
| 検索で条件が効かない・全件になる | name 属性とFormのフィールド名の不一致 |
HTML の th:field と Form |
| 削除・更新のあと、1件しか表示されない | forward: で元のパラメータが検索条件に使われた |
戻り先が全件用のURLか |
| 更新したのに行が増えた | IDが送られていない(disabledにした等)/ID null の save は追加 |
F12 Network の送信値、DBの行 |
| 管理者なのに「不正な遷移です。」 | 許可リストにURL(特に forward 先)が無い | §8 ステップ 5-3 |
Data too long for column 'nm_department' |
DDLが VARCHAR(10) |
§7 ステップ 4-7(講師に報告) |
| クリアを押したら検索が走る | <button> は type 省略で送信ボタン |
§4 ステップ 1-6 |
| 日本語が文字化け | ファイルの文字コードが UTF-8 ではない | Eclipse のエンコーディング設定 |
| ポート8080が使用中 | 前回起動したプロセスが残っている | 停止してから再起動 |
10.3 相談の型(質問は次の4点をそろえて)
- やりたいこと(設計書のどの項目か)
- 期待した結果/実際の結果(画面の状態、エラーの該当ログ)
- 試したこと(調べたキーワード、変更した内容)
- 自分の考え(原因はこうだと思う、理由は…)
11. 提出前チェックリスト
コード
- ビルドが通り、起動できる
- 画面名・URL・modelのキーが定数になっている(文字列の直書きがない)
- 社員機能からの不要な処理(パスワード、部署プルダウン等)が残っていない
- 使っていない import・コメントアウトしたコード・デバッグ用の出力が残っていない
- メッセージの文言が設計書・テスト仕様書と一字一句同じ
- 既存のファイルを不要に書き換えていない(変更は定数・メニューへの追記のみ)
テスト
- 単体テスト仕様書(部署管理)の実施日・結果を記入(第3段階までは必須)
- 異常系を自分で追加確認した(存在しないID、
requiredを外した送信 など) - 結合テスト仕様書(部署関連)を実施できた範囲で記入
理解(口頭で説明できること)
- 画面からDBまでのリクエストの流れ
forward:と全件用エンドポイントを使う理由- 更新の検証順序の理由
- (第5段階まで行った人)AOPの仕組みと許可リストの役割
気づいたことのメモ(加点対象)
- 設計書の誤記や、設計書とDBの食い違い(桁数など)を報告した
合格ライン:第3段階まで(検索・削除・更新)とテストの実施記録。第4・5段階はチャレンジです。