Controller层代码这么写,简洁又优雅!

说到 Controller,相信大家都不陌生,它可以很方便地对外提供数据接口。它的定位,我认为是「不可或缺的配角」,说它不可或缺是因为无论是传统的三层架构还是现在的COLA架构,Controller 层依旧有一席之地,说明他的必要性;说它是配角是因为 Controller 层的代码一般是不负责具体的逻辑业务逻辑实现,但是它负责接收和响应请求

从现状看问题Controller 主要的工作有以下几项

接收请求并解析参数调用 Service 执行具体的业务代码(可能包含参数校验)捕获业务逻辑异常做出反馈业务逻辑执行成功做出响应代码语言:javascript复制//DTO

@Data

publicclassTestDTO{

private Integer num;

private String type;

}

//Service

@Service

publicclassTestService{

public Double service(TestDTO testDTO)throws Exception {

if (testDTO.getNum() <= ) {

thrownew Exception("输入的数字需要大于0");

}

if (testDTO.getType().equals("square")) {

return Math.pow(testDTO.getNum(), );

}

if (testDTO.getType().equals("factorial")) {

double result = ;

int num = testDTO.getNum();

while (num > ) {

result = result * num;

num -= ;

}

return result;

}

thrownew Exception("未识别的算法");

}

}

//Controller

@RestController

publicclassTestController{

private TestService testService;

@PostMapping("/test")

public Double test(@RequestBody TestDTO testDTO){

try {

Double result = this.testService.service(testDTO);

return result;

} catch (Exception e) {

thrownew RuntimeException(e);

}

}

@Autowired

public DTOid setTestService(TestService testService){

this.testService = testService;

}

}如果真的按照上面所列的工作项来开发 Controller 代码会有几个问题

参数校验过多地耦合了业务代码,违背单一职责原则可能在多个业务中都抛出同一个异常,导致代码重复各种异常反馈和成功响应格式不统一,接口对接不友好

改造 Controller 层逻辑

统一返回结构统一返回值类型无论项目前后端是否分离都是非常必要的,方便对接接口的开发人员更加清晰地知道这个接口的调用是否成功(不能仅仅简单地看返回值是否为 null 就判断成功与否,因为有些接口的设计就是如此),使用一个状态码、状态信息就能清楚地了解接口调用情况

代码语言:javascript复制//定义返回数据结构

publicinterfaceIResult{

Integer getCode();

String getMessage();

}

//常用结果的枚举

publicenum ResultEnum implements IResult {

SUCCESS(, "接口调用成功"),

VALIDATE_FAILED(, "参数校验失败"),

COMMON_FAILED(, "接口调用失败"),

FORBIDDEN(, "没有权限访问资源");

private Integer code;

private String message;

//省略get、set方法和构造方法

}

//统一返回数据结构

@Data

@NoArgsConstructor

@AllArgsConstructor

publicclassResult {

private Integer code;

private String message;

private T data;

publicstatic Result success(T data){

returnnew Result<>(ResultEnum.SUCCESS.getCode(), ResultEnum.SUCCESS.getMessage(), data);

}

publicstatic Result success(String message, T data){

returnnew Result<>(ResultEnum.SUCCESS.getCode(), message, data);

}

publicstatic Result failed() {

returnnew Result<>(ResultEnum.COMMON_FAILED.getCode(), ResultEnum.COMMON_FAILED.getMessage(), null);

}

publicstatic Result failed(String message) {

returnnew Result<>(ResultEnum.COMMON_FAILED.getCode(), message, null);

}

publicstatic Result failed(IResult errorResult) {

returnnew Result<>(errorResult.getCode(), errorResult.getMessage(), null);

}

publicstatic Result instance(Integer code, String message, T data){

Result result = new Result<>();

result.setCode(code);

result.setMessage(message);

result.setData(data);

return result;

}

}统一返回结构后,在 Controller 中就可以使用了,但是每一个 Controller 都写这么一段最终封装的逻辑,这些都是很重复的工作,所以还要继续想办法进一步处理统一返回结构

统一包装处理Spring 中提供了一个类 ResponseBodyAdvice ,能帮助我们实现上述需求

ResponseBodyAdvice 是对 Controller 返回的内容在 HttpMessageConverter 进行类型转换之前拦截,进行相应的处理操作后,再将结果返回给客户端。那这样就可以把统一包装的工作放到这个类里面。

代码语言:javascript复制publicinterfaceResponseBodyAdvice {

booleansupports(MethodParameter returnType, Class> converterType);

@Nullable

T beforeBodyWrite(@Nullable T body, MethodParameter returnType, MediaType selectedContentType, Class> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response);

}

supports:判断是否要交给 beforeBodyWrite 方法执行,ture:需要;false:不需要beforeBodyWrite:对 response 进行具体的处理代码语言:javascript复制// 如果引入了swagger或knife4j的文档生成组件,这里需要仅扫描自己项目的包,否则文档无法正常生成

@RestControllerAdvice(basePackages = "com.example.demo")

publicclassResponseAdviceimplementsResponseBodyAdvice {

@Override

publicbooleansupports(MethodParameter returnType, Class> converterType){

// 如果不需要进行封装的,可以添加一些校验手段,比如添加标记排除的注解

returntrue;

}

@Override

public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response){

// 提供一定的灵活度,如果body已经被包装了,就不进行包装

if (body instanceof Result) {

return body;

}

return Result.success(body);

}

}

经过这样改造,既能实现对 Controller 返回的数据进行统一包装,又不需要对原有代码进行大量的改动

处理 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,而其他数据类型的值是 org.springframework.http.converter.json.MappingJackson2HttpMessageConverter

String 类型图片其他类型 (如 Integer 类型)图片现在问题已经较为清晰了,因为我们需要返回一个 Result 对象

所以使用 MappingJackson2HttpMessageConverter 是可以正常转换的

而使用 StringHttpMessageConverter 字符串转换器会导致类型转换失败

现在处理这个问题有两种方式

在 beforeBodyWrite 方法处进行判断,如果返回值是 String 类型就对 Result 对象手动进行转换成 JSON 字符串,另外方便前端使用,最好在 @RequestMapping 中指定 ContentType代码语言:javascript复制@RestControllerAdvice(basePackages = "com.example.demo")

publicclassResponseAdviceimplementsResponseBodyAdvice {

...

@Override

public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response){

// 提供一定的灵活度,如果body已经被包装了,就不进行包装

if (body instanceof Result) {

return body;

}

// 如果返回值是String类型,那就手动把Result对象转换成JSON字符串

if (body instanceof String) {

try {

returnthis.objectMapper.writeValueAsString(Result.success(body));

} catch (JsonProcessingException e) {

thrownew RuntimeException(e);

}

}

return Result.success(body);

}

...

}

@GetMapping(value = "/returnString", produces = "application/json; charset=UTF-8")

public String returnString(){

return"success";

}修改 HttpMessageConverter 实例集合中 MappingJackson2HttpMessageConverter 的顺序。因为发生上述问题的根源所在是集合中 StringHttpMessageConverter 的顺序先于 MappingJackson2HttpMessageConverter 的,调整顺序后即可从根源上解决这个问题网上有不少做法是直接在集合中第一位添加 MappingJackson2HttpMessageConverter代码语言:javascript复制@Configuration

publicclassWebConfigurationimplementsWebMvcConfigurer{

@Override

publicvoidconfigureMessageConverters(List> converters){

converters.add(, new MappingJackson2HttpMessageConverter());

}

}

诚然,这种方式可以解决问题,但其实问题的根源不是集合中缺少这一个转换器,而是转换器的顺序导致的,所以最合理的做法应该是调整 MappingJackson2HttpMessageConverter 在集合中的顺序代码语言:javascript复制@Configuration

publicclassWebMvcConfigurationimplementsWebMvcConfigurer{

/**

* 交换MappingJackson2HttpMessageConverter与第一位元素

* 让返回值类型为String的接口能正常返回包装结果

*

* @param converters initially an empty list of converters

*/

@Override

publicvoidconfigureMessageConverters(List> converters){

for (int i = ; i < converters.size(); i++) {

if (converters.get(i) instanceof MappingJackson2HttpMessageConverter) {

MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter = (MappingJackson2HttpMessageConverter) converters.get(i);

converters.set(i, converters.get());

converters.set(, mappingJackson2HttpMessageConverter);

break;

}

}

}

}

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

