WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

Linq to Entities でのキャッシュに注意

by WebSurfer 19. September 2021 16:54

Linq to Entities でエンティティを追加したり取得したりする場合、デフォルトではエンティティは DbContext にキャッシュされるそうです。それでハマったので、再びそういうことがないよう備忘録を書いておくことにしました。

検証結果のテーブル

元の話は Teratail のスレッド「[C#,.NET5,EFCore5+Microsfot.Data.Sqlite] トランザクションのロールバックが意図通りに動かない」です。その話は表題とは異なり、ロールバックは期待通り動いていたがキャッシュのために動いてないと勘違いしたというものです。

本題に入る前に、まずトランザクション / ロールバックの話を書いておきます。

トランザクションは、保留中の状態 (BeginTransaction の呼び出し後、Commit の呼び出し前) だけからロールバックできるようになっています。逆に言えば、保留中の状態であれば RollBack を呼び出せばロールバックできます。

Microsoft のドキュメント「トランザクションの使用」の「トランザクションを制御する」のセクションのサンプルコードを見てください。

SaveChanges はすべて完全に成功していても、transaction は SaveChanges でコミットされるわけではない(保留中の状態にある)ので、transaction.Commit(); がないと transaction が Dispose される時にロールバックされます。

そのコードで transaction.Commit(); ⇒ transaction.RollBack(); としたのが Teratail のスレッドの話ですが、その場合はもちろん無条件でロールバックされます。

DB はロールバックはされたのですが、キャッシュされたエンティティまではロールバックされないので、キャッシュされたエンティティを使ってその後の操作を行った結果ロールバックが失敗しているように見えたという話です。

エンティティをキャッシュする理由は、Microsoft のドキュメント「追跡と追跡なしのクエリ」に書いてあるように、追跡を行うためということです。どういうことかと言うと、エンティティに加えられた変更を追跡していって、SaveChanges メソッドで変更結果を DB に反映するということらしいです。

その記事に書いてある "If EF Core finds an existing entity, then the same instance is returned. EF Core won't overwrite current and original values of the entity's properties in the entry with the database values." というのは「エンティティがキャッシュにあればキャッシュから取得する。DB の値で上書きされることはない」と言っているように思えます。

"If the entity isn't found in the context, then EF Core will create a new entity instance and attach it to the context." というのは「context.Blogs.Add(...) というようにするとそのエンティティも DbContext にキャッシュされる」ということのように思えます。

Teratail のスレッドのように、自分で RollBack と書くようなことはしないはずなので、普通はキャッシュによる問題には遭遇しなそうな気がします。

そこを、若干無理やりですが、こんなことをすると問題になるかもしれないと作ったサンプルが下のコードです。Visual Studio 2019 のテンプレートで作った .NET 5.0 のコンソールアプリです。DB は SQLite を使っています。

Teratail のものとは違って、普通に Commit と書いて例外発生時のみロールバックするようにしています。ただし、例外を catch してなかったことにしているので、その後キャッシュされたエンティティを使っての作業が継続できるというものです。

これを実行した結果の DB の内容が上の画像です。Name が SEQ1, SEQ2, SEQ3 の既存のレコードがあって、それに赤枠で囲った SEQ5 のレコードを追加しています。

transaction で囲った 1 つ目の SaveChanges で SEQ5 の Value を 2 に UPDATE していますが、2 つ目の SaveChanges で PK 制約違反の例外が発生するので Commit できずロールバックされるようになっています。DB 上では上の画像の通りロールバックされて SEQ5 の Value は初期値 1 のままになっています。

コードのコメント「ここでエンティティがキャッシュされる」のところで SEQ5 のエンティティがキャッシュされています。ロールバックされた後、変数 seq1, seq2, seq3 に SEQ5 のエンティティを取得してその Value プロパティをコンソールに書き出すと以下のように順に 2, 2, 1 となります。

コンソールへの出力

seq1, seq2 はキャッシュから取得されており、キャッシュはロールバックされないので、それらの Value はコードで書き換えた 2 のままになっています。

予想外だったのは seq2 です。これは要注意だと思いました。context.Sequences.ToListAsync() で DB に SELECT クエリを発行してロールバック後のすべてのレコードを取得してくるのですが、SEQ5 のエンティティのみはキャッシュで書き換えられてしまっています。

seq3 は Reload して DB からデータを取得してキャッシュを書き換えた結果です。上の画像のロールバック後の DB の値が Reload で取得されて Value は 1 になっています。

using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace ConsoleAppSQLite
{
    class Program
    {
        static async Task Main(string[] args)
        {
            using (var context = new TestDBContext())
            {
                // ここでエンティティがキャッシュされる
                context.Sequences.Add(
                    new SequenceValue { Name = "SEQ5", Value = 1 });

                // 上で作った Name = "SEQ5", Value = 1 の新規データを INSERT
                context.SaveChanges();

                try
                {
                    using (var transaction = context.Database.BeginTransaction())
                    {
                        // 上で INSERT した Name = "SEQ5", Value = 1 のレコード
                        // の Value を 2 にして UPDATE
                        SequenceValue seq = context.Sequences.Find("SEQ5");
                        seq.Value = 2;                        
                        context.SaveChanges();

                        // Name = "SEQ2", Value = 1 のレコードを INSERT
                        // Name は主キーで "SEQ2" のレコードは DB に存在する
                        // ので PK 制約違反
                        context.Sequences.Add(
                            new SequenceValue { Name = "SEQ2", Value = 100 });
                        context.SaveChanges();

                        // 上の SaveChanges で例外が発生するので Commit されない
                        // 結果 transaction が Dispose される際ロールバックされる
                        transaction.Commit();
                    }
                }
                catch (Exception)
                {
                    // 例外処理・・・何もしないと例外はなかったことになる
                }

                // キャッシュから取得する。ロールバックはキャッシュは書き換え
                // ないので、上のコードで seq.Value = 2 とした結果が取得される
                SequenceValue seq1 = context.Sequences.Find("SEQ5");

                // キャッシュから取得しないようにするには AsNoTracking() を
                // 追加して以下のようにする
                //SequenceValue seq1 = await context.Sequences
                //                           .AsNoTracking()
                //                           .SingleAsync(x => x.Name == "SEQ5");

                Console.WriteLine(seq1.Value);

                // DB に SELECT クエリを発行して全てレコードを取得してくるが
                // Name(主キー)が "SEQ5" のエンティティだけはキャッシュから
                // 取得して list を書き換える。                
                List<SequenceValue> list = await context.Sequences.ToListAsync();

                // 書き換えられないようにするには AsNoTracking() を追加して
                // 以下のようにする
                //List<SequenceValue> list = 
                //    await context.Sequences.AsNoTracking().ToListAsync();

                SequenceValue seq2 = list.Find(x => x.Name == "SEQ5");
                Console.WriteLine(seq2.Value);

                // Reload すると DB からデータを取得してキャッシュを書き換
                // えるので Value はロールバック後の値 1 になる
                SequenceValue seq3 = context.Sequences.Find("SEQ5");
                await context.Entry(seq3).ReloadAsync();
                Console.WriteLine(seq3.Value);
            }
        }
    }


    [Table("SEQUENCES")]
    public class SequenceValue
    {
        [Key, Column("NAME")] 
        public string Name { get; set; }
        
        [Column("VALUE")] 
        public int Value { get; set; }
    }

    public class TestDBContext : DbContext
    {
        protected override void OnConfiguring(DbContextOptionsBuilder builder)
        {
            var path = @"C:\Users\...省略...\test.db";
            var connStr = "Data Source=" + path;

            builder.UseSqlite(connStr);

            // 出力ウィンドウに EF Core ログを表示
            builder.LogTo(msg => System.Diagnostics.Debug.WriteLine(msg));
        }
        public DbSet<SequenceValue> Sequences { get; set; }
    }
}

あと、コメントにも書きましたが、DbExtensions.AsNoTracking メソッドを適用すると "Returns a new query where the entities returned will not be cached in the DbContext or ObjectContext." ということで、キャッシュは使われなくなるようです。こういうやり方が正解なのかどうかは分かりませんが。

Tags: , , , ,

ADO.NET

WinForms で構成情報とコンテキストの DI (CORE)

by WebSurfer 30. March 2021 15:00

.NET Core 3.1 の Windows Forms アプリで、構成情報を取得する方法、さらに DI 機能を追加して、取得した構成情報と EF Core で利用するコンテキストクラスを DI する方法を書きます。

DataGridView に結果を表示

ASP.NET Core アプリのプロジェクトを Visual Studio 2019 のテンプレートを使って作成すると、appsettings.json などの構成ファイルが自動的に生成されてプロジェクトに含まれます。さらに、構成ファイルから情報を読み込んで IConfiguration オブジェクトが生成され、構成情報を取得できるようになります。(詳しくは Microsoft のドキュメント「ASP.NET Core の構成」参照)

また、作成したプロジェクトには DI 機能も自動的に組み込まれ、生成された IConfiguration オブジェクトを DI コンテナに登録し、必要に応じて Controller や Page のコンストラクタ経由で DI できる機能が実装されます。

DI コンテナには ILogger, UserManager, EF Core で使用するコンテキストクラスなども登録でき、これらも必要に応じて Controller や Page のコンストラクタ経由で DI できます。

Windows Forms やコンソールアプリの場合はそれらの機能は Visual Studio 2019 のテンプレートを使っても実装されず、自力でコードを書いて実装する必要があります。

.NET Core 3.1 の Windows Forms アプリで appsettings.json ファイルから IConfiguration オブジェクトを作って構成情報を取得する方法、DI機能を実装して IConfiguration オブジェクトとコンテキストクラスを DI する方法を以下に書きます。

(1) プロジェクトの作成

Visual Studio 2019 のテンプレートを利用して、ターゲットフレームワーク .NET Core 3.1 で Windows Forms アプリのプロジェクトを作成します。

VS2019 のテンプレート

ターゲットフレームワークを、この記事を書いた時点での最新 .NET 5.0 ではなく .NET Core 3.1 としたのは、3.1 が Long Term Support (LTS) 版であること、5.0 でサポートされた新機能を使用しなくても可能なことを確認するためです。

(2) NuGet パッケージのインストール

Visual Studio 2019 の[ツール(T)]⇒[NuGet パッケージマネージャ(N)]⇒[ソリューションの NuGet パッケージの管理(N)...] を開いて以下のパッケージをインストールします。

  1. Microsoft.EntityFrameworkCore.SqlServer
  2. Microsoft.EntityFrameworkCore.Tools
  3. Microsoft.Extensions.Configuration.Json
  4. Microsoft.Extensions.DependencyInjection

下の画像がインストールした結果です。各パッケージのバージョンはこの記事を書いた時点での最新です。プロジェクトのターゲットフレームワークは .NET Core 3.1 なのですが、それに合わせる必要はなかったです。

NuGet パッケージのインストール

ちなみに、上のリストの 1 は EF Core を利用して SQL Server にアクセスして処理を行うための機能、2 は Visual Studio の NuGet Pakage Manager Console で Add-Migration, Scaffold-DbContext などのコマンドを利用できるようにするための機能、3 は appsettings.json などの構成ファイルから情報を取得するための機能、4 は DI 機能の実装のために必要です。

(3) appsettings.json の作成

ソリューションエクスプローラーを操作してプロジェクトに json ファイルを追加します。ファイル名は任意ですが、この記事では ASP.NET アプリに合わせて "appsettings.json" にしました。内容はこの記事では以下の通り接続文字列のみとしましたが、他に任意の情報を含めることができます。

{
  "ConnectionStrings": {
    "NorthwindConnection": "Data Source=(local)\\sqlexpress; ..."
  }
}

作成したらそのプロパティの中の「出力ディレクトリにコピー」を「常にコピーする」または「新しい場合はコピーする」に設定するのを忘れないようにしてください。接続文字列のバックスラッシュ \ は \\ にエスケープする必要があるので注意してください。

appsettings.json のプロパティ設定

(4) 構成情報が取得できることを確認

上記ステップ (1) で作成した Windows Forms アプリの Form1 のコンストラクタに以下のコードを追加して、変数 connString に appsettings.json に設定した接続文字列が取得できることを確認します。

using System.Windows.Forms;
using System.IO;
using WinFormsCore3App1.Contexts;

// NuGet packages:
// Microsoft.Extensions.Configuration.Json
// Microsoft.Extensions.DependencyInjection
// Microsoft.EntityFrameworkCore.SqlServer
// Microsoft.EntityFrameworkCore.Tools

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;

namespace WinFormsCore3App1
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();

            // Configuration の生成
            // appsettings.json に接続文字列が含まれている
            var builder = new ConfigurationBuilder()
                .SetBasePath(Directory.GetCurrentDirectory())
                .AddJsonFile("appsettings.json", optional: false);
            IConfiguration config = builder.Build();

            // appsettings.json から取得した接続文字列
            string connString = 
                config.GetConnectionString("NorthwindConnection");
        }
    }
}

