SpringFox 源码分析(及 Yapi 问题的另一种解决方案)

作者 | 李一帆

初级秃头后端工程师。

在做中台网关时需要基于 Swagger Json 生成契约文件和 SDK 。但生成的 Swagger Json 默认格式不是那么满足需求,就需要进行一些自定义,但要在哪里定义?

同时在使用 Yapi 时,也存在一些需要注意或修改的地方,这与需要“自定义”的地方部分是相同的。 那么就带着问题,阅读源码找到原因。

本文springfox-swagger版本号: 2.6.0

0. 写在前面

因贴出代码较多,较枯燥,先说结论: 

0.1 自定义对象使用 @ApiParam 无法显示属性描述

  • @ApiModel 和 @ApiModelProperty 都可用来标识对象内元素,只是 Springfox 处理时不会处理@ApiParam。

0.2 GET 请求的对象参数,不会生成对象的描述

  • 原因是 GET 请求原则上不应该使用应该配合 @RequestBody使用的对象参数,应该使用 POST 或 @ModelAttribute,但可用自定义强制处理。

  • List 和Optional  description 展示正常。

下面再看源码。

1. GET 请求的参数对象

第一个问题,当方法是GET请求,但参数是一个自定义对象,在展示时是不包括本对象描述的。如果去 Google,会告诉你根据HTTP规范或Restful风格”不建议”这么做,如下。

  • HTTP GET with request body

  • rfc3986

但就像建议我找个另一半而我却仍孤苦伶仃。

所以就需要看看什么时候会生成这些 Model 的描述。

1.1 start()

万事有始有终,SpringFox 始就在:  springfox.documentation.spring.web.plugins  下的  DocumentationPluginsBootstrapper 。该类实现了 SmartLifecycle 接口,实现此接口且通过  @Component  注入到容器的 bean , 在容器初始化后会执行  start()  方法。