@PathVariable 和 @RequestParam 参数校验Get 请求的参数接收一般依赖这两个注解,但是处于 url 有长度限制和代码的可维护性,超过 5 个参数尽量用实体来传参

对 @PathVariable 和 @RequestParam 参数进行校验需要在入参声明约束的注解

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

代码语言:javascript复制@RestController(value = "prettyTestController")

@RequestMapping("/pretty")

@Validated

publicclassTestController{

private TestService testService;

@GetMapping("/{num}")

public Integer detail(@PathVariable("num") @Min() @Max() 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

publicvoidsetTestService(TestService prettyTestService){

this.testService = prettyTestService;

}

}校验原理在 SpringMVC 中,有一个类是 RequestResponseBodyMethodProcessor ,这个类有两个作用(实际上可以从名字上得到一点启发)

用于解析 @RequestBody 标注的参数处理 @ResponseBody 标注方法的返回值解析 @RequestBoyd 标注参数的方法是 resolveArgument

代码语言:javascript复制publicclassRequestResponseBodyMethodProcessorextendsAbstractMessageConverterMethodProcessor{

/**

* 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)) {

thrownew MethodArgumentNotValidException(parameter, binder.getBindingResult());

}

}

if (mavContainer != null) {

mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult());

}

}

return adaptArgumentIfNecessary(arg, parameter);

}

}

publicabstractclassAbstractMessageConverterMethodArgumentResolverimplementsHandlerMethodArgumentResolver{

/**

* Validate the binding target if applicable.

*

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

*/

protectedvoidvalidateIfApplicable(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 异常

代码语言:javascript复制//DTO

@Data

publicclassTestDTO{

@NotBlank

private String userName;

@NotBlank

@Length(min = , max = )

private String password;

@NotNull

@Email

private String email;

}

//Controller

@RestController(value = "prettyTestController")

@RequestMapping("/pretty")

publicclassTestController{

private TestService testService;

@PostMapping("/test-validation")

publicvoidtestValidation(@RequestBody @Validated TestDTO testDTO){

this.testService.save(testDTO);

}

@Autowired

publicvoidsetTestService(TestService testService){

this.testService = testService;

}

}校验原理声明约束的方式,注解加到了参数上面,可以比较容易猜测到是使用了 AOP 对方法进行增强

而实际上 Spring 也是通过 MethodValidationPostProcessor 动态注册 AOP 切面,然后使用 MethodValidationInterceptor 对切点方法进行织入增强

代码语言:javascript复制publicclassMethodValidationPostProcessorextendsAbstractBeanFactoryAwareAdvisingPostProcessorimplementsInitializingBean{

//指定了创建切面的Bean的注解

private Class validatedAnnotationType = Validated.class;

@Override

publicvoidafterPropertiesSet(){

//为所有@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());

}

}

publicclassMethodValidationInterceptorimplementsMethodInterceptor{

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

try {

//方法入参校验,最终还是委托给Hibernate Validator来校验

//所以Spring Validation是对Hibernate Validation的二次封装

result = execVal.validateParameters(

invocation.getThis(), methodToValidate, invocation.getArguments(), groups);

}

catch (IllegalArgumentException ex) {

...

}

//校验不通过抛出ConstraintViolationException异常

if (!result.isEmpty()) {

thrownew ConstraintViolationException(result);

}

//Controller方法调用

Object returnValue = invocation.proceed();

//下面是对返回值做校验,流程和上面大概一样

result = execVal.validateReturnValue(invocation.getThis(), methodToValidate, returnValue, groups);

if (!result.isEmpty()) {

thrownew ConstraintViolationException(result);

}

return returnValue;

}

}自定义校验规则有些时候 JSR303 标准中提供的校验规则不满足复杂的业务需求,也可以自定义校验规则

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

自定义注解类,定义错误信息和一些其他需要的内容注解校验器,定义判定规则代码语言:javascript复制//自定义注解类

@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER})

@Retention(RetentionPolicy.RUNTIME)

@Documented

