Управление потоком исключений в пружине контроллера MVC - PullRequest
0 голосов
/ 01 июня 2018

Какова хорошая практика в потоке исключений controllin весной MVC?

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

@Entity
class A{
  @Id
  @GeneratedValue
  private long id;

  @Column(nullable=false,length=10)
  private String name;
}

class A_DAO{
  public void save(A a) throws ConstraintViolationException{ persistance.save(a)}
}

Теперь, если я хочу сохранить A с более длинным именем, чем 10, следует выдать исключение.

Как бы то ни было с объектом dataManipulator

class A_DataManipulator{
  public Something save(A a ){
    try{
       a_dao.save(a);
    }
    catch(ConstraintViolationException e){
       return new ObjectThatHasExceptionDescription();
    }
    return new SomethingThatSaysItsok()
  }

}

и контроллер

@RequestMapping(value = "/addA", method = RequestMethod.POST)
@ResponseBody
public Something addA(@RequestBody A a){
   return a_data_manipulator.save(a)
}

Я бы хотел сохранить контроллер без исключения (как я слышал, это хорошая практика).

Но мой вопрос в этом случае, как бы выглядел A_Data_Manipulatorлайк?В случае исключения я хотел бы вернуть некоторый статус (404/500 и т. Д.) И несколько пользовательских сообщений.В случае успеха я хотел бы просто вернуть 200.

Полагаю, я мог бы создать что-то вроде этого:

class Message{
 public String msg;
 Message(String s) { this.msg = s}
}

  class A_Data_Manipulator{
              public Message save(A a ){
              try{
                 a_dao.save(a);
              }catch(ConstraintViolationException e){
               return new Message("some violation");
              }
             return null; 
          }
        }

// controller annotations
public ResponseEntity add(A a){
  Msg m = a_data_manipulator.save(a);
  if( m == null )
    return new ResponseEntity(HttpStatus.OK);
  return new ResponseEntity(HttpStatus.BAD_GATE,msg);

}

Это, на мой взгляд, слишком "принудительно", есть ли способкак создать такое поведение?

Спасибо за помощь!

1 Ответ

0 голосов
/ 01 июня 2018

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

Ниже приведены некоторые важные аспекты, связанные с вашим вопросом.

Сериализация исключений

Как уровень контроллера должен справляться с необходимостью сериализации исключений обратно клиенту?

Есть несколько способов справиться с этим, но, возможно, самое простое решение - определить класс, аннотированный как @ ControllerAdvice .В этом аннотированном классе мы разместим наши обработчики исключений для любых конкретных исключений из наших внутренних прикладных уровней, которые мы хотим обработать, и превратим их в действительный объект ответа для возврата нашим клиентам:

 @ControllerAdvice
 public class ExceptionHandlers {

    @ExceptionHandler
    public ResponseEntity<ErrorModel> handle(ValidationException ex) {
        return ResponseEntity.badRequest()
                             .body(new ErrorModel(ex.getMessages()));
    }

    //...
 }

Поскольку мымы не используем Java RMI в качестве протокола сериализации для наших сервисов, мы просто не можем отправить объект Java Exception обратно клиенту.Вместо этого мы должны проверить объект исключения, сгенерированный нашими внутренними прикладными уровнями, и создать действительный, сериализуемый транспортный объект, который мы действительно можем отправить обратно нашим клиентам.Для этого мы определили транспортный объект ErrorModel и просто заполнили сведениями из исключения в соответствующем методе-обработчике.

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

 /**
  * Data Transport Object to represent errors
  */
 public class ErrorModel {

    private final List<String> messages;

    @JsonCreator
    public ErrorModel(@JsonProperty("messages") List<String> messages) {
        this.messages = messages;
    }

    public ErrorModel(String message) {
        this.messages = Collections.singletonList(message);
    }

    public List<String> getMessages() {
        return messages;
    }
 }

Наконец, обратите внимание, как код обработчика ошибок из ExceptionHandlers from before обрабатывает любой ValidationException как HTTP Status 400: Bad Request.Это позволит клиенту проверить код состояния ответа и обнаружить, что наш сервис отклонил их полезную нагрузку, потому что с ней что-то не так.Точно так же легко могли бы быть обработчики для исключений, которые должны быть связаны с ошибками 5xx.

