优雅的处理controller
前言:对于controller每个开发人员应该都很熟悉,从SSH、SSM都离不开这个,到现在的SpringBoot都有它的一席之地,通常我们都知道Controller 层的代码一般是不负责具体的逻辑业务逻辑实现,但是它负责接收和响应请求,同时也负责对外提供第三方的接口。
1.抛出问题
controller层主要处理的业务逻辑包含以下几个:
- 接受请求并解析参数和参数校验
- 调用Service处理业务逻辑
- 捕获异常抛出异常到前端
- 执行成功后返回响应数据
示例代码:
我相信初学者很多人都是这样写代码,甚至都没有去理解各个业务逻辑层直接之间的数据模型,为方便演示我先写一个通常的示例
//Controller层
@RestController
@RequestMapping("/student")
public class StudentController {
@Autowired
private StudentService studentService;
@PostMapping("/save")
public Result saveStudent (StudentDto studentDto) {
try {
//很多人的Controller 传参 没有按照java领域模型定义转换
StudentPo studentPo = new StudentPo();
BeanUtil.copyProperties(studentDto, studentPo, false);
studentService.saveStudent(studentPo);
return Result.success("保存成功", null);
}catch (Exception e) {
}
return Result.failed("保存失败");
}
}
//service层
@Service
public class StudentServiceImpl extends ServiceImpl<StudentMapper, StudentPo> implements StudentService {
@Override
public List<StudentPo> saveStudent(StudentPo studentPo) throws Exception {
if (StrUtil.isEmpty(studentPo.getName())) {
throw new Exception("名字不能为空");
}
if (StrUtil.isEmpty(studentPo.getSexCode())) {
throw new Exception("性别不能为空");
}
return null;
}
}
@TableName(value = "student")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class StudentPo {
/** 主键 type:自增 */
@TableId(type = IdType.AUTO)
private Integer id;
/** 名字 */
private String name;
/** 年龄 */
private Integer age;
/** 性别代码*/
private String sexCode;
private Long courseId;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class StudentDto {
/**
* 主键 type:自增
*/
private Integer id;
/**
* 名字
*/
private String name;
/**
* 年龄
*/
private Integer age;
/**
* 地址
*/
private String address;
/***
* 性别编码
*/
private String sexCode;
}
如果是上面这种写法,我们可以思考一下存在那些问题?
- 参数校验过多地耦合了业务代码,违背单一职责原则
- 可能在多个业务中都抛出同一个异常,导致代码重复
- 各种异常反馈和成功响应格式不统一,接口对接不友好
- 参数验证抛出异常过多,没有进行异常分类,前端不能明确看出具体的异常原因
如何对这一段代码进行改造?
2 定义Java 领域数据模型,各个业务层之间的数据对象按需定义
通常使用的模型解释:
- PO(Persistent Object):持久化对象,用于表示与数据库中的表相对应的 Java 对象。通常包含了表中每一列对应的属性及其对应的 getter/setter 方法。
- VO(Value Object):值对象,用于表示一个特定的值或属性集合,通常不包含业务逻辑。它主要用于数据传输和展示等场景。例如,前端页面所需要的数据可以通过 VO 的方式传递到后台接口。
- BO(Business Object):业务对象,用于表示业务模型中的对象,包含了业务逻辑和操作。通常包含了多个 PO 和 VO,并且对它们进行封装或组合。
- DO(Domain Object):领域对象,用于表示业务领域中的实体,通常包含了业务逻辑和状态信息。与 BO 相似,但更加强调领域模型和业务规则。
- DTO(Data Transfer Object):数据传输对象,用于表示在不同层之间传输的数据对象,通常只包含一组属性和对应的 getter/setter 方法。
- DAO(Data Access Object)是一种设计模式,用于封装对数据源的访问,提供了一些通用的数据库操作。
- POJO(Plain Old Java Object)是一种简单的 Java 对象,它没有实现任何特殊的接口或继承任何特殊的类,也没有被框架、容器或其他工具限制
我们常用的是DTO、VO、PO、DAO对象在实际业务层中的使用
- 控制器参数定义:应该统一定义为XxxDto
- 控制器业务逻辑:将DTO转换为PO,然后调用service
- service需要校验证参数抛出异常,不需要转换直接将POJO和表结构映射,调用DAO中的操作数据库方法
- 当响应数据时,service返回的PO数据需要进行转换,转换为VO对象返回给前端
- 统一返回结果封装
统一返回值类型无论项目前后端是否分离都是非常必要的,方便对接接口的开发人员更加清晰地知道这个接口的调用是否成功,并且还可以自定义写代码能够更好的区分返回值代码对应消息。
- 定义返回数据结构,通常包含代码和消息
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;
ResultEnum(Integer code, String message) {
this.code = code;
this.message = message;
}
@Override
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
@Override
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}
- 数据响应统一封装,减少代码冗余
@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;
}
}
- 统一响应数据扩展处理
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 进行具体的处理
@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);
}
}
4 参数校验
Java API 的规范 JSR303 定义了校验的标准 validation-api ,其中一个比较出名的实现是 hibernate validation。
spring validation 是对其的二次封装,常用于 SpringMVC 的参数自动校验,参数校验的代码就不需要再与业务逻辑代码进行耦合了。
- @PathVariable 和 @RequestParam 参数校验
Get 请求的参数接收一般依赖这两个注解,但是处于 url 有长度限制和代码的可维护性,超过 5 个参数尽量用实体来传参。
对 @PathVariable 和 @RequestParam 参数进行校验需要在入参声明约束的注解。
如果校验失败,会抛出 MethodArgumentNotValidException 异常
@GetMapping("/{num}")
public Result detail(@PathVariable("num") @Min(1) @Max(20) Integer num) {
return Result.success(num * num);
}
@GetMapping("/getByAddress")
public Result getByAccount(@RequestParam @NotBlank String address) {
StudentVO studentVO = new StudentVO();
studentVO.setAddress(address);
return Result.success(studentVO);
}
- 为什么使用注解后就能校验,它的原理是什么?
实际它是使用了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;
}
}
}
}
- 自定义参数校验
在实际的业务需求开发中,JSR303 标准中提供的校验规则往往是不满足复杂的业务需求,当我们需要一些特定的校验,我们就可以使用自定义的校验,参考JSR303 注解我们总结一些定义注解的规则:- 自定义注解类,定义错误信息和一些其他需要使用的内容
- 定义注解校验器,定义判定规则,添加自定义的业务规则
下面我们定义一个Phone的自定义注解
@Target(value = {ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER})
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {PhoneValidator.class})
public @interface Phone {
// 是否允许为空
boolean required() default true;
// 校验不通过时的提示信息
String message() default "手机号码格式错误";
// 校验器组
Class<?>[] groups() default {};
// 负载
Class<? extends Payload>[] payload() default {};
}
public class PhoneValidator implements ConstraintValidator<Phone, 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(Phone constraintAnnotation) {
this.required = constraintAnnotation.required();
}
@Override
public boolean isValid(CharSequence value, ConstraintValidatorContext constraintValidatorContext) {
if (this.required) {
// 验证
return isMobile(value);
}
if (!StrUtil.isEmpty(value)) {
// 验证
return isMobile(value);
}
return true;
}
private boolean isMobile(final CharSequence str) {
Matcher m = pattern.matcher(str);
return m.matches();
}
}
JSR303 提供了丰富的参数校验规则,再加上复杂业务的自定义校验规则,完全把参数校验和业务逻辑解耦开,代码更加简洁,符合单一职责原则。
5.统一异常和自定义异常处理
上面虽然针对验证抛出异常,但是没有对异常进行归类和统一处理,存在一些问题
- 抛出的异常不够具体,只是简单地把错误信息放到了 Exception 中
- 抛出异常后,Controller 不能具体地根据异常做出反馈
- 虽然做了参数自动校验,但是异常返回结构和正常返回结构不一致
自定义异常是为了后面统一拦截异常时,对业务中的异常有更加细颗粒度的区分,拦截时针对不同的异常作出不同的响应。
而统一拦截异常的目的一个是为了可以与前面定义下来的统一包装返回结构能对应上,另一个是我们希望无论系统发生什么异常,Http 的状态码都要是 200 ,尽可能由业务来区分系统的异常。
@RestControllerAdvice(basePackages = "com.jack.study.example")
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 方法返回的是什么数据,也可以方便每一个异常应该如何进行反馈。这样也能更好的专注于业务逻辑的开发,代码简介、功能完善。