Help us understand the problem. What is going on with this article?

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

はじめに

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

やること

こういうテーブルから、

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

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

[
    {
        "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

環境

  • 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
image.png
ASP.NET Core hosted を選択して作成。
image.png
プロジェクトが3つ生成されます。Client がフロントエンド、Server がバックエンドで、Shared には共通で利用するクラスを配置します。デバッグ実行するとちゃんとフロントエンドからバックエンドが呼べる状態で立ち上がってくれるので非常に楽です。
image.png

バックエンド開発

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

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 用に必要)

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

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

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
image.png

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

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にコンテクストクラスをサービスとして登録しておきます。

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 という名前のデータベースを指定しています。

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 クラスが作成される)

Add-Migration -Name Initial

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

Update-Database

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

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にアクセスして、以下の情報が取得できることを確認しておきます。

[
    {
        "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

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

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へのリンクを追加しておきます。

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

Create(新規作成)

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

バックエンド

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

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コンポーネントに追加します。

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

追加すると表示も自動的に更新されます。
image.png

Delete(削除)

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

バックエンド

BooksControllerDeleteメソッドを追加します。

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コンポーネントに追加する。

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
Delete ボタンを押すと、そのレコードが削除されます。
image.png

Update(更新)

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

バックエンド

BooksControllerUpdateメソッドを追加します。

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コンポーネントに追加します。

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
内容が更新されれば OK。
image.png

ソースコード

https://github.com/tamtamyarn/BlazorApp

感想

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

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした