2023年1月28日 星期六

Spring AOP 基本使用

去年年底終於幫公司的專案升級成 Spring Boot,下一個 wish list 就是把一些討厭的邏輯轉成 AOP 的方式插入,然後就可以去除掉一些討厭的關聯了。不過因為自己實際上還沒有真的玩過 AOP,所以這篇就紀錄一點基本的東西。

AOP

AOP 的主要概念就是橫向切入,或者換句話說是用外掛的方式,把本來不屬於程式碼的邏輯 “外掛” 到程式碼當中。這種作法最主要的優點就是,外掛上去的邏輯可以放在別的地方,不會直接跟主邏輯綁在一起,因此也不會影響到主邏輯自身的程式碼。舉例來說,原本的程式碼可能是 A -> B.method(),表示 class A 的某一行去呼叫 class B 的 method() 方法。套上 AOP 時,在編譯期間,程式碼的呼叫會被編譯成 A -> aop.method() -> B.method(),也就是說 A 跟 B 的呼叫過程被額外加入了 AOP 的邏輯。

外掛是怎麼做到的,就我目前的理解是有兩種作法,一種是直接用 code generation 的方式,在程式碼編譯期間把外掛的程式碼包進去。所以雖然原始碼中是寫 A 呼叫 B,但編譯後產生的中繼碼,實際上寫的是 A 呼叫 AOP、然後 AOP 呼叫 B。第二種作法是 runtime proxy,就是透過 reflection 的方式把 aop 掛到 A 和 B 之間。兩種作法的目的是一樣的,但第一種作法顯然執行效率會比較高,因為第二種作法是執行期間才動態做 proxy。依據 Spring AOP 的文件,第一種作法是在 Spring AOP 裡直接使用 AspectJ 的話,AspectJ 會使用的作法;而第二種作法則是 Spring AOP 預設採取的作法。

Spring AOP

Spring AOP 算是簡易版的 AspectJ,功能比較受限,例如 Spring AOP 只能做 method 的攔截,其他 field 攔截等等的是不支援的。詳細的說明可以參考 Spring AOP Capabilities and Goals

不過整體來說,最重要的一件事是,Spring AOP 只能夠對 Spring 管理的 bean 做攔截,這點很重要~。所以如果發現 AOP 一直沒發生效果,第一個先確認想要攔截的對象是不是被 Spring 管理的 bean。

Spring AOP 簡易範例

題外話,話說我在實驗時,有先叫 ChatGPT 產生範例程式碼給我,但結果它產生的程式碼其實有不少細節是錯的….。看起來 ChatGPT 把 Spring AOP 跟 AspectJ 混在一起使用的樣子…。

回歸正題 XD。這裡用一個很簡單的範例先展示 Spring AOP 的用法。首先需要引入 AOP 相關的 dependency,在 Spring Boot 可以簡單地放入 spring-boot-starter-aop 就好。

implementation("org.springframework.boot:spring-boot-starter-aop")

這個範例裡,我建立了一個 CommandLineRunner,作為 Spring Boot 的啟動對象。這個 Runner 裡面會隨機產生 10 個 Event,然後將 Event 餵給 FakeEventProcessor,所以我最後要被 AOP 插入的對象是 FakeEventProcessor。需要注意的是,這裡 FakeEventProcessor 是用注入的方式產生的,而不是自己呼叫 new,因為上面提到了 Spring AOP 只能對 Spring 管理的 bean 發生效用。

@Slf4j
@Component
public class Run implements CommandLineRunner {
    @Autowired
    private FakeEventProcessor processor;

    @Override
    public void run(String... args) throws Exception {
        int loop = 10;
        while(loop-- > 0) {
            var event = generateRandomEvent();
            processor.receiveEvent(event);
        }
    }
}

@Builder
@Getter
@Accessors(fluent = true)
@ToString
public class Event {
    private String eventType;
    private String owner;
}

@Slf4j
@Component
public class FakeEventProcessor {
    public void receiveEvent(Event event) {
        log.info("Receive: {}", event);
    }
}

接著宣告一個 Aspect,目的是我想要攔截 FakeEventProcessor 每次呼叫 receiveEvent() 的時候,都在它被呼叫前先呼叫我要插入的邏輯。

@Slf4j
@Aspect
@Component
public class TelemetryAspect {

    public TelemetryAspect() {
        log.info("Initiate aspect...");
    }

    @Before("execution(* tw.jimwayneyeh.example.otel.FakeEventProcessor.receiveEvent(..))")
    public void before(JoinPoint joinPoint) {
        Event event = (Event) joinPoint.getArgs()[0];
        log.trace("join '{}()': {}", joinPoint.getSignature().getName(), event);
    }
}

這裡我使用的攔截方式是 @Before,表示在目標被執行前先執行攔截的邏輯,並且攔截的對象是 @Before 裡定義的 method tw.jimwayneyeh.example.otel.FakeEventProcessor.receiveEvent(..)。而攔截後,我要拿出傳入的 event 並且印出 log,這樣我才能確認它攔截到的是什麼東西。

最後這段程式碼跑起來,會跑出以下的結果:

TRACE t.j.e.o.TelemetryAspect [main] join 'receiveEvent()': Event(eventType=delete, owner=owner-0)
INFO  t.j.e.o.FakeEventProcessor [main] Receive: Event(eventType=delete, owner=owner-0)
TRACE t.j.e.o.TelemetryAspect [main] join 'receiveEvent()': Event(eventType=update, owner=owner-1)
INFO  t.j.e.o.FakeEventProcessor [main] Receive: Event(eventType=update, owner=owner-1)
TRACE t.j.e.o.TelemetryAspect [main] join 'receiveEvent()': Event(eventType=update, owner=owner-6)
INFO  t.j.e.o.FakeEventProcessor [main] Receive: Event(eventType=update, owner=owner-6)
...(ignored)

這裡只列了最開始三行,因為後面都差不多 XD。可以看到針對每個 event 的呼叫,攔截的 log 會先出現(join 開頭的 log),然後才出現原本 method 執行的寫 log(Receive 開頭的 log),並且兩個 log 印出來的 event 是完全一樣的,表示 Aspect 確實被插入到 method call 之前了。

沒有留言: