【Spring Boot】第20課-切面導向程式設計(AOP)

切面導向程式設計(Aspect Oriented Programming,AOP)是一種特別的開發方式,它的目的是將許多方法的共同行為抽離出來。聽起來有點像物件導向程式設計(Object Oriented Programming,OOP),但 AOP 能做到原方法不需要呼叫,並於指定的時機點觸發。這樣的特性將便於開發與維護。

本文首先介紹 AOP 的概念,透過印 log 的方式,讓讀者體會效果。隨後再以範例專案中寄送信件的共同行為為例子,示範將這個行為分離,達到專注職責與降低依賴。

本文的練習用專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch19

一、AOP 的基本概念

若許多地方都有重複的程式碼,我們可能會基於 OOP 的概念,將邏輯封裝於類別中。如此可方便重複使用,維護時也只要修改一處,所有地方都會生效。

當我們想要在某些方法的執行前、執行後或拋出例外時,都額外做相同的動作(以下稱共通工作),勢必會在需要的地方都寫下一行呼叫的程式,這些重複的部份是 OOP 無法改善的。舉例來說,紀錄日誌、安全檢查等,都是共通工作的例子。另一方面,為了呼叫方法以進行共通工作 ,可能還需要依賴其他的類別,提高元件的耦合度。

AOP 是將這些共通工作從原方法「分離」出來,並設定某個時機點才執行,有點像資料庫的「觸發程序」(trigger)。此處的分離,除了在另一個類別實作之外,更重要的是原方法「不需要呼叫」。

舉例來說,在本文的練習用專案,ProductServicecreateProductdeleteProduct 方法,在最後都有呼叫 MailService 寄送信件的共通工作。經過分離,這兩個方法的實作內容就純粹與建立、刪除產品相關,但寄信的動作一樣會執行。且 ProductService 不必再依賴 MailService

原方法與共通工作之間的關係,起初是由原方法主動去執行。在實踐 AOP 後,變成像是共通工作被悄悄地附加到原方法上,而該方法的程式碼不需要變動。


二、切面的實作

對 AOP 有基本認識後,本節就來實作它。Spring Boot 提供了函式庫,讓開發者能輕鬆地實作 AOP。請在  pom.xml 檔案中匯入。

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>

本節我們會透過印 log 的範例,認識 AOP 的相關名詞,並觀察其效果。請建立一個叫 LogAspect 的類別,並加上 @Component 標記,讓它能被建立成元件。而另外加上的 @Aspect 標記是為了讓 Spring Boot 將這個類別視為切面(Aspect)。

@Component
@Aspect
public class LogAspect {

@Pointcut("execution(* com.vincent.demo.service..*(..))")
public void pointcut() {
}

@Before("pointcut()")
public void before(JoinPoint joinPoint) {
System.out.println("=====before advice starts=====");
System.out.println(getMethodName(joinPoint));
Arrays.stream(joinPoint.getArgs()).forEach(System.out::println);
System.out.println("=====before advice ends=====");
}

private String getMethodName(JoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
return signature.getName();
}
}

以上的切面類別有三個方法。第一個方法冠上了 @Pointcut 標記,意思為「切入點」,且不需要實作內容。標記中的參數是一種表示式,用來匹配哪些方法要套用共通工作。此處意味著要套用到 com.vincent.demo.service 這個套件及子套件下所有元件類別的 public 方法。

第二個方法是共通工作,也就是印 log。它具備了 JoinPoint 型態的參數,意思是「連接點」,代表被 Pointcut 匹配到的方法。此處的實作內容,是獲取執行中的方法,印出其名稱。接著再呼叫 JoinPointgetArgs 方法,逐一印出方法所接收的參數。若讀者不需要獲取執行方法的資訊,也可以直接把 JoinPoint 參數拿掉。

共通工作可看做是 Aspect 建議原方法額外執行的工作,因此被稱為「Advice」。我們可指定 Advice 的執行時機,此處在方法冠上 @Before 標記,代表它會在原方法執行前,先執行此處的程式。至於標記裡的參數,則是指定該 Advice 要套用到哪些方法。此處直接傳入 pointcut 這個方法名稱,以參照的方式處理。讀者也能直接傳入表示式。

第三個方法是取出 JoinPoint 物件中的方法資訊。主要是利用「Java Reflection」得到 Method 物件,再取出方法名稱。

