Controller层代码这么写,简洁又优雅

一个优秀的Controller层逻辑

说到 Controller,相信大家都不陌生,它可以很方便地对外提供数据接口。它的定位,我认为是「不可或缺的配角」,说它不可或缺是因为无论是传统的三层架构还是现在的COLA架构,Controller 层依旧有一席之地,说明他的必要性;说它是配角是因为 Controller 层的代码一般是不负责具体的逻辑业务逻辑实现,但是它负责接收和响应请求

从现状看问题

Controller 主要的工作有以下几项

  • 接收请求并解析参数
  • 调用 Service 执行具体的业务代码(可能包含参数校验)
  • 捕获业务逻辑异常做出反馈
  • 业务逻辑执行成功做出响应

//DTO@Datapublic class TestDTO { private Integer num; private String type;}//Service@Servicepublic class TestService { public Double service(TestDTO testDTO) throws Exception { if (testDTO.getNum() 1) { result = result * num; num -= 1; } return result; } throw new Exception(“未识别的算法”); }}//Controller@RestControllerpublic class TestController { private TestService testService; @PostMapping(“/test”) public Double test(@RequestBody TestDTO testDTO) { try { Double result = this.testService.service(testDTO); return result; } catch (Exception e) { throw new RuntimeException(e); } } @Autowired public DTOid setTestService(TestService testService) { this.testService = testService; }}

如果真的按照上面所列的工作项来开发 Controller 代码会有几个问题

  • 参数校验过多地耦合了业务代码,违背单一职责原则
  • 可能在多个业务中都抛出同一个异常,导致代码重复
  • 各种异常反馈和成功响应格式不统一,接口对接不友好

改造 Controller 层逻辑

统一返回结构

统一返回值类型无论项目前后端是否分离都是非常必要的,方便对接接口的开发人员更加清晰地知道这个接口的调用是否成功(不能仅仅简单地看返回值是否为 null 就判断成功与否,因为有些接口的设计就是如此),使用一个状态码、状态信息就能清楚地了解接口调用情况

//定义返回数据结构public interface IResult { Integer getCode(); String getMessage();}//常用结果的枚举public enum ResultEnum implements IResult { SUCCESS(2001, “接口调用成功”), VALIDATE_FAILED(2002, “参数校验失败”), COMMON_FAILED(2003, “接口调用失败”), FORBIDDEN(2004, “没有权限访问资源”); private Integer code; private String message; //省略get、set方法和构造方法}//统一返回数据结构@Data@NoArgsConstructor@AllArgsConstructorpublic class Result { private Integer code; private String message; private T data; public static Result success(T data) { return new Result(ResultEnum.SUCCESS.getCode(), ResultEnum.SUCCESS.getMessage(), data); } public static Result success(String message, T data) { return new Result(ResultEnum.SUCCESS.getCode(), message, data); } public static Result failed() { return new Result(ResultEnum.COMMON_FAILED.getCode(), ResultEnum.COMMON_FAILED.getMessage(), null); } public static Result failed(String message) { return new Result(ResultEnum.COMMON_FAILED.getCode(), message, null); } public static Result failed(IResult errorResult) { return new Result(errorResult.getCode(), errorResult.getMessage(), null); } public static Result instance(Integer code, String message, T data) { Result result = new Result(); result.setCode(code); result.setMessage(message); result.setData(data); return result; }}

统一返回结构后,在 Controller 中就可以使用了,但是每一个 Controller 都写这么一段最终封装的逻辑,这些都是很重复的工作,所以还要继续想办法进一步处理统一返回结构

统一包装处理

Spring 中提供了一个类 ResponseBodyAdvice ,能帮助我们实现上述需求

ResponseBodyAdvice 是对 Controller 返回的内容在 HttpMessageConverter 进行类型转换之前拦截,进行相应的处理操作后,再将结果返回给客户端。那这样就可以把统一包装的工作放到这个类里面。

public interface ResponseBodyAdvice { boolean supports(MethodParameter returnType, Class converterType); @Nullable T beforeBodyWrite(@Nullable T body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response);}

  • supports:判断是否要交给 beforeBodyWrite 方法执行,ture:需要;false:不需要
  • beforeBodyWrite:对 response 进行具体的处理

// 如果引入了swagger或knife4j的文档生成组件,这里需要仅扫描自己项目的包,否则文档无法正常生成@RestControllerAdvice(basePackages = “com.example.demo”)public class ResponseAdvice implements ResponseBodyAdvice { @Override public boolean supports(MethodParameter returnType, Class converterType) { // 如果不需要进行封装的,可以添加一些校验手段,比如添加标记排除的注解 return true; } @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { // 提供一定的灵活度,如果body已经被包装了,就不进行包装 if (body instanceof Result) { return body; } return Result.success(body); }}

经过这样改造,既能实现对 Controller 返回的数据进行统一包装,又不需要对原有代码进行大量的改动

参数校验

Java API 的规范 JSR303 定义了校验的标准 validation-api ,其中一个比较出名的实现是 hibernate validation ,spring validation 是对其的二次封装,常用于 SpringMVC 的参数自动校验,参数校验的代码就不需要再与业务逻辑代码进行耦合了

@PathVariable 和 @RequestParam 参数校验

Get 请求的参数接收一般依赖这两个注解,但是处于 url 有长度限制和代码的可维护性,超过 5 个参数尽量用实体来传参 对 @PathVariable 和 @RequestParam 参数进行校验需要在入参声明约束的注解

如果校验失败,会抛出 MethodArgumentNotValidException 异常

@RestController(value = “prettyTestController”)@RequestMapping(“/pretty”)@Validatedpublic class TestController { private TestService testService; @GetMapping(“/{num}”) public Integer detail(@PathVariable(“num”) @Min(1) @Max(20) Integer num) { return num * num; } @GetMapping(“/getByEmail”) public TestDTO getByAccount(@RequestParam @NotBlank @Email String email) { TestDTO testDTO = new TestDTO(); testDTO.setEmail(email); return testDTO; } @Autowired public void setTestService(TestService prettyTestService) { this.testService = prettyTestService; }}

校验原理

在 SpringMVC 中,有一个类是 RequestResponseBodyMethodProcessor ,这个类有两个作用(实际上可以从名字上得到一点启发)

  • 用于解析 @RequestBody 标注的参数
  • 处理 @ResponseBody 标注方法的返回值

解析 @RequestBoyd 标注参数的方法是 resolveArgument

