阅读视图

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

【5】微前端知识点总结

一、什么是微前端

微前端借鉴后端微服务理念,将前端单体应用拆分为多个可独立开发、独立部署、独立运行的子应用,再通过主应用统一整合展示。

核心特征:

  • 技术栈无关,子应用可自由选择框架(React、Vue、Angular 等)
  • 独立开发,各团队在独立仓库互不干扰
  • 独立部署,每个子应用单独构建发布
  • 运行时集成,子应用动态加载而非构建时打包在一起
  • 样式与 JS 隔离,子应用间互不污染

解决的核心问题:

  • 巨石应用难以维护:代码量膨胀,构建缓慢,模块耦合严重
  • 多团队协作冲突:共同维护一个仓库,代码冲突频繁,发布相互阻塞
  • 技术栈升级困难:整个应用绑定同一技术栈,无法局部渐进升级
  • 部署耦合:任何小改动都需重新构建部署整个应用,风险高
  • 历史系统整合:新旧系统无法共存,完全重写成本极高

二、技术实现方案

iframe

最简单的隔离方案,通过 <iframe> 标签嵌入子应用。浏览器天然提供 JS 和 CSS 的完全隔离,但 UI 体验差(弹窗、滚动、路由同步问题),通信只能依赖 postMessage,且每次加载都是全新页面,性能较差。

<!-- 主应用嵌入子应用 -->
<iframe src="https://sub-app.example.com" style="width:100%;height:100%;border:none;"></iframe>

<script>
  // 主应用向子应用发送消息
  document.querySelector('iframe').contentWindow.postMessage({ type: 'TOKEN', token: 'xxx' }, '*');

  // 子应用接收主应用消息
  window.addEventListener('message', (e) => {
    if (e.data.type === 'TOKEN') console.log(e.data.token);
  });
</script>

Web Components

利用浏览器原生 Custom Elements 将子应用封装为自定义组件,Shadow DOM 提供天然样式隔离。属于浏览器标准能力,无需额外框架,但生态尚不成熟,与主流框架集成有一定成本,IE 兼容性差。

