Npgsql(ef core)を利用してエンティティのバージョン管理を行う

SQL Serverを利用していると特に工夫することなく利用できるエンティティのバージョン管理(Concurrency Tokens - EF Core | Microsoft Docs)ですが、PostgreSQLでは少し工夫が必要です。

PostgreSQLではMisc | Npgsql Documentationに記載のある通り、内部的に管理されているシステム列のうちxmin列の値をバージョン管理に使用します。

このため、コンソールアプリケーションなどのようにefのChangeTrackerが状態をすべて管理できる場合にはバージョンを気にする必要はないのですが、ASP.NETのような状態管理がefから離れるようなシステムの場合には少し面倒になります。

バージョン管理を利用するための設定

バージョン管理を使用するための設定はContextクラスに以下の設定を追加すればOKです。

 public class HogeContext : DbContext
{
        
        public DbSet<PostalAddress> PostalAddresses { get; set; }
        ...
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            // このようにForNpgsqlUseXminAsConcurrencyTokenを追加すればバージョン管理対象となる
            modelBuilder.Entity<PostalAddress.PostalAddress>().ForNpgsqlUseXminAsConcurrencyToken();
            ....
        }
}

エンティティの取得時にバージョン番号を設定する

ASP.NETなどのウェブアプリケーションではエンティの取得時にバージョン番号を取得してクライアントへバージョン番号を送信し、データの更新時に取得時のバージョン番号をクライアントからサーバーへ送信して、更新時にバージョン番号が一致することを確認します。そのためにはエンティティの取得時にバージョン番号を予め取得する必要があります。

ところが、Npgsqlのバージョン番号はShadow Propertiesに定義されているため普通に取得することはできません。このShadow Propertiesについてはこちらに説明がありますが、efのChangeTrackerにより値が管理されています。

Shadow Properties - EF Core | Microsoft Docs

値の取得方法は以下の通りとなります。

(uint) context.Entry(entity).Property("xmin").CurrentValue;

このようにすることで、Shadow Propertyに定義されているxmin列から値を取得することができます。

ただ、Contextクラスから取得する必要があるため通常のLinqで取得した処理の後に取得した対象エンティティの全てに上記の値を設定する必要があります。

取得した値は、各エンティティにはCurrentVersionというフィールドを定義し[NotMapped]を設定しています。

エンティティ

namespace Hoge
{
    public class PostalAddress
    {
        [Required]
        [MaxLength(36)]
        [MinLength(36)]
        public string Id { get; set; }

        ...

        // NotMappedでCurrentVersionを定義している
        [NotMapped]
        public uint CurrentVersion { get; set; }
    }
}

このエンティティにバージョン番号を設定するためDbContextの拡張メソッドを定義しています。

バージョン番号の取得

public static class DbContextExtensions
{
    public  static void CurrentVersion<T>(this DbContext context,  T entity)
        where T:class, IVersionable
    {
        entity.CurrentVersion = (uint) context.Entry(entity).Property("xmin").CurrentValue;
    }
}

UpdateやDeleteのときにバージョン番号を設定する

次に実際の更新処理についてです。NpgsqlではUpdateやDeleteなどをefから発行すれば自動的にバージョン番号を付加したSQLを発行してくれます。

[15:24:16 INF] Executed DbCommand (0ms) [Parameters=[@p0='7f396576-9ddb-4b5f-9ce4-3a2dcf641c27' (Nullable = false), @p1='3347' (DbType = Object)], CommandType='Text', CommandTimeout='30']
DELETE FROM "customers"
WHERE "id" = @p0 AND "xmin" = @p1;

ここに記載されている条件のxmin=@p1がバージョン番号の比較になります。特に設定しないときには更新対象のデータ取得時一緒に取得され(、ShadowPropertiesに設定され)るバージョン番号が設定されます。

ASP.NETなどのウェブアプリケーションでは、ここにクライアントから送信されてきたバージョン番号を設定する必要があります。設定は例によってShadow Propertiesへ設定することになります。

この処理についてもDbContextに拡張メソッドを定義して設定できるようにしています。

public static void SetCurrentVersion<T>(this DbContext context, T entity, ILogger logger = null)
    where T : class, IVersionable
{
    logger?.LogDebug(
        $"{entity}.CurrentVersion = {entity.CurrentVersion} / xmin = {context.Entry(entity).Property("xmin").CurrentValue}");
    logger?.LogDebug(
        $"{entity} xmin.OriginalValue = {context.Entry(entity).Property("xmin").OriginalValue}");

    // ここでOriginalValueに値を設定する
    context.Entry(entity).Property("xmin").OriginalValue = entity.CurrentVersion;

    logger?.LogDebug(
        $"--> {entity} xmin.OriginalValue = {context.Entry(entity).Property("xmin").OriginalValue}");
}

