1. 概述

JpaRepository 为我们提供了基础的 CRUD 操作方法。但其中一些方法并不那么直观,有时很难判断哪种方法最适合特定场景。

getReferenceById(ID)findById(ID) 就是经常让人困惑的方法。 这些方法是 getOne(ID)findOne(ID)getById(ID) 的新 API 名称。

在本教程中,我们将学习它们的区别,并找出各自更适合的场景。

2. findById()

先从这两个方法中最简单的一个开始。这个方法名副其实,开发者通常不会遇到问题。它只是根据给定的 ID 在仓库中查找实体:

@Override
Optional<T> findById(ID id);

方法返回一个 Optional。因此,如果我们传入一个不存在的 ID,可以正确假设它会返回空。

该方法底层使用立即加载,所以每次调用时都会向数据库发送请求。看个例子:

public User findUser(long id) {
    log.info("Before requesting a user in a findUser method");
    Optional<User> optionalUser = repository.findById(id);
    log.info("After requesting a user in a findUser method");
    User user = optionalUser.orElse(null);
    log.info("After unwrapping an optional in a findUser method");
    return user;
}

这个方法会生成以下日志:

[2023-12-27 12:56:32,506]-[main] INFO  com.baeldung.spring.data.persistence.findvsget.service.SimpleUserService - Before requesting a user in a findUser method
[2023-12-27 12:56:32,508]-[main] DEBUG org.hibernate.SQL - 
    select
        user0_."id" as id1_0_0_,
        user0_."first_name" as first_na2_0_0_,
        user0_."second_name" as second_n3_0_0_ 
    from
        "users" user0_ 
    where
        user0_."id"=?
[2023-12-27 12:56:32,508]-[main] TRACE org.hibernate.type.descriptor.sql.BasicBinder - binding parameter [1] as [BIGINT] - [1]
[2023-12-27 12:56:32,510]-[main] INFO  com.baeldung.spring.data.persistence.findvsget.service.SimpleUserService - After requesting a user in a findUser method
[2023-12-27 12:56:32,510]-[main] INFO  com.baeldung.spring.data.persistence.findvsget.service.SimpleUserService - After unwrapping an optional in a findUser method

Spring 可能会在事务中批量处理请求,但总会执行它们。 总的来说,findById(ID) 不会试图给我们惊喜,而是做我们期望它做的事。然而,困惑在于它有一个功能类似的方法。

3. getReferenceById()

这个方法的签名与 findById(ID) 类似:

@Override
T getReferenceById(ID id);

仅从签名判断,我们可能会假设如果实体不存在,这个方法会抛出异常。这是对的,但这不是唯一的区别。这些方法的主要区别在于 getReferenceById(ID) 是延迟加载的。 Spring 不会立即发送数据库请求,直到我们在事务中显式使用该实体。

3.1. 事务

每个事务都有其专用的持久化上下文。 有时我们可以将持久化上下文扩展到事务范围之外,但这并不常见,且仅适用于特定场景。让我们看看持久化上下文在事务中的行为:

Eager Loading With findById

在事务内,持久化上下文中的所有实体在数据库中都有直接表示。这是托管状态。因此,对实体的所有更改都会反映到数据库中。在事务之外,实体进入游离状态,更改不会反映到数据库,直到实体重新进入托管状态。

延迟加载的实体行为略有不同。Spring 不会加载它们,直到我们在持久化上下文中显式使用它们:

Lazy Loading without usingSpring 会分配一个空的代理占位符来延迟从数据库获取实体。但是,如果我们不这样做,实体在事务外将保持为空代理,任何调用都会导致 LazyInitializationException 但是,如果我们调用或以需要内部信息的方式与实体交互,就会向数据库发出实际请求:

Lazy Loading with using

3.2. 非事务性服务

了解了事务和持久化上下文的行为后,让我们检查以下调用仓库的非事务性服务。**findUserReference 没有连接的持久化上下文,getReferenceById 将在单独的事务中执行:**

