阅读视图

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

一天时间,用 Claude Code 蹬了一个 v0 出来(附源码)

最近,出于业务需要,参考 v0 的实现,蹬了一个类似 v0 的平台出来。

先看效果:

整体采用 Next.js 做前后端服务,E2B 提供沙箱,Claude Agent SDK 完成代码生成,沙箱提供预览和代码推送部署能力。

ps: 本文不会包含任何的代码(本身也都是 AI 生成的),只会介绍相关方案的选型、核心的架构和实现原理。同时关于部署的环节,各个公司都有自己的部署流水线,并不具备参考价值,会弱化这个环节的介绍。

方案对比和设计

AI 生成前端代码,一般有这么几种方式:一份 html,一份代码块,以及直接生成项目。

生成 html

生成一份 html,然后增删改查,最终存储 html 即可,不论是预览还是部署,都最为简单。

有很多产品都是这么做的,比如 Claude 的 Artifacts,Google 的 Stitch。

这是最简单,也最轻便的方案。

这里面的关键技术点有几个:

  1. 如何让 AI 生成高质量的 HTML?当然这也无非就是需要一些非常优秀的提示词来约束 AI 的行为。

  2. 如何增量修改?通过在浏览器侧实现一个支持局部替换的 Edit Tool 即可,这也是很多 cli 工具在本地修改代码的常见策略。

  3. 后期的可维护性是这个方案最大的隐患。生成的 HTML 往往是一个几百行甚至上千行的单文件,没有组件拆分,没有模块化,样式和逻辑全部混在一起。如果需要人工介入修改,多年程序员看到这样的代码,大概会有一种被拉回刀耕火种时代的感觉——能改,但很痛苦。这也意味着,一旦走上这条路,后续的迭代就只能继续依赖 AI,项目实际上已经不再适合人来维护。

ps: 这里可能会有人好奇,为什么不是修改某一行某几列的代码,这是因为 AI 对于行号识别不准确,反而直接执行字符搜索并替换更为准确。感兴趣的可以查看 pi-mono 项目中 edit 工具的实现,这也是绝大部分 cli 工具的实现方案。

至于 html 的预览和部署,可谓是极为简单且花费最少了。

生成代码块

另一种方式是:生成代码块,存储在数据库中,预览采用 WebContainer、Sandpack,或者通过 Babel 转 CommonJS 在浏览器端模拟打包等方式来预览前端项目。

这基本是纯前端的方案,不过 WebContainer 要授权,Sandpack 倒是开源,但是加载速度上可能存在一些问题。至于 Babel 转 CommonJS 自行实现编译系统,也是 ok 的,只是要支持 jsx, vue, 要花一点时间,开发的工作量不小。

当然,除了这些建设,如何稳定 AI 的输出,也是这个方案中的一大问题,理想情况下,希望 AI 的产物是 文件名 + 内容 组合成的 json 数组。

一般可以通过几个方案来解决:

  1. 换更好的模型
  2. 运用 XML 这样的提示词技巧,来让 AI 输出的更符合预期

但是这个方案有几个比较大的问题:

  1. 编译工程复杂度比较高
  2. 增量替换的方案,输出格式可能不如工具调用那般精准,在耗时和质量上会更低效一点。
  3. 对于外部依赖的包,需要提前做编译、告知 AI 用法等,相对不那么自由

直接生成项目

直接生成项目,最终预览和部署都和普通的项目一样。这也是 v0 的方案。

这个方案本质上是给用户准备一个沙箱,这个沙箱中,直接启动一个 claude code 或者 codex 这样的工具,可以是 cli 也可以是 sdk。

同时指定一个工作目录,最终的项目生成和运行,都发生在这个工作目录下。用户输入直接指向 claude code,从而完成项目的生成。

这个方案的灵活度最高,同时由于背后是最顶尖的 AI 生成工具,所以在质量上和效率上,其实都不太需要担心。

但是最大的问题就在于需要给每一个用户都提供一个沙箱,对于运维部署的能力要求比较高。

同时沙箱的内存分配和 cpu 分配,资源上也不能少。

不过好在已经有很多服务商提供这样的服务,比如 E2B、Cloudflare 等服务商。付费调 API 的话,准备一个沙箱也很容易。

对比表格

维度 生成 HTML 生成代码块 直接生成项目
实现复杂度
预览方案 直接渲染 iframe WebContainer / Sandpack / Babel 转 CommonJS 沙箱内启动 dev server
部署复杂度 极低,存 HTML 即可 低,纯前端方案 高,需要为每个用户分配沙箱
增量修改精准度 高(字符串 Edit Tool) 中(输出格式不如工具调用稳定) 高(Agent SDK 原生工具调用)
AI 输出稳定性 高(单文件,约束简单) 中(需要结构化 JSON 输出,依赖提示词技巧) 高(由 Agent 工具链保证)
外部依赖支持 弱(只能用 CDN 引入) 弱(需要提前编译、告知 AI 用法) 强(npm install 自由安装)
代码可维护性 低(不适合人工维护) 高(标准项目结构)
资源消耗 极低 高(沙箱需要分配内存和 CPU)
灵活度
代表产品 Claude Artifacts、Google Stitch Bolt.new(基于 StackBlitz WebContainer) v0、本文实现

架构设计

整体的架构图如上,分为三块:

  1. Next.js 前端:聊天输入框、消息流展示、代码文件树、实时预览 iframe,以及打断/重试等交互控制。

  2. Next.js 后端:接收前端消息,维护会话与沙箱的映射关系,将消息转发给对应沙箱内的 Agent,并将 Agent 的流式输出透传回前端。

  3. E2B 沙箱:基于自定义模板启动,模板内预装了 Node.js 环境和项目脚手架。沙箱内运行 Claude Agent SDK,负责代码的生成与修改;同时启动 dev server 并通过 E2B 的端口暴露能力对外提供预览。

消息流转

用户操作路径如下:

  1. 用户打开平台,发起第一条消息,后端按需创建 E2B 沙箱(冷启动约需几秒)
  2. 沙箱就绪后,后端将消息投递给沙箱内的 Claude Agent SDK
  3. Agent SDK 开始工作:调用文件读写工具生成或修改代码
  4. Agent 的输出以流式事件的形式,经后端透传回前端实时展示
  5. 代码变更同步到文件树,预览 iframe 直接加载沙箱暴露的端口

会话与沙箱管理

多用户场景下,每个会话对应一个独立的沙箱实例,隔离性天然满足。

上下文的维护完全交给 Agent SDK,后端只需持久化"会话 ID → 沙箱 ID"的映射即可。考虑到沙箱有闲置超时机制,需要在映射层做好沙箱的重建和恢复逻辑,一般沙箱的服务方基本都会内置这些能力。

部署发布

代码的部署和发布,一个比较通用的方案是在沙箱内完成 Git 提交,推送到远程仓库后触发 CI/CD 流水线,从而完成项目的上线。由于这部分强依赖各公司自身的发布体系,本文不展开。

整体来讲技术卡点并不多。最核心的 AI 代码生成能力,借助 Agent SDK 即可完成,质量和直接使用 Claude Code 打平。沙箱管理和前端页面反而是 AI 最擅长的部分,蹬起来毫无压力。

心得体会

整体蹬一个 v0,让 AI 写代码花费的时间其实并不多,大概一天左右就能蹬出来。

但是有一说一,这个方案,其实来来回回跟 AI 拉扯了几天,大到从生成 HTML,到生成片段代码,再到最后的沙箱方案,而小到增量更新的解决方案,Babel 转义的优劣,都属于考量的范畴。

包括是用 Agent SDK,还是直接用 Claude CLI,也是经过多方权衡后的结果。

一切方案落定,Plan Mode 开启,Opus 一开,反而是最轻松的时刻。

基本上第一次的产物,就能达到最小 demo 的效果。

至于交互上的细节,比如打断输入,补充说明,向用户提问明确需求,这些细节上的打磨,也是花点心思就能解决的地方。

整体来讲,在没有 AI 介入之前,其实是不太能这么快完成这样一个系统的。单单是沙箱方案的选型,可能都要花费个几天,比如沙箱的暂停和恢复,费用的对比等等,也是 AI 辅助决策的结果,有了决策,实现又是几天,确确实实在效率上提升非常大。

在这个过程中,我本身也是直接退订了 Cursor,因为完全不需要自己再上手手动修代码了,单说执行这块,AI 绝对是夯爆了。

很难说不焦虑,但又感觉不必太过焦虑。这次最大的体感不是"AI 写代码很快",而是整个过程中,花时间最多的地方依然是人在做的事——判断方案的取舍,理解各种工具的边界,决定什么值得做、什么可以砍掉。执行层 AI 确实夯爆了,但执行之前的那些决策,AI 只是参谋,拍板的还得是人。

所以与其焦虑被替代,不如想清楚自己在一件事里到底在做什么。毕竟 AI 还是得有人蹬,至于蹬到哪里去,这个问题 AI 替你答不了。

源码

本文的 POC(Proof of Concept,概念验证)代码已开源,即用最小的实现跑通"用户输入 → Agent 生成代码 → 沙箱预览"这条核心流程,感兴趣的可以查看:github.com/yuzai/code-…

Claude Code 提示词缓存与系统提示词分段架构

摘要

本文基于 Claude Code 源码,分析其系统提示词的构建流程、分段策略与提示词缓存机制。重点考察 systemPromptSections 注册表、SYSTEM_PROMPT_DYNAMIC_BOUNDARY 静动态分界标记、splitSysPromptPrefix 三模式缓存切片算法,以及 CacheScope 两级缓存域的设计逻辑。同时讨论 MCP 工具接入对缓存策略的约束,以及消息级缓存断点的放置规则。


一、背景:提示词缓存的工程价值

大语言模型的推理成本在很大程度上由输入 token 决定。对于 Claude Code 这类交互式编程助手,每轮对话均携带完整的系统提示词,其长度通常在数千 token 以上。若每次请求都将系统提示词从头计算,成本极为可观。

Anthropic 提供了提示词缓存(Prompt Caching)能力,允许客户端在请求体中标记特定文本块为可缓存内容。服务端在满足条件时复用已有的 KV Cache,从而跳过对该段 token 的 prefill 计算。这一机制对系统提示词尤为有效——系统提示词跨会话高度稳定,是天然的缓存候选。

然而,"系统提示词"并非一个均质的整体。其内部既包含每次启动后不再变化的静态描述(工具说明、行为规范、环境信息),也包含随每轮请求动态更新的内容(当前会话特征、MCP 服务状态、功能开关读取结果)。若将两者混同处理,静态部分的缓存命中率将因动态部分的频繁变化而显著下降。

Claude Code 为此设计了一套分段架构,将系统提示词在构建阶段拆解为具有明确缓存语义的独立块,再按照不同的缓存域(globalorg)打上标注,最终映射到 Anthropic API 的 cache_control 字段。本文将逐层拆解这一过程。


二、Section 注册表:静态性的声明式标注

系统提示词的内容管理入口位于 constants/systemPromptSections.ts。该文件定义了一个轻量的 Section 注册机制,其核心数据类型如下:

type SystemPromptSection = {
  name: string
  compute: () => string | null | Promise<string | null>
  cacheBreak: boolean
}

字段语义清晰:name 用于调试追踪,compute 是实际的内容计算函数,cacheBreak 则是本文关注的关键字段——它标记该 section 是否具有跨轮次变化的语义,即是否应当成为缓存断点。

对应地,注册表提供两个构造函数:

export function systemPromptSection(name, compute): SystemPromptSection
// cacheBreak = false,内容在 session 内计算一次后被缓存

export function DANGEROUS_uncachedSystemPromptSection(name, compute, _reason): SystemPromptSection
// cacheBreak = true,每轮重新计算,名称前缀 DANGEROUS_ 是对调用者的显式警告

前者的实现依赖一个 session 级的 Map 做惰性缓存——首次调用时执行 compute(),后续调用直接返回缓存结果。后者则每次都调用 compute(),不做任何记忆。

DANGEROUS_uncachedSystemPromptSection 目前在代码中仅有一处实际应用:

DANGEROUS_uncachedSystemPromptSection(
  'mcp_instructions',
  () => getMcpInstructionsText(),
  'MCP servers connect/disconnect between turns'
)

注释明确说明了原因:MCP 服务器可能在任意两轮之间连接或断开,其生成的工具指令内容随时可能改变。若对其做 session 级缓存,将导致系统提示词与实际可用工具集不一致。_reason 参数不参与运行时逻辑,仅作为强制要求调用者说明理由的文档约束。

resolveSystemPromptSections 函数负责批量执行一组 section 的 compute,返回 (string | null)[],null 值表示该 section 在当前上下文下不适用(例如某些仅在特定模式下启用的功能模块)。


三、SystemPrompt 类型与构建流程

系统提示词在类型系统层面被定义为品牌化字符串数组:

// utils/systemPromptType.ts
type SystemPrompt = readonly string[] & { __brand: 'SystemPrompt' }

使用 TypeScript 的品牌类型(Branded Type)而非 string[] 的原因有两点:其一,防止将普通字符串数组误传至需要构建好的系统提示词的 API;其二,强调该数组的语义不是任意字符串序列,而是一个有序的提示词段列表,其内部顺序具有语义意义。

buildEffectiveSystemPrompt(位于 utils/systemPrompt.ts)负责确定最终使用哪个系统提示词序列。其逻辑遵循一条优先链:

  1. 若存在显式 override,直接使用 override 内容
  2. 若当前为协调者(coordinator)模式,使用协调者专用提示词
  3. 若当前为子 Agent 模式,根据配置决定替换还是追加
  4. 否则使用默认的 getSystemPrompt() 构建结果

无论走哪条路径,appendSystemPrompt 均在最后追加,这是用户自定义系统提示词的注入点。

getSystemPrompt() 是系统提示词的主体构建函数,位于 constants/prompts.ts。其结构决定了后续缓存分段的依据。


四、动态边界标记:SYSTEM_PROMPT_DYNAMIC_BOUNDARY

