作为一个Java开发者,你可以选择各种框架将你的数据存储在关系型数据库中。如果你正在寻找一个遵循DDD的对象-关系映射框架,而且不是很复杂,但仍能为你处理大部分标准的CRUD操作,你应该试试Spring Data JDBC。
Spring Data JDBC的主要开发者Jens Schauder在他最近的Persistence Hub专家会议上,将其描述为一个没有JPA复杂性的对象-关系映射框架。JPA是Jakarta Persistence API的缩写,其实现Hibernate是Java生态系统中最流行的持久性框架。你可以在博客上找到很多关于它们的文章。它们提供了懒加载、自动脏检查、多个缓存层和许多其他高度复杂的功能,可以帮助你建立高度可扩展的持久层。但这些功能也需要对JPA和Hibernate有很好的理解,而且往往是产生错误和性能问题的原因。
Spring Data JDBC的目标是避免大部分这种复杂性,从而使其更容易理解。其他框架如果检测到一个新的或变化的实体,会自动执行SQL语句。他们也可能执行语句来从数据库中获取信息。Spring Data JDBC不做这些事。
如果你想从数据库中读取一个实体,坚持一个新的实体,或更新一个现有的实体,你需要调用Spring Data JDBC的一个存储库方法。然后它就会生成所需的SQL语句并执行它。这可能需要在你的业务代码中增加一行代码,但它使你能控制所有执行的语句。
你不再需要怀疑你的持久化层是否或何时与数据库进行交互。Spring Data JDBC只在你调用存储库方法时执行SQL语句。它让你完全控制你的数据库交互,同时,让你专注于你的业务逻辑。
内容
- 1Spring Data JDBC提供了什么
- 2Spring Data JDBC不提供的东西
- 3将Spring Data JDBC添加到你的项目中
- 4定义你的第一个具有多个实体的聚合
- 5创建一个资源库
- 6持久化和查询聚合
- 7结论
Spring Data JDBC提供了什么
尽管Spring Data JDBC试图避免你可能从其他对象关系映射(ORM)框架中了解的复杂性,但它仍然是一个ORM框架。它在你的Java类和关系数据库中的表之间提供了一个映射。正如你在本文后面所看到的,这种映射是基于几个默认值的,所以你通常只需要提供1个注解来定义你的实体类和它与底层数据库表的映射。但是,如果你的默认映射不适合你的表模型,你当然可以提供额外的映射信息。
Spring Data JDBC专注于聚合体和实体的概念,因为它们是在领域驱动设计(DDD)中定义的。聚合体是一个实体集群,它被视为一个单一的单元。聚合的所有实体都依赖于聚合的根。基于这些概念,你可以建立从聚合根到同一聚合中其他实体的单向关联。而且你可以定义对其他聚合的引用,你可以通过存储库来解决这些引用。
像其他Spring Data模块一样,Spring Data JDBC提供了存储库,你可以用它来加载和持久化聚合体。它们提供了标准的方法来通过主键获取聚合,坚持新的聚合,以及更新或删除现有的聚合。你还可以使用Spring Data流行的派生查询功能,让Spring Data JDBC根据存储库方法的名称生成一个查询。
Spring Data JDBC不提供的东西
与JPA相比,Spring Data JDBC并不管理你的实体对象,也不使用持久化上下文或一级缓存。因此,它不能执行任何自动脏检查,也不能延迟SQL语句的执行。与JPA相比,这可能听起来是一个限制,但它也使你的持久层和它的数据库交互更容易理解。
每当你想持久化一个新的,或改变或删除一个现有的实体或聚合,你需要调用存储库上的相应方法。然后Spring Data JDBC立即执行所需的SQL语句并返回结果。
当你从数据库加载聚合时,Spring Data JBC会执行一条SQL语句,将结果映射到定义的投影上,并返回它。它不会从任何缓存中获取部分或整个结果,也不会保留对返回对象的任何引用。这减少了开销,并避免了JPA中常见的陷阱,即你执行了一个查询,但却从一级缓存中得到了结果,而没有看到由数据库触发器或本地查询执行的最新变化。
关联实体的懒惰加载是其他ORM框架(例如Spring Data JPA)提供的另一个功能。Spring Data JDBC不支持这一点。当你从数据库中获取一个聚合时,它会获取整个聚合和所有相关实体。这使得你必须熟悉DDD中定义的聚合体和实体的概念。如果你建模正确,你的聚合体就会相对较小且简洁,你应该能够在不引起性能问题的情况下获取它。
如前所述,Spring Data JDBC使用引用来模拟聚合体之间的关联。对另一个聚合的引用和对同一聚合中的实体的建模关联之间的主要区别是,引用不会被自动获取。一个引用代表了存储在数据库中的外键。如果你想加载被引用的聚合,你可以用引用调用Spring Data JDBC的存储库方法之一。然后,Spring Data JDBC执行一个SQL语句,从数据库中获取被引用的聚合及其所有实体。
好了,理论够了。让我们来看看一个简单的例子,它定义了一个简单的聚合和存储库。这个例子只是让你快速了解一下使用Spring Data JDBC的情况。我将在以后的文章中更详细地讨论每个部分。
将Spring Data JDBC添加到你的项目中
如果你使用Spring Boot,需要2个步骤来将Spring Data JDBC添加到你的项目中。首先,你需要在项目的依赖项中添加spring-boot-starter-data-jdbc的依赖项和数据库的JDBC驱动:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
在下一步,你需要在application.properties文件中配置你的数据库连接:
spring.datasource.url=jdbc:postgresql://localhost:5432/spring-data-jdbc
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.datasource.driver-class-name=org.postgresql.Driver
完成这些后,你就可以开始使用Spring Data JDBC了。典型的第一步是为你的聚合体和实体类建模。
定义你的第一个具有多个实体的聚合
聚合体是一组实体。而Spring Data JDBC中的实体是简单的POJO,只需要一个带有*@Id*注解的属性就可以被识别为一个实体类。这使得它们的定义很容易。
下面的2段代码显示了ChessGame聚合的定义,它由ChessGame 实体类作为聚合根和ChessMove 实体类组成:
public class ChessGame {
@Id
private Long id;
private String playerWhite;
private String playerBlack;
private List<ChessMove> moves = new ArrayList<>();
// getter and setter methods
}
public class ChessMove {
private Integer moveNumber;
private MoveColor color;
private String move;
// getter and setter methods
}
正如你在代码片断中看到的,我只用*@Id注解来注解ChessGame类的id*属性。然后,Spring Data JDBC希望主键值由数据库管理,例如,通过一个自动递增的列,并在响应SQL INSERT语句时返回。我依靠Spring Data JDBC的默认映射来处理所有其他属性。
这也包括从ChessGame 到ChessMove 实体的一对多关联的映射。与JPA相比,关联映射在Spring Data JDBC中不需要额外的映射注释。这是因为它不支持任何双向的关联和多对多的关联。映射的关联总是从聚合根到依赖的子实体,这些关联可以是一对一的,也可以是一对多的。
多对多的关联总是2个聚合体之间的关联,并通过引用被映射。我将在以后的文章中更详细地解释这个问题。
接下来让我们为ChessGame 聚合创建一个资源库。
创建一个资源库
就像所有其他Spring Data模块一样,你应该为每个聚合定义一个存储库,而不是为每个实体类定义。这种资源库的定义也与其他Spring Data模块一致。你创建一个扩展了Spring Data标准存储库接口之一的接口,并提供实体类和其主键的类型作为类型信息。在这个例子中,我的ChessGameRepository扩展了Spring Data的CrudRepository:
public interface ChessGameRepository extends CrudRepository<ChessGame, Long> {
List<ChessGame> findByPlayerBlack(String playerBlack);
}
CrudRepository定义了一组标准的方法,用于坚持新的、更新或删除现有的聚合,计算或获取所有的聚合,以及通过主键获取一个聚合。
在前面的例子中,我添加了findByPlayerBlack方法。这是一个派生查询方法。像Spring Data JPA一样,Spring Data JDBC会根据方法名称生成一个查询。在这个例子中,它生成一个查询语句,选择国际象棋_游戏 表中所有名称与playerBlack匹配的记录。
聚合体的持久化和查询
在定义了你的聚合体和存储库后,你可以在你的业务代码中使用它们。让我们先用一些ChessMoves来持久化一个ChessGame:
ChessGame game = new ChessGame();
game.setPlayerWhite("Thorben Janssen");
game.setPlayerBlack("A strong player");
ChessMove move1white = new ChessMove();
move1white.setMoveNumber(1);
move1white.setColor(MoveColor.WHITE);
move1white.setMove("e4");
game.getMoves().add(move1white);
ChessMove move1Black = new ChessMove();
move1Black.setMoveNumber(1);
move1Black.setColor(MoveColor.BLACK);
move1Black.setMove("e5");
game.getMoves().add(move1Black);
gameRepo.save(game);
正如你所看到的,你不需要对Spring Data JDBC做任何特定的事情。如果你使用Spring Data JPA或任何其他Spring Data模块,这个测试案例看起来也是一样的。这就是Spring Data的优点之一。
当你执行代码时,你可以在日志输出中看到,Spring Data JDBC首先在chess_game 表中持久化一条记录,然后才为每个ChessMove 对象在chess_move 表中持久化一条记录:
2022-05-19 14:24:42.294 DEBUG 31848 - – [ main] o.s.jdbc.core.JdbcTemplate : Executing SQL update and returning generated keys
2022-05-19 14:24:42.295 DEBUG 31848 - – [ main] o.s.jdbc.core.JdbcTemplate : Executing prepared SQL statement [INSERT INTO "chess_game" ("player_black", "player_white") VALUES (?, ?)]
2022-05-19 14:24:42.338 DEBUG 31848 - – [ main] o.s.jdbc.core.JdbcTemplate : Executing SQL update and returning generated keys
2022-05-19 14:24:42.338 DEBUG 31848 - – [ main] o.s.jdbc.core.JdbcTemplate : Executing prepared SQL statement [INSERT INTO "chess_move" ("chess_game", "chess_game_key", "color", "move", "move_number") VALUES (?, ?, ?, ?, ?)]
2022-05-19 14:24:42.346 DEBUG 31848 - – [ main] o.s.jdbc.core.JdbcTemplate : Executing SQL update and returning generated keys
2022-05-19 14:24:42.346 DEBUG 31848 - – [ main] o.s.jdbc.core.JdbcTemplate : Executing prepared SQL statement [INSERT INTO "chess_move" ("chess_game", "chess_game_key", "color", "move", "move_number") VALUES (?, ?, ?, ?, ?)]
这表明Spring Data JDBC将聚合作为一个单元来处理。当你持久化聚合根时,它会自动持久化所有相关的实体。
当你从数据库中获取一个聚合时也会发生同样的情况。让我们调用我们在上一节定义的ChessGameRepository的findByPlayerBlack方法。它返回一个ChessGame实体,也就是聚合根,以及所有相关的实体。当然,你也可以使用一个不同的投影。我将在未来的文章中向你展示如何做到这一点:
List<ChessGame> games = gameRepo.findByPlayerBlack("A strong player");
games.forEach(g -> log.info(g.toString()));
日志输出显示,Spring Data JDBC首先执行了一个查询,该查询返回所有ChessGame 实体,这些实体是由名字与所提供的绑定参数值相匹配的黑棋手所下的:
2022-05-25 09:00:26.230 DEBUG 36564 - – [ main] o.s.jdbc.core.JdbcTemplate : Executing prepared SQL statement [SELECT "chess_game"."id" AS "id", "chess_game"."player_black" AS "player_black", "chess_game"."player_white" AS "player_white" FROM "chess_game" WHERE "chess_game"."player_black" = ?]
2022-05-25 09:00:26.267 DEBUG 36564 - – [ main] o.s.jdbc.core.JdbcTemplate : Executing prepared SQL query
2022-05-25 09:00:26.268 DEBUG 36564 - – [ main] o.s.jdbc.core.JdbcTemplate : Executing prepared SQL statement [SELECT "chess_move"."move" AS "move", "chess_move"."color" AS "color", "chess_move"."move_number" AS "move_number", "chess_move"."chess_game_key" AS "chess_game_key" FROM "chess_move" WHERE "chess_move"."chess_game" = ? ORDER BY "chess_game_key"]
2022-05-25 09:00:26.281 INFO 36564 - – [ main] com.thorben.janssen.TestQueryMethod : ChessGame [id=1, playerBlack=A strong player, playerWhite=Thorben Janssen, moves=[ChessMove [moveNumber=1, color=WHITE, move=e4], ChessMove [moveNumber=1, color=BLACK, move=e5]]]
当它检索到查询结果并将每个记录映射到ChessGame对象时,Spring Data JDBC执行了另一个查询以获得相关的ChessMove对象。这导致了一个n+1的选择问题,如果你只需要它的一些字段,你应该小心地获取聚合体。在这种情况下,最好选择一个不同的投影。
结论
Spring Data JDBC是一个针对关系型数据库的对象关系映射框架,旨在避免其他ORM框架的大部分复杂性。它通过避免懒惰加载、实体对象的管理生命周期和缓存等功能来做到这一点。相反,它让开发者控制所有执行的SQL语句。这使得预测你的持久层何时执行哪些SQL语句变得更加容易,但它也要求你触发所有的写和读操作。
使用Spring Data JDBC与使用其他Spring Data模块的工作非常相似。你定义由多个实体对象和存储库组成的聚合。
实体的实现非常简单。你定义一个POJO,并用*@Id*来注释主键属性。与同一聚合体中其他实体的关联被建模为关联实体类的类型属性或关联实体类的java.util.List。如果你想引用另一个聚合体,你需要把它建模为一个引用,而不是一个关联。
资源库的定义遵循标准的Spring Data模式。你只需要定义一个扩展了Spring Data标准存储库接口之一的接口,Spring Data JDBC就会提供所需的实现。你也可以将你自己的资源库方法添加为派生查询,或者使用你可能从其他Spring Data模块中知道的*@Query*注解。