Blazor

Blazor 핵심 정리

Blazor Workshop 과정 간에 Blazor 기능 핵심 정리

BlazorWorkshopAuthenticationService Worker
  1. get-started 🌐

    1. @page directive
    2. @onclick
    3. [Parameter]
  2. components-and-layout 🌐

    1. @code
    2. @inject
    3. OnInitializedAsync
    4. Layout
    5. Router
    6. NavLink
  3. customize-a-pizza 🌐

    1. @ symbol
    2. @onclick
    3. [Parameter]
    4. @if
    5. Data Binding (manaul, @bind)
    6. @foreach
    7. @for
    8. Component Event
  4. show-order-status 🌐

    1. @if/else
    2. text element
    3. href=""
    4. OnParametersSetAsync
    5. router paramerter
    6. Routing step-by-step
    7. @using
    8. OnParametersSet
    9. async avoid
    10. StateHasChanged
    11. @implements
    12. Dispose
    13. NavigatorManager
  5. refactor-state-management 🌐

    1. Service Register
    2. AppState pattern
    3. EventCallback with AppState
    4. AppState pattern이 제공하는 것
    5. 렌더링과 이벤팅 복습
  6. checkout-with-validation 🌐

    1. Blazor Client-Side Validation
    2. EditForm 사용하기
    3. DataAnnotationsValidator
    4. ValidationSummary
    5. button type="submit" 사용하기
    6. ValidationMessage
    7. 더나은 UX를 위한 Blazor build-in Component InputText
  7. authentication-and-authorization 🌐

    1. Blazor built-in Authentication DI Service: AuthenticationStateProvider
    2. AddApiAuthorization
    3. RemoteAuthenticatorView
    4. CascadingAuthenticationState and Router
    5. AuthorizeView
    6. Request an access token
    7. Authorizing access to specific order details on Server-side
    8. Enforcing login on specific pages on Client-side
    9. Redirect Component with returnUrl parameter
    10. Simple AuthorizeView
    11. Preserving state across the redirection flow
    12. Customizing the logout experience
  8. javascript-interop 🌐

    1. Using JSRuntime
    2. _Import.razor
  9. templated-components 🌐

    1. csproj for RazorClassLib
    2. Writing a templated dialog
    3. @typeparam
    4. Multiple RenderFragment
  10. progressive-web-app 🌐

    1. Adding a service worker
    2. Making your app installable
    3. Sending push notifications
    4. Sending Notification
    5. Displaying notifications
    6. Handling clicks on notifications.
  11. publish-and-deploy 🌐

아래 내용은 blazor-workshop 과정을 진행하면서 기능 위주로 간략하게 정리한 것이다. 각 제목 옆에 해당 세션에 대한 원본 링크를 추가하였다.

get-started 🌐

Get bits installed and build your first Blazor app

@page directive

@page "/counter"

@page directive에 의해 웹브라우저가 /counter 페이지를 요청하였을 때 이 컴포넌트가 페이지에 그려진다.

@onclick

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

버튼을 클릭하였을 때 @onclick에 연결된 IncrementCount 메소드가 실행된다.

[Parameter]

@code {
    private int currentCount = 0;

    [Parameter]
    public int IncrementAmount { get; set; } = 1;

    private void IncrementCount()
    {
        currentCount += IncrementAmount;
    }
}
<Counter IncrementAmount="10" />

Component의 Parameter를 구성하고 싶을 경우 Parameter Attribute로 꾸민 public property를 작성한다.

components-and-layout 🌐

Get started with components, create the app layout

@code

@code {
    List<PizzaSpecial> specials;
}

@code 안의 코드들은 컴포넌트 생성을 위한 클래스에 더해진다.

@inject

@inject HttpClient HttpClient

작성된 컴포넌트에 HttpClient 타입 인스턴스를 프로퍼티로 주입한다. 프로퍼티 생성은 별도의 Dependency Injection을 사용해주어야한다.

public static async Task Main(string[] args)
{
    var builder = WebAssemblyHostBuilder.CreateDefault(args);
    builder.RootComponents.Add<App>("#app");
    builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
    await builder.Build().RunAsync();
}

OnInitializedAsync

@code 안의 OnInitializedAsync 메소드를 오버라이드하여 컴포넌트 라이프사이클 코드를 작성할 수 있다.

@code {
    List<PizzaSpecial> specials;

    protected override async Task OnInitializedAsync()
    {
        specials = await HttpClient.GetFromJsonAsync<List<PizzaSpecial>>("specials");
    }
}

Layout

레이아웃도 블레이저 컴포넌트이며, Body 프로퍼티를 가지는 LayoutComponentBase를 상속(@inherits)한다.

@inherits LayoutComponentBase

<div class="content">
    @Body
</div>

Router

기본 템플릿에서는 App.razor에서 Router 컴포넌트가 최상위로 감싸고 있다.

RouterView 컴포넌트의 DefaultLayout Parameter가 MainLayout으로 되어있기 때문에 별도로 layout을 적용하지 않으면 기본 레이아웃이 적용된다.

다른 레이아웃을 적용하려면 페이지 컴포넌트에 @layout SomeOtherLayout처럼 layout directive를 사용한다.

<Router AppAssembly="typeof(Program).Assembly" Context="routeData">
    <Found>
        <RouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)" />
    </Found>
    <NotFound>
        <LayoutView Layout="typeof(MainLayout)">
            <div class="main">Sorry, there's nothing at this address.</div>
        </LayoutView>
    </NotFound>
</Router>

Blazor에 의해 제공되는 컴포넌트로, anchor 태그와 다를 것은 없으나 현재 URL과 일치하는 지에 따라 active 클래스가 활성화 된다.

