阅读视图

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

前端向架构突围系列 - 框架设计(七):反应式编程框架Flower的设计

写在前面

这是《前端像架构突围 - 框架设计》系列的最终章。 我们不专门去说框架、聊响应式, 我们去学思想、看更上层的东西。

在前六章,我们聊了面向对象的本质、开闭原则的威力、以及接口职责的隔离。如果说那些是“内功心法”,那么今天我们要聊的,就是如何铸造一把趁手的“兵器”。

我们将从零开始构思一个名为 Flower 的反应式框架。

但请注意,这不是一篇“教你写 Vue 响应式原理”的教程。相反,这是一次关于**“反思”**的旅程。我们要探讨的是:当自动挡的反应式系统在复杂业务中失控时,我们该如何通过架构设计,找回丢失的控制权。


一、 引言:对“魔法”的恐惧

在写这一章之前,我其实犹豫过一段时间。不是因为反应式编程有多难实现,而是因为——我太清楚它有多容易失控了

在很多项目初期,反应式系统简直是天使:状态一改,视图自动更新,逻辑看起来干净又优雅。我们沉浸在 v-modeluseEffect 的便利中,享受着“声明式编程”的红利。

但当状态从 10 个变成 100 个,当依赖关系像蜘蛛网一样交织,当业务逻辑开始变得诡谲多变时,你会慢慢发现,那个曾经乖巧的系统开始“反噬”了:

  • 幽灵更新:改了一个看似无关的字段,为什么会导致半个页面重渲染?
  • 调试黑洞:数据流像一团乱麻,打断点都不知道该打在哪里,只能靠 console.log 碰运气。
  • 心智负担:新人不敢动核心状态,因为“它好像被很多地方依赖了,但我不知道具体是哪”。

这时候你会意识到:系统并不是在“响应变化”,而是在被变化牵着走。

Flower 的设计,正是从这种不安感开始的。

image.png


二、 核心定义:Flower 的边界

如果只是实现一个简单的反应式库,网上有无数个版本的 Object.definePropertyProxy 教程。

但架构师的职责不是“实现功能”,而是**“划定边界” 。在设计 Flower 之初,我做的第一个决策不是它“要有什么”,而是它“不要什么”**。

Flower 不解决以下问题:

  • UI 如何渲染(那是 React/Vue 的事)
  • 组件如何生命周期管理
  • 路由与网络请求

Flower 只解决一个核心命题:

  • 变化管理:变化从哪里产生?它如何有序地流向需要它的地方?

这是一个刻意“做小”的决策。因为我越来越确信:反应式系统一旦什么都想管(比如把 HTTP 请求也裹进响应式里),最终就会变成一团难以维护的泥球。

image.png


三、 设计决策 A:状态不是对象,而是“责任”

在很多主流框架中,状态(State)通常被建模为一个普通对象(Object)。

// 常见的做法
const state = reactive({ count: 0 });
state.count++; // 既是读取,又是修改,还是触发器

对象很方便,但它违反了我们之前提到的 SRP(单一职责原则) 。一个简单的对象属性,同时承担了“数据容器”、“读取接口”、“写入接口”和“变化通知”四个职责。

在 Flower 中,我决定剥夺状态的“对象身份”

我们将状态设计为原子信号(Atom Signal) ,并强制分离读写权限。这其实是 CQS(命令查询职责分离) 在前端的一次微观落地。

工程实现:

// Flower 的设计风格
const [count, setCount] = createSignal(0);

// count() -> 这是一个 Getter,只负责读取和依赖收集
// setCount() -> 这是一个 Setter,只负责写入和通知更新

为什么要这么麻烦?

因为这带来了**“引用透明性”**。

  • 如果你拿到的是 count,我知道你只能读,绝不可能悄悄修改它导致 Bug。
  • 如果你拿到了 setCount,我知道你是“生产者”,你要对变化负责。

通过 API 的设计,我们在代码层面强行约束了开发者的行为。这不是限制,这是保护。

image.png


四、 设计决策 B:拒绝“隐式依赖”的诱惑

自动依赖收集(Auto-Dependency Collection)是现代前端框架最迷人的“魔法”。

// 魔法:你没写任何订阅代码,但它就是工作了
effect(() => {
  console.log(state.name); // 自动收集了 state.name 的依赖
});

它确实好用,但在复杂工程中,我越来越警惕这种“悄悄发生的事情”。当依赖是隐式的,你就很难回答: “为什么这个函数执行了?”

Flower 在这里做了一个极其保守,甚至可以说“反潮流”的选择:显式依赖(Explicit Dependency)

我们参考了 DIP(依赖倒置原则) 的思想:高层逻辑不应该依赖于“运行时悄悄发生的读操作”,而应该依赖于“明确声明的契约”。

工程实现:

// Flower 的设计风格:你需要告诉我你关心什么
effect(
  // 1. 显式声明依赖列表(像 React 的 deps 数组,但更严格)
  [count, name], 
  // 2. 回调函数,参数即为依赖的当前值
  (currentCount, currentName) => {
    console.log(`Update: ${currentCount}, ${currentName}`);
  }
);

这种设计看起来“没那么聪明”,甚至有点啰嗦。但它换来的是确定性

在 Code Review 时,我看一眼依赖列表,就知道这个 Effect 会被什么触发。这种可推理性(Reasonability) ,在维护三年以上的老项目时,比什么魔法都珍贵。

image.png


五、 设计决策 C:调度器——解决“菱形依赖”难题

很多手写的反应式库(Toy Implementation)都会遇到一个经典 Bug: “闪烁”或“过渡态”

想象一下:A 变了,B 依赖 AC 依赖 A,而 D 同时依赖 BC。 当 A 更新时,D 可能会被触发两次(一次来自 B 的路径,一次来自 C 的路径),甚至在第一次触发时读到不一致的数据。这就是著名的 “菱形依赖问题” (Diamond Problem)

这就是为什么我说:“更新机制不是性能问题,而是正确性问题。”

Flower 引入了一个核心模块:调度器 (Scheduler)

工程实现:

调度器的核心逻辑是**“推-拉结合” (Push-Pull)**:

  1. Push 阶段:当信号变化时,不立即执行回调,而是标记所有脏节点(Dirty Marking)。
  2. Pull 阶段:在微任务(Microtask)队列中,按照拓扑排序(Topological Sort)的顺序,一次性计算出最终状态。
// 简化的调度逻辑
let dirtyQueue = new Set();

function schedule(effect) {
  dirtyQueue.add(effect);
  // 利用 Promise.resolve() 延迟到微任务执行
  queueMicrotask(flush);
}

function flush() {
  // 在这里进行排序、去重、批量执行
  // 确保 D 只会执行一次,且是在 B 和 C 都更新完之后
}

通过引入调度层,Flower 保证了:每一次更新,都是系统达到“稳定态”后的结果。 中间过程的动荡,被框架内部消化了。

image.png


六、 删繁就简:Flower 到底剩下了什么?

在设计过程中,我不断地问自己: “如果把 Flower 一层层剥开,删到不能再删,它还剩下什么?”

最后留下的,其实只有三个核心概念,它们构成了 Flower 的骨架:

  1. Signal (信号源) :负责定义数据和权限。
  2. Derive (计算属性) :负责数据的转换与派生。
  3. Effect (副作用) :负责与外部世界(如 DOM、日志)交互。

没有复杂的 Class,没有难以理解的配置对象,没有黑魔法。

这让我再次确认了一个架构真理:框架的价值,不在于提供了多少能力,而在于它限制了多少可能性。

Flower 限制了你随意修改状态的权力,限制了你隐式建立依赖的自由,但它给予了你**“系统无论怎么变,依然尽在掌握”**的安全感。

image.png


七、 结语:从“术”到“道”

回顾《前端向架构突围》的第二章,我们从面向对象的“类与继承”,一路走到设计原则的“SOLID”,最后落地到 Flower 框架的设计。

如果你仔细回味,会发现 Flower 的每一个设计决策,都是前面那些枯燥原则的投影

  • createSignal单一职责原则 的体现。
  • 显式依赖依赖倒置原则 的落地。
  • 不可变接口接口隔离原则 的实践。

架构设计并不是在追求“更聪明的算法”或“更短的代码”,而是在复杂的业务洪流面前,你是否愿意为系统设下清晰而坚定的边界。

反应式编程只是一个切入口。真正重要的,是你如何面对“变化”本身。

至此,框架设计篇章暂告一段落。但我们的突围之路才刚刚开始。在接下来的章节中,我们将走出代码的微观世界,去挑战更为宏大的工程化体系

互动思考: 在你的项目中,是否遇到过“不知道为什么这个组件又重新渲染了”的崩溃时刻?如果让你重新设计,你会更倾向于 Vue 的“自动收集”还是 React 的“显式依赖”?为什么?

前端向架构突围系列 - 框架设计(六):解析接口职责的单一与隔离

写在前面

这是《前端像架构突围》系列的第六篇。

在上一篇我们聊了 契约继承原则 ,今天我们把显微镜聚焦得更细一点,聊聊**“接口”**。

很多同学看到“接口职责单一”和“接口隔离”,第一反应是:跟我前端切图有什么关系?”

关系大了。你是否经历过一个 Table 组件写了 30 多个 props?你是否见过一个 useCommon Hook 里塞进了登录、埋点、弹窗和格式化逻辑?

前端的**“腐烂” ,往往不是因为技术栈落后,而是因为接口设计的边界模糊**。今天我们不谈枯燥的 SOLID 定义,只谈在前端组件、Hooks 和数据层设计中,如何利用**“隔离”**思维,从根本上消灭“上帝组件”。

image.png


一、 前端视角的“接口”究竟是什么?

在架构师的眼里,前端的 Interface 绝不仅仅是 TypeScript 里的 interface Props {}

前端的“接口”,是模块与外界通信的全部契约。 它包含三个维度:

  1. 数据契约:组件的 Props、Vue 的 Emits、以及后端返回的 JSON 结构。
  2. 逻辑契约:Hooks (Composables) 暴露出的 value 和 function。
  3. 交互契约:组件通过 ref 暴露给父组件的实例方法(如 modalRef.open())。

“职责单一”与“隔离”的核心目标只有一个:降低耦合,控制变化的影响范围。

如果你的组件因为“UI调整”要改,因为“后端字段更名”要改,甚至因为“埋点库升级”也要改,那这个组件就成了**“变化磁铁”**,它违反了单一职责,迟早会崩塌。


二、那些违反 ISP (接口隔离原则) 的反模式

我们先来看一个典型的“车祸现场”。这是一个展示用户信息的卡片组件。

反模式 1:全量依赖(贪婪接口)

// 类型定义:后端返回的完整的用户数据模型
interface User {
  id: string;
  name: string;
  avatar: string;
  email: string;
  role: 'admin' | 'user';
  settings: { theme: string; notify: boolean };
  // ... 可能还有20个字段
}

interface UserCardProps {
  user: User; //  罪魁祸首:直接依赖整个 User 对象
  onEdit: (u: User) => void;
}

const UserCard = ({ user, onEdit }: UserCardProps) => {
  // 组件其实只用到了 avatar 和 name
  return (
    <div className="card">
      <img src={user.avatar} />
      <span>{user.name}</span>
      <button onClick={() => onEdit(user)}>Edit</button>
    </div>
  );
};

为什么这是架构上的坏味道?

  1. 语义污染UserCard 本质上只需要“图片”和“名字”。如果你强制传入整个 User 对象,导致我在“好友列表”里复用这个组件时,必须构造一个假的 User 对象(这就叫 mocking hell)。
  2. 不必要的重渲染:如果 User 对象里的 settings.theme 变了,UserCard 会感知到 props 变化从而 re-render,尽管它根本不在乎 theme。
  3. 类型系统的脆弱性:后端如果把 email 字段删了,虽然 UserCard 没用到 email,但 TypeScript 可能会在父组件传参处报错,因为类型契约断了。

破局方案:按需声明(最小知识原则)

架构师的解法是:组件不应该依赖它不需要的东西。

// 1. 定义组件真正关心的接口(ISP)
interface UserCardProps {
  avatarUrl: string;
  displayName: string;
  onEdit: () => void; // 甚至不需要回传 User,由父组件闭包处理
}

// 2. 只有 UI 关注点
const UserCard = ({ avatarUrl, displayName, onEdit }: UserCardProps) => {
  return (
    <div className="card">
      <img src={avatarUrl} />
      <span>{displayName}</span>
      <button onClick={onEdit}>Edit</button>
    </div>
  );
};

// 3. 在父组件层进行“适配”
const Parent = () => {
  const { data: user } = useUser();
  
  return (
    <UserCard 
      avatarUrl={user.avatar}
      displayName={user.name}
      onEdit={() => handleEdit(user.id)}
    />
  );
};

