Controller
1. 参数接收
常见的请求就分为 get 跟 post 两种:
@RestController
@RequestMapping("/product/product-info")
public class ProductInfoController {
@Autowired
ProductInfoService productInfoService;
@GetMapping("/findById")
public ProductInfoQueryVo findById(Integer id) {
...
}
@PostMapping("/page")
public IPage findPage(Page page, ProductInfoQueryVo vo) {
...
}
}
@RestController:之前解释过,@RestController=@Controller+ResponseBody。加上这个注解,springboot 就会吧这个类当成 controller 进行处理,然后把所有返回的参数放到 ResponseBody 中。
@RequestMapping:请求的前缀,也就是所有该 Controller 下的请求都需要加上 /product/product-info 的前缀。
@GetMapping(“/findById”): 标志这是一个 get 请求,并且需要通过 /findById 地址才可以访问到。
@PostMapping(“/page”):同理,表示是个 post 请求。参数:至于参数部分,只需要写上 ProductInfoQueryVo,前端过来的 json 请求便会通过映射赋值到对应的对象中,例如请求这么写,productId 就会自动被映射到 vo 对应的属性当中。
2. 返回格式
约定大于配置,前后端的交互如果有一套规范,像http的返回一样具有通用意义,才能事半功倍。
也就是包含 {code,msg:data}
不封装时:
{
"productId": 1,
"productName": "泡脚",
"productPrice": 100.00,
"productDescription": "中药泡脚加按摩",
"productStatus": 0,
}
封装后:
{
"code": 1000,
"msg": "请求成功",
"data": {
"productId": 1,
"productName": "泡脚",
"productPrice": 100.00,
"productDescription": "中药泡脚加按摩",
"productStatus": 0,
}
}
这些状态码肯定都是要预先编好的,怎么编呢?写个常量 1000?还是直接写死 1000?要这么写就真的书白读的了,写状态码当然是用枚举:
1. 根据业务创建返回值枚举类,毕竟枚举类,当然不能有 setter 方法了,因此我们不能在用 @Data 注解了,我们要用 @Getter。
@Getter
public enum ResultCode implements StatusCode{
SUCCESS(200, "请求成功"),
FAILED(400, "请求失败"),
VALIDATE_ERROR(401, "参数校验失败"),
RESPONSE_PACK_ERROR(500, "活动太火爆了,请稍后再试");
private int code;
private String msg;
ResultCode(int code, String msg) {
this.code = code;
this.msg = msg;
}
}
2. 创建ResultVo 包装类,我们预设了几种默认的方法,比如成功的话就默认传入 object 就可以了,我们自动包装成 success。
@Data
public class ResultVo {
// 状态码
private int code;
// 状态信息
private String msg;
// 返回对象
private Object data;
// 手动设置返回vo
public ResultVo(int code, String msg, Object data) {
this.code = code;
this.msg = msg;
this.data = data;
}
// 默认返回成功状态码,数据对象
public ResultVo(Object data) {
this.code = ResultCode.SUCCESS.getCode();
this.msg = ResultCode.SUCCESS.getMsg();
this.data = data;
}
// 返回指定状态码,数据对象
public ResultVo(StatusCode statusCode, Object data) {
this.code = statusCode.getCode();
this.msg = statusCode.getMsg();
this.data = data;
}
// 只返回状态码
public ResultVo(StatusCode statusCode) {
this.code = statusCode.getCode();
this.msg = statusCode.getMsg();
this.data = null;
}
}
现在的返回肯定就不是 return data;这么简单了,而是需要 new ResultVo(data);
@PostMapping("/findByVo")
public ResultVo findByVo(@Validated ProductInfoVo vo) {
ProductInfo productInfo = new ProductInfo();
BeanUtils.copyProperties(vo, productInfo);
return new ResultVo(productInfoService.getOne(new QueryWrapper(productInfo)));
}
最后返回就会是上面带了状态码的数据了。
3. 参数校验
3.1. 机械化
假设有一个添加 ProductInfo 的接口,最常见的做法。
@Data
public class ProductInfoVo {
// 商品名称
private String productName;
// 商品价格
private BigDecimal productPrice;
// 上架状态
private Integer productStatus;
}
@PostMapping("/findByVo")
public ProductInfo findByVo(ProductInfoVo vo) {
if (StringUtils.isNotBlank(vo.getProductName())) {
throw new APIException("商品名称不能为空");
}
if (null != vo.getProductPrice() && vo.getProductPrice().compareTo(new BigDecimal(0)) < 0) {
throw new APIException("商品价格不能为负数");
}
...
ProductInfo productInfo = new ProductInfo();
BeanUtils.copyProperties(vo, productInfo);
return new ResultVo(productInfoService.getOne(new QueryWrapper(productInfo)));
}
若bean的参数很多,这 if 能把人写傻。
3.2. 自动化
@Validated 参数校验,Validated:检验、验证。
@Data
public class ProductInfoVo {
@NotNull(message = "商品名称不允许为空")
private String productName;
@Min(value = 0, message = "商品价格不允许为负数")
private BigDecimal productPrice;
private Integer productStatus;
}
下面是 @Valid 相关的注解,在实体类中不同的属性上添加不同的注解,就能实现不同数据的效验功能。
@PostMapping("/findByVo")
public ProductInfo findByVo(@Validated ProductInfoVo vo) {
ProductInfo productInfo = new ProductInfo();
BeanUtils.copyProperties(vo, productInfo);
return new ResultVo(productInfoService.getOne(new QueryWrapper(productInfo)));
}
确实可以实现参数检验,但是有缺陷,可以看一下示例:
//传参
productName : 泡脚
productPrice : -1
productStatus : 1
//返回值
{
"timestamp": "2020-04-19T03:06:37.268+0000",
"status": 400,
"error": "Bad Request",
"errors": [
{
"codes": [
"Min.productInfoVo.productPrice",
"Min.productPrice",
"Min.java.math.BigDecimal",
"Min"
],
"arguments": [
{
"codes": [
"productInfoVo.productPrice",
"productPrice"
],
"defaultMessage": "productPrice",
"code": "productPrice"
},
0
],
"defaultMessage": "商品价格不允许为负数",
"objectName": "productInfoVo",
"field": "productPrice",
"rejectedValue": -1,
"bindingFailure": false,
"code": "Min"
}
],
"message": "Validation failed for object\u003d\u0027productInfoVo\u0027. Error count: 1",
"trace": "org.springframework.validation.BindException: org.springframework.validation.BeanPropertyBindingResult: 1 errors\nField error in object \u0027productInfoVo\u0027 on field \u0027productPrice\u0027: rejected value [-1]; codes [Min.productInfoVo.productPrice,Min.productPrice,Min.java.math.BigDecimal,Min]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [productInfoVo.productPrice,productPrice]; arguments []; default message [productPrice],0]; default message [商品价格不允许为负数]\n\tat org.springframework.web.method.annotation.ModelAttributeMethodProcessor.resolveArgument(ModelAttributeMethodProcessor.java:164)\n\tat org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:121)\n\tat org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:167)\n\tat org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:134)\n\tat org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:105)\n\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:879)\n\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:793)\n\tat org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)\n\tat org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1040)\n\tat org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:943)\n\tat org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)\n\tat org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:909)\n\tat javax.servlet.http.HttpServlet.service(HttpServlet.java:660)\n\tat org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)\n\tat javax.servlet.http.HttpServlet.service(HttpServlet.java:741)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:231)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)\n\tat org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)\n\tat com.alibaba.druid.support.http.WebStatFilter.doFilter(WebStatFilter.java:124)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)\n\tat org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)\n\tat org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)\n\tat org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:193)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:166)\n\tat org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:202)\n\tat org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:96)\n\tat org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:541)\n\tat org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:139)\n\tat org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:92)\n\tat org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74)\n\tat org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:343)\n\tat org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:373)\n\tat org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:65)\n\tat org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:868)\n\tat org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1594)\n\tat org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49)\n\tat java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128)\n\tat java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628)\n\tat org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)\n\tat java.base/java.lang.Thread.run(Thread.java:830)\n",
"path": "/leilema/product/product-info/findByVo"
}
虽然成功校验了参数,也返回了异常,并且带上"商品价格不允许为负数"的信息。但是这是springboot自动拦截并处理的,没有按照我们自定义的resultVo进行返回响应。
3.3. 优化异常处理
从示例中可以看出使用@Validated注解时,必须要进行额外的异常处理。
首先我们先看看校验参数抛出了什么异常:Resolved [org.springframework.validation.BindException: org.springframework.validation.BeanPropertyBindingResult: 1 errors
我们看到代码抛出了 org.springframework.validation.BindException 的绑定异常,因此我们的思路就是 AOP 拦截所有 controller,然后异常的时候统一拦截起来,进行封装!完美!
完你个头啊完美,这么呆瓜的操作 springboot 不知道吗?spring mvc 当然知道拉,所以给我们提供了一个 @RestControllerAdvice 来增强所有 @RestController,然后使用 @ExceptionHandler 注解,就可以拦截到对应的异常。
这里我们就拦截 BindException.class 就好了。最后在返回之前,我们对异常信息进行包装一下,包装成 ResultVo,当然要跟上 ResultCode.VALIDATE_ERROR 的异常状态码。
这样前端妹妹看到 VALIDATE_ERROR 的状态码,就会调用数据校验异常的弹窗提示用户哪里没填好。
@RestControllerAdvice
public class ControllerExceptionAdvice {
@ExceptionHandler({BindException.class})
public ResultVo MethodArgumentNotValidExceptionHandler(BindException e) {
// 从异常对象中拿到ObjectError对象
ObjectError objectError = e.getBindingResult().getAllErrors().get(0);
return new ResultVo(ResultCode.VALIDATE_ERROR, objectError.getDefaultMessage());
}
}
效果如下:完美
{
"code": 1002,
"msg": "参数校验失败",
"data": "商品价格不允许为负数"
}
4. 统一响应
再回头看一下 controller 层的返回:
return new ResultVo(productInfoService.getOne(new QueryWrapper(productInfo)));
new ResultVo(data) 是不是有些多余,我能不能只返回一个实体?of course!那就是 AOP 拦截所有 Controller,再 @After 的时候统一帮你封装一下咯。
呸,怕是上一次脸打的不够疼,springboot 能不知道这么个操作吗?
当你想到一件事时第一件事一定是看有没有现成的轮子,重复劳动不可取, 要把精力用在创新上,以目前的开发水平,99%的需求都是有现成方案,而且还是大路货的那种!
@RestControllerAdvice(basePackages = {"com.shangjietech.jmlseventeen"})
public class ControllerResponseAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {
// response是ResultVo类型,或者注释了NotControllerResponseAdvice都不进行包装
//也就是说当你想法返回不是 200的类型时便不会帮你统一封装。
return !methodParameter.getParameterType().isAssignableFrom(ResultVo.class);
}
@Override
public Object beforeBodyWrite(Object data, MethodParameter returnType, MediaType mediaType, Class<? extends HttpMessageConverter<?>> aClass, ServerHttpRequest request, ServerHttpResponse response) {
// String类型不能直接包装
if (returnType.getGenericParameterType().equals(String.class)) {
ObjectMapper objectMapper = new ObjectMapper();
try {
// 将数据包装在ResultVo里后转换为json串进行返回
return objectMapper.writeValueAsString(new ResultVo(data));
} catch (JsonProcessingException e) {
throw new APIException(ResultCode.RESPONSE_PACK_ERROR, e.getMessage());
}
}
// 否则直接包装成ResultVo返回
return new ResultVo(data);
}
}
-
@RestControllerAdvice(basePackages = {“com.shangjietech.jmlseventeen”}) 自动扫描了所有指定包下的 controller,在 Response 时进行统一处理。
-
重写 supports 方法,也就是说,当返回类型已经是 ResultVo 了,那就不需要封装了,当不等与 ResultVo 时才进行调用 beforeBodyWrite 方法,跟过滤器的效果是一样的。
这一点很重要,当你不是参数错误,也不是手动异常,而且也不是success时,就需要以此来判断。 -
最后重写我们的封装方法 beforeBodyWrite,注意除了 String 的返回值有点特殊,无法直接封装成 json,我们需要进行特殊处理,其他的直接 new ResultVo(data); 就 ok 了。
打完收工,看看效果:
@PostMapping("/findByVo")
public ProductInfo findByVo(@Validated ProductInfoVo vo) {
ProductInfo productInfo = new ProductInfo();
BeanUtils.copyProperties(vo, productInfo);
return productInfoService.getOne(new QueryWrapper(productInfo));
}
此时就算我们返回的是 po,接收到的返回就是标准格式了。
{
"code": 1000,
"msg": "请求成功",
"data": {
"productId": 1,
"productName": "泡脚",
"productPrice": 100.00,
"productDescription": "中药泡脚加按摩",
"productStatus": 0,
...
}
4.1. 统一响应冲突时怎么办?
这个世界是包容性很强的,有点系统并不喜欢按行业规范,或者项目比较老。举个例子:我们项目中集成了一个健康检测的功能,也就是这货。
@RestController
public class HealthController {
@GetMapping("/health")
public String health() {
return "success";
}
}
他什么也不需要,只需要你返回一个success,除此之外都不认。
新增不进行封装注解:因为百分之 99 的请求还是需要包装的,只有个别不需要,写在包装的过滤器吧?又不是很好维护,那就加个注解好了。所有不需要包装的就加上这个注解。
其实我第一时间想到的是识别方法名,看了人家的方法顿时醒悟了,难以维护和扩展!
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface NotControllerResponseAdvice {
}
然后在我们的增强过滤方法上过滤包含这个注解的方法:
@RestControllerAdvice(basePackages = {"com.shangjietech.jmlseventeen"})
public class ControllerResponseAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) {
// response是ResultVo类型,或者注释了NotControllerResponseAdvice都不进行包装
return !(methodParameter.getParameterType().isAssignableFrom(ResultVo.class)
|| methodParameter.hasMethodAnnotation(NotControllerResponseAdvice.class));
}
...
最后就在不需要包装的方法上加上注解:
@RestController
public class HealthController {
@GetMapping("/health")
@NotControllerResponseAdvice
public String health() {
return "success";
}
}
5. 统一异常
每个系统都会有自己的业务异常,比如库存不能小于 0 子类的,这种异常并非程序异常,而是业务操作引发的异常,我们也需要进行规范的编排业务异常状态码,并且写一个专门处理的异常类,最后通过刚刚学习过的异常拦截统一进行处理,以及打日志.
- 异常状态码枚举
@Getter public enum AppCode{ APP_ERROR(2000, "业务异常"), PRICE_ERROR(2001, "价格异常"); private int code; private String msg; AppCode(int code, String msg) { this.code = code; this.msg = msg; } }
- 异常类,这里需要强调一下,code 代表 AppCode 的异常状态码,也就是 2000;msg 代表业务异常,这只是一个大类,一般前端会放到弹窗 title 上;最后 super(message); 这才是抛出的详细信息,在前端显示在弹窗体中,在 ResultVo 则保存在 data 中。
@Getter public class APIException extends RuntimeException { private int code; private String msg; // 手动设置异常 public APIException(StatusCode statusCode, String message) { // message用于用户设置抛出错误详情,例如:当前价格-5,小于0 super(message); // 状态码 this.code = statusCode.getCode(); // 状态码配套的msg this.msg = statusCode.getMsg(); } // 默认异常使用APP_ERROR状态码 public APIException(String message) { super(message); this.code = AppCode.APP_ERROR.getCode(); this.msg = AppCode.APP_ERROR.getMsg(); } }
- 最后进行统一异常的拦截,这样无论在 service 层还是 controller 层,开发人员只管抛出 API 异常,不需要关系怎么返回给前端,更不需要关心日志的打印。
@RestControllerAdvice public class ControllerExceptionAdvice { @ExceptionHandler({BindException.class}) public ResultVo MethodArgumentNotValidExceptionHandler(BindException e) { // 从异常对象中拿到ObjectError对象 ObjectError objectError = e.getBindingResult().getAllErrors().get(0); return new ResultVo(ResultCode.VALIDATE_ERROR, objectError.getDefaultMessage()); } @ExceptionHandler(APIException.class) public ResultVo APIExceptionHandler(APIException e) { // log.error(e.getMessage(), e); 由于还没集成日志框架,暂且放着,写上TODO return new ResultVo(e.getCode(), e.getMsg(), e.getMessage()); } }
- 最后使用,我们的代码只需要这么写。
if (null == orderMaster) { throw new APIException(AppCode.ORDER_NOT_EXIST, "订单号不存在:" + orderId); }
就会自动抛出 AppCode.ORDER_NOT_EXIST 状态码的响应,并且带上异常详细信息订单号不存在:xxxx。{ "code": 2003, "msg": "订单不存在", "data": "订单号不存在:1998" }
6. 以上总结
优化异常处理:可以针对40x进行返回;
统一响应:可以针对200和500的返回值
至于有特殊意义的返回值那就需要手写了,毕竟如果加判断值则需要多传参,总体来看更加繁琐。当然也可以加注解。
至此完美,我宣布,@RestControllerAdvice注解就是controller层的优雅之王。
7. RESTful
REST不是一个单词,是Representational State Transfer 的缩写。直译过来就是表述性状态转移。从提出REST的论文中发现,没有明说但是表达的意思是其实它还有个主语 Resource 。
所以全称是资源的表述性状态转移,用 URL 定位资源,用 HTTP 动词来描述所要做的操作。,Restful 自然就是adj。
HTTP的提供了很多动词:GET、PUT、POST、DELETE…这些动词都是有含义的。
进行http通信时根据这些规范我们都能得知这次交互的一些动作,所以 RESTful 风格正确的使用姿势如下:
比如获取一个 user。
错误姿势:GET /getUserById?userId=1。
正确姿势:GET /users/1。
再比如新增 user。
错误姿势:POST /addUser (省略body)。
正确姿势:POST /users (省略body)。
可以看到 HTTP 的动词其实就能指代你要对资源做的操作,所以不需要在 URL 上做一些东西,就把 URL 表明的东西看作一个资源即可。
这里注意要用对 HTTP 动词,比如一个获取资源的请求用 PUT,用了也能获取资源但是这不合适。
其实更深一步的理解是 HTTP 是一个协议。
协议其实就是约定好的一个东西,协议就规定 GET 是获取资源,那你非得在 URL 上再重复一遍或者所有请求不论增删改都用 GET 这个动作,这其实就是没有完全遵循这个协议。
可以说只是把 HTTP 当成一个传输管道,而不是约定好的协议。
这其实是对 HTTP 更深一层的认识,我认为也是 RESTful 被推出的原因。当然理想很丰满,现实很骨感,还是有很多人就 getUserById。不过我个人觉得问题不大,公司统一就行。
Restful 一种软件架构风格、设计风格,而不是标准,只是提供了一组设计原则和约束条件。它主要用于客户端和服务器交互类的软件。基于这个风格设计的软件可以更简洁,更有层次,更易于实现缓存等机制。
RES描述了一个架构样式的网络系统,比如 web 应用程序。在目前主流的三种Web服务交互方案中,REST相比于SOAP(Simple Object Access protocol,简单对象访问协议)以及XML-RPC更加简单明了,无论是对URL的处理还是对Payload的编码,REST都倾向于用更加简单轻量的方法设计和实现。值得注意的是REST并没有一个明确的标准,而更像是一种设计的风格。
同时也是目前主流的http通信规范。