@Constraint(validatedBy = MobileValidator.class)

public @interfaceMobile{

/**

* 是否允许为空

*/

booleanrequired()defaulttrue;

/**

* 校验不通过返回的提示信息

*/

String message()default "不是一个手机号码格式";

/**

* Constraint要求的属性,用于分组校验和扩展,留空就好

*/

Class[] groups() default {};

Class[] payload() default {};

}

//注解校验器

publicclassMobileValidatorimplementsConstraintValidator {

privateboolean required = false;

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

/**

* 在验证开始前调用注解里的方法,从而获取到一些注解里的参数

*

* @param constraintAnnotation annotation instance for a given constraint declaration

*/

@Override

publicvoidinitialize(Mobile constraintAnnotation){

this.required = constraintAnnotation.required();

}

/**

* 判断参数是否合法

*

* @param value object to validate

* @param context context in which the constraint is evaluated

*/

@Override

publicbooleanisValid(CharSequence value, ConstraintValidatorContext context){

if (this.required) {

// 验证

return isMobile(value);

}

if (StringUtils.hasText(value)) {

// 验证

return isMobile(value);

}

returntrue;

}

privatebooleanisMobile(final CharSequence str){

Matcher m = pattern.matcher(str);

return m.matches();

}

}自动校验参数真的是一项非常必要、非常有意义的工作。 JSR303 提供了丰富的参数校验规则,再加上复杂业务的自定义校验规则,完全把参数校验和业务逻辑解耦开,代码更加简洁,符合单一职责原则。

自定义异常与统一拦截异常原来的代码中可以看到有几个问题

抛出的异常不够具体,只是简单地把错误信息放到了 Exception 中抛出异常后,Controller 不能具体地根据异常做出反馈虽然做了参数自动校验,但是异常返回结构和正常返回结构不一致自定义异常是为了后面统一拦截异常时,对业务中的异常有更加细颗粒度的区分,拦截时针对不同的异常作出不同的响应

而统一拦截异常的目的一个是为了可以与前面定义下来的统一包装返回结构能对应上,另一个是我们希望无论系统发生什么异常,Http 的状态码都要是 200 ,尽可能由业务来区分系统的异常

代码语言:javascript复制//自定义异常

publicclassForbiddenExceptionextendsRuntimeException{

publicForbiddenException(String message){

super(message);

}

}

//自定义异常

publicclassBusinessExceptionextendsRuntimeException{

publicBusinessException(String message){

super(message);

}

}

//统一拦截异常

@RestControllerAdvice(basePackages = "com.example.demo")

publicclassExceptionAdvice{

/**

* 捕获 {@code BusinessException} 异常

*/

@ExceptionHandler({BusinessException.class})

publicResult handleBusinessException(BusinessExceptionex) {

return Result.failed(ex.getMessage());

}

/**

* 捕获 {@code ForbiddenException} 异常

*/

@ExceptionHandler({ForbiddenException.class})

publicResult handleForbiddenException(ForbiddenExceptionex) {

return Result.failed(ResultEnum.FORBIDDEN);

}

/**

* {@code@RequestBody} 参数校验不通过时抛出的异常处理

*/

@ExceptionHandler({MethodArgumentNotValidException.class})

publicResult handleMethodArgumentNotValidException(MethodArgumentNotValidExceptionex) {

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

publicResult handleConstraintViolationException(ConstraintViolationExceptionex) {

if (StringUtils.hasText(ex.getMessage())) {

return Result.failed(ResultEnum.VALIDATE_FAILED.getCode(), ex.getMessage());

}

return Result.failed(ResultEnum.VALIDATE_FAILED);

}

/**

* 顶级异常捕获并统一处理,当其他异常无法处理时候选择使用

*/

@ExceptionHandler({Exception.class})

publicResult handle(Exceptionex) {

return Result.failed(ex.getMessage());

}

}

总结做好了这一切改动后,可以发现 Controller 的代码变得非常简洁,可以很清楚地知道每一个参数、每一个 DTO 的校验规则,可以很明确地看到每一个 Controller 方法返回的是什么数据,也可以方便每一个异常应该如何进行反馈

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

最新发表

友情链接