(ASP.NET .NET 6) 양 방향 바인딩 Flags Enum 처리 방법

 

ASP.NET Core 기반 Razor Page에는 Html 요소를 렌더링 할 수 있는 Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper<TModel> 인터페이스를 제공 합니다.
가령 CheckBox는 다음과 같이 Razor 에서 작성 할 수 있습니다.

@Html.CheckBox("isActive", true)

위 Razor 문법은 Html로 렌더링 되면 다음과 같습니다.

<input checked="checked" 
        id="isActive" 
        name="isActive" 
        type="checkbox" 
        value="true" />

또한 표현식(expression)으로 사용하려면 @Html.CheckBoxFor() 메서드를 사용 할 수 있습니다.
그런데 Flags enum 타입의 속성을 처리 할 수 있는 기본 방법은 지원하지 않아서 Razor Page에서 foreach 으로 Enum 요소를 직접 Html로 표현해야 합니다.
이러한 방법은 Server에서 One Way 바인딩으로 View에서 변경 값을 바로 바인딩으로 사용할 수도 없기에 약간 불편한 점이 있습니다.
이 포스트에서는 Flags enum 타입의 속성양 방향 바인딩 처리가 가능한 HtmlHelper를 구현하는 방법을 소개 합니다.

먼저 다음과 같은 모델이 존재 합니다.

[Flags]
public enum EExchangeKind
{
    [Description("Bitget")]
    Bitget = 1 << 0,
    [Description("Bybit")]
    Bybit = 1 << 1,
    [Description("BingX")]
    BingX = 1 << 2,
}
public record SubscriptionKey : IEntity
{
    /// <summary>
    /// 지원 거래소 종류
    /// </summary>
    [Required]
    public EExchangeKind ExchangeKind { get; set; }
}

목표

SubscriptionKey.ExchangeKind 속성은 Flags enum 타입이며 체크박스로 표현하고 다중 체크가 가능해야 합니다.
이를 위해 Enum 요소를 Html CheckBox로 렌더링 처리하는 Custom HtmlHelper를 구현하고 사용자가 선택한 값을 ViewModel에 양 방향 바인딩 되도록 처리해 봅니다.

IHtmlHelper

Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper 인터페이스는 Razor Page에서 ‘@Html’ 형태로 제공 되는 Html 요소 도우미를 처리합니다.
따라서 Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper 인터페이스에 사용되는 확장 메서드를 구현해서 간편하게 사용할 수 있도록 구현해 보겠습니다.

Razor Page에서 모델의 표현식을 받아 Enum 요소를 처리해야 하는데 이때 Microsoft.AspNetCore.Mvc.ViewFeatures.ModelExpressionProvider 클래스를 사용해서 표현식의 메타정보를 읽어올 수 있습니다.
Microsoft.AspNetCore.Mvc.ViewFeatures.ModelExpressionProvider 클래스는 기본적으로 내부 IoC에 관리되고 있는데 HttpContext를 통해 가져다 사용할 수 있습니다.

var expressionProvider = html.ViewContext.HttpContext.RequestServices
    .GetService(typeof(ModelExpressionProvider)) as ModelExpressionProvider;

var fieldName = expressionProvider?.GetExpressionText(expression);

var modelExpression =
    expressionProvider?.CreateModelExpression(html.ViewData, fieldName);

var modelValue = modelExpression?.Model;

이렇게 람다 표현식에 해당되는 속성 이름과 실제 속성 값을 읽어올 수 있습니다.

TagBuilder

Microsoft.AspNetCore.Mvc.Rendering.TagBuilder 클래스는 Html 요소를 만드는데 도와주는 클래스 입니다.
Html 요소의 추가 속성, 값 등을 동적으로 생성하고 관리할때 사용할 수 있습니다.
완성된 Microsoft.AspNetCore.Mvc.Rendering.TagBuilderMicrosoft.AspNetCore.Html.HtmlContentBuilder 클래스로 리스트 형태로 관리할 수 있습니다.

Custrom HtmlHelper 구현

이제 위 내용을 토대로 Enum 요소를 Html CheckBox로 렌더링 하는 HtmlHelper를 다음과 같이 구현해 볼 수 있습니다.
참고로 저는 System.ComponentModel.DescriptionAttribute 어트리뷰트로 Enum 상수에 해당 되는 UI 표시 값을 설정하고 그에 따라 reflection으로 설정 값을 읽어 체크박스 값으로 표시 하도록 처리하였습니다.

[FlagEnumHelpers.cs]

public static class FlagEnumHelpers
{
    public static IHtmlContent CheckBoxForFlagEnum<TModel, TValue>(this IHtmlHelper<TModel> html,
        Expression<Func<TModel, TValue>> expression,
        object htmlAttributes = null)
    {
        var expressionProvider = html.ViewContext.HttpContext.RequestServices
            .GetService(typeof(ModelExpressionProvider)) as ModelExpressionProvider;

        var fieldName = expressionProvider?.GetExpressionText(expression);
        var fullBindingName = html.ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldName(fieldName);
        var fieldId = TagBuilder.CreateSanitizedId(fullBindingName, ".");

        var modelExpression =
            expressionProvider?.CreateModelExpression(html.ViewData, fieldName);
        var modelValue = modelExpression?.Model;

        // Get all enum values
        var values = Enum.GetValues(typeof(TValue));

