NHibernate初学者指南(20):开发中常见的错误(一)
本篇以及下一篇讨论开发人员在使用NHibernate开发中常见的一些错误。
明确请求
NHibernate,FluentNHibernate或者ConfORM都定义了许多关于领域模型映射的约定。但是还有很多人定义非常冗长的映射;定义太多,尤其是没有必要的东西,会使重要的映射元素变得模糊,不容易阅读以及难于理解。
让我们看看下面完全合法的XML映射片段:
<property name="OrderDate" lazy="false" access="Property" type="DateTime" insert="true" update="true" unique="false"> <column name="OrderDate" not-null="true" sql-type="DateTime" /> </property>
尽管片段很长,但是它只定义了一个实体的单个属性的映射。在这个例子中,映射了Order实体的OrderDate属性。
可以使用下面的代码取得同样的结果:
<property name="OrderDate" not-null ="true"/>
很明显后者可读性更强,易于维护,少出错误。
让我们详细看一下不必要的特性,以及给出为什么大多数情况下它们都是不必要的:
- lazy:默认情况下,所有使用<property>标签映射的实体都不是延迟加载的,所以false是默认值。
- access:NHibernate默认使用属性的setter和getter访问实体的数据,所以没有必要显示声明。
- type:NHibernate使用反射确定映射属性的类型。在大多数情况下这是预期行为,只有少数情况下才需要我们帮助NHibernate确定正确的属性类型。
- insert:默认,插入新记录到数据库使用映射属性的值。换句话就是insert的默认值是true。
- update:和insert一样,更新数据库中已存在的记录使用映射属性的值,update的默认值为true。
- unique:默认,NHibernate假定给定的数据库列的值一定不是唯一的,所以,unique的默认值是false。
- column name:当映射底层数据库表的字段时,默认和属性的名称相同。
- column sql-type:大多数情况下,NHibernate能够正确的映射.NET数据类型与底层数据库使用的类型相匹配。
因此,我的建议是:只声明需求的绝对极小值,尽可能多的依赖框架默认约束。
错误映射只读访问
通常我们可能需要以只读模式访问某些数据。例如,此数据可以在屏幕上或纸上非规范化和聚合生成报表。这种情况下,我们可能想使用数据库视图检索数据并映射数据传输对象给这些视图。
典型的例子是检索某月前10名客户的姓名、订单数量、订单总额的列表。假设我们已经创建了一个数据库视图来集合这些数据。这个视图称为Top10CustomersOfMonth,它有CustomerId,CustomerName,NbrOfOrders,Totals,Month和Year字段。
我们还假设有数据传输对象Top10Customers,想把这个数据传输对象映射到视图。这次我们使用Fluent NHibernate映射。代码如下:
public class Top10CustomersMap : ClassMap<Top10Customers> { public Top10CustomersMap() { Table("Top10CustomersOfMonth"); Id(x => x.CustomerId).GeneratedBy.GuidComb(); Map(x => x.CustomerName).Length(50).Not.Nullable(); Map(x => x.NbrOfOrders).Not.Nullable(); Map(x => x.Total).Not.Nullable(); Map(x => x.Month).Not.Nullable(); Map(x => x.Year).Not.Nullable(); } }
尽管上面的代码工作,但是部分代码是低效的,冗长的。正确的映射应该如下所示:
public class Top10CustomersMap2 : ClassMap<Top10Customers> { public Top10CustomersMap2() { Table("Top10CustomersOfMonth"); ReadOnly(); Not.LazyLoad(); SchemaAction.None(); Id(x => x.CustomerId); Map(x => x.CustomerName); Map(x => x.NbrOfOrders); Map(x => x.Total); Map(x => x.Month); Map(x => x.Year); } }
起初我们说了这个视图是只读访问的,所以我们调用ReadOnly()方法告诉NHibernate。
因为数据库视图专门为我们提供了非规范化和聚集的数据视图用于快速检索数据,在这个例子中我们不想使用延迟加载,所以我们调用Not.LazyLoad()。通过避免延迟加载,NHibernate不用创建代理封装数据传输对象,所以更高效。
如果我们通过NHibernate的SchemaExport类从领域模型生成数据库架构,那么我们必须告诉NHibernate不要为上面的映射生成表。注意NHibernate不能,也绝不会能正确地创建一个视图。通过映射文件提供的元信息不足以创建视图。这就是为什么我们添加SchemaAction.None()语句。
当定义在数据传输对象和数据库视图或表之间用于只读访问的映射时,没有必要定义除name以外的元素,少数情况下会定义type,也没有必要定义生成ID的策略,因为不创建ID,仅仅是读取数据。我们也没有必要定义字段是否可空的和string类型字段的length。
盲目依赖NHibernate
NHibernate是个非常有用的工具,在处理持久化到关系数据库的数据时,它使减轻了我们开发人员的很多工作。然而,NHibernate仅仅是个工具。NHibernate在大多数情况下都使用最好的策略访问数据,但是我们不能盲目依赖NHibernate。
很多时候NHibernate并不能为我们作出正确的决定,这种情况下,就需要我们帮助它,给它一些上下文的提示。
一个可能的情况就是如果我们想检索父实体的里列表,它的每个实体都有一个子实体的集合。在操作期间,如果我们想访问所有子实体的属性,那么最好告诉NHibernate我们要这样做,让它一次加载所有的实体而不是依赖默认的延迟加载行为。在后面讨论select(n+1)问题时,我们会特别讨论这个问题。
另一种情况就是我们有一个父实体,它有两个不同的子实体集合。举个例子,blog实体有一个post的集合和author的集合。如果我们强制NHibernate在一次请求中加载blog实体和它的两个子集合。交叉连接基本上结合一个表的每个记录和另一个表的每个记录。结果,从数据库中加载了太多的数据。如果两个表包含非常多的记录,那么这会给系统造成很大的压力。
使用隐式事务
使用NHibernate时,我们强烈建议使用显示事务封装数据库操作。如果不显示定义事务的边界,那么数据库会使用隐式事务封装每个单独的语句。在大多数情况下,这是低效的,因为通常我们在一个业务事务中执行多个语句。因为业务事务的边界是根据我们实现的功能定义的,我们应该始终在适当的位置显示的打开或关闭事务。这样做的最简单的形式就是在打开一个session之后直接开始一个事务,在关闭session之前直接提交事务。
之前文章的例子中,我们都使用这个简单的方法:
using(var session = sessionFactory.OpenSession) using(var tx = session.BeginTransaction()) { // do some database operations tx.Commit(); }
在web应用程序中,你可能想在Global.asax文件中的BeginRequest中开始一个事务,在EndRequest中提交事务。
使用数据生成ID
使用数据库为我们的实体生成ID乍一看非常很有吸引力和直截了当。然而,仔细看看手头的问题表明,使用数据库生成ID是有效地反模式,应严格避免。
数据库生成ID只有在我们的程序和其他也可以更新其中数据的程序分享数据库时才有意义。这种情况,我们可能想依赖数据库生成必要的主键保证它们的唯一性。然而,前面已经讨论了,我们应该防止这种情况发生,并坚持认为我们的程序独自拥有数据库。
NHibernate为我们提供了很多方式来为新实体创建或定义主键值。我个人推荐的两个ID生成器是:HiLo生成器和GuidComb生成器。
HiLo生成器生成整数作为主键值,而GuidComb算法生成的GUID,为数据库中的索引进行了优化。使用.NET框架的标准算法生成的GUID不太合适,所以NHibernate以GuidComb生成器的形式提供了自己的实现。
举个例子,如果我们正在使用称为Command Query Responsibility Segregation (CQRS)的架构模式,它的查询操作和数据操作严格分离,那么我们甚至可能考虑使用客户端生成ID。为栽种情况下保证ID的唯一性的唯一合理的类型就是GUID。实现一个客户端可以使用以及生成在数据库中为索引优化了的GUID的算法相对简单些。一种方式就是复制NHibernate使用的代码,这应该不成问题,因为NHibernate的源代码是免费的。
使用LINQ to NHibernate的错误方式
LINQ to NHibernate 是对框架一个非常好的补充,它使查询数据库变得非常简单。LINQ查询也比使用HQL,Criteria Query或者QueryOver语法更可读。另外,LINQ to NHibernate查询和LINQ to objects一样是可以组合的。
然而,这个API如果使用不当也有危险。要想充分利用LINQ就必须理解系统边界。
只要在LINQ to NHibernate上下文中,我们就可以处理IQueryable<T>类型的结果集。
LINQ查询总是延迟执行。也就是,我们可以定义一个LINQ查询,而系统并不视图执行它。例如,下面的语句并不会引起系统执行查询:
var products = session.Query<Product>().Where(p => p.Discontinued);
当我们开始遍历结果时,查询才会执行:
foreach(var product in products) { // do something with product }
LINQ to NHibernate的数据源是数据库。当我们遍历这样一个查询,Hibernate LINQ提供程序解析查询表达式树生成一个SQL语句,然后在数据库中执行。
如前所示,LINQ查询是可以组合的。如果我们想将上面的查询按照category的名字分组,然后打印cateogry的名字列表,category名字的后边是它的所有产品的名字和单价,我们可以这样做:
using (var session = factory.OpenSession()) using (var tx = session.BeginTransaction()) { var products = session.Query<Product>() .Where(p => p.Discontinued); foreach (var productGroup in products.GroupBy( p => p.Category.Name)) { Console.WriteLine("Category: {0}", productGroup.Key); foreach (var product in productGroup) { Console.WriteLine(" {0} - {1}", product.Name, product.UnitPrice); } } }
上面的代码片段,我们将前面获取产品的查询应用了GroupBy操作。然后我们遍历产品分组并打印category的名字,然后是属于那一组的所有产品列表。
如果我们运行代码,下面的查询就被发送到数据库:
这会触发一个异常,因为查询时不正确的。只有出现在group by语句中的字段才可以出现在select列表中。然而,我们需要所有的字段,所以我们必须寻找另外的解决方案。
我们需要所有的产品,所以可以在GroupBy操作应用之前强制NHibernate加载它们。为此,我们可以添加一个AsEnumerable()语句,如下面的代码所示:
var productGroups = session.Query<Product>()
.Where(p => p.Discontinued)
.AsEnumerable()
.GroupBy(p => p.Category.Name);
现在查询可以执行了,期望的结果可以打印到屏幕上了。
AsEnumerable语句用于转换上下文从LINQ to NHibernate到LINQ to objects而不触发查询执行。现在,我们不论何时遍历查询结果,LINQ提供程序都知道获取表达式树的哪部分转换成SQL语句,然后可以在数据库中执行。
延迟加载的麻烦
延迟加载是NHibernate给我们提供的非常方便的功能,但是如果不注意就会导致我们的系统缓慢。
如果我们定义领域模型的不同实体通过引用或一对多关系彼此关联,延迟加载非常有用。我们只处理一个特定的实体时,延迟加载会阻止NHibernate总是加载所有关联的实体。让我们看一个简单的blog引擎。有一个Blog实体,它有一个Post实体的集合,如下图所示,默认,每个Blog实体的Post实体集只有在需要时才被加载,也就是说,它们是延迟加载的。
select(n+1)问题
当我们使用下面的代码从数据库中加载blog实体:
using (var session = factory.OpenSession()) using (var tx = session.BeginTransaction()) { blog = session.Load<Blog>(1001); tx.Commit(); }
NHibernate生成下面的查询:
注意只查询了Blog表,Post表目前还没有访问。在我们的代码中,只要访问的只是blog实体的属性(如name,author),post就不会被加载。
当我们尝试使用下面的代码访问blog的Post集合时:
var nbrOfPosts = blog.Posts.Count;
有趣的事情发生了。NHibernate创建了另外的查询检索与blog关联的所有post:
假设我们想生成一个报表,打印所有blog的名字和作者,每个blog后面是其所有post的标题列表。我们使用下面的代码完成:
using (var session = factory.OpenSession()) using (var tx = session.BeginTransaction()) { var blogs = session.Query<Blog>(); foreach (var blog in blogs) { Console.WriteLine("Blog {0} by {1}", blog.Name, blog.Author); foreach (var post in blog.Posts) { Console.WriteLine(" {0}", post.Title); } } tx.Commit(); }
结果如下图所示:
在前面的例子中我们有三个不同的blog。每个blog有一些post。使用前面的代码片段,NHibernate创建了一个查询检索所有的blog,然后为每一个blog创建一个额外的查询检索这个blog的post。数学上,如果我们创建n个blog,那么NHibernate总共创建(n+1)个查询。这就是select(n+1)查询问题。
只有少数博客,这可能只是一个小问题。然而,如果有数百甚至上千个blog,这就是一个大问题了。但是不要担心,当我们加载blog的时候,通知NHibernate预先加载所有的post就可以解决这一问题,代码如下所示:
using (var session = factory.OpenSession()) using (var tx = session.BeginTransaction()) { var blogs = session.Query<Blog>() .Fetch(b => b.Posts); foreach (var blog in blogs) { Console.WriteLine("Blog {0} by {1}", blog.Name, blog.Author); foreach (var post in blog.Posts) { Console.WriteLine(" {0}", post.Title); } } tx.Commit(); }
在前面的LINQ to NHibernate查询中,我们使用Fetch方法指定和blog一起预先加载post。只需改变这一个地方,其他都保持不变。
修改之后,NHibernate创建的查询如下图所示:
注意只生成一个查询检索所有的blog和所有的post。
session关闭后访问延迟加载部分
当使用NHibernate时,另一个开发人员容易犯的错误是当他们尝试访问一个延迟加载的实体的属性或集合时,用于检索实体的session已经关闭了。这种情况,NHibernate会抛出一个异常,如下图所示:
我们怎么做可以避免这种情况呢?有几个可能的方法:
- 尽可能地在所有数据已经加载和使用之后关闭session。在web应用程序中如果使用的是每个请求一个session的模型,可以在请求开始的时候打开session,在请求结束的时候关闭session。
- 使NHibernate预先加载原本延迟加载的数据。
- 使用NHibernateUtil类强制延迟加载的集合或属性初始化。在blog的例子中代码是:NHibernateUtil.Initialize(blog.Posts);。
- 使用数据库视图加载数据,当映射这些视图时完全避免使用延迟加载。
刚刚是不是加载了整个数据库?
如果我们有一个复杂的域并用这个领域模型查询数据,一个NHibernate请求在数据库中触发成千上万的查询是很容易发生的。所有这些查询都是由延迟加载导致的。
在领域模型中,通过定义在对象模型中的关系从一个实体到另一个实体很容易。下面的代码看似无害,但是在数据库中它会引起很多的麻烦:
LineItem.Order.Customer.CustomerName
在表达式中限制小数点的个数很重要。