問題の概要
EF Core でリレーションするエンティティのデータを DB から取得し ASP.NET Core Web API でクライアントへ返す際にJsonSerializationException
が発生しました。原因としては取得したオブジェクトが循環参照していたためです。
以下経緯と対処方法の説明です。
下記のように 1 対 N の親子関係にあるモデルがあります。
// 著者エンティティ
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
リクエストに対し、著者一覧とそれに紐づく書籍一覧を返します。
[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 です。
[
{
"authorId":1,
"name":"フィリップ・K・ディック",
"birthday":"1928-12-16T00:00:00",
"books":[
{
"bookId":1,
"title":"アンドロイドは電気羊の夢を見るか?",
"publushYear":1969,
"authorId":1
Author
のインスタンスはBooks
プロパティで複数のBook
インスタンスを参照し、Book
はAuhtor
プロパティから親のAuthor
を参照しているため、Author
Book
お互いに参照が発生し循環しています。
それにより JSON にシリアライズした際も Book.Author
の位置でシリアル化が止まっているようです。
解決方法
Book
エンティティのAuthor
プロパティに[Jsonignore]
アノテーション付けて無視することもできますが、循環参照しない場合でもシリアライズ化されなくなってしまいます。
そこで以下のようにStartup.cs
でSerializerSettings.ReferenceLoopHandling
にReferenceLoopHandling.Ignore (循環参照を無視する)
を設定します。
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 で生成されたテーブルの内容とデータを確認します。
アプリを立ち上げ、ブラウザで 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 拡張で見やすく整形しています。
Visual Studio でデバッグする際はlaunchsetting.json
のprofiles.launchUrl
でブラウザが起動した際にデフォルトでアクセスする URL を設定できるので便利です。ファイルを直接編集して指定することもできますが、ソリューションエクスプローラーからプロジェクトのプロパティ画面の開きデバッグタブからも編集できます。
今回使用したソースコードは以下のリポジトリに置きました。
https://github.com/sano-suguru/RelationalEntitiesSeeding