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
- Check whether the interface parameter is added
@RequestBody
If it is not added, it goes to the second step - Wrap all public properties in the Dto with the public GET method
RequestParameter
- All of the
RequestParameter
Added to theHashSet
1. Determine whether it is added@RequestBody
Parameters 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.RequestParameter
Added 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…