記錄用戶操作

在最近工作中,博主手頭上的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;
    }

以上都是個人愚見,如有錯漏之處歡迎大家來找我交流,指正。

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