shuhelohelo’s blog

Xamarin.Forms多めです.

Mobile Computing C# オンラインクラス 9

動画 www.youtube.com

オリジナルソースコード github.com

EntityFramework Coreをインストール

EntityFramework.Sqlite 3.1をNugetでインストール.

f:id:shuhelohelo:20191230014336p:plain

microsoft.entityframeworkcore.designをNugetでインストール.

f:id:shuhelohelo:20191230014549p:plain

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.

f:id:shuhelohelo:20191230102330p:plain

SQLiteのdbファイルに対してテーブルの表示などの各種DB操作を行いたい場合は,DB Browser for SQLiteというツールを使うと便利.

sqlitebrowser.org

「Open Database」でmydb.dbファイルを開くと,以下のようにテーブルの一覧が表示される.

f:id:shuhelohelo:20191230103126p:plain

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:

C#ではこのusingステートメントを簡略化できる.

            using var transaction = await context.Database.BeginTransactionAsync();

//省略

            await transaction.CommitAsync();

この場合のスコープはこのusingステートメントのひとつ外側のスコープ.それを抜けるときにDisposeされる.

csharp.christiannagel.com

サンプルデータの追加

サンプルデータをプロジェクトに追加し,それらのプロパティを開いてCopy if newerを設定する.

f:id:shuhelohelo:20191230111850p:plain

まずは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を選択する.

f:id:shuhelohelo:20191230171214p:plain

すると,以下のようにコレクションのアイテムにID($1)というIDがつけられる.

f:id:shuhelohelo:20191230171801p:plain

では次にFillBooksAsyncメソッド内の以下の部分にブレークポイントを設定して,処理をここで止めさせる.

            var genres = await context.Genres.ToArrayAsync();

            var books = JsonSerializer.Deserialize<IEnumerable<Book>>(
                await File.ReadAllTextAsync("Data/Books.json"));

そしてローカル変数genresの中を見てみると,先程つけたID$1を持つオブジェクトがあることがわかる.

f:id:shuhelohelo:20191230172336p:plain

グローバル変数や静的変数を使っていないのに,別の場所でつけたIDがついたままになっている.

これはEntityFrameworkがDBから1度読み込んだオブジェクトをキャッシュして状態を保持しているため.コピーではない.

このようにEntityFramework Coreはオブジェクトの状態を追跡していて,SaveChangesAysncメソッドで,加えられた変更をDBに反映させる.

不要な場合は以下のようにcontextからデータを取得する際にAsNoTrackingメソッドをつける.

AsNoTrackingをつけると,EntityFrameworkは変更などの状態を追跡しないので,SaveChangesAysncなどで変更が反映されなくなる.

f:id:shuhelohelo:20191230173533p:plain

DBから取得したデータに変更を加えない場合はこちらが実行速度が速い.

docs.microsoft.com

慣れないうちはそのまま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);

f:id:shuhelohelo:20191230175525p:plain

これはくせがあるので注意!

複数テーブルを連結したデータの取得.

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();

写経ソースコード

github.com