Nhận tham số: @PathVariable, @RequestParam, @RequestBody và các bạn

5 câu hỏi~25 phútTừ khóa: @PathVariable, @RequestParam, required, defaultValue, @RequestBody, @RequestHeader, @ModelAttribute, Pageable, type conversion, MethodArgumentTypeMismatchException, 400

Tiếp đi: nhận tham số. OrderController của mình cần lấy id trên URL, filter trên query string, body JSON khi tạo order. Mỗi cái dùng annotation nào?

Câu này nền tảng nhưng phỏng vấn hay hỏi kèm bẫy: "thiếu param thì sao?", "gửi chữ vào chỗ số thì trả gì?". Mình đi lần lượt trên OrderController.

1. Bài toán: 4 endpoint của OrderController

POST   /api/orders                         body JSON  → tạo order
GET    /api/orders?status=PAID&page=0&size=20      → list, có filter
GET    /api/orders/42                                 → chi tiết
POST   /api/orders/42/cancel   header X-Request-Id    → hủy

Dữ liệu nằm ở 4 chỗ khác nhau trong HTTP request: path, query string, body, header. Mỗi chỗ có một annotation.

2. Vấn đề

3. Giải pháp: mỗi nguồn dữ liệu một annotation

NguồnAnnotationVí dụAi chuyển đổi
Path@PathVariable/orders/{id}ConversionService
Query string / form@RequestParam?status=PAIDConversionService
Nhiều query param → 1 object@ModelAttribute (có thể bỏ)OrderFilterDataBinder
Body JSON@RequestBodyCreateOrderRequestHttpMessageConverter (Jackson)
Header@RequestHeaderX-Request-IdConversionService
page/size/sortPageable?page=0&size=20&sort=createdAt,descPageableHandlerMethodArgumentResolver

Tất cả đều do các HandlerMethodArgumentResolver xử lý trước khi method controller được gọi (luồng tổng ở bài 4.2.2).

4. Code từng bước

Bước 1: dependency. Chỉ cần spring-boot-starter-web. Pageable cần thêm spring-boot-starter-data-jpa (Spring Data Web support tự bật).

Bước 2: config giới hạn page size để client không xin size=100000:

spring:
  data:
    web:
      pageable:
        default-page-size: 20
        max-page-size: 100

Bước 3: code Java.

public record OrderFilter(
        OrderStatus status,                 // enum: "PAID" → OrderStatus.PAID
        Long customerId,
        @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
        @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {}

public record CreateOrderRequest(Long customerId, List<OrderLineRequest> lines) {}

@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
    private final OrderService orderService;

    @PostMapping
    public OrderResponse create(@RequestBody CreateOrderRequest req) {
        return orderService.create(req);
    }

    @GetMapping
    public Page<OrderResponse> list(OrderFilter filter,          // @ModelAttribute ngầm định
                                    @PageableDefault(sort = "createdAt",
                                        direction = Sort.Direction.DESC) Pageable pageable) {
        return orderService.search(filter, pageable);
    }

    @GetMapping("/{id}")
    public OrderResponse get(@PathVariable Long id) {
        return orderService.get(id);
    }

    @PostMapping("/{id}/cancel")
    public OrderResponse cancel(@PathVariable Long id,
                                @RequestParam(defaultValue = "CUSTOMER_REQUEST") CancelReason reason,
                                @RequestHeader(name = "X-Request-Id", required = false) String requestId) {
        return orderService.cancel(id, reason, requestId);
    }
}

Lưu ý: Spring Boot 3 (Spring 6.1+) không còn tự đọc tên tham số từ debug info. Phải build với -parameters (Spring Boot plugin Maven/Gradle đã bật sẵn), nếu không thì phải ghi @PathVariable("id").

Bước 4: test bằng curl.

curl -s localhost:8080/api/orders/42
curl -s "localhost:8080/api/orders?status=PAID&from=2026-10-01&page=0&size=5&sort=total,desc"
curl -s -X POST localhost:8080/api/orders -H 'Content-Type: application/json' \
     -d '{"customerId":7,"lines":[{"productId":1,"quantity":2}]}'

# Gửi sai kiểu
curl -i localhost:8080/api/orders/abc          # → 400 (MethodArgumentTypeMismatchException)
curl -i "localhost:8080/api/orders?status=XYZ" # → 400 (binding error trên OrderFilter)
curl -i -X POST localhost:8080/api/orders -H 'Content-Type: application/json' -d '{bad'
                                               # → 400 (HttpMessageNotReadableException)

5. Cách hoạt động

HTTP request
   │
DispatcherServlet → HandlerAdapter (RequestMappingHandlerAdapter)
   │   với từng tham số của method:
   │     tìm HandlerMethodArgumentResolver hỗ trợ
   ├─ @PathVariable  → lấy "42" từ URI template → ConversionService → Long 42
   ├─ @RequestParam  → request.getParameter    → ConversionService → enum
   ├─ OrderFilter    → ServletModelAttributeMethodProcessor → WebDataBinder
   │                    (constructor record / setter, gom lỗi vào BindingResult)
   ├─ Pageable       → PageableHandlerMethodArgumentResolver
   ├─ @RequestBody   → RequestResponseBodyMethodProcessor → Jackson đọc body
   └─ @RequestHeader → header map → ConversionService
   │
   ├─ chuyển đổi lỗi? → exception → DefaultHandlerExceptionResolver → 400
   ▼
orderController.list(filter, pageable)

6. So sánh lỗi khi tham số sai