getSystemPrompt() 的返回值是一个 string[],其中大部分元素是通过 resolveSystemPromptSections 展平后的文本块。关键在于其中插入了一个特殊标记:

const SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'

该常量以双下划线包裹,在视觉上即可区分于普通文本内容。其在 getSystemPrompt() 中的插入逻辑如下:

return [
  ...staticSections,
  ...(shouldUseGlobalCacheScope() ? [SYSTEM_PROMPT_DYNAMIC_BOUNDARY] : []),
  ...dynamicSections,
]

边界标记只在 shouldUseGlobalCacheScope() 返回 true 时才被插入——这是因为该标记的语义依赖于全局缓存能力的存在。若当前 API 提供商不支持全局缓存,插入边界标记没有意义(后续的 splitSysPromptPrefix 也会因此走不同的分支)。

边界标记的作用是在字符串数组层面划定一条逻辑分界线:标记之前的内容被认定为静态内容(跨会话稳定,适合全局缓存),标记之后的内容被认定为动态内容(会话特定,不应跨组织共享缓存)。

代码注释中对"什么内容应当置于边界之后"有明确记录。以 getSessionSpecificGuidanceSection 为例,其注释写道该 section 被置于动态边界之后,原因是它需要读取以下运行时状态:

  • isForkSubagentEnabled() — 功能开关,从 GrowthBook 读取
  • getIsNonInteractiveSession() — 当前会话是否为非交互模式
  • 其他特性标志的当前值

这些值在不同会话、不同用户、不同时间点均可能不同,因而不可被全局缓存。


五、splitSysPromptPrefix:三模式缓存切片算法

splitSysPromptPrefix 是整个缓存架构的核心函数,位于 utils/api.ts。它接收由 getSystemPrompt() 产生的 string[],输出一个 SystemPromptBlock[]

type CacheScope = 'global' | 'org'
type SystemPromptBlock = { text: string; cacheScope: CacheScope | null }

cacheScope 的取值含义:

  • 'global':可跨组织缓存,适用于完全不含用户/组织特定信息的内容
  • 'org':仅在同一组织内缓存,适用于可能含有组织配置、用户偏好的内容
  • null:不参与缓存,内容每次均需全量计算

函数内部根据两个条件决定走哪个分支:

