+10

Trọn bộ các annotation cơ bản cần phải biết trong Spring Boot (Phần 2)

"Bạn đã sẵn sàng đi tiếp chưa? Khi nền móng hệ thống đã vững vàng ở Phần 1, đây là lúc chúng ta tiến sâu vào 'hệ thần kinh trung ương' của ứng dụng. Phần 2 này sẽ tạm gác lại những khái niệm cơ bản để tập trung vào hiệu năng: từ việc chia tách các luồng xử lý nền, hẹn giờ chạy tự động cho đến bẻ lái hành vi hệ thống theo từng môi trường. Châm một ly cà phê, và cùng nâng cấp tư duy thiết kế của bạn lên một tầm cao mới nhé!"

Phần III. Web & REST API (Spring MVC)

1. @RequestMapping

Trong Spring Boot, @RequestMapping đóng vai trò là một "người điều hướng" (Router). Nhiệm vụ của nó là ánh xạ (map) một địa chỉ URL từ HTTP Request của người dùng (ví dụ: https://domain.com/api/users) tới một phương thức (method) Java cụ thể trong Controller để xử lý yêu cầu đó.

Cách sử dụng cơ bản (Kết hợp Class-level và Method-level)

Cách dùng phổ biến nhất của @RequestMapping là đặt nó ở cả trên đầu Class (để tạo tiền tố URL chung - Base URL) và trên Method (để tạo đường dẫn chi tiết).

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/users") // Base URL cho toàn bộ class này
public class UserController {

    // Đường dẫn thực tế sẽ là: GET/POST/PUT... /api/users/profile
    @RequestMapping("/profile") 
    public String getUserProfile() {
        return "Thông tin User";
    }

    // Đường dẫn thực tế sẽ là: GET/POST/PUT... /api/users/settings
    @RequestMapping("/settings") 
    public String getUserSettings() {
        return "Cài đặt User";
    }
}

Chỉ định phương thức HTTP (HTTP Methods)

Nếu bạn chỉ viết @RequestMapping("/profile") như trên, phương thức đó sẽ chấp nhận tất cả các loại HTTP Method (GET, POST, PUT, DELETE, PATCH). Điều này rất nguy hiểm và không đúng chuẩn RESTful API.

Để giới hạn chỉ cho phép một loại Request cụ thể, bạn phải sử dụng thuộc tính method:

import org.springframework.web.bind.annotation.RequestMethod;

// Chỉ nhận HTTP GET request
@RequestMapping(value = "/profile", method = RequestMethod.GET)
public String getProfile() {
    return "Lấy thông tin";
}

// Chỉ nhận HTTP POST request
@RequestMapping(value = "/profile", method = RequestMethod.POST)
public String createProfile() {
    return "Tạo mới thông tin";
}

2. @GetMapping, @PostMapping, @PutMapping, @DeleteMapping

Bộ tứ @GetMapping, @PostMapping, @PutMapping, và @DeleteMapping là những annotation sinh ra để giải quyết sự cồng kềnh của @RequestMapping.

Được giới thiệu từ phiên bản Spring 4.3, chúng là các biến thể rút gọn (shortcut) được gắn cứng (hardcode) sẵn một loại HTTP Method cụ thể. Việc sử dụng chúng không chỉ giúp code ngắn gọn, sạch sẽ hơn mà còn ép ứng dụng của bạn tuân thủ chặt chẽ theo tiêu chuẩn thiết kế RESTful API (tương ứng với các thao tác CRUD: Create - Read - Update - Delete).

Bảng đối chiếu tiêu chuẩn RESTful API

Annotation Tương đương với Thao tác CRUD Tính lũy đẳng (Idempotent)* Mục đích sử dụng chính
@PostMapping @RequestMapping(method = POST) C (Create) KHÔNG Tạo ra một tài nguyên (bản ghi) mới. Dữ liệu thường được để trong Body của request.
@GetMapping @RequestMapping(method = GET) R (Read) Lấy dữ liệu từ server. Dữ liệu (nếu có gửi lên) phải nằm trên URL (Path hoặc Query param).
@PutMapping @RequestMapping(method = PUT) U (Update) Cập nhật (hoặc ghi đè) toàn bộ thông tin của một tài nguyên đã tồn tại.
@DeleteMapping @RequestMapping(method = DELETE) D (Delete) Xóa một tài nguyên khỏi hệ thống.

(Tính lũy đẳng - Idempotent: Nếu gọi API đó 1 lần hay 100 lần liên tiếp với cùng một dữ liệu, trạng thái của hệ thống kết quả vẫn như nhau. POST không có tính lũy đẳng vì gọi 100 lần sẽ tạo ra 100 bản ghi mới, trong khi DELETE 1 lần hay 100 lần thì bản ghi đó vẫn chỉ ở trạng thái "đã bị xóa")

Ví dụ: Xây dựng một CRUD Controller hoàn chỉnh

Trong một dự án thực tế, người ta thường dùng @RequestMapping ở trên đầu class để định nghĩa Base URL (tiền tố gốc), sau đó dùng bộ tứ annotation này ở từng phương thức để xử lý chi tiết.

import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestController
@RequestMapping("/api/v1/users") // Base URL chung cho toàn bộ class
public class UserController {

    // 1. READ - Lấy danh sách tất cả người dùng
    // URL thực tế: GET /api/v1/users
    @GetMapping
    public List<User> getAllUsers() {
        return userService.findAll();
    }

    // 1. READ - Lấy thông tin 1 người dùng theo ID
    // URL thực tế: GET /api/v1/users/123
    @GetMapping("/{id}")
    public User getUserById(@PathVariable("id") Long id) {
        return userService.findById(id);
    }

    // 2. CREATE - Tạo người dùng mới
    // URL thực tế: POST /api/v1/users
    // Dữ liệu JSON gửi lên sẽ được ép kiểu vào biến User qua @RequestBody
    @PostMapping
    public User createUser(@RequestBody User newUser) {
        return userService.save(newUser);
    }

    // 3. UPDATE - Cập nhật toàn bộ thông tin người dùng có id = 123
    // URL thực tế: PUT /api/v1/users/123
    @PutMapping("/{id}")
    public User updateUser(@PathVariable("id") Long id, @RequestBody User updatedUser) {
        return userService.update(id, updatedUser);
    }

    // 4. DELETE - Xóa người dùng theo id
    // URL thực tế: DELETE /api/v1/users/123
    @DeleteMapping("/{id}")
    public void deleteUser(@PathVariable("id") Long id) {
        userService.deleteById(id);
    }
}

Một số lưu ý về cấu trúc thiết kế

  • Đường dẫn (Path) nên dùng danh từ số nhiều: Tiêu chuẩn RESTful khuyên dùng danh từ số nhiều (như /users, /products, /orders) thay vì động từ (không nên đặt path là /api/v1/getUser hay /api/v1/create-user). Hành động (get, create) đã được thể hiện qua chính HTTP Method (@GetMapping, @PostMapping).

  • Ngoài ra còn có @PatchMapping: PUT được dùng khi bạn muốn thay thế/ghi đè toàn bộ object. Nếu bạn chỉ muốn cập nhật một phần nhỏ (ví dụ: chỉ đổi mỗi mật khẩu hoặc trạng thái kích hoạt mà không chạm vào các trường khác), chuẩn RESTful định nghĩa nên dùng HTTP PATCH, tương ứng với @PatchMapping trong Spring Boot.

3. @PathVariable

Trong Spring Boot, @PathVariable là một annotation dùng để trích xuất giá trị trực tiếp từ đường dẫn URL (URI template) và gán nó vào một biến (parameter) trong phương thức xử lý của Controller. Đây là tính năng cốt lõi để xây dựng các RESTful API chuẩn, giúp định danh chính xác một tài nguyên (resource)

Để sử dụng @PathVariable, bạn cần định nghĩa một "điểm neo" (placeholder) trên URL bằng cặp ngoặc nhọn { }. Tên bên trong ngoặc nhọn phải khớp với tên biến trong tham số của phương thức.

@RestController
@RequestMapping("/api/products")
public class ProductController {

    // URL thực tế từ Client: GET /api/products/123
    // Điểm neo là {id}
    @GetMapping("/{id}")
    public Product getProduct(@PathVariable Long id) {
        // Spring sẽ tự động cắt số "123" trên URL và gán vào biến 'id'
        return productService.findById(id);
    }
}

Xử lý khi tên biến không khớp (Custom Name)

Mặc định, Spring yêu cầu tên biến Java (ví dụ: Long id) phải giống hệt với tên điểm neo trên URL ({id}). Nếu bạn muốn (hoặc bắt buộc phải) đặt tên khác nhau, bạn phải truyền tên của điểm neo vào bên trong annotation.

// Điểm neo trên URL tên là {productId}
@GetMapping("/detail/{productId}")
public String getProductDetail(@PathVariable("productId") Long id) {
    // Biến Java tên là 'id', nhưng nhờ khai báo rõ "productId", Spring vẫn map chính xác.
    return "Chi tiết sản phẩm " + id;
}

Lấy nhiều @PathVariable trên cùng một URL

Trong các hệ thống thực tế, tài nguyên thường có tính phân cấp (sub-resources). Ví dụ: Lấy "đơn hàng số 5" của "khách hàng số 10". Bạn có thể dùng bao nhiêu @PathVariable tùy thích trên cùng một đường dẫn.

@RestController
@RequestMapping("/api/users")
public class UserOrderController {

    // URL thực tế: GET /api/users/10/orders/5
    @GetMapping("/{userId}/orders/{orderId}")
    public Order getSpecificOrder(
            @PathVariable Long userId, 
            @PathVariable Long orderId) {
        
        return orderService.findSpecificOrder(userId, orderId);
    }
}

Bắt buộc hay Không bắt buộc? (Required)

Mặc định, biến được gắn @PathVariable là bắt buộc (required = true). Nếu Client gọi URL mà khuyết mất phần này (ví dụ gọi /api/users/ thay vì/api/users/123), Spring sẽ báo lỗi 404 Not Found (hoặc 405 Method Not Allowe).

Lý do là vì trong kiến trúc REST, URL là đường dẫn vật lý xác định tài nguyên. Thiếu ID nghĩa là đường dẫn bị sai, chứ không phải là thiếu tham số. Do đó, rất hiếm khi người ta dùng required = false với @PathVariable (nếu có dùng, thường là do thiết kế API chưa chuẩn).

4. @RequestParam

Trong Spring Boot, @RequestParam là một annotation dùng để trích xuất các tham số truy vấn (Query Parameters) từ URL.

Nếu @PathVariable dùng để lấy dữ liệu nằm bên trong cấu trúc đường dẫn (như /users/123), thì @RequestParam làm nhiệm vụ lấy những dữ liệu nằm sau dấu chấm hỏi ? (ví dụ: /users?role=admin&age=25).

Đây là công cụ chủ lực để xử lý các tác vụ như: Lọc (Filter), Tìm kiếm (Search), Phân trang (Pagination) hoặc Sắp xếp (Sorting).

Theo mặc định, tên của tham số trên URL phải khớp chính xác với tên biến trong phương thức Java của bạn.

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class UserController {

    // URL thực tế từ Client: GET /api/users?role=admin
    @GetMapping("/api/users")
    public String getUsersByRole(@RequestParam String role) {
        // Biến 'role' sẽ nhận giá trị "admin"
        return "Tìm kiếm user với role: " + role;
    }
}

Các thuộc tính cực kỳ quan trọng (Attributes)

Trong thực tế, người dùng có thể truyền thiếu tham số hoặc bạn muốn đặt giá trị mặc định cho phân trang. @RequestParam cung cấp 3 thuộc tính để bạn kiểm soát việc này:

  • name hoặc value: Dùng khi tham số trên URL có tên khác với tên biến Java (hoặc chứa các ký tự đặc biệt mà Java không cho phép đặt tên biến, ví dụ: user-name).

  • required: Mặc định là true. Nếu Client gọi URL mà không truyền tham số này, Spring sẽ ném lỗi 400 Bad Request. Đặt thành false nếu tham số đó là tùy chọn (optional).

  • defaultValue: Nếu Client không truyền tham số, biến sẽ nhận giá trị mặc định này thay vì bị null. (Lưu ý: defaultValue luôn là String, Spring sẽ tự ép kiểu sang số nếu biến của bạn là int, Long).

Ví dụ kết hợp cấu hình Phân trang (Pagination):

// URL gọi thực tế: GET /api/products?category=laptop
// Không truyền 'page' và 'size', hệ thống sẽ tự lấy giá trị mặc định.

@GetMapping("/api/products")
public List<Product> getProducts(
        @RequestParam(name = "category") String category,
        @RequestParam(name = "page", required = false, defaultValue = "1") int page,
        @RequestParam(name = "size", required = false, defaultValue = "10") int size) {
    
    // Hệ thống sẽ trả về danh sách category=laptop, trang 1, kích thước 10
    return productService.filter(category, page, size);
}

Nhận nhiều giá trị hoặc tham số động

a. Nhận một mảng (List/Array) dữ liệu:

Đôi khi client muốn lọc theo nhiều trạng thái cùng lúc: ?status=ACTIVE&status=PENDING hoặc ?status=ACTIVE,PENDING. Bạn chỉ cần khai báo biến là List.

@GetMapping("/api/orders")
public String getOrdersByStatus(@RequestParam List<String> status) {
    // Biến status sẽ là một list chứa ["ACTIVE", "PENDING"]
    return "Lọc đơn hàng theo các trạng thái: " + status;
}

b. Nhận tất cả tham số dưới dạng Map (Dynamic Params):

Nếu bạn không biết trước Client sẽ gửi lên những tiêu chí tìm kiếm nào (người dùng filter tự do), bạn có thể gom tất cả Query Params vào một Map<String, String>.

// URL: GET /api/search?color=red&priceMin=100&brand=apple

@GetMapping("/api/search")
public String searchDynamic(@RequestParam Map<String, String> allParams) {
    // allParams sẽ chứa { "color":"red", "priceMin":"100", "brand":"apple" }
    return "Tìm kiếm với các tiêu chí: " + allParams;
}

Khi nào @RequestParam thay vì @PathVariable? (Quy tắc thiết kế)

Một nhầm lẫn cực kỳ phổ biến của người mới học là không biết khi nào lấy dữ liệu bằng dấu gạch chéo (/123) và khi nào lấy bằng dấu hỏi chấm (?id=123).

  • Dùng @PathVariable (Ví dụ: /users/123): Khi giá trị đó dùng để Định danh (Identify) một tài nguyên duy nhất, cụ thể. Nó là một phần cấu trúc bắt buộc của hệ thống.

  • Dùng @RequestParam (Ví dụ: /users?role=admin&age=20): Khi giá trị đó dùng để Lọc (Filter), Tìm kiếm (Search), Phân trang (Pagination), hoặc các tham số tùy chọn bổ sung (Optional).

Ví dụ một API chuẩn RESTful kết hợp cả hai: GET /api/users/123/orders?status=DELIVERED

(Dịch nghĩa: Lấy danh sách đơn hàng của khách hàng ID = 123 (Định danh -> Dùng PathVariable), nhưng chỉ lọc ra những đơn hàng có trạng thái là DELIVERED (Bộ lọc -> Dùng RequestParam)).

5. @RequestBody

Trong Spring Boot, @RequestBody là annotation dùng để trích xuất dữ liệu từ phần thân (Body) của HTTP Request và chuyển đổi nó thành một đối tượng Java (Java Object).

Nếu @ResponseBody làm nhiệm vụ dịch từ Java sang JSON để trả về cho Client, thì @RequestBody làm nhiệm vụ ngược lại: Dịch dữ liệu JSON (hoặc XML) từ Client gửi lên thành đối tượng Java để hệ thống có thể xử lý.

Khi Client (trình duyệt, Postman, Mobile App) gửi một yêu cầu POST hoặc PUT, họ thường đính kèm một gói dữ liệu JSON.

Khi Request này đi vào Controller, Spring sẽ:

  1. Đọc header Content-Type của request (thường là application/json).

  2. Giao chuỗi JSON đó cho một trình chuyển đổi (HttpMessageConverter, mặc định là thư viện Jackson).

  3. Jackson sẽ quét các thuộc tính trong file JSON, khớp (map) chúng với các thuộc tính (fields) của class Java, tự động khởi tạo đối tượng và truyền vào hàm của bạn.

Ví dụ sử dụng cơ bản

Giả sử Client gửi lên một đoạn JSON để đăng ký tài khoản:

{
  "username": "john_doe",
  "email": "john@example.com",
  "age": 25
}

Bạn tạo một class (thường gọi là DTO - Data Transfer Object) để hứng dữ liệu này:

public class UserRegistrationDto {
    private String username;
    private String email;
    private int age;
    // Cần có các hàm Getter/Setter (hoặc dùng @Data của Lombok)
}

Trong Controller, bạn dùng @RequestBody để nhận:

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AuthController {

    @PostMapping("/api/register")
    public String registerUser(@RequestBody UserRegistrationDto requestData) {
        // Lúc này requestData đã chứa đầy đủ {username: "john_doe", email: "john...", age: 25}
        System.out.println("Đăng ký cho user: " + requestData.getUsername());
        
        return "Đăng ký thành công!";
    }
}

Những lưu ý cực kỳ quan trọng trong thực tế

a. Chỉ dùng cho POST, PUT, PATCH

Theo chuẩn HTTP, các phương thức GETDELETE không nên chứa Body. Do đó, bạn không nên sử dụng @RequestBody đi kèm với @GetMapping hoặc @DeleteMapping. Nếu muốn truyền dữ liệu cho GET/DELETE, hãy dùng @PathVariable hoặc @RequestParam.

b. Lỗi "Khuyết dữ liệu"

Mặc định, @RequestBody là bắt buộc (required = true). Nếu Client gọi API POST mà phần Body bị trống rỗng, Spring sẽ báo lỗi 400 Bad Request. Nếu bạn muốn cho phép Body trống, có thể dùng @RequestBody(required = false).

c. Combo thực chiến: @RequestBody + @Valid

Trong các dự án thực tế, người ta hiếm khi dùng @RequestBody đứng một mình. Bạn không thể tin tưởng 100% dữ liệu từ Client gửi lên (ví dụ: email bị sai định dạng, tuổi bị âm).

Do đó, @RequestBody luôn được ghép cặp với @Valid (hoặc @Validated) để Spring tự động kiểm tra tính hợp lệ của dữ liệu trước khi cho phép chạy vào logic bên trong.

import jakarta.validation.Valid;

@PostMapping("/api/register")
public String registerUser(@Valid @RequestBody UserRegistrationDto requestData) {
    // Nếu JSON gửi lên có email sai định dạng (dựa trên cấu hình trong DTO),
    // Spring sẽ chặn đứng lại và báo lỗi ngay lập tức, hàm này không bị thực thi.
    return authService.register(requestData);
}

6. @Valid

Trong Spring Boot, @Valid (thuộc package jakarta.validation.Valid) đóng vai trò là "Người gác cổng". Nhiệm vụ của nó là tự động kiểm tra tính hợp lệ của dữ liệu đầu vào (validation) trước khi cho phép luồng xử lý đi sâu vào bên trong Controller và Tầng Service.

Thay vì bạn phải viết hàng chục dòng lệnh if/else để kiểm tra thủ công (ví dụ: if (username == null) return error;), @Valid kết hợp với các annotation ràng buộc (Constraints) sẽ tự động hóa toàn bộ quy trình này.

Kể từ Spring Boot 2.3 trở đi, module Validation đã bị tách ra khỏi gói web mặc định. Để sử dụng @Valid, bạn bắt buộc phải thêm thư viện sau vào file pom.xml:

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

Cách hoạt động (Quy trình 2 bước)

Bước 1: Định nghĩa luật (Rules) trên DTO (Data Transfer Object)

Bạn sử dụng các annotation của thư viện Jakarta Validation (như @NotBlank, @Email, @Min, @Size,...) để đặt ra quy tắc cho từng thuộc tính.

import jakarta.validation.constraints.*;

public class UserDto {

    @NotBlank(message = "Tên đăng nhập không được để trống")
    @Size(min = 5, max = 20, message = "Tên đăng nhập phải từ 5 đến 20 ký tự")
    private String username;

    @NotBlank(message = "Email không được để trống")
    @Email(message = "Email không đúng định dạng")
    private String email;

    @Min(value = 18, message = "Tuổi phải từ 18 trở lên")
    private int age;
    
    // Getters và Setters (Hoặc dùng @Data của Lombok)
}

Bước 2: Kích hoạt kiểm tra bằng @Valid tại Controller

Khi nhận request từ Client, bạn gắn @Valid ngay trước tham số có @RequestBody (hoặc @ModelAttribute).

import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/users")
public class UserController {

    @PostMapping("/register")
    public String registerUser(@Valid @RequestBody UserDto userDto) {
        // Nếu Spring thấy DTO vi phạm bất kỳ rule nào ở Bước 1,
        // nó sẽ chặn Request lại ngay lập tức và ném ra lỗi 400 Bad Request.
        // Dòng code này (và các logic Service) SẼ KHÔNG ĐƯỢC CHẠY.
        
        return "Đăng ký thành công cho: " + userDto.getUsername();
    }
}

Điều gì xảy ra khi Validation thất bại?

Theo mặc định, nếu dữ liệu không hợp lệ, Spring Boot sẽ ném ra một Exception có tên là MethodArgumentNotValidException và trả về cho Client một chuỗi lỗi khá cồng kềnh, khó đọc.

Trong các dự án thực tế, để API trả về cấu trúc lỗi JSON đẹp và thân thiện (ví dụ: báo chính xác trường nào lỗi, nội dung lỗi là gì), lập trình viên sẽ bắt Exception này lại bằng cách sử dụng @RestControllerAdvice@ExceptionHandler.

import org.springframework.http.HttpStatus;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    // Bắt lỗi Validation và trả về JSON chuẩn
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, String> handleValidationExceptions(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        
        // Lấy tất cả các trường bị lỗi và tin nhắn lỗi (message đã định nghĩa ở DTO)
        ex.getBindingResult().getAllErrors().forEach((error) -> {
            String fieldName = ((FieldError) error).getField();
            String errorMessage = error.getDefaultMessage();
            errors.put(fieldName, errorMessage);
        });
        
        return errors; // Client sẽ nhận được: {"username": "Tên đăng nhập không được để trống", "age": "Tuổi phải từ 18 trở lên"}
    }
}

