编程

如何使用 Hibernate 实现软删除

515 2024-06-19 02:50:00

某些应用中,你不希望或不允许从数据库中永久删除记录。但仍然需要删除或隐藏不再活动的记录。一个例子可能是想要保留的用户帐户,因为它链接到仍在使用的其他业务对象。

你有两个基本选项可以将这些信息保存在系统中。你可以保留记录所有更改的审核日志,也可以执行隐藏已删除记录的软删除。我在关于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) 确定存储在数据库中的布尔值的含义:

  1. SoftDeleteType.ACTIVE
    其值为 true 将记录标记为激活,并且 Hibernate 使用 active 作为默认字段名。
  2. 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 实现很直接。它通过 convertToDatabaseColumnconvertToEntityAttribute 方法实现了 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 中实现软删除并不难,不过需要点额外的工作。你需要:

  1. 当删除实体对象时,告诉 Hibernate 执行 SQL UPDATE 而非 DELETE 操作,并且
  2. 查询操作时排除所有软删除记录。

我将在下面的例子中展示如何轻松地做到这一点。所有这些都将使用以下 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 = ?

这就是创建软删除所需的所有步骤。此外,还有两件事情需要处理:

  1. 删除一个 Account 实体时,Hibernate 不会在当前会话(session)中更新其 state 属性值。 
  2. 你需要适配所有查询使之排除已删除实体。

在当前会话更新 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 注释来定义一个子句,该子句默认情况下排除所有软删除的记录。