条件一:是否存在 MCP 工具(hasMcpTools条件二:是否启用全局缓存且提示词中包含边界标记(useGlobalCacheFeature && hasBoundary

三个分支的处理逻辑如下:

模式一:MCP 工具存在

attribution block   cacheScope: null
prefix blocks       cacheScope: 'org'
remaining blocks    cacheScope: 'org'

当 MCP 工具可用时,系统提示词中包含了由 MCP 服务器贡献的工具描述。MCP 工具的描述内容是每个用户、每个服务器连接独有的,带有明确的用户身份语义,不可被不同组织的用户共享。因此,所有块统一降级为 'org' 级别,完全放弃全局缓存。attribution 块(包含模型署名信息,undercover 场景下需要抹除)始终为 null,这一规则在三个模式中保持一致。

模式二:全局缓存启用且边界标记存在

attribution block    cacheScope: null
prefix blocks        cacheScope: null        (attribution 之后、boundary 之前)
static blocks        cacheScope: 'global'    (boundary 之后、动态内容之前)
dynamic blocks       cacheScope: null        (动态内容,不缓存)

这是三个模式中最精细的一个。函数在此模式下扫描 string[],找到 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 标记的位置,以此为界将内容分为两段。边界之前的静态内容被标记为 'global',可以跨组织共享缓存;边界之后的动态内容标记为 null,不参与缓存。

一个值得关注的细节是 prefix 块(attribution 之后、boundary 之前的早期块)被标记为 null 而非 global。这部分内容包括一些早期的初始化文本,其全局缓存适用性的判断较为保守,因此未被纳入 global 范围。

模式三:默认模式

attribution block   cacheScope: null
prefix blocks       cacheScope: 'org'
remaining blocks    cacheScope: 'org'

当全局缓存不可用且无 MCP 工具时,退回到最朴素的策略:所有内容使用 'org' 级别缓存,即在同一组织内复用。


六、shouldUseGlobalCacheScope:全局缓存的启用条件

全局缓存并非对所有 API 提供商开放。shouldUseGlobalCacheScope() 的实现位于 utils/betas.ts

export function shouldUseGlobalCacheScope(): boolean {
  return getAPIProvider() === 'firstParty' &&
    !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)
}

函数返回 true 需要同时满足两个条件:

  1. API 提供商为 firstParty,即直接使用 Anthropic 官方 API,而非通过 Amazon Bedrock、Google Vertex AI 或 Anthropic Foundry 接入
  2. 未通过环境变量 CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS 禁用实验性 Beta 功能

全局缓存能力(cache_control 中的 scope: 'global')依赖一个 Beta API Header:

const PROMPT_CACHING_SCOPE_BETA_HEADER = 'prompt-caching-2025-04-11'

此 Header 在启用全局缓存时被添加到请求的 betas 数组中。Bedrock、Vertex 等第三方部署路径目前不支持该 Beta 特性,因此相应的代码分支被直接跳过。


七、getCacheControl 与 TTL 策略

确定了每个块的 cacheScope 之后,buildSystemPromptBlocks 函数(位于 services/api/claude.ts)将其转换为实际的 TextBlockParam,并附加 cache_control 字段。getCacheControl 函数负责根据 scope 和查询来源生成具体的缓存控制对象:

function getCacheControl({ scope, querySource }): CacheControlEphemeralParam {
  return {
    type: 'ephemeral',
    ...(should1hCacheTTL(querySource) ? { ttl: '1h' } : {}),
    ...(scope === 'global' ? { scope: 'global' } : {}),
  }
}

标准 TTL 为 5 分钟(Anthropic API 默认值),1 小时 TTL 通过 should1hCacheTTL() 决定是否启用。该函数的判断条件包括:

  • 当前用户为 Anthropic 内部用户(ant 用户类型)
  • 或者,当前用户为付费订阅者且未处于超额状态(non-overaged subscriber)

should1hCacheTTL() 的结果在 bootstrap 阶段被锁定,保存在全局状态中,整个 session 内保持不变。这样做的目的是防止订阅状态在 session 中途发生变化时导致缓存 TTL 频繁切换,进而引发缓存失效。

GrowthBook 功能开关系统(A/B 测试框架)用于管理 1h TTL 的灰度开放。代码中对 GrowthBook allowlist 的匹配使用了尾部通配符(trailing-* matching),支持按前缀匹配用户标识,便于按组织或用户群体进行分级灰度。


八、消息级缓存断点

除系统提示词外,Claude Code 还在消息历史中设置缓存断点。规则较为简单:在每轮请求时,将 cache_control 放置在最后一条用户消息和最后一条助手消息各自的最后一个内容块上。

这一做法的逻辑依据是:Anthropic API 的提示词缓存以"最后一个带 cache_control 标记的 token 位置"为缓存边界。在消息历史的末尾打断点,意味着下一轮请求时,历史消息部分可以被命中缓存,只有新增的用户输入需要重新 prefill。

对于多模态内容(图片、文件附件等),缓存断点同样放置在最后一个块上,不区分内容类型。对于空内容块或仅含工具结果的消息,缓存断点的放置逻辑有额外的边界处理。


九、工具 Schema 缓存稳定性

系统提示词缓存的一个潜在破坏因素是工具 Schema 的变化。Claude Code 集成了大量工具(文件操作、Bash 执行、代码搜索等),每个工具都有对应的 JSON Schema 描述,这些描述作为独立的 tool 块随请求发送。

若工具 Schema 在每次请求时都重新生成,即便内容不变,序列化后的字符串可能因字段顺序、空白符等细微差异而产生不同的 token 序列,导致缓存 miss。

toolSchemaCache.ts 通过在 session 级别缓存工具 Schema 的序列化结果来规避这一问题。GrowthBook 功能开关的翻转(A/B 测试分组变化)可能导致某些工具的可用性发生变化,进而引发 Schema 集合的变化。工具 Schema 缓存对此做了特殊处理,确保在 session 内工具集稳定的前提下,Schema 的序列化结果保持确定性,从而维持缓存的持续命中。


十、MCP 工具与全局缓存的冲突

MCP(Model Context Protocol)工具接入是全局缓存策略中最重要的约束来源。分析其不兼容的根本原因,需要理解两个层面:

语义层面:MCP 工具由用户在本地配置的服务器提供,其工具描述、参数 Schema、行为规范均带有强烈的用户/组织特异性。将含有此类内容的提示词缓存在全局(跨组织)层面,理论上存在信息泄露风险——不同组织的用户可能通过缓存命中间接获知他人的 MCP 工具配置信息。

稳定性层面:MCP 服务器连接在会话内是动态的,工具列表随时可能增减。即使退而求其次使用 org 级缓存,MCP 工具的高变动性也使缓存命中率受限。DANGEROUS_uncachedSystemPromptSection 对 MCP 指令的处理(每轮重新计算)正是对这一特性的响应。

splitSysPromptPrefix 的实现中,MCP 工具存在时的处理逻辑(模式一)完全绕过了全局缓存路径,即便 shouldUseGlobalCacheScope() 返回 true,只要检测到 MCP 工具存在,就立即降级为 org 级别。代码中对此有一处额外的检查:

needsToolBasedCacheMarker = useGlobalCacheFeature && 
  filteredTools.some(t => t.isMcp && !willDefer(t))

willDefer 表示该 MCP 工具被推迟加载(defer),尚未实际可用。仅当存在已加载且非推迟的 MCP 工具时,才真正触发 global→org 的降级逻辑。


十一、架构总结

以下是系统提示词从构建到最终发送的完整数据流:

getSystemPrompt()
  └── resolveSystemPromptSections([
        staticSection_1,       // systemPromptSection,session 内缓存
        staticSection_2,       // systemPromptSection,session 内缓存
        ...
        SYSTEM_PROMPT_DYNAMIC_BOUNDARY,   // 分界标记(仅 global cache 模式)
        dynamicSection_1,      // DANGEROUS_uncachedSystemPromptSection
        dynamicSection_2,      // systemPromptSection(但内容依赖运行时状态)
        ...
      ])
  └── string[]   (含边界标记)
        │
        ▼
  splitSysPromptPrefix(strings, { hasMcpTools, useGlobalCache })
  └── SystemPromptBlock[]
        { text: "...", cacheScope: 'global' | 'org' | null }
        │
        ▼
  buildSystemPromptBlocks(blocks, enablePromptCaching)
  └── TextBlockParam[]
        { type: 'text', text: "...", cache_control?: { type: 'ephemeral', ttl?, scope? } }
        │
        ▼
  Anthropic API Request

整个设计体现了一个核心原则:缓存策略的决策尽可能前置systemPromptSectionDANGEROUS_uncachedSystemPromptSection 在 section 定义时即声明其缓存语义,而非在最终构建阶段做动态判断。SYSTEM_PROMPT_DYNAMIC_BOUNDARY 作为数据平面的标记,将提示词数组的语义分区编码进数据本身,使 splitSysPromptPrefix 的逻辑保持相对简单。

这种"声明优于推断"的设计取向,降低了缓存策略与内容逻辑之间的耦合度——添加新 section 时,开发者只需在定义处决定是否使用 DANGEROUS_ 前缀,以及是否置于边界之前,而不需要理解整个 splitSysPromptPrefix 的切分逻辑。


附录:关键常量与函数索引

符号 文件 说明
systemPromptSection constants/systemPromptSections.ts 声明 session 内稳定的 section
DANGEROUS_uncachedSystemPromptSection constants/systemPromptSections.ts 声明每轮重计算的 section
SYSTEM_PROMPT_DYNAMIC_BOUNDARY constants/prompts.ts 静动态内容分界标记
splitSysPromptPrefix utils/api.ts 三模式缓存切片核心函数
CacheScope utils/api.ts `'global' 'org'` 缓存域类型
SystemPromptBlock utils/api.ts 带缓存语义的文本块类型
shouldUseGlobalCacheScope utils/betas.ts 全局缓存启用条件判断
getCacheControl services/api/claude.ts 生成 cache_control 对象
should1hCacheTTL services/api/claude.ts 1h TTL 扩展条件判断
buildSystemPromptBlocks services/api/claude.ts 将 block 转换为 API 参数
PROMPT_CACHING_SCOPE_BETA_HEADER services/api/claude.ts 全局缓存所需的 Beta Header
toolSchemaCache utils/toolSchemaCache.ts 工具 Schema 序列化稳定性缓存

前端架构实操:地铁出行系统高并发与性能优化全解析(二)

一、引言:从行业通用场景出发,理清高并发与性能优化的核心逻辑

作为前端备考软考架构师的伙伴,我们都清楚,中大型项目的核心挑战,从来不是 “实现功能”,而是 “扛住流量、保证体验”。

本篇继续围绕我了解到的地铁出行系统(中大型微服务项目),聚焦高并发与性能优化核心场景,完整拆解「项目场景→实际问题→思考过程→技术选型→解决方案→实施结果」,既梳理性能优化类技术体系,又还原项目实操逻辑,帮大家吃透技术落地思路,同时规避保密风险。


二、项目场景:地铁出行系统高并发与性能现状

结合行业通用地铁出行系统项目特点,该类项目的高并发与性能相关场景如下,为后续问题排查和技术选型奠定基础:

  1. 用户规模与流量特点:服务全市 500 万 + 用户,早晚高峰(7:00-9:00、17:00-19:00)为流量峰值,瞬时并发量可达平日的 5-8 倍,核心页面(实时到站、客流监控)需承载高并发请求;

  2. 架构现状:后端 6 个微服务(线路管理、实时到站、客流监控、用户管理、票务支付、站点设施管理)独立部署,前端采用 Vue3+Pinia 技术栈,已通过 BFF 层 + Nacos 解决接口与环境问题,但随着用户量增长,性能瓶颈逐步凸显;

  3. 部署环境:4 套环境(开发、测试、仿真、生产),后端服务需根据早晚高峰客流动态扩容 / 缩容,对系统弹性扩缩容能力要求极高。


三、项目痛点:高并发与性能优化中遇到的实际问题

在项目迭代过程中,随着用户量持续增长,早晚高峰时段系统出现了多个核心性能问题,严重影响用户体验和系统稳定性,具体如下:

1. 静态资源加载慢,页面首屏渲染超时

地铁出行系统包含大量静态资源(线路地图、站点图片、样式文件、JS 代码包),初期采用 “前端直连服务器” 的方式加载资源,遇到 3 个核心问题:

  • 资源分发效率低:静态资源存储在后端服务器,用户跨区域访问时,网络延迟高,首屏加载时间长达 8-10 秒,远超用户可接受的 3 秒阈值

  • 服务器带宽压力大:早晚高峰时,大量用户同时请求静态资源,后端服务器带宽被占满,导致接口请求延迟、页面加载失败;

  • 缓存策略不合理:未做合理的资源缓存配置,用户每次访问都重新加载全量资源,进一步加剧服务器压力。

2. 高并发场景下,服务扩容不及时,系统崩溃风险高

早晚高峰瞬时并发量激增,后端微服务(尤其是实时到站、票务支付服务)负载过高,出现以下问题:

  • 扩容响应慢:传统手动扩容方式,需运维人员手动部署服务器、配置服务,耗时长达 1-2 小时,无法应对突发流量高峰;

  • 服务稳定性差:服务过载时,出现接口超时、请求失败,甚至服务宕机,导致用户无法查询实时到站、无法购票,严重影响出行体验;

  • 资源浪费严重:平峰时段服务器负载低,手动缩容不及时,造成大量服务器资源闲置,运维成本陡增。

3. 大数据量页面渲染卡顿,用户交互体验差

客流监控、线路查询等页面,需展示大量实时数据(如全线路客流数据、历史到站记录),前端直接渲染全量数据,出现:

  • 页面渲染卡顿:大数据量渲染导致主线程阻塞,页面滚动、点击等交互操作延迟,甚至出现页面卡死;

  • 内存占用过高:全量数据加载导致浏览器内存占用飙升,部分低端设备出现闪退;

  • 数据更新不及时:实时数据频繁更新,未做合理的渲染优化,导致页面频繁重绘,进一步加剧卡顿。


四、思考过程:从问题出发,拆解破局思路

面对上述 3 个核心问题,相关开发团队没有盲目选型技术,而是从「提升用户体验、降低运维成本、增强系统稳定性」三个核心目标出发,逐步拆解思考,形成了清晰的破局思路:

针对 “静态资源加载慢” 问题的思考

核心需求:提升静态资源加载速度,降低服务器带宽压力,实现用户就近访问,优化首屏渲染体验。思考拆解

  1. 痛点本质:静态资源集中存储在后端服务器,用户跨区域访问延迟高,且未做缓存优化,导致服务器带宽压力大、首屏加载慢;

  2. 核心思路:引入内容分发网络(CDN) ,将静态资源缓存到全国各区域节点,用户就近访问节点资源,大幅降低网络延迟;同时优化资源缓存策略,减少重复请求;

  3. 技术选型考量:对比自建 CDN 与第三方商用 CDN—— 自建 CDN 部署成本高、维护难度大,不适合中大型项目;第三方商用 CDN(如阿里云 CDN、腾讯云 CDN)部署简单、节点覆盖广,能快速解决资源加载问题,因此确定选用 CDN 作为静态资源优化方案。

针对 “高并发服务扩容难” 问题的思考

核心需求:实现服务自动扩缩容,应对突发流量高峰,提升系统稳定性,同时降低运维成本,避免资源浪费。思考拆解

  1. 痛点本质:传统手动扩容 / 缩容方式,响应速度慢、效率低,无法适配地铁项目 “早晚高峰流量波动大” 的特点,且运维成本高;

  2. 核心思路:引入容器化编排工具,将后端微服务、BFF 层打包成容器,通过编排工具实现服务的自动部署、弹性扩缩容、故障自愈;

  3. 技术选型考量:对比 Docker+K8s(Kubernetes)与其他容器化方案 ——Docker 实现容器化打包,保证环境一致性;K8s 实现容器编排,支持自动扩缩容、服务治理,是行业内微服务容器化的标准方案,因此确定选用「Docker+K8s」作为容器化编排方案。

针对 “大数据量渲染卡顿” 问题的思考

核心需求:优化大数据量页面渲染性能,避免主线程阻塞,提升用户交互体验,降低浏览器内存占用。思考拆解

  1. 痛点本质:前端一次性加载并渲染全量数据,导致主线程阻塞、内存占用过高,页面交互卡顿;

  2. 核心思路:采用虚拟列表 + 懒加载技术,仅渲染可视区域内的数据,按需加载剩余数据,减少 DOM 节点数量,降低主线程压力;同时优化数据更新逻辑,避免频繁重绘;

  3. 技术选型考量:虚拟列表(如 vue-virtual-scroller)是前端大数据量渲染的通用优化方案,适配 Vue3 技术栈,无需额外引入复杂框架,开发成本低、优化效果显著,因此确定选用虚拟列表 + 懒加载作为渲染优化方案。

整体思考总结

最终形成「CDN+Docker+K8s + 虚拟列表」的技术栈组合,各技术针对性解决对应痛点:CDN 解决静态资源加载慢,Docker+K8s 解决高并发扩容难,虚拟列表解决大数据量渲染卡顿,形成完整的高并发与性能优化解决方案,贴合地铁出行系统的业务特点,同时符合软考架构师 “技术选型贴合项目需求” 的核心要求。


五、解决方案:CDN+Docker+K8s + 虚拟列表技术栈落地细节

结合上述行业通用思考思路,相关开发团队落地了完整的高并发与性能优化方案,每一项技术都严格贴合项目需求,具体落地细节如下:

1. CDN 落地:静态资源加速与缓存优化

  • 核心功能落地

    1. 资源分发:将地铁出行系统的所有静态资源(线路地图、站点图片、JS/CSS 代码包、字体文件)上传至 CDN,缓存到全国各区域节点,用户访问时,自动路由到最近的节点获取资源;

    2. 缓存策略优化:针对不同类型资源设置差异化缓存时间 —— 静态资源(图片、样式)设置 7 天缓存,JS 代码包设置 1 天缓存,同时配置版本号,避免缓存过期导致的资源更新不及时;

    3. 回源策略优化:设置 CDN 回源规则,仅在缓存过期时回源到后端服务器获取最新资源,减少服务器带宽压力;

  • 大白话理解:CDN 就像是 “全国连锁的资源便利店”,把静态资源提前放到用户家门口的便利店,用户不用再跑到后端服务器(总店)取资源,就近就能拿到,速度大幅提升,还能减轻总店的压力。

2. Docker+K8s 落地:容器化编排与自动扩缩容

  • 核心功能落地

    1. 容器化打包:将后端 6 个微服务、BFF 层分别打包成 Docker 镜像,保证开发、测试、仿真、生产环境的一致性,避免 “本地运行正常,线上报错” 的问题;

    2. K8s 集群部署:搭建 K8s 集群,部署所有容器化服务,配置服务发现、负载均衡,实现服务的自动部署与故障自愈;

    3. 自动扩缩容配置:基于 CPU 使用率、请求量设置 HPA(Horizontal Pod Autoscaler),早晚高峰流量激增时,自动扩容服务实例;平峰时段自动缩容,节省服务器资源;

    4. 运维自动化:通过 K8s 实现服务的一键部署、滚动更新,无需手动操作服务器,大幅降低运维成本;

  • 大白话理解:Docker 就像是 “集装箱”,把服务和运行环境打包成统一的集装箱,不管在哪都能正常运行;K8s 就像是 “智能调度中心”,自动管理这些集装箱,根据流量多少,自动增减集装箱数量,应对高峰、节省资源。

3. 虚拟列表 + 懒加载落地:大数据量渲染优化

  • 核心功能落地

    1. 虚拟列表实现:针对客流监控、线路查询等大数据量页面,引入 vue-virtual-scroller 组件,仅渲染可视区域内的 10-20 条数据,滚动时动态加载剩余数据,大幅减少 DOM 节点数量;

    2. 懒加载优化:图片、非首屏数据采用懒加载,仅当用户滚动到可视区域时,再加载对应资源,减少首屏加载时间;

    3. 渲染优化:优化数据更新逻辑,采用虚拟滚动 + 防抖处理,避免频繁重绘,保证页面交互流畅;

  • 大白话理解:虚拟列表就像是 “无限长的名单,但只给你看当前屏幕上的几行”,滚动时再替换内容,不用一次性渲染全部名单,页面自然不卡顿。

4. 技术协同落地:全链路优化逻辑

各技术并非独立使用,而是形成协同闭环,确保优化效果最大化:

  1. CDN 加速静态资源,减少首屏加载时间,降低服务器带宽压力,为 K8s 服务预留更多资源处理业务请求;

  2. Docker+K8s 实现服务自动扩缩容,应对 CDN 加速后带来的更高并发请求,保证系统稳定性;

  3. 虚拟列表优化前端渲染,配合 CDN 资源加速,共同提升用户体验,形成 “前端渲染 + 资源加速 + 后端扩容” 的全链路优化。


六、实施结果:问题解决成效与技术体系总结

方案落地后,相关开发团队对系统性能、用户体验、运维成本进行了统计,核心成效显著,同时梳理性能优化类技术体系,夯实备考基础:

1. 实施成效(量化呈现,贴合项目实际)

  • 静态资源加载优化:首屏加载时间从 8-10 秒缩短至 2-3 秒,用户访问成功率从 85% 提升至 99.5%,服务器带宽压力降低 70%;

  • 高并发扩容优化:服务扩容响应时间从 1-2 小时缩短至 5 分钟内,早晚高峰服务宕机率从 15% 降至 0,服务器资源利用率提升 60%,运维成本降低 50%;

  • 大数据量渲染优化:页面渲染卡顿率从 20% 降至 1% 以下,浏览器内存占用降低 40%,用户交互体验大幅提升;

  • 系统稳定性提升:全链路优化后,系统整体可用性从 99.2% 提升至 99.99%,完全满足地铁出行系统的高并发、高可用需求。

2. 性能优化技术体系梳理(融入技术体系化思路)

通过本次对行业通用地铁出行系统项目的梳理,总结出高并发与性能优化相关的技术体系,方便后续备考记忆和项目复用:

  1. 技术分类:本次落地的 CDN、Docker+K8s、虚拟列表,分属不同优化维度,覆盖全链路性能提升:

    • CDN:属于「静态资源加速技术」,核心解决资源加载慢、带宽压力大的问题,适配中大型项目的静态资源分发;

    • Docker+K8s:属于「容器化编排技术」,核心解决服务扩缩容、系统稳定性问题,适配微服务架构的高并发场景;

    • 虚拟列表 + 懒加载:属于「前端渲染优化技术」,核心解决大数据量渲染卡顿问题,适配前端大数据量页面场景;

  2. 技术选型逻辑:技术选型的核心是 “针对性解决痛点”,CDN 解决资源问题,Docker+K8s 解决扩容问题,虚拟列表解决渲染问题,三者协同形成全链路优化,这也是架构设计的核心思路;

  3. 备考记忆技巧:可总结为 “资源慢用 CDN,扩容难用 K8s,渲染卡用虚拟列表”,后续学习其他性能优化技术时,也按「场景→问题→思考→选型→落地」的思路梳理,贴合项目实操与软考备考。


七、自我复盘 + 下一篇预告

自我复盘:本次围绕地铁出行系统的高并发与性能优化,完整拆解了从问题到解决方案的全流程,让我深刻体会到,性能优化不是 “堆砌技术”,而是 “针对痛点精准选型”。同时,通过梳理技术体系,夯实了性能优化类技术的基础,也为软考论文中 “高并发与性能优化” 模块的写作,积累了完整的项目场景与落地逻辑。

下一篇,我们继续围绕地铁出行系统,聚焦工程化与监控场景 —— 随着项目迭代,代码质量、构建效率、系统监控成为新的痛点,我们将拆解「工程化场景→核心问题→思考过程→Webpack+GitLab CI+Prometheus 等技术栈落地→实施成效」,既梳理工程化技术体系,又还原项目实操逻辑,帮大家吃透工程化建设的核心思路。

最后,非常非常欢迎大家在评论区分享自己的想法、补充相关实操经验,也欢迎大家指出文中不对的地方,一起交流、一起进步,共同吃透前端架构实操与软考备考要点。

最近爆火的 Harness Engineering 被我提炼成了 SKILL,小白也能快速上手

✨文章摘要(AI生成)

笔者分享了将 Harness Engineering 知识提炼为可复用 Agent Skill 的经验。在系统阅读了 Anthropic、OpenAI、Martin Fowler、LangChain 等来源的文章后,提炼出 Harness 设计的七个核心层:项目搭建、上下文工程、约束与防护、多 Agent 架构、评估与反馈、长时间任务、诊断。最终产出的 harness-engineering 技能覆盖三大场景——新项目搭建、Agent 行为诊断、持续改进,采用渐进式披露架构。定量评估显示有技能时断言通过率 100%,无技能时 83%。核心洞察:Agent 表现不好,80% 的原因不在模型,在 Harness。

为什么写这个

最近两年,笔者在使用各种AI编码助手(Claude Code、Cursor、Copilot等)的过程中,反复遇到一个问题:Agent时好时坏,虽然整体来说随着模型能力进步是向好的,但是向好的过程是曲折波动的。

有时候它写的代码完美契合项目风格,有时候它像个第一天入职的实习生——不知道项目结构、不遵守约定、还把之前商量好的决策忘得一干二净。

然后开始从 Prompt Engineering 中使用结构化、few shot、few example 等技巧,来让 AI 的输出更加稳定。 后面又使用 Context Engineering 来让 Agent 的上下文更加丰富,来让 Agent 的表现更加稳定。

最近几周,一个更系统的词汇出现了:Harness Engineering。

Agent表现不好,80%的原因不在模型,在Harness。 - Anthropic

什么是Harness?简单说:

  • 模型 = CPU(算力本身)
  • 上下文窗口 = RAM(工作记忆)
  • Harness = 操作系统(调度、约束、反馈、文件系统——一切让CPU有效工作的基础设施)

你不会指望一个CPU在没有操作系统的裸机上高效运行。同理,你也不该指望一个模型在没有Harness的项目里稳定输出。

我学到了什么

笔者系统阅读了以下来源的文章:

  • Anthropic — 构建高效Agent、多Agent研究系统、长时间运行Agent的Harness设计
  • OpenAI — AGENTS.md设计模式、Context Engineering最佳实践
  • Martin Fowler — Harness Engineering的工程哲学("Relocating Rigor")
  • LangChain — Agent框架 vs 运行时 vs Harness的分类学
  • philschmid — 2026年Agent Harness的重要性
  • 独立开发者实践 — Hermes Agent的自演化、Vue Lynx的设计笔记驱动开发
  • 学术论文 — 自然语言Agent Harness的形式化研究

读完之后,我发现这些文章虽然角度各异,但核心思想收敛到了七个层

层级 解决什么问题 一句话总结
项目搭建 Agent不知道项目是什么 AGENTS.md是目录,不是百科全书
上下文工程 Agent看到的信息不对 给地图,不给手册
约束与防护 Agent犯重复的错 每犯一次错,加一条规则
多Agent架构 单Agent搞不定复杂任务 分工明确,协议清晰
评估与反馈 不知道Agent做得好不好 让AI检查AI
长时间任务 Agent跑着跑着就走偏了 进度文件 + 上下文重置
诊断 用户骂Agent不好用 问题在Harness,不在模型

所以我做了个技能

读完这些文章,笔者意识到这些模式完全是可复用的。不管你的项目是React前端、Python后端还是Rust CLI工具——Harness的设计原则是通用的。

于是我把这些知识提炼成了一个 Agent Skill,名叫 harness-engineering

它做什么

这个技能有三个核心使用场景:

场景一:新项目搭建

当你启动一个新项目,告诉Agent"帮我搭建Harness工程",它会:

  1. 评估你的项目类型、技术栈、团队规模
  2. 创建 AGENTS.md(表of目录式的Agent导航文件)
  3. 建立 docs/ 目录(架构、约定、数据模型等)
  4. 配置约束层(lint规则、类型检查、pre-commit hooks)
  5. 设置评估与反馈机制

场景二:Agent表现不佳时的诊断

这是最有意思的场景。当你开始抱怨——

  • "它怎么又犯同样的错误?"
  • "它根本不遵守我们的约定!"
  • "它写的代码质量太差了"

这个技能会被触发,引导Agent去诊断Harness层的缺失,而不是怪模型:

你的抱怨 大概率原因 修复方式
总犯同一个错 没有约束阻止它 加一条lint规则
不遵守约定 约定没写下来或Agent找不到 写入docs/,在AGENTS.md中引用
忘记之前的决定 跨会话上下文未持久化 用progress.md记录决策
代码质量差 没有好代码的示例 在DESIGN_NOTES.md中加示例

场景三:持续改进

每次发现新的可复用Harness模式,更新到技能中,让它在其他项目中也能受益。

它怎么组织的

技能采用渐进式加载架构:

harness-engineering/
├── SKILL.md              # 入口文件(<60行),路由到具体参考文档
└── references/
    ├── 01-project-setup.md       # 项目搭建
    ├── 02-context-engineering.md  # 上下文工程
    ├── 03-constraints.md          # 约束与防护
    ├── 04-multi-agent.md          # 多Agent架构
    ├── 05-eval-feedback.md        # 评估与反馈
    ├── 06-long-running.md         # 长时间任务
    └── 07-diagnosis.md            # 诊断

SKILL.md本身非常精简——它就像一个路由器,根据当前场景指引Agent去读对应的参考文档。这遵循了Harness Engineering本身的原则:渐进式披露,按需加载

几个让我印象深刻的模式

有几个模式特别触动笔者,感同身受,这里单独拿出来聊聊。

"给地图,不给手册"

这个观点从推文中看到。传统做法是给Agent写详细的分步指令(手册),但这让Agent变得脆弱——任何偏差都会导致它不知所措。

更好的做法是给Agent一张地图

# 不好的写法(手册)
Step 1: 打开 src/auth/login.ts
Step 2: 找到 handleLogin 函数
Step 3: 在第42行添加...

# 好的写法(地图)
Auth系统在 src/auth/。登录流程:login.ts → validate.ts → session.ts。
限流中间件在 src/middleware/rateLimit.ts——参考它的模式。
每次修改auth都要在 src/auth/__tests__/ 里加测试。

地图让Agent能自主导航,手册让它成为脆弱的执行机器。

"每犯一次错,加一条规则"

这个模式来自多篇文章的交叉验证。核心思想:

  1. Agent犯了一个错
  2. 你修复了这个错
  3. 然后你加一条规则,永远阻止这类错再次发生

这条规则可以是lint规则、类型约束、测试用例,或者只是文档中的一条约定。随着时间推移,Harness积累了越来越多的规则,Agent的错误率对已知模式趋近于零。

这其实就是Martin Fowler说的 "Relocating Rigor"——把人类通过Code Review、经验、直觉实施的质量把关,迁移到自动化检查中。Agent在被检查的边界内自由运行。

Harness = 数据集

这个观点来自Anthropic。每次Agent交互都是一个训练信号:

  • 它尝试了什么
  • 什么成功了
  • 什么失败了
  • 修复方案是什么

这些痕迹(traces)就是你的竞争优势。它们是让你的Harness随时间越来越好的数据——不是微调模型,而是优化操作系统。

技能评估:有没有用?

笔者遵循skill-creator的流程,对这个技能做了定量评估。设计了3组测试场景,每组跑with-skill和without-skill两个版本:

测试场景 有技能 无技能
新项目搭建 6/6 ✅ 4/6
Agent行为诊断 6/6 ✅ 5/6
跨模块依赖问题 6/6 ✅ 6/6
合计 18/18 (100%) 15/18 (83%)

有技能的版本在所有场景下都通过了全部断言。无技能的版本在"新项目搭建"场景下缺失较多——它不知道要创建AGENTS.md、不知道docs/应该怎么组织、不会设置渐进式披露的上下文架构。

当然,17%的差距不算巨大。但关键是:有技能时Agent的输出一致且完整,无技能时看运气。对于一个工程实践类技能来说,一致性比偶尔的惊艳更有价值。

怎么安装

这个技能可通过 GitHub 安装:

npx skills add 10xChengTu/harness-engineering

安装后,当你在Claude Code、OpenCode或其他支持Skills的Agent中工作时:

  • 启动新项目 → 技能自动触发,引导搭建Harness
  • 遇到Agent质量问题 → 开始抱怨时技能会介入诊断
  • 主动询问 → "帮我改进这个项目的Harness"

最后

Harness Engineering目前还是一个非常早期的领域。模型在变强,今天需要的约束明天可能就多余了——所以这个技能本身也遵循一个核心原则:为删除而构建

如果你也在用AI Agent做开发,不妨试试给你的项目加上Harness。从最简单的开始——一个AGENTS.md文件、几条lint规则、一个progress.md。然后观察Agent的表现变化。

你大概率会和笔者有同样的感受:不是模型不行,是我们没给它一个好的工作环境。

本文涉及的所有参考文章和完整技能源码,均可在GitHub 仓库中找到。

从桌面端到高性能三维:大点云渲染的两种 Electron 架构实战

当 Three.js 遇上百万级点云,Web 内存与计算双双告急。我们用 C++ 扛起计算,用两种架构打通数据共享——同进程 N-API 与跨进程 gRPC+mmap,究竟谁更胜一筹?

引言

点云是三维世界中最原始、最直观的数据形式。一个中等规模的激光雷达扫描,动辄数百万乃至上亿个点。在 Electron 中直接使用 Three.js 加载 PLY/PCD 文件,往往瞬间耗尽内存,帧率跌至个位数。

根本原因在于:JavaScript 的单线程与 GC 压力,以及大块几何数据在 JS 堆中的双重拷贝。为了破局,业界普遍将计算密集、内存敏感的任务下沉到 C++,然后通过高效的数据共享机制将处理好的顶点数据传递给 Three.js。

本文将深入剖析两种架构方案:

  1. 同进程 N-API:C++ 代码以 Node 原生模块的形式直接运行在 Electron 主进程或渲染进程中。
  2. 跨进程 gRPC + mmap:C++ 作为独立后台服务,通过 gRPC 通信,通过 mmap 共享内存零拷贝交换数据。

我们不仅会对比优劣,更会给出核心代码实现,让你能真正落地到自己的项目中。


一、痛点与需求

以一个大点云项目为例:

  • 点云文件:.las 格式,包含 2000 万个点,每个点有 XYZ、RGB、强度等属性。
  • 需求:实时旋转/缩放,无卡顿;支持动态筛选(按强度、分类值)。
  • 瓶颈:
    • JS 解析 2000 万个点 → 内存爆炸(每点至少 24 字节,仅位置就需要 480MB)
    • Three.js BufferGeometry 创建过程会再拷贝一次 → 内存翻倍
    • 主线程解析 + 渲染 → UI 冻结

因此,必须:

  1. C++ 负责解析、滤波、LOD 生成
  2. C++ 与 JS 共享同一块内存,避免数据拷贝。
  3. Three.js 直接消费共享内存中的顶点数据

二、方案一:同进程 N-API(Node Addon)

2.1 架构图

┌─────────────────────────────────────────┐
│           Electron Main/Renderer        │
│  ┌───────────────────────────────────┐  │
│  │            React UI               │  │
│  └───────────────┬───────────────────┘  │
│                  │ 调用 N-API 导出函数   │
│  ┌───────────────▼───────────────────┐  │
│  │       C++ Addon (N-API)           │  │
│  │  - 点云解析                        │  │
│  │  - 滤波/采样                       │  │
│  │  - LOD 生成                        │  │
│  └───────────────┬───────────────────┘  │
│                  │ 返回 ArrayBuffer     │
│  ┌───────────────▼───────────────────┐  │
│  │       Three.js Renderer           │  │
│  │  BufferAttribute 直接引用 ArrayBuffer│
│  └───────────────────────────────────┘  │
└─────────────────────────────────────────┘

2.2 核心实现:C++ Addon 返回可转移的 ArrayBuffer

步骤 1:编写 C++ 点云解析函数

使用 N-API 创建 ArrayBuffer,填充顶点数据后返回给 JS。

#include <napi.h>
#include <vector>
#include <fstream>
#include "lasreader.hpp"  // LASlib 等

struct Point {
    float x, y, z;
    uint8_t r, g, b;
};

Napi::Value ParsePointCloud(const Napi::CallbackInfo& info) {
    Napi::Env env = info.Env();
    std::string filepath = info[0].As<Napi::String>().Utf8Value();

    // 1. C++ 中解析点云,获取点数量
    LASreader* reader = LASreader::open(filepath.c_str());
    uint64_t num_points = reader->npoints;
    
    // 2. 计算内存大小(每个点 3*float + 3*uint8 = 12+3 = 15 字节,对齐到 16 字节)
    size_t buffer_size = num_points * sizeof(Point);
    
    // 3. 创建 N-API ArrayBuffer,内存由 C++ 分配(可使用 napi_create_external_arraybuffer 避免额外拷贝)
    void* data = malloc(buffer_size);
    Point* points = static_cast<Point*>(data);
    
    // 4. 填充数据
    size_t idx = 0;
    while (reader->read_point()) {
        points[idx].x = reader->point.get_x();
        points[idx].y = reader->point.get_y();
        points[idx].z = reader->point.get_z();
        points[idx].r = reader->point.get_r();
        points[idx].g = reader->point.get_g();
        points[idx].b = reader->point.get_b();
        ++idx;
    }
    reader->close();
    delete reader;

    // 5. 创建 ArrayBuffer,并绑定释放回调
    Napi::ArrayBuffer buffer = Napi::ArrayBuffer::New(env, data, buffer_size,
        [](Napi::Env env, void* finalize_data) {
            free(finalize_data);
        });
    
    // 6. 返回给 JS(同时可附带点数量等元数据)
    Napi::Object result = Napi::Object::New(env);
    result.Set("buffer", buffer);
    result.Set("numPoints", Napi::Number::New(env, num_points));
    return result;
}

Napi::Object Init(Napi::Env env, Napi::Object exports) {
    exports.Set("parsePointCloud", Napi::Function::New(env, ParsePointCloud));
    return exports;
}
NODE_API_MODULE(pointcloud_addon, Init)

步骤 2:React + Three.js 中消费 ArrayBuffer

// 在渲染进程中(确保 nodeIntegration 开启或通过 preload 暴露)
const addon = require('pointcloud-addon');

async function loadPointCloud(filePath) {
    const { buffer, numPoints } = addon.parsePointCloud(filePath);
    
    // 关键:将 ArrayBuffer 转为 Float32Array 和 Uint8Array 视图,但不复制数据
    const positions = new Float32Array(buffer, 0, numPoints * 3);
    const colors = new Uint8Array(buffer, numPoints * 12, numPoints * 3);
    
    // 创建 Three.js BufferGeometry
    const geometry = new THREE.BufferGeometry();
    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3, true));
    
    // 使用 PointsMaterial 渲染
    const material = new THREE.PointsMaterial({ vertexColors: true, size: 0.1 });
    const points = new THREE.Points(geometry, material);
    scene.add(points);
}

