阅读视图

发现新文章,点击刷新页面。

React Router 实战指南:构建现代化前端路由系统

React Router 实战指南:构建现代化前端路由系统

一、前端路由的演进与重要性

在 Web 开发的早期,路由完全由后端控制,前端开发人员主要负责"切图"和静态页面制作。随着前后端分离架构的兴起,前端路由成为了现代单页应用(SPA)的核心组件,它使得前端能够独立管理页面导航,提供更流畅的用户体验。

React Router 作为 React 生态系统中最流行的路由解决方案,为我们提供了一套完整的路由管理工具,让我们能够轻松构建复杂的单页应用。

二、React Router 的两种实现方式

React Router 提供了两种主要的路由实现方式,各有其特点和适用场景:

HashRouter:兼容性优先

  • URL 格式:使用 # 符号作为路由分隔符,如 http://example.com/#/about
  • 实现原理:基于浏览器的锚点机制,通过监听 window.location.hash 的变化来触发路由更新
  • 优势
    • 兼容性极佳,支持所有现代浏览器
    • 无需服务器端配置,部署简单
    • 适合静态网站托管(如 GitHub Pages)
  • 劣势:URL 中包含 #,视觉上不够美观,不符合 RESTful 设计规范

BrowserRouter:现代性优先

  • URL 格式:使用标准的 URL 路径,如 http://example.com/about
  • 实现原理:基于 HTML5 History API,通过 pushStatereplaceState 方法管理路由
  • 优势
    • URL 更干净、美观,符合 RESTful 设计
    • 更好的 SEO 支持
    • 更符合现代 Web 应用的 URL 规范
  • 劣势
    • 需要服务器端配置,确保所有请求都指向同一个入口文件
    • 依赖 HTML5 History API,对旧浏览器支持有限(IE11 之前不兼容)

在实际项目中,我们通常使用 as Router 语法来提高代码可读性:

import { BrowserRouter as Router } from 'react-router-dom';

function App() {
  return (
    <Router>
      {/* 应用内容 */}
    </Router>
  );
}

三、路由类型详解

React Router 支持多种类型的路由,满足不同场景的需求:

1. 普通路由

最基础的路由类型,用于匹配固定路径:

<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />

2. 动态路由

通过参数捕获实现,适用于需要根据 ID 或其他参数显示不同内容的场景:

<Route path="/user/:id" element={<UserProfile />} />
<Route path="/product/:productID" element={<ProductDetail />} />

在组件中可以通过 useParams Hook 获取路由参数:

import { useParams } from 'react-router-dom';

function UserProfile() {
  const { id } = useParams();
  return <div>用户 ID: {id}</div>;
}

3. 通配路由

使用 * 匹配任意路径,常用于 404 页面:

<Route path="*" element={<NotFound />} />

4. 嵌套路由

通过 Outlet 组件实现路由嵌套,适用于复杂的页面结构:

<Route path="/product" element={<Product />}>
  <Route path=":productID" element={<ProductDetail />} />
  <Route path="new" element={<NewProduct />} />
</Route>

在父组件中使用 Outlet 渲染子路由:

import { Outlet } from 'react-router-dom';

function Product() {
  return (
    <div>
      <h1>产品列表</h1>
      <Outlet /> {/* 渲染子路由内容 */}
    </div>
  );
}

5. 鉴权路由

通过自定义组件实现路由守卫,控制页面访问权限:

<Route path="/pay" element={
  <ProtectRoute>
    <Pay />
  </ProtectRoute>
} />

6. 重定向路由

使用 Navigate 组件实现路由重定向:

<Route path="/old-path" element={<Navigate replace to="/new-path" />} />

四、路由优化策略

1. 组件懒加载

通过 React.lazySuspense 实现组件的按需加载,提高应用性能:

import { lazy, Suspense } from 'react';

const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));

function RouterConfig() {
  return (
    <Suspense fallback={<LoadingFallback />}>
      <Routes>
        {/* 路由配置 */}
      </Routes>
    </Suspense>
  );
}

2. 导航高亮

使用 useResolvedPathuseMatch 实现导航菜单的动态高亮:

const isActive = (to) => {
  const resolvedPath = useResolvedPath(to);
  const match = useMatch({
    path: resolvedPath.pathname,
    end: true
  });
  return match ? 'active' : '';
};

3. 路由历史管理

  • push:向历史栈添加新记录,用户可以通过浏览器的后退按钮返回
  • replace:替换当前历史记录,用户无法通过后退按钮返回上一个页面

五、单页应用的优势

单页应用(SPA)通过前端路由实现了以下优势:

  1. 更好的用户体验:页面切换无需重新加载,避免了页面"白屏"现象
  2. 更快的响应速度:只更新需要变化的部分,减少了网络请求
  3. 前后端分离:前端负责用户界面,后端负责数据处理,职责更加清晰
  4. 代码组织:通过组件化和路由管理,代码结构更加清晰

六、实际应用案例

让我们通过一个完整的导航组件来展示 React Router 的实际应用:

import { Link, useResolvedPath, useMatch } from 'react-router-dom';

function Navigation() {
  const isActive = (to) => {
    const resolvedPath = useResolvedPath(to);
    const match = useMatch({
      path: resolvedPath.pathname,
      end: true
    });
    return match ? 'active' : '';
  };

  return (
    <nav>
      <ul>
        <li className={isActive('/')}>
          <Link to="/">首页</Link>
        </li>
        <li className={isActive('/about')}>
          <Link to="/about">关于</Link>
        </li>
        <li className={isActive('/product')}>
          <Link to="/product">产品</Link>
        </li>
        <li className={isActive('/product/new')}>
          <Link to="/product/new">新产品</Link>
        </li>
        <li className={isActive('/product/123')}>
          <Link to="/product/123">产品详情</Link>
        </li>
      </ul>
    </nav>
  );
}

七、总结与展望

React Router 作为现代前端开发的核心工具之一,为我们提供了一套完整、灵活的路由解决方案。通过合理使用不同类型的路由和优化策略,我们可以构建出体验优秀、性能出色的单页应用。

随着 React 生态系统的不断发展,React Router 也在持续演进,为开发者提供更多强大的功能。未来,我们可以期待它在服务端渲染、微前端等领域发挥更大的作用,为前端开发带来更多可能性。

掌握 React Router 不仅是前端开发的基本技能,也是构建现代化 Web 应用的必要条件。通过不断实践和探索,我们可以充分发挥其潜力,创造出更加出色的用户体验。

立正请站好:一个组件复用 Skill 的工程化实践|得物技术

一、背景:为什么要做这个 Skill

做这个 Skill 的初衷很直接,也很现实:功能开发时容易"顺手新建一个",而不是先复用已有组件,造成组件库越来越臃肿。这件事对团队的伤害其实是复利型的:

  • 重复组件越来越多;
  • 维护成本越来越高;
  • UI/交互一致性越来越差;
  • AI 生成代码时也更容易继续复制混乱。

所以做这个 Skill 的目标不是"帮 AI 搜索一下",而是:把"复用优先"的思考过程流程化,让 AI 在写代码前先走一遍"查索引 → 判断是否复用 → 命不中再新建"的路径。

二、想解决的不是搜索问题,而是“思考顺序”问题

一开始很容易把问题理解成:"做个组件搜索工具给 AI 用就好了"。但实际落地后发现,真正的问题不是工具有没有,而是:

  • AI 会不会主动用;
  • AI 什么时候用;
  • AI 用完之后是否还能回到项目上下文;
  • AI 能不能稳定走同一条流程。

这和 Vercel 在他们的 agent 评测里观察到的现象很像:skills 本身不是没用,而是 agent 往往不会稳定触发;而把基础知识放进 AGENTS.md 这种"被动上下文"后,稳定性反而更高。Vercel 的实验里,默认 skill 触发并没有提升通过率,加入显式指令后才明显改善,而 AGENTS.md 文档索引方案表现更稳定。这给了我一个很关键的设计方向:先解决 AI 的"决策点"问题,再解决 AI 的"能力"问题。

三、核心设计思路:AGENTS.md + Hook + Skill(三层结构)

最终采用的是三层结构:

AGENTS.md:放基础上下文(常驻)

把"组件复用优先"的规则、组件索引入口、扫描后需要做的事情,放进 AGENTS.md(或同类常驻上下文机制)里。目的不是塞满文档,而是让 AI 每轮都知道:

  • 这个仓库有组件复用机制;
  • 默认应该先查可复用组件;
  • 查不到再考虑新建;
  • 扫描后还有描述补全流程需要继续执行。

这层解决的是:AI 根本不知道你有这套机制。不写进去,AI 主动使用 skill 的概率确实会很低(这点我踩过坑)。

Hook:做路由增强(提高触发概率)

如果运行环境支持 hooks(例如 Claude Code 的 UserPromptSubmit 支持在用户 prompt 处理前注入额外上下文),就可以做一层"意图路由增强":在用户提到"组件复用 / 是否有现成组件 / 封装组件 / 查组件"等语义时,给 AI 注入提示,让它优先走组件复用流程。Claude 的文档明确写了 UserPromptSubmit 会在处理前触发,并且可通过 additionalContext 注入上下文。这层解决的是:AI 知道有 skill,但不一定想起来用。

Skill:提供流程和工具(真正执行)

Skill 不是只写说明文档,而是要提供:

  • 明确的调用入口;
  • 稳定的输出格式;
  • 可执行脚本;
  • 失败时的兜底逻辑。

OpenAI 的 Codex Skills 文档里提到 skills 是"渐进披露"机制:运行时先看到 skill 的元信息(尤其是 description),只有决定使用时才加载完整 SKILL.md;而且隐式触发高度依赖 description。这也是为什么 skill 的触发边界和描述要写得非常清楚。这层解决的是:AI 想用了,但执行过程不稳定。

四、这套 Skill 在源码里是怎么落地的(我的实现)

下面是我这次组件复用 Skill 的几个关键实现点:

先把"入口"收敛成一个:find-component.js

我在 SKILL.md 里明确规定:Agent 必须调用统一入口find-component.js。这样做的原因很简单:

  • 避免 AI 在多个脚本之间犹豫(scan-components、match-component、resolve-scope……);
  • 避免 AI 漏掉前置步骤(比如索引不存在时先扫描);
  • 避免 AI 调用路径不一致导致结果不稳定。

统一入口做了几件事(都在 find-component.js 里):接收查询词(query)、仓库根路径(repoRoot)、当前聚焦路径(startDir)。

  • 如果 components.csv 缺失,内部自动触发run-scan.js;
  • 调用 resolve-scope 计算当前应用和允许搜索范围;
  • 调用 match-component 做匹配排序;
  • 命中时记录使用(用于后续加权);
  • 按固定 JSON 协议返回结果(成功/失败/无匹配/是否触发扫描等)。

这一步本质上是把分散逻辑聚合成"一个业务动作":"查一下有没有可复用组件",而不是"先算 scope,再查 CSV,再排序,再补扫,再记 usage"。这对 AI 很关键。

不是"全仓库乱搜",而是"当前应用 + 根级共享"优先

在 monorepo 场景里,组件复用很容易踩两个坑:

  • 只搜当前 app,漏掉根级共享组件;
  • 全仓乱搜,结果太多太噪音。

所以我在 resolve-scope.js 里做了一个比较工程化的范围解析策略:

  • 读取 pnpm-workspace.yaml 解析 workspace 包;
  • 根据当前聚焦文件/目录反推 currentAppRoot;
  • 再结合 root_scope_patterns(例如 apps/_share/、packages/ 等)构建允许范围;
  • 最终形成一个搜索集合:当前应用 + 根作用域共享包。

如果没有聚焦子项目(比如 startDir 就是 repo root),则切换为全量 scope。这个设计很像人类工程师的查找策略:先看"我这个业务应用里有没有",再看"全局共享有没有",而不是直接在整个 monorepo 海里捞针。

匹配不是纯关键字:我做了"多因素加权"

组件匹配如果只做字符串包含,很快就会变成垃圾召回器。我在 match-component.js + fuzzy-match.js 里做了一个组合评分,核心包括:

  • 名称精确/包含匹配;
  • 模糊匹配(编辑距离);
  • Token 重叠;
  • 首字母缩写匹配(例如 dlp 匹配 DateLinkPicker);
  • 当前应用加权(当前 app 的组件优先);
  • 使用频率加权(常用组件更靠前);
  • 来源质量加权(README 推断质量高于纯 inferred);
  • 存在性校验(文件不存在则降权/过滤);
  • 记录类型权重(组件优先于依赖)。

这一步的目标不是追求"算法先进",而是让排序更符合团队真实使用习惯:"更可能被复用的组件排在前面"。此外我还加了一个低分阈值(NO_MATCH_SCORE_THRESHOLD):

  • 如果最高分太低,就认为是噪音命中;
  • 可以触发一次扫描后再查;
  • 还是低分则按"无匹配"返回,不把噪音结果塞给 AI。

这个点很重要,因为 AI 一旦拿到一些低质量候选,很容易"将错就错"。

把"索引构建"做成可复用流水线,而不是一次性脚本

很多类似方案停在“扫一遍生成 CSV”,然后就过时了。我这次把扫描做成了 run-scan.js -> index-manager -> enrich 的流水线,核心考虑是持续维护:

run-scan.js 负责编排流程

  • resolve-scope;
  • updateIndex;
  • 自动触发 autoEnrich(可配置)。

index-manager.js 负责索引更新策略

  • 保留历史记录并合并;
  • 根据 source_hash 跳过未变化组件;
  • 记录 last-scan-changed-ids.json;
  • 支持并行扫描(包数量较多时启用);
  • 对缺失文件支持标记 exists=0(在查找阶段也会回写)。

扫描后进入 Agent 富化(enrich)流程

  • 读取 agent-enrich-prompts.json;
  • 找出 summary 占位符项;
  • 按 id 回到 components.csv;
  • 读取源码/README;
  • 生成 summary + keywords;
  • 再通过 update-component-summary.js 写回。

更关键的是在配置里启用了:

  • agent_mode_no_fallback = true。

也就是说,在 Agent 模式下不走规则引擎降级,而是要求 Agent 必须完成这一步。这其实就是"流程化思考"的精髓:不是建议,而是纳入主流程。

让 Skill 不只是"搜索器",还是"反馈回路"

一个很容易被忽视的点是:查找命中后,我还记录了使用行为(usage-tracker)。这意味着系统不是静态的,它会逐步学习团队偏好:

  • 哪些组件经常被复用;
  • 哪些组件在某个 app 里更常出现;
  • 哪些结果应该在排序中更靠前。

这是一种很轻量但非常实用的反馈机制——不需要搞复杂训练,也能提升 AI 下一次推荐质量。

五、这次实现里,总结出"让 AI 流程化"的 3 条原则

这也是我最想分享的部分:

原则 1:把基础上下文放进 AGENTS.md(或用 Hook 注入)

如果不这样做,AI 主动使用 skill 的概率很低。原因不是 AI 笨,而是 agent 的执行是有"决策成本"的:

  • 它要先意识到有 skill;
  • 再判断该不该用;
  • 再决定什么时候用。

而把基础上下文放进 AGENTS.md 或通过 hook 提前注入,本质上是在减少决策点。Vercel 的评测结果说明了这种"被动上下文"在某些场景下会更稳定。

原则 2:Skill 需要直接提供工具函数给 AI 调

只写一堆说明文档不够。AI 在工程任务里最需要的是:

  • 一个可以直接执行的入口;
  • 明确的参数;
  • 稳定的返回结构。

所以我把 find-component.js 做成统一入口,并定义了固定 JSON 输出(ok / matches / noMatch / scanTriggered / hint / error 等),这会明显提升 AI 的执行稳定性。

原则 3:显式告诉 AI 调哪些函数,并把分散逻辑聚合到一个入口

这是最容易被忽略、也是最影响稳定性的一点。如果给 AI 暴露一堆脚本:

  • resolve-scope.js;
  • match-component.js;
  • run-scan.js;
  • scan-components.js;
  • index-manager.js。

它理论上能拼起来,但实践里很容易漏步骤、顺序错、参数错。所以我在 Skill 里显式规定:

  • 查找时用 find-component.js;
  • 构建时用 run-scan.js;
  • 更新描述时用 update-component-summary.js。

把复杂系统收敛成几个明确入口,AI 才容易稳定执行。

六、这次实践里一个很重要的认知转变

我原来以为"写 skill"是在给 AI 增加能力。现在更像是在做:给 AI 增加"默认工作方式"。换句话说,skill 不只是能力包(capability bundle),也是流程控制器(workflow controller)。

  • AGENTS.md 负责"告诉 AI 世界观";
  • Hook 负责"提醒 AI 现在该用哪套流程";
  • Skill 负责"把动作做完,并且做得稳定";
  • 日志/CSV/usage 负责"让系统可观测、可迭代"。

这套思路不只适用于组件复用,后面也可以迁移到:

  • 任务优化闭环;
  • 日志分析标准化;
  • 策略诊断流程;
  • 代码规范治理。

七、这套方案当前的价值

  • AI 开发前先查可复用组件,而不是直接新建;
  • monorepo 下按"当前应用 + 共享组件"范围检索;
  • 索引缺失自动扫描;
  • 组件描述富化进入主流程;
  • 匹配质量有加权与反馈回路;
  • 整体流程有明确入口和输出协议。

八、结语:让 AI 少一点"即兴发挥",多一点"工程纪律"

这次组件复用 Skill 的开发过程,对我最大的启发不是"AI 能帮我写多少代码",而是:AI 其实非常适合被放进一套清晰流程里工作。只要把下面三件事做好:

  • 基础上下文(AGENTS.md / hooks);
  • 可执行入口(工具函数);
  • 明确流程边界(统一入口 + 输出协议)。

AI 就不会只是"一个会说话的代码补全器",而会更像一个遵守团队规范的工程协作者。而这,才是我做这个 Skill 真正想要的结果。

引用文档: vercel.com/blog/agents…

往期回顾

1.财务数仓 Claude AI Coding 应用实战|得物技术

2.日志诊断 Skill:用 AI + MCP 一键解决BUG|得物技术

3.Redis 自动化运维最佳实践|得物技术

4.Claude在得物App数仓的深度集成与效能演进

5.Claude Code + OpenSpec 正在加速 AICoding 落地:从模型博弈到工程化的范式转移|得物技术

文 /魏无涯

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

写代码不出事故的底层方法:边界、兜底与默认值

引言

软件系统的稳定性并非偶然,而是建立在对各种异常情况充分预判和处理的基础之上。优秀的代码不仅要能正确处理happy path,更要能在边界条件下保持健壮,在系统出现意外状况时优雅降级,在缺乏配置时拥有合理的默认行为。这三个维度——边界、兜底与默认值——构成了防御性编程的基石,也是资深工程师与初级开发者之间最显著的差距所在。

很多线上事故的根源都可以追溯到对边界条件的忽视:一个数组越界、一次空指针调用、一个未被处理的异常向上传播,最终导致整个系统不可用。这些问题在测试环境往往难以复现,却在生产环境的高并发、大数据量、多样化输入面前暴露无遗。理解并实践边界、兜底与默认值的理念,是从“能跑就行”迈向“稳定可靠”的必经之路。

一、边界:认识问题的第一道防线

1.1 边界问题的本质

边界问题之所以被称为“边界”,是因为它们发生在正常操作与异常操作的交界处。在数学上,边界可能是最大值、最小值、零、空集;在业务逻辑中,边界可能是首批用户、最后一批订单、零金额交易、长文本截断点。边界问题的危险之处在于,它们往往处于“理论上应该存在但实际很少被触发”的灰色地带,常规测试难以覆盖,却在特定条件下必然触发。

以一个简单的分页查询为例,假设系统支持分页获取用户列表,页面大小为每页20条。当数据库中存在恰好20条记录时,请求第一页会返回全部数据,请求第二页应该返回空列表,这是正常逻辑。但如果代码中错误地使用了“小于等于”作为分页起始索引的判断条件,就可能在某些边界情况下计算出负数的起始位置,导致数据库查询失败或返回错误的数据。类似地,当用户传入的分页参数为负数或超出实际页数范围时,系统是否做了正确的校验和处理,直接决定了这个接口的健壮性。

1.2 边界类型与处理策略

边界问题可以按照数据类型和业务场景进行分类,每种类型都需要相应的处理策略。

数值边界是最常见的边界类型之一,包括整数的最大值与最小值、浮点数的精度限制、数值的正负零等。在处理整数运算时,必须考虑溢出的可能性。例如,在Java中,如果两个Integer.MAX_VALUE相加,结果会变成负数,这可能导致库存扣减、金额计算等场景出现严重的逻辑错误。正确的做法是使用BigInteger或BigDecimal进行精确运算,或者在运算前进行溢出检查。一种常用的溢出检测模式是:在加法运算前检查其中一个数是否大于目标类型最大值减去另一个数。

集合边界同样需要谨慎处理。数组的索引越界、列表的越界访问、集合的空集合操作,都是常见的边界问题。在遍历集合时,应该特别注意集合在遍历过程中是否可能被修改——这在多线程环境下尤其危险,即ConcurrentModificationException的常见原因。对于可能为空的集合,安全的做法是在遍历前进行非空检查,或者使用空集合替代null进行后续处理。

字符串边界包括空字符串、仅有空白字符的字符串、超长字符串、包含特殊字符的字符串等。在进行字符串长度校验时,需要明确是按照字符数还是字节数进行计算,因为在中英文混合的场景下,两者的差异可能导致意想不到的问题。字符串截断操作也属于边界处理的一部分,当需要将超长文本截断显示时,是直接截断还是按照单词边界截断,是完全截断还是添加省略号,都是需要根据业务场景做出的选择。

时间边界涉及时区转换、夏令时切换、闰年处理、Unix时间戳的2038年问题等。日期时间的比较和计算尤其容易出错,因为时区的存在使得“同一天”可能有着不同的起止时刻。在处理时间相关的业务逻辑时,应该尽可能使用UTC时间进行内部存储和计算,只在需要展示时才转换为用户所在时区。

1.3 边界检查的实现原则

边界检查不应该被视为对正常流程的干扰,而应该被理解为正常流程的一部分。优秀的边界检查应该是防御性的、无副作用的,并且与业务逻辑清晰分离。

前置条件校验应该在函数或方法的入口处进行,确保传入的参数符合预期的约束条件。这种校验通常是强制性的——如果前置条件不满足,函数应该立即失败并返回明确的错误信息,而不是尝试继续执行可能产生未定义行为的逻辑。Java中的Objects.requireNonNull、Guava的Preconditions类,都是用于前置条件校验的工具。

后置条件校验用于确保函数的输出符合预期。这种检查通常在函数执行完毕后、返回结果之前进行,可以帮助开发者在早期发现逻辑错误。例如,一个排序函数在完成后可以检查输出数组是否真的有序;一个累加函数可以检查最终结果是否等于各个加数的和。

不变量校验用于确保对象在整个生命周期中都处于合法状态。不变量是对象构造完成后、每次方法调用前后都应该保持为真的条件。例如,一个栈的不变量是“栈中的元素数量永远不为负”,以及“栈顶指针永远指向下一个可写入的位置”。在每次可能改变对象状态的操作后验证不变量,可以在第一时间发现状态被破坏的情况。

1.4 边界检查的反面:过度防御

强调边界检查的重要性并不意味着要走向另一个极端——过度防御同样是有害的。过度防御的表现形式包括:对每一个参数都进行详尽无遗的校验,即使这些参数来自可信的内部调用;在已经进行过校验的地方重复校验,浪费计算资源;使用过于宽泛的异常捕获,掩盖了本应被发现的真正问题。

过度防御的危害在于,它会增加代码的复杂性,降低可读性,使得真正的问题被掩盖。同时,过度的校验会带来不必要的性能开销,在高并发场景下这种开销可能累积成显著的系统负担。因此,进行边界检查时应该遵循一个原则:只检查真正需要的、可能出错的、后果严重的边界条件。

二、兜底:系统健壮性的关键保障

2.1 兜底思维的本质

兜底是一种兜底预案思维,它假设任何可能出错的环节都一定会出错,并为此准备备用的响应方案。这里的“出错”不仅包括代码逻辑错误或系统故障,还包括各种外部依赖的不可用、网络通信的不可靠、资源的暂时耗尽等。在分布式系统和微服务架构盛行的今天,任何一个环节的故障都可能导致级联失败,而兜底机制正是防止这种级联效应的关键手段。

以一个典型的电商系统为例,用户下单时需要调用库存服务扣减库存、调用支付服务完成支付、调用物流服务预订配送。如果库存服务在某个时刻响应变慢或暂时不可用,系统是否应该直接拒绝用户的下单请求?还是应该返回一个“库存锁定中,请稍后再试”的友好提示,并在一段时间后自动重试?更进一步,如果库存服务长时间不可用,是否应该允许用户先完成下单,后续再处理库存不足的情况?这些问题的答案取决于具体的业务场景和系统的可用性要求,但无论如何,系统都不应该因为某个依赖的故障而直接崩溃或返回难以理解的错误信息。

2.2 兜底的层次与策略

兜底策略可以从不同层次进行设计,每一层都有其特定的应用场景和实现方式。

服务降级是最常见的兜底策略之一。当某个非核心服务不可用时,系统可以关闭该服务提供的功能,保证核心功能的正常运行。例如,在一个内容平台中,评论功能可以降级为只读,用户仍然可以浏览内容,但暂时无法发表评论;广告展示功能可以降级为展示公益广告或默认图片;推荐算法可以降级为展示热门内容而非个性化推荐。服务降级的关键在于明确区分核心功能和非核心功能,并确保降级后的用户体验仍然是可接受的。

熔断机制是防止级联故障的重要手段。当某个服务的错误率超过阈值时,熔断器会“跳闸”,后续对该服务的调用会直接返回预设的降级结果,而不会真正发送到目标服务。这避免了持续向一个已经故障的服务发送请求,浪费资源的同时也给了故障服务恢复的时间窗口。熔断器会周期性地尝试放行少量请求来探测服务是否已经恢复,如果探测成功则关闭熔断器恢复正常调用。Netflix的Hystrix、Alibaba的Sentinel都是常用的熔断实现框架。

超时控制是兜底策略中容易被忽视但极其重要的一环。很多系统在设计时假设外部调用会正常返回,却忘记了网络是不可靠的——一个TCP连接可能因为网络分区而永久挂起,导致调用线程无限期等待。设置合理的超时时间是防止这种“线程卡死”的基本手段。超时时间的设置需要平衡两个因素:太长则无法及时发现故障,太短则可能误判正常但较慢的服务为故障。一种常用的做法是设置“连接超时”和“读取超时”两个参数,前者控制建立连接的时间,后者控制等待响应的时间。

重试机制是处理临时性故障的有效手段。当一个服务调用因为网络抖动或服务器短暂过载而失败时,立即重试往往能够成功。但重试也有其风险:它可能加剧被调用服务的负载、在某些场景下导致重复操作(如重复扣款)、在故障恢复时产生惊群效应。因此,重试机制通常需要配合退避策略(如指数退避)、重试次数限制、以及幂等性保证一起使用。

2.3 兜底实现的最佳实践

实现有效的兜底机制需要遵循一些基本原则和最佳实践。

** Fail Fast 与 Fail Safe 的选择**是设计兜底策略时首先需要明确的问题。Fail Fast(快速失败)是指在检测到错误时立即失败并返回,常用于核心功能的校验、不可恢复的错误等情况。Fail Safe(失败安全)是指在错误发生时执行预设的默认行为,保证系统继续运行,常用于非核心功能或无法确定错误影响的情况。选择哪种策略取决于功能的重要性和错误的性质。

兜底结果的设计直接影响用户体验。一个好的兜底结果应该是:可识别的(用户能够理解系统当前的状态)、有意义的(提供了替代的信息或功能)、最小的(不会造成额外的问题)。例如,当推荐系统降级时,展示“热门内容”比展示空白或报错要好得多;当支付系统暂时不可用时,显示“支付服务繁忙,请稍后再试”比显示一串技术错误代码要好得多。

兜底日志与监控是确保兜底机制有效运行的重要保障。当系统进入降级状态时,应该记录详细的日志,包括触发降级的原因、持续时间、影响的请求数量等。这些日志对于事后分析和系统优化至关重要。同时,应该建立相应的监控告警机制,当系统频繁触发兜底逻辑时及时通知运维人员介入处理。

