アプリ開発ときどきアウトドア

主にJavaを使ったアプリ開発やトラブルシューティング等のノウハウ、キャンプや登山の紹介や体験談など。

1. システムエンジニアリング ASP.NET Core 実装技術

ASP.NET Core: エラーメッセージ一覧のカスタマイズ

投稿日:


ASP.NET Core標準のasp-validation-summary属性では単純なエラーメッセージの一覧しか出力しかできません。
ここでは、asp-validation-summary属性と同様なタグヘルパーを作成し、エラーメッセージ一覧の出力をカスタマイズします。

概要

ASP.NET Coreではasp-validation-summary属性を使うことで、検証属性のエラーメッセージをページ上部等に纏めて表示することができます。単純にエラーメッセージ一覧を表示するだけなら十分ですが、実際の業務では次の例のように条件によってエラーメッセージの出力を変更したい場合があります。
ここでは下記のEを例題としてに、独自のタグヘルパーを作成します。

  1. 分かりやすさ向上や注意喚起のための、メッセージ装飾のためにHTMLタグやCSSスタイルを指定したい。
  2. システムエラー時に問合せ画面へののリンクを付与したい。
  3. エラー以外に警告や情報レベルのメッセージを表示したい。
    (情報、警告、エラー等の各種のメッセージを単一部品で表示したい。)
  4. CSVアップロード画面等で大量のエラーメッセージが発生する場合、特定件数以上のメッセージは「他にもエラーがあります。」等のように省略したい。
  5. 入力項目が多い画面で、エラーメッセージをクリックしたら、その入力項目にフォーカスしたい。

前提

  • Visual Studio 2019 + ASP.NET Core 3.1(MVC + Razor)を使用します。
  • ASP.NET Core標準の検証属性を使用する前提です。
    サンプルでは日本語のエラーメッセージを表示していますが、こちらの記事の中で日本語化しています。
  • 実現方法としてASP.NET Coreのタグヘルパーを使用します。
    基本的な知識は下記のマイクロソフトの説明をご覧ください。
  • クライアント側の入力検証のエラーメッセージ出力は考慮しません。
    既存のasp-validation-summary属性はクライアント側の入力検証のエラーメッセージを一覧表示できるような考慮があります。クライアント側の入力検証を使うのであれば、入力項目から離れた一覧にエラーメッセージを表示するより、項目近辺にエラーメッセージを表示した方が分かりやすいためです。
    結果として、サーバ側の入力検証のエラーメッセージの表示を対象とします。
  • 可能な限りASP.NET Core標準のasp-validation-summary属性の動作に似せています。
    参考にしたソースは次の通りです。
    • ValidationSummaryTagHelper.cs: asp-validation-summary属性のソースコードです。メッセージ出力のほとんどの処理は次のDefaultHtmlGenerator.csクラスになります。
    • DefaultHtmlGenerator.cs: ページにHTMLやメッセージを出力する処理があります。
    • ValidationHelpers.cs: 出力対象とするメッセージ一覧(モデル状態エントリリスト)を作成する処理があります。

独自タグヘルパーの概要