(5) DI 機能の実装

DI 機能の実装と IConfiguration オブジェクトの DI コンテナへの登録は以下の 2 行を上のステップ (4) の Form1 のコンストラクタに追加することで可能です。

// DI コンテナ
IServiceCollection services = new ServiceCollection();

// Configuration を DI コンテナに登録
services.AddSingleton(config);

Configuration を DI できるようにするにはさらなるコードの追加が必要です。 それは下にコンテキストクラスの DI 方法と共に書きます。

(6) コンテキストクラスの生成

この記事では Microsoft のサンプルデータベース Northwind の Products, Categories, Suppliers テーブルからリバースエンジニアリングでコンテキストクラスとエンティティクラスを生成して使います。

詳しくは先の記事「スキャフォールディング機能 (CORE)」のステップ (1) を見てください。

上の appsettings.json と違ってバックスラッシュ \ はエスケープする必要はないところに注意してください。エスケープして \\ としたりするとエラーになります。

成功するとコンテキストクラス NorthwindContext.cs と各テーブルのエンティティクラス Product.cs, Category.cs, Supplier.cs が指定したフォルダに生成されます。

NorthwindContext.cs ファイルの NorthwindContext クラスの引数を持たないコンストラクタと OnConfiguring メソッドはコメントアウトしてください。