Shadow PropertiesにはOriginalValueCurrentValueがありますが、OrigginalValueが比較対象のバージョン番号として利用されているようです。(ef coreのソースを読めていないので挙動としてそのようになっているという認識です。)

最後に

SQL Serverだとスムーズに対応できるバージョン番号ですが、PostgreSQLを利用する場合には少し手間が必要です。どうやるんだろうかと思って色々と調べてみたところこんな感じでうまく行っていますという内容ですので、間違いやもっといい方法があれば教えていただけるとうれしいです。

実戦で役立つ C#プログラミングのイディオム/定石&パターン

実戦で役立つ C#プログラミングのイディオム/定石&パターン

追記

aspnet/EntityFrameworkCore: Entity Framework Core is a lightweight and extensible version of the popular Entity Framework data access technologyCurrentVersionのコメントを読んでみたところ以下の通りデータベースに処理を行うときに使用する値と記載がありました。

github.com

        /// <summary>
        ///     Gets the original property values for this entity. The original values are the property
        ///     values as they were when the entity was retrieved from the database.
        /// </summary>
        /// <value> The original values. </value>
        public virtual PropertyValues OriginalValues
        {
            [DebuggerStepThrough] get => new OriginalPropertyValues(InternalEntry);
        }

DbContextのTracking設定をすべてのエンティティに設定する

テストプログラムなどで更新処理を実行するときにContextのTrackingが有効な場合、Contextから更新後のデータを取得するときに最初に取得した情報をContextが保持していてテストがFailになる事があります。忘れた頃に発生する事象で、たいてい急いでいるときにテスト対象のプログラムは正しいけどテストが通らないというつらい状況になったりします。

このようなケースでは最初にエンティティを取得するときにAsNoTrackingを設定すればOKです。

 var personCustomer = CustomerContext.Context.Customers.AsNoTracking().Include(c => c.Person).Include(c => c.Organization)
                .First(c => c.Person != null);

とはいうものの、テストのように基本データを参照しかしないケースではデフォルトとしてAsNoTrackingを設定したいですよね。むしろ、TrackingしたいときにだけAsTrackingを設定することで余計なハマりをなくしたいわけです。

そこでContext単位でAsnoTrackingを一括で設定できる方法はないだろうかということになりますが、以下の通り設定すればContextに含まれるエンティティの取得がすべてTracking対象ではなくなります。

 Context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;

参考

以下の公式ドキュメントにもあるようにReadonlyとしてエンティティを使用する場合にはChangeTrackerの生成コストが小さくなるためおすすめのようです。

ChangeTracker Class (Microsoft.EntityFrameworkCore.ChangeTracking) | Microsoft Docs

Gets or sets the tracking behavior for LINQ queries run against the context. Disabling change tracking is useful for read-only scenarios because it avoids the overhead of setting up change tracking for each entity instance. You should not disable change tracking if you want to manipulate entity instances and persist those changes to the database using SaveChanges(). This method sets the default behavior for the context, but you can override this behavior for individual queries using the AsNoTracking(IQueryable) and AsTracking(IQueryable) methods. The default value is TrackAll. This means the change tracker will keep track of changes for all entities that are returned from a LINQ query.

大人数で開発するときにはこの手のはまりは人数分のコストになるので、技術的に解決できるところはあらかじめ解決しておきたいですね。

追記

Npgsqlを利用している場合には、テスト対象データのバージョン番号を取得するためにはAsTrackingを設定する必要があります。

C#プログラマーのための 基礎からわかるLINQマジック!

C#プログラマーのための 基礎からわかるLINQマジック!

ASP.NET Core2のStartup.csでJson.NETの設定を行う

ASP.NET CoreでJson.NET - Newtonsoftの設定をStartup.csに行うには以下の通りConfigureServicesで初期化を行うことができます。

public void ConfigureServices(IServiceCollection services)
        {
             ...
            services.AddMvc().AddJsonOptions(options =>
            {
                options.SerializerSettings.Converters.Add(new Newtonsoft.Json.Converters.StringEnumConverter());
                options.SerializerSettings.NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore;
            });
            ....
}

今回は値が設定されていない項目については出力しないように設定するようにしました。

なお、この設定は各フィールドに属性を以下の通り設定することでも対応できます。

[JsonProperty("property_name", NullValueHandling=NullValueHandling.Ignore)]
public string Hoge{get;set;}

ASP.NET MVCプログラミング入門 (マイクロソフト関連書)

ASP.NET MVCプログラミング入門 (マイクロソフト関連書)