入力項目へジャンプするリンクを持ったエラーメッセージ一覧を表示します。

  • 実行結果のイメージは下図左側のメッセージ一覧を想定しています。
    右側は、比較対象として既存のasp-validation-summary属性の実行結果です。
    エラーメッセージは「項目名: メッセージ」としています。本来であれば項目名は必要ありませんが、検証の分かりやすさのために表示しています。
  • タグヘルパーの属性名は、asp-validation-summary属性と同様に、div要素のasp-custom-validation-summary属性とし、次のように使う想定です。
    <div class="form-group row">
      <div class="col-sm-6">
        <div class="text-danger" asp-custom-validation-summary></div>
      </div>
      <div class="col-sm-6">
        <div class="text-danger" asp-validation-summary="All"></div>
      </div>
    </div>
    

    タグヘルパーで出力するHTMLイメージは次の通りです。
    入力項目へジャンプ(フォーカス移動)するためのjump関数を共通のjavascriptファイル等に定義しておく前提とし、タグヘルパーでエラーメッセージにjump関数(引数は入力項目のID属性の値)を出力します。

    <div class="text-danger validation-summary-errors">
      <ul class="error-link">
        <li>prop2: <a href="javascript: void(0);" onclick="jump('prop2');">''は無効です。</a></li>
      </ul>
    </div>
    

    jump関数の実装は次のようなコードを想定しています。
    実業務で使うなら、wwwwroot¥js¥site.js等の画面共通のスクリプトファイルを推奨します。

    <script>
        function jump(id) {
    
            if (!id) return false;
    
            const adjust = 0;
            const speed = 400;
            var target = $("#" + id);
            if (target.length > 0) {
                var position = target.offset().top + adjust;
                $('body,html').animate({ scrollTop: position }, speed, 'swing');
                target.focus();
            }
            return false;
        }
    </script>
    
  • エラーメッセージは紐づいている項目の有無に応じて次の2種類に分けられます。
    asp-validation-summary属性の場合、その属性値としてAll/ModelOnly/Noneのいずれかを指定することで、出力するエラー種類を決められますが、今回は固定で全ての種類を出力します。
    AddModelErrorの詳細はマイクロソフトのリファレンスを参照のこと。
    分類 説明
    プロパティエラー 特定の入力項目に紐づくエラーメッセージ。
    検証属性による入力エラーや、次のように独自に項目を指定して追加したメッセージ。
    ModelState.AddModelError(“prop1”, “エラーです。”);
    モデルエラー 権限不足、同時更新(排他)等のように特定の項目に依存しないようなエラーメッセージ。
    次のように項目に空を指定して追加されたメッセージ。
    (空を指定するのは間違いないのですが、それをstring.Emptyで表現するかは要検討です。
    nullを指定すると例外になります。)
    ModelState.AddModelError(string.Empty, “モデルエラーです。”);
  • エラーメッセージの取得方法を間違えると、エラーメッセージの出力順が画面の入力項目の順になりません。これは一般的な業務アプリの要件としてはNGだと思われるため、既存のasp-validation-summary属性のエラーメッセージ取得方法を参考にして実装しています。(詳細は後述の「モデル状態リスト取得」を参照のこと。)

独自タグヘルパーの実装

独自タグヘルパーとして、CustomValidationSummaryTagHelperというクラスを作成します。
量が多いため、分割して説明します。

クラス定義

TagHelperクラスを継承したクラスを定義します。
メイン処理はProcess()メソッドですが、その内容については後の節で説明します。

[HtmlTargetElement(MyElement, Attributes = MyAttributeName)]
public class CustomValidationSummaryTagHelper: TagHelper
{
    private const string MyElement = "div";
    private const string MyAttributeName = "asp-custom-validation-summary";

    [HtmlAttributeNotBound]
    [ViewContext]
    public ViewContext ViewContext { get; set; }

    [HtmlAttributeNotBound]
    protected IHtmlGenerator Generator { get; }

    private static ILogger<CustomValidationSummaryTagHelper> _logger;

    public CustomValidationSummaryTagHelper(
        IHtmlGenerator generator, 
        ILogger<CustomValidationSummaryTagHelper> logger)
    {
        Generator = generator;
        _logger = logger;
    }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        // 次節で説明
    }
...
  • 1行目: cshtml上で当該タグヘルパーを指定できる要素や属性名を[HtmlTargetElement]で定義します。
    ここではdiv要素のasp-custom-validation-summary属性を指定しています。(この属性がcshtmlで宣言された場合に、このタグヘルパーが実行される、の意)
  • 2行目: TagHelperを継承します。
  • 7,9行目: 要素の属性の値は、既定では属性名に対応するプロパティに格納されます。
    cshtml上の属性名はmail-to等のようにケバブケースで定義されている前提とし、この場合の属性値はMailToプロパティに格納されます。
    プロパティに属性値を格納したくない場合、[HtmlAttributeNotBound]を指定します。
    プロパティに対応する属性名を個別指定したい場合、[HtmlAttributeName]を指定することもできます。
  • 8,9行目: 検証結果の内容を確認するためにViewContextを参照する必要があります。
    [ViewContext]を指定することで、タグヘルパー実行時のViewContextが設定されるようになります。
    (ViewContextはHttpContext, HttpRequest, HttpResponse等のViewに関連する情報を提供します。)