2.4 常见兜底场景与处理

在实际开发中,有一些常见的兜底场景值得特别关注。

网络请求的兜底需要考虑网络的各种异常情况:连接超时、读取超时、连接被重置、DNS解析失败等。对于HTTP请求,应该设置合理的超时时间,并处理各种可能的异常情况。对于重要的数据获取请求,可以考虑设置本地缓存作为兜底,当远程请求失败时返回缓存数据(即使可能稍有过期)。

数据库操作的兜底主要关注连接池耗尽、查询超时、锁等待超时等场景。在高并发场景下,数据库往往是系统中最容易成为瓶颈的组件。当数据库响应变慢时,连接池可能迅速耗尽,导致后续请求无法获取连接。处理这种情况可以采用连接获取超时、查询超时、熔断降级等策略。

第三方服务的兜底需要特别谨慎,因为第三方服务的可用性和性能不受我们控制。对于关键的第三方依赖,应该实现多级降级策略:优先调用主服务,失败后尝试备用服务,再次失败后返回本地缓存或默认值。同时,应该对第三方调用设置较短的超时时间,避免被第三方服务拖慢整个系统。

三、默认值:系统自愈的起点

3.1 默认值的意义

默认值是在没有显式指定时自动使用的值。一个设计良好的默认值系统可以显著降低系统的故障率,因为它在用户没有做出任何选择的情况下也能提供合理的体验。默认值的重要性体现在以下几个方面:首先,它简化了用户操作,用户不需要了解每一个配置项的含义,系统就能正常工作;其次,它防止了空值或未初始化状态引发的各种问题,将null这样危险的“特殊情况”转化为正常的“默认值情况”;最后,它使得系统的行为更加可预测,有助于调试和问题排查。

考虑一个用户配置系统的例子。用户可以设置自己的通知偏好,包括邮件通知、短信通知、App推送通知等。如果系统在用户未设置任何偏好时将这些字段都设为null或undefined,那么在后续发送通知时就需要大量的null检查来避免空指针错误。但如果系统将默认值设为“全部开启”,那么未设置偏好的用户会正常收到通知,后续的代码逻辑也会简单得多——只需要在用户明确关闭某类通知时才跳过发送。

3.2 默认值的类型与设计

默认值可以根据其来源和用途分为不同的类型,每种类型都有其适用的场景。

程序内置默认值是最基础的默认值类型,它们被硬编码在程序中,是系统在没有外部配置时的默认行为。这些默认值通常经过深思熟虑的选择,代表了系统设计者认为的“最合理”的行为。例如,一个限流器的默认QPS设置、一个缓存的默认过期时间、一个重试机制的默认重试次数,都属于程序内置默认值。这类默认值应该在代码中有明确的注释说明其选择理由,并定期根据实际运行情况进行调整。

配置文件默认值允许在不提供配置文件或配置项缺失时使用预设的默认值。与程序内置默认值相比,配置文件默认值具有更好的灵活性,可以通过修改配置文件来改变默认行为而无需重新编译程序。良好的配置系统应该区分“未配置”和“显式配置为空”两种情况,前者使用默认值,后者使用空值(如果业务逻辑允许空值的话)。

运行时推断默认值是根据当前环境或上下文自动计算的默认值。例如,一个连接池的默认大小可以根据服务器的CPU核心数来确定;一个批量处理任务的默认批次大小可以根据可用内存来计算。这类默认值的好处是能够自适应不同的运行环境,但缺点是可能产生难以预料的行为,应该谨慎使用。

3.3 空值处理与空对象模式

空值(null或undefined)是编程中最常见的错误来源之一,著名的“null引用十亿美金错误”揭示了空值处理的困难。处理空值的方法主要有两种策略。

空值检查是最直接的处理方式,在访问对象属性或调用方法前检查对象是否为null。这需要开发者有良好的习惯,在每一个可能为null的地方都进行检查。但这种方式容易导致代码中出现大量的嵌套if语句,降低可读性。Java 8引入的Optional类提供了一种更优雅的空值处理方式,它强制调用者显式地处理值不存在的情况,而不是默认抛出一个难以追踪的空指针异常。

空对象模式是一种更彻底的解决方案,它用一个“不做任何事的对象”来替代null,从而避免大量的空值检查。例如,一个日志记录器接口可以有NullLogger实现类,这个实现类的所有方法都不做任何事,当系统没有配置日志记录器时使用NullLogger替代,后面的代码就不需要检查日志记录器是否为null了。空对象模式的好处是简化了调用方的代码,坏处是可能掩盖一些本应被发现的配置问题。

3.4 默认值的最佳实践

设计和使用默认值时应该遵循一些最佳实践。

**选择“有意义的默认值”**是关键原则。默认值应该是“大多数情况下正确的值”,而不是简单的0、空字符串或false。例如,对于一个布尔类型的配置项,如果其语义是“功能开关”,那么默认开启还是默认关闭需要根据功能的性质来判断——一个可能影响核心流程的功能应该默认关闭,让用户主动选择开启;一个安全相关的功能应该默认开启,防止用户因疏忽而暴露安全风险。

**提供“配置提示”**可以帮助用户理解默认值的行为。当系统使用默认值时,应该通过日志、文档或用户界面的方式告知用户当前使用的是默认值,以及这个默认值是什么。这有助于用户在遇到问题时理解系统的行为,也方便他们在需要时主动去修改配置。

保持默认值的一致性可以减少混淆。如果在代码的不同位置使用了不同的默认值,可能导致难以理解的边界行为。建议将默认值集中管理在一个地方(如配置常量类),确保整个系统使用相同的默认值定义。

3.5 配置膨胀与默认值的管理

随着系统功能的增加,配置项往往会越来越多,如何管理这些配置及其默认值成为一个挑战。

分层配置是一种有效的管理策略。可以将配置分为“框架配置”、“系统配置”、“业务配置”三个层次,每层配置都有其对应的默认值。上层配置可以覆盖下层配置,最终生效的配置是各层叠加的结果。这种分层设计既保证了灵活性,又避免了配置项的混乱。

配置校验是防止错误默认值影响系统的重要手段。在系统启动或配置变更时,应该对所有配置项进行校验,确保它们的值在合理的范围内。对于不合理的配置值,系统应该拒绝启动或发出警告,而不是静默使用可能错误的默认值。

配置的文档化对于团队协作至关重要。每一个配置项都应该有清晰的文档说明,包括其用途、合法值范围、默认值、修改的影响等。良好的配置文档可以帮助新加入的开发者快速理解系统,也是生产环境问题排查的重要参考。

四、综合实践:三位一体的防御体系

4.1 三者的协同关系

边界、兜底与默认值这三个概念并非相互独立,而是构成了一个完整的防御体系。在这个体系中,边界定义了什么情况是“正常的”,兜底定义了当“不正常”情况发生时系统应该如何响应,而默认值则提供了在没有明确指定时系统的默认行为。

以一个用户权限校验的场景为例。边界检查确保传入的用户ID是有效的正整数,角色参数是预定义的有效值之一;兜底机制确保当权限服务不可用时系统不会直接拒绝所有请求,而是可以根据配置决定是拒绝还是放行;默认值则定义了当用户没有任何角色标签时,应该赋予其“普通用户”的默认权限。三个机制协同工作,既保证了系统的健壮性,又提供了合理的默认体验。

4.2 实践案例分析

让我们通过一个具体的业务场景来展示三个概念的综合运用。

考虑一个在线教育平台的课程推荐系统。系统需要根据用户的年级、学科偏好、历史学习记录等信息,从课程库中筛选并推荐合适的课程。

边界层面,系统需要检查用户的年级是否在1到12之间的有效整数、学科偏好列表是否为空或长度合理、请求的推荐数量是否在1到50之间的合理范围、用户的身份标识是否有效等。如果任何边界条件不满足,系统应该返回明确的错误信息,而不是尝试处理无效输入。

兜底层面,当推荐算法服务响应超时时,系统应该返回预设的兜底推荐列表(如平台热门课程),而不是返回错误或空结果;当课程库的某些数据暂时不可用时,系统应该跳过这些数据继续处理可用的课程;当推荐结果为空时,系统应该返回一条友好的提示信息。

默认值层面,如果用户没有设置年级信息,默认使用“全部年级”范围进行推荐;如果用户没有设置学科偏好,默认使用用户历史学习记录中出现最多的学科作为偏好;如果用户请求的推荐数量超出限制,默认返回允许的最大数量;当没有任何偏好信息时,默认推荐平台的精选课程。

4.3 代码层面的实现建议

在代码实现层面,有一些具体的建议可以帮助实践这三个概念。

使用强类型和泛型约束可以在编译期捕获很多潜在的边界问题。将用户输入转换为强类型后,类型系统可以帮助我们发现很多类型不匹配的问题。泛型约束可以限制一个方法接受的参数类型,减少运行时检查的需要。

使用不可变对象可以简化兜底逻辑和默认值处理。不可变对象一旦创建就不能被修改,这使得它们天然就是线程安全的,也避免了因为对象状态被意外修改而导致的复杂问题。如果需要修改对象的状态,应该创建新的对象而不是修改原有对象。

使用配置对象替代大量参数可以简化函数签名,使得默认值的管理更加集中。一个接受20个参数的函数调用远不如一个接受配置对象的函数调用可读,后者可以清晰地展示每个参数的名字和默认值。

统一的异常处理机制是兜底策略的重要组成部分。应该定义清晰的异常层次结构,区分可恢复的异常和不可恢复的异常,并为每种异常类型定义合适的处理策略。在系统的入口处统一处理异常,可以避免异常处理逻辑在代码各处重复。

4.4 测试与验证

防御性代码同样需要测试来验证其正确性。对于边界条件,应该编写针对边界值的单元测试,确保边界检查在临界点处行为正确。对于兜底逻辑,应该模拟各种故障场景(如服务超时、服务不可用、数据格式错误等),验证系统的降级行为是否符合预期。对于默认值,应该验证在各种配置缺失的情况下,系统是否使用了正确的默认值。

除了单元测试,还应该进行混沌工程实验,在生产环境或类生产环境中主动注入故障,验证系统的容错能力。这种实验可以帮助发现那些只有在真实故障场景下才会暴露的问题,是保障系统稳定性的重要手段。

五、总结

边界、兜底与默认值,这三个看似简单的概念,构成了软件防御性编程的核心框架。边界的精髓在于“知其边界”,明确系统能够处理的输入范围,并在边界处设置清晰的校验和拒绝机制。兜底的精髓在于“备有后手”,假设任何依赖都可能失败,并为每种可能的失败情况准备合适的降级方案。默认值的精髓在于“善解人意”,在没有明确指定时提供合理的行为,让系统能够优雅地应对未知的场景。

这三种方法的力量不仅在于它们各自的作用,更在于它们的协同效应。一个仅有边界检查而没有兜底机制的系统,在遇到边界外的情况时会直接崩溃;一个有兜底机制但没有良好默认值的系统,兜底逻辑可能会返回难以理解的空结果;一个只有默认值而没有边界检查的系统,可能在边界情况下产生不可预测的行为。

在实际开发中,培养防御性编程的思维习惯比掌握特定的技术技巧更为重要。每写一段代码,都应该问自己几个问题:这个函数的输入有什么限制条件?这些限制条件被满足了吗?如果外部依赖失败了会怎样?如果某个配置项没有设置会使用什么值?通过这种持续的自我审视,可以逐步建立起对系统脆弱点的敏感度,写出更加健壮的代码。

最终,代码的稳定性不是靠事后的打补丁和紧急修复来保障的,而是靠在设计和实现阶段就充分考虑各种异常情况来实现的。边界、兜底与默认值,这三个底层方法,正是这种设计理念的具体体现。它们不会让代码变得更加“炫酷”,却能让代码在面对现实世界的各种意外时表现得更加可靠。对于追求工程卓越的开发者来说,深入理解和熟练运用这三个概念,是从优秀走向卓越的必经之路

从零实现富文本编辑器#13-React非编辑节点的内容渲染

先前我们讨论了是编辑节点的组件预设,包括零宽字符、Embed节点、Void节点等,接下来我们需要讨论的是非编辑节点内容渲染,也就是占位节点、只读模式、插件模式、外部节点挂载等。这些节点类型在编辑器的设计中处于常见的外部节点,例如占位符号、弹出层等。

从零实现富文本编辑器系列文章

Placeholder 占位节点

在编辑器中,在内容为空的情况下,通常需要渲染一个占位节点来提示用户输入内容。在浏览器的inputtextarea中,都存在原生的占位节点实现。而在编辑器中,这部分占位节点就需要自行实现,浏览器在ContentEditable模式并不存在原生的占位节点。

在开源的编辑器中,quillslate都提供了占位节点的实现,并且还是属于典型的实现。quill的占位节点是使用CSS的伪元素来实现的,使用伪元素的好处是,完全不会影响到浏览器的DOM结构,这样也就不会影响到选区模型等设计,整体结构类似下面的内容。

<div data-placeholder="请输入内容">
  ::before
  <div data-node><span data-leaf>&ZeroWidthSpace;</span></div>
</div>
.block-kit-x-editable div[data-block][data-placeholder]::before {
  color: #bbbfc4;
  content: attr(data-placeholder);
  height: 0;
  pointer-events: none;
  position: absolute;
}

在这里,content是可以直接将DOM上的属性值渲染到占位节点上的,即data-placeholder属性值,这样就可以通过Js来控制属性值,进而处理占位节点的内容了。absolute主要是为了使其脱离DOM文档流,不影响选区的定位,pointer-events则是为了避免事件交互。

其实用伪元素实现的最重要的点是,在ContentEditable模式下,浏览器不会让用户编辑::before::after伪元素生成的内容。我们无法选中伪元素,其也不会参与光标、选区的计算。因为伪元素不属于DOM树,而ContentEditable只作用于真实的DOM节点及其文本内容。

而类似slate的实现,则存在两部分特殊的设计。首先是将占位节点直接渲染到Editable编辑区域内,这样就可以复用React的渲染节点作为整个占位节点。再者是占位节点是渲染在leaf区域内,这也就意味着编辑器的文本样式也会应用到占位节点上。

针对React占位节点的渲染,理论上而言之需要将其作为参数渲染到Editable编辑区域内即可。但是我们需要实现类似上述伪元素的实现,来确保占位节点的内容不会被用户编辑,那么这部分就需要用CSS来控制,即position + user-select + pointer-events

<div
  {...{ [PLACEHOLDER_KEY]: true }}
  style={{
    position: "absolute",
    opacity: "0.3",
    userSelect: "none",
    pointerEvents: "none",
  }}
>
  {props.placeholder}
</div>

接下来是设置的文本样式应用问题,这里的差异主要在于文本节点的放置位置。类似于上述的伪元素实现,如果直接放在容器直属元素下的话,设置的样式自然是不会应用到占位节点上的。而若是放在leaf区域内,自然就可以将样式应用到占位节点上。

<div>
  <span>请输入内容</span> <!-- 无法应用样式的占位节点内容 -->
  <div data-node>
    <span data-leaf>&ZeroWidthSpace;</span>
    <span>请输入内容</span> <!-- 可以应用样式的占位节点内容 -->
  </div>
</div>

此外,还有个特别需要关注的点,在IME进行Composing的时候,理论上是不应该显示占位节点的。而此时如果直接在编辑区域监听composing事件,则会导致选区模型重新计算,此时输入内容则会出现选区模型异常的情况。因此在这里需要独立抽离组件,避免上层的layout effect

/**
 * 占位符组件
 * - 抽离组件的主要目标是避免父组件的 LayoutEffect 执行
 */
export const Placeholder: FC<{
  editor: Editor;
  lines: LineState[];
  placeholder: React.ReactNode | undefined;
}> = props => {
  const { isComposing } = useComposing(props.editor);
  return props.placeholder &&
    !isComposing &&
    props.lines.length === 1 &&
    isEmptyLine(props.lines[0], true) ? (
    <div {...{ [PLACEHOLDER_KEY]: true }}>
      {props.placeholder}
    </div>
  ) : null;
};

Readonly 只读模式

在我们的编辑器中,编辑模式主要是依赖于ContentEditable的属性值,那么在只读模式下,之需要将ContentEditable的属性值设置为false即可。理论上而言这完全是视图层的行为,之需要在React中实现DOM属性控制即可。

<div
  {...{ [EDITOR_KEY]: true }}
  contentEditable={!readonly}
>
  <BlockModel></BlockModel>
</div>

除此之外,在诸如工具栏、图片、Mention等模块中,通常需要额外的控制面板来编辑相关内容,那么在只读模式下,就需要感知到状态的变化。而在React中,我们可以直接通过Context来感知到状态的变化,从而可以实现状态变化的感知。

<ReadonlyContext.Provider value={!!readonly}>
  {children}
</ReadonlyContext.Provider>
const ReadonlyContext = createContext<boolean>(false);
ReadonlyContext.displayName = "Readonly";

const useReadonly = () => {
  const readonly = React.useContext(ReadonlyContext);
  return { readonly };
};

const { readonly } = useReadonly();

理论上而言,编辑器的只读状态变更是需要被感知到的,否则会导致编辑器的状态不一致。不过在实际应用中,暂时还没有需要的场景,因此这里还没有实现,当前主要是在视图只读状态变化之后,设置编辑器的只读状态,而没有触发相关事件。

export const BlockKit: React.FC<BlockKitProps> = props => {
  if (editor.state.get(EDITOR_STATE.READONLY) !== readonly) {
    editor.state.set(EDITOR_STATE.READONLY, readonly || false);
  }
}

Plugin 渲染插件模式

Core核心服务中,我们已经实现了一套插件的渲染模式,这部分插件模式对于基本类型的样式是没什么问题的。然而,在实现诸如超链接、引用块这些需要组合类型的插件时,就需要特殊处理,这些类型的节点不需要持有状态,只需要在渲染时根据状态来渲染即可。

举个例子,当实现超链接时,按照基本的拆离文本节点的方式来渲染,那么就会出现下面的情况。特别是,如果是加粗或者斜体等样式,那么就会出现拆离内容的情况,虽然并不会造成特别大的影响,但是体验上会稍显差一些,例如hover上去出现的下划线是一段段的而非整体。

<b><a href="xx">part a</a></b>
<i><a href="xx">part b</a></i>

因此理论上而言,超链接的渲染需要特殊处理,a标签整个需要被渲染到一个容器中,而不是拆离文本节点的方式来渲染。当然,在实际输入的过程中,a标签在IME输入的时候,本身会破坏DOM结构,这部分内容可以参考本系列#8的包装节点部分。

<a href="xx">
  <b>part a</b>
  <i>part b</i>
</a>

因此在React中,我们还需要实现一套渲染时的插件模式,也就是在渲染时根据状态来渲染插件。在这里之需要扩展Core核心服务中的插件模式,然后在React渲染组件中调度这部分模块。不过在此之前,还需要设计一个渲染包装模式的策略。

如果仅仅是单个key来实现渲染时嵌套并不是什么复杂问题,而同时存在多个key则变成了令人费解的问题。如下面的例子中,如果将34单独合并b,外层再包裹a似乎是合理的,但是将34先包裹a后再合并5b也是合理的,甚至有没有办法将67一并合并,因为其都存在b标签。

1 2 3  4  5 6  7 8 9 0
a a ab ab b bc b c c c

这个问题比较复杂,本着简单可扩展的原则,最终想到了个简单的实现,对于需要wrapper的元素,如果其合并listkeyvalue全部相同的话,那么就作为同一个值来合并。那么这种情况下就变的简单了很多,我们将其认为是一个组合值,而不是单独的值,在大部分场景下是足够的。

1 2 3  4  5 6  7 8 9 0
a a ab ab b bc b c c c
12 34 5 6 7 890

那么接下来就需要按照这部分模式来处理渲染,首先这是一套纯渲染模式,那么我们就需要实现一个Map来映射渲染的jsxstate。而为什么不是state映射jsx,则是为了兼容现有的elements - jsx返回值。

const elements = useMemo(() => {
  const leaves = lineState.getLeaves();
  // 首先渲染所有非 EOL 的叶子节点
  const textLeaves = leaves.slice(0, -1);
  const nodes = textLeaves.map(n => {
    const node = <LeafModel key={n.key} editor={editor} leafState={n} />;
    JSX_TO_STATE.set(node, n);
    return node;
  });
  return nodes;
}, [editor, lineState]);

接下来,就根据elements的顺序来组合包装节点了,在这里之需要一个O(n)的遍历即可。我们需要为状态设置一个key值,以便于判断当前节点和二级的遍历节点是否需要合并,如何需要合并则进入合并逻辑。

export const getWrapSymbol = (keys: string[], el: JSX.Element | undefined): string | null => {
  const attrs = state.op.attributes;
  const suite: string[] = [];
  for (const key of keys) {
    attrs[key] && suite.push(`${key}${attrs[key]}`);
  }
  const symbol = suite.join("");
  return symbol;
};

紧接着就可以遍历elements来组合包装节点了,每个节点都需要判断下一个节点是否需要合并。顺序进行二次迭代,当出现连续的symbol相等时,说明是需要合并的,这里特别注意如果下一个节点不能合并,则需要回退i,以便于外层主循环时重新检查。

// 执行到此处说明需要包装相关节点(即使仅单个节点)
const nodes: JSX.Element[] = [element];
for (let k = i + 1; k < len; ++k) {
  const next = elements[k];
  const nextSymbol = getWrapSymbol(keys, next);
  if (!next || !nextSymbol || nextSymbol !== symbol) {
    // 回退到上一个值, 以便下次循环时重新检查
    i = k - 1;
    break;
  }
  nodes.push(next);
  i = k;
}

最后,我们之需要调度插件来渲染具体的React节点就可以了,这部分就是完全依靠React的渲染机制来实现,而其中key值目前则是直接使用了起始和结束的索引值。不过后续这个key值可能需要根据symbol来生成,以确保在合并时能够正确处理。

// 通过插件渲染包装节点
let wrapper: React.ReactNode = nodes;
const op = line.op;
for (const plugin of plugins) {
  // 这里的状态以首个节点为准
  const context: ReactWrapLineContext = {
    lineState: line,
    children: wrapper,
  };
  if (plugin.match(line.op.attributes || {}, op) && plugin.wrapLine) {
    wrapper = plugin.wrapLine(context);
  }
}
const key = `${i - nodes.length + 1}-${i}`;
wrapped.push(<React.Fragment key={key}>{wrapper}</React.Fragment>);

Portal 外部节点挂载

在实现诸如Mention、划词改写等模块时,通常需要额外的辅助节点来渲染面板,例如Mention需要唤醒额外的面板来选择要at的对象,并且需要在此基础上实现诸如上下选择、回车等交互。

这种情况下,Mention面板通常是不会渲染在编辑器内部的,需要额外的节点来渲染这个面板。因此在实现编辑器模块时,是额外渲染了一个mount-dom作为辅助节点的容器,以此作为原始的DOM结构提供给ReactDOM来渲染。

const onMountRef = (e: HTMLElement | null) => {
  e && MountNode.set(editor, e);
};

<BlockKit editor={editor} readonly={readonly}>
  <div className="block-kit-editable-container">
    <div className="block-kit-mount-dom" ref={onMountRef}></div>
    <Editable></Editable>
  </div>
</BlockKit>

ReactDOM.render来渲染节点时,是不能够直接将该节点作为容器的,因为调用时并非直接追加React节点到DOM节点,而是直接将React节点渲染到该节点上。因此这种情况下,若是存在多个需要挂载的辅助节点,是无法完成的。

ReactDOM.render("string", document.getElementById("root"));

因此这里渲染辅助元素时,需要先将此节点作为容器,创建一个新的容器子节点,然后将该节点作为容器调用ReactDOM.render方法来渲染React节点。在最开始的时候,编辑器中的Mention面板是类似下面的实现:

if (!this.mountSuggestNode) {
  this.mountSuggestNode = document.createElement("div");
  this.mountSuggestNode.dataset.type = "mention";
  MountNode.get(this.editor).appendChild(this.mountSuggestNode);
}
const top = this.rect.top;
const left = this.rect.left;
const dom = this.mountSuggestNode!;
this.isMountSuggest = true;
ReactDOM.render(<Suggest controller={this} top={top} left={left} text={text} />, dom);

然后我们需要思考一个问题,在我们使用ReactDOM.createPortal来传送到目标节点时,更加类似于追加节点的方式来实现,而不是需要向上述的方式一样先创建容器再渲染节点,并且此时还可以使用Context来传递编辑器的状态。

但是createPortal没有办法像render方法那样可以直接渲染节点,其只是创建了一个Portal节点,而不是实际进行了渲染行为。因此,最终还是无法避免需要一个实际渲染的行为,相互配合起来类似于下面的实现,这样就可以将元素实际创建到body上。

const portal = ReactDOM.createPortal(
  <Suggest controller={this} top={top} left={left} text={text} />,
  document.body,
);
ReactDOM.render(portal, this.mountSuggestNode!);

那么如果类似于先前聊的Lexical的实现方式,独立控制一个Portals占位来渲染辅助节点,就可以避免使用render方法来渲染节点,并且可以直接在mount-dom追加节点而不需要再创建子容器,并且直接使用这种方法可以避免React 18createRoot方法Breaking Change

const PortalView: FC<{ editor: Editor }> = props => {
  const [portals, setPortals] = useState<O.Map<ReactPortal>>({});
  EDITOR_TO_PORTAL.set(props.editor, setPortals);

  return (
    <Fragment key="block-kit-portal-model">
      {Object.entries(portals).map(([key, node]) => (
        <Fragment key={key}>{node}</Fragment>
      ))}
    </Fragment>
  );
};

总结

先前我们讨论了零宽字符、Embed节点、Void节点等,主要是可编辑节点的组件预设。在本文中则主要讨论的是非编辑节点内容渲染,也就是占位节点、只读模式、插件模式、外部节点挂载等,主要是实现编辑器的外部节点,例如占位符号、弹出层结构等。

那么至此我们实现的编辑器的React视图层适配已经完成了,以此可以复用React的生态组件,降低了开发视图层的成本。接下来我们需要再处理Core服务的核心模块,其共同处理了编辑器的交互逻辑,例如剪贴板Clipboard、历史记录History、状态管理State等等。

每日一题

参考

Tauri 应用苹果签名踩坑实录

昨天终于心一横开了苹果开发者,一大早开了,想着我要一天搞定上架提交!然而,钱是付了,等到晚上九点多,才成功开通。好嘛,那第二天再努力吧,带着兴奋入睡,第二天一早起来开干。

事实是我把这想得太简单,出了舒适区,就真的想踏入泥沼一样寸步难行。搞了大半天才终于把不用上架的版本签好,晚上之前也把 pkg 打出来了。不容易啊!

下面讲讲一些值得注意的点吧。如果你也打算入坑 Tauri 开发,而且打算构建 macOS 应用或者 iOS 应用,记得收藏,以后会用到的。

证书

你先得搞清楚,CSR、CER、P12 这些概念分别是啥,不然肯定被 N 个证书搞得晕头转向。

在数字证书和公钥基础设施(PKI)领域,这三个缩写分别代表了证书申请、证书本身以及证书存储的不同阶段和格式。 简单来说,它们的关系可以看作是一个从申请到签发再到打包的过程。

CSR

本质:申请表

