☰
JTD八种Schema与Ajv类型安全实战:接口契约校验避坑指南
2026/10/29 4:05:14 网站建设 项目流程

做数据接口的同学大多有过类似的经历:一条链路上的校验规则分散在十几个文件里,有人用 JSON Schema 把每个字段的pattern、minimum、maxLength都写满了,有人只写了个type: "object"就算交差,团队里三个人能写出三种风格。更麻烦的是,schema 本身成了一份"半文档",没人能保证它和代码里的 TypeScript 接口始终一致。JSON Type Definition(下称 JTD)就是在这种场景下被提出来的,而 Ajv 作为 JS 生态里最常用的校验器,对 JTD 的支持做得相当完整——它不只能校验,还能生成类型、生成解析器和序列化器。这篇内容面向已经用过 Ajv、正在被 JSON Schema 表达力折腾过的后端或全栈开发者,也适合刚接触接口契约校验、想一次性把规则写对的人。我会把 JTD 的八种 schema 形态逐个拆开,讲清楚每种形态的边界和组合禁区,再把类型安全和扩展机制这两块落到可复制的代码上,最后说说我自己在项目里踩过的几个坑。

1. JTD 的减法设计:校验规则到底该多能表达

JSON Schema 的设计目标是"描述任意 JSON 文档的约束",这个目标决定了它必须足够通用,于是oneOf、anyOf、not、if/then/else、条件依赖这些东西都进来了。表达力是够了,代价是每多一个关键字,校验器的实现复杂度、schema 的可读性、跨语言实现的兼容性都会跟着涨。JTD 的出发点完全相反:它先问"一个接口契约真正需要表达什么",答案基本就是——字段叫什么、什么类型、必填还是可选、允不允许额外字段、数组元素长什么样、对象是不是多态。这六件事之外的,JTD 一律不管。

1.1 一份契约从三人三样到一种写法

先说清楚 JTD 砍掉了什么。数字范围约束(minimum/maximum/exclusiveMinimum)、字符串模式(pattern)、字符串长度、数组长度、唯一性、格式校验(除 timestamp 外)、组合逻辑(allOf/anyOf/oneOf/not)、条件校验(if/then/else、dependentRequired)——这些在 JSON Schema 里属于日常操作,在 JTD 里一个都没有。

砍掉它们换回来的第一件事是写法收敛。因为可选表达极少,两个人写同一份契约几乎不会出现风格差异。properties里就是字段定义,可选字段放到optionalProperties,多态用discriminator,数组用elements,字典用values。就这几种,没有第二种写法能达到同样效果。换回来的第二件事是跨语言一致性。JTD 的规范短到一页纸能读完,任何语言的实现都不会出现"这个关键字我支持你不支持"的兼容性问题。如果你的 schema 要在 JS、Go、Python 三个服务之间共享,这一点比什么都重要。

代价也很明确:数字范围、正则格式这些约束必须挪到应用层去写。我一开始觉得这是倒退,后来发现其实更合理——age字段要求 0 到 150,这本质是业务规则,不是结构契约。把它写在业务代码里,报错信息能带上字段的中文名和当前值,比校验器吐一句must be <= 150有用得多。

一个务实的判断标准:如果这条约束靠"字段长什么样"就能确定,放 JTD;如果这条约束需要业务背景才能解释,放代码。

1.2 JTD 的类型系统故意不完整

JTD 的type只有十一个取值:boolean、string、timestamp、float32、float64、int8、uint8、int16、uint16、int32、uint32。注意这里没有int64和uint64,也没有any或者object。

没有 64 位整数不是疏忽。JSON 的数字本质上是一个双精度浮点数,超过2^53 - 1之后就存在精度丢失的风险,一个int64字段从后端传到前端再传回来,有可能已经不是原来的数了。JTD 规范选择不提供这个类型,是在逼你面对这个问题:要么把大整数用字符串表示,要么拆成两个字段,要么明确接受精度损失。我在一个订单号场景里就吃过这个亏——订单号是 19 位的长整型,用 JSON Schema 的integer写完全没问题,但前端JSON.parse之后末两位直接变了。换到 JTD 之后因为压根没有int64,反而逼着我们把订单号改成了string,问题一次性解决。

