ASP.NET CoreでTypeScriptを使う

Visual Studio 2017でASP.NET Core Web アプリケーションプロジェクトからTypeScriptを試せる環境を作るまでのメモです。

ASP.NET Coreもそうですが、npmもgulpもbowerもわからない状況だったので、空のプロジェクトから始めると理解できるかなと。色々やってみて少しわかってきたので書き残しておきます。(個人的にこんなにツールを使わないといけないの?感があります・・・。)

まず参考にしたのはこちらのドキュメントです。

ASP.NET Core · TypeScript

+αでRazor Pages(やっぱりhtmlよりcshtmlの方がいいので)やgulp.watchも試しました。

ASP.NET CoreでTypeScript

ASP.NET Coreの空のプロジェクトからはじめて、次のTypeScriptのコードが実行できるまでを目指します。

// 実行したいコード
document.addEventListener("DOMContentLoaded", event => {
    alert("Hello!");
});

手順はこんな感じです。

  1. プロジェクトを作成する
  2. Startupクラスを設定する
  3. TypeScriptを実行するcshtmlを準備する
  4. TypeScriptの構成ファイルを準備する
  5. 実行するTypeScriptファイルを準備する
  6. npm構成ファイルを準備する
  7. Gulp構成ファイルを準備する

順番に見ていきましょう。

プロジェクトを作成する

ASP.NET Core Web アプリケーションのプロジェクトを選択してプロジェクトを作ります。上記ドキュメントと同じなので端折ります。

Startupクラスを設定する

プロジェクトで静的なファイルを扱えるようにして、Razor Pagesも動くようにします。具体的にはStartupクラス(Startup.cs)を次のようにします。

public class Startup {
    // 使用するサービスを設定
    public void ConfigureServices(IServiceCollection services) {
        // MVCを追加
        services.AddMvc();
    }

    // ミドルウェアを設定
    public void Configure(IApplicationBuilder app, IHostingEnvironment env) {
        if (env.IsDevelopment()) {
            app.UseDeveloperExceptionPage();
        }

        // 静的なファイルを扱う
        app.UseStaticFiles();

        // MVCを扱う(Razor Pagesを使う場合もこれ)
        app.UseMvc();
    }
}

Configureメソッドは、HTTPリクエストを処理する方法を指定するといった感じかなと。

詳しくはこのあたり。

ASP.NET Core でのアプリケーションの起動 | Microsoft Docs

TypeScriptを実行するcshtmlを準備する

TypeScriptを実行するcshtmlを用意しましょう。プロジェクトの直下にPagesフォルダを作って、そのフォルダにIndex.cshtmlを作ります。

Pages/
   └─ Index.cshtml

ソリューションエクスプローラーでPagesフォルダを選択してポップアップメニューの追加 > Razor ページから作るといい気がします。

Index.cshtmlの中身はこんな感じです。これから作るTypeScript(というかJavaScript)を読み込むためにscript要素を足しておきます。

@page
@model WebApp.Pages.IndexModel
@{
    Layout = null;
}
<!DOCTYPE html>
<html>
<head>
   <meta name="viewport" content="width=device-width" />
   <title>Index</title>
</head>
<body>
    <h1>Hello!</h1>
    @* これから作るjsファイルを指しておく *@
    <script src="~/js/app.js"></script>
</body>
</html>

この段階でデバッグ実行するとページが表示されるはずです。

TypeScriptの構成ファイルを準備する

TypeScript JSON 構成ファイル(tsconfig.json)を作ります。compileOnSavetrueにしておくと、tsファイルを保存するとjsファイルができるようになります。

{
    "compilerOptions": {
        "noImplicitAny": true,
        "noEmitOnError": true,
        "removeComments": false,
        "sourceMap": true,
        "target": "es2015"
    },
    "exclude": [
        "node_modules",
        "wwwroot"
    ],
    "compileOnSave": true
}

細かい部分はよくわからないので、このあたりを確認しながらかなと。

試してませんが、tsconfig.jsonの代わりにプロジェクトのプロパティにあるTypeScript ビルドで設定してもいいのかもしれません。

実行するTypeScriptファイルを準備する

プロジェクト直下にScriptsフォルダを作ります。その中にapp.tsファイルも作りましょう。

Scripts/
   └─ app.ts

app.tsの中身は最初にでてきたコードです。やっとですね。

document.addEventListener("DOMContentLoaded", event => {
    alert("Hello!");
});

