Aspect-Oriented Programming trong Spring

Xem thử

Một nhóm có mười hai service và yêu cầu mới: ghi log thời gian chạy của mọi thao tác ghi dữ liệu. Cách đầu tiên là thêm hai dòng đo thời gian vào đầu và cuối của từng phương thức: bốn mươi chỗ sửa, và một tuần sau lại có người thêm phương thức mới mà quên. Cách thứ hai: viết một đoạn mã đo thời gian duy nhất và nói "áp dụng cho mọi phương thức có annotation @Audited". Đó là lập trình hướng khía cạnh (AOP): một mối quan tâm cắt ngang (log, thời gian, kiểm toán, giao dịch, bảo mật) được viết một lần và áp dụng bằng quy tắc thay vì chép vào từng nơi. Nhưng cách thứ hai có một cái bẫy mà cả @Transactional và @PreAuthorize bạn đã gặp đều mang: nó hoạt động qua proxy, và có những lời gọi proxy không bao giờ thấy.

Bài này dựng một aspect nhỏ và đo mọi thứ nó làm: bean thật sự là gì khi bạn tiêm nó, thứ tự các loại advice, thứ tự giữa hai aspect, và ba loại lời gọi mà aspect bỏ qua (gọi bằng this, phương thức private, phương thức final). Phần final cho một kết quả đáng ngạc nhiên.

Sau bài này, bạn có thể

  • Giải thích aspect, advice, pointcut và join point bằng ví dụ chạy được.
  • Phân biệt proxy CGLIB và proxy JDK, và biết cái nào được dùng mặc định.
  • Nêu thứ tự @Around, @Before, @AfterReturning, @After và thứ tự lồng của nhiều aspect bằng @Order.
  • Nhận ra ba loại lời gọi không được tăng cường (this, private, final) và thiết kế tránh chúng.

Chuẩn bị môi trường và dự án

Mọi thí nghiệm của bài chạy trong một dự án Maven nhỏ. Mục này nói rõ cần cài gì, khai báo thư viện nào và cấu hình ra sao; mã của từng lớp được trình bày ngay tại chỗ nó được dùng, kèm giải thích, nên bạn không cần mở nơi nào khác. Số liệu và thông báo lỗi trong bài được đo từ chính mã đó (JDK 17, Spring Boot 4.1.1, Spring Framework 7.0.9). Bản sao lưu cả dự án nằm ở kho mã của khóa học, thư mục 12-aop/01-aop-proxy, chỉ để bạn clone cho nhanh nếu không muốn tự gõ lại.

Yêu cầu môi trường

Thành phầnPhiên bản đã kiểm chứngGhi chú
JDK17 trở lênjava -version
Maven3.9 trở lênmvn -v
Spring Boot4.1.1khai báo ở parent trong pom.xml; quản lý Spring Framework 7.0.9 và AspectJ
Dockerkhông cầnbài này không dùng cơ sở dữ liệu

Cấu trúc

text
01-aop-proxy/
  pom.xml                       parent Boot 4.1.1, starter aspectj, test
  src/main/java/dev/canhdominich/boot/aop/
    AopApplication.java         @SpringBootApplication
    Events.java                 ghi lại các sự kiện theo thứ tự
    Audited.java                annotation đánh dấu phương thức cần ghi kiểm toán
    OrderService.java           lớp có phương thức public, @Audited, gọi nội bộ, final và private
    PriceCalculator.java, PricingService.java   interface và cài đặt để so proxy JDK với CGLIB
    LoggingAspect.java          @Around, @Before, @AfterReturning, @After (thứ tự 2)
    OuterAspect.java            aspect bên ngoài (thứ tự 1)
    AopDemo.java                chạy mọi thí nghiệm

Khai báo thư viện: pom.xml

pom.xml

Dự án Maven dùng spring-boot-starter-parent 4.1.1 với spring-boot-starter-aspectj (Spring AOP và AspectJ cho cú pháp @Aspect và biểu thức pointcut) và spring-boot-starter-test. Không có web, cơ sở dữ liệu hay Docker. Plugin exec-maven-plugin chạy AopDemo bằng mvn -q compile exec:exec.

xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
    </parent>

    <groupId>dev.canhdominich.javabackend</groupId>
    <artifactId>aop-proxy</artifactId>
    <version>1.0.0</version>
    <name>AOP - Aspect va proxy</name>

    <properties>
        <java.version>17</java.version>
        <main.class>dev.canhdominich.boot.aop.AopDemo</main.class>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aspectj</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>exec-maven-plugin</artifactId>
                <configuration>
                    <executable>java</executable>
                    <arguments>
                        <argument>-classpath</argument>
                        <classpath/>
                        <argument>${main.class}</argument>
                    </arguments>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

Các khái niệm qua mã

Bài dùng spring-boot-starter-aspectj: nó kéo spring-aop và aspectjweaver, thư viện cung cấp cú pháp @Aspect và biểu thức pointcut. Bốn khái niệm:

  • Aspect: một lớp gom mã xuyên suốt (ở đây LoggingAspect, OuterAspect), đánh dấu @Aspect.
  • Advice: đoạn mã chạy tại một điểm (@Around, @Before, @AfterReturning, @After).
  • Pointcut: biểu thức chọn những phương thức nào được tăng cường, ví dụ execution(public * ...OrderService.*(..)) hoặc @annotation(Audited).
  • Join point: một điểm cụ thể trong lúc chạy, ở Spring luôn là một lời gọi phương thức.

Các thành phần của bài. Events ghi lại sự kiện theo thứ tự để mọi điều dưới đây đo được thay vì phỏng đoán:

src/main/java/dev/canhdominich/boot/aop/Events.java

java
package dev.canhdominich.boot.aop;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public final class Events {

    private static final List<String> LOG = Collections.synchronizedList(new ArrayList<>());

    private Events() {
    }

    public static void record(String event) {
        LOG.add(event);
    }

    public static void clear() {
        LOG.clear();
    }

    public static List<String> all() {
        synchronized (LOG) {
            return new ArrayList<>(LOG);
        }
    }
}

Audited là một annotation tự định nghĩa để đánh dấu phương thức cần ghi kiểm toán; pointcut @annotation(Audited) sẽ chọn các phương thức mang nó:

src/main/java/dev/canhdominich/boot/aop/Audited.java

java
package dev.canhdominich.boot.aop;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Audited {
}

OrderService là lớp được tăng cường. Nó có đủ các loại phương thức để thử: place (public, có @Audited, gọi một phương thức private), cancel (public, không @Audited), outer (gọi this.place), receipt (final), và secret (private). Trường prefix được gán trong constructor để thí nghiệm final thấy được trạng thái của đối tượng thật:

src/main/java/dev/canhdominich/boot/aop/OrderService.java

java
package dev.canhdominich.boot.aop;

import org.springframework.stereotype.Service;

@Service
public class OrderService {

    private final String prefix;

    public OrderService() {
        this.prefix = String.valueOf("order-");
    }

    @Audited
    public String place(String item) {
        Events.record("body: place " + item);
        return secret(item);
    }

    public String cancel(String item) {
        Events.record("body: cancel " + item);
        return "cancelled " + item;
    }

    public String outer(String item) {
        Events.record("body: outer " + item);
        return this.place(item);
    }

    public final String receipt(String item) {
        Events.record("body: receipt " + item);
        return prefix + item;
    }

    private String secret(String item) {
        Events.record("body: private secret " + item);
        return prefix + item;
    }
}

PriceCalculator và PricingService là một interface và cài đặt của nó, để so sánh proxy JDK với CGLIB ở cuối:

src/main/java/dev/canhdominich/boot/aop/PriceCalculator.java

java
package dev.canhdominich.boot.aop;

public interface PriceCalculator {

    int priceOf(String item);
}

src/main/java/dev/canhdominich/boot/aop/PricingService.java

java
package dev.canhdominich.boot.aop;