public class RequestResponseBodyMethodProcessor extends AbstractMessageConverterMethodProcessor { /** * Throws MethodArgumentNotValidException if validation fails. * @throws HttpMessageNotReadableException if {@link RequestBody#required()} * is {@code true} and there is no body content or if there is no suitable * converter to read the content with. */ @Override public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer, NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception { parameter = parameter.nestedIfOptional(); //把请求数据封装成标注的DTO对象 Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType()); String name = Conventions.getVariableNameForParameter(parameter); if (binderFactory != null) { WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name); if (arg != null) { //执行数据校验 validateIfApplicable(binder, parameter); //如果校验不通过,就抛出MethodArgumentNotValidException异常 //如果我们不自己捕获,那么最终会由DefaultHandlerExceptionResolver捕获处理 if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) { throw new MethodArgumentNotValidException(parameter, binder.getBindingResult()); } } if (mavContainer != null) { mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult()); } } return adaptArgumentIfNecessary(arg, parameter); }}public abstract class AbstractMessageConverterMethodArgumentResolver implements HandlerMethodArgumentResolver { /** * Validate the binding target if applicable. *

The default implementation checks for {@code @javax.validation.Valid}, * Spring’s {@link org.springframework.validation.annotation.Validated}, * and custom annotations whose name starts with “Valid”. * @param binder the DataBinder to be used * @param parameter the method parameter descriptor * @since 4.1.5 * @see #isBindExceptionRequired */ protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) { //获取参数上的所有注解 Annotation[] annotations = parameter.getParameterAnnotations(); for (Annotation ann : annotations) { //如果注解中包含了@Valid、@Validated或者是名字以Valid开头的注解就进行参数校验 Object[] validationHints = ValidationAnnotationUtils.determineValidationHints(ann); if (validationHints != null) { //实际校验逻辑,最终会调用Hibernate Validator执行真正的校验 //所以Spring Validation是对Hibernate Validation的二次封装 binder.validate(validationHints); break; } } }}

@RequestBody 参数校验

Post、Put 请求的参数推荐使用 @RequestBody 请求体参数

对 @RequestBody 参数进行校验需要在 DTO 对象中加入校验条件后,再搭配 @Validated 即可完成自动校验 如果校验失败,会抛出 ConstraintViolationException 异常

//DTO@Datapublic class TestDTO { @NotBlank private String userName; @NotBlank @Length(min = 6, max = 20) private String password; @NotNull @Email private String email;}//Controller@RestController(value = “prettyTestController”)@RequestMapping(“/pretty”)public class TestController { private TestService testService; @PostMapping(“/test-validation”) public void testValidation(@RequestBody @Validated TestDTO testDTO) { this.testService.save(testDTO); } @Autowired public void setTestService(TestService testService) { this.testService = testService; }}

校验原理

声明约束的方式,注解加到了参数上面,可以比较容易猜测到是使用了 AOP 对方法进行增强

而实际上 Spring 也是通过 MethodValidationPostProcessor 动态注册 AOP 切面,然后使用 MethodValidationInterceptor 对切点方法进行织入增强

public class MethodValidationPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor implements InitializingBean { //指定了创建切面的Bean的注解 private Class validatedAnnotationType = Validated.class; @Override public void afterPropertiesSet() { //为所有@Validated标注的Bean创建切面 Pointcut pointcut = new AnnotationMatchingPointcut(this.validatedAnnotationType, true); //创建Advisor进行增强 this.advisor = new DefaultPointcutAdvisor(pointcut, createMethodValidationAdvice(this.validator)); } //创建Advice,本质就是一个方法拦截器 protected Advice createMethodValidationAdvice(@Nullable Validator validator) { return (validator != null ? new MethodValidationInterceptor(validator) : new MethodValidationInterceptor()); }}public class MethodValidationInterceptor implements MethodInterceptor { @Override public Object invoke(MethodInvocation invocation) throws Throwable { //无需增强的方法,直接跳过 if (isFactoryBeanMetadataMethod(invocation.getMethod())) { return invocation.proceed(); } Class[] groups = determineValidationGroups(invocation); ExecutableValidator execVal = this.validator.forExecutables(); Method methodToValidate = invocation.getMethod(); Set result; try { //方法入参校验,最终还是委托给Hibernate Validator来校验 //所以Spring Validation是对Hibernate Validation的二次封装 result = execVal.validateParameters( invocation.getThis(), methodToValidate, invocation.getArguments(), groups); } catch (IllegalArgumentException ex) { … } //校验不通过抛出ConstraintViolationException异常 if (!result.isEmpty()) { throw new ConstraintViolationException(result); } //Controller方法调用 Object returnValue = invocation.proceed(); //下面是对返回值做校验,流程和上面大概一样 result = execVal.validateReturnValue(invocation.getThis(), methodToValidate, returnValue, groups); if (!result.isEmpty()) { throw new ConstraintViolationException(result); } return returnValue; }}

自定义校验规则

有些时候 JSR303 标准中提供的校验规则不满足复杂的业务需求,也可以自定义校验规则

自定义校验规则需要做两件事情

