如何使用 Hibernate 实现软删除
某些应用中,你不希望或不允许从数据库中永久删除记录。但仍然需要删除或隐藏不再活动的记录。一个例子可能是想要保留的用户帐户,因为它链接到仍在使用的其他业务对象。
你有两个基本选项可以将这些信息保存在系统中。你可以保留记录所有更改的审核日志,也可以执行隐藏已删除记录的软删除。我在关于Hibernate Envers 的文章中解释了审计日志选项。今天,我想展示如何使用 Hibernate 实现软删除。但在这么做之前,让我快速解释一下什么是软删除。
什么是软删除?
软删除通过将记录标记为已删除进行更新,而不是将其从数据库表中删除。对软删除进行建模的常见方法有:
- 使用一个布尔值,说明记录是活跃的还是已删除,
- 使用枚举,对记录的状态进行建模,
- 使用时间戳,保存软删除执行的日期和时间。
添加这样的字段显然只是实现软删除功能的第一步。还必须在持久化新记录时对其进行设置,并且必须更改其软删除指示符,而不是删除记录。要向用户隐藏软删除的记录,还必须调整所有查询,以根据软删除指示符排除记录。
这听起来可能需要做很多工作,但如果使用的是 Hibernate,那就不是了。
如何使用 Hibernate 实现软删除
在 6.4 版本中,Hibernate 团队为 Hibernate ORM 引入了一个官方的软删除功能。现在只需要 1 个注释就可以激活实体类的软删除。然后,Hibernate 生成软删除记录所需的 SQL UPDATE 语句,并调整所有查询语句以排除软删除的记录。在下一节中,我将向你展示如何激活软删除以及不同配置选项。
对于 <=6.3 版本的 Hibernate,你必须自己实现软删除功能。这需要一点额外的工作。但别担心,这并不复杂,而且你可以在实体映射中声明所有需要的部分。因此,不必在业务代码中处理它。在本文的最后,我将向你展示如何实现映射。
Hibernate >= 6.4 的软删除
Hibernate 6.4 引入了对软删除的支持。只需用 @SoftDelete
注释来注释实体类,其余的由 Hibernate 处理。
Hibernates 默认的软删除实现
下面的代码使用 Hibernate 的默认软删除实现。它需要在基础数据库表中删除布尔(boolean )类型的列。可以使用 @SoftDelete
注释的columnName
属性为该列指定其他名称。
@Entity
@SoftDelete
public class Account { ... }
当持久化新的 Account
实体时,Hibernate 会自动将 deleted
字段设置为 false
。
Account a = new Account();
a.setName("thjanssen");
em.persist(a);
10:30:49,099 DEBUG SQL:135 - insert into Account (name,deleted,id) values (?,false,?)
删除实体时,Hibernate 会将 deleted
字段设置为 true
。Hibernate为所有 JPQL 和 Criteria 查询以及所有生成的语句添加了一个子句,该子句排除了 deleted=true
的所有记录。
Account a = em.find(Account.class, a.getId());
em.remove(a);
TypedQuery<Account> q = em.createNamedQuery("Account.FindByName", Account.class);
q.setParameter("name", "%ans%");
a = (Account) q.getSingleResult();
10:30:49,199 DEBUG SQL:135 - select a1_0.id,a1_0.name from Account a1_0 where a1_0.deleted=false and a1_0.id=?
10:30:49,211 DEBUG SQL:135 - update Account set deleted=true where id=? and deleted=false
10:30:49,234 DEBUG SQL:135 - select a1_0.id,a1_0.name from Account a1_0 where a1_0.name like ? escape '' and a1_0.active=true
使用其他软删除类型
Hibernate 支持两种软删除类型(SoftDeleteTypes
) 确定存储在数据库中的布尔值的含义:
- SoftDeleteType.ACTIVE
其值为true
将记录标记为激活,并且 Hibernate 使用active
作为默认字段名。 - SoftDeleteType.DELETED
其值为true
将记录标记为已删除,并且 Hibernate 使用deleted
作为默认字段名。这是默认的设置。
此处,你可以看到将 SoftDeleteType
设置为 ACTIVE
的简单映射。
@Entity
@SoftDelete(strategy = SoftDeleteType.ACTIVE)
public class Account { ... }
现在当执行与之前相同的测试时,Hibernate 将实体的当前状态存储在 active
列中。当我删除实体时,Hibernate 将该字段设置为 false
,并且所有查询只返回 active=true
的记录。
// persist a new Account
Account a = new Account();
a.setName("thjanssen");
em.persist(a);
// find and remove an Account
a = em.find(Account.class, a.getId());
em.remove(a);
// query an Account
TypedQuery<Account> q = em.createNamedQuery("Account.FindByName", Account.class);
q.setParameter("name", "%ans%");
a = (Account) q.getSingleResult();
// persist a new Account
10:46:26,099 DEBUG SQL:135 - insert into Account (name,active,id) values (?,true,?)
// find and remove an Account
10:46:26,199 DEBUG SQL:135 - select a1_0.id,a1_0.name from Account a1_0 where a1_0.active=true and a1_0.id=?
10:46:26,211 DEBUG SQL:135 - update Account set active=false where id=? and active=true
// query an Account
10:46:26,234 DEBUG SQL:135 - select a1_0.id,a1_0.name from Account a1_0 where a1_0.name like ? escape '' and a1_0.active=true
使用另一个字段类型
Hibernate 默认的软删除实现使用 boolean 类型的列来存储每条记录的当前状态。你可以通过提供一个 AttributeConverter
来更改这一点,该 AttributeConverter
将内部使用的布尔值映射到你选择的数据库类型。
如果使用的是旧的数据库,这将非常有用。它们有时使用字符串或枚举来表示每条记录的当前状态。
此处,可以看到一个实体映射,它告诉 Hibernate 将当前记录的状态存储在 state 列中,并使用 StateConverter
AttributeConverter 来映射属性。
@Entity
@SoftDelete(strategy = SoftDeleteType.ACTIVE, columnName = "state", converter = StateConverter.class)
public class Account { ... }
这个 StateConverter
实现很直接。它通过 convertToDatabaseColumn
和 convertToEntityAttribute
方法实现了 AttributeConverter
接口。
Hibernate 的软删除实现依赖于一个布尔值来说明记录是活动的还是已删除的。因此,AttributeConverter
的第一个类型参数必须是布尔值。第二个类型参数指定要存储在数据库中的类型。在这个例子中,我想将布尔值映射到字符串 “active” 或 “inactive”。
public class StateConverter implements AttributeConverter<Boolean, String>{
@Override
public String convertToDatabaseColumn(Boolean attribute) {
return attribute ? "active" : "inactive";
}
@Override
public Boolean convertToEntityAttribute(String dbData) {
return dbData.equals("active");
}
}
警告:如果将布尔值映射到字符串,Hibernate 的 schema 生成会创建一个长度为 1 的 varchar 列。如果字符串长度超过 1 个字符,建议提供你自己的数据库迁移。
当我现在执行与以前相同的测试时,Hibernate 将值 “active” 或 “inactive” 存储在 state 字段中。当我删除实体时,Hibernate 将该字段设置为 “inactive”,并且所有查询只返回 state=active
的记录。
// persist a new Account
Account a = new Account();
a.setName("thjanssen");
em.persist(a);
// find and remove an Account
a = em.find(Account.class, a.getId());
em.remove(a);
// query an Account
TypedQuery<Account> q = em.createNamedQuery("Account.FindByName", Account.class);
q.setParameter("name", "%ans%");
a = (Account) q.getSingleResult();
// persist a new Account
10:46:26,099 DEBUG SQL:135 - insert into Account (name,state,id) values (?,'active',?)
// find and remove an Account
11:18:39,857 DEBUG SQL:135 - select a1_0.id,a1_0.name from Account a1_0 where a1_0.state='active' and a1_0.id=?
11:18:39,867 DEBUG SQL:135 - update Account set state='inactive' where id=? and state='active'
// query an Account
11:18:39,889 DEBUG SQL:135 - select a1_0.id,a1_0.name from Account a1_0 where a1_0.name like ? escape '' and a1_0.state='active'
Hibernate < 6.4 的软删除
Hibernate <6.4 中实现软删除并不难,不过需要点额外的工作。你需要:
- 当删除实体对象时,告诉 Hibernate 执行 SQL UPDATE 而非 DELETE 操作,并且
- 查询操作时排除所有软删除记录。
我将在下面的例子中展示如何轻松地做到这一点。所有这些都将使用以下 Account
实体,该实体使用 AccountState
状态属性来说明帐户是INACTIVE、ACTIVE 还是 DELETED。
@Entity
@NamedQuery(name = "Account.FindByName", query = "SELECT a FROM Account a WHERE name like :name")
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "id", updatable = false, nullable = false)
private Long id;
@Column
private String name;
@Column
@Enumerated(EnumType.STRING)
private AccountState state;
…
}
更新记录而非删除
要实现软删除,你需要重写 Hibernate 的默认删除操作。你可以通过 @SQLDelete
注释来实现该功能。该功能允许你自定义删除实体时执行的原生 SQL 查询。参考下例中的代码。
@Entity
@SQLDelete(sql = "UPDATE account SET state = ‘DELETED’ WHERE id = ?", check = ResultCheckStyle.COUNT)
public class Account { … }
上文代码中的 @SQLDelete
注释告诉 Hibernae, 执行给定的 SQL UPDATE 语句而非默认的 SQL DELETE 语句。它将账户的状态(state)改为 DELETED
,而且你可以在所有查询中都使用 state
属性排除已删除账号。
Account a = em.find(Account.class, a.getId());
em.remove(a);
16:07:59,511 DEBUG SQL:92 – select account0_.id as id1_0_0_, account0_.name as name2_0_0_, account0_.state as state3_0_0_ from Account account0_ where account0_.id=? and ( account0_.state <> 'DELETED')
16:07:59,534 DEBUG SQL:92 – UPDATE account SET state = 'DELETED' WHERE id = ?
这就是创建软删除所需的所有步骤。此外,还有两件事情需要处理:
- 删除一个 Account 实体时,Hibernate 不会在当前会话(session)中更新其
state
属性值。 - 你需要适配所有查询使之排除已删除实体。
在当前会话更新 state 属性
Hibernate 不会解析你提供给 @SQLDelete
注释的原生查询。它只是设置绑定参数的值并执行它。因此,它不知道你为 @SQLDelete
注释提供了 SQL UPDATE 语句而不是 DELETE 语句。在执行删除操作后,它也不知道 state 属性的值是否过期。
大多数情况下,这不是问题。当 Hibernate 执行 SQL 语句时,数据库记录会得到更新,所有查询都使用新的状态值。但是,你提供给EntityManager.remove
(Object 实体)操作的 Account 实体呢?
该实体的 state
属性已过时。如果你在删除后立即发布该引用,那也没什么大不了的。在所有其他情况下,你应该自己更新属性。
最简单的方法是使用生命周期回调,正如我在下面的代码片段中所做的那样。deleteUser
方法上的 @PreRemove
注释告诉 Hibernate 在执行移除操作之前调用此方法。我使用它将 state 属性的值设置为 DELETED
。
@Entity
@SQLDelete(sql = "UPDATE account SET state = 'DELETED' WHERE id = ?", check = ResultCheckStyle.COUNT)
public class Account {
...
@PreRemove
public void deleteUser() {
this.state = AccountState.DELETED;
}
}
查询时排除软删除实体
你需要在所有查询中检查 state
属性,以从查询结果中排除已删除的数据库记录。如果手动执行,这是一个容易出错的任务,并且它会迫使你自己定义所有查询。Hibernate 会话上的 EntityManager.find(Class entityClass, Object primaryKey)
方法和相应的方法不知道 state 属性的语义,也没有将其考虑在内。
Hibernate 的 @Where
注释提供了一种更好的方法来排除所有已删除的实体。它允许定义 SQL 片段,Hibernate 将其添加到所有查询的 WHERE
子句中。下面的代码片段显示了一个 @Where
注释,如果记录的状态为 DELETED
,则该注释将排除此记录。
@Entity
@SQLDelete(sql = "UPDATE account SET state = 'DELETED' WHERE id = ?", check = ResultCheckStyle.COUNT)
@Where(clause = "state <> 'DELETED'")
@NamedQuery(name = "Account.FindByName", query = "SELECT a FROM Account a WHERE name like :name")
public class Account { ... }
如上述代码所示,当你执行 JPQL 查询或者调用 EntityManager.find(Class entityClass, Object primaryKey)
方法时,Hibernate 添加了定义的 WHERE
子句。
TypedQuery<Account> q = em.createNamedQuery("Account.FindByName", Account.class);
q.setParameter("name", "%ans%");
Account a = q.getSingleResult();
16:07:59,511 DEBUG SQL:92 – select account0_.id as id1_0_, account0_.name as name2_0_, account0_.state as state3_0_ from Account account0_ where ( account0_.state <> 'DELETED') and (account0_.name like ?)
Account a = em.find(Account.class, a.getId());
16:07:59,540 DEBUG SQL:92 – select account0_.id as id1_0_0_, account0_.name as name2_0_0_, account0_.state as state3_0_0_ from Account account0_ where account0_.id=? and ( account0_.state <> 'DELETED')
总结
如你所见,使用 Hibernate 实现软删除非常简单。
如果使用的是 >=6.4 版本的 Hibernate,则只需使用 @SoftDelete
来注释实体类。然后,Hibernate 自动管理每条记录的当前状态,并调整所有查询以排除软删除的记录。
如果使用的是 Hibernate<=6.3,则必须自己实现软删除。可以通过使用 @SQLDelete
注释来注释实体类,并提供 SQL UPDATE 语句来更改记录的状态。你还应该使用 Hibernate 的 @Where
注释来定义一个子句,该子句默认情况下排除所有软删除的记录。