Written By: Hoàng Mạnh Cường (Java 10)
Gmail: manhcuong200997@gmail.com
Bài viết gốc: https://www.baeldung.com/spring-mvc-json-param-mapping

1. Tổng quan

Khi sử dụng hỗ trợ mặc định của Spring cho việc giải nén JSON, chúng ta bắt buộc ánh xạ chuỗi JSON mới tới một tham số xử lý yêu cầu duy nhất. Tuy nhiên, đôi khi chúng ta mong muốn một phương thức chi tiết hơn.

Trong bài viết này, chúng ta sẽ tìm hiểu cách sử dụng HandlerMethodArgumentResolver tùy chỉnh để giải mã JSON POST thành những tham số được đề cập tới.

2. Vấn đề

Đầu tiên, hãy xem những hạn chế của cách tiếp cận mặc định của Spring MVC đối với quá trình giải nén JSON.

2.1. Hoạt động mặc định của @RequestBody

Hãy bắt đầu với một ví dụ về nội dung của JSON:

{
   "firstName" : "John",
   "lastName"  :"Smith",
   "age" : 10,
   "address" : {
      "streetName" : "Example Street",
      "streetNumber" : "10A",
      "postalCode" : "1QW34",
      "city" : "Timisoara",
      "country" : "Romania"
   }
}

Tiếp theo, tạo các DTO tương ứng với đầu vào trong JSON:

public class UserDto {
    private String firstName;
    private String lastName;
    private String age;
    private AddressDto address;

    // getters and setters
}
public class AddressDto {

    private String streetName;
    private String streetNumber;
    private String postalCode;
    private String city;
    private String country;

    // getters and setters
}

Cuối cùng, chúng ta sử dụng phương pháp tiêu chuẩn để giải mã yêu cầu dạng JSON của UserDto bằng cách sử dụng annotation @RequestBody:

@Controller
@RequestMapping("/user")
public class UserController {

    @PostMapping("/process")
    public ResponseEntity process(@RequestBody UserDto user) {
        /* business processing */
        return ResponseEntity.ok()
            .body(user.toString());
    }
}

2.2. Hạn chế

Lợi ích chính của giải pháp trên là chúng ta không cần giải mã thủ công JSON POST thành một đối tượng UserDto.

Tuy nhiên, toàn bộ JSON POST cần phải được ánh xạ tới một tham số yêu cầu duy nhất. Điều này có nghĩa là chúng ta cần phải tạo ra một POJO cho mỗi cấu trúc JSON mong muốn, khiến cho nền tảng code trở nên phức tạp bởi những class chỉ được sử dụng cho mục đích này.

Hệ quả đặc biệt rõ rõ khi chúng ta chỉ cần một tập hợp con của các thuộc tính JSON. Trong trình xử lý yêu cầu trên, chúng ta chỉ cần thuộc tính firstNamecity của user, nhưng chúng ta bắt buộc phải giải mã toàn bộ UserDto.

Dù Spring cho phép chúng ta sử dụng Map hoặc ObjectNode như một tham số thay vì DTO, cả hai đều là tùy chọn tham số đơn. Như với DTO, mọi thứ đều được đóng gói cùng nhau. Vì nội dung của MapObjectNode là kiểu String, chúng ta phải tự điều chỉnh chúng thành các đối tượng. Những lựa chọn này giúp chúng ta không phải khai báo các DTO chỉ sử dụng một lần nhưng tạo ra sự phức tạp hơn.

3. Tùy chỉnh HandlerMethodArgumentResolver

Hãy cùng xem một giải pháp cho những hạn chế trên. Chúng ta có thể sử dụng HandlerMethodArgumentResolver của Spring MVC để chỉ khai báo những thuộc tính JSON mong muốn dưới dạng tham số trong trình xử lý yêu cầu.

3.1. Khởi tạo Controller

Đầu tiên, hãy tạo ra một annotation tùy chỉnh mà chúng ta có thể sử dụng để ánh xạ tham số xử lý yêu cầu tới một đường dẫn JSON:

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface JsonArg {
    String value() default "";
}