import org.springframework.stereotype.Service;

@Service
public class PricingService implements PriceCalculator {

    @Override
    public int priceOf(String item) {
        return item.length() * 1000;
    }
}

LoggingAspect là aspect chính: một @Around cho mọi phương thức public của OrderService, một @Before, @AfterReturning và @After cho phương thức có @Audited, và một @Before cho PricingService. Nó có @Order(2):

src/main/java/dev/canhdominich/boot/aop/LoggingAspect.java

java
package dev.canhdominich.boot.aop;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Aspect
@Component
@Order(2)
public class LoggingAspect {

    @Around("execution(public * dev.canhdominich.boot.aop.OrderService.*(..))")
    public Object around(ProceedingJoinPoint point) throws Throwable {
        Events.record("around: before " + point.getSignature().getName());
        try {
            return point.proceed();
        } finally {
            Events.record("around: after " + point.getSignature().getName());
        }
    }

    @Before("@annotation(audited)")
    public void audit(JoinPoint point, Audited audited) {
        Events.record("before (audited): " + point.getSignature().getName());
    }

    @AfterReturning(pointcut = "@annotation(Audited)", returning = "result")
    public void returned(JoinPoint point, Object result) {
        Events.record("after returning: " + point.getSignature().getName() + " -> " + result);
    }

    @After("@annotation(Audited)")
    public void finished(JoinPoint point) {
        Events.record("after (finally): " + point.getSignature().getName());
    }

    @Before("execution(* dev.canhdominich.boot.aop.PricingService.priceOf(..))")
    public void priced(JoinPoint point) {
        Events.record("before: priceOf");
    }
}

OuterAspect có @Order(1) và bao quanh riêng cancel, để đo nhiều aspect lồng nhau thế nào:

src/main/java/dev/canhdominich/boot/aop/OuterAspect.java

java
package dev.canhdominich.boot.aop;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Aspect
@Component
@Order(1)
public class OuterAspect {

    @Around("execution(* dev.canhdominich.boot.aop.OrderService.cancel(..))")
    public Object around(ProceedingJoinPoint point) throws Throwable {
        Events.record("outer aspect: before " + point.getSignature().getName());
        try {
            return point.proceed();
        } finally {
            Events.record("outer aspect: after " + point.getSignature().getName());
        }
    }
}

AopApplication là lớp ứng dụng như các bài Boot:

src/main/java/dev/canhdominich/boot/aop/AopApplication.java

java
package dev.canhdominich.boot.aop;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class AopApplication {

    public static void main(String[] args) {
        SpringApplication.run(AopApplication.class, args);
    }
}

AopDemo chạy các thí nghiệm. Với mỗi lời gọi, nó xóa Events, gọi phương thức qua bean lấy từ container, và in kết quả cùng các sự kiện đã ghi. Phần cuối khởi động lại ứng dụng với spring.aop.proxy-target-class=false:

src/main/java/dev/canhdominich/boot/aop/AopDemo.java

java
package dev.canhdominich.boot.aop;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.aop.support.AopUtils;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ConfigurableApplicationContext;

public class AopDemo {

    public static void main(String[] args) {
        run();
    }

