Mobile Computing C# オンラインクラス 9
オリジナルソースコード github.com
EntityFramework Coreをインストール
EntityFramework.Sqlite 3.1をNugetでインストール.
microsoft.entityframeworkcore.designをNugetでインストール.
Modelを作る
Book,Author,Genreクラスに加え,今回はBook,Author間に多対多の関係があるのでそのための中間テーブルが必要.
多対多を表すときは中間テーブルとなるモデル(ここではBookAuthorクラス)を作る.
これはEntityFramework Coreでは明示的に作る必要がある.
public class BookAuthor { //BookAuthorIDはいらない. //BookIDとAuthorIDの複合主キーだから #region Bookへの参照 public int BookID { get; set; } [Required] public Book Book { get; set; } #endregion #region Authorへの参照 public int AuthorID { get; set; } [Required] public Author Author { get; set; } #endregion }
DbContextを作る
public class BookDataContext:DbContext { public DbSet<Book> Books { get; set; } public DbSet<Author> Authors { get; set; } public DbSet<Genre> Genres { get; set; } public DbSet<BookAuthor> BookAuthors { get; set; } }
OnConfiguringメソッドをoverrideする
ここでDBとしてSQLiteを使うこと,接続先のdbファイルのパスを指定する.
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { //Sqliteを使うことを指定する //ASP.NET CoreならStartup.csに書くのだろうけれど var dbPath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "mydb.db"); optionsBuilder.UseSqlite($"Data Source={dbPath}"); //SqliteはサーバーではなくファイルがDB }
OnModelCreatingをoverrideする
もう一つ,OnModelCreatingメソッドをoverrideするが,ここではモデルの各プロパティに対する制約などをFluent APIで記述する.
例:BookクラスのISBNは一意(ユニーク)なものなので,重複を許さない制約をつけたい. でも属性にはそのような制約をつけるものがない. そういうときにFluent API.
modelBuilder.Entity<Book>() .HasIndex(b => b.ISBN)//ISBNプロパティにインデックスを張る .IsUnique();//Unique制約をつける
この場合,IsUnique
メソッドはHasIndex
メソッドの返り値の型であるIndexBuilder
クラスのメソッドなので,ワンセットとなる.
属性で書くか,Fluent APIで書くかは完全に好み,またはチームのコーディング規約の問題.
modelBuilder.Entity<Author>() .Property(a => a.AuthorName) .IsRequired();
と
[Required] public string AuthorName { get; set; }
は同じ.
多対多の関係の記述
さて,BookAuthorクラスの複合主キーをFluent APIで記述しなければならない.
//BookAuthorクラスの複合主キーを指定 modelBuilder.Entity<BookAuthor>() .HasKey(ba => new { ba.BookID, ba.AuthorID });
そして,BookクラスとBookAuthorクラス,AuthorクラスとBookAuthorクラスの間の一対多の関係をEntityFramework Coreでは自動で認識できないので,これもFluetn APIで指定する必要がある.
(注)最初からシンプルな一対多の関係であれば,EntityFrameworkが自動でうまくやってくれる.しかし今回の一対多は,多対多を表すための一対多なので状況が異なる.
modelBuilder.Entity<Book>() .HasMany(b => b.Authors) .WithOne(a => a.Book);
書き方はまず一対多の一(いち)
の方から始める.
この例ではBookが一
側なのでmodelBuilder.Entity<Book>()
から始める.
Bookから見るとAuthorsは多
なのでHasMany
.
次にAuthorsに対してBookが一
であることを表すためにWithOne
.とする.
マイグレーションをおこなう.
> dotnet ef migrations add Initial
そして,
> dotnet ef database update
これでSQLiteのdbファイルがUseSqlite
メソッドで指定したパスに作成される.
この例ではAdvancedEfCore\bin\Debug\netcoreapp3.1\mydb.db
.
SQLiteのdbファイルに対してテーブルの表示などの各種DB操作を行いたい場合は,DB Browser for SQLite
というツールを使うと便利.
「Open Database」でmydb.db
ファイルを開くと,以下のようにテーブルの一覧が表示される.
DBの初期化を行う.
ProgramクラスをpartialにしてDB初期化メソッドとしてCleanDatabaseAsync
メソッドを定義する.
partial class Program { private async Task CleanDatabaseAsync(BookDataContext context) { //Delete all data from all tables //悪い例 //レコードひとつひとつを消してセーブを繰り返すので //めちゃくちゃ遅い //var books = await context.Books.ToArrayAsync(); //foreach(var book in books) //{ // context.Books.Remove(book); // await context.SaveChangesAsync(); //} //トランザクションを使って,すべて成功かすべて失敗かを保証する using var transaction = await context.Database.BeginTransactionAsync(); //生SQLを実行. await context.Database.ExecuteSqlRawAsync($"DELETE FROM {nameof(context.BookAuthors)}"); await context.Database.ExecuteSqlRawAsync($"DELETE FROM {nameof(context.Books)}"); await context.Database.ExecuteSqlRawAsync($"DELETE FROM {nameof(context.Authors)}"); await context.Database.ExecuteSqlRawAsync($"DELETE FROM {nameof(context.Genres)}"); //何もエラーが怒らなかった場合に限り,それをDBに反映する. await transaction.CommitAsync(); } }
レコード一つ一つ消していったらとても遅いので,ExecuteSqlRawAsync
メソッドで生SQLを実行している.
生SQLの生成ではnameof
メソッドを使い,コンパイラがチェックできるようにしている.
トランザクションを使う
この一連のDB操作のアトミック性を保証するためにTransaction
を使用する.
BeginTransactionAsync
からCommitAsync
までに行われるDBの処理は,すべて成功した場合に限りDBに反映される.
中途半端な状態にならないようにする.
//トランザクションを使って,すべて成功か変更しないかを保証する using (var transaction = await context.Database.BeginTransactionAsync()) { //生SQLを実行. await context.Database.ExecuteSqlRawAsync($"DELETE FROM {nameof(context.BookAuthors)}"); await context.Database.ExecuteSqlRawAsync($"DELETE FROM {nameof(context.Books)}"); await context.Database.ExecuteSqlRawAsync($"DELETE FROM {nameof(context.Authors)}"); await context.Database.ExecuteSqlRawAsync($"DELETE FROM {nameof(context.Genres)}"); //何もエラーが起こらなかった場合に限り,それをDBに反映する. await transaction.CommitAsync(); }
Tips:
using var transaction = await context.Database.BeginTransactionAsync(); //省略 await transaction.CommitAsync();
この場合のスコープはこのusingステートメントのひとつ外側のスコープ.それを抜けるときにDisposeされる.
サンプルデータの追加
サンプルデータをプロジェクトに追加し,それらのプロパティを開いてCopy if newer
を設定する.
まずはGenreデータの追加.
注意: SaveChangesAsyncメソッドは,ループの中に置いたりしないこと.
一連の処理が完了してからまとめて一回行う.
// Note that we add all genre data rows BEFORE calling SaveChanges. foreach (var g in genre) { context.Genres.Add(g); } await context.SaveChangesAsync();
Mainメソッドで,このサンプルデータ読み込みを行う.
async void Main(string[] args) { using var context = new BookDataContext(); await CleanDatabaseAsync(context);//初期化 await FillGenreAsync(context);//ジャンルデータ追加 }
ここでTracking(トラッキング)について.
例えば,FillGenreAsync
メソッド内の以下の部分にブレークポイントをセットする.
foreach (var g in genre) { context.Genres.Add(g);//ブレークポイントをセットする }
そして,ローカル変数g
をマウスオーバーするとg
のオブジェクト情報が表示されるので,その上で右クリックしてコンテキストメニューからMake Object ID
を選択する.
すると,以下のようにコレクションのアイテムにID($1
)というIDがつけられる.
では次にFillBooksAsync
メソッド内の以下の部分にブレークポイントを設定して,処理をここで止めさせる.
var genres = await context.Genres.ToArrayAsync();
var books = JsonSerializer.Deserialize<IEnumerable<Book>>(
await File.ReadAllTextAsync("Data/Books.json"));
そしてローカル変数genres
の中を見てみると,先程つけたID$1
を持つオブジェクトがあることがわかる.
グローバル変数や静的変数を使っていないのに,別の場所でつけたIDがついたままになっている.
これはEntityFrameworkがDBから1度読み込んだオブジェクトをキャッシュして状態を保持しているため.コピーではない.
このようにEntityFramework Coreはオブジェクトの状態を追跡していて,SaveChangesAysncメソッドで,加えられた変更をDBに反映させる.
不要な場合は以下のようにcontextからデータを取得する際にAsNoTracking
メソッドをつける.
AsNoTracking
をつけると,EntityFrameworkは変更などの状態を追跡しないので,SaveChangesAysncなどで変更が反映されなくなる.
DBから取得したデータに変更を加えない場合はこちらが実行速度が速い.
慣れないうちはそのままTrackingさせておけばよい.
最後のAuthorデータの登録はちょっとやり方が違う.
AuthorテーブルにAddしていくのではない.
これは中間テーブルのBookAuthorテーブルへもデータを追加するから.
//Authors.jsonから読み込んだAuthorデータから //新しくAuthorインスタンスを作る //直接authorを使ってはいけない? //直接authorを使っても良いけれど, //もしauthorが変更された場合,それがDBに登録されることになる. //それを避けるためか? var dbAuthor = new Author { AuthorName = author.AuthorName, Nationality = author.Nationality }; // Randomly assign each author one book. // Note that we can use the dbAuthor, although we have not yet written // it to the database. Also note that we are using the book ID as a // foreign key. // 最後に,AuthorをDBに追加するときは // Bookとの多対多の関係を登録する必要がある // ここはちょっとくせがあるな. // AuthorオブジェクトとBookIDを使う. // AuthorとBookじゃないんだな. var dbBookAuthor = new BookAuthor { Author = dbAuthor, BookID = bookIDs[rand.Next(bookIDs.Length)] }; //ここまででまだcontext.Authors.Addが出てきていないことに注目! // Note that we do NOT need to add dbAuthor. It is referenced by // dbBookAuthor, that is enough. // BookAuthorsにAddするだけよい. // これだけでAuthorsテーブルにもAuthorが登録される. // これはEntityFrameworkがやってくれる. context.BookAuthors.Add(dbBookAuthor);
これはくせがあるので注意!
複数テーブルを連結したデータの取得.
BookはGenreの情報を持っているが,Genre情報はGenreテーブルに格納されている.
BookテーブルとGenreテーブルはGenreIDを外部キーとして関連付けられている.
そこで,Genre情報付きでBook情報を取得したい場合,方法は2つある.
方法1:foreachを回してGenre情報を一つ一つ取得していく
var books = await context.Books.ToArrayAsync(); foreach (var book in books) { book.Genre = await context.Genres.FirstAsync(g => g.GenreID == book.GenreID); }
これより,Include
を使った方法2のほうがスマートである.
方法2:Includeメソッドを使う
var books = await context.Books .Include(b => b.Genre) .ToArrayAsync();