第29章 Web 社員管理アプリ:検索
この章の目的
Section titled “この章の目的”この章では、第 28 章の社員一覧画面に 検索機能 を追加します。 第 24 章(Windows 版検索)と同じく、次の 2 つの条件を組み合わせて絞り込みます。
- 名前(姓または名)の部分一致:検索ボックスに入力した文字列
- 部署:プルダウン(
<select>)で選んだ部署
ここで重要になるのが、第 24 章でも学んだ パラメータ化クエリ(SqlParameter) です。
ユーザーが入力した文字列をそのまま SQL に連結すると、SQL インジェクション という重大なセキュリティ問題を引き起こします。Web では誰でもブラウザから URL を組み立てて送れてしまうため、Windows フォーム以上にこの危険が現実的です。
本章では加えて、Web ならではのテーマである 「検索条件をどう URL に乗せるか」 ── GET と POST の使い分けも整理します。
この章でできるようになること
Section titled “この章でできるようになること”この章を終えると、次のことができるようになります。
- Web アプリにおける SQL インジェクションの危険を具体例で説明できる
SqlParameterを使ったパラメータ化クエリで部分一致検索を書ける- 複数条件を組み合わせる SQL を書ける(
WHERE句の組み立て方) EmployeeRepositoryにSearchメソッドを追加し、GetAllと共通のprivateヘルパー(ReadEmployees)で重複を減らせる- 検索画面用の ViewModel(
EmployeeSearchViewModel) を設計できる - Controller のアクション引数で クエリ文字列(
?keyword=...&departmentId=...)を受け取れる - View で
<form method="get">+<select>の検索フォームを書ける - 検索結果を Razor の
@foreachで再表示できる - 「検索画面は GET が基本」という現場の流儀を理解できる
本章で使用する環境
Section titled “本章で使用する環境”| 項目 | 内容 |
|---|---|
| 開発環境 | Visual Studio 2022 |
| プロジェクト種類 | ASP.NET Core Web アプリ(Model-View-Controller) |
| 対象フレームワーク | .NET 8 |
| ソリューション名 | KadaiWebApp(第 28 章で作成・続けて使う) |
| プロジェクト名 | MvcEmployeeApp(第 28 章の続き・作り直さない) |
| ベースとなる章 | 第 28 章(MvcEmployeeApp をそのまま開く) |
| データベース | SQLServer 2022(TrainingDB) |
| 認証方式(DB) | SQL 認証(app_user、第 27 章で作成済み) |
| NuGet パッケージ | Microsoft.Data.SqlClient |
この章は第 28 章の
MvcEmployeeAppを続けて使います新しいプロジェクトは作りません。
KadaiWebAppソリューションのMvcEmployeeAppを開き、検索機能を足していきます(Nullable・NuGet・SQL 認証は第 28 章までで設定済み)。
作業前チェック
Section titled “作業前チェック”- 第 28 章を Git に提出済み、または手元にコードがある
-
app_userでTrainingDBに接続できる(第 27 章 27-2) - 第 28 章
MvcEmployeeAppの/Employeesが動く - 第 24 章(Windows 版検索)の
EmployeeRepository.Searchを読んだことがある(参考用)
29-1 この章で追加する機能
Section titled “29-1 この章で追加する機能”第 28 章の /Employees 画面に、検索 UI を追加します。
社員一覧
名前: [山田 ] 部署: [総務 ▼] [検索] [クリア] ← 本章で追加
2 件の社員が見つかりました。
| 社員ID | 氏名 | 部署名 | メール | 入社日 | 給与 ||--------|--------------|--------|-----------------------|------------|----------|| 1001 | 山田 二郎 | 総務 | yamada.jiro@... | 2001/04/01 | ¥500,000 || 1008 | 中山 大輔 | 総務 | nakayama.daisuke@... | ... | ¥400,000 |
[新規登録] [削除] ← まだ枠だけ検索の仕様(第 24 章と同じ):
| 条件 | 動き |
|---|---|
| 名前のみ入力 | 姓または名に部分一致する社員を表示 |
| 部署のみ選択 | その部署の社員を表示 |
| 名前+部署 両方 | AND 条件で絞り込み |
| 両方とも空 / 「すべての部署」 | 全社員を表示(GetAll と同じ) |
URL の例:
/Employees ← 全件表示/Employees?keyword=山 ← 名前に「山」を含む/Employees?departmentId=1 ← 部署が「総務」(id=1)/Employees?keyword=山&departmentId=1 ← 両方の AND検索条件が URL に乗るため、ブックマークしたり、URL をコピーして共有したりできます ── これが Web ならではの良さです。
29-2 SQL インジェクションの脅威(Web 版)
Section titled “29-2 SQL インジェクションの脅威(Web 版)”検索機能を書く前に、やってはいけない書き方 を再確認します。第 24 章でも学びましたが、Web では危険が一段と現実的です。
ユーザーの入力をそのまま SQL に連結する書き方:
// ❌ やってはいけない書き方string keyword = Request.Query["keyword"];string sql = "SELECT * FROM employees WHERE last_name LIKE '%" + keyword + "%'";ブラウザのアドレスバーから、誰でも次のような URL を送れます。
/Employees?keyword=%' OR '1'='1この値が SQL に直接埋め込まれると、
SELECT * FROM employees WHERE last_name LIKE '%%' OR '1'='1%''1'='1' は常に true なので、全社員が表示 されてしまいます。
これでもまだ「全件見えるだけ」ですが、もっと巧妙な攻撃では:
/Employees?keyword=x'; DROP TABLE employees; --を送られると、テーブルそのものが破壊 されてしまいます。
なぜ Web で特に危険か
Section titled “なぜ Web で特に危険か”| Windows フォーム | Web アプリ |
|---|---|
| 入力は社員(社内ユーザー)のキーボードから | 入力は 世界中のブラウザ から来うる |
| 攻撃には、社員アカウントと社内 PC が必要 | URL を組み立てて送るだけ。自動化も容易 |
| 攻撃を試す機会は限られる | 24 時間、自動ツールが攻撃を試し続ける |
Web では「性善説のコードは即座に攻撃対象になる」と思っておくのが現場の標準です。
29-3 パラメータ化クエリの基本(復習)
Section titled “29-3 パラメータ化クエリの基本(復習)”防御の正しいやり方は パラメータ化クエリ です(第 24 章 24-3 で導入したのと同じ)。
SQL 文には プレースホルダー(@param のような名前) だけを書き、値は別途 SqlParameter として渡します。
const string sql = "SELECT * FROM employees WHERE last_name LIKE '%' + @keyword + '%'";
using SqlCommand cmd = new SqlCommand(sql, conn);cmd.Parameters.AddWithValue("@keyword", keyword);| 仕組み | 説明 |
|---|---|
SQL 文に @keyword を書く | 「ここに値が入る」というマーカー |
Parameters.AddWithValue で値を渡す | SQLServer が 文字列として 安全に扱う |
| SQL とデータが分離 | データに ' や ; が含まれても SQL の構造を壊せない |
Web からどんな入力が来ても、@keyword の中身は「ただの文字列」として扱われるため、SQL を改変されることはありません。
29-4 EmployeeRepository に Search を追加する
Section titled “29-4 EmployeeRepository に Search を追加する”第 28 章の EmployeeRepository を拡張し、Search メソッドを追加します。
GetAll と Search で 読み取りループの中身が重複 するので、ReadEmployees という private ヘルパーに切り出します(第 24 章と同じパターン)。
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;";
public List<Employee> GetAll() { 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 ORDER BY e.employee_id";
using SqlConnection conn = new SqlConnection(ConnectionString); conn.Open();
using SqlCommand cmd = new SqlCommand(sql, conn); return ReadEmployees(cmd); }
public List<Employee> Search(string keyword, int departmentId) { 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 (@keyword = '' OR e.last_name LIKE '%' + @keyword + '%' OR e.first_name LIKE '%' + @keyword + '%') AND (@departmentId = -1 OR e.department_id = @departmentId) ORDER BY e.employee_id";
using SqlConnection conn = new SqlConnection(ConnectionString); conn.Open();
using SqlCommand cmd = new SqlCommand(sql, conn); cmd.Parameters.AddWithValue("@keyword", keyword ?? ""); cmd.Parameters.AddWithValue("@departmentId", departmentId);
return ReadEmployees(cmd); }
public int Insert(Employee employee) { // 第 30 章で実装します throw new NotImplementedException("Insert は第 30 章で実装します。"); }
public int Delete(int employeeId) { // 第 30 章で実装します throw new NotImplementedException("Delete は第 30 章で実装します。"); }
private static List<Employee> ReadEmployees(SqlCommand cmd) { List<Employee> list = new List<Employee>();
using SqlDataReader reader = cmd.ExecuteReader();
int idxId = reader.GetOrdinal("employee_id"); int idxLast = reader.GetOrdinal("last_name"); int idxFirst = reader.GetOrdinal("first_name"); int idxEmail = reader.GetOrdinal("email"); int idxHire = reader.GetOrdinal("hire_date"); int idxSalary = reader.GetOrdinal("salary"); int idxDeptId = reader.GetOrdinal("department_id"); int idxDeptName = reader.GetOrdinal("department_name");
while (reader.Read()) { Employee emp = new Employee(); emp.EmployeeId = reader.GetInt32(idxId); emp.LastName = reader.GetString(idxLast); emp.FirstName = reader.GetString(idxFirst); emp.Email = reader.IsDBNull(idxEmail) ? "" : reader.GetString(idxEmail); emp.HireDate = reader.GetDateTime(idxHire); emp.Salary = reader.IsDBNull(idxSalary) ? 0m : reader.GetDecimal(idxSalary); emp.DepartmentId = reader.IsDBNull(idxDeptId) ? 0 : reader.GetInt32(idxDeptId); emp.DepartmentName = reader.IsDBNull(idxDeptName) ? "" : reader.GetString(idxDeptName); list.Add(emp); }
return list; }}Search メソッドのポイント
Section titled “Search メソッドのポイント”| ポイント | 説明 |
|---|---|
@keyword / @departmentId でパラメータ化 | SQL インジェクション対策 |
LIKE '%' + @keyword + '%' | SQL 内で % を両側に付けて部分一致 |
@keyword = '' OR ... | キーワードが空のときは全件マッチ(SQL 内で吸収) |
@departmentId = -1 OR ... | -1 を「指定なし」のマーカーとして使う |
keyword ?? "" | null が渡されたら "" として扱う |
ReadEmployees をなぜ private static にしたか
Section titled “ReadEmployees をなぜ private static にしたか”private:Repository の外からは使わせない(内部実装)static:インスタンスのフィールドに依存しないのでstaticで書ける
GetOrdinal をループの前に 1 回だけ 呼んでおくと、列が多いときに少し効率がよくなります。
第 24 章 24-4 とほぼ同じコード
Repository は「DB アクセスをまとめる層」なので、Windows / Web で同じ書き方になります。 違うのは接続文字列(
app_user)と、namespace だけです。
ここで一度ビルド確認
Searchを足しましたが、Controller も View もまだ第 28 章のまま(検索フォームは無い)です。Ctrl + Shift + Bでビルドが通ること、F5 で/Employeesが これまでどおり一覧表示 されることを確認しておきましょう。検索フォームは 29-6〜29-8 で追加し、29-9 で動かします。
29-5 DepartmentRepository を確認する
Section titled “29-5 DepartmentRepository を確認する”部署プルダウン用に、第 27 章で作った DepartmentRepository.GetAll() をそのまま使います。
第 28 章でコピー済みなので、本章では追加の作業は不要です。
もし忘れていたら
Data/DepartmentRepository.csとModels/Department.csが無い場合は、第 27 章のものをコピーしてください(namespace はMvcEmployeeApp.Data/MvcEmployeeApp.Modelsに直す)。
29-6 EmployeeSearchViewModel を作る
Section titled “29-6 EmployeeSearchViewModel を作る”検索画面では、次の 3 種類のデータ を View に渡す必要があります。
- 検索条件:現在の
keywordとdepartmentId(入力欄に値を残すため) - プルダウンの選択肢:部署一覧(
Departmentのリスト) - 検索結果:
Employeeのリスト
これらをまとめて持つ専用のクラスを用意します。第 26 章の FortuneViewModel と同じく、View 専用の入れ物(ViewModel) です。
Models フォルダに EmployeeSearchViewModel.cs を追加します。
namespace MvcEmployeeApp.Models;
public class EmployeeSearchViewModel{ // 検索条件 public string Keyword { get; set; } = ""; public int DepartmentId { get; set; } = -1;
// プルダウンの選択肢 public List<Department> Departments { get; set; } = new List<Department>();
// 検索結果 public List<Employee> Results { get; set; } = new List<Employee>();}| プロパティ | 役割 |
|---|---|
Keyword | 検索ボックスの内容(空のときは「指定なし」) |
DepartmentId | 選んだ部署 ID(-1 のときは「すべての部署」) |
Departments | プルダウンに並べる部署一覧 |
Results | 検索結果の社員一覧 |
ViewModel のメリット
ViewBag に複数の値を入れることもできますが、
@ViewBag.Keywordは型が解決されないので タイプミスがビルド時に検出されません。 ViewModel にまとめると、Model.Keywordのように 型付きでアクセス でき、ミスが減ります。
29-7 EmployeesController を検索対応にする
Section titled “29-7 EmployeesController を検索対応にする”EmployeesController.Index を、検索パラメータを受け取れる形に書き換えます。
using Microsoft.AspNetCore.Mvc;using MvcEmployeeApp.Data;using MvcEmployeeApp.Models;
namespace MvcEmployeeApp.Controllers;
public class EmployeesController : Controller{ public IActionResult Index(string keyword = "", int departmentId = -1) { EmployeeRepository empRepo = new EmployeeRepository(); DepartmentRepository deptRepo = new DepartmentRepository();
EmployeeSearchViewModel model = new EmployeeSearchViewModel(); model.Keyword = keyword ?? ""; model.DepartmentId = departmentId; model.Departments = deptRepo.GetAll();
if (string.IsNullOrEmpty(model.Keyword) && model.DepartmentId == -1) { // 検索条件なし → 全件 model.Results = empRepo.GetAll(); } else { model.Results = empRepo.Search(model.Keyword, model.DepartmentId); }
return View(model); }}コードのポイント
Section titled “コードのポイント”| ポイント | 説明 |
|---|---|
アクションの引数 keyword、departmentId | クエリ文字列 ?keyword=...&departmentId=... が Model Binding で自動的に入る |
既定値 "" / -1 | クエリ文字列に値が無いときの初期値(「条件なし」を意味する) |
| 検索条件の判定 | キーワード空 & 部署「すべて」のときは GetAll、それ以外は Search |
return View(model) | ViewModel ごと View に渡す |
なぜ
GetAllとSearchを分けるのか実は
Search("", -1)(キーワード空・部署「すべて」)でも、SQL の@keyword = '' OR ...と@departmentId = -1 OR ...が効いて全件が返ります。つまりSearchだけでも動きます。 それでも条件なしをGetAllに分けているのは、条件がないときは検索用の複雑なWHEREを通さず、単純な SQL で取得できる からです(読みやすさと、無駄な条件評価を避ける意図)。動作はどちらでも同じなので、Search一本にまとめても構いません ── ここは設計の好みです。
Model Binding の基本(第 26 章 26-10 の復習)
ASP.NET Core は、URL のクエリ文字列・フォーム値・ルート値などを、アクションの引数名と一致するもの に自動でセットしてくれます。
Index(string keyword, ...)というアクションがあると、?keyword=山の値がkeyword引数に入ります。
29-8 View に検索フォームを追加する
Section titled “29-8 View に検索フォームを追加する”Views/Employees/Index.cshtml を、検索フォーム + 結果テーブルの構成に書き換えます。
@model MvcEmployeeApp.Models.EmployeeSearchViewModel
@{ ViewData["Title"] = "社員一覧・検索";}
<h1>社員一覧</h1>
<form method="get" asp-controller="Employees" asp-action="Index" class="row g-3 mb-3"> <div class="col-auto"> <label for="keyword" class="form-label">名前</label> <input type="text" id="keyword" name="keyword" value="@Model.Keyword" class="form-control" /> </div>
<div class="col-auto"> <label for="departmentId" class="form-label">部署</label> <select id="departmentId" name="departmentId" class="form-control"> <option value="-1">すべての部署</option> @foreach (MvcEmployeeApp.Models.Department dept in Model.Departments) { if (dept.DepartmentId == Model.DepartmentId) { <option value="@dept.DepartmentId" selected>@dept.DepartmentName</option> } else { <option value="@dept.DepartmentId">@dept.DepartmentName</option> } } </select> </div>
<div class="col-auto align-self-end"> <button type="submit" class="btn btn-primary">検索</button> <a asp-controller="Employees" asp-action="Index" class="btn btn-secondary">クリア</a> </div></form>
<p>@Model.Results.Count 件の社員が見つかりました。</p>
<table class="table"> <thead> <tr> <th>社員ID</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> </tr> } </tbody></table>
<div class="mt-3"> <button type="button" class="btn btn-primary" disabled>新規登録</button> <button type="button" class="btn btn-danger" disabled>削除</button></div>
<p class="text-muted small mt-2">※ 新規登録・削除は第 30 章で実装します。</p>各部分のポイント:
第 26 章との違い:ここでは
asp-forを使っていません第 26 章では
<input asp-for="UserName">のようにasp-forを使いました。本章でname/value/selectedを手書きにしているのは、部署プルダウンの選択肢を部署一覧から動的に作る ためです。属性の対応(name→ クエリ文字列のキー、value→ 入力値の保持、selected→ 選択状態)を手書きで明示すると、検索条件がどう URL に乗り、どう画面に戻るかを追いやすくなります。asp-forでも書けますが、<select>の選択肢を動的生成するにはSelectListなどの追加の仕組みが要ります。
<form method="get">
Section titled “<form method="get">”検索フォームは GET で送信します。送信ボタンを押すと、フォームの各 name 属性とその値が URL のクエリ文字列に組み立てられて、同じアクションが呼ばれます。
名前欄に「山」、部署「総務」を選んで送信 ↓/Employees?keyword=山&departmentId=1<input name="keyword" value="@Model.Keyword">
Section titled “<input name="keyword" value="@Model.Keyword">”name 属性が クエリ文字列のキー になります。value="@Model.Keyword" で、検索後も入力値が消えずに残ります。
<select>(プルダウン)はどう組み立てるか
Section titled “<select>(プルダウン)はどう組み立てるか”<select> は「ドロップダウン(プルダウン)」を作る HTML タグです。中に並べる選択肢ひとつひとつが <option> です。たとえば手書きすると次のようになります。
<select name="departmentId"> <option value="-1">すべての部署</option> <option value="1">総務</option> <option value="2">営業</option></select><option> には 2 つの顔(見せる文字と送る値)があり、加えて <select> 自身が name を持ちます。次の 3 点が要点です。
| 部分 | 役割 |
|---|---|
<option> の value="1" | サーバーに 送られる値(?departmentId=1 の 1 の部分) |
<option>総務</option>(タグで囲んだ文字) | 画面に 見える表示(ユーザーが読む「総務」) |
<select> の name="departmentId" | クエリ文字列の キー になる(?departmentId=...) |
つまり「見せる文字(総務) と 送る値(1) を別々に持てる」のが <option> です。画面では部署名を選ぶのに、サーバーには ID(数字)が届く、という仕組みです。
本章では、この <option> を手書きせず、部署一覧(DB から取得した Model.Departments)から @foreach で 1 個ずつ作って います。
<select id="departmentId" name="departmentId" class="form-control"> <option value="-1">すべての部署</option> @foreach (... dept in Model.Departments) { <option value="@dept.DepartmentId">@dept.DepartmentName</option> }</select>この @foreach は サーバー側で実行され、ただの HTML に展開されてから ブラウザに届きます。部署が 5 件なら、@foreach が <option> を 5 行ぶん作ります(先頭の固定の「すべての部署」とあわせて全 6 行)。
<!-- ブラウザが実際に受け取る HTML(@foreach は消えている) --><select id="departmentId" name="departmentId" class="form-control"> <option value="-1">すべての部署</option> <option value="1">総務</option> <option value="2">営業</option> <option value="3">開発</option> <option value="4">マーケティング</option> <option value="5">品質管理</option></select>部署が増えても減っても View は直さなくてよい ── これが「選択肢を DB から作る」良さです。
<select>/<option>などタグ自体の基本は、付録 K「この研修で使う最低限の HTML」K-3 にもまとまっています。HTML に不慣れなら先に目を通しておくと安心です。
<select> で選択中の項目を保持
Section titled “<select> で選択中の項目を保持”@foreach (... in Model.Departments){ if (dept.DepartmentId == Model.DepartmentId) { <option value="@dept.DepartmentId" selected>@dept.DepartmentName</option> } else { <option value="@dept.DepartmentId">@dept.DepartmentName</option> }}検索後にプルダウンに 検索時に選んだ値が選択されたまま 残るように、対応する <option> に selected 属性を付けます。
「クリア」ボタン(リンク)
Section titled “「クリア」ボタン(リンク)”「クリア」は、見た目はボタンですが <a>(ハイパーリンク) です。btn btn-secondary の Bootstrap クラスでボタン風に見せているだけです。
<a asp-controller="Employees" asp-action="Index" class="btn btn-secondary">クリア</a>asp-controller/asp-actionから、ブラウザに届くときは<a href="/Employees">(クエリ文字列なし)に変換されます。- クリックすると、フォームを送信するのではなく その URL へ画面遷移 します(
<a>はフォームの中にあっても送信ボタンにはならず、リンク先へ移動するだけ)。 - 遷移先
/Employeesには条件が無いので、Controller の既定値(keyword=""/departmentId=-1)が効いて 全件表示・入力欄も空・プルダウンも「すべての部署」で返ってきます。
つまり「クリア」は、ブラウザ側で入力欄を消しているのではなく、条件なしの URL へ移動して、サーバーから新しい(空の)画面を受け取り直している ということです。検索の
<button type="submit">(フォーム送信)や、入力欄だけを画面側で消す一般的なクリアボタン(type="reset")とは動きが違います。
@foreach で表(テーブル)が描かれるしくみ
Section titled “@foreach で表(テーブル)が描かれるしくみ”HTML の表は 「行(<tr>)」の集まり で、各行は 「セル(<td>)」 でできています。<thead> の 1 行は 見出し(固定)、<tbody> の中が データ行 です。
<table> <thead> <tr><th>社員ID</th><th>氏名</th> ...</tr> <!-- 見出し(1行・固定) --> </thead> <tbody> <tr><td>...</td><td>...</td> ...</tr> <!-- データ1行 --> </tbody></table>本章では <tbody> の中身を手で並べず、検索結果 Model.Results を @foreach で 1 人ずつ回し、1 人につき <tr> を 1 行 作っています。
<tbody> @foreach (... emp in Model.Results) { <tr> <td>@emp.EmployeeId</td> <td>@emp.FullName</td> <td>@emp.DepartmentName</td> ... </tr> }</tbody>検索で社員が 2 人見つかったとき、この @foreach は サーバー側で 2 回まわり、次のような ただの HTML(タグだけ) に展開されてからブラウザに届きます。
<!-- ブラウザが実際に受け取る HTML(@foreach は消えている) --><tbody> <tr> <td>1001</td><td>山田 二郎</td><td>総務</td> ... </tr> <tr> <td>1008</td><td>中山 大輔</td><td>総務</td> ... </tr></tbody>ポイントは次の 2 つです。
- ループが回った回数だけ
<tr>が増える ── 検索結果が 10 人なら 10 行、0 人なら 0 行(<tbody>が空になるだけ)。件数に合わせて行数が自動で変わります。 @emp.EmployeeIdのような@で始まる部分が、その社員の値に置き換わる ──@は Razor の印で、サーバーで処理されて消えます。
.cshtmlはそのままブラウザに届くわけではないView(
.cshtml)は サーバーで HTML に変換されてから ブラウザに送られます。@foreachや@emp.FullNameなどの@(Razor)はサーバー側で消化され、ブラウザに届くのは 素の<table>・<tr>・<td>だけ です。この感覚は 第 26 章 26-6 と 付録 K「最低限の HTML」K-4(表) でも扱っています。
@model・@foreach・asp-*・classの役割は早見表に検索フォームの
<form>/<select>/asp-controller/asp-actionやclass="..."の役割は 第 26 章 26-12「MVC のおまじない早見表」 を参照してください。class="row g-3"/form-control/btnなどは Bootstrap の見た目クラスで、付けるとフォームやボタンが整って並ぶだけです(中身は気にしなくて OK)。
検索全体の流れ(ブラウザ → Controller → Repository → DB → View)
Section titled “検索全体の流れ(ブラウザ → Controller → Repository → DB → View)”ここまでの ViewModel・Controller・View が、検索ボタンを押したときに どう連携するか を 1 枚にまとめると次のようになります。検索は GET の往復(リクエスト → レスポンス)で完結します。
- ブラウザは URL(クエリ文字列) を送るだけ。受け取るのは できあがった HTML だけです(
.cshtmlは届きません ── 第 26 章 26-6)。 - 値が SQL に渡るところは必ず パラメータ(
@keyword/@departmentId) ── 29-2〜29-3 の防御がこの 1 本の流れの中で効いています。 - 「クリア」も同じ往復で、条件なしの
/Employeesを GET し直しているだけです。
29-9 動かしてみる
Section titled “29-9 動かしてみる”ViewModel・Controller・View(検索フォーム)までそろったので、検索を動かします(先ほどのビルド確認に続く 2 回目=動作確認です)。 F5 で実行し、ブラウザのアドレスバーに次の URL を入れて開いてください。
https://localhost:xxxx/Employees10 件の社員が表示されたら、検索フォームに次の値を入れて動作を確認します。
| 操作 | URL の例 | 期待される結果 |
|---|---|---|
| 「山」と入力して検索 | ?keyword=山 | 姓に「山」を含む社員:山田 二郎・山口 洋子・中山 大輔 の 3 件 |
| 部署「総務」を選んで検索 | ?departmentId=1 | 部署が総務の社員(山田・佐々木・中山 など) |
| 「山」+ 「総務」 | ?keyword=山&departmentId=1 | 姓に「山」を含み、かつ総務:山田 二郎・中山 大輔 の 2 件 |
| 「クリア」をクリック | (パラメータなし) | 全 10 件 |
URL を直接編集して ?keyword=星野 を試す | ?keyword=星野 | 星野 健一 のみ |
SQL インジェクション対策が効いていることを確認
試しに
?keyword=' OR '1'='1のような URL を打ち込んでみてください。 パラメータ化されているので、「' OR '1'='1」という 文字列をそのまま検索キーワードとして扱う ため、該当社員はいません(0 件)。 攻撃が無力化されている、というのが感じ取れます。
おすすめ:デバッグでパラメータを観察
EmployeeRepository.SearchのExecuteReader直前にブレークポイントを置き、cmd.Parametersの中身を見ると、@keyword/@departmentIdがそれぞれの値で渡されている様子が確認できます。
29-10 GET と POST どちらを使うか
Section titled “29-10 GET と POST どちらを使うか”検索フォームを method="get" にしている理由を整理します。第 26 章 26-15 で学んだ違いを、検索の文脈で見直すと次のようになります。
| 観点 | GET(検索) | POST(更新系) |
|---|---|---|
| URL に条件が乗る | はい | いいえ |
| ブックマークできる | はい | いいえ |
| ブラウザ履歴に残る | はい | URL は残るが値は残らない |
| 同じ操作を何度繰り返しても安全か | はい(冪等) | 注意(二重送信になり得る) |
| 例 | 検索結果ページ、フィルタ画面 | フォーム送信、ログイン、登録 |
検索は基本的に「何度やっても同じ結果が返るだけで、サーバーの状態を変えない」ので、GET が自然です。
「?keyword=山&departmentId=1」のような URL をブックマークに入れたり、Slack に貼ったりして共有できる ── これは Web ならではの良さで、Windows フォームには無い体験です。
POST にする場面はあるか?
検索条件が非常に長い(数百文字)場合や、機密情報を URL に残したくない場合は POST にすることもあります。 しかし通常の業務検索では、GET + クエリ文字列 が現場の標準です。
29-11 Windows フォーム編との比較
Section titled “29-11 Windows フォーム編との比較”第 24 章(Windows 版検索)と本章の構造を並べてみます。
| 観点 | Windows フォーム(第 24 章) | Web MVC(本章) |
|---|---|---|
| 検索 UI | TextBox、ComboBox、Button をフォームに配置 | <input>、<select>、<button> を .cshtml に書く |
| 検索条件の取得 | keywordTextBox.Text、departmentComboBox.SelectedValue | アクションの引数(Model Binding) |
| 検索条件の渡し方 | フォーム内の値を直接読む | URL のクエリ文字列(?keyword=...) |
| 検索結果の表示 | DataGridView.DataSource = list; | View(model) で View に渡す |
| 状態の保持 | コントロールが値を保持(同じフォーム) | URL に値が乗る + View で value 属性に再表示 |
| Repository.Search | ほぼ同じ(接続文字列のみ違う) | ほぼ同じ(接続文字列のみ違う) |
| SQL インジェクション対策 | SqlParameter | SqlParameter(同じ) |
DB アクセス層は コードがほぼ同じ、上層(画面 / Controller / 状態管理)は Web 流に書き換え ── 第 28 章で見たパターンが、検索でも同じように現れます。
よくあるつまずき
Section titled “よくあるつまずき”| つまずき | 原因 | 対応 |
|---|---|---|
| 検索後にプルダウンの選択が「すべての部署」に戻る | selected 属性の付与忘れ | View の @if で selected を出力しているか確認 |
| 検索後にテキスト入力欄が空に戻る | value="@Model.Keyword" を書き忘れ | <input> に value を追加 |
?keyword=山 で結果が 0 件 | LIKE の % が SQL 内に無い | LIKE '%' + @keyword + '%' の % 配置を確認 |
| URL のクエリが受け取れない | アクションの引数名と name 属性が違う | フォームの name="keyword" と Controller の string keyword を一致させる |
| 部署プルダウンが空 | Departments プロパティに値が入っていない | Controller で deptRepo.GetAll() を呼んでセットしているか確認 |
| 検索結果が「件数 0」と出るのに表に全件出る | View の @foreach が別の変数を使っている | Model.Results を回しているか確認 |
model.Keyword が null でエラー | クエリ文字列が無いと既定値が効かないケース | Controller の引数で string keyword = "" のように既定値を付ける |
| 「クリア」ボタンが効かない | asp-controller / asp-action のスペルミス | Index 大文字小文字、Controller 名を確認 |
学んだことチェック
Section titled “学んだことチェック”- SQL インジェクションがなぜ Web で特に危険かを説明できる
-
SqlParameterを使ったパラメータ化クエリを書ける -
LIKE '%' + @keyword + '%'で部分一致を表現できる - 複数条件を AND で組み合わせる
WHERE句を書ける -
GetAllとSearchで共通の読み取り処理をprivate staticメソッドに切り出せる - 検索画面用の ViewModel(
EmployeeSearchViewModel)を設計できる - Controller のアクションがクエリ文字列を Model Binding で受け取る仕組みを説明できる
- View で
<form method="get">の検索フォームを書ける -
<select>で選択中の項目にselectedを付けて状態を保てる - 「検索は GET、更新は POST」の使い分けを説明できる
- Windows 版(第 24 章)と Web 版(本章)で、Repository の中身がほぼ同じであることを確認できた
研修の進め方によっては、隣の人またはチーム内で説明確認を行います。
次の内容を、自分の言葉で説明してください。
- SQL インジェクションは、Windows フォームと比べて Web でなぜ危険度が上がりますか。
@keyword = '' OR ...を SQL に入れている理由は何ですか。ReadEmployeesをprivate staticにした 2 つの理由を答えてください。- なぜ検索フォームは
<form method="get">にしているのですか。 - プルダウンの選択値を検索後も残すには、HTML 側で何をしていますか。
- ViewModel(
EmployeeSearchViewModel)を導入したことで、ViewBagを使う場合と比べて何が良くなりますか。 - 「クリア」リンクと「検索」ボタンは、HTTP レベルでは何が違いますか(URL の形)。
この章の演習課題に取り組みます(タイマーはありません。動かして観察し、自分の言葉で説明できることを重視します。チームで進める場合は声を掛け合いながら自分のペースで進めてください)。
必須課題は、本文で育てている MvcEmployeeApp プロジェクト にそのまま書き込みます(「なぜ」コメントも同じプロジェクト)。発展課題だけは、同じ KadaiWebApp ソリューション内に 別プロジェクト として作ります。
| 課題 | プロジェクト | 内容 |
|---|---|---|
| 課題 29-1(必須) | MvcEmployeeApp(本文の続き) | 検索機能 + 「なぜ」コメント |
| 課題 29-2(発展) | Ext_EmployeeSearchExtra(新規) | 検索をカスタマイズ |
| 課題 29-3(発展) | (コードなし・レポート) | 第 24 章とコード比較 |
課題 29-1 ソースを読み解いて「なぜ」コメントを付ける
Section titled “課題 29-1 ソースを読み解いて「なぜ」コメントを付ける”本文 29-4〜29-9 で検索機能まで仕上げた MvcEmployeeApp の EmployeeRepository.cs・EmployeeSearchViewModel.cs・EmployeesController.cs を読み返し、第 23・24 章の課題と同じ要領 で、次の 2 種類のコメントを書き込んでください。本章のヤマは パラメータ化(SQL インジェクション対策) なので、その「なぜ」を重視します。
- (A) メソッドの役割:各メソッドの 上の行 に、何をするメソッドかを 1 行(
//)で書く - (B) 難所の「なぜ」:下の表の各箇所に、「なぜそう書くのか」「何のためか」 を 前の行 に自分の言葉で書く(言い換えコメントは NG。→ 第 23 章「課題 23-1」・第 7 章コラム)
View(.cshtml)の <form method="get">・asp-*・@foreach・selected・class は MVC の「おまじない」 なので、「何をする決まりか」を一言で十分です(→ 26-12 早見表)。
(B) 「なぜ」コメントを付ける箇所
| ファイル | 箇所 | 説明する観点(= ここに「なぜ」を書く) |
|---|---|---|
EmployeeRepository.cs | cmd.Parameters.AddWithValue("@keyword", ...) | なぜ + で連結せずパラメータで渡すのか(Web で何を防ぐか) |
EmployeeRepository.cs | LIKE '%' + @keyword + '%' | % は何を表すか / なぜ両側に付けるか |
EmployeeRepository.cs | @keyword = '' OR ... | なぜ空のとき全件になるのか |
EmployeeRepository.cs | @departmentId = -1 OR ... | なぜ -1 を「指定なし」のしるしに使うのか |
EmployeeRepository.cs | ReadEmployees を private static に切り出した点 | なぜ GetAll と Search で共通化したのか |
EmployeeSearchViewModel.cs | 条件 / 選択肢 / 結果の 3 種をまとめている点 | なぜ ViewBag でなく ViewModel にまとめるのか |
EmployeesController.cs | Index(string keyword = "", int departmentId = -1) | なぜ引数で受け取れるのか(Model Binding)・既定値の意味 |
EmployeesController.cs | 条件なしは GetAll / それ以外は Search の分岐 | なぜこの分岐にするのか |
確認すること
- (A) 各メソッドの上に「役割」を 1 行コメントした
- (B) 表のすべての箇所に「なぜ/何のため」を前行で書いた
- とくに パラメータ化の「なぜ」(SQL インジェクション対策) を自分の言葉で書けた
- View のおまじない(
<form method="get">/asp-*/selected)は「何をする決まりか」を一言で書いた - 言い換え・丸写しになっていない/
?keyword=' OR '1'='1で 0 件になることを確認した
課題 29-2 検索をカスタマイズする
Section titled “課題 29-2 検索をカスタマイズする”KadaiWebApp ソリューションに新しいプロジェクト Ext_EmployeeSearchExtra を作成し、MvcEmployeeApp のコードをコピーした上で、下の A〜D から 1 つ以上 を選んで改造してください(本体は壊さず、実験はこちらで)。
| 選択肢 | 内容 | ヒント |
|---|---|---|
| A:給与レンジ検索を追加 | 「給与下限」「給与上限」の入力欄を増やす | WHERE e.salary BETWEEN @minSalary AND @maxSalary、「指定なし」は OR で吸収。もちろんパラメータ化 |
| B:メール部分一致を加える | キーワードを email にも当てる | OR e.email LIKE '%' + @keyword + '%' を追加 |
| C:結果 0 件のときに案内を出す | 表でなく「該当する社員はいませんでした。」を表示 | View で @if (Model.Results.Count == 0) 分岐 |
| D:氏名列で並べ替え(やや難) | 氏名ヘッダーのクリックで昇順 / 降順をトグル(?sort=name_asc 等) | sort を引数で受け、ORDER BY を switch で 許可値だけ 当てる。⚠️ sort を SQL に直接連結しない(これも SQL インジェクション対策。default で安全側に)。ヘッダーは <a asp-action="Index" asp-route-keyword="..." asp-route-sort="name_asc">氏名</a> |
確認すること
- 選んだ改造が正しく動く
- パラメータ化を崩していない(
+で値を連結しない/sortを直接連結しない) - 改造を入れた場所(Repository / Controller / View)を自分で指せる
課題 29-3 第 24 章と比べてレポートを書く
Section titled “課題 29-3 第 24 章と比べてレポートを書く”本章の EmployeeRepository.Search と、第 24 章 24-4 の EmployeeRepository.Search を 見比べて、下の表を埋めてください(紙のノート、Markdown、ペア確認の口頭発表 ── 形式は自由)。
| 観点 | 第 24 章(Windows) | 本章(Web) |
|---|---|---|
| メソッドのシグネチャ | ||
| SQL 文の中身 | ||
AddWithValue の呼び方 | ||
| 検索条件をどこから取るか | ||
| 結果を画面にどう渡すか |
そのうえで、次の問いに答えてください。
- Web 版で 追加された防御 は何か(あれば挙げる、なければ「同じ」と書く)
- 検索条件を URL に乗せる ことで生じるメリットとデメリットを 1 つずつ挙げる
- クライアント(ブラウザ)が
SqlParameterを直接送ってくる ことはあるか?
この課題はコードを書きません。「同じ Repository が Windows と Web の両方で動く」を体感する ことが目的です。
提出前チェックリスト
Section titled “提出前チェックリスト”- ソリューション名が
KadaiWebApp、本体プロジェクト名がMvcEmployeeApp - csproj が
<Nullable>disable</Nullable> -
Microsoft.Data.SqlClientが NuGet で追加されている -
Models/EmployeeSearchViewModel.csが作成されている -
Data/EmployeeRepository.csにSearch(string, int)とprivate static ReadEmployees(SqlCommand)がある -
Controllers/EmployeesController.csのIndexがクエリ文字列を受け取る -
Views/Employees/Index.cshtmlに検索フォームが追加されている -
<form method="get">で送信している -
<select>の選択値が検索後も保持される(selected属性が動的に付く) - 「クリア」リンクで条件なしの
/Employeesに戻れる -
?keyword=' OR '1'='1のような攻撃 URL を試して、結果が 0 件になることを確認した - 必須課題 29-1:各メソッド役割 1 行+難所の表すべてに「なぜ」を前行コメントした(とくにパラメータ化の理由)
- View のおまじない(
<form method="get">/asp-*/selected)は「何をする決まりか」を一言で書いた - (発展 29-2 を実施した場合)
Ext_EmployeeSearchExtraで改造が動く -
bin・obj・.vsフォルダが Git 管理に入っていない
Git への提出
Section titled “Git への提出”git statusgit add .git commit -m "Chapter29: 社員検索完成+なぜコメント / <パラメータ化の「なぜ」を一言>"git push origin mainGit の詳しい操作は、付録 C「Git のインストールと提出ルール」 を参照してください。
この章のまとめ
Section titled “この章のまとめ”- 検索条件は クエリ文字列(
?keyword=...&departmentId=...) で Controller に渡るのが Web の自然な形 <form method="get">を使うと、name属性付きの入力部品が 自動でクエリ文字列に組み立てられる- SQL インジェクションは Web で特に危険(全世界から URL を組み立てて送られうるため)
- 防御の正しい書き方は パラメータ化クエリ(
@param+SqlParameter)。Windows / Web で同じ GetAllとSearchの重複をprivate static ReadEmployeesで共通化 すると保守性が上がる- ViewModel(
EmployeeSearchViewModel)に検索条件・選択肢・結果をまとめると、View 側で型付きアクセスができる <input value="@Model.Keyword">と<option selected>で 検索後の入力状態を保持 できる- 「検索は GET、更新は POST」が現場の使い分けの基本
次章では、いよいよ Web 社員管理アプリの仕上げ ── 編集・更新 に入ります。
第 25 章(Windows 版編集)と同じ範囲を Web で実装し、INSERT / UPDATE / DELETE を完成させます。
新たに登場するテーマ:
- ルーティングパラメータ(
/Employees/Edit/1001のような URL 設計) - 編集フォームの POST 送信(検索とは違って状態を変える操作)
- PRG(Post-Redirect-Get)パターン(更新後に GET にリダイレクトしてリロード問題を防ぐ)
- 入力チェック(モデルバリデーション) の基礎
- 第 28・29 章で
disabledだった 新規登録・削除ボタン が、ついに動き出す
第 25 章のコードを手元に置いて、Windows と Web の最後の対比を楽しみながら進めましょう。