WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

JSON シリアライズの際の循環参照エラー

by WebSurfer 8. March 2020 15:51

.NET Framework の ASP.NET MVC アプリや Web API アプリでオブジェクトを JSON 文字列にシリアライズするときの循環参照エラーの問題とその回避方法を書きます。

循環参照エラー

例えば、Entity Framework 6 の Code First 機能を利用して、以下のコード(Microsoft のチュートリアル「新しいデータベースへの Code First」のサンプルコードです)からデーターベースを生成し、Linq to Entities で取得したデータを JSON にシリアライズするケースを考えます。

public class Blog
{
    public int BlogId { get; set; }
    public string Name { get; set; }

    public virtual List<Post> Posts { get; set; }
}

public class Post
{
    public int PostId { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }

    public int BlogId { get; set; }
    public virtual Blog Blog { get; set; }
}

public class BloggingContext : DbContext
{
    public DbSet<Blog> Blogs { get; set; }
    public DbSet<Post> Posts { get; set; }
}

以下のコードのように、Linq to Enitities を使ってデータベースからデータを取得し、ASP.NET MVC5 の Controller.Json メソッドを使って JSON 文字列にシリアライズしてみます。

using System.Collections.Generic;
using System.Linq;
using System.Web.Mvc;
using Mvc5App.DAL;
using System.Data.Entity;

namespace Mvc5App.Controllers
{
    public class HomeController : Controller
    {
        public ActionResult Json()
        {
            var db = new BloggingContext();
            var list = db.Blogs.Include(b => b.Posts);
            return Json(list, JsonRequestBehavior.AllowGet);
        }
    }
}

上のアクションメソッド Json を呼び出すと、この記事の上の画像の通り、循環参照エラーになります。

(注: ちなみに、上のコードを var list = db.Blogs; に変えるとシリアライズの際に遅延ローディングが起こって "この Command に関連付けられている DataReader が既に開かれています。このコマンドを最初に閉じる必要があります" というエラーになります。var list = db.Blogs.ToList(); とすると、シリアライズの際に遅延ローディングが起こるのは同じですが、その時は DataReader が閉じているので循環参照のところまで進んで、上の画像と同じエラーになります)

ASP.NET Web API でも同様で、return db.Blogs.Include(b => b.Posts); とするとシリアライズする際に循環参照が生じて JSON へのシリアライズに失敗します。

原因は Post クラスにナビゲーションプロパティ Blog が定義されているためで、それをシリアライズしようとすると循環参照が発生し InvalidOperationException がスローされるからだそうです。ググってヒットした記事「ASP.NET Web API で循環参照なモデルの公開を解決する」を参考にさせていただきました。

その記事に書いてありますが、.NET Framework の ASP.NET Web API の JSON シリアライザは Newtonsoft の Json.NET のもので、JsonIgnoreAttribute Class という属性が利用できます。なので、上の Post クラスの Blog プロパティに [JsonIgnore] を付与すればシリアライズの際の循環参照エラーは回避できます。

しかしながら、MVC5 アプリで利用する Controller.Json メソッドは内部で JavaScriptSerializer クラスを使っており、JsonIgnore 属性は効果がありません。

なので、Microsoft のドキュメント「Create Data Transfer Objects (DTOs)」を参考にして以下のコードのようにしてみました。

public class BlogDto
{
    public int BlogId { get; set; }
    public string Name { get; set; }
    public List<PostDto> Posts { get; set; }
}

public class PostDto
{
    public int PostId { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public int BlogId { get; set; }
}

public ActionResult Json()
{
    var db = new BloggingContext();

    var list = db.Blogs.Include(b => b.Posts).
               Select(b => new BlogDto
               {
                   BlogId = b.BlogId,
                   Name = b.Name,
                   Posts = b.Posts.Select(p => new PostDto
                   {
                       PostId = p.PostId,
                       Title = p.Title,
                       Content = p.Content,
                       BlogId = p.BlogId
                   }).ToList()
               });

    return Json(list, JsonRequestBehavior.AllowGet);
}

ナビゲーションプロパティを除いた別の入れ物(上の BlogDto クラス、PostDto クラス)に入れてシリアライズしているわけですから、当然ながら循環参照の問題はなくなって JSON 文字列にシリアライズできます。

循環参照を避ける以外に、必要なデータだけを望む形で JSON 文字列にシリアライズするということも出来るわけですから、積極的に DTO を使うのが正解だと思います。

なお、.NET Core 3.0 以降には JsonIgnoreAttribute クラスが利用できるので、上の .NET Framework のケースとは話は違ってきて、MVC, Web API どちらの場合も循環参照になるプロパティに [JsonIgnore] を付与すればシリアライズできます。

(Microsoft のドキュメント Add Newtonsoft.Json-based JSON format support を見ると ASP.NET Core 2.x 以前は Newtonsoft.Json パッケージが、ASP.NET Core 3.x 以降は System.Test.Json が使われているそうです)

Tags: , , ,

MVC

カスタムモデルバインダ (MVC5)

by WebSurfer 14. February 2020 15:29

.NET Framework の ASP.NET MVC5 アプリケーションでカスタムモデルバインダを利用するコードを備忘録として書いておきます。(注:ASP.NET Core 3.1 MVC の記事は「カスタムモデルバインダ (Core 3.1)」を見てください。下のサンプルコードは ASP.NET Core MVC には使えません)

カスタムモデルバインダ

モデルバインド機能だけでなく、ユーザー入力の検証とエラーメッセージの表示ができるようにしてみました。

上の画像を表示する Model と Controller のサンプルコードを以下にアップしておきます。View のコードはスキャフォールディング機能を使って自動生成できるので省略します。

Model とカスタムモデルバインダ

using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.ComponentModel.DataAnnotations;
using System.Web.Mvc;
using System.Globalization;

namespace Mvc5App.Models
{
  // Model
  public class Person2
  {
    public int PersonId { set; get; }

