记录用户操作

在最近工作中,博主手头上的Web项目提了一个新的需求,这个需求大体上就是希望系统能够记录用户做了哪些操作,包括这些操作的细节。

大家都知道,用户的操作无非就是增,删,改,查。因为我现在做的这个项目对用户的查询操作不敏感,所以只需记录增,删,改。

在做之前呢,我的leader给了我一个建议:你要不写一个公共方法吧,到时候哪一个模块需要这个功能就让他自己去调用。这当然是一个方法,而且相对省事,因为我只需要将公共的逻辑抽象出来,之后就算有新的模块添加进项目来,只要这块的功能逻辑不发生大的改变,也就没我啥事了。但是我的内心是拒绝的,因为我早就听说AOP十分适合权限,日志等功能的实现,而且我之前从没有用AOP的机会,现在菜都给送到眼前了,难道还有不吃的道理吗?所以我跟Leader说:这块很适合用AOP来做啊。然后一副我很精通AOP的样子看着Leader。Leader:我还是建议你用公共方法......

OK, 没有完全拒绝那就是认同了,好的,就让我们用AOP的方法来实现这个功能吧。

1.表字段的设计

既然是做记录,而且我打算将这些操作记录放在一张表里,那么先将操作公共的字段定下来。作为主键的ID肯定是必须的,但是这个主键不适合再使用自增ID,很快就会被消耗完。所以这里使用随机字符串。下面是实现随机字符串的工具类和记录实体部分的code。

public class UUIDGenerator implements IdentifierGenerator {

    @Override
    public Serializable generate(SharedSessionContractImplementor session, Object object) throws HibernateException {
        return UUID.randomUUID().toString().replace("-", "").toLowerCase();
    }
}


    @Id
    @GeneratedValue(generator = "system-uuid")
    // xx部分是UUIDGenerator类的绝对地址
    @GenericGenerator(name = "system-uuid", strategy = "xx.xx.xx.xxxx.xx.UUIDGenerator")
    private String id;

除此之外还需要有用户操作的模块名(实体名), 用户操作的模块id(实体id),实体名,操作类型,操作人(用户),操作时间。

以上是增,删操作所需要的公共字段,但是更新操作有一些不同。我们对一个实体进行操作删除和新增,数据可能只需插入一条对应记录;修改(更新)操作对应了一个实体的多个字段,映射到关系型数据库可能就是多条记录。并且更新之后,需要记录下更新前和更新后的值以及操作的字段名。很好,思考到这里,我们的实体类雏形就出来了,就叫他UserLog吧。

 // 实体id
 @Column(name = "entity_id")
 private Long entityId;
    
 // 实体类型
 @Column(name = "entity_type")
 @Enumerated(value = EnumType.STRING)
 private EntityType entityType;

 // 实体名
 @Column(name = "entity_name")
 private String entityName;

 // 操作类型
 @Column(name = "operation_name")
 private String operationName;

 // 操作的字段名
 @Column(name = "field_name")
 private String fieldName;

 // 操作之前的值
 @Column(name = "old_value")
 private String oldValue;

 // 操作之后的值
 @Column(name = "new_value")
 private String newValue;

 // 操作的用户名
 @Column(name = "user_name")
 privatet String userName;

 // 用户操作的时间
 @Column(name = "time")
 private Instant time = Instant.now();

2.切面(AOP)逻辑

所以为什么要使用切面呢?

首先,日志是整个项目公共的逻辑,许多模块都会有涉及;其次,有利于代码的维护和拓展,能够减少代码侵入,也就说,尽量不要去修改之前已经写好的代码,符合设计模式里的开闭原则(有兴趣的同学可以去了解一下设计模式),之前提出的公共方法方案就无法做到这一点。AOP基于代理模式,关于AOP的原理此处就不再赘述,小伙伴们可以自行上网搜索。

我们在使用某种工具或者在项目中引入依赖之前应该先问一下自己(也有可能是问问Leader),在一个项目中引入一项技术的最终目的并不是为了炫技,而是更好更加方便地解决某一个问题。依赖不是越多越好,繁多框架的堆叠也不是为了显得项目更高端。最终的结果可能会是代码难以维护,修复了1个bug然后新增了19个bug。确定要引入之后,如何使用就是接下来要思考的问题了。

考虑到灵活性以及项目本身的目录结构和编码简易性,我决定采用自定义注解的方式进行切点拦截(@Pointcut),即对标明了自定义注解的方法进行拦截。

