Tự viết annotation: nhãn dán + người đọc nhãn
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 đề
- Copy đoạn đo thời gian vào 100 method là rất xấu.
- Business code lẫn với infrastructure code: lock, log, check quyền, đo giờ.
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:
- Annotation: cái nhãn, chứa metadata (
@interface). - Bộ xử lý: thứ đọc nhãn và làm việc. Trong Spring thường là AOP aspect; trong Java thuần là Reflection; lúc compile là annotation processor (Lombok, MapStruct).
Hai meta-annotation quyết định annotation dùng ở đâu và sống tới khi nào:
@Retention | Còn tồn tại tới | Ví dụ |
|---|---|---|
SOURCE | Chỉ trong source, compiler bỏ đi | @Override, Lombok |
CLASS (mặc định) | Có trong .class nhưng runtime không đọc được | Cô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ách | Lúc nào | Ví dụ thực tế |
|---|---|---|
| Spring AOP | Runtime, qua proxy | @Transactional, @ExecutionTime, @AuditLog |
| Reflection thủ công | Runtime | Đọc @ApiVersion(2) trên controller |
| Annotation processor | Compile time, sinh code | Lombok, MapStruct |
| Bean Validation | Runtime, ConstraintValidator | @ValidPhone |
7. Khi nào nên dùng / không nên dùng
- Nên: cross-cutting lặp lại, thể hiện được bằng một nhãn:
@AuditLog,@RequirePermission("ADMIN"),@DistributedLock("order"),@RateLimit(100),@Retry(3). - Không nên: giấu business logic quan trọng sau annotation. Dùng ở 1–2 chỗ thì gọi method bình thường còn rõ hơn.
- Nhớ giới hạn proxy (bài 4.1.3): self-invocation và method private không chạy aspect.
// 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:
@Targetquy định annotation được đặt ở đâu, ví dụ method hay class.@Retentionquy đị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:
@Targetdefines where the annotation can be placed, such as methods or types.@Retentiondefines 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@Idempotentcho 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@Idempotentannotation for the payment API. An@Aroundaspect 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
- Annotation ≠ logic. Annotation = nhãn; aspect/reflection = người đọc nhãn.
- Đọc lúc chạy →
@Retention(RUNTIME). @Around("@annotation(x)")+proceed()= trước và sau method.- Vẫn là proxy: self-invocation không chạy.
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.