架构收益: UserCard 从“特定业务组件”进化成了“通用 UI 组件”。现在它可以展示“当前用户”,也可以展示“推荐好友”,甚至可以展示“宠物信息”(只要有图和名字)。


三、配置地狱 vs 组合隔离

另一种常见的违反“职责单一”的场景,出现在通用组件的设计上。

为了复用,我们经常往组件里加 flag。

反模式 2:上帝组件(God Component)

// 一个试图满足所有人的 List 组件
interface ListProps {
  data: any[];
  //  职责混乱:既负责渲染列表,又负责头部,又负责搜索,又负责分页
  showSearch?: boolean;
  searchPlaceholder?: string;
  onSearch?: (val: string) => void;
  showPagination?: boolean;
  total?: number;
  renderHeader?: boolean;
  headerTitle?: string;
  // ... props 爆炸
}

随着业务迭代,这个组件内部会充斥着 if (showSearch) { ... } 的判断。每次修改任何一个小逻辑,都要小心翼翼防止改坏了其他功能。

破局方案:组合优于配置 (Composition over Configuration)

我们要利用 React/Vue 的 Slot (插槽)Children 机制,将职责隔离给外部。

// 职责单一:List 只管渲染列表
const List = ({ children }) => <div className="list">{children}</div>;
List.Item = ({ title }) => <div className="item">{title}</div>;

// 职责单一:Search 只管搜索
const SearchBar = ({ onSearch }) => <input onChange={...} />;

// 业务层:自由组合
const UserListFeature = () => {
  return (
    <div className="container">
      {/* 搜索职责隔离 */}
      <SearchBar onSearch={handleSearch} />
      
      {/* 列表职责隔离 */}
      <List>
        {users.map(u => (
          <List.Item key={u.id} title={u.name} />
        ))}
      </List>
      
      {/* 分页职责隔离 */}
      <Pagination total={100} />
    </div>
  );
};

架构收益:

  • List 组件 不再需要知道“搜索”的存在。
  • SearchBar 组件 可以单独优化、单独复用。
  • 如果哪天产品经理说“把搜索框放到列表底部”,你只需要调整 JSX 的顺序,而不需要去修改 List 组件内部那复杂的 if/else 渲染逻辑。

四、Hooks 与逻辑层的职责隔离

UI 隔离大家多少有点概念,但逻辑层的隔离往往是重灾区。我们经常看到一个 useTable 承担了所有工作。

混杂逻辑

const useTable = (apiEndpoint) => {
  // 1. 数据获取
  const [data, setData] = useState([]);
  
  // 2. 分页状态
  const [page, setPage] = useState(1);
  
  // 3. 筛选逻辑
  const [filters, setFilters] = useState({});
  
  // 4. URL 同步逻辑 (副作用)
  useEffect(() => {
     history.push(`?page=${page}`);
  }, [page]);
  
  // 5. 甚至还有 Excel 导出逻辑
  const exportExcel = () => { ... };

  return { data, page, setPage, exportExcel, ... };
}

这违反了 SRP。如果你只想换个 URL 同步库(比如从 react-router 换到 next/router),你得去改这个核心 Hook,风险极大。

逻辑拆分与组装 (Headless 思想)

好的架构应该是积木式的:

// 1. 纯粹的分页逻辑 (无副作用)
const usePagination = (initialPage = 1) => { ... };

// 2. 纯粹的数据请求 (不关心 UI)
const useFetchData = (params) => { ... };

// 3. 独立的 URL 同步逻辑
const useUrlSync = (state) => { ... };

// 4. 业务层 Hook:负责组装 (Orchestration)
const useUserTableLogic = () => {
  const { page, setPage } = usePagination();
  const { filters } = useFilters();
  
  // 组装逻辑:当 page 变了,去请求数据
  const { data, loading } = useFetchData({ page, ...filters });
  
  // 组装副作用:状态变了同步 URL
  useUrlSync({ page, filters });
  
  return { data, loading, page, setPage };
};

架构收益: * usePagination 可以被任何列表、轮播图复用。

  • 测试 usePagination 不需要 mock API 请求。
  • 修改 URL 同步逻辑不会影响数据请求逻辑。

五、数据接口的终极隔离 (ACL)

最后一个关键点是前端与后端的接口隔离

很多前端项目直接在组件里使用后端的字段名:

// 糟糕的代码:UI 深度耦合后端字段
<div>{data.user_real_name_v2}</div>
<div>{data.is_vip_flag === 1 ? 'VIP' : 'Normal'}</div>

如果后端重构,把 user_real_name_v2 改成了 realName,把 is_vip_flag 改成了布尔值,你的项目里可能有 50 个文件要跟着改。

架构突围方案:引入 Adapter(适配器)层。

// api/user.ts
// 定义前端需要的纯净 Model
interface UserModel {
  name: string;
  isVip: boolean;
}

// 适配器:将后端脏数据清洗为前端标准数据
const adaptUser = (serverData: any): UserModel => ({
  name: serverData.user_real_name_v2 || serverData.name, // 甚至可以做兼容
  isVip: serverData.is_vip_flag === 1
});

// 组件层只消费 UserModel,完全不知道 serverData 的存在
const UserProfile = ({ user }: { user: UserModel }) => {
  return <div>{user.name} - {user.isVip ? 'VIP' : ''}</div>
};

这就是**“数据接口隔离”**。无论后端怎么变,变化只止步于 adaptUser 函数,UI 层稳如泰山。


六、 总结与思考

在《前端像架构突围》的语境下, “接口职责单一隔离”不仅仅是代码洁癖,它是应对系统复杂度的核心手段。

  • 对 Props 隔离:让组件更通用,减少无谓渲染。
  • 对 Children 隔离:用组合代替配置,消灭上帝组件。
  • 对 Hooks 隔离:逻辑解耦,提升可测试性。
  • 对 API 隔离:建立防腐层,保护前端代码的稳定性。

下一步行动建议: 现在打开你项目里的 components 文件夹,找出一个 Props 超过 10 个的组件,或者一个代码行数超过 300 行的 Hook。试着问自己: “这个模块是不是承担了太多的职责?” ,然后尝试用本文提到的“按需声明”或“组合模式”进行一次重构。

架构能力的提升,就发生在这一次次对“边界”的重新审视中。


互动话题

