Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

custom annotations trong java

Tung Bui Thanh (TungDaDev)14 mins read

Springboot annotation.webp

Code Java và Spring Boot bây giờ đâu đâu cũng thấy annotation: @Entity, @Transactional, @RestController, @PreAuthorize... Nhờ chúng mà cả đống code cấu hình dài dòng gọn lại còn một dòng, phần hạ tầng phức tạp được giấu xuống bên dưới.

Có điều với nhiều dev, annotation vẫn là một cái hộp đen có phép thuật. Có người tưởng annotation tự nó chạy được code, có người lại gọi reflection kiểu thô (getDeclaredFields(), Field.setAccessible(true)) ngay trong vòng lặp nóng rồi thắc mắc sao hệ thống chậm.

Mình sẽ đi từ tầng bytecode của JVM để xem annotation thật ra hoạt động ra sao, qua meta-annotation của Spring (@AliasFor), rồi từng bước làm một custom annotation dùng được cho production: Distributed Rate Limiter dùng Spring AOP, Spring Expression Language (SpEL) và Redis.


# Java annotation nhìn từ bytecode JVM

Điều quan trọng nhất cần nhớ: annotation KHÔNG chứa chút logic nào để chạy cả. Nó chỉ là metadata gắn vào source code, để mấy công cụ khác (compiler, annotation processor, công cụ đọc bytecode, hay code dùng reflection lúc runtime) đọc rồi tự xử lý theo.

# gọi clazz.getannotation() thì JVM làm gì?

Giả sử bạn khai báo một annotation:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RateLimited {
    int rpm() default 60;
    String key();
}

javac sẽ biên dịch nó thành một interface đặc biệt, kế thừa từ java.lang.annotation.Annotation. Còn ở method nào gắn @RateLimited, file .class của class chứa method đó được ghi thêm một attribute bytecode tên là RuntimeVisibleAnnotations (nếu retention là CLASS thì attribute có tên RuntimeInvisibleAnnotations, JVM nạp class nhưng reflection không trả nó ra). Bạn có thể tự xem bằng javap -v.

Lúc app chạy, bạn gọi method.getAnnotation(RateLimited.class) thì:

  1. Lần đầu được hỏi tới, thư viện reflection của JDK lấy mảng byte thô của attribute RuntimeVisibleAnnotations (JVM giữ nó cùng metadata của class trong Metaspace), rồi parse ra, tra tên và giá trị qua Constant Pool của class. Kết quả được cache lại, các lần gọi sau không parse nữa.
  2. Không có class nào "implements RateLimited" cả. JDK sinh động ra một Java Dynamic Proxy (java.lang.reflect.Proxy) implement interface RateLimited.
  3. Proxy này bọc một InvocationHandler nội bộ là sun.reflect.annotation.AnnotationInvocationHandler, bên trong có một Map<String, Object> chứa giá trị đã parse (nằm trên heap như object bình thường).
  4. Khi bạn gọi annotation.rpm(), proxy chuyển cuộc gọi vào handler, handler tra map theo tên method rồi trả về (giá trị kiểu mảng thì clone ra một bản mới mỗi lần).

NOTE

Mỗi lần gọi vẫn phải đi qua getAnnotation(), proxy và handler, nên gọi trong hot-path (vòng lặp chạy cả triệu lần mỗi giây) sẽ tốn kha khá so với đọc một field. Vì vậy mấy framework lớn đều scan annotation từ lúc khởi động (bootstrap phase) rồi giữ kết quả trong cache của riêng nó.


# các mức retentionpolicy: khi nào dùng cái nào?

Hiểu đúng vòng đời của annotation thì vừa đỡ tốn hiệu năng, vừa thiết kế cho đúng chỗ:

RetentionPolicyĐược đọc lúc nàoHoạt động ra saoVí dụ hay gặpChi phí lúc runtime
SOURCECompile-timeCompiler đọc rồi bỏ; JSR 269 Annotation Processor đọc để sinh file mới (Lombok sửa luôn AST).@Override, @SuppressWarnings, Lombok (@Getter, @Builder).0% (Zero)
CLASSPost-compile / Bytecode EnhancementMức mặc định. Nằm trong .class nhưng reflection không đọc được. Dành cho công cụ đọc bytecode.MapStruct @Mapper, AndroidX @Keep (R8/ProGuard đọc), JaCoCo.0%
RUNTIMERunning JVMNạp vào Metaspace, đọc qua Reflection hoặc Spring AOP / CDI Interceptors.Spring @Transactional, @RestController, Jackson @JsonProperty.Tốn chút chi phí reflection lúc đầu

