<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <author>
    <name>yuluo</name>
  </author>
  <generator uri="https://hexo.io/">Hexo</generator>
  <icon>https://iyuluo.com/icon.png</icon>
  <id>https://iyuluo.com/</id>
  <link href="https://iyuluo.com/" rel="alternate"/>
  <link href="https://iyuluo.com/atom.xml" rel="self"/>
  <rights>All rights reserved 2026, yuluo</rights>
  <subtitle>记录Python、Linux、macOS、Android、Git等技术笔记与开发实践</subtitle>
  <title>雨落杂记</title>
  <updated>2026-10-14T15:06:11.587Z</updated>
  <entry>
    <author>
      <name>yuluo</name>
    </author>
    <category term="Apple" scheme="https://iyuluo.com/categories/Apple/"/>
    <category term="Apple" scheme="https://iyuluo.com/tags/Apple/"/>
    <content>
      <![CDATA[<p>上一篇<a href="https://iyuluo.com/2026/10/13/macOS-%E5%BA%94%E7%94%A8%E5%86%99%E5%AE%8C%E4%B9%8B%E5%90%8E%EF%BC%8C%E6%80%8E%E4%B9%88%E4%BA%A4%E5%88%B0%E7%94%A8%E6%88%B7%E6%89%8B%E9%87%8C%EF%BC%88%E4%BA%8C%EF%BC%89%EF%BC%9A%E4%BB%8E%E6%89%8B%E5%8A%A8%E6%B5%81%E7%A8%8B%E5%88%B0%E4%B8%80%E6%9D%A1%E5%91%BD%E4%BB%A4/">《从手动流程到一条命令》</a>把本机发布串起来了：准备版本、测试、签名、公证、附加票据，最后生成安装包和更新清单。</p><p>应用能发出去了，另一个问题就冒出来了：官网还得跟着改。</p><p>发一个新构建，要修改中文首页、英文首页、两份下载页、两份更新日志。版本号改了，包大小可能没改；下载链接换了，搜索引擎读取的元数据还指着旧包。页面都能打开，里面的信息却对不上。</p><p>我这次做的事，就是让 SSHGuard 的官网和应用读取同一份发布数据。网站继续用简单的 HTML 和 CSS，Cloudflare 只负责补上容易漏改的版本信息。</p><p>这篇按实际项目讲清楚：Pages、R2 和 Pages Functions 分别做什么，为什么不用再加一份 JSON 清单，以及下一次发布到底还要手动做哪些事。</p><span id="more"></span><p>下文的部署域名、桶名、绑定名和项目专属路径均使用示例值，不代表实际后台配置。<code>public</code>、<code>functions</code>、<code>_routes.json</code> 等 Cloudflare 约定名称保留；使用时请将示例替换为自己的配置。</p><h2 id="一、官网需要更新的，其实只有一小部分"><a href="#一、官网需要更新的，其实只有一小部分" class="headerlink" title="一、官网需要更新的，其实只有一小部分"></a>一、官网需要更新的，其实只有一小部分</h2><p>先把内容分开看。</p><p>产品介绍、截图、安装指南和隐私说明，属于网站内容。它们并不会每次发版都变化，继续留在网站仓库里维护就好。</p><p>版本号、构建号、包大小、下载链接和更新日志，属于发布内容。应用打包时已经知道这些信息，再到官网手填一次，很容易出现偏差。</p><p>所以这次没有重新搭建网站，也没有引入后台管理系统。只让中文和英文的三个页面读取发布数据：</p><table><thead><tr><th>页面</th><th>自动同步什么</th></tr></thead><tbody><tr><td>首页</td><td>版本、包大小、最低系统要求、下载链接和相关结构化元数据</td></tr><tr><td>下载页</td><td>版本、包大小、最低系统要求、ZIP 和 SHA-256 链接</td></tr><tr><td>更新日志</td><td>对应语言、对应构建的更新说明</td></tr></tbody></table><p>其他页面、截图和 CSS 继续按原来的方式发布。</p><h2 id="二、Cloudflare-的几个名字，到底各管什么"><a href="#二、Cloudflare-的几个名字，到底各管什么" class="headerlink" title="二、Cloudflare 的几个名字，到底各管什么"></a>二、Cloudflare 的几个名字，到底各管什么</h2><p>第一次接触这些服务时，很容易把它们当成同一件事。我实际用到的是下面三个：</p><table><thead><tr><th>服务</th><th>在这个项目里的用途</th></tr></thead><tbody><tr><td>Cloudflare Pages</td><td>部署官网的 HTML、CSS、图片和脚本</td></tr><tr><td>Cloudflare R2</td><td>保存安装包、校验文件、更新说明和 appcast</td></tr><tr><td>Pages Functions</td><td>在用户访问指定页面时，读取发布数据并填进 HTML</td></tr></tbody></table><p>网站地址是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">https://apps.example.com/demo-app/<br></code></pre></td></tr></table></figure><p>安装包和应用更新源在：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">https://downloads.example.com/demo-app/<br></code></pre></td></tr></table></figure><p>两个域名分别给用户看页面和下载文件。官网不会把 ZIP 读进函数再转发一次，下载按钮直接指向 R2 的公开地址。</p><p>Pages Functions 运行在 Cloudflare 的 Workers 运行时上，这个项目不用再单独建立一个 Worker 服务。R2 绑定让函数可以直接访问桶里的对象。<a href="https://developers.cloudflare.com/pages/functions/bindings/#r2-buckets">Cloudflare 的绑定说明</a></p><p>部署网站时，Pages 从 GitHub 拉取代码并编译函数。用户每次打开网页，运行的是已经部署好的函数，不会再去 GitHub 读取代码。</p><h2 id="三、网站还是静态页面，版本信息在返回前填进去"><a href="#三、网站还是静态页面，版本信息在返回前填进去" class="headerlink" title="三、网站还是静态页面，版本信息在返回前填进去"></a>三、网站还是静态页面，版本信息在返回前填进去</h2><p>以下载页为例，网站仓库里仍然有一份正常的 HTML。它里面有标题、安装说明和最后一次核实的版本信息。</p><p>用户访问页面时，函数先取得这份静态响应，再读取 R2 中的 appcast，找到最新正式版本，替换指定位置，最后返回完整页面。</p><pre><code class=" mermaid">sequenceDiagram    participant U as 用户浏览器    participant P as Pages 与 Functions    participant R as R2    U-&gt;&gt;P: 打开 SSHGuard 下载页    P-&gt;&gt;P: 取得静态 HTML    P-&gt;&gt;R: 读取 appcast，或使用有效缓存    R--&gt;&gt;P: 版本、包大小、下载地址    P-&gt;&gt;P: 校验数据，替换指定内容    P--&gt;&gt;U: 返回完整 HTML    U-&gt;&gt;R: 点击下载，直接获取 ZIP</code></pre><p>这里的“替换”用的是 Cloudflare 的 HTMLRewriter。它可以按选择器找到 HTML 中的元素，修改文字、属性或正文。<a href="https://developers.cloudflare.com/workers/runtime-apis/html-rewriter/">HTMLRewriter 文档</a></p><p>页面里的标记很简单，例如：</p><figure class="highlight html"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs html"><span class="hljs-tag">&lt;<span class="hljs-name">h2</span> <span class="hljs-attr">data-release-title</span>&gt;</span>SSHGuard 1.0.0 (5)<span class="hljs-tag">&lt;/<span class="hljs-name">h2</span>&gt;</span><br><span class="hljs-tag">&lt;<span class="hljs-name">a</span> <span class="hljs-attr">data-release-zip</span> <span class="hljs-attr">href</span>=<span class="hljs-string">&quot;旧版的真实下载地址&quot;</span>&gt;</span>下载 macOS 版<span class="hljs-tag">&lt;/<span class="hljs-name">a</span>&gt;</span><br></code></pre></td></tr></table></figure><p>函数只找这些明确的标记，不靠“第三个链接”“第二段文字”这样的顺序去猜位置。后面调整页面布局，也不容易误改其他内容。</p><p>首页的 JSON-LD 也要一起更新。它是提供给搜索引擎的结构化信息；用户看不到，并不代表可以留着旧版本。版本、地址和大小会从同一份数据填入，避免正文和元数据各说各话。</p><p>浏览器拿到的已经是完整 HTML。这个版本同步功能不依赖浏览器 JavaScript，关闭 JavaScript 也能看到更新后的版本和下载链接。</p><h2 id="四、为什么没有再加一个-releases-json"><a href="#四、为什么没有再加一个-releases-json" class="headerlink" title="四、为什么没有再加一个 releases.json"></a>四、为什么没有再加一个 releases.json</h2><p>最开始考虑过给官网单独生成一份 <code>releases.json</code>。</p><p>它当然能工作：软件读取 appcast，官网读取 JSON。但两份文件都要记录版本、包大小和下载链接，多了一处生成、上传和核对的工作。对当前项目来说，收益不大。</p><p>SSHGuard 已经通过 Sparkle 检查更新，现有的 <code>appcast.xml</code> 本来就包含这些信息：</p><ul><li>构建号和对外显示的版本号；</li><li>最低系统要求；</li><li>安装包的完整下载地址和字节数；</li><li>更新包签名；</li><li>更新说明地址。</li></ul><p>Sparkle 的 appcast 使用具体 URL 指向安装包和说明，并不要求它们放在同一个目录。<a href="https://sparkle-project.org/documentation/publishing/">Sparkle 的发布说明</a></p><p>当前就共用这份清单。官网读取其中的元数据，再按说明链接取得正文。</p><p>不过，共用也有一个前提：<strong>不能随手裁剪 appcast 的历史条目。</strong> 官网用它列历史，删除条目后，官网也会少掉对应版本。</p><p>如果以后需要稳定版、测试版分别发通知，或者应用清单只保留少量可升级版本，官网却要保留完整历史，那时再拆一个专门的历史索引会更合适。现在先不增加这层维护。</p><h2 id="五、更新说明改用双语-Markdown"><a href="#五、更新说明改用双语-Markdown" class="headerlink" title="五、更新说明改用双语 Markdown"></a>五、更新说明改用双语 Markdown</h2><p>之前发布脚本把中文 Markdown 转成 HTML，供软件显示更新说明。现在 Sparkle 2.10.0 已能显示基本 Markdown，而 SSHGuard 最低支持 macOS 14，满足其运行条件。Sparkle 从 2.9 开始提供这项支持，要求 macOS 12 及以上。<a href="https://sparkle-project.org/documentation/">Sparkle 文档</a></p><p>因此新版本的说明直接用：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs text">notes.zh.md<br>notes.en.md<br></code></pre></td></tr></table></figure><p>应用按语言读取说明，官网也读取同一份内容。官网把 Markdown 转成自己的 HTML，使用网站现有的排版。</p><p>这并不意味着英文会凭空出现。准备版本时，脚本会创建中英文两份草稿，发布前都需要补全和审校。这里只自动化文件生成和数据同步，函数运行时不会替用户机器翻译。</p><p>说明只使用基本标题、段落、列表、分隔线和行内代码。版本日志用不着完整的 Markdown 插件体系，也不应该把里面的任意 HTML 当作代码执行。</p><p>还有一个之前踩过的坑：<strong>UTF-8 文件本身正确，浏览器仍可能显示乱码。</strong> 上传 Markdown 时，把对象的响应类型设为：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">Content-Type: text/markdown; charset=utf-8<br></code></pre></td></tr></table></figure><p>官网通过函数读取后会自行渲染，但直接打开说明地址、以及软件读取说明时，也应该给出正确的内容类型和编码。</p><h2 id="六、每个版本放在自己的目录里"><a href="#六、每个版本放在自己的目录里" class="headerlink" title="六、每个版本放在自己的目录里"></a>六、每个版本放在自己的目录里</h2><p>安装包、校验文件和双语说明按版本放在一起，发布时上传这一组文件即可。下面以构建 6 为例，不代表它已经发布：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><code class="hljs text">demo-app/<br>├── appcast.xml<br>└── releases/<br>    └── 1.0.0-6/<br>        ├── SSHGuard-1.0.0-6.zip<br>        ├── SSHGuard-1.0.0-6.zip.sha256<br>        ├── notes.zh.md<br>        └── notes.en.md<br></code></pre></td></tr></table></figure><p><code>appcast.xml</code> 的地址固定，每次发布更新它的内容。安装包和说明使用具体的版本地址，发布后不覆盖成另一份内容。</p><p>应用和官网都按清单中的完整 URL 找文件，不需要遍历桶，也不需要递归查找版本目录。</p><h2 id="七、绑定-R2，不需要把访问密钥放进网页"><a href="#七、绑定-R2，不需要把访问密钥放进网页" class="headerlink" title="七、绑定 R2，不需要把访问密钥放进网页"></a>七、绑定 R2，不需要把访问密钥放进网页</h2><p>在 Pages 项目中给 R2 配置一个绑定：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs text">变量名：APP_RELEASES<br>桶名称：demo-app-releases<br></code></pre></td></tr></table></figure><p>函数中就可以这样读取：</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs javascript"><span class="hljs-keyword">const</span> object = <span class="hljs-keyword">await</span> context.<span class="hljs-property">env</span>.<span class="hljs-property">APP_RELEASES</span>.<span class="hljs-title function_">get</span>(<span class="hljs-string">&#x27;demo-app/appcast.xml&#x27;</span>);<br></code></pre></td></tr></table></figure><p><code>APP_RELEASES</code> 是示例绑定名，<code>demo-app-releases</code> 是示例桶名。两者可以不同，但变量名大小写必须与代码一致。绑定修改后需要重新部署才能生效。<a href="https://developers.cloudflare.com/pages/functions/bindings/#r2-buckets">R2 绑定步骤</a></p><p>这段代码在 Cloudflare 上运行，浏览器不会收到 R2 访问密钥，也不需要为了读取版本清单去配置浏览器跨域请求。</p><p>绑定给了函数访问桶的能力，不等于桶已经公开。函数读取清单和说明使用绑定；用户下载 ZIP 使用公开域名。它们是两条不同的访问路径，公开下载仍要单独验证。</p><p>配置后来放进官网仓库的 <code>wrangler.jsonc</code>，生产绑定真实更新桶，预览配置指向隔离测试桶。预览桶需要事先创建并填入测试数据，不能只写一个名字就认为资源已经存在。</p><h2 id="八、只让六个页面运行函数"><a href="#八、只让六个页面运行函数" class="headerlink" title="八、只让六个页面运行函数"></a>八、只让六个页面运行函数</h2><p>官网还承载其他应用，没必要让所有请求都绕一遍 SSHGuard 的版本逻辑。</p><p>中英文各三个页面由 middleware 接入共享处理函数，再通过 <code>public/_routes.json</code> 限定调用范围。目录结构大致是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs text">functions/<br>├── _lib/releases.js<br>├── demo-app/_middleware.js<br>└── en/demo-app/_middleware.js<br></code></pre></td></tr></table></figure><p>路由明确包括首页、下载页、更新日志页，以及它们没有尾斜杠和带 <code>index.html</code> 的入口别名。其他应用、指南、隐私页面、图片和 CSS 保持静态服务。</p><p>不能因为只在某个目录里放了函数，就认为所有子资源都不会调用它。函数调用范围需要明确配置。<a href="https://developers.cloudflare.com/pages/functions/routing/">Pages Functions 路由文档</a></p><p>这样后续调整版本同步逻辑，影响范围也容易检查：就这六页。</p><h2 id="九、缓存与回退：网页能打开，不代表同步成功"><a href="#九、缓存与回退：网页能打开，不代表同步成功" class="headerlink" title="九、缓存与回退：网页能打开，不代表同步成功"></a>九、缓存与回退：网页能打开，不代表同步成功</h2><p>如果每次打开下载页都读取 R2，每次打开日志页又读取一批说明，重复工作会越来越多。</p><p>当前采用两种缓存时间：</p><table><thead><tr><th>数据</th><th>缓存时间</th><th>原因</th></tr></thead><tbody><tr><td>appcast 索引</td><td>5 分钟</td><td>随正式发布变化，需要较快刷新</td></tr><tr><td>已发布版本说明</td><td>1 天</td><td>按版本固定，发布后原则上不覆盖</td></tr></tbody></table><p>这是函数显式使用 Cache API 保存的数据缓存。R2 绑定的 <code>get()</code> 不会因为配置了一个 <code>fetch</code> 缓存参数就自动走相同机制。Cache API 的内容保存在各数据中心，不能保证全球同一时刻刷新。<a href="https://developers.cloudflare.com/workers/runtime-apis/cache/">Cache API 文档</a></p><p>所以上传新清单后，某些地区的官网可能还短暂显示上一版。软件也在自己的检查时机读取更新源，不是清单上传成功后所有用户立即收到推送。</p><p>应用读取的公开 appcast 地址仍按更新源规则绕过 CDN 缓存；官网函数的五分钟内部缓存，是另一层缓存。两者不要混在一起看。</p><p>动态页面响应本身使用 <code>no-store</code>，避免生成的 HTML 又被长时间缓存。安装包使用独立的版本地址，适合采用较长缓存时间。</p><p>再说失败处理。读取不到清单、缺少英文说明、数据格式错误时，网站会保留整份静态页面，记录具体错误。不会只换新版标题，却留下旧版下载地址。</p><p>当前实现先把数据和替换后的完整 HTML 准备好，再返回响应，确保转换过程中出错时仍能使用原始页面。</p><p>判断是否真的同步成功，可以看响应头：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs text">X-Release-Source: r2<br>X-Release-Build: 5<br></code></pre></td></tr></table></figure><p>如果看到：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">X-Release-Source: static-fallback<br></code></pre></td></tr></table></figure><p>说明网站正在使用静态兜底。它能让下载页继续可用，但不保证显示最新版。静态兜底需要在网站维护时刷新，错误日志也要能查到原因。</p><h2 id="十、这次真正踩到的坑：Functions-有依赖，构建命令不能继续留空"><a href="#十、这次真正踩到的坑：Functions-有依赖，构建命令不能继续留空" class="headerlink" title="十、这次真正踩到的坑：Functions 有依赖，构建命令不能继续留空"></a>十、这次真正踩到的坑：Functions 有依赖，构建命令不能继续留空</h2><p>网站原来是纯静态 HTML，Pages 的构建命令可以留空。</p><p>加入 Functions 后，代码用了 <code>fast-xml-parser</code> 来解析 XML。本地已经安装依赖，所以本地编译和验证正常。云端第一次部署却报错：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs text">No build command specified. Skipping build step.<br>Could not resolve &quot;fast-xml-parser&quot;<br></code></pre></td></tr></table></figure><p>这两行放在一起，原因就很清楚了：这次云端没有执行依赖安装，编译函数时找不到解析器。</p><p>处理方式是在 Pages 的构建配置中设置：</p><table><thead><tr><th>设置</th><th>值</th></tr></thead><tbody><tr><td>框架预设</td><td>无</td></tr><tr><td>构建命令</td><td><code>npm ci &amp;&amp; npm run check</code></td></tr><tr><td>构建输出目录</td><td><code>public</code></td></tr><tr><td>根目录</td><td>仓库根目录，留空</td></tr></tbody></table><p><code>npm ci</code> 按锁文件安装依赖；<code>npm run check</code> 检查静态页面；之后 Pages 编译并发布 Functions。仅修改 README 不会自动改变控制台里原来留空的构建命令。<a href="https://developers.cloudflare.com/pages/configuration/build-configuration/">Pages 构建配置</a></p><p>Node 版本也要明确。本项目锁定的本地工具要求 Node 22 及以上，可以在“变量和机密”中配置 <code>NODE_VERSION</code>，例如 <code>22.16.0</code>；如果选择已发布的 Node 24 版本，也应在云端实际验证一次。<a href="https://developers.cloudflare.com/pages/configuration/build-image/">构建环境与版本设置</a></p><p>也不要按错误提示把 <code>fast-xml-parser</code> 标成 external 来绕过构建。函数运行时同样需要它，正确做法是安装并打包依赖。</p><p>另一个需要注意的地方是响应头：静态文件的 <code>_headers</code> 规则不会自动给 Functions 生成的响应补齐所有头。项目在函数响应中显式保留现有安全策略，并去掉转换后不再适用的长度和静态 ETag。<a href="https://developers.cloudflare.com/pages/configuration/headers/">Pages 响应头说明</a></p><h2 id="十一、以后发布，网站和软件分别要做什么"><a href="#十一、以后发布，网站和软件分别要做什么" class="headerlink" title="十一、以后发布，网站和软件分别要做什么"></a>十一、以后发布，网站和软件分别要做什么</h2><p>可以把工作分成两条线。</p><p><strong>网站代码发生变化时</strong>，例如调整布局、增加截图、修改函数逻辑，需要修改官网仓库，再通过 GitHub 与 Pages 部署。</p><p><strong>普通软件发版时</strong>，网站函数已经部署，就只更新 R2 的发布数据，不需要为版本号再改六份 HTML。</p><pre><code class=" mermaid">flowchart TD    subgraph SITE[官网代码更新]        A[修改页面、截图或函数] --&gt; B[提交并推送官网仓库]        B --&gt; C[Pages 安装依赖、检查并部署]    end    subgraph RELEASE[普通软件发版]        D[准备并审校双语说明] --&gt; E[本机测试、签名、公证和验证]        E --&gt; F[生成 ZIP、校验文件、说明和候选清单]        F --&gt; G[先上传本版文件到 R2]        G --&gt; H[验证公开文件]        H --&gt; I[最后发布 appcast]        I --&gt; J[官网按缓存窗口读取新版]        I --&gt; K[软件下次检查时发现新版]        J --&gt; L[核对网页并登记正式发布]        K --&gt; M[完成真实升级验收]    end</code></pre><p>本机操作现在集中到一个入口。下面以示例脚本 <code>scripts/release.sh</code> 演示操作顺序，实际入口名称按自己的项目替换：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 1. 准备构建号与中英文草稿</span><br>bash scripts/release.sh --build 6<br><br><span class="hljs-comment"># 2. 审校说明、删除草稿标记、提交后预检</span><br>bash scripts/release.sh --dry-run<br><br><span class="hljs-comment"># 3. 构建、签名、公证并生成发布文件</span><br>bash scripts/release.sh<br></code></pre></td></tr></table></figure><p>之后仍然手动上传 R2。先上传本版目录中的 ZIP、校验文件和两份说明，然后运行：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 4. 核对公开产物；此时清单可以仍然是上一版</span><br>bash scripts/release.sh --verify<br></code></pre></td></tr></table></figure><p>文件核对通过，再上传 appcast：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 5. 清单上线后再次核对，通过才登记正式发布</span><br>bash scripts/release.sh --verify --record<br></code></pre></td></tr></table></figure><p><code>--record</code> 会更新仓库中的 <code>docs/published-appcast.xml</code>，需要审查后提交。它不是“上传成功就记一笔”，而是公开文件和完整清单与本地一致时才登记。</p><p>最后还要检查官网六页，以及用旧应用完成一次下载、安装、重启。函数响应正常、公开文件可下载、应用实际升级成功，是三件不同的事。</p><p>Shell 脚本负责串起工具和执行顺序，Python 处理版本与 XML。日常只使用统一入口，不需要逐个运行内部脚本，也没有必要为了语言一致把它们重写成一种语言。</p><h2 id="十二、已经验证的和还需要真跑的"><a href="#十二、已经验证的和还需要真跑的" class="headerlink" title="十二、已经验证的和还需要真跑的"></a>十二、已经验证的和还需要真跑的</h2><p>目前本地已经验证了：</p><ul><li>中英文六页使用同一版本、下载地址和包大小，首页 JSON-LD 同步；</li><li>基本 Markdown 渲染和危险内容处理；</li><li>日志每页十版，读取有数量及大小上限；</li><li>说明缺失、清单损坏时保留完整静态页面；</li><li>入口别名重定向正常，其他应用和静态资源保持原行为。</li></ul><p>本地使用 Wrangler 与模拟 R2 验证。普通 Python 静态服务器只会展示兜底 HTML，不能用来判断 Functions 是否工作；本地模拟桶也不会自动读取生产数据。</p><p>初次云端部署还暴露了上面的依赖安装问题。生产环境是否已经正确绑定、函数是否真的返回动态数据，应以部署结果和响应头为准，不能因为页面能打开就认为全部完成。</p><p>构建 6 的目录只是本文示例。下一版双语说明在 Sparkle 中的实际展示、公开文件核对、旧版真实升级，都需要用正式产物再验一次。</p><p>做到这里，重复手填的版本信息基本移交给发布数据了。每次发布仍然需要认真写说明、验证用户拿到的包和完成升级验收；网站里那些“版本改了，链接忘了改”的工作，则不必再靠记忆。</p>]]>
    </content>
    <id>https://iyuluo.com/2026/10/14/macOS-%E5%BA%94%E7%94%A8%E5%86%99%E5%AE%8C%E4%B9%8B%E5%90%8E%EF%BC%8C%E6%80%8E%E4%B9%88%E4%BA%A4%E5%88%B0%E7%94%A8%E6%88%B7%E6%89%8B%E9%87%8C%EF%BC%88%E4%B8%89%EF%BC%89%EF%BC%9A%E8%AE%A9%E5%AE%98%E7%BD%91%E8%B7%9F%E7%9D%80%E7%89%88%E6%9C%AC%E4%B8%80%E8%B5%B7%E6%9B%B4%E6%96%B0/</id>
    <link href="https://iyuluo.com/2026/10/14/macOS-%E5%BA%94%E7%94%A8%E5%86%99%E5%AE%8C%E4%B9%8B%E5%90%8E%EF%BC%8C%E6%80%8E%E4%B9%88%E4%BA%A4%E5%88%B0%E7%94%A8%E6%88%B7%E6%89%8B%E9%87%8C%EF%BC%88%E4%B8%89%EF%BC%89%EF%BC%9A%E8%AE%A9%E5%AE%98%E7%BD%91%E8%B7%9F%E7%9D%80%E7%89%88%E6%9C%AC%E4%B8%80%E8%B5%B7%E6%9B%B4%E6%96%B0/"/>
    <published>2026-10-14T15:01:25.000Z</published>
    <summary>
      <![CDATA[<p>上一篇<a href="https://iyuluo.com/2026/10/13/macOS-%E5%BA%94%E7%94%A8%E5%86%99%E5%AE%8C%E4%B9%8B%E5%90%8E%EF%BC%8C%E6%80%8E%E4%B9%88%E4%BA%A4%E5%88%B0%E7%94%A8%E6%88%B7%E6%89%8B%E9%87%8C%EF%BC%88%E4%BA%8C%EF%BC%89%EF%BC%9A%E4%BB%8E%E6%89%8B%E5%8A%A8%E6%B5%81%E7%A8%8B%E5%88%B0%E4%B8%80%E6%9D%A1%E5%91%BD%E4%BB%A4/">《从手动流程到一条命令》</a>把本机发布串起来了：准备版本、测试、签名、公证、附加票据，最后生成安装包和更新清单。</p>
<p>应用能发出去了，另一个问题就冒出来了：官网还得跟着改。</p>
<p>发一个新构建，要修改中文首页、英文首页、两份下载页、两份更新日志。版本号改了，包大小可能没改；下载链接换了，搜索引擎读取的元数据还指着旧包。页面都能打开，里面的信息却对不上。</p>
<p>我这次做的事，就是让 SSHGuard 的官网和应用读取同一份发布数据。网站继续用简单的 HTML 和 CSS，Cloudflare 只负责补上容易漏改的版本信息。</p>
<p>这篇按实际项目讲清楚：Pages、R2 和 Pages Functions 分别做什么，为什么不用再加一份 JSON 清单，以及下一次发布到底还要手动做哪些事。</p>]]>
    </summary>
    <title>macOS 应用写完之后，怎么交到用户手里（三）：让官网跟着版本一起更新</title>
    <updated>2026-10-14T15:06:11.587Z</updated>
  </entry>
  <entry>
    <author>
      <name>yuluo</name>
    </author>
    <category term="Apple" scheme="https://iyuluo.com/categories/Apple/"/>
    <category term="Apple" scheme="https://iyuluo.com/tags/Apple/"/>
    <content>
      <![CDATA[<p>上一篇《macOS 应用写完之后，怎么交到用户手里？》画了一张地图：写好代码、编译测试、签名、公证、附加票据、打包、上传下载渠道，最后让用户安装。</p><p>地图有了，这一次真的跑了一遍。SSHGuard 的 1.0.0（构建 4）从代码提交一路走到了 Apple 公证通过、上传到 R2、更新清单上线。</p><p>结论先说：<strong>“能跑”的部分几乎不会卡住你，卡住的是“检查和验证”的部分。</strong> 公证被拒时命令返回成功、下载的包能被系统静默挪走、权限报错会跟着系统语言变化……这些都不是文档首页会写的事，是跑过一次才会知道的事。</p><p>这篇就按实际踩过的顺序讲。</p><span id="more"></span><h2 id="一、这次真的跑通了什么"><a href="#一、这次真的跑通了什么" class="headerlink" title="一、这次真的跑通了什么"></a>一、这次真的跑通了什么</h2><p>上一篇结尾时，SSHGuard 停在“签名完成、公证还没提交”。这次把整条链路走完了：</p><pre><code class=" mermaid">flowchart TD    A[&quot;Git 提交 1168e19&quot;] --&gt; B[&quot;构建号 4&lt;br/&gt;回归 + XCTest&quot;]    B --&gt; C[&quot;Developer ID 签名归档&quot;]    C --&gt; D[&quot;Apple 公证：Accepted&quot;]    D --&gt; E[&quot;附加票据、重新打包&quot;]    E --&gt; F[&quot;生成更新包、HTML 说明、清单&quot;]    F --&gt; G[&quot;上传 R2&quot;]    G --&gt; H[&quot;更新清单 appcast.xml 上线&quot;]    style A fill:#dcfce7,stroke:#16a34a,color:#14532d    style D fill:#dcfce7,stroke:#16a34a,color:#14532d    style H fill:#dcfce7,stroke:#16a34a,color:#14532d</code></pre><p>每一步都留了日志，提交 ID、公证状态、Apple 返回的公证日志都保存在本次发布目录里。剩下的只有两件事：用已安装的旧版完成一次真实自动升级验收，以及想好新用户从哪里下载。</p><p>下面按“这次新学到的东西”展开。</p><h2 id="二、其实是三把钥匙"><a href="#二、其实是三把钥匙" class="headerlink" title="二、其实是三把钥匙"></a>二、其实是三把钥匙</h2><p>上一篇说签名私钥要妥善保管。实际跑下来，参与发布的不是一个私钥，而是<strong>三套不同的凭据</strong>，它们的保管方式、丢失后果都不一样：</p><table><thead><tr><th>凭据</th><th>用途</th><th>丢了怎么办</th><th>备份方式</th></tr></thead><tbody><tr><td>Developer ID 私钥</td><td>代码签名</td><td>同一团队可重发新证书，指定要求（DR）不变</td><td>钥匙串导出 <code>.p12</code>，存到受保护的位置</td></tr><tr><td>notarytool 凭据（App 专用密码）</td><td>命令行提交公证</td><td>重新配置即可</td><td>不需要备份，App 专用密码可撤销重建</td></tr><tr><td>Sparkle EdDSA 私钥</td><td>给更新包签名</td><td><strong>最麻烦</strong>：已安装的旧客户端无法验证新签名</td><td><code>generate_keys -x</code> 导出并异地备份</td></tr></tbody></table><p>三个细节值得单独说：</p><p><strong>公证凭据不是 Xcode 登录。</strong> Xcode 里登录 Apple ID 不会自动创建命令行可用的公证凭据。要走 <code>xcrun notarytool store-credentials</code>，输入的是 Apple 账户页面生成的 <strong>App 专用密码</strong>，不是 Apple ID 登录密码。写成独立入口（脚本里的 <code>--setup-notary</code>）是为了让这一步只配一次、且密码不经过脚本和 shell 历史。</p><p><strong>最危险的是 Sparkle 私钥。</strong> 它存在用户机器上的每个已安装应用里（公钥嵌在 app 里）。丢了它，现有用户就再也收不到可信的自动更新——不是换个证书那么简单。导出命令是 <code>generate_keys -x</code>，恢复用 <code>-f</code>，注意都必须带 <code>--account</code> 指向你的钥匙串账户。</p><p><strong>两把钥匙不要同时换。</strong> 更新链路的可信度由“代码签名 + EdDSA 签名”共同保证。如果某个版本同时换了 Apple 签名身份和更新密钥，旧客户端可能无法验证这次更新。分开做，中间至少隔一个正常发布的版本。</p><h2 id="三、公证的两个坑"><a href="#三、公证的两个坑" class="headerlink" title="三、公证的两个坑"></a>三、公证的两个坑</h2><p><code>notarytool</code> 的 <code>--wait</code> 很方便：提交后阻塞到出结果。但用起来有两个坑，第一个坑还藏得很深。</p><h3 id="坑一：被拒时退出码可能仍是-0"><a href="#坑一：被拒时退出码可能仍是-0" class="headerlink" title="坑一：被拒时退出码可能仍是 0"></a>坑一：被拒时退出码可能仍是 0</h3><p>直觉上“命令返回 0 &#x3D; 成功”，但实际不是。公证返回 <code>Invalid</code> 时，<code>submit --wait</code> 的退出码仍可能是 0。</p><p>如果脚本只看退出码，后果是：公证已经被拒了，流程还继续往下走，到 <code>stapler staple</code> 时才报错——报的还是 stapler 的错误，看不出真正原因。</p><p><strong>正确做法是按返回 JSON 里的 <code>status</code> 判定：</strong></p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><code class="hljs bash">xcrun notarytool submit 应用.zip \<br>    --keychain-profile <span class="hljs-string">&quot;<span class="hljs-variable">$NOTARY_PROFILE</span>&quot;</span> \<br>    --output-format json | jq -r <span class="hljs-string">&#x27;.id&#x27;</span><br>xcrun notarytool <span class="hljs-built_in">wait</span> <span class="hljs-string">&quot;<span class="hljs-variable">$SUBMISSION_ID</span>&quot;</span> \<br>    --keychain-profile <span class="hljs-string">&quot;<span class="hljs-variable">$NOTARY_PROFILE</span>&quot;</span> \<br>    --output-format json | jq -r <span class="hljs-string">&#x27;.status&#x27;</span>   <span class="hljs-comment"># Accepted / Invalid / Rejected</span><br></code></pre></td></tr></table></figure><p>把 <code>submit</code> 和 <code>wait</code> 拆成两步还有一个好处：提交 ID 先落盘，网络中断也不丢，之后可以续等。</p><h3 id="坑二：Accepted-不代表“没问题”"><a href="#坑二：Accepted-不代表“没问题”" class="headerlink" title="坑二：Accepted 不代表“没问题”"></a>坑二：Accepted 不代表“没问题”</h3><p>公证日志里，即使状态是 <code>Accepted</code>，也可能有需要修复的警告项——比如某个二进制缺了安全时间戳。这些不会阻止这次发布，但可能成为下一次的坑。</p><p><strong>无论成功失败，都拉一次日志存下来：</strong></p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs bash">xcrun notarytool <span class="hljs-built_in">log</span> <span class="hljs-string">&quot;<span class="hljs-variable">$SUBMISSION_ID</span>&quot;</span> \<br>    --keychain-profile <span class="hljs-string">&quot;<span class="hljs-variable">$NOTARY_PROFILE</span>&quot;</span> \<br>    日志路径/notary-log.json<br></code></pre></td></tr></table></figure><p>被拒时日志里是具体原因；通过时日志里是值得修一修的警告。</p><h3 id="顺带：网络中断不代表提交失败"><a href="#顺带：网络中断不代表提交失败" class="headerlink" title="顺带：网络中断不代表提交失败"></a>顺带：网络中断不代表提交失败</h3><p><code>--wait</code> 因为网络断掉而退出，<strong>Apple 端的提交可能还在正常处理</strong>。这时不要重新提交（会白白多交一份），用已保存的提交 ID 续等：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash">xcrun notarytool <span class="hljs-built_in">wait</span> <span class="hljs-string">&quot;<span class="hljs-variable">$SUBMISSION_ID</span>&quot;</span> \<br>    --keychain-profile <span class="hljs-string">&quot;<span class="hljs-variable">$NOTARY_PROFILE</span>&quot;</span><br></code></pre></td></tr></table></figure><p>这也是为什么要把提交 ID 第一时间写进日志文件。</p><h2 id="四、“上传到下载渠道”其实是三次上传"><a href="#四、“上传到下载渠道”其实是三次上传" class="headerlink" title="四、“上传到下载渠道”其实是三次上传"></a>四、“上传到下载渠道”其实是三次上传</h2><p>上一篇画的时序图是两次上传：上传 Apple 检查、上传下载渠道交付。</p><p>接入自动更新之后，实际上是三次，而且<strong>顺序有讲究</strong>：</p><pre><code class=" mermaid">sequenceDiagram    participant D as 开发者    participant A as Apple 公证    participant R as R2 更新源    participant U as 已安装用户    participant N as 新用户    D-&gt;&gt;A: 上传应用申请公证（检查）    A--&gt;&gt;D: Accepted，返回票据    D-&gt;&gt;R: 上传 ZIP 和 HTML 说明（交付）    N-&gt;&gt;R: 通过下载链接获取安装包    D-&gt;&gt;R: 最后上传 appcast.xml（通知）    U-&gt;&gt;R: 定期检查清单，发现新版本</code></pre><p><strong>为什么清单要最后传：</strong> <code>appcast.xml</code> 一上线，所有装了旧版的用户立刻会收到“有新版本”的推送。如果更新包还没就位，用户点进去会下载到 404。反过来，先传包再传清单，用户拿到的永远是已就位的版本。</p><p>这三次上传的验证内容也不同：</p><ul><li>检查阶段：Apple 返回 <code>status: Accepted</code>、票据能 staple、Gatekeeper 通过</li><li>交付阶段：下载地址能访问、下载下来的 SHA256 和本地逐字节一致</li><li>通知阶段：清单里新版本的下载地址、文件长度、EdDSA 签名和本地生成的一致</li></ul><h2 id="五、ZIP-分发的隐藏坑：App-Translocation"><a href="#五、ZIP-分发的隐藏坑：App-Translocation" class="headerlink" title="五、ZIP 分发的隐藏坑：App Translocation"></a>五、ZIP 分发的隐藏坑：App Translocation</h2><p>用户下载 ZIP，Safari 自动解压到「下载」文件夹。如果他直接双击打开 <code>SSHGuard.app</code>——macOS 会把这个 app 静默放到一个只读的隔离位置运行（App Translocation），<strong>他不会察觉任何异常</strong>。</p><p>但后果很严重：Sparkle 的自动更新在这种状态下<strong>完全静默失效</strong>。文档原话是：</p><blockquote><p>By default Sparkle will not notify your user if an update cannot be performed, like if the app is running from a read-only mount or being impacted by app translocation.</p></blockquote><p>也就是说，从「下载」直接打开的用户，会永远收不到新版本，而且他自己不知道。</p><p>三种补救方式，可以按成本选：</p><ul><li><strong>说明文档里写清楚</strong>：解压后把应用拖进「应用程序」文件夹再打开。最低成本，挡不住不看文档的用户。</li><li><strong>应用自己检测</strong>：bundle 路径包含 <code>/AppTranslocation/</code> 或以 <code>/Volumes/</code> 开头时，启动弹一次提示。SSHGuard 现在就这么做，同时在设置 → 关于里常驻一个橙色警告。</li><li><strong>改用 DMG</strong>：里面放一个指向「应用程序」的符号链接，引导用户拖过去。这是 Sparkle 官方推荐的做法，需要多一次公证（给 DMG 本身）。</li></ul><p>另外，如果你的应用支持“登录时启动”之类的功能，被隔离的随机路径注册出去的行为可能也不符合预期——这又是一个“本机能跑”和“用户机器上能跑”的差异点。</p><h2 id="六、“测试拒绝权限”要用用户的系统语言测"><a href="#六、“测试拒绝权限”要用用户的系统语言测" class="headerlink" title="六、“测试拒绝权限”要用用户的系统语言测"></a>六、“测试拒绝权限”要用用户的系统语言测</h2><p>上一篇的测试清单里有“用户拒绝权限时，应用是否能正确处理”。实际做的时候才发现这句话缺了一个限定：<strong>用什么语言的操作系统测。</strong></p><p>SSHGuard 用 <code>osascript</code> 调起 Terminal&#x2F;iTerm 执行 SSH 命令。用户拒绝“自动化”授权后，osascript 返回错误码 -1743。一开始的代码靠匹配报错文本判断：</p><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs swift"><span class="hljs-keyword">if</span> detail.contains(<span class="hljs-string">&quot;not authorized&quot;</span>) <span class="hljs-operator">||</span> detail.contains(<span class="hljs-string">&quot;apple events&quot;</span>) &#123;<br>    <span class="hljs-comment">// 显示“请到 系统设置 → 隐私与安全性 → 自动化 中允许”</span><br>&#125;<br></code></pre></td></tr></table></figure><p>英文系统下没问题。但报错文案会随系统语言翻译，中文系统下变成：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs text">英文：Not authorized to send Apple events to Terminal. (-1743)<br>中文：未获得授权将Apple事件发送给&quot;终端&quot;。 (-1743)<br></code></pre></td></tr></table></figure><p>英文关键词在中文报错里一个都匹配不上。结果是：中文系统的用户拒绝授权后，看到的是一行原始报错，而不是引导他去“自动化”设置里打开的中文提示。</p><p><strong>修复方式是认错误码，不认报错文本。</strong> <code>-1743</code> 是 <code>errAEEventNotPermitted</code>，在任何语言的系统下都一样。</p><p>这个教训推广开来就是：<strong>凡是靠“系统文案”判断的逻辑，都要在目标用户实际使用的语言下各测一遍。</strong> 发布验收里现在专门有一条：Apple Events 拒绝路径至少在简体中文系统下测一次。</p><h2 id="七、测试矩阵要补齐的不只是“能跑”"><a href="#七、测试矩阵要补齐的不只是“能跑”" class="headerlink" title="七、测试矩阵要补齐的不只是“能跑”"></a>七、测试矩阵要补齐的不只是“能跑”</h2><p>发布的是 universal 二进制（arm64 + x86_64），但测试只跑 arm64，Intel 切片从来没被执行过。最低支持版本写的是 macOS 14，验收却只在 macOS 27 上做。</p><p>这两件事补上之后，测试矩阵才算完整：</p><table><thead><tr><th>维度</th><th>方法</th><th>成本</th></tr></thead><tbody><tr><td>Intel 切片</td><td>Rosetta 下测试目标改为 <code>x86_64</code></td><td>一条命令</td></tr><tr><td>最低系统版本</td><td>macOS 14 虚拟机跑一次冒烟</td><td>每次发布几小时</td></tr><tr><td>破坏性操作</td><td>专用测试账户或可回滚快照</td><td>一次性环境</td></tr></tbody></table><p>Rosetta 那一项成本最低收益最直接：装了 Rosetta 的 Apple Silicon Mac 上，<code>arch -x86_64</code> 就能把测试跑在 Intel 切片下，发现的是“只在 x86_64 路径上才出现”的问题。这次跑下来 201 个测试在 Rosetta 下全过，说明目前没有这类问题——但前提是去跑了。</p><h2 id="八、发完之后：验证和留档"><a href="#八、发完之后：验证和留档" class="headerlink" title="八、发完之后：验证和留档"></a>八、发完之后：验证和留档</h2><h3 id="验证的是“用户拿到的那份”，不是“本地构建的那份”"><a href="#验证的是“用户拿到的那份”，不是“本地构建的那份”" class="headerlink" title="验证的是“用户拿到的那份”，不是“本地构建的那份”"></a>验证的是“用户拿到的那份”，不是“本地构建的那份”</h3><p>打包完、上传完、能下载，只说明传输没坏。<strong>真正该验证的是从最终下载链接拿到的应用本身。</strong></p><p>可以写成一个小脚本（<code>verify_remote.sh</code>），只做读取、不改任何东西：</p><ol><li>从公开地址下载 ZIP 和 HTML，HTTP 状态、Content-Type 正常</li><li>下载后的 SHA256 和本地逐字节一致</li><li><strong>解压下载的 ZIP</strong>，对里面的 <code>.app</code> 跑 <code>codesign --verify</code>、<code>stapler validate</code>、<code>spctl -a -vv</code></li><li>线上 appcast 里本版本的地址、长度、EdDSA 签名和本地一致</li><li><code>appcast.xml</code> 的响应头确认没有被 CDN 缓存</li></ol><p>第 3 步是最关键的一环：本地 <code>notarized/SSHGuard.app</code> 能过验证，只说明本地的包没问题；从线上地址解压出来的那份，才是用户真正会拿到的版本。</p><p>还有一个容易漏的点：<code>curl</code> 下载不带 <code>com.apple.quarantine</code> 属性，<strong>真实用户的“浏览器下载→首次打开”流程在 curl 下载里根本走不到</strong>。要覆盖这一步，得用浏览器从公开地址下载一遍，再首次打开。</p><h3 id="留档：Git-标签和-dSYM-不要只留在这台-Mac-上"><a href="#留档：Git-标签和-dSYM-不要只留在这台-Mac-上" class="headerlink" title="留档：Git 标签和 dSYM 不要只留在这台 Mac 上"></a>留档：Git 标签和 dSYM 不要只留在这台 Mac 上</h3><p>每次发布建议做两件“将来会感谢现在”的事：</p><ul><li><strong>为发布的源码提交打 Git 标签</strong>，比如 <code>v1.0.0-build4</code>。<code>source-commit.txt</code> 记提交号已经够了，但标签让仓库自己就能回答“线上这个版本对应哪份源码”。</li><li><strong>把 <code>SSHGuard.xcarchive</code> 备份到这台 Mac 以外的地方。</strong> 归档里的 dSYM 是将来把崩溃日志还原成可读调用栈的唯一依据。它现在躺在被 Git 忽略的 <code>Build/</code> 目录里，这台 Mac 一出问题就什么都没了。注意别放进公开的更新桶——它是给开发者用的，不是给用户下载的。</li></ul><h2 id="九、把这些串成一条命令"><a href="#九、把这些串成一条命令" class="headerlink" title="九、把这些串成一条命令"></a>九、把这些串成一条命令</h2><p>上面的每一步单独看都不复杂，手动做却容易漏一步或做反顺序。所以实际做法是把这些全部串进一个入口脚本，让“下次发版”变成三件事：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-comment"># 1. 准备下一版：递增构建号，生成更新说明草稿</span><br>bash Scripts/release_local.sh --build 5<br><br><span class="hljs-comment"># 2. 检查说明、删除草稿标记、提交后先预览（不构建不联网）</span><br>bash Scripts/release_local.sh --dry-run<br><br><span class="hljs-comment"># 3. 正式运行</span><br>bash Scripts/release_local.sh<br></code></pre></td></tr></table></figure><p>正式运行的顺序是：</p><pre><code class=" mermaid">flowchart LR    A[&quot;工作区干净检查&lt;br/&gt;公证凭据预检&quot;] --&gt; B[&quot;生成工程&lt;br/&gt;检查漂移&quot;]    B --&gt; C[&quot;签名证书、Rosetta&lt;br/&gt;更新密钥预检&quot;]    C --&gt; D[&quot;回归 + XCTest&lt;br/&gt;arm64 + x86_64&quot;]    D --&gt; E[&quot;签名归档&quot;]    E --&gt; F[&quot;公证并按 status 判定&quot;]    F --&gt; G[&quot;票据验证&quot;]    G --&gt; H[&quot;更新包、HTML、清单&quot;]</code></pre><p>设计上几个值得说的取舍：</p><p><strong>预检放在最贵的操作之前。</strong> 证书、公证凭据、更新密钥、Rosetta 都在测试和归档之前检查，避免出现“公证做完了才发现缺更新密钥”的情况。</p><p><strong>按失败阶段给不同的恢复指引。</strong> 没签名的失败可以直接重跑；归档后没提交公证可以删目录重跑；已提交公证的按 <code>status</code> 区分——被拒要修代码重提交，结果未知要用提交 ID 续等，已通过但后续失败不要重提公证。</p><p><strong>“上传”始终手动。</strong> 脚本只负责本地全部可验证的产物，R2 上传和清单发布是人工步骤，顺序和注意事项写在脚本最后输出的提示里。</p><h2 id="十、还没做完的"><a href="#十、还没做完的" class="headerlink" title="十、还没做完的"></a>十、还没做完的</h2><p>发布流程跑通了，但“交到用户手里”还有几件没收尾：</p><ul><li><strong>真实自动升级验收</strong>：构建 3 → 4 的完整检查、下载、安装、重启还没实际做过。本地编译通过 ≠ 用户能升级成功，这一步必须真跑。</li><li><strong>新用户的下载入口</strong>：现在只有带版本号的 R2 链接，还没有固定的“下载最新版”地址。是做一个简短跳转链接、GitHub Releases，还是下载页，还在权衡。</li><li><strong>发布节奏的稳定性</strong>：现在是手动上传 + 手动验收。跑得次数多了，哪些是每次必忘的步骤，会决定下一个要自动化的点在哪里。</li></ul><p>第一篇解决的是“知道该做什么”，这一篇解决的是“实际做一遍会发现什么”。写代码和发产品之间，隔的大多是这种“不跑一遍不知道”的细节。</p><p>如果这篇里有一个点值得记住，那就是：<strong>所有只看“命令返回成功”就往下走的流程，都值得再查一层。</strong> 公证的退出码、下载的可达性、权限报错的语言——每一个都是“看起来对了”和“实际对了”之间的缝隙。</p>]]>
    </content>
    <id>https://iyuluo.com/2026/10/13/macOS-%E5%BA%94%E7%94%A8%E5%86%99%E5%AE%8C%E4%B9%8B%E5%90%8E%EF%BC%8C%E6%80%8E%E4%B9%88%E4%BA%A4%E5%88%B0%E7%94%A8%E6%88%B7%E6%89%8B%E9%87%8C%EF%BC%88%E4%BA%8C%EF%BC%89%EF%BC%9A%E4%BB%8E%E6%89%8B%E5%8A%A8%E6%B5%81%E7%A8%8B%E5%88%B0%E4%B8%80%E6%9D%A1%E5%91%BD%E4%BB%A4/</id>
    <link href="https://iyuluo.com/2026/10/13/macOS-%E5%BA%94%E7%94%A8%E5%86%99%E5%AE%8C%E4%B9%8B%E5%90%8E%EF%BC%8C%E6%80%8E%E4%B9%88%E4%BA%A4%E5%88%B0%E7%94%A8%E6%88%B7%E6%89%8B%E9%87%8C%EF%BC%88%E4%BA%8C%EF%BC%89%EF%BC%9A%E4%BB%8E%E6%89%8B%E5%8A%A8%E6%B5%81%E7%A8%8B%E5%88%B0%E4%B8%80%E6%9D%A1%E5%91%BD%E4%BB%A4/"/>
    <published>2026-10-13T06:19:12.000Z</published>
    <summary>
      <![CDATA[<p>上一篇《macOS 应用写完之后，怎么交到用户手里？》画了一张地图：写好代码、编译测试、签名、公证、附加票据、打包、上传下载渠道，最后让用户安装。</p>
<p>地图有了，这一次真的跑了一遍。SSHGuard 的 1.0.0（构建 4）从代码提交一路走到了 Apple 公证通过、上传到 R2、更新清单上线。</p>
<p>结论先说：<strong>“能跑”的部分几乎不会卡住你，卡住的是“检查和验证”的部分。</strong> 公证被拒时命令返回成功、下载的包能被系统静默挪走、权限报错会跟着系统语言变化……这些都不是文档首页会写的事，是跑过一次才会知道的事。</p>
<p>这篇就按实际踩过的顺序讲。</p>]]>
    </summary>
    <title>macOS 应用写完之后，怎么交到用户手里（二）：从手动流程到一条命令</title>
    <updated>2026-10-13T06:20:16.690Z</updated>
  </entry>
  <entry>
    <author>
      <name>yuluo</name>
    </author>
    <category term="Apple" scheme="https://iyuluo.com/categories/Apple/"/>
    <category term="Apple" scheme="https://iyuluo.com/tags/Apple/"/>
    <content>
      <![CDATA[<h2 id="背景"><a href="#背景" class="headerlink" title="背景"></a>背景</h2><p>第一次开发 Mac 应用，最容易困惑的地方，是代码写完之后。</p><p>在 Xcode 里点“运行”，应用已经能打开了。但准备交给别人时，又出现了签名、证书、公证、归档、商店审核这些词。</p><p>其实可以先抓住一条主线：</p><p><strong>把代码变成应用 → 测试 → 选择分发渠道 → 完成渠道要求 → 让用户安装。</strong></p><p>下面从头梳理，并说明每条路径分别怎么走。</p><span id="more"></span><h2 id="一、先看完整地图"><a href="#一、先看完整地图" class="headerlink" title="一、先看完整地图"></a>一、先看完整地图</h2><pre><code class=" mermaid">flowchart TD    A[&quot;写好代码&quot;] --&gt; B[&quot;编译，生成 .app 应用&quot;]    B --&gt; C[&quot;测试功能、权限和异常情况&quot;]    C --&gt; D&#123;&quot;用户从哪里获得应用？&quot;&#125;    D --&gt; E[&quot;只在自己的 Mac 上使用&quot;]    E --&gt; F[&quot;本地运行&quot;]    D --&gt; G[&quot;官网、GitHub 或直接发安装包&quot;]    G --&gt; H[&quot;Developer ID 签名&quot;]    H --&gt; I[&quot;Apple 公证&quot;]    I --&gt; J[&quot;附加票据、验证并打包&quot;]    J --&gt; K[&quot;上传下载渠道&quot;]    K --&gt; L[&quot;用户下载并安装&quot;]    D --&gt; M[&quot;TestFlight 测试&quot;]    M --&gt; N[&quot;上传 App Store Connect&quot;]    N --&gt; O[&quot;配置测试与必要的测试审核&quot;]    O --&gt; P[&quot;邀请用户试用并收集反馈&quot;]    D --&gt; Q[&quot;Mac App Store 正式发布&quot;]    Q --&gt; R[&quot;满足沙盒与商店要求&quot;]    R --&gt; S[&quot;上传构建和商店资料&quot;]    S --&gt; T[&quot;提交审核&quot;]    T --&gt; U[&quot;通过后发布&quot;]    U --&gt; V[&quot;用户从 App Store 安装&quot;]</code></pre><p>这几条路径不需要全部走一遍。你可以只给自己用，也可以只提供官网安装包，或者选择 App Store。</p><p>TestFlight 是测试渠道，通常服务于发布前的试用。</p><h2 id="二、共同的起点：把代码变成应用"><a href="#二、共同的起点：把代码变成应用" class="headerlink" title="二、共同的起点：把代码变成应用"></a>二、共同的起点：把代码变成应用</h2><h3 id="1-写代码"><a href="#1-写代码" class="headerlink" title="1. 写代码"></a>1. 写代码</h3><p>源码里包含应用的功能、界面，以及图标等资源。</p><p>用户通常不会拿这些源码安装。他们需要的是编译好的应用。</p><h3 id="2-编译"><a href="#2-编译" class="headerlink" title="2. 编译"></a>2. 编译</h3><p>Xcode 把源码转换成 macOS 可以运行的程序，最终形成：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">SSHGuard.app<br></code></pre></td></tr></table></figure><p><code>.app</code> 看起来像一个文件，实际上是一个应用包，里面装着程序、资源和配置信息。</p><p>开发过程中，点击 Xcode 的“运行”，就会完成编译并启动应用，方便你边写边看效果。</p><h3 id="3-测试"><a href="#3-测试" class="headerlink" title="3. 测试"></a>3. 测试</h3><p>编译成功，只说明程序可以被生成出来。</p><p>接下来要检查：</p><ul><li>是否能启动，主要功能是否正常。</li><li>用户拒绝权限时，应用是否能正确处理。</li><li>保存、删除、恢复等操作是否安全。</li><li>退出再打开后，数据是否正常。</li><li>在支持的系统版本和设备上是否能运行。</li></ul><p>这些工作不能由签名、公证或商店审核代替。</p><h3 id="4-准备发布构建和归档"><a href="#4-准备发布构建和归档" class="headerlink" title="4. 准备发布构建和归档"></a>4. 准备发布构建和归档</h3><p>准备交付时，通常会生成 Release 构建，再保存一份归档。</p><p>可以把归档理解为：<strong>把这次准备发布的版本完整留底。</strong></p><pre><code class=" mermaid">flowchart LR    A[&quot;源代码和资源&quot;] --&gt;|&quot;编译&quot;| B[&quot;可运行的 .app&quot;]    B --&gt;|&quot;归档&quot;| C[&quot;.xcarchive&lt;br/&gt;保存发布构建&quot;]    C --&gt;|&quot;导出&quot;| D[&quot;用于交付的应用&quot;]</code></pre><p><code>.xcarchive</code> 给开发者保存和导出使用，普通用户不需要用它安装。</p><p>完成这些共同步骤之后，再按渠道继续。</p><h2 id="三、路径一：只给自己用"><a href="#三、路径一：只给自己用" class="headerlink" title="三、路径一：只给自己用"></a>三、路径一：只给自己用</h2><p>如果只是给自己做一个小工具，流程很短：</p><pre><code class=" mermaid">flowchart LR    A[&quot;写代码&quot;] --&gt; B[&quot;在 Xcode 编译运行&quot;]    B --&gt; C[&quot;测试和修复&quot;]    C --&gt; D[&quot;在自己的 Mac 上使用&quot;]</code></pre><p>你可以继续通过 Xcode 启动，也可以使用本地生成的应用。</p><p><strong>在自己的开发机器上使用，不需要为了发布而提交 Apple 公证或商店审核。</strong></p><p>但自己的电脑能运行，不代表发给别人就能顺利打开。另一台 Mac 会检查下载应用的来源，而且可能有不同的系统、权限和运行环境。</p><p>准备交给其他人时，就需要选择下面的分发方式。</p><h2 id="四、路径二：官网、GitHub-或直接发安装包"><a href="#四、路径二：官网、GitHub-或直接发安装包" class="headerlink" title="四、路径二：官网、GitHub 或直接发安装包"></a>四、路径二：官网、GitHub 或直接发安装包</h2><p>用户从你的下载链接获得 ZIP 或 DMG，再将应用安装到自己的 Mac。</p><p>这是商店外直接分发。完整流程如下：</p><pre><code class=" mermaid">flowchart TD    A[&quot;测试通过的发布构建&quot;] --&gt; B[&quot;使用 Developer ID 证书签名&quot;]    B --&gt; C[&quot;上传 Apple 公证服务&quot;]    C --&gt; D&#123;&quot;公证结果&quot;&#125;    D --&gt;|&quot;未通过&quot;| E[&quot;查看原因，修复后重新提交&quot;]    E --&gt; B    D --&gt;|&quot;通过&quot;| F[&quot;获得公证票据&quot;]    F --&gt; G[&quot;将票据附加到应用&quot;]    G --&gt; H[&quot;验证签名、票据和系统接受状态&quot;]    H --&gt; I[&quot;生成最终 ZIP 或 DMG&quot;]    I --&gt; J[&quot;验证最终安装包&quot;]    J --&gt; K[&quot;上传官网或 GitHub&quot;]    K --&gt; L[&quot;用户下载、安装并打开&quot;]</code></pre><p>为了让商店外分发的应用在默认 Gatekeeper 安全设置下通过检查，需要使用 Developer ID 签名并完成公证。<a href="https://support.apple.com/en-ie/guide/security/sec3ad8e6e53/web">Apple 平台安全说明</a></p><h3 id="第一步：签名——说明是谁发布的"><a href="#第一步：签名——说明是谁发布的" class="headerlink" title="第一步：签名——说明是谁发布的"></a>第一步：签名——说明是谁发布的</h3><p>签名可以理解为开发者给应用盖的一枚“可验证的印章”。</p><p>它帮助系统确认：</p><ul><li>应用由哪个开发者签发。</li><li>签名之后，文件有没有被改动。</li></ul><p>商店外分发的应用通常使用 <strong>Developer ID Application</strong> 证书签名。<a href="https://developer.apple.com/documentation/xcode/creating-distribution-signed-code-for-the-mac/">Apple 签名文档</a></p><p>证书背后还对应一个私钥。私钥用于执行签名，应妥善保存在本机或安全的构建环境中，不能当成普通文件发给别人。</p><h3 id="第二步：公证——让-Apple-做自动安全检查"><a href="#第二步：公证——让-Apple-做自动安全检查" class="headerlink" title="第二步：公证——让 Apple 做自动安全检查"></a>第二步：公证——让 Apple 做自动安全检查</h3><p>签名完成后，把应用提交到 Apple 公证服务。</p><p>Apple 会检查已知恶意内容，以及签名等必要条件。通过后，为这份软件签发公证票据。<a href="https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution">Apple 公证说明</a></p><p>这里要分清三件事：</p><p><strong>公证不会替你验证所有功能，不会自动上架 App Store，也不会自动提供公开下载链接。</strong></p><p>它只是这份软件的安全检查环节。</p><h3 id="第三步：附加票据——让应用带着检查凭证"><a href="#第三步：附加票据——让应用带着检查凭证" class="headerlink" title="第三步：附加票据——让应用带着检查凭证"></a>第三步：附加票据——让应用带着检查凭证</h3><p>公证通过后，可以把票据附加到应用上，这一步叫 <code>staple</code>。</p><p>这样，用户的 Mac 在无法连接 Apple 服务时，也可以核验应用携带的公证凭证。</p><p>如果提交的是 ZIP，不能直接给 ZIP 附加票据。通常需要给里面的 <code>.app</code> 附加票据，再重新压缩。<a href="https://developer.apple.com/documentation/security/customizing-the-notarization-workflow">Apple 公证工作流</a></p><p>所以，最终发布包必须包含附加票据之后的应用。</p><h3 id="第四步：验证和打包"><a href="#第四步：验证和打包" class="headerlink" title="第四步：验证和打包"></a>第四步：验证和打包</h3><p>发布前，要确认签名完整、公证票据有效，以及 Gatekeeper 接受应用。</p><p>Gatekeeper 就是 macOS 打开下载应用时进行安全检查的机制。即使应用已经公证，首次打开时仍可能出现正常的“是否确定打开”提示。<a href="https://support.apple.com/en-au/102445">Apple 支持说明</a></p><p>用户下载的包装通常有三种：</p><table><thead><tr><th>格式</th><th>安装方式</th><th>常见用途</th></tr></thead><tbody><tr><td>ZIP</td><td>解压，将应用拖进“应用程序”</td><td>简单工具、早期版本</td></tr><tr><td>DMG</td><td>打开磁盘映像，拖动应用安装</td><td>常见的 Mac 安装体验</td></tr><tr><td>PKG</td><td>跟随安装器完成步骤</td><td>需要安装多个组件的应用</td></tr></tbody></table><p>它们是包装和安装方式，不是三个不同的发布渠道。涉及安装器或磁盘映像时，还需要按对应格式处理签名和公证。<a href="https://developer.apple.com/documentation/xcode/packaging-mac-software-for-distribution">Apple 打包文档</a></p><h3 id="第五步：放到用户能下载的地方"><a href="#第五步：放到用户能下载的地方" class="headerlink" title="第五步：放到用户能下载的地方"></a>第五步：放到用户能下载的地方</h3><p>官网、GitHub Releases、直接发文件，都可以交付同一份安装包。</p><table><thead><tr><th>下载渠道</th><th>适合什么情况</th><th>你要负责什么</th></tr></thead><tbody><tr><td>官网</td><td>产品介绍、帮助、购买和下载集中展示</td><td>网站、文件托管、版本说明</td></tr><tr><td>GitHub Releases</td><td>按版本提供安装包</td><td>发布文件、更新说明</td></tr><tr><td>直接发文件</td><td>少量用户试用</td><td>说明版本、安装方式、收集反馈</td></tr></tbody></table><p>它们都不会自动为你的应用增加更新功能。初期可以让用户手动下载新版本；应用内自动更新需要另外实现（本系列第二篇记录了接入 Sparkle 的实际过程）。</p><p>下面这张图说明了两次“上传”的区别：</p><pre><code class=" mermaid">sequenceDiagram    participant D as 开发者    participant A as Apple 公证服务    participant W as 官网或 GitHub    participant U as 用户    D-&gt;&gt;D: 构建、测试并签名    D-&gt;&gt;A: 上传应用，申请公证    A--&gt;&gt;D: 通过后提供公证票据    D-&gt;&gt;D: 附加票据，验证并打包    D-&gt;&gt;W: 上传最终安装包    U-&gt;&gt;W: 下载    U-&gt;&gt;U: 安装并打开应用</code></pre><p><strong>上传 Apple 是为了检查；上传下载渠道是为了交付。</strong></p><h2 id="五、路径三：Mac-App-Store"><a href="#五、路径三：Mac-App-Store" class="headerlink" title="五、路径三：Mac App Store"></a>五、路径三：Mac App Store</h2><p>如果希望用户在 App Store 搜索、安装和更新应用，就走商店流程。</p><pre><code class=" mermaid">flowchart TD    A[&quot;检查功能能否符合沙盒和商店要求&quot;] --&gt; B[&quot;准备并测试商店版本&quot;]    B --&gt; C[&quot;创建 App Store Connect 应用记录&quot;]    C --&gt; D[&quot;准备介绍、截图、隐私信息等资料&quot;]    D --&gt; E[&quot;上传构建&quot;]    E --&gt; F[&quot;选择构建并提交审核&quot;]    F --&gt; G&#123;&quot;审核结果&quot;&#125;    G --&gt;|&quot;需要修改&quot;| H[&quot;根据反馈调整应用或资料&quot;]    H --&gt; F    G --&gt;|&quot;通过&quot;| I[&quot;按所选方式发布&quot;]    I --&gt; J[&quot;用户从 App Store 安装&quot;]    J --&gt; K[&quot;后续版本通过商店更新&quot;]</code></pre><h3 id="先确认功能能否进入商店"><a href="#先确认功能能否进入商店" class="headerlink" title="先确认功能能否进入商店"></a>先确认功能能否进入商店</h3><p>Mac App Store 对应用有额外要求，其中包括 App Sandbox。</p><p>沙盒会限制应用可以访问的文件和系统资源。应用需要通过适当的授权和技术方案完成所需操作。<a href="https://developer.apple.com/documentation/security/protecting-user-data-with-app-sandbox">Apple 沙盒文档</a></p><p>因此，需要访问用户文件、运行工具或与其他应用协作的软件，应提前验证这些功能能否符合要求。不能等全部做完后，才发现核心功能需要调整。</p><h3 id="再准备商店页面和构建"><a href="#再准备商店页面和构建" class="headerlink" title="再准备商店页面和构建"></a>再准备商店页面和构建</h3><p>App Store Connect 是开发者管理商店应用的后台。</p><p>你需要建立应用记录，准备介绍、截图和隐私信息，并上传适合商店分发的构建。</p><p><strong>上传成功只是材料到了后台，还没有上架。</strong></p><p>之后要提交审核。审核通过，再按选择的发布方式让用户获得应用。</p><p>商店版本也要遵守对应的更新规则。<a href="https://developer.apple.com/cn/app-store/review/guidelines/">Apple 审核指南</a></p><h3 id="商店审核和公证不是一回事"><a href="#商店审核和公证不是一回事" class="headerlink" title="商店审核和公证不是一回事"></a>商店审核和公证不是一回事</h3><table><thead><tr><th>对比项</th><th>商店外公证</th><th>App Store 审核</th></tr></thead><tbody><tr><td>主要用途</td><td>自动安全检查</td><td>检查是否符合商店要求</td></tr><tr><td>通过后由谁分发</td><td>开发者安排下载渠道</td><td>App Store</td></tr><tr><td>是否创建商店页面</td><td>不会</td><td>发布后有</td></tr><tr><td>是否替代开发者测试</td><td>不会</td><td>不会</td></tr></tbody></table><p>通过 Mac App Store 分发的应用，不需要再单独完成商店外的 Developer ID 公证流程，商店提交过程包含相应安全检查。<a href="https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution">Apple 公证说明</a></p><h2 id="六、TestFlight：正式发布前先邀请用户试用"><a href="#六、TestFlight：正式发布前先邀请用户试用" class="headerlink" title="六、TestFlight：正式发布前先邀请用户试用"></a>六、TestFlight：正式发布前先邀请用户试用</h2><p>如果还没准备正式发布，希望先收集反馈，可以使用 TestFlight。</p><pre><code class=" mermaid">flowchart TD    A[&quot;准备测试构建&quot;] --&gt; B[&quot;上传 App Store Connect&quot;]    B --&gt; C[&quot;配置测试信息和测试人员&quot;]    C --&gt; D[&quot;外部测试按要求完成测试审核&quot;]    D --&gt; E[&quot;用户通过 TestFlight 安装&quot;]    E --&gt; F[&quot;收集反馈&quot;]    F --&gt; G[&quot;修改并上传新构建&quot;]    G --&gt; C</code></pre><p>TestFlight 提供测试版的安装和反馈渠道。每个构建最多可测试 90 天，外部测试构建可能需要审核，因此不适合作为永久正式安装包。<a href="https://developer.apple.com/help/app-store-connect/test-a-beta-version/testflight-overview/">Apple TestFlight 说明</a></p><p>少量用户试用也可以直接领取公证安装包，不一定要使用 TestFlight。</p><h2 id="七、每次更新，都要重新走哪些步骤？"><a href="#七、每次更新，都要重新走哪些步骤？" class="headerlink" title="七、每次更新，都要重新走哪些步骤？"></a>七、每次更新，都要重新走哪些步骤？</h2><p>发布不是只做一次。</p><p>假设第一版是 1.0，后来修复问题，准备发布 1.1：</p><pre><code class=" mermaid">flowchart TD    A[&quot;修改代码，准备新版本&quot;] --&gt; B[&quot;重新构建和测试&quot;]    B --&gt; C&#123;&quot;使用哪个渠道？&quot;&#125;    C --&gt; D[&quot;直接分发&quot;]    D --&gt; E[&quot;重新签名、公证、打包和验证&quot;]    E --&gt; F[&quot;发布新安装包，通知或提供更新&quot;]    C --&gt; G[&quot;App Store&quot;]    G --&gt; H[&quot;上传新构建，提交新版本审核&quot;]    H --&gt; I[&quot;通过后发布更新&quot;]    C --&gt; J[&quot;TestFlight&quot;]    J --&gt; K[&quot;上传新测试构建并安排测试&quot;]</code></pre><p>上一版的签名和公证，不能直接代替修改后新版本的发布检查。</p><h2 id="八、最后还要模拟一次用户安装"><a href="#八、最后还要模拟一次用户安装" class="headerlink" title="八、最后还要模拟一次用户安装"></a>八、最后还要模拟一次用户安装</h2><p>无论选择什么渠道，都应该测试用户真正拿到的版本。</p><p>从最终下载链接或测试渠道安装，检查首次启动、权限请求和关键功能，不能只测试 Xcode 里的开发构建。注意用浏览器或正常下载渠道获取安装包：<code>curl</code> 等命令行下载不会带上系统的 <code>com.apple.quarantine</code> 属性，用户真实的”下载 → 首次打开”安全检查流程在命令行下载里走不到。</p><p>对于会修改重要文件的软件，使用专用测试账户、独立测试数据或可回滚环境。</p><p>例如 SSH 管理工具，不应拿日常使用的密钥和配置去做破坏性恢复测试。</p><h2 id="九、SSHGuard-当前走的是哪条路？"><a href="#九、SSHGuard-当前走的是哪条路？" class="headerlink" title="九、SSHGuard 当前走的是哪条路？"></a>九、SSHGuard 当前走的是哪条路？</h2><p>SSHGuard 当前选择的是<strong>商店外直接分发</strong>。</p><p>因为它需要管理真实的 <code>~/.ssh</code>、使用系统 SSH 并与终端协作，先沿这条路径完成交付。以后是否上 App Store，再单独评估沙盒和商店要求。</p><p>当前进度如下：</p><pre><code class=" mermaid">flowchart TD    A[&quot;构建与归档：已完成&quot;] --&gt; B[&quot;Developer ID 签名：已完成&quot;]    B --&gt; C[&quot;Apple 公证：Accepted&quot;]    C --&gt; D[&quot;附加票据、最终打包与验证：已完成&quot;]    D --&gt; E[&quot;上传下载渠道：已完成&quot;]    E --&gt; F[&quot;真实自动升级验收：进行中&quot;]     style A fill:#dcfce7,stroke:#16a34a,color:#14532d    style B fill:#dcfce7,stroke:#16a34a,color:#14532d    style C fill:#dcfce7,stroke:#16a34a,color:#14532d    style D fill:#dcfce7,stroke:#16a34a,color:#14532d    style E fill:#dcfce7,stroke:#16a34a,color:#14532d    style F fill:#fef3c7,stroke:#d97706,color:#78350f</code></pre><p>下一步如果继续，就是提交 Apple 公证。通过后附加票据、生成最终安装包，再完成安装与功能验收。下载渠道可以随后选择官网或 GitHub Releases。</p><p><strong>写好代码之后，真正要做的是把“开发者电脑上能运行的程序”，变成“用户拿到后能安装、能使用、能更新的产品”。</strong></p>]]>
    </content>
    <id>https://iyuluo.com/2026/10/10/macOS-%E5%BA%94%E7%94%A8%E5%86%99%E5%AE%8C%E4%B9%8B%E5%90%8E%EF%BC%8C%E6%80%8E%E4%B9%88%E4%BA%A4%E5%88%B0%E7%94%A8%E6%88%B7%E6%89%8B%E9%87%8C%EF%BC%9F/</id>
    <link href="https://iyuluo.com/2026/10/10/macOS-%E5%BA%94%E7%94%A8%E5%86%99%E5%AE%8C%E4%B9%8B%E5%90%8E%EF%BC%8C%E6%80%8E%E4%B9%88%E4%BA%A4%E5%88%B0%E7%94%A8%E6%88%B7%E6%89%8B%E9%87%8C%EF%BC%9F/"/>
    <published>2026-10-10T05:34:00.000Z</published>
    <summary>
      <![CDATA[<h2 id="背景"><a href="#背景" class="headerlink" title="背景"></a>背景</h2><p>第一次开发 Mac 应用，最容易困惑的地方，是代码写完之后。</p>
<p>在 Xcode 里点“运行”，应用已经能打开了。但准备交给别人时，又出现了签名、证书、公证、归档、商店审核这些词。</p>
<p>其实可以先抓住一条主线：</p>
<p><strong>把代码变成应用 → 测试 → 选择分发渠道 → 完成渠道要求 → 让用户安装。</strong></p>
<p>下面从头梳理，并说明每条路径分别怎么走。</p>]]>
    </summary>
    <title>macOS 应用写完之后，怎么交到用户手里？</title>
    <updated>2026-10-13T06:18:52.016Z</updated>
  </entry>
  <entry>
    <author>
      <name>yuluo</name>
    </author>
    <category term="Soft" scheme="https://iyuluo.com/categories/Soft/"/>
    <category term="Tailscale" scheme="https://iyuluo.com/tags/Tailscale/"/>
    <content>
      <![CDATA[<!--Fluid 可选 Front Matter：updated:index_img:og_img:mermaid: true--><h2 id="背景"><a href="#背景" class="headerlink" title="背景"></a>背景</h2><p>最近折腾家里的网络，遇到了一个挺别扭的问题。</p><p>电脑访问正常，NAS 也正常。偏偏负责转发这些流量的路由器，自己连不上外面。</p><p>在线更新检查失败，终端里发起请求一直超时，Tailscale 也在反复报控制服务器连接失败。</p><p><strong>它能帮别人把数据送出去，轮到自己就不行。</strong></p><p>更让人怀疑人生的是：停掉 OpenClash，马上恢复；重新开启，又开始超时。</p><p>当时很自然地把目光放在了 OpenClash 上。是不是核心有问题？是不是本机代理规则写错了？是不是定制版本改坏了什么？</p><p>查到最后才发现，锅不能这么简单地扣过去。</p><span id="more"></span><p>这次真正撞到一起的，是三个东西：</p><blockquote><p>运营商分配的 CGNAT 地址、OpenClash 的本机 TCP 重定向，以及 Tailscale 的来源防伪规则。</p></blockquote><p>下面记录一下这次排查过程。设备名称、实际地址和配置信息均已脱敏。</p><h2 id="一、最烦的地方：每个现象单独看，都像另一个问题"><a href="#一、最烦的地方：每个现象单独看，都像另一个问题" class="headerlink" title="一、最烦的地方：每个现象单独看，都像另一个问题"></a>一、最烦的地方：每个现象单独看，都像另一个问题</h2><p>家里的结构并不复杂：</p><table><thead><tr><th>设备</th><th>用途</th></tr></thead><tbody><tr><td>OpenWrt 系路由器</td><td>默认网关，运行 OpenClash</td></tr><tr><td>路由器上的 Tailscale</td><td>提供远程访问家庭网段的入口</td></tr><tr><td>常开的 Unraid NAS</td><td>文件服务及其他应用</td></tr><tr><td>电脑、手机等</td><td>普通局域网客户端</td></tr></tbody></table><p>故障发生时，局域网设备访问正常，但路由器本机的多个 TCP 请求都超时。</p><p>这就很容易往几个方向想：</p><ul><li>更新源是不是临时不可用？</li><li>DNS 返回的地址是不是有问题？</li><li>Tailscale 是不是连接不稳定？</li><li>代理节点是不是异常？</li><li>刚调整过的网络设置是不是留下了什么影响？</li></ul><p>麻烦在于，这些猜测都有一点道理。</p><p>但有一条证据把范围压了下来：</p><p><strong>同一个裸 IP 请求，OpenClash 开启时超时，关闭后立即成功。</strong></p><p>目标没有变，也没有经过域名解析。</p><p>所以先把 DNS 放到一边。至于目标服务本身不可用，也解释不了这个开关对照。</p><p>不过，这时候只能说：</p><blockquote><p>OpenClash 开启后，某条数据包路径出了问题。</p></blockquote><p>还不能直接说核心坏了。</p><p>现在回头看，这个区别很重要。要是在这里就开始反复换核心、重装插件，很可能忙了一圈，问题还在。</p><h2 id="二、先查规则，两个“看起来很像”的疑点被排除了"><a href="#二、先查规则，两个“看起来很像”的疑点被排除了" class="headerlink" title="二、先查规则，两个“看起来很像”的疑点被排除了"></a>二、先查规则，两个“看起来很像”的疑点被排除了</h2><p>第一个疑点是进程身份。</p><p>进程列表显示 Clash 以 root 运行，而防回环规则匹配的是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">skgid 65534<br></code></pre></td></tr></table></figure><p>乍一看，很像核心自己的出站连接没有被豁免，又被抓回代理里，绕成了一个圈。</p><p>但读取进程状态后，实际情况是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs text">UID：0<br>GID：65534<br></code></pre></td></tr></table></figure><p>这里被进程列表误导了。</p><p>root 是用户身份，65534 是组身份，它们可以同时存在。防火墙按 socket 的组匹配，并不要求用户名也变成别的。</p><p>这个方向先划掉。</p><p>第二个疑点是本机 TCP 处理链。</p><p>当时看到 UDP 有打标规则，TCP 却没有，第一感觉是漏了什么。继续查才确认，当前模式下两者本来就走不同路径：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs text">TCP → NAT REDIRECT → 本机代理端口<br>UDP → 打标 → 策略路由 → TUN<br></code></pre></td></tr></table></figure><p>本机 TCP 链完整存在，目标端口也有 Clash 监听。发起测试后，重定向规则计数还增加了。</p><p>也就是说：</p><p><strong>请求确实被接管了，端口也确实在监听，但连接就是建不起来。</strong></p><p>查到这里，问题开始有点意思了。</p><h2 id="三、一个很有用的对照：显式代理居然成功了"><a href="#三、一个很有用的对照：显式代理居然成功了" class="headerlink" title="三、一个很有用的对照：显式代理居然成功了"></a>三、一个很有用的对照：显式代理居然成功了</h2><p>接着让路由器访问同一个裸 IP 目标，分别走两种方式：</p><table><thead><tr><th>方式</th><th>结果</th></tr></thead><tbody><tr><td>普通请求，由透明代理接管</td><td>TCP 建连超时</td></tr><tr><td>显式连接本机 HTTP 代理</td><td>HTTP 200</td></tr></tbody></table><p>这组结果比“停服务后恢复”更有价值。</p><p>同一个核心，能够处理显式代理请求，说明至少这条目标请求的处理和出站能力是正常的。</p><p>故障范围进一步缩小到了：</p><blockquote><p>普通本机请求，被透明重定向之后，到底发生了什么？</p></blockquote><p>此时继续盯着更新源或者远端服务器，意义已经不大了。因为失败发生在 TCP 建连阶段，连后面的 TLS 和 HTTP 都还没轮到。</p><p>该抓包了。</p><h2 id="四、真正的转折：SYN-已经回到本机，却没人回应"><a href="#四、真正的转折：SYN-已经回到本机，却没人回应" class="headerlink" title="四、真正的转折：SYN 已经回到本机，却没人回应"></a>四、真正的转折：SYN 已经回到本机，却没人回应</h2><p>短时抓包里出现了一批重复的 SYN。</p><p>脱敏后，数据包大致长这样：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs text">入接口：lo<br>源地址：WAN_CGNAT_IP<br>目标：127.0.0.1:透明代理端口<br>TCP 标志：SYN<br></code></pre></td></tr></table></figure><p>没有看到 SYN-ACK。</p><p>这里最关键的不是端口，而是<strong>源地址</strong>。</p><p>路由器准备访问外部目标时，先选用了 WAN 地址作为来源。随后 OpenClash 把目标改成本机代理端口，但原先的源地址仍然保留着。</p><p>于是，一个“路由器自己访问外面”的请求，变成了：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs text">运营商分配的 WAN 地址<br>→ 本机回环接口<br>→ 本机代理端口<br></code></pre></td></tr></table></figure><p>这条路径本身并不必然有问题。</p><p>但它会经过本机的入站检查。</p><p>而家里的路由器上，除了 OpenWrt 的防火墙，还有 Tailscale 安装的规则。</p><h2 id="五、终于找到：Tailscale-把这个请求当成了可疑来源"><a href="#五、终于找到：Tailscale-把这个请求当成了可疑来源" class="headerlink" title="五、终于找到：Tailscale 把这个请求当成了可疑来源"></a>五、终于找到：Tailscale 把这个请求当成了可疑来源</h2><p>继续读取完整规则集，发现 INPUT 会进入 Tailscale 的 <code>ts-input</code> 链。</p><p>里面有一条规则，意思很直接：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs text">来源属于 100.64.0.0/10<br>并且不是从 tailscale0 接口进来<br>→ DROP<br></code></pre></td></tr></table></figure><p>问题来了。</p><p><code>100.64.0.0/10</code> 是 CGNAT 地址范围，运营商会使用，Tailscale 也使用。</p><p>路由器的 WAN 地址，恰好就在这个范围里面。</p><p>重定向之后，请求又是从 <code>lo</code> 进入的，当然不是 <code>tailscale0</code>。</p><p>两个条件都满足，直接丢弃。</p><p><strong>代理端口虽然开着，但 SYN 在到达监听 socket 之前就被挡住了。</strong></p><p>这也解释了为什么之前看 OpenWrt 的 <code>fw4</code>，明明有回环放行和 DNAT 放行，还是不通。</p><p>因为规则不只存在于 <code>fw4</code> 里。一个基础链放行之后，另一个基础链仍然可以把包丢掉。</p><p>Tailscale 官方也有专门的 <a href="https://tailscale.com/docs/reference/cgnat-interoperability">CGNAT 兼容说明</a>：这种地址重叠下，来源防伪规则可能同时挡住合法流量。</p><p>到这里，前面那些零散的现象终于能接起来了。</p><h2 id="六、只换一个源地址，结果就变了"><a href="#六、只换一个源地址，结果就变了" class="headerlink" title="六、只换一个源地址，结果就变了"></a>六、只换一个源地址，结果就变了</h2><p>为了再确认一次，没有修改防火墙，而是让同一个请求临时绑定路由器的 LAN 地址。</p><p>其余条件保持一致。</p><p>结果：</p><table><thead><tr><th>请求来源</th><th>结果</th></tr></thead><tbody><tr><td>绑定 LAN 地址</td><td>很快返回 HTTP 200</td></tr><tr><td>使用默认 WAN 地址</td><td>等待数秒后建连超时</td></tr></tbody></table><p>这一下就很清楚了。</p><p>核心没换，目标没换，透明代理还在。仅仅改变来源地址，结果就从超时变成成功。</p><p>再回头看，之前觉得矛盾的地方，其实都说得通：</p><p><strong>为什么电脑正常？</strong></p><p>电脑的来源是局域网地址，不属于 <code>100.64.0.0/10</code>，不会命中这条规则。</p><p><strong>为什么显式代理正常？</strong></p><p>显式连接本机代理时，使用的是回环来源，同样不命中。</p><p><strong>为什么关掉 OpenClash 就恢复？</strong></p><p>请求不再被转送回本机，也就不会经过这条发生冲突的入站路径。</p><p><strong>为什么 Tailscale 自己也连不上控制服务器？</strong></p><p>它发起的本机 TCP 请求，也可能被透明代理接管，再被它自己的防火墙规则拦住。</p><p>这个结果多少有点绕：原本用来保护连接的规则，碰上另一套流量处理机制，最后把自己的连接也挡了。</p><h2 id="七、最后怎么处理：把远程入口交给-NAS"><a href="#七、最后怎么处理：把远程入口交给-NAS" class="headerlink" title="七、最后怎么处理：把远程入口交给 NAS"></a>七、最后怎么处理：把远程入口交给 NAS</h2><p>找到原因之后，其实可以加一个精确例外。</p><p>只允许本机经回环接口访问透明代理端口，并让这类流量避开 Tailscale 的来源丢弃规则。</p><p>但临时加一条规则，和以后一直稳定，是两回事。</p><p>后面还得考虑：</p><ul><li>Tailscale 重启会不会重建规则？</li><li>防火墙重载后，例外还在不在？</li><li>WAN 重拨换地址怎么办？</li><li>代理端口变化后，规则会不会失效？</li><li>开机时两个服务的启动顺序有没有影响？</li></ul><p>能解决，不过家里已经有一台常开的 Unraid NAS，也运行着 Tailscale。</p><p>所以最后选择把远程访问家庭网段的入口迁到 NAS：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs text">外部设备<br>→ Tailscale<br>→ NAS 子网路由<br>→ 家庭局域网<br></code></pre></td></tr></table></figure><p>主路由器继续负责默认网关和 OpenClash。</p><p>操作顺序也没有直接“一关了之”：</p><ol><li>NAS 通告家庭网段。</li><li>在 Tailscale 后台批准路由。</li><li>从外部网络验证能访问其他家庭设备。</li><li>确认正常后，再停用路由器上的 Tailscale。</li><li>检查路由器上的相关规则是否清理。</li></ol><p>这里验证的重点，是能访问<strong>其他局域网设备</strong>。只打开 NAS 自己的页面，不能证明子网转发已经正常。</p><p>切换之后，使用恢复正常。</p><p>代价也很明确：NAS 要保持在线。它关机，远程回家的入口也就暂时没了。对一台原本就常开的设备来说，这个取舍可以接受。</p><h2 id="八、这次折腾留下的几个提醒"><a href="#八、这次折腾留下的几个提醒" class="headerlink" title="八、这次折腾留下的几个提醒"></a>八、这次折腾留下的几个提醒</h2><p>最开始，确实觉得这像是 OpenClash 的问题。</p><p>毕竟开着就坏，关了就好，证据看起来已经很充分。</p><p>但沿着连接路径继续查，才发现它只是让数据包走到了另一条规则面前。真正发生冲突的，是源地址、重定向和来源检查组合起来后的行为。</p><p>以后再遇到类似情况，可以优先做这几件事：</p><ul><li>裸 IP 与域名请求分开测。</li><li>显式代理与透明代理分开测。</li><li>看清楚失败是在 TCP、TLS，还是 HTTP 阶段。</li><li>检查所有相关防火墙表，而不只看系统默认那一张。</li><li>服务共存时，把“它们之间的交互”也列入排查范围。</li></ul><p>还有一点：</p><p><strong>电脑能上网，只能证明电脑那条路径正常。</strong></p><p>路由器自己的请求从 OUTPUT 出发，客户端流量则从 PREROUTING 进入。看起来都经过同一台设备，实际走的规则可以差很多。</p><p>这次折腾完，至少下次再看到“电脑正常，路由器超时”，不会先急着换核心了。</p><p>先看包从哪里来、被送到哪里，又在哪条规则前停下。</p><p>比对着“连接超时”四个字猜来猜去，有用得多。</p>]]>
    </content>
    <id>https://iyuluo.com/2026/10/06/%E4%B8%80%E6%AC%A1%E2%80%9C%E7%94%B5%E8%84%91%E8%83%BD%E4%B8%8A%E7%BD%91%EF%BC%8C%E8%B7%AF%E7%94%B1%E5%99%A8%E5%8D%B4%E6%96%AD%E7%BD%91%E2%80%9D%E7%9A%84%E6%8A%98%E8%85%BE%EF%BC%9AOpenClash-%E4%B8%8E-Tailscale-%E6%92%9E%E5%88%B0%E4%BA%86%E4%B8%80%E8%B5%B7/</id>
    <link href="https://iyuluo.com/2026/10/06/%E4%B8%80%E6%AC%A1%E2%80%9C%E7%94%B5%E8%84%91%E8%83%BD%E4%B8%8A%E7%BD%91%EF%BC%8C%E8%B7%AF%E7%94%B1%E5%99%A8%E5%8D%B4%E6%96%AD%E7%BD%91%E2%80%9D%E7%9A%84%E6%8A%98%E8%85%BE%EF%BC%9AOpenClash-%E4%B8%8E-Tailscale-%E6%92%9E%E5%88%B0%E4%BA%86%E4%B8%80%E8%B5%B7/"/>
    <published>2026-10-06T10:56:48.000Z</published>
    <summary>
      <![CDATA[<!--
Fluid 可选 Front Matter：
updated:
index_img:
og_img:
mermaid: true
-->

<h2 id="背景"><a href="#背景" class="headerlink" title="背景"></a>背景</h2><p>最近折腾家里的网络，遇到了一个挺别扭的问题。</p>
<p>电脑访问正常，NAS 也正常。偏偏负责转发这些流量的路由器，自己连不上外面。</p>
<p>在线更新检查失败，终端里发起请求一直超时，Tailscale 也在反复报控制服务器连接失败。</p>
<p><strong>它能帮别人把数据送出去，轮到自己就不行。</strong></p>
<p>更让人怀疑人生的是：停掉 OpenClash，马上恢复；重新开启，又开始超时。</p>
<p>当时很自然地把目光放在了 OpenClash 上。是不是核心有问题？是不是本机代理规则写错了？是不是定制版本改坏了什么？</p>
<p>查到最后才发现，锅不能这么简单地扣过去。</p>]]>
    </summary>
    <title>一次“电脑能上网，路由器却断网”的折腾：OpenClash 与 Tailscale 撞到了一起</title>
    <updated>2026-10-06T10:58:42.142Z</updated>
  </entry>
  <entry>
    <author>
      <name>yuluo</name>
    </author>
    <category term="NAS" scheme="https://iyuluo.com/categories/NAS/"/>
    <category term="UNRAID" scheme="https://iyuluo.com/tags/UNRAID/"/>
    <content>
      <![CDATA[<h2 id="一、背景"><a href="#一、背景" class="headerlink" title="一、背景"></a>一、背景</h2><p>最近在折腾 Unraid 7.3 的 Internal Boot，也就是从传统 USB 启动迁移到内部 SSD 启动。</p><p>原本的目标很简单：</p><blockquote><p>用一块企业级 SATA SSD 替代 U 盘，作为 Unraid 的内部启动盘，提高稳定性。</p></blockquote><p>但实际迁移过程中，顺带暴露出了一堆隐藏问题：</p><ol><li>Cache Pool 中一块 NVMe 已经出现真实介质错误；</li><li>Btrfs RAID1 虽然能自修复，但并不代表硬盘健康；</li><li>512G 临时盘无法直接 replace 1T cache 盘；</li><li>Btrfs allocated chunk 和实际 used 不是一回事；</li><li>Internal Boot 写入成功后，BIOS 仍然无法从 SSD 启动；</li><li>LSI SAS2308 HBA 卡上的 SSD 无法被 BIOS 当作启动盘；</li><li>TPM 许可迁移后出现多个 key 文件冲突；</li><li>迁移后 License 页面显示过期时间，引发对 legacy lifetime updates 的疑问。</li></ol><p>这篇文章记录完整处理过程，供后续折腾 Unraid 7.3 Internal Boot、Btrfs cache pool 和 TPM license 的人参考。</p><span id="more"></span><hr><h2 id="二、原始环境"><a href="#二、原始环境" class="headerlink" title="二、原始环境"></a>二、原始环境</h2><p>当前机器大致结构如下：</p><table><thead><tr><th>设备</th><th>用途</th></tr></thead><tbody><tr><td>USB 启动盘</td><td>原 Unraid 启动盘</td></tr><tr><td>240G 企业级 SATA SSD</td><td>准备作为 Internal Boot</td></tr><tr><td>1T NVMe</td><td>原 cache pool 成员之一，健康</td></tr><tr><td>1T NVMe</td><td>原 cache pool 成员之一，后续确认故障</td></tr><tr><td>512G NVMe</td><td>临时用于替换故障 cache 盘</td></tr><tr><td>多块 HDD</td><td>Unraid Array 数据盘</td></tr><tr><td>LSI SAS2308 HBA</td><td>连接部分 SATA&#x2F;SAS 设备</td></tr></tbody></table><hr><h2 id="三、第一次发现问题：Btrfs-scrub-报错但可修复"><a href="#三、第一次发现问题：Btrfs-scrub-报错但可修复" class="headerlink" title="三、第一次发现问题：Btrfs scrub 报错但可修复"></a>三、第一次发现问题：Btrfs scrub 报错但可修复</h2><p>一开始执行 cache pool 的 Btrfs scrub：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">btrfs scrub start -B /mnt/cache<br></code></pre></td></tr></table></figure><p>第一次结果出现大量错误：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs text">Error summary: read=256 verify=20 csum=2937<br>Corrected: 3213<br>Uncorrectable: 0<br>Unverified: 0<br></code></pre></td></tr></table></figure><p>第二次 scrub 又变成：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">Error summary: no errors found<br></code></pre></td></tr></table></figure><p>表面看似没问题，因为：</p><ul><li><code>Corrected</code> 不为 0；</li><li><code>Uncorrectable = 0</code>；</li><li>第二次 scrub 已经无错误。</li></ul><p>但这其实只是说明：</p><blockquote><p>Btrfs RAID1 成功从另一块健康盘读取正确副本，并修复了错误数据。</p></blockquote><p>这不代表故障盘健康。</p><hr><h2 id="四、真正的硬盘故障证据：device-stats-和-dmesg"><a href="#四、真正的硬盘故障证据：device-stats-和-dmesg" class="headerlink" title="四、真正的硬盘故障证据：device stats 和 dmesg"></a>四、真正的硬盘故障证据：device stats 和 dmesg</h2><p>继续查看 Btrfs device stats：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">btrfs device stats /mnt/cache<br></code></pre></td></tr></table></figure><p>结果非常明显：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><code class="hljs text">[/dev/nvme1n1p1].write_io_errs    29513266<br>[/dev/nvme1n1p1].read_io_errs     88468<br>[/dev/nvme1n1p1].flush_io_errs    1244217<br>[/dev/nvme1n1p1].corruption_errs  38172<br>[/dev/nvme1n1p1].generation_errs  5<br><br>[/dev/nvme0n1p1].write_io_errs    0<br>[/dev/nvme0n1p1].read_io_errs     0<br>[/dev/nvme0n1p1].flush_io_errs    0<br>[/dev/nvme0n1p1].corruption_errs  0<br>[/dev/nvme0n1p1].generation_errs  0<br></code></pre></td></tr></table></figure><p>可以确认：</p><blockquote><p><code>/dev/nvme1n1p1</code> 是故障盘，<code>/dev/nvme0n1p1</code> 是健康盘。</p></blockquote><p>再看 dmesg：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">dmesg | grep -i nvme<br></code></pre></td></tr></table></figure><p>其中出现了关键错误：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs text">critical medium error, dev nvme1n1<br>I/O Error (sct 0x2 / sc 0x81)<br></code></pre></td></tr></table></figure><p>这类错误已经不是 APST、省电、驱动、PCIe reset 之类的问题，而是典型的：</p><blockquote><p>SSD 介质 &#x2F; 数据完整性错误。</p></blockquote><p>即使 Windows 下 SMART 显示“健康”，只要出现：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">Media and Data Integrity Errors &gt; 0<br></code></pre></td></tr></table></figure><p>就不能再把它当作可靠盘使用。</p><p>这块故障 NVMe 后续拆下来重新查看，SMART 中也能看到：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">Media and Data Integrity Errors = 36<br></code></pre></td></tr></table></figure><p>这个值正常应该是 0。</p><hr><h2 id="五、关于-SMART-的一个误区"><a href="#五、关于-SMART-的一个误区" class="headerlink" title="五、关于 SMART 的一个误区"></a>五、关于 SMART 的一个误区</h2><p>这次有一个很典型的误区：</p><blockquote><p>Windows 工具显示“健康”不代表盘真的适合继续做 NAS cache。</p></blockquote><p>很多 SMART 工具主要看：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">Critical Warning<br></code></pre></td></tr></table></figure><p>只要它是 0，就显示“健康”。</p><p>但对 NAS &#x2F; Btrfs &#x2F; ZFS 来说，更重要的是：</p><table><thead><tr><th>指标</th><th>要求</th></tr></thead><tbody><tr><td>Media and Data Integrity Errors</td><td>必须为 0</td></tr><tr><td>Error Information Log Entries</td><td>越少越好，需看具体类型</td></tr><tr><td>Reallocated &#x2F; Pending &#x2F; Uncorrectable</td><td>必须为 0</td></tr><tr><td>Btrfs corruption_errs</td><td>必须为 0</td></tr><tr><td>scrub uncorrectable</td><td>必须为 0</td></tr></tbody></table><p>这次坏盘的问题就是：</p><ul><li>Windows 健康状态正常；</li><li>但 Btrfs 已经发现大量读写、flush、corruption 错误；</li><li>dmesg 已经出现 critical medium error；</li><li>SMART 里 Media and Data Integrity Errors 已经不是 0。</li></ul><p>所以结论很明确：</p><blockquote><p>这块盘还能亮、还能读，不等于还能作为 cache &#x2F; appdata &#x2F; docker &#x2F; VM 盘继续使用。</p></blockquote><hr><h2 id="六、512G-替换-1T-cache-盘为什么失败？"><a href="#六、512G-替换-1T-cache-盘为什么失败？" class="headerlink" title="六、512G 替换 1T cache 盘为什么失败？"></a>六、512G 替换 1T cache 盘为什么失败？</h2><p>手边刚好有一块 512G NVMe，于是想直接替换故障的 1T 1T NVMe。</p><p>一开始查看：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">btrfs filesystem show<br></code></pre></td></tr></table></figure><p>看到 cache pool：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs text">Total devices 2 FS bytes used 372.30GiB<br>devid 1 size 953.87GiB used 375.03GiB path /dev/nvme1n1p1<br>devid 2 size 953.87GiB used 375.03GiB path /dev/nvme0n1p1<br></code></pre></td></tr></table></figure><p>这里有一个关键点：</p><blockquote><p><code>FS bytes used</code> 只有 372GiB，说明真实逻辑数据量并不大。</p></blockquote><p>理论上 512G 应该够。</p><p>但在 Unraid GUI 或 Btrfs replace 时提示：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs text">wrong pool state<br>replace device too small<br></code></pre></td></tr></table></figure><p>原因在于：</p><blockquote><p>Btrfs replace 看的不只是实际 used，还会受到 allocated chunk 的影响。</p></blockquote><p>也就是说，Btrfs 可能只用了 372G 数据，但已经分配了更大的 chunk。此前 usage 中曾看到：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">Device allocated: 741.06GiB<br></code></pre></td></tr></table></figure><p>所以直接用 512G 替换会失败。</p><hr><h2 id="七、解决办法：先降级为单盘，再清理-RAID1-allocation"><a href="#七、解决办法：先降级为单盘，再清理-RAID1-allocation" class="headerlink" title="七、解决办法：先降级为单盘，再清理 RAID1 allocation"></a>七、解决办法：先降级为单盘，再清理 RAID1 allocation</h2><p>拆掉故障盘后，Btrfs pool 进入 degraded 状态：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">btrfs filesystem show<br></code></pre></td></tr></table></figure><p>显示：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><code class="hljs text">warning, device 1 is missing<br><br>Label: none uuid: 1d44c14b...<br>Total devices 2 FS bytes used 372.28GiB<br>devid 2 size 953.87GiB used 375.03GiB path /dev/nvme0n1p1<br>*** Some devices missing<br></code></pre></td></tr></table></figure><p>此时需要先手动 degraded mount：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">mkdir</span> -p /mnt/recovery<br>mount -o degraded /dev/nvme0n1p1 /mnt/recovery<br></code></pre></td></tr></table></figure><p>确认数据还能看到后，开始 balance，把 RAID1 profile 转成 single：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">btrfs balance start -dconvert=single -mconvert=dup /mnt/cache<br></code></pre></td></tr></table></figure><p>最终状态变成：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><code class="hljs text">pool status: ONLINE<br>Device size: 953.87GiB<br>Device allocated: 379.06GiB<br>Used: 373.54GiB<br>Data ratio: 1.00<br>Metadata ratio: 2.00<br>Multiple profiles: no<br><br>Data single<br>Metadata DUP<br>System DUP<br></code></pre></td></tr></table></figure><p>这说明：</p><ul><li>cache pool 已经从 RAID1 降级为单盘；</li><li>不再 missing device；</li><li>allocated 已经降到 379G；</li><li>未来再加 512G 重新转 RAID1 就可行了。</li></ul><p>当前状态下，其实已经安全很多：</p><table><thead><tr><th>项目</th><th>状态</th></tr></thead><tbody><tr><td>pool</td><td>ONLINE</td></tr><tr><td>数据</td><td>single</td></tr><tr><td>metadata</td><td>DUP</td></tr><tr><td>corruption errors</td><td>0</td></tr><tr><td>missing device</td><td>无</td></tr><tr><td>allocated</td><td>379G</td></tr></tbody></table><hr><h2 id="八、为什么没有立刻把-512G-加回-RAID1？"><a href="#八、为什么没有立刻把-512G-加回-RAID1？" class="headerlink" title="八、为什么没有立刻把 512G 加回 RAID1？"></a>八、为什么没有立刻把 512G 加回 RAID1？</h2><p>虽然现在可以加回 512G，但我最后没有急着操作。</p><p>原因是：</p><ol><li>当前 cache 已经稳定为单盘；</li><li>512G 是国产消费级 NVMe；</li><li>刚经历一次故障和大量 balance，不宜立刻继续重 IO；</li><li>后续计划等 SSD 价格回落后，换 1T 企业 SATA &#x2F; 企业 NVMe 更合适。</li></ol><p>如果后面要重新加回 512G RAID1，可以在 GUI 中把 512G 加入 pool，然后执行：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">btrfs balance start -dconvert=raid1 -mconvert=raid1 /mnt/cache<br></code></pre></td></tr></table></figure><p>但当前更稳妥的是：</p><blockquote><p>先单盘稳定运行，建立 appdata 备份，再决定是否恢复 RAID1。</p></blockquote><hr><h2 id="九、240G-S4610-做-Internal-Boot-是否合适？"><a href="#九、240G-S4610-做-Internal-Boot-是否合适？" class="headerlink" title="九、240G S4610 做 Internal Boot 是否合适？"></a>九、240G S4610 做 Internal Boot 是否合适？</h2><p>这次准备作为 Internal Boot 的盘是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs text">&lt;ENTERPRISE-SATA-SSD-MODEL-REDACTED&gt;<br>240G 企业级 SATA SSD<br></code></pre></td></tr></table></figure><p>SMART 状态如下：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><code class="hljs text">Reallocated sector count = 0<br>Current pending sector = 0<br>Offline uncorrectable = 0<br>CRC error count = 0<br>Program fail count total = 0<br>Erase fail count total = 0<br>Percent life remaining = 100%<br>Power on hours = 41479<br>Temperature = 27℃<br></code></pre></td></tr></table></figure><p>虽然通电时间已经 4.7 年，但对企业 SATA SSD 来说不算夸张。</p><p>而且它的关键指标很干净：</p><ul><li>无重映射；</li><li>无 pending；</li><li>无 uncorrectable；</li><li>无 CRC；</li><li>PLP 相关项正常；</li><li>剩余寿命 100%。</li></ul><p>这类企业 SATA 盘做 Unraid Internal Boot 非常合适，因为 Unraid boot 盘主要存：</p><ul><li>config；</li><li>license；</li><li>plugins；</li><li>docker templates；</li><li>系统启动文件。</li></ul><p>并不承载高频 appdata &#x2F; database &#x2F; VM 写入。</p><p>所以 240G 甚至是非常富裕的容量。</p><hr><h2 id="十、Internal-Boot-页面中“专用启动池”和“启动-数据池”的区别"><a href="#十、Internal-Boot-页面中“专用启动池”和“启动-数据池”的区别" class="headerlink" title="十、Internal Boot 页面中“专用启动池”和“启动 + 数据池”的区别"></a>十、Internal Boot 页面中“专用启动池”和“启动 + 数据池”的区别</h2><p>Unraid 7.3 Internal Boot 创建时，会让你选择池模式：</p><ol><li>专用启动池；</li><li>启动 + 数据池。</li></ol><p>二者区别如下：</p><table><thead><tr><th>模式</th><th>说明</th><th>是否推荐</th></tr></thead><tbody><tr><td>专用启动池</td><td>SSD 只做 Unraid boot</td><td>推荐</td></tr><tr><td>启动 + 数据池</td><td>SSD 同时做 boot 和普通 data pool</td><td>不推荐当前环境使用</td></tr></tbody></table><p>我的选择是：</p><blockquote><p>专用启动池。</p></blockquote><p>原因很简单：</p><ul><li>Boot 和 cache 应该隔离；</li><li>启动盘不应承载 Docker &#x2F; appdata &#x2F; VM；</li><li>一旦启动盘坏，不应影响普通数据池；</li><li>这种结构更接近传统 USB boot + 独立 cache 的稳定模型。</li></ul><p>最终设计：</p><table><thead><tr><th>设备</th><th>用途</th></tr></thead><tbody><tr><td>240G S4610</td><td>Internal Boot</td></tr><tr><td>1T Toshiba NVMe</td><td>Cache</td></tr><tr><td>512G NVMe</td><td>临时备用 &#x2F; 未来 mirror</td></tr><tr><td>HDD Array</td><td>主数据</td></tr></tbody></table><hr><h2 id="十一、Internal-Boot-写入成功，但拔-U-盘无法启动"><a href="#十一、Internal-Boot-写入成功，但拔-U-盘无法启动" class="headerlink" title="十一、Internal Boot 写入成功，但拔 U 盘无法启动"></a>十一、Internal Boot 写入成功，但拔 U 盘无法启动</h2><p>Internal Boot 创建成功后，<code>lsblk -f</code> 显示：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs text">sdc<br>├─sdc1<br>├─sdc2 vfat FAT32 EFI<br>├─sdc3 zfs_member flash<br>└─sdc4<br></code></pre></td></tr></table></figure><p>说明：</p><ul><li>EFI 分区存在；</li><li>ZFS flash boot pool 存在；</li><li>Internal Boot 本身已经写入成功。</li></ul><p>进一步检查 EFI 文件：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">mkdir</span> -p /mnt/bootssd<br>mount /dev/sdc2 /mnt/bootssd<br>find /mnt/bootssd<br></code></pre></td></tr></table></figure><p>输出：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs text">/mnt/bootssd<br>/mnt/bootssd/EFI<br>/mnt/bootssd/EFI/BOOT<br>/mnt/bootssd/EFI/BOOT/BOOTX64.EFI<br></code></pre></td></tr></table></figure><p>这说明 EFI fallback 文件也存在。</p><p>然后通过 <code>efibootmgr</code> 手动创建 EFI 启动项：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash">efibootmgr -c -d /dev/sdc -p 2 -L <span class="hljs-string">&quot;UnraidSSD&quot;</span> -l <span class="hljs-string">&#x27;\EFI\BOOT\BOOTX64.EFI&#x27;</span><br>efibootmgr -o 0000<br></code></pre></td></tr></table></figure><p>最终：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs text">BootCurrent: 0001<br>BootOrder: 0000<br>Boot0000* UnraidSSD<br>Boot0001* UEFI: USB-Boot-Device, Partition 1<br></code></pre></td></tr></table></figure><p>看起来已经正确。</p><p>但拔掉 U 盘后，机器仍然直接进入 BIOS，Boot 页面看不到这个 SSD。</p><hr><h2 id="十二、最终确认：问题出在-LSI-SAS2308-HBA-启动支持"><a href="#十二、最终确认：问题出在-LSI-SAS2308-HBA-启动支持" class="headerlink" title="十二、最终确认：问题出在 LSI SAS2308 HBA 启动支持"></a>十二、最终确认：问题出在 LSI SAS2308 HBA 启动支持</h2><p>最后发现一个关键事实：</p><blockquote><p>这块 S4610 是通过 LSI SAS2308 HBA 连接到主板的。</p></blockquote><p>这就解释了所有现象。</p><p>系统启动后，Linux 能看到 SSD：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">sdc 223.6G &lt;ENTERPRISE-SATA-SSD-MODEL-REDACTED&gt;<br></code></pre></td></tr></table></figure><p>但 BIOS 阶段看不到启动项。</p><p>这是典型的：</p><blockquote><p>HBA 在操作系统里可识别，但 BIOS &#x2F; UEFI 阶段不提供可启动路径。</p></blockquote><p>LSI SAS2308 这类 HBA 分几个部分：</p><table><thead><tr><th>部分</th><th>作用</th></tr></thead><tbody><tr><td>IT Firmware</td><td>操作系统识别磁盘</td></tr><tr><td>Legacy BIOS ROM</td><td>Legacy 启动</td></tr><tr><td>UEFI ROM</td><td>UEFI 启动</td></tr></tbody></table><p>很多二手 HBA 卡只刷了 IT firmware，没有刷 UEFI boot ROM，或者主板 BIOS 不支持从这张卡启动。</p><p>所以现象就是：</p><table><thead><tr><th>阶段</th><th>状态</th></tr></thead><tbody><tr><td>Unraid 启动后</td><td>能看到 SSD</td></tr><tr><td>EFI 分区</td><td>正常</td></tr><tr><td>BOOTX64.EFI</td><td>正常</td></tr><tr><td>efibootmgr</td><td>能创建 entry</td></tr><tr><td>BIOS 启动阶段</td><td>看不到 SSD &#x2F; 无法启动</td></tr></tbody></table><p>最终结论：</p><blockquote><p>Internal Boot 本身没有问题，问题在于启动 SSD 挂在 HBA 后，主板 BIOS 无法从该 HBA 上的 SATA SSD 启动。</p></blockquote><p>解决方案有两个：</p><h3 id="方案-A：启动-SSD-接板载-SATA"><a href="#方案-A：启动-SSD-接板载-SATA" class="headerlink" title="方案 A：启动 SSD 接板载 SATA"></a>方案 A：启动 SSD 接板载 SATA</h3><p>这是最推荐方案。</p><table><thead><tr><th>设备</th><th>连接方式</th></tr></thead><tbody><tr><td>S4610 Boot SSD</td><td>板载 SATA</td></tr><tr><td>HDD 数据盘</td><td>LSI SAS2308</td></tr><tr><td>NVMe cache</td><td>主板 M.2</td></tr></tbody></table><p>这是最稳、最少玄学的方案。</p><h3 id="方案-B：给-SAS2308-HBA-补刷-UEFI-ROM"><a href="#方案-B：给-SAS2308-HBA-补刷-UEFI-ROM" class="headerlink" title="方案 B：给 SAS2308 HBA 补刷 UEFI ROM"></a>方案 B：给 SAS2308 HBA 补刷 UEFI ROM</h3><p>理论可行，但不推荐作为首选。</p><p>原因：</p><ul><li>有刷错风险；</li><li>不同 OEM 卡兼容性不同；</li><li>主板 BIOS 仍可能不认；</li><li>HBA 初始化会拖慢启动；</li><li>启动盘挂 HBA 本身就不如接板载 SATA 稳。</li></ul><hr><h2 id="十三、BIOS-设置踩坑"><a href="#十三、BIOS-设置踩坑" class="headerlink" title="十三、BIOS 设置踩坑"></a>十三、BIOS 设置踩坑</h2><p>过程中也排查过华硕 BIOS 设置。</p><p>关键设置如下：</p><table><thead><tr><th>项目</th><th>推荐值</th></tr></thead><tbody><tr><td>Secure Boot &#x2F; OS Type</td><td>Other OS</td></tr><tr><td>Launch CSM</td><td>Disabled</td></tr><tr><td>Boot from Storage Devices</td><td>UEFI only</td></tr><tr><td>Fast Boot</td><td>Disabled</td></tr><tr><td>AMI Native NVMe Driver Support</td><td>On</td></tr></tbody></table><p>其中：</p><ul><li>Secure Boot 不关，Unraid EFI 可能无法启动；</li><li>CSM 开启时，有些华硕 BIOS 不扫描 Linux EFI；</li><li>Fast Boot 可能跳过部分存储扫描；</li><li>但这些都调整后仍不行，最终才确认是 HBA 启动支持问题。</li></ul><hr><h2 id="十四、TPM-许可迁移后的多个-key-文件问题"><a href="#十四、TPM-许可迁移后的多个-key-文件问题" class="headerlink" title="十四、TPM 许可迁移后的多个 key 文件问题"></a>十四、TPM 许可迁移后的多个 key 文件问题</h2><p>Internal Boot 成功后，Unraid 进入注册页面，提示：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs text">存在多个许可证密钥<br>License key type: Invalid<br></code></pre></td></tr></table></figure><p>检查：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">ls</span> /boot/config/*.key<br></code></pre></td></tr></table></figure><p>发现：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs text">/boot/config/Basic.key<br>/boot/config/Plus.key<br></code></pre></td></tr></table></figure><p>也就是 <code>/boot/config</code> 下有多个 key 文件。</p><p>Unraid 7.3 TPM licensing 对 key 文件更严格，只能保留一个有效 key。</p><p>最终删除旧的 Basic.key：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">rm</span> /boot/config/Basic.key<br><span class="hljs-built_in">sync</span><br>reboot<br></code></pre></td></tr></table></figure><p>然后重新更新密钥，许可恢复。</p><hr><h2 id="十五、License-Expires-After-是什么？"><a href="#十五、License-Expires-After-是什么？" class="headerlink" title="十五、License Expires After 是什么？"></a>十五、License Expires After 是什么？</h2><p>迁移 TPM license 后，页面显示：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs text">Unraid Plus License<br>GUID: &lt;LICENSE-GUID-REDACTED&gt;<br>License Expires After: 2025-09-11<br></code></pre></td></tr></table></figure><p>这个地方容易误解。</p><p>它不是说系统到期不能用了。</p><p>Unraid 新许可体系里：</p><ul><li>License 本身是永久的；</li><li>Expire 通常指更新资格；</li><li>到期后当前版本仍可继续使用；</li><li>但是否能继续享受 lifetime updates，要看账号里 Manage Purchases 的状态。</li></ul><p>本次账号的 Manage Purchases 中显示：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">OS Updates: Lifetime<br></code></pre></td></tr></table></figure><p>因此这里需要向 Unraid support 确认：</p><blockquote><p>TPM 迁移后，legacy Plus license 是否仍然保留 lifetime OS updates，以及页面中的 expires after 是否只是显示问题或迁移后的兼容字段。</p></blockquote><hr><h2 id="十六、最终状态"><a href="#十六、最终状态" class="headerlink" title="十六、最终状态"></a>十六、最终状态</h2><p>目前处理后的状态：</p><table><thead><tr><th>项目</th><th>状态</th></tr></thead><tbody><tr><td>故障 1T NVMe</td><td>已拆除，不再用于生产数据</td></tr><tr><td>Cache pool</td><td>单盘 online，Data single，Metadata DUP</td></tr><tr><td>Btrfs corruption</td><td>归零</td></tr><tr><td>512G NVMe</td><td>可作为后续临时 mirror</td></tr><tr><td>240G S4610</td><td>Internal Boot 已创建成功</td></tr><tr><td>Internal Boot EFI</td><td>正常存在</td></tr><tr><td>BIOS 启动失败原因</td><td>HBA 不支持 &#x2F; 未提供 UEFI boot</td></tr><tr><td>License 多 key 问题</td><td>已定位并处理</td></tr><tr><td>TPM License</td><td>已迁移，但 lifetime updates 状态需向官方确认</td></tr></tbody></table><hr><h2 id="十七、这次折腾的经验总结"><a href="#十七、这次折腾的经验总结" class="headerlink" title="十七、这次折腾的经验总结"></a>十七、这次折腾的经验总结</h2><h3 id="1-Btrfs-scrub-能修复，不代表硬盘健康"><a href="#1-Btrfs-scrub-能修复，不代表硬盘健康" class="headerlink" title="1. Btrfs scrub 能修复，不代表硬盘健康"></a>1. Btrfs scrub 能修复，不代表硬盘健康</h3><p>如果 scrub 出现 corrected，而不是 uncorrectable，只说明镜像起作用了。</p><p>真正要看：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs bash">btrfs device stats /mnt/cache<br>dmesg | grep -i nvme<br>smartctl -a /dev/nvmeXn1<br></code></pre></td></tr></table></figure><p>尤其是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs text">Media and Data Integrity Errors<br>critical medium error<br>corruption_errs<br></code></pre></td></tr></table></figure><p>这些才是判断硬盘是否可靠的关键。</p><hr><h3 id="2-Windows-SMART-健康状态不能作为-NAS-可靠性判断"><a href="#2-Windows-SMART-健康状态不能作为-NAS-可靠性判断" class="headerlink" title="2. Windows SMART 健康状态不能作为 NAS 可靠性判断"></a>2. Windows SMART 健康状态不能作为 NAS 可靠性判断</h3><p>“健康 &#x2F; 优秀”只适合普通用户粗略参考。</p><p>NAS &#x2F; Btrfs &#x2F; ZFS 环境应该更严格：</p><ul><li>Media Errors 必须为 0；</li><li>corruption 必须为 0；</li><li>pending &#x2F; uncorrectable 必须为 0；</li><li>scrub 不应反复 corrected。</li></ul><hr><h3 id="3-Btrfs-的-used-和-allocated-不是一回事"><a href="#3-Btrfs-的-used-和-allocated-不是一回事" class="headerlink" title="3. Btrfs 的 used 和 allocated 不是一回事"></a>3. Btrfs 的 used 和 allocated 不是一回事</h3><p>这次最大坑之一是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">FS bytes used 372G<br></code></pre></td></tr></table></figure><p>但 replace 512G 仍然失败。</p><p>原因是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">Device allocated<br></code></pre></td></tr></table></figure><p>过高。</p><p>Btrfs replace &#x2F; shrink &#x2F; balance 过程中，allocated chunk 非常关键。</p><hr><h3 id="4-降容量-replace-很麻烦，重建-pool-有时反而更干净"><a href="#4-降容量-replace-很麻烦，重建-pool-有时反而更干净" class="headerlink" title="4. 降容量 replace 很麻烦，重建 pool 有时反而更干净"></a>4. 降容量 replace 很麻烦，重建 pool 有时反而更干净</h3><p>从 1T RAID1 换到 512G RAID1，理论上只要 used 足够小就行。</p><p>但实际会受：</p><ul><li>allocated chunk；</li><li>metadata；</li><li>profile；</li><li>balance 状态；</li></ul><p>影响。</p><p>如果数据量可控，有时备份 appdata 后重建 cache pool 反而更简单。</p><hr><h3 id="5-Internal-Boot-最适合用“专用启动池”"><a href="#5-Internal-Boot-最适合用“专用启动池”" class="headerlink" title="5. Internal Boot 最适合用“专用启动池”"></a>5. Internal Boot 最适合用“专用启动池”</h3><p>不建议把 boot SSD 同时当普通 data pool 使用。</p><p>更稳结构是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs text">小企业 SATA SSD = 专用 Internal Boot<br>NVMe / SSD = cache<br>HDD = array<br></code></pre></td></tr></table></figure><hr><h3 id="6-企业-SATA-SSD-做-boot-很合适"><a href="#6-企业-SATA-SSD-做-boot-很合适" class="headerlink" title="6. 企业 SATA SSD 做 boot 很合适"></a>6. 企业 SATA SSD 做 boot 很合适</h3><p>240G S4610 这种盘虽然不新，但做 Unraid Boot 非常合适：</p><ul><li>稳定；</li><li>有 SMART；</li><li>有 PLP；</li><li>发热低；</li><li>写入压力小；</li><li>比 USB 盘可靠得多。</li></ul><hr><h3 id="7-HBA-上能被-Linux-识别，不代表能被-BIOS-启动"><a href="#7-HBA-上能被-Linux-识别，不代表能被-BIOS-启动" class="headerlink" title="7. HBA 上能被 Linux 识别，不代表能被 BIOS 启动"></a>7. HBA 上能被 Linux 识别，不代表能被 BIOS 启动</h3><p>这是这次 Internal Boot 最大的坑。</p><p>LSI SAS2308 这类 HBA：</p><ul><li>进系统后识别磁盘没问题；</li><li>但 BIOS 阶段不一定能从它启动；</li><li>取决于是否有 UEFI ROM、主板 BIOS 是否支持。</li></ul><p>如果启动盘接在 HBA 上，遇到 BIOS 看不到启动项，优先怀疑 HBA boot support。</p><p>最稳方案：</p><blockquote><p>启动盘接板载 SATA，HBA 只管数据盘。</p></blockquote><hr><h3 id="8-TPM-license-迁移后，注意-boot-config-下只能留一个-key"><a href="#8-TPM-license-迁移后，注意-boot-config-下只能留一个-key" class="headerlink" title="8. TPM license 迁移后，注意 /boot/config 下只能留一个 key"></a>8. TPM license 迁移后，注意 <code>/boot/config</code> 下只能留一个 key</h3><p>如果出现：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs text">存在多个许可证密钥<br>License key type: Invalid<br></code></pre></td></tr></table></figure><p>检查：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">ls</span> /boot/config/*.key<br></code></pre></td></tr></table></figure><p>只保留当前有效的 key，删除旧 key。</p><hr><h3 id="9-Legacy-Plus-用户迁移-TPM-后，要确认-lifetime-updates-状态"><a href="#9-Legacy-Plus-用户迁移-TPM-后，要确认-lifetime-updates-状态" class="headerlink" title="9. Legacy Plus 用户迁移 TPM 后，要确认 lifetime updates 状态"></a>9. Legacy Plus 用户迁移 TPM 后，要确认 lifetime updates 状态</h3><p>如果页面显示：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">License Expires After<br></code></pre></td></tr></table></figure><p>但账号里显示：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">OS Updates: Lifetime<br></code></pre></td></tr></table></figure><p>建议联系 Unraid support 确认。</p><p>尤其是 legacy Basic &#x2F; Plus &#x2F; Pro 用户，理论上应保留 lifetime updates。</p><hr><h2 id="十八、最终推荐架构"><a href="#十八、最终推荐架构" class="headerlink" title="十八、最终推荐架构"></a>十八、最终推荐架构</h2><p>基于这次踩坑，比较稳的 Unraid 7.3 架构是：</p><table><thead><tr><th>层级</th><th>推荐设备</th></tr></thead><tbody><tr><td>Boot</td><td>板载 SATA 上的小企业 SSD</td></tr><tr><td>Cache</td><td>企业 SATA &#x2F; 企业 NVMe &#x2F; 可靠消费 NVMe</td></tr><tr><td>Cache 冗余</td><td>Btrfs RAID1 或 ZFS mirror</td></tr><tr><td>Array</td><td>HDD + parity</td></tr><tr><td>HBA</td><td>只接数据盘，不接启动盘</td></tr><tr><td>Backup</td><td>定期备份 appdata + boot config</td></tr></tbody></table><p>当前更合理的最终结构应该是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs text">S4610 240G       → 板载 SATA，Internal Boot<br>1T NVMe  → Cache<br>512G NVMe        → 临时备用 / 后续 mirror<br>HDD Array        → 主数据<br>LSI SAS2308      → 只接 HDD 数据盘<br></code></pre></td></tr></table></figure><hr><h2 id="十九、结语"><a href="#十九、结语" class="headerlink" title="十九、结语"></a>十九、结语</h2><p>这次迁移本来只是想把 Unraid 从 U 盘启动改成内部 SSD 启动，但实际过程中暴露出的问题远比预期多。</p><p>最终最大的收获是：</p><blockquote><p>存储系统里，“能识别”和“可靠运行”是两回事；<br>“SMART 健康”和“适合 NAS 长期使用”也是两回事；<br>“EFI 文件存在”和“BIOS 能启动”仍然是两回事。</p></blockquote><p>Unraid 7.3 的 Internal Boot 是一个很好的方向，但如果启动盘挂在 HBA &#x2F; 扩展卡上，仍然可能踩 BIOS 启动支持的坑。</p><p>最稳的做法仍然是：</p><blockquote><p>启动盘接板载控制器，数据盘交给 HBA，cache 做好备份和定期 scrub。</p></blockquote><p>这次虽然过程很折腾，但也顺手发现并处理了一块已经开始出问题的 NVMe。某种意义上，Btrfs RAID1 和 scrub 算是提前救了一次数据。</p>]]>
    </content>
    <id>https://iyuluo.com/2026/05/26/Unraid-7-3-Internal-Boot-%E8%BF%81%E7%A7%BB%E5%AE%9E%E5%BD%95%EF%BC%9A%E4%BB%8E-NVMe-%E6%95%85%E9%9A%9C%E3%80%81Btrfs-%E9%99%8D%E7%BA%A7%EF%BC%8C%E5%88%B0-TPM-%E8%AE%B8%E5%8F%AF%E4%B8%8E-HBA-%E5%90%AF%E5%8A%A8%E9%97%AE%E9%A2%98/</id>
    <link href="https://iyuluo.com/2026/05/26/Unraid-7-3-Internal-Boot-%E8%BF%81%E7%A7%BB%E5%AE%9E%E5%BD%95%EF%BC%9A%E4%BB%8E-NVMe-%E6%95%85%E9%9A%9C%E3%80%81Btrfs-%E9%99%8D%E7%BA%A7%EF%BC%8C%E5%88%B0-TPM-%E8%AE%B8%E5%8F%AF%E4%B8%8E-HBA-%E5%90%AF%E5%8A%A8%E9%97%AE%E9%A2%98/"/>
    <published>2026-05-26T15:18:17.000Z</published>
    <summary>
      <![CDATA[<h2 id="一、背景"><a href="#一、背景" class="headerlink" title="一、背景"></a>一、背景</h2><p>最近在折腾 Unraid 7.3 的 Internal Boot，也就是从传统 USB 启动迁移到内部 SSD 启动。</p>
<p>原本的目标很简单：</p>
<blockquote>
<p>用一块企业级 SATA SSD 替代 U 盘，作为 Unraid 的内部启动盘，提高稳定性。</p>
</blockquote>
<p>但实际迁移过程中，顺带暴露出了一堆隐藏问题：</p>
<ol>
<li>Cache Pool 中一块 NVMe 已经出现真实介质错误；</li>
<li>Btrfs RAID1 虽然能自修复，但并不代表硬盘健康；</li>
<li>512G 临时盘无法直接 replace 1T cache 盘；</li>
<li>Btrfs allocated chunk 和实际 used 不是一回事；</li>
<li>Internal Boot 写入成功后，BIOS 仍然无法从 SSD 启动；</li>
<li>LSI SAS2308 HBA 卡上的 SSD 无法被 BIOS 当作启动盘；</li>
<li>TPM 许可迁移后出现多个 key 文件冲突；</li>
<li>迁移后 License 页面显示过期时间，引发对 legacy lifetime updates 的疑问。</li>
</ol>
<p>这篇文章记录完整处理过程，供后续折腾 Unraid 7.3 Internal Boot、Btrfs cache pool 和 TPM license 的人参考。</p>]]>
    </summary>
    <title>Unraid 7.3 Internal Boot 迁移实录：从 NVMe 故障、Btrfs 降级，到 TPM 许可与 HBA 启动问题</title>
    <updated>2026-05-26T15:32:02.671Z</updated>
  </entry>
  <entry>
    <author>
      <name>yuluo</name>
    </author>
    <category term="Python" scheme="https://iyuluo.com/categories/Python/"/>
    <category term="Workspace" scheme="https://iyuluo.com/tags/Workspace/"/>
    <content>
      <![CDATA[<h2 id="Python-新一代-Monorepo-工程组织方式"><a href="#Python-新一代-Monorepo-工程组织方式" class="headerlink" title="Python 新一代 Monorepo 工程组织方式"></a>Python 新一代 Monorepo 工程组织方式</h2><hr><h1 id="一、前言"><a href="#一、前言" class="headerlink" title="一、前言"></a>一、前言</h1><p>最近打开 PyCharm，你可能会看到这样一个提示：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs text">Enable and try workspace mode Beta<br><br>A large project can be split into smaller parts<br>(like backend, API, and shared code),<br>making it easier to organize and maintain.<br></code></pre></td></tr></table></figure><p>很多人第一反应：</p><blockquote><p>“这是啥？”</p><p>“和普通 Project 有什么区别？”</p><p>“是不是类似 pnpm workspace？”</p><p>“Python 终于开始搞 monorepo 了吗？”</p></blockquote><p>实际上：</p><p>这个功能背后，代表的是 Python 工程组织方式的一次重大升级。</p><p>而它真正对应的生态，其实是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">uv + pyproject.toml + workspace<br></code></pre></td></tr></table></figure><p>这篇文章会从：</p><ul><li>为什么需要 Workspace</li><li>什么是 Monorepo</li><li>uv Workspace 怎么用</li><li>PyCharm Workspace Mode 如何配合</li><li>实际部署怎么做</li><li>如何避免架构混乱</li></ul><p>完整讲清楚。</p><span id="more"></span><hr><h1 id="二、传统-Python-项目的问题"><a href="#二、传统-Python-项目的问题" class="headerlink" title="二、传统 Python 项目的问题"></a>二、传统 Python 项目的问题</h1><p>很多 Python 项目最开始都长这样：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><code class="hljs text">backend/<br>├── api/<br>├── utils/<br>├── services/<br>├── rag/<br>├── crawler/<br>├── scripts/<br>└── requirements.txt<br></code></pre></td></tr></table></figure><p>刚开始没问题。</p><p>但项目越来越大后：</p><ul><li>import 开始混乱</li><li>共用代码越来越多</li><li>utils 到处复制</li><li>requirements.txt 爆炸</li><li>IDE indexing 越来越慢</li><li>多服务越来越难管理</li></ul><p>最后你会看到：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs text">utils_final.py<br>utils_v2.py<br>shared_new/<br>common_latest/<br></code></pre></td></tr></table></figure><p>整个工程开始失控。</p><hr><h1 id="三、为什么现在都在搞-Monorepo"><a href="#三、为什么现在都在搞-Monorepo" class="headerlink" title="三、为什么现在都在搞 Monorepo"></a>三、为什么现在都在搞 Monorepo</h1><p>现代 AI &#x2F; 后端项目越来越复杂：</p><p>例如：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><code class="hljs text">AI 平台<br>├── API 服务<br>├── Worker<br>├── RAG 系统<br>├── Agent<br>├── Embedding 服务<br>├── MCP Server<br>├── Shared SDK<br>└── 管理后台<br></code></pre></td></tr></table></figure><p>如果每个都是独立仓库：</p><ul><li>版本同步困难</li><li>共用代码麻烦</li><li>SDK 更新痛苦</li><li>调试困难</li><li>本地开发复杂</li></ul><p>于是很多公司开始统一：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">一个大仓库（Monorepo）<br></code></pre></td></tr></table></figure><hr><h1 id="四、什么是-Workspace"><a href="#四、什么是-Workspace" class="headerlink" title="四、什么是 Workspace"></a>四、什么是 Workspace</h1><p>Workspace 本质上：</p><p>不是部署工具。</p><p>而是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">多项目统一开发管理<br></code></pre></td></tr></table></figure><p>例如：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><code class="hljs text">my-platform/<br>├── apps/<br>│   ├── api/<br>│   ├── worker/<br>│   └── crawler/<br>│<br>├── libs/<br>│   ├── shared/<br>│   ├── rag/<br>│   └── llm_core/<br>│<br>├── pyproject.toml<br>└── uv.lock<br></code></pre></td></tr></table></figure><p>这里：</p><ul><li>apps 是服务</li><li>libs 是共享代码</li></ul><p>整个仓库：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">一个 workspace<br></code></pre></td></tr></table></figure><hr><h1 id="五、为什么-PyCharm-要推出-Workspace-Mode"><a href="#五、为什么-PyCharm-要推出-Workspace-Mode" class="headerlink" title="五、为什么 PyCharm 要推出 Workspace Mode"></a>五、为什么 PyCharm 要推出 Workspace Mode</h1><p>以前 PyCharm 的模型：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">一个 Project = 一个工程<br></code></pre></td></tr></table></figure><p>但现在：</p><p>一个仓库可能包含：</p><ul><li>API</li><li>SDK</li><li>Shared</li><li>Worker</li><li>Agent</li></ul><p>于是 JetBrains 推出了：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">Workspace Mode<br></code></pre></td></tr></table></figure><p>它能自动：</p><ul><li>识别多个子项目</li><li>建立依赖关系</li><li>自动配置 SDK</li><li>自动 linking</li><li>自动处理 import</li></ul><hr><h1 id="六、为什么-uv-是关键"><a href="#六、为什么-uv-是关键" class="headerlink" title="六、为什么 uv 是关键"></a>六、为什么 uv 是关键</h1><p>现在 Python 新生态核心正在变成：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs text">uv<br>+<br>pyproject.toml<br>+<br>workspace<br></code></pre></td></tr></table></figure><p>而不是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs text">requirements.txt<br>setup.py<br>pip<br></code></pre></td></tr></table></figure><p>uv 是 Astral 推出的新一代 Python 包管理工具。</p><p>特点：</p><ul><li>极快</li><li>原生 workspace</li><li>自动 venv</li><li>lockfile</li><li>monorepo 支持优秀</li></ul><hr><h1 id="七、开始实战：创建-Workspace"><a href="#七、开始实战：创建-Workspace" class="headerlink" title="七、开始实战：创建 Workspace"></a>七、开始实战：创建 Workspace</h1><h2 id="1-安装-uv"><a href="#1-安装-uv" class="headerlink" title="1. 安装 uv"></a>1. 安装 uv</h2><p>Mac：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">brew install uv<br></code></pre></td></tr></table></figure><p>Linux &#x2F; Mac：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">curl -LsSf https://astral.sh/uv/install.sh | sh<br></code></pre></td></tr></table></figure><p>检查：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">uv --version<br></code></pre></td></tr></table></figure><hr><h2 id="2-创建根目录"><a href="#2-创建根目录" class="headerlink" title="2. 创建根目录"></a>2. 创建根目录</h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">mkdir</span> my-platform<br><span class="hljs-built_in">cd</span> my-platform<br></code></pre></td></tr></table></figure><p>初始化：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">uv init<br></code></pre></td></tr></table></figure><hr><h2 id="3-配置-Workspace-Root"><a href="#3-配置-Workspace-Root" class="headerlink" title="3. 配置 Workspace Root"></a>3. 配置 Workspace Root</h2><p>修改根目录：</p><h3 id="pyproject-toml"><a href="#pyproject-toml" class="headerlink" title="pyproject.toml"></a>pyproject.toml</h3><figure class="highlight toml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><code class="hljs toml"><span class="hljs-section">[project]</span><br><span class="hljs-attr">name</span> = <span class="hljs-string">&quot;my-platform&quot;</span><br><span class="hljs-attr">version</span> = <span class="hljs-string">&quot;0.1.0&quot;</span><br><span class="hljs-attr">requires-python</span> = <span class="hljs-string">&quot;&gt;=3.12&quot;</span><br><br><span class="hljs-section">[tool.uv.workspace]</span><br><span class="hljs-attr">members</span> = [<br>    <span class="hljs-string">&quot;apps/*&quot;</span>,<br>    <span class="hljs-string">&quot;libs/*&quot;</span><br>]<br></code></pre></td></tr></table></figure><hr><h1 id="八、创建子项目"><a href="#八、创建子项目" class="headerlink" title="八、创建子项目"></a>八、创建子项目</h1><h2 id="1-创建-API-服务"><a href="#1-创建-API-服务" class="headerlink" title="1. 创建 API 服务"></a>1. 创建 API 服务</h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">mkdir</span> -p apps/api<br><span class="hljs-built_in">cd</span> apps/api<br><br>uv init<br></code></pre></td></tr></table></figure><hr><h2 id="2-创建共享库"><a href="#2-创建共享库" class="headerlink" title="2. 创建共享库"></a>2. 创建共享库</h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">mkdir</span> -p libs/shared<br><span class="hljs-built_in">cd</span> libs/shared<br><br>uv init --lib<br></code></pre></td></tr></table></figure><p>这里：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">--lib<br></code></pre></td></tr></table></figure><p>表示：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">这是一个 library<br></code></pre></td></tr></table></figure><p>而不是 app。</p><hr><h1 id="九、配置-Workspace-内部依赖"><a href="#九、配置-Workspace-内部依赖" class="headerlink" title="九、配置 Workspace 内部依赖"></a>九、配置 Workspace 内部依赖</h1><p>例如：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs text">api<br>依赖<br>shared<br></code></pre></td></tr></table></figure><p>那么：</p><h2 id="apps-api-pyproject-toml"><a href="#apps-api-pyproject-toml" class="headerlink" title="apps&#x2F;api&#x2F;pyproject.toml"></a>apps&#x2F;api&#x2F;pyproject.toml</h2><figure class="highlight toml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><code class="hljs toml"><span class="hljs-section">[project]</span><br><span class="hljs-attr">name</span> = <span class="hljs-string">&quot;api&quot;</span><br><span class="hljs-attr">version</span> = <span class="hljs-string">&quot;0.1.0&quot;</span><br><br><span class="hljs-attr">dependencies</span> = [<br>    <span class="hljs-string">&quot;shared&quot;</span><br>]<br><br><span class="hljs-section">[tool.uv.sources]</span><br><span class="hljs-attr">shared</span> = &#123; workspace = <span class="hljs-literal">true</span> &#125;<br></code></pre></td></tr></table></figure><p>这里：</p><figure class="highlight toml"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs toml"><span class="hljs-attr">workspace</span> = <span class="hljs-literal">true</span><br></code></pre></td></tr></table></figure><p>是核心。</p><p>意思：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs text">shared 不从 PyPI 安装<br>而是从当前 workspace 找<br></code></pre></td></tr></table></figure><hr><h1 id="十、统一安装依赖"><a href="#十、统一安装依赖" class="headerlink" title="十、统一安装依赖"></a>十、统一安装依赖</h1><p>回到根目录：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">cd</span> my-platform<br></code></pre></td></tr></table></figure><p>执行：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">uv <span class="hljs-built_in">sync</span><br></code></pre></td></tr></table></figure><p>或者：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">uv <span class="hljs-built_in">sync</span> --all-packages<br></code></pre></td></tr></table></figure><p>这会：</p><ul><li>创建统一 .venv</li><li>安装全部依赖</li><li>建立 workspace linking</li><li>生成 uv.lock</li></ul><hr><h1 id="十一、PyCharm-如何配置-Workspace-Mode"><a href="#十一、PyCharm-如何配置-Workspace-Mode" class="headerlink" title="十一、PyCharm 如何配置 Workspace Mode"></a>十一、PyCharm 如何配置 Workspace Mode</h1><p>直接：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">打开整个 my-platform<br></code></pre></td></tr></table></figure><p>不是打开：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">apps/api<br></code></pre></td></tr></table></figure><p>然后：</p><p>PyCharm 会自动检测：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">workspace project detected<br></code></pre></td></tr></table></figure><p>开启：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">Enable Workspace Mode<br></code></pre></td></tr></table></figure><p>或者：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs text">Settings<br>→ Project Structure<br>→ Use pyproject.toml-based project model (Beta)<br></code></pre></td></tr></table></figure><p>开启即可。</p><hr><h1 id="十二、开启后会发生什么"><a href="#十二、开启后会发生什么" class="headerlink" title="十二、开启后会发生什么"></a>十二、开启后会发生什么</h1><p>PyCharm 会自动：</p><h2 id="自动识别所有成员"><a href="#自动识别所有成员" class="headerlink" title="自动识别所有成员"></a>自动识别所有成员</h2><p>例如：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs text">apps/api<br>libs/shared<br>libs/rag<br></code></pre></td></tr></table></figure><h2 id="自动处理-import"><a href="#自动处理-import" class="headerlink" title="自动处理 import"></a>自动处理 import</h2><p>以前：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs python"><span class="hljs-keyword">from</span> shared.utils <span class="hljs-keyword">import</span> xxx<br></code></pre></td></tr></table></figure><p>可能飘红。</p><p>现在：</p><p>自动识别。</p><h2 id="自动统一-SDK"><a href="#自动统一-SDK" class="headerlink" title="自动统一 SDK"></a>自动统一 SDK</h2><p>整个 workspace 共用：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">.venv<br></code></pre></td></tr></table></figure><p>不需要手动切换 interpreter。</p><hr><h1 id="十三、部署怎么办？"><a href="#十三、部署怎么办？" class="headerlink" title="十三、部署怎么办？"></a>十三、部署怎么办？</h1><p>很多人会问：</p><blockquote><p>“如果 API 依赖 shared，<br>那部署 API 时不是整个 monorepo 都要部署？”</p></blockquote><p>答案：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">不是<br></code></pre></td></tr></table></figure><hr><h1 id="十四、要区分两种东西"><a href="#十四、要区分两种东西" class="headerlink" title="十四、要区分两种东西"></a>十四、要区分两种东西</h1><h2 id="1-代码级依赖"><a href="#1-代码级依赖" class="headerlink" title="1. 代码级依赖"></a>1. 代码级依赖</h2><p>例如：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs text">shared/<br>rag/<br>llm_core/<br></code></pre></td></tr></table></figure><p>这些本来就应该跟随 API。</p><p>因为：</p><p>API 运行需要它们。</p><hr><h2 id="2-服务级依赖"><a href="#2-服务级依赖" class="headerlink" title="2. 服务级依赖"></a>2. 服务级依赖</h2><p>例如：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs text">worker/<br>crawler/<br>embedding_service/<br></code></pre></td></tr></table></figure><p>这些不应该直接 import。</p><p>应该：</p><ul><li>HTTP</li><li>gRPC</li><li>MQ</li><li>Redis Queue</li></ul><p>通信。</p><hr><h1 id="十五、正确的-Monorepo-架构"><a href="#十五、正确的-Monorepo-架构" class="headerlink" title="十五、正确的 Monorepo 架构"></a>十五、正确的 Monorepo 架构</h1><p>推荐：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><code class="hljs text">my-platform/<br>├── apps/<br>│   ├── api<br>│   ├── worker<br>│   ├── crawler<br>│   └── mcp_server<br>│<br>├── libs/<br>│   ├── shared<br>│   ├── rag<br>│   ├── llm_core<br>│   └── ai_utils<br></code></pre></td></tr></table></figure><p>规则：</p><h2 id="apps"><a href="#apps" class="headerlink" title="apps"></a>apps</h2><p>独立服务。</p><p>不要互相 import。</p><h2 id="libs"><a href="#libs" class="headerlink" title="libs"></a>libs</h2><p>共享代码。</p><p>允许 workspace dependency。</p><hr><h1 id="十六、Docker-如何部署"><a href="#十六、Docker-如何部署" class="headerlink" title="十六、Docker 如何部署"></a>十六、Docker 如何部署</h1><p>例如：</p><p>API Dockerfile：</p><figure class="highlight dockerfile"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><code class="hljs dockerfile"><span class="hljs-keyword">FROM</span> python:<span class="hljs-number">3.12</span><br><br><span class="hljs-keyword">WORKDIR</span><span class="language-bash"> /app</span><br><br><span class="hljs-keyword">COPY</span><span class="language-bash"> . .</span><br><br><span class="hljs-keyword">RUN</span><span class="language-bash"> uv <span class="hljs-built_in">sync</span> --package api</span><br><br><span class="hljs-keyword">CMD</span><span class="language-bash"> [<span class="hljs-string">&quot;python&quot;</span>, <span class="hljs-string">&quot;-m&quot;</span>, <span class="hljs-string">&quot;api&quot;</span>]</span><br></code></pre></td></tr></table></figure><p>这里：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">uv <span class="hljs-built_in">sync</span> --package api<br></code></pre></td></tr></table></figure><p>只会安装：</p><ul><li>api</li><li>api 的 workspace 依赖</li></ul><p>不会安装整个 monorepo。</p><hr><h1 id="十七、推荐的一套现代-Python-技术栈"><a href="#十七、推荐的一套现代-Python-技术栈" class="headerlink" title="十七、推荐的一套现代 Python 技术栈"></a>十七、推荐的一套现代 Python 技术栈</h1><p>当前非常推荐：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><code class="hljs text">uv<br>+ workspace<br>+ pyproject.toml<br>+ ruff<br>+ pytest<br>+ PyCharm Workspace Mode<br></code></pre></td></tr></table></figure><p>这是目前 Python 新工程体系里，<br>最接近现代化大型工程的一种方案。</p><hr><h1 id="十八、总结"><a href="#十八、总结" class="headerlink" title="十八、总结"></a>十八、总结</h1><p>PyCharm Workspace Mode 的核心不是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">“多开几个目录”<br></code></pre></td></tr></table></figure><p>而是：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">Python 开始正式进入 Monorepo 时代<br></code></pre></td></tr></table></figure><p>而 uv Workspace：</p><p>则是这个生态的真正基础。</p><p>未来几年：</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">uv + workspace<br></code></pre></td></tr></table></figure><p>大概率会逐渐成为：</p><p>大型 Python 项目的标准组织方式。</p>]]>
    </content>
    <id>https://iyuluo.com/2026/05/06/PyCharm-Workspace-Mode-uv-Workspace-%E5%AE%9E%E6%88%98%E6%8C%87%E5%8D%97/</id>
    <link href="https://iyuluo.com/2026/05/06/PyCharm-Workspace-Mode-uv-Workspace-%E5%AE%9E%E6%88%98%E6%8C%87%E5%8D%97/"/>
    <published>2026-05-06T02:53:08.000Z</published>
    <summary>
      <![CDATA[<h2 id="Python-新一代-Monorepo-工程组织方式"><a href="#Python-新一代-Monorepo-工程组织方式" class="headerlink" title="Python 新一代 Monorepo 工程组织方式"></a>Python 新一代 Monorepo 工程组织方式</h2><hr>
<h1 id="一、前言"><a href="#一、前言" class="headerlink" title="一、前言"></a>一、前言</h1><p>最近打开 PyCharm，你可能会看到这样一个提示：</p>
<figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs text">Enable and try workspace mode Beta<br><br>A large project can be split into smaller parts<br>(like backend, API, and shared code),<br>making it easier to organize and maintain.<br></code></pre></td></tr></table></figure>

<p>很多人第一反应：</p>
<blockquote>
<p>“这是啥？”</p>
<p>“和普通 Project 有什么区别？”</p>
<p>“是不是类似 pnpm workspace？”</p>
<p>“Python 终于开始搞 monorepo 了吗？”</p>
</blockquote>
<p>实际上：</p>
<p>这个功能背后，代表的是 Python 工程组织方式的一次重大升级。</p>
<p>而它真正对应的生态，其实是：</p>
<figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs text">uv + pyproject.toml + workspace<br></code></pre></td></tr></table></figure>

<p>这篇文章会从：</p>
<ul>
<li>为什么需要 Workspace</li>
<li>什么是 Monorepo</li>
<li>uv Workspace 怎么用</li>
<li>PyCharm Workspace Mode 如何配合</li>
<li>实际部署怎么做</li>
<li>如何避免架构混乱</li>
</ul>
<p>完整讲清楚。</p>]]>
    </summary>
    <title>PyCharm Workspace Mode + uv Workspace 实战指南</title>
    <updated>2026-05-06T02:54:11.140Z</updated>
  </entry>
  <entry>
    <author>
      <name>yuluo</name>
    </author>
    <category term="GitHub" scheme="https://iyuluo.com/categories/GitHub/"/>
    <category term="GitHub Actions" scheme="https://iyuluo.com/tags/GitHub-Actions/"/>
    <content>
      <![CDATA[<p>最近踩了个挺典型的坑：<br>前一天还能正常构建，第二天终端里 <code>cargo --version</code> 直接变成 <code>command not found</code>。更离谱的是，<code>~/.zshrc</code> 里 PATH 配置明明还在。</p><p>当时第一反应是 shell 配置坏了，结果查下来不是。</p><span id="more"></span><h2 id="现象看起来像-PATH，实际上是“断链”"><a href="#现象看起来像-PATH，实际上是“断链”" class="headerlink" title="现象看起来像 PATH，实际上是“断链”"></a>现象看起来像 PATH，实际上是“断链”</h2><p>先看几个关键信息：</p><ul><li><code>PATH</code> 里有 <code>~/.cargo/bin</code></li><li><code>~/.cargo/bin/cargo</code> 存在</li><li>但 <code>cargo</code> 是个软链：<code>cargo -&gt; rustup</code></li><li>真正的 <code>~/.cargo/bin/rustup</code> 文件没了</li></ul><p>也就是说，不是找不到 <code>cargo</code>，是 <code>cargo</code> 指向的目标没了。</p><p>这种情况很容易误判成“环境变量没生效”，其实是工具链启动器被清理了。</p><h2 id="根因：self-hosted-runner-和开发环境共用-HOME，被-CI-的缓存清理逻辑误伤"><a href="#根因：self-hosted-runner-和开发环境共用-HOME，被-CI-的缓存清理逻辑误伤" class="headerlink" title="根因：self-hosted runner 和开发环境共用 HOME，被 CI 的缓存清理逻辑误伤"></a>根因：self-hosted runner 和开发环境共用 HOME，被 CI 的缓存清理逻辑误伤</h2><p>我项目是 GitHub Actions 跑在本机 self-hosted runner 上（同一个用户）。<br>Workflow 里用了 <code>swatinem/rust-cache@v2</code>，默认 <code>cache-bin: true</code>。</p><p>这件事在 GitHub-hosted runner 上通常没问题，因为机器是一次性的；<br>但在长期运行的 self-hosted 上，<code>~/.cargo/bin</code> 是你本机常驻环境的一部分，一旦 post-cleanup 动到它，就会出现这种“本机 cargo 消失”的事故。</p><p>一句话总结：<strong>CI 没删你代码，但删了你本机共享环境里的关键二进制</strong>。</p><h2 id="最小改动修复"><a href="#最小改动修复" class="headerlink" title="最小改动修复"></a>最小改动修复</h2><p>我最后没把缓存全关，而是只关了 bin 缓存：</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs yaml"><span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Cache</span> <span class="hljs-string">Rust</span> <span class="hljs-string">build</span><br>  <span class="hljs-attr">uses:</span> <span class="hljs-string">swatinem/rust-cache@v2</span><br>  <span class="hljs-attr">with:</span><br>    <span class="hljs-attr">workspaces:</span> <span class="hljs-string">&#x27;./src-tauri -&gt; target&#x27;</span><br>    <span class="hljs-attr">cache-bin:</span> <span class="hljs-string">&quot;false&quot;</span><br></code></pre></td></tr></table></figure><p>这行建议至少加在所有 self-hosted 的 Rust job 里（比如 macOS &#x2F; Linux）。<br>这样还能继续缓存 registry&#x2F;git&#x2F;target，构建速度保住，同时避开 <code>~/.cargo/bin</code> 被清理。</p><h2 id="如果你现在已经中招了，可以先这么做"><a href="#如果你现在已经中招了，可以先这么做" class="headerlink" title="如果你现在已经中招了，可以先这么做"></a>如果你现在已经中招了，可以先这么做</h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs bash">curl https://sh.rustup.rs -sSf | sh -s -- -y<br><span class="hljs-built_in">source</span> <span class="hljs-string">&quot;<span class="hljs-variable">$HOME</span>/.cargo/env&quot;</span><br>cargo --version<br>rustup show<br></code></pre></td></tr></table></figure><p>另外建议顺手检查一下：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs bash"><span class="hljs-built_in">ls</span> -l ~/.cargo/bin/cargo<br><span class="hljs-built_in">ls</span> -l ~/.cargo/bin/rustup<br></code></pre></td></tr></table></figure><p>如果 <code>cargo -&gt; rustup</code> 但 rustup 不存在，就是同类问题。</p><h2 id="这类问题的通用治理建议（不只-Rust）"><a href="#这类问题的通用治理建议（不只-Rust）" class="headerlink" title="这类问题的通用治理建议（不只 Rust）"></a>这类问题的通用治理建议（不只 Rust）</h2><p>这次是 Rust，换成 Node&#x2F;Python&#x2F;Go 也可能发生。核心原则就两条：</p><ol><li><p><strong>不要让 CI 和你的日常开发环境“共用生活空间”</strong><br>最好给 runner 单独用户（甚至单独机器&#x2F;容器）。</p></li><li><p><strong>缓存“构建产物”和“依赖仓库”，少碰“全局工具目录”</strong><br>例如 <code>~/.cargo/bin</code>、<code>~/.nvm</code>、<code>~/.pyenv</code> 这种位置，一旦被清理就是系统性故障。</p></li></ol><p>再补一个实用建议：<br>给 self-hosted runner 加个构建前自检脚本（比如检查 <code>cargo/node/python</code> 是否可用），早失败比跑完才发现要省很多时间。</p><h2 id="写在最后"><a href="#写在最后" class="headerlink" title="写在最后"></a>写在最后</h2><p>self-hosted 的优势是快、可控、省钱；<br>代价是你得把“本机”当“生产环境”来管理。</p><p>这次问题本身不复杂，但很有代表性：<br><strong>不是代码错了，是运行边界没隔离好。</strong></p><p>如果你也在用 GitHub Actions + self-hosted，建议现在就看一眼你的 workflow：<br>缓存到底是在加速构建，还是在悄悄改你的机器状态。</p>]]>
    </content>
    <id>https://iyuluo.com/2026/02/09/%E4%B8%80%E6%AC%A1%E3%80%8Ccargo-%E7%AA%81%E7%84%B6%E6%B6%88%E5%A4%B1%E3%80%8D%E4%B9%8B%E5%90%8E%EF%BC%9A%E8%81%8A%E8%81%8A-GitHub-Actions-%E6%9C%AC%E6%9C%BA-self-hosted-runner-%E7%9A%84%E6%AD%A3%E7%A1%AE%E6%89%93%E5%BC%80%E6%96%B9%E5%BC%8F/</id>
    <link href="https://iyuluo.com/2026/02/09/%E4%B8%80%E6%AC%A1%E3%80%8Ccargo-%E7%AA%81%E7%84%B6%E6%B6%88%E5%A4%B1%E3%80%8D%E4%B9%8B%E5%90%8E%EF%BC%9A%E8%81%8A%E8%81%8A-GitHub-Actions-%E6%9C%AC%E6%9C%BA-self-hosted-runner-%E7%9A%84%E6%AD%A3%E7%A1%AE%E6%89%93%E5%BC%80%E6%96%B9%E5%BC%8F/"/>
    <published>2026-02-09T14:40:36.000Z</published>
    <summary>
      <![CDATA[<p>最近踩了个挺典型的坑：<br>前一天还能正常构建，第二天终端里 <code>cargo --version</code> 直接变成 <code>command not found</code>。更离谱的是，<code>~/.zshrc</code> 里 PATH 配置明明还在。</p>
<p>当时第一反应是 shell 配置坏了，结果查下来不是。</p>]]>
    </summary>
    <title>一次「cargo 突然消失」之后：聊聊 GitHub Actions + 本机 self-hosted runner 的正确打开方式</title>
    <updated>2026-02-10T06:46:52.894Z</updated>
  </entry>
  <entry>
    <author>
      <name>yuluo</name>
    </author>
    <category term="Cloudflare" scheme="https://iyuluo.com/categories/Cloudflare/"/>
    <category term="Cloudflare" scheme="https://iyuluo.com/tags/Cloudflare/"/>
    <content>
      <![CDATA[<blockquote><p>本文记录了在实际项目中使用 Cloudflare R2 + Workers 构建软件下载分发体系时，从最初设计、踩坑、排错，到最终稳定方案的完整过程。内容包含 <strong>Cache Rules 与 Worker 缓存冲突的真实问题</strong>，适合直接作为生产级参考。</p></blockquote><hr><h2 id="一、背景与目标"><a href="#一、背景与目标" class="headerlink" title="一、背景与目标"></a>一、背景与目标</h2><p>在为 <strong>AirTools</strong> 构建桌面端分发体系时，我希望实现：</p><ul><li>安装包（<code>.exe</code> &#x2F; <code>.dmg</code> 等）全球 CDN 强缓存</li><li>版本元信息（<code>latest.json</code>）实时更新、不被缓存</li><li>不自建服务器，尽量使用 Cloudflare 原生能力</li><li>缓存行为稳定、可预测，不出现随机 500</li></ul><p>最终技术选型为 <strong>Cloudflare R2 + Cloudflare Workers</strong>。</p><hr><span id="more"></span><h2 id="二、初始架构设计"><a href="#二、初始架构设计" class="headerlink" title="二、初始架构设计"></a>二、初始架构设计</h2><p>目标结构如下：</p><figure class="highlight nginx"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><code class="hljs nginx"><span class="hljs-attribute">Client</span><br>  ↓<br>Cloudflare Edge<br>  ├── latest.json → Worker → R2（不缓存）<br>  └── <span class="hljs-regexp">*.exe</span>/<span class="hljs-regexp">*.dmg</span> → Worker → R2 → Edge Cache（强缓存）<br></code></pre></td></tr></table></figure><p>逻辑上并不复杂，但在实际落地过程中，问题主要集中在 <strong>缓存控制细节</strong> 上。</p><hr><h2 id="三、最早遇到的异常现象"><a href="#三、最早遇到的异常现象" class="headerlink" title="三、最早遇到的异常现象"></a>三、最早遇到的异常现象</h2><h3 id="1️⃣-缓存始终不生效"><a href="#1️⃣-缓存始终不生效" class="headerlink" title="1️⃣ 缓存始终不生效"></a>1️⃣ 缓存始终不生效</h3><p>即便返回了：</p><figure class="highlight http"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs http"><span class="hljs-attribute">Cache-Control</span><span class="hljs-punctuation">: </span>public, max-age=2592000, immutable<br></code></pre></td></tr></table></figure><p>响应中仍反复出现：</p><figure class="highlight http"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs http"><span class="hljs-attribute">cf-cache-status</span><span class="hljs-punctuation">: </span>DYNAMIC<br></code></pre></td></tr></table></figure><hr><h3 id="2️⃣-同一个-URL，返回结果不一致"><a href="#2️⃣-同一个-URL，返回结果不一致" class="headerlink" title="2️⃣ 同一个 URL，返回结果不一致"></a>2️⃣ 同一个 URL，返回结果不一致</h3><p>对同一下载地址多次执行：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">curl -I https://dl.airtools.app/v0.1.19/AirTools_0.1.19_x64-setup.exe<br></code></pre></td></tr></table></figure><p>会出现：</p><ul><li>有时 <code>200</code></li><li>有时 <code>500</code></li><li>缓存状态随机变化</li></ul><p>这在下载分发场景中是非常危险的信号。</p><hr><h3 id="3️⃣-Cloudflare-Dashboard-偶发-502"><a href="#3️⃣-Cloudflare-Dashboard-偶发-502" class="headerlink" title="3️⃣ Cloudflare Dashboard 偶发 502"></a>3️⃣ Cloudflare Dashboard 偶发 502</h3><p>在 Workers &#x2F; Builds 页面中，Cloudflare 控制台本身也开始报：</p><figure class="highlight apache"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs apache"><span class="hljs-attribute">API</span> Request Failed (<span class="hljs-number">502</span>)<br></code></pre></td></tr></table></figure><p>这通常意味着某个 Worker 在边缘节点频繁返回 5xx。</p><hr><h2 id="四、Cache-Rules-带来的隐藏问题（关键）"><a href="#四、Cache-Rules-带来的隐藏问题（关键）" class="headerlink" title="四、Cache Rules 带来的隐藏问题（关键）"></a>四、Cache Rules 带来的隐藏问题（关键）</h2><p>在排查过程中，一个非常容易被忽略的问题逐渐浮现：<strong>Cache Rules 与 Worker 的缓存逻辑发生了冲突</strong>。</p><h3 id="1️⃣-Cache-Rules-的“误导性描述”"><a href="#1️⃣-Cache-Rules-的“误导性描述”" class="headerlink" title="1️⃣ Cache Rules 的“误导性描述”"></a>1️⃣ Cache Rules 的“误导性描述”</h3><p>Cloudflare Cache Rules 中常见的选项包括：</p><ul><li>缓存资格（Eligible for cache）</li><li>绕过缓存（Bypass cache）</li></ul><p>这些规则的描述很容易让人误以为：</p><blockquote><p>“只要标记为符合缓存条件，Cloudflare 就一定会缓存”</p></blockquote><p><strong>这是不准确的。</strong></p><p>Cache Rules 只是在「是否允许缓存」这一层做判断，<strong>真正是否缓存，仍然取决于：</strong></p><ul><li>Worker 中是否使用 <code>cacheEverything</code></li><li>返回的 HTTP 状态码是否为 200</li><li>实际响应头中的 <code>Cache-Control</code></li></ul><hr><h3 id="2️⃣-Cache-Rules-覆盖-Worker-Header"><a href="#2️⃣-Cache-Rules-覆盖-Worker-Header" class="headerlink" title="2️⃣ Cache Rules 覆盖 Worker Header"></a>2️⃣ Cache Rules 覆盖 Worker Header</h3><p>在早期配置中，我曾在 Cache Rules 中尝试：</p><ul><li>针对 <code>.exe / .dmg</code> 设置缓存</li><li>或统一修改 <code>Cache-Control</code></li></ul><p>结果是：</p><ul><li>Worker 返回的 <code>Cache-Control</code> 被规则覆盖</li><li>错误响应（500）也被错误地标记为可缓存候选</li><li>实际缓存行为变得不可预测</li></ul><p><strong>结论：当请求经过 Worker 时，Cache Rules 很容易成为“干扰项”。</strong></p><hr><h3 id="3️⃣-最终选择：让-Worker-成为唯一缓存决策点"><a href="#3️⃣-最终选择：让-Worker-成为唯一缓存决策点" class="headerlink" title="3️⃣ 最终选择：让 Worker 成为唯一缓存决策点"></a>3️⃣ 最终选择：让 Worker 成为唯一缓存决策点</h3><p>在最终方案中：</p><ul><li><strong>不再依赖 Cache Rules 控制下载缓存</strong></li><li>Cache Rules 保持最小化甚至删除</li><li>所有缓存判断统一在 Worker 中完成</li></ul><p>这样可以保证：</p><blockquote><p>缓存逻辑是可读的、可调试的、可预测的。</p></blockquote><hr><h2 id="五、真正的根因总结"><a href="#五、真正的根因总结" class="headerlink" title="五、真正的根因总结"></a>五、真正的根因总结</h2><p>综合排查后，问题根因集中在三点：</p><ol><li>Worker 未处理回源失败，导致 500 被返回</li><li>500 响应参与缓存资格判断，污染后续请求</li><li>Cache Rules 与 Worker 缓存逻辑发生冲突</li></ol><hr><h2 id="六、最终稳定方案设计"><a href="#六、最终稳定方案设计" class="headerlink" title="六、最终稳定方案设计"></a>六、最终稳定方案设计</h2><p>核心原则只有一句话：</p><blockquote><p><strong>只缓存成功的安装包，失败请求永不进入缓存路径</strong></p></blockquote><h3 id="缓存策略表"><a href="#缓存策略表" class="headerlink" title="缓存策略表"></a>缓存策略表</h3><table><thead><tr><th>类型</th><th>是否缓存</th><th>说明</th></tr></thead><tbody><tr><td><code>.exe / .dmg / .zip</code></td><td>✅</td><td>强缓存 30 天</td></tr><tr><td><code>latest.json</code></td><td>❌</td><td>永远回源</td></tr><tr><td>500 &#x2F; 502</td><td>❌</td><td>禁止缓存</td></tr></tbody></table><hr><h2 id="七、最终-Worker-实现（生产可用）"><a href="#七、最终-Worker-实现（生产可用）" class="headerlink" title="七、最终 Worker 实现（生产可用）"></a>七、最终 Worker 实现（生产可用）</h2><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br></pre></td><td class="code"><pre><code class="hljs js"><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> &#123;<br>  <span class="hljs-keyword">async</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-params">request, env, ctx</span>) &#123;<br>    <span class="hljs-keyword">const</span> url = <span class="hljs-keyword">new</span> <span class="hljs-title function_">URL</span>(request.<span class="hljs-property">url</span>)<br>    <span class="hljs-keyword">const</span> path = url.<span class="hljs-property">pathname</span><br><br>    <span class="hljs-keyword">const</span> <span class="hljs-variable constant_">ORIGIN</span> = <span class="hljs-string">&#x27;https://dl.airtools.app&#x27;</span><br>    <span class="hljs-keyword">const</span> originUrl = <span class="hljs-variable constant_">ORIGIN</span> + path + url.<span class="hljs-property">search</span><br><br>    <span class="hljs-keyword">if</span> (path.<span class="hljs-title function_">endsWith</span>(<span class="hljs-string">&#x27;/latest.json&#x27;</span>)) &#123;<br>      <span class="hljs-keyword">let</span> res<br>      <span class="hljs-keyword">try</span> &#123;<br>        res = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(originUrl, &#123; <span class="hljs-attr">cf</span>: &#123; <span class="hljs-attr">cacheEverything</span>: <span class="hljs-literal">false</span> &#125; &#125;)<br>      &#125; <span class="hljs-keyword">catch</span> &#123;<br>        <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(<span class="hljs-string">&#x27;Upstream error&#x27;</span>, &#123; <span class="hljs-attr">status</span>: <span class="hljs-number">502</span> &#125;)<br>      &#125;<br><br>      <span class="hljs-keyword">const</span> headers = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Headers</span>(res.<span class="hljs-property">headers</span>)<br>      headers.<span class="hljs-title function_">set</span>(<span class="hljs-string">&#x27;Cache-Control&#x27;</span>, <span class="hljs-string">&#x27;no-store, no-cache, must-revalidate&#x27;</span>)<br><br>      <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(res.<span class="hljs-property">body</span>, &#123; <span class="hljs-attr">status</span>: res.<span class="hljs-property">status</span>, headers &#125;)<br>    &#125;<br><br>    <span class="hljs-keyword">const</span> <span class="hljs-variable constant_">CACHEABLE_EXT</span> = [<br>      <span class="hljs-string">&#x27;.dmg&#x27;</span>, <span class="hljs-string">&#x27;.exe&#x27;</span>, <span class="hljs-string">&#x27;.msi&#x27;</span>, <span class="hljs-string">&#x27;.AppImage&#x27;</span>,<br>      <span class="hljs-string">&#x27;.deb&#x27;</span>, <span class="hljs-string">&#x27;.rpm&#x27;</span>, <span class="hljs-string">&#x27;.zip&#x27;</span>, <span class="hljs-string">&#x27;.tar.gz&#x27;</span>,<br>      <span class="hljs-string">&#x27;.sha256&#x27;</span>, <span class="hljs-string">&#x27;.sig&#x27;</span><br>    ]<br><br>    <span class="hljs-keyword">const</span> isCacheable = <span class="hljs-variable constant_">CACHEABLE_EXT</span>.<span class="hljs-title function_">some</span>(<span class="hljs-function"><span class="hljs-params">ext</span> =&gt;</span><br>      path.<span class="hljs-title function_">toLowerCase</span>().<span class="hljs-title function_">endsWith</span>(ext)<br>    )<br><br>    <span class="hljs-keyword">let</span> res<br>    <span class="hljs-keyword">try</span> &#123;<br>      res = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(originUrl, &#123;<br>        <span class="hljs-attr">cf</span>: &#123;<br>          <span class="hljs-attr">cacheEverything</span>: isCacheable,<br>          <span class="hljs-attr">cacheTtl</span>: isCacheable ? <span class="hljs-number">60</span> * <span class="hljs-number">60</span> * <span class="hljs-number">24</span> * <span class="hljs-number">30</span> : <span class="hljs-literal">undefined</span><br>        &#125;<br>      &#125;)<br>    &#125; <span class="hljs-keyword">catch</span> &#123;<br>      <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(<span class="hljs-string">&#x27;Upstream fetch failed&#x27;</span>, &#123;<br>        <span class="hljs-attr">status</span>: <span class="hljs-number">502</span>,<br>        <span class="hljs-attr">headers</span>: &#123; <span class="hljs-string">&#x27;Cache-Control&#x27;</span>: <span class="hljs-string">&#x27;no-store&#x27;</span> &#125;<br>      &#125;)<br>    &#125;<br><br>    <span class="hljs-keyword">const</span> headers = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Headers</span>(res.<span class="hljs-property">headers</span>)<br><br>    <span class="hljs-keyword">if</span> (isCacheable &amp;&amp; res.<span class="hljs-property">status</span> === <span class="hljs-number">200</span>) &#123;<br>      headers.<span class="hljs-title function_">set</span>(<span class="hljs-string">&#x27;Cache-Control&#x27;</span>, <span class="hljs-string">&#x27;public, max-age=2592000, immutable&#x27;</span>)<br>    &#125; <span class="hljs-keyword">else</span> &#123;<br>      headers.<span class="hljs-title function_">set</span>(<span class="hljs-string">&#x27;Cache-Control&#x27;</span>, <span class="hljs-string">&#x27;no-store, no-cache&#x27;</span>)<br>    &#125;<br><br>    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(res.<span class="hljs-property">body</span>, &#123; <span class="hljs-attr">status</span>: res.<span class="hljs-property">status</span>, headers &#125;)<br>  &#125;<br>&#125;<br></code></pre></td></tr></table></figure><hr><h2 id="八、最终验证结果"><a href="#八、最终验证结果" class="headerlink" title="八、最终验证结果"></a>八、最终验证结果</h2><ul><li><p><code>latest.json</code></p><ul><li><code>cf-cache-status: DYNAMIC</code></li><li>实时回源</li></ul></li><li><p>安装包下载</p><ul><li><code>cf-cache-status: HIT</code></li><li><code>age</code> 持续增长</li><li>支持断点续传</li></ul></li></ul><hr><h2 id="九、经验总结"><a href="#九、经验总结" class="headerlink" title="九、经验总结"></a>九、经验总结</h2><ol><li>不要缓存失败响应</li><li>Worker 必须处理 <code>fetch</code> 异常</li><li>Cache Rules 不适合与复杂 Worker 缓存逻辑混用</li><li>一个域名只承担一种角色（Worker 或 R2）</li><li>判断缓存是否生效，以 <code>cf-cache-status</code> 为准</li></ol><hr><h2 id="十、结语"><a href="#十、结语" class="headerlink" title="十、结语"></a>十、结语</h2><p>Cloudflare R2 + Workers 非常适合用于软件下载分发，但<strong>稳定性来自于细节，而不是默认配置</strong>。</p><p>只要把缓存决策收敛到 Worker 内部，并明确区分“可缓存内容”和“实时内容”，这套架构可以长期稳定运行，且维护成本极低。</p>]]>
    </content>
    <id>https://iyuluo.com/2026/01/29/%E4%BD%BF%E7%94%A8-Cloudflare-R2-Workers-%E6%9E%84%E5%BB%BA%E7%A8%B3%E5%AE%9A%E3%80%81%E9%AB%98%E6%80%A7%E8%83%BD%E7%9A%84%E8%BD%AF%E4%BB%B6%E4%B8%8B%E8%BD%BD%E5%88%86%E5%8F%91/</id>
    <link href="https://iyuluo.com/2026/01/29/%E4%BD%BF%E7%94%A8-Cloudflare-R2-Workers-%E6%9E%84%E5%BB%BA%E7%A8%B3%E5%AE%9A%E3%80%81%E9%AB%98%E6%80%A7%E8%83%BD%E7%9A%84%E8%BD%AF%E4%BB%B6%E4%B8%8B%E8%BD%BD%E5%88%86%E5%8F%91/"/>
    <published>2026-01-29T03:05:14.000Z</published>
    <summary>
      <![CDATA[<blockquote>
<p>本文记录了在实际项目中使用 Cloudflare R2 + Workers 构建软件下载分发体系时，从最初设计、踩坑、排错，到最终稳定方案的完整过程。内容包含 <strong>Cache Rules 与 Worker 缓存冲突的真实问题</strong>，适合直接作为生产级参考。</p>
</blockquote>
<hr>
<h2 id="一、背景与目标"><a href="#一、背景与目标" class="headerlink" title="一、背景与目标"></a>一、背景与目标</h2><p>在为 <strong>AirTools</strong> 构建桌面端分发体系时，我希望实现：</p>
<ul>
<li>安装包（<code>.exe</code> &#x2F; <code>.dmg</code> 等）全球 CDN 强缓存</li>
<li>版本元信息（<code>latest.json</code>）实时更新、不被缓存</li>
<li>不自建服务器，尽量使用 Cloudflare 原生能力</li>
<li>缓存行为稳定、可预测，不出现随机 500</li>
</ul>
<p>最终技术选型为 <strong>Cloudflare R2 + Cloudflare Workers</strong>。</p>
<hr>]]>
    </summary>
    <title>使用 Cloudflare R2 + Workers 构建稳定、高性能的软件下载分发</title>
    <updated>2026-01-29T03:06:35.275Z</updated>
  </entry>
  <entry>
    <author>
      <name>yuluo</name>
    </author>
    <category term="Front-Dev" scheme="https://iyuluo.com/categories/Front-Dev/"/>
    <category term="oklch," scheme="https://iyuluo.com/tags/oklch/"/>
    <content>
      <![CDATA[<p>在做自己的项目时，我逐渐发现一个问题：<br>颜色越写越乱、越调越脏，很多看似简单的调色逻辑在实际效果上完全不符合预期。</p><p>比如：<br>    •明明只是想调亮一点，但 RGB&#x2F;HSL 调完后颜色不是变灰就是变艳；<br>    •做渐变时颜色中途莫名其妙发暗；<br>    •图表配色在亮色主题和暗色主题下反差不一致；<br>    •在宽色域（如 P3）设备上，颜色表现和预期差得更远。</p><p>这些都指向同一个底层原因：<br>RGB（甚至 HSL&#x2F;HSV）并不是一个符合人眼感知的色彩空间。</p><p>于是我开始把新的颜色计算逻辑逐步迁移到 OKLCH —— 一个感知均匀、现代化、已被浏览器原生支持的色彩空间。<br>这篇文章来分享我为什么做这个选择，也希望给你在项目色彩处理上的参考。</p><span id="more"></span><h2 id="一、RGB-HSL-在实际开发中有哪些明显痛点？"><a href="#一、RGB-HSL-在实际开发中有哪些明显痛点？" class="headerlink" title="一、RGB &#x2F; HSL 在实际开发中有哪些明显痛点？"></a>一、RGB &#x2F; HSL 在实际开发中有哪些明显痛点？</h2><ol><li>RGB 不是为调色设计的</li></ol><p>RGB 是硬件色彩空间，核心目的是给屏幕发光，不是为了“符合人眼感知”。</p><p>所以你在 RGB 里调亮度，实际上是同时改三个通道，结果往往是“变亮但不自然”。</p><ol start="2"><li>HSL&#x2F;HSV 看似更直观，但本质还是数学假设</li></ol><p>HSL 的亮度是一个简单公式：<br>L &#x3D; (max + min) &#x2F; 2<br>这与人眼的亮暗感受毫无关系。</p><p>所以它经常出现：<br>    •提亮颜色 → 变灰<br>    •调饱和度 → 色相漂移<br>    •淡色系 → 明显“脏”</p><p>前端工程师、设计师乃至最近的 AI 图像后处理都会遇到这类问题。</p><p>⸻</p><h2 id="二、为什么-OKLCH-是更适合现代开发的替代方案？"><a href="#二、为什么-OKLCH-是更适合现代开发的替代方案？" class="headerlink" title="二、为什么 OKLCH 是更适合现代开发的替代方案？"></a>二、为什么 OKLCH 是更适合现代开发的替代方案？</h2><p>OKLCH 属于 OKLab 色彩模型的极简表示形式，结构非常简单：<br>    •O：感知亮度<br>    •K：色度（饱和度）<br>    •LCH：和 CIELAB 类似的极坐标表示</p><p>它的关键价值可以用一句话总结：</p><p>OKLCH 更接近人眼真实的颜色感知，是为现代宽色域、高精度显示时代而设计的。</p><p>⸻</p><h2 id="三、实际使用中的-5-个核心优势"><a href="#三、实际使用中的-5-个核心优势" class="headerlink" title="三、实际使用中的 5 个核心优势"></a>三、实际使用中的 5 个核心优势</h2><ol><li>调色结果更“像人眼认知”</li></ol><p>在 OKLCH：<br>    •调亮 → 真的变亮，不变灰<br>    •调饱和度 → 不会突然偏色<br>    •改色相 → 渐变更自然、平滑</p><p>只要你写过配色算法，就会立刻感觉到这点的巨大差异。</p><p>⸻</p><ol start="2"><li>渐变、插值更自然</li></ol><p>RGB 做渐变常常中间变脏、发黑；<br>HSL 做渐变色相容易发生折返、跳跃。</p><p>OKLCH 解决了这些：<br>    •渐变更线性更均匀<br>    •不会突然暗掉<br>    •更接近真实光学过渡</p><p>这点在图表、数据可视化、背景渐变中非常实用。</p><p>⸻</p><ol start="3"><li>在宽色域（P3 &#x2F; Rec.2020）下表现稳定</li></ol><p>很多新设备（Mac、iPhone、现代安卓机）已经进入了 P3 时代，RGB&#x2F;HSL 的效果在宽色域中更不稳定。</p><p>OKLCH 是为这类显示技术设计的：<br>    •高饱和区域不塌陷<br>    •色彩更加一致<br>    •HDR 适配更稳定</p><p>对未来兼容性非常友好。</p><p>⸻</p><ol start="4"><li>CSS 已经原生支持，迁移成本极低</li></ol><p>现在浏览器直接支持：</p><p>color: oklch(75% 0.15 240);</p><p>不需要 polyfill，也不需要额外库。<br>迁移甚至可以按需局部替换，成本几乎为零。</p><p>⸻</p><ol start="5"><li>更适合自动化配色、可视化、主题系统</li></ol><p>如果你的项目涉及：<br>    •自动生成颜色<br>    •数据可视化（色板、热力图）<br>    •深浅主题适配<br>    •色彩算法<br>    •图像后处理</p><p>OKLCH 让这些变得更简单且结果更稳定。</p><p>举个例子：<br>颜色对比度（contrast）可以直接依赖 OKLab 的亮度计算，比 WCAG 的相对亮度公式更准确，也更符合人眼实际观感。</p><p>⸻</p><h2 id="四、我在自己的项目中是如何使用-OKLCH-的？"><a href="#四、我在自己的项目中是如何使用-OKLCH-的？" class="headerlink" title="四、我在自己的项目中是如何使用 OKLCH 的？"></a>四、我在自己的项目中是如何使用 OKLCH 的？</h2><p>我的做法是 从局部渐进迁移：<br>    1.先从需要自动计算的颜色入手（错误提示色、图表颜色、背景色等）<br>    2.再把主题色、渐变色统一写成 OKLCH<br>    3.最后再做一些视觉微调，确保在亮&#x2F;暗主题都呈现一致对比度</p><p>这种方式不需要一次性重构，几乎没有成本压力。</p><p>⸻</p><h2 id="五、未来为什么需要-OKLCH？"><a href="#五、未来为什么需要-OKLCH？" class="headerlink" title="五、未来为什么需要 OKLCH？"></a>五、未来为什么需要 OKLCH？</h2><p>一句话：</p><p>未来的显示设备越来越宽色域、越来越精细，RGB&#x2F;HSL 已经无法满足现代视觉需求。</p><p>随着 P3 &#x2F; HDR 成为主流，颜色系统的基础越靠近视觉生理模型，效果越稳定。<br>OKLCH 现在能做的事，RGB&#x2F;HSL 越来越做不到。</p><p>⸻</p><h2 id="六、总结：为什么值得迁移？"><a href="#六、总结：为什么值得迁移？" class="headerlink" title="六、总结：为什么值得迁移？"></a>六、总结：为什么值得迁移？</h2><p>有以下几点<br>    •提高颜色质量的成本极低<br>    •代码更清晰，更接近“亮度&#x2F;饱和度&#x2F;色相”的真实含义<br>    •渐变、配色、可视化效果更自然<br>    •兼容性好、未来可扩展<br>    •对项目整体视觉质量有巨大提升</p><p>对我来说，迁移到 OKLCH 是一种 小投入、高收益 的技术决策。</p>]]>
    </content>
    <id>https://iyuluo.com/2025/11/23/%E4%B8%BA%E4%BB%80%E4%B9%88%E6%88%91%E5%BC%80%E5%A7%8B%E4%BB%8E-RGB-%E8%BD%AC%E5%90%91-OKLCH-%E2%80%94%E2%80%94-%E9%A2%9C%E8%89%B2%E5%A4%84%E7%90%86%E7%9A%84%E6%96%B0%E9%98%B6%E6%AE%B5/</id>
    <link href="https://iyuluo.com/2025/11/23/%E4%B8%BA%E4%BB%80%E4%B9%88%E6%88%91%E5%BC%80%E5%A7%8B%E4%BB%8E-RGB-%E8%BD%AC%E5%90%91-OKLCH-%E2%80%94%E2%80%94-%E9%A2%9C%E8%89%B2%E5%A4%84%E7%90%86%E7%9A%84%E6%96%B0%E9%98%B6%E6%AE%B5/"/>
    <published>2025-11-23T08:25:37.000Z</published>
    <summary>
      <![CDATA[<p>在做自己的项目时，我逐渐发现一个问题：<br>颜色越写越乱、越调越脏，很多看似简单的调色逻辑在实际效果上完全不符合预期。</p>
<p>比如：<br>    •	明明只是想调亮一点，但 RGB&#x2F;HSL 调完后颜色不是变灰就是变艳；<br>    •	做渐变时颜色中途莫名其妙发暗；<br>    •	图表配色在亮色主题和暗色主题下反差不一致；<br>    •	在宽色域（如 P3）设备上，颜色表现和预期差得更远。</p>
<p>这些都指向同一个底层原因：<br>RGB（甚至 HSL&#x2F;HSV）并不是一个符合人眼感知的色彩空间。</p>
<p>于是我开始把新的颜色计算逻辑逐步迁移到 OKLCH —— 一个感知均匀、现代化、已被浏览器原生支持的色彩空间。<br>这篇文章来分享我为什么做这个选择，也希望给你在项目色彩处理上的参考。</p>]]>
    </summary>
    <title>为什么我开始从 RGB 转向 OKLCH —— 颜色处理的新阶段</title>
    <updated>2025-11-23T08:38:34.209Z</updated>
  </entry>
  <entry>
    <author>
      <name>yuluo</name>
    </author>
    <category term="Photos" scheme="https://iyuluo.com/categories/Photos/"/>
    <category term="exiftool" scheme="https://iyuluo.com/tags/exiftool/"/>
    <content>
      <![CDATA[<p>在日常摄影中，我们常常会遇到这样一个问题：使用相机（如 Nikon Z30 等非 GPS 相机）拍摄的照片并没有记录位置信息。如果你希望在后期整理或分享时，让照片具备地理信息，就需要借助专业工具来补充或批量写入 GPS 数据。</p><p>本文将介绍如何使用强大的命令行工具 ExifTool 来解决这个问题，包括：<br>    1.为照片添加 GPS 信息；<br>    2.将一张照片的 GPS 信息复制到另一张或多张照片中；<br>    3.检查和验证修改结果。</p><span id="more"></span><p>什么是 ExifTool？</p><p>ExifTool 是一款非常强大的命令行工具，可以读取、写入和编辑图片、音视频等文件的元数据（Metadata）。它支持几乎所有的照片格式，包括 JPEG、PNG，以及各大相机品牌的 RAW 格式（NEF、CR2、ARW 等）。</p><p>官网地址：<a href="https://exiftool.org/">https://exiftool.org/</a></p><p>⸻</p><p>用 ExifTool 安全地把一张照片的 GPS 信息复制到其它照片（NEF&#x2F;RAW）——完整实操指南</p><p>参考：ExifTool 官方文档 <a href="https://exiftool.org/">https://exiftool.org/</a><br>本文将结论先行：ExifTool 支持直接往 RAW 文件（包括 NEF）写 GPS 信息，并提供默认的 _original 文件自动备份。</p><p>⸻</p><p>📝 概览（结论先行）<br>    •ExifTool 能够直接写入 RAW（NEF、CR2、ARW 等），GPS 信息可以和 EXIF、XMP 一起保存。<br>    •macOS 上推荐用 Homebrew 安装，也可从 exiftool.org 下载 .pkg。<br>    •操作建议：先备份 → 先验证 → 小规模试运行 → 再批量处理。</p><p>⸻</p><p>1）准备工作（安装 + 备份）</p><p>安装 ExifTool</p><p>推荐 Homebrew 安装：</p><p>brew install exiftool</p><p>或直接下载 Mac 安装包：<a href="https://exiftool.org/">https://exiftool.org/</a></p><p>验证安装：</p><p>exiftool -ver</p><p>备份</p><p>虽然 ExifTool 默认会保留 _original 文件（参考官方 Backups 说明），但强烈建议在大规模操作前完整复制目录。</p><p>⸻</p><p>2）读取并验证源文件 GPS 标签</p><p>在写入前先确认源文件 GPS 信息存在哪些 group：</p><h1 id="显示所有-GPS-相关字段，含组名"><a href="#显示所有-GPS-相关字段，含组名" class="headerlink" title="显示所有 GPS 相关字段，含组名"></a>显示所有 GPS 相关字段，含组名</h1><p>exiftool -a -G1 -s -gps* source_with_gps.nef</p><h1 id="只看-EXIF-GPS"><a href="#只看-EXIF-GPS" class="headerlink" title="只看 EXIF GPS"></a>只看 EXIF GPS</h1><p>exiftool -gps:all source_with_gps.nef</p><pre><code class="hljs">•-a：显示重复标签•-G1：显示标签组（EXIF、XMP 等）•-s：显示原始标签名</code></pre><p>（参见官方 Tag Groups 文档）</p><p>⸻</p><p>3）单张复制 GPS 信息</p><p>把源文件的 GPS 信息复制到目标文件：</p><p>exiftool -tagsFromFile source_with_gps.nef -gps:all target_without_gps.nef</p><pre><code class="hljs">•默认会生成 target_without_gps.nef_original（安全备份）。</code></pre><p>如果你已确认无误且不需要 _original：</p><p>exiftool -P -overwrite_original_in_place <br>  -tagsFromFile source_with_gps.nef -gps:all target_without_gps.nef</p><pre><code class="hljs">•-P：保留文件时间戳•-overwrite_original_in_place：覆盖原文件，并尽量保持文件系统属性（官方 In-place Editing）</code></pre><p>⸻</p><p>4）批量操作：一张模板 → 整个目录</p><p>假设 source.jpg 含 GPS，要把其 GPS 写入当前目录下所有 NEF：</p><p>cd &#x2F;path&#x2F;to&#x2F;folder<br>exiftool -tagsFromFile ..&#x2F;source.jpg -gps:all -ext NEF -r .</p><p>解释：<br>    •-ext NEF：只处理 NEF 文件<br>    •-r .：递归处理子目录<br>    •避免 *.*，否则可能匹配 _original 或 .DS_Store 文件（官方 FAQ 有提醒）</p><p>覆盖写入（危险操作，需先备份）：</p><p>exiftool -P -overwrite_original_in_place <br>  -tagsFromFile ..&#x2F;source.jpg -gps:all -ext NEF -r .</p><p>⸻</p><p>5）从 XMP Sidecar 导入 GPS</p><p>如果 NX Studio 把 GPS 写在 .xmp 文件：</p><p>exiftool -tagsFromFile image.nef.xmp -gps:all image.nef</p><p>ExifTool 会把 .xmp 里的 GPS 信息同步到 NEF 文件（参考 XMP Tag Handling）。</p><p>⸻</p><p>6）按 GPX 轨迹打点</p><p>若有 GPX 轨迹文件，可根据照片时间自动匹配 GPS：</p><p>exiftool -geotag track.gpx *.NEF</p><p>支持时区偏移、插值（详见官方 Geotagging 文档）。</p><p>⸻</p><p>7）手动写入经纬度</p><p>如果你手动知道坐标：</p><p>exiftool -GPSLatitude&#x3D;39.9042 -GPSLatitudeRef&#x3D;N <br>  -GPSLongitude&#x3D;116.4074 -GPSLongitudeRef&#x3D;E photo.nef</p><p>使用 -n 可避免格式化，直接写入数值。</p><p>⸻</p><p>8）验证与回滚</p><p>验证写入结果</p><p>exiftool -a -G1 -s -gps* target.nef</p><p>确认 GPSLatitude、GPSLongitude、GPSDateStamp 等标签已写入。</p><p>回滚方法<br>    •如果有 _original 文件：手动还原或使用：</p><p>exiftool -restore_original target.nef</p><pre><code class="hljs">•若已用 -overwrite_original_in_place：只能依赖事前备份。</code></pre><p>⸻</p><p>9）常见坑 &amp; 最佳实践</p><p>✅ 先备份，再操作<br>✅ 先测试 2–3 张，再批量运行<br>✅ 避免 *.*，用 -ext 精确指定文件类型<br>✅ 注意不同软件读取的 group（EXIF vs XMP）<br>✅ 保留 _original 是自动安全网</p><p>⸻</p><p>10）批量脚本示例（macOS &#x2F; bash）</p><p>保存为 copy_gps_from_template.sh，然后：</p><p>chmod +x copy_gps_from_template.sh<br>.&#x2F;copy_gps_from_template.sh source_with_gps.jpg &#x2F;path&#x2F;to&#x2F;target_dir</p><p>脚本内容：</p><p>#!&#x2F;usr&#x2F;bin&#x2F;env bash<br>set -euo pipefail</p><p>SOURCE&#x3D;”$1”         # 模板文件（有 GPS 的图片）<br>TARGET_DIR&#x3D;”$2”     # 目标目录<br>EXT&#x3D;”NEF”           # 要处理的扩展名</p><p>if [ -z “$SOURCE” ] || [ -z “$TARGET_DIR” ]; then<br>  echo “Usage: $0 <source-with-gps> <target-dir>“<br>  exit 1<br>fi</p><p>echo “Dry-run: files that WOULD be processed:”<br>find “$TARGET_DIR” -type f -iname “*.${EXT}” -print | sed -n ‘1,50p’<br>echo “—-“<br>echo “To actually run, edit script and uncomment exiftool command.”</p><h1 id="实际运行（保留-original）"><a href="#实际运行（保留-original）" class="headerlink" title="实际运行（保留 _original）"></a>实际运行（保留 _original）</h1><h1 id="exiftool-tagsFromFile-“-SOURCE”-gps-all-ext-“-EXT”-r-“-TARGET-DIR”"><a href="#exiftool-tagsFromFile-“-SOURCE”-gps-all-ext-“-EXT”-r-“-TARGET-DIR”" class="headerlink" title="exiftool -tagsFromFile “$SOURCE” -gps:all -ext “$EXT” -r “$TARGET_DIR”"></a>exiftool -tagsFromFile “$SOURCE” -gps:all -ext “$EXT” -r “$TARGET_DIR”</h1><h1 id="覆盖原文件（危险）"><a href="#覆盖原文件（危险）" class="headerlink" title="覆盖原文件（危险）"></a>覆盖原文件（危险）</h1><h1 id="OVERWRITE-FLAGS-”-P-overwrite-original-in-place”"><a href="#OVERWRITE-FLAGS-”-P-overwrite-original-in-place”" class="headerlink" title="OVERWRITE_FLAGS&#x3D;”-P -overwrite_original_in_place”"></a>OVERWRITE_FLAGS&#x3D;”-P -overwrite_original_in_place”</h1><h1 id="exiftool-OVERWRITE-FLAGS-tagsFromFile-“-SOURCE”-gps-all-ext-“-EXT”-r-“-TARGET-DIR”"><a href="#exiftool-OVERWRITE-FLAGS-tagsFromFile-“-SOURCE”-gps-all-ext-“-EXT”-r-“-TARGET-DIR”" class="headerlink" title="exiftool $OVERWRITE_FLAGS -tagsFromFile “$SOURCE” -gps:all -ext “$EXT” -r “$TARGET_DIR”"></a>exiftool $OVERWRITE_FLAGS -tagsFromFile “$SOURCE” -gps:all -ext “$EXT” -r “$TARGET_DIR”</h1><p>echo “Done. Verify with: exiftool -a -G1 -s -gps* <file>“</p><p>⸻</p><p>📌 总结<br>    •ExifTool 是处理照片 GPS 信息的标准工具。<br>    •既可以从一张“模板”照片复制 GPS，也可以从 XMP sidecar 或 GPX 轨迹导入。<br>    •默认 _original 文件机制提供了安全保障，建议初期不要关闭。<br>    •批量处理时，要谨慎，推荐小规模试运行。</p><p>⸻</p><p>要不要我帮你再写一份 针对“先小规模测试 → 大规模批处理”的实操清单（比如一个三步 checklist，你只要按顺序执行，就能避免 90% 的坑）？</p>]]>
    </content>
    <id>https://iyuluo.com/2025/09/30/exiftool-%E4%BD%BF%E7%94%A8/</id>
    <link href="https://iyuluo.com/2025/09/30/exiftool-%E4%BD%BF%E7%94%A8/"/>
    <published>2025-09-30T05:59:40.000Z</published>
    <summary>
      <![CDATA[<p>在日常摄影中，我们常常会遇到这样一个问题：使用相机（如 Nikon Z30 等非 GPS 相机）拍摄的照片并没有记录位置信息。如果你希望在后期整理或分享时，让照片具备地理信息，就需要借助专业工具来补充或批量写入 GPS 数据。</p>
<p>本文将介绍如何使用强大的命令行工具 ExifTool 来解决这个问题，包括：<br>    1.	为照片添加 GPS 信息；<br>    2.	将一张照片的 GPS 信息复制到另一张或多张照片中；<br>    3.	检查和验证修改结果。</p>]]>
    </summary>
    <title>使用 ExifTool 为照片批量添加和复制 GPS 信息</title>
    <updated>2025-09-30T14:49:57.051Z</updated>
  </entry>
  <entry>
    <author>
      <name>yuluo</name>
    </author>
    <content>
      <![CDATA[<p>Python 生态中有许多包管理工具，如 <code>pip</code>、<code>poetry</code> 和 <code>conda</code>，而 <code>uv</code> 作为一个新兴工具，正迅速受到关注。<code>uv</code> 由 Astral 开发，基于 Rust 编写，宣称是 Python 包管理的未来。它不仅速度极快（据称比传统工具快 10-100 倍），还集成了项目管理、虚拟环境管理和 Python 版本管理的功能，旨在成为一个全能解决方案。</p><p>截至 2025 年 3 月 29 日，<code>uv</code> 的官方文档和社区反馈显示，其普及度正在提升，尤其在需要高效依赖管理的场景中表现出色。然而，作为一个较新的工具，其长期稳定性仍需更多用户验证。</p><span id="more"></span><h3 id="安装步骤"><a href="#安装步骤" class="headerlink" title="安装步骤"></a>安装步骤</h3><p><code>uv</code> 提供了多种安装方法，适用于不同操作系统。以下是最新且推荐的安装步骤：</p><ul><li><p><strong>使用安装脚本：</strong></p><ul><li><strong>Linux 系统：</strong><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">curl -LsSf https://astral.sh/uv/install.sh | sh<br></code></pre></td></tr></table></figure></li><li><strong>macOS 系统：</strong><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">brew install uv<br></code></pre></td></tr></table></figure></li><li><strong>Windows（PowerShell）：</strong><figure class="highlight powershell"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs powershell">powershell <span class="hljs-literal">-ExecutionPolicy</span> Bypass <span class="hljs-literal">-c</span> <span class="hljs-string">&quot;irm https://astral.sh/uv/install.ps1 | iex&quot;</span><br></code></pre></td></tr></table></figure><em>注意</em>：Windows 用户可能需调整 PowerShell 执行策略，确保脚本顺利运行。</li><li>这些命令会自动下载并安装 <code>uv</code>，无需额外配置。</li></ul></li><li><p><strong>使用 pip 安装：</strong></p><ul><li>运行：<figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">pip install uv<br></code></pre></td></tr></table></figure></li><li>或使用 <code>pipx</code>：<figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">pipx install uv<br></code></pre></td></tr></table></figure></li><li>更多安装选项可参考 <a href="https://docs.astral.sh/uv/get-started/installation/">uv 官方安装文档</a>。</li></ul></li></ul><p><strong>验证安装：</strong><br>安装完成后，运行以下命令检查版本：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">uv --version<br></code></pre></td></tr></table></figure><p>输出类似 <code>uv 0.1.0</code>，表示安装成功。</p><hr><h3 id="项目初始化与依赖管理"><a href="#项目初始化与依赖管理" class="headerlink" title="项目初始化与依赖管理"></a>项目初始化与依赖管理</h3><p><code>uv</code> 的核心功能之一是简化项目初始化和依赖管理。以下是具体用法：</p><ul><li><p><strong>初始化项目：</strong></p><ul><li>在项目目录中运行：<figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">uv project init<br></code></pre></td></tr></table></figure></li><li>此命令会生成 <code>uv.toml</code> 文件，用于存储项目配置。</li></ul></li><li><p><strong>添加依赖：</strong></p><ul><li>添加包（如 <code>requests</code>）：<figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">uv add requests<br></code></pre></td></tr></table></figure></li><li><code>uv</code> 会快速解析并安装依赖到项目虚拟环境中。</li></ul></li><li><p><strong>更新和移除依赖：</strong></p><ul><li>更新所有依赖：<figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">uv <span class="hljs-built_in">sync</span> --upgrade<br></code></pre></td></tr></table></figure></li><li>更新特定包：<figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">uv <span class="hljs-built_in">sync</span> --upgrade-package &lt;package_name&gt; <br></code></pre></td></tr></table></figure></li><li>移除包：<figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">uv remove &lt;package_name&gt;<br></code></pre></td></tr></table></figure></li></ul></li><li><p><strong>锁文件（lockfile）：</strong></p><ul><li>运行 <code>uv lock</code> 生成锁文件，确保依赖版本一致，适合团队协作。</li></ul></li></ul><hr><h3 id="虚拟环境管理"><a href="#虚拟环境管理" class="headerlink" title="虚拟环境管理"></a>虚拟环境管理</h3><p><code>uv</code> 提供便捷的虚拟环境管理功能：</p><ul><li><p><strong>创建和激活：</strong></p><ul><li><code>uv</code> 自动为项目创建虚拟环境，激活方式：<figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">uv activate<br></code></pre></td></tr></table></figure></li><li>激活后，命令行提示符会显示虚拟环境名称。</li></ul></li><li><p><strong>退出虚拟环境：</strong></p><ul><li>输入：<figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">uv deactivate<br></code></pre></td></tr></table></figure></li></ul></li><li><p><strong>列出虚拟环境：</strong></p><ul><li>查看所有管理的虚拟环境：<figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">uv <span class="hljs-built_in">env</span> list<br></code></pre></td></tr></table></figure></li></ul></li><li><p><strong>多版本支持：</strong></p><ul><li>通过 <code>uv python install &lt;version&gt;</code> 安装特定 Python 版本（如 <code>3.10</code>），并自动检测系统已有 Python 安装。</li></ul></li></ul><hr><h3 id="运行脚本和工具"><a href="#运行脚本和工具" class="headerlink" title="运行脚本和工具"></a>运行脚本和工具</h3><p><code>uv</code> 支持运行脚本和安装工具，类似 <code>pipx</code>：</p><ul><li><p><strong>运行脚本：</strong></p><ul><li>执行 Python 脚本：<figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">uv run &lt;script_path&gt;<br></code></pre></td></tr></table></figure>示例：<code>uv run myscript.py</code>。</li></ul></li><li><p><strong>安装工具：</strong></p><ul><li>安装工具包（如 <code>black</code>）：<figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">uv tool install black<br></code></pre></td></tr></table></figure></li><li>运行工具：<figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">uv tool run black myfile.py<br></code></pre></td></tr></table></figure>或使用别名 <code>uvx</code>：<figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">uvx black myfile.py<br></code></pre></td></tr></table></figure></li></ul></li><li><p><strong>临时虚拟环境：</strong></p><ul><li><code>uv tool run</code> 会创建临时环境运行工具，用后即删，保持项目环境干净。</li></ul></li></ul><hr><h3 id="Python-版本管理"><a href="#Python-版本管理" class="headerlink" title="Python 版本管理"></a>Python 版本管理</h3><p><code>uv</code> 内置 Python 版本管理，适合多版本开发：</p><ul><li><p><strong>安装版本：</strong></p><ul><li>安装指定版本：<figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">uv python install 3.10<br></code></pre></td></tr></table></figure></li><li>可同时安装多个版本：<figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">uv python install 3.9 3.10 3.11<br></code></pre></td></tr></table></figure></li></ul></li><li><p><strong>列出和使用：</strong></p><ul><li>查看已安装版本：<figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">uv python list<br></code></pre></td></tr></table></figure></li><li>在 <code>uv.toml</code> 中固定版本：<figure class="highlight toml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs toml"><span class="hljs-section">[tool.uv]</span><br><span class="hljs-attr">python</span> = <span class="hljs-string">&quot;3.10&quot;</span><br></code></pre></td></tr></table></figure></li><li>或运行时指定：<figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">uv --python 3.10 run myscript.py<br></code></pre></td></tr></table></figure></li></ul></li></ul><hr><h3 id="高级功能"><a href="#高级功能" class="headerlink" title="高级功能"></a>高级功能</h3><p><code>uv</code> 提供了一些高级特性：</p><ul><li><p><strong>锁文件和工作区：</strong></p><ul><li><code>uv lock</code> 生成锁文件。</li><li>支持工作区管理，在父目录创建 <code>uv.workspace.toml</code>。</li></ul></li><li><p><strong>兼容 pip：</strong></p><ul><li>支持 <code>uv pip install &lt;package_name&gt;</code>，性能远超传统 <code>pip</code>。</li></ul></li></ul><hr><h3 id="疑难解答与技巧"><a href="#疑难解答与技巧" class="headerlink" title="疑难解答与技巧"></a>疑难解答与技巧</h3><p>以下是常见问题解决方法：</p><ul><li><strong>更新 uv：</strong><ul><li>运行 <code>uv self update</code> 获取最新版本。</li></ul></li><li><strong>清理环境：</strong><ul><li>用 <code>uv clean</code> 重置项目环境。</li></ul></li><li><strong>获取帮助：</strong><ul><li>参考 <a href="https://docs.astral.sh/uv/">uv 官方文档</a> 或社区讨论。</li></ul></li></ul><hr><h3 id="结语"><a href="#结语" class="headerlink" title="结语"></a>结语</h3><p><code>uv</code> 是一个高效、多功能的 Python 包管理工具，适合追求速度和集成体验的开发者。虽然它可能改变传统工作流，但作为新工具，建议先在非关键项目中试用。希望本教程能帮助您快速掌握 <code>uv</code>，提升开发效率！</p>]]>
    </content>
    <id>https://iyuluo.com/2025/03/30/Python-%E6%96%B0%E5%8C%85%E7%AE%A1%E7%90%86%E5%B7%A5%E5%85%B7%EF%BC%9AUV/</id>
    <link href="https://iyuluo.com/2025/03/30/Python-%E6%96%B0%E5%8C%85%E7%AE%A1%E7%90%86%E5%B7%A5%E5%85%B7%EF%BC%9AUV/"/>
    <published>2025-03-30T12:28:43.000Z</published>
    <summary>
      <![CDATA[<p>Python 生态中有许多包管理工具，如 <code>pip</code>、<code>poetry</code> 和 <code>conda</code>，而 <code>uv</code> 作为一个新兴工具，正迅速受到关注。<code>uv</code> 由 Astral 开发，基于 Rust 编写，宣称是 Python 包管理的未来。它不仅速度极快（据称比传统工具快 10-100 倍），还集成了项目管理、虚拟环境管理和 Python 版本管理的功能，旨在成为一个全能解决方案。</p>
<p>截至 2025 年 3 月 29 日，<code>uv</code> 的官方文档和社区反馈显示，其普及度正在提升，尤其在需要高效依赖管理的场景中表现出色。然而，作为一个较新的工具，其长期稳定性仍需更多用户验证。</p>]]>
    </summary>
    <title>Python 新包管理工具：UV</title>
    <updated>2025-09-22T03:42:25.045Z</updated>
  </entry>
  <entry>
    <author>
      <name>yuluo</name>
    </author>
    <category term="DEV" scheme="https://iyuluo.com/categories/DEV/"/>
    <category term="docker" scheme="https://iyuluo.com/tags/docker/"/>
    <content>
      <![CDATA[<p>在国内使用 Docker 时，拉取官方镜像可能会遇到速度缓慢或连接超时的问题。这主要是由于网络环境和访问 Docker 官方镜像仓库（Docker Hub）时的带宽限制所致。为了解决这一问题，配置国内的 Docker 镜像加速器（registry mirrors）是一种有效的解决方案。</p><p>Docker 镜像加速器是由国内服务提供商搭建的镜像仓库，通过同步 Docker Hub 的内容，提供更快的镜像拉取速度。这些加速器能够有效减少镜像下载时间，提高开发和部署效率。</p><span id="more"></span><p>常用的国内 Docker 镜像加速器</p><p>以下是一些常用的国内 Docker 镜像加速器：</p><blockquote><p>•<a href="https://cr.laoyou.ip-ddns.com/">https://cr.laoyou.ip-ddns.com</a><br>    •<a href="https://docker.1panel.live/">https://docker.1panel.live</a><br>    •<a href="https://image.cloudlayer.icu/">https://image.cloudlayer.icu</a><br>    •<a href="https://hub.fast360.xyz/">https://hub.fast360.xyz</a><br>    •<a href="https://docker-0.unsee.tech/">https://docker-0.unsee.tech</a><br>    •<a href="https://docker.1panelproxy.com/">https://docker.1panelproxy.com</a><br>    •<a href="https://docker.tbedu.top/">https://docker.tbedu.top</a><br>    •<a href="https://dockerpull.cn/">https://dockerpull.cn</a><br>    •<a href="https://docker.m.daocloud.io/">https://docker.m.daocloud.io</a><br>    •<a href="https://hub.rat.dev/">https://hub.rat.dev</a><br>    •<a href="https://docker.kejilion.pro/">https://docker.kejilion.pro</a><br>    •<a href="https://docker.hlmirror.com/">https://docker.hlmirror.com</a><br>    •<a href="https://docker.imgdb.de/">https://docker.imgdb.de</a><br>    •<a href="https://docker.melikeme.cn/">https://docker.melikeme.cn</a><br>    •<a href="https://ccr.ccs.tencentyun.com/">https://ccr.ccs.tencentyun.com</a><br>    •<a href="https://pull.loridocker.com/">https://pull.loridocker.com</a></p></blockquote><p>要配置 Docker 使用上述镜像加速器，可以按照以下步骤进行：<br>    1. 创建或编辑 Docker 配置文件：在终端中执行以下命令：</p><pre><code class="hljs"> sudo mkdir -p /etc/dockersudo tee /etc/docker/daemon.json &lt;&lt;-&#39;EOF&#39;&#123;    &quot;registry-mirrors&quot;: [        &quot;https://cr.laoyou.ip-ddns.com&quot;,        &quot;https://docker.1panel.live&quot;,        &quot;https://image.cloudlayer.icu&quot;,        &quot;https://hub.fast360.xyz&quot;,        &quot;https://docker-0.unsee.tech&quot;,        &quot;https://docker.1panelproxy.com&quot;,        &quot;https://docker.tbedu.top&quot;,        &quot;https://dockerpull.cn&quot;,        &quot;https://docker.m.daocloud.io&quot;,        &quot;https://hub.rat.dev&quot;,        &quot;https://docker.kejilion.pro&quot;,        &quot;https://docker.hlmirror.com&quot;,        &quot;https://docker.imgdb.de&quot;,        &quot;https://docker.melikeme.cn&quot;,        &quot;https://ccr.ccs.tencentyun.com&quot;,        &quot;https://pull.loridocker.com&quot;    ]&#125;EOF</code></pre><ol><li>重启 Docker 服务：使配置生效： <figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><code class="hljs bash">    <span class="hljs-built_in">sudo</span> systemctl daemon-reload<br>    <span class="hljs-built_in">sudo</span> systemctl restart docker<br>    <br>1. 验证配置：使用以下命令检查配置是否生效：<br>    ```bash<br>    docker info<br></code></pre></td></tr></table></figure> 在输出中，应看到类似以下内容： <figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><code class="hljs bash">Registry Mirrors:<br>https://cr.laoyou.ip-ddns.com/<br>https://docker.1panel.live/<br>...<br></code></pre></td></tr></table></figure></li></ol><p>这表明 Docker 已成功配置了国内镜像加速器。<br>注意事项</p><blockquote><pre><code class="hljs">•镜像加速器的可用性：由于网络环境和服务提供商的维护情况，镜像加速器的可用性可能会发生变化。建议定期检查并更新配置，以确保使用的镜像加速器稳定可靠。•安全性考虑：使用镜像加速器时，应选择信誉良好的服务提供商，以确保镜像的完整性和安全性。</code></pre></blockquote>]]>
    </content>
    <id>https://iyuluo.com/2025/03/18/Docker-%E9%95%9C%E5%83%8F%E6%8B%89%E5%8F%96%E5%A4%B1%E8%B4%A5/</id>
    <link href="https://iyuluo.com/2025/03/18/Docker-%E9%95%9C%E5%83%8F%E6%8B%89%E5%8F%96%E5%A4%B1%E8%B4%A5/"/>
    <published>2025-03-18T06:00:51.000Z</published>
    <summary>
      <![CDATA[<p>在国内使用 Docker 时，拉取官方镜像可能会遇到速度缓慢或连接超时的问题。这主要是由于网络环境和访问 Docker 官方镜像仓库（Docker Hub）时的带宽限制所致。为了解决这一问题，配置国内的 Docker 镜像加速器（registry mirrors）是一种有效的解决方案。</p>
<p>Docker 镜像加速器是由国内服务提供商搭建的镜像仓库，通过同步 Docker Hub 的内容，提供更快的镜像拉取速度。这些加速器能够有效减少镜像下载时间，提高开发和部署效率。</p>]]>
    </summary>
    <title>Docker 镜像拉取失败</title>
    <updated>2025-03-18T06:10:28.969Z</updated>
  </entry>
  <entry>
    <author>
      <name>yuluo</name>
    </author>
    <category term="Python" scheme="https://iyuluo.com/categories/Python/"/>
    <category term="MarkItDown" scheme="https://iyuluo.com/tags/MarkItDown/"/>
    <content>
      <![CDATA[<p><strong>MarkItDown</strong> 是微软开源的一款多功能文档转换工具，旨在将多种文件格式（如 PDF、Office 文档、图像、音频等）高效转换为 <strong>Markdown</strong> 格式，适用于文档索引、文本分析、内容管理等场景。以下是其核心特性与优势的详细解析：</p><span id="more"></span><hr><h3 id="1-核心功能与支持格式"><a href="#1-核心功能与支持格式" class="headerlink" title="1. 核心功能与支持格式"></a>1. <strong>核心功能与支持格式</strong></h3><p>MarkItDown 支持广泛的文件类型转换，覆盖了办公、多媒体及常见文本格式：</p><ul><li><strong>办公文档</strong>：PDF（提取结构化内容）、Word（保留基础格式和注释）、Excel（表格转Markdown表格）、PowerPoint（提取幻灯片文本和图表）。</li><li><strong>图像与音频</strong>：提取 EXIF 元数据、OCR 文字识别（图片）、语音转文本（音频）。</li><li><strong>其他格式</strong>：HTML（优化维基百科等复杂页面）、CSV&#x2F;JSON&#x2F;XML（结构化数据转Markdown）、ZIP 压缩包（批量处理内部文件）。</li><li><strong>AI 集成</strong>：支持调用 OpenAI 等大语言模型（如 GPT-4），为图片生成描述性文本或增强语义分析。</li></ul><hr><h3 id="2-技术实现与依赖库"><a href="#2-技术实现与依赖库" class="headerlink" title="2. 技术实现与依赖库"></a>2. <strong>技术实现与依赖库</strong></h3><p>MarkItDown 基于模块化设计，整合了多个高效的开源库以实现复杂文件处理：</p><ul><li><strong>文档解析</strong>：  <ul><li>PDF 使用 <code>pdfminer.six</code> 提取文本，Word 依赖 <code>mammoth</code>，Excel 基于 <code>openpyxl</code>，PPT 使用 <code>python-pptx</code>。</li><li>HTML 通过 <code>beautifulsoup4</code> 和 <code>markdownify</code> 清理并转换格式。</li></ul></li><li><strong>多媒体处理</strong>：  <ul><li>音频转文本通过 <code>SpeechRecognition</code> 库实现，OCR 使用 <code>EasyOCR</code>。</li><li>图像描述需配置 ExifTool（元数据提取）和 LLM（如 OpenAI 的 GPT-4）。</li></ul></li><li><strong>其他工具</strong>：  <ul><li>字符编码处理依赖 <code>charset-normalizer</code>，文件类型检测使用 <code>puremagic</code>。</li></ul></li></ul><hr><h3 id="3-应用场景与优劣"><a href="#3-应用场景与优劣" class="headerlink" title="3. 应用场景与优劣"></a>3. <strong>应用场景与优劣</strong></h3><p>MarkItDown 的灵活性和高效性使其在多个领域发挥作用：</p><ul><li><strong>自动化文档管理</strong>：批量转换混合格式文件为统一 Markdown，便于版本控制或知识库构建。</li><li><strong>AI 数据预处理</strong>：将非结构化数据（如 PDF、音频）转换为可分析的文本，提升大语言模型训练效率。</li><li><strong>无障碍支持</strong>：转录音频内容为文本，或从图像中提取文字，助力无障碍工作流。</li><li><strong>开发与协作</strong>：开发者可快速提取文档内容进行索引或分析，团队协作中 Markdown 格式更易维护。</li><li><strong>劣势</strong>：不支持doc和xls等前一代的Office格式，对于PDF中的图片也没有办法，甚至PDF过大也不行。</li></ul><hr><h3 id="4-开源与社区支持"><a href="#4-开源与社区支持" class="headerlink" title="4. 开源与社区支持"></a>4. <strong>开源与社区支持</strong></h3><ul><li><strong>开源协议</strong>：遵循 MIT 许可证，允许自由使用、修改和分发，需保留原始版权声明。</li><li><strong>社区贡献</strong>：微软鼓励开发者参与插件开发（支持第三方扩展）及问题修复，GitHub 提供详细贡献指南和测试框架。</li><li><strong>持续优化</strong>：用户反馈的已知问题（如图片转换失败、PDF 编码错误）正在逐步解决。</li></ul><hr>]]>
    </content>
    <id>https://iyuluo.com/2025/02/11/MarkItDown-%E7%9C%9F%E7%9A%84%E5%A5%BD%E4%B9%88%EF%BC%9F/</id>
    <link href="https://iyuluo.com/2025/02/11/MarkItDown-%E7%9C%9F%E7%9A%84%E5%A5%BD%E4%B9%88%EF%BC%9F/"/>
    <published>2025-02-11T06:05:22.000Z</published>
    <summary>
      <![CDATA[<p><strong>MarkItDown</strong> 是微软开源的一款多功能文档转换工具，旨在将多种文件格式（如 PDF、Office 文档、图像、音频等）高效转换为 <strong>Markdown</strong> 格式，适用于文档索引、文本分析、内容管理等场景。以下是其核心特性与优势的详细解析：</p>]]>
    </summary>
    <title>MarkItDown 真的好么？</title>
    <updated>2025-02-11T07:13:28.869Z</updated>
  </entry>
  <entry>
    <author>
      <name>yuluo</name>
    </author>
    <category term="Life" scheme="https://iyuluo.com/categories/Life/"/>
    <category term="生活" scheme="https://iyuluo.com/tags/%E7%94%9F%E6%B4%BB/"/>
    <content>
      <![CDATA[<p>一年一年又一年<br>转身发现自己过了这么多年<br>依旧一事无成</p><p>倔强要来的娃<br>让我多了一个身份<br>家庭多了一个成员<br>软萌可爱<br>却又有时脾气恶劣<br>天使魔鬼结合体</p><p>从看到娃的那时起<br>软肋铠甲并存<br>想了太多太多<br>她嫌我想的太远<br>我却不知道能否保证娃的健康快乐</p><p>因为娃<br>我跟她疲惫不堪<br>得靠着娃的笑容充电</p><p>值么？<br>不知道<br>但<br>也没得选<br>对么？</p><p>2024年的如今一团糟<br>2025年的以后还会更糟么？</p>]]>
    </content>
    <id>https://iyuluo.com/2024/12/31/2024%E5%B9%B4%E7%9A%84%E5%A6%82%E4%BB%8A/</id>
    <link href="https://iyuluo.com/2024/12/31/2024%E5%B9%B4%E7%9A%84%E5%A6%82%E4%BB%8A/"/>
    <published>2024-12-31T15:43:37.000Z</published>
    <summary>
      <![CDATA[<p>一年一年又一年<br>转身发现自己过了这么多年<br>依旧一事无成</p>
<p>倔强要来的娃<br>让我多了一个身份<br>家庭多了一个成员<br>软萌可爱<br>却又有时脾气恶劣<br>天使魔鬼结合体</p>
<p>从看到娃的那时起<br>软肋铠甲并存<br>想了太多太]]>
    </summary>
    <title>2024年的如今</title>
    <updated>2024-12-31T16:01:49.487Z</updated>
  </entry>
  <entry>
    <author>
      <name>yuluo</name>
    </author>
    <category term="Python" scheme="https://iyuluo.com/categories/Python/"/>
    <category term="JSON" scheme="https://iyuluo.com/tags/JSON/"/>
    <content>
      <![CDATA[<p>在 Python 中，JSON 解析和序列化是非常常见的任务。虽然标准库提供了 <code>json</code> 模块，但当数据量大或者性能要求高时，它可能无法满足需求。这时候可以选择第三方库，比如 <strong>ORJSON</strong>。</p><p>ORJSON 是一个超快速且功能丰富的 JSON 解析和序列化库，它利用 Rust 实现，性能显著优于 Python 标准库和其他 JSON 库（如 <code>ujson</code> 和 <code>rapidjson</code>）。</p><span id="more"></span><h3 id="ORJSON-的优势"><a href="#ORJSON-的优势" class="headerlink" title="ORJSON 的优势"></a>ORJSON 的优势</h3><ol><li><strong>高性能</strong>：在序列化和反序列化速度上，ORJSON 是当前最快的 Python JSON 库之一。</li><li><strong>支持复杂数据类型</strong>：原生支持 <code>datetime</code>、<code>numpy</code> 数组等复杂数据类型的序列化，而无需额外处理。</li><li><strong>严格的标准化支持</strong>：ORJSON 完全符合 JSON 标准，并支持 UTF-8 编码。</li><li><strong>易用性</strong>：API 设计简单直观，几乎可以直接替换标准库 <code>json</code> 模块的功能。</li></ol><hr><h3 id="安装-ORJSON"><a href="#安装-ORJSON" class="headerlink" title="安装 ORJSON"></a>安装 ORJSON</h3><p>在项目中安装 ORJSON 非常简单，只需运行以下命令：</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs bash">pip install orjson<br></code></pre></td></tr></table></figure><hr><h3 id="ORJSON-基本用法"><a href="#ORJSON-基本用法" class="headerlink" title="ORJSON 基本用法"></a>ORJSON 基本用法</h3><h4 id="1-JSON-序列化"><a href="#1-JSON-序列化" class="headerlink" title="1. JSON 序列化"></a>1. <strong>JSON 序列化</strong></h4><p>ORJSON 的序列化使用 <code>orjson.dumps</code> 函数，返回的结果是字节类型（<code>bytes</code>），可以轻松用于文件写入或网络传输。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><code class="hljs python"><span class="hljs-keyword">import</span> orjson<br><br><span class="hljs-comment"># 示例数据</span><br>data = &#123;<span class="hljs-string">&quot;name&quot;</span>: <span class="hljs-string">&quot;Alice&quot;</span>, <span class="hljs-string">&quot;age&quot;</span>: <span class="hljs-number">25</span>, <span class="hljs-string">&quot;is_student&quot;</span>: <span class="hljs-literal">False</span>&#125;<br><br><span class="hljs-comment"># 序列化为 JSON 字节串</span><br>json_bytes = orjson.dumps(data)<br><span class="hljs-built_in">print</span>(json_bytes.decode(<span class="hljs-string">&quot;utf-8&quot;</span>))  <span class="hljs-comment"># 转换为字符串输出</span><br></code></pre></td></tr></table></figure><p>输出：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs json"><span class="hljs-punctuation">&#123;</span><span class="hljs-attr">&quot;name&quot;</span><span class="hljs-punctuation">:</span><span class="hljs-string">&quot;Alice&quot;</span><span class="hljs-punctuation">,</span><span class="hljs-attr">&quot;age&quot;</span><span class="hljs-punctuation">:</span><span class="hljs-number">25</span><span class="hljs-punctuation">,</span><span class="hljs-attr">&quot;is_student&quot;</span><span class="hljs-punctuation">:</span><span class="hljs-literal"><span class="hljs-keyword">false</span></span><span class="hljs-punctuation">&#125;</span><br></code></pre></td></tr></table></figure><h4 id="2-JSON-反序列化"><a href="#2-JSON-反序列化" class="headerlink" title="2. JSON 反序列化"></a>2. <strong>JSON 反序列化</strong></h4><p>ORJSON 的反序列化使用 <code>orjson.loads</code>，可以将 JSON 字符串或字节串快速解析为 Python 对象。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><code class="hljs python">json_data = <span class="hljs-string">b&#x27;&#123;&quot;name&quot;:&quot;Alice&quot;,&quot;age&quot;:25,&quot;is_student&quot;:false&#125;&#x27;</span><br>parsed_data = orjson.loads(json_data)<br><span class="hljs-built_in">print</span>(parsed_data)<br></code></pre></td></tr></table></figure><p>输出：</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs python">&#123;<span class="hljs-string">&#x27;name&#x27;</span>: <span class="hljs-string">&#x27;Alice&#x27;</span>, <span class="hljs-string">&#x27;age&#x27;</span>: <span class="hljs-number">25</span>, <span class="hljs-string">&#x27;is_student&#x27;</span>: <span class="hljs-literal">False</span>&#125;<br></code></pre></td></tr></table></figure><h4 id="3-处理日期和时间"><a href="#3-处理日期和时间" class="headerlink" title="3. 处理日期和时间"></a>3. <strong>处理日期和时间</strong></h4><p>ORJSON 支持直接序列化 <code>datetime</code> 对象，无需额外格式化。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><code class="hljs python"><span class="hljs-keyword">import</span> orjson<br><span class="hljs-keyword">from</span> datetime <span class="hljs-keyword">import</span> datetime<br><br>data = &#123;<span class="hljs-string">&quot;timestamp&quot;</span>: datetime.utcnow()&#125;<br><br><span class="hljs-comment"># 序列化</span><br>json_bytes = orjson.dumps(data, option=orjson.OPT_NAIVE_UTC)<br><span class="hljs-built_in">print</span>(json_bytes.decode(<span class="hljs-string">&quot;utf-8&quot;</span>))<br></code></pre></td></tr></table></figure><p>输出：</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><code class="hljs json"><span class="hljs-punctuation">&#123;</span><span class="hljs-attr">&quot;timestamp&quot;</span><span class="hljs-punctuation">:</span><span class="hljs-string">&quot;2024-12-18T10:15:30.000000Z&quot;</span><span class="hljs-punctuation">&#125;</span><br></code></pre></td></tr></table></figure><hr><h3 id="性能对比"><a href="#性能对比" class="headerlink" title="性能对比"></a>性能对比</h3><p>以下是 ORJSON 与其他 JSON 库的简单性能对比（序列化 100 万条数据）：</p><table><thead><tr><th>库</th><th>序列化时间（秒）</th><th>反序列化时间（秒）</th></tr></thead><tbody><tr><td><code>orjson</code></td><td><strong>0.03</strong></td><td><strong>0.04</strong></td></tr><tr><td><code>ujson</code></td><td>0.09</td><td>0.07</td></tr><tr><td><code>json</code></td><td>0.13</td><td>0.11</td></tr></tbody></table><p>从表中可以看出，ORJSON 的性能显著优于其他库，非常适合大数据量的 JSON 操作。</p><hr><h3 id="总结"><a href="#总结" class="headerlink" title="总结"></a>总结</h3><p>ORJSON 是一款性能卓越的 JSON 库，它以极快的速度、简洁的 API 和出色的功能支持，成为处理 JSON 数据的理想选择。</p><hr><p>可以在 <a href="https://github.com/ijl/orjson">ORJSON 的官方文档</a> 中了解更多细节。</p>]]>
    </content>
    <id>https://iyuluo.com/2024/12/18/%E4%B8%80%E4%B8%AA%E4%BD%BF%E7%94%A8%E7%9A%84Python%E4%B8%8B%E7%9A%84json%E5%BA%93%EF%BC%9Aorjson/</id>
    <link href="https://iyuluo.com/2024/12/18/%E4%B8%80%E4%B8%AA%E4%BD%BF%E7%94%A8%E7%9A%84Python%E4%B8%8B%E7%9A%84json%E5%BA%93%EF%BC%9Aorjson/"/>
    <published>2024-12-18T07:50:42.000Z</published>
    <summary>
      <![CDATA[<p>在 Python 中，JSON 解析和序列化是非常常见的任务。虽然标准库提供了 <code>json</code> 模块，但当数据量大或者性能要求高时，它可能无法满足需求。这时候可以选择第三方库，比如 <strong>ORJSON</strong>。</p>
<p>ORJSON 是一个超快速且功能丰富的 JSON 解析和序列化库，它利用 Rust 实现，性能显著优于 Python 标准库和其他 JSON 库（如 <code>ujson</code> 和 <code>rapidjson</code>）。</p>]]>
    </summary>
    <title>一个使用的Python下的JSON库：ORJSON</title>
    <updated>2024-12-18T07:59:30.344Z</updated>
  </entry>
  <entry>
    <author>
      <name>yuluo</name>
    </author>
    <category term="Python" scheme="https://iyuluo.com/categories/Python/"/>
    <content>
      <![CDATA[<p>简而言之 time 模块中的 time（）函数用于获取当前时间，perf_counter（）函数用于获取当前时间的精确计时器值，通常用于测量程序的执行时间。</p><span id="more"></span><p>主要的区别和应用：</p><h2 id="time-perf-counter"><a href="#time-perf-counter" class="headerlink" title="time.perf_counter()"></a>time.perf_counter()</h2><p>用途: </p><ul><li>time.perf_counter() 返回一个高精度的时间计数器值，用于测量短时间间隔的准确时间。在某些系统上，它可能会使用高精度的时钟源来提供精确到纳秒级的时间戳。<br>特点:</li><li>高分辨率：通常适用于性能测量、算法测试，以及任何需要高精度时间测量的场景。</li><li>不保证从某个固定的时间点（如UNIX纪元时间1970-01-01）开始，只能用于测量时间的流逝。<br>在一些系统中，它可能会受到操作系统的时钟调整影响较小，保持高精度。</li></ul><h2 id="time-time"><a href="#time-time" class="headerlink" title="time.time()"></a>time.time()</h2><p>用途: </p><ul><li>time.time() 返回当前的UNIX时间戳，从1970年1月1日（UTC）开始的秒数。它主要用于获取当前的绝对时间。<br>特点:</li><li>时间戳：用于获取当前时间的绝对值，适合表示具体的日期和时间。</li><li>精度通常在毫秒级：虽然可以提供小数部分，但其精度通常不及 time.perf_counter()。</li><li>可能会受到系统时间调整的影响，例如手动更改系统时间或自动同步时间。</li></ul><p>使用建议</p><p>如果需要测量代码执行时间或某个操作的持续时间，建议使用 time.perf_counter()，因为它提供更高的精度和更稳定的测量。<br>如果你需要获取当前系统时间，例如记录日志时间或时间戳，可以使用 time.time()，因为它表示的是绝对时间。</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><code class="hljs python"><span class="hljs-keyword">import</span> time<br><br><span class="hljs-comment"># 使用 time.perf_counter() 测量时间间隔</span><br>start = time.perf_counter()<br><span class="hljs-comment"># 执行一些操作</span><br>end = time.perf_counter()<br><span class="hljs-built_in">print</span>(<span class="hljs-string">f&quot;操作耗时：<span class="hljs-subst">&#123;end - start&#125;</span> 秒&quot;</span>)<br><br><span class="hljs-comment"># 使用 time.time() 获取当前时间戳</span><br>current_time = time.time()<br><span class="hljs-built_in">print</span>(<span class="hljs-string">f&quot;当前UNIX时间戳：<span class="hljs-subst">&#123;current_time&#125;</span>&quot;</span>)<br></code></pre></td></tr></table></figure><p>总结：选择这两个函数的关键在于用途。如果目的是进行高精度的时间间隔测量，选用 time.perf_counter()。如果目的是获取系统的当前时间戳，选用 time.time()。</p>]]>
    </content>
    <id>https://iyuluo.com/2024/10/29/time%E4%B8%AD%E7%9A%84time-perf-counter-%E5%92%8Ctime-time-%E5%AF%B9%E6%AF%94/</id>
    <link href="https://iyuluo.com/2024/10/29/time%E4%B8%AD%E7%9A%84time-perf-counter-%E5%92%8Ctime-time-%E5%AF%B9%E6%AF%94/"/>
    <published>2024-10-29T01:44:13.000Z</published>
    <summary>
      <![CDATA[<p>简而言之 time 模块中的 time（）函数用于获取当前时间，perf_counter（）函数用于获取当前时间的精确计时器值，通常用于测量程序的执行时间。</p>]]>
    </summary>
    <title>time中的time.perf_counter()和time.time()对比</title>
    <updated>2024-10-29T01:48:52.677Z</updated>
  </entry>
  <entry>
    <author>
      <name>yuluo</name>
    </author>
    <category term="Python" scheme="https://iyuluo.com/categories/Python/"/>
    <category term="Flask" scheme="https://iyuluo.com/tags/Flask/"/>
    <content>
      <![CDATA[<p>当Flask实现的接口，返回的数据中有中文存在的时候，会默认显示为Unicode编码。<br>这个就是ASCII码，用转换工具能够显示原来的。<br>之前可以使用如下方式解决</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs ptyhon">app = Flask（__name__）<br>app.config［&#x27;JSON_AS_ASCII&#x27;］ = False ＃ 解决中文乱码问题<br></code></pre></td></tr></table></figure><p>但是最新的不行了</p><span id="more"></span><p>针对版本的检查，是新版本弃用上面的方法了，新版本的如下<br>Flask 2.3.0以上</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs ptyhon">app = Flask（__name__）<br>app.json.ensure_ascii = False # 解决中文乱码问题<br></code></pre></td></tr></table></figure><p>Flask 2.2.5以下</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs ptyhon">app = Flask（__name__）<br>app.config［&#x27;JSON_AS_ASCII&#x27;］ = False # 解决中文乱码问题<br></code></pre></td></tr></table></figure>]]>
    </content>
    <id>https://iyuluo.com/2024/09/12/%E8%A7%A3%E5%86%B3Flask%E8%BF%94%E5%9B%9E%E4%B8%AD%E6%96%87Unicode%E7%BC%96%E7%A0%81%E7%9A%84%E9%97%AE%E9%A2%98/</id>
    <link href="https://iyuluo.com/2024/09/12/%E8%A7%A3%E5%86%B3Flask%E8%BF%94%E5%9B%9E%E4%B8%AD%E6%96%87Unicode%E7%BC%96%E7%A0%81%E7%9A%84%E9%97%AE%E9%A2%98/"/>
    <published>2024-09-12T02:10:26.000Z</published>
    <summary>
      <![CDATA[<p>当Flask实现的接口，返回的数据中有中文存在的时候，会默认显示为Unicode编码。<br>这个就是ASCII码，用转换工具能够显示原来的。<br>之前可以使用如下方式解决</p>
<figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><code class="hljs ptyhon">app = Flask（__name__）<br>app.config［&#x27;JSON_AS_ASCII&#x27;］ = False ＃ 解决中文乱码问题<br></code></pre></td></tr></table></figure>

<p>但是最新的不行了</p>]]>
    </summary>
    <title>解决Flask返回中文Unicode编码的问题</title>
    <updated>2024-09-12T02:15:03.953Z</updated>
  </entry>
  <entry>
    <author>
      <name>yuluo</name>
    </author>
    <category term="Life" scheme="https://iyuluo.com/categories/Life/"/>
    <category term="Baby" scheme="https://iyuluo.com/tags/Baby/"/>
    <content>
      <![CDATA[<p>从知道她有了的时候，两个人的诧异和紧张；</p><p>到孕期各种检查，身体不舒服；</p><p>到终于的生出来的那一刻；</p><p>不能说五味杂陈，只能说感慨颇多。</p><span id="more"></span><p>小家伙在肚子里面的时候，就是一个调皮的娃，各种扑腾。</p><p>家里人问我们起名字，我们商量好了，写几个名字，让小家伙到时候自己抓一个就行。</p><p>本来要等40周，可惜检查的时候医生说生长太慢了，就提前入院，检查，打针，等待，然后生产。好歹整个过程还算顺利。</p><p>等待的时候，护士问她要不要让我陪产，她说我害怕，就别让我进来了。我犹豫了半天，终于还是进去了。不知道怎么形容整个过程，她说我的汗比她还多。</p><p>生出来之后，医生没有让我剪脐带，但是登记好之后，让我抱抱小家伙。</p><p>一到手里，第一感觉，沉甸甸的小家伙。满身胎脂，眼睛看来看去，动来动去的，看着真的是让人开心，非常单纯得开心。</p><p>但其实到现在还是没感觉到什么父爱，问她也不知道啥叫母爱。小家伙从最开始哼哼唧唧，到后来饿得难受，哭得那个样子，看得人确实心疼。两个人在医院真的被小家伙折磨够呛，虽然已经算很乖的宝宝了，但是哭、闹、哼哼唧唧都让两个人没法彻底睡着。</p><p>都在慢慢学着哄娃，出生之前跟小家伙说，只希望你健康。</p><p>现在看着小家伙熟睡样子，加一句，希望你健康，也希望你快乐。</p>]]>
    </content>
    <id>https://iyuluo.com/2024/06/12/%E5%B0%8F%E5%AE%B6%E4%BC%99%E7%BB%88%E4%BA%8E%E5%87%BA%E6%9D%A5%E4%BA%86/</id>
    <link href="https://iyuluo.com/2024/06/12/%E5%B0%8F%E5%AE%B6%E4%BC%99%E7%BB%88%E4%BA%8E%E5%87%BA%E6%9D%A5%E4%BA%86/"/>
    <published>2024-06-12T08:47:45.000Z</published>
    <summary>
      <![CDATA[<p>从知道她有了的时候，两个人的诧异和紧张；</p>
<p>到孕期各种检查，身体不舒服；</p>
<p>到终于的生出来的那一刻；</p>
<p>不能说五味杂陈，只能说感慨颇多。</p>]]>
    </summary>
    <title>小家伙终于出来了</title>
    <updated>2024-09-12T02:44:40.498Z</updated>
  </entry>
  <entry>
    <author>
      <name>yuluo</name>
    </author>
    <category term="Front-Dev" scheme="https://iyuluo.com/categories/Front-Dev/"/>
    <content>
      <![CDATA[<p>在HTTP请求中，Content-Type是一个HTTP头部字段，用于指示资源的MIME类型（媒体类型）。它在POST和PUT请求中特别重要，因为这些请求通常会发送数据给服务器，而Content-Type告诉服务器如何解析这些数据。以下是几种常见的Content-Type类型及其简要分析：</p><span id="more"></span><ol><li>text&#x2F;plain</li></ol><p>用途：这是最基本的MIME类型，用于传输未经格式化的纯文本。它可以用于发送简单的文本信息，比如纯文本的电子邮件。<br>特点：由于text&#x2F;plain类型的数据不包含任何文本格式（如字体、颜色、HTML标签等），它通常不适用于需要丰富格式的文本传输。这种格式的数据易于人类阅读，但不适合复杂的数据交互。</p><ol start="2"><li>text&#x2F;html</li></ol><p>用途：用于传输HTML格式的文档，这种类型的数据经常被Web服务器用来发送网页到浏览器。<br>特点：text&#x2F;html类型的数据可以包含HTML标签和脚本，这些标签和脚本使得文档具有结构化和交互性。浏览器会解析这些标签来渲染出用户可交互的网页。</p><ol start="3"><li>application&#x2F;json</li></ol><p>用途：用于传输JSON（JavaScript Object Notation）格式的数据。JSON是一种轻量级的数据交换格式，现在广泛用于网络上的数据交换和Web API。<br>特点：JSON格式易于人阅读和编写，同时也易于机器解析和生成。它基于JavaScript的对象字面量语法，但独立于语言，许多编程语言都有解析JSON的能力。</p><ol start="4"><li>application&#x2F;x-www-form-urlencoded</li></ol><p>用途：这是在提交HTML表单时最常用的内容类型。当表单提交时，表单内的数据会被编码为键值对，如key1&#x3D;value1&amp;key2&#x3D;value2。<br>特点：它是一种简单的编码机制，但不适合传输大型的二进制数据或非常复杂的结构化数据。</p><ol start="5"><li>multipart&#x2F;form-data</li></ol><p>用途：用于向服务器上传文件。在表单中使用enctype&#x3D;”multipart&#x2F;form-data”时，表单数据会被分割成多个部分，每个部分对应一个表单控件或文件的内容。<br>特点：这种类型特别适合传输文件或大量数据。每个部分都可以有自己的Content-Type，允许混合传输不同类型的数据。</p><ol start="6"><li>application&#x2F;xml 或 text&#x2F;xml</li></ol><p>用途：用于传输XML（Extensible Markup Language）格式的数据。XML是一种标记语言，用于编码文档以结构化的方式。<br>特点：XML文档是自描述的，可以包含它们自己的元数据。它们是可扩展的，允许定义自定义标签。XML被设计用来传输和存储数据，具有很强的自描述性和可扩展性。</p><ol start="7"><li>application&#x2F;octet-stream</li></ol><p>用途：用于传输二进制流数据。这种类型通常用于下载文件或发送软件和二进制文件。<br>特点：由于application&#x2F;octet-stream是一个通用的二进制格式，客户端通常不会尝试解析它，而是直接下载或通过特定应用程序处理。这使得它非常适合传输不应该被直接显示的文件或数据。<br>选择正确的Content-Type对于确保HTTP请求和响应被正确解析和处理是至关重要的。服务器和客户端都依赖这个头部信息来确定如何处理传输的数据。</p>]]>
    </content>
    <id>https://iyuluo.com/2024/03/22/Content-Type%E7%9A%84%E4%B8%8D%E5%90%8C%E7%B1%BB%E5%9E%8B%E7%9A%84%E5%8C%BA%E5%88%AB/</id>
    <link href="https://iyuluo.com/2024/03/22/Content-Type%E7%9A%84%E4%B8%8D%E5%90%8C%E7%B1%BB%E5%9E%8B%E7%9A%84%E5%8C%BA%E5%88%AB/"/>
    <published>2024-03-22T14:06:52.000Z</published>
    <summary>
      <![CDATA[<p>在HTTP请求中，Content-Type是一个HTTP头部字段，用于指示资源的MIME类型（媒体类型）。它在POST和PUT请求中特别重要，因为这些请求通常会发送数据给服务器，而Content-Type告诉服务器如何解析这些数据。以下是几种常见的Content-Type类型及其简要分析：</p>]]>
    </summary>
    <title>Content-Type的不同类型的区别</title>
    <updated>2024-03-22T14:07:52.692Z</updated>
  </entry>
  <entry>
    <author>
      <name>yuluo</name>
    </author>
    <category term="Python" scheme="https://iyuluo.com/categories/Python/"/>
    <category term="Python" scheme="https://iyuluo.com/tags/Python/"/>
    <content>
      <![CDATA[<p>需要 Python 虚拟环境管理工具的原因有很多，其中包括：</p><p>• 隔离项目依赖项： 每个项目可能需要不同的库和 Python 版本。通过使用虚拟环境，可以将每个项目的依赖项隔离在自己的环境中，避免冲突和版本问题。<br>• 提高开发效率： 虚拟环境可以让你快速地在不同的项目之间切换，而无需担心依赖项冲突或版本问题。<br>• 简化部署： 如果你将项目部署到不同的服务器上，那么你可以使用虚拟环境来确保每个服务器上都安装了正确的依赖项。<br>• 提高安全性： 虚拟环境可以帮助开发者隔离不安全的代码或库，防止它们影响其他项目或系统。</p><p>现在Python环境有三个用的比较多的，分别是<br>• virtualenv<br>• ven<br>• pipenv</p><span id="more"></span><h3 id="联系"><a href="#联系" class="headerlink" title="联系"></a>联系</h3><p>• virtualenv、venv 和 pipenv 都是 Python 的虚拟环境管理工具。<br>• 它们都可以用来创建和管理独立的 Python 环境，可以在不同的项目中使用不同的 Python版本和库。</p><h3 id="区别"><a href="#区别" class="headerlink" title="区别"></a>区别</h3><p>• virtualenv 是最古老的 Python 虚拟环境管理工具。它需要开发者明确地创建和管理虚拟环境。<br>• venv 是 Python 3.3 中内置的虚拟环境管理工具。它比 virtualenv 更易于使用，因为它不需要你明确地创建和管理虚拟环境。<br>• pipenv 是一个更高级的 Python 虚拟环境管理工具。它不仅可以创建和管理虚拟环境，还可以管理项目依赖项。</p><p>表格对比：</p><table><thead><tr><th>特性</th><th align="left">virtualenv</th><th>venv</th><th>pipenv</th></tr></thead><tbody><tr><td>创建时间</td><td align="left">2007</td><td>2013</td><td>2018</td></tr><tr><td>内置于 Python 中</td><td align="left">否</td><td>是</td><td>否</td></tr><tr><td>管理依赖项</td><td align="left">否</td><td>否</td><td>是</td></tr><tr><td>易用性</td><td align="left">较难</td><td>较容易</td><td>最容易</td></tr></tbody></table><p>总体来说，如果只是想创建一个独立的 Python 环境，那么 venv 是一个不错的选择。如果想管理项目依赖项，那么 pipenv 是一个更好的选择。</p><h3 id="何时使用"><a href="#何时使用" class="headerlink" title="何时使用"></a>何时使用</h3><p>何时使用 virtualenv、venv 或 pipenv？</p><p>• 使用 virtualenv：<br>    • 需要对虚拟环境有更精细的控制。<br>    • 需要在旧版本的 Python 上创建虚拟环境。<br>• 使用 venv：<br>    • 想轻松地创建和管理虚拟环境。<br>    • 不需要管理项目依赖项。<br>• 使用 pipenv：<br>    • 想管理项目依赖项。<br>    • 想要一个更易于使用的虚拟环境管理工具。</p><p>请注意，pipenv 仍然是一个相对较新的工具，可能不如 virtualenv 和 venv 稳定。但是，它具有许多有用的特性，并且正在迅速发展。</p>]]>
    </content>
    <id>https://iyuluo.com/2024/02/04/Python%E4%B8%8Bvirtualenv-venv-pipenv-%E4%B9%8B%E9%97%B4%E7%9A%84%E8%81%94%E7%B3%BB%E4%B8%8E%E5%8C%BA%E5%88%AB/</id>
    <link href="https://iyuluo.com/2024/02/04/Python%E4%B8%8Bvirtualenv-venv-pipenv-%E4%B9%8B%E9%97%B4%E7%9A%84%E8%81%94%E7%B3%BB%E4%B8%8E%E5%8C%BA%E5%88%AB/"/>
    <published>2024-02-04T07:36:32.000Z</published>
    <summary>
      <![CDATA[<p>需要 Python 虚拟环境管理工具的原因有很多，其中包括：</p>
<p>• 隔离项目依赖项： 每个项目可能需要不同的库和 Python 版本。通过使用虚拟环境，可以将每个项目的依赖项隔离在自己的环境中，避免冲突和版本问题。<br>• 提高开发效率： 虚拟环境可以让你快速地在不同的项目之间切换，而无需担心依赖项冲突或版本问题。<br>• 简化部署： 如果你将项目部署到不同的服务器上，那么你可以使用虚拟环境来确保每个服务器上都安装了正确的依赖项。<br>• 提高安全性： 虚拟环境可以帮助开发者隔离不安全的代码或库，防止它们影响其他项目或系统。</p>
<p>现在Python环境有三个用的比较多的，分别是<br>• virtualenv<br>• ven<br>• pipenv</p>]]>
    </summary>
    <title>
      <![CDATA[Python下virtualenv & venv & pipenv 之间的联系与区别]]>
    </title>
    <updated>2024-02-04T07:41:40.295Z</updated>
  </entry>
</feed>