在业务中更新迭代过快时, 可以不去关心这些东西, 但这些东西的输出, 更多的是要去转变你的思维, 让你有一个概念、印象这是一个潜移默化的转变过程, 让你看问题、看框架时、看业务时, 能站在上一层。

你的项目中是否也有那种“改一行代码,整个页面都崩了”的祖传组件?欢迎在评论区分享你的“屎山”重构血泪史!

iOS日志系统设计

1 为什么需要日志

软件系统的运行过程本质上是不可见的。绝大多数行为发生在内存与线程之中。在本机调试阶段,我们可以借助断点、内存分析、控制台等手段直接观察系统状态;而一旦进入生产环境,这些能力几乎全部失效。此时,日志成为唯一能够跨越时间和空间的观测手段

但如果进一步追问:日志究竟解决了什么问题? 这个问题并没有那么简单。日志的核心价值并不在于文本本身,而在于可见性。它最基础的作用,是让这些不可见的行为“显影”。一次简单的日志输出,至少隐含了三类信息:时间、位置、描述。

这也是我们不建议使用简单 print 的原因:结构化日志在每次记录时,都会自动携带这些关键信息,从而形成稳定、可检索的观测数据。

之后当系统规模变大、异步逻辑增多时,单条日志已经很难解释问题。真正有价值的,是一组日志所呈现出的因果关系. 在这一层面上,日志更像是事件证据,用于在事后还原一段已经发生过的执行流程。

接下来,我们将从这些问题出发,逐步讨论一套面向真实生产环境的日志与可观测性设计。

2 日志系统

尽管日志在实践中无处不在,它本身仍然存在天然局限:日志是离散的事件,而不是连续的流程;日志天然是“事后信息”,而不是实时状态;在客户端等受限环境中, 如 应用可能随时被杀掉, 各种网络情况不稳定, 隐私与安全限制,日志可能丢失、不可获取、不可上报。

这意味着,日志并不是系统真相本身,它只是我们理解系统的一种工具。当系统复杂度继续提升,仅靠“多打日志”往往无法解决根本问题。

2.1 范围与功能

这样我们应该勾勒出日志系统的一些边界范围.

  1. 不必要求日志的「绝对可靠上报」
  2. 不通过日志修复「系统设计问题」(业务依赖、生命周期、指责转移错误)
  3. 不将日志系统演化成「消息系统」(不持久化、不通过日志兜底)

所以我们通过边界范围基本确定了我们日志系统的一些要求:

  • 结构化并非文本化: 日志首先应该是结构化数据,而不是简单字符串。文本只是表现形式,结构才是核心价值.每条日志必须具备稳定的时间、位置、级别与上下文.日志应当天然支持检索、过滤与关联.
  • 本地优先, 而非远端依赖: 本地记录必须是同步、低成本、稳定的.远端上报只能是 best-effort 行为.系统不能因为远端日志失败而影响主流程

2.2 系统架构

LoggerSystemArchitectureDiagram.svg

我们刻意限制了日志系统的职责范围,使其始终作为一个旁路的、可退化的基础设施存在。这些约束并非功能缺失,而是后续实现能够保持稳定与可演进的前提。所有依赖关系均为单向:日志系统不会反向调用业务模块或基础设施。

3 本地日志

在整体架构中,本地日志被视为整个日志系统中最基础、也是最可靠的一环。无论远端日志是否可用、网络环境是否稳定,本地日志都必须能够在任何情况下正常工作。

因此,在实现层面,我们选择优先构建一套稳定、低成本、符合平台特性的本地日志能力,而不是从远端导出反推本地设计。

3.1 为什么是os.Logger

在 iOS 开发里,print 很直观,但它更像调试手段:没有结构、难以检索、性能成本不可预测,也无法进入系统日志体系。进入生产环境后,这些缺点会被放大。

os.Logger 是系统级日志通道,它的设计目标本身就面向“可长期运行”的生产场景。本文中,os.Logger 指 Apple 的系统日志实现,Logger 指本文封装的对外 API。我们选择它,主要基于这些原因:

  • 低成本写入:日志被拆分为静态模板与动态参数,格式化发生在读取阶段,而非写入阶段
  • 系统工具链一致性:天然接入 Console、Instruments 与系统日志采集工具
  • 隐私与合规能力:原生支持隐私级别控制
  • 结构化上下文:时间戳、级别、分类、源码位置可以稳定保留

因此,日志可以作为“长期存在的基础能力”,在核心路径中持续开启,而不是仅限于调试阶段。需要说明的是,系统日志在生产环境的获取也受平台权限与采集策略限制,所以它是“本地可靠”,但并不是“远端万能”。

在使用层面,Logger API 保持简单直接:

let logger = Logger(subsystem: "LoggerSystem")
logger.info("Request started")
logger.error("Request failed", error: error)

3.2 附加功能

除了系统日志的即时写入,我们还提供了几个本地诊断能力:通过 LogStore / OSLogStore 进行日志检索(按时间、级别、分类)并支持导出为文本或 JSON;同时集成 OSSignpost / OSSignposter 作为性能事件记录方式,用于衡量关键路径耗时。这些能力不进入主写入路径,只在排查与分析时启用。

4 远端日志与 OpenTelemetry

4.1 OpenTelemetry 在客户端日志系统中的位置

OpenTelemetry 由 CNCF 托管,起源于 2019 年 OpenCensus 与 OpenTracing 的合并,并于 2021 年成为 CNCF 顶级项目。它并不是某一个具体 SDK,而是一套用于描述可观测性数据的开放标准,定义了日志、指标与链路追踪的统一语义与数据模型,并配套给出了标准化的传输协议(OTLP)。

在本章中,我们并不试图完整覆盖 OpenTelemetry 的体系,而是聚焦于其中与远端日志相关的部分:

日志数据在 OTel 语义下如何被结构化、如何被分组、以及如何被导出。

认证、上下文传播等问题会显著影响系统依赖关系,本文刻意将其延后,在下一章单独讨论。

4.1.1 Remote Logger 的最小闭环

下图展示了在 OTel 语义下,客户端远端日志链路的最小可用闭环

这一闭环的目标并非“可靠投递”,而是在客户端约束条件下,提供一条可控、可退化的日志导出路径

从数据流动的角度看,这条链路可以被抽象为:

LogRecord[]
  → LogRecordAdapter
  → ResourceLogs / ScopeLogs / LogRecords
  → ExportLogsServiceRequest (OTLP)
  → OTLP Exporter (HTTP / gRPC)

