给接口文档生成流程加一道校验,没写文档的接口别想进测试环境

后端最烦什么?不是加班,不是改需求,是联调时发现接口文档里缺了三个关键字段,对方还理直气壮说“代码里有,你自己看”。

这个问题本质上不是人的问题,是流程的问题。指望每个开发自觉写好文档不现实,得让没写文档的代码根本进不了测试环境。具体做法:在 CI/CD 流水线里加一道文档校验,跟 lint 和单测同级,文档不合格直接构建失败。

下面以我们团队的实际方案为例,基于 Spring Boot + Swagger/OpenAPI 的栈,讲讲怎么把这套机制落地。

校验什么:先定义“没写文档”的判定标准

先明确一件事:Swagger 注解存在 ≠ 文档合格。很多人习惯在每个接口上挂个 @ApiOperation("xxx") 就完事,参数说明、返回值模型、错误码一概没有,这种文档跟没有区别不大。

我们需要一套可量化的规则。我们团队定的是这三条:

  1. 每个公开 API 方法必须有 @Operation 注解,且 summary 不为空
  2. 每个接口的入参对象中,所有字段必须有 @Schema(description = "...") 注解,且 description 不为空
  3. 每个接口的返回值对象同理,所有字段必须有 @Schema(description = "...")

这三条覆盖了最常见的文档缺失场景。你可以根据自己的情况增减规则,比如要求必填字段必须标注 requiredMode,或者要求枚举值必须写说明。

怎么校验:用 ArchUnit 在编译期扫描源码

方案选型上,我们没走运行时拦截的路子,而是用 ArchUnit 在编译期做静态分析。原因很简单:运行时校验太晚了,而且影响启动速度。

ArchUnit 本身是架构测试工具,但它的类扫描能力恰好可以用来检查注解。核心思路:扫描所有 Controller 类的方法,检查 @Operation 注解;再顺着方法签名找出入参和返回值的类型,递归检查 @Schema 注解。

先加依赖,Maven 项目在 pom.xml 里引入:

<dependency>
    <groupId>com.tngtech.archunit</groupId>
    <artifactId>archunit-junit5</artifactId>
    <version>1.2.1</version>
    <scope>test</scope>
</dependency>

然后写测试用例。下面是我们实际在用的代码,稍微简化了一下:

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods;
import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAnyPackage;

import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaMethod;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ConditionEvents;
import com.tngtech.archunit.lang.SimpleConditionEvent;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.web.bind.annotation.RestController;

import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.List;

@AnalyzeClasses(packages = "com.yourcompany.controller")
public class ApiDocumentationTest {

    @ArchTest
    void all_public_api_methods_should_have_operation_annotation(JavaClass javaClass) {
        methods()
            .that().arePublic()
            .and().areDeclaredInClassesThat().areAnnotatedWith(RestController.class)
            .should(haveProperOperationAnnotation())
            .check(javaClass);
    }

    private ArchCondition<JavaMethod> haveProperOperationAnnotation() {
        return new ArchCondition<JavaMethod>("have proper @Operation annotation") {
            @Override
            public void check(JavaMethod method, ConditionEvents events) {
                // 跳过 Spring 内部方法和非 API 方法
                if (method.getName().equals("toString") || method.getName().equals("equals")) {
                    return;
                }

                Operation operation = method.getAnnotationOfType(Operation.class);
                if (operation == null) {
                    events.add(SimpleConditionEvent.violated(method,
                        "Method " + method.getFullName() + " is missing @Operation annotation"));
                    return;
                }
                if (operation.summary() == null || operation.summary().isEmpty()) {
                    events.add(SimpleConditionEvent.violated(method,
                        "Method " + method.getFullName() + " has @Operation but summary is empty"));
                }
            }
        };
    }

    @ArchTest
    void all_request_and_response_objects_should_have_schema_descriptions(JavaClass javaClass) {
        methods()
            .that().arePublic()
            .and().areDeclaredInClassesThat().areAnnotatedWith(RestController.class)
            .should(haveProperSchemaOnParameters())
            .check(javaClass);
    }

    private ArchCondition<JavaMethod> haveProperSchemaOnParameters() {
        return new ArchCondition<JavaMethod>("have @Schema description on all fields") {
            @Override
            public void check(JavaMethod method, ConditionEvents events) {
                // 检查方法参数
                method.getRawParameterTypes().forEach(paramType -> {
                    checkClassFields(paramType, events, method);
                });
                // 检查返回值
                JavaClass returnType = method.getRawReturnType();
                if (!returnType.getName().equals("void")) {
                    checkClassFields(returnType, events, method);
                }
            }
        };
    }

    private void checkClassFields(JavaClass clazz, ConditionEvents events, JavaMethod method) {
        // 跳过 JDK 基础类型和常见框架类
        if (clazz.getName().startsWith("java.") || clazz.getName().startsWith("org.springframework.")) {
            return;
        }
        // 获取原始 Class 对象,反射检查字段
        try {
            Class<?> rawClass = Class.forName(clazz.getName());
            for (Field field : rawClass.getDeclaredFields()) {
                // 跳过静态字段和序列化 ID
                if (java.lang.reflect.Modifier.isStatic(field.getModifiers())) continue;
                if (field.getName().equals("serialVersionUID")) continue;

                Schema schema = field.getAnnotation(Schema.class);
                if (schema == null) {
                    events.add(SimpleConditionEvent.violated(method,
                        "Field " + clazz.getSimpleName() + "." + field.getName()
                        + " is missing @Schema annotation"));
                } else if (schema.description() == null || schema.description().isEmpty()) {
                    events.add(SimpleConditionEvent.violated(method,
                        "Field " + clazz.getSimpleName() + "." + field.getName()
                        + " has @Schema but description is empty"));
                }
            }
        } catch (ClassNotFoundException e) {
            // 忽略无法加载的类
        }
    }
}