HTMLの生成(Processメソッド)

ページに出力するHTMLを生成するメソッドです。
ここでは、ViewContextからエラーメッセージ一覧を取得し、それぞれのエラーメッセージをリスト(ul要素、li要素)としてページに出力します。

  • モデルやプロパティの検証結果はモデル状態(ModelStateEntryクラス)として表現されます。例えば、ある項目に対して複数のメッセージが生成されている場合、単一のモデル状態に複数のメッセージが含まれることになります。ここでは、このモデル状態単位で処理を行います。
  • HTML要素の作成はTagBuilderクラスを使用します。このメソッドの引数になっているoutput(TagHelperOutput型)に対して作成したHTML要素(TagBuilder)を設定することで、ページにHTML要素を出力できます。
public override void Process(TagHelperContext context, TagHelperOutput output)
{
    var viewData = ViewContext.ViewData;

    // プロパティ毎のモデル状態エントリのリストと
    // モデル状態エントリからプロパティ名を取得するためのディクショナリ作成
    var modelStates = GetModelStateList(viewData, true);
    var propDic = viewData.ModelState
        .ToDictionary(x => x.Value, y => y.Key); // swap key and value

    // 各モデルに含まれるエラー群を抽出してメッセージ要素を生成
    bool isHtmlSummaryModified = false;
    var htmlSummary = new TagBuilder("ul");
    htmlSummary.InnerHtml.AppendLine();
    htmlSummary.AddCssClass("error-link"); // 独自cssクラス(リンクを赤にする)
    foreach (var entry in modelStates)
    {
        // 対象のプロパティ名
        string name = propDic.GetValueOrDefault(entry) ?? string.Empty;
        name = name.Replace('.', '_').Replace('[', '_').Replace(']', '_');

        // 対象プロパティに紐づくエラーメッセージ処理
        foreach (var err in entry.Errors)
        {
            var htmlMessage = new TagBuilder("li");
            var msg = err.ErrorMessage;

            // ex. <li>[someprop]: <a href="..." onclick="...">メッセージ</a></li>
            htmlMessage.InnerHtml.Append($"{name}: ");
            if( !string.IsNullOrEmpty(name))
            {
                var htmlLink = new TagBuilder("a");
                htmlLink.InnerHtml.Append(msg);
                htmlLink.Attributes.Add("href", $"javascript: void(0);");
                htmlLink.Attributes.Add("onclick", $"jump('{name}');");
                htmlMessage.InnerHtml.AppendHtml(htmlLink);
            }
            else
            {
                htmlMessage.InnerHtml.Append(msg);
            }

            // サマリに追加
            htmlSummary.InnerHtml.AppendLine(htmlMessage);
            isHtmlSummaryModified = true;
        }
    }

    // 変更がない場合は何も出力しない
    if( !isHtmlSummaryModified)
    {
        output.SuppressOutput();
        return;
    }

    // トップレベル要素を仮作成して既存要素(output)にマージ
    var topTag = new TagBuilder(MyElement);
    topTag.AddCssClass(HtmlHelper.ValidationSummaryCssClassName);
    topTag.InnerHtml.AppendLine();
    topTag.InnerHtml.AppendHtml(htmlSummary);
    output.MergeAttributes(topTag);
    output.Content.AppendLine(topTag.InnerHtml);
    output.Attributes.Remove(new TagHelperAttribute(MyAttributeName));
}
...
  • 7行目: 後述のGetModelStateListメソッドによってモデル状態リストを取得します。
  • 8行目: モデル状態から項目名を取得するためのDictionaryを生成しています。
    今回の場合、エラーメッセージに対応する入力項目にフォーカスを設定するために、エラーメッセージに対応する項目名(input要素のIDやName属性値)をページに埋め込む必要があります。
    ViewContext.ViewData.ModelState(Dictionary型)にはキーが項目名・値がモデル状態(ModelStateEntry)が格納されているので、このDictionaryのキーと値を入れ替えたDictionaryを生成します。
  • 13行目: HTML要素の作成はTagBuilderを使用します。
    次のように要素の値を追加する場合は、InnerHtmlプロパティのAppend等のメソッド群を使用します。
    入力値やプレインテキストを出力したい場合、出力値がHTMLエスケープされるAppendメソッド(最後にHtmlが付かないメソッド)が安全です。HTML要素を直接出力したい場合はAppendHtmlメソッドを使う必要がありますが、XSS攻撃等を防止するために、出力する値を自身でHTMLエンコードする必要があります。
    HTML出力 コード例 備考
    <ul class="xxx"> var tag = new TagBuilder("ul");
    tag.AddCssClass("xxx");
        plain text tag.InnerHtml.Append("plain text"); 値はHTMLエンコードされる。
        <br /> tag.InnerHtml.AppendHtml("<br />"); HTMLエンコードされないので注意。
        <a ... /> tag.InnerHtml.AppendHtml(new TagBuilder("a")); ネスト可
    </ul>
  • 19,20行目: メッセージに対応する項目名を生成します。
    モデル・プロパティの定義によっては項目名に特殊文字が含まれる場合があり、ASP.NET Coreが生成する項目名と一致しない場合があります。HTML4/XHTMLではIDやName属性で使用できる文字種に制限があり、該当する文字をアンダースコア("_")に置き換えているためだと思われます。
    この問題を回避するために、最低限の特定文字("[].")を"_"に置き換えています。
    (配列型プロパティ使用時の"[]"、モデルのネスト時の".")
  • 30行目: プロパティエラーの場合はリンクを付与し、モデルエラーの場合はリンクなしにします。