7. @Validated

Trong Spring Boot, @Validated là một annotation do chính Spring Framework cung cấp (org.springframework.validation.annotation.Validated). Nó được sinh ra để khắc phục hai giới hạn lớn nhất của @Valid (thuộc chuẩn Java EE): Tính năng chia nhóm (Validation Groups) và Kiểm tra tham số rời rạc (Method Validation).

Dưới đây là chi tiết về 2 "siêu năng lực" của @Validated@Valid không thể làm được.

1️⃣Siêu năng lực 1: Validation Groups (Chia nhóm kiểm tra)

Vấn đề: Giả sử bạn có một đối tượng UserDto dùng chung cho cả 2 API: Tạo mới (Create) và Cập nhật (Update).

  • Khi Tạo mới: ID bắt buộc phải để trống (vì DB sẽ tự tăng).
  • Khi Cập nhật: ID bắt buộc phải có (để biết đang sửa ai). Nếu chỉ dùng @Valid, bạn không thể thiết lập 2 luật trái ngược nhau trên cùng một trường id. Bạn sẽ phải tạo ra 2 class DTO khác nhau (CreateUserDto và UpdateUserDto), gây trùng lặp code.

Cách @Validated giải quyết: Bạn có thể tạo ra các "nhóm" (chỉ là các Interface rỗng) và gắn luật cho từng nhóm.