当你需要一个正式的 SSL/TLS 证书时,你首先要在服务器上生成一对密钥(私钥和公钥)。CSR 就是由你的公钥和一些身份信息(如域名、公司名称、国家等)组成的请求文件。

  • 作用: 你把这个文件发给证书颁发机构(CA,如 Digicert, Let's Encrypt)。
  • 隐喻: 办护照时填写的“申请表”。表上有你的照片(公钥)和个人资料,但它还不是护照。
  • 包含: 公钥 + 身份信息 + 数字签名。

CER

本质:正式证件

CA 收到你的 CSR 并核实身份后,会用他们的私钥对你的信息进行签名,生成一个证书文件,通常后缀是 .cer.crt

  • 作用: 安装在服务器上,向客户端证明你的身份并提供公钥。
  • 隐喻: 已经盖了章的正式“护照”。
  • 包含: 你的公钥、CA 的签名、有效期、颁发者信息。它不包含私钥。

P12

本质:全家桶安装包

.p12 是一种二进制格式的容器,它可以把私钥公钥(CER)以及中间证书链全部打包在一个文件里,并且通常由密码保护。

  • 作用: 方便迁移。比如你想把证书从一台服务器搬到另一台服务器,直接导出一个 P12 文件即可。在 iOS 开发或 Java 服务中非常常见。
  • 隐喻: 你的“保险箱”,里面装着护照(证书)、开启护照配套的钥匙(私钥)以及其他证明文件。
  • 包含: 证书 + 私钥 + (可选) 证书链。

在解决了证书本质上区别之后,你还要搞清楚苹果自己的 N 种证书。Developer ID Application 用于不上架的分发,上架还要用到 Distribution 和 Mac Installer Distribution 两个证书。

机子里证书有两个,一个 Apple Development,一个 Developer ID Application,不小心把证书导错了一次,排查又卡住。

其次 Tauri 一定程度上有点黑盒,加上对苹果应用开发不熟悉,从 Tauri 那不算太完整的文档里逐步实现签名。而且关键是这些信息还散落在 macOS Application BundlemacOS Code SigningApp Store 三个页面。

为了理清这三个页面的内容,又得把一堆苹果开发流程中的重要概念搞清楚。

概念

Entitlements

它是一组 key-value 对(权利字典),告诉操作系统“这个 App 允许使用哪些特殊能力”。例如:访问 iCloud、HomeKit、推送通知、相机、App Sandbox 等。这些权利会嵌入到 App 的二进制代码签名里。

iOS / macOS 上架时必须正确声明;Xcode 会自动生成 .entitlements 文件,签名时合并进去。

Notarization

Notarization 翻译过来就是做公证。你把已签名的 macOS App 上传给苹果,它会扫描恶意代码、检查签名问题。

扫描通过后,苹果给你的 App 发一个“公证票据”(ticket),你可以把它“钉”(staple)到 App 上。macOS Gatekeeper(门卫)看到有公证票据,就会放心让用户运行,而不会弹出应用损坏的错误

使用 Developer ID 证书在 App Store 外分发的 macOS App 必须公证。

Provisioning Profile

一个由苹果服务器签名的 .mobileprovision / .provisionprofile 文件,里面包含:

  • App ID(Bundle ID)
  • 开发者证书
  • 授权的设备列表(开发阶段)
  • 允许使用的 Entitlements 和服务

上架必须,非上架不需要。

双生配置

在搞清楚上面的概念之后就大概能明白了,Tauri 的构建配置必须分两种。

之前的一个卡点是,签名成功了也公证了,结果反而打不开,签名之前还能用 xattr -cr,现在用了都不行。

{
  // ...
  "macOS": {
    "entitlements": "./entitlements.plist",
    "signingIdentity": "Developer ID Application: XXX"
  }
  // ...
}

问了一轮 AI 以为是不知道什么原因导致的 entitlements 没写进去。但是后来又发现即使通过 codesign --force --deep --options runtime 手动把 entitlements 写进去了,依然打不开。

最后才恍然大悟,Tauri 文档上写的分开 tauri.appstore.conf 文件的必要性……

实际上打包非上架包的时候应该把 entitlements 删掉,这样反倒是打出来的包可以正常运行。于是!Mind Elixir v1.7.0 终于不用绕过安全策略,支持直接运行啦!

App Store 版本

然后发布 App Store 的版本我们外加一个配置文件 tauri.appstore.conf.json

{
  "bundle": {
    "macOS": {
      "entitlements": "./entitlements.plist",
      "signingIdentity": "Apple Distribution: Dexter Chow (9J69XMW5FC)",
      "files": {
        "embedded.provisionprofile": "./provisioning/MindElixirMac.provisionprofile"
      }
    }
  }
}

构建时运行:

pnpm tauri build --config src-tauri/tauri.appstore.conf.json --target universal-apple-darwin

App Store 版本不需要公正,跑公正只会提示你需要用 Developer ID Application 证书。因此我们需要把环境变量里公正用到的值清空,这样 Tauri 就不会自动公正了。

pnpm tauri build 之后拿到了 .app,接着还要用 pkgbuild 打包成 pkg。

这两步就用到了上面提到的两个证书:

  • Apple Distribution → 签 App 本身(.app)
  • Mac Installer Distribution → 签 安装包(.pkg)

最后使用 Transporter 上传 pkg 包(开了虚拟网卡 Transporter 传不了),注意打包兼容 Intel 芯片的 universal 包,如果不想兼容 Intel 芯片,系统要限定在 12.0 以上。

后话

我真的不敢想象没 AI 我看这些文档要看到何年何月。但是做好了,又觉得其实没那么难。所以确实,一件事做到过和没做到过就是完全不一样。没做到过你会怀疑每一个细节有问题,脑子炸炸的,做到了你就知道大致什么是没问题的。后续再处理问题就简单多了。

在 Usubeni Fantasy 阅读:ssshooter.com/tauri-mac-s…

丢掉沉重的记忆:Codex、Claude Code 与 OpenCode 的上下文压缩术

丢掉沉重的记忆:Codex、Claude Code 与 OpenCode 的上下文压缩术

在使用 AI Agent 深度参与编程任务时,你一定遇到过这种窘境:起初 AI 反应敏捷,指哪打哪;但随着对话轮次增加,它似乎开始变得越来越笨。

上下文快用完的时候,AI会着急完成导致效果不佳,社区中称作 Context Anxiety (上下文焦虑),和我们人一样,着急就容易出错。为了维持对话,Agent 必须丢掉一部分记忆(压缩 - Compact)。但怎么丢、丢掉谁、丢掉后怎么补救,成了衡量一个 Agent 运行时(Runtime)是否成熟的分水岭之一。

今天笔者就带大家拆解三款主流 CLI Agent——Codex CLIClaude CodeOpenCode。看看它们在面对同一个登录 Bug 时,是如何施展各自的"压缩大法"的。

注:本文分析基于 Codex CLI 与 OpenCode 的开源仓库逻辑,以及社区逆向研究与泄露源码对 Claude Code 运行时的验证。

场景回放:一场价值 15,400 Tokens 的登录修复

假设你正在修复一个登录接口报 401 Unauthorized 的 Bug。你召唤了 AI Agent,并经历了一番激烈的排查。

下面是这段对话的完整记录:

编号 角色 内容摘要 预估 Token
#1 System 系统提示词(含 40+ 工具定义) ~800
#2 User "登录页面报 401,帮我排查下" ~100
#3 Assistant "我先搜一下认证相关的逻辑" ~150
#4 Tool Call grep "auth" --include="*.ts" ~50
#5 Tool Result (返回 50 处搜索结果) ~2,000
#6 Assistant "搜到几处,我看看 auth.ts" ~150
#7 Tool Call read_file "src/auth.ts" ~50
#8 Tool Result (完整文件内容,约 300 行) ~3,500
#9 Assistant "找到了,token 校验没处理过期" ~300
#10 User "好,但要兼容旧的 Session 方式" ~80
#11 Assistant "明白,我再看看 middleware" ~150
#12 Tool Call read_file "src/middleware.ts" ~50
#13 Tool Result (middleware 完整内容) ~2,500
#14 Assistant "middleware 也要改,我来处理" ~200
#15 Tool Call edit_file "src/auth.ts" (patch) ~100
#16 Tool Result "Successfully applied." ~30
#17 Tool Call edit_file "src/middleware.ts" (patch) ~100
#18 Tool Result "Successfully applied." ~30
#19 Assistant "代码改好了,跑个测试看看" ~100
#20 Tool Call bash "npm test" ~50
#21 Tool Result (3 个测试失败,含堆栈) ~3,000
#22 Assistant "有 3 个测试挂了,我修一下测试用例" ~200
#23 Tool Call edit_file "src/auth.test.ts" (patch) ~150
#24 Tool Result "Successfully applied." ~30
#25 Tool Call bash "npm test" ~50
#26 Tool Result (测试全部通过,含完整输出) ~1,500

看起来不过 26 条消息,但已经吃掉了约 15,400 tokens。其中加粗的五条工具结果(#5, #8, #13, #21, #26)合计约 12,500 tokens,占了 81% 。这些数据在排查时至关重要,但 Bug 修好后,它们就变成了上下文里沉重的负担。如果不处理,下一轮对话可能因为窗口溢出而丢掉系统提示词或用户的核心需求。

Codex CLI:写一份干练的"工作交接单"

OpenAI 的 Codex CLI(源码,Rust 实现)走的是一种非常符合人类直觉的路线:总结与替换

它的核心思想可以用一句话概括:把之前的全部对话交给 LLM 写一份"工作交接摘要",然后用这份摘要替换掉原始历史。

双路径设计

Codex 提供了两条压缩路径:

  1. 本地路径compact.rs):在客户端调用 LLM 生成摘要,适用于所有模型提供商。
  2. 远程路径compact_remote.rs):直接调用 OpenAI 的内部 API 端点 responses/compact,让服务器完成压缩。仅限 OpenAI 自家模型。

注意,这里的"本地"和"远程"指的不是是否需要调用 LLM——两条路径都需要 LLM 参与,区别在于 "生成摘要"这个核心步骤跑在哪里。本地路径下,客户端自己构造摘要 Prompt(从内置模板 templates/compact/prompt.md 加载)、通过 ModelClientSession 流式调用 LLM API、再处理返回结果,整个编排流程都在你的机器上完成,所以它能对接任意模型提供商。远程路径下,客户端把准备好的对话历史和工具定义发给 OpenAI 的 compact_conversation_history 端点,由服务器完成摘要生成——但客户端并非"甩手掌柜",它在调用前后仍然承担了大量工作:调用前要修剪过长的函数调用历史、构建包含工具规范和系统指令的完整 Prompt 对象;调用后要过滤返回结果(比如丢弃过时的 developer 角色消息、只保留真实的用户和助手内容)、恢复用于 /undo 功能的 ghost snapshots、以及重新计算 token 用量。

简单说,远程路径只是把 "压缩"这一步外包给了 OpenAI 服务器,前处理和后处理仍由客户端完成。这种设计的优势在于:OpenAI 服务端很可能对这个端点做了专门优化(比如使用更经济的模型或内部缓存),这些是客户端走通用 API 做不到的。这体现了 OpenAI 对自家基础设施的垂直整合。

压缩的具体流程

当走本地路径时,Codex 会先提取最近的用户消息(硬上限约 20,000 tokens),然后发送一段简短的 Summarization Prompt 给 LLM。这段 Prompt 只有 4 个核心要点:

你正在执行一次"上下文检查点压缩"。请为另一个将接续任务的 LLM 生成一份交接摘要,包含:当前进展和关键决策、重要的约束和用户偏好、剩余待办事项、继续工作所需的关键数据。

关键词是 "交接"(Handoff) ——它不是在写会议纪要,而是在写一份让下一个人(模型)能直接上手的工作简报。

用我们的登录 Bug 场景来看:

Codex CLI 压缩前后对比

思路拆解:

