用springdoc生成API文档
基于Spring Boot和OpenAPI动态管理API文档,提供Swagger UI

对于Web应用的开发,我们时常需要提供后端API文档,以方便进行团队协作。但是对于频繁变化的字段和参数,我们不可能每次修改过代码之后,都及时更新放在某个角落的文档。直到前端同学过来质问你接口有问题时候,你才解释说某某字段用法发生了变化。我是无法忍受这种低效的来回拉扯的,如果两边无法实现一致性,我的内心将永远无法平静。

你可能听过Swagger,它是一整套API工具,完成API的设计、生成代码、文档化和测试等工作。它有一套规范,用来定义RESTful接口,比如描述路径、字段和约束等等。这个规范后来独立出来成为OpenAPI规范,简称OAS,最新的版本是v3.1.0,一旦用OpenAPI来描述你的项目接口(通常持久化为YAML文档),那么你就可以用Swagger的一整套工具,比如可以用Swagger Codegen生成双端代码,用Swagger UI生成页面,可以像Postman一样调试API,官网有一个Demo可以把玩。

我在国内某公有云工作的时候,团队内曾经被要求先用OpenAPI规范来定义接口,然后再生成接口和模型代码等,最后进行开发。理想很丰满,现实很骨感。团队本来就需求过载,强制要求这样的操作,更进一步导致效率下降,怨声载道。对于小团队,直接上代码开发,把项目跑起来,管它什么规范呢,后期通过给接口添加注解等方式,生成API的OAS描述,然后对接Swagger生态里的工具,逐渐规范化。本文目的就是告诉你如何具体实现此目的,最终给外部提供Swagger UI页面

Swagger Core是OAS的Java实现,它能帮助你将Java里的POJO和Controller等转变为OAS描述。如果你看了它的文档,稍稍有点复杂,离本文的目的还有一些距离。现在互联网上接入Swagger的文章,大多是用springfox在Spring Boot中集成Swagger,但是springfox的仓库已经有4年没有更新了,不支持OAS 3以及一些最新的注解等。我就开始寻找替代品,这不,我就找到了springdoc

springdoc介绍

springdoc项目是2019年发起的,跟springfox类似的,而且支持最新的Java特性、Spring Boot特性、OAS特性、WebMVC、WebFlux以及Java生态中各种丰富的工具。最早它是法国一个Oracle架构师发起的个人项目,后来社区很快就接纳了springdoc。不知道它会不会烂尾,我先捐5刀表示敬意。

那么接下来我们看下如何在项目中用,第一步就是引入依赖:

implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'

