Help us understand the problem. What is going on with this article?

ASP.NET Core で循環参照するオブジェクトを JSON へシリアル化した際に JsonSerializationException: Self referencing loop detected がスローされる問題への対処方

More than 1 year has passed since last update.

問題の概要

EF Core でリレーションするエンティティのデータを DB から取得し ASP.NET Core Web API でクライアントへ返す際にJsonSerializationExceptionが発生しました。原因としては取得したオブジェクトが循環参照していたためです。

以下経緯と対処方法の説明です。
下記のように 1 対 N の親子関係にあるモデルがあります。

Book.csとAuthor.csから抜粋
// 著者エンティティ
public class Author {
  public int AuthorId { get; set; }
  public string Name { get; set; }
  public DateTime Birthday { get; set; }
  // 著者は複数の著書に紐づきます
  public IList<Book> Books { get; set; }
}

// 書籍エンティティ
public class Book {
  public int BookId { get; set; }
  public string Title { get; set; }
  public int PublushYear { get; set; }
  // 書籍は一人の筆者に紐づきます(内容から外れるので共著はないものとする)
  public int AuthorId { get; set; }
  public Author Author { get; set; }
}

クライアントからのGETリクエストに対し、著者一覧とそれに紐づく書籍一覧を返します。

AuthorsController.csから抜粋
[HttpGet]
public async Task<ActionResult> Get() =>
  Ok(await this.context.Authors
    .Include(a => a.Books)
    .ToListAsync()
  );

実際にリクエストを送ると循環参照するオブジェクトを JSON にシリアル化した際に例外が発生しています。(Newtonsoft.Json.JsonSerializationException: Self referencing loop detected for property 'author' with type 'RelationalEntitiesSeeding.Entities.Author'. Path '[0].books[0]'.
この例外は開発用エラーページには遷移せず、ターミナルにひっそりと表示されるだけなのですぐに原因に気がつきませんでした。

本来なら以下のようなデータが返ることを期待します。

期待するレスポンスデータのイメージ(※見やすいように整形しています)
[  
   {  
      "authorId":1,
      "name":"フィリップ・K・ディック",
      "birthday":"1928-12-16T00:00:00",
      "books":[  
         {  
            "bookId":1,
            "title":"アンドロイドは電気羊の夢を見るか?",
            "publushYear":1969,
            "authorId":1
         },
         {  
            "bookId":2,
            "title":"高い城の男",
            "publushYear":1962,
            "authorId":1
         }
      ]
   },
   {  
      "authorId":2,
      "name":"ジョージ・オーウェル",
      "birthday":"1903-06-25T00:00:00",
      "books":[  
         {  
            "bookId":3,
            "title":"1984年",
            "publushYear":1949,
            "authorId":2
         }
      ]
   }
]

しかし実際に返るのは以下のように壊れた JSON です。

壊れたJSON(※見やすいように整形しています)
[  
   {  
      "authorId":1,
      "name":"フィリップ・K・ディック",
      "birthday":"1928-12-16T00:00:00",
      "books":[  
         {  
            "bookId":1,
            "title":"アンドロイドは電気羊の夢を見るか?",
            "publushYear":1969,
            "authorId":1

AuthorのインスタンスはBooksプロパティで複数のBookインスタンスを参照し、BookAuhtorプロパティから親のAuthorを参照しているため、Author Bookお互いに参照が発生し循環しています。
それにより JSON にシリアライズした際も Book.Authorの位置でシリアル化が止まっているようです。

解決方法

BookエンティティのAuthorプロパティに[Jsonignore]アノテーション付けて無視することもできますが、循環参照しない場合でもシリアライズ化されなくなってしまいます。
そこで以下のようにStartup.csSerializerSettings.ReferenceLoopHandlingReferenceLoopHandling.Ignore (循環参照を無視する)を設定します。

Startup.csより抜粋
public void ConfigureServices(IServiceCollection services) {
  services.AddMvc().AddJsonOptions(options => {
    // ↓ JSONシリアル化で循環参照を無視する設定
    options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
  }).SetCompatibilityVersion(CompatibilityVersion.Version_2_1);

  services.AddDbContext<MyDbContext>(options => {
    // DB は SQL Server local DB を使うことにします
    options.UseSqlServer(Configuration.GetConnectionString(nameof(RelationalEntitiesSeeding)));
  });
}

実際に Web API にリクエストを送って正常にシリアライズされるか確認したいと思います。
データベースコンテキストに以下のようにシードデータを設定し初期データを作成します。

public class MyDbContext : DbContext {
  public MyDbContext(DbContextOptions<MyDbContext> options)
    : base(options) { }

  public DbSet<Author> Authors { get; set; }
  public DbSet<Book> Books { get; set; }

  protected override void OnModelCreating(ModelBuilder modelBuilder) {
    modelBuilder.Entity<Author>(entity => {
      entity.Property(author => author.Name).IsRequired();
      entity.HasData(
        new Author { AuthorId = 1, Name = "フィリップ・K・ディック", Birthday = DateTime.Parse("1928/12/16") },
        new Author { AuthorId = 2, Name = "ジョージ・オーウェル", Birthday = DateTime.Parse("1903/06/25") }
      );
    });
    modelBuilder.Entity<Book>(entity => {
      entity.Property(book => book.Title).IsRequired();
      entity.HasOne(book => book.Author).WithMany(author => author.Books);
      entity.HasData(
        new Book { BookId = 1, Title = "アンドロイドは電気羊の夢を見るか?", PublushYear = 1969, AuthorId = 1 },
        new Book { BookId = 2, Title = "高い城の男", PublushYear = 1962, AuthorId = 1 },
        new Book { BookId = 3, Title = "1984年", PublushYear = 1949, AuthorId = 2 }
      );
    });
  }
}

<project_name>.csprojファイルがあるディレクトリで以下のコマンドを入力します。

$ dotnet ef migrations add Initial # マイグレーションファイルの作成
$ dotnet ef database update # データベースの生成(※初期シードデータのインサートも行われる)

SQL Server Object Explorer で生成されたテーブルの内容とデータを確認します。
image.png
image.png
image.png
image.png

アプリを立ち上げ、ブラウザで Web API にアクセスします。

$ dotnet run
# ↓ 抜粋
Hosting environment: Development
Now listening on: https://localhost:5001
Now listening on: http://localhost:5000
Application started. Press Ctrl+C to shut down.

以下の画像の通り循環する参照を持つオブジェクトでも正常に JSON にシリアライズされることが確認できました!
URL はコントローラークラスのアノテーションで設定されている[Route("api/[controller]")]のデフォルト設定です。
※ 画像では JSON Formatter という Google Chrome 拡張で見やすく整形しています。
image.png

Visual Studio でデバッグする際はlaunchsetting.jsonprofiles.launchUrlでブラウザが起動した際にデフォルトでアクセスする URL を設定できるので便利です。ファイルを直接編集して指定することもできますが、ソリューションエクスプローラーからプロジェクトのプロパティ画面の開きデバッグタブからも編集できます。

今回使用したソースコードは以下のリポジトリに置きました。
https://github.com/sano-suguru/RelationalEntitiesSeeding

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away