ControllerAdvice in Spring Boot

ControllerAdvice – обработка ошибок в Spring

Сегодня мы узнаем как правильно обрабатывать исключения в Spring приложениях с помощью ControllerAdvice и RestControllerAdvice аннотации.

Когда мы пишем веб приложение, у нас возникает необходимость обрабатывать исключения. Пришли некорректные данные, в пользователя нет доступа, запись не найдена в базе и так далее. Причин по которым могут возникать ошибки в приложении – очень много.

В связи с этим возникает закономерное желание прийти к единому механизму обработки ошибок а также их корректной отправки клиенту. Неважно будь то пользователи нашей системы или сторонние сервера. Всем нужна понятная и точная информация о том, что именно пошло не так.

Именно для вышеописанных целей и создан механизм ControllerAdvice в фреймворке Spring.

Аннотации ControllerAdvice и RestControllerAdvice позволяют перехватывать и обрабатывать исключения по всему проекту из одного компонента обработки (класса). Все, что вам нужно сделать – создать класс, навешать на него аннотацию ControllerAdvice, создать методы по перехвату ошибок. Давайте сейчас на практике попробуем посмотреть как это делать.

Первое что нам нужно – RESTful веб приложение на Spring Boot.

Для нашего примера я напишу приложение с нуля. Для удобства и быстроты мы не будем подключать базу данных и другие компоненты, которые зачастую характерны для современных веб приложений.

Допустим у меня есть приложение для управлением автомобилями. В наличии контроллер и сервис, который отвечает за создание и поиск авто.

структура веб приложения
структура веб приложения

Для удобства управления объектами я создал класс авто:

package com.example.demo.model;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.math.BigDecimal;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class CarModel {

    private String name;
    private BigDecimal price;
}

Не пугайтесь строк:

@Data
@AllArgsConstructor
@NoArgsConstructor

Это аннотации из библиотеки Lombok, которые призваны немного упростить жизнь программистам и сделать код более читаемым. В нашем случае @Data генерирует геттеры, сеттеры, equals, hashCode, toString. Две остальные аннотации конструктор с параметрами и пустой соответственно.

Мой сервис класс призван хранить в себе логику по обработке авто:

package com.example.demo.service;

import com.example.demo.exception.AuthException;
import com.example.demo.exception.NotFoundException;
import com.example.demo.exception.ValidationException;
import com.example.demo.model.CarModel;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

import static java.util.Objects.isNull;
import static org.apache.logging.log4j.util.Strings.isBlank;

@Service
public class DefaultCarService {
    static List<CarModel> carList = new ArrayList<>();

    static {
        carList.add(new CarModel("Honda", BigDecimal.TEN));
    }

    public void save(CarModel car) throws ValidationException {
        if (isNull(car)) {
            throw new ValidationException("Car object is null");
        }
        if (isBlank(car.getName())) {
            throw new ValidationException("Name can't be empty");
        }
        if (isNull(car.getPrice()) || car.getPrice().compareTo(BigDecimal.ZERO) < 0) {
            throw new ValidationException("Price can't be empty or less than 0");
        }

        //proceed with car saving
    }

    public CarModel getCar(String name) throws NotFoundException {
        return carList.stream()
                .filter(it -> it.getName().equals(name))
                .findFirst()
                .orElseThrow(() -> new NotFoundException("Car not found by name " + name));
    }

    public void adminFunctional(String username) throws AuthException {
        if (!username.equals("admin")) {
            throw new AuthException(String.format("Username %s is not allowed to perform this action", username));
        }
    }
}

Так как базы данных у меня нет, поиск авто я выполнил по обычному списку. Туда я положил одно авто с именем Honda.

Обратите внимание на метод adminFunctional. Добавил я его только потому что хотел показать больше примеров исключений. Хоть код и получился довольно странный – он очень наглядно продемонстрирует нам как можно обработать множество ошибок в несколько строк.

Что касается классов исключений, то их код довольно простой и однотипный для каждого класса. Вот например ValidationException:

package com.example.demo.exception;

import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
@Getter
public class ValidationException extends Exception {

    private final String message;
}

Здесь все то, что мы делали когда знакомились с исключениями в Java. В блоке, где писали собственные классы исключений.

Код для NotFoundException и AuthException идентичный.

Дальше я написал самый обычный REST контроллер, который будет принимать запросы по созданию и поиску авто:

package com.example.demo.controller;

import com.example.demo.exception.AuthException;
import com.example.demo.exception.NotFoundException;
import com.example.demo.exception.ValidationException;
import com.example.demo.model.CarModel;
import com.example.demo.service.DefaultCarService;
import lombok.AllArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@AllArgsConstructor
@RequestMapping("/car")
public class CarController {

    private final DefaultCarService carService;

    @PostMapping
    public ResponseEntity<Void> save(@RequestBody CarModel car) throws ValidationException {
        carService.save(car);
        return ResponseEntity.ok().build();
    }

    @GetMapping("/{name}")
    public ResponseEntity<CarModel> getByName(@PathVariable String name) throws NotFoundException {
        return ResponseEntity.ok(carService.getCar(name));
    }

    @GetMapping("/admin/{username}")
    public ResponseEntity<Void> adminFunctional(@PathVariable String username) throws AuthException {
        carService.adminFunctional(username);
        return ResponseEntity.ok().build();
    }
}

Теперь самое интересное. Я создал обычный класс ControllerAdvisor, навешал на него RestControllerAdvice аннотацию и получил перехват ошибок в одном месте. Здесь, кстати, нет разницы использовать RestControllerAdvice или ControllerAdvice. Первый добавили совсем недавно и по сути RestControllerAdvice – это @ControllerAdvice + @ResponseBody.

В нашем случае я использую @RestControllerAdvice аннотацию, так как у меня REST контроллер в приложении и это просто удобнее. Не нужно добавлять @ResponseBody аннотацию отдельно.

Дальше я создал класс, который будет возвращаться клиенту в случае ошибки:

package com.example.demo.controller;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.http.HttpStatus;

import java.time.LocalDateTime;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ErrorResponse {

    private String message;
    private HttpStatus status;
    private LocalDateTime timestamp;
}

Я подумал что будет уместно отправлять не только сообщение об ошибке, но и статус (хотя статус мы будем отправлять в ответе), а также поле timestamp, чтобы видеть когда произошла ошибка.

В моем перехватчике ошибок я создаю методы для каждого типа ошибки и возвращаю соответствующий результат клиенту:

package com.example.demo.controller;

import com.example.demo.exception.AuthException;
import com.example.demo.exception.NotFoundException;
import com.example.demo.exception.ValidationException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import static java.time.LocalDateTime.now;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.NOT_FOUND;
import static org.springframework.http.HttpStatus.UNAUTHORIZED;

@RestControllerAdvice
public class ControllerAdvisor {

    @ExceptionHandler(ValidationException.class)
    @ResponseStatus(BAD_REQUEST)
    public ErrorResponse handleValidationException(ValidationException validationException) {
        return ErrorResponse.builder()
                .message(validationException.getMessage())
                .status(BAD_REQUEST)
                .timestamp(now())
                .build();
    }

    @ExceptionHandler(NotFoundException.class)
    @ResponseStatus(NOT_FOUND)
    public ErrorResponse handleNotFoundException(NotFoundException notFoundException) {
        return ErrorResponse.builder()
                .message(notFoundException.getMessage())
                .status(HttpStatus.NOT_FOUND)
                .timestamp(now())
                .build();
    }

    @ExceptionHandler(AuthException.class)
    @ResponseStatus(UNAUTHORIZED)
    public ErrorResponse handleNotFoundException(AuthException authException) {
        return ErrorResponse.builder()
                .message(authException.getMessage())
                .status(HttpStatus.UNAUTHORIZED)
                .timestamp(now())
                .build();
    }
}

Аннотация ExceptionHandler принимает класс исключение, на который будет реагировать метод. @ResponseStatus – код http ошибки, которую мы будем отправлять в ответе. Также код ошибки я возвращаю в теле. Хотя это и не обязательно. Я не забываю при этом получать сообщение (message) из класса исключения и перебрасывать его в ErrorResponse, чтобы клиент видел что именно случилось на сервере.

Настало время протестировать наш код. Для этого я использую Postman.

На скрине выше видно, что я делаю попытку сохранить авто с отрицательной ценой. Срабатывает одна из проверок и я получаю ошибку.

Тоже самое будет и с другими типами исключений. Я стандартизировал ответ для исключений и теперь фронтенд сможет прийти к одному подходу обработки таких ситуаций.

Это все, что я хотел написать о ControllerAdvice механизме в Spring. Валидируйте входящие данные, стандартизируйте подход к ошибкам. Это делает код чище и с меньшим количеством багов.

1 thought on “ControllerAdvice – обработка ошибок в Spring”

Leave a Comment

Your email address will not be published.