Controller层代码就该这么写,简洁又优雅
一个优秀的Controller层逻辑
说到Controller,相信大家都不陌生,它可以很方便地对外提供数据接口。它的定位,我认为是不可或缺的配角,说它不可或缺是因为无论是传统的三层架构还是现在的COLA架构,Controller层依旧有一席之地,说明他的必要性;说它是配角是因为Controller层的代码一般是不负责具体的逻辑业务逻辑实现,但是它负责接收和响应请求从现状看问题
Controller主要的工作有以下几项接收请求并解析参数调用Service执行具体的业务代码(可能包含参数校验)捕获业务逻辑异常做出反馈业务逻辑执行成功做出响应DTODatapublicclassTestDTO{privateIntegernum;privateStringtype;}ServiceServicepublicclassTestService{publicDoubleservice(TestDTOtestDTO)throwsException{if(testDTO。getNum()0){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(未识别的算法);}}ControllerRestControllerpublicclassTestController{privateTestServicetestService;PostMapping(test)publicDoubletest(RequestBodyTestDTOtestDTO){try{Doubleresultthis。testService。service(testDTO);returnresult;}catch(Exceptione){thrownewRuntimeException(e);}}AutowiredpublicDTOidsetTestService(TestServicetestService){this。testServicetestService;}}
如果真的按照上面所列的工作项来开发Controller代码会有几个问题参数校验过多地耦合了业务代码,违背单一职责原则可能在多个业务中都抛出同一个异常,导致代码重复各种异常反馈和成功响应格式不统一,接口对接不友好改造Controller层逻辑统一返回结构
统一返回值类型无论项目前后端是否分离都是非常必要的,方便对接接口的开发人员更加清晰地知道这个接口的调用是否成功(不能仅仅简单地看返回值是否为null就判断成功与否,因为有些接口的设计就是如此),使用一个状态码、状态信息就能清楚地了解接口调用情况定义返回数据结构publicinterfaceIResult{IntegergetCode();StringgetMessage();}常用结果的枚举publicenumResultEnumimplementsIResult{SUCCESS(2001,接口调用成功),VALIDATEFAILED(2002,参数校验失败),COMMONFAILED(2003,接口调用失败),FORBIDDEN(2004,没有权限访问资源);privateIntegercode;privateStringmessage;省略get、set方法和构造方法}统一返回数据结构DataNoArgsConstructorAllArgsConstructorpublicclassResultT{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(),null);}publicstaticResultlt;?failed(Stringmessage){returnnewResult(ResultEnum。COMMONFAILED。getCode(),message,null);}publicstaticResultlt;?failed(IResulterrorResult){returnnewResult(errorResult。getCode(),errorResult。getMessage(),null);}publicstaticTResultTinstance(Integercode,Stringmessage,Tdata){ResultTresultnewResult();result。setCode(code);result。setMessage(message);result。setData(data);returnresult;}}
统一返回结构后,在Controller中就可以使用了,但是每一个Controller都写这么一段最终封装的逻辑,这些都是很重复的工作,所以还要继续想办法进一步处理统一返回结构统一包装处理
Spring中提供了一个类ResponseBodyAdvice,能帮助我们实现上述需求
ResponseBodyAdvice是对Controller返回的内容在HttpMessageConverter进行类型转换之前拦截,进行相应的处理操作后,再将结果返回给客户端。那这样就可以把统一包装的工作放到这个类里面。publicinterfaceResponseBodyAdviceT{booleansupports(MethodParameterreturnType,Classlt;?extendsHttpMessageConverterlt;?converterType);NullableTbeforeBodyWrite(NullableTbody,MethodParameterreturnType,MediaTypeselectedContentType,Classlt;?extendsHttpMessageConverterlt;?selectedConverterType,ServerHttpRequestrequest,ServerHttpResponseresponse);}supports:判断是否要交给beforeBodyWrite方法执行,ture:需要;false:不需要beforeBodyWrite:对response进行具体的处理如果引入了swagger或knife4j的文档生成组件,这里需要仅扫描自己项目的包,否则文档无法正常生成RestControllerAdvice(basePackagescom。example。demo)publicclassResponseAdviceimplementsResponseBodyAdviceObject{Overridepublicbooleansupports(MethodParameterreturnType,Classlt;?extendsHttpMessageConverterlt;?converterType){如果不需要进行封装的,可以添加一些校验手段,比如添加标记排除的注解returntrue;}OverridepublicObjectbeforeBodyWrite(Objectbody,MethodParameterreturnType,MediaTypeselectedContentType,Classlt;?extendsHttpMessageConverterlt;?selectedConverterType,ServerHttpRequestrequest,ServerHttpResponseresponse){提供一定的灵活度,如果body已经被包装了,就不进行包装if(bodyinstanceofResult){returnbody;}returnResult。success(body);}}
经过这样改造,既能实现对Controller返回的数据进行统一包装,又不需要对原有代码进行大量的改动处理cannotbecasttojava。lang。String问题
如果直接使用ResponseBodyAdvice,对于一般的类型都没有问题,当处理字符串类型时,会抛出xxx。包装类cannotbecasttojava。lang。String的类型转换的异常
在ResponseBodyAdvice实现类中debug发现,只有String类型的selectedConverterType参数值是org。springframework。http。converter。StringHttpMessageConverter,而其他数据类型的值是org。springframework。http。converter。json。MappingJackson2HttpMessageConverterString类型
img其他类型(如Integer类型)
现在问题已经较为清晰了,因为我们需要返回一个Result对象
所以使用MappingJackson2HttpMessageConverter是可以正常转换的
而使用StringHttpMessageConverter字符串转换器会导致类型转换失败
现在处理这个问题有两种方式在beforeBodyWrite方法处进行判断,如果返回值是String类型就对Result对象手动进行转换成JSON字符串,另外方便前端使用,最好在RequestMapping中指定ContentTypeRestControllerAdvice(basePackagescom。example。demo)publicclassResponseAdviceimplementsResponseBodyAdviceObject{。。。OverridepublicObjectbeforeBodyWrite(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;}修改HttpMessageConverter实例集合中MappingJackson2HttpMessageConverter的顺序。因为发生上述问题的根源所在是集合中StringHttpMessageConverter的顺序先于MappingJackson2HttpMessageConverter的,调整顺序后即可从根源上解决这个问题网上有不少做法是直接在集合中第一位添加MappingJackson2HttpMessageConverterConfigurationpublicclassWebConfigurationimplementsWebMvcConfigurer{OverridepublicvoidconfigureMessageConverters(ListHttpMessageConverterlt;?converters){converters。add(0,newMappingJackson2HttpMessageConverter());}}诚然,这种方式可以解决问题,但其实问题的根源不是集合中缺少这一个转换器,而是转换器的顺序导致的,所以最合理的做法应该是调整MappingJackson2HttpMessageConverter在集合中的顺序ConfigurationpublicclassWebMvcConfigurationimplementsWebMvcConfigurer{交换MappingJackson2HttpMessageConverter与第一位元素让返回值类型为String的接口能正常返回包装结果paramconvertersinitiallyanemptylistofconvertersOverridepublicvoidconfigureMessageConverters(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参数进行校验需要在入参声明约束的注解
如果校验失败,会抛出MethodArgumentNotValidException异常RestController(valueprettyTestController)RequestMapping(pretty)ValidatedpublicclassTestController{privateTestServicetestService;GetMapping({num})publicIntegerdetail(PathVariable(num)Min(1)Max(20)Integernum){returnnumnum;}GetMapping(getByEmail)publicTestDTOgetByAccount(RequestParamNotBlankEmailStringemail){TestDTOtestDTOnewTestDTO();testDTO。setEmail(email);returntestDTO;}AutowiredpublicvoidsetTestService(TestServiceprettyTestService){this。testServiceprettyTestService;}}校验原理
在SpringMVC中,有一个类是RequestResponseBodyMethodProcessor,这个类有两个作用(实际上可以从名字上得到一点启发)用于解析RequestBody标注的参数处理ResponseBody标注方法的返回值
解析RequestBoyd标注参数的方法是resolveArgumentpublicclassRequestResponseBodyMethodProcessorextendsAbstractMessageConverterMethodProcessor{ThrowsMethodArgumentNotValidExceptionifvalidationfails。throwsHttpMessageNotReadableExceptionif{linkRequestBodyrequired()}is{codetrue}andthereisnobodycontentorifthereisnosuitableconvertertoreadthecontentwith。OverridepublicObjectresolveArgument(MethodParameterparameter,NullableModelAndViewContainermavContainer,NativeWebRequestwebRequest,NullableWebDataBinderFactorybinderFactory)throwsException{parameterparameter。nestedIfOptional();把请求数据封装成标注的DTO对象ObjectargreadWithMessageConverters(webRequest,parameter,parameter。getNestedGenericParameterType());StringnameConventions。getVariableNameForParameter(parameter);if(binderFactory!null){WebDataBinderbinderbinderFactory。createBinder(webRequest,arg,name);if(arg!null){执行数据校验validateIfApplicable(binder,parameter);如果校验不通过,就抛出MethodArgumentNotValidException异常如果我们不自己捕获,那么最终会由DefaultHandlerExceptionResolver捕获处理if(binder。getBindingResult()。hasErrors()isBindExceptionRequired(binder,parameter)){thrownewMethodArgumentNotValidException(parameter,binder。getBindingResult());}}if(mavContainer!null){mavContainer。addAttribute(BindingResult。MODELKEYPREFIXname,binder。getBindingResult());}}returnadaptArgumentIfNecessary(arg,parameter);}}publicabstractclassAbstractMessageConverterMethodArgumentResolverimplementsHandlerMethodArgumentResolver{Validatethebindingtargetifapplicable。pThedefaultimplementationchecksfor{codejavax。validation。Valid},Springs{linkorg。springframework。validation。annotation。Validated},andcustomannotationswhosenamestartswithValid。parambindertheDataBindertobeusedparamparameterthemethodparameterdescriptorsince4。1。5seeisBindExceptionRequiredprotectedvoidvalidateIfApplicable(WebDataBinderbinder,MethodParameterparameter){获取参数上的所有注解Annotation〔〕annotationsparameter。getParameterAnnotations();for(Annotationann:annotations){如果注解中包含了Valid、Validated或者是名字以Valid开头的注解就进行参数校验Object〔〕validationHintsValidationAnnotationUtils。determineValidationHints(ann);if(validationHints!null){实际校验逻辑,最终会调用HibernateValidator执行真正的校验所以SpringValidation是对HibernateValidation的二次封装binder。validate(validationHints);break;}}}}RequestBody参数校验
Post、Put请求的参数推荐使用RequestBody请求体参数
对RequestBody参数进行校验需要在DTO对象中加入校验条件后,再搭配Validated即可完成自动校验
如果校验失败,会抛出ConstraintViolationException异常DTODatapublicclassTestDTO{NotBlankprivateStringuserName;NotBlankLength(min6,max20)privateStringpassword;NotNullEmailprivateStringemail;}ControllerRestController(valueprettyTestController)RequestMapping(pretty)publicclassTestController{privateTestServicetestService;PostMapping(testvalidation)publicvoidtestValidation(RequestBodyValidatedTestDTOtestDTO){this。testService。save(testDTO);}AutowiredpublicvoidsetTestService(TestServicetestService){this。testServicetestService;}}校验原理
声明约束的方式,注解加到了参数上面,可以比较容易猜测到是使用了AOP对方法进行增强
而实际上Spring也是通过MethodValidationPostProcessor动态注册AOP切面,然后使用MethodValidationInterceptor对切点方法进行织入增强publicclassMethodValidationPostProcessorextendsAbstractBeanFactoryAwareAdvisingPostProcessorimplementsInitializingBean{指定了创建切面的Bean的注解privateClasslt;?extendsAnnotationvalidatedAnnotationTypeValidated。class;OverridepublicvoidafterPropertiesSet(){为所有Validated标注的Bean创建切面PointcutpointcutnewAnnotationMatchingPointcut(this。validatedAnnotationType,true);创建Advisor进行增强this。advisornewDefaultPointcutAdvisor(pointcut,createMethodValidationAdvice(this。validator));}创建Advice,本质就是一个方法拦截器protectedAdvicecreateMethodValidationAdvice(NullableValidatorvalidator){return(validator!null?newMethodValidationInterceptor(validator):newMethodValidationInterceptor());}}publicclassMethodValidationInterceptorimplementsMethodInterceptor{OverridepublicObjectinvoke(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)DocumentedConstraint(validatedByMobileValidator。class)publicinterfaceMobile{是否允许为空booleanrequired()defaulttrue;校验不通过返回的提示信息Stringmessage()default不是一个手机号码格式;Constraint要求的属性,用于分组校验和扩展,留空就好Classlt;?〔〕groups()default{};Classlt;?extendsPayload〔〕payload()default{};}注解校验器publicclassMobileValidatorimplementsConstraintValidatorMobile,CharSequence{privatebooleanrequiredfalse;privatefinalPatternpatternPattern。compile(1〔34578〕〔09〕{9}34;);验证手机号在验证开始前调用注解里的方法,从而获取到一些注解里的参数paramconstraintAnnotationannotationinstanceforagivenconstraintdeclarationOverridepublicvoidinitialize(MobileconstraintAnnotation){this。requiredconstraintAnnotation。required();}判断参数是否合法paramvalueobjecttovalidateparamcontextcontextinwhichtheconstraintisevaluatedOverridepublicbooleanisValid(CharSequencevalue,ConstraintValidatorContextcontext){if(this。required){验证returnisMobile(value);}if(StringUtils。hasText(value)){验证returnisMobile(value);}returntrue;}privatebooleanisMobile(finalCharSequencestr){Matchermpattern。matcher(str);returnm。matches();}}
自动校验参数真的是一项非常必要、非常有意义的工作。JSR303提供了丰富的参数校验规则,再加上复杂业务的自定义校验规则,完全把参数校验和业务逻辑解耦开,代码更加简洁,符合单一职责原则。
更多关于Spring参数校验请参考:SpringValidation最佳实践及其实现原理,参数校验没那么简单!自定义异常与统一拦截异常
原来的代码中可以看到有几个问题抛出的异常不够具体,只是简单地把错误信息放到了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:mp。weixin。qq。comsK5YGdEomTeX92l15Hv0A
单场45分9篮板,获赞女版奥尼尔!她为何会落选东京奥运?前几天,是20212022赛季WCBA联赛的揭幕战,作为上赛季的WCBA总冠军内蒙古女篮和大学生联合队展开了一场较量。结果自然是职业女篮无论是节奏还是强度上,都是碾压大学……
全国究竟有多少个鸡鸣三省的景点?至少这三个地方不可或缺所谓鸡鸣三省,指的是三省相交之地,一地鸡鸣而达周边三省。鸡鸣三省以前往往用来形容鸡犬之声相闻,老死不相往来的小国寡民思想,但在交通发达的现代,则成了虽有省界相隔,但割不断相邻村……
行动,大于一切有没有那么一瞬间,你讨厌自己的行动力,想做一件事很久了。可是怎么都行动不起来。好久以前阅读到一篇文章,在今日头条写文章,做问答会有收益,作为一个宝妈来说,知道这个消息是高……
讽刺大师斯威夫特讽刺小说家乔纳森斯威夫特(有译为江奈生斯威夫特)是英国启蒙运动中激进民主派的创始人,在世期间写了很多具有代表性的讽刺文章,他被称为英国十八世纪杰出的政论家和讽刺小说家。他出生于……
被松下停产的蓝光CD,竟然被互联网大厂们给请了回来最近,咱们编辑部传来了一个不太好的消息。松下表示:因为近年互联网的普及,蓝光光盘的需求量越来越少,目前蓝光光盘业务已接近亏本的程度,不得已关停止损。这跟着消失的不仅……
泰拳扫腿杀伤力最大的腿法泰拳的扫腿是公认杀伤力最大的腿法。泰拳(muaythai)属于纯粹的硬功,拳手需要经历长期且大量的踢沙袋和连续踢手靶的训练,甚至还有引力带专门训练腿部力量的训练,以及长跑,登山……
二十年游历分享之153中东8以色列8古老文明与现代科技的碰撞我是今日头条的赵先生,在头条持续更新我的游记《二十年游历分享》,介绍世界上主要景点。本篇文章共有13张照片(没有特殊声明的照片都是赵先生自己拍摄的),0个音频,0个视频。……
夜读新的一年,自在生活的12个小方法听过一个很有意思的三七法则:一部手机,70的功能是很少用的;家里的东西,70都是不常用的。很多时候我们过得不快乐,是因为我们总在纠结那无关紧要的70,却没有珍惜最重要的30。……
喝酒后,若身体出现这4个现象,说明你已不适合喝酒,建议早戒酒俗话说:无酒不成席。中国的酒文化已经延续了上千年,人们一直讲究把酒言欢,酒作为很多场合的催化剂,更成为一种常见饮品。小酒怡情,大酒伤身!长期饮酒对身体的伤害是非常大的,所……
擺评达尔优H101无线手柄上手体验前一阵玩老头环的时候,才发现有时候用手柄玩游戏确实比键盘的体验更好一些。更主要的原因也是,我媳妇特别喜欢玩坦克大战,本来自己是有一个有线手柄的,但是为了双打就又买了一个,这次选……
冬日芦花怒放,荡漾在白马河岸12月6日,寒风瑟瑟,郯城县马头镇白马河岸边一片片芦苇荡扬起了毛绒绒的芦花,仿佛给人们送来越冬的棉衣。高军摄冬日芦花怒放,荡漾在白马河岸冬日芦花怒放,荡漾在白马河岸……
实用角色越来越多,曾经的三防角色蝴蝶还有返场的必要吗?嗨咯,各位小伙伴们大家好呀!提起游戏中的蝴蝶角色,相信大家都不陌生吧?作为曾经游戏中的首款三防属性角色,蝴蝶的出现确实是让很多玩家感到眼前一亮!姣好的角色面容、曼妙的角色……