  • 自定义注解类,定义错误信息和一些其他需要的内容
  • 注解校验器,定义判定规则

//自定义注解类@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER})@Retention(RetentionPolicy.RUNTIME)@Documented@Constraint(validatedBy = MobileValidator.class)public @interface Mobile { /** * 是否允许为空 */ boolean required() default true; /** * 校验不通过返回的提示信息 */ String message() default “不是一个手机号码格式”; /** * Constraint要求的属性,用于分组校验和扩展,留空就好 */ Class[] groups() default {}; Class[] payload() default {};}//注解校验器public class MobileValidator implements ConstraintValidator { private boolean required = false; private final Pattern pattern = Pattern.compile(“^1[34578][0-9]{9}#34;); // 验证手机号 /** * 在验证开始前调用注解里的方法,从而获取到一些注解里的参数 * * @param constraintAnnotation annotation instance for a given constraint declaration */ @Override public void initialize(Mobile constraintAnnotation) { this.required = constraintAnnotation.required(); } /** * 判断参数是否合法 * * @param value object to validate * @param context context in which the constraint is evaluated */ @Override public boolean isValid(CharSequence value, ConstraintValidatorContext context) { if (this.required) { // 验证 return isMobile(value); } if (StringUtils.hasText(value)) { // 验证 return isMobile(value); } return true; } private boolean isMobile(final CharSequence str) { Matcher m = pattern.matcher(str); return m.matches(); }}

自动校验参数真的是一项非常必要、非常有意义的工作。JSR303 提供了丰富的参数校验规则,再加上复杂业务的自定义校验规则,完全把参数校验和业务逻辑解耦开,代码更加简洁,符合单一职责原则。

更多关于 Spring 参数校验请参考:

https://juejin.cn/post/6856541106626363399

自定义异常与统一拦截异常

原来的代码中可以看到有几个问题

