Spring Data Jpa 关联对象序列化时出现no Session的解决方案
在使用Spring Data Jpa时, 经常会编写类似下面的代码:
@Entity
public class User {
@Id
@Column(name = "user_id")
private Long id;
@JoinTable
@ManyToMany
private Set<Role> roles;
@Entity
public class Role {
@Id
@Column(name = "role_id")
private Long id;
}
然后进行如下类似下面的调用时发生异常:
@SpringBootTest(classes = JpaDemoApplication.class)
class JpaDemoApplicationTests {
@Autowired UserRepository userRepository;
@Test
void contextLoads() {}
@Test
void test() {
userRepository.findById(1L).orElseThrow().getRoles().forEach(System.out::println);
}
}
failed to lazily initialize a collection of role: com.wymc.demo.User.roles, could not initialize proxy - no Session
org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.wymc.demo.User.roles, could not initialize proxy - no Session
at app//org.hibernate.collection.internal.AbstractPersistentCollection.throwLazyInitializationException(AbstractPersistentCollection.java:614)
at app//org.hibernate.collection.internal.AbstractPersistentCollection.withTemporarySessionIfNeeded(AbstractPersistentCollection.java:218)
at app//org.hibernate.collection.internal.AbstractPersistentCollection.initialize(AbstractPersistentCollection.java:591)
at app//org.hibernate.collection.internal.AbstractPersistentCollection.read(AbstractPersistentCollection.java:149)
at app//org.hibernate.collection.internal.PersistentSet.iterator(PersistentSet.java:188)
at java.base@17.0.2/java.lang.Iterable.forEach(Iterable.java:74)
at app//com.wymc.demo.JpaDemoApplicationTests.test(JpaDemoApplicationTests.java:16)
出现上面的异常的原因也很简单, test
方法中, 调用userRepository.findById
之后, 事务就已经提交, 此时会话就已经关闭, 而懒加载需求会话连接, 因此再调用getRoles
并对它进行迭代的时候就会抛出异常.
这个时候也只需要在方法上加上@Transaction
即可.
@Test
@Transactional
void test() {
userRepository.findById(1L).orElseThrow().getRoles().forEach(System.out::println);
}
然而, 如果我们在controller
中返回实体类时, 就像下面这样
@RestController
@RequestMapping("/user")
public class UserController {
private final UserRepository userRepository;
public UserController(UserRepository userRepository) {
this.userRepository = userRepository;
}
@GetMapping("/{id}")
public User test(@PathVariable("id") Long id) {
return userRepository.findById(id).orElseThrow();
}
}
如果不做任何配置, 你能通过/user/{id}
查询到数据并且得到正确的结果.
但是你会发现, 启动时控制台将会有一个警告:
WARN 19016 — [ main] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
如果你按照他的提示在application.properties
文件中添加spring.jpa.open-in-view=false
配置的话, 就又会出现no session
异常了.
此时控制台会给出一个警告:
WARN 16292 — [nio-8080-exec-1] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: failed to lazily initialize a collection of role: com.wymc.demo.User.roles, could not initialize proxy – no Session; nested exception is com.fasterxml.jackson.databind.JsonMappingException: failed to lazily initialize a collection of role: com.wymc.demo.User.roles, could not initialize proxy – no Session (through reference chain: com.wymc.demo.User[“roles”])]
并响应500异常:
{
"timestamp": "2022-04-16T13:25:30.229+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/user/1"
}
通过异常信息你可以发现, 在序列化时发生了异常.
目前网上主要的解决方法有:
- 配置
spring.jpa.open-in-view=true
, 允许在视图层中开启会话, 这将会在序列化时进行懒加载查询 - 配置
hibernate
的enable_lazy_load_no_trans为true
, 允许在事务外开启会话进行懒加载, 与1同理 - 使用FetchType.EAGER策略, 查询到数据后直接加载关联的数据, 序列化时数据已经被加载因此不会出现异常
- 使用JPQL的
Join Fetch
语法, 通过生成join
查询出关联的数据, 同样也是在序列化之前就已经加载好数据 - 使用
@JsonIgnore
亦或是@JSONField(serialize=false)
, 直接不进行序列化
然而, 除了方法4和5以外, 无一例外都存在一个严重的问题: N+1问题
例如:
再增加一个Menu
类, Role
与Menu
形成多对多关系.
@Entity
public class Menu {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "menu_id")
private Long id;
}
@Entity
public class Role {
@Id
@Column(name = "role_id")
private Long id;
@JoinTable(
name = "role_menus",
joinColumns = @JoinColumn(name = "role_id"),
inverseJoinColumns = @JoinColumn(name = "menu_id"))
@ManyToMany
private Set<Menu> menus;
}
增加一个分页查询接口
@GetMapping
public Page<User> page(Pageable pageable) {
return userRepository.findAll(pageable);
}
然后在数据库随便插入几条数据后进行查询(保证查询的用户关联角色关联菜单都存在数据就行)
查看控制台日志:
Hibernate: select user0_.user_id as user_id1_3_ from user user0_ limit ?
Hibernate: select roles0_.user_id as user_id1_4_0_, roles0_.role_id as role_id2_4_0_, role1_.role_id as role_id1_1_1_ from user_roles roles0_ inner join role role1_ on roles0_.role_id=role1_.role_id where roles0_.user_id=?
Hibernate: select menus0_.role_id as role_id1_2_0_, menus0_.menu_id as menu_id2_2_0_, menu1_.menu_id as menu_id1_0_1_ from role_menus menus0_ inner join menu menu1_ on menus0_.menu_id=menu1_.menu_id where menus0_.role_id=?
Hibernate: select menus0_.role_id as role_id1_2_0_, menus0_.menu_id as menu_id2_2_0_, menu1_.menu_id as menu_id1_0_1_ from role_menus menus0_ inner join menu menu1_ on menus0_.menu_id=menu1_.menu_id where menus0_.role_id=?
Hibernate: select roles0_.user_id as user_id1_4_0_, roles0_.role_id as role_id2_4_0_, role1_.role_id as role_id1_1_1_ from user_roles roles0_ inner join role role1_ on roles0_.role_id=role1_.role_id where roles0_.user_id=?
此时, 我的数据库只有两个用户, 但是竟然查询了五次(根据数据库内容不同查询的次数可能也会不同), 这是因为在序列化时懒加载关联的角色和菜单进行了查询, 为了查询出所有关联的数据, 进行了额外的四次查询, 而实际应用过程中, 这个查询次数只会更多, 并且因为关联的层级递增而快速增长. 这正是n+1
问题, 进行一次查询, 要进行n次关联对象的查询. 这大大增加了查询次数, 并且增加了请求响应时间.
而用JOIN FETCH
来查询也是不切实际的, 并且会导致生成的查询过于复杂, 也没有实际的可操作性.
方法5确实可以解决序列化实体类的懒加载异常问题, 但为了在某些时候能够正常序列化关联的对象, 我们要引入额外的类, 他们具备和实体类大体一样的字段, 在service
层将实体类转换, 按需复制其关联对象的字段, 结合mapstruct
使用, 也是一种可行的方法, 但这样会引入大量额外的类, 他们基本与实体类一致, 却要额外定义, 并且还要再进行一次转换, 对我来说这实在是太麻烦了.
难道没有一种能够在序列化时, 如果在会话关闭, 并且数据还没有加载, 就直接返回空, 如果数据已经加载就能够正常进行序列化的方法吗?
答案是有的.
debug看一下查询出来的User
对象, 可以发现roles
字段的类型为org.hibernate.collection.internal.PersistentSet
而非java.util.HashSet
, 对其进行迭代时的关键源码如下
public class PersistentSet extends AbstractPersistentCollection implements java.util.Set {
@Override
public Iterator iterator() {
read();
return new IteratorProxy( set.iterator() );
}
}
public abstract class AbstractPersistentCollection implements Serializable, PersistentCollection {
protected final void read() {
initialize( false );
}
protected final void initialize(final boolean writing) {
if ( initialized ) {
return;
}
withTemporarySessionIfNeeded(
new LazyInitializationWork<Object>() {
@Override
public Object doWork() {
session.initializeCollection( AbstractPersistentCollection.this, writing );
return null;
}
});
}
private <T> T withTemporarySessionIfNeeded(LazyInitializationWork<T> lazyInitializationWork) {
SharedSessionContractImplementor tempSession = null;
if ( session == null ) {
if ( allowLoadOutsideTransaction ) {
tempSession = openTemporarySessionForLoading();
} else {
throwLazyInitializationException( "could not initialize proxy - no Session" );
}
}
// ...其它代码
}
}
jpa查询会使用PersistentCollection
接口的实现类赋值给关联集合, 读取其内容时会调用AbstractPersistentCollection#initialize()
, 如果未进行初始化(fetchType为lazy时), 则会使用数据库会话加载数据, 如果会话不存在则尝试打开临时会话, 如果不允许打开临时会话, 最后会抛出no Session
异常.
到这里, 其实方法就显而易见了, 我们只需要对PersistentCollection
类型的数据进行判断, 如果已经初始化则序列化, 否则不序列化.
如果使用jackson进行序列化, 代码如下:
public class PersistentCollectionSerializer extends StdSerializer<PersistentCollection> {
protected PersistentCollectionSerializer(Class<PersistentCollection> t) {
super(t);
}
@Override
public void serialize(PersistentCollection value, JsonGenerator gen, SerializerProvider provider)
throws IOException {
if (value.wasInitialized()) {
if (value instanceof Collection<?>) {
provider.findValueSerializer(Collection.class).serialize(value, gen, provider);
return;
} else if (value instanceof Map<?,?>) {
provider.findValueSerializer(Map.class).serialize(value, gen, provider);
return;
}
}
provider.defaultSerializeNull(gen);
}
}
并通过Jackson2ObjectMapperBuilder
添加该序列化器.
@Component
public class ObjectMapperConfiguration {
@Bean
public Jackson2ObjectMapperBuilder jackson2ObjectMapperBuilder() {
return new Jackson2ObjectMapperBuilder()
.createXmlMapper(false)
.serializerByType(
PersistentCollection.class,
new PersistentCollectionSerializer(PersistentCollection.class));
}
}
此时, 查询user得到的roles的结果就为空了.
但是N+1
问题并没有解决, 这只是在序列化时不再序列化未初始化的PersistentCollection
.
要解决N+1
问题, 可以去网上找方案.
这里推荐使用EntityGraph
.
只需要在repository中使用即可:
public interface UserRepository extends JpaRepository<User, Long> {
@EntityGraph(attributePaths = {"roles"})
@Override
Optional<User> findById(Long id);
}
这样通过findById
查询出来的结果将会携带上roles
字段.
也可以在实体类上使用
@NamedEntityGraph(
name = "user.roles",
attributeNodes = {@NamedAttributeNode("roles")})
public class User {}
然后在repository中使用, @EntityGraph
注解通过名称来查找对应的@NamedEntityGraph
public interface UserRepository extends JpaRepository<User, Long> {
@EntityGraph("user.roles")
@Override
Optional<User> findById(Long id);
}
这种方法定义简单, 并且兼容性好.
通过以上方法, 你就可以在一般查询中, 不再序列化关联的集合, 也就不会发生no Session
异常, 同时在有必要的情况下, 能够正常获取到关联的数据.
原创文章,作者:carmelaweatherly,如若转载,请注明出处:https://blog.ytso.com/245073.html