# Spring meta-annotations & cơ chế @aliasfor

Trong Spring, mấy annotation như @RestController thật ra được ghép từ @Controller và @ResponseBody. Kiểu annotation gắn trên annotation này gọi là meta-annotation.

Java thuần không cho kế thừa giữa các @interface. Spring lách giới hạn này bằng cách tự đọc annotation theo nhiều tầng (AnnotatedElementUtils, MergedAnnotations) và annotation @AliasFor để nối thuộc tính tầng trên xuống tầng dưới. Có một điều kiện: annotation được dùng làm meta-annotation phải có @Target cho phép gắn lên annotation type (ANNOTATION_TYPE hoặc TYPE):

# ví dụ triển khai @aliasfor

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@RequestMapping(method = RequestMethod.POST) // @PostMapping chỉ gắn được lên method, nên phải dùng @RequestMapping
public @interface AuditedPostMapping {
 
    // Tạo alias cho thuộc tính path của @RequestMapping
    @AliasFor(annotation = RequestMapping.class, attribute = "path")
    String[] path() default {};
 
    // Thuộc tính riêng của audit
    String auditAction();
    boolean maskSensitiveData() default true;
}

Bạn có thể thắc mắc sao không gắn thẳng @PostMapping cho gọn. Lý do là @PostMapping khai báo @Target(ElementType.METHOD), gắn nó lên một @interface thì javac báo lỗi "annotation interface not applicable to this kind of declaration". @RequestMapping thì cho gắn lên TYPE nên dùng được.

Nhờ @AliasFor, RequestMappingHandlerMapping của Spring MVC vẫn thấy method gắn @AuditedPostMapping(path = "/payments", auditAction = "CAPTURE") là một handler POST /payments bình thường, còn module Audit AOP thì vẫn lấy được auditAction, không phải khai báo lặp lại.


# tự làm custom annotation cho production: distributed rate limiter

Giờ làm thử một thứ dùng được thật: annotation @DistributedRateLimit với các yêu cầu:

  1. Gắn được lên bất kỳ method nào của service hay controller.
  2. Lấy được tham số động lúc runtime (ví dụ userId, ipAddress) bằng Spring Expression Language (SpEL).
  3. Chặn request vượt ngưỡng bằng bộ đếm Fixed Window trên Redis, thao tác đếm phải atomic.
  4. Không trộn vào business logic (Separation of Concerns).

# khai báo custom annotation

package com.company.common.ratelimit;
 
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;
 
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DistributedRateLimit {
 
    /**
     * SpEL Expression để xác định định danh rate limit.
     * Ví dụ: "#userId", "#request.customerId", hoặc "#ip"
     */
    String key();
 
    /**
     * Số lượng request tối đa trong cửa sổ thời gian.
     */
    int limit() default 100;
 
    /**
     * Độ dài của cửa sổ thời gian.
     */
    long duration() default 60;
 
    /**
     * Đơn vị thời gian (mặc định: SECONDS).
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;
 
    /**
     * Thông báo lỗi khi bị chặn.
     */
    String message() default "Hệ thống đang bận. Vui lòng thử lại sau!";
}

# viết aspect bằng Spring AOP & SpEL parser

Chỗ đáng chú ý nhất là dùng SpEL để lấy ra giá trị tham số thật được truyền vào hàm:

package com.company.common.ratelimit;
 
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.expression.MethodBasedEvaluationContext;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.stereotype.Component;
 
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
 
@Aspect
@Component
public class DistributedRateLimitAspect {
 
    private static final Logger log = LoggerFactory.getLogger(DistributedRateLimitAspect.class);
 
    // INCR và PEXPIRE chạy trong cùng một Lua script nên Redis thực thi nguyên khối (atomic)
    private static final RedisScript<Long> FIXED_WINDOW_SCRIPT = RedisScript.of("""
            local current = redis.call('INCR', KEYS[1])
            if current == 1 then
                redis.call('PEXPIRE', KEYS[1], ARGV[1])
            end
            return current
            """, Long.class);
 
    private final StringRedisTemplate redisTemplate;
    private final ExpressionParser spelParser = new SpelExpressionParser();
    private final ParameterNameDiscoverer paramDiscoverer = new DefaultParameterNameDiscoverer();
    // Parse SpEL một lần cho mỗi biểu thức rồi dùng lại
    private final Map<String, Expression> expressionCache = new ConcurrentHashMap<>();
 