class SubApp extends HTMLElement {
  connectedCallback() {
    // Shadow DOM 内部样式与外部完全隔离
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>.title { color: red; }</style>
      <div class="title">子应用内容</div>
    `;
  }
  disconnectedCallback() {
    // 组件卸载时清理资源
  }
}

customElements.define('sub-app', SubApp);
<!-- 主应用中像使用普通标签一样嵌入 -->
<sub-app></sub-app>

NPM 包

将子应用打包为 NPM 包由主应用引入,属于构建时集成。优点是简单直接、TypeScript 支持好,缺点是无法独立部署,版本升级需要主应用重新发布,不能做到真正的技术栈无关。

# 子应用发布为 npm 包
npm publish @company/sub-app

# 主应用安装依赖
npm install @company/sub-app
// 主应用直接 import,构建时打包在一起
import SubAppPage from '@company/sub-app';

// 子应用升级后,主应用必须更新依赖版本并重新构建部署

模块联邦(Module Federation)

Webpack 5 内置特性,支持在运行时跨应用共享模块。可以按模块粒度共享,避免重复加载公共依赖。强依赖 Webpack 5,沙箱隔离能力较弱,应用级别的路由管理需要自行实现。

// 子应用 webpack.config.js —— 暴露模块
new ModuleFederationPlugin({
  name: 'subApp',
  filename: 'remoteEntry.js',
  exposes: {
    './Button': './src/components/Button',
    './utils': './src/utils',
  },
  shared: ['vue'], // 声明共享依赖,避免重复加载
});

// 主应用 webpack.config.js —— 消费模块
new ModuleFederationPlugin({
  name: 'host',
  remotes: {
    subApp: 'subApp@http://localhost:3001/remoteEntry.js',
  },
});
// 主应用中运行时动态加载子应用暴露的模块
const Button = React.lazy(() => import('subApp/Button'));

JS 沙箱

通过代理或快照机制隔离子应用对全局 window 的读写,防止全局变量污染。是当前微前端框架实现 JS 隔离的核心技术手段,具体分为:

  • 快照沙箱:激活时对 window 拍快照,卸载时 diff 还原。简单但性能差,不支持多实例并行。
  • 代理沙箱(Proxy):基于 ES6 Proxy 拦截 window 操作,每个子应用维护独立的虚拟 window,读写互不干扰,支持多实例并行,是现代浏览器下的推荐方式。
// 快照沙箱:激活时保存快照,卸载时还原
class SnapshotSandbox {
  activate() {
    this.snapshot = Object.assign({}, window); // 保存当前 window 快照
  }
  deactivate() {
    for (const key in window) {
      if (window[key] !== this.snapshot[key]) {
        window[key] = this.snapshot[key]; // 还原所有变更
      }
    }
  }
}

// 代理沙箱:每个子应用独立的虚拟 window,互不污染
class ProxySandbox {
  constructor() {
    const fakeWindow = Object.create(null);
    this.proxy = new Proxy(fakeWindow, {
      set(target, prop, value) {
        target[prop] = value;          // 只写虚拟 window,真实 window 不变
        return true;
      },
      get(target, prop) {
        return prop in target ? target[prop] : window[prop]; // 取不到再读真实 window
      },
    });
  }
}

// 多子应用并行效果
// 子应用A: proxy.foo = 'A'  →  fakeWindowA.foo = 'A'
// 子应用B: proxy.foo = 'B'  →  fakeWindowB.foo = 'B'
// 真实 window.foo           →  undefined(始终干净)

2.6 CSS 隔离

防止子应用样式污染主应用或其他子应用,主要有三种方案:

  • 动态样式表:子应用激活时注入样式,卸载时移除。实现简单,但多应用并行展示时会冲突。
  • Shadow DOM:将子应用挂载在 Shadow DOM 内,浏览器原生保证内外样式完全隔离。隔离彻底,但挂载到 document.body 的弹窗、Tooltip 等组件样式会丢失,与主流 UI 库兼容性差。
  • Scoped CSS:运行时为子应用每条 CSS 规则动态添加属性选择器前缀(如 div[data-qiankun="app-name"]),实现作用域限定。兼容性好,但动态计算有一定性能开销,无法阻止主应用样式影响子应用。
// 动态样式表:挂载/卸载时插入和移除 <style> 标签
function mountStyles(cssTexts) {
  return cssTexts.map(css => {
    const el = document.createElement('style');
    el.textContent = css;
    document.head.appendChild(el);
    return el;
  });
}
function unmountStyles(styleEls) {
  styleEls.forEach(el => el.remove());
}

// Shadow DOM:子应用 DOM 和样式在 shadow-root 内完全隔离
const shadow = container.attachShadow({ mode: 'open' });
shadow.innerHTML = `<style>${subAppStyles}</style>${subAppHTML}`;

// Scoped CSS:运行时动态为每条规则加属性前缀
function scopeCSS(cssText, appName) {
  // .btn { color: red }  →  div[data-qiankun="app"] .btn { color: red }
  return cssText.replace(/(^|\})\s*([^{]+)\{/g, (_, prefix, selector) => {
    const scoped = selector.split(',')
      .map(s => `div[data-qiankun="${appName}"] ${s.trim()}`)
      .join(', ');
    return `${prefix} ${scoped} {`;
  });
}

三、微前端产品方案

qiankun

蚂蚁金服出品,基于 single-spa 封装,是目前社区最成熟的微前端框架。

核心技术:

  • HTML Entry:以子应用 index.html 为入口,由 import-html-entry 解析并加载资源,子应用无需改造构建配置
  • JS 隔离:现代浏览器默认使用 ProxySandbox(多实例代理沙箱),每个子应用拥有独立虚拟 window;不支持 Proxy 时降级为 SnapshotSandbox
  • CSS 隔离:支持 Shadow DOM(strictStyleIsolation)和 Scoped CSS(experimentalStyleIsolation)两种模式,默认使用动态样式表
  • 应用通信:props 单向传递 + initGlobalState 全局状态管理
  • 预加载:支持在空闲时预加载子应用资源,加快激活速度

适合场景: 追求稳定大生态、团队规模较大的企业级中后台系统。


无界(Wujie)

腾讯出品,将 iframe 和 Web Components 结合的创新方案。

核心技术:

  • JS 隔离:复用 iframe 的 JS 运行环境作为天然沙箱,彻底隔离,无需手动实现代理沙箱
  • CSS 隔离:子应用 DOM 渲染在 Web Components 的 Shadow DOM 内,样式天然隔离
  • 应用保活:子应用切换时不销毁实例,保留 DOM 和状态,再次进入时秒开
  • 预加载:支持子应用预加载和后台静默运行

适合场景: 对隔离性要求极高、需要应用保活(切换不重载)的场景。


MicroApp(京东)

京东出品,基于 Web Components 封装,接入方式最简单。

核心技术:

  • JS 隔离:自研 Proxy 沙箱,与 qiankun 类似但实现更轻量
  • CSS 隔离:自动为子应用样式添加 CSS 作用域前缀
  • 接入方式:以自定义 HTML 标签 <micro-app> 的形式嵌入,对主应用几乎零侵入
  • Vite 支持:原生支持 Vite 构建的子应用,qiankun 对 Vite 支持较弱

适合场景: 希望以最低成本快速接入微前端、或子应用使用 Vite 构建的项目。


Module Federation(Webpack 5)

Webpack 内置能力,严格来说是模块共享方案而非完整微前端框架。

核心技术:

  • 运行时模块共享:应用间可以在运行时互相暴露和消费模块,避免公共依赖重复打包
  • 去中心化:每个应用既可以是 Host(消费方)也可以是 Remote(提供方),无需统一主应用
  • 无沙箱:没有内置 JS 和 CSS 隔离机制,需要开发者自行约束

适合场景: 深度绑定 Webpack 5、更关注模块级别共享而非应用级别隔离的场景。


产品方案对比

方案 JS 隔离 CSS 隔离 接入成本 Vite 支持 社区生态
qiankun Proxy 沙箱 Shadow DOM / Scoped 较差 ⭐⭐⭐⭐⭐
无界 iframe 天然隔离 Shadow DOM ⭐⭐⭐
MicroApp Proxy 沙箱 Scoped CSS 极低 ⭐⭐⭐
Module Federation ❌ 无 ❌ 无 ⭐⭐⭐

四、qiankun 核心技术详解

JS 沙箱

SnapshotSandbox(快照沙箱)

激活时遍历 window 保存快照,卸载时对比快照还原所有变更。实现简单,但每次激活/卸载都需遍历整个 window,性能较差,且同一时刻只能运行一个子应用。

class SnapshotSandbox {
  activate() {
    this.windowSnapshot = {};
    for (const key in window) {
      this.windowSnapshot[key] = window[key];
    }
    // 恢复上次该沙箱运行时的修改
    Object.keys(this.modifyPropsMap).forEach(key => {
      window[key] = this.modifyPropsMap[key];
    });
  }

  deactivate() {
    this.modifyPropsMap = {};
    for (const key in window) {
      if (window[key] !== this.windowSnapshot[key]) {
        this.modifyPropsMap[key] = window[key]; // 记录变更
        window[key] = this.windowSnapshot[key]; // 还原
      }
    }
  }
}

ProxySandbox(多实例代理沙箱)

每个子应用拥有独立的 fakeWindow,所有对 window 的读写都发生在各自的 fakeWindow 上,真实 window 始终保持干净,天然支持多实例并行。

class ProxySandbox {
  constructor() {
    const fakeWindow = Object.create(null);

    this.proxy = new Proxy(fakeWindow, {
      set(target, prop, value) {
        target[prop] = value; // 只写入虚拟 window
        return true;
      },
      get(target, prop) {
        // 优先读虚拟 window,取不到再读真实 window
        return prop in target ? target[prop] : window[prop];
      },
      has(target, prop) {
        return prop in target || prop in window;
      }
    });
  }
}

多实例并行效果:

子应用A: window.foo = 'A'  →  fakeWindowA.foo = 'A'
子应用B: window.foo = 'B'  →  fakeWindowB.foo = 'B'
真实 window.foo            →  undefined(完全干净)

沙箱盲区: JS 沙箱只拦截 window 属性读写,对 document.body.appendChild、setTimeout、addEventListener 等操作无能为力,子应用卸载时需在 unmount 钩子中手动清理,否则会内存泄漏。


CSS 隔离

Shadow DOM(strictStyleIsolation)

start({ sandbox: { strictStyleIsolation: true } });

子应用的 DOM 被挂载在 Shadow Root 内,浏览器原生保证边界内外样式互不穿透。隔离最彻底,但挂载到 document.body 的弹窗、下拉菜单等组件会逃出 Shadow DOM 边界导致样式丢失,需要手动将这类组件的挂载节点指定到子应用容器内。

主应用 DOM
└── #micro-container
    └── shadow-root        ← 样式边界
        ├── <style>子应用样式</style>
        └── <div id="app">子应用内容</div>

Scoped CSS(experimentalStyleIsolation)

start({ sandbox: { experimentalStyleIsolation: true } });

qiankun 拦截子应用的样式注入,在运行时为每条 CSS 规则动态添加属性选择器前缀,将样式的作用域限定在子应用容器内:

/* 原始 */
.btn { color: red; }

/* 处理后 */
div[data-qiankun="vue-app"] .btn { color: red; }

兼容性好,弹窗问题少,是日常更推荐的方案。但动态计算有性能开销,且无法阻止主应用样式向下影响子应用。


CSS 隔离方案对比:

方案 隔离方向 弹窗兼容 推荐场景
动态样式表(默认) 子应用间不同时存在 基础场景
Shadow DOM 双向完全隔离 ❌ 需额外处理 隔离要求极高
Scoped CSS 子应用不影响外部 日常推荐

4.3 应用间通信

子应用间的通信分为三种场景:主应用向子应用传递数据、子应用向主应用反馈、子应用之间互相通信。

props 传递

最简单直接的方式,主应用在注册子应用时通过 props 字段传入数据或回调函数。子应用在 mount 钩子中接收。这种方式是单向的,适合传递初始配置、用户信息、或让子应用调用主应用提供的方法(如全局登出)。

// 主应用
registerMicroApps([{
  name: 'sub-app',
  props: {
    token: 'xxx',
    userInfo: { name: 'John' },
    onLogout: () => { /* 主应用处理登出逻辑 */ }
  }
}]);

// 子应用 mount 钩子中接收
export async function mount(props) {
  const { token, userInfo, onLogout } = props;
}

initGlobalState(全局状态)

qiankun 内置的发布订阅机制,主应用初始化一个全局状态对象,主应用和所有子应用都可以监听状态变化、也可以更新状态。适合需要跨应用共享且频繁变化的数据,如当前用户信息、主题、语言等。

需要注意的是,子应用只能调用 setGlobalState 修改已存在的一级属性,不能新增顶层字段,状态的结构由主应用初始化时决定。

// 主应用初始化
import { initGlobalState } from 'qiankun';
const actions = initGlobalState({ user: null, theme: 'light' });

actions.onGlobalStateChange((state, prev) => {
  console.log('状态变更:', prev, '→', state);
});

// 子应用中(通过 mount props 获取 actions)
export async function mount(props) {
  props.onGlobalStateChange((state) => {
    console.log('子应用收到状态:', state);
  });
  props.setGlobalState({ theme: 'dark' }); // 触发所有监听者
}

自定义事件总线

当需要子应用之间直接通信,而不必经过主应用中转时,可以在主应用初始化时挂载一个全局事件总线,所有子应用共享使用。这种方式灵活性最高,但需要注意子应用卸载时要及时 off 事件,避免监听器堆积。

// 主应用初始化,挂载到全局
class EventBus {
  constructor() { this.events = {}; }
  on(event, fn) { (this.events[event] ??= []).push(fn); }
  emit(event, data) { (this.events[event] ?? []).forEach(fn => fn(data)); }
  off(event, fn) { this.events[event] = (this.events[event] ?? []).filter(f => f !== fn); }
}
window.__BUS__ = new EventBus();

// 子应用A 发送
window.__BUS__.emit('order:created', { id: 123 });

// 子应用B 接收(unmount 时记得 off)
window.__BUS__.on('order:created', handler);

4.4 生命周期

子应用需要导出三个生命周期钩子供 qiankun 调用:

钩子 触发时机 调用次数 常见用途
bootstrap 资源加载完成后,首次激活前 仅一次 初始化全局配置
mount 路由匹配,子应用激活 多次 渲染应用、绑定事件
unmount 路由离开,子应用卸载 多次 销毁实例、清理定时器和事件监听
首次进入:bootstrap → mount
路由切换:unmount → mount(重复)

五、微前端优势

  • 技术栈自由:各子应用独立选型,新技术可在单个子应用中试用,不影响整体
  • 独立部署:子应用单独构建发布,发布频率和节奏互不干扰
  • 团队自治:团队边界与应用边界对齐,减少跨团队协作摩擦
  • 渐进式迁移:可将旧系统逐模块替换为新技术栈,无需一次性重写
  • 故障隔离:单个子应用崩溃不影响主应用和其他子应用
  • 按需加载:用户只加载当前访问模块,首屏资源体积更小

六、参考资料

面包屑自动推导的算法设计:从“最短路径匹配”到工程可落地

面包屑自动推导的算法设计:从“最短路径匹配”到工程可落地

/**
 * 面包屑组合式函数
 * @description 基于路由栈、菜单树与持久化缓存动态生成面包屑
 * @date 2025-12-11
 * @updated 2026-4-28 - 优化逻辑与类型定义
 */
import type { BreadcrumbItem } from '~/router/types'
import type { ResolvedBreadcrumbNode } from '~/types/breadcrumb'

import { computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { CACHE_KEY, useWebCache } from '~/composables/cache'
import { RouteConfig } from '~/config'
import { buildBreadcrumbRouteKey, useBreadcrumbStore } from '~/store/core/breadcrumb'
import { buildMenuTree, getCommonSegmentCount, normalizePath } from '~/utils/navigation'

/**
 * =============================================================================
 * 文件导读
 * =============================================================================
 * 这份 composable 的目标:把“当前路由”解析成可展示的面包屑列表。
 *
 * 一、核心输入来源
 * - 当前路由:`useRoute()`(path/name/meta/query/params)
 * - 后端菜单:`CACHE_KEY.ROLE_ROUTERS`(用于自动推导父级)
 * - 路由配置:`RouteConfig`(用于补全文案)
 * - 历史状态:`breadcrumbStore`(用于上下文继承与缓存)
 *
 * 二、策略优先级(由高到低)
 * 1)contextual:命中当前菜单链路
 * 2)closestMenu:按路径相似度自动猜父级菜单链路
 * 3)cached:当前 routeKey 对应的历史已解析结果
 * 4)inherited:从上一次访问轨迹弱继承(详情跳详情)
 * 5)currentOnly:只显示当前页
 *
 * 三、代码分区建议阅读顺序
 * 1)基础工具函数:去重/合并/比较(纯函数)
 * 2)menuTrails:把菜单树拍平成“根 → 叶子”链路集合
 * 3)关键策略函数:findClosestMenuTrail / buildContextualTrail
 * 4)弱兜底策略:buildInheritedTrail
 * 5)输出层:resolvedTrail → breadcrumbs → shouldShow
 *
 * 四、维护原则
 * - 先改注释中的策略描述,再改实现,保证“文档与代码一致”。
 * - 新增策略优先级时,务必同步 `resolveTrailByStrategies` 顺序。
 * - 避免在模板层做字符串拼装,统一在本文件完成。
 * =============================================================================
 */

/**
 * 后端菜单原始节点结构(来自 ROLE_ROUTERS 缓存)。
 * 注意:字段全部可选是为了兼容历史数据与不同后端版本返回。
 */
interface MenuNode {
  id?: string | number
  parentId?: string | number
  path?: string
  name?: string
  nameEn?: string
  visible?: boolean
  children?: MenuNode[]
}

/** 本地路由与配置可复用的最小元信息结构。 */
interface LocalRouteMeta {
  title?: string
  i18nKey?: string
}

type StrategySource = 'home' | 'contextual' | 'closestMenu' | 'cached' | 'inherited' | 'currentOnly'
type TrailScoreFn = (leafPath: string, currentPath: string) => number

/**
 * 将节点标准化为“可点击”节点。
 * 约定:除当前页节点外,其它面包屑节点默认可点击,最终点击态会在输出阶段再次收敛。
 */
function toBreadcrumbNode(item: Omit<ResolvedBreadcrumbNode, 'isClickable'>): ResolvedBreadcrumbNode {
  return {
    ...item,
    isClickable: true,
  }
}

/**
 * 去重并合并相邻重复节点。
 * 合并规则:
 * - 相邻且 path/name 相同视为同一节点;
 * - 以“后者覆盖前者”方式合并,保留最新字段(例如 i18nKey/title 修正值)。
 */
function dedupeTrail(items: ResolvedBreadcrumbNode[]): ResolvedBreadcrumbNode[] {
  return items.reduce<ResolvedBreadcrumbNode[]>((acc, item) => {
    const last = acc[acc.length - 1]
    if (last && last.path === item.path && last.name === item.name) {
      acc[acc.length - 1] = {
        ...last,
        ...item,
      }
      return acc
    }
    acc.push(item)
    return acc
  }, [])
}

/**
 * 将当前页节点合并到已有 trail 尾部。
 * - 若尾节点与 current path 相同:只更新尾节点,避免重复“当前节点”;
 * - 否则直接 append。
 */
function mergeTrailWithCurrent(trail: ResolvedBreadcrumbNode[], currentNode: ResolvedBreadcrumbNode): ResolvedBreadcrumbNode[] {
  if (!trail.length) {
    return [currentNode]
  }

  const last = trail[trail.length - 1]
  if (last.path === currentNode.path) {
    const mergedTrail = [...trail]
    mergedTrail[mergedTrail.length - 1] = {
      ...last,
      ...currentNode,
    }
    return mergedTrail
  }

  return [...trail, currentNode]
}

/**
 * 深比较两个 trail 是否语义一致。
 * 用于避免把完全相同的结果重复写入 store,减少无意义状态更新与 watcher 链式触发。
 */
function isSameTrail(a: ResolvedBreadcrumbNode[], b: ResolvedBreadcrumbNode[]): boolean {
  if (a.length !== b.length) {
    return false
  }
  return a.every((item, index) => {
    const other = b[index]
    if (!other) {
      return false
    }
    return item.title === other.title
      && item.titleEn === other.titleEn
      && item.path === other.path
      && item.name === other.name
      && item.i18nKey === other.i18nKey
      && item.isClickable === other.isClickable
  })
}

/**
 * 识别“详情类页面”路径:
 * 这类页面通常不在菜单中,应该优先回挂到同域“管理页”作为父级面包屑。
 */
function isDetailLikePath(path: string): boolean {
  return /\/(?:detail|edit|view|create|pay|statement)(?:\/|$)/i.test(path)
}

/**
 * 判断菜单路径是否带有“管理页”特征。
 * 在最短公共前缀分数相同的情况下,详情类页面优先选择该类菜单链路。
 */
function hasManageHint(path: string): boolean {
  return /\/[^/]*manage(?:ment)?(?:\/|$)/i.test(path)
}

/** 取路径首段(例:/import/si-manage -> import),用于候选分组裁剪。 */
function getPathRootSegment(path: string): string {
  return normalizePath(path).split('/').filter(Boolean)[0] || ''
}

/**
 * 面包屑策略分发器(纯函数)。
 * 按优先级选取第一条可用策略结果,确保行为可预测:
 * home > contextual > closest-menu > cached > inherited > current-only。
 */
export function resolveTrailByStrategies(params: {
  isHomeRoute: boolean
  homeTrail: ResolvedBreadcrumbNode[]
  contextualTrail: ResolvedBreadcrumbNode[]
  closestMenuTrail: ResolvedBreadcrumbNode[]
  cachedTrail: ResolvedBreadcrumbNode[]
  inheritedTrail: ResolvedBreadcrumbNode[]
  currentNode: ResolvedBreadcrumbNode
}): ResolvedBreadcrumbNode[] {
  if (params.isHomeRoute) {
    return dedupeTrail(params.homeTrail)
  }

  if (params.contextualTrail.length > 0) {
    return params.contextualTrail
  }

  if (params.closestMenuTrail.length > 0) {
    return dedupeTrail(mergeTrailWithCurrent(params.closestMenuTrail, params.currentNode))
  }

  if (params.cachedTrail.length > 0) {
    return dedupeTrail(mergeTrailWithCurrent(params.cachedTrail.slice(0, -1), params.currentNode))
  }

  if (params.inheritedTrail.length > 0) {
    return params.inheritedTrail
  }

  return [params.currentNode]
}

export function useBreadcrumb() {
  /** 当前路由对象(响应式) */
  const route = useRoute()
  /** 路由实例(用于按 name/path 查找注册路由) */
  const router = useRouter()
  /** 多语言能力(title 文案渲染) */
  const { t, locale } = useI18n()
  /** 本地缓存访问器(读取后端菜单树) */
  const { webCache } = useWebCache()
  /** 面包屑状态仓库(上下文/历史轨迹) */
  const breadcrumbStore = useBreadcrumbStore()

  /** 当前路由唯一键(path + query + params 归一后组合) */
  const routeKey = computed(() => buildBreadcrumbRouteKey(route))
  /** 首页路由标识(走首页特判策略) */
  const isHomeRoute = computed(() => route.meta?.routeSource === 'home')
  /** 调试开关:URL 带 breadcrumbDebug=1 时输出匹配日志(仅开发环境)。 */
  const breadcrumbDebugEnabled = computed(() => import.meta.env.DEV && String(route.query?.breadcrumbDebug || '') === '1')
  /** A/B 开关:URL 带 breadcrumbScoreAB=1 时打印实验评分对比(仅开发环境)。 */
  const breadcrumbScoreABEnabled = computed(() => import.meta.env.DEV && String(route.query?.breadcrumbScoreAB || '') === '1')

  /** router.getRoutes() -> path 元信息索引,避免在菜单 DFS 内部重复 find。 */
  const routeMetaByPath = computed<Map<string, LocalRouteMeta>>(() => {
    const metaMap = new Map<string, LocalRouteMeta>()
    router.getRoutes().forEach((routeRecord) => {
      const normalizedPath = normalizePath(routeRecord.path)
      if (!normalizedPath) return
      const title = typeof routeRecord.meta?.title === 'string' ? routeRecord.meta.title : undefined
      const i18nKey = typeof routeRecord.meta?.i18nKey === 'string' ? routeRecord.meta.i18nKey : undefined
      if (title || i18nKey) {
        metaMap.set(normalizedPath, {
          title,
          i18nKey,
        })
      }
    })
    return metaMap
  })

  /** RouteConfig -> path 元信息索引,作为本地路由元信息的二级兜底。 */
  const configMetaByPath = computed<Map<string, LocalRouteMeta>>(() => {
    const metaMap = new Map<string, LocalRouteMeta>()
    Object.values(RouteConfig).forEach((config) => {
      const record = config as { path?: string
        title?: string
        i18nKey?: string }
      const normalizedPath = normalizePath(record.path)
      if (!normalizedPath) return
      if (record.title || record.i18nKey) {
        metaMap.set(normalizedPath, {
          title: record.title,
          i18nKey: record.i18nKey,
        })
      }
    })
    return metaMap
  })

  /**
   * 按 path 获取本地路由文案元信息。
   * 优先级:
   * 1)已注册路由 meta;
   * 2)RouteConfig 静态配置;
   * 3)无匹配返回空对象。
   */
  function getLocalMetaByPath(path: string): LocalRouteMeta {
    const normalizedPath = normalizePath(path)
    return routeMetaByPath.value.get(normalizedPath) || configMetaByPath.value.get(normalizedPath) || {}
  }

  /**
   * 将后端菜单树拍平成“根 -> 叶子”的路径集合,供后续策略匹配使用。
   * 这里是自动面包屑的基础数据源:
   * 1) 命中 exact trail:当前路由路径与菜单叶子路径完全一致;
   * 2) 命中 closest trail:当前路由是详情页等非菜单页,按路径相似度回挂父菜单。
   */
  const menuTrails = computed<ResolvedBreadcrumbNode[][]>(() => {
    const menuList = webCache.get(CACHE_KEY.ROLE_ROUTERS) as MenuNode[] | null
    const trails: ResolvedBreadcrumbNode[][] = []
    const menuTree = Array.isArray(menuList) ? buildMenuTree(menuList) : []

    // 防御性限制菜单递归深度,避免异常树结构或循环引用导致无限递归与额外遍历开销。
    const MAX_MENU_WALK_DEPTH = 10

    /**
     * DFS 遍历菜单树并生成 trails。
     * @param nodes 当前层节点列表
     * @param parentTrail 父链路(不含当前节点)
     * @param depth 当前递归深度(用于保护递归)
     */
    function walk(nodes: MenuNode[], parentTrail: ResolvedBreadcrumbNode[] = [], depth = 0) {
      if (depth > MAX_MENU_WALK_DEPTH) {
        return
      }

      nodes.forEach((node) => {
        const backendTitle = typeof node.name === 'string' ? node.name.trim() : ''
        const backendTitleEn = typeof node.nameEn === 'string' ? node.nameEn.trim() : ''
        const currentPath = normalizePath(node.path)
        const localMeta = currentPath ? getLocalMetaByPath(currentPath) : {}

        const title = backendTitle || localMeta.title || ''
        const titleEn = backendTitleEn || backendTitle || localMeta.title || ''
        const i18nKey = backendTitle ? undefined : localMeta.i18nKey

        const hasChildren = Array.isArray(node.children) && node.children.length > 0

        // 分组节点(无 path)只作为层级容器,不入最终可跳转叶子集合。
        if (!currentPath) {
          if (!hasChildren) {
            return
          }

          const groupNode: ResolvedBreadcrumbNode = {
            title,
            titleEn,
            path: '',
            name: String(node.id || title || ''),
            i18nKey,
            isClickable: false,
          }
          const groupTrail = [...parentTrail, groupNode]
          walk(node.children || [], groupTrail, depth + 1)
          return
        }

        // 真实菜单路由节点,记录为一条可命中的 trail。
        const currentNode = toBreadcrumbNode({
          title,
          titleEn,
          path: currentPath,
          name: currentPath,
          i18nKey,
        })
        const currentTrail = [...parentTrail, currentNode]
        trails.push(currentTrail)

        if (hasChildren) {
          walk(node.children || [], currentTrail, depth + 1)
        }
      })
    }

    if (menuTree.length > 0) {
      walk(menuTree)
    }

    return trails
  })

  /** path -> exact trail 索引:用于 O(1) 命中精确菜单链路。 */
  const exactTrailByPath = computed<Map<string, ResolvedBreadcrumbNode[]>>(() => {
    const pathMap = new Map<string, ResolvedBreadcrumbNode[]>()
    menuTrails.value.forEach((trail) => {
      const leafPath = normalizePath(trail[trail.length - 1]?.path)
      if (!leafPath || pathMap.has(leafPath)) return
      pathMap.set(leafPath, trail)
    })
    return pathMap
  })

  /** 首段前缀 -> trails 索引:用于 closest 候选裁剪(同域优先)。 */
  const menuTrailsByRootSegment = computed<Map<string, ResolvedBreadcrumbNode[][]>>(() => {
    const segmentMap = new Map<string, ResolvedBreadcrumbNode[][]>()
    menuTrails.value.forEach((trail) => {
      const leafPath = trail[trail.length - 1]?.path
      const rootSegment = getPathRootSegment(leafPath || '')
      if (!rootSegment) return
      const group = segmentMap.get(rootSegment)
      if (group) {
        group.push(trail)
      } else {
        segmentMap.set(rootSegment, [trail])
      }
    })
    return segmentMap
  })

  /**
   * 所有菜单叶子 path 集合:
   * 用于快速判断某个节点是否“真正来自菜单”,影响 inherited 策略拼接行为。
   */
  const exactMenuPaths = computed(() => new Set(exactTrailByPath.value.keys()))

  /**
   * 解析展示标题:
   * - 有 i18nKey 时优先走国际化;
   * - 无 i18nKey 时按语言选择 title/titleEn。
   */
  function resolveDisplayTitle(item: ResolvedBreadcrumbNode): string {
    const isEn = String(locale.value).toLowerCase().startsWith('en')
    if (item.i18nKey) {
      return t(`router.${item.i18nKey}`, item.title)
    }
    return isEn ? (item.titleEn || item.title) : (item.title || item.titleEn || '')
  }

  /**
   * 构造当前路由节点(最终 trail 的尾节点)。
   * 优先级:
   * 1) store 中自定义标题(业务运行态覆盖);
   * 2) 当前 route.meta 回退。
   */
  function createCurrentRouteNode(): ResolvedBreadcrumbNode {
    const customContext = breadcrumbStore.contextByRouteKey[routeKey.value]
    const routeName = typeof route.name === 'string' ? route.name : route.path

    if (customContext?.customTitle) {
      return {
        title: customContext.customTitle,
        titleEn: customContext.customTitleEn || customContext.customTitle,
        path: normalizePath(route.path),
        name: routeName,
        isClickable: false,
      }
    }

    return {
      title: typeof route.meta?.title === 'string' ? route.meta.title : routeName,
      titleEn: typeof route.meta?.title === 'string' ? route.meta.title : routeName,
      path: normalizePath(route.path),
      name: routeName,
      i18nKey: typeof route.meta?.i18nKey === 'string' ? route.meta.i18nKey : undefined,
      isClickable: false,
    }
  }

  /**
   * 精确匹配菜单 trail:仅 path 完全一致才命中。
   */
  function findExactMenuTrail(path: string): ResolvedBreadcrumbNode[] {
    const normalizedPath = normalizePath(path)
    return exactTrailByPath.value.get(normalizedPath) || []
  }

  /** 默认评分:公共路径段数量越多,说明语义越接近。 */
  const scoreByCommonSegments: TrailScoreFn = (leafPath, currentPath) => getCommonSegmentCount(leafPath, currentPath)

  /**
   * 实验评分:在公共段基础上轻微偏向“更短叶子路径”。
   * 仅用于 A/B 观测,不直接影响线上策略结果。
   */
  const scoreByCompactTrail: TrailScoreFn = (leafPath, currentPath) => {
    const commonSegments = getCommonSegmentCount(leafPath, currentPath)
    if (commonSegments <= 0) return 0
    const leafSegments = leafPath.split('/').filter(Boolean).length || 1
    return commonSegments + (1 / leafSegments) * 0.01
  }

  function pickBestTrail(
    candidates: ResolvedBreadcrumbNode[][],
    normalizedPath: string,
    scoreFn: TrailScoreFn,
  ): { bestTrail: ResolvedBreadcrumbNode[]
    bestScore: number
    tieBreakReason: string } {
    /** 当前最优候选 trail */
    let bestTrail: ResolvedBreadcrumbNode[] = []
    /** 当前最优分值 */
    let bestScore = 0
    /** 记录最后一次生效的 tie-break 原因,便于观测与回放。 */
    let tieBreakReason = 'none'
    /** 详情页标识:同分时启用“优先管理页”策略 */
    const currentIsDetailLike = isDetailLikePath(normalizedPath)

    candidates.forEach((trail) => {
      const leaf = trail[trail.length - 1]
      if (!leaf?.path) return

      const score = scoreFn(leaf.path, normalizedPath)
      if (score > bestScore) {
        bestScore = score
        bestTrail = trail
        tieBreakReason = 'higher_score'
      } else if (score === bestScore && score > 0) {
        // 同分时:详情类页面优先回挂“管理页”菜单(如 /import/si-manage)
        if (currentIsDetailLike) {
          const currentHasManageHint = hasManageHint(leaf.path)
          const bestHasManageHint = hasManageHint(bestTrail[bestTrail.length - 1]?.path || '')
          if (currentHasManageHint !== bestHasManageHint) {
            if (currentHasManageHint) {
              bestTrail = trail
              tieBreakReason = 'detail_manage_hint'
            }
            return
          }
        }

        // 仍同分时,选择层级更浅的菜单,减少错误挂到过深子页面。
        const currentLeafSegments = leaf.path.split('/').filter(Boolean).length
        const bestLeafSegments = bestTrail[bestTrail.length - 1]?.path?.split('/').filter(Boolean).length || Infinity
        if (currentLeafSegments < bestLeafSegments) {
          bestTrail = trail
          tieBreakReason = 'shallower_leaf'
        }
      }
    })

    return {
      bestTrail,
      bestScore,
      tieBreakReason,
    }
  }

  /**
   * 自动猜测“最可能父级菜单链路”:
   * - 先尝试 exact 命中;
   * - 否则按路径公共段得分选择最接近链路;
   * - 若同分,详情类页面优先选择包含 manage/management 的菜单;
   * - 再同分时选择层级更浅的链路,避免误挂过深叶子节点。
   */
  function findClosestMenuTrail(path: string): ResolvedBreadcrumbNode[] {
    const normalizedPath = normalizePath(path)
    const exactTrail = findExactMenuTrail(normalizedPath)
    if (exactTrail.length > 0) {
      if (breadcrumbDebugEnabled.value) {
        console.info('[breadcrumb.closest] exact_match', {
          path: normalizedPath,
          source: 'exact',
          score: Number.POSITIVE_INFINITY,
        })
      }
      return exactTrail
    }

    const rootSegment = getPathRootSegment(normalizedPath)
    const groupedCandidates = rootSegment ? menuTrailsByRootSegment.value.get(rootSegment) : undefined
    const activeCandidates = groupedCandidates && groupedCandidates.length > 0 ? groupedCandidates : menuTrails.value
    const candidateSource = groupedCandidates && groupedCandidates.length > 0 ? 'root_segment' : 'global_fallback'

    const baselineResult = pickBestTrail(activeCandidates, normalizedPath, scoreByCommonSegments)

    if (breadcrumbDebugEnabled.value) {
      console.info('[breadcrumb.closest] matched', {
        path: normalizedPath,
        source: candidateSource,
        candidateCount: activeCandidates.length,
        score: baselineResult.bestScore,
        tieBreakReason: baselineResult.tieBreakReason,
        matchedPath: baselineResult.bestTrail[baselineResult.bestTrail.length - 1]?.path || '',
      })
    }

    if (breadcrumbScoreABEnabled.value) {
      const experimentResult = pickBestTrail(activeCandidates, normalizedPath, scoreByCompactTrail)
      console.info('[breadcrumb.closest] score_ab', {
        path: normalizedPath,
        baselineScore: baselineResult.bestScore,
        baselinePath: baselineResult.bestTrail[baselineResult.bestTrail.length - 1]?.path || '',
        experimentScore: experimentResult.bestScore,
        experimentPath: experimentResult.bestTrail[experimentResult.bestTrail.length - 1]?.path || '',
      })
    }

    return baselineResult.bestScore > 0 ? baselineResult.bestTrail : []
  }

  /**
   * contextual 策略:仅命中当前路由对应的精确菜单链路。
   */
  function buildContextualTrail(): ResolvedBreadcrumbNode[] {
    return findExactMenuTrail(route.path)
  }

  /**
   * 继承上一次已解析面包屑(弱兜底):
   * 用于详情跳详情、列表跳详情等连续跳转,减少“只剩当前节点”的退化情况。
   * 仅在存在 query/params 或详情类路径时启用,避免污染普通菜单页结果。
   */
  function buildInheritedTrail(currentNode: ResolvedBreadcrumbNode): ResolvedBreadcrumbNode[] {
    const normalizedCurrentPath = normalizePath(route.path)
    const isHomeLikeRoute = normalizedCurrentPath === normalizePath(RouteConfig.Index.path)
      || route.path === '/'
      || route.path === '/index'
      || route.path === '/dashboard'
    if (isHomeLikeRoute) {
      return []
    }

    // 仅“详情类跳转”场景尝试继承,普通列表页不继承,避免脏链路。
    const hasQuery = Object.keys(route.query || {}).length > 0
    const hasParams = Object.keys(route.params || {}).length > 0
    const isDetailLikeRoutePath = isDetailLikePath(normalizedCurrentPath)
    if (!hasQuery && !hasParams && !isDetailLikeRoutePath) {
      return []
    }

    // 同一路由键不重复继承,避免形成自引用。
    if (breadcrumbStore.lastResolvedRouteKey === routeKey.value) {
      return []
    }

    // 上一次已解析并落库的 trail。
    const previousTrail = breadcrumbStore.lastResolvedTrail
    if (!previousTrail.length) {
      return []
    }

    const previousLast = previousTrail[previousTrail.length - 1]
    const commonSegments = getCommonSegmentCount(previousLast?.path, route.path)
    if (commonSegments < 1) {
      return []
    }

    // 若上一个尾节点本身是菜单节点,则整条继承;否则去掉旧尾节点再拼当前节点。
    const previousLastIsMenu = exactMenuPaths.value.has(normalizePath(previousLast?.path))
    const baseTrail = previousLastIsMenu ? [...previousTrail] : previousTrail.slice(0, -1)
    if (!baseTrail.length) {
      return []
    }

    if (baseTrail[baseTrail.length - 1]?.path === currentNode.path) {
      return baseTrail
    }

    return dedupeTrail([...baseTrail, currentNode])
  }

  /**
   * 首页链路构造:
   * - 当前就是首页:仅返回当前节点;
   * - 其它页面:返回“首页 > 当前页”。
   */
  function buildHomeTrail(currentNode: ResolvedBreadcrumbNode): ResolvedBreadcrumbNode[] {
    const normalizedCurrentPath = normalizePath(route.path)
    const normalizedHomePath = normalizePath(RouteConfig.Index.path)
    if (!normalizedCurrentPath || normalizedCurrentPath === normalizedHomePath) {
      return [currentNode]
    }

    return [
      {
        title: RouteConfig.Index.title,
        titleEn: RouteConfig.Index.title,
        i18nKey: RouteConfig.Index.i18nKey,
        path: normalizedHomePath,
        name: RouteConfig.Index.name,
        isClickable: true,
      },
      currentNode,
    ]
  }

  function resolveStrategySource(params: {
    isHomeRoute: boolean
    contextualTrail: ResolvedBreadcrumbNode[]
    closestMenuTrail: ResolvedBreadcrumbNode[]
    cachedTrail: ResolvedBreadcrumbNode[]
    inheritedTrail: ResolvedBreadcrumbNode[]
  }): StrategySource {
    if (params.isHomeRoute) return 'home'
    if (params.contextualTrail.length > 0) return 'contextual'
    if (params.closestMenuTrail.length > 0) return 'closestMenu'
    if (params.cachedTrail.length > 0) return 'cached'
    if (params.inheritedTrail.length > 0) return 'inherited'
    return 'currentOnly'
  }

  const resolvedTrail = computed<ResolvedBreadcrumbNode[]>(() => {
    const currentNode = createCurrentRouteNode()
    // 面包屑策略优先级(从高到低):
    // 1) contextualTrail:命中当前菜单;
    // 2) closestMenuTrail:按路径相似度自动猜测父链路;
    // 3) cached/inherited:保留用户连续浏览上下文;
    // 4) currentOnly:仅保留当前页。
    const homeTrail = buildHomeTrail(currentNode)
    const contextualTrail = buildContextualTrail()
    const closestMenuTrail = findClosestMenuTrail(route.path)
    const cachedTrail = breadcrumbStore.getResolvedTrail(routeKey.value)
    const inheritedTrail = buildInheritedTrail(currentNode)
    const strategySource = resolveStrategySource({
      isHomeRoute: isHomeRoute.value,
      contextualTrail,
      closestMenuTrail,
      cachedTrail,
      inheritedTrail,
    })

    const resolved = resolveTrailByStrategies({
      isHomeRoute: isHomeRoute.value,
      homeTrail,
      contextualTrail,
      closestMenuTrail,
      cachedTrail,
      inheritedTrail,
      currentNode,
    })

    if (breadcrumbDebugEnabled.value) {
      console.info('[breadcrumb.strategy] resolved', {
        routePath: normalizePath(route.path),
        source: strategySource,
        trail: resolved.map(item => item.path),
      })
    }

    return resolved
  })

  const breadcrumbs = computed<BreadcrumbItem[]>(() => {
    // 主动读取 locale,确保语言切换时 computed 能触发重算。
    const _currentLocale = locale.value

    // 最终输出给 UI 的面包屑模型:统一在这里计算点击态。
    return resolvedTrail.value.map((item, index) => ({
      title: resolveDisplayTitle(item),
      path: item.path,
      name: item.name,
      i18nKey: item.i18nKey,
      isClickable: index < resolvedTrail.value.length - 1 && Boolean(item.path),
    }))
  })

  watch(
    () => [routeKey.value, resolvedTrail.value] as const,
    ([currentRouteKey, trail]) => {
      // 空键或空链路不入库,避免污染历史。
      if (!currentRouteKey || trail.length === 0) return
      // 持久化时再次收敛点击态,保证 store 中数据结构稳定。
      const persistedTrail = trail.map((item, index) => ({
        ...item,
        isClickable: index < trail.length - 1 && Boolean(item.path),
      }))
      const existingTrail = breadcrumbStore.getResolvedTrail(currentRouteKey)
      // 等价则跳过写入,减少重复状态变更。
      if (isSameTrail(existingTrail, persistedTrail)) {
        return
      }
      breadcrumbStore.saveResolvedTrail(currentRouteKey, persistedTrail)
    },
    {
      immediate: true,
    },
  )

  /**
   * 是否展示面包屑组件。
   * 规则:
   * - blank/purePage 布局隐藏;
   * - 登录注册等认证页面隐藏;
   * - 首页隐藏;
   * - 其余场景有面包屑数据才展示。
   */
  const shouldShow = computed<boolean>(() => {
    const hiddenLayouts = ['blank', 'purePage']
    const layoutType = route.meta?.layoutType

    if (hiddenLayouts.includes(layoutType as string)) {
      return false
    }

    const authPaths = ['/login', '/register', '/certification', '/forget']
    if (authPaths.includes(route.path)) {
      return false
    }

    if (route.path === '/' || route.path === '/index' || route.path === '/dashboard') {
      return false
    }

    return breadcrumbs.value.length > 0
  })

  return {
    breadcrumbs,
    shouldShow,
  }
}

1. 问题背景

在业务系统里,很多详情页并不直接出现在菜单树中。 如果只靠静态配置面包屑,维护成本高且容易错。 我们的目标是:让面包屑以菜单与路径自动推导为主,并保持策略可解释

2. 把面包屑问题抽象成“路径匹配”

可把它看成一个简化版最短路径匹配问题:

  • 输入:当前路由 route.path,历史访问上下文,菜单树。
  • 候选:菜单树中所有“根→叶子”链路。
  • 目标:找到最合理的父链路,再拼上当前节点。

这与“地图匹配”的思想相似: 观测是当前 URL,路网是菜单拓扑,最优路径是最终面包屑链路。

3. 当前方案:规则优先的近似最优

系统并没有走复杂的全局最优算法,而是采用了“可解释、可维护”的策略优先级:

  1. contextual(当前菜单精确命中)
  2. closestMenu(相似路径自动匹配)
  3. cached(同 routeKey 历史结果)
  4. inherited(连续跳转弱继承)
  5. currentOnly(只显示当前页)

优点很明显:

  • 可解释:每一步都能说明“为什么这么选”。
  • 稳定:策略顺序固定,行为可预测。
  • 成本低:前端实时计算压力可控。

4. 核心算法点

4.1 候选空间构建

先将菜单树拍平为“根→叶子”链路集合(trail),作为匹配候选集。 这一步决定了后续匹配上限。

4.2 相似度匹配(closestMenu)

对当前路径与候选叶子路径计算公共段得分。 同分时用两级 tie-break:

  • 详情页优先回挂 manage/management 菜单;
  • 再同分时选择更浅层级,减少误挂深节点。

4.3 时序信息(inherited)

对于“详情跳详情”场景,尝试继承上一条已解析链路,避免退化成“仅当前节点”。

5. 复杂度与瓶颈

当前复杂度主要来自两类线性扫描:

  • 多处 findExactMenuTrail 的全量遍历;
  • closestMenu 的全候选打分遍历。

在菜单规模增大时,这会放大开销,但仍是“可优化而非重构”。

6. 优化策略(保持简单)

6.1 建索引,替代重复扫描

  • Map<path, trail>:O(1) 命中 exact trail;
  • Map<path, meta>:O(1) 读取 route/config 文案。

6.2 候选裁剪

先按首段前缀分组(如 /import/export), closestMenu 仅在组内打分,再回退全局。

6.3 保持策略优先,不升级到复杂概率模型

HMM/Viterbi 适合长序列全局最优,但对前端面包屑属于过度设计。 当前场景下,策略优先 + 轻量评分是更优工程解。

7. 结语

这套方案的价值不在“数学最优”,而在“业务最优”:

  • 解释性强;
  • 运维成本低;
  • 扩展点明确(规则、评分、索引)。

一句话总结: 面包屑不是在拼字符串,而是在做一套可治理的轻量路径匹配系统。

8. 已落地演进(2026-04-28)

按“简单可维护 + 不改策略行为”的原则,已完成以下三步:

8.1 第一步:索引化改造(已完成)

  • 新增 exactTrailByPathpath -> trail,用于 O(1) 精确命中。
  • 新增 routeMetaByPathpath -> route meta,避免菜单 DFS 内反复 router.getRoutes().find(...)
  • 新增 configMetaByPathpath -> config meta,替代 Object.values(RouteConfig).find(...) 线性扫描。

收益:

  • 降低重复遍历,热点查询从“多次线性搜索”变为“哈希查找”。
  • 逻辑行为保持一致,只优化读取路径。

8.2 第二步:closest 候选裁剪 + 可观测日志(已完成)

  • 新增 menuTrailsByRootSegment:按首段前缀分组候选(如 importexport)。
  • closest 匹配优先在同前缀组内打分,无组时回退全量候选。
  • 新增调试日志(开发环境):
    • breadcrumbDebug=1:输出命中来源、候选规模、得分、tie-break 原因、最终命中路径。
    • 日志主题:[breadcrumb.closest][breadcrumb.strategy]

收益:

  • 常见场景减少无关候选打分,提升稳定性与可解释性。
  • 线上行为不受影响,调试信息按开关输出。

8.3 第三步:评分函数抽象 + A/B 评估(已完成)

  • 抽象评分函数接口 TrailScoreFn
  • 基线评分 scoreByCommonSegments 继续作为实际决策函数(保证兼容)。
  • 新增实验评分 scoreByCompactTrail 仅用于对比观测。
  • 新增 A/B 调试开关(开发环境):
    • breadcrumbScoreAB=1:输出 baseline 与 experiment 的分值和命中路径对比。

收益:

  • 为后续评分策略迭代提供低成本实验框架。
  • 避免“直接切算法”带来的不可控风险。

高通的「共享内存架构」,想让 Win 本追上 MacBook Pro

一台 14 或 16 英寸的笔记本电脑,将几十上百 GB 内存直接封装进 SoC,实现超过 200 GB/s 的高性能内存带宽,还有轻薄的机身和安静又狂暴的性能……

你可能以为这是 MacBook Pro——但如果我告诉你,这是一台 ARM 架构的轻薄型 Windows 本呢?

4 月 27 日,华硕发布了灵耀 16 Air 的骁龙版,搭载的是高通骁龙 X2 Elite Extreme 平台,也即高通去年推出的第二代 Windows on ARM 处理器。

这是第一颗将 LPDDR5X 内存做进 SoC 封装的骁龙旗舰 PC 平台,是与苹果「统一内存架构」理念一致、执行接近的平行方案。尽管没能做到 M 芯片的百分百效果,仍然是高通在这条新路上,最关键的一次尝试。

这台华硕灵耀 16 Air 骁龙版,整机 1.2kg、厚度 13.9mm,48GB 内存(频率 9523 MT/s),可提供 20-30 小时续航。机器于 4 月 28 日京东首发,售价 13999 元。华硕同时也有 14 寸版本提供。

同期亮相的还有面向创作者的 ProArt 创 X 2026 二合一笔记本,重 0.82kg、提供 22 小时续航与 2.8K 144Hz OLED 屏。这些机型共同组成了华硕在 ARM Windows 阵营的 2026 全新产品矩阵。

回到顶配 X2 Elite Extreme 的共享内存架构:将内存放进芯片封装内,放到 CPU、GPU 和 NPU 的身边,并不只是改了改电路板布局。实际上,整个计算资源调度的方式,都发生了很大的改变。

苹果在 2020 年的 M1 芯片开始,不仅将内存封装进 PC 级芯片,更让调度变得更加灵活,内存反复读写的次数要求有所降低,结果就是让内存带宽暴增——称为统一内存架构。今年 3 月发布的 M5 Pro 和 M5 Max,则更是将内存带宽推到了 307 GB/s 和 614 GB/s。

骁龙 X2 Elite Extreme 是 Windows on ARM 笔记本第一次通过内存内封装的思路,让 1.2 公斤左右的轻薄本也可以享受类似于统一内存架构带来的快乐。

这背后,是高通和华硕等各大 OEM 一起,想让 Windows 笔记本追上 MacBook Pro 的企图。

让内存搬运再快一点

需要注明的是,「统一内存架构」是苹果使用的说法,高通官方称自己的方案为 SiP(System-in-Package)。

两者所指不完全相同:UMA 描述的是内存访问架构,SiP 则指的是具体的封装技术。但它们的实现效果和追求目标高度一致——共享物理内存池、跨 IP 块缓存一致。

可用于算力密集型任务(比如 AI 推理)的「显存」上限,直接等于整机的内存上限。哪怕是一台 48GB 的轻薄本,理论上也可以本地运行数百亿参数级别的大模型,这在传统架构上需要工作站级独显,采用集显的轻薄本很难做到。(X2 Elite Extreme 最高 SKU 为 128GB 共享内存。)

系统级缓存(SLC)可以在 CPU、Adreno X2 GPU、Hexagon NPU 之间动态分配,比上一代带宽高 70%;192-bit 内存总线搭配 LPDDR5X-9523,能够实现高达 228 GB/s 的C/G/NPU 共享内存带宽。

而传统的混合计算负载(同时依赖 C/G/NPU),被内存搬运所掣肘的情况,也得到了极大缓解。并且,整机功耗也能维持在轻薄本可以接受的水平。

更值得一提的是,这一代 Hexagon NPU 还专门把 DMA 单元升级到 64 位虚拟寻址,让 NPU 终于可以访问超过 4GB 的内存,一定程度上突破了 NPU 坐端侧大模型推理任务的瓶颈。

这的确不是 Windows 阵营第一次试水类似统一内存架构的方案,在此之前,英特尔、AMD 都做过尝试(稍后会详述)。

不过在今天,华硕灵耀 16 Air 骁龙版的高配机型,是 Windows 阵营里首个最大限度接近统一内存架构效果,并且还做到 1.2 公斤左右 ARM 轻薄本上的方案。

让更多 Windows 笔记本用上新架构

在共享/统一内存架构的道路上,每家芯片巨头对的判断都不一样,首先是工程问题,更深一层是商业问题。

一名在某芯片巨头供职的专家告诉爱范儿,行业里无人质疑统一内存架构的优秀,但做与不做,能否持续做,分歧在于厂商对性能目标和成本之间的平衡。

在 X2 顶配 SKU 上,高通目前的看法是:将统一内存架构所解锁的强大性能,交给给到真正需要它的硬核用户,特别是那些工作流里重度依赖 AI 模型/AI 功能的专业用户和创作者,这件事值得花成本去做。

再看英特尔,在上一代 Lunar Lake 架构上做过类似尝试,然而成本炸裂难以控制,不得不终止。英特尔前 CEO Pat Gelsinger 在财报会上明确将该次尝试定义为「one-off」,理由是封装内存把毛利压得太低。

今年 1 月发布的 Panther Lake 机型则回归了传统外置内存路线,据信后续的 Nova Lake 架构也将延续老的策略。英特尔仍然在高端 AI 笔记本市场上占有一席之地,但可以说短期内不会再走统一内存架构这条路了。

AMD 那边,Ryzen AI Max+ 395(Strix Halo)同样采用类似的共享内存架构架构,最高 128GB 板载 LPDDR5X,能够实现高达 256 GB/s 内存带宽,比 X2 Elite Extreme 还激进。

正因为此,在 AMD 的定义下 Strix Halo 属于移动工作站芯片,搭载的笔记本价格都更高,形态也更厚重,抑或是搭载于迷你工作站,不在个人笔记本电脑消费者的选购范围内。

三家芯片厂商,三种不同答案。骁龙 X2 Elite Extreme 消费级笔记本在这个时间点正式面市,虽然很难说撞上了换机窗口(毕竟今年的内存实在太贵),但至少填补了消费级市场的真空。

何时能追上 MacBook Pro 呢?

老实说,骁龙 X2 Elite Extreme 目前也只是跟苹果那边的 M5 基础款能打个有来有回,跟 M5 Pro/Max 这样的工作站级「顶级牛马」距离还比较远。

最直接的差距在于内存带宽的极限值:X2 Elite Extreme 的带宽宣传值能够达到 228GB/s,是 M5 Max 的 ⅓ 左右,比 M5 Pro 的 ⅔ 多一点。

当然还是要给 X2 挽尊一下,这一代仍然是单 die(晶粒),内存带宽存在物理上限。

而苹果在 M5 Pro/Max 这一代用上了新的「融合封装」,也即将两块 die 拼到一起,把内存总线扩展到更高。

在最直接的大模型推理任务上,内存带宽差距直接意味着 token 吞吐速度的差距;在 4K/8K 等极高清的视频剪辑和 AI 处理任务上,或者在其他工程软件的算力密集型任务上,也会有明显体现。

不过至少,Windows 平台在这些专业/工业软件的兼容性上是要比 macOS 好的……

我想,骁龙把共享内存架构带进消费级 Windows 笔记本市场,这件事的意义讨论或许不应该局限于性能数字上谁暴打谁,

而在于 Windows 平台用户不应该一直享受「二等公民」的体验。

即便是一台不超过 1.5 公斤的大屏轻薄本,仍然可以提供远比其它 Windows 性能本更好的 AI 算力,而且仍能保住轻薄本应该有的功耗优势——这,才是更重要的。

当然,围绕在 Windows on ARM 周围的种种问题,比如软件生态、x86 模拟层稳定性、专业软件适配等等,仍然无法被共享内存一劳永逸地解决。

从芯片厂,到微软,再到 ISV,大家都在加紧马力。比如 Photoshop、Lightroom 已经能够稳定运行 ARM 原生版本;达芬奇也早在两年前就完成了 Windows on ARM 的原生支持,甚至比 Adobe 还早。

但软件生态兼容仍有不完美之处,比如 Adobe AE 的部分渲染器和工作流仍然只能在 x86 平台上使用;Blender 的一些渲染功能在 ARM 架构上也会性能打折。

这是一个软件追硬件的时代。只有 X2 这一代能够让足够多用户,特别是创作者和专业用户,真正将骁龙本纳入主力机考虑——ARM 生态才会进入「用户越多适配越多,适配越多用户越多」的正反馈。

苹果也走过同样的路,所以这绝非不可能完成的任务。

 

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

Cladue Code 源码解析-键盘事件与 Vim 模式:parse-keypress 解析状态机

系列索引:《从零吃透 Claude Code 源码》系列 前置知识(React Ink 终端 UI 引擎) 源码路径src/src/vim/src/src/utils/Cursor.tssrc/src/utils/suggestions/


1. 概述

Claude Code 的输入系统分为三层:

┌──────────────────────────────────────────────┐
│            物理层:终端原始字节                  │
│         parse-keypress.ts(ANSI 序列解析)      │
├──────────────────────────────────────────────┤
│            语义层:标准化按键对象                │
│         转换 ParsedKey { name, shift, ctrl } │
├──────────────────────────────────────────────┤
│            应用层:Vim 模式 / 命令补全          │
│         vim/ + suggestions/                   │
└──────────────────────────────────────────────┘

2. 物理层:ANSI 转义序列解析

2.1 为什么终端按键不是简单的 ASCII?

浏览器键盘事件很简单——keydown 事件直接告诉你按了哪个键。但终端里:

  • 方向键 = ESC[A(三个字节的转义序列)
  • Shift+Enter = ESC[13;2u(CSI u 协议,Kitty 键盘格式)
  • 鼠标点击 = ESC[<0;15;40M(SGR 鼠标协议)

这些统称为 ANSI Escape Sequences(ANSI 转义序列),格式为 ESC + [ + 参数 + 命令

2.2 parse-keypress.ts 的解析器

parse-keypress.ts(801 行)是整个输入系统的第一关。它接收终端原始字节流,输出标准化按键对象。

// parse-keypress.ts
// 核心正则:识别不同类型的转义序列

// CSI u (Kitty 键盘协议)
// 格式: ESC [ codepoint [; modifier] u
// ESC[13;2u = Shift+Enter, ESC[27u = Escape
const CSI_U_RE = /^\x1b\[(\d+)(?:;(\d+))?u/

// xterm modifyOtherKeys(备用协议)
// 格式: ESC [ 27 ; modifier ; keycode ~
const MODIFY_OTHER_KEYS_RE = /^\x1b\[27;(\d+);(\d+)~/

// SGR 鼠标事件
// CSI < button ; col ; row M (press) or m (release)
// 按钮码: 64/65 = 滚轮上/下, 32 = 左键拖拽, 0/1/2 = 左/中/右键
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/

// 功能键转义序列(F1-F12, Home, End 等)
const FN_KEY_RE = /^\x1b+(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/

// 终端响应(不是按键,是终端对查询的回复)
const DECRPM_RE = /^\x1b\[\?(\d+);(\d+)\$y$/  // DECRQM 响应
const DA1_RE = /^\x1b\[\?([\d;]*)c$/        // 设备属性响应
const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/ // xterm.js 版本探测

2.3 解析流程

function parseKeypress(buffer: Buffer): ParsedKey | ParsedMouse | null {
  // 1. 检测粘贴事件(粘贴内容通常较长)
  if (isPaste(buffer)) {
    return createPasteKey(buffer.toString())
  }

  // 2. 检测终端响应(DAC1、DECRPM 等)
  if (matchTerminalResponse(buffer)) {
    handleTerminalResponse(buffer)  // 更新终端状态
    return null  // 不是用户按键,不分发
  }

  // 3. 检测鼠标事件
  const mouseMatch = buffer.match(SGR_MOUSE_RE)
  if (mouseMatch) {
    return parseMouseEvent(mouseMatch)
  }

  // 4. 解析修饰键和按键码
  const sequence = buffer.toString()

  // 检测修饰键前缀
  let shift = false, ctrl = false, meta = false, option = false
  if (sequence.startsWith('\x1b')) { meta = true; ... }

  // 根据转义序列匹配到具体按键
  const name = resolveKeyName(sequence, ctrl, shift)

  return {
    kind: 'key',
    name,           // 'arrowLeft', 'enter', 'escape'
    shift, ctrl, meta, option,
    sequence,       // 原始序列 '\x1b[D'
    raw: buffer.toString(),
    isPasted: false,
  }
}

3. 语义层:ParsedKey 对象

解析后的标准化对象:

interface ParsedKey {
  kind: 'key' | 'mouse' | 'paste'
  name: string         // 语义化: 'arrowLeft', 'enter', 'escape', 'tab'
  fn: boolean         // 功能键
  ctrl: boolean       // Ctrl 修饰键
  meta: boolean       // Alt/Option 修饰键
  shift: boolean      // Shift 修饰键
  option: boolean
  super: boolean      // Command/Win 修饰键
  sequence: string     // 原始转义序列
  raw: string         // 原始字节
  isPasted: boolean    // 粘贴事件(特殊处理,绕过命令解析)
}

interface ParsedMouse {
  kind: 'mouse'
  button: number      // 0=左键, 1=中键, 2=右键, 32=拖拽, 64/65=滚轮
  col: number         // 列位置(1-indexed)
  row: number         // 行位置
  action: 'press' | 'release' | 'drag' | 'scroll'
}

4. 应用层:Vim 模式状态机

4.1 两种 Vim 状态

// types.ts
export type VimState =
  | { mode: 'INSERT'; insertedText: string }   // 插入模式
  | { mode: 'NORMAL'; command: CommandState }  // 普通模式

4.2 普通模式状态机

                              ┌──────────────────────────────────────┐
                              │             NORMAL 模式               │
                              │        (CommandState 状态机)           │
                              │                                       │
  idle ────[d/c/y]──────────►│ operator ────[motion]────► execute   │
    │      [d/c/y]              ▲      ├─[数字]────► operatorCount  │
    │                            │      ├─[ia]────► operatorTextObj │
    ├──────[1-9]────────────► count        └─[fFtT]──► operatorFind │
    ├──────[fFtT]────────────► find                                │
    ├──────[g]────────────────► g                                   │
    ├──────[r]────────────────► replace                             │
    ├──────[><]───────────────► indent                               │
    └─────────────────────────►►──────[i/a/o/A/I]──► INSERT模式     │
                              └──────────────────────────────────────┘

4.3 状态定义

// types.ts
export type CommandState =
  | { type: 'idle' }                                    // 空闲,等待按键
  | { type: 'count'; digits: string }                   // 数字前缀(如 5j 中的 5)
  | { type: 'operator'; op: Operator; count: number }   // 操作符待续(d 后等待 motion)
  | { type: 'operatorCount'; op: Operator; count: number; digits: string }
  | { type: 'operatorFind'; op: Operator; count: number; find: FindType }
  | { type: 'operatorTextObj'; op: Operator; count: number; scope: TextObjScope }
  | { type: 'find'; find: FindType; count: number }    // f/F/t/T 寻找
  | { type: 'g'; count: number }                       // g 前缀
  | { type: 'operatorG'; op: Operator; count: number }
  | { type: 'replace'; count: number }                  // r 单字符替换
  | { type: 'indent'; dir: '>' | '<'; count: number }

设计亮点:TypeScript 的穷举类型(discriminated union)确保每个 case 都处理了所有状态。如果未来加新状态,编译器会强制更新所有 switch。


5. Motions:光标移动

motions.ts 将按键解析为光标移动目标——纯函数,无副作用

// motions.ts
export function resolveMotion(
  key: string,
  cursor: Cursor,
  count: number,
): Cursor {
  let result = cursor
  // 支持数字前缀:5j = 执行 5 次 j
  for (let i = 0; i < count; i++) {
    const next = applySingleMotion(key, result)
    if (next.equals(result)) break  // 边界保护
    result = next
  }
  return result
}

function applySingleMotion(key: string, cursor: Cursor): Cursor {
  switch (key) {
    case 'h': return cursor.left()
    case 'l': return cursor.right()
    case 'j': return cursor.downLogicalLine()   // 逻辑行(软换行)
    case 'k': return cursor.upLogicalLine()
    case 'gj': return cursor.down()              // 物理行(显示行)
    case 'gk': return cursor.up()
    case 'w': return cursor.nextVimWord()       // word 词首
    case 'b': return cursor.prevVimWord()        // word 词首(反向)
    case 'e': return cursor.endOfVimWord()       // word 词尾
    case 'W': return cursor.nextWORD()           // WORD(大写,以空白分隔)
    case 'B': return cursor.prevWORD()
    case 'E': return cursor.endOfWORD()
    case '0': return cursor.startOfLogicalLine()
    case '^': return cursor.firstNonBlankInLogicalLine()
    case '$': return cursor.endOfLogicalLine()
    case 'g0': return cursor.startOfDisplayLine() // 屏幕行首
    case 'g^': return cursor.firstNonBlankInDisplayLine()
    case 'g$': return cursor.endOfDisplayLine()
    case '|': return cursor.column(n)            // 到第 n 列
    // ...
  }
}

逻辑行 vs 显示行

这是 Vim 中容易混淆的概念,Claude Code 实现了两种:

  • 逻辑行:文件中的实际行(包含软换行的长行可能被显示为多行)
  • 显示行:终端上看到的物理行

6. Operators:操作符

操作符结合 motion 产生动作(d3w = delete 3 words):

// operators.ts
export type Operator = 'delete' | 'change' | 'yank'

export function executeOperatorMotion(
  op: Operator,
  motion: string,
  count: number,
  ctx: OperatorContext,
): void {
  // 1. 解析 motion 得到目标位置
  const target = resolveMotion(motion, ctx.cursor, count)
  if (target.equals(ctx.cursor)) return

  // 2. 计算操作范围
  const range = getOperatorRange(ctx.cursor, target, motion, op, count)

  // 3. 执行操作
  applyOperator(op, range.from, range.to, ctx, range.linewise)
}

// 操作上下文
export type OperatorContext = {
  cursor: Cursor
  text: string
  setText: (text: string) => void
  setOffset: (offset: number) => void
  enterInsert: (offset: number) => void
  getRegister: () => string
  setRegister: (content: string, linewise: boolean) => void
  getLastFind: () => { type: FindType; char: string } | null
  setLastFind: (type: FindType, char: string) => void
  recordChange: (change: RecordedChange) => void   // 用于 . 重复
}

三大操作符

操作符 快捷键 效果
delete d 删除并放入寄存器
change c 删除并进入插入模式
yank y 复制到寄存器(不删除)

典型组合:

  • dw — 删除一个 word
  • d$ / D — 删除到行尾
  • dd — 删除整行
  • c3w — 改变 3 个 word
  • yyp — 复制当前行并粘贴到下方

7. Text Objects:文本对象

文本对象让你一次性选中一个"块"(括号对、引号、单词等):

// textObjects.ts

// 配对括号定义
const PAIRS: Record<string, [string, string]> = {
  '(': ['(', ')'],   ')': ['(', ')'],   b: ['(', ')'],
  '[': ['[', ']'],   ']': ['[', ']'],
  '{': ['{', '}'],   '}': ['{', '}'],   B: ['{', '}'],
  '<': ['<', '>'],   '>': ['<', '>'],
  '"': ['"', '"'],
  "'": ["'", "'"],
  '`': ['`', '`'],
}

export function findTextObject(
  text: string,
  offset: number,
  objectType: string,
  isInner: boolean,    // i = inner(不含分隔符), a = around(含分隔符)
): TextObjectRange {
  if (objectType === 'w')
    return findWordObject(text, offset, isInner, isVimWordChar)
  if (objectType === 'W')
    return findWordObject(text, offset, isInner, ch => !isVimWhitespace(ch))

  const pair = PAIRS[objectType]
  if (pair) {
    const [open, close] = pair
    return open === close
      ? findQuoteObject(text, offset, open, isInner)
      : findBracketObject(text, offset, open, close, isInner)
  }
  return null
}

常用文本对象

命令 含义 说明
ci" change inner quote 修改引号内的内容
di( delete inner paren 删除括号内的内容
ya{ yank around brace 复制大括号及内容
ciw change inner word 改变当前单词
ci( change inner paren 修改括号内容
yiB yank inner Brace 复制大括号内容

8. 状态转换:transitions.ts

transitions.ts(490 行)是 Vim 状态机的核心——每个状态有一个转换函数:

// transitions.ts
export type TransitionResult = {
  next?: CommandState
  execute?: () => void
}

export function transition(
  state: CommandState,
  input: string,
  ctx: TransitionContext,
): TransitionResult {
  switch (state.type) {
    case 'idle':
      return fromIdle(input, ctx)
    case 'count':
      return fromCount(state, input, ctx)
    case 'operator':
      return fromOperator(state, input, ctx)
    // ...
  }
}

// 从 idle 状态的处理
function fromIdle(input: string, ctx: TransitionContext): TransitionResult {
  // 操作符 → 进入 operator 状态
  if (isOperatorKey(input)) {
    return { next: { type: 'operator', op: OPERATORS[input], count: 1 } }
  }

  // 数字 → 进入 count 状态
  if (/[1-9]/.test(input)) {
    return { next: { type: 'count', digits: input } }
  }

  // f/F/t/T → 进入 find 状态
  if (isFindKey(input)) {
    return { next: { type: 'find', find: input, count: 1 } }
  }

  // g → 进入 g 状态
  if (input === 'g') {
    return { next: { type: 'g', count: 1 } }
  }

  // i/a → 进入 INSERT 模式
  if (input === 'i' || input === 'a') {
    return { next: { mode: 'INSERT', insertedText: '' } }
  }

  // ...
}

9. 持久状态:寄存器与 . 重复

Vim 的"记忆"通过 PersistentState 体现:

// types.ts
export type PersistentState = {
  lastChange: RecordedChange | null    // 最近一次修改(用于 . 重复)
  lastFind: { type: FindType; char: string } | null  // ; 和 , 的搜索目标
  register: string                     // 寄存器内容(d/y/p 使用)
  registerIsLinewise: boolean          // 是否为行级操作
}

Dot Repeat(. 命令)

. 命令是 Vim 最强大的功能之一——重复上次修改。实现方式:

// operators.ts
export type RecordedChange =
  | { type: 'insert'; text: string }
  | { type: 'operator'; op: Operator; motion: string; count: number }
  | { type: 'operatorTextObj'; op: Operator; objType: string; scope: TextObjScope; count: number }
  | { type: 'operatorFind'; op: Operator; find: FindType; char: string; count: number }
  | { type: 'replace'; char: string; count: number }
  // ...

每次修改都记录一个 RecordedChange。执行 . 时,回放这个记录:

function repeatLastChange(ctx: OperatorContext): void {
  const change = ctx.getLastChange()
  if (!change) return

  switch (change.type) {
    case 'insert':
      ctx.setOffset(ctx.cursor.offset + change.text.length)
      ctx.setText(insertText(ctx.text, ctx.cursor.offset, change.text))
      break
    case 'operator':
      executeOperatorMotion(change.op, change.motion, change.count, ctx)
      break
    // ...
  }
}

10. 输入历史与命令补全

10.1 Shell 历史补全

Cursor.ts 实现了类似 Emacs 的 kill-ring(剪切环):

// Cursor.ts
const KILL_RING_MAX_SIZE = 10
let killRing: string[] = []

// 连续删除累积到 kill ring
export function pushToKillRing(
  text: string,
  direction: 'prepend' | 'append' = 'append',
): void {
  if (text.length > 0) {
    if (lastActionWasKill && killRing.length > 0) {
      // 与最近一次 kill 合并
      killRing[0] = direction === 'prepend'
        ? text + killRing[0]
        : killRing[0] + text
    } else {
      killRing.unshift(text)  // 新条目入栈
      if (killRing.length > KILL_RING_MAX_SIZE) killRing.pop()
    }
    lastActionWasKill = true
  }
}

// Alt+Y 在 kill ring 中循环(yank-pop)
export function getKillRingItem(index: number): string {
  const normalizedIndex =
    ((index % killRing.length) + killRing.length) % killRing.length
  return killRing[normalizedIndex] ?? ''
}

10.2 命令模糊搜索(Fuse.js)

命令建议使用 Fuse.js 实现模糊匹配:

// suggestions/commandSuggestions.ts
const fuse = new Fuse(commandData, {
  includeScore: true,
  threshold: 0.3,         // 相对严格的匹配
  location: 0,            // 优先匹配字符串开头
  distance: 100,           // 允许在描述中匹配
  keys: [
    { name: 'commandName', weight: 3 },    // 命令名权重最高
    { name: 'partKey', weight: 2 },        // 驼峰分词
    { name: 'aliasKey', weight: 2 },        // 别名
    { name: 'descriptionKey', weight: 0.5 }, // 描述权重最低
  ],
})

// 输入 "inc" → 匹配 ["/incremental", "invalidate-cache"]
// 输入 "sug" → 匹配 ["suggestions:..."]

10.3 目录自动补全

// suggestions/directoryCompletion.ts
// 根据当前光标前的路径,实时列出匹配的目录/文件

11. 总结:Vim 模式的架构亮点

设计 价值
状态机类型化 TypeScript discriminated union 确保穷举处理
纯函数 Motions motions.ts 无副作用,测试简单,可组合
操作上下文注入 OperatorContext 包含所有副作用,逻辑清晰
RecordedChange 统一的变更记录格式支持 . 重复
Kill Ring 全局剪切环支持 Alt+Y 循环
Fuse.js 模糊搜索 命令补全支持任意子串匹配
ANSI 多协议支持 CSI u + modifyOtherKeys 双协议兼容各种终端

源码速查表

文件 行数 职责
ink/parse-keypress.ts 801 ANSI 转义序列解析、鼠标事件
vim/types.ts 199 状态机类型定义(核心文档)
vim/transitions.ts 490 状态转换函数
vim/motions.ts 82 光标移动(纯函数)
vim/operators.ts 556 操作符执行逻辑
vim/textObjects.ts 186 文本对象边界查找
utils/Cursor.ts 1530 光标操作、kill-ring、Emacs 风格编辑
utils/suggestions/commandSuggestions.ts 567 Fuse.js 命令模糊搜索
utils/suggestions/directoryCompletion.ts 目录/文件路径补全
utils/suggestions/shellHistoryCompletion.ts Shell 历史补全

下一篇预告:将深入 工具系统:40+ 工具的注册与调用机制,解析 tools/ 目录的核心架构,包括工具基类设计、schema 生成、工具发现与生命周期管理。

前端监控体系与实践:从错误上报到内存与 GC 观测

前端监控的目标,是把「用户侧真实体验」和「线上可观测性」连起来:出了问题能第一时间知道、能定位到版本与路径、能量化影响面,而不是依赖用户截图或口头描述。本文先梳理常见监控维度,再用一个基于 WeakRefFinalizationRegistryGC 监控工具 举例,说明如何把「疑似内存泄漏」从感觉变成可上报的信号。


1. 为什么要做前端监控

  • 错误与稳定性:未捕获异常、资源加载失败、接口 4xx/5xx、白屏,直接影响转化与留存。
  • 性能(RUM):首屏、可交互时间、长任务、INP 等,决定「卡不卡」的主观感受。
  • 业务与行为:关键漏斗、按钮曝光点击、实验分流,需要与技术指标同一条时间线对齐。
  • 安全与合规:CSP 违规、异常脚本注入等,有时也要在前端侧留痕。

没有监控时,团队往往在「复现难、归因慢、不知道影响多大」之间消耗;有监控后,可以把问题收敛到:哪次发布、哪条路由、哪类设备

1.1 典型场景:问题只在线上、本地怎么都复现不了

这类情况很常见,监控的意义就在于把「用户环境里的差异」变成可查的数据,而不是依赖你在本机再点一百遍。

虚构但贴近真实的一例:

某后台列表页带「侧滑详情抽屉」。客服反馈:安卓手机用一上午后,列表滚动越来越卡,偶发白屏;你用自己的电脑 Chrome 开着 DevTools 点了一下午,内存曲线平稳、Performance 里也没有明显长任务——本地就是复现不了。

为什么线上和本地会「长得不一样」:

维度 本地开发 线上用户侧
数据量 造数几条、几十条 真实账号上万条、分页反复加载
使用路径 点几条最短路径 反复打开/关闭抽屉、切 tab、退回列表
设备与内存 高配 PC、内存充裕 中低端机、系统杀进程压力大,GC 更频繁
网络与缓存 localhost / 企业内网 弱网、CDN 命中差异、接口偶发慢导致重试堆积
运行时长 每次刷新从零开始 单页长时间不刷新,泄漏是「攒出来」的

这时监控能帮你做什么(和本文 GC 示例如何接上):

  1. RUM / 自定义性能:按「路由 + 版本号」看 INP、长任务次数;若只有「列表+抽屉」这条路径在低端机上飙升,至少知道战场在哪
  2. 面包屑与会话:还原用户操作序列(进了多少次详情、是否总不关抽屉),本地往往不会按这种强度操作。
  3. 灰度打开 GCMonitor 一类工具(低采样、仅特定路由):对「抽屉根节点」在 onUnmounted 之后做延迟存活检查;若线上大量出现「某组件 id 已卸载仍长期存活」的上报,而本地没有——说明强引用链或全局缓存与用户数据规模、打开次数耦合,这就把「无法复现」收窄成可统计的线上特征,再回到代码里查事件监听、全局 Map、单例缓存是否按页面卸载清理。

结论:本地复现不了 ≠ 问题不存在;用线上监控把环境、路径、版本、设备拉齐,再配合有针对性的轻量探针(如 GC 观测),才能把「玄学问题」变成可修的工单。


2. 常见监控分层(你可以按优先级落地)

层级 典型内容 常见载体
采集 全局 error / unhandledrejection、路由变化、性能条目(PerformanceObserver) SDK 或自研脚本
传输 sendBeacon、批量队列、失败重试 网关 / 同域 API
存储与查询 日志索引、TraceId、用户/会话维度 ELK、ClickHouse、厂商后台
告警与工单 阈值、同比、影响用户数 PagerDuty、企业微信等

实践建议:先做「全局错误 + 基础 RUM(导航、LCP 等)」,再按业务补自定义事件;自定义越多,越要在 SDK 里做采样与体积控制,避免拖慢主线程。


3. 性能与内存:RUM 之外的「泄漏」怎么抓

性能监控里,内存问题相对难:堆快照适合线下深挖,线上则更适合:

  • Chrome Memory Pressure API(若可用)与 Performance.measureUserAgentSpecificMemory(需隔离上下文等,使用面有限);
  • 定期观察 JSHeapSizeLimit 相关指标(粗粒度);
  • 结合业务生命周期:路由离开、弹窗关闭后,相关 DOM/闭包是否仍被强引用。

下面示例走另一条路:用语言特性观察「对象是否已被 GC 回收」,适合在开发/灰度阶段对「组件卸载后是否仍被挂住」做自动化怀疑。


4. 示例:GCMonitor——用 WeakRef + FinalizationRegistry 观测回收

4.1 思路说明

  • WeakRef:对目标对象是弱引用,不会阻止 GC;deref() 在对象仍存活时返回引用,否则返回 undefined
  • FinalizationRegistry:当注册的对象被回收时,会异步调用你提供的回调(不要假设回调的精确时机,只把它当作「已回收」的信号之一)。

组合起来可以:

  1. monitor(obj, id):注册监控,记录开始时间;
  2. 回收发生时:在 registry 回调里打日志、算存活时长、可上报监控平台;
  3. 兜底:一段时间后 checkAlive,若 deref() 仍有值,说明对象仍被强引用链挂住,疑似泄漏(也可能是用户仍停留在该页、或正常仍需要该节点——所以要配合「组件已卸载」等业务语义)。

4.2 完整示例代码(utils/gcMonitor.js

// utils/gcMonitor.js
class GCMonitor {
  constructor() {
    this.refs = new Map() // id → { weakRef, timestamp }
    this.registry = new FinalizationRegistry((id) => {
      const info = this.refs.get(id)
      if (info) {
        const duration = Date.now() - info.timestamp
        console.log(`[GC] ✅ 组件 ${id} 已被回收,存活时长:${duration}ms`)
        this.refs.delete(id)
      }
    })
  }

  /**
   * 监控一个 DOM 节点(或任意对象)
   * @param {object} obj - 要监控的对象(通常是组件的根 DOM 元素)
   * @param {string} id - 唯一标识(推荐格式:组件名_路由_时间戳)
   */
  monitor(obj, id) {
    if (this.refs.has(id)) {
      console.warn(`[GCMonitor] 组件 ${id} 已存在监控,跳过`)
      return
    }

    const weakRef = new WeakRef(obj)
    this.refs.set(id, {
      weakRef,
      timestamp: Date.now(),
    })
    this.registry.register(obj, id)

    // 延迟 5 秒后主动检查是否还活着(兜底检测)
    setTimeout(() => this.checkAlive(id), 5000)
  }

  /**
   * 主动检查某个对象是否已被回收
   * @param {string} id
   * @returns {boolean} true=还活着,false=已回收
   */
  checkAlive(id) {
    const info = this.refs.get(id)
    if (!info) return false // 已被 FinalizationRegistry 清理

    const obj = info.weakRef.deref()
    if (obj) {
      console.error(
        `[GCMonitor] 🚨 疑似泄漏:组件 ${id}${
          Date.now() - info.timestamp
        }ms 后仍然存活!`
      )
      // 可上报到监控平台
      // window.__SENTRY__?.captureMessage(`内存泄漏疑似: ${id}`)
      return true
    } else {
      console.log(`[GCMonitor] 组件 ${id} 已被回收(主动检测到)`)
      this.refs.delete(id)
      return false
    }
  }

  /**
   * 记录组件销毁次数(用于统计泄漏率)
   * @param {string} id
   */
  recordDestroy(id) {
    console.log(`[组件销毁] ${id} 已从 DOM 树移除,等待 GC 验证`)
    // 可以扩展:将 id 存入一个 Set,后续对比 GC 回调数量
  }

  /**
   * 获取所有仍存活的监控对象 ID(调试用)
   */
  getAliveIds() {
    const alive = []
    for (const [id, info] of this.refs.entries()) {
      if (info.weakRef.deref()) {
        alive.push(id)
      }
    }
    return alive
  }
}

// 导出全局单例
export default new GCMonitor()

4.3 在组件里怎么用(示意)

要点:在「挂载完成、能拿到根 DOM」时 monitor;在「卸载钩子」里调用 recordDestroy(可选),并把 id 设计成可区分路由与实例。

import gcMonitor from '@/utils/gcMonitor'

const id = `UserCard_/users/${userId}_${Date.now()}`

onMounted(() => {
  const el = rootRef.value // 或 this.$el
  if (el) gcMonitor.monitor(el, id)
})

onUnmounted(() => {
  gcMonitor.recordDestroy(id)
})

4.4 使用时的注意点(避免误报)

  1. FinalizationRegistry 回调是异步且不确定时序的,不能与「同步卸载」画等号;checkAlive 的 5 秒只是示例,长生命周期页面要适当延长或多次采样。
  2. 若组件仍在当前路由或仍挂在树上deref() 一直非空是正常现象,不是泄漏。
  3. 生产环境建议:仅在灰度/调试开关打开时启用;上报时用采样率,避免刷屏。
  4. 兼容性:需较新的 JS 引擎;老旧 WebView 需自行降级或关闭该能力。

4.5 Vue2 示例:在路由切换中「批量」触发泄漏怀疑检查

很多泄漏不是单个组件的问题,而是「某个路由反复进出」才会逐渐堆积。一个很实用的做法是:

  • 组件卸载时登记 id(说明它“应该消失了”)
  • 路由切走后统一延迟检查:对离开的路由里登记过的全部 id 调用 checkAlive,一次切换跑一批,便于统计与上报

下面给出一个 Vue2 + Vue Router 的示例,核心是一个小插件 + 一个 mixin(或基类组件)。

4.5.1 路由批量调度器(utils/gcRouteBatch.js

// utils/gcRouteBatch.js
import gcMonitor from '@/utils/gcMonitor'

/**
 * 在路由切换时,对「离开的路由」里登记过的组件 id 批量触发 checkAlive。
 * - 只负责调度,不负责采集 DOM(DOM 由组件自己 monitor)
 * - 建议仅在灰度/调试开关下启用,并控制采样
 */
export function setupGCRouteBatch(router, options = {}) {
  const {
    enabled = true,
    delayMs = 8000, // 给 GC 留出时间窗口;可根据页面复杂度调大
    sampleRate = 0.1, // 线上建议采样
  } = options

  if (!enabled) return { trackDestroyed: () => {} }

  // routeKey → Set<id>
  const destroyedByRoute = new Map()

  const keyOf = (route) => {
    const name = route && route.name ? route.name : 'noname'
    const path = route && route.path ? route.path : ''
    const fullPath = route && route.fullPath ? route.fullPath : ''
    return `${name}|${path}|${fullPath}`
  }

  function shouldSample() {
    return Math.random() < sampleRate
  }

  function trackDestroyed(route, id) {
    const k = keyOf(route)
    let set = destroyedByRoute.get(k)
    if (!set) {
      set = new Set()
      destroyedByRoute.set(k, set)
    }
    set.add(id)
  }

  router.afterEach((to, from) => {
    if (!from) return
    if (!shouldSample()) return

    const fromKey = keyOf(from)
    const ids = destroyedByRoute.get(fromKey)
    if (!ids || ids.size === 0) return

    // 路由离开后,延迟批量检查:还活着 → 疑似泄漏
    setTimeout(() => {
      for (const id of ids) gcMonitor.checkAlive(id)
      destroyedByRoute.delete(fromKey)
    }, delayMs)
  })

  return { trackDestroyed }
}

4.5.2 组件侧统一接入(Vue2 mixin 示例)

组件侧做两件事:

  • mounted:拿到根 DOM 后 monitor(el, id)
  • beforeDestroyrecordDestroy(id) + 把 id 交给路由批量调度器(归到当前路由)
import gcMonitor from '@/utils/gcMonitor'

// mixins/gcTrackMixin.js
export function createGCTrackMixin(options = {}) {
  const { componentName, getRootEl, trackDestroyed } = options

  return {
    data() {
      const route = this.$route
      const name = componentName || this.$options.name || 'AnonymousComponent'
      const fullPath = route && route.fullPath ? route.fullPath : 'noroute'
      return {
        __gc_track_id__: `${name}_${fullPath}_${Date.now()}_${this._uid}`,
      }
    },
    mounted() {
      const el = getRootEl ? getRootEl.call(this) : this.$el
      if (el) gcMonitor.monitor(el, this.__gc_track_id__)
    },
    beforeDestroy() {
      gcMonitor.recordDestroy(this.__gc_track_id__)
      if (typeof trackDestroyed === 'function') {
        trackDestroyed(this.$route, this.__gc_track_id__)
      }
    },
  }
}

4.5.3 在应用入口启用(main.js

import Vue from 'vue'
import router from './router'
import { setupGCRouteBatch } from '@/utils/gcRouteBatch'

// 建议:仅在灰度/调试环境开启,或受开关控制
const { trackDestroyed } = setupGCRouteBatch(router, {
  enabled: true,
  delayMs: 8000,
  sampleRate: 0.1,
})

// 挂到全局,组件里可通过 this.$gcTrackDestroyed 调用
Vue.prototype.$gcTrackDestroyed = trackDestroyed

4.5.4 在组件中使用(示意)

方式 A:直接在组件里写(最直观)

import gcMonitor from '@/utils/gcMonitor'

export default {
  name: 'UserDrawer',
  mounted() {
    this.__gcId = `UserDrawer_${this.$route.fullPath}_${Date.now()}_${this._uid}`
    gcMonitor.monitor(this.$el, this.__gcId)
  },
  beforeDestroy() {
    gcMonitor.recordDestroy(this.__gcId)
    this.$gcTrackDestroyed && this.$gcTrackDestroyed(this.$route, this.__gcId)
  },
}

方式 B:用 mixin 复用(更适合大规模接入)

import { createGCTrackMixin } from '@/mixins/gcTrackMixin'

export default {
  name: 'UserDrawer',
  mixins: [
    createGCTrackMixin({
      componentName: 'UserDrawer',
      getRootEl() {
        return this.$el // 或者 return this.$refs.rootEl
      },
      trackDestroyed(route, id) {
        this.$gcTrackDestroyed && this.$gcTrackDestroyed(route, id)
      },
    }),
  ],
}

这种写法的好处是:你不需要在每个组件里手动 setTimeout(checkAlive)路由切走就是天然的批处理时机,也便于在监控平台按「from 路由」聚合统计疑似泄漏率。


5. 与「传统监控」如何配合

  • 错误监控(Sentry、自研等):堆栈 + Release + SourceMap,解决「哪行代码炸了」。
  • RUM:LCP、FID/INP、CLS、TTFB,解决「慢在哪里」。
  • 本文 GC 示例:偏向「卸载后的对象是否仍活着」,解决「是不是被挂住了」这一类内存侧怀疑

三者互补:错误告诉你异常路径,性能告诉你主线程与资源,GC 监控在合适场景下帮你缩小泄漏排查的搜索范围。


6. 小结

前端监控的本质是用统一管道把线上信号送回来:从全局错误与 RUM 打底,到业务自定义事件,再到像 GCMonitor 这样针对特定问题的轻量工具。尤其当问题呈现为仅线上、长路径、弱设备才暴露(见上文 1.1 节)时,没有监控几乎只能猜。WeakRefFinalizationRegistry 让我们能用较少侵入的方式观察回收行为;真正落地时,务必结合路由/挂载语义、采样与兼容性,把「疑似泄漏」变成可行动的工单,而不是控制台噪音。


参考与延伸阅读

立正请站好:一个组件复用 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 落地:从模型博弈到工程化的范式转移|得物技术

文 /魏无涯

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

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

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

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比数平台功能介绍及实现原理|得物技术

文 /寇森

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

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

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

❌