app.tsを保存するとapp.jsができてるはずです。

npm構成ファイルを準備する

npm 構成ファイル(package.json)を作ります。このあと説明するGulpを取得するためです。

{
    "version": "1.0.0",
    "name": "asp.net",
    "private": true,
    "devDependencies": {
        "gulp": "3.9.1"
    }
}

ファイルを保存してしばらくすると、プロジェクト直下にnode_modulesというフォルダができるみたいです。ソリューションエクスプローラーですべてのファイルを表示すると見えると思います。

Gulp構成ファイルを準備する

Gulp 構成ファイル(gulpfile.js)を作ります。Scriptsフォルダ内のts、jsファイルをwwwroot/jsフォルダにコピーするタスクを設定します。

/// <binding ProjectOpened='default' />

const gulp = require("gulp");

// publishタスク
// Scriptsフォルダ内のts、jsファイルをwwwroot/jsフォルダにコピーする
gulp.task("publish", () => {
    gulp.src(["scripts/**/*.ts", "scripts/**/*.js", "scripts/**/*.map"])
        .pipe(gulp.dest("wwwroot/js"));
});

// detaultタスク
// Scriptsフォルダ内のjsファイルの変更を監視して、publishタスクを実行する
// 監視するのはjsファイルでいいのかな?
gulp.task("default", () => {
    gulp.watch("scripts/**/*.js", ["publish"]);
});

スクランナーエクスプローラー(ソリューションエクスプローラーでgulpfile.jsを選択してポップアップメニューのスクランナー エクスプローラ)を開きます。publishタスクができているので、ダブルクリックなどでタスクを実行すると、wwwroot/jsフォルダにts、jsファイルがコピーされます。

wwwroot/
   └─ js/
      ├─ app.ts
      ├─ app.js
      └─ app.js.map

デバッグ実行すると、ページが表示されてalertが表示されます。

スクランナーエクスプローラーでdefaultタスクを選択してポップアップメニューのバインド > プロジェクトを開くを選択しておきます。ソリューションを開くと、defaultタスクが実行されてファイルの変更が監視されるようになります。tsファイルを保存するたびにwwwroot/jsフォルダにコピーされるので、開発が捗るんじゃないかなと思います。gulpfile.jsの1行目にある/// <binding ProjectOpened='default' />がそれなのかなと。

Gulpについてはこのへんを参考にしました。

これで目標達成です。

試した環境

Entity Framework Core - 楽観的同時実行制御のサンプル

EF Coreでrowversionデータ型を使った楽観的同時実行制御(排他制御)を試してみました。EF6と大体同じかなと思います。

Concurrency Tokens - EF Core | Microsoft Docs

EF Coreで楽観的同時実行制御するには、Data Annotationsで指定する方法とFluent APIで指定する方法があります。

まずはデータを用意しましょう。

-- テーブル作成
drop table if exists dbo.Monster;
create table dbo.Monster(
    Id int,
    Name nvarchar(20) not null,
    Version rowversion not null,  -- バージョン情報(トークンとも言ったり)
    constraint PK_Monster primary key(Id)
);

-- データ投入
insert into dbo.Monster(Id, Name)
values(1, N'スライム');

続いてモデル。Data Annotationsの場合は、rowversionのプロパティにTimestamp属性を指定します。

// モンスター
public class Monster {
    public int Id { get; set; }
    public string Name { get; set; }

    // Data Annotationsの場合
    [Timestamp]
    public byte[] Version { get; set; }
}

Fluent APIの場合は、Timestamp属性の代わりにDBコンテキストのOnModelCreatingメソッド内で次のように指定します。

// DBコンテキストの一部
public class AppDbContext : DbContext {
    protected override void OnModelCreating(ModelBuilder modelBuilder) {
        // テーブルのマッピング
        modelBuilder.Entity<Monster>().ToTable(nameof(Monster));

        // Fluent APIの場合
        // Timestamp属性の代わりにこちらでも
        modelBuilder.Entity<Monster>()
            .Property(monster => monster.Version)
            .ValueGeneratedOnAddOrUpdate()
            .IsConcurrencyToken();
    }
}

準備が整ったので楽観的同時実行制御によって更新に失敗することを試してみましょう。

