当前位置: 首页 > 知识库问答 >
问题:

在控制器spring MVC中控制异常流

施敏达
2023-03-14

spring MVC中controllin异常流的良好实践是什么?

假设我有一个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)}
}

现在,如果我想保存名称超过10的A,它应该抛出异常。

但是有一个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\u Data\u操纵器会是什么样子?如果出现异常,我想返回一些状态(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个答案

隆长卿
2023-03-14

在我的开发团队中,我们通常遵循一些原则。几个月前,我确实花时间记录了我对这个话题的想法。

以下是与您的问题相关的一些方面。

控制器层应该如何处理将异常序列化回客户端的需要?

有多种方法可以解决这个问题,但最简单的解决方案可能是定义一个注释为@ControllerAdvice的类。在这个带注释的类中,我们将为我们要处理的内部应用程序层中的任何特定异常放置异常处理程序,并将它们转换为有效的响应对象,以返回到我们的客户端:

 @ControllerAdvice
 public class ExceptionHandlers {

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

    //...
 }

由于我们没有使用Java RMI作为服务的序列化协议,因此我们无法将Java异常对象发送回客户端。相反,我们必须检查内部应用程序层生成的异常对象,并构造一个有效的、可序列化的传输对象,我们确实可以将其发送回客户端。为此,我们定义了一个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;
    }
 }

最后,请注意之前的异常处理程序中的错误处理程序代码如何将任何验证异常处理为HTTP状态400:错误请求。这将允许客户端检查响应的状态代码,并发现我们的服务拒绝了他们的负载,因为它有问题。同样容易的是,我们可以为异常设置处理程序,这些异常应该与5xx错误相关联。

这里的原则是:

  • 好的异常包含其上下文的所有相关细节,这样任何捕获块都可以获得任何必要的细节来处理它们

因此,这里的第一点是设计好的异常意味着异常应该封装来自抛出异常的位置的任何上下文细节。此信息对于处理异常的捕获块(例如我们之前的处理程序)可能至关重要,或者在故障排除期间非常有用,可以确定问题发生时系统的确切状态,从而使开发人员更容易重现完全相同的事件。

此外,异常本身最好能传递一些业务语义。换句话说,与其仅仅抛出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;
     }

     //...

  }

请注意,在上面的示例中,我们如何定义了一个语义异常Intify entFundsException来表示当有人试图从账户中提取无效金额时,账户中没有足够资金的例外情况。这是一个特定的业务异常。

还要注意异常如何包含为什么这被视为异常条件的所有上下文细节:它封装了受影响的帐号、其当前余额以及抛出异常时我们试图提取的金额。

任何捕获此异常的块都有足够的细节来确定发生了什么(因为异常本身在语义上是有意义的)以及它发生的原因(因为封装在异常对象中的上下文细节包含该信息)。