timestamp是唯一一个带格式约束的类型,遵循 RFC 3339 的日期时间格式。Ajv 在校验timestamp时会检查完整的格式,包括时区偏移。这里有个细节:2024-01-01T00:00:00Z合法,2024-01-01不合法,2024-01-01 00:00:00也不合法(空格不是合法分隔符)。如果你的上游数据源给出的日期就是不带时间的,那它应该用string类型,格式校验自己写。

1.3 在 Ajv 里切到 JTD 入口的最小改动

Ajv v8 把 JTD 支持放进了独立入口,这是很多人第一次用会卡住的地方。默认的require("ajv")是 JSON Schema 版本,拿它去编译 JTD schema 会直接报 schema 无效。

const Ajv = require("ajv/dist/jtd") const ajv = new Ajv() const userSchema = { properties: { id: { type: "uint32" }, name: { type: "string" }, createdAt: { type: "timestamp" } }, optionalProperties: { nickname: { type: "string" }, tags: { elements: { type: "string" } } } } const validate = ajv.compile(userSchema) console.log(validate({ id: 1, name: "张三", createdAt: "2024-03-01T10:00:00Z" })) console.log(validate.errors)

TypeScript 项目里写法一样,只是导入路径换成ajv/dist/jtd,同时可以带上JTDSchemaType、JTDDataType这两个类型工具,后面讲类型安全那节会详细用。

这里有个容易忽略的版本问题:Ajv v7 时代曾有过new Ajv({ jtd: true })这种选项式写法,v8 之后分成了两个入口。如果你的项目依赖锁在旧版本,按新版本文档写的代码会跑不通,反过来也一样。我建议不管什么版本,先确认require("ajv/dist/jtd")能不能正常拿到构造函数,拿不到就是版本不对,别在选项上折腾。

2. 八种 Schema 形态逐个拆:边界与组合禁区

JTD 规范里 schema 只有八种形态,判断方式是看它的"判别键"是什么。判别键指的是 schema 对象里出现的那一个特征字段,比如出现了properties就是 properties 形态,出现了elements就是 elements 形态。理解这个分类方式,比死记八种名字有用得多。

形态判别键表达的数据典型误用
empty无判别键任意 JSON 值以为空 schema 会拒绝一切
refref引用根定义想跨文件引用
typetype原始类型写int64、写any
enumenum字符串枚举放数字或对象进数组
elementselements同构数组想限制数组长度
propertiesproperties/optionalProperties结构化对象忘写additionalProperties: true
valuesvalues同构字典当成 properties 用
discriminatordiscriminator/mapping带标签的联合类型子 schema 写成 type 形态

2.1 empty 与 ref:两个最小的积木

empty 形态就是{}。它接受任何合法的 JSON 值——对象、数组、字符串、数字、null都行。这个形态乍看没用,实际上有两个真实场景:一是你确实要对某个字段放行,比如插件配置的透传字段;二是配合metadata使用时,一个只带metadata的 schema 也属于 empty 形态,因为它没有其他判别键。

// 这个 schema 接受任意值 const anySchema = {} // 这个是 empty 形态 + metadata,同样接受任意值 const annotated = { metadata: { description: "透传字段,内容由插件自己解释" } }

ref 形态长这样:{ ref: "user" },它指向根 schema 的definitions里名为user的定义。注意是根 schema 的 definitions,不是任意层级的 definitions。JTD 规范把 definitions 限定在 schema 的最外层,这一条限制掉了很多 JSON Schema 里常见的写法,比如"每个子模块自己带一个 definitions"。

const schema = { definitions: { address: { properties: { city: { type: "string" }, street: { type: "string" } } } }, properties: { home: { ref: "address" }, office: { ref: "address" } } }

ref 是支持递归的,这一点在树形结构里很有用:节点含子节点数组,子节点又用ref指回自己。Ajv 编译递归 schema 没问题,但要注意校验深度——一棵畸形的超深树会让校验函数递归到底,栈溢出是可能的。如果你的输入来源不可信,得在外面加一层深度限制。

2.2 type 与 enum:值域收窄的两档

type 形态就是{ type: "..." },前面已经说过十一个取值。这里补两个实操细节。

第一,整数类型的范围检查是严格的。{ type: "uint8" }要求值必须是 0 到 255 之间的整数,200通过,256不通过,1.5也不通过。float32和float64则接受任何 JSON 数字,包括整数。这意味着如果你写{ type: "float64" },传3也是合法的——很多人在这一点上误以为浮点类型只收小数。