Tiếp theo, chúng ta tạo ra một trình xử lý yêu cầu sử dụng annotation để ánh xạ firstNamecity dưới dạng các tham số riêng biệt tương ứng với các thuộc tính trong nội dung của JSON POST:

@Controller
@RequestMapping("/user")
public class UserController {
    @PostMapping("/process/custom")
    public ResponseEntity process(@JsonArg("firstName") String firstName,
      @JsonArg("address.city") String city) {
        /* business processing */
        return ResponseEntity.ok()
            .body(String.format("{\"firstName\": %s, \"city\" : %s}", firstName, city));
    }
}

3.2. Tạo ra Custom HandlerMethodArgumentResolver

Sau khi Spring MVC quyết định trình xử lý nào sẽ xử lý yêu cầu được gửi đến, nó sẽ cố gắng tự động giải quyết các tham số. Điều này bao gồm việc lặp qua tất cả bean trong Spring context triển khai HandlerMethodArgumentResolver interface trong trường hợp có thể giải quyết bất kỳ tham số nào mà Spring MVC không thể thực hiện tự động.

Hãy định nghĩa triển khai của HandlerMethodArgumentResolver sẽ xử lý tất cả tham số xử lý yêu cầu được đánh dấu @JsonArg:

public class JsonArgumentResolver implements HandlerMethodArgumentResolver {

    private static final String JSON_BODY_ATTRIBUTE = "JSON_REQUEST_BODY";

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(JsonArg.class);
    }

    @Override
    public Object resolveArgument(
      MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest,
      WebDataBinderFactory binderFactory) 
      throws Exception {
        String body = getRequestBody(webRequest);
        String jsonPath = Objects.requireNonNull(
          Objects.requireNonNull(parameter.getParameterAnnotation(JsonArg.class)).value());
        Class<?> parameterType = parameter.getParameterType();
        return JsonPath.parse(body).read(jsonPath, parameterType);
    }

    private String getRequestBody(NativeWebRequest webRequest) {
        HttpServletRequest servletRequest = Objects.requireNonNull(
          webRequest.getNativeRequest(HttpServletRequest.class));
        String jsonBody = (String) servletRequest.getAttribute(JSON_BODY_ATTRIBUTE);
        if (jsonBody == null) {
            try {
                jsonBody = IOUtils.toString(servletRequest.getInputStream());
                servletRequest.setAttribute(JSON_BODY_ATTRIBUTE, jsonBody);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
        return jsonBody;
    }
}

Spring sử dụng phương thức supportsParameter() để kiểm tra xem class này có thể xử lý một tham số nhận được. Vì chúng ta muốn trình xử lý của mình xử lý bất kỳ tham số nào được đánh dấu bởi @JsonArg, chúng ta sẽ trả về true nếu tham số đã cho được đánh dấu như vậy.

Tiếp theo, trong phương thức ResolutionArgument(), chúng ta phân giải phần thân JSON và sau đó gán nó dưới dạng một thuộc tính cho yêu cầu nên chúng ta có thể truy cập trực tiếp nó cho các lần gọi tiếp theo. Sau đó, chúng ta lấy đường dẫn JSON từ annotation @JsonArg và sử dụng sự phản chiếu (reflection) để lấy kiểu của tham số. Với đường dẫn JSON và thông tin kiểu tham số, chúng ta có thể deserialize các phần riêng biệt của nội dung của JSON thành các đối tượng.

3.3. Đăng kí cho Custom HandlerMethodArgumentResolver

Để Spring MVC sử dụng JsonArgumentResolver của chúng ta, chúng ta cần đăng ký cho nó:

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        JsonArgumentResolver jsonArgumentResolver = new JsonArgumentResolver();
        argumentResolvers.add(jsonArgumentResolver);
    }
}

JsonArgumentResolver của chúng ta sẽ xử lý tất cả các tham số của trình xử lý yêu cầu được đánh dấu bởi @JsonArgs. Chúng ta sẽ cần đảm bảo giá trị @JsonArgs là một đường dẫn JSON hợp lệ, nhưng đó là một quy trình nhẹ hơn so với phương pháp @RequestBody yêu cầu một POJO riêng biệt cho mọi cấu trúc JSON.