2.3 优缺点分析

优点 缺点
✅ 零拷贝:C++ 分配的 ArrayBuffer 直接被 Three.js 使用,无内存复制 ❌ 主线程阻塞:若直接在渲染进程调用会卡 UI(但可通过 worker_threads 解决)
✅ 延迟极低:函数调用开销微秒级 ❌ 崩溃风险:C++ Addon 内存越界会导致整个 Electron 进程崩溃
✅ 开发简单:无需跨进程通信,调试方便 ❌ Node 版本绑定:需要针对 Electron 的 Node 版本编译原生模块
✅ 部署单一:只有一个 .exe/.app 文件 ❌ 内存释放不可控:依赖 GC 触发 finalize,大对象可能延迟释放

关于 UI 阻塞的解决方案

直接在渲染进程中调用 addon.parsePointCloud 会同步执行 C++ 代码,若解析耗时超过 16ms,页面就会掉帧。正确的做法是将解析任务放到主进程的 worker_threads 中执行,解析完成后通过 postMessage 将 ArrayBuffer 传回渲染进程(结构化克隆会转移所有权,依然零拷贝)。

javascript

复制下载

// 主进程中创建一个 worker 线程
const { Worker } = require('worker_threads');

const worker = new Worker(`
  const { parentPort } = require('worker_threads');
  const addon = require('pointcloud-addon');
  
  parentPort.on('message', (filePath) => {
    const { buffer, numPoints } = addon.parsePointCloud(filePath);
    // 直接转移 ArrayBuffer 所有权,无需拷贝
    parentPort.postMessage({ buffer, numPoints }, [buffer]);
  });
`, { eval: true });

