次の方法で共有



April 2018

Volume 33 Number 4

データ ポイント - EF Core 2 所有エンティティと一時的な回避策

Julie Lerman

Julie Lerman現在 EF Core 2.0 で提供されている「所有エンティティ」機能は、これまでの Entity Framework (EF ~ EF6) で提供されていた「複合型」機能の後継機能です。所有エンティティでは、値オブジェクトをデータ ストアにマッピングできます。値オブジェクトに基づくプロパティが Null になることを許可するビジネス ルールはかなり一般的です。さらに、値オブジェクトは不変なので、値オブジェクトを含むプロパティを置き換え可能にすることも重要です。現バージョンの EF Core は、このいずれのシナリオも既定では許可しません。ただし、今後のバージョンでは、これらのシナリオがサポートされる見込みです。このサポートが提供されるまでの間、ドメイン駆動設計 (DDD) パターンを利用する開発者が、この制限を厄介な問題として抱え込むことのないように、今回はこの制限を回避する方法を紹介します。DDD の実践者は、DDD の原則に十分に即していないとしてこの一時的なパターンを否定するかもしれません。しかし、私の実用主義者的な部分は、このパターンが単なる一時的な解決策だと認識することで、このパターンを受け入れ、支持しています。

先ほど、「既定では」と書きました。 これは、ドメイン クラスやビジネス ルールに大きな影響を与えることなく、EF Core で NULL 所有エンティティに関する独自のルールを強制して、値オブジェクトの置き換えを許可する方法があることを意味します。このコラムでは、その方法を示します。

NULL 値許容の問題を回避する方法は他にもあります。たとえば、単純に値オブジェクト型を独自のテーブルにマッピングして、値オブジェクト データを所属するオブジェクトの残りの部分から物理的に切り離します。シナリオによってはこれが有効な解決策になりますが、個人的には使いたくない方法です。そのため、今回は独自に考え出した回避策を紹介します。しかし、その前に、この問題と回避策をコラム 1 回を使って説明するのか、その重要性に触れておきます。

値オブジェクトの概要

値オブジェクトは、複数の値を 1 つのプロパティにカプセル化できる型です。string 型は、値オブジェクトのわかりやすい例です。string 型は、char 型の集合から構成されます。また、string 型は不変です。この不変性は、値オブジェクトの重要な側面です。文字 c、a、r の組み合わせと順序には、固有の意味があります。これらの組み合わせや順序を変更すると (たとえば、最後の文字を "t" に変えると)、意味がまったく変わります。  オブジェクトは、値すべての組み合わせによって定義されます。そのため、オブジェクトを変更できないという事実は、コントラクトの一部です。また、値オブジェクトにはもう 1 つ重要な側面があります。それは、独自の ID を持たないことです。値オブジェクトは、string 型と同様、別のクラスのプロパティとしてのみ使用できます。値オブジェクトには、他にもルールが規定されていますが、値オブジェクトの概念として最初に知っておくべき重要なルールは今説明したとおりです。

値オブジェクトがそのプロパティによって構成されており、概して、別のクラスのプロパティとして使用されることを踏まえると、そのデータを永続化するには何らかの特別な作業が必要です。ドキュメント データベースなど、非リレーショナル データベースを使用すると、オブジェクトのグラフとそれに組み込まれた値オブジェクトを保存するのは簡単です。しかし、リレーショナルデータベースへの保存は違います。Entity Framework は、最初のバージョンから ComplexType が含まれていました。ComplexType は、EF でデータを永続化していたデータベースにプロパティのプロパティをマッピングできます。値オブジェクトの例としてよく使われるのは PersonName です。これは、FirstName プロパティと LastName プロパティから構成されます。PersonName プロパティを持つ Contact 型がある場合、EF Core では既定で、Contact がマッピングされているテーブルに FirstName の値と LastName の値を追加の列として保存します。

値オブジェクトの使用例

私の場合、値オブジェクトのさまざまな例を見ることが、その概念を深く理解するのに役立ちました。そのため、今度は SalesOrder エンティティと PostalAddress 値オブジェクトという別の例を使用しましょう。注文には普通、配送先住所と請求先住所の両方が含まれます。これらの住所は別の目的で存在する可能性もありますが、注文に際しては、いずれの住所もその定義に不可欠です。人が新しい場所に引っ越す場合は、さらにその注文の配送先を知る必要があります。そのため、両方の住所を注文に埋め込むことは妥当です。しかし、システムで住所を一貫して処理するには、住所を構成する値を PostalAddress という独自のクラスにカプセル化するのが望ましいでしょう (図 1 参照)。