我们的异常类的定义可以是这样的:

 /**
  * 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));
    }

    //...
 }

此外,还值得注意的是,此实现中覆盖了InsufficientFundsExceptiongetMessage()方法。如果决定记录此特定异常,则日志堆栈跟踪将显示此消息的内容。因此,至关重要的是,我们总是在exceptions类中重写此方法,以便它们包含的那些有价值的上下文细节也呈现在我们的日志中。当我们试图诊断系统问题时,这些日志中的详细信息很可能会产生影响:

 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或JPAEException。

相反,您可能希望为您的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。在这种情况下,我们会捕获任何可能的数据访问失败并将它们包装到SavingsAccount tException中,以避免底层抽象异常逃脱我们自己的抽象。

值得注意的是SavingsAccount tException不仅提供上下文详细信息,还包装了底层异常。此异常链接是记录异常时包含在堆栈跟踪中的基本信息。如果没有这些详细信息,我们只能知道我们的系统失败了,但不知道原因:

 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是我们储蓄账户服务的一个常见例外。但它的语义能力有点有限。例如,它告诉我们一个储蓄账户有问题,但它并没有明确告诉我们具体是什么。为此,我们可以考虑添加一条额外的消息,或权衡定义更具上下文背景的异常的可能性(例如,提取货币异常)。鉴于其一般性质,它可以作为我们储蓄账户服务例外层次结构的根源。

/**
  * 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收到给定HTTPendpoint未能执行的错误报告,客户端如何知道是否应该重试该操作?如果失败的条件是永久的,重试将毫无意义。

当我们在充分了解业务领域和经典系统集成问题的基础上设计异常层次结构时,异常是否代表可恢复条件的信息对于设计行为良好的客户端至关重要。

我们可以遵循几种策略来指示异常是暂时的或不在我们的API中:

  • 我们可以记录一个给定的异常是暂时的(例如JavaDocs)

Spring Framework对其数据访问类遵循第三个选项中的方法。继承自瞬态数据访问异常的所有异常在Spring中都被认为是瞬态的和可重试的。

这与Spring重试库配合得相当好。定义重试策略变得特别简单,该策略可以重试导致数据访问层中出现暂时异常的任何操作。考虑以下示例:

  @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中,它也是瞬态DataAccessException和我们的RetryTemboard将在该操作投降之前重试最多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重试库的一部分)来确定给定异常是否在其原因中包含任何瞬时异常,如果是,则将该异常归类为瞬时异常。此谓词用于确定我们发送回客户端的HTTP状态代码的类型。如果异常是暂时性的,我们将发送一个503服务不可用,并提供一个标头,其中包含回退策略的详细信息。

使用这些信息,客户端可以决定重试给定的Web服务调用是否有意义,以及他们在重试之前需要等待多长时间。

Spring框架还提供了使用特定HTTP状态代码注释异常的可能性,例如。

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

我个人倾向于不喜欢这种方法,不仅因为它限制了生成适当的上下文消息,还因为它迫使我将业务层与控制器层结合起来:如果我这样做,我的bunsiness层异常突然需要知道HTTP 400或500错误。这是我认为完全属于控制器层的责任,如果我的业务层不需要担心我所使用的特定通信协议的知识,我更愿意这样做。

我们可以使用输入验证异常技术进一步扩展这个主题,但我相信答案的字符数量有限,我不相信我能把它放在这里。

我希望至少这些信息对你的调查有用。

 类似资料:
  • 我有过 我通过这种方式传递profileJson: 但是我的配置文件Json对象具有所有空字段。我应该怎么做才能让Spring解析我的json?

  • 我一直在尝试使用: 使用此链接: 但我有一个错误: 当我换成: 是工作。我能做些什么来和日期一起工作? 谢啦

  • 我正在使用Spring形式。我只需要得到Staemap作为响应,但我得到的是整个jsp页面作为响应。

  • 我想使用@SessionAttributes注释在SpringMVC中的两个控制器之间共享会话属性。 下面是我用来测试属性共享的一个简单代码:AController。JAVA a.jsp BController.java b.jsp 我期望的行为是转到 /aURL,myParam将被设置为0到99之间的随机值,然后该值将在两个控制器之间共享。 但是,会发生以下情况:我转到/a URL,myPara

  • 在创建资源类和指定资源格输出式化后, 下一步就是创建控制器操作将资源通过 RESTful APIs 展现给终端用户。 Yii 提供两个控制器基类来简化创建 RESTful 操作的工作:yii\rest\Controller 和 yii\rest\ActiveController, 两个类的差别是后者提供一系列将资源处理成 Active Record 的操作。 因此如果使用 Active Recor

  • 控制器是 MVC 模式中的一部分, 是继承yii\base\Controller类的对象,负责处理请求和生成响应。 具体来说,控制器从应用主体 接管控制后会分析请求数据并传送到模型, 传送模型结果到视图,最后生成输出响应信息。 动作 控制器由 操作 组成,它是执行终端用户请求的最基础的单元, 一个控制器可有一个或多个操作。 如下示例显示包含两个动作view and create 的控制器post: