问题
I have a controller with a web method that looks like this:
public Response registerDevice(
@Valid final Device device,
@RequestBody final Tokens tokens
) {...}
And a validator that looks like this:
public class DeviceValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return Device.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
// Do magic
}
}
}
I'm trying to get Spring to validate the Device argument which is being generated by an interceptor. But every time I try, it validates the tokens argument instead.
I've tried using @InitBinder
to specify the validator, @Validated
instead of @Valid
and registering MethodValidationPostProcessor
classes. So far with no luck.
Either the validator is not called at all, or tokens argument is validated when I was the Device argument validated.
I'm using Spring 4.1.6 and Hibernate validator 5.1.3.
Can anyone offer any clues as to what I'm doing wrong? I've searched the web all afternoon trying to sort this out. Can't believe that the validation area of spring is still as messed up as it was 5 years ago :-(
回答1:
Ok. Have now solved it after two days of messing about with all sorts of variations. If there is one thing Spring's validation lets you do - it's come up with an incredible array of things that don't work! But back to my solution.
Basically what I needed was a way to manually create request mapping arguments, validate them and then ensure that no matter whether it was a success or failure, that the caller always received a custom JSON response. Doing this proved a lot harder than I thought because despite the number of blog posts and stackoverflow answers, I never found a complete solution. So I've endeavoured to outline each piece of the puzzle needed to achieve what I wanted.
Note: in the following code samples, I've generalised the names of things to help clarify whats custom and whats not.
Configuration
Although several blog posts I read talked about various classes such as the MethodValidationPostProcessor
, in the end I found I didn't need anything setup beyond the @EnableWebMvc
annotation. The default resolvers etc proved to be what I needed.
Request Mapping
My final request mapping signatures looked like this:
@RequestMapping(...)
public MyMsgObject handleRequest (
@Valid final MyHeaderObj myHeaderObj,
@RequestBody final MyRequestPayload myRequestPayload
) {...}
You will note here that unlike just about every blog post and sample I found, I have two objects being passed to the method. The first is an object that I want to dynamically generate from the headers. The second is a deserialised object from the JSON payload. Other objects could just as easily be included such as path arguments etc. Try something like this without the code below and you will get a wide variety of weird and wonderful errors.
The tricky part that caused me all the pain was that I wanted to validate the myHeaderObj
instance, and NOT validate the myRequestPayload
instance. This caused quite a headache to resolve.
Also note the MyMsgObject
result object. Here I want to return an object that will be serialised out to JSON. Including when exceptions occur as this class contains error fields that need to be populated in addition to the HttpStatus code.
Controller Advice
Next I created an ControllerAdvice
class which contained the binding for validation and a general error trap.
@ControllerAdvice
public class MyControllerAdvice {
@Autowired
private MyCustomValidator customValidator;
@InitBinder
protected void initBinder(WebDataBinder binder) {
if (binder.getTarget() == null) {
// Plain arguments have a null target.
return;
}
if (MyHeaderObj.class.isAssignableFrom(binder.getTarget().getClass())) {
binder.addValidators(this.customValidator);
}
}
@ExceptionHandler(Exception.class)
@ResponseStatus(value=HttpStatus.INTERNAL_SERVER_ERROR)
@ResponseBody
public MyMsgObject handleException(Exception e) {
MyMsgObject myMsgObject = new MyMsgObject();
myMsgObject.setStatus(MyStatus.Failure);
myMsgObject.setMessage(e.getMessage());
return myMsgObject;
}
}
Two things going on here. The first is registering the validator. Note that we have to check the type of the argument. This is because @InitBinder
is called for each argument to the @RequestMapping
and we only want the validator on the MyHeaderObj
argument. If we don't do this, exceptions will be thrown when Spring attempts to apply the validator to arguments it's not valid for.
The second thing is the exception handler. We have to use @ResponseBody
to ensure that Spring treats the returned object as something to be serialised out. Otherwise we will just get the standard HTML exception report.
Validator
Here we use a pretty standard validator implementation.
@Component
public class MyCustomValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return MyHeaderObj.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
...
errors.rejectValue("fieldName", "ErrorCode", "Invalid ...");
}
}
One thing that I still don't really get with this is the supports(Class<?> clazz)
method. I would have thought that Spring uses this method to test arguments to decide if this validator should apply. But it doesn't. Hence all the code in the @InitBinder
to decide when to apply this validator.
The Argument Handler
This is the biggest piece of code. Here we need to generate the MyHeaderObj
object to be passed to the @RequestMapping
. Spring will auto detect this class.
public class MyHeaderObjArgumentHandler implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return MyHeaderObj.class.isAssignableFrom(parameter.getParameterType());
}
@Override
public Object resolveArgument(
MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) throws Exception {
// Code to generate the instance of MyHeaderObj!
MyHeaderObj myHeaderObj = ...;
// Call validators if the argument has validation annotations.
WebDataBinder binder = binderFactory.createBinder(webRequest, myHeaderObj, parameter.getParameterName());
this.validateIfApplicable(binder, parameter);
if (binder.getBindingResult().hasErrors()) {
throw new MyCustomException(myHeaderObj);
}
return myHeaderObj;
}
protected void validateIfApplicable(WebDataBinder binder, MethodParameter methodParam) {
Annotation[] annotations = methodParam.getParameterAnnotations();
for (Annotation ann : annotations) {
Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class);
if (validatedAnn != null || ann.annotationType().getSimpleName().startsWith("Valid")) {
Object hints = (validatedAnn != null ? validatedAnn.value() : AnnotationUtils.getValue(ann));
Object[] validationHints = (hints instanceof Object[] ? (Object[]) hints : new Object[] { hints });
binder.validate(validationHints);
break;
}
}
}
}
The main job of this class is to use whatever means it requires to build the argument (myHeaderObj
). Once built it then proceeds to call the Spring validators to check this instance. If there is a problem (as detected by checking the returned errors), it then throws an exception that the @ExceptionHandler
's can detect and process.
Note the validateIfApplicable(WebDataBinder binder, MethodParameter methodParam)
method. This is code I found in a number of Spring's classes. It's job is to detect if any argument has a @Validated
or @Valid
annotation and if so, call the associated validators. By default, Spring does not do this for custom argument handlers like this one, so it's up to us to add this functionality. Seriously Spring ???? No AbstractSomething ????
The last piece, explicit Exception catches
Lastly I also needed to catch more explicit exceptions. For example the MyCustomException
thrown above. So here I created a second @ControllerAdvise
.
@ControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE) // Make sure we get the highest priority.
public class MyCustomExceptionHandler {
@ExceptionHandler
@ResponseStatus(value = HttpStatus.BAD_REQUEST)
@ResponseBody
public Response handleException(MyCustomException e) {
MyMsgObject myMsgObject = new MyMsgObject();
myMsgObject.setStatus(MyStatus.Failure);
myMsgObject.setMessage(e.getMessage());
return myMsgObject;
}
}
Although superficially the similar to the general exception handler. There is one different. We need to specify the @Order(Ordered.HIGHEST_PRECEDENCE)
annotation. Without this, Spring will just execute the first exception handler that matches the thrown exception. Regardless of whether there is a better matching handler or not. So we use this annotation to ensure that this exception handler is given precedence over the general one.
Summary
This solution works well for me. I'm not sure that I've got the best solution and there may be Spring classes which I've not found which can help. I hope this helps anyone with the same or similar problems.
来源:https://stackoverflow.com/questions/30433160/spring-validation-keeps-validating-the-wrong-argument