Bước 1: Tạo các interface đánh dấu (Marker Interfaces)

public interface OnCreate {}
public interface OnUpdate {}

Bước 2: Gắn nhóm vào DTO

public class UserDto {

    // Nhóm Create yêu cầu null, nhóm Update yêu cầu NotNull
    @Null(groups = OnCreate.class, message = "Không được truyền ID khi tạo mới")
    @NotNull(groups = OnUpdate.class, message = "Bắt buộc phải có ID khi cập nhật")
    private Long id;

    // Không ghi nhóm nào thì mặc định áp dụng cho tất cả, hoặc ghi rõ cả 2
    @NotBlank(groups = {OnCreate.class, OnUpdate.class})
    private String username;
}

Bước 3: Sử dụng @Validated ở Controller để kích hoạt đúng nhóm

@RestController
@RequestMapping("/api/users")
public class UserController {

    // Chỉ chạy các validation thuộc nhóm OnCreate
    @PostMapping
    public String createUser(@Validated(OnCreate.class) @RequestBody UserDto dto) {
        return "Tạo mới thành công";
    }

    // Chỉ chạy các validation thuộc nhóm OnUpdate
    @PutMapping
    public String updateUser(@Validated(OnUpdate.class) @RequestBody UserDto dto) {
        return "Cập nhật thành công";
    }
}

2️⃣Siêu năng lực 2: Method Validation (Kiểm tra tham số rời rạc)

Vấn đề: @Valid thường chỉ hoạt động tốt khi bạn kiểm tra một Object phức tạp (như DTO) truyền qua @RequestBody. Nhưng nếu bạn muốn kiểm tra trực tiếp các tham số nguyên thủy truyền qua @RequestParam hoặc @PathVariable (ví dụ: tuổi phải >= 18) thì @Valid sẽ "bất lực".

Cách @Validated giải quyết: Bạn phải đặt annotation @Validated lên đầu Class (cấp độ class). Lúc này, Spring sẽ tạo ra một Proxy để tự động chặn và kiểm tra mọi tham số rời rạc của tất cả các hàm bên trong class đó.

import org.springframework.validation.annotation.Validated;
import jakarta.validation.constraints.Min;

@RestController
@RequestMapping("/api/users")
@Validated // BẮT BUỘC PHẢI CÓ Ở ĐÂY ĐỂ KIỂM TRA THAM SỐ RỜI RẠC
public class UserController {

    // API: GET /api/users/check?age=15
    @GetMapping("/check")
    public String checkAge(
            // Đặt trực tiếp constraint lên RequestParam
            @RequestParam @Min(value = 18, message = "Bạn phải từ 18 tuổi trở lên") int age) {
        
        return "Hợp lệ, tuổi của bạn là: " + age;
    }
}

Lưu ý: Khi kiểm tra lỗi ở cấp độ tham số rời rạc này, Spring sẽ ném ra lỗi ConstraintViolationException thay vì MethodArgumentNotValidException như khi dùng với DTO.

Tóm tắt: Khi nào dùng @Valid và Khi nào dùng @Validated?

Tình huống Nên dùng annotation nào? Vị trí đặt
1. Validate một Object (DTO) cơ bản @Valid (Chuẩn Java EE, dễ dùng). Trước tham số (ví dụ: @Valid @RequestBody UserDto).
Trước tham số (ví dụ: @Valid @RequestBody UserDto). @Validated Trước tham số và truyền tên nhóm: @Validated(OnCreate.class)
3. Validate trực tiếp @RequestParam hoặc @PathVariable @Validated Đặt ở đầu Class (Controller/Service).

Phần IV: Project Lombok (Thường dùng kèm)

LINK BÀI VIẾT CỦA MÌNH: 16 Lombok Annotations trong 4 phút

Phần V: Tương tác Cơ sở dữ liệu (Spring Data JPA)

1. @Entity

Trong Spring Boot (cụ thể là khi sử dụng module Spring Data JPA và Hibernate), @Entity là một annotation cốt lõi dùng để đánh dấu một class Java là đại diện cho một Bảng (Table) trong cơ sở dữ liệu (Database).

Nếu @Controller là người tiếp tân, @Service là bộ não tính toán, @Repository là thủ kho, thì @Entity chính là bản thiết kế của món hàng mà thủ kho sẽ cất vào kho. Mọi đối tượng (object) được tạo ra từ class @Entity sẽ tương ứng với một dòng (row) trong bảng dữ liệu.

Cách sử dụng và Cấu trúc cơ bản

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;

@Entity
public class Product {

    @Id // BẮT BUỘC: Đánh dấu đây là Khóa chính (Primary Key)
    @GeneratedValue(strategy = GenerationType.IDENTITY) // Tự động tăng (Auto Increment)
    private Long id;

    private String name;
    private double price;

    // BẮT BUỘC: Phải có constructor mặc định không tham số (No-args constructor)
    public Product() {}

    public Product(String name, double price) {
        this.name = name;
        this.price = price;
    }

    // Cần có các Getters và Setters để Hibernate đọc/ghi dữ liệu
}

Ba quy tắc "Sống còn" của @Entity