Разработка контекстных исключений

Принципы здесь:

  • Хорошие исключениясодержит все соответствующие детали их контекста, так что любые блоки перехвата могут получить любую необходимую информацию для их обработки.
  • Стремитесь создавать исключения, характерные для ваших бизнес-операций.Исключения, которые уже передают бизнес-семантику.Это лучше, чем просто выбросить RuntimeException или любое другое общее исключение.
  • Создайте свои исключения, чтобы красиво записывать всю эту значимую информацию.

Итак, первое, что здесь делается, - это проектированиеХорошие исключения подразумевают, что исключения должны инкапсулировать любые контекстные детали из места, где выдается исключение.Эта информация может быть жизненно важной для перехватывающего блока для обработки исключения (например, нашего обработчика ранее), или она может быть очень полезна во время устранения неполадок, чтобы определить точное состояние системы при возникновении проблемы, упрощая для разработчиков воспроизведениеточно такое же событие.

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

Рассмотрим следующий пример:

public class SavingsAccount implements BankAccount {

     //...

     @Override
     public double withdrawMoney(double amount) {
         if(amount <= 0)
             throw new IllegalArgumentException("The amount must be >= 0: " + amount);

         if(balance < amount) {
             throw new InsufficientFundsException(accountNumber, balance, amount);
         }
         balance -= amount;

         return balance;
     }

     //...

  }

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

Также обратите внимание на то, как исключение содержит все контекстуальные сведения о том, почему это считается исключительным условием: оно включает номер затронутого счета, его текущий баланс и сумму денег, которую мы пыталисьотозвать при возникновении исключения.

Любой блок, перехватывающий это исключение, имеет достаточно деталей, чтобы определить, что произошло (поскольку само исключение семантически значимо) и почему это произошло (поскольку контекстные сведения, инкапсулированные в объекте исключения, содержат эту информацию).

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

 /**
  * Thrown when the bank account does not have sufficient funds to satisfy
  * an operation, e.g. a withdrawal.
  */
 public class InsufficientFundsException extends SavingsAccountException {

    private final double balance;
    private final double withdrawal;

    //stores contextual details
    public InsufficientFundsException(AccountNumber accountNumber, double balance, double withdrawal) {
        super(accountNumber);
        this.balance = balance;
        this.withdrawal = withdrawal;
    }

    public double getBalance() {
        return balance;
    }

    public double getWithdrawal() {
        return withdrawal;
    }

    //the importance of overriding getMessage to provide a personalized message
    @Override
    public String getMessage() {
        return String.format("Insufficient funds in bank account %s: (balance $%.2f, withdrawal: $%.2f)." +
                                     " The account is short $%.2f",
                this.getAccountNumber(), this.balance, this.withdrawal, this.withdrawal - this.balance);
    }
 }

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

Один изтакие места, где мы хотим обработать это исключение в каком-то классе ExceptionHandlers.В приведенном ниже коде обратите внимание на то, как обрабатывается исключение в месте, где оно полностью находится вне контекста от того места, где оно было сгенерировано.Тем не менее, поскольку исключение содержит все контекстуальные сведения, мы можем создать очень содержательное контекстное сообщение для отправки обратно нашему клиенту API.

Я использую Spring @ControllerAdvice, чтобы определить обработчики исключений для конкретных исключений.

 @ControllerAdvice
 public class ExceptionHandlers {

    //...

    @ExceptionHandler
    public ResponseEntity<ErrorModel> handle(InsufficientFundsException ex) {

        //look how powerful are the contextual exceptions!!!
        String message = String.format("The bank account %s has a balance of $%.2f. Therefore you cannot withdraw $%.2f since you're short $%.2f",
                ex.getAccountNumber(), ex.getBalance(), ex.getWithdrawal(), ex.getWithdrawal() - ex.getBalance());

        logger.warn(message, ex);
        return ResponseEntity.badRequest()
                             .body(new ErrorModel(message));
    }

    //...
 }

Также стоит отметить, что в этой реализации getMessage() метод InsufficientFundsException был переопределен.Содержимое этого сообщения - то, что будут отображать наши трассировки стека журналов, если мы решим записать это конкретное исключениеПоэтому крайне важно, чтобы мы всегда переопределяли этот метод в наших классах исключений, чтобы эти ценные контекстные сведения, которые они содержат, также отображались в наших журналах.Именно в этих журналах эти подробности, скорее всего, будут иметь значение, когда мы пытаемся диагностировать проблему с нашей системой:

 com.training.validation.demo.api.InsufficientFundsException: Insufficient funds in bank account 1-234-567-890: (balance $0.00, withdrawal: $1.00). The account is short $1.00
    at com.training.validation.demo.domain.SavingsAccount.withdrawMoney(SavingsAccount.java:40) ~[classes/:na]
    at com.training.validation.demo.impl.SavingsAccountService.lambda$null$0(SavingsAccountService.java:45) ~[classes/:na]
    at java.util.Optional.map(Optional.java:215) ~[na:1.8.0_141]
    at com.training.validation.demo.impl.SavingsAccountService.lambda$withdrawMoney$2(SavingsAccountService.java:45) ~[classes/:na]
    at org.springframework.retry.support.RetryTemplate.doExecute(RetryTemplate.java:287) ~[spring-retry-1.2.1.RELEASE.jar:na]
    at org.springframework.retry.support.RetryTemplate.execute(RetryTemplate.java:164) ~[spring-retry-1.2.1.RELEASE.jar:na]
    at com.training.validation.demo.impl.SavingsAccountService.withdrawMoney(SavingsAccountService.java:40) ~[classes/:na]
    at com.training.validation.demo.controllers.SavingsAccountController.onMoneyWithdrawal(SavingsAccountController.java:35) ~[classes/:na]

Цепочка исключений и негерметичные абстракции

Принципы здесь:

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

Эффективное объяснение Javaэто очень хорошо:

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

Чтобы избежать этой проблемы, более высокие уровни должны перехватывать исключения более низкого уровня и в своихместо, выбросить исключения, которые могут быть объяснены в терминах абстракции более высокого уровня.Эта идиома называется переводом исключений:

   // Exception Translation
   try {
      //Use lower-level abstraction to do our bidding
      //...
   } catch (LowerLevelException cause) {
      throw new HigherLevelException(cause, context, ...);
   }

Каждый раз, когда мы используем сторонний API, библиотеку или инфраструктуру, наш код подвержен сбоям из-за исключений, создаваемых их классами.Мы просто не должны допустить, чтобы эти исключения ускользали от наших абстракций.Исключения, создаваемые используемыми нами библиотеками, должны быть преобразованы в соответствующие исключения из нашей собственной иерархии исключений API.

Например, для уровня доступа к данным следует избегать утечек исключений, таких как SQLException или IOException илиJPAException.

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

Рассмотрите следующий пример из нашего SavingsAccountService:

 @Override
 public double saveMoney(SaveMoney savings) {

    Objects.requireNonNull(savings, "The savings request must not be null");

    try {
        return accountRepository.findAccountByNumber(savings.getAccountNumber())
                                .map(account -> account.saveMoney(savings.getAmount()))
                                .orElseThrow(() -> new BankAccountNotFoundException(savings.getAccountNumber()));
    }
    catch (DataAccessException cause) {
        //avoid leaky abstractions and wrap lower level abstraction exceptions into your own exception
        //make sure you keep the exception chain intact such that you don't lose sight of the root cause
        throw new SavingsAccountException(savings.getAccountNumber(), cause);
    }
 }

В приведенном выше примере мы признаем, что возможно, что наш уровень доступа к данным не сможет восстановить данные нашего сберегательного счета.Нет уверенности в том, как это может произойти, однако мы знаем, что среда Spring имеет корневое исключение для всех исключений доступа к данным: DataAccessException.В этом случае мы отлавливаем любые возможные сбои доступа к данным и заключаем их в SavingsAccountException, чтобы избежать исключений, лежащих в основе абстракции, вне нашей собственной абстракции.

Стоит заметить, что SavingsAccountException не только предоставляет контекстную информацию, но также переносит основное исключение.Эта цепочка исключений является фундаментальной информацией, которая включается в трассировку стека, когда исключение регистрируется.Без этих подробностей мы могли бы знать только, что наша система вышла из строя, но не почему:

 com.training.validation.demo.api.SavingsAccountException: Failure to execute operation on account '1-234-567-890'
    at com.training.validation.demo.impl.SavingsAccountService.lambda$withdrawMoney$2(SavingsAccountService.java:51) ~[classes/:na]
    at org.springframework.retry.support.RetryTemplate.doExecute(RetryTemplate.java:287) ~[spring-retry-1.2.1.RELEASE.jar:na]
    at org.springframework.retry.support.RetryTemplate.execute(RetryTemplate.java:164) ~[spring-retry-1.2.1.RELEASE.jar:na]
    at com.training.validation.demo.impl.SavingsAccountService.withdrawMoney(SavingsAccountService.java:40) ~[classes/:na]
    at com.training.validation.demo.controllers.SavingsAccountController.onMoneyWithdrawal(SavingsAccountController.java:35) ~[classes/:na]
    at java.lang.Thread.run(Thread.java:748) [na:1.8.0_141]
    ... 38 common frames omitted
 Caused by: org.springframework.dao.QueryTimeoutException: Database query timed out!
    at com.training.validation.demo.impl.SavingsAccountRepository.findAccountByNumber(SavingsAccountRepository.java:31) ~[classes/:na]
    at com.training.validation.demo.impl.SavingsAccountRepository$$FastClassBySpringCGLIB$$d53e9d8f.invoke(<generated>) ~[classes/:na]
    ... 58 common frames omitted

SavingsAccountException является несколько общим исключением для наших услуг сберегательного счета.Его смысловая сила немного ограничена.Например, он говорит нам, что была проблема со сберегательным счетом, но он не говорит нам, что именно.В связи с этим мы можем рассмотреть возможность добавления дополнительного сообщения или оценки возможности определения более контекстуального исключения (например, WithdrawMoneyException).Учитывая его общий характер, его можно использовать в качестве корня нашей иерархии исключений для наших услуг сберегательного счета.

/**
  * Thrown when any unexpected error occurs during a bank account transaction.
  */
 public class SavingsAccountException extends RuntimeException {

    //all SavingsAccountException are characterized by the account number.
    private final AccountNumber accountNumber;

    public SavingsAccountException(AccountNumber accountNumber) {
        this.accountNumber = accountNumber;
    }

    public SavingsAccountException(AccountNumber accountNumber, Throwable cause) {
        super(cause);
        this.accountNumber = accountNumber;
    }

    public SavingsAccountException(String message, AccountNumber accountNumber, Throwable cause) {
        super(message, cause);
        this.accountNumber = accountNumber;
    }

    public AccountNumber getAccountNumber() {
        return accountNumber;
    }

    //the importance of overriding getMessage
    @Override
    public String getMessage() {
        return String.format("Failure to execute operation on account '%s'", accountNumber);
    }
 }

Повторяемость: переходные и постоянные исключения

Некоторые исключения представляют восстанавливаемые условия (например, QueryTimeoutException), а некоторые нет (например, DataViolationException).

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

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

Это знание о «кратковременности» исключений становится еще более актуальным в распределенных системах, где исключение может быть каким-либо образом сериализовано и отправлено за пределы системы.Например, если клиентский API получает сообщение об ошибке о том, что данная конечная точка HTTP не выполнена, как клиент может узнать, следует ли повторить операцию или нет?Было бы бессмысленно повторять попытку, если условие, для которого оно не выполнено, было постоянным.

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

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

  • Мы можем задокументировать, что данное исключение является временным (например, JavaDocs).
  • Мы могли бы определить аннотацию @TransientException и добавить ее к исключениям.
  • Мы могли бы определить интерфейс маркера или наследовать отTransientServiceException class.

Spring Framework следует подходу в третьем варианте для своих классов доступа к данным.Все исключения, которые наследуются от TransientDataAccessException , считаются переходными и повторными в Spring.

Это довольно хорошо работает с Spring Retry Library .Становится особенно просто определить политику повторных попыток, которая повторяет любую операцию, которая вызвала временное исключение на уровне доступа к данным.Рассмотрим следующий иллюстративный пример:

  @Override
  public double withdrawMoney(WithdrawMoney withdrawal) throws InsufficientFundsException {
     Objects.requireNonNull(withdrawal, "The withdrawal request must not be null");

     //we may also configure this as a bean
     RetryTemplate retryTemplate = new RetryTemplate();
     SimpleRetryPolicy policy = new SimpleRetryPolicy(3, singletonMap(TransientDataAccessException.class, true), true);
     retryTemplate.setRetryPolicy(policy);

     //dealing with transient exceptions locally by retrying up to 3 times
     return retryTemplate.execute(context -> {
         try {
             return accountRepository.findAccountByNumber(withdrawal.getAccountNumber())
                                     .map(account -> account.withdrawMoney(withdrawal.getAmount()))
                                     .orElseThrow(() -> new BankAccountNotFoundException(withdrawal.getAccountNumber()));
         }
         catch (DataAccessException cause) {
            //we get here only for persistent exceptions
            //or if we exhausted the 3 retry attempts of any transient exception.
            throw new SavingsAccountException(withdrawal.getAccountNumber(), cause);
         }
     });
  }

В приведенном выше коде, если DAO не удается получить запись из базы данных, например, из-за тайм-аута запроса, Spring преобразует этот сбой в QueryTimeoutException, которыйтакже TransientDataAccessException и наш RetryTemplate будет повторять эту операцию до 3 раз, прежде чем она сдастся.

Как насчет моделей с кратковременными ошибками?

Когда мы отправляем модели ошибок нашим клиентам, мы также можем воспользоваться возможностью узнать, является ли данное исключение временным или нет.Эта информация позволяет нам сообщить клиентам, что они могут повторить операцию после определенного периода отключения.

  @ControllerAdvice
  public class ExceptionHandlers {

    private final BinaryExceptionClassifier transientClassifier = new BinaryExceptionClassifier(singletonMap(TransientDataAccessException.class, true), false);
    {
        transientClassifier.setTraverseCauses(true);
    }

    //..

    @ExceptionHandler
    public ResponseEntity<ErrorModel> handle(SavingsAccountException ex) {
        if(isTransient(ex)) {
            //when transient, status code 503: Service Unavailable is sent
            //and a backoff retry period of 5 seconds is suggested to the client
            return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
                                 .header("Retry-After", "5000")
                                 .body(new ErrorModel(ex.getMessage()));
        } else {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                                 .body(new ErrorModel(ex.getMessage()));
        }
    }

    private boolean isTransient(Throwable cause) {
        return transientClassifier.classify(cause);
    }

 }

Приведенный выше код использует BinaryExceptionClassifier , который является частью библиотеки Spring Retry,определить, содержит ли данное исключение какие-либо временные исключения в их причинах, и если да, классифицирует это исключение как временное.Этот предикат используется для определения того, какой тип кода состояния HTTP мы отправляем обратно клиенту.Если исключение является временным, мы отправляем 503 Service Unavailable и предоставляем заголовок Retry-After: 5000 с подробной информацией о политике отката.

Используя эту информацию, клиенты могут решить, имеет ли смысл повторять вызов данного веб-сервисаи сколько именно им нужно ждать перед повторной попыткой.

Как насчет аннотированных исключений?

Spring Framework также предлагает возможность аннотировать исключения с помощью конкретных кодов состояния HTTP, например,

 @ResponseStatus(value=HttpStatus.NOT_FOUND, reason="No such Order")  // 404
 public class OrderNotFoundException extends RuntimeException {
     // ...
 }

Мне лично не нравится этот подход, не только из-за его ограничения генерировать соответствующее контекстное сообщение, но также и потому, что он заставляет меня соединять мой бизнес-уровень с моим уровнем контроллера: если я делаю это, внезапно моя глупость -исключения уровня должны знать об ошибках HTTP 400 или 500.Это ответственность, которая, я считаю, относится исключительно к уровню контроллера, и я предпочитаю, чтобы знание того, какой конкретный протокол связи я использую, не беспокоило мой бизнес-уровень.

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

Надеюсь, по крайней мере эта информация будет полезна для вашего расследования.

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