<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>protobuf on Chaney&#39;s MoonBook</title>
    <link>https://chaneyzorn.github.io/tags/protobuf/</link>
    <description>Recent content in protobuf on Chaney&#39;s MoonBook</description>
    <follow_challenge>
        <feedId>94402801171278848</feedId>
        <userId>60932538427591680</userId>
    </follow_challenge>
    <image>
      <title>Chaney&#39;s MoonBook</title>
      <url>https://chaneyzorn.github.io/cm/chaney-cover.jpg</url>
      <link>https://chaneyzorn.github.io/cm/chaney-cover.jpg</link>
    </image>
    <generator>Hugo(0.160.1) -- gohugo.io</generator>
    <language>zh</language>
    <managingEditor>chaneyzorn#gmail#com (ChaneyZorn)</managingEditor>
    <webMaster>chaneyzorn#gmail#com (ChaneyZorn)</webMaster>
    <copyright>Copyright © ChaneyZorn | CC BY-NC-ND 4.0 |</copyright>
    <lastBuildDate>Mon, 10 Aug 2026 20:10:00 +0800</lastBuildDate>
    <atom:link href="https://chaneyzorn.github.io/tags/protobuf/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Go 微服务选型杂谈</title>
      <link>https://chaneyzorn.github.io/codes/go-microservice-notes/</link>
      <pubDate>Mon, 10 Aug 2026 20:10:00 +0800</pubDate><author>chaneyzorn#gmail#com (ChaneyZorn)</author>
      <guid>https://chaneyzorn.github.io/codes/go-microservice-notes/</guid>
      <description><![CDATA[<p>过去几周我在公司的两个项目上做了些调研和初步开发：一个是统一认证鉴权服务（authx），另一个是管理面 API 网关（api-server）。虽然这部分工作目前暂停了，但调研过程中做的几组技术横向对比值得记录下来：</p>
<ul>
<li>gRPC 生态对比：Connect-RPC vs gRPC（和 gRPC-Gateway）</li>
<li>OpenAPI 的几个 proto 生成器对比
<ul>
<li>gRPC-Gateway OpenAPI 注解与 Protovalidate 注解的分工</li>
</ul>
</li>
<li>状态码风格对比：HTTP/gRPC status code vs 自定义信封</li>
<li>session token 形态对比：JWT vs 随机字符串（opaque token）</li>
<li>web 层框架对比：<a href="https://github.com/gin-gonic/gin">gin</a> / <a href="https://github.com/go-chi/chi">chi</a> / <a href="https://github.com/labstack/echo">echo</a> / <a href="https://github.com/go-kratos/kratos">kratos</a></li>
<li>ORM 框架对比：ent vs GORM</li>
<li>go-kratos 生态的默认技术栈与推荐的 service 层次划分</li>
<li>工具链版本管理：<code>go install</code> / <code>go run @version</code> / <code>tools.go</code> vs Go 1.24+ <code>go tool</code></li>
</ul>
<p>authx 的方案做得更早，后来引入 api-server 作为管理面的统一 HTTP 入口，由它代理所有管理面请求，与 authx 等下游服务之间主要走 gRPC。authx 的几个决策也据此重新评估。下文有些对比来自实际编码体验（ent、kratos 分层），有些停留在调研结论（OpenAPI 生成器、Connect-RPC 的大部分论据）。</p>
<p>团队此前已有两套系统在运行：一套基于开源 <a href="https://github.com/QuantumNous/new-api">new-api</a> 项目，负责推理请求的路由与计费，是 Go + Gin + GORM 技术栈；另一套是自研的推理服务部署项目，负责拉起推理服务、承接请求，用的是 Python + FastAPI。在做方案时我也仔细考量了这些已有项目所使用的技术栈。</p>
<h2 id="1-connect-rpc-vs-grpc">1. Connect-RPC vs gRPC</h2>
<h3 id="11-两套方案">1.1 两套方案</h3>
<ul>
<li><strong>gRPC + <a href="https://github.com/grpc-ecosystem/grpc-gateway">gRPC-Gateway</a></strong>：gRPC-Go 做 RPC，gRPC-Gateway 依据 proto 里的 <code>google.api.http</code> 注解生成 HTTP/JSON → gRPC 的转码代理，HTTP 路径、参数映射、请求体绑定全部在注解里显式声明。</li>
<li><strong>Connect-RPC（Go 实现为 <a href="https://github.com/connectrpc/connect-go">connect-go</a>）</strong>：bufbuild 开发的 RPC 框架，生成的 handler 就是一个标准的 <code>net/http</code> handler，同一份服务实现同时支持 Connect、gRPC、gRPC-Web 三种协议，浏览器可直接调用，不需要额外代理。但 HTTP 路径是约定式的 <code>POST /{package}.{Service}/{Method}</code>，不支持自定义。</li>
</ul>
<p>两者对 HTTP 路径的控制粒度，用同一个 <code>SessionService</code> 对比如下。gRPC-Gateway 一侧，HTTP 语义在 proto 里显式声明：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-proto" data-lang="proto"><span class="line"><span class="cl"><span class="kd">service</span> <span class="n">SessionService</span> <span class="p">{</span><span class="err">
</span></span></span><span class="line"><span class="cl">  <span class="k">rpc</span> <span class="n">GetSession</span><span class="p">(</span><span class="n">GetSessionRequest</span><span class="p">)</span> <span class="k">returns</span> <span class="p">(</span><span class="n">GetSessionResponse</span><span class="p">)</span> <span class="p">{</span><span class="err">
</span></span></span><span class="line"><span class="cl">    <span class="k">option</span> <span class="p">(</span><span class="n">google.api.http</span><span class="p">)</span> <span class="o">=</span> <span class="p">{</span> <span class="n">get</span><span class="o">:</span> <span class="s">&#34;/v1/sessions/{id}&#34;</span> <span class="p">};</span><span class="err">
</span></span></span><span class="line"><span class="cl">  <span class="p">}</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl">  <span class="k">rpc</span> <span class="n">Login</span><span class="p">(</span><span class="n">LoginRequest</span><span class="p">)</span> <span class="k">returns</span> <span class="p">(</span><span class="n">LoginResponse</span><span class="p">)</span> <span class="p">{</span><span class="err">
</span></span></span><span class="line"><span class="cl">    <span class="k">option</span> <span class="p">(</span><span class="n">google.api.http</span><span class="p">)</span> <span class="o">=</span> <span class="p">{</span><span class="err">
</span></span></span><span class="line"><span class="cl">      <span class="n">post</span><span class="o">:</span> <span class="s">&#34;/v1/sessions:login&#34;</span><span class="err">
</span></span></span><span class="line"><span class="cl">      <span class="n">body</span><span class="o">:</span> <span class="s">&#34;*&#34;</span><span class="err">
</span></span></span><span class="line"><span class="cl">    <span class="p">};</span><span class="err">
</span></span></span><span class="line"><span class="cl">  <span class="p">}</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="err">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>HTTP 方法、路径风格、路径参数与请求字段的绑定（<code>{id}</code>）、请求体映射（<code>body</code>）都可以逐项配置，还可以用 <code>additional_bindings</code> 给同一个 RPC 加挂旧路径做兼容。Connect-RPC 一侧没有这些配置项，同样的两个 RPC 对外只有约定式路径：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">POST /auth.v1.SessionService/GetSession
</span></span><span class="line"><span class="cl">POST /auth.v1.SessionService/Login
</span></span></code></pre></td></tr></table>
</div>
</div><p>方法恒为 POST，路径由「包名.服务名/方法名」推导，请求体恒为整个请求消息。前者是 REST 风格的资源路径，后者是 RPC 风格的调用路径——这个差异决定了它们各自适合的场景。</p>
<h3 id="12-网关场景">1.2 网关场景</h3>
<p>api-server 是平台管理面 HTTP/JSON 到内部 gRPC 的统一入口，核心诉求之一是<strong>集中、精细地控制公开 HTTP 契约</strong>。对照这个诉求看 Connect-RPC，有几处不匹配：</p>
<ol>
<li><strong>暴露控制粒度不够</strong>：需要按 <code>google.api.http</code> 注解和 gateway 生成输入集精细控制哪些 RPC 暴露为 HTTP，Connect-RPC 无法做到。</li>
<li><strong>HTTP 路径与参数映射不可定制</strong>：Connect-RPC 的路径是约定式的，无法像 <code>google.api.http</code> 那样显式声明 method/path/body，也做不了 <code>additional_bindings</code>（旧路径兼容）。</li>
<li><strong>生态成熟度</strong>：Connect-RPC 生态里做转码的 <a href="https://github.com/connectrpc/vanguard-go"><code>vanguard-go</code></a> 仍处于试验阶段。</li>
<li><strong>协议数量</strong>：已有 HTTP/JSON + gRPC 两条协议，不值得再引入第三种协议和新的网关运行时。</li>
</ol>
<p>基于此，倾向于不引入 Connect-RPC，HTTP/JSON 到 gRPC 的转码交给注解驱动的 gRPC-Gateway。</p>
<h3 id="13-独立服务场景">1.3 独立服务场景</h3>
<p>authx 是独立的认证鉴权服务，处境和网关不同：</p>
<ul>
<li>服务自身是 proto-first 的，希望 HTTP/JSON 与 gRPC 双传输一站解决，不想再维护一套网关；</li>
<li>考察同为认证鉴权系统的开源实现时，<a href="https://github.com/zitadel/zitadel">ZITADEL</a> 的技术栈与目标非常接近（Go + Connect-RPC + buf）；</li>
<li>authx 需要自己暴露少量 HTTP-only 端点：OAuth/OIDC 的 authorize/callback 302 跳转、登录态的 <code>Set-Cookie</code>、JWKS 与 well-known 发现端点，这些端点用 Web 框架承载、与 RPC handler 共用同一监听端口即可。</li>
</ul>
<p>基于此，当时选了 connect-go v1.20：生成的 handler 就是标准的 <code>net/http</code> handler，经 <code>gin.WrapH</code> 挂进 Web 框架，与这些 HTTP-only 端点共用端口，实现起来比较直接。</p>
<h3 id="14-方案演进与小结">1.4 方案演进与小结</h3>
<p>api-server 引入之后，1.3 的前提变了。所有管理面 HTTP 入口收敛到网关，原本规划由 authx 直接承担的 HTTP-only 端点（OIDC callback、<code>Set-Cookie</code>、JWKS）都可以委托给 api-server，authx 只在 gRPC 层提供底层能力：签发和校验 token、管理 session、做权限决策。</p>
<p>connect-go 的 HTTP 能力因此不再被直接使用，继续用它反而多维护一层概念，不如换 <a href="https://github.com/grpc/grpc-go">grpc-go</a> 与下游服务保持一致。这也是 go-kratos 实施时 authx 的实际形态：proto 只生成 <code>*_grpc.pb.go</code>，HTTP server 仅保留健康检查等最基础的非业务端点。</p>
<p>所以两个项目在方案演进后收敛为同一形态：<strong>网关用 gRPC-Gateway，下游服务用 grpc-go</strong>。需要把 HTTP 路径当公共契约精细管理时，注解驱动的 gRPC-Gateway 更合适；服务自身 proto-first、且 HTTP 入口已被网关收敛时，直接走 grpc-go 更轻量。</p>
<h2 id="2-openapi-的-proto-生成器对比">2. OpenAPI 的 proto 生成器对比</h2>
<p>OpenAPI 这块的选型要从 HTTP 映射的管理方式说起。api-server 最初的设想是完全建立在 gRPC-Gateway 的外置 YAML（<code>grpc_api_configuration</code>）上，把所有 HTTP 路径映射集中在一处管理，而不是用 <code>google.api.http</code> 注解侵入式地分散在各个 proto 里——gRPC-Gateway + <code>protoc-gen-openapiv2</code> 这一套都能与外置 YAML 配合，看起来比较自然。</p>
<p>两种方式表达的是同一份映射。外置 YAML 把它们集中在网关侧的一个文件里，按 RPC 全名（<code>selector</code>）逐个指定：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span><span class="lnt">6
</span><span class="lnt">7
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">http</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">rules</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">selector</span><span class="p">:</span><span class="w"> </span><span class="l">auth.v1.SessionService.GetSession</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">get</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;/v1/sessions/{id}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">selector</span><span class="p">:</span><span class="w"> </span><span class="l">auth.v1.SessionService.Login</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">post</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;/v1/sessions:login&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">body</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;*&#34;</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>内嵌注解则把同样的内容分散写进各个 proto 的 RPC 定义上（写法见 1.1 的例子）。前者的吸引力在于 HTTP 层完全由网关侧控制，不需要下游 proto 配合改动。</p>
<p>深入考察后发现两个问题。一是 proto 生态里的众多生成器——包括 kratos 的 <code>protoc-gen-go-http</code> 和各 OpenAPI 生成器——都只认 proto 内嵌的 <code>google.api.http</code> 注解，外置 YAML 的支持面比想象中窄，连 gRPC-Gateway 自家的 <code>protoc-gen-openapiv3</code> 目前也尚未支持外置 YAML。二是前端契约需要 OpenAPI v3，而 openapiv2 只出 Swagger 2.0，既然生成器要换，也就没必要锁定在 gRPC-Gateway 自家的这一套上。</p>
<p>于是 HTTP 映射改为写进 proto 注解——同一份 proto 可以同时作为 gRPC-Gateway、kratos 和 OpenAPI 生成器的输入；OpenAPI v3（3.0.x）生成器也随之重新选型，不再使用「<code>protoc-gen-openapiv2</code> 生成 Swagger 2.0、再用 <a href="https://github.com/getkin/kin-openapi">kin-openapi</a> 转成 v3」的老流程。一共考察了四个候选，分别来自 <code>grpc-gateway</code>、<code>google/gnostic</code> 和 <code>protoc-gen-connect-openapi</code> 三个项目：</p>
<table>
  <thead>
      <tr>
          <th>生成器</th>
          <th>输出版本</th>
          <th>成熟度</th>
          <th><a href="https://github.com/bufbuild/protovalidate">Protovalidate</a> 映射</th>
          <th>其他</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>grpc-gateway <code>protoc-gen-openapiv2</code></td>
          <td>OpenAPI 2.0（Swagger）</td>
          <td>成熟，生产环境广泛使用</td>
          <td>不识别 protovalidate 注解</td>
          <td>自带 <code>openapiv2_field</code>/<code>openapiv2_schema</code> 注解，可手写约束进文档，但同一套信息后端复用不了；要得到 v3 需 kin-openapi 二次转换</td>
      </tr>
      <tr>
          <td>grpc-gateway <code>protoc-gen-openapiv3</code></td>
          <td>OpenAPI 3.x</td>
          <td>较新，仍标注 experimental</td>
          <td>不识别 protovalidate 注解</td>
          <td>功能覆盖和打磨程度尚不如 v2</td>
      </tr>
      <tr>
          <td>google/gnostic <a href="https://github.com/google/gnostic"><code>protoc-gen-openapi</code></a> v0.7.1</td>
          <td>OpenAPI 3.0.x</td>
          <td>较成熟</td>
          <td>不识别 protovalidate 注解，约束字段要后处理补</td>
          <td><code>strategy: all</code> 生成单文件</td>
      </tr>
      <tr>
          <td><a href="https://github.com/sudorandom/protoc-gen-connect-openapi"><code>protoc-gen-connect-openapi</code></a> v0.25.7</td>
          <td>默认 3.1.0，需降版本或接受 3.1</td>
          <td>活跃但较新</td>
          <td>识别并支持一部分 protovalidate 注解，<code>required</code>/<code>min/max</code>/<code>pattern</code>/<code>enum</code> 等直接进 schema</td>
          <td>默认生成 Connect 协议内容，需从 <code>features</code> 中去掉 <code>connectrpc</code>；<code>trim-unused-types</code> 按方法引用裁剪，未被引用的 message 仍可能进入文档</td>
      </tr>
  </tbody>
</table>
<p>gRPC-Gateway 的两个生成器位置很典型：openapiv2 成熟但只出 Swagger 2.0，openapiv3 能出 v3 但还不成熟——「能出 v3」和「敢用在生产」之间隔着一段距离，这也是进一步考察 gnostic 和 connect-openapi 的原因。</p>
<p>connect-openapi 用 <code>features</code> 选项控制启用哪几套注解体系，一共有五个可选项：</p>
<ul>
<li><code>connectrpc</code>：Connect RPC 的 HTTP 路径</li>
<li><code>google.api.http</code>：gRPC-Gateway 风格注解</li>
<li><code>twirp</code>：启用 <a href="https://twitchtv.github.io/twirp/docs/intro.html">Twirp</a> 服务路径生成</li>
<li><code>gnostic</code>：gnostic 项目的 OpenAPI v3 注解</li>
<li><code>protovalidate</code></li>
</ul>
<p>默认启用除 <code>twirp</code> 外的四个，而一旦显式设置就只启用列出的项——所以要让输出不含 Connect 协议内容，实际写法是 <code>features=google.api.http;gnostic;protovalidate</code>。</p>
<p>connect-openapi 的 <code>trim-unused-types</code> 裁剪也不够彻底：它按方法请求/响应引用来决定 schema 是否保留，但即使显式从 <code>features</code> 中去掉 <code>connectrpc</code>、只生成 <code>google.api.http</code> 注解的 HTTP 路径，那些未使用 <code>google.api.http</code> 注解的 RPC 所引用的 schema 也不会被裁掉。如果不考虑这一点，connect-openapi 是四个候选里最值得考虑的一个：唯一内置 Protovalidate 映射，同时认 gRPC-Gateway 和 gnostic 两套注解，覆盖面最全。</p>
<p>这个选型最终没有定下来，目前生成流程中使用的是 gnostic 的 <code>protoc-gen-openapi</code>，Protovalidate 约束字段由后处理补。</p>
<h3 id="21-文档注解与校验注解的分工">2.1 文档注解与校验注解的分工</h3>
<p>同一个字段上，两套注解可以并存：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span><span class="lnt">13
</span><span class="lnt">14
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-proto" data-lang="proto"><span class="line"><span class="cl"><span class="kt">string</span> <span class="n">username</span> <span class="o">=</span> <span class="mi">1</span> <span class="p">[</span><span class="err">
</span></span></span><span class="line"><span class="cl">  <span class="c1">// grpc-gateway 的 OpenAPI 注解：面向文档
</span></span></span><span class="line"><span class="cl">  <span class="p">(</span><span class="n">grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field</span><span class="p">)</span> <span class="o">=</span> <span class="p">{</span><span class="err">
</span></span></span><span class="line"><span class="cl">    <span class="n">min_length</span><span class="o">:</span> <span class="mi">1</span><span class="err">
</span></span></span><span class="line"><span class="cl">    <span class="n">max_length</span><span class="o">:</span> <span class="mi">64</span><span class="err">
</span></span></span><span class="line"><span class="cl">    <span class="n">pattern</span><span class="o">:</span> <span class="s">&#34;^[a-zA-Z0-9_-]+$&#34;</span><span class="err">
</span></span></span><span class="line"><span class="cl">  <span class="p">},</span><span class="err">
</span></span></span><span class="line"><span class="cl">  <span class="c1">// Protovalidate 注解：面向运行时校验
</span></span></span><span class="line"><span class="cl">  <span class="p">(</span><span class="n">buf.validate.field</span><span class="p">)</span><span class="o">.</span><span class="kt">string</span> <span class="o">=</span> <span class="p">{</span><span class="err">
</span></span></span><span class="line"><span class="cl">    <span class="n">min_len</span><span class="o">:</span> <span class="mi">1</span><span class="err">
</span></span></span><span class="line"><span class="cl">    <span class="n">max_len</span><span class="o">:</span> <span class="mi">64</span><span class="err">
</span></span></span><span class="line"><span class="cl">    <span class="n">pattern</span><span class="o">:</span> <span class="s">&#34;^[a-zA-Z0-9_-]+$&#34;</span><span class="err">
</span></span></span><span class="line"><span class="cl">  <span class="p">}</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="p">];</span><span class="err">
</span></span></span></code></pre></td></tr></table>
</div>
</div><table>
  <thead>
      <tr>
          <th></th>
          <th>gRPC-Gateway OpenAPI 注解</th>
          <th>Protovalidate 注解</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>写法</td>
          <td>直接写 JSON Schema 关键字（<code>min_length</code>、<code>pattern</code>），面向文档描述</td>
          <td>按字段类型组织规则（<code>string.min_len</code>、<code>enum.defined_only</code>），面向校验语义</td>
      </tr>
      <tr>
          <td>生效范围</td>
          <td>只进 OpenAPI 文档，给前端看</td>
          <td>后端运行时校验（gateway 拦截器、服务内执行）</td>
      </tr>
      <tr>
          <td>另一侧是否可见</td>
          <td>后端完全读不到</td>
          <td>文档默认读不到，除非生成器内置映射或后处理补充</td>
      </tr>
  </tbody>
</table>
<p>工程规则上，同一条校验信息没必要在 proto 里写两遍：后端用 <strong><a href="https://github.com/bufbuild/protovalidate">Protovalidate</a></strong> 做运行时校验，OpenAPI 文档里的 <code>min</code>/<code>max</code>/<code>pattern</code>/<code>required</code> 等约束字段也从这些规则映射或后处理生成，而不是在文档侧再手写一份。具体怎么映射，取决于生成器选型——connect-openapi 能直接生成，gnostic 和 gRPC-Gateway 则需要后处理阶段补充。</p>
<h2 id="3-httpgrpc-的错误与响应信封">3. HTTP/gRPC 的错误与响应信封</h2>
<p>响应和错误怎么包装，是这次设计里比较特别的一项。HTTP 和 gRPC 两侧各有主流约定，最终采用的方案与两边都不完全一样。</p>
<h3 id="31-三个生态的主流做法">3.1 三个生态的主流做法</h3>
<p><strong>gRPC</strong>：用一组固定的 status code（<code>OK</code>、<code>INVALID_ARGUMENT</code>、<code>NOT_FOUND</code>、<code>UNAUTHENTICATED</code> 等约 17 个）表达调用结果，错误详情走 <code>google.rpc.Status</code>（AIP-193）：<code>code</code> + <code>message</code> + <code>details</code>，<code>details</code> 是可扩展的结构化错误明细。gRPC-Gateway 默认把 status code 映射到对应的 HTTP 状态码（如 <code>NOT_FOUND</code> → 404）。</p>
<p><strong>Connect-RPC</strong>：沿用与 gRPC 相同的 code 集合，但原生面向 HTTP/JSON，错误响应直接是 JSON。<code>code</code> 用字符串（如 <code>&quot;not_found&quot;</code>）而非数字；<code>details</code> 里的自定义错误用 base64 编码，避免客户端必须持有对应 proto 才能解析。gRPC-Gateway 的 <code>code</code> 仍是数字（如 5）。二者共同点在于<strong>传输层状态码负责错误分类，业务错误要归并到有限的 code 集合里</strong>。</p>
<p><strong>REST/OpenAPI</strong> 的主流则是「HTTP 状态码即业务结果」：2xx 成功、4xx 客户端错误、5xx 服务端错误，业务错误类别多时再在 body 里套一层 <code>code</code>/<code>message</code>。好处是基础设施（LB、WAF、CDN、APM）都能识别，前端 <code>response.ok</code> 直接可用。</p>
<p>三种做法的响应示例对比：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="c1">// gRPC-Gateway 成功响应（HTTP 200）
</span></span></span><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;sess_xxx&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;...&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// gRPC-Gateway 错误响应（HTTP 404）
</span></span></span><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;code&#34;</span><span class="p">:</span> <span class="mi">5</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;message&#34;</span><span class="p">:</span> <span class="s2">&#34;session not found&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;details&#34;</span><span class="p">:</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="c1">// Connect-RPC 成功响应（HTTP 200）
</span></span></span><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;sess_xxx&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;...&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// Connect-RPC 错误响应（HTTP 404）
</span></span></span><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;code&#34;</span><span class="p">:</span> <span class="s2">&#34;not_found&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;message&#34;</span><span class="p">:</span> <span class="s2">&#34;session not found&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;details&#34;</span><span class="p">:</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="c1">// REST/OpenAPI 成功响应（HTTP 200）
</span></span></span><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;sess_xxx&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;...&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// REST/OpenAPI 错误响应（HTTP 404）
</span></span></span><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;error_code&#34;</span><span class="p">:</span> <span class="s2">&#34;SESSION_NOT_FOUND&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;message&#34;</span><span class="p">:</span> <span class="s2">&#34;session not found&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><h3 id="32-本方案的信封形式">3.2 本方案的信封形式</h3>
<p>这次设计实际采用的是<strong>全平台统一业务信封 + HTTP 状态码恒为 200</strong>：</p>
<ul>
<li>所有 RPC 响应统一为 <code>{ retcode, retmsg, data }</code>；<code>retcode=0</code> 表示成功，非 0 为业务错误码，按 HTTP 风格分段：<code>400xx</code> 参数错误、<code>401xx</code> 认证失败、<code>403xx</code> 授权失败、<code>404xx</code> 不存在、<code>409xx</code> 冲突、<code>500xx</code>/<code>503xx</code> 内部错误/不可用；api-server 自身的转发/中间件层错误单独占用一个高位段位，与业务错误码区分开。</li>
<li>信封定义在 proto message 层：每个 <code>XxxResponse</code> 含 <code>retcode</code>/<code>retmsg</code>/<code>data</code> 字段，原业务字段下沉为 <code>XxxResponseData</code>。这样 gRPC 与 HTTP/JSON 共享同一结构，gRPC status 只保留传输语义（调用是否到达、是否 panic），不再承载业务错误。</li>
</ul>
<p>示例：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span><span class="lnt">13
</span><span class="lnt">14
</span><span class="lnt">15
</span><span class="lnt">16
</span><span class="lnt">17
</span><span class="lnt">18
</span><span class="lnt">19
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-proto" data-lang="proto"><span class="line"><span class="cl"><span class="c1">// proto 层定义的信封
</span></span></span><span class="line"><span class="cl"><span class="kd">service</span> <span class="n">SessionService</span> <span class="p">{</span><span class="err">
</span></span></span><span class="line"><span class="cl">  <span class="k">rpc</span> <span class="n">GetSession</span><span class="p">(</span><span class="n">GetSessionRequest</span><span class="p">)</span> <span class="k">returns</span> <span class="p">(</span><span class="n">GetSessionResponse</span><span class="p">);</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="kd">message</span> <span class="nc">GetSessionRequest</span> <span class="p">{</span><span class="err">
</span></span></span><span class="line"><span class="cl">  <span class="kt">string</span> <span class="n">id</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="kd">message</span> <span class="nc">GetSessionResponse</span> <span class="p">{</span><span class="err">
</span></span></span><span class="line"><span class="cl">  <span class="kt">int32</span>  <span class="n">retcode</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span><span class="err">
</span></span></span><span class="line"><span class="cl">  <span class="kt">string</span> <span class="n">retmsg</span>  <span class="o">=</span> <span class="mi">2</span><span class="p">;</span><span class="err">
</span></span></span><span class="line"><span class="cl">  <span class="n">GetSessionResponseData</span> <span class="n">data</span> <span class="o">=</span> <span class="mi">3</span><span class="p">;</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="kd">message</span> <span class="nc">GetSessionResponseData</span> <span class="p">{</span><span class="err">
</span></span></span><span class="line"><span class="cl">  <span class="kt">string</span> <span class="n">id</span>   <span class="o">=</span> <span class="mi">1</span><span class="p">;</span><span class="err">
</span></span></span><span class="line"><span class="cl">  <span class="kt">string</span> <span class="n">name</span> <span class="o">=</span> <span class="mi">2</span><span class="p">;</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="err">
</span></span></span></code></pre></td></tr></table>
</div>
</div><div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span><span class="lnt">13
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="c1">// 成功
</span></span></span><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;retcode&#34;</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;retmsg&#34;</span><span class="p">:</span> <span class="s2">&#34;ok&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;data&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;sess_xxx&#34;</span><span class="p">,</span> <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;...&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// 业务错误
</span></span></span><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;retcode&#34;</span><span class="p">:</span> <span class="mi">40101</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;retmsg&#34;</span><span class="p">:</span> <span class="s2">&#34;token expired&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;data&#34;</span><span class="p">:</span> <span class="kc">null</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><h3 id="33-这样设计的好处">3.3 这样设计的好处</h3>
<ul>
<li><strong>前端契约稳定</strong>：调用方只看 <code>retcode</code>，不需要同时理解 HTTP 状态码和 connect/gRPC 错误体两套约定。</li>
<li><strong>多下游混用时不被牵制</strong>：不会因为某个下游返回 500 就让前端把整条链路当服务器故障；部分下游失败时，网关可以用自己的错误码段位表达「下游异常响应」，而不是被 gRPC status 限制。</li>
<li><strong>错误码空间可扩展</strong>：gRPC 的 17 个 code 对业务来说太粗，数字 code 的可读性也差；HTTP 风格段位兼顾了分类能力和可读性。</li>
</ul>
<h3 id="34-代价同样明显">3.4 代价同样明显</h3>
<ul>
<li><strong>HTTP 状态码失去区分能力</strong>：负载均衡、缓存、WAF、APM 无法通过状态码区分成功与失败；如果未来对外开放或接入第三方 SDK，「200 包错误」会让标准客户端困惑。</li>
<li><strong>可观测性需要额外建设</strong>：access log 只记 <code>http_status=200</code> 的话，SRE 会以为一切正常。需要把 <code>retcode</code>（和 RPC 方法标识）作为 access log、trace、metrics 的一级字段，对 <code>retcode != 0</code> 的 span 标记 error，告警按「方法 + retcode」配置而不是只看 5xx。</li>
</ul>
<p>几个前提同时成立时，这个方案才成立：所有下游统一了信封格式、网关保持轻量（不深入业务语义）、前端是内部团队，可以接受「看 retcode 不看 status」的约定。前提一旦变化（比如开放给外部开发者），矛盾会首先集中在 HTTP 语义缺失这一侧。</p>
<h2 id="4-session-token-的形态jwt-vs-随机字符串">4. Session Token 的形态：JWT vs 随机字符串</h2>
<p>authx 设计阶段还有一个需要确定的问题：session token 用什么形态。AuthX 本身是有后端状态的（<code>sessions</code> 表 + Redis），凭证校验又被要求必须实时回源——每次校验都要确认 token 有效、未被吊销。JWT 最大的卖点是「自包含、免回源」，但在这个前提下发挥不出来：验证 access_token 时仍然要查后端状态。</p>
<p>基于这一点，两种方案自身的特性值得考量：</p>
<ul>
<li>
<p><strong>随机字符串（opaque token）</strong>：token 本身没有语义，只是一个标识符。校验必须回源，但回源本来就是必须的；好处是吊销即时生效，没有传播延迟，实现也最简单。Session Cookie、API Key（<code>sk-xxx</code>）都属于这一类。</p>
</li>
<li>
<p><strong>自包含 JWT</strong>：把 <code>user_id</code>、<code>tenant_id</code>、<code>role_ids</code> 等声明直接写进 token。短 TTL（如 5-15 分钟）内可以减少回源次数；代价是吊销只能依赖黑名单或等待过期。「自包含」和「可即时吊销」天然矛盾。</p>
<p>一个 JWT 由 <code>header.payload.signature</code> 三部分组成，每部分都是 Base64URL 编码，用 <code>.</code> 连接。例如：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwidXNlcl9pZCI6InVzcl8xMjMiLCJ0ZW5hbnRfaWQiOiJ0bnRfYWJjIiwicm9sZV9pZHMiOlsiYWRtaW4iXSwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyMzkwODJ9.SflKxwRJSMeKKF2QT4fwpMe...
</span></span></code></pre></td></tr></table>
</div>
</div><p>解码后的 payload 大致是：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span><span class="lnt">6
</span><span class="lnt">7
</span><span class="lnt">8
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;sub&#34;</span><span class="p">:</span> <span class="s2">&#34;1234567890&#34;</span><span class="p">,</span>        <span class="c1">// subject：token 归属的主体标识
</span></span></span><span class="line"><span class="cl">  <span class="nt">&#34;user_id&#34;</span><span class="p">:</span> <span class="s2">&#34;usr_123&#34;</span><span class="p">,</span>       <span class="c1">// 业务侧用户 ID
</span></span></span><span class="line"><span class="cl">  <span class="nt">&#34;tenant_id&#34;</span><span class="p">:</span> <span class="s2">&#34;tnt_abc&#34;</span><span class="p">,</span>     <span class="c1">// 业务侧租户 ID
</span></span></span><span class="line"><span class="cl">  <span class="nt">&#34;role_ids&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;admin&#34;</span><span class="p">],</span>      <span class="c1">// 角色列表
</span></span></span><span class="line"><span class="cl">  <span class="nt">&#34;iat&#34;</span><span class="p">:</span> <span class="mi">1516239022</span><span class="p">,</span>          <span class="c1">// issued at：签发时间戳
</span></span></span><span class="line"><span class="cl">  <span class="nt">&#34;exp&#34;</span><span class="p">:</span> <span class="mi">1516239082</span>           <span class="c1">// expiration：过期时间戳
</span></span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div></li>
</ul>
<p>按场景二选一比较合理。如果后端本来就要回源，opaque token 更直接；如果希望减少回源、能接受延迟吊销，JWT 更合适。</p>
<h3 id="41-业内的常见做法">4.1 业内的常见做法</h3>
<p>主流身份认证服务很少走纯 JWT 或纯 opaque 的极端，普遍采用「JWT access token / ID token + opaque refresh token」的混合形态：access token 短 TTL，资源服务端可以本地验证；refresh token 不透明，由授权服务端统一管理和吊销。</p>
<table>
  <thead>
      <tr>
          <th>服务</th>
          <th>access token / ID token</th>
          <th>refresh token</th>
          <th>核心策略</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="https://auth0.com/">Auth0</a></td>
          <td>JWT 或 opaque</td>
          <td>opaque</td>
          <td>按 audience 决定 access token 格式</td>
      </tr>
      <tr>
          <td><a href="https://supabase.com/">Supabase Auth</a></td>
          <td>JWT</td>
          <td>opaque</td>
          <td>access token 默认 1 小时 TTL</td>
      </tr>
      <tr>
          <td><a href="https://firebase.google.com/">Firebase Auth</a></td>
          <td>ID token 为 JWT</td>
          <td>opaque</td>
          <td>通过 refresh token 换取新 ID token</td>
      </tr>
      <tr>
          <td><a href="https://aws.amazon.com/cognito/">AWS Cognito</a></td>
          <td>JWT</td>
          <td>opaque</td>
          <td>access token 默认 1 小时 TTL，支持撤销</td>
      </tr>
      <tr>
          <td><a href="https://github.com/zitadel/zitadel">ZITADEL</a></td>
          <td>JWT 或 opaque</td>
          <td>opaque</td>
          <td>提供 introspection / revocation 端点</td>
      </tr>
      <tr>
          <td><a href="https://github.com/ory/hydra">Ory Hydra</a></td>
          <td>JWT 或 opaque</td>
          <td>opaque</td>
          <td>refresh token 必须保证即时吊销</td>
      </tr>
  </tbody>
</table>
<p>这些服务的共同点是：把<strong>短 TTL 的 JWT 用于访问凭证</strong>，把<strong>opaque token 用于刷新凭证</strong>。混合形态本身并非不可行，关键在于别把两边的缺点也一起拿过来。JWT 省掉回源，这套方案才有意义；一旦 access token 每次校验仍要回源查库，它的优势就被抵消，反而要多维护一套黑名单或 token 唯一标识（jti）。</p>
<h3 id="42-当前项目的现状">4.2 当前项目的现状</h3>
<p>本项目最终采用的是 JWT access token + JWT refresh token，配合 Redis 黑名单支持强制下线。连 refresh token 也是 JWT，意味着它的吊销同样只能依赖黑名单或等待过期——这在行业里并不常见，多半是设计阶段只关注了统一技术形态，没有把 refresh token 的格式和行业惯例细致对齐；现在回头看，算是一处疏漏。</p>
<p>既然每次校验都要回源确认，access token 的本地验证优势本就不存在。从设计角度看，这种情况下更倾向于使用 opaque token：实现更直接，吊销也简单。</p>
<h2 id="5-gin--chi--echo--kratos">5. gin / chi / echo / kratos</h2>
<p>在 proto-first 架构下，HTTP 框架不再承载业务语义，只是生成 handler 或网关路由的运行载体。选型标准因此也和传统 Web 项目不同。</p>
<h3 id="51-chiapi-server-的最外层路由">5.1 chi：api-server 的最外层路由</h3>
<p>api-server 需要的是一个能和 gRPC-Gateway 无缝配合的路由层。选 <a href="https://github.com/go-chi/chi">chi</a>，关键原因是它只聚焦「路由 + 中间件」，不引入额外的请求模型：</p>
<ul>
<li>chi 基于标准 <code>net/http</code>，中间件签名是 <code>func(http.Handler) http.Handler</code>，可以直接挂载 gRPC-Gateway 生成的 <code>http.Handler</code>；</li>
<li><a href="https://github.com/gin-gonic/gin">Gin</a> 是一整套 Web 框架，有自己的上下文、绑定、渲染、验证和错误处理模型，会与 gRPC-Gateway 的 <code>runtime.ServeMux</code> 以及统一信封模型形成<strong>两套语义</strong>；</li>
<li>这个项目不需要 Gin 的模板、表单绑定、验证等能力，chi 的路由能力足够覆盖 gateway、health、OpenAPI、Custom Handler 几个入口。</li>
</ul>
<p>这样业务语义全在 gRPC/信封一侧，HTTP 层只保留 <code>net/http</code> 外层路由即可，不需要维护第二套请求和错误模型。</p>
<h3 id="52-gin在-authx-里被收窄到-http-only-端点">5.2 Gin：在 authx 里被收窄到 HTTP-only 端点</h3>
<p>authx 对 Web 框架的需求比网关更窄。原设计里选了 <a href="https://github.com/gin-gonic/gin">Gin</a>，理由同样是「与 new-api 同栈」，但职责被刻意收窄：只承载 OAuth callback、JWKS、well-known 这几个 HTTP-only 端点的路由和中间件（CORS、Recovery、访问日志），connect handler 用 <code>gin.WrapH</code> 挂进来共用端口。这些端点后来都委托给了 api-server，Gin 在 authx 里也就失去了存在必要。</p>
<h3 id="53-echo-为什么没成为候选">5.3 echo 为什么没成为候选</h3>
<p><a href="https://github.com/labstack/echo">echo</a> 在两个项目里都没有成为候选，只在考察开源实现时遇到过（<a href="https://github.com/teamhanko/hanko">Hanko</a> 用 <code>labstack/echo/v4</code>）。echo 比 Gin 内置能力更多，但也更厚重；而 Gin 在国内更流行，且已有项目已经采用 Gin。在 proto-first 架构下，echo 没有额外优势。</p>
<h3 id="54-kratos-protoc-gen-go-http不是同一个维度">5.4 kratos protoc-gen-go-http：不是同一个维度</h3>
<p>kratos 自带的 <code>protoc-gen-go-http</code> 属于另一个类别：它不是候选框架，而是 HTTP 代码生成器。它从 <code>google.api.http</code> 注解生成 HTTP handler，把 HTTP query/path/body 绑定到 proto 请求消息，调用 service 接口方法，再把 proto 响应写回 HTTP/JSON。这样 HTTP server 和 gRPC server 可以跑在同一个服务里，共享同一份 service 实现。</p>
<p>它和 gRPC-Gateway、Connect-RPC 都在做「HTTP/JSON → gRPC」，但定位不同：</p>
<ul>
<li><strong>gRPC-Gateway</strong> 是独立网关，通常单独部署，适合管理面网关这类需要集中控制的场景；</li>
<li><strong>Connect-RPC</strong> 是协议框架，路径约定式，同时支持 Connect/gRPC/gRPC-Web；</li>
<li><strong>kratos <code>protoc-gen-go-http</code></strong> 是进程内代码生成，按 <code>google.api.http</code> 注解把已有 gRPC 服务再暴露一层 HTTP，服务仍然是 gRPC 优先。</li>
</ul>
<p>所以 api-server 作为管理面网关选 gRPC-Gateway；独立服务需要多协议暴露时 Connect-RPC 更轻量；kratos 服务内部需要 HTTP 时，<code>protoc-gen-go-http</code> 就能满足需求。</p>
<table>
  <thead>
      <tr>
          <th></th>
          <th>gRPC-Gateway</th>
          <th>Connect-RPC</th>
          <th>kratos <code>protoc-gen-go-http</code></th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>定位</td>
          <td>独立 HTTP/JSON → gRPC 网关</td>
          <td>多协议 RPC 框架</td>
          <td>kratos 进程内 HTTP 代码生成</td>
      </tr>
      <tr>
          <td>部署</td>
          <td>单独进程/服务</td>
          <td>与业务服务同进程</td>
          <td>与业务服务同进程</td>
      </tr>
      <tr>
          <td>HTTP 路径</td>
          <td><code>google.api.http</code> 注解</td>
          <td>约定式 <code>/{pkg}.{Service}/{Method}</code></td>
          <td><code>google.api.http</code> 注解</td>
      </tr>
      <tr>
          <td>协议支持</td>
          <td>HTTP/JSON ↔ gRPC</td>
          <td>Connect/gRPC/gRPC-Web</td>
          <td>HTTP/JSON ↔ gRPC</td>
      </tr>
      <tr>
          <td>主要场景</td>
          <td>管理面网关、公共 HTTP 契约</td>
          <td>独立服务多协议暴露</td>
          <td>kratos 服务内部暴露 HTTP</td>
      </tr>
  </tbody>
</table>
<h2 id="6-ent-vs-gorm">6. ent vs GORM</h2>
<p>最初选择的 ORM 框架并不是 <a href="https://entgo.io">ent</a>。方案设计阶段选择了 <a href="https://github.com/go-gorm/gorm">GORM</a> v2，理由是向 <a href="https://github.com/QuantumNous/new-api">new-api</a> 的技术栈看齐；进入实施阶段后，leader 指定使用 go-kratos 生态，数据层随之换成了生态内常见的 ent。这次切换也是一次对 go-kratos 生态设计的实际体验。</p>
<h3 id="61-模型层面的差异">6.1 模型层面的差异</h3>
<table>
  <thead>
      <tr>
          <th>维度</th>
          <th>GORM</th>
          <th>ent</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>模型定义</td>
          <td>struct + tag，运行时反射</td>
          <td><code>ent/schema</code> 下用 Go 代码声明字段/索引/边（<code>ent.Edge</code>），代码生成出类型安全的 Client</td>
      </tr>
      <tr>
          <td>查询</td>
          <td>链式 API，字段名是字符串</td>
          <td>生成代码，谓词/排序/翻页全部编译期检查</td>
      </tr>
      <tr>
          <td>关联</td>
          <td><code>Preload</code> + tag 约定</td>
          <td><code>Edges()</code> 显式声明，生成 <code>QueryXxx()</code> 遍历方法</td>
      </tr>
      <tr>
          <td>迁移</td>
          <td><code>AutoMigrate</code> 能力有限，通常另配 <a href="https://github.com/golang-migrate/migrate">golang-migrate</a></td>
          <td>自带 schema migration（<code>client.Schema.Create</code>），也可配 <a href="https://github.com/ariga/atlas">Atlas</a></td>
      </tr>
      <tr>
          <td>学习成本</td>
          <td>低，约定式</td>
          <td>生成物多一层，但 IDE 体验和重构安全性好</td>
      </tr>
  </tbody>
</table>
<p>核心差异用两段代码对比更直观。GORM 用 struct tag 描述模型，查询时字段名是字符串：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span><span class="lnt">6
</span><span class="lnt">7
</span><span class="lnt">8
</span><span class="lnt">9
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="c1">// GORM</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kd">type</span><span class="w"> </span><span class="nx">User</span><span class="w"> </span><span class="kd">struct</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">ID</span><span class="w">     </span><span class="kt">uint</span><span class="w">   </span><span class="s">`gorm:&#34;primaryKey&#34;`</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">Name</span><span class="w">   </span><span class="kt">string</span><span class="w"> </span><span class="s">`gorm:&#34;size:64&#34;`</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">Status</span><span class="w"> </span><span class="kt">string</span><span class="w"> </span><span class="s">`gorm:&#34;index&#34;`</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">Tenant</span><span class="w"> </span><span class="nx">Tenant</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">db</span><span class="p">.</span><span class="nf">Where</span><span class="p">(</span><span class="s">&#34;status = ?&#34;</span><span class="p">,</span><span class="w"> </span><span class="s">&#34;active&#34;</span><span class="p">).</span><span class="nf">Find</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">users</span><span class="p">)</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>ent 用 Go 代码声明 schema，查询是谓词/边方法，编译期可检查：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span><span class="lnt">13
</span><span class="lnt">14
</span><span class="lnt">15
</span><span class="lnt">16
</span><span class="lnt">17
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="c1">// ent schema</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kd">func</span><span class="w"> </span><span class="p">(</span><span class="nx">User</span><span class="p">)</span><span class="w"> </span><span class="nf">Fields</span><span class="p">()</span><span class="w"> </span><span class="p">[]</span><span class="nx">ent</span><span class="p">.</span><span class="nx">Field</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="p">[]</span><span class="nx">ent</span><span class="p">.</span><span class="nx">Field</span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nx">field</span><span class="p">.</span><span class="nf">String</span><span class="p">(</span><span class="s">&#34;name&#34;</span><span class="p">).</span><span class="nf">MaxLen</span><span class="p">(</span><span class="mi">64</span><span class="p">),</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nx">field</span><span class="p">.</span><span class="nf">Enum</span><span class="p">(</span><span class="s">&#34;status&#34;</span><span class="p">).</span><span class="nf">Values</span><span class="p">(</span><span class="s">&#34;active&#34;</span><span class="p">,</span><span class="w"> </span><span class="s">&#34;inactive&#34;</span><span class="p">),</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kd">func</span><span class="w"> </span><span class="p">(</span><span class="nx">User</span><span class="p">)</span><span class="w"> </span><span class="nf">Edges</span><span class="p">()</span><span class="w"> </span><span class="p">[]</span><span class="nx">ent</span><span class="p">.</span><span class="nx">Edge</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="p">[]</span><span class="nx">ent</span><span class="p">.</span><span class="nx">Edge</span><span class="p">{</span><span class="nx">edge</span><span class="p">.</span><span class="nf">To</span><span class="p">(</span><span class="s">&#34;tenant&#34;</span><span class="p">,</span><span class="w"> </span><span class="nx">Tenant</span><span class="p">.</span><span class="nx">Type</span><span class="p">)}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c1">// 查询</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">client</span><span class="p">.</span><span class="nx">User</span><span class="p">.</span><span class="nf">Query</span><span class="p">().</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nf">Where</span><span class="p">(</span><span class="nx">user</span><span class="p">.</span><span class="nf">StatusEQ</span><span class="p">(</span><span class="s">&#34;active&#34;</span><span class="p">)).</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nf">WithTenant</span><span class="p">().</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nf">All</span><span class="p">(</span><span class="nx">ctx</span><span class="p">)</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>落地后 ent 的形态是：schema 下用 Go 代码声明字段和边，通过 <code>go tool ent generate</code> 生成类型安全的访问代码；data（持久）层只持 <code>*ent.Client</code>，查询和 upsert 都用生成的链式 API，没有任何 SQL 字符串。</p>
<p>就表达风格而言，GORM 胜在直观简洁，ent 则把字段、索引、关联（<code>ent.Edge</code>）、校验等语义直接写进代码，编译期即可检查。选择 ent 的一部分原因，也是认同这种「schema 即代码」的表达方式。</p>
<h3 id="62-枚举proto-的-int32-与-ent-的-string">6.2 枚举：proto 的 int32 与 ent 的 string</h3>
<p>proto 的 enum 默认生成 int32（配合 <code>iota</code> 式的常量），ent 的 <code>field.Enum</code> 默认生成 string 枚举并做运行时校验。这会造成一个断层：传输层用数字表示状态，持久层用字符串表示状态。</p>
<p>两边的默认值各有道理：proto 侧 int32 紧凑、跨语言兼容性好；ent 侧 string 可读性强，直接看数据库就能知道状态含义。问题是传输层用数字、持久层用字符串，中间必须有一层转换。</p>
<p>建议把转换收敛在持久层（data/Repo 实现）统一处理：写入数据库前把业务枚举（int32）转成 ent 的 string 枚举，读出后再转回来。业务逻辑层（service/biz）只面对统一的业务枚举，持久化细节被隔离在持久层。</p>
<h3 id="63-另一个观察">6.3 另一个观察</h3>
<p>最初选 GORM 的理由只有「与 new-api 同栈」，但考察开源项目时注意到一组事实：Ory Kratos 用自研的 <code>ory/pop</code>，<a href="https://github.com/dexidp/dex">Dex</a> 用 ent，ZITADEL 用 pgx，Hanko 用 pop——<strong>主流项目的 ORM 各不相同，说明这一层选型更多是团队一致性问题而非技术优劣问题</strong>。</p>
<p>另一个相关原则是数据库 schema 演进（迁移）最好与 ORM 解耦。这里的关键是控制粒度：用 <a href="https://github.com/golang-migrate/migrate">golang-migrate</a> 管理版本化的 SQL 迁移文件，可以对 DDL 做更细的控制和回滚；ent 自带的 <code>auto_migrate</code> 开关则让 ORM 自动推进 schema，更省事但可控性弱一些。两种方式都能走，关键是<strong>只选一种权威来源</strong>，避免混用。</p>
<h2 id="7-go-kratos-生态的默认技术栈与分层">7. go-kratos 生态的默认技术栈与分层</h2>
<p>authx 进入实施阶段后，leader 指定了 <a href="https://github.com/go-kratos/kratos">go-kratos</a> 生态。实际代码基于 go-kratos v3 的 kratos-layout 模板，做了少量裁剪。</p>
<p><a href="https://github.com/go-kratos/kratos/releases/tag/v3.0.0">go-kratos v3.0.0 发布于 2026 年 6 月</a>，最大的变化是模块路径整体迁入 <code>/v3</code>（破坏性变更），要求 Go 1.25+；跟进的改进包括 errors 包增加标准库 <code>errors</code> 的包装、logging 中间件转向标准库 <code>slog</code>、config 增加泛型 <code>Get</code>、validate 支持自定义 validator 等。实际使用下来，模板结构与 v2 差别不大。</p>
<h3 id="71-推荐的层次划分">7.1 推荐的层次划分</h3>
<p>kratos-layout 把代码拆成四层：<code>server</code>、<code>service</code>、<code>biz</code>（business）、<code>data</code>。在解释每层职责之前，先说明这三组贯穿各层的模型：DTO、DO、PO。</p>
<h4 id="dto--do--po-三模型">DTO / DO / PO 三模型</h4>
<table>
  <thead>
      <tr>
          <th>模型</th>
          <th>全称</th>
          <th>中文说明</th>
          <th>所在层</th>
          <th>作用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>DTO</td>
          <td>Data Transfer Object</td>
          <td>数据传输对象</td>
          <td>service</td>
          <td>承载传输层的序列化和反序列化；proto 生成的消息即对外接口契约</td>
      </tr>
      <tr>
          <td>DO</td>
          <td>Domain Object</td>
          <td>领域对象</td>
          <td>biz</td>
          <td>表达业务层的业务逻辑；承载业务规则、权限判断、流程编排等语义</td>
      </tr>
      <tr>
          <td>PO</td>
          <td>Persistence Object</td>
          <td>持久化对象</td>
          <td>data</td>
          <td>承载数据库数据的序列化和反序列化；对应数据库表结构</td>
      </tr>
  </tbody>
</table>
<p>一次请求的数据形态变化大致是：客户端传来 DTO → service 转成 DO → biz 处理 DO → data 把 DO 转成 PO 存进数据库；返回时反向再转回来。</p>
<p>四层的关系可以用下面这张图概括：</p>

<pre class="mermaid">flowchart TD
    Client[客户端] -->|gRPC/HTTP 请求| Server[server<br/>启动 gRPC/HTTP server<br/>注册 service]
    Server --> Service[service<br/>DTO ↔ DO 转换<br/>（传输层到业务层）<br/>填充统一响应信封]
    Service --> Biz[biz<br/>领域逻辑<br/>声明 Repo 接口]
    Biz --> Data[data<br/>实现 Repo 接口<br/>DO ↔ PO 转换<br/>（业务层到持久化层）]
    Data --> DB[(数据库)]</pre>
<p>目录结构对应如下：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">services/auth/
</span></span><span class="line"><span class="cl">├── cmd/auth/            # main.go + wire.go + wire_gen.go，唯一的依赖注入装配点
</span></span><span class="line"><span class="cl">├── configs/             # config.yaml
</span></span><span class="line"><span class="cl">└── internal/
</span></span><span class="line"><span class="cl">    ├── conf/            # conf.proto 定义 AppConfig，buf 生成 conf.pb.go
</span></span><span class="line"><span class="cl">    ├── server/          # grpc.go / http.go，装配 kratos 的 gRPC/HTTP server 并注册 service
</span></span><span class="line"><span class="cl">    ├── service/         # DTO↔DO 转换，填统一响应信封，实现 proto 生成的 Service 接口
</span></span><span class="line"><span class="cl">    ├── biz/             # 业务/领域逻辑（biz）：DO（纯领域对象）+ Repo 接口 + Usecase，类型化错误
</span></span><span class="line"><span class="cl">    ├── data/            # 持久层（data）：Repo 接口的实现，DO↔PO 转换，持有 *ent.Client
</span></span><span class="line"><span class="cl">    └── pkg/             # 共享工具
</span></span></code></pre></td></tr></table>
</div>
</div><h4 id="四层的职责">四层的职责</h4>
<ul>
<li><code>server</code>：程序的入口层。负责创建 kratos 的 gRPC server 和 HTTP server，把 <code>service</code> 注册进去，并挂载中间件（如 recovery、validate）。这一层只关心传输和装配，不写业务。</li>
<li><code>service</code>：接口适配层。它实现 proto 生成的 <code>Service</code> 接口，把来自 gRPC/HTTP 的 <code>DTO</code> 转成 <code>DO</code>，调用 <code>biz</code> 的方法；拿到结果后再转回 <code>DTO</code>，填充统一响应信封。</li>
<li><code>biz</code>（business）：业务/领域逻辑层。这里放纯领域对象 <code>DO</code>、以及 Repo 接口声明。业务规则、权限判断、流程编排都在这层，它只知道 <code>DO</code>，不知道 proto 消息，也不知道数据库表结构。</li>
<li><code>data</code>：持久层。它实现 <code>biz</code> 里声明的 Repo 接口，负责 <code>DO</code> 和 <code>PO</code> 的转换，持有 <code>*ent.Client</code> 或数据库连接。</li>
</ul>
<h4 id="单向依赖与依赖注入">单向依赖与依赖注入</h4>
<p>依赖方向只允许从外层指向内层：<code>service</code> 可以 import <code>biz</code>，<code>biz</code> 里声明 Repo 接口；<code>data</code> 实现 Repo 接口，但 <code>service</code> 不直接 import <code>data</code>，否则会跳过业务层、破坏单向依赖。具体对象的创建和注入由 <a href="https://github.com/google/wire">google/wire</a> 在 <code>cmd/auth</code> 里统一完成：server、service、biz、data 各层暴露 <code>ProviderSet</code>，wire 按依赖关系生成装配代码，<code>cmd</code> 是整个项目唯一的依赖注入装配点。</p>
<p>另外，所有生成物（<code>*.pb.go</code>、<code>wire_gen.go</code>、ent 产物）都不手工修改，通过代码生成重新产出。</p>
<h3 id="72-默认技术栈的形态">7.2 默认技术栈的形态</h3>
<table>
  <thead>
      <tr>
          <th>能力</th>
          <th>机制</th>
          <th>说明</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>生命周期</td>
          <td><code>kratos.New(kratos.Server(gs, hs))</code></td>
          <td>kratos 统一管理 gRPC/HTTP server 的启动与优雅关闭</td>
      </tr>
      <tr>
          <td>配置</td>
          <td><code>conf.proto</code> → <code>AppConfig</code></td>
          <td>用 proto 定义配置结构，<a href="https://github.com/bufbuild/buf">buf</a> 生成 Go 代码，kratos config 从 yaml 加载并扫描进结构体</td>
      </tr>
      <tr>
          <td>依赖注入</td>
          <td><a href="https://github.com/google/wire">google/wire</a></td>
          <td>server、service、biz、data 各层暴露 <code>ProviderSet</code>，<code>cmd/auth</code> 是唯一装配点</td>
      </tr>
      <tr>
          <td>错误</td>
          <td>kratos <code>errors</code> 包</td>
          <td>提供 <code>NotFound</code>、<code>BadRequest</code> 等类型化错误，再映射到自定义信封的 <code>retcode</code>/<code>retmsg</code></td>
      </tr>
      <tr>
          <td>中间件</td>
          <td>recovery、validate 等</td>
          <td>按 server 维度挂载，validate 实际用的是 <a href="https://github.com/einride/aip-go"><code>go.einride.tech/aip</code></a> 的 field behavior 校验</td>
      </tr>
      <tr>
          <td>proto 工具链</td>
          <td><a href="https://github.com/bufbuild/buf">buf</a> v2</td>
          <td>lint 规则用 STANDARD，breaking 检测开启；配合 <code>protoc-gen-go</code>（生成消息）、<code>-go-grpc</code>（生成 gRPC 服务端/客户端）、<code>-go-http</code>（生成 HTTP handler）、<code>protoc-gen-openapi</code>（生成 OpenAPI）</td>
      </tr>
      <tr>
          <td>工具管理</td>
          <td>Go 1.24+ <code>tool</code> 指令</td>
          <td><code>go tool buf</code>、<code>go tool wire</code>、<code>go tool ent</code> 直接调用，不再全局 <code>go install</code></td>
      </tr>
      <tr>
          <td>API 风格</td>
          <td>Google AIP</td>
          <td>resource-oriented 资源命名、<code>page_size</code>/<code>page_token</code> 分页、field behavior 标注字段语义、Protovalidate 校验规则写在 proto 里</td>
      </tr>
  </tbody>
</table>
<h4 id="几个术语补充">几个术语补充</h4>
<ul>
<li><strong>buf</strong>：proto 的构建工具，负责 lint、format、生成代码和 breaking change 检测，替代了传统 <code>protoc</code> + 一堆插件的手动管理。</li>
<li><strong>wire</strong>：Google 的编译期依赖注入工具，通过 <code>wire_gen.go</code> 生成装配代码，避免运行时的反射容器。</li>
<li><strong>ProviderSet</strong>：wire 的概念，把一组构造函数打包成一个集合，方便上层统一引用。</li>
<li><strong>AIP（Google API Improvement Proposals）</strong>：Google 的 API 设计规范，kratos 推荐按这套规范来组织资源、错误、分页和字段语义。</li>
<li><strong>field behavior</strong>：AIP 里的概念，例如 <code>REQUIRED</code>、<code>OUTPUT_ONLY</code>、<code>IMMUTABLE</code>，用来标注字段在请求/响应中的角色，配合 validate 中间件做校验。</li>
<li><strong>Protovalidate</strong>：buf 推出的 proto 校验规则框架，校验逻辑写在 proto 里，运行时由 Go 库执行。</li>
</ul>
<p>实际落地时，相对于模板默认形态做了几处裁剪：业务接口只走 gRPC 暴露，HTTP server 仅保留健康检查；响应统一用信封而非 kratos 默认错误透传。</p>
<h2 id="8-工具链管理go-124-tool-指令">8. 工具链管理：Go 1.24+ <code>tool</code> 指令</h2>
<p>Go 1.24 引入了 <code>tool</code> 指令，允许把构建工具直接声明在 <code>go.mod</code> 里。这个机制对 proto、wire、ent、lint 这类代码生成和检查工具特别合适。但在它之前，Go 项目已经有过几种工具版本化管理方案，至今仍广泛存在于各种代码库里。</p>
<h3 id="81-几种工具版本化方案">8.1 几种工具版本化方案</h3>
<p><strong><code>go install ...@latest</code></strong>
把工具安装到全局 <code>GOPATH/bin</code>，然后像普通命令一样调用。<code>@latest</code> 不固定版本，不同环境安装到的版本可能不同。一些较早创建的服务 Makefile 里仍能看到这种写法：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-makefile" data-lang="makefile"><span class="line"><span class="cl"><span class="nf">init</span><span class="o">:</span>
</span></span><span class="line"><span class="cl">    go install github.com/google/wire/cmd/wire@latest
</span></span><span class="line"><span class="cl">    go install github.com/bufbuild/buf/cmd/buf@latest
</span></span></code></pre></td></tr></table>
</div>
</div><p><strong><code>go run ...@version</code></strong>
不在 <code>go.mod</code> 里记录工具，每次调用时直接指定版本。一些服务的 <code>buf.gen.yaml</code> 里常见：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">plugins</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">local</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;go&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;run&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">out</span><span class="p">:</span><span class="w"> </span><span class="l">api</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>版本固定了，但调用路径长，版本号散落在各服务的 YAML 文件里。</p>
<p><strong><code>tools.go</code> 空 import（<code>_ &quot;...&quot;</code>）</strong>
Go 1.24 之前最常见的方案。在项目中放一个带 <code>//go:build tools</code> 标签的 <code>tools.go</code>，用下划线 <code>_</code> 把工具包 import 进来，但不引用它的任何导出符号：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span><span class="lnt">6
</span><span class="lnt">7
</span><span class="lnt">8
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="cp">//go:build tools</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kn">package</span><span class="w"> </span><span class="nx">tools</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kn">import</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">_</span><span class="w"> </span><span class="s">&#34;github.com/bufbuild/buf/cmd/buf&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">_</span><span class="w"> </span><span class="s">&#34;github.com/google/wire/cmd/wire&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">)</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>工具依赖进入 <code>go.mod</code> 的 <code>require</code> 块，普通构建不会把它们编进二进制。缺点是和业务依赖混排，<code>go.mod</code> 看起来嘈杂。</p>
<p><strong><code>go tool</code>（Go 1.24+）</strong>
把工具声明在 <code>go.mod</code> 独立的 <code>tool</code> 块里：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span><span class="lnt">6
</span><span class="lnt">7
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="nf">tool</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">entgo</span><span class="p">.</span><span class="nx">io</span><span class="o">/</span><span class="nx">ent</span><span class="o">/</span><span class="nx">cmd</span><span class="o">/</span><span class="nx">ent</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">github</span><span class="p">.</span><span class="nx">com</span><span class="o">/</span><span class="nx">bufbuild</span><span class="o">/</span><span class="nx">buf</span><span class="o">/</span><span class="nx">cmd</span><span class="o">/</span><span class="nx">buf</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">github</span><span class="p">.</span><span class="nx">com</span><span class="o">/</span><span class="nx">golangci</span><span class="o">/</span><span class="nx">golangci</span><span class="o">-</span><span class="nx">lint</span><span class="o">/</span><span class="nx">v2</span><span class="o">/</span><span class="nx">cmd</span><span class="o">/</span><span class="nx">golangci</span><span class="o">-</span><span class="nx">lint</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">github</span><span class="p">.</span><span class="nx">com</span><span class="o">/</span><span class="nx">google</span><span class="o">/</span><span class="nx">wire</span><span class="o">/</span><span class="nx">cmd</span><span class="o">/</span><span class="nx">wire</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">google</span><span class="p">.</span><span class="nx">golang</span><span class="p">.</span><span class="nx">org</span><span class="o">/</span><span class="nx">protobuf</span><span class="o">/</span><span class="nx">cmd</span><span class="o">/</span><span class="nx">protoc</span><span class="o">-</span><span class="nx">gen</span><span class="o">-</span><span class="k">go</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">)</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p><code>tool</code> 块只声明工具身份，不带版本号；版本仍由 <code>go.mod</code> 的 <code>require</code> 块决定，通常以 <code>// indirect</code> 形式出现。调用简化为 <code>go tool &lt;name&gt;</code>：</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">go tool buf --version
</span></span><span class="line"><span class="cl">go tool wire ./cmd/auth
</span></span><span class="line"><span class="cl">go tool ent generate ./internal/data/ent/schema
</span></span></code></pre></td></tr></table>
</div>
</div><p>改造后的 Makefile 直接调用 <code>go tool buf</code> 等命令，<code>buf.gen.yaml</code> 里也使用 <code>local: [&quot;go&quot;, &quot;tool&quot;, &quot;protoc-gen-go&quot;]</code>。工具身份和业务依赖被明确分开。</p>
<h3 id="82-四种方案对比">8.2 四种方案对比</h3>
<table>
  <thead>
      <tr>
          <th>维度</th>
          <th><code>go install @latest</code></th>
          <th><code>go run @version</code></th>
          <th><code>tools.go</code> 空 import</th>
          <th><code>go tool</code></th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>版本来源</td>
          <td>无，取最新</td>
          <td>命令/YAML 中硬编码</td>
          <td><code>go.mod</code> require</td>
          <td><code>go.mod</code> require</td>
      </tr>
      <tr>
          <td>工具身份声明</td>
          <td>无</td>
          <td>无</td>
          <td>通过 <code>tools.go</code> 文件</td>
          <td><code>go.mod</code> tool 块</td>
      </tr>
      <tr>
          <td>与业务依赖混排</td>
          <td>否</td>
          <td>否</td>
          <td>是</td>
          <td>否</td>
      </tr>
      <tr>
          <td>避免编译进二进制</td>
          <td>是（全局安装）</td>
          <td>是（临时运行）</td>
          <td>需 <code>//go:build tools</code></td>
          <td>自动排除</td>
      </tr>
      <tr>
          <td>调用方式</td>
          <td>全局命令</td>
          <td><code>go run pkg@version</code></td>
          <td><code>go run pkg</code></td>
          <td><code>go tool name</code></td>
      </tr>
      <tr>
          <td>可复现性</td>
          <td>低</td>
          <td>中</td>
          <td>高</td>
          <td>高</td>
      </tr>
  </tbody>
</table>
<p>这些方案的核心差异在于版本是否可控、工具身份是否独立声明。真正值得警惕的问题是<strong>生成器版本不固定，既会导致不同环境生成不同代码，也可能和运行时库版本不匹配</strong>。例如 <code>protoc-gen-go</code> 与 <code>google.golang.org/protobuf</code>、<code>protoc-gen-go-grpc</code> 与 <code>google.golang.org/grpc</code>、ent 的生成器与 <code>entgo.io/ent</code> 之间都有这种风险；<code>go install @latest</code> 或散落在 YAML 里的 <code>go run @version</code> 很难保证所有环境使用同一组版本。<code>tools.go</code> 能固定版本，但工具包和业务依赖混排后不够直观。<code>go tool</code> 把生成器和运行时库放在同一份 <code>go.mod</code> 下管理，不同开发者、CI、不同服务之间更容易得到一致性、可复现的构建。</p>
<h2 id="9-结语">9. 结语</h2>
<p>做方案选型和设计时，主要是聚焦在这几个方面：把同领域主流方案尽量看全，尊重团队已有的技术栈，按真实需求务实落地，以及不断提升个人调研与思考的深度和素养。具体地说，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 的过程，也想尽量坦诚地记录下来。</p>
<p>另一层感受是，这份结论只走到了设计方案层面，错过了在实现阶段继续深化的机会。纸面论证和实际落地之间，往往隔着只有实践才能发现的偏差。如果工作节奏注定要把大量时间花在方案设计上，那至少要把这一阶段的调研做扎实——对比维度、边界条件、落地风险都想清楚。方案设计本身也可以是一种可交付的、有价值的工作，关键是不能让它沦为浅尝辄止的草稿。</p>
]]></description>
    </item>
  </channel>
</rss>