完成後,可試著對 API 發出請求,看看效果。下面筆者呼叫更新產品的 API,也就是「PUT /products/{id}」。

在 Console 視窗能看到印出的 log,包含方法名稱與參數值。第一組 before 區塊是 Spring Security 機制查詢使用者身份時所而產生的,Join Point 為 AppUserServicegetUserByEmail 方法;第二組則是更新產品時所產生,Join Point 為 ProductServicereplaceProduct 方法。

以上便是一個簡單的 Aspect(切面),透過 Pointcut(切入點)的規則去匹配符合條件的 Join Point(連接點,即方法),並套用 Advice(建議,即共通工作)。

三、Advice 的執行時機

在第二節的範例程式,我們將印 log 這項工作,設定在方法執行前執行。本節會介紹 Advice 還能在什麼時候被執行。首先請參考以下程式碼。

透過 @After 標記,可將 Advice 設定在方法執行後執行。即使方法發生例外,這邊的邏輯也會執行。

@After("pointcut()")
public void after(JoinPoint joinPoint) {
System.out.println("=====after advice starts=====");
System.out.println("=====after advice ends=====");
}

透過 @AfterReturning 標記,可將 Advice 設定在方法返回後才執行。但若方法發生例外,則不執行。在標記中有個 returning 的參數,它能夠將方法的回傳值,賦予給 Advice 相同名稱的參數。此處印出回傳值(如果有的話)。

@AfterReturning(pointcut = "pointcut()", returning = "result")
public void afterReturning(JoinPoint joinPoint, Object result) {
System.out.println("=====after returning advice starts=====");
if (result != null) {
System.out.println(result);
}
System.out.println("=====after returning advice ends=====");
}

透過 @Around 標記,可實作同時涵蓋方法執行前後的 Advice。它的方法參數包含 ProceedingJoinPoint 的物件,需呼叫其 proceed 方法,才會開始執行原方法。此處印出花費時間。

@Around("pointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
System.out.println("=====around advice starts=====");
long startTime = System.currentTimeMillis();
Object result = joinPoint.proceed();
long spentTime = System.currentTimeMillis() - startTime;
System.out.println("Time spent: " + spentTime);
System.out.println("=====around advice ends=====");

return result;
}

透過 @AfterThrowing 標記,可將 Advice 設定在方法拋出例外後執行。在標記中有個 throwing 的參數,它能夠將原方法所拋出的例外物件,賦予給 Advice 相同名稱的參數。此處印出方法名稱、接收的參數,以及錯誤訊息。

@AfterThrowing(pointcut = "pointcut()", throwing = "throwable")
public void afterThorwing(JoinPoint joinPoint, Throwable throwable) {
System.out.println("=====after throwing advice starts=====");
System.out.println(getMethodName(joinPoint));
Arrays.stream(joinPoint.getArgs()).forEach(System.out::println);
System.out.println(throwable.getMessage());
System.out.println("=====after throwing advice ends=====");
}

經由 @Pointcut 標記,我們可決定哪些方法要套用這些 Advice。由於 Spring AOP 函式庫的特性,被 Pointcut 匹配到的方法,它們所屬的類別會被 Spring「動態代理」(dynamic proxy),從而產生出元件的代理類別。並將 Advice 的內容帶入,最後建立成元件(bean)。讀者可想成「原類別 + Advice = 代理類別 」,代理類別仍具有原類別的行為。

上述的動態代理會帶來什麼影響呢?當我們在程式中呼叫另一個元件的方法,即 A 元件呼叫 B 元件時,Advice 才會執行。這是因為注入到 A 元件的 B 元件屬於代理物件,只有代理物件才會具備 Advice。若在 B 元件中呼叫自己的方法,並不會執行 Advice,要特別注意。


四、匹配具有特定標記的方法

前面透過 @Pointcut 標記的表示式,我們以指定套件的方式匹配一些方法。在第四、五節會設計一個標記(annotation)冠在方法上,並以指定標記的方式讓來匹配。接著於 Advice 方法中取得標記中的資訊,實現更有變化的處理方式。

下面筆者建立了叫做 @SendEmail 的標記,它接收了三個參數。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SendEmail {
EntityType entity();
ActionType action();
int idParamIndex() default -1;
}

EntityType 代表處理何種對象(如產品、使用者)。