(7) ProductService クラスの作成

SQL Server から EF Core を利用してデータを取得するクラスを作成します。ソリューションエクスプローラーでクラスファイルを ProductService.cs という名前で追加し、以下のコードを実装します。

コードの説明はコメントに書きましたのでそれを見てください。

using Microsoft.Extensions.Configuration;
using WinFormsCore3App1.Contexts;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System.Threading.Tasks;

namespace WinFormsCore3App1
{
    public class ProductService
    {
        private readonly IConfiguration _configuration;
        private readonly NorthwindContext _context;

        // コンストラクタの引数経由で Configuration と
        // NorthwindContext のインスタンスを DI する
        public ProductService(IConfiguration configuration,
                              NorthwindContext context)
        {
            this._configuration = configuration;
            this._context = context;
        }

        // DI された Configuration から接続文字列が取得できる
        // ことを確認するための検証用メソッド
        public string GetConnectionString()
        {
            return _configuration
                   .GetConnectionString("NorthwindConnection");
        }

        // DI されたコンテキスト NorthwindContext を使ってSQL
        // Server の Northwind データベースからデータを取得し
        // List<ProductItem> 型のオブジェクトとして返す。
        // それを DataGridView に表示
        public async Task<List<ProductItem>> GetListAsync()
        {
            var list = from p in _context.Products
                       join s in _context.Suppliers
                       on p.SupplierId equals s.SupplierId
                       join c in _context.Categories
                       on p.CategoryId equals c.CategoryId
                       select new ProductItem 
                       { 
                           ProductId = p.ProductId,
                           ProductName = p.ProductName,
                           Supplier = s.CompanyName,
                           Category = c.CategoryName,
                           UnitPrice = p.UnitPrice.Value
                       };

            return await list.ToListAsync();
        }
    }