第二,float32并不做精度截断。它的语义是"这个数能用 32 位浮点表示",Ajv 的实现里主要检查值是否为有限数字,不会真的把你传的0.1转成 32 位再比较。如果你需要真正的精度控制,那是业务层的事。

enum 形态是{ enum: ["a", "b", "c"] },三个硬性要求:数组必须非空、每个元素必须是字符串、元素之间不能重复。它不接受数字,这一点和 JSON Schema 的enum差别很大,也是从 JSON Schema 迁移过来的人最容易踩的坑。

// 合法 const statusSchema = { enum: ["pending", "paid", "shipped", "closed"] } // 不合法,JTD 编译时报错 const badSchema = { enum: [1, 2, 3] }

如果状态值在数据库里是数字,你有两个选择:在 schema 层用type: "uint8"只约束类型和范围,枚举合法性交给业务代码;或者在出口处把数字转成字符串再序列化。我倾向于后者,因为字符串枚举的可读性对排查问题帮助很大,日志里看到status: 2和status: "paid",调试效率完全不是一个量级。

2.3 elements 与 values:容器形态的两个方向

elements 形态是{ elements: <子 schema> },表达"数组,每个元素都符合这个子 schema"。values 形态是{ values: <子 schema> },表达"对象,每个值都符合这个子 schema,键是任意字符串"。

这两个形态的区分点很简单:数组用 elements,字典用 values。但在实际项目里我见过不少混淆,尤其是当数据结构是"以 ID 为键的对象"时,有人会用properties硬写,字段名写成"1"、"2"这种,结果 schema 完全失去意义。

// 正确:字典用 values const userMapSchema = { values: { properties: { name: { type: "string" }, age: { type: "uint8" } } } } // 正确:数组用 elements const userListSchema = { elements: { properties: { name: { type: "string" } } } }

这两个形态都不支持长度或数量约束,minItems、maxItems、minProperties在 JTD 里不存在。要限制"最多 100 条",得在校验通过之后自己判断data.length。这个限制初看很烦,但它其实划清了结构校验和业务规则的界线——"一页最多 100 条"是分页策略,不是数据结构的一部分。

2.4 properties:默认封闭,这是最容易踩的坑

properties 形态是 JTD 里最常用的,也是最容易出错的。它的完整写法包含三个键:

const schema = { properties: { id: { type: "uint32" }, name: { type: "string" } }, optionalProperties: { email: { type: "string" } }, additionalProperties: true }

三个键的含义分别是:properties里是必填字段,optionalProperties里是可选字段,additionalProperties控制是否允许 schema 之外的字段。

关键在于默认值。additionalProperties不写时的默认值是false,也就是说 properties 形态默认是封闭的,schema 里没声明的字段一律被拒绝。这和 JSON Schema 的默认行为正好相反(JSON Schema 默认允许额外字段),从 JSON Schema 迁过来的人十个里有八个会在这里翻车。

const strict = { properties: { id: { type: "uint32" } } } // 校验失败,因为多了 name 字段 strict.validate({ id: 1, name: "x" })

我的建议是默认保持封闭,真的需要透传时再显式写additionalProperties: true。封闭的好处是能挡住上游多塞字段导致的脏数据,也能在字段改名时立刻暴露问题——如果允许额外字段,旧字段名会被静默吞掉,等到下游读不到数据才发现,排查成本高得多。

还有两个规范细节值得记住。第一,properties和optionalProperties不能有同名字段,编译器会直接报错。第二,这两个键可以只写其中一个,甚至可以都不写但保留additionalProperties,此时等价于"任意对象"。

2.5 discriminator:把联合类型从 if-else 里救出来

多态结构在 JSON Schema 里通常用oneOf加const判别字段实现,写起来啰嗦,校验失败时错误信息也很难读。JTD 用 discriminator 形态专门解决这件事:

const eventSchema = { discriminator: "type", mapping: { created: { properties: { type: { enum: ["created"] }, createdAt: { type: "timestamp" } } }, deleted: { properties: { type: { enum: ["deleted"] }, deletedAt: { type: "timestamp" }, reason: { type: "string" } }, optionalProperties: { operator: { type: "string" } } } } }

