はじめに
先日、ASP.NET Core Blazor WebAssembly が正式リリースとなりました。ということで、今回は簡単な CRUD アプリケーションをバックエンドからフロントエンドまですべて C# で作ってみたいと思います。バックエンドは基本的に以前書いた記事(これとこれ)をほぼ流用しています。
追記
.NET 6 版を公開しました。
やること
こういうテーブルから、
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": "アイザック・アシモフ"
}
]
環境
- 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 とセットでプロジェクトが生成されます。
ASP.NET Core hosted
を選択して作成。
プロジェクトが3つ生成されます。Client がフロントエンド、Server がバックエンドで、Shared には共通で利用するクラスを配置します。デバッグ実行するとちゃんとフロントエンドからバックエンドが呼べる状態で立ち上がってくれるので非常に楽です。
バックエンド開発
まずはエンティティクラスを作成します。BlazorApp.Shared
プロジェクト直下にEntities
フォルダーを作成し、Book
クラスを追加。
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
クラスを作成。
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
コントローラーを追加します。
コントローラーにコンストラクターとテーブルの全レコードを返すList()
メソッドを追加します。
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
にコンテクストクラスをサービスとして登録しておきます。
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 という名前のデータベースを指定しています。
{
"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
コンポーネントを追加。
BookList
コンポーネントを以下のように編集します。
@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
へのリンクを追加しておきます。
<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 です。
Create(新規作成)
表示ができたので、次は画面上からデータを追加できるようにしていきます。
バックエンド
BooksController
にCreate
メソッドを追加します。(結果を返すためにGet
メソッドも追加しています。特に今回は無くてもいいけど、お作法として)
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
コンポーネントに追加します。
@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 ボタンをクリック。
Delete(削除)
続いて削除です。レコードごとにボタンを用意して、クリックで削除できるようにします。
バックエンド
BooksController
にDelete
メソッドを追加します。
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
コンポーネントに追加する。
@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");
}
}
テーブルに列を追加して削除ボタンを表示しています。メソッドに引数がある場合はラムダ式を使います。
動作確認
Update(更新)
最後に更新です。Title と Author を入力して、Add ボタンの代わりに各レコードごとに用意された Update ボタンを押すと、そのレコードが入力した内容で更新されるようにします。
バックエンド
BooksController
にUpdate
メソッドを追加します。
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
コンポーネントに追加します。
@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 ボタンを押しています)
内容が更新されれば OK。
ソースコード
感想
全部 C# で書けるのめっちゃ楽。