几种被拥有实体类型的存储模型

被拥有实体类型(Owned Entity Type)是Entity Framework Core引入的概念,它对应了项目中常见的一种场景:有一类数据具有特定结构,且(由于业务的关系)不天然具备唯一标识,只能作为其他业务对象的属性而存在。

最经典的例子是住址,有国家、城市、街道、门牌号等属性。一般地,除了诸如专门的房地产管理系统,单独的地址不具备意义;相反地它必须被标识为为某个人,某个组织,或某个特定实体所拥有。这也是微软命名其“被拥有实体类型”的原因。

根据微软官方文档的介绍,被拥有实体类型除了“不独立”的特点外,还需要遵从Martin Fowler的联合(Aggregate)的定义,即其所有属性字段应被视作一个整体不可分割,所以将之归并由一个对象进行表达是有意义的。更激进的结论是,数据库中对其中任何一个字段的变更,都理应伴随有其他字段的更改操作,就如同Entity Framework 6中所引入的复杂类型(complex Type)那样。

不了解被拥有实体类型在发生变更时也是否如此。但本文并不打算就此做深入研究,仅希望回答一个问题:如果同一种被拥有实体类型,为多种不同的业务实体对象所拥有,该怎样设计其表结构?预设前提是,为了不“撑宽”主业务对象表影响性能,同时照顾到主业务对象将来可能拥有多于一个此类属性的情况,被拥有实体类型需要被存储于单独的表中……

按拥有者分别建表

按拥有者分别建表是最直观的方案:属于人的地址存为“人的地址”表,属于组织的地址存为“组织的地址”表,等等。这个Table-Per-Owner存储方案的实体关系图如下

 

可以看到在这个方案里,将存在数个结构上类同的表。每多一种拥有者,就需要为之创建单独的表。不仅如此,如果业务逻辑里要求针对此类数据有相似的过滤、排序要求,那么在所有这些表上都需要创建相同的索引…… 这不禁会让人产生疑惑:有没有更简洁的存储方案,比如把此类数据放在同一张表里?

业务数据集中存储+拥有关系分表存储

这是马上能想到的第二种存储方案:地址信息存在单独一张表中;地址与拥有者之间的联系,依据拥有者的类型存在不同的表中。这个Table-Per-Ownership存储方案的实体关系如下

注意,这里拥有关系(PersonAddress,OrganizationAddress)到地址表之间,为0/1到1的关联关系:同一条地址信息,不应该既属于某甲又属于某乙,否则就违背了“被拥有实体类型”的定义而赋予了地址事实上的独立地位;同时因为地址表复用,其中的记录可能专门服务于某类拥有者,故其与拥有关系表也不是一一对应的关联关系。事实上考虑到这点后,可以在拥有关系表中直接以AddressId作为主键。

这个从常规多对多映射关系衍生出来的方案貌视解决了前一方案的问题:主要业务数据被放在了同一张表中,无需重复创建同样的表结构、索引对象,对维护工作带来了便利。但此方案的缺点也很明显:1. 依旧要创建复数张拥有关系表;2. 它只能避免同一条地址信息不被同类拥有者交叉引用,而无法避免被不同类的拥有者交叉引用。

上述第二个缺点尽管可以通过程序手段消除,但依旧说明它存在先天不足,不是一个好方案。

来自上古时期的脑洞大开

这个存储方案是笔者在当前项目中遇见的。在这个诞生在上古时期的项目里,架构师脑洞大开地提出了这样一种存储方案

这个方案的特点是:首先额外创建一张拥有者类型表,并收录所有可能的拥有者类型;其次创建地址表,同时存放地址信息,和拥有者类型以及拥有者标识。该方案彻底克服了前一方案的交叉误用问题,而且只需要增加一张拥有者类型表因此表达上更为简洁。

但是这个方案有两个缺点。

第一个缺点是,除非所有拥有者实体表共用一个序列(SEQUENCE)对象来分配标识字段值,否则无法创建拥有者实体表到被拥有实体表之间的外键关系——缺乏外键约束的影响是,不能从数据库结构上消除“孤儿地址记录”的情况。

第二个严重的缺点是,该如何刻画对象—关系映射?无论在拥有者与被拥有者之间是否事实上建立有外键关联,在Entity Framework (Core)里都不允许定义一个对象的某个属性,既参照对象甲的某个属性,又参照对象乙的某个属性。

对第一个缺点,客户表示可以接受。但第二个缺点必须尝试解决,否则代码无法迁移到Entity Framework (Core)这种新技术上。

