基于Spring Boot Data JPA的通用audit log日志记录的设计和实现

Posted c.

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了基于Spring Boot Data JPA的通用audit log日志记录的设计和实现相关的知识,希望对你有一定的参考价值。

文章目录

基于Spring Boot Data JPA的通用audit log日志记录的设计和实现

本文会讲解关于在Spring Boot Data JPA中如何设计一个通用的日志记录模块。本文重点是设计的思路和部分的具体实现,并不会提供完整的实现代码。博文的主要目的是为了记录自己的实现思路还有给其他有相同需求的小伙伴一些想法。

既然是基于JPA的,那我们必然考虑到会使用到JPA的entity监听器,也就是EntityListener了。而我们主要的实现也会放在EntityListener中了。

需求概要

首先在设计之前,我先来明确一下设计的需求。首先就是能够记录实体上一些字段的改变,并且可以自定义这些字段在页面的显示,比如说这个字段是’status’,可能在页面上我们需要显示成’Status’, 所以我们需要可以自定义那些字段需要记录,并且可以设置最终展示的字段名是什么。

并且我们的日志记录是会通过消息的方式,统一发送到日志服务,日志服务中使用mongoDB进行存储。然后我们前端会有一个通用的日志组件,来获取对应的所有日志。所以我们需要有key和type来标记一类的数据。这个怎么理解呢?

首先type就是什么类型的数据,其实就是entity是什么,比如我们有一个student的实体,那么我们的type可能就是student。那么key是什么呢,key就是具体哪个student的数据我们需要查看。因为student中很多条记录都可能发生变化,但我们可能只想看到某个学生,所以我们还需要一个key来定位哪个学生。

还有一些需求就是,比如我在页面上点击了一个按钮之后才会触发实体的变化,那么我现在该记录中添加一些remark,比如triggerPoint是click save button之类的。还有显示的时候可以添加上一些businessKey,这个怎么理解呢?就比如我修改了一个学生的电话号码。那我点开这个学生的历史记录中就会显示如下:

update KEVIN
phone: xxxxx -> yyyyyyy

那么其中KEVIN 就是一个businessKey,主要是展示给用户看得懂的一些字段。在这里businessKey就是学生的名字。这种需求在一对多的实体中显得尤为重要,一旦我们需要看到多的一方的实体变化,我们就需要使用businessKey让用户能确定是具体哪一条记录发送了改变了。

因为产生了很多比较奇怪但又必要的需求,所以这个日志模块的设计比较多功能,也比较复杂。小伙伴可以挑着去看每个功能的具体实现,因为一些需求所以会产生出一些你认为不必要的设计,所以仅仅只是作为参考,谢谢。

注解设计

根据以上的一些信息,我们可以设计出几个注解:

  1. AuditKey
    用来标记key是什么,默认是实体的Id,当然可以配置成其他字段,而且还支持SpEL表达式。
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface AuditKey 

  /**
   * Spring Expression Language (SpEL) attribute.
   */
  String expression() default "";

比如如下,我们就不使用默认的id作为key,而是使用code的值作为key。

@Data
@SuperBuilder(toBuilder = true)
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "xxxx")
@AuditTable(AuditLogDataTypeEnum.XXX) //标记该实体需要监听变化
public class XXXX extends JpaAuditableAuditLog 
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  
  @AuditKey
  private String code;

  1. AuditColumn
    用于记录哪些字段需要记录变化的,并且on这个值是用来标识该字段发生添加,修改,删除的某个时机才需要记录,不在该列表中的操作都不进行记录。
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface AuditColumn 

  String value();

  AuditActionEnum[] on() default AuditActionEnum.ADD, AuditActionEnum.DELETE, AuditActionEnum.UPDATE;

使用如下:

@Data
@SuperBuilder(toBuilder = true)
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "xxxx")
@AuditTable(AuditLogDataTypeEnum.XXX) //标记该实体需要监听变化
public class XXXX extends JpaAuditableAuditLog 
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  
  @AuditKey
  @AuditColumn("Code")
  private String code;

  1. AuditTable
    用于标识哪个实体需要监听字段变化,只有加上@AuditTable的实体才会监听变化,然后其中的value值跟上面提到的type 是一个概念,标记是什么实体,比如student
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface AuditTable 

  AuditLogDataTypeEnum value();

使用如下:

@Data
@SuperBuilder(toBuilder = true)
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "xxxx")
@AuditTable(AuditLogDataTypeEnum.XXX) //标记该实体需要监听变化
public class XXXX extends JpaAuditableAuditLog 
//...

  1. AuditBusinessKey
    用来标记实体中哪个是具体想展示给用户的BusinessKey (上文有讲解到)
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface AuditBusinessKey 


使用如下:

@Data
@SuperBuilder(toBuilder = true)
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "xxxx")
@AuditTable(AuditLogDataTypeEnum.XXX) //标记该实体需要监听变化
public class XXXX extends JpaAuditableAuditLog 
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  
  @AuditKey
  @AuditColumn("Code")
  @AuditBusinessKey //标注该字段的值作为BusinessKey
  private String code;

  1. RelatedAuditColumn
    针对于一对多的实体,用来标注多的一方。group的值主要用来分组。
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RelatedAuditColumn 

  AuditLogDataTypeEnum type();

  AuditLogDataTypeEnum group() default AuditLogDataTypeEnum.ANY;


