在最近工作中,博主手头上的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种:
- @Before 前置通知,在方法执行之前执行
- @After 后置通知,在方法执行之后执行
- @Around 环绕通知,由你来指定方法什么时候执行。所以你可以在执行之前,之后分别运行一段代码
- @AfterRunning 返回通知
- @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;
}
以上都是个人愚见,如有错漏之处欢迎大家来找我交流,指正。