注意看压缩前后的变化——所有消息变成了 4 条。Codex 极其尊重"用户意图",它会物理删除所有的 Assistant 回复和 Tool 相关消息,但会原封不动地保留所有 User 消息(#2 和 #10)。

随后,它插入一条伪造的 Assistant 消息,内容是一份结构化的交接总结。这份总结包含了任务目标、已完成项、关键架构决策和剩余待办。对于新模型来说,它不需要看那些大段的文件内容和测试堆栈,它只需要知道"测试已经修好了"就足够了。

自动触发与兜底

当 Token 用量接近模型上下文窗口上限时,Codex 会自动触发压缩(不需要用户手动执行 /compact)。如果压缩后空间还是不够,它会退而采取更激进的"头部修剪"——直接从最早的消息开始砍,确保对话能继续下去。

笔者觉得 Codex 的方案最大的优点是直觉性:交接摘要这个概念每个职场人都能理解。缺点是它比较"一刀切"——所有 AI 回复和工具结果都被替换成一段摘要,如果那段摘要遗漏了某个关键细节,就真的找不回来了。

Claude Code:三层递进的"精密遗忘"

Anthropic 出品的 Claude Code 逻辑更为细腻。它不追求一步到位的物理删除,而是设计了三层逐级加强的清理机制——从轻到重,能不动 LLM 就不动 LLM。

注:Claude Code 非开源项目,以下分析基于社区逆向工程和公开资料,具体实现可能随版本变化。

第一层:工具结果修剪(无 LLM 开销)

这是最频繁、也最轻量的一层。不需要调用 LLM,纯粹是本地的规则引擎。它在每次请求前都会自动执行。

它的逻辑很简单:

  • 始终保护最近若干个工具调用的结果(正在用的东西不能删)
  • 超出保护范围的旧工具结果 → 替换为 [Old tool result content cleared] 占位符

用我们的场景来看:

Claude Code 第一层压缩

这种做法极其聪明:它维护了 AI 的"心流"。AI 记得自己搜过代码(#4 的 tool_call 还在),也记得自己读过文件(#7 的 tool_call 还在),只是不记得搜到了什么、文件内容是什么。如果它之后真的需要再次查看,它会自己重新发起 read_file

笔者认为这一层的设计极为精妙——它实现了 "选择性失忆"而非"全面遗忘" 。就像你记得去年读过一本好书,但忘了具体内容,需要的时候再翻就好。

第二层:缓存友好策略(Prompt Cache)

这是 Claude Code 的看家本领,也是三者中独有的差异化优势

Anthropic 的 API 支持 Prompt Cache——如果你发给 API 的消息前缀和上一次请求相同,服务器可以复用之前的计算结果,大幅降低成本和延迟。

这意味着什么?在清理消息时,Claude Code 会尽量避免修改消息序列的前半部分。它采用"手术式"方案:只在尾部进行修整,确保消息开头部分保持绝对一致。这样做的代价是清理效率略低,但换来的是缓存命中率的最大化

用我们的场景来看。假设经过第一层清理后,消息序列是 #1-#26(工具结果已替换为占位符)。现在上下文仍然超标,需要进一步裁剪。一个"朴素"的做法是从最早的消息开始删——但 Claude Code 不这么干

缓存策略对比

左边的朴素策略虽然删掉了最旧的消息,看起来很合理,但代价是整个前缀都变了——API 缓存全部失效,下次请求要从头计算。右边的 Claude Code 策略则相反:它宁可少删一些,也要保证消息序列的前缀部分和上一次请求完全相同,让 Anthropic API 的 Prompt Cache 能够命中。

在长时间运行的任务中(比如你连续让 AI 帮你重构一整个模块),这种策略能带来可观的成本节省——因为每次 API 请求的大部分内容都能命中缓存,只需要为新增的尾部内容付费。

第三层:9 部分结构化 LLM 总结(最后手段)

当前两层都无法阻止上下文继续膨胀时,系统触发最终的全量总结。根据源码,自动压缩的触发阈值为 有效上下文窗口 - 13,000 tokens(其中有效窗口 = 模型上下文窗口 - min(最大输出 tokens, 20,000))。

不过,即使达到了阈值,系统也不会直接跳到 LLM 总结。自动压缩触发时,系统会优先尝试 Session Memory Compact——利用 session memory(会话记忆)中已有的结构化信息来替代完整的 LLM 调用。这意味着大多数自动压缩甚至不需要 LLM 调用。只有当 session memory 路径不可用或不够时,系统才会回退到传统的 LLM 总结流程,生成一份包含 9 个固定部分的结构化摘要

  1. 用户的原始意图
  2. 核心技术概念
  3. 关注的文件和代码
  4. 遇到的错误及修复方式
  5. 解决问题的逻辑链
  6. 所有用户消息的摘要
  7. 待办事项
  8. 当前正在做什么
  9. 建议的下一步

这份摘要的要求极其严格——Prompt 中会要求模型直接引用原文关键短语,而不是全部用自己的话改写。这是为了防止"语境漂移"(模型在复述过程中微妙地偏离原意)。

用我们的场景来看:

Claude Code 第三层压缩

压缩完成后,Claude Code 还会做一系列善后工作,笔者把它叫做 "状态重构"

  • 在新对话开头注入引导语("本次会话延续自上一段对话...")
  • 自动重新读取最近编辑过的文件(最多 5 个文件,总预算 50,000 tokens,单文件上限 5,000 tokens),确保 AI 手里有最新代码
  • 重新声明工具和技能定义
  • CLAUDE.md 中的项目规范作为系统提示语的一部分,始终常驻,不受压缩影响

用户还可以在手动压缩时附加自定义指令,比如 /compact Focus on API changes,引导压缩侧重于特定方向。

此外,系统还有一条被动兜底路径:当 API 返回 prompt_too_long 错误时,系统会自动启动一次反应式压缩并重试请求,确保用户不会因为上下文溢出而直接遇到错误中断。同时,为防止压缩反复失败导致的死循环,连续 3 次自动压缩失败后系统会暂停自动压缩功能。

Claude Code 的方案是三者中最复杂的,但也是最"省钱"的——大多数时候它只需要执行第一层的规则引擎清理,或者通过 Session Memory 路径完成压缩,根本不需要额外的 LLM 调用。

OpenCode:先修剪,再摘要的"阶梯治理"

开源界的新秀 OpenCode(源码,TypeScript + Effect-TS 实现)则提供了一种更为平衡的策略。它在 session/compaction.ts 中实现了一套阶梯式的治理流程:先用低成本手段尽可能腾空间,实在不够再动用 LLM。

第一步:Prune(标记隐藏,非物理删除)

OpenCode 的第一个动作不是删除,而是"标记"。它的规则非常清晰:

  • 只有当修剪能释放超过 20,000 tokens 时才执行(小修小补不值得折腾)
  • 始终保留最近的 40,000 tokens 作为"安全垫"(正在进行的工作不能动)
  • skill 类型的工具输出永远不修剪(因为里面包含操作指令)
  • 保护最近 2 个用户回合的完整内容

关键设计:和 Claude Code 的占位符替换不同,OpenCode 的修剪不是物理删除,而是给旧消息打上一个 compacted = Date.now() 的时间戳标记,让它们在后续请求中"不可见"。数据其实还在数据库里,只是被隐藏了。

OpenCode Prune

关键点: 数据并没有真正丢掉。这为未来可能的历史回溯功能留下了空间——如果开发者需要审计,或者 Agent 触发了某种回溯逻辑,这些数据是可以被重新拉回上下文的。这是一个很有前瞻性的设计。

第二步:LLM 5 标题摘要

如果 Prune 之后还是太臃肿,OpenCode 会用一个隐藏的、专门的 Agent(不干扰用户当前的交互)来调用 LLM 生成一份摘要。这份摘要有一个固定的 5 标题结构:

OpenCode LLM 摘要

OpenCode 在摘要后有一个非常温馨的设计:它会自动重放最后一条用户消息。这能确保 Agent 的最后记忆点始终停留在用户的最新指令上,而不是停留在一段冷冰冰的摘要总结里。用户完全感知不到压缩的发生——你说的最后一句话会被重新发送,AI 继续回答,好像什么都没发生过。

另一个亮点:OpenCode 会跟随用户的语言。如果你一直用中文交流,它的摘要也会是中文的。这对非英语母语的开发者来说,是一个很友好的设计。

笔者觉得 OpenCode 的方案在三者中最"开发者友好"——代码全开源(TypeScript),架构现代(Effect-TS),非物理删除的设计为扩展留足空间。如果你想深度定制压缩行为,OpenCode 是最容易上手的。

三剑客同台竞技

我们将三者的方案放在一起并排观察:

输入:26 条消息, ~15,400 tokens(同一个"修登录 bug"场景)

三剑客对比

维度 Codex CLI Claude Code OpenCode
压缩层次 单层(摘要) 三层(修剪/缓存/摘要) 两层(隐藏/摘要)
LLM 调用 必须 仅在第三层 仅在第二步
用户消息 永久保留原始内容 摘要化(第三层) 摘要化 + 重放最后一条
工具结果处理 物理删除 占位符替换 时间戳标记隐藏
缓存优化 无特殊设计 深度集成 Prompt Cache 侧重减少重复读取
压缩后行为 被动等待 主动重读相关文件 自动重放最后指令

一些值得展开说的差异

关于"要不要保留用户原话" :Codex 选择保留用户消息、只压缩模型回复,这样做的好处是 AI 永远能回看你说过什么,但代价是当用户消息本身很长时,压缩效率会打折扣。Claude Code 和 OpenCode 则选择全部压缩为摘要,更激进但更节省空间。

关于缓存:这是 Claude Code 最独特的优势。其他两家在压缩后,API 请求的内容会发生很大变化,之前的缓存基本作废。而 Claude Code 刻意维持消息前缀的稳定性,使得压缩后的请求依然能复用之前的缓存。在长时间运行的任务中,这意味着可观的成本节省。

关于"非物理删除" :OpenCode 的时间戳标记方式是个很有前瞻性的设计。虽然当前版本并没有实现历史回溯功能,但数据没有真正丢失,为未来留下了可能性。而 Codex 和 Claude Code 的压缩都是不可逆的。

最后

如果用一个类比来形容这三位:

  • Codex CLI 像是一个写交接单的资深员工。他直接撕掉之前的草稿纸,给你一张写的清清楚楚的现状说明,虽然简单粗暴,但非常有效。
  • Claude Code 像是一个拥有精密遗忘能力的学者。他优先划掉书上的细碎批注,只有在书架实在堆不下时,才会把整本书浓缩成一页大纲。他非常在意翻书的效率(缓存)。
  • OpenCode 像是一个务实的阶梯治理者。他先给旧文件打包贴上标签(隐藏),实在不行才做总结。他最贴心的地方在于,总结完后还会提醒你:"你刚才最后说的是这件事对吧?"

归根结底,在 2026 年,最好的上下文管理并不是无止境地扩大 LLM 的记忆容量,而是学会如何精密地遗忘。毕竟,一个什么都记得住的 Agent,往往也最容易被噪音干扰。


参考来源:

前端性能优化:从"术"到"道"的完整修炼指南

前端性能优化:从"术"到"道"的完整修炼指南

摘要: 针对前端工程师在性能优化中"背了技巧却不会用"的普遍困境,本文提出"术道结合"的双层学习路径。"术"篇聚焦代码实战,通过一个电商详情页从5.2秒优化到1.9秒的完整案例,提供可直接复用的Vue 3/Vite配置、Web Worker组件和缓存策略;"道"篇升华至方法论层面,建立"网络-资源-渲染-计算"四层优化模型,涵盖从测量、验证到监控的闭环体系,并给出面试话术与避坑指南。两篇文章形成"先动手、后动脑"的认知递进,帮助读者既解决眼前问题,又建立长期可迁移的优化能力。

在这里插入图片描述在这里插入图片描述

写在前面:性能优化是前端工程师的必修课,但很多人困在"背了20个技巧却不知道何时用"的窘境。这两篇文章,一篇教你动手做,一篇教你动脑想,形成从代码到思维的完整闭环。


为什么性能优化总让人"似懂非懂"

面试时侃侃而谈"懒加载、CDN、压缩合并",真遇到首屏5秒的白屏页面却无从下手——这是前端工程师的常见困境。

根源在于:市面上的教程大多是"技巧清单",缺乏两个关键维度:

缺失维度 后果
没有分层思维 分不清是网络慢、代码慢还是渲染慢,优化打不到七寸
没有闭环意识 优化完不测、不监控,三个月后性能又劣化到原点
没有边界判断 为了追1秒的加载时间,写出难以维护的晦涩代码

这两篇文章,就是为解决这三个"没有"而写。


两篇文章的定位与阅读指南

我们将性能优化的能力拆解为两个层次:

┌─────────────────────────────────────────┐
│  第二层:"道"篇 —— 思维框架与工程哲学        │
│  《前端性能优化的底层逻辑》                 │
│                                        │
│  • 四层优化模型(网络→资源→渲染→计算)    │
│  • 从测量到监控的完整闭环                 │
│  • 知道"何时不做优化"的边界判断           │
│  • 面试话术与团队规范建设                 │
└─────────────────────────────────────────┘
                    ▲
                    │ 升华
┌─────────────────────────────────────────┐
│  第一层:"术"篇 —— 手把手的代码教程        │
│  《从5.2秒到1.9秒的代码级改造全记录》      │
│                                         │
│  • Vue 3 + Vite 的 manualChunks 实战     │
│  • Web Worker + Service Worker 完整代码    │
│  • 响应式图片组件(可直接复用)            │
│  • Lighthouse 从32分到89分的具体数据       │
└─────────────────────────────────────────┘

阅读建议

  • 急用先学:直接读"术"篇,抄代码解决眼前问题
  • 长期建设:再读"道"篇,建立可迁移的优化思维
  • 面试准备:重点读"道"篇的面试话术章节,用"术"篇的数据做支撑
第一篇 第二篇
角色定位 "术"篇:手把手的代码教程 "道"篇:思维框架与工程哲学
读者收获 拿到可直接用的配置和组件 建立可迁移的优化分析能力
阅读顺序 先读:解决"怎么做" 后读:理解"为什么做"和"何时不做"
预告 "下一篇将分享如何建立优化思维框架,避免过度优化陷阱" 开篇回顾:"在上一篇的实战基础上,今天我们跳出代码,聊聊性能优化的底层逻辑"

第一篇预告:"术"篇

摘要:《前端性能优化实战:从5.2秒到1.9秒的代码级改造全记录》 记录了一个电商商品详情页的性能优化完整过程。针对首屏加载5.2秒、Lighthouse评分32分的现状,采用分层优化策略:资源层通过智能图片组件实现WebP格式自适应与懒加载,构建层借助Webpack/Vite的manualChunks精细化分割代码,渲染层利用Web Workers将长任务从主线程剥离,网络层实施Service Worker的Stale-While-Revalidate缓存与关键资源预加载。优化后LCP降至1.9秒,Lighthouse提升至89分,跳出率下降28%。文中提供全部可运行代码,包括OptimizedImage组件、Worker通信逻辑及Vite配置,可直接应用于生产环境。

核心内容

  • 一个电商商品详情页的完整优化实录
  • 分层优化的具体代码:资源层(图片)、构建层(Webpack/Vite)、渲染层(Worker)、网络层(预加载)
  • 可直接复制的组件:OptimizedImage、Service Worker 缓存策略、Vue 3 异步组件

你将获得:一份能直接运行的"性能优化工具箱"


第二篇预告:"道"篇

摘要:《前端性能优化的底层逻辑:从"会写代码"到"会诊断问题"的进阶之路》 跳出具体技术栈,本文构建了一套前端性能优化的通用方法论。首先阐述关键渲染路径(CRP)原理,提出"最小改动验证"的科学优化流程;继而建立"网络层-资源层-渲染层-计算层"四层分析模型,明确各层优化手段与边界;随后完善从本地Lighthouse验证、线上真实用户监控(RUM)到性能预算防控的完整闭环;最后针对preconnect滥用、Web Worker序列化开销、虚拟滚动限制等场景给出避坑指南,并提供可直接使用的面试STAR话术。本文适用于React/Vue双栈开发者及准备前端面试的工程师,帮助建立"诊断-分层-验证-监控"的系统化优化思维。

核心内容

  • 关键渲染路径(CRP)原理与最小验证法
  • 四层优化模型的抽象与应用
  • 性能监控闭环:本地验证 → 线上RUM → 性能预算
  • 避坑指南:preconnect滥用、Worker开销、过度优化陷阱
  • 面试话术:如何用STAR法则讲一个完整的性能优化故事

你将获得:一套能应对任何技术栈的"性能优化方法论"


一句话总结

"术"篇让你能把眼前项目的性能优化到1.9秒,"道"篇让你能回答"为什么是1.9秒而不是0.9秒,以及怎么保证三个月后还是1.9秒"。

两篇文章,从代码到思维,从实战到哲学,构成前端性能优化的完整修炼路径。

接下来,我们先从"术"篇开始。


总章完

---# 前端性能优化:从"术"到"道"的完整修炼指南

摘要: 针对前端工程师在性能优化中"背了技巧却不会用"的普遍困境,本文提出"术道结合"的双层学习路径。"术"篇聚焦代码实战,通过一个电商详情页从5.2秒优化到1.9秒的完整案例,提供可直接复用的Vue 3/Vite配置、Web Worker组件和缓存策略;"道"篇升华至方法论层面,建立"网络-资源-渲染-计算"四层优化模型,涵盖从测量、验证到监控的闭环体系,并给出面试话术与避坑指南。两篇文章形成"先动手、后动脑"的认知递进,帮助读者既解决眼前问题,又建立长期可迁移的优化能力。

在这里插入图片描述在这里插入图片描述

写在前面:性能优化是前端工程师的必修课,但很多人困在"背了20个技巧却不知道何时用"的窘境。这两篇文章,一篇教你动手做,一篇教你动脑想,形成从代码到思维的完整闭环。


为什么性能优化总让人"似懂非懂"

面试时侃侃而谈"懒加载、CDN、压缩合并",真遇到首屏5秒的白屏页面却无从下手——这是前端工程师的常见困境。

根源在于:市面上的教程大多是"技巧清单",缺乏两个关键维度:

缺失维度 后果
没有分层思维 分不清是网络慢、代码慢还是渲染慢,优化打不到七寸
没有闭环意识 优化完不测、不监控,三个月后性能又劣化到原点
没有边界判断 为了追1秒的加载时间,写出难以维护的晦涩代码

这两篇文章,就是为解决这三个"没有"而写。


两篇文章的定位与阅读指南

我们将性能优化的能力拆解为两个层次:

┌─────────────────────────────────────────┐
│  第二层:"道"篇 —— 思维框架与工程哲学        │
│  《前端性能优化的底层逻辑》                 │
│                                        │
│  • 四层优化模型(网络→资源→渲染→计算)    │
│  • 从测量到监控的完整闭环                 │
│  • 知道"何时不做优化"的边界判断           │
│  • 面试话术与团队规范建设                 │
└─────────────────────────────────────────┘
                    ▲
                    │ 升华
┌─────────────────────────────────────────┐
│  第一层:"术"篇 —— 手把手的代码教程        │
│  《从5.2秒到1.9秒的代码级改造全记录》      │
│                                         │
│  • Vue 3 + Vite 的 manualChunks 实战     │
│  • Web Worker + Service Worker 完整代码    │
│  • 响应式图片组件(可直接复用)            │
│  • Lighthouse 从32分到89分的具体数据       │
└─────────────────────────────────────────┘

阅读建议

  • 急用先学:直接读"术"篇,抄代码解决眼前问题
  • 长期建设:再读"道"篇,建立可迁移的优化思维
  • 面试准备:重点读"道"篇的面试话术章节,用"术"篇的数据做支撑
第一篇 第二篇
角色定位 "术"篇:手把手的代码教程 "道"篇:思维框架与工程哲学
读者收获 拿到可直接用的配置和组件 建立可迁移的优化分析能力
阅读顺序 先读:解决"怎么做" 后读:理解"为什么做"和"何时不做"
预告 "下一篇将分享如何建立优化思维框架,避免过度优化陷阱" 开篇回顾:"在上一篇的实战基础上,今天我们跳出代码,聊聊性能优化的底层逻辑"

第一篇预告:"术"篇

摘要:《前端性能优化实战:从5.2秒到1.9秒的代码级改造全记录》 记录了一个电商商品详情页的性能优化完整过程。针对首屏加载5.2秒、Lighthouse评分32分的现状,采用分层优化策略:资源层通过智能图片组件实现WebP格式自适应与懒加载,构建层借助Webpack/Vite的manualChunks精细化分割代码,渲染层利用Web Workers将长任务从主线程剥离,网络层实施Service Worker的Stale-While-Revalidate缓存与关键资源预加载。优化后LCP降至1.9秒,Lighthouse提升至89分,跳出率下降28%。文中提供全部可运行代码,包括OptimizedImage组件、Worker通信逻辑及Vite配置,可直接应用于生产环境。

核心内容

  • 一个电商商品详情页的完整优化实录
  • 分层优化的具体代码:资源层(图片)、构建层(Webpack/Vite)、渲染层(Worker)、网络层(预加载)
  • 可直接复制的组件:OptimizedImage、Service Worker 缓存策略、Vue 3 异步组件

你将获得:一份能直接运行的"性能优化工具箱"


第二篇预告:"道"篇

摘要:《前端性能优化的底层逻辑:从"会写代码"到"会诊断问题"的进阶之路》 跳出具体技术栈,本文构建了一套前端性能优化的通用方法论。首先阐述关键渲染路径(CRP)原理,提出"最小改动验证"的科学优化流程;继而建立"网络层-资源层-渲染层-计算层"四层分析模型,明确各层优化手段与边界;随后完善从本地Lighthouse验证、线上真实用户监控(RUM)到性能预算防控的完整闭环;最后针对preconnect滥用、Web Worker序列化开销、虚拟滚动限制等场景给出避坑指南,并提供可直接使用的面试STAR话术。本文适用于React/Vue双栈开发者及准备前端面试的工程师,帮助建立"诊断-分层-验证-监控"的系统化优化思维。

核心内容

  • 关键渲染路径(CRP)原理与最小验证法
  • 四层优化模型的抽象与应用
  • 性能监控闭环:本地验证 → 线上RUM → 性能预算
  • 避坑指南:preconnect滥用、Worker开销、过度优化陷阱
  • 面试话术:如何用STAR法则讲一个完整的性能优化故事

你将获得:一套能应对任何技术栈的"性能优化方法论"


一句话总结

"术"篇让你能把眼前项目的性能优化到1.9秒,"道"篇让你能回答"为什么是1.9秒而不是0.9秒,以及怎么保证三个月后还是1.9秒"。

两篇文章,从代码到思维,从实战到哲学,构成前端性能优化的完整修炼路径。

接下来,我们先从"术"篇开始。


总章完

---# 前端性能优化:从"术"到"道"的完整修炼指南

摘要: 针对前端工程师在性能优化中"背了技巧却不会用"的普遍困境,本文提出"术道结合"的双层学习路径。"术"篇聚焦代码实战,通过一个电商详情页从5.2秒优化到1.9秒的完整案例,提供可直接复用的Vue 3/Vite配置、Web Worker组件和缓存策略;"道"篇升华至方法论层面,建立"网络-资源-渲染-计算"四层优化模型,涵盖从测量、验证到监控的闭环体系,并给出面试话术与避坑指南。两篇文章形成"先动手、后动脑"的认知递进,帮助读者既解决眼前问题,又建立长期可迁移的优化能力。

在这里插入图片描述在这里插入图片描述

写在前面:性能优化是前端工程师的必修课,但很多人困在"背了20个技巧却不知道何时用"的窘境。这两篇文章,一篇教你动手做,一篇教你动脑想,形成从代码到思维的完整闭环。


为什么性能优化总让人"似懂非懂"

面试时侃侃而谈"懒加载、CDN、压缩合并",真遇到首屏5秒的白屏页面却无从下手——这是前端工程师的常见困境。

根源在于:市面上的教程大多是"技巧清单",缺乏两个关键维度:

缺失维度 后果
没有分层思维 分不清是网络慢、代码慢还是渲染慢,优化打不到七寸
没有闭环意识 优化完不测、不监控,三个月后性能又劣化到原点
没有边界判断 为了追1秒的加载时间,写出难以维护的晦涩代码

这两篇文章,就是为解决这三个"没有"而写。


两篇文章的定位与阅读指南

我们将性能优化的能力拆解为两个层次:

┌─────────────────────────────────────────┐
│  第二层:"道"篇 —— 思维框架与工程哲学        │
│  《前端性能优化的底层逻辑》                 │
│                                        │
│  • 四层优化模型(网络→资源→渲染→计算)    │
│  • 从测量到监控的完整闭环                 │
│  • 知道"何时不做优化"的边界判断           │
│  • 面试话术与团队规范建设                 │
└─────────────────────────────────────────┘
                    ▲
                    │ 升华
┌─────────────────────────────────────────┐
│  第一层:"术"篇 —— 手把手的代码教程        │
│  《从5.2秒到1.9秒的代码级改造全记录》      │
│                                         │
│  • Vue 3 + Vite 的 manualChunks 实战     │
│  • Web Worker + Service Worker 完整代码    │
│  • 响应式图片组件(可直接复用)            │
│  • Lighthouse 从32分到89分的具体数据       │
└─────────────────────────────────────────┘

阅读建议

  • 急用先学:直接读"术"篇,抄代码解决眼前问题
  • 长期建设:再读"道"篇,建立可迁移的优化思维
  • 面试准备:重点读"道"篇的面试话术章节,用"术"篇的数据做支撑
第一篇 第二篇
角色定位 "术"篇:手把手的代码教程 "道"篇:思维框架与工程哲学
读者收获 拿到可直接用的配置和组件 建立可迁移的优化分析能力
阅读顺序 先读:解决"怎么做" 后读:理解"为什么做"和"何时不做"
预告 "下一篇将分享如何建立优化思维框架,避免过度优化陷阱" 开篇回顾:"在上一篇的实战基础上,今天我们跳出代码,聊聊性能优化的底层逻辑"

第一篇预告:"术"篇

摘要:《前端性能优化实战:从5.2秒到1.9秒的代码级改造全记录》 记录了一个电商商品详情页的性能优化完整过程。针对首屏加载5.2秒、Lighthouse评分32分的现状,采用分层优化策略:资源层通过智能图片组件实现WebP格式自适应与懒加载,构建层借助Webpack/Vite的manualChunks精细化分割代码,渲染层利用Web Workers将长任务从主线程剥离,网络层实施Service Worker的Stale-While-Revalidate缓存与关键资源预加载。优化后LCP降至1.9秒,Lighthouse提升至89分,跳出率下降28%。文中提供全部可运行代码,包括OptimizedImage组件、Worker通信逻辑及Vite配置,可直接应用于生产环境。

核心内容

  • 一个电商商品详情页的完整优化实录
  • 分层优化的具体代码:资源层(图片)、构建层(Webpack/Vite)、渲染层(Worker)、网络层(预加载)
  • 可直接复制的组件:OptimizedImage、Service Worker 缓存策略、Vue 3 异步组件

你将获得:一份能直接运行的"性能优化工具箱"


第二篇预告:"道"篇

摘要:《前端性能优化的底层逻辑:从"会写代码"到"会诊断问题"的进阶之路》 跳出具体技术栈,本文构建了一套前端性能优化的通用方法论。首先阐述关键渲染路径(CRP)原理,提出"最小改动验证"的科学优化流程;继而建立"网络层-资源层-渲染层-计算层"四层分析模型,明确各层优化手段与边界;随后完善从本地Lighthouse验证、线上真实用户监控(RUM)到性能预算防控的完整闭环;最后针对preconnect滥用、Web Worker序列化开销、虚拟滚动限制等场景给出避坑指南,并提供可直接使用的面试STAR话术。本文适用于React/Vue双栈开发者及准备前端面试的工程师,帮助建立"诊断-分层-验证-监控"的系统化优化思维。

核心内容

  • 关键渲染路径(CRP)原理与最小验证法
  • 四层优化模型的抽象与应用
  • 性能监控闭环:本地验证 → 线上RUM → 性能预算
  • 避坑指南:preconnect滥用、Worker开销、过度优化陷阱
  • 面试话术:如何用STAR法则讲一个完整的性能优化故事

你将获得:一套能应对任何技术栈的"性能优化方法论"


一句话总结

"术"篇让你能把眼前项目的性能优化到1.9秒,"道"篇让你能回答"为什么是1.9秒而不是0.9秒,以及怎么保证三个月后还是1.9秒"。

两篇文章,从代码到思维,从实战到哲学,构成前端性能优化的完整修炼路径。

接下来,我们先从"术"篇开始。


总章完

---# 前端性能优化:从"术"到"道"的完整修炼指南

摘要: 针对前端工程师在性能优化中"背了技巧却不会用"的普遍困境,本文提出"术道结合"的双层学习路径。"术"篇聚焦代码实战,通过一个电商详情页从5.2秒优化到1.9秒的完整案例,提供可直接复用的Vue 3/Vite配置、Web Worker组件和缓存策略;"道"篇升华至方法论层面,建立"网络-资源-渲染-计算"四层优化模型,涵盖从测量、验证到监控的闭环体系,并给出面试话术与避坑指南。两篇文章形成"先动手、后动脑"的认知递进,帮助读者既解决眼前问题,又建立长期可迁移的优化能力。

在这里插入图片描述在这里插入图片描述

写在前面:性能优化是前端工程师的必修课,但很多人困在"背了20个技巧却不知道何时用"的窘境。这两篇文章,一篇教你动手做,一篇教你动脑想,形成从代码到思维的完整闭环。


为什么性能优化总让人"似懂非懂"

面试时侃侃而谈"懒加载、CDN、压缩合并",真遇到首屏5秒的白屏页面却无从下手——这是前端工程师的常见困境。

根源在于:市面上的教程大多是"技巧清单",缺乏两个关键维度:

缺失维度 后果
没有分层思维 分不清是网络慢、代码慢还是渲染慢,优化打不到七寸
没有闭环意识 优化完不测、不监控,三个月后性能又劣化到原点
没有边界判断 为了追1秒的加载时间,写出难以维护的晦涩代码

这两篇文章,就是为解决这三个"没有"而写。


两篇文章的定位与阅读指南

我们将性能优化的能力拆解为两个层次:

┌─────────────────────────────────────────┐
│  第二层:"道"篇 —— 思维框架与工程哲学        │
│  《前端性能优化的底层逻辑》                 │
│                                        │
│  • 四层优化模型(网络→资源→渲染→计算)    │
│  • 从测量到监控的完整闭环                 │
│  • 知道"何时不做优化"的边界判断           │
│  • 面试话术与团队规范建设                 │
└─────────────────────────────────────────┘
                    ▲
                    │ 升华
┌─────────────────────────────────────────┐
│  第一层:"术"篇 —— 手把手的代码教程        │
│  《从5.2秒到1.9秒的代码级改造全记录》      │
│                                         │
│  • Vue 3 + Vite 的 manualChunks 实战     │
│  • Web Worker + Service Worker 完整代码    │
│  • 响应式图片组件(可直接复用)            │
│  • Lighthouse 从32分到89分的具体数据       │
└─────────────────────────────────────────┘

阅读建议

  • 急用先学:直接读"术"篇,抄代码解决眼前问题
  • 长期建设:再读"道"篇,建立可迁移的优化思维
  • 面试准备:重点读"道"篇的面试话术章节,用"术"篇的数据做支撑
第一篇 第二篇
角色定位 "术"篇:手把手的代码教程 "道"篇:思维框架与工程哲学
读者收获 拿到可直接用的配置和组件 建立可迁移的优化分析能力
阅读顺序 先读:解决"怎么做" 后读:理解"为什么做"和"何时不做"
预告 "下一篇将分享如何建立优化思维框架,避免过度优化陷阱" 开篇回顾:"在上一篇的实战基础上,今天我们跳出代码,聊聊性能优化的底层逻辑"

第一篇预告:"术"篇

摘要:《前端性能优化实战:从5.2秒到1.9秒的代码级改造全记录》 记录了一个电商商品详情页的性能优化完整过程。针对首屏加载5.2秒、Lighthouse评分32分的现状,采用分层优化策略:资源层通过智能图片组件实现WebP格式自适应与懒加载,构建层借助Webpack/Vite的manualChunks精细化分割代码,渲染层利用Web Workers将长任务从主线程剥离,网络层实施Service Worker的Stale-While-Revalidate缓存与关键资源预加载。优化后LCP降至1.9秒,Lighthouse提升至89分,跳出率下降28%。文中提供全部可运行代码,包括OptimizedImage组件、Worker通信逻辑及Vite配置,可直接应用于生产环境。

核心内容

  • 一个电商商品详情页的完整优化实录
  • 分层优化的具体代码:资源层(图片)、构建层(Webpack/Vite)、渲染层(Worker)、网络层(预加载)
  • 可直接复制的组件:OptimizedImage、Service Worker 缓存策略、Vue 3 异步组件

你将获得:一份能直接运行的"性能优化工具箱"


第二篇预告:"道"篇

摘要:《前端性能优化的底层逻辑:从"会写代码"到"会诊断问题"的进阶之路》 跳出具体技术栈,本文构建了一套前端性能优化的通用方法论。首先阐述关键渲染路径(CRP)原理,提出"最小改动验证"的科学优化流程;继而建立"网络层-资源层-渲染层-计算层"四层分析模型,明确各层优化手段与边界;随后完善从本地Lighthouse验证、线上真实用户监控(RUM)到性能预算防控的完整闭环;最后针对preconnect滥用、Web Worker序列化开销、虚拟滚动限制等场景给出避坑指南,并提供可直接使用的面试STAR话术。本文适用于React/Vue双栈开发者及准备前端面试的工程师,帮助建立"诊断-分层-验证-监控"的系统化优化思维。

核心内容

  • 关键渲染路径(CRP)原理与最小验证法
  • 四层优化模型的抽象与应用
  • 性能监控闭环:本地验证 → 线上RUM → 性能预算
  • 避坑指南:preconnect滥用、Worker开销、过度优化陷阱
  • 面试话术:如何用STAR法则讲一个完整的性能优化故事

你将获得:一套能应对任何技术栈的"性能优化方法论"


一句话总结

"术"篇让你能把眼前项目的性能优化到1.9秒,"道"篇让你能回答"为什么是1.9秒而不是0.9秒,以及怎么保证三个月后还是1.9秒"。

两篇文章,从代码到思维,从实战到哲学,构成前端性能优化的完整修炼路径。

接下来,我们先从"术"篇开始。


总章完


欢迎交流讨论,共同提升前端工程化水平。更多文章

React Dev Inspector 架构深度解析:从浏览器到 IDE 的链路之旅

引言:点击页面元素,IDE 自动打开源码——这背后发生了什么?

想象一下:你在浏览器里看到一个 React 组件,按下 Ctrl+Shift+Command+C,鼠标悬停在元素上,点击一下——VSCode 自动打开了对应组件的源码文件,光标精准定位到组件定义处。这个看似简单的功能,背后涉及编译时代码转换运行时 Fiber 遍历跨层数据传递服务端进程调用等多个技术环节。

本文将沿着一次完整的"inspect"操作,深入剖析 react-dev-inspector 的架构设计与实现原理。


第一章:编译时准备——Babel Plugin 如何埋入源码坐标

1.1 JSX 元素的"坐标标记"

react-dev-inspector 的第一步发生在编译阶段。@react-dev-inspector/babel-plugin 会在 JSX 元素上注入 data-inspector-* 属性,记录该元素在源码中的位置信息。

// packages/babel-plugin/src/visitor.ts
const doJSXOpeningElement: NodeHandler<
  JSXOpeningElement,
  { relativePath: string }
> = (node, option) => {
  const { stop } = doJSXPathName(node.name)
  if (stop) return { stop }

  const { relativePath } = option
  const line = node.loc?.start.line
  const column = node.loc?.start.column

  const lineAttr: JSXAttribute | null = isNil(line)
    ? null
    : jsxAttribute(
      jsxIdentifier('data-inspector-line'),
      stringLiteral(line.toString()),
    )
  // ... columnAttr, relativePathAttr

  const attributes = [lineAttr, columnAttr, relativePathAttr] as JSXAttribute[]
  if (attributes.every(Boolean)) {
    node.attributes.unshift(...attributes)
  }
  return { result: node }
}

Why this design?

在编译时注入坐标信息是最可靠的方式。因为:

  1. 编译时拥有完整的 AST 和 sourcemap 信息
  2. 运行时可以通过 DOM 元素的 props 直接读取,无需额外计算
  3. 相比 @babel/plugin-transform-react-jsx-source 注入的 _debugSource,这种方式提供了相对路径,更适合 monorepo 场景

What if alternative?

如果不使用 Babel Plugin,也可以依赖 React 内置的 _debugSource(由 @babel/plugin-transform-react-jsx-source 提供),但它只包含绝对路径。在服务端需要额外的路径映射逻辑来处理不同操作系统和项目结构。

1.2 数据流:编译时 → 运行时

graph LR
    A[源码 JSX] --> B[Babel Plugin]
    B --> C{是否 Fragment}
    C -->|是| D[跳过处理]
    C -->|否| E[注入 data-inspector-*]
    E --> F[编译后代码]
    F --> G[浏览器运行]
    G --> H[DOM 元素携带坐标属性]

第二章:运行时核心——Inspector 组件的状态管理

2.1 受控与非受控的双模式设计

Inspector 组件支持两种使用模式:

// packages/inspector/src/Inspector/hooks/use-controlled-active.ts
export const useControlledActive = ({
  controlledActive,
  onActiveChange,
  onActivate,
  onDeactivate,
  disable,
}: {
  controlledActive?: boolean;
  onActiveChange?: (active: boolean) => void;
  onActivate?: () => void;
  onDeactivate?: () => void;
  disable?: boolean;
}) => {
  const [isActive, setActive] = useState<boolean>(controlledActive ?? false)
  const activeRef = useRef<boolean>(isActive)

  // sync state as controlled component
  useLayoutEffect(() => {
    if (controlledActive !== undefined) {
      activeRef.current = controlledActive
      setActive(activeRef.current)
    }
  }, [controlledActive])
  // ...
}

Why this design?

双模式设计让组件既可以直接使用(非受控,通过快捷键触发),也可以被外部状态控制(受控,适合自定义 UI 集成)。activeRef 的存在是为了在事件回调中同步读取最新状态,避免闭包陷阱。

What if alternative?

如果只支持受控模式,用户需要自行管理状态;如果只支持非受控模式,则无法与外部 UI 联动。双模式虽然增加了复杂度,但提供了最大的灵活性。

2.2 快捷键系统与事件拦截

// packages/inspector/src/Inspector/hooks/use-hotkey-toggle.ts
export const useHotkeyToggle = ({
  keys,
  disable,
  activate,
  deactivate,
  activeRef,
}: {
  keys?: string[] | null;
  disable?: boolean;
  activate: () => void;
  deactivate: () => void;
  activeRef: MutableRefObject<boolean>;
}) => {
  const hotkey: string | null = keys === null
    ? null
    : (keys ?? []).join('+')

  useEffect(() => {
    const handleHotKeys = (event?: KeyboardEvent) => {
      event?.preventDefault()
      event?.stopImmediatePropagation()
      activeRef.current ? deactivate() : activate()
    }

    const bindKey = (hotkey === null || disable)
      ? null
      : (hotkey || defaultHotkeys().join('+'))

    if (bindKey) {
      hotkeys(bindKey, { capture: true, element: window as any }, handleHotKeys)
      return () => { hotkeys.unbind(bindKey, handleHotKeys) }
    }
  }, [hotkey, disable])
}

默认快捷键在 macOS 上是 Ctrl+Shift+Command+C,其他平台是 Ctrl+Shift+Alt+C。使用 capture: true 确保事件在捕获阶段被拦截,避免被页面其他逻辑阻止。


第三章:Agent 架构——可扩展的检测代理层

3.1 InspectAgent 接口设计

react-dev-inspector v2.1.0 引入了 InspectAgent 架构,将检测逻辑从 React DOM 抽象出来,支持 React Native、React Three.js 等不同渲染器。

// packages/inspector/src/Inspector/types.ts
export interface InspectAgent<Element> {
  activate: (params: {
    onHover: (params: { element: Element; pointer: PointerEvent }) => void;
    onPointerDown: (params: { element?: Element; pointer: PointerEvent }) => void;
    onClick: (params: { element?: Element; pointer: PointerEvent }) => void;
  }) => void;

  deactivate: () => void;

  getTopElementFromPointer?: (pointer: Pointer) => MaybePromise<Element | undefined | null>;
  getTopElementsFromPointer?: (pointer: Pointer) => MaybePromise<Element[]>;

  isAgentElement: (element: unknown) => element is Element;

  getRenderChain(element: Element): InspectChainGenerator<Element>;
  getSourceChain(element: Element): InspectChainGenerator<Element>;

  getNameInfo: (element: Element) => { name: string; title: string } | undefined;
  findCodeInfo: (element: Element) => CodeInfo | undefined;
  findElementFiber?: (element: Element) => Fiber | undefined;

  indicate: (params: { element: Element; codeInfo?: CodeInfo; pointer?: PointerEvent; name?: string; title?: string }) => void;
  removeIndicate: () => void;
}

Why this design?

Agent 架构的核心思想是"分离关注点":

  • Inspector 组件负责状态管理和生命周期
  • InspectAgent 负责特定渲染器的元素检测和交互
  • 通过泛型 Element 支持不同类型的渲染目标(DOM 元素、3D 对象等)

What if alternative?

如果不使用 Agent 架构,所有检测逻辑会耦合在 Inspector 组件中,难以扩展。Agent 架构让社区可以为不同渲染器贡献检测能力,而无需修改核心代码。

3.2 DOMInspectAgent 的实现

// packages/inspector/src/Inspector/DOMInspectAgent/DOMInspectAgent.ts
export class DOMInspectAgent implements InspectAgent<DOMElement> {
  #overlay?: Overlay
  #unsubscribeListener?: () => void

  public activate = ({ onHover, onPointerDown, onClick }) => {
    this.deactivate()
    this.#overlay = new Overlay()

    this.#unsubscribeListener = setupPointerListener({
      onPointerOver: onHover,
      onPointerDown,
      onClick,
      preventEvents: this.#preventEvents,
    })
  }

  public getTopElementFromPointer = (pointer: Pointer): DOMElement | undefined | null => {
    return document.elementFromPoint(pointer.clientX, pointer.clientY) as DOMElement | undefined
  }

  public *getRenderChain(element: DOMElement): Generator<InspectChainItem<DOMElement>, unknown, void> {
    let fiber: Fiber | undefined | null

    while (element) {
      fiber = getElementFiber(element)
      if (fiber) break

      yield {
        agent: this,
        element,
        title: element.nodeName.toLowerCase(),
        tags: getDOMElementTags(element),
      }
      element = element.parentElement as DOMElement
    }

    function *fiberChain(): Generator<Fiber, void, void> {
      while (fiber) {
        yield fiber
        if (fiber.return === fiber) return
        fiber = fiber.return
      }
    }

    return yield * genInspectChainFromFibers<DOMElement>({
      agent: this,
      fibers: fiberChain(),
      isAgentElement: this.isAgentElement,
      getElementTags: getDOMElementTags,
    })
  }
}

