ASP.NET Core Blazor WebAssembly と Web API と Entity Framework Core で SQL Server のデータを取得したり追加したり更新したり削除したりする

# はじめに

先日、ASP.NET Core Blazor WebAssembly が正式リリースとなりました。ということで、今回は簡単な CRUD アプリケーションをバックエンドからフロントエンドまですべて C# で作ってみたいと思います。バックエンドは基本的に以前書いた記事（[これ](https://qiita.com/tamtamyarn/items/5a755d4d5d59592a82cd)と[これ](https://qiita.com/tamtamyarn/items/71dec4e747a9a523b24f)）をほぼ流用しています。

# 追記

.NET 6 版を公開しました。

https://qiita.com/tamtamyarn/items/876a5cd4b9ec9cdc1044

## やること

こういうテーブルから、

|BookId  |Title |Author |
|:-:|:--|:--|
|1  |たったひとつの冴えたやり方|ジェイムズ・ティプトリー・ジュニア|
|2  |アンドロイドは電気羊の夢を見るか?|フィリップ・K・ディック|
|3  |夏への扉|ロバート・A. ハインライン|
|4  |幼年期の終り|アーサー C クラーク|
|5  |われはロボット|アイザック・アシモフ|

こういう JSON を返す API を作って、

```json
[
	{
		"bookId": 1,
		"title": "たったひとつの冴えたやりかた",
		"author": "ジェイムズ・ティプトリー・ジュニア"
	},
	{
		"bookId": 2,
		"title": "アンドロイドは電気羊の夢を見るか?",
		"author": "フィリップ・K・ディック"
	},
	{
		"bookId": 3,
		"title": "夏への扉",
		"author": "ロバート・A. ハインライン"
	},
	{
		"bookId": 4,
		"title": "幼年期の終り",
		"author": "アーサー C クラーク"
	},
	{
		"bookId": 5,
		"title": "われはロボット",
		"author": "アイザック・アシモフ"
	}
]
```

こういう UI を構築する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/7d7fce88-f337-62d6-001b-6e21d2b7321b.png)

## 環境

- Visual Studio 2019 16.6.1
- .NET Core 3.1
- ASP.NET Core Blazor WebAssembly 3.2.0
- Entity Framework Core 3.1.4

# プロジェクト作成

Visual Studio 2019 を起動して新しいプロジェクトを作成します。プロジェクト名は適当に。「新しい Blazor アプリを作成します」のところで`ASP.NET Core hosted`を有効にすると、ASP.NET Core Web API とセットでプロジェクトが生成されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/f6110935-e0df-800f-21a7-a82be6f3b6c6.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/177e2559-2a89-f2db-abed-a9edfaf332d2.png)
`ASP.NET Core hosted` を選択して作成。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/2d0095c3-97fa-254f-776d-7ecdd3f7b5f7.png)
プロジェクトが３つ生成されます。Client がフロントエンド、Server がバックエンドで、Shared には共通で利用するクラスを配置します。デバッグ実行するとちゃんとフロントエンドからバックエンドが呼べる状態で立ち上がってくれるので非常に楽です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/e7733aea-43b0-0a76-1201-98f7fb4f963d.png)

# バックエンド開発

まずはエンティティクラスを作成します。`BlazorApp.Shared`プロジェクト直下に`Entities`フォルダーを作成し、`Book`クラスを追加。

```csharp:Book.cs
namespace BlazorApp.Shared.Entities
{
    public class Book
    {
        public int BookId { get; set; }
        public string Title { get; set; }
        public string Author { get; set; }
    }
}

```

パッケージマネージャーコンソールから Entity Framework Core のパッケージをインストールします。（Tools は Migration 用に必要）

```powershell
Install-Package -ProjectName BlazorApp.Server -Id Microsoft.EntityFrameworkCore.SqlServer
Install-Package -ProjectName BlazorApp.Server -Id Microsoft.EntityFrameworkCore.Tools
```

次にエンティティクラスとテーブルをマッピングするためのデータベースコンテキストクラスを作成します。`BlazorApp.Server`プロジェクト直下に`Data`フォルダーを作成し、`AppDbContext`クラスを作成。

