在命名本文的标题都敲打了几分钟时间,问题很简单,然而用简短的一个标题完全描述出来却有点费事。在 Spring MVC 项目结合 Springfox 来生成 Swagger API 文档时,如果一个资源操作因为请求参数的不同而映射到多个 controller 方法,那么 Swagger 可能只能生成某一个 API 条目,其余都被忽略。至于为什么说是 "可能", 可能正好未遵循命名规范而躲过了这一劫。由此引出
我们这里用了资源操作一词,它包含了两部分信息: 资源与操作,比如 /users/{userId}
是资源,而发生在其上的 HTTP 各种方法,如 POST, GET, PUT, DELETE 等就是操作。而 Spring MVC 中允许我们针对不同的查询参数把相同的资源操作映射到不同的 controller 方法上,也是为了保持逻辑上更为清晰。
比如下面的例子路由配置的例子
GET /users/{userId} UserController.getUserInfo //默认
GET /users/{userId} UserController.getUserInfo //当有 ?source=file 时
GET /users/{userId} CloudUserController.getUserInfoFromCloud //当有 ?source=cloud 时
看到上面资源与操作完全相同,仅仅因为 source 查询参数的不同而映射到三个 controller 方法。用代码体现如下图
假设该应用的 Web 上下文是 swagger-test, 那么启动应用后,通过 URL http://localhost:8080/swagger-test/swagger-ui.html 访问,并且把所有 controller 下的API 都展开(看 controller 后显示的向下箭头就代表着展开了所有的 API)
从上图中看到,我们定义了三个 GET /users/{userId}
的 API, Swagger 只显示了一个,CloudUserController 中什么也没有,Swagger 只会显示它找到的最后一个。
这是为什么呢?事情要从源头上找,也就是 swagger-ui.html 显示的内容来自于这里的 http://localhost:8080/swagger-test/v2/api-docs, 打开来看, 在 paths
下只有 /users/{userId}
一个对象
它组织 API 的方式是 资源/操作
, 所以前面想要用参数来区分的三个 API,它们在 /v2/api-docs
都表示为
"paths": { "/users/{userId}": { "get": { ....
资源名都是 /users/{userId}
, 操作也都是 get
,如此 Swagger 在生成 JSON 文档时以上三个 API 使用相同的 JSON key, 造成相互覆盖,只有最后面那个 API 保留了下来。
由 Swagger 组织 资源/操作
的方式受到启发,其实只要做点变通就能让 Swagger 生成所有定义的 API,如果阅读到这儿的读者大概也猜到了。对啦,就是修改路径中的变量名(path variable name),我们不能总是用 {userId}
, 比如另两个改成 {user-id}
, {user_id}
, 这种做法更为混乱,那么下一个怎么办呢?倒不如简单了事,用序号去区分,如 userId$1
, userId$2
, 再多都能应会。
实际上路径变量的改名是对 Swagger 的欺骗行为,因为本质上, 从 RESTful 资源的概念来讲 /users/{userId}
与 /users/{userId$1}
是没有区别。为了达到欺骗的效果,我们只能合理的不去遵循 Java 的变量命名规则了。前面只要区分出不同就行,也可以用 userId1
, userId2
的方式,我之所以选用 userId$1
, userId$2
是效仿了 Java 在对付匿名类生成 class 文件名的做法。
回到前面的例子,修改后的代码如下
@RestController @RequestMapping("/users") public class UserController { @GetMapping(value = "/{userId}") public Map<String, Object> getUserInfo(@PathVariable("userId") Integer userId) { return ImmutableMap.of("UserId", userId, "Source", "DB"); } @GetMapping(value = "/{userId$1}", params = {"source=file"}) public Map<String, Object> getUserInfoFromFile(@PathVariable("userId$1") Integer userId) { return ImmutableMap.of("UserId", userId, "Source", "File"); } }
@RestController @RequestMapping("/users") public class CloudUserController { @GetMapping(value = "/{userId$2}", params = {"source=cloud"}) public Map<String, Object> getUserInfoFromCloud(@PathVariable("userId$2") Integer userId) { return ImmutableMap.of("UserId", userId, "Source", "Cloud"); } }
注意 @PathVariable
中的变量名也要作相应的修改。
重启服务,再次浏览 http://localhost:8080/swagger-test/swagger-ui.html, 是下面的情景
所有的 API 都展露无余,如果通过 swagger-ui.html 来直接对 API 进行测试的话,也都没问题,会命中各自对应的 controller 方法。
查看一下相应的 http://localhost:8080/swagger-test/api-docs
三个 API 由于路径中变量名的不同,它们有了各自独立的 JSON key, 才能在一个地方被平行的容得下。
目前我们是已知有三个 /users/{userId}
API 的 controller 方法实现,假如一个团队中其他成员又用一个不同的请求参数,或其他的方式对 /users/{userId}
又加了一个新的实现方法,会造成某一个 API 在 Swagger 文档中缺失。因此我们最好能有一种机制让 Swagger 生成 /v2/api-docs
文档中发现有相同的资源/操作发生时给予警示。
http://localhost:8080/swagger-test/v2/api-docs 文档的生成是由 @EnableSwagger2
开启的,应该从它入手。看了下源代码,这里先列一个线索
Spring MVC 的所有 API 会在 Spring 启动的时候被扫描并分组存入到 DocumentationCache
缓存中去,就是上面的 scanned
变量。这里面会保存所有 API 条目,不会按 资源/操作
进行去重,进到下一步
然后在访问 http://localhost:8080/swagger-test/v2/api-docs 会进入到 springfox.documentation.swagger2.web.Swagger2Controller
的 getDocumentation(@RequestParam(value = "group", required = false) String swaggerGroup, HttpServletRequest servletRequest)
方法。
/v2/api-docs
根据 group
从 documentationCache
中取出 Spring 启动时扫描到的所有 API, 此时取到的 documentation
仍然是包含所有 API (含重复的 资源/操作
)。关键代码出现在上面的高亮行
Swagger swagger = mapper.mapDocumentation(documentation);
mapper
的实现类是 ServiceModelToSwagger2MapperImpl
, 它的方法 mapDocumentation(Documentation from)
中的行
swagger.setPaths(mapApiListings(from.getApiListings()));
将会把重复的 资源/操作
过滤掉, mapApiListings(Multimap<String, ApiListing> apilistings)
的实现在抽象类 ServiceModelToSwagger2Mapper
中
protected Map<String, Path> mapApiListings(Multimap<String, ApiListing> apiListings) { Map<String, Path> paths = newTreeMap(); for (ApiListing each : apiListings.values()) { for (ApiDescription api : each.getApis()) { paths.put(api.getPath(), mapOperations(api, Optional.fromNullable(paths.get(api.getPath())))); } } return paths; }
上面代码高亮行 api.getPath()
是 key, 对应的 Path 包括所有允许的操作,按顺序是 get
, head
, post
, put
, delete
, options
, 和 patch
.
private Path mapOperations(ApiDescription api, Optional<Path> existingPath) { Path path = existingPath.or(new Path()); for (springfox.documentation.service.Operation each : nullToEmptyList(api.getOperations())) { Operation operation = mapOperation(each); path.set(each.getMethod().toString().toLowerCase(), operation); } return path; }
以上的高亮行, path.set(...)
设置某个路径的允许的操作,从这个点上可以发出警告。如果在 path.set("get", operation)
前,我们检测到 path.get("get")
不为 null 时说明有重复的 资源/操作
定义,给出警告信息。
具体的做法参考, ServiceModelToSwagger2MapperImpl
是一个用 @Component
定义的 Spring Bean, 我们可以创建它的子类,并声明为 @Primary
, 从而替换掉 Swagger2Controller
中的 ServiceModelToSwagger2Mapper
依赖。然后把前面的两个方法
protected Map<String, Path> mapApiListings(Multimap<String, ApiListing> apiListings) private Path mapOperations(ApiDescription api, Optional<Path> existingPath)
置换掉就行了。