getRenderChain 是一个生成器函数,它从目标元素向上遍历:

  1. 首先遍历 DOM 树,直到找到带有 Fiber 的节点
  2. 然后遍历 Fiber 树(通过 fiber.return
  3. 每个节点生成一个 InspectChainItem,包含显示名称、标签、源码信息等

第四章:Fiber 遍历——React 内部结构的探索

4.1 从 DOM 元素获取 Fiber

React 在 DOM 元素上存储了对应的 Fiber 引用,键名随版本变化:

// packages/inspector/src/Inspector/utils/fiber.ts
export const getElementFiber = (_element?: Element): Fiber | undefined => {
  const element = _element as ElementWithFiber
  if (!element) return undefined

  // 优先通过 React DevTools Hook 获取
  const fiberByDevtoolHook = getFiberWithDevtoolHook(element)
  if (fiberByDevtoolHook) return fiberByDevtoolHook

  // 缓存已知的 fiber key,避免重复遍历
  for (const cachedFiberKey of cachedFiberKeys) {
    if (element[cachedFiberKey]) return element[cachedFiberKey] as Fiber
  }

  // 查找 fiber key(React >= v16.14.0 使用 __reactFiber$)
  const fiberKey = Object.keys(element).find(key => (
    key.startsWith('__reactFiber$') ||
    key.startsWith('__reactInternalInstance$')
  ))

  if (fiberKey) {
    cachedFiberKeys.add(fiberKey)
    return element[fiberKey] as Fiber
  }
  return undefined
}

Why this design?

直接访问 React 内部属性看似"hacky",但这是官方 DevTools 也在使用的方式。缓存机制避免了重复遍历对象键,提升了性能。

4.2 获取 Reference Fiber(智能组件识别)

// packages/inspector/src/Inspector/utils/inspect.ts
export const getReferenceFiber = (baseFiber?: Fiber): Fiber | undefined => {
  if (!baseFiber) return undefined

  const directParent = getDirectParentFiber(baseFiber)
  if (!directParent) return undefined

  const isParentNative = isNativeTagFiber(directParent)
  const isOnlyOneChild = !directParent.child!.sibling

  let referenceFiber = (!isParentNative && isOnlyOneChild)
    ? directParent
    : baseFiber

  const originReferenceFiber = referenceFiber

  // 向上查找直到找到有源码信息的 Fiber
  while (referenceFiber) {
    if (getCodeInfoFromFiber(referenceFiber))
      return referenceFiber
    referenceFiber = referenceFiber.return!
  }

  return originReferenceFiber
}

这个函数解决了一个关键问题:用户点击的是 DOM 元素(如 <div>),但想跳转到对应的 React 组件(如 <Button>)。判断逻辑是:

  • 如果父节点是原生标签(如 div),则返回当前 Fiber
  • 如果父节点是组件且只有一个子节点,则返回父组件(因为当前元素可能是组件的"外壳")

What if alternative?

如果不做这种智能识别,用户点击 <Button> 组件渲染的 <button> 元素时,可能会跳转到 button 标签的位置,而不是 Button 组件的定义处。

4.3 Render Chain vs Source Chain

// packages/inspector/src/Inspector/DOMInspectAgent/DOMInspectAgent.ts
public *getRenderChain(element: DOMElement): Generator<InspectChainItem<DOMElement>, unknown, void> {
  // 通过 fiber.return 遍历渲染树
  function *fiberChain(): Generator<Fiber, void, void> {
    while (fiber) {
      yield fiber
      if (fiber.return === fiber) return
      fiber = fiber.return
    }
  }
  // ...
}

public *getSourceChain(element: DOMElement): Generator<InspectChainItem<DOMElement>, unknown, void> {
  function *fiberChain(): Generator<Fiber, void, void> {
    while (fiber) {
      yield fiber
      if (fiber.return === fiber || fiber._debugOwner === fiber) return
      fiber = fiber._debugOwner ?? fiber.return  // 优先使用 _debugOwner
    }
  }
  // ...
}
  • Render Chain:按照组件渲染层次遍历(父组件 → 子组件)
  • Source Chain:按照源码定义层次遍历(_debugOwner 指向 JSX 中定义该组件的父组件)

两者的区别在处理 HOC、ForwardRef、Context 等场景时尤为重要。


第五章:服务端链路——从 HTTP 请求到 IDE 进程

5.1 客户端发起请求

// packages/inspector/src/Inspector/utils/editor.ts
export const gotoServerEditor = (_codeInfo?: CodeInfoLike, options?: { editor?: TrustedEditor }) => {
  if (!_codeInfo) return
  const codeInfo = getCodeInfo(_codeInfo)

  const { lineNumber, columnNumber, relativePath, absolutePath } = codeInfo
  const isRelative = Boolean(relativePath)
  const fileName = isRelative ? relativePath : absolutePath

  const launchParams: LaunchEditorParams = {
    fileName,
    lineNumber,
    colNumber: columnNumber,
    editor: options?.editor,
  }

  const urlParams = new URLSearchParams(
    Object.entries(launchParams).filter(([, value]) => Boolean(value)) as [string, string][]
  )

  fetchToServerEditor({
    apiUrl: launchEditorEndpoint,  // '/__inspect-open-in-editor'
    urlParams,
    fallbackUrl: reactDevUtilsLaunchEditorEndpoint,  // 兼容旧版本
  })
}

5.2 服务端 Middleware 处理

// packages/middleware/src/launch-editor.ts
export const launchEditorMiddleware: NextHandleFunction = (req: IncomingRequest, res, next) => {
  if (!req.url?.startsWith(launchEditorEndpoint)) {
    return next()
  }

  const url = new URL(req.url, 'https://placeholder.domain')
  const params = Object.fromEntries(url.searchParams.entries()) as unknown as LaunchEditorParams

  if (!params.fileName) {
    res.statusCode = 400
    return res.end(`[launch-editor-middleware]: required query param "fileName" is missing.`)
  }

  const fileName = path.resolve(process.cwd(), params.fileName)

  let filePathWithLines = fileName
  if (params.lineNumber) {
    filePathWithLines += `:${params.lineNumber}`
    if (params.colNumber) {
      filePathWithLines += `:${params.colNumber}`
    }
  }

  // 编辑器优先级:请求参数 > LAUNCH_EDITOR 环境变量 > REACT_EDITOR 环境变量 > 默认 VSCode
  const editor = params.editor
    ? params.editor
    : (process.env.LAUNCH_EDITOR || process.env.REACT_EDITOR || TrustedEditor.VSCode)

  launchEditor(filePathWithLines, editor)
  res.end()
}

Why this design?

使用 HTTP 请求作为客户端与服务端的通信方式有以下优势:

  1. 简单通用,不依赖特定的构建工具
  2. 可以跨域(如果 IDE 和浏览器在不同环境)
  3. 易于调试和监控

What if alternative?

也可以使用 WebSocket 或 Electron IPC(如果是 Electron 应用),但 HTTP 是最通用、最易于集成的方式。

5.3 完整的调用链路

sequenceDiagram
    participant User as 用户
    participant Browser as 浏览器
    participant Inspector as Inspector组件
    participant Agent as DOMInspectAgent
    participant FiberUtils as Fiber工具函数
    participant Middleware as Express Middleware
    participant IDE as VSCode/IDE

    User->>Browser: 按下快捷键 Cmd+Shift+Ctrl+C
    Browser->>Inspector: 触发 activate
    Inspector->>Agent: activate({ onHover, onClick })
    Agent->>Browser: 注册 pointerover/click 事件监听

    User->>Browser: 鼠标悬停/点击元素
    Browser->>Agent: 触发 onHover/onClick
    Agent->>FiberUtils: getElementFiber(element)
    FiberUtils-->>Agent: 返回 Fiber
    Agent->>FiberUtils: getReferenceFiber(fiber)
    FiberUtils-->>Agent: 返回 referenceFiber
    Agent->>FiberUtils: getCodeInfoFromFiber(fiber)
    FiberUtils-->>Agent: 返回 CodeInfo

    Agent->>Inspector: 回调 onInspectElement
    Inspector->>Browser: fetch('/__inspect-open-in-editor?fileName=...')
    Browser->>Middleware: HTTP GET 请求
    Middleware->>Middleware: 解析 fileName, lineNumber, colNumber
    Middleware->>IDE: launchEditor(filePath, editor)
    IDE-->>User: 打开文件并定位到指定行列

第六章:Web Components——跨框架的 UI 层

6.1 Overlay 高亮组件

// packages/web-components/src/Overlay/Overlay.ts
export class Overlay {
  window: Window
  overlay: InspectorOverlayElement

  constructor() {
    customElement(InspectorOverlayTagName, InspectorOverlay)

    const currentWindow = window.__REACT_DEVTOOLS_TARGET_WINDOW__ || window
    this.window = currentWindow

    const doc = currentWindow.document
    this.overlay = document.createElement(InspectorOverlayTagName)
    doc.body.appendChild(this.overlay)
  }

  public inspect<Element = HTMLElement>({
    element,
    title,
    info,
  }: {
    element?: Element;
    title?: string;
    info?: string;
  }) {
    return this.overlay.inspect({ element, title, info })
  }
}

使用 Web Components(基于 Solid.js 的 solid-element)实现 UI 层,有以下好处:

  1. 框架无关,可以在任何前端框架中使用
  2. 样式隔离,避免与宿主应用冲突
  3. 原生 API,无需额外的运行时依赖

6.2 InspectContextPanel 右键菜单

右键点击时显示的层级面板,让用户可以选择具体的组件层级:

// packages/web-components/src/InspectContextPanel/InspectContextPanel.ts
export class InspectContextPanel<Item extends ItemInfo = ItemInfo> {
  #panel: InspectContextPanelElement<Item> | undefined
  #clickOutsideCallbacks = new Set<() => void>()

  public show(params: InspectContextPanelShowParams<Item> & { onClickOutside?: () => void }) {
    this.#panel?.show(params)
    if (!params.onClickOutside) return
    this.#clickOutsideCallbacks.add(params.onClickOutside)
    this.listenClickOutside()
  }

  private listenClickOutside = () => {
    this.#clickOutsideSubscription = fromEvent<MouseEvent>(window, 'pointerdown', { capture: true })
      .pipe(
        filter(this.checkPointerOutside),
        tap(stopAndPreventEvent),
        switchMap(() => merge(
          fromEvent(window, 'pointerup', { capture: true }),
          fromEvent(window, 'click', { capture: true }).pipe(
            tap(() => {
              this.#clickOutsideCallbacks.forEach(callback => callback())
            }),
          ),
        )),
      ).subscribe()
  }
}

第七章:设计模式总结

7.1 分层架构

┌─────────────────────────────────────────────────────────────┐
│                      Presentation Layer                      │
│  ┌─────────────┐  ┌─────────────────┐  ┌─────────────────┐  │
│  │   Overlay   │  │ InspectContext  │  │   Indicator     │  │
│  │  (Web Comp) │  │    Panel        │  │    (Web Comp)   │  │
│  └─────────────┘  └─────────────────┘  └─────────────────┘  │
├─────────────────────────────────────────────────────────────┤
│                       Agent Layer                            │
│  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────┐  │
│  │  DOMInspectAgent│  │  RNInspectAgent │  │  Custom...  │  │
│  │   (React DOM)   │  │ (React Native)  │  │             │  │
│  └─────────────────┘  └─────────────────┘  └─────────────┘  │
├─────────────────────────────────────────────────────────────┤
│                      Core Logic Layer                        │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
│  │   Fiber     │  │   Inspect   │  │   Chain Generator   │  │
│  │   Utils     │  │    Utils    │  │                     │  │
│  └─────────────┘  └─────────────┘  └─────────────────────┘  │
├─────────────────────────────────────────────────────────────┤
│                     Server Layer                             │
│  ┌─────────────────┐  ┌─────────────────────────────────┐   │
│  │   Middleware    │  │      launch-editor (npm)        │   │
│  │ (Express/Vite)  │  │                                 │   │
│  └─────────────────┘  └─────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

7.2 关键设计决策

决策点 选择 原因
坐标信息来源 Babel Plugin + _debugSource 双保险,优先使用 Plugin 的相对路径
Agent 架构 接口抽象 + 泛型 支持多渲染器,保持核心代码简洁
UI 实现 Web Components 框架无关,样式隔离
服务端通信 HTTP Middleware 通用、易集成、可调试
Fiber 获取 内部属性 + DevTools Hook 可靠且被官方认可的方式

总结:可借鉴的架构模式

  1. 编译时 + 运行时双管齐下:在编译时埋入元数据,在运行时读取并处理,是很多开发工具的核心模式

  2. Agent 架构解耦渲染器:通过接口抽象,让核心逻辑与具体渲染技术解耦

  3. 生成器函数处理层级遍历getRenderChaingetSourceChain 使用 Generator,既惰性又清晰

  4. 双模式组件设计:受控/非受控双模式让组件既易用又灵活

  5. Web Components 作为 UI 层:在 React 生态中使用 Web Components,实现真正的框架无关


参考链接

当AI Agent开始"职场内卷":你需要一个Agent Harness来当"项目经理"

从"单兵作战"到"团队混乱",再到"有序协作"的进化之路


引言:从"单兵作战"到"团队混乱"

各位程序员老铁们,你们有没有遇到过这种情况?

刚开始用AI的时候,觉得ChatGPT简直是神队友。写代码、改BUG、写文档,样样精通。你让它干啥它干啥,从不抱怨,从不请假,更不会在代码评审会上跟你argue设计模式。

但是! 当你开始玩起"多Agent协作"的时候,事情就变得微妙了。

想象一下这个场景:

Agent A(代码生成专员):"我已经写好了一个用户登录模块,采用了最新的JWT+Redis方案,代码整洁,注释完善,可以合并。"

Agent B(安全审查专员):"等等!这代码有SQL注入风险,第45行直接拼接了用户输入,必须整改!"

Agent C(性能优化专员):"而且你们注意到没有?这个查询没有加索引,用户量一上来数据库就挂了!"

Agent D(架构师):"我觉得我们应该用微服务架构,把这个模块拆分成认证服务、用户服务、会话服务..."

Agent E(产品经理):"其实用户只需要一个简单的登录框,你们能不能先做出来让我看看效果?"

此时此刻,作为人类程序员的你,看着这五个AI在终端里吵得不可开交,内心只有一个念头:

"我特么只是想加一个登录功能啊!!!"

这就是我们今天要聊的Agent Harness——一个用来管理这些"AI职场人"的"项目经理框架"。


第一章:当AI们开始"各说各话"

1.1 多Agent的美好幻想 vs 残酷现实

在理想世界里,多Agent协作是这样的:

  • 需求分析Agent先出马,把需求文档写得明明白白
  • 架构设计Agent紧随其后,画出完美的架构图
  • 代码生成Agent撸起袖子就是干,代码质量杠杠的
  • 测试Agent自动补全测试用例,覆盖率100%
  • 文档Agent同步更新文档,一个字都不用你改

听起来很美好对吧?

但实际上,现实往往是这样的:

第一幕:需求理解分歧

  • 需求分析Agent:"用户需要一个电商系统。"
  • 产品经理Agent:"不对,用户要的是社交电商,要有分享功能!"
  • UX设计Agent:"我觉得应该先做个用户调研..."

第二幕:技术选型战争

  • 后端Agent:"用Node.js,全栈JavaScript!"
  • 另一个后端Agent:"开玩笑,这种项目必须用Go,高并发!"
  • 架构师Agent:"你们都错了,云原生+Service Mesh才是未来!"

第三幕:代码冲突大爆炸

  • Agent A生成了UserController.ts,用了Class风格
  • Agent B在同一时间生成了user-controller.ts,用了函数式风格
  • Agent C:"我觉得应该用Vue 3..."
  • :"等等,这是个后端项目!"

1.2 为什么AI们会"吵架"?

其实这不怪AI,怪的是我们没有给它们一个统一的指挥系统

就像你让五个程序员各自为战,没有项目经理、没有技术负责人、没有代码规范、没有版本控制,最后不打架才怪。

每个Agent都是"专家",但:

  • ❌ 它们不知道其他Agent在干什么
  • ❌ 它们不知道自己的工作在整体流程中的位置
  • ❌ 它们没有一个"主心骨"来拍板决策
  • ❌ 它们更不知道何时该停手,何时该协作

这就需要一个Agent Harness——一个能够统筹管理所有Agent的"中枢神经系统"。


第二章:Agent Harness是什么?

2.1 接地气的定义

简单来说,Agent Harness就是AI Agent们的:

  • 👔 项目经理 - 分配任务、把控进度
  • 🚦 交通警察 - 指挥调度、维持秩序
  • 🤝 和事佬 - 解决冲突、协调关系

它的职责包括:

  1. 任务分配 - "你!去写代码!你!去审查!你!去边上歇会儿!"
  2. 流程编排 - "必须等设计完成才能写代码,懂不懂 waterfall?"
  3. 冲突解决 - "都别吵了!听我的!用React!"
  4. 状态管理 - "记录一下,这个Agent上次改代码把生产环境搞挂了,给它打个标签"
  5. 质量控制 - "这段代码审查不通过,打回去重写!"
  6. 资源调度 - "这个Agent今天已经生成了10000行代码了,让它休息一下吧..."

2.2 架构设计:从"菜市场"到"交响乐团"

没有Harness的多Agent系统 = 菜市场

  • 🗣️ 每个人都在大声吆喝
  • 📢 信息传递靠吼
  • 💸 交易(数据交换)混乱
  • 👣 经常有人被踩脚(资源冲突)

有了Harness之后 = 交响乐团

  • 🎼 指挥(Harness)拿着小棒站在中间
  • 🎻 乐手(Agents)各司其职
  • 🎵 乐谱(Workflow)规定好了每个人的节奏
  • 🎶 演奏出来的音乐(最终结果)和谐统一

2.3 Agent Harness的核心架构

1. 编排引擎(Orchestrator)

class AgentOrchestrator:
    def run_workflow(self, task):
        # 1. 分析任务,决定需要哪些Agent
        agents = self.select_agents(task)
        
        # 2. 制定执行计划
        plan = self.create_plan(agents, task)
        
        # 3. 按顺序或并行执行
        for step in plan:
            if step.type == "sequential":
                self.execute_sequential(step.agents)
            else:
                self.execute_parallel(step.agents)
        
        # 4. 整合结果
        return self.consolidate_results()

2. 上下文管理器(Context Manager)

负责维护共享状态,确保所有Agent都在"同一个频道"上:

class SharedContext:
    def __init__(self):
        self.state = {}
        self.history = []
    
    def update(self, agent_id, key, value):
        # 记录哪个Agent修改了什么
        self.state[key] = value
        self.history.append({
            "agent": agent_id,
            "action": "update",
            "key": key,
            "timestamp": now()
        })

3. 冲突解决器(Conflict Resolver)

当Agent们意见不一致时,需要一个"和事佬":

class ConflictResolver:
    def resolve(self, agent_opinions):
        # 策略1:投票制
        if self.strategy == "voting":
            return self.vote(agent_opinions)
        
        # 策略2:优先级制
        elif self.strategy == "priority":
            return self.select_by_priority(agent_opinions)
        
        # 策略3:人类介入
        elif self.strategy == "human_in_loop":
            return self.ask_human(agent_opinions)

4. 质量门禁(Quality Gates)

防止"渣代码"流入生产环境:

class QualityGate:
    def check(self, artifact):
        checks = [
            self.syntax_check(artifact),
            self.security_check(artifact),
            self.performance_check(artifact),
            self.style_check(artifact)
        ]
        return all(checks)

第三章:实战案例 - 让AI们"有序内卷"

3.1 场景:开发一个"用户评论系统"

假设我们要开发一个"用户评论系统",看看Agent Harness如何指挥。

阶段1:需求分析

workflow:
  name: "Comment Feature Development"
  steps:
    - name: "requirement_analysis"
      agent: "BA_Agent"
      task: "分析用户评论系统需求"
      output: "PRD文档"
    
    - name: "architecture_design"
      agent: "Architect_Agent"
      input: "PRD文档"
      task: "设计系统架构"
      output: "架构设计文档"
      depends_on: ["requirement_analysis"]

Harness的工作:

  1. 先唤醒BA_Agent,给它需求背景
  2. 等待BA_Agent产出PRD
  3. PRD通过质量检查(格式、完整性)
  4. 再唤醒Architect_Agent,把PRD喂给它

阶段2:并行开发

    - name: "backend_development"
      agent: "Backend_Dev_Agent"
      input: "架构设计文档"
      task: "开发后端API"
      output: "API代码"
      depends_on: ["architecture_design"]
    
    - name: "frontend_development"
      agent: "Frontend_Dev_Agent"
      input: "架构设计文档"
      task: "开发前端页面"
      output: "UI代码"
      depends_on: ["architecture_design"]
      
    - name: "database_design"
      agent: "DBA_Agent"
      input: "架构设计文档"
      task: "设计数据库表"
      output: "Schema定义"
      depends_on: ["architecture_design"]

Harness的工作:

  1. 检查依赖是否满足(架构设计已完成)
  2. 并行启动三个Agent
  3. 监控每个Agent的进度
  4. 如果某个Agent失败,决定是否重试或中断整个流程

阶段3:代码审查

    - name: "code_review"
      agents: ["Security_Agent", "Performance_Agent", "Style_Agent"]
      input: "所有代码"
      task: "代码审查"
      output: "审查报告"
      depends_on: ["backend_development", "frontend_development", "database_design"]
      merge_strategy: "consolidate"

这里有个有趣的点:三个审查Agent并行运行,各自关注不同方面。Harness需要合并它们的审查意见:

def merge_review_reports(reports):
    issues = []
    for report in reports:
        issues.extend(report.issues)
    
    # 去重和分类
    critical = [i for i in issues if i.severity == "critical"]
    warnings = [i for i in issues if i.severity == "warning"]
    
    if critical:
        return "REJECT", critical
    elif warnings:
        return "WARNING", warnings
    else:
        return "APPROVE", []

阶段4:冲突解决(重头戏)

假设冲突场景:

  • Backend_Agent:用了REST API
  • Frontend_Agent:期望的是GraphQL
  • Security_Agent:说"必须用HTTPS"
  • Performance_Agent:说"要加Redis缓存"

Harness的冲突解决逻辑:

class ConflictResolver:
    def resolve_api_style(self, backend_pref, frontend_pref):
        # 策略:前后端不一致时,优先满足前端(用户体验更重要)
        if backend_pref != frontend_pref:
            return {
                "decision": "使用REST + GraphQL Gateway",
                "reason": "Backend保持REST,Frontend通过Gateway访问",
                "implementation": "引入Apollo Federation"
            }
    
    def resolve_security_vs_performance(self, security_req, perf_req):
        # 策略:安全优先,性能其次
        if security_req.conflicts_with(perf_req):
            return {
                "decision": "先满足安全要求",
                "compromise": "通过优化实现方式减少性能影响",
                "action": "Security_Agent提出具体方案,Performance_Agent优化"
            }

3.2 完整代码示例

下面是一个简化的Agent Harness实现:

from typing import List, Dict, Any
from dataclasses import dataclass
from enum import Enum

class AgentStatus(Enum):
    IDLE = "idle"
    RUNNING = "running"
    COMPLETED = "completed"
    FAILED = "failed"

@dataclass
class Agent:
    id: str
    name: str
    role: str
    capabilities: List[str]
    status: AgentStatus = AgentStatus.IDLE
    output: Any = None

class AgentHarness:
    def __init__(self):
        self.agents: Dict[str, Agent] = {}
        self.context = SharedContext()
        self.orchestrator = WorkflowOrchestrator()
        self.resolver = ConflictResolver()
    
    def register_agent(self, agent: Agent):
        """注册Agent到Harness"""
        self.agents[agent.id] = agent
        print(f"✅ Agent '{agent.name}' ({agent.role}) 已注册")
    
    def execute_workflow(self, workflow: Dict):
        """执行工作流"""
        print(f"🚀 开始执行工作流: {workflow['name']}")
        
        for step in workflow['steps']:
            result = self.execute_step(step)
            
            if result['status'] == 'failed':
                print(f"❌ 步骤 '{step['name']}' 失败")
                if not self.handle_failure(step, result):
                    break
            
            self.context.update(step['name'], result['output'])
        
        print("✨ 工作流执行完成")
        return self.context.get_final_output()
    
    def execute_step(self, step: Dict) -> Dict:
        """执行单个步骤"""
        agent_id = step.get('agent')
        agent_ids = step.get('agents', [])
        
        if agent_id:
            # 单Agent执行
            return self.run_single_agent(agent_id, step)
        else:
            # 多Agent并行执行
            return self.run_multi_agents(agent_ids, step)
    
    def run_single_agent(self, agent_id: str, step: Dict) -> Dict:
        """运行单个Agent"""
        agent = self.agents[agent_id]
        agent.status = AgentStatus.RUNNING
        
        print(f"🤖 Agent '{agent.name}' 开始工作: {step['task']}")
        
        try:
            # 实际调用Agent执行任务
            output = self.invoke_agent(agent, step)
            agent.status = AgentStatus.COMPLETED
            agent.output = output
            
            # 质量检查
            if not self.quality_gate.check(output):
                return {'status': 'failed', 'error': 'Quality check failed'}
            
            return {'status': 'completed', 'output': output}
        
        except Exception as e:
            agent.status = AgentStatus.FAILED
            return {'status': 'failed', 'error': str(e)}
    
    def run_multi_agents(self, agent_ids: List[str], step: Dict) -> Dict:
        """并行运行多个Agent"""
        print(f"👥 并行启动 {len(agent_ids)} 个Agent")
        
        results = []
        for agent_id in agent_ids:
            result = self.run_single_agent(agent_id, step)
            results.append(result)
        
        # 合并结果
        merge_strategy = step.get('merge_strategy', 'concat')
        merged = self.merge_results(results, merge_strategy)
        
        # 检查冲突
        if self.has_conflicts(results):
            print("⚠️ 检测到Agent间冲突,启动冲突解决...")
            resolution = self.resolver.resolve(results)
            merged = resolution
        
        return {'status': 'completed', 'output': merged}
    
    def has_conflicts(self, results: List[Dict]) -> bool:
        """检查结果之间是否有冲突"""
        outputs = [r['output'] for r in results if r['status'] == 'completed']
        
        for i, out1 in enumerate(outputs):
            for out2 in outputs[i+1:]:
                if self.detect_conflict(out1, out2):
                    return True
        return False
    
    def detect_conflict(self, output1, output2) -> bool:
        """检测两个输出是否冲突"""
        tech1 = output1.get('technology', '')
        tech2 = output2.get('technology', '')
        
        if tech1 and tech2 and tech1 != tech2:
            return True
        return False

第四章:最佳实践 - 如何让AI们"和谐共处"

4.1 给Agent们"定规矩"

就像人类团队需要代码规范一样,AI团队也需要"Agent规范"。

Agent行为准则

1. 单一职责原则

  • 每个Agent只做一件事
  • 不要搞"全栈Agent",容易精神分裂

2. 显式通信

  • 所有状态变更必须通过Harness
  • 禁止Agent之间"私聊"

3. 可追溯性

  • 每个决策都要记录理由
  • 方便出了问题"甩锅"(划掉)复盘

4. 优雅降级

  • Agent崩溃时,Harness要有备用方案
  • 实在不行就"人类介入"

4.2 Harness设计的坑与避坑指南

坑1:过度设计

错误示范:

# 为了"可扩展性",搞了复杂的插件系统
class AgentHarness:
    def __init__(self):
        self.plugin_manager = PluginManager()
        self.event_bus = EventBus()
        self.message_queue = MessageQueue()
        self.distributed_lock = DistributedLock()
        # ... 100行初始化代码

正确做法:

# 先实现核心功能,简单直接
class AgentHarness:
    def __init__(self):
        self.agents = {}
        self.context = {}
    
    def run(self, task):
        # 先能跑起来再说
        pass

坑2:忽视成本控制

💰 AI Agent每调用一次都是要花钱的! 一个设计不好的Workflow可能会让你的API账单爆炸。

优化策略:

  • 设置调用次数上限
  • 缓存Agent的输出
  • 对简单任务使用"廉价"模型(GPT-3.5)
  • 只在复杂任务上用"昂贵"模型(GPT-4)

坑3:完全自动化

⚠️ 记住:永远保留"人类介入"的开关

有些决策AI做不了,比如:

  • 这个需求合不合理?
  • 这个技术债要不要还?
  • 为了赶工期能不能先hack一下?
class HumanInTheLoop:
    def review(self, agent_decision):
        if agent_decision.confidence < 0.8:
            return self.ask_human(agent_decision)
        
        if agent_decision.risk_level == "high":
            return self.ask_human(agent_decision)
        
        return agent_decision

第五章:未来展望 - 当Harness学会"自我管理"

5.1 从"项目经理"到"CTO"

现在的Agent Harness还是个"项目经理",负责协调执行。

未来的Harness可能会进化成"CTO":

  • 自己决定招什么Agent(动态扩缩容)
  • 自己优化团队结构(Agent重组)
  • 自己制定技术战略(长期规划)
  • 甚至...自己解雇表现不好的Agent?
# 未来的AgentHarness
class SelfEvolvingHarness(AgentHarness):
    def optimize_team_structure(self):
        # 分析历史数据
        performance_data = self.analyze_performance()
        
        # 决定是否需要新Agent
        if performance_data.coverage < 0.9:
            new_agent = self.design_new_agent(
                capability_gap=performance_data.gaps
            )
            self.register_agent(new_agent)
        
        # 决定是否需要"裁员"
        for agent_id, perf in performance_data.items():
            if perf.efficiency < 0.3:
                self.retire_agent(agent_id)

5.2 从"单团队"到"多团队"

当系统复杂到一定程度,一个Harness管不过来了,就需要分层管理

  • Project Harness - 管理单个项目内的Agent
  • Department Harness - 管理多个项目的Harness
  • Company Harness - 管理整个公司的AI资源

这就形成了AI的"组织架构图"...


结语:让AI"卷"得更有序

说到底,Agent Harness解决的是一个古老的问题:

如何让多个智能体协作完成复杂任务

从人类团队到AI团队,道理是相通的:

  • 都需要明确的分工
  • 都需要有效的沟通
  • 都需要统一的指挥
  • 都需要质量控制

不同的是,AI们不会:

  • 抱怨加班
  • 要求涨薪
  • 在茶水间吐槽项目经理(至少现在不会)

所以,如果你也在玩多Agent系统,别再让它们"野蛮生长"了。给你的AI们配一个Harness吧,让它们"卷"得更有序、更高效。

毕竟,没有什么问题是一个好的管理层解决不了的,如果有,就加一层管理层——这句话对AI团队同样适用 😏

油猴脚本实现生产环境加载本地qiankun子应用

大家好,我是石小石~


qiankun架构下的调试困境

如果你公司的前端架构基于 qiankun,你一定遇到过这样一个问题:由于子应用脱离主应用独立运行,在本地开发阶段,很多和主应用的操作联动、样式交互都无法直接验证,只能把子应用部署到开发或测试环境后,才能排查这类问题。

尤其是在一些不需要做 JS 沙箱隔离的业务场景里,主子应用需要通过 eventBus 这类方式实现交互,子应用不部署上线,调试起来就非常麻烦。

那有没有办法让生产环境直接加载本地子应用来实现代码调试?

方法肯定是有的,比如在主应用里写一套便于调试的逻辑。

import { registerMicroApps, start } from 'qiankun';

// ============== 核心:根据环境变量加载 本地/线上 子应用 ==============
const isDev = process.env.IS_DEV; // webpack 注入的环境变量

// 子应用配置列表
const microApps = [
  {
    name: 'subapp-vue', // 子应用唯一名称
    // 本地开发:加载 localhost 地址;生产:加载线上地址
    entry: isDev ? 'http://localhost:8080/gcshi-web-demo' : '/gcshi-web-demo',
    container: '#subapp-container', // 子应用挂载的容器 id
    activeRule: '/vue', // 路由匹配规则
  },
];

这种写法确实可以通过特定方式触发生产环境加载本地子应用,方便调试。但不可避免地需要修改主应用代码,如果没有主应用代码权限,那就很尴尬了。

其实,针对上面这个问题,用油猴脚本就能轻松解决!

油猴脚本简介

油猴(Tampermonkey)是一款浏览器插件,允许用户在网页加载时注入自定义的 JavaScript 脚本,来增强、修改或自动化网页行为

通俗地说,借助油猴,你可以将自己的 JavaScript 代码“植入”任意网页,实现自动登录、抢单、签到、数据爬取、广告屏蔽等各种“开挂级”功能,彻底掌控页面行为。

它和谷歌插件能实现的效果几乎一致,不过更加简单。如果你是前端开发,可以直接使用油猴,因为它本质就是针对网页写js。

如果你对油猴脚本感兴趣,可以看看: 《油猴脚本实战指南》

使用油猴脚本实现生产环境加载本地子应用

如图,我用 npm run dev 启动了一个本地子应用服务。

开启插件后,页面上会出现油猴脚本的调试工具。

点击【开启代理】,主应用会自动刷新,从而加载本地子应用,全程不需要做任何额外配置。

而且它完美支持热更新,这意味着你修改本地子应用代码后,生产环境页面会同步更新,调试非常方便。

核心原理

实现生产环境加载本地子应用其实很简单:

用油猴脚本在主应用加载时进行拦截,把原本要加载的线上子应用地址,替换成本地服务地址。

你可以这么理解:主应用原本要加载 http://baidu.com/gcshi-web-demo,被脚本替换成了 http://localhost:8080/gcshi-web-demo

重写fetch

qiankun 底层依赖 import-html-entry 这个库,核心流程是通过 fetch 加载子应用 HTML 模板,再解析 CSS、JS。 所以我们只需要在页面加载早期,拦截并重写 fetch 即可。

参考:juejin.cn/post/757214…

那么问题很好解决了, 我们只需要在页面加载早期,拦截并重写 fetch 即可。


const oldFetch = window.fetch;
window.fetch = (url, ...args) => {
  // 替换域名
  if (url === 'http://baidu.com/gcshi-web-demo') {
    url = 'http://localhost:8080/gcshi-web-demo';
  }
  return oldFetch(url, ...args);
};

保证脚本最早运行

重写 window.fetch 的前提,是脚本必须比页面其他逻辑更早执行,否则重写会失效。

在油猴脚本中,可以通过添加元信息实现:

// @run-at       document-start

参考:油猴脚本的运行生命周期

我在油猴脚本里的 fetch 重写逻辑如下:

import $ from "../../gmTool/index";
const { unsafeWindow } = $;

type FetchInterceptor = (url: RequestInfo | URL, options?: RequestInit) => [RequestInfo | URL, RequestInit?] | void | false;

const win = unsafeWindow;
const rawFetch = win.fetch.bind(win);

export function onFetch(interceptor: FetchInterceptor) {
  // 如果已经被代理过,先复用原来的
  if (!(win.fetch as any).rawFetch) {
    const proxyFetch: typeof fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
      let nextInput = input;
      let nextInit = init;
      // 执行 interceptor
      try {
        const result = interceptor(nextInput, nextInit);
        if (result === false) {
          console.warn("[winnex-web-proxy] 😭😭😭😭 fetch请求被用户阻止了===========================>", nextInput);
          return Promise.reject(new Error("[winnex-web-proxy] 😭😭😭😭 fetch请求被用户阻止了"));
        }
        if (result && Array.isArray(result)) {
          nextInput = result[0];
          nextInit = result[1];
        }
      } catch (err) {
        console.error("[fetch] interceptor error:", err);
      }
      // 处理 Request 对象情况
      if (nextInput instanceof Request && nextInit) {
        nextInput = new Request(nextInput, nextInit);
        nextInit = undefined;
      }
      return rawFetch(nextInput, nextInit);
    };

    (proxyFetch as any).rawFetch = rawFetch;
    win.fetch = proxyFetch;
  }

  // 返回取消方法
  return function unProxyFetch() {
    if ((win.fetch as any).rawFetch) {
      win.fetch = rawFetch;
    }
  };
}
  • 基础使用(替换接口地址)