```csharp:AppDbContext.cs
using BlazorApp.Shared.Entities;
using Microsoft.EntityFrameworkCore;

namespace BlazorApp.Server.Data
{
    public class AppDbContext : DbContext
    {
        public AppDbContext(DbContextOptions options) : base(options)
        {
        }

        public DbSet<Book> Books { get; set; }
    }
}

```

`BlazorApp.Server`プロジェクトの`Controllers`フォルダーに`BooksController`コントローラーを追加します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/86b02731-60f2-37a7-d4b1-c20a1aea5ea9.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/e3eb28ea-ebf8-d96d-e6ba-2d30a177901d.png)

コントローラーにコンストラクターとテーブルの全レコードを返す`List()`メソッドを追加します。

```csharp:BooksController.cs
using System.Collections.Generic;
using BlazorApp.Server.Data;
using BlazorApp.Shared.Entities;
using Microsoft.AspNetCore.Mvc;

namespace BlazorApp.Server.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class BooksController : ControllerBase
    {
        private readonly AppDbContext context;

        public BooksController(AppDbContext context)
        {
            this.context = context;
        }

        [HttpGet]
        public ActionResult<IEnumerable<Book>> List()
        {
            var books = context.Books;
            return books;
        }
    }
}

```

`Startup.cs`にコンテクストクラスをサービスとして登録しておきます。

```csharp:Startup.cs
using BlazorApp.Server.Data;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.Linq;

namespace BlazorApp.Server
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        // This method gets called by the runtime. Use this method to add services to the container.
        // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext<AppDbContext>(options => options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"))); //追加
            services.AddControllersWithViews();
            services.AddRazorPages();
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
                app.UseWebAssemblyDebugging();
            }
            else
            {
                app.UseExceptionHandler("/Error");
            }

            app.UseBlazorFrameworkFiles();
            app.UseStaticFiles();

            app.UseRouting();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapRazorPages();
                endpoints.MapControllers();
                endpoints.MapFallbackToFile("index.html");
            });
        }
    }
}

```

`appsettings.json`に接続文字列を追加。下記では LocalDb に BlazorAppDb という名前のデータベースを指定しています。

```json:appsettings.json
{
  "ConnectionStrings": {
    "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=BlazorAppDb;Trusted_Connection=True;"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*"
}

```

# データ準備

パッケージマネージャーコンソールで以下のコマンドを実行します。（Migrations フォルダーと Migration クラスが作成される）

```powershell
Add-Migration -Name Initial
```

続けて以下のコマンドを実行。（データベースとテーブルが作成される）

```powershell
Update-Database
```

SQL Server Managment Studio か何かで以下のクエリを実行してレコードを追加します。

```sql
INSERT INTO Books (Title, Author) VALUES (N'たったひとつの冴えたやりかた',N'ジェイムズ・ティプトリー・ジュニア');
INSERT INTO Books (Title, Author) VALUES (N'アンドロイドは電気羊の夢を見るか?',N'フィリップ・K・ディック');
INSERT INTO Books (Title, Author) VALUES (N'夏への扉',N'ロバート・A. ハインライン');
INSERT INTO Books (Title, Author) VALUES (N'幼年期の終り',N'アーサー C クラーク');
INSERT INTO Books (Title, Author) VALUES (N'われはロボット',N'アイザック・アシモフ');
```

# バックエンド動作確認

とりあえず全件取得用の API ができたので、デバッグ実行して`/api/books`にアクセスして、以下の情報が取得できることを確認しておきます。

```json
[
	{
		"bookId": 1,
		"title": "たったひとつの冴えたやりかた",
		"author": "ジェイムズ・ティプトリー・ジュニア"
	},
	{
		"bookId": 2,
		"title": "アンドロイドは電気羊の夢を見るか?",
		"author": "フィリップ・K・ディック"
	},
	{
		"bookId": 3,
		"title": "夏への扉",
		"author": "ロバート・A. ハインライン"
	},
	{
		"bookId": 4,
		"title": "幼年期の終り",
		"author": "アーサー C クラーク"
	},
	{
		"bookId": 5,
		"title": "われはロボット",
		"author": "アイザック・アシモフ"
	}
]
```

# フロントエンド開発