这里的2.5.0版本你可以替换成你阅读本文时的版本。下一步运行项目,就可以访问Swagger UI了,对应的路径是/swagger-ui.html,访问后会重定向到/swagger-ui/index.html。Swagger UI会从/v3/api-docs读取你API的OAS定义并以UI方式渲染。如果你项目有一些类似Spring Security的身份验证机制,你可能需要将以下链接加入到白名单中(可参考链接):

  • /swagger-ui.html
  • /swagger-ui/**
  • /v3/api-docs/**

如果你已经成功在网页中打开了,那么你就建立起了对Swagger UI的感性认识。接下来就需要对你的项目进行配置和微调,这个过程是渐进式的,一步一步来就可以完全掌握。

常用配置

以下是一些基础配置,写出来的属性是其默认值:

springdoc.api-docs.path=/v3/api-docs
springdoc.swagger-ui.path=/swagger-ui.html
springdoc.api-docs.enabled=true
springdoc.swagger-ui.enabled=true

Swagger API和Swagger UI的路径我一般不会改,后面两个配置开关是可能会用到的,在生产环境中,你可能不希望API的信息被别人访问到,那么可以在部署的时候将后两者的值设置为false

springdoc默认是将所有包、所有路径的API都扫描了,有些你可能不需要暴露,可以进行定制:

springdoc.packages-to-scan=*
springdoc.paths-to-match=/*
springdoc.packages-to-exclude=
springdoc.paths-to-exclude=

包名列表用逗号分隔,路径列表也用逗号分隔。路径解析springdoc用的是AntPathMatcher,你可以在IDE里查看其源码,以了解其具体匹配规则,从而知道怎么写。

注解的使用

Swagger有大量的注解,方便将接口的定义,映射到OpenAPI规范。官方文档有描述过这些注解,但是我个人觉得这个文档细致程度还是有点欠缺,比如第一个@OpenAPIDefinition,说是用来定义元信息的,但是并没有写得很详细。我只能摸索着前进。接下来我将举例说明一些常见注解的使用:

  • @OpenAPIDefinition 元信息
  • @Info 元信息里的Info信息
  • @Tag 用于分组的标签
  • @Operation 单个接口的信息
  • @Parameter 参数信息
  • @RequestBody 请求体信息
  • @ApiResponse 响应信息

元信息

元信息就是整个API文档的一些信息,比如标题、说明和版本等等。我们看Swagger UI的最顶部,元信息都显示在那里。为了指定这些信息,需要将它放到某个Bean上,我会专门新建一个类处理:

@OpenAPIDefinition(
    info = @Info(
        title = "后端API文档",
        description = "这是文档的描述,描述文档的问题",
        version = "v1.0.1"
    )
)
@Component
public class OpenAPIMetaData {}

加了@Component注解之后方便被扫描到。打开Swagger UI之后,可以看到下面的信息:

元信息

在上图中就可以看到你配置的元信息了。图中的“Servers”是自动获取到的API地址,我们在Swagger UI中发送请求的时候就是发送到这个地址。这个地址也是可以手动在@OpenAPIDefinition中配置的。

元信息中也可以配置开源协议、联系方式、使用协议等信息,因为我们主要是内部使用,并不是面向外部的开发平台,所以这些我就略过了。此外,图中的“OAS 3.1”标签指的是OpenAPI的版本,这个可以通过属性springdoc.api-docs.version来配置,默认值是openapi_3_0,如果你真的了解区别就可以配置。

接口分组

默认情况下,Swagger UI中展现的接口都是按照Controller分组的。实际上,它们分在一组更底层的原因是它们都有同样的Tag(具体详见OAS的定义),同一个Controller里的接口的Tag都是一样的。

如果你想自定义,可以使用注解@Tag,把它放到Controller类上:

@Tag(name = "用户相关", description = "获取以及修改用户信息等")

这样就可以让Swagger UI的说明更可读一些,比如我有一个UserController,效果如图所示:

标签

@Tag可以放到单个接口上,这样不同Controller的接口也可以组织到同一个分组。

单个API描述

上图中,只有接口的方法和路径,别人看了不知道是干啥的。@Operation注解可以帮你说明单个接口,举个例子,我项目是和旅行相关的,其中有个接口,它接受一个参数tripId,获取其对应的行程信息。我为了在Swagger UI中体现更多的信息,就写了这样的代码:

@Operation(summary = "获取行程信息", description = "获取行程的标题、默认交通方式和日程等信息")
@GetMapping("/trips/{tripId}")
public TripResponse detail(@AuthenticationPrincipal User user,
                           @Parameter(description = "行程ID") @PathVariable long tripId) {
    Trip trip = tripService.checkTrip(user, tripId);
    return responseGenerator.generate(trip);
}

它运行后在Swagger UI中就长下图这样。@Operation里的summary是简短的名字,description是更详细的调用说明。话说如果我们这样来弄,javadoc就不需要专门写了,直接管理注解就行了。好像我们实现代码和文档一致性,并不会增加更多的成本,团队成员比葫芦画瓢也能用起来,你说是不是?

方法

你有没有注意到图中有个“Try it out”按钮,点击它之后,你可以填写行程ID,并发送请求了。有了Swagger UI,Postman的必要性也不大了,不需要再花费额外精力维护了。开发效率大大提成,团队协作的一些沟通摩擦会减少很多,省得讨论一些“字段没有了”、“请求没响应”、“参数报错”等等无聊的问题。

模型定义

请求和响应涉及到3个注解:

  • @RequestBody 描述请求体
  • @ApiResponse 描述响应
  • @Schema POJO说明

@RequestBody加在接口的请求体上,写明description字段,可以描述请求体的信息。不过我有点不想用,因为请求体上往往有SpringMVC的@RequestBody 注解,两个同样名字的注解,注定导致其中一个要写全名,又臭又长。@ApiResponse是描述响应的,如果你的响应有多个状态码,比如200、401和404等,就可以通过它列出来,方便接口的调用者知道调用之后有哪些可能的情况,举个例子:

@Operation(description = "获取当前用户信息")
@ApiResponse(responseCode = "200", description = "获取成功")
@ApiResponse(responseCode = "401", description = "身份验证失败",
        content = @Content(schema = @Schema(implementation = ErrorResponse.class) ))
@GetMapping("/current")
public CurrentUser currentUser(@AuthenticationPrincipal User user) { /* ... */ }

上述代码中有两个@ApiResponse,一个是请求成功时候的响应,第二个是某种失败情况下的响应。后者的状态码是401,也就是身份验证不通过,此时还是有响应体的,content字段指定响应内容,schema字段指定响应体,具体的实现类是ErrorResponse,springdoc会自动把这个类转换成JSON,看下图:

这样就可以定义不同情况下的响应了。这里的@Schema注解是非常重要的,它可以像上图一样,放到某个单独的请求中。它还可以放到某个公共的响应类上,通过name字段指定名称,通过description进行详细描述。它还可以放到单个属性上,比如:

@Schema(description = "工单类型", allowableValues = {"auto", "manual"})
private String type;

这个体现在最终的Swagger UI中,就是下图这种,显示字段的可用值,不用再手动告诉前端同学,到底哪些值是可选的,哪些是合法的等等。省事儿指数+1。

几个场景

除了上述的基本信息,我再说几个实际可能碰到的情景,你可能会遇到。

身份验证

大部分项目都有身份验证机制,比如短信验证、用户名密码和邮箱密码等。验证成功之后一般给个token,后序访问业务接口的情况下,把这个token带上。我都是放到Header里的,起名X-Token。那么在网页上操作Swagger UI的时候,也需要给接口提供token。有两个注解我们会用到:

  • @SecurityScheme 定义安全方案
  • @SecurityRequirement 说明接口的安全需求

