LoginSignup
12
19

More than 5 years have passed since last update.

【Entity Framework Core 2.1~ 】エンティティクラスのプロパティは読み取り専用にしよう

Last updated at Posted at 2018-10-28

はじめに

この記事のエンティティクラスとは、Entity Framework Coreにおいてデータベースにマッピングされるクラスのことを指します。

Entity Framework Core 2.1からエンティティクラスのプロパティを読み取り専用にできるようになりました。
これまで、Entity Framework Coreでエンティティクラスを定義する場合、以下のように書き込みもpublicなプロパティが必要でした。

書籍エンティティ
class Book {
  public int Id { get; set; }
  public string Title { get; set; }
  public string Author { get; set; }
}

Entity Framework Core 2.1からは以下のようにセッターをprivateにできるようになりました。

class Book {
  public int Id { get; private set; }
  public string Title { get; private set; }
  public string Author { get; private set; }

  public Book(int id, string title, string author) {
    Id = id;
    Title = title;
    Author = author;
  }
}

インスタンスを外部から書き換えられないので、初期化はプロパティ初期化子1でなくコンストラクターで明示的に行います。
プロパティが書き込み専用ならインスタンスの状態が不変になり、内部のデータを外部からの意図せぬ変更から守ることができます。

※ C# 6 から、コンストラクターでのみ値を代入可能なgetアクセサーだけのプロパティを定義できましたが、Entity Framework Coreではprivate setがないとマッピングが行われません。
※ ただし、OnModelCreatingのフックで明示的にマッピングするとprivate setも不要になります。後半に書きます。

プロジェクトの作成

サンプルプロジェクトを作成しプロパティが読み取り専用なエンティティクラスを使用した CRUD 処理の説明したいと思います。

最終的なソースは以下のリポジトリにおきました。
https://github.com/sano-suguru/readonly-model-props

$ dotnet --version
2.1.403
# テンプレートは最低限のコンソールアプリを使用します。
$ dotnet new console -o "readonly-model-props"
$ cd ./readonly-model-props
# DBマイグレーションを省略するためインメモリ DB を使用します。
$ dotnet add package "Microsoft.EntityFrameworkCore.InMemory"

エンティティクラス

エンティティクラスは 「はじめに」 と同じ書籍エンティティを使用します。
クラス外部からはプロパティが読み取り専用になっています。

Book.cs
class Book {
  public int Id { get; private set; }
  public string Title { get; private set; }
  public string Author { get; private set; }

  public Book(int id, string title, string author) {
    Id = id;
    Title = title;
    Author = author;
  }
}

データベースコンテキスト

データベースはマイグレーションを省略するためインメモリデータベースを使います。
今回は最初からシードデータを持たせておきます。

using Microsoft.EntityFrameworkCore;

class InMemoryDbContext : DbContext {
  public DbSet<Book> Books { get; set; }

  protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
    optionsBuilder.UseInMemoryDatabase(databaseName: "InMemoryDatabase");

  protected override void OnModelCreating(ModelBuilder modelBuilder) =>
    modelBuilder.Entity<Book>().HasData(
      new Book(id: 1, title: "Flutter入門", author: "掌田 津耶乃"),
      new Book(id: 2, title: "Nuxt.jsビギナーズガイド", author: "花谷 拓磨"),
      new Book(id: 3, title: "IntelliJ IDEAハンズオン", author: "山本 裕介")
    );
}

プロパティが読み取り専用の CRUD 処理

プロパティが読み取り専用の場合、インスタンスに対しbook.Title = "FooBar"のように書き換えることができません。
そのため、通常のEntity Framework Coreの CRUD 処理と勝手が変わってくる部分があります。

