ArgumentResolver: Chuỗi Phân Giải Tham Số Spring MVC
Khám phá cơ chế HandlerMethodArgumentResolver trong Spring MVC, tự động chuyển kiểu qua ConversionService và tự tạo custom resolver @CurrentUser.
TL;DR: Khi Spring MVC gọi một controller method, RequestMappingHandlerAdapter duyệt qua danh sách HandlerMethodArgumentResolver. Mỗi resolver kiểm tra supportsParameter(); resolver đầu tiên trả true sẽ thực thi resolveArgument() để chuyển đổi dữ liệu HTTP thành đối tượng Java. Quá trình ép kiểu String sang số, Enum hay Date được ủy quyền cho ConversionService. Thiết kế này tuân thủ Open/Closed Principle, cho phép lập trình viên tạo custom resolver như @CurrentUser mà không cần can thiệp core framework. Tuy nhiên, lập trình viên cần cẩn trọng với lỗ hổng mass assignment khi mapping trực tiếp dữ liệu thô vào entity.
1. Request đi đến controller method được resolve như thế nào?
Sau khi DispatcherServlet và HandlerMapping xác định được handler method phù hợp với URL, quyền thực thi được trao cho RequestMappingHandlerAdapter. Tại đây, adapter phải chuẩn bị đầy đủ các đối số Java tương ứng với chữ ký method trước khi invoke qua reflection.
Quá trình này bao gồm hai giai đoạn đối xứng:
- Resolve Arguments: Duyệt chuỗi resolver để biến đổi request parameters, headers, body thành các Java arguments.
- Handle Return Value: Chuyển đổi giá trị trả về của method thành HTTP response (được xử lý bởi
HandlerMethodReturnValueHandler).
2. HandlerMethodArgumentResolver hoạt động theo cơ chế chuỗi ra sao?
HandlerMethodArgumentResolver là Service Provider Interface (SPI) cốt lõi của Spring MVC:
public interface HandlerMethodArgumentResolver {
// Step 1: Check whether this resolver supports the given method parameter
boolean supportsParameter(MethodParameter parameter);
// Step 2: Extract or convert HTTP request data into the Java argument
Object resolveArgument(
MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory
) throws Exception;
}
Spring MVC cấu hình sẵn hơn 30 resolver built-in. Với mỗi tham số trong controller method, Spring lặp qua danh sách resolver theo thứ tự. Resolver đầu tiên có supportsParameter() trả về true sẽ được chọn để thực thi resolveArgument(), và vòng lặp kết thúc ngay lập tức.

