过去几周我在公司的两个项目上做了些调研和初步开发:一个是统一认证鉴权服务(authx),另一个是管理面 API 网关(api-server)。虽然这部分工作目前暂停了,但调研过程中做的几组技术横向对比值得记录下来:

  • gRPC 生态对比:Connect-RPC vs gRPC(和 gRPC-Gateway)
  • OpenAPI 的几个 proto 生成器对比
    • gRPC-Gateway OpenAPI 注解与 Protovalidate 注解的分工
  • 状态码风格对比:HTTP/gRPC status code vs 自定义信封
  • session token 形态对比:JWT vs 随机字符串(opaque token)
  • web 层框架对比:gin / chi / echo / kratos
  • ORM 框架对比:ent vs GORM
  • go-kratos 生态的默认技术栈与推荐的 service 层次划分
  • 工具链版本管理:go install / go run @version / tools.go vs Go 1.24+ go tool

authx 的方案做得更早,后来引入 api-server 作为管理面的统一 HTTP 入口,由它代理所有管理面请求,与 authx 等下游服务之间主要走 gRPC。authx 的几个决策也据此重新评估。下文有些对比来自实际编码体验(ent、kratos 分层),有些停留在调研结论(OpenAPI 生成器、Connect-RPC 的大部分论据)。

团队此前已有两套系统在运行:一套基于开源 new-api 项目,负责推理请求的路由与计费,是 Go + Gin + GORM 技术栈;另一套是自研的推理服务部署项目,负责拉起推理服务、承接请求,用的是 Python + FastAPI。在做方案时我也仔细考量了这些已有项目所使用的技术栈。

1. Connect-RPC vs gRPC

