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


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

1. 问题

在使用Spring Data Jpa时, 经常会编写类似下面的代码:

@Entity
public class User {
  @Id
  @Column(name = "user_id")
  private Long id;
  @JoinTable
  @ManyToMany
  private Set 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"
}

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

2. 常见解决方法

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

  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), 直接不进行序列化

3. N+1问题

然而, 除了方法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 menus;
}

增加一个分页查询接口

@GetMapping
public Page 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使用, 也是一种可行的方法, 但这样会引入大量额外的类, 他们基本与实体类一致, 却要额外定义, 并且还要再进行一次转换, 对我来说这实在是太麻烦了.

难道没有一种能够在序列化时, 如果在会话关闭, 并且数据还没有加载, 就直接返回空, 如果数据已经加载就能够正常进行序列化的方法吗?

答案是有的.

4. 解决方案

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() {
                @Override
                public Object doWork() {
                    session.initializeCollection( AbstractPersistentCollection.this, writing );
                    return null;
                }
            }
        );
    } 
    
    private  T withTemporarySessionIfNeeded(LazyInitializationWork 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 {
  protected PersistentCollectionSerializer(Class 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中添加@EntityGraph注解即可:

public interface UserRepository extends JpaRepository {
  @EntityGraph(attributePaths = {"roles"})
  @Override
  Optional findById(Long id);
}

这样通过findById查询出来的结果将会携带上roles字段.

也可以在实体类上使用

@NamedEntityGraph(
    name = "user.roles",
    attributeNodes = {@NamedAttributeNode("roles")})
public class User {}

然后在repository中使用, @EntityGraph注解通过名称来查找对应的@NamedEntityGraph

public interface UserRepository extends JpaRepository {
  @EntityGraph("user.roles")
  @Override
  Optional findById(Long id);
}

这种方法定义简单, 并且兼容性好.

通过以上方法, 你就可以在一般查询中, 不再序列化关联的集合, 也就不会发生no Session异常, 同时在有必要的情况下, 能够正常获取到关联的数据.

jpa