第30章 Web 社員管理アプリ:編集・更新
この章の目的
Section titled “この章の目的”この章では、第 28・29 章で組み上げてきた Web 社員管理アプリを完成形にします。
これまで disabled で枠だけ置いていた 新規登録・削除 と、新たに追加する 編集 ── つまり業務アプリの CRUD(Create / Read / Update / Delete)をすべて動かせるようにします。
| 操作 | SQL | 章 |
|---|---|---|
| Create(新規登録) | INSERT | 本章 |
| Read(一覧・検索) | SELECT | 第 28・29 章 |
| Update(更新) | UPDATE | 本章 |
| Delete(削除) | DELETE | 本章 |
加えて、Web ならではのテーマを 3 つ扱います。
- ルーティングパラメータ(
/Employees/Edit/1001の1001の渡し方) - PRG パターン(Post → Redirect → Get で「再読み込みでの二重送信」を防ぐ)
- 入力チェック(ModelState バリデーション) で、エラーを画面に出す
第 25 章(Windows 版編集)を仕上げたときの考え方は、Web でもそのまま使えます。違いは「同じフォーム内で完結」から「URL の変化で画面が遷移する」に置き換わるところです。
この章でできるようになること
Section titled “この章でできるようになること”この章を終えると、次のことができるようになります。
EmployeeRepository.Insert/Update/DeleteをSqlParameterで実装できるOUTPUT INSERTED.column_nameで IDENTITY 採番の値を取得できるExecuteScalar/ExecuteReader/ExecuteNonQueryを使い分けられるDBNull.Valueの必要性を説明できる- ルーティング
{controller}/{action}/{id?}を読み解ける /Employees/Edit/1001のような URL から、Controller の引数に値が入る仕組みを説明できる- PRG(Post-Redirect-Get)パターン を理解し、
RedirectToActionを使い分けられる ModelState.AddModelErrorでエラーを Controller から積み、View でasp-validation-forを使って表示できる- 新規登録・編集・削除をすべて動かせる Web 業務アプリを組み立てられる
- Windows 版(第 25 章)と Web 版の違い(画面遷移と状態管理)を説明できる
本章で使用する環境
Section titled “本章で使用する環境”| 項目 | 内容 |
|---|---|
| 開発環境 | Visual Studio 2022 |
| プロジェクト種類 | ASP.NET Core Web アプリ(Model-View-Controller) |
| 対象フレームワーク | .NET 8 |
| ソリューション名 | KadaiWebApp(第 28 章で作成・続けて使う) |
| プロジェクト名 | MvcEmployeeApp(第 28・29 章の続き・作り直さない) |
| ベースとなる章 | 第 29 章(MvcEmployeeApp をそのまま開く) |
| データベース | SQLServer 2022(TrainingDB) |
| 認証方式(DB) | SQL 認証(app_user、第 27 章で作成済み) |
| NuGet パッケージ | Microsoft.Data.SqlClient |
この章は第 28・29 章の
MvcEmployeeAppを続けて使います新しいプロジェクトは作りません。
KadaiWebAppソリューションのMvcEmployeeAppを開き、これまでdisabledだった新規登録・削除を動かし、編集を足してアプリを完成させます(Nullable・NuGet・SQL 認証は第 28 章までで設定済み)。
作業前チェック
Section titled “作業前チェック”- 第 29 章を Git に提出済み、または手元にコードがある
-
TrainingDBのemployees/departmentsがアクセス可能(app_userで読み書き両方できる) - 第 29 章
MvcEmployeeAppの/Employeesで検索が動く - 第 25 章(Windows 版編集)の
EmployeeRepository.Insert/Update/Deleteを読んだことがある
30-1 この章で完成させる機能
Section titled “30-1 この章で完成させる機能”完成形の /Employees 画面はこうなります。
社員一覧 [新規登録] ← 本章で動く
名前: [ ] 部署: [すべての部署 ▼] [検索] [クリア]
10 件の社員が見つかりました。
| 社員ID | 氏名 | 部署名 | メール | 入社日 | 給与 | 操作 ||--------|--------------|--------|----------|------------|----------|--------------------|| 1001 | 山田 二郎 | 総務 | yamada@. | 2001/04/01 | ¥500,000 | [編集] [削除] || 1002 | 佐藤 昭夫 | 営業 | sato@. | 2002/04/01 | ¥500,000 | [編集] [削除] || ... | | | | | | |URL 設計(本章で追加・変更):
| URL | アクション | 用途 |
|---|---|---|
/Employees | Index (GET) | 一覧・検索(既存) |
/Employees/Create | Create (GET) | 新規登録フォーム |
/Employees/Create | Create (POST) | 新規登録の保存 |
/Employees/Edit/1001 | Edit (GET) | 編集フォーム(1001 の社員) |
/Employees/Edit/1001 | Edit (POST) | 編集の保存 |
/Employees/Delete/1001 | Delete (POST) | 削除 |
Windows 版(第 25 章)が 同じフォームで 1 つのウィンドウ だったのに対し、Web 版では 画面ごとに別の URL を持たせるのが基本です。
30-2 EmployeeRepository に Insert / Update / Delete を実装する
Section titled “30-2 EmployeeRepository に Insert / Update / Delete を実装する”第 29 章で NotImplementedException を返していた Insert と Delete を実装し、新しく Update と GetById を追加します。
第 25 章 25-2 の Windows 版とほぼ同じコードで、namespace と接続文字列だけが違います。
using Microsoft.Data.SqlClient;using MvcEmployeeApp.Models;
namespace MvcEmployeeApp.Data;
public class EmployeeRepository{ private const string ConnectionString = "Server=localhost;Database=TrainingDB;User Id=app_user;Password=AppUserPass1!;TrustServerCertificate=true;";
// 第 28 章で実装済み(GetAll、Search、ReadEmployees は省略 ── そのまま残す)
public Employee GetById(int employeeId) { const string sql = @" SELECT e.employee_id, e.last_name, e.first_name, e.email, e.hire_date, e.salary, e.department_id, d.department_name FROM employees e LEFT JOIN departments d ON e.department_id = d.department_id WHERE e.employee_id = @employeeId";
using SqlConnection conn = new SqlConnection(ConnectionString); conn.Open();
using SqlCommand cmd = new SqlCommand(sql, conn); cmd.Parameters.AddWithValue("@employeeId", employeeId);
List<Employee> result = ReadEmployees(cmd); return result.Count > 0 ? result[0] : null; }
public int Insert(Employee employee) { const string sql = @" INSERT INTO employees (last_name, first_name, email, hire_date, salary, department_id) OUTPUT INSERTED.employee_id VALUES (@lastName, @firstName, @email, @hireDate, @salary, @departmentId)";
using SqlConnection conn = new SqlConnection(ConnectionString); conn.Open();
using SqlCommand cmd = new SqlCommand(sql, conn); cmd.Parameters.AddWithValue("@lastName", employee.LastName); cmd.Parameters.AddWithValue("@firstName", employee.FirstName); cmd.Parameters.AddWithValue("@email", string.IsNullOrEmpty(employee.Email) ? (object)DBNull.Value : employee.Email); cmd.Parameters.AddWithValue("@hireDate", employee.HireDate); cmd.Parameters.AddWithValue("@salary", employee.Salary); cmd.Parameters.AddWithValue("@departmentId", employee.DepartmentId);
int newId = (int)cmd.ExecuteScalar(); return newId; }
public int Update(Employee employee) { const string sql = @" UPDATE employees SET last_name = @lastName, first_name = @firstName, email = @email, hire_date = @hireDate, salary = @salary, department_id = @departmentId WHERE employee_id = @employeeId";
using SqlConnection conn = new SqlConnection(ConnectionString); conn.Open();
using SqlCommand cmd = new SqlCommand(sql, conn); cmd.Parameters.AddWithValue("@employeeId", employee.EmployeeId); cmd.Parameters.AddWithValue("@lastName", employee.LastName); cmd.Parameters.AddWithValue("@firstName", employee.FirstName); cmd.Parameters.AddWithValue("@email", string.IsNullOrEmpty(employee.Email) ? (object)DBNull.Value : employee.Email); cmd.Parameters.AddWithValue("@hireDate", employee.HireDate); cmd.Parameters.AddWithValue("@salary", employee.Salary); cmd.Parameters.AddWithValue("@departmentId", employee.DepartmentId);
return cmd.ExecuteNonQuery(); }
public int Delete(int employeeId) { const string sql = "DELETE FROM employees WHERE employee_id = @employeeId";
using SqlConnection conn = new SqlConnection(ConnectionString); conn.Open();
using SqlCommand cmd = new SqlCommand(sql, conn); cmd.Parameters.AddWithValue("@employeeId", employeeId);
return cmd.ExecuteNonQuery(); }}コード全体について
第 29 章で書いた
GetAll、Search、ReadEmployeesは そのまま残します。本章ではGetById/Insert/Update/Deleteを追加し、第 29 章で枠だけだったInsert/Deleteを本実装に差し替えます。
各メソッドのポイント
Section titled “各メソッドのポイント”| メソッド | 戻り値 | ポイント |
|---|---|---|
GetById | Employee(無ければ null) | 編集画面に開く 1 件の値を取得 |
Insert | 新規採番された employee_id | OUTPUT INSERTED.employee_id で IDENTITY を取得 |
Update | 影響行数(0 か 1) | ExecuteNonQuery は INSERT/UPDATE/DELETE で使う |
Delete | 影響行数(0 か 1) | WHERE employee_id = @employeeId を 必ず付ける(付け忘れると全件削除) |
ExecuteScalar / ExecuteReader / ExecuteNonQuery の使い分け
Section titled “ExecuteScalar / ExecuteReader / ExecuteNonQuery の使い分け”| メソッド | 想定する SQL | 戻り値 |
|---|---|---|
ExecuteScalar | 1 行 1 列が返る SELECT、OUTPUT 付き INSERT | 先頭セルの値(object) |
ExecuteReader | 複数行が返る SELECT | SqlDataReader |
ExecuteNonQuery | INSERT / UPDATE / DELETE などデータ変更系 | 影響行数(int) |
DBNull.Value の扱い(再確認)
Section titled “DBNull.Value の扱い(再確認)”email 列は NULL を許容するため、C# の空文字列を直接渡すと SQL 側で '' として保存され、UNIQUE 制約と相性が悪くなる場合があります。
空のときは DBNull.Value を渡す ことで、SQL 側に「NULL を入れて」と明示します。
cmd.Parameters.AddWithValue("@email", string.IsNullOrEmpty(employee.Email) ? (object)DBNull.Value : employee.Email);(object) のキャストは、三項演算子の左右の型を揃えるためです(DBNull と string の共通の基底型)。
30-3 ルーティングパラメータと URL 設計
Section titled “30-3 ルーティングパラメータと URL 設計”/Employees/Edit/1001 のような URL から、Controller の引数 id に 1001 が入る仕組みを ルーティング といいます。
Program.cs に既定で次のような登録があります(テンプレートが生成)。
app.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}");| 部分 | 説明 |
|---|---|
{controller} | URL の 1 番目 → Controller 名(例:Employees) |
{action} | URL の 2 番目 → アクション名(例:Edit) |
{id?} | URL の 3 番目 → アクション引数 id に渡る。? は省略可能 の意味 |
=Home / =Index | URL が省略されたときの既定値 |
URL → Controller アクション id/Employees → EmployeesController.Index() (なし)/Employees/Create → EmployeesController.Create() (なし)/Employees/Edit/1001 → EmployeesController.Edit(1001) 1001/Employees/Delete/1001 → EmployeesController.Delete(1001) 1001| 観点 | クエリ文字列(第 29 章で使ったもの) | ルートパラメータ(本章) |
|---|---|---|
| 形 | /Employees?keyword=山 | /Employees/Edit/1001 |
| 用途 | 検索条件・フィルタなど | リソースを一意に指す ID |
| 順序 | 任意 | 固定(URL に組み込まれる) |
| 引数の受け取り方 | string keyword | int id |
「ID は URL に組み込む、検索条件はクエリ文字列に付ける」が一般的な使い分けです。
30-4 EmployeeEditViewModel を作る
Section titled “30-4 EmployeeEditViewModel を作る”新規・編集の画面でも、第 29 章と同じように View 専用の入れ物 を用意します。 入力フィールド + 部署プルダウンの選択肢をまとめます。
Models フォルダに EmployeeEditViewModel.cs を追加します。
namespace MvcEmployeeApp.Models;
public class EmployeeEditViewModel{ public int EmployeeId { get; set; } public string LastName { get; set; } = ""; public string FirstName { get; set; } = ""; public string Email { get; set; } = ""; public DateTime HireDate { get; set; } public decimal Salary { get; set; } public int DepartmentId { get; set; }
// プルダウンの選択肢 public List<Department> Departments { get; set; } = new List<Department>();}| プロパティ | 役割 |
|---|---|
EmployeeId | 編集時のみ値が入る(新規時は 0) |
LastName 〜 DepartmentId | 入力欄に対応 |
Departments | 部署プルダウンの選択肢 |
新規モードと編集モードを 1 つの ViewModel で兼ねている のは、第 25 章の EmployeeEditForm が「新規/編集を 1 つのフォームで扱う」設計だったのと同じです。
30-5 EmployeesController を拡張する
Section titled “30-5 EmployeesController を拡張する”ここがこの章の中核です。新規・編集・削除の GET / POST アクション を追加していきます。
本章を通じて、EmployeesController 全体は次のようになります(Index は第 29 章のまま)。
using Microsoft.AspNetCore.Mvc;using MvcEmployeeApp.Data;using MvcEmployeeApp.Models;
namespace MvcEmployeeApp.Controllers;
public class EmployeesController : Controller{ // 第 29 章の Index は省略(そのまま残す)
// GET: /Employees/Create [HttpGet] public IActionResult Create() { DepartmentRepository deptRepo = new DepartmentRepository(); EmployeeEditViewModel model = new EmployeeEditViewModel(); model.HireDate = DateTime.Today; model.Departments = deptRepo.GetAll(); return View(model); }
// POST: /Employees/Create [HttpPost] public IActionResult Create(EmployeeEditViewModel model) { ValidateEmployeeInput(model);
if (!ModelState.IsValid) { DepartmentRepository deptRepo = new DepartmentRepository(); model.Departments = deptRepo.GetAll(); return View(model); }
EmployeeRepository empRepo = new EmployeeRepository(); Employee employee = new Employee(); employee.LastName = model.LastName; employee.FirstName = model.FirstName; employee.Email = model.Email; employee.HireDate = model.HireDate; employee.Salary = model.Salary; employee.DepartmentId = model.DepartmentId; empRepo.Insert(employee);
return RedirectToAction("Index"); }
// GET: /Employees/Edit/{id} [HttpGet] public IActionResult Edit(int id) { EmployeeRepository empRepo = new EmployeeRepository(); Employee employee = empRepo.GetById(id); if (employee == null) { return NotFound(); }
DepartmentRepository deptRepo = new DepartmentRepository(); EmployeeEditViewModel model = new EmployeeEditViewModel(); model.EmployeeId = employee.EmployeeId; model.LastName = employee.LastName; model.FirstName = employee.FirstName; model.Email = employee.Email; model.HireDate = employee.HireDate; model.Salary = employee.Salary; model.DepartmentId = employee.DepartmentId; model.Departments = deptRepo.GetAll(); return View(model); }
// POST: /Employees/Edit/{id} [HttpPost] public IActionResult Edit(int id, EmployeeEditViewModel model) { model.EmployeeId = id; ValidateEmployeeInput(model);
if (!ModelState.IsValid) { DepartmentRepository deptRepo = new DepartmentRepository(); model.Departments = deptRepo.GetAll(); return View(model); }
EmployeeRepository empRepo = new EmployeeRepository(); Employee employee = new Employee(); employee.EmployeeId = id; employee.LastName = model.LastName; employee.FirstName = model.FirstName; employee.Email = model.Email; employee.HireDate = model.HireDate; employee.Salary = model.Salary; employee.DepartmentId = model.DepartmentId; empRepo.Update(employee);
return RedirectToAction("Index"); }
// POST: /Employees/Delete/{id} [HttpPost] public IActionResult Delete(int id) { EmployeeRepository empRepo = new EmployeeRepository(); empRepo.Delete(id); return RedirectToAction("Index"); }
private void ValidateEmployeeInput(EmployeeEditViewModel model) { if (string.IsNullOrWhiteSpace(model.LastName)) { ModelState.AddModelError("LastName", "「姓」は必須です。"); } if (string.IsNullOrWhiteSpace(model.FirstName)) { ModelState.AddModelError("FirstName", "「名」は必須です。"); } if (!string.IsNullOrEmpty(model.Email) && !model.Email.Contains('@')) { ModelState.AddModelError("Email", "「メール」は @ を含む形式で入力してください。"); } if (model.DepartmentId <= 0) { ModelState.AddModelError("DepartmentId", "「部署」を選択してください。"); } if (model.Salary < 0) { ModelState.AddModelError("Salary", "「給与」は 0 以上で入力してください。"); } }}構造のポイント
Section titled “構造のポイント”| ポイント | 説明 |
|---|---|
| GET と POST のペア | Create・Edit は 画面を見せる GET と 保存する POST の 2 つを書く |
[HttpGet] / [HttpPost] 属性 | 同じ名前のアクションでも、HTTP メソッドで自動的に振り分けられる |
int id 引数 | ルーティング {id?} から自動的に値が入る(Model Binding) |
NotFound() | 該当する社員が居なければ HTTP 404 を返す |
RedirectToAction("Index") | 保存後は 一覧画面に戻る(後述の PRG) |
ValidateEmployeeInput | 共通のチェック処理。Create/Edit 両方から呼ぶ |
Delete を [HttpPost] だけにしているのは、GET で削除を実行できてしまうとブラウザの先読みや URL 直接アクセスで誤削除が起こる からです(後述)。
30-6 PRG(Post-Redirect-Get)パターン
Section titled “30-6 PRG(Post-Redirect-Get)パターン”保存後に return View(model) ではなく return RedirectToAction("Index") を返している理由が、この PRG パターンです。
問題:単純に View(...) を返した場合
Section titled “問題:単純に View(...) を返した場合”[HttpPost]public IActionResult Create(EmployeeEditViewModel model){ empRepo.Insert(...); return View(...); // ← よくない}このコードでは、保存後にブラウザに POST のレスポンス画面 が表示されます。 ここでユーザーがブラウザの 再読み込み(F5) を押すと、ブラウザは「直前の POST をもう一度送ろうとする」ため、
1. /Employees/Create に POST(社員 A を登録)2. レスポンスとして登録成功画面3. ユーザーが F5 → 再度 POST → 社員 A がもう 1 件入る(意図しない二重登録)という事故が起きます。
解決:Redirect で GET に切り替える
Section titled “解決:Redirect で GET に切り替える”[HttpPost]public IActionResult Create(EmployeeEditViewModel model){ empRepo.Insert(...); return RedirectToAction("Index"); // ← /Employees に GET でリダイレクト}RedirectToAction は、ブラウザに「この URL を GET で開き直してください」と HTTP 302 のレスポンスを返します。
1. /Employees/Create に POST(社員 A を登録)2. レスポンス: 302 Redirect → /Employees3. ブラウザが自動で /Employees に GET4. ユーザーが F5 → /Employees を GET で開き直すだけ(二重登録は起こらない)この「POST → Redirect → GET」の流れが PRG パターン です。Web アプリの「更新系処理」では事実上の標準です。
この往復を 1 枚にすると、次のようになります(新規登録の例)。
ポイントは、保存の POST と、一覧表示の GET が別のリクエストに分かれている ことです。ブラウザのアドレスバーは最終的に /Employees(GET)になっているので、F5 で繰り返されるのは「一覧の読み直し」だけになります。
| 操作 | 単純に View を返す | PRG パターン |
|---|---|---|
| 保存直後 | 入力フォームの再表示(or 成功画面) | 一覧画面 |
| 再読み込み(F5) | 二重送信のリスク | 安全(GET の再実行) |
| URL バー | /Employees/Create | /Employees |
30-7 入力チェック(ModelState バリデーション)
Section titled “30-7 入力チェック(ModelState バリデーション)”第 25 章では、ValidateInput(out string error) で MessageBox を出すスタイルでした。
Web では、エラーを ModelState に積んで、View 側でエラーマーカーを表示 するのが標準です。
Controller 側:エラーを積む
Section titled “Controller 側:エラーを積む”private void ValidateEmployeeInput(EmployeeEditViewModel model){ if (string.IsNullOrWhiteSpace(model.LastName)) { ModelState.AddModelError("LastName", "「姓」は必須です。"); } // ...}ModelState.AddModelError("プロパティ名", "メッセージ") で、特定のプロパティに対するエラーメッセージを登録します。
すべてのチェック後に ModelState.IsValid を確認すると、エラーが 1 件でも積まれていれば false になります。
View 側:エラーを表示する
Section titled “View 側:エラーを表示する”<div class="mb-3"> <label asp-for="LastName" class="form-label">姓</label> <input asp-for="LastName" class="form-control" /> <span asp-validation-for="LastName" class="text-danger"></span></div>asp-validation-for="LastName" は、ModelState に LastName のエラーが積まれていれば、その内容を出力する タグヘルパー です。エラーが無ければ何も出ません。
姓: [ ]「姓」は必須です。 ← 赤文字で出る入力値は残る
Section titled “入力値は残る”Create の POST で ModelState.IsValid が false だったとき、return View(model) で 同じ ViewModel を View に渡し直して います。
このとき、<input asp-for="LastName" /> は model.LastName の値を value に入れて再描画されるので、ユーザーの入力した値はそのまま残ります(空欄に戻らない)。
これは Web ならではの工夫で、Windows の MessageBox と違って「フォームを閉じない」「値を失わせない」を Razor が自然にやってくれます。
30-8 Create View(新規登録フォーム)を作る
Section titled “30-8 Create View(新規登録フォーム)を作る”Views/Employees フォルダに Create.cshtml を追加します。
@model MvcEmployeeApp.Models.EmployeeEditViewModel
@{ ViewData["Title"] = "社員の新規登録";}
<h1>社員の新規登録</h1>
<form asp-action="Create" method="post"> <div class="mb-3"> <label asp-for="LastName" class="form-label">姓</label> <input asp-for="LastName" class="form-control" /> <span asp-validation-for="LastName" class="text-danger"></span> </div>
<div class="mb-3"> <label asp-for="FirstName" class="form-label">名</label> <input asp-for="FirstName" class="form-control" /> <span asp-validation-for="FirstName" class="text-danger"></span> </div>
<div class="mb-3"> <label asp-for="Email" class="form-label">メール</label> <input asp-for="Email" class="form-control" /> <span asp-validation-for="Email" class="text-danger"></span> </div>
<div class="mb-3"> <label asp-for="HireDate" class="form-label">入社日</label> <input asp-for="HireDate" type="date" class="form-control" /> <span asp-validation-for="HireDate" class="text-danger"></span> </div>
<div class="mb-3"> <label asp-for="Salary" class="form-label">給与</label> <input asp-for="Salary" type="number" step="1" class="form-control" /> <span asp-validation-for="Salary" class="text-danger"></span> </div>
<div class="mb-3"> <label asp-for="DepartmentId" class="form-label">部署</label> <select asp-for="DepartmentId" class="form-control"> <option value="0">選択してください</option> @foreach (MvcEmployeeApp.Models.Department dept in Model.Departments) { <option value="@dept.DepartmentId">@dept.DepartmentName</option> } </select> <span asp-validation-for="DepartmentId" class="text-danger"></span> </div>
<button type="submit" class="btn btn-primary">保存</button> <a asp-action="Index" class="btn btn-secondary">キャンセル</a></form>| 要素 | 役割 |
|---|---|
<form asp-action="Create" method="post"> | 同じ Controller の Create(POST 版)に送信 |
<input asp-for="LastName"> | プロパティ名を name / id / value に自動展開 |
<input type="date"> | ブラウザの日付ピッカーが表示される |
<input type="number" step="1"> | 数値入力のスピンボックスが表示される |
<select asp-for="DepartmentId"> | 選んだ値が DepartmentId に Model Binding |
<a asp-action="Index"> | キャンセルは一覧へのリンク(GET なので何も保存されない) |
View のタグヘルパーと見た目クラスについて
<form asp-action>・<input asp-for>・<select asp-for>・<a asp-action>の役割は 第 26 章 26-12「MVC のおまじない早見表」 にまとまっています。本章で初めて出るasp-validation-for(30-7)・asp-route-idと<input type="hidden" asp-for>(30-9)は、それぞれの本文の説明を合わせて参照してください。class="mb-3"/form-label/form-control/btn btn-primaryなどは Bootstrap の見た目クラスで、付けると入力欄やボタンが整って表示されるだけです(中身の動きには関係しません。よく使うクラスの早見は 付録 K「最低限の HTML」K-5)。
<select>/<option>や@foreachが どうやって HTML になるのか 自体があやふやな場合は、第 29 章 29-8「<select>(プルダウン)はどう組み立てるか」 と付録 K(K-3 フォーム / K-4 表)に立ち返ると読みやすくなります。本章の<select asp-for="DepartmentId">は、その仕組みに加えて 選択中の<option>へのselected付けを ASP.NET Core が自動でやってくれる 版です(下記)。
<select>の選択値復元第 29 章では
<option>1 つ 1 つにselectedを付けていましたが、本章のように<select asp-for="DepartmentId">と書くと、ASP.NET Core がモデルの値に合う<option>に自動でselectedを付けて くれます。 ただし、リストの先頭に「選択してください(value=0)」を入れて、Validation でその「未選択」を弾けるようにしている点はポイントです。
30-9 Edit View(編集フォーム)を作る
Section titled “30-9 Edit View(編集フォーム)を作る”Views/Employees フォルダに Edit.cshtml を追加します。
新規(Create)とほぼ同じですが、画面タイトルと送信先アクション、それに EmployeeId を hidden で送る点が違います。
@model MvcEmployeeApp.Models.EmployeeEditViewModel
@{ ViewData["Title"] = "社員の編集";}
<h1>社員の編集 (ID: @Model.EmployeeId)</h1>
<form asp-action="Edit" asp-route-id="@Model.EmployeeId" method="post"> <input type="hidden" asp-for="EmployeeId" />
<div class="mb-3"> <label asp-for="LastName" class="form-label">姓</label> <input asp-for="LastName" class="form-control" /> <span asp-validation-for="LastName" class="text-danger"></span> </div>
<div class="mb-3"> <label asp-for="FirstName" class="form-label">名</label> <input asp-for="FirstName" class="form-control" /> <span asp-validation-for="FirstName" class="text-danger"></span> </div>
<div class="mb-3"> <label asp-for="Email" class="form-label">メール</label> <input asp-for="Email" class="form-control" /> <span asp-validation-for="Email" class="text-danger"></span> </div>
<div class="mb-3"> <label asp-for="HireDate" class="form-label">入社日</label> <input asp-for="HireDate" type="date" class="form-control" /> <span asp-validation-for="HireDate" class="text-danger"></span> </div>
<div class="mb-3"> <label asp-for="Salary" class="form-label">給与</label> <input asp-for="Salary" type="number" step="1" class="form-control" /> <span asp-validation-for="Salary" class="text-danger"></span> </div>
<div class="mb-3"> <label asp-for="DepartmentId" class="form-label">部署</label> <select asp-for="DepartmentId" class="form-control"> <option value="0">選択してください</option> @foreach (MvcEmployeeApp.Models.Department dept in Model.Departments) { <option value="@dept.DepartmentId">@dept.DepartmentName</option> } </select> <span asp-validation-for="DepartmentId" class="text-danger"></span> </div>
<button type="submit" class="btn btn-primary">更新</button> <a asp-action="Index" class="btn btn-secondary">キャンセル</a></form>Create と Edit の違い
Section titled “Create と Edit の違い”| 観点 | Create | Edit |
|---|---|---|
| URL | /Employees/Create | /Employees/Edit/1001 |
<form asp-action="..."> | Create | Edit + asp-route-id="@Model.EmployeeId" |
<input type="hidden" asp-for="EmployeeId"> | 不要 | 必要(更新対象の ID を一緒に送る) |
| 初期値 | 空 + 今日の日付 | DB から GetById で取得 |
| 保存後 | Insert | Update |
<input type="hidden"> は 画面に出ないが POST 送信される項目 を表す HTML タグです。EmployeeId をフォームの中に隠して持たせることで、POST 時に model.EmployeeId にも値が入ります。
なぜ
EmployeeIdを hidden にするのか更新の SQL は
UPDATE ... WHERE employee_id = @employeeIdで、「どの社員を更新するのか」を表す ID が必ず要ります。一方、姓・名・給与などは編集させたい項目なので入力欄に出しますが、EmployeeIdは ユーザーに書き換えさせたくない(変えると別人を更新してしまう)ので入力欄としては見せません。そこで 画面に出さず hidden で一緒に送る ことで、サーバーが正しい 1 件を更新できます。(この ID は
asp-route-id="@Model.EmployeeId"で URL(/Employees/Edit/1001)にも乗り、Controller 側はmodel.EmployeeId = id;でその URL の値を採用します。hidden は「フォームの中身としても ID を確実に持たせておく」役割です。)
asp-route-idの役割
<form asp-action="Edit" asp-route-id="@Model.EmployeeId" method="post">で、asp-route-idが URL のルーティングパラメータ{id}にあたる値を指定します。 これにより、フォームの送信先が/Employees/Edit/1001になります。
30-10 Index View にリンクを追加する
Section titled “30-10 Index View にリンクを追加する”第 29 章の Views/Employees/Index.cshtml を更新し、行ごとの編集・削除リンクと、上部の新規登録リンクを追加します。
disabled プレースホルダーボタンは 削除 します。
@model MvcEmployeeApp.Models.EmployeeSearchViewModel
@{ ViewData["Title"] = "社員一覧・検索";}
<h1>社員一覧</h1>
<p> <a asp-action="Create" class="btn btn-primary">新規登録</a></p>
@* 検索フォーム(第 29 章のまま、省略) *@
<table class="table"> <thead> <tr> <th>社員ID</th> <th>氏名</th> <th>部署名</th> <th>メール</th> <th>入社日</th> <th>給与</th> <th>操作</th> </tr> </thead> <tbody> @foreach (MvcEmployeeApp.Models.Employee emp in Model.Results) { <tr> <td>@emp.EmployeeId</td> <td>@emp.FullName</td> <td>@emp.DepartmentName</td> <td>@emp.Email</td> <td>@emp.HireDate.ToString("yyyy/MM/dd")</td> <td>@emp.Salary.ToString("C0")</td> <td> <a asp-action="Edit" asp-route-id="@emp.EmployeeId" class="btn btn-sm btn-outline-primary">編集</a>
<form asp-action="Delete" asp-route-id="@emp.EmployeeId" method="post" style="display:inline" onsubmit="return confirm('@emp.FullName を削除します。よろしいですか?');"> <button type="submit" class="btn btn-sm btn-outline-danger">削除</button> </form> </td> </tr> } </tbody></table>一覧の
@foreachが 検索結果 1 件につき<tr>を 1 行 に展開して表を描くしくみは、第 29 章 29-8「@foreachで表(テーブル)が描かれるしくみ」 と同じです。本章ではその各行の末尾に、「編集」リンク(<a>)と「削除」フォーム(<form>)が 1 組ずつ増えるだけです。
削除を <form method="post"> にしている理由
Section titled “削除を <form method="post"> にしている理由”削除は 状態を変える操作 なので、ブラウザの「先読み」や URL 直接アクセスで誤って実行されないよう、POST で送る必要があります。
<a href="/Employees/Delete/1001"> のような形(GET)にすると、
- ブラウザがページ表示時に「リンク先を先読み」してアクセスする実装があると勝手に削除される
- URL をコピー・貼り付けすると意図せず削除される
- 検索エンジンのクローラーがクロールすると削除される
といった事故が起き得ます。<form method="post"> を組んで、ボタン押下で初めて削除リクエストが飛ぶようにします。
onsubmit="return confirm('...')" は、削除前にブラウザ標準の確認ダイアログを出す簡単なやり方です。Windows 版の MessageBox.Show(..., MessageBoxButtons.OKCancel) に対応します。
30-11 動かしてみる
Section titled “30-11 動かしてみる”F5 で実行し、次の操作を順に確認してください。
| 操作 | 期待される動き |
|---|---|
一覧 → [新規登録] | /Employees/Create に GET。空のフォームが表示 |
| 何も入れずに「保存」 | 同じフォームに戻り、必須項目にエラー表示 |
| 全部入れて「保存」 | /Employees にリダイレクト、追加した社員が一覧に出る |
行の [編集] | /Employees/Edit/{id} に GET。各欄に既存値が入る |
| 値を変えて「更新」 | /Employees にリダイレクト、表に変更が反映 |
[削除] → ダイアログで OK | /Employees にリダイレクト、その社員が消える |
[削除] → ダイアログでキャンセル | 何も起こらない |
| 保存直後に F5(再読み込み) | 一覧画面の再読み込みのみ(二重登録は起こらない) |
/Employees/Edit/99999(存在しない ID) | HTTP 404 ページ |
/Employees/Delete/1001 をアドレスバーから GET | エラーまたは何も起きない(POST 限定のため) |
デバッグでフローを観察
Create(POST)/Edit(POST)/Deleteの最初の行にブレークポイントを置いて F5 で動かすと、ボタン押下からリダイレクトまでの流れがそのまま追えます。RedirectToAction("Index")を返した直後にIndex(GET)がもう一度呼ばれることが、目で見て分かります。
30-12 Windows フォーム編との比較
Section titled “30-12 Windows フォーム編との比較”第 25 章(Windows 版)と本章を並べてみます。
| 観点 | Windows フォーム(第 25 章) | Web MVC(本章) |
|---|---|---|
| 編集画面の出し方 | EmployeeEditForm.ShowDialog() でモーダル | /Employees/Edit/{id} に GET で遷移 |
| 編集データの渡し方 | フォームのプロパティ(Employee Target) | URL のルーティングパラメータ |
| 入力チェック | ValidateInput(out string error) + MessageBox | ValidateEmployeeInput(model) + ModelState + asp-validation-for |
| 保存後の動き | ダイアログを閉じる → 一覧を再読み込み | RedirectToAction("Index")(PRG) |
| 削除確認 | MessageBox.Show(..., OKCancel) | <form ... onsubmit="return confirm('...')"> |
| 二重送信対策 | 必要ない(同じプロセスで完結) | 必須(PRG パターン) |
| Repository | 同じ Insert/Update/Delete | 同じ Insert/Update/Delete(接続文字列のみ違う) |
Repository(DB アクセス)は そのままコピーで動く、UI と画面遷移は Web 流に置き換える ── 第 28・29 章で見たパターンが、CRUD 完成形でも同じように当てはまります。
30-13 補足:DataAnnotations による宣言的バリデーション
Section titled “30-13 補足:DataAnnotations による宣言的バリデーション”本文では Controller で if 文によるチェックを書きましたが、ASP.NET Core MVC には 宣言的(クラスに属性を付ける) バリデーション機能があります。 業務でよく見るので、概要を見ておきましょう。
EmployeeEditViewModel の各プロパティに 属性 を付けます。
using System.ComponentModel.DataAnnotations;
public class EmployeeEditViewModel{ public int EmployeeId { get; set; }
[Required(ErrorMessage = "「姓」は必須です。")] [Display(Name = "姓")] public string LastName { get; set; } = "";
[Required(ErrorMessage = "「名」は必須です。")] [Display(Name = "名")] public string FirstName { get; set; } = "";
// メールは「任意」項目。既定は空文字 "" で、[EmailAddress] は空文字も「不正」と // 判定するため、属性を付けると “任意のはずが必須化” してしまう。よってここでは // 属性を付けず、本文の if チェック(空でなければ @ を含むか確認)を残す(→ 付録 M でも同方針) public string Email { get; set; } = "";
[Required] public DateTime HireDate { get; set; }
// decimal の範囲は typeof 形式で書く([Range(0, 99999999)] は int 用のため) [Range(typeof(decimal), "0", "99999999", ErrorMessage = "給与は 0 以上で入力してください。")] public decimal Salary { get; set; }
[Range(1, int.MaxValue, ErrorMessage = "「部署」を選択してください。")] public int DepartmentId { get; set; }
public List<Department> Departments { get; set; } = new List<Department>();}属性化のときの 2 つの注意
- 任意項目に
[EmailAddress]をそのまま付けない:""で、[EmailAddress]は空文字を「不正」と判定します。そのため任意のメール欄に付けると 未入力で保存できなく なります(任意のはずが必須化)。任意項目は属性に頼らず、本文の if チェック(空でなければ形式確認)を残すのが安全です。decimalの範囲はtypeof形式で:[Range(0, 99999999)]はint用なので、decimalのSalaryには[Range(typeof(decimal), "0", "99999999")]と書きます。
Controller では、ほとんどのチェックを ValidateEmployeeInput に書かずに ModelState.IsValid を見るだけで済むようになります(任意項目のメールだけは上記の理由で if を残します)。
[HttpPost]public IActionResult Create(EmployeeEditViewModel model){ if (!ModelState.IsValid) { // ... return View(model); } // ...}| 観点 | 本文の if 文方式 | DataAnnotations 方式 |
|---|---|---|
| エラーメッセージの場所 | Controller の中 | プロパティの属性 |
| 共通ルールの再利用 | 同じ if を書き写す | 属性を別の ViewModel にも付ければ済む |
| カスタムロジック | 自由に書ける | カスタム属性を作る必要がある |
| 自動でクライアント側 JS バリデーション | 手動 | ライブラリと組み合わせれば自動で出せる |
この研修で if 文方式を採用した理由
Section titled “この研修で if 文方式を採用した理由”- Windows 編(第 25 章)が
ValidateInputの if 文だったので、流れを揃えると読みやすい - 「どこで何をチェックしているか」が コードを読めば一目で分かる
- 属性ベースは「決められた範囲のチェック」には強いが、複雑なルールが入ると別途カスタム属性が必要
業務で [Required] や [EmailAddress] が付いた ViewModel を見たら、「同じことを属性で書いているだけ」と読み解いてください。
30-14 補足:CSRF と偽造防止トークン
Section titled “30-14 補足:CSRF と偽造防止トークン”30-10 で「削除は GET ではなく POST にする」と学びました。これは 意図しないリクエストで操作が実行されてしまう のを防ぐ第一歩です。ただし POST にするだけでは、他サイトから利用者のブラウザを使ってこっそり POST を送りつける攻撃 ── CSRF(クロスサイト・リクエスト・フォージェリ) までは防げません。
ASP.NET Core には、これを防ぐ 偽造防止トークン(アンチフォージェリトークン) の仕組みがあります。ポイントは 2 つです。
- 本章の
<form asp-action="..." method="post">のように タグヘルパーで作った POST フォームには、__RequestVerificationTokenという隠しトークンが自動で埋め込まれています(あなたが何も書かなくても入っています。ブラウザの「ページのソースを表示」で確認できます)。 - ただし、トークンは「埋め込まれているだけ」では効きません。サーバー側で 検証する指示 を付けて初めて、「正しいトークンが付いた POST だけを受け付ける」状態になります。
// POST アクションに付けると、トークンの無い(=外部から偽造された)POST を弾く[HttpPost][ValidateAntiForgeryToken]public IActionResult Create(EmployeeEditViewModel model) { ... }(アプリ全体の POST にまとめて効かせたいときは、Program.cs で AutoValidateAntiforgeryTokenAttribute をフィルターとして登録する書き方もあります。)
この研修での扱い
本文では学びやすさを優先して
[ValidateAntiForgeryToken]を付けていませんが、実務の Web フォームでは POST に付けるのが標準です。本章で作った削除フォーム(<form ... method="post">)はすでにトークンを持っているので、検証属性を足すだけで有効になります。「SQL インジェクション対策 = パラメータ化」と並ぶ、フォームのもう一つの定番防御として覚えておきましょう。
30-15 CRUD 機能の組み立て地図 ― 最終演習に向けて
Section titled “30-15 CRUD 機能の組み立て地図 ― 最終演習に向けて”この章で、社員管理という 1 つの業務機能を CRUD まで完成 させました。同じ作り方は、別の業務機能を作るときにもそのまま使えます。1 つの CRUD 機能は、次の部品でできています。
| 部品 | 役割 | 本章での例 |
|---|---|---|
| Model(エンティティ) | DB の 1 行を表すクラス | Employee |
| Repository | DB アクセスをまとめる層(基本は GetAll / GetById / Insert / Update / Delete の 5 メソッド) | EmployeeRepository |
| ViewModel | 画面に必要なものをまとめる入れ物(入力欄+プルダウンの選択肢など) | EmployeeSearchViewModel / EmployeeEditViewModel |
| Controller | URL を受け取り、Repository を呼び、View を返す。一覧・検索は GET、登録・編集・削除は「画面を見せる GET」と「保存する POST」のペア | EmployeesController |
View(.cshtml) | 画面。一覧(Index)・新規(Create)・編集(Edit)の 3 種が基本 | Index / Create / Edit |
| ルーティング | URL とアクションの対応({controller}/{action}/{id?})。ID は URL、検索条件はクエリ文字列 | /Employees/Edit/1001 |
新しい機能を作るときは、
どんなデータか(Model)→ DB 操作(Repository)→ 画面に渡す形(ViewModel)→ URL とアクション(Controller)→ 画面(View)
の順に考えると迷いにくくなります。
次に向けて
第 31 章で Windows 版と Web 版を振り返ったあと、研修の総まとめとして 最終演習課題 に進みます。そこでは手順書ではなく 要件定義書 を読み、必要なテーブルと画面を 自分で設計 して、ここで身につけた CRUD 一式を自分の力で組み立てます。この地図が、その設計の出発点になります。
よくあるつまずき
Section titled “よくあるつまずき”| つまずき | 原因 | 対応 |
|---|---|---|
Edit/1001 で 404 | int id が URL から取れていない | ルーティング {id?} を変えていないか、asp-route-id のスペルを確認 |
| 保存できない・エラーも出ない | ModelState.IsValid が false のまま View に戻っているのに気付かない | View に <span asp-validation-for="..."> が抜けていないか確認、<div asp-validation-summary="All"> を追加してまとめて表示するのも有効 |
| 編集後に部署が消える | POST 時に Departments が空 | エラー再表示の前に model.Departments = deptRepo.GetAll(); を必ず再取得 |
| 削除ボタンを押すと「405 Method Not Allowed」 | <a href> で削除しようとしている | <form method="post"> に変える |
| F5 で同じ社員が何度も登録される | 保存後に View(...) を返している | RedirectToAction("Index") に変える(PRG) |
email が空のときに UNIQUE 制約違反 | string.Empty を渡すと別の社員と空文字で衝突 | DBNull.Value を渡す(本文 30-2) |
編集画面の EmployeeId が 0 で UPDATE 失敗 | hidden を書き忘れ | <input type="hidden" asp-for="EmployeeId" /> を追加 |
| 入社日のフォーマットエラー | ロケール差 | <input type="date"> を使う(ブラウザが標準フォーマットで送信) |
| 削除確認のダイアログが出ない | <form onsubmit> のスペル | onsubmit="return confirm('...')" の return を忘れない |
| 登録・更新で「重複したキー」のエラー(例:同じメール) | email の UNIQUE 制約違反などで SqlException が発生し未処理だと 500 | 一意になる値の重複は起こりうる。現場では SqlException を catch して ModelState にエラーを移し、やさしく再表示する(→ 付録 M「エラー時にやさしい画面」) |
学んだことチェック
Section titled “学んだことチェック”-
EmployeeRepository.Insert / Update / DeleteをSqlParameterで実装できる -
OUTPUT INSERTED.column_nameで新規採番された値を取得できる -
ExecuteScalar/ExecuteReader/ExecuteNonQueryの使い分けを説明できる -
DBNull.Valueが必要な場面を説明できる - ルーティング
{controller}/{action}/{id?}の各部分の役割を説明できる -
asp-route-idでルーティングパラメータ付きの URL を組み立てられる - PRG パターン(
RedirectToAction)で二重送信を防ぐ意味を説明できる -
ModelState.AddModelErrorでエラーを積み、View でasp-validation-forを使って表示できる -
<input type="hidden" asp-for="...">で画面に出さない値も POST で送れる - 削除を
<form method="post">で実装する理由を説明できる - DataAnnotations による宣言的バリデーションの存在と、本研修で if 文を使っている理由を説明できる
- CSRF と偽造防止トークンの考え方(タグヘルパーの POST フォームはトークンを自動で持つが、
[ValidateAntiForgeryToken]で検証して初めて効く)を説明できる - CRUD 機能が Model / Repository / ViewModel / Controller / View / ルーティングの部品でできていることを説明できる
- Windows 版(第 25 章)と Web 版の構造的な違いを説明できる
研修の進め方によっては、隣の人またはチーム内で説明確認を行います。
次の内容を、自分の言葉で説明してください。
OUTPUT INSERTED.employee_idを SQL に書いている理由は何ですか。ExecuteScalar/ExecuteReader/ExecuteNonQueryを、用途で 1 文ずつ説明してください。- PRG パターンの「P・R・G」が何の略か、それぞれ説明してください。
- なぜ削除は GET ではなく POST で送るのですか。
ModelState.IsValidがfalseだったとき、Controller はどんな振る舞いをしますか。- 編集フォームで
<input type="hidden" asp-for="EmployeeId" />が必要な理由は何ですか。 - Windows 版の
EmployeeEditForm.ShowDialog()に対応する Web 版の概念は何だと言えますか。
この章の演習課題に取り組みます(タイマーはありません。動かして観察し、自分の言葉で説明できることを重視します。チームで進める場合は声を掛け合いながら自分のペースで進めてください)。
必須課題は、本文で育ててきた MvcEmployeeApp プロジェクト にそのまま書き込みます(「なぜ」コメントも同じプロジェクト)。発展課題だけは、同じ KadaiWebApp ソリューション内に 別プロジェクト として作ります。
| 課題 | プロジェクト | 内容 |
|---|---|---|
| 課題 30-1(必須) | MvcEmployeeApp(本文の続き) | CRUD 完成 + 「なぜ」コメント |
| 課題 30-2(発展) | Ext_EmployeeCrudExtra(新規) | CRUD をカスタマイズ |
| 課題 30-3(発展) | (コードなし・レポート) | Windows 版と Web 版の最終比較 |
課題 30-1 CRUD を完成させ、ソースを読み解いて「なぜ」コメントを付ける
Section titled “課題 30-1 CRUD を完成させ、ソースを読み解いて「なぜ」コメントを付ける”まず本文 30-2〜30-11 にそって MvcEmployeeApp の CRUD を完成させ、本文 30-11 のシナリオ表(新規登録 → 一覧に出る / 必須項目を空で保存 → エラー / 編集 → 反映 / 削除 → 消える / 保存直後の F5 で二重登録されない / /Employees/Edit/99999 で 404)が期待どおり動くことを確認してください(本文のコピーで OK)。
動作を確認できたら、EmployeeRepository.cs・EmployeesController.cs・EmployeeEditViewModel.cs を読み返し、第 23〜25・28・29 章の課題と同じ要領 で、次の 2 種類のコメントを書き込んでください。本章のヤマは PRG パターン と 削除の安全性(WHERE 必須・POST 限定) なので、その「なぜ」を重視します。
- (A) メソッドの役割:各メソッドの 上の行 に、何をするメソッドかを 1 行(
//)で書く - (B) 難所の「なぜ」:下の表の各箇所に、「なぜそう書くのか」「何のためか」 を 前の行 に自分の言葉で書く(言い換えコメントは NG。→ 第 23 章「課題 23-1」・第 7 章コラム)
View(.cshtml)の asp-action・asp-for・asp-route-id・asp-validation-for・<input type="hidden">・<form method="post">・onsubmit="return confirm(...)" は MVC の「おまじない」 なので、「何をする決まりか」を一言で十分です(→ 26-12 早見表、初出のものは 30-7 / 30-9 / 30-10 の本文を参照)。
(B) 「なぜ」コメントを付ける箇所
| ファイル | 箇所 | 説明する観点(= ここに「なぜ」を書く) |
|---|---|---|
EmployeeRepository.cs | すべての値を AddWithValue で渡している点 | なぜ文字列連結でなくパラメータで渡すのか(Web で何を防ぐか) |
EmployeeRepository.cs | Insert の OUTPUT INSERTED.employee_id | なぜ採番された ID を SQL から受け取るのか |
EmployeeRepository.cs | @email に ... ? (object)DBNull.Value : ... | なぜ空のとき DBNull.Value を渡すのか |
EmployeeRepository.cs | Update / Delete の WHERE employee_id = @employeeId | なぜ WHERE が必須か(付け忘れると何が起きるか) |
EmployeeRepository.cs | Insert は ExecuteScalar / Update・Delete は ExecuteNonQuery | なぜ使い分けるのか(ExecuteNonQuery の戻り値 = 影響行数) |
EmployeesController.cs | Create / Edit を [HttpGet] と [HttpPost] の 2 つに分けている点 | なぜ同名アクションを GET / POST で分けるのか |
EmployeesController.cs | 保存後の return RedirectToAction("Index") | なぜ View を返さず Redirect するのか(PRG = 二重送信対策) |
EmployeesController.cs | if (!ModelState.IsValid) { ...; return View(model); } | なぜ無効時は同じ View に戻すのか(入力値が消えない理由) |
EmployeesController.cs | エラー再表示の前の model.Departments = deptRepo.GetAll(); | なぜ部署プルダウンを取り直すのか |
EmployeesController.cs | Edit(GET) の if (employee == null) return NotFound(); | なぜ存在しない ID で 404 を返すのか |
EmployeesController.cs | Delete を [HttpPost] だけにしている点 | なぜ GET では削除させないのか |
EmployeeEditViewModel.cs | 新規・編集を 1 つの ViewModel で兼ねている点 | なぜ分けず 1 つにまとめるのか |
確認すること
- 本文 30-11 のシナリオが期待どおり動く(とくに F5 で二重登録されない・存在しない ID で 404)
- (A) 各メソッドの上に「役割」を 1 行コメントした
- (B) 表のすべての箇所に「なぜ/何のため」を前行で書いた
- とくに PRG の「なぜ」・削除の
WHERE必須/POST 限定の「なぜ」・パラメータ化の「なぜ」 を自分の言葉で書けた - View のおまじない(
asp-*/hidden/<form method="post">)は「何をする決まりか」を一言で書いた - 言い換え・丸写しになっていない
課題 30-2 CRUD をカスタマイズする
Section titled “課題 30-2 CRUD をカスタマイズする”KadaiWebApp ソリューションに新しいプロジェクト Ext_EmployeeCrudExtra を作成し、MvcEmployeeApp のコードをコピーした上で、下の A〜D から 1 つ以上 を選んで改造してください(本体は壊さず、実験はこちらで)。
| 選択肢 | 内容 | ヒント |
|---|---|---|
| A:給与の上限チェック | 給与が 100 万円を超えたらエラー | ValidateEmployeeInput に model.Salary > 1_000_000m の分岐を追加 |
| B:登録/更新後にメッセージを出す | 一覧画面の上に「社員 〇〇 を登録しました」 | TempData["Message"] = "..." を Controller で設定し、Index View で @TempData["Message"] を表示(Redirect をまたいでも残るのが TempData) |
| C:削除確認を専用画面に | ダイアログでなく /Employees/Delete/{id} の GET で確認画面、POST で実行 | GET 確認画面 = [HttpGet] public IActionResult Delete(int id)(GetById(id) で対象を取り、Delete.cshtml に表示)。⚠️ 既存の削除アクションと 同名・同じ引数(Delete(int id))は 2 つ作れない(コンパイルエラー CS0111。[HttpGet]/[HttpPost] 属性では区別されない)。そこで POST 側は メソッド名を変えて [HttpPost, ActionName("Delete")] public IActionResult DeleteConfirmed(int id) にする(URL は Delete のまま・これが ASP.NET Core の定石)。確認画面の実行ボタンは <form asp-action="Delete" method="post">。GET では消さない |
| D:DataAnnotations に置き換える(やや難) | ValidateEmployeeInput を属性ベースに | 本文 30-13 を参考に EmployeeEditViewModel へ [Required] / [Range(typeof(decimal), ...)] などを付け、Controller の ValidateEmployeeInput 呼び出しを外す。任意のメールは [EmailAddress] をそのまま付けると空欄で弾かれるため if を残すか工夫する(→ 30-13 の注意)。using System.ComponentModel.DataAnnotations; を忘れずに |
確認すること
- 選んだ改造が正しく動く
- PRG パターンを崩していない(保存後に必ず Redirect)
- パラメータ化クエリの形・削除の POST 限定を崩していない
- 改造を入れた場所(Repository / Controller / View / Model)を自分で指せる
- (D を選んだ場合)if 文方式 / DataAnnotations 方式の使い分けの判断軸 を 1 つ以上挙げられる
課題 30-3 Windows 版と Web 版の最終比較レポート
Section titled “課題 30-3 Windows 版と Web 版の最終比較レポート”ここまで第 23〜25 章(Windows 版)と第 28〜30 章(Web 版)で、同じ業務(社員管理 CRUD)を 2 つのプラットフォームで実装 してきました。 2 つを並べて見比べ、自分の言葉でレポートしてください(紙のノート / Markdown / ペア確認の口頭発表 ── 形式は自由)。
観点(最低限)
- Repository 層(
EmployeeRepository)は、Windows と Web で本質的に何が違いましたか? - 画面の作り方は、それぞれどんな技術を使いましたか?
- ユーザーの操作の捉え方(イベント vs リクエスト)はどう違いましたか?
- 状態の保持(現在どの社員を編集中か、など)はどう実現しましたか?
- もう一方のプラットフォームで業務アプリを作る指示が出たら、何を最初に確認しますか?
このレポートは、第 31 章「まとめ:Windows フォーム vs Web の比較」の素材にもなります。
提出前チェックリスト
Section titled “提出前チェックリスト”- ソリューション名が
KadaiWebApp、本体プロジェクト名がMvcEmployeeApp - csproj が
<Nullable>disable</Nullable> -
Microsoft.Data.SqlClientが NuGet で追加されている -
Data/EmployeeRepository.csにGetById/Insert/Update/Deleteがすべて実装されている -
Models/EmployeeEditViewModel.csが作成されている -
Controllers/EmployeesController.csに Create(GET/POST)、Edit(GET/POST)、Delete(POST)がある -
Views/Employees/Create.cshtmlとViews/Employees/Edit.cshtmlがある -
Views/Employees/Index.cshtmlの disabled ボタンは削除し、行ごとの編集/削除リンクと上部の新規登録リンクがある - 削除は
<form method="post">+confirmダイアログで実装されている - 保存後は
RedirectToAction("Index")で一覧に戻る(PRG) - 必須項目のエラー、メール形式エラー、部署未選択エラーが画面に表示される
- 保存直後に F5 しても二重登録されない / 存在しない ID で 404 になることを確認した
- 必須課題 30-1:各メソッド役割 1 行+難所の表すべてに「なぜ」を前行コメントした(とくに PRG・削除の
WHERE/POST 限定・パラメータ化) - View のおまじない(
asp-*/hidden/<form method="post">)は「何をする決まりか」を一言で書いた - (発展 30-2 を実施した場合)
Ext_EmployeeCrudExtraで改造が動く -
bin・obj・.vsフォルダが Git 管理に入っていない
Git への提出
Section titled “Git への提出”git statusgit add .git commit -m "Chapter30: 社員管理アプリ(Web CRUD)完成+なぜコメント / <PRG・削除の「なぜ」を一言>"git push origin mainGit の詳しい操作は、付録 C「Git のインストールと提出ルール」 を参照してください。
この章のまとめ
Section titled “この章のまとめ”EmployeeRepositoryにGetById/Insert/Update/Deleteを追加し、業務アプリの CRUD が完成 したOUTPUT INSERTED.employee_idで IDENTITY の採番値を SQL から受け取れる- ルーティング
{controller}/{action}/{id?}で、URL の一部を引数として受け取れる - 保存後は
RedirectToAction("Index")を返す PRG パターン が、Web アプリの標準 ModelState.AddModelError+<span asp-validation-for="...">で、エラーを画面に表示する- 削除は
<a href>(GET)ではなく<form method="post">で実装する - 編集画面の
<input type="hidden" asp-for="EmployeeId" />で、更新対象の ID を一緒に送る - DataAnnotations を使うと、バリデーションを 属性で宣言的に書ける(発展)
- Windows 版(第 25 章)と Web 版で、Repository は同じ・上層は別のメンタルモデル
- 第 28〜30 章を通じて、第 23〜25 章とまったく同じ業務を Web で再構築できた
次章では、第 18〜25 章で作った Windows フォーム版 と、第 26〜30 章で作った Web 版 を 横に並べて比較 し、それぞれの特性を整理します。
同じ「社員管理」というお題を、2 つのプラットフォームで作り上げた経験を振り返り、
- どんな業務にどちらが向いているか
- 設計の共通点と違い
- 「次にどちらを学ぶべきか」の判断軸
を整理します。コードを書く章ではなく、読み返し・振り返り・チーム議論 が中心の章です。
第 23〜30 章の課題提出物が手元にある状態で第 31 章に進むと、議論の材料が多くなって学びが深まります。