举出一个简单的例子,实现上述的需求:

@SecurityScheme(
        type = SecuritySchemeType.APIKEY,
        name = TOKEN_HEADER,
        in = SecuritySchemeIn.HEADER)
@Component
public class OpenAPIMetaData {
    public static final String TOKEN_HEADER = "X-Token";
}

@SecurityScheme注解用来定义安全方案,上面代码中的type字段用来表示是哪种类型的,SecuritySchemeType.APIKEY表示API访问所需的key,也就是上面提到的token。name字段表示这个安全方案的名字。in字段表示验证信息放在哪里,除了HEADERSecuritySchemeIn中还有QUERYCOOKIE,这个从字面上就可以理解。那么最终来说,上面代码的含义就是通过Header里的X-Token值来进行身份验证。

定义完安全方案之后,还需要声明哪些接口需要这个安全方案,轮到@SecurityRequirement登场了。

@SecurityRequirement可以放在@OpenAPIDefinition中,如下图:

@OpenAPIDefinition(
    info = @Info(/* 省略 */),
    security = @SecurityRequirement(name = TOKEN_HEADER)
)

此代码就是把安全方案应用到所有的接口上。如果你只是部分接口上需要验证,可以把@SecurityRequirement放到Controller上,或者放到单独的方法上。

最后,就剩下在Swagger UI中操作了:

security

一旦定义完安全方案,界面内会出现“Authorize”按钮,点击之后会有一个对话框填写验证身份的token。如果某个接口需要验证,Swagger UI中的最右侧会出现一个锁的图标,填写完token之后,这个锁会锁住。

字段设置

springdoc将POJO转化为请求和响应的时候,用的是Jackson中的ObjectMapper,但不是Spring容器里的,是它自己生成的。这就存在一个问题,如果你有一些自定义的属性,就比较麻烦:

# 驼峰转下划线
spring.jackson.property-naming-strategy=SNAKE_CASE

这个属性的意思指,Java对象中的字段,和JSON中的字段,是驼峰风格和下划线之间的相互转换。springdoc自己的ObjectMapper不知道这个属性,怎么做呢,好说,用我们自己的就行:

@Bean
public ModelResolver modelResolver(ObjectMapper objectMapper,
                                   SpringDocConfigProperties properties) {
    return new ModelResolver(objectMapper)
            .openapi31(properties.isOpenapi31());
}

这样操作之后,Spring容器中原来的ModelResolver就会被我们创建的这个替换掉,这样就能正确输出字段了。

接口排序

默认情况下,Swagger UI中,分组的排序是不确定的,分组内接口的排序也是不确定的。为了能更好地组织接口,我们得找到一种方法来调整它们的顺序。我搜了搜,有两个属性可以控制:

# 控制分组排序,alpha表示字典序排序
springdoc.swagger-ui.tagsSorter=alpha
# 控制分组内排序的,除了alpha,还有method,也就是按照方法排序
springdoc.swagger-ui.operationsSorter=alpha # or method

除了上面提到的值,这两个属性还可以配置成一个JS函数,就是一个比较函数,它会在前端使用。这个就有点奇怪,在Java的配置文件里写JS代码?好别扭呀,我如果要写个有点复杂的比较函数,难道要先uglify一下,再拷贝到配置里?显然是设计有问题了。强迫症患者表示无法接受。

搜了搜,这个没有十分完美的方案。有一个方便自定义的分组排序方案,贴出代码我再解释:

@Bean
public OpenApiCustomizer tagOrder() {
    String[] tagPriority = new String[] {"tag1", "tag2", "tag3"};
    return openApi -> {
        Comparator<Tag> comparator = Comparator.comparing(tag ->
                ArrayUtils.indexOf(tagPriority, tag.getName()));
        openApi.getTags().sort(comparator);
    };
}

这个OpenApiCustomizer类如其名,用来自定义OpenAPI对象的。我们可以获取到所有的Tag,这个列表的顺序就决定了分组的顺序,那么我们把数组排序下不就得了?在Java层面,你想怎么排序就很自由了。在代码中,我自定义了一个Comparator,它选取Tag名称,按照预先设定的顺序来排序,所以整理tagPriority就可以了,是不是很简单?

需要注意的是,这个openApi.getTags()返回的Tag,是OAS中顶层定义的Tag。 必须是你显式用@Tag注解定义过的,而且还不能只定义name属性,所以你额外再定义个description属性吧,那种隐式生成的Tag不在其中。Swagger注解文档OAS文档中都有描述,有兴趣的可以看看。

致于分组内的排序,我懒得搞了,爱怎么排怎么排吧。

总结

就归纳这么多吧 ,这些足以应付简单的API文档需求了。我写这篇文章搞了我一个星期,事无巨细地去了解了Swagger和OpenAPI,其实投入产出比挺低的,我急着去写业务代码呢。希望你看了我的经验,能减少一些时间上的浪费。


最后修改于 2024-06-22