図 1 PostalAddress 値オブジェクト

public class PostalAddress : ValueObject<PostalAddress>
{
  public static PostalAddress Create (string street, string city,
                                      string region, string postalCode)   {
    return new PostalAddress (street, city, region, postalCode);
  }
  private PostalAddress () { }
  private PostalAddress (string street, string city, string region,
                         string postalCode)   {
    Street = street;
    City = city;
    Region = region;
    PostalCode = postalCode;
  }
  public string Street { get; private set; }
  public string City { get; private set; }
  public string Region { get; private set; }
  public string PostalCode { get; private set; }
  public PostalAddress CopyOf ()   {
    return new PostalAddress (Street, City, Region, PostalCode);
  }
}

PostalAddress は、Jimmy Bogard が作成した ValueObject 基底クラスを継承しています (bit.ly/2EpKydG、英語)。ValueObject には、値オブジェクトに必要な強制的なロジックがいくつか用意されています。たとえば、Object.Equals のオーバーライドがあります。これで確実にすべてのプロパティを比較できます。リフレクションが多用されることに留意してください。これにより、運用アプリのパフォーマンスに影響する可能性があります。

PostalAddress 値オブジェクトの他の 2 つの重要な特徴は、ID キー プロパティがないことと、すべてのプロパティが設定されていなくてはならないという不変ルールがコンストラクターによって強制されることです。しかし、値オブジェクトとして定義された型を所有エンティティでマッピングできるための唯一のルールは、それ自体の ID キーがないことです。所有エンティティは、値オブジェクトの他の属性を考慮しません。

PostalAddress を定義したので、これを SalesOrder クラスの Shipping­Address プロパティおよび BillingAddress プロパティとして使用できるようになります (図 2 参照)。これらは、関連するデータのナビゲーション プロパティではありませんが、スカラーの Notes や OrderDate に似た追加のプロパティです。

図 2 PostalAddress 型のプロパティを含む SalesOrder クラス

public class SalesOrder {
  public SalesOrder (DateTime orderDate, decimal orderTotal)   {
    OrderDate = orderDate;
    OrderTotal = orderTotal;
    Id = Guid.NewGuid ();
  }
  private SalesOrder () { }
  public Guid Id { get; private set; }
  public DateTime OrderDate { get; private set; }
  public decimal OrderTotal { get; private set; }
  private PostalAddress _shippingAddress;
  public PostalAddress ShippingAddress => _shippingAddress;
  public void SetShippingAddress (PostalAddress shipping)
  {
    _shippingAddress = shipping;
  }
  private PostalAddress _billingAddress;
  public PostalAddress BillingAddress => _billingAddress;
  public void CopyShippingAddressToBillingAddress ()
  {
    _billingAddress = _shippingAddress?.CopyOf ();
  }
  public void SetBillingAddress (PostalAddress billing)
  {
    _billingAddress = billing;
  }
}

これらの住所は現在 SalesOrder 内にあり、その注文を行った人物の現住所にかかわらず、正確な情報を提供できます。これで、注文の配送先を常に把握できます。

値オブジェクトを EF Core 所有エンティティとしてマッピングする

以前のバージョンの EF では、クラスが別のエンティティのプロパティとして使用され、そのクラス自身がキー プロパティを持たないことを検出すると、ComplexType を使用してマッピングすべきクラスだと自動的に認識していました。しかし、EF Core では、所有エンティティを自動的に推測できません。OnModelCreating メソッドで新しい OwnsOne メソッドを使用して DbContext Fluent API マッピングでこれを指定し、そのエンティティのどのプロパティが所有エンティティであるかを指定します。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
  modelBuilder.Entity<SalesOrder>().OwnsOne(s=>s.BillingAddress);
  modelBuilder.Entity<SalesOrder>().OwnsOne(s=>s.ShippingAddress);
}

今回は、EF Core 移行を使用して、モデルのマッピング先のデータベースを記述する移行ファイルを作成しました。図 3 に、SalesOrder テーブルを表す移行のセクションを示します。2 つの住所それぞれの PostalAddress プロパティが SalesOrder を構成していることを、EF Core が認識したことがわかります。列名は EF Core 規則のとおりです。ただし、Fluent API を使用して列名に影響を与えることができます。

