カスタムHTMLヘルパーの移行 (ASP.NET Framework4からASP.NET 6 CORE MVC)

概要

ASP.NET 6 COREでもHTMLヘルパーは引き続き使えますが、一部書き方が変更になっています。
ASP.NET COREでHTMLヘルパーを作成する際に主に以下の処理についてどのように書き換えるかを説明します。
・ラムダ式からのデータ取得
・ModelStateの考慮
・Client Side Validationの出力
・Htmlヘルパーで複数のタグを返却する方法

Htmlヘルパーの作成

詳細はソースのコメントを参照してもらうとして、ポイントは以下になります。

  • ModelMetadata.FromLambdaExpressionやExpressionHelper.GetExpressionTextは廃止になり、ModelExpressionProviderを使うようになりました。
  • GetUnobtrusiveValidationAttributesは廃止になり、ValidationHtmlAttributeProviderを使うようになりました。
  • 戻り値の型がMvcHtmlStringからIHtmlContentに変更になりました。
  • TagBuilderをStringに変更する方法が変わりました(ToStringではなくWriteTo)。

SampleTextHelper.cs
※コントローラーやモデルなどのサンプルは、「カスタムタグヘルパーの色々な出力方法」を参照してください。

#nullable enable

using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using System.Linq.Expressions;

namespace TestWebCore3.Helpers
{
    /// <summary>
    /// ただテキストボックスを出力するだけのヘルパー
    /// 値はラムダ式で受け取り、ModelStateにデータがあったらModelStateの値を使用する
    /// Client Side Validation用の文字列を出力する
    /// </summary>
    public static class SampleTextHelper
    {
        /// <summary>
        /// テキストボックス出力(ラムダ式だけ)
        /// </summary>
        /// <typeparam name="TModel">モデルの型</typeparam>
        /// <param name="htmlHelper">Htmlヘルパー</param>
        /// <param name="expression">ラムダ式</param>
        /// <returns></returns>
        public static IHtmlContent SampleTextFor<TModel>(this IHtmlHelper<TModel> htmlHelper
            , Expression<Func<TModel, string?>> expression)
        {
            return SampleTextFor(htmlHelper, expression
                , HtmlHelper.AnonymousObjectToHtmlAttributes(null));
        }

        /// <summary>
        /// テキストボックス出力(ラムダ+ダイナミックオブジェクトでHtml属性を渡す)
        /// </summary>
        /// <typeparam name="TModel">モデルの型</typeparam>
        /// <param name="htmlHelper">Htmlヘルパー</param>
        /// <param name="expression">ラムダ式</param>
        /// <param name="htmlAttributes">HTML属性(dynamic object)</param>
        /// <returns></returns>
        public static IHtmlContent SampleTextFor<TModel>(this IHtmlHelper<TModel> htmlHelper
            , Expression<Func<TModel, string?>> expression, object htmlAttributes)
        {
            // HtmlHelper.AnonymousObjectToHtmlでdynamic objectをIDictionaryに変換できる
            // cshtmlからHelperに引数を渡す際にIDictionaryは渡しにくいのでdynamic objectで
            // 渡す
            return SampleTextFor(htmlHelper, expression
                , HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes));
        }

        /// <summary>
        /// テキストボックス出力(ラムダ+IDictonary)
        /// </summary>
        /// <typeparam name="TModel">モデルの型</typeparam>
        /// <param name="htmlHelper">Htmlヘルパー</param>
        /// <param name="expression">ラムダ式</param>
        /// <param name="htmlAttributes">HTML属性(IDictionary)</param>
        /// <returns></returns>
        /// <exception cref="NullReferenceException"></exception>
        public static IHtmlContent SampleTextFor<TModel>(
              this IHtmlHelper<TModel> htmlHelper
            , Expression<Func<TModel, string?>> expression
            , IDictionary<string, object?> htmlAttributes)
        {

            // ラムダ式から値を取得する

            // .Net Framework4 MVC5
            // ModelMetadata metadata = ModelMetadata.FromLambdaExpression(
            //      expression, htmlHelper.ViewData);

            // .NET CORE6 MVC
            // --------------------------------------------
            // ラムダ式からモデルのメタデータと値を取得する
            // --------------------------------------------
            // ModelexpressionProviderをServiceProviderから取得する
            ModelExpressionProvider? modelExpressionProvider 
                = (ModelExpressionProvider ?)htmlHelper.ViewContext?.HttpContext?
                    .RequestServices?.GetService(typeof(IModelExpressionProvider));

            // ラムダ式からモデルのメタデータを取得する
            ModelExpression? metadata = modelExpressionProvider?
                    .CreateModelExpression(htmlHelper.ViewData, expression);
            if (metadata == null)
            {
                throw new NullReferenceException("metadata do not get.");
            }

            string? value = null;

            // ラムダ式からモデルの値を取得する
            if (metadata.Model != null)
            {
                value = (string)metadata.Model;
            }

            // ラムダ式からモデルの名前を取得する

            // .NET Framework4では、モデルの名前は
            // ExpressionHelper.GetExpressionText(expression) で取得していた
            string? name = modelExpressionProvider?.GetExpressionText(expression);
            if (name == null)
            {
                throw new NullReferenceException("name do not get.");
            }
            return SimpleTextHelperCore(htmlHelper, metadata, name, value, htmlAttributes);
        }