    public static List<String> run() {
        List<String> lines = new ArrayList<>();
        SpringApplication application = new SpringApplication(AopApplication.class);
        application.setDefaultProperties(Map.of("spring.main.web-application-type", "none", "spring.main.banner-mode", "off", "logging.level.root", "OFF"));
        try (ConfigurableApplicationContext context = application.run()) {
            OrderService orders = context.getBean(OrderService.class);
            PriceCalculator prices = context.getBean(PriceCalculator.class);
            lines.add("-- what the beans really are");
            lines.add("OrderService class: " + describe(orders));
            lines.add("PriceCalculator class: " + describe(prices));
            lines.add("-- advice around a public method with @Audited");
            lines.addAll(trace("place", () -> orders.place("book")));
            lines.add("-- a public method without @Audited");
            lines.addAll(trace("cancel", () -> orders.cancel("book")));
            lines.add("-- a method that calls another through this");
            lines.addAll(trace("outer", () -> orders.outer("book")));
            lines.add("-- a final method");
            lines.addAll(trace("receipt", () -> orders.receipt("book")));
            lines.add("-- an interface-based bean that an aspect advises");
            lines.addAll(trace("priceOf", () -> String.valueOf(prices.priceOf("book"))));
        }
        lines.add("-- the same beans with spring.aop.proxy-target-class=false");
        SpringApplication jdk = new SpringApplication(AopApplication.class);
        jdk.setDefaultProperties(Map.of("spring.main.web-application-type", "none", "spring.main.banner-mode", "off", "logging.level.root", "OFF", "spring.aop.proxy-target-class", "false"));
        try (ConfigurableApplicationContext context = jdk.run()) {
            lines.add("PriceCalculator class: " + describe(context.getBean(PriceCalculator.class)));
            lines.add("OrderService class (no interface): " + describe(context.getBean(OrderService.class)));
            lines.addAll(trace("priceOf", () -> String.valueOf(context.getBean(PriceCalculator.class).priceOf("book"))));
            try {
                context.getBean(PricingService.class);
                lines.add("getBean(PricingService.class): found");
            } catch (RuntimeException e) {
                lines.add("getBean(PricingService.class): " + e.getClass().getSimpleName());
            }
        }
        lines.forEach(System.out::println);
        return lines;
    }

    private static String describe(Object bean) {
        String name = bean.getClass().getSimpleName();
        String kind = AopUtils.isCglibProxy(bean) ? "CGLIB proxy" : AopUtils.isJdkDynamicProxy(bean) ? "JDK dynamic proxy" : "not a proxy";
        return name.replaceAll("\\$Proxy\\d+", "\\$Proxy<n>") + " (" + kind + ", target " + AopUtils.getTargetClass(bean).getSimpleName() + ")";
    }

    private static List<String> trace(String label, java.util.function.Supplier<String> call) {
        Events.clear();
        String result = call.get();
        List<String> lines = new ArrayList<>();
        lines.add(label + " returned " + result);
        Events.all().forEach(event -> lines.add("    " + event));
        return lines;
    }
}

Chạy bằng mvn -q compile exec:exec. Kết quả (đã bỏ log ở stderr; số thứ tự của proxy JDK được thay bằng <n>):

text
-- what the beans really are
OrderService class: OrderService$$SpringCGLIB$$0 (CGLIB proxy, target OrderService)
PriceCalculator class: PricingService$$SpringCGLIB$$0 (CGLIB proxy, target PricingService)
-- advice around a public method with @Audited
place returned order-book
    around: before place
    before (audited): place
    body: place book
    body: private secret book
    after returning: place -> order-book
    after (finally): place
    around: after place
-- a public method without @Audited
cancel returned cancelled book
    outer aspect: before cancel
    around: before cancel
    body: cancel book
    around: after cancel
    outer aspect: after cancel
-- a method that calls another through this
outer returned order-book
    around: before outer
    body: outer book
    body: place book
    body: private secret book
    around: after outer
-- a final method
receipt returned nullbook
    body: receipt book
-- an interface-based bean that an aspect advises
priceOf returned 4000
    before: priceOf
-- the same beans with spring.aop.proxy-target-class=false
PriceCalculator class: $Proxy<n> (JDK dynamic proxy, target PricingService)
OrderService class (no interface): OrderService$$SpringCGLIB$$0 (CGLIB proxy, target OrderService)
priceOf returned 4000
    before: priceOf
getBean(PricingService.class): NoSuchBeanDefinitionException

Bạn tiêm cái gì: proxy

