ASP .NET Core 3.1 + EntityFramework Core を使って、一対多のリレーションがあるデータをレスポンスしてみた

ASP.NET Core 3.1 + EntityFramework Coreを使ってアプリを書いた際、1モデルのデータを返す方法はチュートリアルにありました。

 

一方、一対多のリレーションがあるデータをどうやってレスポンスすれば良いか、最初のうちは分かりませんでした。

そのため、色々と試したことをメモしておきます。

 

目次

 

環境

 

環境構築

まずは、MacASP.NET Core プロジェクトを作るまでの環境構築です。

 

.NET Core SDKのインストール

.NET Core SDKをHomebrewで管理しようと調べてみたところ、caskにありました。

$ brew search dotnet-sdk
==> Casks
dotnet-sdk

 

そのため、caskを使ってインストールしました。

$ brew cask install dotnet-sdk
...
==> Linking Binary 'dotnet' to '/usr/local/bin/dotnet'.
🍺  dotnet-sdk was successfully installed!

 

これで dotnet コマンドが使えるようになりました。

$ dotnet --version
3.1.100

 
なお、SDKのバージョンアップする場合は、以下のコマンドで行います。

$ brew cask upgrade dotnet-sdk
...
==> Linking Binary 'dotnet' to '/usr/local/bin/dotnet'.
==> Purging files for version 3.0.101,1b9f265d-ba27-4f0c-8b4d-1fd42bd8448b:2bbd64abddeeea91149df3aa39d049ae of Cask dotnet-sdk
🍺  dotnet-sdk was successfully upgraded!

 

ソリューションとプロジェクトを作る

今回は、dotnetコマンドを使って作成します。

 

ソリューションファイルの作成
$ dotnet new sln

 

プロジェクトファイルの作成

今回は、 EFCoreRelationSample というプロジェクトを、Web APIテンプレートを使って作成します。

 

まずは用意されているテンプレートを確認します。今回は webapi を使えば良さそうです。

$ dotnet new --list
...

Templates                                         Short Name                                 
------------------------------------------------------------                            
ASP.NET Core Web App (Model-View-Controller)      mvc                              
...
ASP.NET Core Web API                              webapi
...

 

プロジェクトを作成し、ソリューションに追加します。

# プロジェクトを作成
$ dotnet new webapi -o EFCoreRelationSample

# ソリューションに追加
dotnet sln add ./EFCoreRelationSample

 

EntityFramework CoreまわりのNuGetパッケージを追加

まずはSDKのグローバルに、EntityFramework Core (以下、 EF Core )まわりのコマンドを追加します。
EF Core ツールリファレンス (.NET CLI)-EF Core | Microsoft Docs

$ dotnet tool install --global dotnet-ef
次のコマンドを使用してツールを呼び出せます。dotnet-ef
ツール 'dotnet-ef' (バージョン '3.1.0') が正常にインストールされました。

なお、 dotnet ef コマンドは .NET Core SDKに同梱されていません。
Announcing Entity Framework Core 3.1 and Entity Framework 6.4 | .NET Blog

 

続いて、EF Core関連のパッケージを追加します。

# プロジェクトのあるディレクトリへ移動
$ cd EFCoreRelationSample/

# 今回SQLiteを使うため、SQLiteパッケージを追加
$ dotnet add package Microsoft.EntityFrameworkCore.SQLite

# マイグレーションなどのために追加
$ dotnet add package Microsoft.EntityFrameworkCore.Design

 
なお、 Microsoft.EntityFrameworkCore.SQLite の制限内容はこちら。 SQLite Database Provider - Limitations - EF Core | Microsoft Docs

 
また、Microsoft.EntityFrameworkCore.Design を追加する目的はこちら。
c# - Purpose of package "Microsoft.EntityFrameworkCore.Design" - Stack Overflow

 

ここまでで、ASP.NET Core & EF Coreの環境ができました。

 

モデルまわりの作成

今回使用するモデルまわりを作成します。

 

モデルの作成

今回作成する一対多のリレーションを持つモデルとして、BlogとPostを使います。公式チュートリアルとほぼ同じ型です。
リレーションシップ-EF Core | Microsoft Docs

 

テンプレートにはモデルを入れるディレクトリがないため、

  • Controllersと同列に Models というディレクトリを作成
  • Modelsの中にモデルファイル BlogPostOfFk.cs を作成

とします。

 

2つのモデルのリレーションについて、今回はナビゲーションプロパティを使って定義します。

EF Coreの規則として、親側にだけ定義すれば、自動でリレーションが貼られるようです。
https://docs.microsoft.com/ja-jp/ef/core/modeling/relationships?tabs=data-annotations#single-navigation-property

 
ただ、後述のControllerにて親から子をたぐる処理を書く際、ナビゲーションプロパティが定義されていない状態ではコンパイルが通りませんでした。

