1. 概述

在数据库中,有些条目具有自然标识符,比如书籍的ISBN或个人的社会保障号(SSN)。除了传统的数据库ID,Hibernate允许我们声明某些字段为自然ID,并基于这些属性轻松查询。

本教程将讨论@NaturalId注解,并在Spring Boot项目中学习如何使用和实现它。

2. 简单的自然ID

只需通过@NaturalId注解标记一个字段,即可将其指定为自然标识符。这样,我们可以利用Hibernate的API无缝查询关联的列。

本文示例中,我们将使用HotelRoomConferenceRoom数据模型。首先,我们将实现ConferenceRoom实体,其独特的name属性用于区分:

@Entity
public class ConferenceRoom {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NaturalId
    private String name;

    private int capacity;

    public ConferenceRoom(String name, int capacity) {
        this.name = name;
        this.capacity = capacity;
    }

    protected ConferenceRoom() {
    }

    // getters
}

首先,我们需要在name字段上添加@NaturalId注解。请注意,该字段是不可变的:它在构造函数中声明,不提供setter方法。此外,Hibernate需要一个无参数构造函数,但我们可以将其设置为protected,避免直接使用。

现在,我们可以使用bySimpleNaturalId方法,通过名称(自然ID)轻松在数据库中搜索会议室:

@Service
public class HotelRoomsService {

    private final EntityManager entityManager;

    // constructor

    public Optional<ConferenceRoom> conferenceRoom(String name) {
        Session session = entityManager.unwrap(Session.class);
    return session.bySimpleNaturalId(ConferenceRoom.class)
          .loadOptional(name);
    }
}

运行测试并检查生成的SQL以确认预期行为。为了查看Hibernate/JPA SQL日志,我们需要添加适当的日志配置:

logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE

现在,调用conferenceRoom方法,查找自然ID为"Colorado"的会议室:

@Test
void whenWeFindBySimpleNaturalKey_thenEntityIsReturnedCorrectly() {
    conferenceRoomRepository.save(new ConferenceRoom("Colorado", 100));

    Optional<ConferenceRoom> result = service.conferenceRoom("Colorado");

    assertThat(result).isPresent()
      .hasValueSatisfying(room -> "Colorado".equals(room.getName()));
}

我们可以检查生成的SQL,期望它会根据name列查询conference_room表:

select c1_0.id,c1_0.capacity,c1_0.name 
from conference_room c1_0 
where c1_0.name=?

3. 复合自然ID

自然标识符也可以由多个字段组成。在这种情况下,可以为所有相关字段都添加@NaturalId注解。

例如,考虑GuestRoom实体,其复合键由roomNumberfloor字段组成:

@Entity
public class GuestRoom {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NaturalId
    private Integer roomNumber;

    @NaturalId
    private Integer floor;

    private String name;
    private int capacity;

    public GuestRoom(int roomNumber, int floor, String name, int capacity) {
        this.roomNumber = roomNumber;
        this.floor = floor;
        this.name = name;
        this.capacity = capacity;
    }

    protected GuestRoom() {
    }
    // getters
}

与第一个例子类似,我们将使用Hibernate的SessionbyNaturalId方法。然后,我们将使用流式API指定构成复合键的字段值

public Optional<GuestRoom> guestRoom(int roomNumber, int floor) {
    Session session = entityManager.unwrap(Session.class);
    return session.byNaturalId(GuestRoom.class)
      .using("roomNumber", roomNumber)
      .using("floor", floor)
      .loadOptional();
}

现在,尝试通过测试方法查询第三层的23号客房:

@Test
void whenWeFindByNaturalKey_thenEntityIsReturnedCorrectly() {
    guestRoomJpaRepository.save(new GuestRoom(23, 3, "B-423", 4));

    Optional<GuestRoom> result = service.guestRoom(23, 3);

    assertThat(result).isPresent()
      .hasValueSatisfying(room -> "B-423".equals(room.getName()));
}

检查SQL时,我们应该看到一个直接使用复合键的简单查询:

select g1_0.id,g1_0.capacity,g1_0.floor,g1_0.name,g1_0.room_number 
from guest_room g1_0 
where g1_0.floor=? 
and g1_0.room_number=?

4. 与Spring Data集成

Spring Data(/the-persistence-layer-with-spring-data-jpa)的JpaRepository接口默认不支持通过自然标识符查询。然而,我们可以扩展这些接口来添加额外的方法以实现此功能。首先,我们需要声明扩展后的接口:

@NoRepositoryBean
public interface NaturalIdRepository<T, ID> extends JpaRepository<T, ID> {
    Optional<T> naturalId(ID naturalId);
}

接下来,我们将创建这个接口的泛型实现,并需要将泛型类型转换为领域实体。为此,我们可以扩展JPA的SimpleJpaRepository,利用其getDomainClass方法:

public class NaturalIdRepositoryImpl<T, ID extends Serializable> extends SimpleJpaRepository<T, ID> implements NaturalIdRepository<T, ID> {
    private final EntityManager entityManager;

    public NaturalIdRepositoryImpl(JpaEntityInformation<T, ?> entityInformation, EntityManager entityManager) {
        super(entityInformation, entityManager);
        this.entityManager = entityManager;
    }

    @Override
    public Optional<T> naturalId(ID naturalId) {
        return entityManager.unwrap(Session.class)
      .bySimpleNaturalId(this.getDomainClass())
          .loadOptional(naturalId);
    }

}

此外,我们需要添加@EnableJpaRepositories注解,让Spring扫描整个包并注册我们的自定义仓库:

@Configuration
@EnableJpaRepositories(repositoryBaseClass = NaturalIdRepositoryImpl.class)
public class NaturalIdRepositoryConfig {
}

这将使我们能够扩展NaturalIdRepository接口,为具有自然ID的实体创建仓库:

@Repository
public interface ConferenceRoomRepository extends NaturalIdRepository<ConferenceRoom, String> {
}

因此,我们将能够使用增强的仓库API,利用naturalId方法进行简单查询:

@Test
void givenNaturalIdRepository_whenWeFindBySimpleNaturalKey_thenEntityIsReturnedCorrectly() {
    conferenceRoomJpaRepository.save(new ConferenceRoom("Nevada", 200));

    Optional result = conferenceRoomRepository.naturalId("Nevada");

    assertThat(result).isPresent()
      .hasValueSatisfying(room -> "Nevada".equals(room.getName()));
}

最后,检查生成的SQL语句:

select c1_0.id,c1_0.capacity,c1_0.name 
from conference_room c1_0 
where c1_0.name=?

5. 总结

在这篇文章中,我们了解了拥有自然标识符的实体,并发现Hibernate的API允许我们轻松根据这些特殊标识符进行查询。随后,我们在Spring Data JPA中创建了一个通用仓库,并扩展了它,以利用Hibernate的这一特性。

如往常一样,本文的代码示例可在GitHub找到。