Tự viết annotation: nhãn dán + người đọc nhãn

4 câu hỏi~25 phútTừ khóa: @interface, @Target, @Retention, metadata, Reflection, @Around, ProceedingJoinPoint, @Idempotent

Trong Java, tự viết annotation thường để làm gì?

Ví dụ dễ liên hệ nhất là các annotation bạn dùng hằng ngày: @Service, @Transactional, @Cacheable, @PreAuthorize. Bản thân chúng không chứa logic. Framework nhìn thấy annotation rồi chạy logic phía sau. Tự viết annotation là làm đúng điều đó cho nhu cầu riêng của shop-app.

Annotation
   ↓
Đánh dấu / cung cấp metadata
   ↓
Reflection / AOP / Framework đọc annotation
   ↓
Thực hiện logic

1. Bài toán: đo thời gian 100 method

@Service
public class OrderService {
    public void createOrder() throws InterruptedException {
        long start = System.currentTimeMillis();
        Thread.sleep(1500);
        System.out.println("Order created");
        System.out.println("Execution time: " + (System.currentTimeMillis() - start) + "ms");
    }
}

Rồi cancelOrder(), payment(), sendEmail()... đều cần.

2. Vấn đề

Ta muốn chỉ viết @ExecutionTime lên method là xong.

3. Giải pháp: custom annotation + bộ xử lý

Hai mảnh luôn đi với nhau:

Hai meta-annotation quyết định annotation dùng ở đâu và sống tới khi nào:

@RetentionCòn tồn tại tớiVí dụ
SOURCEChỉ trong source, compiler bỏ đi@Override, Lombok
CLASS (mặc định)Có trong .class nhưng runtime không đọc đượcCông cụ bytecode
RUNTIMEĐọc được bằng Reflection lúc chạy@Transactional, mọi annotation dùng với AOP

@Target giới hạn chỗ đặt: METHOD, TYPE (class), FIELD, PARAMETER...

4. Code từng bước

Bước 1: Dependency

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

Bước 2: Tạo annotation

package com.example.shop.annotation;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ExecutionTime {
    String value() default "";
}

Dùng thử:

@Service
public class OrderService {
    @ExecutionTime("CREATE_ORDER")
    public void createOrder() throws InterruptedException {
        Thread.sleep(1500);
        System.out.println("Order created");
    }
}

Chạy lúc này chỉ in Order created. Bản thân @ExecutionTime không làm gì cả. Nó giống một cái nhãn dán: "method này cần đo execution time". Cần có thằng đọc nhãn.

Bước 3: Aspect đọc annotation

@Aspect
@Component
public class ExecutionTimeAspect {

    private static final Logger log = LoggerFactory.getLogger(ExecutionTimeAspect.class);

    // bind tham số "executionTime" để đọc được value()
    @Around("@annotation(executionTime)")
    public Object measure(ProceedingJoinPoint joinPoint, ExecutionTime executionTime) throws Throwable {
        long start = System.currentTimeMillis();
        try {
            return joinPoint.proceed();          // chạy method thật
        } finally {
            log.info("[{}] {} execution time: {}ms",
                    executionTime.value(),
                    joinPoint.getSignature().getName(),
                    System.currentTimeMillis() - start);
        }
    }
}

Bước 4: Test

curl -X POST localhost:8080/orders -H "Content-Type: application/json" -d '{"productId":1,"quantity":1}'

# log:
Order created
[CREATE_ORDER] createOrder execution time: 1503ms

5. Cách hoạt động

Controller
   ↓
Spring Proxy của OrderService
   ↓  method có @ExecutionTime không?
   ↓  YES
ExecutionTimeAspect
   ┌──────────────────────────┐
   │ start timer              │
   │ joinPoint.proceed() ─────┼──→ createOrder() THẬT
   │ end timer, log           │
   └──────────────────────────┘
   ↓
return

joinPoint.proceed() chính là "cho method gốc tiếp tục chạy". Nhờ vậy @Around làm được cả trước lẫn sau, thậm chí không gọi method gốc (cache, idempotency).

6. So sánh: ai đọc annotation?