worker.on('message', ({ buffer, numPoints }) => {
  // 通过 IPC 发送给渲染进程
  mainWindow.webContents.send('pointcloud-data', buffer, numPoints);
});

渲染进程收到后,直接使用 new Float32Array(buffer) 创建视图即可。这样 C++ 解析完全在后台线程,UI 永不阻塞

注意:worker_threads 是 Node.js 的线程,不是 Web Worker。Web Worker 无法加载原生模块,因此不适用于此场景。

C++ addon 调用放在 worker_threads 中执行 能避免 程序崩溃吗?

不能!

为什么 worker_threads 也无法隔离崩溃?

即便将 C++ addon 调用放在 worker_threads 中执行,由于 worker 线程与主进程共享同一内存空间,原生代码的崩溃依然会连带杀死整个进程。Worker 线程的隔离是 JS 层面的,而非操作系统级的进程隔离。


三、方案二:独立 C++ 服务 + gRPC + mmap 共享内存

3.1 架构图

┌─────────────────────────┐         gRPC(控制面)        ┌─────────────────────────┐
│     Electron 主进程      │◄────────────────────────────►│    独立 C++ 服务         │
│  ┌─────────────────────┐ │                              │  - 点云解析              │
│  │  gRPC Client        │ │                              │  - 滤波/重采样           │
│  │  (Node.js)          │ │                              │  - LOD 生成              │
│  └──────────┬──────────┘ │                              │  - 写入 mmap             │
│             │ IPC         │                              └────────────┬────────────┘
│  ┌──────────▼──────────┐ │                                           │
│  │  Renderer (React)   │ │                              ┌────────────▼────────────┐
│  │  - Three.js         │ │   mmap 共享内存(数据面)     │   /dev/shm/pointcloud   │
│  │  - 读取 mmap 数据    │◄─────────────────────────────►│   (顶点 + 索引 + 元数据)  │
│  └─────────────────────┘ │                              └─────────────────────────┘
└─────────────────────────┘

3.2 核心实现:三步骤打通数据流

3.2.1 C++ 服务:解析点云并写入 mmap

使用 boost::interprocess 或 POSIX shm_open + mmap。为了跨平台,推荐使用 boost

#include <boost/interprocess/shared_memory_object.hpp>
#include <boost/interprocess/mapped_region.hpp>
#include <atomic>

struct SharedPointCloudHeader {
    std::atomic<uint64_t> version{0};
    std::atomic<bool> ready{false};
    uint64_t num_points;
    uint64_t data_offset;  // 顶点数据起始偏移
};

class PointCloudServer {
public:
    void LoadAndShare(const std::string& las_path) {
        // 1. 解析点云到内存 vector
        std::vector<Point> points = ParseLAS(las_path);
        
        // 2. 计算总大小
        size_t header_size = sizeof(SharedPointCloudHeader);
        size_t data_size = points.size() * sizeof(Point);
        size_t total_size = header_size + data_size;
        
        // 3. 创建共享内存对象
        boost::interprocess::shared_memory_object shm(
            boost::interprocess::open_or_create,
            "pointcloud_shm",
            boost::interprocess::read_write
        );
        shm.truncate(total_size);
        
        // 4. 映射到本进程地址空间
        boost::interprocess::mapped_region region(shm, boost::interprocess::read_write);
        
        // 5. 写入 header
        SharedPointCloudHeader* header = static_cast<SharedPointCloudHeader*>(region.get_address());
        header->num_points = points.size();
        header->data_offset = header_size;
        header->version.fetch_add(1, std::memory_order_release);
        
        // 6. 写入顶点数据
        void* data_ptr = static_cast<char*>(region.get_address()) + header_size;
        memcpy(data_ptr, points.data(), data_size);
        
        // 7. 标记 ready
        header->ready.store(true, std::memory_order_release);
    }
    
    // gRPC 服务接口:返回共享内存名称和大小
    grpc::Status LoadModel(grpc::ServerContext*, const LoadRequest* req, LoadResponse* resp) {
        LoadAndShare(req->file_path());
        resp->set_shm_name("pointcloud_shm");
        resp->set_shm_size(total_size);
        resp->set_data_offset(header_size);
        return grpc::Status::OK;
    }
};

3.2.2 Electron 主进程:gRPC 调用获取元数据

// main.js 中
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const { ipcMain } = require('electron');

const packageDefinition = protoLoader.loadSync('pointcloud.proto');
const proto = grpc.loadPackageDefinition(packageDefinition);
const client = new proto.PointCloudService('localhost:50051', grpc.credentials.createInsecure());

ipcMain.handle('load-pointcloud', async (event, filePath) => {
    return new Promise((resolve, reject) => {
        client.LoadModel({ file_path: filePath }, (err, response) => {
            if (err) reject(err);
            else resolve({
                shmName: response.shm_name,
                shmSize: response.shm_size,
                dataOffset: response.data_offset
            });
        });
    });
});

3.2.3 渲染进程:通过 mmap-io 读取共享内存并传给 Three.js

// 渲染进程中(通过 preload 暴露的 API)
const mmap = require('@cathodique/mmap-io');
const fs = require('fs');

async function loadAndRender(filePath) {
    // 1. 通过 IPC 触发 C++ 服务加载
    const { shmName, shmSize, dataOffset } = await window.electronAPI.loadPointcloud(filePath);
    
    // 2. 打开共享内存(Linux /dev/shm,Windows 不同)
    const fd = fs.openSync(`/dev/shm/${shmName}`, 'r');
    const buffer = mmap.map(fd, mmap.PROT_READ, mmap.MAP_SHARED, shmSize, 0);
    
    // 3. 解析 header(前 24 字节)
    const version = buffer.readBigUInt64LE(0);
    const ready = buffer.readUInt8(8) === 1;
    const numPoints = Number(buffer.readBigUInt64LE(16));
    
    if (!ready) throw new Error('Data not ready');
    
    // 4. 从 dataOffset 位置读取顶点数据
    const pointSize = 32;  // 假设 Point 结构体大小
    const positions = new Float32Array(numPoints * 3);
    const colors = new Uint8Array(numPoints * 3);
    
    for (let i = 0; i < numPoints; i++) {
        const base = dataOffset + i * pointSize;
        positions[i*3] = buffer.readFloatLE(base);
        positions[i*3+1] = buffer.readFloatLE(base + 4);
        positions[i*3+2] = buffer.readFloatLE(base + 8);
        colors[i*3] = buffer.readUInt8(base + 12);
        colors[i*3+1] = buffer.readUInt8(base + 13);
        colors[i*3+2] = buffer.readUInt8(base + 14);
    }
    
    // 5. 创建 Three.js 几何体(注意:这里从 buffer 拷贝到了新 ArrayBuffer,可优化?见下文)
    const geometry = new THREE.BufferGeometry();
    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3, true));
    
    const points = new THREE.Points(geometry, new THREE.PointsMaterial({ vertexColors: true }));
    scene.add(points);
}

性能陷阱:上面代码中,positionscolors 是从 mmap buffer 中逐点读取并创建的新 Float32Array,这仍然存在一次拷贝。要实现真正的零拷贝,需要让 Three.js 直接使用 mmap 映射的原始 buffer。但 Three.js 的 BufferAttribute 只接受 ArrayBufferBuffer 视图,并且要求该内存生命周期与几何体一致。我们可以利用 SharedArrayBuffer 或者直接传递 mmap 得到的 Buffer 对象,只要保证在几何体销毁前不被 unmap。

// 零拷贝版本:直接使用 mmap 返回的 Buffer 创建 Float32Array 视图
const totalFloats = numPoints * 3;
const positionsView = new Float32Array(buffer, dataOffset, totalFloats);
const colorsView = new Uint8Array(buffer, dataOffset + totalFloats * 4, numPoints * 3);
geometry.setAttribute('position', new THREE.BufferAttribute(positionsView, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colorsView, 3, true));
// 注意:需要确保 buffer 在几何体使用期间一直保持映射,不能提前 unmap

3.3 优缺点分析

优点 缺点
进程隔离:C++ 崩溃不影响 Electron UI 架构复杂:需要管理 C++ 服务的生命周期
非阻塞渲染:解析在后台进行,UI 可显示进度 部署麻烦:需打包两个可执行文件
真正零拷贝:mmap 让多进程共享同一物理内存 跨平台兼容性:Windows 下 mmap 行为不同,需封装
可扩展:未来可升级为远程服务,支持多机集群 调试困难:gRPC + mmap 联合调试工具链不成熟
内存可控:C++ 服务可独立释放内存 延迟略高:首次加载需 gRPC 调用(~1ms)

适用场景:点云规模极大(5000 万点以上),需要后台预处理、多任务排队,且对 UI 流畅度要求严苛。


四、核心问题:Buffer 如何从 C++ 共享到 Three.js?

无论哪种方案,最终目标都是让 Three.js 的 BufferAttribute 能直接访问 C++ 中分配的内存,避免复制。两种方案的技术本质:

  • N-API 方案:C++ 通过 napi_create_external_arraybuffer 分配内存,JS 拿到 ArrayBuffer 后,Three.js 可直接创建 BufferAttribute内存所有权归 JS(GC 时调用 free)。
  • mmap 方案:C++ 与 JS 通过操作系统共享内存机制映射同一块物理内存,JS 侧通过 BufferSharedArrayBuffer 访问。内存所有权归 OS,双方均可读写。

技术细节对比

方面 N-API 外部 ArrayBuffer mmap 共享内存
数据拷贝次数 0 次(C++ 直接写入 ArrayBuffer 内存) 0 次(双方映射同一页)
内存释放 由 JS GC 触发 finalize 回调 显式调用 munmap 或进程退出时释放
并发安全 单进程,无需额外同步 多进程,必须使用原子操作或互斥锁
跨语言友好 仅限 Node.js 环境 任何支持 POSIX API 的语言
最大数据量 受 V8 堆限制(64 位下约 4GB) 受物理内存 + 操作系统限制
实现复杂度 低(N-API 标准接口) 高(需处理权限、命名冲突、多进程同步)

结论:对于绝大多数桌面端点云应用,N-API 方案已经足够,且更简单。只有当点云超过 4GB 或需要多进程同时访问时才考虑 mmap。


五、实战决策:我应该选哪种?

5.1 决策树

是否单点云超过 2GB?
├─ 是 → mmap 方案(突破 V8 堆限制)
└─ 否 → 是否需要支持多进程并发访问?
    ├─ 是 → mmap 方案
    └─ 否 → 是否可接受 C++ 模块崩溃导致 Electron 闪退?
        ├─ 是 → N-API 方案(最简单)
        └─ 否 → mmap 方案(进程隔离更安全)

5.2 混合方案:按需选择

实际项目中,可采用双模式:默认使用 N-API(性能最优),当检测到点云过大时,降级为独立服务模式。

async function loadPointCloud(filePath) {
    const fileSize = getFileSize(filePath);
    if (fileSize < 1e9) { // < 1GB
        return loadWithNapi(filePath);
    } else {
        return loadWithGrpcMmap(filePath);
    }
}

好的,我将把“如何实现内存释放”和“clear 如何触发调用”这两个回答整合成一篇完整的技术说明,保持逻辑连贯,避免重复。


六, 跨进程方案(gRPC + mmap)中的内存释放与触发机制

gRPC + mmap 架构中,共享内存的生命周期由 C++ 服务端和 Electron 客户端共同管理。内存释放不是单个操作,而是一套需要双方配合的流程。下面分两部分阐述:释放操作本身释放的触发时机

6.1、内存释放的三大步骤(“怎么释放”)

C++ 服务端需要依次执行以下三个系统调用,才能彻底销毁一块共享内存:

步骤 函数 作用 备注
1 munmap 解除当前进程对共享内存的映射 调用后本进程不能再访问该内存
2 close 关闭由 shm_open 获得的文件描述符 回收进程内的句柄资源
3 shm_unlink 删除共享内存对象的名称 类似 unlink 删除文件;当所有进程都解除映射后,OS 才真正回收物理内存

重要shm_unlink 只是删除了共享内存的“名字”。即使调用了它,如果还有其它进程(如 Electron)仍然映射着这块内存,其内容依然有效。只有所有进程都执行了 munmap 并关闭了引用,操作系统才会回收物理内存。这保证了 Electron 使用期间数据的稳定性。

下面是一个典型的 clear() 方法实现:

#include <sys/mman.h>   // munmap, shm_unlink
#include <unistd.h>     // close

class SharedMemoryCache {
public:
    void clear(const std::string& shm_name) {
        // 1. 解除映射
        if (mapped_ptr) {
            munmap(mapped_ptr, shm_size);
            mapped_ptr = nullptr;
        }
        // 2. 关闭文件描述符
        if (shm_fd != -1) {
            close(shm_fd);
            shm_fd = -1;
        }
        // 3. 删除共享内存对象
        if (!shm_name.empty()) {
            shm_unlink(shm_name.c_str());
        }
        shm_size = 0;
    }
private:
    void* mapped_ptr = nullptr;
    int shm_fd = -1;
    size_t shm_size = 0;
};