class Program {
    static void Main(string[] args) {
        using (var dbContext = new AppDbContext()) {
            // ロガープロバイダーを設定
            var serviceProvider = dbContext.GetInfrastructure();
            var loggerFactory = serviceProvider.GetService<ILoggerFactory>();
            loggerFactory.AddProvider(new AppLoggerProvider());
        }

        // 同時に更新する1つ目のエンティティ
        var monster1 = default(Monster);
        using (var dbContext = new AppDbContext()) {
            monster1 = dbContext.Set<Monster>().Find(1);
        }

        // 同時に更新する2つ目のエンティティ
        // 楽観的同時実行制御による更新失敗を確認したいため、コピーを作っておく
        var monster2 = new Monster {
            Id = monster1.Id,
            Name = monster1.Name,
            Version = monster1.Version.Clone() as byte[],
        };

        // 1つ目のエンティティを更新 => 成功する
        using (var dbContext = new AppDbContext(tracking: true)) {
            monster1.Name = "スライムベス";
            dbContext.Set<Monster>().Attach(monster1).State = EntityState.Modified;

            dbContext.SaveChanges();

            // 実行されるSQL
            // WHERE句にVersionカラムが含まれている
            /*
           Executed DbCommand (0ms) [Parameters=[@p1='?', @p0='?' (Size = 4000), @p2='?' (Size = 8)], CommandType='Text', CommandTimeout='30']
           SET NOCOUNT ON;
           UPDATE [Monster] SET [Name] = @p0
           WHERE [Id] = @p1 AND [Version] = @p2;
           SELECT [Version]
           FROM [Monster]
           WHERE @@ROWCOUNT = 1 AND [Id] = @p1;
           */
        }

        // 2つ目のエンティティを更新 => 失敗する(上記SaveChangesでVersionカラムが更新されているため)
        using (var dbContext = new AppDbContext(tracking: true)) {
            monster2.Name = "バブルスライム";
            dbContext.Set<Monster>().Attach(monster2).State = EntityState.Modified;

            try {
                // SaveChangesを呼び出すと例外が発生する
                dbContext.SaveChanges();
            } catch (DbUpdateConcurrencyException exception) {
                Console.WriteLine(exception.Message);
                // 例外メッセージ
                /*
               Database operation expected to affect 1 row(s) but actually affected 0 row(s).
               Data may have been modified or deleted since entities were loaded.
               See http://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.
               */
            }
        }
    }
}

まとめ

rowversionとTimestamp属性(またはFluent API)を使うと、

  • SaveChangesメソッドで実行されるUPDATE文のWHERE句にVersionカラムが含まれる
  • すでにVersionカラムが更新されていた場合、SaveChangesメソッドを呼び出すと更新に失敗してDbUpdateConcurrencyExceptionがスローされる

ということを確認できました。

コードとクエリ全体はこちらに。

EF Coreで楽観的同時実行制御 · GitHub

Entity Framework Core - Owned typesのサンプル

EF Coreの「Owned types」を試してみました。日本語訳はどうなるんでしょう。所有型?かな?

What is new in EF Core 2.0 - EF Core | Microsoft Docs

「Owned types」はEF6の複合型に似た機能のようで、テーブルにある複数カラムをプロパティクラスにマッピングするものです。

ということで早速。次のテーブルがあるとします。

-- テーブル作成
drop table if exists dbo.Character;
create table dbo.Character(
    Id int,
    Name nvarchar(4) not null,   -- 名前
    Level int not null,   -- レベル
    Hp int not null,  -- HP
    Mp int not null,  -- MP
    constraint PK_Character primary key(Id)
);

次のモデルにマッピングしてみましょう。HpとMpのカラムは再利用したいという体でStatusクラスとしています。

// ステータス
public class Status {
    public int Hp { get; set; }
    public int Mp { get; set; }
}

// キャラクター
public class Character {
    public int Id { get; set; }
    public string Name { get; set; }
    public int Level { get; set; }
    // HPとMPを持つステータス
    public Status Status { get; set; }
}

マッピングするにはDBコンテキストのOnModelCreatingメソッド内でOwnsOneメソッドを使ってStatusプロパティを指定します。

OwnsOneメソッドの2つ目の引数では入れ子になったStatusクラスのマッピングも指定できます。マッピングを指定しないとStatusクラスの各プロパティは「Status_Hp」「Status_Mp」といったカラム名マッピングされるので、下記コードでは実際のカラム名に合うように調整しました。

// DBコンテキストの一部
public class AppDbContext : DbContext {
    protected override void OnModelCreating(ModelBuilder modelBuilder) {
        // Characterテーブルへのマッピング
        modelBuilder.Entity<Character>().ToTable(nameof(Character));

        // CharacterがStatusを所有する
        modelBuilder.Entity<Character>().OwnsOne(
            character => character.Status,
            statusBuilder => {
                // StatusをHpカラムとMpカラムへマッピング
                // (デフォルトだと「Status_Hp」「「Status_Mp」」といったカラムにマッピングされる)
                statusBuilder.Property(status => status.Hp).HasColumnName(nameof(Status.Hp));
                statusBuilder.Property(status => status.Mp).HasColumnName(nameof(Status.Mp));
            });
    }
}

これで準備が整ったので、うまくマッピングできているか確認してみましょう。

class Program {
    // 結果出力用
    private static void Dump(IEnumerable<Character> characters) {
        foreach (var character in characters) {
            Console.WriteLine($"{character.Name}");
            Console.WriteLine($"Lv {character.Level}");
            Console.WriteLine($"HP {character.Status.Hp}");
            Console.WriteLine($"MP {character.Status.Mp}");
            Console.WriteLine();
        }
    }

    static void Main(string[] args) {
        // データ投入
        using (var dbContext = new AppDbContext(tracking: true)) {
            dbContext.Set<Character>().AddRange(
                new Character {
                    Id = 1,
                    Name = "エイト",
                    Level = 10,
                    Status = new Status { Hp = 59, Mp = 32 },
                },
                new Character {
                    Id = 2,
                    Name = "ゼシカ",
                    Level = 10,
                    Status = new Status { Hp = 47, Mp = 28 },
                });

            dbContext.SaveChanges();
        }

        // 確認
        using (var dbContext = new AppDbContext()) {
            var characters = dbContext.Set<Character>().ToList();
            Dump(characters);
            /*
           エイト
           Lv 10
           HP 59
           MP 32
           ゼシカ
           Lv 10
           HP 47
           MP 28
           */
        }

        // データ更新
        using (var dbContext = new AppDbContext(tracking: true)) {
            // エイトを取得
            var character = dbContext.Set<Character>().Find(1);

            // エイトはレベルがあがった!
            character.Level += 1;
            character.Status.Hp += 12;
            character.Status.Mp += 2;

            dbContext.SaveChanges();
        }

        // 確認
        using (var dbContext = new AppDbContext()) {
            var characters = dbContext.Set<Character>().ToList();
            Dump(characters);
            /*
           エイト
           Lv 11
           HP 71
           MP 34
           ゼシカ
           Lv 10
           HP 47
           MP 28
           */
        }
    }
}

コードとクエリはこちら。

EF CoreのOwned typesのサンプル · GitHub

Entity Framework Core - IncludeとThenIncludeを試す

IncludeメソッドとThenIncludeメソッドは、あわせて読み込む関連エンティティを指定するために使います。Eager loadingと呼ばれている機能ですね。

Loading Related Data - EF Core | Microsoft Docs

このIncludeメソッドとThenIncludeメソッドを使うとどんなSQLが実行されるのか気になったので試してみました。実行されたSQLのログをペタペタとはっていきたいと思います。

コード全体やクエリはこちらに。

EF CoreのIncludeとThenInclude · GitHub

モデルとかデータとか

まず次のようなモデルを用意してみました。1対多の関係が1つ、多対多の関係が2つあります。1対多の関係が全部で5つと言ったほうがいいのか。

// MonsterCategory - Monster : 1対多
// Monster - MonsterArea - Area : 多対多
// Monster - MonsterItem - Item : 多対多
// 地域
public class Area {
    public int Id { get; set; }
    public string Name { get; set; }
}

// アイテム
public class Item {
    public int Id { get; set; }
    public string Name { get; set; }
}

// モンスターカテゴリ
public class MonsterCategory {
    public int Id { get; set; }
    public string Name { get; set; }
    public List<Monster> Monsters { get; set; }
}

// モンスター
public class Monster {
    public int Id { get; set; }
    public string Name { get; set; }
    public int CategoryId { get; set; }
    public MonsterCategory Category { get; set; }
    public List<MonsterArea> Areas { get; set; }
    public List<MonsterItem> Items { get; set; }
}

// モンスター生息地
public class MonsterArea {
    public int MonsterId { get; set; }
    public int No { get; set; }
    public int AreaId { get; set; }
    public Monster Monster { get; set; }
    public Area Area { get; set; }
}

// モンスター保持アイテム
public class MonsterItem {
    public int MonsterId { get; set; }
    public int No { get; set; }
    public int ItemId { get; set; }
    public Monster Monster { get; set; }
    public Item Item { get; set; }
}

次にロガーとDBコンテキストを用意します。

// ロガープロバイダー
public class AppLoggerProvider : ILoggerProvider {
    public ILogger CreateLogger(string categoryName) {
        if (string.Equals(categoryName, DbLoggerCategory.Database.Command.Name)) {
            return new ConsoleLogger();
        }

        return NullLogger.Instance;
    }

    public void Dispose() {
    }

    // ロガー
    private class ConsoleLogger : ILogger {
        public IDisposable BeginScope<TState>(TState state) => null;

        // 情報レベル以上のログを有効にする
        public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information;

        public void Log<TState>(
            LogLevel logLevel, EventId eventId,
            TState state, Exception exception,
            Func<TState, Exception, string> formatter) {
            Console.WriteLine(formatter(state, exception));
            Console.WriteLine("---");
        }
    }
}

// DBコンテキスト
public class AppDbContext : DbContext {
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) {
        var connectionString = new SqlConnectionStringBuilder {
            DataSource = ".",
            InitialCatalog = "Test",
            IntegratedSecurity = true,
        }.ToString();

        optionsBuilder.UseSqlServer(connectionString);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder) {
        modelBuilder.Entity<Area>().ToTable(nameof(Area));
        modelBuilder.Entity<Item>().ToTable(nameof(Item));
        modelBuilder.Entity<MonsterCategory>().ToTable(nameof(MonsterCategory));
        modelBuilder.Entity<Monster>().ToTable(nameof(Monster));
        modelBuilder.Entity<MonsterArea>().ToTable(nameof(MonsterArea))
            // 複合主キーのマッピング
            .HasKey(area => new { area.MonsterId, area.No });
        modelBuilder.Entity<MonsterItem>().ToTable(nameof(MonsterItem))
            .HasKey(item => new { item.MonsterId, item.No });
    }
}

データを投入しておきましょう。

// 地域
dbContext.Set<Area>().AddRange(
    new Area { Id = 1, Name = "サンタローズ" },
    new Area { Id = 2, Name = "レヌール" },
    new Area { Id = 3, Name = "ラインハット" });

// アイテム
dbContext.Set<Item>().AddRange(
    new Item { Id = 1, Name = "やくそう" },
    new Item { Id = 2, Name = "どくけしそう" });

// モンスターカテゴリ
dbContext.Set<MonsterCategory>().AddRange(
    new MonsterCategory { Id = 1, Name = "スライム" },
    new MonsterCategory { Id = 2, Name = "悪魔" });

// モンスター(とモンスター生息地・モンスター保持アイテム)
dbContext.Set<Monster>().AddRange(
    new Monster {
        Id = 1,
        Name = "スライム",
        CategoryId = 1,    // スライム
        Areas = new List<MonsterArea> {
            new MonsterArea { No = 1, AreaId = 1 }, // サンタローズ
        },
        Items = new List<MonsterItem> {
            new MonsterItem { No = 1, ItemId = 1 }, // やくそう
        },
    },
    new Monster {
        Id = 2,
        Name = "ドラキー",
        CategoryId = 2,    // 悪魔
        Areas = new List<MonsterArea> {
            new MonsterArea { No = 1, AreaId = 1 }, // サンタローズ
            new MonsterArea { No = 2, AreaId = 2 }, // レヌール
        },
    },
    new Monster {
        Id = 3,
        Name = "バブルスライム",
        CategoryId = 1,    // スライム
        Areas = new List<MonsterArea> {
            new MonsterArea { No = 1, AreaId = 3 }, // ラインハット
        },
        Items = new List<MonsterItem> {
            new MonsterItem { No = 1, ItemId = 1 }, // やくそう
            new MonsterItem { No = 2, ItemId = 2 }, // どくけしそう
        },
    });

dbContext.SaveChanges();

Includeメソッドで1対多の1を読み込む

まずはIncludeメソッドを使って1対多の多から1を読み込んでみます。

// モンスターカテゴリを含めてモンスターを読み込む
var monsters = dbContext.Set<Monster>()
    .Include(monster => monster.Category)
    .ToList();
