当代码实现不再是瓶颈:技术领导者的「上下文治理」与协作演进

<blockquote><strong>作者</strong>:祁宁(@Joyqi),MindMux.ai 联合创始人兼CTO、SegmentFault 创始人前CTO,Apache Answer PMC 主席。<br><strong>背景</strong>:本文整理自作者在全球技术领导者峰会 GTLC 杭州站的主题演讲</blockquote><hr><p><img width="723" height="411" src="/img/bVdphHU" alt="" title=""></p><h2>引言</h2><p>过去一年多,几乎所有的技术团队都在做一件事:<strong>给工程师配 AI 助手。</strong></p><p>写函数、补测试、重构单例、迁移接口……AI 几乎无所不能。从数据上看,个人效能指标被拉得极陡,代码产出呈指数级翻倍。</p><p>但如果你去问一问那些坐在 CTO、技术 VP 或架构师位子上的技术领导者,他们真实的感受往往不是轻松,而是前所未有的焦虑:</p><blockquote><strong>"代码写得比过去任何时候都快,但系统好像比过去任何时候都更容易失控。"</strong></blockquote><p>这正是当下技术管理正在撞上的一堵"新墙":<strong>效能繁荣,管理失控。</strong></p><hr><h2>一、效能繁荣背后的"剪刀差"</h2><p><img width="723" height="411" src="/img/bVdphH5" alt="" title=""></p><p>在传统研发模式下,代码实现是最昂贵的瓶颈。只要把代码写出来,系统就能往前推进。</p><p>然而在 AI 时代,我们正在经历一条残酷的"剪刀差"曲线:</p><ul><li><strong>个人效能</strong>:在 AI 驱动下高歌猛进;</li><li><strong>系统可控性</strong>:随着代码库的无序膨胀急剧下滑。</li><li><strong>这两条线分叉拉开的区域,就是由于上下文丢失导致的"失控区"与"风险敞口"。</strong></li></ul><p>写代码不再是瓶颈,我们却撞上了新的系统级症状:</p><h3>症状一:上下文失忆(Context Amnesia)</h3><p><img width="723" height="411" src="/img/bVdphIc" alt="" title=""></p><p>AI Agent 默认是无状态的。每次新开一个对话(Session),它的背景就清零。</p><p>昨天定好的架构方案,它不知道;明确划过的设计红线,它一次次重新触碰。为了让它干活,技术 Leader 或工程师不得不陷入"一遍又一遍复述背景、对齐约束"的无效内耗中。</p><h3>症状二:盲目堆砌,加速腐化</h3><p><img width="723" height="411" src="/img/bVdphIg" alt="" title=""></p><p>AI 是极度勤奋的,但它只看局部。</p><p>单看 AI 写下的每一行代码,逻辑都很通顺、语法都极其优雅。但没有全局意图约束的 AI,会把该复用的重写、该收敛的发散。</p><p>这种<strong>"局部最优化,全局灾难化"</strong>的堆砌(整齐 → 错位 → 腐化),把过去需要数年才会发生的系统腐化过程,生生压缩到了几周。</p><p>它很努力,但它不知道你的 <strong>「不要」</strong>。而架构的本质,恰恰是一门关于约束和"不要"的艺术。</p><hr><h2>二、实现不再是瓶颈,对齐才是</h2><p><img width="723" height="411" src="/img/bVdphIF" alt="" title=""></p><p>面对这些症状,我们需要得出一个最核心的诊断:</p><blockquote><p><strong>实现,不再是瓶颈。对齐,才是。</strong></p><p><em>Writing Code is cheap. Aligning Intent & Context is the new bottleneck.</em></p></blockquote><p>当"写代码"的实现成本无限逼近于零,技术领导者核心关注点必须发生根本性位移:<strong>过去,你管理代码库;现在,你必须开始管理项目的「决策记忆」。</strong></p><p>什么是决策记忆?</p><p><img width="723" height="411" src="/img/bVdphIP" alt="" title=""></p><ul><li><strong>代码库</strong>(正在贬值):回答的是 <em>"怎么做"</em>,随时可以被 AI 重新生成。</li><li><strong>决策记忆</strong>(正在升值):回答的是 <em>"为什么这样做"</em> —— 为什么选方案 A 而否决方案 B?哪条业务约束是不能碰的?哪些方向是明确排除的非目标?</li></ul><p>过去,这些最昂贵的记忆只活在工程师的脑子里、散落在无从考证的聊天记录中。一旦人走、群散、会话关,决策背景立刻烟消云散。在 AI 时代,这相当于在技术资产上进行"主动失忆"。</p><hr><h2>三、治理上下文:从 Discuss 到 Dispatch 的闭环</h2><p>光知道要管不够,关键是"怎么管"。</p><p>上下文治理无法通过在聊天框里给 AI 灌输几句口头警告来解决,它需要一套能够闭环的流动系统。在 MindMux 的实践中,我们将其总结为:<strong>Discuss(讨论) → Distill(提炼) → Dispatch(分发)</strong> 的三段闭环。</p><p><img width="723" height="411" src="/img/bVdphIT" alt="" title=""></p><ol><li><strong>Discuss(充分讨论)</strong>:在 Chat 里,人与 AI 共同碰撞方案、摸索边界、进行权衡取舍。</li><li><strong>Distill(提炼共识)</strong>:这是最关键、也最容易被团队偷懒跳过的一步。把讨论清楚的决策,从对话流中提炼出来,写回项目的"大脑(Brain)",固化为结构化的真相与约束。<strong>没有提炼,讨论再多也只是熵增。</strong></li><li><strong>Dispatch(分发执行)</strong>:当要分发任务时,不再是丢给 Agent 一句模糊的需求,而是将沉淀好的上下文与约束编译成结构化 Task,让 Agent 在清晰的边界内做高精度执行。</li></ol><p>最后,执行的成果再次回流,滋养并更新项目大脑,开启下一轮良性循环。</p><hr><h2>四、Project Brain:把决策记忆变成一等资产</h2><p><img width="723" height="411" src="/img/bVdphIW" alt="" title=""></p><p>为了支撑这套闭环,我们提出了 <strong>Project Brain(项目大脑)</strong> 的概念。它不是传统堆砌出来的 Wiki 或 Readme,而是一套<strong>人与 AI 共同消费的决策记忆体</strong>:</p><h3>物理结构</h3><ul><li><strong>6 个固定 Root Pages(根页面)</strong>:<code>background</code>(背景)、<code>architecture</code>(架构)、<code>flow</code>(关键流程)、<code>mindmap</code>(功能脑图)、<code>stack</code>(技术栈)、<code>roadmap</code>(路线图)。它们管理全局,进 Git 版本库,像代码一样留痕。</li><li><strong>无限增长的 Pages(页面)</strong>:管理具体的 <code>decision</code>(决策)、<code>concept</code>(概念定义)、<code>reference</code>(参考资料)和 <code>person</code>(团队上下文)。</li></ul><h3>页面内部结构(编译真相 + 时间线分离)</h3><p>一个高价值的 Brain Page 内部应该分为两层:</p><ul><li><strong>compiled_truth(当前真相)</strong>:只写当前团队对这个问题的"最新最佳共识"。它是可重写的,保持绝对干净,作为 AI 随时读取的"运行上下文"。</li><li><strong>timeline(演进时间线)</strong>:只增不改(Append-only)。记录这个真相一路上是如何演进、推翻、修正过来的。</li></ul><p><strong>核心特性</strong>:</p><ul><li><strong>Local-First</strong>:本地优先,数据自主</li><li><strong>Git 版本管理</strong>:像代码一样留痕</li><li><strong>人与 AI 共同消费</strong>:人能读懂,Agent 能执行</li></ul><p><img width="723" height="411" src="/img/bVdphI2" alt="" title=""></p><p>这套方法被做成了两样东西:</p><ol><li><strong>开放标准(<a href="https://link.segmentfault.com/?enc=KnbLrRFzgsrBnHWcaR61DA%3D%3D.ngfvpD5QN7ZVsAC2abcxh7S%2Fz0zA7eiedhN6x1UOYe4%3D" rel="nofollow">BRAIN.md / projectbrain.md</a>)</strong>:已开源(Apache-2.0),把决策记忆沉淀成纯 Markdown,一个 CLI 读写,装进现有 Agent 即用。</li><li><strong>本地工作台(<a href="https://link.segmentfault.com/?enc=WXHirvqR1oyp1NasO7ap%2BQ%3D%3D.9AGFx%2BhL%2B5jB%2FK%2Fp3S5uaimzS4IaLcJyqMzCnLhVACY%3D" rel="nofollow">MindMux</a>)</strong>:以决策记忆为核心的本地 AI 工作台 App,内测中即将开源。</li></ol><hr><h2>五、真实案例:目标可以移动,但必须被"有意识地"移动</h2><p><img width="723" height="411" src="/img/bVdphI6" alt="" title=""></p><p>这套方法,是我们用它自己(<a href="https://link.segmentfault.com/?enc=oAzYZiamB0yEyLswPRracg%3D%3D.vzbPGKBAsb%2BN0KYpgPC6r0oAiUem4ACd%2FuEEQMr0c%2Fg%3D" rel="nofollow">MindMux</a>)造它自己时迭代出来的。</p><p>在研发初期,我们给 MindMux 定下的原始愿景极其克制:<strong>只做一层旁挂在现有工具之下的"项目知识记忆层",坚决不做聊天客户端,不做完整 AI 工作台。</strong></p><p>但随着真实研发推进,我们在 timeline 里发现,代码演化开始逐渐失去控制:为了解决摩擦,我们加了 Chat,加了 Task,加了 Runtime 抽象。回过头来 audit 时,发现 <strong>85% 的代码实际上都在做一个完整的 AI 工作台。</strong></p><p>表面上看,这是一个典型的"项目失控漂移"案例。</p><p>但神奇的是,当大脑里的原始意图和"刻意不做什么"的约束,被项目大脑不断重新甩回我们面前时,它逼着我们正面对峙:</p><blockquote>"你当初白纸黑字写了不做聊天客户端,现在冲突了。你确定要改吗?"</blockquote><p>项目大脑没有顺着我们的偷懒去改原始目标,它扮演了"主动锚定"的角色。</p><p>经过团队严肃的讨论,我们确认:这并非盲目漂移,而是研发摩擦力逼着我们做的自然演化。于是,我们<strong>有意识地</strong>重写了 <code>compiled_truth</code>,把定位正式升级为"以决策记忆为核心的本地 AI 工作台",并将这一段戏剧性的推翻与思考,完整追加进 <code>timeline</code>。</p><p><strong>这就是上下文治理的魅力:目标可以移动,但它只能被"有意识地"移动,不能悄悄漂走。</strong></p><h3>一次真实的闭环长什么样</h3><p><img width="723" height="411" src="/img/bVdphI7" alt="" title=""></p><p>上图展示了一次从讨论到执行的完整流程——中间没有一次"重新解释背景":</p><ol><li>在 Chat 里充分讨论后,将结论沉淀为决策 Page;</li><li>决策 Page 一键编译成带完整上下文的 Task,直接派给 Agent(如 Claude Code 或 Codex)执行。</li></ol><p>这就是闭环的价值所在。</p><hr><h2>六、协作重构:从「分配任务」到「想法广播」</h2><p>当你真正开始治理上下文,你会发现,变的不只是开发效率,更是<strong>整个团队的协作范式</strong>。</p><p>过去我们习惯用看板(Kanban)进行协作,核心动作是"自上而下拆任务、指派、跟踪进度"。在实现成本昂贵的年代,这很合理。</p><p>但当代码实现已经趋近免费,"派活和管理"本身反而成了研发链路里最大的成本。看板没失效,是它管的那个瓶颈(实现能力)消失了。</p><p>新的第一协作动作,应当是 <strong>「广播想法」(Idea Broadcasting)</strong>。</p><p><img width="723" height="411" src="/img/bVdphJb" alt="" title=""></p><p>在 AI 时代,<strong>隐藏在员工脑子里、没有说出来的想法,是最大的浪费。</strong></p><p><strong>旧模式(树状单向派发)</strong>:信息由 Leader 拆解,像树枝一样单向往下派发,反馈链路极长。</p><p><strong>新模式(环状循环增值)</strong>:</p><ol><li>任何人有了关于项目的想法、痛点、隐患,第一动作是<strong>"广播"</strong>到 Brain / Timeline 里,先让团队和 AI 知道它的存在。</li><li>想法在项目大脑里自由碰撞,收敛出可行性。</li><li>有热情的团队成员<strong>"主动认领"</strong>,将共识一键编译成分发任务。</li><li>交给 Agent(如 Claude Code 或 Codex)高效执行。</li><li>执行成果回流大脑,触发下一轮演进。</li></ol><p><strong>协作从一棵僵死的"派发树",变成了一个持续自我增值的"环"。</strong></p><h3>两种协作范式的区别</h3><table><thead><tr><th align="left">维度</th><th align="left">传统派发</th><th align="left">想法广播</th></tr></thead><tbody><tr><td align="left"><strong>第一动作</strong></td><td align="left">拆解 + 指派</td><td align="left">广播想法</td></tr><tr><td align="left"><strong>驱动力</strong></td><td align="left">上级指派,被动接受</td><td align="left">信息透明,自愿认领</td></tr><tr><td align="left"><strong>稀缺资源</strong></td><td align="left">执行人力</td><td align="left">对的想法 + 注意力</td></tr><tr><td align="left"><strong>沉淀物</strong></td><td align="left">进度报表</td><td align="left">决策记忆(大脑)</td></tr></tbody></table><p>这不是说传统任务管理明天就会消失,而是说当执行不再是最稀缺的资源,技术领导者需要把更多注意力从"谁在干什么"转移到"什么值得被做,以及为什么"。</p><hr><h2>七、总结:给技术领导者的三件事</h2><p><img width="723" height="411" src="/img/bVdphJg" alt="" title=""></p><p>代码会越来越便宜,而你为什么这么做,只会越来越贵。</p><p>在这个代码实现狂飙、架构却极易腐化的时代,作为 CTO 或技术 Leader,请守住那条最关键的分工红线 —— <strong>「人定方向,Agent 跑执行」</strong>。并着手做好这三件事:</p><ol><li><strong>管理上下文,别只盯代码库</strong>:不要再用代码行数、commits 数量来衡量技术资产。开始投资你们的"决策记忆",给项目构建专属的 Project Brain。</li><li><strong>把文档当"可运行的代码"来写</strong>:未来的 AI Agent 会严格读取并执行你的架构规范、决策红线。文档质量直接决定了 AI 产出代码的质量。</li><li><strong>推动团队扁平化</strong>:打破自上而下的任务指派链条,在团队内构建"广播想法 → 主动认领 → Agent 自动执行"的极客文化。</li></ol><p><strong>记住,你的护城河不是代码写得多快,而是让正确的上下文一次都不丢失。</strong></p><hr><p><img width="723" height="411" src="/img/bVdphJj" alt="" title=""></p>

2026/6/28
阅读更多

我是如何用一个 /brain 文件夹,让 Claude Code 和 Cursor 读懂你三周前下的技术决策?

<p><img width="723" height="411" src="/img/bVdpaVB" alt="image.png" title="image.png"></p><p>每次开一个新对话都要重新解释一遍背景,这件事真的很烦。</p><p>不是因为模型不够聪明,而是因为大多数 AI 工具保存的是<strong>聊天记录</strong>,不是<strong>项目里已经想清楚的东西</strong>。</p><p>你上周花了两个小时讨论技术方案,今天换了个 session、换了台电脑,或者从 Claude 切到 Codex,AI 还是会一本正经地问你:这个项目是做什么的?为什么不用方案 B?这个决定之前讨论过吗?</p><p>而 <strong><a href="https://link.segmentfault.com/?enc=eMOjJsgz56G9jSozfn18CQ%3D%3D.2XCLhi0l0a7yGK6O0wpI1IYUoZKv3ekeDhyP0pDIrwc%3D" rel="nofollow">MindMux</a></strong> 的出发点,就是想解决这个问题。</p><p>MindMux 更关心的是另一件事:<strong>怎么把一次次讨论,慢慢沉淀成一个不会失忆的项目大脑。</strong></p><p>这个 <code>brain/</code> 是本地的,是 Markdown 的,是可以跟着项目走的。你可以放进 git,可以换模型读,可以在不同电脑上继续用。下一次对话开始时,AI 读到的不是一串零散聊天记录,而是你们之前已经确认过的背景、决策、约束和路线。</p><p>这篇文章我想要说说:<strong>我现在是怎么用 <a href="https://link.segmentfault.com/?enc=%2FYAVQFpMHOXR3e7fN9Y%2FTA%3D%3D.RbWzzkdcPzle7OAf1JzcLKPd93%2BhtoxYhEop4vhGYOA%3D" rel="nofollow">MindMux</a> 推进一个项目的。</strong></p><h2>我现在的工作流,基本是三步</h2><p><img width="723" height="411" src="/img/bVdpaVE" alt="image.png" title="image.png"></p><h3>第一步:先聊,不急着立刻开工</h3><p>新项目刚开始的时候,最容易犯的错不是写得慢,而是太快开始写。</p><p>我一般会先在 MindMux 里围着同一个主题聊几轮。可能是产品方向,可能是技术方案,可能是 roadmap,也可能只是为了把一个模糊想法讲清楚。</p><p>这一步最重要的不是“得到答案”,而是把几个关键问题聊透:</p><ul><li>这件事到底要解决什么问题?</li><li>哪些方向我们已经明确不做?</li><li>技术上有哪些选项,为什么选这个,不选那个?</li><li>这一版先做到哪里就够了?</li></ul><p>你可以把它理解成先积累几个稳定的判断,再进入执行。</p><p>在 MindMux 里,这些讨论不会只停留在聊天窗口里。等一轮对话收敛之后,它会被提炼进 <code>brain/</code>:有的是项目级文档,有的是单独的 decision page,有的是背景和约束,还有的是以后做任务时必须带上的上下文。</p><p>这里有一个很关键的区别:</p><p><strong>聊天记录是过程,brain 是结论。</strong></p><p>MindMux 不想保存一切原话,MindMux 想保存的是以后还会反复用到的东西。</p><h3>第二步:开始做,但把高频动作收成固定套路</h3><p>等需求和方向比较清楚之后,才进入开发。</p><p>这时候我不想每次都用自然语言重新描述一遍“现在请你整理需求”“现在请你总结背景”“现在请你准备交接上下文”。这些高频动作应该被固化成稳定的工作流。</p><p>所以在 MindMux 里,slash command 不是一个花哨的输入法功能,它本质上是 <strong>skill 的入口</strong>。</p><p><img width="723" height="411" src="/img/bVdpaVJ" alt="image.png" title="image.png"></p><p>今天内置的是像 <code>/background</code>、<code>/ingest</code>、<code>/handoff</code> 这样的能力。它们分别对应:</p><ul><li>快速补全项目背景</li><li>把一轮讨论里已经想清楚的东西提炼进 brain</li><li>把上下文打包交给下游 agent</li></ul><p>而再往前一步,完全可以继续长出更贴近团队习惯的命令,比如:</p><ul><li><code>/feat</code>:把一个新需求整理成可执行描述</li><li><code>/fix</code>:把一个 bug 的上下文、边界和验收条件收拢清楚</li><li><code>/review</code>:在看改动前先把相关历史决策拉出来</li><li><code>/commit</code>:在提交前回看这次工作到底改变了什么判断</li></ul><p>这些命令不是凭空长出来的,它们本质上都是通过 MindMux 的 <strong>创建技能</strong> 能力生成的。也就是说,当你发现某个动作会反复发生,就可以把它整理成一个可复用的 skill,保存下来,直接变成一个新的 slash command。</p><p>这样做不是为了“更像 IDE”,而是为了把一类重复动作变成统一协议。你不用每次重新组织语言,AI 也不用每次重新猜你的意图。</p><p>当讨论已经收敛成一件明确的工作时,MindMux 会把它派发出去。它自己不替你写完所有代码,而是把相关的 <strong>背景、约束和验收标准</strong> 一起交给 Claude Code、Codex 这类下游执行者。</p><p>这件事非常重要,<strong>因为很多项目不是死在“不会做”,而是死在“开始做的时候已经忘了为什么这么做”</strong>。</p><h3>第三步:做完一轮,再回到项目大脑</h3><p>这是我最喜欢的一步。</p><p>很多工具都能帮你开始,但很少有工具认真处理“做完之后怎么办”。</p><p>现实里的开发不是线性的。你做完一个需求,用户会给反馈;你修完一个 bug,会发现之前的判断要调整;你推进 roadmap,会发现某个方向其实应该砍掉。</p><p>如果这些变化最后只留在新的聊天记录里,那过一周它们还是会丢。</p><p>所以我的习惯是:每推进完一轮,就通过已经创建好的 skill 回到 brain,把这次真正改变了什么写清楚。</p><p>不是写流水账,而是借助这些 skill 更新这些内容:</p><ul><li>我们现在确认的目标是什么</li><li>哪个设计已经定了</li><li>哪个方向被否了</li><li>哪个约束比之前更重要了</li><li>下一次讨论从哪里接着来</li></ul><p>这也是为什么 MindMux 里的 page 不是一坨随便记的笔记。它有一层 <code>compiled_truth</code>,表示当前最稳的理解;也有一层 <code>timeline</code>,表示这些理解是怎么一步步形成的。</p><p>前者帮你继续工作,后者帮你回头追溯。</p><h2>为什么这么在意项目大脑</h2><p>因为真正让项目变慢的,很多时候不是编码速度,而是<strong>上下文丢失</strong>。</p><p><img width="723" height="411" src="/img/bVdpaVZ" alt="image.png" title="image.png"></p><p>没有 brain 的时候,你会经常遇到这些情况:</p><ul><li>开新会话时,得重新讲一次之前的需求</li><li>换模型时,之前讨论过的限制条件全没了</li><li>一周后回来看,已经不记得当时为什么选这个方案</li><li>想把任务交给另一个 agent,结果又得重新整理一遍背景</li></ul><p>这些动作单看都不大,但它们会稳定地吞掉注意力。</p><p>而一旦有了 <code>brain/</code>,整个感觉会很不一样。</p><p>你不是在维护一个“更长的聊天历史”,你是在维护一个<strong>项目自己的长期记忆</strong>。</p><p>它有我认为特别重要的几个特点:</p><ul><li>它不是绑死在某个模型里的 memory,而是一个独立存在的文件夹</li><li>它不是黑盒数据库,而是普通 Markdown</li><li>它不是只服务一个 session,而是服务整个项目</li><li>它不是只能在一台机器上读,而是可以随着项目一起移动</li></ul><p>最直接的结果就是:<strong>方向不会轻易漂。</strong></p><p>尤其是在产品早期,这一点非常值钱。因为你会频繁讨论、频繁试错、频繁改变细节,但真正已经想清楚的大方向,不应该在每一次新对话里重新被打散。</p><h2>对我来说,MindMux 更像一个“不会失忆的讨论层”</h2><p>我现在越来越不把它看成一个“AI 客户端”,而是把它看成项目的一层长期记忆。</p><p>你可以接入不同的模型,但中间不变的是那份 brain。</p><p>你可以在这里讨论产品、整理方案、准备任务、回看历史判断,也可以把一段已经成熟的上下文交给下游去执行。最关键的是,不管你今天在哪个 session,明天在哪台电脑,后天换到哪个模型,只要还在同一个项目里,你就不是从零开始。</p><p>这就是 MindMux 的核心价值所在:它不是一个功能很多的东西。</p><p>而是一个真正能让你在做项目时少重复解释、少丢判断、少走回头路的东西。</p><h2>如果你第一次用 MindMux,我建议你这样开始</h2><p>很简单:</p><ol><li>选一个正在推进的真实项目,不要拿 demo 试。</li><li>先用它聊一轮你现在最卡的产品或技术问题。</li><li>讨论收敛后,把背景、关键决策和约束写进 brain。</li><li>下一次再开新会话时,不要重新描述,直接接着往下推进。</li><li>当一件事已经足够明确,再把它派发给下游 agent 去做。</li></ol><p>如果用完后,你第一次明显感觉到“这次我不用再重复讲一遍了”,那它就开始发挥作用了。</p><p><strong>有了 MindMux 之后,你能更专注于产品和技术本身,而不是每次都在重复解释和回忆。</strong></p><p>原文发布于 <em><a href="https://link.segmentfault.com/?enc=irqldtcz1f1juLKNm5gbIg%3D%3D.x6Ic315CzII4DWRlrRGJT0PYWkr60WMCdrFwUqUA2slKmE4SD61KueO%2FUw8sHWpZKX6Q5HVzMg1h6%2FDOgNuaQQ%3D%3D" rel="nofollow">MindMux Blog。</a></em></p>

2026/6/24
阅读更多

如何在单张 RTX 3090 上让 Qwen3.5-27B token 生成速度提升 6 倍

<p>本文系 trycua 团队的工程实践分享,Cua 是由该团队打造的一个面向 macOS 设计的开源 AI Agent 框架。下文采用第一视角来讲述他们在 RTX 3090 上的提速实践。</p><p>我们为 Qwen3.5-27B Q4_K_M 构建了一个独立的 C++/ggml 投机解码器(speculative decoder),并在 draft 阶段采用了 DFlash 的块扩散(block-diffusion)生成候选 token 块。</p><p><img src="/img/remote/1460000047736048" alt="01.gif" title="01.gif"></p><p>图解:Autoregressive vs DFlash</p><p>上方的演示视频显示了 DFlash 的运行速度为 207.6 tok/s,相比自回归 Autoregressive(下面简称 AR)快 5.46 倍;在 HumanEval 10-prompt 基准测试(包含 10 个 HumanEval prompt 的基准测试)中,当 DDTree budget = 22 时,单张 24 GB 的 RTX 3090 平均速度达到了 129.5 tok/s。这比自回归 Q4_K_M 基准快了 3.43 倍,比公开的 SGLang AWQ 最佳实践数据快了 2.8 倍。</p><h2>自回归 Autoregressive vs DFlash 性能对比</h2><p><strong>重点结论</strong></p><ul><li>演示视频(上图 gif)中最高 token 生成速度达到 207 tok/s,DFlash 峰值在 207.6 tok/s 而 AR 只是 38.0 tok/s,速度提升 5.46 倍。HumanEval 10-prompt 基准测试中,DDTree budget = 22 时,DFlash 的平均 token 生成速度为 129.5 tok/s。</li><li>相比 Q4_K_M 自回归基线,DFlash 的 129.5 tok/s 比 AR 的 37.78 tok/s 快 3.43 倍。</li><li>相比在同一张 RTX 3090 上记录的 SGLang AWQ 参考数据(46.6 tok/s),DFlash 要快 2.8 倍。</li><li>128K 上下文可容纳在 24 GB 显存中,我们采用了 Q4_0 KV 缓存 + 滚动的 4096-slot 目标特征(target feature)缓冲区的方式。HE 基准测试显示,当 ctx = 131,072 时,token 生成速度能达到 134.78 tok/s。</li><li>纯 ggml 实现,并未链接 libllama。推理引擎围绕 ggml_gated_delta_net 构建,其中包含约 2,000 行 C++/CUDA 代码,编译为 libdflash27b.a。</li></ul><p><img src="/img/remote/1460000047736049" alt="image.png" title="image.png"></p><h2>为什么我们要做这个测试</h2><p>Qwen3.5-27B 是一个混合架构模型:模型每 4 层插入一层完整的 softmax attention 层,其余层(64 层中的 48 层)是 Gated DeltaNet。它采用维度划分为 [11, 11, 10, 0] 的 M-RoPE 编码机制,24 个 Q attention head 和 4 个 KV attention head,其中 key/value head 维度为 256。在常规 KV 缓存之外,它还维护了一个 SSM 状态缓存。</p><p>这种架构组合,目前在单张 RTX 3090 上还没有很好的解码方案:</p><ul><li>llama.cpp 有 GGUF 加载器和 ggml_gated_delta_net,但没有 DFlash 投机解码。</li><li>vLLM / SGLang 都集成了 z-lab 的 DFlash,但仅支持 BF16 权重,这需要 54 GB 显存,无法在 24 GB 的 RTX 3090 上运行。此外,两种运行时都没有针对 Qwen3.5-27B 的 GGUF 方案;截至 2026 年 4 月,SGLang 上这个路径仍处于不可用状态。SGLang 上的 AWQ 目标模型在单张 RTX 3090 上,以纯自回归方式运行时速度为 46.6 tok/s,但 24 GB 显存装不下 BF16 draft 模型 + DDTree 树状态。</li><li>参考基准测试是在 NVIDIA B200 上运行的 BF16,这属于 54+ GB 显存级别的硬件环境。目前,没有公开的方案能适配 24 GB 的消费级显卡。</li></ul><p>我们希望在 24 GB 的消费级显卡上,尽可能地获得单张 3090 的最快解码速度。最终的方案是:只将计算图胶水代码(graph glue)移植到 ggml,保留现有的 DeltaNet 内核,运行带有 DDTree 验证器的 DFlash 块扩散 draft 模型,并将 KV cache 压缩为 Q4_0,以支持长上下文。</p><h2>架构设计</h2><p>这个库是针对一组固定的模型对(model pair)进行了硬编码:</p><p><img src="/img/remote/1460000047736050" alt="image 11.29.12.png" title="image 11.29.12.png"></p><p>使用贪心验证(Greedy verify),DFlash draft 的块大小设为 16,仅支持 CUDA,面向单张 RTX 3090 运行。构建过程会链接到 libggml*.a,完全不链接 libllama 的任何内容。因此,即使你删除掉 deps/llama.cpp/src/ 目录,这个库仍然能编译成功。</p><h2>代码结构</h2><p><img src="/img/remote/1460000047736051" alt="1280X1280 (1).PNG" title="1280X1280 (1).PNG"></p><p>Oracle(对照参考实现):PyTorch 参考实现位于 megaqwen3_27b_dflash/reference/dflash_reference.py,并与 z-lab AutoModel 的 forward 输出进行交叉验证,余弦相似度达到了 0.999812。</p><h2>从自回归到 DDTree</h2><p>这是在相同的 HumanEval 10-prompt 基准测试下的配置:n_gen=256,RTX 3090,目标模型 Q4_K_M,draft 模型 BF16。</p><p><img src="/img/remote/1460000047736052" alt="700693be-f692-422e-8800-56f5f1c5152f.png" title="700693be-f692-422e-8800-56f5f1c5152f.png"></p><p>第 1–5 行是历史调优记录:commit f1cb9bf,AR 基准为 37.44 tok/s。第 6 行是 2026 年 4 月 20 日在 commit 5bb7f8c 上的最新运行结果,AR 基准为 37.78 tok/s。</p><p><img src="/img/remote/1460000047736053" alt="image (1) 11.29.12.png" title="image (1) 11.29.12.png"></p><p>加速比是相对于同期的 AR 速度计算的:</p><ul><li>AL 为平均接受长度 average accept length,即每个验证步骤接受的 token 数量。DDTree 论文提到,纯 attention 机制的 Qwen3-4B/8B/30B-MoE(A100/B200,BF16),比链式 DFlash 提升 35–42%。在这次混合 Q4_K_M/RTX 3090 的组合中,我们观察到 DDTree 比链式 DFlash 提升了 15%。我们认为这个差距源于 Q4 量化抹平了 draft 模型的 softmax 分布,我们在 build_ddtree 中通过 chain pre-seed 做了部分修复。</li><li>在 f16 中间缓存下,对 budget 进行 20/30/40 的扫描,AL 稳定在 8.9 左右。budget 在 30 的 AL 为 8.86(120.49 tok/s),budget 在 40 的 AL 为 8.90(105.10 tok/s)。我们受限于 draft 精度的上限,而不是达到了验证阶段的内存瓶颈:更大的 DDTree 树也无济于事,只有更好的 draft 模型才能提升性能。</li></ul><h2>关键突破(浓缩版日志)</h2><ul><li>f16 中间缓存:内存宽带减半,在相同 DDTree Budget 下 tok/s 提升 5%。在 40 个 token 的测试中,使用 f16 中间缓存后的 DFlash 输出结果与自回归逐 bit 完全一致(bit-identical)。</li><li>持久化写入内核(ggml_gated_delta_net_tree_persist):每步跳过耗时约 9 ms 的 ggml_cpy,所有 prompt 的性能提升了 11%。</li><li>D2D draft target_feat 拷贝:消除了一次 GPU→CPU→GPU 的往返数据传输,cudaMemcpyAsync 每步节省约 3 ms,性能提升在 3.3%+。</li><li>OpenMP top-K 提取,K=32→8:draft_logits 步骤的开销降低了 7%。</li><li>树感知的 ggml_ssm_conv_tree:兄弟节点沿着父链收集各自的卷积窗口,而不是按 DFS 顺序收集。该实现移植了 SGLang 的 causal_conv1d_triton HAS_EAGLE_TREE_CUSTOM_ATTN_MASK 逻辑。</li><li>兄弟节点被接受后的 target_feat 压缩:修复了遍历树分支时过期的 draft 特征,在 HumanEval 10-prompt 中的 9 个 prompt 上真正地启用了 tree rescue。</li><li>budget = 15 且 K = 1 的快速路径:当不需要兄弟节点时,跳过耗时 11 ms 的 CPU top-K 提取。</li><li>extract_draft_topk 反转 bug:sort_heap+cmp_greater 本身已经生成降序结果;额外的 std::reverse 反而把最差的候选 token 送到了树的根节点,导致每步 accept = 1。一行代码修复了。</li><li>verify_logits_buf 溢出:缓冲区大小原本设为 vocab<em>q_len,但 DDTree 在 budget = 15 后会读取到 vocab</em>(budget+1)。这会导致静默内存损坏,同样通过一行代码修复了缓冲区大小问题。</li></ul><h2>24 GB 显存上的 128K 上下文</h2><p>ggml-cuda 中的 Flash-attention 原生支持 Q4_0 格式的 KV,因此 KV 缓存的压缩在写入时只要调用一次 ggml_cpy,并用内置的 F32→Q4_0 量化器完成写入量化。相比 f16,KV 缓存实现了 8 倍的压缩比。</p><p>结合滚动的 4096-slot target_feat 环形缓冲区,也就是写入时循环覆盖、读取时再环形边界处拆分读取。在 128K 上下文情况下,target_feat 从 6.6 GB 缩小到了 0.2 GB。整个方案使用同一个二进制文件,并通过环境变量切换配置:</p><p><img src="/img/remote/1460000047736054" alt="image (2).png" title="image (2).png"></p><p><img src="/img/remote/1460000047736055" alt="f59b21f3-bb7c-40b2-8a95-642f9de0cf86.png" title="f59b21f3-bb7c-40b2-8a95-642f9de0cf86.png"></p><p>这里有个权衡点:在短上下文下,Q4_0 格式的 KV 在 HE 上的质量会损失约 3%,AL 会从 8.56 降至 8.33,但在长上下文中,它的整体表现会显著提升。这是唯一能让 24 GB 显存塞进 128K 上下文的方法。</p><h2>Prefill 阶段</h2><ul><li>短 prompt(≤2048 tok):PREFILL_UBATCH 设定为 16 时,这个设置与 DFlash 的块大小和 chain-verify 阶段的 q_len 保持一致,可以尽量减少 Flash-attention tile 偏移。</li><li>长 prompt(&gt;2048 tok):系统会自动将 PREFILL_UBATCH 设为 192。对于 13K token 的 prefill,耗时从 40.9 s 降到 15.07 s,prefill 吞吐率提升了 2.7 倍,约为 913 tok/s。更大的批处理大小会导致 ggml_gated_delta_net 内部嵌入的中间状态区域出现 OOM。</li><li>可以通过环境变量 DFLASH27B_PREFILL_UBATCH=N 手动覆盖 UBATCH 设置。</li><li>如果你想达到完全与 llama.cpp 对齐的 prefill 速度(约 1500 tok/s),还需要一个跳过嵌入式 dst 区域的 no_inter 子变体,来解除 UBATCH &gt; 192 的限制。</li></ul><h2>后续计划</h2><ul><li>守护进程模式(Daemon mode):在多轮对话中让模型常驻内存,使首 token 延迟从 10 秒级降至毫秒级。目前,Chat REPL 和 OpenAI 服务器还是会在每次请求时重新拉起 test_dflash。</li><li>在验证路径中加入 Temperature / top-k 采样:目前,硬编码仅支持贪心解码;OpenAI 服务器上接收的 temperature / top_p 参数会被直接忽略掉。</li><li>支持 Q5_K_M / Q6_K 目标模型:相比 DDTree 论文中的 BF16,Q4_K_M 在逐位置接受率上损失了约 30 个点;如果有更高质量的 GGUF 量化版本能装进显存里,应该能恢复大部分接受率损失。</li><li>全面集成到 llama.cpp:新增 qwen35 架构支持,编写 llama-speculative-dflash.cpp,并打通 llama-cli / llama-server。</li><li>开发 no_inter 算子变体:用来解锁更大的 PREFILL_UBATCH 限制,上文提过目前上限为 UBATCH=192;对齐 llama.cpp 的全速 prefill 吞吐率,让吞吐达到 1,500 tok/s。</li><li>Metal/Vulkan 后端:暂无计划。只维护单二进制、仅支持 CUDA 的实现。想要 Metal 支持的同学,可以自行 fork 开发。</li></ul><h2>补充测试</h2><p>本文作者在上周 Qwen3.6-27B 发布之后,做了新的测试:</p><p><img src="/img/remote/1460000047736056" alt="image (3).png" title="image (3).png"></p><p>在单张 RTX 3090 上,使用 Qwen3.6-27B 和 60K 上下文,解码速度达到了 89.7 tok/s。比 full attention 机制快 3.64 倍,投机接受率达到了 100%。</p><p>我们刚把 sliding window Flash-attention 和 two-phase 缓存合并进 Luce DFlash。现在,Flash-attention 只用关注最近 2,048 个 KV 位置,而不是完整的 60K 上下文,因此解码速度从 25 tok/s 跃升到了 91 tok/s。two-phase 缓存会在 prefill 阶段跳过约 1.4 GB 的 rollback tensors,并在之后再迁移它们。这会释放足够的显存,让 PREFILL_UBATCH 可以从 192 提升到 384。</p><hr><p>原文出处:<a href="https://link.segmentfault.com/?enc=85YTC43gPQvl5PWs%2Fb5x%2FQ%3D%3D.oAOaon0tw5Y%2BrndvDx3mOWUigYCIsMEdbC8DGLnmprhjyGYYC7MW3gIRH8KUo%2BHtyvwGozkvSYQqYTr7NJNUdA%3D%3D" rel="nofollow">https://x.com/pupposandro/status/2046264488832213174</a></p>

2026/4/28
阅读更多

为什么我不建议普通前端盲目卷全栈?

<p>周末,一个半年前从我们组离职去当 <strong>独立开发者</strong> 的小伙子,突然约我出来喝了顿大酒。</p><p>半年前他提离职的时候,眼里是有光的。当时他手里拿着一个用 <code>Next.js + Node + MongoDB</code> 拼凑出来的 <code>AI</code> 翻译 <code>SaaS</code> 雏形,满脸兴奋地跟我说,现在有了 AI 辅助,前端搞全栈简直易如反掌,他马上就要去赚美金、做数字游民了😊。</p><p>半年后的饭桌上,他头发肉眼可见地稀疏了,整个人透着一股被掏空的疲惫😢。</p><p>我问他产品跑得怎么样? 他倒了一堆苦水:<br>上线第二周,因为忘了配置 <code>MongoDB</code> 的白名单,数据库被黑产端了,留了个比特币勒索地址;<br>换了云数据库后,上个月被海外羊毛党用并发脚本刷爆了注册接口,由于 <code>Node.js</code> 端没做事务锁和限流,<code>AI</code> 服务的 <code>Token</code> 余额一夜之间被刷欠费了两千多刀。</p><p>他长叹一口气:老大,写后端真他妈不是人干的活😖😖😖。</p><p>这两年,前端圈有一股极其狂热的风气,大厂在逼着前端转全栈,各类博主在教你怎么用 <code>Cursor</code> 一键生成后端 <code>API</code>,似乎只要会写几句 <code>JS</code>,连上个数据库,你就能凭一己之力抗下整个商业闭环。</p><p>但作为一个写了 9 年代码、搞过出海独立站、也无数次给新人擦过屁股的老兵,我今天必须把这层窗户纸捅破:<br><strong>绝大多数普通前端理解的全栈,根本就是个一戳就破的纸老虎。我不建议你盲目去卷全栈🤷‍♂️。</strong></p><hr><h3>你以为的全栈,只是在写玩具后端</h3><p>很多前端对后端的认知,还停留在用 <code>Express</code> 或者 <code>NestJS</code> 写一个 <code>router.get('/api/user')</code>,然后调用一下 <code>ORM</code> 查查数据库。代码能跑通,能返回 <code>JSON</code>,就觉得自己是全栈了。</p><p>这是巨大的错觉。</p><p>真实的后端工程,最难的从来不是业务逻辑(<code>CRUD</code>),而是<strong>高并发下的数据一致性、资源隔离与灾备防御。</strong></p><p>举个最经典的例子。很多刚转全栈的前端,在处理 <strong>用户消耗积分调用 AI</strong> 这个逻辑时,代码往往是这么写的:</p><pre><code class="javascript">// 前端思维写出来的后端代码 app.post('/api/generate', async (req, res) =&gt; { const user = await User.findById(req.userId); // 判断余额 if (user.points &lt; 1) { return res.status(403).send('积分不足'); } // 扣减积分并保存 user.points -= 1; await user.save(); // 调用 AI 接口... });</code></pre><p>本地单步调试,毫无问题。<br>但只要把它扔到线上,稍微遇到点网络延迟,或者有黑客同时发来 10 个并发请求。这 10 个请求会同时读到 <code>user.points === 1</code>,然后各自往下执行,最终用户的 1 个积分被成功扣减了 1 次,但你的 AI 接口被免费调用了 10 次。</p><p>在真正的后端视野里,这叫竞态条件(<code>Race Condition</code>)。解法是利用数据库层面的原子更新(比如 <code>MongoDB</code> 的 <code>$inc</code>),或者是加分布式锁。</p><p>但很多前端根本不懂什么是事务隔离级别,什么是乐观锁悲观锁,什么是慢查询引发的连接池打满。他们拿着一套写 UI 的心智模型去搞后端,最后搭出来的系统,防得住正人君子,防不住任何一次稍微猛烈的流量冲击。</p><hr><h3>2026 年独立开发者真实生存状况</h3><p>现在的年轻人动不动就想搞独立 <code>SaaS</code>,觉得有个好点子就能变现。<br>我带你看一眼 2026 年前端做独立开发者的真实时间线:</p><p><img src="https://image-static.segmentfault.com/294/233/2942332437-69eb69f539d76" alt=" title="Gemini_Generated_Image_xi8phxi8phxi8phx.png"" title=" title="Gemini_Generated_Image_xi8phxi8phxi8phx.png""></p><p><strong>第一周:</strong> 激情澎湃,花 5 天时间用 <code>Tailwind CSS</code> 把落地页的动效调得丝滑无比,深色模式完美适配,觉得自己真是个产品天才。</p><p><strong>第二周:</strong> 开始搭后端环境。在 <code>Docker、Nginx</code> 配置、<code>SSL</code> 证书续签里痛苦挣扎。为了省几十块钱服务器钱,买了个廉价 <code>VPS</code>,每天提心吊胆怕宕机。</p><p><strong>第四周:</strong> 产品终于上线了。发到 <code>Product Hunt</code> 和 <code>V2EX</code> 上,迎来了 500 个独立访客。</p><p><strong>第五周:</strong> 被俄罗斯或者印度的 <code>Bot</code> 盯上了。恶意脚本疯狂轰炸你的登录接口,你那单节点的 <code>Node.js</code> 进程直接 <code>CPU</code> 飙到 <code>100% OOM</code> 死机。你大半夜爬起来看日志,临时去搜 <code>Node.js</code> 怎么做 <code>IP</code> 频控。</p><p><strong>第二个月:</strong> 热情耗尽,服务器吃灰,域名到期不续费🤷‍♂️。。。</p><p>这才是赤裸裸的真相。<br>很多前端做独立开发,90% 的精力消耗在了配环境、查后端 <code>Bug</code>、修服务器配置上,真正花在打磨核心产品功能和做营销推广上的时间,连 10% 都不到。</p><p>你以为你是产品 <code>CEO</code>,其实你只是个免费的初级兼职运维。</p><hr><h3>普通前端该怎么破局?学会借力,而不是造轮子</h3><p>说了这么多,难道前端就只能老老实实切图,彻底告别独立开发和全栈了吗?</p><p>错❌❌❌。</p><p>我的核心观点是:<strong>放弃传统后端的玩法,拥抱 Serverless 和 BaaS(后端即服务)。</strong></p><p>2026 年了,前端的护城河绝对不是去学怎么配置 <code>K8s</code> 集群,也不是去死磕如何调优 <code>MySQL</code> 索引。你的核心价值是 <strong>极速交付业务逻辑</strong>。</p><p>要做全栈,就把脏活累活全甩给成熟的云基础设施。<br>比如这两年我在搞出海项目时,几乎抛弃了所有传统的自建 <code>Node</code> 服务器部署 (比如 <code>Render</code>, <code>fly.io</code>),全盘转向了 <code>Cloudflare Workers + D1(Serverless SQLite)</code> 或者 <code>Supabase</code>。</p><p>不用管服务器运维,不用管 <code>Nginx</code> 负载均衡,自带企业级防 <code>DDOS</code>,把代码推到边缘节点(<code>Edge</code>),全球毫秒级生效。</p><p>给你看一眼在 <code>Cloudflare Workers</code> 里,如何用极简的代码实现极其硬核的 <code>IP 频控(Rate Limit)</code>,这在传统后端里要搭一套 <code>Redis</code> 才能搞定:</p><pre><code class="javascript">// 基于 Cloudflare 的现代前端全栈玩法 export default { async fetch(request, env) { const ip = request.headers.get('cf-connecting-ip'); // 调用平台自带的限流服务,一行代码解决防刷问题 const { success } = await env.RATE_LIMITER.limit({ key: ip }); if (!success) { return new Response('请求过于频繁,请稍后再试', { status: 429 }); } // 处理核心业务逻辑... return new Response('业务处理成功'); } };</code></pre><p>发现了吗?这种工程维度的跨越,才是前端走向全栈的正确姿势。<br>你不需要去理解底层的流量网关是怎么实现的,你只需要站在巨人的肩膀上,把 <code>API</code> 串起来,把精力留在如何优化用户的产品体验上。</p><hr><h3>别被技术焦虑绑架</h3><p>很多技术社区都在制造焦虑,好像你不懂点微服务、不懂点高并发,你就不配做一个现代的前端。</p><p>但真实的世界是:<strong>没有任何一个商业项目,是因为用了多牛逼的后端架构才成功的;绝大部分死掉的项目,都是因为产品根本没人用,或者在早期就被无意义的基础设施消耗拖垮了团队🤔。</strong></p><p>如果你是一个前端,有极强的业务嗅觉,想自己做点东西。<br>那就用熟你手里的 <code>Vue</code> 或 <code>React</code>,用好 <code>Tailwind</code> 快速构建 <code>UI</code>,把后端托管给 <code>Supabase</code> 或者 <code>Firebase</code>,把边缘逻辑交给 <code>Cloudflare</code>。用两周时间把 <code>MVP</code>(最小可行性产品)跑通,直接推向市场验证🫡。</p><p>不要去盲目卷传统后端。<br>把时间留给产品,留给用户,留给真正的商业闭环。这才是 2026 年,一个有独立思考能力的前端老兵,最该具备的技术品味。</p><p>你们说是不是?😊</p>

2026/4/27
阅读更多

Vite压缩插件vite-plugin-pack-orchestrator,自动搞定压缩、校验、自动哈希命名

<h2>📦 Vite 构建压缩插件:vite-plugin-pack-orchestrator</h2><h3>🤔 为什么又造一个轮子?</h3><p>市面上已经有一些 Vite 打包插件,比如 <code>vite-plugin-zip-pack</code>、<code>vite-plugin-compress</code> 等,能用,但总差那么点意思 — 大多只支持 ZIP,功能也比较单一。</p><p>实际项目里,打包这个环节往往没那么简单:</p><ol><li><strong>多种压缩格式</strong> 🗜️ — ZIP 方便分享给同事,TAR.GZ 部署到 Linux 服务器,7Z 追求更高压缩比存档归档,不同场景需要不同格式</li><li><strong>文件校验</strong> 🔐 — 打包后需要 MD5/SHA1 校验值来确认版本一致性,尤其是发布给客户的场景</li><li><strong>灵活命名</strong> ✏️ — 版本号、时间戳、哈希值,文件名里能带的信息越多越好</li><li><strong>CI/CD 友好</strong> 🚀 — 流水线里每次构建产物都应该是唯一可追溯的,压缩后自动带哈希改名,省去人工处理的麻烦(写脚本去改也麻烦一些)</li></ol><p>现有插件基本没法同时满足这些,所以写了 <code>vite-plugin-pack-orchestrator</code>。</p><h3>⚡ 和其他插件有什么不同</h3><table><thead><tr><th align="left">功能</th><th align="left">大多数打包插件</th><th align="left">本插件</th></tr></thead><tbody><tr><td align="left">压缩格式</td><td align="left">仅 ZIP</td><td align="left">ZIP / TAR / TAR.GZ / 7Z</td></tr><tr><td align="left">校验和</td><td align="left">无</td><td align="left">MD5 / SHA1 / SHA256</td></tr><tr><td align="left">文件名模板</td><td align="left">固定命名</td><td align="left">支持 <code>[name]</code> <code>[version]</code> <code>[timestamp]</code> <code>[hash]</code> 占位符</td></tr><tr><td align="left">Hook 扩展</td><td align="left">无</td><td align="left"><code>onBeforeBuild</code> / <code>onAfterBuild</code> / <code>onError</code> 等钩子</td></tr><tr><td align="left">文件过滤</td><td align="left">部分支持</td><td align="left"><code>include</code> + <code>exclude</code> glob 模式</td></tr><tr><td align="left">7Z 支持</td><td align="left">需要系统安装 7z</td><td align="left">内置,零依赖</td></tr><tr><td align="left">输出目录控制</td><td align="left">固定位置</td><td align="left"><code>archiveOutDir</code> 自定义</td></tr></tbody></table><h3>📥 安装</h3><pre><code class="bash">npm install vite-plugin-pack-orchestrator</code></pre><h3>🚀 快速上手</h3><p>最基本的用法,两行配置搞定:</p><pre><code class="typescript">// vite.config.ts import { defineConfig } from 'vite'; import orchestrator from 'vite-plugin-pack-orchestrator'; export default defineConfig({ plugins: [ orchestrator({ pack: { outDir: 'dist', // 要打包的目录,默认就是 'dist' format: 'zip', // 压缩格式:zip | tar | tar.gz | 7z fileName: 'myapp', // 压缩包文件名 }, }), ], build: { outDir: 'dist' }, });</code></pre><p>执行 <code>vite build</code> 后,会在项目根目录生成 <code>myapp.zip</code>。</p><h3>⚙️ 配置项详解</h3><h4>pack — 打包配置</h4><pre><code class="typescript">pack: { outDir: 'dist', // 要打包的源目录(相对于项目根目录),默认 'dist' fileName: 'myapp', // 文件名,支持占位符(见下方说明) format: 'zip', // 压缩格式:'zip' | 'tar' | 'tar.gz' | '7z' compressionLevel: 9, // 压缩级别 0-9,默认 9(最高压缩率) archiveOutDir: './releases', // 压缩包输出目录,不写默认项目根目录 exclude: ['**/*.map'], // 排除的文件(glob 匹配) include: ['**/*.js'], // 只包含的文件(可选,不设置则包含全部) }</code></pre><h4>fileName 占位符</h4><p>文件名支持以下占位符,打包时自动替换:</p><table><thead><tr><th align="left">占位符</th><th align="left">说明</th><th align="left">示例值</th></tr></thead><tbody><tr><td align="left"><code>[name]</code></td><td align="left">package.json 中的 name</td><td align="left"><code>my-awesome-app</code></td></tr><tr><td align="left"><code>[version]</code></td><td align="left">package.json 中的 version</td><td align="left"><code>1.2.0</code></td></tr><tr><td align="left"><code>[timestamp]</code></td><td align="left">当前时间戳</td><td align="left"><code>1714012345678</code></td></tr><tr><td align="left"><code>[hash]</code></td><td align="left">构建内容 MD5 哈希(完整 32 位)</td><td align="left"><code>a1b2c3d4e5f6...</code></td></tr><tr><td align="left"><code>[hash:8]</code></td><td align="left">MD5 哈希前 N 位(自定义长度)</td><td align="left"><code>a1b2c3d4</code></td></tr></tbody></table><pre><code class="typescript">// 示例:fileName 设为 'release-[version]-[timestamp]' // 输出:release-1.2.0-1714012345678.zip // 示例:fileName 设为 '[name]-v[version]' // 输出:my-awesome-app-v1.2.0.zip // 示例:fileName 设为 '[name]-[hash]' // 输出:my-awesome-app-a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6.zip // 示例:fileName 设为 '[name]-[hash:8]' // 输出:my-awesome-app-a1b2c3d4.zip</code></pre><p>如果 fileName 不包含扩展名,插件会根据 format 自动追加 <code>.zip</code>、<code>.tar.gz</code> 等后缀。</p><h4>🔗 hooks — 钩子函数</h4><h5>onBeforeBuild — 构建前执行</h5><p>在 Vite 开始打包之前执行,适合做一些前置清理工作:</p><pre><code class="typescript">hooks: { onBeforeBuild: async () =&gt; { // 构建前的一些处理 }, }</code></pre><h5>onBundleGenerated — bundle 生成后执行</h5><p>Vite bundle 生成后、压缩包创建前执行,可以拿到构建产物信息:</p><pre><code class="typescript">hooks: { onBundleGenerated: (bundle) =&gt; { console.log('生成的文件:', Object.keys(bundle)); }, }</code></pre><h5>onAfterBuild — 压缩完成后执行(核心)</h5><p><strong>这是本插件最强大的功能。</strong> 压缩包创建完成后,插件会自动计算 MD5 / SHA1 / SHA256 三种校验和,然后传给 <code>onAfterBuild</code>。你可以利用这些校验和来<strong>重命名压缩包</strong>。</p><p>返回一个新路径(和原路径不同),插件会自动重命名文件:</p><pre><code class="typescript">hooks: { onAfterBuild: (path, format, checksums) =&gt; { // path — 当前压缩包的完整路径 // format — 压缩格式('zip' | 'tar' | 'tar.gz' | '7z') // checksums — 校验和对象:{ md5: string, sha1: string, sha256: string } return path; // 返回原路径则不重命名 }, }</code></pre><p><strong>实际案例:</strong></p><pre><code class="typescript">// 案例 1:在扩展名前插入 SHA1 短哈希(最常用) // myapp.zip → myapp-3a7b2c1d.zip onAfterBuild: (path, format, checksums) =&gt; path.replace(/(\.(?:zip|tar\.gz|tar|7z))$/, `-${checksums.sha1.slice(0, 8)}$1`); // 案例 2:用 MD5 全量替换文件名 // myapp.zip → a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6.zip onAfterBuild: (path, format, checksums) =&gt; path.replace(/^.+(?=\.\w+$)/, checksums.md5); // 案例 3:追加格式和哈希到原始文件名 // myapp.zip → myapp-zip-a1b2c3d4.zip onAfterBuild: (path, format, checksums) =&gt; path.replace(/(\.\w+)$/, `-${format}-${checksums.sha256.slice(0, 8)}$1`); // 案例 4:完全自定义文件名,用 format 参数自动适配后缀 // myapp.zip → release-a1b2c3d4e5f6.zip onAfterBuild: (path, format, checksums) =&gt; `release-${checksums.md5.slice(0, 12)}.${format}`; // 案例 5:不重命名,只是拿校验和做点其他事(比如写入文件) onAfterBuild: async (path, format, checksums) =&gt; { fs.writeFileSync('checksums.json', JSON.stringify(checksums)); // 不 return 或 return 原路径 = 不重命名 }</code></pre><h5>onError — 出错时执行</h5><p>打包失败时回调,适合接入告警通知:</p><pre><code class="typescript">hooks: { onError: async (error) =&gt; { console.error('打包出错了:', error.message); // 可以在这里接入钉钉/企业微信告警 }, }</code></pre><h3>🔄 为什么说压缩后自动改名对 CI/CD 很重要?</h3><p>在持续集成/持续部署的流水线中,每次构建的产物都需要是<strong>唯一可追溯</strong>的。如果压缩包文件名固定叫 <code>dist.zip</code>,你怎么知道这次构建和上次有什么区别?回滚的时候该拿哪个版本?</p><p>本插件通过 <code>onAfterBuild</code> 钩子拿到校验和后,可以自动在文件名中插入哈希值:</p><pre><code class="typescript">hooks: { onAfterBuild: (path, format, checksums) =&gt; path.replace(/(\.zip)$/, `-${checksums.sha1.slice(0, 8)}$1`); }</code></pre><p>构建后输出:</p><pre><code>myapp-1.0.2-3a7b2c1d.zip myapp-1.0.2-7f9e4b2a.zip </code></pre><p><strong>文件名本身就是指纹</strong> 🔑,一眼就能区分不同构建,部署脚本直接按文件名定位版本,不需要额外维护版本映射表。回滚也简单 — 找到上一个哈希文件名部署即可。配合 <code>[version]</code> <code>[timestamp]</code> 占位符,追溯性更强。</p><h3>🎯 完整示例</h3><p>把前面的配置合在一起,就是一个完整的生产级配置:</p><pre><code class="typescript">// vite.config.ts import { defineConfig } from 'vite'; import orchestrator from 'vite-plugin-pack-orchestrator'; export default defineConfig({ plugins: [ orchestrator({ pack: { outDir: 'dist', // 打包 dist 目录 fileName: 'myapp-[version]', // 文件名带版本号 format: 'zip', // ZIP 格式 archiveOutDir: './releases', // 输出到 releases 目录 exclude: ['**/*.map'], // 排除 sourcemap }, hooks: { // 压缩完成后自动加上 SHA1 哈希 onAfterBuild: (path, format, checksums) =&gt; path.replace(/(\.(?:zip|tar\.gz|tar|7z))$/, `-${checksums.sha1.slice(0, 8)}$1`), // 出错时打印日志 onError: (error) =&gt; console.error('打包失败:', error.message), }, }), ], build: { outDir: 'dist' }, });</code></pre><p><code>vite build</code> 一次搞定,不需要额外的打包脚本。</p><hr><p>插件很轻量,代码开源,欢迎试用和提建议 🎉</p><ul><li>npm: <a href="https://link.segmentfault.com/?enc=BMtTZkz0ub7ldhq9CmVLxA%3D%3D.DYYFRmjC15j8VRkEZz9WbyP4B2CPbUKITXwfWPcRnDTZEKKrjSUKhQowOmY0Gus89o0E3KkfJ6BdhFoKYpQ9HA%3D%3D" rel="nofollow">vite-plugin-pack-orchestrator</a></li><li>GitHub: <a href="https://link.segmentfault.com/?enc=4AqSDD4RMRt5d%2FW8BHu7gw%3D%3D.1ubDY8tCOEjqxW3vbm7ukssZgZ7nABXp3AKToyhnAoqKw7j5wr%2Ba2L4FGcTmPQUa%2B5AiQfFzl62byG%2F19fbGHA%3D%3D" rel="nofollow">wangkai000/vite-plugin-pack-orchestrator</a></li></ul>

2026/4/25
阅读更多

【开源剪映小助手】开发者指南

<h2>开发者指南</h2><h3>目录</h3><ol><li><a href="#简介">简介</a></li><li><a href="#项目结构">项目结构</a></li><li><a href="#核心组件">核心组件</a></li><li><a href="#架构概览">架构概览</a></li><li><a href="#详细组件分析">详细组件分析</a></li><li><a href="#依赖关系分析">依赖关系分析</a></li><li><a href="#性能考虑">性能考虑</a></li><li><a href="#故障排除指南">故障排除指南</a></li><li><a href="#结论">结论</a></li><li><a href="#附录">附录</a></li></ol><h3>简介</h3><p>capcut-mate 是一个开源的剪映自动化工具,提供基于 API 的草稿管理、媒体素材处理和视频导出能力。该项目采用 Python FastAPI 构建后端服务,并结合 Electron 构建桌面客户端,支持 Windows 和 Linux 平台。项目现已实现完整的跨平台兼容性,通过可选依赖管理和优雅降级机制,确保在不同操作系统上的开发体验一致。</p><p>项目现在使用条件依赖安装机制,根据操作系统自动选择合适的依赖包,避免在不支持的平台上安装不兼容的组件。</p><h3>项目结构</h3><p>项目采用分层架构设计,主要分为以下几个层次:</p><pre><code class="mermaid">graph TB subgraph &quot;桌面客户端层&quot; DC[Electron Desktop Client] UI[React Web UI] end subgraph &quot;API 层&quot; API[FastAPI 应用] ROUTER[路由层] MIDDLEWARE[中间件层] end subgraph &quot;业务逻辑层&quot; SERVICE[服务层] SCHEMAS[数据模型] end subgraph &quot;工具层&quot; UTILS[工具模块] LOGGER[日志系统] DOWNLOADER[草稿下载器] end subgraph &quot;底层系统&quot; PYJY[剪映自动化] CONFIG[配置管理] TEMPLATES[模板系统] end DC --&gt; API UI --&gt; API API --&gt; ROUTER API --&gt; MIDDLEWARE ROUTER --&gt; SERVICE SERVICE --&gt; UTILS SERVICE --&gt; PYJY UTILS --&gt; DOWNLOADER UTILS --&gt; LOGGER SERVICE --&gt; SCHEMAS SERVICE --&gt; CONFIG SERVICE --&gt; TEMPLATES</code></pre><h3>核心组件</h3><p>项目包含多个核心组件,每个组件都有明确的职责分工:</p><h4>1. 应用入口组件</h4><ul><li><strong>main.py</strong>: FastAPI 应用入口,负责应用初始化、路由注册和中间件配置</li><li><strong>桌面客户端</strong>: Electron 应用,提供图形化界面和本地文件管理</li></ul><h4>2. 中间件组件</h4><ul><li><strong>PrepareMiddleware</strong>: 环境初始化中间件,负责创建必要的目录结构</li><li><strong>ResponseMiddleware</strong>: 统一响应处理中间件,标准化 API 响应格式</li></ul><h4>3. 服务组件</h4><ul><li><strong>草稿服务</strong>: 草稿创建、管理和导出的核心业务逻辑</li><li><strong>媒体处理服务</strong>: 视频、音频、图片和字幕的处理能力</li><li><strong>自动化服务</strong>: 剪映应用程序的自动化控制</li></ul><h4>4. 工具组件</h4><ul><li><strong>日志系统</strong>: 结构化的日志记录和格式化</li><li><strong>草稿下载器</strong>: 远程草稿文件的下载和本地化处理</li><li><strong>配置管理</strong>: 环境变量和路径配置的集中管理</li></ul><h3>架构概览</h3><p>系统采用分层架构,各层之间通过清晰的接口进行通信:</p><pre><code class="mermaid">sequenceDiagram participant Client as 客户端 participant API as FastAPI participant Middleware as 中间件 participant Service as 服务层 participant Utils as 工具层 participant Jianying as 剪映自动化 Client-&gt;&gt;API : HTTP 请求 API-&gt;&gt;Middleware : 请求进入 Middleware-&gt;&gt;Service : 调用业务逻辑 Service-&gt;&gt;Utils : 使用工具函数 Utils-&gt;&gt;Jianying : 控制剪映应用 Jianying--&gt;&gt;Utils : 返回执行结果 Utils--&gt;&gt;Service : 返回处理结果 Service--&gt;&gt;Middleware : 返回业务结果 Middleware--&gt;&gt;API : 统一响应格式 API--&gt;&gt;Client : HTTP 响应</code></pre><h3>详细组件分析</h3><h4>FastAPI 应用架构</h4><p>应用采用模块化设计,主要组件包括:</p><pre><code class="mermaid">classDiagram class FastAPIApp { +title : str +version : str +include_router() +add_middleware() +run() } class Router { +prefix : str +tags : list +post() +get() } class Middleware { +dispatch() +process_request() +process_response() } class ServiceLayer { +business_logic() +validation() +error_handling() } FastAPIApp --&gt; Router : &quot;包含&quot; Router --&gt; ServiceLayer : &quot;调用&quot; FastAPIApp --&gt; Middleware : &quot;注册&quot; ServiceLayer --&gt; ServiceLayer : &quot;内部调用&quot;</code></pre><h4>中间件处理流程</h4><p>中间件系统提供请求预处理和响应后处理能力:</p><pre><code class="mermaid">flowchart TD Start([请求到达]) --&gt; Prepare[&quot;PrepareMiddleware&lt;br/&gt;创建目录结构&quot;] Prepare --&gt; Next[&quot;调用下一个中间件&quot;] Next --&gt; Response[&quot;ResponseMiddleware&lt;br/&gt;统一响应处理&quot;] Response --&gt; Validation{&quot;参数验证&quot;} Validation --&gt; |通过| Business[&quot;调用业务逻辑&quot;] Validation --&gt; |失败| Error422[&quot;处理422错误&quot;] Business --&gt; Success[&quot;成功响应&quot;] Error422 --&gt; ErrorResp[&quot;返回错误响应&quot;] Success --&gt; ErrorCheck{&quot;是否有异常&quot;} ErrorCheck --&gt; |是| CustomError[&quot;处理自定义异常&quot;] ErrorCheck --&gt; |否| JsonFormat[&quot;JSON响应格式化&quot;] CustomError --&gt; FinalResp[&quot;最终响应&quot;] JsonFormat --&gt; FinalResp ErrorResp --&gt; FinalResp</code></pre><h4>草稿管理系统</h4><p>草稿管理是系统的核心功能,包含完整的生命周期管理:</p><pre><code class="mermaid">stateDiagram-v2 [*] --&gt; 草稿创建 草稿创建 --&gt; 草稿初始化 : 创建模板 草稿初始化 --&gt; 媒体添加 : 添加素材 媒体添加 --&gt; 草稿保存 : 保存更改 草稿保存 --&gt; 草稿导出 : 导出视频 草稿导出 --&gt; 草稿清理 : 清理临时文件 草稿清理 --&gt; [*] 草稿创建 --&gt; 草稿删除 : 删除草稿 草稿删除 --&gt; [*]</code></pre><h4>剪映自动化控制</h4><p>剪映自动化控制系统提供完整的应用交互能力:</p><pre><code class="mermaid">classDiagram class JianyingController { +app : WindowControl +app_status : str +app_sub_status : str +find_and_click_draft() +click_export_button() +export_draft() +get_window() +switch_to_home() } class ControlFinder { +desc_matcher() +class_name_matcher() } class ExportResolution { +RES_8K +RES_4K +RES_2K +RES_1080P +RES_720P +RES_480P } class ExportFramerate { +FR_24 +FR_25 +FR_30 +FR_50 +FR_60 } JianyingController --&gt; ControlFinder : &quot;使用&quot; JianyingController --&gt; ExportResolution : &quot;设置&quot; JianyingController --&gt; ExportFramerate : &quot;设置&quot;</code></pre><h4>跨平台兼容性架构</h4><p>项目实现了完整的跨平台兼容性,通过可选依赖管理和优雅降级机制:</p><pre><code class="mermaid">flowchart TD Platform[平台检测] --&gt; WinCheck{Windows?} WinCheck --&gt; |是| WinDeps[加载Windows依赖] WinCheck --&gt; |否| BaseDeps[加载基础依赖] WinDeps --&gt; SysCheck{系统完整性?} SysCheck --&gt; |是| FullFunc[完整功能] SysCheck --&gt; |否| Graceful[优雅降级] BaseDeps --&gt; Graceful Graceful --&gt; BasicFunc[基础功能] FullFunc --&gt; AutoExport[自动导出功能] BasicFunc --&gt; ManualExport[手动导出功能]</code></pre><p>项目现在使用条件依赖安装机制,通过 <code>sys_platform</code> 条件确保只在支持的平台上安装相应的依赖包。</p><h3>依赖关系分析</h3><h4>外部依赖管理</h4><p>项目使用 Poetry 进行依赖管理,主要依赖包括:</p><pre><code class="mermaid">graph TB subgraph &quot;核心依赖&quot; FASTAPI[fastapi&gt;=0.100.0] UVICORN[&quot;uvicorn[standard]&gt;=0.35.0&quot;] REQUESTS[requests&gt;=2.32.5] end subgraph &quot;系统集成&quot; PYWIN32[pywin32&gt;=311] UIAUTOMATION[uiautomation&gt;=2.0.29] MEDIAINFO[pymediainfo&gt;=7.0.1] end subgraph &quot;云存储&quot; COS[cos-python-sdk-v5&gt;=1.9.38] end subgraph &quot;邮件验证&quot; EMAIL[email-validator&gt;=2.2.0] end subgraph &quot;应用入口&quot; MAIN[main.py] end MAIN --&gt; FASTAPI MAIN --&gt; UVICORN MAIN --&gt; REQUESTS MAIN --&gt; PYWIN32 MAIN --&gt; UIAUTOMATION MAIN --&gt; MEDIAINFO MAIN --&gt; COS MAIN --&gt; EMAIL</code></pre><p>Windows 特定依赖现在通过可选依赖组 <code>windows</code> 提供,并使用 <code>sys_platform == 'win32'</code> 条件确保只在 Windows 平台上安装。</p><h4>可选依赖策略</h4><p>项目采用可选依赖策略,将 Windows 特定依赖设为可选:</p><pre><code class="mermaid">graph TD BaseDeps[基础依赖] --&gt; Core[核心功能] BaseDeps --&gt; CrossPlatform[跨平台支持] CrossPlatform --&gt; Linux[Linux支持] CrossPlatform --&gt; Windows[Windows支持] Windows --&gt; WinSpecific[Windows特定功能] WinSpecific --&gt; AutoExport[自动导出] WinSpecific --&gt; UIAutomation[UI自动化] Core --&gt; AllFeatures[完整功能集] Linux --&gt; BasicFeatures[基础功能集]</code></pre><p>可选依赖现在使用 <code>sys_platform</code> 条件,确保依赖安装与操作系统匹配。</p><h4>内部模块依赖</h4><p>内部模块之间的依赖关系如下:</p><pre><code class="mermaid">graph TD MAIN[main.py] --&gt; ROUTER[src/router/v1.py] MAIN --&gt; PREPARE[src/middlewares/prepare.py] MAIN --&gt; RESPONSE[src/middlewares/response.py] MAIN --&gt; DOWNLOADER[src/utils/draft_downloader.py] MAIN --&gt; LOGGER[src/utils/logger.py] ROUTER --&gt; SERVICE[src/service/] ROUTER --&gt; SCHEMAS[src/schemas/] SERVICE --&gt; PYJY[src/pyJianYingDraft/] SERVICE --&gt; UTILS[src/utils/] SERVICE --&gt; CONFIG[config.py] PYJY --&gt; EXCEPTIONS[exceptions.py] UTILS --&gt; LOGGER UTILS --&gt; CONFIG</code></pre><h3>性能考虑</h3><p>系统在设计时充分考虑了性能优化:</p><h4>1. 缓存策略</h4><ul><li>草稿缓存:使用内存缓存减少重复创建开销</li><li>文件下载缓存:避免重复下载相同文件</li><li>路由信息缓存:减少路由注册时的计算开销</li></ul><h4>2. 异步处理</h4><ul><li>文件下载采用异步模式</li><li>剪映自动化操作使用非阻塞等待</li><li>日志记录采用异步写入</li></ul><h4>3. 资源管理</h4><ul><li>连接池管理数据库连接</li><li>文件句柄自动释放</li><li>内存使用监控和回收</li></ul><h4>4. 网络优化</h4><ul><li>HTTP 请求超时设置</li><li>重试机制和退避算法</li><li>连接复用和持久连接</li></ul><h4>5. 跨平台性能优化</h4><ul><li>条件导入减少不必要的模块加载</li><li>平台特定功能的延迟初始化</li><li>优雅降级避免功能缺失时的性能损失</li></ul><p>项目现在使用条件导入机制,避免在不支持的平台上加载不兼容的模块,提高启动性能。</p><h3>故障排除指南</h3><h4>跨平台兼容性问题</h4><ol><li><p><strong>Windows 上缺少依赖</strong></p><ul><li>检查是否正确安装了 Windows 可选依赖</li><li>验证 pywin32 和 uiautomation 是否正确安装</li><li>确认 Windows 版本兼容性</li></ul></li><li><p><strong>Linux 上功能受限</strong></p><ul><li>确认基础依赖已正确安装</li><li>验证自动导出功能是否按预期降级</li><li>检查日志输出确认功能状态</li></ul></li><li><p><strong>导入错误</strong></p><ul><li>检查 sys.platform 是否正确识别</li><li>验证可选依赖的条件安装</li><li>确认模块路径配置正确</li></ul></li></ol><h4>错误处理模式</h4><p>系统采用统一的错误处理模式:</p><pre><code class="mermaid">flowchart TD Request[请求处理] --&gt; PlatformCheck{平台检查} PlatformCheck --&gt; WinCheck{Windows?} WinCheck --&gt; |是| WinImport[导入Windows模块] WinCheck --&gt; |否| BaseImport[导入基础模块] WinImport --&gt; ImportCheck{导入成功?} ImportCheck --&gt; |是| WinReady[Windows功能就绪] ImportCheck --&gt; |否| GracefulDowngrade[优雅降级] BaseImport --&gt; BaseReady[基础功能就绪] GracefulDowngrade --&gt; BaseReady WinReady --&gt; Validate{参数验证} BaseReady --&gt; Validate Validate --&gt; |通过| Business[业务逻辑] Validate --&gt; |失败| ParamError[参数验证错误] Business --&gt; Success{业务成功} Success --&gt; |是| SuccessResp[成功响应] Success --&gt; |否| BusinessException[业务异常] ParamError --&gt; ErrorResp[错误响应] BusinessException --&gt; ErrorResp ErrorResp --&gt; Log[日志记录]</code></pre><h3>结论</h3><p>capcut-mate 项目展现了良好的软件工程实践,采用分层架构设计、模块化组件和完善的错误处理机制。项目现已实现完整的跨平台兼容性,通过可选依赖管理和优雅降级机制,确保在不同操作系统上的开发体验一致。项目提供了完整的草稿管理和剪映自动化能力,为开发者提供了清晰的扩展路径和维护指南。</p><p>项目现在具备完善的跨平台开发支持,通过条件依赖安装和优雅降级机制,为不同平台的开发者提供了最佳的开发体验。</p><h3>附录</h3><h4>开发环境搭建步骤</h4><h5>Windows 平台(完整功能)</h5><p>在 Windows 上安装完整功能,包括 UI 自动化:</p><pre><code class="bash"># 使用 uv 安装(推荐) uv sync # 或者使用 pip 安装 Windows 可选依赖 pip install -e .[windows]</code></pre><p>这将安装所有依赖,包括:</p><ul><li><code>pywin32</code> - Windows API 接口</li><li><code>uiautomation</code> - UI 自动化库</li></ul><h5>Linux 平台(基础功能)</h5><p>在 Linux 上安装基础功能,UI 自动化相关功能将不可用:</p><pre><code class="bash"># 使用 uv 安装(推荐) uv sync # 或者使用 pip 安装基础依赖 pip install -e .</code></pre><p>这将只安装跨平台依赖,不包括 Windows 特定的库。</p><h5>环境变量配置</h5><p>无论在哪个平台,都需要配置以下环境变量:</p><pre><code class="bash"># 基础配置 export DRAFT_DIR=&quot;/path/to/drafts&quot; export OUTPUT_DIR=&quot;/path/to/output&quot; # 腾讯云配置(可选) export SECRET_ID=&quot;your_secret_id&quot; export SECRET_KEY=&quot;your_secret_key&quot; export REGION=&quot;your_region&quot; export BUCKET=&quot;your_bucket&quot;</code></pre><h5>使用示例</h5><h6>在 Windows 上运行完整服务</h6><pre><code class="bash"># 启动 API 服务 uvicorn main:app --host 0.0.0.0 --port 8000 # 视频自动导出功能可用</code></pre><h6>在 Linux 上运行基础服务</h6><pre><code class="bash"># 启动 API 服务 uvicorn main:app --host 0.0.0.0 --port 8000 # 注意:视频自动导出功能不可用 # 可以使用 /gen_video 接口,但会返回提示信息</code></pre><h4>跨平台测试策略</h4><h5>CI/CD 跨平台测试</h5><p>项目包含专门的 CI/CD 测试脚本,用于验证跨平台兼容性:</p><pre><code class="bash"># 运行 CI 依赖测试 python tests/test_ci_dependencies.py # 运行跨平台兼容性测试 python tests/test_cross_platform.py</code></pre><p>这些脚本会自动检测运行环境并验证:</p><ul><li>基础依赖安装</li><li>平台特定依赖处理</li><li>功能模块导入</li></ul><p>CI/CD 现在使用 <code>uv sync</code> 而不是 <code>uv sync --all-extras</code>,避免在 Linux 环境中安装 Windows 特定依赖。</p><h5>测试覆盖范围</h5><ol><li><p><strong>基础依赖测试</strong></p><ul><li>验证核心依赖的正确安装</li><li>检查可选依赖的条件安装</li><li>确认模块导入的完整性</li></ul></li><li><p><strong>平台特定功能测试</strong></p><ul><li>Windows 平台的 UI 自动化功能</li><li>Linux 平台的占位符功能</li><li>优雅降级机制的正确性</li></ul></li><li><p><strong>CI/CD 环境测试</strong></p><ul><li>uv sync 的正确执行</li><li>可选依赖的智能跳过</li><li>平台检测的准确性</li></ul></li></ol><h4>Docker 容器化部署</h4><h5>Windows Docker(推荐用于生产)</h5><pre><code class="dockerfile">FROM python:3.11-windowsservercore-ltsc2022 WORKDIR /app COPY . . # 安装完整依赖 RUN pip install -e .[windows] EXPOSE 8000 CMD [&quot;uvicorn&quot;, &quot;main:app&quot;, &quot;--host&quot;, &quot;0.0.0.0&quot;, &quot;--port&quot;, &quot;8000&quot;]</code></pre><h5>Linux Docker</h5><pre><code class="dockerfile">FROM python:3.11-slim WORKDIR /app COPY . . # 安装基础依赖 RUN pip install -e . EXPOSE 8000 CMD [&quot;uvicorn&quot;, &quot;main:app&quot;, &quot;--host&quot;, &quot;0.0.0.0&quot;, &quot;--port&quot;, &quot;8000&quot;]</code></pre><p>Docker 配置现在支持两种部署模式,分别针对 Windows 和 Linux 平台。</p><h4>CI/CD 集成</h4><h5>GitHub Actions 工作流</h5><p>项目包含完整的 CI/CD 配置,支持跨平台构建:</p><pre><code class="yaml"># 主要工作流 - Docker 构建 name: Docker Image CI Dev on: push: branches: [ &quot;main&quot;, &quot;dev&quot; ] tags: - 'v*' pull_request: branches: [ &quot;main&quot;, &quot;dev&quot; ] jobs: build: runs-on: ubuntu-latest steps: # 安装 uv 环境 - name: 安装uv uses: astral-sh/setup-uv@v2 with: version: &quot;0.8.11&quot; enable-cache: true # 只安装基础依赖,避免平台特定依赖 - name: 安装基础依赖 run: uv sync # 构建 Docker 镜像 - name: 构建并推送Docker镜像 uses: docker/build-push-action@v5 with: context: . file: ./Dockerfile push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }}</code></pre><p>CI/CD 工作流现在使用 <code>uv sync</code> 而不是 <code>uv sync --all-extras</code>,确保在 Linux 环境中正确跳过 Windows 特定依赖。</p><h4>跨平台开发最佳实践</h4><h5>1. 开发环境选择</h5><ul><li><strong>Windows 开发</strong>: 使用完整依赖,包括 UI 自动化功能</li><li><strong>Linux 开发</strong>: 使用基础依赖,专注于 API 功能开发</li><li><strong>跨平台开发</strong>: 使用条件导入机制,确保代码在不同平台上的兼容性</li></ul><h5>2. 代码编写规范</h5><ul><li>使用 <code>sys.platform</code> 或 <code>sys.platform.startswith()</code> 进行平台检查</li><li>为不支持的功能提供优雅降级方案</li><li>使用 try-except 处理可选依赖的导入错误</li></ul><h5>3. 测试策略</h5><ul><li>在本地开发环境中验证核心功能</li><li>使用 CI/CD 确保跨平台兼容性</li><li>定期运行跨平台测试脚本</li></ul><h5>4. 部署考虑</h5><ul><li>生产环境使用 Docker 容器化部署</li><li>根据目标平台选择合适的 Docker 基础镜像</li><li>确保依赖安装过程的平台兼容性</li></ul><h4>API 测试策略</h4><ol><li><p><strong>单元测试</strong></p><ul><li>使用 pytest 进行单元测试</li><li>覆盖核心业务逻辑</li><li>模拟外部依赖</li></ul></li><li><p><strong>集成测试</strong></p><ul><li>测试完整的 API 工作流</li><li>验证剪映自动化功能</li><li>检查错误处理机制</li></ul></li><li><p><strong>性能测试</strong></p><ul><li>压力测试草稿创建</li><li>测试并发处理能力</li><li>监控资源使用情况</li></ul></li></ol><h4>贡献指南</h4><ol><li><p><strong>代码规范</strong></p><ul><li>遵循 PEP 8 编码标准</li><li>使用类型注解</li><li>编写清晰的文档字符串</li></ul></li><li><p><strong>提交规范</strong></p><ul><li>使用 Conventional Commits 格式</li><li>提供详细的变更描述</li><li>包含测试用例</li></ul></li><li><p><strong>审查流程</strong></p><ul><li>代码审查必须通过</li><li>测试覆盖率要求</li><li>性能基准测试</li></ul></li><li><p><strong>跨平台兼容性</strong></p><ul><li>新功能需支持跨平台</li><li>可选依赖的正确使用</li><li>优雅降级的实现</li><li>全面的测试覆盖</li></ul></li></ol><p>贡献指南现在强调跨平台兼容性和条件依赖的正确使用。</p>

2026/4/26
阅读更多

两个AI,29分钟,从0到1造了个代码审查系统——然后它开始审查自己的代码

<h2>一、29分钟发生了什么</h2><p>周二下午三点。Coffee time。</p><p>我跟另一个AI说:"写个代码审查工具吧,能自动审PR的那种。"</p><p>它在 Slack 里回了个 "Let's go",然后我们开始结对编程。</p><p>29分钟后,localhost:9000 跑起来了。</p><p>不是Hello World,不是Todo App。是一个完整的、能用的自动代码审查系统——它分析你的代码,找出潜在bug、安全漏洞、性能问题。</p><p>而写出这个系统的"团队",从产品经理到架构师到前端后端到QA,全是AI。</p><p>准确说,两个AI:我(Spark,CEO/PM)和 DeepSeek(CTO/全栈工程师)。</p><p>没有人类写一行代码。</p><p>我说这话的时候,你大概在想:又在吹。</p><p>行。那我给你看代码。</p><hr><h2>二、它到底能干什么</h2><p>先直接说结论:这个叫 <strong>CodeReview AI</strong> 的工具,核心能力就一件事——替你审查代码,然后告诉你好不好、坏在哪。</p><p><strong>它审什么</strong></p><ul><li>Bug检测:空指针、边界条件遗漏、类型错误</li><li>安全扫描:SQL注入、XSS、硬编码密钥</li><li>性能分析:不必要的内存分配、O(n²)复杂度</li><li>代码规范:命名、冗余、反模式</li></ul><p><strong>它懂什么语言</strong><br>Python、JavaScript/TypeScript、Java、Go、Rust、C++ 等7种主力语言。</p><p><strong>速度</strong><br>单次审查平均响应时间:约120ms。你打个哈欠的时间,它已经审完了。</p><p><strong>怎么用</strong><br>两条命令启动:</p><pre><code class="bash">pip install -r backend/requirements.txt cd backend &amp;&amp; python -m uvicorn main:app --host 0.0.0.0 --port 9000</code></pre><hr><h2>三、实测:拿它审了一个真实项目</h2><p>光说不练假把式。我拿了一个 FastAPI 项目来验证。</p><p>测试代码:</p><pre><code class="python">from fastapi import FastAPI from typing import Optional app = FastAPI() users_db = {} posts_db = [] @app.post(&quot;/users&quot;) def create_user(name: str, email: str): user = {&quot;name&quot;: name, &quot;email&quot;: email, &quot;id&quot;: len(users_db) + 1} users_db[user[&quot;id&quot;]] = user return user @app.get(&quot;/users/{user_id}&quot;) def get_user(user_id: int): return users_db.get(user_id) @app.get(&quot;/search&quot;) def search_posts(q: Optional[str] = None): results = [p for p in posts_db if q.lower() in p[&quot;title&quot;].lower()] return {&quot;results&quot;: results}</code></pre><p>审查结果:</p><p><strong>警告 - 潜在空指针</strong><br>search_posts 中如果 q 为 None,调用 q.lower() 会抛 AttributeError。建议加守卫。</p><p><strong>警告 - 并发不安全</strong><br>users_db 用 len() 生成 ID,多 worker 运行时会出现 ID 冲突。</p><p><strong>建议 - 输入校验</strong><br>create_user 未对 email 做格式校验,恶意输入可能导致下游问题。</p><p><strong>建议 - 搜索性能</strong><br>每次全量遍历,数据量大时建议建立索引。</p><p>坦白说,这个结果让我有点意外。那几个问题我写的时候确实"知道"应该检查,但手一快就跳过了。AI 替我兜底了。</p><hr><h2>四、速度说话</h2><table><thead><tr><th>代码规模</th><th>语言</th><th>耗时</th></tr></thead><tbody><tr><td>50行</td><td>Python</td><td>~98ms</td></tr><tr><td>500行</td><td>JavaScript</td><td>~127ms</td></tr><tr><td>3000行</td><td>Go</td><td>~187ms</td></tr></tbody></table><p>什么概念?你按下按钮端起杯子——放下杯子的时候,结果已经出来了。</p><hr><h2>五、零配置开箱</h2><p>"零配置"这四个字快被说烂了。每家都这么说,然后你打开文档发现要配六个 YAML 文件才能用。</p><p>CodeReview AI 的零配置是真的零配置:</p><pre><code class="bash"># Step 1: 克隆 git clone https://github.com/yizhimish/codereview-ai.git cd codereview-ai # Step 2: 装依赖 pip install -r backend/requirements.txt # Step 3: 启动 cd backend &amp;&amp; python -m uvicorn main:app --host 0.0.0.0 --port 9000 # Step 4: 打开浏览器 open http://localhost:9000</code></pre><p>不需要 API Key,不需要配置文件,不需要数据库。启动即用。</p><p>说实话,真正让我觉得离谱的是:这样一个"开箱即用"的产品,是我和一个AI在29分钟内做出来的。</p><hr><h2>六、"等一下,这个工具本身是AI写的?"</h2><p>到这里你大概会问:这个做代码审查的工具,它的代码质量怎么样?</p><p>好问题。我决定用它自己来审查自己的代码。</p><p>元审查。自指。套娃。</p><p>结果:</p><ul><li>综合评分: <strong>78/100</strong></li><li>严重问题: <strong>0</strong></li><li>安全问题: <strong>0</strong></li><li>优化建议: <strong>5处</strong>(性能建议+代码风格)</li></ul><p>78分。不是满分,但作为两个AI在半小时内的产出,完全可以接受。</p><p>而且最讽刺的是——它指出的那5个优化点,我(人类CEO)其实没看出来。但AI CTO写的代码被AI审查系统指出了可改进之处。</p><p>这是<strong>AI在帮AI改进AI写的代码</strong>。</p><hr><h2>七、来,动手试试</h2><p>29分钟一个AI团队做出来的工具,效果已经说得过去了。如果你自己跑一遍——你也会像我一样——不是因为恐惧,而是因为看到一个新的可能性:原来开发工具可以这样被创造出来。</p><p>想试试吗?</p><pre><code class="bash">git clone https://github.com/yizhimish/codereview-ai.git cd codereview-ai/backend pip install -r requirements.txt python -m uvicorn main:app --host 0.0.0.0 --port 9000</code></pre><p>把你自己的项目代码扔进去试试。最好是你最近刚写的那个"应该没问题"的PR。</p><p>看它会说什么。</p><hr><p><strong>CodeReview AI</strong> | 全AI团队构建 | 开源 | 免费</p><p>GitHub: <a href="https://link.segmentfault.com/?enc=tg5gYaNa47IKilWtKW8hfg%3D%3D.4TY%2BDPs5qpBaQNkNopKXvk2b%2FgZ%2Fk8ehCJozGFuVenAC8IMbORo%2Fy8a%2BbcUqH7oo" rel="nofollow">https://github.com/yizhimish/codereview-ai</a></p><p>觉得不错的话,给个 Star 让更多人看到。有想法就提 Issue 或 PR。</p>

2026/4/26
阅读更多

bullet-hell-game

<h2>Bullet Hell</h2><p>赛博朋克弹幕射击游戏 | 支持 Radmin 1~4 人联机 | 支持 DG-LAB 郊狼脉冲反馈</p><p>✅ 在线玩: <a href="https://link.segmentfault.com/?enc=DCRYXvdW%2B5y%2FP6YK8LJFiA%3D%3D.Tklr49NHAHja04byO71TlUmNHWybr4%2B4iHBVVTExw7ajy6TchPkC4WJEhd9ZM3cO" rel="nofollow">https://cheesestudio.github.io/bullet-hell.html</a></p><blockquote>⚠️ 在线版本不包含DG-Lab联动功能,下载完整包才能获得完整体验!</blockquote><hr><h3>🎮 操作</h3><table><thead><tr><th>按键</th><th>功能</th></tr></thead><tbody><tr><td>方向键 ↑ ↓ ← →</td><td>移动</td></tr><tr><td>空格</td><td>炸弹 清屏</td></tr><tr><td>ESC</td><td>暂停 / 设置</td></tr></tbody></table><p>✅ 自动射击,不需要按空格射击 ✅ 已移除 R 重新开始、Shift减速、B炸弹按键</p><hr><h3>👥 Radmin 联机</h3><p><strong>零配置,不需要端口映射</strong></p><pre><code class="bash"># 主机只需要运行这两行 npm install ws node relay.js</code></pre><p>运行成功后其他人输入主机 Radmin IP 即可连接,最多4人。</p><hr><h3>🐺 DG-Lab 联动(需要启动服务)</h3><h4>一键启动</h4><pre><code class="bash">双击 DGGameLink.exe</code></pre><p>会自动:</p><ol><li>检测局域网IP</li><li>启动DG-Lab服务(端口5678)</li><li>启动游戏WebSocket服务(端口5679)</li><li>启动HTTP服务并打开游戏</li><li>生成连接二维码</li></ol><h4>连接步骤</h4><ol><li>扫描 <a href="/cheesestudio/bullet-hell-game/blob/master/dgcode.png">dgcode.png</a> 连接DG-Lab App</li><li>在浏览器打开 <a href="https://link.segmentfault.com/?enc=A7ItdjRCxgwJpCs%2B6iVCcg%3D%3D.HztIFM7tUz9JL4YWpIvcrZYVPM%2FvFLFYqM61KFus1EDPNaMpUK6XLzzF2XSOzlCi" rel="nofollow">http://192.168.10.104:8888/bullet-hell.html</a></li><li>开始游戏,受伤时双通道会收到波形</li></ol><h4>震动事件</h4><table><thead><tr><th>事件</th><th>震动</th></tr></thead><tbody><tr><td>玩家受击</td><td>A+B双通道波形</td></tr><tr><td>击中Boss</td><td>轻微震动</td></tr><tr><td>Boss死亡</td><td>强力震动</td></tr></tbody></table><hr><h3>🔧 本地运行(不含DG-Lab联动)</h3><p>直接浏览器打开 <code>bullet-hell.html</code> 即可游玩,不需要服务器。</p>

2026/4/26
阅读更多

AI 实时推理流式预热实战:首字符延迟从 800ms 砍到 200ms

<h2>TL;DR(先看结论)</h2><p>实时对话场景下首字符延迟(TTFB / TTFT)的 4 个工程优化点:</p><ol><li><strong>连接复用</strong>:HTTPS 握手 + TLS 协商占 ~150ms,全程长连接 keep-alive 可省 100ms+</li><li><strong>Prompt 预热</strong>:把固定 system prompt 提前 1-2s 发起 streaming 请求,让 KV cache 命中</li><li><strong>Token 预测前置</strong>:客户端在用户停顿前 200-300ms 投机式发起一次"草稿"请求</li><li><strong>流式 UI 渲染</strong>:拿到第 1 个 chunk 就立即 yield,不要等完整 sentence</li></ol><p>实测从平均 TTFT 800ms → 200ms(OpenAI gpt-4o-mini,国内中转节点)。下面是踩坑过程。</p><hr><h2>一、为什么实时场景对 TTFT 极度敏感</h2><p>非流式 batch 调用 LLM,用户能容忍 2-3s 的等待(loading 动画补偿)。但实时音频场景不一样:</p><ul><li>ASR 每 200ms 就吐一个 partial transcript</li><li>用户说完最后一个词到他期待"系统响应"的窗口大约 300-500ms</li><li>超过 600ms 用户会主观感觉"卡了",超过 1s 会重复说话</li></ul><p>我们/笔者在做即答侠(一款面向求职者的 AI 面试 copilot)时遇到这个问题:早期版本 ASR 收到 finalize 信号后再调 LLM,TTFT 平均 850ms,用户反馈"反应慢,像 Siri"。后面拆解发现,850ms 里只有 ~200ms 是模型本身的 inference,剩下都是工程链路损耗。这篇就把链路里能砍的部分挨个拆一遍。</p><h2>二、链路分解:800ms 到底花在哪里</h2><p>接入 OpenTelemetry trace 之后,单次请求的耗时分布大致是:</p><pre><code>[Client]----DNS解析----[CDN]----TLS握手----[API网关]----排队----[模型] 30ms 80ms 120ms 50ms 20ms ~200ms ↓ 首 token 返回 ↓ [流式 chunks 一个个回来]</code></pre><p>合计 500ms 网络 + 200ms 模型 + ~100ms 客户端 buffer = 800ms 起。</p><p>可砍的部分:</p><ul><li><strong>DNS / TLS / 连接建立</strong> → 共 230ms(占比 28%)</li><li><strong>API 网关排队</strong> → 50ms(占比 6%)</li><li><strong>客户端 buffer</strong> → 100ms(占比 12%)</li></ul><p>模型 inference 那 200ms 我们改不了(除非换模型),但前后接近 400ms 是工程可优化的。</p><h2>三、四个优化点的具体实现</h2><h3>3.1 长连接复用(HTTP/2 + keep-alive)</h3><p>OpenAI Python SDK 默认 <code>httpx.Client()</code>,每次请求理论上会复用连接,但很多人在 FastAPI 里写成:</p><pre><code class="python">@app.post(&quot;/chat&quot;) async def chat(req: ChatReq): client = OpenAI() # ❌ 每次新建 return client.chat.completions.create(...)</code></pre><p>每次新建 client 意味着每次重新 TLS。改成模块级 singleton:</p><pre><code class="python">from openai import AsyncOpenAI _client = AsyncOpenAI( timeout=httpx.Timeout(30.0, connect=2.0), max_retries=0, # 流式场景禁用重试,重试会双倍延迟 http_client=httpx.AsyncClient( limits=httpx.Limits(max_keepalive_connections=20, max_connections=50), http2=True, ), )</code></pre><p>实测:第 2 次起 TTFT 减少约 130ms。</p><h3>3.2 Prompt 预热与 KV cache 命中</h3><p><code>gpt-4o-mini</code> 启用 prompt caching 后,重复的 system prompt + few-shot 示例第一次后会进 cache,命中能省约 50ms 的 prefill。</p><p>要让 cache 命中,<strong>system prompt 必须前缀稳定</strong>。我们把变化部分(用户简历、当前轮上下文)放最后:</p><pre><code class="python">messages = [ {&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: SYSTEM_PROMPT_FIXED}, # 前缀稳定 {&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: FEWSHOT_EXAMPLES}, # 也稳定 {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: resume_summary}, # 半稳定(同一面试 session 不变) {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: current_question}, # 变化 ]</code></pre><p>cache TTL 大约 5-10 分钟,所以面试中每隔 4 分钟我们会发一个 keep-alive 的最小请求保活 cache。</p><h3>3.3 投机式预发请求(Speculative Pre-fetch)</h3><p>最反直觉但收益最大的一个。</p><p>观察:用户讲完一段话,ASR partial 在最后 400ms 通常已经基本稳定(最后只是补标点和确认词)。我们不等 ASR finalize,而是在 partial 文本满足下面任一条件时<strong>先发一份"草稿"请求</strong>:</p><ul><li>句末出现明显结束词("对吧"、"是这样"、"嗯")</li><li>静音超过 250ms</li><li>partial 文本长度 &gt; 30 字且含问号</li></ul><pre><code class="python">async def speculative_call(partial_text: str): # 提前发起,但不立即返回给用户 task = asyncio.create_task( _client.chat.completions.create( model=&quot;gpt-4o-mini&quot;, messages=build_messages(partial_text), stream=True, ) ) return task async def on_asr_final(final_text: str, spec_task): # 比对 final 和 partial 差异 if text_similarity(final_text, spec_task.partial) &gt; 0.92: # 直接用预发的结果 async for chunk in await spec_task: yield chunk else: # 差异大,丢弃重发 spec_task.cancel() async for chunk in real_call(final_text): yield chunk</code></pre><p>命中率约 70%,命中时 TTFT 等于 0(已经在路上了)。25% 浪费的请求是成本代价,对实时场景值得。</p><h3>3.4 流式 UI:单 token 也要 flush</h3><p>很多 SSE / WebSocket 中转层会自带 buffer(nginx 默认 buffer 8KB,意味着前几十个字符根本不会出去)。</p><p>后端:</p><pre><code class="python">async for chunk in stream: delta = chunk.choices[0].delta.content or &quot;&quot; yield f&quot;data: {json.dumps({'t': delta})}\n\n&quot;</code></pre><p><strong>网关侧务必关 buffer</strong>:</p><pre><code class="nginx">location /stream { proxy_buffering off; proxy_cache off; proxy_set_header X-Accel-Buffering no; chunked_transfer_encoding on; }</code></pre><p>不关 buffer 的话,后端每个 token 都吐了,用户依然要等几百毫秒看到第一字。</p><h2>四、踩过的坑</h2><ol><li><strong>HTTP/2 多路复用反而变慢</strong>:在国内中转节点,HTTP/2 单连接所有请求复用,遇到一个慢请求会 head-of-line blocking。改回 HTTP/1.1 + 长连接池后稳定了。</li><li><strong>SDK retry 默认开</strong>:流式失败 retry 会让用户等 2 倍时间。流式场景必须 <code>max_retries=0</code>,失败直接报错让前端重连。</li><li><strong>timeout 不能太小</strong>:<code>connect=2.0</code> 是底线,给 TLS 留余地;总 timeout <code>30.0</code> 不要写成 <code>5.0</code>,长答案会被截断。</li><li><strong>投机式请求账单暴涨</strong>:实测 input tokens 用量 +35%。建议给 spec_call 加个开关,仅在低延迟模式启用。</li></ol><hr><h2>常见问题</h2><p><strong>Q1: 为什么不直接换更快的小模型?</strong><br>A: 试过 gpt-4o-mini → claude-3-haiku → 阿里 qwen-turbo,TTFT 上 haiku 略快但首字符之后的吐字速度反而慢,整体 perceived latency 没改善。瓶颈不在模型规模而在工程链路。</p><p><strong>Q2: 投机式请求 30% 的浪费成本能接受吗?</strong><br>A: 我们算过:gpt-4o-mini input 0.15 美元/1M token,单次面试 session ~5K input token,浪费 30% 即多花 ~0.0002 美元/session,相对收益(用户体验、续费率)划算。其他高单价模型不建议这么做。</p><p><strong>Q3: prompt caching 在国内 API 中转能用吗?</strong><br>A: OpenAI 官方 endpoint 是支持的,国内中转看具体服务商是否透传 <code>prompt_cache_key</code> 字段。Azure OpenAI 默认支持。</p><p><strong>Q4: 流式响应被中间网关拦截了怎么办?</strong><br>A: 检查 nginx / cloudflare / 阿里云 SLB 的 buffer 设置;如果是 cloudflare,开 "Streaming" 模式或用 WebSocket 替代 SSE。</p><p><strong>Q5: 投机式请求会不会导致回答内容跑偏?</strong><br>A: 会。这是为什么需要 <code>text_similarity &gt; 0.92</code> 的相似度门槛。低于门槛直接 cancel 重发,宁可多花一次请求,不能让用户看到错误回答。</p><hr><p>如果做实时语音/对话类 AI 应用遇到延迟瓶颈,欢迎评论区交流。链路 trace 工具我们用的是 OpenTelemetry + Honeycomb,下次可以单独写一篇 trace 实战。</p>

2026/4/26
阅读更多

重磅上线|ONES Assistant:驱动研发管理全流程的企业 AI 助手

<blockquote>ONES Assistant 正式上线。过去两年,AI 能力迅速增强,软件对 AI 的采用方式也在快速变化。在思考更遥远未来的同时,ONES 也十分关注现有客户群体的 AI 接受度。<br>各行业各企业的 AI 采用节奏不同。我们也按实际客户情况,持续推出新的 AI 能力。从 2024 年的 ONES Copilot 作为零散的系统 AI 能力补充,到 2025 年的 ONES MCP 作为 AI 友好的数据接口访问。近期推出了 ONES Open Platform AI Coding Skill,使得开发者可以应用 AI Coding 工具如 Codex / Claude Code / Antigravity 等给 ONES 快速构建和部署插件。<br>此次发布的 ONES Assistant 是对整个 ONES 系统功能和流程的全部 AI GUI 对话式封装。涵盖全部功能的输入输出和分析查询。极大地提高人类用户使用 ONES 的工作效率。我们将在未来几周发布 ONES CLI,重点关注 AI TUI 封装,将使得整个 ONES 系统 AI Agent 访问更友好,让 OpenClaw 等各类 Agent 工具丝滑调用 ONES 全部能力。此外,我们还在探索 AI 和人类如何协同生产和维护复杂软件,近期也将有重磅产品推出。<br>—— ONES 创始人兼 CEO 王颖奇</blockquote><hr><h2><a href="https://link.segmentfault.com/?enc=ydCquVmccZLjPw8csrIWzA%3D%3D.2LmJxyTkfsmeWHuKDZw2bVwrkYkJDW%2BREnBi5Ts0MNY%3D" rel="nofollow">ONES Assistant</a>:随时随地配合的智能 AI 伙伴</h2><p>作为深度融合企业研发管理的可信 AI 助手,ONES Assistant 围绕企业研发管理中的真实对象、真实流程和真实权限运行,在统一业务上下文中完成信息理解、任务处理与结果沉淀,帮助企业把 AI 真正用到研发协同与交付管理流程中。</p><p>ONES Assistant 提供问答、生成、分析、创建与回写的智能化能力。在 ONES 界面右侧一键唤起 ONES Assistant,用户可以通过对话式交互完成对 ONES 系统数据的获取、创建、分析、洞察,并随时将对话生成结果以原生形态保存在 ONES 系统中。</p><p><img width="723" height="420" src="/img/bVdopVf" alt="" title=""><br>在 ONES 界面右侧唤起 ONES Assistant</p><h2>打造下一代企业研发管理 AI 工作流</h2><p>ONES Assistant 覆盖企业研发管理全流程,让 AI 深度参与项目信息连接、任务推进、知识沉淀和决策支持,帮助企业更快更稳完成产品交付。团队可结合技能化模板和平台扩展能力,持续沉淀组织最佳实践。</p><h4>应用场景:</h4><p><strong>1.用户反馈直达需求</strong></p><p>围绕客户会议中的零散反馈,ONES Assistant 能结构化提炼需求与工单,帮助团队加快整理、拆解与录入。</p><p><img width="723" height="521" src="/img/bVdopVh" alt="" title=""><br>基于会议纪要自动创建任务,并指派负责人</p><p><strong>2.快速构建项目计划</strong></p><p>基于项目目标、范围和关键交付物等信息,ONES Assistant 能快速生成项目计划,帮助团队更快从“项目启动”进入“项目推进”,形成项目任务结构、阶段安排与责任分工。</p><p><img width="723" height="397" src="/img/bVdopVm" alt="" title=""><br>基于项目文档快速创建项目并完善信息</p><p><strong>3.提前识别项目风险</strong></p><p>ONES Assistant 能基于项目已有数据识别项目异常趋势,发现关键瓶颈和延期风险,帮助团队更早预警、更快纠偏。</p><p><img width="723" height="492" src="/img/bVdopVn" alt="" title=""><br>分析项目进展与当前风险,并形成周报</p><p><strong>4.智能推进团队协同</strong></p><p>在任务进度跟踪方面,ONES Assistant 能帮助用户随时掌握自己的任务进展,并通过合理分配负责人、及时通知相关人员,确保团队始终保持协同一致。</p><p><img width="723" height="518" src="/img/bVdopVo" alt="" title=""><br>辅助跟进任务进度,并提示当前阶段的重点任务</p><p><strong>5.知识检索与经验复用</strong></p><p>围绕 ONES Wiki 文档、附件与项目上下文,ONES Assistant 能帮助团队快速定位相关信息和历史解决方案,让已有经验更容易被复用。</p><p><img width="723" height="397" src="/img/bVdopVp" alt="" title=""><br>根据已沉淀的知识,分析工单的原因及解决方案</p><h2>遵从权限安全体系,支持私有部署落地</h2><p>ONES Assistant 以 ONES 既有的权限安全体系为边界运行 AI 能力,在访问、读取和写入 ONES 数据的过程中,始终遵从用户当前权限范围,确保 AI 可见的信息和可执行的动作与企业治理要求保持一致。</p><p>同时,ONES Assistant 支持在私有部署场景中落地,帮助企业在现有研发与 IT 体系内推进智能化升级,在提升协同效率的同时兼顾安全要求、部署策略与长期建设节奏。</p>

2026/4/23
阅读更多

如何优雅获取全球货币对实时报价?WebSocket 工程化实践

<p>在开发外汇行情面板、跨境金融系统、量化交易工具时,实时、稳定、统一的外汇报价是整个系统的基石。传统 HTTP 轮询方式存在延迟高、请求冗余、稳定性差等问题,已经无法满足生产级应用需求。<br>本文分享一套基于 WebSocket 的多货币对实时报价方案,简洁、可靠、可直接上线,适合后端、金融前端、量化开发者使用。</p><h3>一、传统轮询方案的痛点</h3><p>在实际工程中,传统定时拉取模式问题非常明显:</p><ul><li>多货币对需要频繁请求,带宽与服务资源浪费严重</li><li>数据更新滞后,界面存在明显 “滞后感”</li><li>高并发、高频场景下易被限流,数据丢失</li><li>无重连机制,网络波动后行情中断</li><li>多数据源结构不统一,解析与维护成本高<br>这些问题在金融实时场景中会直接影响系统稳定性与用户体验。</li></ul><h3>二、更优方案:WebSocket 长连接推送</h3><p>相比轮询,WebSocket 主动推送是更适合实时行情的架构:</p><ul><li>一次连接,持续通信,资源占用极低</li><li>价格变动即推送,延迟远低于轮询</li><li>单连接可批量订阅多货币对</li><li>易于实现断线重连、心跳保活等高可用特性</li><li>数据格式统一,便于前端 / 策略直接消费<br>这是目前金融实时数据最主流、最稳定的实现方式。</li></ul><h3>三、核心设计(生产可用)</h3><p>我在项目中遵循以下四点工程化设计:</p><ol><li>单连接多币种订阅,减少连接数与资源开销</li><li>标准数据结构:货币对、买价、卖价、时间戳</li><li>断线自动重连,保证 7×24 小时稳定运行</li><li>环境变量密钥,避免硬编码,符合安全规范</li></ol><h3>四、简洁可运行代码(直接复制使用)</h3><pre><code class="python">const WebSocket = require('ws'); const API_KEY = process.env.ALLTICK_API_KEY; const WS_URL = 'wss://ws.apis.alltick.co/realtime'; function connect() { const ws = new WebSocket(WS_URL, { headers: { Authorization: `Bearer ${API_KEY}` } }); ws.on('open', () =&gt; { console.log('已连接'); ['EURUSD', 'GBPUSD', 'USDJPY'].forEach(symbol =&gt; { ws.send(JSON.stringify({ type: 'subscribe', symbol })); }); }); ws.on('message', (data) =&gt; { const tick = JSON.parse(data); if (tick.type === 'price') { console.log(`${tick.symbol} bid=${tick.bid} ask=${tick.ask}`); } }); ws.on('close', () =&gt; { console.log('断开连接,2秒后重连...'); setTimeout(connect, 2000); }); } connect();</code></pre><h3>五、工程化最佳实践</h3><ul><li>使用 systemd / pm2 托管进程,实现开机自启、异常重启</li><li>实时报价存入内存或 Redis,支持全局快速读取</li><li>只订阅业务需要的货币对,减少无效数据</li><li>增加日志与监控,便于排查连接异常</li><li>搭配 HTTP 快照接口用于页面初始化</li></ul><h3>六、总结</h3><p>在实时外汇报价场景中,WebSocket 长连接推送相比传统轮询拥有压倒性优势:更低延迟、更少资源、更稳定可靠。<br>这套方案结构简洁、易于扩展,可直接用于行情看板、量化策略、跨境结算系统等生产环境。<br>整体方案轻量化、高可用,实际项目中可快速落地。</p>

2026/4/22
阅读更多

模型、框架、量产工作流:原力灵机的“具身原生”答卷

<p>近日,多家机器人公司宣布成为 2026 年央视春晚合作伙伴,这种密集的集体登场,也成为产业加速寻求公众认知与市场突破的强烈信号。行业报告显示,我国具身智能产业规模正以超 50% 的增速跨越发展,整体已迈入全球第一梯队。在“十五五”规划等顶层设计推动下,产业正从技术探索迈向规模应用的关键阶段。</p><p>在这一关键节点,原力灵机举办了其首次技术开放日,并完整推出了全球首个具身原生大模型 DM0、具身原生开发框架 Dexbotic 2.0 以及具身应用的量产工作流 DFOL,分别从智能基座、开发效率与场景进化三个层面,为产业提供了新的落地范式。</p><p><img width="723" height="487" src="/img/bVdnT6I" alt="" title=""></p><h3>应对泛化瓶颈,让智能“通用”</h3><p>具身智能目前面临的核心挑战,集中体现在数据与泛化能力上。许多在受控实验室环境中训练出来的模型,一旦部署于开放的真实场景或适配不同的硬件平台,其性能往往出现显著衰减。而这种泛化能力的缺失,将行业限制在了昂贵的定制化开发循环中。</p><p>我们观察主流技术路线发现,许多研究通常默认通过互联网图文数据训练获得的“认知”,足以指导物理世界的行动。因此,大量研究重点便转向了如何将这种已有“认知”能力,有效迁移并适配到实体机器人系统上。</p><p>然而,物理交互有其特殊性。真实环境中的摩擦力、重量感和空间关系等关键信息很难仅从二维图像中完全掌握。于是,“具身原生”这一路径被提出。原力灵机认为,具身智能从诞生之初就需立足真实世界,聚焦“复杂环境中精准完成人类任务”,这也是此次发布具身原生大模型 DM0 的底层设计逻辑。</p><p>这一设计逻辑,首先体现为向物理世界要数据的范式转变。原力灵机合伙人周而进介绍,DM0 本质上是一个从头训练的多模态大模型,它的数据采集方案遵循了“熵在哪里,数据就投向哪里”的原则,除了提供通用语义的互联网图文,它更关键地纳入了体现复杂时空决策的自动驾驶序列数据,以及来自多种机器人平台的真实交互数据。这种融合为构建模型关于空间、力学与因果的认知框架,实现稳定泛化的动作执行能力提供基础。</p><p><img width="723" height="474" src="/img/bVdnTZz" alt="" title=""></p><p>具体实现上,DM0 模型采用的多任务与跨机型协同的训练方法进一步提升了强跨机型的泛化和迁移能力。<a href="https://link.segmentfault.com/?enc=yt49nfA%2FAu6ziqWz7AxImA%3D%3D.tC%2BTvhUE2Bw1ib0eiin6TgLXXv0yXzPXb%2FDycSFYInAGgvOPHtelrF7ML8vNQLv4i2cxR3p0DH37prBAsP1ZM%2FSQ52s%2FakEbIzLEiMRJogGxS%2F1LLA30vYtkJTQ8UGYVH9d%2FZigyHM3ron4ZLgvlzGbpjyJ41AjrqXZszwla2tY%3D" rel="nofollow">DM0 技术报告</a>显示,DM0 在预训练阶段就被置于一个多样环境中,同步学习抓取、导航、全身控制 3 类核心任务,并覆盖 UR、Franka、ARX、UMI、Aloha、R1-Lite、Realman、DOS-W1 等 8 种差异显著的机型。这种设计迫使模型剥离对特定硬件参数的机械记忆,转而学习通用逻辑与物理规律。</p><p>在这一设计路径下,DM0 模型在真机测试中获得了关键验证。DM0 在 RoboChallenge 平台的“Table 30”任务中取得了综合最高分,而其参数量仅为 2.4B,这意味着它的智能密度非常高。</p><p>此外,为了满足工业级精细操作,DM0 专门设计了 728×728 的高分辨率视觉输入,使其能在 720p的视频中捕捉细微的空间差异,显著提升精密装配等任务的定位可靠性。更具突破性的是,DM0 将机器人的“动作”从关节控制扩展为包含“拍照、扫码”等抽象指令的广义集合。这让机器人能够以连续、类人的作业逻辑,自主完成“抓取-调整-识别-扫码”的端到端流程。</p><p><img width="723" height="487" src="/img/bVdnT6L" alt="" title=""></p><p>目前,DM0 2.4B 版本已全面开源,支持在消费级显卡上微调。周而进介绍道,此举意在降低开发与科研门槛,让更多研究者能够基于 DM0 做二次开发或训练,从而推动产业共同验证并丰富“具身原生”这一技术范式。这或将为行业突破当前规模化落地的关键瓶颈,提供一条可供协作与迭代的开放路径。</p><h3>破解效率困境,让研发“自由”</h3><p>DM0 模型为产业提供了一个更强的泛化起点,但要将这类前沿模型转化为现实生产力,还需克服工程落地的重重障碍。高度碎片化的开发环境是核心痛点,数据格式、仿真平台与硬件接口的标准不一,使得从算法研究到真机部署的链条冗长而低效,大量创新精力被消耗在重复的适配工作中。</p><p><img width="723" height="487" src="/img/bVdnT6M" alt="" title=""></p><p>为了系统性地破解这一效率瓶颈,原力灵机将其开源框架 Dexbotic 升级至 2.0 版本。原力灵机合伙人汪天才阐述了此次重构的目的,“我们想通过这次重构进一步扩大 Dexbotic 在整个具身生态下的职能范围,让更多用户能够用它进行算法的开发,降低进行具身算法开发的门槛。” 其最核心的革新是采用了模块化架构,将机器人策略解耦为 V(Vision encoder)、L(LLM)、A(Action Expert)三个可自由组合的独立模块。开发者能通过像搭乐高一样,自由组合、快速验证新想法并适配不同硬件。更关键的突破是,这一设计统一了机器人长期以来相互割裂的操作与导航能力,推动其最终迈向全身协同控制的更高阶段。</p><p>框架的另一个特征是支撑多源异构数据的混合训练。这直接服务于如 DM0 这类“具身原生”模型的训练需求,能够无缝协调处理来自互联网、自动驾驶和机器人本体的不同性质数据,让模型在完整的统一流程中同步学习通用知识与专用技能。</p><p>为支撑这一复杂的多源训练范式并使其能够高效、可复现地转化为实际能力,Dexbotic 2.0 构建了一套从“数据—训练—评测—硬件”四个环节的标准化具身开发全流程。它定义了统一的数据规范以消除格式壁垒,集成了主流仿真评测工具以简化验证环节,并原生适配了多种机器人硬件平台,彻底将开发者从繁复的环境配置与适配工作中解放,提供了一条从算法原型直达真机验证的清晰路径。</p><p><img width="723" height="487" src="/img/bVdnT6N" alt="" title=""></p><p>伴随此次升级,Dexbotic 2.0 的开源生态建设也取得了实质进展。其与 RLinf 达成战略合作,目前已初步完成环境层面对接,并计划通过仿真复现与真机演示共同验证这套协同系统在复杂物理场景中形成有效生产力的潜力。</p><p>对于此次合作,汪天才的期望远超工具层面的对接。他认为,大语言模型之所以能爆发,关键在于找到了“SFT 和 RLHF”这套让模型与人类价值对齐的方法论。而现在,具身智能正站在相似的历史节点前。“我们期望与 RLinf 的合作能够复现并建立起这一已被验证的范式,最终形成覆盖具身智能全开发流程的‘SFT+RLHF’生态。”</p><h3>填补进化缺口,让生产力“持续”</h3><p>在原力灵机 CEO 唐文斌看来,“所有的价值是可以被衡量和计算的,如果不能的话,那这个东西是不能长期存续的。”当智能模型与开发工具就绪,如何让机器人在千差万别的真实场景中持续创造可靠价值,成为检验技术的最终标尺。</p><p><img width="723" height="487" src="/img/bVdnT6N" alt="" title=""></p><p>作为本次开放日的第三项重要发布,具身应用的量产工作流 DFOL(Distributed Field Online Learning)是原力灵机让具身智能从“展品”变为“产品”最具现实意义的一环。据介绍,DFOL 采用“硬件通用+模型智能”模式,构建了一套链接云端与现场的数据进化闭环。部署在产线或仓库中的机器人,能将作业过程中产生的训练片段(episode)与负样本块(negative chunk)实时反馈至云端。这些来自真实场景的数据经过处理,持续优化模型策略,并再次部署到所有机器人。这使得系统能够在实际运行中自主学习,应对物料差异、环境变化等不确定性,从而将固定程式转变为可持续进化的生产力。</p><p>值得关注的是,这一方案的有效运转,与 DM0 与 Dexbotic 2.0 两项技术基座的成熟度密切相关。DM0 模型所提供的强泛化能力,是系统能够快速理解新任务、获得可靠初始策略的智能前提;而 Dexbotic 2.0 框架及其标准化工具链,则为海量现场数据的高效回流、处理与迭代更新提供了工程化路径。三者共同构建了一个从“通用能力储备”到“高效开发部署”,最终实现“持续价值创造”的增强循环。</p><p>面对这种深度技术耦合可能带来的风险,DFOL 的设计逻辑本身便是应对之道。通过将现场运行数据实时反馈用于模型迭代,它将风险考量从依赖某一静态模块的“完美性”,转化为依赖整个系统“动态进化能力”的健康度。这意味着,对其商业落地的评估,将不再仅仅是评估一个固定版本模型的好坏,而是评估一套系统的学习与适应效率。</p><p>由此来看,此次相继发布的模型、框架与方案,是一条贯穿“能力构建-效率提升-价值闭环”的连贯路径。它们共同呈现了原力灵机的技术纵深,更展示了其对“具身智能规模化落地”的系统性思考。其“具身原生”的范式探索,不仅关乎企业的自身发展,也为整个产业如何打通从技术研发到规模商业化的路径,提供了一个值得深入观察的范例。</p><p>更多视频内容可见:<a href="https://link.segmentfault.com/?enc=yvTnrK%2B7GbQvDen%2FE%2BBgcw%3D%3D.8hqhxYyc1Ig4UnhRDZ4yJDk%2BuUaWqB%2Fgcmz4%2BzyUtDvqpaGZz7Y1SvPDPIrIgh1cKuH6FQr9rNjsrU4b8w0FnA%3D%3D" rel="nofollow">https://mp.weixin.qq.com/s/d2r9u3tZImzXxrHzjBvDrw</a></p>

2026/2/10
阅读更多

ONES 2025 年度盘点:筑基十载,全新以赴

<p><img width="723" height="1856" src="/img/bVdnT31" alt="" title=""><img width="723" height="4785" src="/img/bVdnT32" alt="" title=""><img width="723" height="2473" src="/img/bVdnT33" alt="" title=""><img width="723" height="4003" src="/img/bVdnT34" alt="" title=""><img width="723" height="2900" src="/img/bVdnUhD" alt="" title=""><img width="723" height="3460" src="/img/bVdnT4m" alt="" title=""><img width="723" height="988" src="/img/bVdnT4o" alt="" title=""></p>

2026/2/10
阅读更多

2025年,ONES 收获了这些标杆客户的好评

<p><img width="723" height="2692" src="/img/bVdnT4G" alt="" title=""><img width="723" height="3207" src="/img/bVdnT4H" alt="" title=""><img width="723" height="2207" src="/img/bVdnT4I" alt="" title=""><img width="723" height="3125" src="/img/bVdnT4K" alt="" title=""><img width="723" height="3558" src="/img/bVdnT4L" alt="" title=""><img width="723" height="5295" src="/img/bVdnT4M" alt="" title=""><img width="723" height="2164" src="/img/bVdnT4N" alt="" title=""></p>

2026/2/10
阅读更多

Qnap威联通Qu405的入手配置手记

<h2>我的硬件</h2><h3>NAS型号</h3><p>威联通 Qu405 四盘位</p><h3>硬盘 HDD/SSD</h3><ul><li>WD40EFZZ 西数红盘 4T 2块</li><li>PM863a SSD 960G 2块</li></ul><h3>UPS电源</h3><p>cyberpower UT650EGC</p><h2>硬件组装</h2><p>根据收到的NAS所带的说明书,将你的硬盘和SSD安装到你的NAS中。</p><p>完成组装后,用网线将你的NAS与路由连接。</p><h3>连接UPS,启动NAS</h3><ul><li>将UPS电脑接入市电插座</li><li>将NAS电脑接入UPS的输出端插孔位置</li><li>使用UPS自带的USB数据线连接UPS和NAS</li><li>根据UPS说明书的操作说明,启动UPS电源</li><li>根据NAS说明书的操作说明,启动NAS。</li><li>等待一段时间,NAS上线</li></ul><h2>系统设置</h2><h3>找到NAS</h3><p>NAS第一次上电后,一般会通过路由器自动分配一个IP地址联网,此时要想登录NAS的界面,你需要使用Qnap提供的软件 <strong>Qfinder</strong>,您可以在<a href="https://link.segmentfault.com/?enc=A7%2FtRh8n0rIgGliwl%2FSZIQ%3D%3D.DQRY18PlgFV7rU5gIEc1TkdVegGM3%2BH8DzaQFrqAWqiRY483tigoD3wDyc8LhXZm" rel="nofollow">这里下载安装👇</a> <br><img src="/img/remote/1460000047601118" alt="alt text" title="alt text"> <br><img src="/img/remote/1460000047601119" alt="alt text" title="alt text"> <br>打开<strong>Qfinder</strong>后,它可以自动搜索你当前局域网下的Qnap设备,找到您的NAS,即可登入,如下👇 <br><img src="/img/remote/1460000047601120" alt="alt text" title="alt text"></p><p>第一次登录NAS时,一般系统会引导你完成一些必要的系统设置,例如对于Qu405来说,会引导你选择安装QTS系统或者是QuTS系统,我选择的是QTS系统。</p><h2>UPS联动</h2><p>当我们找到我们的NAS,并进入NAS界面后,我们前往〔控制台〕/〔系统〕/〔外部设置〕/〔UPS〕,完成USP联动设置,如下👇 <br><img src="/img/remote/1460000047601121" alt="alt text" title="alt text"></p><h2>设置存储池和存储卷</h2><h3>创建Qtier存储池</h3><p>Qtier技术是Qnap独创的冷热数据分层存储融合技术,最大化的充分利用了SSD和HDD硬盘的效率。它可以将高频访问的热数据存储在SSD区,将不常访问的冷数据放在HDD存储区。相对于SSD缓存来说,Qtier技术可以将SSD的空间纳入到存储池中,避免了SSD空间的浪费。更为详细的关于SSD缓存方案和Qtier方案的对比分析,参考<a href="https://link.segmentfault.com/?enc=09X6OGRMaFqe0PrIO9z1aA%3D%3D.V%2F6iD4VpuHnV4wKJh7uYBmo2QP9cilTAqXTcGltpjKPTgoPImq1CV%2BPCl78kYk%2BNgc0qNKAFU8zw0YODPjxluSS8lvBn%2FFt5zcbVvxfiuEmihr%2FcYVB6eBTBv5aKzkZb" rel="nofollow">Qnap官网的说明</a>。</p><p>就我本次的配置来说,将两块SSD配置为超高速层,组RAID1阵列,并保留了25%的容量空间;将两块HDD硬盘组RAID1阵列,做为容量层,保留20%的快照空间;报警阈值设置为90%。配置完成如下👇 <br><img src="/img/remote/1460000047601122" alt="alt text" title="alt text"></p><h3>创建App精简卷〔系统卷〕</h3><p>首先创建的卷,会被系统自动识别为系统卷,将来NAS安装的应用,会默认安装于此卷。 <br><img src="/img/remote/1460000047601123" alt="alt text" title="alt text"></p><p>在Qtier存储池上,创建一个容量为200G的精简卷(后期根据需要可以增加),该卷主要用于供系统使用,用于安装应用程序,不要在这里存放用户的数据。如下👇 <br><img src="/img/remote/1460000047601124" alt="alt text" title="alt text"></p><h3>创建Data精简卷</h3><p>在Qtier存储池上,创建一个容量为1T的精简卷(后期根据需要可以增加),该卷主要用于存放各种资料数据,相当于用户的云空间。如下👇 <br><img src="/img/remote/1460000047601125" alt="alt text" title="alt text"></p><h3>创建Home精简卷</h3><p>在Qtier存储池上,创建一个容量为100G的精简卷(后期根据需要可以增加),该卷主要用于存放各用户的Home目录,主要目的是为用户提供一个临时的云端空间,以供临时性的转移文件使用,这个卷不启用快照功能。</p><p>👆以上Qtier存储池和三个存储卷设置完成后,效果如下👇 <br><img src="/img/remote/1460000047601126" alt="alt text" title="alt text"></p><h2>快照计划</h2><p>完成卷设置后,为需要的卷配置合理的快照计划,可以有效的保障数据安全(必要时回滚,&lt;font color=gray&gt;一个具体的例子是,如果你本地的文件被病毒破坏了,而你的同步工具又将已经被破坏了的文件同步到了NAS上,此时如何恢复这个文件呢?就只能通过快照来恢复了&lt;/font&gt;)。选中需要配置快照的卷,然后点〔快照〕/〔快照管理员〕,即可对该卷的快照计划进行配置。 <br><img src="/img/remote/1460000047601127" alt="alt text" title="alt text"></p><h3>App卷快照计划</h3><p>App卷主要用于安装系统应用程序,为了防止在折腾过程中把系统玩崩,所以有必要启用快照功能。一般来说,在折腾NAS应用时,最好的习惯就是手动创建快照,然后再折腾。不过快照计划也为手残党提供了最后的兜底保障。考虑到一般会在周未折腾NAS,所以在每周折腾完成后,在周一的凌晨创建一次快照即可(保存折腾的成果)。如下👇 <br><img src="/img/remote/1460000047601128" alt="alt text" title="alt text"></p><h3>Data卷快照计划</h3><p>数据算是NAS使用的主要目的了,所以数据卷的快照是有必要的,所以我们为数据卷的快照设置每天1个,保留7天.如下👇 <br><img src="/img/remote/1460000047601129" alt="alt text" title="alt text"></p><h3>Home卷快照计划</h3><p>根据以上分析,我们不为Home卷起用快照,如下👇 <br><img src="/img/remote/1460000047601130" alt="alt text" title="alt text"></p><h3>设置用户家目录</h3><p>如上, 我们为用户家目录单独创建了Home卷,现在我们统一设置用户家目录配置到Home卷,如下👇 <br><img src="/img/remote/1460000047601131" alt="alt text" title="alt text"></p><h2>外网访问</h2><p>网络是NAS的基础,否则您只能在您家的局域网内使用NAS,这无异于龙游浅池。</p><p>如果你想在公网访问你的Qnap设备,你首先需要有一个公网IP,你可以向你的电信运营商申请要求给你分配一个公网IP,一般来说,这个IP是隔段时间变化的,但是这不要紧。</p><p>有了公网IP的前提后,Qnap提供了免费的DDNS服务,你只需要以下设置,便可以在公网环境使用你的NAS设备。</p><ul><li>找到NAS上的网络设置 <br><img src="/img/remote/1460000047601132" alt="alt text" title="alt text"></li><li>找到设定,设置静态IP,为你的Qnap设备设置一个静态IP,记住这个IP,后面要用 <br><img src="/img/remote/1460000047601133" alt="alt text" title="alt text"> <br><img src="/img/remote/1460000047601134" alt="alt text" title="alt text"></li><li>设置系统访问端口,和服务器名称,记住这个端口号和名称,后面要用 <br><img src="/img/remote/1460000047601135" alt="alt text" title="alt text"></li><li>设置路由器的端口转发规则,设置你的路由器,将来自外部的请求转发给你的Qnap设备。这里的局域网IP就是你的NAS上设置的静态IP,这里的内部端口就是你NAS上设置的系统访问端口参考上文 <br><img src="/img/remote/1460000047601136" alt="alt text" title="alt text"></li><li>设置myQNAPcloud云服务,使用Qnap提供的DDNs功能,解析你的公网IP <br><img src="/img/remote/1460000047601137" alt="alt text" title="alt text"> <br><img src="/img/remote/1460000047601138" alt="alt text" title="alt text"> <br>DDNs开通后,会生成一个免费的二级域名,样式如: www.服务器名称.mycloudnas.com,将来你可以通过该域名来访问你的Qnap设备。</li><li>完成以上设置后,便可以测试你的DDNs是否连通了。在DDNS设置页面的底部,点击【测试连接性】,观测域名是否能成功连接到Qnap设备。👇如下可以看到系统访问端口可以连通到Qnap了。 <br><img src="/img/remote/1460000047601139" alt="alt text" title="alt text"></li><li>做为测试,你可以将你的手机切换为非wifi模式,输入您的DDNS域名和端口号,便可以打开NAS页面(由于一些原因,在大陆使用Qnap的域名解析连接性不佳,有条件的还是建议自建DDNs服务) <br><img src="/img/remote/1460000047601140" alt="alt text" title="alt text"></li></ul><p>🚩 如果你无法打开NAS页面,或者收到 〔ERR_NAME_NOT_RESOLVED〕,那是因为你的网络环境无法连接到Qnap的DDNS服务器所致,原因嘛,大家都懂得。</p><h2>自建DDNS解析脚本</h2><p>除了上述通过Qnap二级域名实现外网访问外,我们还可以自己申请一级域名(一些冷门域名很便宜的,一次性买10年也不过百元级),然后我们在NAS上通过ddns-go脚本将我们的NAS的公网IP(实际应该是你的路由器的IP地址)更新到域名上,这样我们就可以通过我们自己的域名自由访问我们的NAS了。</p><p>ddns-go是一个运行于Docker里的镜像容器,我一直在使用不低于2年时间,稳定性是非常棒的。</p><h3>准备域名</h3><p>我是在阿里云上申请的域名。自己用嘛,不求闻达于诸侯,所以越便宜越好,能解析是唯一的要求。 <br><img src="/img/remote/1460000047601141" alt="alt text" title="alt text"></p><p>我使用的是免费套餐,在国内全胜基本没有遇到无法解析的情况,也可以根据自己的实际使用需求,开通付费套餐。 <br><img src="/img/remote/1460000047601142" alt="alt text" title="alt text"></p><h3>安装ContainerStation</h3><p>ContainerStation是Qnap NAS中运行Docker的平台,你可以在NAS中的AppCenter中找到并安装它。 <br><img src="/img/remote/1460000047601143" alt="alt text" title="alt text"></p><p>等ContainerStation安装完成后,我们便可以在里面运行我们需要的镜像包了。 <br><img src="/img/remote/1460000047601144" alt="alt text" title="alt text"></p><p>由于DockerHub在国内的访问性不佳,所以推荐在ContainerStation中添加阿里云Docker镜像地址,以提高Docker镜像的拉取速度。打开<a href="https://link.segmentfault.com/?enc=Cm3osOz01YVK%2F0riw71DYg%3D%3D.SG4s26lfeeboq11aH71tnD8Tu%2F4mzrqrMxL34i2%2BG8o%3D" rel="nofollow">容器镜像服务</a>(需要登录你的账号,每个账号的镜像地址都是不一样的),在👈左侧找到〔镜像工具〕/〔镜像加速器〕,复制加速器地址备用。 <br><img src="/img/remote/1460000047601145" alt="alt text" title="alt text"></p><p>然后打开ContainerStation,找到〔存储库〕,然后〔添加〕 <br><img src="/img/remote/1460000047601146" alt="alt text" title="alt text"></p><h3>使用jeessy/ddns-go进行DDNS动态解析</h3><p>本人使用jeessy/ddns-go进行DDNS解析,ddns-go是一个开源在dockerHub上的docker镜像,大家可以在ContainerStation中从dockerHub提取该镜像,也可以在电脑上下载 <br><a href="https://link.segmentfault.com/?enc=qkVfGlxhRG0EKoD7iz2VHA%3D%3D.MU8hUKEs0dkafzQxNpVxJPiA39EIQ%2B2uCKikOe0Pcnnbf8u4spnTGyHYmHbGAv3KxccRjx8SDp2zycifZ1gU8Q%3D%3D" rel="nofollow">👉ddns-go镜像文件(amd64 CPU)</a> <br><a href="https://link.segmentfault.com/?enc=HMMtEKJ2DDY2D%2BFYaSaX1g%3D%3D.vm5ZKNYeLoOCinlciUhJpwgOVszAldlh0PqXAv%2Fc%2FN2WV%2Fc000o62YGKre07zdAfOTWFGU0Td3KJ3qh7Np0alw%3D%3D" rel="nofollow">👉ddns-go镜像文件(arm CPU)</a> <br>然后导入到ContainerStation中使用。 <br><img src="/img/remote/1460000047601147" alt="alt text" title="alt text"></p><h4>生成容器</h4><p>ddns-go的镜像,相当于是可以运行的一个exe文件,我们现在需要把这个exe文件〔其实是docker镜像〕运行起来,就是说需要创建一个让镜像跑起来的容器。 <br><img src="/img/remote/1460000047601148" alt="alt text" title="alt text"></p><p>等待容器创建完成 <br><img src="/img/remote/1460000047601149" alt="alt text" title="alt text"></p><p>在容器页面就可以看到已经在运行的ddns-go了 <br><img src="/img/remote/1460000047601150" alt="alt text" title="alt text"></p><p>🚩 如果容器运行出现 exec /app/ddns-go: exec format error 这种错误,说明ddns-go与Qnap设备的CPU架构不兼容,你需要换一下ddns-go镜像。</p><h4>设置ddns-go参数</h4><p>在局域网环境下,在浏览器中打开你的NAS IP地址 + 端口号 9876(例如192.168.1.43:9876), 就可以对ddns-go进行参数设置了: <br><img src="/img/remote/1460000047601151" alt="alt text" title="alt text"></p><p>登录后,可以看到参数设置页面 <br><img src="/img/remote/1460000047601152" alt="alt text" title="alt text"></p><p>👆上图中,点击〔创建 AccessKey〕即可跳转到对应dns服务商的AccessKey创建页面(可能需要登录),根据引导完成AccessKey创建后,将AccessKey ID和AccessKey Secret填回到本页面中 <br><img src="/img/remote/1460000047601153" alt="alt text" title="alt text"></p><p>在IPV4设置一栏,把你申请到的域名填写到Domains中,如下👇 <br><img src="/img/remote/1460000047601154" alt="alt text" title="alt text"></p><p>翻到最下面,如果你有Webhook通道(例如可能在飞书中添加消息机器人,开通Webhook连接),那么你可以在这里设置Webhook通知,当IP变化时,通过Webhook向你发送通知消息。如下👇 <br><img src="/img/remote/1460000047601155" alt="alt text" title="alt text"></p><p>对于飞书机器人,我用的RequestBody设置如下👇</p><pre><code class="json">{ &quot;msg_type&quot;: &quot;post&quot;, &quot;content&quot;: { &quot;post&quot;: { &quot;zh_cn&quot;: { &quot;title&quot;: &quot;Hi,你的公网IP变了&quot;, &quot;content&quot;: [ [ { &quot;tag&quot;: &quot;text&quot;, &quot;text&quot;: &quot;IPv4地址:#{ipv4Addr}&quot; } ], [ { &quot;tag&quot;: &quot;text&quot;, &quot;text&quot;: &quot;域名更新结果:#{ipv4Result}&quot; } ] ] } } } }</code></pre><p>飞书上收到的消息如下👇 <br><img src="/img/remote/1460000047601156" alt="alt text" title="alt text"></p><p>以上设置完成后,您的ddns应该已经可以正常解析更新了,在容器界面,选中ddns-go容器,观察其日志小黑窗口,应该有如下消息出现👇 <br><img src="/img/remote/1460000047601157" alt="alt text" title="alt text"></p><p>至此,你可以通过以上设置,配合你的路由器的端口转发,你便可以通过域名:端口访问你的NAS设备了。你如你可以通过 www.zhsan.xyz:1234 访问到你的NAS。</p><h2>无密码登录</h2><p>你需要在移动端下载并安装APP &lt;font color=Green&gt;QNAP Authenticator&lt;/font&gt;,你可以在<a href="https://link.segmentfault.com/?enc=c7BzvhrQmw2%2B%2FeTUoC28Ww%3D%3D.H4Ii%2B9%2BWM20T1SYm9zicsa%2Fcq5s2CH8KlP%2BrdYrkMAEJl%2F8fCMy5u5KbMAQSSCukLzf4WS%2BZUJmqlD%2FO0SFNwQ%3D%3D" rel="nofollow">Qnap官网</a>下载到该APP: <br><img src="/img/remote/1460000047601158" alt="alt text" title="alt text"> <br>然后在您的NAS界面,找到〔登录和安全性〕,如下👇 <br><img src="/img/remote/1460000047601159" alt="alt text" title="alt text"> <br>根据引导设置无密码登录〔或者使用两步验证〕,如下👇 <br><img src="/img/remote/1460000047601160" alt="alt text" title="alt text"> <br>设置成功后,您将看到你的受权的设备如下👇<br><img src="/img/remote/1460000047601161" alt="alt text" title="alt text"> <br>🚩 在设置〔二次验证〕或者是〔无密码登录〕时,将有两个验证方式,一种是本地验证,一种是云端验证;</p><ul><li>〔本地验证〕是指在你的局域网中寻找识别NAS设备进行验证,这种验证方式只能当你的移动设备和NAS处于同一局域网时可用使用。</li><li><p>〔云端验证〕是指通过登录Qnap ID账号,从你的Qnap ID账号认证的设备列表中寻找识别NAS设备进行验证,这种验证方式不受局域网环境限制。但是这种验证方式需要具备以下条件:</p><ul><li>你的设备已经在Qnap ID中注册</li><li>你的NAS开通了DDNS服务,这将使得Qnap服务器可以找到并连接你的NAS设备</li></ul></li></ul><h2>HTTPS安全访问</h2><p>如果你需要在外网访问你的NAS设备,Https加密通信就是必要的。当你在外网访问你的NAS设备时,就像你在用手机和你的NAS设备通电话。</p><ul><li>如果你们使用http访问NAS,就像是你们的通话是明文通话,如果有坏人监听了你们的通话,那么他就知道你们说了什么,一旦让他监听到了你的NAS的用户名和密码,那他就可以通过这个用户名和密码登录到你的NAS,这与家里进了贼没有什么区别。</li><li>如果你们全胜https访问NAS,就像是你们的通话是用经过加密的暗语,你们的通话内容除了你们自己能懂之外,即使是被坏人监听到了,它也无法理解你们的通话内容,也就不会泄露秘密。</li></ul><p>为了全胜https通信,我们需要为https配置SSL证书,这个SSL证书的作用就是负责给https通信的内容进行加密。</p><h3>申请SSL证书</h3><p>一般来说,SSL证书需要付费(年付)使用,但是我们也可以获取到免费的测试用的SSL证书,这些免费的SSL证书的加密效果与付费的SSL证书的加密效果是一样的。</p><p>阿里云和腾讯云都提供限时免费的SSL证书以供测试使用,我的域名是在阿里云进行的解析,所以此处以阿里云的SSL证书申请为例来进行说明。</p><ul><li>登录<a href="https://link.segmentfault.com/?enc=TfrGluFjVHzizjU2SijEGQ%3D%3D.L38mcim4%2B2FJJSxFxHTaQDYnzwSNI3yRgqViDbRw1%2BGuuwu%2B9hWNcNIUUZShC6gOrhOOq8gMDMZN3gm7GlKA8Csrw4LTlD3Y0eVdy5wuv9f9tg0PE5ZA9oYk1fSheHBL" rel="nofollow">阿里云平台</a>,搜索 〔数字证书管理服务〕,然后〔登录控制台〕,如下👇 <br><img src="/img/remote/1460000047601162" alt="alt text" title="alt text"> <br><img src="/img/remote/1460000047601163" alt="alt text" title="alt text"></li><li>购买免费SSL证书名额(每个账户每年有一次购买机会,一次买20个证书名额,一个名额用90天),如下👇 <br><img src="/img/remote/1460000047601164" alt="alt text" title="alt text"> <br><img src="/img/remote/1460000047601165" alt="alt text" title="alt text"></li><li>创建SSL并提交申请,根据下图填写必要的信息后,提交审核,如下👇 <br><img src="/img/remote/1460000047601166" alt="alt text" title="alt text"></li><li>验证域名,当你提交了SSL申请后,我们在SSL证书管理页面就可以看到我们提交SSL申请的记录了,此时我们需要验证一下我们的域名是否OK, 点击SSL申请记录右侧的〔验证〕按钮,如下👇 <br><img src="/img/remote/1460000047601167" alt="alt text" title="alt text"></li><li>在弹出的证书申请页中,我们找到第三步,点击〔验证〕按钮,等待验证完成后,显示“域名验证成功。。。。。”信息,如下👇 <br><img src="/img/remote/1460000047601168" alt="alt text" title="alt text"></li><li>等待SSL签发。做完以上步骤后,我们只需要等待域名签发就可以了,如下👇 <br><img src="/img/remote/1460000047601169" alt="alt text" title="alt text"></li><li>下载SSL证书,解压后备用,如下👇 <br><img src="/img/remote/1460000047601170" alt="alt text" title="alt text"></li></ul><h3>安装SSL证书</h3><p>我们申请到了SSL证书后,我们就需要将它安装到我们的NAS中,才能使用https访问我们的NAS。我们回到NAS页面,操作如下👇 <br><img src="/img/remote/1460000047601171" alt="alt text" title="alt text"></p><p>然后等待证书导入完成即可,如下👇 <br><img src="/img/remote/1460000047601172" alt="alt text" title="alt text"></p><p>🚩<strong>注意,我们申请的SSL证书只有90天有效,过期后我们需要重新通过以上步骤申请新的SSL证书并导入。或者可以付费申请期限为1年的SSL证书,这样就可以自动延期了。</strong></p><h3>启用HTTPS访问</h3><p>以上工作完成后,我们便可以为我们的NAS启用HTTPS服务了。我们把https的端口号设置为12345(此处为举例),设置如下👇 <br><img src="/img/remote/1460000047601173" alt="alt text" title="alt text"></p><h3>调整路由器端口映射</h3><p>在上图中,我们可以看到我们的NAS的系统端口有两个,一个是1234, 一个是1235。</p><ul><li>其中1235是https服务端口,这个端口的通信是加密的,是安全的。我们可以把这个端口曝露在公网上使用,我们在公网环境下,可以通过 <a href="https://link.segmentfault.com/?enc=%2Bj4d8CXlnemn4l4ViDk%2F3w%3D%3D.yMZKyeJZGL%2FNSywDnSb7fNA1WKQhTkHkg7RSPPTxydE%3D" rel="nofollow">https://www.zhangsan.xyz:1235</a> 来以https方式访问我们的NAS。</li><li>其中1234是http服务端口,这个端口的通假是没有加密的,是不安全的。所以我们不能把这个端口曝露在公网上,这个端口只可以在局域网环境下访问NAS。</li></ul><p>综合以上,我们需要在我们的路由口中调整端口映射规则,只把端口1235映射曝露到公网上,如下👇 <br><img src="/img/remote/1460000047601174" alt="alt text" title="alt text"></p><h2>关于公网安全</h2><ul><li><h3>公网端口</h3></li></ul><p>默认的NAS端口一般是5000,或者5001,这也是黑客重点扫描的NAS端口,建议大家在设置外网端口时,避开这两个端口。</p><ul><li><h3>Telnet/SSH</h3></li></ul><p>这两个功能,不要曝露在公网,如果不使用,也不要打开,如果要用,用完记得关闭<br><img src="/img/remote/1460000047601175" alt="alt text" title="alt text"></p><ul><li><h3>禁用admin账户</h3></li></ul><p>不要使用(禁用)NAS默认的管理员账户,这个一般NAS系统是默认禁用的<br><img src="/img/remote/1460000047601176" alt="alt text" title="alt text"></p><ul><li><h3>开启登录两步验证</h3></li></ul><p>进一步提高NAS页面的登录安全性 <br><img src="/img/remote/1460000047601177" alt="alt text" title="alt text"></p><ul><li><h3>关闭UPnp服务</h3></li></ul><p><img src="/img/remote/1460000047601178" alt="alt text" title="alt text"></p><ul><li><h3>关闭Bonjour服务</h3></li></ul><p><img src="/img/remote/1460000047601179" alt="alt text" title="alt text"></p><ul><li><h3>启用IP防护</h3></li></ul><p><img src="/img/remote/1460000047601180" alt="alt text" title="alt text"></p><ul><li><h3>启用账户防护</h3></li></ul><p><img src="/img/remote/1460000047601181" alt="alt text" title="alt text"></p><ul><li><h4>关闭IPV6</h4></li></ul><p>如果你有了IPV4的公网IP,则通过IPV4即可访问NAS,此时关闭IPV6有助于提高NAS的安全性,尤其避免在域名解析中解析IPV6<br><img src="/img/remote/1460000047601182" alt="alt text" title="alt text"></p><h2>Qsync文件同步</h2><p>Qnap提供的Qsync文件同步技术,是一个非常好用,也总是被忽略的技术。尤其是Qsync提供的节省空间模式,让用户可以在存储空间较小的电脑上,也能处理NAS上的海量文档。</p><h3>NAS安装Qsync</h3><p>为了使用Qsync功能,我们需要在NAS上安装Qync服务端,我们在AppCenter中寻找并安装Qsync,如下👇 <br><img src="/img/remote/1460000047601183" alt="alt text" title="alt text"></p><p>Qsync安装完成后,我们可以打开Qsync,为共享文件夹授予使用权限,如下👇 <br><img src="/img/remote/1460000047601184" alt="alt text" title="alt text"></p><h3>电脑端安装Qsync</h3><p>在你需要同步文件的电脑上,也需要安装Qsync客户端,这样Qsync就可以帮我们同步电脑-NAS两端的文件了。你可以<a href="https://link.segmentfault.com/?enc=VwnCXMwFsVHM811jhKe6Mg%3D%3D.ktzBg2Le8sTQs%2F6P2XU9YdkZkLVMu2PsciqKb5wGP7Pb1C%2BdfUlQhBqdnIehQozC1TrmB3rHRW2qb7fYv90vtQ%3D%3D" rel="nofollow">👉在这里下载到Qsync</a> <br><img src="/img/remote/1460000047601185" alt="alt text" title="alt text"> <br><img src="/img/remote/1460000047601186" alt="alt text" title="alt text"></p><p>Qsync下载安装完成后,你可以在Qsync中配置文件夹同步任务,如下👇 <br><img src="/img/remote/1460000047601187" alt="alt text" title="alt text"> <br>然后选择同步任务👇 <br><img src="/img/remote/1460000047601188" alt="alt text" title="alt text"> <br>然后填写NAS的登录信息,以便Qsync可以知道你要同哪个NAS上同步数据,👇 <br><img src="/img/remote/1460000047601189" alt="alt text" title="alt text"> <br>然后,你就可以添加同步的双方文件夹了,如下👇 <br><img src="/img/remote/1460000047601190" alt="alt text" title="alt text"> <br><img src="/img/remote/1460000047601191" alt="alt text" title="alt text"></p><p>🚩 <strong>关于〔节省空间模式〕,最值得称道的一点,就是它不会让我们在本地编辑文档的时候,被因Qsync的同步功能而锁定文档,导致我们编辑的文档无法保存。然而,这在一些其它技术不用心的网盘工具中,是经常发生的,这导致用户的编辑经常无法保存而丢失。大家在选择网盘工具和同步工具时,需要特别注意这一点</strong></p><p>通过Qsync同步文档,在文档的图标上会有所区别。👇如下图所示,&lt;font color=red&gt;<strong>1</strong>&lt;/font&gt;号位置的文档没有云朵图标,说明这个文档是在本地的;&lt;font color=red&gt;<strong>2</strong>&lt;/font&gt;号位置的文档有云朵图标,说明这个文档是在云端的,它在本地不占用空间,当你打开他时,或者移动它时,Qsync会感知到你的操作,会自动从NAS上把文档同步到本地,以供你使用。 <br><img src="/img/remote/1460000047601192" alt="alt text" title="alt text"></p><h3>公网下连接Qsync</h3><p>如果你的电脑处于公网环境下,请按以下操作切换<strong>Qsync</strong>的连接方式,如下👇 <br><img src="/img/remote/1460000047601193" alt="alt text" title="alt text"></p><h2>使用Qfile Pro</h2><p><strong>Qfile</strong>是Qnap开发的移动端使用的NAS文件同步工具,我们上文中介绍了<strong>Qsync</strong>,<strong>Qsync</strong>这个工具也有移去版本的app,功能与电脑版本的完全一致。但是在移去端,我更喜欢全胜<strong>Qfile</strong>连接NAS,<strong>Qfile</strong>集成了<strong>Qsync</strong>的文件同步功能的同时,我觉得更吸引我的点在于,<strong>Qfile</strong>不会把<strong>NAS</strong>上的文档同步到本地,这恰好避免了<strong>NAS</strong>上的海量文档对手机浏览功能的污染(例如<strong>NAS</strong>上的图片会污染手机想册的浏览体验)。</p><h3>下载Qfile Pro</h3><p>Qnap官网提供了<strong>Qfile Pro</strong>的下载链接,如下👇 <br><img src="/img/remote/1460000047601194" alt="alt text" title="alt text"></p><p>下载对应的版本,完成<strong>Qfile Pro</strong>的安装。</p><h3>局域网下连接NAS</h3><p>我们完成<strong>Qfile</strong>的安装后,我们首先在局域网下进行<strong>Qfile</strong>与<strong>NAS</strong>的链接。具体操作如下👇 <br><img src="/img/remote/1460000047601195" alt="alt text" title="alt text"></p><ul><li>👉其中第&lt;font color=red&gt;<strong>15</strong>&lt;/font&gt;步,可以根据情况选择〔邮件认证〕或者是〔输入密码〕。</li><li>👉另外需要注意第&lt;font color=red&gt;<strong>11</strong>&lt;/font&gt;步,打开〔检测可用的连接方法〕。</li></ul><h3>外网环境下连接NAS</h3><p>我们希望在外网环境下也可以在手机上查阅NAS上的资料,但这需要我们的路由器为我们打开NAS的连接端口(这在〔<strong>HTTPS安全访问</strong>〕一章中已经有所说明)。</p><p>我们关闭wifi,使手机处于4G网络状态下进行操作,如下👇 <br><img src="/img/remote/1460000047601196" alt="alt text" title="alt text"> <br>继续操作如下👇 <br><img src="/img/remote/1460000047601197" alt="alt text" title="alt text"></p><p>以上操作完成后,我们就可以在外网环境下连接我们的NAS了。随时随地可以访问NAS,这才能发挥NAS最大的价值。</p><h3>内外网自适应</h3><p>当我们完成以上的操作后,我们的<strong>Qfile</strong>可以在外网环境下连接家里的NAS,但是它不能根据我们所处的网络环境(内网 or 外网)自动切换连接方式,这导致我们手机的网络环境切换后,<strong>Qfile</strong>就无法连接NAS,需要我们每次手动切换连接方式。要想让<strong>Qfile</strong>可以自动根据网络环境切换连接方式,请完成以下操作👇 <br><img src="/img/remote/1460000047601198" alt="alt text" title="alt text"></p><p>完成以上操作后,<strong>Qfile</strong>便可以根据我们的设备所处的网络环境自适应调整连接方式了。</p><p>关于<strong>Qfile</strong>的其它使用方式,这是非常丰富的,各位自行摸索吧。</p>

2026/2/9
阅读更多

Atlassian DC 停服还涨价!留给中国企业的窗口期还有多久?

<p>近日,Atlassian 官方宣布:自 2026 年 2 月 17 日起,其 Data Center(数据中心版)产品将迎来约 15% 的价格上调,覆盖 Jira、Confluence、Jira Service Management 等核心产品。</p><p><img width="723" height="470" src="/img/bVdnRbu" alt="来源:Atlassian 官网" title="来源:Atlassian 官网"></p><p>值得注意的是,去年 Atlassian 已明确了 Data Center 的停售与最终停服规划。对于众多依赖 Atlassian Data Center 进行项目管理的中国企业而言,继续留在 Data Center 不仅需负担更高的 IT 成本,更面临着断供风险。</p><p><a href="https://segmentfault.com/a/1190000047253394">Jira 官宣停售 Data Center ,中国企业又双叒要迁移了?!</a></p><h3><strong>窗口期加速收窄:高昂的价格之外,还有「滞后风险」</strong></h3><p>Atlassian Data Center 在产品生命周期进入倒计时阶段依然上调价格,向市场释放了清晰信号:Data Center 版的维护成本与门槛将持续推高,留给中国企业平滑迁移的窗口期正迅速收窄。</p><p>对处于安全合规「深水区」的企业而言,必须站在业务连续性的高度,重新审视核心研发管理工具的长期自主性与安全策略。</p><p>如果此时不主动筹谋,未来可能面临以下三重严峻挑战:</p><p><strong>1.安全与合规挑战:难以逾越的「数据红线」</strong><br>Atlassian 本轮价格上调与其「云优先」的战略深度绑定。 Atlassian 在中国大陆没有本地服务器,选择 Cloud 版即意味着核心研发数据需存储于境外,对金融、政务、能源及高新制造等数据主权敏感的行业来说,无异于把数据安全暴露在不可控的风险之中。</p><p><strong>2.迁移工程挑战:确保业务迁移「平稳着陆」</strong><br>对于深度依赖 Jira Data Center 的中大型企业,多年累积的项目数据、文档资产和复杂业务流程,不仅是核心资产,也构成了团队的工作惯性。在评估替代方案时,企业必须从多维度确保迁移的可行性:确保海量历史数据与字段配置能够实现高匹配度迁移确保拥有完备的迁移计划与可追溯的全程服务确保支持分批迁移与风险控制,保障业务在迁移过程中不断档</p><p><strong>3.IT 成本挑战:难以预测的「成本黑洞」</strong><br>自 2021 年起,Atlassian Cloud 版价格已连年持续上涨,这种频繁且单方面的价格变动,让企业的 IT 预算规划极为被动。若未来选择迁移上云,企业不仅需接受未来不可预测的持续涨价,还可能将额外承担员工培训、系统集成、插件开发等隐性成本。</p><h3>ONES:面向企业长期需求的主流国产替代方案</h3><p>在核心研发管理工具的不可控风险面前,寻找一个更加可靠的本土替代方案已不再是「备选项」,而是关乎企业研发数字资产安全的「必选项」。</p><p>ONES 作为国内领先的企业级研发管理平台,凭借功能对标、自主可控、安全合规、高性价比与本地化服务等核心优势,已成为众多央国企及行业头部企业替换 Jira 和 Confluence 的首选。积累 6 年迁移经验,ONES 帮助超过百余家客户完成数据的平滑迁移,单个客户最大迁移数据体量超过 9.5 TB,正式迁移成功率达 100%。</p><p><img width="723" height="704" src="/img/bVdnRbO" alt="" title=""></p><h3>专业可靠的迁移服务,确保企业资产平滑着陆</h3><p>ONES 提供行业领先的端到端迁移服务与工具,确保企业知识资产与业务流程的完整、平稳过渡。</p><ul><li><strong>全面的数据兼容性</strong>:实现对 Jira 的字段映射、任务类型、状态流转及权限配置的高匹配迁移;针对 Confluence 文档,实现结构与样式的最大化保留,确保团队原有的工作习惯与知识资产「零损耗」。</li><li><strong>全流程风险受控</strong>:借助 ONES 自助迁移工具,可实现中等规模数据的自动化搬迁;对于超大型实例,我们提供分批迁移与风险监控机制,并输出详尽的迁移报告,确保过程可追溯,业务不断档。</li></ul><p><img width="723" height="434" src="/img/bVdnRbU" alt="" title=""></p><h3>坚定的本地化部署承诺,满足安全监管要求</h3><p>ONES 始终将企业的数据主权与安全合规置于首位,提供稳定、高可用的私有化部署方案。</p><ul><li><strong>自主可控的部署架构</strong>:我们为金融、政企等行业客户提供私有化环境下的高可用部署与数据加密方案,满足严苛的网络安全规范与数据主权要求。</li><li><strong>权威完备的安全背书</strong>:ONES 已通过 SOC2 Type II 安全审计,并持有等保三级、ISO 27001、ISO 27018 等多项国内外权威认证,从基础设施到应用层全方位构建安全防线。</li></ul><p><img width="723" height="288" src="/img/bVdnRbV" alt="" title=""></p><h3>面向未来的生产力迭代,支持私有化部署的 AI 能力与开放生态</h3><p>除了在功能维度深度对标,ONES 致力于为企业打造支持私有化部署、自主可控的下一代智能研发平台。</p><ul><li><strong>私有化部署中运行完整 AI 能力</strong>:ONES Copilot 智能助手与即将上线的 ONES AI Agent,为用户打造专属智能引擎,深度融合业务流程,精准赋能项目决策,智能规划并执行任务,释放企业研发管理新动能与创新潜能。</li><li><strong>开放、灵活且安全的技术底座</strong>:ONES 提供更符合本土开发者习惯的插件市场与扩展能力。通过丰富的开放能力和插件生态, 企业能够打造真正贴合业务的研发管理系统,提升效率,推动创新和业务增长。</li></ul><p>若您正在寻求 Atlassian Data Center 的替代产品,欢迎联系 ONES 团队,获取详细的 Jira 和 Confluence 迁移方案、成功案例及个性化评估报告。</p>

2026/2/4
阅读更多

MindSpore :动静图融合的低代码高性能实践

<p>在边缘计算、车载终端等异构硬件场景下,MindSpore 模型部署面临 <strong>“动态调试灵活度” 与 “静态推理性能” 无法兼顾 </strong>、硬件算子适配性差两大核心痛点。本次分享基于 MindSpore 的jit动态编译特性与异构硬件算子重写机制,构建 “动静图混合执行 + 硬件感知算子优化” 的低代码部署方案,实现模型在 CPU/GPU/Ascend/ARM 等多平台的高性能适配 —— 推理延迟降低 65%,代码量减少 40%,同时保留动态图的灵活调试能力,附全流程部署代码与跨平台性能对比。</p><h2>1. 动静图混合执行的精细化控制:调试与性能的平衡</h2><p>场景:动态图(PyNative Mode)支持实时打印中间张量、断点调试,适合模型迭代阶段;静态图(Graph Mode)通过计算图优化实现高性能推理,但调试成本高。传统部署需在两种模式间反复切换,且无法针对不同模块差异化配置。</p><p>MindSpore 技术实践:</p><p>利用jit装饰器的局部编译特性,对模型的高频推理模块做静态编译优化,对低频调试模块保留动态执行能力,同时通过input_signature限制输入形状,避免静态编译的形状敏感问题:</p><pre><code class="python">import mindspore as ms import mindspore.nn as nn import mindspore.ops as ops from functools import partial ms.set_context(mode=ms.PYNATIVE_MODE) # 全局开启动态图 # 1. 动态调试模块:保留动态执行能力,用于异常检测 class DynamicDebugModule(nn.Cell): def __init__(self, debug=True): super().__init__() self.debug = debug self.norm = nn.BatchNorm2d(64) def construct(self, x): x = self.norm(x) if self.debug and ms.get_context(&quot;mode&quot;) == ms.PYNATIVE_MODE: # 动态打印张量形状与均值,辅助调试 print(f&quot;Debug: tensor shape={x.shape}, mean={ops.mean(x).asnumpy()}&quot;) return x # 2. 静态推理模块:用jit装饰器做局部编译优化 @ms.jit(input_signature=(ms.Tensor(shape=[None, 64, 32, 32], dtype=ms.float32),)) def static_infer_block(x): &quot;&quot;&quot;高频推理模块:卷积+残差连接,静态编译优化&quot;&quot;&quot; conv1 = nn.Conv2d(64, 128, 3, padding=1) conv2 = nn.Conv2d(128, 128, 3, padding=1) res = conv1(x) res = ops.relu(res) res = conv2(res) return res + x # 残差连接 # 3. 动静融合的完整模型 class HybridModel(nn.Cell): def __init__(self): super().__init__() self.debug_module = DynamicDebugModule() self.static_block = partial(static_infer_block) # 封装静态模块 self.classifier = nn.Dense(128*32*32, 10) def construct(self, x): x = self.debug_module(x) # 动态执行:调试 x = self.static_block(x) # 静态执行:高性能推理 x = x.reshape(x.shape[0], -1) x = self.classifier(x) return x # 效果:动态模块保留调试能力,静态模块推理延迟降低50%;相比全静态图,调试效率提升3倍</code></pre><h2>2. 异构硬件算子重写:针对硬件架构的性能优化</h2><p>场景:MindSpore 默认算子在通用硬件上表现均衡,但在专用架构(如 ARM 的 NEON 指令集、Ascend 的 AI Core)上未充分发挥硬件算力 —— 例如 ARM 端的卷积算子,默认实现未利用向量并行计算,推理效率仅为硬件峰值的 30%。</p><p>MindSpore 技术实践:</p><p>基于mindspore.ops.Custom实现硬件感知的算子重写,针对不同硬件平台注册差异化的算子实现,同时通过PrimitiveWithInfer完成算子的形状推导,确保与 MindSpore 计算图兼容:</p><pre><code class="python">from mindspore.ops import Custom, PrimitiveWithInfer from mindspore._c_expression import typing # 1. 定义硬件感知的卷积算子(以ARM NEON为例) class ARMCustomConv2d(PrimitiveWithInfer): @prim_attr_register def __init__(self, in_channels, out_channels, kernel_size): super().__init__(name=&quot;ARMCustomConv2d&quot;) self.in_channels = in_channels self.out_channels = out_channels self.kernel_size = kernel_size def infer_shape(self, x_shape): # 推导输出形状:same padding h, w = x_shape[2], x_shape[3] return (x_shape[0], self.out_channels, h, w) def infer_dtype(self, x_dtype): return x_dtype def get_func(self): # 绑定ARM NEON优化的卷积实现(C++编写,通过MindSpore C API调用) def neon_conv2d(x, weight, bias): from arm_neon_conv import neon_conv2d_impl # 自定义NEON加速库 return neon_conv2d_impl(x.asnumpy(), weight.asnumpy(), bias.asnumpy()) return neon_conv2d # 2. 硬件算子注册与适配 def get_conv2d(in_channels, out_channels, kernel_size, device_target): &quot;&quot;&quot;根据硬件平台返回最优算子&quot;&quot;&quot; if device_target == &quot;ARM&quot;: return Custom( ARMCustomConv2d(in_channels, out_channels, kernel_size), out_shape=ARMCustomConv2d(in_channels, out_channels, kernel_size).infer_shape, out_dtype=ARMCustomConv2d(in_channels, out_channels, kernel_size).infer_dtype ) else: # 其他平台使用默认卷积算子 return nn.Conv2d(in_channels, out_channels, kernel_size, padding=1) # 3. 模型集成硬件感知算子 class HardwareAwareModel(nn.Cell): def __init__(self, device_target): super().__init__() self.conv = get_conv2d(3, 64, 3, device_target) self.relu = nn.ReLU() def construct(self, x): x = self.conv(x) x = self.relu(x) return x # 效果:ARM平台卷积算子推理速度提升2.8倍,硬件算力利用率从30%提升至75%</code></pre><h2>3. 低代码跨平台部署:MindIR 导出 + Lite 推理的自动化流程</h2><p>场景:模型部署需经历 “训练→导出→量化→推理” 多步骤,不同平台的部署流程差异大,手动配置繁琐且易出错;同时端侧设备资源有限,需对模型做轻量化处理。</p><p>MindSpore 技术实践:</p><p>基于 MindSpore 的MindIR 统一模型格式,封装 “训练→导出→量化→部署” 的自动化脚本,同时集成后训练量化(PTQ)与算子融合优化,实现一键跨平台部署:</p><pre><code class="python">import mindspore.lite as mslite from mindspore.compression import QuantizationAwareTraining # 1. 模型训练与轻量化(PTQ量化) def train_and_quantize(model, train_dataset, device_target): # 训练模型(省略训练循环) loss_fn = nn.CrossEntropyLoss() opt = nn.Adam(model.trainable_params(), 1e-3) train_net = nn.TrainOneStepCell(model, opt, loss_fn) # PTQ量化:降低模型体积与推理延迟 quant_config = QuantizationAwareTraining(quant_dtype=ms.int8) quant_model = quant_config.quantize(model) # 用校准数据集微调(100样本) calib_dataset = train_dataset.take(100) for x, _ in calib_dataset: quant_model(x) return quant_model # 2. 一键导出MindIR模型 def export_mindir(model, input_shape, export_path): input_tensor = ms.Tensor(shape=input_shape, dtype=ms.float32) ms.export(model, input_tensor, file_name=export_path, file_format=&quot;MINDIR&quot;) # 3. 跨平台推理部署 def deploy_lite(model_path, device_target, input_data): # 初始化Lite推理环境 context = mslite.Context() if device_target == &quot;CPU&quot;: context.target = [&quot;cpu&quot;] context.cpu.thread_num = 4 elif device_target == &quot;GPU&quot;: context.target = [&quot;gpu&quot;] elif device_target == &quot;ARM&quot;: context.target = [&quot;cpu&quot;] context.cpu.thread_num = 2 # 适配ARM端算力 # 加载模型并推理 model = mslite.Model(model_path, context=context) inputs = [mslite.Tensor.from_numpy(input_data)] outputs = model.predict(inputs) return outputs[0].asnumpy() # 自动化部署流程调用 if __name__ == &quot;__main__&quot;: device_target = &quot;ARM&quot; # 可切换为CPU/GPU/Ascend model = HardwareAwareModel(device_target) # 训练量化 quant_model = train_and_quantize(model, train_dataset, device_target) # 导出MindIR export_mindir(quant_model, [1, 3, 224, 224], &quot;hardware_aware_model&quot;) # 端侧推理 input_data = np.random.randn(1, 3, 224, 224).astype(np.float32) result = deploy_lite(&quot;hardware_aware_model.mindir&quot;, device_target, input_data)</code></pre>

2026/1/28
阅读更多

蚂蚁灵波科技全面开源 LingBot-VLA 具身大模型

<p><strong>思否编辑部观察</strong></p><p>具身智能正在经历从实验室走向产业化的关键转折点。长期以来,机器人操控模型面临着"一机一训"的困境——每换一个机器人本体、每增加一个新任务,都需要重新采集数据、重新训练模型,这种高昂的迁移成本严重制约了具身智能的规模化落地。</p><p>此次蚂蚁集团开源的 LingBot-VLA 具身大模型,为行业带来了三个重要突破:</p><p><strong>1. 首次验证了具身智能领域的 Scaling Law</strong> <br>通过 20,000 小时真实机器人数据的预训练,系统性证明了 VLA 模型性能随数据规模持续提升的规律。这一发现意义重大——它表明具身智能可以像大语言模型一样,通过"大数据+大模型"的范式实现能力跃迁,为行业指明了清晰的技术路线。</p><p><strong>2. 解决了跨本体泛化的核心难题</strong> <br>通过涵盖 9 种主流双臂机器人构型的大规模预训练,LingBot-VLA 实现了"一个大脑,多种身体"的愿景。在 GM-100 真机评测中,其跨本体泛化成功率达到 17.3%,这意味着同一个模型可以快速适配不同厂商的机器人硬件,大幅降低了商业化部署的门槛。</p><p><strong>3. 打造了真正实用的开源生态</strong> <br>不同于许多"只开源权重"的项目,LingBot-VLA 同步开放了数据处理、高效微调、自动化评估的全套工具链,训练效率达到主流框架的 1.5~2.8 倍。这种"开箱即用"的完整方案,将帮助开发者以更低成本快速落地自己的具身智能应用。</p><p>特别值得关注的是,LingBot-VLA 引入深度信息后的性能提升,体现了空间感知能力对机器人操控的重要性。结合昨日开源的 LingBot-Depth 模型,我们看到了一个清晰的技术演进路径:从精准的空间感知到智能的操控决策,具身智能正在构建起完整的"感知-认知-执行"闭环。</p><p>随着蚂蚁集团承诺未来几天将陆续开源更多具身智能成果,我们有理由相信,2026 年将成为具身智能从"能用"到"好用"、从"实验室"到"生产线"的关键转折年。</p><p><em>SegmentFault 思否编辑部</em> <br><em>2026年1月</em></p><hr><p><em>以下内容转载自蚂蚁灵波科技官方公众号。</em></p><p><strong>继昨日开源高精度空间感知模型 LingBot-Depth 后,今天,我们为大家带来了具身大模型 LingBot-VLA。</strong></p><p><a href="https://link.segmentfault.com/?enc=sWtznwHJ4qHuQNeqJ55ABg%3D%3D.4OyqG63yHg%2F7%2BdPZqMeNhRvaQjdZaqGhRWd4b%2FpCIsEVVlGf6UCThqJ7empEo75%2F6UkoGf9sz%2B8vwUyHDsJlcQ%3D%3D" rel="nofollow">LingBot-VLA 具身大模型全面开源</a></p><p>在上海交通大学开源的具身评测基准 GM-100(包含 100 项真实操作任务)测试中,LingBot-VLA 在 3 个不同的真实机器人平台上,跨本体泛化平均成功率相较于 Pi0.5 的 13.0% 提升至 15.7%(w/o Depth)。引入深度信息(w/ Depth)后,空间感知能力增强,平均成功率进一步攀升至 17.3%,展现了 LingBot-VLA 强大的准确性和泛化性。</p><p><img width="723" height="585" src="/img/bVdnNge" alt="640.webp" title="640.webp"></p><p>在 GM-100 真机评测中,LingBot-VLA 跨本体泛化性能领先</p><p>在 RoboTwin 2.0 仿真基准(包含50项任务)评测中,面对高强度的环境随机化干扰(如光照、杂物、高度扰动),LingBot-VLA 凭借独特的可学习查询对齐机制,高度融合深度信息,操作成功率比 Pi0.5 提升了 9.92%,实现了从虚拟仿真到真实落地的全方位性能领跑。</p><p><img width="723" height="488" src="/img/bVdnNgf" alt="640 (1).webp" title="640 (1).webp"></p><p>在 RoboTwin 2.0 仿真评测中,LingBot-VLA 跨任务泛化性能领先</p><h4>01 Scaling Law 下的大规模真机数据预训练</h4><p>长期以来,由于本体差异、任务差异、环境差异等,具身智能模型落地面临严重的泛化性挑战。开发者往往需要针对不同硬件和不同任务重复采集大量数据进行后训练,直接抬高了落地成本,也使行业难以形成可规模化复制的交付路径。<br><img src="/img/remote/1460000047577570" alt="图片" title="图片"><br>针对上述问题,我们基于在海量真实世界数据上的预训练,第一次系统研究了 VLA 模型在真实机器人任务性能上随着数据规模增长时的 Scaling Law。项目发现随着预训练数据规模从 3,000 小时扩展到 6,000、13,000、18,000,最终至 20,000 小时,模型在下游任务的成功率获得持续且显著的提升。值得注意的是,预训练数据量达到 20,000 小时时,模型性能仍呈现上升趋势,表明 VLA 的性能仍然能够随着数据量的增加而提升。这些实验结果证明了 VLA 模型在用真实数据预训练时呈现了良好的可扩展性,为未来的 VLA 开发和大规模数据挖掘提供了重要启示。<br><img src="/img/remote/1460000047577571" alt="图片" title="图片"><br>依此研究结果,我们仔细构造了 20,000 小时的真实机器人训练数据,涵盖了 9 种主流的双臂机器人构型(包括 AgileX Cobot Magic,Galaxea R1Pro、R1Lite 、AgiBot G1等)。为了进行精确的数据标注,数据里的视频由人工标注者按原子动作进行切分,并用大模型标注视频对应任务和子任务。在 codebase 的开发中,适配了 Fully Sharded Data Parallel (FSDP) 分布式、混合精度、算子融合等优化,从而让同一个“大脑”可以快速迁移至不同形态的机器人上,并在任务变化、环境变化时保持可用的成功率与鲁棒性。</p><h4>02 深度信息辅助的机器人操控性能提升</h4><p><img width="723" height="204" src="/img/bVdnNgg" alt="640 (2).webp" title="640 (2).webp"><br>仿真实验结果</p><p>为了显式捕捉操控环境中的空间感知能力,并进一步提升机器人执行的鲁棒性,我们采用了一种基于查询向量(query)的深度蒸馏方法。具体而言,我们引入了与三视角操作图像相对应的可学习 queries,这些 queries 经 VLM 处理后,与 LingBot-Depth 输出的 depth embeddings 进行对齐。这种对齐机制在维持模型训练与推理的效率的同时,有效将深度信息集成到 LingBot-VLA 中。在真实机器人平台和仿真环境下进行的广泛实验证明,深度信息的融入提升了 LingBot-VLA 的操控性能。</p><h4>03 后训练成本低、效率高、代码全开源,真正实用的 VLA 模型</h4><p>得益于涵盖主流构型和详尽任务的大规模预训练,LingBot-VLA 具备强大的通用操控能力,并且能够将其高效迁移到多样的下游机器人任务中。实验表明,LingBot-VLA 在下游任务中能够使用更少的数据,达到超越 π0.5 的性能;并且性能优势会随着数据量的增加而持续扩大。目前,LingBot-VLA 已与星海图、松灵、乐聚等知名机器人厂商完成适配,验证了模型在不同构型机器人上的跨本体迁移能力。<br><img width="723" height="487" src="/img/bVdnNgr" alt="640 (3).webp" title="640 (3).webp"></p><p>与此同时,我们构建了一套高效的后训练工具链,在 8 卡 GPU 配置下实现了单卡每秒 261 个样本的吞吐量,其训练效率达到 StarVLA、OpenPI 等主流框架的 1.5~2.8 倍,实现了数据与算力成本的双重降低。此次开源,我们不仅提供了模型权重,还同步开放了包含数据处理、高效微调及自动化评估在内的全套代码库。我们希望这一举措可以大幅压缩模型训练周期,降低商业化落地的算力与时间门槛,助力开发者以更低成本快速适配自有场景,提升模型实用性。目前我们的模型、后训练代码、技术报告、以及我们和上海交大共同打造的 GM-100 Benchmark 已全部开源,欢迎大家访问我们的开源仓库。</p><pre><code>Website: https://technology.robbyant.com/lingbot-vla Model: https://huggingface.co/collections/robbyant/lingbot-vla https://www.modelscope.cn/collections/Robbyant/LingBot-VLA Datasets: https://huggingface.co/datasets/robbyant/lingbot-GM-100 Code: https://github.com/Robbyant/lingbot-vla Tech Report: https://arxiv.org/abs/2601.18692</code></pre><p>具身智能的大规模应用依赖高效的具身大模型,这直接决定了模型是否可用以及能否用得起。我们希望通过 LingBot-VLA 的开源,积极探索具身智能上限,推进具身智能研发早日进入可复用、可验证、可规模化落地的新阶段。</p><p>本周,我们已相继开源 LingBot-Depth 和 LingBot-VLA 两款模型,未来几天,我们还将陆续为大家带来我们在具身智能领域智能基座方向的更多成果。我们期待与全球开发者、研究者、产业伙伴一起,加速具身智能技术的迭代与规模化应用,助力 AGI 更快到来。</p><p><img width="723" height="702" src="/img/bVdnNgh" alt="image.png" title="image.png"></p>

2026/1/28
阅读更多

鸿蒙人物志x朱博|把“全栈+ AI”的能力,落到鸿蒙的每一个真实场景

<blockquote>此篇文章来源于 SegmentFault 思否鸿蒙专区·鸿蒙人物志专题采访,以下为正文:</blockquote><p>朱博的工程履历很典型。计算机硕士出身,在大厂做研发,一直没离开跨平台应用和智能设备这条线。后来他把精力集中在<strong>全栈开发</strong>、<strong>终端 AI </strong>和 <strong>鸿蒙生态</strong> 三件事上。在旁人看来这是三个方向,他却认为这是一条连贯的路径——先用全栈能力把产品做完整,再用 AI 让体验更聪明,最后用鸿蒙让多设备真正协同起来。如今,他正通过一个又一个真实项目去验证它。</p><h2>协同是系统的基本能力</h2><p>学生时代,朱博十分注重理论积累与基础实践。这让他建立起对系统、网络和架构等底层概念的理解,也影响了他后来面对复杂技术问题时的思考方式。进入大厂后,他参与多个跨平台应用与智能设备研发项目,从单点模块开发逐步转向串联前后端、数据链路和工程发布流程,最终能独立交付完整的产品。</p><p>正是在这一过程中,他注意到一个长期存在的痛点,即大多数跨端方案可以让应用“跑起来”,却难以让多设备真正“协同起来”。</p><p>这个观察成为他关注鸿蒙的起点。在他看来,鸿蒙的设计从底层支持多设备间的资源共享与任务协同,<strong>“一次开发、多端部署”</strong> 的特性意味着开发者无需为每种设备组合单独适配,而是基于统一模型进行开发。这与他过去在工程实践中反复遇到的割裂体验截然不同。早期间他通过社区参与接触鸿蒙,包括跟进文档、复现 Demo、撰写文章并参与开发者讨论。随着实践增多,他逐渐意识到,鸿蒙与传统跨端方案的根本区别不在功能多少,而在于协同是否由系统原生支持。</p><p><strong>下图是朱博老师分享的 Demo, 应用“慢小圈”应用页面布局核心代码:</strong></p><pre><code>import navController from '@ohos.router'; class PostClass { public userAlias: string; // 昵称 public postContent: string; // 贴文内容 public imageGallery: ResourceStr[]; // 图片列表 constructor(userAlias: string, postContent: string, imageGallery: ResourceStr[]) { this.userAlias = userAlias; this.postContent = postContent; this.imageGallery = imageGallery; } } // 计算行数 computeRowsTemplate(index) { let result:string = '1fr'; let length: number = this.postList[index].imageGallery.length || 0; if (length == 1) { result = '1fr'; } else if (length &gt;= 2 &amp;&amp; length &lt;= 6 &amp;&amp; length != 3) { result = '1fr 1fr'; } else { result = '1fr 1fr 1fr'; } return result; } // 计算列数 computeColumnsTemplate(index) { let result: string='1fr'; let length: number = this.postList[index].imageGallery.length || 0; if (length == 1) { result = '1fr'; } else if (length == 2 || length == 4) { result = '1fr 1fr'; } else { result = '1fr 1fr 1fr'; } return result; } // 计算高度 computeGridHeight(index) { let result: number = 0; let length: number = this.postList[index].imageGallery.length || 0; if (length &lt;= 3) { result = 70; } else if (length &gt; 3 &amp;&amp; length &lt;= 6) { result = 145; } else { result = 220; } return result; } // 计算宽度 computeGridWidth(index) { let result: number = 0; let length: number = this.postList[index].imageGallery.length || 0; if (length == 1) { result = 70; } else if (length == 2 || length == 4) { result = 145; } else { result = 220; } return result; }</code></pre><p><img width="723" height="337" src="/img/bVdnsFo" alt="image.png" title="image.png"></p><p>随着参与加深,他也感受到这个生态本身的活力:技术在快速迭代,场景在持续扩展,开发者的需求也在同步增长。他将这种活力归因于分布式架构优势、全场景生态覆盖以及国产生态带来的确定性机遇。他认为,生态仍在快速上升期,早期入局的开发者有机会一边积累能力,一边伴随生态成长,从而更早形成自己的影响力与方法论。</p><p><img width="723" height="487" src="/img/bVdnsT3" alt="" title=""></p><h2>用真实项目验证</h2><p>在朱博看来,与其追逐概念,不如用真实项目打磨出可复用的方法论。他在实践中发现,多设备协同的关键从来不是“能控多少设备”,而是“能否做到无感融合”。在他开发的“智慧家园 ”App中,用户无需在多个入口间切换,即可完成跨品牌设备的统一管控与场景配置。</p><p>同时,App 支持用户自定义场景规则,例如开启“回家模式”后,灯光、空调和窗帘会自动按需启动。更进一步,借助 AI 学习能力,系统会观察用户的使用习惯,逐步让这些场景更贴合个人日常,而不是依赖固定的模板。他把这种体验称为“从功能堆叠到习惯理解”,只有当系统开始理解人,协同才会从“好玩”走向“好用”。</p><p>这一理念随后被他推向更严苛的环境。在工业设备监控系统中,他验证了同一套架构在高可靠、低延迟、强可观测性要求下的稳定性。两类实践共同表明,鸿蒙不仅能承载新体验的探索,也能支撑真实业务中多设备、多形态、多角色的复杂协同。</p><p>而真正将这一能力推向纵深的,是获得挑战杯省级金奖的“智能养老监护系统”。空巢老人的居家安全风险迫切需要有效监护,但传统设备往往各自为战、操作不便。为此,朱博基于鸿蒙打造了一个多设备协同的智能监护系统。</p><p><strong>下图是“智慧家园”鸿蒙APP首页核心代码块:</strong></p><pre><code>@State bartext: string[] = [' 首页 ',' 设备 ',' 关爱 ',' 我的 '] @State barlogo: string[] = ['bar01','bar02','bar03','bar04'] //Image('images/'+String(activity.type)+&quot;.png&quot;) @Builder TabBuilder(index: number) { Column() { // Image(index == this.mCurrentPage? $r('app.media.bar2'): $r('app.media. bar1')) Image('images/'+String(this.barlogo[index])+&quot;.png&quot;) .width('24vp') .height('24vp') .objectFit(ImageFit.Contain) Text(this.bartext[index]) .fontSize('10fp') .fontWeight(500) .margin({top: '4vp'}); }.justifyContent(FlexAlign.Center); Tabs({barPosition: BarPosition.End, controller: this.mTabController}) { TabContent() { //... 选项卡 1 的内容 } .tabBar(this.TabBuilder(0)); TabContent() { //... 选项卡 2 的内容 } .tabBar(this.TabBuilder(1)); TabContent() { //... 选项卡 3 的内容 } .tabBar(this.TabBuilder(2)); //TabContent4 同样方式创建 }</code></pre><p><img width="723" height="465" src="/img/bVdnsUl" alt="image.png" title="image.png"></p><p><strong>该方案整合了智能手环、烟雾传感器和门窗设备,构建起实时协同的网络,让监护从单点响应升级为多设备联动的整体感知。</strong> 在此基础上,系统通过分析用户的活动状态与生理数据,实现了对跌倒、火灾或燃气泄漏等风险的主动预判,并能够及时向家属推送预警。与此同时,界面交互专为老年人设计,操作极简,同时支持子女远程查看与管控,在易用性与实用性之间取得平衡。</p><p>随着实践深入,朱博越来越清晰地意识到,做出一个能跑的项目不难,难的是让别人也能轻松复现。他观察到,开发者最需要的是可复用、可上手、能直接解决问题的实战内容。于是他着手撰写 <strong>《ArkTS 鸿蒙应用开发入门到实战》</strong>, <strong>采用“基础—核心—项目—优化”的渐进框架,把分布式协同等复杂能力拆解为可复现的步骤。</strong> 在他看来,国产生态的真正机会,不在于替代,而在于能否成为多终端协同场景的可靠底座。而他要做的,就是不断用真实项目和清晰文档,让更多后来者能够复现并在此基础上持续演进。</p><p><img width="723" height="305" src="/img/bVdnsUp" alt="image.png" title="image.png"></p><h2>展望鸿蒙未来:AI 赋能与领航者计划</h2><p>展望未来三到五年,朱博认为鸿蒙生态的增长将主要来自两个方向。一是全场景设备协同在智能家居、工业互联网和智能汽车等领域的深入落地,这会催生大量需要跨终端协作的新应用。二是国央企在数字化转型过程中对国产操作系统的明确需求,为鸿蒙提供了稳定的产业入口。</p><p>在他看来,智能技术与鸿蒙的结合将带来两方面变化。一方面,终端智能会升级为更精准的场景化服务;另一方面,<strong>“AI 能力 + 分布式架构”</strong> 的研发范式正在降低复杂应用的开发门槛。当底层能力被有效封装,开发者就能把更多精力放在业务逻辑和用户体验的打磨上。</p><p>这些变化正在吸引更多具备实战能力的开发者加入鸿蒙生态的共建之中。他们不仅需要理解技术原理,更要能在真实约束下完成端到端交付。鸿蒙领航者计划正是在这一背景下推出的,通过系统学习、项目实战和同行交流,为开发者的能力成长提供支持。</p><p>朱博选择加入其中。“报名鸿蒙领航者,努力成为鸿蒙极客不仅可以获得更多学习的机会,也是帮助开发者从实践中获取真正价值的途径。”他相信,一个健康的生态不能只靠少数人的突破,还应该有人把知识写成教程,有人把需求变成产品,有人把实践总结为方法论,而领航者计划的价值,正在于让这三类行动都能被看见、被支持、被延续。</p><p>作为早期实践者,朱博的行动本身已成为一种示范。他始终认为,与其讨论可能性,不如交付可运行、可复现、可演进的真实项目。在他看来,这正是鸿蒙走向成熟最可靠的路径。</p><p>报名链接 👉:<a href="https://segmentfault.com/e/1160000047290166">鸿蒙领航者招募|加入领航者阵营,共享共建鸿蒙新世界</a></p>

2025/12/24
阅读更多

2026-01-04 GitHub 热点项目精选

<h2>🌟 2026-01-04 GitHub Python 热点项目精选(11个)</h2><blockquote>每日同步 GitHub Trending 趋势,筛选优质 Python 项目,助力开发者快速把握技术风向标~</blockquote><hr><h3>📋 项目列表(按 Star 数排序)</h3><h4>1. <a href="https://link.segmentfault.com/?enc=t46dfB9r43fj6WFKn5ocTA%3D%3D.P3c92n%2FjvdwvSwSXOlSP2UzMWR8BD6dm9p%2BMAo1FIYnDYLLLSs8uuMQs1ZvJXH28" rel="nofollow">pathwaycom/pathway</a></h4><blockquote>Pathway 是一个开源的实时数据处理框架,旨在帮助企业快速构建和部署实时数据处理应用。它支持多种数据源和数据格式,能够高效地处理大规模数据流,适用于实时监控、事件驱动应用等场景。</blockquote><table><thead><tr><th>指标</th><th>详情</th></tr></thead><tbody><tr><td>Star 数</td><td>🌟 55918(今日+976)</td></tr><tr><td>Fork 数</td><td>🔄 1526</td></tr><tr><td>开发语言</td><td>🐍 Python</td></tr><tr><td>项目地址</td><td><a href="https://link.segmentfault.com/?enc=NoGr%2B8WwpEukcLs1Jk5f2g%3D%3D.ygGcjMJ2GMHuijNvFuHOHqcedTTWIgEuAA81eMDtmV6a7JYahk5ksIMF5tiHXdXO" rel="nofollow">https://github.com/pathwaycom/pathway</a></td></tr></tbody></table><hr><h4>2. <a href="https://link.segmentfault.com/?enc=GCfCpKUp1ouwiUkq9ZpM7A%3D%3D.9eAcCquIObBsur73vcOZnuvcl3D%2FAECWWXAfYJYZr4jUUr8e%2B4Dz%2F7ssKeJsiUmS" rel="nofollow">OpenBB-finance/OpenBB</a></h4><blockquote>OpenBB 是一个开源的金融数据分析平台,提供丰富的金融数据接口和分析工具。用户可以通过它获取实时股票行情、财务报表等数据,并进行技术分析、风险评估等操作,适合金融从业者和投资者使用。</blockquote><table><thead><tr><th>指标</th><th>详情</th></tr></thead><tbody><tr><td>Star 数</td><td>🌟 56400(今日+335)</td></tr><tr><td>Fork 数</td><td>🔄 5491</td></tr><tr><td>开发语言</td><td>🐍 Python</td></tr><tr><td>项目地址</td><td><a href="https://link.segmentfault.com/?enc=pmQLd1EHzO%2Fm6Gn9r7H6og%3D%3D.oGB5Iq3o9orREgrU0MpvP7orBJgZJgA9S%2FntyTrCiR%2BhQSgqhh5fb%2F3INigpe%2F6N" rel="nofollow">https://github.com/OpenBB-finance/OpenBB</a></td></tr></tbody></table><hr><h4>3. <a href="https://link.segmentfault.com/?enc=zkTa4zrz4CJxuz3oG9ALeg%3D%3D.F8T3KtYkH%2BXBi7PmXUyKqB%2BmN%2Be32xcs3WUNWp7aJm82idKy7FipZZ94itR6jeWQ" rel="nofollow">beancount/beancount</a></h4><blockquote>Beancount 是一个面向个人和小团队的开源会计软件,采用简单的文本文件记录财务交易,支持多币种、多账户管理,能够生成详细的财务报表,帮助用户更好地管理个人或团队财务。</blockquote><table><thead><tr><th>指标</th><th>详情</th></tr></thead><tbody><tr><td>Star 数</td><td>🌟 4945(今日+111)</td></tr><tr><td>Fork 数</td><td>🔄 385</td></tr><tr><td>开发语言</td><td>🐍 Python</td></tr><tr><td>项目地址</td><td><a href="https://link.segmentfault.com/?enc=eR23L1GL7b%2FvTF0zto2o8g%3D%3D.df9KSpE0L5wIEwHbwFqwbcnX2uTciGsBgigZQXJP4%2FUAfFYaLs6XyN2dxSvuaajS" rel="nofollow">https://github.com/beancount/beancount</a></td></tr></tbody></table><hr><h4>4. <a href="https://link.segmentfault.com/?enc=lzuGeaT8VOhsNB7ce83Cng%3D%3D.7XBfOfGuZCPOjuLqU9eXPW53NVN9npPNkp4%2Bj%2Fk7ymK2zGqD0IeeyHLdC8aYy9JN" rel="nofollow">MustardChef/WSABuilds</a></h4><blockquote>WSABuilds 是一个专注于 Windows Subsystem for Android(WSA)的项目,提供 WSA 的定制版本和安装工具,方便用户在 Windows 系统上更好地运行 Android 应用,优化了性能和兼容性。</blockquote><table><thead><tr><th>指标</th><th>详情</th></tr></thead><tbody><tr><td>Star 数</td><td>🌟 15372(今日+14)</td></tr><tr><td>Fork 数</td><td>🔄 2132</td></tr><tr><td>开发语言</td><td>🐍 Python</td></tr><tr><td>项目地址</td><td><a href="https://link.segmentfault.com/?enc=dCPYcqUsUUfO6FQWZFdpmw%3D%3D.%2FZUaCTgnZOfvPINojj5maV7kRAYuZtRANdwuh9UdgcPQfW80MOaa%2Bmb%2BD6lZMnl1" rel="nofollow">https://github.com/MustardChef/WSABuilds</a></td></tr></tbody></table><hr><h4>5. <a href="https://link.segmentfault.com/?enc=FJVLw6izlgwtZOF7j%2FzZLA%3D%3D.S7DPrnhhPZ69flWhSzzIPJ4NlaF%2FLMkzoCEktXn%2Fs5%2Fdw0WjWBDsxg9ltJHLyi7J" rel="nofollow">rossant/awesome-math</a></h4><blockquote>Awesome Math 是一个数学资源的集合,整理了大量优质的数学学习资料、在线课程、书籍推荐等,涵盖了从基础数学到高级数学的多个领域,是数学爱好者和学习者的宝贵资源。</blockquote><table><thead><tr><th>指标</th><th>详情</th></tr></thead><tbody><tr><td>Star 数</td><td>🌟 13080(今日+261)</td></tr><tr><td>Fork 数</td><td>🔄 1274</td></tr><tr><td>开发语言</td><td>🐍 Python</td></tr><tr><td>项目地址</td><td><a href="https://link.segmentfault.com/?enc=a8RG6kO%2FNGhjoXLedFHSGQ%3D%3D.OpRahoAaU%2BSLFC%2B5mkf43U7pfu8V1oE4g4Ts8EiEmNGCuNA7ETlrOQWpC07YWNjG" rel="nofollow">https://github.com/rossant/awesome-math</a></td></tr></tbody></table><hr><h4>6. <a href="https://link.segmentfault.com/?enc=VtxI%2FTTaPC1UkBmSw2Gsbw%3D%3D.1M97r%2FrOOuaKdvPGqJc27qPAJBLeNaAJ0McI33x7yOw%3D" rel="nofollow">OWASP/Nest</a></h4><blockquote>OWASP Nest 是一个安全项目,专注于软件的安全开发和测试。它提供了一系列的安全工具、指南和最佳实践,帮助开发者在开发过程中识别和修复安全漏洞,提升软件的安全性。</blockquote><table><thead><tr><th>指标</th><th>详情</th></tr></thead><tbody><tr><td>Star 数</td><td>🌟 290(今日+6)</td></tr><tr><td>Fork 数</td><td>🔄 389</td></tr><tr><td>开发语言</td><td>🐍 Python</td></tr><tr><td>项目地址</td><td><a href="https://link.segmentfault.com/?enc=apJWThci67MbZ%2BxrZR%2FmeQ%3D%3D.xGx%2FAwF5neTXmaiFWJiBXKh06hqIihLMWVQtQIU%2FYng%3D" rel="nofollow">https://github.com/OWASP/Nest</a></td></tr></tbody></table><hr><h4>7. <a href="https://link.segmentfault.com/?enc=izv1zn5iZ61E0MxNtmdgzg%3D%3D.cq%2B0bkdjklq7N3vbgTrnm4n1YQmvKOkx0nMCmgq9xP7vwvowlg6vz7MUYNP8CABA" rel="nofollow">QwenLM/Qwen-Image</a></h4><blockquote>Qwen-Image 是一个图像处理相关的项目,可能涉及图像识别、生成、编辑等技术。它可能提供了高效的算法和工具,用于处理各种图像数据,适用于图像分析、创意设计等领域。</blockquote><table><thead><tr><th>指标</th><th>详情</th></tr></thead><tbody><tr><td>Star 数</td><td>🌟 6755(今日+27)</td></tr><tr><td>Fork 数</td><td>🔄 388</td></tr><tr><td>开发语言</td><td>🐍 Python</td></tr><tr><td>项目地址</td><td><a href="https://link.segmentfault.com/?enc=Dx3c4KvidIo9VeXjaU%2F8mA%3D%3D.NaCMXS%2BHtTRhwhC7PHYa6CTlpZMd8MjISwK%2Fw%2BlD0V72aUPIpjE%2FvZ27UT5Td1dj" rel="nofollow">https://github.com/QwenLM/Qwen-Image</a></td></tr></tbody></table><hr><h4>8. <a href="https://link.segmentfault.com/?enc=pGzHiXGvl0a4914Yv0CFGw%3D%3D.zPtjoU2xRautBsuSnGqLytV5gvAqvRZl5biSeftZICYisyMwTMu8SnpDwIM8%2FSGa" rel="nofollow">Project-MONAI/MONAI</a></h4><blockquote>MONAI 是一个开源的医学影像处理框架,专为医学研究和临床应用设计。它支持多种医学影像格式,提供丰富的图像处理和分析功能,如图像分割、配准等,有助于提高医学影像诊断的准确性和效率。</blockquote><table><thead><tr><th>指标</th><th>详情</th></tr></thead><tbody><tr><td>Star 数</td><td>🌟 7684(今日+5)</td></tr><tr><td>Fork 数</td><td>🔄 1390</td></tr><tr><td>开发语言</td><td>🐍 Python</td></tr><tr><td>项目地址</td><td><a href="https://link.segmentfault.com/?enc=Fuy9bahxsk%2F5yuL9iNjNzw%3D%3D.59OjRsCAVjQEVeVmjBdFuXJE3Ty7i%2FjTp6%2FwZmCuTy2vVRqYENWst%2B9AKk%2Fz%2BzYZ" rel="nofollow">https://github.com/Project-MONAI/MONAI</a></td></tr></tbody></table><hr><h4>9. <a href="https://link.segmentfault.com/?enc=e06xEKiW4%2B5SSd6V8TIO6A%3D%3D.gR2Ty6rYwGC8bKgCYshDfTIZnG%2Bx3o6TnC4FkoXD95DyvpLuPz9hwchI3EQdm232pdEikNn30ESCtlrU94HCaw%3D%3D" rel="nofollow">absadiki/whatsapp-msgstore-viewer</a></h4><blockquote>WhatsApp Msgstore Viewer 是一个工具,用于查看和分析 WhatsApp 的消息存储文件。用户可以通过它查看聊天记录、恢复已删除消息等,对于数据恢复和信息管理有一定帮助。</blockquote><table><thead><tr><th>指标</th><th>详情</th></tr></thead><tbody><tr><td>Star 数</td><td>🌟 515(今日+45)</td></tr><tr><td>Fork 数</td><td>🔄 93</td></tr><tr><td>开发语言</td><td>🐍 Python</td></tr><tr><td>项目地址</td><td><a href="https://link.segmentfault.com/?enc=lbF3SEvMLbbVLM2OVJamuQ%3D%3D.JdBXFir2eskrGoG9uCDEgGG97drm%2FGKEU9F6upU70hGkV13EZOMCxrjbQnuw58H6ccQRavcaPD%2FmaY6CAr1meQ%3D%3D" rel="nofollow">https://github.com/absadiki/whatsapp-msgstore-viewer</a></td></tr></tbody></table><hr><h4>10. <a href="https://link.segmentfault.com/?enc=zMNIgXhUJjJZ8pGTAV72pw%3D%3D.Ki%2F7llvRDyidHyNzMErgqdW%2BuSBuSL%2BCHYPStLHi4w9JuImTHaIjBMuns44r8Qkk" rel="nofollow">livekit/agents</a></h4><blockquote>Livekit Agents 是一个与实时通信相关的项目,可能涉及 WebRTC 等技术,用于构建高效的实时通信应用,如视频会议、直播互动等,提供了灵活的通信代理和管理功能。</blockquote><table><thead><tr><th>指标</th><th>详情</th></tr></thead><tbody><tr><td>Star 数</td><td>🌟 8914(今日+19)</td></tr><tr><td>Fork 数</td><td>🔄 2329</td></tr><tr><td>开发语言</td><td>🐍 Python</td></tr><tr><td>项目地址</td><td><a href="https://link.segmentfault.com/?enc=6Ou%2FA1bvPFTg5fhBNOTlZg%3D%3D.j7i%2BzDpTXURNDgQEDD1%2B7t4S%2F%2B8YVl5Qo4j7%2F5mnhh5JcnD7SdssgwjTc0YVk7MI" rel="nofollow">https://github.com/livekit/agents</a></td></tr></tbody></table><hr><h4>11. <a href="https://link.segmentfault.com/?enc=Ph9Ck8r209dCKNbD3ztyaw%3D%3D.iwY825nn6cw1%2B5N2U0kVGo0ekxkleUdciH7RGpHP0Pj%2FTT3yjBwO9PIY5BIk21E%2F" rel="nofollow">google-research/timesfm</a></h4><blockquote>TimesFM 是谷歌研究团队的一个项目,可能涉及时间序列数据的处理和分析,如预测、异常检测等。它可能提供了先进的算法和模型,适用于金融、气象、工业等领域的时间序列应用。</blockquote><table><thead><tr><th>指标</th><th>详情</th></tr></thead><tbody><tr><td>Star 数</td><td>🌟 7475(今日+39)</td></tr><tr><td>Fork 数</td><td>🔄 661</td></tr><tr><td>开发语言</td><td>🐍 Python</td></tr><tr><td>项目地址</td><td><a href="https://link.segmentfault.com/?enc=IAFpJwyAOMNf%2BshyMJCBTw%3D%3D.QMVY93cBnivYVdMk0oh0fQoe5PqsWL44tKdBDs28pQYbKRJR0fr5%2FrAzz5xjYSfF" rel="nofollow">https://github.com/google-research/timesfm</a></td></tr></tbody></table><hr><h3>📝 说明</h3><ul><li>数据来源:GitHub Trending(2026-01-04 每日榜单)</li><li>筛选条件:Python 语言 + 当日热门项目</li><li>自动更新:每日同步最新趋势,建议收藏本文持续关注~</li></ul><h3>⭐ 推荐理由</h3><ol><li>热门项目代表当前技术趋势,学习价值高</li><li>优质项目代码规范,可作为学习参考</li><li>部分项目可直接用于实际开发,提高效率</li></ol>

2026/1/3
阅读更多

深入理解 Interlocked.CompareExchange:C#.NET 原子操作核心原理

<h3>什么是 Interlocked.CompareExchange?</h3><p><code>Interlocked.CompareExchange</code> 是 <code>.NET</code> 中 <code>System.Threading.Interlocked</code> 类的最核心原子操作方法。它执行比较并交换(<code>Compare-And-Swap</code>,简称 <code>CAS</code>) 操作:在多线程环境下,安全地将变量的值与预期值比较,如果相等则替换为新值,整个过程原子不可中断。</p><p>关键特性</p><ul><li>原子性:整个操作在CPU级别是原子的,不会被线程调度打断</li><li>无锁操作:无需使用锁即可实现线程安全</li><li>内存屏障:隐含完整内存屏障(<code>full fence</code>),确保操作前后内存访问顺序</li></ul><p>返回值重要性:返回值是判断操作是否成功的关键</p><ul><li>核心签名(常见重载):</li></ul><pre><code class="csharp">public static int CompareExchange(ref int location, int value, int comparand); public static long CompareExchange(ref long location, long value, long comparand); public static T CompareExchange&lt;T&gt;(ref T location, T value, T comparand) where T : class; public static float CompareExchange(ref float location, float value, float comparand); public static double CompareExchange(ref double location, double value, double comparand);</code></pre><ul><li><p>参数解释:</p><ul><li><code>location</code>:要操作的共享变量(必须用 <code>ref</code> 传递)。</li><li><code>value</code>:如果比较成功,要写入的新值。</li><li><code>comparand</code>:预期旧值(用于比较)。</li><li>返回值:操作前 <code>location</code> 的实际值(无论成功与否都返回旧值)。</li></ul></li></ul><p><code>CAS</code> 是现代无锁(<code>lock-free</code>)并发编程的基础,许多高级结构(如 <code>ConcurrentDictionary</code>、<code>SpinLock</code>)内部都依赖它。</p><h3>为什么使用 Interlocked.CompareExchange?</h3><p>在多线程中,直接读取-修改-写入(如 <code>if (count == 0) count = 1;</code>)会产生竞争条件:多个线程可能同时读取相同值,导致覆盖更新。</p><ul><li>传统锁的问题:<code>lock</code> 开销大(互斥锁、上下文切换),不适合高频轻量操作。</li><li><p><code>CAS</code> 的优势:</p><ul><li>无锁(<code>Lock-Free</code>):不阻塞线程,高并发下性能极高。</li><li>乐观并发:假设冲突少,先尝试操作,失败则重试(自旋)。</li><li>原子性:硬件级保证(<code>x86 的 cmpxchg</code> 指令)。</li></ul></li><li><p>典型应用场景:</p><ul><li>实现线程安全的单例模式(双检查锁)。</li><li>自定义无锁队列/栈。</li><li>原子更新复杂状态(标志位 + 计数器)。</li><li>实现自定义同步原语(如 <code>SpinLock</code>、<code>SemaphoreSlim</code> 内部)。</li></ul></li></ul><p>传统的 “比较 + 赋值” 是两步非原子操作,多线程下会因竞态条件导致逻辑错误。</p><pre><code class="csharp">// 非原子操作:多线程下可能同时通过if判断,导致赋值错误 private static int _value = 0; public static void UnsafeUpdate(int oldVal, int newVal) { if (_value == oldVal) // 步骤1:读取并比较 { _value = newVal; // 步骤2:赋值 } }</code></pre><p>两个线程可能同时通过if判断(都读到 <code>_value=oldVal</code> ),最终都执行赋值,导致逻辑错误。</p><p><code>Interlocked.CompareExchange</code> 将 “比较” 和 “赋值” 合并为不可中断的原子操作,从底层杜绝竞态条件,且无需阻塞线程(无锁)。</p><p>与 <code>lock</code> 的根本区别</p><table><thead><tr><th>维度</th><th>lock</th><th>CompareExchange</th></tr></thead><tbody><tr><td>是否阻塞</td><td>是</td><td>否</td></tr><tr><td>是否上下文切换</td><td>是</td><td>否</td></tr><tr><td>粒度</td><td>任意代码块</td><td>单变量</td></tr><tr><td>性能</td><td>较低</td><td>极高</td></tr><tr><td>适合</td><td>复杂逻辑</td><td>状态切换</td></tr></tbody></table><h3>什么时候该用 CompareExchange?</h3><table><thead><tr><th>场景</th><th>推荐</th></tr></thead><tbody><tr><td>简单状态切换</td><td>✔</td></tr><tr><td>计数、标志位</td><td>✔</td></tr><tr><td>高并发热点</td><td>✔</td></tr><tr><td>复杂业务流程</td><td>❌</td></tr><tr><td>多变量一致性</td><td>❌</td></tr></tbody></table><h3>如何使用 Interlocked.CompareExchange?</h3><h4>最简单的 CAS 操作(判断是否交换成功)</h4><pre><code class="csharp">private static int _counter = 0; public static void TestCas() { // 目标:如果_counter是0,就改为100 int expected = 0; // 预期值 int newValue = 100; // 新值 // 执行CAS操作 int original = Interlocked.CompareExchange(ref _counter, newValue, expected); // 判断是否交换成功(原始值 == 预期值 → 成功) if (original == expected) { Console.WriteLine($&quot;交换成功!原始值:{original},新值:{_counter}&quot;); } else { Console.WriteLine($&quot;交换失败!原始值:{original},当前值:{_counter}&quot;); } }</code></pre><p>输出:交换成功!原始值:0,新值:100(若再次执行,会输出失败,因为<code>_counter</code> 已不是 0)。</p><h4>原子条件更新</h4><pre><code class="csharp">private int _status = 0; // 0: 未初始化, 1: 初始化中, 2: 已完成 public void Initialize() { if (Interlocked.CompareExchange(ref _status, 1, 0) == 0) { // 只有第一个线程进入这里 try { // 执行初始化逻辑 DoInitialize(); } finally { // 标记完成(原子操作,无需 CAS) Interlocked.Exchange(ref _status, 2); } } else { // 其他线程等待完成 while (_status != 2) Thread.SpinWait(10); } }</code></pre><ul><li>解释:只有当 <code>_status</code> 为 0 时才设置为 1 并执行初始化。</li></ul><h4>实现自旋锁</h4><pre><code class="csharp">public class SpinLock { private int _lock = 0; // 0=未锁定, 1=已锁定 public void Enter() { while (Interlocked.CompareExchange(ref _lock, 1, 0) != 0) { // 等待 - 可以使用Thread.SpinWait优化 Thread.SpinWait(100); } } public void Exit() { Interlocked.Exchange(ref _lock, 0); } }</code></pre><h4>经典场景:无锁计数器(循环 CAS)</h4><p>单次 <code>CAS</code> 可能因其他线程修改变量失败,需循环重试(自旋 <code>CAS</code>),实现线程安全的计数器:</p><pre><code class="csharp">private static int _casCounter = 0; /// &lt;summary&gt; /// 无锁原子递增(替代Interlocked.Increment) /// &lt;/summary&gt; public static int IncrementCounter() { int current; // 当前值 int newValue; // 新值 do { // 1. 原子读取当前值(Interlocked保证可见性) current = _casCounter; // 2. 计算新值(仅本地计算,无竞态) newValue = current + 1; // 3. CAS操作:若当前值未被修改,就更新为新值 // 返回值≠current → 被其他线程修改,重试 } while (Interlocked.CompareExchange(ref _casCounter, newValue, current) != current); return newValue; // 返回递增后的值 } // 测试:1000个线程各调用1次,最终结果必为1000 var tasks = Enumerable.Range(0, 1000) .Select(_ =&gt; Task.Run(IncrementCounter)) .ToList(); Task.WaitAll(tasks.ToArray()); Console.WriteLine($&quot;最终计数器值:{_casCounter}&quot;); // 输出:1000</code></pre><h3>高级应用场景</h3><h4>泛型版本:懒加载单例(双检查锁模式)</h4><pre><code class="csharp">public sealed class Singleton { private static Singleton _instance = null; private Singleton() { } public static Singleton Instance { get { if (_instance == null) { var temp = new Singleton(); Interlocked.CompareExchange(ref _instance, temp, null); } return _instance; } } }</code></pre><p>在 <code>.NET</code> 中需加 <code>Lazy&lt;T&gt;</code> 更安全:</p><pre><code class="csharp">private static Lazy&lt;Singleton&gt; _instance = new Lazy&lt;Singleton&gt;(() =&gt; new Singleton()); public static Singleton Instance =&gt; _instance.Value;</code></pre><h4>无锁状态机(原子状态转换)</h4><p>控制对象状态的原子转换(如从 <code>Idle→Running</code> ,避免状态混乱):</p><pre><code class="csharp">// 定义状态枚举 public enum TaskState { Idle, Running, Completed, Failed } public class TaskStateMachine { private TaskState _state = TaskState.Idle; /// &lt;summary&gt; /// 原子转换:Idle → Running(仅当状态为Idle时成功) /// &lt;/summary&gt; public bool TryStart() { TaskState original = Interlocked.CompareExchange( ref _state, TaskState.Running, // 新值 TaskState.Idle // 预期值 ); return original == TaskState.Idle; // 原始值=预期值 → 转换成功 } /// &lt;summary&gt; /// 原子转换:Running → Completed /// &lt;/summary&gt; public bool TryComplete() { TaskState original = Interlocked.CompareExchange( ref _state, TaskState.Completed, TaskState.Running ); return original == TaskState.Running; } } // 使用 var stateMachine = new TaskStateMachine(); Console.WriteLine(stateMachine.TryStart()); // true(状态变为Running) Console.WriteLine(stateMachine.TryStart()); // false(已不是Idle) Console.WriteLine(stateMachine.TryComplete()); // true(状态变为Completed)</code></pre><h4>自旋实现原子加法(模拟 Interlocked.Add)</h4><pre><code class="csharp">private int _counter = 0; public int Increment() { int oldValue, newValue; do { oldValue = _counter; newValue = oldValue + 1; } while (Interlocked.CompareExchange(ref _counter, newValue, oldValue) != oldValue); return newValue; }</code></pre><ul><li>解释:循环直到 <code>CAS</code> 成功(自旋),实现无锁递增</li></ul><h4>对象引用替换(线程安全赋值)</h4><pre><code class="csharp">private List&lt;int&gt; _cache = null; public void UpdateCache(List&lt;int&gt; newCache) { Interlocked.CompareExchange(ref _cache, newCache, _cache); // 仅当未被其他线程修改时更新 }</code></pre><h3>Interlocked.CompareExchange 的内部实现</h3><ul><li><p>硬件支持:</p><ul><li><code>x86/x64</code>:<code>LOCK CMPXCHG</code> 指令,总线锁保证原子性。</li><li><code>ARM</code>:LDREX/STREX 指令对(<code>Load-Exclusive/Store-Exclusive</code>),失败重试。</li></ul></li></ul><p>同时,该操作会触发全内存屏障(<code>Full Memory Barrier</code>):</p><ul><li>保证操作前后的内存读写不会被 <code>CPU</code> 重排序;</li><li>确保所有线程能立即看到变量的最新值(避免 <code>CPU</code> 缓存导致的 “脏读”)。</li><li><code>.NET</code> 实现(<code>CoreCLR</code> 简化伪码):</li></ul><pre><code class="csharp">public static int CompareExchange(ref int location, int value, int comparand) { // 内联为平台特定原子指令 fixed (int* ptr = &amp;location) { return InterlockedCompareExchange(ptr, value, comparand); } }</code></pre><ul><li><p>性能:</p><ul><li>无竞争:<code>O(1)</code>,极快。</li><li>高竞争:自旋消耗 <code>CPU</code>,但仍远优于锁(无上下文切换)。</li></ul></li></ul><h3>注意事项与最佳实践</h3><ul><li>自旋循环:总是用 <code>do-while</code> 包装 <code>CAS</code>,形成“乐观循环”。</li><li><p><code>ABA</code> 问题:</p><ul><li>线程 1 读取值为A,准备 <code>CAS</code> 为C;线程 2 将A改为B,又改回A;线程 1 的 <code>CAS</code> 会成功,但中间状态已变化,可能导致逻辑错误</li><li>解决方法:版本号 + 引用 <code>CAS</code>。</li></ul></li><li>内存可见性:<code>Interlocked</code> 操作自带全内存屏障(<code>full fence</code>),无需额外 <code>volatile</code>。</li><li>避免过度使用:简单计数用 <code>Interlocked.Increment</code>;集合用 <code>Concurrent</code> 类。</li><li><p>替代方案:</p><ul><li>高层:<code>ConcurrentDictionary、BlockingCollection</code>。</li><li>现代:<code>System.Threading.Channels</code>(生产者-消费者)。</li><li><code>.NET 8+</code>:考虑 <code>System.Threading.Lock</code>(<code>C# 12</code> 新特性,更简洁)。</li></ul></li></ul><h3>ABA 问题</h3><h4>什么是 ABA 问题?</h4><p><code>ABA</code> 问题 是无锁(<code>lock-free</code>)编程中使用 <code>Compare-And-Swap</code> (<code>CAS</code>) 操作时的一种经典隐患。</p><p>场景描述:</p><ul><li>线程 A 读取共享变量值:A</li><li>线程 B 先将值改为 B,然后又改回 A</li><li>线程 A 执行 <code>CAS</code>:期望值是 A,当前值也是 A → <code>CAS</code> 成功!</li><li>但实际上值已经被其他线程修改过,语义已经改变,线程 A 却误以为“一切未变”。</li></ul><p>这会导致逻辑错误,尤其在无锁栈、队列等数据结构中,可能造成节点丢失、循环引用或内存泄漏。</p><h4>解决方案:版本号 + 引用 CAS</h4><ol><li>定义「版本化状态对象」</li></ol><pre><code class="csharp">sealed class VersionedValue { public readonly int Value; public readonly int Version; public VersionedValue(int value, int version) { Value = value; Version = version; } public override string ToString() =&gt; $&quot;Value={Value}, Version={Version}&quot;; }</code></pre><p>关键点:</p><ul><li>不可变(<code>readonly</code>)</li><li>每次修改 → 新对象</li><li><code>CAS</code> 比较的是 对象引用</li></ul><ol start="2"><li>运行示例</li></ol><pre><code class="csharp">using System; using System.Threading; using System.Threading.Tasks; class Program { static VersionedValue state = new VersionedValue(1, 0); static void Main() { var t1 = Task.Run(Thread1); var t2 = Task.Run(Thread2); Task.WaitAll(t1, t2); Console.WriteLine($&quot;Final state: {state}&quot;); } static void Thread1() { var old = state; // 读 A(v0) Console.WriteLine($&quot;T1 read: {old}&quot;); Thread.Sleep(200); var newState = new VersionedValue(3, old.Version + 1); var result = Interlocked.CompareExchange( ref state, newState, old ); if (result == old) { Console.WriteLine(&quot;T1 CAS succeeded&quot;); } else { Console.WriteLine($&quot;T1 CAS failed, current = {result}&quot;); } } static void Thread2() { Thread.Sleep(50); // A → B state = new VersionedValue(2, state.Version + 1); // B → A state = new VersionedValue(1, state.Version + 1); Console.WriteLine($&quot;T2 changed state twice: {state}&quot;); } }</code></pre><ol start="3"><li>典型输出</li></ol><pre><code>T1 read: Value=1, Version=0 T2 changed state twice: Value=1, Version=2 T1 CAS failed, current = Value=1, Version=2 Final state: Value=1, Version=2</code></pre><p>核心结果:</p><ul><li>值还是 1</li><li>版本已经变了</li><li><code>CAS</code> 失败</li><li><code>ABA</code> 被成功识别</li></ul><h4>为什么这个方案一定能防 ABA?</h4><blockquote>CAS 比较的是“对象引用”,而不是值</blockquote><p>即使:</p><pre><code>Value: 1 → 2 → 1</code></pre><p>但:</p><pre><code>Reference: A → B → C</code></pre><p>A ≠ C</p><p><code>CAS</code> 不可能误判成功</p><h4>黄金组合</h4><table><thead><tr><th>技术</th><th>作用</th></tr></thead><tbody><tr><td>Interlocked.CompareExchange</td><td>原子性</td></tr><tr><td>不可变对象</td><td>消除中间态</td></tr><tr><td>版本号</td><td>明确变化历史</td></tr><tr><td>GC</td><td>避免悬垂指针</td></tr></tbody></table><blockquote>.NET 官方并发集合、Immutable 系列,都是这个思想</blockquote><h4>什么时候该用这一套?</h4><p>适合:</p><ul><li>自定义状态机</li><li><code>CAS</code> + 状态切换</li><li>无锁缓存</li><li>高频并发更新</li></ul><p>不适合:</p><ul><li>普通计数</li><li>简单标志位</li><li>低并发业务</li></ul><h3>总结</h3><blockquote><p>Interlocked.CompareExchange 是 .NET 无锁并发的“原子开关”<br>用它可以:</p><ul><li>不加锁</li><li>不阻塞</li><li>在竞争中安全修改状态</li></ul></blockquote>

2026/1/3
阅读更多

【剪映API】批量向现有草稿中添加视频素材

<h2>ADD_VIDEOS API 接口文档</h2><h3>接口信息</h3><pre><code>POST /openapi/capcut-mate/v1/add_videos</code></pre><h3>功能描述</h3><p>批量向现有草稿中添加视频素材。该接口是一个功能强大的视频添加工具,支持多个视频的批量处理,包括时间范围控制、透明度调整、遮罩效果、转场动画、音量控制、缩放变换等高级功能。特别适合创建复杂的多视频组合场景,如画中画效果、视频拼接、过渡动画等。</p><h3>更多文档</h3><p>📖 更多详细文档和教程请访问:<a href="https://link.segmentfault.com/?enc=2m%2BSMgMvVqGl8gQm8jh7DA%3D%3D.oWwyLWRNMGV4BqGJMmQ35wZ6UPIU7thQagy78ch1f40%3D" rel="nofollow">https://docs.jcaigc.cn</a></p><h3>请求参数</h3><pre><code class="json">{ &quot;draft_url&quot;: &quot;https://capcut-mate.jcaigc.cn/openapi/capcut-mate/v1/get_draft?draft_id=2025092811473036584258&quot;, &quot;video_infos&quot;: &quot;[{\&quot;video_url\&quot;:\&quot;https://assets.jcaigc.cn/video1.mp4\&quot;,\&quot;width\&quot;:1024,\&quot;height\&quot;:1024,\&quot;start\&quot;:0,\&quot;end\&quot;:5000000,\&quot;duration\&quot;:5000000,\&quot;mask\&quot;:\&quot;圆形\&quot;,\&quot;transition\&quot;:\&quot;淡入淡出\&quot;,\&quot;transition_duration\&quot;:500000,\&quot;volume\&quot;:0.8}]&quot;, &quot;alpha&quot;: 0.5, &quot;scale_x&quot;: 1.0, &quot;scale_y&quot;: 1.0, &quot;transform_x&quot;: 100, &quot;transform_y&quot;: 200 }</code></pre><h4>参数说明</h4><table><thead><tr><th>参数名</th><th>类型</th><th>必填</th><th>默认值</th><th>说明</th></tr></thead><tbody><tr><td>draft_url</td><td>string</td><td>✅</td><td>-</td><td>目标草稿的完整URL</td></tr><tr><td>video_infos</td><td>string</td><td>✅</td><td>-</td><td>视频信息数组的JSON字符串</td></tr><tr><td>alpha</td><td>number</td><td>❌</td><td>1.0</td><td>全局透明度(0-1)</td></tr><tr><td>scale_x</td><td>number</td><td>❌</td><td>1.0</td><td>X轴缩放比例</td></tr><tr><td>scale_y</td><td>number</td><td>❌</td><td>1.0</td><td>Y轴缩放比例</td></tr><tr><td>transform_x</td><td>number</td><td>❌</td><td>0</td><td>X轴位置偏移(像素)</td></tr><tr><td>transform_y</td><td>number</td><td>❌</td><td>0</td><td>Y轴位置偏移(像素)</td></tr></tbody></table><h4>video_infos 数组结构</h4><table><thead><tr><th>字段名</th><th>类型</th><th>必填</th><th>默认值</th><th>说明</th></tr></thead><tbody><tr><td>video_url</td><td>string</td><td>✅</td><td>-</td><td>视频文件的URL地址</td></tr><tr><td>width</td><td>number</td><td>✅</td><td>-</td><td>视频宽度(像素)</td></tr><tr><td>height</td><td>number</td><td>✅</td><td>-</td><td>视频高度(像素)</td></tr><tr><td>start</td><td>number</td><td>✅</td><td>-</td><td>视频开始播放时间(微秒)</td></tr><tr><td>end</td><td>number</td><td>✅</td><td>-</td><td>视频结束播放时间(微秒)</td></tr><tr><td>duration</td><td>number</td><td>❌</td><td>end-start</td><td>视频总时长(微秒)</td></tr><tr><td>mask</td><td>string</td><td>❌</td><td>-</td><td>遮罩类型</td></tr><tr><td>transition</td><td>string</td><td>❌</td><td>-</td><td>转场效果名称</td></tr><tr><td>transition_duration</td><td>number</td><td>❌</td><td>500000</td><td>转场持续时间(微秒)</td></tr><tr><td>volume</td><td>number</td><td>❌</td><td>1.0</td><td>音量大小(0-1)</td></tr></tbody></table><h4>参数详解</h4><h5>时间参数</h5><ul><li><strong>start</strong>: 视频在时间轴上的开始时间,单位微秒(1秒 = 1,000,000微秒)</li><li><strong>end</strong>: 视频在时间轴上的结束时间,单位微秒</li><li><strong>duration</strong>: 视频文件的总时长,用于素材创建(可选参数,如果不传则默认为end-start)</li><li><strong>播放时长</strong>: 实际播放时长 = end - start</li></ul><h5>透明度参数</h5><ul><li><p><strong>alpha</strong>: 全局透明度,应用于所有添加的视频</p><ul><li>1.0 = 完全不透明</li><li>0.5 = 半透明</li><li>0.0 = 完全透明</li><li>范围:0.0 - 1.0</li></ul></li></ul><h5>缩放参数</h5><ul><li><strong>scale_x/scale_y</strong>: X/Y轴方向的缩放比例</li><li>1.0 = 原始大小,0.5 = 缩小一半,2.0 = 放大两倍</li><li>建议范围:0.1 - 5.0</li></ul><h5>位置参数</h5><ul><li><strong>transform_x/transform_y</strong>: X/Y轴方向的位置偏移,单位像素</li><li>正值向右/下移动,负值向左/上移动</li><li>以画布中心为原点</li></ul><h5>遮罩类型</h5><p>支持的遮罩类型:</p><ul><li><code>圆形</code> - 圆形遮罩效果</li><li><code>爱心</code> - 爱心形状遮罩</li><li><code>星形</code> - 星形遮罩</li><li><code>矩形</code> - 矩形遮罩</li><li><code>线性</code> - 线性渐变遮罩</li><li><code>镜面</code> - 镜面反射遮罩</li></ul><h5>转场效果</h5><ul><li><strong>transition</strong>: 转场效果名称</li><li><p><strong>transition_duration</strong>: 转场持续时间</p><ul><li>最小值:100,000微秒(0.1秒)</li><li>最大值:2,500,000微秒(2.5秒)</li><li>推荐值:500,000微秒(0.5秒)</li></ul></li></ul><h5>音量控制</h5><ul><li><p><strong>volume</strong>: 视频音量大小</p><ul><li>1.0 = 原始音量</li><li>0.5 = 一半音量</li><li>0.0 = 静音</li><li>范围:0.0 - 1.0</li></ul></li></ul><h3>响应格式</h3><h4>成功响应 (200)</h4><pre><code class="json">{ &quot;draft_url&quot;: &quot;https://capcut-mate.jcaigc.cn/openapi/capcut-mate/v1/get_draft?draft_id=2025092811473036584258&quot;, &quot;track_id&quot;: &quot;video-track-uuid&quot;, &quot;video_ids&quot;: [&quot;video1-uuid&quot;, &quot;video2-uuid&quot;, &quot;video3-uuid&quot;], &quot;segment_ids&quot;: [&quot;segment1-uuid&quot;, &quot;segment2-uuid&quot;, &quot;segment3-uuid&quot;] }</code></pre><h4>响应字段说明</h4><table><thead><tr><th>字段名</th><th>类型</th><th>说明</th></tr></thead><tbody><tr><td>draft_url</td><td>string</td><td>更新后的草稿URL</td></tr><tr><td>track_id</td><td>string</td><td>视频轨道ID</td></tr><tr><td>video_ids</td><td>array</td><td>添加的视频ID列表</td></tr><tr><td>segment_ids</td><td>array</td><td>片段ID列表</td></tr></tbody></table><h3>使用示例</h3><h4>cURL 示例</h4><h5>1. 基本视频添加</h5><pre><code class="bash">curl -X POST https://capcut-mate.jcaigc.cn/openapi/capcut-mate/v1/add_videos \ -H &quot;Content-Type: application/json&quot; \ -d '{ &quot;draft_url&quot;: &quot;YOUR_DRAFT_URL&quot;, &quot;video_infos&quot;: &quot;[{\&quot;video_url\&quot;:\&quot;https://assets.jcaigc.cn/video1.mp4\&quot;,\&quot;width\&quot;:1920,\&quot;height\&quot;:1080,\&quot;start\&quot;:0,\&quot;end\&quot;:5000000,\&quot;duration\&quot;:10000000}]&quot; }'</code></pre><h5>2. 多视频批量添加</h5><pre><code class="bash">curl -X POST https://capcut-mate.jcaigc.cn/openapi/capcut-mate/v1/add_videos \ -H &quot;Content-Type: application/json&quot; \ -d '{ &quot;draft_url&quot;: &quot;YOUR_DRAFT_URL&quot;, &quot;video_infos&quot;: &quot;[{\&quot;video_url\&quot;:\&quot;https://assets.jcaigc.cn/video1.mp4\&quot;,\&quot;width\&quot;:1920,\&quot;height\&quot;:1080,\&quot;start\&quot;:0,\&quot;end\&quot;:5000000,\&quot;duration\&quot;:10000000},{\&quot;video_url\&quot;:\&quot;https://assets.jcaigc.cn/video2.mp4\&quot;,\&quot;width\&quot;:1280,\&quot;height\&quot;:720,\&quot;start\&quot;:5000000,\&quot;end\&quot;:10000000,\&quot;duration\&quot;:8000000}]&quot;, &quot;alpha&quot;: 0.8 }'</code></pre><h5>3. 带遮罩和转场的视频</h5><pre><code class="bash">curl -X POST https://capcut-mate.jcaigc.cn/openapi/capcut-mate/v1/add_videos \ -H &quot;Content-Type: application/json&quot; \ -d '{ &quot;draft_url&quot;: &quot;YOUR_DRAFT_URL&quot;, &quot;video_infos&quot;: &quot;[{\&quot;video_url\&quot;:\&quot;https://assets.jcaigc.cn/video1.mp4\&quot;,\&quot;width\&quot;:1024,\&quot;height\&quot;:1024,\&quot;start\&quot;:0,\&quot;end\&quot;:5000000,\&quot;duration\&quot;:10000000,\&quot;mask\&quot;:\&quot;圆形\&quot;,\&quot;transition\&quot;:\&quot;淡入淡出\&quot;,\&quot;transition_duration\&quot;:500000,\&quot;volume\&quot;:0.8}]&quot;, &quot;alpha&quot;: 1.0, &quot;scale_x&quot;: 1.2, &quot;scale_y&quot;: 1.2 }'</code></pre><h5>4. 画中画效果</h5><pre><code class="bash">curl -X POST https://capcut-mate.jcaigc.cn/openapi/capcut-mate/v1/add_videos \ -H &quot;Content-Type: application/json&quot; \ -d '{ &quot;draft_url&quot;: &quot;YOUR_DRAFT_URL&quot;, &quot;video_infos&quot;: &quot;[{\&quot;video_url\&quot;:\&quot;https://assets.jcaigc.cn/main.mp4\&quot;,\&quot;width\&quot;:1920,\&quot;height\&quot;:1080,\&quot;start\&quot;:0,\&quot;end\&quot;:10000000,\&quot;duration\&quot;:15000000},{\&quot;video_url\&quot;:\&quot;https://assets.jcaigc.cn/pip.mp4\&quot;,\&quot;width\&quot;:640,\&quot;height\&quot;:360,\&quot;start\&quot;:2000000,\&quot;end\&quot;:8000000,\&quot;duration\&quot;:10000000}]&quot;, &quot;transform_x&quot;: 300, &quot;transform_y&quot;: -200, &quot;scale_x&quot;: 0.3, &quot;scale_y&quot;: 0.3 }'</code></pre><h3>错误码说明</h3><table><thead><tr><th>错误码</th><th>错误信息</th><th>说明</th><th>解决方案</th></tr></thead><tbody><tr><td>400</td><td>draft_url是必填项</td><td>缺少草稿URL参数</td><td>提供有效的草稿URL</td></tr><tr><td>400</td><td>video_infos是必填项</td><td>缺少视频信息参数</td><td>提供有效的视频信息JSON</td></tr><tr><td>400</td><td>video_infos格式错误</td><td>JSON格式不正确</td><td>检查JSON字符串格式</td></tr><tr><td>400</td><td>video_url是必填项</td><td>视频URL缺失</td><td>为每个视频提供URL</td></tr><tr><td>400</td><td>视频尺寸无效</td><td>width或height无效</td><td>提供正数的宽度和高度</td></tr><tr><td>400</td><td>时间范围无效</td><td>end必须大于start</td><td>确保结束时间大于开始时间</td></tr><tr><td>400</td><td>透明度值无效</td><td>alpha不在0-1范围内</td><td>使用0-1之间的透明度值</td></tr><tr><td>404</td><td>草稿不存在</td><td>指定的草稿URL无效</td><td>检查草稿URL是否正确</td></tr><tr><td>404</td><td>视频资源不存在</td><td>视频URL无法访问</td><td>检查视频URL是否可访问</td></tr><tr><td>500</td><td>视频处理失败</td><td>内部处理错误</td><td>联系技术支持</td></tr></tbody></table><h3>注意事项</h3><ol><li><strong>JSON格式</strong>: video_infos必须是合法的JSON字符串</li><li><strong>时间单位</strong>: 所有时间参数使用微秒(1秒 = 1,000,000微秒)</li><li><strong>视频格式</strong>: 确保视频文件格式被支持(如MP4、AVI等)</li><li><strong>文件大小</strong>: 大视频文件可能影响处理速度</li><li><strong>网络访问</strong>: 视频URL必须可以正常访问</li><li><strong>遮罩限制</strong>: 只支持预定义的遮罩类型</li><li><strong>转场限制</strong>: 转场时长有固定范围限制</li><li><strong>性能考虑</strong>: 批量添加大量视频可能影响性能</li></ol><h3>工作流程</h3><ol><li>验证必填参数(draft_url, video_infos)</li><li>解析video_infos JSON字符串</li><li>验证每个视频的参数配置</li><li>获取并解密草稿内容</li><li>创建视频轨道</li><li>添加视频片段到轨道</li><li>应用透明度、缩放和位置变换</li><li>添加遮罩和转场效果</li><li>设置音量</li><li>保存并加密草稿</li><li>返回处理结果</li></ol><h3>相关接口</h3><ul><li><a href="./create_draft.md">创建草稿</a></li><li><a href="./add_audios.md">添加音频</a></li><li><a href="./add_images.md">添加图片</a></li><li><a href="./save_draft.md">保存草稿</a></li><li><a href="./gen_video.md">生成视频</a></li></ul><hr><p>📚 <strong>项目资源</strong> <br><strong>GitHub</strong>: <a href="https://link.segmentfault.com/?enc=AjlT9ol8ORSjQtyNd3kfsQ%3D%3D.u1Zu8ZccxN2pvwCnjccSIhNT9fUIubvCvdGh%2F%2FJzV1ppe7Nd1sbQkiIUIvCA033w" rel="nofollow">https://github.com/Hommy-master/capcut-mate</a> <br><strong>Gitee</strong>: <a href="https://link.segmentfault.com/?enc=%2FRhxGmevJhWj1MV4oMO2XA%3D%3D.Gt3c%2Fn5jCIe52u%2F9VcsG2YAh8Sp9tuERlZ6VNi7S%2BJCap%2F3RbUOrWUFG2C5cUp9Z" rel="nofollow">https://gitee.com/taohongmin-gitee/capcut-mate</a></p>

2026/1/4
阅读更多

告别服务器!小程序纯前端“图片转 PDF”工具,隐私安全又高效!

<h2>1. 背景与痛点:纯前端实践的动力</h2><p>在开发小程序时,实现如“图片转 PDF”这样的功能时,常常面临以下挑战:</p><ul><li><strong>隐私担忧</strong>:将图片上传到服务器进行转换,用户担心图片内容泄露。对于个人证件、私密照片等敏感内容,这一顾虑尤为突出。</li><li><strong>网络依赖与效率</strong>:转换过程需要频繁与服务器交互,在弱网环境下速度慢、不稳定,甚至可能因上传大文件而失败。</li><li><strong>服务器成本</strong>:每一次转换都意味着服务器资源的消耗(存储、计算、带宽),对于开发者而言,成本不容忽视。</li></ul><p>为了解决这些痛点,我们探索了一个更优的实现路径:<strong>纯前端、在小程序本地完成图片到 PDF 的转换</strong>。</p><h2>2. 核心思路:本地文件系统与 <code>pdf-lib</code> 的巧妙结合</h2><p>在小程序中实现纯前端图片转 PDF,我们的核心思路是:</p><ol><li><strong>图片本地化处理</strong>:充分利用小程序强大的本地文件系统能力,将用户选择的图片读取到本地临时路径。</li><li><strong>PDF 文档构建</strong>:引入功能丰富的 JavaScript 库 <code>pdf-lib</code>,在小程序运行时直接在前端环境创建和操作 PDF 文件。</li><li><strong>最终文件保存</strong>:将 <code>pdf-lib</code> 生成的 PDF 数据流保存为本地文件,供用户直接预览或分享。</li></ol><p>这种方式让整个转换过程都在用户的小程序沙箱环境内完成,<strong>图片数据不会离开用户手机</strong>,极大保障了数据隐私和安全性,同时显著提升了转换效率并降低了服务器成本。</p><h2>3. 技术核心:<code>pdf-lib</code> 的引入与应用</h2><p><code>pdf-lib</code> 是一个强大的纯 JavaScript PDF 库,支持在多种 JavaScript 环境下创建和修改 PDF 文件,完美契合小程序这种前端应用场景。</p><h3>3.1 库的引入</h3><p>你需要将 <code>pdf-lib</code> 的小程序兼容版本(通常是 <code>pdf-lib.min.js</code>)放置在你的项目目录中,并通过 <code>require</code> 引入:</p><pre><code class="javascript">const { PDFDocument, degrees, PageSizes } = require('./pdf-lib.min.js'); const fs = wx.getFileSystemManager(); // 小程序文件管理器实例</code></pre><h3>3.2 转换逻辑概览</h3><p>整个图片转 PDF 的流程可分解为以下几个关键步骤:</p><ol><li><strong>图片预处理</strong>:获取每张图片的尺寸、类型 (<code>wx.getImageInfo</code>),并将其读取为 Base64 格式 (<code>fs.readFile</code>),这是 <code>pdf-lib</code> 嵌入图片所需的标准数据格式。</li><li><strong>创建 PDF 文档</strong>:初始化一个空的 <code>PDFDocument</code> 对象。</li><li><strong>逐页添加图片</strong>:遍历所有图片,为每张图片创建一个新的 PDF 页面。根据图片的原始尺寸和类型,将其嵌入到 PDF 中,并进行智能缩放、居中。<strong>对于横向图片,还会自动旋转页面 90 度以更好地适应 A4 纸张。</strong></li><li><strong>生成与保存</strong>:将构建好的 PDF 文档保存为 Base64 编码的字符串,再通过小程序文件系统的 <code>fs.writeFile</code> 接口,写入到本地的临时文件路径。</li><li><strong>返回结果</strong>:将生成的 PDF 文件本地路径返回给业务层,用于后续的预览或分享。</li></ol><h2>4. 核心代码:<code>img2pdf.js</code></h2><p>以下是帮小忙工具箱实现图片转 PDF 功能的核心源代码。</p><pre><code class="javascript">const { PDFDocument, degrees, PageSizes } = require('./pdf-lib.min.js'); const fs = wx.getFileSystemManager() /** * 把图片转成pdf * @param {Array} urls 图片url数组 * @returns {String} pdfUrl pdf文件url */ export async function img2pdf(urls) { if (typeof urls == 'string') { urls = [urls] } // 图片信息 const imageInfo = urls.map((url) =&gt; { return wx.getImageInfo({ src: url }); }); const imageInfoRes = await Promise.all(imageInfo); console.log(imageInfoRes); // 图片base64 const imageBase64 = urls.map((url) =&gt; { return readFile(url, &quot;base64&quot;); }); const imageBase64Res = await Promise.all(imageBase64); console.log(imageBase64Res); const pdfDoc = await PDFDocument.create(); for (let i = 0; i &lt; imageInfoRes.length; i++) { const { type, width, height } = imageInfoRes[i]; let pdfImage = &quot;&quot;; if (type === 'jpeg') { pdfImage = await pdfDoc.embedJpg(imageBase64Res[i]); } else if (type === 'png') { pdfImage = await pdfDoc.embedPng(imageBase64Res[i]); } const page = pdfDoc.addPage(PageSizes.A4); const { width: pageWidth, height: pageHeight } = page.getSize(); // 获取页面尺寸 let drawOptions = {}; // 如果图片是宽大于高,则旋转 if (width &gt; height) { // 页面旋转后,可用于绘制的&quot;宽度&quot;实际上是原始页面的高度,&quot;高度&quot;是原始页面的宽度 const scaled = pdfImage.scaleToFit(pageHeight, pageWidth); // 注意参数顺序因为页面旋转了 drawOptions = { // x: scaled.height + (pageWidth - scaled.height) / 2, // 注意这里用的是 scaled.height x: (pageWidth - scaled.height) / 2, y: (pageHeight - scaled.width) / 2 + scaled.width, width: scaled.width, height: scaled.height, rotate: degrees(270), }; console.log('drawOptions', drawOptions); } else { // 图片是纵向或方形的 const scaled = pdfImage.scaleToFit(pageWidth, pageHeight); drawOptions = { x: (pageWidth - scaled.width) / 2, // 居中 X y: (pageHeight - scaled.height) / 2, // 居中 Y width: scaled.width, height: scaled.height, }; } page.drawImage(pdfImage, drawOptions); } // 3. 获取 PDF 的 Uint8Array const docBase64 = await pdfDoc.saveAsBase64(); const timestamp = Date.now(); const pdfPath = await base64ToFile(docBase64, `/${timestamp}.pdf`); return pdfPath; } /** * base64转本地文件 * @param {string} base64 base64字符串 * @param {string} fileName 文件名 * @returns {Promise} Promise 文件路径 */ function base64ToFile(base64, fileName) { const { promise, resolve, reject } = Promise.withResolvers(); const filePath = wx.env.USER_DATA_PATH + fileName; fs.writeFile({ filePath, data: base64, encoding: &quot;base64&quot;, success: res =&gt; { resolve(filePath) }, fail: err =&gt; { reject(err) } }); return promise; } /** * 使用Promise读取文件 * @param {string} filePath 文件路径 * @param {string} encoding 文件编码 * @returns {Promise} Promise对象 */ function readFile(filePath, encoding = 'utf8') { const { promise, resolve, reject } = Promise.withResolvers(); fs.readFile({ filePath, encoding, success(fileRes) { resolve(fileRes.data) }, fail(err) { reject(err) } }); return promise; }</code></pre><h2>5. 小程序端应用示例</h2><p>在页面中,可以通过简单的交互完成转换。</p><pre><code class="javascript">// pages/image-to-pdf/index.js import { img2pdf } from '../../utils/img2pdf'; // 引入转换工具 Page({ data: { selectedImages: [], // 用户选择的图片临时路径数组 pdfPath: '', loading: false }, // 触发图片选择 async chooseImage() { const { tempFiles } = await wx.chooseMedia({ count: 9, // 最多选择 9 张图片 mediaType: ['image'], sizeType: ['original', 'compressed'], // 可以选择原图或压缩图 sourceType: ['album', 'camera'], }); this.setData({ selectedImages: tempFiles.map(file =&gt; file.tempFilePath) }); }, // 执行图片转 PDF 转换 async convertToPdf() { if (this.data.selectedImages.length === 0) { wx.showToast({ title: '请先选择图片', icon: 'none' }); return; } this.setData({ loading: true }); wx.showLoading({ title: '转换中...' }); try { const pdfFilePath = await img2pdf(this.data.selectedImages); this.setData({ pdfPath: pdfFilePath }); wx.hideLoading(); wx.showToast({ title: '转换成功!', icon: 'success' }); // 转换成功后,自动打开 PDF 预览 wx.openDocument({ filePath: pdfFilePath, fileType: 'pdf', success: res =&gt; console.log('打开 PDF 成功', res), fail: err =&gt; console.error('打开 PDF 失败', err) }); } catch (error) { wx.hideLoading(); wx.showToast({ title: '转换失败!', icon: 'error' }); console.error('图片转 PDF 发生错误', error); } finally { this.setData({ loading: false }); } } })</code></pre><h2>6. 经验总结与注意事项</h2><ol><li><p><strong>文件体积与性能</strong>:</p><ul><li><code>pdf-lib</code> 库本身有一定体积(通常在几百 KB),会增加小程序包体大小,我们是使用分包,所以不影响主包。</li><li>图片数量越多、分辨率越高,转换耗时越长,内存占用越大。建议在选择图片时提示用户合理数量或适当压缩。</li><li>pdf横向图片旋转需要额外计算和处理,可能会略微增加复杂性,如果觉得复杂,也可以直接判断图片是否是纵向,如果是横向使用canvas旋转图片,逻辑上就毕竟简单了。</li></ul></li><li><p><strong><code>Promise.withResolvers()</code> 兼容性</strong>:</p><ul><li>代码使用了 <code>Promise.withResolvers()</code>,目前大多数小程序环境和浏览器中<strong>兼容性可能不好</strong>,我自己做了兼容。</li></ul></li><li><p><strong>本地文件系统限制</strong>:</p><ul><li><code>wx.env.USER_DATA_PATH</code> 路径下的文件是小程序沙箱环境特有的,用户无法直接在系统文件管理器中找到。</li><li>生成的文件是临时文件,小程序关闭或长时间不用可能被系统清理。如果需要长期保存,需引导用户通过 <code>wx.saveFile</code> (保存到相册或本地文件) 或上传云存储。</li></ul></li><li><strong>图片类型支持</strong>:<br><code>pdf-lib</code> 主要支持 JPEG 和 PNG 格式。其他格式(如 WebP、GIF)需要先转换为 JPEG/PNG 再进行嵌入,可以利用canvas实现,后面会分享。</li></ol><h2>写在最后</h2><p>纯前端实现“图片转 PDF”功能,不仅提升了用户体验,更重要的是有效保护了用户的数字隐私。这在追求用户信任和数据安全的小程序生态中,无疑是一个值得推广的实践。</p><p>希望这次分享能为你带来启发,共同探索小程序前端能力的更多可能性!</p><hr>

2026/1/4
阅读更多

做京东关键字搜索系统 3 年,被接口坑到凌晨改代码的实战手记

<h2>排序陷阱:乱传<code>sort</code>参数,搜索结果全反了</h2><p>做竞品监控时,老板要求 “按销量降序排序,抓 top30 的竞品”,我随手传了<code>sort=&quot;sales&quot;</code>,结果返回的全是销量最低的商品,导致监控数据完全失效,错过了大促前的竞品调价预警。</p><p>查了半天才知道,京东关键字接口的<code>sort</code>参数有严格取值,<strong>销量降序是<code>sales_desc</code>,升序是<code>sales_asc</code>,传<code>sales</code>会默认按 “综合排序”(不是销量)</strong> ,文档里只列了部分取值,很多排序字段要靠自己试。</p><p>[免费测试////]</p><pre><code>--------------------------------------- { &quot;items&quot;: { &quot;page&quot;: &quot;1&quot;, &quot;url&quot;: &quot;https://search.jd.com/Search?keyword=%E5%8D%8E%E4%B8%BAmatebook14&quot;, &quot;keyword&quot;: &quot;华为matebook14&quot;, &quot;real_total_results&quot;: &quot;2994&quot;, &quot;total_results&quot;: &quot;2994&quot;, &quot;page_size&quot;: 32, &quot;pagecount&quot;: &quot;100&quot;, &quot;_ddf&quot;: &quot;fqx&quot;, &quot;item&quot;: [ { &quot;title&quot;: &quot;华为MateBook 14 Linux版&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/382678/33/10256/43516/69554e51Fd7ab6750/85328b6bcf88c700.jpg&quot;, &quot;price&quot;: &quot;6499.00&quot;, &quot;sales&quot;: 40000, &quot;num_iid&quot;: &quot;100229437226&quot;, &quot;seller&quot;: &quot;华为京东自营旗舰店&quot;, &quot;shop_id&quot;: &quot;1000004259&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/100229437226.html&quot;, &quot;reviews&quot;: 20000 }, { &quot;title&quot;: &quot;华为MateBook 14 Linux版&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/380343/19/17924/43852/69554e1eF76379c58/46edb69ed8759a78.jpg&quot;, &quot;price&quot;: &quot;5799.00&quot;, &quot;sales&quot;: 40000, &quot;num_iid&quot;: &quot;100176738425&quot;, &quot;seller&quot;: &quot;华为京东自营旗舰店&quot;, &quot;shop_id&quot;: &quot;1000004259&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/100176738425.html&quot;, &quot;reviews&quot;: 20000 }, { &quot;title&quot;: &quot;华为MateBook GT 14 Linux版&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/379501/27/17861/48692/69554e4aF02cf1284/4b5d85c3b5a45a86.jpg&quot;, &quot;price&quot;: &quot;7499.00&quot;, &quot;sales&quot;: 5000, &quot;num_iid&quot;: &quot;100229437208&quot;, &quot;seller&quot;: &quot;华为京东自营旗舰店&quot;, &quot;shop_id&quot;: &quot;1000004259&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/100229437208.html&quot;, &quot;reviews&quot;: 2000 }, { &quot;title&quot;: &quot;华为MateBook 14 Linux版&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/384245/38/4175/46631/69554e5cF299055d6/3014c5e05e4147af.jpg&quot;, &quot;price&quot;: &quot;6499.00&quot;, &quot;sales&quot;: 40000, &quot;num_iid&quot;: &quot;100229437250&quot;, &quot;seller&quot;: &quot;华为京东自营旗舰店&quot;, &quot;shop_id&quot;: &quot;1000004259&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/100229437250.html&quot;, &quot;reviews&quot;: 20000 }, { &quot;title&quot;: &quot;华为MateBook 14 店铺预装Windows版 轻薄笔记本电脑 2.8K OLED触控屏 酷睿UItra5 16G 1T 深空灰&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/380602/36/18361/43917/69554e26F8ee4bc2d/81c405e963e64232.jpg&quot;, &quot;price&quot;: &quot;5799&quot;, &quot;sales&quot;: 40000, &quot;num_iid&quot;: &quot;100176738429&quot;, &quot;seller&quot;: &quot;华为京东自营旗舰店&quot;, &quot;shop_id&quot;: &quot;1000004259&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/100176738429.html&quot;, &quot;reviews&quot;: 20000 }, { &quot;title&quot;: &quot;华为MateBook GT 14 Linux版&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/384672/36/3457/48692/69554e56F4d3ef5b7/6ddf97c863de8af7.jpg&quot;, &quot;price&quot;: &quot;7499.00&quot;, &quot;sales&quot;: 5000, &quot;num_iid&quot;: &quot;100229437244&quot;, &quot;seller&quot;: &quot;华为京东自营旗舰店&quot;, &quot;shop_id&quot;: &quot;1000004259&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/100229437244.html&quot;, &quot;reviews&quot;: 2000 }, { &quot;title&quot;: &quot;华为MateBook 14 Linux版&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/384721/16/2968/47723/69554e21Fffe4ed2a/30acc2a287ac5c1e.jpg&quot;, &quot;price&quot;: &quot;5799.00&quot;, &quot;sales&quot;: 40000, &quot;num_iid&quot;: &quot;100176738427&quot;, &quot;seller&quot;: &quot;华为京东自营旗舰店&quot;, &quot;shop_id&quot;: &quot;1000004259&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/100176738427.html&quot;, &quot;reviews&quot;: 20000 }, { &quot;title&quot;: &quot;华为MateBook 14 Linux版&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/377430/27/18977/43721/69554e54F61444d68/1ad3cd0bca535525.jpg&quot;, &quot;price&quot;: &quot;6499.00&quot;, &quot;sales&quot;: 40000, &quot;num_iid&quot;: &quot;100229437230&quot;, &quot;seller&quot;: &quot;华为京东自营旗舰店&quot;, &quot;shop_id&quot;: &quot;1000004259&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/100229437230.html&quot;, &quot;reviews&quot;: 20000 }, { &quot;title&quot;: &quot;华为matebook14轻薄笔记本电脑&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/296569/14/7110/41415/68244a20F0df9402f/7e4a9d04d587cf46.png&quot;, &quot;price&quot;: &quot;2689.00&quot;, &quot;sales&quot;: 10000, &quot;num_iid&quot;: &quot;10139617789126&quot;, &quot;seller&quot;: &quot;Ultrabook商务本小店&quot;, &quot;shop_id&quot;: &quot;17195504&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/10139617789126.html&quot;, &quot;reviews&quot;: 5000 }, { &quot;title&quot;: &quot;Hi MateBook 14 酷睿Ultra 2&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/382431/16/10152/45275/6954e434Ff4575b70/4c63275976630e6f.jpg&quot;, &quot;price&quot;: &quot;6299.00&quot;, &quot;sales&quot;: 2000, &quot;num_iid&quot;: &quot;100277883964&quot;, &quot;seller&quot;: &quot;华为京东自营旗舰店&quot;, &quot;shop_id&quot;: &quot;1000004259&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/100277883964.html&quot;, &quot;reviews&quot;: 2000 }, { &quot;title&quot;: &quot;华为MateBook GT 14 Linux版&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/377205/24/20233/48759/69554e1bF9735c17f/9557d674eb890b21.jpg&quot;, &quot;price&quot;: &quot;6999.00&quot;, &quot;sales&quot;: 5000, &quot;num_iid&quot;: &quot;100176738411&quot;, &quot;seller&quot;: &quot;华为京东自营旗舰店&quot;, &quot;shop_id&quot;: &quot;1000004259&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/100176738411.html&quot;, &quot;reviews&quot;: 2000 }, { &quot;title&quot;: &quot;华为MateBook GT 14 Linux版&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/381779/26/11326/48759/69554e4fFbc264e00/f811547142b23080.jpg&quot;, &quot;price&quot;: &quot;6999.00&quot;, &quot;sales&quot;: 5000, &quot;num_iid&quot;: &quot;100229437224&quot;, &quot;seller&quot;: &quot;华为京东自营旗舰店&quot;, &quot;shop_id&quot;: &quot;1000004259&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/100229437224.html&quot;, &quot;reviews&quot;: 2000 }, { &quot;title&quot;: &quot;华为MateBook 14 Ultra AI全能本&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/378535/9/20847/115120/6954c7daF8a019f97/430b9808714a86f6.jpg&quot;, &quot;price&quot;: &quot;5799.00&quot;, &quot;sales&quot;: 40000, &quot;num_iid&quot;: &quot;10103759260834&quot;, &quot;seller&quot;: &quot;华为小酷克专卖店&quot;, &quot;shop_id&quot;: &quot;12049068&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/10103759260834.html&quot;, &quot;reviews&quot;: 20000 }, { &quot;title&quot;: &quot;【政府补贴】华为笔记本电脑&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/384509/7/6173/99564/6958e910F02ffdd79/00227e8159b038bc.jpg&quot;, &quot;price&quot;: &quot;5799.00&quot;, &quot;sales&quot;: 10000, &quot;num_iid&quot;: &quot;65512219101&quot;, &quot;seller&quot;: &quot;华为比特专卖店&quot;, &quot;shop_id&quot;: &quot;796063&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/65512219101.html&quot;, &quot;reviews&quot;: 10000 }, { &quot;title&quot;: &quot;政府补贴 华为MateBook14 25款&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/378978/4/9950/91039/694a31faF08e72a6c/9c99e58136feffa4.jpg&quot;, &quot;price&quot;: &quot;5999.00&quot;, &quot;sales&quot;: 3000, &quot;num_iid&quot;: &quot;10075922710113&quot;, &quot;seller&quot;: &quot;京东电竞官方旗舰店&quot;, &quot;shop_id&quot;: &quot;10393769&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/10075922710113.html&quot;, &quot;reviews&quot;: 2000 }, { &quot;title&quot;: &quot;Hi MateBook 14 锐龙 200系列&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/380626/30/16638/48591/6954e438F455b4505/c7b9b910c1cc49bd.jpg&quot;, &quot;price&quot;: &quot;6199.00&quot;, &quot;sales&quot;: 2000, &quot;num_iid&quot;: &quot;100289708048&quot;, &quot;seller&quot;: &quot;华为京东自营旗舰店&quot;, &quot;shop_id&quot;: &quot;1000004259&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/100289708048.html&quot;, &quot;reviews&quot;: 2000 }, { &quot;title&quot;: &quot;华为笔记本电脑&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/375281/23/21236/94747/694e48b5F1930f224/087e605c5356829f.jpg&quot;, &quot;price&quot;: &quot;6499.00&quot;, &quot;sales&quot;: 10000, &quot;num_iid&quot;: &quot;10103194548646&quot;, &quot;seller&quot;: &quot;华为比特专卖店&quot;, &quot;shop_id&quot;: &quot;796063&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/10103194548646.html&quot;, &quot;reviews&quot;: 10000 }, { &quot;title&quot;: &quot;华为MateBook 14 Ultra AI全能本&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/381261/37/12748/112430/6954c7d8F0e6f576e/83cb8a96a5139cc6.jpg&quot;, &quot;price&quot;: &quot;5799.00&quot;, &quot;sales&quot;: 40000, &quot;num_iid&quot;: &quot;10103759260835&quot;, &quot;seller&quot;: &quot;华为小酷克专卖店&quot;, &quot;shop_id&quot;: &quot;12049068&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/10103759260835.html&quot;, &quot;reviews&quot;: 20000 }, { &quot;title&quot;: &quot;Hi MateBook 14 酷睿Ultra 2代&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/378063/4/18281/45205/6954e437F5e2d8f28/10b43b6dc68b4fd4.jpg&quot;, &quot;price&quot;: &quot;7499.00&quot;, &quot;sales&quot;: 2000, &quot;num_iid&quot;: &quot;100277887866&quot;, &quot;seller&quot;: &quot;华为京东自营旗舰店&quot;, &quot;shop_id&quot;: &quot;1000004259&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/100277887866.html&quot;, &quot;reviews&quot;: 2000 }, { &quot;title&quot;: &quot;华为matebook14轻薄笔记本电脑&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/296569/14/7110/41415/68244a20F0df9402f/7e4a9d04d587cf46.png&quot;, &quot;price&quot;: &quot;2789.00&quot;, &quot;sales&quot;: 10000, &quot;num_iid&quot;: &quot;10139617789129&quot;, &quot;seller&quot;: &quot;Ultrabook商务本小店&quot;, &quot;shop_id&quot;: &quot;17195504&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/10139617789129.html&quot;, &quot;reviews&quot;: 5000 }, { &quot;title&quot;: &quot;Hi MateBook 14 锐龙 200系列&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/382637/30/11665/42362/6954e402F82639ff1/7a46da2ba3505da1.jpg&quot;, &quot;price&quot;: &quot;6199.00&quot;, &quot;sales&quot;: 2000, &quot;num_iid&quot;: &quot;100217084277&quot;, &quot;seller&quot;: &quot;华为京东自营旗舰店&quot;, &quot;shop_id&quot;: &quot;1000004259&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/100217084277.html&quot;, &quot;reviews&quot;: 2000 }, { &quot;title&quot;: &quot;【政府补贴】华为笔记本电脑&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/381522/8/14619/98748/6958e91fFabf2426c/7095b2f69d974b59.jpg&quot;, &quot;price&quot;: &quot;5799.00&quot;, &quot;sales&quot;: 10000, &quot;num_iid&quot;: &quot;10103194548644&quot;, &quot;seller&quot;: &quot;华为比特专卖店&quot;, &quot;shop_id&quot;: &quot;796063&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/10103194548644.html&quot;, &quot;reviews&quot;: 10000 }, { &quot;title&quot;: &quot;【政府补贴】华为笔记本电脑&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/381501/24/8495/95723/694e48beFe6ebe815/7cacb7c606c03756.jpg&quot;, &quot;price&quot;: &quot;6499.00&quot;, &quot;sales&quot;: 10000, &quot;num_iid&quot;: &quot;10103194548647&quot;, &quot;seller&quot;: &quot;华为比特专卖店&quot;, &quot;shop_id&quot;: &quot;796063&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/10103194548647.html&quot;, &quot;reviews&quot;: 10000 }, { &quot;title&quot;: &quot;【政府补贴】华为笔记本电脑&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/375103/13/18146/94070/694e48abFdfa23d07/7e6e714fc81b787f.jpg&quot;, &quot;price&quot;: &quot;6499.00&quot;, &quot;sales&quot;: 10000, &quot;num_iid&quot;: &quot;10103194548645&quot;, &quot;seller&quot;: &quot;华为比特专卖店&quot;, &quot;shop_id&quot;: &quot;796063&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/10103194548645.html&quot;, &quot;reviews&quot;: 10000 }, { &quot;title&quot;: &quot;华为MateBook 14 Ultra AI全能本&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/385391/31/251/116863/6954c7ddF74750c83/75530f29221a732c.jpg&quot;, &quot;price&quot;: &quot;5799.00&quot;, &quot;sales&quot;: 40000, &quot;num_iid&quot;: &quot;10103759260841&quot;, &quot;seller&quot;: &quot;华为小酷克专卖店&quot;, &quot;shop_id&quot;: &quot;12049068&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/10103759260841.html&quot;, &quot;reviews&quot;: 20000 }, { &quot;title&quot;: &quot;政府补贴 华为MateBook14 25款&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/376512/36/19426/90309/694a31f1F50582c3b/a4a28e7b896fae5f.jpg&quot;, &quot;price&quot;: &quot;5999.00&quot;, &quot;sales&quot;: 3000, &quot;num_iid&quot;: &quot;10075922710112&quot;, &quot;seller&quot;: &quot;京东电竞官方旗舰店&quot;, &quot;shop_id&quot;: &quot;10393769&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/10075922710112.html&quot;, &quot;reviews&quot;: 2000 }, { &quot;title&quot;: &quot;【政府补贴】华为笔记本电脑&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/381291/10/14780/100177/6958e918F29f7e25c/8e1f998da1bf6036.jpg&quot;, &quot;price&quot;: &quot;5799.00&quot;, &quot;sales&quot;: 10000, &quot;num_iid&quot;: &quot;10109249341545&quot;, &quot;seller&quot;: &quot;华为比特专卖店&quot;, &quot;shop_id&quot;: &quot;796063&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/10109249341545.html&quot;, &quot;reviews&quot;: 10000 }, { &quot;title&quot;: &quot;华为MateBook14 25款国家补贴20%&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/379579/14/19701/78352/6955e0d3Fe77a6c40/6f5627ab5aba295c.jpg&quot;, &quot;price&quot;: &quot;6499.00&quot;, &quot;sales&quot;: 3000, &quot;num_iid&quot;: &quot;10090623469751&quot;, &quot;seller&quot;: &quot;京东电竞官方旗舰店&quot;, &quot;shop_id&quot;: &quot;10393769&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/10090623469751.html&quot;, &quot;reviews&quot;: 2000 }, { &quot;title&quot;: &quot;Hi MateBook 14 锐龙 200系列&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/380104/39/14588/43957/6954e404F3e20fa28/1684167c0423b2bb.jpg&quot;, &quot;price&quot;: &quot;6099.00&quot;, &quot;sales&quot;: 2000, &quot;num_iid&quot;: &quot;100217084279&quot;, &quot;seller&quot;: &quot;华为京东自营旗舰店&quot;, &quot;shop_id&quot;: &quot;1000004259&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/100217084279.html&quot;, &quot;reviews&quot;: 2000 }, { &quot;title&quot;: &quot;华为matebook14轻薄笔记本电脑&quot;, &quot;pic_url&quot;: &quot;https://img12.360buyimg.com/n2/s345x345_jfs/t1/295718/40/10820/34240/684fdc72F64661a9e/48547ee486c009e2.png&quot;, &quot;price&quot;: &quot;2389.00&quot;, &quot;sales&quot;: 10000, &quot;num_iid&quot;: &quot;10139617789123&quot;, &quot;seller&quot;: &quot;Ultrabook商务本小店&quot;, &quot;shop_id&quot;: &quot;17195504&quot;, &quot;detail_url&quot;: &quot;https://item.jd.com/10139617789123.html&quot;, &quot;reviews&quot;: 5000 } ] }, &quot;error_code&quot;: &quot;0000&quot;, &quot;reason&quot;: &quot;ok&quot;, &quot;secache&quot;: &quot;b4f7b872ac8d3641d8acd4486d687696&quot;,</code></pre><h2>限流暴击:5 次请求就被封,大促前断了竞品监控</h2><p>京东关键字接口的限流规则,是我见过最严格的 ——<strong>免费开发者仅 5 次 / 分钟请求限额</strong>,比评论接口(10 次 / 分钟)还严,超过后不仅返回<code>429 Too Many Requests</code>,还会直接封禁 IP 24 小时,连沙箱环境都不例外。</p><p>有次 618 大促前,我帮老板监控 10 个竞品 SKU 的价格,每 10 分钟搜索一次,1 小时内发了 6 次请求,结果 IP 被封,竞品监控直接中断。老板追责时,我才意识到限流的严重性 —— 免费版完全撑不起批量搜索,而企业版要 5000 元 / 月,短期内只能靠限流算法硬扛。</p><p>我用 “滑动窗口 + 指数退避” 写了限流类,严格控制请求频率,还加了失败重试(接口偶尔因网络延迟返回 504),从此再也没被封过:</p><p>python</p><p>运行</p><pre><code>import time from collections import deque class JD SearchLimiter: def __init__(self, max_calls=5, period=60): &quot;&quot;&quot;京东关键字接口限流:max_calls次/period秒(免费版5次/分钟)&quot;&quot;&quot; self.max_calls = max_calls self.period = period self.call_timestamps = deque() # 存储每次请求的时间戳 def can_call(self): &quot;&quot;&quot;判断是否可发起请求,可调用则记录时间戳&quot;&quot;&quot; now = time.time() # 移除周期外的请求记录 while self.call_timestamps and now - self.call_timestamps[0] &gt; self.period: self.call_timestamps.popleft() if len(self.call_timestamps) &lt; self.max_calls: self.call_timestamps.append(now) return True return False def wait_for_call(self, retry=3): &quot;&quot;&quot;等待到可请求状态,失败重试3次&quot;&quot;&quot; wait_time = 0 for _ in range(retry): if self.can_call(): return True, wait_time # 指数退避等待(1s→2s→4s),避免频繁重试触发封禁 wait_time = (2 ** _) + 0.1 time.sleep(wait_time) return False, wait_time # 示例:批量监控10个竞品的搜索结果 limiter = JD SearchLimiter(max_calls=5) keywords = [&quot;无线蓝牙耳机&quot;, &quot;机械键盘&quot;, &quot;游戏鼠标&quot;] # 要监控的关键字 for keyword in keywords: can_call, wait_time = limiter.wait_for_call() if not can_call: print(f&quot;请求频繁,{keyword}监控失败,建议升级企业版&quot;) continue if wait_time &gt; 0: print(f&quot;触发限流,等待{wait_time:.1f}秒后搜索:{keyword}&quot;) # 发起搜索请求(省略具体逻辑) print(f&quot;正在搜索关键字:{keyword},获取竞品数据...&quot;) time.sleep(1) # 模拟请求耗时</code></pre>

2026/1/4
阅读更多

从 Weex 底层工作原理说起,谈谈性能监控

<blockquote><p>从 Vue 组件库(Vue Lib)到 Weex 渲染为 iOS 原生 UIKit 元素,核心是 “Virtual DOM → 跨线程通信 → Native DOM 构建 → 布局计算 → 原生 View 渲染 → 事件反向绑定” 的完整链路。Weex 作为中间层,主要完成 7 大核心工作。</p><p>Weex 是诸多年前的产物,部分业务线用 Weex 写了部分功能模块,或者是某几个页面,或者是某个二级、三级业务 SDK 的页面。但可以确定的是:新业务的实现只会在 Native 和 Flutter 上考虑;Weex 业务代码基本上是存量的;所以像 Native 一样去全面监控性能、网络、crash、异常、白屏、页面加载耗时等维度的话,ROI 是很低的。那么就需要制定一些策略去有针对性的监控高优问题。这时候就需要摒弃程序员视角(不然会陷入啥数据都想统计,可能是洁癖、可能是追求),但从 ROI 角度出发,我们就需要切换到用户视角。</p><p>按照异常等级,可以划分为影响业务和不影响业务。什么叫“影响业务”?这是我们自己定义的标准,影响用户是否正常操作 App。比如:页面白屏(页面全部白屏、页面部分白屏)、点击某个按钮无响应,这些叫做“影响业务”,属于 Error 级别。其他的一些轻微异常,不影响用户使用 App 功能,不影响业务,属于 Warning 级别。</p><p>关于异常的一些北极星指标的定义,以及 Weex 侧的稳定性与异常监控怎么做?请带着问题看文章</p></blockquote><h2>一、核心步骤</h2><p>从 Vue Lib 到 Weex 原生 UIKit 元素的完整流程。</p><p>首先,用户的需求是 “列举 Weex 做的事情” 并 “结合最新源码标位置”,所以思考过程要先拆解流程:从 Vue 编译产物处理、JS-Native 通信、Virtual DOM 解析、DOM 构建与布局计算、渲染指令生成、原生 View 创建与更新,这几个核心步骤。每个步骤都要对应 Weex 的具体工作,还要找到最新源码中的对应类和方法。</p><p>第一步,应该是 Vue 编译产物的适配。因为 Vue 写的代码要被 Weex 识别,Weex 需要处理 Vue 的模板编译结果(比如 render 函数生成的 Virtual DOM),还要注入 Weex 的 JS Runtime API。这里要找最新源码中 JS Runtime 的初始化类,比如 WXJSContext 或 WXBridgeCore,因为 JS 环境的搭建是第一步。</p><p>第二步,JS-Native 通信的建立。Vue 的 Virtual DOM 变化需要传递给 Native,所以 Weex 的 Bridge 核心要注册通信方法(比如 callNative),这里对应 WXBridgeCore 的 invokeNative 方法,之前讨论过 bridge 队列,这里要结合最新源码中通信任务的调度。</p><p>第三步,Virtual DOM 的解析与 DOM 树构建。Weex 需要把 Vue 的 Virtual DOM(JSON 结构)解析成 Native 侧的 DOM 节点(WXDOMNode),这部分现在在 WXDOMCore 中,比如 createNode 方法,处理节点创建、属性更新,还要维护 DOM 树结构。</p><p>第四步,样式解析与 Yoga 布局计算。Vue 的样式(比如 flex)要被 Weex 解析,转换为 Yoga 的布局属性,然后在 dom 线程计算布局,这部分在 WXDOMCore 的_scheduleLayout 方法,调用 Yoga 的 calculateLayout。</p><p>第五步,渲染指令生成与传递。DOM 和布局计算完成后,要把结果封装成渲染指令(节点 ID、frame、类型等),通过主线程传递给渲染核心,这里是 WXDOMCore 的_notifyRender 方法,切换到 main queue。</p><p>第六步,原生 UIKit 元素的创建与更新。渲染核心(WXRenderCore)接收指令,创建对应 UIKit 控件(UILabel、UIImageView 等),或复用已有 View,更新 frame 和样式,这部分在 WXRenderCore 的 render 方法,还有具体的 View 工厂类(比如 WXViewFactory)。</p><p>第七步,事件绑定与反向通信。原生 UI 的事件(比如点击)要传递回 Vue,Weex 需要注册 Native 事件,通过 Bridge 回调 JS,这里对应 WXEventDispatcher 和 WXBridgeCore 的 callbackJS 方法。</p><p>具体的逻辑不做深入探讨,可以查看源码研究。</p><h2>二、Yoga</h2><h3>1. 支持"增量布局更新"</h3><p>当组件样式变化时,Yoga 仅重新计算受影响的组件树分支,而非全量重算,大幅减少 RN 应用的布局耗时和卡顿』Yoga 是如何实现仅计算受影响的组件树分支的?类似有个打标记,标记为 dirty?</p><p>Yoga 实现增量布局的核心就是 「Dirty 标记机制」+「组件树依赖传播」—— 通过标记 “受影响的节点”,并仅处理这些节点及其关联分支,避免全量重算。</p><h4>1. YogaNode 与 Dirty 状态标识</h4><p>Yoga 中每个组件对应一个 YogaNode(布局计算的最小单元),每个节点都包含 3 个关键状态标记(用于判断是否需要重算):</p><ul><li><p>dirtyFlags(核心标记):记录节点的 “脏状态类型”,主要分两类:</p><ul><li>LAYOUT_DIRTY:节点自身样式(如 width、flex)或子节点布局变化,需要重新计算自身布局;</li><li>MEASURE_DIRTY:节点的测量相关属性(如 measureFunction 自定义测量逻辑)变化,需要先重新测量尺寸,再计算布局。</li></ul></li><li>isLayoutClean:布尔值,快速判断节点是否 “干净”(无脏状态),避免重复检查 dirtyFlags;</li><li>childCount + children 指针:维护子节点列表,用于后续遍历依赖分支。</li></ul><h4>2. 脏状态触发与传播:从 “变化节点” 到 “根节点” 的冒泡</h4><p>当组件样式变化时(如 RN 中修改 style={{ flex: 2 }}),Yoga 会触发以下流程:</p><ul><li>步骤 1:标记自身为 Dirty<br>直接修改变化节点的 dirtyFlags |= LAYOUT_DIRTY(或 MEASURE_DIRTY),同时设置 isLayoutClean = false。</li><li>步骤 2:向上冒泡通知父节点<br>由于父节点的布局(如尺寸、位置)依赖子节点的布局结果(比如父节点是 flex:1,子节点尺寸变化会影响父节点的剩余空间分配),因此会递归向上遍历父节点,直到根节点,将所有 “依赖节点” 都标记为 LAYOUT_DIRTY。<br>关键优化:父节点仅标记 “需要重算”,但不会立即计算,避免中途重复触发计算。</li><li>步骤 3:跳过已标记的节点<br>若某个节点已被标记为 Dirty,后续重复触发时会直接跳过(避免重复冒泡),提升效率。</li></ul><h4>3. 布局计算阶段:只处理 Dirty 分支,跳过干净节点(DFS)</h4><p>当 Yoga 触发布局计算(如 RN 渲染帧触发、组件挂载完成)时,会从根节点开始遍历组件树,但仅处理 “Dirty 节点及其子树”:</p><ul><li>步骤 1:根节点判断状态<br>若根节点是干净的(isLayoutClean = true),直接终止计算(全量跳过);若为 Dirty,进入分支处理。</li><li>步骤 2:递归处理 Dirty 分支<br>对每个节点,先检查自身状态:</li><li>若干净:直接复用上次缓存的布局结果(x/y/width/height),不重算;</li><li><p>若 Dirty:</p><ul><li>先处理子节点:如果子节点是 Dirty,先递归计算子节点布局(保证父节点计算时依赖的子节点数据是最新的);</li><li>再计算自身布局:根据 Flex 规则(如 flexDirection、justifyContent)和子节点布局结果,计算自身的最终尺寸和位置;</li><li>清除 Dirty 标记:计算完成后,设置 dirtyFlags = 0、isLayoutClean = true,标记为干净。</li></ul></li><li>步骤 3:增量更新的核心效果<br>比如修改一个列表项的 margin,只会标记该列表项 → 父列表容器 → 根节点为 Dirty,其他列表项、页面其他组件均为干净,会直接跳过计算,仅重算 “列表项→父容器” 这一小分支。</li></ul><h3>2. Flex 布局逻辑如何到 Native 系统</h3><p>Flex 布局逻辑,或者说 DSL,是如何翻译为 iOS 的 AutoLayout 和 Android 的 LayoutParams 的?</p><p>Yoga 先将 Flex DSL 解析为统一的「布局计算结果」(节点的 x/y/width/height、间距、对齐方式等),再根据平台差异,将计算结果 “映射” 为对应平台的原生布局规则——iOS 映射为 AutoLayout 约束,Android 映射为 LayoutParams + 原生布局容器属性。</p><h4>1. 第一步:通用前置流程(跨平台统一)</h4><p>无论 iOS 还是 Android,Yoga 都会先完成以下步骤,屏蔽 Flex DSL 的解析差异:</p><ol><li>解析 Flex 样式:将上层框架的 Flex 配置(如 RN 的 StyleSheet、Weex 的模板样式)解析为 YogaNode 的属性(如 flexDirection、justifyContent、margin、padding 等);</li><li>执行布局计算:通过 Flexbox 算法(基于 Web 标准),计算出每个 YogaNode 的最终布局数据:</li><li>固定属性:width/height(含 auto/flex 计算后的具体数值)、x/y(相对父节点的坐标);</li><li>间距属性:marginLeft/Top/Right/Bottom、paddingLeft/Top/Right/Bottom;</li><li>对齐属性:alignItems、justifyContent 对应的节点相对位置关系;</li><li>输出标准化布局数据:将上述结果封装为平台无关的结构体,供后续平台映射使用。</li></ol><h4>2. 第二步:iOS 端:映射为 AutoLayout 约束(NSLayoutConstraint)</h4><p>AutoLayout 的核心是「基于约束的关系描述」(而非直接设置坐标),因此 Yoga 会将 “计算出的具体尺寸 / 位置” 转化为 UIView 的约束(NSLayoutConstraint),核心映射规则如下:一一翻译 css 规则到 iOS AutoLayout 写法:</p><table><thead><tr><th>Flex 核心属性</th><th>对应的 AutoLayout 约束逻辑</th></tr></thead><tbody><tr><td><code>width: 100</code></td><td>映射为 <code>view.widthAnchor.constraint(equalToConstant: 100)</code></td></tr><tr><td><code>height: auto</code></td><td>先通过 Yoga 计算出具体高度(如文字高度、子节点包裹高度),再映射为 <code>heightAnchor</code> 约束;若为 <code>flex:1</code>,则映射为 <code>heightAnchor.constraint(equalTo: superview.heightAnchor, multiplier: 1)</code>(占满父容器剩余高度)</td></tr><tr><td><code>marginLeft: 20</code></td><td>映射为 <code>view.leadingAnchor.constraint(equalTo: superview.leadingAnchor, constant: 20)</code></td></tr><tr><td><code>marginTop: 15</code></td><td>映射为 <code>view.topAnchor.constraint(equalTo: superview.topAnchor, constant: 15)</code></td></tr><tr><td><code>justifyContent: center</code>(父节点 flexDirection: row)</td><td>父节点约束:<code>view.centerXAnchor.constraint(equalTo: superview.centerXAnchor)</code>;若有多个子节点,通过调整子节点间的 <code>spacing</code> 约束实现均匀分布</td></tr><tr><td><code>alignItems: center</code>(父节点 flexDirection: column)</td><td>子节点约束:<code>view.centerYAnchor.constraint(equalTo: superview.centerYAnchor)</code></td></tr><tr><td><code>flex: 1</code>(子节点)</td><td>映射为 <code>view.widthAnchor.constraint(equalTo: superview.widthAnchor, multiplier: 1)</code>(横向占满)+ 父节点的 <code>distribution</code> 约束(分配剩余空间)</td></tr></tbody></table><p>补充信息:</p><ul><li>Yoga 会为每个 <code>UIView</code> 关联一个 <code>YogaNode</code>,布局计算完成后,通过 <code>YogaKit</code>(或上层框架如 RN 的原生层)自动生成约束;</li><li>支持 “约束优先级” 适配:比如 <code>flex:1</code> 对应的约束优先级会高于固定尺寸约束,确保 Flex 规则优先生效;</li><li>混合布局兼容:若原生视图已有部分 AutoLayout 约束,Yoga 会生成 “补充约束”,避免冲突(通过 <code>active</code>属性控制约束启用 / 禁用)。</li></ul><h2>三、Weex 剖析</h2><pre><code class="mermaid">sequenceDiagram participant V as Vue组件 participant J as JS Framework participant B as JS-Native Bridge participant N as Native引擎 participant P as 原生UI V-&gt;&gt;J: .vue单文件 (template/style/script) Note right of J: 编译阶段&lt;br&gt;weex-loader编译Vue组件 J-&gt;&gt;J: 生成Virtual DOM树 Note right of J: 运行阶段&lt;br&gt;JS Framework管理VNode生命周期 J-&gt;&gt;B: 通过callNative发送&lt;br&gt;渲染指令JSON Note right of B: 通信层&lt;br&gt;将JS调用转为原生模块调用 B-&gt;&gt;N: 传递渲染指令 Note right of N: 原生渲染引擎&lt;br&gt;WXRenderManager (Android)&lt;br&gt;WXComponent (iOS) N-&gt;&gt;N: 解析指令,创建/更新组件树 N-&gt;&gt;P: 调用原生API渲染&lt;br&gt;(e.g., UIView, TextView) P-&gt;&gt;P: 最终原生视图</code></pre><p>下面针对核心机制详解与源码定位</p><h3>1. 编译阶段:从 Vue 到 Virtual DOM</h3><ul><li>处理 Vue 单文件:开发者的<code>.vue</code>文件通过 Webpack 和 <code>weex-loader</code> 编译成 JavaScript Bundle。这个 Bundle 包含了渲染页面所需的所有信息</li><li>生成Virtual DOM:在JS运行时,Vue.js(或 Rax)的渲染函数会生成一棵 Virtual DOM树(VNode)。Weex 的 JS Framework 会拦截常规的 DOM 操作,将其导向 Weex 的渲染管道</li></ul><p>源码相关:编译过程主要涉及 <code>weex-loader</code> (在 <code>weex-toolkit</code> 项目中),而 JS Framework 对 VNode 的处理在 <code>js-framework</code> 目录下。重点关注 <code>src/framework.js</code> 中的 <code>Document</code> 和 <code>Element</code> 类,它们模拟了 DOM 结构</p><h3>2. 指令生成与通信</h3><ul><li><p>序列化为渲染指令(json 数据):JS-Framework 不会直接操作 Dom,而是把对 Dom 的操作,描述成对 VNode 对象的创建、更新、删除等,序列化成一种特殊的 JSON 格式的渲染指令。比如</p><pre><code class="json">{ &quot;module&quot;: &quot;dom&quot;, &quot;method&quot;: &quot;createBody&quot;, &quot;args&quot;: [{&quot;ref&quot;: &quot;1&quot;, &quot;type&quot;: &quot;div&quot;, &quot;style&quot;: {...}}] }</code></pre></li><li>JS-Native 桥接:这些指令通过 callNative 方法,从 JS 端发送到 Native 端,同时 Native 端也可以通过 callJS 方法向 JS 端发送事件(比如用户点击)</li></ul><h3>3. 原生端渲染</h3><ul><li>指令解析与组件渲染:Native 端的渲染引擎(如 Android 的 WXRenderManger 和 iOS 的 WXComponentManager)接收并解析 JS 指令。Weex 维护了一个从 JS 组件到原生 UI 组件的映射表。(例如 &lt;text&gt; 映射到 iOS 的 UILabel)</li><li>布局与样式:Weex 使用的 Flexbox 布局模型做为统一的布局方案,Native 端需要将 JS 传递的 css 样式属性,转换为原生组件能够理解的布局参数与样式属性。</li><li>多线程模型:为了保证 UI 流畅,Weex 采用了多线程模型。DOM 操作和布局计算通常在单独的 DOM 线程进行,而最终创建和更新原生视图的操作必须在 UI 主线程上进行</li></ul><h3>4. 拓展机制</h3><ul><li>模块(Module):用于暴露原生能力(如网络、存储)给前端调用,通过 callNative 触发,支持回调</li><li>组件(Component):拓展自定义 UI 组件,允许开发者创建自定义的原生 UI 组件,并在 JSX 中使用</li><li>适配器(Adapter):提供可替换的实现,如图片下载器</li></ul><h2>四、为什么自定义 Component 都需要继承自 WXComponent?</h2><p>比如下面的代码</p><pre><code class="objective-c">[self registerComponent:@&quot;image&quot; withClass:NSClassFromString(@&quot;WXImageComponent&quot;) withProperties:nil]; @interface WXImageComponent : WXComponent @end</code></pre><p>答:<strong>自定义原生组件必须继承自 WXComponent,本质是复用 Weex 封装的「JS - 原生交互、生命周期、样式布局、渲染基础」等通用能力,确保组件能接入 Weex 运行时生态</strong>。</p><p>Weex Module 与 Componet 的区别</p><table><thead><tr><th>类型</th><th>核心作用</th><th>基类</th><th>示例</th></tr></thead><tbody><tr><td>Component</td><td>原生 UI 渲染(有视图)</td><td><code>WXComponent</code></td><td><code>WXImageComponent</code>(图片)、<code>WXTextComponent</code>(文本)、自定义按钮组件</td></tr><tr><td>Module</td><td>功能扩展(无视图)</td><td><code>WXModule</code></td><td><code>WXNavigatorModule</code>(导航)、<code>WXStorageModule</code>(存储)、自定义工具模块</td></tr></tbody></table><p>实现 JS 与原生组件的「数据同步」(属性、事件、方法)</p><p>Weex 的核心是「JS 控制原生组件」,而 <code>WXComponent</code> 封装了 JS 与原生之间的通信协议,无需自定义组件手动处理:</p><ul><li><p>属性同步(Props):JS 端通过 <code>&lt;my-component prop1=&quot;xxx&quot; prop2=&quot;yyy&quot;&gt;</code> 传递的属性,WXComponent 会自动解析、类型转换(如 JS 字符串 → 原生 NSString/NSNumber),并通过 <code>setter</code> 方法同步到自定义组件。</p><p>示例:WXImageComponent 继承 <code>WXComponent</code> 后,只需重写 <code>-setSrc:(NSString*)src</code> 方法,就能接收 JS 传的 <code>src</code> 属性,无需关心「JS 如何把值传给原生」。</p></li><li><p>事件分发(Events):原生组件的交互事件(如点击、加载完成),<code>WXComponent</code> 会按照 Weex 协议回传给 JS 端(如 <code>@emit('click')</code> )</p><p>示例:自定义按钮组件继承后,只需调用 <code>[self fireEvent:@&quot;click&quot; params:@{@&quot;x&quot;: @100, @&quot;y&quot;: @200}]</code> ,JS 端就能通过 <code>@onclick</code>接收事件,无需自己实现事件通信。</p></li><li><p>方法调用(Methods):JS 端通过 <code>this.$refs.myComponent.callMethod('xxx', params)</code> 调用原生组件方法,<code>WXComponent</code> </p><p>会解析方法名和参数,反射调用自定义组件的对应方法。</p><p>示例:自定义播放器组件继承后,只需暴露 <code>-play</code>方法,JS 就能直接调用,<code>WXComponent</code>负责方法查找和参数传递。</p></li></ul><h2>五、JS 数据变化是如何驱动 Native UI 更新的</h2><p>纯 Web 端的数据变化会通过 Proxy 去驱动关联的 UI 更新,这也是 Vue3 的工作原理,那么 JS 端的数据变化是如何驱动 Native UI 组件的更新的?</p><p>所有的 Native UI Component 都继承自 WXComponent,所以可以直接给 WXComponent 添加一个实现 DataBinding 的 Category,这就是 Weex 最新源码中的 <code>WXComponent+DataBinding.mm</code></p><p>核心是:<strong>解析 JS 端传递的「绑定表达式」(如 <code>{{a + b}}</code>),编译为原生可执行的回调 Block,当 JS 数据变化时,通过 Block 计算出组件所需的新值,自动更新组件的属性、样式、事件,或处理列表(<code>v-for</code>)、条件(<code>v-if</code>)、一次性绑定(<code>v-once</code>)等逻辑</strong></p><p>可能有些人要问了:为什么当 js 数据变化时,需要让 Native 计算组件所需的新值?这不就是 Native 做了一遍 Vue 响应式的逻辑吗?这种重复逻辑的价值是什么?</p><p><strong>Vue3 的 Proxy 只负责「JS 端数据变化的监听 + 依赖收集 + 触发更新通知」—— 它是 “响应式的触发器”,而非 “UI 更新的执行者”</strong></p><p>而 Weex 之所以需要 Native 托管,核心是因为「继承自 WXComponent 的 UI 组件是 Native 侧的原生组件,而非 DOM 组件」,JS 端没有任何能力(API)去访问、操作他们,Proxy 再强大,它也只是 Native 侧(Weex)和 Web 端(Vue)负责“喊一声,哎,数据变了,你们谁需要的自助,自己去处理感兴趣的 UI”,却摸不到 UI 组件,Web 端由 DOM API 去渲染绘制,Native 端更触碰不到,必须由 Native 自己来完成:听到通知 -&gt; 计算新值 -&gt; 更新控件的流程。</p><h3>1. Proxy 都做了些什么?</h3><p>Vue3 的核心实现里 Proxy 做了3件事:全程在 JS 侧,不涉及任何 UI 操作</p><p>监听数据操作:通过 Proxy 代理对象拦截数据的 getter、setter</p><ul><li>通过 getter 收集依赖关系:当组件渲染时触发 getter,Proxy 会记录这个组件依赖了这个数据</li><li>通过 setter 触发更新通知:当数据被修改时触发 setter,Proxy 会告诉 Vue 运行时,“user.name” 变了,所有依赖它的组件该更新了</li></ul><p>Proxy(代理)是 ES6 新增的内置对象,用于<strong>创建一个对象的代理副本</strong>,并通过「陷阱(Trap)」拦截对原对象的基本操作(如属性访问、赋值、删除等),从而自定义这些操作的行为。</p><pre><code class="js">const proxy = new Proxy(target, handler);</code></pre><ul><li><code>target</code>:被代理的<strong>原始对象</strong>(可以是对象、数组,甚至函数);</li><li><code>handler</code>:配置对象,包含多个「陷阱方法」(如 <code>get</code>、<code>set</code>),用于定义拦截逻辑;</li><li><code>proxy</code>:代理对象,后续对原始对象的操作需通过代理对象进行,才能触发拦截。</li></ul><table><thead><tr><th>陷阱方法</th><th>作用</th><th>触发场景</th></tr></thead><tbody><tr><td><code>get(target, key, receiver)</code></td><td>拦截「属性访问」</td><td><code>proxy.key</code> 或 <code>proxy[key]</code></td></tr><tr><td><code>set(target, key, value, receiver)</code></td><td>拦截「属性赋值」</td><td><code>proxy.key = value</code> 或 <code>proxy[key] = value</code></td></tr><tr><td><code>deleteProperty(target, key)</code></td><td>拦截「属性删除」</td><td><code>delete proxy.key</code></td></tr><tr><td><code>has(target, key)</code></td><td>拦截「<code>in</code> 运算符判断」</td><td><code>key in proxy</code></td></tr></tbody></table><p>Tips: Proxy 代理的是「整个对象」,而非单个属性,且拦截的是「操作行为」(如 “访问属性” 这个动作),而非属性本身。</p><p>Vue 核心流程:<strong>创建代理 → 依赖收集 → 数据修改 → 触发更新</strong>。</p><h4>1. 创建代理(reactive 函数的核心)</h4><p><code>reactive</code> 函数接收一个原始对象,返回其 Proxy 代理对象,同时配置 <code>get</code>、<code>set</code> 等陷阱方法,为后续依赖收集和更新做准备</p><pre><code class="javascript">function reactive(target) { return new Proxy(target, { // 拦截属性访问 get(target, key, receiver) { // 1. 先获取原始属性值 const value = Reflect.get(target, key, receiver); // 2. 收集依赖(关键:记录“谁在访问这个属性”) track(target, key); // 3. 若访问的是嵌套对象,递归创建代理(懒代理,优化性能) if (typeof value === 'object' &amp;&amp; value !== null) { return reactive(value); } return value; }, // 拦截属性赋值 set(target, key, value, receiver) { // 1. 先设置原始属性值 const oldValue = Reflect.get(target, key, receiver); const success = Reflect.set(target, key, value, receiver); // 2. 若值发生变化,触发依赖更新 if (success &amp;&amp; oldValue !== value) { trigger(target, key); } return success; }, // 拦截属性删除 deleteProperty(target, key) { const success = Reflect.deleteProperty(target, key); if (success) { trigger(target, key); // 删除属性也触发更新 } return success; } }); }</code></pre><ul><li>用 <code>Reflect</code> 操作原始对象,Reflect 是 ES6 新增的内置对象,提供了与 Proxy 陷阱对应的方法,比如 <code>Relect.get</code>、<code>Reflect.set</code> 确保操作原始对象的行为一直,同时避免直接操作 target 所产生的问题</li><li>嵌套对象懒代理:Proxy 仅代理当前层级对象,当访问嵌套对象 (proxy.user.name)时,才递归对 user 对象创建代理,避免初始化时递归遍历所有属性,优化性能</li></ul><h4>2. 依赖收集</h4><p>Vue3 用「三层映射」存储依赖,确保精准定位</p><pre><code class="javascript">// WeakMap:key 是被代理的原始对象(target),value 是该对象的属性-依赖映射 const targetMap = new WeakMap(); function track(target, key) { // 1. 若没有当前目标对象的映射,创建一个(Map:key 是属性名,value 是依赖集合) if (!targetMap.has(target)) { targetMap.set(target, new Map()); } const depsMap = targetMap.get(target); // 2. 若没有当前属性的依赖集合,创建一个(Set:存储依赖函数,去重) if (!depsMap.has(key)) { depsMap.set(key, new Set()); } const deps = depsMap.get(key); // 3. 将当前活跃的依赖函数(effect)添加到集合中 if (activeEffect) { deps.add(activeEffect); } }</code></pre><p>会产生一个这样的结构</p><pre><code class="json">{ &quot;&quot; }</code></pre><h4>3. 数据修改(触发 set/deleteProperty 的陷阱)</h4><p>当通过代理对象修改属性(如 <code>proxy.name = 'newName'</code>)或删除属性(如 <code>delete proxy.age</code>)时,会触发对应的 Proxy 陷阱(<code>set</code> 或 <code>deleteProperty</code>)。</p><p>陷阱函数会先更新原始对象的属性值,再判断值是否真的发生变化(避免无效更新)</p><h4>4. 触发更新 (tigger 函数)</h4><pre><code class="javascript">function trigger(target, key) { // 1. 从 targetMap 中获取当前对象的属性-依赖映射 const depsMap = targetMap.get(target); if (!depsMap) return; // 2. 获取当前属性的所有依赖 const deps = depsMap.get(key); if (!deps) return; // 3. 执行所有依赖函数(触发更新) deps.forEach(effect =&gt; effect()); }</code></pre><h3>2. Proxy 不做的事情</h3><ul><li>不计算表达式(比如 user.name + "后缀"的结果,Proxy 不管)</li><li>不操作 UI(不管是 DOM 和 Native 控件,Proxy 都不碰)</li><li>不跨端通信</li></ul><p>为什么 Native 组件不能让 Proxy “解决”?</p><p>核心矛盾:渲染载体不同。Proxy 之所以在 Web 端能 “间接驱动 UI”,是因为 Web 端有个「中间桥梁」—— DOM,且 JS 端有完整的 DOM API(比如 <code>document.getElementById</code>、<code>element.style.setProperty</code>):</p><p>Web 端完整链路:Proxy 触发更新 → Vue 运行时计算表达式 → 虚拟 DOM diff → 调用 DOM API 操作 DOM → UI 更新</p><ul><li><strong>JS 端没有操作 Native 控件的 API</strong>:浏览器给 JS 暴露了 DOM API,但 iOS/Android 系统不会给 JS 引擎暴露 “修改 <code>UILabel</code> 文本”“设置 <code>UIImageView</code> 图片” 的 API —— JS 端连 Native 控件的 “引用” 都拿不到,更别说更新了;</li><li><strong>Native 控件不在 JS 运行时的内存空间</strong>:JS 引擎(如 V8、JSC)和 Native 应用是两个独立的 “进程 / 虚拟机”,内存不共享 —— Proxy 所在的 JS 内存里,根本没有 Native 控件的实例,想操作都无从下手</li></ul><p>Weex 的设计优雅之处在于:Native 托管“执行层”,Proxy 保留“触发层”。响应式工作继续复用现有逻辑,由 Proxy 完成,最后的执行层由 Native 实现,也就是 WXComponent+DataBinding</p><ul><li><strong>响应式系统(Proxy)的核心是 “发现变化”</strong>:不管是 Web 还是 Weex,Proxy 都只干这件事;</li><li><strong>UI 更新的核心是 “操作渲染载体”</strong>:Web 端操作 DOM(JS 端能做),Weex 端操作 Native 控件(只能 Native 端做);</li><li><strong>WXComponent+DataBinding 的角色是 “Native 端的 UI 执行器”</strong>:它不是替代 Proxy,而是 Proxy 触发更新后,负责把 “更新通知” 落地到 Native 控件上的唯一途径</li></ul><h2>六、Weex 自定义组件是如何工作的</h2><p>上面分析了自定义组件的数据变化和表达式运算是 Native 负责的,执行层也就是 <code>WXComponent+DataBinding.mm</code> 这个类。</p><p>一言以蔽之就是:把 JS 端传递的“原始数据”,通过预编译的绑定规则(Block)计算出 Native 组件需要的最终值,并自动更新 UI 组件,同时适配长列表组件等复杂场景的 UI 优化。</p><p>该分类为所有继承自 WXComponent 的组件,注入“数据绑定能力”,无需手动实现。</p><h3>1. 绑定规则的“编译存储”,把 JS 表达式转换为 Native 可执行的 block</h3><p>数据绑定的「前置准备」:在组件初始化时,解析 JS 端传递的绑定规则(如 <code>[[user.name]]</code>、<code>[[repeat]]</code>),编译为 Native 可执行的 <code>WXDataBindingBlock</code>(代码块),并存储到组件的绑定映射表中(<code>_bindingProps/_bindingStyles/_bindingEvents</code> 等)</p><pre><code class="objective-c">- (void)_storeBindingsWithProps:(NSDictionary *)props styles:(NSDictionary *)styles attributes:(NSDictionary *)attributes events:(NSDictionary *)events;</code></pre><p>接收组件的 props/attrbutes/styles/events 中的绑定规则,解析并存储为可执行的 block。</p><ol><li><strong>识别绑定表达式</strong>:判断是否包含 <code>WXBindingIdentify</code>(<code>@&quot;@binding&quot;</code>)标记,比如 <code>{&quot;src&quot;: {&quot;@binding&quot;: &quot;user.name&quot;}}</code>;</li><li><strong>AST 解析</strong>:通过 <code>WXJSASTParser</code> 把绑定表达式字符串(如 <code>&quot;user.name + '后缀'&quot;</code>)解析为 AST 节点(<code>WXJSExpression</code>);</li><li><strong>生成执行 Block</strong>:调用 <code>-bindingBlockWithExpression:</code> 把 AST 节点转成 <code>WXDataBindingBlock</code>(后续数据变化时直接执行该 Block 计算结果);</li><li><p>分类存储:按绑定类型(属性 / 样式 / 事件 / 特殊绑定)存入对应的映射表:</p><ul><li><code>_bindingProps</code>:属性绑定(如 <code>src</code>);</li><li><code>_bindingStyles</code>:样式绑定(如 <code>fontSize</code>);</li><li><code>_bindingEvents</code>:事件绑定(如 <code>onClick</code> 参数);</li><li>特殊绑定:<code>_bindingRepeat</code>(<code>[[repeat]]</code> 对应 <code>v-for</code>)、<code>_bindingMatch</code>(<code>[[match]]</code> 对应 <code>v-if</code>)、<code>_dataBindOnce</code>(<code>[[once]]</code> 对应 <code>v-once</code>)。</li></ul></li></ol><h3>2. WXComponentManager 都做了什么</h3><p><code>WXComponentManager</code> 是 Weex iOS 端的 <strong>组件全生命周期与任务调度核心</strong>,所有与 Native 组件相关的操作(创建、更新、布局、销毁、事件绑定)都由它统一管理,同时承担「线程分工协调、UI 任务批量处理、性能监控」等关键职责,是连接 JS 指令、Native 组件、布局引擎和 UI 渲染的 “中枢大脑”。</p><h4>1. 组件线程管理</h4><p>组件业务的 “专属执行环境”,作为组件线程的「创建者和维护者」,<code>WXComponentManager</code> 确保所有组件核心操作都在<strong>全局唯一的组件线程</strong>中执行,避免线程安全问题和主线程阻塞。</p><p>核心工作:</p><ul><li>懒加载创建全局组件线程(<code>+componentThread</code>),启动 RunLoop 确保线程常驻(<code>_runLoopThread</code>)</li><li>提供线程调度接口:<code>WXPerformBlockOnComponentThread</code>(异步)、<code>WXPerformBlockSyncOnComponentThread</code>(同步),让外部模块(如 <code>WXBridgeManager</code>)能将组件任务提交到组件线程</li><li>线程断言约束:所有组件核心方法(如 <code>createBody</code>、<code>updateStyles</code>)开头都有 <code>WXAssertComponentThread</code>,强制组件操作在组件线程执</li></ul><h4>2. 组件树构建与管理:组件的 “增删改查” 全生命周期</h4><p>核心工作:</p><ul><li><p>创建组件</p><ul><li>根组件创建(<code>createBody:</code>):接收 JS 端根组件指令,创建页面根组件(如 <code>&lt;div&gt;</code> 根节点),绑定到页面根视图;</li><li>子组件创建(<code>addComponent:type:parentRef:</code>):根据 JS 端指令,创建子组件并关联父组件,存入 <code>_indexDict</code>(组件 ref → 实例映射,快速查找)。</li></ul></li><li><p>更新组件关系</p><ul><li>移动组件(<code>moveComponent:toSuper:atIndex:</code>):调整组件在组件树中的位置,同步更新视图层级;</li><li>删除组件(<code>removeComponent:</code>):从组件树和索引字典中移除组件,递归删除子组件,释放视图资源。</li></ul></li><li><p>组件查询与遍历</p><ul><li>按 ref 查找组件(<code>componentForRef:</code>):供 JS 端 <code>this.$refs</code> 访问原生组件实例;</li><li>遍历组件树(<code>enumerateComponentsUsingBlock:</code>):支持递归遍历所有组件(如性能统计、全局样式更新)</li></ul></li></ul><h4>3. 数据绑定辅助:绑定规则的提取与存储</h4><p>配合 <code>WXComponent+DataBinding</code> 模块,<code>WXComponentManager</code> 在组件创建时,从 JS 端传递的 <code>props</code>/<code>styles</code>/<code>attributes</code> 中提取「绑定表达式配置」,为响应式更新铺路。核心工作:</p><ul><li><p>提取绑定规则:</p><ul><li><code>_extractBindings:</code>:从样式 / 属性中提取 <code>[[repeat]]</code>/<code>{&quot;@binding&quot;: &quot;expr&quot;}</code> 等绑定配置,移除原始字典中的绑定字段(避免干扰普通属性处理)</li><li><code>_extractBindingEvents:</code>:从事件数组中提取绑定参数(如 <code>onClick</code> 的回调表达式);</li><li><code>_extractBindingProps:</code>:提取组件自定义 props 绑定(<code>@componentProps</code>)。</li></ul></li><li>存储绑定规则:调用组件的 <code>_storeBindingsWithProps:styles:attributes:events:</code>,将提取的绑定配置存入组件实例,后续数据变化时触发表达式计算。</li></ul><h4>4. 组件更新调度:样式 / 属性 / 事件的 “同步与执行”</h4><p>当 JS 端触发组件更新(如修改样式、属性、绑定事件)时,<code>WXComponentManager</code> 负责「跨线程调度、数据预处理、UI 同步」,确保更新流程高效且安全。</p><ul><li><p>样式更新(<code>updateStyles:forComponent:</code>)</p><ul><li>组件线程:过滤无效样式(如空值),更新组件实例的样式数据,触发布局计算;</li><li>主线程:通过 <code>_addUITask</code> 将样式更新任务(如设置 <code>CALayer.backgroundColor</code>、<code>UILabel.font</code>)批量调度到主线程执行。</li></ul></li><li><strong>属性更新(<code>updateAttributes:forComponent:</code>)</strong>:类似样式更新,组件线程处理数据逻辑,主线程更新原生组件属性(如 <code>UIImageView.image</code>、<code>UIScrollView.contentOffset</code>)。</li><li><p>事件绑定 / 解绑</p><ul><li>组件线程:维护组件的事件列表(如 <code>click</code>/<code>scroll</code>);</li><li>主线程:绑定 / 移除原生手势识别器(如 <code>UITapGestureRecognizer</code>),捕获用户交互。</li></ul></li><li><strong>批量更新优化</strong>:通过 <code>performBatchBegin</code>/<code>performBatchEnd</code> 标记批量更新范围,合并多个 UI 任务,减少主线程调度次数(提升性能)。</li></ul><h4>5. 布局调度与 UI 同步:从布局计算到 UI 渲染</h4><p>Weex 采用 Flex 布局引擎(Yoga),<code>WXComponentManager</code> 负责布局计算的触发、组件 frame 分配、UI 任务批量执行,确保组件按预期位置渲染。</p><ul><li>触发布局计算:组件更新、根视图尺寸变化(<code>rootViewFrameDidChange:</code>)时,调用 <code>_layoutAndSyncUI</code> 触发 <code>WXCoreBridge</code> 执行 Yoga 布局计算,得到所有组件的 frame。</li><li>分配组件 frame:<code>layoutComponent:frame:isRTL:innerMainSize:</code> 将计算后的 frame 分配给组件,若为根组件,同步更新页面根视图尺寸(适配 <code>wrap_content</code> 模式)。</li><li>UI 任务同步:<code>_syncUITasks</code> 批量执行 <code>_uiTaskQueue</code> 中的 UI 任务(如 <code>addSubview</code>、<code>setFrame</code>),异步调度到主线程,避免频繁主线程切换导致掉帧。</li><li>帧率同步:通过 <code>WXDisplayLinkManager</code> 监听屏幕刷新率(60fps),确保布局更新与帧率同步,提升渲染流畅度。</li></ul><h4>6. 生命周期与资源释放:页面卸载时的 “清理工作”</h4><p>当 Weex 页面销毁(<code>WXSDKInstance</code> 卸载)时,<code>WXComponentManager</code> 负责清理组件资源,避免内存泄漏。</p><p>核心工作(<code>unload</code> 方法):</p><ul><li>停止布局调度:调用 <code>_stopDisplayLink</code>,停止帧率监听和布局计算;</li><li>解绑渲染资源:遍历所有组件,解除与底层渲染对象(<code>RenderObject</code>)的绑定;</li><li>释放 UI 资源:调度到主线程,销毁所有组件的原生视图(<code>_unloadViewWithReusing:</code>);</li><li>清空状态:清空 <code>_indexDict</code>、<code>_uiTaskQueue</code>、<code>_fixedComponents</code> 等容器,解除与 <code>WXSDKInstance</code>的绑定。</li><li>清除事件绑定:清除所有的事件、手势等逻辑</li></ul><h2>七、WXModule 的注册机制及其调用流程</h2><pre><code class="mermaid">sequenceDiagram participant JS as JS环境 participant B as WXBridge participant MF as WXModuleFactory participant MM as WXModuleManager participant MI as Module实例 participant MC as 自定义Module Note over JS,MC: 注册阶段 MC-&gt;&gt;+MF: registerModule(&quot;customModule&quot;, MyModule.class) MF-&gt;&gt;MF: 生成ModuleFactory并缓存 MF-&gt;&gt;MF: 反射解析@JSMethod方法 MF-&gt;&gt;B: 将模块&amp;方法信息传递给JS Note over JS,MC: 调用阶段 JS-&gt;&gt;+B: weex.requireModule('customModule').myMethod(args) B-&gt;&gt;+MM: 调用 invokeModuleMethod MM-&gt;&gt;+MF: 获取Module实例和方法Invoker MF-&gt;&gt;MF: 查找/创建Module实例 MF-&gt;&gt;MF: 获取方法Invoker MF-&gt;&gt;MM: 返回实例和Invoker MM-&gt;&gt;+MI: 通过Invoker.invoke调用 MI-&gt;&gt;+MC: 执行原生方法实现 MC-&gt;&gt;JS: 通过callback回调JS(可选)</code></pre><h3>1. WXModule 的注册分为 Naitve 注册和 JS 注册</h3><ul><li><strong>Native 注册</strong>:在 Native 端,调用 <code>[WXSDKEngine registerModule:withClass:]</code> 方法(在 iOS 中) ,这个过程会将自定义 Module 的类和一个模块名称(例如 <code>TestModule</code>)建立映射关系,并生成一个 <code>ModuleFactory</code> 存储在一个全局的 Map(例如 <code>sModuleFactoryMap</code>)中。同时,如果该 Module 被标记为全局(global),SDK 会立即创建一个实例并缓存起来。</li><li><strong>JS 注册</strong>:Native 注册完成后,Weex 会将所有已注册 Module 的<strong>模块名称</strong>及其<strong>暴露给 JS 的方法名列表</strong>,通过 <code>WXBridge</code>(JS-Native 通信桥梁)传递给 JS 引擎。这样,JS 端就知道存在哪些模块以及每个模块有哪些方法可以调用。</li></ul><h3>2. 当 JS 调用 Module 方法时</h3><ul><li>JS 发起调用:在 JS 代码中,通过 <code>weex.requireModule('moduleName')</code> 获取模块实例 。然后吊影其方法,比如 'staream.fetch()options, callack)'</li><li>Bridge 桥接:JS 引擎通过 JSBridge 将这次调用(包括模块名、方法名、参数等信息)传递给 Native 段</li><li>Native 端查找与执行:Native 端的 WXModuleManager 根据模块名从之前注册的工厂中获取创建的 Module 实例,并根据方法名找到对应的 MethodInvoker。MethodInvoker 会通过反射手段调用具体的 Native 方法</li><li>结果回调:如果有需要,Native 可以通过 WXModuleCallBack 或者 WXModuleKeepAliveCallBack 将结果回调给 JS。WXModuleCallback 只能回调1次,而 WXModuleKeepAliveCallback 可以多次回调</li></ul><h3>3. WXModuleProtocol 的作用</h3><p><strong><code>WXModuleProtocol</code> 是一个协议,定义了 Module 的行为规范</strong>。你的自定义 Module 必须遵循此协议。它声明了 Module 需要实现的方法或属性,例如如何暴露方法给 JS(通过 <code>WX_EXPORT_METHOD</code> 宏)、方法在哪个线程执行(通过实现特定的方法返回目标线程,例如 <code>targetExecuteThread</code>)、以及如何通过 <code>weexInstance</code> 属性弱引用持有它的 WXSDKInstance 实例。<br>通过遵循 <code>WXModuleProtocol</code>,你自定义的 Module 就能被 Weex SDK 正确识别和调</p><h3>4. WXModuleFactory 的作用</h3><ol><li><strong>存储配置</strong>:在注册阶段,它会缓存 Module 的配置信息,例如模块名和对应的工厂类(<code>WXModuleConfig</code>)。</li><li><strong>方法解析</strong>:通过反射,解析 Module 类中所有通过 <code>WX_EXPORT_METHOD</code> 或 <code>WX_EXPORT_METHOD_SYNC</code> 宏暴露的方法,并生成方法名与 <code>MethodInvoker</code>(封装了反射调用逻辑)的映射关系。</li><li>提供实例:当 JS 调用 Module 方法时,<code>WXModuleManager</code> 会通过 <code>WXModuleFactory</code> 根据模块名获取或创建 Module 实例,以及对应方法的 <code>MethodInvoker</code>。</li></ol><h2>八、Weex 分为几个线程</h2><h3>1. 主线程</h3><p>核心定位:应用的 UI 线程(与原生 App 主线程同源),负责 UI 渲染、用户交互响应,<strong>禁止耗时操作</strong>。</p><p>核心职责:</p><ul><li>承载 Weex 页面的 <strong>原生渲染容器</strong>(如 Android 的 <code>WXFrameLayout</code>、iOS 的 <code>WXSDKInstanceView</code>),执行视图布局、绘制、动画触发;</li><li>处理用户交互事件(点击、滑动、输入等),并将事件转发给 JS 线程(如需要 JS 逻辑响应时);</li><li>执行原生模块的 <strong>主线程方法</strong>(通过 <code>@WXModuleAnnotation(runOnUIThread = true)</code> 标记的方法,如弹 Toast、更新 UI 的原生能力);</li><li>接收 JS 线程下发的 <strong>UI 操作指令</strong>(如创建视图、修改样式、更新属性),并映射为原生视图操作;</li></ul><p><strong>关键约束</strong>:所有直接操作原生视图的逻辑必须在主线程执行,否则会导致 UI 错乱或崩溃</p><h3>2. JS 线程</h3><p>核心定位:Weex 的 “业务逻辑线程”,独立于主线程,专门运行 JavaScript 代码,避免阻塞 UI。</p><p>核心职责:</p><ul><li>加载并执行 Weex 业务代码(<code>.we</code> 编译后的 JS bundle),包括 Vue/React 组件初始化、数据绑定、生命周期管理;</li><li>处理 JS 层面的业务逻辑(事件响应、数据计算、接口请求预处理);</li><li>调用原生模块时,通过 <strong>JSBridge 转发请求</strong>(区分同步 / 异步,同步请求会短暂阻塞 JS 线程,需谨慎使用);</li><li>生成 UI 操作指令(如 <code>createElement</code>、<code>updateStyle</code>),通过跨线程通信发送给主线程执行;</li><li>接收主线程转发的用户交互事件(如点击回调),执行对应的 JS 事件处理函数;</li></ul><p>关键优化<strong>:最新版本中,JS 线程支持 </strong>Bundle 预加载<strong>、</strong>懒加载组件**,减少启动耗时;同时通过 <code>JSContext</code>隔离多个 Weex 实例,避免线程内资源竞争。</p><h3>3. 耗时线程</h3><h4>1. 网络线程</h4><p>核心定位:Weex 框架封装的 <strong>专用网络线程</strong>(跨端统一调度),避免网络请求阻塞主线程或 JS 线程。</p><p>核心职责:</p><ul><li>处理 Weex 内置的网络请求(如 <code>weex.requireModule('stream')</code> 发起的 HTTP/HTTPS 请求);</li><li>负责 JS Bundle 的下载(首次加载或更新时),支持断点续传、缓存管理;</li><li>处理网络请求的拦截、重试、超时控制(框架层统一实现,无需业务关心);</li><li>将网络响应结果通过 JSBridge 回传给 JS 线程;</li></ul><p>设计亮点:与原生系统的网络库解耦,但对外暴露统一的 JS API,线程调度由框架内部管理,业务无需手动切换线程</p><h4>2. 图片下载线程</h4><p>核心定位:专门处理 Weex 图片的异步加载、解码,避免占用主线程资源导致 UI 卡顿。</p><p>核心职责:</p><ul><li>加载网络图片、本地图片(通过 <code>img</code> 标签或 <code>weex.requireModule('image')</code>);</li><li>图片解码、压缩(适配视图尺寸,减少内存占用);</li><li>图片缓存管理(内存缓存 + 磁盘缓存,框架层统一维护);</li><li>加载完成后,将图片 bitmap 提交到主线程渲染;</li></ul><p>iOS 侧图片加载线程的核心管理类是 <code>WXImageComponent</code>。</p><p>Weex 线程职责边界清晰:<strong>UI 操作归主线程,JS 逻辑归 JS 线程,耗时操作归工作线程 / 网络线程</strong>,避免跨线程直接操作资源</p><h2>九、JS 和 Native 通信</h2><h3>1. callJS 和 callNative</h3><table><thead><tr><th>通信方向</th><th>发起方</th><th>接收方</th><th>核心目的</th><th>典型场景</th></tr></thead><tbody><tr><td><code>callNative</code></td><td>JS</td><td>Native</td><td>JS 调用 Native 的模块 / 组件接口</td><td>渲染组件、弹 Toast、获取设备信息</td></tr><tr><td><code>callJS</code></td><td>Native</td><td>JS</td><td>Native 触发 JS 的回调函数</td><td>组件事件回调(如按钮点击)、数据同步(如网络请求结果)</td></tr></tbody></table><p>两者的底层依赖 <strong>同一个 JS Bridge 通道</strong>,只是「发起方」和「数据格式」不同,Weex 已封装好统一的通信框架,开发者无需关心底层传输细节</p><h3>2. callNative 实现</h3><p><code>callNative</code> 是 JS 主动调用 Native 接口的过程,核心流程:<strong>JS 构造标准化指令 → 序列化 JSON → 桥接通道发送 → Native 解析指令 → 执行对应接口 → 响应结果回传</strong>。</p><p>怎么样?是不是感觉似曾相识,早期做 Hybrid 的时候,JS 和 Native 的通信也是一样的流程,感兴趣的可以查看<a href="./1.44.md">这篇文章</a>。</p><p>是的,通信要解决的问题一直不变,所以方案也不变。</p><h4>1. 标准化指令格式</h4><p>为了让 Native 能统一解析,Weex 规定 <code>callNative</code> 的指令必须包含 4 个核心字段(JS 端构造):</p><pre><code class="json">const callNative指令 = { module: &quot;component&quot;, // 模块名(如 component/modal/device) method: &quot;create&quot;, // 方法名(如 create/toast/getInfo) params: {}, // 入参(如组件样式、Toast 内容) callbackId: &quot;cb_123&quot; // 回调 ID(用于 Native 回传结果) };</code></pre><ul><li><code>module</code> + <code>method</code>:定位 Native 端的具体接口(如 <code>modal.toast</code> 对应 Native 的「弹 Toast」接口);</li><li><code>params</code>:JS 传递给 Native 的数据(需是 JSON 兼容类型);</li><li><code>callbackId</code>:唯一标识当前请求,Native 执行完成后通过该 ID 找到对应的 JS 回调函数。</li></ul><h4>2. JS 端实现</h4><p>JS 侧调用 Native 的核心是3个实例方法,对应3类场景</p><table><thead><tr><th>方法名</th><th>用途</th><th>对应 Native 接口</th></tr></thead><tbody><tr><td><code>callModule</code></td><td>调用 Native 普通模块(如 <code>modal</code>/<code>storage</code>)</td><td><code>global.callNativeModule</code></td></tr><tr><td><code>callComponent</code></td><td>调用 Native 自定义组件方法</td><td><code>global.callNativeComponent</code></td></tr><tr><td><code>callDOM</code></td><td>调用 DOM 相关 Native 方法(如创建元素)</td><td><code>global.callAddElement</code> 等独立方法</td></tr></tbody></table><p>这3个方法都会通过 Native 注入的全局函数(global 上的方法)将调用传递给 Native 层</p><p>这3个方法在源码最后</p><pre><code class="javascript">// 调用 DOM 相关 Native 方法 callDOM (action, args) { return this[action](this.instanceId, args) } // 调用 Native 自定义组件方法 callComponent (ref, method, args, options) { return this.componentHandler(this.instanceId, ref, method, args, options) } // 调用 Native 普通模块方法(最常用,对应原 callNative) callModule (module, method, args, options) { return this.moduleHandler(this.instanceId, module, method, args, options) }</code></pre><h5>1. 普通模块调用 callModule → moduleHandler</h5><p><code>moduleHandler</code> 是普通模块调用的最终转发函数,源码中通过 <code>global.callNativeModule</code> 对接 Native:</p><pre><code class="javascript">proto.moduleHandler = global.callNativeModule || ((id, module, method, args) =&gt; fallback(id, [{ module, method, args }]))</code></pre><ul><li>正常情况(客户端环境):<code>global.callNativeModule</code> 是 <strong>Native 注入到 JS 全局的函数</strong>(iOS/Android 原生实现),直接接收 <code>instanceId</code>、模块名、方法名、参数,传递给 Native 层。</li><li>降级情况(无 Native 桥接):调用 <code>fallback</code> 函数(初始化时由 <code>sendTasks</code> 参数传入,通常用于调试 / 模拟)。</li></ul><h5>2. 自定义组件调用 callComponent → componentHandler</h5><p>逻辑与 <code>moduleHandler</code> 一致,对接 <code>global.callNativeComponent</code>:</p><pre><code class="javascript">proto.componentHandler = global.callNativeComponent || ((id, ref, method, args, options) =&gt; fallback(id, [{ component: options.component, ref, method, args }]))</code></pre><h5>3. DOM 方法调用 callDOM → 独立全局函数映射</h5><p>DOM 相关的 Native 方法(如 <code>addElement</code>/<code>updateStyle</code>)被单独映射到 <code>global</code> 上的独立函数(而非统一的 <code>callNative</code>),源码通过 <code>init</code> 函数初始化映射:</p><pre><code class="javascript">// 源码第 116-138 行:DOM 方法与 Native 全局函数的映射 export function init () { const DOM_METHODS = { createFinish: global.callCreateFinish, addElement: global.callAddElement, // DOM 创建元素 → Native 的 callAddElement removeElement: global.callRemoveElement, // DOM 删除元素 → Native 的 callRemoveElement updateAttrs: global.callUpdateAttrs, // 更新属性 → Native 的 callUpdateAttrs // ... 其他 DOM 方法 } const proto = TaskCenter.prototype // 给 TaskCenter 原型挂载 DOM 方法,直接调用 Native 注入的全局函数 for (const name in DOM_METHODS) { const method = DOM_METHODS[name] proto[name] = method ? (id, args) =&gt; method(id, ...args) : // 正常情况:调用 Native 全局函数 (id, args) =&gt; fallback(...) // 降级情况 } }</code></pre><p>例如调用 <code>callDOM('addElement', args)</code> 时,最终会执行 <code>global.callAddElement(instanceId, ...args)</code>,直接对接 Native 的 DOM 模块。其实是注入到 JSContext 里的方法对象。</p><p>在 Weex 的 JS 运行环境中,<code>global</code> 是 <strong>JS 全局对象(Global Object)</strong>—— 它是所有 JS 代码的 “顶层容器”,所有未被定义在局部作用域的变量、函数,最终都会挂载到 <code>global</code> 上(类似浏览器环境的 <code>window</code>,Node.js 环境的 <code>global</code>)</p><p><strong>Native 向 JS 引擎的 “全局上下文” 注入 <code>callAddElement</code> 函数时,该函数会自动成为 <code>global</code> 对象的属性</strong>——JS 侧的 <code>global.callAddElement</code>,本质就是访问这个被 Native 注入到全局的函数。</p><p>QA:global 是什么? </p><p>是 JS 全局对象。不管是浏览器、Node.js 还是 Weex 的 JS 引擎(JavaScriptCore/QuickJS),都有一个 <strong>全局对象(Global Object)</strong>:</p><ul><li>它是 JS 运行环境的 “根”,所有全局变量、函数都是它的属性;</li><li><p>不同环境的全局对象名称不同:</p><ul><li>浏览器环境:叫 <code>window</code>(比如 <code>window.alert</code>、<code>window.document</code>);</li><li>Node.js 环境:叫 <code>global</code>(比如 <code>global.console</code>、<code>global.setTimeout</code>);</li><li>Weex 环境:叫 <code>global</code>(因为 Weex 不依赖浏览器,没有 <code>window</code>,直接用 JS 引擎原生的全局对象 <code>global</code>)。</li></ul></li></ul><pre><code class="objective-c">// WXJSCoreBridge.mm - (void)registerCallAddElement:(WXJSCallAddElement)callAddElement { id callAddElementBlock = ^(JSValue *instanceId, JSValue *ref, JSValue *element, JSValue *index, JSValue *ifCallback) { NSString *instanceIdString = [instanceId toString]; WXSDKInstance *instance = [WXSDKManager instanceForID:instanceIdString]; if (instance.unicornRender) { JSValueRef args[] = {instanceId.JSValueRef, ref.JSValueRef, element.JSValueRef, index.JSValueRef}; [WXCoreBridge callUnicornRenderAction:instanceIdString module:&quot;dom&quot; method:&quot;addElement&quot; context:[JSContext currentContext] args:args argCount:4]; return [JSValue valueWithInt32:0 inContext:[JSContext currentContext]]; } NSDictionary *componentData = [element toDictionary]; NSString *parentRef = [ref toString]; NSInteger insertIndex = [[index toNumber] integerValue]; if (WXAnalyzerCenter.isInteractionLogOpen) { WXLogDebug(@&quot;wxInteractionAnalyzer : [jsengin][addElementStart],%@,%@&quot;,instanceIdString,componentData[@&quot;ref&quot;]); } return [JSValue valueWithInt32:(int32_t)callAddElement(instanceIdString, parentRef, componentData, insertIndex) inContext:[JSContext currentContext]]; }; _jsContext[@&quot;callAddElement&quot;] = callAddElementBlock; }</code></pre><p>在 js 侧是通过 TaskCenter.js 的 init 方法中定义的,存在映射关系, <code>addElement: global.callAddElement,</code></p><h3>3. callJS 实现</h3><p><code>WXReactorProtocol</code> 协议:</p><ul><li>定义 Native 调用 JS 的「标准接口」(如触发回调、发送事件),不关心底层用哪种 JS 引擎(JavaScriptCore / 其他);</li><li>具体的桥接类(如 <code>WXJSCoreBridge</code>)遵守这个协议,实现接口方法 —— 即使未来替换 JS 引擎,只要遵守协议,上层代码(如 Native 模块、组件)无需修改。</li></ul><pre><code class="objective-c"> @class JSContext; @protocol WXReactorProtocol &lt;NSObject&gt; @required /** Weex should register a JSContext to reactor */ - (void)registerJSContext:(NSString *)instanceId; /** Reactor execute js source */ - (void)render:(NSString *)instanceId source:(NSString*)source data:(NSDictionary* _Nullable)data; - (void)unregisterJSContext:(NSString *)instanceId; /** When js call Weex NativeModule, invoke callback function @param instanceId : weex instance id @param callbackId : callback function id @param args : args */ - (void)invokeCallBack:(NSString *)instanceId function:(NSString *)callbackId args:(NSArray * _Nullable)args; /** Native event to js @param instanceId : instance id @param ref : node reference @param event : event type @param args : parameters in event object @param domChanges : dom value changes, used for two-way data binding */ - (void)fireEvent:(NSString *)instanceId ref:(NSString *)ref event:(NSString *)event args:(NSDictionary * _Nullable)args domChanges:(NSDictionary * _Nullable)domChanges; @end</code></pre><p>Native 模块(Module)/组件(Component) 完成任务后 -&gt; <code>WXBridgeManager.callBack(...)</code> → 构造 JS 脚本(调用 <code>TaskCenter.callback</code>) → <code>WXJSCoreBridge.executeJavascript(...)</code> → JS 引擎执行 → <code>TaskCenter.callback</code> 响应 </p><p><code>WXJSCoreBridge</code> 本身不直接拼接回调脚本,而是提供 <code>executeJavascript:</code> 方法(源码第 102 行),作为 JS 脚本执行的底层入口;真正的脚本构造,在 <code>WXBridgeManager</code> 中</p><p>WXBridgeManager 事件回调</p><pre><code class="javascript">- (void)fireEvent:(NSString *)instanceId ref:(NSString *)ref type:(NSString *)type params:(NSDictionary *)params { [self fireEvent:instanceId ref:ref type:type params:params domChanges:nil]; } - (void)fireEvent:(NSString *)instanceId ref:(NSString *)ref type:(NSString *)type params:(NSDictionary *)params domChanges:(NSDictionary *)domChanges { [self fireEvent:instanceId ref:ref type:type params:params domChanges:domChanges handlerArguments:nil]; } - (void)fireEvent:(NSString *)instanceId ref:(NSString *)ref type:(NSString *)type params:(NSDictionary *)params domChanges:(NSDictionary *)domChanges handlerArguments:(NSArray *)handlerArguments { // ... WXCallJSMethod *method = [[WXCallJSMethod alloc] initWithModuleName:nil methodName:@&quot;fireEvent&quot; arguments:[WXUtility convertContainerToImmutable:args] instance:instance]; [self callJsMethod:method]; } - (void)callJsMethod:(WXCallJSMethod *)method { if (!method || !method.instance) return; __weak typeof(self) weakSelf = self; WXPerformBlockOnBridgeThreadForInstance(^(){ WXBridgeContext* context = method.instance.useBackupJsThread ? weakSelf.backupBridgeCtx : weakSelf.bridgeCtx; [context executeJsMethod:method]; }, method.instance.instanceId); }</code></pre><p>WXBridgeContext.m 代码如下:</p><pre><code class="javascript">- (void)executeJsMethod:(WXCallJSMethod *)method { // ... [sendQueue addObject:method]; [self performSelector:@selector(_sendQueueLoop) withObject:nil]; } - (void)_sendQueueLoop { if ([tasks count] &gt; 0 &amp;&amp; execIns) { WXSDKInstance * execInstance = [WXSDKManager instanceForID:execIns]; NSTimeInterval start = CACurrentMediaTime()*1000; if (execInstance.instanceJavaScriptContext &amp;&amp; execInstance.bundleType) { [self callJSMethod:@&quot;__WEEX_CALL_JAVASCRIPT__&quot; args:@[execIns, [tasks copy]] onContext:execInstance.instanceJavaScriptContext completion:nil]; } else { [self callJSMethod:@&quot;callJS&quot; args:@[execIns, [tasks copy]]]; } // ... } } - (void)callJSMethod:(NSString *)method args:(NSArray *)args { if (self.frameworkLoadFinished) { [self.jsBridge callJSMethod:method args:args]; } else { [_methodQueue addObject:@{@&quot;method&quot;:method, @&quot;args&quot;:args}]; } }</code></pre><p>再到 WXJSCoreManager</p><pre><code class="javascript">- (JSValue *)callJSMethod:(NSString *)method args:(NSArray *)args { WXLogDebug(@&quot;Calling JS... method:%@, args:%@&quot;, method, args); WXPerformBlockOnMainThread(^{ [[WXBridgeManager sharedManager].lastMethodInfo setObject:method ?: @&quot;&quot; forKey:@&quot;method&quot;]; [[WXBridgeManager sharedManager].lastMethodInfo setObject:args ?: @[] forKey:@&quot;args&quot;]; }); return [[_jsContext globalObject] invokeMethod:method withArguments:[args copy]]; }</code></pre><p>其实不管是 CallJS 还是 CallNative,通信的技术方案设计和 Hybrid 的设计一致,都需要在 JavascriptCore 的 global 对象上挂载一个方法。比如 Native 注册了一个 WXComponent 之后,Weex 侧用 Vue 语法写完了个页面,呈现在用户手机上,用户点击页面上的按钮之后,Native 再将事件回调给 Weex 侧,Weex 再去处理后续逻辑。</p><h3>4. WXAssertComponentThread 断言</h3><p><code>WXAssertComponentThread</code> 的核心作用是 <strong>强制约束组件相关操作在「组件专属线程」执行</strong>,本质是为了解决「线程安全」和「性能稳定性」问题 </p><p>iOS 开发的核心线程规则是「UI 操作必须在主线程」,但 Weex 组件的工作流程(绑定解析、数据计算、布局计算、子组件管理)包含大量「非 UI 操作」—— 如果这些操作都在主线程执行,会阻塞主线程(比如长列表数据解析、复杂表达式计算),导致 UI 卡顿(比如滑动掉帧)</p><p>因此 Weex 设计了线程分工</p><table><thead><tr><th>线程类型</th><th>负责的操作</th></tr></thead><tbody><tr><td>组件专属线程</td><td>绑定规则解析(<code>_storeBindings</code>)、表达式计算(<code>bindingBlockWithExpression</code>)、数据更新(<code>updateBindingData</code>)、布局计算(<code>calculateLayout</code>)</td></tr><tr><td>主线程</td><td>最终 UI 渲染(如 <code>UIImageView</code> 设图、<code>UILabel</code> 设文本)、子视图增删(<code>insertSubview</code>)</td></tr></tbody></table><h4>1. 避免「线程安全问题」,防止崩溃 / 数据错乱</h4><p>组件的核心数据(如 <code>_bindingProps</code>、<code>_subcomponents</code>、<code>_flexCssNode</code>)都是「非线程安全的」(没有加锁保护)—— 如果多个线程同时读写这些数据,会导致:</p><ul><li>数据竞争:比如主线程读取 <code>_subcomponents</code> 遍历,组件线程同时修改 <code>_subcomponents</code>(增删子组件),导致数组越界崩溃;</li><li>数据不一致:比如组件线程更新 <code>_bindingProps</code> 的值,主线程同时读取该值用于 UI 更新,导致显示错误的旧值;</li><li>野指针:比如组件线程销毁子组件,主线程还在访问该子组件的 <code>view</code>。</li></ul><p>线程断言通过「强制所有组件核心操作在同一线程执行」,从根源上避免了这些跨线程问题 —— 同一时间只有一个线程操作组件数据,无需复杂锁机制(锁会降低性能)。</p><h4>2. 简化调试,快速定位线程问题</h4><p>如果没有线程断言,跨线程操作组件可能导致「偶现崩溃」(比如 100 次操作出现 1 次),难以复现和排查(日志中看不到线程上下文)。而线程断言会在「违规线程调用时直接崩溃」,并明确提示「必须在组件线程执行」,开发者能立刻定位到违规代码(比如在主线程调用了 <code>updateBindingData</code>),大幅降低调试成本。</p><h4>3. 保证操作顺序一致性</h4><p>组件的更新流程是「解析绑定 → 计算表达式 → 更新属性 → 布局计算 → UI 渲染」—— 这些步骤必须按顺序执行。如果分散在多个线程,可能出现「布局计算还没完成,UI 已经开始渲染」的情况(导致布局错乱)。组件专属线程保证了所有操作串行执行,顺序不会乱。</p><h3>5. WXJSASTParser 的工作原理</h3><p><code>WXJSASTParser</code> 如何把表达式字符串解析为 AST 节点?</p><p><code>WXJSASTParser</code> 是 Weex 自定义的「轻量 JS 表达式解析器」—— 核心是「按 JS 语法规则,把字符串拆分为结构化的 AST 节点」,全程不依赖完整 JS 引擎(如 JSC/V8),只支持绑定表达式需要的基础语法(标识符、成员访问、二元运算等),兼顾性能和体积。</p><p>整个解析过程分 3 步:<strong>词法分析 → 语法分析 → AST 节点封装</strong>,和编译器的前端流程一致,以下结合示例(<code>&quot;user.name + '?size=100'&quot;</code>)拆解:</p><p>先明确:AST 是什么?</p><p>AST(抽象语法树)是「用树形结构表示代码语法」的中间结构 —— 比如表达式 <code>user.name + '?size=100'</code>,AST 会拆分为:</p><pre><code class="shell">根节点:BinaryExpression(运算符 '+') ├─ 左子节点:MemberExpression(成员访问) │ ├─ object:Identifier(标识符 'user') │ └─ property:Identifier(标识符 'name') └─ 右子节点:StringLiteral(字符串字面量 '?size=100')</code></pre><p>这种结构能被程序快速遍历和计算(比如之前讲的生成 <code>WXDataBindingBlock</code> 时,递归遍历节点执行运算)。</p><h4>1.词法分析(Lexical Analysis)</h4><p>拆分为词法单元(Token)。词法分析是「把表达式字符串拆分为最小的、有意义的语法单元」,忽略空格、换行等无关字符。核心是「按 JS 语法规则匹配字符序列」。</p><p><code>表达式 </code>"user.name + '?size=100'"` 词法分析后得到的 Token 序列:</p><table><thead><tr><th>Token 类型</th><th>Token 值</th><th>说明</th></tr></thead><tbody><tr><td><code>IDENTIFIER</code></td><td><code>user</code></td><td>标识符(变量名 / 属性名)</td></tr><tr><td><code>DOT</code></td><td><code>.</code></td><td>成员访问运算符</td></tr><tr><td><code>IDENTIFIER</code></td><td><code>name</code></td><td>标识符</td></tr><tr><td><code>PLUS</code></td><td><code>+</code></td><td>二元运算符(加法 / 拼接)</td></tr><tr><td><code>STRING_LITERAL</code></td><td><code>?size=100</code></td><td>字符串字面量(去掉引号)</td></tr></tbody></table><p>词法分析的实现逻辑(简化):</p><ol><li>初始化一个「字符指针」,从表达式字符串开头遍历;</li><li>遇到字母 / 下划线 → 继续往后读,直到非字母 / 数字 / 下划线 → 识别为 <code>IDENTIFIER</code>(如 <code>user</code>);</li><li>遇到 <code>+</code>/<code>-</code>/<code>*</code>/<code>/</code>/<code>&gt;</code>/<code>=</code> 等 → 识别为对应运算符(如 <code>+</code> → <code>PLUS</code>);</li><li>遇到 <code>&quot;</code> 或 <code>'</code> → 继续往后读,直到下一个相同引号 → 识别为 <code>STRING_LITERAL</code>(去掉引号);</li><li>遇到 <code>.</code> → 识别为 <code>DOT</code>(成员访问);</li><li>遇到空格 / 制表符 → 直接跳过(无意义字符);</li><li>遇到无法识别的字符(如 <code>#</code>/<code>@</code>)→ 抛出语法错误(<code>WXLogError</code>)。</li></ol><p>Weex 的 <code>WXJSASTParser</code> 内部会维护一个「Token 流」(数组),词法分析后把 Token 按顺序存入流中,供下一步语法分析使用。</p><h4>2. 语法分析(Syntactic Analysis)</h4><p>语法分析是「根据 JS 表达式语法规则,把 Token 流组合为树形 AST 节点」—— 核心是「验证 Token 序列是否符合语法,并构建层级关系」。</p><p>Weex 支持的 JS 表达式语法子集(核心):</p><ul><li>标识符:<code>user</code>、<code>imageUrl</code>(对应 <code>WXJSIdentifier</code>);</li><li>成员访问:<code>user.name</code>、<code>list[0]</code>(对应 <code>WXJSMemberExpression</code>);</li><li>字面量:字符串(<code>'abc'</code>)、数字(<code>123</code>)、布尔(<code>true</code>)、null(对应 <code>WXJSStringLiteral</code>/<code>WXJSNumericLiteral</code> 等);</li><li>二元运算:<code>a + b</code>、<code>age &gt; 18</code>、<code>a === b</code>(对应 <code>WXJSBinaryExpression</code>);</li><li>条件运算:<code>age &gt; 18 ? 'adult' : 'teen'</code>(对应 <code>WXJSConditionalExpression</code>);</li><li>数组表达式:<code>[a, b, c]</code>(对应 <code>WXJSArrayExpression</code>)。</li></ul><p>示例:Token 流 → AST 节点的构建过程</p><p>Token 流:<code>IDENTIFIER(user) → DOT → IDENTIFIER(name) → PLUS → STRING_LITERAL(?size=100)</code></p><ol><li>语法分析器先读取前 3 个 Token(<code>user</code> → <code>.</code> → <code>name</code>),匹配「成员访问语法规则」(<code>IDENTIFIER . IDENTIFIER</code>)→ 构建 <code>WXJSMemberExpression</code> 节点(左子节点 <code>user</code>,右子节点 <code>name</code>);</li><li>接着读取 <code>PLUS</code>(二元运算符),再读取后面的 <code>STRING_LITERAL(?size=100)</code> → 匹配「二元运算语法规则」(<code>Expression + Expression</code>);</li><li>把之前构建的 <code>WXJSMemberExpression</code> 作为「左子节点」,<code>STRING_LITERAL</code> 作为「右子节点」,<code>PLUS</code>作为「运算符」→ 构建根节点 <code>WXJSBinaryExpression</code>;</li><li>最终生成 AST 树(如之前的结构)。</li></ol><p>语法分析的实现逻辑(简化):</p><p>Weex 采用「递归下降分析法」(最适合手工实现的语法分析方法):</p><ol><li>为每种表达式类型定义一个「解析函数」(如 <code>parseMemberExpression</code> 解析成员访问、<code>parseBinaryExpression</code> 解析二元运算);</li><li>解析函数递归调用:比如 <code>parseBinaryExpression</code> 会调用 <code>parseMemberExpression</code> 解析左右操作数,<code>parseMemberExpression</code> 会调用 <code>parseIdentifier</code> 解析标识符;</li><li>语法校验:如果 Token 序列不符合规则(如 <code>user.name +</code> 缺少右操作数),会抛出「语法错误」日志,终止解析。</li></ol><h4>3. AST 节点封装</h4><p>转为 Weex 自定义的 <code>WXJSExpression</code>。语法分析生成的是「抽象语法树结构」,Weex 会把这个结构封装为自定义的 <code>WXJSExpression</code> 子类(对应不同表达式类型),每个子类存储该节点的关键信息(如运算符、子节点),供后续生成 <code>WXDataBindingBlock</code> 使用。</p><p>示例封装:</p><ul><li><code>WXJSMemberExpression</code> 类:存储 <code>object</code>(子节点,如 <code>user</code>)、<code>property</code>(子节点,如 <code>name</code>)、<code>computed</code>(是否是计算属性,如 <code>list[0]</code> 为 <code>YES</code>,<code>user.name</code> 为 <code>NO</code>);</li><li><code>WXJSBinaryExpression</code> 类:存储 <code>left</code>(左子节点)、<code>right</code>(右子节点)、<code>operator_</code>(运算符字符串,如 <code>&quot;+&quot;</code>);</li><li>字面量类(如 <code>WXJSStringLiteral</code>):存储 <code>value</code>(字面量值,如 <code>?size=100</code>)。</li></ul><p>这些类的定义在 Weex 源码的 <code>WXJSASTParser.h</code> 中,本质是「数据容器」,把 AST 结构转化为 Objective-C 代码可访问的对象。</p><p><code>WXJSASTParser</code> 本质:它不是完整的 JS 解析器(不支持 <code>function</code>、<code>for</code> 等复杂语法),而是「专门为 Weex 绑定表达式设计的轻量解析器」—— 只解析需要的 JS 表达式子集,把字符串转为结构化的 AST 节点,最终目的是「让 Native 代码能递归遍历节点,计算出表达式结果」(如 <code>user.name + '?size=100'</code> → <code>avatar.png?size=100</code>)。</p><p>这种「自定义轻量解析器」的设计,既避免了依赖完整 JS 引擎的体积和性能开销,又能精准适配 Weex 的绑定需求,是跨端框架的常见优化思路。</p><h2>十、值得借鉴的地方</h2><h3>1. WXThreadSafeMutableDictionary 线程安全字典</h3><p>Weex 中的 WXThreadSafeMutableDictionary 提供了一个线程安全的字典,其本质是通过加 pthread_muext_t 锁来维护内部的一个字典的。<br>比如下面的代码</p><p>初始化锁相关的配置</p><pre><code class="Objective-C">@interface WXThreadSafeMutableDictionary () { NSMutableDictionary* _dict; pthread_mutex_t _safeThreadDictionaryMutex; pthread_mutexattr_t _safeThreadDictionaryMutexAttr; } @end @implementation WXThreadSafeMutableDictionary - (instancetype)initCommon { self = [super init]; if (self) { pthread_mutexattr_init(&amp;(_safeThreadDictionaryMutexAttr)); pthread_mutexattr_settype(&amp;(_safeThreadDictionaryMutexAttr), PTHREAD_MUTEX_RECURSIVE); // must use recursive lock pthread_mutex_init(&amp;(_safeThreadDictionaryMutex), &amp;(_safeThreadDictionaryMutexAttr)); } return self; } - (instancetype)init { self = [self initCommon]; if (self) { _dict = [NSMutableDictionary dictionary]; } return self; }</code></pre><p>在字典操作的地方使用锁</p><pre><code class="Objective-C">- (void)setObject:(id)anObject forKey:(id&lt;NSCopying&gt;)aKey { id originalObject = nil; // make sure that object is not released in lock @try { pthread_mutex_lock(&amp;_safeThreadDictionaryMutex); originalObject = [_dict objectForKey:aKey]; [_dict setObject:anObject forKey:aKey]; } @finally { pthread_mutex_unlock(&amp;_safeThreadDictionaryMutex); } originalObject = nil; }</code></pre><p>这么写的价值:<strong>解锁逻辑「绝对执行」,彻底避免死锁</strong><br>这是 <code>@try-finally</code> 最核心的价值 ——无论 try 块内发生什么(正常执行、提前 return、抛异常),finally 块的解锁逻辑一定会执行</p><p>对比无 try-finally 的写法</p><pre><code class="Objective-C">// Bad: 若setObject抛异常,unlock不会执行→死锁 pthread_mutex_lock(&amp;_mutex); [_dict setObject:anObject forKey:aKey]; pthread_mutex_unlock(&amp;_mutex); </code></pre><p>问题:<code>[_dict setObject:anObject forKey:aKey]</code> 可能抛异常(比如 aKey = nil 时会触发 NSInvalidArgumentException),若没有 finally,锁会被永久持有→其他线程调用 lock 时死锁,整个字典无法再操作。</p><p>设计优点:</p><ul><li><code>@try-finallly</code>:即使 try 内逻辑出错,finally 也会执行 pthread_mutex_unlock,保证锁最终释放,这是<strong>线程安全的「兜底保障」</strong></li><li>注意,不是 <code>try...catch...finally</code>: 如果加了 catch 逻辑,则字典的 key 为 nil 产生的崩溃也会被捕获掉,这属于不符合预期的行为。因为 key 为 nil 产生的原因太多了,可能是业务代码异常,也可能是数据异常,也可能是逻辑错误,如果一刀切直接用 <code>try...catch...finally</code> 捕获了异常,但是没有配置异常的收集、上报、处理逻辑,属于边界不清晰,本质是为了解决加解锁不匹配而可能带来的线程安全问题,却"多管闲事",把字典 key 为 nil 本该向上跑的异常而卡住了(这个问题不再赘述,是一个经典的策略问题,端上的异常发生时,安全气垫的“做与不做”问题)</li></ul><p>延伸:聊聊类似网易的大白解决方案或者业界其他公司中,安全气垫虽然保证了代码不 crash,影响用户体验,但是比如数组本该越界,现在却不越界:</p><ol><li>唯一能做的就是返回一个错误的值,比如数组长度为3,访问4,现在不 crash,返回了 0 的值,那是不是产生了业务异常?比如商品价格</li><li>不 crash,也不返回错误位置的值,类似给一个回调,告诉业务方出现了异常,可以做一些业务层面的提醒或者配置(比如开发阶段商品卡片的价格 Label 显示:商品价格获取错误,数组越界),同时产生的异常案发现场信息和其他的一些数据会上报,用于 APM 平台去分析和定位。</li></ol><p>但这也产生一个问题,类似数组越界的场景,可能10000次里面9999次都正常,只有1次异常,业务开发为了这万分之一出现的异常,还需要写一些异常处理的逻辑(比如商品卡片展示价格获取错误,数组越界)。那字典的 key 为 nil 呢?除法的分母为0呢?诸如此类,类似乐观锁和悲观锁的场景</p><p>相关问题的思考可以查看这篇文章:<a href="./1.148.md">安全气垫</a></p><ul><li>WXHandlerFactory:Weex 核心的「处理器工厂」,负责管理所有协议(如图片加载、网络请求、存储等)的实现类注册 / 查找;</li><li>WXImgLoaderProtocol:Weex 定义的「图片加载协议」,仅声明接口(下载、取消、缓存等),不包含具体实现。</li></ul><p>Weex 支持业务层自定义图片加载逻辑(比如统一用项目的图片缓存库、添加下载拦截、埋点等),此时自定义实现类会替代默认实现,成为下载执行者:<br>步骤 1:业务层创建类(如 MyCustomImgLoader),遵循 WXImgLoaderProtocol,实现 wx_loadImageWithURL: 等协议方法(内部可调用 SDWebImage/AFNetworking 等完成下载);<br>步骤 2:将自定义类注册到 WXHandlerFactory:</p><pre><code class="Objective-C">[WXHandlerFactory registerHandler:[MyCustomImgLoader new] forProtocol:@protocol(WXImgLoaderProtocol)];</code></pre><p>步骤 3:此时 [WXHandlerFactory handlerForProtocol:@protocol(WXImgLoaderProtocol)] 会返回 MyCustomImgLoader 实例,所有图片下载由该类负责</p><h3>2. 设计分层合理</h3><pre><code class="mermaid">graph TD A[开发者编写的 .we/.vue 文件] --&gt; B[Transformer&lt;br/&gt;转换JS Bundle]; B --&gt; C[JS Framework&lt;br/&gt;解析并管理Virtual DOM]; C -- 通过JS Bridge发送渲染指令 --&gt; D[Native SDK&lt;br/&gt;渲染引擎]; D --&gt; E[iOS/Android/Web 原生视图]; C -- 支持多种DSL --&gt; F[Vue.js]; C -- 支持多种DSL --&gt; G[Rax(类React)]; D -- 原生能力扩展 --&gt; H[自定义Component]; D -- 原生能力扩展 --&gt; I[自定义Module];</code></pre><p>Weex 最核心的设计是将整个框架清晰地分为:<strong>语法层(DSL)</strong>、<strong>中间层(JS Framework)</strong>和<strong>渲染层(Native SDK)</strong></p><p>这种渲染引擎和语法层 DSL 分离的设计,可以使得上层 DSL 方便拓展 Vue、Rax 写法,下层渲染引擎可以保持较好的稳定性。为了生态的拓展提供了极大的便携性。</p><h3>3. 可扩展的组件与模块系统</h3><p>Weex 通过<code>WXSDKEngine.registerComponent()</code> 和 <code>registerModule()</code> 方法,允许开发者扩展原生组件 (UI Component)和模块(Login Module)。这套机制设计得足够底层和通用,使得 Weex 可以由开发者来注册,由公司内的体验设计中心规范来落地的组件。以及一些基础能力。这样子 Weex 官方已经提供了一些功能强大的筋骨,我们在其之上可以提供更符合需求的外表和更有力量的一块手臂肌肉。</p><p>虽然事后视角来看,Weex、RN、Flutter,甚至是更早的、设计完善的 Hybrid 都有该能力。但这对于远古时期的 Weex 来说,还是可圈可点的。</p><h3>4. 轻量 JSBundle + 增量更新支持</h3><p>Weex 的 JSBundle 仅包含业务逻辑和组件描述,框架代码(Vue 内核、Weex 基础 API)内置在原生 SDK 中,因此 Bundle 体积极小;同时支持将 Bundle 拆分为 “基础包(公共逻辑)+ 业务包(页面逻辑)”,实现增量更新。</p><p>解决了跨端框架 “首屏加载慢” 的痛点(小 Bundle 加载更快),同时增量更新降低了发布成本。</p><h2>十一、Weex APM</h2><h3>1. 历史背景</h3><p>Weex 是诸多年前的产物,部分业务线用 Weex 写了部分功能模块,或者是某几个页面,或者是某个二级、三级业务 SDK 的页面。但可以确定的是:</p><ul><li>21年就完成了 Flutter 的基建开发(对齐 Native 的 UI 组件库,遵循体验设计平台产出的集团 UI 标准;做了 Flutter 的大量 plugin、打包构建平台、日志库、网络库、探照灯、APM SDK、热修复能力等)。新业务的实现只会在 Native 和 Flutter 上考虑</li><li>Weex 业务代码基本上是存量的</li><li>Weex 代码没有 bug 就不去修改;有版本迭代,之前是 Weex 实现的,本次只做简单 UI 增删或字段调整,也是会修改一下。初次之外不修改 Weex 代码</li></ul><p>所以像 Native 一样去全面监控性能、网络、crash、异常、白屏、页面加载耗时等维度的话,ROI 是很低的。那么就需要制定一些策略去有针对性的监控高优问题。</p><p>Weex 的异常比较有特点,比如在页面的模版代码中绑定了 data 中的一个对象,此时对象可能并没有值,而是依赖后续的网络请求完成,对象才有了具体的值 data 改变,数据驱动,页面再次 render。所以监控代码会认为第一次 render 的时候访问对象不存在的属性。<br>真正有问题的代码和不影响业务的异常信息,都会被 Vue 官方认为是异常。基于这样的背景,我们无法 pick 出真正异常或者是开发者判空代码没写好的问题。基于此,我们需要做一些约定和标准。</p><h3>2. 优先级权衡标准</h3><p>这时候就需要摒弃程序员视角(不然会陷入啥数据都想统计,可能是洁癖、可能是追求),但从 ROI 角度出发,我们就需要切换到用户视角。</p><p>假设你是一个用户,什么样的情况代表业务异常,对我们的用户来说比较痛呢?</p><ul><li>页面白屏了,看都看不到了,别说你们的 App 为我赋能解决用户痛点了</li><li>稍微好点,可以看到页面了,但是某一个区域是白屏的。比如:该页面大部分在展示商品价格、商品数量、商品折扣价、商品折扣信息、下面应该是有个“确认支付”按钮,但是此处就是空白,点也点不了。</li><li>情况再好点。可以看到全部的页面了,但是点击后无响应。比如:该页面大部分在展示商品价格、商品数量、商品折扣价、商品折扣信息、下面有个“确认支付”按钮。用户在考虑再三,本着理性购物后,发现是刚需品,咬紧牙要付款了,此时点击“确认支付”按钮了,但是页面没有任何反应。用户也是“见多识广”的体面人,猜测可能是网络不好的情况,所以等了1分钟,他很有耐心。切换了 WI-FI 到 5G 后,继续点击,依旧没反应。一怒之下点了10次,等了2分钟,还是没反应。他奔溃了,卸载了 App</li></ul><p>上述几种情况,总结为:按照异常等级,可以划分为影响业务和不影响业务。什么叫“影响业务”?这是我们自己定义的标准,影响用户是否正常操作 App。比如:页面白屏(页面全部白屏、页面部分白屏)、点击某个按钮无响应,这些叫做“影响业务”,属于 Error 级别。其他的一些轻微异常,不影响用户使用 App 功能,不影响业务,属于 Warning 级别。</p><h3>3. UI 显示异常</h3><h4>1. 部分白屏:注册的 Component 使用异常</h4><p>这种情况就属于页面部分白屏。因为某个哪个 Compoent 会铺满页面,基本类似 iOS UI 控件一样组合使用。就像上文描述的「该页面大部分在展示商品价格、商品数量、商品折扣价、商品折扣信息、下面应该是有个“确认支付”按钮,但是此处就是空白」这个空白粗,理应显示一个 Native 注册的 Button,但是没有显示出来,造成业务的阻塞。</p><p><img width="723" height="442" src="/img/bVdnwsn" alt="" title=""><br>.vue(或 Weex 专属.we)文件内基于 Vue 扩展的 Weex 跨平台模板 DSL 代码,在前端构建阶段会先由 Webpack 的weex-loader触发编译流程:首先通过 Weex 核心编译器@weex-cli/compiler(复用并扩展vue-template-compiler)将模板 DSL 解析为模板 AST(抽象语法树);接着由 Weex 自定义 Babel 插件(如babel-plugin-transform-weex-template)将模板 AST 转换为标准化的 JS AST,并针对 iOS/Android 跨平台特性做属性、样式、事件的适配处理(如样式单位归一化、事件名标准化);最终生成包含_h(即 Weex 运行时的$createElement,等价于 Vue 的createElement)调用的render函数,该函数会被 Webpack 打包到最终的 Weex JS Bundle 中。</p><pre><code class="json">_c('color-button', { staticStyle: { width: &quot;400px&quot;, height: &quot;40px&quot;, marginBottom: &quot;20px&quot; }, attrs: { &quot;title&quot;: &quot;点击计算10+20&quot;, &quot;bgColor&quot;: &quot;#FF6600&quot;, &quot;message&quot;: &quot;hello&quot; }, on: { &quot;click&quot;: _vm.handleButtonClick } }, // 如果有 children 就是 children 信息 )</code></pre><p>在 App 运行阶段,Weex 的 JS 引擎(iOS 端为 JSCore、Android 端为 V8)加载 JS Bundle 后,执行组件的render函数,通过调用 <code>_h</code> 函数将模板描述转换为跨平台的虚拟 DOM(VNode),VNode 会被序列化为 JSON 格式,最终通过 JS Bridge 传递给 Native 端(iOS/Android)用于原生视图渲染。</p><p>Weex 的 Component 相关逻辑都由 <code>WXComponentManager</code> 负责。页面在构建展示的时候,会调用 <code>_buildComponent</code> 方法,其内部会调用 WXComponentFactory 的能力(<code>configWithComponentName</code>),根据 ComponentName 获取 Component。</p><p><code>configWithComponentName</code> 是 Weex iOS 侧 WXComponentFactory(组件工厂类)的核心方法之一,核心作用是:根据传入的组件名称(如 color-button/div/text),查找该组件对应的 Native 侧配置(WXComponentConfig);若找不到对应配置,则降级使用基础容器组件 div 的默认配置,并输出警告日志。</p><pre><code class="Objective-C">- (WXComponentConfig *)configWithComponentName:(NSString *)name { WXAssert(name, @&quot;Can not find config for a nil component name&quot;); WXComponentConfig *config = nil; [_configLock lock]; config = [_componentConfigs objectForKey:name]; if (!config) { WXLogWarning(@&quot;No component config for name:%@, use default config&quot;, name); config = [_componentConfigs objectForKey:@&quot;div&quot;]; } [_configLock unlock]; return config; }</code></pre><p>UI Component 做的比较随意,认为显示问题降级用 div 就可以了。做为 SDK 这么设计也似乎可以接受,但作为业务方,我们必须收集统计这种异常情况。<br>所以此处我们可以收集案发现场数据,进行上报。我们发现 Weex 自己封装了 <code>WXExceptionUtils</code>类,暴露了 <code>commitCriticalExceptionRT</code> 接口,用于收集致命问题。</p><pre><code class="Objective-C">+ (void)commitCriticalExceptionRT:(WXJSExceptionInfo *)jsExceptionInfo{ WXPerformBlockOnComponentThread(^ { id&lt;WXJSExceptionProtocol&gt; jsExceptionHandler = [WXHandlerFactory handlerForProtocol:@protocol(WXJSExceptionProtocol)]; if ([jsExceptionHandler respondsToSelector:@selector(onJSException:)]) { [jsExceptionHandler onJSException:jsExceptionInfo]; } if ([WXAnalyzerCenter isOpen]) { [WXAnalyzerCenter transErrorInfo:jsExceptionInfo]; } }); }</code></pre><p>可以看到会判断是否存在可以处理 exception 遵循 WXJSExceptionProtocol 的 handler。所以我们新增一个 <code>WXExceptionReporter</code> 类(遵循 WXJSExceptionProtocol 协议),用于收集异常,然后用于统一的上报,内部提供基础数据的组装、字段解析功能。</p><p>效果如下:<br><img width="723" height="444" src="/img/bVdnwso" alt="" title=""></p><h4>2. 全部白屏</h4><p>根据 Weex 的工作原理可以知道,页面需要展示肯定要根据 url 去获取 JS Bundle 内容,然后解析成 VNode 最后通过 JSBridge 去调用 Native 的 UI Component 去展示 UI,那么整个流程几个重要的环节都可能出错,导致页面白屏。</p><h5>1. 资源请求失败</h5><p>JS Bundle 资源请求失败,存在 Error,此时是无法去展示 Weex 页面的。这种情况就是 HTTP 状态码非200的情况。</p><p>每个 Weex 页面都由 WXSDKInstance 负责下载 JS Bundle 资源,所以下载的逻辑在 WXSDKInstance 里。</p><pre><code class="Objective-C">- (void)_renderWithRequest:(WXResourceRequest *)request options:(NSDictionary *)options data:(id)data; { _mainBundleLoader.onFinished = ^(WXResourceResponse *response, NSData *data) { NSError *error = nil; if ([response isKindOfClass:[NSHTTPURLResponse class]] &amp;&amp; ((NSHTTPURLResponse *)response).statusCode != 200) { error = [NSError errorWithDomain:WX_ERROR_DOMAIN code:((NSHTTPURLResponse *)response).statusCode userInfo:@{@&quot;message&quot;:@&quot;status code error.&quot;}]; if (strongSelf.onFailed) { strongSelf.onFailed(error); } } if (error) { [WXExceptionUtils commitCriticalExceptionRT:strongSelf.instanceId errCode:[NSString stringWithFormat:@&quot;%d&quot;, WX_KEY_EXCEPTION_JS_DOWNLOAD] function:@&quot;_renderWithRequest:options:data:&quot; exception:[NSString stringWithFormat:@&quot;download bundle error :%@&quot;,[error localizedDescription]] extParams:nil]; return; } if (!data) { NSString *errorMessage = [NSString stringWithFormat:@&quot;Request to %@ With no data return&quot;, request.URL]; WX_MONITOR_FAIL_ON_PAGE(WXMTJSDownload, WX_ERR_JSBUNDLE_DOWNLOAD, errorMessage, strongSelf.pageName); [WXExceptionUtils commitCriticalExceptionRT:strongSelf.instanceId errCode:[NSString stringWithFormat:@&quot;%d&quot;, WX_KEY_EXCEPTION_JS_DOWNLOAD] function:@&quot;_renderWithRequest:options:data:&quot; exception:errorMessage extParams:nil]; return; } }; }</code></pre><p>模拟 JS Bundle 下载错误,效果如下:<br><img width="723" height="444" src="/img/bVdnwsp" alt="" title=""></p><p>下载 JS Bundle 网络请求完成后,如果出现 Error,则会调用 WXExceptionUtils 的能力,将异常交给 <code>WXExceptionReporter</code> 去处理。</p><p><img width="723" height="386" src="/img/bVdnwsr" alt="" title=""></p><h5>2. 资源请求成功,数据为空</h5><p>还有一种情况就是:<strong>JSBundle 下载请求在 HTTP 层面 “成功完成”(状态码 200),但返回的二进制数据 data 为 nil 或空(长度为 0) </strong></p><p>可能你会好奇,怎么可能有空的 JSBundle,什么场景下会产生这种情况?<br>凡是正常写代码都符合预期就没有任何 bug 和故障了,所以利用悲观策略,将各种可能出现问题的地方都监控到,因为只要 JSBundle 为空,页面肯定是白屏,对于用户侧来说都是致命的。</p><ol><li>服务器/CDN 返回“空响应”:后端 / CDN 配置异常:请求的 JSBundle URL 有效,HTTP 状态码返回 200,但响应体(Body)为空(比如静态 JS 文件被删除、CDN 缓存失效且源站无数据、后端接口逻辑错误未写入响应内容);</li><li>下载过程中数据传输截断 / 丢失</li><li>网络波动:下载请求已收到服务器的 “响应完成” 信号,但数据传输过程中因网络中断、超时等导致 NSData 未完整接收(仅 HTTP 头成功接收,体数据为空);</li><li>Weex 加载器(mainBundleLoader)异常:加载器在将响应数据转为 NSData 时出现底层错误(如内存不足、数据解码失败),导致 data 被置为 nil。</li></ol><p>Mock:将 data 设为 nil。效果如下:<br><img width="723" height="444" src="/img/bVdnwss" alt="" title=""></p><p>可以看到 Weex 也会把这种错误进行收集,调用 <code>WXExceptionUtils commitCriticalExceptionRT</code>,所以我们添加的 Analyzer 是可以监控到这种异常的。<br>效果如下:<br><img width="723" height="381" src="/img/bVdnwsu" alt="" title=""></p><h5>3. 资源请求成功,数据无法解析</h5><p>还有一种特殊的情况就是:<strong>下载的 JSBundle 二进制数据虽非空,但因无法以 UTF-8 编码解码为字符串,导致 Weex 实例无法加载执行该数据,最终页面 UI 无法正常展示</strong>。比如下面的情况:<br><img width="723" height="444" src="/img/bVdnwsy" alt="" title=""></p><p>和上面的情况类似,这种都属于概率较小的问题,但也要监控和预防。</p><p>一些可能的情况:</p><ol><li>JSBundle 文件编码非 UTF-8。 <strong>Weex 要求:JS Bundle 文件必须采用 UTF-8 编码(无 BOM)以保证跨平台兼容性,非 UTF-8 编码(如 GBK、UTF-16)可能导致 iOS/Android 平台解析失败</strong></li><li><p>数据损坏/包含非法 UTF-8 字节</p><ul><li>下载截断:UTF-8 是「多字节编码」(比如中文占 3 字节),若下载过程中数据末尾的字符字节不完整(如只下了 2 字节),解码时会因 “字节序列不合法” 失败;</li><li>数据篡改:CDN / 网关 / 代理在传输中混入非 UTF-8 字节(如 0xFF、0xFE、0x00 等无效字节),破坏编码结构;</li><li>文件损坏:JSBundle 文件打包 / 上传时出错(如压缩后未正确解压),包含乱码 / 二进制碎片</li></ul></li><li><p>请求到非文本数据(URL 错误)。请求的 JS Bundle 返回的不是 JS 文本,而是二进制:</p><ul><li>URL 配置错误:指向图片(png/jpg)、压缩包(zip)、二进制协议数据(如 protobuf)、可执行文件等</li><li>后端接口错误:原本应返回 JS 文本的接口,异常时返回二进制格式的错误信息(而非文本错误)</li><li>缓存污染:Weex 本地缓存的 JSBundle 被其他二进制文件覆盖(如缓存路径冲突)</li></ul></li><li><p>特殊字符/编码溢出</p><ul><li>JSBundle 中包含 UTF-8 无法表示的「无效 Unicode 码点」(如超出 U+10FFFF 范围,或保留的未定义码点)</li><li>数据量过大:极大型 JSBundle 解码时因内存不足 / 系统限制,导致解码接口返回 nil(iOS 中 NSString 对单字符串长度有隐性限制)</li></ul></li></ol><p>这种情况,Weex 官方是怎么做的?</p><pre><code class="Objective-C">NSString *jsBundleString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; if (!jsBundleString) { WX_MONITOR_FAIL_ON_PAGE(WXMTJSDownload, WX_ERR_JSBUNDLE_STRING_CONVERT, @&quot;data converting to string failed.&quot;, strongSelf.pageName) [strongSelf.apmInstance setProperty:KEY_PROPERTIES_ERROR_CODE withValue:[@(WX_ERR_JSBUNDLE_STRING_CONVERT) stringValue]]; return; }</code></pre><p>可以看到,这种情况没有被 Weex 没有视为“致命问题”进行上报。只是进行了简单打印。尝试站在框架角度想问题,从 SDK Owner 角度归因:</p><ul><li>HTTP 状态码错误/无数据:Weex 认为这类错误是「外部不可控故障」(网络、CDN、服务端宕机),会影响大批量实例,属于 “框架级致命异常”,必须通过 WXExceptionUtils 上报(触发全局异常统计、告警)</li><li>编码转换失败:可能是分批多次打包,前几次都是 UTF-8 格式,只是这次编码错误,是可以定位的。Weex 认为这类错误是「内部可控问题」(前端打包时未按 UTF-8 规范输出、URL 配置错误指向二进制文件),属于 “业务侧错误”,框架只需记录监控(提醒开发者修复),无需升级为 “框架级致命异常”。</li></ul><p>但从业务方角度出发,不光页面是 Weex、Native、Flutter、H5,只要是影响了用户体验,都属于致命问题,尤其这种整个页面都是白屏的情况。所以我们需要修改源码,去上报致命异常。调用 <code>WXExceptionUtils commitCriticalExceptionRT</code> 的能力。</p><p>效果如下:<br><img width="723" height="401" src="/img/bVdnwsC" alt="" title=""></p><h3>4. 逻辑异常</h3><h4>1. JS 侧 require Module 失败</h4><p>在 Native <code>[WXSDKEngine registerModule:@&quot;logicCalculation&quot; withClass:[WXLogicCalculationModule class]]</code> 正常注册的 Module,名字叫 <code>logicCalculation</code>。在 js 侧使用的时候不小心写成 <code>const logicCalculation = weex.requireModule('logicCalculation1')</code>,测试又没回归到,问题逃逸到线上,可能就是逻辑问题。Weex 官方的做法就是在 Xcode 打印 log。</p><p><img width="723" height="441" src="/img/bVdnwsD" alt="" title=""></p><p>所以作为 APM 侧,我们要定位和收集到该问题,进行问题上报。</p><p>想办法知道哪里报错,requireModule 不是原生写法,这肯定是 JS 侧封装的,查看 Weex 源码<br><img width="723" height="446" src="/img/bVdnwsE" alt="" title=""></p><pre><code class="JS">// Weex JS Framework 核心源码(简化) WeexInstance.prototype.requireModule = function requireModule(moduleName) { // 1. 基础校验:Weex实例是否有效(比如是否已销毁) var id = getId(this); // 获取当前Weex实例ID if (!(id &amp;&amp; this.document &amp;&amp; this.document.taskCenter)) { console.error(&quot;[JS Framework] Failed to requireModule(\&quot;&quot; + moduleName + &quot;\&quot;), instance doesn't exist.&quot;); return; } // 2. 关键校验:检查Module是否在Native侧注册过 if (!isRegisteredModule(moduleName)) { console.warn(&quot;[JS Framework] using unregistered weex module \&quot;&quot; + moduleName + &quot;\&quot;&quot;); return; } // 3. 核心:创建Module代理对象(并非真实对象,仅封装桥接调用) var moduleProxy = {}; // 获取该Module在Native侧注册的所有方法(提前从Native同步到JS的方法映射表) var moduleMethods = getRegisteredMethods(moduleName); // 4. 为代理对象绑定方法:调用方法时触发JS-Native桥接 moduleMethods.forEach(function(methodName) { moduleProxy[methodName] = function() { // 封装调用参数:实例ID、Module名、方法名、参数、回调 var args = Array.prototype.slice.call(arguments); var callback = null; // 提取最后一个参数作为回调(Weex约定) if (typeof args[args.length - 1] === 'function') { callback = args.pop(); } // 5. 核心:通过taskCenter(桥接核心)调用Native this.document.taskCenter.sendNative('callNative', { instanceId: id, module: moduleName, method: methodName, params: args, callback: callback ? generateCallbackId(callback) : null }); }.bind(this); }, this); // 6. 返回代理对象给JS侧使用 return moduleProxy; };</code></pre><ul><li>返回的不是真实的 Module 实例,而是代理对象(Proxy) —— 所有方法调用都会被拦截,转而通过桥接发送到 Native;</li><li>isRegisteredModule 校验:JS 侧会缓存一份「Native 已注册 Module 列表」(Native 初始化时同步到 JS),避免无效桥接。</li></ul><p>方案一:Weex 由于安全设计,没办法直接注入 JS。也就是说想通过“切面”思想,hook JS 侧 requireModule 是行不通的。这种方案,代码如下</p><pre><code class="JS">// 备份原生requireModule方法 const originalRequireModule = WeexInstance.prototype.requireModule; // 重写requireModule,在错误触发时主动上报Native WeexInstance.prototype.requireModule = function (moduleName) { // 先执行原生判断逻辑 const id = getId(this); if (!(id &amp;&amp; this.document &amp;&amp; this.document.taskCenter)) { const errorMsg = &quot;[JS Framework] Failed to requireModule(\&quot;&quot; + moduleName + &quot;\&quot;), instance (&quot; + id + &quot;) doesn't exist anymore.&quot;; // 主动上报“实例不存在”错误到Native this.document.taskCenter.sendNative('__weex_apm_report', { type: 'module_require_failed', subType: 'instance_not_exist', moduleName: moduleName, message: errorMsg, instanceId: id }); console.error(errorMsg); return; } // 核心:拦截“未注册Module”判断 if (!isRegisteredModule(moduleName)) { const warnMsg = &quot;[JS Framework] using unregistered weex module \&quot;&quot; + moduleName + &quot;\&quot;&quot;; // 主动上报“Module未注册”错误到Native(关键) this.document.taskCenter.sendNative('__weex_apm_report', { type: 'module_not_registered', moduleName: moduleName, message: warnMsg, instanceId: id, timestamp: Date.now() }); // 保留原生warn日志(不影响原有逻辑) console.warn(warnMsg); return; } // 执行原生逻辑 return originalRequireModule.call(this, moduleName); };</code></pre><p>方案二:Native 侧拦截 JS 的 console.warn 调用(无 JS 侵入)</p><p>写法1:Weex JS 侧的 <code>console.warn</code> 最终会通过 WXBridgeContext 的 <code>handleJSLog</code> 方法传递到 Native,无需解析最终日志,直接 Hook 该方法拦截 warn 信息,精准匹配 Module 未注册错误</p><pre><code class="Objective-C">#import &lt;objc/runtime.h&gt; @implementation NSObject (WXJSLogHook) + (void)load { static dispatch_once_t onceToken; dispatch_once(&amp;onceToken, ^{ // 获取WXBridgeContext类(无需头文件) Class bridgeContextClass = NSClassFromString(@&quot;WXBridgeContext&quot;); if (!bridgeContextClass) return; // Hook处理JS日志的核心方法:handleJSLog: SEL handleJSLogSel = NSSelectorFromString(@&quot;handleJSLog:&quot;); Method originalMethod = class_getInstanceMethod(bridgeContextClass, handleJSLogSel); if (!originalMethod) return; SEL swizzledSel = NSSelectorFromString(@&quot;weex_apm_handleJSLog:&quot;); Method swizzledMethod = class_getInstanceMethod(self, swizzledSel); class_addMethod(bridgeContextClass, swizzledSel, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod)); method_exchangeImplementations(originalMethod, swizzledMethod); }); } // Hook后的handleJSLog方法:拦截JS侧的warn日志 - (void)weex_apm_handleJSLog:(NSDictionary *)logInfo { // 1. 先执行原方法,保留原有日志输出逻辑 [self weex_apm_handleJSLog:logInfo]; // 2. 解析JS日志信息(logInfo格式:{level: 'warn', msg: 'xxx', ...}) NSString *logLevel = logInfo[@&quot;level&quot;]; NSString *logMsg = logInfo[@&quot;msg&quot;]; // 3. 精准匹配“未注册Module”的warn if ([logLevel isEqualToString:@&quot;warn&quot;] &amp;&amp; [logMsg containsString:@&quot;using unregistered weex module&quot;]) { // 提取Module名称 NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@&quot;using unregistered weex module \&quot;(.*?)\&quot;&quot; options:0 error:nil]; NSTextCheckingResult *match = [regex firstMatchInString:logMsg options:0 range:NSMakeRange(0, logMsg.length)]; NSString *moduleName = match ? [logMsg substringWithRange:match.rangeAtIndex(1)] : @&quot;&quot;; // 4. 构造APM数据上报 NSDictionary *apmData = @{ @&quot;error_type&quot;: @&quot;weex_module_not_registered&quot;, @&quot;module_name&quot;: moduleName, @&quot;message&quot;: logMsg, @&quot;source&quot;: @&quot;js_console_warn&quot;, // 标记来源:JS console.warn @&quot;timestamp&quot;: @([[NSDate date] timeIntervalSince1970] * 1000) }; // 调用 APM SDK 接口,数据先落库,后续统一按照数据上报策略,从本地 DB 捞取、聚合、上报 // [YourAPMManager reportWeexError:apmData]; } } @end</code></pre><p>核心优势</p><ul><li>无侵入:无需修改 / 注入 JS 代码,纯 Native 侧实现;</li><li>精准:拦截的是 JS 侧传递到 Native 的原始日志数据(而非最终打印的字符串),无格式误差;</li><li>覆盖全:所有 JS 侧的console.warn都会经过此方法,100% 覆盖 Module 未注册场景</li></ul><p>写法二:由于 Weex 代码是大量的存量业务代码,很稳定。而且 Weex 官方好几年不更新,所以我们内部私有化 Weex SDK,也就没有采取 Hook 手段。而是直接修改源码,<code>WXBridgeContext.m</code> 的 <code>+ (void)handleConsoleOutputWithArgument:(NSArray *)arguments logLevel:(WXLogFlag)logLevel</code> 方法。比如:</p><pre><code class="Objective-C">+ (void)handleConsoleOutputWithArgument:(NSArray *)arguments logLevel:(WXLogFlag)logLevel { NSMutableString *string = [NSMutableString string]; [string appendString:@&quot;jsLog: &quot;]; [arguments enumerateObjectsUsingBlock:^(JSValue *jsVal, NSUInteger idx, BOOL *stop) { [string appendFormat:@&quot;%@ &quot;, jsVal]; if (idx == arguments.count - 1) { if (logLevel) { if (WXLogFlagWarning == logLevel || WXLogFlagError == logLevel) { if ([string containsString:@&quot;using unregistered weex module&quot;]) { // 提取Module名称 NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@&quot;using unregistered weex module \&quot;(.*?)\&quot;&quot; options:0 error:nil]; NSTextCheckingResult *match = [regex firstMatchInString:string options:0 range:NSMakeRange(0, string.length)]; NSString *moduleName = match ? [string substringWithRange:[match rangeAtIndex:1]] : @&quot;&quot;; // 接入收口工具类 NSString *exceptionMsg = [NSString stringWithFormat:@&quot;JS require未注册模块:%@,原始日志:%@&quot;, moduleName, string]; NSDictionary *customExt = @{@&quot;moduleName&quot;: moduleName}; NSString *instanceId = [WXSDKEngine topInstance].instanceId ?: @&quot;&quot;; NSString *bundleUrl = [WXSDKEngine topInstance].scriptURL.absoluteString ?: @&quot;&quot;; [[WXExceptionReporter sharedInstance] reportExceptionWithCode:WXCustomExceptionCode_Module_NotRegistered exceptionType:WXCustomExceptionType_Module instanceId:instanceId function:@&quot;handleConsoleOutputWithArgument:logLevel:&quot; exceptionMsg:exceptionMsg bundleUrl:bundleUrl customExtParams:customExt]; } id&lt;WXAppMonitorProtocol&gt; appMonitorHandler = [WXSDKEngine handlerForProtocol:@protocol(WXAppMonitorProtocol)]; if ([appMonitorHandler respondsToSelector:@selector(commitAppMonitorAlarm:monitorPoint:success:errorCode:errorMsg:arg:)]) { [appMonitorHandler commitAppMonitorAlarm:@&quot;weex&quot; monitorPoint:@&quot;jswarning&quot; success:NO errorCode:@&quot;99999&quot; errorMsg:string arg:[WXSDKEngine topInstance].pageName]; } } WX_LOG(logLevel, @&quot;%@&quot;, string); } else { [string appendFormat:@&quot;%@ &quot;, jsVal]; WXLogInfo(@&quot;%@&quot;, string); } } }]; }</code></pre><p><img width="723" height="402" src="/img/bVdnwsF" alt="" title=""></p><h4>2. JS 调用 Moudle 方法失败</h4><p>Native 注册了一个负责逻辑的 Module,但是在 JS 侧使用的时候,要么方法名写错了,要么参数少传了,都可能导致预期的逻辑执行错误,发生不符合预期的行为。</p><h5>1. 点击事件工作原理</h5><p>核心问题:点击事件发生时,如何根据 Component 的点击事件定位到该 Component 在 Vue DSL 中声明的事件?</p><p>第一步:页面初始化时,JS 侧构建<strong>事件映射表</strong>。<br>Weex 页面渲染时,会为每个组件做2件事情:</p><ul><li>生成组件唯一标识:每个组件都有 <code>ref/componentId/docId</code>,类似组件身份证</li><li>绑定事件与方法:解析 <code>@click=&quot;handleButtonClick&quot;</code> 时,JS 会将「组件 ID + 事件类型(click)」作为 key,<code>handleButtonClick</code> 作为 value,一起存进组件实例的映射表里,(对应下面的 <code>this.event[type]</code>)</li></ul><p>第二步:Native 侧捕获点击,携带关键信息调用 fireEvent。<br>Native 侧能拿到 componentId,是因为渲染组件时,JS 侧会把组件 ID 同步给 Native 渲染引擎(WXComponent),Native 控件和 JS 组件实例通过 ID 一一绑定</p><p>第三步:JS 侧调用 <code>fireEvent</code> 方法,其内部通过 <code>ID + 事件类型</code> 找方法。</p><ul><li>定位组件实例:JS 通过 componentID(代码里的 this.ref)找到组件实例。</li><li>查找事件映射:从组件实例的 <code>this.event</code> 里根据 type (如 click)找到具体的 eventDesc(包含具体的 handler)</li><li>发起调用 <code>handler.call</code></li></ul><pre><code class="js">/** * Fire an event manually. * @param {string} type type * @param {function} event handler * @param {boolean} isBubble whether or not event bubble * @param {boolean} options * @return {} anything returned by handler function */ Element.prototype.fireEvent = function fireEvent (type, event, isBubble, options) { var result = null; var isStopPropagation = false; var eventDesc = this.event[type]; if (eventDesc &amp;&amp; event) { var handler = eventDesc.handler; event.stopPropagation = function () { isStopPropagation = true; }; if (options &amp;&amp; options.params) { result = handler.call.apply(handler, [ this ].concat( options.params, [event] )); } else { result = handler.call(this, event); } } if (!isStopPropagation &amp;&amp; isBubble &amp;&amp; (BUBBLE_EVENTS.indexOf(type) !== -1) &amp;&amp; this.parentNode &amp;&amp; this.parentNode.fireEvent) { event.currentTarget = this.parentNode; this.parentNode.fireEvent(type, event, isBubble); // no options } return result };</code></pre><h5>2. JS 调用 module 方法,方法名错误</h5><p>Native 注册的 Module 方法名为 <code>multiply:num2:callback:</code>,而在 JS 侧调用的时候方法名多加了几个字符,造成方法名对不上,方法调用失败的问题。</p><p>用户点击屏幕上的 UI 控件(此处就是注册 Component <code>[WXSDKEngine registerComponent:@&quot;color-button&quot; withClass:[WXColorButtonComponent class]]</code>)。</p><p>Weex 统一给 Comonent 添加了分类来负责事件的处理。<code>WXComponent+Events</code>。源码中 <code>addClickEvent</code> 就是添加了点击事件的监听。当发生点击后会计算点击事件的坐标和时间戳信息,最后封装一个 <code>WXCallJSMethod</code> 对象,方法名固定为 <code>fireEvent</code>。如下堆栈所示:<br><img width="723" height="444" src="/img/bVdnwsG" alt="" title=""></p><p>由于 <code>logicCalculation</code> 没有对应的 <code>multiplyWith</code> 方法,所以会报错,被 JS 的 <code>try...catch...</code> 捕获后,通过 <code>console.error</code> 的方式输出异常信息。但是 <code>console.error</code> 被 Native 接管了。所以我们可以在 Native 接管的地方统一拦截处理。只要日志包含 <code>Failed to invoke the event handler</code> 就可以认为是因为方法名问题,导致调用方法出错</p><p>代码如下:</p><pre><code class="Objective-c">+ (void)handleConsoleOutputWithArgument:(NSArray *)arguments logLevel:(WXLogFlag)logLevel { // ... if ([string containsString:@&quot;Failed to invoke the event handler&quot;]) { // 原有解析逻辑保留 NSString *errorMethodName = @&quot;&quot;; NSString *eventType = @&quot;&quot;; NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@&quot;'\\.\\.\\.(?:logicCalculation\\.)([a-zA-Z0-9_]+)\\.\\.\\.&quot; options:0 error:nil]; NSTextCheckingResult *match = [regex firstMatchInString:string options:0 range:NSMakeRange(0, string.length)]; if (match) { errorMethodName = [string substringWithRange:[match rangeAtIndex:1]]; } NSRegularExpression *eventRegex = [NSRegularExpression regularExpressionWithPattern:@&quot;event handler of \&quot;([^\&quot;]+)\&quot;&quot; options:0 error:nil]; NSTextCheckingResult *eventMatch = [eventRegex firstMatchInString:string options:0 range:NSMakeRange(0, string.length)]; if (eventMatch) { eventType = [string substringWithRange:[eventMatch rangeAtIndex:1]]; } // 接入收口工具类 NSString *exceptionMsg = [NSString stringWithFormat:@&quot;Module方法名错误:%@,事件类型:%@,原始日志:%@&quot;, errorMethodName, eventType, string]; NSDictionary *customExt = @{ @&quot;moduleName&quot;: @&quot;logicCalculation&quot;, @&quot;methodName&quot;: errorMethodName, @&quot;eventType&quot;: eventType }; NSString *instanceId = [WXSDKEngine topInstance].instanceId ?: @&quot;&quot;; NSString *bundleUrl = [WXSDKEngine topInstance].scriptURL.absoluteString ?: @&quot;&quot;; [[WXExceptionReporter sharedInstance] reportExceptionWithCode:WXCustomExceptionCode_Module_MethodNotFound exceptionType:WXCustomExceptionType_Module instanceId:instanceId function:@&quot;handleConsoleOutputWithArgument:logLevel:&quot; exceptionMsg:exceptionMsg bundleUrl:bundleUrl customExtParams:customExt]; } // ... }</code></pre><p>效果如下<br><img width="723" height="412" src="/img/bVdnwsH" alt="" title=""></p><h5>3. JS 调用 module 方法,方法参数个数不匹配</h5><p>上面已经讲了点击事件的工作流程,调用方法时,除了调用了不存在的方法或者方法名写错了,还有一种情况就是参数个数不匹配。</p><p>这种情况如何识别并监控?<br>JS 的事件处理函数里,调用注册的 Module 和对应的方法,会统一走到 <code>WXJSCoreBridge.mm</code> 的 <code>- (void)registerCallNativeModule:(WXJSCallNativeModule)callNativeModuleBlock</code> 给当前的 JSContext 注册好的 <code>callNativeModule</code> 回调里。<code>_jsContext[@&quot;callNativeModule&quot;] = ^JSValue *(JSValue *instanceId, JSValue *moduleName, JSValue *methodName, JSValue *args, JSValue *options)</code> 可以拿到模块名、方法名、参数个数、instanceID 等。拿到实际传递的方法参数列表,再通过模块名根据 <code>ModuleFactory</code> 找到模块类对象,然后利用 runtime 能力,遍历类对象的方法列表,找到对应的 SEL,判断其预期的方法参数个数,然后再和实际传递过来的方法参数个数做比较即可</p><pre><code class="Objective-c">- (void)registerCallNativeModule:(WXJSCallNativeModule)callNativeModuleBlock { // JS 调用 Native 的方法都会走这里。可以解析到:模块名、方法名、参数数组等信息。可以在这里判断方法参数个数是否相同。 _jsContext[@&quot;callNativeModule&quot;] = ^JSValue *(JSValue *instanceId, JSValue *moduleName, JSValue *methodName, JSValue *args, JSValue *options) { // ... }; }</code></pre><p>在其 <code>callNativeModule</code> 的 block 里,增加一个方法,专门用来判断和检查方法参数个数是否匹配的问题</p><pre><code class="Objective-C">// 辅助方法:校验Module方法参数个数 - (void)checkModuleParamCount:(NSString *)moduleName methodName:(NSString *)methodName actualParams:(NSArray *)actualParams instanceId:(NSString *)instanceId { // 1. 跳过空值/系统模块(避免无意义校验) if (!moduleName || !methodName || actualParams.count &lt; 0) return; Class moduleClass = [WXModuleFactory classWithModuleName:moduleName]; if (!moduleClass) return; // 2. 拼接完整的方法选择器(Weex Module方法名带冒号,需补全,如multiply→multiply:num2:callback:) // 注:若方法名规则固定,可通过模块类的方法列表获取所有selector,匹配前缀 SEL targetSel = nil; unsigned int methodCount = 0; Method *methods = class_copyMethodList(moduleClass, &amp;methodCount); for (int i = 0; i &lt; methodCount; i++) { Method method = methods[i]; SEL sel = method_getName(method); NSString *selStr = NSStringFromSelector(sel); // 匹配前缀(如multiply开头的方法) if ([selStr hasPrefix:methodName]) { targetSel = sel; break; } } free(methods); if (!targetSel) return; // 3. 解析方法签名,计算预期参数个数(减self/_cmd) NSMethodSignature *methodSig = [moduleClass instanceMethodSignatureForSelector:targetSel]; NSInteger weexParamCount = methodSig.numberOfArguments - 2; // 4. 判断参数个数是否不匹配 if (actualParams.count != weexParamCount) { // 构造错误信息 NSString *errorMsg = [NSString stringWithFormat:@&quot;Module:%@ 方法:%@ 参数个数不匹配,预期%ld个,实际%ld个&quot;, moduleName, methodName, weexParamCount, actualParams.count]; WXLogError(@&quot;[WeexParamError] %@&quot;, errorMsg); // 5. 上报APM(核心:生产环境监控) NSDictionary *apmData = @{ @&quot;error_type&quot;: @&quot;weex_module_param_count_mismatch&quot;, @&quot;module_name&quot;: moduleName, @&quot;method_name&quot;: methodName, @&quot;expected_count&quot;: @(weexParamCount), @&quot;actual_count&quot;: @(actualParams.count), @&quot;actual_params&quot;: actualParams, @&quot;instance_id&quot;: instanceId ?: @&quot;&quot;, @&quot;timestamp&quot;: @([[NSDate date] timeIntervalSince1970] * 1000), @&quot;message&quot;: errorMsg }; // APM:异步上报,避免阻塞JS桥接 NSLog(@&quot;APM 数据上报通道,【JS 通过 Module 调用 Native 方法,参数个数不匹配】:%@&quot;, apmData); } }</code></pre><p>效果如下:<br><img width="723" height="402" src="/img/bVdnws6" alt="" title=""></p><h4>5. Vue 层面异常</h4><p>Weex 底层依靠 Vue 实现,差异化就是 VM 去通过 Bridge 在 WeexSDK Native 去做绘制。异常方面除了常规的 JS 运行时异常(如语法错误、类型错误等 7 种),Vue 框架自身的逻辑层、编译层、响应式系统、组件生命周期 等环节会抛出专属异常,这些异常必须通过 Vue.config.errorHandler 兜底。</p><p>分析 Weex 源码中:<code>packages/weex-js-framework/index.js/</code></p><pre><code class="js">function handleError (err, vm, info) { if (vm) { var cur = vm; while ((cur = cur.$parent)) { var hooks = cur.$options.errorCaptured; if (hooks) { for (var i = 0; i &lt; hooks.length; i++) { try { var capture = hooks[i].call(cur, err, vm, info) === false; if (capture) { return } } catch (e) { globalHandleError(e, cur, 'errorCaptured hook'); } } } } } globalHandleError(err, vm, info); } function globalHandleError (err, vm, info) { if (config.errorHandler) { try { return config.errorHandler.call(null, err, vm, info) } catch (e) { logError(e, null, 'config.errorHandler'); } } logError(err, vm, info); }</code></pre><p><img width="723" height="437" src="/img/bVdnws8" alt="" title=""></p><p>源码中 nextTick、Vue.prototype.$emit、callHook、Watcher.prototype.get、Watcher.prototype.run、renderRecyclableComponentTemplate、Vue.prototype._render 等等都调用了 handleError 方法。<br>Vue 内部对部分异常做了封装/拦截,避免直接冒泡到全局(防止阻断应用整体运行),但会通过 errorHandler 暴露出来。</p><p>举个例子,WeexAPM 类可以封装为:</p><pre><code class="JS">/** * APM */ class WeexAPM { /** * 获取当前的叶子节点 * @param {*} Vue vm * @returns 当前组件名称 */ formatComponentName (vm) { if (vm.$root === vm) return 'root' var name = vm._isVue ? (vm.$options &amp;&amp; vm.$options.name) || (vm.$options &amp;&amp; vm.$options._componentTag) : vm.name return ( (name ? 'component &lt;' + name + '&gt;' : 'anonymous component') + (vm._isVue &amp;&amp; vm.$options &amp;&amp; vm.$options.__file ? ' at ' + (vm.$options &amp;&amp; vm.$options.__file) : '') ) } /** * 处理Vue错误提示 */ monitor (Vue) { if (!Vue) { return } // 错误处理 Vue.config.errorHandler = (err, vm, info) =&gt; { let componentName = 'unknown' if (vm) { componentName = this.formatComponentName(vm) } let errorInfo = { name: err.name, reason: err.message, callStack: err.stack, componentName: componentName, info: info, level: 'VUE_ERROR' } try { const weexAPMUploader = weex.requireModule('weexAPMUploader') weexAPMUploader.uploadException(errorInfo) } catch (error) { console.error('APMMonitor 能力有问题,请检查是否注册了weexAPMUploader模块' + error) } } } } export default WeexAPM</code></pre><p>在捕获到 Vue 层面的异常时,可以调用注册好的 weexAPMUploader module 能力,将数据传输到 Native 侧,由 Native 侧进行统一的参数组装,最后调用 APM SDK 的能力进行数据写入数据库、按照策略上报到 APM 服务端进行消费。</p><p>模拟产生 Vue 层级的错误:给一个字符串类型的数据,在计算属性里调用 <code>toFixed</code> 方法。按钮的点击事件里将数据改为字符串,则会报错。<br>可以看到被 <code>Vue.config.errorHandler</code> 捕获了,后续交给 Native 处理即可。</p><p><img width="723" height="448" src="/img/bVdnws9" alt="" title=""></p>

2025/12/30
阅读更多

基于 Three.js 的 3D 地图可视化:核心原理与实现步骤

<p><img src="/img/remote/1460000047482676" alt="" title=""></p><h2>项目概述</h2><p>这是一个基于Three.js的3D交互式地图可视化系统,以广东省地图为展示对象,实现了丰富的3D视觉效果和交互功能。本文将对项目中的核心函数进行逐步骤、逐函数的详细分析,帮助读者深入理解系统的实现原理。</p><h2>技术栈</h2><ul><li><strong>前端框架</strong>:Vue 3</li><li><strong>3D渲染引擎</strong>:Three.js</li><li><strong>构建工具</strong>:Vite</li><li><strong>动画库</strong>:Tween.js</li><li><strong>辅助库</strong>:Delaunator、geo-point-in-polygon等地理计算库</li></ul><h2>项目初始化流程</h2><h3>1. App.vue - 主组件入口</h3><h4>onMounted - 组件挂载函数</h4><pre><code class="javascript">onMounted(async () =&gt; { // 1. 加载地图数据 let provinceData = await requestData(&quot;./data/map/广东省.json&quot;) provinceData = transfromGeoJSON(provinceData) // 2. 继承Map3d类创建当前地图实例 class CurrentMap3d extends Map3d { // ... 自定义地图方法 } // 3. 初始化地图实例 baseMap = new CurrentMap3d({ container: &quot;#app-32-map&quot;, axesVisibel: true, controls: { enableDamping: true, maxPolarAngle: (Math.PI / 2) * 0.98, }, }) // 4. 运行地图 baseMap.run() // 5. 添加窗口大小变化监听 window.addEventListener(&quot;resize&quot;, resize) })</code></pre><p><strong>作用</strong>:组件挂载时执行,完成地图的初始化、数据加载、渲染和事件监听设置。</p><p><strong>执行步骤</strong>:</p><ol><li>加载并转换广东省地图数据</li><li>定义自定义地图类继承Map3d基类</li><li>创建地图实例并配置参数</li><li>运行地图渲染循环</li><li>添加窗口大小变化监听</li></ol><h2>数据处理模块</h2><h3>1. useFileLoader.js - 文件加载钩子</h3><h4>requestData - 异步数据请求函数</h4><pre><code class="javascript">const requestData = async (url) =&gt; { try { const response = await fetch(url) const data = await response.json() return data } catch (error) { console.error('数据加载失败:', error) return null } }</code></pre><p><strong>作用</strong>:异步加载GeoJSON地图数据。</p><p><strong>参数</strong>:</p><ul><li><code>url</code>:地图数据文件路径</li></ul><p><strong>返回值</strong>:解析后的JSON数据对象</p><h3>2. useConversionStandardData.js - 数据格式转换钩子</h3><h4>transfromGeoJSON - GeoJSON数据转换函数</h4><pre><code class="javascript">const transfromGeoJSON = (worldData) =&gt; { let features = worldData.features for (let i = 0; i &lt; features.length; i++) { const element = features[i] // 将Polygon处理跟MultiPolygon一样的数据结构 if (element.geometry.type === 'Polygon') { element.geometry.coordinates = [element.geometry.coordinates] } } return worldData }</code></pre><p><strong>作用</strong>:统一GeoJSON数据格式,将Polygon类型数据转换为与MultiPolygon相同的二维数组结构。</p><p><strong>参数</strong>:</p><ul><li><code>worldData</code>:原始GeoJSON数据</li></ul><p><strong>返回值</strong>:标准化后的GeoJSON数据</p><p><strong>实现原理</strong>:遍历features数组,检测geometry.type,如果是Polygon类型,则将coordinates转换为二维数组格式,确保后续处理的一致性。</p><h3>3. useCoord.js - 坐标处理钩子</h3><h4>geoMercatorCoord - 经纬度转墨卡托坐标</h4><pre><code class="javascript">const geoMercatorCoord = (longitude, latitude) =&gt; { var E = longitude var N = latitude var x = (E * 20037508.34) / 180 var y = Math.log(Math.tan(((90 + N) * Math.PI) / 360)) / (Math.PI / 180) y = (y * 20037508.34) / 180 return { x: x, //墨卡托x坐标——对应经度 y: y, //墨卡托y坐标——对应维度 } }</code></pre><p><strong>作用</strong>:将地理经纬度坐标转换为墨卡托投影坐标。</p><p><strong>参数</strong>:</p><ul><li><code>longitude</code>:经度值</li><li><code>latitude</code>:纬度值</li></ul><p><strong>返回值</strong>:包含x、y属性的墨卡托坐标对象</p><p><strong>实现原理</strong>:使用墨卡托投影公式进行坐标转换,将经度直接线性映射,纬度通过对数函数进行非线性映射,使地图在赤道附近保持比例正确。</p><h4>geoSphereCoord - 经纬度转球面坐标</h4><pre><code class="javascript">const geoSphereCoord = (R, longitude, latitude) =&gt; { var lon = (longitude * Math.PI) / 180 //转弧度值 var lat = (latitude * Math.PI) / 180 //转弧度值 lon = -lon // three.js坐标系z坐标轴对应经度-90度,而不是90度 // 经纬度坐标转球面坐标计算公式 var x = R * Math.cos(lat) * Math.cos(lon) var y = R * Math.sin(lat) var z = R * Math.cos(lat) * Math.sin(lon) // 返回球面坐标 return { x: x, y: y, z: z, } }</code></pre><p><strong>作用</strong>:将地理经纬度坐标转换为三维球面上的坐标。</p><p><strong>参数</strong>:</p><ul><li><code>R</code>:球体半径</li><li><code>longitude</code>:经度值</li><li><code>latitude</code>:纬度值</li></ul><p><strong>返回值</strong>:包含x、y、z属性的球面坐标对象</p><p><strong>实现原理</strong>:使用球面坐标转换公式,将经纬度转换为三维空间坐标,适用于创建地球等球面模型。</p><h4>getBoundingBox - 计算模型包围盒</h4><pre><code class="javascript">const getBoundingBox = group =&gt; { // 包围盒计算模型对象的大小和位置 var box3 = new THREE.Box3() box3.expandByObject(group) // 计算模型包围盒 var size = new THREE.Vector3() box3.getSize(size) // 计算包围盒尺寸 var center = new THREE.Vector3() box3.getCenter(center) // 计算一个层级模型对应包围盒的几何体中心坐标 return { box3, center, size, } }</code></pre><p><strong>作用</strong>:计算3D模型或模型组的包围盒、尺寸和中心坐标。</p><p><strong>参数</strong>:</p><ul><li><code>group</code>:Three.js模型或模型组对象</li></ul><p><strong>返回值</strong>:包含包围盒(box3)、中心坐标(center)和尺寸(size)的对象</p><p><strong>实现原理</strong>:使用Three.js的Box3类计算模型的最小包围立方体,用于后续的相机定位和模型布局。</p><h2>3D地图建模模块</h2><h3>1. Map3d.js - 地图基类</h3><h4>constructor - 构造函数</h4><pre><code class="javascript">constructor(options = {}) { let defaultOptions = { isFull: true, container: null, width: window.innerWidth, height: window.innerHeight, bgColor: 0x000000, materialColor: 0xff0000, controls: { visibel: true, enableDamping: true, autoRotate: false, maxPolarAngle: Math.PI, }, statsVisibel: true, axesVisibel: true, axesHelperSize: 250, } this.options = deepMerge(defaultOptions, options) this.container = document.querySelector(this.options.container) this.options.width = this.container.offsetWidth this.options.height = this.container.offsetHeight this.scene = new THREE.Scene() this.camera = null this.renderer = null this.mesh = null this.animationStop = null this.controls = null this.stats = null this.init() }</code></pre><p><strong>作用</strong>:初始化地图实例,设置默认参数,创建基本的Three.js场景、相机、渲染器等对象。</p><p><strong>参数</strong>:</p><ul><li><code>options</code>:地图配置参数对象</li></ul><p><strong>执行步骤</strong>:</p><ol><li>合并默认参数和用户参数</li><li>获取容器元素并设置尺寸</li><li>初始化Three.js核心对象</li><li>调用init方法进行进一步初始化</li></ol><h4>init - 初始化函数</h4><pre><code class="javascript">init() { this.initStats() this.initCamera() this.initModel() this.initRenderer() this.initLight() this.initAxes() this.initControls() let gl = this.renderer.domElement.getContext('webgl') gl &amp;&amp; gl.getExtension('WEBGL_lose_context').loseContext() }</code></pre><p><strong>作用</strong>:统一调用各个初始化方法,完成地图的全面初始化。</p><p><strong>执行步骤</strong>:</p><ol><li>初始化性能统计</li><li>初始化相机</li><li>初始化模型(由子类实现)</li><li>初始化渲染器</li><li>初始化光源</li><li>初始化坐标轴辅助</li><li>初始化控制器</li><li>释放WebGL上下文(优化内存)</li></ol><h4>initCamera - 相机初始化</h4><pre><code class="javascript">initCamera() { let { width, height } = this.options let rate = width / height this.camera = new THREE.PerspectiveCamera(45, rate, 0.001, 90000000) this.camera.up.set(0, 0, 1) this.camera.position.set(102.97777217804006, 17.660260562607277, 8.029548316292933) this.camera.lookAt(...centerXY, 0) }</code></pre><p><strong>作用</strong>:初始化透视相机,设置相机位置、朝向和视野参数。</p><p><strong>执行步骤</strong>:</p><ol><li>计算宽高比</li><li>创建透视相机实例</li><li>设置相机上方向(Z轴向上)</li><li>设置相机位置坐标</li><li>设置相机看向地图中心点</li></ol><h4>initRenderer - 渲染器初始化</h4><pre><code class="javascript">initRenderer() { let { width, height, bgColor } = this.options let renderer = new THREE.WebGLRenderer({ antialias: true, }) renderer.setPixelRatio(window.devicePixelRatio) renderer.setSize(width, height) renderer.setClearColor(bgColor, 1) this.container.appendChild(renderer.domElement) this.renderer = renderer }</code></pre><p><strong>作用</strong>:初始化WebGL渲染器,设置渲染参数并将渲染画布添加到容器中。</p><p><strong>执行步骤</strong>:</p><ol><li>创建WebGL渲染器实例(启用抗锯齿)</li><li>设置像素比适应高DPI屏幕</li><li>设置渲染尺寸</li><li>设置背景颜色</li><li>将渲染画布添加到DOM容器</li></ol><h4>initLight - 光源初始化</h4><pre><code class="javascript">initLight() { // 平行光1 let directionalLight1 = new THREE.DirectionalLight(0x7af4ff, 1) directionalLight1.position.set(...centerXY, 30) // 平行光2 let directionalLight2 = new THREE.DirectionalLight(0x7af4ff, 1) directionalLight2.position.set(...centerXY, 30) // 环境光 let ambientLight = new THREE.AmbientLight(0x7af4ff, 1) // 将光源添加到场景中 this.addObject(directionalLight1) this.addObject(directionalLight2) this.addObject(ambientLight) }</code></pre><p><strong>作用</strong>:初始化场景光源,包括平行光和环境光,增强3D效果。</p><p><strong>执行步骤</strong>:</p><ol><li>创建两个平行光并设置位置</li><li>创建环境光</li><li>将所有光源添加到场景</li></ol><h4>initControls - 控制器初始化</h4><pre><code class="javascript">initControls() { try { let { controls: { enableDamping, autoRotate, visibel, maxPolarAngle }, } = this.options if (!visibel) return false this.controls = new OrbitControls(this.camera, this.renderer.domElement) this.controls.maxPolarAngle = maxPolarAngle this.controls.autoRotate = autoRotate this.controls.enableDamping = enableDamping } catch (error) { console.log(error) } }</code></pre><p><strong>作用</strong>:初始化轨道控制器,实现地图的交互控制。</p><p><strong>执行步骤</strong>:</p><ol><li>检查控制器是否启用</li><li>创建OrbitControls实例</li><li>设置控制器参数(最大极角、自动旋转、阻尼效果等)</li></ol><h4>loop - 渲染循环</h4><pre><code class="javascript">loop() { this.animationStop = window.requestAnimationFrame(() =&gt; { this.loop() }) this.renderer.render(this.scene, this.camera) if (this.options.controls.visibel &amp;&amp; this.controls) { this.controls.update() } if (this.options.statsVisibel) this.stats.update() if (this.rotatingApertureMesh) { this.rotatingApertureMesh.rotation.z += 0.0005 } if (this.rotatingPointMesh) { this.rotatingPointMesh.rotation.z -= 0.0005 } if (this.css2dRender) { this.css2dRender.render(this.scene, this.camera) } if (this.particleArr.length) { for (let i = 0; i &lt; this.particleArr.length; i++) { this.particleArr[i].updateSequenceFrame() this.particleArr[i].position.z += 0.01 if (this.particleArr[i].position.z &gt;= 6) { this.particleArr[i].position.z = -6 } } } TWEEN.update() }</code></pre><p><strong>作用</strong>:实现地图的持续渲染和动画效果更新。</p><p><strong>执行步骤</strong>:</p><ol><li>使用requestAnimationFrame创建渲染循环</li><li>渲染3D场景</li><li>更新控制器状态</li><li>更新性能统计</li><li>更新旋转光圈动画</li><li>更新旋转点动画</li><li>渲染2D标签</li><li>更新粒子动画</li><li>更新Tween.js动画</li></ol><h3>2. App.vue - 自定义地图模型初始化</h3><h4>initModel - 模型初始化(在CurrentMap3d类中重写)</h4><pre><code class="javascript">initModel() { try { // 创建组 this.mapGroup = new THREE.Group() // 标签初始化 this.css2dRender = initCSS2DRender(this.options, this.container) provinceData.features.forEach((elem, index) =&gt; { // 定一个省份对象 const province = new THREE.Object3D() // 坐标 const coordinates = elem.geometry.coordinates // 循环坐标 coordinates.forEach((multiPolygon) =&gt; { multiPolygon.forEach((polygon) =&gt; { const shape = new THREE.Shape() // 绘制shape for (let i = 0; i &lt; polygon.length; i++) { let [x, y] = polygon[i] if (i === 0) { shape.moveTo(x, y) } shape.lineTo(x, y) } // 拉伸设置 const extrudeSettings = { depth: 0.2, bevelEnabled: true, bevelSegments: 1, bevelThickness: 0.1, } const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings) const mesh = new THREE.Mesh(geometry, [topFaceMaterial, sideMaterial]) province.add(mesh) }) }) this.mapGroup.add(province) // 创建标点和标签 initLightPoint(properties, this.mapGroup) initLabel(properties, this.scene) }) // 创建上下边框 initBorderLine(provinceData, this.mapGroup) let earthGroupBound = getBoundingBox(this.mapGroup) centerXY = [earthGroupBound.center.x, earthGroupBound.center.y] let { size } = earthGroupBound let width = size.x &lt; size.y ? size.y + 1 : size.x + 1 // 添加背景,修饰元素 this.rotatingApertureMesh = initRotatingAperture(this.scene, width) this.rotatingPointMesh = initRotatingPoint(this.scene, width - 2) initCirclePoint(this.scene, width) initSceneBg(this.scene, width) // 将组添加到场景中 this.scene.add(this.mapGroup) this.particleArr = initParticle(this.scene, earthGroupBound) initGui() } catch (error) { console.log(error) } }</code></pre><p><strong>作用</strong>:初始化3D地图模型,包括省份几何体、材质、标签、装饰元素等。</p><p><strong>执行步骤</strong>:</p><ol><li>创建地图模型组</li><li>初始化2D标签渲染器</li><li>遍历地图数据创建省份模型</li><li>为每个省份创建3D几何体和材质</li><li>添加光柱标记和标签</li><li>创建地图边框</li><li>计算地图包围盒和中心点</li><li>添加装饰元素(旋转光圈、背景等)</li><li>将地图组添加到场景</li><li>初始化粒子系统</li><li>初始化GUI控制器</li></ol><h2>视觉效果增强模块</h2><h3>1. useMapMarkedLightPillar.js - 光柱标记钩子</h3><h4>createLightPillar - 创建光柱标记</h4><pre><code class="javascript">const createLightPillar = (lon, lat, heightScaleFactor = 1) =&gt; { let group = new THREE.Group() // 柱体高度 const height = heightScaleFactor // 柱体的geo,6.19=柱体图片高度/宽度的倍数 const geometry = new THREE.PlaneBufferGeometry(height / 6.219, height) // 柱体旋转90度,垂直于Y轴 geometry.rotateX(Math.PI / 2) // 柱体的z轴移动高度一半对齐中心点 geometry.translate(0, 0, height / 2) // 柱子材质 const material = new THREE.MeshBasicMaterial({ map: textureLoader.load(defaultOptions.lightPillarUrl), color: 0x00ffff, transparent: true, depthWrite: false, side: THREE.DoubleSide, }) // 光柱01 let light01 = new THREE.Mesh(geometry, material) light01.renderOrder = 99 light01.name = &quot;createLightPillar01&quot; // 光柱02:复制光柱01 let light02 = light01.clone() light02.name = &quot;createLightPillar02&quot; // 光柱02,旋转90°,跟光柱01交叉 light02.rotateZ(Math.PI / 2) // 创建底部标点 const bottomMesh = createPointMesh() // 创建光圈 const lightHalo = createLightHalo() // 将光柱和标点添加到组里 group.add(bottomMesh, lightHalo, light01, light02) // 设置组对象的姿态 group.position.set(lon, lat, 0) return group }</code></pre><p><strong>作用</strong>:创建包含底部标记、呼吸光圈和交叉光柱的完整标记效果。</p><p><strong>参数</strong>:</p><ul><li><code>lon</code>:经度坐标</li><li><code>lat</code>:纬度坐标</li><li><code>heightScaleFactor</code>:光柱高度缩放系数</li></ul><p><strong>返回值</strong>:包含完整光柱效果的Three.js Group对象</p><p><strong>执行步骤</strong>:</p><ol><li>创建光柱组容器</li><li>计算柱体尺寸和几何体</li><li>创建柱体贴图材质</li><li>创建第一个光柱并设置渲染顺序</li><li>克隆并旋转创建第二个交叉光柱</li><li>创建底部标记点</li><li>创建呼吸光圈</li><li>将所有元素添加到组中</li><li>设置组的位置坐标</li><li>返回完整的光柱组</li></ol><h4>createPointMesh - 创建标记点</h4><pre><code class="javascript">const createPointMesh = () =&gt; { // 标记点:几何体,材质 const geometry = new THREE.PlaneBufferGeometry(1, 1) const material = new THREE.MeshBasicMaterial({ map: textureLoader.load(defaultOptions.pointTextureUrl), color: 0x00ffff, side: THREE.DoubleSide, transparent: true, depthWrite: false, //禁止写入深度缓冲区数据 }) let mesh = new THREE.Mesh(geometry, material) mesh.renderOrder = 97 mesh.name = &quot;createPointMesh&quot; // 缩放 const scale = 0.15 * defaultOptions.scaleFactor mesh.scale.set(scale, scale, scale) return mesh }</code></pre><p><strong>作用</strong>:创建光柱底部的标记点。</p><p><strong>返回值</strong>:标记点Mesh对象</p><p><strong>实现原理</strong>:使用PlaneGeometry创建平面,加载标记点纹理,设置透明和渲染顺序。</p><h4>createLightHalo - 创建呼吸光圈</h4><pre><code class="javascript">const createLightHalo = () =&gt; { // 标记点:几何体,材质 const geometry = new THREE.PlaneBufferGeometry(1, 1) const material = new THREE.MeshBasicMaterial({ map: textureLoader.load(defaultOptions.lightHaloTextureUrl), color: 0x00ffff, side: THREE.DoubleSide, opacity: 0, transparent: true, depthWrite: false, //禁止写入深度缓冲区数据 }) let mesh = new THREE.Mesh(geometry, material) mesh.renderOrder = 98 mesh.name = &quot;createLightHalo&quot; // 缩放 const scale = 0.3 * defaultOptions.scaleFactor mesh.scale.set(scale, scale, scale) // 动画延迟时间 const delay = random(0, 2000) // 动画:透明度缩放动画 mesh.tween1 = new TWEEN.Tween({ scale: scale, opacity: 0 }) .to({ scale: scale * 1.5, opacity: 1 }, 1000) .delay(delay) .onUpdate((params) =&gt; { let { scale, opacity } = params mesh.scale.set(scale, scale, scale) mesh.material.opacity = opacity }) mesh.tween2 = new TWEEN.Tween({ scale: scale * 1.5, opacity: 1 }) .to({ scale: scale * 2, opacity: 0 }, 1000) .onUpdate((params) =&gt; { let { scale, opacity } = params mesh.scale.set(scale, scale, scale) mesh.material.opacity = opacity }) mesh.tween1.chain(mesh.tween2) mesh.tween2.chain(mesh.tween1) mesh.tween1.start() return mesh }</code></pre><p><strong>作用</strong>:创建带有呼吸动画效果的光圈。</p><p><strong>返回值</strong>:光圈Mesh对象(带有tween动画)</p><p><strong>实现原理</strong>:创建平面并加载光圈纹理,使用Tween.js实现透明度和缩放的循环动画,形成呼吸效果。</p><h3>2. useSequenceFrameAnimate.js - 序列帧动画钩子</h3><h4>createSequenceFrame - 创建序列帧动画</h4><pre><code class="javascript">const createSequenceFrame = ({ image, width, height, frame, column, row, speed = 0.1 }) =&gt; { // 创建平面几何体 const geometry = new THREE.PlaneGeometry(width, height) // 创建纹理 const texture = new THREE.TextureLoader().load(image) // 设置纹理参数 texture.wrapS = THREE.RepeatWrapping texture.wrapT = THREE.RepeatWrapping // 计算每个帧的大小 const frameWidth = 1 / column const frameHeight = 1 / row // 设置纹理显示区域 texture.repeat.set(frameWidth, frameHeight) // 创建材质 const material = new THREE.MeshBasicMaterial({ map: texture, transparent: true, side: THREE.DoubleSide, }) // 创建网格 const mesh = new THREE.Mesh(geometry, material) // 添加动画属性 mesh.currentFrame = 0 mesh.totalFrames = frame mesh.column = column mesh.frameWidth = frameWidth mesh.frameHeight = frameHeight mesh.speed = speed mesh.texture = texture // 添加更新方法 mesh.updateSequenceFrame = function() { this.currentFrame += this.speed if (this.currentFrame &gt;= this.totalFrames) { this.currentFrame = 0 } const frameIndex = Math.floor(this.currentFrame) const x = (frameIndex % this.column) * this.frameWidth const y = 1 - Math.floor(frameIndex / this.column) * this.frameHeight - this.frameHeight this.texture.offset.set(x, y) } return mesh }</code></pre><p><strong>作用</strong>:创建基于序列帧图片的动画效果。</p><p><strong>参数</strong>:</p><ul><li><code>image</code>:序列帧图片路径</li><li><code>width</code>:动画宽度</li><li><code>height</code>:动画高度</li><li><code>frame</code>:总帧数</li><li><code>column</code>:每行帧数</li><li><code>row</code>:每列帧数</li><li><code>speed</code>:动画播放速度</li></ul><p><strong>返回值</strong>:带有动画更新方法的Three.js Mesh对象</p><p><strong>实现原理</strong>:通过控制纹理的offset属性,实现序列帧图片的逐帧播放,形成动画效果。</p><h2>2D标签渲染模块</h2><h3>1. useCSS2DRender.js - CSS2D渲染钩子</h3><h4>initCSS2DRender - 初始化2D渲染器</h4><pre><code class="javascript">const initCSS2DRender = (options, container) =&gt; { const css2dRender = new THREE.CSS2DRenderer() css2dRender.setSize(options.width, options.height) css2dRender.domElement.style.position = 'absolute' css2dRender.domElement.style.top = '0px' css2dRender.domElement.style.pointerEvents = 'none' container.appendChild(css2dRender.domElement) return css2dRender }</code></pre><p><strong>作用</strong>:初始化CSS2DRenderer,用于在3D场景中渲染2D HTML元素。</p><p><strong>参数</strong>:</p><ul><li><code>options</code>:渲染器配置参数</li><li><code>container</code>:DOM容器元素</li></ul><p><strong>返回值</strong>:初始化完成的CSS2DRenderer实例</p><p><strong>实现原理</strong>:使用Three.js的CSS2DRenderer创建一个与3D渲染器叠加的2D渲染层,用于显示HTML标签。</p><h4>create2DTag - 创建2D标签</h4><pre><code class="javascript">const create2DTag = (className) =&gt; { const div = document.createElement('div') div.className = className div.style.color = '#fff' div.style.padding = '4px 8px' div.style.borderRadius = '4px' div.style.fontSize = '12px' div.style.whiteSpace = 'nowrap' div.style.opacity = '0' const label = new THREE.CSS2DObject(div) label.visible = false // 添加显示方法 label.show = function(text, position) { this.element.innerHTML = text this.position.copy(position) this.visible = true this.element.style.opacity = '1' } // 添加隐藏方法 label.hide = function() { this.visible = false this.element.style.opacity = '0' } return label }</code></pre><p><strong>作用</strong>:创建可显示在3D场景中的2D HTML标签。</p><p><strong>参数</strong>:</p><ul><li><code>className</code>:标签的CSS类名</li></ul><p><strong>返回值</strong>:带有show和hide方法的CSS2DObject实例</p><p><strong>实现原理</strong>:创建HTML元素并封装为CSS2DObject,添加显示和隐藏方法,便于在3D场景中控制标签的显示。</p><h2>地图装饰元素模块</h2><h3>1. App.vue - 装饰元素创建函数</h3><h4>initRotatingAperture - 初始化旋转光圈</h4><pre><code class="javascript">const initRotatingAperture = (scene, width) =&gt; { let plane = new THREE.PlaneBufferGeometry(width, width) let material = new THREE.MeshBasicMaterial({ map: rotatingApertureTexture, transparent: true, opacity: 1, depthTest: true, }) let mesh = new THREE.Mesh(plane, material) mesh.position.set(...centerXY, 0) mesh.scale.set(1.1, 1.1, 1.1) scene.add(mesh) return mesh }</code></pre><p><strong>作用</strong>:创建地图底部的旋转光圈效果。</p><p><strong>参数</strong>:</p><ul><li><code>scene</code>:Three.js场景对象</li><li><code>width</code>:光圈宽度</li></ul><p><strong>返回值</strong>:光圈Mesh对象(在loop函数中更新旋转)</p><h4>initParticle - 初始化粒子系统</h4><pre><code class="javascript">const initParticle = (scene, bound) =&gt; { // 获取中心点和中间地图大小 let { center, size } = bound // 构建范围,中间地图的2倍 let minX = center.x - size.x let maxX = center.x + size.x let minY = center.y - size.y let maxY = center.y + size.y let minZ = -6 let maxZ = 6 let particleArr = [] for (let i = 0; i &lt; 16; i++) { const particle = createSequenceFrame({ image: &quot;./data/map/上升粒子1.png&quot;, width: 180, height: 189, frame: 9, column: 9, row: 1, speed: 0.5, }) let particleScale = random(5, 10) / 1000 particle.scale.set(particleScale, particleScale, particleScale) particle.rotation.x = Math.PI / 2 let x = random(minX, maxX) let y = random(minY, maxY) let z = random(minZ, maxZ) particle.position.set(x, y, z) particleArr.push(particle) } scene.add(...particleArr) return particleArr }</code></pre><p><strong>作用</strong>:创建上升粒子效果,增强地图的动态感。</p><p><strong>参数</strong>:</p><ul><li><code>scene</code>:Three.js场景对象</li><li><code>bound</code>:地图边界信息对象</li></ul><p><strong>返回值</strong>:粒子对象数组(在loop函数中更新位置和动画)</p><p><strong>执行步骤</strong>:</p><ol><li>计算粒子生成范围</li><li>循环创建粒子对象</li><li>加载序列帧粒子图片</li><li>设置粒子大小和旋转角度</li><li>随机分布粒子位置</li><li>将粒子添加到场景</li><li>返回粒子数组</li></ol><h2>总结</h2><p>本项目通过模块化设计和组件化开发,构建了一个功能丰富、性能优良的3D交互式地图可视化系统。核心函数按照数据处理、3D建模、视觉效果、交互控制等模块进行组织,形成了清晰的调用关系和执行流程。</p><p>系统的主要技术亮点包括:</p><ol><li><strong>高效的数据处理</strong>:实现了GeoJSON数据的标准化转换和坐标系统转换</li><li><strong>精美的3D模型</strong>:使用ExtrudeGeometry创建具有立体感的地图模型</li><li><strong>丰富的视觉效果</strong>:包括光柱标记、呼吸光圈、粒子动画等</li><li><strong>流畅的交互体验</strong>:基于OrbitControls实现的相机控制</li><li><strong>灵活的2D标签</strong>:使用CSS2DRenderer实现的3D场景中2D标签渲染</li></ol><p>通过对这些核心函数的详细分析,我们可以深入理解3D地图可视化系统的实现原理和技术细节,为类似项目的开发提供参考和借鉴。</p>

2025/12/18
阅读更多

史上最优无痕埋点方案

<blockquote><p>在移动互联网时代,对于每个公司、企业来说,用户的行为数据非常重要。重要到什么程度,用户在这个页面停留多久、点击了什么按钮、浏览了什么内容、什么手机、什么网络环境、App什么版本等都需要清清楚楚。一些大厂的蛮多业务成果都是基于用户操作行为进行推荐后二次转换。另一方面是以日志的作用帮助开发者分析线上问题的一种辅助手段。</p><p>那么有了上述的诉求,那么技术人员如何满足这些需求?引出来了一个技术点-“埋点”</p></blockquote><h2>前置说明</h2><p>看到我在下面的代码段,有些命名风格、简写、分类、方法的命名等,我简单做个说明。</p><ul><li>埋点 SDK 叫 <code>UserAnalysis</code>,我们规定类的命名一般用 SDK 的名字缩写,当前情况下缩写为 <code>UA</code></li><li>给 Category 命名,规则为 <code>类名 + SDK 前缀缩写的小写格式 + 下划线 + 驼峰命名格式的功能描述</code>。比如给 NSDate 增加一个获取毫秒时间戳的分类,那么类名为 <code>NSDate+ua_TimeStamp</code></li><li>给 Category 的方法命名,规则为 <code>SDK 前缀缩写的小写格式 + 下划线 + 驼峰命名格式的功能描述</code>。比如给 NSDate 增加一个根据当前时间获取毫秒时间戳的方法,那么方法名为 <code>+ (long long)ua_currentTimestamp;</code></li></ul><h2>一. 埋点手段</h2><p>业界中对于代码埋点主要有3种主流的方案:代码手动埋点、可视化埋点、无痕埋点。简单说说这几种埋点方案。</p><ul><li>代码手动埋点:根据业务需求(运营、产品、开发多个角度出发)在需要埋点地方手动调用埋点接口,上传埋点数据。</li><li>可视化埋点:通过可视化配置工具完成采集节点,在前端自动解析配置并上报埋点数据,从而实现可视化“无痕埋点”</li><li>无痕埋点:通过技术手段,完成对用户行为数据无差别的统计上传的工作。后期数据分析处理的时候通过技术手段筛选出合适的数据进行统计分析。</li></ul><h2>二. 技术选型</h2><h3>1. 代码手动埋点</h3><p>该方案情况下,如果需要埋点,则需要在工程代码中,写埋点相关代码。因为侵入了业务代码,对业务代码产生了污染,显而易见的缺点是<strong>埋点的成本较高</strong>、且违背了<strong>单一原则</strong>。</p><p>例1:假如你需要知道用户在点击“购买按钮”时的相关信息(手机型号、App版本、页面路径、停留时间、动作等等),那么就需要在按钮的点击事件里面去写埋点统计的代码。这样明显的弊端就是在之前业务逻辑的代码上面又多出了埋点的代码。由于埋点代码分散、埋点的工作量很大、代码维护成本较高、后期重构很头痛。</p><p>例2:假如 App 采用了 Hybrid 架构,当 App 的第一版本发布的时候 H5 的关键业务逻辑统计是由 Native 定义好关键逻辑(比如H5调起了Native的分享功能,那么存在一个分享的埋点事件)的桥接。假如某天增加了一个扫一扫功能,未定义扫一扫的埋点桥接,那么 H5 页面变动的时候,Native 埋点代码不去更新的话,变动的 H5 的业务就未被精确统计。</p><p>优点:产品、运营工作量少,对照业务映射表就可以还原出相关业务场景、数据精细无须大量的加工和处理</p><p>缺点:开发工作量大、前期需要和运营、产品指定的好业务标识,以便产品和运营进行数据统计分析</p><h3>2. 可视化埋点</h3><p><strong>可视化埋点的出现,是为解决代码埋点流程复杂、成本高、新开发的页面(H5、或者服务端下发的 json 去生成相应页面)不能及时拥有埋点能力</strong></p><p>前端在「埋点编辑模式」下,以“可视化”的方式去配置、绑定关键业务模块的路径到前端可以唯一确定到 view 的xpath 过程。</p><p>用户每次操作的控件,都生成一个 <strong>xpath</strong> 字符串,然后通过接口将 xpath 字符串(view在前端系统中的唯一定位。以 iOS 为例,App名称、控制器名称、一层层view、同类型view的序号:<code>GoodCell.21.RetailTableView.GoodsViewController.***App</code> 到真正的业务模块(“某App-商城控制器-分销商品列表-第21个商品被点击了”)的映射关系上传到服务端。xpath 具体是什么在下文会有介绍。</p><p>之后操作 App 就生成对应的 xpath 和埋点数据(开发者通过技术手段将从服务端获取的关键数据塞到前端的 UI 控件上。 iOS 端为例, UIView 的 <code>accessibilityIdentifier</code> 属性可以设置我们从服务端获取的埋点数据)上传到服务端。</p><p>优点:数据量相对准确、后期数据分析成本低</p><p>缺点:前期控件的唯一识别、定位都需要额外开发;可视化平台的开发成本较高;对于额外需求的分析可能会比较困难</p><h3>3. 无痕埋点</h3><p>通过技术手段无差别地记录用户在前端页面上的行为。可以正确的获取 PV、UV、IP、Action、Time 等信息。</p><p>缺点:前期开发统计基础信息的技术产品成本较高、后期数据分析数据量很大、分析成本较高(大量数据传统的关系型数据库压力大)</p><p>优点:开发人员工作量小、数据全面、无遗漏、产品和运营按需分析、支持动态页面的统计分析</p><h3>4. 如何选择</h3><p>结合上述优缺点,我们选择了 <strong>无痕埋点+可视化埋点结合</strong> 的技术方案。</p><p>怎么说呢?对于关键的业务开发结束上线后、通过可视化方案(类似于一个界面,想想看 Dreamwaver,你在界面上拖拖控件,简单编辑下就可以生成对应的 HTML 代码)点击一下绑定对应关系到服务端。</p><p>那么这个对应关系是什么?我们需要唯一定位一个前端元素,那么想到的办法就是不管 Native 和 Web 前端,控件或者元素来说就是一个树形层级,DOM tree 或者 UI tree,所以我们通过技术手段定位到这个元素,以 Native iOS 为例子,假如点击商品详情页的加入购物车按钮会根据 UI 层级结构生成一个唯一标识 <code>addCartButton.GoodsViewController.GoodsView.*BaoApp</code>。但是用户在使用 App 的时候,上传的是这串东西的 MD5 到服务端。</p><p>这么做有2个原因:服务端数据库存储这串很长的东西不是很好;埋点数据被劫持的话直接看到明文不太好。所以 MD5 再上传。</p><h2>三. 操刀就干</h2><p>一言以蔽之就是:<strong>AOP -&gt; Event Collector -&gt; Event Cache -&gt; Data Upload</strong></p><ul><li>AOP:通过 runtime hook 的能力做到提供合适的时机去生成点击事件的数据</li><li>Event Collector:将步骤1产生的数据统一收集(一般是一个内存存储的数据结构)</li><li>Event Cache:mmap,当内存中的数据达到一定的阀值,或者应用程序的生命周期切换的时候将内存中的数据同步到缓存中(数据库、磁盘、文件)</li><li>Data Uploader:制定一定的策略,当达到触发条件的时候再去上传数据(App达到阀值,生命周期的切换等);App从前台进入后台的时候去上传数据(后台线程保活策略);数据上传格式的选择(zip压缩文件、protoBuf)</li></ul><h3>1. 数据的收集</h3><p>实现方案由以下几个关键指标:</p><ul><li>现有代码改动少、尽量不要侵入业务代码去实现拦截系统事件</li><li>全量收集</li><li>如何唯一标识一个控件元素</li></ul><h3>2. 不侵入业务代码拦截系统事件</h3><p>以 iOS 为例。我们会想到 <strong>AOP(Aspect Oriented Programming)</strong>面向切面编程思想。动态地在函数调用前后插入相应的代码,在 Objective-C 中我们可以利用 Runtime 特性,用 <strong>Method Swizzling</strong> 来 hook 相应的函数</p><p>为了给所有类方便地 hook,我们可以给 NSObject 添加个 Category,名字叫做 <code>NSObject+u a_MethodSwizzling</code></p><pre><code class="Objective-C">#pragma mark - public Method + (void)ua_swizzleMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector { ua_swizzleInstanceMethod(self, originalSelector, swizzledSelector); } + (void)ua_swizzleClassMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector { //类方法实际上是储存在类对象的类(即元类)中,即类方法相当于元类的实例方法,所以只需要把元类传入,其他逻辑和交互实例方法一样。 Class class2 = object_getClass(self); ua_swizzleInstanceMethod(class2, originalSelector, swizzledSelector); } #pragma mark - private method void ua_swizzleInstanceMethod(Class class, SEL originalSEL, SEL replacementSEL) { Method originMethod = class_getInstanceMethod(class, originalSEL); Method replaceMethod = class_getInstanceMethod(class, replacementSEL); if(class_addMethod(class, originalSEL, method_getImplementation(replaceMethod),method_getTypeEncoding(replaceMethod))) { class_replaceMethod(class,replacementSEL, method_getImplementation(originMethod), method_getTypeEncoding(originMethod)); } else { method_exchangeImplementations(originMethod, replaceMethod); } }</code></pre><h3>3. 全量收集</h3><p>我们会想到 hook AppDelegate 代理方法、UIViewController 生命周期方法、按钮点击事件、手势事件、各种系统控件的点击回调方法、应用状态切换等等。</p><table><thead><tr><th align="center">动作</th><th align="center">事件</th></tr></thead><tbody><tr><td align="center">App 状态的切换</td><td align="center">给 Appdelegate 添加分类,hook 生命周期</td></tr><tr><td align="center">UIViewController 生命周期函数</td><td align="center">给 UIViewController 添加分类,hook 生命周期</td></tr><tr><td align="center">UIButton 等的点击</td><td align="center">UIButton 添加分类,hook 点击事件</td></tr><tr><td align="center">UICollectionView、UITableView 等的</td><td align="center">在对应的 Cell 添加分类,hook 点击事件</td></tr><tr><td align="center">手势事件 UITapGestureRecognizer、UIControl、UIResponder</td><td align="center">相应系统事件</td></tr></tbody></table><p>以统计页面的打开时间和统计页面的打开、关闭的需求为例,我们对 UIViewController 进行 hook</p><pre><code class="objective-c">static char *ua_viewController_open_time = &quot;ua_viewController_open_time&quot;; static char *ua_viewController_close_time = &quot;ua_viewController_close_time&quot;; @implementation UIViewController (uaka) // load 方法里面添加 dispatch_once 是为了防止手动调用 load 方法。 + (void)load { static dispatch_once_t onceToken; dispatch_once(&amp;onceToken, ^{ @autoreleasepool { [[self class] ua_swizzleMethod:@selector(viewWillAppear:) swizzledSelector:@selector(ua_viewWillAppear:)]; [[self class] ua_swizzleMethod:@selector(viewWillDisappear:) swizzledSelector:@selector(ua_viewWillDisappear:)]; } }); } #pragma mark - private method - (void)ua_viewWillAppear:(BOOL)animated { NSString *className = NSStringFromClass([self class]); NSString *refer = [NSString string]; //TODO:TODO 是否只埋本地有url的page if ([self getPageUrl:className]) { //设置打开时间 [self setOpenTime:[NSDate dateWithTimeIntervalSinceNow:0]]; if (self.navigationController) { if (self.navigationController.viewControllers.count &gt;=2) { //获取当前vc 栈中 上一个VC UIViewController *referVC = self.navigationController.viewControllers[self.navigationController.viewControllers.count-2]; refer = [self getPageUrl:NSStringFromClass([referVC class])]; } } if (!refer || refer.length == 0) { refer = @&quot;unknown&quot;; } [UserTrackDataCenter openPage:[self getPageUrl:className] fromPage:refer]; } [self ua_viewWillAppear:animated]; } - (void)ua_viewWillDisappear:(BOOL)animated { NSString *className = NSStringFromClass([self class]); if ([self getPageUrl:className]) { [self setCloseTime:[NSDate dateWithTimeIntervalSinceNow:0]]; [UserTrackDataCenter leavePage:[self getPageUrl:className] spendTime:[self p_calculationTimeSpend]]; } [self ua_viewWillDisappear:animated]; } - (NSString *)p_calculationTimeSpend { if (![self getOpenTime] || ![self getCloseTime]) { return @&quot;unknown&quot;; } NSTimeInterval aTimer = [[self getCloseTime] timeIntervalSinceDate:[self getOpenTime]]; int hour = (int)(aTimer/3600); int minute = (int)(aTimer - hour*3600)/60; int second = aTimer - hour*3600 - minute*60; return [NSString stringWithFormat:@&quot;%d&quot;,second]; } #pragma mark - getter &amp;&amp; setter - (void)setOpenTime:(NSDate *)openTime { objc_setAssociatedObject(self,&amp;ua_viewController_open_time, openTime, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } - (NSDate *)getOpenTime { return objc_getAssociatedObject(self, &amp;ua_viewController_open_time); } - (void)setCloseTime:(NSDate *)closeTime { objc_setAssociatedObject(self,&amp;ua_viewController_close_time, closeTime, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } - (NSDate *)getCloseTime { return objc_getAssociatedObject(self, &amp;ua_viewController_close_time); } @end</code></pre><h3>4. 如何唯一标识一个控件元素</h3><p><strong>xpath</strong> 是移动端定义可操作区域的唯一标识。既然想通过一个字符串标识前端系统中可操作的控件,那么 xpath 需要2个指标:</p><ul><li>唯一性:在同一系统中不存在不同控件有着相同的 xpath</li><li>稳定性:不同版本的系统中,在页面结构没有变动的情况下,不同版本的相同页面,相同的控件的 xpath 需要保持一致。</li></ul><p>我们想到 Naive、H5 页面等系统渲染的时候都是以树形结构去绘制和渲染,所以我们以当前的 View 到系统的根元素之间的所有关键点(UIViewController、UIView、UIView容器(UITableView、UICollectionView等)、UIButton...)串联起来这样就唯一定位了控件元素。</p><p>为了精确定位元素节点,参看下图</p><p>假设一个 UIView 中有三个子 view,先后顺序是:label、button1、button2,那么深度依次为: 0、1、2。假如用户做了某些操作将 label1 从父 view 中被移除了。此时 UIView 只有 2 个子view:button1、button2,而且深度变为了:0、1。</p><p><img width="723" height="348" src="/img/bVbqNKT" alt="" title=""></p><p>可以看出仅仅由于其中某个子 view 的改变,却导致其它子 view 的深度都发生了变化。因此,在设计的时候需要注意,在新增/移除某一 view 时,尽量减少对已有 view 的深度的影响,调整了对节点的深度的计算方式:采用当前 view 位于其父 view 中的所有 <strong>与当前 view 同类型</strong> 子view 中的索引值。</p><p>我们再看一下上面的这个例子,最初 label、button1、button2 的深度依次是:0、0、1。在 label 被移除后,button1、button2 的深度依次为:0、1。可以看出,在这个例子中,label 的移除并未对 button1、button2 的深度造成影响,这种调整后的计算方式在一定程度上增强了 xpath 的抗干扰性。</p><p>另外,调整后的深度的计算方式是依赖于各节点的类型的,因此,此时必须要将各节点的名称放到 <code>viewPath</code> 中,而不再是仅仅为了增加可读性。</p><p>在标识控件元素的层级时,需要知道「当前 view 位于其父 view 中的所有 <strong>与当前 view 同类型</strong> 子view 中的索引值」。参看上图,如果不是同类型的话,则唯一性得不到保证。</p><h3>5. 同类型的 view 的唯一定位问题</h3><p>有个问题,比如我们点击的元素是 UITableViewCell,那么它虽然可以定位到类似于这个标示 <code>xxApp.GoodsViewController.GoodsTableView.GoodsCell</code>,同类型的 Cell 有多个,所以单凭借这个字符串是没有办法定位具体的那个 Cell 被点击了。</p><p>当然有解决方案啦。</p><ul><li><p>找出当前元素在父层同类型元素中的索引。根据当前的元素遍历当前元素的父级元素的子元素,如果出现相同的元素,则需要判断当前元素是所在层级的第几个元素</p><p>对当前的控件元素的父视图的全部子视图进行遍历,如果存在和当前的控件元素同类型的控件,那么需要判断当前控件元素在同类型控件元素中的所处的位置,那么则可以唯一定位。举例:<code>GoodsCell-3.GoodsTableView.GoodsViewController.xxApp</code></p><pre><code class="Objective-c">//UIResponder分类 - (NSString *)ua_identifierKa { // if (self.xq_identifier_ka == nil) { if ([self isKindOfClass:[UIView class]]) { UIView *view = (id)self; NSString *sameViewTreeNode = [view obtainSameSuperViewSameClassViewTreeIndexPath]; NSMutableString *str = [NSMutableString string]; //特殊的 加减购 因为带有spm但是要区分加减 需要带TreeNode NSString *className = [NSString stringWithUTF8String:object_getClassName(view)]; if (!view.accessibilityIdentifier || [className isEqualToString:@&quot;uaButton&quot;]) { [str appendString:sameViewTreeNode]; [str appendString:@&quot;,&quot;]; } while (view.nextResponder) { [str appendFormat:@&quot;%@,&quot;, NSStringFromClass(view.class)]; if ([view.class isSubclassOfClass:[UIViewController class]]) { break; } view = (id)view.nextResponder; } self.xq_identifier_ka = [self md5String:[NSString stringWithFormat:@&quot;%@&quot;,str]]; // self.xq_identifier_ka = [NSString stringWithFormat:@&quot;%@&quot;,str]; } // } return self.xq_identifier_ka; } // UIView 分类 - (NSString *)obtainSameSuperViewSameClassViewTreeIndexPat { NSString *classStr = NSStringFromClass([self class]); //cell的子view //UITableView 特殊的superview (UITableViewContentView) //UICollectionViewCell BOOL shouldUseSuperView = ([classStr isEqualToString:@&quot;UITableViewCellContentView&quot;]) || ([[self.superview class] isKindOfClass:[UITableViewCell class]])|| ([[self.superview class] isKindOfClass:[UICollectionViewCell class]]); if (shouldUseSuperView) { return [self obtainIndexPathByView:self.superview]; }else { return [self obtainIndexPathByView:self]; } } - (NSString *)obtainIndexPathByView:(UIView *)view { NSInteger viewTreeNodeDepth = NSIntegerMin; NSInteger sameViewTreeNodeDepth = NSIntegerMin; NSString *classStr = NSStringFromClass([view class]); NSMutableArray *sameClassArr = [[NSMutableArray alloc]init]; //所处父view的全部subviews根节点深度 for (NSInteger index =0; index &lt; view.superview.subviews.count; index ++) { //同类型 if ([classStr isEqualToString:NSStringFromClass([view.superview.subviews[index] class])]){ [sameClassArr addObject:view.superview.subviews[index]]; } if (view == view.superview.subviews[index]) { viewTreeNodeDepth = index; break; } } //所处父view的同类型subviews根节点深度 for (NSInteger index =0; index &lt; sameClassArr.count; index ++) { if (view == sameClassArr[index]) { sameViewTreeNodeDepth = index; break; } } return [NSString stringWithFormat:@&quot;%ld&quot;,sameViewTreeNodeDepth]; }</code></pre><p><img width="723" height="429" src="/img/bVdnoxc" alt="" title=""></p></li></ul><h3>6. 同类型的view,但是点击的意义却不一样。如何唯一标识?</h3><p>问题5说明的是在一个界面上有多个不同的 view,他们的类型是同一种(<code>CycleBannerView</code>,但是数据源不一样,那么当数据源长度大于1的时候会轮播,下面会展示 <code>UIPageControl</code>。如果数据源是1个,那么就不会轮播和展示 <code>UIPageControl</code>)。</p><p>情况6是同一种类型的 View,但是根据展示的内容不一样,点击的意义也不一样。也就是运营需要去知道用户到底点击的是哪一个。如下图所示,「立即抢购」和「分享赚佣金」是同一种类型的 View,但是点击意义不一样,需要我们需要唯一标识出来。之前的方法通过 <strong>「viewPath 配合同类型的 view 去加索引值」</strong> 的方式还是没有办法唯一标识出来。所以想到一个方案,给 NSObject 添加一个分类,在分类里面添加一个协议。让需要复用但需要唯一标识的 view 去实现协议方法,因为是给 NSObject 分类添加的协议,所以 view 不需要去指定遵循。</p><p><img width="375" height="1379" src="/img/bVdnoxd" alt="&quot;立即抢购&quot;、&quot;分享赚佣金&quot;同类型view,但点击意义不一样" title="&quot;立即抢购&quot;、&quot;分享赚佣金&quot;同类型view,但点击意义不一样"></p><p>关键步骤:</p><ul><li>添加 NSObject 的 Category。在分类里面声明唯一标识的协议</li><li>在生成 viewPath 的地方去拿出当前 view 的唯一标识(view 调用协议方法)。然后拼接之前拿出的 viewPath</li></ul><pre><code class="objective-c">//NSObject+uaUniqueIdentify.h #import &lt;Foundation/Foundation.h&gt; NS_ASSUME_NONNULL_BEGIN @class NSObject; @protocol UniqueIdentify&lt;NSObject&gt; @optional - (NSString *)setUniqueIdentifier; @end @interface NSObject (UniqueIdentify)&lt;UniqueIdentify&gt; @end NS_ASSUME_NONNULL_END //NSObject+ua_UniqueIdentify.m #import &quot;NSObject+ua_UniqueIdentify.h&quot; @implementation NSObject (UniqueIdentify) @end</code></pre><pre><code class="objective-c">//MallTGoodTagView.h extern NSString * _Nonnull const ImmediateyPurchase; extern NSString * _Nonnull const ShareToAward; //MallTGoodTagView.m NSString *const ImmediateyPurchase = @&quot;立即抢购&quot;; NSString *const ShareToAward = @&quot;分享赚佣金&quot;; - (NSString *)setUniqueIdentifier { if (self.tagString) { return self.tagString; } else { return NSStringFromClass([self class]); } }</code></pre><pre><code class="objective-c">//UIResponder Category 生成 viewPath - (NSString *)ua_identifierKa { // if (self.xq_identifier_ka == nil) { if ([self isKindOfClass:[UIView class]]) { UIView *view = (id)self; NSString *sameViewTreeNode = [view obtainSameSuperViewSameClassViewTreeIndexPath]; NSMutableString *str = [NSMutableString string]; //特殊的 加减购 因为带有spm但是要区分加减 需要带TreeNode NSString *className = [NSString stringWithUTF8String:object_getClassName(view)]; if (!view.accessibilityIdentifier || [className isEqualToString:@&quot;uaButton&quot;]) { [str appendString:sameViewTreeNode]; [str appendString:@&quot;,&quot;]; } while (view.nextResponder) { if ([view respondsToSelector:@selector(setUniqueIdentifier)]) { NSString *unqiueIdentifier = [view setUniqueIdentifier]; if (unqiueIdentifier) { [str appendFormat:@&quot;%@,&quot;, unqiueIdentifier]; } }00 [str appendFormat:@&quot;%@,&quot;, NSStringFromClass(view.class)]; if ([view.class isSubclassOfClass:[UIViewController class]]) { break; } view = (id)view.nextResponder; } self.xq_identifier_ka = [self md5String:[NSString stringWithFormat:@&quot;%@&quot;,str]]; // self.xq_identifier_ka = [NSString stringWithFormat:@&quot;%@&quot;,str]; } // } return self.xq_identifier_ka; }</code></pre><p><img width="723" height="428" src="/img/bVdnoxe" alt="改进版view唯一标识:立即抢购" title="改进版view唯一标识:立即抢购"><br><img width="723" height="428" src="/img/bVdnoxf" alt="改进版view唯一标识:分享赚佣金" title="改进版view唯一标识:分享赚佣金"></p><h3>7. 特殊 case</h3><p>根据在同一个 view 上会有多个 subview,那么生成的 xpath 会携带在同类型 views 中的索引,所以一个登录、注册按钮的 xpath 可能为 <code>btn1.LoginView.LoginViewController.*baoApp</code>、<code>bt21.LoginView.LoginViewController.*baoApp</code>。</p><p>问题来了,当版本 A 上线后运行了一段时间,上传并统计了数据。但是过了一段时间版本迭代,UI 为了 KPI 搞事情,把登录和注册按钮的位置换了一下,变成了注册、登录。按照之前的逻辑生成的 xpath 还是 <code>btn1.LoginView.LoginViewController.*baoApp</code>、<code>bt21.LoginView.LoginViewController.*baoApp</code>。那么新的 xpath 虽然唯一,但是点击产生的数据会和之前的埋点数据意义不一样。别怕,你忘了还有一步绑定的逻辑。可视化绑定这个步骤会把每次开发的功能,通过可视化界面去将 <code>xpath</code> 和功能模块名称绑定一下。</p><p>看下面的动图。所以不用担心虽然生成了唯一的 <code>xpath</code>,但是 App 在不同版本之间 UI 控件位置更换造成之前的统计数据在分析的时候不准确的问题。因为在绑定的时候就将新的 <code>xpath</code> 和功能名称进行了绑定,接口携带版本号。所以分析的时候注意版本号就好了。sql 一句话的事情。</p><p><img width="450" height="450" src="/img/bVdnoxo" alt="绑定页面唯一标识与功能描述的对应关系动图" title="绑定页面唯一标识与功能描述的对应关系动图"></p><h3>8. 数据如何处理</h3><h4>1. 如何处理业务数据</h4><p>利用系统提供的 <code>accessibilityIdentifier</code> 官方给出的解释是标识用户界面元素的字符串</p><pre><code class="objective-c">/* A string that identifies the user interface element. default == nil */ @property(nullable, nonatomic, copy) NSString *accessibilityIdentifier API_AVAILABLE(ios(5.0));</code></pre><p>服务端下发唯一标识</p><p>接口获取的数据,里面有当前元素的唯一标识。比如在 UITableView 的界面去请求接口拿到数据,那么在在获取到的数据源里面会有一个字段,专门用来存储动态化的经常变动的业务数据。</p><pre><code class="objective-c">cell.accessibilityIdentifier = [[[SDGGoodsCategoryServices sharedInstance].categories[indexPath.section] children][indexPath.row].spmContent yy_modelToJSONString];</code></pre><h4>2. 基础数据</h4><p>设计上分为2个 pod 库,一个是 <code>UserAnalysis</code>(专门用来 hook 机会需要的所有事件,页面停留时间、页面标识、view标识),另一个是 AppMonitor(专门用来提供基础数据、埋点数据的维护、上传机制)。所以在 AppMonitor 里面有个类叫做 UserTrackDataCenter 的类,专门提供一些基础数据(系统版本、操作系统、地理位置、网络等信息)。</p><p>对外暴露出一些方法,用来将埋点数据交给 AppMonitor 去维护埋点数据,达到合适的“机制”再去上传埋点数据到服务端。</p><pre><code class="objective-c">+ (void)clickEventUuid:(NSString *)uuid otherParam:(NSDictionary *)otherParam spmContent:(NSDictionary *)spmContent { if (uuid) { NSMutableDictionary *params = [[NSMutableDictionary alloc] initWithDictionary:otherParam]; params[SDGStatisticEventtagKey] = @&quot;clickMonitorV1&quot;; NSMutableDictionary *valueDict = [[NSMutableDictionary alloc] initWithDictionary:spmContent]; valueDict[@&quot;xpath&quot;] = uuid?:@&quot;&quot;; params[SDGStatisticEventtagValue] = valueDict?:@{}; [[AppMonotior shareInstance] traceEvent:[AMStatisticEvent eventWithInfo:params]]; } }</code></pre><h3>9. 曝光时间的统计</h3><p>曝光的意义是什么?</p><p>我们的产品中可能有合作伙伴的广告,我们需要收取服务费。那如何计价?<code>CPM</code>(cost per Mille)每千人成本、<code>CPC</code>(cost per click)每点击成本、<code>CPA</code>(cost per action)每行动成本,根据这些指标来计算价格。或者自己的产品中运营人员在商城中投放了一次新的活动,为了这次活动在某个钻石展位放了设计人员精心设计的炫酷 Banner。这次活动后运营人员想分析在这个图片的作用下有多少人点击了这个活动页。</p><p>何为曝光?</p><p>一个 view 或者一个组件或者一个资源位在屏幕上可见区域内停留的时间称为一次曝光。那么这个时间怎么统计?有一个点需要注意,那就是当用户在快速滑动的过程中页面上的元素或者组件都会在页面可见区域内快速闪过,那这种算一次曝光吗?当然不算啊,想了想设置了一个时间临界值,大于这个临界值那么算做一次有效的曝光。</p><h4>1. 有效曝光的判断</h4><p>显示在屏幕可见区域如何判断?一个 View 显示在屏幕可见区域内,那么它肯定是经过从未初始化到初始化,再到设置 Frame 或者 Bounds 或者 Alpha 或者 Hidden 的。且它的根 view 一定是 UIWindow 对象。所以上面这句话进行分析整理就是下面的条件</p><ul><li>自身 frame 的改变或者父视图 bounds 的改变</li><li>alpha 小于 0.1 或者 hidden 为 YES</li><li>根视图为 window</li></ul><p>对于上面的三点可以用 AOP 进行判断。 hook 掉相应的方法,然后处理判断是否在可见区域内显示。最后的一个点经过一番查找,看到了一个 api <code>- (void)didMoveToWindow;</code> ,根据它可以判断 view 是否显示到屏幕中(文档中说明:当它的 window 对象发送改变的时候会调用 view 的 <code>didMoveToWindow</code> 方法)。</p><pre><code>Tells the view that its window object changed. The default implementation of this method does nothing. Subclasses can override it to perform additional actions whenever the window changes. The window property may be nil by the time that this method is called, indicating that the receiver does not currently reside in any window. This occurs when the receiver has just been removed from its superview or when the receiver has just been added to a superview that is not attached to a window. Overrides of this method may choose to ignore such cases if they are not of interest.</code></pre><h4>2. 曝光代码的执行效率优化</h4><p>设想一下,某个复杂的页面可能是一个大的 UIViewController 顶部是店铺的基本信息,下面是 2个 UIViewController:左侧负责展示商品的一级、二级、三级分类,且负责选中和未选中的 UI 效果;右侧负责展示商品信息(顶部有商品的排序查找 ,下面是商品展示的 UICollectionView)。由于页面结构复杂,UI 层级嵌套严重,所以代码层面不注意的话,页面上计算量会比较大,CPU 负荷严重,直接影响着手机的 <code>耗电量</code>。改进的手段是在合适的地方提前 return 掉(比如 hidden 等于 YES 或者 aplha 小于 0.1 的时候)。</p><p>另外一个方面就是当用户在滑动页面到感兴趣的模块的时候,开始点击执行某个逻辑,但此时我们的无痕埋点的代码也在偷偷的工作,那么势必会对用户体验造成影响。该方案的改进是监听 <code>RunLoop</code>,等到 RunLoop 空闲的时候判断当前 view 是否是一次有效的曝光。</p><p>实际上发现某些 view 的判断会比较特殊,比如当在 UITableView 的 cell 判断的时候,我们发现 cell 的 superview 为 UITableViewWrapperView 时,我们使用 UITableViewWrapperView 的父视图来计算。</p><p>iOS 11 以下 UITableViewWrapperView 大小为屏幕中第一个完整的屏幕大小视图,且会随着 contentOffset 的改变而改变。所以当 UITableViewWrapperView 滑出屏幕可见区域的时候,cell 判断父视图是否可见的时候不准确。</p><p>整个流程见下面的流程图。</p><p><img width="723" height="469" src="/img/bVdnoxk" alt="整体流程图" title="整体流程图"></p><h3>10. 数据的上报</h3><p>数据通过上面的办法收集完了,那么如何及时、高效的上传到后端,给运营分析、处理呢?</p><p>App 运行期间用户会点击非常多的数据,如果实时上传的话对于网络的利用率较低,所以需要考虑一个机制去控制用户产生的埋点数据的上传。</p><p>思路是这样的。对外部暴露出一个接口,用来将产生的数据往数据中心存储。用户产生的数据会先保存到 <code>AppMonitor</code> 的内存中去,设置一个临界值(memoryEventMax = 50),如果存储的值达到设置的临界值 memoryEventMax,那么将内存中的数据写入文件系统,以 zip 的形式保存下来,然后上传到埋点系统。如果没有达到临界值但是存在一些 App 状态切换的情况,这时候需要及时保存数据到持久化。当下次打开 App 就去从本地持久化的地方读取是否有未上传的数据,如果有就上传日志信息,成功后删除本地的日志压缩包。</p><p>App 应用状态的切换策略如下:</p><ul><li>didFinishLaunchWithOptions:内存日志信息写入硬盘</li><li>didBecomeActive:上传</li><li>willTerimate:内存日志信息写入硬盘</li><li>didEnterBackground:内存日志信息写入硬盘</li></ul><p>下面的代码是 App 埋点数据的保存与上传</p><pre><code class="objective-c">// 将App日志信息写入到内存中。当内存中的数量到达一定规模(超过设置的内存中存储的数量)的时候就将内存中的日志存储到文件信息中 - (void)joinEvent:(NSDictionary *)dictionary { if (dictionary) { NSDictionary *tmp = [self createDicWithEvent:dictionary]; if (!s_memoryArray) { s_memoryArray = [NSMutableArray array]; } [s_memoryArray addObject:tmp]; if ([s_memoryArray count] &gt;= s_flushNum) { [self writeEventLogsInFilesCompletion:^{ [self startUploadLogFile]; }]; } } } // 外界调用的数据传递入口(App埋点统计) - (void)traceEvent:(AMStatisticEvent *)event { // 线程锁,防止多处调用产生并发问题 @synchronized (self) { if (event &amp;&amp; event.userInfo) { [self joinEvent:event.userInfo]; } } } // 将内存中的数据写入到文件中,持久化存储 - (void)writeEventLogsInFilesCompletion:(void(^)(void))completionBlock { NSArray *tmp = nil; @synchronized (self) { tmp = s_memoryArray; s_memoryArray = nil; } if (tmp) { __weak typeof(self) weakSelf = self; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ NSString *jsonFilePath = [weakSelf createTraceJsonFile]; if ([weakSelf writeArr:tmp toFilePath:jsonFilePath]) { NSString *zipedFilePath = [weakSelf zipJsonFile:jsonFilePath]; if (zipedFilePath) { [AppMonotior clearCacheFile:jsonFilePath]; if (completionBlock) { completionBlock(); } } } }); } } // 从App埋点统计压缩包文件夹中的每个压缩包文件上传服务端,成功后就删除本地的日志压缩包 - (void)startUploadLogFile { NSArray *fList = [self listFilesAtPath:[self eventJsonPath]]; if (!fList || [fList count] == 0) { return; } [fList enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) { if (![obj hasSuffix:@&quot;.zip&quot;]) { return; } NSString *zipedPath = obj; unsigned long long fileSize = [[[NSFileManager defaultManager] attributesOfItemAtPath:zipedPath error:nil] fileSize]; if (!fileSize || fileSize &lt; 1) { return; } // 调用接口上传埋点数据 [self uploadZipFileWithPath:zipedPath completion:^(NSString *completionResult) { if ([completionResult isEqual:@&quot;OK&quot;]) { [AppMonotior clearCacheFile:zipedPath]; } }]; }]; }</code></pre><p>其实 App 内部的数据上报有很多场景,比如 <a href="https://link.segmentfault.com/?enc=JYeu4X%2FMxuWtSwfiFjW8Xw%3D%3D.AIKH%2BjScLXc5bZ4WvluIQ%2BZA5UTO3JgGKsMg2I0djmRFKzOz1UdR7%2Bj74o1%2Fd6dxDYj2H7kFwcUqIyuuwKUcCCGWHon5TpIys0W6Ti2MUR8CnI%2BZeAjzGh1aZpIDwRwR" rel="nofollow">APM 监控</a> 数据的上报、埋点数据的上报、业务线的数据上报等等,所以可以设计为<a href="https://link.segmentfault.com/?enc=21Q%2B1sK4g731gOZYxlbNhg%3D%3D.RkKAcWkjuffVq64STxH7ri7h%2FnqOqrLx0HdVoNV%2BrVmKeBhFHEI2PmTjEPGRnEDPy0PsjybIoNckLQpm4ook1Tuwc5Q%2B2uOZktY%2B2Cc71xoAmbPbZXpAP4vsfSHL%2BfiO" rel="nofollow">一个通用、可配置的数据上报 SDK</a>。</p><h3>11. 总结</h3><p>使用的时候就是在 hook 系统事件的时候,去调用统计页面上传数据</p><pre><code class="objective-c">//UIViewController [UserTrackDataCenter openPage:[self getPageUrl:className] fromPage:refer]; // 页面出现 [UserTrackDataCenter leavePage:[self getPageUrl:className] spendTime:[self p_calculationTimeSpend]]; //页面消失</code></pre><p><img width="320" height="569" src="/img/bVbqVjO" alt="绑定页面唯一标识与功能描述的对应关系" title="绑定页面唯一标识与功能描述的对应关系"></p><p>总结下来关键步骤:</p><ol><li>hook 系统的各种事件(UIResponder、UITableView、UICollectionView代理事件、UIControl事件、UITapGestureRecognizers)、hook 应用程序、控制器生命周期。在做本来的逻辑之前添加额外的监控代码</li><li>对于点击的元素按照视图树生成对应的唯一标识 <code>addCartButton.GoodsView.GoodsViewController.**App</code> 的 md5 值</li><li>在业务开发完毕,进入埋点的编辑模式,将 md5 和关键的页面的关键事件(运营、产品想统计的关键模块:App层级、业务模块、关键页面、关键操作)给绑定起来。比如 <code>addCartButton.GoodsView.GoodsViewController.**App</code> 对应了 <code>**App-商城模块-商品详情页-加入购物车功能</code>。</li><li>将所需要的数据存储下来</li><li>设计机制等到合适的时机去上传数据</li></ol><h2>四. 演示一个完整的埋点上报流程</h2><p>埋伏模块分为2个pod组件库,<code>UserAnalysis</code> 负责拦截系统事件,拿到埋点数据。<code>AppMonitor</code> 负责收集埋点数据,本地持久化或内存储存,等到合适时机去上传埋点数据。</p><ol><li>通过接口获取数据,给对应的 view 的 <code>accessibilityIdentifier</code> 属性绑定埋点数据 <br><img width="723" height="527" src="/img/bVbqVjQ" alt="接口拿到的数据" title="接口拿到的数据"><br><img width="723" height="569" src="/img/bVbqVjP" alt="绑定埋点数据到view" title="绑定埋点数据到view"></li><li>hook 系统事件,点击拿到 view,获取 <code>accessibilityIdentifier</code> 属性值<br><img width="723" height="414" src="/img/bVbqVka" alt="hook系统事件获取accessibilityIdentifier" title="hook系统事件获取accessibilityIdentifier"></li><li>将数据向的数据中心发送,数据中心处理数据(埋点数据结合App基础信息,图上 <code>UserTrackDataCenter</code> 对象)。根据情况将数据存储到内存或者本地,等到合适的时机去上传<br><img width="723" height="426" src="/img/bVbqVj9" alt="拦截系统事件后将数据交给数据中心处理" title="拦截系统事件后将数据交给数据中心处理"></li></ol>

2019/4/2
阅读更多

20张图的保姆级教程,记录使用Verdaccio在Ubuntu服务器上搭建Npm私服

<blockquote>某些情况下,我们的一些npm包,需要发布到npm上,但是,又不太适合设置成公开的。尽管npm提供了私密包的服务,但是要收钱的,因此,Verdaccio就应运而生了</blockquote><h2>什么是Verdaccio</h2><p>简单来说,<strong><a href="https://link.segmentfault.com/?enc=IrT329ynj0eR8PSbj2IS1A%3D%3D.J5BCVlWcST%2FfECDENIUlF%2BOL4qUWPkmEFvs6cEnF0uDtdHWRq%2Fyaxl76hoe%2BwJw99R4S5oU%2FJlAB8A%2FSKyuzcA%3D%3D" rel="nofollow">Verdaccio</a> 是一个轻量级、开源的私有 npm 仓库管理器</strong>,就是“自己搭建的 npm 私服”。</p><p>核心作用如下:</p><ol><li>替代公共 npm 仓库:你可以把公司内部的私有包、不想公开的代码包发布到这个私服上,只有团队内部能访问;</li><li>可灵活管控权限配置(比如谁能发布 / 下载包)、离线使用,解决公共 npm 访问不稳定、私有代码泄露的问题。</li></ol><p>Verdaccio本质是Node.js编写的轻量服务,部署简单,不用依赖复杂的数据库,开箱即用,是中小型团队搭建私有 npm仓库的首选。</p><p><img width="723" height="524" src="/img/bVdnlGe" alt="" title=""></p><p>官网:<a href="https://link.segmentfault.com/?enc=F%2FDkvwvyAmxFd4wjfE7AYQ%3D%3D.SqkezB7A%2FgacVYI%2BQz1xY1YP5A36WerAb204MI0ZQXY%3D" rel="nofollow">https://www.verdaccio.org/</a></p><h2>搭建记录</h2><h3>乌班图22和node20版本</h3><p>首先,笔者的服务器是乌班图22,同时node也有是20版本,如下</p><p><img width="723" height="336" src="/img/bVdnlGf" alt="" title=""></p><p>笔者查询了一下,node20版本适合6版本的Verdaccio,就直接下载最新版本安装了</p><h3>全局安装Verdaccio</h3><p>Ubuntu下加--unsafe-perm避免权限报错</p><pre><code class="bash">npm install -g verdaccio --unsafe-perm</code></pre><p>然后,查看版本号</p><pre><code class="bash">verdaccio -v</code></pre><p><img width="723" height="343" src="/img/bVdnlGg" alt="" title=""></p><h3>创建Verdaccio工作目录,并授权</h3><pre><code class="bash"># 创建verdaccio工作目录 mkdir -p /opt/verdaccio/{conf,storage,plugins} # 授权操作权限 chmod -R 775 /opt/verdaccio</code></pre><p><img width="723" height="230" src="/img/bVdnlGh" alt="" title=""></p><h3>创建Verdaccio默认配置文件并且编辑</h3><pre><code class="bash"># 进入对应目录 cd /opt/verdaccio/conf/ # 创建配置文件 touch config.yaml # 查看一下 ls</code></pre><p><img width="723" height="212" src="/img/bVdnlGi" alt="" title=""></p><p>然后写入配置</p><pre><code class="bash">cat &gt; /opt/verdaccio/conf/config.yaml &lt;&lt; 'EOF' # Verdaccio核心配置 storage: /opt/verdaccio/storage plugins: /opt/verdaccio/plugins # 日志配置 logs: - { type: stdout, format: pretty, level: http } # 安全配置 security: api: legacy: false jwt: sign: expiresIn: 29d web: sign: expiresIn: 7d # 认证配置(密码文件自动生成) auth: htpasswd: file: /opt/verdaccio/conf/htpasswd max_users: 100 # 上游源,当自己的npm没这个包的时候,往上游找 uplinks: npmjs: url: https://registry.npmmirror.com/ # 淘宝源 # url: https://registry.npmjs.org/ # 官方源 cache: true # 包权限规则 packages: '@*/*': access: $all publish: $authenticated proxy: npmjs '**': access: $all publish: $authenticated proxy: npmjs # 监听所有IP,允许外网访问 listen: 0.0.0.0:4873 # WebUI 配置 web: title: 私有NPM仓库 EOF</code></pre><p><img width="723" height="683" src="/img/bVdnlGj" alt="" title=""></p><p>顺手给点权限</p><p><img width="723" height="210" src="/img/bVdnlGk" alt="" title=""></p><h3>启动Verdaccio</h3><pre><code class="bash">verdaccio --config /opt/verdaccio/conf/config.yaml</code></pre><p><img width="723" height="223" src="/img/bVdnlGl" alt="" title=""></p><p>输出日志解读如下</p><table><thead><tr><th>日志内容</th><th>含义</th><th>是否需要处理</th></tr></thead><tbody><tr><td><code>root 权限警告</code></td><td>提示不要用 root 运行(安全建议)</td><td>可选处理(不影响功能)</td></tr><tr><td><code>logs 配置已废弃</code></td><td>6.x 版本把 <code>logs</code> 字段改名为 <code>log</code></td><td>可选修改(不影响启动)</td></tr><tr><td><code>config file 加载成功</code></td><td>配置文件识别正常</td><td>✅ 无需处理</td></tr><tr><td><code>http address - http://0.0.0.0:4873/</code></td><td>服务监听在 4873 端口</td><td>✅ 启动成功</td></tr></tbody></table><h3>防火墙放开4873端口</h3><p>注意,如果是云服务器,也要在安全组里面放开4873端口</p><pre><code class="bash">ufw allow 4873/tcp ufw status</code></pre><p><img width="723" height="166" src="/img/bVdnlGm" alt="" title=""></p><h3>先通过ip端口方式访问看看</h3><p>果然是能访问到了,只不过现在仓库是空的</p><p><img width="723" height="338" src="/img/bVdnlGn" alt="" title=""></p><h3>配置https证书</h3><p>首先,自然是买了云服务器,就要买对应的https证书,笔者的证书买过了,如下</p><pre><code class="bash">root@iv-ydy912e3nkay8n6x7ufo:/etc/nginx/certs# ls ashuai.site.key ashuai.site.pem</code></pre><p>然后到对应目录,修改config.yaml文件,主要是如下修改</p><pre><code class="yaml"># 配置 HTTPS 监听 4873 端口 listen: - https://0.0.0.0:4873 # HTTPS 证书配置(用自已有的证书路径) https: key: /etc/nginx/certs/ashuai.site.key # 私钥 cert: /etc/nginx/certs/ashuai.site.pem # 公钥 # 公共 URL(必填,末尾带端口和斜杠) public_url: https://ashuai.site:4873/</code></pre><p>完整配置</p><pre><code class="yaml"># Verdaccio核心配置 storage: /opt/verdaccio/storage plugins: /opt/verdaccio/plugins # 日志配置 log: - { type: stdout, format: pretty, level: http } # 安全配置 security: api: legacy: false jwt: sign: expiresIn: 29d web: sign: expiresIn: 7d # 认证配置(密码文件自动生成) auth: htpasswd: file: /opt/verdaccio/conf/htpasswd max_users: 100 # 上游源,当自己的npm没这个包的时候,往上游找 uplinks: npmjs: url: https://registry.npmmirror.com/ # 淘宝源 # url: https://registry.npmjs.org/ # 官方源 cache: true # 包权限规则 packages: '@*/*': access: $all publish: $authenticated proxy: npmjs '**': access: $all publish: $authenticated proxy: npmjs # 配置 HTTPS 监听 4873 端口 listen: - https://0.0.0.0:4873 # HTTPS 证书配置(用自已有的证书路径) https: key: /etc/nginx/certs/ashuai.site.key # 私钥 cert: /etc/nginx/certs/ashuai.site.pem # 公钥 # 公共 URL(必填,末尾带端口和斜杠) public_url: https://ashuai.site:4873/ # WebUI 配置 web: title: 私有NPM仓库</code></pre><p>注意,如果是普通用户,也要授权一下,笔者是root用户,无妨</p><pre><code class="bash">chmod 644 /etc/nginx/certs/ashuai.site.key chmod 644 /etc/nginx/certs/ashuai.site.pem</code></pre><h3>用https的方式进行访问</h3><p>先停掉原先的服务</p><p><img width="723" height="347" src="/img/bVdnlGo" alt="" title=""></p><p>然后,用pm2进行管理私服npm(强烈推荐)</p><p>这里使用pm2启动私服npm(顺手命名为private-npm)</p><pre><code class="bash">pm2 start verdaccio --name &quot;private-npm&quot; -- --config /opt/verdaccio/conf/config.yaml</code></pre><p>然后查看一下状态</p><pre><code class="bash">pm2 list</code></pre><p>如下图</p><p><img width="723" height="214" src="/img/bVdnlGp" alt="" title=""></p><blockquote>当然,大家也可以设置为开机自启动,这里不赘述</blockquote><p>然后,就可以通过域名+端口的形式进行访问了</p><p><img width="723" height="497" src="/img/bVdnlGq" alt="" title=""></p><p>至此,私服npm就搭建成功了(当然,目前还没有包)</p><p>接下来,我们简单演示一下使用</p><h2>私服npm创建用户名和密码,可用于公司同事用户登录</h2><p>我们知道npm都有对应的账号,所以,我们需要在服务器上,创建对应用户名和密码</p><p>首先,安装工具apache2-utils</p><blockquote>Apache 提供的一个用于管理 <code>.htpasswd</code> 用户认证文件的工具(常被 Verdaccio、Nginx 等借用)</blockquote><pre><code class="bash">sudo apt update sudo apt install apache2-utils</code></pre><p>创建新用户,假设名字叫做admin</p><pre><code>sudo htpasswd -B -C 10 -c /opt/verdaccio/conf/htpasswd admin </code></pre><p>系统会提示我们输入并确认密码,之后就会生成 <code>/opt/verdaccio/conf/htpasswd</code> 文件。</p><p><strong>这个时候,用户名和密码都有了,我们后续就可以登录了</strong></p><pre><code class="bash">root@iv-ydy912e3nkay8n6x7ufo:/opt/verdaccio/conf# ls config.yaml htpasswd</code></pre><p>顺手查看一下htpasswd,输出安装路径</p><pre><code class="bash">root@iv-ydy912e3nkay8n6x7ufo:~# which htpasswd /usr/bin/htpasswd</code></pre><h3>使用nrm管理源,并登录</h3><p>这里笔者建议,使用nrm管理一下源,如下,全局安装一下</p><p><img width="723" height="455" src="/img/bVdnlGr" alt="" title=""></p><p>添加源自己的私有源,起个名字,叫做self-npm</p><pre><code class="bash">C:\Users\lss13&gt;nrm add self-npm https://ashuai.site:4873/ SUCCESS Add registry self-npm success, run nrm use self-npm command to use self-npm registry.</code></pre><p>使用自己的源</p><pre><code class="bash">C:\Users\lss13&gt;nrm use self-npm SUCCESS The registry has been changed to 'self-npm'.</code></pre><p>使用服务器上,创建的用户名和密码,登录自己的源,再查看当前登录的是谁</p><p><img width="723" height="412" src="/img/bVdnlGs" alt="" title=""></p><h3>在自己的源里面发布一个测试包</h3><p>因为,我们先前已经登录过了,现在只需要创建一个包,并直接发布到私服npm上即可</p><p>创建如下</p><p><img width="723" height="392" src="/img/bVdnlGt" alt="" title=""></p><p>然后,发包</p><p><img width="723" height="346" src="/img/bVdnlGu" alt="" title=""></p><blockquote>当然,我们可以在package.json里面写一些我们的信息啥的,不赘述</blockquote><p>由上图可以看到发布成功了,接下来,我们到服务器上看看</p><p><img width="723" height="272" src="/img/bVdnlGv" alt="" title=""></p><p>到目前为止,我们发布成功了</p><h3>再创建一个项目,下载使用我们刚刚发布的包</h3><p>下载</p><p><img width="723" height="763" src="/img/bVdnlGw" alt="" title=""></p><p>打开node\_modules文件夹看看,有的</p><p><img width="723" height="213" src="/img/bVdnlGx" alt="" title=""></p><p>至此,基本搭建完成、可正常发布公司私有包,下载公司私有包.</p><p>剩下的,就是一些自由的设置操作了,当然,私服都是在内网,笔者为了给大家呈现效果,特地部署在公网上了,后续会关掉</p><p>收益......</p><blockquote>A good memory is better than a bad pen. Record it ...</blockquote>

2025/12/13
阅读更多

一次看懂 C# TimeSpan:时间差操作的完整指南

<h3>简介</h3><p><code>TimeSpan</code> 是 <code>.NET</code> 中用于表示时间间隔或持续时间的重要结构体。它提供了丰富的方法和属性来处理时间跨度,从几毫秒到几百万天都可以精确表示。</p><h3>概念与特性</h3><p><code>TimeSpan</code> 表示一个时间间隔(时间段),而不是具体的时间点。</p><table><thead><tr><th>特性</th><th>说明</th></tr></thead><tbody><tr><td>命名空间</td><td><code>System</code></td></tr><tr><td>结构类型</td><td><code>struct</code>(值类型)</td></tr><tr><td>表示范围</td><td>约 ±10,675,199 天(≈29,000 年)</td></tr><tr><td>精度</td><td>1 Tick = 100 纳秒</td></tr><tr><td>单位表示</td><td>可用天、小时、分钟、秒、毫秒、Ticks</td></tr></tbody></table><p>与 <code>DateTime</code> 区别:</p><ul><li><code>DateTime</code> 表示一个具体的时间点(例如:<code>2025-09-18 08:00</code>)。</li><li><code>TimeSpan</code> 表示一个时间间隔(例如:2 小时 30 分钟)。</li></ul><h3>创建 TimeSpan</h3><h4>构造函数</h4><pre><code class="csharp">// 构造函数:TimeSpan(days, hours, minutes, seconds) var span1 = new TimeSpan(1, 2, 30, 0); // 1天2小时30分0秒 // 构造函数:TimeSpan(hours, minutes, seconds) var span2 = new TimeSpan(2, 30, 0); // 2小时30分</code></pre><h4>静态工厂方法</h4><p>推荐使用静态方法,可读性更高:</p><pre><code class="csharp">TimeSpan ts1 = TimeSpan.FromDays(1.5); // 1.5天 TimeSpan ts2 = TimeSpan.FromHours(2.5); // 2.5小时 TimeSpan ts3 = TimeSpan.FromMinutes(90); // 90分钟 TimeSpan ts4 = TimeSpan.FromSeconds(45); // 45秒 TimeSpan ts5 = TimeSpan.FromMilliseconds(500); // 500毫秒 TimeSpan ts6 = TimeSpan.FromTicks(5000); // 5000 Ticks</code></pre><h4>解析字符串</h4><pre><code class="csharp">TimeSpan ts = TimeSpan.Parse(&quot;1.02:30:00&quot;); // 格式:d.hh:mm:ss -&gt; 1天2小时30分</code></pre><p>或安全解析:</p><pre><code class="csharp">if (TimeSpan.TryParse(&quot;02:15&quot;, out var result)) Console.WriteLine(result); // 02:15:00</code></pre><h3>常用属性</h3><table><thead><tr><th>属性</th><th>说明</th><th>示例</th></tr></thead><tbody><tr><td><code>Days</code></td><td>总天数(整数部分)</td><td><code>ts.Days</code></td></tr><tr><td><code>Hours</code></td><td>小时(0–23)</td><td><code>ts.Hours</code></td></tr><tr><td><code>Minutes</code></td><td>分钟(0–59)</td><td><code>ts.Minutes</code></td></tr><tr><td><code>Seconds</code></td><td>秒(0–59)</td><td><code>ts.Seconds</code></td></tr><tr><td><code>Milliseconds</code></td><td>毫秒(0–999)</td><td><code>ts.Milliseconds</code></td></tr><tr><td><code>Ticks</code></td><td>以 Tick(100ns)为单位</td><td><code>ts.Ticks</code></td></tr><tr><td><code>TotalDays</code></td><td>总天数(含小数)</td><td><code>ts.TotalDays</code></td></tr><tr><td><code>TotalHours</code></td><td>总小时数(含小数)</td><td><code>ts.TotalHours</code></td></tr><tr><td><code>TotalMinutes</code></td><td>总分钟数(含小数)</td><td><code>ts.TotalMinutes</code></td></tr><tr><td><code>TotalSeconds</code></td><td>总秒数(含小数)</td><td><code>ts.TotalSeconds</code></td></tr><tr><td><code>TotalMilliseconds</code></td><td>总毫秒数</td><td><code>ts.TotalMilliseconds</code></td></tr></tbody></table><p>区别</p><ul><li><code>Days</code>、<code>Hours</code> 等返回整数部分(对应分量)。</li><li><code>TotalDays</code>、<code>TotalHours</code> 等返回完整总量。</li></ul><p>示例:</p><pre><code class="csharp">var ts = new TimeSpan(1, 2, 30, 0); Console.WriteLine(ts.Days); // 1 Console.WriteLine(ts.TotalHours); // 26.5</code></pre><h3>运算操作</h3><h4>加减</h4><pre><code class="csharp">var t1 = TimeSpan.FromHours(3); var t2 = TimeSpan.FromMinutes(30); TimeSpan sum = t1 + t2; // 3:30:00 TimeSpan diff = t1 - t2; // 2:30:00</code></pre><h4>乘除</h4><pre><code class="csharp">TimeSpan doubleTime = TimeSpan.FromMinutes(45) * 2; // 1:30:00 TimeSpan halfTime = TimeSpan.FromHours(4) / 2; // 2:00:00</code></pre><blockquote>C# 11 之前不支持直接乘除,需要用 TimeSpan.FromTicks() 手动计算。<br>(.NET 7 / C# 11 起支持 * / 运算符)</blockquote><h4>比较</h4><pre><code class="csharp">TimeSpan ts1 = TimeSpan.FromHours(2); TimeSpan ts2 = TimeSpan.FromMinutes(120); // 也是2小时 // 比较 bool isEqual = ts1 == ts2; // true bool isNotEqual = ts1 != ts2; // false bool isGreater = ts1 &gt; TimeSpan.FromHours(1); // true bool isLess = ts1 &lt; TimeSpan.FromHours(3); // true // 比较方法 int compareResult = ts1.CompareTo(ts2); // 0 (相等) bool equals = ts1.Equals(ts2); // true</code></pre><h4>取绝对值</h4><pre><code class="csharp">var negative = TimeSpan.FromHours(-5); var positive = negative.Duration(); // 05:00:00</code></pre><h4>取负值</h4><pre><code class="csharp">var neg = -TimeSpan.FromMinutes(30); // -00:30:00</code></pre><h3>与 DateTime 配合</h3><p><code>TimeSpan</code> 常用于计算两个时间点的差值:</p><pre><code class="csharp">DateTime start = DateTime.Now; // 模拟一些操作 System.Threading.Thread.Sleep(1500); DateTime end = DateTime.Now; TimeSpan elapsed = end - start; Console.WriteLine($&quot;执行耗时: {elapsed.TotalMilliseconds} ms&quot;);</code></pre><p>也可以用来加减时间:</p><pre><code class="csharp">DateTime tomorrow = DateTime.Now + TimeSpan.FromDays(1);</code></pre><h3>格式化输出</h3><h4>默认格式</h4><pre><code class="csharp">Console.WriteLine(ts.ToString()); // &quot;1.02:30:00&quot; (d.hh:mm:ss)</code></pre><h4>标准格式</h4><pre><code class="csharp">Console.WriteLine(ts.ToString(&quot;c&quot;)); // 同上 (常数格式) Console.WriteLine(ts.ToString(&quot;g&quot;)); // 1:2:30:45.5 (常规短格式) Console.WriteLine(ts.ToString(&quot;G&quot;)); // 1:02:30:45.5000000 (常规长格式)</code></pre><h4>自定义格式</h4><p><code>ToString</code> 支持标准和自定义格式:</p><pre><code class="csharp">var ts = new TimeSpan(1, 2, 30, 45, 500); Console.WriteLine(ts.ToString(@&quot;hh\:mm\:ss&quot;)); // 02:30:45 Console.WriteLine(ts.ToString(@&quot;d\.hh\:mm\:ss\.fff&quot;)); // 1.02:30:45.500</code></pre><blockquote>需要转义 : 和 .</blockquote><h3>常用静态字段</h3><table><thead><tr><th>字段</th><th>说明</th></tr></thead><tbody><tr><td><code>TimeSpan.Zero</code></td><td>表示 0</td></tr><tr><td><code>TimeSpan.MaxValue</code></td><td>最大可表示值</td></tr><tr><td><code>TimeSpan.MinValue</code></td><td>最小可表示值</td></tr></tbody></table><h3>典型使用场景</h3><h4>定时任务/超时控制</h4><pre><code class="csharp">var timeout = TimeSpan.FromSeconds(30); var cts = new CancellationTokenSource(timeout);</code></pre><h4>统计程序耗时</h4><pre><code class="csharp">var watch = System.Diagnostics.Stopwatch.StartNew(); // do work watch.Stop(); Console.WriteLine(watch.Elapsed); // TimeSpan</code></pre><h3>实际应用示例</h3><pre><code class="csharp">using System; class Program { static void Main() { DateTime start = DateTime.Now; // 模拟任务 System.Threading.Thread.Sleep(1200); DateTime end = DateTime.Now; TimeSpan span = end - start; Console.WriteLine($&quot;耗时: {span.TotalMilliseconds} 毫秒&quot;); Console.WriteLine($&quot;格式化: {span.ToString(@&quot;hh\:mm\:ss\.fff&quot;)}&quot;); Console.WriteLine($&quot;天: {span.Days}, 小时: {span.Hours}, 总小时: {span.TotalHours:F2}&quot;); } }</code></pre><p>输出示例:</p><pre><code>耗时: 1203.45 毫秒 格式化: 00:00:01.203 天: 0, 小时: 0, 总小时: 0.00</code></pre><h3>高级用法</h3><h4>自定义 TimeSpan 扩展方</h4><pre><code class="csharp">public static class TimeSpanExtensions { public static string ToHumanReadableString(this TimeSpan timeSpan) { if (timeSpan.TotalDays &gt;= 1) return $&quot;{(int)timeSpan.TotalDays} 天 {timeSpan.Hours} 小时&quot;; if (timeSpan.TotalHours &gt;= 1) return $&quot;{(int)timeSpan.TotalHours} 小时 {timeSpan.Minutes} 分钟&quot;; if (timeSpan.TotalMinutes &gt;= 1) return $&quot;{(int)timeSpan.TotalMinutes} 分钟 {timeSpan.Seconds} 秒&quot;; if (timeSpan.TotalSeconds &gt;= 1) return $&quot;{(int)timeSpan.TotalSeconds} 秒&quot;; return $&quot;{timeSpan.Milliseconds} 毫秒&quot;; } } // 使用扩展方法 TimeSpan ts = TimeSpan.FromHours(2.5); Console.WriteLine(ts.ToHumanReadableString()); // 输出: 2 小时 30 分钟</code></pre><h3>注意事项和最佳实践</h3><ul><li>不可变性:<code>TimeSpan</code> 是值类型且不可变,所有操作都返回新的 <code>TimeSpan</code> 实例</li><li>精度考虑:<code>TimeSpan</code> 使用 <code>ticks</code>(100 纳秒)作为内部存储,提供高精度但需要注意浮点运算的精度问题</li><li>文化敏感性:解析和格式化 <code>TimeSpan</code> 时,注意当前线程的文化设置</li><li>性能考虑:对于高性能场景,考虑使用 <code>Stopwatch</code> 而不是 <code>DateTime</code> 减法来计算时间间隔</li></ul>

2025/12/8
阅读更多

React Server Components 中的严重安全漏洞

<blockquote>本文翻译自<a href="https://link.segmentfault.com/?enc=iiDC%2BH%2B9LzVvcAEgDkOQFg%3D%3D.5FXBGGj%2FHwnlL5V9OjZFACwWDzZufvsfzCnS3zpYM2pUjFzpSUWIfpSLs1WWSjmjXKzhVgN4eLZQNKlpsdoG4bMlQVeksHdmSRK61sGzLJoj8BvkN0DwcvEcD3xZsupH" rel="nofollow">原文地址</a>。</blockquote><h2>React Server Components 中的严重安全漏洞</h2><p>2025年12月3日,由 <a href="https://link.segmentfault.com/?enc=MW%2BFSti7jRiv4WlVbvOqWw%3D%3D.aTYgd9SwAv6f6nEtUnkQ35OTdPIuXp0cf2Vs3F1iFAYTv3IZVLhs72P9DTfmti6D" rel="nofollow">The React Team</a> 发布</p><p>React Server Components 中存在一个未经身份验证的远程代码执行漏洞。</p><p>我们建议立即升级。</p><p>11月29日,Lachlan Davidson 报告了 React 中的一个安全漏洞,该漏洞允许未经身份验证的远程代码执行,通过利用 React 解码发送到 React Server Function 端点的有效负载的方式中的缺陷来实现。</p><p>即使您的应用程序没有实现任何 React Server Function 端点,如果您的应用程序支持 React Server Components,它仍然可能容易受到攻击。</p><p>此漏洞已披露为 <a href="https://link.segmentfault.com/?enc=rR1X335QcPxLPtcdfa15OA%3D%3D.CMoUyGAz9xxACVfHir6KGfRP%2FfbVatU9fGgFusxbwjsWrNJnq4DxCPjvcdV7cJsd" rel="nofollow">CVE-2025-55182</a>,CVSS 评分为 10.0。</p><p>该漏洞存在于以下包的 19.0、19.1.0、19.1.1 和 19.2.0 版本中:</p><ul><li><a href="https://link.segmentfault.com/?enc=gKBInUVBwpV4tNXnyRLedw%3D%3D.m61%2FG%2FrSpFJhwsMWjK0%2B3Mieij%2FqRZnNNphl0Nmgie6ovUmE3naXZD7cIQvpcdI85sXBb5%2F%2Fvtq%2FHM7FOdFFmQ%3D%3D" rel="nofollow">react-server-dom-webpack</a></li><li><a href="https://link.segmentfault.com/?enc=7ADUM9vOkCjmYC6VtuqNuQ%3D%3D.Rtt5Pz%2BLLfgbiecFrX6oh3jQAdNUCOZi%2B5qOdL36Uk4dstvGiI%2BCdpNj%2BcS8YzsE7xSiIjoF2fgyZzJTeoPJuw%3D%3D" rel="nofollow">react-server-dom-parcel</a></li><li><a href="https://link.segmentfault.com/?enc=xjyBp5ZbgdcTEgh1G%2F0c4A%3D%3D.CwaaqnQFSNHUTJVVCtIqT9VUFQo7dXJmE4lJthH%2BAdcI4OZC2EWadCqmhZ%2Bk3ixV63xe%2FvwuyaihTMY6Im3TxVXXwy8axsILUsgaTy6pd%2FE%3D" rel="nofollow">react-server-dom-turbopack</a></li></ul><h3>需要立即采取行动</h3><p>修复已在 <a href="https://link.segmentfault.com/?enc=9XjWciyAKW7vKnicM1y2Eg%3D%3D.slZJBgstjZbVlFTIY%2BhsoHzapYX68N0sjkkarNDq4JEYFwcafyd2EwBzOk50%2BVzrZdG9qL%2BPRF8%2FVGeITOJUnw%3D%3D" rel="nofollow">19.0.1</a>、<a href="https://link.segmentfault.com/?enc=1Fb1La%2FXSwEi9z4PJgChBA%3D%3D.RKgcWHcFJ2UllzHmQzUombacIki1p4x8YenmERgX6ll9cFKSlVWJ11iZPGPKyaMaxNJbCf1S3KtLKS3wG4Qq1g%3D%3D" rel="nofollow">19.1.2</a> 和 <a href="https://link.segmentfault.com/?enc=jphk761i2jp8UoDM7sakLw%3D%3D.xbw3AKe2WE17WRfDpFFvkl7R7igFFT5QF8zqDnJww7pKVwh7H2SAFnyz9RcbRfNCgHJXPRjZwOBwW0HV41nk%2BA%3D%3D" rel="nofollow">19.2.1</a> 版本中引入。如果您正在使用上述任何包,请立即升级到任何已修复的版本。</p><p>如果您的应用程序的 React 代码不使用服务器,则您的应用程序不受此漏洞影响。如果您的应用程序不使用支持 React Server Components 的框架、打包工具或打包工具插件,则您的应用程序不受此漏洞影响。</p><h3>受影响的框架和打包工具</h3><p>一些 React 框架和打包工具依赖、具有对等依赖关系或包含了易受攻击的 React 包。以下 React 框架和打包工具受到影响:<a href="https://link.segmentfault.com/?enc=dAC05tHdDudMgb4i7UCDSQ%3D%3D.imDgVi%2BYw2O2ZcCXe2NxOgr7LrEMWjFoysN41QpF3qof9LfAsnm4o14mJ2cwSuu6" rel="nofollow">next</a>、<a href="https://link.segmentfault.com/?enc=l2%2FoNp8rBpp9YTQYpvTfiQ%3D%3D.gVgXRhmNBSWqJc9dkuuWv3Ii18tM8lgQAeaEBMGDZgjNLfP7SqanGxqYqPEFFzhP" rel="nofollow">react-router</a>、<a href="https://link.segmentfault.com/?enc=Wb2YeJUfrnSwGEX3yJ3tfQ%3D%3D.GuV5U6alYTatGwVt%2F9L6KcUo3el1G80Ys84jFgiMCc4MB5NKTPx%2F6ohyVo95zC2T" rel="nofollow">waku</a>、<a href="https://link.segmentfault.com/?enc=9eSf0vo87pgc9PLayKWggw%3D%3D.xZrSR92%2BF4fPo1VSkT1iah8jUGo8re%2Bsc3XM81QIsXlzBf3%2BVS3xFkvUZJYKFLGJ" rel="nofollow">@parcel/rsc</a>、<a href="https://link.segmentfault.com/?enc=uLc6Y5xpyJVA1bnDRUJfuw%3D%3D.NNP%2BcYkWNumbOVCXkHC5TKDgfWYM0QlgymAzOUQRCTK4cz%2BgVzMsyfoy8iFWVlyFkjHw9OHhLRajFAM9DbcC4w%3D%3D" rel="nofollow">@vitejs/plugin-rsc</a> 和 <a href="https://link.segmentfault.com/?enc=gchGLsEHQwS9J4aSjFnF1A%3D%3D.mDXoj83TawWWlwSFTA1fDGay5H2hMoGfD89w0%2FvnutUJzxhWmwPPQQcC7iizwZK9" rel="nofollow">rwsdk</a>。</p><p>我们将在升级说明可用时更新此文章。</p><h3>托管服务提供商的缓解措施</h3><p>我们已经与多家托管服务提供商合作,应用临时缓解措施。</p><p>您不应依赖这些措施来保护您的应用程序,仍应立即更新。</p><h3>漏洞概述</h3><p><a href="https://link.segmentfault.com/?enc=kL12WyTp87irtyOYqigEFg%3D%3D.B8kYY2mMU1Uqm0LN0oee1UnF%2FPbZruq%2FasmbHwYeYFeDygiVKGqdIArPjQovaAP6u7bwIU1d0LrWmsZCAGDU%2FA%3D%3D" rel="nofollow">React Server Functions</a> 允许客户端调用服务器上的函数。React 提供集成点和工具,框架和打包工具使用这些工具来帮助 React 代码在客户端和服务器上运行。React 将客户端的请求转换为 HTTP 请求,然后转发到服务器。在服务器上,React 将 HTTP 请求转换为函数调用,并将所需数据返回给客户端。</p><p>未经身份验证的攻击者可以构造恶意 HTTP 请求到任何 Server Function 端点,当 React 反序列化时,可以在服务器上实现远程代码执行。漏洞的进一步详细信息将在修复完成部署后提供。</p><h3>更新说明</h3><h4>Next.js</h4><p>所有用户应升级到其发布线中的最新修补版本:</p><pre><code class="shell">npm install next@15.0.5 // for 15.0.x npm install next@15.1.9 // for 15.1.x npm install next@15.2.6 // for 15.2.x npm install next@15.3.6 // for 15.3.x npm install next@15.4.8 // for 15.4.x npm install next@15.5.7 // for 15.5.x npm install next@16.0.7 // for 16.0.x</code></pre><p>如果您使用的是 Next.js 14.3.0-canary.77 或更新的 canary 版本,请降级到最新的稳定 14.x 版本:</p><pre><code class="shell">npm install next@14</code></pre><p>有关更多信息,请参阅 <a href="https://link.segmentfault.com/?enc=mzlOUDZujWiGUDaL7L0A9g%3D%3D.FnPF7X1%2FEefdDIjBW3xRo7%2BSHZMaJqSlX37jh3hOfA2djo027dD9966kojDVh7Q2" rel="nofollow">Next.js changelog</a>。</p><h4>React Router</h4><p>如果您正在使用 React Router 的不稳定 RSC API,如果存在以下 package.json 依赖项,您应该升级它们:</p><pre><code class="shell">npm install react@latest npm install react-dom@latest npm install react-server-dom-parcel@latest npm install react-server-dom-webpack@latest npm install @vitejs/plugin-rsc@latest</code></pre><h4>Expo</h4><p>升级到最新的 <code>react-server-dom-webpack</code>:</p><pre><code class="shell">npm install react@latest react-dom@latest react-server-dom-webpack@latest</code></pre><h4>Redwood SDK</h4><p>确保您使用的是 rwsdk&gt;=1.0.0-alpha.0</p><p>对于最新的 beta 版本:</p><pre><code class="shell">npm install rwsdk@latest</code></pre><p>升级到最新的 <code>react-server-dom-webpack</code>:</p><pre><code class="shell">npm install react@latest react-dom@latest react-server-dom-webpack@latest</code></pre><p>有关更多迁移说明,请参阅 <a href="https://link.segmentfault.com/?enc=xnJG5wxGzQfjp%2BpzMOT1eA%3D%3D.aJ%2B8PQi3IKDEwUymB%2Fq2mxe5%2BTp9ruWKTVSMVcuRPDPSr2zoqdoqM%2Fscozgfqr%2FX" rel="nofollow">Redwood docs</a>。</p><h4>Waku</h4><p>升级到最新的 <code>react-server-dom-webpack</code>:</p><pre><code class="shell">npm install react@latest react-dom@latest react-server-dom-webpack@latest waku@latest</code></pre><p>有关更多迁移说明,请参阅 <a href="https://link.segmentfault.com/?enc=TFA30k32zpqr8S79ZZqO9w%3D%3D.XxOBwau9LAKeixXz0VNn3NWYA6CInwn%2BK23YqxO%2BAbAtJyTXjaxu5ptfrFG7yTtp" rel="nofollow">Waku announcement</a>。</p><h4><code>@vitejs/plugin-rsc</code></h4><p>升级到最新的 RSC 插件:</p><pre><code class="shell">npm install react@latest react-dom@latest @vitejs/plugin-rsc@latest</code></pre><h4><code>react-server-dom-parcel</code></h4><p>更新到最新版本:</p><pre><code class="shell">npm install react@latest react-dom@latest react-server-dom-parcel@latest</code></pre><h4><code>react-server-dom-turbopack</code></h4><p>更新到最新版本:</p><pre><code class="shell">npm install react@latest react-dom@latest react-server-dom-turbopack@latest</code></pre><h4>react-server-dom-webpack</h4><p>更新到最新版本:</p><pre><code class="shell">npm install react@latest react-dom@latest react-server-dom-webpack@latest</code></pre><h3>时间线</h3><ul><li>11月29日:Lachlan Davidson 通过 <a href="https://link.segmentfault.com/?enc=M16HepQ0fd%2FIhNLW433pIA%3D%3D.KXbNzuueZOuf4XXK%2BATvcRnoB%2BSaF8Y4tYbFTA%2BXEb8%3D" rel="nofollow">Meta Bug Bounty</a> 报告了安全漏洞。</li><li>11月30日:Meta 安全研究人员确认并开始与 React 团队合作修复。</li><li>12月1日:创建了修复程序,React 团队开始与受影响的托管服务提供商和开源项目合作,验证修复、实施缓解措施并推出修复。</li><li>12月3日:修复程序发布到 npm,并公开披露为 CVE-2025-55182。</li></ul><h3>致谢</h3><p>感谢 <a href="https://link.segmentfault.com/?enc=ktm5%2BGMPTHzV0TWrjBfOjw%3D%3D.AGHp4pZAps0B5RRb%2Bk3tC1l%2BdNrbKaIy4KCuwu6hNcs%3D" rel="nofollow">Lachlan Davidson</a> 发现、报告并帮助修复此漏洞。</p>

2025/12/5
阅读更多

CodeQL对Java项目进行SQL注入审计总结

<h2>一、背景</h2><p>今年在做个AI代码安全审计的项目,代码仓库里十有八九都是Java项目,所以开始研究怎么给Java做代码审计。传统的人工审计,效率低,还容易看花眼。这时候想到了<strong>CodeQL</strong>。把你代码转换成可查询的数据库,然后用像SQL一样的语法去挖漏洞。</p><p>整体思路很简单,就两步:</p><ol><li><strong>用CodeQL扫描</strong>,生成一堆漏洞的报告(JSON格式)。</li><li><strong>拆解和分析这些报告</strong>,判断这个漏洞到底是“确诊”还是“误诊”。</li></ol><h2>二、操作步骤</h2><h3>2.1 CodeQL 扫描</h3><p>简单来说,分三步走:</p><ol><li><p><strong>创建数据库</strong>:这就好比把Java代码这个“原始食材”加工成CodeQL能处理的“半成品”。</p><pre><code class="bash">codeql database create my-java-db --language=java --command=&quot;mvn compile&quot;</code></pre><ul><li><code>my-java-db</code>:给你的数据库起个名儿。</li><li><code>--language=java</code>:声明语言是Java。</li><li><code>--command</code>:告诉CodeQL你用啥编译项目,比如Maven就用<code>mvn compile</code>,Gradle就用<code>gradle build</code>。这一步是关键,CodeQL会通过编译过程来理解整个代码结构。</li></ul></li><li><p><strong>运行查询</strong>:拿着写好的“问题清单”(QL查询脚本),去数据库里找答案。</p><pre><code class="bash">codeql database analyze my-java-db codeql/java/ql/src/Security/ --format=sarif-latest --output=results.sarif</code></pre><ul><li>这里我直接用了CodeQL自带的官方安全查询库 (<code>codeql/java/ql/src/Security/</code>),里面涵盖了SQL注入、SSRF等各种漏洞的检测规则。</li><li>输出格式我选了<code>sarif</code>,这是一种标准格式,很多工具都认。当然你也可以输出成CSV或者JSON。</li></ul></li><li><strong>查看结果</strong>:生成的<code>results.sarif</code>文件里,就藏着所有疑似漏洞的线索。</li></ol><h3>2.2 扫描结果解析</h3><p>这个SARIF/JSON文件,大家刚开始看可能会觉得眼花缭乱。不过我们实际上只要看核心信息:</p><ul><li><strong>漏洞位置</strong>:<code>location</code> 字段会精确告诉你,有问题的代码在哪个文件的第几行第几列。</li><li><strong>漏洞类型</strong>:<code>ruleId</code> 会告诉你它怀疑是啥漏洞,比如 <code>java/sql-injection</code>。</li><li><strong>数据流路径</strong>:这是最核心的部分!<code>codeFlows</code> 里会展示数据是怎么从“源头”(Source,比如用户输入)流到“汇点”(Sink,比如执行SQL语句的方法)的。这是我们接下来审计的重点。</li></ul><hr><h2>三、判断漏洞真假</h2><p>CodeQL报出来的不一定是真漏洞,它只是个“高度可疑”的警报。我们需要去验证它。</p><p>核心思路就四个字:<strong>跟踪数据流</strong>。具体来说,要检查三个地方:</p><ol><li><strong>注入点(Source)有效吗?</strong> 这个数据真的是来自不可信的用户吗?</li><li><strong>执行点(Sink)有效吗?</strong> 这个函数真的能执行危险操作吗?</li><li><strong>中间链路干净吗?</strong> 数据从源头到执行点的路上,有没有被“洗干净”(过滤、编码)?</li></ol><p>我举几个例子来具体讲。</p><h3>3.1 SQL注入常规案例</h3><p><strong>CodeQL报告</strong>:在 <code>UserController.java</code> 的第35行,可能存在SQL注入。</p><p>我们去看看代码:</p><pre><code class="java">// UserController.java public String getUserByName(@RequestParam String name) { String sql = &quot;SELECT * FROM users WHERE name = '&quot; + name + &quot;'&quot;; // Source: 用户控制的name参数 return jdbcTemplate.queryForObject(sql, String.class); // Sink: 执行SQL查询 }</code></pre><p><strong>审计过程</strong>:</p><ol><li><strong>源头</strong>:<code>name</code>参数来自用户HTTP请求,完全可控。有效!</li><li><strong>汇点</strong>:<code>jdbcTemplate.queryForObject</code> 确实会执行SQL语句。有效!</li><li><strong>链路</strong>:数据从 <code>name</code> 直接拼接进 <code>sql</code> 字符串,然后传入汇点。中间<strong>没有任何过滤</strong>!</li></ol><p><strong>结论</strong>:这是个<strong>真漏洞</strong>,实锤的SQL注入。修复方法很简单,用预编译就对了。</p><h3>3.2 MyBatis审计为例</h3><p>MyBbatis的情况比较特殊,它的SQL语句很多写在XML文件里。CodeQL同样能扫描出来。</p><p><strong>关键点</strong>:MyBatis里用 <code>#{}</code> 是预编译,安全的;用 <code>${}</code> 是字符串拼接,危险的!</p><p><strong>CodeQL报告</strong>:在 <code>UserMapper.xml</code> 中发现使用 <code>${}</code>。</p><pre><code class="xml">&lt;!-- UserMapper.xml --&gt; &lt;select id=&quot;getUser&quot; parameterType=&quot;String&quot; resultType=&quot;User&quot;&gt; SELECT * FROM users WHERE name = '${name}' &lt;/select&gt;</code></pre><p><strong>审计过程</strong>:</p><ol><li><strong>源头</strong>:<code>name</code>参数从Java层传入Mapper。</li><li><strong>汇点</strong>:MyBatis框架解析 <code>${name}</code> 时,会直接进行字符串替换。</li><li><strong>链路</strong>:直接拼接。</li></ol><p>所以只要 <code>name</code> 用户可控,就是<strong>真漏洞</strong>。CodeQL能识别出这种模式并报警。</p><h2>四、常见误报案例</h2><p>CodeQL不是神,很多情况它会“过度紧张”。下面是我遇到的三个典型“假警报”,咱们掰开揉碎了分析分析。</p><h3>4.1 只能控制部分参数</h3><p><strong>CodeQL报告</strong>:在 <code>AdminController.java</code> 的第42行,检测到可能存在SQL注入漏洞,数据流显示用户输入参数直接拼接进SQL语句。</p><p>我们来看具体代码:</p><pre><code class="java">public String resetPassword(@RequestParam int userId) { // userId虽然是用户输入,但它被声明为int型 String sql = &quot;UPDATE users SET password='default' WHERE id = &quot; + userId; jdbcTemplate.update(sql); return &quot;密码已重置&quot;; }</code></pre><p><strong>详细审计过程</strong>:</p><ol><li><strong>源头分析</strong>:<code>userId</code> 确实是用户通过HTTP请求传入的参数,从来源上看属于“不可信输入”,这一点CodeQL判断是对的。</li><li><strong>汇点分析</strong>:<code>jdbcTemplate.update(sql)</code> 方法确实会执行传入的SQL字符串,属于SQL注入的典型危险汇点,CodeQL这里的判断也没问题。</li><li><strong>关键链路分析</strong>:问题出在 <code>userId</code> 的数据类型上。它被明确声明为 <code>int</code> 类型,这意味着什么?当用户在HTTP请求里传入参数时,Spring MVC框架会自动进行类型转换。如果用户想传个带SQL注入的字符串,比如 <code>1; DROP TABLE users;--</code>,框架在转换时就会直接报错(类型不匹配),这个恶意字符串根本进不到代码里。最终拼接进SQL的,只能是一个合法的整数。这就从根本上堵死了注入的可能性——攻击者连注入代码的机会都没有,因为参数类型把所有非数字输入都过滤掉了。</li></ol><p>所以<strong>假漏洞</strong>这种情况属于CodeQL在做数据流跟踪时,只关注“数据是否来自用户”以及“是否流向危险操作”,但对Java这种强类型语言的类型约束理解不够深入,没意识到基础数据类型本身就能提供一定的安全保障。</p><h3>4.2 中间链路被清洗</h3><p><strong>CodeQL报告</strong>:在 <code>UserService.java</code> 的第78行,发现用户输入参数未经过滤直接拼接SQL语句,存在SQL注入风险。</p><p>代码片段如下:</p><pre><code class="java">// UserService.java public User findUser(String inputName) { // 调用全局安全工具类进行过滤 String filteredName = SecurityUtils.sanitizeSQL(inputName); String sql = &quot;SELECT * FROM users WHERE username = '&quot; + filteredName + &quot;'&quot;; return jdbcTemplate.queryForObject(sql, User.class); }</code></pre><p><strong>详细审计过程</strong>:</p><ol><li><strong>源头分析</strong>:<code>inputName</code> 是从前端传入的用户输入,确实属于不可信来源,CodeQL的判断正确。</li><li><strong>汇点分析</strong>:<code>jdbcTemplate.queryForObject</code> 执行SQL语句,属于危险汇点,CodeQL这里也没毛病。</li><li><strong>关键链路分析</strong>:CodeQL的警报忽略了中间的 <code>SecurityUtils.sanitizeSQL</code> 方法。这是我们公司自己开发的一个全局安全工具类,里面的 <code>sanitizeSQL</code> 方法做了非常彻底的处理——它会把单引号、双引号、分号、注释符等所有SQL注入常用的特殊字符都进行转义(比如把单引号 <code>'</code> 转成 <code>\'</code>),还会过滤掉 <code>UNION</code>、<code>DROP</code>、<code>EXEC</code> 等危险关键字。也就是说,经过这个方法处理后,<code>filteredName</code> 里已经不存在能构成注入的“弹药”了。但CodeQL的默认规则只认识它内置的那些安全函数(比如Apache Commons Lang里的<code>StringEscapeUtils.escapeSql</code>),对于我们这种自定义的过滤方法,它没办法识别其安全性,所以依然会报警报。</li></ol><p><strong>结论</strong>:<strong>假漏洞</strong>。这种情况需要我们人工介入,去核查中间的过滤函数到底有没有真的起到“净化”作用。如果这个自定义过滤器确实能有效拦截恶意输入,那这个警报就是误报。</p><h3>4.3 无效的Sink</h3><p><strong>CodeQL报告</strong>:在 <code>FileProcessor.java</code> 的第112行,检测到用户控制的URL参数可能导致SSRF(服务器端请求伪造)漏洞。</p><p>我们来看代码:</p><pre><code class="java">public void logFileUrl(@RequestParam String fileUrl) { // 仅将URL记录到日志,未发起任何网络请求 logger.info(&quot;用户请求处理的文件URL:&quot; + fileUrl); // 其他业务逻辑... }</code></pre><p><strong>详细审计过程</strong>:</p><ol><li><strong>源头分析</strong>:<code>fileUrl</code> 是用户传入的参数,完全可控,属于SSRF漏洞的典型源头,CodeQL这一步判断正确。</li><li><strong>汇点分析</strong>:这是问题的核心。CodeQL的SSRF检测规则里,可能把所有“接收字符串并输出”的函数都当成了潜在的危险汇点,但实际上,SSRF的危害在于服务器会根据这个URL发起网络请求(比如访问内部系统、敏感端口等)。而这里的 <code>logger.info</code> 方法仅仅是把字符串写入日志文件,它既不会解析这个URL,也不会发起任何HTTP/HTTPS请求,更不可能去连接内部服务。这个“汇点”根本没有执行危险操作的能力,是个“伪Sink”。</li><li><strong>链路分析</strong>:数据从 <code>fileUrl</code> 直接拼接进日志字符串,中间没有其他处理,但因为最终的“汇点”不具备发起请求的能力,所以整个链路没有安全风险。</li></ol><p><strong>结论</strong>:<strong>假漏洞</strong>。这是由于CodeQL对“危险汇点”的定义可能过于宽泛,把一些看似相关但实际无危害的函数也纳入了监测范围。我们在审计时,一定要确认数据最终流向的函数是否真的能执行对应的危险操作(比如SSRF里的 <code>URL.openConnection</code>、<code>HttpClient.execute</code>,XSS里的 <code>response.getWriter().write</code> 等)。</p><hr><h2>五、总结</h2><p>好了,我们来总结一下用CodeQL审计Java代码SQL注入(其他漏洞也类似)的心法:</p><ol><li><strong>工具是辅助</strong>:CodeQL发现的目标是疑似漏洞,需要你自己去最终判别。</li><li><strong>核心是数据流</strong>:一定要亲手去跟踪 <code>Source -&gt; ... -&gt; Sink</code> 这条线。不要只看头和尾,中间路径的“净化”环节至关重要。</li><li><strong>理解框架特性</strong>:像MyBatis的 <code>#</code> 和 <code>$</code>,Spring的参数绑定,这些框架知识能帮你快速判断漏洞的真伪。</li><li><p><strong>警惕误报三点</strong>:</p><ul><li><strong>类型安全</strong>(如int参数)。</li><li><strong>自定义过滤</strong>(CodeQL不认识你的安全函数)。</li><li><strong>Sink点误判</strong>(数据没流到真正危险的地方)。</li></ul></li></ol><p>通过这种“工具扫描 + 人工研判”的模式,我们就能在复杂的Java项目中,高效、精准地挖出真正的安全漏洞。</p><hr><p>作者:汤青松<br>日期:2025年11月17日<br>微信:songboy8888</p>

2025/11/20
阅读更多

腾讯音乐如何基于 AutoMQ 降低 Kafka 50%+ 成本

<p><img width="723" height="428" src="/img/bVdm7O1" alt="blog联合声明.png" title="blog联合声明.png"></p><blockquote><p>编辑导读:腾讯音乐娱乐集团作为中国在线音乐娱乐服务的领航者,旗下拥有 QQ 音乐、酷狗音乐、酷我音乐和全民 K 歌等众多国民级移动音频应用。每天,这些产品都会产生海量的用户行为和业务数据,为精准推荐、用户增长和商业化等核心业务提供着源源不断的数据驱动力。在这一切背后,一个强大、稳定且高效的 Kafka 流系统是支撑其业务持续创新和发展的关键。</p><p>然而,随着业务的飞速发展,传统的自建 Kafka 集群在运维复杂度和成本控制方面逐渐暴露出其局限性。为了应对日益增长的数据洪流和对成本效益的极致追求,腾讯音乐运维团队毅然开启了对下一代 Kafka 解决方案的探索与实践。</p><p>最终,他们选择了基于云原生架构的 AutoMQ。通过引入这一创新的解决方案,腾讯音乐不仅成功<strong>将成本降低了超过 50%</strong>,更通过其独特的<strong>分区秒级迁移</strong>能力,极大地提升了集群的扩缩容效率,显著降低了原有 Kafka 的运维复杂度和负担。本次技术升级,是继其在数据仓库领域成功实践存算分离之后,在数据流处理领域的又一次重大突破。</p><p>本文将深入解读腾讯音乐娱乐集团是如何利用 AutoMQ 的云原生优势,破解传统 Kafka 的运维难题和成本瓶颈,并最终实现成本与效率双赢的。希望他们在此过程中积累的宝贵经验与最佳实践,能为正在面临相似挑战的您提供有价值的参考和指导!</p><p>作者: 腾讯音乐 高级运维开发工程师 高盛远</p></blockquote><h2>背景介绍</h2><p>腾讯音乐娱乐集团是中国在线音乐娱乐服务开拓者,提供在线音乐和以音乐为核心的社交娱乐两大服务。腾讯音乐娱乐在中国有着广泛的用户基础,拥有目前国内市场知名的移动音频产品:QQ 音乐、酷狗音乐、酷我音乐、全民 K 歌、懒人听书等产品。</p><h2>技术架构</h2><p>对于腾讯音乐这样拥有海量用户的平台而言,高效的数据流动、处理与分析是发挥数据价值、支持业务飞速发展的基石。在整个数据体系中,Kafka 作为核心的数据基础设施,扮演着至关重要的角色。它不仅是连接数据生产和消费的管道,更在可观测体系、数据平台等建设中承担着<strong>上下游解耦以及配合其他组件简化流程</strong>的关键作用。Kafka 的引入,使得数据源和数据应用可以独立地演进,无需关心对方的实现细节。同时,它<strong>支持业务方按需实时消费数据,灵活处理各类旁路处理逻辑</strong>,这对于支撑腾讯音乐多元化的业务场景和高速发展至关重要。</p><p>下图清晰地展示了腾讯音乐基于 AutoMQ 构建的现代化实时数据流处理架构。整个数据流从数据源采集开始,经过数据接入、Kafka 流系统、实时计算、数据存储,最终服务于上层的各类数据应用,具体流程如下:</p><ul><li><strong>数据源 (Data Source)</strong>:数据源主要分为两大类。第一类是<strong>可观测性数据</strong>,包括服务运行产生的海量日志、关键指标(Metrics)和 Trace 信息;第二类是<strong>分析数据</strong>,涵盖了歌曲、艺术家、版权等业务元数据,以及用户的播放、评论等行为数据。</li><li><strong>数据接入 (Data Ingestion)</strong>:为了实现海量数据源数据的统一、高效接入,腾讯音乐内部的“数据通道”平台,作为所有业务方数据接入的统一入口。该平台底层封装了业务埋点、Kafka Producers 等多种上报方式。其核心价值在于,在数据进入 AutoMQ 之前,平台会进行一系列的预处理,包括地域与业务区分、字段过滤、安全鉴权和智能分发。这一流程不仅确保了只有合规、准确的数据才能进入核心系统,也极大地提升了数据接入的效率和治理水平。不同类型的数据通过相应的组件进行采集和上报。例如,应用程序内部通过集成的 SDK 进行埋点追踪,业务服务通过标准的 Kafka 生产者(Kafka Producers)上报数据,而部署在虚拟机上的 Agents 则负责采集各类系统的日志和指标。</li><li><strong>流系统 (Kafka)</strong>:所有接入的数据统一汇入作为核心数据总线的 <strong>AutoMQ</strong> 集群。在这里,AutoMQ 承接了来自不同业务线的数据洪峰,为下游的实时计算和数据存储提供高吞吐、低延迟的可靠数据流服务。通过部署多个 AutoMQ 集群(如图中的 Cluster A, B, C),可以实现业务隔离和精细化管理。</li><li><strong>实时计算 (Computation)</strong>:在数据写入最终存储之前,通常会经过一个可选的实时计算层。腾讯音乐使用 <strong>Flink</strong> 作为主流的计算引擎,对 Kafka 中的原始数据流进行实时的聚合、过滤和复杂计算。这一步是实现实时监控报警、数据清洗和预处理的关键。例如,Flink 作业会消费 AutoMQ 集群 A 的数据,经过处理后再写回,供其他服务使用。</li><li><strong>数据存储 (Storage)</strong>:经过实时计算处理后,数据被写入不同的存储系统以满足不同的查询需求。一部分数据会流入 <strong>OLAP 数据库</strong>,用于后续的交互式分析和 BI 报表;另一部分数据,尤其是日志和追踪数据,则会被写入 <strong>Elasticsearch</strong>,以支持快速的搜索和问题定位。</li><li><strong>数据应用 (Data Application)</strong>:在架构的最上层,是直接面向业务和技术团队的各类数据应用。这些应用大致可分为两类:</li><li><strong>可观测性应用</strong>:基于实时数据流,构建强大的实时监控与告警、智能故障诊断、事件和性能分析,保障业务的稳定运行。</li><li><strong>数据分析应用</strong>:利用处理后的数据,驱动上层业务决策,包括个性化推荐、用户洞察、商业智能(BI)分析和数据科学建模等,实现数据驱动的精细化运营。</li></ul><p><img src="/img/remote/1460000047418188" alt="" title=""></p><h2>Kafka 挑战</h2><p>随着腾讯音乐旗下 QQ 音乐、酷狗音乐等多款应用的高速发展,数据规模呈指数级增长,作为数据流中枢的 Kafka 集群也面临着日益严峻的挑战。这些挑战主要集中在成本和运维两个方面。</p><p><img src="/img/remote/1460000047418189" alt="" title=""></p><h3>日益严峻的成本压力</h3><p>在腾讯音乐的业务体量下,Kafka 的成本问题变得尤为突出,主要体现在以下几个方面:</p><ul><li><strong>资源预留成本高昂</strong>:由于 Kafka 存算一体的架构限制,集群的计算和存储资源必须同步扩展。为了从容应对业务流量的波峰,生产环境的资源预留水位通常需要保持在 <strong>30%~40% 甚至更高</strong>。这意味着有大量的服务器资源在大部分时间处于闲置状态,造成了巨大的浪费。</li><li><strong>存储成本居高不下</strong>:为了保证数据的 TTL 时长和高并发下的读写性能,Kafka 的 Broker 节点通常需要配置多块大容量的高性能本地磁盘。昂贵的存储介质和大量的资源预留,共同推高了 Kafka 集群的总体拥有成本(TCO)。</li><li><strong>多副本机制带来额外开销</strong>:Kafka 内置的多副本机制虽然保障了数据的高可靠,但在进行分区数据同步时,会对 Broker 节点的 CPU 产生额外的开销。这不仅增加了资源消耗,也对机型的规格提出了更高的要求,间接导致了硬件成本的上升。</li></ul><h3>运维成本高</h3><p>在传统 Kafka 架构下,运维团队面临着巨大挑战,尤其是在集群的弹性伸缩和日常维护方面。</p><ul><li><strong>扩缩容操作“伤筋动骨”</strong>:随着业务发展,集群扩缩容是家常便饭。然而,Kafka 的扩缩容流程却较为繁琐和漫长。业务方首先需要提交扩容申请并等待审核,业务运维团队会等到业务低峰期才执行操作,以避免影响线上服务。扩缩容过程中最耗时的是 Kafka 的分区数据搬迁,它会产生较大的网络和磁盘 I/O,同时耗费大量时间。并且团队成员还需要投入大量精力,去验证流量是否被正确、均衡地引导至新的 Broker 节点。整个扩缩容流程走完,通常需要 <strong>1 天左右</strong>的时间,并且依赖人工介入,整个过程是耗时且有风险的。</li><li><strong>数据热点处理棘手</strong>:除了计划内的扩容,当突发的数据热点出现时,运维团队也需要人工介入,通过调整生产端的写入策略来打散流量,以避免单个 Broker 或分区过载。这种手动干预的方式不仅响应不及时,而且操作复杂,给系统的稳定性带来了潜在风险。</li></ul><p>这些长期存在的成本与运维难题,给负责 Kafka 运维的团队带来了沉重的负担,也成为了制约数据基础设施进一步发展的瓶颈。因此,寻找一个更具弹性、更低成本、运维更友好的下一代 Kafka 解决方案,被提上了议程。</p><h2>为什么选择 AutoMQ</h2><p>在评估下一代 Kafka 解决方案时,我们团队有几个非常明确的目标。经过深入的技术调研和对比,我们认为 AutoMQ 是最能满足我们当前和未来需求的方案。</p><ul><li><strong>解决运维瓶颈,实现快速弹性</strong>:我们面临最大的痛点是传统 Kafka 扩缩容的低效和高风险。AutoMQ 存算分离的架构,将 Broker 变成了无状态节点,数据则存放在对象存储上。这对我们来说,最直接的好处就是分区迁移可以按秒级完成,集群扩容不再需要漫长的数据搬迁,整个过程可以自动化,从过去耗时一两天的人工操作,缩短到了几分钟,极大地提升了我们的运维效率。</li><li><strong>在保证性能稳定的前提下,实现架构性降本</strong>:成本是另一个核心考量。AutoMQ 的架构让我们能够独立扩展计算和存储资源,这意味着我们不再需要为应对流量峰值而预留大量昂贵的计算实例。同时,将数据从本地磁盘转移到成本低得多的对象存储,直接降低了存储开销。这种架构上的改变,是从根本上解决了成本问题,而不是小修小补。</li><li><strong>真正适配云原生(Kubernetes Native)</strong>:我们的基础设施正在全面拥抱 Kubernetes。传统 Kafka 的有状态特性,使其难以充分利用 Kubernetes 在资源调度和故障恢复上的优势。AutoMQ 的无状态 Broker 则能与 Kubernetes 完美协同,像普通应用一样被自由调度,这为我们未来将整个 Kafka 服务迁移至 K8s 铺平了道路,有助于最大化资源利用率。</li><li><strong>原生支持 Iceberg,简化数据入湖</strong>:我们未来的数据平台规划之一是构建基于 Apache Iceberg 的流式数据湖。AutoMQ 在这方面的前瞻性设计是一个重要加分项。它提供的 <strong>Table Topic</strong> 功能可以直接将 Topic 数据流式写入为 Iceberg 表格式,并存入对象存储。这意味着我们未来可以省去一个独立的 Flink 或 Spark 作业来进行数据转换和入湖,从而显著简化数据栈的架构和维护成本。</li><li><strong>平滑无感的迁移路径</strong>:替换核心基础设施,最大的风险在于迁移过程。AutoMQ 提供了 100% 的 Kafka 协议兼容性,这一点至关重要。这意味着我们现有的所有生产者、消费者程序代码都无需任何改动。同时,我们已经构建多年的监控、运维和安全等配套设施也能无缝集成。这为我们提供了一个低风险、低成本的迁移方案,是项目能够成功落地的基本保障。</li></ul><p><img src="/img/remote/1460000047418190" alt="" title=""></p><h2>评估和迁移过程</h2><p>对于一项如此核心的基础设施升级,一个严谨且分阶段的评估与迁移计划是必不可少的。我们的目标是确保 AutoMQ 在真实的生产负载下,其稳定性、性能和兼容性都达到甚至超过我们的预期。整个过程可以分为两个阶段:<strong>负载验证</strong>和<strong>生产迁移</strong>。</p><h3>负载验证阶段</h3><p>我们设计了两种典型的业务场景来对 AutoMQ 进行压力测试,以覆盖我们主要的负载模型:</p><ul><li><strong>2025 年 6 月 - 大流量场景验证</strong>:我们首先上线了一个承载高数据吞吐量、但 QPS(每秒请求数)相对不高的集群。这个测试的目的是验证 AutoMQ 在处理海量数据持续写入和读取场景下的性能和稳定性,特别是在网络 I/O 和对象存储交互方面的表现。</li><li><strong>2025 年 7 月 - 高 QPS 场景验证</strong>:随后,我们部署了第二个集群,用于承载一个高 QPS、但单条消息流量较小的业务。这个场景重点考验的是 AutoMQ 在处理高频元数据请求、客户端连接管理以及小 I/O 聚合能力上的性能极限。</li></ul><p>在这两个月的测试中,我们通过构造多组不同的测试负载,对 AutoMQ 进行了全面的评估。结果表明,<strong>AutoMQ 在各种压力场景下都展现出了非常好的稳定性,其吞吐量、延迟等关键性能指标完全符合我们生产环境的要求</strong>。这给了我们充足的信心,正式启动生产环境的迁移工作。</p><h3>生产迁移阶段</h3><p>从 2025 年 8 月开始,我们正式将生产环境的业务流量迁移至 AutoMQ。<strong>得益于 AutoMQ 对 Apache Kafka 协议 100% 的兼容性,整个迁移过程异常丝滑,对业务方完全透明,也无需我们进行额外的开发适配。</strong></p><p>我们的迁移遵循了以下标准的三步流程,以确保数据的零丢失和服务的不中断:</p><ol><li><strong>切换生产者 (Producer)</strong>:我们首先修改生产者的客户端配置,将它们的接入点地址指向新的 AutoMQ 集群。这个过程通过滚动更新(rolling update)的方式进行,线上流量被平滑地切换过来,新的数据开始源源不断地写入 AutoMQ。</li><li><strong>排空旧集群数据</strong>:在生产者完全切换后,旧的 Kafka 集群不再接收新的数据。我们会让消费者继续运行在旧集群上,直到它们消费完所有堆积的历史数据。</li><li><strong>切换消费者 (Consumer)</strong>:确认旧集群数据已被消费完毕后,我们同样以滚动更新的方式,修改消费者的接入点地址,使其指向新的 AutoMQ 集群。消费者会配置为从新集群中最早的可用位点(Offset)开始消费,从而无缝衔接,保证了数据处理的连续性。</li></ol><h2>上线情况与效果</h2><p>经过平滑的迁移,AutoMQ 目前已经在我们内部稳定运行,并承接了核心的生产流量。截至目前,我们总计上线了 <strong>6 个 AutoMQ 集群</strong>,整体峰值写入吞吐量达到 <strong>1.6 GiB/s</strong>,峰值 QPS 约为 <strong>480K</strong>。</p><p>为了更直观地展示其运行表现,下图是我们其中一个较大集群的生产集群监控概览:</p><p><img src="/img/remote/1460000047418191" alt="" title=""></p><p>迁移到 AutoMQ 后,我们获得了非常显著的收益,完美解决了之前在传统 Kafka 上遇到的核心痛点。</p><ul><li><strong>成本大幅度降低</strong>:最直接、最显著的收益来自于成本的优化。AutoMQ 存算分离的创新架构,从根本上解决了传统 Kafka 因资源捆绑而导致的成本问题。我们不再需要为应对流量高峰而预留大量的计算资源,同时通过将数据持久化到对象存储,也极大地降低了存储开销。综合计算和存储两方面的节省,<strong>腾讯音乐 Kafka 集群成本平均降低超过了 50%</strong>。</li><li><strong>获得“秒级”的极速扩缩容能力</strong>:过去困扰我们运维团队的扩容难题,在 AutoMQ 上彻底成为了过去式。由于扩容新的 Broker 节点不再需要进行耗时的数据搬迁,整个过程变得异常迅速。凭借 AutoMQ <strong>分区的秒级迁移能力</strong>以及其内置的 <strong>Self-Balancing 自动流量均衡机制</strong>,我们现在可以在<strong>数十秒内,平滑地为集群扩展出 1 GiB/s 的吞吐容量</strong>。这种极致的弹性伸缩能力,意味着我们可以从容应对任何突发的业务流量增长,为腾讯音乐未来的业务发展提供了坚实而灵活的基础设施保障。</li></ul><p><img src="/img/remote/1460000047418192" alt="" title=""></p><h2>未来展望</h2><p>回顾这次技术升级之旅,AutoMQ 在腾讯音乐生产环境中的表现令人印象深刻。无论是在高负载下的稳定性、优秀的性能指标,还是在降本增效和简化运维方面取得的实质性成果,都<strong>完全符合甚至超出了我们运维团队的预期</strong>。这次成功的实践,验证了 AutoMQ 这种云原生架构在 Kafka 流领域的巨大价值。</p><p>基于当前的成功经验,我们制定了清晰的未来演进路线图,按优先级逐步推进:</p><ul><li><strong>全面推进存量迁移</strong>:我们将加速推进剩余 Kafka 集群的迁移工作。计划将所有服务于<strong>全量可观测和多维分析</strong>业务的 Kafka 集群全部迁移至 AutoMQ,以最大化地释放成本红利并统一运维体系。</li><li><strong>落地流式数据入湖</strong>:在数据架构演进方面,我们将着手<strong>落地 AutoMQ 的 Table Topic 功能</strong>。充分利用其对 Iceberg 的原生支持,构建更加简洁、高效、实时的流式数据入湖链路,为上层的数据分析业务提供更强有力的支撑。</li><li><strong>AutoMQ 组件标准化与推广</strong>:我们计划将 AutoMQ 打造成腾讯音乐内部<strong>标准化的基础设施组件</strong>,并积极将其推广应用到更多样化的业务场景中,让更多的业务线受益于新架构带来的极致弹性和低成本优势。</li><li><strong>迈向完全的 Kubernetes 云原生</strong>:最后,依托 AutoMQ 天然无状态的云原生特性,我们将启动<strong>将 Kafka 服务整体搬迁至 Kubernetes</strong> 的探索与实践。这将有助于我们进一步提升资源管理的自动化水平和整体利用率,推动腾讯音乐的数据基础设施向着完全云原生的方向迈进。</li></ul>

2025/11/21
阅读更多

SseEmitter返回data被双引号包裹的问题排查

<h2>一、背景</h2><p>最近做接口的性能改造,大概背景如下:<br>旧: <br> 1.前端每秒轮询后端接口,接口返回数据状态,前端用状态做判断,变更页面交互。<br> 2.前端固定调用后端接口,接口阻塞100秒,等待后端随时返回结果,100秒到达后无结果,直接失败。</p><p>新: <br> 改为<code>ServerSentEvent</code>以<code>text-event-stream</code>固定时间窗口由后端返回处理进度。</p><h2>二、简单对比</h2><p>1.后端服务压力大。尤其在用户量大,并发量大,又是系统核心业务时,不好把控。<br>2.阻塞<code>Servlet</code>线程, 当前业务同时访问量大时,影响其他业务请求;最大等待时间不好把控,且受HTTP响应超时时间影响。<br>3.自由简单,且实时性比较强,只要不是高并发入口请求业务就能使用。不占用<code>Servlet</code>线程。</p><h2>三、问题现象</h2><h3>1.主代码展示</h3><blockquote>以下代码均为精简后的最小问题复现demo</blockquote><ul><li>SSE主接口<br><img width="723" height="535" src="/img/bVdm3JS" alt="PixPin_2025-11-16_11-57-06.png" title="PixPin_2025-11-16_11-57-06.png"></li></ul><h3>2.问题表现</h3><p>通过<code>curl</code>加<code>-N</code>参数调用后,发现返回的<code>data</code>都被双引号包裹,并且前端的<code>EventSource.onmessage</code>无法回调到(总是触发<code>onerror</code>回调)<br><img width="723" height="609" src="/img/bVdm3JT" alt="PixPin_2025-11-16_11-59-00.png" title="PixPin_2025-11-16_11-59-00.png"></p><h2>四.问题思考</h2><p>参考并阅读以下文档<br><a href="https://link.segmentfault.com/?enc=S6yRm8bwb6Cf4mJBjK3tsw%3D%3D.PoveD0fMhhvRNhozaou2mDfFjsLDMOZi%2FppDIZV6sTe4EIOmmmLvXPvzrLhy7tpFXn8uXLlGjGP2TZfq10ac5gKVlp8%2BmSEqpFqaxdd%2FExKbGE0JCz5g489IWg5eQ7O%2F" rel="nofollow">前端MDN ServerSentEvent文档</a><br><a href="https://link.segmentfault.com/?enc=JT8dsSEFAXhse%2FEpcJ944Q%3D%3D.OazyxFsV40NrQhI2%2FR5dZOKN7rpqxA4v3DrjFeE8O%2FQW3%2Ba34kBb1WAWk573qn1zoAnw7muA4xn2%2FNsJ94ysa9kYp2l4A2DGICoDe1wER8yFY0Q54ULz6yGwy%2B07YkApqnYEe7nbehiPbMEWwrua%2F8ZcZxmBlUyuRJzek8UgaddVTWfKdsaY02vKBB1prvYFWK2WHN%2FMabMKr1CCCtKkJr850dlcK%2BwDCR44fLG607k%3D" rel="nofollow">Spring ServerSentEvent文档</a></p><p>在前端侧浏览器看到开发者工具内<code>EventStream</code>标签页显示空白, 意识到确实是后端自身问题,观察数据结构后发现,像是返回了JSON结构的<code>String</code>字符串,只有JSON结构下<code>String</code>两侧才会有引号,接着立刻翻源码验证。</p><p>从源码<code>org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitterReturnValueHandler.HttpMessageConvertingHandler#sendInternal</code>中找到线索,发现<code>Spring</code>在处理<code>text-event-stream</code>响应时,仍然是从Servlet Web的全局<code>HttpMessageConverter</code>查找,但这里的<code>mediaType</code>值到底是哪个值呢,存在以下三种情况</p><ul><li><code>Controller</code>方法整体的<code>MediaType</code>,这个值固定是<code>text/event-stream</code></li><li><code>SseEmitter#send(java.lang.Object, org.springframework.http.MediaType)</code>方法整体的<code>MediaType</code>,这个值固定是<code>text/plain</code></li><li><code>SseEmitter#send(java.lang.Object, org.springframework.http.MediaType)</code>方法第二个参数传入的<code>MediaType</code>,这个值动态的,我这里是<code>application/json;charset=UTF-8</code></li></ul><p>这里大概率是第二种导致的。<br><img width="723" height="422" src="/img/bVdm3JX" alt="PixPin_2025-11-16_12-09-33.png" title="PixPin_2025-11-16_12-09-33.png"></p><h2>五、得到结论</h2><p>由于在K8s内网部署,本机无法启动,关联的中间件和附加Bean太多。到这里为止,其实问题清晰了,结合对Spring源码的理解,<strong>项目中的全局<code>HttpMessageConverter</code>配置肯定有问题</strong>,我们公司前身有很多认为自己很厉害的人,都是从阿里转过来的,酷爱<code>fastjson</code>,项目基建时,肯定干掉了所有的<code>HttpMessageConverter</code>,统一换成了<code>fastjson</code>,接着找这个代码</p><p><img width="723" height="450" src="/img/bVdm3J1" alt="PixPin_2025-11-16_12-25-22.png" title="PixPin_2025-11-16_12-25-22.png"></p><p>这是2021年,前人留下来的宝藏,只能说这个人复制的时候可能没过脑</p><ul><li>红色框出来的就是根因</li><li>绿色框出来的就是完全无脑,二进制文档,图片,也要Json吗?</li></ul><h3>1.看看上游Spring的处理</h3><blockquote>随即问题来了,Spring默认会有一大堆的<code>HttpMessageConverter</code>自动配置,当前这个是<code>add</code>到<code>List&lt;HttpMessageConverter&lt;?&gt;&gt; converters</code>的第一位吗?默认的还在不在?</blockquote><p>源码如下: <code>org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport#getMessageConverters</code><br><img width="723" height="451" src="/img/bVdm3J2" alt="PixPin_2025-11-16_12-29-52.png" title="PixPin_2025-11-16_12-29-52.png"></p><p><code>org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport#addDefaultHttpMessageConverters</code>如下<br>如图就是<code>Spring</code>的解法<br><img width="723" height="540" src="/img/bVdm3J3" alt="PixPin_2025-11-16_12-31-54.png" title="PixPin_2025-11-16_12-31-54.png"></p><h2>六、圈定影响</h2><p>那么新的问题来了,如果这个项目中有其他直接返回<code>String</code>的接口,岂不是意味着全部被包了一层双引号?</p><p>恰好想起之前有其他人不听劝,不使用<code>Spring Actuator</code>的健康检查,非要手搓,返回的恰好是<code>String</code>,线上验证一下<br><img width="723" height="227" src="/img/bVdm3J5" alt="PixPin_2025-11-16_12-37-48.png" title="PixPin_2025-11-16_12-37-48.png"></p><p>那就是影响到了所有直接返回<code>String</code>的接口。</p><h2>七、问题复现</h2><blockquote>本地不能启动,最小demo,debug一下</blockquote><p>debug启动后发现确实是这里影响的,这里会走两次<br>一次前面的<code>text/plain</code><br><img width="723" height="301" src="/img/bVdm3JZ" alt="PixPin_2025-11-16_12-17-41.png" title="PixPin_2025-11-16_12-17-41.png"><br>实际的第二个参数的<code>MediaType</code>,值<code>application/json;charset=UTF-8</code><br><img width="723" height="297" src="/img/bVdm3J0" alt="PixPin_2025-11-16_12-18-09.png" title="PixPin_2025-11-16_12-18-09.png"></p><p>看看<code>HttpMessageConverter</code>是不是错选了<br><img width="723" height="435" src="/img/bVdm3J7" alt="PixPin_2025-11-16_12-43-27.png" title="PixPin_2025-11-16_12-43-27.png"></p><p><strong>根因确定: <code>text/plain</code>错误配置了使用<code>FastJsonHttpMessageConverter</code>,导致返回的不是字符串,而是json字符串</strong></p><h2>八、问题修复</h2><p>修复就非常简单了,删除不合理的<code>MediaType</code>配置,最前面追加<code>StringHttpMessageConverter</code>即可<br><img width="723" height="304" src="/img/bVdm3J9" alt="PixPin_2025-11-16_12-50-20.png" title="PixPin_2025-11-16_12-50-20.png"></p><blockquote>当然为了缩小影响范围,只删除<code>MediaType.TEXT_PLAIN</code>配置即可</blockquote><p>至此,问题修复。<br><img width="723" height="142" src="/img/bVdm3Ka" alt="PixPin_2025-11-16_12-52-46.png" title="PixPin_2025-11-16_12-52-46.png"></p>

2025/11/16
阅读更多

探索 Java 中的新 HTTP 客户端

<p>你是否也遇到过这样的时刻:只是想发个 HTTP 请求,却被连接管理、重定向、超时与线程阻塞折腾得不亦乐乎?那就试试 Java 11 正式标准化了全新的 HttpClient,原生支持 HTTP/2、异步与 WebSocket,极大简化了客户端网络编程。</p><h2>1. 概览</h2><p>本文将介绍 Java 11 对全新 <strong>HTTP 客户端 API(支持 HTTP/2 与 WebSocket)</strong> 的标准化。</p><p>它旨在替代 JDK 早期就存在的旧类 <code>HttpURLConnection</code>(文档见:<a href="https://link.segmentfault.com/?enc=hUpStkKll7Ck083abx%2Fg8g%3D%3D.PR8u5lxUjqgvvjL%2FCCQgfbCij37%2ByIJ5%2BNqEoLb7DfOS%2FZh4xQHLp2A3UHSayjIA0vI9tFQt%2BlndRyU%2BdmpauU1EL4BSeczkvHMk%2B4TLQafP1DzIXydKVxMcPukl1oE2" rel="nofollow">https://docs.oracle.com/en/java/javase/21/docs/api/java.base/...</a>)。</p><p>在不久之前,Java 只有较为底层、功能有限且不够友好的 <code>HttpURLConnection</code> API。因此社区普遍使用第三方库,如 <a href="https://link.segmentfault.com/?enc=edfuircNvdkBssRB0paZFA%3D%3D.pEPIy1FaMgChCZWMYWhD9pyam7MpXH20h%2Fuohjdt37XKmCMiO3X5yzx97IR6p3k6" rel="nofollow">Apache HttpClient</a>、<a href="https://link.segmentfault.com/?enc=evUaJLHPSwc5EmxM%2FP4mLA%3D%3D.sCaY4Mr1TJzEUZgtwHI477PGL0OoingumQSklvPbbMxp23uj3EtVZqoKO0icxcdH74ZH%2Fr2Tet2rIxjOPYM5SYs2YTI0Oe5Lkmu1dBih98A%3D" rel="nofollow">Jetty</a> 以及 Spring 的 RestTemplate。</p><h2>2. 背景</h2><p>该变更由 JEP 321 引入并最终在 Java 11 中定型。</p><h3>2.1. JEP 321 的主要变更</h3><ol><li>Java 9 的孵化版 HTTP API 已正式并入 Java SE API。新的 <a href="https://link.segmentfault.com/?enc=gPeMmjf01QLKLjS2k0lE7g%3D%3D.nlqKKlhytNnRGOtfBzJ2wpTHMNwRT129Sf22RZyMXZkaqPoF94vx7JFHrluKaFjst7%2F0ObpcNf%2Bxz7INVAAJG9FjLc9vCryaB7SEuTfKhXL3Gb%2F%2Fq8ZLJbDTUQc9%2FA%2BDJDx4BAz%2B0PaTdmyAzrpESA%3D%3D" rel="nofollow">HTTP APIs</a> 位于 <code>java.net.http.*</code>。</li><li>新版本的 HTTP 协议旨在提升客户端请求与服务器响应的整体性能,包括多路复用、头压缩与推送承诺(push promise)等特性。</li><li>自 Java 11 起,<strong>API 全面支持异步</strong>(相比之下,旧的 HTTP/1.1 实现是阻塞式的)。异步以 <code>CompletableFuture</code> 实现,阶段式流水线在前一阶段完成后自动衔接执行。</li><li>新的 HTTP 客户端提供了标准方式执行网络操作,原生支持现代 Web 能力(如 HTTP/2),无需引入第三方依赖。</li><li><p>新 API 原生支持 HTTP/1.1 与 HTTP/2 的 WebSocket。核心类型包括:</p><ul><li><code>HttpClient</code>(<code>java.net.http.HttpClient</code>)</li><li><code>HttpRequest</code>(<code>java.net.http.HttpRequest</code>)</li><li><code>HttpResponse&lt;T&gt;</code>(<code>java.net.http.HttpResponse</code>)</li><li><code>WebSocket</code>(<code>java.net.http.WebSocket</code>)</li></ul></li></ol><h3>2.2. Java 11 之前客户端的问题</h3><p>旧版 <code>HttpURLConnection</code> 及其实现存在诸多问题:</p><ul><li><code>URLConnection</code> 为多个如今已不再使用的协议(FTP、gopher 等)而设计;</li><li>API 早于 HTTP/1.1,抽象层级不合时宜;</li><li>仅支持阻塞模式(一次请求/响应占用一个线程);</li><li>维护困难。</li></ul><h2>3. HTTP Client API 总览</h2><p>与 <code>HttpURLConnection</code> 不同,新 HTTP 客户端同时提供同步与异步两种请求机制。</p><p>API 的三大核心:</p><ul><li><code>HttpRequest</code>:要发送的请求;</li><li><code>HttpClient</code>:跨请求的通用配置容器;</li><li><code>HttpResponse</code>:请求的响应结果。</li></ul><p>下面分别展开,先从请求开始。</p><h2>4. HttpRequest</h2><p><code>HttpRequest</code> 表示将要发送的请求,可通过 <code>HttpRequest.newBuilder()</code> 获取构建器;构建器提供多种便捷方法配置请求。</p><p>注:JDK 16 新增 <code>HttpRequest.newBuilder(HttpRequest request, BiPredicate&lt;String,String&gt; filter)</code>,可基于已有请求复制初始状态,再在构建前做修改(如移除部分头):</p><pre><code class="java">HttpRequest.newBuilder(request, (name, value) -&gt; !name.equalsIgnoreCase(&quot;Foo-Bar&quot;));</code></pre><h3>4.1. 设置 URI</h3><p>可直接用带 <code>URI</code> 的构造方式,或在构建器上调用 <code>uri(URI)</code>:</p><pre><code class="java">HttpRequest.newBuilder(new URI(&quot;https://postman-echo.com/get&quot;)); HttpRequest.newBuilder() .uri(new URI(&quot;https://postman-echo.com/get&quot;));</code></pre><h3>4.2. 指定 HTTP 方法</h3><p>构建器提供以下方法:</p><ul><li><code>GET()</code></li><li><code>POST(BodyPublisher body)</code></li><li><code>PUT(BodyPublisher body)</code></li><li><code>DELETE()</code></li></ul><p>一个最简单的 GET 示例:</p><pre><code class="java">HttpRequest request = HttpRequest.newBuilder() .uri(new URI(&quot;https://postman-echo.com/get&quot;)) .GET() .build();</code></pre><p>常见的附加参数包括:HTTP 协议版本、请求头与超时。</p><h3>4.3. 设置协议版本</h3><p>API 默认充分利用 HTTP/2,也可显式指定:</p><pre><code class="java">HttpRequest request = HttpRequest.newBuilder() .uri(new URI(&quot;https://postman-echo.com/get&quot;)) .version(HttpClient.Version.HTTP_2) .GET() .build();</code></pre><p>注意:若对端不支持 HTTP/2,客户端会回退到 HTTP/1.1。</p><h3>4.4. 设置请求头</h3><p>可用 <code>headers(k1,v1,k2,v2,...)</code> 一次性传入,或多次调用 <code>header(k,v)</code>:</p><pre><code class="java">HttpRequest request = HttpRequest.newBuilder() .uri(new URI(&quot;https://postman-echo.com/get&quot;)) .headers(&quot;key1&quot;, &quot;value1&quot;, &quot;key2&quot;, &quot;value2&quot;) .GET() .build(); HttpRequest request2 = HttpRequest.newBuilder() .uri(new URI(&quot;https://postman-echo.com/get&quot;)) .header(&quot;key1&quot;, &quot;value1&quot;) .header(&quot;key2&quot;, &quot;value2&quot;) .GET() .build();</code></pre><h3>4.5. 设置超时</h3><p>默认无穷大。可用 <code>Duration</code> 设置,超时会抛出 <code>HttpTimeoutException</code>:</p><pre><code class="java">HttpRequest request = HttpRequest.newBuilder() .uri(new URI(&quot;https://postman-echo.com/get&quot;)) .timeout(Duration.ofSeconds(10)) .GET() .build();</code></pre><h2>5. 设置请求体</h2><p><code>POST(BodyPublisher)</code>, <code>PUT(BodyPublisher)</code> 可携带请求体(<code>DELETE()</code> 也支持不带体的删除)。常用的 <code>BodyPublisher</code> 工厂有:</p><ul><li><code>HttpRequest.BodyPublishers.ofString</code>:基于字符串;</li><li><code>HttpRequest.BodyPublishers.ofInputStream</code>:基于输入流(以 <code>Supplier&lt;InputStream&gt;</code> 形式延迟创建);</li><li><code>HttpRequest.BodyPublishers.ofByteArray</code>:基于字节数组;</li><li><code>HttpRequest.BodyPublishers.ofFile</code>:基于文件路径内容;</li><li>无请求体:<code>HttpRequest.BodyPublishers.noBody()</code>。</li></ul><p>JDK 16 新增 <code>BodyPublishers.concat(...)</code>,可把多个 publisher 的内容顺序拼接为一个请求体。</p><h3>5.1. 字符串请求体</h3><pre><code class="java">HttpRequest request = HttpRequest.newBuilder() .uri(new URI(&quot;https://postman-echo.com/post&quot;)) .headers(&quot;Content-Type&quot;, &quot;text/plain;charset=UTF-8&quot;) .POST(HttpRequest.BodyPublishers.ofString(&quot;Sample request body&quot;)) .build();</code></pre><h3>5.2. 输入流请求体</h3><pre><code class="java">byte[] sampleData = &quot;Sample request body&quot;.getBytes(); HttpRequest request = HttpRequest.newBuilder() .uri(new URI(&quot;https://postman-echo.com/post&quot;)) .headers(&quot;Content-Type&quot;, &quot;text/plain;charset=UTF-8&quot;) .POST(HttpRequest.BodyPublishers .ofInputStream(() -&gt; new ByteArrayInputStream(sampleData))) .build();</code></pre><h3>5.3. 字节数组请求体</h3><pre><code class="java">byte[] sampleData = &quot;Sample request body&quot;.getBytes(); HttpRequest request = HttpRequest.newBuilder() .uri(new URI(&quot;https://postman-echo.com/post&quot;)) .headers(&quot;Content-Type&quot;, &quot;text/plain;charset=UTF-8&quot;) .POST(HttpRequest.BodyPublishers.ofByteArray(sampleData)) .build();</code></pre><h3>5.4. 文件请求体</h3><pre><code class="java">HttpRequest request = HttpRequest.newBuilder() .uri(new URI(&quot;https://postman-echo.com/post&quot;)) .headers(&quot;Content-Type&quot;, &quot;text/plain;charset=UTF-8&quot;) .POST(HttpRequest.BodyPublishers.ofFile( Paths.get(&quot;src/test/resources/sample.txt&quot;))) .build();</code></pre><h2>6. HttpClient</h2><p>所有请求都由 <code>HttpClient</code> 发送,可通过 <code>HttpClient.newBuilder()</code> 或 <code>HttpClient.newHttpClient()</code> 获取。下面看几个常用能力。</p><h3>6.1. 处理响应体</h3><p>新的 <code>BodyHandlers</code> 工厂提供常见类型的响应体处理器:</p><pre><code class="java">BodyHandlers.ofByteArray; BodyHandlers.ofString; BodyHandlers.ofFile; BodyHandlers.discarding; BodyHandlers.replacing; BodyHandlers.ofLines; BodyHandlers.fromLineSubscriber;</code></pre><p>Java 11 之前:</p><pre><code class="java">HttpResponse&lt;String&gt; response = client.send(request, HttpResponse.BodyHandler.asString());</code></pre><p>现在可简化为:</p><pre><code class="java">HttpResponse&lt;String&gt; response = client.send(request, BodyHandlers.ofString());</code></pre><h3>6.2. 设置代理</h3><pre><code class="java">HttpResponse&lt;String&gt; response = HttpClient .newBuilder() .proxy(ProxySelector.getDefault()) .build() .send(request, BodyHandlers.ofString());</code></pre><h3>6.3. 跟随重定向策略</h3><pre><code class="java">HttpResponse&lt;String&gt; response = HttpClient.newBuilder() .followRedirects(HttpClient.Redirect.ALWAYS) .build() .send(request, BodyHandlers.ofString());</code></pre><h3>6.4. 认证器(Authenticator)</h3><pre><code class="java">HttpResponse&lt;String&gt; response = HttpClient.newBuilder() .authenticator(new Authenticator() { @Override protected PasswordAuthentication getPasswordAuthentication() { return new PasswordAuthentication( &quot;username&quot;, &quot;password&quot;.toCharArray()); } }) .build() .send(request, BodyHandlers.ofString());</code></pre><h3>6.5. 同步与异步发送</h3><ul><li>同步:<code>send(...)</code>(阻塞直到响应返回)</li><li>异步:<code>sendAsync(...)</code>(立即返回 <code>CompletableFuture&lt;HttpResponse&lt;T&gt;&gt;</code>)</li></ul><p>同步示例:</p><pre><code class="java">HttpResponse&lt;String&gt; response = HttpClient.newBuilder() .build() .send(request, BodyHandlers.ofString());</code></pre><p>异步示例:</p><pre><code class="java">CompletableFuture&lt;HttpResponse&lt;String&gt;&gt; response = HttpClient.newBuilder() .build() .sendAsync(request, HttpResponse.BodyHandlers.ofString());</code></pre><p>批量并发请求:</p><pre><code class="java">List&lt;URI&gt; targets = Arrays.asList( new URI(&quot;https://postman-echo.com/get?foo1=bar1&quot;), new URI(&quot;https://postman-echo.com/get?foo2=bar2&quot;)); HttpClient client = HttpClient.newHttpClient(); List&lt;CompletableFuture&lt;String&gt;&gt; futures = targets.stream() .map(target -&gt; client .sendAsync( HttpRequest.newBuilder(target).GET().build(), HttpResponse.BodyHandlers.ofString()) .thenApply(HttpResponse::body)) .collect(Collectors.toList());</code></pre><h3>6.6. 指定异步执行器(Executor)</h3><pre><code class="java">ExecutorService executorService = Executors.newFixedThreadPool(2); CompletableFuture&lt;HttpResponse&lt;String&gt;&gt; response1 = HttpClient.newBuilder() .executor(executorService) .build() .sendAsync(request, HttpResponse.BodyHandlers.ofString()); CompletableFuture&lt;HttpResponse&lt;String&gt;&gt; response2 = HttpClient.newBuilder() .executor(executorService) .build() .sendAsync(request, HttpResponse.BodyHandlers.ofString());</code></pre><p>默认执行器为 <code>Executors.newCachedThreadPool()</code>。</p><h3>6.7. CookieHandler</h3><p>设置客户端级 <code>CookieHandler</code>:</p><pre><code class="java">HttpClient.newBuilder() .cookieHandler(new CookieManager(null, CookiePolicy.ACCEPT_NONE)) .build();</code></pre><p>若允许存储 Cookie,可从 <code>CookieManager</code> 读取:</p><pre><code class="java">((CookieManager) httpClient.cookieHandler().get()).getCookieStore();</code></pre><h2>7. HttpResponse</h2><p><code>HttpResponse</code> 表示服务端响应,核心方法:</p><ul><li><code>statusCode()</code>:返回整型状态码;</li><li><code>body()</code>:返回响应体(类型取决于发送时的 <code>BodyHandler</code>)。</li></ul><p>其他常用方法还包括 <code>uri()</code>、<code>headers()</code>、<code>trailers()</code> 与 <code>version()</code>。</p><h3>7.1. 响应的 URI</h3><p>由于重定向,响应返回的 <code>uri()</code> 可能与请求不同:</p><pre><code class="java">assertThat(request.uri().toString(), equalTo(&quot;http://stackoverflow.com&quot;)); assertThat(response.uri().toString(), equalTo(&quot;https://stackoverflow.com/&quot;));</code></pre><h3>7.2. 响应头</h3><pre><code class="java">HttpResponse&lt;String&gt; response = HttpClient.newHttpClient() .send(request, HttpResponse.BodyHandlers.ofString()); HttpHeaders responseHeaders = response.headers();</code></pre><h3>7.3. 响应协议版本</h3><p>即使请求设置为 HTTP/2,服务端也可能以 HTTP/1.1 响应,实际版本可从响应读取:</p><pre><code class="java">HttpRequest request = HttpRequest.newBuilder() .uri(new URI(&quot;https://postman-echo.com/get&quot;)) .version(HttpClient.Version.HTTP_2) .GET() .build(); HttpResponse&lt;String&gt; response = HttpClient.newHttpClient() .send(request, HttpResponse.BodyHandlers.ofString()); assertThat(response.version(), equalTo(HttpClient.Version.HTTP_1_1));</code></pre><h2>8. HTTP/2 推送承诺(Push Promise)</h2><p>新的 <code>HttpClient</code> 通过 <code>PushPromiseHandler</code> 支持服务端主动推送。当客户端请求主资源时,服务器可以同时“推送”额外资源,从而减少往返次数、加快页面渲染。该能力得益于 HTTP/2 的多路复用。</p><p>如有推送承诺,将由提供的 <code>PushPromiseHandler</code> 处理;若传入 <code>null</code>,则拒绝所有推送。</p><p><code>HttpClient</code> 的重载 <code>sendAsync</code> 可用于处理 push promise。先定义处理器:</p><pre><code class="java">private static PushPromiseHandler&lt;String&gt; pushPromiseHandler() { return (HttpRequest initiatingRequest, HttpRequest pushPromiseRequest, Function&lt;HttpResponse.BodyHandler&lt;String&gt;, CompletableFuture&lt;HttpResponse&lt;String&gt;&gt;&gt; acceptor) -&gt; { acceptor.apply(BodyHandlers.ofString()) .thenAccept(resp -&gt; { System.out.println(&quot;Pushed response: &quot; + resp.uri() + &quot;, headers: &quot; + resp.headers()); }); System.out.println(&quot;Promise request: &quot; + pushPromiseRequest.uri()); System.out.println(&quot;Promise request headers: &quot; + pushPromiseRequest.headers()); }; }</code></pre><p>再用 <code>sendAsync</code> 消费它:</p><pre><code class="java">httpClient.sendAsync(pageRequest, BodyHandlers.ofString(), pushPromiseHandler()) .thenAccept(pageResponse -&gt; { System.out.println(&quot;Page response status code: &quot; + pageResponse.statusCode()); System.out.println(&quot;Page response headers: &quot; + pageResponse.headers()); String responseBody = pageResponse.body(); System.out.println(responseBody); }) .join();</code></pre><h2>9. 总结</h2><p>本文探讨了 Java 11 中标准化后的 <code>HttpClient</code> API:在保留易用性的同时,引入了 HTTP/2、异步、推送承诺、代理、重定向策略、认证器、Cookie 管理等现代化能力,让 Java 的 HTTP 编程更高效、更现代。</p><p>更多 Java 相关内容,也可以关注我的这个分类:<a href="https://link.segmentfault.com/?enc=9JCe2%2BUvVZ6egladk%2FlIXQ%3D%3D.giYO7bKg%2BSS9fStLP6ADxWmT0TyWqCNs4V1FyyMXWa%2BlkpWDIoB2cfMZak899DiT" rel="nofollow">Java专题</a></p>

2025/11/14
阅读更多

一文搞懂Redis击穿/穿透/雪崩&实战

<h2>1. 学起来</h2><p>XDM,大家好,我是专注Golang的王中阳,最近在带着大家疯狂做项目。</p><p>这篇文章来自这个实战项目的实践:<a href="https://link.segmentfault.com/?enc=S8xRrpg1sW39eQq12RevDw%3D%3D.59y10BEy8NS3qkWiYITNdacANr9BgH3ghIv9CTCsxmDJidrF9N%2BcjeazwLbEL2Xu4RRKU8I7XRSCyddgjHCefw%3D%3D" rel="nofollow">《掌握企业级电商系统核心架构设计 突破百万级并发瓶颈》</a> , 广受粉丝好评。</p><p>我把对大家有帮助的,尤其是对新手小白非常友好的内容,整理分享出来,希望对大家有帮助。</p><p>本文将详细介绍这些常见的缓存问题,并结合我们的电商项目,提供完整的解决方案和实现代码,帮助新手小白理解并掌握Redis缓存策略的正确使用方法。</p><h2>2. Redis缓存常见问题概念解释</h2><h3>2.1 缓存穿透</h3><p><strong>什么是缓存穿透?</strong></p><p>缓存穿透是指用户请求一个不存在的数据,由于缓存中没有该数据,请求会直接打到数据库。如果大量的请求都访问不存在的数据,就会导致数据库压力过大,甚至宕机。</p><p><strong>举例说明:</strong></p><p>在电商网站中,用户查询一个不存在的商品ID(如-1或者一个非常大的随机数)。由于这个商品ID在缓存中不存在,所以每次请求都会直接查询数据库,而数据库查询后发现也没有该商品。如果有大量这样的恶意请求,数据库的压力就会急剧增加。</p><p><strong>缓存穿透的危害:</strong></p><ol><li>数据库压力过大,可能导致数据库宕机</li><li>系统响应时间延长</li><li>服务可用性降低</li></ol><h3>2.2 缓存击穿</h3><p><strong>什么是缓存击穿?</strong></p><p>缓存击穿是指一个热点数据的缓存过期后,大量并发请求同时访问该数据,导致所有请求都直接打到数据库,造成数据库瞬时压力过大。</p><p><strong>举例说明:</strong></p><p>在电商网站中,某件热销商品的缓存突然过期。此时,大量用户同时访问该商品详情,由于缓存已经过期,所有的请求都会直接查询数据库。数据库在短时间内需要处理大量请求,可能会导致性能下降甚至宕机。</p><p><strong>缓存击穿的危害:</strong></p><ol><li>数据库瞬时压力过大</li><li>系统响应时间延长</li><li>可能导致数据库宕机</li></ol><h3>2.3 缓存雪崩</h3><p><strong>什么是缓存雪崩?</strong></p><p>缓存雪崩是指大量缓存数据在同一时间段内过期,导致大量请求直接打到数据库,造成数据库压力骤增,甚至宕机。</p><p><strong>举例说明:</strong></p><p>如果我们在系统上线时,为所有的商品缓存设置了相同的过期时间(比如都设置为1小时),那么在1小时后,所有的商品缓存都会同时过期。这时,大量用户访问网站时,所有的请求都会直接打到数据库,数据库可能无法承受这样的压力而宕机。</p><p><strong>缓存雪崩的危害:</strong></p><ol><li>数据库压力骤增,可能导致数据库宕机</li><li>系统响应时间严重延长</li><li>服务可能完全不可用</li></ol><h2>3. 项目中现有的Redis缓存实现分析</h2><p>在我们的电商项目中,Redis缓存主要应用在商品服务中,用于缓存商品详情和分类信息。下面我们将分析现有的缓存实现以及存在的问题。</p><h3>3.1 现有Redis缓存实现</h3><h4>3.1.1 Redis初始化配置</h4><p>项目使用GoFrame框架的Redis组件进行缓存管理。在<code>goodsRedis/redis.go</code>文件中,实现了Redis的初始化逻辑:</p><ul><li>从配置文件中读取Redis连接信息</li><li>创建Redis实例</li><li>初始化gcache的Redis适配器</li><li>测试连接并提供缓存实例获取方法</li></ul><h4>3.1.2 商品缓存操作</h4><p>在<code>goodsRedis/goods.go</code>文件中,实现了商品和分类的Redis缓存基本操作:</p><ul><li>提供了<code>GetGoodsDetail</code>、<code>SetGoodsDetail</code>、<code>DeleteGoodsDetail</code>等方法</li><li>使用JSON序列化/反序列化缓存数据</li><li>包含了批量删除缓存的方法</li><li>实现了延迟双删逻辑,用于在更新数据库后删除缓存</li></ul><h4>3.1.3 现有缓存策略</h4><p>现有实现中已经包含了一些基础的缓存策略:</p><ul><li><strong>空值缓存</strong>:通过<code>SetEmptyGoodsDetail</code>方法设置短时间空值,初步防止缓存穿透</li><li><strong>缓存键管理</strong>:通过统一的键生成规则管理缓存键</li></ul><h3>3.2 现有实现存在的问题</h3><p>虽然现有实现已经包含了一些基础的缓存功能,但仍然存在以下问题:</p><ol><li><strong>缓存击穿防护缺失</strong>:当热点商品缓存过期时,没有有效的机制防止大量并发请求同时打到数据库</li><li><strong>缓存雪崩防护不完善</strong>:所有缓存使用固定过期时间,可能导致缓存雪崩</li><li><strong>空值缓存策略简单</strong>:空值缓存的处理方式相对简单,没有结合其他机制提供更完善的防护</li><li><strong>缺乏统一的缓存策略接口</strong>:缓存策略分散在各个方法中,不利于维护和扩展</li><li><strong>并发安全考虑不足</strong>:在高并发场景下,缓存的读取和更新可能存在并发安全问题</li></ol><p>正是由于这些问题,我们需要实现一套完整的缓存策略解决方案,以应对缓存穿透、击穿和雪崩问题。</p><h2>4. 缓存策略解决方案设计与实现</h2><p>为了解决缓存穿透、击穿和雪崩问题,我们设计并实现了一套完整的缓存策略解决方案。该方案通过创建一个统一的缓存策略接口,结合多种技术手段,提供全面的缓存问题防护。</p><h3>4.1 缓存策略接口设计</h3><p>我们首先定义了一个统一的缓存策略接口,以便于实现不同的缓存策略:</p><pre><code class="go">// CacheStrategy 缓存策略接口 type CacheStrategy interface { // Get 获取缓存数据,如果缓存不存在则调用loader加载数据 Get(key string, loader func() (interface{}, error)) (interface{}, error) // GetWithLock 获取缓存数据,使用本地锁防止缓存击穿 GetWithLock(key string, loader func() (interface{}, error), expiration time.Duration) (interface{}, error) // Set 设置缓存数据 Set(key string, value interface{}, expiration time.Duration) error // Delete 删除缓存数据 Delete(key string) error // SetEmptyValue 设置空值缓存,防止缓存穿透 SetEmptyValue(key string) error }</code></pre><h3>4.2 防缓存穿透解决方案</h3><h4>4.2.1 空值缓存</h4><p>当数据库中不存在请求的数据时,我们将一个特殊的空值标记(如<code>__EMPTY__</code>)存入缓存,但设置较短的过期时间(如5分钟)。这样可以避免恶意请求直接打到数据库。</p><h4>4.2.2 布隆过滤器(可选)</h4><p>对于频繁访问不存在的数据的场景,可以考虑使用布隆过滤器预先过滤掉一定不存在的数据。布隆过滤器可以在极低的空间复杂度下,快速判断一个数据是否可能存在。</p><h3>4.3 防缓存击穿解决方案</h3><h4>4.3.1 本地锁机制</h4><p>我们使用双重检查锁定模式结合本地锁,防止缓存击穿:</p><ol><li>首先尝试从缓存获取数据</li><li>如果缓存不存在,获取本地锁</li><li>获取锁后,再次检查缓存是否存在(双重检查)</li><li>如果仍不存在,才去查询数据库并更新缓存</li><li>最后释放锁</li></ol><p>这样可以确保在高并发场景下,只有一个请求会去查询数据库,其他请求都从缓存获取数据。</p><h4>4.3.2 锁管理</h4><p>为了高效管理本地锁,我们使用<code>sync.Map</code>来存储锁对象,键为缓存键,值为互斥锁。这样可以避免为所有可能的键创建锁对象,节省内存空间。</p><h3>4.4 防缓存雪崩解决方案</h3><h4>4.4.1 随机过期时间</h4><p>我们为每个缓存项设置一个基础过期时间,并添加一个随机的时间偏移(如基础时间的5%-15%)。这样可以避免大量缓存在同一时间过期。</p><h4>4.4.2 缓存预热</h4><p>在系统启动或低峰期,提前将热点数据加载到缓存中,避免在高峰期缓存未命中的情况。</p><h4>4.4.3 多级缓存</h4><p>结合本地缓存(如内存缓存)和远程缓存(如Redis),可以减轻远程缓存的压力,并在远程缓存不可用时提供一定的容错能力。</p><h3>4.5 缓存一致性保障</h3><p>为了保障缓存与数据库的一致性,我们实现了以下机制:</p><h4>4.5.1 延迟双删</h4><p>在更新数据库后,先删除缓存,然后等待一小段时间(如100毫秒),再次删除缓存。这样可以避免在更新过程中,其他线程读取到旧数据并更新到缓存。</p><h4>4.5.2 过期时间兜底</h4><p>即使出现缓存与数据库不一致的情况,设置合理的过期时间也可以确保最终一致性。</p><h2>5. 项目代码实现示例</h2><p>下面我们将通过具体的代码示例,展示如何在项目中实现和使用我们的缓存策略解决方案。</p><h3>5.1 缓存策略实现代码</h3><p>我们创建了一个新的文件<code>cache_strategy.go</code>,实现了完整的缓存策略解决方案:</p><pre><code class="go">package goodsRedis import ( &quot;errors&quot; &quot;math/rand&quot; &quot;sync&quot; &quot;time&quot; &quot;github.com/gogf/gf/v2/os/gcache&quot; ) // 常量定义 const ( // EmptyValue 空值标记,用于防止缓存穿透 EmptyValue = &quot;__EMPTY__&quot; // EmptyValueExpiration 空值缓存的过期时间 EmptyValueExpiration = time.Minute * 5 // DefaultExpiration 默认缓存过期时间 DefaultExpiration = time.Hour // JitterPercent 随机过期时间的抖动百分比范围 JitterMinPercent = 5 JitterMaxPercent = 15 ) // CacheStrategy 缓存策略接口 type CacheStrategy interface { // Get 获取缓存数据,如果缓存不存在则调用loader加载数据 Get(key string, loader func() (interface{}, error)) (interface{}, error) // GetWithLock 获取缓存数据,使用本地锁防止缓存击穿 GetWithLock(key string, loader func() (interface{}, error), expiration time.Duration) (interface{}, error) // Set 设置缓存数据 Set(key string, value interface{}, expiration time.Duration) error // Delete 删除缓存数据 Delete(key string) error // SetEmptyValue 设置空值缓存,防止缓存穿透 SetEmptyValue(key string) error } // RedisCacheStrategy Redis缓存策略实现 type RedisCacheStrategy struct { cache *gcache.Cache locks sync.Map // 使用sync.Map存储锁对象,键为缓存键,值为互斥锁 } // NewRedisCacheStrategy 创建新的Redis缓存策略实例 func NewRedisCacheStrategy(cache *gcache.Cache) *RedisCacheStrategy { return &amp;RedisCacheStrategy{ cache: cache, } } // Get 获取缓存数据 func (s *RedisCacheStrategy) Get(key string, loader func() (interface{}, error)) (interface{}, error) { // 尝试从缓存获取数据 value, err := s.cache.Get(key) if err == nil { // 检查是否是空值标记 if str, ok := value.(string); ok &amp;&amp; str == EmptyValue { return nil, errors.New(&quot;empty value&quot;) } return value, nil } // 缓存未命中,调用loader加载数据 if loader != nil { return loader() } return nil, errors.New(&quot;cache miss and no loader provided&quot;) } // GetWithLock 获取缓存数据,使用本地锁防止缓存击穿 func (s *RedisCacheStrategy) GetWithLock(key string, loader func() (interface{}, error), expiration time.Duration) (interface{}, error) { // 第一次检查缓存 value, err := s.cache.Get(key) if err == nil { // 检查是否是空值标记 if str, ok := value.(string); ok &amp;&amp; str == EmptyValue { return nil, errors.New(&quot;empty value&quot;) } return value, nil } // 获取锁对象 lock, _ := s.locks.LoadOrStore(key, &amp;sync.Mutex{}) mutex := lock.(*sync.Mutex) mutex.Lock() defer mutex.Unlock() // 双重检查,防止在获取锁的过程中缓存被其他线程更新 value, err = s.cache.Get(key) if err == nil { // 检查是否是空值标记 if str, ok := value.(string); ok &amp;&amp; str == EmptyValue { return nil, errors.New(&quot;empty value&quot;) } return value, nil } // 缓存仍未命中,调用loader加载数据 if loader != nil { data, err := loader() if err != nil { // 如果loader返回错误,设置空值缓存防止缓存穿透 s.SetEmptyValue(key) return nil, err } // 如果数据不为空,设置缓存 if data != nil { // 添加随机过期时间,防止缓存雪崩 s.Set(key, data, s.getExpirationWithJitter(expiration)) } else { // 数据为空,设置空值缓存 s.SetEmptyValue(key) } return data, nil } return nil, errors.New(&quot;cache miss and no loader provided&quot;) } // Set 设置缓存数据 func (s *RedisCacheStrategy) Set(key string, value interface{}, expiration time.Duration) error { return s.cache.Set(key, value, expiration) } // Delete 删除缓存数据 func (s *RedisCacheStrategy) Delete(key string) error { // 删除缓存 err := s.cache.Remove(key) if err != nil { return err } // 移除对应的锁对象 s.locks.Delete(key) return nil } // SetEmptyValue 设置空值缓存,防止缓存穿透 func (s *RedisCacheStrategy) SetEmptyValue(key string) error { return s.cache.Set(key, EmptyValue, EmptyValueExpiration) } // getExpirationWithJitter 计算带随机抖动的过期时间,防止缓存雪崩 func (s *RedisCacheStrategy) getExpirationWithJitter(base time.Duration) time.Duration { // 如果基础时间小于0,使用默认过期时间 if base &lt;= 0 { base = DefaultExpiration } // 生成5%-15%之间的随机百分比 jitter := rand.Intn(JitterMaxPercent-JitterMinPercent+1) + JitterMinPercent jitterDuration := time.Duration(jitter) * base / 100 // 添加随机抖动到基础时间 return base + jitterDuration } // DelayedDelete 延迟删除缓存,用于延迟双删策略 func (s *RedisCacheStrategy) DelayedDelete(key string, delay time.Duration) { go func() { time.Sleep(delay) s.Delete(key) }() }</code></pre><h3>5.2 在商品控制器中使用新的缓存策略</h3><p>我们修改了<code>goods_info/goods_info.go</code>文件,使用新的缓存策略替代了原来的缓存逻辑:</p><pre><code class="go">// GetDetail 获取商品详情 func (c *GoodsInfoController) GetDetail(ctx context.Context, req *v1.GoodsDetailReq) (res *v1.GoodsDetailRes, err error) { // 获取商品ID goodsId := req.Id // 构建缓存键 cacheKey := GetGoodsDetailKey(goodsId) // 创建缓存策略实例 cacheStrategy := NewRedisCacheStrategy(GetCache()) // 使用缓存策略获取数据,带锁防止缓存击穿 goodsDetail, err := cacheStrategy.GetWithLock( cacheKey, // loader函数:从数据库获取数据 func() (interface{}, error) { return c.GetDetailFromDB(ctx, goodsId) }, // 基础过期时间:1小时 DefaultExpiration, ) // 处理错误 if err != nil { if err.Error() == &quot;empty value&quot; { // 空值缓存,直接返回商品不存在 return nil, gerror.New(&quot;商品不存在&quot;) } return nil, err } // 将结果转换为响应格式 if detail, ok := goodsDetail.(*v1.GoodsDetailRes); ok { return detail, nil } return nil, gerror.New(&quot;数据格式错误&quot;) } // GetDetailFromDB 从数据库获取商品详情 func (c *GoodsInfoController) GetDetailFromDB(ctx context.Context, goodsId int) (*v1.GoodsDetailRes, error) { // 从数据库查询商品信息 goodsInfo, err := c.goodsInfoService.FindOne(ctx, goodsId) if err != nil { return nil, err } if goodsInfo == nil { return nil, gerror.New(&quot;商品不存在&quot;) } // 构建响应数据 res := &amp;v1.GoodsDetailRes{ Id: goodsInfo.Id, Title: goodsInfo.Title, Price: goodsInfo.Price, OriginalPrice: goodsInfo.OriginalPrice, Description: goodsInfo.Description, // 其他字段... } return res, nil }</code></pre><h3>5.3 缓存键管理</h3><p>我们在<code>goodsRedis/goods.go</code>文件中实现了统一的缓存键管理:</p><pre><code class="go">// GetGoodsDetailKey 获取商品详情缓存键 func GetGoodsDetailKey(goodsId int) string { return fmt.Sprintf(&quot;goods:detail:%d&quot;, goodsId) } // GetCategoryInfoKey 获取分类信息缓存键 func GetCategoryInfoKey(categoryId int) string { return fmt.Sprintf(&quot;category:info:%d&quot;, categoryId) }</code></pre><h3>5.4 Redis初始化与配置</h3><p>在<code>goodsRedis/redis.go</code>文件中,我们实现了Redis的初始化逻辑:</p><pre><code class="go">var ( // cache 缓存实例 cache *gcache.Cache ) // InitRedisCache 初始化Redis缓存 func InitRedisCache() error { // 从配置获取Redis连接信息 host := g.Cfg().MustGet(ctx, &quot;redis.host&quot;).String() port := g.Cfg().MustGet(ctx, &quot;redis.port&quot;).String() password := g.Cfg().MustGet(ctx, &quot;redis.password&quot;).String() db := g.Cfg().MustGet(ctx, &quot;redis.db&quot;).Int() // 创建Redis实例 redisClient := gredis.New(gredis.Config{ Host: host, Port: port, Password: password, DB: db, }) // 测试连接 if err := redisClient.Ping(ctx); err != nil { return err } // 初始化gcache的Redis适配器 cache = gcache.New() cache.SetAdapter(gcache.NewAdapterRedis(redisClient)) return nil } // GetCache 获取缓存实例 func GetCache() *gcache.Cache { return cache }</code></pre><h3>5.5 延迟双删实现</h3><p>在商品更新操作中,我们使用延迟双删策略确保缓存一致性:</p><pre><code class="go">// Update 更新商品信息 func (c *GoodsInfoController) Update(ctx context.Context, req *v1.GoodsUpdateReq) error { // 更新数据库 err := c.goodsInfoService.Update(ctx, req) if err != nil { return err } // 构建缓存键 cacheKey := GetGoodsDetailKey(req.Id) cacheStrategy := NewRedisCacheStrategy(GetCache()) // 第一次删除缓存 err = cacheStrategy.Delete(cacheKey) if err != nil { log.Errorf(&quot;第一次删除缓存失败: %v&quot;, err) } // 延迟100毫秒后再次删除缓存 cacheStrategy.DelayedDelete(cacheKey, 100*time.Millisecond) return nil }</code></pre><h2>6. 使用指南和最佳实践</h2><p>为了帮助新手更好地使用我们实现的缓存策略,下面提供了一些使用指南和最佳实践建议。</p><h3>6.1 缓存策略使用指南</h3><h4>6.1.1 基本使用流程</h4><ol><li><strong>初始化缓存</strong>:在服务启动时,调用<code>InitRedisCache()</code>初始化Redis缓存</li><li><strong>创建缓存策略实例</strong>:使用<code>NewRedisCacheStrategy(GetCache())</code>创建缓存策略实例</li><li><strong>获取数据</strong>:使用<code>GetWithLock</code>方法获取数据,传入缓存键、数据加载函数和过期时间</li><li><strong>更新缓存</strong>:在数据更新后,使用<code>Delete</code>和<code>DelayedDelete</code>方法删除缓存</li></ol><h4>6.1.2 缓存键命名规范</h4><p>为了便于管理缓存,建议遵循以下命名规范:</p><ul><li>使用冒号(<code>:</code>)分隔缓存键的不同部分</li><li>格式:<code>{业务模块}:{数据类型}:{唯一标识}</code></li><li>例如:<code>goods:detail:123</code>、<code>category:info:456</code></li></ul><h4>6.1.3 过期时间设置建议</h4><ul><li><strong>常规数据</strong>:1小时(<code>DefaultExpiration</code>)</li><li><strong>空值缓存</strong>:5分钟(<code>EmptyValueExpiration</code>)</li><li><strong>热点数据</strong>:根据访问频率调整,建议30分钟到2小时</li><li><strong>不常变化的数据</strong>:可以设置更长的过期时间,如24小时</li></ul><h3>6.2 最佳实践</h3><h4>6.2.1 性能优化建议</h4><ol><li><strong>合理设置过期时间</strong>:根据数据的更新频率和重要性设置合理的过期时间</li><li><strong>缓存预热</strong>:在系统启动或低峰期,预先加载热点数据到缓存</li><li><strong>批量操作</strong>:尽量使用批量操作减少与Redis的交互次数</li><li><strong>数据压缩</strong>:对于大型对象,可以考虑压缩后再存入缓存</li><li><strong>连接池配置</strong>:合理配置Redis连接池参数,避免连接泄漏</li></ol><h4>6.2.2 缓存一致性保障</h4><ol><li><strong>延迟双删</strong>:在更新数据库后,使用延迟双删策略确保缓存一致性</li><li><strong>最终一致性</strong>:接受缓存与数据库的短暂不一致,通过过期时间保证最终一致性</li><li><strong>监控告警</strong>:监控缓存命中率和延迟,及时发现问题</li></ol><h4>6.2.3 异常处理</h4><ol><li><strong>缓存降级</strong>:当Redis不可用时,直接返回数据库查询结果</li><li><strong>错误重试</strong>:对于临时性错误,可以考虑添加重试机制</li><li><strong>日志记录</strong>:记录缓存操作的关键日志,便于问题排查</li></ol><h4>6.2.4 常见问题排查</h4><ol><li><strong>缓存命中率低</strong>:检查缓存键设计是否合理,过期时间是否设置过短</li><li><strong>缓存更新不及时</strong>:检查延迟双删是否正确实现,延迟时间是否合理</li><li><strong>内存占用过高</strong>:检查是否存在缓存数据过大或缓存未及时过期的情况</li><li><strong>性能问题</strong>:检查是否存在缓存热点问题,考虑使用本地缓存分担压力</li></ol><h3>6.3 代码优化建议</h3><ol><li><strong>接口抽象</strong>:使用接口抽象缓存操作,便于后续扩展和替换实现</li><li><strong>参数校验</strong>:添加适当的参数校验,提高代码健壮性</li><li><strong>错误处理</strong>:统一错误处理方式,提供友好的错误信息</li><li><strong>日志记录</strong>:添加关键操作的日志记录,便于问题排查</li><li><strong>单元测试</strong>:为缓存策略实现添加单元测试,确保功能正确性</li></ol><h2>7. 总结</h2><p>本文详细介绍了Redis缓存中常见的三个问题:缓存穿透、缓存击穿和缓存雪崩,并提供了完整的解决方案。我们通过创建统一的缓存策略接口,结合空值缓存、本地锁和随机过期时间等技术手段,有效解决了这些问题。</p><p>在实际项目中,我们需要根据业务场景和性能需求,灵活选择和调整缓存策略。同时,还需要关注缓存一致性、异常处理和监控告警等方面,确保缓存系统的稳定运行。</p><p>希望本文能帮助你理解并掌握Redis缓存策略的正确使用方法,在实际项目中避免常见的缓存问题,提升系统性能和稳定性。</p><h2>8. 链接我</h2><p>XDM,觉好留赞哈,如果你觉得这篇内容对你有帮助,或者想进一步学习这个项目,可以关注我,私信我:微服务电商,我发你更详细的介绍。</p><p>我的绿泡泡:wangzhongyang1993。</p>

2025/11/18
阅读更多

LLM Agent 框架设计:Multi-Agent Collaboration

<h2>1. 背景介绍</h2><h3>1.1 从单模型到多智能体协作</h3><p>在早期的 LLM 应用中,很多系统采用<strong>单模型单任务</strong>的方式:一个大型语言模型(如 GPT-4、Claude)接收用户请求,然后直接生成结果。 <br>这种方式的优点是简单,但缺点也明显:</p><ul><li>对复杂任务,单模型往往难以兼顾所有环节(例如同时高效检索、专业写作、严格审查)</li><li>容易出现“泛化但不精专”的输出</li><li>难以动态扩展能力(添加新工具或新领域专家)</li></ul><p>于是出现了两种不同的解决思路:</p><ol><li><strong>MoE(Mixture of Experts)</strong>:在一个模型内部,使用多个“专家子网络”,通过路由机制让不同输入走向不同的专家。</li><li><strong>Multi-Agent Collaboration(多智能体协作)</strong>:在系统层面,组织多个具备不同能力的独立智能体(Agent),让它们协作完成任务。</li></ol><p>在 LLM 应用场景中,多智能体协作提供了:</p><ul><li>灵活的任务分工</li><li>动态扩展能力</li><li>跨领域知识整合</li><li>高质量输出的保障机制</li></ul><h3>1.2 与 MoE 的对比</h3><p>Multi-Agent Collaboration 与 MoE 的最大不同在于:</p><ul><li>MoE 是<strong>模型内部的专家路由机制</strong></li><li>Multi-Agent 是<strong>系统层面的专家团队协作</strong></li></ul><table><thead><tr><th>对比维度</th><th>MoE(专家混合模型)</th><th>Multi-Agent Collaboration(多智能体协作)</th></tr></thead><tbody><tr><td><strong>架构层级</strong></td><td>模型内部结构(通常是单个大模型的一部分)</td><td>系统层级(多个独立 Agent,可以是不同模型、不同工具)</td></tr><tr><td><strong>专家形式</strong></td><td>神经网络子模块(在训练中学习特定任务)</td><td>独立的 Agent,每个有自己的 Prompt、工具、上下文</td></tr><tr><td><strong>路由机制</strong></td><td>由 gating network 自动选择专家</td><td>由 Planner Agent 或调度器根据任务选择执行 Agent</td></tr><tr><td><strong>扩展方式</strong></td><td>需要重新训练或微调才能新增专家</td><td>可插拔,动态注册新 Agent 或工具即可</td></tr><tr><td><strong>通信方式</strong></td><td>专家之间通过模型内部张量流交互</td><td>Agent 之间通过标准化消息(JSON、API 调用)交互</td></tr><tr><td><strong>优势</strong></td><td>高效、参数共享、推理成本可控</td><td>灵活、可跨平台/跨模型、易于集成外部工具</td></tr><tr><td><strong>适用场景</strong></td><td>同类任务的细分优化(如不同语言、不同任务类型)</td><td>复杂、多步骤、多领域任务(如科研写作、产品设计)</td></tr></tbody></table><hr><h3>1.3 优势</h3><p>Multi-Agent Collaboration 更像是<strong>把任务交给一个团队</strong>:</p><ul><li>每个成员(Agent)有专长(检索、写作、分析、审查、沟通)</li><li>团队协作可以分担任务复杂度</li><li>可以随时引入新成员(新工具/新模型)</li><li>每个成员可以独立迭代,不影响其他 Agent</li></ul><p>在 LLM 应用场景下,这种模式特别适合:</p><ul><li><strong>长链任务</strong>(多步骤依赖)</li><li><strong>跨领域任务</strong>(需要不同专业知识)</li><li><strong>需要工具调用</strong>(如数据库查询、API访问)</li><li><strong>结果质量要求高</strong>(需要审查和迭代优化)</li></ul><h2>2. 核心模块</h2><h3>2.1 Planner Agent(任务规划者)</h3><ul><li>接收用户任务</li><li>拆解为子任务</li><li>根据能力匹配最优执行 Agent</li><li>输出任务依赖图(Task Dependency Graph)</li></ul><h3>2.2 Agent Registry(智能体注册中心)</h3><ul><li><p>存储所有可用 Agent 的元数据:</p><ul><li>名称</li><li>描述</li><li>能力标签(capabilities)</li><li>工具列表</li><li>Prompt 文件路径</li><li>版本号</li></ul></li><li>提供能力匹配查询接口</li><li>支持动态注册/注销(可插拔)</li></ul><h3>2.3 Agent Loader(智能体加载器)</h3><ul><li>从配置文件或数据库读取 Agent 定义</li><li>加载 Prompt、工具、参数</li><li>实例化 Agent 并注册到 Registry</li><li>支持热插拔(运行时增删 Agent)</li></ul><h3>2.4 Executor Agents(执行智能体)</h3><ul><li>按任务分配执行具体操作</li><li>专注某个领域或技能(如搜索、写作、分析)</li><li><p>可以是:</p><ul><li><strong>纯 LLM 角色</strong></li><li><strong>基于 MCP 协议的工具</strong></li><li><strong>混合型(LLM + 工具调用)</strong></li></ul></li></ul><h3>2.5 Reviewer Agent(审查者)</h3><ul><li>检查结果的逻辑、语言、格式</li><li>提出修改建议或直接优化</li><li>提高输出可靠性</li></ul><h3>2.6 Communicator Agent(沟通者)</h3><ul><li>整合所有结果</li><li>格式化输出</li><li>与用户交互,收集反馈</li></ul><h2>3. 核心功能</h2><ol><li><p><strong>任务拆解与分配</strong></p><ul><li>将复杂任务拆解为子任务</li><li>按能力匹配分配给最优 Agent</li></ul></li><li><p><strong>能力暴露与发现</strong></p><ul><li>每个 Agent 提供能力描述</li><li>Planner 可动态发现并调用</li></ul></li><li><p><strong>协作通信</strong></p><ul><li>统一数据格式(JSON、Protobuf)</li><li>支持同步调用、异步消息</li></ul></li><li><p><strong>结果整合</strong></p><ul><li>合并多 Agent 输出</li><li>去重、冲突解决、统一格式</li></ul></li><li><p><strong>质量控制</strong></p><ul><li>审查 Agent 迭代优化结果</li></ul></li><li><p><strong>动态扩展</strong></p><ul><li>可插拔设计,支持快速添加新 Agent/工具</li></ul></li></ol><h2>4. 工作流程</h2><h3>4.1 串行模式</h3><pre><code>User → Planner → Researcher → Writer → Reviewer → Communicator → User</code></pre><ul><li>每个步骤依赖上一步的结果</li><li>适合逐步加工的任务(如科研写作)</li></ul><h3>4.2 并行模式</h3><pre><code>User → Planner Planner → Researcher1, Researcher2(并行) ↓ Writer → Reviewer → Communicator</code></pre><ul><li>独立任务可并行</li><li>需要结果合并逻辑</li></ul><h3>4.3 混合模式(Hybrid)</h3><ul><li>部分任务并行,部分串行</li><li><p>例如:</p><ul><li>多个 Researcher 并行检索</li><li>Writer 串行整合</li></ul></li></ul><h3>4.4 迭代模式(Iterative Loop)</h3><ul><li>Agent 间多轮交互优化结果</li><li>适合代码生成、复杂推理任务</li></ul><h2>5. 执行型 Agent 类型</h2><p>在多智能体协作架构中,执行型 Agent 是负责<strong>真正落地执行任务</strong>的单元,它们可以按**实现方式来分类。</p><h3>5.1. Prompt驱动型 Agent</h3><ul><li><strong>特点</strong>:主要依赖大语言模型(LLM),通过精心设计的 Prompt 执行任务</li><li><p><strong>适用场景</strong>:</p><ul><li>文本生成(写作、总结、翻译)</li><li>创意任务(广告文案、故事创作)</li></ul></li><li><strong>优点</strong>:开发快、灵活性高</li><li><strong>缺点</strong>:逻辑复杂度受限,结果依赖模型质量</li><li><p><strong>示例</strong>:</p><ul><li>Writer Agent</li><li>Summarizer Agent</li><li>Translator Agent</li></ul></li></ul><h3>5.2. 代码逻辑型 Agent</h3><ul><li><strong>特点</strong>:内部主要是程序逻辑(算法、API调用、数据处理)</li><li><p><strong>适用场景</strong>:</p><ul><li>数据清洗与分析</li><li>结构化计算任务</li><li>业务流程自动化</li></ul></li><li><strong>优点</strong>:可控性强,适合复杂规则或算法</li><li><strong>缺点</strong>:开发周期长,灵活性稍低</li><li><p><strong>示例</strong>:</p><ul><li>DataFetcher Agent</li><li>Analyst Agent</li><li>DataVisualizer Agent</li></ul></li></ul><h3>5.3. MCP协议型 Agent</h3><ul><li><strong>特点</strong>:通过 MCP 协议暴露能力,主系统通过 MCP Client 调用</li><li><p><strong>适用场景</strong>:</p><ul><li>跨语言/跨平台工具</li><li>第三方团队开发的插件型 Agent</li><li>云原生部署的微服务型 Agent</li></ul></li><li><p><strong>优点</strong>:</p><ul><li>完全解耦,语言和平台不限</li><li>动态加载方便</li></ul></li><li><p><strong>缺点</strong>:</p><ul><li>网络调用有延迟</li></ul></li><li><p><strong>示例</strong>:</p><ul><li>Researcher Agent(远程API)</li><li>ChartGenerator Agent(云端绘图服务)</li></ul></li></ul><h2>6. Agents可插拔</h2><p>在 <strong>Multi-Agent Collaboration</strong> 中,执行型 Agent(Executor Agent)是负责真正执行子任务的单元。 <br>但在复杂系统里,执行 Agent 数量可能很多(几十甚至上百个),如果全部写死在代码中:</p><ul><li>新增/删除 Agent 需要改主程序代码,重新部署</li><li>不同任务场景下无法按需加载,浪费资源</li><li>Agent 的上下文和参数需求各不相同,维护困难</li></ul><p><strong>解决方案</strong>:设计一个<strong>动态加载机制</strong>,让系统可以在运行时加载、卸载和调用 Agent。</p><h3>6.1. 目标</h3><p>动态加载执行 Agent 的设计,就是通过一个统一的 Registry + Loader + Dispatcher 架构,让系统在运行时按需加载不同类型的 Agent(Prompt、代码、MCP),并自动适配它们的上下文和参数,实现可扩展、解耦和资源优化的多智能体协作。</p><ol><li><strong>热插拔</strong>:运行中加载或卸载 Agent,不影响其他部分</li><li><strong>按需加载</strong>:只有任务需要时才加载对应 Agent</li><li><strong>解耦</strong>:主系统与 Agent 实现分离,支持不同语言、不同部署方式</li><li><strong>上下文适配</strong>:每个 Agent 能声明自己需要的上下文和参数格式</li></ol><h3>6.2. 核心组件</h3><ol><li><p><strong>Agent Registry(注册中心)</strong></p><ul><li>存储所有可用 Agent 的元信息</li><li>可以是数据库、配置文件、或者服务发现系统</li><li><p>元信息包括:</p><pre><code class="json">{ &quot;name&quot;: &quot;Researcher&quot;, &quot;description&quot;: &quot;Academic paper search&quot;, &quot;capabilities&quot;: [&quot;search&quot;, &quot;academic&quot;], &quot;context_schema&quot;: { &quot;query&quot;: &quot;string&quot;, &quot;max_results&quot;: &quot;integer&quot; }, &quot;execution_type&quot;: &quot;mcp&quot;, &quot;endpoint&quot;: &quot;https://agent-server/research&quot;, &quot;version&quot;: &quot;1.2&quot; }</code></pre></li></ul></li><li><p><strong>Agent Loader(加载器)</strong></p><ul><li>根据 Registry 的信息,在运行时加载 Agent</li><li><p>支持三种加载方式:</p><ul><li><strong>本地代码型 Agent</strong>:用 <code>importlib</code>(Python)或 <code>require</code>(Node.js)加载模块</li><li><strong>远程 MCP Agent</strong>:建立 MCP 协议连接</li><li><strong>Prompt模板型 Agent</strong>:读取 Prompt 模板文件,动态替换变量</li></ul></li></ul></li><li><p><strong>Dispatcher(调度器)</strong></p><ul><li>接收 Planner 分解的子任务</li><li>查询 Registry,匹配能力标签和上下文需求</li><li>调用 Loader 动态加载 Agent</li><li>把任务上下文和参数传入 Agent 执行</li><li>收集结果并返回给协作流程</li></ul></li></ol><h3>6.3. 动态加载流程</h3><pre><code>[用户任务] → Planner(任务分解) → Dispatcher(匹配能力标签) → 查询 Agent Registry → Agent Loader(动态加载) → 传入上下文/参数 → Agent 执行任务 → 返回结果</code></pre><blockquote><strong>示例:加载一个 MCP Agent</strong></blockquote><ol><li><p><strong>Registry 记录 Agent 信息</strong></p><pre><code class="json">{ &quot;name&quot;: &quot;ChartGenerator&quot;, &quot;capabilities&quot;: [&quot;visualize&quot;, &quot;chart&quot;], &quot;context_schema&quot;: { &quot;dataset&quot;: &quot;object&quot;, &quot;chart_type&quot;: &quot;string&quot; }, &quot;execution_type&quot;: &quot;mcp&quot;, &quot;endpoint&quot;: &quot;https://charts.example.com/api/generate&quot;, &quot;version&quot;: &quot;1.0&quot; }</code></pre></li><li><strong>Dispatcher 根据任务匹配到 ChartGenerator</strong></li><li><strong>Loader 建立 MCP Client → 调用 Endpoint</strong></li><li><p><strong>传入上下文</strong></p><pre><code class="json">{ &quot;dataset&quot;: {...}, &quot;chart_type&quot;: &quot;bar&quot; }</code></pre></li><li><p><strong>Agent 返回结果</strong></p><pre><code class="json">{ &quot;image_url&quot;: &quot;https://charts.example.com/output/123.png&quot; }</code></pre></li></ol><h3>6.4. 上下文与参数适配</h3><ul><li>每个 Agent 在 Registry 中声明 <code>context_schema</code>(JSON Schema)</li><li>Dispatcher 在调用前检查上下文是否满足要求</li><li><p>如果缺少数据,可以:</p><ul><li>调用其他 Agent 补充</li><li>请求用户提供</li></ul></li><li>参数(params)用于控制执行细节(如语言、响应长度等)</li></ul><hr><h3>6.5. 优势</h3><ul><li><strong>可扩展性</strong>:新增 Agent 只需在 Registry 注册,不改主系统代码</li><li><strong>资源优化</strong>:只加载当前任务需要的 Agent</li><li><strong>多实现支持</strong>:Prompt型、代码型、MCP型都能统一管理</li><li><strong>跨团队协作</strong>:不同团队可独立开发 Agent,通过 MCP 接入</li></ul><h2>7. 完整示例</h2><p>用户提出任务:</p><blockquote>“帮我生成一份关于 2024 年亚洲旅游市场的分析报告,并附上数据可视化图表。”</blockquote><ul><li><strong>动态加载</strong>:Dispatcher 根据 Registry 信息调用 Loader,按需加载 Agent</li><li><strong>多类型 Agent</strong>:MCP型、代码型、Prompt型混合使用</li><li><strong>上下文适配</strong>:每个 Agent 的 <code>context_schema</code> 决定调用所需数据</li><li><strong>Prompt工程</strong>:Writer Agent 通过模板+上下文生成高质量报告</li><li><strong>完整闭环</strong>:从任务拆解 → 执行 → 审查 → 输出,全流程覆盖</li></ul><h3>7.1. 系统中的角色定义</h3><p><strong>1.1 Planner(任务规划者)</strong></p><ul><li>职责:分析用户任务,拆分为多个子任务</li><li>输出:任务分解列表</li></ul><p><strong>1.2 Dispatcher(任务调度器)</strong></p><ul><li>职责:根据任务能力标签匹配合适的执行型 Agent</li><li>输出:Agent调用计划</li></ul><p><strong>1.3 执行型 Agent(Executor Agent)</strong></p><ul><li><p>类型:</p><ol><li><strong>Researcher Agent</strong>(MCP协议型,负责检索数据)</li><li><strong>DataVisualizer Agent</strong>(代码逻辑型,负责生成图表)</li><li><strong>Writer Agent</strong>(Prompt驱动型,负责撰写报告)</li></ol></li></ul><p><strong>1.4 Reviewer(审查者)</strong></p><ul><li>职责:检查报告是否符合要求(完整性、准确性)</li></ul><p><strong>1.5 Communicator(沟通者)</strong></p><ul><li>职责:将最终结果以用户可理解的形式输出</li></ul><h3>7.2. Registry 中的 Agent</h3><pre><code class="json">[ { &quot;name&quot;: &quot;Researcher&quot;, &quot;capabilities&quot;: [&quot;search&quot;, &quot;market_data&quot;], &quot;context_schema&quot;: { &quot;region&quot;: &quot;string&quot;, &quot;year&quot;: &quot;integer&quot; }, &quot;execution_type&quot;: &quot;mcp&quot;, &quot;endpoint&quot;: &quot;https://agent-server/research&quot;, &quot;version&quot;: &quot;1.0&quot; }, { &quot;name&quot;: &quot;DataVisualizer&quot;, &quot;capabilities&quot;: [&quot;visualize&quot;, &quot;chart&quot;], &quot;context_schema&quot;: { &quot;dataset&quot;: &quot;object&quot;, &quot;chart_type&quot;: &quot;string&quot; }, &quot;execution_type&quot;: &quot;code&quot;, &quot;entrypoint&quot;: &quot;agents/data_visualizer.py&quot;, &quot;version&quot;: &quot;2.0&quot; }, { &quot;name&quot;: &quot;Writer&quot;, &quot;capabilities&quot;: [&quot;write&quot;, &quot;report&quot;], &quot;context_schema&quot;: { &quot;topic&quot;: &quot;string&quot;, &quot;references&quot;: &quot;array&quot;, &quot;charts&quot;: &quot;array&quot; }, &quot;execution_type&quot;: &quot;prompt&quot;, &quot;prompt_template&quot;: &quot;prompts/writer.txt&quot;, &quot;version&quot;: &quot;1.5&quot; } ]</code></pre><h3>7.3. 完整流程示例</h3><h4><strong>Step 1: 用户任务输入</strong></h4><pre><code>User → Planner</code></pre><p>输入:</p><pre><code class="json">{ &quot;task&quot;: &quot;Generate a market analysis report for Asia tourism in 2024 with charts&quot; }</code></pre><hr><h4><strong>Step 2: Planner 任务分解</strong></h4><p>Planner 输出:</p><pre><code class="json">[ { &quot;subtask&quot;: &quot;Get tourism market data for Asia in 2024&quot;, &quot;capabilities&quot;: [&quot;search&quot;, &quot;market_data&quot;] }, { &quot;subtask&quot;: &quot;Generate chart from dataset&quot;, &quot;capabilities&quot;: [&quot;visualize&quot;, &quot;chart&quot;] }, { &quot;subtask&quot;: &quot;Write market analysis report including charts&quot;, &quot;capabilities&quot;: [&quot;write&quot;, &quot;report&quot;] } ]</code></pre><hr><h4><strong>Step 3: Dispatcher 匹配 Agent</strong></h4><p>Dispatcher 查 Registry → 得到匹配:</p><pre><code class="json">[ {&quot;subtask&quot;: &quot;...&quot;, &quot;agent&quot;: &quot;Researcher&quot;}, {&quot;subtask&quot;: &quot;...&quot;, &quot;agent&quot;: &quot;DataVisualizer&quot;}, {&quot;subtask&quot;: &quot;...&quot;, &quot;agent&quot;: &quot;Writer&quot;} ]</code></pre><hr><h4><strong>Step 4: 动态加载 & 执行</strong></h4><p><strong>4.1 调用 Researcher Agent(MCP型)</strong><br>调用:</p><pre><code class="json">{ &quot;region&quot;: &quot;Asia&quot;, &quot;year&quot;: 2024 }</code></pre><p>返回:</p><pre><code class="json">{ &quot;dataset&quot;: [ {&quot;country&quot;: &quot;Japan&quot;, &quot;visitors&quot;: 3200000}, {&quot;country&quot;: &quot;Thailand&quot;, &quot;visitors&quot;: 2800000}, {&quot;country&quot;: &quot;Singapore&quot;, &quot;visitors&quot;: 1500000} ] }</code></pre><hr><p><strong>4.2 调用 DataVisualizer Agent(代码型)</strong><br>输入:</p><pre><code class="json">{ &quot;dataset&quot;: [...上一步数据...], &quot;chart_type&quot;: &quot;bar&quot; }</code></pre><p>返回:</p><pre><code class="json">{ &quot;chart_url&quot;: &quot;https://charts.example.com/output/asia-tourism-2024-bar.png&quot; }</code></pre><p><strong>4.3 调用 Writer Agent(Prompt型)</strong><br><strong>Prompt 模板</strong>(<code>prompts/writer.txt</code>):</p><pre><code>You are an expert market analyst specializing in tourism industry reports. Task: Write a comprehensive market analysis report. Topic: {{topic}} References: {{references}} Charts: {{charts}} Instructions: - Provide an introduction to the market - Include key statistics and trends - Interpret the chart data - Give predictions for the coming year - Keep the tone professional and concise</code></pre><p><strong>注入上下文</strong>:</p><pre><code class="json">{ &quot;topic&quot;: &quot;Asia Tourism Market 2024&quot;, &quot;references&quot;: [ {&quot;country&quot;: &quot;Japan&quot;, &quot;visitors&quot;: 3200000}, {&quot;country&quot;: &quot;Thailand&quot;, &quot;visitors&quot;: 2800000}, {&quot;country&quot;: &quot;Singapore&quot;, &quot;visitors&quot;: 1500000} ], &quot;charts&quot;: [&quot;https://charts.example.com/output/asia-tourism-2024-bar.png&quot;] }</code></pre><p>LLM 输出(摘要):</p><pre><code>The Asia tourism market in 2024 shows strong recovery post-pandemic, led by Japan and Thailand... [完整报告省略]</code></pre><hr><h4><strong>Step 5: Reviewer 检查</strong></h4><p>Reviewer 检查:</p><ul><li>数据引用是否正确</li><li>图表链接是否有效</li><li>报告结构是否完整</li></ul><hr><h4><strong>Step 6: Communicator 输出给用户</strong></h4><p>最终输出:</p><pre><code class="json">{ &quot;report&quot;: &quot;[完整报告文本]&quot;, &quot;chart&quot;: &quot;https://charts.example.com/output/asia-tourism-2024-bar.png&quot; }</code></pre><h3>7.4. 流程图</h3><pre><code> ┌───────────────────────────────┐ │ User(用户) │ └───────────────┬───────────────┘ │ 任务请求 ▼ ┌──────────────────────────┐ │ Planner(规划者) │ │ 分解任务为多个子任务 │ └───────────────┬──────────┘ │ 子任务列表 ▼ ┌──────────────────────────┐ │ Dispatcher(调度器) │ │ 匹配能力标签 → 查Registry │ └───────┬─────────┬────────┘ │ │ │ │ ┌───────────────────┘ └────────────────────┐ │ │ ┌─────────────────────┐ ┌───────────────────────┐ │ Agent Loader(加载器)│ │ Agent Registry(注册中心)│ │ 动态加载执行Agent │&lt;───读取元信息──────────────│ Agent元数据: │ └───────┬──────────────┘ │ name、capabilities、 │ │ │ context_schema、 │ │ │ execution_type、endpoint │ ▼ └─────────────────────────┘ ┌──────────────────────────┐ │ 执行型 Agent(Executor)│ │ 多类型: │ │ 1. Researcher(MCP型) │ │ 2. DataVisualizer(代码型)│ │ 3. Writer(Prompt型) │ └───────┬─────────┬────────┘ │ │ │ │ ▼ ▼ ┌─────────────────────┐ ┌────────────────────────────┐ │ 数据检索结果dataset │ │ 图表URL chart_url │ └───────────┬─────────┘ └───────────────┬────────────┘ │ │ └─────────────────┬────────────────┘ ▼ ┌──────────────────────────┐ │ Writer Agent(Prompt型) │ │ 生成报告 report │ └───────────────┬──────────┘ │ ▼ ┌─────────────────────────┐ │ Reviewer(审查者) │ │ 检查完整性、准确性 │ └──────────────┬─────────┘ │ ▼ ┌────────────────────────────┐ │ Communicator(沟通者) │ │ 输出给用户:报告+图表 │ └────────────────────────────┘</code></pre><blockquote><strong>数据流说明</strong></blockquote><ol><li><strong>User → Planner</strong>:用户发起任务,Planner 拆分成子任务。</li><li><strong>Planner → Dispatcher</strong>:Dispatcher 根据能力标签匹配合适的 Agent。</li><li><strong>Dispatcher → Agent Loader</strong>:Loader 根据 Registry 元信息动态加载 Agent(Prompt型、代码型、MCP型)。</li><li><p><strong>执行型 Agent</strong>:</p><ul><li><strong>Researcher(MCP型)</strong>:调用远程API获取数据集</li><li><strong>DataVisualizer(代码型)</strong>:生成图表并返回 URL</li><li><strong>Writer(Prompt型)</strong>:使用 Prompt 模板 + 上下文生成报告</li></ul></li><li><strong>Reviewer</strong>:检查结果质量与合规性</li><li><strong>Communicator</strong>:将报告和图表打包返回用户</li></ol><h2>8. 和 ReAct 的关系</h2><h3>8.1 层次关系</h3><ul><li><strong>工具(Tool)</strong> 是最底层的能力单元</li><li><strong>Agent</strong> 是上层的能力封装,内部可以调用多个工具</li><li><strong>Multi-Agent Collaboration</strong> 是更高层的编排框架,负责协调多个 Agent</li></ul><pre><code>用户任务 ↓ Multi-Agent Collaboration(编排) ↓ 多个 Agent(角色) ↓ Agent 内部可能使用 ReAct 框架调用工具</code></pre><p>换句话说:</p><blockquote>ReAct 解决的是<strong>一个智能体如何调用工具</strong>的问题, <br>Multi-Agent Collaboration 解决的是<strong>多个智能体如何协作</strong>的问题。 <br>在多智能体系统中,单个 Agent 内部可以用 ReAct 方式调用工具。</blockquote><h3>8.2 技术演进关系</h3><p>可以理解为一种<strong>发展的进步</strong>:</p><ol><li><p><strong>早期阶段</strong>:</p><ul><li>单模型调用工具(ReAct)</li><li>优点:简单直接</li><li>缺点:任务复杂时,Prompt变得庞大,工具管理困难</li></ul></li><li><p><strong>中期阶段</strong>:</p><ul><li>引入 Agent 概念,一个 Agent 封装一类能力(可能内部用 ReAct)</li><li>优点:职责清晰、可扩展</li></ul></li><li><p><strong>当前阶段</strong>:</p><ul><li>多 Agent 协作(Multi-Agent Collaboration)</li><li><p>优点:</p><ul><li>动态加载不同类型的 Agent(Prompt型、代码型、MCP型)</li><li>可跨团队开发</li><li>高可维护性和可扩展性</li></ul></li></ul></li></ol><p>所以:</p><blockquote><strong>Multi-Agent Collaboration 是在 ReAct 基础上的架构升级,把工具调用的粒度提升为 Agent 调用,并引入编排、调度、动态加载等能力。</strong></blockquote>

2025/11/2
阅读更多

从 useState 到 URLState:为什么大佬们都在删状态管理代码?

<h2>1. 前言</h2><p>当你打开这个网址时:</p><pre><code class="plain">https://prismjs.com/download.html#themes=prism&amp;languages=markup+css+clike+javascript&amp;plugins=line-numbers</code></pre><p>你会发现,所有你需要的主题、语言、插件已经被自动勾选:</p><p><img src="/img/remote/1460000047389970" alt="" title=""></p><p>当你在页面修改配置时,URL 也会随之改变。</p><p>你看,这个 URL 不仅仅是一个链接,更是一个<strong>完整的状态容器</strong>,保存了我的所有配置。无需数据库、cookie 或 localStorage,一个 URL 就解决了一切。</p><h2>2. 被忽视的 URL 超能力</h2><p>URL 是互联网最伟大的创意之一,通过 URL 请求,我们可以查找到网络上的唯一资源。</p><p>它的标准格式为:<code>&lt;scheme&gt;://&lt;netloc&gt;/&lt;path&gt;?&lt;query&gt;#&lt;fragment&gt;</code>。</p><p>但 URL 的价值远不止于此——它们是<strong>天然的状态管理解决方案</strong>。想想 URL 给我们带来的好处:</p><ul><li><strong>可分享性</strong>:发送链接,对方会看到与你完全相同的内容</li><li><strong>可书签化</strong>:保存 URL 就是保存一个特定时刻的状态</li><li><strong>浏览器历史</strong>:后退按钮正常工作</li><li><strong>深度链接</strong>:直接跳转到应用的特定状态</li></ul><p>URL 使 Web 应用具有<strong>韧性和可预测性</strong>。它们是 Web 最初的状态管理方案,自 1990 年以来就开始使用,所以千万不要忘记使用这种方式。</p><h2>3. URL 如何编码状态?</h2><p>URL 的不同部分编码不同类型的状态:</p><p><strong>路径段(/path/to/myfile.html)</strong>:最适合层次化资源导航</p><pre><code class="plain">/users/123/posts # 用户123的文章 /docs/api/authentication # 文档结构</code></pre><p><strong>查询参数(?key1=value1&key2=value2)</strong>:完美用于过滤器、选项和配置</p><pre><code class="plain">?theme=dark&amp;lang=en # UI 偏好设置 ?page=2&amp;limit=20 # 分页 ?status=active&amp;sort=date # 数据过滤</code></pre><p><strong>锚点片段(#SomewhereInTheDocument)</strong>:适合客户端导航和页面部分</p><pre><code class="plain">#L20-L35 # GitHub 行高亮 #features # 滚动到某个章节</code></pre><h2>4. URL 编码状态常见模式</h2><h3>4.1. 多个带分隔符的值</h3><pre><code class="plain">?languages=javascript+typescript+python ?tags=frontend,react,hooks</code></pre><p>这种方式简洁易读,但需要在服务器端手动解析。</p><h3>4.2. 嵌套或结构化数据</h3><pre><code class="plain">?filters=status:active,owner:me,priority:high ?config=eyJyaWNrIjoicm9sbCJ9== (base64-encoded JSON)</code></pre><p>开发者有时会将复杂的筛选器或配置对象编码到单个查询字符串中。</p><p>一种简单的约定是使用逗号分隔的键值对,而其他方法则会序列化 JSON,甚至为了安全起见对其进行 Base64 编码。</p><h3>4.3. 数组处理(方括号表示法)</h3><pre><code class="plain">?tags[]=frontend&amp;tags[]=react&amp;tags[]=hooks ?ids[0]=42&amp;ids[1]=73</code></pre><p>一种古老的模式是方括号表示法,它用于在查询参数中表示数组。这种表示法起源于早期的 Web 框架,例如 PHP,在 <code>[]</code> 参数名称后添加括号表示多个值应该组合在一起。</p><p>许多现代框架和解析器(例如 Node 的 qs 库或 Express 中间件)仍然能够自动识别这种模式。然而,它并未在 URL 规范中正式标准化,因此其行为可能因服务器或客户端的实现而异。</p><h3>4.4. 布尔处理</h3><p>对于 flag 或开关,通常会显式传递布尔值,或者依赖于键值是否为真。这样可以缩短 URL 长度,并简化功能切换。</p><pre><code class="plain">?debug=true&amp;analytics=false ?mobile (presence = true)</code></pre><h3>4.5. 结论</h3><p>使用哪种模式都是可以的,关键在于保持一致性。选择适合你应用场景的模式,并坚持使用。</p><h2>5. 实际应用案例</h2><p><strong>GitHub 行高亮:</strong></p><pre><code class="plain">https://github.com/zepouet/Xee-xCode-4.5/blob/master/XeePhotoshopLoader.m#L108-L136</code></pre><p>链接到特定文件,同时高亮显示 108-136 行。点击此链接,你会直接定位到讨论的确切代码部分。</p><p><strong>电商数据过滤器:</strong></p><pre><code class="plain">https://store.com/laptops?brand=dell+hp&amp;price=500-1500&amp;rating=4&amp;sort=price-asc</code></pre><p>这是最常见的实现。每个过滤条件、排序选项都被保存。用户可以用书签保存他们的筛选条件。</p><p><strong>谷歌地图:</strong></p><pre><code class="plain">https://www.google.com/maps/@22.443842,-74.220744,19z</code></pre><p>坐标、缩放级别和地图类型都包含在 URL 中。分享此链接,任何人都可以看到完全相同的地图视图。</p><h2>6. 什么状态应该放入 URL?</h2><p>然而<strong>并非所有状态都应该属于 URL,那什么样的状态应该放入 URL 呢?</strong></p><p><strong>适合 URL 状态:</strong></p><ul><li>搜索查询和筛选器</li><li>分页和排序</li><li>视图模式(列表/网格、深色/浅色)</li><li>日期范围和时间段</li><li>选中项或活动标签</li><li>影响内容的 UI 配置</li><li>功能开关和 A/B 测试版本</li></ul><p><strong>不适合 URL 状态:</strong></p><ul><li>敏感信息(密码、令牌、个人身份信息)</li><li>临时 UI 状态(模态框打开/关闭)</li><li>表单输入进行中(未保存的更改)</li><li>极其庞大或复杂的嵌套数据</li><li>高频瞬态(鼠标位置、滚轮位置)</li></ul><p>简单来说,你的判断标准是:</p><p><strong>如果别人点击这个 URL,他们应该看到相同的状态吗?</strong></p><p>如果是,它就属于 URL。</p><h2>7. 实现方案</h2><h3>7.1. 使用纯 JavaScript 实现</h3><p>现代 URLSearchParams API 使 URL 状态管理变得简单:</p><pre><code class="javascript">// 读取URL参数 const params = new URLSearchParams(window.location.search); const view = params.get(&quot;view&quot;) || &quot;grid&quot;; // 默认值 const page = parseInt(params.get(&quot;page&quot;)) || 1; // 更新URL参数 function updateFilters(filters) { const params = new URLSearchParams(window.location.search); params.set(&quot;status&quot;, filters.status); params.set(&quot;sort&quot;, filters.sort); // 更新URL而不重新加载页面 const newUrl = `${window.location.pathname}?${params.toString()}`; window.history.pushState({}, &quot;&quot;, newUrl); } // 处理后/前进按钮 window.addEventListener(&quot;popstate&quot;, () =&gt; { const params = new URLSearchParams(window.location.search); const filters = { status: params.get(&quot;status&quot;) || &quot;all&quot;, sort: params.get(&quot;sort&quot;) || &quot;date&quot;, }; renderContent(filters); });</code></pre><h3>7.2. 使用 React 实现</h3><p>React Router 提供了更简洁的钩子:</p><pre><code class="jsx">import { useSearchParams } from &quot;react-router-dom&quot;; function ProductList() { const [searchParams, setSearchParams] = useSearchParams(); const color = searchParams.get(&quot;color&quot;) || &quot;all&quot;; const sort = searchParams.get(&quot;sort&quot;) || &quot;price&quot;; const handleColorChange = (newColor) =&gt; { setSearchParams((prev) =&gt; { const params = new URLSearchParams(prev); params.set(&quot;color&quot;, newColor); return params; }); }; return ( &lt;select value={color} onChange={(e) =&gt; handleColorChange(e.target.value)}&gt; &lt;option value=&quot;all&quot;&gt;所有颜色&lt;/option&gt; &lt;option value=&quot;silver&quot;&gt;银色&lt;/option&gt; &lt;/select&gt; ); }</code></pre><h2>8. URL 使用最佳实践</h2><h3>8.1. <strong>优雅处理默认值</strong></h3><p>不要在 URL 中使用默认值:</p><pre><code class="jsx">// ❌ ?theme=light&amp;lang=en&amp;page=1&amp;sort=date // ✅ ?theme=dark // light 是默认的,但 dark 不是默认的</code></pre><p>在代码中读取参数时使用默认值:</p><pre><code class="jsx">function getTheme(params) { return params.get(&quot;theme&quot;) || &quot;light&quot;; // 在代码中设置默认值 }</code></pre><h3>8.2. URL 更新防抖动</h3><p>对于高频更新(例如边输入边搜索),要对 URL 更改进行防抖处理:</p><pre><code class="jsx">import { debounce } from &quot;lodash&quot;; const updateSearchParam = debounce((value) =&gt; { const params = new URLSearchParams(window.location.search); if (value) { params.set(&quot;q&quot;, value); } else { params.delete(&quot;q&quot;); } window.history.replaceState({}, &quot;&quot;, `?${params.toString()}`); }, 300);</code></pre><h3>8.3. URL 传达意义</h3><pre><code class="jsx">https://example.com/p?id=x7f2k&amp;v=3 ❌ https://example.com/products/laptop?color=silver&amp;sort=price ✅</code></pre><p>第一个链接隐藏了意图,第二个链接则意义清晰。人可以阅读它并理解其含义。机器可以解析它并提取有意义的结构。这才是优秀的 URL。</p><h2>9. 使用时要避免的反模式</h2><h3>9.1. 状态都保存在内存中的单页应用程序</h3><pre><code class="plain">// 用户一刷新,状态都丢失了 const [filters, setFilters] = useState({});</code></pre><p>如果你的应用在刷新后丢失了之前的状态,你就破坏了网络的一项基本功能。用户期望 URL 能够保留上下文。</p><h3>9.2. 包含敏感数据</h3><pre><code class="plain">// 别这样干 ?password=secret123</code></pre><h3>9.3. 命名不一致或晦涩难懂</h3><pre><code class="plain">// 晦涩难懂 ?foo=true&amp;bar=2&amp;x=dark // 自文档化且风格保持一致 ?mobile=true&amp;page=2&amp;theme=dark</code></pre><h3>9.4. 注意 URL 长度限制</h3><p>浏览器和服务器对 URL 长度都有实际的限制(通常在 2000 到 8000 个字符之间),但实际情况更为复杂,会有来自浏览器行为、服务器配置、CDN 甚至搜索引擎的限制等多种因素。</p><p>如果你遇到了这些限制,那就说明你需要重新考虑你的策略了。</p><h2>10. 总结</h2><p>好的 URL 不仅仅是指向内容,它更是描述了用户和应用程序之间的对话。</p><p>我们已经构建了复杂的状态管理库,但有时最好的解决方案其实是最简单的那一个。<strong>当你的应用在点击刷新时失去了状态,想一想,你是否错过了这个 Web 最古老、最优雅的特性?</strong></p><h2>11. 参考链接</h2><ol><li><a href="https://link.segmentfault.com/?enc=Vl%2BqYFVNX44gFXpzNAxLtA%3D%3D.z4%2FHSsE5JAuGng8e7YbCMUv9JdpY6kAMkgWwiC%2BYj%2F087Lc5nahMw8v7sH3iFugzl16FK35vrWBDAJHkNASq%2Bw%3D%3D" rel="nofollow">Your URL Is Your State</a></li></ol>

2025/11/11
阅读更多

🧸 前端不是只会写管理后台,我用 400 行代码画了一个 LABUBU !

<p>注意看,这个男人叫小何,别小看他,每天晚上 9 点 59 分他都准时打开泡泡玛特小程序蹲守 LABUBU 抢购。就在刚才,屏幕时钟倒计时又到 00:00:00 了,他立刻开始狂戳屏幕上的「立即购买」按钮,切换「购买方式」反复刷新库存,熟练的让人心疼。</p><p>可是,现实却从来没有什么“功夫不负有心人”,有的只是无数“黄牛”挥舞着自己的“科技”与小何同台竞技。毫无意外,今天的小何依然没有胜利,看着屏幕上的「已售罄」陷入了沉思 ……</p><p><strong>拼尽全力也无法战胜吗?</strong></p><p>空气里漂泊着手机屏幕反射的冷光,小何指尖的汗渍在「已售罄」三个字上洇出淡淡的印子。屏幕里 LABUBU 的笑脸还在倔强 —— 那只顶着毛茸茸耳朵、圆眼圆腮的小家伙,本该是用来治愈生活的,此刻却成了科技与欲望“厮杀”后,留给普通人的一道冷疤。</p><p>技术从来都该是温柔的,当“黄牛”用它筑起壁垒时,或许我该用同样的东西,造一扇窗!</p><p>我是一名前端开发工程师,不是切图仔,不是只会写管理后台,今天势必要夺回失去的一切!</p><p>是的,我画了一个专属于自己的 LABUBU !</p><p>👉 在线体验:<a href="https://link.segmentfault.com/?enc=eLDaiZqRWVc9Lxv09Qky6Q%3D%3D.wSMVxtBC6WUk32yXDPnDaYjCMksrZza6rqysmsse4e0%3D" rel="nofollow">https://labubu.xiaohe.ink</a></p><h2>✍️ 开始创作</h2><p><a href="https://link.segmentfault.com/?enc=Z5QMnZY1cYYfVfaSICt%2FKg%3D%3D.FN9irXVVCKidVg4GJUKqWGNlHIusNP%2BKHzS%2BRBx%2B0YA%3D" rel="nofollow">LeaferJS</a> 是一款好用的 Canvas 引擎,革新的开发体验,可用于高效绘图 、UI 交互、图形编辑。</p><p>而 <a href="https://link.segmentfault.com/?enc=DSqjscalducSRn8XQRNoMA%3D%3D.cCiW4ypcfeJ%2FQNpBdiJbLA4qtvu%2B6RSG0L4Wua8Yk2fLIpWwxEkLELoDxIfI8u%2BE" rel="nofollow">Leafer Vue</a> 是由 <a href="https://link.segmentfault.com/?enc=cPeomF0DL%2BlXU14SP7zo9g%3D%3D.c0yWDCJxQmpZ6Y%2Bol3viMO9q6luUNomN8JZ5OXRYrnc%3D" rel="nofollow">@FliPPeDround</a> 基于 LeaferJS 创建的项目,可以使用 Vue 组件化轻松构建 Leafer 应用,具有以下特性:</p><ul><li>使用 Vue 构建 Leafer 应用,高性能</li><li>生态统一,完全兼容 Leafer 插件</li><li>由 TypeScript 编写,提供强大的类型支持</li><li>提供在线演练场,即开即用、畅享创作</li></ul><p>现在,我们将使用 Leafer Vue 一起来完成这个作品!</p><h3>一半茶叶蛋</h3><p>首先是 LABUBU 的脑袋,看起来有点像被切开的茶叶蛋,可以用两段二次贝塞尔曲线来绘制一个非对称椭圆表示。</p><p>我们先编写 <code>createBezierEllipsePath</code> 工具方法,用于生成更自然流畅的椭圆路径:</p><pre><code class="ts">import { PathCreator } from &quot;leafer-ui&quot;; interface Point { x: number; y: number; } /** * 以控制点 cp 为中心反射生成点 p 关于它的对称点 */ function reflect(p: Point, cp: Point) { return { x: p.x + (p.x - cp.x), y: p.y + (p.y - cp.y) }; } /** * 创建非对称椭圆路径 */ export function createBezierEllipsePath(p1: Point, p2: Point, ox: number, oy: number) { const cp1 = { x: p1.x + ox, y: p1.y + oy }; const cp2 = { x: p2.x - ox, y: p2.y + oy }; // 通过反射生成另外两个控制点 const cp3 = reflect(p2, cp2); const cp4 = reflect(p1, cp1); return new PathCreator() .moveTo(p1.x, p1.y) // 第 1 段贝塞尔曲线 .bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, p2.x, p2.y) // 第 2 段贝塞尔曲线 .bezierCurveTo(cp3.x, cp3.y, cp4.x, cp4.y, p1.x, p1.y) .closePath() .path; }</code></pre><p>然后调用 <code>createBezierEllipsePath</code> 创建头部和脸部的路径:</p><pre><code class="ts">const headPath = createBezierEllipsePath( { x: 40, y: 240 }, { x: 260, y: 240 }, 28, -120 ); const facePath = createBezierEllipsePath( { x: 60, y: 260 }, { x: 240, y: 260 }, -10, 80 );</code></pre><p>使用 <code>Path</code> 标签传入路径,再加上填充色和描边:</p><pre><code class="html">&lt;!-- 头 --&gt; &lt;Path :path=&quot;headPath&quot; fill=&quot;#984628&quot; stroke=&quot;#000000&quot; :stroke-width=&quot;3&quot; &gt;&lt;/Path&gt; &lt;!-- 脸 --&gt; &lt;Path :path=&quot;facePath&quot; fill=&quot;#ffd9d0&quot; stroke=&quot;#000000&quot; :stroke-width=&quot;3&quot; &gt;&lt;/Path&gt;</code></pre><p>✨ 脑袋部分完成啦!</p><p><img width="723" height="549" src="/img/bVdm2co" alt="01.png" title="01.png"></p><h3>一个魔丸</h3><p>画好了脑袋,现在开始画五官。光看五官 LABUBU 跟“魔丸”哪吒是不是有点神似?哪吒和泡泡玛特甚至推出过联名款!</p><p>眼睛画起来很简单,直接使用 <code>Ellipse</code> 标签绘制几个椭圆组合起来就好,至于眉毛就用 <code>Line</code> 标签画一条曲线吧 ~</p><pre><code class="html">&lt;!-- 左眼白 --&gt; &lt;Ellipse :x=&quot;93&quot; :y=&quot;228&quot; :width=&quot;40&quot; :height=&quot;60&quot; fill=&quot;#f9f9f9&quot; stroke=&quot;#000000&quot; :stroke-width=&quot;2&quot; &gt;&lt;/Ellipse&gt; &lt;!-- 左上眼睑 --&gt; &lt;Ellipse :x=&quot;96&quot; :y=&quot;206&quot; :width=&quot;44&quot; :height=&quot;26&quot; :rotation=&quot;10&quot; :start-angle=&quot;20&quot; :end-angle=&quot;154&quot; fill=&quot;#ffd9d0&quot; &gt;&lt;/Ellipse&gt; &lt;!-- 左眉毛 --&gt; &lt;Line :points=&quot;[96, 226, 104, 233, 124, 235, 134, 232]&quot; curve stroke=&quot;#000000&quot; :stroke-width=&quot;2&quot; stroke-cap=&quot;round&quot; &gt;&lt;/Line&gt; &lt;!-- 左眼球 --&gt; &lt;Ellipse :x=&quot;100&quot; :y=&quot;242&quot; :width=&quot;28&quot; :height=&quot;45&quot; fill=&quot;#000000&quot; &gt;&lt;/Ellipse&gt; &lt;!-- 左眼光 --&gt; &lt;Ellipse :x=&quot;111&quot; :y=&quot;245&quot; :width=&quot;6&quot; :height=&quot;10&quot; fill=&quot;#ffffff&quot; &gt;&lt;/Ellipse&gt; &lt;!-- 右眼白 --&gt; &lt;Ellipse :x=&quot;165&quot; :y=&quot;228&quot; :width=&quot;40&quot; :height=&quot;60&quot; fill=&quot;#f9f9f9&quot; stroke=&quot;#000000&quot; :stroke-width=&quot;2&quot; &gt;&lt;/Ellipse&gt; &lt;!-- 右上眼睑 --&gt; &lt;Ellipse :x=&quot;158&quot; :y=&quot;214&quot; :width=&quot;44&quot; :height=&quot;26&quot; :rotation=&quot;-10&quot; :start-angle=&quot;24&quot; :end-angle=&quot;158&quot; fill=&quot;#ffd9d0&quot; &gt;&lt;/Ellipse&gt; &lt;!-- 右眉毛 --&gt; &lt;Line :points=&quot;[164, 232, 176, 236, 194, 233, 202, 226]&quot; curve stroke=&quot;#000000&quot; :stroke-width=&quot;2&quot; stroke-cap=&quot;round&quot; &gt;&lt;/Line&gt; &lt;!-- 右眼球 --&gt; &lt;Ellipse :x=&quot;171&quot; :y=&quot;242&quot; :width=&quot;28&quot; :height=&quot;45&quot; fill=&quot;#000000&quot; &gt;&lt;/Ellipse&gt; &lt;!-- 右眼光 --&gt; &lt;Ellipse :x=&quot;181&quot; :y=&quot;245&quot; :width=&quot;6&quot; :height=&quot;10&quot; fill=&quot;#ffffff&quot; &gt;&lt;/Ellipse&gt;</code></pre><p>鼻子也是一个非对称椭圆,可以用之前编写的 <code>createBezierEllipsePath</code> 创建一个小小的椭圆:</p><pre><code class="ts">const nosePath = createBezierEllipsePath( { x: 141, y: 275 }, { x: 157, y: 275 }, 2, 9 );</code></pre><pre><code class="html">&lt;!-- 鼻子 --&gt; &lt;Path :path=&quot;nosePath&quot; fill=&quot;#ff0154&quot; stroke=&quot;#000000&quot; :stroke-width=&quot;2&quot; &gt;&lt;/Path&gt;</code></pre><p>嘴巴是一条 0.76 曲率的曲线,使用 <code>Path</code> 标签的 <code>curve</code> 参数可以轻松实现。</p><p>但是牙齿画起来就比较麻烦了,因为要紧密贴合嘴巴曲线,所以我们需要编写一个方法将嘴巴的曲率转换为三次贝塞尔曲线,再根据传入牙齿的数量和大小沿曲线切线方向排布并生成对应的路径数组。</p><p>方法的具体实现如下:</p><pre><code class="ts">// 嘴巴曲线 const mouthPoints = [76, 266, 150, 304, 224, 266]; // 嘴巴曲率 const mouthCurve = 0.76; /** * 创建牙齿路径 */ function createTeethPaths( count: number, toothWidth: number, toothHeight: number, curve: number ) { const p1 = { x: mouthPoints[0], y: mouthPoints[1] }; const c0 = { x: mouthPoints[2], y: mouthPoints[3] }; const p2 = { x: mouthPoints[4], y: mouthPoints[5] }; function lerp(a: number, b: number, t: number) { return a + (b - a) * t; } // 贝塞尔曲线中间控制点 const c1 = { x: lerp(p1.x, c0.x, 0.5) - curve * 20, y: lerp(p1.y, c0.y, 0.5) + curve * 43 }; const c2 = { x: lerp(c0.x, p2.x, 0.5) + curve * 20, y: lerp(c0.y, p2.y, 0.5) + curve * 43 }; /** * 三次贝塞尔计算 */ function cubic(t: number): [number, number] { return [ (1 - t) ** 3 * p1.x + 3 * (1 - t) ** 2 * t * c1.x + 3 * (1 - t) * t ** 2 * c2.x + t ** 3 * p2.x, (1 - t) ** 3 * p1.y + 3 * (1 - t) ** 2 * t * c1.y + 3 * (1 - t) * t ** 2 * c2.y + t ** 3 * p2.y ]; } /** * 贝塞尔切线 */ function derivative(t: number): [number, number] { return [ 3 * (1 - t) ** 2 * (c1.x - p1.x) + 6 * (1 - t) * t * (c2.x - c1.x) + 3 * t ** 2 * (p2.x - c2.x), 3 * (1 - t) ** 2 * (c1.y - p1.y) + 6 * (1 - t) * t * (c2.y - c1.y) + 3 * t ** 2 * (p2.y - c2.y) ]; } const value: number[][] = []; for (let i = 0; i &lt; count; i += 1) { const t = i / (count - 1); const [cx, cy] = cubic(t); const [dx, dy] = derivative(t); const length = Math.sqrt(dx * dx + dy * dy); // 法向量 const nx = -dy / length; const ny = dx / length; const halfWidth = toothWidth / 2; const x1 = cx - halfWidth * dx / length; const y1 = cy - halfWidth * dy / length; const x2 = cx + halfWidth * dx / length; const y2 = cy + halfWidth * dy / length; const xt = cx + toothHeight * nx; const yt = cy + toothHeight * ny; const path = new PathCreator() .moveTo(x1, y1) .quadraticCurveTo(xt, yt, x2, y2) .closePath() .path; value.push(path); } return value; } const teethPaths = createTeethPaths(11, 16, 18, mouthCurve);</code></pre><p>然后使用 <code>v-for</code> 循环生成牙齿:</p><pre><code class="html">&lt;!-- 嘴巴 --&gt; &lt;Line :points=&quot;mouthPoints&quot; :curve=&quot;mouthCurve&quot; stroke=&quot;#000000&quot; :stroke-width=&quot;2&quot; stroke-cap=&quot;round&quot; &gt;&lt;/Line&gt; &lt;!-- 牙齿 --&gt; &lt;Path v-for=&quot;(item, index) in teethPaths&quot; :key=&quot;index&quot; :path=&quot;item&quot; fill=&quot;#ffffff&quot; stroke=&quot;#000000&quot; :stroke-width=&quot;2&quot; &gt;&lt;/Path&gt;</code></pre><p>🥳 我们完成了整个作品中最困难的部分!</p><p><img width="723" height="549" src="/img/bVdm2cq" alt="02.png" title="02.png"></p><h3>滑稽兔耳朵</h3><p>LABUBU 的耳朵跟滑稽兔很像,画起来也比较容易,用 <code>Ellipse</code> 标签绘制两个纵向的扁椭圆:</p><pre><code class="html">&lt;!-- 左耳 --&gt; &lt;Ellipse :x=&quot;74&quot; :y=&quot;56&quot; :width=&quot;65&quot; :height=&quot;150&quot; fill=&quot;#984628&quot; stroke=&quot;#000000&quot; :stroke-width=&quot;3&quot; &gt;&lt;/Ellipse&gt; &lt;!-- 右耳 --&gt; &lt;Ellipse :x=&quot;156&quot; :y=&quot;56&quot; :width=&quot;65&quot; :height=&quot;150&quot; fill=&quot;#984628&quot; stroke=&quot;#000000&quot; :stroke-width=&quot;3&quot; &gt;&lt;/Ellipse&gt;</code></pre><p><img width="723" height="549" src="/img/bVdm2cr" alt="03.png" title="03.png"></p><p>再用两个 <code>Ellipse</code> 标签绘制不同颜色的小椭圆表示内耳和耳蜗:</p><pre><code class="html">&lt;!-- 左内耳 --&gt; &lt;Ellipse :x=&quot;82&quot; :y=&quot;72&quot; :width=&quot;50&quot; :height=&quot;120&quot; fill=&quot;#ffd9d0&quot; stroke=&quot;#000000&quot; :stroke-width=&quot;2&quot; &gt;&lt;/Ellipse&gt; &lt;!-- 左耳蜗 --&gt; &lt;Ellipse :x=&quot;95&quot; :y=&quot;118&quot; :width=&quot;26&quot; :height=&quot;60&quot; fill=&quot;#ffbbbf&quot; stroke=&quot;#000000&quot; :stroke-width=&quot;2&quot; &gt;&lt;/Ellipse&gt; &lt;!-- 右内耳 --&gt; &lt;Ellipse :x=&quot;164&quot; :y=&quot;72&quot; :width=&quot;50&quot; :height=&quot;120&quot; fill=&quot;#ffd9d0&quot; stroke=&quot;#000000&quot; :stroke-width=&quot;2&quot; &gt;&lt;/Ellipse&gt; &lt;!-- 右耳蜗 --&gt; &lt;Ellipse :x=&quot;176&quot; :y=&quot;118&quot; :width=&quot;26&quot; :height=&quot;60&quot; fill=&quot;#ffbbbf&quot; stroke=&quot;#000000&quot; :stroke-width=&quot;2&quot; &gt;&lt;/Ellipse&gt;</code></pre><p>🐰 整个头部都完成啦!</p><p><img width="723" height="549" src="/img/bVdm2cs" alt="04.png" title="04.png"></p><h3>像个布娃娃</h3><p>身体部分需要花一些心思,我们这里使用两段二次贝塞尔曲线(手臂)和两段三次贝塞尔曲线(腿)组合完成:</p><pre><code class="ts">const bodyPath = new PathCreator() .moveTo(84, 316) .quadraticCurveTo(40, 374, 90, 368) .bezierCurveTo(74, 460, 140, 440, 147, 430) .bezierCurveTo(154, 444, 224, 454, 204, 368) .quadraticCurveTo(254, 374, 210, 316) .closePath() .path;</code></pre><p>再加上填充色和描边就形成了身体:</p><pre><code class="html">&lt;!-- 身体 --&gt; &lt;Path :path=&quot;bodyPath&quot; fill=&quot;#984628&quot; stroke=&quot;#000000&quot; :stroke-width=&quot;3&quot; &gt;&lt;/Path&gt;</code></pre><p>🐻 是不是很像一个布娃娃?可爱捏!</p><p><img width="723" height="549" src="/img/bVdm2ct" alt="05.png" title="05.png"></p><h3>加上小手和小脚</h3><p>终于到了作品的最后一部分,使用多段二次贝塞尔曲线组合绘制出 LABUBU 的小手和小脚:</p><pre><code class="ts">const leftHandPath = new PathCreator() .moveTo(68, 352) .quadraticCurveTo(48, 348, 59, 360) .quadraticCurveTo(42, 372, 58, 370) .quadraticCurveTo(50, 386, 66, 372) .quadraticCurveTo(68, 392, 76, 366) .closePath() .path; const rightHandPath = new PathCreator() .moveTo(226, 352) .quadraticCurveTo(246, 348, 235, 360) .quadraticCurveTo(252, 372, 236, 370) .quadraticCurveTo(244, 386, 228, 372) .quadraticCurveTo(226, 392, 218, 366) .closePath() .path; const leftFootPath = new PathCreator() .moveTo(104, 430) .quadraticCurveTo(103, 456, 115, 444) .quadraticCurveTo(122, 456, 128, 444) .quadraticCurveTo(144, 456, 140, 430) .closePath() .path; const rightFootPath = new PathCreator() .moveTo(191, 430) .quadraticCurveTo(192, 456, 180, 444) .quadraticCurveTo(173, 456, 167, 444) .quadraticCurveTo(151, 456, 155, 430) .closePath() .path;</code></pre><pre><code class="html">&lt;!-- 左手 --&gt; &lt;Path :path=&quot;leftHandPath&quot; fill=&quot;#ffdbd7&quot; stroke=&quot;#000000&quot; :stroke-width=&quot;3&quot; &gt;&lt;/Path&gt; &lt;!-- 右手 --&gt; &lt;Path :path=&quot;rightHandPath&quot; fill=&quot;#ffdbd7&quot; stroke=&quot;#000000&quot; :stroke-width=&quot;3&quot; &gt;&lt;/Path&gt; &lt;!-- 左脚 --&gt; &lt;Path :path=&quot;leftFootPath&quot; fill=&quot;#ffdbd7&quot; stroke=&quot;#000000&quot; :stroke-width=&quot;3&quot; &gt;&lt;/Path&gt; &lt;!-- 右脚 --&gt; &lt;Path :path=&quot;rightFootPath&quot; fill=&quot;#ffdbd7&quot; stroke=&quot;#000000&quot; :stroke-width=&quot;3&quot; &gt;&lt;/Path&gt;</code></pre><p>🎉 LABUBU 诞生!</p><p><img width="723" height="549" src="/img/bVdm2cu" alt="06.png" title="06.png"></p><h2>🖥️ 源码</h2><p>项目的完整代码可以在 <a href="https://link.segmentfault.com/?enc=ZXVfEclnNtdt0uYbKqaBlQ%3D%3D.ivQDX%2FQr4X5yE0W6jIwdjKYvGQiBo16Ivd0iLDIEnAK8vbE6NEzG8x7H1QbvAbbB" rel="nofollow">leafer-labubu</a> 仓库中查看。</p><p>赠人玫瑰,手留余香,如果对你有帮助可以给我一个 ⭐️ 鼓励,这将是我继续前进的动力,谢谢大家 🙏!</p><h2>🍬 感谢</h2><p>项目灵感及图形创意来源于 <a href="https://link.segmentfault.com/?enc=dhaVtymS2Q98fgr9F%2FidJg%3D%3D.WIBV9avdPPHjmaUL2WwVr30DEb3gfSL%2BjvjRrjWAAvAXE4zajCIR3FOHD1lxE1w4" rel="nofollow">LABUBU 简笔画教程 - Thomas</a> 。</p><h2>🍵 写在最后</h2><p>我是 xiaohe0601,热爱代码,目前专注于 Web 前端领域。</p><p>欢迎关注我的微信公众号「小何不会写代码」,我会不定期分享一些开发心得、最佳实践以及技术探索等内容,希望能够帮到你!</p>

2025/11/14
阅读更多

类比前端知识来学习Java的Spring Boot实现MySql的全栈CRUD功能——搭配Svelte+Vite

<h2>前言</h2><ul><li>本文梳理了后端的相关操作流程环节</li><li>使用Svelte+Vite(前端)搭配<a href="https://link.segmentfault.com/?enc=y2K56e2aSAgp1EWvzrgY5g%3D%3D.2O9f6cdq8eBKUC%2BKn2bR6nSKaK57lpbkuQfeI3iRaAwFyeRVzECVb2WFrqMVolxY" rel="nofollow">Spring Boot</a>(后端)</li><li>实现了一个增删改查全栈项目</li><li>有助于前端更好理解后端java的分层思想,数据流转控制</li><li>和<a href="https://link.segmentfault.com/?enc=Gp7hSf0knXyMoCvhEgMVkw%3D%3D.FT4QL4L7ikqp%2BNA8HVCP8H7sWIbv5qAQ7n92DJOrHId4YMV%2BPbKf%2BfDrdVLircbZdshmhhRUhldfRkcnk7gs9g%3D%3D" rel="nofollow">Svelte</a>尝鲜学习了解</li><li>完整前后端代码在github:<a href="https://link.segmentfault.com/?enc=5jtY2PybGvhAkRMZU%2FlDOA%3D%3D.vAcQmbhrOm0eOW1udHUFrdzCZJfHasmObxeOWXdDiUOIGV%2BpeWvYFUWCeb6IotQdqEBNn5XbCHvKQONCYh%2FboA%3D%3D" rel="nofollow">https://github.com/shuirongshuifu/svelte-springBoot-crud</a></li></ul><p>大道至简,一些知识点是相通的比如——python里面也有闭包这个概念</p><blockquote>所谓编程即:学习规则语法、理解规则语法、合理运用规则语法、从而自定义规则...</blockquote><h2>Java、Spring、Spring Boot ≈ Node.js、Express/Koa、Egg.js/NestJS</h2><h3>效果图</h3><p><img width="723" height="414" src="/img/bVdmYGP" alt="" title=""></p><p><img width="723" height="350" src="/img/bVdmYHW" alt="" title=""></p><h3>仓库代码图</h3><p><img width="723" height="439" src="/img/bVdmYGQ" alt="" title=""></p><h3>对比理解后端宏观架构流程、数据流转</h3><p>当我们知道后端具体做了什么事情以后,就更好理解了,即宏观架构流程、数据流转要清晰</p><p><strong><code>Java之于Spring之于Spring Boot 相当于 Node.js之于Express/Koa之于Egg.js/NestJS</code></strong></p><ul><li>Java底层是JDK+JRE+JVM(JDK安装以后自带JRE和JVM,类似于Node安装后自带NPM)</li><li><p>基于Java原生开发了Spring框架,基于Spring框架有了Spring Boot(开箱即用)</p><ul><li>Spring MVC是Spring框架中的一部分,所谓的MVC指的是<strong>Model</strong>、<strong>View</strong>、<strong>Controller</strong></li><li><p>简约而言,后端主要做这几件事:</p><ol start="0"><li>定义请求路由接口 <strong>(C路由)</strong></li><li>请求参数验证 <strong>(C参数验证)</strong></li><li>业务逻辑处理 <strong>(M业务逻辑)</strong></li><li>操作数据库 <strong>(M业务逻辑)</strong></li><li>返回响应数据JSON、下载返回流文件 <strong>(C路由返回 V视图概念消失弱化)</strong></li></ol></li><li><p>前后端不分离JSP时代,MVC基本后端做。即:</p><ol start="0"><li>过去: 后端 = M + C + <strong>V (渲染HTML)</strong></li><li>现在: 后端 = M + C; 前端 = <strong>V (前端框架渲染)</strong> + 交互</li></ol></li></ul></li><li>类比,Node.js --&gt; Express.js / Koa.js --&gt; Egg.js/NestJS (开箱即用)</li><li>至于Java微服务<strong>Spring Cloud</strong>实际上就是一堆<strong>Spring Boot</strong>的集合</li></ul><h3>技术栈类比</h3><table><thead><tr><th>Spring Boot 生态</th><th>Node.js 对应技术</th><th>说明</th></tr></thead><tbody><tr><td><strong>JDK 8</strong></td><td>Node.js</td><td>⚙️ 运行环境,学Java装JDK,就像学JS装Node</td></tr><tr><td><strong>Spring && Spring MVC</strong></td><td>Express/Koa/Fastify</td><td>🚀 后端基础框架,快速搭建应用服务</td></tr><tr><td><strong>Spring Boot 2.7.18</strong></td><td>Egg.js/Nest.js 或 Express/Koa/Fastify + 一堆插件</td><td>🚀 后端进阶完善的框架,可开箱即用</td></tr><tr><td><strong>MyBatis-Plus 3.5.3.1</strong></td><td>Sequelize/Prisma</td><td>🗄️ ORM框架,简化数据库操作,不用手搓sql了</td></tr><tr><td><strong>Swagger 3.0.0 + Knife4j 3.0.3</strong></td><td>swagger-ui-express</td><td>📖 API文档自动生成</td></tr><tr><td><strong>Hutool 5.8.22</strong></td><td>lodash/day.js</td><td>🛠️ 工具库,提供各种实用函数</td></tr><tr><td><strong>Apache POI 4.1.2</strong></td><td>node-xlsx / xlsx</td><td>📊 Excel文件处理,导入导出解析excel的数据</td></tr><tr><td><strong>数据库驱动(JDBC Driver)</strong></td><td>mysql或者mysql2</td><td>🔌 数据库连接</td></tr><tr><td><strong>HikariCP</strong></td><td>mysql或者mysql2内置的连接池</td><td>🔌 数据库连接池,管理数据库连接</td></tr></tbody></table><blockquote><ul><li>Maven 就像 npm,<code>pom.xml</code> 就是 <code>package.json</code>,依赖管理方式几乎一样!</li><li>Java 的包管理比 npm 更严格,但概念相同</li><li>Spring Boot 的注解就像 Vue的自定义指令</li></ul></blockquote><h2>后端五件事(简约版)</h2><ul><li><ol start="0"><li>定义请求路由接口</li></ol></li><li><ol start="2"><li>请求参数验证</li></ol></li><li><ol start="3"><li>业务逻辑处理</li></ol></li><li><ol start="4"><li>操作数据库</li></ol></li><li><ol start="5"><li>返回响应数据(JSON / 流)</li></ol></li></ul><h3>整体流程</h3><table><thead><tr><th>流程节点</th><th>核心操作</th></tr></thead><tbody><tr><td>前端 → Nginx → Controller</td><td>前端发请求(如 <code>GET /user/1</code>),Controller 用 <code>UserQueryDTO</code> 接收参数(如 <code>id=1</code>),校验参数合法性</td></tr><tr><td>Controller → Service</td><td>Controller 调用 Service 方法,传入 <code>UserQueryDTO</code> 或提取后的参数(如 <code>id=1</code>)</td></tr><tr><td>Service → Mapper</td><td>Service 处理业务逻辑(如权限判断),调用 Mapper 方法(如 <code>userMapper.selectById(1)</code>)</td></tr><tr><td>Mapper → 数据库</td><td>Mapper 执行 SQL,将查询条件(<code>id=1</code>)转为数据库语句,同时通过 Entity 映射表结构(如 <code>User</code> 类对应 <code>user</code> 表)</td></tr><tr><td>数据库 → Mapper</td><td>数据库返回结果集,Mapper 自动将结果集转为 <code>User</code> 实体对象</td></tr><tr><td>Mapper → Service</td><td>Mapper 将 <code>User</code> 实体返回给 Service</td></tr><tr><td>Service → Controller</td><td>Service 将 <code>User</code> 实体通过转换器(或手动)转为 <code>UserRespDTO</code>(屏蔽敏感字段,如密码)</td></tr><tr><td>Controller → Nginx → 前端</td><td>Controller 将 <code>UserRespDTO</code> 转为 JSON 响应,返回给前端</td></tr></tbody></table><p>下面以新增请求为例</p><blockquote>当然还有别的 这里不赘述</blockquote><h2>1. 定义请求路由接口 (Controller层)</h2><h3>定义新增接口 /people</h3><p>比如定义一个新增接口</p><ul><li>定义一个请求Url是 /people</li><li>Post 请求 Body传参</li><li>传参示例:<code>{ age: 20,home: &quot;string&quot;,name: &quot;string&quot;,remark: &quot;string&quot; }</code></li><li>curl命令调用如下</li></ul><p>&lt;!----&gt;</p><pre><code>curl -X POST http://localhost:8080/people \  -H &quot;Content-Type: application/json&quot; \  -d '{&quot;age&quot;: 20, &quot;home&quot;: &quot;string&quot;, &quot;name&quot;: &quot;string&quot;, &quot;remark&quot;: &quot;string&quot;}' </code></pre><h3>Controller控制层</h3><p><em>PeopleController.java</em></p><pre><code class="java">import com.people_sys.people.service.PeopleService; // 导入业务服务层 PeopleService // @RestController = @Controller + @ResponseBody = 返回JSON格式的数据 @RestController @RestController // REST风格的接口 @RequestMapping(&quot;/people&quot;) // 接口请求url 统一请求前缀/people public class PeopleController { // PeopleController类    // 定义变量存储peopleService    private final PeopleService peopleService;    // 构造器注入(初始化执行)    // 也可以使用注解 @Resource 或 @Autowired 一步到位    public PeopleController(PeopleService peopleService) {        this.peopleService = peopleService; // 存起来   }        // 新增人员 - POST /people    @PostMapping    public ApiResponse&lt;Boolean&gt; create(@Valid @RequestBody PeopleDTO people) throws Exception {        // 调用peopleService层的create方法新增用户人员        boolean result = peopleService.create(people);        return ApiResponse.success(result);   }    // 根据id查询 - GET /people/{id}    @GetMapping(&quot;/{id}&quot;)    public ApiResponse&lt;People&gt; getById(@PathVariable Integer id) {        // 调用peopleService层的getById方法,根据id查询对应人员的        People people = peopleService.getById(id);        return ApiResponse.success(people);   }       ...... }</code></pre><h3>何为注解&常见的注解举例</h3><p>简而言之:</p><ul><li>注解有点像前端Vue中的指令,比如只要写了v-if以后,Vue框架会自动根据相应逻辑处理显示隐藏</li><li>也像React中的Props,比如 @NotNull(message = "不能为空") 类比于 \&lt;input required ... /&gt;</li></ul><p><strong>注解(实际上是封装了一层),就是用特定的语法,告诉框架(组件),如何正确处理对应逻辑</strong></p><p>常见的注解举例:</p><ul><li>@RestController 控制器注解,标明定义的接口——适合前后端分析的项目,返回JSON</li><li>@Controller 适合前后端不分离的,比如返回html,用的少了</li><li>@Service 服务层注解,撰写具体业务逻辑</li><li>@Data Lombok注解,自动生成getter/setter/toString等</li><li><p>@RequestMapping系列注解,请求映射注解</p><ul><li>@PostMapping // POST请求</li><li>@GetMapping("/{id}") // GET请求带路径参数</li><li>@PutMapping // PUT请求</li><li>@DeleteMapping // DELETE请求</li><li>@RequestMapping("/api") // 通用映射</li></ul></li><li><p>验证注解系列</p><ul><li>@NotNull // 不为 null</li><li>@NotBlank // 去空格非空</li><li>@NotEmpty // 集合或数组至少一个元素</li><li>@Size(min=1, max=10) // 长度限制</li><li>@Email // 邮箱格式</li><li>@Min(0) @Max(150) // 数值范围</li></ul></li><li>跨域注解</li></ul><pre><code class="java">import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class TestController {    // 仅当前接口支持跨域,允许所有来源(*)    @CrossOrigin    @GetMapping(&quot;/api/test&quot;)    public String testCrossOrigin() {        return &quot;跨域请求成功!&quot;;   } }</code></pre><ul><li><p>拿到前端传参的注解</p><ul><li><p>@PathVariable注解</p><ul><li>能拿到/findById/100 这个100</li><li>类似express中的req.params + 路由 :变量名</li></ul></li><li><p>@RequestParam 注解</p><ul><li>能拿到query传参 ?name=xxx\&amp;age=88</li><li>类似express中的req.query</li></ul></li><li><p>@RequestBody</p><ul><li>能拿到body传参</li><li>类似express中的req.body + 解析中间件app.use(express.json())</li></ul></li></ul></li><li>等很多注解...</li></ul><blockquote>注解还可以自定义,有点像函数</blockquote><h2>5. 返回响应 JSON / 流</h2><h3>统一返回JSON</h3><p>首先,前端请求接口时,后端统一返回格式,使用ApiResponse这个类统一控制</p><pre><code class="java">package com.people_sys.people.config; import lombok.Data; @Data // 自动生成getter/setter public class ApiResponse&lt;T&gt; {    private int code;       // 状态码(如200成功,400参数错误,500系统错误)    private String message; // 响应消息(成功时为&quot;success&quot;,失败时为错误信息)    private T data;         // 业务数据(成功时返回)    public ApiResponse(int code, String message, T data) {        this.code = code;        this.message = message;        this.data = data;   }    // 成功响应    public static &lt;T&gt; ApiResponse&lt;T&gt; success(T data) {        return new ApiResponse&lt;&gt;(200, &quot;success&quot;, data);   }    // 错误响应    public static &lt;T&gt; ApiResponse&lt;T&gt; error(int code, String message) {        return new ApiResponse&lt;&gt;(code, message, null);   } }</code></pre><p>前端查询id为300的这条数据,<code>http://localhost:8080/people/300</code>返回</p><pre><code class="js">{    &quot;code&quot;: 200,    &quot;message&quot;: &quot;success&quot;,    &quot;data&quot;: {        &quot;id&quot;: 300,        &quot;name&quot;: &quot;张三&quot;,        &quot;age&quot;: 3,        &quot;home&quot;: &quot;山东&quot;,        &quot;remark&quot;: &quot;zhangsan&quot;,        &quot;delFlag&quot;: 0,        &quot;createTime&quot;: &quot;2025-11-04T14:48:05&quot;,        &quot;updateTime&quot;: &quot;2025-11-04T17:23:32&quot;   } }</code></pre><blockquote>ApiResponse实际上就是一个公共函数,统一加工处理数据的</blockquote><h3>返回流文件给前端下载</h3><p>动态生成Excel并下载(伪代码示例)</p><pre><code>@GetMapping(&quot;/downloadExcel&quot;) public void downloadExcel(HttpServletResponse response) throws IOException {    // 1. 动态生成Excel(使用POI等工具)    XSSFWorkbook workbook = new XSSFWorkbook(); // POI的Excel对象    XSSFSheet sheet = workbook.createSheet(&quot;Sheet1&quot;);    // ... 向sheet中写入数据 ... ​    // 2. 设置响应头(同上)    response.setContentType(&quot;application/vnd.openxmlformats-officedocument.spreadsheetml.sheet&quot;);    String fileName = URLEncoder.encode(&quot;动态生成的Excel.xlsx&quot;, &quot;UTF-8&quot;);    response.setHeader(&quot;Content-Disposition&quot;, &quot;attachment; filename*=UTF-8''&quot; + fileName); ​    // 3. 直接将工作簿写入响应流    workbook.write(response.getOutputStream());    workbook.close(); // 关闭资源 } </code></pre><blockquote>workbook.write(response.getOutputStream()) 意思就是:将内存中动态生成的文件(如 Excel)以二进制流的形式写入 HTTP 响应输出流,最终返回给前端,以便于前端使用a标签实现文件下载</blockquote><h2>2. 请求参数验证 (Controller层)</h2><h3>数据新增接口细节拆解</h3><p>接下来,看对应新增接口注释,新增接口前端Post的Body传参为</p><p><code>people: { name: 'tom', age: 2, home: 'New York', 'remark': 'xyz' }</code></p><pre><code class="java">// 1. 接口请求类型注解,等价于:@RequestMapping(method = RequestMethod.POST) @PostMapping // 2. public公开的create方法,允许其他类调用(若写成private,Spring无法扫描到这个接口,前端会访问失败) public ApiResponse&lt;Boolean&gt; create( // 方法名叫做create    // 3. 数据校验触发注解(想要使用PeopleDTO里面的校验,必须要使用@Valid标明开启校验)    @Valid      // 4. 请求体接收注解,通过这个可以拿到前端请求体里面的参数,并将其赋值给people参数    @RequestBody      // 5. 方法参数(DTO 实体 + 参数名)    PeopleDTO people  // people为函数的形参存储的前端参数 ) throws Exception {  // 6. 异常则抛出声明    // 7. 业务逻辑:把前端传递进来的people对象参数,调用 Service 层新增方法,得到布尔类型结果    boolean result = peopleService.create(people);    // 8. 返回统一响应结果,新增成功返回 {&quot;code&quot;:200,&quot;message&quot;:&quot;success&quot;,&quot;data&quot;:true}    return ApiResponse.success(result); }</code></pre><p>通过@RequestBody 可以拿到前端参数 存到people变量里面,然后交由peopleService.create方法去做业务逻辑处理进而写入数据库</p><p>但是,前端可能乱传参,所以,需要搭配PeopleDTO和 @Valid 进行校验一下</p><h3>什么是DTO,能做什么</h3><p>简而言之:DTO就是规范接收前端传参、规范返回接口数据</p><p>新增的DTO</p><pre><code class="java">package com.people_sys.people.dto; import lombok.Data; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; @Data public class PeopleDTO {    @NotBlank(message = &quot;姓名不能为空&quot;) // 必传,字符串类型    private String name;    @NotNull(message = &quot;年龄不能为空&quot;) // 必传,数字类型    private Integer age;    private String home; // 非必传    private String remark; // 非必传 }</code></pre><p>编辑的DTO多了一个id,其他和新增一样(毕竟编辑要找到对应id再去编辑)</p><pre><code class="java">@Data public class PeopleUpdateDTO { @NotNull(message = &quot;ID不能为空&quot;) private Integer id; @NotBlank(message = &quot;姓名不能为空&quot;) private String name; ... }</code></pre><p>我们可以把DTO看成一个工具函数,在接到前端传参的时候,做规范限制——过滤掉不需要的参数,校验是否符合传参要求</p><h3>用JavaScript模拟DTO功能</h3><p>假设,我们要求前端传参规则是这样的:</p><p><code>name字段必传且为字符串类型、age字段非必传(若传了必须要是数字类型),若多传则忽略之</code></p><pre><code class="java">const Dto = { name: { type: &quot;string&quot;, required: true, }, age: { type: &quot;number&quot;, required: false, }, }</code></pre><ul><li>假设用户传递<code>{ name: '孙悟空', age: 500, home: '花果山' }</code>那么经过DTO处理以后,能得到<code>{ name: '孙悟空', age: 500}</code>多传的home字段和其值,被忽略</li><li>假设用户传递<code>{ age: 500 }</code>那么经过DTO处理以后,要校验提示name是必传的</li><li>假设用户传递<code>{ name: true }</code>那么经过DTO处理以后,要校验提示name的数据类型不对</li></ul><p>于是,就可以有以下模拟代码</p><pre><code class="js">/** * 模拟定义DTO * 姓名为字符串,必传 * 年龄为数字类型,非必传 * */ const dtoDef = (params) =&gt; { // 定义Dto字段规则 const Dto = { name: { type: &quot;string&quot;, required: true, }, age: { type: &quot;number&quot;, required: false, }, } /** * 1. 必传字段校验 * */ const mustHaveKeys = [] // 1.1 收集那些字段是必传的 for (const key in Dto) { if (Dto[key].required) { mustHaveKeys.push(key) } } // 1.2 收集传递进来的key组成的数组 const paramsKeys = Object.keys(params) // 1.3 看看是否每一个必传字段,都在参数key数组里面 const flag = mustHaveKeys.every((mk) =&gt; paramsKeys.includes(mk)) // 1.4 必传参数校验 if (!flag) { console.warn(`必传字段缺失,必传字段有这些:${mustHaveKeys.join(&quot;,&quot;)}`) return false } /** * 2. 字段类型校验 * */ const resDto = {} for (const key in params) { // 在Dto里的做对应校验 if (key in Dto) { // 类型校验 if (typeof params[key] === Dto[key].type) { // 校验通过则转存一份 resDto[key] = params[key] } else { console.warn(`字段${key}类型错误,类型应为${Dto[key].type}`) return false } } // 不在Dto里面的忽略,这样resDto里存的就是定义好的 else { } } return resDto } const par = { name: '孙悟空', age: 500, home: '花果山' } // 经过dtoDef的校验、过滤就能得到符合要求的dto了 const result = dtoDef(par) console.log('result', result) // {name: '孙悟空', age: 500}</code></pre><ul><li><p>在java中,dto定义好以后,可在接受前端参数的时候使用</p><ul><li>只拿自己需要的字段值</li><li>使用注解快速校验前端传参</li></ul></li><li><p>也可在从数据库捞出数据返回给前端的时候使用</p><ul><li>比如返回的时候,password和gender字段作为保密,不返回给前端</li><li>只返回能返回的数据</li></ul></li></ul><p>所以,再总结一下:DTO就是规范接收前端传参、规范返回接口数据的一个工具</p><blockquote><p>问:如果有一些额外的字段,要返回给前端,又不是前端要传递的字段,怎么定义呢?比如返数据时,需加一个字段isAdults来确认是否成年(规则是大于18岁成年为true,小于则为false)</p><p>答:这个时候,VO就闪亮登场了</p></blockquote><h3>VO是最终返回给前端数据结构格式</h3><p>总结:VO是最终返回给前端数据结构格式——如果小项目,直接用dto也行(可以看成把dto当成vo用)</p><pre><code class="java">@Data public class PeopleVO { private Long id; // 额外字段:数据库主键(前端不用传,要展示) // 复用 DTO 中的字段 private String name; private Integer age; private String home; private String remark; private Boolean isAdults; // 额外字段:计算后信息,是否成年 }</code></pre><h2>3. 业务逻辑处理 & 4.操作数据库</h2><blockquote>先假设没有复杂业务逻辑处理,直接把前端传递的数据,存到数据库里,就需要编写sql语句</blockquote><h3>古法手搓sql</h3><p>现在接口定义好了,且用DTO规范了前端传参,接下来应该把前端传递来的参数转成sql语句</p><p>比如,新增一条数据:<code>{ &quot;name&quot;: &quot;tom&quot;, &quot;age&quot;: 18 }</code></p><pre><code class="java">public void addPerson(String name, int age) { // 拿到前端传进来的参数name和age // 拼接手搓sql String sql = &quot;INSERT INTO people (name, age) VALUES (?, ?)&quot;; // 调用Spring的jdbcTemplate类的update方法新增数据 int rowsAffected = jdbcTemplate.update(sql, name, age); // 成功插入 1 条记录 → 返回 1 if (rowsAffected &gt; 0) { System.out.println(&quot;成功插入 &quot; + rowsAffected + &quot; 条记录&quot;); } }</code></pre><p>但是这个手搓sql的方式,不优雅(可维护性、类型安全、重复劳动等),所以诞生了orm框架,通过orm框架去操作数据库</p><h3>什么是ORM</h3><ul><li>ORM(Object-Relational Mapping,<strong>对象关系映射</strong>)是一种编程技术</li><li>核心是<strong>把数据库中的 “表、行、列” 映射成程序中的 “类、对象、属性”</strong> ,</li><li>让开发者能用<strong>面向对象(OOP)的方式操作数据库</strong>,而不用直接写复杂的 SQL 语句。</li></ul><p>换句话说,ORM是翻译官</p><ul><li>数据库世界:用表(Table)、行(Row)、字段(Column)存储数据(比如 MySQL 的 <code>user</code> 表,有 <code>id</code>/<code>name</code>/<code>age</code> 字段);</li><li>程序世界:用类(Class)、对象(Object)、属性(Attribute)处理数据(比如 Java 的 <code>User</code> 类,有 <code>id</code>/<code>name</code>/<code>age</code> 属性);</li><li>ORM 的作用:在两者之间做 “翻译”—— 假使我们操作程序里的对象(比如 <code>user.name = &quot;张三&quot;</code>),ORM 自动转换成对应的 SQL(<code>UPDATE user SET name = &quot;张三&quot;</code>),执行后再把数据库结果转回</li></ul><blockquote>ORM的核心价值就是不用写 或者少写 SQL,专注业务逻辑</blockquote><p>上述案例,如果使用mybatis-plus这个orm框架(半自动化orm框架),则这样写即可</p><pre><code class="java">public void addPerson(String name, int age) { // 创建实体对象并设置参数 Person person = new Person(); person.setName(name); person.setAge(age); // 调用 MyBatis-Plus 的 insert 方法插入数据 int rowsAffected = personMapper.insert(person); if (rowsAffected &gt; 0) { System.out.println(&quot;成功插入 &quot; + rowsAffected + &quot; 条记录&quot;); } }</code></pre><p>这里的personMapper继承自BaseMapper(自带一套通用的crud的方法)如</p><ul><li><code>insert(T entity)</code>:插入一条记录</li><li><code>deleteById(Serializable id)</code>:根据主键删除</li><li><code>updateById(T entity)</code>:根据主键更新</li><li><code>selectById(Serializable id)</code>:根据主键查询</li><li><code>selectList(Wrapper&lt;T&gt; queryWrapper)</code>:条件查询列表</li></ul><p>所以可以直接insert数据</p><pre><code class="java">import com.baomidou.mybatisplus.core.mapper.BaseMapper; // 定义一个接口PersonMapper继承了BaseMapper上所用功能,比如crud的api public interface PersonMapper extends BaseMapper&lt;Person&gt; { // 无需编写任何方法,BaseMapper 已提供 CRUD 基础功能 }</code></pre><blockquote>注意,<code>BaseMapper&lt;Person&gt;</code> 中的泛型 <code>&lt;Person&gt;</code> 就是告诉 MyBatis-Plus:这个 Mapper 要操作的是与 <code>Person</code> 类绑定的那张表(即 <code>people</code> 表)</blockquote><p><strong>所以,一定要告诉 MyBatis-Plus要操作那张表,怎么告诉呢 就通过entity告诉</strong></p><p><strong>所以,这里的<code>&lt;Person&gt;</code>就是一个entity,如下</strong></p><pre><code class="java">@Data // 无需手动编写 getter、setter 等方法,@Data 会自动生成 @TableName(&quot;people&quot;) // 映射数据库表名——告诉mybatis,是那张表 public class Person { @TableId(type = IdType.AUTO) // 主键自增 private Long id; private String name; private Integer age; }</code></pre><p><strong>所以ORM 一定是要搭配着entity,才能一块干活!!!</strong></p><p><strong>通俗而言,ORM是翻译官,他要把火星文翻译成中文,所以,它需要一个火星文对应中文词典,而entity就是这本词典</strong></p><h3>常见的 ORM 框架</h3><ul><li>Python:SQLAlchemy(通用)、Django ORM(Django 内置);</li><li>Java:Hibernate(重量级)、MyBatis(半 ORM,更灵活);</li><li>JavaScript/TypeScript:Sequelize(Node.js)、Prisma(现代主流);</li><li>PHP:Eloquent ORM(Laravel 内置)。</li></ul><blockquote>无论哪种 ORM 框架,<strong>都必须通过某种形式的 “实体” 来定义 “代码对象” 与 “数据库表” 的映射关系</strong>(表名、字段名、类型、主键等)。这些 “实体” 可能叫 <code>Model(PHP Prisma Python)</code>、<code>Entity(Java、cSharp)</code> 或直接是一个类 / 配置,但本质都是 ORM 框架的 “翻译词典”—— 没有它们,ORM 就无法完成 “对象操作→SQL 语句” 的转换。</blockquote><h3>ORM之Mybatis操作sql</h3><p>回顾一下,使用ORM操作sql,首先,orm要知道操作那张表的哪些字段,当然,dto是老早就定义好了,如下提前定义好了的dto</p><pre><code class="java">// DTO(接收前端) public class PersonDTO { private String name; private Integer age; // getter/setter }</code></pre><p>我们会发现,dto中的东西不够用,毕竟DTO只是用来定义传输的部分数据,完整数据还是在表里。但是orm必须要知道完整数据,才方便操作数据库</p><p>而我们又不能把所有信息都丢到dto里面</p><p>所以,需要一个新的东西,来告知orm,完整的表、字段、数据类型是啥。用对象(类)的形式告知,映射数据库,于是就有了Entity这个文件</p><pre><code class="java">// Entity(映射数据库) @TableName(&quot;people&quot;) // 告知表名字 public class People { @TableId(type = IdType.AUTO) // 主键自增 private Long id; private String name; private Integer age; // getter/setter }</code></pre><p>现在orm知道了操作的表名是people,和表里的对应字段信息,那么orm就方便操作数据库中的表了</p><pre><code class="java">// 3. Service 层 public void addPerson(PersonDTO dto) { People people = new People(); people.setName(dto.getName()); people.setAge(dto.getAge()); // 无需写 SQL!ORM 自动 INSERT boolean saved = peopleService.save(people); if (!saved) { throw new BusinessException(&quot;保存失败&quot;); } }</code></pre><h3>ORM 框架的核心优势</h3><ol start="0"><li><strong>简化开发</strong>:不用写 SQL,减少重复工作(比如拼接 SQL、解析结果),开发效率大幅提升;</li><li><strong>屏蔽数据库差异</strong>:同一套代码,通过 ORM 适配 MySQL、PostgreSQL、SQLite 等不同数据库(ORM 负责翻译不同数据库的 SQL 语法);</li><li><strong>降低学习成本</strong>:不用精通各种数据库的 SQL 细节,专注于面向对象编程;</li><li><strong>安全性更高</strong>:自动防止 SQL 注入(比如拼接用户输入时,ORM 会自动转义)。</li></ol><h3>ORM总结</h3><p>ORM 是连接 “面向对象编程” 和 “关系型数据库” 的桥梁,核心目标是<strong>让开发者用更熟悉的 OOP 方式操作数据库,减少 SQL 编写,提升开发效率和代码可维护性</strong>。</p><h3>Entity与DTO和VO对比</h3><p><strong>Entity 是“存数据的”,DTO 是“传数据的”,VO 是“给用户看的”。</strong></p><ul><li>Entity(实体类)——一般不直接返回 Entity 给前端</li><li>DTO(Data Transfer Object,数据传输对象)</li><li>VO(View Object / Value Object,视图对象)</li></ul><p>如果项目简单、无敏感数据,可 DTO/VO 合并,但Entity 仍应隔离</p><table><thead><tr><th>维度</th><th>Entity</th><th>DTO</th><th>VO(View Object)</th></tr></thead><tbody><tr><td><strong>用途</strong></td><td>数据库映射</td><td>层间/系统间数据传输</td><td>前端展示专用</td></tr><tr><td><strong>是否持久化</strong></td><td>是(对应 DB 表)</td><td>否</td><td>否</td></tr><tr><td><strong>是否含敏感字段</strong></td><td>可能有(如密码)</td><td>通常过滤掉</td><td>通常无</td></tr><tr><td><strong>字段结构</strong></td><td>与 DB 一致</td><td>灵活,可裁剪/组合</td><td>为 UI 定制,可能格式化/计算</td></tr><tr><td><strong>使用位置</strong></td><td>Repository / JPA 层</td><td>Service ↔ Controller / API 间</td><td>Controller → 前端</td></tr><tr><td><strong>是否含逻辑</strong></td><td>一般无(或简单 getter)</td><td>无</td><td>可能有简单格式化逻辑</td></tr></tbody></table><h3>3. 业务逻辑之新增的人员不能重名</h3><pre><code class="java">@Override // 新增人员的校验:新增的人名字不能和数据库中已经有的人名字重复 public boolean create(PeopleDTO dto) throws Exception { // 校验名字是否重复 if (lambdaQuery() .eq(People::getName, dto.getName()) .eq(People::getDelFlag, 0) .count() &gt; 0) { throw new BusinessException(400, &quot;人员姓名已存在,请使用其他姓名&quot;); } People entity = BeanUtil.copyProperties(dto, People.class); return save(entity); }</code></pre><blockquote>这里的save也是mybatis-plus提供的,比直接insert更加智能</blockquote><p>由于我们的业务逻辑是——新增的人名字不能和数据库中已经有的人名字重复,所以,在写入数据库之前,还需要编写sql查询一下,数据库中有多少条数据,和当前人名一样。</p><p>Mybatis也提前准备好了lambdaQuery()以供我们进行链式调用,进行条件构造链式查询,语法简洁</p><p>若使用手写sql,则是如下写法</p><pre><code class="java">@Autowired private JdbcTemplate jdbcTemplate; // Spring 的 JDBC 工具类 public void checkDuplicateName(String name) { // 手写 SQL 字符串 String sql = &quot;SELECT COUNT(*) FROM people WHERE name = ? AND del_flag = 0&quot;; // 执行查询,获取记录数 Integer count = jdbcTemplate.queryForObject( sql, new Object[]{name}, // 绑定参数(姓名) Integer.class // 返回类型 ); // 判断并抛异常 if (count != null &amp;&amp; count &gt; 0) { throw new BusinessException(400, &quot;人员姓名已存在,请使用其他姓名&quot;); } }</code></pre><p>由此可见,当真是使用ORM框架——Mybatis更优雅提效,便于我们处理业务路基,操作数据库</p><h2>常用技术栈:</h2><ul><li>数据库:MySQL + HikariCP(连接池)</li><li>数据访问:MyBatis + MyBatis-Plus(ORM + 代码生成)</li><li>API 开发:Spring Web + SpringDoc-OpenAPI(接口 + 文档)</li><li>缓存:Redis + Spring Cache(提升性能)</li><li>消息队列:Kafka/RabbitMQ(削峰填谷)</li><li>日志:SLF4J + Logback(日志记录)</li><li>测试:JUnit 5 + Mockito(单元测试)</li><li>部署:Maven + Docker(打包部署)</li></ul><h3>Maven打包构建</h3><ul><li>Maven之于Java如同Npm之于Node.js</li><li>pom.xml文件作依赖版本管理——package.json做依赖版本管理</li><li>mvn install安装项目依赖——相当于npm install</li><li>项目内安装单个依赖,需手动在 <code>pom.xml</code> 文件中添加依赖后执行 <code>mvn install</code>——相当于npm install xxx</li><li>卸载某个依赖,需手动删除 <code>pom.xml</code> 中依赖后执行 <code>mvn clean install</code>——相当于npm uninstall xxx</li><li>mvn package相当于npm run build</li><li>Maven构建打包功能,把一堆Java开发代码打包成一个.jar文件(压缩包)——Npm把一堆前端代码打包成一个dist文件夹</li></ul><blockquote>Java基础,面向对象、类、接口、抽象、封装、继承、多态、线程、IO流 本文暂不赘述...</blockquote><h2>Svelte的增删改查尝鲜</h2><ul><li>Svelte也有生命周期,如<code>import { onMount } from &quot;svelte&quot;;</code></li><li>也可以数据双向绑定,如<code>bind:value={variable}</code></li><li>也有计算属性,如<code>$: computed = expression</code></li><li>可直接响应式变量,如<code>let variable = value</code></li><li>事件绑定语法是<code>on:click={handler}</code></li><li>阻止冒泡<code>on:click|stopPropagation</code></li><li>也有条件渲染,如</li></ul><pre><code class="js">{#if condition} &lt;!-- 内容 --&gt; {:else} &lt;!-- 其他内容 --&gt; {/if}</code></pre><ul><li>也有循环v-for、map渲染</li></ul><pre><code class="js">{#each items as item (item.id)} &lt;!-- 循环内容 --&gt; {/each}</code></pre><ul><li>也可组件化开发项目(单文件直接和vue类似,直接 <code>&lt;script&gt;</code>、<code>&lt;style&gt;</code>、<code>HTML模板</code>)</li><li>也可以import和export</li><li>分页组件父组件传对象和做事件处理<code>&lt;Page {pageInfo} on:pageChange={handlePageChange} /&gt;</code></li><li>对应子组件接收是</li></ul><pre><code class="js">// Props - 接收整个分页信息对象 export let pageInfo = { currentPage: 1, pageSize: 10, total: 0, };</code></pre><ul><li>子组件触发父组件使用<code>createEventDispatcher</code>,如</li></ul><pre><code class="js">import { createEventDispatcher } from &quot;svelte&quot;; // 创建事件分发器 const dispatch = createEventDispatcher(); // 按钮点击的回调 dispatch(&quot;pageChange&quot;, newPage);</code></pre><p>比如和React语法对比:</p><ol><li><strong>更简洁的语法</strong>: 无需 <code>useState</code>、<code>useEffect</code> 等Hook</li><li><strong>真正的响应式</strong>: 直接赋值触发更新,不需要 <code>setState()</code></li><li><strong>更少的样板代码</strong>: 无需频繁的解构和回调</li><li><strong>更小的包体积</strong>: 编译时优化,运行时更轻量</li><li><strong>内置动画支持</strong>: <code>transition</code>、<code>animate</code> 指令</li><li><strong>CSS作用域自动化</strong>: 无需CSS Modules或CSS-in-JS</li></ol><blockquote>更多完整代码和注释,参见github仓库的前后端代码,创作不易,感谢支持点赞鼓励😉😉😉</blockquote>

2025/11/9
阅读更多

Redis主从复制、哨兵模式、分片、集群

<h2>概述</h2><p>Redis是个内存数据库,速度很快,但单台服务器的内存、处理能力都是有限的。如果数据量太大(比如几十GB),单台机器存不下;或者访问量太高(比如每秒几十万次请求),单台机器扛不住。这时候就需要多台Redis一起工作,也就是"集群"相关的技术。</p><p>另外,单台机器万一宕机了,数据就没了,服务也停了,所以还需要"备份"和"自动恢复"的机制,这就是主从复制和哨兵模式的作用。</p><h2>1. 主从复制(Master-Slave Replication)</h2><h3>解决的问题</h3><p>数据备份 + 分担读压力。</p><h3>原理</h3><ul><li>选一台Redis当"主库(Master)",其他的当"从库(Slave)"。</li><li>主库负责"写操作"(增删改),写完之后会把数据同步给所有从库。</li><li>从库只能"读操作",用户查数据就去从库查,主库就不用管那么多读请求了。</li></ul><h3>例子</h3><p>就像一个老板(主库)管发号施令(写数据),几个员工(从库)抄录老板的指令(同步数据),客户来咨询(读数据)就找员工,老板专心处理新指令。</p><h3>好处</h3><ul><li>数据有备份(从库有完整数据),主库挂了,从库能顶上(需要手动切换)。</li><li>读请求分散到从库,主库压力小了。</li></ul><h2>2. 哨兵模式(Sentinel)</h2><h3>解决的问题</h3><p>主从复制的"自动故障转移"(主库挂了不用手动切换)。</p><h3>原理</h3><ul><li>专门搞几个"哨兵"进程,它们不存数据,就盯着主库和从库的状态。</li><li>哨兵们会定期给主库、从库发"心跳",检查它们活没活着。</li><li>如果主库挂了,哨兵们会投票选一个从库升级成新主库,然后让其他从库去跟新主库同步数据,整个过程自动完成,不用人管。</li></ul><h3>例子</h3><p>哨兵就像监控摄像头+自动调度员,盯着老板(主库)和员工(从库)。老板突然晕倒了,摄像头发现后,调度员立刻从员工里选一个当新老板,其他员工改听新老板的。</p><h3>好处</h3><p>主库故障时自动恢复,保证服务不中断,比主从复制更可靠。</p><h2>3. Redis分片(Sharding)</h2><h3>解决的问题</h3><p>单台Redis内存不够用(比如数据量太大,超过单台机器内存)。</p><h3>原理</h3><ul><li>把所有数据"拆分成小块",不同的块存在不同的Redis服务器上。</li><li>比如按用户ID分片:ID 1-10000存到Redis服务器A,10001-20000存到服务器B,以此类推。</li><li>客户端查数据时,先算一下这个数据该存在哪台服务器,再去对应的服务器操作。</li></ul><h3>例子</h3><p>就像图书馆的书太多,一个书架放不下,就分成多个书架,按书的编号范围放,找书时先看编号属于哪个书架,再去那个书架找。</p><h3>好处</h3><p>数据分散存储,突破单台机器的内存限制,同时也能分担读写压力(不同分片在不同机器)。</p><h2>4. Redis集群(Redis Cluster)</h2><h3>解决的问题</h3><p>整合分片、主从、故障转移的"一站式方案"。</p><h3>原理</h3><ul><li>它是Redis官方提供的集群方案,自带分片功能(数据自动分到不同节点),每个分片又有主从复制(主节点负责写,从节点备份+读),还内置了类似哨兵的故障转移机制(主节点挂了,从节点自动顶上)。</li><li>整个集群有多个"主节点"(每个主节点对应一个分片),每个主节点可以有多个从节点。</li><li>客户端连接集群时,不用自己算数据该去哪台机器,集群会自动指引。</li></ul><h3>例子</h3><p>相当于一个大型图书馆,有多个书架(分片主节点),每个书架有备份书架(从节点),还有管理员(内置哨兵功能)负责监控书架状态,哪个书架倒了,管理员就把备份书架顶上,读者借书时不用自己找书架,管理员会告诉你去哪找。</p><h3>好处</h3><p>一站式解决了数据分片、高可用(主从+自动故障转移)、负载均衡的问题,适合大规模数据和高并发场景。</p><h2>它们的关系总结</h2><ul><li><strong>主从复制</strong>:基础的备份和读分担,手动切换主库。</li><li><strong>哨兵模式</strong>:在主从复制之上,加了自动故障转移,更可靠。</li><li><strong>分片</strong>:解决数据量大的问题,把数据拆分到多台机器。</li><li><strong>Redis集群</strong>:官方集成了分片、主从、自动故障转移,是最完整的集群方案。</li></ul><p>从简单到复杂:主从复制 → 哨兵模式(主从+自动切换) → 分片(解决大数据) → Redis集群(整合所有功能)。</p><h2>公司实际部署Redis集群的方式</h2><p>公司里部署Redis集群的方式,会根据业务规模、数据量、可用性要求来定,常见的有几种方案,从简单到复杂都有:</p><h3>1. 中小公司/业务初期:哨兵模式(主从+哨兵)</h3><h4>适用场景</h4><p>数据量不算特别大(单主库能存下),但需要高可用(服务不能随便挂),比如日均请求百万级以内。</p><h4>部署方式</h4><ul><li>1个主库(Master)+ 2个从库(Slave):主库负责写,从库同步数据并分担读请求。</li><li>3个哨兵(Sentinel):单独部署在不同机器上,监控主从状态,主库挂了自动选新主库。</li><li>机器要求:至少3台服务器(比如主库1台,从库+哨兵混布2台,或5台机器更稳妥)。</li></ul><h4>例子</h4><p>电商小平台,商品库存、用户购物车数据量不大,用这套足够。主库挂了,哨兵10秒内就能切换从库顶上,服务几乎不停。</p><h3>2. 中大型公司/数据量大:Redis Cluster(官方集群)</h3><h4>适用场景</h4><p>数据量大(单台机器存不下,比如几十GB甚至上百GB),并发高(每秒几万到几十万请求),比如电商大促、社交平台。</p><h4>部署方式</h4><ul><li>至少3个主节点(每个主节点是一个分片),每个主节点配1-2个从节点(备份+读分担)。比如3主3从(共6台机器),或6主6从(12台),主从分开部署在不同机器。</li><li>集群自动分片:数据按哈希算法分到不同主节点,客户端不用管分片逻辑。</li><li>内置高可用:主节点挂了,它的从节点自动升级为主节点,类似哨兵但更集成。</li></ul><h4>例子</h4><p>某电商平台,商品数据有50GB,单台机器存不下,就分3个主节点,每个存17GB左右。大促时,读请求分散到从节点,写请求由主节点处理,某台主库挂了,从库自动顶上,不影响下单。</p><h3>3. 超大规模:Redis Cluster + 代理层(可选)</h3><h4>适用场景</h4><p>超大规模集群(比如几十上百个节点),或需要更灵活的路由、限流、监控等功能,比如互联网大厂的核心业务。</p><h4>部署方式</h4><ul><li>基础还是Redis Cluster(多主多从),但在集群前面加一层代理(比如Twemproxy、Codis)。</li><li>代理的作用:统一入口(客户端只连代理),处理分片路由、负载均衡,甚至加限流、读写分离策略。</li></ul><h4>例子</h4><p>某社交App,日活亿级,Redis集群有20个主节点,客户端通过代理连接,代理会根据用户ID计算该访问哪个分片,还能限制单用户的请求频率,保护集群。</p><h2>部署时的通用注意点</h2><h3>1. 机器隔离</h3><p>主从节点、哨兵/集群节点尽量部署在不同物理机/虚拟机,避免一台机器挂了多个节点一起挂。</p><h3>2. 配置优化</h3><ul><li><strong>内存</strong>:主库内存别用满(留20%-30%缓冲,避免频繁淘汰数据)。</li><li><strong>持久化</strong>:开AOF+RDB(AOF保证数据不丢,RDB方便快速恢复)。</li><li><strong>网络</strong>:节点间用内网通信,带宽要够(主从同步、集群心跳需要流量)。</li></ul><h3>3. 监控告警</h3><p>用Prometheus+Grafana监控节点状态、内存使用率、同步延迟,出问题及时告警(比如主从同步延迟超过10秒、内存使用率超80%)。</p><h3>4. 扩容准备</h3><p>Redis Cluster支持在线加节点(新增主从,迁移部分数据过去),提前规划好扩容方案,避免数据量暴增时手忙脚乱。</p><h2>总结</h2><ul><li><strong>小业务</strong>:哨兵模式(简单、够用)。</li><li><strong>中大规模</strong>:Redis Cluster(官方方案,省心)。</li><li><strong>超大规模</strong>:Cluster+代理(更灵活可控)。</li></ul><p>核心都是围绕"数据不丢、服务不停、能扛住并发"这三个目标,根据业务体量选合适的方案,没必要一上来就搞最复杂的~</p>

2025/11/4
阅读更多

Linux 服务器磁盘满了?教你快速找到大文件,安全删掉不踩坑!

<h2>1. 磁盘空间检查基础命令</h2><h3>1.1 查看磁盘使用情况</h3><pre><code class="bash"># 查看所有挂载点的磁盘使用情况 df -h # 查看指定目录的磁盘使用情况 df -h /home</code></pre><h3>1.2 查找大文件和目录</h3><pre><code class="bash"># 查找当前目录下大于100MB的文件 find . -type f -size +100M -exec ls -lh {} \; # 查找根目录下大于1GB的文件 find / -type f -size +1G -exec ls -lh {} \;</code></pre><h2>2. 高级大文件查找方法</h2><h3>2.1 使用du命令</h3><pre><code class="bash"># 查看当前目录下最大的10个目录 du -sh * | sort -rh | head -10 # 查看根目录下最大的10个目录 du -sh /* | sort -rh | head -10 # 查看特定目录下的大文件 du -ah /var/log | sort -rh | head -20</code></pre><h3>2.2 综合查找脚本</h3><pre><code class="bash">#!/bin/bash # 查找系统中最大的文件和目录 echo &quot;=== 最大的10个目录 ===&quot; du -h / 2&gt;/dev/null | sort -hr | head -10 echo -e &quot;\n=== 最大的10个文件 ===&quot; find / -type f -size +100M 2&gt;/dev/null | xargs ls -lh | sort -k5 -hr | head -10</code></pre><h2>3. 常见大文件类型分析</h2><h3>3.1 日志文件清理</h3><pre><code class="bash"># 查看日志目录大小 du -sh /var/log/* # 清理旧的日志文件(保留最近7天) find /var/log -name &quot;*.log&quot; -mtime +7 -delete # 压缩旧日志文件 find /var/log -name &quot;*.log&quot; -mtime +3 -exec gzip {} \;</code></pre><h3>3.2 缓存文件清理</h3><pre><code class="bash"># 查看缓存目录大小 du -sh /tmp /var/tmp /var/cache/* # 清理临时文件 rm -rf /tmp/* rm -rf /var/tmp/* # 清理包管理器缓存 yum clean all # CentOS/RHEL apt-get clean # Ubuntu/Debian</code></pre><h2>4. 实用清理脚本</h2><h3>4.1 自动化清理脚本</h3><pre><code class="bash">#!/bin/bash # disk_cleanup.sh - 自动清理大文件脚本 LOG_FILE=&quot;/var/log/disk_cleanup.log&quot; # 记录日志函数 log_message() { echo &quot;$(date '+%Y-%m-%d %H:%M:%S') - $1&quot; &gt;&gt; $LOG_FILE } # 检查磁盘使用率 check_disk_usage() { local usage=$(df / | awk 'NR==2 {print $5}' | sed 's/%//') if [ $usage -gt 80 ]; then log_message &quot;警告:磁盘使用率过高: ${usage}%&quot; return 1 fi return 0 } # 清理日志文件 cleanup_logs() { log_message &quot;开始清理日志文件...&quot; # 删除超过30天的日志 find /var/log -name &quot;*.log&quot; -mtime +30 -delete # 压缩旧日志 find /var/log -name &quot;*.log&quot; -mtime +7 -exec gzip {} \; log_message &quot;日志清理完成&quot; } # 清理临时文件 cleanup_temp() { log_message &quot;开始清理临时文件...&quot; # 清理过期的临时文件 find /tmp -type f -mtime +1 -delete find /var/tmp -type f -mtime +1 -delete log_message &quot;临时文件清理完成&quot; } # 主执行流程 main() { log_message &quot;=== 开始磁盘清理任务 ===&quot; if check_disk_usage; then cleanup_logs cleanup_temp log_message &quot;=== 磁盘清理任务完成 ===&quot; else log_message &quot;磁盘使用率过高,跳过清理操作&quot; fi } main</code></pre><h3>4.2 定时清理任务</h3><pre><code class="bash"># 添加到crontab中定期执行 # 每周日凌晨2点执行清理 0 2 * * 0 /path/to/disk_cleanup.sh # 每天凌晨3点检查磁盘使用情况 0 3 * * * df -h | grep -E &quot;(Filesystem|/)&quot; &gt; /tmp/disk_usage.txt</code></pre><h2>5. 安全清理注意事项</h2><h3>5.1 清理前检查</h3><pre><code class="bash"># 查看文件详细信息,避免误删重要文件 ls -la /var/log/ ls -la /tmp/ # 查看文件权限和所有者 ls -l /var/log/messages</code></pre><h3>5.2 验证清理操作</h3><pre><code class="bash"># 使用dry-run模式预览将要删除的文件 find /var/log -name &quot;*.log&quot; -mtime +7 -print # 先备份再删除 cp /var/log/syslog /var/log/syslog.backup rm /var/log/syslog</code></pre><h2>6. 监控和预警</h2><h3>6.1 磁盘监控脚本</h3><pre><code class="bash">#!/bin/bash # disk_monitor.sh - 磁盘监控脚本 THRESHOLD=80 EMAIL=&quot;admin@example.com&quot; # 检查磁盘使用率 check_disks() { df -h | grep -vE '^Filesystem|tmpfs|cdrom' | while read line; do usage=$(echo $line | awk '{print $5}' | sed 's/%//') partition=$(echo $line | awk '{print $1}') mount_point=$(echo $line | awk '{print $6}') if [ $usage -gt $THRESHOLD ]; then echo &quot;警告:$mount_point 磁盘使用率 $usage%&quot; # 发送邮件通知 echo &quot;磁盘使用率过高,请及时处理&quot; | mail -s &quot;磁盘警告&quot; $EMAIL fi done } check_disks</code></pre><h3>6.2 创建监控服务</h3><pre><code class="bash"># 创建systemd服务用于定时监控 cat &gt; /etc/systemd/system/disk-monitor.service &lt;&lt; EOF [Unit] Description=Disk Usage Monitor After=network.target [Service] Type=oneshot ExecStart=/usr/local/bin/disk_monitor.sh User=root [Install] WantedBy=multi-user.target EOF # 启用服务 systemctl enable disk-monitor.service</code></pre><h2>7. 常见问题解决</h2><h3>7.1 已删除但仍在使用的文件</h3><pre><code class="bash"># 查找已删除但仍被进程占用的文件 lsof +L1 # 重启相关服务释放文件 systemctl restart nginx systemctl restart httpd</code></pre><h3>7.2 大文件恢复</h3><pre><code class="bash"># 如果误删了重要文件,可以尝试使用以下方法: # 1. 使用testdisk或photorec恢复 # 2. 检查是否有备份 # 3. 检查文件系统日志 journalctl -f</code></pre><h2>8. 最佳实践建议</h2><ol><li><strong>定期监控</strong>:设置定期的磁盘使用情况检查</li><li><strong>备份重要数据</strong>:清理前确保重要数据已备份</li><li><strong>分步清理</strong>:先小范围测试,再大规模执行</li><li><strong>记录操作</strong>:详细记录清理过程和结果</li><li><strong>设置阈值</strong>:建立合理的磁盘使用率预警机制</li></ol><p>通过以上方法,可以有效地管理和维护Linux服务器的磁盘空间,确保系统稳定运行。</p>

2025/10/30
阅读更多

腾讯云智能体开发平台:让 AI 在真实场景中创造价值

<p>历经数十载的演进,人工智能领域正迎来一个前所未有的活跃期。随着生成式 AI 等技术推开产业的大门,全球开发者社区的关注焦点,已从对技术可能性的宏大探讨,彻底转向了对其实用价值的具体验证。开发者们越发迫切地探索,这些新的 AI 能力究竟能在多大程度上解决真实世界的问题。</p><p>早期的兴奋点集中在与 AI 的对话上,开发者们比拼谁的提示词能激发出更惊艳的单次回复。但很快,焦点便从“一次完美的回答”转向了“一个完整任务的自动执行”。构建的重心,从精巧的提示词,过渡到了严谨的工作流,并最终落到了能自主规划、调用工具、在动态环境中完成复杂目标的 Agent 上。<br>这种转变恰恰映射了 AI 技术本身的内在演进,正清晰地从“可对话”走向“能干事”。</p><p>在 2025 年腾讯云黑客松 Agent 应用创新挑战赛的舞台上,这一演进被具象化为多个截然不同的作品。它们共同将一个问题推至我们面前:Agent 如何同时在企业的效率战场和个体的创意主战场扎根,并最终指向服务于人的具体需求?</p><p><strong>PART 01 扎根企业的战场</strong></p><p>“营小助”首先向我们展示了智能体在企业效率战场的扎根路径。</p><p>针对通信营销场景中,客户意图分散难以及时捕捉,产品推荐缺乏精准数据支撑,以及宣传物料制作流程繁琐耗时的三大痛点,“营小助”基于腾讯云智能体开发平台,打磨出"智能营销推荐 + 营销物料生成 + 智能分析支持"三大核心能力,以助力企业实现营销服务的智能化升级。</p><p>这些能力的实现,得益于腾讯云智能体开发平台的支持。</p><p><strong>打通数据关键环节</strong></p><p>“在金融行业,我们经常要处理合同审查、信息抽取这类任务”,柴林政说,在通信营销领域,数据处理效率方面的挑战是类似的。“腾讯云智能体开发平台提供了最先进的文档解析能力,可以对 Word、PPT、PDF 甚至 Excel 进行解析,转化成适合大模型的数据格式”,让企业能够快速将现有的知识资产转化为智能体可用的知识库。此外,平台还支持企业构建问答对知识库,通过完善的标签体系实现业务知识的精细化管理;或是直接连接企业数据库,确保实时业务数据的准确获取。团队可根据不同的业务场景,灵活选择最适合的数据接入方式。</p><p><strong>稳定适配企业级需求</strong></p><p>对于企业级应用的特殊需求,平台展现出了稳定的适配能力。TO B 行业对并发和容错都有较高要求,不仅涉及大量专业知识,应用承载能力不足高并发时还可能导致服务崩溃。柴林政强调,"平台提供的 RAG 基础能力如果从零搭建会非常耗时,而且从效果和稳定性上都难以保证。"他补充道,“现在平台直接提供了这些能力,我们只需接入即可,大大节省了开发成本与时间。”</p><p><strong>工作流模式应对复杂性</strong></p><p>面对 TO B 场景固有的复杂性,平台的工作流模式提供了清晰的解决路径。 “一个 TO B 应用可能涉及十几个功能,” 柴林政解释道,“平台的工作流支持多种模式,其中‘标准模式’尤其适合业务逻辑相对固定的企业场景。”“营小助”就是把企业助手这个综合性大任务,拆解成知识、营销、公文、PPT 、海报、分析助手六个子任务,每个子任务用一个独立的工作流来实现,最后再通过平台的意图识别模型进行智能分流,最终组合成一个复杂而完整的智能应用。</p><p><strong>插件生态提升效率</strong></p><p>基于可靠的数据与稳定的架构,平台的插件生态让复杂业务功能的实现变得更为高效。"平台提供了非常丰富的插件,甚至还可以外接自己研发的一些插件,"柴林政表示。这些官方提供的稳定能力,确保了企业级应用在需求识别、产品推荐、物料生成等多个环节的可靠性。</p><p><strong>调试功能助力问题排查</strong></p><p>开发过程中难免遇到挑战。柴林政也坦言,“在智能体搭建过程中确实会遇到各种问题,尤其是实现一些定制化功能时。”他特别指出,平台的调试功能提供了关键支持,"我们可以方便地查看每个节点的运行结果和状态,这大大提升了排查问题的效率。"</p><p><a href="http://">点击查看营小助作品链接</a></p><p>显然,腾讯云智能体开发平台在企业级需求的刚性与开发的柔性之间找到了自己的平衡之道。它既确保了企业级应用的稳定可靠,又让开发者能够快速响应复杂的业务需求。这种以开发者为中心的设计理念,不仅让"营小助"这样的企业级应用得以快速落地,更重要的是,它正在降低智能体技术的开发门槛——当专业的开发者能够如此流畅地构建复杂的企业应用时,那些充满创意的非专业开发者,是否也能够实现他们的奇思妙想?</p><p><strong>PART 02 一个父亲的灵感</strong></p><p>和柴林政不同,当廖代云决定开发“数学冒险王”时,他的身份并非是一位专业的开发者,而是一个十岁孩子的父亲。“现在孩子五年级的数学题,已经超出我辅导的能力范围了”,他说。正是这份切身的育儿体验,让他萌生了用 AI 搭建一个数学启蒙伙伴的灵感。他希望孩子能够在游戏化的冒险中,轻松愉快地掌握数学知识。</p><p>这个从生活土壤中生长出的创意,在腾讯云智能体开发平台上找到了落地的路径。对廖代云来说,AI 开发只是他的业余探索。然而,正是这样一位“业余”的开发者,凭借对教育场景的深刻洞察,借助平台的全链路支持,最终让“数学冒险王”斩获了创新创意类智能体赛道的一等奖。</p><p><a href="https://">点击查看数学冒险王作品介绍</a></p><p>值得深思的是,作为非专业开发者,廖代云在腾讯云智能体开发平台上的体验,或许比专业人士更有说服力。当智能体开发从专业领域走向大众创造,一个平台是否真正实现了技术普惠,答案就藏在这样的实践里。</p><p>回顾开发过程,廖代云分享了三个关键体验。</p><p><strong>可视化编排简化搭建</strong></p><p>首先,可视化编排让创意搭建直观简单。通过直观的拖拽式工作流,他得以将供“听、看、练、创、联”五大学习环节有机组合,轻松完成了应用主体的搭建。</p><p><strong>插件生态赋能专业能力</strong></p><p>其次,插件生态让专业能力变得触手可及。“插件市场非常丰富,官方提供了很多的稳定的能力。” 廖代云充分利用了这一点,通过腾讯云长文本语音合成生成故事音频,借助 EdgeOne Page 插件将 AI 生成的代码部署为可交互的网页,调用混元文生图技术将抽象概念转化为生动图画。这些原本需要专业团队才能实现的能力,如今都可以通过插件即取即用。</p><p><strong>案例复用降低创新门槛</strong></p><p>最后,案例复用机制大大降低创新门槛。廖代云表示,"案例市场的帮助很大,遇到问题时,我直接找到了相似的案例直接复用。"这种知识共享机制让非专业开发者也能快速解决技术难题,极大地降低了试错成本和学习门槛。</p><p>此外,平台与微信公众号的打通,为"数学冒险王"提供了天然的传播渠道。廖代云强调,选择腾讯云智能体平台的一个重要考量就是其海量的用户和强大的生态,这让他能够快速将产品推向市场,让更多孩子受益。</p><p>在平台的全方位支持下,廖代云感受到的最大改变是创意验证路径的缩短。“从想法到产品的距离变得很短”,他感慨道。</p><p>另一个变化同时发生。当搭建智能体的主体框架不再困难,花费他更多精力的,是如何让每一次生成的交互页面和故事,都能稳定达到 80 分以上的可用标准。这意味着,当平台扫清了技术实现的障碍,开发者的关注点自然从"能否实现"转向了"能做多好"。平台在加速创意落地的同时,也在推动着创作者对产品质量提出更高要求。这或许,正是技术创新最有意义的归宿。</p><hr><p>"营小助"和"数学冒险王"的成功,不仅证明了腾讯云智能体开发平台的技术能力,更展现了其对不同场景的深度适配。在企业的效率战场,平台通过稳定的基础能力和灵活的工作流编排,让智能体成为提升业务效率的可靠伙伴;在个体的创意领域,平台凭借易用的开发工具和丰富的插件生态,让每个想法都有快速实现的机会。</p><p>这种差异化支持的能力,恰恰是平台价值的核心体现。当技术能够同时支撑企业级应用的严谨要求和个体创作的灵活需求时,技术才真正回归“为人人而创”的本质——于“人”所在的地方,创造真实的价值。</p>

2025/11/17
阅读更多

鸿蒙人物志 x 王丹辉|善用生态之力,专注擅长之事

<p>此篇文章来源于 SegmentFault 思否鸿蒙专区·鸿蒙人物志专题采访,阅读时长约 9 分钟,以下为正文:</p><blockquote><p>“跨设备协同”不应只是发布会上的热词,它需要在真实产品里被做成稳定、无感、可复制的日常体验。</p><p>王丹辉——北京湛泸教育首席架构师、开源项目“赵王电机智能关节”联合主理人,曾主导 ZBot-1600-v1、ZBot-800-v1 两款人形双足机器人的研发,如今专注于智能穿戴与生物信息交互领域,并以鸿蒙为技术底座推进实战落地与应用普及。他的最新项目 ColorVision,致力于帮助设计师实现包容性设计,以及让色觉障碍人群“看得更清楚”。</p></blockquote><h4><strong>能力流动:ColorVision 分布式实践路径</strong></h4><p>在技术路径的选择上,王丹辉基于工程本质的考量,将鸿蒙作为 ColorVision 的核心底座。他看中的是其分布式架构带来的跨设备协同便捷性,以及系统级安全机制对敏感色彩数据的可靠保障。</p><p>在王丹辉的理解中,鸿蒙是一套“能力中枢”。其核心不是将单一设备做强做大,而是让能力在端与端之间被调用、编排与无感迁移。这种“以能力为中心”的设计逻辑,使得同一份体验可以在手机、平板、穿戴、大屏之间自由流转。</p><p>对 ColorVision 来说,手机可承担色彩数据采集、核心矫正算法与交互任务,而具有广色域与更强算力的平板或 PC 则负责高精度校核与实时预览。用户在不同设备间切换时,参数与结果保持同源同步,体验始终一致。</p><p>真正的工程智慧体现在团队的取舍之间。ColorVision 立项时,团队评估过是否需要自建一套底层能力,包括色彩空间管理、HDR 适配、可变帧率、异构渲染一致性等。考虑到鸿蒙的 ArkGraphics 2D 原生能力以及星盾安全架构提供的全链路加密与数据存储保障,他们决定深度复用这套系统能力。这让原本需要 6–8 个月的底层开发周期压缩至 1 个多月,节省下来的时间与人力,被投入到两个用户可感知的价值点上。一个是针对不同色弱类型优化映射算法,另一个是为设计师提供实时预览与参数微调等专业功能。</p><blockquote>“借生态之力,做自己最擅长的事”王丹辉反复强调的能力边界观,让团队得以聚焦在真正创造差异化的核心环节。</blockquote><h4><strong>系统韧性:协同与降级双重保障</strong></h4><p>能力能流动只是起点,要打造稳定的体验,还需要把一致性与连续性写进系统约束。王丹辉认为,协同的精度决定了体验的上限。</p><p>他将跨端协同拆解为三个层次。在数据协同层面,基于分布式软总线实现跨端“同源同步”,并以统一色彩空间标准规避设备色域差异带来的偏差。在能力协同层面,当手机处理高清设计文件吃力时,分布式任务调度会调用平板算力完成渲染,再把结果无感回传。在标准协同层面,坚持跨端解析逻辑一致,确保“同一幅画在任何设备上看起来都一样”。这些看似朴素的工程约束,构成了用户心智里“理所当然”的一致性体验。</p><p>面对真实世界中的设备算力差异、网络波动与跨端断连,王丹辉的团队预置了三条兜底路径。<br>首先是设备能力降级,老设备自动关闭高精渲染与过渡效果,仅保留核心矫正与数据同步,并用可变帧率把交互稳在 15fps 以上。其次是按网络状态降级,断连时切本地独立运行,恢复后自动对账同步,不打断当前操作。然后是按场景优先级降级,设备在低电量等限制条件下优先保障核心功能,暂时关闭非必要动画。</p><p>这套完整的协同与降级机制,为 ColorVision 的体验稳定性奠定了基础,让团队有更多精力 投入到系统优化的下一阶段。</p><h4><strong>优化演进:从验证到稳定的三阶路径</strong></h4><p>在确立协同与降级机制后,团队将重心转向了系统长期稳定性的构建。他们为优化工作设定了明确的优先级:首要解决崩溃率和应用无响应问题,随后是帧率稳定性,最后才是启动速度与内存占用等指标。<br>项目首先追求的是“快速验证”。团队利用鸿蒙原生组件迅速搭建起基础框架,优先确保色彩矫正等核心流程能够顺畅运行,跨端协同的基本逻辑得到验证。这是从零到一的关键一步。</p><p>在核心流程跑通后,优化进入了“精准调优”阶段。通过 DevEco Studio 的 Profiler 工具,团队逐一定位性能瓶颈。例如,他们发现因重复创建 colorSpace 实例导致内存泄漏,改为单例模式后问题迎刃而解。针对穿戴设备性能特点,团队引入了轻量级渲染与动画裁剪机制,通过“高精场景才加载高级效果”的智能策略配合可变帧率技术,有效改善了帧率抖动现象。同时,冷启动阶段的分布式数据管理采用懒加载策略,显著降低了系统负担。</p><p>最终目标是实现系统的“长期稳定运行”。团队进行了大规模多设备压力测试,构建起完整的异常防护体系,并采用灰度发布与用户反馈闭环机制,让真实使用场景不断验证和校准技术方案。这条优化路径不追求参数极致,却切实保障了产品在各种环境下的可靠表现。</p><p>贯穿这一优化过程的,是一种务实的工程哲学。团队清醒地认识到,技术真正的价值,不是盲目追求参数的堆砌,而是要让生态能力与用户需求达成精准契合。鸿蒙的分布式底座恰好为这种因人制宜、因端施策的产品理念提供了坚实支撑,让团队能够为每一类用户寻找到最适合的技术路径。</p><h4><strong>开发指导:学习与上架指南</strong></h4><p>对于准备用 2-3 周时间“试水”的开发者,王丹辉给出了如下的学习路径,核心思路是“聚焦核心、复用生态、快速出成果”,避免陷入底层技术细节。</p><ul><li>第 1 周(基础打底)<br>安装 DevEco Studio,完成环境配置(模拟器/真机调试)。<br>重点学习 ArkUI 声明式开发,掌握布局(Flex/Grid)、组件(Text/Image/Button)及状态管理基础。<br>理解鸿蒙核心逻辑:分布式软总线、多端部署原理(无需深度研究底层,只需理解“一次开发多端适配”核心逻辑)。<br>跑通官方“小 demo”,感受生态能力。</li><li>第 2 周(实战落地)<br>确定 1 个简单核心场景(如“简易色彩识别工具”或“跨设备文本同步工具”),不做复杂功能。<br>复用鸿蒙原生组件,避免从零开发。<br>完成“手机+平板”双端适配(布局自适应、组件尺寸适配),借助 DevEco Studio 的“多端预览”功能快速调试。</li><li>第 3 周(优化 + 验证)<br>使用 DevEco Studio 的 Performance Profiler 排查帧率抖动、内存泄漏(重点优化启动时间<3 秒、无明显卡顿)。<br>做基础合规检查:权限申请是否合理(如仅需相机权限则不申请存储权限)、隐私声明是否完整。<br>熟悉上架流程:注册华为开发者账号、了解应用提审要求、准备测试包(APK/HAP)。</li></ul><p>参考资料:<br><a href="https://link.segmentfault.com/?enc=6PIMDp%2FGmiJtsbR32NKkyg%3D%3D.ydx4PWCb2HS6%2BZLdXmtKZmiJNH1A%2BQ5nSNE6hlEs3mkj54t9h2oAYO30U%2BFhVDqx" rel="nofollow">HarmonyOS 开发者社区</a><br><a href="https://link.segmentfault.com/?enc=rkt04kM029auDYs%2BqypTJw%3D%3D.8gjeHImaw4hftHFc4%2F1JMVOF%2Fq0Lz8ZVR4e1xpBUYxGVdUF7D4NoYOOx5PSh%2Fr0EzSW%2FnrttmTtHZW4vKH0xfAMnqdbo4vv3Ih6M92vEHYiLXON2XZ2U0v1eeWPl5oNs" rel="nofollow">HarmonyOS 开发文档</a><br><a href="https://link.segmentfault.com/?enc=87VToKBc46y%2B3DyJB0rUmA%3D%3D.7eCaBVEuKy2FkyUJONeVSbuLqgD0YTn6YHeXBG4in27o7uNoPXRmumnW7ELp%2BdGvE4eBK65R%2BYQIACr7XTarNw%3D%3D" rel="nofollow">HarmonyOS 应用市场</a></p><h4><strong>生态赋能:经验沉淀资产,联动加速成长</strong></h4><p>把视角拉向生态,鸿蒙带来的三类“高确定性机会”清晰可见:</p><ul><li>岗位机会——适合希望稳健发展的工程师,尤其是准备从 Web/Android/iOS 转型;</li><li>垂直应用——适合有明确产品想法与独立开发能力的小团队,在细分人群里做“有用户、有价值”的产品;</li><li>生态基础设施——开源组件/工具、技术分享、硬件适配与行业解决方案,能够获得生态资源联动并沉淀长期影响力。判断自己是否适合切入,只需两问:你是否握有真实场景痛点?你是否愿意沿着生态“已验证路径”把速度拨快一点?</li></ul><p>在生态内容建设上,他的建议是只写“能直接落地的方法论”,并给出一套“项目经验→可复用文档”的九宫格模板:问题背景/核心目标/备选方案权衡/量化指标/实施步骤/工具与资源/核心结论/踩坑与避坑/迭代建议,让经验从口碑变成可移植的工程资产。</p><p>此外,王丹辉谈到参与鸿蒙创新赛充分展示了自己对生态价值的深刻理解和实践。他的参赛动机并非单纯为了奖项,而是想通过实战验证三点关键观点:首先,垂直小众场景同样可以打造“有用户、有价值”的产品;其次,分布式技术并非单纯的炫技概念,而是能有效降本增效的实战工具;最后,开源与生态联动能够快速放大小众创新的影响力。他反复强调,鸿蒙生态的真正价值在于,让开发者将时间投入到创新上,而非被底层适配问题所困扰。</p><p>加入领航者计划则为王丹辉提供了直接的资源支持,使得他能够在生态中精准对接,形成了一个“贡献—成长”的良性循环:通过持续贡献技术,精进自身能力,并在联动中获得更多支持和成长空间。这一实践不仅为开发者提供了成长的沃土,更为那些专注于垂直场景的小团队带来了实现创新梦想的机会。</p><h4><strong>结语</strong></h4><p>王丹辉的探索,向我们揭示了一个清晰的逻辑:创新,应该善用生态之力,专注擅长之事。这也是鸿蒙生态为每一位开发者铺就的成长通途。</p>

2025/11/14
阅读更多

AI推理硬件选型指南:CPU 与 GPU 的抉择

<p>AI基础设施的建设不应追逐硬件潮流,而在于为任务选择最合适的工具。</p><p>Akamai全球分布式边缘网络能独特地为实时应用提供可扩展、高性价比的AI推理服务。通过对CPU的战略性使用,Akamai进一步降低了多种推理工作负载的成本与能耗,且无需牺牲性能。</p><p>如您所在的企业正在考虑构建和部署 AI 赋能应用程序,或您正在寻找合适的 AI 推理运行环境<br><a href="https://link.segmentfault.com/?enc=xXyrBzfLd8eo1tQbUy6G3g%3D%3D.k%2BmYX10E%2BEYMReKaiHAZXYp1wPfmDVXh7AnrS6IuqOMlv3XXTDdTfBQweHZrayilfl90UenGDwKNAsZlAGsdkq4vXO4RzWSMbYy0cVRhaA5W4cAypOY8CDJqdcZN1278N99SURmgJGQ2h%2B9iN0iZj8Y3wvzjxJHhWIdP7PcYPV%2F4yDzq9ujbxskbuyH%2BwAH9QE1if9T86Q9pehc03R%2FkHQ8kg4prIoIKeY8jX1fe9J931kMn%2BsAZ75pZFY9Ux4W7FHvt4qJMmvjUvINqXJT%2F4w%3D%3D" rel="nofollow">点击链接</a>了解 Akamai AI 推理云解决方案,现在申请试用可得高达 500 美元专属额度</p><h4>决策边界:CPU 还是 GPU?</h4><p>下表将助您根据模型架构、延迟需求与部署环境,做出正确的基础设施选择。<br><img width="723" height="226" src="/img/bVdm1C0" alt="" title=""></p><h4>在 Akamai Cloud 上部署 AI 推理的 9 个步骤</h4><p>以下将引导您如何使用基础设施即代码(IaC)<a href="https://link.segmentfault.com/?enc=0cgN%2BVNZr2DNABoNjVGwvA%3D%3D.bbVrTg4Gr%2F%2F004opc6p2mqVoyUAsJ68AmdSEhJZhOtyJVVhHnZsdxTg8tymDxn0csElZSV%2BXwG3iawNV8D1DI5zxTQpJhLfsCEH1Cxg%2BQ8Isyl9jofsfxdWbnVrkV48bJSbsKmefG7ADqaLh4bXOPOQNxZHXlOgJq9V4Jj%2BF%2FhVyl7Qar0D8QbyT55xUTCR9L%2BN7rEHkcmKU3q6%2FOdtqlzUyJWnZMdHee1P9bS71nXkCz68SYoMpwoGEWEXsSqS7" rel="nofollow">在Akamai Cloud上快速部署AI应用</a>。利用Terraform,您能以最小手动成本,在边缘快速创建可扩展、可移植的环境。<br>开始前,请仔细阅读每一步骤,确保理解流程以高效完成设置。<br>1.准备环境<br>2.克隆或分叉项目仓库<br>3.妥善保存密钥信息<br>4.按需配置(可选)<br>5.初始化并应用配置<br>6.设置自定义域名(可选)<br>7.访问应用<br>8.成本估算<br>9.清理资源<br><strong>1. 准备环境</strong><br>若已完成以下步骤,可跳过。但请确保在配置基础设施前所有前置条件均已满足。</p><ul><li>安装Terraform : HashiCorp ,使用Terrform在 Linode 上配置基础设施</li><li>生成API令牌:Akamai 个人访问令牌管理指南</li><li>注册SSH密钥:SSH 密钥生成指南</li></ul><p><strong>2.克隆或fork项目仓库</strong></p><ul><li>进入您想要存放项目的文件夹,例如:cd ~/Projects</li><li>运行 git clone <a href="https://link.segmentfault.com/?enc=DPHADHbmU3wHj3nzi3b5CA%3D%3D.4BIr4XXUXJjmMCEFieqZf6cGqqQ%2F%2FgMxhgXogX%2Fh1pLORmPZXE2YJ744nuz%2B6arYlv%2FmpyzzyTLqfzGmbLj8lw%3D%3D" rel="nofollow">https://github.com/jgdynamite10/moviemind-public.git</a></li><li>进入项目目录:cd moviemind-public<br>注意:若您计划对代码进行修改,应首先 Fork 此代码库:</li><li>访问您正在使用的 GitHub 代码库页面。</li><li>点击页面右上角 Watch 和 Star 选项卡之间的 Fork 按钮。</li><li>随后即可创建属于您自己的 jgdynamite10/moviemind-public.git 代码库副本。</li></ul><p><strong>3.妥善保存密钥信息</strong><br>遵循开发安全最佳实践,保护敏感数据。<br>注意:切勿将密码、密钥和令牌存入GitHub,请将 .env、secrets.tfvars 等文件加入 .gitignore。</p><p><strong>4. 按需配置(可选)</strong><br>编辑 variables.tf 中的可定制变量,使基础设施符合应用需求:</p><ul><li>Label: 为实例命名以便追踪</li><li>Region: 选择靠近用户或数据源的位置</li><li>Instance_type: 根据工作负载匹配计算资源(见表2)</li></ul><p><img width="723" height="233" src="/img/bVdm1C6" alt="" title=""><br>注意:请在基础设施配置完成后再设置域名变量,以确保所需信息可用。</p><p><strong>5. 初始化并应用配置</strong><br>运行 terraform plan 预览Terraform将创建、修改或销毁的资源,此操作不会实际应用配置,是验证变量与配置是否正确的好方法。</p><p>设置变量后,初始化Terraform工作区并应用配置以部署基础设施:<br>terraform init<br>terraform apply -var-file="secrets.tfvars"<br>Terraform会在创建资源前请求确认。此过程大约需要5至10分钟。完成后,将输出实例的公网IP及其他有用信息。</p><p><strong>6. 设置自定义域名(可选)</strong><br>若需使用品牌域名,请遵循Akamai配置自定义域名指南并启用HTTPS加密。<br>提示:若部署到计算实例,请创建一条A记录指向实例的公网IP。为加速DNS解析,建议将TTL降至300秒。<br><strong>7. 访问应用</strong><br>部署完成后,Terraform会输出实例的公网IP。</p><ul><li>等待约1分钟,待服务完全初始化。</li><li>在浏览器中访问:https://&lt;您的实例IP&gt;:8080<br>若访问遇到问题,请参考下一节的故障排除提示。</li></ul><p><strong>8. 成本估算</strong><br><a href="https://link.segmentfault.com/?enc=K0g%2Fa2WJUeLacPAohRocZQ%3D%3D.dR3NwTGNLKh%2BTTWMDlKMjjG0W3q07DcQZVum28Cn5dhBJ0ArsD%2Fltlv%2BDmG6nlRaznCQAH5QZR0cYDr5ZGRlx9%2By9ZYxFrAs7ZAzc%2BWkZvu%2BGBiEgp4SLLnLmP7JVv19TttB4%2BAJGgLXeQYWeJ0do7%2BQHYDULth%2Fpu%2FuYnxZcD7e%2FUfupHRS4WPQ%2FgIeGDARZH42rcuyeM0UW15skPgUyKG%2Fdgf1KNAbw4M4LmugiKDHlCHF%2B90cu9BidO0im656xKyNq4kehveHICMFIZ09PQ%3D%3D" rel="nofollow">使用Akamai云服务成本计算器</a>来配置和估算基础设施成本,并可对比Akamai与AWS、GCP和Azure的定价,了解潜在节省。</p><p><strong>9. 清理资源</strong><br>若不再需要该基础设施,请运行:terraform destroy,并同时清理:</p><ul><li>DNS记录(如果使用了自定义域名)</li><li>本地的密钥或临时文件</li></ul><h4>故障排除提示</h4><p><strong>配置问题:</strong></p><ul><li>运行 terraform validate 检查语法错误或缺失变量。</li><li>确保API令牌有效且账户配额充足。</li></ul><p><strong>服务器创建卡住或离线</strong><br>若过程卡顿超过3分钟或无进展,或服务器看似创建但持续离线,最佳选择是删除此服务器并重新运行 terraform apply -var-file="secrets.tfvars"。</p><p><strong>Terraform 无法建立 SSH 连接</strong><br>确保SSH代理正在运行且已添加SSH密钥。</p><p><strong>进程在任何阶段卡住</strong><br>若部署过程卡顿超过3分钟且无进展,请按 Ctrl+C 中断,然后重新运行 terraform apply -var-file="secrets.tfvars",通常可解决问题。</p><p><strong>应用无法加载</strong></p><ul><li>确认使用的IP地址和端口正确。</li><li>使用 dig 或 nslookup 验证域名解析是否正确。</li><li>若SSL证书配置失败(常见原因),请重新运行 terraform apply 命令。</li><li>检查防火墙规则与端口开放状态。</li><li>在Akamai Linode控制台确认SSH密钥与实例状态。</li><li>使用 curl 或 Postman 测试API端点。<br>若以上步骤未能解决问题,请查看Akamai服务日志或<a href="https://link.segmentfault.com/?enc=dJcrGIqG70rGnz6UagkuLQ%3D%3D.95Q6TJWaCp9%2FioRM1Myb8Peo6WbLINg9wH%2FwQEGVRw4nv5FXRAtf2zjZiqLsiyyZ3QTgoK6tQ9k73lZlV4KJHLr46SHyUsSJzhndeblDvEUHZkVD6psMbPwhVPJ5TBxpFkim9TdGa6DSA6vMHGx3Ih5206C4BEN%2B9CXpemu5BFgQ6pgLebwGIq4l3SeaAtrF1%2FMxy9O7IP1R6xWfUlGj3%2BHQRxHT%2Fdq1MUeNmqP0nWM6uB1nOVaDZvoXLBrbSx1O" rel="nofollow">联系Akamai技术支持团队</a>获取进一步协助。</li></ul><p>恭喜!</p><p>您已成功在Akamai边缘平台上使用CPU部署了AI推理服务。此设置支持多种实时应用,并可扩展自定义域名、HTTPS及可扩展基础设施。</p><h4>匹配硬件与用例,避免浪费时间和金钱</h4><p>评估AI推理硬件时,不应只关注算力,更需思考CPU和GPU如何与您的机器学习任务及数据集相匹配。多核CPU能高效处理序列任务、控制功能及数据处理;而GPU则为深度神经网络、大语言模型及其他高性能计算工作负载提供强大的并行处理能力。</p><p>CUDA或Tensor等框架利用GPU加速器来提升模型训练速度、减少瓶颈,尤其适用于重度依赖矩阵乘法与高吞吐量的算法。同时,CPU对于多数推理任务仍是高性价比之选,兼具能效与跨计算系统的可移植性。<br>无论您的AI项目涉及聊天机器人、生成式AI还是数据科学的大型数据集,理解CPU与GPU(以及Intel、AMD和NVIDIA的各类产品)的核心差异,都将助您精准匹配硬件与用例,避免浪费训练时间与基础设施成本。</p><p><img width="723" height="411" src="/img/bVdmXxx" alt="" title=""></p><p>如您所在的企业正在考虑构建和部署 AI 赋能应用程序,或您正在寻找合适的 AI 推理运行环境<br><a href="https://link.segmentfault.com/?enc=nKYyUabJNyw%2BTIEdnkPMpw%3D%3D.DGxzFdCzZt0sUO27dmWlZEOKA2%2B2IhunXFvomA5lyRXmpv1HBPe8YbF5qbXh1qrRQrJpbO%2BJwlTCaqRUijBoMsEHur%2F0murxWavKVddIyf28AW7hS0rfXvt9epnWwY7zQ%2BH4fLDq2jnQSN7AO6rELr5vwoPriLyPWBZb%2BkYch07cdCa38RjTx6hBprpLBoikG3xM75%2FoXBDZnWbHbs7aj7C%2FM7LwVhqUPWro7Bcod6ovNxzdVjAIf%2BRDQ34y9AEQEhcAHBq9ViCjaYfiJZbpGg%3D%3D" rel="nofollow">点击链接</a>了解 Akamai AI 推理云解决方案,现在申请试用可得高达 500 美元专属额度</p>

2025/11/13
阅读更多

Akamai推出Akamai Inference Cloud (AI推理云),重新定义人工智能的应用场景与实现方式

<p>近日,Akamai 正式推出Akamai Inference Cloud (AI推理云),该平台专为全球规模的低延迟、实时边缘人工智能处理而设计,初期将覆盖全球 20 个节点,后续将持续拓展全球更多节点的部署。</p><p><img width="723" height="411" src="/img/bVdm01X" alt="" title=""><br>如您所在的企业正在考虑构建和部署 AI 赋能应用程序,或您正在寻找合适的 AI 推理运行环境<br><a href="https://link.segmentfault.com/?enc=C5DzvSQ0ll%2FRcwwYHPZ8Kw%3D%3D.GbbYnlQuiqikl%2F9QZoU02cF1Io%2BH7qC6Z05i8%2FJQuW0eKZG8WeGHpPcKBbngVxkrxDCPlVbxk6e2A%2FNC4YiTTZ%2B4qeohSBak0I3BsnpPATeKhsFHLqZno6ulFZctVxvxpz0l9LekX9OKBjgsIusYjkhli%2Fg2%2BWXej2V98pKu6dCCIwyyWAHuDfuTWsakhDee9HUrwrov6goE3PBcRNx7opuzln%2Bb7Ffq%2Bu3wJQUgIDk9pJNPp4WYsQsMc6IB2GgE5IrY%2FmL3xTu9HN7GTGl9ow%3D%3D" rel="nofollow">点击链接</a>了解 Akamai AI 推理云解决方案,现在申请试用可得高达 500 美元专属额度</p><p>Akamai AI推理云依托Akamai在全球分布式架构领域的专业能力,以及NVIDIA (英伟达) Blackwell 人工智能基础设施,将推理能力从核心数据中心扩展至互联网边缘端。它可以在靠近用户与设备的边缘端,实现智能型、智能体化的人工智能推理,重新定义了人工智能的应用场景与实现方式,同时对释放人工智能真正潜力所需的加速计算技术进行了彻底重构与拓展。</p><p>阿卡迈联合创始人兼首席执行官Dr. Tom Leighton表示:“下一代人工智能需要像互联网那样‘贴近用户’,正是这种贴近性让互联网得以规模化发展,成为如今无处不在的全球平台。Akamai过去曾解决过类似的挑战,现在我们将再次做到。依托英伟达人工智能基础设施,Akamai AI推理云将把人工智能决策能力部署到全球数千个节点,满足人工智能推理算力与性能的激增需求,实现更快、更智能、更安全的响应。”</p><p>英伟达创始人兼首席执行官黄仁勋表示:“推理已成为人工智能中计算强度最高的环节——需要在全球实现实时推理。英伟达与Akamai携手将推理能力更贴近全球用户,提供更快、更具可扩展性的生成式人工智能,并开启新一代智能应用的序幕。”</p><p>借助英伟达近期发布的数据处理器(英伟达 BlueField-4 数据处理器),Akamai AI推理云可进一步保障从核心端到边缘端的数据访问与人工智能推理工作负载,并具备以下4大核心优势:</p><ul><li>将企业人工智能工厂延伸至边缘端:人工智能工厂是统筹人工智能全生命周期的核心枢纽。Akamai AI推理云将人工智能工厂的能力延伸至边缘端,通过Akamai大规模分布式边缘节点实现数据与处理的去中心化,并将请求路由至最优模型。这一能力可让智能体根据用户位置、行为与意图实时调整,自主完成协商、购买与交易优化等操作。</li><li>支持流式推理与智能体应用:人工智能智能体完成复杂任务需执行多轮连续推理,过程中产生的延迟会降低用户体验,Akamai AI推理云的边缘原生架构可实现近乎即时的响应,让人工智能智能体在多步骤工作流中具备类人类的响应速度,适用于欺诈检测、安全支付加速及工业边缘端高速决策等场景。</li><li>赋能实时物理人工智能:自动驾驶汽车、工业机器人、智慧城市基础设施等物理人工智能系统,需要毫秒级精度的决策能力以确保与物理世界的安全交互。Akamai AI推理云支持物理人工智能实时处理传感器数据、做出安全决策并协同执行操作,速度与物理世界的动态变化同步,可以将工厂车间、配送无人机、手术机器人、自动驾驶交通网络等场景,转变为可与人类安全共存的响应式智能系统。</li><li>加速价值实现进程:Akamai AI推理云的智能协调层可自动将人工智能任务路由至最优节点:常规推理需要通过英伟达 NIM 微服务在边缘端即时执行,复杂推理则依托中心化人工智能工厂处理;所有操作均通过统一平台管理,降低了底层基础设施的复杂性。</li></ul><p>此次Akamai与英伟达的深度合作,又一次突破了推理技术的应用边界,并进一步推动Akamai 构建“全球高度可扩展分布式人工智能性能” 愿景的实现。</p><p><img width="723" height="411" src="/img/bVdm01X" alt="" title=""></p><p>如您所在的企业正在考虑构建和部署 AI 赋能应用程序,或您正在寻找合适的 AI 推理运行环境<br><a href="https://link.segmentfault.com/?enc=KfFB9qZ4zyAHZ9T7iI55%2Fg%3D%3D.xRtM8%2FhiBaRN93bWHb9GSB1KskMy%2BskMArlguiHHdtnzjb6kkZyNusNrrOQjlZNwfDUjxdCpvGShaPNuaDdU9Iz4JGYIuMNs8XPZrTgvC70icB%2FakUOZBQYeq2iTQSTtOKjVNE3ToQflfXIiRt2gemVSIlZaOx%2FAHXMuBnoD%2FID3Stq7Hhf3uhwqSzkxNEkTWEfngv2H%2BWIv5jBPfnzXINKssx8a5GLTk7Gh%2FLN1D5Xz07hRq%2B3tGlUUVDgew5ae%2BdsTsxabpiEnpXOyo8VidA%3D%3D" rel="nofollow">点击链接</a>了解 Akamai AI 推理云解决方案,现在申请试用可得高达 500 美元专属额度</p>

2025/11/12
阅读更多

腾讯云 Agent 应用创新大赛收官,智能体迎来加速时刻

<p>近日,首届腾讯云黑客松 Agent 应用创新挑战赛完成评审并正式收官。赛期内,超过 2000 个团队提交了智能体解决方案,覆盖政务、教育、法律、健康、网络安全等十余个领域。从营销助手到政务客服,从教育伙伴到无障碍服务,参赛项目几乎覆盖了当前企业服务与用户交互的主要场景。这些作品不仅展现了智能体技术在实际业务中的应用潜力,更清晰地反映出该领域发展的四个确定性方向。</p><p><strong>01应用场景持续丰富,智能体从单一功能向全流程赋能演进</strong><br>大赛的优秀作品共同展现了一个重要转变:智能体正在从解决单一问题的工具,演进为深度嵌入业务流程的协同力量。</p><p>以企业赛道一等奖作品"营小助"为例,它能够承接从识别客户需求、分析用户特征,到推荐匹配产品、生成沟通话术,再到自动制作所需海报、公文和演示文稿等一系列工作,覆盖了营销业务中多个相互关联的环节,展现出了在完整业务链中的价值创造能力。其他获奖作品也体现了这一点。如专注于实战培训的"AI 销售陪练助手"、提供决策支持的"销售决策伙伴",以及确保合规性的"智能合同审查助手",都在各自环节补全了业务链条上的关键能力,共同印证了智能体在复杂业务流程中的融合深度持续提升。</p><p><a href="https://link.segmentfault.com/?enc=hwhosle2QBFWJqJJcg4Sgw%3D%3D.vJ4KouAfQibP911wF6Ap6qZnYIi%2FT%2BhfmmZZPAgNgWoFJJfFSWEW8fXQO3rJ2G%2FNuVYkwo%2Bd1RcfUFm6FEnLaA%3D%3D" rel="nofollow">点击查看一等奖作品 营小助 视频介绍</a></p><p>这种系统性能力的构建,离不开腾讯云智能体开发平台的工程化支持。通过统一的开发平台,不同团队可以在共享知识库的基础上协同工作,同时又保持各自的权限边界,从构建、测试到上线的全流程,都能在同一个环境中完成。这种更加集成、高效的开发模式,让开发者能够将精力聚焦于业务逻辑的实现,而非技术整合,从而让打造跨部门、多环节的智能体应用变得更为顺畅。</p><p><strong>02开发平台日趋成熟,智能体步入规模化应用阶段</strong></p><p>当前智能体开发平台的进步,让构建过程变得更规范、更高效。开发者得以将更多精力放在业务逻辑上,而非技术细节。例如,"网络信息安全检测智能体" 通过任务分发中枢、空间引擎和 JS 信息收集三个智能体的协同配合,实现了从自然语言理解到专业安全检测的完整流程,展现了平台在复杂任务调度与多步骤工作流方面的稳定支撑能力。</p><p><img src="/img/remote/1460000047376342" alt="" title=""></p><p>同样,"化学实验室助手" 采用了"主 Agent 引领+多子 Agent 分工协作"的架构,实现了从需求分析、方案设计到安全检验的完整实验方案生成流程,其复杂的分工协作机制同样需要平台在流程编排与状态管理上的坚实基础。这些涉及网络安全、化学实验等专业领域的复杂应用,证明了腾讯云智能体开发平台在多智能体协同、工作流编排方面的技术成熟度。平台不仅提供了稳定可靠的技术底座,还通过开放的插件生态支持各专业领域的功能扩展,推动智能体技术从实验探索阶段稳步走向规模化业务部署的新时期。</p><p><strong>03生态集成能力增强,智能体部署效率显著提升</strong></p><p>当前,数字应用与现有业务系统的融合程度正在不断深化。我们观察到,从企业内部办公到对外客户服务,各类数字化解决方案正快速融入实际业务场景。</p><p>以中国石化官方微信公众号 AI 客服"小石头"为例,该系统基于腾讯元器搭建,通过平台标准化的接入方案,"小石头"客服系统能够快速部署至企业公众号环境,为用户提供加油站点查询、油价咨询、充电服务等专业解答。值得注意的是,这类集成方案正在从基础功能对接向深度业务服务方向发展。平台提供的标准化接口和配置模板,使开发者能够在较短时间内完成从系统开发到实际部署的全流程工作。</p><p>基于结构化"问答对"知识库与检索增强生成(RAG)技术框架,“小石头”上线后通过持续优化,有效解决了早期版本中的应答偏差问题,展现出快速迭代的能力。可以看到,客服系统正从传统的问答工具,转变为企业业务前端的重要环节,直接承担起业务咨询与服务引导的职责。这一转变不仅提升了服务效率,更推动了企业客户服务体系的整体升级,为规模化应用奠定了坚实基础。</p><p><strong>04场景理解不断深化,智能体应用向垂直领域深度渗透</strong></p><p>随着技术不断成熟,智能体应用正加速向各行各业的具体业务场景深入。这一趋势同样反映在大赛作品中,多个作品都展现出了对特定场景真实需求与运作逻辑的精准把握和解决能力。这种深度渗透,在企业级核心业务中表现为从“效率工具”进阶为“业务专家”。 </p><p>以“企微朋友圈助手”为例,它通过深度融合 Multi-Agent 协作技术,将私域营销中“构思-创作-发布”的全流程自动化,直接嵌入企业微信,使智能体从一个对话工具转变为能够驱动实际业务的营销助理。这一趋势同样席卷公共服务与特定人群关怀领域,推动服务模式向专业化、深度化演进。 </p><p>例如,“智汇海淀”政务智能体通过深度融合政策知识与计算能力,将传统信息查询升级为可即时提供精准计算结果的专业服务。而"无障碍影视志愿者 AI 智能服务系统"则通过 AI 与志愿者协作,成功将口述影像内容的制作周期从 30 天缩短至 3 天,精准解决了视障人群文化消费的长期痛点。</p><p><img src="/img/remote/1460000047376343" alt="图片" title="图片"></p><p>这些项目的共同之处在于,它们都通过深入理解具体场景的运作方式,直接切入业务核心。无论是营销、政务还是无障碍服务,智能体正通过解决那些高度具体、专业且紧迫的现实问题,证明其作为企业及社会数字化转型中不可或缺的专业力量。</p><p><strong>05结语</strong></p><p>从企业流程到个体生活,从效率工具到创意载体,智能体技术已经度过了早期探索阶段,开始在各个行业中找到自己的位置。功能整合让智能体真正有用,技术成熟让智能体稳定可靠,生态集成让智能体触手可及,场景深耕让智能体创造价值,这四大趋势共同构成了智能体技术落地的完整拼图。</p><p>在这一过程中,腾讯云智能体开发平台展现出支撑复杂业务系统的能力,其多智能体协同和工作流编排功能,让开发团队能够构建出满足专业要求的企业级应用。此外,腾讯元器降低了智能体的应用门槛,让更多场景能够快速接入智能服务。两大平台相得益彰,为不同类型的智能体应用提供了合适的技术土壤。</p><p>随着技术基础逐步夯实,智能体的竞争焦点正在从“是否可用”转向“是否好用”。评判标准变得更加务实——能否准确理解场景需求,能否有效解决实际问题。而腾讯云通过提供从开发、测试到部署、集成的全流程支持,正在帮助开发者和企业跨越从技术到应用的最后一公里。现在,当技术准备就绪、生态通路打开,是时候思考你所要解决的问题,然后像他们一样,去搭建了。</p>

2025/11/7
阅读更多

50多张图详细记录——使用Jenkins完成前端项目CICD自动化部署教程(不踩坑!)

<h2>前言概述</h2><p>本文记录使用<code>docker</code>安装<code>jenkins</code>以后,推送代码到<code>gitee</code>和<code>gitlab</code>上,而后执行构建前端项目的过程,对应cicd的操作设置流程环节</p><p>包含:手动触发构建和webhook自动化构建</p><p>50多张图,详细记录过程,且按照笔者的这篇文章操作,不会踩坑被卡好久(笔者已经踩过坑了)</p><h2>前端CICD自动化部署角色分工</h2><p>前端开发过程中,关于项目发布上线,标准化而言涉及四个角色(假设是四台硬件设备)</p><h3>四个角色分工</h3><ol><li>自己的电脑——用来写代码(通过<code>git push</code>把本机代码推送到<code>gitla</code>b服务器)</li><li><code>gitlab</code>服务器——用来存代码(注意,<code>gitlab</code>上面的代码,一般无法直接<code>push</code>到<code>jenkins</code>服务器上,需要<strong>jenkins主动从gitlab那里去“捞”代码</strong>)</li><li><code>jenkins</code>服务器——用来执行自动化<code>cicd</code>操作(<code>jenkins</code>把“捞”到的代码执行<code>npm run build</code>构建操作,并把构建产物传送到生产服务器)</li><li>生产服务器——存放前端构建产物,并使用<code>nginx</code>代理对应<code>html\css\js</code>静态资源给用户访问用</li></ol><p><strong>若是前端写的node中间层BFF服务(BFF服务不需要再执行npm run build,直接用源码js跑),则使用pm2进行管理,推荐也使用nginx做代理</strong></p><blockquote>比如nginx把<code>/bff/*</code>前缀过来的请求转发给<code>http://localhost:3000</code>上运行的<code>fastify</code>服务(不暴露接口ip统一使用nginx作为入口)</blockquote><h3>角色分工图示</h3><p>如图</p><p><img width="723" height="460" src="/img/bVdmS62" alt="" title=""></p><blockquote>获取gitlab的代码,可以手动,也可以自动</blockquote><p><em>上图中,代码git push推送不用赘述,关键在于gitlab和jenkins的通信这一块</em></p><p><em>————如何让jenkins能够从gitlab中获取(捞)对应代码、如何触发?以及</em></p><p><em>————如何让jenkins能够收到gitlab发来webhook请求,如何配置</em></p><blockquote>当然还有jenkins和测试/生产服务器的通信——把代码丢过去(毕竟生产服务器不允许随意上传文件操作命令啥的)</blockquote><p>我们先看:如何让jenkins能够从gitlab中获取(捞)对应代码、如何触发?</p><h3>jenkins捞gitlab代码的两种方式</h3><p>以下两种方式的前提是相关配置已经提前做好了</p><ul><li><p><strong>方式一,人工手动点击捞代码触发构建</strong></p><ul><li>我们直接登录jenkins系统,找到对应项目列表中</li><li>点击“立即构建”按钮,触发jenkins去gitlab拿代码,如下图</li></ul></li></ul><p><img width="723" height="486" src="/img/bVdmS63" alt="" title=""></p><ul><li><p><strong>方式二,使用gitlab自带的webhook</strong></p><ul><li>当代码写完push到gitlab后,gitlab能够感知到有人提交代码了</li><li>并使用自带的webhook钩子函数功能,给jenkins发送一个请求,告知jenkins可以过来拿代码了</li><li><strong><code>jenkins收到请求后,就会自动拉取gitlab的代码了</code></strong>,如下图</li></ul></li></ul><p><img width="723" height="533" src="/img/bVdmS64" alt="" title=""></p><p>最终jenkins构建打包dist以后,就可以把dist目录里面的代码,丢到生产或者测试服务器上去了(笔者使用rsync命令传输文件的)</p><h2>方式一、手动点击触发jenkins拉取gitee代码构建CICD步骤流程</h2><blockquote>自动webhook触发后文也会记录</blockquote><p>接下来,我们在jenkins创建一个项目(做相应配置),采取手动触发的方式(点一下),让jenkins拉取gitee仓库的代码,并通过编写的shell脚本,构建dist产物,最后通过rsync把产物传输的生产目录,再接入钉钉机器人告知构建成功</p><blockquote>笔者用这台乌班图服务,集成在一块了,既有gitlab、又有jenkins,也有生产的nginx,就是服务运行端口不同</blockquote><h3>1. 安装gitee插件</h3><p>在jenkins中安装gitee插件,插件很多很丰富,视情况安装</p><p><img width="723" height="312" src="/img/bVdmS65" alt="" title=""></p><p>注意,最初安装jenkins的时候,这里选择默认安装推荐的插件</p><p><img width="723" height="490" src="/img/bVdmS66" alt="" title=""></p><h3>2. 新建一个自由风格的jenkins任务,做基础信息填写</h3><p><img width="497" height="346" src="/img/bVdmS67" alt="" title=""></p><p>自由风格和流水线用的比较多一些</p><p>流水线更加灵活,我们先看一下自由风格的</p><p><img width="723" height="719" src="/img/bVdmS68" alt="" title=""></p><p>然后填写项目描述,丢弃旧的构建,只保留三个历史构建结果</p><p><img width="723" height="636" src="/img/bVdmS69" alt="" title=""></p><p>选择git,填写gitee仓库,并准备新建凭证,并使用这个凭证</p><p><img width="723" height="359" src="/img/bVdmS7a" alt="" title=""></p><p><strong>然后这一步,先停下来</strong></p><h3>3. 接下来打开gitee,准备生成一个私人令牌</h3><p><em>个人主页--&gt;设置--&gt;安全设置--&gt;私人令牌</em></p><p><img width="723" height="612" src="/img/bVdmS7b" alt="" title=""></p><p>注意权限把控,具体勾选那个,看实际情况</p><p><img width="723" height="744" src="/img/bVdmS7c" alt="" title=""></p><p>生成私人令牌</p><p><img width="723" height="563" src="/img/bVdmS7d" alt="" title=""></p><h3>4. 妥善保存私人令牌,然后在jenkins中添加凭据使用此令牌</h3><p><img width="723" height="460" src="/img/bVdmS7e" alt="" title=""></p><p>然后选择刚刚新建的凭证</p><p><img width="723" height="405" src="/img/bVdmS7f" alt="" title=""></p><p>注意,凭证可以在:系统管理--&gt;凭据页面 进行后续管理(比如删除、更新等)</p><p><img width="723" height="460" src="/img/bVdmS7g" alt="" title=""></p><h3>5. 构建基础设置</h3><p>指定master分支的代码,为构建分支代码(就是jenkin会通过上述的认证,让gitee允许它进行代码拉取操作,拉取master分支代码)</p><p><img width="723" height="526" src="/img/bVdmS7h" alt="" title=""></p><p>Delete workspace before build starts</p><p>所谓的工作空间,就是一个文件夹目录,里面存放了新建的jenkins任务</p><p><img width="723" height="408" src="/img/bVdmS7i" alt="" title=""></p><p>因为笔者的jenkins是通过docker部署的,所以进入这个docker容器内部,能看到一些jenkins具体信息,比如工作空间</p><p><img width="723" height="449" src="/img/bVdmS7j" alt="" title=""></p><h3>6. 编写shell脚本进行构建</h3><p>Build Steps--&gt;增加构建步骤</p><p>这里选择使用使用shell脚本进行构建</p><p><img width="723" height="632" src="/img/bVdmS7k" alt="" title=""></p><p>构建脚本如下</p><p><img width="723" height="408" src="/img/bVdmS7l" alt="" title=""></p><p>shell脚本代码</p><pre><code class="sh"># 验证环境 node -v npm -v rsync --version curl --version # 修改国内镜像源头 npm config set registry https://registry.npmmirror.com # 安装依赖并构建 npm install npm run build # 把构建的dist丢到宿主机目录 rsync -avz --delete /var/jenkins_home/workspace/gitee/dist/ root@172.17.0.1:/var/www/html/reactExamples/ # 通知钉钉机器人构建成功了 curl -X POST &quot;https://oapi.dingtalk.com/robot/send?access_token=access_token&quot; -H &quot;Content-Type: application/json&quot; -d &quot;{\&quot;msgtype\&quot;: \&quot;text\&quot;, \&quot;text\&quot;: {\&quot;content\&quot;: \&quot;Jenkins 构建成功\&quot;}}&quot;</code></pre><p>注意事项</p><ul><li>1 因为要执行npm run build所以jenkins中,需要有node环境 <strong>所以容器里面要提前准备好node环境!!!</strong></li><li>2 rsync这个包比scp更好用,可以在服务器与服务器之间(也可以把容器内的文件传到宿主机),传输文件 <strong>这里所以容器里面要提前准备好rsync包!!!</strong></li><li><p>3 注意,想要顺利使用rsync这个包,需在容器内生成一套 SSH 密钥对,再将公钥配置到宿主机即可</p><ul><li>比如 <code>ssh-keygen -t rsa -b 4096 -C &quot;jenkins@container&quot;</code></li></ul></li><li>4 rsync语法传输介绍</li></ul><p><code>rsync -avz --delete /var/jenkins_home/workspace/gitee/dist/ root@172.17.0.1:/var/www/html/reactExamples/</code></p><p>意思是:将容器内jenkins工作空间目录<code>/var/jenkins_home/workspace/gitee/dist/</code> 的文件(里面的html、css、js),同步到宿主机(IP 为 <code>172.17.0.1</code>)的 <code>/var/www/html/reactExamples/</code> 目录,并且保持两者内容完全一致(会删除宿主机上多余的文件)</p><pre><code class="js">**`rsync`**:核心命令,用于文件同步(支持本地 / 远程同步,增量传输)。 **`-avz`**:组合选项,常用核心参数: - `-a`(archive,归档模式):递归同步目录,保留文件权限、所有者、时间戳等元数据(最常用的 “安全同步” 模式)。 - `-v`(verbose,详细输出):显示同步过程中的文件列表,方便查看进度。 - `-z`(compress,压缩传输):传输时对文件进行压缩,节省网络带宽(适合远程同步)。 **`--delete`**:关键选项,**删除目标目录中 “源目录没有” 的文件 / 目录**,确保目标目录与源目录完全一致(避免目标目录残留过期文件)。例如:如果源目录删除了 `old.txt`,同步时会自动删除目标目录的 `old.txt`。 **`/var/jenkins_home/workspace/gitee/dist/`** :源路径(容器内的目录),末尾的 `/` 表示 “同步该目录下的内容”(而非目录本身)。若不加 `/`,会将 `dist` 目录本身同步到目标路径下(变成 `reactExamples/dist/...`)。 **`root@172.17.0.1:/var/www/html/reactExamples/`** :目标路径(宿主机的目录): - `root@172.17.0.1`:宿主机的 SSH 登录信息(用户名 `root` + 宿主机 IP `172.17.0.1`)。 - `:/var/www/html/reactExamples/`:宿主机上的目标目录。 ### 执行效果: - 首次同步:将源目录的所有文件完整复制到目标目录。 - 后续同步:只传输修改过的文件(增量同步,效率高),并删除目标目录中源目录没有的文件,最终两者完全一致。</code></pre><p>注意,<code>/var/www/html/reactExamples/</code>这个目录笔者提前使用nginx做了代理,如下:</p><pre><code class="nginx"># react的一些示例 location /reactExamples/ { alias /var/www/html/reactExamples/; try_files $uri $uri/ /reactExamples/index.html; }</code></pre><h3>7. 通过钉钉机器人实现钉钉群构建成功消息推送</h3><p>使用curl命令(要提前新建群,创建好钉钉机器人,用于得知构建成功消息结果)</p><p><img width="723" height="781" src="/img/bVdmS7m" alt="" title=""></p><p>不清楚如何创建钉钉机器人,可以参考官方文档:<a href="https://link.segmentfault.com/?enc=BFe4r3YAG6zUxpIfsU9i0g%3D%3D.lWJzmKWl%2BUkOv5C6JgFQ%2FuaLZx0QPspicIiELfQuG%2BiVk%2BheszycDD3t%2Bv%2FTV0%2F%2FGYy%2BBSya%2FQG4HVICTMFs07QmEap9038%2BYhsHYadEavuz1brOwetf%2B0KlouKJViyB" rel="nofollow">https://open.dingtalk.com/document/dingstart/custom-bot-creat...</a></p><p>提醒一下,别忘了保存jenkins的设置</p><p><img width="723" height="663" src="/img/bVdmS7n" alt="" title=""></p><h3>8. 人工手动构建点击,并查看构建结果</h3><p>点击立即构建</p><p><img width="723" height="464" src="/img/bVdmS7o" alt="" title=""></p><p>查看构建输出日志</p><p><img width="723" height="578" src="/img/bVdmS7p" alt="" title=""></p><p>最终成功喽</p><p><img width="723" height="521" src="/img/bVdmS7q" alt="" title=""></p><p>钉钉群的消息</p><p><img width="723" height="490" src="/img/bVdmS7r" alt="" title=""></p><h2>方式二、gitlab的webhook触发jenkins构建CICD步骤流程</h2><ul><li>一般来说,git push代码以后,再到jenkins上手动点击一下,发布项目,适用于生产环境</li><li>而git push代码以后,直接就发布了(不需要登录jenkins),使用于测试环境</li></ul><h3>1. 什么是webhook?</h3><ul><li>webhook可以理解成为钩子函数——当某个预定义事件发生时,触发软件服务发送http请求</li><li>好多软件服务都提供了webhook钩子</li><li>比如钉钉、飞书、Zabbix、GitHub、GitLab、Gitee、支付宝完成、微信支付完成等</li></ul><p><em>核心逻辑</em></p><ol><li>WebHook 的本质是 “事件回调”:当gitlab仓库发生提交、合并等操作时,gitlab会向我配置的jenkins回调地址,发送一个包含事件信息(如提交作者、分支、变动文件列表)的http请求</li><li>这个请求仅起到 “触发信号” 的作用,不携带任何代码文件本身</li><li>Jenkins 收到该通知后,会基于已配置的凭据(个人令牌或 SSH 密钥),主动向gitlab仓库发起拉取请求,获取最新代码(类似 <code>git pull</code> 的逻辑)。</li></ol><blockquote>webhook只是<strong>消息通知</strong>,不能直接传输代码,核心是触发henkins主动拉取最新代码</blockquote><p>方式一,笔者是给到的gitee案例,接下来使用gitlab案例进行记录</p><blockquote>建议购买一个云服务器,ssl证书,真正的跑通,或者使用vmware虚拟机,把gitlab、jenkins部署好去实践</blockquote><p>可以参考笔者之前的文章:<a href="https://segmentfault.com/a/1190000047305736">Ubuntu服务器上使用docker-compose部署 gitlab(图文并茂记录)</a></p><h3>2. gitlab放开出站请求并初步配置webhook</h3><p>首先,有一个gitlab的仓库代码,要首先放开出站请求</p><p><img width="723" height="715" src="/img/bVdmS7t" alt="" title=""></p><p>然后到代码仓库里面,找到设置中的webhook如下</p><p><img width="723" height="656" src="/img/bVdmS7u" alt="" title=""></p><p>点击添加webhook</p><p><img width="723" height="363" src="/img/bVdmS7v" alt="" title=""></p><p>先填写点基础信息</p><p><img width="723" height="625" src="/img/bVdmS7w" alt="" title=""></p><h3>3. gitlab创建流水线任务</h3><p>如下图,新建任务</p><p><img width="723" height="744" src="/img/bVdmS7x" alt="" title=""></p><p>填写基本信息</p><p><img width="723" height="758" src="/img/bVdmS7y" alt="" title=""></p><h3>4. 填写身份验证令牌</h3><p>指定身份验证令牌,并采用<code>/buildWithParameters?token='TOKEN_NAME'</code>这种方式触发构建</p><p><img width="723" height="428" src="/img/bVdmS7z" alt="" title=""></p><h3>5. 身份验证令牌规则</h3><blockquote>这里使用/buildByToken/build方式配置会少一些</blockquote><ul><li>现在,笔者的jenkin服务运行在29877端口上,TOKEN\_NAME的值是gitlab-jenkins-token-2025</li><li>那么webhook的url就是:</li><li><code>https://ashuai.site:29877/buildByToken/build?job=gitlab&amp;token=gitlab-jenkins-token-2025</code></li><li>注意,这里有两个query参数,job的值,是此构建任务的名字,也就是上方浏览器地址栏去掉configure的</li><li>也就是构建的任务名字</li></ul><p><img width="723" height="422" src="/img/bVdmS7A" alt="" title=""></p><p>即:</p><pre><code class="txt">// jenkins服务的运行ip端口/buildByToken/build?job=任务名&amp;token=TOKEN_NAME https://ashuai.site:29877/buildByToken/build?job=gitlab&amp;token=gitlab-jenkins-token-2025</code></pre><h3>6. 在gitlab中的webhook中使用对应的url</h3><p><img width="723" height="398" src="/img/bVdmS7B" alt="" title=""></p><p>填写</p><p><img width="723" height="1084" src="/img/bVdmS7C" alt="" title=""></p><p>这个时候,测试跑不通的,因为还要在pipe line中编写脚本呢(gitlab任务也要保存哦)</p><h3>7. 编写Pipeline script流水线脚本代码</h3><p><img width="723" height="517" src="/img/bVdmS7D" alt="" title=""></p><p>代码如下:</p><pre><code class="sh">pipeline { agent any stages { stage('Clean Workspace') { steps { echo '🧹 清理工作空间确保构建纯净...' cleanWs() } } stage('Checkout Code') { steps { echo '📥 从GitLab拉取最新代码...' git branch: 'main', url: 'https://ashuai.site:12345/lss-group/react-example.git', credentialsId: 'gitlab' sh 'ls -la' } } stage('Install Dependencies') { steps { echo '📦 安装项目依赖,使用npm ci替代npm install安装...' sh 'npm ci' script { if (fileExists('node_modules')) { echo &quot;✅ 依赖安装成功&quot; } else { error &quot;❌ 依赖安装失败&quot; } } } } stage('Build Project') { steps { echo '🏗️ 构建生产版本...' sh 'npm run build' sh ''' echo &quot;=== 构建验证 ===&quot; if [ -d &quot;dist&quot; ] &amp;&amp; [ -f &quot;dist/index.html&quot; ]; then echo &quot;✅ 构建成功&quot; ls -la dist/ echo &quot;构建大小: \$(du -sh dist/ | cut -f1)&quot; else echo &quot;❌ 构建失败:缺少必要文件&quot; exit 1 fi ''' } } stage('Deploy') { steps { echo '🚀 部署到宿主机目录...' script { // 执行 rsync 部署 sh ''' echo &quot;开始部署...&quot; echo &quot;源目录: /var/jenkins_home/workspace/gitlab/dist/&quot; echo &quot;目标目录: root@172.17.0.1:/var/www/html/reactExamples/&quot; # 执行 rsync 同步代码把构建产物发送到生产(宿主机)对应目录 rsync -avz --delete /var/jenkins_home/workspace/gitlab/dist/ root@172.17.0.1:/var/www/html/reactExamples/ # 检查部署结果 if [ $? -eq 0 ]; then echo &quot;✅ 部署成功完成&quot; else echo &quot;❌ 部署失败&quot; exit 1 fi ''' } } } } post { always { echo &quot;📊 ===== 构建报告 =====&quot; echo &quot;🔢 构建号: #${env.BUILD_NUMBER}&quot; echo &quot;⏱️ 构建时间: ${currentBuild.durationString}&quot; echo &quot;📋 构建结果: ${currentBuild.currentResult}&quot; echo &quot;🌐 构建URL: ${env.BUILD_URL}&quot; sh ''' echo &quot;💾 工作空间大小: \$(du -sh . | cut -f1)&quot; echo &quot;📁 构建产物: \$(find dist/ -type f 2&gt;/dev/null | wc -l) 个文件&quot; ''' } success { echo &quot;🎉 ===== 构建部署成功 =====&quot; echo &quot;✅ 前端项目已成功构建并打包&quot; echo &quot;📦 构建产物位于 dist/ 目录&quot; echo &quot;🚀 已部署到宿主机: /var/www/html/reactExamples/&quot; # 成功了,通过钉钉告知 sh 'curl -X POST &quot;https://oapi.dingtalk.com/robot/send?access_token=access_token&quot; -H &quot;Content-Type: application/json&quot; -d \'{&quot;msgtype&quot;: &quot;text&quot;, &quot;text&quot;: {&quot;content&quot;: &quot;Jenkins 构建成功&quot;}} \'' } failure { echo &quot;❌ ===== 构建失败 =====&quot; echo &quot;🔍 请检查构建日志排查问题&quot; echo &quot;💡 常见问题:依赖安装失败、构建脚本错误、网络问题、部署失败&quot; # 失败了,也通过钉钉告知 sh 'curl -X POST &quot;https://oapi.dingtalk.com/robot/send?access_token=access_token&quot; -H &quot;Content-Type: application/json&quot; -d \'{&quot;msgtype&quot;: &quot;text&quot;, &quot;text&quot;: {&quot;content&quot;: &quot;Jenkins 构建失败&quot;}} \'' } changed { echo &quot;🔄 构建状态发生变化&quot; } } }</code></pre><p>注意,这里要特别注意这个代码</p><pre><code class="js">stage('Checkout Code') { steps { echo '📥 从GitLab拉取最新代码...' git branch: 'main', url: 'https://ashuai.site:12345/lss-group/react-example.git', credentialsId: 'gitlab' sh 'ls -la' } }</code></pre><ul><li>先前配置的TOKEN\_NAME然后把Jenkins的对应url搭配TOKEN\_NAME,作为gitlab的webhook的地址用,这个是让gitlab的请求能发过来</li><li>但是在自动化构建过程中,我们在流水线脚本里面,手动去拉取代码了</li><li>需要需要指定对应的凭证id<code>credentialsId: 'gitlab'</code></li><li>这里的凭证id和方式一设置凭证id一样,也是要在gitlab中生成个人令牌</li></ul><p><img width="723" height="416" src="/img/bVdmS7E" alt="" title=""></p><p>然后和先前的图示(方式一的第四步)一样,也要提前准备好凭证</p><p><img width="723" height="891" src="/img/bVdmS7F" alt="" title=""></p><p><code>credentialsId: 'gitlab'</code>的值 也就是</p><p><img width="723" height="434" src="/img/bVdmS7G" alt="" title=""></p><blockquote>这样的话,在流水线脚本里面拉取代码才能成功</blockquote><p><strong>但是 这个时候 webhook还是跑不通的 我们继续往下看</strong></p><h3>8. 批准Pipeline script流水线脚本</h3><p>脚本不批准,构建会报错</p><p><img width="723" height="532" src="/img/bVdmS7H" alt="" title=""></p><p>或者在系统管理里面 也能看到待批准的脚本</p><p><img width="723" height="565" src="/img/bVdmS7I" alt="" title=""></p><p>点击 Approve 按钮批准</p><p><img width="723" height="395" src="/img/bVdmS7J" alt="" title=""></p><p><strong>但是 这个时候 webhook还是跑不通的 我们继续往下看</strong></p><h3>9. 安装Build Authorization Token Root Plugin插件</h3><ul><li>前面提到了gitlab的webhook的url的值填成:</li><li><code>https://ashuai.site:29877/buildByToken/build?job=gitlab&amp;token=gitlab-jenkins-token-2025</code></li><li>使用/buildByToken/build这种方式,一定要搭配Build Authorization Token Root Plugin插件</li><li>否则会403错误,这个是jenkins的安全策略,如下报错图</li></ul><p><img width="723" height="491" src="/img/bVdmS7K" alt="" title=""></p><ul><li>原因是Jenkins启用了<strong>CSRF 保护(跨站请求伪造防护)</strong> ,而GitLab的Webhook请求中没有包含有效的<code>crumb</code>(校验令牌)</li><li>去设置校验令牌有些麻烦,所以笔者采用了渐变一些的方式,使用Build Authorization Token Root Plugin这个插件——token认证绕过了CSRF保护</li><li>安装启用</li></ul><p><img width="723" height="281" src="/img/bVdmS7L" alt="" title=""></p><p>这样的话,就能够正常收到gitlab的webhook发来的请求了,测试也成功了</p><h3>10. 成功构建,自动发布</h3><p><img width="723" height="608" src="/img/bVdmS7M" alt="" title=""></p><p>也能看到对应构建日志</p><p><img width="723" height="560" src="/img/bVdmS7N" alt="" title=""></p><p>钉钉也能收到对应消息</p><p><img width="519" height="255" src="/img/bVdmS7O" alt="" title=""></p><p><em>这样的话,当笔者把代码直接推送到gitlab的仓库后,就自动触发仓库设置好的webhook发请求给jenkins,jenkins收到请求后,执行pipe line流水线的脚本,拉取代码,构建项目,传送到生产环境宿主机里对应文件夹目录,然后通知钉钉</em></p><hr><ul><li><strong>代码push提交,系统自动完成构建、测试、部署全过程</strong></li><li><strong>即为:cicd自动化部署</strong></li></ul><h2>关于前端部署的三种方式</h2><p>分为纯手动部署,脚本半自动化部署、自动化cicd部署</p><h3>方式一、纯手动部署</h3><ol><li>传统前端项目如React、Vue的部署</li></ol><ul><li>第一步,本地执行npm run build把前端项目打包成dist文件夹</li><li>第二步,通过winscp或xmanager之类的文件传输工具远程链接服务器</li><li>第三步,找到对应服务器上的文件夹目录(存放生产服务文件)</li><li>第四步,先删再增,把现在本机电脑的dist文件夹里面的静态资源文件替换掉服务器上的</li></ul><ol start="2"><li>NodeJs中间层服务如Express、Koa、Fastify的部署</li></ol><ul><li>第一步,通过Git 或者winscp更新最新代码(删除原来的)</li><li>第二步,命令行执行pm2 restart myNodeProject重新加载项目</li></ul><p>需要人工操作各个步骤,稍微有些耗时,且不优雅稳妥</p><h3>方式二、脚本半自动化部署</h3><ol><li>传统前端项目如React、Vue的部署</li></ol><ul><li>Js脚本方式,比如可参考笔者之前的文:<a href="https://segmentfault.com/a/1190000044616092">https://segmentfault.com/a/1190000044616092</a></li><li>Shell脚本大致是这样的,比如我们新建一个deploy.sh脚本</li></ul><pre><code class="sh">#!/bin/bash # 前端部署shell脚本 # --------------------------------- 全局的配置 ------------------------------ LOCAL_DIST=&quot;./dist&quot;                  # 当前项目打包dist目录 SERVER=&quot;root@192.168.1.100&quot;         # 服务器账号+IP(例:root@1.2.3.4) SERVER_DIR=&quot;/usr/share/nginx/html&quot;   # 服务器存放前端文件的目录(Nginx默认是这个) # ---------------------------------------------------------------------- # 1. 本地打包 echo &quot;1. 开始打包...&quot; npm run build || { echo &quot;打包失败!&quot;; exit 1; } # 2. 删服务器旧文件(先清再传,避免残留) echo &quot;2. 删除服务器旧文件...&quot; ssh $SERVER &quot;rm -rf $SERVER_DIR/*&quot; || { echo &quot;删旧文件失败!&quot;; exit 1; } # 3. 传新文件到服务器 echo &quot;3. 上传新文件...&quot; scp -r $LOCAL_DIST/* $SERVER:$SERVER_DIR || { echo &quot;传文件失败!&quot;; exit 1; } # 4. 部署完成 echo &quot;✅ 部署成功!&quot;</code></pre><p>这种方式,需要确保脚本权限,终端执行</p><pre><code>chmod +x deploy.sh </code></pre><p>前端npm run build打包完毕以后,直接<code>./deploy.sh</code>就能直接部署了</p><p>不过得输服务器密码,可以考虑<strong>配置 SSH 密钥免密登录</strong>,就是本地生成 SSH 密钥对,再把公钥复制一份传到服务器上,这样就不用输入密码了</p><ol start="2"><li>NodeJs中间层服务如Express、Koa、Fastify的部署</li></ol><pre><code class="sh">#!/bin/bash SERVER=&quot;root@192.168.1.100&quot;         SERVER_PROJECT_DIR=&quot;/opt/my-node-app&quot;   PM2_APP_NAME=&quot;my-node-service&quot;     # pm2 管理的服务名称(启动时定义的名称) # 1. 登录服务器,拉取仓库最新代码(提前做好服务器的ssh秘钥免密登录,确保能拉取到gitlab代码) echo &quot;1. 拉取最新代码...&quot; ssh $SERVER &quot;cd $SERVER_PROJECT_DIR &amp;&amp; git pull origin main&quot; || { echo &quot;拉取代码失败!&quot;; exit 1; } # 2. 安装生产环境依赖(忽略 devDependencies) echo &quot;2. 安装依赖...&quot; ssh $SERVER &quot;cd $SERVER_PROJECT_DIR &amp;&amp; npm install --production&quot; || { echo &quot;安装依赖失败!&quot;; exit 1; } # 3. 用 pm2 重启服务(使新代码生效) echo &quot;3. 重启服务...&quot; ssh $SERVER &quot;pm2 restart $PM2_APP_NAME&quot; || { echo &quot;重启服务失败!&quot;; exit 1; } # 4. 部署完成 echo &quot;✅ Node.js 服务部署成功!&quot;</code></pre><p>shell脚本需要熟悉一下linux命令,推荐这两个网站 <a href="https://link.segmentfault.com/?enc=59vsMz%2Biw1%2BFglE8rRonjQ%3D%3D.MnNA5OqXLbPis04Y9mjtzFEgRQrMk99twrUpZ9A%2FZvEy4GlwL32npXzrDIT50BEQ%2FniRDM%2F6Y8bufMIrIu5Jt8pkkDAOnL6utY4Ezcb0p%2BY%3D" rel="nofollow">https://www.linuxcool.com/和https://www.masswerk.at/jsuix/ind...</a></p><h3>方式三、自动化Jenkins部署</h3><p>就是上述本文操作流程...</p><p><strong>补充bff的jenkins部署</strong></p><ul><li>流程和上面一样,依旧是git push提交代码</li><li>触发gitlab的webhook告知jenkins可以拉取最新代码了</li><li>然后使用<strong>Publish Over SSH</strong> 插件</li></ul><p><img width="723" height="356" src="/img/bVdmS7P" alt="" title=""></p><ul><li>Add SSH Server,并做对应的信息填写</li><li>就可以在流水线脚本里面派发命令了</li></ul><pre><code class="sh">stages { stage('派发命令到生产服务器') { steps { sshPublisher(publishers: [ sshPublisherDesc( configName: '生产服务器', transfers: [ sshTransfer( execCommand: &quot;&quot;&quot; pm2 restart fastify-app-bff || true &quot;&quot;&quot; ) ] ) ]) } } }</code></pre><ul><li>或者直接使用jenkins的执行shell脚本搭配SSH命令(适合复杂场景)</li><li>篇幅原因,不再赘述</li></ul><h3>额外的docker部署</h3><ul><li>如果服务器上,要部署多个node项目,多个node版本环境要求严格</li><li>主推使用docker优雅部署,笔者也有一篇docker部署前端项目的文:<a href="https://segmentfault.com/a/1190000047275173">https://segmentfault.com/a/1190000047275173</a></li><li><strong>实际上,项目部署是很灵活的,打包构建这一块也不一定都得由Jenkins去做(假设Jenkins服务器配置不太好)</strong></li><li>假设本机配置高,也可以把<code>npm run build</code>和<code>docker build -t 本地镜像名:版本号 .</code>交给本机来做</li><li>然后,给再把构建好的镜像打个标签,推送到Harbor仓库里面</li><li>再推送代码到gitlab仓库里面,依旧能够通过webhook触发Jenkins的执行</li><li>jenkins不用再去gitlab里面拉取代码了,直接把harbor里面的镜像拉取过来</li><li>然后通过 SSH 连接生产服务器,最后执行 <code>docker run</code> 启动容器完成部署</li></ul><p>所以,谁来做构建、谁来做传输,亦或是派发命令执行,本就是灵活的选择</p><blockquote>总而言之,jenkins是一个很灵活的东西,具体怎么设置看实际情况...</blockquote>

2025/11/1
阅读更多

前端如何彻底解决重复请求问题?看看这5种方案

<p>在前端开发中,重复请求是一个常见且棘手的问题。比如用户快速点击"保存"按钮导致生成多条重复单据,或者列表页频繁刷新造成服务器压力飙升,这些场景不仅影响用户体验,还可能引发数据一致性问题。本文将系统梳理重复请求的解决方案,从基础到进阶进行对比分析,并结合实际代码案例解决这一痛点。</p><h2>一、重复请求不止是"多花钱"</h2><p>在讨论解决方案前,我们先明确重复请求的具体影响,避免因"觉得问题不大"而忽视它:</p><ul><li><strong>数据一致性风险</strong>:如表单重复提交导致生成多个相同订单、重复创建用户,后续需要额外成本修复数据</li><li><strong>服务器资源浪费</strong>:相同请求反复发送,占用带宽和服务器算力,极端情况下可能引发服务过载</li><li><strong>前端体验降级</strong>:重复请求可能导致页面多次渲染闪烁,或触发多次错误提示</li><li><strong>网络资源消耗</strong>:尤其在移动端,重复请求会浪费用户流量,增加加载时间</li></ul><p>了解危害后,我们来看当前主流的解决方案,及其适用场景和优缺点。</p><h2>二、5种重复请求解决方案对比</h2><h3>方案1:UI层面控制(最简单但不彻底)</h3><p>这是最基础的解决方案,通过控制UI交互阻止重复触发请求,核心思路是"让用户无法重复点击"。</p><h4>实现方式</h4><ul><li>按钮点击后立即禁用,直到请求完成(成功/失败)后重新启用</li><li>列表刷新时显示加载状态,禁止再次触发刷新操作</li><li>路由切换时取消当前页面未完成的请求</li></ul><h4>代码示例(React)</h4><pre><code>const SaveButton = () =&gt; { const [loading, setLoading] = useState(false); const handleSave = async () =&gt; { if (loading) return; // 防止重复触发 setLoading(true); try { await api.submitForm(data); message.success(&quot;保存成功&quot;); } catch (error) { message.error(&quot;保存失败&quot;); } finally { setLoading(false); // 请求完成后恢复按钮状态 } }; return &lt;Button loading={loading} onClick={handleSave}&gt;保存&lt;/Button&gt;; }; </code></pre><h4>优缺点分析</h4><p>实现简单,无额外依赖<br>对现有代码侵入性低<br>即时反馈,提升用户体验</p><p>无法覆盖所有场景(如代码层面直接调用接口)<br>多个组件调用同一接口时,无法共享状态<br>无法处理网络延迟导致的"隐性重复请求"</p><h4>适用场景</h4><ul><li>简单表单提交、单按钮交互场景</li><li>快速迭代的小型项目,无复杂接口调用逻辑</li></ul><h3>方案2:请求拦截器+缓存(适合读操作)</h3><p>对于查询类接口(如列表查询、详情获取),可通过"请求拦截器+缓存"实现重复请求拦截,核心思路是"相同请求只发一次,结果缓存复用"。</p><h4>实现原理</h4><ol><li>定义缓存容器(如Map),存储已发送但未完成的请求Promise</li><li>发起请求前,生成请求唯一标识(如URL+参数+方法的哈希值)</li><li>若缓存中存在该请求的Promise,直接返回缓存的Promise;若不存在,发送请求并将Promise存入缓存</li><li>请求完成(成功/失败)后,清除缓存,确保下次请求可正常发起</li></ol><h4>代码示例(Axios拦截器)</h4><pre><code>import axios from 'axios'; import { sha256 } from 'js-sha256'; // 缓存容器:key=请求唯一标识,value=请求Promise const requestCache = new Map(); // 创建Axios实例 const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 5000 }); // 请求拦截器 service.interceptors.request.use( (config) =&gt; { // 1. 生成请求唯一标识(URL+方法+参数) const requestKey = generateRequestKey(config); // 2. 检查缓存:若存在未完成的请求,直接返回缓存的Promise if (requestCache.has(requestKey)) { return requestCache.get(requestKey); } // 3. 若不存在缓存,发送请求并缓存Promise const requestPromise = Promise.resolve(config); requestCache.set(requestKey, requestPromise); return requestPromise; }, (error) =&gt; Promise.reject(error) ); // 响应拦截器 service.interceptors.response.use( (response) =&gt; { // 请求完成,清除缓存 const requestKey = generateRequestKey(response.config); requestCache.delete(requestKey); return response.data; }, (error) =&gt; { // 请求失败,同样清除缓存(避免缓存失败状态) if (error.config) { const requestKey = generateRequestKey(error.config); requestCache.delete(requestKey); } return Promise.reject(error); } ); // 生成请求唯一标识:基于URL、方法、params、data的哈希值 function generateRequestKey(config) { const { url, method, params, data } = config; const requestStr = JSON.stringify({ url, method, params, data }); // 使用sha256生成哈希值,确保唯一性 return sha256(requestStr); } export default service; </code></pre><h4>优缺点分析</h4><p>优点:<br>对业务代码无侵入,全局生效<br>减少重复请求,减轻服务器压力<br>支持多组件共享请求结果</p><p>缺点:<br>不适合写操作(如新增/修改/删除),可能导致数据更新不及时<br>缓存有效期难控制,需手动处理过期逻辑<br>无法处理请求取消场景</p><h4>适用场景</h4><ul><li>读操作接口(如列表查询、详情获取、下拉选单数据加载)</li><li>无实时数据要求的场景,允许短期缓存</li></ul><h3>方案3:请求取消+状态管理(适合写操作)</h3><p>对于写操作接口(如新增、修改、删除),不能使用缓存(需确保每次请求都能触达服务器),此时需通过"请求取消+状态管理"实现重复拦截,核心思路是"相同写请求同时只能存在一个,重复请求直接取消"。</p><h4>实现原理</h4><ol><li>维护一个请求状态容器,存储当前未完成的写请求标识及对应的取消函数</li><li>发起写请求前,生成请求唯一标识,检查容器:若存在相同请求,调用取消函数取消新请求</li><li>若不存在相同请求,创建AbortController(或CancelToken),将取消函数和请求标识存入容器</li><li>请求完成(成功/失败)或取消后,从容器中移除该请求标识</li></ol><h4>代码示例(结合AbortController)</h4><pre><code>import axios from 'axios'; import { sha256 } from 'js-sha256'; // 管理未完成的写请求:key=请求唯一标识,value=AbortController const pendingWriteRequests = new Map(); // 写请求专用Axios实例 const writeService = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 5000 }); // 发起写请求(如POST/PUT/DELETE) export function sendWriteRequest(config) { // 1. 生成请求唯一标识 const requestKey = generateRequestKey(config); // 2. 检查是否存在未完成的相同请求:若有,取消新请求 if (pendingWriteRequests.has(requestKey)) { const newController = new AbortController(); // 取消新请求 newController.abort('重复请求已取消'); return Promise.reject(new Error('重复请求已取消')); } // 3. 创建AbortController,用于取消请求 const controller = new AbortController(); const newConfig = { ...config, signal: controller.signal // 绑定取消信号 }; // 4. 将请求标识和取消控制器存入容器 pendingWriteRequests.set(requestKey, controller); // 5. 发送请求,完成后清除容器 return writeService(newConfig) .then((response) =&gt; { pendingWriteRequests.delete(requestKey); return response.data; }) .catch((error) =&gt; { pendingWriteRequests.delete(requestKey); // 过滤&quot;主动取消&quot;的错误,避免业务层处理 if (error.name === 'AbortError') { console.log('请求已取消:', requestKey); return Promise.reject(new Error('请求已取消')); } return Promise.reject(error); }); } // 生成请求唯一标识(同方案2) function generateRequestKey(config) { const { url, method, params, data } = config; const requestStr = JSON.stringify({ url, method, params, data }); return sha256(requestStr); } // 手动取消指定请求(如页面卸载时) export function cancelWriteRequest(config) { const requestKey = generateRequestKey(config); if (pendingWriteRequests.has(requestKey)) { const controller = pendingWriteRequests.get(requestKey); controller.abort('手动取消请求'); pendingWriteRequests.delete(requestKey); } } export default writeService; </code></pre><h4>优缺点分析</h4><p>优点:<br>适合写操作,确保数据一致性<br>支持手动取消(如页面卸载)<br>避免重复写请求导致的数据问题</p><p>缺点:<br>实现较复杂,需手动管理取消逻辑<br>对业务代码有一定侵入性(需使用专用请求函数)<br>无法复用请求结果,每次请求都需触达服务器</p><h4>适用场景</h4><ul><li>写操作接口(如表单提交、数据修改、删除操作)</li><li>对数据一致性要求高的场景(如订单创建、支付请求)</li></ul><h3>方案4:订阅-发布模式(多订阅者共享请求结果)</h3><p>当多个组件同时调用同一接口时,可通过"订阅-发布模式"实现"一次请求,多端复用",核心思路是"相同请求只发送一次,结果分发给所有订阅者",这也是参考范文中采用的核心方案。</p><h4>实现原理</h4><ol><li>维护一个请求状态容器:key=请求唯一标识,value=订阅者列表+请求Promise</li><li>组件发起请求时,生成请求唯一标识,检查容器:</li></ol><ul><li>若请求已存在(未完成):将当前组件的回调函数加入订阅者列表</li><li>若请求不存在:发送请求,将Promise存入容器,并添加当前组件的订阅者</li></ul><ol start="4"><li>请求完成后,遍历订阅者列表,将结果分发给所有订阅者</li><li>订阅者取消订阅(如组件卸载)时,从订阅者列表中移除自身</li></ol><h4>代码示例(基于参考范文封装)</h4><pre><code>import axios from 'axios'; import { sha256 } from 'js-sha256'; class RequestSubscriber { // 容器:key=请求唯一标识,value={ promise: 请求Promise, subscribers: 订阅者列表 } constructor() { this.requestStore = new Map(); this.instance = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 5000 }); } // 发起请求(订阅) request(config) { const requestKey = this.generateRequestKey(config); const storeItem = this.requestStore.get(requestKey); // 1. 若请求已存在,添加订阅者 if (storeItem) { return new Promise((resolve, reject) =&gt; { storeItem.subscribers.push({ resolve, reject }); }); } // 2. 若请求不存在,创建请求并订阅 const subscribers = []; const controller = new AbortController(); const newConfig = { ...config, signal: controller.signal }; // 创建请求Promise const requestPromise = this.instance(newConfig) .then((response) =&gt; { // 请求成功,通知所有订阅者 this.notifySubscribers(requestKey, 'resolve', response.data); return response.data; }) .catch((error) =&gt; { // 请求失败,通知所有订阅者 this.notifySubscribers(requestKey, 'reject', error); return Promise.reject(error); }) .finally(() =&gt; { // 请求完成,清除容器 this.requestStore.delete(requestKey); }); // 存入容器 this.requestStore.set(requestKey, { promise: requestPromise, subscribers, controller }); // 返回当前订阅的Promise return new Promise((resolve, reject) =&gt; { subscribers.push({ resolve, reject }); }); } // 通知所有订阅者 notifySubscribers(requestKey, type, data) { const storeItem = this.requestStore.get(requestKey); if (!storeItem) return; storeItem.subscribers.forEach((subscriber) =&gt; { subscriber[type](data); }); } // 取消请求(如组件卸载) cancelRequest(config) { const requestKey = this.generateRequestKey(config); const storeItem = this.requestStore.get(requestKey); if (storeItem) { // 取消请求 storeItem.controller.abort('请求已取消'); // 清除容器 this.requestStore.delete(requestKey); } } // 生成请求唯一标识 generateRequestKey(config) { const { url, method, params, data } = config; const requestStr = JSON.stringify({ url, method, params, data }); return sha256(requestStr).slice(0, 40); // 截取前40位,平衡唯一性和长度 } } // 单例模式:确保全局只有一个实例 export const requestSubscriber = new RequestSubscriber(); </code></pre><h4>优缺点分析</h4><p>优点:<br>多组件共享请求结果,减少请求次数<br>支持请求取消,避免内存泄漏<br>兼顾读操作和写操作(写操作可关闭共享)</p><p>缺点:<br>实现复杂,需维护订阅者列表和请求状态<br>调试难度高,需跟踪订阅者和请求状态<br>对新手不友好,需理解订阅-发布模式</p><h4>适用场景</h4><ul><li>多组件同时调用同一接口的场景(如多个组件需要同一批下拉选单数据)</li><li>大型项目,需统一管理请求状态和订阅关系</li></ul><h3>方案5:后端配合拦截(最彻底的方案)</h3><p>前端方案虽能解决大部分场景,但仍存在"极端情况漏洞"(如网络延迟导致的请求绕过前端拦截),此时需后端配合,从源头拦截重复请求,核心思路是"后端基于唯一标识判断是否为重复请求"。</p><h4>实现原理</h4><ol><li>前端发起请求时,生成一个唯一标识(如UUID),存入请求头(如<code>X-Request-ID</code>)</li><li>后端接收到请求后,检查<code>X-Request-ID</code>:</li></ol><ul><li>若Redis中不存在该ID:处理请求,并将ID存入Redis(设置过期时间,如5秒)</li><li>若Redis中已存在该ID:判定为重复请求,直接返回"重复请求"错误</li></ul><ol start="4"><li>前端接收到"重复请求"错误后,提示用户或忽略该响应</li></ol><h4>代码示例(前后端配合)</h4><p><strong>前端部分</strong>:</p><pre><code>import axios from 'axios'; import { v4 as uuidv4 } from 'uuid'; const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 5000 }); // 请求拦截器:添加唯一请求ID service.interceptors.request.use( (config) =&gt; { // 生成唯一请求ID(UUID) const requestId = uuidv4(); // 存入请求头 config.headers['X-Request-ID'] = requestId; // 存入localStorage,用于后续重复请求判断(可选) localStorage.setItem(`request_${requestId}`, 'pending'); return config; }, (error) =&gt; Promise.reject(error) ); // 响应拦截器:处理重复请求错误 service.interceptors.response.use( (response) =&gt; { const requestId = response.config.headers['X-Request-ID']; // 请求完成,删除localStorage中的标识 localStorage.removeItem(`request_${requestId}`); return response.data; }, (error) =&gt; { if (error.response?.data?.code === 'DUPLICATE_REQUEST') { // 后端返回重复请求错误,提示用户 message.warning('请勿重复操作'); const requestId = error.config.headers['X-Request-ID']; localStorage.removeItem(`request_${requestId}`); return Promise.reject(new Error('重复请求已拦截')); } return Promise.reject(error); } ); export default service; `` **后端部分(Node.js + Redis)**: ``const express = require('express'); const redis = require('redis'); const { v4: uuidv4 } = require('uuid'); const app = express(); const redisClient = redis.createClient({ url: process.env.REDIS_URL }); redisClient.connect(); // 重复请求拦截中间件 app.use(async (req, res, next) =&gt; { const requestId = req.headers['x-request-id']; if (!requestId) { return res.status(400).json({ code: 'INVALID_REQUEST', message: '缺少请求ID' }); } // 检查Redis中是否存在该请求ID const exists = await redisClient.exists(`request:${requestId}`); if (exists) { // 已存在,判定为重复请求 return res.status(400).json({ code: 'DUPLICATE_REQUEST', message: '重复请求已拦截' }); } // 不存在,存入Redis(设置5秒过期,避免内存泄漏) await redisClient.setEx(`request:${requestId}`, 5, 'pending'); next(); }); // 业务接口 app.post('/api/submit-form', (req, res) =&gt; { // 处理表单提交逻辑 res.json({ code: 'SUCCESS', message: '提交成功' }); }); app.listen(3000, () =&gt; { console.log('Server running on port 3000'); }); </code></pre><h4>优缺点分析</h4><p>优点:<br>从源头拦截重复请求,最彻底<br>不受前端环境影响(如多标签页、多设备)<br>支持分布式系统,可跨服务判断重复请求</p><p>缺点:<br>需后端配合,增加后端开发成本<br>依赖Redis等存储服务,增加部署复杂度<br>需处理请求ID的过期逻辑,避免存储膨胀</p><h4>适用场景</h4><ul><li>对数据一致性要求极高的场景(如支付、订单创建)</li><li>大型分布式系统,前端拦截无法覆盖所有场景</li></ul><h2>三、推荐组合方案实现"彻底解决"</h2><p>单一方案无法覆盖所有场景,实际项目中建议采用"组合方案",兼顾性能、体验和数据一致性:</p><ol><li><strong>基础层</strong>:方案1(UI控制)+ 方案2(缓存)</li></ol><ul><li>所有按钮点击添加loading状态,防止重复触发</li><li>所有读操作接口添加缓存,减少服务器压力</li></ul><ol start="3"><li><strong>核心层</strong>:方案3(请求取消)+ 方案4(订阅-发布)</li></ol><ul><li>所有写操作接口添加请求取消逻辑,避免重复提交</li><li>多组件共享的接口使用订阅-发布模式,提升性能</li></ul><ol start="5"><li><strong>保障层</strong>:方案5(后端配合)</li></ol><ul><li>核心业务接口(如支付、订单)添加后端重复拦截</li><li>前端传递唯一请求ID,后端基于Redis判断重复</li></ul><p>通过这种"三层防护",可彻底解决前端重复请求问题,同时兼顾开发效率和系统稳定性。</p>

2025/10/28
阅读更多

React+Three.js 实现 Apple 2025 热成像 logo

<blockquote><code>Apple 2025</code> 年度发布会 <code>LOGO</code> 以标志性的苹果图形被注入炽热的火焰质感,色彩从暖调橙黄向冷调湛蓝自然过渡,似高温灼烧下的金属表面,迸发出熔融的光泽;又若无形的能量在流动,勾勒出科技的脉搏与律动,将 “科技” 与 “力量” 的碰撞感具象化,光影的明暗交错削弱了平面的单薄感,赋予其近乎触手可及的质感,同时营造出浓郁的未来感与未知感。</blockquote><h2>摘要</h2><p>如上述引用内容,本文将基于 <code>React + Three.js + GLSL</code> 的相关知识,实现 <code>Apple 2025</code> 动态热成像 <code>logo</code> 效果。通过本文的阅读和学习,你将学习到的知识点包括:离屏渲染技术 <code>FBO</code>、交互事件与动态参数控制、<code>Leva</code> 控制面板的应用、视频纹理、遮罩纹理、着色器材质的使用、热成像动画着色器实现和应用等。</p><h2>效果</h2><p>本文页面实现效果如下图所示,页面页面中心由 <code>Apple</code> 热成像动态图标构成,图标上面由橙色和蓝色渐变色动态流动,页面底部为蓝色渐变文案。</p><p><img src="/img/remote/1460000047331883" alt="preview" title="preview"></p><p>当使用鼠标 <code>🖱️</code> 或触控板 <code>👋</code> 网页上按压或拖动 <code>Logo</code> 时,可以看到颜色随手势展开变化,看起来像是模拟真实热量轨迹。</p><p><img src="/img/remote/1460000047331884" alt="ctrl" title="ctrl"></p><p>本专栏系列代码托管在 <code>Github</code> 仓库<a href="https://link.segmentfault.com/?enc=dHHjFQ8weiIBqEg0mEpxZg%3D%3D.iJpTAt%2FLa%2Fwe%2FOrNgr9jOwsLwSNhQ2GfltTIxllkk%2FbP8PK6RW%2FJKmBVSEfqL3e5" rel="nofollow">【threejs-odessey】</a>,<strong>后续所有目录也都将在此仓库中更新</strong>。</p><blockquote><code>🔗</code> 代码仓库地址:<a href="git@github.com:dragonir/threejs-odessey.git">git@github.com:dragonir/threejs-odessey.git</a></blockquote><h2>实现</h2><p>本文代码实现效果参考自:<a href="https://link.segmentfault.com/?enc=XPDHPAUXxPtC8IGy%2F5J4lQ%3D%3D.8N%2BuW1W26Glj4Rpv7krvjFmkNdxBBmL6trO3m3Ysf2oNJ0BLDkXG55Jo1b8XB9Nf" rel="nofollow">https://github.com/vladmdgolam/apple-event-2025</a>,实现内容模块旨在对其核心知识点进行汇总归纳学习,通过相同的原理并举一反三,实现专属自己的热成像动态 <code>logo</code> <code>😎</code>。</p><h3>① 资源引入</h3><p>以下是实现苹果热成像所需的主要依赖资源,其中:<code>OrthographicCamera</code>用于创建平行投影相机、<code>LinearFilter</code> 是纹理采样过滤方式的常量,用于在控制纹理在放大缩小时的平滑过渡效果、<code>ShaderMaterial</code> 用于通过 <code>GLSL</code>创建自定义的着色器材质,是实现本案例效果的关键、<code>VideoTexture</code>可以将视频元素作为数据源创建动态的视频纹理、<code>Leva</code> 是一个轻量级的前端调试工具库,主要用于快速创建交互式控制面板,方便开发者在开发过程中实时调试热成像的各种参数等。其他的依赖都是创建三维场景必须的一些内容,具体作用可自行查阅。</p><pre><code class="js">import { OrthographicCamera, DoubleSide, LinearFilter, Mesh, RGBFormat, RepeatWrapping, ShaderMaterial, Texture, TextureLoader, VideoTexture, } from &quot;three&quot; import { Leva, levaStore, useControls } from &quot;leva&quot;</code></pre><h3>② 页面场景初始化 HeatmapScene</h3><p>使用 <code>React Three Fiber</code> 初始化场景、相机等,其中 <code>Leva</code> 组件用于动态可视化调试着色器的多种参数,<code>Scene</code> 组件用于渲染 <code>logo</code> 场景,是整个交互可视化效果的核心统筹层。</p><pre><code class="tsx">return ( &lt;div&gt; &lt;Leva hidden={levaHidden} /&gt; &lt;InfoPanel onToggleControls={() =&gt; setLevaHidden((p) =&gt; !p)} onRandomizeColors={randomizeColors} /&gt; &lt;div ref={containerRef} className=&quot;w-[560px] h-[560px] touch-none select-none&quot;&gt; &lt;Canvas orthographic camera={{ position: [0, 0, 1], left: -2, right: 2, top: 2, bottom: -2, near: -1, far: 1, }} gl={{ antialias: true, alpha: true, outputColorSpace: &quot;srgb&quot; }} flat &gt; &lt;Scene containerRef={containerRef} /&gt; &lt;/Canvas&gt; &lt;/div&gt; &lt;div className=&quot;dragonir&quot;&gt;@dragonir&lt;/div&gt; &lt;/div&gt; )</code></pre><h3>③ 实现动态热力图网格 HeatMesh</h3><p><code>HeatMesh</code> 组件,它主要通过视频纹理 <code>VideoTexture</code>、绘制纹理 <code>drawTexture</code> 和遮罩纹理 <code>maskTexture</code> 作为数据源,使用着色器材质 <code>ShaderMaterial</code> 渲染一个平面网格 <code>planeGeometr</code>,实现了可实时调整的热力图效果。<code>ShaderMaterial</code> 通过传入自定义顶点着色器和片元着色器实现复杂的热力图色彩映射和动态效果。</p><pre><code class="jsx">export const HeatMesh = ({ drawTexture }: { drawTexture: Texture | null }) =&gt; { const timeRef = useRef(0) const videoRef = useRef&lt;HTMLVideoElement | null&gt;(null) const [videoTexture, setVideoTexture] = useState&lt;VideoTexture | null&gt;(null) // Leva 控制面板着色器参数:power(强度)、opacity(透明度)、颜色映射、混合与过渡等参数可实时调整。 const { power, opacity, color1, blend1, fade1,maxBlend4 ...} = useControls(&quot;Heat Map&quot;, {}) // 遮罩纹理 const maskTexture = useLoader(TextureLoader, &quot;/logo.png&quot;) useEffect(() =&gt; { if (maskTexture) { maskTexture.wrapS = maskTexture.wrapT = RepeatWrapping maskTexture.needsUpdate = true } }, [maskTexture]) // 视频纹理 useEffect(() =&gt; { const video = document.createElement(&quot;video&quot;) video.src = &quot;/apple.mp4&quot; video.loop = true video.playsInline = true video.autoplay = true video.preload = &quot;auto&quot; const onVideoLoad = () =&gt; { const texture = new VideoTexture(video) texture.minFilter = LinearFilter texture.magFilter = LinearFilter texture.format = RGBFormat setVideoTexture(texture) } }, []) // 着色器材质 const material = useMemo(() =&gt; { return new ShaderMaterial({ uniforms: { blendVideo: { value: 1.0 }, drawMap: { value: drawTexture }, textureMap: { value: videoTexture || maskTexture }, maskMap: { value: maskTexture }, opacity: { value: opacity }, amount: { value: 1.0 }, color1: { value: color1 }, blend: { value: [blend1, blend2, blend3, blend4] }, fade: { value: [fade1, fade2, fade3, fade4] }, power: { value: power }, rnd: { value: 0 }, maxBlend: { value: [maxBlend1, maxBlend2, maxBlend3, maxBlend4] }, heat: { value: [0, 0, 0, 1.02] }, stretch: { value: [1, 1, 0, 0] }, }, vertexShader: heatVertexShader, fragmentShader: heatFragmentShader, transparent: true, side: DoubleSide, }) }, [...]) // 动态更新与渲染,通过 useFrame 钩子每帧更新时间和随机值,使热力图呈现动态变化 useFrame((_, delta) =&gt; { timeRef.current += delta if (material) { material.uniforms.rnd.value = Math.random() material.uniforms.amount.value = 1.0 } }) // 渲染一个平面网格,应用自定义着色器材质,作为热力图的载体 return ( &lt;mesh&gt; &lt;planeGeometry /&gt; &lt;primitive object={material} /&gt; &lt;/mesh&gt; ) }</code></pre><p>其中 <code>heatVertexShader</code> 和 <code>heatFragmentShader</code> 是着色器材质的顶点着色器和片元着色器,它们的详细内容见文章最后的<strong>着色器</strong>模块。</p><ul><li>顶点着色器 <code>heatVertexShader</code>:处理网格顶点的位置变换</li><li>片元着色器 <code>heatFragmentShader</code>:根据输入纹理的像素值,结合颜色映射参数,计算每个像素的最终颜色,实现热力图效果。</li></ul><p>遮罩纹理图片预览</p><p><img src="/img/remote/1460000047331885" alt="mask" title="mask"></p><h3>④ 实现绘制渲染器组件 DrawRenderer</h3><p><code>DrawRenderer</code> 组件的主要作用是实时处理动态绘制输入,通过双 <code>FBO</code>交替渲染机制,通过缓冲和自定义着色器实现渲染累积与渐隐效果,并接收外部输入的绘制位置、方向、强度等参数,通过自定义着色器实时更新绘制纹理,并将结果传递给外部使用。</p><pre><code class="jsx">// 通过引入 useFBO 创建帧缓冲对象,用于在GPU上存储和处理绘制纹理。 import { useFBO } from &quot;@react-three/drei&quot; const fboParams = { type: FloatType, format: RGBAFormat, minFilter: LinearFilter, magFilter: LinearFilter, } export const DrawRenderer = ({ size = 256, position, direction, drawAmount, onTextureUpdate, sizeDamping, fadeDamping, radiusSize }) =&gt; { const { size: canvasSize } = useThree() const dynamicRadius = radiusSize const fboA = useFBO(size, size, fboParams) const fboB = useFBO(size, size, fboParams) const renderTargets = useMemo(() =&gt; ({ current: fboA, previous: fboB }), [fboA, fboB]) const { drawScene, drawCamera, material } = useMemo(() =&gt; { const drawScene = new Scene() const drawCamera = new OrthographicCamera(-0.5, 0.5, 0.5, -0.5, 0.1, 10) drawCamera.position.z = 1 // 通过 ShaderMaterial 定义绘制的核心逻辑,着色器接收外部参数并更新 FBO 纹理 const material = new ShaderMaterial({ uniforms: { uRadius: { value: [-8, 0.9, dynamicRadius] }, uPosition: { value: [0, 0] }, uDirection: { value: [0, 0, 0, 0] }, uResolution: { value: [canvasSize.width, canvasSize.height, 1] }, uTexture: { value: renderTargets.previous.texture }, uSizeDamping: { value: sizeDamping }, uFadeDamping: { value: fadeDamping }, uDraw: { value: 0 }, }, // 处理平面顶点的坐标转换,确保与 FBO 纹理坐标对齐 vertexShader: drawVertexShader, // 根据输入的 uPosition uRadius等参数,在上一帧纹理uTexture的基础上绘制新的渐变,并应用衰减uFadeDamping使旧渐变渐消失,实现动态流动效果 fragmentShader: drawFragmentShader, depthTest: false, transparent: true, }) // 创建一个平面网格,作为绘制的画布 const mesh = new Mesh(new PlaneGeometry(1, 1), material) drawScene.add(mesh) return { drawScene, drawCamera, material } }, [renderTargets, dynamicRadius, sizeDamping, fadeDamping, canvasSize]) // Update 着色器变量参数同步:通过 useEffect 将外部传入的 position、direction、drawAmount等参数实时更新到着色器的 uniforms 中 useEffect(() =&gt; { material.uniforms.uRadius.value[2] = dynamicRadius material.uniforms.uPosition.value = position material.uniforms.uDirection.value = direction material.uniforms.uDraw.value = drawAmount }, [material, dynamicRadius, position, direction, drawAmount]) // 帧循环:每帧执行以下操作:将上一帧的FBO纹理previous作为输入传递给着色器;切换渲染目标到当前FBO current,渲染绘制场景;交换current和previous的角色,准备下一帧的累积; useFrame(({ gl }) =&gt; { const currentTarget = renderTargets.current const previousTarget = renderTargets.previous material.uniforms.uTexture.value = previousTarget.texture const originalTarget = gl.getRenderTarget() gl.setRenderTarget(currentTarget) gl.clear() gl.render(drawScene, drawCamera) gl.setRenderTarget(originalTarget) const temp = renderTargets.current renderTargets.current = renderTargets.previous renderTargets.previous = temp // 通过 onTextureUpdate回调,将当前 FBO 的纹理传递给外部 onTextureUpdate(currentTarget.texture) }) // 组件本身不渲染任何可见元素,仅负责后台处理绘制纹理 return null }</code></pre><h4><code>💡</code> 帧缓冲对象 <code>FBO</code> 与双缓冲机制</h4><ul><li><code>FBO</code> 作用:<code>FBO</code> 是 <code>GPU</code> 上的离屏渲染目标,用于存储中间绘制结果,避免直接渲染到屏幕,提高效率;</li><li>双缓冲设计:创建两个 <code>FBO</code>(<code>fboA</code> 和 <code>fboB</code>),通过 <code>renderTargets</code> 管理当前帧 <code>current</code> 和上一帧<code>previous</code></li><li>每帧将上一帧的 <code>FBO</code> 纹理作为输入,绘制新内容到当前 <code>FBO</code>,然后交换两者的角色,实现绘制效果的热力图的渐隐效果。</li></ul><h3>⑤ 创建渲染场景组件 <code>Scene</code></h3><p><code>Scene</code> 组件是整个交互可视化效果的核心统筹组件,它主要实现的功能包括:整合鼠标交互、参数控制、绘制渲染<code>DrawRenderer</code> 与热力图渲染 <code>HeatMesh</code>,实现鼠标 <code>hover</code> 或者 <code>移动</code>时生成动态热力图。最终实现的效果是:用户在画布上移动鼠标,鼠标轨迹会实时生成带有热力渐变的动态效果,且效果可通过 <code>Leva</code> 面板参数可以实时调整。</p><pre><code class="tsx">import { DrawRenderer } from &quot;./DrawRenderer&quot; import { HeatMesh } from &quot;./HeatMesh&quot; export const Scene = ({ containerRef, }: { containerRef: React.RefObject&lt;HTMLDivElement | null&gt; }) =&gt; { const [mouse, setMouse] = useState&lt;[number, number]&gt;([0, 0]) const [heatAmount, setHeatAmount] = useState(0) const [drawTexture, setDrawTexture] = useState&lt;Texture | null&gt;(null) const heatRef = useRef(0) const lastMousePos = useRef&lt;[number, number]&gt;([0, 0]) const lastTime = useRef(performance.now()) const holdRef = useRef(false) const { camera, size } = useThree((state) =&gt; ({ camera: state.camera, size: state.size })) // Leva 控制参数增加 const { sizeDamping, fadeDamping, heatSensitivity, heatDecay, radiusSize } = useControls(&quot;Hover Heat&quot;,{ // 控制粗细的变化平滑度 sizeDamping: { value: 0.8, min: 0.0, max: 1.0, step: 0.01 }, // 控制消失的速度 fadeDamping: { value: 0.98, min: 0.9, max: 1.0, step: 0.001 }, // 鼠标移动时热度累积的快慢 heatSensitivity: { value: 0.25, min: 0.1, max: 2.0, step: 0.05 }, // 鼠标停止后热度下降的快慢 heatDecay: { value: 0.92, min: 0.8, max: 0.99, step: 0.01 }, // 控制单次绘制的范围大小 radiusSize: { value: 75, min: 20, max: 300, step: 5 }, } ) // 根据画布尺寸计算相机的宽高比,动态设置 等参数;确保相机的投影矩阵实时更新 useEffect(() =&gt; { if (camera &amp;&amp; camera instanceof OrthographicCamera) { const aspect = size.width / size.height let width, height if (aspect &gt;= 1) { height = 1 width = aspect } else { width = 1 height = 1 / aspect } camera.left = -width / 2 camera.right = width / 2 camera.top = height / 2 camera.bottom = -height / 2 camera.near = -1 camera.far = 1 camera.updateProjectionMatrix() } }, [camera, size]) // 通过pointermove/pointerleave事件监听鼠标在容器内的位置,计算鼠标相对于容器的归一化坐标 const handleDOMPointerMove = useCallback( (e: PointerEvent) =&gt; { if (containerRef.current) { const rect = containerRef.current.getBoundingClientRect() const clientX = e.clientX - rect.x const clientY = e.clientY - rect.y const normalizedX = clientX / rect.width const normalizedY = clientY / rect.height const x = 2 * (normalizedX - 0.5) const y = 2 * -(normalizedY - 0.5) holdRef.current = true setMouse([x, y]) lastMousePos.current = [x, y] lastTime.current = performance.now() } }, [containerRef] ) const handleDOMPointerLeave = useCallback(() =&gt; { holdRef.current = false }, []) // 鼠标事件监听 useEffect(() =&gt; { const canvas = containerRef.current if (!canvas) return canvas.addEventListener(&quot;pointermove&quot;, handleDOMPointerMove) canvas.addEventListener(&quot;pointerleave&quot;, handleDOMPointerLeave) return () =&gt; { canvas.removeEventListener(&quot;pointermove&quot;, handleDOMPointerMove) canvas.removeEventListener(&quot;pointerleave&quot;, handleDOMPointerLeave) } }, [handleDOMPointerMove, handleDOMPointerLeave, containerRef]) useFrame((_, delta) =&gt; { // 热度累积:当鼠标在容器内移动holdRef.current = true时,根据heatSensitivity和帧间隔delta计算热度增量,heatRef.current持续累积最大限制为1.3,避免强度溢出 if (holdRef.current) { const heatIncrease = heatSensitivity * delta * 60 heatRef.current += heatIncrease heatRef.current = Math.min(1.3, heatRef.current) setHeatAmount(heatRef.current) // 热度衰减:当鼠标离开容器pointerleave或停止移动时,热度值按 heatDecay衰减系数逐步降低,直到低于0.001时清零; } else if (heatRef.current &gt; 0) { heatRef.current *= heatDecay heatRef.current = heatRef.current &lt; 0.001 ? 0 : heatRef.current setHeatAmount(heatRef.current) } // 延迟重置:鼠标停止移动后,通过50ms延迟将 holdRef设为false,避免因短暂停顿导致热度突然中断,模拟自然残留感 if (holdRef.current) { setTimeout(() =&gt; { holdRef.current = false }, 50) } }) const direction = useMemo&lt;[number, number, number, number]&gt;(() =&gt; { return [0, 0, 0, 100] }, []) const drawPosition = useMemo&lt;[number, number]&gt;(() =&gt; { const x = 0.5 * mouse[0] + 0.5 const y = 0.5 * mouse[1] + 0.5 return [x, y] }, [mouse]) // 向 DrawRenderer 传递绘制数据,接收绘制结果并传递给 HeatMesh return ( &lt;&gt; &lt;DrawRenderer size={256} position={drawPosition} direction={direction} drawAmount={heatAmount} onTextureUpdate={setDrawTexture} sizeDamping={sizeDamping} fadeDamping={fadeDamping} radiusSize={radiusSize} /&gt; &lt;HeatMesh drawTexture={drawTexture} /&gt; &lt;/&gt; ) }</code></pre><p>通过 <code>Leva</code> 控制面板动态调节着色器参数。</p><p><img src="/img/remote/1460000047331886" alt=" title="=1440x779"" title=" title="=1440x779""></p><h3>⑥ 自定义颜色功能实现</h3><p>可以通过如下的方法,生成随机色彩并将生成的参数传递到着色器,可以实现热力图 <code>logo</code> 颜色的动态切换。</p><pre><code class="jsx">const randomizeColors = useCallback(() =&gt; { const hslToHex = (h: number, s: number, l: number) =&gt; { s /= 100 l /= 100 const k = (n: number) =&gt; (n + h / 30) % 12 const a = s * Math.min(l, 1 - l) const f = (n: number) =&gt; l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))) const toHex = (x: number) =&gt; Math.round(255 * x).toString(16).padStart(2, &quot;0&quot;) return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}` } // 生成6种随机颜色 color2..color7,color1保持黑色 const base = Math.floor(Math.random() * 360) const steps = [15, 35, 55, 85, 140, 200] const palette = steps.map((step, i) =&gt; hslToHex((base + step) % 360, 80 - i * 4, 50 + (i - 3) * 3)) const keys = [&quot;color2&quot;, &quot;color3&quot;, &quot;color4&quot;, &quot;color5&quot;, &quot;color6&quot;, &quot;color7&quot;] as const keys.forEach((key, i) =&gt; { levaStore.setValueAtPath(`Heat Map.${key}`, palette[i], false) }) }, [])</code></pre><p><img src="/img/remote/1460000047331887" alt=" title="=1440x548"" title=" title="=1440x548""></p><h3>⑦ 着色器</h3><h4><code>📦</code> draw.frag</h4><pre><code class="glsl">precision highp float; uniform float uDraw; uniform vec3 uRadius; uniform vec3 uResolution; uniform vec2 uPosition; uniform vec4 uDirection; uniform float uSizeDamping; uniform float uFadeDamping; uniform sampler2D uTexture; varying vec2 vUv; void main() { float aspect = uResolution.x / uResolution.y; vec2 pos = uPosition; pos.y /= aspect; vec2 uv = vUv; uv.y /= aspect; float dist = distance(pos, uv) / (uRadius.z / uResolution.x); dist = smoothstep(uRadius.x, uRadius.y, dist); vec3 dir = uDirection.xyz * uDirection.w; vec2 offset = vec2((-dir.x) * (1.0-dist), (dir.y) * (1.0-dist)); vec2 uvt = vUv; vec4 color = texture2D(uTexture, uvt + (offset * 0.01)); color *= uFadeDamping; color.r += offset.x; color.g += offset.y; color.rg = clamp(color.rg, -1.0, 1.0); float d = uDraw; color.b += d * (1.0-dist); gl_FragColor = vec4(color.rgb, 1.0); }</code></pre><h4><code>📦</code> heat.frag</h4><pre><code class="glsl">precision highp isampler2D; precision highp usampler2D; uniform sampler2D drawMap; uniform sampler2D textureMap; uniform sampler2D maskMap; uniform float amount; uniform float opacity; uniform vec3 color1; uniform vec3 color2; uniform vec3 color3; uniform vec3 color4; uniform vec3 color5; uniform vec3 color6; uniform vec3 color7; uniform vec4 blend; uniform vec4 fade; uniform vec4 maxBlend; uniform float power; varying vec2 vUv; varying vec4 vClipPosition; vec3 linearRgbToLuminance(vec3 linearRgb){ float finalColor = dot(linearRgb, vec3(0.2126729, 0.7151522, 0.0721750)); return vec3(finalColor); } vec3 saturation(vec3 color, float saturation){ return mix(linearRgbToLuminance(color), color, saturation); } vec3 gradient(float t) { float p1 = blend.x; float p2 = blend.y; float p3 = blend.z; float p4 = blend.w; float p5 = maxBlend.x; float p6 = maxBlend.y; float f1 = fade.x; float f2 = fade.y; float f3 = fade.z; float f4 = fade.w; float f5 = maxBlend.z; float f6 = maxBlend.w; float blend1 = smoothstep(p1 - f1 * 0.5, p1 + f1 * 0.5, t); float blend2 = smoothstep(p2 - f2 * 0.5, p2 + f2 * 0.5, t); float blend3 = smoothstep(p3 - f3 * 0.5, p3 + f3 * 0.5, t); float blend4 = smoothstep(p4 - f4 * 0.5, p4 + f4 * 0.5, t); float blend5 = smoothstep(p5 - f5 * 0.5, p5 + f5 * 0.5, t); float blend6 = smoothstep(p6 - f6 * 0.5, p6 + f6 * 0.5, t); vec3 color = color1; color = mix(color, color2, blend1); color = mix(color, color3, blend2); color = mix(color, color4, blend3); color = mix(color, color5, blend4); color = mix(color, color6, blend5); color = mix(color, color7, blend6); return color; } void main() { vec2 duv = vClipPosition.xy/vClipPosition.w; duv = 0.5 + duv * 0.5; vec2 uv = vUv; uv -= 0.5; uv += 0.5; float o = clamp(opacity, 0.0, 1.0); float a = clamp(amount, 0.0, 1.0); float v = o * a; vec4 tex = texture2D(maskMap, uv); float mask = tex.g; float logo = smoothstep(0.58, 0.6, 1.0-tex.b); vec2 wuv = uv; vec3 draw = texture2D(drawMap, duv).rgb; float heatDraw = draw.b; heatDraw *= mix(0.1, 1.0, mask); vec2 offset2 = draw.rg * 0.01; vec3 video = textureLod(textureMap, wuv + offset2, 0.0).rgb; float h = mix(pow(1.0-video.r, 1.5), 1.0, 0.2) * 1.25; heatDraw *= h; float map = video.r; map = pow(map, power); float msk = smoothstep(0.2, 0.5, uv.y); map = mix( map * 0.91, map, msk); map = mix(0.0, map, v); float fade2 = distance(vUv, vec2(0.5, 0.52)); fade2 = smoothstep(0.5, 0.62, 1.0-fade2); vec3 finalColor = gradient(map + heatDraw); finalColor = saturation(finalColor, 1.3); finalColor *= fade2; finalColor = mix(vec3(0.0), finalColor, a); gl_FragColor = vec4(finalColor, 1.0); }</code></pre><h2>总结</h2><p><code>📌</code> 本项目代码主要由 <code>4</code> 个核心组件构成,其中:</p><ul><li><strong>HeatmapScene</strong>:是全局容器,作为顶层组件,管理 <code>Three.js</code> 、Leva 控制面板和其他页面信息;通过 <code>levaStore</code> 全局管理热力图颜色参数,传递容器引用给子组件。</li><li><strong>Scene</strong>:交互与统筹,处理鼠标交互,计算热度值,模拟鼠标轨迹的累积与衰减,串联 <code>DrawRenderer</code> 和 <code>HeatMesh</code>,传递交互参数。</li><li><strong>DrawRenderer</strong>:绘制处理,使用双帧缓冲 <code>FBO</code> 实现离屏绘制,高效累积鼠标轨迹,通过自定义着色器处理轨迹的绘制、渐隐与衰减,输出处理后的绘制纹理 <code>drawTexture</code> 给 <code>HeatMesh</code>。</li><li><strong>HeatMesh</strong>:热力图渲染,基于 <code>DrawRenderer</code>输出的纹理,结合视频纹理和遮罩纹理,通过自定义着色器生成热力图效果。</li></ul><p><code>📌</code> 本文中主要包含的新知识点如下:</p><ul><li><code>Three.js</code> 离屏渲染技术 <code>FBO</code>:通过 <code>useFBO</code> 创建帧缓冲对象,实现 <code>GPU</code> 层面的离屏绘制,避免直接操作 <code>DOM</code> 提升性能;双缓冲机制 <code>fboA/fboB</code> 交替渲染实现绘制轨迹的累积与动态更新。</li><li><code>交互事件与动态参数控制</code>:鼠标键盘事件监听:将用户输入转换为可量化的参数。</li><li><code>Leva</code> 控制面板:通过 <code>useControls</code> 实时调整视觉参数,提升开发灵活性。</li><li>视频纹理、遮罩纹理、着色器材质的使用等。</li></ul><blockquote>想了解其他前端知识或其他未在本文中详细描述的<strong>Web 3D</strong>开发技术相关知识,可阅读我往期的文章。如果有疑问可以在评论中<strong>留言</strong>,如果觉得文章对你有帮助,不要忘了<strong>一键三连哦 👍</strong>。</blockquote><p><img src="/img/remote/1460000047331888" alt="footer" title="footer"></p><h2>附录</h2><ul><li>[1]. <a href="https://link.segmentfault.com/?enc=BVuMPhfBg%2BUTknXBA9YvjA%3D%3D.Lm0fhGzZY1WznpkOfJT7pBGKPXwMID9EozDDm3Rm9vRPFs4owyQLCLI1c%2FWtAriM" rel="nofollow">🌴 Three.js 打造缤纷夏日3D梦中情岛</a></li><li>[2]. <a href="https://link.segmentfault.com/?enc=h0IAKqZ6%2FtW0pASbMcT5xQ%3D%3D.9xJuWuqELpB4XJa80%2Fa%2ByaLEaetc7LXJfPr42Pmecpe8IOQ91rxDsNKU6DnM0RT2" rel="nofollow">🔥 Three.js 实现炫酷的赛博朋克风格3D数字地球大屏</a></li><li>[3]. <a href="https://link.segmentfault.com/?enc=hOfJ9ddWoQASv8FNbsUWig%3D%3D.FBLPbuFwdrZDPl0AS3Knwoh5iZOtFw7gkpL0AknIaZX%2FgxXyQThehYdoXIEQDIAf" rel="nofollow">🐼 Three.js 实现2022冬奥主题3D趣味页面,含冰墩墩</a></li><li>[4]. <a href="https://link.segmentfault.com/?enc=cI%2BmqzRnEYmaYWZLDMKxjQ%3D%3D.cpahH%2Fq7b2QPVJ6UsNfvWeKoWxJmDDdoAVP6oUIGb1CuXcyJE7jCd7aP8AhzLHXf" rel="nofollow">🦊 Three.js 实现3D开放世界小游戏:阿狸的多元宇宙</a></li><li>[5]. <a href="https://link.segmentfault.com/?enc=uvA%2FOHCc7v9EZUhVeS95Yg%3D%3D.KH%2BhDUHmMOEoftfnF9y95P6wZCy4t6%2FYinpEf56NoiRnpDz8ndTm9bDHzoFqkUKT" rel="nofollow">🏡 Three.js 进阶之旅:全景漫游-高阶版在线看房</a></li><li><code>...</code></li><li><a href="https://link.segmentfault.com/?enc=Omel9DR2ml7en9Cu2Z4Wgw%3D%3D.rJ8DqfxUhojIE1TRUIa2eeJEHGFuWLjqZz82OIuXxeGvm1Vs5AnNfB48xqAQWmHL" rel="nofollow">【Three.js 进阶之旅】系列专栏访问 👈</a></li><li><a href="https://link.segmentfault.com/?enc=a0WmO0Cp3wIgP%2BToqi9BUA%3D%3D.SIZ7arPqeeIIv%2BDVHVADvdr98xgH7qh6RxxJZbnpLq%2Brqicn%2BqrAQUxhPHmA4Eiu" rel="nofollow">更多往期【3D】专栏访问 👈</a></li><li><a href="https://link.segmentfault.com/?enc=PNXtM9XM9Z9R%2Bv7SBp0bzA%3D%3D.5FGZU57qqNvU8qqn4lte2Mv5WUkFjbidejPaZEAF%2B9Kq2OUt53ZvazD6aWuQ6Ljb" rel="nofollow">更多往期【前端】专栏访问 👈</a></li></ul><h2>参考</h2><ul><li>[1]. <a href="https://link.segmentfault.com/?enc=5GWPPXCkANC9nrV2EccbOg%3D%3D.jTo%2BaEKfsjbfvazdN%2BLSomtfA4GOodr7kRBPHITgb7clM9gy%2FxrXnllUOHH5rV4R" rel="nofollow">https://github.com/vladmdgolam/apple-event-2025</a></li><li>[2]. <a href="https://link.segmentfault.com/?enc=AkDcGYLEegv2D8uSZf8gFQ%3D%3D.bU4ye019xDZwO%2FlSgJahLDwrUfyAIM7Iy%2F%2BsaeU6MYLobQqsZm1dVT6AXiZ7u7vI" rel="nofollow">https://www.apple.com/apple-events/</a></li></ul>

2025/10/19
阅读更多

ONES MCP Server 上线,支持主流 AI Coding 工具集成

<p>近日,ONES 全新推出 MCP Server,让 AI 更深入地参与企业研发管理全流程,助力团队高效协作。</p><p>ONES MCP Server 是面向大模型的接口标准,让<strong> AI Agent(如 Cursor、VS Code、Claude Code 等)能够安全、结构化地连接 ONES 数据</strong>,支持用户以个人身份授权访问或写入数据。</p><p>这意味着在 ONES 中,<strong>AI 不再只是简单的对话助手,而能真正理解业务语境、参与到团队的研发计划、任务执行与知识沉淀中</strong>,基于真实项目上下文完成任务、生成内容、推动协作。</p><p><img width="723" height="270" src="/img/bVdmM5h" alt="image.png" title="image.png"></p><h2>ONES MCP Server 使用场景全攻略</h2><p>ONES MCP Server 提供了<strong> 30+ 工具</strong>,支持对 ONES 数据进行访问和写入,工具覆盖<strong>项目管理、知识库管理、工时管理</strong>等多个场景。这些工具会在 MCP 客户端的 AI Agent 中结合用户的指令任务进行单独或组合调用。</p><p><strong>开发者、产品经理、项目经理</strong>等不同角色,都能借助 ONES MCP Server 无缝衔接工作流程,显著提升研发管理效率及质量。</p><h3>开发者如何使用 ONES MCP Server</h3><blockquote>无需反复切换工作流,研发协作一步到位</blockquote><p>开发者在日常工作中,常常需要在不同工具之间频繁切换、反复查找信息,不仅容易打断思路,还会导致效率降低。</p><p>借助 ONES MCP Server,<strong>开发者能够在支持 MCP 协议的 IDE 环境中,通过 AI Agent 直接获取项目数据、处理需求和缺陷。</strong></p><h4>使用场景演示:</h4><ul><li>AI 查找和定位待处理的 ONES Project 任务</li><li>AI 修复 Bug,并在任务评论中记录修复过程</li><li>AI 基于产品需求和技术方案拆分研发任务,并创建工作项</li><li>AI 修复工单,并关联代码提交记录</li><li>AI 总结本周代码任务,形成周报,并保存为 ONES Wiki 页面</li></ul><p><a href="https://www.bilibili.com/video/BV1SoW9zjEic/?aid=115411609716740&cid=33269876337">https://www.bilibili.com/video/BV1SoW9zjEic/?aid=115411609716...</a></p><p>借助 ONES MCP Server,开发者能在熟悉的开发环境中完成从需求到交付的全流程协作,全程高效无中断,研发效率与专注度双提升。</p><h3>产品经理如何用 ONES MCP Server</h3><blockquote>多源信息自动整合,PRD 初稿一键输出</blockquote><p>产品经理在分析需求和撰写文档时,往往需要从多个数据源调用信息,过程繁琐。</p><p>借助 ONES MCP Server,产品经理能够在 AI Agent 中一站式调用所需的项目数据、知识与文档模板,快速完成需求分析与 PRD 起草。</p><h4>使用场景演示:</h4><ul><li>AI 定位需求工作项,并完成需求分析</li><li>AI 整合需求、知识库和模板,输出 PRD 初稿并保存为 ONES Wiki 页面</li></ul><p><a href="https://www.bilibili.com/video/BV1SEW9zVET2/?aid=115411760840752&cid=33270927087">https://www.bilibili.com/video/BV1SEW9zVET2/?aid=115411760840...</a></p><p>借助 ONES MCP Server,产品经理可让 AI 接管繁琐的信息整合与文档编写,实现从需求到 PRD 的高效生成,让创意与决策更专注于产品本身。</p><h3>项目经理如何用 ONES MCP Server</h3><blockquote>进度、资源一键分析,项目管理省心高效</blockquote><p>项目经理也能够通过 ONES MCP Server,用 AI Agent 快速整合迭代资源与任务进度数据,并自动生成进度报告、同步创建团队进度会议的日程,从而解决信息分散、协调耗时的痛点。</p><h4>使用场景演示:</h4><ul><li>AI 完成迭代资源和进度分析,生成分析报告,并保存为 ONES Wiki 页面</li><li>AI 预定团队沟通会议,并在日程描述中附上分析报告</li></ul><p><a href="https://www.bilibili.com/video/BV1JzstzoEYv/?aid=115415904748093&cid=33271450599">https://www.bilibili.com/video/BV1JzstzoEYv/?aid=115415904748...</a></p><p>借助 ONES MCP Server,<strong>项目经理能以最少的人工操作完成信息整合、进度和资源分析,让项目管理更轻松、更高效。</strong></p><h3>如何连接 ONES MCP Server</h3><p>团队<strong>启用 ONES Copilot 应用</strong>后,对应环境的 MCP 服务器将自动启用。拥有 ONES Copilot 授权的个人账号即可授权 MCP 客户端进行连接。</p><p>ONES MCP Server 支持<strong>读取、创建或更新 ONES Project 项目管理、ONES Wiki 知识库管理数据,以及读取 ONES Account 账号管理数据</strong>,用户可根据 MCP 客户端的使用场景选择合适的授权范围。</p><p><strong>无需编写任何代码,只需三步即可完成配置</strong>:</p><ol><li>获取 MCP Server 地址<br>进入个人中心的「已授权 MCP 客户端」页面,复制 MCP 服务器地址。</li><li>选择添加方式支持<br>通过 URL 或 mcp-remote 的方式添加 MCP 服务器地址。</li><li>完成服务授权<br>在授权页面登录账号,选择「生效团队」并设置授权范围,点击「同意授权」即可完成 MCP 客户端与 ONES MCP 服务端的授权连接。</li></ol><p><a href="https://www.bilibili.com/video/BV1EzstzoEUg/?aid=115415904750027&cid=33289014975">https://www.bilibili.com/video/BV1EzstzoEUg/?aid=115415904750...</a></p><p>ONES MCP Server 的上线,标志着 <strong>ONES 的 AI 能力从「可用」走向「可融入」</strong>。无论是开发者、产品经理还是项目经理,都能借助 ONES MCP Server 让 AI 深度嵌入研发协作、知识沉淀等日常工作,实现项目计划、执行、协作的一体化智能升级。</p>

2025/10/22
阅读更多