ORM中的继承关系映射全解


 实体继承是基于OO和关系型数据库软件系统设计中的一个重要主题。本文通过基于NBear的实例解析ORM中的实体继承体系映射的方方面面。

    单表继承体系

 

    所谓单表继承体系就是用一张数据库表存储整个继承体系中的所有实体的数据。单表继承体系适合那种继承体系中实体数目相对较少,总记录数相对较少,子类对父类的属性扩展也相对较少的情形。

    单表继承体系优点是读/写继承体系中的每个实体的数据,都只需操作一张表,性能较好,并且,新增继承类,或扩展实体属性都只需要增减一张表的字段就可以了,易于维护;主要缺点是,因为所有的实体共享一张表,表中会有比较多的NULL字段值的数据,浪费了一些存储空间,同时,如果记录数过多,表就会更庞大,也会影响表的读写性能。

    简单单表继承体系

    让我们先看一个假象的例子:

[Table("AllInOneTable")]
public interface Parent : IEntity
{
[PrimaryKey]
int ID { get; }
string Name { get; set; }
}
[Table("AllInOneTable")]
public interface AnotherParent : IEntity
{
[PrimaryKey]
int ID { get; }
int Age { get; set; }
}
[Table("AllInOneTable")]
public interface Child : Parent, AnotherParent
{
[PrimaryKey]
new int ID { get; set; }
DateTime Birthday { get; set; }
}

    我们可以看到,在上例中,我们定义了两个基实体Parent和AnotherParent,Child实体同时从两个基类继承。注意,代码中加粗的行,如果多个不同的基接口包含相同名称的属性,代码会编译失败,此时,需要像这样使用new关键字来避免编译失败。

    这里,我们采用的是单表继承体系方式,注意每个实体都映射到AllInOneTable这个表,只不过对每个实体来说,只使用了AllInOneTable表的部分字段。

    但是,以这样的简单方式定义单表继承时,因为从表中读数据时无法知道一行数据真正对应的是哪一个子类,所以,实际情况下,一般我们都要附加一些查询条件和字段默认值。

    带附加条件的单表继承体系

    采用单表继承体系方案时,继承体系中的不同子类不仅仅扩展父类的属性,肯定还会附带一些字段查询条件和默认值。这里会用到NBear.Common.TableAttribute的AdditionalWhere和AdditionalInsert属性,关于这两个属性的详细用法请参考表映射。

    请看下面的代码:

public interface Message : IEntity
{
[PrimaryKey]
int ID { get; }
string Title { get; set; }
string Content { get; set; }
DateTime UpdateTime { get; set; }
}
[Table("Message", AdditionalInsert = "[MessageType] = 1", AdditionalWhere = "[MessageType] = 1")]
public interface CommonMessage : Message
{
int UserID { get; set; }
}
[Table("Message", AdditionalInsert = "[MessageType] = 2", AdditionalWhere = "[MessageType] = 2")]
public interface SpecialMessage : Message
{
int GroupID { get; set; }
}

    这里我们实际要持久化的是两个实体CommonMessage和SpecialMessage,他们有一个共同的抽象父类Message,父类作为一个抽象类不会被直接使用。我们将整个继承体系存于Message数据表。包含CommonMessage和SpecialMessage的所有属性。但是,就像我们在上面的假象示例中所说的,如果直接查询Message表,返回CommonMessage对应的字段数据,那么连SpecialMessage插入的那些数据也会被返回,反之亦然,这显然是不符合要求的。

    因此,我们需要定义附加的查询条件和插入默认值,即为Message表增加一个MessageType字段,该字段值为1的数据代表CommonMessage,值为2代表该行数据是SpecialMessage,如上面的代码中定义AdditionalWhere和AdditionalInsert后,查询CommonMessage或SpecialMessae时,就只会返回真正对应到他们的MessageType值的记录了;当插入数据时,为CommonMessage和SpecialMessage,框架也会自动为其设置必要的MessageType默认值。
 一实体一具体表

    所谓一实体一具体表就是每个实体对应一张数据表,并且,每个数据表冗余包含其父类的所有属性字段,并且,子类和父类共享相同的主键值。一实一具体表方案适合需要较高查询性能,继承体系层次不太复杂,并且基类包含较少的属性而子类扩展较多属性,并且能够承受一定的数据库空间浪费的情况。

    一实体一具体表方案的优点主要就是查询性能好,读操作只需操作一张表,和实体数据的对应结构清晰,数据库表迁移和维护会比较方便;主要的缺点是数据冗余较大,因为每次插入一条子类数据时,同时要插入一份子类包含的父类字段的数据到所有父类层次表中。

    请看示例代码: [Table("ParentTable")]
