Spring Boot优雅的写出Controller代码
前言
一个完整的后端请求由4个部分组成:
- 接口地址(即url地址)
- 请求方式(常见的是get和post,restful风格中还包含put和delete)
- 请求数据(request, 有head和body)
- 响应数据(response)
本文主要记录解决下述问题:
- 接收到请求后,如何优雅的校验参数
- 返回响应数据时,如何集中处理
- 收到请求后,处理业务逻辑时出现了异常如何进行处理
Controller层接收 参数
常见的请求就分为get和post两种:
1 @RestController 2 @RequestMapping("/product/product-info") 3 public class ProductInfoController { 4 5 @Autowired 6 ProductInfoService productInfoService; 7 8 @GetMapping("/findById") 9 public ProductInfoQueryVo findById(Integer id) { 10 ... 11 } 12 13 @PostMapping("/page") 14 public IPage findPage(Page page, ProductInfoQueryVo vo) { 15 ... 16 } 17 }
@RestController:@RestController = @Controller + ResponseBody。加上这个注解,spring boot就会把这个类当成controller进行处理,然后把所有返回的参数放到ResponseBody中
@RequestMapping:请求的前缀,即所有该Controller下的请求都需要加上该注解中的前缀
@GetMapping:标志这是一个get请求,并且需要根据注解中指定的url地址才能访问到
@PostMapping:标志这是一个post请求,同样需要根据其指定的url地址才能访问
参数:至于参数部分,只需要写上ProductInfoQueryVo,前端过来的json请求便会通过映射赋值到对应的对象中,例如在请求中这么写,productId就会被自动映射到vo对应的属性中
size : 1 current : 1 productId : 1 productName : 泡脚
统一状态码
返回格式
通常需要将后端返回的数据进行包装,增加状态码,状态信息,以便前端接收到数据以后可以根据不同的状态码、响应数据状态、是否成功、是否异常来进行不同的显示。
封装前,返回的数据可能是这样的:
{ "productId": 1, "productName": "泡脚", "productPrice": 100.00, "productDescription": "中药泡脚加按摩", "productStatus": 0, }
封装后的数据是这样的:
{ "code": 1000, "msg": "请求成功", "data": { "productId": 1, "productName": "泡脚", "productPrice": 100.00, "productDescription": "中药泡脚加按摩", "productStatus": 0, } }
封装ResultVo
通常这些状态码肯定都是预先编好的,下面介绍如何编写:
1.定义一个状态码的接口,所有状态码都需要实现它,有了标准才好做事
1 public interface StatusCode { 2 public int getCode(); 3 public String getMsg(); 4 }
2.根据与前端约定的状态码编写枚举类
1 @Getter 2 public enum ResultCode implements StatusCode{ 3 SUCCESS(1000, "请求成功"), 4 FAILED(1001, "请求失败"), 5 VALIDATE_ERROR(1002, "参数校验失败"), 6 RESPONSE_PACK_ERROR(1003, "response返回包装失败"); 7 8 private int code; 9 private String msg; 10 11 ResultCode(int code, String msg) { 12 this.code = code; 13 this.msg = msg; 14 } 15 }
3.写ResultVo包装类
@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; } }
4.使用,此时在Controller层中,返回的结果就不是return data; 而是 return new ResultVo(data);
1 @PostMapping("/findByVo") 2 public ResultVo findByVo(@Validated ProductInfoVo vo) { 3 ProductInfo productInfo = new ProductInfo(); 4 BeanUtils.copyProperties(vo, productInfo); 5 return new ResultVo(productInfoService.getOne(new QueryWrapper(productInfo))); 6 }
最终返回的结果就是带状态码的数据。
统一校验
原始做法
假如有一个添加ProductInfo的接口,在没有统一校验时,我们需要这么做:
1 @Data 2 public class ProductInfoVo { 3 // 商品名称 4 private String productName; 5 // 商品价格 6 private BigDecimal productPrice; 7 // 上架状态 8 private Integer productStatus; 9 }
1 @PostMapping("/findByVo") 2 public ProductInfo findByVo(ProductInfoVo vo) { 3 if (StringUtils.isNotBlank(vo.getProductName())) { 4 throw new APIException("商品名称不能为空"); 5 } 6 if (null != vo.getProductPrice() && vo.getProductPrice().compareTo(new BigDecimal(0)) < 0) { 7 throw new APIException("商品价格不能为负数"); 8 } 9 ... 10 11 ProductInfo productInfo = new ProductInfo(); 12 BeanUtils.copyProperties(vo, productInfo); 13 return new ResultVo(productInfoService.getOne(new QueryWrapper(productInfo))); 14 }
缺陷:存在大量的if语句
@Validated参数校验
通过@Validated注解,我们只需要在vo上加一点点注解,就可以完成校验功能。
1 @Data 2 public class ProductInfoVo { 3 @NotNull(message = "商品名称不允许为空") 4 private String productName; 5 6 @Min(value = 0, message = "商品价格不允许为负数") 7 private BigDecimal productPrice; 8 9 private Integer productStatus; 10 }
1 @PostMapping("/findByVo") 2 public ProductInfo findByVo(@Validated ProductInfoVo vo) { 3 ProductInfo productInfo = new ProductInfo(); 4 BeanUtils.copyProperties(vo, productInfo); 5 return new ResultVo(productInfoService.getOne(new QueryWrapper(productInfo))); 6 }
运行,并尝试输入错误的参数,观察结果。
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" }
通过观察结果发现,虽然成功校验了参数,也返回了异常,并且带上了“商品价格不允许为负数”的信息。但没有对返回结果进行封装,因此每次出现异常时,需要自动把状态码写好。
优化异常处理
针对上面的问题,对异常处理进行优化。首先查看校验参数抛出的异常:
Resolved [org.springframework.validation.BindException: org.springframework.validation.BeanPropertyBindingResult: 1 errors
可以看见代码抛出了org.springframework.validation.BindException的绑定异常,因此思路就是通过AOP拦截所有controller,在异常时统一拦截起来进行封装。
spring boot提供了解决方案:通过使用@RestControllerAdvice注解来增强所有的@RestController,然后使用@ExceptionHandler注解,就可以拦截到对应的异常。
这里对BindException。class进行拦截,最后在返回之前再对异常信息及逆行包装,封装成ResultVo。
1 @RestControllerAdvice 2 public class ControllerExceptionAdvice { 3 4 @ExceptionHandler({BindException.class}) 5 public ResultVo MethodArgumentNotValidExceptionHandler(BindException e) { 6 // 从异常对象中拿到ObjectError对象 7 ObjectError objectError = e.getBindingResult().getAllErrors().get(0); 8 return new ResultVo(ResultCode.VALIDATE_ERROR, objectError.getDefaultMessage()); 9 } 10 }
返回结果如下:
{ "code": 1002, "msg": "参数校验失败", "data": "商品价格不允许为负数" }
统一响应
统一包装响应
通过AOP拦截所有Controller,再@After时统一使用ResulVo进行封装,优化Controller中每个方法的返回结果都需要使用new ResultVo(data);进行封装的问题
在spring boot中通过如下配置即可实现
1 @RestControllerAdvice(basePackages = {"com.bugpool.leilema"}) 2 public class ControllerResponseAdvice implements ResponseBodyAdvice<Object> { 3 @Override 4 public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) { 5 // response是ResultVo类型,或者注释了NotControllerResponseAdvice都不进行包装 6 return !methodParameter.getParameterType().isAssignableFrom(ResultVo.class); 7 } 8 9 @Override 10 public Object beforeBodyWrite(Object data, MethodParameter returnType, MediaType mediaType, Class<? extends HttpMessageConverter<?>> aClass, ServerHttpRequest request, ServerHttpResponse response) { 11 // String类型不能直接包装 12 if (returnType.getGenericParameterType().equals(String.class)) { 13 ObjectMapper objectMapper = new ObjectMapper(); 14 try { 15 // 将数据包装在ResultVo里后转换为json串进行返回 16 return objectMapper.writeValueAsString(new ResultVo(data)); 17 } catch (JsonProcessingException e) { 18 throw new APIException(ResultCode.RESPONSE_PACK_ERROR, e.getMessage()); 19 } 20 } 21 // 否则直接包装成ResultVo返回 22 return new ResultVo(data); 23 } 24 }
1.@RestControllerAdvice(basePackages = {"com.bugpool.leilema"}) 自动扫描了所有指定包下的 controller,在 Response 时进行统一处理。
2.重写 supports 方法,也就是说,当返回类型已经是 ResultVo 了,那就不需要封装了,当不等与 ResultVo 时才进行调用 beforeBodyWrite 方法,跟过滤器的效果是一样的。
3.最后重写我们的封装方法 beforeBodyWrite,注意除了 String 的返回值有点特殊,无法直接封装成 json,我们需要进行特殊处理,其他的直接 new ResultVo(data); 就 ok 了。
原来的Controller层的方法即可优化如下:
1 @PostMapping("/findByVo") 2 public ProductInfo findByVo(@Validated ProductInfoVo vo) { 3 ProductInfo productInfo = new ProductInfo(); 4 BeanUtils.copyProperties(vo, productInfo); 5 return productInfoService.getOne(new QueryWrapper(productInfo)); 6 }
此时就算返回的是po,接收到的返回也是标准格式
{ "code": 1000, "msg": "请求成功", "data": { "productId": 1, "productName": "泡脚", "productPrice": 100.00, "productDescription": "中药泡脚加按摩", "productStatus": 0, ... } }
NOT 统一响应
不开启统一响应原因:项目中若存在健康检测等特殊功能,需要以自定义规则返回结果
1 @RestController 2 public class HealthController { 3 @GetMapping("/health") 4 public String health() { 5 return "success"; 6 } 7 }
新增不进行封装注解:因为百分之 99 的请求还是需要包装的,只有个别不需要,写在包装的过滤器不是很好维护,因此只需要在所有不需要包装上单独加这个注解即可。
@Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface NotControllerResponseAdvice { }
在增强的过滤方法上包含这个注解的方法
1 @RestControllerAdvice(basePackages = {"com.bugpool.leilema"}) 2 public class ControllerResponseAdvice implements ResponseBodyAdvice<Object> { 3 @Override 4 public boolean supports(MethodParameter methodParameter, Class<? extends HttpMessageConverter<?>> aClass) { 5 // response是ResultVo类型,或者注释了NotControllerResponseAdvice都不进行包装 6 return !(methodParameter.getParameterType().isAssignableFrom(ResultVo.class) 7 || methodParameter.hasMethodAnnotation(NotControllerResponseAdvice.class)); 8 } 9 ...
此时在调用对应包含此注解方法,返回的结果将不会再自动封装
success
统一异常
每个系统都会有自己的业务异常,比如库存不能小于 0 子类的,这种异常并非程序异常,而是业务操作引发的异常,我们也需要进行规范的编排业务异常状态码,并且写一个专门处理的异常类,最后通过刚刚学习过的异常拦截统一进行处理,以及打日志。
1.异常状态码枚举
1 @Getter 2 public enum AppCode implements StatusCode { 3 4 APP_ERROR(2000, "业务异常"), 5 PRICE_ERROR(2001, "价格异常"); 6 7 private int code; 8 private String msg; 9 10 AppCode(int code, String msg) { 11 this.code = code; 12 this.msg = msg; 13 } 14 }
2.异常类,这里需要强调一下,code 代表 AppCode 的异常状态码,也就是 2000;msg 代表业务异常,这只是一个大类,一般前端会放到弹窗 title 上;最后 super(message); 这才是抛出的详细信息,在前端显示在弹窗体中,在 ResultVo 则保存在 data 中
1 @Getter 2 public class APIException extends RuntimeException { 3 private int code; 4 private String msg; 5 6 // 手动设置异常 7 public APIException(StatusCode statusCode, String message) { 8 // message用于用户设置抛出错误详情,例如:当前价格-5,小于0 9 super(message); 10 // 状态码 11 this.code = statusCode.getCode(); 12 // 状态码配套的msg 13 this.msg = statusCode.getMsg(); 14 } 15 16 // 默认异常使用APP_ERROR状态码 17 public APIException(String message) { 18 super(message); 19 this.code = AppCode.APP_ERROR.getCode(); 20 this.msg = AppCode.APP_ERROR.getMsg(); 21 } 22 23 }
3.统一异常拦截
1 @RestControllerAdvice 2 public class ControllerExceptionAdvice { 3 4 @ExceptionHandler({BindException.class}) 5 public ResultVo MethodArgumentNotValidExceptionHandler(BindException e) { 6 // 从异常对象中拿到ObjectError对象 7 ObjectError objectError = e.getBindingResult().getAllErrors().get(0); 8 return new ResultVo(ResultCode.VALIDATE_ERROR, objectError.getDefaultMessage()); 9 } 10 11 @ExceptionHandler(APIException.class) 12 public ResultVo APIExceptionHandler(APIException e) { 13 // log.error(e.getMessage(), e); 由于还没集成日志框架,暂且放着,写上TODO 14 return new ResultVo(e.getCode(), e.getMsg(), e.getMessage()); 15 } 16 }
4.使用
if (null == orderMaster) { throw new APIException(AppCode.ORDER_NOT_EXIST, "订单号不存在:" + orderId); }
{ "code": 2003, "msg": "订单不存在", "data": "订单号不存在:1998" }
就会自动抛出 AppCode.ORDER_NOT_EXIST 状态码的响应,并且带上异常详细信息订单号不存在:xxxx。

浙公网安备 33010602011771号