public enum EntityType {
PRODUCT("product"), APP_USER("user");

private String presentName;

EntityType(String presentName) {
this.presentName = presentName;
}

@Override
public String toString() {
return presentName;
}
}

ActionType 代表對該對象做了何種操作(如建立、刪除)。

public enum ActionType {
CREATE, UPDATE, DELETE;
}

idParamIndex 代表對象的 ID 位於方法參數的第幾個(如果有的話)。

我們希望被冠上 @SendEmail 標記的方法,在成功執行完返回後寄送信件。而信件內容則根據標記中的參數來決定。

標記實作完成後,請讀者參考以下範例程式,冠在 ProductServiceAppUserService 的方法上。並可去除 ProductServiceMailService 的依賴、MailServiceUserIdentity 的依賴,以及 MailService 中寄送產品通知信的方法。

public class ProductService {
// 其餘略過

@SendEmail(entity = EntityType.PRODUCT, action = ActionType.CREATE)
public ProductResponse createProduct(ProductRequest request) {
// 其餘略過
}

@SendEmail(entity = EntityType.PRODUCT, action = ActionType.UPDATE, idParamIndex = 0)
public ProductResponse replaceProduct(String id, ProductRequest request) {
// 其餘略過
}

@SendEmail(entity = EntityType.PRODUCT, action = ActionType.DELETE, idParamIndex = 0)
public void deleteProduct(String id) {
// 其餘略過
}
}

public class AppUserService {
// 其餘略過

@SendEmail(entity = EntityType.APP_USER, action = ActionType.CREATE)
public AppUserResponse createUser(AppUserRequest request) {
// 其餘略過
}
}

接著建立一個叫 SendEmailAspect 的切面類別,我們將實作有關信件寄送內容的邏輯,包含主旨與訊息。類別中只宣告字串模板,在第五節會插入使用者名稱、操作對象的種類與 id,來組成實際內容。

@Component
@Aspect
public class SendEmailAspect {
@Autowired
private UserIdentity userIdentity;

@Autowired
private MailService mailService;

private static final Map<ActionType, String> SUBJECT_TEMPLATE_MAP;
private static final Map<ActionType, String> MESSAGE_TEMPLATE_MAP;

static {
SUBJECT_TEMPLATE_MAP = new EnumMap<>(ActionType.class);
SUBJECT_TEMPLATE_MAP.put(ActionType.CREATE, "New %s");
SUBJECT_TEMPLATE_MAP.put(ActionType.UPDATE, "Update %s");
SUBJECT_TEMPLATE_MAP.put(ActionType.DELETE, "Delete %s");

MESSAGE_TEMPLATE_MAP = new EnumMap<>(ActionType.class);
MESSAGE_TEMPLATE_MAP.put(ActionType.CREATE, "Hi, %s. There's a new %s (%s) created.");
MESSAGE_TEMPLATE_MAP.put(ActionType.UPDATE, "Hi, %s. There's a %s (%s) updated.");
MESSAGE_TEMPLATE_MAP.put(ActionType.DELETE, "Hi, %s. A %s (%s) is just deleted.");
}

@Pointcut("@annotation(com.vincent.demo.aop.SendEmail)")
public void pointcut() {
}

@AfterReturning(pointcut = "pointcut()", returning = "result")
public void sendEmail(JoinPoint joinPoint, Object result) {
// TODO
}
}

類別中的 pointcut 方法,也定義了要匹配具有 @SendEmail 標記的方法。

五、結合標記實作 Advice

上一節宣告好切面所需要的常數與方法,本節就來完成 Advice 的實作。

@Component
@Aspect
public class SendEmailAspect {
// 其餘略過

@AfterReturning(pointcut = "pointcut()", returning = "result")
public void sendEmail(JoinPoint joinPoint, Object result) {
if (userIdentity.isAnonymous()) {
return;
}

SendEmail annotation = getAnnotation(joinPoint);
String subject = composeSubject(annotation);
String message = composeMessage(annotation, joinPoint, result);

mailService.sendMail(subject, message, userIdentity.getEmail());
}

private SendEmail getAnnotation(JoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
return signature.getMethod().getAnnotation(SendEmail.class);
}

private String composeSubject(SendEmail annotation) {
String template = SUBJECT_TEMPLATE_MAP.get(annotation.action());
return String.format(template, annotation.entity());
}

private String composeMessage(SendEmail annotation, JoinPoint joinPoint, Object entity) {
String template = MESSAGE_TEMPLATE_MAP.get(annotation.action());

int idParamIndex = annotation.idParamIndex();
String entityId = idParamIndex == -1
? getEntityId(entity)
: (String) joinPoint.getArgs()[idParamIndex];

return String.format(template,
userIdentity.getName(), annotation.entity(), entityId);
}

private String getEntityId(Object obj) {
try {
Field field = obj.getClass().getDeclaredField("id");
field.setAccessible(true);
return (String) field.get(obj);
} catch (NoSuchFieldException | IllegalAccessException e) {
e.printStackTrace();
return "";
}
}
}