Lưu ý: Trong đoạn trên, ở bài viết gốc tác giả sử dụng @JsonArgs, tuy nhiên mình nghĩ @JsonArg mới chính xác. Các bạn hãy cho mình ý kiến của các bạn về vấn đề này trong phần bình luận ở cuối bài viết nhé.

3.4. Sử dụng các tham số với các kiểu tùy chỉnh

Để thấy rằng điều này cũng sẽ hoạt động với các lớp Java tùy chỉnh, hãy định nghĩa một trình xử lý yêu cầu với các tham số POJO được nhấn mạnh:

@PostMapping("/process/custompojo")
public ResponseEntity process(
  @JsonArg("firstName") String firstName, @JsonArg("lastName") String lastName,
  @JsonArg("address") AddressDto address) {
    /* business processing */
    return ResponseEntity.ok()
      .body(String.format("{\"firstName\": %s, \"lastName\": %s, \"address\" : %s}",
        firstName, lastName, address));
}

Bây giờ chúng ta có thể ánh xạ AddressDto dưới dạng một tham số riêng biệt.

3.5. Kiểm thử Custom JsonArgumentResolver

Hãy viết một trường hợp thử nghiệm để chứng minh rằng JsonArgumentResolver hoạt động như mong đợi:

@Test
void whenSendingAPostJSON_thenReturnFirstNameAndCity() throws Exception {

    String jsonString = "{\"firstName\":\"John\",\"lastName\":\"Smith\",\"age\":10,\"address\":{\"streetName\":\"Example Street\",\"streetNumber\":\"10A\",\"postalCode\":\"1QW34\",\"city\":\"Timisoara\",\"country\":\"Romania\"}}";
    
    mockMvc.perform(post("/user/process/custom").content(jsonString)
      .contentType(MediaType.APPLICATION_JSON)
      .accept(MediaType.APPLICATION_JSON))
      .andExpect(status().isOk())
      .andExpect(MockMvcResultMatchers.jsonPath("$.firstName").value("John"))
      .andExpect(MockMvcResultMatchers.jsonPath("$.city").value("Timisoara"));
}

Tiếp theo, hãy viết một thử nghiệm trong đó chúng ta gọi tới endpoint thứ hai phân tích trực tiếp cú pháp JSON thành POJO:

@Test
void whenSendingAPostJSON_thenReturnUserAndAddress() throws Exception {
    String jsonString = "{\"firstName\":\"John\",\"lastName\":\"Smith\",\"address\":{\"streetName\":\"Example Street\",\"streetNumber\":\"10A\",\"postalCode\":\"1QW34\",\"city\":\"Timisoara\",\"country\":\"Romania\"}}";
    ObjectMapper mapper = new ObjectMapper();
    UserDto user = mapper.readValue(jsonString, UserDto.class);
    AddressDto address = user.getAddress();

    String mvcResult = mockMvc.perform(post("/user/process/custompojo").content(jsonString)
      .contentType(MediaType.APPLICATION_JSON)
      .accept(MediaType.APPLICATION_JSON))
      .andExpect(status().isOk())
      .andReturn()
      .getResponse()
      .getContentAsString();

    assertEquals(String.format("{\"firstName\": %s, \"lastName\": %s, \"address\" : %s}",
      user.getFirstName(), user.getLastName(), address), mvcResult);
}

4. Kết luận

Trong bài viết này, chúng tôi đã xem xét một số hạn chế trong việc phân giải mặc định của Spring MVC và sau đó học cách sử dụng HandlerMethodArgumentResolver tùy chỉnh để vượt qua chúng.

Các bạn có thể xem code đầy đủ cho ví dụ trên tại đây

Bài viết của mình tới đây là kết thúc. Hi vọng những thông tin trong bài viết này có thể hỗ trợ tốt cho các bạn trong công việc hiện tại và trong tương lai.

Thanks for watching!