Spring MV 3.2 Exception Response Mapping

2019-05-03 11:45发布

问题:

Spring 3.2.15, MVC-based REST API here (not Spring Boot, sadly!). I am trying to implement an exception mapper/handler that meets the following criteria:

  • No matter what happens (success or error), the Spring app always returns a response entity of MyAppResponse (see below); and
  • In the event of processing a request successfully, return an HTTP status of 200 (typical); and
  • In the event of processing a request and an exception occurs, I need to control the mapping of the specific exception to a particular HTTP status code
    • Spring MVC framework errors (such as BlahException) must map to HTTP 422
    • Custom app exceptions, such as my FizzBuzzException have their own status mapping schemes:
      • FizzBuzzException -> HTTP 401
      • FooBarException -> HTTP 403
      • OmgException -> HTTP 404
    • All other exceptions, that is, non-Spring exceptions, and non-custom app exceptions (the 3 listed above), should produce an HTTP 500

Where the MyAppResponse object is:

// Groovy pseudo-code
@Canonical
class MyAppResponse {
    String detail
    String randomNumber
}

It appears like ResponseEntityExceptionHandler might be able to do this for me, but I'm not seeing the forest through the trees w.r.t. how it gets passed arguments. I'm hoping I can do something like:

// Groovy-pseudo code
@ControllerAdvice
class MyAppExceptionMapper extends ResponseEntityExceptionHandler {
    ResponseEntity<Object> handleFizzBuzzException(FizzBuzzException fbEx, HttpHeaders headers, HttpStatus status) {
        // TODO: How to reset status to 401?
        status = ???

        new ResponseEntity(fbEx.message, headers, status)
    }

    ResponseEntity<Object> handleFooBarException(FooBarException fbEx, HttpHeaders headers, HttpStatus status) {
        // TODO: How to reset status to 403?
        status = ???

        new ResponseEntity(fbEx.message, headers, status)
    }

    ResponseEntity<Object> handleOmgException(OmgException omgEx, HttpHeaders headers, HttpStatus status) {
        // TODO: How to reset status to 404?
        status = ???

        new ResponseEntity(omgEx.message, headers, status)
    }

    // Now map all Spring-generated exceptions to 422
    ResponseEntity<Object> handleAllSpringExceptions(SpringException springEx, HttpHeaders headers, HttpStatus status) {
        // TODO: How to reset status to 422?
        status = ???

        new ResponseEntity(springEx.message, headers, status)
    }

    // Everything else is a 500...
    ResponseEntity<Object> handleAllOtherExceptions(Exception ex, HttpHeaders headers, HttpStatus status) {
        // TODO: How to reset status to 500?
        status = ???

        new ResponseEntity("Whoops, something happened. Lol.", headers, status)
    }
}

Any idea how I can fully implement this mapping logic and the requirement for the entity to be a MyAppResponse instance and not just a string?

Then, is annotating the class with @ControllerAdvice the only thing that I need to do to configure Spring to use it?

回答1:

To reduce @bond-java-bond answer you do not need to build ResponseEntity by yourself:

  1. Use @ResponseStatus for each handleSomeException method (e.g. @ResponseStatus(HttpStatus.UNAUTHORIZED))
  2. Return custom MyAppResponse from those methods

But if each kind of exceptions will be processed by the same way (diffs by HTTP status only) I suggest to reduce MyAppExceptionMapper like this:

@ControllerAdvice
public class MyAppExceptionMapper {
    private final Map<Class<?>, HttpStatus> map;
    {
        map = new HashMap<>();
        map.put(FizzBuzzException.class, HttpStatus.UNAUTHORIZED);
        map.put(FooBarException.class, HttpStatus.FORBIDDEN);
        map.put(NoSuchRequestHandlingMethodException.class, HttpStatus.UNPROCESSABLE_ENTITY);
        /* List Spring specific exceptions here as @bond-java-bond suggested */
        map.put(Exception.class, HttpStatus.INTERNAL_SERVER_ERROR);
    }

    // Handle all exceptions
    @ExceptionHandler(Exception.class)
    @ResponseBody
    public ResponseEntity<MyAppResponse> handleException(Exception exception) {
        MyAppResponse response = new MyAppResponse();
        // Fill response with details

        HttpStatus status = map.get(exception.getClass());
        if (status == null) {
            status = map.get(Exception.class);// By default
        }

        return new ResponseEntity<>(response, status);
    }
}

Pros:

  1. Pretty short.
  2. No code duplication.
  3. Slightly more effective.
  4. Easy to extend.

Also, you can move mapping configuration outside and inject it.

How to configure MVC Dispatcher Servlet

First of all, check if mvc-dispatcher-servlet.xml (or another contextConfigLocation from web.xml) contains:

<context:component-scan base-package="base.package"/>
<mvc:annotation-driven/>

Secondly, check if @ControllerAdvice annotated class and @Controller annotated class both belong to subpackage of base.package.

See complete examples at Exception Handling in Spring MVC or Spring MVC @ExceptionHandler Example for more details.



回答2:

First the error / exception handler should not worry about the success response.

Thus the responsibility of success response should lie with controller (plain or REST controller) method(s) annotated with @RequestMapping as below

@RequestMapping(value = "/demo", method = RequestMethod.GET)
@ResponseStatus(value = HttpStatus.OK)
public MyAppResponse doSomething() { .... }

For mapping a particular HTTP response code with exception(s) simply write a @ControllerAdvice as below (no additional configuration required)

@ControllerAdvice
public class CustomExceptionHandler {

    // Handle FizzBuzzException with status code as 401
    @ExceptionHandler(value = FizzBuzzException.class)
    @ResponseBody
    public ResponseEntity<MyAppResponse> handleException(FizzBuzzException ex) {
        return new ResponseEntity<MyAppResponse>(buildResponse(ex), HttpStatus.UNAUTHORIZED);
    }

    // Handle FooBarException with status code as 403
    @ExceptionHandler(value = FooBarException.class)
    @ResponseBody
    public ResponseEntity<MyAppResponse> handleException(FooBarException ex) {
        return new ResponseEntity<MyAppResponse>(buildResponse(ex), HttpStatus.FORBIDDEN);
    }

    // Handle OmgException with status code as 404
    @ExceptionHandler(value = OmgException.class)
    @ResponseBody
    public ResponseEntity<MyAppResponse> handleException(OmgException ex) {
        return new ResponseEntity<MyAppResponse>(buildResponse(ex), HttpStatus.NOT_FOUND);
    }

    // handle Spring MVC specific exceptions with status code 422
    @ExceptionHandler(value = {NoSuchRequestHandlingMethodException.class, HttpRequestMethodNotSupportedException.class, HttpMediaTypeNotSupportedException.class,
        HttpMediaTypeNotAcceptableException.class, MissingPathVariableException.class, MissingServletRequestParameterException.class, ServletRequestBindingException.class,
        ConversionNotSupportedException.class, TypeMismatchException.class, HttpMessageNotReadableException.class, HttpMessageNotWritableException.class, MethodArgumentNotValidException.class,
        MissingServletRequestPartException.class, BindException.class, NoHandlerFoundException.class, AsyncRequestTimeoutException.class})
    @ResponseBody
    public ResponseEntity<MyAppResponse> handleException(Exception ex) {
        return new ResponseEntity<MyAppResponse>(buildResponse(ex), HttpStatus.UNPROCESSABLE_ENTITY);
    }

    // Handle rest of the exception(s) with status code as 500
    @ExceptionHandler(value = Exception.class)
    @ResponseBody
    public ResponseEntity<MyAppResponse> handleException(Exception ex) {
        return new ResponseEntity<MyAppResponse>(buildResponse(ex), HttpStatus.INTERNAL_SERVER_ERROR);
    }

    private MyAppResponse buildResponse(Throwable t) {
        MyAppResponse response = new MyAppResponse();
        // supply value to response object
        return response;
    }
}

Let know in comments if any further information is required.

P.S.: List of Spring MVC exception reference