        /// <summary>
        /// テキストボックス出力(メイン)
        /// </summary>
        /// <param name="htmlHelper">HTMLヘルパー</param>
        /// <param name="metadata">モデルメタデータ</param>
        /// <param name="name">モデル名前</param>
        /// <param name="value">モデル値</param>
        /// <param name="htmlAttributes">HTML属性</param>
        /// <returns>出力タグ</returns>
        private static IHtmlContent SimpleTextHelperCore(IHtmlHelper htmlHelper
            , ModelExpression metadata, string name, string? value
            , IDictionary<string, object?> htmlAttributes)
        {
            //// このサンプルの内容だけならば、以下のクラスで出力することもできる
            //IHtmlGenerator? generator =
            //    (IHtmlGenerator?)htmlHelper.ViewContext.HttpContext
            //    .RequestServices.GetService(typeof(IHtmlGenerator));
            //if (generator != null)
            //{
            //    TagBuilder inputTag = generator.GenerateTextBox(htmlHelper.ViewContext
            //        , metadata.ModelExplorer, metadata.Name, metadata.Model, "", htmlAttributes);
            //}


            // FullNameを取得する
            // TemplateInfoにHtmlFieldPrefixを設定した場合はPrefix+nameになる
            // 設定していない場合はfullName = name
            string fullName = htmlHelper.ViewContext.ViewData.TemplateInfo
                .GetFullHtmlFieldName(name);

            // Inputタグを生成する
            TagBuilder tagBuilder = new TagBuilder("input");
            tagBuilder.MergeAttributes(htmlAttributes);
            tagBuilder.MergeAttribute("type", "text", replaceExisting: true);
            tagBuilder.MergeAttribute("name", fullName, replaceExisting: true);

            string? valueParameter = value;

            // 本当はViewDataからも値を取得するがViewDataに値を設定するメリットが
            // あまり見いだせないので対応しない

            // モデルステートから値を取得する
            string? modelStateValue = GetModelStateValue(htmlHelper, fullName);
            if (modelStateValue != null)
            {
                // モデルステートに値があれば優先して使用する
                valueParameter = modelStateValue;
            }
            // 値を設定
            tagBuilder.MergeAttribute("value", valueParameter, replaceExisting: true);

            // Idを付与
            tagBuilder.GenerateId(fullName, "_");

            // ModelStateにエラーがある場合はテキストボックスに
            // 「input-validation-error」のclassを設定する
            ModelStateEntry? modelState;
            if (htmlHelper.ViewData.ModelState.TryGetValue(fullName, out modelState))
            {
                if (modelState.Errors.Count > 0)
                {
                    tagBuilder.AddCssClass(HtmlHelper.ValidationInputCssClassName);
                }
            }
            // ASP.NET Framework4
            //tagBuilder.MergeAttribute(htmlHelper
            //      .GetUnobtrusiveValidationAttributes(name, metadata));

            // Client Side Validationの属性を出力するために
            // ValidationHtmlAttributeProviderを取得する
            ValidationHtmlAttributeProvider? attributeProvider =
                    (ValidationHtmlAttributeProvider ?)htmlHelper.ViewContext.HttpContext
                    .RequestServices.GetService(typeof(ValidationHtmlAttributeProvider));

            if (attributeProvider != null)
            {
                // Client Side Validationの属性を付与する
                attributeProvider.AddAndTrackValidationAttributes(
                    htmlHelper.ViewContext, metadata.ModelExplorer, name
                    , tagBuilder.Attributes);
            }


            // inputタグを出力する
            return tagBuilder;


            // 複数のタグを返却する場合はWriteToを使ってStringWriterに出力し
            // HtmlStringでIHtmlContentに戻して返却する
            //var writer = new StringWriter();
            //tagBuilder.WriteTo(writer, HtmlEncoder.Default);
            //hiddenInput.WriteTo(writer, HtmlEncoder.Default);
            //return new HtmlString(writer.ToString());
        }

