OpenAPI与Swagger实战:从代码生成到CI质量门禁
1. 从一份被投诉的接口文档说起去年年底团队里负责对接外部合作方的小组收到了一封措辞相当不客气的邮件。对方的技术负责人列了整整两页问题字段类型标注不一致、错误码没有统一说明、分页参数在三个接口里出现了三种写法、示例请求体里还残留着测试环境的域名。最要命的是这份文档是我们手动维护在一个内部协作平台上的更新滞后了将近两周而这两周里后端已经改了四个接口的返回结构。这件事之后我们下定决心把API文档的生成方式彻底翻新。核心思路只有一条让文档从代码里长出来而不是靠人去手写维护。围绕这个目标我们最终落地了一套基于OpenAPI规范、用Swagger系工具链做呈现和调试的方案。这套东西说起来概念不复杂但真正在项目里跑通、跑顺中间踩的坑一点都不少。这篇内容适合三类人看一是正在被手写文档折磨、想找一套可持续方案的后端开发者二是需要和前端、测试、外部合作方频繁对接接口的团队负责人三是刚接触OpenAPI和Swagger、分不清这俩到底啥关系的初学者。我会从概念辨析讲起一直讲到注解怎么写、UI怎么配、CI里怎么卡质量把整套流程里那些文档上不会写的经验都摊开来说。先给一个最直白的结论OpenAPI是规范Swagger是实现这套规范的一堆工具。很多人把这两个词混着用导致沟通时经常鸡同鸭讲。搞清楚这个区别后面的所有选择都会顺理成章。2. OpenAPI与Swagger到底谁是谁2.1 一个规范一套工具链OpenAPI Specification简称OAS是一份语言无关的接口描述规范它定义了一个JSON或YAML文件应该长什么样才能完整描述一组HTTP接口路径、方法、参数、请求体、响应体、状态码、鉴权方式等等。你可以把它理解成接口的身份证模板——只要按这个模板填任何懂这套规范的工具都能读懂你的接口。Swagger则是一整套围绕OpenAPI规范构建的工具集合。最早Swagger规范本身就是OpenAPI的前身后来规范捐给了Linux基金会下的OpenAPI Initiative改名OpenAPI而Swagger这个名字保留下来专指工具链。所以现在你听到的Swagger通常指的是这几样东西Swagger Editor在线或本地的编辑器左边写YAML/JSON右边实时预览文档。Swagger UI把OpenAPI描述文件渲染成可交互网页的工具能直接在页面上发请求。Swagger Codegen根据描述文件生成客户端SDK或服务端桩代码。Swagger Hub托管和协作平台商业产品。在Java生态里还有两个高频出现的库需要区分清楚springfox和springdoc。前者是老牌选手支持Swagger 2规范对Spring Boot 2.6以上版本兼容性越来越差后者是后起之秀直接支持OpenAPI 3规范和Spring Boot新版本配合得更好。我们项目在选型时就是因为springfox在新版本Spring Boot上各种报错果断换成了springdoc。2.2 为什么非要选OpenAPI 3而不是Swagger 2这个问题在选型会上被反复问过。Swagger 2的生态确实成熟很多老项目还在用但OpenAPI 3有几个实打实的优势让我们无法拒绝对比维度Swagger 2OpenAPI 3请求体描述用body参数和form参数混在一起独立的requestBody对象支持多content-type响应描述只能按状态码描述支持按content-type区分不同响应结构组件复用definitions和parameters分开统一到components下支持更多类型示例支持较弱支持example、examples多示例回调与链接不支持支持callbacks和links最直观的差别在多content-type支持上。我们有个上传接口既接受application/json的元数据又接受multipart/form-data的文件流Swagger 2描述起来非常别扭OpenAPI 3用requestBody下的content字段就能干净地表达。所以除非有历史包袱新项目一律上OpenAPI 3。2.3 描述文件长什么样在动手写注解之前先看一眼原生的OpenAPI描述文件建立直观印象。下面是一个精简的例子openapi: 3.0.3 info: title: 订单服务接口 version: 1.2.0 description: 提供订单创建、查询、取消能力 paths: /orders/{orderId}: get: summary: 查询订单详情 parameters: - name: orderId in: path required: true schema: type: string responses: 200: description: 查询成功 content: application/json: schema: $ref: #/components/schemas/Order 404: description: 订单不存在 components: schemas: Order: type: object properties: id: type: string amount: type: number format: double status: type: string enum: [CREATED, PAID, CANCELLED]这份文件就是整个方案的核心资产。Swagger UI读它、Codegen读它、自动化测试工具也读它。理解了它的结构后面用注解生成它就只是把代码翻译成这份文件的过程。3. 在Spring Boot项目里落地springdoc3.1 依赖引入与版本匹配的坑我们用的是Spring Boot 3.x对应的springdoc版本是2.x。这里有个非常容易踩的坑springdoc 1.x对应Spring Boot 2.xspringdoc 2.x对应Spring Boot 3.x版本选错会直接启动失败报一堆javax和jakarta包冲突的错。因为Spring Boot 3把javax.全面换成了jakarta.而springdoc 1.x还在用javax。Maven依赖这样写dependency groupIdorg.springdoc/groupId artifactIdspringdoc-openapi-starter-webmvc-ui/artifactId version2.3.0/version /dependency如果你用的是WebFlux而不是WebMvc把artifactId换成springdoc-openapi-starter-webflux-ui。这个细节很多人第一次会忽略结果UI页面死活打不开。引入之后默认访问路径是/swagger-ui.html它会自动重定向到/swagger-ui/index.html。描述文件的默认地址是/v3/api-docs。这两个地址建议记牢后面配置和排查都要用。3.2 全局配置别让默认值坑了你springdoc的默认配置能跑但生产环境直接暴露会有问题。我们在application.yml里做了这些调整springdoc: api-docs: path: /v3/api-docs enabled: true swagger-ui: path: /swagger-ui.html tags-sorter: alpha operations-sorter: method disable-swagger-default-url: true packages-to-scan: com.example.order.controller paths-to-match: /api/**几个关键点解释一下。packages-to-scan限定扫描范围避免把一些内部管理接口也扫进去paths-to-match只匹配/api/**开头的路径把actuator那些监控端点排除掉tags-sorter和operations-sorter让UI里的接口按字母和HTTP方法排序接口多了以后找起来方便很多。注意生产环境一定要通过配置或网关把swagger-ui和api-docs的访问关掉或限制内网访问。我们见过有团队把带完整接口信息的文档页直接暴露在公网等于把系统结构图送给了别人。3.3 用注解把接口信息写进代码springdoc的核心注解来自io.swagger.v3.oas.annotations包。最常用的几个是Tag、Operation、Parameter、Schema。看一个完整的Controller示例RestController RequestMapping(/api/orders) Tag(name 订单管理, description 订单的创建、查询与取消) public class OrderController { Operation(summary 创建订单, description 根据商品和数量创建一笔新订单) ApiResponses({ ApiResponse(responseCode 200, description 创建成功), ApiResponse(responseCode 400, description 参数校验失败), ApiResponse(responseCode 409, description 库存不足) }) PostMapping public ResultOrderVO create( RequestBody Valid CreateOrderDTO dto) { return Result.ok(orderService.create(dto)); } Operation(summary 查询订单详情) GetMapping(/{orderId}) public ResultOrderVO detail( Parameter(description 订单ID, required true) PathVariable String orderId) { return Result.ok(orderService.detail(orderId)); } }DTO上的字段描述用Schemapublic class CreateOrderDTO { Schema(description 商品ID, example SKU10086, requiredMode RequiredMode.REQUIRED) private String skuId; Schema(description 购买数量, example 2, minimum 1, maximum 99) private Integer quantity; }这里有个经验example的值一定要填真实可用的样例。我们早期偷懒填了string、0这种占位符结果前端同学直接复制到调试工具里发请求全部报参数错误反过来投诉文档没用。后来统一要求example必须是能跑通的真实值这个问题就消失了。4. 让文档质量在CI里被卡住4.1 为什么文档也需要质量门禁文档写得好不好靠人自觉是靠不住的。项目一忙注解就懒得更新几个月后文档和代码又对不上了。我们的做法是把文档质量检查塞进CI流水线让它在合并请求阶段就暴露问题。具体检查三类东西一是描述文件能否正常生成生成失败说明注解有语法问题二是所有接口是否都有summary和description三是所有DTO字段是否都有description。后两项用脚本扫描生成的OpenAPI JSON就能实现。4.2 用脚本扫描缺失的描述思路很简单在CI里先启动应用或直接调用生成接口拿到/v3/api-docs的JSON然后用一段Python脚本遍历找出缺字段的地方。import json import sys with open(openapi.json, r, encodingutf-8) as f: spec json.load(f) missing [] for path, methods in spec.get(paths, {}).items(): for method, detail in methods.items(): if method not in (get, post, put, delete, patch): continue if not detail.get(summary): missing.append(f{method.upper()} {path} 缺少 summary) if not detail.get(description): missing.append(f{method.upper()} {path} 缺少 description) if missing: print(文档质量检查未通过) for item in missing: print( -, item) sys.exit(1) print(文档质量检查通过)这段脚本挂到CI的某个阶段不通过就阻断合并。刚开始团队会有点抵触觉得增加了负担但两周之后就习惯了因为写注解本来就是顺手的事被卡一次比被合作方投诉十次划算得多。4.3 把描述文件作为构建产物归档每次构建时把生成的openapi.json作为产物归档好处有两个。一是可以追溯历史版本接口什么时候改的、改成什么样翻归档文件一目了然。二是可以拿它做契约测试前端可以基于某个版本的描述文件生成mock服务后端没写完也能先联调。我们用的是在构建脚本里加一步curlcurl -s http://localhost:8080/v3/api-docs -o openapi.json然后在流水线的产物配置里把这个文件声明为归档项。这一步几乎零成本但收益很大。5. 那些文档里不会写的踩坑记录5.1 泛型返回类型被吞掉的问题我们统一用ResultT包装返回结果发现Swagger UI里所有接口的响应schema都显示成Result里面的泛型T完全丢失看不到具体字段。这是Java泛型擦除导致的经典问题。解决办法是在方法上显式指定响应类型用Operation配合ApiResponse的content或者更简单地在Schema里用implementation指定。springdoc对泛型的支持其实做了不少工作但遇到多层嵌套泛型时还是会力不从心。我们的做法是给每个具体返回类型定义一个别名类比如ResultOrderVO就定义一个OrderResult extends ResultOrderVO虽然有点笨但UI里显示得清清楚楚。5.2 日期格式在文档和实际返回里不一致DTO里有个LocalDateTime字段文档里显示成string但没说明格式。前端按ISO格式解析结果后端配置的Jackson序列化格式是yyyy-MM-dd HH:mm:ss两边对不上联调时排查了半天。后来我们在Schema里显式标注格式Schema(description 创建时间, example 2024-01-15 10:30:00, type string, format date-time) private LocalDateTime createTime;同时在全局配置里统一Jackson的日期格式让文档、实际返回、前端解析三者对齐。这个坑的教训是凡是格式敏感的类型都要在文档里写死格式并给真实示例不能指望别人去猜。5.3 分组配置让接口不再一锅粥项目大了以后所有接口堆在一个页面里找起来非常痛苦。springdoc支持用GroupedOpenApi做分组Bean public GroupedOpenApi orderApi() { return GroupedOpenApi.builder() .group(订单服务) .pathsToMatch(/api/orders/**) .build(); } Bean public GroupedOpenApi userApi() { return GroupedOpenApi.builder() .group(用户服务) .pathsToMatch(/api/users/**) .build(); }配置之后Swagger UI右上角会出现分组下拉框可以按业务域切换。我们按业务域分了六组对接方只需要看自己关心的那组清爽很多。5.4 鉴权信息怎么在UI里带上内部接口需要登录态Swagger UI默认发请求不带token导致所有需要鉴权的接口都返回401没法在线调试。解决办法是在配置里声明安全方案Bean public OpenAPI customOpenAPI() { return new OpenAPI() .info(new Info().title(订单服务).version(1.0)) .components(new Components() .addSecuritySchemes(bearerAuth, new SecurityScheme() .type(SecurityScheme.Type.HTTP) .scheme(bearer) .bearerFormat(JWT))) .addSecurityItem(new SecurityRequirement().addList(bearerAuth)); }配置后UI右上角会出现Authorize按钮填入token后所有请求都会自动带上Authorization头。这个功能对内部联调效率提升非常明显。6. 从文档到契约把OpenAPI用出更多价值6.1 用描述文件生成前端请求代码OpenAPI描述文件不只是给人看的还能直接生成前端调用代码。我们用openapi-generator-cli一条命令就能根据描述文件生成TypeScript的API客户端openapi-generator-cli generate \ -i openapi.json \ -g typescript-axios \ -o ./src/api生成的代码包含所有接口的封装、请求参数类型、响应类型前端直接import就能用。这样接口一改重新生成一次类型不匹配的地方编译期就报错比运行时才发现问题强太多。这一步把文档从参考材料升级成了契约价值完全不一样了。6.2 基于描述文件做接口mock后端接口还没写完前端要先行开发怎么办用描述文件起一个mock服务就行。工具很多原理都是读OpenAPI文件按schema生成符合结构的假数据。我们用的是prismprism mock openapi.json它会启动一个本地服务所有接口都返回符合schema的示例数据。前端可以完全按真实接口的方式去调等后端写完直接切地址即可。这个做法让前后端并行开发真正落地而不是停留在口号上。6.3 契约测试防止接口悄悄变更最怕的情况是后端改了接口但没通知前端上线才发现。我们的做法是在CI里加一步契约测试把当前生成的描述文件和上一个发布版本的描述文件做diff如果有破坏性变更比如删了字段、改了类型、加了必填参数就报警并要求人工确认。破坏性变更的判定规则可以自己定我们用的是这几条删除已有字段字段类型发生变化新增必填参数删除已有接口路径响应状态码减少非破坏性的变更新增可选字段、新增接口则允许直接通过。这套机制运行半年成功拦下了三次可能导致线上故障的接口变更。7. 一些关于长期维护的实在话整套方案跑下来我最大的体会是工具能解决文档怎么生成但解决不了文档愿不愿意维护。注解写在代码里改代码时顺手就改了这是它比手写文档强的地方。但如果团队没有把文档质量纳入流程再好的工具也会被绕过。我们后来定了几条规矩效果不错。第一任何新增接口的合并请求必须包含完整的注解CI会卡。第二接口有破坏性变更时必须在合并请求描述里说明影响范围。第三每个季度做一次文档巡检把长期没人访问的接口标记出来确认是否还需要保留。这些规矩不复杂但坚持下来文档的可用性就稳住了。另外提醒一句别追求一步到位。我们最开始只要求接口有summary后来才逐步加上description、example、错误码说明。如果一开始就要求面面俱到团队会觉得负担太重而抵触。循序渐进让习惯先建立起来再谈质量提升。最后分享一个我们内部用的小技巧把Swagger UI的地址做成二维码贴在工位上对接方来问接口时直接让对方扫码自己看。省下来的沟通时间比想象中多得多。