Theo chuẩn đặc tả của JPA (Java Persistence API), một class gắn @Entity bắt buộc phải tuân thủ 3 luật sau, nếu vi phạm ứng dụng sẽ báo lỗi ngay khi khởi động:

  1. Phải có Khóa chính (@Id): Mỗi bảng trong database phải có cách để phân biệt các dòng với nhau. Bạn phải gắn @Id lên ít nhất một thuộc tính.

  2. Phải có Constructor không tham số: Hibernate sử dụng công nghệ Reflection để tự động tạo ra các object khi lấy dữ liệu từ DB lên, quá trình này cần một constructor rỗng (public hoặc protected).

  3. Class không được là final: Hibernate cần tạo ra các class Proxy (kế thừa từ Entity của bạn) để thực hiện kỹ thuật Lazy Loading (tải dữ liệu lười biếng). Nếu class là final, nó không thể được kế thừa.

Cạm bẫy thực chiến: @Entity và Lombok (@Data)

Nếu bạn đang dùng thư viện Lombok để code cho ngắn, tuyệt đối KHÔNG nên dùng @Data chung với @Entity.

@Data tự động sinh ra các hàm equals(), hashCode(), và toString() bao gồm tất cả các thuộc tính. Khi Entity của bạn có quan hệ với Entity khác (ví dụ: User có danh sách Order, Order lại thuộc về User), việc gọi toString() sẽ tạo ra một vòng lặp vô tận (Infinite Recursion) gây tràn bộ nhớ (StackOverflowError) và sập ứng dụng.

Cách làm chuẩn mực (Best Practice) với Lombok:

import lombok.Getter;
import lombok.Setter;
import lombok.NoArgsConstructor;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;

@Entity
@Getter
@Setter
@NoArgsConstructor // Lombok tự sinh constructor rỗng
public class Employee {
    
    @Id
    private Long id;
    private String fullName;
}

2. @Table

Trong Spring Boot (JPA / Hibernate), nếu @Entity là lời tuyên bố "Class này sẽ được lưu thành một bảng", thì @Table chính là bản hướng dẫn chi tiết để nói với Database rằng: "Cái bảng đó tên chính xác là gì, nằm ở đâu, và có những quy tắc đặc biệt nào không?".

Mặc định, nếu bạn chỉ dùng @Entity mà không có @Table, Hibernate sẽ lấy chính tên của Class (hoặc đôi khi tự động chuyển sang dạng snake_case tùy cấu hình) để làm tên bảng. Tuy nhiên, trong thực tế dự án, chúng ta gần như luôn luôn sử dụng @Table để kiểm soát chặt chẽ cơ sở dữ liệu.

Mục đích phổ biến nhất: Đổi tên Bảng

Trong cơ sở dữ liệu (SQL), có rất nhiều từ khóa cấm (Reserved Keywords) như USER, ORDER, GROUP, TABLE, SELECT. Nếu class Java của bạn vô tình trùng tên với các từ khóa này, ứng dụng sẽ bị crash (báo lỗi SQL Syntax) khi khởi động.

@Table giúp bạn chỉ định một cái tên an toàn và chuẩn mực hơn cho Database (thường tuân theo quy tắc số nhiều và dùng dấu gạch dưới).

import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import jakarta.persistence.Id;

@Entity
// Đổi tên bảng thành "tbl_users" hoặc "users" để tránh từ khóa cấm "USER"
@Table(name = "tbl_users") 
public class User {
    
    @Id
    private Long id;
    private String username;
}

Định nghĩa các quy tắc ràng buộc (Unique Constraints)

Đôi khi, bạn muốn đảm bảo rằng sự kết hợp của 2 cột không bao giờ được trùng lặp. Ví dụ: Trong một bảng EmployeeProject, một nhân viên chỉ được tham gia một dự án duy nhất 1 lần (nghĩa là cặp employee_idproject_id phải là duy nhất).

Bạn không thể dùng @Column(unique = true) vì nó chỉ áp dụng cho 1 cột đơn lẻ. Lúc này, bạn phải cấu hình ở cấp độ bảng bằng thuộc tính uniqueConstraints:

import jakarta.persistence.*;

@Entity
@Table(
    name = "employee_project",
    uniqueConstraints = {
        // Đảm bảo không có 2 dòng nào có cùng mã nhân viên VÀ mã dự án
        @UniqueConstraint(name = "uk_emp_proj", columnNames = {"employee_id", "project_id"})
    }
)
public class EmployeeProject {
    @Id
    private Long id;

    @Column(name = "employee_id")
    private Long employeeId;

    @Column(name = "project_id")
    private Long projectId;
}

Đánh chỉ mục (Indexes) để tăng tốc độ tìm kiếm

Nếu ứng dụng của bạn thường xuyên phải tìm kiếm (Query) theo một cột nào đó (ví dụ: tìm user theo số điện thoại hoặc email), việc đánh Index ở Database là bắt buộc để tránh hệ thống bị chậm khi dữ liệu phình to.

Bạn có thể ra lệnh cho Hibernate tự động tạo Index ở dưới DB thông qua thuộc tính indexes của @Table:

@Entity
@Table(
    name = "users",
    indexes = {
        // Tạo index trên cột email để tốc độ đăng nhập/tìm kiếm nhanh hơn
        @Index(name = "idx_user_email", columnList = "email"),
        // Tạo index trên nhiều cột cùng lúc
        @Index(name = "idx_first_last_name", columnList = "first_name, last_name")
    }
)
public class User {
    // ...
}

Chỉ định Schema hoặc Catalog (Dành cho DB lớn)

Trong các hệ thống lớn sử dụng PostgreSQL, Oracle hoặc SQL Server, một Database có thể được chia thành nhiều khu vực nhỏ gọi là Schema (ví dụ: schema cho hệ thống nhân sự hr, schema cho bán hàng sales).

Bạn có thể chỉ định rõ bảng này sẽ được tạo trong schema nào:Trong các hệ thống lớn sử dụng PostgreSQL, Oracle hoặc SQL Server, một Database có thể được chia thành nhiều khu vực nhỏ gọi là Schema (ví dụ: schema cho hệ thống nhân sự hr, schema cho bán hàng sales).

Bạn có thể chỉ định rõ bảng này sẽ được tạo trong schema nào:

@Entity
@Table(name = "salary_records", schema = "hr")
public class SalaryRecord {
    // Bảng này sẽ được lưu với tên đầy đủ là: hr.salary_records
}

💡 Lưu ý Thực chiến (Best Practice): Mặc định Spring Boot sử dụng một chiến lược đặt tên gọi là CamelCaseToUnderscoresNamingStrategy. Tức là nếu bạn có class tên là UserProfile và bạn KHÔNG dùng @Table, nó sẽ tự động tạo bảng tên là user_profile ở DB. Tuy nhiên, để code rõ ràng, dễ bảo trì, và an toàn khi chuyển đổi qua lại giữa các loại Database (MySQL, Oracle, SQL Server...), các đội ngũ dự án thường thống nhất luôn luôn viết @Table(name = "...") một cách tường minh thay vì dựa dẫm vào tính năng tự động của Spring.

3. @Id

Trong Spring Boot (JPA / Hibernate), @Id là một annotation (thuộc package jakarta.persistence) mang ý nghĩa sống còn: Nó được dùng để đánh dấu một thuộc tính (field) đóng vai trò là Khóa chính (Primary Key) của bảng trong cơ sở dữ liệu.

Theo đặc tả của JPA, mọi class được gắn @Entity đều BẮT BUỘC phải có ít nhất một trường được đánh dấu @Id. Nếu không, ứng dụng sẽ báo lỗi (crash) ngay khi vừa khởi động.

Dưới đây là chi tiết về cách sử dụng @Id và các chiến lược tạo giá trị tự động (Auto-generation) đi kèm với nó.

Sử dụng @Id cơ bản (Khóa chính tự gán)

Nếu bạn chỉ dùng một mình annotation @Id, điều đó có nghĩa là bạn (hoặc người dùng) sẽ tự tay cung cấp giá trị cho khóa chính này trước khi lưu xuống Database.

Thường được dùng cho các trường hợp mã (code) do con người hoặc hệ thống bên ngoài tự định nghĩa (ví dụ: Mã sinh viên, Mã số thuế, CCCD).

import jakarta.persistence.Entity;
import jakarta.persistence.Id;

@Entity
public class Employee {

    @Id // Đánh dấu đây là Khóa chính
    private String employeeCode; // Ví dụ: "EMP-001" (Người dùng tự nhập)

    private String fullName;
}

Lưu ý: Nếu bạn gọi lệnh save() mà quên set giá trị cho employeeCode, hệ thống sẽ văng lỗi vì Khóa chính không bao giờ được phép null.

Sinh khóa tự động với @GeneratedValue

Trong 90% trường hợp thực tế, chúng ta không tự nhập Khóa chính mà muốn Database tự động sinh ra một con số (tăng dần 1, 2, 3...) mỗi khi có một dòng dữ liệu mới.

Để làm được điều này, bạn phải kết hợp @Id với annotation @GeneratedValue. Spring Boot (Hibernate) cung cấp 4 chiến lược (Strategy) tạo khóa chính như sau:

a. GenerationType.IDENTITY (Phổ biến nhất)

Chiến lược này giao toàn quyền sinh khóa cho Database bằng cột tự tăng (ví dụ: AUTO_INCREMENT trong MySQL, PostgreSQL). Hibernate sẽ gửi lệnh INSERT trắng trường ID, DB tự sinh ID và trả về cho Hibernate.

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; // ID tự nhảy 1, 2, 3...

👉 Khuyên dùng cho: Các database hỗ trợ AUTO_INCREMENT như MySQL, SQL Server, PostgreSQL.

b. GenerationType.SEQUENCE (Tối ưu hiệu năng cho DB lớn)

