Related Technology Stack

Kotlin1.5 Springboot2.5 Springfox3.0

The cause of

Recently, alipay’s computer website payment needs to define an interface supporting form Post submission to receive alipay’s callback. in

After defining the interface, we found that Springfox reported a null pointer when initializing Swagger, so swagger API Doc could not be loaded

Analysis of the

1. An incorrect location is reported

springfox.documentation.service.RequestParameter#equals

springfox.documentation.schema.Example#equals

2. Interface definition

First, take a look at the interface definition that identifies the problem

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

Dto definition

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

3. Kotlin compiles to Java

It doesn’t seem to be a problem. It’s nice. Why is a null pointer reported? First let’s look at what dtos look like when compiled into Java code

public final class Dto {
   @ApiModelProperty
   public String field;

   @NotNull
   public final String getField(a) {
      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; }}Copy the code

As you can see, the Field access modifier is public. In fact, this public is the culprit

4. Springfox source code analysis

Let’s take a look at an overview of how SpringFox handles interface parameters

  1. Check whether the interface parameter is added@RequestBodyIf it is not added, it goes to the second step
  2. Wrap all public properties in the Dto with the public GET methodRequestParameter
  3. All of theRequestParameterAdded to theHashSet

1. Determine whether it is added@RequestBodyParameters such as

Take a look at the source code associated with the first step

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. Pass through all parameters of the method
        for (ResolvedMethodParameter methodParameter : methodParameters) {
            //2. Determine whether expansion is required.
            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); }}Copy the code

Here you can see that shouldExpand determines if our parameters are annotated with @requestBody annotations, and that the interface we’re defining is a POST interface that receives the form, preceded by @ModelAttribute annotations (optional). So this will go to expander. Expand will break down the class and parse each field one by one. Then enter the following code:

public class ModelAttributeParameterExpander {
        public List<Compatibility<springfox.documentation.service.Parameter, RequestParameter>> expand(
              ExpansionContext context) {
            / /...

            // Wrap all getters and public fields in model as ModelAttributeFields
            List<ModelAttributeField> attributes =
                allModelAttributes(
                    propertyLookupByGetter,
                    getters,
                    fieldsByName,
                    alternateTypeProvider,
                    context.ignorableTypes());
                        // Handles getter methods and public fields, wrapping them as the corresponding 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
       
         propertyLookupByGetter, Iterable
        
          getters, Map
         
           fieldsByName, AlternateTypeProvider alternateTypeProvider, Collection
          
            ignorables)
          
         ,>
        