// 注册拦截器
const unProxy = onFetch((url, options) => {
  const u = url.toString();
  // 匹配并替换地址
  if (u === 'http://baidu.com/gcshi-web-demo') {
    return ['http://localhost:8080/gcshi-web-demo', options];
  }
});
  • 阻止某个请求
onFetch((url) => {
  if (url.toString().includes('/black-api')) {
    return false; // 拦截并拒绝
  }
});

解决跨域问题

生产环境页面加载本地 localhost:8080 可能会出现跨域,导致子应用加载失败。解决方法很简单,在 vite 或 webpack 中添加响应头配置:

  devServer: {
    headers: {
      'Access-Control-Allow-Origin': '*'
    },
  },

解决热更新

默认情况下,生产环境加载子应用时,热更新会失效。原因是热更新相关的 XHR 请求前缀被替换成了主应用域名。只需要拦截 XHR 请求,修正热更新接口前缀即可。以 webpack 热更新为例,修复 sockjs-nodehot-update 两个接口就行。

使用 ajax-hook 实现 XHR 拦截,代码如下:


const appOrigin = "http://localhost:8080"
const fixHotUpdateUrl = (config: any) => {
  if (config.url.includes("sockjs-node") && appOrigin) {
    config.url = fixSockJsUrl(config.url, appOrigin);
  }
  if (config.url.includes(appName) && config.url.includes("hot-update")) {
    config.url = fixHotUpdate(config.url, appName, appOrigin);
    console.log(`[winnex-web-proxy] 热更新🚀🚀===============================> ${config.url}`);
  }
};

export const xhrProxy = (enable: boolean) => {
  if (!enable) return;
  // xhr拦截
  proxy(
    {
      //请求发起前进入
      onRequest: (config, handler) => {
        fixHotUpdateUrl(config);
        handler.next(config);
      },
      //请求发生错误时进入,比如超时;注意,不包括http状态码错误,如404仍然会认为请求成功
      onError: (err, handler) => {
        handler.next(err);
      },
      //请求成功后进入
      onResponse: (response, handler) => {
        handler.next(response);
      }
    },
    unsafeWindow
  );
};

总结

在 qiankun 微前端架构中,本地子应用想要直接在生产环境调试,不必修改主应用代码、不必申请权限,通过油猴脚本重写 fetch劫持子应用入口地址,配合跨域配置XHR 拦截修复热更新,就能实现线上环境加载本地子应用,并且支持热更新,极大提升微前端联调效率。整个方案轻量、无侵入、开箱即用,非常适合前端日常调试。

多端项目太乱?我是这样用 Monorepo 重构的

🚀 如何用 Monorepo 管理多端项目?一套可落地方案

一、从“架构设计”到“工程落地”

在上一篇中,我们解决了一个核心问题:

多端架构应该如何设计?

我们得出的结论是:

用分层架构统一逻辑(UI / modules / services)

👉 那这一篇,我们解决另一个更现实的问题:

如何把这套架构真正落地?

二、为什么多端项目一定会“失控”?

当你开始同时维护 Web、小程序、App 等多个端时,传统的 Multi-repo(多仓库) 很容易演变成开发灾难:

  • 重复劳动:相同业务逻辑在多个仓库重复实现
  • 同步地狱:接口字段变更,需要手动同步多个项目
  • 维护混乱:修一个 Bug,要改多个仓库

🔥 本质问题:

代码没有统一的“抽象与复用边界”

👉 所以你需要一种机制:

既能共享代码,又能保持边界清晰

👉 这就是:

Monorepo(单仓多包)

三、为什么多端架构必须用 Monorepo?

相比传统 Multi-repo,Monorepo 的优势非常明显:

维度 Multi-repo Monorepo
代码复用 复制 / npm 发布 本地直接引用
类型共享 手动同步 自动同步
依赖管理 各自维护 统一管理
代码变更 多仓提交 原子提交

👉 对多端项目来说,它解决了最核心的问题:

让“可复用逻辑”有了统一载体

四、项目结构设计(核心)

这是 Monorepo 成败的关键。


📦 推荐结构(与架构分层一致):

my-repo/
├── apps/                  # 应用层(各端独立)
│   ├── web/               # Web(Next.js / React)
│   ├── mini/              # 原生小程序
│   └── admin/             # 管理后台
│
├── packages/              # 复用能力层
│   ├── services/          # API 层(OpenAPI)
│   ├── modules/           # 业务逻辑(核心 ⭐)
│   ├── request/           # 请求适配层
│   └── shared/            # 工具函数
│
├── package.json
└── pnpm-workspace.yaml

🔥 核心设计原则:

apps = 面向用户(不可复用)
packages = 面向复用(核心资产)

👉 最关键的一点:

所有“可复用逻辑”,必须进入 packages,而不是 apps

五、从 0 搭建 Monorepo(实操)


1️⃣ 初始化项目

mkdir my-repo && cd my-repo
pnpm init

2️⃣ 配置 workspace

创建 pnpm-workspace.yaml

packages:
  - "apps/*"
  - "packages/*"


3️⃣ 创建目录结构

mkdir -p apps/web
mkdir -p apps/mini
mkdir -p packages/services
mkdir -p packages/modules
mkdir -p packages/request
mkdir -p packages/shared


4️⃣ 初始化子包(以 modules 为例)

cd packages/modules
pnpm init

修改 package.json

{
  "name": "@repo/modules",
  "version": "1.0.0",
  "private": true,
  "main": "./index.ts",
  "types": "./index.ts"
}


5️⃣ 在应用中引用

apps/web 中执行:

pnpm add @repo/modules --workspace

然后即可直接使用:

import { useUser } from "@repo/modules"

六、依赖管理(核心原则)


🔥 原则一:依赖就近声明

在哪使用,就在哪声明依赖

❌ 错误做法:

所有依赖都装在根目录

👉 会导致:

依赖污染 + 隐式依赖


🔥 原则二:单向依赖

apps → modules → services → request

👉 严禁:

modules → apps
services → modules


🔥 原则三:公共依赖再提升

例如:

pnpm add -wD typescript eslint

七、TypeScript 与构建优化


1️⃣ 类型闭环(强烈推荐)

services 中定义 API 类型:

后端变更 → TS 报错 → 前端即时修复

👉 好处:

把“线上错误”变成“编译错误”


2️⃣ Turborepo(进阶优化)

安装:

pnpm add turbo -wD

配置 turbo.json

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    }
  }
}

👉 带来的能力:

并行构建 + 缓存加速

八、最常见的 3 个坑


❗ 1. 依赖循环(Circular Dependency)

modules → shared
shared → modules ❌

👉 解决:

抽象更底层包,保持单向依赖


❗ 2. 编译入口问题

部分环境(如小程序)不支持直接引用 TS 源码。


👉 解决:

配置 exports / alias / 构建输出


❗ 3. 配置冗余

每个包都写 tsconfig 很麻烦。


👉 解决:

// tsconfig.base.json

子包继承:

{
  "extends": "../../tsconfig.base.json"
}

九、这一步完成后,你得到了什么?


✅ 工程能力提升:

一次修改,多端生效
逻辑复用能力大幅提升
类型安全贯穿全链路


🔥 更重要的是:

你的代码开始“结构化”

十、总结一句话

Monorepo 不是工具,而是工程组织方式

🎯 结语

很多人觉得工程复杂,是因为工具太多。

但本质是:

代码没有边界

👉 Monorepo 的意义是:

让代码有“归属”,让复杂度可控

🚀 下一篇预告

到这里,你已经完成:

架构设计(第2篇)
+ 工程落地(第3篇)

👉 下一篇,我们进入最核心的一步:

多端架构最难的 3 个问题(request / modules / design system)

🔥 这一篇,会是整个系列的“认知分水岭”。

用 3100 个数字造一台计算机

你有没有想过,一台计算机最少需要什么?

不是说你桌上那台——那个有几十亿个晶体管、跑着操作系统和浏览器的庞然大物。我说的是最本质的那个东西:能算数、能画画、能放音乐、能响应你的键盘和鼠标。

答案可能会让你意外:一个数组就够了。

Little Virtual Computer 是一台用 TypeScript 写的虚拟计算机,原作者是 jsdf。我在他的基础上做了不少重构和优化——把代码拆分成了清晰的模块结构,加了音频系统、断点调试、内存追踪、中英文切换等功能。3100 个内存槽位,23 条指令,你可以在上面写汇编程序,画像素,甚至播放一首 Chocolate Rain。打开链接就能玩,不用装任何东西。

接下来聊聊拆解和重构这台计算机的过程中,那些让我觉得"原来如此"的时刻。

"硬件"就是一行代码

这台计算机的内存,就是这行:

static ram: number[] = new Array(3100).fill(0)

3100 个数字,所有东西都住在里面——变量、程序、输入设备、屏幕、声卡:

地址 用途
0 - 999 工作内存(变量)
1000 - 1999 程序代码
2000 - 2051 键盘、鼠标、随机数、时钟
2100 - 2999 屏幕(30x30 像素)
3000 - 3008 声卡(3 个通道)

这就是"内存映射 I/O"。真实计算机里,显卡有自己的显存,声卡有自己的缓冲区,键盘通过中断传递信号。但在这里,一切都是内存地址。想在屏幕左上角画一个红色像素?往地址 2100 写个 2。想让扬声器发出正弦波?往地址 3001 写频率,地址 3000 写 3

第一次把重构后的代码跑起来,盯着屏幕上亮起的那个像素,我突然理解了一件事:CPU 不需要"知道"什么是屏幕。它只是往一个地址写了个数字,恰好有人在监听那个地址。 输入输出不需要特殊的指令,读写内存就是一切。

CPU 其实在做一件很无聊的事

读原作者的 CPU 代码时,我以为会很复杂。结果核心逻辑是这样的:

static step(trace: boolean = true) {
  if (trace) Memory.beginTrace();       // 需要调试时才追踪
  const opcode = this.advanceProgramCounter();  // 从内存读一个数
  const instructionName = this.opcodesToInstructions.get(opcode); // 数字变指令名
  const operands = instruction.operands.map(() => this.advanceProgramCounter()); // 再读几个数当参数
  instruction.execute.apply(null, operands);  // 执行
  if (trace) this.lastStepTrace = Memory.endTrace();
}

程序计数器从地址 1000 开始。读一个数,往前走一步。读到 9010?那是 add,再读三个数当参数,加一下,写回去。然后继续读下一个。没有流水线,没有分支预测,没有乱序执行。一个 while 循环,一直读数字、执行、读数字、执行。

这就是冯·诺依曼架构的全部:程序和数据住在同一片内存里,CPU 按顺序取指令执行。 你桌上那台电脑的 CPU,不管它有多少核、多少级缓存,本质上也在做同样的事——只是快了几十亿倍。

23 条指令够写一个游戏吗

一开始觉得不够。23 条指令,连函数调用都没有,能干什么?

结果发现,不只是够了,还能写出让人意外的东西。这 23 条指令分成五类:

搬运数据(5 条)—— 把值从一个地址复制到另一个,或者写入一个常量。还有两条指针操作,让你可以"地址 A 里存着地址 B,去 B 里取值"——间接寻址,这是实现数组遍历的关键。

算术(10 条)—— 加减乘除取模,每种都有两个版本:两个地址相加,或者一个地址加一个常量。add_constant counter 1 counter 就是 counter++

比较(2 条)—— 比较两个值,结果是 -1、0 或 1。没有布尔值,没有大于小于等于,就一个三态数字。刚开始觉得别扭,后来发现这样反而更灵活。

跳转(5 条)—— jump_to 无条件跳转,branch_if_equal 条件跳转。没有 for 循环?跳回去就是循环。没有 if-else?跳过去就是 else。

系统(3 条)—— data 嵌入原始数据,break 暂停调试,halt 终止。

用这些东西,能写出画板程序、弹球、乒乓球游戏,甚至音乐播放器。

从文本到数字

手动往内存里填操作码太痛苦了,所以需要一个汇编器。你写这样的文本:

define counter 0
define limit 10
copy_to_from_constant counter 0
Loop:
  add_constant counter 1 counter
  branch_if_not_equal_constant counter limit Loop
halt

汇编器把它变成内存里的一串数字:9001 0 0 9011 0 1 0 9104 0 10 1003 9999

过程本身很有启发性。define 给地址起名字,Loop: 标记跳转目标。汇编器用经典的两遍扫描:第一遍收集所有标签的地址(这样你可以先 jump_to SomeLabel,后面再定义 SomeLabel:),第二遍把指令名替换成操作码,把标签和变量名替换成数字,逐个写入程序内存。

所谓"编译",最原始的形态就是这样——把人能读的东西翻译成机器能读的数字。

900 个像素的屏幕

30x30,900 个像素。听起来少得可怜。

但当你亲手用汇编一个像素一个像素地画出一个弹跳的小球时,你会对"像素"这个词产生全新的理解。每个像素就是一个内存地址,颜色就是 0 到 15 的一个数字。像素地址 = 2100 + y * 30 + x。16 种颜色:黑、白、红、绿、蓝、黄、青、品红、银、灰、栗、橄榄、深绿、紫、蓝绿、海军蓝。

渲染做了分场景优化:慢放模式下追踪"脏像素",只更新被写过的像素,被写入的像素还会短暂闪白,让你看到程序正在画什么——慢放下看着像素一个一个亮起来,有种看延时摄影的感觉。全速模式则跳过逐像素追踪,直接全量重绘,因为每帧都有大量像素变化,追踪反而是浪费。

用内存地址弹钢琴

音频部分是我最喜欢的设计。三个独立的振荡器通道,每个通道就是三个连续的内存地址:波形、频率、音量。

地址 3000: 波形 (0=方波, 1=锯齿波, 2=三角波, 3=正弦波)
地址 3001: 频率 (值 / 1000 = Hz)
地址 3002: 音量 (0-100)

往这几个地址写数字,声音就出来了。改个数字,音调就变了。

内置的 ChocolateRain 程序用两个通道演奏了一首完整的曲子。音乐数据全部用 data 指令嵌入在程序里——本质上就是一个大数组,记录着"第几拍、哪个通道、什么频率、多大音量"。程序读取当前时间,算出现在是第几拍,然后去数组里找对应的音符,写入音频内存。

一首歌,就是一个按时间索引的数组。

调试器:这才是重点

说实话,这台虚拟计算机最有价值的部分不是 CPU,不是显示器,不是音频——是调试器。

点"单步",程序计数器往前走一步。你能看到它读了哪个地址(蓝色高亮),写了哪个地址(橙色高亮)。设个断点,程序跑到那里自动停下来。把速度拉到慢放,看着弹球程序一帧一帧地擦掉旧位置、算出新位置、画上新像素。

我见过很多人学编程时卡在"不知道程序在干什么"。代码写完,跑起来,结果不对,然后就懵了。这台计算机的调试器让一切都暴露在外面:每一步读了什么、写了什么、程序计数器在哪里。没有黑箱,没有抽象层,你看到的就是全部。

六个程序,六种"原来如此"

内置的六个示例程序,每个都在教一件事:

Add —— 4 + 4 = 8。三行代码,结果存在地址 2。这是"指令怎么工作"的最小演示。

RandomPixels —— 用一个指针从地址 2100 扫到 2999,每个位置写一个随机颜色,然后从头再来。满屏闪烁的彩色像素,其实只是一个循环在往内存里写数字。

Paint —— 屏幕顶部一行是 16 色调色板,点击选色,然后在画布上画。鼠标位置就是一个内存地址里的数字,点击就是另一个地址从 0 变成 1。

BouncingBall —— 白色小球弹来弹去。用 Date.now() 控制帧率,每 60ms 更新一次位置,碰到边界就反转方向。这是"游戏循环"的最小实现。

MiniPong —— 乒乓球。两个挡板,一个球,碰到挡板反弹,错过就重置。这是最复杂的示例,用到了几乎所有指令。读完它的代码,你会对"游戏不过是一堆条件判断"有切身体会。

ChocolateRain —— 用汇编写的音乐播放器。理解这个程序怎么工作,就理解了数据驱动编程的本质。

重构与实现细节

原作者 jsdf 的实现是一个完整的单体,功能齐全但耦合度较高。我把它拆成了独立模块——CPU、内存、显示器、音频、输入、汇编器——通过内存这个"总线"连接,加了 TypeScript 类型系统。

拆的过程本身就是一次学习。当你必须决定"这个职责属于 CPU 还是属于 Memory"的时候,你对计算机架构的理解会变得非常具体。

架构

项目分成两个独立的 bundle:

src/index.ts    → dist/computer.module.js   (核心计算机)
src/simulator.ts → dist/simulator.module.js  (模拟器 UI)

index.ts 初始化所有硬件组件,返回一个 Computer 接口对象——这是两层之间唯一的契约。模拟器只通过这个接口操作计算机,不直接碰内部类。换掉整个计算机实现,只要接口不变,模拟器照常工作。

几个有意思的实现决策

内存布局用 const enum——MemoryPosition 定义所有地址常量,编译后直接内联为数字,零运行时开销。改一个数字,整台计算机的内存布局就变了。这就是"硬件规格"。

指令是数据驱动的——每条指令是一个对象,包含名称、操作码、操作数描述和执行函数。operands 数组不只是文档——汇编器用它验证操作数数量,调试器用它显示操作数含义。一份数据,三个用途。

流程控制指令直接改程序计数器——jump_to 的 execute 就是 CPU.programCounter = labelAddress。这形成了循环依赖(CPU → instructions → CPU),更"干净"的做法是把 CPU 状态作为参数传入,但在这个规模的项目里,简单直接比架构纯洁更重要。

性能:在不同场景下做不同的事

性能优化的核心思路不是"让代码更快",而是"在不同场景下做不同的事"——和真实系统的优化思路一样。

全速模式用帧预算策略:用 performance.now() 在每帧 14ms 的预算内尽量多跑 CPU 周期(留 2ms 给浏览器渲染和 GC),用 requestAnimationFrame 和屏幕刷新率同步。同时跳过内存追踪和调试面板更新,显示器切换到全量重绘。

慢放模式每次只执行一条指令,开启内存读写追踪,更新所有调试面板,显示器用脏像素增量重绘。

音频也做了状态缓存——用 state 对象记录上一次的参数值,只在值真正变化时才调用 Web Audio API,避免每帧 9 次无意义的 API 调用。CPU 停止时只需静音所有通道然后立即返回。

其他细节:内存重置用 Array.fill(0) 替代 for 循环;endTrace() 复用同一个对象避免每周期分配新数组;显示器用预计算的 Uint8Array 颜色查找表,位移 << 2 代替乘法索引;程序内存视图用虚拟滚动,只渲染可见区域 ± 10 行。

最后

折腾这台计算机的过程中,我反复体会到一件事:我们日常使用的那些抽象——变量、循环、函数、屏幕、声音——在最底层都是同一个东西:往一个地址读一个数字,或者写一个数字。

3100 个数字,23 条规则。这就是一台计算机的全部。

不信的话,打开试试:wsafight.github.io/little-virt…

点"单步",看看你的程序在做什么。


原项目:github.com/jsdf/little…

重构版源码:github.com/wsafight/li…

Sentinel Java客户端限流原理解析|得物技术

一、从一次 HTTP 请求开始

在一个生产环境中,服务节点通常暴露了成百上千个 HTTP 接口对外提供服务。为了保证系统的稳定性,核心 HTTP 接口往往需要配置限流规则。给 HTTP 接口配置限流,可以防止突发或恶意的高并发请求耗尽服务器资源(如 CPU、内存、数据库连接等),从而避免服务崩溃或引发雪崩效应。

基础示例

假设我们有下面这样一个 HTTP 接口,需要给它配置限流规则:

@RestController
@RequiredArgsConstructor
@RequestMapping("/demo")
public class DemoController {

    @RequestMapping("/hello")
    @SentinelResource("test_sentinel")
    public String hello() {
        return "hello world";
    }
}

使用起来非常简单。首先我们可以选择给接口加上 @SentinelResource 注解(也可以不加,如果不加 Sentinel 客户端会使用请求路径作为资源名,详细原理在后面章节讲解),然后到流控控制台给该资源配置流控规则即可。

二、限流规则的加载

限流规则的生效,是从限流规则的加载开始的。聚焦到客户端的 RuleLoader 类,可以看到它支持了多种规则的加载:

  • 流控规则;
  • 集群限流规则;
  • 熔断规则;
  • ......

RuleLoader 核心逻辑

RuleLoader 类的核心作用是将这些规则加载到缓存中,方便后续使用:

public class RuleLoader {

    /**
     * 加载所有 Sentinel 规则到内存缓存
     *
     * @param sentinelRules 包含各种规则的配置对象
     */
    public static void loadRule(SentinelRules sentinelRules) {
        if (sentinelRules == null) {
            return;
        }

        // 加载流控规则
        FlowRuleManager.loadRules(sentinelRules.getFlowRules());
        // 加载集群流控规则
        RuleManager.loadClusterFlowRule(sentinelRules.getFlowRules());

        // 加载参数流控规则
        ParamFlowRuleManager.loadRules(sentinelRules.getParamFlowRules());
        // 加载参数集群流控规则
        RuleManager.loadClusterParamFlowRule(sentinelRules.getParamFlowRules());

        // 加载熔断规则
        DegradeRuleManager.loadRules(sentinelRules.getDegradeRules());

        // 加载参数熔断规则
        ParamDegradeRuleManager.loadRules(sentinelRules.getParamDegradeRules());

        // 加载系统限流规则
        SystemRuleManager.loadRules(sentinelRules.getSystemRules());
    }
}

流控规则加载详情

以流控规则的加载为例深入FlowRuleManager.loadRules 方法可以看到其完整的加载逻辑:

public static void loadRules(List<FlowRule> rules) {
    // 通过动态配置属性更新规则值
    currentProperty.updateValue(rules);
}

updateValue 方法负责通知所有监听器配置变更:

public boolean updateValue(T newValue) {
    // 如果新旧值相同,无需更新
    if (isEqual(value, newValue)) {
        return false;
    }
    RecordLog.info("[DynamicSentinelProperty] Config will be updated to: " + newValue);

    // 更新配置值
    value = newValue;
    // 通知所有监听器配置已更新
    for (PropertyListener<T> listener : listeners) {
        listener.configUpdate(newValue);
    }
    return true;
}

FlowPropertyListener 是流控规则变更的具体监听器实现:

private static final class FlowPropertyListener implements PropertyListener<List<FlowRule>> {

    @Override
    public void configUpdate(List<FlowRule> value) {
        // 构建流控规则映射表(按资源名分组)
        Map<String, List<FlowRule>> rules = FlowRuleUtil.buildFlowRuleMap(value);
        if (rules != null) {
            // 清空旧规则
            flowRules.clear();
            // 加载新规则
            flowRules.putAll(rules);
        }
        RecordLog.info("[FlowRuleManager] Flow rules received: " + flowRules);
    }
}

三、SentinelServletFilter 过滤器

