我们打开 SimpleJpaRepository 可以看到常用的返回类型包括:Optional、Iterable、List、Page、Long、Boolean、Entity 对象等,⽽实际上⽀持的返回类型还要多⼀些。
由于 Repository ⾥⾯⽀持 Iterable,所以其实 java 标准的 List、Set 都可以作为返回结果,并且也会⽀持其⼦类,Spring Data ⾥⾯定义了⼀个特殊的⼦类 Steamable,Streamable 可以替代 Iterable 或任何集合类型。它还提供了⽅便的⽅法来访问 Stream,可以直接在元素上进⾏ ….filter(…) 和 ….map(…) 操作,并将 Streamable 连接到其他元素。我们看个关于 UserRepository 直接继承 JpaRepository 的例⼦。
@Test
public void test_stream() {
User user = userRepository.save(User.builder().name("jackxx").email("[email protected]").build());
Assertions.assertNotNull(user);
Streamable<User> userStreamable = userRepository.findAll(PageRequest.ofSize(10));
userStreamable.and(User.builder().name("jack222 ").build());
userStreamable.forEach(System.out::println);
}
然后我们就会得到如下输出:
User(id=1, name=jackxx, [email protected])
User(id=null, name=jack222 , email=null)
这个例⼦ Streamable
,实现了 Streamable 的返回结果,
官⽅给我们提供了⾃定义 Streamable 的⽅法,不过在实际⼯作中很少出现要⾃定义保证结果类的情况,在这⾥我简单介绍⼀下⽅法,看如下例⼦:
第一步:定义一个 Product 实体,公开 API 以访问产品的价格
class Product {
MonetaryAmount getPrice() { … }
}
第二步:Streamable
的包装类型可以通过 Products.of(…)
构造(通过 Lombok 注解创建的⼯⼚⽅法)。包装器类型在 Streamable
上公开了计算新值的其他 API。
@RequiredArgConstructor(staticName = "of")
class Products implements Streamable<Product> {
private Streamable<Product> streamable;
public MonetaryAmount getTotal() {
return streamable.stream()
.map(Priced::getPrice)
.reduce(Money.of(0), MonetaryAmount::add);
}
}
第三步:可以将包装器类型直接⽤作查询⽅法返回类型,⽆须返回 Stremable
。
interface ProductRepository implements Repository<Product, Long> {
Products findAllByDescriptionContaining(String text);
}
通过以上例⼦你就可以做到⾃定义 Streamable,其原理很简单,就是实现 Streamable 接⼝,⾃⼰定义⾃⼰的实现类即可。我们也可以看下源码 QueryExecutionResultHandler ⾥⾯是否有 Streamable ⼦类的判断,来⽀持⾃定义 Streamable,关键源码如下:
在实际开发中,我们如何返回 List/Stream/Page/Slice 呢?代码如下:
public interface UserRepository extends JpaRepository<User,Long> {
// ⾃定义⼀个查询⽅法,返回 Stream 对象,并且有分⻚属性
@Query("select u from User u")
Stream<User> findAllByStream(Pageable pageable);
// 测试 Slice 的返回结果
@Query("select u from User u")
Slice<User> findAllBySlice(Pageable pageable);
}
然后,编写我们的测试用例:
@Test
public void test() throws JsonProcessingException {
// 新增 7 条数据⽅便测试分⻚结果
userRepository.save(User.builder().name("jack1").email("[email protected]").build());
userRepository.save(User.builder().name("jack2").email("[email protected]").build());
userRepository.save(User.builder().name("jack3").email("[email protected]").build());
userRepository.save(User.builder().name("jack4").email("[email protected]").build());
userRepository.save(User.builder().name("jack5").email("[email protected]").build());
userRepository.save(User.builder().name("jack6").email("[email protected]").build());
userRepository.save(User.builder().name("jack7").email("[email protected]").build());
// 我们利⽤ ObjectMapper 将我们的返回结果 son to String
ObjectMapper objectMapper = new ObjectMapper();
// 返回 Stream 类型结果
Stream<User> userStream = userRepository.findAllByStream(PageRequest.of(1, 3));
userStream.forEach(System.out::println);
//返回分⻚数据
Page<User> userPage = userRepository.findAll(PageRequest.of(0, 3));
System.out.println(objectMapper.writeValueAsString(userPage));
// 返回 Slice 结果
Slice<User> userSlice = userRepository.findAllBySlice(PageRequest.of(0, 3));
System.out.println(objectMapper.writeValueAsString(userSlice));
// 返回 List 结果
List<User> userList = userRepository.findAllById(Lists.newArrayList(1L, 2L));
System.out.println(objectMapper.writeValueAsString(userList));
}
Spring Data 的⽀持可以通过使⽤ Java 8 Stream 作为返回类型来逐步处理查询⽅法的结果。需要注意的是:流的关闭问题,try catch 是⼀种常⽤的关闭⽅法,如下所示:
Stream<User> stream;
try {
stream = repository.findAllByStream();
stream.forEach(…);
} catch (Exception e) {
e.printStackTrace();
} finally {
if (stream!=null){
stream.close();
}
}
Page 和 Slice 的主要区别:Slice 只查询偏移量,不计算分⻚数据
我们可以使⽤ Spring 的异步⽅法执⾏ Repository 查询,这意味着⽅法将在调⽤时⽴即返回,并且实际的查询执⾏将发⽣在已提交给 Spring TaskExecutor 的任务中,⽐较适合定时任务的实际场景。异步使⽤起来⽐较简单,直接加 @Async 注解即可,如下所示:
@Async
Future<User> findByFirstname(String firstname);
@Async
CompletableFuture<User> findOneByFirstname(String firstname);
@Async
ListenableFuture<User> findOneByLastname(String lastname);
Spring Data 支持三种异步返回类型:
以上是对 @Async 的⽀持,关于实际使⽤需要注意以下三点内容:
Spring Data Common⾥⾯对React还是有⽀持的,那为什么在 JpaRespository ⾥⾯没看到有响应的返回结果⽀持呢?其实 Common ⾥⾯提供的只是接⼝,⽽JPA⾥⾯没有做相关的 Reactive 的实现,但是本身 Spring Data Common ⾥⾯对 Reactive 是⽀持的。
打开 ResultProcessor 类的源码看⼀下⽀持的类型有哪些。
从上图可以看出 processResult 的时候分别对 PageQuery、Stream、Reactive 有了各⾃的判断
这⾥我们先⽤表格总结⼀下返回值,下表列出了 Spring Data JPA Query Method 机制⽀持的⽅法的返回值类型:
返回值类型 | 描述 |
---|---|
void | 不返回结果,一般是更新操作。 |
Map | 返回 Map 结构,key 是字段,value 是数据库里面字段对应的值。 |
Primitives | Java 的基本类型,一般常见的是统计操作(如 long,boolean 等)。 |
Wrapper types | Java 的包装类。 |
T | 最多只返回一个实体,没有查询结果时返回 null。 如果超过了一个结果就会抛出 IncorrectResultSizeDataAccessException 的异常。 |
Iterator |
一个迭代器。 |
Collection |
一个集合。 |
List |
List 及其任何子类。 |
Optional |
返回 Java 8 中的 Optional 类。 查询方法的返回结果最多只能有一个。 如果超过了一个结果就会抛出 IncorrectResultSizeDataAccessException 的异常。 |
Stream |
Java 8 Stream |
Future |
返回 Future。查询方法需要带有 @Async 注解,并且开启 Spring 异步执行方法的功能。 一般配合多线程使用。关系型数据库实际工作中很少用到。 |
CompletableFuture |
返回 Java 8 中新引入的 CompletableFuture 类,查询方法需要带有 @Async 注解,并且开启 Spring 异步执行方法的功能。 |
ListenableFuture |
返回 org.springframework.util.concurrent.ListenableFuture 类,查询方法需要带有 @Async 注解,并且开启 Spring 异步执行方法的功能。 |
Slice |
返回指定大小的数据和是否还有可用数据的信息。需要方法带有 Pageable 类型的参数。 |
Page |
在 Slice 的基础上附加返回分页总数等信息。需要方法带有 Pageable 类型的参数。 |
Spring JPA 对 Projections 扩展的⽀持,我个⼈觉得这是个⾮常好的东⻄,从字⾯意思上理解就是映射,指的是和 DB 的查询结果的字段映射关系。⼀般情况下,返回的字段和 DB的查询结果的字段是⼀⼀对应的;但有的时候,需要返回⼀些指定的字段,或者返回⼀些复合型的字段,⽽不需要全部返回。
原来我们的做法是⾃⼰写各种 entity 到 view 的各种 convert 的转化逻辑,⽽ Spring Data正是考虑到了这⼀点,允许对专⽤返回类型进⾏建模,有选择地返回同⼀个实体的不同视图对象。
下⾯以 User 查询对象为例,看看怎么⾃定义返回 DTO:
@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy= GenerationType.AUTO)
private Long id;
private String name;
private String email;
private String sex;
private String address;
}
看上⾯的原始 User 实体代码,如果我们只想返回 User 对象⾥⾯的 name 和 email,应该怎么做?下⾯我们介绍三种⽅法。
⾸先,我们新增⼀个Entity类:通过 @Table 指向同⼀张表,这张表和 User 实例⾥⾯的表⼀样都是 user,完整内容如下:
@Entity
@Table(name = "user")
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class UserOnlyNameEmailEntity {
@Id
@GeneratedValue(strategy= GenerationType.AUTO)
private Long id;
private String name;
private String email;
}
然后,新增⼀个 UserOnlyNameEmailEntityRepository,做单独的查询:
public interface UserOnlyNameEmailEntityRepository extends JpaRepository<UserOnlyNameEmailEntity,Long> {
}
测试⽤例:
@Test
public void testProjections1() {
userRepository.save(User.builder().name("jack12").email("[email protected]").sex("man").address("shanghai").build());
List<User> users = userRepository.findAll();
System.out.println(users);
UserOnlyNameEmailEntity uName = userOnlyNameEmailEntityRepository.getById(1L);
System.out.println(uName);
}
我们看⼀下输出结果:
Hibernate: insert into user (address, email, name, sex, id) values (?, ?, ?, ?, ?)
Hibernate: select user0_.id as id1_0_, user0_.address as address2_0_, user0_.email as email3_0_, user0_.name as name4_0_, user0_.sex as sex5_0_ from user user0_
[User(id=1, name=jack12, [email protected], sex=man, address=shanghai)]
Hibernate: select useronlyna0_.id as id1_0_0_, useronlyna0_.email as email3_0_0_, useronlyna0_.name as name4_0_0_ from user useronlyna0_ where useronlyna0_.id=?
UserOnlyNameEmailEntity(id=1, name=jack12, [email protected])
上述结果可以看到,当在 user 表⾥⾯插⼊了⼀条数据,⽽ userRepository 和 userOnlyNameEmailEntityRepository 查询的都是同⼀张表 user,这种⽅式的好处是简单、⽅便,很容易可以想到;缺点就是通过两个实体都可以进⾏ update 操作,如果同⼀个项⽬⾥⾯这种实体⽐较多,到时候就容易不知道是谁更新的,从⽽导致出 bug 不好查询,实体职责划分不明确。
⾸先,新建⼀个 DTO 类来返回我们想要的字段,它是 UserOnlyNameEmailDto,⽤来接收 name、email 两个字段的值,具体如下:
@Data
@Builder
@AllArgsConstructor
public class UserOnlyNameEmailDto {
private String name;
private String email;
}
其次,在 UserRepository ⾥⾯做如下⽤法:
// 测试只返回 name 和 email 的 DTO
UserOnlyNameEmailDto findByEmail(String email);
测试⽤例⾥⾯写法如下:
@Test
public void testProjections2() {
userRepository.save(User.builder().name("jack12").email("[email protected]").sex("man").address("shanghai").build());
UserOnlyNameEmailDto userOnlyNameEmailDto = userRepository.findByEmail("[email protected]");
System.out.println(userOnlyNameEmailDto);
}
输出结果如下:
Hibernate: insert into user (address, email, name, sex, id) values (?, ?, ?, ?, ?)
Hibernate: select user0_.name as col_0_0_, user0_.email as col_1_0_ from user user0_ where user0_.email=?
UserOnlyNameEmailDto(name=jack12, [email protected])
这⾥需要注意的是,如果我们去看源码的话,看关键的 PreferredConstructorDiscoverer 类时会发现,UserDTO ⾥⾯只能有⼀个全参数构造⽅法,如下所示:
如上图所示,Constructor 选择的时候会帮我们做构造参数的选择,如果 DTO ⾥⾯有无参构造函数,将会优先选择无参的构造方法;如果有多个构造⽅法或者没有构造函数,则会返回 null,就会报转化错误的异常,这⼀点需要注意,异常是这样的:
org.springframework.core.convert.ConverterNotFoundException: No converter found capable of converting from type [com.example.jpa.example1.entity.User] to type [com.example.jpa.example1.pojo.UserOnlyNameEmailDto]
所以这种⽅式的优点就是返回的结果不需要是个实体对象,对 DB 不能进⾏除了查询之外的任何操作;缺点就是有 set ⽅法还可以改变⾥⾯的值,构造⽅法不能更改,必须全参数,这样如果是不熟悉 JPA 的新⼈操作的时候很容易引发 Bug。
我们再来学习⼀种返回不同字段的⽅式,这种⽅式与上⾯两种的区别是只需要定义接⼝,它的好处是只读,不需要添加构造⽅法,我们使⽤起来⾮常灵活,⼀般很难产⽣ Bug,那么它怎么实现呢?
⾸先,定义⼀个 UserOnlyName 的接⼝:
public interface UserOnlyName {
String getName();
String getEmail();
}
UserRepository 写法如下:
/**
* 接⼝的⽅式返回DTO
*/
UserOnlyName findByAddress(String address);
测试用例如下:
@Test
public void testProjections3() {
userRepository.save(User.builder().name("jack12").email("[email protected]").sex("man").address("shanghai").build());
UserOnlyName userOnlyName = userRepository.findByAddress("shanghai");
System.out.println(userOnlyName);
}
运⾏结果如下:
Hibernate: select user0_.name as col_0_0_, user0_.email as col_1_0_ from user user0_ where user0_.address=?
org.springframework.data.jpa.repository.query.AbstractJpaQuery$TupleConverter$TupleBackedMap@50a1af86
这个时候会发现我们的 userOnlyName 接⼝成了⼀个代理对象,⾥⾯通过 Map 的格式包含了我们的要返回字段的值(如:name、email),我们⽤的时候直接调⽤接⼝⾥⾯的⽅法即可,如 userOnlyName.getName() 即可;这种⽅式的优点是接口为只读,并且语义更清晰,所以这种是⽐较推荐的做法。
源码实现是在 org.hibernate.query.criteria.internal.QueryStructure,看⼀下最终 DTO 和接⼝转化执⾏的 query 有什么不同,看下图 debug 显示的 Query 语句的位置:
我们可以清楚得看到两个语句的区别:
是返回 DTO 类的时候 QueryStructure ⽣成的 JPQL 语句。
select new com.example.jpa.example1.pojo.UserOnlyNameEmailDto(generatedAlias0.name, generatedAlias0.email) from User as generatedAlias0 where generatedAlias0.email=:param0
返回 DTO 接⼝形式的 query ⽣成的 JPQL。
select generatedAlias0.name, generatedAlias0.email from User as generatedAlias0 where generatedAlias0.address=:param0
两种最⼤的区别是 DTO 类需要构造⽅法 new ⼀个对象出来,这就是我们第⼆种⽅法⾥⾯需要注意的 DTO 构造函数的问题;⽽通过图⼀我们可以看到接⼝直接通过 as 别名,映射成 map 即可,⾮常灵活。