モデル状態リスト取得

画面上の入力項目の順番に合わせてモデル状態リストを取得するためのメソッドです。

  • プロパティエラー、モデルエラーに対応する状態リストを取得します。プロパティエラーが不要(モデルエラーのみ)で良い場合は、引数のwithPropertyで指定できるようにしています。
  • モデル状態はViewContext.ViewData.ModelStateのDictionaryを使って取得することも可能ですが、画面の表示順通りにモデル状態を取得できません。これではエラーメッセージの表示順がバラバラになってしまい、業務的に困ります...
    一方で、モデル状態は親子関係のデータ構造になっており、このデータ構造を再帰的に順番に辿ることで画面の表示順の状態モデルのリストを取得します。(asp-validation-summary属性の実装を参考にしています。)
  • 最上位のViewContext.ViewData.ModelState.Rootのモデル状態(ModelStateEntry)を起点として、再帰的にモデル状態を取得します。
private List<ModelStateEntry> GetModelStateList(
    ViewDataDictionary viewData, bool withProperty = false)
{
    var entries = new List<ModelStateEntry>();
    var metadata = viewData.ModelMetadata;
    var modelState = viewData.ModelState;

    if (modelState.Count <= 0)
    {
        return entries;
    }

    // モデルエラーのみの場合は
    // プロパティがHtmlFieldPrefix(空値)になっているもののみを返却
    if (!withProperty)
    {
        if( modelState.TryGetValue(
            viewData.TemplateInfo.HtmlFieldPrefix, out var entry))
        {
            entries.Add(entry);
        }
        return entries;
    }

    // モデルがネストされる場合があるため、
    // 再帰的に全てのモデルを探索してエラー情報を収集
    Visit(modelState.Root, metadata, entries, metadata.Name);

    // 項目名が紐づかいないエラーがある場合は漏らさず追加
    if (entries.Count < modelState.Count)
    {
        foreach (var pair in modelState)
        {
            if (!entries.Contains(pair.Value))
            {
                entries.Add(pair.Value);
            }
        }
    }
    return entries;
}
  • 15-23行目: モデルエラーのみの取得で良い場合、そのためのメソッドでモデル状態リストを取得して返却します。
  • 27行目: モデル状態を再帰的に探索してモデル状態リストを作成します。このメソッドの内容は次に説明します。
  • 30-39行目: 実在しない項目名のメッセージが漏れないようにします。
    次のように実在しない項目名が設定されたメッセージは、モデル状態を再帰的に辿っても取得できません。ViewContext.ViewData.ModelStateのDictionaryには登録されているので、そこから取得したモデル状態をリストに追加して数合わせしています。
    ModelState.AddModelError("noexists", "エラーメッセージ")

モデル状態リスト取得(再帰処理)

モデル状態を再帰的に辿ってモデル状態リストを作成するメソッドです。