@Component
public class DocumentationPluginsBootstrapper implements SmartLifecycle {

接着看 start 方法:

@Override
public void start() {
    if (initialized.compareAndSet(false, true)) {
        // 拿到 DocumentationPlugin 插件
        List<DocumentationPlugin> plugins = pluginOrdering()
            .sortedCopy(documentationPluginsManager.documentationPlugins());
        for (DocumentationPlugin each : plugins) {
            //获取文档类型
            DocumentationType documentationType = each.getDocumentationType();
            if (each.isEnabled()) {
                // 启用则扫描生成文档
                scanDocumentation(buildContext(each));
                        ...

最后一句调用了   buildContext  方法,通过   Docket  对象创建   DocumentaionContext  对象。

private DocumentationContext buildContext(DocumentationPlugin each) {
    return each.configure(this.defaultContextBuilder(each));
}

1.2 创建 DocumentationContextBuilder 对象

再往下走看   defaultContextBuilder 方法 。

private DocumentationContextBuilder defaultContextBuilder(DocumentationPlugin each) {
    DocumentationType documentationType = each.getDocumentationType();
    // 获取所有的RequestHnadler
    List<RequestHandler> requestHandlers = FluentIterable.from(this.handlerProviders).transformAndConcat(this.handlers()).toList();
    return this.documentationPluginsManager.createContextBuilder(documentationType, this.defaultConfiguration).requestHandlers(requestHandlers);
}

handlerProviders  是   RequestHandlerProvider  接口,实现类是   WebMvcRequestHandlerProvider  ,其中  requestHandlers  方法会接收 Spring 中的所有请求映射。接着看   DocumentationContextBuilder  的构造过程: documentationPluginsManager.createContextBuilder

public DocumentationContextBuilder createContextBuilder(DocumentationType documentationType,
                                                        DefaultConfiguration defaultConfiguration) {
  return defaultsProviders.getPluginFor(documentationType, defaultConfiguration)
      .create(documentationType)
      .withResourceGroupingStrategy(resourceGroupingStrategy(documentationType));
}

defaultsProviders  也是一个插件接口   DefaultsProviderPlugin  ,只有一个实现类  DefaultConfiguration  ,不过该类未使用  @Compoent  注解,所以需要通过   getPluginFor  给一个替换值  defaultConfiguration ,其实也就是  DefaultConfiguration  本身。看看  create  方法:

@Override
public DocumentationContextBuilder create(DocumentationType documentationType) {
  return new DocumentationContextBuilder(documentationType)
          .operationOrdering(defaults.operationOrdering())
          .apiDescriptionOrdering(defaults.apiDescriptionOrdering())
          .apiListingReferenceOrdering(defaults.apiListingReferenceOrdering())
          .additionalIgnorableTypes(defaults.defaultIgnorableParameterTypes())
          .rules(defaults.defaultRules(typeResolver))
          .defaultResponseMessages(defaults.defaultResponseMessages())
          .pathProvider(new RelativePathProvider(servletContext))
          .typeResolver(typeResolver)
          .enableUrlTemplating(false)
          .selector(ApiSelector.DEFAULT);
}

这里在给  DocumentationContextBuilder  设置相关参数,至此拿到了  DocumentationContextBuilder

1.3 初始化 Docket

回到 1.1 中的 buildContext defaultContextBuilder  方法执行完毕, 接下来是   each.configure

return each.configure(this.defaultContextBuilder(each));

each  是  DocumentationPlugin  只有个实现类 Docket ,到这就有点熟悉了。 Docket  对象是我们在外部用来创建的,而外部赋值的对象值,最终都会整合到  DocumentationContext 。这里就是在二次赋值。可以看下一般定义的  Docket  对象。

public class SwaggerConfig {
    @Bean
    public Docket docket() {
        return new Docket(DocumentationType.SWAGGER_2)
                .groupName(SWAGGER_GROUP)
                .apiInfo(new ApiInfoBuilder().title("xx").version("1.0.0").build())
                ......
                .select()
                .apis(basePackage("xxx"))
                .paths(PathSelectors.any())
                .build();
    }
}

外部创建时只设置了默认的参数。但接口,定义,模型等关键信息等都未初始化。

1.4. 扫描

再次回到最初的起点  start()  , 看看  scanDocumentation(buildContext(each)) 的  scanDocumentation  。

private void scanDocumentation(DocumentationContext context) {
  scanned.addDocumentation(resourceListing.scan(context));

其中   scan  位于   ApiDocumentationScanner 类。有两个  scan  ,分别来看。

public Documentation scan(DocumentationContext context) {
  ApiListingReferenceScanResult result = apiListingReferenceScanner.scan(context);
  ...
  Multimap<String, ApiListing> apiListings = apiListingScanner.scan(listingContext);

1.4.1

第一个  apiListingReferenceScanner.scan  位于   ApiListingReferenceScanner

public ApiListingReferenceScanResult scan(DocumentationContext context) {
  // 接口选择器 在构建Docket时通过.select()默认配置
  ApiSelector selector = context.getApiSelector();
  // 根据package路径(一般)或注解区分, 过滤筛选掉不符规则的 RequestHandler 接口
  Iterable<RequestHandler> matchingHandlers = from(context.getRequestHandlers())
      .filter(selector.getRequestHandlerSelector());
  for (RequestHandler handler : matchingHandlers) {
    // 接口分组 resourceGroup = Controller,RequestMapping = method
    ResourceGroup resourceGroup = new ResourceGroup(handler.groupName(),
        handler.declaringClass(), 0);

这个方法拿到了所有接口信息并进行了分组,其中 ArrayListMultimap 是  guava  的方法。

1.4.2

第二个  scan ,   apiListingScanner.scan

public Multimap<String, ApiListing> scan(ApiListingScanningContext context) {
    for (RequestMappingContext each : sortedByMethods(requestMappingsByResourceGroup.get(resourceGroup))) {
      // 循环Controller下的所有接口的实例对象, 拿到该接口的所有Model
      models.putAll(apiModelReader.read(each.withKnownModels(models)));
      apiDescriptions.addAll(apiDescriptionReader.read(each));

each.withKnownModels  是复制对象,关键是 apiModelReader.read  ,就是在读取 Model 信息了。

1.5 读取Model信息

public Map<String, Model> read(RequestMappingContext context) {
    // 忽略的 class
  Set<Class> ignorableTypes = newHashSet(context.getIgnorableParameterTypes());
  Set<ModelContext> modelContexts = pluginsManager.modelContexts(context);
  Map<String, Model> modelMap = newHashMap(context.getModelMap());
  for (ModelContext each : modelContexts) {
    markIgnorablesAsHasSeen(typeResolver, ignorableTypes, each);
    // ModelContext 转 Model
    Optional<Model> pModel = modelProvider.modelFor(each);
    ...

先看  pluginsManager.modelContexts ,怎么取的  modelContexts

public Set<ModelContext> modelContexts(RequestMappingContext context) {
  // 构建接口的ModelContext集合
  for (OperationModelsProviderPlugin each : operationModelsProviders.getPluginsFor(documentationType)) {
    each.apply(context);
  }
  return context.operationModelsBuilder().build();
}

OperationModelsProviderPlugin  是一个接口,有两个实现类,通过文档类型来获取。

  • OperationModelsProviderPlugin:处理返回类型,参数类型等。

  • SwaggerOperationModelsProvider:swagger注解提供的值类型, @ApiResponse @ApiOperation 等。

那么从第一个  OperationModelsProviderPlugin  来。

@Override
public void apply(RequestMappingContext context) {
  // 收集返回类型
  collectFromReturnType(context);
  // 收集参数类型
  collectParameters(context);
  // 收集接口型号
  collectGlobalModels(context);
}

到了这,本问题( GET 方法的请求 Object 不描述)的答案就要呼之欲出了。进入  collectParameters 方法。

private void collectParameters(RequestMappingContext context) {
  // 获取所有类型
  List<ResolvedMethodParameter> parameterTypes = context.getParameters();
  for (ResolvedMethodParameter parameterType : parameterTypes) {
    // 过滤
    if (parameterType.hasParameterAnnotation(RequestBody.class)
          || parameterType.hasParameterAnnotation(RequestPart.class)) {
        ResolvedType modelType = context.alternateFor(parameterType.getParameterType());

破案了,可以看到 过滤时只会处理两种 :通过  @RequestBody  和  @ReuqestPart  注解标注的,而GET方法的参数是不使用这两个注解的,所以也就不被处理。

至于另一个实现类  SwaggerOperationModelsProvider  主要是收集使用  @ApiOperation  时主句属性值和 @ApiResponse  响应状态码涉及到的型号,与本次无关就不再详细介绍。

而开头  read 方法最后一行  modelContext  转化为   Model modelProvider.modelFor() 是通过 ModelProvider 实现,下一问题会讲到它。

1.6. 解决问题

那么,如何解决这个问题。本文给出四个方法。

1.6.1 @ModelAttribute

  • 使用  @ModelAttribute ,大体是可以的,但使用后首先传递的不是  json 字符串对象, 请求路径会有变化。   Swagger UI 展示时也不是一个对象而是一个个参数,如果想 基于  swagger json  生成契约代码、SDK等就不OK了,反向生成出来的就不是对象了。
  • 而且,会导致部分  List<Enum>  加载失败,UI 丢失  List<Enum>  元素或描述会出现问题。最后一个问题会谈到。

1.6.2 additionalModels

使用  Docket 的  additionalModels  方法,在配置类中注入   TypeResolver  。直接将该  Model  强制加入,但不能一劳永逸。

return new Docket(DocumentationType.SWAGGER_2)
.additionalModels(typeResolver.resolve(xxx))

1.6.3 第三方

借助第三方类库 如swagger-bootstrap-ui的工具类。

1.6.4 重写

上面已经谈到了加载  Model  的逻辑,那么重写  OperationModelsProviderPlugin  的  apply  方法,添加自定义收集器,或者直接重写  collectParameters 方法都可以。如新增自定义收集器:强制处理指定  Model

private void collectGetParameters(RequestMappingContext context) {
    for (ResolvedMethodParameter parameterType : parameterTypes) {
        // 不存在@RequestBody注解
        if (!parameterType.hasParameterAnnotation(RequestBody.class)...) {
                    // 根据后缀、注解等逻辑判断特定类
            if (xxx) {
                ResolvedType modelType = context.alternateFor(parameterType.getParameterType());
               // 加入处理
                context.operationModelsBuilder().addInputParam(modelType);
            }
        } ...

2. @ApiParam 不显示描述

上一个问题的结尾说到  apiModelReader.read 的  modelProvider.modelFor()  方法。 ModelProvider  是一个接口,有两个实现类:

  • DefaultModelProvider:默认,每次都会将 modelContext 转换为 model。

  • CachingModelProvider:声明了 guava 缓存池,先从缓存池取,没有则调用初始化处理器,转换为模型,再放入缓存池。

2.1 model转换

在  ApiModelReader  的构造方法里指定使用  CachingModelProvider  ,但第一次调用缓存里是没的,往下走到  populateDependencies

private void populateDependencies(ModelContext modelContext, Map<String, Model> modelMap) {
  Map<String, Model> dependencies = modelProvider.dependencies(modelContext);
  for (Model each : dependencies.values()) {
    mergeModelMap(modelMap, each);

CachingModelProvider  的  dependencies  依赖的是  DefaultModelProvider ,等于又绕了回来。

public Map<String, Model> dependencies(ModelContext modelContext) {
  return delegate.dependencies(modelContext);

所以还是看默认的  DefaultModelProvider 中的实现。

public Map<String, Model> dependencies(ModelContext modelContext) {
  for (ResolvedType resolvedType : dependencyProvider.dependentModels(modelContext)) {
    ModelContext parentContext = ModelContext.fromParent(modelContext, resolvedType);
    Optional<Model> model = modelFor(parentContext).or(mapModel(parentContext, resolvedType));
    if (model.isPresent()) {
      models.put(model.get().getName(), model.get());
    }

dependencyProvider.dependentModels  和上面一个路子,一默认一缓存,交替接口。

public Set<ResolvedType> dependentModels(ModelContext modelContext) {
  return from(resolvedDependencies(modelContext))

关注 resolvedDependencies 方法。

private List<ResolvedType> resolvedDependencies(ModelContext modelContext) {
  List<ResolvedType> dependencies = newArrayList(resolvedTypeParameters(modelContext, resolvedType));

这里都是在构造拓展类型   ResolvedType  ,接着往下关注  resolvedPropertiesAndFields  方法。

private List<ResolvedType> resolvedPropertiesAndFields(ModelContext modelContext, ResolvedType resolvedType) {
  List<ResolvedType> properties = newArrayList();
  for (ModelProperty property : nonTrivialProperties(modelContext, resolvedType))

看到  ModelProperty  ,也就是对象的属性,那就看  nonTrivialProperties  方法。

private FluentIterable<ModelProperty> nonTrivialProperties(ModelContext modelContext, ResolvedType resolvedType) {
  return from(propertiesFor(modelContext, resolvedType))
      .filter(not(baseProperty(modelContext)));

接着是  propertiesFor

private List<ModelProperty> propertiesFor(ModelContext modelContext, ResolvedType resolvedType) {
  return propertiesProvider.propertiesFor(resolvedType, modelContext);

这个 propertiesProvider.propertiesFor  仍是一缓存一默认的策略,直接看实现。

public List<ModelProperty> propertiesFor(ResolvedType type, ModelContext givenContext) {
  for (Map.Entry<String, BeanPropertyDefinition> each : propertyLookup.entrySet()) {
    properties.addAll(candidateProperties(type, annotatedMember.get(), jacksonProperty, givenContext));

2.2 判断元素注解类型

List<ModelProperty>  = properties 通过   candidateProperties  方法获取添加。

List<ModelProperty> candidateProperties( ResolvedType type, AnnotatedMember member...) {
  List<ModelProperty> properties = newArrayList();
  // 根据 元素注解进行不同处理
  if (member instanceof AnnotatedMethod) {
    properties.addAll(findAccessorMethod(type, member)
        .transform(propertyFromBean(givenContext, jacksonProperty))
        .or(new ArrayList<ModelProperty>()));
  } else if (member instanceof AnnotatedField) {
    ...
  } else if (member instanceof AnnotatedParameter) {
    ...
  }

根据   AnnotatedMember  判断类成员的类型,进行不同的处理。不过我们一般都会加入   @Data 注解,  getxxx 方法导致我们基本都走到了第一个分支,调用了  propertyFromBean  方法。

return new Function<ResolvedMethod, List<ModelProperty>>() {
@Override
  public List<ModelProperty> apply(ResolvedMethod input) {
  return newArrayList(beanModelProperty(input, jacksonProperty, givenContext));

2.3 加载Plugin

接着是  beanModelProperty

private ModelProperty beanModelProperty(
  return schemaPluginsManager.property(new ModelPropertyContext(propertyBuilder,...

最后调用了  schemaPluginsManager.property  方法。

public ModelProperty property(ModelPropertyContext context) {
  // 根据文档类型取出 ModelPropertyBuilderPlugin
  for (ModelPropertyBuilderPlugin enricher : propertyEnrichers.getPluginsFor(context.getDocumentationType())) {
    enricher.apply(context);
  }

ModelPropertyBuilderPlugin  是一个接口,看它的实现类  ApiModelPropertyPropertyBuilder

public void apply(ModelPropertyContext context) {
  // 取出元素的注解
  Optional<ApiModelProperty> annotation = Optional.absent();
  ...
  if (annotation.isPresent()) {
    context.getBuilder()
        .allowableValues(annotation.transform(toAllowableValues()).orNull())
        .required(annotation.transform(toIsRequired()).or(false))
        .readOnly(annotation.transform(toIsReadOnly()).or(false))
        .description(annotation.transform(toDescription()).orNull())
        ...
  }
}

终于找到问题的答案了,这个调用链有点长...可以看到是通过判断是否存在  @ApiModelProperty  注解,再进行赋值。所以  @ApiParam  不被处理。

2.4 解决问题

如果单纯想让其显示描述的话,重写  ApiModelPropertyPropertyBuilder  的  apply  方法判断  @ApiParam  不失为一个好办法,如下。

public class ModelPropertyPlugin implements ModelPropertyBuilderPlugin {
    @Override
    public void apply(ModelPropertyContext context) {
        Optional<ApiParam> annotation = Optional.absent();
        if (context.getBeanPropertyDefinition().isPresent()) {
        annotation = annotation.or(findPropertyAnnotation(context.getBeanPropertyDefinition().get(), ApiParam.class));
        }
        ModelPropertyBuilder modelPropertyBuilder = context.getBuilder();
        if (annotation.isPresent()) {
           // 存在 @ApiParam注解。设置描述
            modelPropertyBuilder.description(annotation.get().value());
        }
    } ...

完事。

3. 对于Enum

在第一个问题说到  List<Enum>  可能会丢失或描述有问题。其实,就对于单纯的  private Enum enum; ,在  Swagegr UI  展示时你也不能保证它显示的是你定义  code 还是  name 。而对于嵌套数据结构,就更没法处理了。

3.1 解决

问题的原因是因为嵌套类型的  Enum 取其中的 name code 时和 Enum 不同,具体原因因篇幅就不再详细说明。可通过以下方式取出。

BeanPropertyDefinition propertyDefinition = context.getBeanPropertyDefinition().get();
ModelPropertyBuilder modelPropertyBuilder = context.getBuilder();

ResolvedType resolvedTypeObj = (ResolvedType) Objects.requireNonNull(type).get(modelPropertyBuilder);
            // 针对 Optional<enum>
            Class erasedType = resolvedTypeObj.getErasedType();
            // 针对 List<enum>
            Class bindingsType = erasedType;
            if (List.class.isAssignableFrom(erasedType)) {
                bindingsType = resolvedTypeObj.getTypeBindings().getBoundType(0).getErasedType();
            }
            Class fieldType = propertyDefinition.getField().getRawType();
            ...

这样,可以根据类型,取出 Enum 类的 name 和 code。对于嵌套类型也就可以自定义设置描述了。

4. 总结

基于三个问题分析,基本也将 SpringFox 的加载机制介绍了一部分。

全文完

以下文章您可能也会感兴趣:

我们正在招聘 Java 工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com 。

我来评几句
登录后评论

已发表评论数()

相关站点

热门文章