JHipster 中的设计(1)RESTful API Response 与异常处理的设计

一、 Response 设计

在JHipster生成的项目中,RESTful API的Response相比一些传统的方式,特别的依赖了Response.header来传输一些附加信息,比如分页请求结果中的总数、执行的方法代码等。下面以用户相关接口为例:

namemethoduribody
Get UserGET/users/{userId}
List UsersGET/users?page={page}
Create UserPOST/usersuser info
Update UserPUT/usersuser info
Delete UserDELETE/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-ContextX-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标准的状态码和详细的错误信息来描述一个异常。因为自定义太多的状态码相对更难维护,而且设计起来也会很容易崩溃。想一想你需要为很多的异常都单独设计一个特定的状态码,那么至少还需要维护一份文档,对吧?

以上仅为参考,并非标准。

  • 4
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值