        /// <summary>
        /// ModelStateの値を取得する
        /// </summary>
        /// <param name="htmlHelper">Htmlヘルパー</param>
        /// <param name="key">項目名</param>
        /// <returns>値</returns>
        private static string? GetModelStateValue(this IHtmlHelper htmlHelper, string key)
        {
            ModelStateEntry? modelState;
            if (htmlHelper.ViewData.ModelState.TryGetValue(key, out modelState))
            {
                // ModelStateに値が設定されている場合
                if (modelState.RawValue != null)
                {
                    // string配列の場合は先頭を返却する
                    // (同名の項目が複数あると配列で設定される。その場合は先頭を取得する
                    if (modelState.RawValue.GetType() == typeof(string[]))
                    {
                        return ((string[])modelState.RawValue)[0];
                    }
                    else if (modelState.RawValue.GetType() == typeof(string))
                    {
                        return (string)modelState.RawValue;
                    }
                    return modelState.RawValue.ToString();
                }
            }
            return null;
        }

    }
}

Index.cshtml

@model TestWebCore3.Models.Test.ViewModel.TestViewModel
@using TestWebCore3.Helpers


@section scripts {
    @* Jquery.jsは_Layout.cshtmlで読込み済の想定 *@
    @* Client Side Validationを有効にするために下記2つのJSを読み込んでいる *@
    <script src="~/lib/jquery-validation/dist/jquery.validate.js"></script>
    <script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js"></script>
}

<form asp-action="Check" asp-controller="Test">
    @* エラーを出力する *@
    @Html.ValidationSummary()

    @Html.SampleTextFor(m => m.TestId, new {tabindex=1, style="width:100px;"})

    <input type="submit" value="クリック" class="btn btn-default" />
</form>

出力結果(抜粋)

<form action="/Test/Check" method="post" novalidate="novalidate">
    <div class="validation-summary-valid" data-valmsg-summary="true">
        <ul><li style="display:none"></li></ul>
    </div>

    <input data-val="true" data-val-required="テストIDを入力してください" id="TestId" name="TestId" style="width:100px;" tabindex="1" type="text" value="">

    <input type="submit" value="クリック" class="btn btn-default">
    <input name="__RequestVerificationToken" type="hidden" value="CfDJ8NoLZtPk-fNLjKQV2jeAXteeKJUx3i3eD-FlED89PfH8e4UMs7NznC5ADTrWRRmBMj3P_dT7vgGF_9LdwUbLp0zouZrj5tfchGfUwpZB4JVYgUcMzkHTpFYBYTBQ4tohMunxdArrJnEBPy9bohDLKGw">
</form>

複数のタグを返却する方法

ヘルパーの戻り値がIHtmlContentにかわったため、ToStringでタグを出力できなくなりました。
TagBuilder.WriteToでタグの文字列をいったんStringWriterに出力し、HtmlStringクラスをnewすることでStringWriterの文字列をIHtmlContentに戻して返却します。
複数のタグを返却したい場合は、TagBuilder.WriteToをタグの数分行ったあとにStringWriterからIHtmlContentを生成します。
下記サンプルでは、上記のサンプルを修正して、ラベルとテキストの2つのタグを出力します。
ラベルはIHtmlGeneratorクラスを使って標準のラベルタグを出力します。

SampleTextHelper.cs

#nullable enable

using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using System.Linq.Expressions;
using System.Text.Encodings.Web;

namespace TestWebCore3.Helpers
{
    /// <summary>
    /// ただテキストボックスを出力するだけのヘルパー
    /// 値はラムダ式で受け取り、ModelStateにデータがあったらModelStateの値を使用する
    /// Client Side Validation用の文字列を出力する
    /// </summary>
    public static class SampleTextHelper
    {
        /// <summary>
        /// テキストボックス出力(ラムダ式だけ)
        /// </summary>
        /// <typeparam name="TModel">モデルの型</typeparam>
        /// <param name="htmlHelper">Htmlヘルパー</param>
        /// <param name="expression">ラムダ式</param>
        /// <returns></returns>
        public static IHtmlContent SampleTextFor<TModel>(this IHtmlHelper<TModel> htmlHelper
            , Expression<Func<TModel, string?>> expression)
        {
            return SampleTextFor(htmlHelper, expression
                , HtmlHelper.AnonymousObjectToHtmlAttributes(null));
        }