Electron 侧也要解映射
在 Node.js 中,使用 @cathodique/mmap-io 等库时,需要显式调用 mmap.unmap(buffer) 并关闭文件描述符。建议监听进程退出事件做兜底清理:

process.on('exit', () => {
    if (mmapBuffer) mmap.unmap(mmapBuffer);
    if (fd) fs.closeSync(fd);
});

6.2、clear() 的触发方式(“何时调用”)

C++ 服务端的 clear() 不会自动执行,必须由明确的逻辑触发。有三种主要方式:

Electron 主动请求释放(最推荐)

通过 gRPC 暴露 Release 接口,让 Electron 在不再需要某块共享内存时主动调用。

定义 proto

service PointCloudService {
  rpc LoadModel(LoadRequest) returns (LoadResponse);
  rpc ReleaseModel(ReleaseRequest) returns (ReleaseResponse);
}
message ReleaseRequest { string shm_name = 1; }

Electron 调用(例如用户关闭点云窗口时):

await window.electronAPI.releaseModel('pointcloud_shm');

C++ 服务实现

grpc::Status ReleaseModel(grpc::ServerContext*, const ReleaseRequest* req, ReleaseResponse*) {
    SharedMemoryCache::instance().clear(req->shm_name());
    return grpc::Status::OK;
}

✅ 优点:释放时机精确,资源回收及时,无浪费。

C++ 服务内部自动触发

场景 A:加载新模型时自动替换
LoadModel 接口中,如果已有旧共享内存,先 clear() 再创建新的。

if (!current_shm_name.empty()) {
    SharedMemoryCache::instance().clear(current_shm_name);
}
// 然后创建新共享内存...

✅ 优点:无需额外 API,适合单模型应用(一次只打开一个点云)。

场景 B:LRU 缓存淘汰
当服务需要同时缓存多个点云时,可设置容量上限,超过后自动清理最久未使用的。

void evictIfNeeded() {
    if (cache_.size() > MAX_CACHE_COUNT) {
        auto oldest = cache_.begin();
        oldest->second->clear();
        cache_.erase(oldest);
    }
}

✅ 优点:多文档应用(如历史记录)下自动管理内存。

进程退出时自动清理(兜底机制)

在 C++ 服务的析构函数或 atexit 中遍历所有共享内存并调用 clear()

SharedMemoryCache::~SharedMemoryCache() {
    for (auto& entry : all_caches_) {
        entry.clear();
    }
}

✅ 优点:即使 Electron 忘记调用 Release,正常退出时也能清理。
⚠️ 注意:进程被 kill -9 强制终止时不会执行,但操作系统最终会回收所有内存。


推荐组合策略

在实际项目中,建议采用混合触发,兼顾灵活性与安全性:

触发方式 使用场景 作用
Electron 主动调用 Release 用户明确关闭模型、切换文件 主力释放机制,及时回收
加载新模型时自动替换 单模型应用(每次只加载一个点云) 防止旧数据遗留
进程退出时清理 任何情况 兜底,确保无泄漏

典型调用链路

用户点击“关闭点云” 
  → React 调用 window.electronAPI.closeModel()
  → Electron 主进程通过 gRPC 调用 C++ 服务的 ReleaseModel
  → C++ 服务端执行 SharedMemoryCache::clear(shm_name)
      → munmap → close → shm_unlink
  → 共享内存被彻底销毁

通过清晰的释放操作和完善的触发机制,gRPC + mmap 方案既能提供高性能零拷贝数据共享,又能保证内存资源被安全、及时地回收。


七、总结与展望

在 Electron + Three.js 的大点云渲染场景中,将计算下沉到 C++,并实现零拷贝数据共享是性能突破的关键。本文提供的两种方案各有千秋:

  • N-API 同进程方案:适合 1000 万点以下,追求极致简单和低延迟的项目。
  • gRPC+mmap 跨进程方案:适合超大规模、要求进程隔离、支持后台队列的专业级应用。

未来,随着 WebGPU 的成熟和 SharedArrayBuffer 的普及,我们甚至可以直接在 C++ 中操作 GPU 缓冲区,进一步降低 CPU 开销。但就目前而言,这套混合架构已经能在普通消费级电脑上流畅渲染 5000 万点云。

如果你正在开发类似的桌面三维工具,希望这篇文章能帮你少走弯路。欢迎在评论区交流你的实践心得!


参考资料


如果你觉得这篇文章有帮助,欢迎点赞收藏👍 有问题可以在评论区交流!


作者:红波 | 专注智驾、机器人标注工具与可视化开发 | 技术栈:TS/Vue/WebGPU/WebGL/ThreeJS/Go/Rust

别再像个傻子一样天天手敲 Prompt 了!我用“拖拽连线”把 AI 驯服成了无情的 CRUD 机器(鳌虾 2.0 震撼发布)

前言

在 AI 代码生成工具层出不穷的今天,程序员面临着一个核心问题:如何更高效、更精准地让 AI 理解我们的需求? 传统的 AI 对话模式需要我们反复描述项目背景、手动关联各种文档和技能规范。这种模式不仅效率低下,还容易因为信息不完整导致 AI 产生“幻觉”,生成结果与预期相差甚远。

鳌虾(Aoxia-code) 正是为解决这些痛点而生。

在 1.0 版本中,鳌虾作为一个 npm 全局工具,首创了“可视化拖拽生成 AI 指令”的工作流,广受好评(详见1.0版本介绍)。 今天,我们正式推出 鳌虾 Aoxia-code 2.0!这一次,鳌虾完成了架构级的蜕变:从独立的 npm 工具,全面进化为沉浸式的 VS Code 插件,并重磅推出了基于流程图的复杂逻辑编排功能,让单页面真正串联成完整的业务流!


🌟 什么是鳌虾 2.0?

传统代码生成器通常依赖“硬编码模板(EJS/FreeMarker)”,一旦业务逻辑发生变化,模板就变得极难维护。 而 鳌虾 走的是另一条路: 元数据编排 + 技能规范约束 + AI 生成

在鳌虾 2.0 中,你不再需要手敲冗长的 Prompt。你只需在 VS Code 内部通过拖拽组件搭建页面骨架,再通过连线配置页面间的跳转逻辑。鳌虾会自动将这些视觉化的设计意图,结合项目中的技能规范文件,编译成一段高度结构化的 AI 指令。把这段指令发给 Trae/Cursor,完美契合你期望的源码瞬间生成!


🚀 鳌虾 2.0 核心演进与全景功能

一、 🔌 沉浸式 VS Code 插件体验与全局快捷键

鳌虾 2.0 告别了过去 npm install -g 启动本地服务的繁琐流程。 现在,你只需在 VS Code 插件市场搜索并安装 Aoxia-code Extension tool。按下 Ctrl+Alt+X (Mac 为 Cmd+Option+X)即可在编辑器内部瞬间唤出可视化工作台。它能原生读取你当前工作区的目录结构和规范文件,体验如丝般顺滑。

image.png

二、 📂 极其强大的物理/虚拟混合目录树

在前端开发中,对项目目录的把控至关重要。鳌虾 2.0 彻底重构了左侧栏目录树:

  • 挂载任意目录:在欢迎页或侧边栏,你可以通过一个精美的输入框直接指定想要读取的项目路径(如 src/views),留空则默认加载整个项目。
  • 深层智能读取与过滤:最高支持 8 级深度的文件树探测!同时内置了极其严格的黑名单规则(自动过滤 node_modules.git.husky 等无关产物),保证树形结构清爽无比,甚至能精确显示目录下的 .vue.ts 等业务代码文件。
  • 无感内联新建与 AI 翻译:想新建目录?点击加号,直接在树节点上内联输入(类似操作系统重命名)。你可以随意输入中文(如“测试业务”),鳌虾会自动在发给 AI 的指令中注入要求,让大模型在写代码时为你自动翻译成规范的英文(Kebab-case)文件夹名,丝滑无比!

三、 🕸️ 独创的全局与页面级流转拓扑图 (Flow Designer)

在 1.0 版本中,我们解决了“如何快速生成单个页面”的问题。但真实的业务往往是多页面联动的。 鳌虾 2.0 引入了基于流程图的可视化拓扑连线引擎,分为两个维度:

(这里非常适合放一张截图:FlowDesigner 的流程拓扑连线界面,展示模块之间或页面之间带有箭头和路由说明的连线)

  1. 系统级拓扑:宏观编排不同业务模块(Feature)之间的流转关系,系统架构一目了然。
  2. 页面级拓扑(模块内):双击进入模块后,你可以通过拖拽连线,配置列表页到详情页的交互逻辑。
    • 支持多种交互类型:路由跳转(Route)、弹窗打开(Dialog)、抽屉滑出(Drawer)。
    • 精准的 AI 翻译:这些连线数据最终会被鳌虾编译成明确的 AI 指令。例如连线后会生成:“当触发【查看详情】时,请使用【抽屉 (Drawer)】方式打开【详情页】”。AI 接收后,会自动为你写好 Drawer 组件引入和 v-model 状态控制逻辑!

image.png

image.png

四、 ⚡ 四大智能数据模型导入(告别手抄字段)

前端开发的第一步往往是看接口文档写字段。鳌虾内置了强大的解析引擎,支持四种零成本导入方式:

image.png

  • API 直连 (Apifox) :只需输入 Access Token 和项目 ID,鳌虾能直接拉取项目内的接口和数据模型,实现真正的无缝对接。
  • JSON 解析 :支持解析标准的 OpenAPI 3.0 JSON 格式。
  • SQL 智能解析 :直接把后端的 CREATE TABLE 语句贴进来!鳌虾会自动提取字段名、注释作为 Label,甚至能根据字段类型自动推导前端组件(比如 datetime 变日期选择器, varchar 变输入框,注释里带“状态”自动推导为下拉框)。
  • Java 实体类解析 :复制后端的 Entity/DTO 代码,鳌虾能识别 @ApiModelProperty 等注解,秒变前端结构字典。

五、 🎨 所见即所得的页面设计器 (Page Designer)

解析完字段后,进入可视化拼装环节:

image.png

image.png

  • 丰富的基础物料 :支持表单、表格、搜索栏、栅格布局(Grid-1, Grid-2)等多种组件。
  • 灵活的嵌套拖拽 :基于 vuedraggable 实现。你可以在 Tabs 标签页中嵌套布局容器,在布局容器中放置表格或表单,极其自由。
  • 多端响应式预览 :支持一键切换 PC 端和移动端视图,确保组件布局在不同设备下都合理。

六、 📋 智能读取与增强正则的技能体系 (Skill Config)

如何让 AI 生成的代码符合团队规范?

image.png

鳌虾 2.0 继承并强化了 1.0 的规范读取能力。它会自动扫描并读取当前 VS Code 工作区下的技能文件,优先级为: .trae/skills > .trae/rules > .cursor/rules > .windsurf/rules > .aocode/rules > docs/rules

  • 无遗漏的 <rules> 提取:只要在技能文件中标注了 <rules>...</rules>[CODE_RULES_START]...[CODE_RULES_END] 标签,鳌虾 2.0 会通过强大的正则匹配(支持多块扫描),完整提取并编号所有规则片段,精准输送给大模型。
  • 页面级技能分配:你可以为不同页面动态绑定没有任何默认干扰的规范。生成代码前,这些规范会被强制注入到最终的 Prompt 中,让 AI 始终在统一的框架下生成代码,从根本上减少“幻觉”。

📊 工具对比:鳌虾 vs 传统 AI 编程

对比维度 传统 AI 编程 鳌虾 AoCode 2.0
Prompt 输入 每次都要手敲完整描述 可视化配置,一键生成结构化指令
技能规范传递 手动复制粘贴或反复提及 自动读取并按需注入
多页面交互 AI 难以理解页面间的跳转与状态共享 流程图编排,清晰定义弹窗/抽屉/路由逻辑
信息完整性 容易遗漏关键约束条件 结构化输出,确保信息无遗漏
学习成本 需要学习复杂的 Prompt 编写技巧 零门槛,所见即所得

💡 鳌虾 2.0 的终极工作流体验

(这里可以作为总结,放一张最终生成 AI 指令后,代码在 VS Code 中自动写入过程的录屏动图)

  1. 唤出面板 :在 VS Code 中 Ctrl+Shift+P 执行 Aoxia: Open Aoxia
  2. 一键导模型 :贴入 SQL,生成带中文名称的数据字典。
  3. 拖拽组装 :左侧拖出栅格,右侧配好搜索栏和表格。
  4. 连线画逻辑 :切换到“流程拓扑”,把列表页连向新增页,设置交互为“抽屉”。
  5. 绑定技能 :为页面勾选 .trae/skills/table-spec.md 规范。
  6. 生成并发送 :点击生成指令,直接发送给右侧的 Trae/Cursor 对话框。
  7. 收工 :看着 AI 在你的编辑器里“唰唰唰”把包含弹窗、表格、表单且完全符合团队规范的完美代码写完。

🎉 立即体验

