Spring REST API & Data JPA/Request Binding — @PathVariable, @RequestParam, @RequestBody và cơ chế bên dưới
8/46
Bài 8 / 46~12 phútRequest & ResponseMiễn phí lượt xem

Request Binding — @PathVariable, @RequestParam, @RequestBody và cơ chế bên dưới

Bài này bóc đúng một thứ: 6 nguồn dữ liệu trong HTTP request, 6 annotation Spring tương ứng, và cơ chế HttpMessageConverter + HandlerMethodArgumentResolver biến chúng thành object Java. Tại sao mỗi annotation tồn tại, tại sao @RequestBody cần Jackson, và tại sao quên @Valid là lỗi thầm lặng.

TL;DR: Mỗi HTTP request mang dữ liệu theo 6 con đường — path, query string, body, header, cookie, request attribute. Spring ánh xạ 6 con đường đó thành 6 annotation: @PathVariable, @RequestParam, @RequestBody, @RequestHeader, @CookieValue, @RequestAttribute. Mỗi annotation có 1 HandlerMethodArgumentResolver chuyên biệt bên dưới. Riêng @RequestBody thêm một bước: chuyển byte thô sang object Java qua HttpMessageConverter (mặc định Jackson). Pitfall lớn nhất: không có @Valid trước @RequestBody — Spring vẫn bind được, nhưng constraint annotation trên DTO hoàn toàn im lặng, dữ liệu sai vẫn chui thẳng vào service.

Bài 02 — @RestController & mapping đã giải thích DispatcherServlet định tuyến request đến đúng method controller. Câu hỏi atomic của bài này hẹp hơn: sau khi Spring chọn được method, nó lấy dữ liệu từ HTTP request và đổ vào parameter Java bằng cách nào?

1. Sáu nguồn dữ liệu trong một HTTP request

Một HTTP request không chỉ có body. Dữ liệu tồn tại ở nhiều vị trí khác nhau trong cấu trúc của request:

GET /orders/42?status=active HTTP/1.1
Host: api.olhub.vn
Authorization: Bearer eyJ...
X-Request-Id: req-abc-123
Cookie: theme=dark; JSESSIONID=xyz
AnnotationNguồn dữ liệuVí dụ cụ thể
@PathVariableURL path segment/orders/{id}id = 42
@RequestParamQuery string hoặc form body?status=active
@RequestBodyRequest body (JSON / XML){"customer":"Alice","total":99.99}
@RequestHeaderHTTP headerAuthorization: Bearer eyJ...
@CookieValueCookietheme=dark
@RequestAttributeAttribute set bởi Filter/Interceptorreq.setAttribute("user", ...)

Mỗi annotation tồn tại vì mỗi vị trí trong request có format và cách đọc khác nhau. Spring không thể đoán bạn muốn đọc từ đâu — phải khai tường minh.

2. @PathVariable — bind từ URL path segment

@GetMapping("/orders/{id}")
public OrderDto get(@PathVariable Long id) { ... }

Spring lấy phần URL khớp placeholder {id}, tự chuyển String "42" sang Long 42. Khi conversion thất bại (URL chứa "abc" cho kiểu Long), Spring throw MethodArgumentTypeMismatchException và trả 400 Bad Request — không crash app, không exception chưa handle.

Tên không trùng giữa placeholder và parameter

Khi đặt tên khác nhau (naming convention URL và Java thường khác):

@GetMapping("/orders/{order_id}")
public OrderDto get(@PathVariable("order_id") Long orderId) { ... }

Phần trong ngoặc "order_id" là tên trong URL template. Parameter orderId là tên Java.

Nhiều path variable

@GetMapping("/users/{userId}/orders/{orderId}")
public OrderDto get(
    @PathVariable Long userId,
    @PathVariable Long orderId
) { ... }

Mỗi placeholder trong URL template ánh xạ 1:1 với một parameter @PathVariable. Tên phải khớp hoặc khai explicit trong ngoặc.

Tại sao @PathVariable chứ không phải đọc HttpServletRequest thủ công?

Không có annotation, bạn phải viết:

@GetMapping("/orders/{id}")
public OrderDto get(HttpServletRequest req) {
    String idStr = (String) req.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
    // ... extract "id" từ map, parse sang Long, handle exception
}

Annotation encapsulate toàn bộ đoạn đó — type conversion, error handling, null check. Controller method chỉ nhận giá trị đã sẵn sàng.

3. @RequestParam — bind từ query string

@GetMapping("/orders")
public List<OrderDto> list(
    @RequestParam(defaultValue = "ACTIVE") String status,
    @RequestParam(required = false) Long minTotal,
    @RequestParam(defaultValue = "0") int page,
    @RequestParam(defaultValue = "20") int size
) { ... }

