ASP.NET Core MVCのチュートリアルを丁寧にやってみた③(タグヘルパーの確認や検索機能の追加など)

ASP.NET Core

ASP.NET Core MVCの公式チュートリアルを丁寧に解説する記事の第3回です。

第2回ではEntity Framework Coreを使ったDB連携について見ていきました。

第3回では、タグヘルパーやPOSTリクエストの処理を確認し、さらに検索機能を追加するところまでを扱います。

公式チュートリアルのパート6~7に対応します。

パート 6: ASP.NET Core のコントローラーのメソッドとビュー
パート 6: ASP.NET Core MVC アプリにモデルを追加する
スポンサーリンク

タグヘルパー

まずはタグヘルパーについて見ていきます。
しばらくは作成済みのコード内容の確認が続くので退屈かもしれませんが、とても重要なので確実に理解しておきたい部分です。

まずアプリをデバッグ実行したら、「http://localhost:ポート番号/Movies」にアクセスします。
※事前に適当なデータを「Create New」から追加してあります。

Edit・Details・Deleteの各リンク部分のソースをブラウザで確認してみます。

<td>
    <a href="/Movies/Edit/3">Edit</a> |
    <a href="/Movies/Details/3">Details</a> |
    <a href="/Movies/Delete/3">Delete</a>
</td>

href属性が「/コントローラー名/アクションメソッド名/ID」となっていますね。

これらはViews/Movies/Index.cshtmlの以下のコードから自動生成されています。

<td>
    <a asp-action="Edit" asp-route-id="@item.Id">Edit</a> |
    <a asp-action="Details" asp-route-id="@item.Id">Details</a> |
    <a asp-action="Delete" asp-route-id="@item.Id">Delete</a>
</td>

asp-actionでアクションメソッド名を、asp-route-idでMovieモデル内のIDを指定して、href属性を動的に生成しています。

このようにタグヘルパーを使うと通常のHTMLのように記述できるので、C#に詳しくないWebデザイナー等でも直感的に理解しやすいというメリットがあります。

今回見たのは「アンカータグヘルパー」でしたが、他にも様々な種類のタグヘルパーがあります。
興味のある方は下記ドキュメントを参照してください。

ASP.NET Core の組み込みタグ ヘルパー
タグ ヘルパーに組み込まれた ASP.NET Core によって生産性がどのように向上するかをご確認ください。

POSTリクエストの処理

POSTリクエストの処理を確認するために、MoviesコントローラーのEditメソッドを見てみましょう。

Editメソッドは2つあり、上がGETメソッド、下がPOSTメソッドでアクセスした際の処理になります。
※以降、わかりやすさのために適宜コメントを付与しています。

// GET: Movies/Edit/5
public async Task<IActionResult> Edit(int? id)
{
  // idやMovieテーブルがnullの場合はNotFoundを返す
    if (id == null || _context.Movie == null)
    {
        return NotFound();
    }

  // Movieテーブルからidが一致する映画データを取得し、ビューに返す
    var movie = await _context.Movie.FindAsync(id);
    if (movie == null)
    {
        return NotFound();
    }
    return View(movie);
}

// Edit画面で「Save」をクリックした際に実行される
// POST: Movies/Edit/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, [Bind("Id,Title,Genre,Revenue,ReleaseDate")] Movie movie)
{
    if (id != movie.Id)
    {
        return NotFound();
    }

    // モデル検証が成功した場合の処理
    if (ModelState.IsValid)
    {
        try
        {
            // 送信されたデータをもとにMovieテーブルのデータを更新
            _context.Update(movie);
            await _context.SaveChangesAsync();
        }
        // 同時に同じデータが変更された(競合が発生した)場合の例外処理
        catch (DbUpdateConcurrencyException)
        {
            if (!MovieExists(movie.Id))
            {
                return NotFound();
            }
            else
            {
                throw;
            }
        }
        // Indexページにリダイレクトする
        return RedirectToAction(nameof(Index));
    }
    return View(movie);
}

メソッド内の3つの属性とデータを保存する処理について詳しく見てみましょう。

HttpPost属性

メソッドの前に[HttpPost]属性を付けると、POSTメソッドによるリクエストのみ受け付けることができます。

何も記載しなかった場合は[HttpGet]属性を付けた場合と同じ挙動になります。
可読性を高めるためにあえて[HttpGet]属性を付けるようにするのもありかなと思います。

ValidateAntiForgeryToken属性