       ,> {

            // All getter methods
            Stream<ModelAttributeField> modelAttributesFromGetters =
                StreamSupport.stream(getters.spliterator(), false) .filter(method -> ! ignored(alternateTypeProvider, method, ignorables)) .map(toModelAttributeField(fieldsByName, propertyLookupByGetter, alternateTypeProvider));// All fields modified by publicStream<ModelAttributeField> modelAttributesFromFields = fieldsByName.values().stream() .filter(ResolvedMember::isPublic)  .filter(ResolvedMember::isPublic) .map(toModelAttributeField(alternateTypeProvider));returnStream.concat( modelAttributesFromFields, modelAttributesFromGetters) .collect(toList()); }}Copy the code

Next through ModelAttributeParameterExpander simpleFields to enter the following code

package springfox.documentation.swagger.readers.parameter; public class SwaggerExpandedParameterBuilder implements ExpandedParameterBuilderPlugin { @Override public void apply(ParameterExpansionContext context) { //1. Context is a collection of information for a single field or getter method. // If the field has an ApiModelProperty annotation, the returned Optional has an associated annotation wrapped object. // If the getter method, In the metadataAccessor of the context it keeps a copy of the field for the getter. // So this field is treated the same as the getter. Optional<ApiModelProperty> apiModelPropertyOptional  = context.findAnnotation(ApiModelProperty.class); //2. If there are ApiModelProperty annotations on the field, Execute fromApiModelProperty apiModelPropertyOptional. IfPresent (apiModelProperty - > fromApiModelProperty (context, apiModelProperty)); }}Copy the code

Obviously, our Dto has an ApiModelProperty annotation on its field. So let’s go to fromApiModelProperty

2. PackingRequestParameter

package springfox.documentation.swagger.readers.parameter; public class SwaggerExpandedParameterBuilder implements ExpandedParameterBuilderPlugin { private void fromApiModelProperty(ParameterExpansionContext context, ApiModelProperty apiModelProperty) { //... / / 1. Generate RequestParameterBuilder context. GetRequestParameterBuilder () .description(descriptions.resolve(apiModelProperty.value())) .required(apiModelProperty.required()) .hidden(ApiModelProperty.hidden ()) //2. Apimodelproperty.example () returns an empty string by default. Example (new ExampleBuilder().value(apiModelProperty.example()).build())) .precedence(SWAGGER_PLUGIN_ORDER) .query(q -> q.enumerationFacet(e -> e.allowedValues(allowable))); }}Copy the code

So a RequestParameterBuilder is generated that corresponds to our field or getter, and the field scalarExample is null except value. At the same time, you can see that the fields should be exactly the same as the RequestParameterBuilder generated by the getters corresponding to the fields, because it takes the information from the field annotations.

As a result, the value of the RequestParameter field from build() is exactly the same. 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);
  }
Copy the code

You can see that the scalarExample in the RequestParameter is finally compared to equals. So if scalarExample is not empty, it must enter 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);
  }
Copy the code

Remember that the RequestParameterBuilder only assigns values to the value field of Example? Therefore, whenever Example#equals is triggered, NullPointException must be reported

So it doesn’t matter where the RequestParameterBuilder builds (), we just need to find out where equals is triggered.

3.RequestParameterAdded to theHashSet

Let’s go to the caller of the code shown in the first step. The code snippet is as follows:

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

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

            // Trigger the first step
            List<Compatibility<springfox.documentation.service.Parameter, RequestParameter>> compatibilities
                = readParameters(context);

            // Take the data returned by compatibilities#getModern and form a HashSetCollection<RequestParameter> requestParameters = compatibilities.stream() .map(Compatibility::getModern) .filter(Optional::isPresent) .map(Optional::get) .collect(toSet()); context.operationBuilder() .requestParameters(aggregator.aggregate(requestParameters)); }}Copy the code

Did something pop into your head when you saw a HashSet? Yes, HashCode also causes a Hash collision that triggers equals. So let’s take a look at what compatibilities#getModern actually returns.

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 newCompatibility<>( context.getParameterBuilder().build(), context.getRequestParameterBuilder().build()); }}Copy the code

I listed the call chain above, and you can see that compatibilities#getModern returns the RequestParameter we talked about earlier. Go to RequestParameter#hashCode, dude

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

As you can see, if there are two RequestParameters with the same field value, equals will be triggered due to a hash collision, resulting in a NullPointException.

Code snippets for hash collisions

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 it is empty, it is initialized
	        if ((tab = table) == null || (n = tab.length) == 0)
	            n = (tab = resize()).length;
                // Hash with length -1
                // Keys with the same hash value must fall into the same position in the array so that subsequent elements go into the 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))))
                    / /...}}Copy the code

conclusion

The problem is strange. On the one hand, I’m still not familiar with Kotlin, and my knowledge of Lateinit is only superficial. In fact, I think this is where Kotlin’s compilation is wrong. Because normal attributes like var definitions, when compiled into Java code by default, generate a private field with the corresponding getter&setter methods. At the same time, I don’t see any need to make fields public for what LateInit is trying to do (it throws an exception if you try to access an unassigned attribute).

On the other hand, I think springFox’s design is a little bit out of whack. Why allow code that assigns a single Example#value by default when RequestParameter#equals exists? If @APIModelProperty is not added to the field, then NullpointException will occur. It’s irrational and confusing.

github

Github.com/scientificC…