Skip to content

第30章 Web 社員管理アプリ:編集・更新

この章では、第 28・29 章で組み上げてきた Web 社員管理アプリを完成形にします。 これまで disabled で枠だけ置いていた 新規登録・削除 と、新たに追加する 編集 ── つまり業務アプリの CRUD(Create / Read / Update / Delete)をすべて動かせるようにします。

操作SQL
Create(新規登録)INSERT本章
Read(一覧・検索)SELECT第 28・29 章
Update(更新)UPDATE本章
Delete(削除)DELETE本章

加えて、Web ならではのテーマを 3 つ扱います。

  1. ルーティングパラメータ(/Employees/Edit/10011001 の渡し方)
  2. PRG パターン(Post → Redirect → Get で「再読み込みでの二重送信」を防ぐ)
  3. 入力チェック(ModelState バリデーション) で、エラーを画面に出す

第 25 章(Windows 版編集)を仕上げたときの考え方は、Web でもそのまま使えます。違いは「同じフォーム内で完結」から「URL の変化で画面が遷移する」に置き換わるところです。


この章でできるようになること

Section titled “この章でできるようになること”

この章を終えると、次のことができるようになります。

  • EmployeeRepository.Insert / Update / DeleteSqlParameter で実装できる
  • 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 版の違い(画面遷移と状態管理)を説明できる

項目内容
開発環境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 章までで設定済み)。


  • 第 29 章を Git に提出済み、または手元にコードがある
  • TrainingDBemployees / departments がアクセス可能(app_user で読み書き両方できる)
  • 第 29 章 MvcEmployeeApp/Employees で検索が動く
  • 第 25 章(Windows 版編集)の EmployeeRepository.Insert/Update/Delete を読んだことがある

完成形の /Employees 画面はこうなります。

社員一覧 [新規登録] ← 本章で動く
名前: [ ] 部署: [すべての部署 ▼] [検索] [クリア]
10 件の社員が見つかりました。
| 社員ID | 氏名 | 部署名 | メール | 入社日 | 給与 | 操作 |
|--------|--------------|--------|----------|------------|----------|--------------------|
| 1001 | 山田 二郎 | 総務 | yamada@. | 2001/04/01 | ¥500,000 | [編集] [削除] |
| 1002 | 佐藤 昭夫 | 営業 | sato@. | 2002/04/01 | ¥500,000 | [編集] [削除] |
| ... | | | | | | |

URL 設計(本章で追加・変更):

URLアクション用途
/EmployeesIndex (GET)一覧・検索(既存)
/Employees/CreateCreate (GET)新規登録フォーム
/Employees/CreateCreate (POST)新規登録の保存
/Employees/Edit/1001Edit (GET)編集フォーム(1001 の社員)
/Employees/Edit/1001Edit (POST)編集の保存
/Employees/Delete/1001Delete (POST)削除

Windows 版(第 25 章)が 同じフォームで 1 つのウィンドウ だったのに対し、Web 版では 画面ごとに別の URL を持たせるのが基本です。


30-2 EmployeeRepository に Insert / Update / Delete を実装する

Section titled “30-2 EmployeeRepository に Insert / Update / Delete を実装する”

第 29 章で NotImplementedException を返していた InsertDelete を実装し、新しく UpdateGetById を追加します。 第 25 章 25-2 の Windows 版とほぼ同じコードで、namespace と接続文字列だけが違います。

EmployeeRepository.cs
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 章で書いた GetAllSearchReadEmployeesそのまま残します。本章では GetById / Insert / Update / Delete を追加し、第 29 章で枠だけだった Insert / Delete を本実装に差し替えます。

メソッド戻り値ポイント
GetByIdEmployee(無ければ null)編集画面に開く 1 件の値を取得
Insert新規採番された employee_idOUTPUT 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戻り値
ExecuteScalar1 行 1 列が返る SELECT、OUTPUT 付き INSERT先頭セルの値(object)
ExecuteReader複数行が返る SELECTSqlDataReader
ExecuteNonQueryINSERT / UPDATE / DELETE などデータ変更系影響行数(int)

email 列は NULL を許容するため、C# の空文字列を直接渡すと SQL 側で '' として保存され、UNIQUE 制約と相性が悪くなる場合があります。 空のときは DBNull.Value を渡す ことで、SQL 側に「NULL を入れて」と明示します。

cmd.Parameters.AddWithValue("@email",
string.IsNullOrEmpty(employee.Email) ? (object)DBNull.Value : employee.Email);

(object) のキャストは、三項演算子の左右の型を揃えるためです(DBNullstring の共通の基底型)。


30-3 ルーティングパラメータと URL 設計

Section titled “30-3 ルーティングパラメータと URL 設計”

/Employees/Edit/1001 のような URL から、Controller の引数 id1001 が入る仕組みを ルーティング といいます。

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 / =IndexURL が省略されたときの既定値
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 keywordint id

ID は URL に組み込む、検索条件はクエリ文字列に付ける」が一般的な使い分けです。


新規・編集の画面でも、第 29 章と同じように View 専用の入れ物 を用意します。 入力フィールド + 部署プルダウンの選択肢をまとめます。

