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
小米MIXFold2渲染图7000mAh8英寸大屏,售价可能最近折叠屏可谓是机圈热词,先是OPPO发布了便携度极高的OPPOFindN,再到华为首款纵向折叠屏华为P50Pocket的预热,这些手机不断突破着折叠屏的定义与门槛,让大家对折……
美国一颗报废卫星或于9日坠落朝鲜半岛美国ERBS地球探测卫星(美国航天局)据海外网1月9日电据韩国《中央日报》报道,韩国科学技术信息通信部1月8日表示,美国航天局一颗报废卫星正在坠入地球大气层,残骸可能于今……
俄飞船已返回地球并在哈萨克斯坦着陆12月20日消息,据俄媒从莫斯科州飞行控制中心报道,载有从国际空间站返回的太空游客前泽友作、平野洋三和俄罗斯宇航员亚历山大米苏尔金的联盟MS20飞船返回舱,已在杰兹卡兹甘市东南……
乒坛名将冠军王皓王皓,1983年12月1日出生于吉林省长春市,中国男子乒乓球队前运动员。(现已退役)为什么要写这篇文章,因为我是乒乓球粉丝,更是二王一马时代其中王皓的粉丝。虽然经历了科龙时代独……
小米13Pro和红米K60Pro有什么区别?小米和红米在今年12月份都推出自己旗下最新的旗舰手机,分别是小米13系以及红米K60系机型,其中小米13Pro以及红米K60Pro是各自系列中的最高配置版本,所以有不少的小伙伴……
DNF掩耳盗铃!旭旭宝宝神操作,提前充值,威胁策划DNF第一红眼旭旭宝宝,再次爆出神操作,深夜发文喊话策划,一月我一毛不充,这是发生了什么事?地下城与勇士旭旭宝宝神操作旭旭宝宝半夜三更不睡觉发文,鉴于DNF策划一整年的表……
物理学家发明了光波的智能量子传感器我是斜杠青年,一个热爱前沿科学的杂食性学者!来源:耶鲁大学夏奉年这种艺术渲染描绘了被称为莫雷超材料的二维材料的智能传感过程。超材料的量子几何性质决定了它如何响应传入……
双十一快递陆续到货,收快递正确打开方式11月13日,国家邮政局印发通知,要求进一步优化措施科学精准做好行业疫情防控工作。通知要求,要进一步优化完善行业疫情防控措施。要抓好各项制度规范落实。各级邮政管理部……
民生人社丨全年活动不间断!2023年我省将开展12项就业服务纵览客户端讯(燕赵都市报纵览新闻何晨曦)近日,河北省人社厅印发《关于开展2023年全省公共就业服务专项活动的通知》,聚焦高校毕业生、退役军人、脱贫劳动力、登记失业人员、农民工等……
散文古镇赵桥桃花香李虹昌文李虹昌今年的暮春来的也太快了,一天时间就仿佛身在江南。菏泽的大街小巷、小道旁有人竟然都穿起了短袖汗衫,整个鲁西南大地,一时间如同进入了盛夏。可我还没有来得及细细品味香草……
欧美经济深陷衰退之忧通胀高企和利率上升的双重挤压,令人们不堪重负图说一,全球货币紧缩已转入下半场,通胀仍是各大央行面对的棘手问题。摄《财经》记者金焱文《财经》特派记者金焱发自华盛顿……
中医医师的药方,仅供参考,希望能帮助大家甘草干(生)姜汤甘草干(生)姜汤具有解毒功效。甘草与生姜也是在生活中常用的食材和饮品。甘草生姜汤在中药对抗新冠的过程中有一定作用,显示出对这一人造蛋白毒物有一定的抑……