CáchLúc nàoVí dụ thực tế
Spring AOPRuntime, qua proxy@Transactional, @ExecutionTime, @AuditLog
Reflection thủ côngRuntimeĐọc @ApiVersion(2) trên controller
Annotation processorCompile time, sinh codeLombok, MapStruct
Bean ValidationRuntime, ConstraintValidator@ValidPhone

7. Khi nào nên dùng / không nên dùng

// Kết quả mong muốn: method chỉ còn business logic
@AuditLog
@DistributedLock("order")
@RequirePermission("CREATE_ORDER")
public void createOrder() {
    // chỉ còn business logic
}

Có demo bằng Java core (không dùng Spring) không?

Có. Ta tự làm "thằng đọc nhãn" bằng Reflection:

public class OrderService {
    @ExecutionTime("CREATE_ORDER")
    public void createOrder() throws InterruptedException {
        Thread.sleep(1500);
        System.out.println("Order created");
    }

    public void cancelOrder() throws InterruptedException {   // không có nhãn
        Thread.sleep(500);
        System.out.println("Order cancelled");
    }
}

public class Main {
    public static void main(String[] args) throws Exception {
        OrderService service = new OrderService();
        for (String name : List.of("createOrder", "cancelOrder")) {
            Method method = OrderService.class.getMethod(name);
            ExecutionTime ann = method.getAnnotation(ExecutionTime.class);
            if (ann != null) {
                long start = System.currentTimeMillis();
                method.invoke(service);
                System.out.println("[" + ann.value() + "] " + (System.currentTimeMillis() - start) + "ms");
            } else {
                method.invoke(service);
            }
        }
    }
}
Order created
[CREATE_ORDER] 1502ms
Order cancelled

Chỗ thú vị: nếu viết service.createOrder() trực tiếp thì chỉ in Order created. Java không tự làm gì cả. Và nếu đổi @Retention thành CLASS, getAnnotation trả về null.

Vấn đề: chẳng lẽ mỗi lần gọi method đều viết Reflection? Đó là lý do người ta dùng dynamic proxy, và Spring AOP chính là bọc sẵn việc đó cho bạn.

Cho mình code mẫu để học. Có ví dụ nào thực tế hơn đo thời gian chạy không?

Một bài toán rất thật cho shop-app: user bấm "Thanh toán" hai lần (mạng chậm, double-click), hệ thống trừ tiền hai lần. Ta viết @Idempotent: cùng Idempotency-Key thì trả lại kết quả cũ, không chạy lại.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    long ttlSeconds() default 3600;
}
@Aspect
@Component
public class IdempotentAspect {

    private final StringRedisTemplate redis;
    private final ObjectMapper mapper;

    public IdempotentAspect(StringRedisTemplate redis, ObjectMapper mapper) {
        this.redis = redis;
        this.mapper = mapper;
    }

    @Around("@annotation(idempotent)")
    public Object handle(ProceedingJoinPoint pjp, Idempotent idempotent) throws Throwable {
        HttpServletRequest req =
            ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
        String key = req.getHeader("Idempotency-Key");
        if (key == null) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Missing Idempotency-Key");
        }
        String redisKey = "idem:" + pjp.getSignature().toShortString() + ":" + key;

        // SETNX: chỉ request đầu tiên chiếm được key
        Boolean first = redis.opsForValue()
            .setIfAbsent(redisKey, "PROCESSING", Duration.ofSeconds(idempotent.ttlSeconds()));
        if (Boolean.FALSE.equals(first)) {
            String saved = redis.opsForValue().get(redisKey);
            if ("PROCESSING".equals(saved)) {
                throw new ResponseStatusException(HttpStatus.CONFLICT, "Request is processing");
            }
            Class<?> type = ((MethodSignature) pjp.getSignature()).getReturnType();
            return mapper.readValue(saved, type);          // trả kết quả cũ
        }