Request: GET /orders?status=PENDING&minTotal=100&page=2&size=50

Spring bind từng query param theo tên, auto-convert sang kiểu Java. defaultValue là String — Spring tự chuyển sang kiểu target. Phải chọn giá trị parse được, không thì fail lúc startup hoặc runtime:

// SAI - "abc" khong parse sang int:
@RequestParam(defaultValue = "abc") int page

// DUNG:
@RequestParam(defaultValue = "0") int page

required vs defaultValue

@RequestParam Long userId                           // required, missing -> 400
@RequestParam(required = false) Long userId         // optional, null neu missing
@RequestParam(defaultValue = "0") Long userId       // optional voi default

required = false trả null khi param vắng mặt — chỉ dùng được với kiểu reference (Long, String), không phải primitive (long, int). Primitive không nhận null.

Multi-value

// URL: ?tags=java&tags=spring&tags=rest
@RequestParam List<String> tags   // -> ["java", "spring", "rest"]

Spring detect collection type, bind tất cả value của cùng tên param.

Tại sao phân biệt @PathVariable@RequestParam?

Path variable là định danh resource — thay đổi thì chỉ một resource khác (/orders/42 vs /orders/43). Query param là modifier — filter, sort, page, không xác định resource. REST convention: dùng path cho ID, query cho filter/pagination.

4. @RequestBody — bind từ request body qua HttpMessageConverter

@PostMapping("/orders")
public OrderDto create(@RequestBody OrderRequest req) { ... }

public record OrderRequest(
    String customer,
    BigDecimal total,
    List<OrderItem> items
) {}

Cơ chế bên dưới — HttpMessageConverter

@RequestBody không đơn giản như đọc String từ query param. Body là stream byte thô — Spring cần biết format (JSON? XML? form?) và cần công cụ parse format đó sang object Java. Đặt hai nguồn dữ liệu cạnh nhau thì thấy rõ chỗ khác nhau:

Hai cột đối chiếu: query param đọc ra đã là chuỗi nên chỉ cần ConversionService đổi kiểu, còn body là byte thô nên phải đọc header Content-Type rồi hỏi từng converter canRead trước khi MappingJackson2 dựng được object

Luồng chính xác:

  1. RequestMappingHandlerAdapter phát hiện parameter có @RequestBody.
  2. Đọc header Content-Type của request — ví dụ application/json.
  3. Duyệt danh sách HttpMessageConverter đã đăng ký, hỏi: converter nào canRead(OrderRequest.class, application/json)?
  4. MappingJackson2HttpMessageConverter trả true — đây là converter cho JSON, sử dụng Jackson ObjectMapper bên dưới.
  5. Jackson đọc byte stream từ body, build object OrderRequest bằng canonical constructor của record.
  6. Object pass vào method controller.

Khi body JSON sai cú pháp, hoặc kiểu field không khớp DTO (ví dụ gửi "total": "not-a-number"), Jackson throw JsonParseException hoặc HttpMessageNotReadableException, Spring map về 400 Bad Request.

Tại sao @RequestBody cần HttpMessageConverter riêng còn @RequestParam không cần?

Query param luôn là String trong URL — type conversion đơn giản (String → Long, String → Enum). Body là byte stream có thể ở bất kỳ format nào — JSON, XML, Protobuf. HttpMessageConverter là abstraction cho phép Spring support nhiều format mà không hardcode logic parse vào framework. Bạn có thể thêm converter custom cho format bất kỳ.

Record DTO là pattern chuẩn cho @RequestBody

// Modern (Java 17+, Boot 3):
public record OrderRequest(
    String customer,
    BigDecimal total,
    List<OrderItem> items
) {}

// Legacy (verbose, still works):
public class OrderRequest {
    private String customer;
    private BigDecimal total;
    private List<OrderItem> items;
    // getters, setters, equals, hashCode, toString...
}

Record ngắn hơn 10x. Immutable — field final, không setter, thread-safe. Jackson 2.12+ (Spring Boot 3.x mặc định) hỗ trợ record native qua jackson-module-parameter-names có sẵn trong Boot starter — không cần @JsonCreator hay @JsonProperty.

@Valid — pitfall lớn nhất của @RequestBody

public record OrderRequest(
    @NotBlank String customer,     // constraint khai bao...
    @NotNull @Positive BigDecimal total
) {}

// SAI - constraint KHONG trigger:
@PostMapping("/orders")
public OrderDto create(@RequestBody OrderRequest req) { ... }

// DUNG:
@PostMapping("/orders")
public OrderDto create(@Valid @RequestBody OrderRequest req) { ... }

Không có @Valid, Spring bind body thành công nhưng Bean Validation không chạy. customer = "" vẫn chui vào service. Không có exception, không có log cảnh báo — lỗi thầm lặng. Khi có @Valid, constraint fail → Spring throw MethodArgumentNotValidException → 400 Problem Details với danh sách field vi phạm.