NavLinkMatch.All은 전체 URL이 일치할 때에만 active 되는 것을 의미한다.

<NavLink href="" class="nav-tab" Match="NavLinkMatch.All">
        <img src="img/pizza-slice.svg" />
        <div>Get Pizza</div>
</NavLink>

customize-a-pizza 🌐

Add customized pizzas to orders

@ symbol

@ 심볼은 razor 파일에서 C# 코드의 시작을 의미한다. 필요할 경우 C# 코드를 괄호로 감쌀 수도 있다.

@onclick

연결할 DOM UI event에 C# delegate를 지정한다. Delegate는 인자를 받아도 되지만, 받지 않아도 된다.

<li @onclick="@(() => ShowConfigurePizzaDialog(special))" style="background-image: url('@special.ImageUrl')">

[Parameter]

컴포넌트 파라미터는 [Parameter] 프로퍼티가 입혀져야한다.

반드시 setter가 존재해야하며, 프레임워크에 의해 get, set이 사용되므로 public으로 선언되어야한다.

또한 프레임워크에 의해 렌더링 프로세스 과정에서만 set 되어야하므로 해당 프로퍼티를 비정상적으로 설정하지 말자.

@code {
    [Parameter] public Pizza Pizza { get; set; }
}

@if

razor에서 해당 조건을 만족시킬 때만 렌더링하고 싶다면 @if를 이용한다.

@if (showingConfigureDialog)
{
    <ConfigurePizzaDialog Pizza="configuringPizza" />
}

Data Binding (manaul, @bind)

직접 two-way binding을 구현하고 싶다면, @onchange에 값을 변경시키는 delegate를 적용한다.

<input
    type="range"
    min="@Pizza.MinimumSize"
    max="@Pizza.MaximumSize"
    step="1"
    value="@Pizza.Size"
    @onchange="@((ChangeEventArgs e) => Pizza.Size = int.Parse((string) e.Value))" />

하지만 @bind를 이용하여 자동으로 two-way binding을 구현하는 편이 좋다.

어떤 이벤트를 기점으로 바인딩 값이 최신화되기를 바라면, @bind:event에 이벤트 이름을 써놓으면 된다.

<input type="range" min="@Pizza.MinimumSize" max="@Pizza.MaximumSize" step="1" @bind="Pizza.Size" @bind:event="oninput" />

@foreach

@foreach (var topping in Pizza.Toppings)
    {
        <div class="topping">
            @topping.Topping.Name
            <span class="topping-price">@topping.Topping.GetFormattedPrice()</span>
            <button type="button" class="delete-topping" @onclick="@(() => RemoveTopping(topping.Topping))">x</button>
        </div>
    }

@for

@for (var i = 0; i < people.Length; i++)
{
    var person = people[i];
    <text>Name: @person.Name</text>
}

Component Event

상하위 컴포넌트 간의 통신은 Component Event를 정의함으로써 가능하다.

Component Event는 하위 컴포넌트에 선언되는 Callback Parameter로 상위 컴포넌트에서 구독한다.

하위 컴포넌트의 Callback Parameter 설정

[Parameter] public EventCallback OnCancel { get; set; }
[Parameter] public EventCallback OnConfirm { get; set; }

하위 컴포넌트의 Callback Parameter 사용

<div class="dialog-buttons">
    <button class="btn btn-secondary mr-auto" @onclick="OnCancel">Cancel</button>
    <span class="mr-center">
        Price: <span class="price">@(Pizza.GetFormattedTotalPrice())</span>
    </span>
    <button class="btn btn-success ml-auto" @onclick="OnConfirm">Order ></button>
</div>

상위 컴포넌트의 Callback Parameter 설정

<ConfigurePizzaDialog Pizza="configuringPizza" OnCancel="CancelConfigurePizzaDialog" />

일반 델리게이트 유형을 사용한 경우 컴포넌트가 렌더링되거나 업데이트 되지 않을 수 있는데, EventCallback 타입은 이벤트 핸들러가 컴포넌트에서 정상적으로 작동하기를 보장하기 위한 특별한 타입이다.

show-order-status 🌐

Show order status

@if/else

@if/else를 이용하여 조건부 렌더링을 작성할 수 있다.

<div class="main">
    @if (ordersWithStatus == null)
    {
        <text>Loading...</text>
    }
    else if (!ordersWithStatus.Any())
    {
        <h2>No orders placed</h2>
        <a class="btn btn-success" href="">Order some pizza</a>
    }
    else
    {
        <text>TODO: show orders</text>
    }
</div>

text element

<text>는 HTML element가 아니며, Blazor Component 또한 아니다. 컴파일 후에는 존재하지도 않는다.

razor에서 text는 C# 코드가 아닌 마크업 문자열로 다루기 원할 때 작성한다. 필요할 경우에 한해서만 쓰이기 때문에 찾아 쓸 필요는 없다.

<text>TODO: show orders</text>

href=""

웹브라우저는 기본으로 non-slash-prefixed URL에 대해서 <base href="/">를 적용한다.

OnParametersSetAsync

OnParametersSetAsync 메소드는 컴포넌트의 파라미터나 프로퍼티가 변경될 때 발생한다.

router paramerter

@page "/myorders/{orderId:int}"

string을 받을 경우에는 @page directive에서 {parameterName}와 같이 사용하면 된다. 그리고 컴포넌트의 파라미터로 설정해주면 된다. 이때 파라미터는 대소문자와 관계없다.

하지만 string이 아닌 다른 타입으로 받을 경우 {parameterName:int}와 같이 파라미터 이름 뒤에 :타입을 붙여주면된다. bool, datetime, guid 등의 타입이 지원된다.