Thay vì gắn trực tiếp thuộc tính tự tăng vào bảng, chiến lược này sử dụng một "bộ đếm" độc lập trong Database gọi là Sequence. Hibernate có thể lấy trước (fetch) 50 cái ID một lúc từ Sequence mang về bộ nhớ Java, sau đó chèn dần vào DB giúp tăng tốc độ insert cực nhanh (kỹ thuật batch insert).

@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "user_seq")
@SequenceGenerator(name = "user_seq", sequenceName = "my_user_sequence", allocationSize = 50)
private Long id;

👉 Khuyên dùng cho: Oracle, PostgreSQL (Các hệ thống lớn cần Insert hàng vạn record cùng lúc).

c. GenerationType.AUTO (Mặc định)

Nếu bạn không ghi thuộc tính strategy, Spring sẽ dùng AUTO. Ở chế độ này, Hibernate sẽ tự động nhìn vào loại Database bạn đang dùng và tự chọn chiến lược phù hợp (thường là SEQUENCE hoặc tạo hẳn một bảng riêng để lưu số đếm). 👉 Lời khuyên: Không nên dùng. Hãy chỉ định rõ ràng IDENTITY hoặc SEQUENCE để kiểm soát tốt nhất hệ thống của bạn.

d. Sinh mã UUID (Unique ngẫu nhiên)

Nếu dự án của bạn là hệ thống phân tán (Microservices) hoặc bạn không muốn người ngoài đoán được số lượng user bằng cách nhìn vào ID (vì ID 1, 2, 3 rất dễ đoán), người ta thường dùng UUID (Một chuỗi 36 ký tự cực kỳ phức tạp).

import java.util.UUID;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;

@Id
@GeneratedValue(strategy = GenerationType.UUID) // Từ Hibernate 6 (Spring Boot 3)
private UUID id; 
// Kết quả: 550e8400-e29b-41d4-a716-446655440000

Vấn đề thực chiến: Khóa chính phức hợp (Composite Key)

Đôi khi, khóa chính của bạn không phải là 1 cột, mà là sự kết hợp của 2 cột (Ví dụ: Bảng StudentSubject điểm danh có khóa chính là student_idsubject_id).

Bạn KHÔNG THỂ gắn 2 chữ @Id vào 2 biến riêng lẻ được. Để giải quyết bài toán này, bạn cần sử dụng một trong hai kỹ thuật là @IdClass hoặc @EmbeddedId.

Ví dụ sử dụng @EmbeddedId:

// BƯỚC 1: Tạo một class chứa 2 ID
@Embeddable
public class StudentSubjectId implements Serializable {
    private Long studentId;
    private Long subjectId;
}

// BƯỚC 2: Sử dụng trong Entity
@Entity
public class StudentSubject {
    
    @EmbeddedId // Đánh dấu thuộc tính này là Khóa chính phức hợp
    private StudentSubjectId id;

    private Double score;
}

4. @Column

Trong Spring Boot (JPA / Hibernate), nếu @Entity là bản thiết kế của cái Bảng, thì @Column (thuộc package jakarta.persistence) chính là bản vẽ chi tiết cho từng Cột (Column) trong bảng đó.

Mặc định, nếu bạn khai báo một thuộc tính trong class @Entity (như String firstName), Hibernate sẽ tự động tạo một cột trong Database. Spring Boot có cơ chế ngầm định tự động chuyển đổi định dạng tên từ camelCase (chữ lạc đà) sang snake_case (rắn cắn), tức là firstName sẽ thành cột first_name.

Tuy nhiên, khi bạn muốn can thiệp sâu hơn vào cấu trúc của cột (độ dài, bắt buộc nhập, duy nhất,...), bạn sẽ cần sử dụng @Column.

Các thuộc tính (Attributes) thường dùng nhất

Dưới đây là những tùy chỉnh bạn sẽ gặp ở hầu hết các dự án thực tế:

a. name - Đổi tên cột

// Bắt Hibernate phải map với cột tên là "usr_email_address" thay vì "email"
@Column(name = "usr_email_address")
private String email;

b. nullable - Bắt buộc nhập (NOT NULL)

Mặc định nullable = true (cho phép trống). Khi đổi thành false, cột đó trong Database sẽ được gắn cờ NOT NULL. Nếu bạn cố lưu một object có trường này bằng null, ứng dụng sẽ văng lỗi.

@Column(nullable = false)
private String password;

c. unique - Giá trị duy nhất (UNIQUE)

Đảm bảo không có 2 dòng nào trong bảng có cùng một giá trị ở cột này. Thường dùng cho Email, Số điện thoại, CCCD.

@Column(unique = true)
private String phoneNumber;

d. length - Độ dài chuỗi (VARCHAR size)

Chỉ áp dụng cho kiểu String. Mặc định Hibernate sẽ tạo cột VARCHAR(255). Bạn nên tinh chỉnh lại cho chuẩn với thiết kế.

@Column(length = 10) // Sẽ tạo thành VARCHAR(10) trong DB
private String zipCode;

Các thuộc tính nâng cao (Dành cho Logic nghiệp vụ)

a. updatableinsertable - Khóa thao tác

Đôi khi có những cột bạn chỉ muốn lưu lần đầu tiên lúc tạo mới, và vĩnh viễn không bao giờ được phép sửa đổi (ví dụ: ngày tạo tài khoản, người tạo).

  • updatable = false: Lệnh UPDATE của SQL sẽ bỏ qua cột này.
  • insertable = false: Lệnh INSERT của SQL sẽ bỏ qua cột này.
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt; // Cột này không bao giờ bị sửa sau khi đã tạo

b. columnDefinition - Định nghĩa SQL "hardcode"

Nếu bạn muốn dùng một kiểu dữ liệu đặc thù của riêng loại Database bạn đang dùng (ví dụ kiểu JSON trong MySQL, kiểu TEXT siêu dài), hoặc muốn set giá trị Default dưới DB.

// Ép Hibernate phải tạo cột bằng câu lệnh SQL chuẩn xác này
@Column(columnDefinition = "TEXT")
private String description;

@Column(columnDefinition = "boolean default true")
private Boolean isActive;

(Lưu ý: Hạn chế lạm dụng columnDefinition vì nó làm mất đi tính "độc lập CSDL" của Hibernate. Nếu sau này dự án đổi từ MySQL sang Oracle, câu lệnh SQL bị hardcode này có thể gây lỗi).

Ví dụ thực chiến tổng hợp

Khi kết hợp tất cả lại, một Entity chuẩn mực trong dự án thực tế sẽ trông như thế này:

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // Tên cột là username, không được null, không được trùng, dài tối đa 50 ký tự
    @Column(name = "username", nullable = false, unique = true, length = 50)
    private String username;

    // Password bắt buộc phải có
    @Column(nullable = false)
    private String password;

    // Email không được trùng, độ dài mặc định là 255
    @Column(unique = true)
    private String email;

    // Ngày tạo: Không được null, không cho phép update sau khi đã lưu lần đầu
    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    // Dùng kiểu TEXT của database thay vì VARCHAR
    @Column(columnDefinition = "TEXT")
    private String bio;
}

Sau khi đã thiết kế xong Bảng và Cột, mảnh ghép quan trọng nhất của Database Relational (Cơ sở dữ liệu quan hệ) là cách các bảng liên kết với nhau.

5. @OneToOne, @OneToMany, @ManyToOne, @ManyToMany

Bốn annotation @OneToOne, @OneToMany, @ManyToOne@ManyToMany là trái tim của JPA/Hibernate. Chúng giúp chuyển đổi mô hình Cơ sở dữ liệu quan hệ (Relational Database) với các Khóa ngoại (Foreign Key) thành thế giới Lập trình hướng đối tượng (OOP) của Java.

Để làm chủ được chúng, bạn cần nhớ một nguyên tắc bất di bất dịch: Trong một mối quan hệ luôn có "Bên chủ" (Owner) và "Bên phụ" (Inverse). Bên chủ là bên nắm giữ Khóa ngoại trong Database.

1. @OneToOne (Quan hệ Một - Một)

Kịch bản: Mỗi User (Người dùng) có đúng 1 UserProfile (Hồ sơ chi tiết), và ngược lại.

Cách thiết lập: Chúng ta quyết định bảng User sẽ giữ khóa ngoại profile_id. Vậy User là Bên chủ.

// BÊN CHỦ (Nắm giữ khóa ngoại)
@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // @JoinColumn ra lệnh: Tạo một cột tên là "profile_id" trong bảng user
    @OneToOne
    @JoinColumn(name = "profile_id") 
    private UserProfile profile;
}

// BÊN PHỤ (Không chứa khóa ngoại)
@Entity
public class UserProfile {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // mappedBy = "profile" nghĩa là: Hãy nhìn vào biến 'profile' bên class User 
    // để biết cách liên kết. Đừng tạo thêm cột nào ở bảng user_profile cả.
    @OneToOne(mappedBy = "profile")
    private User user;
}

2. @OneToMany & @ManyToOne (Quan hệ Một - Nhiều / Nhiều - Một)

Đây là mối quan hệ phổ biến nhất (chiếm 80% cấu trúc DB).

Kịch bản: 1 Category (Danh mục) có Nhiều Product (Sản phẩm). 1 Product thuộc về 1 Category.

Nguyên tắc Database: Khóa ngoại luôn luôn nằm ở bảng "Nhiều" (bảng Product). Vậy Product bắt buộc phải là Bên chủ.

// BÊN CHỦ - BẢNG "NHIỀU" (Nắm giữ khóa ngoại)
@Entity
public class Product {
    @Id
    private Long id;

    // Nhiều Product thuộc về 1 Category
    // Tạo cột "category_id" trong bảng product
    @ManyToOne
    @JoinColumn(name = "category_id") 
    private Category category;
}

// BÊN PHỤ - BẢNG "MỘT"
@Entity
public class Category {
    @Id
    private Long id;