@AfterReturning 的 Advice 方法,需先判斷 API 的請求方是否為已驗證(可想成已登入)的使用者。若未驗證,則無法取得請求方的資訊以填入信件內容,因此不執行寄信。關於 UserIdentity 元件,是筆者在第19課所實作的元件。它能從 Spring Security 的 Context 獲取 API 請求方的使用者資訊,讀者可前往參考。

在後端得知請求方身份的情況下,我們便可開始產生信件內容。首先呼叫 getAnnotation 方法,從原執行方法獲取 @SendEmail 的標記。

接著呼叫 composeSubjectcomposeMessage 方法,它們會根據標記所接收的 ActionType 獲取對應的主旨與訊息模板。主旨的部份,僅需於模板填入 EntityType。訊息則需填入請求方的使用者名稱、操作對象的種類與 id。

操作對象 ID 的取得方式比較特別。讀者可以注意到,被冠上 @SendEmail 標記的方法,有的有 id 參數,而有的沒有,但有回傳值。此處筆者根據標記是否有提供 idParamIndex 參數,決定是從方法參數獲取對象 ID,或者呼叫 getEntityId 方法,利用 Java Reflection,從回傳值的 id 欄位取得。

最後將主旨與訊息組好後,即可呼叫 MailService 將信件寄出,收件者是請求方自己。

若讀者想要嘗試收到信後的效果,請在資料庫將使用者的信箱地址改為真實的。並參考第13課,在「mail.properties」配置檔中,準備好郵件伺服器的環境參數,如此才會真正收到信。

經由這兩節的實作,我們透過 AOP 的設計方式,將呼叫寄信的動作從 ProductServiceAppUserService 分離出來。除了讓方法關注在自身的業務邏輯,也讓元件降低依賴。


六、其他 Pointcut 表示式

前面的章節使用了兩種 Pointcut 表示式,分別為「execution」與「@annotation」。而 Spring AOP 的函式庫尚提供其他表示式,有興趣的讀者,可參考以下網站。
https://cloud.tencent.com/developer/article/1497814

本節會針對第二節使用的 execution 表示式做說明。以 * com.vincent.demo.service..*.*(..) 的表示式來看,我們依序拆解成幾個部份。元件方法需全部符合,才能被匹配到。

  1. 回傳值型態規則:此處使用 *,代表所有回傳值。若有指定的型態,需給予完整套件路徑,如 com.vincent.demo.entity.product.ProductResponse
  2. 套件與類別規則:此處使用 com.vincent.demo.service..*,代表該套件及子套件下的所有類別。若不想包含子套件,可寫成 com.vincent.demo.service.*;若想直接指定類別,則換成完整套件路徑即可,如 com.vincent.demo.service.ProductService
  3. 方法名稱規則:此處使用 .*,代表不限定方法名稱。若想加以限定,例如匹配名稱以「create」字眼開頭的方法,可寫成 create*
  4. 方法參數規則:此處使用 (..),代表不限定參數為何。若想加以限定,例如只有一個字串參數,可寫成 (String);第一個參數是字串,其餘不限,可寫成 (String,..);第一個參數是字串,第二個是ProductRequest,可寫成 (String,com.vincent.demo.entity.product.ProductRequest);無任何參數,可寫成 ()

回傳值型態、方法名稱與參數規則,在 Pointcut 表示式是必備的。而其他可以略過,代表不限制。

本文的完成專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch20

上一篇:【Spring Boot】第17.6課-實作 Spring Security 的認證 Filter(以 JWT 為例)

下一篇:【Spring Boot】第21課-使用 Mockito 進行單元測試

留言