关于后端:Controller-就该这么写

44次阅读

共计 13256 个字符,预计需要花费 34 分钟才能阅读完成。

一个优良的 Controller 层逻辑

说到 Controller,置信大家都不生疏,它能够很不便地对外提供数据接口。它的定位,我认为是「不可或缺的主角」,说它不可或缺是因为无论是传统的三层架构还是当初的 COLA 架构,Controller 层仍旧有一席之地,阐明他的必要性;说它是主角是因为 Controller 层的代码个别是不负责具体的逻辑业务逻辑实现,然而它负责接管和响应申请

从现状看问题

Controller 次要的工作有以下几项

  • 接管申请并解析参数
  • 调用 Service 执行具体的业务代码(可能蕴含参数校验)
  • 捕捉业务逻辑异样做出反馈
  • 业务逻辑执行胜利做出响应
//DTO
@Data
public class TestDTO {
    private Integer num;
    private String type;
}


//Service
@Service
public class TestService {public Double service(TestDTO testDTO) throws Exception {if (testDTO.getNum() <= 0) {throw new Exception("输出的数字须要大于 0");
        }
        if (testDTO.getType().equals("square")) {return Math.pow(testDTO.getNum(), 2);
        }
        if (testDTO.getType().equals("factorial")) {
            double result = 1;
            int num = testDTO.getNum();
            while (num > 1) {
                result = result * num;
                num -= 1;
            }
            return result;
        }
        throw new Exception("未辨认的算法");
    }
}


//Controller
@RestController
public 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 代码会有几个问题

  1. 参数校验过多地耦合了业务代码,违反繁多职责准则
  2. 可能在多个业务中都抛出同一个异样,导致代码反复
  3. 各种异样反馈和胜利响应格局不对立,接口对接不敌对

革新 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
@AllArgsConstructor
public class Result<T> {
    private Integer code;
    private String message;
    private T data;

    public static <T> Result<T> success(T data) {return new Result<>(ResultEnum.SUCCESS.getCode(), ResultEnum.SUCCESS.getMessage(), data);
    }

    public static <T> Result<T> 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 <T> Result<T> instance(Integer code, String message, T data) {Result<T> result = new Result<>();
        result.setCode(code);
        result.setMessage(message);
        result.setData(data);
        return result;
    }
}

对立返回构造后,在 Controller 中就能够应用了,然而每一个 Controller 都写这么一段最终封装的逻辑,这些都是很反复的工作,所以还要持续想方法进一步解决对立返回构造

对立包装解决

Spring 中提供了一个类 ResponseBodyAdvice,能帮忙咱们实现上述需要

ResponseBodyAdvice 是对 Controller 返回的内容在 HttpMessageConverter 进行类型转换之前拦挡,进行相应的解决操作后,再将后果返回给客户端。那这样就能够把对立包装的工作放到这个类外面。

public interface ResponseBodyAdvice<T> {boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType);

    @Nullable
    T beforeBodyWrite(@Nullable T body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response);
}
  • supports:判断是否要交给 beforeBodyWrite 办法执行,ture:须要;false:不须要
  • beforeBodyWrite:对 response 进行具体的解决
// 如果引入了 swagger 或 knife4j 的文档生成组件,这里须要仅扫描本人我的项目的包,否则文档无奈失常生成
@RestControllerAdvice(basePackages = "com.example.demo")
public class ResponseAdvice implements ResponseBodyAdvice<Object> {
    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        // 如果不须要进行封装的,能够增加一些校验伎俩,比方增加标记排除的注解
        return true;
    }
  

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
        // 提供肯定的灵便度,如果 body 曾经被包装了,就不进行包装
        if (body instanceof Result) {return body;}
        return Result.success(body);
    }
}

通过这样革新,既能实现对 Controller 返回的数据进行对立包装,又不须要对原有代码进行大量的改变

参数校验

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

@PathVariable 和 @RequestParam 参数校验

Get 申请的参数接管个别依赖这两个注解,然而处于 url 有长度限度和代码的可维护性,超过 5 个参数尽量用实体来传参

对 @PathVariable 和 @RequestParam 参数进行校验须要在入参申明束缚的注解

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

@RestController(value = "prettyTestController")
@RequestMapping("/pretty")
public 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,这个类有两个作用(实际上能够从名字上失去一点启发)

  1. 用于解析 @RequestBody 标注的参数
  2. 解决 @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.
    * <p>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
@Data
public 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<? extends Annotation> 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<ConstraintViolation<Object>> 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 规范中提供的校验规定不满足简单的业务需要,也能够自定义校验规定

自定义校验规定须要做两件事件

  1. 自定义注解类,定义错误信息和一些其余须要的内容
  2. 注解校验器,定义断定规定
// 自定义注解类
@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<? extends Payload>[] payload() default {};}

// 注解校验器
public class MobileValidator implements ConstraintValidator<Mobile, CharSequence> {

    private boolean required = false;

    private final Pattern pattern = Pattern.compile("^1[34578][0-9]{9}$"); // 验证手机号

    /**
     * 在验证开始前调用注解里的办法,从而获取到一些注解里的参数
     *
     * @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 参数校验请参考:Spring Validation 最佳实际及其实现原理,参数校验没那么简略!

自定义异样与对立拦挡异样

原来的代码中能够看到有几个问题

  1. 抛出的异样不够具体,只是简略地把错误信息放到了 Exception 中
  2. 抛出异样后,Controller 不能具体地依据异样做出反馈
  3. 尽管做了参数主动校验,然而异样返回构造和失常返回构造不统一

自定义异样是为了前面对立拦挡异样时,对业务中的异样有更加细颗粒度的辨别,拦挡时针对不同的异样作出不同的响应

而对立拦挡异样的目标一个是为了能够与后面定义下来的对立包装返回构造能对应上,另一个是咱们心愿无论零碎产生什么异样,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 办法返回的是什么数据,也能够不便每一个异样应该如何进行反馈

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

如果感觉我写得还不错,心愿你能够给我一个收费的赞🤓

正文完
 0