Spring Data Jpa 关联对象序列化时出现no Session的解决方案


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"
}

通过异常信息你可以发现, 在序列化时发生了异常.

目前网上主要的解决方法有:

  1. 配置spring.jpa.open-in-view=true, 允许在视图层中开启会话, 这将会在序列化时进行懒加载查询
  2. 配置hibernateenable_lazy_load_no_transtrue, 允许在事务外开启会话进行懒加载, 与1同理
  3. 使用FetchType.EAGER策略, 查询到数据后直接加载关联的数据, 序列化时数据已经被加载因此不会出现异常
  4. 使用JPQL的Join Fetch语法, 通过生成join查询出关联的数据, 同样也是在序列化之前就已经加载好数据
  5. 使用@JsonIgnore亦或是@JSONField(serialize=false), 直接不进行序列化

然而, 除了方法4和5以外, 无一例外都存在一个严重的问题: N+1问题

例如:

再增加一个Menu类, RoleMenu形成多对多关系.

@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

(0)
上一篇 2022年4月18日
下一篇 2022年4月18日

相关推荐

发表回复

登录后才能评论