foreach (var monster in monsters) {
    Console.WriteLine($"#{monster.Id} {monster.Name} [{monster.Category.Name}]");
}
/*
#1 スライム [スライム]
#2 ドラキー [悪魔]
#3 バブルスライム [スライム]
*/

ログを確認してみます。実行されるSQLはINNER JOINされてます。

Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [monster].[Id], [monster].[CategoryId], [monster].[Name], [monster.Category].[Id], [monster.Category].[Name]
FROM [Monster] AS [monster]
INNER JOIN [MonsterCategory] AS [monster.Category] ON [monster].[CategoryId] = [monster.Category].[Id]
---

Includeメソッドで1対多の多を読み込む

今度はIncludeメソッドを使って1対多の1から多を読み込んでみます。

var monsters = dbContext.Set<Monster>()
    .Include(monster => monster.Areas)
    .Include(monster => monster.Items)
    .ToList();
foreach (var monster in monsters) {
    var areas = string.Join(", ", monster.Areas.Select(area => area.AreaId));
    var items = string.Join(", ", monster.Items.Select(item => item.ItemId));
    Console.WriteLine($"#{monster.Id} {monster.Name} in {areas} has {items}");
}
/*
#1 スライム in 1 has 1
#2 ドラキー in 1, 2 has
#3 バブルスライム in 3 has 1, 2
*/

ログを確認すると、SELECT文が3つ実行されていることがわかります。どうもIncludeするごとにSELECT文が増える様子。こんなもの?

Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [monster].[Id], [monster].[CategoryId], [monster].[Name]
FROM [Monster] AS [monster]
ORDER BY [monster].[Id]
---
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [monster.Areas].[MonsterId], [monster.Areas].[No], [monster.Areas].[AreaId]
FROM [MonsterArea] AS [monster.Areas]
INNER JOIN (
    SELECT [monster0].[Id]
    FROM [Monster] AS [monster0]
) AS [t] ON [monster.Areas].[MonsterId] = [t].[Id]
ORDER BY [t].[Id]
---
Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [monster.Items].[MonsterId], [monster.Items].[No], [monster.Items].[ItemId]
FROM [MonsterItem] AS [monster.Items]
INNER JOIN (
    SELECT [monster1].[Id]
    FROM [Monster] AS [monster1]
) AS [t0] ON [monster.Items].[MonsterId] = [t0].[Id]
ORDER BY [t0].[Id]
---

Includeメソッドで多を読み込み、さらにThenIncludeメソッドで1を読み込む

ThenIncludeメソッドを使うと、Includeメソッドで指定した関連エンティティのさらに関連エンティティを読み込むことができます。

前述のサンプルにさらにThenIncludeメソッドを加えると、多対多のエンティティを一度に取得できます。確認してみましょう。

var monsters = dbContext.Set<Monster>()
    .Include(monster => monster.Areas)
        .ThenInclude(monsterArea => monsterArea.Area)
    .Include(monster => monster.Items)
        .ThenInclude(monsterItem => monsterItem.Item)
    .ToList();
foreach (var monster in monsters) {
    var areas = string.Join(", ", monster.Areas.Select(area => area.Area.Name));
    var items = string.Join(", ", monster.Items.Select(item => item.Item.Name));
    Console.WriteLine($"#{monster.Id} {monster.Name} in {areas} has {items}");
}
/*
#1 スライム in サンタローズ has やくそう
#2 ドラキー in サンタローズ, レヌール has
#3 バブルスライム in ラインハット has やくそう, どくけしそう
*/

多対1の多から1をThenIncludeしているので、SQLも前述のサンプルにThenIncludeした分のINNER JOINが加わった感じです。

Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [monster].[Id], [monster].[CategoryId], [monster].[Name]
FROM [Monster] AS [monster]
ORDER BY [monster].[Id]
---
Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [monster.Areas].[MonsterId], [monster.Areas].[No], [monster.Areas].[AreaId], [m.Area].[Id], [m.Area].[Name]
FROM [MonsterArea] AS [monster.Areas]
INNER JOIN [Area] AS [m.Area] ON [monster.Areas].[AreaId] = [m.Area].[Id]
INNER JOIN (
    SELECT [monster0].[Id]
    FROM [Monster] AS [monster0]
) AS [t] ON [monster.Areas].[MonsterId] = [t].[Id]
ORDER BY [t].[Id]
---
Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [monster.Items].[MonsterId], [monster.Items].[No], [monster.Items].[ItemId], [m.Item].[Id], [m.Item].[Name]
FROM [MonsterItem] AS [monster.Items]
INNER JOIN [Item] AS [m.Item] ON [monster.Items].[ItemId] = [m.Item].[Id]
INNER JOIN (
    SELECT [monster1].[Id]
    FROM [Monster] AS [monster1]
) AS [t0] ON [monster.Items].[MonsterId] = [t0].[Id]
ORDER BY [t0].[Id]
---