  • 抛出的异常不够具体,只是简单地把错误信息放到了 Exception 中
  • 抛出异常后,Controller 不能具体地根据异常做出反馈
  • 虽然做了参数自动校验,但是异常返回结构和正常返回结构不一致

自定义异常是为了后面统一拦截异常时,对业务中的异常有更加细颗粒度的区分,拦截时针对不同的异常作出不同的响应

而统一拦截异常的目的一个是为了可以与前面定义下来的统一包装返回结构能对应上,另一个是我们希望无论系统发生什么异常,Http 的状态码都要是 200 ,尽可能由业务来区分系统的异常

//自定义异常public class ForbiddenException extends RuntimeException { public ForbiddenException(String message) { super(message); }}//自定义异常public class BusinessException extends RuntimeException { public BusinessException(String message) { super(message); }}//统一拦截异常@RestControllerAdvice(basePackages = “com.example.demo”)public class ExceptionAdvice { /** * 捕获 {@code BusinessException} 异常 */ @ExceptionHandler({BusinessException.class}) public Result handleBusinessException(BusinessException ex) { return Result.failed(ex.getMessage()); } /** * 捕获 {@code ForbiddenException} 异常 */ @ExceptionHandler({ForbiddenException.class}) public Result handleForbiddenException(ForbiddenException ex) { return Result.failed(ResultEnum.FORBIDDEN); } /** * {@code @RequestBody} 参数校验不通过时抛出的异常处理 */ @ExceptionHandler({MethodArgumentNotValidException.class}) public Result handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { BindingResult bindingResult = ex.getBindingResult(); StringBuilder sb = new StringBuilder(“校验失败:”); for (FieldError fieldError : bindingResult.getFieldErrors()) { sb.append(fieldError.getField()).append(“:”).append(fieldError.getDefaultMessage()).append(“, “); } String msg = sb.toString(); if (StringUtils.hasText(msg)) { return Result.failed(ResultEnum.VALIDATE_FAILED.getCode(), msg); } return Result.failed(ResultEnum.VALIDATE_FAILED); } /** * {@code @PathVariable} 和 {@code @RequestParam} 参数校验不通过时抛出的异常处理 */ @ExceptionHandler({ConstraintViolationException.class}) public Result handleConstraintViolationException(ConstraintViolationException ex) { if (StringUtils.hasText(ex.getMessage())) { return Result.failed(ResultEnum.VALIDATE_FAILED.getCode(), ex.getMessage()); } return Result.failed(ResultEnum.VALIDATE_FAILED); } /** * 顶级异常捕获并统一处理,当其他异常无法处理时候选择使用 */ @ExceptionHandler({Exception.class}) public Result handle(Exception ex) { return Result.failed(ex.getMessage()); }}

总结

做好了这一切改动后,可以发现 Controller 的代码变得非常简洁,可以很清楚地知道每一个参数、每一个 DTO 的校验规则,可以很明确地看到每一个 Controller 方法返回的是什么数据,也可以方便每一个异常应该如何进行反馈

这一套操作下来后,我们能更加专注于业务逻辑的开发,代码简洁、功能完善,何乐而不为呢?

原文链接:https://mp.weixin.qq.com/s/KA1gwlup0uybaJR0PbGFyw

郑重声明:本文内容及图片均整理自互联网,不代表本站立场,版权归原作者所有,如有侵权请联系管理员(admin#wlmqw.com)删除。
(0)
用户投稿
上一篇 2022年8月4日
下一篇 2022年8月4日

相关推荐

  • 谈谈新手站长应该如何学习网站推广(怎么学网络推广)

    怎么学网络推广(谈谈新手站长应该如何学习网站推广) 对于大部分站长使用的推广方法来说,其实都是被别人用烂的,当自己用的时候,基本上效果已经寥寥无几的,所以,很多站长在看到别人使用什…

    2022年10月22日
  • Spring Security:用户和Spring应用之间的安全屏障

    一、前言 1.历史 Spring Security最早叫Acegi Security,这个名称并不是说它和Spring就没有关系,它依然是为Spring框架提供安全支持的。Aceg…

    2022年6月22日
  • ★“滴滴业务”还能维持多久,@“滴滴司机”怎么转型#?

    就拿我所在的这个十八线小城市来说,跑滴滴快车说白了基本就和为人民服务差不多!地面小,10单里面有8单都是4.5.6块钱的小单子,起步价除了抽成到手4.3元,还经常跑将近3公里才能接…

    2022年7月24日
  • 女人一旦上了年纪,搭配“减法”这3点要记住,优雅不失腔调

    许多女性上了年纪之后,很容易把自己打扮得廉价又土气,很大一部分原因是因为选择了那些根本不适合自身的单品,一些单品的设计繁杂、花纹颜色多样,在成熟女性身上反而不能彰显气质,只能穿出老…

    2022年7月5日
  • 互联网医疗之医药电商行业研究报告

    (报告出品方/作者:财通证券,张文录) 1. 互联网医疗业态丰富多样 互联网医疗行业包括在线零售药房、在线问诊、消费医疗健康服务等 TO C 端业 务模式,以及数字化基础设施、线上…

    2022年8月7日
  • 油漆怎么洗掉「手上衣服上油漆去除小窍门」

    我们在进行房屋装修时,经常会用到油漆。因此,人身和物品上经常会沾染到油漆,油漆内含有甲醛,对人体有害。因此,由于油漆的品种多,特性不一样,如何清洗这些油漆是我们必须考虑的。下面介绍…

    2022年5月7日
  • 《部落幸存者》幸福值怎么提升?幸福值提升方法介绍

    部落幸存者中幸福值是一项非常重要的属性,可能许多玩家还不清楚部落幸存者幸福值怎么提升?这里给大家带来了部落幸存者幸福值提升方法介绍,一起来看下吧。希望能给大家带来帮助。 幸福值提升…

    2022年10月27日
  • 超临界物质,或许惊人地简单

    我们对物质在相当低的温度和压强下的行为已经有了相当深入的理解,但物质在高温高压下的情况却很模糊。 二氧化碳的压强-温度相图。(图 Ben Finney, Mark Jacobs, …

    2022年8月18日
  • 买螃蟹时,挑大挑小都不对,老渔民:牢记3点,螃蟹个个膏多肉肥

    导读:前几天去江苏连云港游玩了几天,在这几天的时间里,我不仅吃到了各种各样的海鲜,而且还学会了很多挑选海鲜的方法,比如很多人都爱吃的梭子蟹,不少人去海鲜市场花了大几十元买的大梭子蟹…

    2022年9月18日
  • 84消毒液的正确配比及方法注意事项!

    84消毒液该如何正确的进行配比?它的比例又是多少呢?大家一定要仔细根据文章内容的详细说明来进行兑比。 84消毒液是很多家庭必备的物品,尤其是疫情期间。今天小编来和大家说一说,84消…

    2022年8月13日

联系我们

联系邮箱:admin#wlmqw.com
工作时间:周一至周五,10:30-18:30,节假日休息