Routing step-by-step

  1. App.razor는 Router를 가지고 있다. Router는 브라우저의 클라이언트-사이드 navigation API와 인터렉션한다. navigation event handler를 등록하여 유저가 link를 클릭 할 때마다 알림을 받는다.
  2. 유저가 link를 클릭하였을 때, Router는 destination URL이 현재 SPA 내에 위치하는 지를 확인한다. 위치하지 않는다면, 기존의 full-page navigation이 동작하고, 위치할 경우 Router가 처리한다.
  3. Router는 @page URL 패턴에 맞는 컴포넌트를 찾는다.
    • 찾는다면, 그걸로 화면을 그린다
    • 맞는게 없다면, 서버에 위치해있다고 생각하고 full-page load한다
    • full-page load 했음에도 이전과 같은 client-side Blazor를 그린다면, 서버 클라이언트 모두 맞는 컴포넌트가 없다고 판단하여 NotFound를 그리게 된다.

@using

기존 .cs 에서 사용한 using과 같다.

OnParametersSet

컴포넌트 라이프사이클 중 하나로, 컴포넌트가 첫번째 초기화되었을 때와 파라미터들이 변경되는 매번 동작한다. Router Parameter를 변경하였을 때에도 파라미터가 변경되어 동작한다.

async avoid

async avoid를 쓸 경우 예외가 상위로 전달되지 않는다. (이미 상위 caller는 종료했기 때문) 그때문에 trycatch를 통해 Exception을 잘 처리하는 것이 중요하다.

StateHasChanged

Blazor 컴포넌트에 강제로 상태가 변화하였음을 알리는 방법이다. 이 메소드가 호출되면 컴포넌트를 다시 렌더링한다.

@implements

해당 디렉티브를 가진 컴포넌트가 인터페이스를 구현함을 의미한다.

@implements IDisposable

Dispose

IDisoposable이 구현된 경우, 컴포넌트가 UI에서 사라지는 시점에 Framework는 Dispose를 자동으로 호출한다.

프로그래밍 방식으로 Navigation 할 경우 NavigataionManager를 활용한다.

@inject NavigationManager NavigationManager
async Task PlaceOrder()
{
    var response = await HttpClient.PostAsJsonAsync("orders", order);
    var newOrderId = await response.Content.ReadFromJsonAsync<int>();
    order = new Order();
    NavigationManager.NavigateTo($"myorders/{newOrderId}");
}

refactor-state-management 🌐

Refactor state management

Service Register

Blazor의 서비스는 program.csMain 함수에서 이루어진다.

public static async Task Main(string[] args)
{
    var builder = WebAssemblyHostBuilder.CreateDefault(args);
    builder.RootComponents.Add<App>("#app");

    builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
    builder.Services.AddScoped<OrderState>();

    await builder.Build().RunAsync();
}

Blazor에서 Singleton 서비스 라이프사이클은 all users를 의미하며, 이는 서버사이드 컴포넌트와 대칭을 이루기 위함이다. Scoped는 현재 unit-of-work를 의미한다.

AppState pattern

DI를 이용하여 오브젝트 안에 상태를 넣은 후 관계된 컴포넌트끼리 공유하는 패턴이다. 이는 주입되는 오브젝트가 컴포넌트의 라이프사이클을 뛰어넘어 존재하기 때문에 가능하며, presentation(컴포넌트)과 business logic을 강력하게 분리할 수 있다는 장점을 제공한다.

EventCallback with AppState

EventCallback은 Event Handler가 선언된 컴포넌트에 Event Notification을 보내고 컴포넌트를 렌더링한다.

만약 Event Handler가 컴포넌트가 아닌 곳에서 선언되었다면(일반 cs 파일 등), Event Handler와 연결된 컴포넌트로 대체된다. DI로 주입된 오브젝트에서 DI를 주입받은 컴포넌트로 대체되어, 해당 컴포넌트에 Event Notification을 보내고 렌더링하는 것이다.

AppState pattern이 제공하는 것

  • 컴포넌트들 밖에서 공유 상태를 제공함
  • 컴포넌트는 상태를 바꾸기 위해 AppState의 메소드를 호출
  • EventCallback은 change notification을 전달(dispatch)하는 것을 처리

렌더링과 이벤팅 복습

  • 컴포넌트는 파라미터가 바뀌거나 이벤트를 받았을 때 재렌더링됨
  • 이벤트의 전달은 이벤트 핸들러 delegate target에 의존함
  • EventCallback을 사용함으로써 가장 유연하고 친숙하게 이벤트를 전달할 수 있음

checkout-with-validation 🌐

Checkout with validation

Blazor Client-Side Validation

Blazor 클라이언트측 검증은 EditContext를 토대로 이루어진다.

EditContext는 편집 과정 상태를 추적하며, 따라서 어떤 필드가 수정되었는지, 어떤 데이터가 입력되었는지, 필드들이 valid한지 안한지를 파악한다. 다양한 built-in 컴포넌트가 EditContext에 의해 상태가 읽혀지고(validation 메시지를 보여주기), 쓰여지는 것(유저에 의해 작성된 데이터 채우기) 모두 관리된다.

EditForm 사용하기

EditForm은 HTML form tag로 렌더링된다. 이와 동시에 EditContext를 통해 form 내부의 변화를 추적한다.

한 컴포넌트 안에 여러 EditForm을 쓰는 것은 가능하나, 겹쳐 쓸 수는 없다.(이는 HTML에서의 form에서도 마찬가지이다.)

Model 파라미터를 설정함으로써 EditContext에게 form이 submit될 때 validate 해야 할 개체를 알려준다.

DataAnnotationsValidator

DataAnnotationsValidatorEditContext의 이벤트에 연결되어 DataAnnotation 규칙을 실행시킨다.

<DataAnnotationsValidator />

ValidationSummary

