本系列包含两篇文章,第 1 部分 讨论了 Hibernate 和其他对象-关系映射(ORM)工具的几个基本最佳实践。通过使用通用基领域类和接口、集中的审计和泛型数据访问对象(泛型 DAO),应用程序可以建立更紧凑且可维护的领域模型和持久化层。
通过应用 第 1 部分 中的概念,可以提供新的代码重用机会。在这个部分中,我们首先讨论如何使用 Hibernate 和多态性在领域模型中集成行为。接下来,继续 第 1 部分 对泛型 DAO 的讨论。在应用程序中集成和使用泛型 DAO 之后,您可能会发现更多的通用操作。我们将演示如何在泛型 DAO 中集成数据分页和查询,从而减少代码。最后,讨论改进领域模型性能的策略。如果不采用这些策略,领域模型中配置错误的关联可能会导致大量多余的查询,也可能获取不需要的记录,从而浪费资源。
因为数据库表本身没有行为,开发人员常常把领域模型实体的行为放在服务或视图层中。但是,这种方式并不合适,因为这违反了面向对象的基本规则:对象拥有行为和数据。如果把行为从对象转移到服务中,对象就成了纯粹的数据容器。另外,把实体的行为放在服务或视图层中,会导致实体的核心逻辑分散在应用程序中的许多地方,导致维护问题。Hibernate 等工具有助于把行为与数据一起放在模型中,这有助于构造领域驱动的模型。
我们继续以 第 1 部分 中使用的职员示例为例。图 1 给出的对象模型定义了工资支付方式功能:
图 1. 工资支付方式计算的对象模型![工资支付方式计算的对象模型](https://i-blog.csdnimg.cn/blog_migrate/6b23a3e0f1e7c8c9a009b2161b9847c5.png)
假设数据库包含一个 Employee
表,这个表与 PayRate
表相关联。工资支付方式表有一个 employeeType
列,这个列有两个有效值:Hourly
和 Salary
。用来计算职员工资的算法取决于这个列的值,因为小时制职员的工资计算方法与周薪制职员的工资计算方法不一样。周薪制职员每周领取相同的工资,无论他们一周工作了多少小时。对于小时制职员,必须根据他们工作的小时数支付工资,还可能需要计算加班时间。
如果把工资计算代码放在领域实体中,就会产生更紧凑的领域模型,而且避免把逻辑分散在各层中。工资支付方式示例使用单一表继承。为此,首先需要定义一个识别器(discriminator) — 识别器是表中的一列,它告诉 Hibernate 为了表示数据库中的数据应该实例化的对象类型。对于这个示例,employeeType
列作为识别器。接下来,必须定义超类,这个类作为这些实体的基类 — 这个示例使用抽象类 PayRate
。清单 1 给出这个超类的注解和类声明:
@Entity
@Inheritance(strategy=InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn( name="employeeType", discriminatorType=DiscriminatorType.STRING )
public abstract class PayRate extends BaseEntity{//...}
最后,需要为每个可能的子类创建实现。清单 2 为周薪制职员和小时制职员定义了子类:
清单 2. 子类的定义@Entity
@DiscriminatorValue("Salary")
public class SalaryPayRate extends PayRate {//...}
@Entity
@DiscriminatorValue("Hourly")
public class HourlyPayRate extends PayRate {//...}
在查询 PayRate
表时,如果 employeeType="Salary"
,Hibernate 就自动创建 SalaryPayRate
的实例;如果 employeeType="Hourly"
,就创建 HourlyPayRate
的实例。然后,应用程序代码可以调用 createPayCheck
方法,并确保用正确的算法计算工资。Hibernate 作为这些类的工厂,它会在正确的时间创建正确的实例。
在编写查询时,Hibernate 还能够感知多态性。在编写寻找所有周薪制职员的查询时,Hibernate 会在查询中加上 employeeType="Salary"
,这样就能够获得所需的结果。清单 3 给出一个多态性查询示例;注意,这个查询并没有什么特殊之处:
Criteria crit = session.createCriteria(PayRate.class);
crit.add(Restictions.eq("jobRole", "Programmer");
...
Criteria salaryCrit = session.createCriteria(SalaryPayRate.class);
salaryCrit.add(Restrictions.eq("yearlyRate",50000.00));
有时候,实体的识别器不是简单的单一列。可能由多个列组成识别器的逻辑,逻辑也可能不是基本的当 A 列等于 X 时 逻辑。也可以定义一个公式来决定要创建的实例。例如,如果一列中的值是 null,就创建某个类的实例;如果这一列包含实际值,就创建不同的实例。这些类型的策略常常意味着数据模型应该重构,但是遗留数据模型可能无法进行重构。
使用 Hibernate 为模型中的实体选择行为是很不错,但是对于不同的实体常常还需要不同的数据集。如果一些字段对于某个子类有意义,但是对于其他子类没有意义,就可以把它们从超类中删除,并放到它们所属的子类中。这可以避免未来的代码维护者意外使用不属于当前实例的字段。
例如,为了计算小时制职员的工资,需要加班时间信息,而这一信息对于计算周薪制职员的工资是不必要的。加班工资对于周薪制职员没有意义,所以可以把它从 PayRate
类转移到 HourlyRate
类中。同样,yearlyRate
字段不属于 HourlyRate
类,可以转移到 SalaryRate
类中。
PayRate
类定义周薪制职员和小时制职员通用的字段,见清单 4:
PayRate
类
public abstract class PayRate extends BaseDomainEntity{
public String getJobRole(){//...}
@OneToMany(cascade = CascadeType.ALL)
@OrderBy("payPeriodBeginDate")
public List<PayCheck> getPayChecks(){//...}
@OneToOne(cascade = CascadeType.ALL)
public Employee getAssociatedEmployee(){//...}
}
SalaryRate
类继承这些字段并添加一个 yearlyRate
字段,见清单 5:
SalaryRate
类
@Entity
@DiscriminatorValue("Salary")
public class SalaryPayRate extends PayRate {
private double yearlyRate;
//...
}
随着越来越多的应用程序使用泛型 DAO,您会在数据访问层中发现更多的通用功能。提取通用行为并把它们包含在泛型 DAO 中,让类的所有用户通过泛型 DAO 访问这些功能,这样就可以减少代码重复。
常见的两种通用操作是根据搜索参数执行查询和数据库级的分页。
几乎所有持久化实体都需要某种查询功能。通常通过给对象中要搜索的一个或多个字段输入搜索文本(有时候支持通配符功能)来指定查询。一种查询方法是,创建持久化对象的一个实例,然后填充需要用搜索条件搜索的字段。 Hibernate 通过示例(example) 查询支持这种方式。
图 2 给出扩展的 BaseDao
接口,它支持按示例查询功能:
BaseDao
接口
![扩展的 BaseDao 接口](https://i-blog.csdnimg.cn/blog_migrate/61ad2dbf785ba89f7600a52b9d2159b5.png)
在给出这个接口的实现之前,我们先考虑一下如何使用这样的 DAO。请考虑以下代码,这段代码演示了对某个持久化实体执行查询的过程:
Employee example = employeeDao.getNewInstance();
example.setLastName("Smith");
List<Employee> results = employeeDao.getByExample(example);
在 Web 应用程序中,搜索参数可能是代表搜索表单的后端对象,而查询的结果会显示在用户界面上。清单 6 展示了使用 Hibernate 实现 getAllByExample()
方法的方式:
getAllByExample
方法的实现
List<B> getAllByExample(B example) {
Criteria criteria = getCurrentSession().createCriteria(getQueryClass());
Example hibExample = Example.create(example);
return criteria.add(hibExample).list();
}
这种方式的优点在于,它能够显著减少代码。不但通过使用泛型 DAO 减少了 DAO 代码,还可以创建通用的 UI 模板代码,这种代码不知道所查询和显示的数据的类型。它只知道需要从 UI 收集某些数据并使用这些数据执行查询,然后把结果显示给用户。
在执行产生大型结果集的数据库查询时,常常对结果进行分页,然后让用户在页面之间导航。有两种实现分页的方式。第一种是执行查询,从数据库获得完整的结果集,然后每次只向用户显示结果集中的一页。由于要传输完整的结果集并把它存储在用户会话中,这种方式需要成本。
另一种方式是在数据库中执行分页。这会减少资源消耗,但是需要比较复杂的编程模型。幸运的是,通过扩展泛型 DAO 可以使这种功能适应各种对象类型。图 3 给出了进一步扩展的 BaseDao
接口:
BaseDao
接口
![分页](https://i-blog.csdnimg.cn/blog_migrate/36bbe4e395c70322039df028d783574c.png)
图 3 引入了 PageInfo
类,这个类识别应该获取的数据页面。getPageAll
方法的实现见清单 7:
getPageAll
方法的实现
public List<B> getPageAll(PageInfo pageinfo) {
return getCurrentSession().createCriteria(getQueryClass()).
setFirstResult(pageinfo.getFirstRow()).
setMaxResults(pageinfo.getMaxResults()).list();
}
本文包含的示例代码提供了更多可以添加到泛型 DAO 中的功能(参见 下载)。
在对 Hibernate 应用程序进行性能调优时,大部分时间花在调整 Hibernate 处理实体关联的方式上。我们希望尽可能减少装入应用程序中的数据量,同时尽可能减少需要执行的查询数量。Hibernate 提供了两种处理关联的主要方式:惰性抓取(lazy fetching)和即时抓取(eager fetching)。
惰性抓取可以尽可能减少从数据库查询到的数据量。如果关联被标为惰性的,那么在装载对象时并不通过关联查询所有数据,而是直到实际使用这个关联时才执行查询。例如,Employee
对象有一个与之相关联的 Address
对象。这两个对象之间的关联是惰性的,所以当装载 Employee
对象时,Hibernate 并不自动装载 Address
。相反,当装载 Employee
对象时,Hibernate 会创建 Address
的一个代理实例。当首次访问这个代理实例时,代理要求 Hibernate 会话查询 Address
对象,以后的所有调用都直接交给查询到的实例。如果在 Employee
对象的生命周期内没有使用 Address
对象,就不需要执行 Address
查询。这个特性确保 Hibernate 只在需要时装载数据。
在大多数情况下,应该使用惰性抓取作为默认的关联抓取策略。真实的对象模型往往包含大量复杂的对象关联;如果不使用惰性抓取,那么在装载单一对象时,很容易导致将大量数据装载到会话中。启用惰性抓取很简单,但是根据关联类型的不同,有细微的差异。基于集合的关联(OneToMany
和 ManyToMany
)在默认情况下设置为惰性的,所以不需要进行配置。单一对象关联(OneToOne
和 ManyToOne
)在默认情况下不是惰性的。为了对这些关联使用惰性抓取,需要在关联的注解中指定惰性抓取:
@ManyToOne(fetch=FetchType.LAZY) public void getAddress() { |
![]() |
|
尽管惰性抓取应该作为大多数应用程序的主要抓取策略,但是它增加了复杂性。最严重的问题是 LazyInitializationException
。惰性的关联可能在最初装载它的父对象之后很长时间才被访问(并获取相关联的实体)。但是,为了查询关联的数据,需要 Hibernate 会话可用并与数据库连接池中的一个连接相连。如果在获取对象之后关闭 Hibernate 会话和相关联的数据库连接,在访问关联之前没有恢复,那么就会发生这种错误。如果对惰性关联执行查询,但是没有相关联的数据库连接,Hibernate 就会抛出 LazyInitializationException
异常。有许多处理这个问题的策略,但是这个主题超出了本文的范围(相关讨论参见 参考资料 中的 “Open Sessions in View” 链接)。
要考虑的另一个问题是,在访问惰性关联时要执行多少个查询。当第一次访问一个惰性关联的实例时,执行一个查询。这对于单一对象实例不是什么大问题,但是循环遍历对象列表很容易导致执行大量查询。例如,假设一个示例程序要输出与 10 个职员相关联的地址。如果使用惰性抓取,在默认情况下将执行 11 个查询 — 执行一个查询获取职员的列表,然后为获取每个职员的地址各执行一次查询。这显然会导致严重的性能问题。这个问题很常见,Hibernate 把这个问题称为 n+1 选择问题。(Martin Fowler 把它称为 波动装载(ripple loading)。)
即时抓取正好与惰性抓取相反。当装载一个对象时,会立即装载标为即时抓取的所有关联。当装载单一对象时,运行的查询并不比使用惰性关联时少。但是,因为查询会立即运行,所以不会出现 LazyInitializationException
异常。即时抓取还有助于解决 n+1 选择问题。如果Employee
对象的Address
关联是即时的,并执行查询获取 10 个 Employee
实例时,只执行两个查询:一个查询获取 Employee
实例的列表,一个查询获取与 Employee
对象相关联的 Address
实例列表。Hibernate 会无缝地把这些列表合并在一起。
即时抓取可能不适合作为默认策略。如果过分频繁地使用这种策略,很容易导致装载大量不需要的数据。但是,有时候总是需要访问某个关联,在这种情况下使用即时抓取是有意义的。启用即时抓取的过程与启用惰性抓取基本相同。只需在注解中指定即时抓取:
@ManyToOne(fetch=FetchType.EAGER) public void getAddress() {//...} |
Hibernate 为解决 惰性抓取的问题 提供了一个优雅的解决方案,同时保持惰性抓取的优点。Hibernate 允许查询的定义覆盖关联的默认抓取策略。可以在对对象树进行一般访问期间使用惰性抓取,但是在特殊情况下(在查询时已经知道需要使用关联),可以通过设置查询参数把关联标为即时的。例如,在职员示例程序中,可能需要查询在一段时期内工作了特定小时数的所有职员,以便生成邮件标签。清单 8 中的查询可以完成这个任务:
Criteria addressCrit = empCrit.createCriteria("payChecks")
.add(Restrictions.eq("hoursWorked", hoursParam));
empCrit.setFetchMode("address",FetchMode.JOIN);
return empCrit.list();
清单 8 中的查询首先构造一个一般的条件查询,但是有意思的部分是对 setFetchMode
的调用。这个调用覆盖职员地址关联默认的惰性抓取策略。FetchMode.JOIN
在搜索查询期间获取 Address
实例,使用联结在一个查询中返回所有数据。在许多情况下,这就是我们需要的,它的效果比任何其他策略都要好。
但是,在使用 FetchMode.JOIN
时要注意一些问题。在使用基于集合的关联时,查询返回的行数可能与默认抓取策略返回的行数不同。这是因为返回的一个父对象可能有多个子对象会返回。Hibernate 在运行时无缝地隐藏这些细节,它会正确地解析结果集并返回正确的对象列表。但是,如果查询中还使用了 setFirstResult
或 setMaxResults
,这种行为就会导致问题。在一般情况下,Hibernate 使用与数据库相关的 SQL 语句实现这些特性,但是因为原始的 SQL 查询返回不正确的行数,与数据库相关的技术无法发挥作用。相反,会从数据库获取完整的数据集,然后 Hibernate 提取满足请求所需的数据部分。这样的话,原本用来纠正 n+1 选择问题 的一个简单的性能调整却导致把大量不使用的行装载到应用程序层中。
Hibernate 还提供了第二种可以在查询中使用的抓取模式设置。FetchMode.SELECT
覆盖即时抓取策略,让关联使用惰性抓取。但是,无法在查询时覆盖关联来使用默认的即时抓取技术(第二个查询立即执行)。
n+1 选择问题 的另一个解决方案是,通过批量处理惰性装载请求,混合使用惰性抓取和即时抓取。这种方式仍然以惰性方式装载数据,但并不是每次惰性装载一个关联,而是装载多个关联。许多 ORM 框架实现了这种基本思想。Hibernate 把这个设置称为 BatchSize
,TopLink 把它称为批量读取(batch reading)。
对于前面的示例,假设一个查询获取 10 个 Employee
实例。与 Employee
相关联的 Address
实例是惰性装载的,每批装载 5 个。最初,使用一个查询获取 Employee
实例,但是不执行针对 Address
实例的查询。当访问 Address
实例之一时,Hibernate 获取这个 Address
实例和后面 4 个惰性装载的实例。假设要访问与 Employee
实例相关联的所有 Address
实例,那么只需要用两个查询获取所有 Address
实例,而不是 10 个查询。
为了使用这种策略,需要在 Address
类中使用 @BatchSize
注解,见清单 9:
@Entity
@Table(name="ADDRESS")
@BatchSize(size=5)
public class Address extends AuditableEntity {//...}
注意,在清单 9 中,是对Address
加上注解,而不是 Employee
类。加上这个注解之后,每次自动装载 5 个 Address
实例(如果可惰性装载的记录不足 5 个,那么装载的实例数量会更少)。还可以给集合加上 @BatchSize
注解,从而批量装载实体的集合。
本系列讨论了许多解决常见的持久化层问题的方法。尽管解决方案很简单(把主键重构在基类中以及修改模型的抓取策略),但是能够显著改进 Hibernate 和领域模型的效果,产生更容易维护的应用程序。通过 Hibernate 等框架把继承和多态性等面向对象概念应用于数据库,就能够产生表现力和可重用性更强的领域模型。我们希望这里介绍的最佳实践能够适用于您的环境和领域。
描述 | 名字 | 大小 | 下载方法 |
---|---|---|---|
本文的示例代码 | PatternsOfPersistenceCode.zip | 16KB | HTTP |