在这一结构之上,可以按需叠加增强能力,例如批处理、失败缓存或延迟调度,但这些能力不会改变日志的协议语义

4.1.2 结构化与分组:从 LogRecord 到 OTel Logs

在 OTel 模型中,LogRecord 仍然是最小的事件单元,用于描述“发生了什么”。

但真正的上传结构并不是一组扁平的日志列表,而是按以下层级组织:

  • ResourceLogs:描述日志产生的资源环境(设备、系统、应用)
  • ScopeLogs:描述产生日志的逻辑作用域(模块、库)
  • LogRecords:具体的日志事件

这一分组方式的意义在于:

  • 避免重复携带环境信息
  • 明确日志的来源与归属
  • 为后端聚合与分析提供稳定结构

在客户端侧,这一阶段通常通过一个 Adapter 或 Mapper 完成,其职责只是语义映射,而非业务处理。

4.1.3 批处理与调度:Processor 的职责边界

在日志被结构化之后,下一步并不是立刻发送,而是进入处理阶段。

LogRecordProcessor 位于这一阶段的入口位置。以 BatchLogRecordProcessor 为例,它负责:

  • 将多条日志聚合为批次
  • 控制发送频率
  • 降低网络与系统调用成本

需要注意的是,Processor 层体现的是策略位置,而不是协议逻辑。

它不关心日志如何被编码,也不关心最终通过何种方式发送,只负责决定什么时候值得尝试导出

4.1.4 导出边界:Exporter 作为协议适配层

LogRecordExporter 是远端日志链路中的协议边界

在这一层中,结构化日志会被转换为 OTLP 定义的 ExportLogsServiceRequest,并通过具体传输方式发送。常见实现包括:

  • OTLP/HTTP
  • OTLP/gRPC

无论采用哪种方式,Exporter 的核心职责都是一致的:

编码日志结构,并完成协议级发送。

它不感知业务上下文,也不参与重试策略之外的系统决策。

4.2 RemoteLogger 架构图

下面这张图是 RemoteLogger 在 OTel 语义下的最小闭环:

RemoteSinkSystem.svg

5 上下文边界

从 RemoteLogger 开始真正发日志的那一刻,日志系统就第一次碰到外部依赖:鉴权。它不是“可选附加项”,而是远端链路的必经之门。

5.1 鉴权

日志端点是基础设施入口(infra endpoint),它的目标是控制写入来源,而不是验证用户身份。因此更合理的鉴权方式是:IP allowlist、mTLS、ingestion key、project‑level key,配合采样与限流。这些机制与用户态解耦、无需刷新、不参与业务控制流,且失败也不会影响客户端主流程。

在这种模型下,日志系统只做一件事:携带已准备好的凭证。它不维护鉴权状态,也不触发刷新,更不等待鉴权完成。

5.1.1 当鉴权被卷入业务流程

问题发生在日志复用业务鉴权体系时:access token 短期、频繁刷新、刷新依赖网络与生命周期,而鉴权失败本身又需要被记录。直觉做法是“刷新后重试”,但这会形成典型的依赖循环:

Logger
  → RemoteExporter
     → AuthManager
        → RefreshToken
           → Network
              → Logger

这不是实现复杂的问题,而是依赖方向错误的问题:日志系统依赖了本应被它观测的系统,直接造成 auth blind zone(鉴权失败本身无法被观测的区域)

现实里,很多团队不得不复用业务鉴权,但这时唯一能做的是“隔离副作用”:不触发刷新、不重试鉴权、失败即丢弃,并保持凭证只读与短生命周期缓存。这样做无法消灭问题,却能把依赖循环缩到最小。

结论只有一句:日志系统只能消费鉴权结果,不能成为鉴权流程的一部分。

5.2 Traceparent

traceparent 是 W3C Trace Context 标准里的核心头部,用于跨进程传播 Trace。它不是日志系统的一部分,而是“流程上下文”的载体。日志系统只负责把它携带出去,而不负责生成、维护或推进它。

它的基本结构非常固定:

traceparent: {version}-{trace-id}-{parent-id}-{trace-flags}
  • version:版本号,当前常见为 00
  • trace-id:16 字节(32 hex)的全局 trace 标识
  • parent-id:8 字节(16 hex)的当前 span 标识
  • trace-flags:采样与调试标记(如 01 表示采样)

这四个字段共同决定“这条日志属于哪条 trace、处于哪一段 span 上下文”。

5.2.1 构造与传递

在客户端日志系统中,traceparent 应当被视为外部上下文

  • 它由业务流程或 tracing 系统生成
  • 它随着请求/事件生命周期变化
  • 它不由日志系统创建,也不由日志系统维护

日志系统只做一件事:在日志生成或导出时附带 traceparent,让后端能够把日志与 Trace 对齐。

这也意味着:日志系统不能尝试“修复” traceparent,也不能在缺失时伪造它。缺失就缺失,伪造会制造错误的因果链。

traceparent 的构造来自 tracing 系统(SDK 或上游服务),它会在一次请求开始时生成新的 trace-id,并在 span 变化时更新 parent-id。日志系统只需在“生成日志的瞬间”读取当前上下文并携带即可。

换句话说,traceparent 的生命周期与日志系统无关,而与业务流程一致。日志系统需要尊重这个边界,否则它会再次变成主流程的一部分。

5.3 Context 的角色

如果说 traceparent 是“具体的上下文载体”,那么 Context 是“上下文在系统里的容器与作用域”。它回答的不是“字段长什么样”,而是“这份上下文从哪来、到哪去、何时结束”。

在日志系统里,Context 只应当承担两件事:

  1. 携带流程信息,让日志具备可关联性
  2. 限定生命周期,避免上下文在系统内滞留

这意味着 Context 的设计重点不是“存什么”,而是“何时注入、如何传播、何时释放”。

更宽泛地看,Context 的生命周期其实是一种“作用域建模”。它既像 tracing 里的 active span,也像 DI 里的 scope:谁负责创建、谁负责结束、跨线程如何继承,这些都会直接决定 traceparent 的 parent-id 何时变化。换句话说,Context 的问题往往不是协议问题,而是作用域与生命周期策略的问题。

Context 最难的部分其实是作用域与注入方式:

  • 它和依赖注入(DI)的关系应该是什么
  • 在多线程/异步场景中,Context 的边界如何定义
  • Context 是否应该是显式传参,还是隐式绑定

