踩坑日记之Springfox+Kotlin lateinit引发NullPointException

相关技术栈

Kotlin1.5 Springboot2.5 Springfox3.0

起因

最近对接支付宝的电脑网站支付,需要定义一个支持表单Post提交的接口来接收支付宝的回调。在

定义完接口后发现Springfox初始化swagger时报了空指针,导致swagger api doc无法加载

分析

1. 报错位置

springfox.documentation.service.RequestParameter#equals

springfox.documentation.schema.Example#equals

2. 接口定义

首先,来看看出问题的接口定义

@ApiOperation("xxx")
@ApiResponse(
    code = 0,
    message = "ok",
)
@PostMapping(
    "/api",
    consumes = [MediaType.APPLICATION_FORM_URLENCODED_VALUE]
)
fun api(dto:Dto) {
        //do something
}

Dto定义

@ApiModel
class Dto {
    @ApiModelProperty
    lateinit var field: String
}

3. Kotlin编译成Java

看起来似乎没啥毛病,很nice。为什么会报空指针呢?首先我们来看下Dto编译成Java代码是什么样子

public final class Dto {
   @ApiModelProperty
   public String field;

   @NotNull
   public final String getField() {
      String var1 = this.field;
      if (var1 != null) {
         return var1;
      } else {
         Intrinsics.throwUninitializedPropertyAccessException("field");
         throw null;
      }
   }

   public final void setField(@NotNull String var1) {
      Intrinsics.checkNotNullParameter(var1, var1);
      this.field = var1;
   }
}

可以发现,field访问修饰符是public。事实上这个public就是罪魁祸首

4. springfox源码分析

我们先来看一下springfox处理接口参数的一个大致过程

  1. 判断接口参数前是否加了@RequestBody等参数,如果没加则进入第二步
  2. 将Dto里的所有public属性跟public get方法包装成RequestParameter
  3. 将所有的RequestParameter 添加到HashSet

1. 判断是否加了@RequestBody等参数

先看看第一步相关的源码

package springfox.documentation.spring.web.readers.operation;

public class OperationParameterReader implements OperationBuilderPlugin {

    private List<Compatibility<springfox.documentation.service.Parameter, RequestParameter>>
      readParameters(OperationContext context) {
        List<ResolvedMethodParameter> methodParameters = context.getParameters();
        List<Compatibility<springfox.documentation.service.Parameter, RequestParameter>> parameters = new ArrayList<>();

        int index = 0;
            //1\. 遍历方法所有参数
        for (ResolvedMethodParameter methodParameter : methodParameters) {
                    //2\. 判断是否需要扩展。
            if (shouldExpand(methodParameter, alternate)) {
              parameters.addAll(
                  expander.expand(
                      new ExpansionContext("", alternate, context)));
            } else {
              //其他流程
            }
        }
        return parameters.stream()
            .filter(hiddenParameter().negate())
            .collect(toList());
      }
}

private boolean shouldExpand(final ResolvedMethodParameter parameter, ResolvedType resolvedParamType) {
    return !parameter.hasParameterAnnotation(RequestBody.class)
        && !parameter.hasParameterAnnotation(RequestPart.class)
        && !parameter.hasParameterAnnotation(RequestParam.class)
        && !parameter.hasParameterAnnotation(PathVariable.class)
        && !builtInScalarType(resolvedParamType.getErasedType()).isPresent()
        && !enumTypeDeterminer.isEnum(resolvedParamType.getErasedType())
        && !isContainerType(resolvedParamType)
        && !isMapType(resolvedParamType);
  }

这里可以看到shouldExpand会判断我们的参数是否被@RequestBody这类注解标注,而我们定义的接口是一个接收form表单的post接口,其前面的注解应该是@ModelAttribute(不加也可以)。所以这里就会进到expander.expand这里会将类拆解开来,对每个字段逐一解析。 然后进入到如下代码:

public class ModelAttributeParameterExpander {
        public List<Compatibility<springfox.documentation.service.Parameter, RequestParameter>> expand(
              ExpansionContext context) {
                //...
                //将model里所有的getter方法跟public修饰的字段包装成ModelAttributeField
            List<ModelAttributeField> attributes =
                allModelAttributes(
                    propertyLookupByGetter,
                    getters,
                    fieldsByName,
                    alternateTypeProvider,
                    context.ignorableTypes());
                //处理getter方法跟public字段,将其包装为对应的RequestParamter
                simpleFields.forEach(each -> parameters.add(simpleFields(context.getParentName(), context, each)));
                    return parameters.stream()
                        .filter(hiddenParameter().negate())
                        .filter(voidParameters().negate())
                        .collect(toList());
        }
}

