Administrator
发布于 2020-09-29 / 2902 阅读
48

Swagger/Knife4j 接口文档自动化实践

起因:前后端又为接口字段吵起来了

九月末的一次需求联调,前端同学找我:"你这个接口的 amount 字段到底是字符串还是数字?我这边拿到的有时候是 "19.90",有时候是 19.9。"

查了一下,是 BigDecimal 序列化的问题,有些地方用了 @JsonSerialize(ToStringSerializer) 有些没用。但更大的问题是我们一直没有接口文档,接口定义靠口头和 Postman 的收藏夹,改了字段没人通知。

于是花了两天把 Swagger 配上。这篇记一下过程,主要是几个容易忽略的配置。

为什么选 Knife4j

原生的 springfox-swagger-ui 界面实在不好用:没有搜索、没有全局参数、不能导出、中文接口一堆英文标签。Knife4j 是国产的增强方案(前身是 swagger-bootstrap-ui),在 springfox 的基础上换了套 UI,加了这些功能:

  • 接口搜索、按 tag 分组折叠
  • 全局参数(比如统一加 token 请求头)
  • 导出 Markdown / HTML / Word 文档
  • 离线文档缓存

我们用的版本组合:

<!-- Spring Boot 2.3.4 + Knife4j 2.0.4(对应 springfox 2.10.5 / Swagger 2.0 规范)-->
<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-spring-boot-starter</artifactId>
    <version>2.0.4</version>
</dependency>

只要引这一个依赖,knife4j-spring-boot-starter 内部已经依赖了 springfox-swagger2springfox-swagger-ui,不需要再单独引。

配置类

@Configuration
@EnableSwagger2
@EnableKnife4j
public class SwaggerConfig {

    @Bean
    public Docket createRestApi() {
        return new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(apiInfo())
                .select()
                // 只扫描带 @ApiOperation 的方法,避免把 Spring 自带端点也扫进来
                .apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
                .paths(PathSelectors.any())
                .build()
                .globalOperationParameters(globalParams());
    }

    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title("订单服务 API")
                .description("订单中心接口文档")
                .version("1.0")
                .contact(new Contact("后端组", "", "backend@xxx.com"))
                .build();
    }

    private List<Parameter> globalParams() {
        ParameterBuilder builder = new ParameterBuilder();
        builder.name("Authorization")
               .description("登录令牌")
               .modelRef(new ModelRef("string"))
               .parameterType("header")
               .required(false)
               .build();
        return Collections.singletonList(builder.build());
    }
}

几个点:

  • @EnableKnife4j 是 Knife4j 的注解,加上它才会有增强的 UI 和 /doc.html 页面。
  • RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class)basePackage 更好用。用 basePackage("com.xxx.controller") 的话,一些继承来的或者 Spring 的默认端点也可能被扫进去,文档里会出现一堆没用的接口。要求每个接口方法都写 @ApiOperation,顺便强制大家写描述。
  • 全局 header 参数globalOperationParameters,这样每个接口调试页面都会自带一个 Authorization 输入框,不用每次手填。

注解怎么用

Controller 层

@Api(tags = "订单管理")
@RestController
@RequestMapping("/api/order")
public class OrderController {

    @ApiOperation("查询订单详情")
    @GetMapping("/{orderNo}")
    public Result<OrderDetailVO> detail(
            @ApiParam(value = "订单号", required = true, example = "SO202009290001")
            @PathVariable String orderNo) {
        return Result.success(orderService.getDetail(orderNo));
    }

    @ApiOperation(value = "创建订单", notes = "会校验库存和优惠券,失败时返回具体原因码")
    @PostMapping
    public Result<CreateOrderVO> create(@RequestBody @Valid CreateOrderDTO dto) {
        return Result.success(orderService.create(dto));
    }
}

example 属性很重要。前端打开文档点"调试"的时候,示例值会自动填好,省去他猜格式的麻烦。

DTO / VO 层

@ApiModel("创建订单请求")
@Data
public class CreateOrderDTO {

    @ApiModelProperty(value = "用户ID", required = true, example = "10086")
    @NotNull(message = "用户ID不能为空")
    private Long userId;

    @ApiModelProperty(value = "收货地址ID", required = true, example = "3321")
    @NotNull
    private Long addressId;

    @ApiModelProperty(value = "订单金额(单位:分)", required = true, example = "1990")
    @NotNull
    @Min(1)
    private Long amount;