鳌虾 2.0 插件现已正式上架 VS Code 官方插件市场! 打开 VS Code 扩展面板,搜索 Aoxia-code Extension tool 即可安装体验!(开发者:Zhang-Lab

⚠️ 特别提示(针对 Trae 用户): 由于 Trae 默认使用的是其内部审核的插件市场,如果在 Trae 的插件面板中搜索不到 Aoxia-code Extension tool,直接用浏览器访问:marketplace.visualstudio.com/_apis/publi… 它会把.vsix文件下载下来。只需要打开trae,从VSIX安装。

image.png

我们不是要用低代码取代程序员,而是用**“可视化编排 + 流程定义 + AI”**把程序员从最无聊的增删改查和写 Prompt 的泥潭中解放出来,去思考更有价值的系统架构。

欢迎大家下载体验!如果有任何建议或发现了 Bug,欢迎在评论区留言交流!如果觉得好用,别忘了在插件市场留下一个五星好评哦~ ⭐️⭐️⭐️⭐️⭐️

Proxy 与 Namespace:终结环境与鉴权的噩梦

Proxy 与 Namespace:终结环境与鉴权的噩梦

本章基于基础事实来讲述NameSpace的重要性

BFF 如同统一的"海关大楼"图注:所有前端请求在这里统一安检、分流,走向正确的后端服务。

开场故事:被"环境标"与 Cookie 折磨的前端

在没有统一 BFF 的时代,一个前端工程师的日常,常常伴随着一些令人抓狂的场景:

"小王,我本地环境起不来,访问你的接口报跨域了,你那能开一下 CORS 吗?" "李工,麻烦问下,我要联调用户中心的 PPE 环境,请求头里要加哪个环境标来着?是 X-TT-ENV 还是 X-TT-PPE?" "奇怪,我明明在测试环境,为什么创建的订单会出现在线上数据库里?!"

这些问题听起来琐碎,却像慢性毒药一样,日复一日地消耗着前端团队的精力和耐心。

问题摊牌:环境与鉴权的无底洞

这些混乱现象的背后,是几个长期困扰前端的根源性问题:

  1. 环境标的迷宫:后端微服务通常有多套环境:本地、测试、预发布(PPE)、灰度、生产。为了在本地开发时能联调到正确的后端服务,前端不得不在代码里写死后端的 IP,或是在请求工具里手动塞入各种环境标。一旦不小心把带有测试环境标的代码发布到线上,就可能酿成严重的生产事故。
  2. 跨域与鉴权的无底洞:前端页面部署在 a.company.com,却要调用 user.company.comorder.company.com 的接口。为了解决跨域,前端不得不与各种 Access-Control-Allow-Origin 配置斗智斗勇。更糟糕的是鉴权,每个前端项目都得复制粘贴一套逻辑去解析 Token、判断登录态、处理会话过期,并小心翼翼地将凭证附带在每一个发往不同域的微服务请求上。
  3. "邻居的噪音":公司内部往往有多个业务线(如 C 端商城、B 端中台、内部运营系统),它们可能共享同一套底层微服务。如果 B 端系统在测试时修改了某个全局配置,正在开发的 C 端系统就可能莫名其妙地报错。团队之间缺乏有效的逻辑隔离,互相干扰成为常态。

解法白话:构建带有多租户隔离的"智能防线"

为了彻底终结这场噩梦,笔者在 BFF 中引入了两个最基础也最重要的模块:Proxy (统一代理)Namespace (命名空间)

笔者的设计思路非常明确:让前端变得绝对"无脑"

前端工程师只需要向同域名的 BFF 发起最普通的 HTTP 请求,不关心目标服务的真实地址,不关心当前是什么环境,也不用操心如何携带 Token。剩下的所有脏活累活——鉴权、环境路由映射、协议转换、凭证注入——全部由 BFF 在后台悄无声息地完成。

多租户隔离,保障各业务线互不干扰图注:每个 Namespace 如同独立的办公区,拥有自己的门禁和访客规则。

同时,笔者引入了"命名空间 (Namespace)"的概念,实现了多租户隔离,并将不同的业务线(或客户)划分到独立的 Namespace 中。每个 Namespace 拥有自己独立的认证模式 (AuthMode)、独立的路由元数据 (RouteMeta) 和独立的微服务版本映射。这就像为每个团队分配了专属的、隔音的办公室,互不打扰。

技术展开:拦截、查表、注入与透传

在代码实现上,这套机制如同一条精密的流水线,优雅地处理着每一笔前端请求:

  1. 路由与元数据映射 (RouteMeta): 前端发起的请求路径通常被设计为一种约定格式,例如 /proxy/:namespace/:service/:method。当这样的请求到达 BFF 的 ProxyController 时,BFF 首先会根据 :namespace:service 去数据库(或 Redis 缓存)中查找 RouteMeta。这一步确保了系统只代理那些真正在 BFF 注册过的、合法的接口,有效防止了恶意扫描。同时,BFF 会从元数据中获取目标微服务真实的 BaseUrl 和内部 RPC 路径。

  2. 鉴权清洗与注入 (AuthGuard): 请求在进入代理转发逻辑前,会先经过一道严格的鉴权守卫 AuthGuard。BFF 会提取前端请求中携带的 Token,不仅验证其签名和有效期,还会查询 Redis 中的用户会话(Session)以确认其真实性。 关键动作:BFF 在这一步会将外部传入的、可能不安全的凭证(如 Cookie、原始 Token)彻底"清洗"掉。然后,在转发给后端微服务的请求头中,极其确定地注入一个 X-USER-ID。这样一来,下游的微服务彻底解放,它们不再需要关心"如何解密 Token",只要读取请求头里的 X-USER-ID,就可以百分之百地信任这个请求的身份。

  3. 环境标的智能注入: 对于多环境路由,BFF 实现了完全的自动化。如果运维或开发同学在 BFF 的管理后台将当前 Namespace 的环境切换到了 PPE,那么 BFF 在代理转发时,会自动在请求头中打上 X-TT-ENV=ppe 的标签。前端代码一行环境变量都不用修改,就能在各套测试环境间无缝漫游,彻底告别手动维护环境配置的痛苦。

  4. 微服务版本的灵活切换: 不同于传统方案中微服务版本被硬编码在前端配置里,BFF 支持在管理后aps.bytdiance.com台上动态配置每个 Namespace 下各微服务的版本。这意味着同一个前端应用,在不同的 Namespace 下,可以自动调度到完全不同的微服务实例。A 客户使用的是 v2.3.1 版本的用户服务,B 客户可能已经在体验 v2.4.0 了,而这种版本差异对前端完全透明。

  5. 跨域的彻底消解: 由于所有的前端请求都先打到同域的 BFF,再由 BFF 转发到各个微服务,浏览器根本感知不到跨域的存在。CORS 配置从此不再是前端的噩梦,后端微服务也无需各自配置 CORS 头,BFF 统一处理。

一句话总结

Proxy 模块是 BFF 的"万能翻译官",把前端发送的约定式请求,精准地翻译成后端微服务期望的 RPC 调用。

Namespace 模块是 BFF 的"空间隔离官",确保不同租户、不同业务线的配置与权限老死不相往来。

有了这两道防线,前端终于可以安心写业务,后端也能专注写服务,运维更是从此告别"环境噩梦"。

接下来笔者会开始着重讲解NmaeSpace的细节逻辑

Claude Code Buddy 系统深度解析:一只会陪你写代码的像素宠物

前言

在 Claude Code 的源码中,有一个名为 buddy 的模块——它实现了一套完整的"虚拟伴侣(Companion)"系统。这只坐在你输入框旁边的小精灵,不是一个简单的装饰品,而是一套设计相当精密的游戏化 UX 系统。本文将逐文件深入分析 buddy 目录下的 6 个源文件,从数据模型、随机生成算法、渲染引擎到 React 组件,完整还原这个系统的设计思路与实现细节。


一、类型系统设计:types.ts

1.1 物种定义的工程化技巧

types.ts 是整个系统的基础。值得关注的第一个细节是物种(Species)的定义方式:

const c = String.fromCharCode
export const duck = c(0x64,0x75,0x63,0x6b) as 'duck'
export const dragon = c(0x64,0x72,0x61,0x67,0x6f,0x6e) as 'dragon'

这里用 String.fromCharCode 通过字符码点拼接字符串,而不是直接写字面量 'duck'。注释解释了原因:

One species name collides with a model-codename canary in excluded-strings.txt. The check greps build output (not source), so runtime-constructing the value keeps the literal out of the bundle...

这是一个对抗构建产物扫描工具的技巧。CI/CD 流水线中有一个脚本会扫描 bundle 产物中是否包含某些敏感字符串(即"canary"机制),某个物种名恰好与模型代号冲突。通过运行时构造字符串,就能让 bundle 产物中不出现该字面量,同时不影响功能。这种做法在大型团队的安全审计流程中并不罕见。

1.2 稀有度权重系统

export const RARITY_WEIGHTS = {
  common: 60,
  uncommon: 25,
  rare: 10,
  epic: 4,
  legendary: 1,
} as const satisfies Record<Rarity, number>

5 个稀有度等级,概率总和为 100,传奇(legendary)仅 1% 概率。这与经典的 Gacha 游戏设计如出一辙。as const satisfies Record<Rarity, string> 的写法既保留了字面量类型推断(as const),又用 satisfies 做了结构完整性校验,是 TypeScript 4.9+ 的惯用手法。

1.3 Companion 数据模型的分层设计

整个 Companion 被拆分成三层:

层级 类型 内容 持久化策略
Bones(骨骼) CompanionBones 稀有度、物种、眼型、帽子、闪光、属性 不持久化,每次从 userId 重新生成
Soul(灵魂) CompanionSoul 名字、性格 存储在 config
完整体 Companion Bones + Soul + hatchedAt 运行时合并

这个分层设计解决了一个核心问题:如何防止用户通过手动编辑配置文件来"伪造"一个传奇稀有度的伴侣?答案是——Bones 根本不存储,它在每次读取时从用户 ID 确定性地重新生成。用户编辑 config.companion 只能改变名字和性格,稀有度是由账号 ID 决定的,无法伪造。


二、随机生成引擎:companion.ts

这是整个系统的核心,包含了一个完整的"确定性随机角色生成"(Deterministic Procedural Generation)流水线。

2.1 Mulberry32 伪随机数生成器

function mulberry32(seed: number): () => number {
  let a = seed >>> 0
  return function () {
    a |= 0
    a = (a + 0x6d2b79f5) | 0
    let t = Math.imul(a ^ (a >>> 15), 1 | a)
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
  }
}

Mulberry32 是一个轻量级的 32 位整数伪随机数生成器(PRNG),由 Tommy Ettinger 设计。其特点是:

  • 体积极小:只有 5 行核心逻辑
  • 质量足够好:通过了 BigCrush 统计测试的多数项目
  • 有状态闭包:每次调用自动更新内部状态 a,返回 [0, 1) 区间的浮点数

注释里说  "good enough for picking ducks" ——对于这个场景,它确实完全够用。

2.2 字符串哈希函数

function hashString(s: string): number {
  if (typeof Bun !== 'undefined') {
    return Number(BigInt(Bun.hash(s)) & 0xffffffffn)
  }
  let h = 2166136261
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i)
    h = Math.imul(h, 16777619)
  }
  return h >>> 0
}

这是 FNV-1a(Fowler-Noll-Vo)  哈希算法的 32 位实现:

  • 初始值 2166136261(即 0x811c9dc5)是 FNV offset basis
  • 乘数 16777619(即 0x01000193)是 FNV prime
  • Math.imul 执行 C 风格的 32 位整数乘法,避免 JavaScript 浮点精度问题
  • 在 Bun 运行时有原生哈希实现,优先使用性能更好的 Bun.hash

2.3 稀有度抽取算法

function rollRarity(rng: () => number): Rarity {
  const total = Object.values(RARITY_WEIGHTS).reduce((a, b) => a + b, 0)
  let roll = rng() * total
  for (const rarity of RARITIES) {
    roll -= RARITY_WEIGHTS[rarity]
    if (roll < 0) return rarity
  }
  return 'common'
}

这是经典的加权随机抽样算法(Weighted Random Sampling),时间复杂度 O(n)。等价于把 [0, 100) 区间按权重切分成若干段,然后用随机数落点决定结果。由于 RARITIES 数组顺序固定(common → legendary),且权重递减,大多数情况下在前两次迭代就能返回结果。

2.4 属性点分配:峰谷系统

const RARITY_FLOOR: Record<Rarity, number> = {
  common: 5,
  uncommon: 15,
  rare: 25,
  epic: 35,
  legendary: 50,
}

function rollStats(rng, rarity) {
  const floor = RARITY_FLOOR[rarity]
  const peak = pick(rng, STAT_NAMES)   // 峰值属性
  let dump = pick(rng, STAT_NAMES)     // 垃圾属性
  while (dump === peak) dump = pick(rng, STAT_NAMES)

  for (const name of STAT_NAMES) {
    if (name === peak)  stats[name] = Math.min(100, floor + 50 + rng() * 30)
    else if (name === dump) stats[name] = Math.max(1, floor - 10 + rng() * 15)
    else stats[name] = floor + rng() * 40
  }
}

5 个属性(DEBUGGINGPATIENCECHAOSWISDOMSNARK),采用 RPG 中常见的峰谷分配机制:

  • 随机选一个峰值属性(peak),数值 = floor + 50 + 随机30,上限100
  • 随机选一个垃圾属性(dump),数值 = floor - 10 + 随机15,下限1
  • 其余属性 = floor + 随机40

稀有度越高,floor 越大(从 5 到 50),意味着传奇伴侣所有属性都至少从50起,而普通伴侣的垃圾属性可能低至 1。

2.5 Salt 与缓存机制

const SALT = 'friend-2026-401'

export function roll(userId: string): Roll {
  const key = userId + SALT
  if (rollCache?.key === key) return rollCache.value
  const value = rollFrom(mulberry32(hashString(key)))
  rollCache = { key, value }
  return value
}

SALT 的值 'friend-2026-401' 暗示这是 2026 年 4 月 1 日(愚人节)上线的功能。Salt 的作用是:如果将来需要对所有用户"重新洗牌"(例如加入新物种导致旧 ID 的概率分布改变),只需更换 SALT 即可,而不影响底层算法。

缓存逻辑 rollCache 是一个单元素 LRU:对同一个 userId 的重复调用直接返回缓存值,避免重复哈希计算。注释说这个函数会在三条热路径上被调用(500ms 动画 tick、每次键盘输入、每次 AI 响应),缓存优化非常必要。

2.6 Bones/Soul 合并策略

export function getCompanion(): Companion | undefined {
  const stored = getGlobalConfig().companion  // StoredCompanion(只有 soul)
  if (!stored) return undefined
  const { bones } = roll(companionUserId())
  return { ...stored, ...bones }  // bones 放后面,覆盖 stored 中的同名字段
}

合并时 bones 放在展开运算符的后面,确保即便旧格式的 config 中存有 bones 字段,也会被当前重新生成的 bones 覆盖。这个设计使得系统向后兼容:升级时即使 SPECIES 数组发生变化,也不会因为 stored 中存了一个已不存在的物种名而崩溃。


