1. 概述
Spring Data的CrudRespository#save无疑很简单,但有一个功能可能是一个缺点:它更新表中的每一列。这就是 CRUD 中 U 的语义,但如果我们想做一个 PATCH 怎么办?
在本教程中,我们将介绍执行部分更新而不是完整更新的技术和方法。
2. 问题
如前所述,save() 将使用提供的数据覆盖任何匹配的实体,这意味着我们无法提供部分数据。这可能会变得不方便,特别是对于具有大量场的大型对象。
如果我们看一下ORM,就会发现存在一些补丁:
- Hibernate的@DynamicUpdate注释,动态重写更新查询
- JPA 的@Column注释,因为我们可以使用可更新参数禁止对特定列进行更新
但是我们将以特定的意图来解决这个问题:我们的目的是为我们的实体准备保存方法,而不依赖于ORM。
3. 我们的案例
首先,让我们构建一个客户实体:
@Entity
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
public long id;
public String name;
public String phone;
}
然后我们定义一个简单的 CRUD 存储库:
@Repository
public interface CustomerRepository extends CrudRepository<Customer, Long> {
Customer findById(long id);
}
最后,我们准备一个客户服务:
@Service
public class CustomerService {
@Autowired
CustomerRepository repo;
public void addCustomer(String name) {
Customer c = new Customer();
c.name = name;
repo.save(c);
}
}
4. 加载和保存方法
让我们首先看一个可能熟悉的方法:从数据库加载我们的实体,然后只更新我们需要的字段。这是我们可以使用的最简单的方法。
让我们在服务中添加一种方法来更新客户的联系人数据。
public void updateCustomerContacts(long id, String phone) {
Customer myCustomer = repo.findById(id);
myCustomer.phone = phone;
repo.save(myCustomer);
}
我们将调用findById方法并检索匹配的实体。然后,我们继续更新所需的字段并保留数据。
当要更新的字段数量相对较少且我们的实体相当简单时,此基本技术非常有效。
要更新数十个字段会发生什么情况?
4.1. 映射策略
当我们的对象具有大量具有不同访问级别的字段时,实现DTO 模式是很常见的。
现在假设我们的对象中有一百多个电话字段。像以前一样编写将数据从 DTO 倾倒到我们的实体的方法可能会很烦人且非常难以维护。
尽管如此,我们可以使用映射策略来解决这个问题,特别是使用 MapStruct实现。
让我们创建一个CustomerDto:
public class CustomerDto {
private long id;
public String name;
public String phone;
//...
private String phone99;
}
我们还将创建一个客户映射器:
@Mapper(componentModel = "spring")
public interface CustomerMapper {
void updateCustomerFromDto(CustomerDto dto, @MappingTarget Customer entity);
}
@MappingTarget注释允许我们更新现有对象,从而节省了编写大量代码的痛苦。
MapStruct有一个@BeanMapping方法装饰器,它允许我们定义一个规则来在映射过程中跳过空值。
让我们将其添加到我们的updateCustomerFromDto方法接口中:
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
有了这个,我们可以加载存储的实体并在调用 JPAsave方法之前将它们与 DTO 合并——事实上,我们只会更新修改后的值。
因此,让我们向服务添加一个方法,它将调用我们的映射器:
public void updateCustomer(CustomerDto dto) {
Customer myCustomer = repo.findById(dto.id);
mapper.updateCustomerFromDto(dto, myCustomer);
repo.save(myCustomer);
}
这种方法的缺点是我们无法在更新期间将null值传递给数据库。
4.2. 更简单的实体
最后,请记住,我们可以从应用程序的设计阶段开始解决这个问题。
必须将我们的实体定义为尽可能小。
让我们看一下我们的客户实体。
我们将稍微构建一下它,并将所有电话字段提取到ContactPhone实体,并处于一对多关系下:
@Entity public class CustomerStructured {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
public Long id;
public String name;
@OneToMany(fetch = FetchType.EAGER, targetEntity=ContactPhone.class, mappedBy="customerId")
private List<ContactPhone> contactPhones;
}
代码很干净,更重要的是,我们取得了一些成就。现在我们可以更新我们的实体,而无需检索和填写所有电话数据。
处理小型实体和有界实体允许我们仅更新必要的字段。
这种方法的唯一不便是,我们应该有意识地设计我们的实体,而不是陷入过度设计的陷阱。
5. 自定义查询
我们可以实现的另一种方法是为部分更新定义自定义查询。
事实上,JPA 定义了两个注释,@Modifying和 @Query,允许我们显式地编写更新语句。
现在,我们可以告诉我们的应用程序在更新期间的行为方式,而不会给ORM留下负担。
让我们在存储库中添加自定义更新方法:
@Modifying
@Query("update Customer u set u.phone = :phone where u.id = :id")
void updatePhone(@Param(value = "id") long id, @Param(value = "phone") String phone);
现在我们可以重写我们的更新方法:
public void updateCustomerContacts(long id, String phone) {
repo.updatePhone(id, phone);
}
我们现在能够执行部分更新。只需几行代码,无需更改实体,我们就实现了目标。
这种技术的缺点是,我们必须为对象的每个可能的部分更新定义一种方法。
6. 结论
部分数据更新是一项非常基本的操作;虽然我们可以让我们的 ORM 来处理它,但有时完全控制它是有利可图的。
正如我们所看到的,我们可以预加载数据,然后更新它或定义我们的自定义语句,但请记住要意识到这些方法所暗示的缺点以及如何克服它们。
像往常一样,本文的源代码可在GitHub 上找到。