一、 Response 设计
在JHipster生成的项目中,RESTful API的Response相比一些传统的方式,特别的依赖了Response.header来传输一些附加信息,比如分页请求结果中的总数、执行的方法代码等。下面以用户相关接口为例:
name | method | uri | body |
---|---|---|---|
Get User | GET | /users/{userId} | |
List Users | GET | /users?page={page} | |
Create User | POST | /users | user info |
Update User | PUT | /users | user info |
Delete User | DELETE | /users/{userId} |
响应成功
根据以上示例,请求成功的Response如下:
1. Get user,将用户对象直接放入Response Body
// GET /users/{userId}
Response {
"status": 200,
"body": {
// user info
}
}
2. List Users,将用户列表数据放入Response Body,并将总的用户数放入Header中:
// GET /users?page={page}
Response {
"status": 200,
"header": {
X-Total-Count: 40
// more info
},
"body": {
// user list data
}
}
3. Create User,将新创建的用户对象放入Response Body,并将状态码更改为201:
// POST /users, body: user info
Response {
"status": 201,
"body": {
// user info
}
}
4. Update User,与Create User类似。
// PUT /users, body: user info
Response {
"status": 200,
"body": {
// user info
}
}
5. Delete User,无Body,状态码为200则意味着删除成功。
// DELETE /users/{userId}
Response {
"status": 200,
"body": null
}
注:除了以上有说明的header信息之外,JHipster可能还会将其他一些额外的信息放入header中,比如:
X-Application-Context
,X-Content-Type-Options
等。
通过以上示例可以看出,JHipster 将一些附加的信息放入了Response.header中,比如用户列表总数。而如果是将返回结果统一封装,并将主要结果放入Response.body.data属性中,附加信息放入Response.body中,那么则必须考虑如果只存在单个对象时如何处理,比如可能产生的如下两种response结构:
// GET /users/{userId}
Response {
"status": 200,
"body": {
"data": {
// user info
}
}
}
// GET /users?page={page}
Response {
"status": 200,
"body": {
"data": {
// user list data
},
"total": 40
}
}
当然,使用JHipster的方式,可能让人忽视Response.header中的附加信息,因为不管是开发人员还是譬如Postman这样的接口测试工具,都更关注的是Response.body中的内容。
响应异常
那么如果是发生异常,不管是客户端请求错误还是服务器异常,返回的都是统一的数据结构,JHipster遵从RFC7807,其数据结构示例如下:
Response {
"status": 400
"body": {
"entityName" : "userManagement",
"errorKey" : "userexists",
"type" : "http://www.JHipster.tech/problem/login-already-used",
"title" : "Login already in use",
"status" : 400,
"message" : "error.userexists",
"params" : "userManagement"
}
// 其它信息,不重要
"X-Application-Context": "ex_5:swagger,dev:8081",
"X-Content-Type-Options: "nosniff",
"X-ex5App-error": "error.userexists",
"X-ex5App-params": "userManagement",
"X-XSS-Protection": "1; mode=block",
// ...
}
异常时的错误信息结构,根据项目或开发人员的不同会有所不同,但是统一的结构却是必须的。而在服务端,根据异常的不同需要填充不同的返回信息。实现的方式主要分为两种:一种方式是在controller方法中,捕获异常并将其转换成友好的响应结果返回;另外一种方式是在controller中抛出异常,由全局的异常捕获器将异常捕获,然后再将其转换成友好的响应结果返回。个人更倾向于第二种方式,当然这种方式的最终实现也有多种,JHipster主要的实现方式如下节。
二、 全局异常处理
在Controller类的方法中,非必要时不捕获Service层异常和参数效验后直接抛出异常的方式能够使方法更简洁,也能更直观的体现业务意义。同时统一捕获异常处理,也有便于修改和维护等优点。
JHipster在实现全局异常处理时,除了使用spring提供的类和方法之外,还使用了problem-spring-web库。problem-spring-web是一个用于处理Spring Web MVC中问题的库,它可以很容易地从Spring应用程序生成一个application/problem+json的响应。
更多介绍请参考Github地址:https://github.com/zalando/problem-spring-web。
JHipster在实现全局异常处理时,有以下几个特点:
- 几乎所有的自定义异常都是
org.zalando.problem.AbstractThrowableProblem
的直接或间接子类; - 返回的内容都被转译为
org.zalando.problem.Problem
对象结构数据; - 全局异常处理类继承自
org.zalando.problem.spring.web.advice.ProblemHandling
,它已经包含了默认的异常处理,所以即使不使用@ExceptionHandler捕获异常,只需要重载process方法,也能处理异常。
实现
实现一个最简单的全局异常类,只需要添加@ControllerAdvice
注解,并实现org.zalando.problem.spring.web.advice.ProblemHandling
接口即可。如果你需要自定义处理,那么重载process(...)
方法即可,示例如下:
@ControllerAdvice
public class ExceptionTranslator implements ProblemHandling {
@Override
public ResponseEntity<Problem> process(@Nullable ResponseEntity<Problem> entity, NativeWebRequest request) {
// do something
}
}
不过JHipster预先做了一些额外的处理,其代码如下:
@ControllerAdvice
public class ExceptionTranslator implements ProblemHandling {
/**
* Post-process Problem payload to add the message key for front-end if needed
*/
@Override
public ResponseEntity<Problem> process(@Nullable ResponseEntity<Problem> entity, NativeWebRequest request) {
if (entity == null || entity.getBody() == null) {
return entity;
}
Problem problem = entity.getBody();
if (!(problem instanceof ConstraintViolationProblem || problem instanceof DefaultProblem)) {
return entity;
}
ProblemBuilder builder = Problem.builder()
.withType(Problem.DEFAULT_TYPE.equals(problem.getType()) ? ErrorConstants.DEFAULT_TYPE : problem.getType())
.withStatus(problem.getStatus())
.withTitle(problem.getTitle())
.with("path", request.getNativeRequest(HttpServletRequest.class).getRequestURI());
if (problem instanceof ConstraintViolationProblem) {
builder
.with("violations", ((ConstraintViolationProblem) problem).getViolations())
.with("message", ErrorConstants.ERR_VALIDATION);
return new ResponseEntity<>(builder.build(), entity.getHeaders(), entity.getStatusCode());
} else {
builder
.withCause(((DefaultProblem) problem).getCause())
.withDetail(problem.getDetail())
.withInstance(problem.getInstance());
problem.getParameters().forEach(builder::with);
if (!problem.getParameters().containsKey("message") && problem.getStatus() != null) {
builder.with("message", "error.http." + problem.getStatus().getStatusCode());
}
return new ResponseEntity<>(builder.build(), entity.getHeaders(), entity.getStatusCode());
}
}
@Override
public ResponseEntity<Problem> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, @Nonnull NativeWebRequest request) {
BindingResult result = ex.getBindingResult();
List<FieldErrorVM> fieldErrors = result.getFieldErrors().stream()
.map(f -> new FieldErrorVM(f.getObjectName(), f.getField(), f.getCode()))
.collect(Collectors.toList());
Problem problem = Problem.builder()
.withType(ErrorConstants.CONSTRAINT_VIOLATION_TYPE)
.withTitle("Method argument not valid")
.withStatus(defaultConstraintViolationStatus())
.with("message", ErrorConstants.ERR_VALIDATION)
.with("fieldErrors", fieldErrors)
.build();
return create(ex, problem, request);
}
@ExceptionHandler(BadRequestAlertException.class)
public ResponseEntity<Problem> handleBadRequestAlertException(BadRequestAlertException ex, NativeWebRequest request) {
return create(ex, request, HeaderUtil.createFailureAlert(ex.getEntityName(), ex.getErrorKey(), ex.getMessage()));
}
@ExceptionHandler(ConcurrencyFailureException.class)
public ResponseEntity<Problem> handleConcurrencyFailure(ConcurrencyFailureException ex, NativeWebRequest request) {
Problem problem = Problem.builder()
.withStatus(Status.CONFLICT)
.with("message", ErrorConstants.ERR_CONCURRENCY_FAILURE)
.build();
return create(ex, problem, request);
}
}
源码分析
在上面的代码中,ExceptionTranslator
类捕获了BadRequestAlertException
异常,并作出了自定义的处理,HeaderUtil.createFailureAlert(...)
方法主要是将额外的信息放入Response.header中,其源码如下:
public static HttpHeaders createFailureAlert(String entityName, String errorKey, String defaultMessage) {
log.error("Entity processing failed, {}", defaultMessage);
HttpHeaders headers = new HttpHeaders();
headers.add("X-ex5App-error", "error." + errorKey);
headers.add("X-ex5App-params", entityName);
return headers;
}
两个参数的意义:
- X-ex5App-error:表示错误的key,通常一类操作使用同一个key,比如用户相关操作对应的key为’userManagement’;
- X-ex5App-params:具体错误代号,比如创建用户时,账号已经存在对应’error.userexists’。
当然以上参数实际上也会体现在Response.body中:
Response.body {
"entityName" : "userManagement",
"errorKey" : "userexists",
"type" : "http://www.JHipster.tech/problem/login-already-used",
"title" : "Login already in use",
"status" : 400,
"message" : "error.userexists",
"params" : "userManagement"
}
自定义异常
一个好的异常类,根据其名称便能快速的理解其业务意义,再封装一些详细的错误信息,使用起来也更为便捷。JHipster便这样做了,以客户端相关异常为例,其定义了如下结构的一系列异常:
org.zalando.problem.AbstractThrowableProblem
- BadRequestAlertException
- EmailAlreadyUsedException
- EmailNotFoundException
- LoginAlreadyUsedException
这是个让人一眼就能看分辨其异常原因的异常定义。同时在最后的具体异常里封装了异常原因、状态码等信息,便于使用,比如:
public class EmailNotFoundException extends AbstractThrowableProblem {
public EmailNotFoundException() {
super(ErrorConstants.EMAIL_NOT_FOUND_TYPE, "Email address not registered", Status.BAD_REQUEST);
}
}
这样我们只需要throw new EmailNotFoundException()
,便能抛出带有详细信息的一个异常。
三、总结
通过对以上的分析以及其它的一些经验,得出以下几个可参考的特点或建议:
1. Response 设计
- 通过将附加信息放入Response.header中,简化了Response.body的数据结构;
- 所有的异常都转译为结构统一且对用户友好的数据返回,用户不需要关心具体的堆栈信息;
- 发生异常时,将Response.status设置成4xx或5xx,以表示客户端或服务端错误。这样也易于前端通过try-cache捕获异常,而不是对body中status进行判断来捕获异常。如果有具体的错误代码,再根据body.status来判断。
2. 异常处理
- 自定义贴合业务意义的异常有易于代码的可读性以及使用效率,比如封装相同的详细的错误信息和状态码等;
- 全局的异常处理可以使Controller层的代码更简洁且更具有可读性,也更容易修改和维护;
- Controller类对于用户请求的方法中,应该主要体现处理用户请求的业务意义,而不是处理一大堆异常并将其转译为友好的返回结果;
- 尽量使用HTTP标准的状态码和详细的错误信息来描述一个异常。因为自定义太多的状态码相对更难维护,而且设计起来也会很容易崩溃。想一想你需要为很多的异常都单独设计一个特定的状态码,那么至少还需要维护一份文档,对吧?
以上仅为参考,并非标准。