public User findUserReference(long id) {
    log.info("Before requesting a user");
    User user = repository.getReferenceById(id);
    log.info("After requesting a user");
    return user;
}

这段代码会生成以下日志输出:

[2023-12-27 13:21:27,590]-[main] INFO  com.baeldung.spring.data.persistence.findvsget.service.TransactionalUserReferenceService - Before requesting a user
[2023-12-27 13:21:27,590]-[main] INFO  com.baeldung.spring.data.persistence.findvsget.service.TransactionalUserReferenceService - After requesting a user

如我们所见,没有数据库请求。理解延迟加载后,Spring 假设如果我们不在内部使用实体,可能就不需要它。 技术上我们无法使用它,因为我们唯一的事务是 getReferenceById 方法内部的事务。因此,返回的 user 将是一个空代理,如果我们访问其内部信息会导致异常:

public User findAndUseUserReference(long id) {
    User user = repository.getReferenceById(id);
    log.info("Before accessing a username");
    String firstName = user.getFirstName();
    log.info("This message shouldn't be displayed because of the thrown exception: {}", firstName);
    return user;
}

3.3. 事务性服务

让我们检查使用 @Transactional 服务时的行为:

@Transactional
public User findUserReference(long id) {
    log.info("Before requesting a user");
    User user = repository.getReferenceById(id);
    log.info("After requesting a user");
    return user;
}

由于与上一个示例相同的原因(我们在事务内没有使用实体),这会产生类似的结果:

[2023-12-27 13:32:44,486]-[main] INFO  com.baeldung.spring.data.persistence.findvsget.service.TransactionalUserReferenceService - Before requesting a user
[2023-12-27 13:32:44,486]-[main] INFO  com.baeldung.spring.data.persistence.findvsget.service.TransactionalUserReferenceService - After requesting a user

此外,在此事务性服务方法之外与此 user 交互的任何尝试都会导致异常:

@Test
void whenFindUserReferenceUsingOutsideServiceThenThrowsException() {
    User user = transactionalService.findUserReference(EXISTING_ID);
    assertThatExceptionOfType(LazyInitializationException.class)
      .isThrownBy(user::getFirstName);
}

但是,现在 findUserReference 方法定义了我们的事务范围。这意味着我们可以在服务方法中尝试访问 user,这应该会导致数据库调用:

@Transactional
public User findAndUseUserReference(long id) {
    User user = repository.getReferenceById(id);
    log.info("Before accessing a username");
    String firstName = user.getFirstName();
    log.info("After accessing a username: {}", firstName);
    return user;
}

上面的代码会按以下顺序输出消息:

[2023-12-27 13:32:44,331]-[main] INFO  com.baeldung.spring.data.persistence.findvsget.service.TransactionalUserReferenceService - Before accessing a username
[2023-12-27 13:32:44,331]-[main] DEBUG org.hibernate.SQL - 
    select
        user0_."id" as id1_0_0_,
        user0_."first_name" as first_na2_0_0_,
        user0_."second_name" as second_n3_0_0_ 
    from
        "users" user0_ 
    where
        user0_."id"=?
[2023-12-27 13:32:44,331]-[main] TRACE org.hibernate.type.descriptor.sql.BasicBinder - binding parameter [1] as [BIGINT] - [1]
[2023-12-27 13:32:44,331]-[main] INFO  com.baeldung.spring.data.persistence.findvsget.service.TransactionalUserReferenceService - After accessing a username: Saundra

数据库请求不是在调用 getReferenceById() 时发出的,而是在调用 user.getFirstName() 时发出的。

3.4. 带有新仓库事务的事务性服务

让我们看一个更复杂的例子。假设我们有一个仓库方法,每次调用时都会创建一个单独的事务:

@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
User getReferenceById(Long id);

