ORマッパー(EFCore)で出身DBやUpdatable/ReadOnlyを変数型で表現する
EntityFrameworkCoreを使ったときに色々心配になったので、変数型と構文チェックの力を借りて工夫してみたという話。
例えばEFCoreで普通にDbContextを作ると
public class MyDbContext : DbContext { public DbSet<Account> Account { get; set; } }
AccountテーブルにアクセスできるDbContextはこんな感じになるかと思います。一般的な使い方なのですが
というのが気になっていました。そこでDbContextの記述を工夫することにしました。
まずはインターフェース。DIを使ってインターフェースを通してDBにアクセスすることを想定してます。
// プライマリーDBアクセス用 public interface IMyDbContext: IMyNoTrackingDbContext<MainDb>, IDisposable { DbSet<Account> Account { get; } } // リードレプリカアクセス用 public interface IMyReadDbContext: IMyNoTrackingDbContext<ReadDb>, IDisposable { } public interface IMyNoTrackingDbContext<TDb> : IDisposable where TDb: MyDbType { // 更新不要の場合はこちらから取得する IQueryable<IAccount<TDb>> NoTrackingAccount { get; } } public interface IAccount<TDb> where TDb: MyDbType { public string AccountId { get; } }
プライマリーDBにアクセスするインターフェースとリードレプリカにアクセスするインターフェースを別々に用意しています。 ジェネリクス引数にあるMainDbがプライマリーDB、ReadDbがリードレプリカを表します。 ジェネリクスの指定なども考えてMainDb、ReadDbは次のように宣言します。
public class MyDbType {} public class MainDb : MyDbType {} public class ReadDb: MyDbType {}
クラスの定義はインターフェースを継承してこんな感じ。
public class MyDbContext : DbContext, IMyDbContext { public DbSet<Account> Account { get; set; } IQueryable<IAccount<MainDb>> IMyNoTrackingDbContext<MainDb>.NoTrackingAccount => this.Account.AsNoTracking(); } public class MyReadDbContext: DbContext, IMyReadDbContext { public DbSet<ReadOnlyAccount> Account { get; set; } IQueryable<IAccount<ReadDb>> IMyNoTrackingDbContext<ReadDb>.NoTrackingAccount => this.Account.AsNoTracking(); } public class Account : IAccount<MainDb> { public string AccountId { get; set; } } public class ReadOnlyAccount : IAccount<ReadDb> { public string AccountId { get; protected set; } }
ここまで準備すると、プログラム中の型を見るだけで色々わかるようになります。 AccountならプライマリーDB出身でアップデート可能、IAccount<ReadDb>ならリードレプリカ出身、といった感じです。
記述量の多さに関しては自動生成で何とかします。私の場合はDB定義をJSONで書いて、T4を使って自動生成しています。
AsNoTrackingはDB定義の記述以外では使用禁止にします。アップデート不可能なAccoutインスタンスが登場するのを防ぎます。全文検索で定期的に探してNoTrackingAccountから取得するように置き換えます。
失敗パターン
上記の形になる前に一度失敗をしています。そのときは
public class MyDbContext : DbContext, IMyDbContext { public DbSet<Account> Account { get; set; } } public class MyReadDbContext: DbContext, IMyReadDbContext { public DbSet<ReadOnlyAccount> Account { get; set; } IQueryable<ReadOnlyAccount> IMyReadDbContext.ReadOnlyAccount => this.Account.AsNoTracking(); }
のように出身DBを表すジェネリクス変数なしで定義していました。しばらく運用した結果、
- AccountインスタンスがAsNoTrackingされてて、値を上書きしても更新されないかも
- プライマリーDB用とリードレプリカ用で同じ内容を2回書く必要があってBad
という問題が発生しました。
AccountインスタンスがAsNoTrackingされてて、値を上書きしても更新されないかも
当初はReadOnlyでNoTrackingに呼び出したいなら全部リードレプリカから取得すれば良い、と考えてました。 プライマリーDBからNoTrackingで取得することは想定していなかったのです。
ところが、プライマリーDBからもNoTrackingで呼び出す必要が出てきました。 具体的には、変更をセーブした直後にその値を取得する、という場面では全部プライマリーDBで実施する必要があります。 (プライマリーDBに)変更をセーブした直後に、(リードレプリカから)その値を取得する、ということをすると 低確率で変更後の値を取得できなかったのです(リードレプリカへのコピーが間に合ってない)。
結果としてプライマリーDBからAsNoTracking でデータを取得するようになり、 Account型を見ても更新可能なAccountインスタンスなのかAsNoTrackingされたAccountインスタンスなのか一見ではわからなくなってしましました。
プライマリーDB用とリードレプリカ用で同じ内容を2回書く必要があってBad
例えばAccountIdでAccountインスタンスを取得する関数を作成した場合
public static class MyDbContextExtensions { public static Account GetAccountFromPrimaryDb( this IMyDbContext db, string accountId) { return db.Account.FirstOrDefault(e => e.AccountId == accountId); } public static ReadOnlyAccount GetAccountFromReadDb( this IMyReadDbContext db, string accountId) { return db.ReadOnlyAccount.FirstOrDefault(e => e.AccountId == accountId); } }
こんな感じで同じような記述を2回書く必要が出てきてしまいました。 上記くらい単純な例ならまだ良いのですが、実際は多数のJoinを行う関数が存在し、複雑な記述の同期を取る必要がでてきてしまいました。
改善後は
public static class MyDbContextExtensions { public static IAccount<TDb> GetAccount<TDb>( this IMyNoTrackingDbContext<TDb> db, string accountId) where TDb: MyDbType { return db.NoTrackingAccount.FirstOrDefault(e => e.AccountId == accountId); } }
だけでOKになります。
何とかしたいけど何とかなってない点
上記の例は、NoTrackingなAccountインスタンスを取得するだけならOKです。 しかし、Update可能なAccountインスタンスも取得できるようにしようとすると
public static class MyDbContextExtensions { public static T GetAccount<T, TDb>( this IQueryable<T> accountTable, string accountId) where T: IAccount<TDb> where TDb: MyDbType { return accountTable.FirstOrDefault(e => e.AccountId == accountId); } }
こんな感じでジェネリクス変数2つを用いた記述になります。こうすると
Account wriableAccount = db.Account.GetAccount<Account, MainDb>(accountId); IAccount<MainDb> readOnlyAccountFromMainDb = db.NoTrackingAccount.GetAccount<IAccount<MainDb>, MainDb>(accountId); IAccount<ReadDb> readOnlyAccountFromReadDb = readDb.NoTrackingAccount.GetAccount<IAccount<ReadDb>, ReadDb>(accountId);
のように全パターン対応できます。ただジェネリクス変数を書くのが手間です。 このジェネリクス変数、省略可能にできる気がするのですが、ジェネリクスを2段階経てしまうと推測できないようでコンパイルエラーとなります。