    // DataGridView に渡すデータを格納する Data Transfer
    // Object クラスの定義
    public class ProductItem
    {
        public int ProductId { get; set; }

        public string ProductName { get; set; }

        public string Supplier { get; set; }

        public string Category { get; set; }

        public decimal UnitPrice { get; set; }

    }
}

(8) Form1 クラスの完成

Form1 のコンストラクタで DI コンテナに Configuration, NorthwindContext, ProductService を登録し、ServiceProvider から ProductService のインスタンスを生成する際 Configuration と NorthwindContext のインスタンスはコンストラクタ経由 DI されるように設定します。

上記ステップ (4) のコードを含めた完全なコードは以下の通りです。実行した結果がこの記事の一番上にある画像です。

using System.Windows.Forms;
using System.IO;

// NuGet packages:
// Microsoft.Extensions.Configuration.Json
// Microsoft.Extensions.DependencyInjection
// Microsoft.EntityFrameworkCore.SqlServer
// Microsoft.EntityFrameworkCore.Tools

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using WinFormsCore3App1.Contexts;
using Microsoft.EntityFrameworkCore;

namespace WinFormsCore3App1
{
    public partial class Form1 : Form
    {
        // ProductService は SQL Server から EF Core を利用
        // してデータを取得するクラス
        private readonly ProductService productService;

        public Form1()
        {
            InitializeComponent();

            // Configuration の生成
            // appsettings.json に接続文字列が含まれている
            var builder = new ConfigurationBuilder()
                .SetBasePath(Directory.GetCurrentDirectory())
                .AddJsonFile("appsettings.json", optional: false);
            IConfiguration config = builder.Build();

            // appsettings.json から取得した接続文字列
            string connString = 
                config.GetConnectionString("NorthwindConnection");

            // DI コンテナ
            IServiceCollection services = new ServiceCollection();

            // Configuration を DI コンテナに登録
            services.AddSingleton(config);

            // NorthwindContext はリバースエンジニアリングで生成
            // したコンテキストクラス。それを DI コンテナに登録
            services.AddDbContext<NorthwindContext>(options =>
                options.UseSqlServer(connString));

            // ProductService を DI コンテナに登録
            services.AddSingleton<ProductService>();

            // ServiceProvider を生成
            var provider = services.BuildServiceProvider();

            // ServiceProvider から ProductService のインスタンス
            // を生成。その際、Configuration と NorthwindContext
            // のインスタンスはコンストラクタ DI される
            productService = 
                provider.GetRequiredService<ProductService>();

            // DataGridView と BindingSource はデザイン画面で
            // ツールボックスから Form にドラッグ&ドロップ
            dataGridView1.DataSource = bindingSource1;
        }

        private async void Form1_Load(object sender, System.EventArgs e)
        {
            // ProductService に DI した Configuration から接続
            // 文字列を取得できることの確認用
            var northwind = productService.GetConnectionString();

            // EF Core を使って Northwind の Products テーブルか
            // ら List<T> 型のデータを取得し DataGridView に表示 
            var list = await productService.GetListAsync();
            bindingSource1.DataSource = list;
        }
    }
}

Tags: , , ,

CORE

About this blog

2010年5月にこのブログを立ち上げました。その後 ブログ2 を追加し、ここは ASP.NET 関係のトピックス、ブログ2はそれ以外のトピックスに分けました。

Calendar

<<  September 2021  >>
MoTuWeThFrSaSu
303112345
6789101112
13141516171819
20212223242526
27282930123
45678910

View posts in large calendar