对此有两种思路:第一种是彻底放弃在拥有者与被拥有者之间建立参照关联关系的念头,采用“分段永久化”的策略来实施保存,即调用两次SaveChanges方法,第一次保存拥有者的变更,取得拥有者的标识字段值后再填充被拥有者记录进行保存;第二种是应用TPH。

应用TPH的关键是把外键字段从基类中剥离,放到子类里声明,否则无法骗过Entity Framework (Core)顺利地创建外键关系。于是有如下的Code-First代码

 1 public enum OwnerType { Person = 1, Organization }
 2 
 3 public class Person 
 4 { 
 5     public long PersonId { get; set; }
 6     /* Omitted for abbrevation */
 7 }
 8 
 9 public class Organization
10 {
11     public long OrganizationId { get; set; }
12     /* Omitted for abbrevation */
13 }
14 
15 public abstract class Address
16 {
17     public long AddressId { get; set; }
18     public string Country { get; set; }
19     public string City { get; set; }
20     public string Line1 { get; set; }
21     public string Line2 { get; set; }
22 }
23 
24 public class PersonAddress: Address
25 {
26     public long OwnerPersonId { get; set; }
27     public virtual Person OwnerPerson { get; set; }
28 }
29 
30 public class OrganizationAddress: Address
31 {
32     public long OwnerOrganizationId { get; set; }
33     public virtual Organization OwnerOrganization { get; set; }
34 }
35 
36 public class MyDbContext: DbContext
37 {
38     /* Omitted for abbrevation */
39 
40     protected override void OnModelCreating(ModelBuilder modelBuilder)
41     {
42         modelBuilder.Entity<Address>()
43             .ToTable("Address", "dbo")
44             .HasDiscriminator<OwnerType>("OwnerTypeId")
45             .HasValue<PersonAddress>(OwnerType.Person)
46             .HasValue<OrganizationAddress>(OwnerType.Organization);
47 
48         modelBuilder.Entity<PersonAddress>(builder =>
49         {
50             builder.Property(e => e.OwnerPersonId)
51                 .HasColumnName("OwnerId");
52             builder.HasOne(e => e.OwnerPerson)
53                 .WithMany()
54                 .HasForeignKey(e => e.OwnerPersonId);
55         });
56 
57         modelBuilder.Entity<OrganizationAddress>(builder =>
58         {
59             builder.Property(e => e.OwnerOrganizationId)
60                 .HasColumnName("OwnerId");
61             builder.HasOne(e => e.OwnerOrganization)
62                 .WithMany()
63                 .HasForeignKey(e => e.OwnerOrganizationId);
64         });
65     }
66 }

注意这里没有应用Complex Type或Owned Entity Type来描述地址基本信息,以获得更好的适应性——毕竟“被拥有实体类型”在此文中只是一个用来描述目标对象特性的一个概念,无须严格遵从。

采用Db-First也可以,但需要打开edmx文件手动编辑其CSDL段与C-S Mapping段如下

 1 <!-- CSDL content -->
 2 <edmx:ConceptualModels>
 3   <Schema>
 4     <EntityContainer>
 5       <!-- Omitted for abbrevation -->
 6 
 7       <EntitySet Name="Addresses" EntityType="Address" />
 8       <AssociationSet Name="PersonPersonAddress" Association="PersonPersonAddress">
 9         <End Role="Person" EntitySet="People" />