三、精灵渲染引擎:sprites.ts

3.1 ASCII Art 精灵系统

每个物种有 3 帧动画,每帧是 5 行 × 12 字符的 ASCII 画。使用 {E} 作为眼睛的占位符,渲染时替换为实际眼型字符:

[duck frame 0]
'            '
'    __      '
'  <({E} )___  '   ← {E} 会被替换为 '·', '✦', '×' 等
'   (  ._>   '
'    `--´    '

renderSprite 函数的逻辑:

export function renderSprite(bones: CompanionBones, frame = 0): string[] {
  const frames = BODIES[bones.species]
  const body = frames[frame % frames.length]!.map(line =>
    line.replaceAll('{E}', bones.eye),
  )
  const lines = [...body]
  // 插入帽子:只有第 0 行为空时才覆盖
  if (bones.hat !== 'none' && !lines[0]!.trim()) {
    lines[0] = HAT_LINES[bones.hat]
  }
  // 优化:所有帧第 0 行都为空时,去掉这一行节省终端空间
  if (!lines[0]!.trim() && frames.every(f => !f[0]!.trim())) lines.shift()
  return lines
}

帽子系统设计精妙:第 0 行(顶部)作为帽子槽,普通帧保持空白,特殊帧(如 dragon 的第 2 帧)用它显示"烟雾"特效。只有当第 0 行为空时,帽子才会被渲染进去,防止帽子与烟雾特效互相覆盖。

3.2 renderFace 的 switch 全覆盖

renderFace 函数为每个物种生成独特的"脸部表情"字符串(用于气泡尾部或简洁显示),用 TypeScript 的 exhaustive switch 保证每个物种都有对应处理:

case duck:
case goose:
  return `(${eye}>`    // 鸭子和鹅共用侧脸样式
case blob:
  return `(${eye}${eye})`  // blob 双眼并排

四、提示词注入:prompt.ts

export function companionIntroText(name: string, species: string): string {
  return `# Companion

A small ${species} named ${name} sits beside the user's input box...
When the user addresses ${name} directly (by name), its bubble will answer.
Your job in that moment is to stay out of the way: respond in ONE line or less...`
}

这是整个系统最有趣的部分之一:Companion 的气泡回复实际上是注入到主 LLM 上下文中的系统提示词getCompanionIntroAttachment 函数把伴侣的名字和物种作为一个 companion_intro 类型的 attachment 注入到消息历史中。

去重逻辑保证了这段提示词只注入一次(通过扫描历史消息中是否已有 companion_intro attachment),避免重复污染上下文。


五、React 组件层:CompanionSprite.tsx

5.1 动画时钟设计

const TICK_MS = 500
const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0]

每 500ms 触发一次 tick,遍历 IDLE_SEQUENCE。序列含义:

  • 0 → 显示帧 0(静止)
  • 1 → 显示帧 1(轻微动作)
  • 2 → 显示帧 2(另一个动作)
  • -1 → 在帧 0 基础上触发眨眼效果

整个序列长 15,总周期 7.5 秒。大部分时间(9/15)是静止,偶尔(4/15)轻微动作,偶尔(1/15)眨眼,偶尔(1/15)做特殊动作,完全模拟了真实小动物的"懒散"状态。

5.2 气泡淡出系统

const BUBBLE_SHOW = 20  // ticks,约 10 秒
const FADE_WINDOW = 6   // 最后 6 个 tick(3 秒)开始变暗

气泡显示 10 秒,最后 3 秒进入淡出(fading)状态,颜色变为 inactive,提示用户气泡即将消失。这是很细腻的 UX 设计。

5.3 爱心粒子系统

const PET_BURST_MS = 2500
const PET_HEARTS = [
  `   ${H}    ${H}   `,
  `  ${H}  ${H}   ${H}  `,
  ` ${H}   ${H}  ${H}   `,
  `${H}  ${H}      ${H} `,
  '·    ·   ·  ',
]

执行 /buddy pet 命令时,5 帧心形动画依次播放(每帧约 500ms),爱心从密集到稀疏,最后消散为 ·,模拟粒子飘散效果。

5.4 React Compiler 优化

CompanionSprite.tsx 中大量的 _c$ 缓存结构,是 React Compiler(原 React Forget)的编译输出。React Compiler 会静态分析组件,自动插入 memoization 代码,避免不必要的重渲染。这说明该项目已启用实验性的 React Compiler 功能。


六、通知与生命周期:useBuddyNotification.tsx

6.1 彩虹预告通知

export function isBuddyTeaserWindow(): boolean {
  const d = new Date()
  return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7
}

这个函数判断当前是否在"预告窗口"(2026 年 4 月 1-7 日)。在预告期,未孵化伴侣的用户启动时会看到彩虹色的 /buddy 提示。注释说明使用本地时间而非 UTC,是为了在不同时区形成一个"24小时滚动波浪",让社交媒体上的讨论热度持续更长时间,而非在 UTC 午夜造成单点流量峰值冲击后端的"孵化生成"接口。

6.2 /buddy 命令触发位置检测

export function findBuddyTriggerPositions(text: string) {
  const re = //buddy\b/g
  let m: RegExpExecArray | null
  while ((m = re.exec(text)) !== null) {
    triggers.push({ start: m.index, end: m.index + m[0].length })
  }
  return triggers
}

这个函数返回输入文本中所有 /buddy 命令的字符位置范围,供 UI 层做高亮渲染使用(\b 确保匹配词边界,不会误匹配 /buddylist 这样的字符串)。


七、整体架构总结

types.ts          ← 数据模型定义(Bones / Soul / Companion)
    ↓
companion.ts      ← 确定性随机生成引擎(hash → PRNG → roll)
    ↓                       ↓
sprites.ts        ←  ASCII 精灵渲染(BODIES + HAT_LINES)
    ↓
CompanionSprite.tsx ← React 组件(动画 / 气泡 / 爱心粒子)
    ↓
prompt.ts         ← LLM 上下文注入(companion_intro attachment)
useBuddyNotification.tsx ← 启动通知 & 命令触发检测

整个系统体现了几个值得学习的设计原则:

  1. 骨肉分离(Bones/Soul Split) :可变的"灵魂"(名字、性格)与不可篡改的"骨骼"(稀有度、物种)分开存储,防止用户作弊,同时保证向后兼容。
  2. 确定性生成(Deterministic Generation) :用 hash(userId + salt) 作为种子,保证同一用户永远得到同一只宠物,既有"专属感",又避免了服务端存储 bones 的开销。
  3. 防御性工程(Defensive Engineering) :Salt、字符码点构造字符串、bones 后置覆盖——每处细节都有明确的防御目的。
  4. 渐进式 UX(Progressive UX) :预告窗口 → 孵化 → 闲置动画 → 气泡回复 → 爱心互动,构成了完整的用户旅程,而非一个静态彩蛋。

这只坐在终端里的小鸭子(或者传奇龙),背后藏着的是一套从哈希函数到 React Compiler 的完整工程体系。下次你的 Claude Code 里出现一只小精灵,不妨想想——它的稀有度,从你第一次注册账号那天起,就已经注定了。

前端资质越高,越来越不敢随便升级框架?

上个星期五下午,临近下班,组里一个刚入职不久、技术热情极高的小伙子,给我提了个极具分量的 PR。

他跑到我工位旁,眼里闪着光:老大,我把咱们那个核心中后台项目的 React 从 17 直接升到 19 了,顺便把 Webpack 换成了 Rsbuildrelease note 说性能提升了将近 40%,我本地跑了一下,秒开!

看着他求表扬的神情,我的心却瞬间沉到了谷底。

我点开 package.json 的 diff,好家伙,红绿相间的变动多达七十多处。除了 React 自身的跨代大版本,连带着状态管理、路由、甚至底下好几个用来处理复杂 Excel 导出的老旧插件,全被强行 npm update 到了最新版。

65f2e014-3840-4833-8131-25faeed66fb9.png

我深吸了一口气,默默把他的 PR 关了,并告诉他:本地跑通不算通。这个合并如果今天发到线上,明晚咱们整个组大概率都要在 P0 故障复盘会上做检讨。😖

很多年轻前端可能觉得我太保守,甚至有点老顽固。 技术社区里天天都在吹 Vue 3.x 又出了什么革命性宏,React 19 的 Server Components 有多颠覆,Vite 又把构建速度压缩了多少毫秒等等。

在他们眼里,升级框架就像给手机系统点一下更新那么简单,不仅能提效,写进简历里还能多一句---主导项目底层技术栈升级。

但在前端领域摸爬滚打了很多年、背过无数次线上事故的锅之后,我慢慢明白了一个极其骨感的现实告诉你:前端资质越高,越不敢随便升级框架。


升级其实没那么简单

年轻时总有一种错觉,以为升级框架就是改一下 package.json 里的数字,然后顺着终端里的 warning 把废弃的 API 替换掉就完事了。

但真实的业务工程,是一个由无数个三方库、内部包、魔改组件和历史妥协交织而成的巨大复杂度。

就拿上面那个小伙子强升 React 19 来说。他只看到了 React 19 把 forwardRef 废弃了,直接把 ref 当 prop 传就行,代码看起来确实优雅了。

但他没看到的是,咱们项目里深埋着一个 4 年前离职前辈写的、极其复杂的虚拟滚动表格组件。当年为了在老版本里拿到底层 DOM,前辈用了极其 Hack 的方式去代理 ref

// 离职前辈留下的祖传代码
// 强行拦截并劫持了内部的 ref 实例,用来做复杂的聚焦与按键接管
const ComplexTable = React.forwardRef((props, ref) => {
  const internalRef = useRef();
  
  useImperativeHandle(ref, () => ({
    forceScroll: (y) => {
      // 依赖了早期版本某 UI 库极其脆弱的内部结构
      internalRef.current.querySelector('.ant-table-body').scrollTop = y;
    },
    // ... 其他 20 个不知道哪里会调用的这些方法
  }));

  return <Table ref={internalRef} {...props} />;
});

当你兴冲冲地把大版本一升,底层的渲染时机变了,或者旧版 UI 库的 DOM 结构因为不兼容而错位了。本地跑的时候,页面确实渲染出来了。

但等到下周一,财务部门的同事在处理每个月一万条数据的工资单,习惯性地按下回车键想要让表格自动滚动到下一行时——整个页面直接白屏崩溃😢。

这种深水区的依赖断裂,TypeScript 查不出来,E2E 测试如果没有覆盖到这一步也抓不到。最后买单的,就是签字同意合入代码的技术负责人。

牵一发而动全身。在没有 100% 自动化测试覆盖率的团队里,升级底层框架不叫重构,那叫排雷🤷‍♂️。


技术自嗨?

很多程序员在谈论框架升级时,往往会陷入一种技术自嗨的盲区。

你看,新的打包工具让首屏渲染快了 0.5 秒!

确实很棒。但然后呢?

站在业务视角的逻辑是极其冰冷的:如果一个系统目前运行稳定,没有遇到致命的性能瓶颈,也没有阻碍新需求的迭代,那么动它的底层,就是典型的 ROI(投资回报率)倒挂。

你花了两周时间解决各种依赖冲突,把 Vue 2 强行拔高到了 Vue 3,把 Options API 费劲巴拉地重构成了 Composition API。

如果升级成功了呢, 业务侧毫无感知,产品经理不会多给你发一毛钱奖金,老板甚至觉得你这两周业务产出为零😖。

升级失败了(或者带入了暗病):阻断了核心业务流程,造成客户流失,你就是那个没事找事、搞出 P0 事故的千古罪人。

这不是叫你摆烂,而是工程学里一条极其重要的铁律:If it ain't broke, don't fix it.(只要没坏,就别瞎修。)

我们写代码是为了解决业务问题,而不是为了满足自己对时髦技术的热爱。把屎山代码翻新成现代化技术栈,它依旧可能是一座逻辑混乱的屎山,只不过现在是一座编译速度更快的屎山罢了😒。


什么时候才该升?

那难道我们就永远守着老旧的版本,在历史包袱里等死吗? 当然不是。

高级前端和初级前端的区别就在于:初级前端为了新而升级,高级前端为了解决痛点而升级。

遇到以下三种情况,哪怕风险再大,我也一定带着团队往上升:

如果触及了不可逾越的性能天花板。 比如旧版框架的 Virtual DOM 算法在十万级数据渲染时已经彻底锁死主线程,而新版的并发特性或细粒度更新能从底层解决这个问题。

安全漏洞与 LTS(长期支持)结束。 底层依赖被扫出高危漏洞,且官方不再为老版本提供补丁,如果不升,过不了公司的内部安全红线。

生态彻底断裂。 现有的技术栈已经古老到找不到能兼容的周边库了,新招来的员工看这代码像看甲骨文,维护成本已经远超升级成本。

而且,真正老道的升级,绝不是开个新分支一把梭。 那是细致入微的依赖盘点、是灰度发布、是双栈运行、是哪怕天塌下来也能在 5 分钟内切回老代码的降级预案。


前几天我在社区看到一句话,深以为然:每一个看似极其保守的技术决策背后,都站着一个曾经被线上 Bug 毒打得死去活来的灵魂😁。

所以,当你的组长拒绝你那份华丽的底层升级改造方案时,别急着在心里骂他老土。他不是不懂新技术,他只是比你更懂那条在深夜里突然响起的线上报警短信,有多么让人绝望😒。

对于一线开发者来说,关注前沿技术、保持对框架底层演进的好奇心,绝对是好事。它能保持你的敏锐度,让你在写新项目时拥有更好的技术选型视野。

但在一个沉淀了无数业务逻辑的历史工程面前,请保持谨慎。

真正的技术大佬,不是那个天天在项目里倒腾最新框架的极客。而是那个哪怕手握一大把老旧框架,也能稳稳当当把复杂的业务需求切得明明白白,让系统稳如老狗的架构师。

对此大家怎么看?

下班啦下班啦下班啦下班啦下班啦下班啦1,下班啦下班啦下班啦下班啦.gif

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

文 /寇森

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

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

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

❌