Propagation.REQUIRES_NEW 意味着外部事务不会传播,仓库方法将创建自己的持久化上下文。 在这种情况下,即使我们使用事务性服务,Spring 也会创建两个不会交互的独立持久化上下文,任何使用 user 的尝试都会导致异常:

@Test
void whenFindUserReferenceUsingInsideServiceThenThrowsExceptionDueToSeparateTransactions() {
    assertThatExceptionOfType(LazyInitializationException.class)
      .isThrownBy(() -> transactionalServiceWithNewTransactionRepository.findAndUseUserReference(EXISTING_ID));
}

我们可以使用几种不同的传播配置来创建事务之间更复杂的交互,它们可能会产生不同的结果。

3.5. 不获取实体的情况下访问实体

考虑一个实际场景。假设我们有一个 Group 类:

@Entity
@Table(name = "group")
public class Group {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    @OneToOne
    private User administrator;
    @OneToMany(mappedBy = "id")
    private Set<User> users = new HashSet<>();
    // getters, setters and other methods
}

我们想将一个用户添加为组的管理员,可以使用 findById()getReferenceById()。在这个测试中,我们使用 findById() 获取一个用户并将其设为新组的管理员:

@Test
void givenEmptyGroup_whenAssigningAdministratorWithFindBy_thenAdditionalLookupHappens() {
    Optional<User> optionalUser = userRepository.findById(1L);
    assertThat(optionalUser).isPresent();
    User user = optionalUser.get();
    Group group = new Group();
    group.setAdministrator(user);
    groupRepository.save(group);
    assertSelectCount(2);
    assertInsertCount(1);
}

合理假设应该只有一个 SELECT 查询,但我们得到了两个。这是因为额外的 ORM 检查。让我们执行类似操作但使用 getReferenceById()

@Test
void givenEmptyGroup_whenAssigningAdministratorWithGetByReference_thenNoAdditionalLookupHappens() {
    User user = userRepository.getReferenceById(1L);
    Group group = new Group();
    group.setAdministrator(user);
    groupRepository.save(group);
    assertSelectCount(0);
    assertInsertCount(1);
}

在这个场景中,我们不需要关于用户的额外信息;只需要一个 ID。因此,我们可以使用 getReferenceById() 方便提供的占位符,这样我们只有一个 INSERT 而没有额外的 SELECT。

这样,数据库在映射时负责数据的正确性。例如,使用错误的 ID 时会得到异常:

@Test
void givenEmptyGroup_whenAssigningIncorrectAdministratorWithGetByReference_thenErrorIsThrown() {
    User user = userRepository.getReferenceById(-1L);
    Group group = new Group();
    group.setAdministrator(user);
    assertThatExceptionOfType(DataIntegrityViolationException.class)
      .isThrownBy(() -> {
          groupRepository.save(group);
      });
    assertSelectCount(0);
    assertInsertCount(1);
}

同时,我们仍然只有一个 INSERT 而没有任何 SELECT。

但是,我们不能使用相同的方法将用户添加为组成员。因为我们使用 Set,会调用 equals(T)hashCode() 方法。Hibernate 抛出异常,因为 getReferenceById() 没有获取真实对象:

@Test
void givenEmptyGroup_whenAddingUserWithGetByReference_thenTryToAccessInternalsAndThrowError() {
    User user = userRepository.getReferenceById(1L);
    Group group = new Group();
    assertThatExceptionOfType(LazyInitializationException.class)
      .isThrownBy(() -> {
          group.addUser(user);
      });
}

因此,选择方法时应考虑数据类型和使用实体的上下文。

4. 结论

findById()getReferenceById() 的主要区别在于它们何时将实体加载到持久化上下文中。理解这一点有助于实现优化并避免不必要的数据库查找。这个过程与事务及其传播紧密相关。这就是为什么应该观察事务之间的关系。

如往常一样,本教程中使用的所有代码都可以在 GitHub 上找到。


原始标题:When to Use the getReferenceById() and findById() Methods in Spring Data JPA | Baeldung