Bảng đối chiếu Annotation và Resolver mặc định
| Annotation / Kiểu tham số | Resolver Class chịu trách nhiệm |
|---|---|
@PathVariable | PathVariableMethodArgumentResolver |
@RequestParam | RequestParamMethodArgumentResolver |
@RequestBody | RequestResponseBodyMethodProcessor |
@RequestHeader | RequestHeaderMethodArgumentResolver |
@CookieValue | ServletCookieValueMethodArgumentResolver |
@AuthenticationPrincipal | AuthenticationPrincipalArgumentResolver (Spring Security) |
Pageable | PageableHandlerMethodArgumentResolver (Spring Data) |
Đối với tham số kiểu Pageable, resolver không kiểm tra annotation mà kiểm tra trực tiếp kiểu dữ liệu: parameter.getParameterType().equals(Pageable.class).
3. Type conversion chuyển đổi String thành Java type như thế nào?
Mọi dữ liệu từ URL, query parameter hay header đều là chuỗi ký tự (String). Quá trình ép kiểu từ chuỗi sang kiểu dữ liệu đích (Long, UUID, LocalDate, Enum) được thực hiện bởi ConversionService.
Các Converter mặc định trong Spring Boot
Spring Boot tự động cấu hình DefaultConversionService với các bộ chuyển đổi thông dụng:
| Đầu vào String | Kiểu Java đích | Bộ chuyển đổi / Cơ chế |
|---|---|---|
"1024" | Integer, Long, Double | StringToNumberConverter |
"2026-04-01" | LocalDate | DateTimeFormatAnnotationFormatterFactory (ISO mặc định) |
"550e8400-e29b-..." | UUID | StringToUUIDConverter |
"ACTIVE" | OrderStatus (enum) | StringToEnumConverter (Enum.valueOf(), phân biệt hoa thường) |
"true" / "false" | Boolean | StringToBooleanConverter |
Nếu client truyền giá trị không thể chuyển đổi (ví dụ ?id=abc cho tham số kiểu Long), framework sẽ ném ra MethodArgumentTypeMismatchException và trả về mã lỗi 400 Bad Request.
Xử lý enum case-insensitive với custom Converter
StringToEnumConverter mặc định phân biệt chữ hoa chữ thường. Nếu client truyền ?status=active thay vì ACTIVE, API sẽ báo lỗi 400. Để hỗ trợ không phân biệt hoa thường, ta đăng ký một Converter tùy biến:
public enum OrderStatus { PENDING, ACTIVE, COMPLETED, CANCELLED }
@Component
public class OrderStatusConverter implements Converter<String, OrderStatus> {
@Override
public OrderStatus convert(String source) {
try {
return OrderStatus.valueOf(source.trim().toUpperCase());
} catch (IllegalArgumentException ex) {
throw new MethodArgumentTypeMismatchException(
source, OrderStatus.class, "status", null, ex
);
}
}
}
Spring Boot tự động phát hiện các Bean implement interface Converter và đăng ký trực tiếp vào ConversionService.
4. Làm thế nào để tự viết custom ArgumentResolver cho @CurrentUser?
Khi nhiều endpoint cần trích xuất thông tin người dùng đang đăng nhập từ SecurityContextHolder, việc lặp lại mã kiểm tra xác thực ở từng method gây trùng lặp và khó kiểm thử:
// Anti-pattern: manual duplication across controller methods
@GetMapping("/profile")
public UserDto getProfile() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
User user = (User) auth.getPrincipal();
return UserDto.from(user);
}
Sử dụng custom ArgumentResolver giúp giải quyết triệt để vấn đề này qua 4 bước:
Bước 1: Khai báo Custom Annotation
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME) // Retain at runtime for reflection
public @interface CurrentUser {}
Bước 2: Hiện thực hóa HandlerMethodArgumentResolver
@Component
public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(CurrentUser.class)
&& parameter.getParameterType().isAssignableFrom(UserPrincipal.class);
}
@Override
public Object resolveArgument(
MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory
) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated() || !(auth.getPrincipal() instanceof UserPrincipal principal)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unauthorized");
}
return principal;
}
}
Bước 3: Đăng ký Resolver với WebMvcConfigurer
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private final CurrentUserArgumentResolver currentUserResolver;
public WebMvcConfig(CurrentUserArgumentResolver currentUserResolver) {
this.currentUserResolver = currentUserResolver;
}
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(currentUserResolver);
}
}
Bước 4: Áp dụng tại Controller
@GetMapping("/profile")
public UserDto getProfile(@CurrentUser UserPrincipal user) {
return UserDto.from(user);
}
@PostMapping("/orders")
public OrderDto createOrder(@CurrentUser UserPrincipal user, @Valid @RequestBody CreateOrderRequest req) {
return orderService.create(user.getId(), req);
}
5. Lỗ hổng mass assignment xảy ra như thế nào và phòng chống ra sao?
Lỗ hổng Mass Assignment (Over-posting) xảy ra khi client gửi thêm các thuộc tính nhạy cảm trong payload JSON và framework tự động map các thuộc tính đó vào đối tượng xử lý.
// Vulnerable DTO: exposes authorization-sensitive fields to client
public record UserUpdateRequest(
String fullName,
String email,
boolean isAdmin, // Risk: client can self-assign admin role
String role
) {}
Nếu client gửi payload:
{
"fullName": "Nguyen Van A",
"isAdmin": true,
"role": "SUPERADMIN"
}
Jackson sẽ deserialize toàn bộ các trường này, dẫn đến nguy cơ leo thang đặc quyền (Privilege Escalation).
Biện pháp xử lý: Phân tách DTO theo vai trò
Không bao giờ dùng chung một DTO cho các mức độ phân quyền khác nhau:
// Safe DTO for normal users
public record UserPublicUpdateRequest(
@NotBlank String fullName,
@Email String email
) {}
// Privileged DTO for admins only
public record UserAdminUpdateRequest(
@NotBlank String fullName,
@Email String email,
boolean isAdmin,
String role
) {}
Không nên chỉ dựa vào @JsonIgnore để chặn gán dữ liệu. Trong nhiều trường hợp cấu hình ObjectMapper hoặc khi kế thừa class, Jackson vẫn có thể deserialize nếu constructor hoặc setter cho phép. Giải pháp an toàn nhất là loại bỏ hoàn toàn các trường nhạy cảm khỏi request DTO.
6. Thứ tự ưu tiên đăng ký resolver ảnh hưởng đến hệ thống như thế nào?
Khi đăng ký resolver qua WebMvcConfigurer.addArgumentResolvers(), Spring MVC sẽ chèn custom resolver vào đầu danh sách duyệt, đứng trước toàn bộ các resolver mặc định.