なお、name引数はどのような辿り方をするのかを追跡するためのデバッグ用であり不要にしても動作できます。(name引数を使えばASP.NETが生成する項目名を独自に生成することもできますが、ASP.NET Coreの実装と完全に一致させるのは大変なので使用していません。)

private void Visit(
    ModelStateEntry entry, 
    ModelMetadata metadata, 
    IList<ModelStateEntry> entries, string name)
{
    // 配列やリスト型のプロパティの場合、含まれる全てを再帰処理
    if (metadata.ElementMetadata != null && entry.Children != null)
    {
        for(int i = 0; i<entry.Children.Count; i++)
        {
            Visit(entry.Children[i], metadata.ElementMetadata, 
                entries, name + metadata.ElementMetadata.Name + $"[{i}]");
        }
    }

    // プロパティを含む場合はそれぞれに対して再帰処理
    for (var i = 0; i < metadata.Properties.Count; i++)
    {
        var propMetadata = metadata.Properties[i];
        var propModelStateEntry = entry.GetModelStateForProperty(propMetadata.PropertyName);
        if (propModelStateEntry != null)
        {
            string newName = 
                (!string.IsNullOrEmpty(name) ? name + "." : "") + propMetadata.Name;
            Visit(propModelStateEntry, propMetadata, entries, newName);
        }
    }

    // 対象がプロパティの場合はエントリ追加
    if (!entry.IsContainerNode)
    {
        _logger.LogTrace($"AddEntry[{name}]: Errors={entry.Errors.Count}");
        entries.Add(entry);
    }
}

独自タグヘルパーの使用

作成したタグヘルパーを使えるように宣言する必要があります。
プロジェクト全体で全てのタグヘルパーを参照できるよう、_ViewImports.cshtmlに宣言を追加します。
詳細はマイクロソフトのリファレンスをご覧ください。

@using ExampleWeb
@using ExampleWeb.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

// custom tag helpers
@addTagHelper *, ExampleWeb

ページで使用する場合、次のようにdiv要素にasp-custom-validation-summary属性を指定します。

@model ValidateSummaryModel

<div>
    <form asp-action="Regist">

        <h1>バリデーションテスト</h1>
        <p />
        <div class="form-group row">
            <div class="col-sm-6">
                <div class="text-danger" asp-custom-validation-summary></div>
            </div>
            <div class="col-sm-6">
                <div class="text-danger" asp-validation-summary="All"></div>
            </div>
        </div>
...


(adsbygoogle = window.adsbygoogle || []).push({});


(adsbygoogle = window.adsbygoogle || []).push({});

-1. システムエンジニアリング, ASP.NET Core, 実装技術

執筆者:

関連記事

ExcelからPowerPointへの図表貼り付けVBA

パフォーマンスモニタの監視データ(blg)に基づいてPowerPointで報告用のレポートを作成する必要がありました。パフォーマンスログのデータをCSVに変換してExcelに取り込んでグラフを作成し、 …

保守運用

運用 と 保守 の 違い

若い頃は 運用 と 保守 の違いを調べても良くわからなかった… この辺を使い分けられる人をほとんど見たことない… ある事項が運用なのか保守なのかの話をすると認識が合わない&#8 …

DB操作フレームワーク はJPA or mybatis?

開発に向けた準備で、開発標準を準備するフレームワーク(FW)チーム、それらを使って実装を行う業務チームが集まって、「DB操作を行うためのFWは何を使うか?」という協議になった。 FWチームは、FW・J …

Javaによるzipファイルの安全な解凍方法

以前、業務アプリ(Java)でzipファイルの操作が必要となったため、Javaにおけるzip圧縮解凍について調査しました。また、zip4jを使った圧縮・解凍についても説明しました。 ここでは、もう少し …

Javaでのパスワード付きzipファイルの圧縮/解凍方法(ZipCrypto/AES)

先日、JavaでのZIP暗号化の考察という記事を書きましたが、zip4jのメンテナンスが再開されており、バージョン2系が公開されていましたので、これを使って通常のzip圧縮/解凍、パスワード付きzip …

プロフィール ゆっきーです。
都内でシステムエンジニアをやっています。
もっと詳細を見る