Go JSON Schema 反射生成实战:基于 invopop/jsonschema 从 Go 类型自动产出 Draft 2020-12 Schema
【免费下载链接】nhostThe Open Source Firebase Alternative with GraphQL.项目地址: https://gitcode.com/GitHub_Trending/nh/nhost
本文以 Nhost 仓库中引入的 invopop/jsonschema 库文档 为主体,系统讲解如何通过 Go 反射(reflection)从任意 Go 结构体自动生成符合 JSON Schema Draft 2020-12 规范的 Schema 文档。你将掌握其核心 APIReflector、全套jsonschema结构体标签、jsonschema_extras扩展标签、Go 注释自动抽取、自定义键名与自定义类型定义等实战能力,并看到该库在 Nhost 项目依赖链(MCP 工具输入 Schema 生成)中的真实用法。
一、库的定位与核心特性
invopop/jsonschema是一个通过反射(reflection)从 Go 类型生成 JSON Schema 的 Go 库。其典型场景包括:为 REST / GraphQL API 自动生成请求参数校验规则、为配置结构体生成编辑器补全与校验文件、为 MCP(Model Context Protocol)工具自动生成输入输出 Schema 等。
该库的核心特性(源自 README):
- 支持任意复杂类型,包括
interface{}、map、slice 等; - 支持 minLength、maxLength、pattern、format 等 json-schema 校验关键字;
- 支持简单的字符串与数字枚举(enum);
- 支持通过
jsonschema_extras结构体标签注入自定义属性字段; - 底层基于
encoding/json、reflect等标准库实现,无额外运行时依赖。
二、版本背景:从 fork 到 Draft 2020-12
该库是 alecthomas/jsonschema 的一个 fork。Invopop 团队在其 GOBL 库中将 jsonschema 作为基石使用,为了持续迭代功能而独立维护了这个分支,并在原版基础上做了几项重要变更(这些变更意味着与旧版并不完全兼容):
- 升级到 JSON Schema Draft 2020-12:原版停留在 draft-04,本库已迁移到最新草案版本(
https://json-schema.org/draft/2020-12/schema)。 - 自动生成 Schema ID:从当前 Go 包的 URL 自动推导
$id以保证唯一性,可通过Anonymous选项关闭。 - 移除
FullyQualifyTypeName选项:如遇命名冲突,官方建议使用多个带不同 ID 的 Schema 文件、将DoNotReference设为true隐藏全部 definitions,或通过Namer属性自定义命名策略。 - 移除
yaml标签支持:为避免不一致行为(参见原仓库相关讨论),如需处理 YAML 数据,官方推荐先将 YAML 转换为 JSON 再使用本库。
版本约束
项目仍处于 v0 版本方案(Go 模块惯例),破坏性变更随时可能出现,建议在go.mod中固定模块版本标签或分支。此外,由于库内使用了泛型,Go 版本要求 >= 1.18。
在 Nhost 仓库中,该库以github.com/invopop/jsonschema v0.13.0(间接依赖)的形式被引入,见 go.mod,源码位于 vendor/github.com/invopop/jsonschema 目录。
三、快速上手:一个完整的反射示例
先看 README 中的经典示例。定义如下 Go 类型:
type TestUser struct { ID int `json:"id"` Name string `json:"name" jsonschema:"title=the name,description=The name of a friend,example=joe,example=lucy,default=alex"` Friends []int `json:"friends,omitempty" jsonschema_description:"The list of IDs, omitted when empty"` Tags map[string]interface{} `json:"tags,omitempty" jsonschema_extras:"a=b,foo=bar,foo=bar1"` BirthDate time.Time `json:"birth_date,omitempty" jsonschema:"oneof_required=date"` YearOfBirth string `json:"year_of_birth,omitempty" jsonschema:"oneof_required=year"` Metadata interface{} `json:"metadata,omitempty" jsonschema:"oneof_type=string;array"` FavColor string `json:"fav_color,omitempty" jsonschema:"enum=red,enum=green,enum=blue"` }只需一行调用即可生成 Schema:
jsonschema.Reflect(&TestUser{})生成结果如下:
{ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/invopop/jsonschema_test/test-user", "$ref": "#/$defs/TestUser", "$defs": { "TestUser": { "oneOf": [ { "required": ["birth_date"], "title": "date" }, { "required": ["year_of_birth"], "title": "year" } ], "properties": { "id": { "type": "integer" }, "name": { "type": "string", "title": "the name", "description": "The name of a friend", "default": "alex", "examples": ["joe", "lucy"] }, "friends": { "items": { "type": "integer" }, "type": "array", "description": "The list of IDs, omitted when empty" }, "tags": { "type": "object", "a": "b", "foo": ["bar", "bar1"] }, "birth_date": { "type": "string", "format": "date-time" }, "year_of_birth": { "type": "string" }, "metadata": { "oneOf": [ { "type": "string" }, { "type": "array" } ] }, "fav_color": { "type": "string", "enum": ["red", "green", "blue"] } }, "additionalProperties": false, "type": "object", "required": ["id", "name"] } } }通过这个例子可以总结出几条核心规则:
- 字段名与必填推断:字段名默认取自
json标签;没有omitempty的字段会被放入required数组(如id、name),带omitempty的字段(如friends)则不会。 - 标签订阅:
jsonschema标签中的title、description、example(可重复,输出为examples数组)、default直接映射到 Schema 对应关键字。 - 多分支约束:
oneof_required=date/oneof_required=year在父级生成带title标识的oneOf分组;oneof_type=string;array将字段本身生成为多类型oneOf。 - 枚举:
enum=red,enum=green,enum=blue生成enum数组。 - 特殊类型映射:
time.Time自动映射为string+format: "date-time"。 - 扩展属性:
jsonschema_extras:"a=b,foo=bar,foo=bar1"把重复键合并为数组,直接写入 Schema 顶层属性(a、foo)。 - $defs 引用:顶层通过
$ref: "#/$defs/TestUser"引用定义块,保持结构可复用。
关于 YAML 的说明
正如文档所述,yaml标签支持已被移除。如果必须处理 YAML 数据,推荐先把 YAML 转成 JSON(例如使用 invopop 团队维护的invopop/yaml库),再用本库生成 Schema,避免标签语义在两种格式间的不一致。
四、Reflector:可配置的反射器
jsonschema.Reflect只是使用默认Reflector的快捷方式。实际项目中通常需要自定义行为,此时应创建jsonschema.Reflector实例并设置参数。源码中 Reflector 结构体 提供了以下配置项:
| 配置字段 | 说明 |
|---|---|
BaseSchemaID ID | 定义 Schema ID 的基础 URI,例如设为https://invopop.com/schemas后,结构体User{}的 ID 为https://invopop.com/schemas/user;未设置时使用类型完整包路径,可用Anonymous关闭 |
Anonymous bool | 为true时隐藏自动生成的$id,输出所谓"匿名 Schema",官方不推荐 |
AssignAnchor bool | 为true时在每个 definition(含根 Schema)内使用原始结构体名作为$anchor(CamelCase,便于 URI 兼容),anchor 本身不会被引用 |
AllowAdditionalProperties bool | 为true时不再为所有结构体输出additionalProperties: false,即 JSON 中的额外键不会导致校验失败(反序列化时仍会被丢弃) |
RequiredFromJSONSchemaTags bool | 改为仅要求标记了jsonschema:required的键,覆盖默认的"未标记omitempty即必填"逻辑 |
DoNotReference bool | 不再输出顶层$defs映射,而是把整个类型结构内联输出成一棵树 |
ExpandedStruct bool | 为true时将反射类型的定义直接放入根节点,而不是通过$ref引用(要求传入的是结构体类型) |
FieldNameTag string | 更换取字段名的标签,默认使用json |
IgnoredTypes []any | 定义应被忽略的类型切片,忽略后仅允许附加属性(additionalProperties: true) |
Lookup func(reflect.Type) ID | 提供自定义的类型到 Schema ID 映射,使已有 Schema 文档按 ID 被引用而非内嵌(反射类型永远是非指针的底层元素) |
Mapper func(reflect.Type) *Schema | 将自定义 Go 类型映射为指定 Schema 的钩子函数 |
Namer func(reflect.Type) string | 自定义类型名,默认为 reflect 包提供的类型名 |
KeyNamer func(string) string | 自定义属性键名,默认原样使用键名或 json 标签值 |
AdditionalFields func(reflect.Type) []reflect.StructField | 为给定类型追加结构体字段 |
LookupComment func(reflect.Type, string) string | 自定义注释查找:给定类型与(可选)字段名返回注释串,为空时继续查询CommentMap |
CommentMap map[string]string | 全限定 Go 类型与字段到注释字符串的字典,标签未提供 description 时使用 |
4.1 ExpandedStruct:内联展开顶层结构
当希望顶层结构体不再通过$defs自引用时,可设置ExpandedStruct: true。考虑如下类型(包含匿名内嵌、私有字段、忽略字段等边界情况):
type GrandfatherType struct { FamilyName string `json:"family_name" jsonschema:"required"` } type SomeBaseType struct { SomeBaseProperty int `json:"some_base_property"` // jsonschema 的 required 标签对私有字段与忽略字段没有意义, // 下面的例子用于验证:即使打了 required 标签, // 这些字段也不会出现在输出 Schema 的 required 中。 somePrivateBaseProperty string `json:"i_am_private" jsonschema:"required"` SomeIgnoredBaseProperty string `json:"-" jsonschema:"required"` SomeSchemaIgnoredProperty string `jsonschema:"-,required"` SomeUntaggedBaseProperty bool `jsonschema:"required"` someUnexportedUntaggedBaseProperty bool Grandfather GrandfatherType `json:"grand"` }输出结果:
{ "$schema": "http://json-schema.org/draft/2020-12/schema", "required": ["some_base_property", "grand", "SomeUntaggedBaseProperty"], "properties": { "SomeUntaggedBaseProperty": { "type": "boolean" }, "grand": { "$schema": "http://json-schema.org/draft/2020-12/schema", "$ref": "#/definitions/GrandfatherType" }, "some_base_property": { "type": "integer" } }, "type": "object", "$defs": { "GrandfatherType": { "required": ["family_name"], "properties": { "family_name": { "type": "string" } }, "additionalProperties": false, "type": "object" } } }该示例验证了几个实现细节(对应 reflectFieldName 的逻辑):
json:"-"与jsonschema:"-"都会让字段被完全忽略;- 未导出的私有字段(
PkgPath != "")不会进入输出,即便打了required标签; - 未带
omitempty的导出字段(含未打 json 标签的SomeUntaggedBaseProperty)会被视为必填; - 嵌套的
GrandfatherType会注册到$defs并通过$ref引用。
五、从 Go 注释自动生成描述:AddGoComments
手动在每个字段的标签里写description既繁琐又易漏。如果类型和字段旁已有 Go 注释,可以直接使用Reflector的AddGoComments(base, path string)方法:它通过go/parser解析指定目录(含子目录)的 Go 源码,构建"包导入路径 + 类型 + 字段 → 注释"的字典并存入CommentMap,随后自动作为description输出;若标签中已手动提供 description,则以手动为准(标签优先)。
假设包内定义了如下类型:
package main // User is used as a base to provide tests for comments. type User struct { // Unique sequential identifier. ID int `json:"id" jsonschema:"required"` // Name of the user Name string `json:"name"` }使用方式(注意:go/parser无法可靠推断模块全限定路径,需要手动传入模块 URL 与源码目录):
r := new(Reflector) if err := r.AddGoComments("github.com/invopop/jsonschema", "./"); err != nil { // deal with error } s := r.Reflect(&User{})预期输出:
{ "$schema": "http://json-schema.org/draft/2020-12/schema", "$ref": "#/$defs/User", "$defs": { "User": { "required": ["id"], "properties": { "id": { "type": "integer", "description": "Unique sequential identifier." }, "name": { "type": "string", "description": "Name of the user" } }, "additionalProperties": false, "type": "object", "description": "User is used as a base to provide tests for comments." } } }从 reflect_comments.go 的实现可以看到更多细节:
- 类型注释默认只取首句摘要(
go/doc的Synopsis),可通过WithFullComment()选项改为完整注释文本;字段注释默认全部保留; - 注释键的格式为
"导入路径.类型名"与"导入路径.类型名.字段名",与CommentMap的键约定一致(见 lookupComment); - 查找顺序为
LookupComment函数 →CommentMap字典,两者都未命中才返回空描述。
六、自定义键名:KeyNamer
写 Web API 时,JSON 响应键通常采用 snake_case,而 Go 结构体字段习惯用 PascalCase。逐一写json:"..."标签很繁琐,此时可向Reflector注入func(string) string类型的KeyNamer,在生成时统一转换键名。
例如:
type User struct { GivenName string PasswordSalted []byte `json:"salted_password"` }配合strcase.SnakeCase(来自github.com/stoewer/go-strcase):
r := new(jsonschema.Reflector) r.KeyNamer = strcase.SnakeCase // from package github.com/stoewer/go-strcase r.Reflect(&User{})输出对比(diff 形式):
{ "$schema": "http://json-schema.org/draft/2020-12/schema", "$ref": "#/$defs/User", "$defs": { "User": { "properties": { - "GivenName": { + "given_name": { "type": "string" }, "salted_password": { "type": "string", "contentEncoding": "base64" } }, "additionalProperties": false, "type": "object", - "required": ["GivenName", "salted_password"] + "required": ["given_name", "salted_password"] } } }这里还有两个值得注意的细节:
KeyNamer的入参是 json 标签值而非原始字段名:字段PasswordSalted带json:"salted_password",因此传给KeyNamer的参数就是"salted_password"(对 snake_case 转换而言保持不变)。[]byte自动映射为 base64:源码中 reflectSliceOrArray 对字节切片输出"type": "string"与"contentEncoding": "base64"(json.RawMessage除外)。
七、自定义类型定义:四个扩展钩子
当结构体自带自定义 JSON 序列化/反序列化逻辑(例如把一个字符串解析为对象)时,本库会识别并尝试调用以下四种方法,让你完全掌控某个类型的 Schema:
| 方法签名 | 作用 |
|---|---|
JSONSchema() *Schema | 阻止自动生成,完全返回自定义 Schema 定义 |
JSONSchemaExtend(schema *jsonschema.Schema) | 在自动生成之后被调用,便于追加或修改字段 |
JSONSchemaAlias() any | 反射该类型时返回一个替代对象,用其类型生成 Schema |
JSONSchemaProperty(prop string) any | 结构体中的每个属性都会被调用,可返回替代对象来转换该属性的 Schema |
注意:以上方法必须定义在非指针接收者上才会被调用(源码通过
t.Implements(...)判断,见 reflect.go 的别名检测与 reflectCustomSchema)。
以CompactDate(只包含年月)为例,它实现了自定义 Marshal/Unmarshal 与JSONSchema():
type CompactDate struct { Year int Month int } func (d *CompactDate) UnmarshalJSON(data []byte) error { if len(data) != 9 { return errors.New("invalid compact date length") } var err error d.Year, err = strconv.Atoi(string(data[1:5])) if err != nil { return err } d.Month, err = strconv.Atoi(string(data[7:8])) if err != nil { return err } return nil } func (d *CompactDate) MarshalJSON() ([]byte, error) { buf := new(bytes.Buffer) buf.WriteByte('"') buf.WriteString(fmt.Sprintf("%d-%02d", d.Year, d.Month)) buf.WriteByte('"') return buf.Bytes(), nil } func (CompactDate) JSONSchema() *Schema { return &Schema{ Type: "string", Title: "Compact Date", Description: "Short date that only includes year and month", Pattern: "^[0-9]{4}-[0-1][0-9]$", } }生成的 Schema:
{ "$schema": "http://json-schema.org/draft/2020-12/schema", "$ref": "#/$defs/CompactDate", "$defs": { "CompactDate": { "pattern": "^[0-9]{4}-[0-1][0-9]$", "type": "string", "title": "Compact Date", "description": "Short date that only includes year and month" } } }可以看到,CompactDate虽然是一个结构体,但因为实现了JSONSchema(),其 Schema 完全由我们自定义:类型变为string、附带正则pattern约束,与自定义的"YYYY-MM"序列化格式严格对应。
八、内置类型映射与标签处理机制(源码视角)
8.1 特殊 Go 类型 → JSON Schema 类型
从 reflectTypeToSchema 可以看到内置的类型映射表:
time.Time→string+format: "date-time";net.IP→string+format: "ipv4";url.URL→string+format: "uri"(见timeType、ipType、uriType定义);- 整数族(int/int8/…/uint64)→
integer;浮点族 →number;bool→boolean;string→string; - slice/array →
array,元素递归生成(固定长度数组还会附带minItems/maxItems);[]byte→string+contentEncoding: "base64";json.RawMessage不生成 items; - map →
object,additionalProperties为元素类型 Schema;整数键的 map 使用patternProperties: {"^[0-9]+$": ...}; - 实现
EnumDescriptor() ([]byte, []int)的 protobuf 枚举类型 →oneOf: [{"type":"string"},{"type":"integer"}]; interface{}字段 → 不输出type(空 Schema,表示任意值)。
8.2 jsonschema 标签的完整关键字
标签解析集中在 structKeywordsFromTags 及其派生的关键字处理器中,按字段类型分发:
- 通用关键字(genericKeywords):
title、description、type、anchor、oneof_required、anyof_required、oneof_ref、oneof_type、anyof_ref、anyof_type。其中*_required按分组名(title)聚合同一oneOf/anyOf分支并追加 required 字段;*_type用;分隔多个类型;*_ref用;分隔多个$ref。 - 字符串关键字(stringKeywords):
minLength、maxLength、pattern、format、readOnly、writeOnly、default、example(可重复)、enum(可重复)。 - 数值关键字(numericalKeywords):
multipleOf、minimum、maximum、exclusiveMaximum、exclusiveMinimum、default、example、enum(数字会转换为json.Number)。 - 数组关键字(arrayKeywords):
minItems、maxItems、uniqueItems、default、format、pattern;未处理的关键字会下放给items的元素类型继续处理(不支持[][]...深层嵌套的情况)。 - 布尔关键字:
default(值为true/false)。 - 必填与可空:默认规则是"json 标签未含
omitempty即必填"(requiredFromJSONTags);jsonschema:"required"可强制必填;jsonschema:"nullable"会把属性包装为oneOf: [原Schema, {"type":"null"}](见 reflectFieldName 与 reflectStructFields)。 - 内嵌展开:匿名结构体字段(以及
json:"...,inline"标记的字段)会被递归展开,属性直接继承到父级。
8.3 Schema ID 的生成规则
在 ReflectFromType 中,$id的推导顺序为:显式BaseSchemaID→ 类型完整包路径构造的https://<pkg-path>→ 否则不设置;最终 ID 为BaseSchemaID.Add(ToSnakeCase(typeName))。而 id.go 提供的ID类型实现了完整的 URI 操作:Validate()(校验 scheme 为 http/https、含合法 hostname 与路径)、Add()(追加路径并清除锚点)、Anchor()(追加#锚点)、Def()(追加#/$defs/名称)、Base()(去除锚点与末尾斜杠)。同时 schema.go 定义的Schema结构体几乎完整覆盖了 Draft 2020-12 的全部关键字($defs、oneOf/anyOf/allOf/not、if/then/else、prefixItems/items/contains、patternProperties、dependentRequired、contentEncoding等),并支持布尔 Schema(TrueSchema/FalseSchema,见 MarshalJSON/UnmarshalJSON)。
九、仓库中的真实用法:MCP 工具输入 Schema 自动生成
在 Nhost 的依赖链中,invopop/jsonschema被mark3labs/mcp-go用于为 MCP(Model Context Protocol)工具自动生成输入/输出 Schema,参见 vendor/github.com/mark3labs/mcp-go/mcp/tools.go 中WithInputSchema[T any]的实现:
// Generate schema using invopop/jsonschema library // Configure reflector to generate clean, MCP-compatible schemas reflector := jsonschema.Reflector{ DoNotReference: true, // Removes $defs map, outputs entire structure inline Anonymous: true, // Hides auto-generated Schema IDs AllowAdditionalProperties: true, // Removes additionalProperties: false } schema := reflector.Reflect(zero) // Clean up schema for MCP compliance schema.Version = "" // Remove $schema field这段代码是前面各配置项的最佳实践样板:
DoNotReference: true去掉$defs引用、整体内联输出,保证 MCP 工具 Schema 自包含;Anonymous: true隐藏自动$id,避免生成环境的包路径泄露到协议中;AllowAdditionalProperties: true去掉additionalProperties: false,让 MCP 客户端在传参时更具宽容性;- 手动清空
$schema字段以符合 MCP 规范。
在 Nhost 的 CLI 中,MCP 相关工具(如文档检索工具)正是构建在这套 MCP 服务器框架之上的,相关代码位于 cli/mcp/tools 目录(例如 docs/list.go、docs/search.go 均引入了github.com/mark3labs/mcp-go/mcp包)。这意味着:当你在nhost mcp start的 MCP 服务器中调用任何工具时,其入参校验 Schema 正是由 invopop/jsonschema 反射 Go 结构体即时生成的——这为该库在真实生产链路中的应用提供了一个可追溯的实例。
十、实践建议与小结
- 固定版本:库仍处于 v0 阶段,破坏性变更频繁,务必在
go.mod中锁定 tag 或 branch。 - 优先复用已有注释:代码注释规范的项目,优先使用
AddGoComments让 description 自动跟随文档,再对特殊字段用标签覆盖。 - 控制 Schema 体积:大型类型图默认会产生庞大的
$defs,若目标是单文件自包含(如 MCP 工具),请开启DoNotReference+Anonymous。 - 善用自定义钩子:涉及自定义序列化(如紧凑日期、加密字段、ID 包装类型)时,用
JSONSchema()/JSONSchemaAlias()保证"序列化格式"与"校验 Schema"严格一致。 - 注意 key 命名策略:跨语言 API 场景用
KeyNamer统一 snake_case,减少每个字段手写json标签的心智负担。
总而言之,invopop/jsonschema 提供了一条从 Go 类型系统直达 JSON Schema Draft 2020-12 的自动化路径:结构体标签负责声明式约束,Reflector配置负责输出形态,四个自定义钩子负责类型级特例,而 Go 注释抽取则让 Schema 与代码文档天然同步。无论是构建 API 校验、配置补全还是 MCP 工具协议,它都是一套值得沉淀在工具箱中的基础设施。
【免费下载链接】nhostThe Open Source Firebase Alternative with GraphQL.项目地址: https://gitcode.com/GitHub_Trending/nh/nhost
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考