CSRF(クロスサイトリクエストフォージェリ)攻撃を防ぐための属性です。

Edit.cshtml内のformタグヘルパー(<form asp-action=”Edit”>)で自動生成される対策トークンとセットで使用します。

POSTメソッドには必ず[ValidateAntiForgeryToken]を付与すると覚えておけばいいでしょう。

攻撃手法等については解説しませんが、詳細が気になる方は以下を参照してください。

ASP.NET Core でクロスサイト リクエスト フォージェリ (XSRF/CSRF) 攻撃を防止する
悪意のある Web サイトがクライアント ブラウザーとアプリの間の対話に影響を与える可能性がある Web アプリに対する攻撃を防ぐ方法について説明します。

Bind属性

オーバーポスティング(過多ポスティング)攻撃を防ぐための属性で、フォーム画面から送信されるプロパティの名前を指定します。

モデルにPOST送信したくないプロパティがある場合に、それを送信・変更されるのを防ぐために使います。

例えばMovie.csに「Secret」というプロパティがあり、Edit画面からはSecretの値を変更されたくないとします。(もちろんSecretの入力フォームもない)
もしEditメソッドの引数にBind属性を指定していなかったとすると、悪意のあるユーザーがツール等を使ってSecretプロパティをPOST送信した場合、Editメソッド側では送信された値を通常通り受け取ってしまい、Secretの値が意図せずに更新されてしまいます。

少しわかりにくいかと思うので、こちらも詳細については以下を参照してください。

チュートリアル: ASP.NET MVC
MVC スキャフォールディングによってコントローラーとビューに自動的に作成される作成、読み取り、更新、削除 (CRUD) コードを確認し、カスタマイズします。

Bind属性も、POSTメソッドの場合に必ず指定すると覚えておけばとりあえずOKです。

ModelState.IsValidとSaveChangesAsync

モデルバインドシステムによって、Edit画面から送信されたフォームの値が取得され、Movieオブジェクトが作成されます。

ModelState.IsValidで、そのMovieオブジェクトがフォームの値で変更できることを検証し、問題がなければコンテキストを更新します。

その後SaveChangesAsyncメソッドを実行することでUpdate文が実行され、データベースのデータが変更されます。

検索機能の追加

ここからは、ジャンルまたはタイトルで映画を絞り込める検索機能を追加していきます。

まずMoviesController.csのIndexメソッドを次のように書き換えます。

// GET: Movies
public async Task<IActionResult> Index(string searchString)
{
    // Movieテーブルから全てのデータを取得するLINQクエリ
    var movies = _context.Movie.Select(m => m);

    // タイトル検索処理
    if (!string.IsNullOrEmpty(searchString))
    {
        // タイトルに検索文字列が含まれるデータを抽出するLINQクエリ
        movies = movies.Where(s => s.Title!.Contains(searchString));
    }

    // ToListAsyncメソッドが呼び出されたらクエリが実行され(遅延実行)、その結果をビューに返す
    return View(await movies.ToListAsync());
}

画面からserachStringをパラメータとして受け取り、タイトルと一致するデータをDBから検索して画面に表示します。

次は画面に検索窓を作成します。Views/Movies/Index.htmlを次のように書き換えてください。

@model IEnumerable<MvcMovie.Models.Movie>

@{
    ViewData["Title"] = "Index";
}

<h1>Index</h1>

<p>
    <a asp-action="Create">Create New</a>
</p>

<form asp-controller="Movies" asp-action="Index" method="get">
    <p>
        Title: <input type="text" name="SearchString" />
        <input type="submit" value="検索" />
    </p>
</form>

formタグ内で「method=”get”」と指定しているのがポイントです。ここでは検索のみで値の更新は行わないので、POSTメソッドではなくGETメソッドを指定しています。

ではデバッグ実行してみましょう。
検索窓が表示され、タイトル名で表示を絞り込むことができるようになりました。

アドレスバーに「SearchString=<検索した文字列>」と表示されることも確認できます。

ジャンルによる検索機能の追加

次に、ジャンル名でも表示を絞り込めるように検索機能を追加します。

Modelsフォルダ内に新たにMovieGenreViewModelを追加しましょう。

using Microsoft.AspNetCore.Mvc.Rendering;

namespace MvcMovie.Models
{
    public class MovieGenreViewModel
    {
        public List<Movie>? Movies { get; set; }
        public SelectList? Genres { get; set; }
        public string? MovieGenre { get; set; }
        public string? SearchString { get; set; }
    }
}

