블레이저 Form Validation with IStringLocalizer

데이터 검증 메시지를 지역화할 때 마땅한 수단이 없어, 고민들 한 번 씩 해보셨을 겁니다.

닷넷의 데이터 어노테이션을 사용하는 방법이 있지만, 여기에는 두 가지 문제가 있습니다.

첫째로, 라이브러리에서 제공하는 영문 메시지 템플릿은 변경 적용하는 것이 힘들다는 것입니다.
이를 위해서는 수 많은 코드가 필요합니다. (몇 몇 오픈 소스 프로젝트 있기는 합니다만, 불편해 보여서 사용해보지 않았습니다.)

둘째로, 커스텀 resx 파일을 작성하는 경우, Key-Value를 먼저 고민해야 해서, 로직 코드 보다 부담이 많아질 수 있습니다.

블레이저는 다행스럽게도 EditForm 요소가 검증 메시지로 동적인 문자열을 사용할 수 있게끔 지원합니다.

이를 IStringLocalizer 와 함께 사용하면 보다 간편하게 지역화된 Validation 메세지를 작성할 수 있습니다.

@*MyComponent.razor*@
@implements IDisposable

@IStringLocalizer<AppTexts> Texts

<EditForm EditContext="_editContext" OnValidSubmit="Submit" FormName="WeekDaysSelection">
    <div>
        <label>
            <InputCheckbox @bind-Value="Model!.HasMon" /> @Texts["월"]
        </label>
    </div>
    <div>
        <label>
            <InputCheckbox @bind-Value="Model!.HasTue" /> @Texts["화"]
        </label>
    </div>
@* 생략 *@
    <div>
        <label>
            <InputCheckbox @bind-Value="Model!.HasSun" /> @Texts["일"]
        </label>
    </div>
    <div>
        <ValidationMessage For="() => Model!.Selecteds" />
    </div>
    <div>
        <button type="submit">@Texts["확인"]</button>
    </div>
</EditForm>

@code {
    [SupplyParameterFromForm]
    public WeekDays? Model { get; set; }

    private EditContext? _editContext;
    private ValidationMessageStore? _messageStore;

    protected override void OnInitialized()
    {
        Model ??= new();
        _editContext = new(Model);
        _editContext.OnValidationRequested += HandleValidationRequested;
        _messageStore = new(_editContext);
    }

    // 데이터 검증 요청 핸들러
    private void HandleValidationRequested(object? sender,
        ValidationRequestedEventArgs args)
    {
        _messageStore?.Clear();

        var selected = Model!.Selecteds.Count();
        if (selected < 1)
        {
            _messageStore?.Add(() => Model.Selecteds, Texts["하나 이상 선택해야 합니다."]);
        }
        else if (selected > 5)
        {
            _messageStore?.Add(() => Model.Selecteds, Texts["5개 보다 많이 선택하면 안됩니다."]);
        }
    }

    private void Submit()
    {
        // 데이터 검증이 성공했을 때 실행되는 코드       
    }

    public void Dispose()
    {
        if (_editContext is not null)
        {
            _editContext.OnValidationRequested -= HandleValidationRequested;
        }
    }

    public class WeekDays
    {
       public bool HasSun { get; set; }
       public bool HasMon { get; set; }
       public bool HasTue { get; set; }
       public bool HasWed { get; set; }
       public bool HasThu { get; set; }
       public bool HasFri { get; set; }
       public bool HasSat { get; set; }

       public IEnumerable<DayOfWeek> Selecteds
       {
          get
          {
             if (HasSun) yield return DayOfWeek.Sunday;
             if (HasMon) yield return DayOfWeek.Monday;
             if (HasTue) yield return DayOfWeek.Tuesday;
             if (HasWed) yield return DayOfWeek.Wednesday;
             if (HasThu) yield return DayOfWeek.Thursday;
             if (HasFri) yield return DayOfWeek.Friday;
             if (HasSat) yield return DayOfWeek.Saturday;
         }
      }
}

EditForm.EditContext

이 객체는 Form 을 통해 사용자가 데이터를 편집하는 상황(Context)을 나타내는데, 유용한 메타 정보를 제공합니다. .

아래 코드는 데이터 모델로 EditContext 를 생성하고, 메타 정보 중 관심 있는 부분, 즉, 데이터 검증 요청 이벤트를 구독하는 코드입니다.

    protected override void OnInitialized()
    {
        Model ??= new();
        _editContext = new(Model);
        editContext.OnValidationRequested += HandleValidationRequested;
        messageStore = new(editContext);
    }

마지막 줄은, 생성된 EditContext로 ValidationMessageStore 객체를 초기화하는데, 이렇게 초기화된 메시지 저장소 객체는 아래 요소가 사용합니다.

    <div>
        <ValidationMessage For="() => Model!.Selecteds" />
    </div>

ValidationMessageStore 객체와 ValidationMessage 요소의 연관성은 매직입니다.

검증 처리 과정

EditContext.OnValidationRequested 이벤트의 전파는 EditForm 이 관장하는데, OnValidSubmit 이 null 이 아닐 때 이벤트 전파를 요청합니다.

<EditForm EditContext="_editContext" OnValidSubmit="Submit" ...

만약 아래와 같이 설정하면, 이벤트는 전파되지 않습니다.

<EditForm EditContext="_editContext" OnSubmit="Submit" ...

Form Data가 도착했을 때, EditForm 이 데이터를 검증하는 전반적인 처리 과정은 아래와 같습니다.

OnValidSubmit == null ? ( OnSubmit == null ? Do nothing : OnSubmit 호출)
: EditContext.EditContext.OnValidationRequested 호출

ValidationMessage 요소가 EditContext 객체를 CasCade 받음.
ValidationMessageStore 에서 EditContext에 해당하는 메시지를 찾음. => 있는 경우만, 메시지 렌더링.

ValidationMessage 요소는 ValidationMessageStore 에서 메시지를 찾기 때문에, 새로운 검증 요청 때마다, 이를 비워주지 않으면, 지난 번 메시지 코드가 보여지게 됩니다.

    // 데이터 검증 요청 핸들러
    private void HandleValidationRequested(object? sender,
        ValidationRequestedEventArgs args)
    {
        _messageStore?.Clear();

요청 핸들러의 실행 런타임

요청 핸들러의 실행은 블레이저의 호스팅 모델에 따라 달라집니다.
Static SSR/Interactive Server 의 경우 서버에서, Interactive Webassembly 인 경우 클라이언트에서 실행됩니다.

Form Data - Model 바인딩

이는 블레이저가 처리합니다.
블레이저는 호스팅 모델에 따라 아래와 같이 모델을 코드에 전달합니다.

  1. Static SSR인 경우,

    1. Enhance Form 인 경우
      blazor.web.js 의 fetch 요청을 통해,
    2. 아닌 경우
      Html Form submission을 통해
  2. Interactive Server 인 경우, 웹소켓을 통해,

  3. Interactive Webassembly 인 경우, wasm - js interop 을 통해 전달합니다.

참고로, 아주 가끔, 바인딩이 안되는 경우가 있습니다.
이 경우, 도움 안되는 에러 메시지만 보여질 뿐이라, 저도 원인을 찾지는 못했습니다.

아무리 봐도, 코드에 문제가 없어 보인다면, 비주얼 스튜디오를 몇 번 열고 닫으면 해결이 됩니다.
안될 때는 릴리스 모드로 빌드해도 안되니, 그냥 VS를 끄고 닫는 게 좋습니다. 될 때까지.

팁이라면, EditForm 을 통해 할당되는 파라미터는 OnParametersSet 보다는 OnInitialized 에서 할당을 처리하는 게 그나마, 발생확률을 줄이는 것 같습니다.

IStringLocalizer<T>

이 객체는 T.resx 파일을 찾고, 파일이 없거나, 있어도 해당 key 가 존재하지 않으면, key 값을 반환합니다.

예제로 설명하면,

@IStringLocalizer<AppTexts> Texts

<EditForm EditContext="_editContext" OnValidSubmit="Submit" FormName="WeekDaysSelection">
    <div>
        <label>
            <InputCheckbox @bind-Value="Model!.HasMon" /> @Texts["월"]
        </label>
 ...

IStringLocalizer<AppTexts>는 서비스 컨테이너로부터 주입받고, 코드에서 Texts[“월”] 을 호출했는데,

AppTexts.resx 파일이 없거나, 있어도, 이 파일에 key "월"에 해당하는 value 가 없으면, "월"을 반환한다는 의미입니다.

이는 AppTexts.resx 파일이 존재하지 않아도 됨을 의미합니다.
다만, 자리 지킴이 파일인 AppTexts.cs 파일은 있어야 합니다.

public class AppTexts { }

IStringLocalizer<AppTexts> 객체의 모든 키를 한글로만 지정하면, 가상의 AppTexts.resx 는 한글 자원 파일과 같기 때문에, UI를 한글로 빠르게 작성할 수 있습니다.

나중에 영문이 필요한 경우에만, AppTexts.en.resx 파일을 작성하면 됩니다.
다만, 여기 저기 분산된 한글 자원을 찾는 게 손이 많이 가는 일이기는 합니다.

4개의 좋아요

이제 제가좀 여유가 생겨서 Blzor , css , front 단을
공부해볼생각입니다. @BigSquare 님을 글을 좀 정독 해볼려고요
그리고 차기 웹 솔루션 bolilerplate를 구상중이라서요 ㅎ

2개의 좋아요

블레이저 문서에서는 요소를 작성할 때, 렌더링 모델과 상관없이 하라고 하는데, 현실적으로 달성하기 매우 힘든 목표인 것 같습니다.
(예제는 가능해도, 현실 앱은 어려움이 많습니다)

따라서, 여러 가지 렌더링 모델의 특징을 모두 살피기 보다는, 한 가지만 정해놓고 그것만 사용하는 것을 추천합니다.

  1. 블레이저 웹어셈블리
    전형적인 3 tier 모델로, javascript의 역할을 C# 코드가 담당하고, 로딩 타임을 빼면 성능도 가장 좋습니다.
    표준 웹 API 호출에는 javascript 가 여전히 필요하지만, 어렵지 않은 코드이고, 예제들도 쉽게 구할 수 있어(빙신도 꽤 잘합니다) 특별히 많은 공부가 필요하지 않습니다.
    API와 DB를 별도로 작성해야 합니다.
    이 렌더링 모델의 가장 큰 장점은 거의 아무런 수정 없이, WPF, 윈폼, MAUI 하이브리드 호스팅 모델과의 호환성이 매우 좋습니다. 거의 그대로 사용할 수 있을 정도입니다.
    물론, 별도의 API를 작성해야 하는 부담이 있지만, 지금은 supabase 등과 같이 경제적인 Saas 도구가 많으므로 오히려 개발 경제성 면에서 가장 이득이라 할 수 있습니다. (Db만 작성하면 API가 자동으로 생성됩니다)

  2. 블레이저 서버
    2 tier 모델에 적합하고, 성능은 중간, javascript 의 용도는 위와 같습니다.
    DB 만 구성하면 됩니다.
    예전에 문제가 많았던 웹소켓 연결이 끊겼을 때 화면이 8.0에서는 개선되었습니다.

  3. Static SSR
    전통적인 웹앱과 거의 비슷하게 동작해서, 서버의 원리만 알면, 구현하기는 매우 간단합니다.
    그러나, 성능적으로 가장 느리고, 클라이언트 코드에 C#을 쓸 수 없다는 게 가장 큰 단점입니다. 따라서, 반드시 javascript 를 익혀야 합니다.

렌더링 모드를 섞어 쓰는 것은 개인적으로 비추합니다.

개인적인 추천 순서는 웹어셈블리 > Static SSR > 서버 입니다.

안녕하세요.
지역화 때문에 많은 고민을 하고 있습니다.
쉽게 가는 방법을 찾다고 결국에는 정상적이고 확실한 방법으로 가게 되네요 ㅠㅠ
IStringLocalizer을 구현해서 내부 로직에서 DB에서 관리하는 지역화 리소스를 가져와서 지역화를 했습니다.
문제는 EditForm Validation 메세지를 지역화하는게 문제 였는데…
결국에는 기존 Attribute들을 상속받아 다시 구현을 했어요.
구현한 내부로직에는 IStringLocalizer을 구현한 걸 재사용합니다.
깃허브에 아직 올리지는 않았는데 몇일 안으로 올라가면 공유해 볼께요 !!

1개의 좋아요

고생 많이 하셨네요. 저는 그 만큼 부지런하지 않아서 ㅠㅠ

그런데, Db 에 저장하는 방식은 성능 문제가 있지는 않은지요?
어떻게 구현하셨는지 참 궁금합니다. 얼른 올려 주세요. ^^

FluentValidation + Blazored.FluentValidation 활용하여 메시지 생성하고 있습니다. 한국어 로캘 에러메시지도 지원하고, 기본 유효성 검사 행위를 여러개 지원해서 별도의 메시지를 따로 작성할 일이 거의 없더라구요.
LanguageManager 오타 있는 부분 커스텀, DisplayNameResolver 만 커스텀했습니다.

DTO

namespace Business.Models
{
    public class EditableTankLevelHistoryDto : IKeyEntity
    {
        public int Id { get; set; }
        
        public int TankId { get; set; }

        [Display(Name = "탱크")]
        public string TankName { get; set; } = null!;

        public DateTime HistoryAt { get; set; } = new DateTime(
            DateTime.Now.Year,
            DateTime.Now.Month,
            DateTime.Now.Day, 
            DateTime.Now.Hour, 
            DateTime.Now.Minute, 
            0
        );

        [Display(Name = "레벨(mm)")]
        public int Level { get; set; }
        public object?[] KeyValues => [Id];

        public class FromMap : RelationProfile<TankLevelHistory, EditableTankLevelHistoryDto> 
        {
            public FromMap()
            {
                Mapping.ForMember(d => d.TankName, opts =>
                {
                    opts.MapFrom(s => s.Tank.Name);
                });
            }
        }
        public class ToMap : RelationProfile<EditableTankLevelHistoryDto, TankLevelHistory> { }

        public class Validator : AbstractValidator<EditableTankLevelHistoryDto>
        {
            public Validator()
            {
                RuleFor(x => x.TankId).GreaterThan(0);
                RuleFor(x => x.Level).GreaterThan(0);
            }
        }
    }
}

Program.cs(서비스 등록)

builder.Services.AddValidatorsFromAssembly(typeof(ProjectInfo).Assembly);
ValidatorOptions.Global.LanguageManager = new CustomFluentValidationLanguageManager();
ValidatorOptions.Global.DisplayNameResolver = (type, memberInfo, expression) =>
{
    return memberInfo.GetCustomAttribute<System.ComponentModel.DataAnnotations.DisplayAttribute>()?.GetName()
        ?? memberInfo?.Name
        ?? type.Name;
};

View

...
        <EditForm id="editform" Model="@EditModel" Context="EditFormContext" OnInvalidSubmit="OnInvalidSubmit"
                  OnValidSubmit="SaveConfirmPopup.Show">
            <FluentValidationValidator />
...
@code {
...
    void OnInvalidSubmit(EditContext context)
    {
        foreach (var msg in context.GetValidationMessages())
        {
            Snackbar.Add(msg, Severity.Error);
        }
    }
}
3개의 좋아요

기본 메시지에 한국어를 지원한다고 하니, 매우 다행스럽네요.

한 번 찾아 보겠습니다.

많이 쓰고 사용성도 훌륭한 라이브러리라 정말 잘 쓰고있습니다 ㅎㅎ

1개의 좋아요

사용해보니, 자잘한 코드들이 많이 줄어드는 것 같습니다.

특히, RuleFor().Must() 가 매우 범용적이네요.

~~ (이) 가 유효하지 않습니다.

1개의 좋아요

닷넷 재단 라이브러리라 꾸준히 유지관리되는것도 장점인것 같습니다. ㅎㅎ