ValidationSummary는 ul 엘리먼트 안에 EditContext부터 받은 validation 메시지를 출력한다

<ValidationSummary />

button type="submit" 사용하기

button 엘레먼트에 별도 이벤트를 설정하지 말고 EditFormOnValidSubmit으로 이벤트 핸들러를 옮기자.

OnValidSubmit는 form 안의 데이터가 valid함을 확인한 후에 작동한다.

<EditForm Model="OrderState.Order.DeliveryAddress" OnValidSubmit="PlaceOrder">

ValidationMessage

각 데이터 부분별로 validation 메시지를 출력하기 위해서 ValidationMessage 컴포넌트를 사용할 수 있다.

<div class="form-field">
    <label>Name:</label>
    <div>
        <input @bind="Address.Name" />
        <ValidationMessage For="@(() => Address.Name)" />
    </div>
</div>

For 파라미터에는 람다식을 사용한다. 이는 프로퍼티의 실제 value를 평가하지 않고 프로퍼티의 메타데이터를 불러오기 위함이다.

별도의 ErrorMessage를 작성하려면 Required DataAnnotation에 ErrorMessage 파라미터에 추가한다.

더나은 UX를 위한 Blazor build-in Component InputText

Blazor built-in InputText 컴포넌트를 사용하면 value가 변경되는 순간에 EditContext에 즉시 알려주므로 validation 상태가 최신화된다. 또한 EditContext로부터 validity 정보를 받음으로 valid 정보를 유저가 수정함에 바로 보여줄 수 있다.

이 외에도 InputCheckbox, InputDate, InputSelect 등이 있다.

authentication-and-authorization 🌐

Authenticate users and authorize access to order status

Blazor built-in Authentication DI Service: AuthenticationStateProvider

Blazor는 AuthenticationStateProvider와 OpenID 기반의 데이터를 핸들링하는 컴포넌트와 서비스들을 제공한다.

Microsoft.AspNetCore.Components.WebAssembly.Authentication 패키지에 포함되어있다.

아래와 같은 기능을 제공한다.

  • 유저가 로그인하려고 하거나 보호된 리소스에 접근할 때, 로그인 페이지(/authentication/login)로 디다이렉트시킴.
  • 로그인 페이지에서 앱은 구성된 ID 제공자의 권한 부여 끝점으로 리디렉션하기 위해 준비합니다. 끝점은 사용자가 인증되었는지 여부와 응답하여 하나 이상의 토큰을 발급하는 역할을 합니다. 앱에서 인증 응답을 받기 위한 로그인 콜백을 제공합니다.
    • 사용자가 인증되지 않은 경우 먼저 기본 인증 시스템(일반적으로 ASP.NET Core Identity)으로 리디렉션됩니다.
    • 사용자가 인증되면 인증 끝점이 적절한 토큰을 생성하고 브라우저를 다시 로그인 콜백 끝점(/authentication/login-callback)으로 리디렉션합니다.
  • Blazor Web Assembly 앱이 로그인 콜백 끝점(/authentication/login-callback)을 로드하면 인증 응답이 처리됩니다.
    • 인증 프로세스가 성공적으로 완료되면 사용자가 인증되고 선택적으로 사용자가 요청한 원래 보호된 URL로 다시 전송됩니다.
    • 어떤 이유로든 인증 프로세스가 실패하면 로그인 실패 페이지(/authentication/login-failed)로 전송되고 오류가 표시됩니다.

AddApiAuthorization

서비스를 활성화하려면 AddApiAuthorizationProgram.cs에서 호출한다.

public static async Task Main(string[] args)
{
    var builder = WebAssemblyHostBuilder.CreateDefault(args);
    builder.RootComponents.Add<App>("#app");

    builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
    builder.Services.AddScoped<OrderState>();

    // Add auth services
    builder.Services.AddApiAuthorization();

    await builder.Build().RunAsync();
}

RemoteAuthenticatorView

인증 흐름을 조정하기 위해서 Authentication 컴포넌트를 Pages에 추가한다. 위에서 말했듯이 기본 경로를 사용한다.

@page "/authentication/{action}"

<RemoteAuthenticatorView Action="@Action" />

@code{
    [Parameter] public string Action { get; set; }
}

RemoteAuthenticatorView는 다양한 모든 인증 흐름(registerr, login, profile, logout)을 처리한다. Action 파라미터는 {action} route 파라미터를 활용한다. 자세한 내용은 앱 경로 사용자 지정를 참고한다.

CascadingAuthenticationState and Router

앱에 인증 상태 정보를 공유하기 위해서 CascadingAuthenticationState 컴포넌트로 App.razorRouter 컴포넌트를 둘러싼다.

<CascadingAuthenticationState>
    <Router AppAssembly="typeof(Program).Assembly" Context="routeData">
        ...
    </Router>
</CascadingAuthenticationState>

이를 통해 cascading 파라미터를 모든 자손 컴포넌튼에 제공한다. cascading 파라미터는 수준 제한 없이 모든 자손에 전달되는 파라미터이다.

AuthorizeView

@inject NavigationManager Navigation
@inject SignOutSessionStateManager SignOutManager

<div class="user-info">
    <AuthorizeView>
        <Authorizing>
            <text>...</text>
        </Authorizing>
        <Authorized>
            <img src="img/user.svg" />
            <div>
                <a href="authentication/profile" class="username">@context.User.Identity.Name</a>
                <button class="btn btn-link sign-out" @onclick="BeginSignOut">Sign out</button>
            </div>
        </Authorized>
        <NotAuthorized>
            <a class="sign-in" href="authentication/register">Register</a>
            <a class="sign-in" href="authentication/login">Log in</a>
        </NotAuthorized>
    </AuthorizeView>
