<?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>go on Chaney&#39;s MoonBook</title>
    <link>https://chaneyzorn.github.io/tags/go/</link>
    <description>Recent content in go 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>Sun, 23 Aug 2026 21:53:49 +0800</lastBuildDate>
    <atom:link href="https://chaneyzorn.github.io/tags/go/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>私有化交付的 License 机制设计</title>
      <link>https://chaneyzorn.github.io/codes/private-license-design/</link>
      <pubDate>Sun, 23 Aug 2026 21:53:49 +0800</pubDate><author>chaneyzorn#gmail#com (ChaneyZorn)</author>
      <guid>https://chaneyzorn.github.io/codes/private-license-design/</guid>
      <description><![CDATA[<p>私有化交付场景下的 License 机制有一个基本前提：软件最终运行在客户的环境里，而客户拥有那台机器上的 root、硬件和网络。这意味着我们既不能假设本地时钟可信，也不能假设程序里的校验逻辑不可绕过——只要对方有足够决心，总可以 patch 二进制。</p>
<p>所以对这类 License 机制要有一个合理的预期：它不可能做到「不可破解」。合理的定位是，在提供正常期限和功能授权的同时，把破解成本抬高到不再是简单修改就能绕过的程度；再配合短授权周期和顺畅续期，让未授权使用本身变得不划算。</p>
<p>围绕这个定位，我们在某个产品管理面控制台上做了一套离线 License 机制。下面按实现中遇到的主要问题展开：</p>
<ul>
<li>公钥如何嵌入客户侧二进制</li>
<li>硬件绑定与 k8s 容器里的权限问题</li>
<li>用锚点加单调时钟防御时钟回拨</li>
<li>garble 二进制符号混淆</li>
<li>真实攻击路径与安全边界</li>
</ul>
<h2 id="1-把公钥嵌入客户侧二进制">1. 把公钥嵌入客户侧二进制</h2>
<p>离线 License 的验证链路很朴素：程序里嵌一个公钥，启动后读取客户现场的证书文件，用公钥验签，再按证书里的授权内容决定哪些功能可用。难点在第一步——公钥必须跟着二进制一起发到客户现场，而那个现场对客户是完全开放的。</p>
<p>换句话说，我们要把一把验证钥匙放进对方可以任意分析的机器里。这把钥匙本身不需要保密（公钥本来就可以公开），但如果它太容易被找到、被替换，整个授权机制就会失效。</p>
<h3 id="11-私钥证书公钥各自的角色">1.1 私钥、证书、公钥各自的角色</h3>
<p>这套机制里的密钥材料只有三种：</p>
<ul>
<li><strong>私钥</strong>：放在签发机器上，是唯一需要保密的东西；</li>
<li><strong>证书</strong>：用私钥签出来的授权文件，交给客户部署；</li>
<li><strong>公钥</strong>：嵌在客户运行的程序里，只负责验证证书签名。</li>
</ul>
<p>在我们的设计中，证书本身是一行文本：<code>&lt;base64url(payload)&gt;.&lt;base64url(signature)&gt;</code>。其中 payload 是 JSON 格式，承载客户名、到期时间、模块清单、配额、硬件绑定等授权内容；signature 则是对 payload 字节做 RSA-SHA256 签名后的结果。我们选用了 JSON 作为 payload 格式，主要是为了扩展方便，但它并不是唯一选择——固定长度字段同样是常见做法，还能避免引入 JSON 解析库、减小证书体积。</p>
<p>签名和验签基于 RSA 的非对称运算。签发时，先用 SHA-256 等哈希算法计算 payload 的摘要，再用私钥对这个摘要做「加密」运算，得到 signature。验签时，用公钥对 signature 做「解密」运算，把结果与 payload 的哈希摘要比对。只有持有私钥的签发方才能生成有效的 signature，而任何人都可以用公开的公钥验证它。证书在传输或部署过程中如果被篡改，摘要就会对不上，验签随之失败。</p>
<p>程序启动时和运行过程中会周期性读取证书，用内嵌公钥对证书验签并检查有效期限，通过后就按 payload 内容放行对应功能。</p>
<h3 id="12-公钥如何进入程序">1.2 公钥如何进入程序</h3>
<p>公钥进入程序通常有三种做法。</p>
<p><strong>硬编码到源码常量</strong>：把公钥直接写进源码。</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></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">// 字符串形式</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kd">const</span><span class="w"> </span><span class="nx">PublicKey</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">&#34;-----BEGIN PUBLIC KEY-----\nMIIB...&#34;</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="kd">var</span><span class="w"> </span><span class="nx">PublicKey</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="p">[]</span><span class="kt">byte</span><span class="p">{</span><span class="mh">0x30</span><span class="p">,</span><span class="w"> </span><span class="mh">0x82</span><span class="p">,</span><span class="w"> </span><span class="o">...</span><span class="p">}</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>这是最简单的方式，但公钥以可识别形态存在于源码和二进制中，客户用 <code>strings</code> 命令就能定位并替换。另外，源码里一旦固定生产公钥，本地开发同事就必须持有由权威私钥签发的开发证书才能跑通流程，整个团队会被绑定到私钥持有者身上。公钥的存放方式在 1.4 节讨论。</p>
<p><strong>编译期通过 -ldflags 注入</strong>：源码里留空变量，构建时再写入公钥值。</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-sh" data-lang="sh"><span class="line"><span class="cl"><span class="c1"># 源码中先声明空变量：var PublicKey string</span>
</span></span><span class="line"><span class="cl">garble -literals build -ldflags<span class="o">=</span><span class="s2">&#34;-X main.PublicKey=REAL_PUBLIC_KEY_VALUE&#34;</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>这样源码里不直接出现公钥，但注入的值仍是字符串常量。配合 garble 混淆时，garble 的 <code>-literals</code> 不会处理通过 <code>-ldflags -X</code> 写入的字面量，公钥值会以明文留在二进制里（验证过程见第 4 节）。因此这条路在工程上不够干净，不是我们最终选择。</p>
<p><strong>用 go:embed 嵌入</strong>：通过 <code>//go:embed</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></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:embed public_key.pem // public_key.pem 被 git 忽略</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kd">var</span><span class="w"> </span><span class="nx">PublicKey</span><span class="w"> </span><span class="p">[]</span><span class="kt">byte</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>把 <code>public_key.pem</code> 放在被 git 忽略的文件里，公钥就不在源码中，而是通过文件进入编译流程；加载路径也和本地开发、CI 保持一致。这是我们最终采用的方式。</p>
<p>需要说明的是，garble 能正常编译 embed，但不会混淆 embed 文件的字节内容（验证见第 4 节）。所以从「内容是否被隐藏」的角度看，go:embed 并不比 <code>-ldflags</code> 更先进，两者都需要额外方式来保护内容（比如可逆变换）。go:embed 的优势在于分离更干净：公钥是一个独立文件，CI/CD 可以在构建前对它做清晰的预处理（变换、转换、注入），本地开发也更容易用临时文件替换，而不会被编译命令的复杂度牵制。</p>
<h3 id="13-基于-goembed-的进一步设计">1.3 基于 go:embed 的进一步设计</h3>
<p>选定 <code>go:embed</code> 后，还有两个直接影响实现的问题：如何防止公钥被直接替换，以及 embed 文件在代码仓库里如何组织。</p>
<p><strong>可逆变换，而不是明文嵌入</strong>：<code>go:embed</code> 把文件内容作为连续数据直接嵌入二进制，这块数据在二进制里有可定位的边界，且 garble 不会混淆 embed 文件的内容（见第 4 节）。也就是说，直接嵌入的公钥会以明文形式躺在二进制里一个找得到的位置。</p>
<p>因此我们在 embed 文件里存的不是 PEM 明文，而是公钥 PKIX DER 字节经过某种可逆变换后的形态，让它不再以可识别的 ASN.1 结构明文出现。客户即便用 <code>strings</code> 命令搜索二进制，也定位不到可以直接替换的公钥内容；想绕过这层变换需要额外投入，替换成本从直接替换变成了需要专门处理。</p>
<p>我们的设计判断是：不指望彻底隐藏这块数据的边界，而是把门槛放在变换过程上。攻击者即便定位到这块数据区域，拿到的也不是 ASN.1 公钥，而是一组需要还原掩码才能使用的字节；想替换公钥，就得先还原掩码、再重新生成变换后的文件并重新编译。</p>
<p>变换所用的 XOR 掩码以字面量形式写在源码里，由 garble 的字面量混淆保护，静态搜索拿不到原始掩码序列（验证见第 4 节）；程序结构的隐藏同样交给 garble。但这只解决了静态分析。程序运行时必须还原掩码才能做 XOR，动态调试、内存 dump 或者直接 patch 校验逻辑仍然是可行的。我们的安全边界没有因此改变：整套机制始终定位为「抬高绕过成本」，而不是「防住拥有 root 权限的刻意攻击」。</p>
<p><strong>独立的 embed 叶子包</strong>：在我们的代码结构里，签发工具负责生成 embed 文件，验签代码负责读取它。签发工具本身属于 license 包的一部分，如果 <code>//go:embed</code> 声明也放在 license 包里，就会出现循环依赖：编译 license 包需要先拿到 embed 文件，而生成 embed 文件又需要先编译 license 包里的签发工具。这就是「生成工具依赖自己要生成的文件」的死锁，无法通过编译。把它独立成一个叶子包后，签发工具只依赖变换函数，只有验签侧依赖文件本身，循环被打破。而且文件缺失时 <code>go:embed</code> 会直接编译报错，不会出现「漏注入而编出无钥二进制」的隐患。</p>
<h3 id="14-公钥的存放位置">1.4 公钥的存放位置</h3>
<p>公钥无需保密，但放在哪里会直接影响开发体验和泄露面。我们最关心的问题是：<strong>后续同事做日常代码开发时，如何不与权威密钥文件强绑定</strong>。围绕这个问题，我们评估了三种做法。</p>
<p><strong>做法一：代码仓库里不放任何密钥或证书，本地开发完全自签，CI/CD 从环境变量读取权威公钥。</strong> 本地脚本在启动服务前自动生成一次性 dev 密钥对和自签证书，开发者无感知；CI/CD 构建时则从 GitHub Actions / GitLab CI Variables 等环境变量里读取权威公钥，生成 embed 文件并打包。优点是仓库最干净，没有任何密钥文件；权威私钥自始至终只存在于签发机器，不会进入仓库或 CI/CD；测试走临时钥匙对，生产包直接嵌入权威公钥，两者走同一条加载路径。代价是需要 CI/CD 支持读取环境变量里的公钥，本地开发也需要自签脚本兜底。</p>
<p><strong>做法二：公钥和开发证书都提交进仓库，权威私钥离线保存。</strong> 公钥本身就是设计为可公开的，开发证书则让同事本地直接能跑，不用关心密钥。优点是对开发同事最友好，CI/CD 也无需改造。缺点是开发证书有有效期，需要定期用权威私钥重新签发并更新到仓库。更大的隐患是仓库里同时放着公钥和这张证书：证书一旦外流，就能在任何嵌了同一把公钥的程序上通过验签，风险更高。</p>
<p><strong>做法三：公钥提交进仓库，但不提供开发证书，每位同事本地自签。</strong> 这样不用担心开发证书泄漏，也不需要改动 CI/CD。但每个人本地生成的自签公钥和证书容易污染仓库；一旦误提交，会让 CI/CD 打包进非预期的公钥，影响成品。虽然危害可控，但清理起来麻烦。</p>
<p>我们最终选择了做法一。它的核心优势是<strong>把权威私钥的暴露面降到最低</strong>：私钥不进入仓库，不进入 CI/CD，只有签发机器持有；同时生产包在 CI/CD 里直接嵌入权威公钥，不需要后续再换密钥或重新签名。开发和测试用的临时密钥对全部被 git 忽略，即使误提交也不会影响生产镜像。</p>
<h2 id="2-硬件绑定k8s-容器里的权限问题">2. 硬件绑定：k8s 容器里的权限问题</h2>
<p>证书除了限定时间和模块，还可以做硬件绑定：payload 里带一组硬件指纹的 SHA-256，运行时采集当前机器的指纹并比对，命中数低于阈值就认为绑定不匹配。这相当于把证书和某台物理机绑定，防止客户把部署目录整体复制到另一台机器上继续使用。</p>
<p>我们的控制台本身运行在 k8s 里，主容器是一个普通 Pod，通过读取多个集群的 kubeconfig 来纳管这些集群。要读取机器 UUID、MAC 地址、主板序列号、磁盘序列号这类 sysfs 信息，通常需要 root 权限；但主容器又是持有全部集群 kubeconfig 的组件，权限越大风险越大。这里出现了一个矛盾：<strong>要做硬件绑定，就得提权；提了权，又会让关键组件的暴露面变大</strong>。</p>
<p>我们的处理方式是把提权需求限制在一个短生命周期的 <strong>initContainer</strong>：它以 root 运行，只读挂载宿主机的 <code>/sys</code>，把采集结果写进与主容器共享的临时卷，然后退出。主容器维持原有安全基线不变，启动时从临时卷读取指纹文件做绑定校验。真正需要特权的代码只运行很短一段时间，而且不接触网络或 kubeconfig。</p>
<p>采集过程遵循两个原则。第一，原始序列号不离开采集进程——每个值就地哈希成 SHA-256 再写出，输出的 JSON 可以安全地跨环境传输。实施人员把采集结果带回签发方、前端页面展示当前环境指纹，看到的都只是哈希。第二，主容器权限不作任何放大，保持原来的安全基线。</p>
<p>前端页面向实施人员展示的硬件指纹摘要如下：</p>
<p><img alt="前端页面展示的硬件指纹摘要" loading="lazy" src="/codes/private-license-design/asserts/hw_factors.webp#center"></p>
<p>匹配规则采用阈值制：N 个指纹中至少命中指定个数即通过。这样客户换一块网卡之类的单点硬件更换不会导致证书失效；整机更换则走重新签发流程。在虚拟化环境里，UUID、MAC 地址、磁盘序列号都可以被 hypervisor 控制，所以硬件指纹只能防「把部署目录整体复制到另一台机器」这种低成本复制；如果 hypervisor 层可以人为操控、连硬件标识一起克隆，这种场景就无法防御。它的价值是提高克隆或错误部署的成本，与整体威胁模型定位一致。</p>
<p>采集和运行时比对用的是同一份二进制，随主镜像一起分发。签发证书前在目标节点上运行它采集硬件指纹，运行时 initContainer 里运行的也是它。同一份代码算出来的哈希自然一致。</p>
<h2 id="3-防御时钟回拨锚点加单调时钟">3. 防御时钟回拨：锚点加单调时钟</h2>
<p>离线产品没有远程心跳，证书有效期只能靠本地时间判定。而本地时间客户可控，这是整个机制里脆弱的地方。这里需要先区分两个时间概念：</p>
<ul>
<li><strong>墙上时钟（wall clock）</strong>：系统显示的时间，可以被随意修改；</li>
<li><strong>单调时钟（monotonic clock）</strong>：进程启动后真实流逝的时间，不受墙上时钟调整的影响。</li>
</ul>
<h3 id="31-只记录-last-seen-的问题">3.1 只记录 last seen 的问题</h3>
<p>第一种思路是持久化一个「last seen」时间戳，每次检查时取 <code>max(墙上时钟, last seen)</code> 作为有效时间并落盘。这能挡住「过期之后把时钟拨回有效期窗口」的情况：last seen 已经越过到期点，再往回拨也不会生效。</p>
<p>但它漏掉了另一种情况：<strong>在过期前回拨并冻结时钟</strong>。假设证书在 8 月 25 日到期，客户在 8 月 20 日把系统时间拨回 8 月 1 日并保持不动。此后每次检查墙上时钟都是 8 月 1 日，始终小于 last seen（8 月 20 日）。于是 max 函数的结果冻结在 8 月 20 日不再推进，到期时刻始终等不到，证书实际上不会过期。</p>
<p>这个方案只防得住「过期后回拨」，防不住「过期前冻结」。</p>
<h3 id="32-锚点加单调时钟">3.2 锚点加单调时钟</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></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">有效时间 = max(墙上时钟, 锚点 + 单调时钟流逝)
</span></span></code></pre></td></tr></table>
</div>
</div><p>锚点（即 last seen 时间戳）持久化在一个独立文件里，单调时钟对应进程运行时长。墙上时钟正常时，有效时间跟随墙上时钟，锚点同步推进并落盘；墙上时钟被回拨或冻结时，有效时间从锚点起按进程真实运行时长继续走，到期后仍然拒绝服务。重启后从落盘锚点接着累计。</p>
<p>「冻结时钟」「删锚点文件再冻结」「冻结加反复重启」这些操作，每次最多让有效时间从某个更近的点继续走；只要外部干预停止，锚点就会稳定向前推进，不会回退。这意味着绕过者必须持续干预，一次性操作无法永久生效，从而提高回拨成本。</p>
<p>时间防御逻辑因此集中在一处：<strong>证书拥有自己的「固有时钟」</strong>，不散落在各个业务判断点。</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></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">// 证书状态只依赖于「自己的有效时间」，</span><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="kd">func</span><span class="w"> </span><span class="p">(</span><span class="nx">c</span><span class="w"> </span><span class="o">*</span><span class="nx">Checker</span><span class="p">)</span><span class="w"> </span><span class="nf">State</span><span class="p">()</span><span class="w"> </span><span class="nx">Status</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">now</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nf">effectiveNow</span><span class="p">(</span><span class="nf">wallClockNow</span><span class="p">())</span><span class="w"> </span><span class="c1">// 算出证书自己的有效时间</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="nx">currentLicense</span><span class="p">.</span><span class="nf">StatusAt</span><span class="p">(</span><span class="nx">now</span><span class="p">)</span><span class="w"> </span><span class="c1">// 再查该时间点的状态</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>Go 的 <code>time.Time</code> 内部同时保存墙上时钟读数和单调读数，<code>time.Now()</code> 返回的时间点也携带两者。当两个 <code>time.Time</code> 都带有单调读数时，<code>time.Since(start)</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></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="nx">start</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">()</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c1">// 客户通过 settimeofday 把墙上时钟往回拨一年</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">elapsed</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nx">time</span><span class="p">.</span><span class="nf">Since</span><span class="p">(</span><span class="nx">start</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c1">// elapsed 仍然是进程启动后真实流逝的时间，不受墙上时钟回拨影响</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>但单调读数不能持久化：进程重启会归零，格式化或序列化也会丢掉。所以锚点用墙上时钟落盘，单调部分只存于内存，两者各司其职。</p>
<p>我们还希望锚点文件的语义不要过于明显。如果文件名或内容带有明显语义，查看目录的人就会知道「这是回拨防御状态，删了它就能重置」。所以它被存成 8 字节小端 uint64 裸二进制，不带字段名、不是 JSON、文件名也不带语义。</p>
<h3 id="33-安全边界">3.3 安全边界</h3>
<p>这层防御不承诺防 root。有 root 权限的客户可以直接删除锚点文件或 patch 二进制删掉时间判定，那才是真正的边界。删除之外的另一种操作是备份早期锚点文件、之后再配合冻结时钟恢复它，让有效时间回退到备份点；但这类操作和「删锚点文件再冻结」一样需要持续干预，无法一次操作永久生效，不改变整体的防御定位。我们同时用二进制混淆把校验逻辑和锚点机制隐藏起来（见第 4 节），把绕过的成本从直接删除文件抬高到需要认真逆向；交付流程则负责控制证书授权周期和续期通道。</p>
<p>另外两个小边界是：进程停止期间时间不走，因为服务本就不在提供；Linux 的 CLOCK_MONOTONIC 不计系统 suspend 时长，VM 挂起期间时间暂停。</p>
<h3 id="34-测试策略把两个时钟都注入">3.4 测试策略：把两个时钟都注入</h3>
<p>这套机制的正确性很大程度上集中在时间判定上，而时间测试最容易写成 <code>time.Sleep</code> 式的脆弱用例。我们的做法是把墙上时钟和单调时钟都做成依赖注入：在代码里提供两个可替换的时钟源，一个返回当前 wall time，一个返回当前 uptime。测试时把它们换成手动推进的假时钟，所有有效期判定都走这两个注入源，不再依赖真实时间。</p>
<p>几类原本难以构造的场景，由此可以写成普通单测：墙上时钟冻结在过期前、单调时钟推 30 天，断言证书 expired；冻结期间锚点照常落盘，重新构造校验器模拟重启，从落盘锚点继续走，再推几天，断言 expired，验证「冻结加反复重启」无法重置时间；宽限期边界前后 1 秒的状态迁移、回拨超容忍度的告警，也都是表驱动断言。</p>
<h2 id="4-garble-二进制符号混淆">4. garble 二进制符号混淆</h2>
<p>前面几节多次提到 garble，这一节集中说明它的原理、能力边界，以及围绕它做的几个验证。</p>
<p><a href="https://github.com/burrowers/garble">garble</a> 的原理是包装 Go 工具链的 <code>toolexec</code>，在编译期替换标识符：包路径、类型名、函数名、变量名全部被替换成无意义的哈希串，同时裁剪符号表与调试信息。开启 <code>-literals</code> 后，连字符串和数字字面量也会被混淆，运行时才还原。最终构建产物里既没有可读的调用栈，也没有能直接定位 license 校验逻辑的符号名。</p>
<h3 id="41--literals-混淆什么不混淆什么">4.1 -literals 混淆什么、不混淆什么</h3>
<p>garble 的 <code>-literals</code> 模式会对源码中的字符串和字节字面量做 AST 级变换，把字面量替换成运行时现场解密的匿名函数，使用 XOR/ADD/SUB、随机 key、swap、split、shuffle、seed 等策略，并通过 external keys 和 proxy dispatcher 把值藏进随机嵌套 struct。这套机制会让字面量在二进制里以碎片形式出现，加上 junk 字节和符号混淆，单个字面量的边界并不突出。</p>
<p>源码里的普通字面量在它的保护范围内。我们以 XOR 掩码做过验证：32 字节的掩码以 <code>var keyMask = []byte{...}</code> 形式写在源码里，普通构建的二进制里能直接搜到连续字节，但经 <code>garble -literals</code> 混淆后，原始掩码序列在二进制里无法直接搜到，静态 <code>strings</code> 或十六进制搜索都拿不到。</p>
<p>但 <code>-literals</code> 只覆盖「源码里的字面量」，有两类数据不在它的处理范围内，我们同样做过验证。</p>
<p><strong><code>-ldflags -X</code> 注入的值不会被混淆。</strong> 用 garble v0.17.0 + Go 1.26.5 验证：第 1 节的注入命令产出的二进制中，<code>REAL_PUBLIC_KEY_VALUE</code> 仍能被 <code>strings</code> 找到，而源码中硬编码的普通常量在同一次构建里是被混淆的。另一个问题是 garble 会重命名标识符，<code>-X</code> 指定的包路径和变量名需要 garble 能正确解析。garble 仓库的 issue <a href="https://github.com/burrowers/garble/issues/717">#717</a> 跟踪的正是这个问题：<code>-literals</code> 与 <code>-ldflags</code> 配合时注入值未被混淆，至今未修复（README 中「<code>-literals</code> 也会替换 <code>-X</code> 注入的字符串」的说法与此不符，属于过时信息）；<a href="https://github.com/burrowers/garble/issues/820">#820</a> 则显示 ldflags 在 garble 测试套件中曾触发 ABI 相关失败。</p>
<p><strong><code>go:embed</code> 嵌入的文件字节不会被混淆。</strong> garble 能正常编译 embed，但经实测，嵌入的明文会原样出现在二进制里。embed 数据在二进制里是一个连续的 blob，前面跟着 Go runtime 生成的 slice header，garble 不会处理这个 header，也不会拆分或伪装这块数据。这正是第 1 节选择对公钥做可逆变换、而不是直接嵌入明文的原因。</p>
<h3 id="42-与可逆变换的分工">4.2 与可逆变换的分工</h3>
<p>可逆变换和 garble 的分工不同：可逆变换藏的是内容，让公钥不以可识别结构明文存在；garble 藏的是结构，让校验逻辑的位置和调用关系不可读。它们都不构成密码学边界，但合力把简单修改的成本抬高到了需要逆向分析的程度。</p>
<h2 id="5-真实攻击路径与安全边界">5. 真实攻击路径与安全边界</h2>
<p>按攻击成本从低到高，可以回顾这套设计挡住了什么、没挡住什么。</p>
<p>最低成本的攻击——用 <code>strings</code> 命令搜索二进制找到 PEM 公钥，替换后自签证书——已经被挡住。公钥不是 PEM 明文，而是经过可逆变换的 DER 数据；掩码本身也被 garble 字面量混淆，静态搜索拿不到。这意味着简单的静态替换不再奏效。</p>
<p>再往上走，攻击者可以定位到 embed 的边界（slice header 里的长度和指针），确认这里有一块变换后的数据。但想还原出公钥，仍然需要拿到 XOR 掩码。静态逆向 garble 的字面量混淆可以恢复掩码，成本明显高于字符串替换；动态调试则能在运行内存里直接读出还原后的掩码。</p>
<p>更实际的攻击路径会绕开公钥和掩码本身：直接 patch 二进制让验签入口永远放行，或者让掩码还原步骤原样返回输入。有 root 权限的客户也可以删除锚点文件重置时间防御。这些场景不在设计目标之内。</p>
<p>所以这套机制的真实定位是：挡住无预谋的静态替换和误用，把有预谋的绕过成本抬高到需要逆向、调试或重新编译；最终保障仍然交给短授权周期和顺畅续期。它不是密码学边界，而是一道工程门槛。</p>
<h2 id="结语">结语</h2>
<p>整套设计的基本取舍是如实描述能力边界：承认本地没有可信时钟和不可跳过的校验，把工程投入放在抬高轻易绕过的成本上——可逆变换、混淆、落盘文件的去语义化、锚点推进——同时把商业保障交给短授权周期和顺畅续期，比如 UI 上传即生效、无需重启、无需 SSH 上节点。</p>
<p>私有化 License 机制无法做到绝对安全，但可以让正常客户不会误用，让未授权使用不再零成本。</p>
]]></description>
    </item>
    <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>

<div class="mermaid-block">
    <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 class="mermaid-hint">Mermaid 图表需要浏览器启用 JavaScript 并从 CDN 加载 mermaid 库才能渲染；某些网络环境或移动端可能加载失败，此时下方显示的是图表源码。</p>
</div>
<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>
    <item>
      <title>Go Channel 的边界行为</title>
      <link>https://chaneyzorn.github.io/codes/go-channel-boundary/</link>
      <pubDate>Sun, 26 Jul 2026 10:50:39 +0800</pubDate><author>chaneyzorn#gmail#com (ChaneyZorn)</author>
      <guid>https://chaneyzorn.github.io/codes/go-channel-boundary/</guid>
      <description><![CDATA[<p>Go channel 的边界行为可以从三个状态、两种容量以及一组特殊操作来理解。三个状态是 <code>nil</code>、open 和 closed；两种容量是无缓冲和有缓冲。下面按生命周期、特殊行为和同步语义分层说明。</p>
<h2 id="1-概览">1. 概览</h2>
<h3 id="11-状态与分类">1.1 状态与分类</h3>
<ul>
<li><code>nil</code>：只声明、未通过 <code>make</code> 初始化。</li>
<li>open：已经 <code>make</code>，可以正常通信。</li>
<li>closed：已经调用 <code>close</code>，不再接受发送。</li>
</ul>
<p>再结合容量，channel 可分为无缓冲和有缓冲两类。</p>
<h3 id="12-核心行为矩阵">1.2 核心行为矩阵</h3>
<table>
  <thead>
      <tr>
          <th>操作</th>
          <th>nil channel</th>
          <th>open channel</th>
          <th>closed channel</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>ch &lt;- v</code></td>
          <td>永久阻塞</td>
          <td>根据容量决定是否阻塞</td>
          <td>panic</td>
      </tr>
      <tr>
          <td><code>&lt;-ch</code></td>
          <td>永久阻塞</td>
          <td>根据数据决定是否阻塞</td>
          <td>缓冲区排空后立即返回零值</td>
      </tr>
      <tr>
          <td><code>close(ch)</code></td>
          <td>panic</td>
          <td>成功</td>
          <td>panic</td>
      </tr>
      <tr>
          <td><code>len(ch)</code></td>
          <td>0</td>
          <td>缓冲元素数</td>
          <td>尚未取出的缓冲元素数</td>
      </tr>
      <tr>
          <td><code>cap(ch)</code></td>
          <td>0</td>
          <td>创建时容量</td>
          <td>保持原容量</td>
      </tr>
      <tr>
          <td><code>for range ch</code></td>
          <td>永久阻塞</td>
          <td>持续接收</td>
          <td>排空后退出</td>
      </tr>
  </tbody>
</table>
<h2 id="2-生命周期">2. 生命周期</h2>
<h3 id="21-初始化">2.1 初始化</h3>
<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-go" data-lang="go"><span class="line"><span class="cl"><span class="kd">var</span><span class="w"> </span><span class="nx">nilCh</span><span class="w"> </span><span class="kd">chan</span><span class="w"> </span><span class="kt">int</span><span class="w">          </span><span class="c1">// nil channel</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">unbuffered</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nb">make</span><span class="p">(</span><span class="kd">chan</span><span class="w"> </span><span class="kt">int</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">buffered</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nb">make</span><span class="p">(</span><span class="kd">chan</span><span class="w"> </span><span class="kt">int</span><span class="p">,</span><span class="w"> </span><span class="mi">3</span><span class="p">)</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><table>
  <thead>
      <tr>
          <th>初始化方式</th>
          <th>状态</th>
          <th style="text-align: right"><code>len</code></th>
          <th style="text-align: right"><code>cap</code></th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>var ch chan T</code></td>
          <td>nil</td>
          <td style="text-align: right">0</td>
          <td style="text-align: right">0</td>
      </tr>
      <tr>
          <td><code>make(chan T)</code></td>
          <td>open、无缓冲</td>
          <td style="text-align: right">0</td>
          <td style="text-align: right">0</td>
      </tr>
      <tr>
          <td><code>make(chan T, n)</code></td>
          <td>open、有缓冲</td>
          <td style="text-align: right">0</td>
          <td style="text-align: right">n</td>
      </tr>
  </tbody>
</table>
<p>channel 的容量创建后不能改变。</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-go" data-lang="go"><span class="line"><span class="cl"><span class="nx">n</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="o">-</span><span class="mi">1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">ch</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nb">make</span><span class="p">(</span><span class="kd">chan</span><span class="w"> </span><span class="kt">int</span><span class="p">,</span><span class="w"> </span><span class="nx">n</span><span class="p">)</span><span class="w"> </span><span class="c1">// runtime panic: makechan: size out of range</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p><code>len(ch)</code> 的语义只限于返回当前缓冲区中的元素个数，不代表 channel 的完整状态。例如：</p>
<ul>
<li>对无缓冲 channel，<code>len(ch)</code> 恒为 0，但 send/receive 是否阻塞取决于是否有配对的接收/发送方正在等待，不能从 <code>len(ch)</code> 推断。</li>
<li>对有缓冲 channel，<code>len(ch)</code> 只是某一时刻的快照；检查它之后、执行 send/receive 之前，其他 goroutine 可能已经改变了 channel 状态。</li>
</ul>
<p>因此不能用 <code>len(ch)</code> 来判断下一次 send 或 receive 是否会阻塞。</p>
<h3 id="22-nil-channel">2.2 nil channel</h3>
<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></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="kd">var</span><span class="w"> </span><span class="nx">ch</span><span class="w"> </span><span class="kd">chan</span><span class="w"> </span><span class="kt">int</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">ch</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="mi">1</span><span class="w">   </span><span class="c1">// 永久阻塞</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">v</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="o">&lt;-</span><span class="nx">ch</span><span class="w"> </span><span class="c1">// 永久阻塞</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nb">close</span><span class="p">(</span><span class="nx">ch</span><span class="p">)</span><span class="w"> </span><span class="c1">// panic</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>nil channel 不等同于 closed channel：</p>
<ul>
<li>nil channel 永远无法完成通信。</li>
<li>closed channel 的接收操作立即完成。</li>
</ul>
<h3 id="23-无缓冲-channel">2.3 无缓冲 channel</h3>
<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-go" data-lang="go"><span class="line"><span class="cl"><span class="nx">ch</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nb">make</span><span class="p">(</span><span class="kd">chan</span><span class="w"> </span><span class="kt">int</span><span class="p">)</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><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></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="k">go</span><span class="w"> </span><span class="kd">func</span><span class="p">()</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">ch</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="mi">42</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">v</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="o">&lt;-</span><span class="nx">ch</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p><code>ch &lt;- 42</code> 只有在另一个 goroutine 已经或即将执行接收时才能完成。反方向也一样：接收方会等待发送方。</p>
<p>如果在同一个 goroutine 中发送后才接收：</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></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="nx">ch</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nb">make</span><span class="p">(</span><span class="kd">chan</span><span class="w"> </span><span class="kt">int</span><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">ch</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="mi">42</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="o">&lt;-</span><span class="nx">ch</span><span class="p">)</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><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></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">fatal error: all goroutines are asleep - deadlock!
</span></span></code></pre></td></tr></table>
</div>
</div><p>正确写法需要另一个 goroutine，或使用缓冲区。</p>
<h3 id="24-有缓冲-channel">2.4 有缓冲 channel</h3>
<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></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="nx">ch</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nb">make</span><span class="p">(</span><span class="kd">chan</span><span class="w"> </span><span class="kt">int</span><span class="p">,</span><span class="w"> </span><span class="mi">2</span><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">ch</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="c1">// 不阻塞</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">ch</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="mi">2</span><span class="w"> </span><span class="c1">// 不阻塞</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">ch</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="mi">3</span><span class="w"> </span><span class="c1">// 缓冲区已满，阻塞</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><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></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="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="o">&lt;-</span><span class="nx">ch</span><span class="p">)</span><span class="w"> </span><span class="c1">// 取出 1，释放一个位置</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>可以将其理解为固定容量队列，但 channel 还包含同步和唤醒语义。</p>
<h3 id="25-关闭-channel">2.5 关闭 channel</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></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="nb">close</span><span class="p">(</span><span class="nx">ch</span><span class="p">)</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><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></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="nx">ch</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nb">make</span><span class="p">(</span><span class="kd">chan</span><span class="w"> </span><span class="kt">int</span><span class="p">,</span><span class="w"> </span><span class="mi">2</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">ch</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="mi">10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">ch</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="mi">20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nb">close</span><span class="p">(</span><span class="nx">ch</span><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">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="o">&lt;-</span><span class="nx">ch</span><span class="p">)</span><span class="w"> </span><span class="c1">// 10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="o">&lt;-</span><span class="nx">ch</span><span class="p">)</span><span class="w"> </span><span class="c1">// 20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="o">&lt;-</span><span class="nx">ch</span><span class="p">)</span><span class="w"> </span><span class="c1">// 0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="o">&lt;-</span><span class="nx">ch</span><span class="p">)</span><span class="w"> </span><span class="c1">// 0</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>区分“发送了零值”和“channel 已排空并关闭”，可使用 comma-ok：</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></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="nx">v</span><span class="p">,</span><span class="w"> </span><span class="nx">ok</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="o">&lt;-</span><span class="nx">ch</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="k">if</span><span class="w"> </span><span class="p">!</span><span class="nx">ok</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="c1">// channel 已关闭且缓冲区已排空</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>缓冲区还有数据时，即使 channel 已关闭，<code>ok</code> 仍为 <code>true</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></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="nx">ch</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nb">make</span><span class="p">(</span><span class="kd">chan</span><span class="w"> </span><span class="kt">int</span><span class="p">,</span><span class="w"> </span><span class="mi">1</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">ch</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="mi">0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nb">close</span><span class="p">(</span><span class="nx">ch</span><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">v</span><span class="p">,</span><span class="w"> </span><span class="nx">ok</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="o">&lt;-</span><span class="nx">ch</span><span class="w"> </span><span class="c1">// 0, true：这是发送过的零值</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">v</span><span class="p">,</span><span class="w"> </span><span class="nx">ok</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="o">&lt;-</span><span class="nx">ch</span><span class="w">  </span><span class="c1">// 0, false：关闭后的零值</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><h2 id="3-特殊行为">3. 特殊行为</h2>
<h3 id="31-range-行为">3.1 range 行为</h3>
<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-go" data-lang="go"><span class="line"><span class="cl"><span class="k">for</span><span class="w"> </span><span class="nx">v</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="k">range</span><span class="w"> </span><span class="nx">ch</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">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">v</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></code></pre></td></tr></table>
</div>
</div><p>等价于反复接收，直到 channel 已关闭且缓冲区排空：</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="k">for</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">v</span><span class="p">,</span><span class="w"> </span><span class="nx">ok</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="o">&lt;-</span><span class="nx">ch</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="p">!</span><span class="nx">ok</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">break</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="w">    </span><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">v</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></code></pre></td></tr></table>
</div>
</div><p>如果发送方从不关闭 channel，接收方会在取完最后一个值后继续等待：</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="nx">ch</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nb">make</span><span class="p">(</span><span class="kd">chan</span><span class="w"> </span><span class="kt">int</span><span class="p">,</span><span class="w"> </span><span class="mi">1</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">ch</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="mi">1</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="k">for</span><span class="w"> </span><span class="nx">v</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="k">range</span><span class="w"> </span><span class="nx">ch</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">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">v</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="c1">// 取出 1 后阻塞，因为 ch 没有关闭</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>channel 不需要为了垃圾回收而关闭。只有接收方需要知道“不会再有数据”时才需要关闭。</p>
<h3 id="32-多发送方与多接收方">3.2 多发送方与多接收方</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><span class="lnt">15
</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="nx">ch</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nb">make</span><span class="p">(</span><span class="kd">chan</span><span class="w"> </span><span class="kt">int</span><span class="p">,</span><span class="w"> </span><span class="mi">1</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kd">var</span><span class="w"> </span><span class="nx">wg</span><span class="w"> </span><span class="nx">sync</span><span class="p">.</span><span class="nx">WaitGroup</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">wg</span><span class="p">.</span><span class="nf">Go</span><span class="p">(</span><span class="kd">func</span><span class="p">()</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">v</span><span class="p">,</span><span class="w"> </span><span class="nx">ok</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="o">&lt;-</span><span class="nx">ch</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;receiver 1:&#34;</span><span class="p">,</span><span class="w"> </span><span class="nx">v</span><span class="p">,</span><span class="w"> </span><span class="nx">ok</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="nx">wg</span><span class="p">.</span><span class="nf">Go</span><span class="p">(</span><span class="kd">func</span><span class="p">()</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">v</span><span class="p">,</span><span class="w"> </span><span class="nx">ok</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="o">&lt;-</span><span class="nx">ch</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;receiver 2:&#34;</span><span class="p">,</span><span class="w"> </span><span class="nx">v</span><span class="p">,</span><span class="w"> </span><span class="nx">ok</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="nx">ch</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="mi">42</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nb">close</span><span class="p">(</span><span class="nx">ch</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">wg</span><span class="p">.</span><span class="nf">Wait</span><span class="p">()</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><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></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">receiver 1: 42 true
</span></span><span class="line"><span class="cl">receiver 2: 0 false
</span></span></code></pre></td></tr></table>
</div>
</div><p>42 只被其中一个接收者拿到；另一个被 <code>close(ch)</code> 唤醒后得到零值。</p>
<p>关闭 channel 会唤醒所有阻塞的接收者：</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></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="nx">ch</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nb">make</span><span class="p">(</span><span class="kd">chan</span><span class="w"> </span><span class="kt">int</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kd">var</span><span class="w"> </span><span class="nx">wg</span><span class="w"> </span><span class="nx">sync</span><span class="p">.</span><span class="nx">WaitGroup</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="k">for</span><span class="w"> </span><span class="nx">i</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span><span class="w"> </span><span class="nx">i</span><span class="w"> </span><span class="p">&lt;</span><span class="w"> </span><span class="mi">3</span><span class="p">;</span><span class="w"> </span><span class="nx">i</span><span class="o">++</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="o">:=</span><span class="w"> </span><span class="nx">i</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nx">wg</span><span class="p">.</span><span class="nf">Go</span><span class="p">(</span><span class="kd">func</span><span class="p">()</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="p">,</span><span class="w"> </span><span class="nx">ok</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="o">&lt;-</span><span class="nx">ch</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nx">fmt</span><span class="p">.</span><span class="nf">Printf</span><span class="p">(</span><span class="s">&#34;receiver %d ok=%v\n&#34;</span><span class="p">,</span><span class="w"> </span><span class="nx">id</span><span class="p">,</span><span class="w"> </span><span class="nx">ok</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="c1">// 等待接收者都阻塞在 &lt;-ch 上（仅作示例，实际代码不要依赖 sleep）</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">time</span><span class="p">.</span><span class="nf">Sleep</span><span class="p">(</span><span class="mi">50</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="nx">time</span><span class="p">.</span><span class="nx">Millisecond</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nb">close</span><span class="p">(</span><span class="nx">ch</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">wg</span><span class="p">.</span><span class="nf">Wait</span><span class="p">()</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><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></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">receiver 0 ok=false
</span></span><span class="line"><span class="cl">receiver 1 ok=false
</span></span><span class="line"><span class="cl">receiver 2 ok=false
</span></span></code></pre></td></tr></table>
</div>
</div><p>缓冲数据仍然只会分配给其中某一个接收者；排空后，所有接收者都能立即得到零值和 <code>ok=false</code>。</p>
<p>多个发送方同时发送时，接收顺序与 goroutine 的启动顺序无关，因为实际调度是随机的。因此不能依赖代码中的书写顺序来判断实际接收顺序：</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-go" data-lang="go"><span class="line"><span class="cl"><span class="k">go</span><span class="w"> </span><span class="kd">func</span><span class="p">()</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">ch</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="p">}()</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">go</span><span class="w"> </span><span class="kd">func</span><span class="p">()</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">ch</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="mi">2</span><span class="w"> </span><span class="p">}()</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>可能先收到 1，也可能先收到 2。</p>
<p>一般约定是：</p>
<blockquote>
<p>创建和关闭 channel 的责任通常属于发送方；存在多个发送方时，应由一个协调者统一关闭。</p>
</blockquote>
<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></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="k">if</span><span class="w"> </span><span class="nx">shouldClose</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="nb">close</span><span class="p">(</span><span class="nx">ch</span><span class="p">)</span><span class="w"> </span><span class="c1">// 其他发送者可能仍在发送或也准备关闭</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>send</code> 与 <code>close</code> 没有安全保证，可能产生数据竞争或 <code>send on closed channel</code>。</p>
<h3 id="33-select-的边界行为">3.3 select 的边界行为</h3>
<p><code>select</code> 同时监听多个 channel 操作，哪个 case 就绪就执行哪个。下面是一个典型结构：</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></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="k">select</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">case</span><span class="w"> </span><span class="nx">v</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="o">&lt;-</span><span class="nx">input</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nf">use</span><span class="p">(</span><span class="nx">v</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">case</span><span class="w"> </span><span class="nx">output</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="nx">result</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">case</span><span class="w"> </span><span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</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></code></pre></td></tr></table>
</div>
</div><p>本节讨论 <code>select</code> 在 nil channel、closed channel 等边界条件下的行为。</p>
<h4 id="331-nil-channel-case-会被禁用">3.3.1 nil channel case 会被禁用</h4>
<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="kd">var</span><span class="w"> </span><span class="nx">ch</span><span class="w"> </span><span class="kd">chan</span><span class="w"> </span><span class="kt">int</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="k">select</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">case</span><span class="w"> </span><span class="o">&lt;-</span><span class="nx">ch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c1">// 永远不会选中</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">default</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c1">// 执行这里</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>这可以用于动态启用或禁用 case：</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-go" data-lang="go"><span class="line"><span class="cl"><span class="k">if</span><span class="w"> </span><span class="p">!</span><span class="nx">enabled</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">ch</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="kc">nil</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><h4 id="332-closed-channel-的接收-case-始终就绪">3.3.2 closed channel 的接收 case 始终就绪</h4>
<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="nb">close</span><span class="p">(</span><span class="nx">ch</span><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="k">select</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">case</span><span class="w"> </span><span class="nx">v</span><span class="p">,</span><span class="w"> </span><span class="nx">ok</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="o">&lt;-</span><span class="nx">ch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c1">// 立即执行，ok=false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">default</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></code></pre></td></tr></table>
</div>
</div><p>如果循环中的 select 没有处理 <code>ok</code>，closed channel 可能导致忙循环：</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="k">for</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">select</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">case</span><span class="w"> </span><span class="nx">v</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="o">&lt;-</span><span class="nx">ch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nf">process</span><span class="p">(</span><span class="nx">v</span><span class="p">)</span><span class="w"> </span><span class="c1">// ch 关闭后不断处理零值</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">default</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></code></pre></td></tr></table>
</div>
</div><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></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="k">case</span><span class="w"> </span><span class="nx">v</span><span class="p">,</span><span class="w"> </span><span class="nx">ok</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="o">&lt;-</span><span class="nx">ch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="p">!</span><span class="nx">ok</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></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>或者将它设为 nil，禁用这个 case：</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></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="k">case</span><span class="w"> </span><span class="nx">v</span><span class="p">,</span><span class="w"> </span><span class="nx">ok</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="o">&lt;-</span><span class="nx">ch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="p">!</span><span class="nx">ok</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">ch</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="kc">nil</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">continue</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></code></pre></td></tr></table>
</div>
</div><h4 id="333-向-closed-channel-发送的-case">3.3.3 向 closed channel 发送的 case</h4>
<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="nb">close</span><span class="p">(</span><span class="nx">ch</span><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="k">select</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">case</span><span class="w"> </span><span class="nx">ch</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="mi">1</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c1">// 如果该 case 被选中，会 panic</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">default</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></code></pre></td></tr></table>
</div>
</div><p><code>select</code> 不会替发送方安全地忽略 closed channel。必须通过所有权和同步保证发送时 channel 仍开放。</p>
<h4 id="334-多个-case-同时就绪">3.3.4 多个 case 同时就绪</h4>
<p>如果多个 case 同时可执行，Go 会选择其中一个，不能依赖固定优先级：</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></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="k">select</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">case</span><span class="w"> </span><span class="o">&lt;-</span><span class="nx">a</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">case</span><span class="w"> </span><span class="o">&lt;-</span><span class="nx">b</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></code></pre></td></tr></table>
</div>
</div><h4 id="335-所有-case-都不可执行">3.3.5 所有 case 都不可执行</h4>
<ul>
<li>有 <code>default</code>：立即执行 <code>default</code>。</li>
<li>没有 <code>default</code>：当前 goroutine 阻塞。</li>
<li>空 <code>select {}</code>：永久阻塞。</li>
</ul>
<h3 id="34-关闭作为广播信号">3.4 关闭作为广播信号</h3>
<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-go" data-lang="go"><span class="line"><span class="cl"><span class="nx">start</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nb">make</span><span class="p">(</span><span class="kd">chan</span><span class="w"> </span><span class="kd">struct</span><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="k">go</span><span class="w"> </span><span class="kd">func</span><span class="p">()</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="o">&lt;-</span><span class="nx">start</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nf">work</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="k">go</span><span class="w"> </span><span class="kd">func</span><span class="p">()</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="o">&lt;-</span><span class="nx">start</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nf">work</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="nb">close</span><span class="p">(</span><span class="nx">start</span><span class="p">)</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>关闭 channel 后，所有 <code>&lt;-start</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></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="nx">start</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="kd">struct</span><span class="p">{}{}</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>对于无缓冲 channel，一次发送只会唤醒一个接收者。</p>
<p><code>struct{}</code> 不携带实际数据，常用于纯信号 channel：</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-go" data-lang="go"><span class="line"><span class="cl"><span class="kd">chan</span><span class="w"> </span><span class="kd">struct</span><span class="p">{}</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><h3 id="35-channel-方向">3.5 channel 方向</h3>
<p>函数参数可以限制 channel 操作：</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="kd">func</span><span class="w"> </span><span class="nf">producer</span><span class="p">(</span><span class="nx">out</span><span class="w"> </span><span class="kd">chan</span><span class="o">&lt;-</span><span class="w"> </span><span class="kt">int</span><span class="p">)</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">out</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="mi">1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nb">close</span><span class="p">(</span><span class="nx">out</span><span class="p">)</span><span class="w"> </span><span class="c1">// send-only channel 可以关闭</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="nf">consumer</span><span class="p">(</span><span class="nx">in</span><span class="w"> </span><span class="o">&lt;-</span><span class="kd">chan</span><span class="w"> </span><span class="kt">int</span><span class="p">)</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">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="o">&lt;-</span><span class="nx">in</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></code></pre></td></tr></table>
</div>
</div><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></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="kd">func</span><span class="w"> </span><span class="nf">consumer</span><span class="p">(</span><span class="nx">in</span><span class="w"> </span><span class="o">&lt;-</span><span class="kd">chan</span><span class="w"> </span><span class="kt">int</span><span class="p">)</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">in</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="mi">1</span><span class="w">   </span><span class="c1">// 不能发送</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nb">close</span><span class="p">(</span><span class="nx">in</span><span class="p">)</span><span class="w"> </span><span class="c1">// 不能关闭 receive-only channel</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>方向限制主要用于表达所有权并防止误用。</p>
<h3 id="36-会引发-panic-的操作">3.6 会引发 panic 的操作</h3>
<p>panic 不是 channel 正常生命周期的一部分，而是错误使用导致的运行时异常。下面列出三类常见场景。</p>
<h4 id="361-向-closed-channel-发送">3.6.1 向 closed channel 发送</h4>
<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-go" data-lang="go"><span class="line"><span class="cl"><span class="nb">close</span><span class="p">(</span><span class="nx">ch</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">ch</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="c1">// panic: send on closed channel</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><h4 id="362-重复关闭">3.6.2 重复关闭</h4>
<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-go" data-lang="go"><span class="line"><span class="cl"><span class="nb">close</span><span class="p">(</span><span class="nx">ch</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nb">close</span><span class="p">(</span><span class="nx">ch</span><span class="p">)</span><span class="w"> </span><span class="c1">// panic: close of closed channel</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><h4 id="363-关闭-nil-channel">3.6.3 关闭 nil channel</h4>
<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-go" data-lang="go"><span class="line"><span class="cl"><span class="kd">var</span><span class="w"> </span><span class="nx">ch</span><span class="w"> </span><span class="kd">chan</span><span class="w"> </span><span class="kt">int</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nb">close</span><span class="p">(</span><span class="nx">ch</span><span class="p">)</span><span class="w"> </span><span class="c1">// panic: close of nil channel</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>这些是普通 runtime panic，可以通过 <code>recover</code> 捕获，但不应依赖 <code>recover</code> 管理 channel 生命周期。</p>
<p>与之相对，程序整体死锁会产生如下 fatal error：</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">fatal error: all goroutines are asleep - deadlock!
</span></span></code></pre></td></tr></table>
</div>
</div><p>它属于 runtime fatal error，不能用常规 <code>recover</code> 恢复。</p>
<h2 id="4-同步保证">4. 同步保证</h2>
<p>channel 不只是数据队列，也建立 goroutine 之间的 happens-before 关系。</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></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="kd">var</span><span class="w"> </span><span class="nx">value</span><span class="w"> </span><span class="kt">int</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">done</span><span class="w"> </span><span class="o">:=</span><span class="w"> </span><span class="nb">make</span><span class="p">(</span><span class="kd">chan</span><span class="w"> </span><span class="kd">struct</span><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="k">go</span><span class="w"> </span><span class="kd">func</span><span class="p">()</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">value</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="mi">42</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nb">close</span><span class="p">(</span><span class="nx">done</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="o">&lt;-</span><span class="nx">done</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">value</span><span class="p">)</span><span class="w"> </span><span class="c1">// 能观察到 value = 42</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>关闭 <code>done</code> 发生在接收方观察到关闭之前，因此前面的写入对接收方可见。</p>
<p>因此 channel 常用于任务完成通知。多个 goroutine 同时访问其他共享变量时，仍需要确保所有访问都被 channel、锁或其他同步机制正确排序。</p>
<h2 id="5-参考">5. 参考</h2>
<ul>
<li><a href="https://go.dev/ref/spec">The Go Programming Language Specification</a> — channel 类型、send/receive/close 的语义定义。</li>
<li><a href="https://go.dev/ref/mem">The Go Memory Model</a> — channel 建立的 happens-before 关系。</li>
<li><a href="https://go.dev/doc/effective_go#channels">Effective Go — Channels</a> — 官方推荐的 channel 使用模式。</li>
</ul>
<h2 id="6-延伸阅读">6. 延伸阅读</h2>
<ul>
<li><a href="https://go.dev/blog/codelab-share">Go Blog: Share Memory By Communicating</a> — channel 作为同步与通信工具的设计思路。</li>
<li><a href="https://go.dev/blog/pipelines">Go Blog: Go Concurrency Patterns: Pipelines and cancellation</a> — 多发送方/接收方、关闭责任等工程实践。</li>
<li><a href="https://go.dev/blog/advanced-go-concurrency-patterns">Go Blog: Advanced Go Concurrency Patterns</a> — <code>select</code>、nil channel 禁用 case 等模式。</li>
<li><a href="https://pkg.go.dev/golang.org/x/sync/errgroup"><code>golang.org/x/sync/errgroup</code></a> — 带错误收集和取消的 goroutine 组。</li>
<li><a href="https://github.com/sourcegraph/conc"><code>github.com/sourcegraph/conc</code></a> — 提供 <code>WaitGroup</code>、<code>Pool</code> 等结构化并发工具（Go 1.20+）。</li>
</ul>
]]></description>
    </item>
  </channel>
</rss>