var blog = await _context.BlogOfFks
    .Include(p => p.PostsOfFk)  // この p.PostOfFk が参照できなくてエラー
    .ToListAsync();
return Ok(blog);

 
そのため、以下のように明示的に定義します。なお、外部キーについては定義しなくても動作するようです。

using System.Collections.Generic;

namespace EFCoreRelationSample.Models
{
    public class BlogOfFk
    {
        public int Id { get; set; }
        public string Url { get; set; }
        
        public List<PostOfFk> PostsOfFk { get; set; }
    }

    public class PostOfFk
    {
        public int Id { get; set; }
        public string Content { get; set; }

        public BlogOfFk BlogOfFk { get; set; }
    }
}

 

DbContextの作成

モデルができたので、次はDBと接続するためのDbContextを作成します。

DbContextの適切な置き場が分からなかったため、今回は Models の下に MyContext.cs として作成しました。

DbContextを継承した MyContext クラスを作成し、

  • コンストラク
  • DbSet型のpublicプロパティ

を用意します。

using Microsoft.EntityFrameworkCore;

namespace EFCoreRelationSample.Models
{
    public class MyContext : DbContext
    {
        public MyContext(DbContextOptions<MyContext> options) : base(options) {}
        
        // モデルクラスへのアクセス (DbSet<TModel>) 型のpublicプロパティ
        public DbSet<BlogOfFk> BlogOfFks { get; set; }
        public DbSet<PostOfFk> PostOfFks { get; set; }
    }
}

 

接続文字列の作成 (appsettings.json)

次に、SQLiteへの接続するための接続文字列を用意します。

今回は、プロジェクトルート直下にある appsettings.json の中に記載します。

接続するSQLiteは、プロジェクトルート直下の WebApplication.db とします。

{
  // ...
  "AllowedHosts": "*",
  
  // 以下を追加
  "ConnectionStrings": {
    "MyContext": "Data Source=./WebApplication.db"
  }
}

なお、SQLiteファイルはEF Coreのマイグレーションにより自動生成されます。

 

Startup.csに、DbContextと接続文字列を追加

今回使用するDbContextと接続文字列を追加します。

SQLiteへ接続するため、 UseSqlite() を使います。

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();

    // 以下を追加
    services.AddDbContext<MyContext>(options => options.UseSqlite(
        Configuration.GetConnectionString("MyContext")));
}

 

マイグレーションの作成

モデルまわりができたため、EF Coreによるマイグレーションを作成します。

 

マイグレーションファイルの作成

以下のコマンドを実行すると、 Migrations ディレクトリの中にマイグレーションファイルが作成されます。

$ dotnet ef migrations add InitialCreate

今回は

  • MyContextModelSnapshot.cs
  • <タイムスタンプ>_InitialCreate.cs

の2ファイルが作成されました。

 

データベースへの反映

マイグレーションファイルの内容をSQLiteへ反映します。

$ dotnet ef database update

 
実行時のログより、BlogOfFk・PostOfFk モデルを元に、2つのテーブルを作成するSQLが発行されたことが分かります。

BlogOfFk

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "BlogOfFks" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_BlogOfFks" PRIMARY KEY AUTOINCREMENT,
          "Url" TEXT NULL
      );

 

PostOfFk

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE "PostOfFks" (
          "Id" INTEGER NOT NULL CONSTRAINT "PK_PostOfFks" PRIMARY KEY AUTOINCREMENT,
          "Content" TEXT NULL,
          "BlogOfFkId" INTEGER NULL,
          CONSTRAINT "FK_PostOfFks_BlogOfFks_BlogOfFkId" FOREIGN KEY ("BlogOfFkId") 
              REFERENCES "BlogOfFks" ("Id") ON DELETE RESTRICT
      );

モデルにはナビゲーションプロパティだけ定義したものの、子モデルの外部キー BlogOfFkId が自動生成されました。

一方、モデルに定義した

  • コレクションナビゲーションプロパティ (BlogOfFk)
  • 参照ナビゲーションプロパティ (PostsOfFk)

は、テーブルのフィールドとして作成されませんでした。

 

初期データの投入

初期データを投入するため、データのシード処理を実装します。
データシード処理-EF Core | Microsoft Docs

投入方法はいくつかあるようですが、今回はDbContextの中で実装します。

OnModelCreating() メソッドをオーバーライドして実装します。

namespace EFCoreRelationSample.Models
{
    public class MyContext : DbContext
    {
        // ...
        
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            // データのシードを定義
            // 親データ
            modelBuilder.Entity<BlogOfFk>().HasData(
                new {Id = 1, Url = "https://example.com/foo"},
                new {Id = 2, Url = "https://example.com/bar"}
            );
            
