在最近工作中,博主手頭上的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;
}
以上都是個人愚見,如有錯漏之處歡迎大家來找我交流,指正。