你的浏览器还没开启 Javascript 功能!

ASP.NET Core MVC 階層化アーキテクチャ Chap0 (ScaffoldからC.R.U.D操作を理解する)

ASP.NET Core MVC を用いて開発する際に、間違った実装方法をする人をときどきみかけます。

確かにASP.NET Core MVC はJavaのSpring Bootと比べ、初心者には優しい作りになっています。

何せScaffold機能提供しておりますので、最初のひな型を自動的に生成することができます。しかし、そのひな型のアーキテクチをそのまま用いて実装し続けると、モジュール間の疎結合や関心の分離の原則が失われ、機能が増えるたびにどんどんメンテナンスしにくいプログラムになる恐れがあります。

また、Web Formsしか経験ない開発者にとって、MVCへシフトすることにあたって新たに覚える必要な観念が多々あります。

例)

  • オブジェクト指向
  • SOLID原則
  • ORM
  • Linq
  • DI (Dependency Injection)
  • 非同期

Web Formsの知識であるデータ操作(DataSet、DataTable)やフロント側(サーバーコントロールによるコードビハインド)はMVCでは一切使いませんので、学習コストがそれ相応にあると覚悟したほうがいいと思います。

でも、諦めることなかれ。

幸運なことに、ここ何年Microsoft公式リファレンスがかなり充実するようになって、学習リソースは探そうと思えばいくらでもあります。

これらの学習リソースあるにも関わらず、多くの開発者は公式リファレンスをスキップして、今までの経験則を元にいきなり実装し始めてしまいます。

今回は最も基本的なScaffoldからCRUD操作(Create,Read,Update,Delete)を用いて、ASP.NET Core MVC の仕組みを理解して、階層化アーキテクチャを実装することにおける前提知識を身に着けます。

前提

今回はScaffoldを使って自動生成されたコードの中身を解説していきます。

1. 開発環境

  • 開発ツール : Visual Studio Code or Visual Studio 2019
  • .NET Core Version : 3.0
  • RDB : SQL Server LocalDB
  • ORM : EF Core
  • データベース : Northwind

プロジェクト作成

1. ソリューション作成

今後の階層化アーキテクチャを実装するため、最初にソリューションフォルダを作成し、ソリューションファイルを作成します。

mkdir ds.NorthwindApp

cd ds.NorthwindApp

dotnet new sln -n ds.NorthwindApp

2. MVCプロジェクト作成

コマンドを用いてMVCプロジェクトを作成し、プロジェクトをソリューションに加えます。

dotnet new mvc -n ds.NorthwindApp.Web

dotnet sln add ds.NorthwindApp.Web

Scaffoldでコード自動生成

1. ScaffoldからDbContextのEntityを作成

プロジェクトフォルダに入ります。

cd .ds.NorthwindApp.Web

Scaffoldコマンド実行できるようにdotnet toolをインストールします。

dotnet tool install --global dotnet-ef

プロジェクトに必要なパッケージをインストールします。

dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Microsoft.EntityFrameworkCore.SqlServer

LocalDBの接続情報を使ってScaffoldコマンドを実行します。

dotnet ef dbcontext scaffold "Server=(localdb)\mssqllocaldb;Database=Northwind;Trusted_Connection=True;" 
Microsoft.EntityFrameworkCore.SqlServer 
-c NorthwindContext -o Models -d --schema dbo --use-database-names -f

上記のコマンドを実行すると、ModelsフォルダにDbContexとEntityクラスが作成されます。

2. 接続文字列の修正

Scaffoldで自動生成されたdbContextに接続文字列がそのまま書かれています。非常に非セキュアのため、appsettings.jsonに接続文字列を定義するように修正します。

  • NorthwindContext.cs

    OnConfiguringメソッドの中身を削除します。

      protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
      {
      }
  • appsettings.json

      {
          "Logging": {
              "LogLevel": {
                  "Default": "Information",
                  "Microsoft": "Warning",
                  "Microsoft.Hosting.Lifetime": "Information"
              }
          },
          "AllowedHosts": "*",
          "ConnectionStrings": {
              "Northwind": "Server=(localdb)\\mssqllocaldb;Database=Northwind;Trusted_Connection=True;"
          }
      }
  • Startup.cs

      public void ConfigureServices(IServiceCollection services)
      {
          services.AddControllersWithViews();
          services.AddDbContext<NorthwindContext>(option =>
              option.UseSqlServer(Configuration.GetConnectionString("Northwind"))
          );
      }

3. Controller & Viewの作成

dotnet cliの必要モジュールをインストールします。

dotnet tool install -g dotnet-aspnet-codegenerator

必要パッケージをプロジェクトにインストールします。

dotnet add package Microsoft.VisualStudio.Web.CodeGeneration.Design

Controllerを自動生成します。

dotnet aspnet-codegenerator controller -name CustomersController -m Customers -dc  NorthwindContext -outDir Controllers -udl -scripts -f