Cùng một body sai đi vào hai nhánh: nhánh không có @Valid bind xong rồi gọi thẳng service với dữ liệu sai, nhánh có @Valid bind y hệt rồi thêm bước WebDataBinder validate và ném MethodArgumentNotValidException trả 400

Điểm đáng nhớ nằm ở hàng đầu tiên: bước bind giống nhau từng chữ ở cả hai nhánh. @Valid không đổi cách Spring đọc body, nó chỉ thêm một bước sau đó — nên khi thiếu, không có triệu chứng nào để mà thấy.

Luôn đặt @Valid trước @RequestBody

Đây là lỗi phổ biến nhất khi viết controller đầu tiên. Thứ tự annotation không quan trọng (@Valid @RequestBody hay @RequestBody @Valid đều được), nhưng thiếu @Valid là lỗi hoàn toàn im lặng.

5. @RequestHeader — bind từ HTTP header

@GetMapping("/orders/{id}")
public OrderDto get(
    @PathVariable Long id,
    @RequestHeader("Authorization") String authToken,
    @RequestHeader(value = "X-Request-Id", required = false) String requestId,
    @RequestHeader(defaultValue = "en") String acceptLanguage
) { ... }

Pattern hay gặp:

  • Authorization cho JWT Bearer token.
  • X-Request-Id hoặc X-Correlation-Id cho distributed tracing.
  • Accept-Language cho i18n.

Bind toàn bộ header vào HttpHeaders (Spring wrapper) để có method tiện ích:

@RequestHeader HttpHeaders headers
// headers.getContentType(), headers.getAccept(), headers.getFirst("X-Custom")
@GetMapping("/dashboard")
public DashboardDto get(
    @CookieValue(value = "theme", defaultValue = "light") String theme
) { ... }

Ít dùng trong REST API thuần JSON — cookie gắn với session-based auth. JWT API thường không cần. Pattern này hay gặp trong app web truyền thống hoặc khi cần đọc cookie preference của user (theme, locale).

7. @RequestAttribute — bind từ request attribute

Filter hoặc Interceptor chạy trước controller, có thể gắn object vào request attribute:

@Component
public class JwtAuthFilter extends OncePerRequestFilter {
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        String token = req.getHeader("Authorization");
        User user = jwtService.validate(token);
        req.setAttribute("currentUser", user);   // <-- ghi vao attribute
        chain.doFilter(req, res);
    }
}

@GetMapping("/profile")
public UserDto profile(@RequestAttribute("currentUser") User user) { ... }

Filter set attribute → controller bind. Tránh phải gọi jwtService.validate() lặp lại trong mỗi controller.

Pattern này ổn, nhưng Spring Security có @AuthenticationPrincipal chuẩn hơn cho trường hợp inject user từ auth context. @RequestAttribute dùng khi cần pass data tùy biến không liên quan security.

8. Cơ chế bên dưới — HandlerMethodArgumentResolver

Sáu annotation trong bài không hoạt động bằng reflection magic. Mỗi annotation có 1 class HandlerMethodArgumentResolver cụ thể chịu trách nhiệm:

AnnotationResolver
@PathVariablePathVariableMethodArgumentResolver
@RequestParamRequestParamMethodArgumentResolver
@RequestBodyRequestResponseBodyMethodProcessor
@RequestHeaderRequestHeaderMethodArgumentResolver
@CookieValueServletCookieValueMethodArgumentResolver
@RequestAttributeRequestAttributeMethodArgumentResolver

Interface của resolver:

public interface HandlerMethodArgumentResolver {
    boolean supportsParameter(MethodParameter parameter);
    Object resolveArgument(MethodParameter parameter,
                           ModelAndViewContainer mavContainer,
                           NativeWebRequest webRequest,
                           WebDataBinderFactory binderFactory) throws Exception;
}

RequestMappingHandlerAdapter giữ danh sách resolver. Khi cần resolve một parameter:

  1. Duyệt danh sách resolver theo thứ tự đăng ký.
  2. Hỏi supportsParameter() — resolver kiểm tra parameter có annotation phù hợp không.
  3. Resolver đầu tiên trả true được chọn — gọi resolveArgument() để lấy giá trị thực.
  4. Giá trị pass vào method khi Spring invoke handler.

Đây là lý do bạn có thể viết custom resolver: implement interface, đăng ký vào WebMvcConfigurer.addArgumentResolvers(), Spring tự thêm vào danh sách.

Xem chi tiết cơ chế resolver tại HandlerMethodArgumentResolver & type conversion.

Pitfall tổng hợp