Hai dòng đầu trả lời câu hỏi nền tảng: lớp thật của OrderService mà bạn lấy từ container là OrderService$$SpringCGLIB$$0, một lớp con do Spring sinh ra lúc chạy, và AopUtils xác nhận đó là proxy CGLIB bọc quanh một OrderService thật. Chuỗi hoạt động như sau: lúc khởi động, Spring thấy có aspect khớp với bean, nên thay bean đó trong container bằng một đối tượng đại diện (proxy). Khi bạn gọi một phương thức, proxy chạy các advice khớp rồi mới (hoặc trong lúc) gọi đối tượng thật. Một bean không khớp aspect nào thì không bị thay: hai bean PricingService ở dòng thứ hai là proxy chỉ vì có @Before nhắm vào nó.

Có hai loại proxy:

  • CGLIB (mặc định của Boot): sinh một lớp con của lớp đích. Dùng được cả khi lớp không có interface.
  • JDK dynamic proxy: sinh một đối tượng cài đặt các interface của đích. Khi đặt spring.aop.proxy-target-class=false và bean có interface, PriceCalculator trở thành $Proxy<n>; nhưng nó chỉ có kiểu interface, nên getBean(PricingService.class) (theo lớp cụ thể) thất bại với NoSuchBeanDefinitionException. Với OrderService (không có interface) Spring vẫn dùng CGLIB.

Cách đọc hình: hàng trên: bean có aspect khớp bị thay bằng lớp con CGLIB, kể cả khi có interface. Hàng dưới: khi tắt proxy-target-class và có interface thì là proxy JDK chỉ mang kiểu interface; không có interface thì vẫn CGLIB.

Thứ tự advice quanh một phương thức

Thí nghiệm place ghi tám sự kiện cho một lời gọi orders.place("book"):

  1. around: before place: phần đầu của @Around (trước proceed()).
  2. before (audited): place: @Before.
  3. body: place book và body: private secret book: thân phương thức (và phương thức private nó gọi).
  4. after returning: place -> order-book: @AfterReturning, chỉ khi thành công, thấy kết quả trả về.
  5. after (finally): place: @After, chạy dù thành công hay ném lỗi (như khối finally).
  6. around: after place: phần sau của @Around.

Hình dung @Around là lớp ngoài cùng quanh tất cả (nó quyết định có gọi proceed() hay không và có thể sửa kết quả hay nuốt lỗi), và @Before, @AfterReturning, @After nằm bên trong nó. Bài sau đo thêm @AfterThrowing và các thay đổi @Around có thể làm.

Cách đọc hình: các ô đi từ trên xuống: vào bằng @Around rồi @Before, chạy thân, rồi ra bằng @AfterReturning, @After, và phần sau của @Around.

Nhiều aspect: thứ tự lồng

cancel bị hai aspect cùng khớp: OuterAspect (@Order(1)) và LoggingAspect (@Order(2)). Sự kiện ghi: outer aspect: before, around: before, thân, around: after, outer aspect: after. Aspect có giá trị @Order nhỏ hơn nằm ở ngoài, gần người gọi hơn: nó vào trước và ra sau, bao quanh aspect kia như lớp hành. Thứ tự này quan trọng khi hai aspect tương tác (một aspect ghi log, một aspect thử lại khi lỗi: cái nào ở ngoài quyết định log thấy mỗi lần thử hay chỉ kết quả cuối).

Cách đọc hình: @Order(1) mở đầu và kết thúc; @Order(2) nằm trong; thân phương thức ở giữa. Lớp nào ngoài cùng là lớp đầu tiên nhìn thấy lời gọi và cuối cùng nhìn thấy kết quả.

Ba loại lời gọi mà proxy không thấy