Includeメソッドで多を読み込み、さらにThenIncludeメソッドで多を読み込む

ThenIncludeメソッドでの読み込みをもう1つ試してみましょう。多を読み込んでさらに多を読み込みます。

// モンスターとモンスター生息地を含めて、モンスターカテゴリを読み込む
var categories = dbContext.Set<MonsterCategory>()
    .Include(category => category.Monsters)
        .ThenInclude(monster => monster.Areas)
    .ToList();
foreach (var monster in categories.SelectMany(category => category.Monsters)) {
    var areas = string.Join(", ", monster.Areas.Select(area => area.AreaId));
    Console.WriteLine($"#{monster.Id} {monster.Name} [{monster.Category.Name}] in {areas}");
}
/*
#1 スライム [スライム] in 1
#3 バブルスライム [スライム] in 3
#2 ドラキー [悪魔] in 1, 2
*/

実行されるSQLは、多をIncludeまたはThenIncludeするごとにSELECT文が増えていく感じでしょうか。

Executed DbCommand (0ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [category].[Id], [category].[Name]
FROM [MonsterCategory] AS [category]
ORDER BY [category].[Id]
---
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [category.Monsters].[Id], [category.Monsters].[CategoryId], [category.Monsters].[Name]
FROM [Monster] AS [category.Monsters]
INNER JOIN (
    SELECT [category0].[Id]
    FROM [MonsterCategory] AS [category0]
) AS [t] ON [category.Monsters].[CategoryId] = [t].[Id]
ORDER BY [t].[Id], [category.Monsters].[Id]
---
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [category.Monsters.Areas].[MonsterId], [category.Monsters.Areas].[No], [category.Monsters.Areas].[AreaId]
FROM [MonsterArea] AS [category.Monsters.Areas]
INNER JOIN (
    SELECT DISTINCT [category.Monsters0].[Id], [t0].[Id] AS [Id0]
    FROM [Monster] AS [category.Monsters0]
    INNER JOIN (
        SELECT [category1].[Id]
        FROM [MonsterCategory] AS [category1]
    ) AS [t0] ON [category.Monsters0].[CategoryId] = [t0].[Id]
) AS [t1] ON [category.Monsters.Areas].[MonsterId] = [t1].[Id]
ORDER BY [t1].[Id0], [t1].[Id]
---

IncludeメソッドやThenIncludeメソッドを使うとSELECT文が複数実行される場合があるということがなんだか気になるところでした。

Entity Framework Core - SQLをログで確認する

EF Coreでやってみたいことは色々ありますが、まずは実行されるSQLを確認できるようにしておきたいのでログまわりを少し試します。

ドキュメントだとこのあたりですね。

Logging - EF Core | Microsoft Docs

ログを出力する

上記ドキュメントそのままなんですが、ログを出力するためにまずはILoggerProviderILoggerを実装します。

// ロガープロバイダー
public class AppLoggerProvider : ILoggerProvider {
    // ロガーを生成
    public ILogger CreateLogger(string categoryName) {
        return new ConsoleLogger();
    }

    public void Dispose() {
    }

    // ロガー
    private class ConsoleLogger : ILogger {
        public IDisposable BeginScope<TState>(TState state) => null;
        public bool IsEnabled(LogLevel logLevel) => true;

        // ログを出力
        public void Log<TState>(
            LogLevel logLevel, EventId eventId,
            TState state, Exception exception,
            Func<TState, Exception, string> formatter) {
            // コンソールに出力
            Console.WriteLine(formatter(state, exception));
        }
    }
}

コンソールアプリでは実装したロガープロバイダーをDBコンテキストに設定します。(エンティティとDBコンテキストのコードは前回の記事を参考にしてもらえると。)

using (var dbContext = new AppDbContext()) {
    // ロガープロバイダーを設定する
    var serviceProvider = dbContext.GetInfrastructure();
    var loggerFactory = serviceProvider.GetService<ILoggerFactory>();
    loggerFactory.AddProvider(new AppLoggerProvider());

    // データを取得
    var monsters = dbContext.Monsters.ToList();
    foreach (var monster in monsters) {
        Console.WriteLine($"#{monster.Id} {monster.Name}");
    }
    //#1 スライム
    //#2 ドラキー
}

実行すると次のようなログがわっさーと出てきます。ちょっと見づらいですが、コネクションを開いたり閉じたりしたログ、SELECT文を実行したログなどが確認できますね。

Compiling query model:
'from Monster <generated>_0 in DbSet<Monster>
select [<generated>_0]'
Optimized query model:
'from Monster <generated>_0 in DbSet<Monster>
select [<generated>_0]'
(QueryContext queryContext) => IEnumerable<Monster> _InterceptExceptions(
    source: IEnumerable<Monster> _TrackEntities(
        results: IEnumerable<Monster> _ShapedQuery(
            queryContext: queryContext,
            shaperCommandContext: SelectExpression:
                SELECT [m].[Id], [m].[Name]
                FROM [Monster] AS [m],
            shaper: UnbufferedEntityShaper<Monster>),
        queryContext: queryContext,
        entityTrackingInfos: { itemType: Monster },
        entityAccessors: List<Func<Monster, object>>
        {
            Func<Monster, Monster>,
        }
    ),
    contextType: ConsoleApp.AppDbContext,
    logger: DiagnosticsLogger<Query>,
    queryContext: queryContext)
Opening connection to database 'Test' on server '.'.
Opened connection to database 'Test' on server '.'.
Executing DbCommand [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [m].[Id], [m].[Name]
FROM [Monster] AS [m]
Executed DbCommand (15ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [m].[Id], [m].[Name]
FROM [Monster] AS [m]
A data reader was disposed.
Closing connection to database 'Test' on server '.'.
Closed connection to database 'Test' on server '.'.

ログをフィルタする

このままではちょっと情報が多いので、実行されるSQLだけを確認したいという体でログをフィルタしたいと思います。

ILoggerProvider.CreateLoggerメソッドの引数にcategoryNameがあります。ログにはいくつかカテゴリがあるようで、おそらくDbLoggerCategoryで定義されているのがそれです。

ソースを見た+試してみたところ、DbLoggerCategory.Database.CommandSQLの実行に関係してそうです。これを使ってフィルタするには次のようなコードになるのかなと思います。

// ロガープロバイダー
public class AppLoggerProvider : ILoggerProvider {
    // ロガーを生成
    public ILogger CreateLogger(string categoryName) {
        // SQLの実行に関するログだけ出力する
        if (string.Equals(categoryName, DbLoggerCategory.Database.Command.Name)) {
            return new ConsoleLogger();
        }

        // 何も出力しないロガー
        return NullLogger.Instance;
    }

    public void Dispose() {
    }

    // ロガー
    private class ConsoleLogger : ILogger {
        public IDisposable BeginScope<TState>(TState state) => null;
        public bool IsEnabled(LogLevel logLevel) => true;

        // ログを書き込む
        public void Log<TState>(
            LogLevel logLevel, EventId eventId,
            TState state, Exception exception,
            Func<TState, Exception, string> formatter) {
            // ログをコンソールに出力
            // LogLevelとEventIdも出力してみる
            Console.WriteLine($"{nameof(logLevel)}: {logLevel}");
            Console.WriteLine($"{nameof(eventId)}: {eventId}");
            Console.WriteLine(formatter(state, exception));
            Console.WriteLine("---");
        }
    }
}

このロガーで再度実行すると次のようなログが出力されます。

logLevel: Debug
eventId: Microsoft.EntityFrameworkCore.Database.Command.CommandExecuting
Executing DbCommand [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [m].[Id], [m].[Name]
FROM [Monster] AS [m]
---
logLevel: Information
eventId: Microsoft.EntityFrameworkCore.Database.Command.CommandExecuted
Executed DbCommand (43ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT [m].[Id], [m].[Name]
FROM [Monster] AS [m]
---
logLevel: Debug
eventId: Microsoft.EntityFrameworkCore.Database.Command.DataReaderDisposing
A data reader was disposed.
---

ILogger.Logメソッドの引数にあるLogLevelEventIdも気になったので出力してみましたが、このパラメータを使ってフィルタしたり、出力先を変えたりすることもできそうですね。

ちなみにEventIdRelationalEventIdで定義されています。

今回はこんなところで。