Pitfall 1: Quên @Valid trước @RequestBody — đã giải thích ở mục 4. Constraint không trigger, dữ liệu sai vào service im lặng.

Pitfall 2: defaultValue không parse được sang kiểu target:

// SAI - "abc" khong parse sang int -> 400 moi request:
@RequestParam(defaultValue = "abc") int page

// DUNG:
@RequestParam(defaultValue = "0") int page

Pitfall 3: @PathVariable trên primitive, URL truyền sai kiểu:

@GetMapping("/orders/{id}")
public OrderDto get(@PathVariable long id) { ... }
// URL: /orders/abc -> MethodArgumentTypeMismatchException -> 400 (dung)
// URL: /orders/   -> 404 (khong match pattern, cung dung)

Primitive và wrapper đều Spring xử lý tốt, nhưng nên dùng wrapper (Long) khi path variable có thể vắng mặt (optional pattern).

Pitfall 4: Dùng @RequestBody cho GET request:

// GET khong co body theo HTTP spec - client co the khong gui:
@GetMapping("/search")
public List<OrderDto> search(@RequestBody SearchCriteria criteria) { ... }

HTTP cho phép GET có body nhưng semantics undefined — proxy và server có thể bỏ qua. Tìm kiếm phức tạp dùng POST /search với body, hoặc flatten criteria thành query param cho GET.

Pitfall 5: Tên parameter Java không khớp query param name:

// Query: ?minTotal=100 nhung parameter ten la "min":
@RequestParam Long min   // -> null (100 bi bo qua)

Mặc định Spring bind theo tên parameter Java. Nếu khác: @RequestParam("minTotal") Long min.

Liên hệ các bài khác

  • @RestController & mapping: DispatcherServlet chọn controller method trước, rồi mới đến bước bind argument bài này giải thích. Hiểu routing trước khi hiểu binding.
  • HandlerMethodArgumentResolver & type conversion: bài tiếp theo đào sâu cơ chế resolver — cách Spring convert String sang Enum/Date/custom type, và cách viết custom resolver cho annotation riêng (@CurrentUser, @TenantId).
  • Bài validation (module 03): @Valid kích hoạt Bean Validation chạy trên DTO — bài đó giải thích constraint nào dùng khi nào và cách handle MethodArgumentNotValidException trả về 400 Problem Details chuẩn.

Tóm tắt

  • HTTP request mang dữ liệu theo 6 con đường — path, query, body, header, cookie, attribute. Spring có 6 annotation tương ứng.
  • @PathVariable cho URL segment định danh resource. @RequestParam cho query string filter/page.
  • @RequestBody cho JSON body — dùng HttpMessageConverter (Jackson mặc định) để chuyển byte stream thành object Java. Record DTO là pattern chuẩn: immutable, concise, Jackson native support.
  • @RequestHeader cho HTTP header. @CookieValue cho cookie. @RequestAttribute cho data filter đã gắn vào request.
  • Bên dưới: mỗi annotation có 1 HandlerMethodArgumentResolver. RequestMappingHandlerAdapter duyệt danh sách resolver, chọn cái đầu tiên supportsParameter() = true, gọi resolveArgument().
  • Pitfall duy nhất cần nhớ: thiếu @Valid trước @RequestBody — binding thành công nhưng validation im lặng.

Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Cho method controller sau, xác định annotation nào bind từ nguồn nào, và request mẫu trông ra sao:
    @GetMapping("/users/{userId}/orders")
    public Page<OrderDto> list(
      @PathVariable Long userId,
      @RequestParam(defaultValue = "ACTIVE") String status,
      @RequestHeader(value = "X-Tenant-Id", required = false) String tenantId,
      Pageable pageable
    ) { ... }
  2. Q2
    Giải thích tại sao @RequestBody cần HttpMessageConverter (Jackson) để hoạt động, trong khi @RequestParam không cần. Cơ chế bên dưới khác nhau thế nào?
  3. Q3
    Đoạn code sau có lỗi gì? Tại sao lỗi này nguy hiểm hơn lỗi compile error?
    public record CreateUserRequest(
      @NotBlank String username,
      @Email String email,
      @Size(min = 8) String password
    ) {}
    
    @PostMapping("/users")
    public UserDto create(@RequestBody CreateUserRequest req) {
      return userService.create(req);
    }
  4. Q4
    Bạn cần đọc custom header X-Api-Version ở nhiều controller method. Liệt kê 3 cách tiếp cận và giải thích cách nào clean nhất và vì sao.
  5. Q5
    Giải thích cơ chế HandlerMethodArgumentResolver theo từng bước khi Spring resolve parameter @PathVariable Long id trong request GET /orders/42.

Bài tiếp theo: HandlerMethodArgumentResolver & type conversion

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

ArgumentResolver — chuỗi resolver, type conversion, custom annotation