それぞれのプロパティの役割は次の通りです。

  • Movies:Movieテーブルにある映画データをMovie型リストで格納
  • Genres:Movieテーブルにある全ジャンルをSelectList型で格納
  • MovieGenre:ドロップダウンリストで選択されたジャンルを格納
  • SearchString:検索文字列を格納

SelectListクラスは、ドロップダウンリストとしてユーザーに1つの項目を選択させたい場合に使用します。(項目が重複しないリスト)

MoviesController.csのIndexメソッドを次のように書き換えます。

// GET: Movies
public async Task<IActionResult> Index(string movieGenre, string searchString)
{
    // Movieテーブルから全てのジャンルを取得するLINQクエリ
    var genreQuery = _context.Movie
        .OrderBy(m => m.Genre)
        .Select(m => m.Genre);

    // Movieテーブルから全てのデータを取得するLINQクエリ
    var movies = _context.Movie.Select(m => m);

    // タイトル検索処理
    if (!string.IsNullOrEmpty(searchString))
    {
        // タイトルに検索文字列が含まれるデータを抽出するLINQクエリ
        movies = movies.Where(s => s.Title!.Contains(searchString));
    }

    // ジャンル検索処理
    if (!string.IsNullOrEmpty(movieGenre))
    {
        // 選択したジャンルがと一致するデータを抽出するLINQクエリ
        movies = movies.Where(x => x.Genre == movieGenre);
    }

    // ジャンルと抽出した映画データをそれぞれリストにしてプロパティに格納する
    var movieGenreVM = new MovieGenreViewModel
    {
        Genres = new SelectList(await genreQuery.Distinct().ToListAsync()),
        Movies = await movies.ToListAsync()
    };

    return View(movieGenreVM);
}

さらに、index.cshtmlを次のように書き換えます。(ついでに表記を一部日本語化しています)

@model MvcMovie.Models.MovieGenreViewModel

@{
    ViewData["Title"] = "Index";
}

<h1>Index</h1>

<p>
    <a asp-action="Create">新規作成</a>
</p>

<form asp-controller="Movies" asp-action="Index" method="get">
    <p>

        <select asp-for="MovieGenre" asp-items="Model.Genres">
            <option value="">全て</option>
        </select>

        タイトル: <input type="text" asp-for="SearchString" />
        <input type="submit" value="検索" />
    </p>
</form>

<table class="table">
    <thead>
        <tr>
            <th>
                @Html.DisplayNameFor(model => model.Movies[0].Title)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Movies[0].ReleaseDate)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Movies[0].Genre)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Movies[0].Revenue)
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model.Movies)
        {
            <tr>
                <td>
                    @Html.DisplayFor(modelItem => item.Title)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.ReleaseDate)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Genre)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Revenue)
                </td>
                <td>
                    <a asp-action="Edit" asp-route-id="@item.Id">Edit</a> |
                    <a asp-action="Details" asp-route-id="@item.Id">Details</a> |
                    <a asp-action="Delete" asp-route-id="@item.Id">Delete</a>
                </td>
            </tr>
        }
    </tbody>
</table>

ここまで修正できたら、デバッグ実行してみましょう。

無事にジャンル名でも検索できるようになりました。

まとめ

前半ではタグヘルパーの説明とPOSTリクエストをコントローラで受け付けた場合の挙動を確認し、後半ではViewModelを使った検索処理を追加しました。

第4回(最終回)では、モデルの検証についてより詳しく見ていきたいと思います。

コメント

  1. K.F より:

    とても丁寧なチュートリアルで大変助かります。
    スキャフォールディングのところでビルドしましたがエラーが発生し、
    インスール済みのNugetパッケージをいれろと怒られましたが、
    インスールし直したところうまくいきました。

    ④も楽しみにしております。

    • ひらひら より:

      コメントありがとうございます。そう言っていただき大変励みになります。

      スキャフォールディングについてですが、私の方で再度確認したところ確かにそのようなエラーが発生しました。
      詳細は不明ですが、最初に以下の2つのパッケージをインストールすると回避できるようなので、記事の内容も修正しました。
      Install-Package Microsoft.EntityFrameworkCore.SqlServer
      Install-Package Microsoft.EntityFrameworkCore.Design

      ④は今月中に公開する予定ですので、そちらもぜひ参考にしていただければ嬉しいです。

タイトルとURLをコピーしました