Đây là phần đáng nhớ nhất: aspect không chạy, và không có thông báo nào.

  • Gọi bằng this. orders.outer("book") chạy around: before outer, thân của outer, rồi thân của place bên trong, và around: after outer. Không có around: before place, không có before (audited): place, mặc dù place có @Audited. Lý do: outer đang chạy trong đối tượng thật; this.place(...) là lời gọi trực tiếp giữa hai phương thức của cùng đối tượng, không đi qua proxy. Đây chính là bẫy self-invocation của @Transactional ở bài Spring Data JPA.
  • Phương thức private. secret luôn được gọi từ bên trong và proxy cũng không thể ghi đè phương thức private, nên không bao giờ có advice quanh nó (bạn thấy body: private secret book không kèm sự kiện advice nào).
  • Phương thức final. receipt ghi body: receipt book mà không có sự kiện advice nào, và trả về nullbook thay vì order-book. Hai điều đồng thời: CGLIB tạo lớp con nên không ghi đè được phương thức final, vì thế lời gọi orders.receipt(...) chạy ngay trên chính đối tượng proxy, không phải trên đối tượng thật. Và đối tượng proxy được tạo mà không chạy constructor của lớp đích, nên trường prefix của nó là null. Kết quả nullbook là một lỗi dữ liệu im lặng: mã chạy, trả về chuỗi sai, và không có exception.

Đang tải Animation…

Cách đọc hình: hàng trên: this.place và private secret không qua proxy. Hàng dưới: final receipt không được tăng cường và trả nullbook vì chạy trên đối tượng proxy. Ô cuối là quy tắc thiết kế.

Điều muốnCách đúngĐo được khi sai
Advice chạy cho một lời gọiGọi từ bean khác qua proxythis. bỏ qua advice
Advice cho một phương thứcPhương thức public, không finalprivate và final không được tăng cường
Trạng thái đúng khi qua proxyKhông dùng phương thức final trên bean được tăng cườngfinal trả trạng thái rỗng (nullbook)
Chọn bean theo lớpTiêm theo interface hoặc giữ CGLIBProxy JDK không tìm được theo lớp cụ thể
Thứ tự nhiều aspect@Order rõ ràngThứ tự không đảm bảo nếu bỏ trống

Quyết định thiết kế và đánh đổi

Quyết địnhLựa chọnĐánh đổi
Mối quan tâm xuyên suốtAspect với pointcut theo annotationMã chạy "vô hình": cần tài liệu và test
Loại proxyCGLIB mặc địnhLớp không được final, cần constructor dùng được
Chọn đích@annotation(Audited) thay vì tên lớpPhải nhớ gắn annotation
Nhiều aspect@Order tường minhPhải suy nghĩ về lồng nhau
Lời gọi nội bộTách sang bean khácCấu trúc lớp phản ánh ranh giới aspect
Phương thức finalTránh trên bean có aspectMột số lớp bất biến muốn final

Playbook: gặp sự cố aspect thì làm gì

Triệu chứngKiểm tra đầu tiênNguyên nhân hay gặpCách xử lý
Aspect không chạyBean có là proxy không (AopUtils.isAopProxy)Bean không do Spring quản lý, hoặc pointcut saiKiểm tra bean và biểu thức pointcut
Aspect chạy ở đây nhưng không ở chỗ kiaLời gọi có qua proxy khôngGọi bằng thisChuyển phương thức sang bean khác
Giá trị null lạ trong phương thứcPhương thức có final khôngChạy trên đối tượng proxyBỏ final
NoSuchBeanDefinitionException theo lớp cụ thểLoại proxyProxy JDK chỉ có kiểu interfaceTiêm theo interface hoặc proxy-target-class=true
Thứ tự advice sai giữa hai aspect@OrderKhông đặt, hoặc đặt ngượcĐặt @Order rõ ràng
Khởi động chậm khi thêm nhiều aspectPhạm vi pointcutPointcut quá rộng (execution(* *(..)))Thu hẹp pointcut theo package hoặc annotation

Lỗi thường gặp khi chạy lab

Triệu chứngNguyên nhânCách xử lý
Aspect không chạyThiếu spring-boot-starter-aspectj hoặc bean không phải bean SpringGiữ starter trong pom.xml; bean phải do container quản lý
getBean(PricingService.class) báo không tìm thấyBean là proxy JDK chỉ có kiểu interfaceTiêm theo interface, hoặc giữ proxy-target-class=true

Bài thực hành