Models フォルダに EmployeeEditViewModel.cs を追加します。

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)
LastNameDepartmentId入力欄に対応
Departments部署プルダウンの選択肢

新規モードと編集モードを 1 つの ViewModel で兼ねている のは、第 25 章の EmployeeEditForm が「新規/編集を 1 つのフォームで扱う」設計だったのと同じです。


30-5 EmployeesController を拡張する

Section titled “30-5 EmployeesController を拡張する”

ここがこの章の中核です。新規・編集・削除の GET / POST アクション を追加していきます。 本章を通じて、EmployeesController 全体は次のようになります(Index は第 29 章のまま)。

EmployeesController.cs
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 以上で入力してください。");
}
}
}
ポイント説明
GET と POST のペアCreateEdit画面を見せる GET保存する POST の 2 つを書く
[HttpGet] / [HttpPost] 属性同じ名前のアクションでも、HTTP メソッドで自動的に振り分けられる
int id 引数ルーティング {id?} から自動的に値が入る(Model Binding)
NotFound()該当する社員が居なければ HTTP 404 を返す
RedirectToAction("Index")保存後は 一覧画面に戻る(後述の PRG)
ValidateEmployeeInput共通のチェック処理。Create/Edit 両方から呼ぶ

Delete[HttpPost] だけにしているのは、GET で削除を実行できてしまうとブラウザの先読みや URL 直接アクセスで誤削除が起こる からです(後述)。


保存後に 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 → /Employees
3. ブラウザが自動で /Employees に GET
4. ユーザーが 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 側でエラーマーカーを表示 するのが標準です。

private void ValidateEmployeeInput(EmployeeEditViewModel model)
{
if (string.IsNullOrWhiteSpace(model.LastName))
{
ModelState.AddModelError("LastName", "「姓」は必須です。");
}
// ...
}

ModelState.AddModelError("プロパティ名", "メッセージ") で、特定のプロパティに対するエラーメッセージを登録します。 すべてのチェック後に ModelState.IsValid を確認すると、エラーが 1 件でも積まれていれば false になります。

<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" は、ModelStateLastName のエラーが積まれていれば、その内容を出力する タグヘルパー です。エラーが無ければ何も出ません。

姓: [ ]
「姓」は必須です。 ← 赤文字で出る

Create の POST で ModelState.IsValidfalse だったとき、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 を追加します。

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 で送る点が違います。

Edit.cshtml
@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>
観点CreateEdit
URL/Employees/Create/Employees/Edit/1001
<form asp-action="...">CreateEdit + asp-route-id="@Model.EmployeeId"
<input type="hidden" asp-for="EmployeeId">不要必要(更新対象の ID を一緒に送る)
初期値空 + 今日の日付DB から GetById で取得
保存後InsertUpdate

<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-idURL のルーティングパラメータ {id} にあたる値を指定します。 これにより、フォームの送信先が /Employees/Edit/1001 になります。


30-10 Index View にリンクを追加する

Section titled “30-10 Index View にリンクを追加する”

第 29 章の Views/Employees/Index.cshtml を更新し、行ごとの編集・削除リンクと、上部の新規登録リンクを追加します。 disabled プレースホルダーボタンは 削除 します。

Index.cshtml
@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) に対応します。


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) + MessageBoxValidateEmployeeInput(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] をそのまま付けない:Email の既定は空文字 "" で、[EmailAddress] は空文字を「不正」と判定します。そのため任意のメール欄に付けると 未入力で保存できなく なります(任意のはずが必須化)。任意項目は属性に頼らず、本文の if チェック(空でなければ形式確認)を残すのが安全です。
  • decimal の範囲は typeof 形式で:[Range(0, 99999999)]int 用なので、decimalSalary には [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.csAutoValidateAntiforgeryTokenAttribute をフィルターとして登録する書き方もあります。)

この研修での扱い

本文では学びやすさを優先して [ValidateAntiForgeryToken] を付けていませんが、実務の Web フォームでは POST に付けるのが標準です。本章で作った削除フォーム(<form ... method="post">)はすでにトークンを持っているので、検証属性を足すだけで有効になります。「SQL インジェクション対策 = パラメータ化」と並ぶ、フォームのもう一つの定番防御として覚えておきましょう。


30-15 CRUD 機能の組み立て地図 ― 最終演習に向けて

Section titled “30-15 CRUD 機能の組み立て地図 ― 最終演習に向けて”

この章で、社員管理という 1 つの業務機能を CRUD まで完成 させました。同じ作り方は、別の業務機能を作るときにもそのまま使えます。1 つの CRUD 機能は、次の部品でできています。

部品役割本章での例
Model(エンティティ)DB の 1 行を表すクラスEmployee
RepositoryDB アクセスをまとめる層(基本は GetAll / GetById / Insert / Update / Delete の 5 メソッド)EmployeeRepository
ViewModel画面に必要なものをまとめる入れ物(入力欄+プルダウンの選択肢など)EmployeeSearchViewModel / EmployeeEditViewModel
ControllerURL を受け取り、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 一式を自分の力で組み立てます。この地図が、その設計の出発点になります。