    public DistributedRateLimitAspect(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
 
    @Around("@annotation(rateLimit)")
    public Object enforceRateLimit(ProceedingJoinPoint joinPoint, DistributedRateLimit rateLimit) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
 
        // 1. Phân giải SpEL để trích xuất Dynamic Key từ method parameters
        String evaluatedKey = evaluateSpelExpression(rateLimit.key(), method, joinPoint.getArgs(), joinPoint.getTarget());
        String redisKey = String.format("ratelimit:%s.%s:%s",
                method.getDeclaringClass().getSimpleName(), method.getName(), evaluatedKey);
 
        // 2. Đếm request trong cửa sổ hiện tại (Fixed Window Counter, atomic nhờ Lua)
        long windowMillis = rateLimit.timeUnit().toMillis(rateLimit.duration());
        Long currentRequests = redisTemplate.execute(FIXED_WINDOW_SCRIPT, List.of(redisKey), String.valueOf(windowMillis));
 
        if (currentRequests != null && currentRequests > rateLimit.limit()) {
            log.warn("Rate limit breached for key: {} (count: {}/{})", redisKey, currentRequests, rateLimit.limit());
            throw new RateLimitExceededException(rateLimit.message());
        }
 
        // 3. Tiến hành gọi method thực tế
        return joinPoint.proceed();
    }
 
    private String evaluateSpelExpression(String expressionStr, Method method, Object[] args, Object target) {
        if (!expressionStr.startsWith("#")) {
            return expressionStr; // Static key thuần túy
        }
 
        MethodBasedEvaluationContext context = new MethodBasedEvaluationContext(
            target, method, args, this.paramDiscoverer
        );
 
        Expression expression = expressionCache.computeIfAbsent(expressionStr, spelParser::parseExpression);
        Object val = expression.getValue(context);
        return val != null ? val.toString() : "anonymous";
    }
}

Vài chỗ trong aspect này đáng nói kỹ hơn.

Vì sao phải dùng Lua script. Cách viết hay gặp là gọi increment() rồi, nếu kết quả bằng 1, gọi tiếp expire(). Đó là hai lệnh riêng. Nếu app chết, mất kết nối hay timeout đúng lúc giữa hai lệnh, key sẽ nằm lại trong Redis không có TTL, bộ đếm không bao giờ reset và user đó bị chặn vĩnh viễn. Gói INCR và PEXPIRE vào một Lua script thì Redis chạy cả script như một khối, không có khe hở nào. Dùng millisecond (PEXPIRE, toMillis) thay vì giây cũng tránh được lỗi khi ai đó khai báo duration nhỏ hơn 1 giây: toSeconds() sẽ ra 0, mà EXPIRE key 0 thì Redis xoá luôn key.

Fixed window có giới hạn. Bộ đếm reset theo từng cửa sổ cố định, nên user có thể gửi đủ limit request ở cuối cửa sổ này rồi thêm limit request ngay đầu cửa sổ sau, tức là gấp đôi ngưỡng trong một khoảng ngắn. Với chống spam thông thường thì chấp nhận được. Cần mượt hơn thì dùng sliding window log (sorted set), sliding window counter hoặc token bucket, cũng viết bằng Lua, hoặc dùng thư viện có sẵn như Bucket4j, RRateLimiter của Redisson.

Key có cả tên class. Chỉ dùng method.getName() thì hai service cùng có method process() sẽ dùng chung bộ đếm.

SpEL #request lấy tên tham số từ đâu. DefaultParameterNameDiscoverer đọc tên tham số từ bytecode, nên code phải được compile với cờ -parameters. Spring Boot parent (Maven) và plugin Gradle của Spring Boot đã bật sẵn cờ này. Không có cờ này thì từ Spring 6.1 Spring không tìm được tên tham số nữa (bản cũ còn đọc từ debug info, bản mới đã bỏ), #request thành null và #request.customerId ném SpelEvaluationException. Lúc đó dùng #p0/#a0 theo vị trí tham số. Biểu thức SpEL được parse một lần rồi cache trong expressionCache, vì parseExpression khá tốn mà biểu thức thì cố định theo annotation.

Trả HTTP 429. Aspect chỉ ném exception, muốn client nhận đúng 429 thì exception cần được map sang status code, ví dụ:

package com.company.common.ratelimit;
 
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
 
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS) // Spring MVC tự trả HTTP 429
public class RateLimitExceededException extends RuntimeException {
    public RateLimitExceededException(String message) {
        super(message);
    }
}

Hoặc xử lý trong @RestControllerAdvice nếu muốn trả thêm header Retry-After (thời gian còn lại lấy bằng lệnh PTTL của key).