这套测试跑一次大概 2-3 秒,放在 mvn test 阶段完全不会拖慢构建。一旦有人写了新的 Controller 方法但没加文档注解,测试直接挂,CI 流水线就红了。

怎么嵌入流程:放在 CI 的 test 阶段,和单测同级

校验逻辑写好了,关键是怎么让它变成一道硬门槛。

我们的 CI 流程用的是 GitLab CI,配法很简单,在 .gitlab-ci.yml 里把测试命令放在 test stage:

test:
  stage: test
  script:
    - mvn test
  only:
    - feature/*
    - develop

mvn test 会自动执行所有的单元测试,包括上面写的 ArchUnit 文档校验测试。测试不通过,构建失败,对应的 feature 分支无法合并到 develop,自然也就部署不到测试环境。

如果你用的是 Jenkins 或者 GitHub Actions,逻辑完全一样——把 mvn test 挂在构建流程里,测试不通过就阻断后续的打包和部署步骤。GitHub Actions 的配置大概长这样:

name: Build and Test
on: [push, pull_request]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
      - name: Run tests
        run: mvn test
      - name: Build
        if: success()
        run: mvn package -DskipTests

if: success() 保证了只有测试通过才执行打包,没写文档的代码根本产不出制品。

进阶:结合 OpenAPI 生成阶段做二次校验

ArchUnit 校验的是源码层面的注解,但有时候注解写了,实际生成的 OpenAPI 文档可能因为配置问题而不完整。比如 Swagger 的 springdoc 插件没扫到某些路径,或者响应模型没有被正确展开。

我们在 integration-test 阶段加了一道补充校验:启动应用后调一下 /v3/api-docs 接口,用 JSON Schema 验证器检查生成的文档是否包含了所有必需的字段。

@Test
void generated_openapi_doc_should_contain_all_paths() {
    String openApiJson = restTemplate.getForObject("/v3/api-docs", String.class);
    // 检查关键路径是否存在
    assertThat(openApiJson).contains("/api/users");
    assertThat(openApiJson).contains("/api/orders");
    // 检查关键字段说明是否存在
    assertThat(openApiJson).contains("\"description\"");
}

这道校验比 ArchUnit 更重,需要启动完整应用上下文,所以我们把它放在 integration-test stage,在打包之后、部署之前执行。文档缺失在任何一个阶段都能被拦住。

落地阻力与应对

推行这套机制的时候,团队里一定会有人抱怨“太麻烦了”。我们的处理方式是:把校验规则写进团队编码规范,同时在 IDE 里配好 Live Template,让写文档和写代码一样快。

IntelliJ IDEA 里可以配一个快捷模板,输入 @schema 自动展开成 @Schema(description = "$END$"),光标直接停在 description 里。再加上 Code Review 阶段抽查,两周左右大家就习惯了。

另一个常见问题是老项目的存量代码。我们的策略是:新代码强制校验,老代码逐步治理。ArchUnit 的 @AnalyzeClasses 注解支持 packages 参数,可以先只扫描新模块,等老模块重构时再纳入扫描范围。

常见问题

这套校验会影响启动速度吗?

不会。ArchUnit 校验是纯编译期的静态分析,运行在 mvn test 阶段,根本不涉及应用启动。只有集成测试阶段的 OpenAPI 文档校验需要启动应用,但那是独立阶段,不影响日常开发。

DTO 里有些字段确实不需要 description,比如 id、createTime 这种通用字段,怎么办?

可以在 checkClassFields 方法里加白名单逻辑,跳过某些字段名或字段类型。比如跳过所有名为 idcreateTimeupdateTime 的字段,或者跳过 Long 类型的 id 字段。白名单规则直接写在测试代码里,团队内可见,避免滥用。

Swagger 注解太啰嗦,有没有更轻量的方案?

如果你的项目不用 Swagger UI,只是想生成一份结构化的接口文档给前端,可以考虑用 Javadoc + 自定义 Doclet 的方案,解析 Javadoc 标签生成文档。但就实际效果来说,Swagger 注解和代码在一起,可维护性更好。嫌啰嗦可以配 Live Template,敲三个字母就出来了。

如果我用的是 Spring WebFlux 或者 Kotlin,这套方案还适用吗?

ArchUnit 不关心底层是 WebMVC 还是 WebFlux,它只扫描字节码上的注解。只要你的 Controller 类有 @RestController 或类似注解,方法上有 @Operation,就能用。Kotlin 项目稍微注意一下反射那部分的兼容性,Kotlin 的 data class 字段名和 getter 的映射关系跟 Java 有点差异,可能需要用 Kotlin 反射库替换 java.lang.reflect.Field