<rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0">
<channel>
<atom:link href="https://liuyaowen.cn/feed" rel="self" type="application/rss+xml"/>
<title>刘耀文</title>
<link>https://liuyaowen.cn</link>
<description>刘耀文个人网站，聚焦技术分享、项目展示与成长记录，涵盖前端开发、人工智能、个人作品集等内容，致力于打造专业、有温度的开发者主页。</description>
<language>zh-CN</language>
<copyright>© 刘耀文 </copyright>
<pubDate>Tue, 04 Aug 2026 22:02:47 GMT</pubDate>
<generator>Mix Space CMS (https://github.com/mx-space)</generator>
<docs>https://mx-space.js.org</docs>
<image>
    <url>https://avatars.githubusercontent.com/u/55525531?v=4</url>
    <title>刘耀文</title>
    <link>https://liuyaowen.cn</link>
</image>
<item>
    <title>读《LobeHub 性能与 DX 优化》：把成本从每次渲染挪到只发生一次</title>
    <link>https://liuyaowen.cn/posts/default/lobehub-performance-dx-optimization-analysis</link>
    <pubDate>Fri, 31 Jul 2026 11:50:59 GMT</pubDate>
    <description>本文是对 innei.in/posts/tech/lobehub-performance-dx-op</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/lobehub-performance-dx-optimization-analysis'>https://liuyaowen.cn/posts/default/lobehub-performance-dx-optimization-analysis</a></blockquote>
          <blockquote>
<p>本文是对 <a href="https://innei.in/posts/tech/lobehub-performance-dx-optimization">innei.in/posts/tech/lobehub-performance-dx-optimization</a> 一文的分析。原文以 LobeHub 这个 ChatGPT/LLM 客户端项目为例，介绍了它在前端组件、Electron 主进程、Next.js 开发体验等多个层面做的一系列优化。本文不重复原文叙述，而是把里面散落的具体改动归纳成一个可以迁移的工程框架：<strong>四类成本 × 成本位置转移</strong>。</p>
</blockquote>
<h2>相关笔记</h2>
<ul>
<li>[[笔记/web/Java/JVM 原理、性能调优与故障排查]]：JVM 调优和本文思路类似，都在做&quot;把高频小成本摊薄&quot;。</li>
</ul>
<h2>1. 先建立一个直觉：基础设施小成本会被组件数量放大</h2>
<p>LobeHub 这一系列优化的共同前提是：<strong>单次操作 0.1 ms 不算贵，但乘以组件数量之后是另一回事</strong>。</p>
<p>文章里给出一个粗略的乘法：</p>
<pre><code class="language-text">0.1 ms × 500 个组件 = 50 ms</code></pre><p>流畅渲染的帧预算是 16.7 ms。一旦基础设施里的某个微小操作变成了&quot;每个组件渲染时都要做一次&quot;，再乘以一两百个组件，就会从&quot;可以忽略&quot;变成&quot;明显卡顿&quot;。</p>
<p>这个直觉很重要。它解释了为什么 LobeHub 优化的是<strong>普通读者看上去根本不起眼的东西</strong>：</p>
<ul>
<li>一个 <code>&lt;Flexbox&gt;</code> 组件函数本身的执行；</li>
<li>一次 <code>useStyles()</code> Hook 调用；</li>
<li>一次 Tooltip 的浮层初始化；</li>
<li>一个平时不可见的 Modal 在 React 树里挂着的 Hook。</li>
</ul>
<p>这些都是&quot;基础设施层小动作&quot;。优化单个不会让某个页面从 1000 ms 降到 10 ms，但合起来，可以让一个打开首页就要 mount 几百个组件的应用变得明显顺畅。</p>
<p>接下来所有的具体改动，都可以理解为：<strong>把这个 0.1 ms 的小动作从&quot;每次都发生&quot;挪到&quot;只发生一次&quot;</strong>，或者挪到&quot;真正需要时再发生&quot;。</p>
<h2>3. 替换 react-layout-kit：把 CSS-in-JS 的运行时挪到模块加载</h2>
<p><code>react-layout-kit</code> 的 <code>Flexbox</code> 看起来只是一个布局组件，但在 antd-style / Emotion 体系下，它内部仍然要：</p>
<pre><code class="language-text">组件执行
  ↓
读取 props
  ↓
构造样式对象
  ↓
序列化成 CSS 字符串
  ↓
计算 Hash
  ↓
查询 Emotion Cache
  ↓
没有命中则插入 CSS 规则
  ↓
返回 className</code></pre><p>五种不同 props 组合：</p>
<pre><code class="language-jsx">&lt;Flexbox gap={8} /&gt;
&lt;Flexbox gap={12} /&gt;
&lt;Flexbox gap={16} /&gt;
&lt;Flexbox gap={8} align="center" /&gt;
&lt;Flexbox gap={8} justify="center" /&gt;</code></pre><p>每种都可能生成不同的 CSS：</p>
<pre><code class="language-css">.css-a1 { display: flex; gap: 8px; }
.css-b2 { display: flex; gap: 12px; }
.css-c3 { display: flex; gap: 16px; }</code></pre><p>就算命中缓存，前面仍然要：执行组件函数、构造样式参数、做对象分配、做序列化、做 Hash 计算和查询。开发环境下还要保存调试信息和 source map，内存问题会更突出。</p>
<h3>新方案：所有实例共享一份 CSS</h3>
<p>LobeHub 自己写了一个内部 <code>Flex</code>，保持原 API，但底层换成固定类：</p>
<pre><code class="language-css">:where(.lobe-flex) {
  --lobe-flex: 0 1 auto;
  --lobe-flex-direction: column;
  --lobe-flex-wrap: nowrap;
  --lobe-flex-justify: flex-start;
  --lobe-flex-align: stretch;
  --lobe-flex-width: auto;
  --lobe-flex-height: auto;
  --lobe-flex-padding: 0;
  --lobe-flex-gap: 0;

  display: flex;
  flex: var(--lobe-flex);
  flex-direction: var(--lobe-flex-direction);
  flex-wrap: var(--lobe-flex-wrap);
  justify-content: var(--lobe-flex-justify);
  align-items: var(--lobe-flex-align);
  width: var(--lobe-flex-width);
  height: var(--lobe-flex-height);
  padding: var(--lobe-flex-padding);
  gap: var(--lobe-flex-gap);
}</code></pre><p>组件只把 props 映射成 CSS 变量：</p>
<pre><code class="language-jsx"><div>{children}</div></code></pre><p>效果是：</p>
<pre><code class="language-text">gap=8   → 共享 .lobe-flex + 设置 --lobe-flex-gap: 8px
gap=12  → 共享 .lobe-flex + 设置 --lobe-flex-gap: 12px
gap=16  → 共享 .lobe-flex + 设置 --lobe-flex-gap: 16px</code></pre><p>省掉的成本：</p>
<ul>
<li>不再为每个 gap 值生成新 Hash 类名；</li>
<li>不再向 <code>&lt;style&gt;</code> 持续插入规则；</li>
<li>不再需要 Emotion Cache 查询。</li>
</ul>
<p>新增的成本：</p>
<ul>
<li>每次渲染需要构造一个小的 <code>cssVars</code> 对象；</li>
<li>拼一下 <code>className</code>。</li>
</ul>
<p>后者明显比前者便宜。文章里指出，单组件开销大约 0.1 ms，真正严重的是几百个组件 × 0.1 ms。</p>
<h3>CSS 变量默认值为什么要写在每个 Flex 上</h3>
<p>CSS 自定义属性默认会继承。假设：</p>
<pre><code class="language-html"><div>
  <div></div>
</div></code></pre><p>子元素没有显式定义 <code>--lobe-flex-gap</code>，可能继承父元素的 16px，子布局就多出一段间距。</p>
<p>所以内部 CSS 在每个 <code>.lobe-flex</code> 上都重置默认值：</p>
<pre><code class="language-css">:where(.lobe-flex) {
  --lobe-flex-gap: 0;
  --lobe-flex-direction: column;
  --lobe-flex-align: stretch;
}</code></pre><p>这一层默认值是必要成本。读到类似的&quot;用 CSS 变量驱动组件样式&quot;代码时，可以直接检查是不是每一层都重置了默认值。</p>
<h2>5. 首页常驻：React Activity 与&quot;访问后缓存&quot;</h2>
<p>LobeHub 的首页（<code>DesktopHomeLayout</code>）里塞了很多东西：</p>
<ul>
<li>侧边栏；</li>
<li>最近记录；</li>
<li>输入区域；</li>
<li>技能列表；</li>
<li>多个弹窗入口；</li>
<li>大量 store selector；</li>
<li>数据恢复逻辑。</li>
</ul>
<p>旧路由结构大致是：</p>
<pre><code class="language-jsx">&lt;Route path="/" element={&lt;Home /&gt;} /&gt;
&lt;Route path="/settings" element={&lt;Settings /&gt;} /&gt;</code></pre><p>进设置页再回首页，要走完整的卸载-重建链路：</p>
<pre><code class="language-text">Home Fiber 被卸载
  ↓
Home DOM 被删除
  ↓
Home Effect 被清理
  ↓
Home 子组件状态丢失
  ↓
返回时：
重新执行 Home
  ↓
重新执行所有子组件
  ↓
重新运行所有 Hook 和 selector
  ↓
重新创建 Fiber
  ↓
重新创建 DOM
  ↓
重新插入 DOM
  ↓
重新计算布局和绘制</code></pre><p>文章测得 <code>DesktopHomeLayout</code> 返回时接近 500 ms。</p>
<h3>改法：把首页移出路由分支</h3>
<p>改造后的结构大致是：</p>
<pre><code class="language-jsx">&lt;DesktopLayoutContainer&gt;
  &lt;DesktopHomeLayout&gt;
    &lt;DesktopHome /&gt;
  &lt;/DesktopHomeLayout&gt;
  &lt;Suspense fallback={&lt;Loading /&gt;}&gt;
    &lt;Outlet /&gt;
  &lt;/Suspense&gt;
&lt;/DesktopLayoutContainer&gt;</code></pre><p>原来 index route 不再单独创建首页组件。</p>
<p>首页内部根据路径判断：</p>
<pre><code class="language-tsx">const { pathname } = useLocation();
const isHomeRoute = pathname === '/';

&lt;Activity mode={isHomeRoute ? 'visible' : 'hidden'}&gt;
  &lt;Home /&gt;
&lt;/Activity&gt;</code></pre><p><code>Activity</code> 在 <code>mode=&quot;hidden&quot;</code> 时的行为：</p>
<ul>
<li><code>display: none</code> 隐藏内容；</li>
<li>保留组件的 React State；</li>
<li>保留对应 DOM；</li>
<li>清理组件的 Effect 和订阅；</li>
<li>隐藏期间仍可能以低优先级响应新 props；</li>
<li>再次显示时恢复原状态、重新创建 Effect。</li>
</ul>
<p>可以理解为：</p>
<pre><code class="language-text">Fiber、State、DOM：保留
Effect、订阅：清理
视觉：display: none</code></pre><p>返回时主要工作变成：</p>
<pre><code class="language-text">hidden → visible
  ↓
恢复 Effect
  ↓
浏览器重新展示已有 DOM</code></pre><p>不再重建整个 DOM 树。</p>
<h3>hasActivated：访问后缓存，不是启动时预渲染</h3>
<p>实际代码里加了：</p>
<pre><code class="language-tsx">const [hasActivated, setHasActivated] = useState(isHomeRoute);
useEffect(() =&gt; {
  if (isHomeRoute) setHasActivated(true);
}, [isHomeRoute]);

if (!hasActivated) return null;</code></pre><p>逻辑是：</p>
<pre><code class="language-text">用户从未访问首页 → 不创建首页
用户第一次访问首页 → 创建首页
用户以后离开首页 → 隐藏但保留</code></pre><p>这是&quot;访问后缓存&quot;，不是&quot;应用启动时无条件预渲染&quot;。否则用户直接进 <code>/settings</code>，应用也会在后台把沉重的首页 mount 一遍，反而增加启动时间和内存。</p>
<h3>绝对定位和代价</h3>
<p>常驻首页容器使用：</p>
<pre><code class="language-tsx">style={{
  inset: 0,
  position: 'absolute',
}}</code></pre><p>因为首页已经不属于正常路由输出，要避免它参与其他页面的文档流布局。首页可见时覆盖主内容，隐藏时由 Activity 设为不可见。</p>
<p>这种优化的代价是<strong>常驻占用</strong>：</p>
<ul>
<li>Fiber 内存；</li>
<li>DOM 内存；</li>
<li>组件 State；</li>
<li>缓存数据；</li>
<li>浏览器布局对象。</li>
</ul>
<p>适合的场景：</p>
<ul>
<li>高频返回的首页；</li>
<li>编辑器；</li>
<li>聊天会话；</li>
<li>需要保留输入状态的页面。</li>
</ul>
<p>不适合的场景：</p>
<ul>
<li>很少返回的页面；</li>
<li>DOM 特别庞大的报表；</li>
<li>大量 <code>&lt;video&gt;</code> / <code>&lt;audio&gt;</code> / <code>&lt;iframe&gt;</code> 页面。</li>
</ul>
<p><code>&lt;video&gt;</code>、<code>&lt;audio&gt;</code>、<code>&lt;iframe&gt;</code> 隐藏后 DOM 仍存在，可能继续执行自身行为，需要在 Effect 清理函数里显式暂停或释放。</p>
<h2>7. Tooltip 单例化：共享一个浮层</h2>
<p>旧 Tooltip 的每个实例都可能拥有：</p>
<ul>
<li><code>useFloating</code> 定位逻辑；</li>
<li>鼠标进入/离开定时器；</li>
<li>Focus 和 Hover 交互 Hook；</li>
<li>Portal；</li>
<li>Arrow 定位；</li>
<li>Open State；</li>
<li>Motion 动画；</li>
<li><code>ResizeObserver</code>；</li>
<li>Scroll 监听；</li>
<li>自动更新位置逻辑。</li>
</ul>
<p>页面里如果有 200 个图标各自包了 Tooltip：</p>
<pre><code class="language-jsx">&lt;Tooltip title="编辑"&gt;&lt;Button /&gt;&lt;/Tooltip&gt;
&lt;Tooltip title="删除"&gt;&lt;Button /&gt;&lt;/Tooltip&gt;
&lt;Tooltip title="复制"&gt;&lt;Button /&gt;&lt;/Tooltip&gt;</code></pre><p>即使都没打开 Tooltip，每个实例的触发器和 Hook 仍然要创建。文章里那个 <code>TooltipFloating</code> 接近 200 行，包含 Floating UI、debounce、<code>AnimatePresence</code>、动态 transformOrigin、内容切换动画、箭头逻辑、Portal 和布局动画。</p>
<h3>单例 Tooltip 的结构</h3>
<pre><code class="language-jsx">&lt;TooltipGroup&gt;
  &lt;Tooltip title="编辑"&gt;&lt;Button /&gt;&lt;/Tooltip&gt;
  &lt;Tooltip title="删除"&gt;&lt;Button /&gt;&lt;/Tooltip&gt;
  &lt;Tooltip title="复制"&gt;&lt;Button /&gt;&lt;/Tooltip&gt;
&lt;/TooltipGroup&gt;</code></pre><p>每个 Tooltip 只注册：</p>
<pre><code class="language-text">触发节点
title
placement
回调</code></pre><p>整个 Group 共享一个真正浮层：</p>
<pre><code class="language-text">一个 Root
一个 Portal
一个 Floating Content
一个 Arrow
一套定位状态</code></pre><p>鼠标进入某个触发器后，只更新当前 payload：</p>
<pre><code class="language-ts">activeItem = {
  title: '删除',
  anchor: deleteButton,
  placement: 'top',
};</code></pre><p>浮层被重新定位到新 anchor，而不是每个按钮都创建完整的浮层组件树。</p>
<p>实际实现基于 Base UI 的 <code>&lt;BaseTooltip.Root handle={handle}&gt;</code>，由 Group Context 共享 <code>handle</code>。普通 Tooltip 发现自己位于 Group 内部就进入共享模式；受控 Tooltip、带 <code>defaultOpen</code> 或显式 <code>standalone</code> 的 Tooltip 保留独立实现。</p>
<h3>为什么 Base UI 比完整 UI 库更容易优化</h3>
<p>Base UI 属于 Headless 组件，主要提供：</p>
<ul>
<li>可访问性；</li>
<li>交互状态；</li>
<li>定位；</li>
<li>键盘行为；</li>
<li>ARIA；</li>
<li>基础 Portal。</li>
</ul>
<p>样式和动画由项目自己控制。对比完整组件库，可以少承担：</p>
<ul>
<li>兼容层；</li>
<li>主题包装；</li>
<li>多层 Context；</li>
<li>默认 DOM；</li>
<li>默认动画；</li>
<li>样式注入逻辑。</li>
</ul>
<p>更准确的表述不是&quot;Ant Design 慢、Base UI 快&quot;，而是 <strong>&quot;LobeHub 能基于 Base UI 实现更小的组件树和共享浮层&quot;</strong>。是否值得换基础库要看你自己的项目里这种场景有多少。</p>
<h2>9. Electron 包体积与原生模块三件事</h2>
<p>Electron 主进程原本可能直接依赖：</p>
<pre><code class="language-ts">import somePackage from 'some-package';</code></pre><p>如果构建工具把依赖全部 externalize，产物里只保留 <code>require(&#39;some-package&#39;)</code>，运行时必须携带完整 <code>node_modules/some-package</code>。整个 <code>node_modules</code> 还包含：</p>
<ul>
<li>未使用文件；</li>
<li>README；</li>
<li>类型声明；</li>
<li>Source Map；</li>
<li>多模块格式；</li>
<li>测试代码；</li>
<li>可选资源；</li>
<li>重复依赖。</li>
</ul>
<h3>改为打包普通 JS 依赖</h3>
<p>优化后的 electron-vite 不再把所有依赖 externalize。普通 JavaScript 包会被 Rollup 打入：</p>
<pre><code class="language-text">dist/main/index.js
dist/preload/index.js</code></pre><p>Rollup 顺手做：</p>
<ul>
<li>Tree Shaking；</li>
<li>去重；</li>
<li>压缩；</li>
<li>内联依赖；</li>
<li>删除未引用代码。</li>
</ul>
<p>electron-builder 配置：</p>
<pre><code class="language-js">files: [
  'dist',
  'resources',
  '!node_modules',
  ...getFilesPatterns(),
]</code></pre><p>整体排除 <code>node_modules</code>，只重新加入必须保留的原生依赖。</p>
<h3>原生模块不能一起 Bundle</h3>
<p>比如：</p>
<pre><code class="language-text">better-sqlite3
sharp
node-mac-permissions</code></pre><p>这些包可能带 <code>.node</code> 文件，是本机动态库，不是 JavaScript。Node.js 加载它需要真实文件路径：</p>
<pre><code class="language-cpp">process.dlopen(...)</code></pre><p>所以原生模块要同时满足三件事：</p>
<p><strong>Rollup 不打包它</strong></p>
<pre><code class="language-js">rollupOptions: {
  external: getExternalDependencies(),
}</code></pre><p>生成结果仍然保留 <code>require(&#39;node-mac-permissions&#39;)</code>。</p>
<p><strong>electron-builder 把它放进安装包</strong></p>
<pre><code class="language-js">files: [...getFilesPatterns()]</code></pre><p><strong>从 ASAR 中解包</strong></p>
<pre><code class="language-js">asarUnpack: getAsarUnpackPatterns()</code></pre><p>最终文件落在 <code>app.asar.unpacked/node_modules/...</code>。</p>
<p>项目还会递归读取原生模块的 <code>package.json</code>，把它的传递依赖一并加入，否则原生模块本身存在，但启动时仍可能因为缺少子依赖而失败。</p>
<p>三项必须一致：</p>
<pre><code class="language-text">external 列表 = builder include 列表 = asarUnpack 列表</code></pre><p>少任何一项都可能&quot;构建成功，运行时失败&quot;。这是 Electron 打包最容易被新人踩的坑。</p>
<h3>Electron 语言资源删除</h3>
<p>Electron Framework 自带 Chromium 本地化资源：</p>
<pre><code class="language-text">en.lproj
zh_CN.lproj
ja.lproj
fr.lproj</code></pre><p>这些是 Chromium 原生界面资源，不是应用自己的 i18n JSON。</p>
<p>构建完成后的 <code>afterPack</code> Hook 遍历：</p>
<pre><code class="language-text">Electron Framework.framework/
  Versions/A/Resources/*.lproj</code></pre><p>只保留英语相关目录：</p>
<pre><code class="language-js">new Set(['en', 'en_GB', 'en-US', 'en_US']);</code></pre><p>不能全部删掉，Electron Framework 仍需要基本本地化资源。</p>
<h2>11. IPC 类型安全：从字符串到 Controller 注册表</h2>
<p>旧写法：</p>
<pre><code class="language-ts">ipcRenderer.invoke('minimizeWindow', params);
ipcMain.handle('minimize-window', handler);</code></pre><p>拼写不一致、TS 检查不出来、通道名 / 参数 / 返回值 / 主进程实现 / 渲染进程调用彼此独立。重构主进程方法时，调用方可能仍然编译通过，运行时才报错。</p>
<h3>Controller + 装饰器</h3>
<p>主进程改成：</p>
<pre><code class="language-ts">export default class BrowserWindowsCtr extends ControllerModule {
  static override readonly groupName = 'windows';

  @IpcMethod()
  minimizeWindow() {
    BrowserWindow.getFocusedWindow()?.minimize();
    return { success: true };
  }
}</code></pre><p>通道名自动推导为 <code>windows.minimizeWindow</code>。Controller 统一注册到 <code>controllerIpcConstructors</code>，既用于运行时安装 IPC Handler，也用于 TypeScript 提取服务类型。</p>
<h3>渲染进程用 Proxy</h3>
<pre><code class="language-ts">const ipc = ensureElectronIpc();
await ipc.windows.minimizeWindow();</code></pre><p><code>ensureElectronIpc()</code> 可以用 JavaScript Proxy 收集属性访问：</p>
<pre><code class="language-ts">ipc.windows.minimizeWindow()
  ↓
window.electronAPI.invoke('windows.minimizeWindow')</code></pre><p>TypeScript 类型来自 Controller 注册表，能推导：</p>
<ul>
<li>可用 Controller；</li>
<li>可用方法；</li>
<li>参数；</li>
<li>返回值。</li>
</ul>
<p>但 TS 类型只在编译期存在。IPC 是跨进程边界，渲染进程可能被攻击，主进程仍然要做运行时参数校验，不能只依赖 TS。</p>
<h2>13. Next.js 到 Vite：拆分前后端构建职责</h2>
<p>LobeHub 的方案不是简单&quot;删 Next.js&quot;，而是拆分职责：</p>
<pre><code class="language-text">Vite
  ├─ React SPA
  ├─ Desktop UI
  ├─ Mobile UI
  ├─ React Router
  └─ 前端开发服务器

Next.js
  ├─ API Route
  ├─ Auth
  ├─ 服务端逻辑
  ├─ Serverless 部署
  └─ 必要的服务端入口</code></pre><p>开发纯 UI 时只启动 Vite，不启动完整 Next.js App Router、RSC、服务端打包、路由分析流程。</p>
<h3>大型 Next.js 项目开发内存较高的原因</h3>
<p>App Router 项目开发时可能同时维护：</p>
<ul>
<li>客户端模块图；</li>
<li>服务端模块图；</li>
<li>RSC 模块图；</li>
<li>路由树；</li>
<li>Server Action；</li>
<li>Route Handler；</li>
<li>中间件；</li>
<li>热更新状态；</li>
<li>Source Map；</li>
<li>构建缓存。</li>
</ul>
<p>同一个模块还可能存在客户端版本、服务端版本、RSC 引用版本。Vite 纯 SPA 模式主要维护浏览器端 ESM 图，按请求转换模块，只开发 UI 时进程模型简单很多。</p>
<p>文章里的 <code>Next.js: 10 GB+</code>，<code>Vite: 约 1 GB</code> 是 LobeHub 项目自身的实验结果，不是所有项目都会固定相差十倍。项目规模、插件、Source Map、依赖树、Node.js 版本都会影响。</p>
<h3>迁移代价</h3>
<p>迁移后要重新处理：</p>
<ul>
<li>App Router 到 React Router 的路由映射；</li>
<li>SSR 和 SEO；</li>
<li>服务端配置向前端注入；</li>
<li>鉴权和路由守卫；</li>
<li>Next API 的代理；</li>
<li>Vite 与 Next 两个开发服务器；</li>
<li>静态资源路径；</li>
<li>Electron 和 Web 的构建差异；</li>
<li>RSC 组件边界。</li>
</ul>
<p>这属于架构级 DX 优化，不是简单地把 <code>next dev</code> 改成 <code>vite</code>。如果当前 Next.js 用得很顺，未必值得搬。</p>
<h2>参考</h2>
<p>[1] LobeHub 性能与 DX 优化. innei. <a href="https://innei.in/posts/tech/lobehub-performance-dx-optimization">https://innei.in/posts/tech/lobehub-performance-dx-optimization</a></p>
<p>[2] antd-style createStaticStyles. antd-style. <a href="https://github.com/ant-design/antd-style">https://github.com/ant-design/antd-style</a></p>
<p>[3] Base UI. MUI. <a href="https://base-ui.com/">https://base-ui.com/</a></p>
<p>[4] React Activity (Offscreen Component). React. <a href="https://react.dev/blog/2025/04/23/react-19-updates">https://react.dev/blog/2025/04/23/react-19-updates</a></p>
<p>[5] class-variance-authority. <a href="https://github.com/joebell.co.uk/cva">https://github.com/joebell.co.uk/cva</a></p>
<p>[6] electron-vite. <a href="https://electron-vite.org/">https://electron-vite.org/</a></p>
<p>[7] electron-builder. <a href="https://www.electron.build/">https://www.electron.build/</a></p>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/lobehub-performance-dx-optimization-analysis#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">165065403934969856</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title>Spring Boot ConfigData 源码解析：配置资源发现、激活上下文、Profile 推导、递归 Import 与 ContributorIterator 后序遍历</title>
    <link>https://liuyaowen.cn/posts/default/spring-boot-config-data</link>
    <pubDate>Thu, 30 Jul 2026 18:29:08 GMT</pubDate>
    <description>阅读说明

本文以 Spring Boot 2.7.18 的实现为基线，沿着 ConfigDataE</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/spring-boot-config-data'>https://liuyaowen.cn/posts/default/spring-boot-config-data</a></blockquote>
          <h2>阅读说明</h2>
<p>本文以 Spring Boot 2.7.18 的实现为基线，沿着 <code>ConfigDataEnvironment.processAndApply()</code> 的真实执行链路展开。</p>
<p>文章中的代码主要保留方法名、条件和数据结构，省略日志、异常包装和与主线无关的细节，便于观察控制流。</p>
<p>全文严格区分五个动作：</p>
<ul>
<li>resolve：把 <code>ConfigDataLocation</code> 解析为 <code>ConfigDataResource</code>。</li>
<li>load：<code>Loader</code> 把 Resource 读取为 <code>ConfigData</code> 和 <code>PropertySource</code>。</li>
<li>bind：从单个 <code>PropertySource</code> 绑定 <code>spring.config.import</code> 与 <code>spring.config.activate</code>。</li>
<li>active：Contributor 在当前 <code>ActivationContext</code> 下满足激活条件。</li>
<li>apply：最终把有效的 <code>PropertySource</code> 加入真实 <code>Environment</code>。</li>
</ul>
<p>ConfigData 不是&quot;读取完 <code>application.yml</code> 就结束&quot;的文件加载器，而是一个分阶段求解配置依赖图的过程。每一阶段都会持续展开当前能够确定的 import，直到 Contributor Tree 达到稳定状态。Profile 确定后，再补充 profile-specific 资源，最后统一应用到 Environment。</p>
<h2>2. 启动入口与初始 Contributor Tree</h2>
<p>ConfigData 的执行入口位于 Environment 准备阶段。</p>
<p><code>SpringApplication</code> 在创建和刷新 <code>ApplicationContext</code> 之前，先准备 <code>ConfigurableEnvironment</code>。<code>EnvironmentPostProcessorApplicationListener</code> 调用 <code>ConfigDataEnvironmentPostProcessor</code>，后者构造 <code>ConfigDataEnvironment</code> 并执行 <code>processAndApply()</code>。</p>
<p>此时以下工作均尚未开始：</p>
<ul>
<li>BeanDefinition 扫描；</li>
<li>自动配置类解析；</li>
<li>单例 Bean 创建；</li>
<li><code>ApplicationContext</code> 刷新。</li>
</ul>
<p>入口调用链如下：</p>
<pre><code class="language-text">SpringApplication.run()
  └─ prepareEnvironment()
      └─ EnvironmentPostProcessorApplicationListener
          └─ ConfigDataEnvironmentPostProcessor.postProcessEnvironment()
              └─ ConfigDataEnvironment.processAndApply()</code></pre><h3>2.1 初始 Contributor 的来源</h3>
<p><code>ConfigDataEnvironment</code> 构造时，会读取当前 <code>Environment</code> 中已经存在的 <code>PropertySource</code>，并将它们包装成 <code>Kind.EXISTING</code> 的 Contributor。</p>
<p>这些来源通常包括：</p>
<ul>
<li><code>commandLineArgs</code></li>
<li><code>systemProperties</code></li>
<li><code>systemEnvironment</code></li>
<li><code>defaultProperties</code></li>
</ul>
<p>它们参与早期 Binder 查询和占位符解析，但不会被 ConfigData 机制再次导入。</p>
<p>随后，框架根据以下配置创建 <code>INITIAL_IMPORT</code> 节点：</p>
<ul>
<li><code>spring.config.import</code></li>
<li><code>spring.config.additional-location</code></li>
<li><code>spring.config.location</code></li>
</ul>
<p>若没有显式配置 <code>spring.config.location</code>，则使用默认搜索位置，例如：</p>
<pre><code class="language-text">classpath:/
classpath:/config/
file:./
file:./config/
file:./config/*/</code></pre><p>初始位置采用 Contributor 表示，而不是立即调用 Resolver，是为了让初始位置和后续 <code>spring.config.import</code> 位置都经过相同的处理流程：</p>
<pre><code class="language-text">选择待处理节点
    ↓
resolveAndLoad
    ↓
生成子 Contributor
    ↓
替换不可变树</code></pre><p>初始树的逻辑结构如下：</p>
<pre><code class="language-text">ROOT
├─ EXISTING: commandLineArgs
├─ EXISTING: systemProperties
├─ EXISTING: systemEnvironment
├─ INITIAL_IMPORT: spring.config.import
├─ INITIAL_IMPORT: spring.config.additional-location
├─ INITIAL_IMPORT: spring.config.location / 默认搜索位置
└─ EXISTING: defaultProperties</code></pre><p><code>INITIAL_IMPORT</code> 不对应某个已经加载的文件，也不贡献 <code>PropertySource</code>。它只持有一组 <code>ConfigDataLocation</code>，用于在 <code>withProcessedImports()</code> 中触发第一次 <code>resolveAndLoad()</code>。</p>
<h2>4. 贯穿全文的配置样例</h2>
<p>下面的配置同时包含：</p>
<ul>
<li>无条件 import；</li>
<li>CloudPlatform 条件；</li>
<li>Profile 条件；</li>
<li>递归 import；</li>
<li>profile-specific 文件。</li>
</ul>
<p>假定运行环境最终被识别为 Kubernetes，基础文档激活 dev Profile。</p>
<p><code>application.yml</code> 的文档 0：</p>
<pre><code class="language-yaml">spring:
  profiles:
    active: dev
  config:
    import: classpath:common.yml
app:
  source: base</code></pre><p><code>application.yml</code> 的文档 1：</p>
<pre><code class="language-yaml">spring:
  config:
    activate:
      on-cloud-platform: kubernetes
    import: classpath:k8s.yml
platform:
  mode: kubernetes</code></pre><p><code>application.yml</code> 的文档 2：</p>
<pre><code class="language-yaml">spring:
  config:
    activate:
      on-profile: dev
    import: classpath:feature.yml
feature:
  mode: dev-document</code></pre><p><code>common.yml</code>：</p>
<pre><code class="language-yaml">spring:
  config:
    import: classpath:db.yml
common:
  enabled: true</code></pre><p><code>db.yml</code>：</p>
<pre><code class="language-yaml">db:
  url: jdbc:mysql://localhost/base</code></pre><p><code>k8s.yml</code>：</p>
<pre><code class="language-yaml">platform:
  service-discovery: dns</code></pre><p><code>feature.yml</code>：</p>
<pre><code class="language-yaml">feature:
  enabled: true</code></pre><p><code>application-dev.yml</code>：</p>
<pre><code class="language-yaml">spring:
  config:
    import: classpath:dev-db.yml
app:
  source: application-dev</code></pre><p><code>dev-db.yml</code>：</p>
<pre><code class="language-yaml">db:
  pool-size: 20</code></pre><p>这里必须区分&quot;文件&quot;和&quot;文档&quot;。</p>
<p><code>application.yml</code> 会被 Loader 一次性读取，但其中三个 YAML 文档会成为三个独立 <code>PropertySource</code>，随后生成三个独立 Contributor。</p>
<p>每个 Contributor 都有自己的：imports、activate、active 状态、children。</p>
<p>因此，一个文件已经被 Loader 读取，并不表示文件中的每个文档都在当前阶段生效。</p>
<h2>6. processAndApply 的三次导入处理</h2>
<p><code>processAndApply()</code> 会调用三次 <code>withProcessedImports()</code>，但 <code>ImportPhase</code> 只有两种：</p>
<ul>
<li><code>BEFORE_PROFILE_ACTIVATION</code></li>
<li><code>AFTER_PROFILE_ACTIVATION</code></li>
</ul>
<p>前两次调用都属于 BEFORE，差异在于 <code>ActivationContext</code> 的完整度。第三次在 Profiles 创建后进入 AFTER。</p>
<p>主干流程如下：</p>
<pre><code class="language-java">processAndApply() {
    importer = new ConfigDataImporter(...);
    contributors = processInitial(contributors, importer);
    activationContext =
        createActivationContext(contributors.getBinder(...));
    contributors =
        processWithoutProfiles(contributors, importer, activationContext);
    activationContext =
        withProfiles(contributors, activationContext);
    contributors =
        processWithProfiles(contributors, importer, activationContext);
    applyToEnvironment(contributors, activationContext, ...);
}</code></pre><h3>6.1 第一次：context 为 null 的 BEFORE</h3>
<p><code>processInitial()</code> 传入 <code>null</code> <code>ActivationContext</code>。</p>
<p><code>ImportPhase.get(null)</code> 返回 <code>BEFORE_PROFILE_ACTIVATION</code>。</p>
<p>没有 <code>spring.config.activate</code> 的文档，在 <code>properties.isActive(null)</code> 中仍然 active，因为其 <code>activate</code> 字段为 <code>null</code>。</p>
<p>声明了任意 activate 条件的文档会进入 <code>Activate.isActive(null)</code>，并直接返回 <code>false</code>。</p>
<p>这一阶段不只是读取 <code>application.yml</code> 一层。</p>
<p>无条件文档的 imports 会持续展开，例如：</p>
<pre><code class="language-text">application.yml
    ↓ import
common.yml
    ↓ import
db.yml</code></pre><p>外层 while 会一直运行，直到整条当前可见的无条件依赖链都完成绑定与 import 处理。</p>
<h3>6.2 第二次：CloudPlatform 已知，Profiles 仍为 null 的 BEFORE</h3>
<p>第一次达到稳定状态后，<code>createActivationContext()</code> 使用当前 <code>Environment</code> 与 Contributor-backed Binder 推导 CloudPlatform，并明确把 profiles 设置为 <code>null</code>。</p>
<p>推导顺序是：先检查配置中是否强制指定 CloudPlatform，再调用 <code>CloudPlatform.getActive(environment)</code>。</p>
<p><code>processWithoutProfiles()</code> 再次调用 <code>withProcessedImports()</code>。</p>
<p>当前 phase 仍然是 BEFORE，但此前因为 <code>on-cloud-platform</code> 而 inactive 的文档，现在可以重新判断。</p>
<p>平台匹配时，这些文档的 import 会被展开。</p>
<p>只声明 <code>on-profile</code> 的文档仍然 inactive。</p>
<p>同时声明平台和 Profile 的文档要求两个条件同时满足，因此要等待第三阶段。</p>
<h3>6.3 Profile 推导与第三次 AFTER</h3>
<p><code>withProfiles()</code> 从当前 Contributor Tree 构造 Binder，过滤带 <code>IGNORE_PROFILES</code> 的 <code>PropertySource</code>，并读取：</p>
<ul>
<li><code>spring.profiles.active</code></li>
<li><code>spring.profiles.default</code></li>
<li><code>spring.profiles.include</code></li>
<li><code>spring.profiles.group</code></li>
<li><code>additionalProfiles</code></li>
</ul>
<p><code>Profiles</code> 构造器还会展开 Profile group，并使用 <code>LinkedHashSet</code> 保持顺序和终止循环。</p>
<p>随后 <code>activationContext.withProfiles(profiles)</code> 生成同时包含 CloudPlatform 与 Profiles 的新上下文。</p>
<p><code>processWithProfiles()</code> 第三次调用 <code>withProcessedImports()</code>。</p>
<p>此时 <code>activationContext.getProfiles() != null</code>，所以 <code>ImportPhase.get()</code> 返回 <code>AFTER_PROFILE_ACTIVATION</code>。</p>
<p>这个阶段具备两项新增能力：</p>
<ol>
<li><code>on-profile</code> 文档可以判断为 active；</li>
<li>Resolver 除了普通 <code>resolve()</code>，还会执行 <code>resolveProfileSpecific()</code>。</li>
</ol>
<p>标准文件 Resolver 因此能够发现：<code>application-dev.yml</code>、<code>common-dev.yml</code>、<code>application-prod.yml</code>。</p>
<h2>8. 使用真实数据结构推演完整执行过程</h2>
<h3>8.1 初始树</h3>
<p>忽略 EXISTING 节点后，初始树如下：</p>
<pre><code class="language-text">ROOT
└─ INITIAL_IMPORT
     imports = [classpath:/]
     children = {}</code></pre><h3>8.2 第一次 BEFORE：加载基础文档并展开无条件 import</h3>
<p><code>INITIAL_IMPORT</code> 被选中。它没有激活条件，imports 非空，并且 children 中没有 BEFORE key。</p>
<p>Resolver 在 <code>profiles == null</code> 时只执行普通 resolve，找到 <code>application.yml</code>。</p>
<p>Loader 读取三个 YAML 文档，生成一个 <code>ConfigData</code>。</p>
<p><code>asContributors()</code> 按 <code>PropertySource</code> 逆序生成三个 <code>UNBOUND_IMPORT</code>，并通过 <code>INITIAL_IMPORT.withChildren(BEFORE, children)</code> 挂入新树：</p>
<pre><code class="language-text">INITIAL_IMPORT
└─ BEFORE
   ├─ UNBOUND application.yml#2
   ├─ UNBOUND application.yml#1
   └─ UNBOUND application.yml#0</code></pre><p>此时三个文档都已经物理读取，但尚未绑定 activate 和 import。</p>
<p>后序 priority-order 遍历首先找到 <code>application.yml#2</code>。由于 Kind 为 <code>UNBOUND_IMPORT</code>，不检查 active，执行 <code>withBoundProperties()</code>，得到：</p>
<pre><code class="language-text">BOUND application.yml#2
  activate.on-profile = dev
  imports = [feature.yml]</code></pre><p>当前 context 为 null，因此 <code>isActive(null) == false</code>，<code>feature.yml</code> 暂不展开。</p>
<p>接下来绑定 <code>application.yml#1</code>：</p>
<pre><code class="language-text">BOUND application.yml#1
  activate.on-cloud-platform = kubernetes
  imports = [k8s.yml]</code></pre><p>context 仍为 null，所以同样 inactive，<code>k8s.yml</code> 暂不展开。</p>
<p>最后绑定 <code>application.yml#0</code>：</p>
<pre><code class="language-text">BOUND application.yml#0
  activate = null
  imports = [common.yml]
  spring.profiles.active = dev</code></pre><p>它在 null context 下 active，因此下一轮会被选中，加载 <code>common.yml</code>，并写入 BEFORE children。</p>
<p><code>common.yml</code> 先成为 <code>UNBOUND common.yml</code>，随后绑定为：</p>
<pre><code class="language-text">BOUND common.yml
  imports = [db.yml]</code></pre><p>它没有激活条件，所以继续加载 <code>db.yml</code>。<code>db.yml</code> 绑定后没有 imports，不再成为待处理节点。</p>
<p>第一次 BEFORE 达到稳定状态后的树如下：</p>
<pre><code class="language-text">INITIAL_IMPORT
└─ BEFORE
   ├─ BOUND application.yml#2
   │    activate.on-profile = dev
   │    imports = [feature.yml]
   │    当前 inactive
   │
   ├─ BOUND application.yml#1
   │    activate.on-cloud-platform = kubernetes
   │    imports = [k8s.yml]
   │    当前 inactive
   │
   └─ BOUND application.yml#0
        imports = [common.yml]
        └─ BEFORE
           └─ BOUND common.yml
                imports = [db.yml]
                └─ BEFORE
                   └─ BOUND db.yml</code></pre><p>此时 <code>application.yml#1</code> 和 <code>application.yml#2</code> 已经完成 load 与 bind，但没有展开内部 import。</p>
<p>第一次阶段结束的条件不是&quot;所有文件都激活&quot;，而是当前树中不存在：</p>
<ul>
<li><code>UNBOUND_IMPORT</code></li>
<li>以及不存在&quot;active 且 BEFORE 尚未处理的 Contributor&quot;</li>
</ul>
<h3>8.3 第二次 BEFORE：平台条件配置加入</h3>
<p><code>createActivationContext()</code> 推导出 <code>CloudPlatform.KUBERNETES</code>、<code>profiles = null</code>。</p>
<p>第二次后序遍历中，<code>application.yml#1</code> 的平台条件匹配：</p>
<pre><code class="language-text">activate.on-cloud-platform = kubernetes
当前平台 = Kubernetes</code></pre><p>因此 <code>isActive(context) == true</code>，其 BEFORE key 尚不存在，所以加载 <code>k8s.yml</code>。</p>
<pre><code class="language-text">application.yml#1
  activate.on-cloud-platform = kubernetes
  isActive(context) = true
  children[BEFORE] = [k8s.yml]</code></pre><p><code>k8s.yml</code> 完成绑定后没有 imports。</p>
<p><code>application.yml#2</code> 仍然要求 dev Profile，而 <code>profiles == null</code>：</p>
<pre><code class="language-text">application.yml#2
  activate.on-profile = dev
  isActive(context) = false</code></pre><p>所以保持 inactive。</p>
<h3>8.4 withProfiles：从 active 配置中推导 dev</h3>
<p><code>withProfiles()</code> 创建的 Binder 会过滤 <code>IGNORE_PROFILES</code>，并对关键绑定启用 <code>FAIL_ON_BIND_TO_INACTIVE_SOURCE</code>。</p>
<p>基础文档 <code>application.yml#0</code> 当前 active，其中的 <code>spring.profiles.active: dev</code> 会被读取。</p>
<p><code>Profiles</code> 同时处理：</p>
<ul>
<li><code>spring.profiles.active</code></li>
<li><code>spring.profiles.default</code></li>
<li><code>spring.profiles.include</code></li>
<li><code>spring.profiles.group</code></li>
<li><code>additionalProfiles</code></li>
</ul>
<p>完成 group 展开后，最终上下文为：</p>
<pre><code class="language-text">CloudPlatform = KUBERNETES
Profiles.active = [dev]</code></pre><h3>8.5 第三次 AFTER：Profile 条件与 profile-specific 文件加入</h3>
<p><code>application.yml#2</code> 重新判断时：</p>
<pre><code class="language-text">on-profile = dev
Profiles 接受 dev</code></pre><p>因此变为 active。它的 AFTER key 不存在，所以 <code>feature.yml</code> 在 AFTER 阶段被加载并绑定。</p>
<p>已经在 BEFORE 处理过 imports 的节点，其 AFTER key 仍然不存在。因此，同一个 imports 会带着 Profiles 再交给 Resolver。</p>
<p>Resolver 执行：</p>
<pre><code class="language-text">resolve(location)
resolveProfileSpecific(location, profiles)</code></pre><p>普通资源已经存在于 <code>ConfigDataImporter.loaded</code>，会被去重。只有新出现的 profile-specific Resource 才会进入加载结果。</p>
<p><code>INITIAL_IMPORT</code> 在 AFTER 阶段重新解析 <code>classpath:/</code>。普通 <code>application.yml</code> 已经加载并被去重。</p>
<p><code>StandardConfigDataLocationResolver</code> 针对 dev 生成 <code>application-dev.yml</code>。</p>
<p><code>StandardConfigDataLoader</code> 会为该 <code>PropertySource</code> 标记 <code>PROFILE_SPECIFIC</code>。</p>
<p><code>application-dev.yml</code> 先作为 <code>UNBOUND_IMPORT</code> 加入 AFTER children，再绑定得到：</p>
<pre><code class="language-text">imports = [dev-db.yml]</code></pre><p>当前 Profile 已知，并且该文档 active，所以 <code>dev-db.yml</code> 继续在 AFTER 阶段展开。</p>
<p>最终树的大致结构如下：</p>
<pre><code class="language-text">INITIAL_IMPORT
├─ AFTER
│  └─ BOUND application-dev.yml
│       imports = [dev-db.yml]
│       └─ AFTER
│          └─ BOUND dev-db.yml
│
└─ BEFORE
   ├─ BOUND application.yml#2
   │    activate.on-profile = dev
   │    └─ AFTER
   │       └─ BOUND feature.yml
   │
   ├─ BOUND application.yml#1
   │    activate.on-cloud-platform = kubernetes
   │    └─ BEFORE
   │       └─ BOUND k8s.yml
   │
   └─ BOUND application.yml#0
        └─ BEFORE
           └─ BOUND common.yml
                └─ BEFORE
                   └─ BOUND db.yml</code></pre><p><code>application.yml</code> 的条件文档最初都位于 <code>INITIAL_IMPORT</code> 的 BEFORE children，因为它们来自第一次普通解析。它们内部的 import 在之后变为 active 时，会写入对应 phase 的 children。</p>
<p><code>application-dev.yml</code> 来自 <code>resolveProfileSpecific()</code>，属于 profile-specific 资源，并在树结构调整后位于 AFTER 高优先级区域。</p>
<h2>10. BEFORE 与 AFTER 的真实区别</h2>
<p><code>ImportPhase</code> 描述的是：当前节点的 imports 在 Profile 推导之前执行，还是在 Profile 推导之后执行。</p>
<p>它不直接表示节点 inactive 或 active。</p>
<p><code>ImportPhase.get()</code> 的逻辑只有一个判断：</p>
<pre><code class="language-java">phase = activationContext != null
    && activationContext.getProfiles() != null
        ? AFTER_PROFILE_ACTIVATION
        : BEFORE_PROFILE_ACTIVATION;</code></pre><p>因此 BEFORE 表示 Profiles 尚未确定，AFTER 表示 Profiles 已经确定。</p>
<h3>10.1 Resolver 行为的差异</h3>
<p><code>ConfigDataImporter</code> 从 <code>ActivationContext</code> 中取得 Profiles，并传给 <code>ConfigDataLocationResolvers.resolve(...)</code>。</p>
<p>Resolvers 总是先调用普通解析 <code>resolver.resolve(context, location)</code>。</p>
<p>如果 Profiles 为 null，直接返回普通解析结果。</p>
<p>如果 Profiles 不为 null，还会执行 <code>resolver.resolveProfileSpecific(context, location, profiles)</code>。</p>
<p>因此：</p>
<ul>
<li>普通解析：<code>resolve(location)</code></li>
<li>Profile 已知后的解析：<code>resolve(location)</code> + <code>resolveProfileSpecific(location, profiles)</code></li>
</ul>
<p>对于标准文件位置，<code>resolveProfileSpecific()</code> 会遍历 accepted profiles，并把 Profile 传入 <code>StandardConfigDataReference</code>。</p>
<p>例如：</p>
<ul>
<li>目录 <code>classpath:/</code> 产生 <code>application-dev.yml</code> 候选</li>
<li>明确文件 <code>common.yml</code> 产生 <code>common-dev.yml</code> 候选</li>
</ul>
<p><code>StandardConfigDataLoader</code> 检测 <code>resource.getProfile() != null</code> 时，会给对应 <code>PropertySource</code> 添加 <code>PROFILE_SPECIFIC</code>。</p>
<h3>10.2 同一个 imports 处理两次</h3>
<p>一个 BOUND Contributor 的 imports 在 BEFORE 完成后，只会拥有 <code>children[BEFORE]</code>。</p>
<p>进入 AFTER 时，<code>hasUnprocessedImports(AFTER)</code> 仍然返回 <code>true</code>。因此，同一个 Location 会在两种上下文中各解析一次。</p>
<p>第一次：<code>Profiles = null</code>，只解析普通资源。</p>
<p>第二次：<code>Profiles = [dev]</code>，解析普通资源 + profile-specific 资源。</p>
<p><code>ConfigDataImporter.loaded</code> 使用 <code>ConfigDataResource</code> 去重，所以普通资源不会重复执行 <code>Loader.load()</code>。</p>
<p>第二次解析的价值在于发现此前不存在的 profile-specific Resource。</p>
<p>&quot;处理两次&quot;指的是：同一个配置位置在两种上下文中各执行一次 resolve，并不表示同一个文件一定被物理读取两次。</p>
<h3>10.3 PROFILE_SPECIFIC 的树结构调整</h3>
<p><code>withChildren(AFTER, children)</code> 会调用 <code>moveProfileSpecific()</code>。</p>
<p>该方法会在 BEFORE 子树中寻找带 <code>PROFILE_SPECIFIC</code> Option 的子节点，把它们从原位置移出，去掉临时 Option 后加入当前节点的 AFTER children。</p>
<p>这个调整让 profile-specific 来源进入 Profile 激活后的高优先级区域，同时保留非 profile-specific import 的原有父子关系。</p>
<p>下面两个状态相关，但含义不同：</p>
<ul>
<li><code>fromProfileSpecificImport</code>：来自 <code>ConfigDataResolutionResult.isProfileSpecific()</code>，表示 Resource 是由 <code>resolveProfileSpecific()</code> 产生的。</li>
<li><code>PROFILE_SPECIFIC</code> Option：附着在 <code>PropertySource</code> 上，用于后续树结构与优先级调整。</li>
</ul>
<h2>12. Options、去重、异常与最终优先级</h2>
<h3>12.1 ConfigData.Option 的来源与作用</h3>
<p>Options 由 <code>ConfigDataLoader</code> 在构造 <code>ConfigData</code> 时提供，并按照 <code>PropertySource</code> 传递给 <code>ofUnboundImport()</code>。</p>
<p>Spring Boot 不会根据&quot;本地配置、远程配置、配置中心&quot;等类型统一推断这些标记。自定义 Loader 决定返回哪些 Options。</p>
<table>
<thead>
<tr>
<th>Option</th>
<th>设置位置</th>
<th>消费位置</th>
<th>作用</th>
</tr>
</thead>
<tbody><tr>
<td><code>IGNORE_IMPORTS</code></td>
<td>Loader 或 <code>EMPTY_LOCATION</code> 固定选项</td>
<td><code>withBoundProperties()</code></td>
<td>绑定后移除 <code>spring.config.import</code>，普通属性仍可贡献</td>
</tr>
<tr>
<td><code>IGNORE_PROFILES</code></td>
<td>由 Loader 决定</td>
<td><code>withProfiles()</code>、include 扫描、非法属性校验</td>
<td>不让该 <code>PropertySource</code> 参与 active、default、include、group 推导</td>
</tr>
<tr>
<td><code>PROFILE_SPECIFIC</code></td>
<td><code>StandardConfigDataLoader</code> 在 resource.profile != null 时设置</td>
<td><code>moveProfileSpecific()</code></td>
<td>把 profile-specific 来源移动到 AFTER 高优先级区域</td>
</tr>
</tbody></table>
<p><code>EMPTY_LOCATION</code> 使用 <code>IGNORE_IMPORTS</code>，表示该 Location 合法，但没有实际 <code>PropertySource</code>，不应再从空节点中读取 import。</p>
<p><code>IGNORE_PROFILES</code> 的精确定义是：忽略该 <code>PropertySource</code> 中影响 Profile 推导的属性。它不等同于&quot;远程配置必然忽略 Profile&quot;。</p>
<h3>12.2 Resource 去重与循环 import</h3>
<p><code>ConfigDataImporter</code> 维护 <code>Set&lt;ConfigDataResource&gt; loaded</code>。</p>
<p>候选 Resource 已存在时，不会再次调用 Loader。</p>
<p>对应 Location 会被记录到 <code>loadedLocations</code>。</p>
<p>假设 <code>A import B</code>，<code>B import A</code>：</p>
<pre><code class="language-text">load A
  loaded = [A]

A import B
  load B
  loaded = [A, B]

B 再解析出 A 时
  loaded.contains(A) == true
  → 不会生成新的 Contributor，循环自然终止</code></pre><p>去重依据是 <code>ConfigDataResource.equals()</code> 与 <code>ConfigDataResource.hashCode()</code>，而不是字符串 Location。</p>
<p>不同 Location 解析到同一个 Resource 时，同样只加载一次。</p>
<p>同一个 Location 在 AFTER 阶段解析出新的 profile-specific Resource 时，因为 Resource 不同，仍然可以加载。</p>
<h3>12.3 optional、空位置与强制位置检查</h3>
<p><code>optional:</code> 前缀和 Resource 自身的 optional 状态，会被 <code>ConfigDataImporter</code> 记录。</p>
<p><code>ConfigDataNotFoundException</code> 对 optional 位置采用忽略策略。非 optional 位置则按照 <code>spring.config.on-not-found</code> 的策略处理。</p>
<p>Resolver 可能返回一个合法的空目录 Resource。Loader 返回 <code>ConfigData.EMPTY</code>，随后创建 <code>EMPTY_LOCATION</code> Contributor。</p>
<p>最终 apply 前，<code>checkMandatoryLocations()</code> 会收集 active Contributor 中的非 optional imports，再减去：</p>
<ul>
<li>树中已经出现的 Location</li>
<li><code>loadedLocations</code></li>
<li><code>optionalLocations</code></li>
</ul>
<p>仍然存在的 Location 被视为没有成功解析的强制 import。</p>
<h3>12.4 最终 apply 与属性优先级</h3>
<p><code>applyToEnvironment()</code> 先执行：</p>
<ul>
<li>非法 Profile 属性校验；</li>
<li>强制位置检查。</li>
</ul>
<p>随后使用 ContributorIterator 遍历整棵树。</p>
<p>只有满足以下条件的节点会加入 <code>Environment</code>：</p>
<pre><code class="language-text">Kind == BOUND_IMPORT
PropertySource != null
在最终 ActivationContext 下 active</code></pre><p>结构化逻辑如下：</p>
<pre><code class="language-text">for (contributor : contributors) {
    if (kind == BOUND_IMPORT
            && propertySource != null) {
        if (contributor.isActive(finalContext)) {
            environment
                .getPropertySources()
                .addLast(propertySource);
        }
    }
}</code></pre><p>inactive 节点仍然留在 Contributor Tree 中，但不会进入 <code>Environment</code>。</p>
<p>命令行参数、<code>systemProperties</code> 等 <code>EXISTING</code> <code>PropertySource</code> 已经位于 <code>Environment</code> 中，并且在 ConfigData 结果之前，所以保持更高优先级。</p>
<p>ConfigData 节点内部依赖 <code>AFTER → BEFORE → parent</code> 的遍历顺序。</p>
<p>最后 <code>DefaultPropertiesPropertySource</code> 会被移动到末尾，维持默认属性最低优先级。</p>
<h2>14. 完整执行模型</h2>
<p>ConfigData 的完整执行可以压缩为以下连续过程。</p>
<p><strong>第一步</strong>，包装已有 Environment PropertySource，创建 INITIAL_IMPORT，组成 ROOT。</p>
<pre><code class="language-text">ROOT
├─ EXISTING
├─ INITIAL_IMPORT
└─ EXISTING</code></pre><p><strong>第二步</strong>，执行第一次 BEFORE。</p>
<pre><code class="language-text">context = null
Profiles = null
CloudPlatform = null</code></pre><p>持续加载并绑定无条件文档，递归展开无条件 imports。</p>
<p><strong>第三步</strong>，创建 ActivationContext。从 Environment 与 Contributor Binder 推导 CloudPlatform，Profiles 仍然为 null。</p>
<p><strong>第四步</strong>，执行第二次 BEFORE。重新判断平台条件，持续展开 on-cloud-platform 文档的 imports。</p>
<p><strong>第五步</strong>，计算 Profiles：</p>
<ul>
<li>过滤 <code>IGNORE_PROFILES</code></li>
<li>读取 active</li>
<li>读取 default</li>
<li>读取 include</li>
<li>读取 additionalProfiles</li>
<li>展开 group</li>
</ul>
<p><strong>第六步</strong>，执行第三次 AFTER。</p>
<ul>
<li>重新判断 on-profile 条件。</li>
<li>每个 imports 带着 Profiles 再解析。</li>
<li>发现 <code>application-dev.yml</code>、<code>common-dev.yml</code>、其他 profile-specific Resource。</li>
<li>使用 loaded Resource 集合去重，并继续递归展开新节点。</li>
</ul>
<p><strong>第七步</strong>，每个阶段内部都执行同一套不动点算法：</p>
<pre><code class="language-text">后序 priority-order 遍历
    ↓
选择一个待处理节点
    ↓
绑定或者加载
    ↓
withReplacement 生成新树
    ↓
从根重新遍历
    ↓
直到当前阶段稳定</code></pre><p><strong>第八步</strong>，执行最终 apply。</p>
<ul>
<li>校验强制位置</li>
<li>校验非法 Profile 属性</li>
<li>只加入 active BOUND PropertySource</li>
<li>按照 <code>AFTER → BEFORE → parent</code> 排序</li>
<li>设置 Environment 的 defaultProfiles 和 activeProfiles</li>
</ul>
<p>最终心智模型如下：</p>
<ul>
<li>Contributor Tree 是 ConfigData 的中间表示</li>
<li>ActivationContext 决定当前哪些节点可以继续展开</li>
<li>ImportPhase 决定位置解析时是否拥有 Profiles</li>
<li>ContributorIterator 给出优先级后序遍历</li>
<li>withProcessedImports 的 while 把当前可求解的配置依赖持续展开到稳定</li>
<li>applyToEnvironment 才把最终结果交给真实 Environment</li>
</ul>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/spring-boot-config-data#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">164803215282409472</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title>HTTP 1.1、HTTP 2 与 HTTP 3：把三次协议演进串起来</title>
    <link>https://liuyaowen.cn/posts/default/http-evolution-1-1-2-3</link>
    <pubDate>Tue, 28 Jul 2026 05:37:47 GMT</pubDate>
    <description>写这篇是因为最近又在排查一个网络问题，顺手把 HTTP 这几个版本的演进重新过了一遍。HTTP/1.</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/http-evolution-1-1-2-3'>https://liuyaowen.cn/posts/default/http-evolution-1-1-2-3</a></blockquote>
          <p>写这篇是因为最近又在排查一个网络问题，顺手把 HTTP 这几个版本的演进重新过了一遍。HTTP/1.1 已经用了二十多年，HTTP/2 是 2015 年标准化的，HTTP/3 这两年才在主流站点铺开。它们不是简单加几个字段，而是<strong>底层传输模型在变</strong>。</p>
<p>这篇把 HTTP/1.1、HTTP/2、HTTP/3 (QUIC) 放一起讲，沿着&quot;为什么不够用 → 怎么改 → 改完还剩什么问题&quot;这条线写下来。</p>
<h2>相关笔记</h2>
<ul>
<li>[[笔记/web/计算机网络/TCP 重传、滑动窗口、流量控制与拥塞控制]]</li>
<li>[[笔记/web/计算机网络/计算机网络框架]]</li>
<li>[[笔记/web/计算机网络/HTTP 1.1 与 HTTP 2 协议对比]]</li>
</ul>
<h2>1. HTTP/1.0 简史</h2>
<p>HTTP 是 Tim Berners-Lee 在 1989 年搞出来的，最初的需求很简单：让分布在全世界的物理学家能互相引用文档。每个文档就是一个文件，浏览器把文件名发给服务器，服务器把内容返回回来。</p>
<p>第一个正式版本 HTTP/1.0（RFC 1945，1996 年）核心就一条：<strong>一次请求一次响应，然后断开 TCP</strong>。</p>
<pre><code class="language-text">浏览器  ── GET /index.html ──&gt;  服务器
浏览器  &lt;─ 200 OK + HTML   ──  服务器
浏览器  ── GET /logo.png   ──&gt;  服务器（再建一条 TCP）
浏览器  &lt;─ 200 OK + PNG   ──  服务器</code></pre><p>一个页面里 10 张图片，就要建 10 次 TCP。TCP 握手 1.5 个 RTT，加上 TLS 又是几个 RTT。在拨号上网那个年代，握手是大头。</p>
<p>HTTP/1.0 没解决这个问题，但它定了一套<strong>可扩展的协议骨架</strong>：方法、状态码、首部字段、Body。这套骨架后来没变过——HTTP/2 改的是&quot;怎么把这些东西传过去&quot;，不是&quot;这些东西是什么意思&quot;。</p>
<h2>3. 浏览器为什么要开 6 个连接</h2>
<p>既然 HTTP/1.1 的连接是串行的，要并发就只能多开几条 TCP。</p>
<p>Chrome 对同一个域名默认并发 <strong>6 条</strong>，Firefox 也是 6 条左右。这不是 bug，是 HTTP/1.1 时代的必要妥协。</p>
<pre><code class="language-text">浏览器 → 域名 example.com
  连接 #1: GET /
  连接 #2: GET /a.js
  连接 #3: GET /b.css
  连接 #4: GET /1.png
  连接 #5: GET /2.png
  连接 #6: GET /3.png</code></pre><p>副作用：</p>
<ul>
<li>每条连接独立跑 TCP 拥塞控制，互相不协调；</li>
<li>每条连接都吃内存、文件描述符、端口；</li>
<li>前端工程师为了&quot;减少请求数&quot;发明了雪碧图、CSS/JS 合并、域名分片（sharding）这些奇技淫巧。</li>
</ul>
<p>这些技巧在 HTTP/2 之后都不再需要了。</p>
<h2>5. 二进制分帧</h2>
<p>HTTP/1.x 的报文是文本：</p>
<pre><code class="language-text">GET /index.html HTTP/1.1\r\n
Host: example.com\r\n
\r\n</code></pre><p>文本协议的好处是可读、调试方便。代价是解析慢、歧义多（换行到底是 <code>\n</code>、<code>\r\n</code>、还是别的）。</p>
<p>HTTP/2 换成二进制。每个帧有 9 字节固定头：</p>
<pre><code class="language-text">+-----------------------------------------------+
|                 Length (24)                    |
+---------------+---------------+---------------+
|   Type (8)    |   Flags (8)   |
+-+-------------+---------------+-------------------------------+
|R|                 Stream Identifier (31)      |
+=+=============================================================+
|                   Frame Payload (0...)                      ...
+---------------------------------------------------------------+</code></pre><ul>
<li><code>Length</code>：帧负载字节数</li>
<li><code>Type</code>：帧类型（HEADERS、DATA、PRIORITY 等）</li>
<li><code>Flags</code>：标志位（END_STREAM、END_HEADERS）</li>
<li><code>Stream Identifier</code>：流 ID</li>
</ul>
<p>常见帧类型：</p>
<table>
<thead>
<tr>
<th>Type</th>
<th>作用</th>
</tr>
</thead>
<tbody><tr>
<td>HEADERS</td>
<td>携带请求/响应首部</td>
</tr>
<tr>
<td>DATA</td>
<td>携带 Body</td>
</tr>
<tr>
<td>PRIORITY</td>
<td>声明流优先级</td>
</tr>
<tr>
<td>RST_STREAM</td>
<td>取消一个流</td>
</tr>
<tr>
<td>SETTINGS</td>
<td>协商连接参数</td>
</tr>
<tr>
<td>WINDOW_UPDATE</td>
<td>流量控制</td>
</tr>
<tr>
<td>PING</td>
<td>心跳与 RTT 测量</td>
</tr>
<tr>
<td>GOAWAY</td>
<td>通知要关连接了</td>
</tr>
<tr>
<td>PUSH_PROMISE</td>
<td>服务器推送预告</td>
</tr>
</tbody></table>
<p>注意 HEADERS 和 DATA 是分开的帧。所以一个请求可以先发 HEADERS，再分多个 DATA 帧发 Body，每个 DATA 帧任意长度。</p>
<h2>7. HPACK 头部压缩</h2>
<p>HTTP 头部是 Web 性能的老油条问题：</p>
<ul>
<li>普通请求的头部大约 600-800 字节；</li>
<li><code>Cookie</code>、<code>User-Agent</code>、<code>Accept</code>、<code>Referer</code> 这些字段每个请求几乎一样；</li>
<li>HTTP/1.x 不压缩头部（gzip 是压 body 的）；</li>
<li>一个页面 50 个请求，光头部就是 30-40 KB。</li>
</ul>
<p>HTTP/2 引入了 <strong>HPACK</strong>（RFC 7541）。</p>
<h3>静态表与动态表</h3>
<p>核心是两张表：</p>
<ul>
<li><strong>静态表</strong>：规范写死的 61 个常见 header 名值对，比如 <code>:method GET</code>、<code>:scheme https</code>、<code>:authority example.com</code>。</li>
<li><strong>动态表</strong>：连接双方各自维护的、可变化的表。</li>
</ul>
<p>发送 header 时：</p>
<ul>
<li>在静态表里 → 只发 1 字节索引；</li>
<li>在动态表里 → 发索引 + 匹配方式；</li>
<li>都不在 → 发字面值，可选地插入动态表。</li>
</ul>
<p>第一次发：</p>
<pre><code class="language-text">:method = GET            → 索引 2
:scheme = https          → 索引 7
:authority = example.com → 字面值，插入动态表 #62
:path = /api/users       → 字面值，插入动态表 #63
user-agent = curl/7.0    → 字面值</code></pre><p>第二次同域名请求：</p>
<pre><code class="language-text">:method = GET            → 索引 2（1 字节）
:scheme = https          → 索引 7（1 字节）
:authority = example.com → 索引 62（动态表，1 字节）
:path = /api/posts       → 字面值
user-agent = curl/7.0    → 字面值</code></pre><h3>Huffman 编码</h3>
<p>字面值还会用一套专门为 HTTP 头部字符频率设计的 Huffman 编码再压一遍。解码是 O(n) 位运算，性能高。</p>
<h3>为什么 HPACK 比 gzip 更适合 HTTPS</h3>
<p>有人会问：gzip 压一下不就完了？</p>
<p>关键是<strong>安全</strong>：</p>
<ul>
<li>gzip 是基于上下文的压缩，靠重复模式；</li>
<li>攻击者可以通过观察压缩长度推断明文——这就是 CRIME 和 BREACH 攻击；</li>
<li>HPACK 是基于字典索引的预定义编码，<strong>攻击者无法通过长度推断明文</strong>。</li>
</ul>
<p>HPACK 是为了&quot;在 HTTPS 下既压缩又安全&quot;专门设计出来的。</p>
<h2>9. HTTP/2 没解决的：TCP 层的队头阻塞</h2>
<p>HTTP/2 解决了应用层的队头阻塞，没解决传输层的。</p>
<p>为什么？HTTP/2 的多路复用是逻辑层面的，底层还是一条 TCP。TCP 把数据看作<strong>有序字节流</strong>，协议规定：</p>
<blockquote>
<p>任何一个包丢失，TCP 必须缓存所有后续包，直到丢失的包被重传并交付，才能交给上层应用。</p>
</blockquote>
<p>所以一旦某个 TCP 包丢了，<strong>整条连接的所有 HTTP/2 流都卡住</strong>——即使其他流的帧早就到了。</p>
<pre><code class="language-text">Stream 1: HEADERS → DATA → DATA → [包丢失] → 等重传
Stream 3: HEADERS → DATA → DATA → [包到了] → 但被 TCP 缓存住
Stream 5: HEADERS → DATA → DATA → [包到了] → 但被 TCP 缓存住</code></pre><p>在丢包率低的有线网络这不是问题。但<strong>移动网络、卫星网络、地铁车厢</strong>这些场景，HTTP/2 的优势会被严重削弱。</p>
<p>还有两个 HTTP/2 没解决的：</p>
<ul>
<li>握手开销：TCP 三次握手 + TLS 1.2 至少 <strong>3-RTT</strong> 才能传第一个字节；</li>
<li>连接迁移：TCP 连接绑四元组（IP + 端口），切换网络（Wi-Fi → 4G）就断了。</li>
</ul>
<p>为了解决这些，Google 干脆把 TCP 也换了，基于 UDP 做了 QUIC。这就是 HTTP/3。</p>
<h2>11. 握手对比：为什么 HTTP/3 是 0-RTT</h2>
<p>这是 HTTP/3 最容易被低估的改进。先看对比：</p>
<h3>HTTP/1.1 + TLS 1.2</h3>
<pre><code class="language-text">TCP 握手:   SYN → SYN+ACK → ACK                            (1 RTT)
TLS 握手:   ClientHello → ServerHello+Cert+... → Finished  (2 RTT)
HTTP 请求:  GET /                                          (3 RTT 才能传第一个字节)</code></pre><h3>HTTP/2 + TLS 1.2</h3>
<p>跟 HTTP/1.1 一样的握手开销，只是 ALPN 协商时声明 <code>h2</code>。</p>
<h3>HTTP/2 + TLS 1.3</h3>
<pre><code class="language-text">TCP 握手:   SYN → SYN+ACK → ACK                            (1 RTT)
TLS 1.3:    ClientHello → ServerHello+Finished+Cert        (1 RTT)
HTTP 请求:  GET /                                          (2 RTT)</code></pre><p>TLS 1.3 把握手从 2-RTT 降到 1-RTT。</p>
<h3>HTTP/3 + QUIC</h3>
<pre><code class="language-text">QUIC 握手（内置 TLS 1.3）:  ClientHello → ServerHello+Cert+Finished  (1 RTT)
HTTP 请求:                  GET /                                      (1 RTT)

如果是恢复连接（之前连过）：ClientHello 直接带 Early Data
                                              ↓
                                       (0 RTT 就能传应用数据)</code></pre><p>QUIC 把 TCP 握手和 TLS 握手合并了。<strong>1-RTT 握手 + 0-RTT 恢复</strong>。</p>
<p>0-RTT 不是没有代价——存在重放攻击风险，所以一般只用于幂等请求（GET、HEAD）。涉及金钱的操作（POST、转账）不能用 0-RTT。</p>
<h2>13. 落地：Nginx 怎么开 HTTP/2 和 HTTP/3</h2>
<h3>HTTP/2</h3>
<pre><code class="language-nginx">server {
    listen 443 ssl http2;
    server_name example.com;

    ssl_certificate     /path/to/fullchain.pem;
    ssl_certificate_key /path/to/privkey.pem;

    # 性能调优
    http2_max_field_size       16k;
    http2_max_header_size      32k;
    http2_max_requests         1000;
    http2_body_buffer_size     16k;

    location / {
        root /var/www/html;
    }
}</code></pre><p><code>http2</code> 是 <code>listen</code> 的参数，不是单独的配置项。<strong>必须搭配 HTTPS</strong>——浏览器不支持明文 HTTP/2。</p>
<h3>HTTP/3</h3>
<p>Nginx 1.25.0+ 原生支持 QUIC（之前是实验模块）：</p>
<pre><code class="language-nginx">server {
    # 监听 UDP 443（不是 TCP）
    listen 443 quic reuseport;
    # 同时监听 TCP 443 兜底
    listen 443 ssl http2;
    server_name example.com;

    ssl_certificate     /path/to/fullchain.pem;
    ssl_certificate_key /path/to/privkey.pem;

    # 告诉客户端 HTTP/3 可用
    add_header Alt-Svc 'h3=":443"; ma=86400';

    location / {
        root /var/www/html;
    }
}</code></pre><p>关键点：</p>
<ul>
<li>443 端口要同时监听 <strong>TCP 和 UDP</strong>——HTTP/3 走 UDP，老客户端走 TCP/HTTP/2；</li>
<li><code>add_header Alt-Svc</code> 让浏览器知道这个站点支持 HTTP/3；</li>
<li><code>reuseport</code> 让多个 worker 进程共享同一个 UDP 端口；</li>
<li>防火墙必须<strong>放行 UDP 443</strong>（很多公司内网只放 TCP 443，HTTP/3 在这种网络下不可用）。</li>
</ul>
<h3>验证</h3>
<pre><code class="language-bash"># HTTP/2
curl -I --http2 https://example.com
# 输出：HTTP/2 200

# HTTP/3
curl -I --http3 https://example.com
# 需要 curl 编译时带 quiche 或 ngtcp2

# 浏览器
# Chrome DevTools → Network → Protocol 列
# 看到 h2 表示 HTTP/2，h3 表示 HTTP/3</code></pre><h2>15. 参考</h2>
<p>[1] RFC 1945 - HTTP/1.0. <a href="https://datatracker.ietf.org/doc/html/rfc1945">https://datatracker.ietf.org/doc/html/rfc1945</a></p>
<p>[2] RFC 7230 - HTTP/1.1: Message Syntax and Routing. <a href="https://datatracker.ietf.org/doc/html/rfc7230">https://datatracker.ietf.org/doc/html/rfc7230</a></p>
<p>[3] RFC 7540 - HTTP/2. <a href="https://datatracker.ietf.org/doc/html/rfc7540">https://datatracker.ietf.org/doc/html/rfc7540</a></p>
<p>[4] RFC 7541 - HPACK. <a href="https://datatracker.ietf.org/doc/html/rfc7541">https://datatracker.ietf.org/doc/html/rfc7541</a></p>
<p>[5] RFC 8446 - TLS 1.3. <a href="https://datatracker.ietf.org/doc/html/rfc8446">https://datatracker.ietf.org/doc/html/rfc8446</a></p>
<p>[6] RFC 9000 - QUIC. <a href="https://datatracker.ietf.org/doc/html/rfc9000">https://datatracker.ietf.org/doc/html/rfc9000</a></p>
<p>[7] RFC 9114 - HTTP/3. <a href="https://datatracker.ietf.org/doc/html/rfc9114">https://datatracker.ietf.org/doc/html/rfc9114</a></p>
<p>[8] High Performance Browser Networking. Ilya Grigorik. <a href="https://hpbn.co/">https://hpbn.co/</a></p>
<p>[9] HTTP/2 简介. Google Developers. <a href="https://developers.google.com/web/fundamentals/performance/http2">https://developers.google.com/web/fundamentals/performance/http2</a></p>
<p>[10] Chrome Status - HTTP/2 Server Push 移除. <a href="https://chromestatus.com/feature/5762410658996224">https://chromestatus.com/feature/5762410658996224</a></p>
<p>[11] Nginx HTTP/2 模块. <a href="https://nginx.org/en/docs/http/ngx_http_v2_module.html">https://nginx.org/en/docs/http/ngx_http_v2_module.html</a></p>
<p>[12] 图解网络. 小林. <a href="https://xiaolincoding.com/network/">https://xiaolincoding.com/network/</a></p>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/http-evolution-1-1-2-3#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">163884322787430400</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title>Java 虚拟线程如何与网络 I/O 协作：Carrier、Continuation 与 Poller</title>
    <link>https://liuyaowen.cn/posts/default/java-virtual-threads-network-io-carrier-continuation-poller</link>
    <pubDate>Sat, 25 Jul 2026 12:03:41 GMT</pubDate>
    <description>虚拟线程执行下面这段代码时，表面上和普通线程没有区别：

int count = socket.ge</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/java-virtual-threads-network-io-carrier-continuation-poller'>https://liuyaowen.cn/posts/default/java-virtual-threads-network-io-carrier-continuation-poller</a></blockquote>
          <p>虚拟线程执行下面这段代码时，表面上和普通线程没有区别：</p>
<pre><code class="language-java">int count = socket.getInputStream().read(buffer);
process(buffer, count);</code></pre><p>当 Socket 暂时没有数据，当前虚拟线程会暂停。承载它的 Carrier Thread 随后可以运行其他虚拟线程。网络数据到达后，原虚拟线程重新得到调度，read() 返回，代码继续执行 process()。</p>
<p>这里容易产生几个疑问：</p>
<ul>
<li>Carrier Thread 只能线性执行代码，当前方法还没有执行完，它怎么去运行另一个任务？</li>
<li>虚拟线程暂停后保存在哪里？</li>
<li>网络事件到达时，Poller 如何找到对应的虚拟线程？</li>
<li>调度队列中保存的是虚拟线程、回调函数，还是完整调用栈？</li>
<li>网络数据怎样回到原来的 buffer？</li>
<li>Continuation.yield()、Thread.yield() 和 LockSupport.park() 分别负责什么？</li>
</ul>
<p>本文以 JDK 21 的实现为基线，沿着一次 Socket 读取的调用过程分析这些问题。JDK 21 的虚拟线程采用 M:N 调度，大量虚拟线程由 JDK 调度到少量平台线程上运行；默认调度器是一个独立的 ForkJoinPool。（OpenJDK）</p>
<h2>Carrier Thread 没有任务切换指令</h2>
<p>先去掉虚拟线程，只看一个普通的工作线程：</p>
<pre><code class="language-java">final class Worker extends Thread {
    private final BlockingQueue&lt;Runnable&gt; queue;
    Worker(BlockingQueue&lt;Runnable&gt; queue) {
        this.queue = queue;
    }
    @Override
    public void run() {
        while (!isInterrupted()) {
            try {
                Runnable task = queue.take();
                task.run();
            } catch (InterruptedException e) {
                interrupt();
            }
        }
    }
}</code></pre><p>这条线程始终线性执行。它从队列取出一个 Runnable，调用 run()，等待该方法返回，然后进入下一轮循环。</p>
<pre><code class="language-java">Runnable taskA = queue.take();
taskA.run();
Runnable taskB = queue.take();
taskB.run();</code></pre><p>Carrier Thread 的工作方式也是如此。它不会在 taskA.run() 尚未返回时，突然跳到 taskB.run()。</p>
<p>虚拟线程能够切换，是因为 taskA.run() 可以在业务调用尚未完成时暂时返回。返回之前，JVM 会保存虚拟线程的调用栈。以后再次执行这个任务时，JVM 恢复原调用栈，代码继续运行。</p>
<p>因此，一条 Carrier Thread 上实际发生的是：</p>
<pre><code class="language-java">virtualThreadAResumeTask.run(); // 保存调用栈后暂时返回
virtualThreadBResumeTask.run(); // Carrier 回到调度循环后执行</code></pre><p>Carrier Thread 仍然遵守普通方法调用规则。特殊能力位于 Continuation，它允许一段尚未完成的调用栈暂停和恢复。</p>
<h2>调度队列中保存了什么</h2>
<p>JDK 21 的 VirtualThread 内部有几个关键字段。省略诊断、线程容器和中断处理后，可以整理成下面的结构：</p>
<pre><code class="language-java">final class VirtualThread extends Thread {
    private final Executor scheduler;
    private final Continuation cont;
    private final Runnable runContinuation;
    private volatile int state;
    private volatile boolean parkPermit;
    private volatile Thread carrierThread;
}</code></pre><p>其中：</p>
<ul>
<li>scheduler 指向虚拟线程调度器。</li>
<li>cont 保存可暂停和恢复的执行状态。</li>
<li>runContinuation 是提交给调度器的任务。</li>
<li>state 记录虚拟线程当前处于运行、挂起或可调度状态。</li>
<li>parkPermit 保存一次 unpark 许可。</li>
<li>carrierThread 指向当前承载该虚拟线程的平台线程。</li>
</ul>
<p>构造虚拟线程时，会创建 Continuation，并生成一个绑定到当前虚拟线程的方法引用：</p>
<pre><code class="language-java">this.cont = new VThreadContinuation(this, task);
this.runContinuation = this::runContinuation;</code></pre><p>方法引用可以展开成一个普通的 Runnable：</p>
<pre><code class="language-java">final class ResumeTask implements Runnable {
    private final VirtualThread virtualThread;
    ResumeTask(VirtualThread virtualThread) {
        this.virtualThread = virtualThread;
    }
    @Override
    public void run() {
        virtualThread.runContinuation();
    }
}</code></pre><p>调度队列中保存的核心内容是这样一个恢复任务。它持有 VirtualThread 引用，而 VirtualThread 再持有 Continuation。</p>
<pre><code class="language-text">ForkJoinPool.WorkQueue
    └── ForkJoinTask
          └── runContinuation
                └── VirtualThread
                      └── Continuation</code></pre><p>VirtualThread 不直接操作 ForkJoinPool 中的某个工作队列。它只调用：</p>
<pre><code class="language-java">scheduler.execute(runContinuation);</code></pre><p>默认调度器收到普通 Runnable 后，会将其包装为 ForkJoinTask，再放入内部队列。OpenJDK 的注释说明，在调度器 Worker 上提交时，任务进入本地队列；其他线程提交时，任务进入外部提交队列。（GitHub）</p>
<h2>虚拟线程第一次运行</h2>
<p>启动虚拟线程时，内部会把状态由 NEW 修改为 STARTED，随后提交 runContinuation：</p>
<pre><code class="language-java">void start() {
    if (!compareAndSetState(NEW, STARTED)) {
        throw new IllegalThreadStateException();
    }
    submitRunContinuation();
}
private void submitRunContinuation() {
    scheduler.execute(runContinuation);
}</code></pre><p>某条 Carrier Thread 从 ForkJoinPool 队列中取出任务，最终调用：</p>
<pre><code class="language-java">virtualThread.runContinuation();</code></pre><p>runContinuation() 的主要结构如下：</p>
<pre><code class="language-java">private void runContinuation() {
    int initialState = state();
    if (initialState != STARTED
            && initialState != UNPARKED
            && initialState != YIELDED) {
        return;
    }
    if (!compareAndSetState(initialState, RUNNING)) {
        return;
    }
    mount();
    try {
        cont.run();
    } finally {
        unmount();
        if (cont.isDone()) {
            afterDone();
        } else {
            afterYield();
        }
    }
}</code></pre><p>mount() 将当前平台线程记录为 Carrier，并修改 JVM 中的当前线程身份：</p>
<pre><code class="language-java">private void mount() {
    Thread carrier = Thread.currentCarrierThread();
    setCarrierThread(carrier);
    carrier.setCurrentThread(this);
}</code></pre><p>因此，虚拟线程中的业务代码调用：</p>
<pre><code class="language-java">Thread.currentThread()</code></pre><p>得到的是 VirtualThread 对象，而不是底层 Carrier Thread。</p>
<p>随后执行：</p>
<pre><code class="language-java">cont.run();</code></pre><p>第一次调用时，Continuation 从用户任务入口开始执行。OpenJDK 的 runContinuation() 确实是在 mount() 后调用 cont.run()，并在 finally 中完成 unmount() 和后续状态处理。（GitHub）</p>
<h2>Continuation.yield() 如何释放 Carrier</h2>
<p>假设业务代码的调用关系如下：</p>
<pre><code class="language-java">void handleRequest(Socket socket) throws IOException {
    User user = loadUser();
    byte[] buffer = new byte[1024];
    int count = socket.getInputStream().read(buffer);
    process(user, buffer, count);
}</code></pre><p>虚拟线程执行到网络等待位置时，Carrier Thread 上的逻辑调用栈可能是：</p>
<pre><code class="language-text">ForkJoinPool.runWorker()
VirtualThread.runContinuation()
Continuation.run()
handleRequest()
InputStream.read()
NioSocketImpl.implRead()
Poller.poll()
LockSupport.park()
VirtualThread.park()
Continuation.yield()</code></pre><p>执行 Continuation.yield() 后，JVM 会冻结属于当前 Continuation 的栈帧。局部变量、对象引用、调用关系和程序执行位置都会被保留。</p>
<p>例如，handleRequest() 对应的栈帧中可能存在：</p>
<pre><code class="language-text">socket  -&gt; Socket@100
user    -&gt; User@200
buffer  -&gt; byte[]@300
count   -&gt; 尚未赋值</code></pre><p>这些数据属于虚拟线程的执行状态，不再依赖当前 Carrier Thread 的平台栈。</p>
<p>冻结成功后，控制流会离开 Continuation，回到调用 cont.run() 的位置。此时属于虚拟线程的深层调用栈已经从 Carrier Thread 上移走，Carrier Thread 上只剩调度器相关栈帧：</p>
<pre><code class="language-text">ForkJoinPool.runWorker()
VirtualThread.runContinuation()</code></pre><p>cont.run() 随后返回，runContinuation() 进入 finally，执行 unmount()：</p>
<pre><code class="language-java">private void unmount() {
    Thread carrier = this.carrierThread;
    carrier.setCurrentThread(carrier);
    setCarrierThread(null);
}</code></pre><p>runContinuation() 返回后，Carrier Thread 回到 ForkJoinPool 的工作循环，从队列中获取其他任务。</p>
<p>后续某条 Carrier Thread 再次调用 cont.run() 时，JVM 恢复此前冻结的栈帧，原来的 Continuation.yield() 开始返回，调用链依次继续：</p>
<pre><code class="language-text">Continuation.yield() 返回
VirtualThread.park() 返回
LockSupport.park() 返回
Poller.poll() 返回
NioSocketImpl.implRead() 继续</code></pre><p>这里没有重新调用 handleRequest()，也没有从方法开头重新执行。恢复点位于此前暂停的位置。</p>
<h2>park() 为什么不会立即重新入队</h2>
<p>Continuation.yield() 只负责暂停执行，它并不决定虚拟线程什么时候继续。调度策略取决于是谁调用了它。</p>
<p>虚拟线程调用 Thread.yield() 时，当前线程依然具备运行条件，只是暂时让出 Carrier。执行栈冻结后，afterYield() 会立即重新提交 runContinuation：</p>
<pre><code class="language-text">RUNNING
  -&gt; YIELDING
  -&gt; YIELDED
  -&gt; 重新提交
  -&gt; RUNNING</code></pre><p>LockSupport.park() 表达的是等待条件尚未满足。例如等待网络数据、等待锁释放或者等待队列元素。执行栈冻结后，虚拟线程不能立即重新进入运行队列，否则会不断恢复、检查条件、再次挂起，造成 CPU 空转。</p>
<p>它的状态变化为：</p>
<pre><code class="language-text">RUNNING
  -&gt; PARKING
  -&gt; PARKED</code></pre><p>只有其他线程调用 unpark() 后，它才重新具备运行条件：</p>
<pre><code class="language-text">PARKED
  -&gt; UNPARKED
  -&gt; 重新提交
  -&gt; RUNNING</code></pre><p>OpenJDK 的 afterYield() 会检查当前状态。PARKING 会转为 PARKED，默认不重新提交；YIELDING 会转为 YIELDED 并立即提交。（GitHub）</p>
<p>对应代码可以简化为：</p>
<pre><code class="language-java">private void afterYield() {
    int s = state();
    if (s == PARKING) {
        setState(PARKED);
        if (parkPermit
                && compareAndSetState(PARKED, UNPARKED)) {
            submitRunContinuation();
        }
        return;
    }
    if (s == YIELDING) {
        setState(YIELDED);
        submitRunContinuation();
    }
}</code></pre><h2>Socket 读取如何进入 Poller</h2>
<p>下面进入网络 I/O 路径。</p>
<pre><code class="language-java">int count = socket.getInputStream().read(buffer);</code></pre><p>以 JDK 21 在 Linux 上的实现为例，JDK 会先尝试读取 Socket。内核接收缓冲区已有数据时，读取直接完成，不需要挂起虚拟线程。</p>
<p>如果当前没有数据，非阻塞读取会得到 EAGAIN 或等价状态。JDK 随后将文件描述符注册给 Poller，并挂起当前虚拟线程。JEP 444 描述了这一处理：JDK 中的阻塞网络操作无法立即完成时，虚拟线程会卸载；I/O 可以完成后，再把该虚拟线程提交回调度器。（OpenJDK）</p>
<p>简化后的读取逻辑如下：</p>
<pre><code class="language-java">int read(int fd, byte[] buffer) throws IOException {
    while (true) {
        int result = nonBlockingRead(fd, buffer);
        if (result &gt;= 0) {
            return result;
        }
        Poller.poll(fd);
    }
}</code></pre><p>Poller.poll(fd) 内部可以抽象为：</p>
<pre><code class="language-java">void poll(int fd) {
    Thread thread = Thread.currentThread();
    waiters.put(fd, thread);
    registerWithEpoll(fd);
    LockSupport.park();
}</code></pre><p>由于当前执行的是虚拟线程，Thread.currentThread() 返回对应的 VirtualThread。</p>
<p>此时存在两份关联。</p>
<p>Linux 内核中的 epoll 记录：</p>
<pre><code class="language-text">监听 fd 37 的可读事件</code></pre><p>JDK 的 Poller 记录：</p>
<pre><code class="language-text">fd 37 -&gt; VirtualThread@500</code></pre><p>操作系统只认识文件描述符，不认识 Java 虚拟线程。Poller 中的映射负责将内核事件重新关联到 Java 线程对象。JDK 21 的 Poller 就是网络事件通知和虚拟线程唤醒之间的中间层。（GitHub）</p>
<p>注册完成后，当前虚拟线程执行：</p>
<pre><code class="language-java">LockSupport.park();</code></pre><p>park() 进入 VirtualThread.park()，设置 PARKING 状态并调用 Continuation.yield()。执行栈冻结后，虚拟线程变成 PARKED，Carrier Thread 被释放。</p>
<p>等待期间的对象关系如下：</p>
<pre><code class="language-text">Linux epoll
    └── fd 37
Poller
    └── fd 37 -&gt; VirtualThread@500
VirtualThread@500
    ├── state = PARKED
    ├── carrierThread = null
    └── Continuation
          └── 保存 read() 及其上层调用栈
ForkJoinPool
    └── 当前没有该虚拟线程的恢复任务</code></pre><p>虚拟线程此时没有占用 Carrier Thread，也不在运行队列中。Poller 保存它的等待关系，Continuation 保存它的执行状态。</p>
<h2>网络就绪后如何重新调度</h2>
<p>网络数据到达后，Linux 协议栈将字节放入 Socket 的内核接收缓冲区，并将 fd 标记为可读。</p>
<p>Linux 上的 Poller 线程通常阻塞在 epoll_wait()。事件返回后，它获得就绪的文件描述符：</p>
<pre><code class="language-java">int[] readyFds = epollWait();
for (int fd : readyFds) {
    Thread thread = waiters.remove(fd);
    if (thread != null) {
        LockSupport.unpark(thread);
    }
}</code></pre><p>Poller 不执行用户的 handleRequest()，也不会在 Poller Thread 中调用原来的 InputStream.read()。它只找到等待该 fd 的虚拟线程，然后调用 unpark()。</p>
<p>VirtualThread.unpark() 的关键逻辑如下：</p>
<pre><code class="language-java">void unpark() {
    Thread currentThread = Thread.currentThread();
    if (!getAndSetParkPermit(true)
            && currentThread != this) {
        int s = state();
        if (s == PARKED
                && compareAndSetState(PARKED, UNPARKED)) {
            submitRunContinuation();
        }
    }
}</code></pre><p>submitRunContinuation() 最终执行：</p>
<pre><code class="language-java">scheduler.execute(runContinuation);</code></pre><p>这一步把虚拟线程重新交给调度器。OpenJDK 的 unpark() 会先设置 parkPermit，再通过状态 CAS 将已挂起的虚拟线程转为 UNPARKED，随后提交其恢复任务。（GitHub）</p>
<p>由于调用 unpark() 的通常是 Poller Thread，它不属于默认调度器的 Worker，因此这次任务一般走 ForkJoinPool 的外部提交路径。</p>
<p>某条 Carrier Thread 之后取出该任务，再次调用：</p>
<pre><code class="language-java">virtualThread.runContinuation();</code></pre><p>状态由 UNPARKED 修改为 RUNNING，虚拟线程挂载到新的 Carrier Thread，然后执行：</p>
<pre><code class="language-java">cont.run();</code></pre><p>此前冻结的调用栈被恢复，LockSupport.park() 返回，Socket 读取逻辑继续。</p>
<h2>网络数据没有进入调度队列</h2>
<p>调度队列中没有网络数据，也没有下面这种对象：</p>
<pre><code class="language-java">new ResumeTask(virtualThread, networkData);</code></pre><p>Poller 的职责是通知“fd 现在可能可读”，调度器负责安排虚拟线程继续执行。网络字节仍然保存在 Socket 的内核接收缓冲区中。</p>
<p>虚拟线程恢复后，读取循环再次调用操作系统：</p>
<pre><code class="language-java">int read(int fd, byte[] buffer) throws IOException {
    while (true) {
        int result = nonBlockingRead(fd, buffer);
        if (result &gt;= 0) {
            return result;
        }
        Poller.poll(fd);
        // park 返回后，再次进入循环
    }
}</code></pre><p>第一次 nonBlockingRead() 返回 EAGAIN，虚拟线程进入等待。</p>
<p>网络就绪后，Poller 调用 unpark()，虚拟线程恢复，随后再次执行：</p>
<pre><code class="language-java">nonBlockingRead(fd, buffer);</code></pre><p>这一次内核接收缓冲区已有数据，系统调用将字节复制到原来的 buffer 中，并返回字节数。</p>
<p>之所以仍然能够访问原来的 buffer，是因为 Continuation 保存的调用栈中仍持有：</p>
<pre><code class="language-text">buffer -&gt; byte[]@300</code></pre><p>数据传递路径为：</p>
<pre><code class="language-text">网卡
  -&gt; Linux 网络协议栈
  -&gt; Socket 内核接收缓冲区
  -&gt; 虚拟线程被唤醒
  -&gt; 恢复 read() 调用栈
  -&gt; 再次执行 read()
  -&gt; 数据复制到原 buffer</code></pre><p>调度队列只传递执行资格。Poller 只传递就绪通知。网络数据由内核 Socket 缓冲区保存。</p>
<h2>parkPermit 解决丢失唤醒</h2>
<p>虚拟线程准备挂起时，网络事件可能提前到达。</p>
<p>考虑下面的时序：</p>
<pre><code class="language-text">虚拟线程发现当前没有数据
Poller 注册 fd
网络数据立即到达
Poller 调用 unpark()
虚拟线程随后才执行 park()</code></pre><p>如果 unpark() 只能唤醒已经处于 PARKED 状态的线程，这次通知就会丢失。虚拟线程之后进入 park()，可能一直无法恢复。</p>
<p>parkPermit 相当于一个容量为 1 的许可：</p>
<pre><code class="language-java">private volatile boolean parkPermit;</code></pre><p>unpark() 首先设置：</p>
<pre><code class="language-java">parkPermit = true;</code></pre><p>park() 开始时先尝试消费许可：</p>
<pre><code class="language-java">if (getAndSetParkPermit(false)) {
    return;
}</code></pre><p>如果网络事件已经到达，park() 直接返回，不再冻结调用栈。</p>
<p>连续调用多次 unpark() 也只会保存一个许可：</p>
<pre><code class="language-java">unpark(thread);
unpark(thread);
unpark(thread);</code></pre><p>最终仍然只是：</p>
<pre><code class="language-text">parkPermit = true</code></pre><p>下一次 park() 消费该许可后恢复为 false。</p>
<p>这也是 JUC 同步器通常使用循环检查条件的原因：</p>
<pre><code class="language-java">while (!conditionSatisfied()) {
    LockSupport.park();
}</code></pre><p>park() 返回只说明线程重新获得执行机会，业务条件仍需重新判断。</p>
<h2>PARKING 解决重复调度</h2>
<p>RUNNING 和 PARKED 两个状态还不够，因为调用栈冻结需要一段执行过程。</p>
<p>虚拟线程会先执行：</p>
<pre><code class="language-java">state = PARKING;
Continuation.yield();</code></pre><p>在设置 PARKING 后、Continuation 完成冻结前，Poller 可能已经调用 unpark()。</p>
<p>此时原 Carrier Thread 仍可能执行当前虚拟线程，不能立刻把恢复任务提交给调度器。否则另一条 Carrier Thread 可能同时取出该虚拟线程。</p>
<p>因此，在 PARKING 状态下，unpark() 只设置 parkPermit，暂不提交任务。</p>
<p>等 Continuation 冻结完成，原 Carrier 执行 afterYield()：</p>
<pre><code class="language-java">state = PARKED;
if (parkPermit
        && compareAndSetState(PARKED, UNPARKED)) {
    submitRunContinuation();
}</code></pre><p>这样既保留了提前到达的唤醒通知，也避免两条 Carrier Thread 同时运行同一个虚拟线程。</p>
<p>另外，runContinuation() 在恢复前还会对状态执行 CAS。即使调度队列中意外出现两个指向同一虚拟线程的恢复任务，也只有一条 Carrier Thread 能成功将状态改为 RUNNING。</p>
<h2>三条线程如何协作</h2>
<p>完整实现虽然涉及 ForkJoinPool、Continuation、Poller 和操作系统，但运行过程仍然可以拆成三条普通线程循环。</p>
<p>Carrier Thread 的循环：</p>
<pre><code class="language-java">while (true) {
    Runnable task = scheduler.takeTask();
    task.run();
}</code></pre><p>Poller Thread 的循环：</p>
<pre><code class="language-java">while (true) {
    int[] readyFds = epollWait();
    for (int fd : readyFds) {
        VirtualThread thread = waiters.remove(fd);
        LockSupport.unpark(thread);
    }
}</code></pre><p>虚拟线程中的 Socket 读取循环：</p>
<pre><code class="language-java">while (true) {
    int count = tryRead(fd, buffer);
    if (count &gt;= 0) {
        return count;
    }
    registerPoller(fd, Thread.currentThread());
    LockSupport.park();
}</code></pre><p>三条执行流都保持线性。它们通过以下对象建立联系：</p>
<pre><code class="language-text">ForkJoinPool WorkQueue
    保存可运行的 runContinuation
VirtualThread
    持有 scheduler、Continuation 和线程状态
Poller
    保存 fd 到 VirtualThread 的等待关系
Socket 内核接收缓冲区
    保存网络数据</code></pre><p>Carrier Thread 调用恢复任务，Poller 根据 fd 找到等待线程，虚拟线程通过 scheduler 重新提交自己，Continuation 保存暂停位置和局部对象引用。</p>
<h2>完整调用路径</h2>
<p>将整个过程连接起来，可以得到下面的关键路径：</p>
<pre><code class="language-text">Thread.startVirtualThread
  -&gt; VirtualThread.start
  -&gt; submitRunContinuation
  -&gt; ForkJoinPool 工作队列
  -&gt; Carrier 取出任务
  -&gt; VirtualThread.runContinuation
  -&gt; mount
  -&gt; Continuation.run
  -&gt; 用户代码
  -&gt; Socket.read
  -&gt; 非阻塞 read 返回 EAGAIN
  -&gt; Poller 注册 fd
  -&gt; LockSupport.park
  -&gt; VirtualThread.park
  -&gt; Continuation.yield
  -&gt; 调用栈被冻结
  -&gt; cont.run 暂时返回
  -&gt; unmount
  -&gt; VirtualThread 进入 PARKED
  -&gt; Carrier 返回调度循环
网络数据到达
  -&gt; Socket 内核接收缓冲区
  -&gt; epoll_wait 返回 fd
  -&gt; Poller 找到 VirtualThread
  -&gt; LockSupport.unpark
  -&gt; VirtualThread.unpark
  -&gt; submitRunContinuation
  -&gt; ForkJoinPool 工作队列
  -&gt; Carrier 取出恢复任务
  -&gt; VirtualThread.runContinuation
  -&gt; mount
  -&gt; Continuation.run
  -&gt; 恢复被冻结的调用栈
  -&gt; LockSupport.park 返回
  -&gt; 再次执行 read
  -&gt; 数据复制到原 buffer
  -&gt; 用户代码继续</code></pre><p>理解这条路径后，虚拟线程的调度就不再神秘。Carrier Thread 始终执行普通任务循环；Continuation 允许当前任务在保留调用栈的情况下暂时返回；Poller 将网络 fd 与等待中的虚拟线程关联起来；unpark() 在事件到达后重新提交恢复任务。</p>
<h2>JDK 21 与 JDK 24 的 Pinning 差异</h2>
<p>JDK 21 中，如果虚拟线程在持有 synchronized Monitor 时进入某些阻塞操作，Continuation 可能无法卸载，虚拟线程和 Carrier Thread 会一起阻塞，这种情况称为 Pinning。</p>
<p>因此，JDK 21 阶段经常建议避免在长时间 synchronized 临界区中执行网络 I/O，必要时使用 ReentrantLock。</p>
<p>JDK 24 交付了 JEP 491，修改了 JVM Monitor 与虚拟线程的协作方式。虚拟线程在 synchronized 方法或代码块中阻塞时，绝大部分情况下也能卸载并释放 Carrier Thread。JDK 24 之后，选择 synchronized 还是 java.util.concurrent.locks，应更多依据可中断获取、公平性、超时和条件变量等语义，而不是单纯为了规避 Monitor Pinning。（OpenJDK）</p>
<p>持锁期间执行长时间网络 I/O 仍然需要谨慎。即便 Carrier 能够释放，锁本身仍然处于持有状态，其他需要进入同一临界区的线程仍会等待。</p>
<h2>参考源码</h2>
<ul>
<li>JEP 444：Virtual Threads。（OpenJDK）</li>
<li>JDK 21 java.lang.VirtualThread。（GitHub）</li>
<li>JDK 21 jdk.internal.vm.Continuation。（GitHub）</li>
<li>JDK 21 sun.nio.ch.Poller。（GitHub）</li>
<li>JEP 491：Synchronize Virtual Threads without Pinning。（OpenJDK）</li>
</ul>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/java-virtual-threads-network-io-carrier-continuation-poller#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">162894276609445888</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title>我的 TypeScript 全栈技术栈取舍：SSR、SPA 与 API 的边界</title>
    <link>https://liuyaowen.cn/posts/default/typescript-fullstack-ssr-spa-api-boundary</link>
    <pubDate>Thu, 23 Jul 2026 03:51:05 GMT</pubDate>
    <description>最近在整理一套可以长期复用的 TypeScript 项目模板。

我的项目大多是 AI 工具、管理后</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/typescript-fullstack-ssr-spa-api-boundary'>https://liuyaowen.cn/posts/default/typescript-fullstack-ssr-spa-api-boundary</a></blockquote>
          <p>最近在整理一套可以长期复用的 TypeScript 项目模板。</p>
<p>我的项目大多是 AI 工具、管理后台、工作流、编辑器和桌面端配套应用。技术栈以 React、TypeScript 和 Vite 为主，后端会使用 Node.js、PostgreSQL 和 Drizzle。</p>
<p>一开始考虑的是直接选择一个 React 全栈框架，把路由、SSR、数据请求、API 和部署统一起来。重点看了 Next.js、React Router Framework Mode 和 TanStack Start，也顺带关注了 Remix 3。</p>
<p>研究了一段时间后，我发现框架能力并不是越完整越合适。对于偏重交互的应用，SSR 和同构运行模型会引入不少额外概念，而这些概念未必能直接转化为产品收益。</p>
<p>最后确定的方向是：</p>
<pre><code class="language-text">公开站点使用 SSR 或 SSG
登录后的产品使用 SPA
业务能力通过统一 HTTP API 提供</code></pre><p>这篇文章记录一下具体的判断过程。</p>
<hr>
<h2>TanStack Start 的吸引力</h2>
<p>TanStack Start 很符合我对现代 TypeScript 框架的期待。</p>
<p>它建立在 TanStack Router 和 Vite 之上，提供类型安全路由、Search Params、Loader、Server Function、Middleware 和 SSR。再配合 TanStack Query、Form 和 Table，可以形成一套完整的前端应用开发体系。</p>
<p>一个简单的服务端查询可以写成：</p>
<pre><code class="language-ts">export const listProjectsFn = createServerFn()
  .handler(async () =&gt; {
    return db.select().from(projects)
  })</code></pre><p>路由中直接调用：</p>
<pre><code class="language-tsx">export const Route = createFileRoute('/projects')({
  loader: () =&gt; listProjectsFn(),
  component: ProjectsPage,
})</code></pre><p>组件通过路由读取结果：</p>
<pre><code class="language-tsx">function ProjectsPage() {
  const projects = Route.useLoaderData()

  return &lt;ProjectList projects={projects} /&gt;
}</code></pre><p>这种写法不需要手动维护 HTTP Client 和返回类型。Server Function 在客户端表现得像一个普通的异步函数，输入和输出类型可以直接推导。</p>
<p>对于简单页面，这套模式很顺畅。</p>
<p>问题主要出现在页面交互开始变复杂之后。</p>
<p>例如，列表需要手动刷新，可以调用：</p>
<pre><code class="language-ts">router.invalidate()</code></pre><p>如果列表使用 TanStack Query，则会写成：</p>
<pre><code class="language-ts">query.refetch()</code></pre><p>新增一条记录后，可以重新执行路由 Loader：</p>
<pre><code class="language-ts">await router.invalidate()</code></pre><p>也可以让 Query Cache 失效：</p>
<pre><code class="language-ts">await queryClient.invalidateQueries({
  queryKey: ['projects'],
})</code></pre><p>这时项目里可能同时存在两套数据生命周期：</p>
<pre><code class="language-text">Route Loader 管理路由数据
TanStack Query 管理客户端缓存</code></pre><p>如果继续加入 Query Options、Mutation、TanStack Form、TanStack Table 和 Zod，一个普通 Feature 很容易拆出很多文件：</p>
<pre><code class="language-text">project.server.ts
project.functions.ts
project.queries.ts
project.mutations.ts
project.schema.ts
project.form.ts
project.columns.tsx</code></pre><p>这些模块单独看都有合理用途，但组合起来后，简单业务的框架代码占比会比较高。</p>
<p>TanStack 生态本身没有要求项目必须这样写。问题在于它提供的能力很多，开发者很容易在项目初期就把完整方案铺开。</p>
<p>对于复杂 Dashboard、实时数据、无限列表和乐观更新，这些抽象能够发挥作用。对于普通 CRUD，它们可能显得偏重。</p>
<hr>
<h2>Next.js 的复杂度在运行边界</h2>
<p>Next.js 的代码通常更短。</p>
<p>Server Component 可以直接查询数据库：</p>
<pre><code class="language-tsx">export default async function ProjectPage() {
  const projectList = await db
    .select()
    .from(projects)

  return &lt;ProjectList projects={projectList} /&gt;
}</code></pre><p>修改操作可以写成 Server Action：</p>
<pre><code class="language-ts">'use server'

export async function createProject(
  formData: FormData,
) {
  await db.insert(projects).values({
    name: String(formData.get('name')),
  })

  revalidatePath('/projects')
}</code></pre><p>页面直接提交：</p>
<pre><code class="language-tsx">&lt;form action={createProject}&gt;
  &lt;input name="name" /&gt;
  &lt;button type="submit"&gt;创建&lt;/button&gt;
&lt;/form&gt;</code></pre><p>这种模式对内容站、电商和以服务端渲染为主的页面很合适。页面、数据读取和修改逻辑可以放在相近的位置，项目初期的代码量也不大。</p>
<p>随着客户端交互增加，代码会逐渐涉及：</p>
<pre><code class="language-text">Server Component
Client Component
Server Action
Route Handler
Suspense
缓存失效
序列化边界</code></pre><p>Next.js 的额外成本通常不表现为大量包装函数，而是运行模型和框架规则。</p>
<p>对于编辑器、工作流和管理后台，页面里往往有较多本地状态、轮询、实时数据、弹窗和多区域交互。此时 Server Component 与 Client Component 的边界需要持续维护。</p>
<p>Next.js 依然是成熟的生产方案，只是它更偏向以服务端组件和页面渲染为中心组织应用，不完全符合我目前的产品形态。</p>
<hr>
<h2>React Router 的数据模型比较统一</h2>
<p>React Router Framework Mode 使用 Loader 和 Action 组织路由数据。</p>
<pre><code class="language-tsx">export async function loader() {
  return {
    projects: await listProjects(),
  }
}

export async function action({
  request,
}: Route.ActionArgs) {
  const formData = await request.formData()

  return createProject({
    name: String(formData.get('name')),
  })
}

export default function ProjectsPage({
  loaderData,
}: Route.ComponentProps) {
  return (
    &lt;&gt;
      &lt;ProjectList projects={loaderData.projects} /&gt;

      &lt;Form method="post"&gt;
        &lt;input name="name" /&gt;
        &lt;button type="submit"&gt;创建&lt;/button&gt;
      &lt;/Form&gt;
    &lt;/&gt;
  )
}</code></pre><p>它的模型比较接近传统 Web 开发：</p>
<pre><code class="language-text">读取数据使用 loader
修改数据使用 action
页面提交使用 Form 或 fetcher</code></pre><p>Action 完成后，React Router 会重新验证相关 Loader，因此普通表单场景不需要额外维护 Query Cache。</p>
<p>这种方式适合表单和路由驱动的应用。Request、Response、Cookie、Session 和上传都使用标准 Web API，调试路径也比较清楚。</p>
<p>页面内操作较多时，Action 可能需要根据 <code>intent</code> 分发不同业务：</p>
<pre><code class="language-ts">switch (formData.get('intent')) {
  case 'create':
    return createProject(formData)

  case 'archive':
    return archiveProject(formData)

  case 'delete':
    return deleteProject(formData)
}</code></pre><p>也可以拆成独立 Resource Route。</p>
<p>React Router 的代码量通常介于 Next.js 和完整 TanStack 方案之间。它适合传统 Web 数据流，但对于复杂客户端缓存和多个组件共享远程状态，项目最终仍可能引入 TanStack Query。</p>
<hr>
<h2>SPA 的数据流更适合产品后台</h2>
<p>把几个全栈框架放在一起比较之后，我重新审视了普通 SPA。</p>
<p>一个 SPA 项目的远程数据流很直接：</p>
<pre><code class="language-text">React Component
    ↓
TanStack Query
    ↓
HTTP API
    ↓
业务逻辑
    ↓
数据库</code></pre><p>查询：</p>
<pre><code class="language-tsx">const projectsQuery = useQuery({
  queryKey: ['projects'],
  queryFn: projectApi.list,
})</code></pre><p>手动刷新：</p>
<pre><code class="language-tsx">&lt;button onClick={() =&gt; projectsQuery.refetch()}&gt;
  刷新
&lt;/button&gt;</code></pre><p>新增后刷新列表：</p>
<pre><code class="language-tsx">const createProjectMutation = useMutation({
  mutationFn: projectApi.create,

  onSuccess: () =&gt; {
    queryClient.invalidateQueries({
      queryKey: ['projects'],
    })
  },
})</code></pre><p>这套模型只有一份远程数据状态。</p>
<pre><code class="language-text">查询使用 useQuery
修改使用 useMutation
缓存刷新使用 invalidateQueries
页面参数使用 Router Search Params
本地状态使用 useState 或 Jotai</code></pre><p>对于登录后的管理系统，这样的模型已经足够。</p>
<p>管理后台、AI 工具和工作流应用通常不会依赖搜索引擎索引内部页面。用户登录后会停留较长时间，主要操作发生在浏览器端。</p>
<p>SSR 在这些页面中的收益有限，但会增加 Hydration、服务端与客户端模块边界、Loader 生命周期和缓存同步等问题。</p>
<p>因此，我决定不让整个产品都进入 SSR 模型。</p>
<hr>
<h2>SSR 与 SPA 按路由区域拆分</h2>
<p>最终的产品路由可以分成三个区域：</p>
<pre><code class="language-text">/                     官网首页
/pricing              定价页面
/blog/*                博客
/docs/*                文档
/tools/*               公开工具页面

/app/*                 登录后的产品
/app/projects/*        项目管理
/app/workflows/*       工作流
/app/analytics/*       数据分析

/api/*                 业务 API</code></pre><p>公开区域使用 SSR 或 SSG，负责：</p>
<ul>
<li>SEO；</li>
<li>首屏内容；</li>
<li>Open Graph；</li>
<li>博客和文档；</li>
<li>产品介绍；</li>
<li>公开工具页面。</li>
</ul>
<p><code>/app</code> 区域使用 SPA，负责：</p>
<ul>
<li>Dashboard；</li>
<li>工作流；</li>
<li>编辑器；</li>
<li>AI 生成；</li>
<li>实时数据；</li>
<li>复杂表格；</li>
<li>多面板交互。</li>
</ul>
<p>API 负责：</p>
<ul>
<li>认证；</li>
<li>权限；</li>
<li>业务逻辑；</li>
<li>数据库访问；</li>
<li>文件上传；</li>
<li>Webhook；</li>
<li>Desktop 和其他客户端。</li>
</ul>
<p>这样每个区域只维护一种主要数据模型。</p>
<hr>
<h2>最终的 Monorepo 结构</h2>
<p>目前比较适合我的结构是：</p>
<pre><code class="language-text">repo/
├── apps/
│   ├── site/
│   ├── web/
│   ├── api/
│   ├── desktop/
│   └── worker/
│
├── packages/
│   ├── ui/
│   ├── contracts/
│   ├── db/
│   ├── shared/
│   └── config/
│
├── pnpm-workspace.yaml
├── package.json
└── tsconfig.json</code></pre><p>各应用的职责如下：</p>
<pre><code class="language-text">apps/site
    官网、博客、文档和公开页面

apps/web
    登录后的 SPA 产品

apps/api
    统一业务 API

apps/desktop
    Tauri 客户端，需要时增加

apps/worker
    异步任务和 AI Job，需要时增加</code></pre><p>共享包保持克制。</p>
<p><code>packages/ui</code> 存放基础组件、样式 Token 和动画预设。</p>
<p><code>packages/contracts</code> 存放真正需要跨客户端共享的 Zod Schema、错误结构和 API 类型。</p>
<p><code>packages/db</code> 存放 Drizzle Schema、Migration 和数据库 Client。</p>
<p>业务代码默认留在具体 App 中，不会在项目开始阶段就提取到 <code>packages/core</code>。</p>
<hr>
<h2>API 使用 Hono</h2>
<p>API 层选择 Hono。</p>
<p>它的路由写法比较轻：</p>
<pre><code class="language-ts">const app = new Hono()

const projectRoutes = app
  .get('/projects', async (c) =&gt; {
    return c.json(await listProjects())
  })
  .post(
    '/projects',
    zValidator('json', createProjectSchema),
    async (c) =&gt; {
      const input = c.req.valid('json')
      const project = await createProject(input)

      return c.json(project, 201)
    },
  )</code></pre><p>客户端可以使用 Hono RPC：</p>
<pre><code class="language-ts">const client = hc&lt;AppType&gt;('/api')</code></pre><p>在 TanStack Query 中调用：</p>
<pre><code class="language-ts">export function useProjects() {
  return useQuery({
    queryKey: ['projects'],

    queryFn: async () =&gt; {
      const response =
        await client.projects.$get()

      if (!response.ok) {
        throw new Error(
          'Failed to load projects',
        )
      }

      return response.json()
    },
  })
}</code></pre><p>完整调用链如下：</p>
<pre><code class="language-text">Component
    ↓
TanStack Query
    ↓
Hono RPC Client
    ↓
Hono Route
    ↓
Feature Function
    ↓
Drizzle</code></pre><p>这套 API 可以同时提供给 Web、Desktop、浏览器扩展、Agent 和 Webhook。</p>
<p>相比框架内部专用的 Server Function，HTTP API 的复用范围更大。</p>
<hr>
<h2>后端按 Feature 组织</h2>
<p>前后端拆分不意味着后端需要复制 Java 的分层方式。</p>
<p>不会采用这种全局目录：</p>
<pre><code class="language-text">controllers/
services/
repositories/
mappers/
dto/</code></pre><p>API 仍然按 Feature 组织：</p>
<pre><code class="language-text">apps/api/src/features/project/
├── project.routes.ts
├── project.schema.ts
├── project.query.ts
├── project.command.ts
└── project.policy.ts</code></pre><p>简单 Feature 只保留必要文件：</p>
<pre><code class="language-text">project.routes.ts
project.schema.ts</code></pre><p>数据库实例可以直接导入：</p>
<pre><code class="language-ts">import { db } from '@repo/db'</code></pre><p>只有存在多个实现、复杂测试或运行时替换需求时，才使用参数注入或构造器注入。</p>
<p>不会为了保持架构形式，为每张表创建 Repository、Service 和 Mapper。</p>
<hr>
<h2>工具链保持激进，运行层保持稳定</h2>
<p>项目工具链会使用相对新的方案：</p>
<pre><code class="language-text">Vite
Rolldown
Oxc
Oxlint
Oxfmt
Vite+
Vitest
Playwright</code></pre><p>这些工具不会直接处理线上业务数据。即使某个版本出现兼容问题，也可以回退到对应的底层命令。</p>
<p>应用层使用：</p>
<pre><code class="language-text">React
TanStack Router
TanStack Query
TanStack Form
TanStack Table
Jotai
Tailwind CSS
shadcn/ui
Base UI
Motion
Lucide</code></pre><p>其中 TanStack Form、Table 和 Jotai 都按需使用，不作为每个页面的固定依赖。</p>
<p>数据与安全层相对保守：</p>
<pre><code class="language-text">Node.js LTS
PostgreSQL
Drizzle ORM
Better Auth 稳定版本
Zod
正式数据库 Migration
Cookie Session</code></pre><p>工具升级失败会影响开发效率，认证和数据库升级失败则可能影响用户和数据。两部分不应该使用相同的升级策略。</p>
<hr>
<h2>部署方式</h2>
<p>源码上拆成多个 App，不代表生产环境必须维护大量服务。</p>
<p>可以使用同一个域名：</p>
<pre><code class="language-text">example.com/
    → Site

example.com/app/*
    → SPA

example.com/api/*
    → API</code></pre><p>网关按路径分流：</p>
<pre><code class="language-text">/          → Site
/app/*     → Web
/api/*     → API</code></pre><p>认证保持同源 Cookie。SPA 调用 <code>/api</code> 时不需要处理跨域 Token，Desktop 等外部客户端再使用单独的认证方式。</p>
<p>个人项目早期也可以让 Hono 同时提供 API 和 SPA 静态文件，保持一个 Docker 镜像和一个 Node 进程。</p>
<hr>
<h2>结论</h2>
<p>这次技术选型没有确定一个统一管理所有能力的全栈框架，而是把不同类型的页面放回它们更适合的运行模型中。</p>
<pre><code class="language-text">公开内容使用 SSR 或 SSG
登录后的产品使用 SPA
通用业务能力使用 HTTP API</code></pre><p>最终技术栈大致是：</p>
<pre><code class="language-text">Site
    Astro 或其他 SSR / SSG 方案

Web
    Vite
    React
    TanStack Router
    TanStack Query

API
    Hono
    Drizzle
    PostgreSQL
    Better Auth
    Zod

UI
    Tailwind CSS
    shadcn/ui
    Base UI
    Motion
    Lucide

Tooling
    Vite
    Rolldown
    Oxc
    Oxlint
    Oxfmt
    Vitest
    Playwright</code></pre><p>这套结构保留了现代 TypeScript 工具链，也让业务后台继续使用熟悉的 SPA 数据流。</p>
<p>框架能力可以按项目需求增加。对于大多数偏工具型的产品，保持清楚的客户端、API 和数据边界，比在每个页面中同时组合 SSR、Loader、Server Function 和 Query 更容易维护。</p>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/typescript-fullstack-ssr-spa-api-boundary#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">162045532200505344</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title>为什么 Vite、Webpack、Rspack 能实现 @ 别名</title>
    <link>https://liuyaowen.cn/posts/default/vite-webpack-rspack</link>
    <pubDate>Tue, 09 Jun 2026 11:39:13 GMT</pubDate>
    <description>Resolver 是什么?

对于：

import Button from &quot;@/componen</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/vite-webpack-rspack'>https://liuyaowen.cn/posts/default/vite-webpack-rspack</a></blockquote>
          <h2>Resolver 是什么?</h2>
<p>对于：</p>
<pre><code class="language-ts">import Button from "@/components/Button";</code></pre><p>JavaScript 引擎实际上只看到了 这些：</p>
<pre><code class="language-text">specifier = "@/components/Button"</code></pre><p>接下来需要解决的问题是：</p>
<pre><code class="language-text">specifier
    ↓
真实模块</code></pre><p>这个过程称为 Resolution。</p>
<p>Node.js ESM 规范中的入口算法：</p>
<pre><code class="language-text">ESM_RESOLVE(specifier, parentURL)</code></pre><p>本质就是完成这个映射过程。</p>
<h2>Node 默认支持哪些 specifier</h2>
<p>Node 原生 Resolver 只支持四类：</p>
<pre><code class="language-js">import "./foo.js"
import "../foo.js"
import "/app/foo.js"</code></pre><p>路径模块。</p>
<pre><code class="language-js">import react from "react"</code></pre><p>包模块。</p>
<pre><code class="language-js">import db from "#db"</code></pre><p>Package Imports。</p>
<pre><code class="language-js">import x from "file:///app/a.js"</code></pre><p>URL 模块。</p>
<p>除此之外：</p>
<pre><code class="language-js">import x from "@/utils"</code></pre><p>Node 无法解析。</p>
<p>直接进入：</p>
<pre><code class="language-text">Module Not Found</code></pre><p>异常路径。</p>
<h2>为什么 Alias 可以工作</h2>
<p>假设存在：</p>
<pre><code class="language-js">import Button from "@/components/Button"</code></pre><p>Webpack 配置：</p>
<pre><code class="language-js">resolve: {
  alias: {
    "@": "/project/src"
  }
}</code></pre><p>Webpack 在执行真正解析前：</p>
<pre><code class="language-text">@/components/Button</code></pre><p>先经过 Alias Resolver：</p>
<pre><code class="language-text">/project/src/components/Button</code></pre><p>随后再进入正常模块解析。</p>
<p>因此 Alias 并不是一种新的模块类型。</p>
<p>而是：</p>
<pre><code class="language-text">旧 specifier
    ↓
新 specifier</code></pre><p>的一次转换。</p>
<h2>Vite Alias</h2>
<p>Vite：</p>
<pre><code class="language-ts">resolve: {
  alias: {
    "@": path.resolve(__dirname, "src")
  }
}</code></pre><p>执行流程与 Webpack 完全一致。</p>
<p>对于：</p>
<pre><code class="language-ts">import x from "@/utils/request"</code></pre><p>Vite 首先执行：</p>
<pre><code class="language-text">@ -&gt; /project/src</code></pre><p>得到：</p>
<pre><code class="language-text">/project/src/utils/request</code></pre><p>然后继续执行后续解析。</p>
<p>因此：</p>
<pre><code class="language-text">Webpack Alias
Vite Alias
Rspack Alias</code></pre><p>本质是同一个东西。</p>
<p>区别只是 Resolver 实现不同。</p>
<h2>TypeScript Paths 为什么经常失效</h2>
<p>配置：</p>
<pre><code class="language-json">{
  "compilerOptions": {
    "paths": {
      "@/*": ["src/*"]
    }
  }
}</code></pre><p>很多人会发现：</p>
<pre><code class="language-text">VSCode 正常
TypeScript 正常
Node 运行失败</code></pre><p>原因是：</p>
<pre><code class="language-text">paths</code></pre><p>不是运行时 Resolver。</p>
<p>TypeScript 只会在：</p>
<pre><code class="language-text">类型检查
IDE 跳转
编译阶段</code></pre><p>使用 paths。</p>
<p>Node 运行时根本不会读取：</p>
<pre><code class="language-json">tsconfig.json</code></pre><p>因此：</p>
<pre><code class="language-json">{
  "paths": {}
}</code></pre><p>无法让 Node 识别：</p>
<pre><code class="language-js">import "@/utils"</code></pre><p>必须额外配置：</p>
<pre><code class="language-text">Webpack Alias
Vite Alias
Rspack Alias
Node Loader</code></pre><p>其中之一。</p>
<h2>Node Loader 如何实现 Alias</h2>
<p>Node 提供：</p>
<pre><code class="language-js">export async function resolve(
  specifier,
  context,
  nextResolve
)</code></pre><p>允许接管解析过程。</p>
<p>例如：</p>
<pre><code class="language-js">export async function resolve(
  specifier,
  context,
  nextResolve
) {
  if (specifier.startsWith("@/")) {
    return {
      url: pathToFileURL(
        path.resolve(
          process.cwd(),
          "src",
          specifier.slice(2)
        )
      ).href,
      shortCircuit: true
    }
  }

  return nextResolve(specifier, context)
}</code></pre><p>此时：</p>
<pre><code class="language-js">import x from "@/utils"</code></pre><p>会变成：</p>
<pre><code class="language-text">file:///project/src/utils.js</code></pre><p>然后进入正常加载流程。</p>
<h2>Browser Import Map</h2>
<p>浏览器没有：</p>
<pre><code class="language-text">node_modules</code></pre><p>因此：</p>
<pre><code class="language-js">import React from "react"</code></pre><p>无法工作。</p>
<p>Import Map 的作用：</p>
<pre><code class="language-html">&lt;script type="importmap"&gt;
{
  "imports": {
    "react": "/vendor/react.js"
  }
}
&lt;/script&gt;</code></pre><p>执行：</p>
<pre><code class="language-js">import React from "react"</code></pre><p>时。</p>
<p>浏览器先映射：</p>
<pre><code class="language-text">react
    ↓
/vendor/react.js</code></pre><p>再继续加载。</p>
<h2>Resolver 的本质</h2>
<p>观察：</p>
<pre><code class="language-text">Webpack Alias
Vite Alias
Rspack Alias
TS Paths
Node Loader
Import Map</code></pre><p>会发现它们都在做同一件事：</p>
<pre><code class="language-text">specifier
    ↓
mapping
    ↓
new specifier
    ↓
real URL</code></pre><p>区别仅在于：</p>
<pre><code class="language-text">映射规则存放的位置不同</code></pre><h2>模块图的起点</h2>
<p>对于：</p>
<pre><code class="language-ts">import App from "./App"</code></pre><p>编译器首先执行：</p>
<pre><code class="language-text">specifier
    ↓
resolver
    ↓
real module</code></pre><p>得到：</p>
<pre><code class="language-text">App.tsx</code></pre><p>然后继续递归：</p>
<pre><code class="language-text">App.tsx
    ↓
Button.tsx
    ↓
request.ts
    ↓
...</code></pre><p>最终构建：</p>
<pre><code class="language-text">Module Graph</code></pre><p>Tree Shaking、Code Splitting、HMR、Chunk 拆分全部建立在这个图之上。</p>
<p>因此 Resolver 是整个现代前端工具链真正的入口。</p>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/vite-webpack-rspack#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">146582777024233523</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title>Node.js ESM 模块解析算法理解</title>
    <link>https://liuyaowen.cn/posts/default/node-js-esm</link>
    <pubDate>Tue, 09 Jun 2026 11:36:56 GMT</pubDate>
    <description>Node.js ESM 解析算法（Resolution Algorithm）的职责只有两个：

将 </description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/node-js-esm'>https://liuyaowen.cn/posts/default/node-js-esm</a></blockquote>
          <p>Node.js ESM 解析算法（Resolution Algorithm）的职责只有两个：</p>
<ol>
<li>将 <code>import</code> 中的 specifier 解析为最终 URL</li>
<li>判断该 URL 对应的模块格式</li>
</ol>
<p>例如：</p>
<pre><code class="language-js">import react from "react";
import util from "./utils.js";
import config from "#config";</code></pre><p>Node.js 最终需要得到：</p>
<pre><code class="language-text">react      -&gt; file:///project/node_modules/react/index.js
./utils.js -&gt; file:///project/src/utils.js
#config    -&gt; file:///project/src/config/index.js</code></pre><p>以及：</p>
<pre><code class="language-text">module
commonjs
json
wasm</code></pre><p>等模块格式。</p>
<p>整个 ESM 规范本质上是在描述：</p>
<pre><code class="language-text">specifier
    ↓
resolved URL
    ↓
module format
    ↓
load
    ↓
execute</code></pre><h1>一、ESM_RESOLVE 总体流程</h1>
<p>Node.js ESM 的入口算法：</p>
<pre><code class="language-text">ESM_RESOLVE(specifier, parentURL)</code></pre><p>可以简化为：</p>
<pre><code class="language-text">判断 specifier 类型

URL
路径
#imports
裸包名

        ↓

解析得到 URL

        ↓

检查文件是否合法

        ↓

判断模块格式

        ↓

返回给 Loader</code></pre><p>整个算法的核心目标：</p>
<pre><code class="language-text">specifier
      ↓
唯一 URL</code></pre><p>而不是像 CommonJS 那样不断猜测。</p>
<h1>二、specifier 分类</h1>
<p>Node.js 将 specifier 分为四类。</p>
<h2>1. URL Specifier</h2>
<p>例如：</p>
<pre><code class="language-js">import x from "file:///app/src/a.js";</code></pre><p>或者：</p>
<pre><code class="language-js">import x from "data:text/javascript,export default 1";</code></pre><p>如果本身是合法 URL：</p>
<pre><code class="language-text">new URL(specifier)</code></pre><p>成功。</p>
<p>那么直接返回。</p>
<h2>2. Relative Specifier</h2>
<p>例如：</p>
<pre><code class="language-js">import x from "./foo.js";
import y from "../bar.js";</code></pre><p>Node.js 根据当前模块位置解析。</p>
<p>假设：</p>
<pre><code class="language-text">file:///app/src/main.js</code></pre><p>执行：</p>
<pre><code class="language-js">import "./utils.js";</code></pre><p>得到：</p>
<pre><code class="language-text">file:///app/src/utils.js</code></pre><p>本质上就是：</p>
<pre><code class="language-js">new URL("./utils.js", parentURL)</code></pre><h2>3. Package Imports</h2>
<p>例如：</p>
<pre><code class="language-js">import db from "#db";</code></pre><p>或者：</p>
<pre><code class="language-js">import logger from "#utils/logger";</code></pre><p>以：</p>
<pre><code class="language-text">#</code></pre><p>开头。</p>
<p>会进入：</p>
<pre><code class="language-text">PACKAGE_IMPORTS_RESOLVE()</code></pre><p>读取当前包：</p>
<pre><code class="language-json">{
  "imports": {
    "#db": "./src/db/index.js",
    "#utils/*": "./src/utils/*.js"
  }
}</code></pre><p>例如：</p>
<pre><code class="language-js">import db from "#db";</code></pre><p>最终得到：</p>
<pre><code class="language-text">./src/db/index.js</code></pre><h2>4. Bare Specifier</h2>
<p>例如：</p>
<pre><code class="language-js">import react from "react";</code></pre><pre><code class="language-js">import lodash from "lodash";</code></pre><pre><code class="language-js">import axios from "axios";</code></pre><p>既不是：</p>
<pre><code class="language-text">URL
路径
#import</code></pre><p>就属于裸包名。</p>
<p>进入：</p>
<pre><code class="language-text">PACKAGE_RESOLVE()</code></pre><p>开始查找 node_modules。</p>
<h1>三、PACKAGE_RESOLVE</h1>
<p>这是 Node.js 查找 npm 包的核心逻辑。</p>
<p>例如：</p>
<pre><code class="language-js">import react from "react";</code></pre><p>当前文件：</p>
<pre><code class="language-text">/app/src/pages/home/index.js</code></pre><p>Node.js 会不断向上查找：</p>
<pre><code class="language-text">/app/src/pages/home/node_modules/react
/app/src/pages/node_modules/react
/app/src/node_modules/react
/app/node_modules/react
/node_modules/react</code></pre><p>直到找到。</p>
<p>等价于：</p>
<pre><code class="language-text">while(currentDirectory){
    查找 node_modules/packageName
    找不到继续向上
}</code></pre><h1>四、读取 package.json</h1>
<p>找到包以后：</p>
<pre><code class="language-text">node_modules/react/package.json</code></pre><p>Node.js 开始读取配置。</p>
<p>优先级如下：</p>
<pre><code class="language-text">exports
    ↓
main
    ↓
直接路径</code></pre><h1>五、exports 机制</h1>
<p>现代 Node.js 包解析几乎完全依赖 exports。</p>
<p>例如：</p>
<pre><code class="language-json">{
  "exports": {
    ".": "./dist/index.js",
    "./jsx-runtime": "./dist/jsx-runtime.js"
  }
}</code></pre><p>允许：</p>
<pre><code class="language-js">import React from "react";</code></pre><p>对应：</p>
<pre><code class="language-text">.</code></pre><p>得到：</p>
<pre><code class="language-text">./dist/index.js</code></pre><p>允许：</p>
<pre><code class="language-js">import jsx from "react/jsx-runtime";</code></pre><p>对应：</p>
<pre><code class="language-text">./jsx-runtime</code></pre><p>得到：</p>
<pre><code class="language-text">./dist/jsx-runtime.js</code></pre><p>但是：</p>
<pre><code class="language-js">import internal from "react/internal";</code></pre><p>如果 exports 中不存在：</p>
<pre><code class="language-json">"./internal"</code></pre><p>则报错：</p>
<pre><code class="language-text">Package Path Not Exported</code></pre><h1>六、exports 的本质</h1>
<p>exports 可以理解为：</p>
<pre><code class="language-text">包对外暴露的公开 API</code></pre><p>例如：</p>
<pre><code class="language-json">{
  "exports": {
    ".": "./dist/index.js",
    "./api": "./dist/api.js"
  }
}</code></pre><p>允许：</p>
<pre><code class="language-js">import x from "my-lib";
import y from "my-lib/api";</code></pre><p>禁止：</p>
<pre><code class="language-js">import z from "my-lib/dist/internal.js";</code></pre><p>即使文件真实存在。</p>
<h1>七、Conditional Exports</h1>
<p>exports 不一定是字符串。</p>
<p>可以是对象。</p>
<p>例如：</p>
<pre><code class="language-json">{
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "default": "./dist/index.js"
    }
  }
}</code></pre><p>Node.js 会根据条件选择。</p>
<p>ESM：</p>
<pre><code class="language-js">import x from "lib";</code></pre><p>匹配：</p>
<pre><code class="language-json">"import"</code></pre><p>得到：</p>
<pre><code class="language-text">./dist/index.mjs</code></pre><p>CommonJS：</p>
<pre><code class="language-js">require("lib");</code></pre><p>匹配：</p>
<pre><code class="language-json">"require"</code></pre><p>得到：</p>
<pre><code class="language-text">./dist/index.cjs</code></pre><h1>八、PACKAGE_SELF_RESOLVE</h1>
<p>假设：</p>
<pre><code class="language-json">{
  "name": "my-lib",
  "exports": {
    ".": "./src/index.js",
    "./core": "./src/core.js"
  }
}</code></pre><p>当前代码就在：</p>
<pre><code class="language-text">my-lib</code></pre><p>内部。</p>
<p>那么：</p>
<pre><code class="language-js">import core from "my-lib/core";</code></pre><p>不会去外部 node_modules 查找。</p>
<p>Node.js 会发现：</p>
<pre><code class="language-text">当前包名就是 my-lib</code></pre><p>直接使用当前 package.json 的 exports。</p>
<p>这就是：</p>
<pre><code class="language-text">PACKAGE_SELF_RESOLVE</code></pre><h1>九、imports 机制</h1>
<p>imports 和 exports 很像。</p>
<p>区别：</p>
<pre><code class="language-text">exports 给别人用
imports 给自己用</code></pre><p>例如：</p>
<pre><code class="language-json">{
  "imports": {
    "#db": "./src/db/index.js",
    "#utils/*": "./src/utils/*.js"
  }
}</code></pre><p>之后：</p>
<pre><code class="language-js">import db from "#db";</code></pre><p>实际解析：</p>
<pre><code class="language-text">./src/db/index.js</code></pre><p>再例如：</p>
<pre><code class="language-js">import stringUtil from "#utils/string.js";</code></pre><p>得到：</p>
<pre><code class="language-text">./src/utils/string.js</code></pre><h1>十、Pattern Match</h1>
<p>imports 和 exports 支持通配符。</p>
<p>例如：</p>
<pre><code class="language-json">{
  "exports": {
    "./features/*": "./src/features/*.js"
  }
}</code></pre><p>执行：</p>
<pre><code class="language-js">import user from "pkg/features/user";</code></pre><p>匹配：</p>
<pre><code class="language-text">*</code></pre><p>得到：</p>
<pre><code class="language-text">user</code></pre><p>最终：</p>
<pre><code class="language-text">./src/features/user.js</code></pre><h1>十一、PACKAGE_TARGET_RESOLVE</h1>
<p>这是 exports/imports 真正执行映射的地方。</p>
<p>支持四种类型。</p>
<h2>String</h2>
<pre><code class="language-json">{
  "exports": {
    ".": "./dist/index.js"
  }
}</code></pre><p>直接解析。</p>
<h2>Object</h2>
<pre><code class="language-json">{
  "exports": {
    ".": {
      "node": "./node.js",
      "browser": "./browser.js",
      "default": "./index.js"
    }
  }
}</code></pre><p>根据 conditions 选择。</p>
<h2>Array</h2>
<pre><code class="language-json">{
  "exports": {
    ".": [
      "./native.js",
      "./fallback.js"
    ]
  }
}</code></pre><p>前一个失败。</p>
<p>继续尝试下一个。</p>
<h2>null</h2>
<pre><code class="language-json">{
  "exports": {
    "./internal/*": null
  }
}</code></pre><p>明确禁止导出。</p>
<h1>十二、ESM_FILE_FORMAT</h1>
<p>URL 定位完成后。</p>
<p>Node.js 需要判断：</p>
<pre><code class="language-text">这个文件应该按什么格式加载？</code></pre><h2>.mjs</h2>
<pre><code class="language-text">module</code></pre><h2>.cjs</h2>
<pre><code class="language-text">commonjs</code></pre><h2>.json</h2>
<pre><code class="language-text">json</code></pre><h2>.wasm</h2>
<pre><code class="language-text">wasm</code></pre><h2>.node</h2>
<pre><code class="language-text">addon</code></pre><p>原生扩展模块。</p>
<h1>十三、type 字段的作用</h1>
<p>对于：</p>
<pre><code class="language-text">.js</code></pre><p>文件。</p>
<p>Node.js 会查找最近的 package.json。</p>
<p>例如：</p>
<pre><code class="language-json">{
  "type": "module"
}</code></pre><p>那么：</p>
<pre><code class="language-js">app.js</code></pre><p>被解释为：</p>
<pre><code class="language-text">ESM</code></pre><p>如果：</p>
<pre><code class="language-json">{
  "type": "commonjs"
}</code></pre><p>则：</p>
<pre><code class="language-js">app.js</code></pre><p>被解释为：</p>
<pre><code class="language-text">CommonJS</code></pre><p>因此：</p>
<pre><code class="language-text">.mjs</code></pre><p>永远 ESM。</p>
<pre><code class="language-text">.cjs</code></pre><p>永远 CommonJS。</p>
<pre><code class="language-text">.js</code></pre><p>取决于 type。</p>
<h1>十四、为什么 ESM 不支持目录导入</h1>
<p>CommonJS：</p>
<pre><code class="language-js">require("./foo");</code></pre><p>Node.js 会尝试：</p>
<pre><code class="language-text">foo.js
foo.json
foo.node
foo/index.js
foo/index.json</code></pre><p>ESM：</p>
<pre><code class="language-js">import "./foo";</code></pre><p>不会猜。</p>
<p>如果：</p>
<pre><code class="language-text">foo</code></pre><p>是目录。</p>
<p>直接报错：</p>
<pre><code class="language-text">Unsupported Directory Import</code></pre><p>正确写法：</p>
<pre><code class="language-js">import "./foo/index.js";</code></pre><p>或者：</p>
<pre><code class="language-js">import "./foo.js";</code></pre><h1>十五、LOOKUP_PACKAGE_SCOPE</h1>
<p>Node.js 如何找到最近的 package.json？</p>
<p>算法：</p>
<pre><code class="language-text">当前目录
      ↓
父目录
      ↓
继续向上
      ↓
直到根目录</code></pre><p>例如：</p>
<pre><code class="language-text">/app/src/pages/home/index.js</code></pre><p>查找：</p>
<pre><code class="language-text">/app/src/pages/home/package.json
/app/src/pages/package.json
/app/src/package.json
/app/package.json</code></pre><p>找到第一个就停止。</p>
<h1>十六、自定义 ESM Resolver</h1>
<p>Node.js 默认解析：</p>
<pre><code class="language-js">import x from "./a.js";
import y from "react";</code></pre><p>如果想支持：</p>
<pre><code class="language-js">import x from "@/utils";</code></pre><p>怎么办？</p>
<p>答案：</p>
<pre><code class="language-text">Loader Hook</code></pre><p>例如：</p>
<pre><code class="language-js">import { pathToFileURL } from "node:url";
import path from "node:path";

export async function resolve(
  specifier,
  context,
  nextResolve
) {
  if (specifier.startsWith("@/")) {
    return {
      url: pathToFileURL(
        path.resolve(
          process.cwd(),
          "src",
          specifier.slice(2)
        )
      ).href,
      shortCircuit: true
    };
  }

  return nextResolve(specifier, context);
}</code></pre><p>运行：</p>
<pre><code class="language-bash">node --loader ./loader.mjs app.js</code></pre><p>之后：</p>
<pre><code class="language-js">import util from "@/utils.js";</code></pre><p>就会自动映射：</p>
<pre><code class="language-text">src/utils.js</code></pre><h1>十七、完整解析链路</h1>
<p>Node.js ESM 解析可以总结为：</p>
<pre><code class="language-text">import specifier
        ↓
ESM_RESOLVE
        ↓
判断类型

URL
路径
#imports
裸包名

        ↓

PACKAGE_RESOLVE

        ↓

exports
imports
main

        ↓

得到最终 URL

        ↓

ESM_FILE_FORMAT

        ↓

module
commonjs
json
wasm

        ↓

Loader

        ↓

Execute</code></pre><h1>总结</h1>
<p>Node.js ESM 的核心思想不是“寻找文件”。</p>
<p>而是：</p>
<pre><code class="language-text">specifier
      ↓
确定 URL
      ↓
确定模块格式
      ↓
加载执行</code></pre><p>整个 exports、imports、type、conditional exports、loader 机制，本质上都是围绕这一目标构建的。</p>
<p>ESM 解析模型相比 CommonJS 更严格、更静态、更接近浏览器，也更适合现代工具链（Vite、Webpack、Rspack、Rollup、Turbopack）进行分析和优化。</p>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/node-js-esm#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">146582777024233522</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title>从 C++ 到 Rust、Go：把主流编译流程真正串起来</title>
    <link>https://liuyaowen.cn/posts/default/c-rust-go</link>
    <pubDate>Mon, 08 Jun 2026 00:49:13 GMT</pubDate>
    <description>最近在看 C++、Rust、Go 的编译过程。

刚开始最容易被一堆名词卡住：

GCC
Clang</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/c-rust-go'>https://liuyaowen.cn/posts/default/c-rust-go</a></blockquote>
          <p>最近在看 C++、Rust、Go 的编译过程。</p>
<p>刚开始最容易被一堆名词卡住：</p>
<ul>
<li>GCC</li>
<li>Clang</li>
<li>LLVM</li>
<li>AST</li>
<li>IR</li>
<li>SSA</li>
<li>MIR</li>
<li>GIMPLE</li>
<li>RTL</li>
<li>Linker</li>
<li>Loader</li>
</ul>
<p>单独看每个词，好像都能理解一点。但把它们放到一张图里，就很容易乱。</p>
<p>后来我发现，问题不在于名词太多，而在于没有从一段真实程序出发。</p>
<p>编译器不是简单地“把源码翻译成汇编”。更准确地说，它是在不断降低程序的抽象层级：</p>
<pre><code class="language-text">人类写的源码
    ↓
结构化表示
    ↓
语义明确的表示
    ↓
适合优化的表示
    ↓
接近机器的表示
    ↓
机器可以执行的二进制</code></pre><p>每一层中间表示，都是为了让某一类问题变得更容易处理。</p>
<p>下面用一段很小的 C++ 程序，把主流编译链路串起来。</p>
<pre><code class="language-cpp">extern "C" int printf(const char*, ...);

#define INC(x) ((x) + 1)

int add(int a, int b) {
    return a + b;
}

int main() {
    int x = INC(add(1, 2));
    printf("%d\n", x);
    return 0;
}</code></pre><p>这段代码故意保留了几个点：</p>
<pre><code class="language-text">宏：INC
函数调用：add
外部符号：printf
C++ 符号：add 会发生 name mangling
链接阶段：printf 需要从 C 标准库解析
优化空间：add(1, 2) 和 INC 可以被优化成常量</code></pre><p>一个小程序，基本够覆盖主流编译流程。</p>
<h2>1. 源码不是编译器真正想要的东西</h2>
<p>程序员看到的是：</p>
<pre><code class="language-cpp">int x = INC(add(1, 2));</code></pre><p>人很容易理解它大概等价于：</p>
<pre><code class="language-cpp">int x = ((add(1, 2)) + 1);</code></pre><p>但编译器不能一开始就这么理解。</p>
<p>源码只是字符流：</p>
<pre><code class="language-text">i n t 空格 x 空格 = 空格 I N C ...</code></pre><p>编译器必须先把字符处理成更稳定的结构。</p>
<p>这也是为什么现代编译流程不会从源码直接跳到汇编。源码适合人读，不适合机器分析。</p>
<h2>2. 预处理：C/C++ 最早的一层文本系统</h2>
<p>C/C++ 的第一步通常是预处理。</p>
<pre><code class="language-bash">clang++ -E main.cpp -o main.ii</code></pre><p>预处理器主要处理：</p>
<pre><code class="language-text">#include
#define
#ifdef
#pragma</code></pre><p>在这段程序里，最明显的是：</p>
<pre><code class="language-cpp">#define INC(x) ((x) + 1)</code></pre><p>预处理后，核心代码会变成类似这样：</p>
<pre><code class="language-cpp">int main() {
    int x = ((add(1, 2)) + 1);
    printf("%d\n", x);
    return 0;
}</code></pre><p>这一步有个很重要的认知：</p>
<blockquote>
<p>预处理不是语义分析。</p>
</blockquote>
<p>它不理解类型。</p>
<p>它不知道 <code>add</code> 是不是函数。</p>
<p>它也不知道 <code>INC(add(1, 2))</code> 是否合理。</p>
<p>它只是按照规则展开文本。</p>
<p>这也是 C/C++ 宏容易制造问题的原因。宏不是函数，它不会遵守函数那套类型检查和作用域规则。</p>
<p>例如：</p>
<pre><code class="language-cpp">#define SQUARE(x) x * x</code></pre><p>如果写：</p>
<pre><code class="language-cpp">SQUARE(1 + 2)</code></pre><p>会被展开成：</p>
<pre><code class="language-cpp">1 + 2 * 1 + 2</code></pre><p>而不是：</p>
<pre><code class="language-cpp">(1 + 2) * (1 + 2)</code></pre><p>很多 C/C++ 的奇怪问题，其实还没进入真正编译阶段，只是在预处理阶段就已经埋雷了。</p>
<h2>3. 词法分析：把字符切成 Token</h2>
<p>预处理后，编译器开始做词法分析。</p>
<p>源码片段：</p>
<pre><code class="language-cpp">int x = ((add(1, 2)) + 1);</code></pre><p>会被切成类似：</p>
<pre><code class="language-text">keyword      int
identifier   x
operator     =
punctuator   (
punctuator   (
identifier   add
punctuator   (
literal      1
punctuator   ,
literal      2
punctuator   )
punctuator   )
operator     +
literal      1
punctuator   )
punctuator   ;</code></pre><p>这一步只负责“切词”。</p>
<p>它知道 <code>int</code> 是关键字。</p>
<p>知道 <code>x</code> 是标识符。</p>
<p>知道 <code>1</code> 是数字字面量。</p>
<p>但它还不知道：</p>
<pre><code class="language-text">x 是局部变量
add 是函数
printf 是外部函数
1 能不能传给 add</code></pre><p>这些都不是词法分析负责的。</p>
<p>可以用 Clang 看 Token：</p>
<pre><code class="language-bash">clang++ -Xclang -dump-tokens -fsyntax-only main.cpp</code></pre><p>如果编译错误里出现非法字符、字符串没有闭合、数字字面量格式错误，通常就停在这一层附近。</p>
<h2>4. 语法分析：从 Token 变成 AST</h2>
<p>Token 还是线性的。</p>
<p>编译器需要知道程序结构。</p>
<p>例如：</p>
<pre><code class="language-cpp">int x = ((add(1, 2)) + 1);</code></pre><p>语法分析后会变成一棵树，简化后大概是：</p>
<pre><code class="language-text">VarDecl x : int
    └── BinaryOperator +
        ├── CallExpr add
        │   ├── IntegerLiteral 1
        │   └── IntegerLiteral 2
        └── IntegerLiteral 1</code></pre><p>这就是 AST，抽象语法树。</p>
<p>AST 的作用不是为了显得高级，而是为了把源码结构稳定下来。</p>
<p>源码是文本。</p>
<p>AST 是结构。</p>
<p>这一步之后，编译器终于知道：</p>
<pre><code class="language-text">这是一个变量声明
变量名是 x
初始化表达式是一个加法
加法左边是函数调用
加法右边是整数 1</code></pre><p>但 AST 仍然不能证明程序正确。</p>
<p>例如：</p>
<pre><code class="language-cpp">int x = foo(1, 2);</code></pre><p>即使 <code>foo</code> 根本不存在，也可以先构建 AST。</p>
<p>语法上没问题。</p>
<p>语义上才有问题。</p>
<p>可以用 Clang 看 AST：</p>
<pre><code class="language-bash">clang++ -Xclang -ast-dump -fsyntax-only main.cpp</code></pre><p>真实 C++ AST 会非常长。模板、重载、隐式转换、构造函数、析构函数都会出现在里面。</p>
<p>这也是 C++ 前端复杂度很高的原因。不是因为 Token 难切，也不是因为语法树难画，而是因为语义太复杂。</p>
<h2>5. 语义分析：编译器真正开始理解程序</h2>
<p>语义分析会处理这些问题：</p>
<pre><code class="language-text">add 是否存在
add 参数数量是否正确
add 参数类型是否匹配
printf 是否声明过
int x 的初始化是否合法
函数返回值是否符合声明</code></pre><p>对这段代码来说：</p>
<pre><code class="language-cpp">int x = INC(add(1, 2));</code></pre><p>预处理后是：</p>
<pre><code class="language-cpp">int x = ((add(1, 2)) + 1);</code></pre><p>语义分析会确认：</p>
<pre><code class="language-text">add 的类型是 int(int, int)
add(1, 2) 返回 int
返回值可以和 1 做加法
结果可以赋给 int x</code></pre><p>如果写成：</p>
<pre><code class="language-cpp">int x = add("hello", 2);</code></pre><p>语法仍然成立。</p>
<p>AST 也能构建。</p>
<p>但语义分析会报错，因为参数类型不匹配。</p>
<p>很多开发者平时说“编译错误”，其实可以再细分：</p>
<pre><code class="language-text">语法错误：代码结构不合法
语义错误：结构合法，但意思不合法
链接错误：单个文件能编译，但最终符号找不到</code></pre><p>这三个错误发生在完全不同的阶段。</p>
<p>Rust 的 Borrow Checker 也属于这个大范围。</p>
<p>例如：</p>
<pre><code class="language-rust">let r;

{
    let x = 1;
    r = &x;
}

println!("{}", r);</code></pre><p>这不是语法错误。</p>
<p>它的结构完全合法。</p>
<p>真正的问题是语义层面的：<code>x</code> 已经离开作用域，<code>r</code> 不能再引用它。</p>
<p>所以 Rust 的所有权、借用、生命周期，不应该理解成语法特性，而应该理解成更强的语义分析。</p>
<p>这也是 Rust 编译器比 Go 编译器复杂很多的原因之一。</p>
<h2>6. AST 不适合优化，所以需要 IR</h2>
<p>很多人第一次学编译流程，会以为：</p>
<pre><code class="language-text">源码
  ↓
AST
  ↓
汇编</code></pre><p>这个模型太粗糙。</p>
<p>现代编译器不会长期停留在 AST 上做优化。</p>
<p>原因很简单：</p>
<blockquote>
<p>AST 太接近源码。</p>
</blockquote>
<p>例如：</p>
<pre><code class="language-cpp">int x = INC(add(1, 2));</code></pre><p>AST 会保留很多源码结构。</p>
<p>但优化器真正关心的是：</p>
<pre><code class="language-text">这个值从哪里来
这个值被谁使用
这个计算有没有副作用
这个分支是否可达
这个变量能不能放进寄存器
这个函数能不能内联</code></pre><p>AST 对这些问题并不友好。</p>
<p>所以编译器会把 AST 降低到 IR。</p>
<p>IR 是 Intermediate Representation，中间表示。</p>
<p>比如这段：</p>
<pre><code class="language-cpp">int x = INC(add(1, 2));</code></pre><p>可以理解为降低成类似三地址码：</p>
<pre><code class="language-text">t1 = call add, 1, 2
t2 = t1 + 1
x = t2
call printf, "%d\n", x
return 0</code></pre><p>这就比 AST 更适合优化。</p>
<p>表达式树被拆成了简单指令。</p>
<p>数据依赖也更清楚。</p>
<p>LLVM IR 会更像这样，简化后：</p>
<pre><code class="language-llvm">define i32 @_Z3addii(i32 %a, i32 %b) {
entry:
  %sum = add nsw i32 %a, %b
  ret i32 %sum
}

define i32 @main() {
entry:
  %call = call i32 @_Z3addii(i32 1, i32 2)
  %add = add nsw i32 %call, 1
  call i32 (ptr, ...) @printf(ptr @.str, i32 %add)
  ret i32 0
}</code></pre><p>这里有几个点值得注意。</p>
<p><code>add</code> 在 C++ 里可能会被编译成：</p>
<pre><code class="language-text">_Z3addii</code></pre><p>这是 C++ name mangling。</p>
<p>因为 C++ 支持函数重载，链接器不能只看到一个名字 <code>add</code>。</p>
<p>例如：</p>
<pre><code class="language-cpp">int add(int, int);
double add(double, double);</code></pre><p>这两个函数源码里都叫 <code>add</code>，但链接时必须区分。</p>
<p>而 <code>printf</code> 因为声明成：</p>
<pre><code class="language-cpp">extern "C" int printf(const char*, ...);</code></pre><p>所以不会被 C++ 改名，仍然叫：</p>
<pre><code class="language-text">printf</code></pre><p>这就是 <code>extern &quot;C&quot;</code> 的实际意义之一：控制符号名，方便和 C ABI 对接。</p>
<h2>7. 优化：编译器开始改写程序</h2>
<p>如果不开优化，编译器会比较忠实地保留程序结构。</p>
<p>如果打开优化：</p>
<pre><code class="language-bash">clang++ -O2 -S -emit-llvm main.cpp -o main.ll</code></pre><p>这段代码很可能被优化成类似：</p>
<pre><code class="language-llvm">define i32 @main() {
entry:
  call i32 (ptr, ...) @printf(ptr @.str, i32 4)
  ret i32 0
}</code></pre><p><code>add(1, 2)</code> 没了。</p>
<p><code>INC</code> 展开后的 <code>+ 1</code> 也没了。</p>
<p><code>x</code> 也没了。</p>
<p>最后只剩：</p>
<pre><code class="language-text">printf("%d\n", 4)</code></pre><p>这不是编译器乱改代码。</p>
<p>这是它证明了这些改写不会改变可观察行为。</p>
<p>这里发生了几类典型优化：</p>
<pre><code class="language-text">函数内联：add(1, 2) 被展开
常量折叠：1 + 2 + 1 变成 4
死代码删除：中间变量 x 不再需要</code></pre><p>如果看过 Java JIT、Go SSA、Rust LLVM 优化，会发现这些优化名字经常重复出现。</p>
<p>原因很简单：程序优化的基本问题是相通的。</p>
<h2>8. SSA：现代优化器为什么喜欢“变量只赋值一次”</h2>
<p>SSA 是理解现代编译器优化的关键。</p>
<p>SSA，全称 Static Single Assignment。</p>
<p>意思是每个变量在静态程序里只赋值一次。</p>
<p>看这个例子：</p>
<pre><code class="language-cpp">int f(bool cond) {
    int x = 1;

    if (cond) {
        x = 2;
    }

    return x;
}</code></pre><p>普通代码里，<code>x</code> 被赋值两次。</p>
<p>控制流一复杂，优化器就很难判断某个位置的 <code>x</code> 到底来自哪里。</p>
<p>SSA 会把它变成类似：</p>
<pre><code class="language-text">x1 = 1

if cond goto then else merge

then:
  x2 = 2
  goto merge

merge:
  x3 = phi(x1, x2)
  return x3</code></pre><p>这里的：</p>
<pre><code class="language-text">phi(x1, x2)</code></pre><p>表示：</p>
<pre><code class="language-text">如果从未进入 if 的路径来，x3 = x1
如果从 then 分支来，x3 = x2</code></pre><p>SSA 的价值在于，它把“变量会变化”这件事变成了显式的数据流关系。</p>
<p>这对优化非常重要。</p>
<p>例如：</p>
<pre><code class="language-text">常量传播
死代码删除
公共子表达式消除
循环不变代码外提
值编号
逃逸分析</code></pre><p>都会受益于 SSA。</p>
<p>LLVM IR 是 SSA 形式。</p>
<p>Go 编译器内部使用 SSA。</p>
<p>HotSpot C2 也使用接近 SSA 的 Sea of Nodes IR。</p>
<p>很多数据库优化器虽然不叫 SSA，但也会维护类似的数据依赖关系。</p>
<p>这不是某个编译器的小技巧，而是现代优化系统的基础方法。</p>
<h2>9. 后端：从平台无关 IR 到机器相关 IR</h2>
<p>LLVM IR 仍然是平台无关的。</p>
<p>比如：</p>
<pre><code class="language-llvm">%sum = add i32 %a, %b</code></pre><p>这句话没有指定：</p>
<pre><code class="language-text">x86 用哪条指令
ARM 用哪条指令
结果放哪个寄存器
参数从哪里来
调用约定是什么</code></pre><p>这些都是后端的问题。</p>
<p>后端要处理：</p>
<pre><code class="language-text">指令选择
寄存器分配
指令调度
调用约定
栈帧布局
目标平台 ABI</code></pre><p>同一个加法，在 x86-64 下可能是：</p>
<pre><code class="language-asm">mov eax, edi
add eax, esi
ret</code></pre><p>在 ARM64 下可能是：</p>
<pre><code class="language-asm">add w0, w0, w1
ret</code></pre><p>IR 相同，目标机器不同，最终指令就不同。</p>
<p>这也是 LLVM 的价值所在。</p>
<p>语言实现者只要生成 LLVM IR，就可以复用 LLVM 的后端能力。</p>
<p>否则每写一门语言，都要自己支持：</p>
<pre><code class="language-text">x86-64
ARM64
RISC-V
Windows ABI
Linux ABI
macOS ABI
寄存器分配
指令选择
调试信息
异常处理</code></pre><p>这几乎不现实</p>
<h2>10. 目标文件：机器码还不是最终程序</h2>
<p>后端生成汇编后，汇编器会生成目标文件：</p>
<pre><code class="language-bash">clang++ -c main.cpp -o main.o</code></pre><p><code>main.o</code> 里面有机器码，但它还不是完整程序。</p>
<p>可以用：</p>
<pre><code class="language-bash">nm main.o</code></pre><p>看到符号。</p>
<p>可能会看到类似：</p>
<pre><code class="language-text">0000000000000000 T _Z3addii
0000000000000010 T main
                 U printf</code></pre><p>含义大概是：</p>
<pre><code class="language-text">_Z3addii 已定义
main 已定义
printf 未定义</code></pre><p><code>U printf</code> 不是错误。</p>
<p>它只是说：当前目标文件里没有 <code>printf</code> 的实现，链接阶段需要去别的地方找。</p>
<p>这就是很多人第一次遇到 <code>undefined reference</code> 时容易误解的地方。</p>
<p>它不是语法错误。</p>
<p>也不是类型错误。</p>
<p>它是链接阶段的符号解析失败。</p>
<h2>11. 链接：把分散的二进制拼成一个程序</h2>
<p>链接器做的事情可以粗略理解为：</p>
<pre><code class="language-text">main.o
  +
libc
  +
启动代码
  +
运行时库
  ↓
可执行文件</code></pre><p>它要解决：</p>
<pre><code class="language-text">符号解析
地址分配
重定位
静态库选择
动态库记录
入口点设置</code></pre><p>如果写 C++，还会涉及：</p>
<pre><code class="language-text">name mangling
ODR
模板实例化
静态初始化
异常表
RTTI
虚表</code></pre><p>比如：</p>
<pre><code class="language-text">undefined reference to `foo'</code></pre><p>说明链接器找不到 <code>foo</code> 的实现。</p>
<pre><code class="language-text">multiple definition of `foo'</code></pre><p>说明多个目标文件都提供了同一个强符号。</p>
<pre><code class="language-text">undefined reference to `_Z3addii'</code></pre><p>说明 C++ 符号名对不上，可能是声明和定义不一致，也可能是 C/C++ ABI 混用出了问题。</p>
<p>链接器是独立于编译器前端的另一个大系统。</p>
<p>很多大型 C++ 工程的构建问题，其实不是编译器问题，而是链接器问题。</p>
<h2>12. 加载：程序运行前，操作系统还要接手</h2>
<p>执行：</p>
<pre><code class="language-bash">./a.out</code></pre><p>也不是 CPU 直接从 <code>main</code> 开始跑。</p>
<p>Linux 下大致会经历：</p>
<pre><code class="language-text">内核读取 ELF
映射程序段到虚拟内存
加载动态链接器
加载共享库
处理重定位
初始化运行时
调用 main</code></pre><p>程序的内存布局大致包括：</p>
<pre><code class="language-text">.text     代码段
.rodata   只读数据
.data     已初始化全局变量
.bss      未初始化全局变量
heap      堆
stack     栈</code></pre><p>所以严格说，完整链路不是：</p>
<pre><code class="language-text">源码 → 可执行文件</code></pre><p>而是：</p>
<pre><code class="language-text">源码 → 目标文件 → 可执行文件 → 进程</code></pre><p>编译器解决“怎么生成程序”。</p>
<p>链接器解决“怎么合成程序”。</p>
<p>加载器解决“怎么把程序变成进程”。</p>
<h2>13. Clang、LLVM、GCC 应该怎么放在一张图里</h2>
<p>现在再看这些名字，就清楚很多。</p>
<p>Clang + LLVM 是这样：</p>
<pre><code class="language-text">C++ Source
    ↓
Clang Frontend
    ↓
Clang AST
    ↓
LLVM IR
    ↓
LLVM Optimizer
    ↓
LLVM Backend
    ↓
Object File
    ↓
Linker
    ↓
Executable</code></pre><p>GCC 是另一条链路：</p>
<pre><code class="language-text">C++ Source
    ↓
GCC C++ Frontend
    ↓
GENERIC
    ↓
GIMPLE
    ↓
GIMPLE SSA
    ↓
RTL
    ↓
GCC Backend
    ↓
Object File
    ↓
Linker
    ↓
Executable</code></pre><p>这两条链路解决的是同一类问题，但内部表示不同。</p>
<p>Clang 不是 LLVM 的别名。</p>
<p>LLVM 也不是 Clang 的别名。</p>
<p>更准确地说：</p>
<pre><code class="language-text">Clang 是 C/C++/Objective-C 前端。
LLVM 是中端和后端基础设施。
GCC 是另一套完整编译器系统。</code></pre><p>对应关系可以这样记：</p>
<pre><code class="language-text">Clang 负责读懂 C++。
LLVM 负责优化和生成机器码。
GCC 自己既有前端，也有中端和后端。</code></pre><h2>14. Rust 为什么有 HIR、MIR，又为什么用 LLVM</h2>
<p>Rust 的流程大致是：</p>
<pre><code class="language-text">Rust Source
    ↓
AST
    ↓
HIR
    ↓
THIR
    ↓
MIR
    ↓
Borrow Check
    ↓
LLVM IR
    ↓
LLVM Backend
    ↓
Machine Code</code></pre><p>Rust 这几层不是为了显得复杂。</p>
<p>每层都有明确责任。</p>
<p>AST 接近源码。</p>
<p>HIR 会把一些语法糖和表层结构降下来，让程序形态更稳定。</p>
<p>THIR 更适合类型检查后的表达式分析。</p>
<p>MIR 是 Rust 很关键的一层，适合表达控制流、move、borrow、drop、生命周期等语义。</p>
<p>LLVM IR 则负责进入通用优化和机器码生成阶段。</p>
<p>这里要注意一个区分：</p>
<pre><code class="language-text">MIR 是 Rust 语义层的 IR。
LLVM IR 是机器代码生成层的 IR。</code></pre><p>Borrow Checker 不能直接依赖 LLVM IR。</p>
<p>因为 LLVM IR 已经太低级，Rust 的 ownership、borrow、drop 语义在那一层已经不适合作为主要分析对象。</p>
<p>也不能直接依赖 AST。</p>
<p>因为 AST 太接近语法表面，语法糖太多，不适合做严格的数据流和控制流分析。</p>
<p>所以 Rust 需要 MIR。</p>
<p>这就是中间表示真正的价值：不是多一层抽象，而是为特定分析提供合适的程序形态。</p>
<h2>15. Go 为什么看起来简单很多</h2>
<p>Go 的编译流程大致是：</p>
<pre><code class="language-text">Go Source
    ↓
Parser
    ↓
AST
    ↓
Type Check
    ↓
SSA
    ↓
Go Backend
    ↓
Machine Code</code></pre><p>Go 默认不走 LLVM。</p>
<p>它有自己的 SSA 和后端。</p>
<p>这和 Go 的设计目标有关。</p>
<p>Go 语言本身刻意保持简单：</p>
<pre><code class="language-text">没有模板元编程
没有复杂宏系统
没有 Rust 那种 ownership 检查
没有 C++ 那种重载和隐式规则</code></pre><p>所以 Go 编译器可以更直接。</p>
<p>这也是 Go 编译速度快的一个重要原因。</p>
<p>当然，“简单”不是说 Go 编译器没有技术含量。</p>
<p>Go 的逃逸分析、内联、SSA 优化、栈增长、GC 相关元数据生成，都有不少工程细节。</p>
<p>但和 C++、Rust 相比，Go 前端语义复杂度确实低很多。</p>
<h2>16. 为什么现代编译器总在发明新的 IR</h2>
<p>到这里，基本可以回答最初的问题。</p>
<p>为什么 GCC 有 GIMPLE 和 RTL？</p>
<p>为什么 LLVM 有 LLVM IR、SelectionDAG、Machine IR？</p>
<p>为什么 Rust 有 HIR、THIR、MIR？</p>
<p>为什么 Go 有 SSA？</p>
<p>因为没有一种表示适合所有阶段。</p>
<p>AST 适合表示源码结构。</p>
<p>HIR 适合消除表层语法。</p>
<p>MIR 适合表达语言语义和控制流。</p>
<p>LLVM IR 适合做平台无关优化。</p>
<p>Machine IR 适合做寄存器分配和指令调度。</p>
<p>RTL 适合 GCC 后端描述接近机器的操作。</p>
<p>同一段程序，在不同阶段要被看成不同的东西。</p>
<p>对人来说，它是业务逻辑。</p>
<p>对前端来说，它是语法树。</p>
<p>对类型系统来说，它是约束集合。</p>
<p>对优化器来说，它是数据流图。</p>
<p>对后端来说，它是指令选择和寄存器分配问题。</p>
<p>对链接器来说，它是符号和重定位记录。</p>
<p>对加载器来说，它是 ELF 段和动态库依赖。</p>
<p>编译器的复杂性，正是来自这些视角之间的切换。</p>
<h2>17. 真正需要记住的主流流程</h2>
<p>如果只保留主流路径，不陷入全部细节，可以记成这条线：</p>
<pre><code class="language-text">源码
  ↓
预处理
  ↓
Token
  ↓
AST
  ↓
语义分析
  ↓
IR
  ↓
SSA
  ↓
优化
  ↓
目标相关 IR
  ↓
汇编 / 目标文件
  ↓
链接
  ↓
可执行文件
  ↓
加载执行</code></pre><p>对应到几个主流编译器：</p>
<pre><code class="language-text">Clang/LLVM:
C++ → Clang AST → LLVM IR → LLVM Backend → Object

GCC:
C++ → GENERIC → GIMPLE SSA → RTL → Object

Rust:
Rust → AST/HIR/THIR → MIR → LLVM IR → Object

Go:
Go → AST → Type Check → SSA → Go Backend → Object</code></pre><p>这张图比单独背 AST、IR、SSA 更有用。</p>
<p>因为它告诉你每个系统在同一条工业流水线里的位置。</p>
<h2>18. 学这套东西对工程有什么用</h2>
<p>这不是纯理论。</p>
<p>遇到宏问题，你知道去看预处理结果：</p>
<pre><code class="language-bash">clang++ -E main.cpp</code></pre><p>遇到语法和语义问题，你知道它发生在前端：</p>
<pre><code class="language-bash">clang++ -Xclang -ast-dump -fsyntax-only main.cpp</code></pre><p>想看优化前后的差异，你知道去看 LLVM IR：</p>
<pre><code class="language-bash">clang++ -O0 -S -emit-llvm main.cpp -o main_O0.ll
clang++ -O2 -S -emit-llvm main.cpp -o main_O2.ll</code></pre><p>遇到符号找不到，你知道看目标文件：</p>
<pre><code class="language-bash">nm main.o</code></pre><p>想看 ELF 结构：</p>
<pre><code class="language-bash">readelf -a a.out</code></pre><p>想看汇编：</p>
<pre><code class="language-bash">objdump -d a.out</code></pre><p>这些工具不是为了炫技。</p>
<p>它们对应的是编译流程中的不同阶段。</p>
<p>能定位阶段，问题就已经解决了一半。</p>
<h2>19. 编译器真正有价值的地方</h2>
<p>编译器最有价值的地方，不是几个术语，而是一种看复杂系统的方法。</p>
<p>一个复杂输入，不会被直接执行。</p>
<p>它会先变成某种中间表示。</p>
<p>然后被分析、约束、优化、降低，最后才进入执行层。</p>
<p>这个模式不只存在于编译器里。</p>
<p>数据库会把 SQL 变成逻辑计划和物理计划。</p>
<p>JVM 会把字节码变成内部 IR，再交给 JIT 优化。</p>
<p>React 会把 UI 更新组织成 Fiber 结构。</p>
<p>Kubernetes Scheduler 会把调度请求变成资源约束和评分模型。</p>
<p>这些系统看起来不一样，但底层思路很接近：</p>
<pre><code class="language-text">输入不是直接执行的。
先建立表示。
再基于表示做分析和优化。
最后执行。</code></pre><p>编译器只是这个思想最经典、最完整的版本。</p>
<p>如果能把 C++、Rust、Go、GCC、LLVM 这条线看懂，再回头看 JVM、数据库、前端框架、调度系统，很多设计就不再是孤立的名词了。</p>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/c-rust-go#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">146582777024233521</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title>Spring 6 循环依赖与早期代理：三级缓存的真实边界</title>
    <link>https://liuyaowen.cn/posts/default/spring</link>
    <pubDate>Fri, 05 Jun 2026 11:47:08 GMT</pubDate>
    <description>Spring 6 循环依赖与早期代理：三级缓存的真实边界

基线：Spring Framework </description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/spring'>https://liuyaowen.cn/posts/default/spring</a></blockquote>
          <h1>Spring 6 循环依赖与早期代理：三级缓存的真实边界</h1>
<blockquote>
<p>基线：Spring Framework 6、Spring Boot 3。本文解释一个具体代理不一致案例，不把结论推广为所有 Bean 的普遍行为。</p>
</blockquote>
<h2>1. 问题模型</h2>
<p>典型循环依赖：</p>
<pre><code class="language-text">A -&gt; B -&gt; Repository -&gt; A</code></pre><p>如果依赖注入发生在 Bean 完整初始化之前，Spring 可能通过三级缓存提前暴露引用。后续 Bean 初始化还可能经过 BeanPostProcessor 创建代理，于是出现：</p>
<pre><code class="language-text">Early Reference != Final Bean</code></pre><h2>2. 三级缓存能做什么</h2>
<p>三级缓存中的 ObjectFactory 可以提供早期引用。实现 SmartInstantiationAwareBeanPostProcessor 的后处理器有机会通过 getEarlyBeanReference 参与早期代理。</p>
<p>AbstractAutoProxyCreator 会记录 early proxy reference，避免同一个自动代理在初始化后重复创建。但不是每一个 BeanPostProcessor 都参与 early reference。</p>
<p>普通后处理器若只在 postProcessAfterInitialization 阶段包装 Bean，就可能造成早期引用与最终对象身份不一致。具体是否发生，取决于 Spring 版本、后处理器顺序和代理类型。</p>
<h2>3. Spring Boot 3 的处理建议</h2>
<p>Spring Boot 2.6 起循环引用默认更严格。Boot 3 项目应优先拆除循环依赖，不要用 spring.main.allow-circular-references=true 作为默认修复。</p>
<p>推荐顺序：</p>
<ol>
<li>拆分服务和 Repository 的职责；</li>
<li>使用事件或接口反转依赖方向；</li>
<li>对确实需要延迟创建的依赖使用 @Lazy，并评估生命周期；</li>
<li>检查事务、缓存、异步、异常翻译和自定义后处理器是否创建代理。</li>
</ol>
<h2>4. 排障方法</h2>
<p>记录实际 Spring Framework 版本；查看 Bean 的代理类型和 Advisor；分别跟踪 getEarlyBeanReference 与 postProcessAfterInitialization；确认是否存在多个后处理器重复包装；检查 allowCircularReferences 和 BeanDefinition 顺序。</p>
<p>三级缓存不是“所有循环依赖都能解决”的保证。它只是容器在特定生命周期阶段提供早期引用的机制，最终仍需要一个清晰且可维护的依赖图。</p>

          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/spring#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">146582777024233520</guid>
  <category>post</category>
<category>技术</category>
 </item>
  <item>
    <title># 从 Linux 网络到 Kubernetes 网络：彻底理解 Pod 是如何通信的</title>
    <link>https://liuyaowen.cn/posts/default/linux-kubernetes-pod</link>
    <pubDate>Tue, 02 Jun 2026 14:01:18 GMT</pubDate>
    <description>很多人学习 Kubernetes 网络时，一上来就接触：

Pod
Service
Ingress
</description>
    <content:encoded><![CDATA[
      <blockquote>This rendering is produced by marked and may have formatting issues. For the best experience, visit: <a href='https://liuyaowen.cn/posts/default/linux-kubernetes-pod'>https://liuyaowen.cn/posts/default/linux-kubernetes-pod</a></blockquote>
          <p>很多人学习 Kubernetes 网络时，一上来就接触：</p>
<ul>
<li>Pod</li>
<li>Service</li>
<li>Ingress</li>
<li>CNI</li>
<li>Flannel</li>
<li>Calico</li>
</ul>
<p>结果越学越乱。</p>
<p>真正的问题在于：</p>
<blockquote>
<p>Kubernetes 网络并不是一个独立体系，而是建立在 Linux 网络之上的一层抽象。</p>
</blockquote>
<p>如果不了解 Linux 网络，那么 Pod、Service、VXLAN 这些概念都会变成黑盒。</p>
<h2>一、网络世界只有三个核心概念</h2>
<p>整个网络可以抽象成三件事：</p>
<pre><code class="language-text">设备
地址
转发</code></pre><p>对应：</p>
<pre><code class="language-text">设备：
网卡
交换机
路由器

地址：
MAC
IP

转发：
交换
路由
NAT</code></pre><h2>二、MAC 与 IP</h2>
<h3>MAC 地址</h3>
<p>MAC 是二层地址。</p>
<pre><code class="language-text">00:11:22:33:44:55</code></pre><p>作用是在同一个局域网内定位设备。</p>
<h3>IP 地址</h3>
<p>IP 是三层地址。</p>
<pre><code class="language-text">192.168.1.10
10.244.1.2
8.8.8.8</code></pre><p>作用是跨网络定位设备。</p>
<h2>三、交换机、ARP、路由器</h2>
<p>交换机工作在二层，只看 MAC 地址，不看 IP、TCP、HTTP。</p>
<p>ARP 负责：</p>
<pre><code class="language-text">IP -&gt; MAC</code></pre><p>路由器工作在三层：</p>
<pre><code class="language-text">查看目标 IP
↓
查路由表
↓
决定下一跳</code></pre><p>路由表示例：</p>
<pre><code class="language-bash">ip route</code></pre><pre><code class="language-text">192.168.1.0/24 dev eth0
default via 192.168.1.1</code></pre><p>含义：</p>
<pre><code class="language-text">同网段直接发送
其他流量交给默认网关</code></pre><h2>四、NAT</h2>
<p>私网地址不能直接在公网路由：</p>
<pre><code class="language-text">192.168.x.x
172.16.x.x
10.x.x.x</code></pre><p>所以路由器会做 SNAT：</p>
<pre><code class="language-text">SRC 192.168.1.10
↓
SRC 公网 IP</code></pre><p>并通过 <code>conntrack</code> 记录连接映射，用于回包恢复。</p>
<h2>五、Linux Network Namespace</h2>
<p>Namespace 是 Linux 的隔离机制。</p>
<p>Network Namespace 隔离的是一整套网络栈：</p>
<pre><code class="language-text">网卡
IP
路由表
ARP 表
iptables
端口空间</code></pre><p>所以容器不是虚拟机，而是：</p>
<pre><code class="language-text">普通 Linux 进程
+
Namespace 隔离
+
Cgroups 限制
+
RootFS 文件系统</code></pre><h2>六、veth Pair</h2>
<p>Namespace 之间需要连接，Linux 提供了 <code>veth pair</code>。</p>
<pre><code class="language-text">vethA &lt;====&gt; vethB</code></pre><p>它像一根虚拟网线：</p>
<pre><code class="language-text">从 vethA 发出的包，会从 vethB 出来
从 vethB 发出的包，会从 vethA 出来</code></pre><h2>七、Linux Bridge</h2>
<p>Bridge 是软件交换机。</p>
<pre><code class="language-text">ContainerA
   |
 Bridge
   |
ContainerB</code></pre><p>它和物理交换机一样：</p>
<pre><code class="language-text">学习 MAC
查 MAC 表
转发数据帧</code></pre><h2>八、Docker 网络</h2>
<p>Docker 默认网络结构：</p>
<pre><code class="language-text">Container
  eth0
    │
veth
    │
docker0
    │
Host</code></pre><p>其中 <code>docker0</code> 是 Linux Bridge，同时被 Docker 配置了 IP：</p>
<pre><code class="language-text">docker0 = 172.17.0.1</code></pre><p>所以 <code>docker0</code> 既是交换机，也是容器默认网关。</p>
<p>Docker 自己维护 IPAM：</p>
<pre><code class="language-text">172.17.0.0/16</code></pre><p>容器 IP 通常由 Docker 分配：</p>
<pre><code class="language-text">172.17.0.2
172.17.0.3
172.17.0.4</code></pre><h2>九、Pod 是什么</h2>
<p>Pod 不是容器。</p>
<p>一个 Pod 里可以有多个容器：</p>
<pre><code class="language-text">Pod
├── app
└── sidecar</code></pre><p>真正持有网络命名空间的是 <code>pause container</code>。</p>
<pre><code class="language-text">pause container
└── Network Namespace

app
└── 加入 pause 的 Network Namespace

sidecar
└── 加入 pause 的 Network Namespace</code></pre><p>所以同一个 Pod 内多个容器共享：</p>
<pre><code class="language-text">IP
localhost
端口空间</code></pre><h2>十、Kubernetes 单节点网络</h2>
<p>很多 CNI 会在 Node 上创建类似 <code>docker0</code> 的网桥，比如：</p>
<pre><code class="language-text">cni0</code></pre><p>结构：</p>
<pre><code class="language-text">PodA
  |
veth
  |
cni0
  |
veth
  |
PodB</code></pre><p>同节点 Pod 通信时：</p>
<pre><code class="language-text">PodA ARP 查询 PodB MAC
↓
cni0 广播
↓
PodB 回复
↓
cni0 根据 MAC 表转发</code></pre><p>这个过程本质是二层交换。</p>
<h2>十一、CNI 是什么</h2>
<p>Kubernetes 本身不直接实现网络。</p>
<p>它通过 CNI 插件处理 Pod 网络。</p>
<p>常见 CNI：</p>
<pre><code class="language-text">Flannel
Calico
Cilium</code></pre><p>CNI 负责：</p>
<pre><code class="language-text">创建 veth
配置 IP
配置路由
接入 Bridge 或其他数据平面
维护跨节点通信</code></pre><p>Pod IP 通常由 CNI 的 IPAM 模块分配，不是 Kubernetes 或 Linux 自动分配。</p>
<h2>十二、跨节点 Pod 通信</h2>
<p>假设：</p>
<pre><code class="language-text">Node1
PodA = 10.244.1.2

Node2
PodB = 10.244.2.2</code></pre><p>PodA 访问 PodB：</p>
<pre><code class="language-text">10.244.1.2 -&gt; 10.244.2.2</code></pre><p>问题是：</p>
<pre><code class="language-text">Node1 怎么知道 10.244.2.0/24 在 Node2？</code></pre><p>这就是 CNI 要解决的问题。</p>
<h2>十三、VXLAN</h2>
<p>VXLAN 是：</p>
<pre><code class="language-text">L2 Overlay over L3</code></pre><p>意思是：</p>
<pre><code class="language-text">把二层网络封装到三层 IP 网络里面</code></pre><p>原始 Pod 包：</p>
<pre><code class="language-text">SRC 10.244.1.2
DST 10.244.2.2</code></pre><p>经过 VXLAN 封装：</p>
<pre><code class="language-text">Outer IP:
SRC Node1IP
DST Node2IP

UDP

VXLAN Header

Inner IP:
SRC 10.244.1.2
DST 10.244.2.2</code></pre><p>包结构：</p>
<pre><code class="language-text">Outer Ethernet
Outer IP
UDP
VXLAN
Inner Ethernet
Inner IP
TCP
HTTP</code></pre><p>中间物理网络只看到：</p>
<pre><code class="language-text">Node1IP -&gt; Node2IP</code></pre><p>Node2 收到后解 VXLAN，再把原始 Pod 包送给 PodB。</p>
<h2>十四、flannel.1 是什么</h2>
<p><code>flannel.1</code> 不是进程。</p>
<p>它是 Linux VXLAN 网络设备。</p>
<p>类似：</p>
<pre><code class="language-text">eth0
docker0
cni0
vethxxx</code></pre><p>路由表里：</p>
<pre><code class="language-text">10.244.2.0/24 dev flannel.1</code></pre><p>意思是：</p>
<pre><code class="language-text">去 10.244.2.0/24 的流量
交给 flannel.1 这个 VXLAN 设备处理</code></pre><p>然后 Linux 内核负责 VXLAN 封装。</p>
<p><code>flanneld</code> 才是进程，它负责：</p>
<pre><code class="language-text">创建 flannel.1
写路由表
维护 Node 和 PodCIDR 的映射</code></pre><h2>十五、Service 的本质</h2>
<p>Service 提供稳定访问入口。</p>
<p>例如：</p>
<pre><code class="language-text">Service IP = 10.96.0.10</code></pre><p>但这个 IP 通常不是某张真实网卡上的 IP。</p>
<p>Service 本质是：</p>
<pre><code class="language-text">虚拟 IP
+
iptables / IPVS 转发规则</code></pre><h2>十六、kube-proxy</h2>
<p><code>kube-proxy</code> 监听：</p>
<pre><code class="language-text">Service
Endpoint / EndpointSlice</code></pre><p>然后生成转发规则。</p>
<p>例如：</p>
<pre><code class="language-text">10.96.0.10:8080
↓ DNAT
10.244.1.2:8080</code></pre><p>所以访问 Service 时，真实过程是：</p>
<pre><code class="language-text">访问 Service IP
↓
iptables / IPVS 命中规则
↓
DNAT 到某个后端 Pod IP
↓
通过 CNI 网络转发到目标 Pod</code></pre><h2>十七、CoreDNS</h2>
<p>Pod 内访问服务名：</p>
<pre><code class="language-bash">curl user-service</code></pre><p>解析过程：</p>
<pre><code class="language-text">user-service
↓
CoreDNS
↓
Service ClusterIP
↓
kube-proxy
↓
Pod IP</code></pre><h2>十八、Ingress</h2>
<p>Ingress 是规则，不是真正的网关进程。</p>
<p>真正处理流量的是 Ingress Controller，比如：</p>
<pre><code class="language-text">Nginx Ingress Controller
Traefik
APISIX
Kong</code></pre><p>外部请求链路：</p>
<pre><code class="language-text">Browser
↓
LoadBalancer / NodePort
↓
Ingress Controller
↓
Service
↓
Pod</code></pre><p>Ingress Controller 通常是七层反向代理，会解析：</p>
<pre><code class="language-text">Host
Path
TLS
HTTP Header</code></pre><h2>十九、完整链路总结</h2>
<p>Linux 网络基础：</p>
<pre><code class="language-text">MAC
↓
ARP
↓
交换机
↓
路由器
↓
路由表
↓
NAT</code></pre><p>容器网络基础：</p>
<pre><code class="language-text">Namespace
↓
veth
↓
Bridge
↓
IPAM
↓
Docker 网络</code></pre><p>Kubernetes 网络：</p>
<pre><code class="language-text">Pod
↓
CNI
↓
PodCIDR
↓
cni0 / route / eBPF
↓
VXLAN / BGP
↓
Service
↓
CoreDNS
↓
Ingress</code></pre><h2>二十、最终结论</h2>
<p>Kubernetes 网络不是凭空出现的新体系。</p>
<p>它本质是：</p>
<pre><code class="language-text">Linux 网络能力
+
CNI 自动化
+
Kubernetes 声明式编排</code></pre><p>更具体地说：</p>
<pre><code class="language-text">Kubernetes 网络
=
Namespace
+
veth
+
Bridge
+
Route
+
NAT
+
iptables / IPVS
+
VXLAN / BGP / eBPF
+
CNI</code></pre><p>理解 Kubernetes 网络，正确路径不是先背 Pod、Service、Ingress，而是先理解：</p>
<pre><code class="language-text">Linux 如何转发一个包
容器如何拥有自己的网络栈
Pod 如何通过 CNI 接入网络
跨节点 Pod 如何通过 VXLAN 或路由互通
Service 如何通过 DNAT 找到后端 Pod
Ingress 如何作为七层入口转发流量</code></pre>
          <p style='text-align: right'>
          <a href='https://liuyaowen.cn/posts/default/linux-kubernetes-pod#comments'>Finished reading? Leave a comment</a>
          </p>
    ]]>
    </content:encoded>
  <guid isPermaLink="false">146582777024233519</guid>
  <category>post</category>
<category>技术</category>
 </item>
  
</channel>
</rss>