        // Create checkbox list
        var htmlContent = new HtmlContentBuilder(10);
        foreach (var item in values)
        {
            TagBuilder builder = new TagBuilder("input");
            string stringVal = string.Empty;
            long targetValue = Convert.ToInt64(item);
            long flagValue = Convert.ToInt64(modelValue);

            // Reflation for Attributes
            var fi = item.GetType().GetField(item.ToString());
            if (fi is not null)
            {
                // Check DescriptionAttribute
                var attributes = (DescriptionAttribute[])fi.GetCustomAttributes(typeof(DescriptionAttribute), false);
                if(attributes.Length > 0)
                {
                    stringVal = attributes[0].Description;
                }
                else
                {
                    stringVal = item.ToString();
                }
            }

            if ((targetValue & flagValue) == targetValue)
                builder.MergeAttribute("checked", "checked");

            builder.MergeAttribute("type", "checkbox");
            builder.MergeAttribute("value", item.ToString());
            builder.MergeAttribute("name", fieldId);
            builder.MergeAttribute("id", fieldId);

            // Add optional html attributes
            if (htmlAttributes != null)
                builder.MergeAttributes(new RouteValueDictionary(htmlAttributes));

            builder.InnerHtml.AppendHtml(stringVal);
            var htmlString = GetString(builder);
            //htmlContent.AppendHtml(htmlString);
            htmlContent.AppendHtml(builder);
            htmlContent.AppendHtml("<br/>");
        }

        return htmlContent;
    }

    public static string GetString(IHtmlContent content)
    {
        using (var writer = new System.IO.StringWriter())
        {
            content.WriteTo(writer, HtmlEncoder.Default);
            return writer.ToString();
        }
    }

    public class ReverseComparer : IComparer
    {
        public int Compare(Object x, Object y)
        {
            return (new CaseInsensitiveComparer()).Compare(y, x);
        }
    }
}

이렇게 만들어진 FlagEnumHelpers는 실제로 Razor Page에서 다음과 같이 사용할 수 있습니다.

@Html.CheckBoxForFlagEnum(m => m.SubscriptionKey.ExchangeKind)

또는 추가 적인 Html 요소 속성이 있는 경우 이렇게도 사용할 수 있습니다.

@Html.CheckBoxForFlagEnum(m => m.SubscriptionKey.ExchangeKind, new {@disable = true})

ModelBinder

이렇게 Html 렌더링이 되었으면 모델에 올바르게 바인딩 되도록 구현해야 하는데 Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinder 인터페이스 구현으로 모델의 특정 속성이 바인딩 되는 처리를 직접 구현할 수 있습니다.
Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinder 인터페이스 Task BindModelAsync(ModelBindingContext bindingContext) 메서드의 bindingContext 파라메터를 통해 해당 속성의 메타정보를 읽어 올 수 있는데 해당 메타정보로 Enum인지 체크하고 Flags 계산으로 직접 속성의 결과를 처리할 수 있습니다.

결정된 속성 값은 Microsoft.AspNetCore.Mvc.ModelBinding.ModelBindingResult 구조체에 ModelBindingResult Success(object? model) 정적 메서드를 통해 바인딩 처리 됩니다.
이렇게 바인딩 처리 담당 FlagEnumModelBinder 클래스를 구현할 수 있습니다.

[FlagEnumModelBinder.cs]

public class FlagEnumModelBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        if (bindingContext is null)
        {
            throw new ArgumentNullException(nameof(bindingContext));
        }

        if (bindingContext.ModelMetadata.IsFlagsEnum is false)
        {
            throw new InvalidOperationException($"Cannot use custom model binder EnunMemberValueBinder for non FlagsEnum type: {bindingContext.ModelMetadata.ModelType.Name} on field {bindingContext.FieldName}");
        }

        var fieldName = bindingContext.FieldName;
        var valueProvider = bindingContext.ValueProvider.GetValue(fieldName);
        var value = valueProvider.ToArray();

        if (value is not null)
        {
            // In case it is a checkbox list/dropdownlist/radio button list
            if (value is string[])
            {
                // Create flag value from posted values
                var flagValue = ((string[])value).Aggregate(0, (current, v) => current | (int)Enum.Parse(bindingContext.ModelMetadata.ModelType, v));

                bindingContext.Result =
                    ModelBindingResult.Success(Enum.ToObject(bindingContext.ModelMetadata.ModelType, flagValue));
                return Task.CompletedTask;
            }
            // In case it is a single value
            if (value.GetType().IsEnum)
            {
                bindingContext.Result =
                    ModelBindingResult.Success(Enum.ToObject(bindingContext.ModelMetadata.ModelType, value));
                return Task.CompletedTask;
            }
        }

        // No binding action
        return Task.CompletedTask;
    }
}

이렇게 바인딩 처리 담당으로 구현한 FlagEnumModelBinder 클래스는 Microsoft.AspNetCore.Mvc.ModelBinderAttribute 어트리뷰트로 해당 모델 또는 속성에서 사용 할 수 있습니다.

public record SubscriptionKey : IEntity
{
    /// <summary>
    /// 지원 거래소 종류
    /// </summary>
    [Required]
    [ModelBinder(typeof(FlagEnumModelBinder), Name = "SubscriptionKey.ExchangeKind")]
    public EExchangeKind ExchangeKind { get; set; }
}

[결과 화면]
image

실제 Html은 다음과 같습니다.

<input checked="checked"
       disable="True"
       id="SubscriptionKey.ExchangeKind"
       name="SubscriptionKey.ExchangeKind"
       type="checkbox" value="Bitget">Bitget</input>
<br/>
<input disable="True"
       id="SubscriptionKey.ExchangeKind"
       name="SubscriptionKey.ExchangeKind"
       type="checkbox"
       value="Bybit">Bybit</input>
<br/>
<input checked="checked"
       disable="True"
       id="SubscriptionKey.ExchangeKind"
       name="SubscriptionKey.ExchangeKind"
       type="checkbox" value="BingX">BingX</input>
<br/>


Reference