这些问题没有一个完美答案,需要团队给出清晰的工程约定。

5.4 结语

这套日志系统的核心不是“更多日志”,而是“正确的边界”:本地优先、远端旁路、结构化可关联、上下文可控。真正决定系统稳定性的,不是某一个 API,而是你如何定义依赖方向与生命周期。最后想留下的一句话是:可观测性不是无限的,它永远受平台约束。

丧心病狂!在浏览器全天候记录用户行为排障

1 前言

QA:“bug, 你把这个 bug 处理一下。”

我:“这个 bug 复现不了,你先复现一下。”

QA:“我也复现不了。”

(PS: 面面相觑脸 x 2)

众所周知,每个公司每个项目都可能存在偶现的缺陷,毋庸置疑,这为问题的定位和修复带来了严重的阻碍。

要解决这个问题,社区方案中常常依赖 datadog、sentry 等问题记录工具,但这些工具存在采样率限制或依赖错误做信息收集,很难做到 100% 的日志记录。

emoji_002.png

偶然间,我看到了 pagespy,它符合需求,但又不完全符合,好在调研下来,我们只要魔改一番,保留其基础的日志能力,修改其存储方式,就能得到一个能做全天候日志采集的工具。

那么,目标明确:

  • 实现全时段用户行为录制与回放
  • 最小化对用户体验的影响
  • 确保数据安全与隐私保护
  • 与现有系统(如 intercom )无缝集成

2 SDK 设计

目前 pagespy 设计目标和我们预期并不一致,并不能开箱即用。pagespy 的方案不满足我们需求的点在于:

  1. 没有持久化能力,内存存储,单次录制不对数据做导出则数据清空。
  2. pagespy 的设计理念中。数据是需要显式由用户手动导出的,但我们是需要持续存储数据。

经过对 pagespy 的源码解析以及文档阅读,整理出来其中分支的 OSpy(离线版 pagespy 的数据走向如下):

image.png

我们可以通过 inject 的形式,把这两个能力代理到我们的逻辑中。

image.png

样式上,则通过插入一段 style 强制将 dom 样式隐藏。

  document.head.insertAdjacentHTML(
    'beforeend',
    `<style>
    #o-spy {
      display: none;
    }
    </style>`,
  );

至此,我们已经基本脱离了 pagespy 的数据 in & out 逻辑,所有数据都由我们来处理,包括数据存储也需要我们重新设计。

2.1 日志存储方案

✅ 确定日志存储方案。需要注意避免大量日志将用户的电脑卡死。

✅ pagespy 的设计理念中。数据是需要显式由用户手动导出的,但我们是需要持续存储数据。

✅ pagespy 为了防止爆内存引入了时间上限等因素,会时不时清除数据(rrweb 存在非常重要的首屏帧,缺少该帧后续都无法渲染成功),这会导致以单个浏览器标签作为切片的设计逻辑被迫中断,会对我们的逻辑带来负面影响。

为了实现全时段存储的目标,经评估除了 indexDB 之外没有其他很好的存储方案可以满足我们的大容量需求。在此,决定引入 dexie 进行数据库管理。

import type { EntityTable } from 'dexie';
import Dexie from 'dexie';

const DB_NAME = 'SpyDataHarborDB';

export class DataHarborClient {
  db: DBType;
  constructor() {
    this.db = new Dexie(DB_NAME) as DBType;
    this.db.version(1).stores({
      logs: '++id,[tabId+timestamp],tabId, timestamp',
      metas: '++id,tabId,startTime,endTime',
    });
  }
}

export const { db } = new DataHarborClient();

我们将日志以浏览器标签页为维度进行拆分,引入了 tabId 的概念。并设计了两个表,一个用于存储日志,一个用于存在 tab 的基本信息。

type DBType = Dexie & {
  logs: EntityTable<{
    id?: number;
    tabId: string;
    timestamp: number;
    data: string;
  }>;
  metas: EntityTable<{
    id?: number;
    tabId: string;
    size: number;
    startTime: number;
    endTime: number;
  }>;
};

这意味着,从 pagespy 得到的数据只需要直接入库,我们在每次入库后做一次日志清理,即可实现一个基本的存储系统。

  async addLog(data: CacheMessageItem) {
    const now = new Date();

    const dataStr = JSON.stringify(data);

    await db.logs.add({
      tabId: this.tabId,
      timestamp: now.getTime(),
      data: dataStr,
    });

    await db.transaction('rw', ['metas'], async (tx) => {
      const meta = await tx.metas.get({
        tabId: this.tabId,
      });
      if (meta) {
        meta.size += dataStr.length;
        meta.endTime = now.getTime();
        await db.metas.put(meta);
        return meta;
      } else {
        await db.metas.add({
          tabId: this.tabId,
          size: dataStr.length,
          startTime: now.getTime(),
          endTime: now.getTime(),
        });
      }
    });
  }

在我们完成日志入库之后,额外需要考虑的是持续直接入库的性能损耗。 经测试,通过 worker 进行操作与直接在主线程进行操作,对主线程的耗时影响对比表格如下(基于 performance.now()):

操作方式 峰值 最低值 中位数 平均值
worker + insert 5.3 ms 0ms 0.1ms 0.31ms
直接 insert 149.5 ms 0.4ms 3.6ms 55.29ms

所以最终决策将数据库操作转移到 worker 中实现——但这又反应了一点问题,目前 pagespy 的入库数据是序列化后的字符串,并不能很好地享受主线程和 worker 线程之间通过 transfer 传输的性能优势。

2.2 安全和合规问题

目前可知,我们的方案先天就存在较严重的合规问题 🙋,这体现在:

  1. pagespy 会保存一些隐秘的 storage、cookie 数据到 indexedDB 中,有一定安全风险。
  2. pagespy 基于 rrweb ⏺️ 录制页面,用户在电脑上的行为和信息可能被记录。(如 PII 数据)

第一个问题,我们可以考虑直接基于 Pagespy 来记录,其实际上提供了 API 允许我们自行决定要抛弃哪些信息。

使用时,类似于:

    network: (data) => {
      if (['fetch', 'xhr'].includes(data.requestType)) {
        data.responseHeader?.forEach((item) => {
          if (item[0] === 'set-cookie') {
            item[1] = obfuscate(item[1]);
          }
        });
        return true;
      }
      return true;
    },

image.png 第二个问题,我们应考虑基于 rrweb 的默认隐私策略来做处理,rrweb 在 sentry、posthog 中都有使用,都是基于默认屏蔽规则来允许,所以我们使用默认屏蔽规则,其他库的隐私合规也相当于一起做了。

所以,我们需遵循以下规则(rrweb 默认屏蔽规则)修改 Web 端,而不是 SDK:

  • 具有该类名的元素.rr-block不会被记录。它将被替换为具有相同尺寸的占位符。
  • 具有该类名的元素.rr-ignore将不会记录其输入事件。
  • 具有类名的元素.rr-mask及其子元素的所有文本都将被屏蔽。和 block 的区别是,只会屏蔽文本,不会直接替换 dom 结构(也就是背景颜色之类的会保留)
  • input[type="password"]将被默认屏蔽。

根据元素是否包含“用户输入能力”,分为 3 种处理方式:

  • 1️⃣ 包含输入能力(如 input, textarea,canvas 可编辑区域)

    • 目的:既屏蔽用户的输入行为,也屏蔽输入内容
    • 处理方式:添加 rr-ignorerr-block 两个类
    • 效果:

image.png

  • 2️⃣ 不包含输入能力(如纯展示类的文本)

    • 目的:保留结构,隐藏文本内容,避免泄露隐私
    • 处理方式:添加 rr-mask 类,将文本进行混淆显示
    • 效果:

image.png

  • 3️⃣ 图片、只读 canvas 包含隐私信息(如签名)

    • 目的:隐藏内容
    • 处理方式:添加 rr-block

2.3 日志获取和处理

在上述流程中,我们设计了基于浏览器标签页的存储系统,但由于 rrweb 和 ospy 的设计,我们仍有两个问题待解决:

  1. ospy 中的 meta 帧只在 download 时获取,并需要是 logs 的最后一帧。
  2. rrweb 存在特殊限制,即必须存在首 2 帧,否则提取出来的日志无法显示页面。

这两个问题我们需要特殊处理,针对 meta 帧的情况,首先要知道,meta 帧包含了客户端信息等数据:

image.png

image.png

这部分信息虽然相比之下不是那么重要,但在特定场景中非常有用,nice to have。在此前提下,由于 ospy 未提供对外函数,我们需要自行添加该帧。目前,meta 帧会在 spy 初始化时自动插入,然后在读取时排序到尾部。

// 这个其实是 spy 的源码
export const minifyData = (d: any) => {
  return strFromU8(zlibSync(strToU8(JSON.stringify(d)), { level: 9 }), true);
};

export const getMetaLog = () => {
  return minifyData({
    ua: navigator.userAgent,
    title: document.title,
    url: window.location.href,
    startTime: 0,
    endTime: 0,
    remark: '',
  });
};

第二个问题相比之下更加致命,但解决起来又异常简单。rrweb 的机制决定了我们在导出的时候必定要查询出第一二帧,我们在获取日志时需要特殊处理:

  1. 获取用户指定日期范围内的日志的 tabId。
  2. 基于 tabId 筛查出所有日志,筛查出 < endTime 的所有日志。
async getTabLogs({ tabId, end }: { tabId: string; end: number }) {
    // 日志获取逻辑
}

(如你所见,获取日志阶段 start 直接 gank 没了)

此外,由于持续存储特性,读取日志时会面临数据量过大的问题。例如,8 分钟连续操作导出的日志约 17MB,一小时约 120MB。按照平均每小时录制数据量估算,静态浏览约 2 - 5MB,普通交互约 50MB,高频交互约 100MB。以单个用户每日使用 8 小时计算,平均用户约 400MB / 天,重度用户约 800MB / 天。基于 14 天保留策略,单用户最大存储空间约为 12GB。

这意味着如果用户选择的时间范围较大,传统读取流程可能读取 10GB+ 日志到内存,这显然会导致浏览器内存溢出。

为避免读取大量日志导致浏览器内存溢出,我们采用分片式读取。核心思想是将指定 tab 的日志数据按需 “分片提取”,通过回调逐步传输给调用方,确保高效、稳定地处理大体积日志的读取与传输:

  1. 读取元信息 (meta):

    • 通过 tabIddb.metas 获取对应日志的元信息(如日志总大小)。
  2. 判断是否需要分片:

    • 如果日志总大小小于阈值 MIN_SLICE_CHUNK_SIZE一次性读取所有日志,拼接成完整 JSON,再调用 callback 发送。
  3. 大文件分片处理逻辑:

    • 根据日志总大小计算合适的 chunkSize,从而决定分片数量 chunkCount
    • 每次读取一部分日志数据(受限于计算出的 limit),拼接为 JSON 片段,通过 callback 逐步传出。
    • 每片都使用 Comlink.transfer() 进行内存零拷贝传输,提高性能。
  4. 合并与补充 meta 信息:

    • 如果日志数据中有 meta 类型数据(携带一些压缩信息),在最后一片中进行处理与拼接,保持语义完整。
  5. 进度追踪与标记:

    • 每一片传输都附带 progresspartNumber,便于前端追踪处理进度。
  async getTabLogs(
    {
      tabId,
      end,
    }: {
      tabId: string;
      end: number;
    },
    callback: (log: { content: Uint8Array; progress: number; partNumber: number }) => void | Promise<void>,
  ) {
  
    ...

    const totalSize = meta.size + BUFFER_SIZE;
    // 根据 totalSize、MAX_SLICE_CHUNK、MIN_SLICE_CHUNK_SIZE 计算出最佳分片大小
    const chunkSize = Math.max(Math.min(totalSize / MAX_SLICE_CHUNK, MIN_SLICE_CHUNK_SIZE), MIN_SLICE_CHUNK_SIZE);

    const chunkCount = Math.ceil(totalSize / chunkSize);

    let offset = 0;
    const count = await db.logs
      .where('tabId')
      .equals(tabId)
      .and((log) => log.timestamp <= end)
      .count();

    const limit = Math.max(1, Math.ceil(count / chunkCount / 3));

    let metaData: string | null = null;

    let startTime = 0;
    let endTime = 0;

    let preLogStr = '';
    let progressContentSize = 0;
    let partNumber = 1;
    while (offset <= count) {
      try {
        const logs = await db.logs
          .where('tabId')
          .equals(tabId)
          .and((log) => log.timestamp <= end)
          .offset(offset)
          .limit(limit)
          .toArray();

        let baseStr = preLogStr;
        if (offset > 0) {
          baseStr += ',';
        } else if (offset === 0) {
          baseStr += '[';
        }

        endTime = logs?.[logs.length - 1]?.timestamp ?? endTime;
        if (offset === 0) {
          startTime = logs?.[0].timestamp ?? 0;
        }

        offset += logs.length;

        const logData = logs.map((log) => log.data).filter((log) => log !== '"PERIOD_DIVIDE_IDENTIFIER"');
        ...

        const logsStr = logData.join(',');
        baseStr += logsStr;

        if (offset === count) {
          if (!metaData) {
            await callback({
              content: transfer(baseStr + ']'),
              progress: 1,
              partNumber,
            });
          } else {
            const metaJson = JSON.parse(metaData);
            const parseMetaData = parseMinifiedData(metaJson.data);
            const metaMinifyData = minifyData({
              ...parseMetaData,
              startTime,
              endTime,
            });
            const metaStr = JSON.stringify({
              type: 'meta',
              timestamp: endTime,
              data: metaMinifyData,
            });
            await callback({
              content: transfer(baseStr + ',' + metaStr + ']'),
              progress: 1,
              partNumber,
            });
          }
          break;
        }

        progressContentSize += baseStr.length;
        const progress = Math.min(0.99, progressContentSize / totalSize);

        // 如果 size < minSize,那么就继续获取
        if (baseStr.length < MIN_SLICE_CHUNK_SIZE) {
          preLogStr = baseStr;
          continue;
        }

        preLogStr = '';
        await callback({
          content: transfer(baseStr),
          progress,
          partNumber,
        });
        partNumber++;
      } catch (error) {
        console.log(error);
        break;
      }
    }
  }

