问题起源

应用SpringCloud构建我的项目时,应用Swagger生成相应的接口文档是举荐的选项,Swagger可能提供页面拜访,间接在网页上调试后端系统的接口, 十分不便。最近却遇到了一个有点困惑的问题,演示接口示例如下(原有性能接口带有业务实现逻辑,这里简化了接口):

/** * @description: 演示类 * @author: Huang Ying **/@Api(tags = "演示类")@RestController@Slf4jpublic 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:手动配置接口参数,示例:

案例实战

接口测试1

接口示例如开篇所示,咱们先应用如下接口,全副应用默认值,即@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的后果:

接口测试2

咱们批改@ApiImplicitParam的required值为true,@RequestParam不变,重启模块
@ApiImplicitParam(name = "uid", value = "用户ID", paramType = "query", required = true, dataType = "Long")

看swagger的后果:

通过调试浏览器能够发现,为空校验是js实现的,js判断为空后,并未发动申请到后端,这样咱们能够认为swagger内@ApiImplicitParam的required参数失效了。

接口测试3

在后面咱们应用postman测试接口时,发现参数项是空的,咱们加上参数,但不写值测试后,后果让人惊讶:

并且无论@ApiImplicitParam的required值如何批改,后果都是一样的,必定有一个中央是搞错了,导致咱们误判。

起初认真查阅材料,发现是咱们对@RequestParam的required参数了解错了,这个required为true的含意是:接口参数名肯定要存在,但参数前面有没有值它管不着。拿刚刚的例子来说:

这两个申请是通过的:localhost:8080/api/json//demo?uidlocalhost:8080/api/json//demo?uid=只有这种申请是不通过的:localhost:8080/api/json//demo?

小论断

通过上述三个接口的测试场景,咱们至多能够明确3点:

  1. @ApiImplicitParam的required参数不会对@RequestParam的required值造成侵入,它们俩不相干。
  2. @ApiImplicitParam的required参数会影响swagger doc的js逻辑判断,为空校验是在js层面上实现的。
  3. @RequestParam的required参数默认状况下只会校验是否有该参数名,不校验它是否有值。

源码分析

swagger局部

上一节当中提及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源码如下:

//判断是否requiredif (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;            }        }    }}

SpringCloud业务模块局部

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@Nullablepublic 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)办法时,这部分代码如下:

@Overrideprotected 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条,援用一下:

  1. @ApiImplicitParam的required参数不会对@RequestParam的required值造成侵入,它们俩不相干。
  2. @ApiImplicitParam的required参数会影响swagger doc的js逻辑判断,为空校验是在js层面上实现的。
  3. @RequestParam的required参数默认状况下只会校验是否有该参数名,不校验它是否有值。

后面2个问题曾经从源码中找到解释,来看第3个问题:如果参数设置required=true,但只是要求参数名存在,如果此字段是Long类型或Integer类型,写成uid=或'uid',也能通过校验,最终进入办法后,还是得手动写代码进行为空校验,这显然不是咱们想要的后果?该如何解决呢?

申请参数data bind的问题

接上一节,如果这样通用的参数,得挨个判断是否为空,这样的做法就有点好受了,有没有更好的解决办法呢?预期的实现成果是字段加上require=true后,Long类型或其余数值类型能够把"",null过滤掉,要不然require还有什么意义呢?

解决办法有两个思路:

  1. POST申请办法中将多个参数封装到一个POJO类里,用@RequestBody申明,POJO类中能够应用@Validator框架的@NotNull等注解,并在参数前申明@Valid。
  2. 自定义参数绑定规定扩大。

计划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类,示例如下:

@InitBinderpublic 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注解,代码如下:

@ControllerAdvicepublic 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参数。

重启利用,看一下成果:

扩大DataBinder后相干源码浏览

都曾经到这儿了,再加把劲把相干的源码看一下,还是在org.springframework.web.method.annotation.AbstractNamedValueMethodArgumentResolver类的resolveArgument办法的后半段:

@Override@Nullablepublic 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. */@Overridepublic 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架构社区微信群独特探讨技术