ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

OpenSpec 规格驱动开发实战:从规格定义到代码生成与团队协作

OpenSpec 规格驱动开发实战:从规格定义到代码生成与团队协作 1. 从“规格散落各处”到“单一事实来源”OpenSpec 到底在解决什么问题如果你参与过稍微有点规模的软件项目大概率经历过这种场景需求文档在飞书里接口定义在 Swagger 里数据库字段说明在某个人的 Notion 里而实际代码里的类型定义又是另一套。前后端联调的时候后端说“我按文档来的”前端说“文档上不是这么写的”最后翻聊天记录翻了半小时才找到三个月前某次评审会上口头确认的改动。这种“规格漂移”几乎是所有多人协作项目的通病而 OpenSpec 想做的事情就是把这堆散落的东西收拢到一个地方让规格成为代码之外唯一可信的“事实来源”。OpenSpec 这个名字拆开看很直白——Open 加 Spec开放规格。它不是某个大厂闭门造车的产物而是一套围绕“规格驱动开发”理念构建的工具链和约定。核心思路是把 API 接口、数据模型、业务规则这些东西用一种机器可读、人类也能看懂的格式描述出来然后基于这份描述自动生成文档、类型定义、Mock 数据甚至测试用例。你改一处规格所有下游产物跟着变不用再手动同步五个地方。我第一次接触这类工具的时候心里是有点抵触的。因为过去几年“API 优先”“契约测试”这些概念喊得很响但真正落地的时候往往变成“多写一份 YAML 而已”维护成本反而上去了。OpenSpec 让我改变看法的点在于它的规格文件不是写给别人看的摆设而是能直接参与开发流程的活文档。比如你定义了一个用户注册接口请求体里 email 字段是必填且格式校验OpenSpec 可以据此生成 TypeScript 类型、生成请求校验中间件、生成 Postman 集合甚至生成对应的单元测试骨架。这些产物不是一次性的规格变了重新生成就行。那它适合谁用我觉得三类人收益最明显。第一类是中小团队的技术负责人团队里没有专职的文档工程师但又不想让接口文档变成“写完就过期”的废纸。第二类是前后端分离项目里的前端开发者经常因为后端字段改名、类型调整而返工有了规格驱动就能提前拿到稳定的类型定义。第三类是做 To B 产品或者开放平台的团队对外提供的 API 需要长期维护版本兼容性规格文件本身就是最好的版本差异对比依据。需要说明的是OpenSpec 目前并不是一个像 Swagger 那样有巨大生态的成熟标准它更像是一套正在演进中的实践方案。网上关于它的讨论集中在“怎么用”“值不值得用”这两个问题上说明大家还在探索阶段。我写这篇东西的出发点就是把我自己趟过的路、踩过的坑、以及那些文档里不会写的细节整理出来让后来的人少走点弯路。2. 规格文件长什么样OpenSpec 的描述语言与组织方式2.1 一份最小可用的规格定义包含哪些字段OpenSpec 的规格描述语言本身并不复杂它没有发明一套全新的语法而是在现有格式的基础上做了约定。你可以用 YAML 写也可以用 JSON甚至在某些实现里支持用 TypeScript 直接写规格。我个人的习惯是用 YAML因为可读性好diff 的时候也清晰。一份描述用户查询接口的规格大概长这样openapi: 3.0.0 info: title: 用户服务 version: 1.2.0 paths: /users/{userId}: get: summary: 获取用户详情 parameters: - name: userId in: path required: true schema: type: string format: uuid responses: 200: description: 用户信息 content: application/json: schema: $ref: #/components/schemas/User components: schemas: User: type: object required: - id - email - createdAt properties: id: type: string format: uuid email: type: string format: email nickname: type: string maxLength: 32 createdAt: type: string format: date-time看起来跟 OpenAPI 规范很像对吧确实OpenSpec 在语法层面大量借鉴了 OpenAPI 的成熟设计因为那套东西已经被验证过是可行的。但 OpenSpec 的侧重点不太一样——OpenAPI 主要面向“描述 HTTP 接口”而 OpenSpec 想覆盖更广的范围包括数据模型、事件定义、业务规则约束等等。这里有几个字段值得单独拎出来说。required数组决定了哪些字段是必填的这个信息在生成 TypeScript 类型时会直接变成非可选属性前端拿到类型就知道哪些字段不用做空值判断。format字段是校验的关键email格式会在生成的校验逻辑里变成正则匹配uuid会变成对应的格式检查。maxLength这种约束看起来不起眼但它能直接变成数据库建表时的字段长度限制省得你手动去对。注意format字段的取值在不同工具链里的支持程度不一样。email、date-time、uuid这些通用格式基本都支持但如果你用了自定义 format比如phone-cn那就需要自己写校验器插件不然生成出来的代码会忽略这个约束。2.2 规格文件的目录结构怎么组织才不乱单文件写规格只适合 demo 级别的小项目。真实项目里接口动辄几十上百个全塞一个文件里光是找某个接口就要滚半天。OpenSpec 社区比较推荐的做法是按业务域拆分然后用一个入口文件引用。我自己的项目里是这么组织的specs/ main.yaml # 入口文件引用其他规格 common/ schemas.yaml # 公共数据模型 errors.yaml # 统一错误码定义 user/ user-api.yaml # 用户相关接口 user-model.yaml # 用户数据模型 order/ order-api.yaml order-model.yaml入口文件main.yaml里用$ref把各个子文件串起来openapi: 3.0.0 info: title: 主规格 version: 1.0.0 paths: /users: $ref: ./user/user-api.yaml#/paths/~1users /orders: $ref: ./order/order-api.yaml#/paths/~1orders components: schemas: User: $ref: ./user/user-model.yaml#/User Order: $ref: ./order/order-model.yaml#/Order这种拆分方式的好处是不同业务域的负责人可以各自维护自己的规格文件合并冲突的概率大大降低。但有个坑要注意$ref的路径解析在不同工具里行为不一致。有的工具要求路径相对于当前文件有的要求相对于项目根目录。我建议统一用相对路径并且在 CI 里加一步“规格文件解析校验”确保所有引用都能正确解析。另外公共模型放在common/目录下是个好习惯但不要什么都往里塞。我见过一个项目把用户模型、订单模型、支付模型全放在 common 里结果 common 目录变成了新的“垃圾堆”。判断标准很简单如果一个模型只被一个业务域使用就放在那个业务域的目录下只有被两个以上业务域引用的模型才提升到 common。2.3 版本管理规格文件怎么跟代码版本对齐规格文件的版本管理是个容易被忽视但很要命的问题。代码有 Git 分支规格文件也有但两者的版本节奏往往不一致。后端可能已经改了接口但规格文件还没更新或者规格文件更新了但前端还没拉取最新版本。我的做法是把规格文件和代码放在同一个仓库里但用独立的版本号字段来标识规格的兼容性。info.version字段遵循语义化版本规范主版本号变了表示有不兼容的改动次版本号变了表示新增了向后兼容的功能修订号变了表示修了 bug 或者改了描述文字。然后在 CI 流程里加一个检查如果规格文件的主版本号变了但代码里没有对应的迁移脚本或者兼容层就阻断合并。这个检查用脚本就能实现读取规格文件的版本号跟上一个 commit 的版本号对比主版本号增加时触发人工确认。还有个实用技巧是在规格文件里加一个x-changelog扩展字段记录每次改动的摘要。这个字段不影响代码生成但 review 的时候一眼就能看出这次改了什么info: title: 用户服务 version: 1.3.0 x-changelog: - version: 1.3.0 date: 2025-01-15 changes: - 新增用户昵称字段 nickname - 用户列表接口支持按注册时间排序 - version: 1.2.0 date: 2024-12-20 changes: - 用户详情接口返回字段增加 createdAt3. 把规格变成代码OpenSpec 的代码生成链路拆解3.1 生成 TypeScript 类型前端最先受益的环节规格驱动开发最直接的收益方是前端。以前前端要等后端把接口写完、部署到测试环境才能拿到真实的字段定义。现在只要规格文件定稿前端就能生成类型定义提前开始写页面逻辑。OpenSpec 生成 TypeScript 类型的逻辑并不复杂核心是把规格里的components/schemas映射成 interface 或者 type。我用的工具链是基于openapi-typescript改造的生成出来的代码大概是这样export interface User { id: string; email: string; nickname?: string; createdAt: string; } export interface GetUserParams { userId: string; } export interface GetUserResponse { data: User; code: number; message: string; }这里有几个细节值得注意。nickname字段在规格里没有放在required数组里所以生成出来是可选属性nickname?: string。createdAt虽然是日期时间但在 JSON 传输里是字符串格式所以类型是string而不是Date。这些映射规则看起来简单但如果不一致前端写代码的时候就会很别扭。我踩过的一个坑是枚举类型的处理。规格里定义枚举通常是这样status: type: string enum: - active - inactive - banned生成 TypeScript 的时候有的工具会生成type UserStatus active | inactive | banned有的会生成enum UserStatus { Active active, ... }。前者更符合 TypeScript 的习惯后者在运行时会产生额外的对象。我建议在工具配置里明确指定用 union type 而不是 enum减少运行时代码体积。提示生成类型之后建议在项目里加一个npm run gen:types脚本并且在 CI 里检查生成结果是否和规格文件同步。如果规格改了但类型没重新生成CI 直接报错。这个检查能避免很多“本地能跑、线上报错”的问题。3.2 生成请求校验中间件后端的第一道防线前端类型是编译时的约束后端校验是运行时的保障。OpenSpec 可以根据规格里的约束条件生成请求参数校验的中间件代码。以 Node.js 生态为例用ajv这个 JSON Schema 校验库可以把规格里的约束直接转成校验规则。比如规格里定义了email字段是format: email、maxLength: 255生成的校验逻辑会检查请求体里的 email 是否符合邮箱格式、长度是否超限。如果校验不通过直接返回 400 错误附带具体的字段和原因。const validate ajv.compile({ type: object, required: [email, password], properties: { email: { type: string, format: email, maxLength: 255 }, password: { type: string, minLength: 8, maxLength: 64 } } }); app.post(/users, (req, res) { if (!validate(req.body)) { return res.status(400).json({ code: 400, message: 参数校验失败, errors: validate.errors }); } // 业务逻辑 });这样做的好处是校验规则和规格文件保持一致不会出现“文档说必填、代码里没校验”的情况。但有个性能问题要注意ajv.compile应该在应用启动时执行一次而不是每次请求都编译。我见过有人在路由处理函数里调用ajv.compileQPS 一上来 CPU 直接飙满。正确的做法是在模块加载阶段就把校验函数编译好请求处理时只调用编译后的函数。另一个坑是format校验的默认行为。ajv默认不认识email、uuid这些格式需要额外安装ajv-formats插件并注册。如果不注册format: email会被静默忽略等于没校验。这个坑很隐蔽因为不报错只是校验不生效。我建议在项目初始化的时候就加上格式校验插件并且写一个测试用例专门验证格式校验是否生效。3.3 生成 Mock 数据和测试用例联调阶段的加速器前后端联调最烦的事情之一是等后端部署。后端说“我本地跑通了等我发个测试环境”然后一等就是半天。OpenSpec 可以根据规格生成 Mock 数据前端直接用一个本地 Mock 服务就能开发不用等后端。Mock 数据的生成逻辑是根据字段类型和约束造一个合理的假值。email字段生成userexample.com这种uuid生成一个随机 UUIDdate-time生成当前时间。如果字段有example属性就优先用 example 里的值。我通常会在规格里给关键字段加上 example这样 Mock 数据看起来更真实email: type: string format: email example: zhangsantest.com测试用例的生成更有意思。OpenSpec 可以根据规格里的约束自动生成边界测试用例。比如password字段minLength: 8就会生成一个 7 位密码的用例预期失败和一个 8 位密码的用例预期成功。maxLength: 64会生成 65 位的用例。这些用例覆盖了常见的边界情况省得手动去写。不过自动生成的测试用例只能覆盖参数校验层面业务逻辑的测试还是得自己写。我的做法是把自动生成的用例作为基础然后在上面补充业务场景的测试。比如用户注册接口自动生成的用例只测参数格式我再手动加上“邮箱已存在”“密码强度不够”这些业务校验的用例。4. 落地 OpenSpec 时最容易翻车的几个环节4.1 规格文件和实际实现不一致怎么发现、怎么防规格驱动开发最大的风险不是工具不好用而是规格文件和实际代码脱节。规格说字段是必填代码里没校验规格说返回 200代码里返回了 500。这种不一致如果没人发现规格就变成了摆设大家又回到“看代码为准”的老路。发现不一致的手段有两个层面。第一个层面是自动化检查在 CI 里跑契约测试用规格文件生成测试用例拿真实的接口去跑看返回结果是否符合规格定义。这个检查能覆盖大部分结构性的不一致比如字段缺失、类型错误、状态码不对。第二个层面是人工 review。自动化检查只能验证“格式对不对”验证不了“语义对不对”。比如规格里说status字段的枚举值是active/inactive/banned代码里也确实返回了这三个值之一但业务上某个场景应该返回banned却返回了inactive这种问题自动化检查发现不了。我的做法是在 code review 清单里加一条如果这次改动涉及接口行为变化必须同步更新规格文件并且规格文件的改动要单独 review。防止不一致的根本办法是让规格文件成为开发的起点而不是事后的补充。新功能开发时先写规格review 通过后再写代码。这个流程听起来很理想化但实际操作中确实能减少很多返工。我自己的团队执行了三个月之后接口联调阶段的 bug 数量下降了大概四成。4.2 工具链选型别被“全家桶”绑架OpenSpec 本身是一套约定不是某个具体的工具。围绕这套约定有各种各样的工具可以选择。代码生成可以用openapi-generator校验可以用ajv文档渲染可以用redoc或者swagger-ui。选择多了是好事但也容易挑花眼。我的建议是按需选型不要一上来就搞全家桶。先从最痛的点入手如果前端等类型定义等得痛苦就先上类型生成如果后端参数校验老出问题就先上校验中间件。每个工具单独引入、单独验证跑通了再考虑集成。有个选型原则我觉得挺重要优先选社区活跃、文档齐全的工具哪怕功能少一点。我试过一个小众的规格代码生成器功能确实强大支持各种自定义模板但社区几乎没人用遇到问题只能自己啃源码。后来换成了openapi-generator功能没那么花哨但遇到问题搜一下基本都有答案整体效率反而更高。另外要注意工具之间的兼容性。比如openapi-generator生成的类型定义和ajv的校验规则对同一个规格文件的解读可能有细微差异。我遇到过规格里写nullable: true生成类型时变成了string | null但校验时ajv默认不允许 null导致类型说可以传 null 但校验会拒绝。这种问题需要在集成测试里覆盖确保各工具对规格的理解一致。4.3 团队协作怎么让所有人都愿意维护规格工具再好团队不配合也是白搭。我见过太多项目一开始轰轰烈烈搞规格驱动两个月后规格文件就没人更新了。根本原因通常是写规格的人觉得是额外负担用规格的人觉得规格不准还不如直接看代码。让规格文件保持活力的关键是把维护成本降到最低同时让不维护的代价变高。降低维护成本的做法包括提供规格文件的模板和片段让写规格像填表格一样简单在 IDE 里装规格文件的语法高亮和自动补全插件把常用的数据模型抽成公共组件避免重复定义。提高不维护代价的做法包括CI 里加规格同步检查规格没更新就阻断合并把规格文件的更新纳入 code review 的必查项定期跑契约测试不一致的地方自动创建 issue 指派给负责人。还有一个软性的技巧让规格文件变得“有用”。当前端发现从规格生成的类型定义确实减少了联调 bug当后端发现自动生成的校验中间件确实拦住了一些脏数据他们就会自发地维护规格。工具的价值被感知到了推广就不难了。5. 一个完整案例从零搭建规格驱动的用户服务5.1 需求梳理与规格初稿假设我们要做一个用户服务包含注册、登录、查询用户信息三个接口。按照规格驱动的流程第一步不是写代码而是写规格。先梳理数据模型。用户有 id、email、password、nickname、status、createdAt 这些字段。id 是 UUIDemail 是邮箱格式且唯一password 只在注册和登录时传输查询用户信息时不返回。status 是枚举值createdAt 是日期时间。然后梳理接口。注册接口是 POST /users请求体包含 email、password、nickname返回创建成功的用户信息。登录接口是 POST /sessions请求体包含 email、password返回一个 token。查询用户接口是 GET /users/{userId}返回用户信息。把这些整理成规格文件大概两百行 YAML。写完之后团队 review 一遍确认字段类型、必填性、枚举值都符合业务预期。这一步花的时间大概半天但省掉了后面无数次的“这个字段到底是不是必填”的扯皮。5.2 生成代码并集成到项目规格定稿后跑代码生成命令。生成 TypeScript 类型、请求校验中间件、Mock 数据、测试用例骨架。然后把这些产物集成到项目里。前端项目里把生成的类型定义放到src/types/目录下页面组件引用这些类型。后端项目里把校验中间件挂到对应的路由上Mock 数据放到mocks/目录下供本地开发使用。集成过程中要注意生成产物的版本管理。我建议把生成的代码也提交到 Git 仓库里而不是在构建时动态生成。这样做的好处是 review 的时候能看到类型定义的变化而且构建过程不依赖代码生成工具CI 更稳定。但要在 README 里写清楚生成命令并且加一个 CI 检查确保生成产物和规格文件同步。5.3 联调与迭代规格变更的标准流程联调阶段发现规格有问题是很正常的。比如前端说 nickname 字段应该允许为空后端说 status 枚举值要加一个 pending。这时候不要直接改代码而是走规格变更流程。标准流程是先改规格文件提交 PR说明变更原因和影响范围。然后重新生成代码把生成产物的变更也放在同一个 PR 里。review 通过后合并前后端各自拉取最新代码重新生成自己这边的产物。这个流程比“直接改代码然后口头通知”要慢一点但好处是变更可追溯、影响可评估。我自己的经验是走标准流程的变更后续出问题的概率明显更低。因为改规格的时候会强迫你想清楚这个字段改了哪些接口会受影响哪些下游系统需要同步更新5.4 上线后的规格维护上线不是终点规格维护是长期工作。我的做法是每个月做一次规格审查检查规格文件和实际接口是否一致清理不再使用的字段和接口更新版本号和 changelog。审查的时候用契约测试跑一遍全量接口生成一份一致性报告。不一致的地方分两类处理如果是代码没跟上规格就改代码如果是规格没跟上代码就改规格。两类问题都要记录在案避免反复出现。还有个实用技巧是把规格文件部署成在线文档让产品、测试、运维都能访问。产品看接口能力测试看字段约束运维看版本变更。文档的访问量本身就是一个信号——如果没人看说明规格文件的价值没被认可需要反思推广方式。6. 我对 OpenSpec 这套东西的真实看法用了大半年 OpenSpec 之后我的感受是它不是一个银弹但确实解决了一类很具体的问题。如果你的项目只有两三个接口前后端就一个人那规格驱动开发的收益很有限直接写代码更快。但如果你的项目有几十个接口前后端多人协作接口变更频繁那规格驱动带来的收益是实实在在的。最大的收益不是省了多少写文档的时间而是减少了沟通成本。以前前后端联调一半时间在扯“这个字段到底有没有”“这个状态码是什么意思”。现在规格文件就是共同语言有争议就看规格规格没写清楚就补规格。这种确定性的提升对团队效率的影响是深远的。当然也有不顺手的地方。规格文件的编写本身需要学习成本团队里总有人觉得“多此一举”。工具链的成熟度也参差不齐遇到问题有时候得自己写脚本解决。但这些问题是工程实践中常见的不是 OpenSpec 独有的。如果你打算尝试我的建议是从一个小模块开始不要一上来就全量铺开。选一个前后端协作最痛、接口变更最频繁的模块用规格驱动的方式做一遍看看效果。跑通了再逐步推广跑不通就及时止损。工程实践没有标准答案适合自己的才是最好的。
返回列表