    [Display(Name = "名前")]
    public string Name { set; get; }

    [Display(Name = "メールアドレス")]
    public string Mail { set; get; }

    // Age は int? にしないと未入力ではカスタム��デルバイ
    // ンダでも動かない。既定のモデルバインダと同様に null
    // が渡されて例外がスローされるようで「年齢 フィールド
    // が必要です。 」というエラーメッセージが表示される
    [Display(Name = "年齢")]
    public int? Age { set; get; }
  }

  // カスタムモデルバインダー
  public class CustomModelBinder : IModelBinder
  {
    public object BindModel(ControllerContext contContext, 
                          ModelBindingContext bindContext)
    {
      if (bindContext == null)
      {
        throw new ArgumentNullException("引数が null");
      }

      var model = new Person2();
      model.Name = PostedData<string>(bindContext, "Name");
      model.Mail = PostedData<string>(bindContext, "Mail");
      // ここでは string 型で取得する
      string age = PostedData<string>(bindContext, "Age");

      if (string.IsNullOrEmpty(age))
      {
        bindContext.ModelState.AddModelError("Age", 
                                           "年齢は必須");
      }
      else
      {
        int intAge;
        if (!int.TryParse(age, out intAge))
        {
          bindContext.ModelState.AddModelError("Age",
                                            "年齢は整数");
        }
        else
        {
          model.Age = intAge;
          if (intAge < 0 || intAge > 200)
          {
            bindContext.ModelState.AddModelError("Age", 
                                "年齢は 0 ~ 200 の範囲");
          }
        }
      }

      if (string.IsNullOrEmpty(model.Name))
      {
        bindContext.ModelState.AddModelError("Name",
                                            "名前は必須");
      }
      else if (model.Name.Length < 2 || 
               model.Name.Length > 20)
      {
        bindContext.ModelState.AddModelError("Name",
                             "名前は 2 ~ 20 文字の範囲");
      }

      else if (model.Name.StartsWith("佐藤") &&
               model.Age < 20)
      {
        bindContext.ModelState.AddModelError("",
              "佐藤さんは二十歳以上でなければなりません");
      }

      if (string.IsNullOrEmpty(model.Mail))
      {
        bindContext.ModelState.AddModelError("Mail",
                                  "メールアドレスは必須");
      }
      else
      {
        bool isValidEmai = Regex.IsMatch(model.Mail,
          @"・・・正規表現(省略)・・・",
          RegexOptions.IgnoreCase, 
          TimeSpan.FromMilliseconds(250));

        if (!isValidEmai)
        {
          bindContext.ModelState.AddModelError("Mail", 
                        "有効な Email 形式ではありません");
        }
      }

      return model;
    }

    // ヘルパーメソッド
    // Core では ValueProviderResult.ConvertTo メソッドは使え
    // ませんので注意。
    private static T PostedData<T>(ModelBindingContext context,
                                   string key)
    {
      var result = context.ValueProvider.GetValue(key);
      context.ModelState.SetModelValue(key, result);
      return (T)result.ConvertTo(typeof(T));
    }
  }
}

Controller / Action Method

モデルバインダをターゲットとなる型に関連付けるため、POST データを受けるアクションメソッドの引数に [ModelBinder(typeof(CustomModelBinder))] を付与します。(これはローカルな関連付けで、Global.asax の Application_Start メソッドでグローバルに関連付けを行うこともできるそうです)

using System;
using System.Web.Mvc;
using Mvc5App.Models;

namespace Mvc5App.Controllers
{
  public class ValidationController : Controller
  {
    // カスタムモデルバインダーを使ったサンプル
    public ActionResult Create4()
    {
      return View();
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public ActionResult Create4(
      [ModelBinder(typeof(CustomModelBinder))] Person2 model)
    {
      if (!ModelState.IsValid)
      {
        return View(model);
      }

      return RedirectToAction("Index", "Home");
    }
  }
}

Tags: , ,

MVC

コンマ区切りスクリプトと検証の整合

by WebSurfer 28. November 2019 15:41

数字を 3 桁でコンマ区切りする JavaScript と ASP.NET MVC5 のクライアント側での検証の話です。

コンマ区切りスクリプトと検証の整合

元の話は Teratail のスレッド「3桁コンマ区切り数字をコンマ無しでFrom送信したい」です。

コンマ区切り用 JavaScript のコードの動作は、初期画面では数字を 3 桁でコンマ区切りし、ユーザーが編集するときはコンマを除去し、編集完了後は再び 3 桁でコンマ区切りするというものです。コードは Teratail のスレッドの私 SurferOnWww の回答にありますので見てください。

そのコンマ区切り用 JavaScript の動作と、以下のようにモデルにアノテーション属性を付与するとデフォルトで有効になる控えめな JavaScript による検証が、かなり微妙ながら基本的なところでは整合を取って動きましたので、備忘録として書いておくことにしました。(実は、バッティングして動かないと思い込んでました(汗))

public class PurchaseRecord
{
  // ・・・中略・・・

  [Display(Name = "価格")]
  [Required(ErrorMessage = "{0} は必須")]
  [RegularExpression(@"^\d{1,6}$", 
    ErrorMessage = "数字 1 ~ 6 文字")]
  [Range(100, 10000, 
    ErrorMessage = "{0}は{1}~{2}の間で入力してください。")]
  [DisplayFormat(DataFormatString = "{0:N0}", 
      ApplyFormatInEditMode = true)]
  public decimal Price { get; set; }
}

どのような動きになるかと言うと以下の通りです:

  1. テキストボックスの初期表示は 1,234
  2. ユーザーが編集動作に入る時 focus イベントが発生しスクリプトで 1234 に書き換わる
  3. ユーザーが例えば 3210 というように編集
  4. 次の作業に移るためフォーカスを外す
  5. change イベントが発生し 3210 に対し検証がかかる
  6. blur イベントが発生しスクリプトで 3,210 に書き換える
  7. ユーザーが送信ボタンをクリック
  8. submit イベントが発生しスクリプトで 3,210 を 3210 に書き換える
  9. サーバーで 3210 を受信、サーバー側での検証は OK となる。

・・・という順序になってうまくいきます。

以上、基本的な動きは OK ではあるものの、かなり微妙なところで動いていますので、実際に運用に使う場合は十分な検証が必要だと思います。思いつくのは:

(1) change ⇒ blur の順序でイベントが発生しなければならないが、全てのブラウザでそうかは不明。(メジャーなブラウザは大丈夫のようですが、昔の Forefox は反対だったという話があります)

(2) 上のステップ 3 でユーザーが数字だけ入力してくれると期待するのは無理がある。(上のサンプルでは RegularExpression 属性を追加してチェックするようにしてますが、それで十分か?)

(3) ユーザーがブラウザの JavaScript を無効にした場合はサーバー側だけで検証することになる。

・・・などです。

特に、不特定多数のユーザーが不特定多種のブラウザでアクセスしてくるインターネットに公開するような場合は別の方法(サーバー側だけで検証するとか、String 型にするとか)を考えた方が良いかもしれません。

今のところ気が付いた問題点は以下の通りです:

問題 1: ステップ 3 でユーザーが 3,210 とカンマを入れて入力すると、ステップ 5 の時点ではカンマ入りなので正規表現での検証で引っかかるという問題があります。

問題 2: コンマ区切り用 JavaScript のコードには全角 ⇒ 半角変換の機能が実装されていますが、タイミングの問題で検証に引っかかってしまいます。どういうことかと言うと、全角数字を入力するとステップ 6 の時点で半角に変換しますが、検証がかかるステップ 5 の時点ではまだ全角なので正規表現による検証で NG となります。

その後、ステップ 6 の時点で半角に変換されるので、見かけは正しく半角なのにエラーが出て混乱を招くと思います。なので、全角 ⇒ 半角変換のコードは削除した方がよさそうです。

最後にオマケを二つ書いておきます。

その 1: 上のステップ 2 で編集操作に入った時、キャレットが末尾にあるのが自然と思いますが、そうしたい場合は以下のように 2 行追加してください。

elm.addEventListener('focus',
  function () {
    this.value = delFigure(this.value);

    // キャレットを文字列の末尾に持ってくる
    // ため以下の 2 行を追加
    var len = this.value.length;
    this.setSelectionRange(len, len);
  }, false);

その 2: クライアント側での検証を無効にすると「価格」として有効でない文字列、例えば 123x とかでもサーバーに送信されてしまいます。その場合、モデルバインディングできないのでアノテーション属性に設定した検証がかかる以前にエラーとなります。

そのエラーメッセージが気に入らないので自分で設定したいという場合は Controller にコードを追加して書き換えることができます。詳しくは別の記事「int 型プロパティの検証、エラーメッセージ」を見てください。

Tags: , , ,

MVC

About this blog

2010年5月にこのブログを立ち上げました。その後 ブログ2 を追加し、ここは ASP.NET 関係のトピックス、ブログ2はそれ以外のトピックスに分けました。

Calendar

<<  March 2020  >>
MoTuWeThFrSaSu
2425262728291
2345678
9101112131415
16171819202122
23242526272829
303112345

View posts in large calendar