起因:前后端又为接口字段吵起来了
九月末的一次需求联调,前端同学找我:"你这个接口的 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-swagger2 和 springfox-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 默认展示成一堆枚举名,看不出含义。加 @ApiModelProperty 的 allowableValues:
@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();
}
多个 Docket 用 groupName 区分,文档页面右上角会有下拉框切换。注意 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 类型的字段在文档里会展开成 year、month、dayOfMonth 等一堆内部字段,非常难看。加一个类型替换规则:
@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,我们发版时把接口变更的部分导出来贴到需求单里,评审的时候产品也能看懂。
小结
- Knife4j 2.0.4 配 Spring Boot 2.3 需要显式设
spring.mvc.pathmatch.matching-strategy=ant_path_matcher,否则启动报 NPE。 - 用
withMethodAnnotation(ApiOperation.class)而不是basePackage,文档更干净,也顺带强制大家写接口描述。 @ApiModelProperty的example和"单位"注释是减少前后端扯皮的关键,别偷懒。- 多 Docket 用
groupName分组,网关聚合用SwaggerResourcesProvider。 - 生产必须关:profile 开关 +
knife4j.production=true+ Nginx 网络层限制,三层都要有。LocalDateTime和BigDecimal记得做类型替换。