结构上,discriminator是判别字段名,mapping的键是判别字段可能的取值,值是子 schema。这里有几条硬规则:

  • mapping里每个子 schema必须是 properties 形态,不能写成{ type: "string" }这种;
  • 子 schema 的properties里不能包含判别字段本身;
  • mapping不能为空。

第三条规则看起来奇怪,其实很合理:判别字段由 discriminator 自己处理,子 schema 再声明一次就重复了。换到实际写法上,Ajv 会自动校验判别字段的值必须在 mapping 的键里,并且该字段本身必须是字符串。

错误定位时,discriminator 的 schemaPath 形如/mapping/deleted/properties/deletedAt/type,比oneOf那种"第 3 个分支里第 2 个子约束失败"的报错清晰太多。这也是我从oneOf迁到 discriminator 之后最直观的收益。

2.6 八种形态的组合禁区速查

JTD 规范对组合有明确限制,违反会直接编译失败。整理成一张表更省事:

你的意图能不能这么写正确做法
一个字段既可以是字符串也可以是数字不能用discriminator或拆成两个字段
一个字段可以是字符串或 null可以{ type: "string", nullable: true }
数组元素是多种类型不能elements加discriminator
对象既要固定字段又要任意额外字段可以properties+additionalProperties: true
字典的值是联合类型不能直接写values里套discriminator
引用别的文件里的定义不能本地 definitions 或构建期合并

这些限制初看是束缚,用一段时间之后会发现它们强迫你把数据结构设计得更扁平、更明确。真需要表达能力更强的场景,说明这里可能不该用 JSON 传输,或者该拆成多个接口。

3. 类型安全:让 schema 和 TypeScript 类型不再各说各话

手写 TypeScript 接口和 schema 是两套东西,只要有两个人分别维护,就一定会漂移。最常见的漂移方式是:接口里加了个可选字段,schema 忘了加,于是这个字段永远校验不过;或者接口里删了个字段,schema 还留着,校验通过但类型报错。JTD 在这一点上有先天优势,因为它的结构足够简单,schema 和类型之间的映射几乎是同构的,Ajv 也为此提供了两个类型工具。

3.1 JTDSchemaType:用接口去约束 schema

JTDSchemaType<T>的用法是把你的接口类型作为泛型参数,然后让 schema 变量声明成这个类型。TypeScript 会在编译期检查 schema 是否真的能描述T。

import Ajv, { JTDSchemaType } from "ajv/dist/jtd" interface User { id: number name: string createdAt: string nickname?: string } const userSchema: JTDSchemaType<User> = { properties: { id: { type: "uint32" }, name: { type: "string" }, createdAt: { type: "timestamp" } }, optionalProperties: { nickname: { type: "string" } } }

这段代码的价值在于:如果你把nickname从optionalProperties挪到properties,TypeScript 立刻报错,因为User里nickname是可选的,而 schema 要求必填。反过来,如果你在User里加了一个必填字段但忘了在 schema 里声明,同样会报错。

实际使用时有两个细节要注意。第一,JTDSchemaType对additionalProperties的处理比较严格,如果你的 schema 允许额外字段,类型检查可能会放宽或报错,具体行为跟 Ajv 版本有关。第二,timestamp在类型层面映射成string,它不会帮你变成Date,这个转换得自己做。

3.2 JTDDataType:从 schema 反推类型

反方向的工具是JTDDataType<S>,给定 schema 推导出对应的数据类型。这个更适合"schema 是唯一事实来源"的项目组织方式。

import { JTDDataType } from "ajv/dist/jtd" const userSchema = { properties: { id: { type: "uint32" }, name: { type: "string" } }, optionalProperties: { tags: { elements: { type: "string" } } } } as const type User = JTDDataType<typeof userSchema> // 推导结果:{ id: number; name: string; tags?: string[] }

这里必须写as const,这是我在项目里踩过的坑。不写的话,{ type: "uint32" }会被 TypeScript 推断成{ type: string },类型推导就失效了,JTDDataType会退化成unknown或者一个非常宽泛的类型。加上as const之后字面量类型才保留得住。

as const的副作用是 schema 变成了只读,如果你有代码要动态往properties里塞字段(比如按配置生成),就得先做一次深拷贝或者改用JTDSchemaType那套。两种工具不能混着用得太随意,我的做法是:接口先定下来的项目用JTDSchemaType,schema 先行(尤其是要和别的语言共享)的项目用JTDDataType。