    @ApiModelProperty(value = "使用的优惠券ID,不使用传 0", example = "0")
    private Long couponId;

    @ApiModelProperty(hidden = true)          // 内部字段,不出现在文档里
    private String operator;
}

这个 amount 字段的注释解决的就是开头那个问题——单位写清楚example = "1990" 加上"单位:分",前端一眼就明白了。

@ApiModelProperty(hidden = true) 用于隐藏内部字段(比如操作人、租户ID),这些字段前端不该关心。

枚举类型

枚举字段 Swagger 默认展示成一堆枚举名,看不出含义。加 @ApiModelPropertyallowableValues

@ApiModelProperty(value = "订单状态", allowableValues = "WAIT_PAY,PAID,SHIPPED,FINISHED,CANCELED",
                  example = "PAID")
private String status;

分组配置:一个服务拆成"内部"和"开放"两档

我们的服务里有些接口是给内部其他微服务调用的(走 Feign),有些是给前端的。混在一个文档里又乱又不安全。用多 Docket 分组:

@Bean
public Docket openApi() {
    return new Docket(DocumentationType.SWAGGER_2)
            .groupName("1.开放接口")
            .apiInfo(apiInfo())
            .select()
            .apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
            .paths(PathSelectors.ant("/api/open/**"))      // 只匹配开放接口
            .build();
}

@Bean
public Docket innerApi() {
    return new Docket(DocumentationType.SWAGGER_2)
            .groupName("2.内部接口")
            .apiInfo(apiInfo())
            .select()
            .apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
            .paths(PathSelectors.ant("/api/inner/**"))
            .build();
}

@Bean
public Docket adminApi() {
    return new Docket(DocumentationType.SWAGGER_2)
            .groupName("3.管理后台")
            .apiInfo(apiInfo())
            .select()
            .apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
            .paths(PathSelectors.ant("/api/admin/**"))
            .build();
}

多个 DocketgroupName 区分,文档页面右上角会有下拉框切换。注意 groupName 必须唯一,重复的话 springfox 会报错。

分组之后还能做更细的权限控制——比如管理后台的接口文档只在内网暴露,这个后面说。

如果用了 Spring Cloud Gateway

我们网关是 Spring Cloud Gateway(Hoxton.SR8),把它自己各个微服务的文档聚合到一个入口。加个配置类:

@Component
public class SwaggerResourceProvider implements SwaggerResourcesProvider {

    private static final String API_URI = "/v2/api-docs";

    private final RouteLocator routeLocator;

    public SwaggerResourceProvider(RouteLocator routeLocator) {
        this.routeLocator = routeLocator;
    }

    @Override
    public List<SwaggerResource> get() {
        List<SwaggerResource> resources = new ArrayList<>();
        routeLocator.getRoutes().subscribe(route -> {
            resources.add(swaggerResource(
                    route.getId(),
                    route.getUri().toString().replace("lb://", "") + API_URI));
        });
        return resources;
    }

    private SwaggerResource swaggerResource(String name, String location) {
        SwaggerResource resource = new SwaggerResource();
        resource.setName(name);
        resource.setLocation(location);
        resource.setSwaggerVersion("2.0");
        return resource;
    }
}

网关引入 knife4j-spring-boot-starter 之后访问 http://gateway:port/doc.html,右上角下拉框里能看到所有微服务。

注意网关里要放行这几个路径:

spring:
  cloud:
    gateway:
      routes:
        - id: order-service
          uri: lb://order-service
          predicates:
            - Path=/api/order/**
          filters:
            - StripPrefix=2

另外网关的鉴权过滤器要把 /doc.html/v2/api-docs/webjars/**/swagger-resources/** 这几个路径排除掉,否则 Swagger 拉不到文档定义。

生产环境必须关掉

这是最重要的一节。Swagger 文档会把你的所有接口路径、参数结构、甚至示例值都暴露出去,对攻击者来说是一份完整的攻击地图。而且 Knife4j 的调试功能是可以直接发请求的。

我们的做法是用 @Profile,只在非 prod 环境装配:

@Configuration
@EnableSwagger2
@EnableKnife4j
@Profile({"dev", "test"})        // 生产不加载
public class SwaggerConfig {
    ...
}

@Profile 在配置类上有个问题:如果生产环境误配了 spring.profiles.active=dev 就全暴露了。所以再加一层开关,双保险:

@Configuration
@EnableSwagger2
@EnableKnife4j
@ConditionalOnProperty(name = "swagger.enabled", havingValue = "true", matchIfMissing = true)
public class SwaggerConfig {
    ...
}
# application-prod.yml
knife4j:
  production: true        # Knife4j 自带的开关,开启后禁用调试功能
swagger:
  enabled: false

knife4j.production=true 是 Knife4j 2.0 之后提供的配置项。开启之后:所有接口的"调试"按钮消失,文档只可看不可用。这个适合"要在生产保留文档给内部排查用,但不能让人随便发请求"的场景。

还有一层:生产上干脆不暴露这个路径。我们在 Nginx 上加了规则:

location ~* /(doc\.html|v2/api-docs|swagger-resources|webjars) {
    allow 10.20.0.0/16;      # 只允许内网
    deny all;
}

三层防护(profile + 配置开关 + 网络层),我见过有公司因为 Swagger 没关被扫描器扫走整个接口列表的。

几个踩过的坑

坑一:SpringBoot 2.3 的路径匹配问题

Spring Boot 2.3 之后 spring.mvc.pathmatch.matching-strategy 的默认值变了,springfox 2.10.5 会报这个错:

org.springframework.context.ApplicationContextException: Failed to start bean
'documentationPluginsBootstrapper'; nested exception is java.lang.NullPointerException
	at org.springframework.web.servlet.handler.HandlerMethodMappingNamingStrategy...

解决办法是在配置文件里显式指定:

spring:
  mvc:
    pathmatch:
      matching-strategy: ant_path_matcher

坑二:接口上没写 @ApiOperation 就扫不到

因为我们用了 withMethodAnnotation(ApiOperation.class),忘写注解的接口不会出现在文档里。这个是有意为之,但新人不知道,经常跑来问"我的接口怎么没显示"。后来在 CI 里加了个检查,Controller 的 public 方法必须有 @ApiOperation

坑三:LocalDateTime 参数显示成一堆字段

不加处理的话,LocalDateTime 类型的字段在文档里会展开成 yearmonthdayOfMonth 等一堆内部字段,非常难看。加一个类型替换规则:

@Bean
public Docket createRestApi() {
    return new Docket(DocumentationType.SWAGGER_2)
            .directModelSubstitute(LocalDateTime.class, String.class)
            .directModelSubstitute(LocalDate.class, String.class)
            .directModelSubstitute(BigDecimal.class, String.class)
            ...
}

BigDecimal 也替换成 String,正好解决开头那个"到底是字符串还是数字"的争议——文档里统一显示成字符串,和 @JsonSerialize(ToStringSerializer) 的行为一致。

坑四:文件上传接口

文件参数要在文档里能直接选文件,需要指定 dataType:

@ApiOperation("导入商品")
@PostMapping("/import")
public Result<Integer> importItems(
        @ApiParam(value = "Excel文件", required = true)
        @RequestPart("file") MultipartFile file) {
    ...
}
// springfox 会把 MultipartFile 识别成 file 类型,Knife4j 页面上有选择文件的按钮

效果

配完之后的变化其实不在技术层面:

  • 联调时间:原来一个中等复杂度的需求,前后端对齐接口要来回三四轮,现在基本一次过。
  • 接口变更:Swagger 的 JSON 定义可以 diff,字段改了前端能立刻发现。
  • 新人上手:新同事不用再问"这个接口怎么调",直接看文档。

另外一个意外收获:Knife4j 支持导出 Markdown,我们发版时把接口变更的部分导出来贴到需求单里,评审的时候产品也能看懂。

小结

  1. Knife4j 2.0.4 配 Spring Boot 2.3 需要显式设 spring.mvc.pathmatch.matching-strategy=ant_path_matcher,否则启动报 NPE。
  2. withMethodAnnotation(ApiOperation.class) 而不是 basePackage,文档更干净,也顺带强制大家写接口描述。
  3. @ApiModelPropertyexample 和"单位"注释是减少前后端扯皮的关键,别偷懒。
  4. 多 Docket 用 groupName 分组,网关聚合用 SwaggerResourcesProvider
  5. 生产必须关:profile 开关 + knife4j.production=true + Nginx 网络层限制,三层都要有。LocalDateTimeBigDecimal 记得做类型替换。

参考