找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4648

积分

0

好友

605

主题
发表于 昨天 21:13 | 查看: 4| 回复: 0

说到 Controller,相信大家都不陌生,它可以很方便地对外提供数据接口。它的定位,我认为更像是“不可或缺的配角”。说它不可或缺,是因为无论是传统的三层架构还是现在的 COLA 架构,Controller 层依然占有一席之地;说它是配角,则是因为 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 void 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 类,能帮助我们对 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 方法执行,true:需要;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 返回数据统一包装的 System Design 目标,又无需对原有代码进行大量改动。

处理 cannot be cast to java.lang.String 问题

直接使用 ResponseBodyAdvice,在处理大多数类型时都没问题,但当遇到字符串类型时,会抛出 xxx.包装类 cannot be cast to java.lang.String 的类型转换异常。

ResponseBodyAdvice 实现类中 debug 可以发现,只有 String 类型的 selectedConverterType 参数值是 org.springframework.http.converter.StringHttpMessageConverter,而其他数据类型(如 Integer)的值是 org.springframework.http.converter.json.MappingJackson2HttpMessageConverter

  • String 类型

Spring Boot ResponseBodyAdvice Debug 界面 String 类型转换器

  • 其他类型 (如 Integer 类型)

Spring Boot ResponseBodyAdvice Debug 界面 Jackson 类型转换器

问题根源现在很清晰了。我们需要返回一个 Result 对象,这个对象可以被 MappingJackson2HttpMessageConverter 正常转换,但 StringHttpMessageConverter 却无法处理,导致了类型转换失败。

针对此问题,有两种解决思路:

  1. beforeBodyWrite 方法中做判断,如果返回值是 String 类型,就手动将 Result 对象转换成 JSON 字符串。另外,为了方便前端使用,最好在 @RequestMapping 中指定 ContentType。
@RestControllerAdvice(basePackages = "com.example.demo")
public class ResponseAdvice implements ResponseBodyAdvice<Object> {
    ...
    @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;
        }
        // 如果返回值是String类型,那就手动把Result对象转换成JSON字符串
        if (body instanceof String) {
            try {
                return this.objectMapper.writeValueAsString(Result.success(body));
            } catch (JsonProcessingException e) {
                throw new RuntimeException(e);
            }
        }
        return Result.success(body);
    }
    ...
}

@GetMapping(value = "/returnString", produces = "application/json; charset=UTF-8")
public String returnString() {
    return "success";
}
  1. 修改 HttpMessageConverter 实例集合中 MappingJackson2HttpMessageConverter 的顺序。因为问题的根源就是 StringHttpMessageConverter 的顺序先于 MappingJackson2HttpMessageConverter,调整顺序后即可从根源上解决。

网上有不少做法是直接在集合中第一位添加一个转换器:

@Configuration
public class WebConfiguration implements WebMvcConfigurer {

    @Override
    public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
        converters.add(0, new MappingJackson2HttpMessageConverter());
    }
}

诚然,这种方式能解决问题,但问题的本质不是缺少转换器,而是顺序错了。更合理的做法应该是调整 MappingJackson2HttpMessageConverter 在集合中的顺序。

@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {

    /**
     * 交换MappingJackson2HttpMessageConverter与第一位元素
     * 让返回值类型为String的接口能正常返回包装结果
     *
     * @param converters initially an empty list of converters
     */
    @Override
    public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
        for (int i = 0; i < converters.size(); i++) {
            if (converters.get(i) instanceof MappingJackson2HttpMessageConverter) {
                MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter = (MappingJackson2HttpMessageConverter) converters.get(i);
                converters.set(i, converters.get(0));
                converters.set(0, mappingJackson2HttpMessageConverter);
                break;
            }
        }
    }
}

参数校验

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

@PathVariable 和 @RequestParam 参数校验

Get 请求的参数接收通常依赖这两个注解。不过考虑到 URL 的长度限制和代码的可维护性,超过 5 个参数时,还是建议用实体来传参。

@PathVariable@RequestParam 参数进行校验,需要在入参上声明约束注解。如果校验失败,会抛出 MethodArgumentNotValidException 异常。

@RestController(value = "prettyTestController")
@RequestMapping("/pretty")
@Validated
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 标注方法的返回值。

其解析 @RequestBody 的方法是 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 提供了丰富的参数校验规则,再结合自定义校验规则,能彻底将参数校验与业务逻辑解耦,让代码更加简洁,更符合单一职责原则。

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

回顾最初的代码,能看到几个问题:

  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 的校验规则,也能直观地了解每个方法返回的数据结构,以及每种异常应该如何反馈。

经过这番操作,我们更能专注于业务逻辑的开发,代码既简洁又功能完善,何乐而不为呢?





上一篇:GitHub热门4.6k星:Rust写的DeepSeek-TUI终端编程Agent适配V4
下一篇:OceanBase 回收站机制详解:DROP database/index 会进回收站吗?
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-5-8 01:45 , Processed in 0.632717 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表