Spring Boot 应用程序中最受欢迎的启动器之一是 Spring Data JPA。因此,您几乎没有机会在求职面试中回避与 JPA 相关的问题。让我们来看看最常见的问题以及详细的答案。
Spring Data JPA 是基于 Spring 的应用程序中最流行的启动器之一。如果我们查看 GitHub 统计数据,我们会看到开发人员使用Spring Data JPA。与Spring JDBC的165K相比,很明显,在许多技术面试中,开发人员都会面临有关 Spring Data JPA 和相关技术的问题。
在本文中,我们将看看最受欢迎的Spring Data JPA采访问题。此外,我们将讨论基础技术——JPA和Hibernate。首先,将有一些关于一般知识的基本问题,然后我们将回顾高级主题。
1. 你能解释一下JPA和JDBC之间的区别吗?
JDBC (Java DataBase Connectivity)——是Java应用程序的一个API,它定义了客户机如何与数据库通信。该API使用了接近于关系数据库领域的抽象:Connection、Statement、ResultSet。它是一个低级的数据库访问框架,允许开发人员执行SQL语句并以类似表格的格式——ResultSet获取结果。我们可以按照以下步骤描述它的工作流程:
- 连接数据库;
- 以结果集的形式从数据库中获取数据;
- 循环遍历结果集中的每条记录,为每条记录创建一个Java类实例,并用记录值填充其属性。
在使用JDBC处理DB时,我们应该编写大量类似的样板代码来将记录转换为Java对象。假设我们有一个DB表,其中包含关于用户的记录:他们的姓和名以及ID(唯一的标识号)。要将这些数据获取到应用程序中,我们应该这样做:
try (Connection connection = DriverManager.getConnection(URL, USER, PASSWORD)) {
Statement statement = connection.createStatement();
ResultSet resultSet = statement.execute(SELECT * FROM user);
List<User> users = new ArrayList<>();
while (resultSet.next()) {
String id = resultSet.getLong("user_id");
String firstName = resultSet.getString("first_name");
String lastName = resultSet.getString("last_name");
users.add(new User(id, firstName, lastName));
}
} catch (SQLException sqlException) {
//...
}
为了减少样板代码的数量,出现了使用从DB表到Java对象的“映射”实现此例程工作的自动化的框架。首先,有文本文件(通常是XML),指定将哪个类映射到哪个表,哪个属性映射到哪个列。然后,开发人员开始直接在Java类中使用注释来指定这些映射。对象关系映射(ORM)框架是如何出现的。
JPA是Java世界ORM框架的规范。JPA代表雅加达(以前的Java)持久性API。该API提供了比JDBC更高的抽象水平。JPA不仅允许我们与数据库交互,还可以将记录从数据库直接映射到java对象,而无需开发人员方面的任何手动操作。API的基本类是EntityManager,负责执行查询并将关系数据转换为Java对象。我们只需要创建一个类,将其映射到DB表,然后将其作为参数与查询一起传递给EntityManager。
我们的User类将如下所示:
@Entity
@Table(name = "user")
public class User {
@Id
@Column(name = "user_id", nullable = false)
private Long id;
@Column(name = "first_name")
private String firstName;
@Column(name = "last_name")
private String lastName;
//getters and setters are omitted
}
现在,我们需要编写更少的代码来完成与之前在JDBC上显示的相同的任务:
entityManager.getTransaction().begin();
TypedQuery<User> query = entityManager.createQuery("SELECT u FROM User u", User.class);
List<User> users = query.getResultList();
entityManager.getTransaction().commit();
entityManager.close();
我们还需要提到,并非每个ORM都实施JPA规范。BlazeDS或MyBatis就是这样的例子。
2. Jpa和Hibernate有什么区别?
JPA是一个规范。事实上,它是一组接口,供应商提供实际的实现。RedHat的Hibernate是目前Java世界中最受欢迎的实现。还有其他JPA实现,例如EclipseLink或Apache OpenJPA。他们每个人都必须遵守JPA规范。除此之外,其中一些通常提供规范中未描述的特定于供应商的API。
3. 命名主要的JPA对象
在JPA规范中,有以下主要对象:
实体
–映射到数据库表的类。它的字段对应于表的列。常规JPA实体的声明如下所示:
@Entity
public class User {
@Id
private UUID id;
private String firstName;
private String lastName;
//getters and setters are omitted
}
映射超类
– 为实体声明公共属性的类。我们使用该类构建一个实体层次结构来重用公共属性。与数据库中层次结构中的实体关联的每个表都将包含来自MappedSuperclass实体及其后代的所有字段。例如,对于以下类层次结构:
@MappedSuperclass
public class Person {
@Id
private UUID id;
private String firstName;
private String lastName;
//getters and setters are omitted
}
@Entity
public class User extends Person {
private String username;
private String bio;
//getters and setters are omitted
}
我们需要创建一个包含列id、firstname、lastname、username和bio的表格。
可嵌入的
– 我们可以嵌入到其他实体中的类。如果我们需要将一包字段视为一个实体,并在各种实体中重用,那么我们需要一个可嵌入类。例如,我们希望存储不同实体的地址:用户、客户端等。我们可以使用继承,但在这种情况下,组合更可取:
@Embeddable
public class Address {
public String country;
public String city;
public String street;
public String building;
//getters and setters are omitted
}
@Entity
public class User {
@Id
@Column(name = "id", nullable = false)
private UUID id;
private String firstName;
private String lastName;
@Embedded
private Address address;
//getters and setters are omitted
}
对于User实体,我们需要创建一个包含列id、firstname、lastname、country、city、street和building的表格。当我们从此表中获取User,我们可以同时将他们的地址用作单独的对象。
EntityManager
–允许我们管理JPA实体实例生命周期的设施。EntityManager API是JPA规范的一部分。我们可以通过使用它从数据库中创建、读取、更新或删除实体。EntityManager允许我们执行SQL查询来选择实体。此外,我们可以使用一种特殊的查询语言-JPQL-来获取数据。JPQL类似于SQL,但在查询中使用类和属性,而不是表和列。以下是EntityManager用于获取数据的示例:
EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("default");
EntityManager entityManager = entityManagerFactory.createEntityManager();
entityManager.getTransaction().begin();
TypedQuery<User> query = entityManager.createQuery (
"SELECT u FROM User u WHERE u.address.country='US'",
User.class
);
List<User> users = query.getResultList();
entityManager.getTransaction().commit();
entityManager.close();
Session
–扩展EntityManager的Hibernate特定接口。EntityManager定义的所有方法都可以在Hibernate会话中使用。其中许多其他方法对Hibernate是唯一的,例如isDirty()、evict()、cancelQuery()等...
然后,您可能有一个合理的问题:“在哪些情况下,使用EntityManager更好,在哪些情况下使用Session”?我将引用JBoss Hibernate团队的数据架构师Emmanuel Bernard的话。他给出了以下答案:“我们鼓励人们使用EntityManager。从团队的角度来看,我们希望我们只有一个API。我们正在尽可能多地推进标准。如果我们将来能以某种方式合并或弃用会话,我们会这样做,但人们仍然会使用一些实体管理器中没有的东西,我们不想仅仅为了好玩而破坏每个人的应用程序。”
因此,尽可能尝试使用EntityManager,当您别无选择只能使用Session时,从EntityManager中解开它:
Session session = entityManager.unwrap(Session.class);
4. 您可以指定哪些关联类型和映射?
与关系数据库模型一样,JPA中有四种关联类型:一对一、一对多、多对一和多对多。您可以将它们定义为单向或双向。这意味着您只能在一个关联的实体或两者中创建属性。
单向多对一关联“一个用户可以创建多个职位”:
@Entity
public class User {
//id, first name, last name, etc.
//No references to the Post entity. The association is unidirectional.
}
@Entity
public class Post {
//...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user; //reference to the User entity
//...
}
双向一对多/多对一关联“一个用户可以创建许多职位”:
@Entity
public class User {
//...
@OneToMany (mappedBy = "user")
private Set<Post> posts = new HashSet<>(); //reference to collection of posts created by the User
//...
}
@Entity
public class Post {
//...
@ManyToOne
@JoinColumn(name = "user_id")
private User user; //reference to the User entity
//...
}
在关系数据库中,我们只有单向关系(定义为外键)。ORM引擎通过提供双向引擎,为我们提供了更大的灵活性。对于后者,我们应该始终牢记数据库方面的局限性。因此,当我们定义双向关联时,我们应该知道它的哪一边是“拥有”的。
关联的“拥有”方负责保留参考资料。相应的表将有参考列和外键约束。必须填充“拥有”侧引用。否则,该关联将不会被存储。对于上面的示例,如果我们将Post实例添加到User.posts集合并保存用户实体,则不会保存关联。但是,如果我们分配Post.user字段,一切都会好起来的。
注意 : 要快速弄清楚哪一边是“拥有”的,只需查看参考字段上的注释即可。如果有mappedBy属性——这是关联的反比侧;否则——拥有属性。
5. 实体ID,它是什么?
根据JPA规范,Entity是一个满足以下要求的Java类:
- 用@Entity注释注释;
- 具有无args构造函数;
- 不是最终的;
- 有一个ID字段(或多个字段)。
如您所见,ID是必需的。原因很简单-ORM引擎使用ID唯一标识存储在数据库中的每个实体实例。
在99%的情况下,ID是生成的。为了避免唯一性问题,选择正确的方式生成ID很重要。我们可以使用客户端或数据库生成ID策略。反过来,它又被分为几个子战略。
在选择ID类型和生成策略时,经验法则是:对于单个DB实例和整体应用程序,在大多数情况下,使用序列生成服务器端ID是最好的选择。如果我们有微服务架构和分布式数据库,最好使用客户端生成的UUID。 在某些情况下,我们可以使用由多个属性组成的复合ID。其中一项属性通常是指另一个实体。复合密钥很常见,特别是当我们在现有数据库上构建应用程序时。
“JPA中的ID”主题至关重要且具有挑战性;这就是为什么我们可以推荐以下文章:
- [JPA实体DB生成ID的终极指南;]
- [JPA实体客户端生成ID的终极指南;]
- [JPA实体复合ID的终极指南。]
6. hbm2ddl.Auto/Ddl-auto属性代表什么?
事实上,它是一个单一的属性,但它有两个名字。第一个-hibernate.hbm2ddl.auto由“pure”Hibernate使用,而第二个-spring.jpa.hibernate.ddl-auto是第一个以上的Spring包装。此属性指示ORM如何处理数据库模式。它有五个可能的值:
create-首先,Hibernate将删除映射到实体的数据库表。然后,它将生成并执行DDL,以创建符合JPA模型的DB模式。create-drop–将执行与上一个相同的操作,但在应用程序关闭后,Hibernate将删除映射到实体的数据库表。update–在此模式下,Hibernate将仅为新表/列生成DDL脚本。即使该值称为update,它也不会创建更改或删除现有列的脚本。例如,如果您将属性标记为唯一属性或从实体代码中删除一些属性,Hibernate将忽略它。validate–在此模式下,Hibernate不会生成任何DDL语句,也不会触摸您的数据库。它只会检查您的JPA模型是否完全符合数据库。如果出现不匹配,应用程序将无法使用SchemaManagementException启动。none– 什么也不做。 事实上,关于ddl-auto属性,您只需要知道,除了在生产台上validate外,使用上述任何值都不是个好主意。对于测试,create/create-drop可能是一个不错的选择。如果您不想在测试期间每次都重新填充数据库,您可以使用update选项。
为了获得更好的开发体验,更喜欢专门的解决方案来管理数据库更改,如Liquibase或Flyway。即使是由[JPA Buddy]与spring.sql.init.mode一起手工编写或自动生成的简单.sql文件也会做得更好,因为您:
- 在执行DDL之前,对DDL拥有完全的控制权;
- 可以设置正确的Java到数据库类型映射;
- 使用属性转换器和Hibernate类型没有问题。
7. 如何使用JPA从数据库中获取数据?
使用JPA,有几种方法可以从数据库中获取数据。其中一些开箱即用,有些需要额外的库。让我们看看它们。
注意: 我们将为每种方法编写相同的代码,以更好地了解差异。作业内容为:“您有一个PostgreSQL数据库,其中有一个名为User的表。编写一个代码来选择所有电子邮件以gmail.com结尾且名为Alice的用户。按lastName排序结果。”
| COLUMN NAME | TYPE |
|---|---|
| id | bigint |
| first_name | varchar(50) |
| last_name | varchar(50) |
| varchar(255) |
JPQL
此选项开箱即用,可能是获取数据的最古老方式。JPQL是一种专门为JPA创建的类似SQL的查询语言。EntityManager负责JPQL的执行。以下是任务实施:
entityManager.getTransaction().begin();
TypedQuery<User> query = entityManager.createQuery(
"SELECT u FROM User u " +
"where u.email like '%gmail.com' and u.firstName = ?1 " +
"order by u.lastName",
User.class)
.setParameter(1, "Alice");
List<User> users = query.getResultList();
entityManager.getTransaction().commit();
entityManager.close();
优点:
- DB-不可知的查询;
- 不需要额外的库。
缺点:****
- 构建时没有查询验证;
- 与SQL相比,功能有限。
标准API
Criteria API引入了DSL(Domain Specific Language)来创建查询。它允许我们在没有SQL或JPQL的情况下编写查询,并使用Java和对实体属性的字符串引用。因此,我们可以构建查询,不是来自字符串,而是来自Java对象。当我们组合不同的谓词或逐句添加顺序到查询时,它会添加一些类型安全。尽管如此,由于实体属性名称错别字,查询在运行时可能会失败。以下是选择在gmail.com域名上注册的所有Alices的代码。
entityManager.getTransaction().begin();
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<User> cr = cb.createQuery(User.class);
Root<User> root = cr.from(User.class);
cr.select(root).where(
cb.and(
cb.like(root.get("email"), "%gmail.com"),
cb.equal(root.get("firstName"),
cb.parameter(String.class, "name"))
)
);
cr.orderBy(cb.asc(root.get("lastName")));
TypedQuery<User> query = entityManager.createQuery(cr);
query.setParameter("name", "Alice");
List<User> results = query.getResultList();
entityManager.getTransaction().commit();
entityManager.close();
优点:
- DB-不可知的查询;
- 不需要额外的图书馆;
- 类型安全:我们可以将查询部分作为参数传递,连接它们等——打错别字的机会更少。
缺点:
- 构建时没有字段名称验证;
- 与SQL和JPQL相比,该代码很难读取。
查询DSL
QueryDSL是一个外部库,使用注释处理来生成其他类(称为Q)类型。这些是JPA实体的副本,但引入了一个API,允许我们创建数据库查询。例如,如果您拥有具有firstName和lastName字符串属性的User实体,那么生成的Q类型将称为QUser,并将具有相同名称但类型不同的属性-StringPath。每个属性都有特定的方法来编写查询。例如,like())、eq()等。这些方法构成了我们可以使用的DSL语言。让我们来看看代码示例:
entityManager.getTransaction().begin();
List<User> users = new JPAQuery<User>(entityManager)
.select(QUser.user)
.from(QUser.user)
.where(QUser.user.email.endsWith("gmail.com")
.and(QUser.user.firstName.eq("Alice")))
.orderBy(QUser.user.lastName.asc())
.fetch();
entityManager.getTransaction().commit();
entityManager.close();
优点:
- DB-不可知的查询;
- 类型安全;
- 编译时查询验证。
缺点:
- 需要额外的依赖性;
- 需要重新生成有关数据模型更改的类。
Spring Data JPA @Query/Derived方法
Spring Data JPA是Spring Framework的一部分,该框架允许我们使用精心设计的存储库API使用派生方法名称或JPQL或SQL查询执行数据库查询。
派生方法是以特殊方式命名的存储库接口方法。它们对于简单的查询非常方便;有时,它们甚至可能比JPQL查询更强大。例如,无法使用JPQL从数据库中获取顶级N条记录,尽管使用派生方法,您可以轻松完成此任务。
然而,在许多条件下,对于海量查询,派生方法很难读取和维护。让我们看看可以执行我们之前实现的查询的接口:
public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByFirstNameAndEmailEndsWithOrderByLastNameAsc(String firstName, String email);
}
正如我们所看到的,方法名称几乎不可读。在这种情况下,我们可以使用纯JPQL甚至原生SQL。Spring Data框架将在替换参数、打开事务等方面完成所有繁重的工作:
public interface UserRepository extends JpaRepository<User, Long> {
@Query("select u from User u " +
"where u.firstName = ?1 and u.email like concat('%', ?2) " +
"order by u.lastName")
List<User> findByNameAndEmail(String firstName, String email);
}
额外好处-方法名称和JPQL查询在应用程序启动时进行验证。它不是一个编译时检查,但比运行时故障要好得多。
优点:
- 无需学习SQL和JPQL进行简单的查询;
- 启动时间查询验证;
- 代码易于阅读和使用;
- 添加一些额外的功能,如[映射]。
缺点:
- 要求Spring Data作为依赖项;
- 对于复杂的查询,方法名称可能很麻烦;
- 启动时不会检查原生SQL查询。
原生查询
JPQL不支持某些SQL子句,因此在所有其他基于JPA的DSL库中都不支持。对于这些情况,我们可以在JPA中使用本机SQL。它给了我们一些灵活性:我们只能在需要时使用精心优化的查询,并使用JPA来避免在其他情况下编写大量简单的SQL查询。
以下是如何在JPA中执行SQL的示例:
entityManager.getTransaction().begin();
Query users = (Query) entityManager.createNativeQuery(
"""
SELECT u.id, u.first_name, u.last_name, u.email
FROM "user" u WHERE u.email like(CONCAT('%', :email)) and u.first_name= :firstName
ORDER BY u.last_name
""", User.class)
.setParameter("email", "gmail.com")
.setParameter("firstName", "Alisa");
List<User> authors = (List<User>) users.getResultList();
entityManager.getTransaction().commit();
entityManager.close();
以下是它在Spring Data JPA中的工作原理:
@Query(value = """
SELECT u.id, u.first_name, u.last_name, u.email
FROM "user" u WHERE u.email like(CONCAT('%', :email)) and u.first_name= :firstName
ORDER BY u.last_name
""", nativeQuery = true)
List<User> findByNameAndEmailNative(String firstName, String email);
优点:
- 没有限制-我们使用纯SQL;
- 代码易于阅读和使用;
- 调试查询更简单。
缺点:
- 查询正确性仅在执行时进行检查;
- 很难在模式更改时重构代码;
- 手动类型转换-本机查询无法正确参数化。
8. JPA中的实体状态是什么?
JPA指定了四种不同的对象状态:
- 瞬态-所有新实体从未与打开的数据库会话(持久上下文)相关联,数据库中也没有相应的行;
- 托管-从数据库中获取并与持久上下文关联的所有实体。ORM跟踪此状态下对实体的所有更改。当我们在没有事务回滚的情况下关闭会话时,ORM会自动向数据库提交所有更改;
- 分离-当数据库会话关闭时,实体传输到此状态。ORM不再跟踪对它们所做的任何更改。为了保存实体,我们需要通过将它们“附加”到会话中来重新管理它们。最简单的方法是调用EntityManager的
merge()方法。一些框架(如Spring Data JPA)可以自动完成。 - 已删除-在我们调用
remove()方法后,实体进入此状态。只有在我们刷新会话更改或关闭后,它们才会从数据库中删除。
9. 为什么我们需要Equals()/Hashcode(),以及如何为JPA实体正确实现它们?
默认情况下,在Java中,如果对象实例在内存中的地址相等,则对象实例是相等的。对于JPA来说,这种方法效果不佳。如果我们在不同的事务中获取相同的实体实例,它们在内存中的地址将不同。它可能会导致缓存问题、Set集合问题等。这就是为什么正确定义JPA实体的equals()方法至关重要。
同样的推理也适用于hashCode()方法的实现。JDK集合框架和Hibernate特定的集合大量使用此方法,因此不正确的实现可能会影响应用程序性能,导致实体在集合中“丢失”,或者如果我们使用惰性属性加载,甚至会导致运行时异常。[JPA]中[关于Lombok文章中]有一些此类行为的例子。
自然,实体是可变的。数据库通常甚至生成实体的ID,因此在实体首次持久化后也会更改。这意味着我们没有可以依赖的字段来计算哈希代码值或在实体平等计算中使用的。让我们看看如何做到这一点。
通过实施这些方法,我们应该:
- 保持体面的性能;
- 考虑JPA世界的细节;
- 保存hashCode()和equals()之间的合同。
如果我们使用Hibernate ORM,以下实现似乎最合适:
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o))
return false;
User user = (User) o;
return id != null && Objects.equals(id, user.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
首先,hashCode()实现总是返回相同的值。尽管这种方法远非最佳实践,但这一选择在JPA世界是可以接受和可取的。原因很简单:当实体从一个状态移动到另一个状态时,所有字段,甚至id,都可能会发生变化。因此,如果我们不想因为字段值变化而失去集合中的实体,最好有一个不可变的值。
这种方法听起来可能很糟糕,因为它违背了在HashSet或HashMap中使用多个存储桶的目的。但是,出于性能原因,您应该始终限制存储在集合中的实体数量。如果您从未将数千个实体带入集合,那就最好了,因为数据库端的性能惩罚对应用程序的影响远远大于使用单个散列块。 因此,在equals()方法中比较哈希代码毫无意义。在以下情况下,我们情况下的对象将不相等:
- 对象或其
id为空; - 对象的ID不匹配;
- 对象属于不同的类。
如果对象具有相同的引用,我们假设它们是相等的。此外,当对象ID匹配且上面的所有检查通过时,我们认为这两个对象也是相等的。
10. @Transactional 注释是做什么的?配置它的方法是什么?
数据库事务是被视为单个工作单元的一系列操作。换句话说,要么所有行动都将执行,要么不执行。Spring为管理交易提供了强大的机制。只需用@Transactional注释方法,我们在方法执行开始时自动打开数据库事务,在完成后自动关闭事务。
问题是:如果我们从另一种方法调用事务方法,会发生什么?我们应该创建新交易还是重复使用?如果我们打开新事务,它应该能够读取调用方法中更改的数据吗?
事实上,我们可以管理事务方法行为;让我们看看如何做到这一点。
交易传播
事务传播允许您指定事务上下文已经存在时执行事务方法的行为。例如,您可以将其配置为在现有事务中创建嵌套事务;或者暂停活动事务并创建新事务。以下是所有可用的选项:
REQUIRED– 默认值。Spring使用活动事务来执行业务逻辑。如果没有事务,它会创建一个新的交易。NESTED–如果没有活跃的事务,它的工作原理就像REQUIRED。当存在活动事务时,Spring会记住调用方法的点,并创建一个新事务。如果执行错误,事务将回滚到保存的点。REQUIRES_NEW– Spring暂停现有事务(如果存在,然后创建一个新事务。MANDATORY–如果有活跃的交易,Spring会使用它。否则,它会抛出TransactionRequiredException。SUPPORTS–如果有活跃的交易,Spring会使用它。否则,代码将以非事务形式执行。NOT_SUPPORTED–如果有活跃的交易,Spring将暂停它。接下来,代码将以非事务形式执行。NEVER–如果有活跃的事务,Spring会抛出TransactionalException。否则,代码将以非事务形式执行。
交易隔离
事务隔离是[ACID]的四个属性之一,以及原子性、一致性和耐用性。
有四个隔离级别:
- 读未提交数据(Read uncommitted)
- 读已提交数据(Read committed)
- 可重复读(Repeatable read)
- 串行化(Serializable)
它们都决定了用户如何同时与数据交互,以及他们可能会产生哪些并发效应。
Spring完全支持所有这些选项,并提供另一个选项——DEFAULT。在此模式下,Spring将使用数据库的默认隔离级别。
因此,对于每种事务方法,我们可以指定一个隔离级别,从而管理事务之间的更改可见性。
@Transactional(readOnly = true)
如果您只计划从数据库获取数据而不更改数据,请考虑将readOnly属性设置为true。
@Transactional(readOnly = true)
User findById(Integer id);
通过明确告诉Spring您只需要读取数据,您可以赢得一些性能积分。此外,根据您的数据库,Spring可以省略表锁,甚至拒绝您可能意外触发的写入操作。
@Modifying 注释
@Modifying注释旨在与@Query注释一起使用。当我们一起使用这两个注释时,我们不仅可以执行SELECT语句,还可以执行INSERT、UPDATE和DELETE语句。
@Transactional
@Modifying
@Query("update User u set u.isActive = true where u.email is not null")
int updateActivityStatus();
DML(数据修改语言)查询的@Modifying注释将在运行时导致InvalidDataAccessApiUsage异常。为了防止这种错误,请使用[JPA Buddy]。它有助于通过明智的检查提前抓住这些问题。
11. 你偶然发现过Lazyinitexception吗?如果是这样,你是怎么摆脱它的?
LazyInitializationException可能是JPA世界最常见的异常之一。当您引用未从数据库加载的关联并且实体分离时,Hibernate会抛出它。以下是一个简单的例子:
entityManager.getTransaction().begin();
TypedQuery<User> query = entityManager.createQuery(
"SELECT u FROM User u",
User.class);
List<User> users = query.getResultList();
entityManager.getTransaction().commit();
entityManager.close();
for (User user : users) {
int size = user.getProfiles().size(); //Here we are stumbling upon LazyInitializationException.
}
当实体分离时,Hibernate无法从数据库加载所需的字段。如果我们查看错误消息,我们会看到这样的东西。
Exception in thread "main" org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: entities.User.profiles: could not initialize proxy - no Session ...
对于惰性加载的属性值,Hibernate创建代理类,这些代理类使用现有会话在第一次读取尝试时加载实际值。因此,如果会话关闭,则加载失败。
您可能认为修复它最直接的方法——在事务上下文中执行所有代码。事实上,确实如此。但在这种情况下,您将摆脱LazyInitException,但会遇到N+1问题。因此,修复它的正确方法-使用join子句在一个查询中获取所有必需的数据。例如:
entityManager.getTransaction().begin();
TypedQuery<User> query = entityManager.createQuery(
"SELECT u FROM User u join fetch u.profiles",
User.class);
List<User> users = query.getResultList();
entityManager.getTransaction().commit();
entityManager.close();
for (User user : users) {
int size = user.getProfiles().size();
log.info(String.format("User with id=%d has %d profiles", user.getId(), size));
}
Hibernate:
select
u1_0.id,
u1_0.email,
u1_0.first_name,
u1_0.last_activity,
u1_0.last_name,
p1_0.user_id,
p1_0.id,
p1_0.created_at,
p1_0.name
from
"user" u1_0
join
profile p1_0
on u1_0.id=p1_0.user_id
此外,一些更高级的选项可以同时加载所需的数据并避免此异常,例如EntityGraph、DTO和映射。
我们可以定义实体图,并指定我们希望在注释@NamedGraph或使用Java API中获取的关联。之后,我们可以将图表与查询一起传递,因此JPA框架生成适当的查询。
Spring Data JPA使用DTO和映射。这些是包含实体字段子集的类或接口定义。Spring将根据类定义修改查询,并仅获取指定的字段。
12. 什么是N+1查询问题,我们如何解决它?
当我们获取实体列表并在列表中每个实体的其他单独查询中选择关联实体时,就会出现N+1问题。例如,我们有与User多对一相对应的Profile实体:
@Entity
public class Profile {
//...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
//...
现在,让我们尝试执行以下代码:
entityManager.getTransaction().begin();
TypedQuery<Profile> query = entityManager.createQuery(
"SELECT p FROM Profile p",
Profile.class);
List<Profile> profiles = query.getResultList();
for (Profile profile : profiles) {
Long id = profile.getId();
String userEmail = profile.getUser().getEmail(); //Here we will fetch info about email as many times as many users we have in the database
log.info(String.format("Profile with id=%d belongs to user with email: %s", id, userEmail));
}
entityManager.getTransaction().commit();
entityManager.close();
Hibernate执行1个查询来获取配置文件
select
p1_0.id,
p1_0.created_at,
p1_0.name,
p1_0.user_id
from
profile p1_0
以及为每个关联用户获取电子邮件的N个其他查询:
select
u1_0.id,
u1_0.email,
u1_0.first_name,
u1_0.last_activity,
u1_0.last_name
from
"user" u1_0
where
u1_0.id=?
Profile - User关联是懒惰的,因此我们仅在请求时加载用户信息。由于我们收集了配置文件,我们执行了一个查询来加载User数据N次。
在这一点上,您可能会认为这个问题很容易解决:只需将FetchType.Lazy更改为FetchType.Eager。但这样,你只会让情况变得更糟。
让我们测试一下。*-To-One关联默认为Eager类型,因此让我们简单地删除FetchType.Lazy参数:
@Entity
public class Profile {
//...
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
//...
现在,让我们从数据库中选择所有配置文件:
entityManager.getTransaction().begin();
TypedQuery<Profile> query = entityManager.createQuery(
"SELECT p FROM Profile p",
Profile.class);
query.getResultList();
entityManager.getTransaction().commit();
entityManager.close();
我们将获得Hibernate执行的相同查询列表!
select
p1_0.id,
p1_0.created_at,
p1_0.name,
p1_0.user_id
from
profile p1_0
-- N times
select
u1_0.id,
u1_0.email,
u1_0.first_name,
u1_0.last_activity,
u1_0.last_name
from
"user" u1_0
where
u1_0.id=?
如您所见,这次,我们甚至没有解决任何字段,并且已经遇到了N+1问题。
为了避免这个问题,我们应该分析我们需要哪些数据,并尝试使用单个查询获取数据。在下面的示例中,我们使用JPQL进行操作,但我们也可以使用实体图、DTO和映射。
entityManager.getTransaction().begin();
TypedQuery<Profile> query = entityManager.createQuery(
"SELECT p FROM Profile p join fetch p.user",
Profile.class);
List<Profile> profiles = query.getResultList();
for (Profile profile : profiles) {
Long id = profile.getId();
String userEmail = profile.getUser().getEmail();
log.info(String.format("Profile with id=%d belongs to user with email: %s", id, userEmail));
}
entityManager.getTransaction().commit();
entityManager.close();
select
p1_0.id,
p1_0.created_at,
p1_0.name,
u1_0.id,
u1_0.email,
u1_0.first_name,
u1_0.last_activity,
u1_0.last_name
from
profile p1_0
join
"user" u1_0
on u1_0.id=p1_0.user_id
13. 除了JPQL,从数据库中选择关联实体还有其他方法是什么?(实体图、Dto、映射)
为了避免N+1问题和LazyInitException,我们可以在JPQL中使用join语句。但还有其他选项可以指定我们要选择的属性和关联。它们是:
- 实体图-在JPA标准中指定;
- DTO和映射-由Spring Data JPA库使用。
- 让我们详细看看他们。
实体图
JPA 2.1中引入了实体图特征。长期以来,它一直是最令人期待的功能之一。实体图为我们提供了对需要获取的数据的另一层控制。在Spring Data JPA中,我们可以在@Query声明的正上方指定所需的数据。下面的示例展示了如何获取User实体及其profiles属性:
@EntityGraph(attributePaths = {"profiles"})
List<User> findUserByEmailAndName(String email, String firstName);
此外,我们可以为实体指定命名实体图,并在不同地方重用。例如,我们可以将图表与实体管理器一起使用:
@NamedEntityGraph (name = "user-with-profiles",
attributeNodes = {@NamedAttributeNode("profiles")}
)
@Entity
public class User {
//...
}
EntityGraph entityGraph = entityManager.getEntityGraph("user-with-profiles");
Map<String, Object> properties = new HashMap<>();
properties.put("javax.persistence.fetchgraph", entityGraph);
Post post = entityManager.find(User.class, id, properties);
在Spring Data JPA中,它看起来是这样的:
@EntityGraph(value = "user-with-profiles")
List<User> findUserByEmailAndName(String email, String firstName);
DTO和映射
DTO(数据传输对象)是在进程之间传输数据的对象。JPA实体的DTO通常包含实体属性的子集。Spring Data JPA允许我们将DTO指定为存储库中的方法返回类型。在下面,Spring Data优化查询,仅获取DTO中定义的属性。我们可以将DTO指定为类:
public class UserDto implements Serializable {
private final String firstName;
private final String email;
public UserDto(String firstName, String email) {
this.firstName = firstName;
this.email = email;
}
//getters and setters are omitted
}
...或作为界面。Spring Data JPA从接口的方法名称中派生属性名称。因此,对于以下定义,假设该实体具有属性firstName和lastName。
public interface UserDto {
String getFirstName();
String getLastName();
}
DTO的使用与存储库方法没有任何不同:
List<UserDto> findUserByEmailAndName(String email, String firstName);
因此,我们提高了查询效率,因为只从数据库中检索特定字段。对于上面的存储库定义,查询将是:
select u1_0.first_name, u1_0.last_name from "user" u1_0
14. Hibernate的L1和L2缓存是什么?
建立与数据库的连接并获得必要的数据通常是最耗时的过程。如果可以保存之前选择的数据,并在收到同一请求时返回,您可以显著提高应用程序的性能。这正是L1和L2缓存的作用。
让我们从L1缓存开始,也称为持久性上下文。它是一个会话级内存缓存。L1缓存是Hibernate的组成部分,并在会话期间缓存实体。L1缓存中的所有实体都处于附加状态。由于无法禁用L1,因此不建议长时间打开会话,以避免大量内存消耗并保持数据最新状态。
让我们假设我们有一个博客应用程序。一个用户可以写许多帖子和许多评论。每个帖子可以有很多标签,一个标签可以标记许多帖子。如果我们打开交易并获取评论列表,Hibernate将与关联用户一起将评论数据存储在L1缓存中。原因是Hibernate默认急切地加载所有*对一关联。然后,如果我们开始为用户获取帖子,然后为帖子添加标签,Hibernate将继续将实体加载到L1中。因此,如果我们不关闭事务,我们可以通过浏览实体图“只是”意外地将整个数据库加载到内存中。
对于同一个博客应用程序,如果我们保持长时间的交易,我们可能会遇到另一个问题。让我们假设我们有一个带有“Hibernate的JPA”的帖子,ID = 1,并带有“Java”标签。我们的应用程序中有以下代码(在一次事务中执行):
List<Post> posts = postRepository.findByTags_Name("Java");
Post post = posts.stream().filter(p -> p.getId() == 1).findFirst().orElseThrow();
//Someone updated post text in another transaction
Post postById = postRepository.findById(1L).orElseThrow();
问题是,对于最后一个语句,Hibernate甚至不会执行SQL,它将从L1缓存中获取数据。因此,在我们关闭事务并再次执行查询之前,我们不会看到更改。 相比之下,L2缓存没有连接到Hibernate会话,可以在会话之间共享存储的数据。默认情况下,L2缓存被禁用。因此,当我们只有L1缓存,并且两个用户从数据库请求相同的数据时,Hibernate将执行两个相同的查询。启用L2缓存后,将只执行一个数据库调用。Hibernate在L1缓存中查找数据,然后在L2缓存中查找数据,然后才执行数据库查询。让我们[启用L2缓存],看看以下非事务性测试方法:
@Test
void testL2CacheQueries() { //The method is not under @Transaction
Post postById = postRepository.findById(1L).orElseThrow();
postById.setTitle("New title");
postRepository.save(postById);
Post postById2 = postRepository.findById(1L).orElseThrow();
assertEquals(postById.getTitle(), postById2.getTitle());
}
您会看到Hibernate只执行两个查询:
select --column list
from post post0_
left outer join user_1 user1_ on post0_.user_id=user1_.id
where post0_.id=? ;
update post
set title=?, user_id=?
where id=?;
该测试将通过,因为L2缓存被设计为非常一致。因此,它可以帮助我们提高应用程序性能,即使是读写事务。唯一的缺点是,如果我们使用原生SQL查询,整个缓存可能会失效。为了防止这种情况,我们需要[明确定义我们更新的实体]。
15. 为什么以及如何测试 Spring Data Repositories?
存储库是在域和数据映射层之间进行中介的对象。因此,存储库测试意味着它应该:
- 生成适当的查询,选择我们需要的数据;
- 生成查询的确切数量。
在孤立的上下文中使用模拟而不是数据库,将Spring Data Repositories测试为单独的单元没有多大意义。存储库的主要目的是对真实数据库执行查询,因此我们谈论集成测试。
对于这种测试,我们需要设置一个带有预定义数据集的数据库,然后测试存储库的工作原理。应用程序测试应该使用固定数据,因此理论上我们需要为每个测试套件重新创建数据库,以避免测试干扰。
为了简化数据库设置,建议使用[测试容器库]。这个库的优点是,如果它可以在容器中运行,它允许我们运行我们在生产中使用的同一供应商的数据库。使用testcontainers,我们可以轻松地为每个测试、测试套装或所有测试运行数据库实例,无论我们喜欢哪种方式。
除了数据库实例外,我们需要创建数据库模式并填充测试数据。
有几种方法可以创建数据库模式:
- 让Hibernate自动生成和执行DDL;
- 使用DB版本控制脚本(Flyway或Liquibase);
- 创建
schema.sql并启动Spring boot 运行它;
第一个选项可以作为起点,但它非常有限。例如,Hibernate不接受属性转换器和类型映射。此外,我们无法控制生成的DDL。
第二种选择效果很好,但会带来太多的开销。我们需要一遍又一遍地经历每个测试的所有DB进化。除此之外,我们可以非常安全地使用它,但测试执行可能需要很多时间。
第三种选择似乎是单元测试的首选解决方案。我们有一个脚本表示最终数据库状态,并考虑了所有特定于应用程序的类型映射、转换器、索引等。唯一的缺点是,我们应该在每次更新数据模型时更新它。
要用测试数据填充数据库,有三个主要选项:
- 在测试代码中插入数据;
- 使用
@Sql注释进行测试; - 使用
data.sql文件,并使用Spring Boot设置运行它。
如果您有一个非常小的测试数据集,每个表都有几张表和几条记录,第一个选项是好的。当数据库增长时,我们需要更新大量的代码:复制公共数据,跟踪引用等。
@Sql注释非常适合运行SQL脚本,用特定于测试的数据填充数据库。对于每个测试,我们都可以创建小型可维护脚本,并且我们可以在测试方法的正上方看到它们的名字。那很方便。
至于data.sql文件,我们可以将其与schema.sql一起使用。它可用于在数据库中填充通用参考数据,如国家、城市等。
因此,对于Spring Data JPA存储库测试,完美的解决方案如下所示:
- 使用测试容器和与生产中运行相同的数据库;
- 使用
schema.sql文件初始化测试数据库模式; - 使用
data.sql文件初始化通用参考数据;
对于单个测试,请使用@Sql注释为这个精确测试创建数据。
16。什么是Lombok Library?它对Jpa有什么用,它如何使用?
Lombok是一个旨在用注释和字节码仪器取代样板代码编写的库。例如,我们不需要编写获取器和设置器,我们只需要用@Getter和@Setter注释类,这些方法将在运行时自动生成。
它看起来像一个清理JPA实体代码的理想库,但要正确使用lombok,我们需要深入了解到底生成哪些方法,以及内部是什么。经典示例:生成equals()和hashCode()方法可能会导致LazyInitException。默认情况下,他们使用所有字段,包括关联。反过来,关联可能会被懒惰地初始化。当我们将分离的实体添加到集合中时,集合类在引擎盖下调用equals()方法......因此,我们得到了异常。对于附加实体,我们会收到一个额外的查询,该查询加载关联实体。无论如何,这绝对是我们对方法调用的期望。为了避免这种情况,我们应该通过@ToString.Exclude注释对应字段来明确排除这些方法中的关联。
在[JPA Buddy博客]中使用Lombok时,有更多陷阱的例子。
17。什么对“*-To-many”关联更好:List<>还是Set<>?
在JPA中,效率不在于实体使用的数据结构,而在于查询。List和Set都可能导致N+1问题,可以通过在Spring Data JPA中使用JPQL、实体图或映射来解决。与List不同,Set不能包含重复的元素。这意味着我们需要为子实体定义equals()和hashCode()方法,无论如何,这都是良好做法。
List在JPA世界中更常见,但我们需要记住一件事。让我们看看以下实体和相关存储库,它们选择一个拥有所有宠物的所有者:
@Entity
public class Owner {
//id, name
@OneToMany(mappedBy = "owner", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Dog> dogs = new ArrayList<>();
@OneToMany(mappedBy = "owner", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Cat> cats = new ArrayList<>();
//setters, getters
}
public interface OwnerRepository extends JpaRepository<Owner, Long> {
@EntityGraph(attributePaths = {"dogs", "cats"})
List<Owner> findByNameLikeIgnoreCase(String name);
}
如果我们使用两个与List集合的一对多关联,并尝试急切地获取两者,我们将在应用程序启动时获得MultipleBagFetchException。Hibernate抛出此异常的原因是,重复可以通过两个关联发生。该List由Hibernate中的PersistentBag实现,后者无法处理重复。
在这种情况下,将List替换为Set可能会有所帮助,但有一个很大的缺点——我们将在查询中得到笛卡尔积产品。它可以极大地影响查询性能。我们将获得以下SQL:
select
...
from
owner owner0_
left outer join
dog dogs1_
on owner0_.id=dogs1_.owner_id
left outer join
cat cats2_
on owner0_.id=cats2_.owner_id
where
upper(owner0_.name) like upper(?) escape ?
为了永远解决这个问题,我们需要按照[本文]的建议,将数据获取分为两个查询,并在一次交易中分别用两个集合两次获取所有者。L1缓存将帮助我们将第二个集合合并到现有的所有者实体中。
public interface OwnerRepository extends JpaRepository<Owner, Long> {
@EntityGraph(attributePaths = {"cats"})
List<Owner> findWithCatsByNameLikeIgnoreCase(String name);
@EntityGraph(attributePaths = {"dogs"})
List<Owner> findWithDogsByNameLikeIgnoreCase(String name);
@Transactional
default List<Owner> findByNameLikeIgnoreCase (String name) {
List<Owner> ownersWithCats = findWithCatsByNameLikeIgnoreCase(name);
List<Owner> ownersWithDogs = findWithDogsByNameLikeIgnoreCase(name);
return ownersWithDogs; //or cats, they are the same in L1 cache
}
}
18。在Spring Apps中使用“在视图中打开Session”模式的利弊是什么?
Spring Boot应用程序中的spring.jpa.open-in-view设置将JPA实体管理器绑定到当前的Web请求。这意味着在我们处理请求时,应用程序使数据库会话保持打开状态。在Spring Boot 2.x中,默认情况下启用了它。这种行为有其利弊。
优点:
- 当我们为REST API创建非常复杂的JSON或为MVC页面创建棘手的模型时,我们不会获得
LazyInitException; - 就这样。
缺点:
- 长时间的数据库会话会影响其吞吐量,从而影响应用程序性能;
- 我们不控制其他查询,因此我们可以得到N+1查询问题;
- 所有其他查询都会在
auto-commit模式下打开事务,每个语句都会刷新事务日志,因此我们再次影响DB性能。
所有这些都意味着禁用此模式将是一个好的做法。Spring Boot开发人员通过在应用程序启动时显示以下消息来隐式警告我们问题:spring.jpa.open-in-view is enabled by default。因此,可以在视图渲染期间执行数据库查询。显式配置spring.jpa.open-in-view以禁用此警告。