• Spring Data JPA 之 @Entity 回调方法


    13 @Entity 回调方法的正确使用

    为什么要讲回调函数呢?因为在⼯作中,我发现有些同事会把这个回调⽅法⽤得⾮常复杂,不得要领,所以我专⻔拿出⼀个课时来为你详细说明,并分享我的经验供你参考。我将通过“语法 + 实践”的⽅式讲解如何使⽤ @Entity 的回调⽅法,从⽽达到提⾼开发效率的⽬的。下⾯开始本课时的学习。

    13.1 Java Persistence API 规定的回调方法

    13.1.1 Entity 的回调事件注解

    JPA 协议⾥⾯规定,可以通过⼀些注解,为其监听回调事件、指定回调⽅法。下⾯我整理了⼀个回调事件注解表,分别列举了 @PrePersist、@PostPersist、@PreRemove、@PostRemove、@PreUpdate、@PostUpdate、@PostLoad注解及其概念。

    注解描述
    @PrePersistEntityManager.persist 方法调用之前的回调注解,可以理解为新增之前的回调方法
    @PostPersist在操作 EntityManager.persist 方法之后调用的回调注解,EntityManager.flush 或 EntityManager.commit 方法之后调用此方法,也可以理解为在保存到数据库之后进行调用
    @PreRemote在操作 EntityManager.remote 之前调用的回调注解,可以理解为在操作删除方法之前调用
    @PostRemote在操作 EntityManager.remote 之后调用的回调注解,可以理解为在删除方法操作之后调用
    @PreUpdate在实体更新之前调用,所谓的更新其实是在 merge 之后,实体发生变化,这一注解可以理解为在变化储存到数据库之前调用
    @PreUpdate在实体更新之后调用,即实体的字段的值变化之后,在调用 EntityManager.flush 或 EntityManager.commit 方法之后调用这个方法。
    @PostLoad在实体从 DB 加载到程序里面之后回调

    13.1.2 语法注意事项

    关于上表所述的⼏个⽅法有⼀些需要注意的地⽅,如下:

    1. 回调函数都是和 EntityManager.flush 或 EntityManager.commit 在同⼀个线程⾥⾯执⾏的,只不过调⽤⽅法有先后之分,都是同步调⽤,所以当任何⼀个回调⽅法⾥⾯发⽣异常,都会触发事务进⾏回滚,⽽不会触发事务提交。
    2. Callbacks 注解可以放在实体⾥⾯,可以放在 super-class ⾥⾯,也可以定义在 entity 的 listener ⾥⾯,但需要注意的是:放在实体(或者 super-class)⾥⾯的⽅法,签名格式为void methodName(),即没有参数,⽅法⾥⾯操作的是 this 对象⾃⼰;放在实体的 EntityListener ⾥⾯的⽅法签名格式为 void methodName(Object),也就是⽅法可以有参数,参数是代表⽤来接收回调⽅法的实体。
    3. 使上述注解⽣效的回调⽅法可以是 public、private、protected、friendly 类型的,但是不能是 static 和 finnal 类型的⽅法。

    JPA ⾥⾯规定的回调⽅法还有⼀些,但不常⽤,我就不过多介绍了。接下来,我们看⼀下回调注解在实体⾥⾯是如何使⽤的。

    13.2 JPA 回调注解的使用方法

    这⾥我介绍两种⽅法,是你可能会在实际⼯作中⽤到的。

    13.2.1 第一种用法:在实体和 super-class 中使用

    第⼀步:修改 BaseEntity,在⾥⾯新增回调函数和注解,代码如下:

    @Data
    @MappedSuperclass
    @EntityListeners(AuditingEntityListener.class)
    public class BaseEntity {
    
        @Id
        @GeneratedValue(strategy = GenerationType.AUTO)
        protected Long id;
        // @Version 由于本身有乐观锁机制,这个我们测试的时候先注释掉,改⽤⼿动设置的值;
        protected Long version;
        @Column(name = "is_deleted")
        protected boolean deleted;
    
        // @CreatedBy 这个可能会被 AuditingEntityListener 覆盖,为了⽅便测试,我们先注释掉
        protected Integer createUserId;
        @CreatedDate
        protected LocalDateTime createdDate;
        @LastModifiedBy
        protected Integer lastModifiedUserId;
        @LastModifiedDate
        protected LocalDateTime lastModifiedDate;
    
        @PreUpdate
        public void preUpdate() {
            System.out.println("preUpdate::" + this);
            this.setCreateUserId(200);
        }
    
        @PostUpdate
        public void postUpdate() {
            System.out.println("postUpdate::" + this);
        }
    
        @PreRemove
        public void preRemove() {
            System.out.println("preRemove::" + this);
        }
    
        @PostRemove
        public void postRemove() {
            System.out.println("postRemove::" + this);
        }
    
        @PostLoad
        public void postLoad() {
            System.out.println("postLoad::" + this);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48

    上述代码中,我在类⾥⾯使⽤了@PreUpdate、@PostUpdate、@PreRemove、@PostRemove、@PostLoad ⼏个注解,并在相应的回调⽅法⾥⾯加了相应的⽇志。并且在 @PreUpdate ⽅法⾥⾯修改了 create_user_id 的值为 200,这样做是为了⽅便我们后续测试。

    第⼆步:修改⼀下 User 类,也新增两个回调函数,并且和 BaseEntity 做法⼀样,代码如下:

    @Entity
    @Data
    @Builder
    @AllArgsConstructor
    @NoArgsConstructor
    @EntityListeners(AuditingEntityListener.class)
    public class User extends BaseEntity {
    
        private String name;
        private String email;
        @Enumerated(EnumType.STRING)
        private SexEnum sex;
        private Integer age;
    
        @PrePersist
        private void prePersist() {
            System.out.println("prePersist::" + this);
            this.setVersion(1L);
        }
    
        @PostPersist
        public void postPersist() {
            System.out.println("postPersist::" + this);
        }
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    我在其中使⽤了 @PrePersist、@PostPersist 回调事件,为了⽅便我们测试,我在 @PrePersist ⾥⾯将 version 修改为 1。

    第三步:写⼀个测试⽤例测试⼀下。

    @DataJpaTest
    @TestInstance(TestInstance.Lifecycle.PER_CLASS)
    @Import(JpaConfiguration.class)
    class UserRepositoryTest {
    
        @Autowired
        private UserRepository userRepository;
        @MockBean
        private MyAuditorAware myAuditorAware;
    
        /**
         * 为了和测试⽅法的事务分开,我们在 init ⾥⾯初始化数据做新增操作
         */
        @BeforeAll
        @Rollback(false)
        @Transactional
        public void init() {
            //由于测试⽤例模拟 web context 环境不是我们的重点,这⾥利⽤ @MockBean,mock 掉我们的⽅法,期待返回 13 这个⽤户 ID
            Mockito.when(myAuditorAware.getCurrentAuditor()).thenReturn(Optional.of(13));
            User u1 = User.builder()
                    .name("jack")
                    .email("123456@126.com")
                    .sex(SexEnum.BOY)
                    .age(20)
                    .build();
            //没有 save 之前 version 是 null
            Assertions.assertNull(u1.getVersion());
            userRepository.save(u1);
            //这⾥⾯触发保存⽅法,这个时候我们将 version 设置成了 1,然后验证⼀下
            Assertions.assertEquals(1, u1.getVersion());
        }
    
        /**
         * 测试⼀下更新和查询
         */
        @Test
        @Rollback(false)
        @Transactional
        void testCallBackUpdate() {
            //此时会触发 @PostLoad 事件
            User u1 = userRepository.getOne(1L);
            //我们从 db ⾥⾯重新查询出来,验证⼀下 version 是不是 1
            Assertions.assertEquals(1, u1.getVersion());
            u1.setSex(SexEnum.GIRL);
            //此时会触发 @PreUpdate 事件
            userRepository.save(u1);
            List<User> u3 = userRepository.findAll();
            u3.forEach(u -> {
                //我们从 db 查询出来,验证⼀下 CreateUserId 是否为我们刚才修改的 200
                Assertions.assertEquals(200, u.getCreateUserId());
            });
        }
    
        /**
         * 测试⼀下删除事件
         */
        @Test
        @Rollback(false)
        @Transactional
        void testCallBackDelete() {
            //此时会触发 @PostLoad 事件
            User u1 = userRepository.getById(1L);
            Assertions.assertEquals(200, u1.getCreateUserId());
            userRepository.delete(u1);
            //此时会触发 @PreRemove、@PostRemove 事件
            System.out.println("delete_after::");
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68

    我们通过测试⽤例验证了回调函数的事件后,看⼀下输出的 SQL 和⽇志:

    在这里插入图片描述

    我们通过上图的⽇志也可以看到响应的回调函数被触发了,并且可以看到我们在 insert 之前执⾏ prePersist ⽇志、在 insert 之后执⾏ postPersist ⽇志、在 select 之后执⾏ postLoad ⽅法的⽇志,以及在 update 的 sql 前后执⾏的 preUpdate 和 postUpdate ⽇志。

    如果我们执⾏上⾯ remove 的测试⽤例,也会得到⼀样的效果:在 delete sql 之前会执⾏ preRemove 的⽅法并且打印⽇志,在 delete sql 之后会执⾏ postRemove ⽅法并打印⽇志。

    那么使⽤这种⽅法,回调函数⾥⾯发⽣异常会怎么样呢?这也是你可能会遇到的问题,我来告诉你解决办法。

    我们稍微修改⼀下上⾯的 @PostPersist ⽅法,⼿动抛⼀个异常出来,看看会发⽣什么。

    @PostPersist
    public void postPersist() {
        System.out.println("postPersist::" + this);
        throw new RuntimeException("test exception transactional roll back");
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    我们再跑测试⽤例就会发现,其中发⽣了 RollbackException 异常,这样的话数据是不会提交到 DB ⾥⾯的,也就会导致数据进⾏回滚,后⾯的业务流程⽆法执⾏下去。

    org.springframework.transaction.TransactionSystemException: Could not commit JPA transaction; nested exception is javax.persistence.RollbackException: Error while committing the transaction
    
    • 1

    所以在使⽤此⽅法时,你要注意考虑异常情况,避免不必要的麻烦。

    13.2.2 第二种用法:自定义EntityListener

    第⼀步:⾃定义⼀个 EntityLoggingListenner ⽤来记录操作⽇志,通过 listener 的⽅式配置回调函数注解,代码如下:

    public class EntityLoggingListener {
        @PrePersist
        private void prePersist(BaseEntity entity) {
            // entity.setVersion(1); 如果注释了,测试⽤例这个地⽅的验证也需要去掉
            System.out.println("[listener]prePersist::" + entity);
        }
    
        @PostPersist
        public void postPersist(BaseEntity entity) {
            System.out.println("[listener]postPersist::" + entity);
        }
    
        @PreUpdate
        public void preUpdate(BaseEntity entity) {
            //entity.setCreateUserId(200); 如果注释了,测试⽤例这个地⽅的验证也需要去掉
            System.out.println("[listener]preUpdate::" + entity);
        }
    
        @PostUpdate
        public void postUpdate(BaseEntity entity) {
            System.out.println("[listener]postUpdate::" + entity);
        }
    
        @PreRemove
        public void preRemove(BaseEntity entity) {
            System.out.println("[listener]preRemove::" + entity);
        }
    
        @PostRemove
        public void postRemove(BaseEntity entity) {
            System.out.println("[listener]postRemove::" + entity);
        }
    
        @PostLoad
        public void postLoad(Object entity) {
            //查询⽅法⾥⾯可以对⼀些敏感信息做⼀些⽇志
            if (entity instanceof User) {
                System.out.println("[listener]postLoad::" + entity);
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41

    然后在 BaseEntity 和 User 的 @EntityListeners 中添加 EntityLoggingListener,代码如下:

    @Data
    @MappedSuperclass
    @EntityListeners(value = {
            AuditingEntityListener.class,
            EntityLoggingListener.class
    })
    public class BaseEntity {
    }
    
    @Entity
    @Data
    @Builder
    @AllArgsConstructor
    @NoArgsConstructor
    @EntityListeners(value = {
            AuditingEntityListener.class,
            EntityLoggingListener.class
    })
    @ToString(callSuper = true)
    public class User extends BaseEntity {
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    在这⼀步骤中需要注意的是:

    1. 我们上⾯注释的代码,也可以改变 entity ⾥⾯的值,但是在这个 Listener 的⾥⾯我们不做修改,所以把 setVersion 和 setCreateUserId 注释掉了,要注意测试⽤例⾥⾯这两处也需要修改。
    2. 如果在 @PostLoad ⾥⾯记录⽇志,不⼀定每个实体、每次查询都需要记录⽇志,只需要对⼀些敏感的实体或者字段做⽇志记录即可。
    3. 回调函数时我们可以加上参数,这个参数可以是⽗类 Object,可以是 BaseEntity,也可以是具体的某⼀个实体;我推荐⽤ BaseEntity,因为这样的⽅法是类型安全的,它可以约定⼀些框架逻辑,⽐如 getCreateUserId、getLastModifiedUserId 等。

    第⼆步:还是⼀样的道理,写⼀个测试⽤例跑⼀下。

    这次我们执⾏ testCallBackDelete(),看看会得到什么样的效果

    在这里插入图片描述

    通过⽇志我们可以很清晰地看到 callback 注解标注的⽅法的执⾏过程,及其实体参数的值。你就会发现,原来⾃定义 EntityListener 回调函数的⽅法也是如此简单。

    细⼼的你这个时候可能也会发现,我们上⾯其实应⽤了两个 EntityListener,所以这个时候 @EntityListeners 有个加载顺序的问题,你需要重点注意⼀下。

    13.2.3 关于 @EntityListeners 加载顺序的说明

    1. 默认如果⼦类和⽗类都有 EntityListeners,那么 listeners 会按照加载的顺序执⾏所有 EntityListeners;
    2. EntityListeners 和实体⾥⾯的回调函数注解可以同时使⽤,但需要注意顺序问题;
    3. 如果我们不想加载 super-class ⾥⾯的 EntityListeners,那么我们可以通过注解 @ExcludeSuperclassListeners,排除所有⽗类⾥⾯的实体监听者,需要⽤到的时候,我们再在⼦类实体⾥⾯重新引⼊即可,代码如下:
    @ExcludeSuperclassListeners
    public class User extends BaseEntity {
    }
    
    • 1
    • 2
    • 3

    看完了上⾯介绍的两种⽅式,关于 Callbacks 注解的⽤法你是不是已经掌握了呢?我强调需要注意的地⽅你要重点看⼀下,并切记在应⽤时不要搞错了。

    上⾯说了这么多回调函数的注解使⽤⽅法,那么它的最佳实践是什么呢?

    14.3 JPA 回调注解的实践

    我以个⼈经验总结了⼏个最佳实践。

    1. 回调函数⾥⾯应尽量避免直接操作业务代码,最好⽤⼀些具有框架性的公⽤代码,如上⼀课时我们讲的 Auditing,以及本课时前⾯提到的实体操作⽇志等;

    2. 注意回调函数⽅法要在同⼀个事务中进⾏,异常要可预期,⾮可预期的异常要进⾏捕获,以免出现意想不到的线上 Bug;

    3. 回调函数⽅法是同步的,如果⼀些计算量⼤的和⼀些耗时的操作,可以通过发消息等机制异步处理,以免阻塞主流程,影响接⼝的性能。⽐如上⾯说的⽇志,如果我们要将其记录到数据库⾥⾯,可以在回调⽅法⾥⾯发个消息,异步进行记录。

    4. 在回调函数⾥⾯,尽量不要直接在操作 EntityManager 后再做 session 的整个⽣命周期的其他持久化操作,以免破坏事务的处理流程;也不要进⾏其他额外的关联关系更新动作,业务性的代码⼀定要放在 service 层⾯,否则太过复杂,时间⻓了代码很难维护;(ps:我曾经看到有⼈把回调函数⽤得⼗分复杂,做各种状态流转逻辑,时间⻓了连他⾃⼰也不知道是⼲什么的,耦合度太⾼了,你⼀定要谨慎。)

    5. 回调函数⾥⾯⽐较适合⽤⼀些计算型的transient⽅法,如下⾯这个操作:

      public class UserListener {
      
          @PrePersist
          public void prePersist(User user) {
              // 通过⼀些逻辑计算年龄;
              user.calculationAge();
          }
      
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
    6. JPA 官⽅⽐较建议放⼀些默认值,像有时候在保存的时候需要赋值默认值,就可以使用。

    14.4 JPA 回调注解的实现原理和事件机制

    那么 callbacks 的实现原理是什么呢?其实很简单,Java Persistence API规定:JPA 的实现⽅需要实现功能,需要⽀持回调事件注解;⽽ Hibernate 内部负责实现,Hibernate 内部维护了⼀套实体的 EventType,其内部包含了各种回调事件,下⾯列举⼀下:

    public static final EventType<PreLoadEventListener> PRE_LOAD = create( "pre-load", PreLoadEventListener.class );
    public static final EventType<PreDeleteEventListener> PRE_DELETE = create( "pre-delete", PreDeleteEventListener.class );
    public static final EventType<PreUpdateEventListener> PRE_UPDATE = create( "pre-update", PreUpdateEventListener.class );
    public static final EventType<PreInsertEventListener> PRE_INSERT = create( "pre-insert", PreInsertEventListener.class );
    
    public static final EventType<PostLoadEventListener> POST_LOAD = create( "post-load", PostLoadEventListener.class );
    public static final EventType<PostDeleteEventListener> POST_DELETE = create( "post-delete", PostDeleteEventListener.class );
    public static final EventType<PostUpdateEventListener> POST_UPDATE = create( "post-update", PostUpdateEventListener.class );
    public static final EventType<PostInsertEventListener> POST_INSERT = create( "post-insert", PostInsertEventListener.class );
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    更多的事件类型,你可以通过查看 org.hibernate.event.spi.EventType 类,了解更多;在 session factory 构建的时候,org.hibernate.jpa.event.internal.CallbackRegistryImpl 负责注册这些事件,我们看⼀下 debug 的关键节点:

    org.hibernate.jpa.event.internal.CallbackRegistryImpl#registerCallbacks

    在这里插入图片描述

    通过⼀步⼀步断点,再结合 Hibernate 的官⽅⽂档,可以了解内部 EventType 事件的创建机制,由于我们不常⽤这部分原理,知道有这么回事即可,你有兴趣也可以深⼊ debug 研究⼀下

    13.5 本章小结

    这⼀节,我们分析了语法,列举了实战使⽤场景及最佳实践,相信通过上⾯提到的异常、异步、避免死循环等处理⽅法,你已经知道回调函数的正确使⽤⽅法了。

  • 相关阅读:
    conda使用一般步骤
    shell实现控制进程并发数
    网络原理---拿捏传输层:TCP/UDP协议
    mysql数据库安装
    互联网获客经验分享(一)
    后端 --- Elasticsearch学习笔记(入门篇)
    开源MyBatisGenerator组件源码分析
    设置Windows主机的浏览器为wls2的默认浏览器
    c++输入输出文件操作stream
    【无标题】
  • 原文地址:https://blog.csdn.net/qq_40161813/article/details/126205687