private List<ModelAttributeField> allModelAttributes(
      Map<Method, PropertyDescriptor> propertyLookupByGetter,
      Iterable<ResolvedMethod> getters,
      Map<String, ResolvedField> fieldsByName,
      AlternateTypeProvider alternateTypeProvider,
      Collection<Class> ignorables) {

        //所有getter方法
    Stream<ModelAttributeField> modelAttributesFromGetters =
        StreamSupport.stream(getters.spliterator(), false)
            .filter(method -> !ignored(alternateTypeProvider, method, ignorables))
            .map(toModelAttributeField(fieldsByName, propertyLookupByGetter, alternateTypeProvider));

        //所有public修饰的字段
    Stream<ModelAttributeField> modelAttributesFromFields =
        fieldsByName.values().stream()
            .filter(ResolvedMember::isPublic)
            .filter(ResolvedMember::isPublic)
            .map(toModelAttributeField(alternateTypeProvider));

    return Stream.concat(
        modelAttributesFromFields,
        modelAttributesFromGetters)
        .collect(toList());
  }

接下来通过ModelAttributeParameterExpander.simpleFields进入如下代码

package springfox.documentation.swagger.readers.parameter;

public class SwaggerExpandedParameterBuilder implements ExpandedParameterBuilderPlugin {    
    @Override
  public void apply(ParameterExpansionContext context) {
        //1\. 查找字段上的ApiModelProperty注解,context则为单个字段或者getter方法的信息集合
        //如果字段上存在ApiModelProperty注解,则返回的Optional存在相关注解包装对象
        //如果是getter方法,在context的metadataAccessor中会保留一份getter对应的字段的信息
        //所以这里字段跟getter的处理方式相同
    Optional<ApiModelProperty> apiModelPropertyOptional = context.findAnnotation(ApiModelProperty.class);
        //2\. 如果字段上存在ApiModelProperty注解,则执行fromApiModelProperty
    apiModelPropertyOptional.ifPresent(apiModelProperty -> fromApiModelProperty(context, apiModelProperty));
  }
}

显然,我们的Dtofield字段上是有ApiModelProperty注解的。所以接下来进入fromApiModelProperty

2. 包装RequestParameter

package springfox.documentation.swagger.readers.parameter;

public class SwaggerExpandedParameterBuilder implements ExpandedParameterBuilderPlugin {
        private void fromApiModelProperty(
          ParameterExpansionContext context,
          ApiModelProperty apiModelProperty) {
            //some codes

            //1\. 生成RequestParameterBuilder
        context.getRequestParameterBuilder()
               .description(descriptions.resolve(apiModelProperty.value()))
               .required(apiModelProperty.required())
               .hidden(apiModelProperty.hidden())
                            //2\. apiModelProperty.example()默认返回空字符串。
                            //所以这里会生成一个除了value其他字段都为空的Example实例
               .example(new ExampleBuilder().value(apiModelProperty.example()).build())
               .precedence(SWAGGER_PLUGIN_ORDER)
               .query(q -> q.enumerationFacet(e -> e.allowedValues(allowable)));
      }
}

所以这里就会生成一个跟我们字段或者getter对应的RequestParameterBuilder,且其字段scalarExample除了value以外其他字段都为null。同时可以看出来,字段跟与字段对应的getter生成的RequestParameterBuilder应该是一模一样的,因为取的都是字段注解上的信息.

所以,其build()出来的RequestParameter的字段值也是一模一样的。因为是RequestParameter#equals报错,我们先来看看其equals方法

public boolean equals(Object o) {
    if (this == o) {
      return true;
    }
    if (o == null || getClass() != o.getClass()) {
      return false;
    }
    RequestParameter that = (RequestParameter) o;
    return parameterIndex == that.parameterIndex &&
                //...

        Objects.equals(scalarExample, that.scalarExample);
  }

可以看到最终会对RequestParameter里的scalarExample进行equals比较。所以如果scalarExample不为空则必然进入进入Example#equals

@Override
  public boolean equals(Object o) {
    if (this == o) {
      return true;
    }
    if (o == null || getClass() != o.getClass()) {
      return false;
    }
    Example example = (Example) o;
    return id.equals(example.id) &&
        Objects.equals(summary, example.summary) &&
        Objects.equals(description, example.description) &&
        value.equals(example.value) &&
        externalValue.equals(example.externalValue) &&
        mediaType.equals(example.mediaType) &&
        extensions.equals(example.extensions);
  }

还记得前面提到的RequestParameterBuilder只为Example的value字段赋了值吗?所以,只要触发Example#equals ,则必然会报出NullPointException

所以接下来这个RequestParameterBuilder在哪完成build()其实已经不需要关心了,我们只需要找到是哪里触发了这个equals即可。

3. 将RequestParameter 添加到HashSet

我们进入第一步所展示的代码的调用方,代码片段如下:

package springfox.documentation.spring.web.readers.operation;