バックエンドの開発がとりあえず完了したのでフロントエンド開発に移ります。`BlazorApp.Client`プロジェクトの`Pages`フォルダー配下に`BookList`コンポーネントを追加。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/d953fdfb-ede8-ebfb-a019-dc0772fa7655.png)

`BookList`コンポーネントを以下のように編集します。

```csharp:BookList.razor
@page "/booklist"
@using BlazorApp.Shared.Entities
@inject HttpClient Http

@if (books == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>ID</th>
                <th>Title</th>
                <th>Author</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var book in books)
            {
                <tr>
                    <td>@book.BookId</td>
                    <td>@book.Title</td>
                    <td>@book.Author</td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    private Book[] books;

    protected override async Task OnInitializedAsync()
    {
        books = await Http.GetFromJsonAsync<Book[]>("api/Books");
    }
}

```

コンポーネントを開く際に`OnInitializedAsycn()`メソッドが呼ばれるため、そこで先ほど作った API を呼んで、HTML 上のテーブルに書き出すという流れですね。

このままだと動線がないので、`Shared/NavMenu.razor`のリンクに`BookList`へのリンクを追加しておきます。

```html:NavMenu.razor
<div class="top-row pl-4 navbar navbar-dark">
    <a class="navbar-brand" href="">BlazorApp</a>
    <button class="navbar-toggler" @onclick="ToggleNavMenu">
        <span class="navbar-toggler-icon"></span>
    </button>
</div>

<div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
    <ul class="nav flex-column">
        <li class="nav-item px-3">
            <NavLink class="nav-link" href="" Match="NavLinkMatch.All">
                <span class="oi oi-home" aria-hidden="true"></span> Home
            </NavLink>
        </li>
        <li class="nav-item px-3">
            <NavLink class="nav-link" href="counter">
                <span class="oi oi-plus" aria-hidden="true"></span> Counter
            </NavLink>
        </li>
        <li class="nav-item px-3">
            <NavLink class="nav-link" href="fetchdata">
                <span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
            </NavLink>
        </li>
        <!--ここから追加-->
        <li class="nav-item px-3">
            <NavLink class="nav-link" href="booklist">
                <span class="oi oi-book" aria-hidden="true"></span> Book list
            </NavLink>
        </li>
        <!--ここまで追加-->
    </ul>
</div>

@code {
    private bool collapseNavMenu = true;

    private string NavMenuCssClass => collapseNavMenu ? "collapse" : null;

    private void ToggleNavMenu()
    {
        collapseNavMenu = !collapseNavMenu;
    }
}

```

# フロントエンド動作確認

デバッグ実行して`Book list`リンクを開き、以下のように表示されれば OK です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/c6cd13cd-fe79-543b-8dc3-a2719aeb4bdb.png)

# Create（新規作成）

表示ができたので、次は画面上からデータを追加できるようにしていきます。

## バックエンド

`BooksController`に`Create`メソッドを追加します。（結果を返すために`Get`メソッドも追加しています。特に今回は無くてもいいけど、お作法として）

```csharp:BooksController.cs
using System.Collections.Generic;
using System.Linq;
using BlazorApp.Server.Data;
using BlazorApp.Shared.Entities;
using Microsoft.AspNetCore.Mvc;

namespace BlazorApp.Server.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class BooksController : ControllerBase
    {
        private readonly AppDbContext context;

        public BooksController(AppDbContext context)
        {
            this.context = context;
        }

        [HttpGet]
        public ActionResult<IEnumerable<Book>> List()
        {
            var books = context.Books;
            return books;
        }

        // ここから追加
        [HttpGet("{id}")]
        public ActionResult<Book> Get(int id)
        {
            var book = context.Books.SingleOrDefault(b => b.BookId.Equals(id));
            return book;
        }

        [HttpPost]
        public ActionResult<Book> Create(Book book)
        {
            context.Books.Add(book);
            context.SaveChanges();
            return CreatedAtAction(nameof(Get), new { id = book.BookId }, book);
        }
        // ここまで追加
    }
}

```

## フロントエンド

新規作成用のフォームを`BookList`コンポーネントに追加します。