Còn một quyết định phải chốt trước khi lên production: Redis chết thì sao? Với code trên, exception từ Redis sẽ làm hỏng luôn request (fail-closed). Nhiều hệ thống chọn bắt lỗi kết nối, ghi log rồi cho request đi qua (fail-open) để rate limiter không trở thành điểm chết của cả service. Chọn bên nào tuỳ việc bạn đang bảo vệ cái gì.

# gắn annotation vào business service

@Service
public class PaymentProcessingService {
 
    /**
     * Giới hạn mỗi User chỉ được thực hiện tối đa 5 giao dịch thanh toán trong vòng 60 giây.
     * Khóa rate-limit được trích xuất trực tiếp từ trường customerId của object request!
     */
    @DistributedRateLimit(
        key = "#request.customerId",
        limit = 5,
        duration = 60,
        timeUnit = TimeUnit.SECONDS,
        message = "Bạn đã thực hiện quá nhiều giao dịch. Vui lòng chờ 1 phút!"
    )
    public TransactionReceipt processPayment(PaymentRequest request) {
        // Business logic an toàn tuyệt đối, không cần bận tâm về rate-limiting code
        return executePayment(request);
    }
}

# mấy chỗ hay "gãy" khi dùng custom annotation

# bẫy tự gọi trong cùng class (self-invocation trap)

Spring AOP chạy dựa trên dynamic proxy. Chỉ khi code bên ngoài gọi bean qua proxy thì aspect mới chạy. Còn nếu method A gọi method B trong cùng một class:

@Service
public class OrderService {
 
    public void processAll() {
        // GỌI NỘI BỘ (this.executeSingle): Proxy BỊ BYPASS HOÀN TOÀN!
        // Annotation @DistributedRateLimit trên executeSingle() sẽ KHÔNG CHẠY!
        this.executeSingle();
    }
 
    @DistributedRateLimit(key = "'fixed'", limit = 2)
    public void executeSingle() {
        // ...
    }
}

Cách xử lý: tách executeSingle() sang một service riêng, hoặc inject chính proxy của service đó qua ObjectProvider<OrderService> (hay @Lazy) rồi gọi qua nó. Lưu ý: dù Spring Boot mặc định dùng CGLIB proxy (subclass) chứ không phải JDK dynamic proxy, bẫy này vẫn y nguyên, vì this bên trong method luôn là object gốc chứ không phải proxy. Cũng vì cơ chế proxy mà aspect không chặn được method private, và với final method thì CGLIB không override được.

# reflection tốn cpu & sinh rác

Đừng gọi clazz.getDeclaredFields() và field.setAccessible(true) lặp đi lặp lại trong hot-path. JDK có cache danh sách field bên trong Class, nhưng mỗi lần gọi getDeclaredFields() nó vẫn phải trả về một mảng mới chứa các bản copy của từng Field, để code bên ngoài không sửa được bản gốc. Gọi liên tục như vậy:

  • Sinh rác bộ nhớ (GC allocation churn) và tốn CPU. Không phải memory leak, nhưng GC phải dọn liên tục.
  • setAccessible(true) còn phải qua kiểm tra quyền của module system. Từ Java 16-17, gọi nó lên class nội bộ của JDK mà package chưa được opens sẽ ném InaccessibleObjectException.
  • Cách xử lý: tra cứu một lần rồi cache kết quả, ví dụ ConcurrentHashMap<Method, Metadata>. org.springframework.util.ReflectionUtils có sẵn cache cho danh sách method/field khai báo. Còn nếu phải đọc/ghi field liên tục thì đổi sang MethodHandle hoặc VarHandle lấy một lần lúc khởi động, JIT tối ưu chúng tốt hơn nhiều so với Field.get().

# tổng kết

Tiêu chíCustom annotation viết vộiCustom annotation cho production
Cơ chế xử lýTự quét bằng reflection lúc runtime trong vòng lặp.Aspect của Spring AOP chặn trước/sau khi method chạy.
Quản lý tham sốGhi cứng tên field hoặc tham số tĩnh.Dùng SpEL để tính tham số động lúc runtime (parse một lần, cache lại).
Xử lý ProxyDễ dính bẫy self-invocation, annotation mất tác dụng.Thiết kế theo đúng ranh giới bean, kiểm soát được injection.
Khả năng mở rộngLogic dính chặt vào một class.Tách bạch (Separation of Concerns): domain sạch sẽ, phần hạ tầng nằm ở aspect.

Custom annotation đi cùng Spring AOP là một bộ đồ nghề rất đáng dùng. Dùng đúng cách thì code nghiệp vụ gọn gàng, dễ đọc, còn mấy chuyện kỹ thuật rắc rối (security, audit, rate limiting, metrics) được gom về một chỗ để quản lý.


Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.