    // 1 Category có Nhiều Product
    // mappedBy = "category" -> Ánh xạ theo biến 'category' bên class Product
    @OneToMany(mappedBy = "category")
    private List<Product> products;
}

Lưu ý: Nếu bạn quên chữ mappedBy@OneToMany, Hibernate sẽ tưởng đây là một quan hệ độc lập và tự động sinh ra một bảng thứ 3 (bảng trung gian) làm rác Database.

3. @ManyToMany (Quan hệ Nhiều - Nhiều)

Kịch bản: 1 Student (Sinh viên) học nhiều Course (Khóa học), và 1 Course có nhiều Student.

Trong Database, quan hệ Nhiều-Nhiều bắt buộc phải có một bảng trung gian (Join Table) để nối 2 bảng này lại (ví dụ bảng student_course).

// CHỌN ĐẠI MỘT BÊN LÀM CHỦ (Ví dụ chọn Student)
@Entity
public class Student {
    @Id
    private Long id;

    @ManyToMany
    @JoinTable(
        name = "student_course", // Tên bảng trung gian
        joinColumns = @JoinColumn(name = "student_id"), // Khóa ngoại trỏ về Student
        inverseJoinColumns = @JoinColumn(name = "course_id") // Khóa ngoại trỏ về Course
    )
    private List<Course> courses;
}

// BÊN PHỤ
@Entity
public class Course {
    @Id
    private Long id;

    @ManyToMany(mappedBy = "courses") // Ánh xạ theo biến 'courses' bên class Student
    private List<Student> students;
}

4. Hai thuộc tính "Sống còn": FetchTypeCascadeType

Khi khai báo các quan hệ trên, bạn sẽ luôn phải đối mặt với 2 cấu hình đi kèm rất quan trọng:

a. Kéo dữ liệu thế nào? (FetchType.LAZY vs FetchType.EAGER)

Khi bạn query lấy 1 Category từ Database, Spring Boot có nên tự động query lấy luôn danh sách hàng nghìn Products của nó lên bộ nhớ không?

  • EAGER (Tham lam): Lấy tất cả lên ngay lập tức. Dễ gây tràn RAM và chậm hệ thống. (Mặc định của @ManyToOne@OneToOne).

  • LAZY (Lười biếng): Chỉ lấy dữ liệu của Category. Danh sách Products sẽ bị bỏ trống, và nó CHỈ được query lấy từ DB khi nào code của bạn thực sự gọi hàm category.getProducts(). (Mặc định của @OneToMany@ManyToMany).

Quy tắc vàng: Ở các dự án lớn, luôn chủ động set tất cả các quan hệ về FetchType.LAZY để tối ưu hiệu năng.

@ManyToOne(fetch = FetchType.LAZY) // Ép về Lazy để tối ưu
@JoinColumn(name = "category_id")
private Category category;

Hiệu ứng dây chuyền (CascadeType)

Khi bạn Xóa (hoặc Lưu) Category, bạn có muốn Spring Boot tự động Xóa (hoặc Lưu) tất cả Products thuộc về nó không?

  • CascadeType.ALL: Áp dụng mọi thao tác dây chuyền (Lưu, Xóa, Cập nhật).
  • CascadeType.REMOVE: Parent bị xóa thì Child cũng bị xóa theo.
// Khi 1 Category bị xóa, toàn bộ Product của nó sẽ bị xóa sạch khỏi DB
@OneToMany(mappedBy = "category", cascade = CascadeType.ALL)
private List<Product> products;

6. @JoinColumn

Trong Spring Boot (JPA / Hibernate), nếu @ManyToOne hay @OneToOne là bản thỏa thuận giữa hai Class Java rằng chúng có liên quan đến nhau, thì @JoinColumn chính là chiếc đinh ghim xác định vị trí chính xác của Khóa ngoại (Foreign Key) ở dưới Database.

Nói cách khác, @JoinColumn là cách bạn ra lệnh cho Hibernate: "Hãy tạo một cột trong bảng này, dùng nó làm Khóa ngoại để trỏ sang bảng kia".

Vị trí đặt @JoinColumn (Quy tắc "Bên Chủ")

Nguyên tắc tối thượng: @JoinColumn LUÔN LUÔN được đặt ở "Bên Chủ" (Owning Side) của mối quan hệ. Bên chủ chính là bảng thực tế trong Database sẽ chứa cột Khóa ngoại.

  • Trong quan hệ @ManyToOne: Bên "Nhiều" luôn là bên chủ (Ví dụ: Bảng Product chứa category_id).

  • Trong quan hệ @OneToOne: Bạn tự quyết định bảng nào chứa khóa ngoại, bảng đó sẽ gắn @JoinColumn.

  • Trong quan hệ @OneToMany: Rất hiếm khi dùng @JoinColumn trực tiếp (vì khóa ngoại nằm ở bảng kia). Thay vào đó, ta dùng mappedBy.

Các thuộc tính quan trọng nhất

Giống như @Column, @JoinColumn cung cấp các công cụ để bạn thiết kế vật lý cho cột Khóa ngoại:

a. name (Tên cột Khóa ngoại)

  • Xác định tên của cột trong Database.
  • Lưu ý: Nếu bạn không dùng @JoinColumn, Hibernate sẽ tự động sinh ra tên cột theo công thức: tên_biến + "" + tên_khóa_chính_bảng_kia (ví dụ: categoryid). Tuy nhiên, best practice là luôn khai báo rõ ràng.

b. referencedColumnName (Cột đích trỏ tới)

  • Mặc định, Khóa ngoại sẽ trỏ tới Khóa chính (@Id) của bảng kia. Nếu bạn muốn khóa ngoại trỏ tới một cột khác (không phải khóa chính, nhưng cột đó phải là Unique), bạn dùng thuộc tính này.

c. nullable (Bắt buộc phải có liên kết không?)

  • Nếu nullable = false, khi bạn lưu Product mà không có Category, Database sẽ chặn lại và báo lỗi.

d. unique (Đảm bảo quan hệ 1-1)

  • Thường được sử dụng đi kèm với @OneToOne để chắc chắn rằng không có 2 dòng nào dùng chung một Khóa ngoại.

Ví dụ thực chiến

Kịch bản: Một Sản phẩm (Product) bắt buộc phải thuộc về một Danh mục (Category). Cột khóa ngoại sẽ tên là cat_id.

@Entity
@Table(name = "product")
public class Product {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    // Đây là "Bên Chủ"
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(
        name = "cat_id",                // Tên cột khóa ngoại trong bảng product
        referencedColumnName = "id",    // Trỏ tới cột 'id' của bảng category (có thể bỏ trống vì là mặc định)
        nullable = false                // Bắt buộc product phải có category
    )
    private Category category;
}

Phân biệt @JoinColumn vs mappedBy

Đây là "cạm bẫy" lớn nhất mà 99% người học JPA đều ít nhất một lần mắc phải. Hãy nhớ rằng chúng là hai thái cực đối lập nhau và KHÔNG BAO GIỜ được đặt chung trên một biến.

Tiêu chí @JoinColumn mappedBy (Bên trong @OneToMany / @OneToOne)
Vai trò "Tôi là chủ. Hãy tạo Khóa ngoại ở bảng của tôi." "Tôi là phụ. Khóa ngoại nằm ở bảng kia kìa, hãy nhìn theo biến đó."
Hành động ở DB Tạo ra một cột vật lý trong bảng hiện tại. KHÔNG tạo ra cột nào trong bảng hiện tại.
Vị trí Đặt ở Bên chứa khóa ngoại (thường là @ManyToOne). Đặt ở Bên không chứa khóa ngoại (thường là @OneToMany)

Ví dụ sửa lỗi sai kinh điển:

// LỖI SAI: Đặt cả 2 cái ở bảng Category
@Entity
public class Category {
    // SAI HOÀN TOÀN: Đã mappedBy (nhường quyền) thì không được dùng JoinColumn (đòi quyền)
    @OneToMany(mappedBy = "category")
    @JoinColumn(name = "category_id") 
    private List<Product> products;
}

// CÁCH VIẾT ĐÚNG:
@Entity
public class Category {
    // Chỉ dùng mappedBy. Báo cho Hibernate biết khóa ngoại nằm ở biến 'category' của class Product
    @OneToMany(mappedBy = "category")
    private List<Product> products;
}

Phần VI: Quản lý Giao dịch (Transaction Management)

1. @Transactional

Trong Spring Boot, @Transactional là một trong những annotation quyền lực và quan trọng nhất khi làm việc với cơ sở dữ liệu. Nó được dùng để quản lý Giao dịch (Transaction).

Hiểu đơn giản, một giao dịch là một chuỗi các thao tác với Database. @Transactional đảm bảo quy tắc "Tất cả hoặc Không gì cả" (All-or-Nothing): Hoặc là toàn bộ chuỗi thao tác thành công (Commit), hoặc nếu có bất kỳ lỗi nào xảy ra ở giữa chừng, toàn bộ dữ liệu sẽ được hoàn tác về trạng thái ban đầu (Rollback).

Bài toán thực tế: Chuyển tiền ngân hàng

Giả sử bạn có hàm chuyển tiền từ tài khoản A sang tài khoản B, gồm 2 bước:

  1. Trừ 500k của A.

  2. Cộng 500k cho B.

Nếu sau khi trừ tiền của A xong, máy chủ bị sập hoặc có lỗi xảy ra khiến bước 2 không thực hiện được. Kết quả: A mất tiền, B không nhận được. Dữ liệu bị sai lệch nghiêm trọng.

Giải pháp: Đặt @Transactional lên đầu hàm.

import org.springframework.transaction.annotation.Transactional; import org.springframework.stereotype.Service;