Do đó, nếu phương thức supportsParameter() của custom resolver được viết quá lỏng lẻo (ví dụ luôn trả về true), nó sẽ chiếm quyền xử lý của các resolver built-in như @RequestBody hay @RequestParam, làm hỏng luồng hoạt động chuẩn của ứng dụng.
7. ArgumentResolver kết nối với các thành phần khác trong Spring MVC ra sao?
- Request binding: Trình bày 6 nguồn annotation dữ liệu HTTP. Bản chất mỗi annotation đó đều được hỗ trợ bởi một
HandlerMethodArgumentResolverchuyên biệt. - ResponseEntity & HTTP status codes: Xử lý đối xứng ở chiều response. Sau khi controller hoàn thành,
HandlerMethodReturnValueHandlerchịu trách nhiệm định dạng output. - Bean Validation:
@Validtrên@RequestBodykhông nằm trong resolver mà kích hoạt sau khi resolver trả về đối tượng Java, thông quaWebDataBinder.validate().
8. Những điểm then chốt cần ghi nhớ là gì?
HandlerMethodArgumentResolverquyết định cách biến đổi dữ liệu request thành đối số cho controller method thông qua hai bướcsupportsParametervàresolveArgument.- Mỗi tham số được xử lý độc lập theo thứ tự xuất hiện trong method signature.
ConversionServicechịu trách nhiệm ép kiểu từ String sang các kiểu dữ liệu phức tạp như Enum, UUID, Date.- Custom resolver là giải pháp thanh lịch để inject thông tin xác thực (
@CurrentUser), giúp code controller ngắn gọn và dễ unit test. - Ngăn chặn lỗ hổng mass assignment bằng cách thiết kế Request DTO tinh gọn, chỉ chứa các trường client được phép thay đổi.
9. Bài tập tự kiểm tra mức độ hiểu sâu
- Q1Khi một controller method có 3 tham số khác nhau, Spring MVC thực hiện việc resolve đối số theo cơ chế nào?
- Q2Tại sao annotation dùng cho custom ArgumentResolver bắt buộc phải có @Retention(RetentionPolicy.RUNTIME)?
- Q3Nếu client gửi ?status=active trong khi Enum khai báo là ACTIVE, Spring MVC mặc định sẽ xử lý thế nào?
- Q4Điều gì sẽ xảy ra nếu custom resolver có phương thức supportsParameter() luôn trả về true vô điều kiện?
- Q5Giải pháp an toàn và triệt để nhất để ngăn ngừa lỗ hổng Mass Assignment khi cập nhật dữ liệu là gì?
Bài tiếp theo: ResponseEntity & HTTP status codes
Bài này có giúp bạn hiểu bản chất không?
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.
Request Binding: Ánh Xạ Dữ Liệu HTTP Vào Spring Controller
Bài tiếpResponseEntity và HTTP Status Chuẩn Ngữ Nghĩa REST
Lộ trình Junior Java Backend Developer
Chặng 15/21 · REST API với Spring MVC — bài 9/21 của chặng
Thảo luận & góp ý
Hỏi đáp về bài này