つまずき原因対応
Edit/1001 で 404int id が URL から取れていないルーティング {id?} を変えていないか、asp-route-id のスペルを確認
保存できない・エラーも出ないModelState.IsValidfalse のまま 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一意になる値の重複は起こりうる。現場では SqlExceptioncatch して ModelState にエラーを移し、やさしく再表示する(→ 付録 M「エラー時にやさしい画面」)

  • EmployeeRepository.Insert / Update / DeleteSqlParameter で実装できる
  • 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 版の構造的な違いを説明できる

研修の進め方によっては、隣の人またはチーム内で説明確認を行います。

次の内容を、自分の言葉で説明してください。

  1. OUTPUT INSERTED.employee_id を SQL に書いている理由は何ですか。
  2. ExecuteScalar / ExecuteReader / ExecuteNonQuery を、用途で 1 文ずつ説明してください。
  3. PRG パターンの「P・R・G」が何の略か、それぞれ説明してください。
  4. なぜ削除は GET ではなく POST で送るのですか。
  5. ModelState.IsValidfalse だったとき、Controller はどんな振る舞いをしますか。
  6. 編集フォームで <input type="hidden" asp-for="EmployeeId" /> が必要な理由は何ですか。
  7. 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.csEmployeesController.csEmployeeEditViewModel.cs を読み返し、第 23〜25・28・29 章の課題と同じ要領 で、次の 2 種類のコメントを書き込んでください。本章のヤマは PRG パターン削除の安全性(WHERE 必須・POST 限定) なので、その「なぜ」を重視します。

  • (A) メソッドの役割:各メソッドの 上の行 に、何をするメソッドかを 1 行(//)で書く
  • (B) 難所の「なぜ」:下の表の各箇所に、「なぜそう書くのか」「何のためか」前の行 に自分の言葉で書く(言い換えコメントは NG。→ 第 23 章「課題 23-1」・第 7 章コラム)

View(.cshtml)の asp-actionasp-forasp-route-idasp-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.csInsertOUTPUT INSERTED.employee_idなぜ採番された ID を SQL から受け取るのか
EmployeeRepository.cs@email... ? (object)DBNull.Value : ...なぜ空のとき DBNull.Value を渡すのか
EmployeeRepository.csUpdate / DeleteWHERE employee_id = @employeeIdなぜ WHERE が必須か(付け忘れると何が起きるか)
EmployeeRepository.csInsertExecuteScalar / UpdateDeleteExecuteNonQueryなぜ使い分けるのか(ExecuteNonQuery の戻り値 = 影響行数)
EmployeesController.csCreate / Edit[HttpGet][HttpPost] の 2 つに分けている点なぜ同名アクションを GET / POST で分けるのか
EmployeesController.cs保存後の return RedirectToAction("Index")なぜ View を返さず Redirect するのか(PRG = 二重送信対策)
EmployeesController.csif (!ModelState.IsValid) { ...; return View(model); }なぜ無効時は同じ View に戻すのか(入力値が消えない理由)
EmployeesController.csエラー再表示の前の model.Departments = deptRepo.GetAll();なぜ部署プルダウンを取り直すのか
EmployeesController.csEdit(GET)if (employee == null) return NotFound();なぜ存在しない ID で 404 を返すのか
EmployeesController.csDelete[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 万円を超えたらエラーValidateEmployeeInputmodel.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 / ペア確認の口頭発表 ── 形式は自由)。

観点(最低限)

  1. Repository 層(EmployeeRepository)は、Windows と Web で本質的に何が違いましたか?
  2. 画面の作り方は、それぞれどんな技術を使いましたか?
  3. ユーザーの操作の捉え方(イベント vs リクエスト)はどう違いましたか?
  4. 状態の保持(現在どの社員を編集中か、など)はどう実現しましたか?
  5. もう一方のプラットフォームで業務アプリを作る指示が出たら、何を最初に確認しますか?

このレポートは、第 31 章「まとめ:Windows フォーム vs Web の比較」の素材にもなります。


  • ソリューション名が KadaiWebApp、本体プロジェクト名が MvcEmployeeApp
  • csproj が <Nullable>disable</Nullable>
  • Microsoft.Data.SqlClient が NuGet で追加されている
  • Data/EmployeeRepository.csGetById / Insert / Update / Delete がすべて実装されている
  • Models/EmployeeEditViewModel.cs が作成されている
  • Controllers/EmployeesController.cs に Create(GET/POST)、Edit(GET/POST)、Delete(POST)がある
  • Views/Employees/Create.cshtmlViews/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 で改造が動く
  • binobj.vs フォルダが Git 管理に入っていない

Terminal window
git status
git add .
git commit -m "Chapter30: 社員管理アプリ(Web CRUD)完成+なぜコメント / <PRG・削除の「なぜ」を一言>"
git push origin main

Git の詳しい操作は、付録 C「Git のインストールと提出ルール」 を参照してください。


  • EmployeeRepositoryGetById / 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 章に進むと、議論の材料が多くなって学びが深まります。