使用如下:

  @OneToMany
  @Builder.Default
  @RelatedAuditColumn(type = AuditLogDataTypeEnum.XXX)
  private List<XXXX> xxx = new ArrayList<>();
  1. AuditLog
    用于标记在controller层的一些方法,用来实现上文中记录remark的功能,其中remarks的值,是可以设置多个remark
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface AuditLog 

  AuditRemark[] remarks() default ;

  1. AuditRemark
    用于配置remark的信息了,其中column是针对实体的那个字段设置remark,然后其他都是一些remark的信息,并且支持SpEL表达式,然后condition是在什么条件下这个remark才会生效
@Target(ElementType.ANNOTATION_TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface AuditRemark 

  AuditLogDataTypeEnum type() default AuditLogDataTypeEnum.ANY;

  String column() default "[any]";

  @AliasFor(annotation = AuditRemark.class, attribute = "value")
  String triggerPoint() default "";

  String remark() default "";

  @AliasFor(annotation = AuditRemark.class, attribute = "triggerPoint")
  String value() default "";

  /**
   * Spring Expression Language (SpEL) attribute.
   */
  String triggerPointExpression() default "";

  /**
   * Spring Expression Language (SpEL) attribute.
   */
  String remarkExpression() default "";

  /**
   * Spring Expression Language (SpEL) attribute.
   * <p>The default is @code "", meaning the event is always handled.
   */
  String condition() default "";

  /**
   * Spring Expression Language (SpEL) attribute.
   */
  String triggerByExpression() default "";

  String triggerBy() default "";


比如我们可以如下使用,表示为student实体的status中加上remark,如果不指定column,则会给所有变化的字段都加上remark

  @PutMapping("/student")
  @AuditLog(remarks = @AuditRemark(type = AuditLogDataTypeEnum.STUDENT, column = "status", triggerPoint = "Create Button"))
  public ResponseEntity<?> createStudent(
      @RequestBody StudentDTO studentDTO) 
    return ResponseEntity.ok(.....);
  

为了实现以上的一些需求,我们设计了这么多的注解,接下来我们来看看具体的监听器中是如何实现的。

EntityListener的具体实现

代码有一点多,我们先简单来分析一下。entityListener主要有两个方法我们需要主要关注的,一个就是@PostPersist方法,会在实体添加的时候触发,还有一个就是@PostUpdate方法。会在实体修改的时候触发。

接下来我们就是来处理实体的哪些字段有变化了。

  1. 首先我们只需要关注有带有@AuditTable的实体,没有带@AuditTable的实体,我们就不需要监听了。
  2. 我们只需要关注带@AuditColumn的字段还有带@RelatedAuditColumn的字段,这两个我们需要分开处理。因为带@RelatedAuditColumn的字段是多的一对多或者是一对一的另一方(需要分开实现),所以我们可能需要递归遍历这个实体里面的所有带@AuditColumn的字段
  3. 如何监测字段发生改变了呢?对于@PostPersist来说,新增的我们都记录,因为对于新增的来说,@AuditColumn的值都是从无变有,当然如果新的值还是null,那我们也无需关注。而对于@PostUpdate来说,我们需要获取修改前和修改后的实体,然后遍历比对每个带@AuditColumn的字段的值,如果有变化,就需要记录。所以这里需要createEntityManager 获取一个新的entityManager来获取修改前的数据,因为当前的entityManager获取到的已经是变化后的数据了。

大概的思路就是这样了,只是说具体的一些字段是list或者是一对一,一对多我们需要单独处理和递归遍历,然后最后也是按照上面的思路去监测字段变化。

@Configurable
@Slf4j
public class JpaAuditableLogListener 

  private static final String ENTITY_ID = "id";
  private final ExpressionParser parser = new SpelExpressionParser();

  @PostPersist
  public void handlePostPersist(JpaAuditableAuditLog jpaAuditable)  //处理添加的实体
    try 
      if (!jpaAuditable.getClass().isAnnotationPresent(AuditTable.class)) 
        return;
      
      handlePersist(jpaAuditable);
      publishEvent(jpaAuditable); //最后把生成的数据发送给日志模块
     catch (Exception e) 
      log.error("catch exception in JpaAuditableLogListener.handlePrePersist() method, exception detail:[]",
          ExceptionUtils.getStackTrace(e));
     finally 
      jpaAuditable.getAuditLogDataDTOList().clear();
    
  

  @PostUpdate
  public void handlePostUpdate(JpaAuditableAuditLog jpaAuditable)  //处理修改后的实体
    EntityManager entityManager = null;
    try 
      if (!jpaAuditable.getClass().isAnnotationPresent(AuditTable.class)) 
        return;
      
      ApplicationContext applicationContext = SpringApplicationContextUtil.getApplicationContext();
      if (Objects.isNull(applicationContext)) 
        return;
      
      entityManager = applicationContext.getBean(EntityManagerFactory.class).createEntityManager();
      JpaAuditableAuditLog originalEntity = entityManager.find(jpaAuditable.getClass(), getValueFromFieldName(jpaAuditable, ENTITY_ID)); //获取修改前的实体
      handleUpdate(jpaAuditable, originalEntity);
      publishEvent(jpaAuditable);
     catch (Exception e) 
      log.error("catch exception in JpaAuditableLogListener.handlePrePreUpdate() method, exception detail:[]",
          ExceptionUtils.getStackTrace(e));
     finally 
      jpaAuditable.getAuditLogDataDTOList().clear();
      if (Objects.nonNull(entityManager)) 
        entityManager.close();
      
    
  

  private void handlePersist(JpaAuditableAuditLog rootEntity)  
    List<Field> fieldList = getFieldsWithAnnotation(rootEntity, AuditColumn.class);
    rootEntity.getAuditLogDataDTOList().addAll(fieldList.stream().filter(field ->
        Arrays.asList(field.getAnnotation(AuditColumn.class).on()).contains(AuditActionEnum.ADD)
    ).map(field -> 
      AuditLogDataTypeEnum type = rootEntity.getClass().getAnnotation(AuditTable.class).value();
      Object toValue = getValueFromFieldName(rootEntity, field.getName());
      return getAuditLogDataDTO(rootEntity, type, type, field, null, toValue, AuditActionEnum.ADD);
    ).collect(Collectors.toList()));
    handleRelateAuditColumn(rootEntity, rootEntity, null); //单独处理RelateAuditColumn的case
  


  private void handleUpdate(JpaAuditableAuditLog rootEntity, JpaAuditableAuditLog originalEntity) 
    if (Objects.isNull(originalEntity)) 
      return;
    
    AuditLogDataTypeEnum type = rootEntity.getClass().getAnnotation(AuditTable.class).value();
    List<Field> fieldList = getFieldsWithAnnotation(rootEntity, AuditColumn.class);
    rootEntity.getAuditLogDataDTOList().addAll(fieldList.stream().filter(field ->
        Arrays.asList(field.getAnnotation(AuditColumn.class).on()).contains(AuditActionEnum.UPDATE)
    ).map(field -> 
      Object fromValue = getValueFromFieldName(originalEntity, field.getName());
      Object toValue = getValueFromFieldName(rootEntity, field.getName());
      return getAuditLogDataDTO(rootEntity, type, type, field, fromValue, toValue, AuditActionEnum.UPDATE);
    ).collect(Collectors.toList()));
    handleRelateAuditColumn(rootEntity, rootEntity, originalEntity);//单独处理RelateAuditColumn的case
  


  public void handleRelateAuditColumn(JpaAuditableAuditLog rootEntity, Object currentEntity, Object originalEntity) 
    List<Field> relatedAuditColumnFieldList = getFieldsWithAnnotation(currentEntity, RelatedAuditColumn.class);
    List<Field> originalEntityRelatedAuditColumnFieldList = getFieldsWithAnnotation(originalEntity, RelatedAuditColumn.class);
    if (CollectionUtils.isEmpty(relatedAuditColumnFieldList) && CollectionUtils.isEmpty(originalEntityRelatedAuditColumnFieldList)) 
      return;
    
    for (Field relatedAuditColumnField : (CollectionUtils.isEmpty(relatedAuditColumnFieldList) ? originalEntityRelatedAuditColumnFieldList
        : relatedAuditColumnFieldList)) 
      boolean isList = Objects.requireNonNull(ResolvableType.forField(relatedAuditColumnField).resolve()).isAssignableFrom(List.class);
      if (isList)  //需要区分是list还是非list的情况
        handleRelatedEntities(rootEntity, currentEntity, originalEntity, relatedAuditColumnField); //需要递归遍历处理
       else 
        handleRelatedEntity(rootEntity, currentEntity, originalEntity, relatedAuditColumnField);
      
    
  

  private void handleRelatedEntity(JpaAuditableAuditLog rootEntity, Object currentEntity, Object originalEntity, Field relatedAuditColumnField) 
    Object originalRelateEntity = getValueFromFieldName(originalEntity, relatedAuditColumnField.getName());
    Object currentRelateEntity = getValueFromFieldName(currentEntity, relatedAuditColumnField.getName());
    this.handleRelateAuditColumn(rootEntity, currentRelateEntity, originalRelateEntity);
    handleRelatedSingleEntity(rootEntity, relatedAuditColumnField, currentRelateEntity, originalRelateEntity);
  

  private void handleRelatedEntities(JpaAuditableAuditLog rootEntity

以上是关于基于Spring Boot Data JPA的通用audit log日志记录的设计和实现的主要内容,如果未能解决你的问题,请参考以下文章

初入spring boot(五 )Spring Data JPA

Spring Boot整合Spring Data JPA

Spring Boot学习进阶笔记-Spring-data-jpa

Spring Boot教程35——Spring Data JPA

Spring Boot Data JPA:休眠会话问题

Spring Boot + Spring Data JPA + PostgreSQL