dotnet aspnet-codegenerator controller -name SuppliersController -m Suppliers -dc  NorthwindContext -outDir Controllers -udl -scripts -f

上記のコマンドを実行した結果、ControllersフォルダとViewsフォルダにEnityクラスに対応したCRUD操作できるコードが自動生成されます。

Scaffoldされたコードの解説

Scaffoldによる自動生成されたCustomersのCRUD操作のコードを解説していきます。

1. Index (一覧)

DI (Dependency Injection)によって、CustomersControllerではDbContextが注入され、
NorthwindContextオブジェクトを用いてデータ参照・操作が可能になります。

CustomersController.cs

public class CustomersController : Controller
{
    private readonly NorthwindContext _context;

    public CustomersController(NorthwindContext context)
    {
        _context = context;
    }

    public async Task<IActionResult> Index()
    {
        return View(await _context.Customers.ToListAsync());
    }

Indexに対応するViewはViews/Customers/Index.cshtml です。

  • 画面

ASP.NET Core MVC はRazorのHtml Helperに加え、Tag Helperを使用できます。
下記のようにデータのidの対応したリンクのHTMLが生成されます。

  • Razor (cshtml)

      <td>
          <a asp-action="Edit" asp-route-id="@item.CustomerID">Edit</a> |
          <a asp-action="Details" asp-route-id="@item.CustomerID">Details</a> |
          <a asp-action="Delete" asp-route-id="@item.CustomerID">Delete</a>
      </td>
  • アプリ実行時に生成されるHTML

      <td>
          <a href="/Customers/Edit/ALFKI">Edit</a> |
          <a href="/Customers/Details/ALFKI">Details</a> |
          <a href="/Customers/Delete/ALFKI">Delete</a>
      </td>

2. Details (詳細)

Detailsメソッドはidに応じて処理を行います。

  • idもしくはidに対応するデータが存在しない場合、404にレスポンスを返します。
  • データが存在する場合、詳細画面を表示します。
public async Task<IActionResult> Details(string id)
{
    if (id == null)
    {
        return NotFound();
    }

    var customers = await _context.Customers
        .FirstOrDefaultAsync(m => m.CustomerID == id);
    if (customers == null)
    {
        return NotFound();
    }

    return View(customers);
}

Details.cshtmlの@modelはCustomersクラスにデータバインディングしており、
Indexと違ってIEnumerableを用いていません。

@model ds.NorthwindApp.Web.Models.Customers

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

画面

3. Edit (編集)

EditはGetとPostそれぞれに対応したActionがあります。

  • 編集画面表示
  • データ更新
public async Task<IActionResult> Edit(string id)
{
    if (id == null)
    {
        return NotFound();
    }

    var customers = await _context.Customers.FindAsync(id);
    if (customers == null)
    {
        return NotFound();
    }
    return View(customers);
}

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(string id, 
    [Bind("CustomerID,CompanyName,ContactName,ContactTitle,Address,City,Region,PostalCode,Country,Phone,Fax")] Customers customers)
{
    if (id != customers.CustomerID)
    {
        return NotFound();
    }

    if (ModelState.IsValid)
    {
        try
        {
            _context.Update(customers);
            await _context.SaveChangesAsync();
        }
        catch (DbUpdateConcurrencyException)
        {
            if (!CustomersExists(customers.CustomerID))
            {
                return NotFound();
            }
            else
            {
                throw;
            }
        }
        return RedirectToAction(nameof(Index));
    }
    return View(customers);
}

Viewのcshtmlに関してはTagヘルパーによって送信フォーム(<form asp-action="Edit">)があります。

Edit.cshtml

<!-- 送信フォーム-->
<form asp-action="Edit">

    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
    <input type="hidden" asp-for="CustomerID" />
    <div class="form-group">
        <label asp-for="CompanyName" class="control-label"></label>
        <input asp-for="CompanyName" class="form-control" />
        <span asp-validation-for="CompanyName" class="text-danger"></span>
    </div>
    <div class="form-group">
        <label asp-for="ContactName" class="control-label"></label>
        <input asp-for="ContactName" class="form-control" />
        <span asp-validation-for="ContactName" class="text-danger"></span>
    </div>

    <!-- 途中省略-->


    <!-- submit-->
    <div class="form-group">
        <input type="submit" value="Save" class="btn btn-primary" />
    </div>
</form>

実際に生成されるHTML

<!-- 送信フォーム-->
<form action="/Customers/Edit/ALFKI" method="post" novalidate="novalidate">