```csharp:BookList.razor
@page "/booklist"
@using BlazorApp.Shared.Entities
@inject HttpClient Http

<label>Title</label>
<input type="text" @bind="title" />
<label>Author</label>
<input type="text" @bind="author" />
<button @onclick="Add">Add</button>

@if (books == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>ID</th>
                <th>Title</th>
                <th>Author</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var book in books)
            {
                <tr>
                    <td>@book.BookId</td>
                    <td>@book.Title</td>
                    <td>@book.Author</td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    private Book[] books;

    private string title;
    private string author;

    protected override async Task OnInitializedAsync()
    {
        books = await Http.GetFromJsonAsync<Book[]>("api/Books");
    }

    private async Task Add()
    {
        var book = new Book() { Title = title, Author = author };
        await Http.PostAsJsonAsync<Book>("api/Books", book);
        title = "";
        author = "";
        books = await Http.GetFromJsonAsync<Book[]>("api/Books");
    }
}

```

テーブルの上に Title と Author を入力するフィールドと、送信するボタンを用意しています。ボタンを押すと Add メソッドが実行されて先ほど作成したバックエンドの API が呼び出されます。

## 動作確認

Title と Author を入力して Add ボタンをクリック。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/219ae0ab-2c19-e4c2-9475-706035bcbf26.png)

追加すると表示も自動的に更新されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/aba709f5-6cce-5aa0-3a22-2dcca72be664.png)

# Delete（削除）

続いて削除です。レコードごとにボタンを用意して、クリックで削除できるようにします。

## バックエンド

`BooksController`に`Delete`メソッドを追加します。

```csharp:BooksController.cs
using System.Collections.Generic;
using System.Linq;
using BlazorApp.Server.Data;
using BlazorApp.Shared.Entities;
using Microsoft.AspNetCore.Mvc;

namespace BlazorApp.Server.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class BooksController : ControllerBase
    {
        private readonly AppDbContext context;

        public BooksController(AppDbContext context)
        {
            this.context = context;
        }

        [HttpGet]
        public ActionResult<IEnumerable<Book>> List()
        {
            var books = context.Books;
            return books;
        }

        [HttpGet("{id}")]
        public ActionResult<Book> Get(int id)
        {
            var book = context.Books.SingleOrDefault(b => b.BookId.Equals(id));
            return book;
        }

        [HttpPost]
        public ActionResult<Book> Create(Book book)
        {
            context.Books.Add(book);
            context.SaveChanges();
            return CreatedAtAction(nameof(Get), new { id = book.BookId }, book);
        }

        // ここから追加
        [HttpDelete("{id}")]
        public IActionResult Delete(int id)
        {
            var book = context.Books.SingleOrDefault(b => b.BookId.Equals(id));
            context.Books.Remove(book);
            context.SaveChanges();

            return NoContent();
        }
        // ここまで追加
    }
}

```

## フロントエンド

削除ボタンを`BookList`コンポーネントに追加する。

```csharp:BookList.razor
@page "/booklist"
@using BlazorApp.Shared.Entities
@inject HttpClient Http

<label>Title</label>
<input type="text" @bind="title" />
<label>Author</label>
<input type="text" @bind="author" />
<button @onclick="Add">Add</button>

@if (books == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>ID</th>
                <th>Title</th>
                <th>Author</th>
                <th>Delete</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var book in books)
            {
                <tr>
                    <td>@book.BookId</td>
                    <td>@book.Title</td>
                    <td>@book.Author</td>
                    <td><button @onclick="() => Delete(book.BookId)">Delete</button></td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    private Book[] books;

    private string title;
    private string author;

    protected override async Task OnInitializedAsync()
    {
        books = await Http.GetFromJsonAsync<Book[]>("api/Books");
    }

    private async Task Add()
    {
        var book = new Book() { Title = title, Author = author };
        await Http.PostAsJsonAsync<Book>("api/Books", book);
        title = "";
        author = "";
        books = await Http.GetFromJsonAsync<Book[]>("api/Books");
    }

    public async Task Delete(int bookId)
    {
        await Http.DeleteAsync($"api/Books/{bookId}");
        books = await Http.GetFromJsonAsync<Book[]>("api/Books");
    }
}

```

テーブルに列を追加して削除ボタンを表示しています。メソッドに引数がある場合はラムダ式を使います。

