从阿里跳槽来的工程师,写个Controller都这么优雅
目录一个优秀的Controller层逻辑从现状看问题改造Controller层逻辑总结
一个优秀的Controller层逻辑
说到Controller,相信大家都不陌生,它可以很方便地对外提供数据接口。它的定位,我认为是不可或缺的配角,说它不可或缺是因为无论是传统的三层架构还是现在的COLA架构,Controller层依旧有一席之地,说明他的必要性;说它是配角是因为Controller层的代码一般是不负责具体的逻辑业务逻辑实现,但是它负责接收和响应请求
从现状看问题
Controller主要的工作有以下几项
接收请求并解析参数
调用Service执行具体的业务代码(可能包含参数校验)
捕获业务逻辑异常做出反馈
业务逻辑执行成功做出响应DTO
Data
publicclassTestDTO{
privateIntegernum;
privateStringtype;
}
Service
Service
publicclassTestService{
publicDoubleservice(TestDTOtestDTO)throwsException{
if(testDTO。getNum0){
thrownewException(输入的数字需要大于0);
}
if(testDTO。getType。equals(square)){
returnMath。pow(testDTO。getNum,2);
}
if(testDTO。getType。equals(factorial)){
doubleresult1;
intnumtestDTO。getNum;
while(num1){
resultresultnum;
num1;
}
returnresult;
}
thrownewException(未识别的算法);
}
}
Controller
RestController
publicclassTestController{
privateTestServicetestService;
PostMapping(test)
publicDoubletest(RequestBodyTestDTOtestDTO){
try{
Doubleresultthis。testService。service(testDTO);
returnresult;
}catch(Exceptione){
thrownewRuntimeException(e);
}
}
Autowired
publicDTOidsetTestService(TestServicetestService){
this。testServicetestService;
}
}
如果真的按照上面所列的工作项来开发Controller代码会有几个问题
参数校验过多地耦合了业务代码,违背单一职责原则
可能在多个业务中都抛出同一个异常,导致代码重复
各种异常反馈和成功响应格式不统一,接口对接不友好
改造Controller层逻辑
统一返回结构
统一返回值类型无论项目前后端是否分离都是非常必要的,方便对接接口的开发人员更加清晰地知道这个接口的调用是否成功(不能仅仅简单地看返回值是否为就判断成功与否,因为有些接口的设计就是如此),使用一个状态码、状态信息就能清楚地了解接口调用情况定义返回数据结构
publicinterfaceIResult{
IntegergetCode;
StringgetMessage;
}
常用结果的枚举
publicenumResultEnumimplementsIResult{
SUCCESS(2001,接口调用成功),
VALIDATEFAILED(2002,参数校验失败),
COMMONFAILED(2003,接口调用失败),
FORBIDDEN(2004,没有权限访问资源);
privateIntegercode;
privateStringmessage;
省略get、set方法和构造方法
}
统一返回数据结构
Data
NoArgsConstructor
AllArgsConstructor
publicclassResultT{
privateIntegercode;
privateStringmessage;
privateTdata;
publicstaticTResultTsuccess(Tdata){
returnnewResult(ResultEnum。SUCCESS。getCode,ResultEnum。SUCCESS。getMessage,data);
}
publicstaticTResultTsuccess(Stringmessage,Tdata){
returnnewResult(ResultEnum。SUCCESS。getCode,message,data);
}
publicstaticResultlt;?failed{
returnnewResult(ResultEnum。COMMONFAILED。getCode,ResultEnum。COMMONFAILED。getMessage,);
}
publicstaticResultlt;?failed(Stringmessage){
returnnewResult(ResultEnum。COMMONFAILED。getCode,message,);
}
publicstaticResultlt;?failed(IResulterrorResult){
returnnewResult(errorResult。getCode,errorResult。getMessage,);
}
publicstaticTResultTinstance(Integercode,Stringmessage,Tdata){
ResultTresultnewResult;
result。setCode(code);
result。setMessage(message);
result。setData(data);
returnresult;
}
}
统一返回结构后,在Controller中就可以使用了,但是每一个Controller都写这么一段最终封装的逻辑,这些都是很重复的工作,所以还要继续想办法进一步处理统一返回结构
统一包装处理
Spring中提供了一个类codeResponseBodyAdvicecode,能帮助我们实现上述需求
ResponseBodyAdvice是对Controller返回的内容在strongtoutiaooriginspanHttpMessageConverterstrong进行类型转换之前拦截,进行相应的处理操作后,再将结果返回给客户端。那这样就可以把统一包装的工作放到这个类里面。publicinterfaceResponseBodyAdviceT{
booleansupports(MethodParameterreturnType,Classlt;?extendsHttpMessageConverterlt;?converterType);
able
TbeforeBodyWrite(ableTbody,MethodParameterreturnType,MediaTypeselectedContentType,Classlt;?extendsHttpMessageConverterlt;?selectedConverterType,ServerHttpRequestrequest,ServerHttpResponseresponse);
}
supports:判断是否要交给beforeBodyWrite方法执行,ture:需要;false:不需要
beforeBodyWrite:对response进行具体的处理如果引入了swagger或knife4j的文档生成组件,这里需要仅扫描自己项目的包,否则文档无法正常生成
RestControllerAdvice(basePackagescom。example。demo)
publicclassResponseAdviceimplementsResponseBodyAdviceObject{
Override
publicbooleansupports(MethodParameterreturnType,Classlt;?extendsHttpMessageConverterlt;?converterType){
如果不需要进行封装的,可以添加一些校验手段,比如添加标记排除的注解
returntrue;
}
Override
publicObjectbeforeBodyWrite(Objectbody,MethodParameterreturnType,MediaTypeselectedContentType,Classlt;?extendsHttpMessageConverterlt;?selectedConverterType,ServerHttpRequestrequest,ServerHttpResponseresponse){
提供一定的灵活度,如果body已经被包装了,就不进行包装
if(bodyinstanceofResult){
returnbody;
}
returnResult。success(body);
}
}
经过这样改造,既能实现对Controller返回的数据进行统一包装,又不需要对原有代码进行大量的改动
处理cannotbecasttojava。lang。String问题
如果直接使用ResponseBodyAdvice,对于一般的类型都没有问题,当处理字符串类型时,会抛出blockquotetoutiaoorigincodexxx。包装类cannotbecasttojava。lang。Stringblockquote的类型转换的异常
在ResponseBodyAdvice实现类中debug发现,只有String类型的strongtoutiaooriginspanselectedConverterTypestrong参数值是blockquotetoutiaooriginspanorg。springframework。http。converter。StringHttpMessageConverterblockquote,而其他数据类型的值是blockquotetoutiaooriginspanorg。springframework。http。converter。json。MappingJackson2HttpMessageConverterblockquote
String类型
其他类型(如Integer类型)
现在问题已经较为清晰了,因为我们需要返回一个codeResultcode对象
所以使用blockquotetoutiaoorigincodeMappingJackson2HttpMessageConverterblockquote是可以正常转换的
而使用codestrongtoutiaoorigincodeStringHttpMessageConverterstrongcode字符串转换器会导致类型转换失败
现在处理这个问题有两种方式
在beforeBodyWrite方法处进行判断,如果返回值是String类型就对codeResultcode对象手动进行转换成JSON字符串,另外方便前端使用,最好在codeRequestMappingcode中指定ContentTypeRestControllerAdvice(basePackagescom。example。demo)
publicclassResponseAdviceimplementsResponseBodyAdviceObject{
。。。
Override
publicObjectbeforeBodyWrite(Objectbody,MethodParameterreturnType,MediaTypeselectedContentType,Classlt;?extendsHttpMessageConverterlt;?selectedConverterType,ServerHttpRequestrequest,ServerHttpResponseresponse){
提供一定的灵活度,如果body已经被包装了,就不进行包装
if(bodyinstanceofResult){
returnbody;
}
如果返回值是String类型,那就手动把Result对象转换成JSON字符串
if(bodyinstanceofString){
try{
returnthis。objectMapper。writeValueAsString(Result。success(body));
}catch(JsonProcessingExceptione){
thrownewRuntimeException(e);
}
}
returnResult。success(body);
}
。。。
}
GetMapping(valuereturnString,producesapplicationjson;charsetUTF8)
publicStringreturnString{
returnsuccess;
}
修改strongtoutiaoorigincodeHttpMessageConverterstrong实例集合中MappingJackson2HttpMessageConverter的顺序。因为发生上述问题的根源所在是集合中strongtoutiaooriginspanStringHttpMessageConverterstrong的顺序先于blockquotetoutiaooriginspanMappingJackson2HttpMessageConverterblockquote的,调整顺序后即可从根源上解决这个问题
网上有不少做法是直接在集合中第一位添加blockquotetoutiaooriginspanMappingJackson2HttpMessageConverterblockquoteConfiguration
publicclassWebConfigurationimplementsWebMvcConfigurer{
Override
publicvoidconfigureMessageConverters(ListHttpMessageConverterlt;?converters){
converters。add(0,newMappingJackson2HttpMessageConverter);
}
}
诚然,这种方式可以解决问题,但其实问题的根源不是集合中缺少这一个转换器,而是转换器的顺序导致的,所以最合理的做法应该是调整blockquotetoutiaooriginspanMappingJackson2HttpMessageConverterblockquote在集合中的顺序Configuration
publicclassWebMvcConfigurationimplementsWebMvcConfigurer{
交换MappingJackson2HttpMessageConverter与第一位元素
让返回值类型为String的接口能正常返回包装结果
paramconvertersinitiallyanemptylistofconverters
Override
publicvoidconfigureMessageConverters(ListHttpMessageConverterlt;?converters){
for(inti0;iconverters。size;i){
if(converters。get(i)instanceofMappingJackson2HttpMessageConverter){
MappingJackson2HttpMessageConvertermappingJackson2HttpMessageConverter(MappingJackson2HttpMessageConverter)converters。get(i);
converters。set(i,converters。get(0));
converters。set(0,mappingJackson2HttpMessageConverter);
break;
}
}
}
}
参数校验
JavaAPI的规范JSR303定义了校验的标准validationapi,其中一个比较出名的实现是hibernatevalidation,springvalidation是对其的二次封装,常用于SpringMVC的参数自动校验,参数校验的代码就不需要再与业务逻辑代码进行耦合了
PathVariable和RequestParam参数校验
Get请求的参数接收一般依赖这两个注解,但是处于url有长度限制和代码的可维护性,超过5个参数尽量用实体来传参
对PathVariable和RequestParam参数进行校验需要在入参声明约束的注解
如果校验失败,会抛出strongtoutiaooriginspanMethodArgumentNotValidExceptionstrong异常RestController(valueprettyTestController)
RequestMapping(pretty)
Validated
publicclassTestController{
privateTestServicetestService;
GetMapping({num})
publicIntegerdetail(PathVariable(num)Min(1)Max(20)Integernum){
returnnumnum;
}
GetMapping(getByEmail)
publicTestDTOgetByAccount(RequestParamNotBlankEmailStringemail){
TestDTOtestDTOnewTestDTO;
testDTO。setEmail(email);
returntestDTO;
}
Autowired
publicvoidsetTestService(TestServiceprettyTestService){
this。testServiceprettyTestService;
}
}
校验原理
在SpringMVC中,有一个类是RequestResponseBodyMethodProcessor,这个类有两个作用(实际上可以从名字上得到一点启发)
用于解析RequestBody标注的参数
处理ResponseBody标注方法的返回值
解析RequestBoyd标注参数的方法是resolveArgumentpublicclassRequestResponseBodyMethodProcessorextendsAbstractMessageConverterMethodProcessor{
ThrowsMethodArgumentNotValidExceptionifvalidationfails。
throwsHttpMessageNotReadableExceptionif{linkRequestBodyrequired}
is{codetrue}andthereisnobodycontentorifthereisnosuitable
convertertoreadthecontentwith。
Override
publicObjectresolveArgument(MethodParameterparameter,ableModelAndViewContainermavContainer,
NativeWebRequestwebRequest,ableWebDataBinderFactorybinderFactory)throwsException{
parameterparameter。nestedIfOptional;
把请求数据封装成标注的DTO对象
ObjectargreadWithMessageConverters(webRequest,parameter,parameter。getNestedGenericParameterType);
StringnameConventions。getVariableNameForParameter(parameter);
if(binderFactory!){
WebDataBinderbinderbinderFactory。createBinder(webRequest,arg,name);
if(arg!){
执行数据校验
validateIfApplicable(binder,parameter);
如果校验不通过,就抛出MethodArgumentNotValidException异常
如果我们不自己捕获,那么最终会由DefaultHandlerExceptionResolver捕获处理
if(binder。getBindingResult。hasErrorsisBindExceptionRequired(binder,parameter)){
thrownewMethodArgumentNotValidException(parameter,binder。getBindingResult);
}
}
if(mavContainer!){
mavContainer。addAttribute(BindingResult。MODELKEYPREFIXname,binder。getBindingResult);
}
}
returnadaptArgumentIfNecessary(arg,parameter);
}
}
publicabstractclassAbstractMessageConverterMethodArgumentResolverimplementsHandlerMethodArgumentResolver{
Validatethebindingtargetifapplicable。
pThedefaultimplementationchecksfor{codejavax。validation。Valid},
Springs{linkorg。springframework。validation。annotation。Validated},
andcustomannotationswhosenamestartswithValid。
parambindertheDataBindertobeused
paramparameterthemethodparameterdescriptor
since4。1。5
seeisBindExceptionRequired
protectedvoidvalidateIfApplicable(WebDataBinderbinder,MethodParameterparameter){
获取参数上的所有注解
Annotationannotationsparameter。getParameterAnnotations;
for(Annotationann:annotations){
如果注解中包含了Valid、Validated或者是名字以Valid开头的注解就进行参数校验
ObjectvalidationHintsValidationAnnotationUtils。determineValidationHints(ann);
if(validationHints!){
实际校验逻辑,最终会调用HibernateValidator执行真正的校验
所以SpringValidation是对HibernateValidation的二次封装
binder。validate(validationHints);
break;
}
}
}
}
RequestBody参数校验
Post、Put请求的参数推荐使用RequestBody请求体参数
对RequestBody参数进行校验需要在DTO对象中加入校验条件后,再搭配Validated即可完成自动校验
如果校验失败,会抛出strongtoutiaoorigincodeConstraintViolationExceptionstrong异常DTO
Data
publicclassTestDTO{
NotBlank
privateStringuserName;
NotBlank
Length(min6,max20)
privateStringpassword;
Not
Email
privateStringemail;
}
Controller
RestController(valueprettyTestController)
RequestMapping(pretty)
publicclassTestController{
privateTestServicetestService;
PostMapping(testvalidation)
publicvoidtestValidation(RequestBodyValidatedTestDTOtestDTO){
this。testService。save(testDTO);
}
Autowired
publicvoidsetTestService(TestServicetestService){
this。testServicetestService;
}
}校验原理
声明约束的方式,注解加到了参数上面,可以比较容易猜测到是使用了AOP对方法进行增强
而实际上Spring也是通过strongtoutiaooriginspanMethodValidationPostProcessorstrong动态注册AOP切面,然后使用strongtoutiaoorigincodeMethodValidationInterceptorstrong对切点方法进行织入增强publicclassMethodValidationPostProcessorextendsAbstractBeanFactoryAwareAdvisingPostProcessorimplementsInitializingBean{
指定了创建切面的Bean的注解
privateClasslt;?extendsAnnotationvalidatedAnnotationTypeValidated。class;
Override
publicvoidafterPropertiesSet{
为所有Validated标注的Bean创建切面
PointcutpointcutnewAnnotationMatchingPointcut(this。validatedAnnotationType,true);
创建Advisor进行增强
this。advisornewDefaultPointcutAdvisor(pointcut,createMethodValidationAdvice(this。validator));
}
创建Advice,本质就是一个方法拦截器
protectedAdvicecreateMethodValidationAdvice(ableValidatorvalidator){
return(validator!?newMethodValidationInterceptor(validator):newMethodValidationInterceptor);
}
}
publicclassMethodValidationInterceptorimplementsMethodInterceptor{
Override
publicObjectinvoke(MethodInvocationinvocation)throwsThrowable{
无需增强的方法,直接跳过
if(isFactoryBeanMetadataMethod(invocation。getMethod)){
returninvocation。proceed;
}
Classlt;?groupsdetermineValidationGroups(invocation);
ExecutableValidatorexecValthis。validator。forExecutables;
MethodmethodToValidateinvocation。getMethod;
SetConstraintViolationObjectresult;
try{
方法入参校验,最终还是委托给HibernateValidator来校验
所以SpringValidation是对HibernateValidation的二次封装
resultexecVal。validateParameters(
invocation。getThis,methodToValidate,invocation。getArguments,groups);
}
catch(IllegalArgumentExceptionex){
。。。
}
校验不通过抛出ConstraintViolationException异常
if(!result。isEmpty){
thrownewConstraintViolationException(result);
}
Controller方法调用
ObjectreturnValueinvocation。proceed;
下面是对返回值做校验,流程和上面大概一样
resultexecVal。validateReturnValue(invocation。getThis,methodToValidate,returnValue,groups);
if(!result。isEmpty){
thrownewConstraintViolationException(result);
}
returnreturnValue;
}
}
自定义校验规则
有些时候JSR303标准中提供的校验规则不满足复杂的业务需求,也可以自定义校验规则
自定义校验规则需要做两件事情
自定义注解类,定义错误信息和一些其他需要的内容
注解校验器,定义判定规则自定义注解类
Target({ElementType。METHOD,ElementType。FIELD,ElementType。ANNOTATIONTYPE,ElementType。CONSTRUCTOR,ElementType。PARAMETER})
Retention(RetentionPolicy。RUNTIME)
Documented
Constraint(validatedByMobileValidator。class)
publicinterfaceMobile{
是否允许为空
booleanrequireddefaulttrue;
校验不通过返回的提示信息
Stringmessagedefault不是一个手机号码格式;
Constraint要求的属性,用于分组校验和扩展,留空就好
Classlt;?groupsdefault{};
Classlt;?extendsPayloadpayloaddefault{};
}
注解校验器
publicclassMobileValidatorimplementsConstraintValidatorMobile,CharSequence{
privatebooleanrequiredfalse;
privatefinalPatternpatternPattern。compile(1〔34578〕〔09〕{9});验证手机号
在验证开始前调用注解里的方法,从而获取到一些注解里的参数
paramconstraintAnnotationannotationinstanceforagivenconstraintdeclaration
Override
publicvoidinitialize(MobileconstraintAnnotation){
this。requiredconstraintAnnotation。required;
}
判断参数是否合法
paramvalueobjecttovalidate
paramcontextcontextinwhichtheconstraintisevaluated
Override
publicbooleanisValid(CharSequencevalue,ConstraintValidatorContextcontext){
if(this。required){
验证
returnisMobile(value);
}
if(StringUtils。hasText(value)){
验证
returnisMobile(value);
}
returntrue;
}
privatebooleanisMobile(finalCharSequencestr){
Matchermpattern。matcher(str);
returnm。matches;
}
}
自动校验参数真的是一项非常必要、非常有意义的工作。JSR303提供了丰富的参数校验规则,再加上复杂业务的自定义校验规则,完全把参数校验和业务逻辑解耦开,代码更加简洁,符合单一职责原则。
自定义异常与统一拦截异常
原来的代码中可以看到有几个问题
抛出的异常不够具体,只是简单地把错误信息放到了Exception中
抛出异常后,Controller不能具体地根据异常做出反馈
虽然做了参数自动校验,但是异常返回结构和正常返回结构不一致
自定义异常是为了后面统一拦截异常时,对业务中的异常有更加细颗粒度的区分,拦截时针对不同的异常作出不同的响应
而统一拦截异常的目的一个是为了可以与前面定义下来的统一包装返回结构能对应上,另一个是我们希望无论系统发生什么异常,Http的状态码都要是200,尽可能由业务来区分系统的异常自定义异常
publicclassForbiddenExceptionextendsRuntimeException{
publicForbiddenException(Stringmessage){
super(message);
}
}
自定义异常
publicclassBusinessExceptionextendsRuntimeException{
publicBusinessException(Stringmessage){
super(message);
}
}
统一拦截异常
RestControllerAdvice(basePackagescom。example。demo)
publicclassExceptionAdvice{
捕获{codeBusinessException}异常
ExceptionHandler({BusinessException。class})
publicResultlt;?handleBusinessException(BusinessExceptionex){
returnResult。failed(ex。getMessage);
}
捕获{codeForbiddenException}异常
ExceptionHandler({ForbiddenException。class})
publicResultlt;?handleForbiddenException(ForbiddenExceptionex){
returnResult。failed(ResultEnum。FORBIDDEN);
}
{codeRequestBody}参数校验不通过时抛出的异常处理
ExceptionHandler({MethodArgumentNotValidException。class})
publicResultlt;?handleMethodArgumentNotValidException(MethodArgumentNotValidExceptionex){
BindingResultbindingResultex。getBindingResult;
StringBuildersbnewStringBuilder(校验失败:);
for(FieldErrorfieldError:bindingResult。getFieldErrors){
sb。append(fieldError。getField)。append(:)。append(fieldError。getDefaultMessage)。append(,);
}
Stringmsgsb。toString;
if(StringUtils。hasText(msg)){
returnResult。failed(ResultEnum。VALIDATEFAILED。getCode,msg);
}
returnResult。failed(ResultEnum。VALIDATEFAILED);
}
{codePathVariable}和{codeRequestParam}参数校验不通过时抛出的异常处理
ExceptionHandler({ConstraintViolationException。class})
publicResultlt;?handleConstraintViolationException(ConstraintViolationExceptionex){
if(StringUtils。hasText(ex。getMessage)){
returnResult。failed(ResultEnum。VALIDATEFAILED。getCode,ex。getMessage);
}
returnResult。failed(ResultEnum。VALIDATEFAILED);
}
顶级异常捕获并统一处理,当其他异常无法处理时候选择使用
ExceptionHandler({Exception。class})
publicResultlt;?handle(Exceptionex){
returnResult。failed(ex。getMessage);
}
}
总结
做好了这一切改动后,可以发现Controller的代码变得非常简洁,可以很清楚地知道每一个参数、每一个DTO的校验规则,可以很明确地看到每一个Controller方法返回的是什么数据,也可以方便每一个异常应该如何进行反馈
这一套操作下来后,我们能更加专注于业务逻辑的开发,代码简洁、功能完善,何乐而不为呢?
来源:https:juejin。cnpost7123091045071454238