PRG(Post Redirect Get)パターンのために、ModelStateやViewDataを持ち回りたい(ASP.NET 6 CORE MVC)

概要

PostからGetにModelStateを持ち回る場合、TempData(一時的なセッション)に格納するが、.NET COREではJson等でシリアライズしてからでないとTempDataに格納できなくなった。
ただしModelStateを直接シリアライズできない。デシリアライズする際に、ModelStateDictionaryがReadOnlyのためエラーが発生する。
なので、いったんModelStateをList<Entry>の形式(EntryはModelStateの値を保持するためのクラス。新しく作る)に変換してからシリアライズする。

前提

ModelStateを格納するためのTempDataを使うためには、Program.csでTempDataのセットアップを行う必要がある。

Program.cs (抜粋)

// MVCを追加する
var mvcBuilder = builder.Services.AddControllersWithViews();
// セッションベースのTempDataを有効にする
mvcBuilder.AddSessionStateTempDataProvider();

ModelStateのシリアライズ・デシリアライズ

◆ModelStateのシリアライズは以下のように行う。タイミングはPostからGetにリダイレクトする前。
※controllerはControllerクラス。EntryはModelStateEntry格納用のクラス。ソース全量は後述する。

// ModelStateをList<Entry>に変換する(デシリアライズ可能にするため)
List<Entry> modelStateList = controller.ModelState.ToList()
    .Select(state => new Entry(state)).ToList();
// セッション(TempData)に格納する
controller.TempData["tekitona_moji"] = JsonSerializer.Serialize(modelStateList);

◆ModelStateのデシリアライズは以下のように行う。タイミングはGetの処理が終わった後あたり。
※controllerはControllerクラス。EntryはModelStateEntry格納用のクラス。ソース全量は後述する。

// TempDataからJson文字列を取り出し、デシリアライズする
string? json = controller.TempData["tekitona_moji"] as string;
if (json != null)
{
    List<Entry>? modelStateList = JsonSerializer.Deserialize<List<Entry>>(json);
    if (modelStateList != null)
    {
        // 取得した値をModelStateに追加する
        modelStateList.ForEach(entry => entry.SetModelState(controller.ModelState));
    }
}

デシリアライズする際の注意(JsonElement型の注意)

ModelStateのModelStateEntry.RawValueには画面で入力した値が格納されている。
RawValueの型はobject型で、stringとstring[]とnullが設定される。
通常stringだが、同一名の項目(name属性が同じinputタグ)が複数あるとRawValueの型はstring[]になる。
例えばCheckBoxのHtmlヘルパー(@Html.CheckBox)はCheckBox用のInputタグとHidden用のInputタグを同一名で作成する。
その場合、RawValueはstring[]になり、CheckBoxの値とHiddenの値が配列で格納される。

object型に対してJsonのデシリアライズを行うと、JsonElement型が割り当てられてしまう(JsonSerializerを使った場合。JsonConvertの場合はJArray)。
RawValueがstring型だった場合は問題ないが、string[]型だった場合、JsonElementのArray型でデシリアライズされ、デシリアライズされたModelStateの値をHtmlヘルパーやタグヘルパーでinputタグに設定する際にJsonElementのArray型をstringにコンバートできずに例外が発生してしまう。
※JsonElementのstring型はコンバートできる模様(エラーにならずに画面に反映される)

デシリアライズする際は、JsonElementをstringやstring[]に変換しなおす必要がある。
※ソースは後述

ViewDataのシリアライズ・デシリアライズ

ViewDataはDictionaryに変換してからシリアライズする。

◆ViewDataのシリアライズは以下のように行う。タイミングはPostからGetにリダイレクトする前。
※controllerはControllerクラス。EntryはModelStateEntry格納用のクラス。ソース全量は後述する。

// ViewDataをデシリアライズ可能な形式に変換する
Dictionary<string, object?> dicViewData 
    = controller.ViewData.ToDictionary(m => m.Key, m => m.Value);

// TempDataに格納する
controller.TempData["tekitona_moji2"] = JsonSerializer.Serialize(dicViewData);

◆ViewDataのデシリアライズは以下のように行う。タイミングはGetの処理が終わった後あたり。
※controllerはControllerクラス。EntryはModelStateEntry格納用のクラス。ソース全量は後述する。
※ViewDataに配列を格納する場合はデシリアライズ時にJsonElementをstring[]などの配列に置き換える(以下のソースは配列の想定なし)

// TempDataからJson文字列を取り出し、デシリアライズする
string? json = controller.TempData["tekitona_moji2"] as string;
if (json != null)
{
    /// ViewDataに配列を格納する場合はJsonElementから型の変更を行う
    Dictionary<string, object?>? dicViewData 
        = JsonSerializer.Deserialize<Dictionary<string, object?>>(json);
    if (dicViewData != null)
    {
        // 取得した値をViewDataに追加する
        dicViewData.ToList().ForEach(obj => controller.ViewData.Add(obj));

    }
}

ソース全量

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using System.Text.Json;

namespace TestWebCore3.Filters
{
    /// <summary>
    /// ModelState引き継ぎクラス
    /// </summary>
    public class ModelStateHandover
    {
        protected internal class Entry
        {
            public string? Key { get; set; }
            public object? RawValue { get; set; }
            public string? AttemptedValue { get; set; }
            public ModelValidationState? State { get; set; }
            public IEnumerable<string>? Errors { get; set; }

            /// <summary>
            /// Json複合化用のコンストラクタ
            /// </summary>
            public Entry()
            {

            }

            /// <summary>
            /// コンストラクタ
            /// </summary>
            /// <param name="entry">ModelStateEntry(ModelState1件分)</param>
            public Entry(KeyValuePair<string, ModelStateEntry> entry)
            {
                Key = entry.Key;
                RawValue = entry.Value.RawValue;
                AttemptedValue = entry.Value.AttemptedValue;
                State = entry.Value.ValidationState;
                Errors = entry.Value.Errors?.Select(x => x.ErrorMessage) ?? Enumerable.Empty<string>();
            }

            /// <summary>
            /// ModelStateに値を設定する
            /// </summary>
            /// <param name="modelStateDictionary">設定先のModelState</param>
            /// <returns>パラメータのModelState</returns>
            public ModelStateDictionary SetModelState(ModelStateDictionary modelStateDictionary)
            {
                ArgumentNullException.ThrowIfNull(this.Key);

                object? value = this.RawValue;
                // デシリアライズした際にobject型はJsonElementになってしまうため、stringやstring[]に直す
                // これをやらないとHtmlヘルパーやタグヘルパーでModelStateの値をConvertできずに例外が発生する
                // (特にstring[]の場合)
                if (this.RawValue != null && this.RawValue.GetType() == typeof(JsonElement))
                {
                    JsonElement jsonElement = (JsonElement)this.RawValue;
                    switch (jsonElement.ValueKind)
                    {
                        case JsonValueKind.Array:
                            string[] strArray = new string[jsonElement.GetArrayLength()];
                            int i = 0;
                            foreach (var val in jsonElement.EnumerateArray())
                            {
                                strArray[i] = val.GetString() ?? "";
                                i++;
                            }
                            value = strArray;
                            break;
                        case JsonValueKind.String:
                            value = jsonElement.GetString();
                            break;
                        case JsonValueKind.Null:
                        case JsonValueKind.Undefined:
                            value = null;
                            break;
                        default:
                            // ModelStateは、nullかstringかstring[]しかないはず。
                            // 入力チェック前の値を保持するため型に当てはまらない入力
                            // (数字項目にアルファベットなど)
                            // をした際に値を保持するためstringで保持しているはず。
                            value = jsonElement.ToString();
                            break;
                    }
                }
                // ModelStateに追加する
                modelStateDictionary.SetModelValue(this.Key, value, this.AttemptedValue);
                if (this.Errors != null)
                {
                    foreach (var error in this.Errors)
                    {
                        modelStateDictionary.AddModelError(this.Key, error);
                    }
                }
                return modelStateDictionary;
            }

        }

        /// <summary>
        /// ModelState退避
        /// </summary>
        /// <param name="controller">コントローラ</param>
        public void PushModelState(Controller controller)
        {
            // ModelStateをList<Entry>に変換する(デシリアライズ可能にするため)
            List<Entry> modelStateList = controller.ModelState.ToList()
                .Select(state => new Entry(state)).ToList();
            // セッション(TempData)に格納する
            controller.TempData["tekitona_moji"] = JsonSerializer.Serialize(modelStateList);

        }

        /// <summary>
        /// ModelState復帰
        /// </summary>
        /// <param name="controller">コントローラ</param>
        public void PopModelState(Controller controller)
        {

            // TempDataからJson文字列を取り出し、デシリアライズする
            string? json = controller.TempData["tekitona_moji"] as string;
            if (json != null)
            {
                List<Entry>? modelStateList = JsonSerializer.Deserialize<List<Entry>>(json);
                if (modelStateList != null)
                {
                    // 取得した値をModelStateに追加する
                    modelStateList.ForEach(entry => entry.SetModelState(controller.ModelState));
                }
            }
        }

        /// <summary>
        /// ViewData退避
        /// </summary>
        /// <param name="controller">コントローラ</param>
        public void PushViewData(Controller controller)
        {
            // ViewDataをデシリアライズ可能な形式に変換する
            Dictionary<string, object?> dicViewData 
                = controller.ViewData.ToDictionary(m => m.Key, m => m.Value);

            // TempDataに格納する
            controller.TempData["tekitona_moji2"] = JsonSerializer.Serialize(dicViewData);
        }

        /// <summary>
        /// ViewData復帰
        /// </summary>
        /// <param name="controller">コントローラ</param>
        public void PopViewData(Controller controller)
        {
            // TempDataからJson文字列を取り出し、デシリアライズする
            string? json = controller.TempData["tekitona_moji2"] as string;
            if (json != null)
            {
                /// ViewDataに配列を格納する場合はJsonElementから型の変更を行う
                Dictionary<string, object?>? dicViewData 
                    = JsonSerializer.Deserialize<Dictionary<string, object?>>(json);
                if (dicViewData != null)
                {
                    // 取得した値をViewDataに追加する
                    dicViewData.ToList().ForEach(obj => controller.ViewData.Add(obj));

                }
            }
        }
    }
}

おまけ(CheckBoxのHidden)

CheckBoxのHiddenだが、これはCheckBox単体だと、CheckBoxがチェックされなかった際に値がリクエストに含まれないため、Hiddenを追加してチェックされない場合でも値がリクエストに含まれるようにしている。
つまりCheckBoxがチェックされた場合は、CheckBox分とHidden分の2つの値がリクエストに含まれ、モデルバインドによって先頭の値(CheckBoxのチェック時の値)が変数にバインドされる。
CheckBoxがチェックされなかった場合は、Hiddenの1つ分の値のみがリクエストに含まれる。
そもそもチェックされなかった場合の値が不要な場合(リクエストに値が含まれていなければプログラム側で未チェック時の値を設定する)、Program.csのConfigurationオプションでHidden項目を出力しない設定を行う。

buider.Services.Configure
{
options.HtmlHelperOptions.CheckBoxHiddenInputRenderMode = CheckBoxHiddenInputRenderMode.None;
});