        try {
            Object result = pjp.proceed();
            redis.opsForValue().set(redisKey, mapper.writeValueAsString(result),
                    Duration.ofSeconds(idempotent.ttlSeconds()));
            return result;
        } catch (Throwable ex) {
            redis.delete(redisKey);                        // lỗi thì cho phép thử lại
            throw ex;
        }
    }
}
@RestController
@RequestMapping("/payments")
public class PaymentController {
    @PostMapping
    @Idempotent(ttlSeconds = 86400)
    public PaymentResponse pay(@RequestBody PaymentRequest req) {
        return paymentService.charge(req);
    }
}
curl -X POST localhost:8080/payments -H "Idempotency-Key: 7f3a-001" -H "Content-Type: application/json" -d '{"orderId":10}'
# {"paymentId":555,"status":"PAID"}
curl -X POST localhost:8080/payments -H "Idempotency-Key: 7f3a-001" -H "Content-Type: application/json" -d '{"orderId":10}'
# {"paymentId":555,"status":"PAID"}   ← cùng kết quả, không trừ tiền lần 2
Request 1 ──SETNX idem:...:7f3a-001 → OK──→ charge() ──→ lưu kết quả
Request 2 ──SETNX → FAIL──→ đọc Redis
                              ├─ "PROCESSING" → 409
                              └─ kết quả JSON → trả lại

Đây là demo học tập; production cần tính thêm: key nên gắn với user, và so khớp body request để tránh dùng lại key cho request khác.

Phỏng vấn hỏi custom annotation thì trả lời sao?

Q1. Custom annotation là gì, dùng để làm gì?

🇻🇳 Tiếng Việt:

Custom annotation là cách gắn metadata lên class, method, field hoặc parameter. Bản thân annotation thường không chạy logic. Nó được đọc bởi Reflection, AOP, annotation processor hoặc framework để thực hiện các hành vi dùng lại như logging, phân quyền, validation, caching, audit, idempotency.

🇬🇧 English:

A custom annotation is a way to attach metadata to classes, methods, fields or parameters. The annotation itself usually doesn't execute business logic. It is processed by reflection, AOP, annotation processors or frameworks to implement reusable behaviors such as logging, authorization, validation, caching, auditing or idempotency.

Q2. @Retention và @Target để làm gì?

🇻🇳 Tiếng Việt:

@Target quy định annotation được đặt ở đâu, ví dụ method hay class. @Retention quy định annotation tồn tại tới khi nào: SOURCE, CLASS hay RUNTIME. Muốn AOP hoặc Reflection đọc lúc chạy thì phải là RUNTIME.

🇬🇧 English:

@Target defines where the annotation can be placed, such as methods or types. @Retention defines how long it is kept: SOURCE, CLASS or RUNTIME. If AOP or reflection must read it at runtime, it has to be RUNTIME.

Q3. Bạn đã viết custom annotation nào trong project?

🇻🇳 Tiếng Việt:

Em viết @Idempotent cho API thanh toán. Một aspect @Around đọc header Idempotency-Key, dùng Redis SETNX để chỉ request đầu tiên được xử lý, lưu kết quả và trả lại cho request trùng. Nhờ vậy double-click không trừ tiền hai lần, và controller không phải chứa logic này.

🇬🇧 English:

I wrote an @Idempotent annotation for the payment API. An @Around aspect reads the Idempotency-Key header, uses Redis SETNX so only the first request is processed, stores the result and returns it for duplicates. Double clicks no longer charge twice, and controllers stay free of that logic.

Cách nhớ ngắn gọn


Tự kiểm tra

Chỉ khai báo @ExecutionTime mà không có aspect thì có log không?

Không. Annotation chỉ là metadata, phải có code đọc nó.

Quên @Retention(RUNTIME) thì chuyện gì xảy ra?

Mặc định là CLASS, Reflection/AOP không thấy annotation lúc runtime, aspect không chạy.

Làm sao aspect lấy được giá trị value() của annotation?

Bind tham số: @Around("@annotation(executionTime)") và khai báo tham số ExecutionTime executionTime.

Vì sao @Idempotent xóa key khi method lỗi?

Để client có thể thử lại với cùng key; nếu giữ "PROCESSING" thì các lần thử sau bị chặn tới khi hết TTL.

Lombok xử lý annotation ở đâu?

Compile time bằng annotation processor (Retention SOURCE), không phải runtime.