3.3 compileParser 与 compileSerializer:运行时那层不能省

有一种观点是"有了 TypeScript 类型就不需要运行时校验了"。这个想法只在数据完全由你自己的代码产生时成立。一旦数据来自 HTTP 请求、消息队列、文件导入,运行时校验一步都不能少——因为类型在运行时不存在,JSON.parse出来的东西可能是任何形状。

Ajv 的 JTD 入口提供的不只是compile,还有两个生成代码的 API:

const parse = ajv.compileParser(userSchema) const data = parse('{"id":1,"name":"张三"}') if (data === undefined) { console.error(parse.message, parse.position) } else { console.log(data.id) // 类型上可以直接访问 } const serialize = ajv.compileSerializer(userSchema) const text = serialize({ id: 1, name: "张三", extra: "会被丢掉" })

compileParser生成的是一个"边解析边校验"的函数,遇到不合规的字段立刻停止,不需要先把整个 JSON 解析成对象再校验一遍。这在处理大 payload 时有明确收益。失败时返回undefined,错误信息在parse.message,出错字符位置在parse.position。因为 JSON 解析的结果不可能是undefined,所以用undefined当失败标记是安全的。

compileSerializer的注意点更重要:它会按 schema 声明顺序输出字段,并且丢弃 schema 里没有声明的属性。上面例子里的extra字段会静默消失。这个行为在"往外发数据、需要脱敏"的场景里是好事,但如果你是拿它做通用序列化,很容易出现"存进去有、取出来没"的诡异问题。我给它的定位是"对外输出的专用序列化器",别当JSON.stringify用。

3.4 前后端共用一个 JTD 文件的工程组织

我的做法是把 schema 放在一个独立目录,按领域拆文件,构建期做一次合并和校验:

contracts/ user.jtd.json order.jtd.json index.js // 合并 definitions 并导出

index.js里做的事情是读所有文件,把每个文件的内容塞进根definitions,然后用ref互相引用。因为 JTD 的definitions只能在根层级,所以不能直接在各自的文件里写跨文件引用,必须走这层合并。这一步看起来多此一举,但它能保证最终产物是一个自包含的 schema,前端打包进去、后端 require 进来都是同一份。

合并时有两件事要检查:definitions的键名不会冲突(我用文件名作为前缀),以及没有循环引用导致的无限递归(这个只能靠编译时的栈深度限制兜底)。

4. 扩展机制:metadata、nullable 和自定义关键字的边界

JTD 规范是封闭的,除了八个形态的判别键和nullable、metadata、definitions这几个通用键,其他键都不允许出现。这个封闭性是它跨语言可靠的根基,但也确实会让人想问"我想加点自己的东西怎么办"。答案是三个层次:能用metadata就用metadata,能用nullable就别自己造,真要在 Ajv 层挂关键字就得接受非标准化的代价。

4.1 metadata:给校验之外的工具留位置

metadata是 JTD 里唯一一个"给你自由发挥"的位置,它可以挂在任何 schema 上,内容是完全自由的 JSON 对象。规范明确说了校验器不应该根据metadata拒绝数据,也就是说它对校验结果没有影响。

const schema = { properties: { phone: { type: "string", metadata: { label: "手机号", format: "cn-mobile", example: "13800000000" } } } }

我见过的最实用用法有三个:给表单生成器提供字段标签和占位符;给 OpenAPI 或文档生成器提供描述信息;给前端提供默认值和校验提示文案。这三种用法都不影响校验行为,纯粹是给工具链提供元信息。

但要注意,Ajv 默认不会把metadata暴露到编译结果里。如果你想在运行时读到它,需要自己遍历 schema 建一份索引,或者用ajv.getSchema拿到原始 schema 再查。我一般是在构建期就把metadata抽出来生成一份字段配置表,运行时直接读那张表,比在 schema 上扒要快得多。

还有一个约束:metadata里不要放需要校验的内容。我见过有人在metadata里写required: true然后期望校验器认这个字段,这完全是误解——JTD 的必填是由properties和optionalProperties的位置决定的,metadata里写什么都没有用。

4.2 nullable 是规范内建的,不要自己造

处理 null 是接口设计里的高频问题。JTD 提供的是nullable: true:

const schema = { properties: { id: { type: "uint32" }, deletedAt: { type: "timestamp", nullable: true } } }

nullable可以加在任何形态上(包括ref),表示"这个位置除了符合 schema 之外,还允许是null"。加在ref上的写法是{ ref: "user", nullable: true },表示整个引用对象可以是 null。

从 JSON Schema 迁过来的人容易做的替代方案是用enum: ["a", "b", null]或者干脆把字段改成optionalProperties。前者不行,因为 JTD 的enum只收字符串;后者语义不对,可选表示"字段可以不存在",nullable表示"字段存在但值是 null"。这两者在实际业务里含义完全不同——deletedAt: null表示"没删除",而deletedAt字段缺失可能表示"这条记录很老,根本没这个字段"。混用会让下游很难判断。

我给自己定的规则:字段本身是数据的一部分、只是暂时没有值,用nullable;字段在新版本里才引入、老数据可能没有,用optionalProperties。

4.3 在 Ajv 里挂自定义关键字

Ajv 本身有addKeyword机制,理论上你可以往 JTD schema 里塞自定义关键字。但这里必须说清楚代价:一旦你这么做了,这份 schema 就不再是标准 JTD,其他语言的实现、其他工具(代码生成器、文档生成器)都会在读到你加的关键字时报错或者静默忽略。

我的建议是:如果这个约束是全局性的、所有消费方都需要的,那它应该进业务代码,写在校验之后;如果它只是你这一侧的辅助信息,那它应该进metadata;只有当你确实在做一个内部工具链、schema 不对外时,才考虑挂关键字。

真要做的话,基本写法是这样:

const ajv = new Ajv() ajv.addKeyword({ keyword: "x-range", type: "number", schemaType: "array", validate: (range, data) => data >= range[0] && data <= range[1] })

这个关键字能起作用,但它挂上去之后 schema 就不可移植了。而且 JTD 模式的 schema 词法检查和 JSON Schema 模式不完全一样,某些关键字组合会直接编译失败,得实测。

4.4 扩展带来的三处兼容性风险

第一是跨语言。你的 Go 服务用 JTD 库解析这份 schema,它不认识x-range,可能直接报"未知关键字"然后整个服务起不来。JTD 的严格性在这里变成了双刃剑。

第二是工具链。代码生成器在把 JTD 转成类型时,遇到未知关键字通常会忽略,但如果这个关键字改变了字段的可选性或者类型,生成出来的类型就是错的,而且是那种"编译能过、运行时出错"的错。

第三是版本升级。Ajv 在小版本里调整过对某些关键字的处理方式,你的自定义关键字可能在升级后行为变化,而这类问题不会有任何编译期提示。

我的实际经验是:项目跑了两年,唯一稳定用下来的扩展就是metadata,其他尝试最后都退回了业务代码。这不是说扩展机制不好,而是说在一个多人协作、多语言互通的环境里,标准化的收益远大于那点表达力。

5. 实战中的坑:错误定位、编译时机与混用代价

前面讲的都是"怎么写对",这一节讲"跑起来之后会遇到什么"。这些问题在文档里基本查不到,都是我在项目里一点点试出来的,希望你能少走点弯路。

5.1 JTD 的错误对象比 JSON Schema 更"薄"

JSON Schema 模式下,Ajv 的错误对象包含keyword、message、params和instancePath,你可以直接拿到"最小值是 0,当前值是 -1"这种信息。JTD 模式下的错误对象只有两个字段:instancePath和schemaPath。

validate({ id: -1, name: "x" }) // validate.errors 大概是: // [{ instancePath: "/id", schemaPath: "/properties/id/type" }]

没有message,意味着你不能直接把错误对象丢给前端展示,必须自己做一层映射。instancePath指向数据的出错位置,schemaPath指向 schema 的约束位置。前者对定位问题更直接,但它是 JSON Pointer 格式,/address/zip这种,给用户看的时候得转成address.zip。

我的做法是写一个小函数把schemaPath转成可读的字段路径,因为instancePath在数组场景下会变成/items/0/name这种带下标的路径,反而不如字段路径稳定:

function fieldPath(schemaPath) { const segs = schemaPath.split("/").filter(Boolean) const out = [] for (let i = 0; i < segs.length; i++) { const seg = segs[i] if (seg === "properties" || seg === "optionalProperties") { out.push(segs[i + 1]) i++ } else if (seg === "elements") { out.push("[]") } else if (seg === "values") { out.push("{*}") } else if (seg === "mapping") { out.push(segs[i + 1]) i++ } } return out.join(".") } // "/properties/address/properties/zip/type" -> "address.zip"

拿到字段路径之后,再去一张配置表里查这个字段的中文名和提示语,拼出来的错误信息就能直接给用户看。这张表可以从metadata里生成,正好和前面那节呼应上。

5.2 编译时机与进程启动成本

ajv.compile()做的是代码生成,它会把 schema 编译成一段 JavaScript 函数再new Function出来。这个过程不便宜,一个中等复杂度的 schema 大概要几百微秒到几毫秒。如果你的代码在请求处理函数里调compile,QPS 上来之后会明显卡顿。

正确的做法是在服务启动时把所有 schema 编译好,放进一个 Map:

const validators = new Map() function getValidator(name, schema) { if (!validators.has(name)) { validators.set(name, ajv.compile(schema)) } return validators.get(name) }

如果 schema 是动态的(比如按租户配置生成),用WeakMap以 schema 对象本身为键做缓存,避免内存泄漏。另外注意 JTD 的 schema 没有$id,所以没法用ajv.getSchema("id")这种方式按名字取,只能自己维护映射。

还有一点:编译出来的校验函数是有状态的,validate.errors会保留上一次的结果。如果你把同一个 validator 用在并发场景,错误对象可能被覆盖。稳妥的做法是每次失败后立刻把errors深拷贝出来,或者用validate.errors之前先判断返回值。

5.3 和既有 JSON Schema 体系共存的迁移路线

完全迁移到 JTD 通常不现实,尤其是项目里已经有一批跑得很稳的 JSON Schema。我的建议是分三步走,而不是一次性重写。

第一步,新契约全部用 JTD,老契约保持不动。两个 Ajv 实例并存,入口分别是ajv和ajv/dist/jtd,互不干扰。这一步的成本几乎为零。

第二步,把老契约里那些"其实只是结构校验"的部分挑出来,也就是只用到了type、properties、required、items的 schema——这类 schema 转 JTD 几乎是机械操作,required: ["a"]变成把a放进properties,items变成elements,唯一要小心的是additionalProperties的默认值反转。

第三步,带pattern、minimum、anyOf的那些留在 JSON Schema 里,不要硬转。这些约束本来就是业务规则,转过去也得在代码里补回来,收益不大。

实测下来,一个两百来行的 JSON Schema 集合,能无痛转成 JTD 的大概占六成,剩下的四成都带复杂约束。这个比例说明 JTD 确实覆盖了大部分日常场景,但它不是 JSON Schema 的超集或者替代品,两者定位不同。

5.4 序列化器的取舍与实测对比

最后说说compileSerializer到底值不值得用。我在一个中等规模的对象上做过对比:字段大约 20 个,嵌套两层,包含一个八元素的数组。结论是生成序列化器在速度上有优势,主要因为它省掉了JSON.stringify的通用分支判断,也不需要处理循环引用和toJSON方法。

但代价是行为不一致。JSON.stringify遇到undefined会跳过字段,遇到Date会调toJSON,遇到函数会跳过。生成序列化器只认 schema 里的声明,其他一律按类型处理——你把undefined塞进一个type: "string"的字段,输出的结果可能是一个非法值。我在一个日志上报的场景里就遇到过这种问题,某个字段偶发undefined,JSON.stringify会跳过它,序列化器则输出了一个诡异的空字符串,下游解析失败。

所以我的结论是:对外提供的、结构完全受控的输出接口,可以用生成序列化器;内部调试、日志、缓存这类对宽容度有要求的场景,老老实实用JSON.stringify。这个区分的成本很低,收益是避免了一类极难排查的问题。

还有个小技巧,compileParser配合validate一起用时,其实不需要先validate再解析,因为解析本身就在校验。但如果你需要在解析失败时给出比parse.message更友好的提示,那还是得先用validate拿到结构化的错误对象,再决定是否解析。两个都编译,成本也就多一点点。

我个人在实际操作中的体会是,JTD 这套东西的价值不在"少写几个关键字",而在它逼你把契约和业务规则分开。schema 管结构,代码管规则,两边各司其职,出了问题也知道该去哪边找。至于那些扩展机制,能用metadata解决的就别挂关键字,能写在业务代码里的就别塞进 schema,防线划清楚比多一个关键字重要得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询