Spring REST API & Data JPA
9/46
Bài 9 / 46~12 phútRequest & ResponseLộ trình · chặng 15/21Miễn phí… lượt xem

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:

  1. Resolve Arguments: Duyệt chuỗi resolver để biến đổi request parameters, headers, body thành các Java arguments.
  2. 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:

Java
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.

Vòng đời một tham số: duyệt lần lượt hai resolver, resolver đầu tiên trả true thì vòng duyệt dừng, rồi resolveArgument lấy chuỗi thô từ query và ConversionService đổi chuỗi thành OrderStatus trước khi giá trị vào method

Bảng đối chiếu Annotation và Resolver mặc định

Annotation / Kiểu tham sốResolver Class chịu trách nhiệm
@PathVariablePathVariableMethodArgumentResolver
@RequestParamRequestParamMethodArgumentResolver
@RequestBodyRequestResponseBodyMethodProcessor
@RequestHeaderRequestHeaderMethodArgumentResolver
@CookieValueServletCookieValueMethodArgumentResolver
@AuthenticationPrincipalAuthenticationPrincipalArgumentResolver (Spring Security)
PageablePageableHandlerMethodArgumentResolver (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 StringKiểu Java đíchBộ chuyển đổi / Cơ chế
"1024"Integer, Long, DoubleStringToNumberConverter
"2026-04-01"LocalDateDateTimeFormatAnnotationFormatterFactory (ISO mặc định)
"550e8400-e29b-..."UUIDStringToUUIDConverter
"ACTIVE"OrderStatus (enum)StringToEnumConverter (Enum.valueOf(), phân biệt hoa thường)
"true" / "false"BooleanStringToBooleanConverter

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:

Java
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ử:

Java
// 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

Java
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME) // Retain at runtime for reflection
public @interface CurrentUser {}

Bước 2: Hiện thực hóa HandlerMethodArgumentResolver

Java
@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

Java
@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

Java
@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ý.

Java
// 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:

JSON
{
  "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:

Java
// 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
) {}
Hạn chế của @JsonIgnore trong Request Body

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.

Tham số @RequestBody đi vào danh sách resolver duyệt từ trên xuống: custom resolver nằm ở hàng đầu và nuốt luôn tham số vì supportsParameter viết return true, còn RequestResponseBodyMethodProcessor ở hàng cuối không bao giờ được hỏi tới

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 HandlerMethodArgumentResolver chuyên biệt.
  • ResponseEntity & HTTP status codes: Xử lý đối xứng ở chiều response. Sau khi controller hoàn thành, HandlerMethodReturnValueHandler chịu trách nhiệm định dạng output.
  • Bean Validation: @Valid trên @RequestBody không nằm trong resolver mà kích hoạt sau khi resolver trả về đối tượng Java, thông qua WebDataBinder.validate().

8. Những điểm then chốt cần ghi nhớ là gì?

  • HandlerMethodArgumentResolver quyết định cách biến đổi dữ liệu request thành đối số cho controller method thông qua hai bước supportsParameter và resolveArgument.
  • Mỗi tham số được xử lý độc lập theo thứ tự xuất hiện trong method signature.
  • ConversionService chị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

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Khi 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?
  2. Q2
    Tại sao annotation dùng cho custom ArgumentResolver bắt buộc phải có @Retention(RetentionPolicy.RUNTIME)?
  3. Q3
    Nế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?
  4. 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?
  5. Q5
    Giả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.

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

Bài tiếp theo

ResponseEntity và HTTP Status Chuẩn Ngữ Nghĩa REST