        /// <summary>
        /// テキストボックス出力(ラムダ+ダイナミックオブジェクトでHtml属性を渡す)
        /// </summary>
        /// <typeparam name="TModel">モデルの型</typeparam>
        /// <param name="htmlHelper">Htmlヘルパー</param>
        /// <param name="expression">ラムダ式</param>
        /// <param name="htmlAttributes">HTML属性(dynamic object)</param>
        /// <returns></returns>
        public static IHtmlContent SampleTextFor<TModel>(this IHtmlHelper<TModel> htmlHelper
            , Expression<Func<TModel, string?>> expression, object htmlAttributes)
        {
            // HtmlHelper.AnonymousObjectToHtmlでdynamic objectをIDictionaryに変換できる
            // cshtmlからHelperに引数を渡す際にIDictionaryは渡しにくいのでdynamic objectで
            // 渡す
            return SampleTextFor(htmlHelper, expression
                , HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes));
        }

        /// <summary>
        /// テキストボックス出力(ラムダ+IDictonary)
        /// </summary>
        /// <typeparam name="TModel">モデルの型</typeparam>
        /// <param name="htmlHelper">Htmlヘルパー</param>
        /// <param name="expression">ラムダ式</param>
        /// <param name="htmlAttributes">HTML属性(IDictionary)</param>
        /// <returns></returns>
        /// <exception cref="NullReferenceException"></exception>
        public static IHtmlContent SampleTextFor<TModel>(
              this IHtmlHelper<TModel> htmlHelper
            , Expression<Func<TModel, string?>> expression
            , IDictionary<string, object?> htmlAttributes)
        {

            // ラムダ式から値を取得する

            // .Net Framework4 MVC5
            // ModelMetadata metadata = ModelMetadata.FromLambdaExpression(
            //      expression, htmlHelper.ViewData);

            // .NET CORE6 MVC
            // --------------------------------------------
            // ラムダ式からモデルのメタデータと値を取得する
            // --------------------------------------------
            // ModelexpressionProviderをServiceProviderから取得する
            ModelExpressionProvider? modelExpressionProvider 
                = (ModelExpressionProvider ?)htmlHelper.ViewContext?.HttpContext?
                    .RequestServices?.GetService(typeof(IModelExpressionProvider));

            // ラムダ式からモデルのメタデータを取得する
            ModelExpression? metadata = modelExpressionProvider?
                    .CreateModelExpression(htmlHelper.ViewData, expression);
            if (metadata == null)
            {
                throw new NullReferenceException("metadata do not get.");
            }

            string? value = null;

            // ラムダ式からモデルの値を取得する
            if (metadata.Model != null)
            {
                value = (string)metadata.Model;
            }

            // ラムダ式からモデルの名前を取得する

            // .NET Framework4では、モデルの名前は
            // ExpressionHelper.GetExpressionText(expression) で取得していた
            string? name = modelExpressionProvider?.GetExpressionText(expression);
            if (name == null)
            {
                throw new NullReferenceException("name do not get.");
            }
            return SimpleTextHelperCore(htmlHelper, metadata, name, value, htmlAttributes);
        }

