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 来说并不是真正的问题,但这是应该注意的事情。
我正在尝试将控制器方法的请求参数映射到 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 来说并不是真正的问题,但这是应该注意的事情。