抽出クエリ(READ

プロパティが読み取り専用でも特別な処理はありません。

using (var context = new InMemoryDbContext()) {
  var books = context.Books.AsNoTracking().AsEnumerable();
  foreach (var book in books) {
    Console.WriteLine($"{book.Id} {book.Title} {book.Author}");
  }
}

データベースのデータがすべて抽出されました。

結果
1 Flutter入門 掌田 津耶乃
2 Nuxt.jsビギナーズガイド 花谷 拓磨
3 IntelliJ IDEAハンズオン 山本 裕介

AsNoTrackingについて

上記ではIQueryable<Book>に対しAsNoTracking付いています。
通常、インスタンスは Entity Framework Coreによって追跡され、エンティティ内で検出された変更はすべてSaveChangesメソッドを呼んだときデータベースに保存されます。
AsNoTrackingによって追跡されない場合、変更追跡の情報を設定する必要がないのでクエリが高速化されます。

新規作成クエリ(CREATE

プロパティ初期化子が使えないので、Bookインスタンスの初期値はコンストラクタに渡されます。

using (var context = new InMemoryDbContext()) {
  var newbook = new Book(
    id: 4,
    title: "C#プログラミングのイディオム/定石&パターン",
    author: "出井 秀行"
  );
  context.Books.Add(newbook);

  // 結果の確認
  foreach (var book in context.Books.AsNoTracking()) {
    Console.WriteLine($"{book.Id} {book.Title} {book.Author}");
  }
}

データベースに書籍が追加されているのが確認できます。

結果
1 Flutter入門 掌田 津耶乃
2 Nuxt.jsビギナーズガイド 花谷 拓磨
3 IntelliJ IDEAハンズオン 山本 裕介
4 C#プログラミングのイディオム/定石&パターン 出井 秀行

更新クエリ(UPDATE

データベースから抽出した更新対象のインスタンスを直接書き換えることができないため、少し特殊です。
新規にBookのインスタンスを作成し、その際に更新するプロパティ以外を更新対象のプロパティで初期化します。
作成したインスタンスで更新をかけますが、Entity Framework CoreではIdは自動で主キーになっているため、一致するIdのデータを更新されます。

using (var context = new InMemoryDbContext()) {
  var target = context.Books
    .AsNoTracking()
    .Single(b => b.Id == 3);
  context.Books.Update(
    new Book(
      id: target.Id,
      title: target.Title,
      author: "今井 勝信"
    )
  );
  context.SaveChanges();

  // 更新の確認
  var updated = context.Books.AsNoTracking().Single(b => b.Id == 3);
  Console.WriteLine($"{updated.Id} {updated.Title} {updated.Author}");
}

対象(Id == 3が)のデータが更新されていることが確認できます。

結果
3 IntelliJ IDEAハンズオン 今井 勝信  # 元は 山本 裕介

削除クエリ(DELETE

抽出クエリと同様、プロパティが読み取り専用でも特別な処理はありません。

using (var context = new InMemoryDbContext()) {
  var target = context.Books.Single(b => b.Id == 1);
  context.Books.Remove(target);
  context.SaveChanges();

  foreach (var book in context.Books.AsNoTracking()) {
    Console.WriteLine($"{book.Id} {book.Title} {book.Author}");
  }
}

ID == 1のデータがデータベースから削除されているのが確認できます。

結果
2 Nuxt.jsビギナーズガイド 花谷 拓磨
3 IntelliJ IDEAハンズオン 今井 勝信
4 C#プログラミングのイディオム/定石&パターン 出井 秀行

プロパティを内部からも読み取り専用にする

public ~ { get; private set }の場合、クラス内部からはデータを書き換えることが可能でしたが、getのみの場合はコンストラクタでの初期化のみ可能です。
「はじめに」で触れたprivate setもなくgetアクセサのみプロパティを持つエンティティクラスを定義したいと思います。

まず、エンティティクラスのプロパティからprivate setを除きます。

   class Book {
-    public int Id { get; private set; }
-    public string Title { get; private set; }
-    public string Author { get; private set; }
+    public int Id { get; }
+    public string Title { get; }
+    public string Author { get; }

     public Book(int id, string title, string author) {
       Id = id;
       Title = title;
       Author = author;
     }
   }

このままではEntity Framework Coreでマッピングが行われないため、
データベースコンテキストのOnModelCreatingで明示的にプロパティのマッピングを定義します。

   class InMemoryDbContext : DbContext {
     public DbSet<Book> Books { get; set; }

-    protected override void OnModelCreating(ModelBuilder modelBuilder) =>
+    protected override void OnModelCreating(ModelBuilder modelBuilder) {
+      modelBuilder.Entity<Book>(book => {
+        book.HasKey(nameof(Book.Id));
+        book.Property(e => e.Title);
+        book.Property(e => e.Author);
+      });
       modelBuilder.Entity<Book>().HasData(
         new Book(id: 1, title: "Flutter入門", author: "掌田 津耶乃"),
         new Book(id: 2, title: "Nuxt.jsビギナーズガイド", author: "花谷 拓磨"),
         new Book(id: 3, title: "IntelliJ IDEAハンズオン", author: "山本 裕介")
       );
+    }
   }

これでエンティティクラスのプロパティをgetのみにすることができました。

Idをコンストラクタから取り除く

今回、エンティティクラスではコンストラクタの引数にIdを初期値として渡せるようにしました。
本来Entity Framework CoreではIdは自動でインクリメントされ採番されます。
自動で採番されるIDにはコンストラクタから値を渡す必要がありません。

class Book {
  public int Id { get; private set; } // private な setter を定義(EF Coreに使われる)
  public string Title { get; }
  public string Author { get; }

  public Book(string title, string author) {  // コンストラクタの引数に id を渡さない
    Title = title;
    Author = author;
  }
}

DbContext.OnModelCreatingではIDのマッピングの定義は不要です。
(エンティティクラス側でprivate setがあるため)

InMemoryDbContextの抜粋
  protected override void OnModelCreating(ModelBuilder modelBuilder) =>
    modelBuilder.Entity<Book>(book => {
      // book.HasKey(nameof(Book.Id)); 不要
      book.Property(e => e.Title);
      book.Property(e => e.Author);
    });

IDが自動採番されているのを確認します。

using (var context = new InMemoryDbContext()) {
  Book[] newbooks = {
    new Book(title: "Flutter入門", author: "掌田 津耶乃"),
    new Book(title: "Nuxt.jsビギナーズガイド", author: "花谷 拓磨"),
    new Book(title: "IntelliJ IDEAハンズオン", author: "山本 裕介")
  };
  context.Books.AddRange(newbooks);
  context.SaveChanges();

  foreach (var book in context.Books.AsNoTracking()) {
    Console.WriteLine($"{book.Id} {book.Title} {book.Author}");
  }
}
結果(IDが採番されているのが確認できる)
1 Flutter入門 掌田 津耶乃
2 Nuxt.jsビギナーズガイド 花谷 拓磨
3 IntelliJ IDEAハンズオン 山本 裕介

ただし、Idをコンストラクタから取り除いた場合、OnModelCreatingのフックでシードデータを設定することはできません。
modelBuilder.Entity<TEntity>().HasDataでシードデータ生成する際、IDは自動採番されないためです。

modelBuilder.Entity().HasDataで初期データをシーディングしようとした場合
Unhandled Exception: System.InvalidOperationException:
  The seed entity for entity type 'Book' cannot be added because there was no value provided for the required property 'Id'.

ASP.NET Core MVCWeb APIでは以下のようにProgram.csでシードデータを採番するコードを自分で書くとよいと思います。

public static void Main(string[] args) {
  var host = BuildWebHost(args);
  using (var scope = host.Services.CreateScope()) {
    var provider = scope.ServiceProvider;
    try {
      var context = provider.GetRequiredService<InMemoryDbContext>();
      context.Books.AddRange(new[] {
        new Book(title: "Flutter入門", author: "掌田 津耶乃"),
        new Book(title: "Nuxt.jsビギナーズガイド", author: "花谷 拓磨"),
      });
      Context.SaveChanges(); 
    } catch (Exception ex) {
      var logger = provider.GetRequiredService<ILogger<Program>>();
      logger.LogError(ex, "データベース初期化中にエラーが発生しました。");
    }
  }
  host.Run();
}

  1. セッターもpublicならnew Book { Id = 1, Title = "Scala本", Author = "ゴンタロウ" }のように初期化できる。 

12
19
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
19