図 3 PostalAddress プロパティのすべての列を含む SalesOrder テーブルの移行

migrationBuilder.CreateTable(
  name: "SalesOrders",
  columns: table => new
  {
    Id = table.Column(nullable: false)
              .Annotation("Sqlite:Autoincrement", true),
    OrderDate = table.Column(nullable: false),
    OrderTotal = table.Column(nullable: false),
    BillingAddress_City = table.Column(nullable: true),
    BillingAddress_PostalCode = table.Column(nullable: true),
    BillingAddress_Region = table.Column(nullable: true),
    BillingAddress_Street = table.Column(nullable: true),
    ShippingAddress_City = table.Column(nullable: true),
    ShippingAddress_PostalCode = table.Column(nullable: true),
    ShippingAddress_Region = table.Column(nullable: true),
    ShippingAddress_Street = table.Column(nullable: true)
  }

なお、先述のように、住所を SalesOrder テーブルに配置するのは規則に従ったものであり、個人的な好みでもあります。次の代替コードを使用すると、住所を個別のテーブルに分割して、NULL 値の許容の問題を完全に回避することができます。

modelBuilder.Entity<SalesOrder> ().OwnsOne (
  s => s.BillingAddress).ToTable("BillingAddresses");
modelBuilder.Entity<SalesOrder> ().OwnsOne (
  s => s.ShippingAddress).ToTable("ShippingAddresses");

コードで SalesOrder を作成する

請求先住所と配送先住所の両方を含む販売注文を挿入するのは簡単です。

private static void InsertNewOrder()
{
  var order=new SalesOrder{OrderDate=DateTime.Today, OrderTotal=100.00M};
  order.SetShippingAddress (PostalAddress.Create (
    "One Main", "Burlington", "VT", "05000"));
  order.SetBillingAddress (PostalAddress.Create (
    "Two Main", "Burlington", "VT", "05000"));
  using(var context=new OrderContext()){
    context.SalesOrders.Add(order);
    context.SaveChanges();
  }
}

しかし、請求先住所と配送先住所がまだ入力されていなくても注文を保存できるようにビジネス ルールで許可しており、ユーザーが別のタイミングで注文を完了できるとします。次のように、BillingAddress プロパティを設定するコードをコメントにします。

// order.BillingAddress=new Address("Two Main","Burlington", "VT", "05000");

SaveChanges が呼び出されると、EF Core では、BillingAddress のプロパティを SalesOrder テーブルに配置するために、BillingAddress のプロパティの特定を試みます。しかし、この場合、BillingAddress は NULL なので、特定は失敗します。EF Core には内部的に、規則に従ってマッピングされた所有型プロパティは NULL にできないというルールがあります。

EF Core では、所有型のプロパティの読み取りができるように所有型は利用可能になっていると想定します。値オブジェクトはソフトウェア設計上非常に重要なので、この動作は値オブジェクトを使用するうえで、あるいは EF Core を使用するうえで、厄介な問題と感じるかもしれません。私も最初はそう感じましたが、回避策を生み出すことができました。

NULL 値オブジェクトを許可する一時的な回避策

この回避策の目的は、EF Core が ShippingAddress、BillingAddress などの所有型を、ユーザーが指定しているかどうかに関わらず、受け取るようにすることです。つまり、保存層を満足させるためだけに、ユーザーが配送先住所や請求先住所の入力を強制されることはありません。ユーザーが住所を入力しない場合は、PostalAddress オブジェクトのプロパティは NULL 値になります。SalesOrder を保存するタイミングで、DbContext によってこの PostalAddress オブジェクトが追加されます。

PostalAddress クラスに少し変更を加え、Empty という 2 つ目のファクトリ メソッドを追加して、DbContext で容易に空の PostalAddress を作成できるようにしました。

public static PostalAddress Empty()
{
  return new PostalAddress(null,null,null,null);
}

さらに、IsEmpty という新しいメソッドを ValueObject 基底クラスに追加し (図 4 参照)、オブジェクトのプロパティに NULL 値が含まれるかどうかをコードで簡単に判断できるようにしました。IsEmpty では、ValueObject クラスに既に存在するコードを利用します。コードでは各プロパティを反復処理し、プロパティに値があれば false を返してオブジェクトが空ではないことを示します。そうでなければ、true を返します。

図 4 ValueObject 基底クラスに追加した IsEmpty メソッド

public bool IsEmpty ()
{
  Type t = GetType ();
  FieldInfo[] fields = t.GetFields
    (BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
  foreach (FieldInfo field in fields)
  {
    object value = field.GetValue (this);
    if (value != null)
    {
      return false;
    }
  }
  return true;
}

しかし、この NULL 所有エンティティを許可する回避策まだ完璧ではありません。さらに、この新しいロジックすべてを使用して、新しい SalesOrders が常に ShippingAddress および BillingAddress を持つようにし、EF Core で住所をデータベースに保存できるようにする必要があります。回避策にこの最後のコードを追加した当初は、コードの最後の部分 (わざわざ公開はしません) によって SalesOrder クラスが EF Core のルールである厄介なドメイン駆動設計を強いるものになっていたので不満でした。

これだけで、洗練されたソリューションのできあがり

さいわい、私が毎年秋に講演している「DevIntersection」に、EF チームの Diego Vega と Andrew Peters も登壇していました。そこで、2 人にこの回避策を示し、SalesOrder の ShippingAddress と BillingAddress の値を非 NULL にしなければならないという悩みの種について説明したところ、賛同を得ました。Andrew はすぐに、今回 ValueObject 基底クラスで行った作業と PostalAddress に加えたカスタマイズを使用して、この問題への対処を SalesOrder で処理することなく EF Core で行う方法を思いつきました。その魔法は、DbContext クラスの SaveChanges メソッドのオーバーライドで行います (図 5 参照)。

図 5 NULL 所有型に値が設定されるように SaveChanges をオーバーライドする

public override int SaveChanges()
{
  foreach (var entry in ChangeTracker.Entries()
             .Where(e => e.Entity is SalesOrder && e.State == EntityState.Added))
  {
    if (entry.Entity is SalesOrder)
    {
      if (entry.Reference("ShippingAddress").CurrentValue == null)
      {
        entry.Reference("ShippingAddress").CurrentValue = PostalAddress.Empty();
      }
      if (entry.Reference("BillingAddress").CurrentValue == null)
      {
        entry.Reference("BillingAddress").CurrentValue = PostalAddress.Empty();
      }
  }
  return base.SaveChanges();
}

DbContext が追跡しているエントリのコレクションで、SaveChanges は、データベースに追加するようフラグを付けられた SalesOrder を反復処理し、それらがそれぞれ空として設定されるようにします。

空の所有型にクエリを実行する方法

EF Core で NULL 値オブジェクトを保存するというニーズを満たしたら、次はデータベースにクエリを実行してそれらを取得します。しかし、EF Core では、それらのプロパティを空の状態で解決します。もともと NULL だった ShippingAddress または BillingAddress は、プロパティに NULL 値を含むインスタンスとして戻ります。クエリの後に、空の PostalAddress プロパティを NULL で置き換えるロジックが必要です。

これを実現するスマートな方法を探し出すのにはかなり時間がかかりました。残念ながら、オブジェクトはクエリ結果から具体化されているので、オブジェクトを変更するライフサイクル フックはまだありません。クエリ パイプラインには、内部 EntityMaterializerSource クラス内に CreateReadValueExpression という名前の置き換え可能なサービスがあります。しかし、これはオブジェクトではなく、スカラー値でしか使用できません。これよりも複雑な他のアプローチも数多く試しました。最終的には、これが一時的な回避策である事実について、時間をかけて自分を納得させました。そのため、多少コードのにおいがあるとしても、よりシンプルなソリューションがあれば、受け入れることができます。また、このタスクの制御は、クエリが EF Core でデータベースの呼び出しを行う専用のクラスにカプセル化されている場合でも、それほど難しくありません。

このメソッドの名前は、FixOptionalValueObjects としました。

private static void FixOptionalValueObjects (SalesOrder order) {
  if (order.ShippingAddress.IsEmpty ()) { order.SetShippingAddress (null); }
  if (order.BillingAddress.IsEmpty ()) { order.SetBillingAddress (null); }
}

これで、ユーザーが値オブジェクトを NULL のままにでき、EF Core でそのオブジェクトを非 NULL として保存、取得し、それらを NULL としてコード ベースに返すことができる回避策が完成しました。

値オブジェクトを置き換える

現バージョンの EF Core 2 には、所有オブジェクトを置き換えることができないという制限もあると述べました。値オブジェクトは本質的に、不変です。そのため、変更する必要がある場合は、置き換えるしかありません。論理的には、これは、あたかも OrderDate プロパティを変更しているかのように SalesOrder を変更していることを意味します。しかし、EF Core で所有エンティティを追跡する方法のせいで、EF Core では常に、ホスト (SalesOrder など) が新しくないとしても、この置き換えが追加されたと判断します。

この問題を解決するために、SaveChanges のオーバーライドに変更を加えました (図 6 参照)。オーバーライドでは、追加または変更された SalesOrders をフィルター処理するようになっています。また、参照プロパティの状態を変更するためにコードに新しく 2 行追加して、ShippingAddress および BillingAddress の状態を注文の状態 (Added または Modified) と同一にするようになっています。変更した SalesOrder オブジェクトは、UPDATE コマンドで ShippingAddress および BillingAddress プロパティの値に含めることができるようになっています。

図 6 置き換えた所有型を Modified としてマークしてその所有型を SaveChanges が把握できるようにする

public override int SaveChanges () {
  foreach (var entry in ChangeTracker.Entries ().Where (
    e => e.Entity is SalesOrder &&
    (e.State == EntityState.Added || e.State == EntityState.Modified))) {
    if (entry.Entity is SalesOrder order) {
      if (entry.Reference ("ShippingAddress").CurrentValue == null) {
        entry.Reference ("ShippingAddress").CurrentValue = PostalAddress.Empty ();
      }
      if (entry.Reference ("BillingAddress").CurrentValue == null) {
        entry.Reference ("BillingAddress").CurrentValue = PostalAddress.Empty ();
      }
      entry.Reference ("ShippingAddress").TargetEntry.State = entry.State;
      entry.Reference ("BillingAddress").TargetEntry.State = entry.State;
    }
  }
  return base.SaveChanges ();
}

このパターンはうまく機能します。というのも、クエリを実行したのとは異なる OrderContext インスタンスを使用して保存しており、このインスタンスには PostalAddress オブジェクト状態の先入観がないからです。この追跡オブジェクトの代替パターンは、GitHub に報告された問題 (bit.ly/2sxMECT、英語) のコメントで確認できます。

短期的に有効な実用的ソリューション

オプションの所有エンティティを許可する変更と所有エンティティの置き換えができるという見込みがなければ、ソフトウェアでデータ永続性を処理するために個別のデータ モデルを作成するという手順を取っていたでしょう。しかし、この一時的な回避策によって、余分な作業や投資をしなくて済みました。また、この回避策も間もなく不要になり、簡単にドメイン モデルを直接データベースにマッピングして、データ モデルを EF Core に定義させることができるようになることもわかっています。今回は、ソリューションの設計時に値オブジェクトと EF Core 2 を使用するための回避策を生み出すために、時間、手間、および考えを喜んでつぎ込みました。そして、他の開発者も同じことができるように、喜んでサポートしました。

本稿付属のコード サンプルは、コンソール アプリにホストされ、回避策のテストや、データの SQLite データベースへの永続化を行えるようになっています。このデータベースは、InMemory プロバイダーを使ったテストを記述する以外にも使用しています。それは、100% 確実に、データが期待どおりの方法で保存されていることを確認するため、データベースを検査したかったからです。


Julie Lerman は、バーモント ヒルズ在住の Microsoft Regional Director、Microsoft MVP、ソフトウェア チームの指導者、およびコンサルタントです。世界中のユーザー グループやカンファレンスで、データ アクセスなどのトピックについてプレゼンテーションを行っています。彼女のブログは thedatafarm.com/blog (英語) で、彼女は O'Reilly Media から出版されている『Programming Entity Framework』(2010 年) および『Code First』版 (2011 年)、『DbContext』版 (2012 年) を執筆しています。彼女の Twitter (@julielerman、英語) をフォローして、juliel.me/PS-Videos (英語) で彼女の Pluralsight コースをご覧ください。

この記事のレビューに協力してくれたマイクロソフト技術スタッフの Andriy Svyryd に心より感謝いたします。
Andriy Svyryd は、2010 から Entity Framework チームに所属するウクライナ人 .NET 開発者です。彼が貢献したすべてのプロジェクトについては、github.com/AndriySvyryd (英語) で確認できます。


この記事について MSDN マガジン フォーラムで議論する