1.1 两套方案

  • gRPC + gRPC-Gateway:gRPC-Go 做 RPC,gRPC-Gateway 依据 proto 里的 google.api.http 注解生成 HTTP/JSON → gRPC 的转码代理,HTTP 路径、参数映射、请求体绑定全部在注解里显式声明。
  • Connect-RPC(Go 实现为 connect-go:bufbuild 开发的 RPC 框架,生成的 handler 就是一个标准的 net/http handler,同一份服务实现同时支持 Connect、gRPC、gRPC-Web 三种协议,浏览器可直接调用,不需要额外代理。但 HTTP 路径是约定式的 POST /{package}.{Service}/{Method},不支持自定义。

两者对 HTTP 路径的控制粒度,用同一个 SessionService 对比如下。gRPC-Gateway 一侧,HTTP 语义在 proto 里显式声明:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
service SessionService {
  rpc GetSession(GetSessionRequest) returns (GetSessionResponse) {
    option (google.api.http) = { get: "/v1/sessions/{id}" };
  }

  rpc Login(LoginRequest) returns (LoginResponse) {
    option (google.api.http) = {
      post: "/v1/sessions:login"
      body: "*"
    };
  }
}

HTTP 方法、路径风格、路径参数与请求字段的绑定({id})、请求体映射(body)都可以逐项配置,还可以用 additional_bindings 给同一个 RPC 加挂旧路径做兼容。Connect-RPC 一侧没有这些配置项,同样的两个 RPC 对外只有约定式路径:

1
2
POST /auth.v1.SessionService/GetSession
POST /auth.v1.SessionService/Login

方法恒为 POST,路径由「包名.服务名/方法名」推导,请求体恒为整个请求消息。前者是 REST 风格的资源路径,后者是 RPC 风格的调用路径——这个差异决定了它们各自适合的场景。

1.2 网关场景

api-server 是平台管理面 HTTP/JSON 到内部 gRPC 的统一入口,核心诉求之一是集中、精细地控制公开 HTTP 契约。对照这个诉求看 Connect-RPC,有几处不匹配:

  1. 暴露控制粒度不够:需要按 google.api.http 注解和 gateway 生成输入集精细控制哪些 RPC 暴露为 HTTP,Connect-RPC 无法做到。
  2. HTTP 路径与参数映射不可定制:Connect-RPC 的路径是约定式的,无法像 google.api.http 那样显式声明 method/path/body,也做不了 additional_bindings(旧路径兼容)。
  3. 生态成熟度:Connect-RPC 生态里做转码的 vanguard-go 仍处于试验阶段。
  4. 协议数量:已有 HTTP/JSON + gRPC 两条协议,不值得再引入第三种协议和新的网关运行时。

基于此,倾向于不引入 Connect-RPC,HTTP/JSON 到 gRPC 的转码交给注解驱动的 gRPC-Gateway。

1.3 独立服务场景

authx 是独立的认证鉴权服务,处境和网关不同:

  • 服务自身是 proto-first 的,希望 HTTP/JSON 与 gRPC 双传输一站解决,不想再维护一套网关;
  • 考察同为认证鉴权系统的开源实现时,ZITADEL 的技术栈与目标非常接近(Go + Connect-RPC + buf);
  • authx 需要自己暴露少量 HTTP-only 端点:OAuth/OIDC 的 authorize/callback 302 跳转、登录态的 Set-Cookie、JWKS 与 well-known 发现端点,这些端点用 Web 框架承载、与 RPC handler 共用同一监听端口即可。

基于此,当时选了 connect-go v1.20:生成的 handler 就是标准的 net/http handler,经 gin.WrapH 挂进 Web 框架,与这些 HTTP-only 端点共用端口,实现起来比较直接。

1.4 方案演进与小结

api-server 引入之后,1.3 的前提变了。所有管理面 HTTP 入口收敛到网关,原本规划由 authx 直接承担的 HTTP-only 端点(OIDC callback、Set-Cookie、JWKS)都可以委托给 api-server,authx 只在 gRPC 层提供底层能力:签发和校验 token、管理 session、做权限决策。

connect-go 的 HTTP 能力因此不再被直接使用,继续用它反而多维护一层概念,不如换 grpc-go 与下游服务保持一致。这也是 go-kratos 实施时 authx 的实际形态:proto 只生成 *_grpc.pb.go,HTTP server 仅保留健康检查等最基础的非业务端点。

所以两个项目在方案演进后收敛为同一形态:网关用 gRPC-Gateway,下游服务用 grpc-go。需要把 HTTP 路径当公共契约精细管理时,注解驱动的 gRPC-Gateway 更合适;服务自身 proto-first、且 HTTP 入口已被网关收敛时,直接走 grpc-go 更轻量。

2. OpenAPI 的 proto 生成器对比

OpenAPI 这块的选型要从 HTTP 映射的管理方式说起。api-server 最初的设想是完全建立在 gRPC-Gateway 的外置 YAML(grpc_api_configuration)上,把所有 HTTP 路径映射集中在一处管理,而不是用 google.api.http 注解侵入式地分散在各个 proto 里——gRPC-Gateway + protoc-gen-openapiv2 这一套都能与外置 YAML 配合,看起来比较自然。

两种方式表达的是同一份映射。外置 YAML 把它们集中在网关侧的一个文件里,按 RPC 全名(selector)逐个指定:

1
2
3
4
5
6
7
http:
  rules:
    - selector: auth.v1.SessionService.GetSession
      get: "/v1/sessions/{id}"
    - selector: auth.v1.SessionService.Login
      post: "/v1/sessions:login"
      body: "*"

内嵌注解则把同样的内容分散写进各个 proto 的 RPC 定义上(写法见 1.1 的例子)。前者的吸引力在于 HTTP 层完全由网关侧控制,不需要下游 proto 配合改动。

深入考察后发现两个问题。一是 proto 生态里的众多生成器——包括 kratos 的 protoc-gen-go-http 和各 OpenAPI 生成器——都只认 proto 内嵌的 google.api.http 注解,外置 YAML 的支持面比想象中窄,连 gRPC-Gateway 自家的 protoc-gen-openapiv3 目前也尚未支持外置 YAML。二是前端契约需要 OpenAPI v3,而 openapiv2 只出 Swagger 2.0,既然生成器要换,也就没必要锁定在 gRPC-Gateway 自家的这一套上。

于是 HTTP 映射改为写进 proto 注解——同一份 proto 可以同时作为 gRPC-Gateway、kratos 和 OpenAPI 生成器的输入;OpenAPI v3(3.0.x)生成器也随之重新选型,不再使用「protoc-gen-openapiv2 生成 Swagger 2.0、再用 kin-openapi 转成 v3」的老流程。一共考察了四个候选,分别来自 grpc-gatewaygoogle/gnosticprotoc-gen-connect-openapi 三个项目:

生成器输出版本成熟度Protovalidate 映射其他
grpc-gateway protoc-gen-openapiv2OpenAPI 2.0(Swagger)成熟,生产环境广泛使用不识别 protovalidate 注解自带 openapiv2_field/openapiv2_schema 注解,可手写约束进文档,但同一套信息后端复用不了;要得到 v3 需 kin-openapi 二次转换
grpc-gateway protoc-gen-openapiv3OpenAPI 3.x较新,仍标注 experimental不识别 protovalidate 注解功能覆盖和打磨程度尚不如 v2
google/gnostic protoc-gen-openapi v0.7.1OpenAPI 3.0.x较成熟不识别 protovalidate 注解,约束字段要后处理补strategy: all 生成单文件
protoc-gen-connect-openapi v0.25.7默认 3.1.0,需降版本或接受 3.1活跃但较新识别并支持一部分 protovalidate 注解,required/min/max/pattern/enum 等直接进 schema默认生成 Connect 协议内容,需从 features 中去掉 connectrpctrim-unused-types 按方法引用裁剪,未被引用的 message 仍可能进入文档

gRPC-Gateway 的两个生成器位置很典型:openapiv2 成熟但只出 Swagger 2.0,openapiv3 能出 v3 但还不成熟——「能出 v3」和「敢用在生产」之间隔着一段距离,这也是进一步考察 gnostic 和 connect-openapi 的原因。

connect-openapi 用 features 选项控制启用哪几套注解体系,一共有五个可选项:

  • connectrpc:Connect RPC 的 HTTP 路径
  • google.api.http:gRPC-Gateway 风格注解
  • twirp:启用 Twirp 服务路径生成
  • gnostic:gnostic 项目的 OpenAPI v3 注解
  • protovalidate

默认启用除 twirp 外的四个,而一旦显式设置就只启用列出的项——所以要让输出不含 Connect 协议内容,实际写法是 features=google.api.http;gnostic;protovalidate

connect-openapi 的 trim-unused-types 裁剪也不够彻底:它按方法请求/响应引用来决定 schema 是否保留,但即使显式从 features 中去掉 connectrpc、只生成 google.api.http 注解的 HTTP 路径,那些未使用 google.api.http 注解的 RPC 所引用的 schema 也不会被裁掉。如果不考虑这一点,connect-openapi 是四个候选里最值得考虑的一个:唯一内置 Protovalidate 映射,同时认 gRPC-Gateway 和 gnostic 两套注解,覆盖面最全。

这个选型最终没有定下来,目前生成流程中使用的是 gnostic 的 protoc-gen-openapi,Protovalidate 约束字段由后处理补。

2.1 文档注解与校验注解的分工

同一个字段上,两套注解可以并存:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
string username = 1 [
  // grpc-gateway 的 OpenAPI 注解:面向文档
  (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
    min_length: 1
    max_length: 64
    pattern: "^[a-zA-Z0-9_-]+$"
  },
  // Protovalidate 注解:面向运行时校验
  (buf.validate.field).string = {
    min_len: 1
    max_len: 64
    pattern: "^[a-zA-Z0-9_-]+$"
  }
];
gRPC-Gateway OpenAPI 注解Protovalidate 注解
写法直接写 JSON Schema 关键字(min_lengthpattern),面向文档描述按字段类型组织规则(string.min_lenenum.defined_only),面向校验语义
生效范围只进 OpenAPI 文档,给前端看后端运行时校验(gateway 拦截器、服务内执行)
另一侧是否可见后端完全读不到文档默认读不到,除非生成器内置映射或后处理补充

工程规则上,同一条校验信息没必要在 proto 里写两遍:后端用 Protovalidate 做运行时校验,OpenAPI 文档里的 min/max/pattern/required 等约束字段也从这些规则映射或后处理生成,而不是在文档侧再手写一份。具体怎么映射,取决于生成器选型——connect-openapi 能直接生成,gnostic 和 gRPC-Gateway 则需要后处理阶段补充。

3. HTTP/gRPC 的错误与响应信封

响应和错误怎么包装,是这次设计里比较特别的一项。HTTP 和 gRPC 两侧各有主流约定,最终采用的方案与两边都不完全一样。

3.1 三个生态的主流做法

gRPC:用一组固定的 status code(OKINVALID_ARGUMENTNOT_FOUNDUNAUTHENTICATED 等约 17 个)表达调用结果,错误详情走 google.rpc.Status(AIP-193):code + message + detailsdetails 是可扩展的结构化错误明细。gRPC-Gateway 默认把 status code 映射到对应的 HTTP 状态码(如 NOT_FOUND → 404)。

Connect-RPC:沿用与 gRPC 相同的 code 集合,但原生面向 HTTP/JSON,错误响应直接是 JSON。code 用字符串(如 "not_found")而非数字;details 里的自定义错误用 base64 编码,避免客户端必须持有对应 proto 才能解析。gRPC-Gateway 的 code 仍是数字(如 5)。二者共同点在于传输层状态码负责错误分类,业务错误要归并到有限的 code 集合里

REST/OpenAPI 的主流则是「HTTP 状态码即业务结果」:2xx 成功、4xx 客户端错误、5xx 服务端错误,业务错误类别多时再在 body 里套一层 code/message。好处是基础设施(LB、WAF、CDN、APM)都能识别,前端 response.ok 直接可用。

三种做法的响应示例对比:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// gRPC-Gateway 成功响应(HTTP 200)
{
  "id": "sess_xxx",
  "name": "..."
}

// gRPC-Gateway 错误响应(HTTP 404)
{
  "code": 5,
  "message": "session not found",
  "details": []
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Connect-RPC 成功响应(HTTP 200)
{
  "id": "sess_xxx",
  "name": "..."
}

// Connect-RPC 错误响应(HTTP 404)
{
  "code": "not_found",
  "message": "session not found",
  "details": []
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// REST/OpenAPI 成功响应(HTTP 200)
{
  "id": "sess_xxx",
  "name": "..."
}

// REST/OpenAPI 错误响应(HTTP 404)
{
  "error_code": "SESSION_NOT_FOUND",
  "message": "session not found"
}

3.2 本方案的信封形式

这次设计实际采用的是全平台统一业务信封 + HTTP 状态码恒为 200

  • 所有 RPC 响应统一为 { retcode, retmsg, data }retcode=0 表示成功,非 0 为业务错误码,按 HTTP 风格分段:400xx 参数错误、401xx 认证失败、403xx 授权失败、404xx 不存在、409xx 冲突、500xx/503xx 内部错误/不可用;api-server 自身的转发/中间件层错误单独占用一个高位段位,与业务错误码区分开。
  • 信封定义在 proto message 层:每个 XxxResponseretcode/retmsg/data 字段,原业务字段下沉为 XxxResponseData。这样 gRPC 与 HTTP/JSON 共享同一结构,gRPC status 只保留传输语义(调用是否到达、是否 panic),不再承载业务错误。

示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// proto 层定义的信封
service SessionService {
  rpc GetSession(GetSessionRequest) returns (GetSessionResponse);
}

message GetSessionRequest {
  string id = 1;
}

message GetSessionResponse {
  int32  retcode = 1;
  string retmsg  = 2;
  GetSessionResponseData data = 3;
}

message GetSessionResponseData {
  string id   = 1;
  string name = 2;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 成功
{
  "retcode": 0,
  "retmsg": "ok",
  "data": { "id": "sess_xxx", "name": "..." }
}

// 业务错误
{
  "retcode": 40101,
  "retmsg": "token expired",
  "data": null
}

3.3 这样设计的好处

  • 前端契约稳定:调用方只看 retcode,不需要同时理解 HTTP 状态码和 connect/gRPC 错误体两套约定。
  • 多下游混用时不被牵制:不会因为某个下游返回 500 就让前端把整条链路当服务器故障;部分下游失败时,网关可以用自己的错误码段位表达「下游异常响应」,而不是被 gRPC status 限制。
  • 错误码空间可扩展:gRPC 的 17 个 code 对业务来说太粗,数字 code 的可读性也差;HTTP 风格段位兼顾了分类能力和可读性。

3.4 代价同样明显

  • HTTP 状态码失去区分能力:负载均衡、缓存、WAF、APM 无法通过状态码区分成功与失败;如果未来对外开放或接入第三方 SDK,「200 包错误」会让标准客户端困惑。
  • 可观测性需要额外建设:access log 只记 http_status=200 的话,SRE 会以为一切正常。需要把 retcode(和 RPC 方法标识)作为 access log、trace、metrics 的一级字段,对 retcode != 0 的 span 标记 error,告警按「方法 + retcode」配置而不是只看 5xx。

几个前提同时成立时,这个方案才成立:所有下游统一了信封格式、网关保持轻量(不深入业务语义)、前端是内部团队,可以接受「看 retcode 不看 status」的约定。前提一旦变化(比如开放给外部开发者),矛盾会首先集中在 HTTP 语义缺失这一侧。

4. Session Token 的形态:JWT vs 随机字符串

authx 设计阶段还有一个需要确定的问题:session token 用什么形态。AuthX 本身是有后端状态的(sessions 表 + Redis),凭证校验又被要求必须实时回源——每次校验都要确认 token 有效、未被吊销。JWT 最大的卖点是「自包含、免回源」,但在这个前提下发挥不出来:验证 access_token 时仍然要查后端状态。

基于这一点,两种方案自身的特性值得考量:

  • 随机字符串(opaque token):token 本身没有语义,只是一个标识符。校验必须回源,但回源本来就是必须的;好处是吊销即时生效,没有传播延迟,实现也最简单。Session Cookie、API Key(sk-xxx)都属于这一类。

  • 自包含 JWT:把 user_idtenant_idrole_ids 等声明直接写进 token。短 TTL(如 5-15 分钟)内可以减少回源次数;代价是吊销只能依赖黑名单或等待过期。「自包含」和「可即时吊销」天然矛盾。

    一个 JWT 由 header.payload.signature 三部分组成,每部分都是 Base64URL 编码,用 . 连接。例如:

    1
    
    eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwidXNlcl9pZCI6InVzcl8xMjMiLCJ0ZW5hbnRfaWQiOiJ0bnRfYWJjIiwicm9sZV9pZHMiOlsiYWRtaW4iXSwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyMzkwODJ9.SflKxwRJSMeKKF2QT4fwpMe...
    

    解码后的 payload 大致是:

    1
    2
    3
    4
    5
    6
    7
    8
    
    {
      "sub": "1234567890",        // subject:token 归属的主体标识
      "user_id": "usr_123",       // 业务侧用户 ID
      "tenant_id": "tnt_abc",     // 业务侧租户 ID
      "role_ids": ["admin"],      // 角色列表
      "iat": 1516239022,          // issued at:签发时间戳
      "exp": 1516239082           // expiration:过期时间戳
    }
    

按场景二选一比较合理。如果后端本来就要回源,opaque token 更直接;如果希望减少回源、能接受延迟吊销,JWT 更合适。

4.1 业内的常见做法

主流身份认证服务很少走纯 JWT 或纯 opaque 的极端,普遍采用「JWT access token / ID token + opaque refresh token」的混合形态:access token 短 TTL,资源服务端可以本地验证;refresh token 不透明,由授权服务端统一管理和吊销。

服务access token / ID tokenrefresh token核心策略
Auth0JWT 或 opaqueopaque按 audience 决定 access token 格式
Supabase AuthJWTopaqueaccess token 默认 1 小时 TTL
Firebase AuthID token 为 JWTopaque通过 refresh token 换取新 ID token
AWS CognitoJWTopaqueaccess token 默认 1 小时 TTL,支持撤销
ZITADELJWT 或 opaqueopaque提供 introspection / revocation 端点
Ory HydraJWT 或 opaqueopaquerefresh token 必须保证即时吊销

这些服务的共同点是:把短 TTL 的 JWT 用于访问凭证,把opaque token 用于刷新凭证。混合形态本身并非不可行,关键在于别把两边的缺点也一起拿过来。JWT 省掉回源,这套方案才有意义;一旦 access token 每次校验仍要回源查库,它的优势就被抵消,反而要多维护一套黑名单或 token 唯一标识(jti)。

4.2 当前项目的现状

本项目最终采用的是 JWT access token + JWT refresh token,配合 Redis 黑名单支持强制下线。连 refresh token 也是 JWT,意味着它的吊销同样只能依赖黑名单或等待过期——这在行业里并不常见,多半是设计阶段只关注了统一技术形态,没有把 refresh token 的格式和行业惯例细致对齐;现在回头看,算是一处疏漏。

既然每次校验都要回源确认,access token 的本地验证优势本就不存在。从设计角度看,这种情况下更倾向于使用 opaque token:实现更直接,吊销也简单。

5. gin / chi / echo / kratos

在 proto-first 架构下,HTTP 框架不再承载业务语义,只是生成 handler 或网关路由的运行载体。选型标准因此也和传统 Web 项目不同。

5.1 chi:api-server 的最外层路由

api-server 需要的是一个能和 gRPC-Gateway 无缝配合的路由层。选 chi,关键原因是它只聚焦「路由 + 中间件」,不引入额外的请求模型:

  • chi 基于标准 net/http,中间件签名是 func(http.Handler) http.Handler,可以直接挂载 gRPC-Gateway 生成的 http.Handler
  • Gin 是一整套 Web 框架,有自己的上下文、绑定、渲染、验证和错误处理模型,会与 gRPC-Gateway 的 runtime.ServeMux 以及统一信封模型形成两套语义
  • 这个项目不需要 Gin 的模板、表单绑定、验证等能力,chi 的路由能力足够覆盖 gateway、health、OpenAPI、Custom Handler 几个入口。

这样业务语义全在 gRPC/信封一侧,HTTP 层只保留 net/http 外层路由即可,不需要维护第二套请求和错误模型。

5.2 Gin:在 authx 里被收窄到 HTTP-only 端点

authx 对 Web 框架的需求比网关更窄。原设计里选了 Gin,理由同样是「与 new-api 同栈」,但职责被刻意收窄:只承载 OAuth callback、JWKS、well-known 这几个 HTTP-only 端点的路由和中间件(CORS、Recovery、访问日志),connect handler 用 gin.WrapH 挂进来共用端口。这些端点后来都委托给了 api-server,Gin 在 authx 里也就失去了存在必要。

5.3 echo 为什么没成为候选

echo 在两个项目里都没有成为候选,只在考察开源实现时遇到过(Hankolabstack/echo/v4)。echo 比 Gin 内置能力更多,但也更厚重;而 Gin 在国内更流行,且已有项目已经采用 Gin。在 proto-first 架构下,echo 没有额外优势。

5.4 kratos protoc-gen-go-http:不是同一个维度

kratos 自带的 protoc-gen-go-http 属于另一个类别:它不是候选框架,而是 HTTP 代码生成器。它从 google.api.http 注解生成 HTTP handler,把 HTTP query/path/body 绑定到 proto 请求消息,调用 service 接口方法,再把 proto 响应写回 HTTP/JSON。这样 HTTP server 和 gRPC server 可以跑在同一个服务里,共享同一份 service 实现。

它和 gRPC-Gateway、Connect-RPC 都在做「HTTP/JSON → gRPC」,但定位不同:

  • gRPC-Gateway 是独立网关,通常单独部署,适合管理面网关这类需要集中控制的场景;
  • Connect-RPC 是协议框架,路径约定式,同时支持 Connect/gRPC/gRPC-Web;
  • kratos protoc-gen-go-http 是进程内代码生成,按 google.api.http 注解把已有 gRPC 服务再暴露一层 HTTP,服务仍然是 gRPC 优先。

所以 api-server 作为管理面网关选 gRPC-Gateway;独立服务需要多协议暴露时 Connect-RPC 更轻量;kratos 服务内部需要 HTTP 时,protoc-gen-go-http 就能满足需求。

gRPC-GatewayConnect-RPCkratos protoc-gen-go-http
定位独立 HTTP/JSON → gRPC 网关多协议 RPC 框架kratos 进程内 HTTP 代码生成
部署单独进程/服务与业务服务同进程与业务服务同进程
HTTP 路径google.api.http 注解约定式 /{pkg}.{Service}/{Method}google.api.http 注解
协议支持HTTP/JSON ↔ gRPCConnect/gRPC/gRPC-WebHTTP/JSON ↔ gRPC
主要场景管理面网关、公共 HTTP 契约独立服务多协议暴露kratos 服务内部暴露 HTTP

6. ent vs GORM

最初选择的 ORM 框架并不是 ent。方案设计阶段选择了 GORM v2,理由是向 new-api 的技术栈看齐;进入实施阶段后,leader 指定使用 go-kratos 生态,数据层随之换成了生态内常见的 ent。这次切换也是一次对 go-kratos 生态设计的实际体验。

6.1 模型层面的差异

维度GORMent
模型定义struct + tag,运行时反射ent/schema 下用 Go 代码声明字段/索引/边(ent.Edge),代码生成出类型安全的 Client
查询链式 API,字段名是字符串生成代码,谓词/排序/翻页全部编译期检查
关联Preload + tag 约定Edges() 显式声明,生成 QueryXxx() 遍历方法
迁移AutoMigrate 能力有限,通常另配 golang-migrate自带 schema migration(client.Schema.Create),也可配 Atlas
学习成本低,约定式生成物多一层,但 IDE 体验和重构安全性好

核心差异用两段代码对比更直观。GORM 用 struct tag 描述模型,查询时字段名是字符串:

1
2
3
4
5
6
7
8
9
// GORM
type User struct {
    ID     uint   `gorm:"primaryKey"`
    Name   string `gorm:"size:64"`
    Status string `gorm:"index"`
    Tenant Tenant
}

db.Where("status = ?", "active").Find(&users)

ent 用 Go 代码声明 schema,查询是谓词/边方法,编译期可检查:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// ent schema
func (User) Fields() []ent.Field {
    return []ent.Field{
        field.String("name").MaxLen(64),
        field.Enum("status").Values("active", "inactive"),
    }
}

func (User) Edges() []ent.Edge {
    return []ent.Edge{edge.To("tenant", Tenant.Type)}
}

// 查询
client.User.Query().
    Where(user.StatusEQ("active")).
    WithTenant().
    All(ctx)

落地后 ent 的形态是:schema 下用 Go 代码声明字段和边,通过 go tool ent generate 生成类型安全的访问代码;data(持久)层只持 *ent.Client,查询和 upsert 都用生成的链式 API,没有任何 SQL 字符串。

就表达风格而言,GORM 胜在直观简洁,ent 则把字段、索引、关联(ent.Edge)、校验等语义直接写进代码,编译期即可检查。选择 ent 的一部分原因,也是认同这种「schema 即代码」的表达方式。

6.2 枚举:proto 的 int32 与 ent 的 string

proto 的 enum 默认生成 int32(配合 iota 式的常量),ent 的 field.Enum 默认生成 string 枚举并做运行时校验。这会造成一个断层:传输层用数字表示状态,持久层用字符串表示状态。

两边的默认值各有道理:proto 侧 int32 紧凑、跨语言兼容性好;ent 侧 string 可读性强,直接看数据库就能知道状态含义。问题是传输层用数字、持久层用字符串,中间必须有一层转换。

建议把转换收敛在持久层(data/Repo 实现)统一处理:写入数据库前把业务枚举(int32)转成 ent 的 string 枚举,读出后再转回来。业务逻辑层(service/biz)只面对统一的业务枚举,持久化细节被隔离在持久层。

6.3 另一个观察

最初选 GORM 的理由只有「与 new-api 同栈」,但考察开源项目时注意到一组事实:Ory Kratos 用自研的 ory/popDex 用 ent,ZITADEL 用 pgx,Hanko 用 pop——主流项目的 ORM 各不相同,说明这一层选型更多是团队一致性问题而非技术优劣问题

另一个相关原则是数据库 schema 演进(迁移)最好与 ORM 解耦。这里的关键是控制粒度:用 golang-migrate 管理版本化的 SQL 迁移文件,可以对 DDL 做更细的控制和回滚;ent 自带的 auto_migrate 开关则让 ORM 自动推进 schema,更省事但可控性弱一些。两种方式都能走,关键是只选一种权威来源,避免混用。

7. go-kratos 生态的默认技术栈与分层

authx 进入实施阶段后,leader 指定了 go-kratos 生态。实际代码基于 go-kratos v3 的 kratos-layout 模板,做了少量裁剪。

go-kratos v3.0.0 发布于 2026 年 6 月,最大的变化是模块路径整体迁入 /v3(破坏性变更),要求 Go 1.25+;跟进的改进包括 errors 包增加标准库 errors 的包装、logging 中间件转向标准库 slog、config 增加泛型 Get、validate 支持自定义 validator 等。实际使用下来,模板结构与 v2 差别不大。

7.1 推荐的层次划分

kratos-layout 把代码拆成四层:serverservicebiz(business)、data。在解释每层职责之前,先说明这三组贯穿各层的模型:DTO、DO、PO。

DTO / DO / PO 三模型

模型全称中文说明所在层作用
DTOData Transfer Object数据传输对象service承载传输层的序列化和反序列化;proto 生成的消息即对外接口契约
DODomain Object领域对象biz表达业务层的业务逻辑;承载业务规则、权限判断、流程编排等语义
POPersistence Object持久化对象data承载数据库数据的序列化和反序列化;对应数据库表结构

一次请求的数据形态变化大致是:客户端传来 DTO → service 转成 DO → biz 处理 DO → data 把 DO 转成 PO 存进数据库;返回时反向再转回来。

四层的关系可以用下面这张图概括:

flowchart TD
    Client[客户端] -->|gRPC/HTTP 请求| Server[server
启动 gRPC/HTTP server
注册 service] Server --> Service[service
DTO ↔ DO 转换
(传输层到业务层)
填充统一响应信封] Service --> Biz[biz
领域逻辑
声明 Repo 接口] Biz --> Data[data
实现 Repo 接口
DO ↔ PO 转换
(业务层到持久化层)] Data --> DB[(数据库)]

目录结构对应如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
services/auth/
├── cmd/auth/            # main.go + wire.go + wire_gen.go,唯一的依赖注入装配点
├── configs/             # config.yaml
└── internal/
    ├── conf/            # conf.proto 定义 AppConfig,buf 生成 conf.pb.go
    ├── server/          # grpc.go / http.go,装配 kratos 的 gRPC/HTTP server 并注册 service
    ├── service/         # DTO↔DO 转换,填统一响应信封,实现 proto 生成的 Service 接口
    ├── biz/             # 业务/领域逻辑(biz):DO(纯领域对象)+ Repo 接口 + Usecase,类型化错误
    ├── data/            # 持久层(data):Repo 接口的实现,DO↔PO 转换,持有 *ent.Client
    └── pkg/             # 共享工具

四层的职责

  • server:程序的入口层。负责创建 kratos 的 gRPC server 和 HTTP server,把 service 注册进去,并挂载中间件(如 recovery、validate)。这一层只关心传输和装配,不写业务。
  • service:接口适配层。它实现 proto 生成的 Service 接口,把来自 gRPC/HTTP 的 DTO 转成 DO,调用 biz 的方法;拿到结果后再转回 DTO,填充统一响应信封。
  • biz(business):业务/领域逻辑层。这里放纯领域对象 DO、以及 Repo 接口声明。业务规则、权限判断、流程编排都在这层,它只知道 DO,不知道 proto 消息,也不知道数据库表结构。
  • data:持久层。它实现 biz 里声明的 Repo 接口,负责 DOPO 的转换,持有 *ent.Client 或数据库连接。

单向依赖与依赖注入

依赖方向只允许从外层指向内层:service 可以 import bizbiz 里声明 Repo 接口;data 实现 Repo 接口,但 service 不直接 import data,否则会跳过业务层、破坏单向依赖。具体对象的创建和注入由 google/wirecmd/auth 里统一完成:server、service、biz、data 各层暴露 ProviderSet,wire 按依赖关系生成装配代码,cmd 是整个项目唯一的依赖注入装配点。

另外,所有生成物(*.pb.gowire_gen.go、ent 产物)都不手工修改,通过代码生成重新产出。

7.2 默认技术栈的形态

能力机制说明
生命周期kratos.New(kratos.Server(gs, hs))kratos 统一管理 gRPC/HTTP server 的启动与优雅关闭
配置conf.protoAppConfig用 proto 定义配置结构,buf 生成 Go 代码,kratos config 从 yaml 加载并扫描进结构体
依赖注入google/wireserver、service、biz、data 各层暴露 ProviderSetcmd/auth 是唯一装配点
错误kratos errors提供 NotFoundBadRequest 等类型化错误,再映射到自定义信封的 retcode/retmsg
中间件recovery、validate 等按 server 维度挂载,validate 实际用的是 go.einride.tech/aip 的 field behavior 校验
proto 工具链buf v2lint 规则用 STANDARD,breaking 检测开启;配合 protoc-gen-go(生成消息)、-go-grpc(生成 gRPC 服务端/客户端)、-go-http(生成 HTTP handler)、protoc-gen-openapi(生成 OpenAPI)
工具管理Go 1.24+ tool 指令go tool bufgo tool wirego tool ent 直接调用,不再全局 go install
API 风格Google AIPresource-oriented 资源命名、page_size/page_token 分页、field behavior 标注字段语义、Protovalidate 校验规则写在 proto 里

几个术语补充

  • buf:proto 的构建工具,负责 lint、format、生成代码和 breaking change 检测,替代了传统 protoc + 一堆插件的手动管理。
  • wire:Google 的编译期依赖注入工具,通过 wire_gen.go 生成装配代码,避免运行时的反射容器。
  • ProviderSet:wire 的概念,把一组构造函数打包成一个集合,方便上层统一引用。
  • AIP(Google API Improvement Proposals):Google 的 API 设计规范,kratos 推荐按这套规范来组织资源、错误、分页和字段语义。
  • field behavior:AIP 里的概念,例如 REQUIREDOUTPUT_ONLYIMMUTABLE,用来标注字段在请求/响应中的角色,配合 validate 中间件做校验。
  • Protovalidate:buf 推出的 proto 校验规则框架,校验逻辑写在 proto 里,运行时由 Go 库执行。

实际落地时,相对于模板默认形态做了几处裁剪:业务接口只走 gRPC 暴露,HTTP server 仅保留健康检查;响应统一用信封而非 kratos 默认错误透传。

8. 工具链管理:Go 1.24+ tool 指令

Go 1.24 引入了 tool 指令,允许把构建工具直接声明在 go.mod 里。这个机制对 proto、wire、ent、lint 这类代码生成和检查工具特别合适。但在它之前,Go 项目已经有过几种工具版本化管理方案,至今仍广泛存在于各种代码库里。

8.1 几种工具版本化方案

go install ...@latest 把工具安装到全局 GOPATH/bin,然后像普通命令一样调用。@latest 不固定版本,不同环境安装到的版本可能不同。一些较早创建的服务 Makefile 里仍能看到这种写法:

1
2
3
init:
    go install github.com/google/wire/cmd/wire@latest
    go install github.com/bufbuild/buf/cmd/buf@latest

go run ...@version 不在 go.mod 里记录工具,每次调用时直接指定版本。一些服务的 buf.gen.yaml 里常见:

1
2
3
plugins:
  - local: ["go", "run", "google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11"]
    out: api

版本固定了,但调用路径长,版本号散落在各服务的 YAML 文件里。

tools.go 空 import(_ "..." Go 1.24 之前最常见的方案。在项目中放一个带 //go:build tools 标签的 tools.go,用下划线 _ 把工具包 import 进来,但不引用它的任何导出符号:

1
2
3
4
5
6
7
8
//go:build tools

package tools

import (
    _ "github.com/bufbuild/buf/cmd/buf"
    _ "github.com/google/wire/cmd/wire"
)

工具依赖进入 go.modrequire 块,普通构建不会把它们编进二进制。缺点是和业务依赖混排,go.mod 看起来嘈杂。

go tool(Go 1.24+) 把工具声明在 go.mod 独立的 tool 块里:

1
2
3
4
5
6
7
tool (
    entgo.io/ent/cmd/ent
    github.com/bufbuild/buf/cmd/buf
    github.com/golangci/golangci-lint/v2/cmd/golangci-lint
    github.com/google/wire/cmd/wire
    google.golang.org/protobuf/cmd/protoc-gen-go
)

tool 块只声明工具身份,不带版本号;版本仍由 go.modrequire 块决定,通常以 // indirect 形式出现。调用简化为 go tool <name>

1
2
3
go tool buf --version
go tool wire ./cmd/auth
go tool ent generate ./internal/data/ent/schema

改造后的 Makefile 直接调用 go tool buf 等命令,buf.gen.yaml 里也使用 local: ["go", "tool", "protoc-gen-go"]。工具身份和业务依赖被明确分开。

8.2 四种方案对比

维度go install @latestgo run @versiontools.go 空 importgo tool
版本来源无,取最新命令/YAML 中硬编码go.mod requirego.mod require
工具身份声明通过 tools.go 文件go.mod tool 块
与业务依赖混排
避免编译进二进制是(全局安装)是(临时运行)//go:build tools自动排除
调用方式全局命令go run pkg@versiongo run pkggo tool name
可复现性

这些方案的核心差异在于版本是否可控、工具身份是否独立声明。真正值得警惕的问题是生成器版本不固定,既会导致不同环境生成不同代码,也可能和运行时库版本不匹配。例如 protoc-gen-gogoogle.golang.org/protobufprotoc-gen-go-grpcgoogle.golang.org/grpc、ent 的生成器与 entgo.io/ent 之间都有这种风险;go install @latest 或散落在 YAML 里的 go run @version 很难保证所有环境使用同一组版本。tools.go 能固定版本,但工具包和业务依赖混排后不够直观。go tool 把生成器和运行时库放在同一份 go.mod 下管理,不同开发者、CI、不同服务之间更容易得到一致性、可复现的构建。

9. 结语

做方案选型和设计时,主要是聚焦在这几个方面:把同领域主流方案尽量看全,尊重团队已有的技术栈,按真实需求务实落地,以及不断提升个人调研与思考的深度和素养。具体地说,Connect-RPC、gRPC-Gateway、gin/chi/echo/kratos、ent、GORM、各 OpenAPI 生成器都纳入比较,是为了避免只在熟悉选项里打转;不抛开已经跑起来的 new-api/FastAPI 系统去追求所谓更先进的技术,是因为那并不务实;api-server 的网关角色、authx 的认证鉴权角色、前端契约必须是 OpenAPI v3,这些约束直接决定结论;而从倾向 Connect-RPC 转向 gRPC-Gateway、从 GORM 切换到 ent 的过程,也想尽量坦诚地记录下来。

另一层感受是,这份结论只走到了设计方案层面,错过了在实现阶段继续深化的机会。纸面论证和实际落地之间,往往隔着只有实践才能发现的偏差。如果工作节奏注定要把大量时间花在方案设计上,那至少要把这一阶段的调研做扎实——对比维度、边界条件、落地风险都想清楚。方案设计本身也可以是一种可交付的、有价值的工作,关键是不能让它沦为浅尝辄止的草稿。