public interface Parent : IEntity
{
[PrimaryKey]
int ID { get; }
string Name { get; set; }
}
[Table("AnotherParentTable")]
public interface AnotherParent : IEntity
{
[PrimaryKey]
int ID { get; }
int Age { get; set; }
}
[Table("ChildTable")]
public interface Child : Parent, AnotherParent
{
[PrimaryKey]
new int ID { get; set; }
DateTime Birthday { get; set; }

    这种方案下,每个实体对应一张表,每张表包含冗余的父类的数据。也就是说,如上面的例子,Child类对应的ChildTable包含父类Parent和AnotherParent对应的表的所有字段。同时,当插入或更新一条Child数据时,必须同时保存对应的Parent和AnotherParent类的记录。也就是说,当使用Gateway来插入或更新Child记录时,必须显式如下更新Parent和AnotherParent:
//obj is a Child instance
Gateway.Save<Child>(obj, tran);
Gateway.Save<Parent>(obj, tran);
Gateway.Save<AnotherParent>(obj, tran);

     一实体一扩展表

    所谓一实体一扩展表是指继承体系中的每个实体对应一张数据表,但是,每个子类不冗余包含父类的所有属性,而只是包含扩展的属性和共享的主键值。一实体一扩展表方案适合继承体系非常复杂,结构易变,并希望最大程度减少数据冗余的情形。

    一实体一扩展表方案的优点是结构灵活,新增子类或插入中间的继承类都很方便,冗余数据最少;但是缺点是,无论读还是写操作都会涉及到子类和所有的父类。读操作时,必须自然链接查询所有的父类对应的数据表,而插入或更新数据时,也需要写所有的父类表。