@Service
public class BankService {

    @Transactional
    public void transferMoney(Long accountA, Long accountB, double amount) {
        // Bước 1: Trừ tiền A
        accountRepository.deductBalance(accountA, amount);
        
        // Cố tình gây lỗi (Ví dụ: chia cho 0, hoặc lỗi kết nối DB)
        // int error = 1 / 0; 

        // Bước 2: Cộng tiền B
        accountRepository.addBalance(accountB, amount);
        
        // Nếu có lỗi ném ra, Spring sẽ tự động Rollback bước 1. Tiền của A vẫn còn nguyên.
    }
}

Hai "cạm bẫy" chết người khi dùng @Transactional

Trong thực tế, 80% lỗi liên quan đến @Transactional đến từ việc lập trình viên không hiểu rõ cơ chế ngầm (Proxy) của nó. Dưới đây là 2 lỗi kinh điển nhất:

Cạm bẫy 1: Quên cấu hình rollbackFor (Lỗi Rollback hụt)

Mặc định, @Transactional CHỈ thực hiện Rollback khi gặp Unchecked Exception (các lỗi kế thừa từ RuntimeException như NullPointerException, IllegalArgumentException).

Nếu code của bạn ném ra một Checked Exception (các lỗi bắt buộc phải có try-catch hoặc throws như IOException, SQLException), Spring sẽ... bỏ qua và vẫn lưu dữ liệu (Commit).

Cách khắc phục: Luôn tạo thói quen khai báo rollbackFor = Exception.class để bắt mọi loại lỗi.

// Ép Spring phải Rollback với TẤT CẢ các loại Exception
@Transactional(rollbackFor = Exception.class)
public void importFile() throws IOException {
    // ... logic đọc file và lưu DB
}

Cạm bẫy 2: Lỗi gọi hàm nội bộ (Self-Invocation / Proxy Bypass)

Bản chất của @Transactional là Spring sẽ tạo ra một lớp "môi giới" (Proxy) bọc ngoài class của bạn. Khi ai đó gọi hàm từ bên ngoài vào, nó phải đi qua Proxy, Proxy sẽ mở Transaction.

Tuy nhiên, nếu bạn gọi một hàm @Transactional từ một hàm khác nằm trong cùng một class, lệnh gọi đó không đi qua Proxy. Kết quả: @Transactional bị vô hiệu hóa hoàn toà

@Service
public class OrderService {

    // Hàm này KHÔNG có @Transactional
    public void createOrder() {
        System.out.println("Bắt đầu tạo đơn...");
        
        // GỌI HÀM NỘI BỘ TRONG CÙNG CLASS
        // Lỗi: Transaction sẽ KHÔNG được kích hoạt!
        saveToDatabase(); 
    }

    @Transactional
    public void saveToDatabase() {
        // ... logic lưu DB
    }
}

Cách khắc phục: Chuyển hàm saveToDatabase sang một Class/Service khác, hoặc gọi trực tiếp từ Controller vào hàm có sẵn @Transactional.

Điều hướng giao dịch (Propagation)

Khi một hàm có @Transactional gọi một hàm khác cũng có @Transactional, Spring sẽ xử lý thế nào? Đây gọi là tính lan truyền (Propagation).

Có 7 loại cấu hình Propagation, nhưng bạn chỉ cần nhớ 2 loại dùng nhiều nhất:

  • Propagation.REQUIRED (Mặc định): "Nhập gia tùy tục". Nếu bên ngoài chưa có giao dịch, nó tạo mới. Nếu bên ngoài đã có, nó sẽ "hòa chung" vào giao dịch bên ngoài. Chỉ cần 1 chỗ lỗi, TẤT CẢ cùng Rollback.

  • Propagation.REQUIRES_NEW: "Khu tự trị". Luôn luôn tạm dừng giao dịch bên ngoài (nếu có) và tạo ra một giao dịch mới hoàn toàn độc lập. Giao dịch mới lỗi chỉ rollback chính nó, không ảnh hưởng đến cái bên ngoài.

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog() {
    // Lưu lịch sử thao tác. 
    // Dù giao dịch chính bên ngoài có bị lỗi và rollback, 
    // hàm lưu log này vẫn được commit vào DB.
}

Vị trí đặt @Transactional chuẩn mực

  • Nên đặt ở Tầng Service (@Service): Vì đây là nơi chứa Logic nghiệp vụ (Business Logic). Một tính năng thường gọi nhiều lệnh từ Database (Repository), đặt ở Service giúp bao bọc tất cả trong 1 giao dịch.

  • Có thể đặt ở Class hoặc Method: Nếu đặt trên đầu class, mọi hàm public trong class đó đều tự động có Transaction. Nếu đặt ở hàm, nó sẽ ghi đè cấu hình của class. (Chỉ có tác dụng với hàm public, các hàm private hay protected sẽ bị Spring ngó lơ).

Phần VII: Lập lịch & Xử lý Bất đồng bộ (Scheduling & Async)

1. @EnableScheduling

Trong Spring Boot, @EnableScheduling đóng vai trò là "Công tắc tổng" để bật tính năng Hẹn giờ / Lập lịch chạy ngầm (Scheduled Tasks / Cron Jobs).

Nếu ứng dụng của bạn cần thực hiện những công việc tự động lặp đi lặp lại như: gửi email báo cáo vào 8h sáng mỗi ngày, quét dọn file rác mỗi 30 phút, hoặc cập nhật tỷ giá ngân hàng mỗi 5 giây, bạn sẽ cần đến annotation này.

Kích hoạt tính năng (Bật công tắc)

Để sử dụng lập lịch, trước tiên bạn phải gắn @EnableScheduling vào một class cấu hình (có @Configuration) hoặc gắn trực tiếp lên class Main (class chứa hàm main của Spring Boot).

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling // BẮT BUỘC CÓ: Bật tính năng chạy ngầm cho toàn hệ thống
public class MySpringBootApplication {
    public static void main(String[] args) {
        SpringApplication.run(MySpringBootApplication.class, args);
    }
}

2. @Scheduled

Trong Spring Boot, annotation chính xác được sử dụng để lập lịch chạy tác vụ là @Scheduled (thuộc module Spring Task Scheduling). Nó cho phép bạn thực thi một method tự động lặp đi lặp lại theo một chu kỳ thời gian nhất định (như 5 phút một lần) hoặc vào một thời điểm cụ thể (như 12h đêm mỗi ngày), tương tự như Cron job trong Linux.

Ứng dụng phổ biến: Dọn dẹp dữ liệu rác trong database mỗi đêm, đồng bộ dữ liệu với hệ thống khác mỗi giờ, hoặc gửi email tổng kết hàng ngày.

Định nghĩa các tác vụ với @Scheduled

Sau khi đã bật công tắc tổng, bạn có thể biến bất kỳ hàm (method) nào bên trong các Spring Bean (@Service, @Component) thành một tác vụ tự động bằng cách gắn thêm annotation @Scheduled. Lưu ý bắt buộc: Method này phải trả về void và không được có tham số truyền vào.

Có 3 chiến lược lập lịch phổ biến nhất:

a. fixedRate (Chạy lặp lại theo nhịp độ cố định)

Hệ thống sẽ đếm thời gian kể từ lúc bắt đầu của lần chạy trước. (Tính bằng mili-giây).

@Service
public class ReportService {
    
    // Cứ đều đặn mỗi 5000ms (5 giây) sẽ kích hoạt hàm này một lần.
    @Scheduled(fixedRate = 5000)
    public void generateReport() {
        System.out.println("Đang xuất báo cáo định kỳ...");
    }
}

b. fixedDelay (Chạy lặp lại với thời gian nghỉ cố định)

Hệ thống sẽ đếm thời gian kể từ lúc kết thúc của lần chạy trước. Nó đảm bảo tác vụ cũ phải làm xong hoàn toàn, nghỉ ngơi đủ thời gian thì mới chạy tiếp.

@Service
public class CleanupService {

    // Chờ hàm chạy xong, nghỉ chính xác 10 giây (10000ms) rồi mới chạy lần tiếp theo.
    @Scheduled(fixedDelay = 10000)
    public void clearTempFiles() {
        System.out.println("Đang dọn rác... (Có thể mất thời gian lâu)");
    }
}

c. cron (Hẹn giờ chính xác theo lịch lịch)

Đây là cách mạnh mẽ nhất, sử dụng biểu thức Cron (Cron Expression) để chỉ định chính xác giờ, ngày, tháng chạy tác vụ.

Cấu trúc Cron trong Spring gồm 6 tham số: Giây Phút Giờ Ngày Tháng Thứ.

@Service
public class EmailService {

    // Chạy vào đúng 08:00:00 sáng mỗi ngày
    @Scheduled(cron = "0 0 8 * * *")
    public void sendMorningEmail() {
        System.out.println("Gửi email chào buổi sáng!");
    }

    // Chạy vào lúc 12:00:00 trưa vào các ngày Thứ 2 đến Thứ 6 hàng tuần
    @Scheduled(cron = "0 0 12 * * MON-FRI")
    public void sendWorkReminder() {
        System.out.println("Nhắc nhở công việc trưa");
    }
}

Cạm bẫy "Tắc đường" (Single Thread Pitfall)

Đây là lỗi thực chiến mà rất nhiều người gặp phải.

Theo mặc định, Spring Boot chỉ cấp phát Duy nhất một Luồng (Single Thread) để chạy tất cả các tác vụ @Scheduled trong hệ thống của bạn. Điều này có nghĩa là:

  • Nếu bạn có Task A và Task B cùng đến giờ chạy.
  • Nhưng Task A xử lý quá nặng và mất 10 phút mới xong.
  • Thì Task B bắt buộc phải "xếp hàng" chờ đủ 10 phút đó mới được chạy (dù lịch của nó đã đến từ lâu).

Cách giải quyết: Bạn phải mở rộng Thread Pool (Hồ bơi luồng) cho Scheduler bằng cách thêm cấu hình vào file application.properties:

# Yêu cầu Spring cấp tối đa 10 luồng để chạy các tác vụ lập lịch đồng thời
spring.task.scheduling.pool.size=10

Bí kíp: Xử lý khi fixedRate bị "tắc nghẽn"

Khác với cạm bẫy "Tắc đường" ở trên đó là các tác vụ khác nhau chặn nhau, thế nhưng nếu một luồng tự chặn chính nó thì sao. Cấu hình spring.task.scheduling.pool.size là chưa đủ để giải quyết bài toán này

Như đã biết, fixedRate yêu cầu tác vụ phải chạy đều đặn. Ví dụ: fixedRate = 5000 (cứ 5 giây chạy 1 lần).

  • Lúc 0s: Chạy lần 1.
  • Lúc 5s: Chạy lần 2.
  • Lúc 10s: Chạy lần 3.

Câu hỏi đặt ra: Điều gì xảy ra nếu lần 1 phải xử lý quá nhiều dữ liệu và mất tới 8 giây mới xong? Lần 2 có được chạy đúng vào giây thứ 5 không?

Trả lời: Theo mặc định là KHÔNG. Lần 2 sẽ phải đợi lần 1 xong xuôi (ở giây thứ 8) rồi mới lật đật chạy bù. Nhịp độ bị phá vỡ.

Để giải quyết và ép hệ thống phải chạy song song đúng nhịp độ (lần 1 chưa xong kệ lần 1, đến giây thứ 5 hệ thống tự mở luồng khác để chạy lần 2), bạn phải kết hợp @Scheduled với annotation @Async (Bất đồng bộ).

import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync;

// Lưu ý: Phải gắn thêm @EnableAsync ở class Main hoặc class Configuration

@Service
public class HeavyTaskService {

    @Async // Tách ra một luồng riêng biệt
    @Scheduled(fixedRate = 5000)
    public void processHeavyData() throws InterruptedException {
        System.out.println("Bắt đầu xử lý... Luồng: " + Thread.currentThread().getName());
        
        // Giả lập tác vụ mất 8 giây
        Thread.sleep(8000); 
        
        System.out.println("Xong!");
    }
}

Kết quả: Nhờ @Async, mỗi khi đến hạn 5 giây, Spring sẽ ném tác vụ đó cho một Thread mới xử lý, đảm bảo nhịp độ fixedRate luôn chính xác tuyệt đối mà không bị tắc nghẽn bởi các lần chạy trước.

Tóm lại

  • pool.size=10 dùng để các Task khác nhau không chặn nhau.
  • @Async dùng để các lần chạy của Cùng một Task không chặn nhau.

Giải quyết vấn đề Múi giờ (zone) với Cron

Nếu bạn dùng cron, thời gian mặc định sẽ được tính theo múi giờ của hệ điều hành máy chủ (Server). Điều này rất nguy hiểm nếu Server của bạn đặt ở Mỹ (AWS, Google Cloud) nhưng bạn lại muốn gửi email báo cáo vào 8h sáng giờ Việt Nam.

Thuộc tính zone sẽ giúp bạn chốt cứng múi giờ.

// Bất kể server đặt ở đâu, đúng 08:00 sáng giờ Việt Nam mới chạy
@Scheduled(cron = "0 0 8 * * *", zone = "Asia/Ho_Chi_Minh")
public void sendMorningReport() {
    System.out.println("Gửi báo cáo lúc 8h sáng VN");
}

3. @EnableAsync

Trong Spring Boot, @EnableAsync đóng vai trò là "Công tắc tổng" để để kích hoạt hệ thống xử lý bất đồng bộ.

Kích hoạt và Sử dụng cơ bản

Bạn chỉ cần đặt @EnableAsync lên class Main hoặc một class @Configuration bất kỳ hoặc gắn trực tiếp lên class Main (class chứa hàm main của Spring Boot).

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;

@SpringBootApplication
@EnableAsync // Bật tính năng xử lý bất đồng bộ
public class MySpringBootApplication {
    public static void main(String[] args) {
        SpringApplication.run(MySpringBootApplication.class, args);
    }
}

4. @Async

Trong Spring Boot, annotation @Async được sử dụng để thực thi một method một cách bất đồng bộ (asynchronous). Thay vì block (chặn) thread hiện tại cho đến khi method chạy xong, Spring sẽ đưa task đó cho một thread khác xử lý ngầm, giúp thread chính có thể tiếp tục làm việc khác ngay lập tức.

Ứng dụng phổ biến nhất của @Async là các tác vụ chạy ngầm như: Gửi email, đẩy push notification, gọi API bên thứ ba chậm chạp, hoặc xử lý file/batch job.

Sử dụng trên Method

Đánh dấu method bạn muốn chạy bất đồng bộ bằng @Async. Method này phải trả về void (nếu không cần kết quả) hoặc CompletableFuture (nếu cần lấy kết quả).

@Service
public class EmailService {

    @Async
    public void sendWelcomeEmail(String email) {
        System.out.println("Bắt đầu gửi email trên luồng: " + Thread.currentThread().getName());
        // Giả lập gửi email mất 3 giây
        // Code ở hàm gọi (ví dụ Controller) sẽ không bị khựng lại chờ 3 giây này.
    }
}

Bản chất hoạt động ngầm (Cơ chế Proxy)

Spring sử dụng kỹ thuật AOP (Aspect-Oriented Programming) để xử lý @Async. Khi ứng dụng khởi động, Spring không đưa class EmailService thật cho Controller dùng, mà nó tạo ra một Proxy (Lớp vỏ bọc).

Khi Controller gọi hàm sendWelcomeEmail():

  1. Nó gọi vào Proxy.
  2. Proxy thấy hàm này có @Async, nó liền bốc công việc này ném vào một Thread Pool (Hồ bơi luồng).
  3. Proxy ngay lập tức trả lại quyền điều khiển (return) cho Controller để Controller phản hồi cho người dùng.
  4. Ở dưới background, một Thread trong Pool sẽ lẳng lặng thực thi việc gửi email.

CẠM BẪY CHẾT NGƯỜI: Lỗi tràn RAM (OutOfMemoryError)

Nếu bạn chỉ bật @EnableAsync và dùng, hệ thống của bạn đang mang một quả bom nổ chậm.

Mặc định, Spring Boot sử dụng một trình quản lý luồng tên là SimpleAsyncTaskExecutor. Thằng này có một đặc điểm cực kỳ tồi tệ: Nó KHÔNG tái sử dụng luồng (No Thread Reuse). Cứ mỗi lần bạn gọi hàm @Async, nó lại new Thread() một luồng mới cứng. Nếu có 10.000 user gọi API, nó tạo ra 10.000 luồng. Hệ thống sẽ cạn kiệt RAM và sập ngay lập tức.

Cách khắc phục (Bắt buộc làm thực tế): Bạn phải cấu hình một ThreadPoolTaskExecutor chuẩn mực để giới hạn số lượng luồng.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;

@Configuration
public class AsyncConfig {

    @Bean(name = "customTaskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        
        // 1. Số luồng cốt lõi luôn duy trì (dù không có việc)
        executor.setCorePoolSize(5);
        
        // 2. Số lượng luồng tối đa được phép tạo ra khi quá tải
        executor.setMaxPoolSize(20);
        
        // 3. Hàng đợi: Nếu 20 luồng đều bận, các task tiếp theo sẽ đứng xếp hàng (tối đa 500 task)
        executor.setQueueCapacity(500);
        
        // 4. Tiền tố tên của luồng (để dễ debug trong log)
        executor.setThreadNamePrefix("MyAsyncThread-");
        
        executor.initialize();
        return executor;
    }
}

Lúc này, bạn chỉ định rõ tên Thread Pool khi gọi @Async:

@Async("customTaskExecutor")
public void sendWelcomeEmail(String email) { ... }

Lấy dữ liệu trả về từ hàm @Async

Nếu hàm chạy ngầm của bạn cần tính toán và trả về kết quả (không phải kiểu void), bạn KHÔNG THỂ trả về kiểu dữ liệu thông thường (như String hay User). Bạn bắt buộc phải bọc nó trong CompletableFuture.

@Async("customTaskExecutor")
public CompletableFuture<String> processPayment() {
    // Xử lý mất 5 giây...
    return CompletableFuture.completedFuture("Thanh toán thành công");
}

Ở nơi gọi hàm, bạn có thể chờ kết quả nếu muốn:

CompletableFuture<String> futureResult = paymentService.processPayment();

// Làm các việc khác trong lúc chờ thanh toán...

// Lấy kết quả (Hàm get() sẽ block luồng chính cho đến khi luồng Async làm xong)
String result = futureResult.get();

Cạm bẫy tương tự @Transactional: Self-Invocation

@Async hoạt động dựa trên Proxy, nên nó dính chung một nhược điểm chí mạng với @Transactional: Tính năng sẽ bị vô hiệu hóa nếu bạn gọi hàm nội bộ.

@Service
public class UserService {

    public void registerUser() {
        System.out.println("Đăng ký thành công!");
        
        // GỌI HÀM NỘI BỘ TRONG CÙNG CLASS
        // Lỗi: Hàm này vẫn chạy ĐỒNG BỘ trên cùng một luồng, giao diện người dùng bị treo!
        sendEmailAsync(); 
    }

    @Async
    public void sendEmailAsync() {
        // Gửi email...
    }
}

Cách giải quyết: Giống hệt @Transactional. Bạn phải tách hàm sendEmailAsync() ra một class @Service khác (ví dụ EmailService) và @Autowired nó vào để sử dụng.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí