ASP.NET Core - [Blazor] 6. DataBlazor Web Assembly
WebAssembly용으로 작성된 Blazor의 구현체를 Blazor WebAssembly라고 하는데 WebAssembly는 browser의 내부에서 동작하는 virtual machine이며 고수준 언어는 native성능과 근접하게 실행될 수 있는 저수준 언저 중립 assembler로 compile됩니다. WebAssembly는 또한 JavaScript application에서 가능한 API들의 접근을 제공하며 이것으로 WebAssembly application이 domain개체 model에 접근할 수 있고 단계적 style sheet를 사용하며 비동기 HTTP요청을 시작할 수 있음을 의미합니다.
Blazor WebAssembly라는 이름에서 알 수 있듯이 이 것은 WebAssembly virtual machine에서 동작하는 Blazor의 구현체입니다. Blazor WebAssembly는 server에 의존하지 않고 Blazor application자체가 완전히 browser안에서 실행될 수 있으며 지속적 HTTP연결 없이 Blazor Server와 동일한 모든 기능에 접근할 수 있는 진정한 client-side application이라 할 수 있습니다.
WebAssembly와 Blazor WebAssembly모두 초기단계이며 별다른 제한은 없지만 WebAssembly는 비교적 최신의 browser에서만 지원됩니다. 따라서 구버전의 browser에서 WebAssembly를 사용할 수 없습니다. 또한 Blazor WebAssembly application은 browser에서 제공하는 API들로 제한되므로 WebAssembly application에서 .NET의 모든 기능을 사용할 수 없습니다. 이것은 Angular와 같은 client-side framework와 비교해 Blazor에게 불리한 것은 아니지만 Entity Framework Core와 같은 기능은 browser가 WebAssembly application을 HTTP요청만으로 제한하기 때문에 사용할 수 없습니다.
Blazor WebAssembly의 이러한 제한에도 불구하고 매우 흥미로운 기술이라 할 수 있으며 JavaScript framework없이 C#과 ASP.NET Core를 사용한 진정한 client-side application을 작성할 수 있습니다.
1. Project 준비하기
이전에 사용하던 Project를 계속 사용할 것이지만 몇가지 추가적인 사전 작업이 필요합니다. 우선 DataController.cs이름의 class file을 Controllers folder에 아래와 같이 추가합니다.
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MyBlazorApp.Models;
using System;
namespace MyBlazorApp.Controllers
{
[ApiController]
[Route("/api/Product")]
public class DataController : ControllerBase
{
private BlazorTDBContext context;
public DataController(BlazorTDBContext ctx)
{
context = ctx;
}
[HttpGet]
public IEnumerable<Product> GetAll()
{
IEnumerable<Product> products = context.Product.Include(p => p.ProductCategory).Include(p => p.ProductManufacturer);
return products;
}
[HttpGet("{id}")]
public async Task<Product> GetDetails(int id)
{
Product p = await context.Product.Include(p => p.ProductCategory).Include(p => p.ProductManufacturer).FirstAsync(p => p.ProductId == id);
return p;
}
[HttpPost]
public async Task Save([FromBody] Product p)
{
await context.Product.AddAsync(p);
await context.SaveChangesAsync();
}
[HttpPut]
public async Task Update([FromBody] Product p)
{
context.Update(p);
await context.SaveChangesAsync();
}
[HttpDelete("{id}")]
public async Task Delete(int id)
{
context.Product.Remove(new Product() { ProductId = id });
await context.SaveChangesAsync();
}
[HttpGet("/api/category")]
public IAsyncEnumerable<Category> GetCategories() => context.Category.AsAsyncEnumerable();
[HttpGet("/api/manufacturer")]
public IAsyncEnumerable<Manufacturer> GetManufacturers() => context.Manufacturer.AsAsyncEnumerable();
}
}
위 controller는 Product개체에 대한 생성, 조회, 변경 및 삭제가 가능한 action을 제공합니다. 또한 Category와 Manufacturer개체를 반환하는 action도 추가하였습니다. 실제로는 각각의 data type에 대해 분리된 controller의 생성을 권장하지만 위의 action들은 단지 Product기능에 대한 지원만을 필요로 하므로 단일 controller에서 모든 동작을 결합하였습니다.
2. Blazor WebAssembly 설정
Blazor WebAssembly는 browser에서 실행되도록 Razor component를 compile할 수 있는 별도의 project가 필요합니다. compile 된 component는 web service를 통해 data를 제공할 수 있는 표준 ASP.NET Core server에 의해 browser로 전달될 수 있습니다. Blazor WebAssembly component가 ASP.NET Core server에서 제공되는 data를 쉽게 사용하도록 만들기 위해 서로 공유되는 item을 포함하는 제3의 project도 필요합니다.
이전 project로 부터 기존 class 중 일부를 data model project로 이동시키는 과정을 포함할 것이므로 총 3개의 project에 대한 생성과정을 거칠 것입니다. 물론 이러한 절차 중 일부는 Visual Studio 마법사를 통해 수행할 수도 있지만 필요한 설명의 최소화를 위해 command-line tool을 사용하여 이 과정을 시작하고자 합니다.
(1) 공유 project 생성
우선 시작하기 전에 Visual Studio나 Viaual Studio code를 닫은 후 PowserShell창을 열어 현재 project의 csproj file이 존재하는 folder로 이동하고 아래 명령을 내려 줍니다.
dotnet new classlib -o ../DataModel -f net7.0 dotnet add ../DataModel package System.ComponentModel.Annotations --version 5.0.0 Move-Item -Path @("Models/Product.cs", "Models/Manufacturer.cs", "Models/Category.cs") ../DataModel |
위의 명령은 DataModel이름의 새로운 project를 생성하고 System.ComponentModel.Annotations package(data validation에 사용된 attribute를 포함하는)를 설치하며 data model class를 새로운 project로 이동시킵니다.
(2) Blazor WebAssembly Project 생성
project는 가급적 빈 project를 생성해 application에서 필요한 package와 설정을 추가해 만들어 나갈것을 권장합니다. 위에서 사용된 PowerShell창에서 아래 명령을 내려 Blazor WebAssembly Project를 생성합니다.
dotnet new blazorwasm -o ../BlazorWebAssembly -f net7.0 dotnet add ../BlazorWebAssembly reference ../DataModel |
위 명령은 BlazorWebAssembly이름의 Blazor WebAssembly project를 생성하고 Product, Manufacturer, Category class를 사용할 수 있는 DataModel project에 대한 참조를 추가합니다.
(3) ASP.NET Core Project 준비
계속해서 PowerShell을 통해 아래 명령을 내려 줍니다.
dotnet add reference ../DataModel ../BlazorWebAssembly dotnet add package Microsoft.AspNetCore.Components.WebAssembly.Server --version 7.0.4 |
위 명령은 data model class와 Blazor WebAssembly project의 component를 사용하기 위해 해당 project에 대한 참조를 생성합니다.
(4) Solution 참조 추가
계속해서 PowerShell을 통해 아래 명령을 내려줍니다.
cd.. dotnet sln add ../MyBlazorApp/DataModel ../MyBlazorApp/BlazorWebAssembly |
위 명령은 새로운 project에 대한 참조를 해당 project의 solution file에 추가하도록 합니다.
(5) Project 열기
3개의 project에 대한 모든 설정이 완료되면 Visual Studio를 실행해 project의 sln(solution file) file을 열어 다음과 같이 project가 구성되었는지 확인합니다.
(6) Blazor WebAssembly 설정 완료하기
이제 Blazor WebAssembly project의 content를 client로 전달하기 위해 ASP.NET Core project를 설정해야 하므로 Program.cs file을 열어 아래와 같이 변경합니다.
builder.Services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
options.JsonSerializerOptions.WriteIndented = true;
});
var app = builder.Build();
//app.MapGet("/", () => "Hello World!");
app.UseStaticFiles();
app.MapControllers();
app.MapControllerRoute("controllers", "controllers/{controller=Home}/{action=Index}/{id?}");
app.MapRazorPages();
app.MapBlazorHub();
app.MapFallbackToPage("/_Host");
app.UseBlazorFrameworkFiles("/webassembly");
app.MapFallbackToFile("/webassembly/{*path:nonfile}", "/webassembly/index.html");
app.Run();
위 예제는 /webassembly로의 요청이 Blazor WebAssembly에서 BlazorWebAssembly project의 content를 사용해 처리되도록 하기 위해 ASP.NET Core에서 필요한 pipeline을 설정하는 것입니다. 또한 System.Text.Json에 대한 순환참조 오류를 방지하기 위한 설정도 추가하였습니다.
● Base URL 설정
이제 다음으로 /webassembly URL에 대한 요청에 응답하기 위해 사용될 HTML file을 변경하도록 합니다. 이를 위해 BlazorWebAssembly project의 wwwroot folder에 있는 index.html file을 아래와 같이 변경합니다.
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>BlazorWebAssembly</title>
<base href="/webassembly/" />
base요소의 href attribute에 있는 webssembly 전후에 /문자를 반드시 포함시켜야 합니다. 이렇게 하지 않으면 Blazor WebAssembly는 작동하지 않습니다.
예제에서 base요소는 문서의 모든 상대적 URL이 정의되고 Blazor WebAssembly routing system의 정확한 동작을 위해 필요한 URL을 설정합니다.
● 정적 Web Asset Base Path 설정
Visual Studio의 BlazorWebAssembly project에서 mouse 오른쪽 button을 눌러 Edit Project File을 선택한 뒤 아래 요소를 추가합니다.
<ImplicitUsings>enable</ImplicitUsings>
<StaticWebAssetBasePath>/webassembly/</StaticWebAssetBasePath>
추가한 요소의 이름은 StaticWebAssetBasePath이며 내부에 /로 시작하고 /로 끝나는 /webassembly/ content를 가지고 있습니다.
StaticWebAssetBasePath 속성에 있는 webassembly 전후에 반드시 /문자를 포함시켜야 합니다.
(7) Placeholder Component Test
project를 실행하고 /webassembly로 URL을 요청하면 다음과 같이 BlazorWebAssembly project생성에 사용된 template의 placeholder content를 보게 될 것입니다.
이어서 Counter와 Fetch data를 click하면 서로 다른 content가 표시됨을 알 수 있습니다.
3. Blazor WebAssembly Component 생성
Blazor WebAssembly는 application을 구축할 때 routing system을 통해 연결되고 layout을 통해 공용 content를 표시하는 component에 의존하며 이는 Blazor Server에서의 접근법과 크게 다르지 않습니다. 이제 Blazor WebAssembly로 작동하는 Razor component를 생성하는 방식을 알아보고 이를 통해 이전에 만든 form application을 다시 만들어 볼 것입니다.
(1) Data Model Namespace 가져오기
지금 여기서 생성할 모든 component는 DataModel project의 class를 사용하는데 이를 위해 @using표현식을 각 component에 추가하기보다는 BlazorWebAssembly project의 root folder에 있는 _Imports.razor file에 data model class를 위한 namespace를 추가할 것입니다.
@using BlazorWebAssembly.Shared
@using MyBlazorApp.Models
model class들을 DataModel project로 옮겨놓기는 했지만 예제에서와 같이 MyBlazorApp.Models라는 namespace를 지정하였습니다. 이 것은 옮겨놓은 모든 class file이 MyBlazorApp.Models라고 지정하는 namespace 선언을 가지고 있기 때문이며 비록 file이 다른 곳으로 이동되어도 class에 존재하는 기존의 namepace가 바뀌지 않기 때문에 가능한 것입니다.
(2) Component 생성
이전 예제에서는 Blazor folder에 Razor Component를 정의하여 ASP.NET Core의 다른 부분으로 부터 새로운 content가 분리되도록 하였습니다. 하지만 BlazorWebAssembly project에는 Blazor content만 존재하므로 project template에서 도입된 기본방식에 따를 것이며 Pages와 Shared folder를 사용할 것입니다.
따라서 BlazorWebAssembly project의 Pages folder에 List.razor이름의 Razor Component fle을 아래와 같이 추가합니다.
@page "/forms"
@page "/forms/list"
<h5 class="bg-primary text-white text-center p-2">Products (WebAssembly)</h5>
<table class="table table-sm table-striped table-bordered">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Category</th>
<th>Manufacturer</th>
<th></th>
</tr>
</thead>
<tbody>
@if (Products.Count() == 0)
{
<tr><th colspan="5" class="p-4 text-center">Loading Data...</th></tr>
}
else
{
@foreach (Product p in Products)
{
<tr>
<td>@p.ProductId</td>
<td>@p.ProductName</td>
<td>@p.ProductCategory.CategoryName</td>
<td>@p.ProductManufacturer.ManufacturerName</td>
<td class="text-center">
<NavLink class="btn btn-sm btn-info" href="@GetDetailsUrl(p.ProductId)">
Details
</NavLink>
<NavLink class="btn btn-sm btn-warning" href="@GetEditUrl(p.ProductId)">
Edit
</NavLink>
<button class="btn btn-sm btn-danger" @onclick="@(() => HandleDelete(p))">
Delete
</button>
</td>
</tr>
}
}
</tbody>
</table>
<NavLink class="btn btn-primary" href="forms/create">Create</NavLink>
@code {
[Inject]
public HttpClient? Http { get; set; }
public Product[] Products { get; set; } = Array.Empty<Product>();
protected async override Task OnInitializedAsync() {
await UpdateData();
}
private async Task UpdateData() {
if (Http != null)
Products = await Http.GetFromJsonAsync<Product[]>("/api/product") ?? Array.Empty<Product>();
}
string GetEditUrl(int id) => $"forms/edit/{id}";
string GetDetailsUrl(int id) => $"forms/details/{id}";
public async Task HandleDelete(Product p)
{
if (Http != null)
{
HttpResponseMessage resp = await Http.DeleteAsync($"/api/product/{p.ProductId}");
if (resp.IsSuccessStatusCode)
await UpdateData();
}
}
}
해당 component를 이전 Blazor Server의 것과 비교해 보면 상당부분이 동일하다는 것을 알 수 있습니다. Blazor의 두 유형 모두 같은 Razor 지시자를 사용하고 @onclick attribute와 같은 handler method를 사용하며 c#구분을 위한 같은 core기능을 사용하고 있습니다. C# class로 compile 된 WebAssembly component는 Blazor Server 측의 것과 비슷합니다. 하지만 가장 핵심적인 차이점은 생성된 C# class가 browser에서 실행된다는 점입니다.
● Blazor WebAssembly Component 탐색
위 예제에서 탐색을 위해 사용되는 URL이 앞에 /가 없는채로 아래와 같이 표현됨에 주목하시기 바랍니다.
<NavLink class="btn btn-primary" href="forms/create">Create</NavLink>
application의 최상위 URL은 이전예제에서 base요소를 사용해 지정되었으며 탐색이 root에 상대적으로 수행되는 상대적인 URL을 사용하였습니다. 이 경우 상대적인 'forms/create'URL은 base요소에서 root로 지정된 '/webassembly/'와 결합되므로 탐색에 사용되는 URL은 최종적으로 /webassembly/forms/create가 되는 것입니다. 선행 /문자를 포함하는 것은 /forms/create로 대신 탐색이 이루어질 수 있고 이것은 application의 Blazor WebAssembly부분에서 관리되는 URL을 벗어나게 됩니다. 이러한 변경사항은 단지 URL 탐색에서만 필요한 것이며 @page지시자를 통해 지정된 URL의 경우에는 적용되지 않습니다.
● Blazor WebAssembly Component의 Data가져오기
Blazor WebAssembly의 가장 큰 변화는 Entify Framework Core를 사용할 수 없다는 것입니다. runtime자체야 Entity Framework Core Class를 실행할 수 있겠지만 browser가 WebAssembly application를 HTTP요청으로 제한하므로 SQL을 사용할 수 없는 것입니다. 따라서 data를 가져오기 위해 Blazor WebAssembly application은 web service를 사용해야 하고 때문에 시작하기 전에 예제 project에 API controller를 추가해야 합니다.
Blazor WebAssembly application의 시작의 일부로 component가 표준 의존성 주입기능을 통해 수신할 수 있는 HttpClient class의 service가 생성됩니다. 그리고 List component는 Inject attribute로 적용된 속성을 통해 HttpClient component를 아래와 같이 수신하게 됩니다.
[Inject]
public HttpClient? Http { get; set; }
HttpClient class는 HTTP 요청을 보내기 위해 아래 표에 해당하는 method를 제공하고 있습니다.
GetAsync(url) | 해당 method는 HTTP GET 요청을 보냅니다. |
PostAsync(url, data) | 해당 method는 HTTP POST 요청을 보냅니다. |
PutAsync(url, data) | 해당 method는 HTTP PUT 요청을 보냅니다. |
PatchAync(url, data) | 해당 method는 HTTP PATCH 요청을 보냅니다. |
DeleteAsync(url) | 해당 method는 HTTP DELETE 요청을 보냅니다. |
SendAsync(request) | 해당 method는 HttpRequestMessage개체를 사용해 설정된 HTTP요청을 보냅니다. |
위 표의 method는 Task<HttpResponseMessage>를 반환하는데 이것은 HTTP server로부터 비동기 요청으로 수신된 응답 서술합니다. 아래 표에서는 가장 유용하게 사용될 수 있는 HttpResponseMessage의 속성들을 나타내고 있습니다.
Content | 해당 속성은 server에서 반환된 content를 반환합니다. |
HttpResponseHeaders | 해당 속성은 응답 header를 반환합니다. |
StatusCode | 해당 속성은 응답 상태 code를 반환합니다. |
IsSuccessStatusCode | 해당 속성은 응답 상태 code가 요청이 성공적으로 이루어졌다는 것을 나탸내는 200과 299사이면 true를 반환합니다. |
List component는 사용자가 Delete button을 click 하게 되면 web service로 개체의 삭제를 DeleteAsync method를 통해 요청하게 됩니다.
public async Task HandleDelete(Product p)
{
if (Http != null)
{
HttpResponseMessage resp = await Http.DeleteAsync($"/api/product/{p.ProductId}");
if (resp.IsSuccessStatusCode)
await UpdateData();
}
}
이 method는 오로지 DELETE요청이 성공했는지만을 확인하는 경우와 같이 web service가 다시 보내는 data에 대한 작업이 필요하지 않은 경우 유용하게 사용될 수 있습니다. 여기서 HttpClient service를 사용할 때만 요청 URL에 대한 경로가 지정되었음에 주목하시기 바랍니다. 왜냐하면 web service는 같은 scheme, host 그리고 application의 post를 사용해야만 사용이 가능하기 때문입니다.
web service가 data를 반환하는 동작의 경우 아래 표에 설명된 HttpClient class의 확장 method가 더욱 유용해질 수 있습니다. 이들 method는 data를 JSON으로 직렬화하므로 이것을 server로 보내고 C#개체로 JSON응답을 구문분석할 수 있습니다. 요청이 아무런 결과도 반환하지 않는 경우 generic type인수는 무시됩니다.
GetFromJsonAsync<T>(url) | 해당 method는 HTTP GET요청을 보내고 type T로 응답을 받습니다. |
PostJsonAsync<T>(url, data) | 해당 method는 직렬화된 data값인 T와 함께 HTTP POST요청을 보냅니다. |
PutJsonAsync<T>(url, data) | 해당 method는 직렬화된 data값인 T와 함께 HTTP PUT요청을 보냅니다. |
예제의 List component는 web service로 data를 요청하기 위해 GetJsonAsync<T> method를 사용합니다.
private async Task UpdateData() {
if (Http != null)
Products = await Http.GetFromJsonAsync<Product[]>("/api/products") ?? Array.Empty<Product>();
}
Product[]에 generic type 인수를 설정하는 것은 HttpClient에게 응답을 Product개체의 배열로 읽어 들여야 한다는 것을 말해주는 것입니다.
HttpClient는 어떠한 scope나 생명주기에 관한 문제를 제공하지 않으며 위 표에 설명된 method 중 하나가 호출되는 경우에만 요청을 보내게 됩니다. 그러나 새로운 data를 언제 요청할지에 대해서는 약간의 생각이 필요합니다. 예제에서는 개체가 삭제되고 난 이후 component가 초기화될 때 요청한 data에서 개체를 삭제하는 대신 web service로 재질의를 수행하고 있습니다. 이러한 방법은 다른 사용자에 의해 만들어진 database로 전체 변경사항을 반영하게 될 것이므로 모든 application환경에 적합한 방법이라고는 할 수는 없습니다.
(3) Layout 생성
template는 placeholder content의 탐색기능을 제공하는 layout을 포함한 Blazor WebAssembly project를 생성하는 데 사용됩니다. 하지만 이들 탐색기능은 필요하지 않으므로 우선 새로운 layout을 생성할 것입니다. BlazorWebAssembly project의 Shared folder에 EmptyLayout.razor이름의 Razor Component를 아래와 같이 추가합니다.
@inherits LayoutComponentBase
<div class="m-2">
@Body
</div>
이 새로운 layout은 이전에 했던 것과 마찬가지로 @layout표현식을 사용해 적용해야 하지만 BlazorWebAssembly project의 App.razor file에서 다음과 같이 정의하여 routing설정을 변경함으로써 기본 layout으로서 사용하고자 합니다.
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(EmptyLayout)" />
</Found>
<NotFound>
<LayoutView Layout="@typeof(EmptyLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
(4) CSS Style 정의
template는 Bootstrap CSS framework의 자체 복사본과 Blazor WebAssembly오류와 validation요소 그리고 application의 layout을 설정하는데 필요한 style을 결합하는 추가적인 stylesheet를 생성합니다. 아래 예제는 BlazorWebAssembly project의 wwwroot folder에 있는 index.html file을 변경하여 link요소를 교체하고 error요소에 직접적으로 style을 적용하였습니다. 이 것은 Microsoft layout에 사용되는 style을 삭제하고 해당 project에 Bootstrap CSS stylesheet를 추가하게 됩니다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>BlazorWebAssembly</title>
<base href="/webassembly/" />
<link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
<link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet" />
<!--<link href="css/app.css" rel="stylesheet" />-->
<link rel="icon" type="image/png" href="favicon.png" />
<link href="BlazorWebAssembly.styles.css" rel="stylesheet" />
</head>
<body>
<div id="app">
<svg class="loading-progress">
<circle r="40%" cx="50%" cy="50%" />
<circle r="40%" cx="50%" cy="50%" />
</svg>
<div class="loading-progress-text"></div>
</div>
<div id="blazor-error-ui" class="text-center bg-danger h6 text-white p-2 fixed-top w-100" style="display:none">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="_framework/blazor.webassembly.js"></script>
</body>
</html>
해당 적용 효과를 확인해 보기 위해 project를 실행하고 /webassembly/forms로 URL을 요청합니다. 그러면 다음과 같은 응답을 생성할 것입니다.
Blazor WebAssembly component는 표준 Blazor 생명주기를 따르고 component는 web service를 통해 수신한 data를 표시합니다.
4. Blazor WebAssembly Form Application 마무리
현재는 List component에 표시된 Delete button만이 작동하기 때문에 필요한 component를 추가함으로써 Blazor WebAssembly form application을 완성할 것입니다.
(1) Details Component 생성
BlazorWebAssembly project의 Pages folder에 Details.razor이름의 Razor Component를 아래와 같이 추가합니다.
@page "/forms/details/{id:long}"
<h4 class="bg-info text-center text-white p-2">Details (WebAssembly)</h4>
<div class="form-group">
<label>ID</label>
<input class="form-control" value="@ProductData.ProductId" disabled />
</div>
<div class="form-group">
<label>Name</label>
<input class="form-control" value="@ProductData.ProductName" disabled />
</div>
<div class="form-group">
<label>Price</label>
<input class="form-control" value="@ProductData.ProductPrice" disabled />
</div>
<div class="form-group">
<label>Category</label>
<input class="form-control" value="@ProductData.ProductCategory?.CategoryName" disabled />
</div>
<div class="form-group">
<label>Manufacturer</label>
<input class="form-control" value="@($"{ProductData.ProductManufacturer?.ManufacturerName}, {ProductData.ProductManufacturer?.ManufacturerInc}")" disabled />
</div>
<div class="text-center p-2">
<NavLink class="btn btn-info" href="@EditUrl">Edit</NavLink>
<NavLink class="btn btn-secondary" href="forms">Back</NavLink>
</div>
@code {
[Inject]
public NavigationManager? NavManager { get; set; }
[Inject]
public HttpClient? Http { get; set; }
[Parameter]
public int Id { get; set; }
public Product ProductData { get; set; } = new Product();
protected async override Task OnParametersSetAsync()
{
if (Http != null)
{
ProductData = await Http.GetFromJsonAsync<Product>($"/api/Product/{Id}") ?? new();
}
}
public string EditUrl => $"forms/edit/{Id}";
}
Details component는 List component에서 구축된 pattern에 따라 Blazor Server와 2가지 차이점이 존재합니다. 하나는 data가 HttpClient service를 통해 들어온다는 것이며 다른 하나는 탐색 대상이 상대적 URL을 사용해 표현된다는 것입니다. routing data에서 매개변수를 가져오는 것과 같은 다른 모든 점에 있어서 Blazor WebAssembly는 Blazor Server에서와 같은 방식으로 작동합니다.
(2) Editor Component 생성
forms application을 완성하기 위한 마지막 단계로 BlazorWebAssembly project의 Pages folder에 Editor.razor이름의 Razor Component를 아래와 같이 추가합니다.
@page "/forms/edit/{id:int}"
@page "/forms/create"
<link href="/blazorValidation.css" rel="stylesheet" />
<h4 class="bg-@Theme text-center text-white p-2">@Mode (WebAssembly)</h4>
<EditForm Model="ProductData" OnValidSubmit="HandleValidSubmit">
<DataAnnotationsValidator />
@if (Mode == "Edit") {
<div class="form-group">
<label>ID</label>
<InputNumber class="form-control" @bind-Value="ProductData.ProductId" readonly />
</div>
}
<div class="form-group">
<label>Name</label>
<ValidationMessage For="@(() => ProductData.ProductName)" />
<InputText class="form-control" @bind-Value="ProductData.ProductName" />
</div>
<div class="form-group">
<label>Price</label>
<ValidationMessage For="@(() => ProductData.ProductPrice)" />
<InputNumber class="form-control" @bind-Value="ProductData.ProductPrice" />
</div>
<div class="form-group">
<label>Category</label>
<ValidationMessage For="@(() => ProductData.ProductCategoryId)" />
<select @bind="ProductData.ProductCategoryId" class="form-control">
<option selected disabled value="0">Choose a Category</option>
@foreach (var category in Categories)
{
<option value="@category.Value">@category.Key</option>
}
</select>
</div>
<div class="form-group">
<label>Manufacturer</label>
<ValidationMessage For="@(() => ProductData.ProductManufacturerId)" />
<select @bind="ProductData.ProductManufacturerId" class="form-control">
<option selected disabled value="0">Choose a Manufacturer</option>
@foreach (var manufacturer in Manufacturers)
{
<option value="@manufacturer.Value">@manufacturer.Key</option>
}
</select>
</div>
<div class="text-center p-2">
<button type="submit" class="btn btn-@Theme">Save</button>
<NavLink class="btn btn-secondary" href="forms">Back</NavLink>
</div>
</EditForm>
@code {
[Inject]
public HttpClient? Http { get; set; }
[Inject]
public NavigationManager? NavManager { get; set; }
[Parameter]
public int Id { get; set; }
public Product ProductData { get; set; } = new Product();
public IDictionary<string, int> Categories { get; set; } = new Dictionary<string, int>();
public IDictionary<string, int> Manufacturers { get; set; } = new Dictionary<string, int>();
protected async override Task OnParametersSetAsync()
{
if (Http != null)
{
if (Mode == "Edit")
ProductData = await Http.GetFromJsonAsync<Product>($"/api/product/{Id}") ?? new();
var category = await Http.GetFromJsonAsync<Category[]>("/api/category");
Categories = (category ?? Array.Empty<Category>()).ToDictionary(d => d.CategoryName, d => d.CategoryId);
var manufacturer = await Http.GetFromJsonAsync<Manufacturer[]>("/api/manufacturer");
Manufacturers = (manufacturer ?? Array.Empty<Manufacturer>()).ToDictionary(l => $"{l.ManufacturerName}, {l.ManufacturerInc}", l => l.ManufacturerId);
}
}
public string Theme => Id == 0 ? "primary" : "warning";
public string Mode => Id == 0 ? "Create" : "Edit";
public async Task HandleValidSubmit()
{
if (Http != null)
{
if (Mode == "Create")
await Http.PostAsJsonAsync("/api/product", ProductData);
else
await Http.PutAsJsonAsync("/api/product", ProductData);
NavManager?.NavigateTo("forms");
}
}
}
해당 예제의 component는 Blazor form기능을 사용함과 동시에 data를 읽고 쓰기 위해 web service로의 HTTP요청을 사용합니다. GetFromJsonAsync<T> method는 web service로부터 data를 읽기 위해 사용되며 PostAsJsonAsync와 PutAsJsonAsync method는 사용자가 form을 submit 할 때 POST 또는 PUT요청을 보내기 위해 사용됩니다.
예제에서는 이전에 생성한 어떠한 사용자 정의 select component나 validation component도 사용하지 않았습니다. project사이에서 component를 공유하는 것은 특히 Blazor WebAssembly를 개발이 시작된 후에 도입할 때 다소 어려운 면이 있습니다. 이 것은 향후 release에서 개선될 거라 예상되지만 해당 예제에서는 가급적 쉽게 끝낼 수 있도록 하였습니다. 위 예제는 결과적으로 종전 project와 다르게 select요소가 값이 선택될 때 trigger 되지 않으며 submit button은 자동적으로 disable 되지 않고 category와 manufacturer의 조합에 대한 어떠한 제약도 존재하지 않습니다.
project를 실행하고 /webassembly/forms로 URL을 요청하면 form application에 대한 Blazor WebAssembly version을 볼 수 있을 것입니다. Details button을 click 하면 table에서 선택한 개체에 대한 field들을 볼 수 있을 것이며 Edit button을 click하면 수정가능한 form이 제공되는 것을 볼 수 있을 것입니다. 몇몇 부분을 수정하고 Save button을 click하면 변경사항이 web service로 전송되고 data table에 해당 내용이 표시될 것입니다.