    我们还是以代码来说明:

[Table("ParentTable")]
public interface Parent : IEntity
{
[PrimaryKey]
int ID { get; }
string Name { get; set; }
}
[Table("AnotherParentTable")]
public interface AnotherParent : IEntity
{
[PrimaryKey]
int ID { get; }
int Age { get; set; }
}
[Table("ChildTable")]
public interface Child : IEntity
{
[PrimaryKey]
int ID { get; set; }
DateTime Birthday { get; set; }
}
[Table("select ChildTable.ID, ChildTable.Birthday, ParentTable.Name, AnotherParentTable.Age from ChildTable inner join ParentTable on ChildTable.ID =
ParentTable.ID inner join AnotherParentTable on ChildTable.ID = AnotherParentTable.ID", IsView=true)]
public interface ChildView : Child, Parent, AnotherParent
{
[PrimaryKey]
new int ID { get; set; }
}

    首先,请注意以上代码中的Child的定义,此时,Child没有从Parent和AnotherParent继承,因为,实际上,对应于ChildTable的字段的只有现在的Child实体包含的ID和Birthday属性。

    接着,注意这个新增的ChildView类,ChildView同时从Child,Parent和AnotherParent继承,因此,包含所有的这些实体的属性。因此ChildView实际上代表了我们逻辑上的Child对象,包含Child及其所有父类的属性。注意,ChildView是一个内联视图,对应了Child,Parent和AnotherParent对应的数据表的自然链接查询。因此,读数据时,只需通过Gateway.Select<ChildView>就能读取数据,但是,请注意,实际的查询是一个关联查询,所以性能肯定没有前面的单表继承体系和一实体一具体表好,但是,理论上,当数据量不是特别大时,查询操作的性能差异并不明显。

    写操作时,和一实体一具体表类似,需要同时写Child,Parent和AnotherParent:

//obj is a ChildView instance
Gateway.Save<Child>(obj, tran);
Gateway.Save<Parent>(obj, tran);
Gateway.Save<AnotherParent>(obj, tran);

接口实现映射vs基类继承映射

    目前的ORM方案,似乎比较少讨论到一个OOD中非常重要的方面——接口。当绝大多数ORM事先都能较好的映射常见的单根继承关系时,却往往忽略(回避?)了接口的地位。

    注,这里的接口因为主要是讨论实体定义,因此,不涉及包含方法定义的接口,这里只关注包含需要持久化的实体属性接口。

    我们知道,实现一个接口和继承一个基类是不同的OO语义。实现接口意味着一个类宣告遵守接口描述的契约,这就意味着,每个实现接口的类,必须,自己负责接口的实现。而继承一个基类代表不仅遵守基类宣告的契约,而且直接重用基类的实现(当然,需要的话也可以过在基类的部分或全部实现)。

    前面我们已经讨论了各种常见的ORM中的继承体系映射方案,我们会发现,单从映射的灵活性,自然程度来讲,一实体一扩展表方案无疑最自然。分析其原因,是因为,它就和我们在OO中定义继承体系中的类一样,子类只需定义扩展的部分,无需重复包含父类已经定义的属性。

    OK,那么,和基类继承映射相比,接口处在一个什么地位呢?

    首先,我们发现,接口是一个抽象类,因此,不想具体的实体类那样需要持久化,因此,接口不像具体的实体类那样需要对应一张表。

    其次,接口描述的是一种契约,换句话说,以仅仅包含属性的接口为例,假设有一个包含字段A,B的接口,那么,一个实体继承这个接口,意味着,这个实体对应的数据表包含相同类型的A,B字段。

    不知道同学们发现没有,此时出现的情况和一实体一具体表的情况有点相似。尽管子类继承一个接口(一个抽象基类),但是,每个实现接口的子类实际上重复包含了接口中定义的字段属性,区别是,此时因为基类是接口,不需要持久化,因此,不会有一实体一具体表那样的的数据冗余。

    一直没有看到其他的ORM实现了利用接口来描述的共享字段契约(也可能是我孤陋寡闻^-^),也许是大多数ORM使用具体类而不是接口来描述实体的定义。

    在NBear中,由于采用接口来定义实体,当然,就可以很方便的用接口来定义字段共享契约。

    那么,采用接口来定义字段共享契约有什么用呢?

    仅仅接口定义实体方式下,在定义实体的时候不用像具体类实现接口那样每个类实现一边接口那样要多打一些代码,那么,还不足以说明接口定义字段共享契约的意义。

    接口定义字段共享契约真正的意义在于,它能够引导我们更好的设计和映射我们的OO系统。我们常常发现,当按照传统的单继承的思路来设计继承体系时,尤其是,当脑袋中一直包含着让模型更适合使用ORM框架来持久化时,我们往往会忽略接口,而过度依赖基类继承。

    举例来说,在双鱼座的最近一篇Blog中,他举了一个User和UserGroup都可以拥有权限的例子。他的实现方案是,让User和UserGroup都从一个PrivilegeOwner类继承。在这样的实现下,如果我要实现将User分为两类,一类可以拥有Privilege的PowerUser,一类不能拥有Privilege的普通CommonUser,那么他这个模型,单纯以基类继承就很难扩展以描述这种情形了。

    要解除这样的限制,最自然的方案就是通过接口。我们可以定义一个IPrivilegeOwner接口和一个BaseUser基类,让PowerUser和CommonUser都继承BaseUser,而PowerUser和UserGroup则实现IPrivilegeOwner。

    我们可以发现,当引入接口这样的共享字段契约的概念,我们的模型就能不用受基类继承(大多数系统只支持单根继承)的限制,从而定义得更加灵活,更优雅。

本文作者:
« 
» 
快速导航

Copyright © 2016 phpStudy | 豫ICP备2021030365号-3