public class OperationParameterReader implements OperationBuilderPlugin {   
    @Override
  public void apply(OperationContext context) {

        //触发第一步
    List<Compatibility<springfox.documentation.service.Parameter, RequestParameter>> compatibilities
        = readParameters(context);

        //拿出compatibilities#getModern返回的数据组成一个HashSet
    Collection<RequestParameter> requestParameters = compatibilities.stream()
        .map(Compatibility::getModern)
        .filter(Optional::isPresent)
        .map(Optional::get)
        .collect(toSet());
    context.operationBuilder()
        .requestParameters(aggregator.aggregate(requestParameters));
  }
}

看到HashSet是不是突然想到了什么?没错,HashCode相同导致Hash碰撞进而触发equals。所以我们先来看看compatibilities#getModern究竟返回了什么。

package springfox.documentation.spring.web.plugins;

//OperationParameterReader.readParameters
//  -> ModelAttributeParameterExpander.expand
//    -> ModelAttributeParameterExpander.simpleFields
//      -> DocumentationPluginsManager.expandParameter
public class DocumentationPluginsManager {
        public Compatibility<springfox.documentation.service.Parameter, RequestParameter> expandParameter(
            ParameterExpansionContext context) {
          for (ExpandedParameterBuilderPlugin each : parameterExpanderPlugins.getPluginsFor(context.getDocumentationType())) {
            each.apply(context);
          }
          return new Compatibility<>(
              context.getParameterBuilder().build(),
              context.getRequestParameterBuilder().build());
        }
}

我在上面列出了调用链,可以看到,compatibilities#getModern返回的就是我们之前说的RequestParameter。好家伙,赶紧去看RequestParameter#hashCode

    @Override
  public int hashCode() {
    return Objects.hash(name,
        parameterIndex,
        in,
        description,
        required,
        deprecated,
        hidden,
        parameterSpecification,
        precedence,
        scalarExample,
        examples,
        extensions);
  }

这里可以看出,如果存在两个字段值相同的RequestParameter,则势必会在因为hash碰撞而触发equals,从而最终导致NullPointException

关于hash碰撞的代码片段

package java.util;

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {  

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                       boolean evict) {
            Node<K,V>[] tab; Node<K,V> p; int n, i;
                    //为空则初始化
            if ((tab = table) == null || (n = tab.length) == 0)
                n = (tab = resize()).length;
                    //hash值与长度-1按位与。
                    //hash值相同的key必然会落到数组中同一个位置从而后来的元素会进入else
            if ((p = tab[i = (n - 1) & hash]) == null)
                tab[i] = newNode(hash, key, value, null);
            else {
                Node<K,V> e; K k;
                if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                    //......
        }
}

总结

这次问题很奇葩,一方面是我对Kotlin还是不够熟,对lateinit的了解仅仅停留在很浅的层次。事实上我觉得这应该是Kotlin的编译不合理之处。因为正常的像var定义的属性,默认编译成java代码后,会生成一个私有的字段跟对应的getter&setter方法。同时,对于lateinit想要实现的功能(如果尝试访问没赋值的属性,会抛出异常。以及像isInitialized这类判断方法),我觉得完全没必要把字段用public来修饰。

另一方面,我觉得springfox的设计也有不合理之处,既然有RequestParameter#equals的存在,为什么要允许前面这种默认只赋值一个Example#value的代码存在呢?且从表现上来看,一个public修饰的字段跟一个对应的getter方法,如果字段上不加@ApiModelProperty,则表现正常,加了,则直接导致NullpointException。这不合理,且容易令人困惑。

github

https://github.com/scientificCommunity/blog-sample/blob/main/src/main/kotlin/org/baichuan/example/spring/springfox/Application.kt

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,711评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,932评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,770评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,799评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,697评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,069评论 1 276
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,535评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,200评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,353评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,290评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,331评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,020评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,610评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,694评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,927评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,330评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,904评论 2 341

推荐阅读更多精彩内容

  • 前言 人生苦多,快来 Kotlin ,快速学习Kotlin! 什么是Kotlin? Kotlin 是种静态类型编程...
    任半生嚣狂阅读 26,125评论 9 118
  • 到本章为止,kotlin基本的知识点都记录完毕。还有关于一些泛型和反射的知识点后续会更新上来,知识点和内容来自《K...
    Haife阅读 3,426评论 0 6
  • 简介 Kotlin[https://github.com/JetBrains/kotlin] 是 JetBrain...
    Whyn阅读 632评论 0 1
  • 本文是在学习和使用kotlin时的一些总结与体会,一些代码示例来自于网络或Kotlin官方文档,持续更新... 对...
    竹尘居士阅读 3,261评论 0 8
  • 16宿命:用概率思维提高你的胜算 以前的我是风险厌恶者,不喜欢去冒险,但是人生放弃了冒险,也就放弃了无数的可能。 ...
    yichen大刀阅读 6,026评论 0 4