        /// <summary>
        /// テキストボックス出力(メイン)
        /// </summary>
        /// <param name="htmlHelper">HTMLヘルパー</param>
        /// <param name="metadata">モデルメタデータ</param>
        /// <param name="name">モデル名前</param>
        /// <param name="value">モデル値</param>
        /// <param name="htmlAttributes">HTML属性</param>
        /// <returns>出力タグ</returns>
        private static IHtmlContent SimpleTextHelperCore(IHtmlHelper htmlHelper
            , ModelExpression metadata, string name, string? value
            , IDictionary<string, object?> htmlAttributes)
        {
            //// このサンプルの内容だけならば、以下のクラスで出力することもできる
            //IHtmlGenerator? generator =
            //    (IHtmlGenerator?)htmlHelper.ViewContext.HttpContext
            //    .RequestServices.GetService(typeof(IHtmlGenerator));
            //if (generator != null)
            //{
            //    TagBuilder inputTag = generator.GenerateTextBox(htmlHelper.ViewContext
            //        , metadata.ModelExplorer, metadata.Name, metadata.Model, "", htmlAttributes);
            //}


            // FullNameを取得する
            // TemplateInfoにHtmlFieldPrefixを設定した場合はPrefix+nameになる
            // 設定していない場合はfullName = name
            string fullName = htmlHelper.ViewContext.ViewData.TemplateInfo
                .GetFullHtmlFieldName(name);

            // Inputタグを生成する
            TagBuilder tagBuilder = new TagBuilder("input");
            tagBuilder.MergeAttributes(htmlAttributes);
            tagBuilder.MergeAttribute("type", "text", replaceExisting: true);
            tagBuilder.MergeAttribute("name", fullName, replaceExisting: true);

            string? valueParameter = value;

            // 本当はViewDataからも値を取得するがViewDataに値を設定するメリットが
            // あまり見いだせないので対応しない

            // モデルステートから値を取得する
            string? modelStateValue = GetModelStateValue(htmlHelper, fullName);
            if (modelStateValue != null)
            {
                // モデルステートに値があれば優先して使用する
                valueParameter = modelStateValue;
            }
            // 値を設定
            tagBuilder.MergeAttribute("value", valueParameter, replaceExisting: true);

            // Idを付与
            tagBuilder.GenerateId(fullName, "_");

            // ModelStateにエラーがある場合はテキストボックスに
            // 「input-validation-error」のclassを設定する
            ModelStateEntry? modelState;
            if (htmlHelper.ViewData.ModelState.TryGetValue(fullName, out modelState))
            {
                if (modelState.Errors.Count > 0)
                {
                    tagBuilder.AddCssClass(HtmlHelper.ValidationInputCssClassName);
                }
            }
            // ASP.NET Framework4
            //tagBuilder.MergeAttribute(htmlHelper
            //      .GetUnobtrusiveValidationAttributes(name, metadata));

            // Client Side Validationの属性を出力するために
            // ValidationHtmlAttributeProviderを取得する
            ValidationHtmlAttributeProvider? attributeProvider =
                    (ValidationHtmlAttributeProvider ?)htmlHelper.ViewContext.HttpContext
                    .RequestServices.GetService(typeof(ValidationHtmlAttributeProvider));

            if (attributeProvider != null)
            {
                // Client Side Validationの属性を付与する
                attributeProvider.AddAndTrackValidationAttributes(
                    htmlHelper.ViewContext, metadata.ModelExplorer, name
                    , tagBuilder.Attributes);
            }

            // ラベルを生成
            // IHtmlGeneratorを取得する
            IHtmlGenerator? generator =
                (IHtmlGenerator?)htmlHelper.ViewContext.HttpContext
                .RequestServices.GetService(typeof(IHtmlGenerator));
            if (generator == null)
            {
                throw new NullReferenceException("IHtmlGenerator is null.");
            }
            TagBuilder labelTag = generator.GenerateLabel(htmlHelper.ViewContext
                , metadata.ModelExplorer, fullName, metadata.ModelExplorer.Metadata.DisplayName, null);


            // 複数のタグを返却する場合はWriteToを使ってStringWriterに出力し
            // HtmlStringでIHtmlContentに戻して返却する
            var writer = new StringWriter();
            labelTag.WriteTo(writer, HtmlEncoder.Default);
            tagBuilder.WriteTo(writer, HtmlEncoder.Default);
            return new HtmlString(writer.ToString());
        }

        /// <summary>
        /// ModelStateの値を取得する
        /// </summary>
        /// <param name="htmlHelper">Htmlヘルパー</param>
        /// <param name="key">項目名</param>
        /// <returns>値</returns>
        private static string? GetModelStateValue(this IHtmlHelper htmlHelper, string key)
        {
            ModelStateEntry? modelState;
            if (htmlHelper.ViewData.ModelState.TryGetValue(key, out modelState))
            {
                // ModelStateに値が設定されている場合
                if (modelState.RawValue != null)
                {
                    // string配列の場合は先頭を返却する
                    // (同名の項目が複数あると配列で設定される。その場合は先頭を取得する
                    if (modelState.RawValue.GetType() == typeof(string[]))
                    {
                        return ((string[])modelState.RawValue)[0];
                    }
                    else if (modelState.RawValue.GetType() == typeof(string))
                    {
                        return (string)modelState.RawValue;
                    }
                    return modelState.RawValue.ToString();
                }
            }
            return null;
        }

    }
}

出力結果(抜粋)

<form action="/Test/Check" method="post" novalidate="novalidate">
    <div class="validation-summary-valid" data-valmsg-summary="true">
        <ul><li style="display:none"></li></ul>
    </div>

    <label for="TestId">テストID</label>
    <input data-val="true" data-val-required="テストIDを入力してください" id="TestId" name="TestId" style="width:100px;" tabindex="1" type="text" value="">

    <input type="submit" value="クリック" class="btn btn-default">
    <input name="__RequestVerificationToken" type="hidden" value="CfDJ8NoLZtPk-fNLjKQV2jeAXteeKJUx3i3eD-FlED89PfH8e4UMs7NznC5ADTrWRRmBMj3P_dT7vgGF_9LdwUbLp0zouZrj5tfchGfUwpZB4JVYgUcMzkHTpFYBYTBQ4tohMunxdArrJnEBPy9bohDLKGw">
</form>