Làm trong dự án của bài (thư mục 12-aop/01-aop-proxy). Sau mỗi bước, so kết quả với tiêu chí nghiệm thu.

  1. Chạy thí nghiệm. mvn -q compile exec:exec. Tiêu chí: các dòng khớp bài, đặc biệt nullbook và thiếu advice ở this.place.
  2. Gắn aspect vào phương thức mới. Thêm @Audited vào cancel và chạy lại. Tiêu chí: thêm before (audited): cancel, after returning: cancel -> cancelled book và after (finally): cancel quanh thân cancel.
  3. Sửa self-invocation. Chuyển place sang một bean PlacementService mới (@Service) và cho OrderService.outer gọi qua nó. Tiêu chí: khi gắn @Audited cho PlacementService.place (và OrderService nhận PlacementService qua constructor), outer returned có thêm before (audited): place, after returning và after (finally) quanh thân place; around: before place vẫn không có vì pointcut @Around chỉ nhắm OrderService.
  4. Bỏ final. Bỏ final khỏi receipt và chạy lại. Tiêu chí: receipt returned order-book và có around: before receipt.
  5. Đảo thứ tự. Đổi @Order của hai aspect (OuterAspect thành 3). Tiêu chí: around: before cancel đứng trước outer aspect: before cancel (aspect ngoài đã đổi chỗ vào trong).
  6. Chạy bộ test (tùy chọn). Bộ test nằm trong bản sao lưu ở kho mã của khóa học; sau khi clone, mvn test cho Tests run: 6, Failures: 0, Errors: 0. Dọn dẹp bằng mvn clean.

Hiểu lầm và giới hạn

  • "Aspect sửa mã của lớp." Spring AOP không sửa lớp; nó đặt một proxy phía trước.
  • "Mọi lời gọi phương thức đều được tăng cường." Chỉ lời gọi qua proxy, vào phương thức public không final.
  • "final an toàn hơn." Trên bean được tăng cường, final làm mất advice và trạng thái.
  • Số đo thuộc về Spring Boot 4.1.1 (Spring Framework 7.0.9, AspectJ do Boot quản lý): tên lớp proxy, thứ tự advice và hành vi final là của cách cài đặt CGLIB hiện tại. Bài chưa nói về @AfterThrowing, @Around sửa kết quả, nhiều aspect trên giao dịch (bài sau), AspectJ weaving lúc biên dịch hay nạp lớp (khắc phục giới hạn proxy), và hiệu năng của proxy.

Tự kiểm tra

  1. Khi bạn tiêm một bean có aspect, bạn nhận được đối tượng nào?
  2. Nêu thứ tự @Around, @Before, @AfterReturning, @After quanh một lời gọi thành công.
  3. Vì sao this.place() bên trong outer không có advice?
  4. Vì sao phương thức final trả nullbook?
  5. Aspect nào bao ngoài khi một có @Order(1) và một có @Order(2)?

Gợi ý đáp án. (1) Một proxy (lớp con CGLIB mặc định) bọc đối tượng thật. (2) @Around mở đầu, @Before, thân, @AfterReturning, @After, @Around kết thúc. (3) Lời gọi trực tiếp trong đối tượng thật không đi qua proxy. (4) Proxy không ghi đè được phương thức final, nên nó chạy trên chính đối tượng proxy, nơi constructor của đích chưa từng chạy. (5) @Order(1) ở ngoài.

Điều cần nhớ

  • AOP viết một mối quan tâm xuyên suốt một lần và áp bằng pointcut; ở Spring nó chạy qua proxy.
  • Bean được tăng cường là proxy: CGLIB mặc định, JDK khi tắt proxy-target-class và có interface.
  • Thứ tự: @Around ngoài cùng, rồi @Before, thân, @AfterReturning, @After; aspect có @Order nhỏ ở ngoài.
  • this, private và final thoát khỏi aspect một cách im lặng; final còn trả trạng thái rỗng.

Đọc thêm

Bài tiếp theo hoàn thiện chương: các loại advice còn lại (@AfterThrowing, @Around sửa kết quả và thử lại) và cách @Transactional chính là một aspect, kể cả thứ tự giữa aspect của bạn và giao dịch.