</div>

@code{
    async Task BeginSignOut()
    {
        await SignOutManager.SetSignOutState();
        Navigation.NavigateTo("authentication/logout");
    }
}

AuthorizeView는 유저의 허가(AuthZ) 상황에 맞추어 다른 콘텐츠를 보여주는 built-in 컴포넌트이다. 위 코드는 허가(AuthZ) 조건을 별도로 설정하지 않았으므로 인증(AuthN)되었다면 Authorized로, 아닐 경우 NotAuthorized로 설정된다.

필요한 곳 어디서든지 AuthorizeView를 쓸 수 있다.

등록, 로그인 및 사용자 프로파일 확인 링크는 인증 구성 요소로 이동하는 일반 링크입니다. 로그아웃 링크는 버튼이며 위조된 요청이 사용자를 로그아웃하지 못하도록 하는 추가적인 논리가 있습니다. 버튼을 사용하면 사용자 작업에 의해서만 로그아웃이 트리거될 수 있으며, SignOutSession StateManager 서비스는 전체 로그아웃 흐름의 상태를 유지하여 전체 흐름이 사용자 작업으로 시작되도록 합니다.

Request an access token

액세스 토큰을 받아 outbound 리퀘스트에 적용하려면 HttpClientBaseAddressAuthorizationMessageHandler를 연결해 사용한다. 이 메시지 핸들러는 built-in IAccessTokenProvider 서비스를 사용해 액세스 토큰을 받아오고 매 리퀘스트의 표준 Authorization 헤더에 입력한다. 만약 액세스 토큰이 없을 경우 유저를 로그인 페이지로 리다이렉트하여 새로운 액세스 토큰을 받게끔 하는 AccessTokenNotAvailableException 에러를 발생시킨다.

BaseAddressAuthorizationMessageHandler, AccessTokenNotAvailableException 모두 Microsoft.AspNetCore.Components.WebAssembly.Authentication 네임스페이스에 속해있다.

namespace BlazingPizza.Client
{
    public class OrdersClient
    {
        private readonly HttpClient httpClient;

        public OrdersClient(HttpClient httpClient)
        {
            this.httpClient = httpClient;
        }

        public async Task<IEnumerable<OrderWithStatus>> GetOrders() =>
            await httpClient.GetFromJsonAsync<IEnumerable<OrderWithStatus>>("orders");


        public async Task<OrderWithStatus> GetOrder(int orderId) =>
            await httpClient.GetFromJsonAsync<OrderWithStatus>($"orders/{orderId}");


        public async Task<int> PlaceOrder(Order order)
        {
            var response = await httpClient.PostAsJsonAsync("orders", order);
            response.EnsureSuccessStatusCode();
            var orderId = await response.Content.ReadFromJsonAsync<int>();
            return orderId;
        }
    }
}
builder.Services.AddHttpClient<OrdersClient>(client => client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress))
    .AddHttpMessageHandler<BaseAddressAuthorizationMessageHandler>();

Authorizing access to specific order details on Server-side

namespace BlazingPizza.Server
{
    [Route("orders")]
    [ApiController]
    [Authorize]
    public class OrdersController : Controller
    {
        ...
        [HttpPost]
        public async Task<ActionResult<int>> PlaceOrder(Order order)
        {
            ...
            order.UserId = GetUserId();
            ...
        }
        ...
        private string GetUserId()
        {
            return HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
        }
    }
}

서버 측의 Controller의 HttpContextUser로부터 값을 가져온다.

ASP.NET Core Controller는 매 request마다 새로 만들어진다. Spring MVC의 Controller가 Singleton Lifecycle인 것과는 차이가 있다.

Enforcing login on specific pages on Client-side

App.razorRouterViewAuthorizeRouterView로 바꾼 후 Blazor 페이지 컴포넌트에 AuthorizeAttribute를 추가한다.

@attribute [Authorize]
<CascadingAuthenticationState>
    <Router AppAssembly="typeof(Program).Assembly" Context="routeData">
        <Found>
            <AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)">
                <NotAuthorized>
                    <p>You are not authorized to access this resource.</p>
                </NotAuthorized>
                <Authorizing>
                    <div class="main">Please wait...</div>
                </Authorizing>
            </AuthorizeRouteView>
        </Found>
        <NotFound>
            <LayoutView Layout="typeof(MainLayout)">
                <div class="main">Sorry, there's nothing at this address.</div>
            </LayoutView>
        </NotFound>
    </Router>
</CascadingAuthenticationState>

Redirect Component with returnUrl parameter

@inject NavigationManager Navigation
@code {
    protected override void OnInitialized()
    {
        Navigation.NavigateTo($"authentication/login?returnUrl={Navigation.Uri}");
    }
}
https://localhost:5001/Identity/Account/Login?ReturnUrl=%2Fconnect%2Fauthorize%2Fcallback%3Fclient_id%3DBlazingPizza.Client%26redirect_uri%3Dhttps%253A%252F%252Flocalhost%253A5001%252Fauthentication%252Flogin-callback%26response_type%3Dcode%26scope%3DBlazingPizza.ServerAPI%2520openid%2520profile%26state%3D4f009a76ebdf482c894a67a834a19656%26code_challenge%3DXuCn2RXO8cKq2rXr4jwZyqZGFpJ_VrLEhRlk_9wGbd0%26code_challenge_method%3DS256%26response_mode%3Dquery

returnUrl를 이용하면, 리다이렉트된 로그인 페이지에서 로그인했을 때 returnUrl로 이동한다.

Simple AuthorizeView

AuthorizeView 내에 별도 Authorizing, Authorized, NotAuthorized 없이 내부를 작성하면 Authorized 되었을 때만 보인다.

<AuthorizeView>
    <NavLink href="myorders" class="nav-tab">
        <img src="img/bike.svg" />
        <div>My Orders</div>
    </NavLink>
</AuthorizeView>

Preserving state across the redirection flow

persisted하려는 데이터를 정해, RemoteAuthenticationState를 상속한 클래스에 추가한다. RemoteAuthenticationState는 returnURL과 같은 리다이렉트로부터 상태를 보존하기 위해 인증 시스템에서 사용된다.

Microsoft.AspNetCore.Components.WebAssembly.Authentication 네임스페이스에 속해있다.

public class PizzaAuthenticationState : RemoteAuthenticationState
{
    public Order Order { get; set; }
}

기본 RemoteAuthenticationState 서비스를 대체하기 위해 Program.cs에서 해당 클래스를 서비스로 추가한다. AddApiAuthorization은 Auth 기능 활성화를 위해 맨처음 추가했던 서비스이다.

builder.Services.AddApiAuthorization<PizzaAuthenticationState>();

현재 상태를 persist하기 위한 로직 코드를 추가한다.

이를 위해 @page "/authentication/{action}"에 있는 Authenticator 페이지 컴포넌트가 RemoteAuthenticatorView가 아닌 RemoteAuthenticatorViewCore를 사용하도록 변경한다.

@page "/authentication/{action}"
@inject OrderState OrderState
@inject NavigationManager NavigationManager

<RemoteAuthenticatorViewCore
    TAuthenticationState="PizzaAuthenticationState"
    AuthenticationState="RemoteAuthenticationState"
    OnLogInSucceeded="RestorePizza"
    Action="@Action" />

@code{
    [Parameter] public string Action { get; set; }

    public PizzaAuthenticationState RemoteAuthenticationState { get; set; } = new PizzaAuthenticationState();

    protected override void OnInitialized()
    {
        if (RemoteAuthenticationActions.IsAction(RemoteAuthenticationActions.LogIn, Action))
        {
            // Preserve the current order so that we don't loose it
            RemoteAuthenticationState.Order = OrderState.Order;
        }
    }

    private void RestorePizza(PizzaAuthenticationState pizzaState)
    {
        if (pizzaState.Order != null)
        {
            OrderState.ReplaceOrder(pizzaState.Order);
        }
    }
}

RemoteAuthenticatorViewCore의 각 파라미터는 다음과 같다

  • TAuthenticationState: `AuthenticationState의 타입
  • AuthenticationState: 인증 작업 중에 유지 되는 인스턴스
  • OnLogInSucceeded: 로그인 작업이 성공할 때 저장된 인증 상태를 사용하여 호출되는 이벤트 콜백
  • Action: RemoteAuthenticationActions 구성 요소가 처리해야 하는 동작

미인증 상태에서 로그인 화면으로 들어가면 Local Storage에 Authentication State가 존재하는 것을 알 수 있다.

Customizing the logout experience

Auth 서비스 추가 중에 옵션을 설정함으로써 로그아웃 되었을 때 리다이렉트될 페이지를 지정할 수 있다.

builder.Services.AddApiAuthorization<PizzaAuthenticationState>(options =>
{
    options.AuthenticationPaths.LogOutSucceededPath = "";
});

javascript-interop 🌐

Track order status on a real time map

Using JSRuntime

@using Microsoft.JSInterop
@inject IJSRuntime JSRuntime

<div id="@elementId" style="height: 100%; width: 100%;"></div>

@code {
    string elementId = $"map-{Guid.NewGuid().ToString("D")}";

    [Parameter] double Zoom { get; set; }
    [Parameter] List<Marker> Markers { get; set; }

    protected async override Task OnAfterRenderAsync(bool firstRender)
    {
        await JSRuntime.InvokeVoidAsync(
            "deliveryMap.showOrUpdate",
            elementId,
            Markers);
    }
}

IJSRuntime 인스턴스를 DI되어 InvokeVoidAsync 혹은 InvokeAsync<TResult> 메소드를 사용한다. 이 메소드들의 첫번째 인자는 root window 오브젝트로부터의 함수 위치이며(콘솔창에서 접근할 때의 identity), 나머지 파라미터는 JSON serialized되어 해당 함수에 전달된다.

_Import.razor

_Import.razor에서 전역 using을 해주고 있으므로 다른 곳에서 다시 @using directive를 사용하지 않아도 된다.

templated-components 🌐

Create and use components with template parameters

csproj for RazorClassLib

<Project Sdk="Microsoft.NET.Sdk.Razor">

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components" Version="5.0.7" />
    <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="5.0.7" />
  </ItemGroup>

</Project>

Writing a templated dialog

LayoutComponentBase가 Body 파라미터로 RenderFragement타입을 받는데, 이는 런타임이 별도로 핸들링하는 델리게이트 타입이다. 이 타입은 어떠한 컴포넌트에서도 파라미터로 사용할 수 있다.

@if (Show)
{
    <div class="dialog-container">
        <div class="dialog">
            @ChildContent
        </div>
    </div>
}

@code {
    [Parameter] public RenderFragment ChildContent { get; set; }
    [Parameter] public bool Show { get; set; }
}

이때 ChildContent라는 파라미터는 특별한 이름으로 하나의 콘텐츠 파라미터가 있을 때에 대한 컨벤션이다.

같은 솔루션 내의 다른 프로젝트에서 다음과 같이 프로젝트를 참조할 수 있다.

<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
  ...
  <ItemGroup>
    <ProjectReference Include="..\BlazingComponents\BlazingComponents.csproj" />
    <ProjectReference Include="..\BlazingPizza.ComponentsLibrary\BlazingPizza.ComponentsLibrary.csproj" />
    <ProjectReference Include="..\BlazingPizza.Shared\BlazingPizza.Shared.csproj" />
  </ItemGroup>
  ...
</Project>

위에 작성한 컴포넌트를 통해 다일로그 사용을 변경한다.

이전

@if (OrderState.ShowingConfigureDialog)
{
    <ConfigurePizzaDialog
        Pizza="OrderState.ConfiguringPizza"
        OnConfirm="OrderState.ConfirmConfigurePizzaDialog"
        OnCancel="OrderState.CancelConfigurePizzaDialog" />
}

이후

<TemplatedDialog Show="OrderState.ShowingConfigureDialog">
    <ConfigurePizzaDialog
        Pizza="OrderState.ConfiguringPizza"
        OnCancel="OrderState.CancelConfigurePizzaDialog"
        OnConfirm="OrderState.ConfirmConfigurePizzaDialog" />
</TemplatedDialog>

TemplatedDialog 내부의 컨텐츠가 RenderFragment 타입으로 ChildContent 파라미터에 전달된 것이다.

@typeparam

제네릭 타입 컴포넌트를 만들기 위해 컴포넌트 최상단에 @typeparam directive를 사용한다.

@typeparam TItem

그 후 @code에서 제네릭을 사용하여 코드를 입력한다.

@code {
    IEnumerable<TItem> items;

    [Parameter] public Func<Task<IEnumerable<TItem>>> Loader { get; set; }

    protected override async Task OnParametersSetAsync()
    {
        items = await Loader();
    }
}

Multiple RenderFragment

여러 RenderFragment를 파라미터로 받을 때에는 ChildContent 대신 각각의 파라미터 이름을 작성한다.

RenderFragment가 파라미터를 받을 경우에는 제네릭을 활용해 파라미터 타입을 기재한다.

@typeparam TItem

@if (items == null)
{
    @Loading
}
else if (!items.Any())
{
    @Empty
}
else
{
    <div class="list-group @ListGroupClass">
        @foreach (var item in items)
        {
            <div class="list-group-item">
                @Item(item)
            </div>
        }
    </div>
}

@code {
    IEnumerable<TItem> items;

    [Parameter] public Func<Task<IEnumerable<TItem>>> Loader { get; set; }

    [Parameter] public RenderFragment Loading { get; set; }
    [Parameter] public RenderFragment Empty { get; set; }
    [Parameter] public RenderFragment<TItem> Item { get; set; }
    [Parameter] public string ListGroupClass { get; set; }

    protected override async Task OnParametersSetAsync()
    {
        items = await Loader();
    }
}

@typeparam으로 인해, 해당 타입을 파라미터로 넘길 수 있다.(옵션)

Item 파라미터는 파라미터를 받는 RenderFragment<T> 였다. 기본으로 이 파라미터는 context라고 불린다. <Item></Item> 안쪽에서는 @context를 통해 해당 파라미터를 참조할 수 있다. 이 내부 파라미터의 이름을 Context 파라미터를 이용하여 바꿀 수 있다.

<TemplatedList Loader="@LoadOrders" TItem="OrderWithStatus">
    <Loading>
        Loading...
    </Loading>
    <Empty>
        <h2>No orders placed</h2>
        <a class="btn btn-success" href="">Order some pizza</a>
    </Empty>
    <Item Context="item">
        <div class="col">
            <h5>@item.Order.CreatedTime.ToLongDateString()</h5>
            Items:
            <strong>@item.Order.Pizzas.Count()</strong>;
            Total price:
            <strong>£@item.Order.GetFormattedTotalPrice()</strong>
        </div>
        <div class="col">
            Status: <strong>@item.StatusText</strong>
        </div>
        <div class="col flex-grow-0">
            <a href="myorders/@item.Order.OrderId" class="btn btn-success">
                Track &gt;
            </a>
        </div>
    </Item>
</TemplatedList>

progressive-web-app 🌐

Progressive Web App (PWA) features

Adding a service worker

PWA의 거의 모든 기능들은 서비스 워커라는 것을 필요로한다. 이는 Javascript 파일로 보통 작다. 어플리케이션 컨텍스트 밖에서의 이벤트 핸들러가 작성되는 공간이다.

.NET 기반인 Blazor의 경우에도 서비스 워커는 Javascript로 작성되어야한다. 왜냐하면 어플리케이션 밖에서 동작하기 때문이다. 기술적으로 .NET 코드로 작성할 수 있으나, 비지니스 코드보다 .NET에서 동작하기 위한 코드를 더 많이 작성해야하기 때문에 합리적이지는 않다.

서비스 워커를 추가하고 싶다면, wwwroot 폴더 내에 service-worker.js 파일을 추가한다. 파일에 아래 내용을 입력한다.

self.addEventListener("install", async (event) => {
  console.log("Installing service worker...")
  self.skipWaiting()
})

self.addEventListener("fetch", (event) => {
  // You can add custom logic here for controlling whether to use cached data if offline, etc.
  // The following line opts out, so requests go directly to the network as usual.
  return null
})

위 코드는 아무것도 하지않는다. 스스로를 설치하며, 브라우저가 자신의 도메인으로 fetch 이벤트를 발생할 때에도 아무것도 하지 않는다. 필요하다면 이 파일에 오프라인 지원과 같은 기능들을 추가할 수 있다.

이 서비스 워커 파일을 등록하려면, index.html<body> 안에 script를 입력한다.

<script>
  navigator.serviceWorker.register("service-worker.js")
</script>

Making your app installable

wwwrootmanifest.json 파일을 작성한다.

{
  "short_name": "Blazing Pizza",
  "name": "Blazing Pizza",
  "icons": [
    {
      "src": "img/icon-512.png",
      "type": "image/png",
      "sizes": "512x512"
    }
  ],
  "start_url": "/",
  "background_color": "#860000",
  "display": "standalone",
  "scope": "/",
  "theme_color": "#860000"
}

작성한 파일은 index.html<head> 안에서 등록한다.

<link rel="manifest" href="manifest.json" />

이렇게 한 후 사이트에 접속하면 웹브라우저에서 앱을 설치할 수 있다는 표시가 나타나게 된다.

Sending push notifications

Push Notification을 보내기 위해선, 유저의 허가를 받아야한다. 동의를 받고나면, 브라우저는 subscription을 생성하게 된다. subscription은 토큰들로 유저에게 notification을 route할 때 사용할 수 있다.

JSRuntime을 주입해서 blazorPushNotifications.requestSubscription 함수를 실행하여 허가를 받는다.

protected override void OnInitialized()
{
    // In the background, ask if they want to be notified about order updates
    _ = RequestNotificationSubscriptionAsync();
}


async Task RequestNotificationSubscriptionAsync()
{
    var subscription = await JSRuntime.InvokeAsync<NotificationSubscription>("blazorPushNotifications.requestSubscription");
    if (subscription != null)
    {
        try
        {
            await OrdersClient.SubscribeToNotifications(subscription);
        }
        catch (AccessTokenNotAvailableException ex)
        {
            ex.Redirect();
        }
    }
}

SubscribeToNotifications는 다음과 같이 서버에 구독을 요청한다.

public async Task SubscribeToNotifications(NotificationSubscription subscription)
{
    var response = await httpClient.PutAsJsonAsync("notifications/subscribe", subscription);
    response.EnsureSuccessStatusCode();
}

Sending Notification

notification을 보내는 작업은 서버 쪽에 여러 복잡한 암호화 작업이 필요하지만, WebPush NuGet 패키지를 이용하여 쉽게 구현할 수 있다.

[HttpPost]
public async Task<ActionResult<int>> PlaceOrder(Order order)
{
    ...

    // In the background, send push notifications if possible
    var subscription = await _db.NotificationSubscriptions.Where(e => e.UserId == GetUserId()).SingleOrDefaultAsync();
    if (subscription != null)
    {
        _ = TrackAndSendNotificationsAsync(order, subscription);
    }

    return order.OrderId;
}
private static async Task SendNotificationAsync(Order order, NotificationSubscription subscription, string message)
{
    // For a real application, generate your own
    var publicKey = "BLC8GOevpcpjQiLkO7JmVClQjycvTCYWm6Cq_a7wJZlstGTVZvwGFFHMYfXt6Njyvgx_GlXJeo5cSiZ1y4JOx1o";
    var privateKey = "OrubzSz3yWACscZXjFQrrtDwCKg-TGFuWhluQ2wLXDo";

    var pushSubscription = new PushSubscription(subscription.Url, subscription.P256dh, subscription.Auth);
    var vapidDetails = new VapidDetails("mailto:<someone@example.com>", publicKey, privateKey);
    var webPushClient = new WebPushClient();
    try
    {
        var payload = JsonSerializer.Serialize(new
        {
            message,
            url = $"myorders/{order.OrderId}",
        });
        await webPushClient.SendNotificationAsync(pushSubscription, payload, vapidDetails);
    }
    catch (Exception ex)
    {
        Console.Error.WriteLine("Error sending push notification: " + ex.Message);
    }
}
private static async Task TrackAndSendNotificationsAsync(Order order, NotificationSubscription subscription)
{
    // In a realistic case, some other backend process would track
    // order delivery progress and send us notifications when it
    // changes. Since we don't have any such process here, fake it.
    await Task.Delay(OrderWithStatus.PreparationDuration);
    await SendNotificationAsync(order, subscription, "Your order has been dispatched!");

    await Task.Delay(OrderWithStatus.DeliveryDuration);
    await SendNotificationAsync(order, subscription, "Your order is now delivered. Enjoy!");
}
private static async Task SendNotificationAsync(Order order, NotificationSubscription subscription, string message)
{
    // For a real application, generate your own
    var publicKey = "BLC8GOevpcpjQiLkO7JmVClQjycvTCYWm6Cq_a7wJZlstGTVZvwGFFHMYfXt6Njyvgx_GlXJeo5cSiZ1y4JOx1o";
    var privateKey = "OrubzSz3yWACscZXjFQrrtDwCKg-TGFuWhluQ2wLXDo";

    var pushSubscription = new PushSubscription(subscription.Url, subscription.P256dh, subscription.Auth);
    var vapidDetails = new VapidDetails("mailto:<someone@example.com>", publicKey, privateKey);
    var webPushClient = new WebPushClient();

    try
    {
        var payload = JsonSerializer.Serialize(new
        {
            message,
            url = $"myorders/{order.OrderId}"
        });
        await webPushClient.SendNotificationAsync(pushSubscription, payload, vapidDetails);
    }
    catch(Exception ex)
    {
        Console.Error.WriteLine("Error sending push notification: " + ex.Message);
    }
}

Displaying notifications

service-worker.js에 notification을 보여주는 코드를 작성한다.

self.addEventListener("push", (event) => {
  const payload = event.data.json()
  event.waitUntil(
    self.registration.showNotification("Blazing Pizza", {
      body: payload.message,
      icon: "img/icon-512.png",
      vibrate: [100, 50, 100],
      data: { url: payload.url },
    }),
  )
})

Handling clicks on notifications.

service-worker.js에서 notificationclick 이벤트를 추가한다.

self.addEventListener("notificationclick", (event) => {
  event.notification.close()
  event.waitUntil(clients.openWindow(event.notification.data.url))
})

publish-and-deploy 🌐

Deploy your app to Azure

Azure 사용법이라 따로 적지 않음.