## 動作確認

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/73024b0f-1f57-6813-7d34-92b21325dc2a.png)
Delete ボタンを押すと、そのレコードが削除されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/1623a7c1-a998-bead-e7df-3462e9dff675.png)

# Update（更新）

最後に更新です。Title と Author を入力して、Add ボタンの代わりに各レコードごとに用意された Update ボタンを押すと、そのレコードが入力した内容で更新されるようにします。

## バックエンド

`BooksController`に`Update`メソッドを追加します。

```csharp:BooksController.cs
using System.Collections.Generic;
using System.Linq;
using BlazorApp.Server.Data;
using BlazorApp.Shared.Entities;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace BlazorApp.Server.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class BooksController : ControllerBase
    {
        private readonly AppDbContext context;

        public BooksController(AppDbContext context)
        {
            this.context = context;
        }

        [HttpGet]
        public ActionResult<IEnumerable<Book>> List()
        {
            var books = context.Books;
            return books;
        }

        [HttpGet("{id}")]
        public ActionResult<Book> Get(int id)
        {
            var book = context.Books.SingleOrDefault(b => b.BookId.Equals(id));
            return book;
        }

        [HttpPost]
        public ActionResult<Book> Create(Book book)
        {
            context.Books.Add(book);
            context.SaveChanges();
            return CreatedAtAction(nameof(Get), new { id = book.BookId }, book);
        }

        [HttpDelete("{id}")]
        public IActionResult Delete(int id)
        {
            var book = context.Books.SingleOrDefault(b => b.BookId.Equals(id));
            context.Books.Remove(book);
            context.SaveChanges();

            return NoContent();
        }

        // ここから追加
        [HttpPut]
        public IActionResult Update(Book book)
        {
            context.Entry(book).State = EntityState.Modified;
            context.SaveChanges();
            return NoContent();
        }
        // ここまで追加
    }
}

```

## フロントエンド

Update ボタンを`BookList`コンポーネントに追加します。

```csharp:BookList.razor
@page "/booklist"
@using BlazorApp.Shared.Entities
@inject HttpClient Http

<label>Title</label>
<input type="text" @bind="title" />
<label>Author</label>
<input type="text" @bind="author" />
<button @onclick="Add">Add</button>

@if (books == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>ID</th>
                <th>Title</th>
                <th>Author</th>
                <th>Update</th>
                <th>Delete</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var book in books)
            {
                <tr>
                    <td>@book.BookId</td>
                    <td>@book.Title</td>
                    <td>@book.Author</td>
                    <td><button @onclick="() => Update(book.BookId)">Update</button></td>
                    <td><button @onclick="() => Delete(book.BookId)">Delete</button></td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    private Book[] books;

    private string title;
    private string author;

    protected override async Task OnInitializedAsync()
    {
        books = await Http.GetFromJsonAsync<Book[]>("api/Books");
    }

    private async Task Add()
    {
        var book = new Book() { Title = title, Author = author };
        await Http.PostAsJsonAsync<Book>("api/Books", book);
        title = "";
        author = "";
        books = await Http.GetFromJsonAsync<Book[]>("api/Books");
    }

    public async Task Delete(int bookId)
    {
        await Http.DeleteAsync($"api/Books/{bookId}");
        books = await Http.GetFromJsonAsync<Book[]>("api/Books");
    }

    public async Task Update(int bookId)
    {
        var book = new Book() { BookId = bookId, Title = title, Author = author };
        await Http.PutAsJsonAsync<Book>("api/Books", book);
        title = "";
        author = "";
        books = await Http.GetFromJsonAsync<Book[]>("api/Books");
    }
}
```

Delete ボタンと同様に、Update ボタン用の列を追加しています。

## 動作確認

Title と Author を入力して Update ボタンを押します。（ここでは最初の行の Update ボタンを押しています）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/1f69e3c2-8c3a-7a21-ae7b-fff16a9ecf3d.png)
内容が更新されれば OK。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/1db634c8-cb25-2fa1-4e19-b7fd1ccb0492.png)

# ソースコード

https://github.com/tamtamyarn/BlazorApp

# 感想

全部 C# で書けるのめっちゃ楽。