说实话,AOP这一块的专有名词挺多的,第一次看的小伙伴可能不太理解。我这里就用一套不太严谨的说辞来帮助大家简单理解一下:AOP或者说代理模式,是对原有的,被代理的方法的增强,我可以在被代理的方法之前,之后,或者是抛出异常时执行一段我想执行的代码,而@Pointcut注解就是在指定你要的切入点,你要对哪些方法进行代理。

那么,可能有的小伙伴就会问了:这么麻烦,我为什么不在我的方法里写呢,搞这么花里胡哨的?

这是一个好问题,首先日志一块公共的逻辑,可以剥离出来,其次是为了写出更好的,高质量的代码(低耦合),这又要说到设计模式的好处了,他确实能大大降低后期维护成本,减少需求变更所带来的工作量。

自定义注解如下:

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface OperationLog {

    // 操作类型
    Operation operation() default Operation.GET;

    // 操作目标
    AuditType moduleType() default AuditType.PROJECT;
}

将这个注解加到public方法上,我这里选择的是service层的方法。

    // 新增
    @OperationLog(operation = Operation.ADD, moduleType = AuditType.XXX)
    @Override
    public CRUDResult addOperationRecord(Long id, OperationRecordModule add) {

    }

    // 更新
    @OperationLog(operation = Operation.UPDATE, moduleType = AuditType.XXX)
    @Transactional
    @Override
    public CRUDResult updateModule(Long id, ModuleDTO module) {

    }

关于@Pointcut更多的用法,有兴趣的同学可以自行探索。

接下来就是最核心的部分了,切面类的编写。对于实体的增加,删除,更新操作,我们需要用到AOP的通知型注解。目前共有以下5种:

  1. @Before 前置通知,在方法执行之前执行
  2. @After    后置通知,在方法执行之后执行
  3. @Around 环绕通知,由你来指定方法什么时候执行。所以你可以在执行之前,之后分别运行一段代码
  4. @AfterRunning  返回通知
  5. @AfterThrowing 异常通知

对于用户的新增,删除,修改操作,我们必须在原有被代理方法执行之后,原有方法没有抛出异常,我们才能算用户这次操作是成功的,将这次操作录入数据库。对于删除操作,一旦完成,原有的实体记录就不再存在,所以如果想要记录与该实体相关联的其他实体的信息,必须在被代理方法执行前进行查询;更新操作也是如此,我们需要在更新操作完成之前查询数据库中之前的实体信息,在更新操作完成后才能比较修改的字段。综上所述,可以使用@Around注解。

通常我们与前端交互使用DTO来进行,可以比较新旧DTO的数据来得出修改的字段,ItemNameMapping是我的自定义注解,将英文字段名映射成中文,代码如下:

 private <T> List<UserLog> compareDiff(T oldBean, T newBean) throws Exception{
        List<UserLog> userLogs = new ArrayList<>();
        Class<?> clazz = oldBean.getClass();
        Field[] fields = clazz.getDeclaredFields();
        for(Field f: fields) {
            String name = f.getName();
            if(name.equals("serialVersionUID")) continue;
            if(f.isAnnotationPresent(ItemNameMapping.class)) {
                name = f.getAnnotation(ItemNameMapping.class).mapping();
            }
            PropertyDescriptor pd = new PropertyDescriptor(f.getName(), clazz);
            Method method = pd.getReadMethod();
            Object o1 = method.invoke(oldBean);
            Object o2 = method.invoke(newBean);
            UserLog a = null;
            if(o1 == null && o2 == null) {
                continue;
            } else if(o1 != null && o2 != null) {
                if(!o1.toString().equals(o2.toString())) {
                    a = buildUserLog(o1, o2, name);
                }
            } else {
                a = buildUserLog(o1, o2, name);
            }
            if(a != null) {
                userLogs.add(a);
            }
        }
        return userLogs;
    }

AOP核心代码如下:

    @Around("point()")
    public Object aroundAnnotation(ProceedingJoinPoint joinPoint) throws Throwable {
        Object[] args = joinPoint.getArgs();
        MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();
        Method method = methodSignature.getMethod();
        OperationLog log = method.getAnnotation(OperationLog.class);
        Operation operation = log.operation();
        Object proceed;
        if(operation.equals(Operation.UPDATE)) {
            proceed = update(args, log, joinPoint);
        } else if(operation.equals(DELETE)) {
            proceed = delete(args, log, joinPoint);
        } else {
            proceed = joinPoint.proceed();
        }
        return proceed;
    }

以上都是个人愚见,如有错漏之处欢迎大家来找我交流,指正。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章