Spring 引导:请求参数到 POJO 的可选映射

Spring Boot: Optional mapping of request parameters to POJO

我正在尝试将控制器方法的请求参数映射到 POJO 对象,但前提是它的任何字段都存在。但是,我似乎无法找到实现这一目标的方法。我有以下 POJO:

public class TimeWindowModel {

    @NotNull
    public Date from;

    @NotNull
    public Date to;

}

如果指定了 none 个字段,我想得到一个空的 Optional,否则我会得到一个 Optional,其中包含经过验证的 POJO 实例。 Spring 通过在处理程序中保留未注释的方式支持将请求参数映射到 POJO 对象:

@GetMapping("/shop/{shopId}/slot")
public Slice<Slot> getSlots(@RequestAttribute("staff") Staff staff,
                            @PathVariable("shopId") Long shopId, @Valid TimeWindowModel timeWindow) {
    // controller code
}

由此,Spring 会将请求参数“from”和“to”映射到 TimeWindowModel 的实例。但是,我想让这个映射成为可选的。对于 POST 请求,您可以使用 @RequestBody @Valid Optional<T>,这将为您提供包含 T 实例的 Optional<T>,但前提是提供了请求正文,否则它将为空。这使得 @Valid 按预期工作。

当没有注释时,Optional<T> 似乎没有做任何事情。您总是会得到一个带有 POJO 实例的 Optional<T>。与 @Valid 结合使用时会出现问题,因为它会抱怨未设置“from”和“to”。

目标是获取 (a) POJO 实例,其中 “from”和“to”都不为空,或者 (b) 什么都没有。如果只指定了其中一个,那么 @Valid 应该会失败并报告另一个丢失。

要实现逻辑 (a) 两者都不是空值或 (b) 两者都是空值,您需要实现自定义验证。 例子在这里:

https://blog.clairvoyantsoft.com/spring-boot-creating-a-custom-annotation-for-validation-edafbf9a97a4

https://www.baeldung.com/spring-mvc-custom-validator

通常,您创建一个新注解,它只是一个存根,然后您创建一个验证器,它实现 ConstraintValidator,您在其中提供您的逻辑,然后将您的新注解放入您的 POJO。

我想出了一个解决方案,使用自定义 HandlerMethodArgumentResolver、Jackson 和 Jackson Databind。

注解:

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestParamBind {
}

解析器:

public class RequestParamBindResolver implements HandlerMethodArgumentResolver {

    private final ObjectMapper mapper;

    public RequestParamBindResolver(ObjectMapper mapper) {
        this.mapper = mapper.copy();
        this.mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
    }

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterAnnotation(RequestParamBind.class) != null;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mav, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        // take the first instance of each request parameter
        Map<String, String> requestParameters = webRequest.getParameterMap()
                .entrySet().stream()
                .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue()[0]));

        // perform the actual resolution
        Object resolved = doResolveArgument(parameter, requestParameters);

        // *sigh*
        // see: 
        if (parameter.hasParameterAnnotation(Valid.class)) {
            String parameterName = Conventions.getVariableNameForParameter(parameter);
            WebDataBinder binder = binderFactory.createBinder(webRequest, resolved, parameterName);
            // DataBinder constructor unwraps Optional, so the target could be null
            if (binder.getTarget() != null) {
                binder.validate();
                BindingResult bindingResult = binder.getBindingResult();
                if (bindingResult.getErrorCount() > 0)
                    throw new MethodArgumentNotValidException(parameter, bindingResult);
            }
        }

        return resolved;
    }

    private Object doResolveArgument(MethodParameter parameter, Map<String, String> requestParameters) {
        Class<?> clazz = parameter.getParameterType();
        if (clazz != Optional.class)
            return mapper.convertValue(requestParameters, clazz);

        // special case for Optional<T>
        Type type = parameter.getGenericParameterType();
        Class<?> optionalType = (Class<?>)((ParameterizedType)type).getActualTypeArguments()[0];
        Object obj = mapper.convertValue(requestParameters, optionalType);

        // convert back to a map to find if any fields were set
        // TODO: how can we tell null from not set?
        if (mapper.convertValue(obj, new TypeReference<Map<String, String>>() {})
                .values().stream().anyMatch(Objects::nonNull))
            return Optional.of(obj);

        return Optional.empty();
    }
}

然后,我们注册它:

@Configuration
public class WebConfig implements WebMvcConfigurer {

    //...

    @Override
    public void addArgumentResolvers(
      List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(new RequestParamBindResolver(new ObjectMapper()));
    }
}

终于可以这样使用了:

@GetMapping("/shop/{shopId}/slot")
public Slice<Slot> getSlots(@RequestAttribute("staff") Staff staff,
                            @PathVariable("shopId") Long shopId,
                            @RequestParamBind @Valid Optional<TimeWindowModel> timeWindow) {
    // controller code
}

这完全符合您的预期。

我确信可以通过在解析器中使用 Spring 自己的 DataBind 类 来完成此操作。然而,Jackson Databind 似乎是最 straight-forward 的解决方案。也就是说,它无法区分设置为空的字段和未设置的字段。这对我的 use-case 来说并不是真正的问题,但这是应该注意的事情。