Protobuf, JSON Schema, and OpenAPI
如果你正在使用 Protobuf,那么你已经拥有一个描述消息和服务的 schema。Protobuf 最为人熟知的功能是生成多种编程语言的类型、客户端和服务桩,但其插件系统还能输出更多内容,比如文档,以及将你的 schema 转换为其他格式。本文将介绍其中两种格式:JSON Schema 和 OpenAPI。这两种格式都能让你的原始 Protobuf schema 拓展到新场景。
两个插件让这一切成为可能:Buf 的 protoc-gen-jsonschema 和一个由我编写维护的社区插件 protoc-gen-connect-openapi。让我们来看看它们的产出以及如何将它们集成到项目中。
示例 schema
我们将使用一个小型库存服务,其中包含几条 Protovalidate 规则。一个产品包含一个特定格式的 SKU、2 到 100 个字符长度的名称、非负的库存数量,以及以十进制字符串表示的单价。
syntax = "proto3";
package acme.inventory.v1;
import "buf/validate/validate.proto";
message Product {
string sku = 1 [(buf.validate.field).string.pattern = "^[A-Z0-9-]+$"];
string name = 2 [
(buf.validate.field).string.min_len = 2,
(buf.validate.field).string.max_len = 100
];
int32 quantity = 3 [(buf.validate.field).int32.gte = 0];
string unit_price = 4 [(buf.validate.field).string.pattern = "^[0-9]+\\.[0-9]{2}$"];
}
message GetProductRequest {
string sku = 1 [(buf.validate.field).string.pattern = "^[A-Z0-9-]+$"];
}
message GetProductResponse {
Product product = 1;
}
service InventoryService {
rpc GetProduct(GetProductRequest) returns (GetProductResponse);
}JSON Schema
protoc-gen-jsonschema 会为你的消息生成 JSON Schema(draft 2020-12)定义。以下是它针对 Product 生成的结果:
{
"$id": "acme.inventory.v1.Product.jsonschema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {
"name": {
"default": "",
"maxLength": 100,
"minLength": 2,
"type": "string"
},
"quantity": {
"anyOf": [
{
"exclusiveMaximum": 2147483648,
"minimum": 0,
"type": "integer"
},
{
"pattern": "^-?[0-9]+$",
"type": "string"
}
],
"default": 0
},
"sku": {
"default": "",
"pattern": "^[A-Z0-9-]+$",
"type": "string"
},
"unitPrice": {
"default": "",
"pattern": "^[0-9]+\\.[0-9]{2}$",
"type": "string"
}
},
"title": "Product",
"type": "object"
}可以看到 Protovalidate 的规则都体现在了输出中:min_len 和 max_len 变成了 minLength 和 maxLength,SKU 的正则表达式变成了 pattern。quantity 的整数分支带有 minimum: 0 以及 int32 的上限。基于 Protobuf 的 JSON 映射,anyOf 也允许用字符串表示整数。这是 .jsonschema.json 变体,所以字段使用 JSON 名称:Protobuf 中的 unit_price 在这里显示为 unitPrice。
默认情况下,该插件会生成几个不同的文件,对应三个维度的组合:使用 Protobuf 还是 JSON 字段名、引用的 message 是内联还是拆分到独立文件、是否保留字符串编码整数这类替代表示形式。插件的 README 详细介绍了每种变体以及其他可用选项。
基于 JSON Schema 输出可以做的很多事情。你可以将 VS Code 或 JetBrains IDE 指向该 schema,从而获得字段名自动补全以及超范围值错误提示。这种用法最常见于编辑配置文件。你也可以用同一份 JSON Schema 文件在不可信数据入口处(如 webhook 或浏览器客户端)校验 payload。此外,你可以利用它来约束 Gemini 或 ChatGPT 等大语言模型的结构化输出,确保响应能被解析为你预期的数据格式。你还可以将同一份文件用于 表单生成器、假数据生成器 以及 写入时进行校验的文档存储系统。
OpenAPI
Connect 的单次调用就是带 JSON 请求体的 HTTP POST,而 OpenAPI 正擅长描述此类端点。
protoc-gen-connect-openapi 生成的就是 OpenAPI 描述。它由我编写并维护,属于个人项目而非 Buf 官方组件。它能生成 OpenAPI 3.1 文档,按 Connect 协议定义描述每个端点及相关类型。以下是我们为库存服务生成的输出示例(节选):
openapi: 3.1.0
info:
title: acme.inventory.v1
paths:
/acme.inventory/v1/InventoryService/GetProduct:
post:
operationId: acme.inventory.v1.InventoryService.GetProduct
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/acme.inventory.v1.GetProductRequest'
required: true
responses:
default:
description: Error
content:
application/json:
schema:
$ref: '#/components/schemas/connect.error'
"200":
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/acme.inventory.v1.GetProductResponse'
components:
schemas:
acme.inventory.v1.Product:
type: object
properties:
sku:
type: string
pattern: ^[A-Z0-9-]+$
name:
type: string
maxLength: 100
minLength: 2
quantity:
type: integer
minimum: 0
format: int32
unitPrice:
type: string
pattern: ^[0-9]+\.[0-9]{2}$
additionalProperties: false我截取了部分输出,因为其中还包含了一些实际有用但对本文显得冗余的标准选项和参数。例如,default 响应引用了 connect.error schema,该 schema 描述了任意端点都可能返回的 Connect 错误。
OpenAPI 包含一些在 Protobuf schema 中通常不会定义的特性,比如服务器 URL 和认证方案。如果在本地运行该插件,可以使用 base=<file> 将手动编写的 OpenAPI 文件合并进去。它同样支持来自 google/gnostic 项目的 gnostic 注解,让你无需单独的配置文件,直接在 proto 中保留这些细节:文件级的服务器和安全方案、按 RPC 划分的操作设置,以及字段级的示例和格式等扩展信息。
拿到 OpenAPI spec 之后能做什么?你可以把它导入 Scalar、Swagger UI 或 Redoc 来生成文档站点,也可以交给 openapi-generator 这类工具,为 Connect 尚未直接支持的语言生成客户端。一些 API 网关产品还能根据 OpenAPI 规范在边缘拦截不匹配的流量,把那些低成本的爬虫和扫描器挡在后端之外。虽然我认为 Protobuf 是更简洁、更精确的 schema 格式,但很多个人和公司已经在 API 服务中深度集成了 OpenAPI,能够复用这些集成自然非常有价值。
三种运行插件的方式
这些插件实际上有三种运行方式,取决于你想在多大程度上自己维护依赖和构建流程。
作为本地插件
两个插件都是用 Go 写的,可以用 go install 安装:
go install github.com/bufbuild/protoschema-plugins/cmd/protoc-gen-jsonschema@latest
go install github.com/sudorandom/protoc-gen-connect-openapi@latest把它们加进 buf.gen.yaml,然后运行 buf generate:
version: v2
plugins:
- local: protoc-gen-jsonschema
out: gen/jsonschema
- local: protoc-gen-connect-openapi
out: gen/openapi作为远程插件
这两个插件在 BSR 上也以远程插件的形式提供。把配置中的 local 换成 remote 后,Buf CLI 会把生成请求发给 BSR,由它在云端运行插件并返回生成的文件,本地什么都不用装:
version: v2
plugins:
- remote: buf.build/bufbuild/protoschema-jsonschema
out: gen/jsonschema
- remote: buf.build/community/sudorandom-connect-openapi
out: gen/openapi$ buf generate
$ ls gen/openapi/acme/inventory/v1/
inventory.openapi.yaml
为了可复现构建,固定插件版本,例如 buf.build/bufbuild/protoschema-jsonschema:v0.5.0。
作为生成的 SDK
对于已发布到 BSR 的模块,可以直接通过 URL 下载生成的文件。要获取 URL,打开该模块的 SDKs 选项卡,选择上述任一插件,然后复制 archive URL。例如,以下命令下载了 connectrpc/eliza 的 JSON Schema 和 OpenAPI 压缩包:
curl -fsSL -o eliza-jsonschema.zip https://buf.build/gen/archive/connectrpc/eliza/bufbuild/protoschema-jsonschema/latest.zip
curl -fsSL -o eliza-openapi.zip https://buf.build/gen/archive/connectrpc/eliza/community/sudorandom-connect-openapi/latest.zip对于所有模块和插件,其 URL 都遵循以下模式:
https://buf.build/gen/archive/{owner}/{module}/{plugin_owner}/{plugin}/{reference}.zip<