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

2006

积分

0

好友

277

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

在Web开发中,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;
    }
}

按照上述模式开发,我们不难发现几个问题:

  1. 职责不清:参数校验过多地耦合在业务代码中,违背了单一职责原则。
  2. 代码重复:多个业务方法可能抛出相同的异常,导致异常处理代码重复。
  3. 响应混乱:异常反馈和成功响应的格式不统一,给接口对接带来困扰。

改造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
@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方法都需要手动返回Result对象。但这无疑增加了重复劳动。接下来,我们看看如何自动化地完成响应包装。

统一包装处理

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方法。
  • beforeBodyWrite:对响应体进行具体处理。

我们可以实现这个接口来统一包装返回值:

// 如果引入了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 Web开发中优化 后端架构 的常见手段。

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

直接使用ResponseBodyAdvice处理大部分类型没有问题,但当Controller方法返回String类型时,可能会抛出类转换异常。这是因为String类型的返回值使用StringHttpMessageConverter进行转换,而我们的包装结果是Result对象,两者不匹配。

通过调试可以发现差异:

  • String 类型selectedConverterType参数值为org.springframework.http.converter.StringHttpMessageConverter
    字符串类型调试信息截图
  • 其他类型 (如 Integer)selectedConverterType参数值为org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
    非字符串类型调试信息截图

问题的根源在于StringHttpMessageConverter无法处理Result对象。有两种解决方案:

  1. 手动转换字符串:在beforeBodyWrite方法中,对String类型的返回值手动将Result对象转换为JSON字符串,并建议在@RequestMapping中指定ContentType。

    @RestControllerAdvice(basePackages = "com.example.demo")
    public class ResponseAdvice implements ResponseBodyAdvice<Object> {
        private final ObjectMapper objectMapper = new ObjectMapper();
    
        @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);
        }
        // ... supports方法
    }
    
    @GetMapping(value = "/returnString", produces = "application/json; charset=UTF-8")
    public String returnString() {
        return "success";
    }
  2. 调整转换器顺序:更优雅的方式是调整HttpMessageConverter集合中MappingJackson2HttpMessageConverter的顺序,使其优先于StringHttpMessageConverter

    @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规范JSR303定义了校验标准,其实现Hibernate Validation和Spring的二次封装Spring Validation可以方便地集成到SpringMVC中,实现参数自动校验。

@PathVariable 和 @RequestParam 参数校验
对于GET请求,通常使用@PathVariable@RequestParam接收参数。校验时需要在Controller类上标注@Validated,并在参数上声明约束注解。如果校验失败,会抛出ConstraintViolationException

@RestController(value = "prettyTestController")
@RequestMapping("/pretty")
@Validated // 类级别注解
public class TestController {

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

@RequestBody 参数校验
对于POST、PUT等请求的请求体参数,校验更为常见。首先在DTO对象的字段上添加校验注解,然后在Controller方法参数上使用@Validated标注。校验失败会抛出MethodArgumentNotValidException异常。

//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 {

    @PostMapping("/test-validation")
    public void testValidation(@RequestBody @Validated TestDTO testDTO) {
        this.testService.save(testDTO);
    }
}

校验原理浅析

  • @RequestBody校验:主要由RequestResponseBodyMethodProcessor处理。它在解析参数后,会检查参数上是否有@Valid@Validated或名称以“Valid”开头的注解,然后调用Hibernate Validator执行校验,失败则抛出MethodArgumentNotValidException
  • @PathVariable/@RequestParam校验:Spring通过MethodValidationPostProcessor动态注册AOP切面,利用MethodValidationInterceptor对标注了@Validated的Bean的方法进行增强。在执行方法前后校验参数和返回值,最终也是委托Hibernate Validator完成,失败抛出ConstraintViolationException

自定义校验规则
当标准校验规则无法满足复杂业务时,我们可以自定义校验规则,这需要两步:

  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}$"); // 验证手机号

    @Override
    public void initialize(Mobile constraintAnnotation) {
        this.required = constraintAnnotation.required();
    }

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

自动化的参数校验将业务逻辑与校验规则彻底解耦,代码更加简洁,符合单一职责原则。深入理解其原理,有助于编写更健壮的校验逻辑和排查相关问题,这些都属于高质量的 技术文档 应涵盖的内容。

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

原始代码中的异常处理存在不足:异常信息不够具体、Controller难以根据异常类型精细反馈、异常响应结构与成功响应结构不一致。

自定义异常:定义具有业务含义的异常类,以便在全局异常处理中区分对待。

//自定义异常
public class ForbiddenException extends RuntimeException {
    public ForbiddenException(String message) {
        super(message);
    }
}

//自定义异常
public class BusinessException extends RuntimeException {
    public BusinessException(String message) {
        super(message);
    }
}

统一拦截异常:使用@RestControllerAdvice@ExceptionHandler创建一个全局异常处理器。其目的:一是使异常响应格式与统一成功响应格式保持一致;二是尽可能让所有异常都返回HTTP状态码200,而用业务状态码来区分系统状态。

@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层的代码将变得异常简洁和清晰。每个参数的校验规则一目了然,每个方法的返回类型明确可知,每种异常的处理方式也定义妥当。开发者可以更加专注于核心业务逻辑的开发,而将接口规范化、统一化的繁重工作交给框架和既定模式。

这套组合拳打下来,不仅提升了代码的可维护性和可读性,也为团队协作和API管理带来了极大的便利,何乐而不为呢?如果你在实践中有更多心得或疑问,欢迎来 云栈社区 与广大开发者一起交流探讨。

一张表达幽默感的卡通表情图




上一篇:Python实战:构建石油货币统计套利模型,捕捉均值回归机会
下一篇:Python Flet框架入门:构建跨平台桌面与Web应用的现代GUI指南
您需要登录后才可以回帖 登录 | 立即注册

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

GMT+8, 2026-1-10 08:51 , Processed in 0.277190 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

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