10         <End Role="PersonAddress" EntitySet="Addresses" />
11       </AssociationSet>
12       <AssociationSet Name="OrganizationOrganizationAddress" Association="OrganizationOrganizationAddress">
13         <End Role="Organization" EntitySet="Organizations" />
14         <End Role="OrganizationAddress" EntitySet="Addresses" />
15       </AssociationSet>
16     </EntityContainer>
17 
18     <!-- Omitted for abbrevation -->
19 
20     <EntityType Name="Address" Abstract="true">
21       <Key>
22         <PropertyRef Name="AddressId" />
23       </Key>
24       <Property Name="AddressId" Type="Int64" Nullable="false" annotation:StoreGeneratedPattern="Identity" />
25       <Property Name="Country" Type="String" Nullable="false" MaxLength="50" Unicode="true" FixedLength="false" />
26       <Property Name="PostalCity" Type="String" Nullable="false" MaxLength="50" Unicode="true" FixedLength="false" />
27       <Property Name="Line1" Type="String" Nullable="false" MaxLength="255" Unicode="true" FixedLength="false" />
28       <Property Name="Line2" Type="String" Nullable="false" MaxLength="255" Unicode="true" FixedLength="false" />
29     </EntityType>
30     <EntityType Name="PersonAddress" BaseType="Address">
31       <NavigationProperty Name="OwnerPerson" Relationship="PersonPersonAddress" FromRole="PersonAddress" ToRole="Person" />
32       <Property Type="Int64" Name="OwnerPersonId" Nullable="false" />
33     </EntityType>
34     <Association Name="PersonPersonAddress">
35       <End Type="Person" Role="Person" Multiplicity="1" />
36       <End Type="PersonAddress" Role="PersonAddress" Multiplicity="*" />
37       <ReferentialConstraint>
38         <Principal Role="Person">
39           <PropertyRef Name="PersonId" />
40         </Principal>
41         <Dependent Role="PersonAddress">
42           <PropertyRef Name="OwnerPersonId" />
43         </Dependent>
44       </ReferentialConstraint>
45     </Association>
46     <EntityType Name="OrganizationAddress" BaseType="Address">
47       <NavigationProperty Name="OwnerOrganization" Relationship="OrganizationOrganizationAddress" FromRole="OrganizationAddress" ToRole="Organization" />
48       <Property Type="Int64" Name="OwnerOrganizationId" Nullable="false" />
49     </EntityType>
50     <Association Name="OrganizationOrganizationAddress">
51       <End Type="Organization" Role="Organization" Multiplicity="1" />
52       <End Type="OrganizationAddress" Role="OrganizationAddress" Multiplicity="*" />
53       <ReferentialConstraint>
54         <Principal Role="Organization">
55           <PropertyRef Name="OrganizationId" />
56         </Principal>
57         <Dependent Role="OrganizationAddress">
58           <PropertyRef Name="OwnerOrganizationId" />
59         </Dependent>
60       </ReferentialConstraint>
61     </Association>
62   </Schema>
63 </edmx:ConceptualModels>
64 <!-- C-S mapping content -->
65 <edmx:Mappings>
66   <Mapping >
67     <EntityContainerMapping>
68       <!-- Omitted for abbrevation -->
69 
70       <EntitySetMapping Name="Addresses">
71         <EntityTypeMapping TypeName="IsTypeOf(Address)">
72           <MappingFragment StoreEntitySet="Address">
73             <ScalarProperty Name="AddressId" ColumnName="AddressId" />
74             <ScalarProperty Name="Country" ColumnName="Country" />
75             <ScalarProperty Name="City" ColumnName="City" />
76             <ScalarProperty Name="Line1" ColumnName="Line1" />
77             <ScalarProperty Name="Line2" ColumnName="Line2" />
78           </MappingFragment>
79         </EntityTypeMapping>
80         <EntityTypeMapping TypeName="IsTypeOf(PersonAddress)">
81           <MappingFragment StoreEntitySet="Address">
82             <ScalarProperty Name="AddressId" ColumnName="AddressId" />
83             <ScalarProperty Name="OwnerPersonId" ColumnName="OwnerId" />
84             <Condition ColumnName="OwnerTypeId" Value="1" />
85           </MappingFragment>
86         </EntityTypeMapping>
87         <EntityTypeMapping TypeName="IsTypeOf(OrganizationAddress)">
88           <MappingFragment StoreEntitySet="Address">
89             <ScalarProperty Name="AddressId" ColumnName="AddressId" />
90             <ScalarProperty Name="OwnerOrganizationId" ColumnName="OwnerId" />
91             <Condition ColumnName="OwnerTypeId" Value="2" />
92           </MappingFragment>
93         </EntityTypeMapping>
94       </EntitySetMapping>
95     </EntityContainerMapping>
96   </Mapping >
97 </edmx:Mappings>

实验证明此方案可行,在永久化变更时只需调用一次SaveChanges方法,Entity Framework (Core)能够正确决定插入/修改次序,Address表里的外键字段也能正确填充。

结语

本文讨论了被拥有实体类型的单独存储问题,比较了几种可能方案,并着重介绍了其中源自上古时期的一种反常规设计以及如何在Entity Framework (Core)中实现其对象—关系映射。

必须指出两点:1. 这一实现是通过特殊安排骗过Entity Framework (Core)完成的,未来能否继续成功需要打问号;2. Entity Framework (Core)无法自动生成迁移代码,需要手动干预。所以日常开发中,笔者更倾向第一种Table-Per-Owner的“低效”方案。

posted @ 2022-05-03 00:10  OfAllTheIdiots  阅读(124)  评论(0)    收藏  举报