使用SpringCloud构建项目时,使用Swagger生成相应的接口文档是推荐的选项,Swagger能够提供页面访问,直接在网页上调试后端系统的接口, 非常方便。最近却遇到了一个有点困惑的问题,演示接口示例如下(原有功能接口带有业务实现逻辑,这里简化了接口):
/** * @description: 演示类 * @author: Huang Ying **/ @Api(tags = "演示类") @RestController @Slf4j public class DemoController { @ApiOperation(value = "测试接口") @ApiImplicitParams({ @ApiImplicitParam(name = "uid", value = "用户ID", paramType = "query", dataType = "Long") }) @RequestMapping(value = "/api/json/demo", method = RequestMethod.GET) public String auth(@RequestParam(value = "uid") Long uid) { System.out.println(uid); return "the uid: " + uid; } }
问题出在接口参数uid的必填性上, @RequestParam
注解里require默认为true,要求必填,但 @ApiImplicitParam
注解里require默认为false,要求非必填,该业务接口在进行功能联调时,uid居然能得到一个null值,按照一般认知习惯 @ApiImplicitParam
注解的主要作用是生成接口文档,不应该对 @RequestParam
的属性有侵入性才对,目前反馈的bug,让我怀疑 @ApiImplicitParam
是不是会侵入 @RequestParam
的require属性?
SpringBoot版本:2.1.6.RELEASE
SpringCloud版本:Greenwich.SR3
SpringCloud业务模块使用的swagger:
swagger bootstrap ui 1.9.6 增强swagger ui样式
spring4all-swagger 1.9.0.RELEASE 配置化swagger参数,免去代码开发
SpringCloud业务网关使用的swagger:
knife4j 2.0.1 增强swagger ui样式(网关用gateway搭建,swagger使用knife4j-spring-boot-starter依赖,可以聚合业务模块的swagger文档)
此次的范围只针对SpringCloud业务模块,暂时不涉及业务网关的Swagger文档。
测试工具目前有两个:
swagger doc:使用浏览器进行访问,如下图:
postman:手动配置接口参数,示例:
接口示例如开篇所示,我们先使用如下接口,全部使用默认值,即@ApiImplicitParam的required为false,@RequestParam的required为true:
@ApiOperation(value = "测试接口") @ApiImplicitParams({ @ApiImplicitParam(name = "uid", value = "用户ID", paramType = "query", dataType = "Long") }) @RequestMapping(value = "/api/json/demo", method = RequestMethod.GET) public String auth(@RequestParam(value = "uid") Long uid) { System.out.println(uid); return "the uid: " + uid; }
看swagger的结果:
看postman的结果:
我们修改@ApiImplicitParam的required值为true,@RequestParam不变,重启模块
@ApiImplicitParam(name = "uid", value = "用户ID", paramType = "query", required = true, dataType = "Long")
看swagger的结果:
通过调试浏览器可以发现,为空校验是js完成的,js判断为空后,并未发起请求到后端,这样我们可以认为swagger内@ApiImplicitParam的required参数生效了。
在前面我们使用postman测试接口时,发现参数项是空的,我们加上参数,但不写值测试后,结果让人诧异:
并且无论@ApiImplicitParam的required值如何修改,结果都是一样的,肯定有一个地方是搞错了,导致我们误判。
后来仔细查阅资料,发现是我们对@RequestParam的required参数理解错了,这个required为true的含义是:接口参数名一定要存在,但参数后面有没有值它管不着。拿刚刚的例子来说:
这两个请求是通过的: localhost:8080/api/json//demo?uid localhost:8080/api/json//demo?uid= 只有这种请求是不通过的: localhost:8080/api/json//demo?
经过上述三个接口的测试场景,我们至少可以明确3点:
上一节当中提及swagger读取@ApiImplicitParam注解的required参数,最终会体现在js上,通过浏览器F12的追踪,定位到swaggerbootstrapui.js文件上,这里摘抄部分源码:
# 点击发送按钮时,逐行读取参数信息,并提取required参数 paramBody.find("tr").each(function () { var paramtr=$(this); var cked=paramtr.find("td:first").find(":checked").prop("checked"); var _urlAppendflag=true; //that.log(cked) if (cked){ //如果选中,留意此行的required:paramtr.data("required")信息提取 var trdata={name:paramtr.find("td:eq(2)").find("input").val(),in:paramtr.data("in"),required:paramtr.data("required"),type:paramtr.data("type"),emflag:paramtr.data("emflag"),schemavalue:paramtr.data("schemavalue")}; //that.log("trdata....") //that.log(trdata); //获取key //var key=paramtr.find("td:eq(1)").find("input").val(); var key=trdata["name"]; //获取value var value=""; var reqflag=false; // 后面代码省略 } })
js上判断该属性required是否为true的处理,js源码如下:
//判断是否required if (trdata.hasOwnProperty("required")){ var required=trdata["required"]; if (required){ if(!reqflag){ //必须,验证value是否为空 if(value==null||value==""){ validateflag=true; var des=trdata["name"] //validateobj={message:des+"不能为空"}; validateobj={message:des+i18n.message.debug.fieldNotEmpty}; return false; } } } }
swagger前端js验证通过可以向后台发送请求,或者使用postman向后台系统发送请求时,开始进入后台的一系列过滤器、Servlet处理,东西还不少:
// 实际的业务方法部分 auth:28, DemoController (com.hy.demo.controller) invoke0:-1, NativeMethodAccessorImpl (sun.reflect) invoke:62, NativeMethodAccessorImpl (sun.reflect) invoke:43, DelegatingMethodAccessorImpl (sun.reflect) invoke:498, Method (java.lang.reflect) // 请求参数的提取、控制部分 doInvoke:190, InvocableHandlerMethod (org.springframework.web.method.support) invokeForRequest:138, InvocableHandlerMethod (org.springframework.web.method.support) invokeAndHandle:104, ServletInvocableHandlerMethod (org.springframework.web.servlet.mvc.method.annotation) invokeHandlerMethod:892, RequestMappingHandlerAdapter (org.springframework.web.servlet.mvc.method.annotation) handleInternal:797, RequestMappingHandlerAdapter (org.springframework.web.servlet.mvc.method.annotation) handle:87, AbstractHandlerMethodAdapter (org.springframework.web.servlet.mvc.method) // 下面是各种基础Web服务组件的过滤器等,暂时不关心 doDispatch:1039, DispatcherServlet (org.springframework.web.servlet) doService:942, DispatcherServlet (org.springframework.web.servlet) processRequest:1005, FrameworkServlet (org.springframework.web.servlet) doGet:897, FrameworkServlet (org.springframework.web.servlet) service:634, HttpServlet (javax.servlet.http) service:882, FrameworkServlet (org.springframework.web.servlet) service:741, HttpServlet (javax.servlet.http) internalDoFilter:231, ApplicationFilterChain (org.apache.catalina.core) doFilter:166, ApplicationFilterChain (org.apache.catalina.core) doFilter:53, WsFilter (org.apache.tomcat.websocket.server) internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core) doFilter:166, ApplicationFilterChain (org.apache.catalina.core) doFilter:84, SecurityBasicAuthFilter (com.github.xiaoymin.swaggerbootstrapui.filter) internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core) doFilter:166, ApplicationFilterChain (org.apache.catalina.core) doFilter:53, ProductionSecurityFilter (com.github.xiaoymin.swaggerbootstrapui.filter) internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core) doFilter:166, ApplicationFilterChain (org.apache.catalina.core) doFilter:124, WebStatFilter (com.alibaba.druid.support.http) internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core) doFilter:166, ApplicationFilterChain (org.apache.catalina.core) doFilterInternal:88, HttpTraceFilter (org.springframework.boot.actuate.web.trace.servlet) doFilter:109, OncePerRequestFilter (org.springframework.web.filter) internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core) doFilter:166, ApplicationFilterChain (org.apache.catalina.core) doFilterInternal:99, RequestContextFilter (org.springframework.web.filter) doFilter:109, OncePerRequestFilter (org.springframework.web.filter) internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core) doFilter:166, ApplicationFilterChain (org.apache.catalina.core) doFilterInternal:92, FormContentFilter (org.springframework.web.filter) doFilter:109, OncePerRequestFilter (org.springframework.web.filter) internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core) doFilter:166, ApplicationFilterChain (org.apache.catalina.core) doFilterInternal:93, HiddenHttpMethodFilter (org.springframework.web.filter) doFilter:109, OncePerRequestFilter (org.springframework.web.filter) internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core) doFilter:166, ApplicationFilterChain (org.apache.catalina.core) filterAndRecordMetrics:114, WebMvcMetricsFilter (org.springframework.boot.actuate.metrics.web.servlet) doFilterInternal:104, WebMvcMetricsFilter (org.springframework.boot.actuate.metrics.web.servlet) doFilter:109, OncePerRequestFilter (org.springframework.web.filter) internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core) doFilter:166, ApplicationFilterChain (org.apache.catalina.core) doFilterInternal:200, CharacterEncodingFilter (org.springframework.web.filter) doFilter:109, OncePerRequestFilter (org.springframework.web.filter) internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core) doFilter:166, ApplicationFilterChain (org.apache.catalina.core) invoke:202, StandardWrapperValve (org.apache.catalina.core) invoke:96, StandardContextValve (org.apache.catalina.core) invoke:490, AuthenticatorBase (org.apache.catalina.authenticator) invoke:139, StandardHostValve (org.apache.catalina.core) invoke:92, ErrorReportValve (org.apache.catalina.valves) invoke:74, StandardEngineValve (org.apache.catalina.core) service:343, CoyoteAdapter (org.apache.catalina.connector) service:408, Http11Processor (org.apache.coyote.http11) process:66, AbstractProcessorLight (org.apache.coyote) process:853, AbstractProtocol$ConnectionHandler (org.apache.coyote) doRun:1587, NioEndpoint$SocketProcessor (org.apache.tomcat.util.net) run:49, SocketProcessorBase (org.apache.tomcat.util.net) runWorker:1149, ThreadPoolExecutor (java.util.concurrent) run:624, ThreadPoolExecutor$Worker (java.util.concurrent) run:61, TaskThread$WrappingRunnable (org.apache.tomcat.util.threads) run:748, Thread (java.lang)
聚集重点在请求参数的读取校验方面,首先看 org.springframework.web.method.annotation.AbstractNamedValueMethodArgumentResolver
类的resolveArgument方法:
@Override @Nullable public final Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer, NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception { // 留意此方法调用 NamedValueInfo namedValueInfo = getNamedValueInfo(parameter); MethodParameter nestedParameter = parameter.nestedIfOptional(); Object resolvedName = resolveStringValue(namedValueInfo.name); if (resolvedName == null) { throw new IllegalArgumentException( "Specified name must not resolve to null: [" + namedValueInfo.name + "]"); } // 后面暂时省略 }
getNamedValueInfo
方法的实现如下:
/** * Obtain the named value for the given method parameter. */ private NamedValueInfo getNamedValueInfo(MethodParameter parameter) { NamedValueInfo namedValueInfo = this.namedValueInfoCache.get(parameter); if (namedValueInfo == null) { namedValueInfo = createNamedValueInfo(parameter); namedValueInfo = updateNamedValueInfo(parameter, namedValueInfo); this.namedValueInfoCache.put(parameter, namedValueInfo); } return namedValueInfo; }
进入 createNamedValueInfo(parameter)
方法时,这部分代码如下:
@Override protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { RequestParam ann = parameter.getParameterAnnotation(RequestParam.class); return (ann != null ? new RequestParamNamedValueInfo(ann) : new RequestParamNamedValueInfo()); } /** * NamedValueInfo的定义 * Represents the information about a named value, including name, whether it's required and a default value. */ protected static class NamedValueInfo { private final String name; private final boolean required; @Nullable private final String defaultValue; public NamedValueInfo(String name, boolean required, @Nullable String defaultValue) { this.name = name; this.required = required; this.defaultValue = defaultValue; } }
这段代码很关键,这里只读取@RequestParam注解,不会读@ApiImplicitParam注解,所以@ApiImplicitParam注解不会影响@RequestParam的属性,并且无论是从swagger doc过来的请求,还是postman过来的请求,都执行这一段代码,最终读取注解的结果用CurrenctHashMap存储,key的格式是 method 'xxx' parameter y
,xxx为方法名,y为参数的顺序号,如 method 'auth' parameter 0
,基本上可以保证唯一性。
源码阅读到这里,基本上可以验证前面提及的小结论的前2条,引用一下:
前面2个问题已经从源码中找到解释,来看第3个问题:如果参数设置required=true,但只是要求参数名存在,如果此字段是Long类型或Integer类型,写成 uid=
或'uid',也能通过校验,最终进入方法后,还是得手动写代码进行为空校验,这显然不是我们想要的结果?该如何解决呢?
接上一节,如果这样通用的参数,得挨个判断是否为空,这样的做法就有点难受了,有没有更好的解决办法呢?预期的实现效果是字段加上require=true后,Long类型或其他数值类型可以把"",null过滤掉,要不然require还有什么意义呢?
解决方法有两个思路:
方案2更通用一些,适用GET、POST请求,并且原有的单个参数声明无需封装到POJO类里。
官网本身提供自定义参数绑定的扩展,见 https://docs.spring.io/spring/docs/5.1.8.RELEASE/spring-framework-reference/web.html#mvc-ann-initbinder
官网的例子是在指定的Controller类中使用@InitBinder注解,影响范围仅限该Controller类,示例如下:
@InitBinder public void initBinder(WebDataBinder binder) { /* * 注册对于String类型参数对象的属性进行trim操作的编辑器, * 构造参数代表空串是否转为null,false,则将null转为空串。 */ binder.registerCustomEditor(String.class, new StringTrimmerEditor(false)); // 这里我还添加了其他类型的属性编辑器,true表示允许使用"",并且将""处理为空,false表示不允许使用"" binder.registerCustomEditor(Short.class, new CustomNumberEditor(Short.class, false)); binder.registerCustomEditor(Integer.class, new CustomNumberEditor(Integer.class, false)); binder.registerCustomEditor(Long.class, new CustomNumberEditor(Long.class, false)); binder.registerCustomEditor(Float.class, new CustomNumberEditor(Float.class, false)); binder.registerCustomEditor(Double.class, new CustomNumberEditor(Double.class, false)); binder.registerCustomEditor(BigDecimal.class, new CustomNumberEditor(BigDecimal.class, false)); binder.registerCustomEditor(BigInteger.class, new CustomNumberEditor(BigInteger.class, false)); }
由于此次面临的问题是全模块@RequestParam的值的问题,需要做一个全局的配置,此时需要新增一个类,并使用@ControllerAdvice注解,代码如下:
@ControllerAdvice public class CustomWebBindingInitializer implements WebBindingInitializer { @InitBinder @Override public void initBinder(WebDataBinder binder) { /* * 注册对于String类型参数对象的属性进行trim操作的编辑器, * 构造参数代表空串是否转为null,false,则将null转为空串。 */ binder.registerCustomEditor(String.class, new StringTrimmerEditor(false)); // 这里我还添加了其他类型的属性编辑器,true表示允许使用"",并且将""处理为空,false表示不允许使用"" binder.registerCustomEditor(Short.class, new CustomNumberEditor(Short.class, false)); binder.registerCustomEditor(Integer.class, new CustomNumberEditor(Integer.class, false)); binder.registerCustomEditor(Long.class, new CustomNumberEditor(Long.class, false)); binder.registerCustomEditor(Float.class, new CustomNumberEditor(Float.class, false)); binder.registerCustomEditor(Double.class, new CustomNumberEditor(Double.class, false)); binder.registerCustomEditor(BigDecimal.class, new CustomNumberEditor(BigDecimal.class, false)); binder.registerCustomEditor(BigInteger.class, new CustomNumberEditor(BigInteger.class, false)); } }
注意一下CustomNumberEditor实例初始化的传的false参数。
重启应用,看一下效果:
都已经到这儿了,再加把劲把相关的源码看一下,还是在 org.springframework.web.method.annotation.AbstractNamedValueMethodArgumentResolver
类的resolveArgument方法的后半段:
@Override @Nullable public final Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer, NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception { // 前面省略 if (binderFactory != null) { WebDataBinder binder = binderFactory.createBinder(webRequest, null, namedValueInfo.name); try { // 在这里对参数进行转换 arg = binder.convertIfNecessary(arg, parameter.getParameterType(), parameter); } catch (ConversionNotSupportedException ex) { throw new MethodArgumentConversionNotSupportedException(arg, ex.getRequiredType(), namedValueInfo.name, parameter, ex.getCause()); } catch (TypeMismatchException ex) { throw new MethodArgumentTypeMismatchException(arg, ex.getRequiredType(), namedValueInfo.name, parameter, ex.getCause()); } } handleResolvedValue(arg, namedValueInfo.name, parameter, mavContainer, webRequest); return arg; }
从 binder.convertIfNecessary
方法一路跟下去,中间省略一些调用,最终到达 org.springframework.beans.propertyeditors.CustomNumberEditor
类的setAsText方法:
/** * Parse the Number from the given text, using the specified NumberFormat. */ @Override public void setAsText(String text) throws IllegalArgumentException { if (this.allowEmpty && !StringUtils.hasText(text)) { // Treat empty String as null value. setValue(null); } else if (this.numberFormat != null) { // Use given NumberFormat for parsing text. setValue(NumberUtils.parseNumber(text, this.numberClass, this.numberFormat)); } else { // Use default valueOf methods for parsing text. setValue(NumberUtils.parseNumber(text, this.numberClass)); } }
仔细看allowEmpty变量,针对Long类型的参数,我们扩展数据绑定时,该变量设置的是false,表示不接受空值,试验中我们传的值是空串,那么这里的条件分支判断就必须对空串转换成数值,执行 Long.valueOf("")
结果报出运行时异常java.lang.NumberFormatException,告知客户端参数不对,这是期望的结果。
本篇以实际的研发排错过程为出发点,刚开始自己也以为@ApiImplicitParam对@RequestParam的required属性的有侵入性,觉得诧异便深入源码论证自己的想法,经阅读源码后发现事实并不是这样,是刚开始我们对required的理解有误。既然required的作用非常有限,那么肯定能找到通用的解决方案避免手动写代码对所有参数进行为空判断,这些解决一个问题后,发现新的问题,再继续解决,最终得到的结果,分析若有不详尽之处,请指正,谢谢。
专注Java高并发、分布式架构,更多技术干货分享与心得,请关注公众号:Java架构社区
可以扫左边二维码添加好友,邀请你加入Java架构社区微信群共同探讨技术