在我的开发团队中,我们通常遵循许多原则。几个月前,我实际上花时间记录了我对这个话题的想法。
以下是与您的问题相关的一些相关方面。
异常序列化
控制器层应该如何处理将异常序列化回客户端的需求?
有多种方法可以解决这个问题,但也许最简单的解决方案是定义一个注释为@ControllerAdvice的类。在这个带注释的类中,我们将为我们想要处理的内部应用程序层中的任何特定异常放置异常处理程序,并将它们转换为有效的响应对象以返回给我们的客户:
@ControllerAdvice
public class ExceptionHandlers {
@ExceptionHandler
public ResponseEntity<ErrorModel> handle(ValidationException ex) {
return ResponseEntity.badRequest()
.body(new ErrorModel(ex.getMessages()));
}
//...
}
由于我们没有使用 Java RMI 作为服务的序列化协议,因此我们根本无法将 JavaException对象发送回客户端。相反,我们必须检查内部应用程序层生成的异常对象,并构造一个有效的、可序列化的传输对象,我们确实可以将其发送回客户端。就此而言,我们定义了一个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之前的错误处理程序代码如何将任何ValidationException视为 HTTP 状态 400:错误请求。这将允许客户端检查响应的状态代码并发现我们的服务拒绝了他们的有效负载,因为它有问题。就像我们可以很容易地为应该与 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]
异常链和泄漏抽象
这里的原则是:
- 开发人员必须非常了解他们正在使用的抽象,并了解此抽象或类可能抛出的任何异常。
- 不应允许您的库中的异常从您自己的抽象中逃脱。
- 确保使用异常链接以避免在将低级异常包装到高级异常时丢失重要的上下文细节。
Effective Java 很好地解释了它:
当一个方法抛出一个与它执行的任务没有明显联系的异常时,这是令人不安的。当方法传播由较低级别抽象引发的异常时,通常会发生这种情况。它不仅令人不安,而且还通过实现细节污染了更高层的 API。如果更高层的实现在以后的版本中发生变化,它抛出的异常也会发生变化,可能会破坏现有的客户端程序。
为了避免这个问题,较高层应该捕获较低级别的异常,并在他们的位置抛出异常,这些异常可以用较高级别的抽象来解释。这个成语被称为异常翻译:
// Exception Translation
try {
//Use lower-level abstraction to do our bidding
//...
} catch (LowerLevelException cause) {
throw new HigherLevelException(cause, context, ...);
}
每次我们使用第三方 API、库或框架时,我们的代码都会因为它们的类抛出异常而失败。我们根本不能允许这些异常从我们的抽象中逃脱。我们使用的库抛出的异常应该从我们自己的 API 异常层次结构中转换为适当的异常。
例如,对于您的数据访问层,您应该避免泄漏异常,如SQLExceptionorIOException或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. 在这种情况下,我们捕获任何可能的数据访问失败并将它们包装到 aSavingsAccountException中,以避免底层抽象异常逃脱我们自己的抽象。
值得注意的是,它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);
}
}
可重试性:瞬态与持久性异常
一些例外代表可恢复的条件(例如 a QueryTimeoutException),而有些则不代表(例如DataViolationException)。
当异常条件是暂时的,并且我们相信如果我们再试一次我们可能会成功,我们说这种异常是暂时的。另一方面,当异常情况是永久的,那么我们说这种异常是持久的。
这里的主要观点是瞬时异常是重试块的良好候选者,而持久异常需要以不同方式处理,通常需要一些人工干预。
这种关于异常“暂时性”的知识在分布式系统中变得更加相关,在分布式系统中,异常可以以某种方式序列化并发送到系统边界之外。例如,如果客户端 API 收到一个给定 HTTP 端点执行失败的错误报告,客户端如何知道该操作是否应该重试?如果失败的条件是永久性的,那么重试将毫无意义。
当我们基于对业务领域和经典系统集成问题的良好理解设计异常层次结构时,异常是否代表可恢复条件的信息对于设计良好行为的客户端至关重要。
我们可以遵循几种策略来指示异常是暂时的或不在我们的 API 中:
- 我们可以记录给定的异常是暂时的(例如JavaDocs)。
- 我们可以定义一个
@TransientException注释并将其添加到异常中。
TransientServiceException我们可以定义一个标记接口或从一个类继承。
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同样是 a 的 a 中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包含退避策略详细信息的标头。
使用此信息,客户端可以决定重试给定的 Web 服务调用是否有意义,以及在重试之前他们需要等待多长时间。
带注释的异常怎么样?
Spring 框架还提供了使用特定 HTTP 状态代码注释异常的可能性,例如
@ResponseStatus(value=HttpStatus.NOT_FOUND, reason="No such Order") // 404
public class OrderNotFoundException extends RuntimeException {
// ...
}
我个人倾向于不喜欢这种方法,不仅因为它限制生成适当的上下文消息,还因为它迫使我将我的业务层与我的控制器层耦合:如果我这样做,突然我的 bunsiness 层异常需要了解 HTTP 400 或 500 错误。这是我认为完全属于控制器层的责任,如果知道我使用的特定通信协议不应该是我的业务层需要担心的事情,我更愿意这样做。
我们可以通过输入验证异常技术进一步扩展该主题,但我相信答案的字符数量有限,我不相信我可以让它适合这里。
我希望至少这些信息对您的调查有用。