Tình huốngExceptionStatus mặc định
Thiếu @RequestParam requiredMissingServletRequestParameterException400
/orders/abc cho Long idMethodArgumentTypeMismatchException400
Field sai kiểu trong @ModelAttributeMethodArgumentNotValidException (Spring 6, trước đây BindException)400
Body JSON hỏng / sai kiểuHttpMessageNotReadableException400
Thiếu header requiredMissingRequestHeaderException400
Sai Content-TypeHttpMediaTypeNotSupportedException415

Format body lỗi cho đẹp và thống nhất là việc của bài 4.2.7.

7. Khi nào dùng cái nào

@RequestParam với required = false mà không có defaultValue, kiểu int page thì sao? Mình thử thì bị lỗi 500.

Đúng rồi, đó là bẫy kinh điển. Không gửi param thì giá trị là null, mà int primitive không nhận null → IllegalStateException → 500.

// Sai: thiếu param → null → không gán được vào int
@RequestParam(required = false) int page

// Đúng, chọn 1 trong 3
@RequestParam(defaultValue = "0") int page
@RequestParam(required = false) Integer page          // tự xử lý null
@RequestParam Optional<Integer> page

Một điểm nữa: defaultValue là String, được convert như giá trị client gửi. Gửi ?page= (rỗng) cũng rơi về default.

Vậy OrderFilter không có annotation gì mà Spring vẫn bind được? Sao nó không hiểu nhầm là @RequestBody?

Bạn hiểu gần đúng, chỉ cần chỉnh một điểm: không có annotation thì Spring áp quy tắc mặc định, và mặc định không bao giờ là @RequestBody.

Hệ quả hay gặp: quên @RequestBody ở POST → Spring bind từ query string (rỗng) → object toàn null, không có lỗi gì. Thấy DTO toàn null thì kiểm tra cái này đầu tiên.

Client gửi ?sort=password,asc hoặc sort theo cột không có index thì sao? Pageable có tự chặn không?

Không chặn. Pageable chỉ parse chuỗi. Sort theo field không tồn tại sẽ ném PropertyReferenceException khi query; sort theo field tồn tại nhưng nhạy cảm hoặc không index thì chạy thật. Nên whitelist:

private static final Set<String> SORTABLE = Set.of("createdAt", "total", "status");

static Pageable sanitize(Pageable p) {
    for (Sort.Order o : p.getSort()) {
        if (!SORTABLE.contains(o.getProperty())) {
            throw new BadRequestException("Cannot sort by " + o.getProperty());
        }
    }
    return p;
}

Chi tiết phân trang phía JPA (count query, keyset) ở bài 4.3.9. Và nhớ max-page-size ở bước 2.

Phỏng vấn hỏi về nhận tham số trong Spring MVC thì trả lời sao?

Q1. Phân biệt @PathVariable, @RequestParam, @RequestBody?

🇻🇳 Tiếng Việt:

@PathVariable lấy giá trị từ đường dẫn, dùng để định danh resource như /orders/42. @RequestParam lấy từ query string hoặc form, hợp với filter và tham số tùy chọn. @RequestBody đọc body của request, thường là JSON, qua HttpMessageConverter để thành object, dùng cho dữ liệu tạo hoặc sửa. Hai cái đầu dùng ConversionService, cái cuối dùng Jackson.

🇬🇧 English:

@PathVariable reads a value from the URI path and identifies a resource, like /orders/42. @RequestParam reads from the query string or form data and fits filters and optional parameters. @RequestBody reads the request body, usually JSON, through an HttpMessageConverter into an object, for create or update payloads. The first two use the ConversionService, the last one uses Jackson.

Q2. Client gửi sai kiểu tham số thì Spring trả gì?

🇻🇳 Tiếng Việt:

Spring ném exception chuyển đổi như MethodArgumentTypeMismatchException, MissingServletRequestParameterException hoặc HttpMessageNotReadableException, và DefaultHandlerExceptionResolver map chúng thành 400. Em thêm @RestControllerAdvice để trả body lỗi thống nhất, nói rõ field nào sai. Bẫy cần tránh là tự parse bằng Integer.parseInt rồi để lọt NumberFormatException thành 500.

🇬🇧 English:

Spring throws conversion exceptions such as MethodArgumentTypeMismatchException, MissingServletRequestParameterException or HttpMessageNotReadableException, and the DefaultHandlerExceptionResolver maps them to 400. I add a @RestControllerAdvice to return a consistent error body that names the invalid field. The trap is parsing manually with Integer.parseInt and letting a NumberFormatException leak out as a 500.

Q3. Filter có nhiều tham số thì thiết kế thế nào?

🇻🇳 Tiếng Việt:

Em gom vào một record filter và để Spring bind như @ModelAttribute, kèm Pageable cho page, size, sort. Em giới hạn max page size và whitelist các field được sort để tránh query nặng hoặc lộ dữ liệu.

🇬🇧 English:

I group them into a filter record bound as a @ModelAttribute, plus a Pageable for page, size and sort. I cap the maximum page size and whitelist sortable fields to avoid heavy queries or data exposure.

Cách nhớ ngắn gọn


Tự kiểm tra

GET /api/orders/abc với @PathVariable Long id trả status gì?

400, do MethodArgumentTypeMismatchException.

@RequestParam(required = false) int size, client không gửi size: chuyện gì xảy ra?

null không gán được vào primitive → IllegalStateException → 500. Dùng defaultValue hoặc Integer.

POST quên @RequestBody trên DTO thì sao?

Spring coi như @ModelAttribute, bind từ query string → object toàn null, không báo lỗi.

Làm sao chặn client gọi size=100000?

spring.data.web.pageable.max-page-size (Spring cắt về giới hạn).

Vì sao Spring Boot 3 đôi khi báo không biết tên tham số của @PathVariable?

Spring 6.1 bỏ đọc tên từ debug info; cần compile với -parameters hoặc ghi tên rõ.