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_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);
}
我认为这也是强迫",有什么方法可以创造这样的行为吗?
感谢您的帮助!
答案 0 :(得分:3)
我们的开发团队通常遵循一系列原则。几个月前,我实际上花了document my thoughts来讨论这个话题。
以下是与您的问题相关的一些相关方面。
控制器层应该如何处理将异常序列化回客户端的需要?
有多种方法可以解决这个问题,但也许最简单的解决方案是定义一个注释为@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
的错误处理程序代码如何将任何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]
这里的原则是:
有效的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 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
)。
当异常情况是暂时的,我们相信如果我们再试一次,我们可能会成功,我们说这种异常是暂时的。另一方面,当异常条件是永久性的时候,我们说这种异常是持久的。
这里的主要观点是瞬态异常是重试块的良好候选者,而持久异常需要以不同方式处理,通常需要一些人为干预。
这种短暂性的知识&#39;在异常可以以某种方式序列化并发送到系统边界之外的分布式系统中,异常变得更加相关。例如,如果客户端API收到报告给定HTTP端点无法执行的错误,那么客户端如何知道是否应该重试该操作?如果失败的条件是永久性的,则重试是没有意义的。
当我们基于对业务领域和经典系统集成问题的充分理解来设计异常层次结构时,异常表示可恢复条件的信息对于设计良好的行为客户端至关重要。
我们可以遵循几种策略来表明异常是暂时的或不在我们的API中:
@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
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 Framework还提供了使用特定HTTP状态代码注释异常的可能性,例如
@ResponseStatus(value=HttpStatus.NOT_FOUND, reason="No such Order") // 404
public class OrderNotFoundException extends RuntimeException {
// ...
}
我个人倾向于不喜欢这种方法,不仅因为它限制了生成适当的上下文消息,而且还因为它迫使我将业务层与我的控制器层耦合:如果我这样做,突然间我的bunsiness-layer异常需要了解HTTP 400或500错误。这是我认为属于控制器层的责任,如果我使用的具体通信协议的知识不应该是我的业务层需要担心的事情,我更喜欢。
我们可以使用输入验证异常技术进一步扩展主题,但我相信答案的字符数量有限,而且我不相信我能在这里适应它。
我希望至少这些信息对您的调查有用。