주말 아침 #3

C# 및 MVVM 툴킷으로 Blazor 서버 애플리케이션 만들기 | bromix

MVVM 툴킷 및 RelayCommands로 Blazor 서버 애플리케이션 만들기 | bromix

MVVM 툴킷을 사용한 블레이저에서 쉽게 비동기 명령 취소하기 | bromix

이외에 읽을만한 글들입니다.

C# 팁: 보간 문자열 서식 지정 | Code4IT

.NET에서 exe의 Windows 응용 프로그램 매니페스트 읽기 | meziantou

애플리케이션의 로그 수준 제어 | Steven Giesel

.NET 7로 성장하는 최소 API | Christian Nagel

6개의 좋아요

엔티티 프레임워크 코어를 사용하여 엔티티 저장 후 즉시 MediatR 알림 보내기 | no dogma blog

몇 주 전에 엔티티 프레임워크에서 개체를 저장한 직후에 액세스하는 방법에 대한 게시물을 작성했는데, 해당 게시물에서는 저장된 각 엔티티에 대해 콘솔에 한 줄을 출력했습니다.

엔티티가 생성/업데이트되었다는 알림을 보내려고 할 때 이 방법이 어떻게 유용할 수 있는지 보여드리려고 했습니다. 이번 포스팅에서는 엔티티가 저장된 직후 MediatR 알림을 보내는 방법을 보여드리겠습니다.

여기에서는 MediatR 사용 방법에 대해 설명하지 않을 것이며, 이에 대한 좋은 문서가 있으며, 저도 이에 대한 몇 개의 게시물을 작성했습니다. 핸들러는 ProductNotificationHandler.cs 파일에서 찾을 수 있으며, 매우 간단하며, 콘솔에 한 줄을 인쇄합니다.

여기서는 엔티티가 저장된 직후 알림을 전송하기 위해 DbContext의 SaveChangesAsync 메서드를 재> 정의하는 방법을 보여드리겠습니다.

5개의 좋아요

로직 코드를 뷰모델로 옮기는 아이디어를 위해 굳이 MVVM 툴킷을 사용할 필요가 있는지는 의문입니다.

MVVM 툴킷으로 인해 뷰모델에 총 세개의 Notify 코드가 추가되지만, 뷰가 구독하지 않기 때문에 이벤트는 트리거되지 않습니다.

// PropertyChanged = null
    public async Task LoadDataAsync()
    {
        IsLoading = true; // Notify

        try
        {
            // ...
            Forecasts = await _weatherService.GetForecastAsync(DateTime.Now); // Notify
        }
        finally
        {
            IsLoading = false; // Notify
        }
    }

강좌의 코드는 MVVM 툴킷이 없어도 뷰가 뷰모델을 사용하는 데 문제가 없습니다.

internal sealed class WeatherViewModel
{
    private readonly WeatherForecastService _weatherService;

    public WeatherViewModel(WeatherForecastService weatherService)
    {
        _weatherService = weatherService;
    }

   public bool IsLoading;

   public WeatherForecast[] Forecasts = Array.Empty<WeatherForecast>();

    public async Task LoadDataAsync()
    {
        IsLoading = true;

        try
        {
            await Task.Delay(TimeSpan.FromSeconds(2)); // simulate loading
            Forecasts = await _weatherService.GetForecastAsync(DateTime.Now);
        }
        finally
        {
            IsLoading = false;
        }
    }
}

MVVM 툴킷이 블레이저에서 힘을 발휘하려면, 블레이저가 데이터 바인딩을 지원해야 할 것 같은데,

//.razor 에서
@BindingContext WeatherViewModel
//혹은
@DataContext WeatherViewModel

(양방향) 데이터 바인딩은 Notify => Read/Notify => Read 의 라운드 트립 구조이기에, 블레이저 서버 앱에서는 SignalR 데이터량이 폭증하는 부작용이 있을 것 같습니다.

물론, 블레이저 웹어셈블리 앱에서는 채택할 만할 것 같습니다.

5개의 좋아요

문자열 보간은 즐겨쓰는 기능이었는데 서식도 지정할 수 있다니 엄청 신기하네요 ㅎㅎ

2개의 좋아요

의견 지점 감사합니다.

전개한 내용 (특히 첫 번째 내용)이 친절하지 않아서 생긴 문제로 보이며 사실 연속된 시리즈의 마무리 – MVVM 툴킷으로 명령어 사용 및 비동기 취소가 저자가 전달하고자 하는 주된 내용으로 이해됩니다. 이것 만으로도 Blazor에서 MVVM 툴킷을 사용해서 코드를 좀 더 간결하게 만들어 준다고 생각합니다.

...
<button disabled="@ViewModel.CountCommand.IsRunning" @onclick="() => ViewModel.CountCommand.ExecuteAsync(null)">Click!</button>
...
    [RelayCommand]
    public async Task OnCount()
    {
        for (var i = 0; i < 200; i++)
        {
            Count++;

            await Task.Delay(10);
        }
    }

한편 실시간 데이터의 표시가 아닌 이상 Blazor에서는 입력(버튼의 클릭 등)의 이벤트에 의해 화면 갱신이 이루어지므로 INotifyPropertyChanged 이벤트를 구독하지 않아도 대부분의 시나리오에서 문제가 없습니다. 가령,

에서 await 이전에 IsLoading의 변경이 화면에 (자동으로) 적용되며 LoadDataAsync() 메소드의 콜이 끝나는 시점에 다시 IsLoading의 변경된 상태가 화면에 적용됩니다.

하지만 버튼을 눌렀을 때 1부터 100까지 10ms 단위로 출력하는 등은 동작하지 않죠. 이부분을 다음의 방식으로 간단히 해결할 수 있습니다.

다음처럼 ViewModel을 MVVM 툴킷으로 구성한 후

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

using System.ComponentModel;

namespace BlazorApp41.ViewModels;

public partial class IndexViewModel : ObservableObject
{
    [ObservableProperty]
    private int _count;

    public Action StateHasChanged { get; set; } = () => { };


    protected override void OnPropertyChanged(PropertyChangedEventArgs e)
    {
        base.OnPropertyChanged(e);

        StateHasChanged();
    }

    [RelayCommand]
    public async Task OnCount()
    {
        for (var i = 0; i < 200; i++)
        {
            Count++;

            await Task.Delay(10);
        }
    }
}

StateHasChanged Action 딜리게이트를 다음처럼 하면

@code {
    protected override void OnInitialized()
    {
        ViewModel.StateHasChanged = () => InvokeAsync(StateHasChanged);
    }
}

버튼을 눌렀을 때 동작이 완료될 때까지 버튼은 비활성화 되며 숫자가 10ms 간격으로 증가하는 것을 확인할 수 있습니다.

@page "/"
@using BlazorApp41.ViewModels;
@inject IndexViewModel ViewModel

<PageTitle>Index</PageTitle>

<h1>Hello, world!</h1>

<h2>@ViewModel.Count</h2>

<button disabled="@ViewModel.CountCommand.IsRunning" @onclick="async () => await ViewModel.CountCommand.ExecuteAsync(null)">Click!</button>

Welcome to your new app.

<SurveyPrompt Title="How is Blazor working for you?" />

@code {
    protected override void OnInitialized()
    {
        ViewModel.StateHasChanged = () => InvokeAsync(StateHasChanged);
    }
}

Blazor Webassembly에서는 말씀하신 것 처럼 ViewModel의 속성이 변경됨에 따라 갱신되는 화면의 렌더링을 웹브라우저에서 하므로 문제 될 것이 전혀 없으나 Blazor Server 경우 html 렌더링을 서버에서 하며 ViewModel 인스턴스 역시 서버에 있어서 ViewModel의 속성 변경에 의한 화면 렌더링에 의한 데이터량의 증가는 당연히 발생하지만 우려하시는 것 처럼 실시간 데이터의 표현이 아닌 이상 Blazor Server의 사용목적에 벗어나지는 않는다라는 것이 저의 의견입니다.

결론은 MVVM 툴킷을 Blazor에서 사용하는데 문제가 없으며 다른 환경과 동일한 바인딩 혜택을 누릴 수 있다고 생각합니다.

4개의 좋아요

관련해서 이렇게 쓰면 괜찮지 않을까 샘플을 만들어봤습니다.

기본 구조는 아래와 같고요,
ViewModel → ViewModelBase → IStateHasChanged
ViewModelComponent → OwningComponentBase

IoC를 이용하기 위해 Program.cs에 IndexViewModel를 추가해야 하고

builder.Services.AddScoped<IndexViewModel>();

이후 사용법은 거의 동일합니다.

| IndexViewModel.cs

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

using N31.BlazorServerMVVM.Base;

namespace N31.BlazorServerMVVM.ViewModels;

public partial class IndexViewModel : ViewModelBase
{
    [ObservableProperty] private int _count;
    [ObservableProperty] private int _inputCount = 200;

    [RelayCommand]
    public async Task OnCountAsync(CancellationToken ct)
    {
        try
        {
            for (var i = 0; i < InputCount; i++)
            {
                Count++;

                await Task.Delay(10, ct);
            }
        }
        catch (OperationCanceledException)
        {
        }
    }
}

| Index.razor

@inherits ViewModelComponent<IndexViewModel>

@page "/"

@using N31.BlazorServerMVVM.Base;
@using N31.BlazorServerMVVM.ViewModels;

<PageTitle>Index</PageTitle>

<h1>Hello, world!</h1>

Welcome to your new app.

<h2>@ViewModel.Count</h2>

<div>
    <button disabled="@ViewModel.CountCommand.IsRunning" @onclick="() => ViewModel.CountCommand.ExecuteAsync(null)">Click!</button>
    <button disabled="@(!ViewModel.CountCommand.CanBeCanceled)" @onclick="@ViewModel.CountCommand.Cancel">Cancel</button>
</div>

<div>
    <input disabled="@ViewModel.CountCommand.IsRunning" @bind="@ViewModel.InputCount" @bind:event="oninput" />
    <input disabled="@ViewModel.CountCommand.IsRunning" @bind="@ViewModel.InputCount" @bind:event="oninput" />
</div>

<SurveyPrompt Title="How is Blazor working for you?" />

Blazor는 razor의 상속 표현에 제네릭을 쓸 수 있어서 ViewModel을 좀 더 사용하기 편한 느낌입니다.

@inherits ViewModelComponent<IndexViewModel>
5개의 좋아요

저의 요점이 "구독하지 않아도 되는데, 구독을 전제로 한 도구가 필요할까?"입니다.

MVVM 툴킷은 데이터 바인딩을 전제로, 바인딩 소스(뷰모델)를 위한 도구입니다. 이 도구는 바인딩 타겟(뷰)이 적절한 장치들( BindableProperty, Command, RoutedEvent, Binding 등)을 갖추고 있을 때 편리한 것이지, 이 장치들을 사용자 일일이 구현해야 한다면 긁어 부스럼에 지나지 않겠죠.

릴레이 커맨드 예제 코드가 이러한 점을 보여주고 있습니다. 블레이저 요소가 Command 속성을 제공했다면, 아래와 같이 간단했을 코드입니다.

<button @OnClickCommand = "BindingContext.CountCommand" disabled="BindingContext.CountCommand.IsRunning" ...

또한, BindableProperty도 제공하지 않기 때문에, StateHasChanged 메서드로 이를 흉내낸 것이죠.

이러한 흉내가 약간의 부하를 늘려도, 기능 상 동일하게 동작한다면, WPF/UWP/MAUI/Blazor WASM 처럼 싱글 인스턴스 기반 앱에서는 굳이 불필요하다고 말할 것까지는 없을 것입니다. 그러나, Blazor Server앱은 동일한 인스턴스가 동시에 수백에서 수천개까지 존재하는 것을 전제하기 때문에, 약간의 부하 증가도 접속자 수에 정비례하게 증가하게 되어, 신중할 필요가 있을 것입니다.

이 글과 관련한 논의를 이어 가면서 문득

블레이저의 이벤트 전파 수단으로 delegate 가 아닌 EventCallback 을 채택한 것은 부하 부담이 가장 큰 원인이지 않을까?

하는 생각도 들었습니다. 참조 객체인 delegate는 상태를 저장해야 하는 반면, 값 객체인 EventCallback은 그렇지 않죠.

그럼에도, 원 글의 의도 중, 뷰모델에 뷰의 상태를 보관하는 패턴은 블레이저의 상태 관리 측면에서는 훌륭한 선택지임에는 틀림없어 보입니다.

추신: 그나저나 이놈의 핫 리로드 문제는 아직도 해결이 안됐습니다. 비주얼 스튜디오 다시 깔았는데도요. ㅠㅠ

2개의 좋아요

VS 17.7 P1부터 Blazor에서 다시 핫리로드가 잘 동작합니다.

2개의 좋아요

저도 오늘 새벽에 확인했습니다. ^^

근데, 17.6 에서도 어떨 때는 되다가, 다시 안되는 경험을 했기에 조금 지켜봐야 할 것 같습니다.

그리고, 이번 버전은 되기는 하는데, 뭔가 좀 느리네요. 길면 5초 정도 걸리는 것 같습니다.

2개의 좋아요