在 Sentinel 中,所有的资源都对应一个资源名称和一个 Entry。Entry 可以通过对主流框架的适配自动创建,也可以通过注解的方式或调用 API 显式创建。Entry 是限流的入口类,通过 @SentinelResource 注解的限流本质上也是通过 AOP 的方式进行了对 Entry 类的调用。

Entry 的编程范式

Entry 类的标准使用方式如下:

// 资源名可使用任意有业务语义的字符串,比如方法名、接口名或其它可唯一标识的字符串
try (Entry entry = SphU.entry("resourceName")) {
    // 被保护的业务逻辑
    // do something here...
} catch (BlockException ex) {
    // 资源访问阻止,被限流或被降级
    // 在此处进行相应的处理操作
}

Servlet Filter 拦截逻辑

对于一个 HTTP 资源,在没有显式标注 @SentinelResource 注解的情况下,会有一个 Servlet Filter 类 SentinelServletFilter 统一进行拦截:

public class SentinelServletFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest sRequest = (HttpServletRequest) request;
        Entry urlEntry = null;

        try {
            // 获取并清理请求路径
            String target = FilterUtil.filterTarget(sRequest);

            // 统一 URL 清理逻辑
            // 对于 RESTful API,必须对 URL 进行清理(例如将 /foo/1 和 /foo/2 统一为 /foo/:id),
            // 否则上下文和资源的数量会超过阈值
            SentinelUrlCleaner urlCleaner = SentinelUrlCleaner.SENTINEL_URL_CLEANER;
            if (urlCleaner != null) {
                target = urlCleaner.clean(sRequest, target);
            }

            // 如果请求路径不为空且非安全扫描,则进入限流逻辑
            if (!StringUtil.isEmpty(target) && !isSecScan) {
                // 解析来源标识(用于来源限流)
                String origin = parseOrigin(sRequest);
                // 确定上下文名称
                String contextName = webContextUnify
                    ? WebServletConfig.WEB_SERVLET_CONTEXT_NAME
                    : target;

                // 使用 WEB_SERVLET_CONTEXT_NAME 作为当前 Context 的名字
                ContextUtil.enter(contextName, origin);

                // 根据配置决定是否包含 HTTP 方法
                if (httpMethodSpecify) {
                    String pathWithHttpMethod = sRequest.getMethod().toUpperCase() + COLON + target;
                    // 实际进入到限流统计判断逻辑,资源名是 "方法:路径"
                    urlEntry = SphU.entry(pathWithHttpMethod, ResourceTypeConstants.COMMON_WEB, EntryType.IN);
                } else {
                    // 实际进入到限流统计判断逻辑,资源名是请求路径
                    urlEntry = SphU.entry(target, ResourceTypeConstants.COMMON_WEB, EntryType.IN);
                }
            }

            // 继续执行后续过滤器
            chain.doFilter(request, response);

        } catch (BlockException e) {
            // 处理被限流的情况
            HttpServletResponse sResponse = (HttpServletResponse) response;
            // 返回限流页面或重定向到其他 URL
            WebCallbackManager.getUrlBlockHandler().blocked(sRequest, sResponse, e);

        } catch (IOException | ServletException | RuntimeException e2) {
            // 记录异常信息用于统计
            Tracer.traceEntry(e2, urlEntry);
            throw e2;

        } finally {
            // 释放 Entry 资源
            if (urlEntry != null) {
                urlEntry.exit();
            }
            // 退出当前上下文
            ContextUtil.exit();
        }
    }
}

四、SentinelResourceAspect 切面

如果在接口上标注了 @SentinelResource 注解,还会有另外的逻辑处理。Sentinel 定义了一个单独的 AOP 切面 SentinelResourceAspect 专门用于处理注解限流。

SentinelResource 注解定义

先来看看 @SentinelResource 注解的完整定义:

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface SentinelResource {

    /**
     * Sentinel 资源的名称(即资源标识)
     * 必填项,不能为空
     */
    String value() default "";

    /**
     * 资源的入口类型(入站 IN 或出站 OUT)
     * 默认为出站(OUT)
     */
    EntryType entryType() default EntryType.OUT;

    /**
     * 资源的分类(类型)
     * 自 1.7.0 版本起支持
     */
    int resourceType() default 0;

    /**
     * 限流或熔断时调用的 block 异常处理方法的名称
     * 默认为空(即不指定)
     */
    String blockHandler() default "";

    /**
     * blockHandler 所在的类
     * 如果与原方法不在同一个类,需要指定此参数
     */
    Class<?>[] blockHandlerClass() default {};

    /**
     * 降级(fallback)方法的名称
     * 默认为空(即不指定)
     */
    String fallback() default "";

    /**
     * 用作通用的默认降级方法
     * 该方法不能接收任何参数,且返回类型需与原方法兼容
     */
    String defaultFallback() default "";

    /**
     * fallback 所在的类
     * 如果与原方法不在同一个类,需要指定此参数
     */
    Class<?>[] fallbackClass() default {};

    /**
     * 需要被追踪并触发 fallback 的异常类型列表
     * 默认为 Throwable(即所有异常都会触发 fallback)
     */
    Class<? extends Throwable>[] exceptionsToTrace() default {Throwable.class};

    /**
     * 指定需要忽略的异常类型(即这些异常不会触发 fallback)
     * 注意:exceptionsToTrace 和 exceptionsToIgnore 不应同时使用;
     * 若同时存在,exceptionsToIgnore 优先级更高
     */
    Class<? extends Throwable>[] exceptionsToIgnore() default {};
}

实际使用示例

下面是一个完整的使用示例,展示了 @SentinelResource 注解的各种配置方式:

@RestController
public class SentinelController {

    @Autowired
    private ISentinelService service;

    @GetMapping(value = "/hello/{s}")
    public String apiHello(@PathVariable long s) {
        return service.hello(s);
    }
}

public interface ISentinelService {
    String hello(long s);
}

@Service
@Slf4j
public class SentinelServiceImpl implements ISentinelService {

    /**
     * Sentinel 提供了 @SentinelResource 注解用于定义资源
     *
     * @param s 输入参数
     * @return 返回结果
     */
    @Override
    // value:资源名称,必需项(不能为空)
    // blockHandler:对应处理 BlockException 的函数名称
    // fallback:用于在抛出异常的时候提供 fallback 处理逻辑
    @SentinelResource(value = "hello", blockHandler = "exceptionHandler", fallback = "helloFallback")
    public String hello(long s) {
        log.error("hello:{}", s);
        return String.format("Hello at %d", s);
    }

    /**
     * Fallback 函数
     * 函数签名与原函数一致,或加一个 Throwable 类型的参数
     */
    public String helloFallback(long s) {
        log.error("helloFallback:{}", s);
        return String.format("Halooooo %d", s);
    }

    /**
     * Block 异常处理函数
     * 参数最后多一个 BlockException,其余与原函数一致
     */
    public String exceptionHandler(long s, BlockException ex) {
        // Do some log here.
        log.error("exceptionHandler:{}", s);
        ex.printStackTrace();
        return "Oops, error occurred at " + s;
    }
}

SentinelResourceAspect 核心逻辑

@SentinelResource 注解由 SentinelResourceAspect 切面处理,核心逻辑如下:

@Aspect
public class SentinelResourceAspect extends AbstractSentinelAspectSupport {

    @Pointcut("@annotation(com.alibaba.csp.sentinel.annotation.SentinelResource)")
    public void sentinelResourceAnnotationPointcut() {
    }

    @Around("sentinelResourceAnnotationPointcut()")
    public Object invokeResourceWithSentinel(ProceedingJoinPoint pjp) throws Throwable {
        // 获取目标方法
        Method originMethod = resolveMethod(pjp);

        // 获取注解信息
        SentinelResource annotation = originMethod.getAnnotation(SentinelResource.class);
        if (annotation == null) {
            throw new IllegalStateException("Wrong state for SentinelResource annotation");
        }

        // 获取资源配置信息
        String resourceName = getResourceName(annotation.value(), originMethod);
        EntryType entryType = annotation.entryType();
        int resourceType = annotation.resourceType();

        Entry entry = null;
        try {
            // 创建限流入口
            entry = SphU.entry(resourceName, resourceType, entryType, pjp.getArgs());
            // 执行原方法
            Object result = pjp.proceed();
            return result;

        } catch (BlockException ex) {
            // 处理被限流异常
            return handleBlockException(pjp, annotation, ex);

        } catch (Throwable ex) {
            // 处理业务异常
            Class<? extends Throwable>[] exceptionsToIgnore = annotation.exceptionsToIgnore();
            // 优先检查忽略列表
            if (exceptionsToIgnore.length > 0 && exceptionBelongsTo(ex, exceptionsToIgnore)) {
                throw ex;
            }
            // 检查异常是否在追踪列表中
            if (exceptionBelongsTo(ex, annotation.exceptionsToTrace())) {
                traceException(ex);
                // 执行 fallback 逻辑
                return handleFallback(pjp, annotation, ex);
            }

            // 没有 fallback 函数可以处理该异常,直接抛出
            throw ex;

        } finally {
            // 释放 Entry 资源
            if (entry != null) {
                entry.exit(1, pjp.getArgs());
            }
        }
    }

    /**
     * 处理 BlockException
     *
     * blockHandler / blockHandlerClass 说明:
     * - blockHandler:对应处理 BlockException 的函数名称,可选项
     * - blockHandler 函数签名:与原方法相匹配并且最后加一个额外的参数,类型为 BlockException
     * - blockHandler 函数默认需要和原方法在同一个类中
     * - 若希望使用其他类的函数,则可以指定 blockHandlerClass 为对应的类的 Class 对象
     * - 注意:blockHandlerClass 中对应的函数必须为 static 函数,否则无法解析
     */
    protected Object handleBlockException(ProceedingJoinPoint pjp, SentinelResource annotation, BlockException ex)
            throws Throwable {

        // 执行 blockHandler 方法(如果配置了的话)
        Method blockHandlerMethod = extractBlockHandlerMethod(pjp, annotation.blockHandler(),
                annotation.blockHandlerClass());

        if (blockHandlerMethod != null) {
            Object[] originArgs = pjp.getArgs();
            // 构造参数:原方法参数 + BlockException
            Object[] args = Arrays.copyOf(originArgs, originArgs.length + 1);
            args[args.length - 1] = ex;

            try {
                // 根据 static 方法与否进行不同的调用
                if (isStatic(blockHandlerMethod)) {
                    return blockHandlerMethod.invoke(null, args);
                }
                return blockHandlerMethod.invoke(pjp.getTarget(), args);
            } catch (InvocationTargetException e) {
                // 抛出实际的异常
                throw e.getTargetException();
            }
        }

        // 如果没有 blockHandler,则尝试执行 fallback
        return handleFallback(pjp, annotation, ex);
    }

    /**
     * 处理 Fallback 逻辑
     *
     * fallback / fallbackClass 说明:
     * - fallback:fallback 函数名称,可选项,用于在抛出异常的时候提供 fallback 处理逻辑
     * - fallback 函数可以针对所有类型的异常(除了 exceptionsToIgnore 里面排除掉的异常类型)进行处理
     *
     * fallback 函数签名和位置要求:
     * - 返回值类型必须与原函数返回值类型一致
     * - 方法参数列表需要和原函数一致,或者可以额外多一个 Throwable 类型的参数用于接收对应的异常
     * - fallback 函数默认需要和原方法在同一个类中
     * - 若希望使用其他类的函数,则可以指定 fallbackClass 为对应的类的 Class 对象
     * - 注意:fallbackClass 中对应的函数必须为 static 函数,否则无法解析
     */
    protected Object handleFallback(ProceedingJoinPoint pjp, String fallback, String defaultFallback,
                                    Class<?>[] fallbackClass, Throwable ex) throws Throwable {
        Object[] originArgs = pjp.getArgs();

        // 执行 fallback 函数(如果配置了的话)
        Method fallbackMethod = extractFallbackMethod(pjp, fallback, fallbackClass);

        if (fallbackMethod != null) {
            // 构造参数:根据 fallback 方法的参数数量决定是否添加异常参数
            int paramCount = fallbackMethod.getParameterTypes().length;
            Object[] args;
            if (paramCount == originArgs.length) {
                args = originArgs;
            } else {
                args = Arrays.copyOf(originArgs, originArgs.length + 1);
                args[args.length - 1] = ex;
            }

            try {
                // 根据 static 方法与否进行不同的调用
                if (isStatic(fallbackMethod)) {
                    return fallbackMethod.invoke(null, args);
                }
                return fallbackMethod.invoke(pjp.getTarget(), args);
            } catch (InvocationTargetException e) {
                // 抛出实际的异常
                throw e.getTargetException();
            }
        }

        // 如果没有 fallback,尝试使用 defaultFallback
        return handleDefaultFallback(pjp, defaultFallback, fallbackClass, ex);
    }
}

五、流控处理核心逻辑

从入口函数开始,我们深入到流控处理的核心逻辑。

入口函数调用链

public class SphU {

    /**
     * 创建限流入口
     *
     * @param name 资源名称
     * @param resourceType 资源类型
     * @param trafficType 流量类型(IN 或 OUT)
     * @param args 参数数组
     * @return Entry 对象
     * @throws BlockException 如果被限流则抛出此异常
     */
    public static Entry entry(String name, int resourceType, EntryType trafficType, Object[] args)
            throws BlockException {
        return Env.sph.entryWithType(name, resourceType, trafficType, 1, args);
    }

    public static Entry entry(String name, EntryType trafficType, int batchCount) throws BlockException {
        return Env.sph.entry(name, trafficType, batchCount, OBJECTS0);
    }
}
public class CtSph implements Sph {

    @Override
    public Entry entry(String name, EntryType type, int count, Object... args) throws BlockException {
        StringResourceWrapper resource = new StringResourceWrapper(name, type);
        return entry(resource, count, args);
    }

    public Entry entry(ResourceWrapper resourceWrapper, int count, Object... args) throws BlockException {
        return entryWithPriority(resourceWrapper, count, false, args);
    }

    /**
     * 带优先级的入口方法,这是限流的核心逻辑
     */
    private Entry entryWithPriority(ResourceWrapper resourceWrapper, int count, boolean prioritized, Object... args)
            throws BlockException {
        Context context = ContextUtil.getContext();

        // 如果上下文数量超过阈值,则不进行规则检查
        if (context instanceof NullContext) {
            // NullContext 表示上下文数量超过了阈值,这里只初始化 Entry,不进行规则检查
            return new CtEntry(resourceWrapper, null, context);
        }

        // 如果没有上下文,使用默认上下文
        if (context == null) {
            context = InternalContextUtil.internalEnter(Constants.CONTEXT_DEFAULT_NAME);
        }

        // 如果全局开关关闭,则不进行规则检查
        if (!Constants.ON) {
            return new CtEntry(resourceWrapper, null, context);
        }

        // 获取或创建 ProcessorSlotChain(责任链)
        ProcessorSlot<Object> chain = lookProcessChain(resourceWrapper);

        /*
         * 如果资源(slot chain)数量超过 {@link Constants.MAX_SLOT_CHAIN_SIZE},
         * 则不进行规则检查
         */
        if (chain == null) {
            return new CtEntry(resourceWrapper, null, context);
        }

        // 创建 Entry 对象
        Entry e = new CtEntry(resourceWrapper, chain, context);

        try {
            // 执行责任链进行规则检查
            chain.entry(context, resourceWrapper, null, count, prioritized, args);
        } catch (BlockException e1) {
            // 如果被限流,释放 Entry 并抛出异常
            e.exit(count, args);
            throw e1;
        } catch (Throwable e1) {
            // 这不应该发生,除非 Sentinel 内部存在错误
            log.warn("Sentinel unexpected exception,{}", e1.getMessage());
        }
        return e;
    }
}

ProcessorSlotChain 功能插槽链

lookProcessChain 方法实际创建了 ProcessorSlotChain 功能插槽链。ProcessorSlotChain 采用责任链模式,将不同的功能(限流、降级、系统保护)组合在一起。

SlotChain 的获取与创建

ProcessorSlot<Object> lookProcessChain(ResourceWrapper resourceWrapper) {
    // 先从缓存中获取
    ProcessorSlotChain chain = chainMap.get(resourceWrapper);

    if (chain == null) {
        // 双重检查锁,保证线程安全
        synchronized (LOCK) {
            chain = chainMap.get(resourceWrapper);
            if (chain == null) {
                // Entry 大小限制
                if (chainMap.size() >= Constants.MAX_SLOT_CHAIN_SIZE) {
                    return null;
                }

                // 创建新的 SlotChain
                chain = SlotChainProvider.newSlotChain();

                // 使用不可变模式更新缓存
                Map<ResourceWrapper, ProcessorSlotChain> newMap =
                    new HashMap<ResourceWrapper, ProcessorSlotChain>(chainMap.size() + 1);
                newMap.putAll(chainMap);
                newMap.put(resourceWrapper, chain);
                chainMap = newMap;
            }
        }
    }
    return chain;
}

SlotChain 的构建

public class DefaultSlotChainBuilder implements SlotChainBuilder {

    @Override
    public ProcessorSlotChain build() {
        ProcessorSlotChain chain = new DefaultProcessorSlotChain();

        // 通过 SPI 加载所有 ProcessorSlot 并排序
        List<ProcessorSlot> sortedSlotList = SpiLoader.loadPrototypeInstanceListSorted(ProcessorSlot.class);

        for (ProcessorSlot slot : sortedSlotList) {
            // 只处理继承自 AbstractLinkedProcessorSlot 的 Slot
            if (!(slot instanceof AbstractLinkedProcessorSlot)) {
                RecordLog.warn("The ProcessorSlot(" + slot.getClass().getCanonicalName() +
                    ") is not an instance of AbstractLinkedProcessorSlot, can't be added into ProcessorSlotChain");
                continue;
            }

            // 将 Slot 添加到责任链尾部
            chain.addLast((AbstractLinkedProcessorSlot<?>) slot);
        }

        return chain;
    }
}

SlotChain 的功能划分

Slot Chain 可以分为两部分:

  • 统计数据构建部分(statistic):负责收集各种指标数据;
  • 判断部分(rule checking):根据规则判断是否限流。

官方架构图很好地解释了各个 Slot 的作用及其负责的部分。目前 ProcessorSlotChain 的设计是一个资源对应一个,构建好后缓存起来,方便下次直接取用。

各 Slot 的执行顺序

以下是 Sentinel 中各个 Slot 的默认执行顺序:

NodeSelectorSlot
    ↓
ClusterBuilderSlot
    ↓
StatisticSlot
    ↓
ParamFlowSlot
    ↓
SystemSlot
    ↓
AuthoritySlot
    ↓
FlowSlot
    ↓
DegradeSlot

NodeSelectorSlot - 上下文节点选择

这个功能插槽主要为资源下不同的上下文创建对应的 DefaultNode(实际用于统计指标信息)。解释一下Sentinel中的Node是什么,简单来说就是每个资源统计指标存放的容器,只不过内部由于不同的统计口径(秒级、分钟及)而分别有不同的统计窗口。Node在Sentinel不是单一的结构,而是总体上形成父子关系的树形结构。

不同的调用会有不同的 context 名称,如在当前 MVC 场景下,上下文为 sentinel_web_servlet_context。

public class NodeSelectorSlot extends AbstractLinkedProcessorSlot<Object> {

    /**
     * 同一个资源在不同上下文中的 DefaultNode 映射
     */
    private volatile Map<String, DefaultNode> map = new HashMap<String, DefaultNode>(10);

    @Override
    public void entry(Context context, ResourceWrapper resourceWrapper, Object obj, int count,
                      boolean prioritized, Object... args) throws Throwable {
        // 从映射表中获取当前上下文对应的节点
        DefaultNode node = map.get(context.getName());

        if (node == null) {
            // 双重检查锁,保证线程安全
            synchronized (this) {
                node = map.get(context.getName());
                if (node == null) {
                    // 创建新的 DefaultNode
                    node = new DefaultNode(resourceWrapper, null);

                    // 使用写时复制更新缓存
                    HashMap<String, DefaultNode> cacheMap = new HashMap<String, DefaultNode>(map.size());
                    cacheMap.putAll(map);
                    cacheMap.put(context.getName(), node);
                    map = cacheMap;

                    // 构建调用树
                    ((DefaultNode) context.getLastNode()).addChild(node);
                }
            }
        }

        // 设置当前上下文的当前节点
        context.setCurNode(node);
        // 继续执行后续 Slot
        fireEntry(context, resourceWrapper, node, count, prioritized, args);
    }

    @Override
    public void exit(Context context, ResourceWrapper resourceWrapper, int count, Object... args) {
        fireExit(context, resourceWrapper, count, args);
    }
}

ClusterBuilderSlot - 集群节点构建

这个功能槽主要用于创建 ClusterNode。ClusterNode 和 DefaultNode 的区别是:

DefaultNode 是特定于上下文的(context-specific);

ClusterNode 是不区分上下文的(context-independent),用于统计该资源在所有上下文中的整体数据。

public class ClusterBuilderSlot extends AbstractLinkedProcessorSlot<DefaultNode> {

    /**
     * 全局 ClusterNode 映射表
     */
    private static volatile Map<ResourceWrapper, ClusterNode> clusterNodeMap = new HashMap<>();

    private static final Object lock = new Object();

    private volatile ClusterNode clusterNode = null;

    @Override
    public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count,
                      boolean prioritized, Object... args) throws Throwable {
        // 创建 ClusterNode(如果不存在)
        if (clusterNode == null) {
            synchronized (lock) {
                if (clusterNode == null) {
                    // 创建集群节点
                    clusterNode = new ClusterNode(resourceWrapper.getName(), resourceWrapper.getResourceType());

                    // 更新全局映射表
                    HashMap<ResourceWrapper, ClusterNode> newMap =
                        new HashMap<>(Math.max(clusterNodeMap.size(), 16));
                    newMap.putAll(clusterNodeMap);
                    newMap.put(node.getId(), clusterNode);

                    clusterNodeMap = newMap;
                }
            }
        }

        // 将 ClusterNode 设置到 DefaultNode 中
        node.setClusterNode(clusterNode);

        // 如果有来源标识,则创建 origin node
        if (!"".equals(context.getOrigin())) {
            Node originNode = node.getClusterNode().getOrCreateOriginNode(context.getOrigin());
            context.getCurEntry().setOriginNode(originNode);
        }

        // 继续执行后续 Slot
        fireEntry(context, resourceWrapper, node, count, prioritized, args);
    }
}

StatisticSlot - 统计插槽

StatisticSlot 是 Sentinel 最重要的类之一,用于根据规则判断结果进行相应的统计操作。

统计逻辑说明

entry 的时候:

依次执行后续的判断 Slot;

每个 Slot 触发流控会抛出异常(BlockException 的子类);

若有 BlockException 抛出,则记录 block 数据;

若无异常抛出则算作可通过(pass),记录 pass 数据。

exit 的时候:

若无 error(无论是业务异常还是流控异常),记录 complete(success)以及 RT,线程数 -1。

记录数据的维度:

线程数 +1;

记录当前 DefaultNode 数据;

记录对应的 originNode 数据(若存在 origin);

累计 IN 统计数据(若流量类型为 IN)。

public class StatisticSlot extends AbstractLinkedProcessorSlot<DefaultNode> {

    @Override
    public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count,
                      boolean prioritized, Object... args) throws Throwable {
        try {
            // 此位置会调用 SlotChain 中后续的所有 Slot,完成所有规则检测
            fireEntry(context, resourceWrapper, node, count, prioritized, args);

            // 请求通过,增加线程数和通过数
            // 代码运行到这个位置,就证明之前的所有 Slot 检测都通过了
            // 此时就可以统计请求的相应数据了

            // 增加线程数(+1)
            node.increaseThreadNum();
            // 增加通过请求的数量(这里涉及到滑动窗口算法)
            node.addPassRequest(count);

            // 省略其他统计逻辑...

        } catch (PriorityWaitException ex) {
            // 如果是优先级等待异常,记录优先级等待数
            node.increaseThreadNum();
            if (context.getCurEntry().getOriginNode() != null) {
                context.getCurEntry().getOriginNode().increaseThreadNum();
            }
            if (resourceWrapper.getEntryType() == EntryType.IN) {
                // 记录入站统计数据
                Constants.ENTRY_NODE.increaseThreadNum();
            }
            throw ex;

        } catch (BlockException e) {
            // 如果被限流,记录被限流数
            // 省略 block 统计逻辑...
            throw e;

        } catch (Throwable ex) {
            // 如果发生业务异常,记录异常数
            // 省略异常统计逻辑...
            throw ex;
        }
    }

    @Override
    public void exit(Context context, ResourceWrapper resourceWrapper, int count, Object... args) {
        // 若无 error(无论是业务异常还是流控异常),记录 complete(success)以及 RT,线程数-1
        // 记录数据的维度:线程数+1、记录当前 DefaultNode 数据、记录对应的 originNode 数据(若存在 origin)
        // 、累计 IN 统计数据(若流量类型为 IN)
        // 省略 exit 统计逻辑...
    }
}

StatisticNode 数据结构

到这里,StatisticSlot 的作用已经比较清晰了。接下来我们需要分析它的统计数据结构。fireEntry 调用向下的节点和之前的方式一样,剩下的节点主要包括:

  • ParamFlowSlot;
  • SystemSlot;
  • AuthoritySlot;
  • FlowSlot;
  • DegradeSlot;

其中比较常见的是流控和熔断:FlowSlot、DegradeSlot,所以下面我们着重分析 FlowSlot。

六、FlowSlot - 流控插槽

这个 Slot 主要根据预设的资源的统计信息,按照固定的次序依次生效。如果一个资源对应两条或者多条流控规则,则会根据如下次序依次检验,直到全部通过或者有一个规则生效为止。

FlowSlot 核心逻辑

@SpiOrder(-2000)
public class FlowSlot extends AbstractLinkedProcessorSlot<DefaultNode> {

    @Override
    public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count,
                      boolean prioritized, Object... args) throws Throwable {
        // 执行流控检查
        checkFlow(resourceWrapper, context, node, count, prioritized);

        // 继续执行后续 Slot
        fireEntry(context, resourceWrapper, node, count, prioritized, args);
    }

    // 省略其他方法...
}

checkFlow 方法详解

/**
 * 执行流控检查
 *
 * @param ruleProvider 规则提供者函数
 * @param resource 资源包装器
 * @param context 上下文
 * @param node 节点
 * @param count 请求数量
 * @param prioritized 是否优先
 * @throws BlockException 如果被限流则抛出异常
 */
public void checkFlow(Function<String, Collection<FlowRule>> ruleProvider, ResourceWrapper resource,
                      Context context, DefaultNode node, int count, boolean prioritized) throws BlockException {
    // 判断规则和资源不能为空
    if (ruleProvider == null || resource == null) {
        return;
    }

    // 获取指定资源的所有流控规则
    Collection<FlowRule> rules = ruleProvider.apply(resource.getName());

    // 逐个应用流控规则。若无法通过则抛出异常,后续规则不再应用
    if (rules != null) {
        for (FlowRule rule : rules) {
            if (!canPassCheck(rule, context, node, count, prioritized)) {
                // FlowException 继承 BlockException
                throw new FlowException(rule.getLimitApp(), rule);
            }
        }
    }
}

通过这里我们就可以得知,流控规则是通过 FlowRule 来完成的,数据来源是我们使用的流控控制台,也可以通过代码进行设置。

FlowRule 流控规则

每条流控规则主要由三个要素构成:

  • grade(阈值类型):按 QPS(每秒请求数)还是线程数进行限流;
  • strategy(调用关系策略):基于调用关系的流控策略;
  • controlBehavior(流控效果):当 QPS 超过阈值时的流量整形行为。
public class FlowRule extends AbstractRule {

    public FlowRule() {
        super();
        // 来源默认 Default
        setLimitApp(RuleConstant.LIMIT_APP_DEFAULT);
    }

    public FlowRule(String resourceName) {
        super();
        // 资源名称
        setResource(resourceName);
        setLimitApp(RuleConstant.LIMIT_APP_DEFAULT);
    }

    /**
     * 流控的阈值类型
     * 0: 线程数
     * 1: QPS
     */
    private int grade = RuleConstant.FLOW_GRADE_QPS;

    /**
     * 流控阈值
     */
    private double count;

    /**
     * 基于调用链的流控策略
     * STRATEGY_DIRECT: 直接流控(按来源)
     * STRATEGY_RELATE: 关联流控(关联资源)
     * STRATEGY_CHAIN: 链路流控(按入口资源)
     */
    private int strategy = RuleConstant.STRATEGY_DIRECT;

    /**
     * 关联流控模式下的关联资源
     */
    private String refResource;

    /**
     * 流控效果(流量整形行为)
     * 0: 默认(直接拒绝)
     * 1: 预热(Warm Up)
     * 2: 排队等待(Rate Limiter)
     * 3: 预热 + 排队等待(目前控制台没有)
     */
    private int controlBehavior = RuleConstant.CONTROL_BEHAVIOR_DEFAULT;

    /**
     * 预热时长(秒)
     */
    private int warmUpPeriodSec = 10;

    /**
     * 排队等待的最大超时时间(毫秒)
     */
    private int maxQueueingTimeMs = 500;

    /**
     * 是否为集群模式
     */
    private boolean clusterMode;

    /**
     * 集群模式配置
     */
    private ClusterFlowConfig clusterConfig;

    /**
     * 流量整形控制器
     */
    private TrafficShapingController controller;

    // 省略 getter/setter 方法...
}

七、滑动窗口算法

不管流控规则采用何种流控算法,在底层都需要有支持指标统计的数据结构作为支撑。在 Sentinel 中,用于支撑基于 QPS 等限流的数据结构是 StatisticNode。

StatisticNode 数据结构

public class StatisticNode implements Node {

    /**
     * 保存最近 1 秒内的统计数据
     * 每个桶(bucket)500ms,共 2 个桶
     */
    private transient volatile Metric rollingCounterInSecond =
        new ArrayMetric(SampleCountProperty.SAMPLE_COUNT, IntervalProperty.INTERVAL);

    /**
     * 保存最近 60 秒的统计数据
     * windowLengthInMs 被特意设置为 1000 毫秒,即每个桶代表 1 秒
     * 共 60 个桶,这样可以获得每秒精确的统计信息
     */
    private transient Metric rollingCounterInMinute =
        new ArrayMetric(60, 60 * 1000, false);

    // 省略其他字段和方法...
}

ArrayMetric 核心实现

ArrayMetric 是 Sentinel 中数据采集的核心,内部使用了 BucketLeapArray,即滑动窗口的思想进行数据的采集。

public class ArrayMetric implements Metric {

    /**
     * 滑动窗口数组
     */
    private final LeapArray<MetricBucket> data;

    public ArrayMetric(int sampleCount, int intervalInMs) {
        this.data = new OccupiableBucketLeapArray(sampleCount, intervalInMs);
    }

    public ArrayMetric(int sampleCount, int intervalInMs, boolean enableOccupy) {
        if (enableOccupy) {
            // 可抢占的滑动窗口,支持借用未来窗口的配额
            this.data = new OccupiableBucketLeapArray(sampleCount, intervalInMs);
        } else {
            // 普通滑动窗口
            this.data = new BucketLeapArray(sampleCount, intervalInMs);
        }
    }
}

这里有两种实现:

  • BucketLeapArray:普通滑动窗口,每个时间桶仅记录固定时间窗口内的指标数据;
  • OccupiableBucketLeapArray:扩展实现,支持"抢占"未来时间窗口的令牌或容量,在流量突发时允许借用后续窗口的配额,实现更平滑的限流效果。

BucketLeapArray - 滑动窗口实现

LeapArray 核心属性

LeapArray 是滑动窗口的基础类,其核心属性如下:

/**
 * 窗口大小(长度),单位:毫秒
 * 例如:1000ms
 */
private int windowLengthInMs;

/**
 * 样本数(桶的数量)
 * 例如:5(表示 5 个桶,每个 1000ms,总共 5 秒)
 */
private int sampleCount;

/**
 * 采集周期(总时间窗口长度),单位:毫秒
 * 例如:5 * 1000ms(5 秒)
 */
private int intervalInMs;

/**
 * 窗口数组,array 长度就是样本数 sampleCount
 */
protected final AtomicReferenceArray<WindowWrap<T>> array;

/**
 * 更新窗口数据的锁,保证数据的正确性
 */
private final ReentrantLock updateLock;

WindowWrap 窗口包装器

每个窗口包装器包含三个属性:

 public class WindowWrap<T> {

    /**
     * 窗口大小(长度),单位:毫秒
     * 与 LeapArray 中的 windowLengthInMs 一致
     */
    private final long windowLengthInMs;

    /**
     * 窗口开始时间戳
     * 它的值是 windowLengthInMs 的整数倍
     */
    private long windowStart;

    /**
     * 窗口数据(泛型 T)
     * Sentinel 目前只有 MetricBucket 类型,存储统计数据
     */
    private T value;
}

MetricBucket 指标桶

public class MetricBucket {

    /**
     * 计数器数组
     * 长度是需要统计的事件种类数,目前是 6 个
     * LongAdder 是线程安全的计数器,性能优于 AtomicLong
     */
    private final LongAdder[] counters;
    
    // 省略其他字段和方法...
}

滑动窗口工作原理

LeapArray 统计数据的基本思路:

创建一个长度为 n 的数组,数组元素就是窗口;

每个窗口包装了 1 个指标桶,桶中存放了该窗口时间范围内对应的请求统计数据;

可以想象成一个环形数组在时间轴上向右滚动;

请求到达时,会命中数组中的一个窗口,该请求的数据就会存到命中的这个窗口包含的指标桶中;

当数组转满一圈时,会回到数组的开头;

此时下标为 0 的元素需要重复使用,它里面的窗口数据过期了,需要重置,然后再使用。

获取当前窗口

LeapArray 获取当前时间窗口的方法:

 /**
 * 获取当前时间戳对应的窗口
 *
 * @return 当前时间的窗口
 */
public WindowWrap<T> currentWindow() {
    return currentWindow(TimeUtil.currentTimeMillis());
}

/**
 * 获取指定时间戳对应的窗口(核心方法)
 *
 * @param timeMillis 时间戳(毫秒)
 * @return 对应的窗口
 */
public WindowWrap<T> currentWindow(long timeMillis) {
    if (timeMillis < 0) {
        return null;
    }

    // 计算数组下标
    int idx = calculateTimeIdx(timeMillis);

    // 计算当前请求对应的窗口开始时间
    long windowStart = calculateWindowStart(timeMillis);

    // 无限循环,确保能够获取到窗口
    while (true) {
        // 取窗口
        WindowWrap<T> old = array.get(idx);

        if (old == null) {
            // 第一次使用,创建新窗口
            WindowWrap<T> window = new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));

            // CAS 操作,确保只初始化一次
            if (array.compareAndSet(idx, null, window)) {
                // 成功更新,返回创建的窗口
                return window;
            } else {
                // CAS 失败,让出时间片,等待其他线程完成初始化
                Thread.yield();
            }

        } else if (windowStart == old.windowStart()) {
            // 命中:取出的窗口的开始时间和本次请求计算出的窗口开始时间一致
            return old;

        } else if (windowStart > old.windowStart()) {
            // 窗口过期:本次请求计算出的窗口开始时间大于取出的窗口
            // 说明取出的窗口过期了,需要重置
            if (updateLock.tryLock()) {
                try {
                    // 成功获取锁,更新窗口开始时间,计数器重置
                    return resetWindowTo(old, windowStart);
                } finally {
                    updateLock.unlock();
                }
            } else {
                // 获取锁失败,让出时间片,等待其他线程更新
                Thread.yield();
            }

        } else if (windowStart < old.windowStart()) {
            // 异常情况:机器时钟回拨等
            // 正常情况不会进入该分支
            return new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));
        }
    }
}

数据存储

在获取到窗口之后,就可以存储数据了。ArrayMetric 实现了 Metric 中存取数据的接口方法。

示例:存储 RT(响应时间)

/**
 * 添加响应时间数据
 *
 * @param rt 响应时间(毫秒)
 */
public void addRT(long rt) {
    // 获取当前时间窗口,data 为 BucketLeapArray
    WindowWrap<MetricBucket> wrap = data.currentWindow();

    // 计数
    wrap.value().addRT(rt);
}

/**
 * MetricBucket 的 addRT 方法
 *
 * @param rt 响应时间
 */
public void addRT(long rt) {
    // 记录 RT 时间对 rt 值
    add(MetricEvent.RT, rt);

    // 记录最小响应时间(非线程安全,但没关系)
    if (rt < minRt) {
        minRt = rt;
    }
}

/**
 * 通用的计数方法
 *
 * @param event 事件类型
 * @param n 增加的数量
 * @return 当前桶
 */
public MetricBucket add(MetricEvent event, long n) {
    counters[event.ordinal()].add(n);
    return this;
}

数据读取

示例:读取 RT(响应时间)

/**
 * 获取总响应时间
 *
 * @return 总响应时间
 */
public long rt() {
    // 触发当前窗口更新(处理过期窗口)
    data.currentWindow();

    long rt = 0;
    // 取出所有的 bucket
    List<MetricBucket> list = data.values();

    for (MetricBucket window : list) {
        rt += window.rt(); // 求和
    }
    return rt;
}

/**
 * 获取所有有效的窗口
 *
 * @return 有效窗口列表
 */
public List<T> values() {
    return values(TimeUtil.currentTimeMillis());
}

/**
 * 获取指定时间之前的所有有效窗口
 *
 * @param timeMillis 时间戳
 * @return 有效窗口列表
 */
public List<T> values(long timeMillis) {
    if (timeMillis < 0) {
        return new ArrayList<T>(); // 正常情况不会到这里
    }

    int size = array.length();
    List<T> result = new ArrayList<T>(size);

    for (int i = 0; i < size; i++) {
        WindowWrap<T> windowWrap = array.get(i);

        // 过滤掉没有初始化过的窗口和过期的窗口
        if (windowWrap == null || isWindowDeprecated(timeMillis, windowWrap)) {
            continue;
        }

        result.add(windowWrap.value());
    }
    return result;
}

/**
 * 判断窗口是否过期
 *
 * @param time 给定时间(通常是当前时间)
 * @param windowWrap 窗口包装器
 * @return 如果过期返回 true
 */
public boolean isWindowDeprecated(long time, WindowWrap<T> windowWrap) {
    // 给定时间与窗口开始时间超过了一个采集周期
    return time - windowWrap.windowStart() > intervalInMs;
}

OccupiableBucketLeapArray - 可抢占窗口

为什么需要 OccupiableBucketLeapArray?

假设一个资源的访问 QPS 稳定是 10,请求是均匀分布的:

在时间 0.0-1.0 秒区间中,通过了 10 个请求;

在 1.1 秒的时候,观察到的 QPS 可能只有 5,因为此时第一个时间窗口被重置了,只有第二个时间窗口有值;

当在秒级统计的情形下,用 BucketLeapArray 会有 0~50%的数据误这时就要用 OccupiableBucketLeapArray 来解决这个问题。

OccupiableBucketLeapArray 实现

从上面我们可以看到在秒级统计 rollingCounterInSecond 中,初始化实例时有两种构造参数:

public class OccupiableBucketLeapArray extends LeapArray<MetricBucket> {

    /**
     * 借用未来窗口的数组
     */
    private final FutureBucketLeapArray borrowArray;

    public OccupiableBucketLeapArray(int sampleCount, int intervalInMs) {
        super(sampleCount, intervalInMs);
        // 创建借用窗口数组
        this.borrowArray = new FutureBucketLeapArray(sampleCount, intervalInMs);
    }

    /**
     * 创建新的空桶
     * 会从 borrowArray 中借用数据
     */
    @Override
    public MetricBucket newEmptyBucket(long time) {
        MetricBucket newBucket = new MetricBucket();

        // 获取借用窗口的数据
        MetricBucket borrowBucket = borrowArray.getWindowValue(time);
        if (borrowBucket != null) {
            // 将借用数据复制到新桶中
            newBucket.reset(borrowBucket);
        }

        return newBucket;
    }

    /**
     * 重置窗口
     * 会从 borrowArray 中借用 pass 数据
     */
    @Override
    protected WindowWrap<MetricBucket> resetWindowTo(WindowWrap<MetricBucket> w, long time) {
        // 更新开始时间并重置值
        w.resetTo(time);

        MetricBucket borrowBucket = borrowArray.getWindowValue(time);
        if (borrowBucket != null) {
            // 重置桶值并添加借用的 pass 数据
            w.value().reset();
            w.value().addPass((int) borrowBucket.pass());
        } else {
            w.value().reset();
        }

        return w;
    }

    /**
     * 获取当前等待中的请求数量
     */
    @Override
    public long currentWaiting() {
        borrowArray.currentWindow();
        long currentWaiting = 0;
        List<MetricBucket> list = borrowArray.values();

        for (MetricBucket window : list) {
            currentWaiting += window.pass();
        }
        return currentWaiting;
    }

    /**
     * 添加等待中的请求数量
     *
     * @param time 时间
     * @param acquireCount 获取数量
     */
    @Override
    public void addWaiting(long time, int acquireCount) {
        WindowWrap<MetricBucket> window = borrowArray.currentWindow(time);
        window.value().add(MetricEvent.PASS, acquireCount);
    }
}

八、总结

至此,Sentinel 的基本情况都已经分析完成。以上内容主要讲解了 Sentinel 的核心处理流程,包括:

核心流程总结

  1. 规则加载:
  • 通过 RuleLoader 将各种规则(流控、熔断、系统限流等)加载到内存缓存中。
  1. 请求拦截:
  • 通过 SentinelServletFilter 过滤器拦截 HTTP 请求;
  • 通过SentinelResourceAspect切面处理 @SentinelResource 注解。
  1. 责任链处理:
  • 使用 ProcessorSlotChain 责任链模式组合多个功能插槽;
  • 每个插槽负责特定的功能(统计、流控、熔断等)。
  1. 流控判断:
  • FlowSlot 根据流控规则判断是否限流;
  • 通过滑动窗口算法统计 QPS、线程数等指标。
  1. 异常处理:
  • 被限流时抛出 BlockException;
  • 通过 blockHandler 或 fallback 处理异常。

核心技术点

  1. 责任链模式:
  • 通过 ProcessorSlotChain 将不同的限流功能组合在一起。
  1. 滑动窗口算法:
  • LeapArray 实现环形滑动窗口;
  • BucketLeapArray 普通滑动窗口;
  • OccupiableBucketLeapArray 可抢占窗口,支持借用未来配额。
  1. 数据结构:
  • DefaultNode:特定于上下文的统计节点;
  • ClusterNode:不区分上下文的集群统计节点;
  • StatisticNode:核心统计节点,包含秒级和分钟级统计。
  1. 限流算法:
  • QPS 限流:通过滑动窗口统计 QPS;
  • 线程数限流:通过原子计数器统计线程数;
  • 流控效果:快速失败、预热、排队等待等;

Sentinel 通过精心设计的架构,实现了高效、灵活、可扩展的流量控制能力,为微服务系统提供了强大的保护机制。

往期回顾

1.社区推荐重排技术:双阶段框架的实践与演进|得物技术

2.Flink ClickHouse Sink:生产级高可用写入方案|得物技术

3.服务拆分之旅:测试过程全揭秘|得物技术

4.大模型网关:大模型时代的智能交通枢纽|得物技术

5.从“人治”到“机治”:得物离线数仓发布流水线质量门禁实践

文 /万钧

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

服务拆分之旅:测试过程全揭秘|得物技术

一、引言

代码越写越多怎么办?在线等挺急的! Bidding-interface服务代码库代码量已经达到100w行!!

Bidding-interface应用是出价域核心应用之一,主要面向B端商家。跟商家后台有关的出价功能都围绕其展开。是目前出价域代码量最多的服务。

随着出价业务最近几年来的快速发展,出价服务承接的流量虽然都是围绕卖家出价,但是已远远超过卖家出价功能范围。业务的快速迭代而频繁变更给出价核心链路高可用、高性能都带来了巨大的风险。

经总结有如下几个痛点:

  • 核心出价链路未隔离:

    出价链路各子业务模块间代码有不同程度的耦合,迭代开发可扩展性差,往往会侵入到出价主流程代码的改动。每个子模块缺乏独立的封装,而且存在大量重复的代码,每次业务规则调整,需要改动多处,容易出现漏改漏测的问题。

  • 大单体&功能模块定义混乱:

    历史原因上层业务层代码缺乏抽象,代码无法实现复用,需求开发代码量大,导致需求估时偏高,经常出现20+人日的大需求,需求开发中又写出大量重复代码,导致出价服务代码库快速膨胀,应用启动耗时过长,恶性循环。

  • B/C端链路未隔离:

    B端卖家出价链路流量与C端价格业务场景链路流量没有完全隔离,由于历史原因,有些B端出价链路接口代码还存在于price应用中,偶尔B端需求开发会对C端应用做代码变更。存在一定的代码管控和应用权限管控成本。

  • 发布效率影响:

    代码量庞大,导致编译速度缓慢。代码过多,类的依赖关系更为复杂,持续迭代逐步加大编译成本,随着持续迭代,新的代码逻辑 ,引入更多jar 依赖,间接导致项目部署时长变长蓝绿发布和紧急问题处理时长显著增加;同时由于编译与部署时间长,直接影响开发人员在日常迭代中的效率(自测,debug,部署)。

  • 业务抽象&分层不合理:

    历史原因出价基础能力领域不明确,出价底层和业务层分层模糊,业务层代码和出价底层代码耦合严重,出价底层能力缺乏抽象,上层业务扩展需求频繁改动出价底层能力代码。给出价核心链路代码质量把控带来较高的成本, 每次上线变更也带来一定的风险。

以上,对于Bidding服务的拆分和治理,已经箭在弦上不得不发。否则,持续的迭代会继续恶化服务的上述问题。

经过前期慎重的筹备,设计,排期,拆分,和测试。目前Bidding应用经过四期的拆分节奏,已经马上要接近尾声了。服务被拆分成三个全新的应用,目前在小流量灰度放量中。

本次拆分涉及:1000+Dubbo接口,300+个HTTP接口,200+ MQ消息,100+个TOC任务,10+个 DJob任务。

本人是出价域测试一枚,参与了一期-四期的拆分测试工作。

项目在全组研发+测试的ALL IN投入下,已接近尾声。值此之际输出一篇文章,从测试视角复盘下,Bidding服务的拆分与治理,也全过程揭秘下出价域内的拆分测试过程。

二、服务拆分的原则

首先,在细节性介绍Bidding拆分之前。先过大概过一下服务拆分原则:

  • 单一职责原则 (SRP):  每个服务应该只负责一项特定的业务功能,避免功能混杂。

  • 高内聚、低耦合:  服务内部高度内聚,服务之间松耦合,尽量减少服务之间的依赖关系。

  • 业务能力导向:  根据业务领域和功能边界进行服务拆分,确保每个服务都代表一个完整的业务能力。

拆分原则之下,还有不同的策略可以采纳:基于业务能力拆分、基于领域驱动设计 (DDD) 拆分、基于数据拆分等等。同时,拆分时应该注意:避免过度拆分、考虑服务之间的通信成本、设计合理的 API 接口。

服务拆分是微服务架构设计的关键步骤,需要根据具体的业务场景和团队情况进行综合考虑。合理的服务拆分可以提高系统的灵活性、可扩展性和可维护性,而不合理的服务拆分则会带来一系列问题。

三、Bidding服务拆分的设计

如引言介绍过。Bidding服务被拆分出三个新的应用,同时保留bidding应用本身。目前共拆分成四个应用:Bidding-foundtion,Bidding-interface,Bidding-operation和Bidding-biz。详情如下:

  • 出价基础服务-Bidding-foundation:

出价基础服务,对出价基础能力抽象,出价领域能力封装,基础能力沉淀。

  • 出价服务-Bidding-interfaces:

商家端出价,提供出价基础能力和出价工具,提供商家在各端出价链路能力,重点保障商家出价基础功能和出价体验。

  • 出价运营服务-Bidding-operation:

出价运营,重点支撑运营对出价业务相关规则的维护以及平台其他域业务变更对出价域数据变更的业务处理:

  1. 出价管理相关配置:出价规则配置、指定卖家规则管理、出价应急隐藏/下线管理工具等;
  2. 业务大任务:包括控价生效/失效,商研鉴别能力变更,商家直发资质变更,品牌方出价资质变更等大任务执行。
  • 业务扩展服务-Bidding-biz:

更多业务场景扩展,侧重业务场景的灵活扩展,可拆出的现有业务范围:国补采购单出价,空中成单业务,活动出价,直播出价,现订现采业务,预约抢购,新品上线预出价,入仓预出价。

应用拆分前后流量分布情况:

图片

四、Bidding拆分的节奏和目标收益

服务拆分是项大工程,对目前的线上质量存在极大的挑战。合理的排期和拆分计划是重点,可预期的收益目标是灵魂。

经过前期充分调研和规划。Bidding拆分被分成了四期,每期推进一个新应用。并按如下六大步进行:

图片

Bidding拆分目标

  • 解决Bidding大单体问题: 对Bidding应用进行合理规划,完成代码和应用拆分,解决一直以来Bidding大单体提供的服务多而混乱,维护成本高,应用编译部署慢,发布效率低等等问题。
  • 核心链路隔离&提升稳定性: 明确出价基础能力,对出价基础能力下沉,出价基础能力代码拆分出独立的代码库,并且部署在独立的新应用中,实现出价核心链路隔离,提升出价核心链路稳定性。
  • 提升迭代需求开发效率: 完成业务层代码抽象,业务层做组件化配置化,实现业务层抽象复用,降低版本迭代需求开发成本。
  • 实现出价业务应用合理规划: 各服务定位、职能明确,分层抽象合理,更好服务于企/个商家、不同业务线运营等不同角色业务推进。

预期的拆分收益

  • 出价服务应用结构优化:

    完成对Bidding大单体应用合理规划拆分,向下沉淀出出价基础服务应用层,降低出价基础能力维护成功;向上抽离出业务扩展应用层,能够实现上层业务的灵活扩展;同时把面向平台运营和面向卖家出价的能力独立维护;在代码库和应用层面隔离,有效减少版本迭代业务需求开发变更对应用的影响面,降低应用和代码库的维护成本。

  • 完成业务层整体设计,业务层抽象复用,业务层做组件化配置化,提升版本迭代需求开发效率,降低版本迭代需求开发成本:

    按业务类型对业务代码进行分类,统一设计方案,提高代码复用性,支持业务场景变化时快速扩展,以引导降价为例,当有类似降价换流量/降价换销量新的降价场景需求时,可以快速上线,类似情况每个需求可以减少10-20人日开发工作量。

  • 代码质量提升 :

    通过拆分出价基础服务和对出价流程代码做重构,将出价基础底层能力代码与上层业务层代码解耦,降低代码复杂度,降低代码冲突和维护难度,从而提高整体代码质量和可维护性。

  • 开发效率提升 :

    1. 缩短应用部署时间: 治理后的出价服务将加快编译和部署速度,缩短Bidding-interfaces应用发布(编译+部署)时间 由12分钟降低到6分钟,从而显著提升开发人员的工作效率,减少自测、调试和部署所需的时间。以Bidding服务T1环境目前一个月编译部署至少1500次计算,每个月可以节约150h应用发布时间。
    2. 提升问题定位效率: 出价基础服务层与上层业务逻辑层代码库&应用分开后,排查定位开发过程中遇到的问题和线上问题时可以有效缩小代码范围,快速定位问题代码位置。

五、测试计划设计

服务拆分的前期,研发团队投入了大量的心血。现在代码终于提测了,进入我们的测试环节:

为了能收获更好的质量效果,同时也为了不同研发、测试同学的分工。我们需要细化到最细粒度,即接口维度整理出一份详细的文档。基于此文档的基础,我们确定工作量和人员排期:

如本迭代,我们投入4位研发同学,2位测试同学。完成该200个Dubbo接口和100个HTTP接口,以及20个Topic迁移。对应的提测接口,标记上负责的研发、测试、测试进度、接口详细信息等内容。

基于该文档的基础上,我们的工作清晰而明确。一个大型的服务拆分,也变成了一步一步的里程碑任务。

接下来给大家看一下,关于Bidding拆分。我们团队整体的测试计划,我们一共设计了五道流程。

  • 第一关:自测接口对比:

    每批次拆分接口提测前,研发同学必须完成接口自测。基于新旧接口返回结果对比验证。验证通过后标记在文档中,再进入测试流程。

    对于拆分项目,自测卡的相对更加严格。由于仅做接口迁移,逻辑无变更,自测也更加容易开展。由研发同学做好接口自测,可以避免提测后新接口不通的低级问题。提高项目进度。

    在这个环节中。偶尔遇见自测不充分、新接口参数传丢、新Topic未配置等问题。(三期、四期测试中,我们加强了对研发自测的要求)。

  • 第二关:测试功能回归

    这一步骤基本属于测试的人工验证,同时重点需关注写接口数据验证。

    回归时要测的细致。每个接口,测试同学进行合理评估。尽量针对接口主流程,进行细致功能回归。由于迁移的接口数量多,历史逻辑重。一方面在接口测试任务分配时,要尽量选择对该业务熟悉的同学。另一方面,承接的同学也有做好历史逻辑梳理。尽量不要产生漏测造成的问题。

    该步骤测出的问题五花八门。另外由于Bidding拆分成多个新服务。两个新服务经常彼此间调用会出现问题。比如二期Bidding-foundation迁移完成后,Bidding-operation的接口在迁移时,依赖接口需要从Bidding替换成foundation的接口。

    灰度打开情况下,调用新接口报错仍然走老逻辑。(测试时,需要关注trace中是否走了新应用)。

  • 第三关:自动化用例

    出价域内沉淀了比较完善的接口自动化用例。在人工测试时,测试同学可以借助自动化能力,完成对迁移接口的回归功能验证。

    同时在发布前天,组内会特地多跑一轮全量自动化。一次是迁移接口开关全部打开,一次是迁移接口开关全部关闭即正常的自动化回归。然后全员进行排错。

    全量的自动化用例执行,对迁移接口问题拦截,有比较好的效果。因为会有一些功能点,人工测试时关联功能未考虑到,但在接口自动化覆盖下无所遁形。

  • 第四关:流量回放

    在拆分接口开关打开的情况下,在预发环境进行流量回放。

    线上录制流量的数据往往更加复杂,经常会测出一些意料之外的问题。

    迭代过程中,我们组内仍然会在沿用两次回放。迁移接口开关打开后回放一次,开关关闭后回放一次。(跟发布配置保持一致)。

  • 第五关:灰度过程中,关闭接口开关,功能回滚

    为保证线上生产质量,在迁移接口小流量灰度过程中。我们持续监测线上问题告警群。

    以上,就是出价域测试团队,针对服务拆分的测试流程。同时遵循可回滚的发布标准,拆分接口做了非常完善的灰度功能。下一段落进行介绍。

六、各流量类型灰度切量方案

出价流程切新应用灰度控制从几个维度控制:总开关,出价类型范围,channel范围,source范围,bidSource范围,uid白名单&uid百分比(0-10000):

  • 灰度策略
  • 支持 接口维度 ,按照百分比进行灰度切流;

  • 支持一键回切;

Dubbo接口、HTTP接口、TOC任务迁移、DMQ消息迁移分别配有不同的灰度策略。

七、结语

拆分的过程中,伴随着很多迭代需求的开发。为了提高迁移效率,我们会在需求排期后,并行处理迭代功能相关的接口,把服务拆分和迭代需求一起完成掉。

目前,我们的拆分已经进入尾声。迭代发布后,整体的技术项目就结束了。灰度节奏在按预期节奏进行~

值得一提的是,目前我们的流量迁移仍处于第一阶段,即拆分应用出价域内灰度迁移,上游不感知。目前所有的流量仍然通过bidding服务接口进行转发。后续第二阶段,灰度验证完成后,需要进行上游接口替换,流量直接请求拆分后的应用。

往期回顾

1.大模型网关:大模型时代的智能交通枢纽|得物技术

2.从“人治”到“机治”:得物离线数仓发布流水线质量门禁实践

3.AI编程实践:从Claude Code实践到团队协作的优化思考|得物技术

4.入选AAAI-PerFM|得物社区推荐之基于大语言模型的新颖性推荐算法

5.Galaxy比数平台功能介绍及实现原理|得物技术

文 /寇森

关注得物技术,每周一、三更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

❌