糯麦 NurMai

400-158-5662

糯麦科技

/

新闻资讯

/

技术讨论

/

构建一个卓越的Controller层逻辑

构建一个卓越的Controller层逻辑

转载 新闻资讯

于 2024-06-06 15:47:05 发布

18891 浏览

谈及Controller层,它在软件开发中扮演着至关重要的角色,作为对外提供数据接口的桥梁。我将其定位为“幕后英雄,不可或缺”。


之所以说它不可或缺,是因为从传统的三层架构到现代的COLA架构,Controller层始终占据着一席之地,这充分证明了它在系统架构中的必要性。它是连接前端请求与后端业务逻辑的关键节点,确保了数据流的顺畅与准确。


同时,称其为“幕后英雄”,是因为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() <= 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@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<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 ,能帮助我们实现上述需求:

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);}

使用一个状态码、状态信息就能清楚地了解接口调用情况:

ResponseBodyAdvice 是对 Controller 返回的内容在 HttpMessageConverter 进行类型转换之前拦截,进行相应的处理操作后,再将结果返回给客户端。


那这样就可以把统一包装的工作放到这个类里面:

● 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 validation。


spring 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,这个类有两个作用(实际上可以从名字上得到一点启发)

● 用于解析 @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.    * <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@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<? 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 标准中提供的校验规则不满足复杂的业务需求,也可以自定义校验规则。


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


● 自定义注解类,定义错误信息和一些其他需要的内容

● 注解校验器,定义判定规则

//自定义注解类@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 提供了丰富的参数校验规则,再加上复杂业务的自定义校验规则,完全把参数校验和业务逻辑解耦开,代码更加简洁,符合单一职责原则。


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

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

● 抛出的异常不够具体,只是简单地把错误信息放到了 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方法所返回的具体数据内容。同时,我们也为异常处理制定了明确的反馈机制,确保在发生错误时能够及时、准确地给予反馈。


这一系列的改动不仅提高了代码的可读性和可维护性,也让我们能够更加专注于业务逻辑的开发。简洁的代码结构、完善的功能设计,为我们带来了更高效、更稳定的工作体验。这样的改进,何乐而不为呢?

Controller

MVC

安达小程序开发

安达网站建设

阅读排行

  • 1. 几行代码就能实现Html大转盘抽奖

    大转盘抽奖是网络互动营销的一种常见形式,其通过简单易懂的界面设计,让用户在游戏中体验到乐趣,同时也能增加商家与用户之间的互动。本文将详细介绍如何使用HTML,CSS和JavaScript来实现大转盘抽奖的功能。

    查看详情
  • 2. 浙江省同区域公司地址变更详细流程

    提前准备好所有需要的资料,包含:房屋租赁合同、房产证、营业执照正副本、代理人身份证正反面、承诺书(由于我们公司其中一区域已有注册另外一公司,所以必须需要承诺书)

    查看详情
  • 3. 微信支付商户申请接入流程

    微信支付,是微信向有出售物品/提供服务需求的商家提供推广销售、支付收款、经营分析的整套解决方案,包括多种支付方式,如JSAPI支付、小程序支付、APP支付H5支付等支付方式接入。

    查看详情
  • 4. 阿里云域名ICP网络备案流程

    根据《互联网信息服务管理办法》以及《非经营性互联网信息服务备案管理办法》,国家对非经营性互联网信息服务实行备案制度,对经营性互联网信息服务实行许可制度。

    查看详情
  • 5. 微信小程序申请注册流程

    微信小程序注册流程与微信公众号较为相似,同时微信小程序支持通过已认证的微信公众号进行注册申请,无需进行单独认证即可使用,同一个已认证微信公众号可同时绑定注册多个小程序。

    查看详情