            // 子データ
            modelBuilder.Entity<PostOfFk>().HasData(
                new {Id = 1, Content = "ふー", BlogOfFkId = 1},
                new {Id = 2, Content = "ばー", BlogOfFkId = 1}
            );
        }
    }
}

 

初期データを投入するため、

  • 先ほど作成したマイグレーションファイル (Migrations/ の下) を削除
  • SQLite (WebApplication.db) を削除
  • dotnet ef migrations add InitialCreate
  • dotnet ef database update

を実行したところ

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      INSERT INTO "BlogOfFks" ("Id", "Url")
      VALUES (1, 'https://example.com/foo');
      
...

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      INSERT INTO "PostOfFks" ("Id", "BlogOfFkId", "Content")
      VALUES (1, 1, 'ふー');

などのログが表示され、シード処理が行われていることが確認できました。

 
Rider IDEで確認しても、以下のようにデータが存在しています。

BlogOfFk

f:id:thinkAmi:20191217221650p:plain:w400

 

PostOfFk

f:id:thinkAmi:20191217221704p:plain:w400

 

EF Coreから発行したSQLをコンソールへ出力

コントローラを作成する前に、EF Coreで発行したSQLをコンソールで確認する設定を追加しておきます。

基本的には以下の記事の通りですが、.NET Core 3.1版では少々変更がありました。
【C#】Entity Framework Core の SQL の実行ログを確認する - Qiita

 

NuGetパッケージを追加

コンソールへ出力するためのNuGetパッケージを追加します。

$ dotnet add package Microsoft.Extensions.Logging.Console

 

DbContextへ追加

DbContextへ

  • staticなreadonlyフィールドを追加
  • OnConfiguring()をオーバーライドして実装

を追加します。

ただ、.NET Core 3.1ではLoggerFactoryの追加方法が変わったようです。

 

そのため

  • LoggerFactory.Create()で生成
  • AddFilter()で、 DbLoggerCategory.Database.Command.Name を対象にする
  • AddConsole()で、コンソールへ出力

とします。

static readonly ILoggerFactory loggerFactory = LoggerFactory.Create(builder =>
{
    builder
        .AddFilter(DbLoggerCategory.Database.Command.Name, LogLevel.Information)
        .AddConsole();
});

// UseLoggerFactory で上記のロガーファクトリを登録する。
// Insert 文の値をロギングするために EnableSensitiveDataLogging を許可する。
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
    optionsBuilder
        .EnableSensitiveDataLogging()
        .UseLoggerFactory(loggerFactory);

 

コントローラの作成

あとはコントローラを作成すれば完成です。

 

失敗:Include()を使用したリレーションデータのレスポンス

コントローラに必要な

  • DbContextフィールド
  • コンストラクタで、DbContextフィールドへの設定
  • HTTP GETでアクセスした時のメソッド

を実装します。

以下は、 /BlogPost/p2c にアクセスした時に、BlogOfFkモデルからPostOfFkモデルをたぐって表示する例となります。

namespace EFCoreRelationSample.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class BlogPostController : ControllerBase
    {
        private readonly MyContext _context;

        public BlogPostController(MyContext context)
        {
            this._context = context;
        }

        // 1 -> N の順でたぐる場合
        [HttpGet("p2c", Name = "Parent2Child")]
        public async Task<IActionResult> P2C()
        {
            var blog = await _context.BlogOfFks
                .Include(p => p.PostsOfFk)
                .ToListAsync();
            return Ok(blog);
        }
    }
}

 

Rider IDEで実行し、ブラウザで https://localhost5000/api/BlogPost/p2c へアクセスすると

An unhandled exception occurred while processing the request.
JsonException: A possible object cycle was detected which is not supported. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 32.

というエラーが表示されました。

 
おそらく、モデルに

  • コレクションナビゲーションプロパティ (BlogOfFk)
  • 参照ナビゲーションプロパティ (PostsOfFk)

の2つが実装されているため、循環してしまったものと考えられました。

 

対応ですが、ASP.NET Core 2系では

のように、 SerializerSettings.ReferenceLoopHandling を設定すれば良いようでした。

 
ただ、ASP.NET Core 3系では、JSONライブラリに変更が入りました。

その影響で、SerializerSettings.ReferenceLoopHandling を設定するには、NuGetパッケージ Microsoft.AspNetCore.Mvc.NewtonsoftJson を追加する必要がありそうでした。

 
NewtonsoftJson の追加も考えましたが、今回は AutoMapper を使うことで対応することにしました。

 

AutoMapperを使った、リレーションデータのレスポンス

AutoMapperを使うにあたり、以下の記事が参考になりました。
ASP.NET CoreでAutoMapperを使う - ryuichi111stdの技術日記

ただ、ASP.NET Core3.1では少々変更されていました。

そのため、上記記事とstackoverflowを参考に

  • NuGetパッケージの追加
  • DTOクラスを追加
  • Profileクラスを追加
  • Startup.csへの追加
  • コントローラへの追加

を行いました。
c# - How to set up Automapper in ASP.NET Core - Stack Overflow

 

NuGetパッケージの追加

必要なNuGetパッケージを追加します。

# AutoMapper本体を追加
dotnet add package AutoMapper

# 拡張も入れる
dotnet add package AutoMapper.Extensions.Microsoft.DependencyInjection

 

DTOクラスを追加

ブラウザ側へ返す時のデータ型となるDTOクラスを追加します。

今回は DTO/BlogPostOfFKDTO.cs として追加しました。

また、DTOのプロパティ名はモデルと同一としました。

using System.Collections.Generic;
using EFCoreRelationSample.Models;

namespace EFCoreRelationSample.DTO
{
    public class PostOfFkDTO
    {
        // Postのフィールド
        public int Id { get; set; }
        public string Content { get; set; }
        
        // FKのフィールド
        public BlogOfFk BlogOfFk { get; set; }
    }

    public class BlogOfFkDTO
    {
        // Blogのフィールド
        public int Id { get; set; }
        public string Url { get; set; }

        // FK(Post)のフィールド
        public List<PostOfFk> PostsOfFk { get; set; }
    }   
}

 

Profileクラスを追加

次に、モデルとDTOを結びつけるProfileクラスを追加します。

置き場所が分からなかったため、今回は DTO/MyProfile.cs として作成しました。

Profileクラスでは CreateMap() を使って、モデルとDTOを結びつけます。

using AutoMapper;
using EFCoreRelationSample.Models;

namespace EFCoreRelationSample.DTO
{
    public class MyProfile : Profile
    {
        public MyProfile()
        {
            CreateMap<BlogOfFk, BlogOfFkDTO>();
            CreateMap<PostOfFk, PostOfFkDTO>();
        }
    }
}

 

Startup.csへの追加

ConfigureServices() に、 AddAutoMapper() を追加します。

public void ConfigureServices(IServiceCollection services)
{
    ...
    // 【追加】AutoMapperを追加
    services.AddAutoMapper(typeof(MyProfile));
}

 

コントローラへの追加

まず、

  • IMapper型のフィールドを追加
  • コンストラクタに IMapper 型の引数を追加

を行います。

public class BlogPostController : ControllerBase
{

    private readonly IMapper _mapper;

    public BlogPostController(MyContext context, IMapper mapper)
    {
        this._context = context;
        this._mapper = mapper;
    }
    ...
}

 

続いてコントローラのメソッドを書き換えます。

ただ、今回はAutoMapper有無を比較できるよう P2CWithAutoMapper() を新しく作成してみました。

AutoMapperを使う場合は、 ProjectTo() を追加することで、DTOへの変換を行っています。

[HttpGet("p2c-auto", Name = "Parent2ChildWithAutoMapper")]
public async Task<IActionResult> P2CWithAutoMapper()
{
    var blogs = await _context.BlogOfFks
        .Include(p => p.PostsOfFk)
        .ProjectTo<BlogOfFkDTO>(_mapper.ConfigurationProvider)  // 追加
        .ToListAsync();
    return Ok(blogs);
}

ちなみに、同じような機能に Map() もあります。 ProjectTo() との違いは、こちらが参考になりました。
LINQ to Entities の遅延評価と AutoMapper が便利という話 - Qiita  

Rider IDEASP.NET Coreを起動した時の動作を変更

あとは確認するだけです。

ただ、Rider IDEASP.NET Coreを起動した時の動作を変更したいため、 Properties/launchSettings.json を修正します。

  • launchBrowser で、起動した時にブラウザも自動で開くか
  • launchUrl で、ブラウザで最初にアクセスするURLを指定

などができます。

"EFCoreRelationSample": {
  "commandName": "Project",
  "launchBrowser": true,
  "launchUrl": "api/BlogPost",
  "applicationUrl": "https://localhost:5001;http://localhost:5000",
  "environmentVariables": {
    "ASPNETCORE_ENVIRONMENT": "Development"
  }
}

 

動作確認

ASP.NET Coreアプリを起動し、ブラウザで https://localhost5000/api/BlogPost/p2c-auto を開くと、リレーションデータのJSONが表示されました。

f:id:thinkAmi:20191217221737p:plain

 

ソースコード

GitHubに上げました。
https://github.com/thinkAmi-sandbox/ASP_Net_Core-sample

なお、マイグレーションファイルは自動生成されたものなので、GitHubには含めていません。