    <input type="hidden" data-val="true" data-val-length="The field CustomerID must be a string with a maximum length of 5." data-val-length-max="5" id="CustomerID" name="CustomerID" value="ALFKI">
    <div class="form-group">
        <label class="control-label" for="CompanyName">CompanyName</label>
        <input class="form-control" type="text" data-val="true" data-val-length="The field CompanyName must be a string with a maximum length of 40." data-val-length-max="40" data-val-required="The CompanyName field is required." id="CompanyName" maxlength="40" name="CompanyName" value="Alfreds Futterkiste">
        <span class="text-danger field-validation-valid" data-valmsg-for="CompanyName" data-valmsg-replace="true"></span>
    </div>
    <div class="form-group">
        <label class="control-label" for="ContactName">ContactName</label>
        <input class="form-control" type="text" data-val="true" data-val-length="The field ContactName must be a string with a maximum length of 30." data-val-length-max="30" id="ContactName" maxlength="30" name="ContactName" value="Maria Anders">
        <span class="text-danger field-validation-valid" data-valmsg-for="ContactName" data-valmsg-replace="true"></span>
    </div>

    <!-- 途中省略-->

    <div class="form-group">
        <input type="submit" value="Save" class="btn btn-primary">
    </div>

    <input name="__RequestVerificationToken" type="hidden" value="CfDJ8HiC10LQMAlKrZRSsHv9vhLoQ9AJ5DIGKL1iaIzKyzzDmSK0PGVMQBG3OssEmg2dfuiPFuE9b473ufEbvuGA6zJ93yd4ib3m-4zLuM41lUvQ1TcdsyekFknx-y1yoiROKlOW9WI4DevQ-E7K46QZP_w">
</form>

画面

4. Create (新規作成)

Editとほぼ作りが同様です。GetとPostそれぞれに対応したActionがあります。

  • 新規画面表示
  • データ更新
public IActionResult Create()
{
    return View();
}

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create([Bind("CustomerID,CompanyName,ContactName,ContactTitle,Address,City,Region,PostalCode,Country,Phone,Fax")] Customers customers)
{
    if (ModelState.IsValid)
    {
        _context.Add(customers);
        await _context.SaveChangesAsync();
        return RedirectToAction(nameof(Index));
    }
    return View(customers);
}

5. Delete (削除)

DeleteはAction(Get,Post)2つありますが、メソッド名が違います。
何故なら、2つのActionの戻り値とパラメータが同じため、同じ名前のメソッドを命名できません。

  • Delete(string id) : 一覧画面よりIDを受け取り対応したデータの削除確認画面を表示
  • DeleteConfirmed(string id) : 削除確認画面よりIDを受け取り、対象データを削除

DeleteConfirmedにアノテーション
[HttpPost, ActionName("Delete")] がついているため、2つのActionのURLは同じです。

public async Task<IActionResult> Delete(string id)
{
    if (id == null)
    {
        return NotFound();
    }

    var customers = await _context.Customers
        .FirstOrDefaultAsync(m => m.CustomerID == id);
    if (customers == null)
    {
        return NotFound();
    }

    return View(customers);
}

[HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteConfirmed(string id)
{
    var customers = await _context.Customers.FindAsync(id);
    _context.Customers.Remove(customers);
    await _context.SaveChangesAsync();
    return RedirectToAction(nameof(Index));
}

Delete.cshtml

<!-- 削除送信フォーム-->
<form asp-action="Delete">
    <input type="hidden" asp-for="CustomerID" />
    <input type="submit" value="Delete" class="btn btn-danger" /> |
    <a asp-action="Index">Back to List</a>
</form>

画面

まとめ

Scaffoldを用いれば短時間でCRUD操作のWebアプリケーションを実装できますが、
実業務は往々にして複雑のため、Scaffoldを用いることはあまりありません。

しかし、ASP.NET Core MVC の初学者はScaffoldでMVCフレームワークにおけるCRUD操作の概要を大体理解できます。

また、今回はMVCの留意すべき部分(Route設定、Modelのアノテーション、モデルバインディング、etc…)は特段解説していません。

これらの解説に関しては以前作成したASP.NET Core MVC の入門コンテンツを参考にしていただければ幸いです。

ちょっとだけ愚痴

ASP.NET Core MVC とは限らず、1つの技術 or フレームワークは数時間の学習でマスターできる代物ではありません。

特にWeb Formsしか作ったことがない開発者にとって多くの固定概念(手続き型プログラミング、コードビハインドetc…)を打ち破り、時間をかけて学習し、苦しい過程を乗り越える必要あります。

※私もMSDOS、Web Formsしか経験ないときにMVCを触ってかなり戸惑いました。

新技術を習得する際に急ぎは禁物です。
いきなり新技術をプロジェクトに適応しようとしてもうまくいきません。
なぜなら、その技術の基本的な仕組み(何ができる、何ができない)すらわからない状態では何しても無駄です。

SIerのプロジェクトでよくあることですが、学習時間を設けないまま、いきなり新技術を使って期限内にプロジェクト遂行を命じます。

当然、開発者は問題に直面したときにGoogle検索に頼って問題を解決する傾向があります。

  • 検索 → 自分に一番近い事象発見 → コピペ → デバッグ

    上記を繰り返し

確かに、そうしないと期限に間に合いません。

その結果的として、その開発者はプロジェクトを通じて成長があまり得られません。

備考

今回作成したソースコードです。

GitHubリポジトリ

では!!( `ー´)ノ