ORマッパー(EFCore)で出身DBやUpdatable/ReadOnlyを変数型で表現する

 EntityFrameworkCoreを使ったときに色々心配になったので、変数型と構文チェックの力を借りて工夫してみたという話。

 例えばEFCoreで普通にDbContextを作ると

public class MyDbContext : DbContext
{
    public DbSet<Account> Account { get; set; }
}

 AccountテーブルにアクセスできるDbContextはこんな感じになるかと思います。一般的な使い方なのですが

  • AccountインスタンスがプライマリーDB出身なのかリードレプリカ出身なのか
  • Accountインスタンスの値を上書きしたときに更新されるだろうか?(AsNoTrackingされてたら?)

というのが気になっていました。そこで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段階経てしまうと推測できないようでコンパイルエラーとなります。