3 工作流设计

3.1 👼 基础工作流

我们公司采用 intercom 和外部客户沟通,用户可以在网页右下角的 intercom iframe 中和客服沟通。

image.png

所以,如果有办法将整个日志流程合并到目前的 intercom 流程中,不仅贴合目前的业务情况,而且不改变用户习惯。

通过调研,可以确定以下方案:

  1. CS 侧配置默认时间范围,需要 POST /configure-card 进行表单填写,填写后表单会在下一步被携带到 payload 中。
  2. CS 侧在发送时,会 POST /initialize接口(由自有后端提供),接口需返回 canvas json 数据。如:
{
  canvas: {
    content: {
      components: [
        {
          type: "text",
          text: "*Log Submission*",
          style: "header",
        },
        {
          type: "button",
          label: "Select logs",
          style: "primary",
          id: "submit_button",
          action: {
            type: "sheet",
            url: "xxxxxx",
          },
        },
      ],
    },
  },
}
  1. 发送后,用户点击 sheet 按钮可以跳转到前端,但需注意,该请求为 POST 请求。
  2. 用户填写完表单,提交时可以直接请求后端接口,也可以由 intercom 服务端向后端发起 POST 请求。
  3. 如期望在提交后修改消息状态,则必须在上一步执行【由 intercom 服务端向后端发起 POST 请求】(推荐,最完整的 flow),此时后端需返回 canvas json,后端同步触发逻辑,添加 note 到 intercom 页面,方便 CS 创建 jira 单时携带复现链接

Editor _ Mermaid Chart-2025-05-09-062511.png

3.2 ⚠️ 增强工作流

在我们上述 flow 中,需要获取用户授权,由用户操作触发下载和上传日志的过程,但实际上有比较刑的方案。

具体 flow 如图:

image.png

该方案的整体优势是:

  1. 无需 CS 介入,无需修改 CS 流程。
  2. 用户对日志上传感知力度小

换句话说,隐私合规风险较大。

4 工作流技术要点

4.1 😈 iframe 实现

Iframe 指的是 【日志上传 iframe】,对应这一步骤:

image.png

由于 intercom 将基于 POST 请求去调用服务希望得到 html 的限制,这里存在两个问题:

  1. Intercom 使用 POST 请求,则我们的服务需要支持 POST 请求返回 html,目前是不支持的,所以需要解决方案。
  2. 由于我们的 iframe 网页要读取日志,那么 iframe 地址必须和 Web 端同源,但生产的 API 地址和 Web 端不同源。

基本方向上,我们可以通过反向代理的方式实现:

image.png

iframe 的同源限制比预想的还要麻烦一些,由于 intercom 的接入方式是 iframe 嵌套,类似于:A(<https://samesite.com/>)->B(<https://xxxx.com/>)->A(<https://samesite.com>)

这个过程会导致两个跨域限制:

  1. Cookie 的跨域限制,具体表现为用于登录态的 Cookie 由于未显式设置 Samesite: None ,无法被携带进内层网页,进而丢失登录态。
  2. indexedDB 的跨域限制,由于中间多了一层外域,浏览器限制了最里边的网页读取 indexedDB,具体表现为读取到的数据为🈳。

Cookie 的跨域限制通过显式设置 Samesite 可以解决,但进一步地,为了确保安全性,我们需要给网页其他路径添加X-Frame-Options SAMEORIGIN; 防止外域嵌套我们的其他网页。

后者卡了一阵子,最后的解决思路是通过 postMessage 通信的方式变相读取——反正能读取到就行。

  window.top.postMessage(
    {
      type: 'uploadLogs',
      id: topUUID,
      params: {
        start,
        end,
      },
    },
    '*',
  );

(有趣的是,排查过程中发现了 chrome devtools 的缺陷,devtools 里的 document 都指不到最外层,但是实际上 window.top 和 window.parent.parent.parent 都是最外层,具体不细说了)

4.2 🥹 日志安全与上传

日志的格式是 JSON 格式,将其拖拽到 ospy 中即可复原用户浏览器操作记录,一旦泄漏会有极高的安全风险。在此,提出加密方案用于解决该问题。

思路其实很简单:在文件上传前对文件内容进行 AES 加密,对 AES 密钥做 RSA 非对称加密,通过公钥加密,然后将加密后的密钥附加到文件尾。

image.png

其实还可以进一步,我们在写入日志的时候就加密,但这样读取的时候压力会比较大,因为日志是一段一段的,或许我们还需要定制分隔符。

5 总结

好,那么理所当然的,我们应该不会遇到其他卡点卡,方案落地应该是没问题了。但——

Leader: “有个问题,我们没有分片上传”

我: ”Woc? 又要自己写?”

欲知后事如何,且听下回分解。

❌