主题
Go Web 与 RPC 工具选型:先分清它们替你承担哪一层
Gin、Echo、chi、Fiber 是处理 HTTP 请求的框架或路由器;validator 检查字段;grpc-go 和 connect-go 处理 RPC;Buf 管理 Protobuf 契约;swag 与 oapi-codegen 在代码和接口文档之间生成文件。把这十个工具放进同一张性能榜,无法回答一个项目该怎么组合它们。
选型先看已经拥有的代码、客户端和运维能力,再看工具能减少哪些重复工作、会引入哪些语义变化。本篇逐个说明常用入口与限制,并实际验证 chi、validator、Connect 三种协议兼容和 OpenAPI 模型生成。版本全部明确列出,固定版本是复现和查证基线,不表示应在新生产项目里不经审查地沿用。
先按层选择,不必每层都选一个
| 层 | 工具 | 主要决定 |
|---|---|---|
| HTTP 路由与请求上下文 | Gin、Echo、chi、Fiber | Handler 模型、中间件兼容、绑定与错误处理 |
| 字段验证 | validator | 标签含义、自定义规则、错误格式 |
| RPC 运行时 | grpc-go、connect-go | 客户端协议、流模式、HTTP 栈、生态能力 |
| Protobuf 工程工具 | Buf | lint、依赖、生成、兼容性检查 |
| 从代码生成文档 | swag | 注释规范、生成结果、文档是否与实现一致 |
| 从契约生成 Go | oapi-codegen | OpenAPI 版本、生成目标、运行时校验 |
正在绘制图示…
查看图源代码
flowchart LR
A[HTTP / OpenAPI 契约] --> B[oapi-codegen]
B --> C[Go 类型、客户端或服务端接口]
C --> D[net/http / 框架与业务]
D --> E[validator 或契约校验器]
F[Go 注释] --> G[swag]
G --> H[Swagger 文档]
I[Protobuf 契约] --> J[Buf 检查与生成编排]
J --> K[grpc-go 或 connect-go 生成代码]
K --> L[RPC 业务实现]
图里的 validator 和契约校验器不是自动串联的:生成器产出 Go 类型以后,仍需应用主动调用验证逻辑。OpenAPI 和 Protobuf 也可以用于不同边界,不必把整个公司强行统一成一种协议。
四种 Web 框架:先看 Handler 是否仍是 net/http
| 工具与查证版本 | 处理函数形状 | 主要优势 | 迁移时要检查 |
|---|---|---|---|
| Gin v1.11.0 | func(*gin.Context) | 路由分组、绑定、渲染和成熟中间件习惯 | Abort/Next、自动错误响应、Context 生命周期 |
| Echo v4.13.4 | func(echo.Context) error | 返回 error,集中处理响应错误 | 绑定来源覆盖、错误处理器、Context 复用 |
| chi v5.2.3 | 标准 http.Handler | 与标准中间件、httptest 直接组合 | 绑定校验需要自己配,路由上下文API不同 |
| Fiber v2.52.9 | func(*fiber.Ctx) error | fasthttp 栈和类似 Express 的接口 | 适配器、内存引用、取消与流式语义 |
标准库 ServeMux 本身也是基线。Go 1.22 起已支持方法和路径参数,小服务未必需要框架。若团队已有通用日志、鉴权、追踪等 func(http.Handler) http.Handler 组件,保留 net/http 模型通常能减少迁移工作;这是一条基于依赖关系的判断,不是吞吐排名。
Gin:方便的 Context,也带有自动行为
常用入口是 gin.New 或 gin.Default、GET/POST、Group、Use。gin.Default 默认加日志和 Recovery;想自己定义边界时用 New。Gin Engine 实现 http.Handler,可以交给 http.Server,但内层中间件使用 Gin 自己的 Context、Next 和 Abort。
ShouldBindJSON 在绑定失败时把错误交还给你;BindJSON 走 MustBind 路径,会按错误先 Abort 并设置响应状态。在 v1.11.0 源码中,识别到 MaxBytesError 是 413,其他绑定错误通常为 400。若你的 API 要统一 Problem Details,用可控的 ShouldBind 路径更容易在提交响应前决定格式。Gin 固定 Context 源码
Abort 阻止后续处理链,不等于 Go 的 return。当前函数里写了 AbortWithStatus 后,如果下面还有业务代码,仍应明确 return。Context 来自复用机制,也不要在请求结束后把原 Context 留给后台工作;需要的字段先复制,后台任务另设生命周期。便利接口减少样板代码,但没有取消 HTTP 响应只能提交一次的约束。
Echo:让处理函数把错误交给统一出口
Echo 用 echo.New、e.GET、e.Group、e.Use 建服务,Handler 返回 error,统一的 HTTPErrorHandler 可以把领域错误映射成状态码和错误体。c.JSON 等方法本身也返回 error,调用方通常直接返回它。
绑定入口 c.Bind 需要留意来源。v4.13.4 的默认 Binder 先绑定路径参数;对 GET、DELETE、HEAD 再绑定 Query;最后绑定 Body,后面的来源可能覆盖前面的值。Header 绑定另有入口。如果目标对象 ID 来自路径,不要又让 Body 悄悄覆盖同一字段;可以分 DTO,或选择 BindPathParams、BindQueryParams、BindBody 单一来源。Echo 固定 bind.go
Echo 的绑定不等于完整业务校验。可以挂接自己的 Validator 实现,并把字段错误转成对外契约。它同样复用 Context,不应把它跨请求留存;下游调用用 c.Request().Context(),不要因为框架也有一个“Context”就忽略标准取消链。Echo v4.13.4
chi:保留标准 Handler 的组合方式
chi 的常用入口是 chi.NewRouter、Use、Route、Mount、Get 和 chi.URLParam。处理函数仍接收标准 ResponseWriter 与 *Request,所以已有 net/http 中间件可以直接使用。它负责路由和组合,不替你选择 JSON 验证库或统一错误模型。chi v5.2.3
本次的小服务从路径读三位数字 ID:
go
r.Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) {
in := struct {
ID string `validate:"required,number,len=3"`
}{ID: chi.URLParam(r, "id")}
if err := validate.Struct(in); err != nil {
http.Error(w, "id must contain 3 digits", 422)
return
}
w.WriteHeader(204)
})测试断言 /users/123 为 204,abc、12、-12、+12、1.2 为 422,不存在的路由为 404;标准中间件给这些路径都加了相同 Header。这个实验验证兼容与校验路径,不是路由吞吐基准。
Fiber:换了 HTTP 栈,就要重新验收边界
这里固定讨论 Fiber v2.52.9,不把 v3 API 混进来。Fiber 构建在 fasthttp 上,fiber.New、app.Get、app.Use 和 *fiber.Ctx 组成它的接口。它不天然等于标准 http.Handler;即便用适配器连接 net/http 组件,也要重新测 Header、取消、升级、SSE、代理和性能,不要假设接口转换保留了所有语义。Fiber v2.52.9
它对减少分配的追求还影响返回值生命周期。固定源码明确说明,Body、BodyRaw、Get 等返回的内容通常只在当前 Handler 内有效,需要长期保留时复制,或评估 Immutable 配置的成本。把请求体切片直接放进异步队列,可能在后续请求复用缓冲后读到其他内容。Fiber v2.52.9 ctx.go
这个差异比一张“hello world 每秒多少次”的图更值得先看。如果项目严重依赖标准 HTTP 生态,适配与验证成本可能大于路由阶段节省的时间;如果团队已经熟悉 fasthttp 并测过真实负载,则可以在明确边界下选它。本文没有运行这三种框架的性能对比,也没有宣称任何一个绝对更快。
validator:标签是程序,不能凭英文猜语义
validator 用 validator.New 建实例,用 Struct 或 Var 校验。常见标签有 required、min/max、len、oneof,以及集合元素验证的 dive;跨字段限制和领域规则可以用自定义校验。注册规则应在服务启动时完成,再复用实例,不要在处理请求时并发修改全局校验器。validator v10.27.0
一个很具体的误用是把 numeric,len=3 当成“三个数字字符”。实际跑一遍:
go
for _, s := range []string{"123", "-12", "+12", "1.2", "abc"} {
fmt.Printf("%s numeric=%v number=%v\n", s,
v.Var(s, "required,numeric,len=3") == nil,
v.Var(s, "required,number,len=3") == nil)
}输出:
text
123 numeric=true number=true
-12 numeric=true number=false
+12 numeric=true number=false
1.2 numeric=true number=false
abc numeric=false number=false原因在固定版本的正则定义里:numeric 允许正负号和小数,number 对字符串使用 [0-9]+。三位数字字符串在本例应选 required,number,len=3。这同时保留 001,如果先转整数再校验,就失去原始字符长度。v10.27.0 regexes.go
required 对数值字段会把零值视为缺失,业务若允许 0,要用指针或独立存在标记表达“有值”。WithRequiredStructEnabled 明确启用该版本的结构体 required 行为,避免依赖未来默认变化。错误处理还需区分 ValidationErrors 和调用方式错误 InvalidValidationError,不要直接把内部 Go 字段名和原始值输出给客户端。
验证格式也不验证权限或事实。“邮箱格式合法”不证明邮箱属于此用户;“订单 ID 合法”不证明订单存在或允许修改。validator 应放在这些业务检查之前,而不是取代它们。
grpc-go 与 connect-go:同用 Protobuf,服务接入方式不同
grpc-go:完整的 gRPC 运行时
grpc-go 的常用入口是 grpc.NewServer、生成的 RegisterXServer,以及客户端 grpc.NewClient 和 NewXClient。它围绕 gRPC 协议提供四种调用模式、Metadata、状态、拦截器、名称解析、负载均衡等能力。已有 gRPC 服务发现、观测和多语言客户端时,这些生态约定是重要的选择依据。grpc-go v1.75.1
使用时仍要为每次调用设置合适的 Context,区分 Unary 与 Stream 拦截器,并长期复用 ClientConn。也要锁定 protobuf 生成器、gRPC 生成器和运行时的兼容范围;升级生成代码后,旧运行时可能不再满足它要求的 API。
它不是普通 REST 路由器。浏览器调用、网关、HTTP 错误映射和流式代理需要单独设计。上一类项目的问题是协议与运维整合,而不是在 Gin 路由表中多注册一个路径即可解决。
connect-go:把类型化 RPC 接到标准 HTTP Handler
connect-go 的 Handler 基于 net/http,可以放进 ServeMux,并支持 Connect、gRPC 和 gRPC-Web 协议。常见入口由 protoc-gen-connect-go 生成,也可以直接使用 connect.NewUnaryHandler 和 connect.NewClient。选择它通常是希望保持 Protobuf 契约,同时更直接地利用标准 HTTP 的部署与中间件方式。connect-go v1.18.1
本次直接创建一个一元 Handler,用标准 protobuf StringValue 作为消息,核心是:
go
h := connect.NewUnaryHandler("/demo.v1.Greeter/Hello",
func(ctx context.Context, r *connect.Request[wrapperspb.StringValue]) (
*connect.Response[wrapperspb.StringValue], error) {
if r.Msg.Value == "" {
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("name required"))
}
return connect.NewResponse(wrapperspb.String("hello " + r.Msg.Value)), nil
})同一个 Handler 启动在本地 TLS/HTTP2 测试服务上,客户端分别使用默认模式、WithGRPC、WithGRPCWeb,实测:
text
Connect: hello Go; empty name=invalid_argument
gRPC: hello Go; empty name=invalid_argument
gRPC-Web: hello Go; empty name=invalid_argument这验证三种协议的一元调用与错误映射,不代表浏览器拥有相同的双向流能力。浏览器和 HTTP 版本会约束客户端流/双向流,反向代理也可能缓冲;要按具体传输路径验收。Connect Go streaming
Connect 自己的拦截器与 HTTP 中间件分工不同:前者看到 RPC 消息和错误,后者看到 HTTP Header、方法与连接。不要因为标准中间件可包住 Handler,就假设普通 JSON 错误体符合每一种 RPC 协议。
Buf:先管好契约,再生成代码
Buf 是 Protobuf 工程工具,不是运行在每次请求里的 RPC 服务端。buf lint 检查风格和规则;buf build 构建描述;buf breaking 把当前契约与指定基线比较;buf generate 按插件配置生成代码。生成器仍由对应语言/协议插件负责。Buf 官方文档
在多人维护的服务中,最有价值的是把“字段删了没保留编号”“名称变化会不会破坏客户端”变成 CI 中可重复检查。FILE 等破坏性检查类别比纯 wire 兼容更严格;应根据使用方依赖的代码与 JSON 形态选择。它检测的是规则覆盖的结构性变化,不知道某字段单位被你从秒改成毫秒。Buf breaking
文末附了 v2 格式的 buf.yaml 和完整示意 proto,展示 STANDARD lint 与 FILE breaking 配置。本次没有安装或执行 Buf,不把该配置写成已通过检查。实际接入时要固定 Buf CLI、插件版本、依赖锁文件以及基线 revision,先运行 build/lint,再与真实发布版本比较;不能把“与自己比较无差异”当兼容验证。
是否使用远程生成插件还涉及代码发送与构建可用性。离线或有严格出网要求的环境,优先规划本地插件与依赖缓存。工具能降低重复操作,但不会让供应链与版本管理自动消失。
swag 与 oapi-codegen:生成方向和规范版本都不同
| 工具与本篇版本 | 输入 | 输出 | 最容易误会的地方 |
|---|---|---|---|
| swag v1.16.6 | Go 注释、类型 | Swagger 2.0 文档 | 文档生成不会自动约束真实 Handler |
| oapi-codegen v2.4.1 | OpenAPI 3.0 契约 | Go 模型、客户端、服务端胶水等 | 生成类型不等于完整请求校验 |
swag:适合从现有 Go 服务补文档
swag 通过 @Summary、@Param、@Success、@Failure、@Router 等注释生成接口描述。它可用于已有 Gin、Echo 等服务,文档 UI 只是生成结果的展示层,不是框架的自动安全边界。swag v1.16.6
例如本次文档输入声明:
go
// @Summary Check a user
// @Param id path string true "User ID"
// @Success 204 "No Content"
// @Failure 404 {string} string "Not Found"
// @Router /users/{id} [get]注释里的 404 不会自动让函数在找不到用户时返回 404;代码、注释、测试必须一起维护。生成产物应进入审查,CI 可重新生成并检查是否有未提交差异。只看 Swagger UI 能打开,不能证明每个状态和字段都正确。
本次实际生成文件的开头是 "swagger": "2.0",路径中含 /users/{id} 的 GET 及 204/404 响应,与上述注释一致。该固定版本输出 Swagger 2.0,不要把它直接当作 OpenAPI 3.0 或 3.1 输入交给另一工具。转换规范需要独立审查,尤其是请求体、安全方案、nullable 和 schema 语义。
oapi-codegen:先写契约,再让 Go 接口跟着变
oapi-codegen 可以生成模型、客户端和不同 HTTP 框架的服务端接口/适配。已有明确外部契约、多语言客户端或设计评审流程时,从规范出发更容易控制变更。生成文件不应手工修改,业务逻辑写在自己的实现层。oapi-codegen v2.4.1
本次 OpenAPI 3.0.3 Schema 把 id 定义为三位数字字符串:
yaml
id:
type: string
pattern: '^[0-9]{3}$'使用固定 v2.4.1 生成 models 后,实际 Go 类型只是:
go
type User struct {
Id string `json:"id"`
}测试故意赋值 User{Id: "bad-value"} 再 JSON 编码,得到:
text
generated model accepts: {"id":"bad-value"}这证明了生成模型不会神奇地执行 pattern。还要添加契约验证中间件或显式业务校验,并测试反例;生成的 strict server 接口也不能被理解为自动检查所有 schema 约束。类型安全、结构解析、协议校验和业务校验是相邻但不同的层。项目 Request/Response validation 说明
这个版本明确以 OpenAPI 3.0 为基础,3.1 支持仍有上游限制,也不直接支持 Swagger 2.0。新版本是否改变,必须重新查看相应 tag 文档。正因为兼容范围不是由工具名字决定,本文没有拿另一篇的 3.1.1 契约直接喂给 v2.4.1。固定 README 的设计边界
真正开始选型时,拿一条有代表性的接口做验证
先把业务层写成普通 Go 函数,例如接收 context.Context 和业务输入结构体,返回结果与领域错误。框架适配层只负责绑定、验证、调用和错误映射。这样换 Gin/chi 或加一个 RPC 入口时,不必同时重写核心业务。
| 验证项 | 应观察什么 |
|---|---|
| 成功、验证失败、权限失败 | 状态与错误体是否一致,链是否正确停止 |
| 客户端断开、下游超时 | 取消是否传到需要停止的工作 |
| SSE/大文件/升级 | Flush、缓冲、Body 上限、可选接口是否保留 |
| 真实 JSON 与中间件链 | CPU、分配、延迟分布,而非空 Handler 纳秒数 |
| 过载与慢客户端 | 排队、拒绝策略、内存是否有界 |
| 升级生成器或框架 | 生成差异、契约兼容、旧客户端回归 |
先排除不能满足协议和生态需求的方案,再比较真实负载。数据库耗时 30ms 时,路由节省几百纳秒通常不会改变用户体验;若路由真是热点,应通过 profile 和可复现基准确认。本文没有提供跨框架性能排行。
资料、版本与本次验证
- Gin v1.11.0,Echo v4.13.4,chi v5.2.3,Fiber v2.52.9:上述版本源码已经通过固定 Go 模块获取并核对相关实现。
- validator v10.27.0,特别是 regexes.go。
- grpc-go v1.75.1,connect-go v1.18.1,protobuf-go v1.36.6。
- Buf v2 配置文档 与 breaking,查阅日期 2026-10-13;本次未运行 CLI。
- swag v1.16.6,oapi-codegen v2.4.1。
运行环境是 Go 1.27.2、Linux/amd64。实际 test/race/vet 与 go mod verify 通过,覆盖 chi 路由和标准中间件、validator 正负例、Connect/gRPC/gRPC-Web 一元调用,以及生成模型不执行 pattern 的反例。swag 和 oapi-codegen 均已实际生成,完整输入与生成结果放在下面。Gin/Echo/Fiber 在本文做源码与接口核对,未运行其端到端或性能实验;grpc-go 四种模式的独立实验见《RPC 与 gRPC》。
完整代码、配置和复现命令
先保存下面文件,保持 contract 子目录。运行测试不要求重新生成模型,models.gen.go 已完整附上。首次下载依赖后,可离线测试:
sh
GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org go mod download
go mod verify
GOPROXY=off GOSUMDB=off go test -v -count=1 ./...
GOPROXY=off GOSUMDB=off go test -race -count=1 ./...
GOPROXY=off GOSUMDB=off go vet ./...重新生成模型的实际命令,固定生成器版本:
sh
GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org \
go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.4.1 \
--config contract/oapi.yaml contract/openapi.yaml也可以运行附带的 generate.sh,同时重新生成模型与 Swagger 文档:
sh
GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org sh generate.sh生成命令需要访问模块元信息;本次尝试完全关闭代理时,go run 工具@版本 因缺少 deprecation 查询结果失败,随后用上述显式代理命令重新生成成功。测试本身在关闭代理后通过。脚本给固定版本 swag 生成的 JSON 补一个文件末尾换行,以便生成结果逐字节比较。Buf 文件是配置示意,本次未执行。使用时需要自行固定 CLI 版本并指定真实历史基线,不能从本文推断它已通过你项目的兼容检查。
go.mod
go.mod
module example.com/webapi/07_tools
go 1.27.2
require (
github.com/go-chi/chi/v5 v5.2.3
github.com/go-playground/validator/v10 v10.27.0
)
require (
connectrpc.com/connect v1.18.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
)go.sum
text
connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw=
connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=tools_test.go
代码块 · 46 行 · 点击收起 / 展开
go
package toolsdemo
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/go-playground/validator/v10"
)
func app() http.Handler {
validate := validator.New(validator.WithRequiredStructEnabled())
r := chi.NewRouter()
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Standard-Middleware", "yes")
next.ServeHTTP(w, r)
})
})
r.Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) {
in := struct {
ID string `validate:"required,number,len=3"`
}{ID: chi.URLParam(r, "id")}
if err := validate.Struct(in); err != nil {
http.Error(w, "id must contain 3 digits", 422)
return
}
w.WriteHeader(204)
})
return r
}
func TestStandardHandlerAndValidation(t *testing.T) {
h := app()
for _, tc := range []struct {
path string
want int
}{{"/users/123", 204}, {"/users/abc", 422}, {"/users/-12", 422}, {"/users/+12", 422}, {"/users/1.2", 422}, {"/users/12", 422}, {"/missing", 404}} {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("GET", tc.path, nil))
if w.Code != tc.want || w.Header().Get("X-Standard-Middleware") != "yes" {
t.Fatalf("%s status=%d header=%v", tc.path, w.Code, w.Header())
}
}
t.Log("chi is an http.Handler; standard middleware wraps routes; validator rejects invalid path values")
}teaching_test.go
代码块 · 71 行 · 点击收起 / 展开
go
package toolsdemo
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"connectrpc.com/connect"
"github.com/go-playground/validator/v10"
"google.golang.org/protobuf/types/known/wrapperspb"
)
func Example_validatorNames() {
v := validator.New(validator.WithRequiredStructEnabled())
for _, s := range []string{"123", "-12", "+12", "1.2", "abc"} {
fmt.Printf("%s numeric=%v number=%v\n", s, v.Var(s, "required,numeric,len=3") == nil, v.Var(s, "required,number,len=3") == nil)
}
// Output:
// 123 numeric=true number=true
// -12 numeric=true number=false
// +12 numeric=true number=false
// 1.2 numeric=true number=false
// abc numeric=false number=false
}
func TestConnectAndGRPCOnHTTP(t *testing.T) {
const path = "/demo.v1.Greeter/Hello"
h := connect.NewUnaryHandler(path, func(ctx context.Context, r *connect.Request[wrapperspb.StringValue]) (*connect.Response[wrapperspb.StringValue], error) {
if r.Msg.Value == "" {
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("name required"))
}
return connect.NewResponse(wrapperspb.String("hello " + r.Msg.Value)), nil
})
mux := http.NewServeMux()
mux.Handle(path, h)
server := httptest.NewUnstartedServer(mux)
server.EnableHTTP2 = true
server.StartTLS()
defer server.Close()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
for _, mode := range []struct {
name string
options []connect.ClientOption
}{{"Connect", nil}, {"gRPC", []connect.ClientOption{connect.WithGRPC()}}, {"gRPC-Web", []connect.ClientOption{connect.WithGRPCWeb()}}} {
c := connect.NewClient[wrapperspb.StringValue, wrapperspb.StringValue](server.Client(), server.URL+path, mode.options...)
result, err := c.CallUnary(ctx, connect.NewRequest(wrapperspb.String("Go")))
if err != nil || result.Msg.Value != "hello Go" {
t.Fatalf("%s %v %v", mode.name, result, err)
}
_, err = c.CallUnary(ctx, connect.NewRequest(wrapperspb.String("")))
if connect.CodeOf(err) != connect.CodeInvalidArgument {
t.Fatal(err)
}
t.Logf("%s: %s; empty name=%s", mode.name, result.Msg.Value, connect.CodeOf(err))
}
}
func TestGeneratedModelDoesNotValidatePattern(t *testing.T) {
u := User{Id: "bad-value"}
b, err := json.Marshal(u)
if err != nil {
t.Fatal(err)
}
if string(b) != `{"id":"bad-value"}` {
t.Fatal(string(b))
}
t.Log("generated model accepts:", string(b))
}models.gen.go
go
// Package toolsdemo provides primitives to interact with the openapi HTTP API.
//
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.4.1 DO NOT EDIT.
package toolsdemo
// User defines model for User.
type User struct {
Id string `json:"id"`
}generate.sh
sh
#!/bin/sh
set -eu
# Run from this module's root. The caller selects GOPROXY/GOSUMDB.
go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.4.1 \
--config contract/oapi.yaml contract/openapi.yaml
go run github.com/swaggo/swag/cmd/swag@v1.16.6 init \
-g main.go -d contract/swag -o contract/swag/docs --outputTypes json
# The pinned swag version omits a final newline; normalize the checked-in artifact.
printf '\n' >> contract/swag/docs/swagger.jsoncontract/openapi.yaml
yaml
openapi: 3.0.3
info:
title: User example
version: 1.0.0
paths: {}
components:
schemas:
User:
type: object
required: [id]
properties:
id:
type: string
pattern: '^[0-9]{3}$'contract/oapi.yaml
yaml
package: toolsdemo
generate:
models: true
output: models.gen.go
output-options:
skip-prune: truecontract/swag/main.go
go
package main
import "net/http"
// @title User API example
// @version 1.0
// @description Documentation generation fixture.
// @BasePath /v1
func main() {}
// GetUser is a documentation fixture, not a running API.
// @Summary Check a user
// @Param id path string true "User ID"
// @Success 204 "No Content"
// @Failure 404 {string} string "Not Found"
// @Router /users/{id} [get]
func GetUser(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }contract/swag/docs/swagger.json
代码块 · 37 行 · 点击收起 / 展开
json
{
"swagger": "2.0",
"info": {
"description": "Documentation generation fixture.",
"title": "User API example",
"contact": {},
"version": "1.0"
},
"basePath": "/v1",
"paths": {
"/users/{id}": {
"get": {
"summary": "Check a user",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
},
"404": {
"description": "Not Found",
"schema": {
"type": "string"
}
}
}
}
}
}
}buf.yaml
yaml
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
breaking:
use:
- FILEproto/users/v1/users.proto
proto
syntax = "proto3";
package users.v1;
option go_package = "example.com/webapi/gen/users/v1;usersv1";
message User { string id = 1; }
message GetUserRequest { string id = 1; }
message GetUserResponse { User user = 1; }
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
}