《互联网产品的客户端架构·规模化与架构演进》1.2 共享内核与端上适配层

共享内核不是把所有代码塞进一个包,而是把可共享的判断放在 core、把端差异隔离在 adapter,并用依赖规则防止逆向泄漏。本节用四层模型与依赖审计函数,演示如何把金额、订单等核心逻辑抽成可跨端复用、可自动检查的内核,并给出内核版本、边界案例与 CI 接入方式。

三份金额代码,三种舍入

一个交易功能铺到三个端后,团队发现退款金额偶尔差 1 分。排查后发现三个端各写了一份「按比例分摊优惠」的实现:

  • Web 用浮点数计算后四舍五入。
  • iOS 先转 Decimal 再截断。
  • Android 用整数分计算,但分摊余数给了第一行。

三份代码都「看起来对」,但边界处理不同。更麻烦的是,修复时要在三个仓库各改一次,还要保证改法一致。

这类问题的根因不是缺测试,而是核心判断没有唯一实现。共享内核要解决的正是这件事:把与端无关的判断收在一处,让端只负责适配。

四层模型

共享不是二元的「共享/不共享」,而是四层:

层放什么可以依赖典型例子
core纯判断、纯计算、状态机只能依赖 core金额分摊、订单状态迁移、券判定
protocol数据结构、序列化、契约core、protocol订单 DTO、事件 schema
adapter平台能力封装前三层HTTP、存储、支付 SDK、埋点
ui端上界面与交互任意层页面、组件、路由

规则只有一条:依赖只能向下,不能向上,也不能跨层回头。core 不知道 adapter 存在,protocol 不知道 ui 存在。

这条规则听起来教条,但它挡住了最常见的一种腐化:某个端为了省事,在 core 里直接调用平台 API。一旦发生,core 就只能在那个端运行,共享名存实亡。

共享的判据

不是所有代码都值得共享。三个判据:

  1. 判断是否与端无关。 金额、状态、规则与端无关;手势、动画、权限申请与端有关。
  2. 错了是否要人工介入。 资金与状态错误值得共享,样式差异不值得。
  3. 是否有稳定的输入输出。 能写成纯函数、能用测试固定行为的,适合共享;依赖全局状态与生命周期的,不适合。

按这三条,core 应该很小:金额、订单、券、库存、幂等键。它不应该包含「页面如何组织」「按钮放哪」「用什么动画」。

依赖审计

四层模型只有能被检查才有意义。下面这个模块把模块、层级与依赖变成图,输出两类问题:层级违规与循环依赖:

// file: src/platform/shared-core.ts
// tier: L1
// verify: node --test test/platform/shared-core.test.ts

export type Layer = "core" | "protocol" | "adapter" | "ui";

export type ModuleNode = {
  name: string;
  layer: Layer;
  deps: string[];
};

export const LAYER_ORDER: Layer[] = ["core", "protocol", "adapter", "ui"];

const ALLOWED: Record<Layer, Layer[]> = {
  core: ["core"],
  protocol: ["core", "protocol"],
  adapter: ["core", "protocol", "adapter"],
  ui: ["core", "protocol", "adapter", "ui"],
};

export function layerRank(layer: Layer): number {
  return LAYER_ORDER.indexOf(layer);
}

export function allowedDependency(from: Layer, to: Layer): boolean {
  return ALLOWED[from].includes(to);
}

export function findByName(nodes: ModuleNode[], name: string): ModuleNode | undefined {
  return nodes.find((n) => n.name === name);
}

export function auditEdges(nodes: ModuleNode[]): string[] {
  const problems: string[] = [];
  for (const node of nodes) {
    for (const dep of node.deps) {
      const target = findByName(nodes, dep);
      if (!target) {
        problems.push(`missing:${node.name}->${dep}`);
        continue;
      }
      if (!allowedDependency(node.layer, target.layer)) {
        problems.push(`layer:${node.name}(${node.layer})->${dep}(${target.layer})`);
      }
    }
  }
  return problems;
}

export function detectCycles(nodes: ModuleNode[]): string[][] {
  const graph = new Map<string, string[]>();
  for (const node of nodes) graph.set(node.name, node.deps);
  const state = new Map<string, 0 | 1 | 2>();
  const stack: string[] = [];
  const cycles: string[][] = [];

  const visit = (name: string): void => {
    const mark = state.get(name) ?? 0;
    if (mark === 1) {
      const start = stack.indexOf(name);
      cycles.push([...stack.slice(start), name]);
      return;
    }
    if (mark === 2) return;
    state.set(name, 1);
    stack.push(name);
    for (const dep of graph.get(name) ?? []) {
      if (graph.has(dep)) visit(dep);
    }
    stack.pop();
    state.set(name, 2);
  };

  for (const name of graph.keys()) visit(name);
  return cycles;
}

export function coreDependencies(nodes: ModuleNode[]): string[] {
  const out = new Set<string>();
  for (const node of nodes) {
    if (node.layer !== "core") continue;
    for (const dep of node.deps) {
      const target = findByName(nodes, dep);
      if (target) out.add(target.name);
    }
  }
  return [...out].sort();
}

export function auditGraph(nodes: ModuleNode[]): { edges: string[]; cycles: string[][] } {
  return { edges: auditEdges(nodes), cycles: detectCycles(nodes) };
}

auditEdges 检查每条边是否合法,detectCycles 用深度优先搜索找环。两者分开,是因为它们的修复方式不同:层级违规通常要挪代码,循环依赖通常要抽接口。

测试与真实输出

✔ 层级顺序固定 (0.041958ms)
✔ 层级可以比较 (0.034208ms)
✔ core 只能依赖 core (0.03975ms)
✔ protocol 可以依赖 core 与 protocol (0.036667ms)
✔ adapter 可以依赖前三层 (0.038125ms)
✔ ui 可以依赖任意层 (0.043167ms)
✔ 健康依赖图没有边违规 (0.047625ms)
✔ 缺失依赖被单独标记 (0.040042ms)
✔ core 依赖 adapter 被标记为层级违规 (0.044917ms)
✔ 健康图没有环 (0.039417ms)
✔ 两节点互相依赖被识别为环 (0.051542ms)
✔ 自依赖也被识别为环 (0.038625ms)
✔ 共享内核的直接依赖被列出 (0.035875ms)
✔ 按名称查找模块 (0.033458ms)
✔ 组合审计同时返回边问题与环 (0.04625ms)
ℹ tests 15
ℹ suites 0
ℹ pass 15
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 132.897417

环的表示值得注意:["a","b","a"] 首尾相同,是为了让报告直接可读。自依赖返回 ["a","a"],不特殊处理,因为它在修复上与两节点环同类。

一次「共享包」失败的上线

某团队把五个端的公共代码抽成一个 npm 包,命名为 shared。半年后这个包变成第二个应用:里面有 HTTP 客户端、有页面组件、有平台判断,还有直接读 localStorage 的工具函数。

结果:

  • 小程序端无法构建,因为包在顶层读了 window。
  • Web 端为了兼容小程序,引入了 polyfill,首屏体积增加。
  • 每次改一个端的样式,其他四个端都要回归。

复盘时团队说「共享包失控了」。更准确地说,是共享包没有层级。如果一开始分成 core、protocol、adapter 三个包,并用依赖规则检查,core 不会读到 window,adapter 也不会被 UI 反向依赖。

适配层该有多薄

适配层容易走向两个极端:要么薄到只有一个 if (isIOS),要么厚到重新实现一遍业务。

一个实用标准:适配层只做三件事——能力探测、平台 API 调用、错误归一化。它不判断业务规则,不组装页面,也不决定重试策略。

例如支付适配层只负责「拉起哪个 SDK、把渠道错误码转成统一错误」;「支付失败后是否允许重试」属于 core 的支付状态机。边界清楚,测试才好写:适配层用假实现测调用,core 用纯函数测判断。

内核的版本与发布

core 一旦被多个端依赖,它就有了版本问题。这里不需要引入完整的语义化版本工具链,但必须约定三条:

  1. 内核只做加法。 新增函数、新增字段可以;改变已有函数的含义、删除字段不行。确需破坏性变更时,新增一个函数名,旧函数保留一个版本周期。
  2. 行为变更必须带测试。 内核的每个行为都有测试固定,改行为等于改测试。评审时先看测试 diff,再看实现 diff。
  3. 内核不引用端。 内核的依赖列表里出现平台名,就是版本分裂的开始。window、document、UIApplication、wx 都不该出现在 core。

内核发布节奏可以比端慢。端每周发版,内核每月发一次,中间用 protocol 层兼容。慢不是问题,问题是内核在没有测试的情况下悄悄变化。

三个边界案例

时区。 内核应该只处理时间戳与偏移量,不处理「今天」「昨天」这类本地概念。展示层根据设备时区格式化,业务层根据平台时区计算业务日。把 Date 的本地方法放进内核,会让同一个时间戳在五个端得到五种「今天」。

货币。 金额用整数最小单位加币种,不用浮点。跨币种比较必须显式换算,不能直接比数值。内核可以提供 Money 类型,但不应内置汇率——汇率是数据,不是规则。

排序。 内核可以定义排序键与稳定性要求,但不应依赖平台的默认排序。不同端的字符串比较规则不同,中文、日文、表情符号的结果可能不一致。需要跨端一致的排序,必须在 core 里写明确的比较函数。

这三个案例的共同点是:内核只处理确定的部分,把与环境相关的部分留给适配层。

把审计接进流水线

依赖审计只有接进 CI 才有效。三条门槛:

  • 出现层级违规时,构建失败,而不是警告。
  • 出现循环依赖时,构建失败;如果是历史遗留,加入显式豁免清单,并写清理期限。
  • 内核包的依赖列表变化时,要求评审。

审计脚本不需要复杂,读取模块清单、跑 auditGraph、有输出就退出非零即可。关键是规则与代码同源:审计用的层级定义,应该和 core 代码里使用的类型来自同一处,避免规则与实现各写一份。

豁免清单要小且可审计。一个永远不清理的豁免清单,等于没有规则。

共享与复用的区别

共享(share)与复用(reuse)经常被当成同义词,但在多端架构里它们的目标不同:

  • 共享关注一致性:同一份判断只实现一次,避免结果分叉。
  • 复用关注效率:同一段代码被多处调用,减少重复劳动。

共享一定带来复用,复用不一定带来共享。把一段 UI 工具函数复制到五个端,是复用代码片段,不是共享判断;把金额分摊抽成内核,才是共享。

判断标准仍然是「错了会怎样」。金额分摊错了要退款,必须共享;日期格式化错了只是显示难看,可以各端复用同一套实现,也可以各写一份,只要格式符合本地习惯。

一个团队如果只追求复用率,会把页面组件、平台判断、HTTP 客户端都塞进共享包,最后得到前面那个失控的 shared。追求一致性,才会问「哪些判断必须唯一」。

内核测试的写法

内核测试有三条要求:

  1. 不依赖环境。 不打网络、不读文件、不读系统时间。时间、随机数、ID 生成器都通过参数注入。
  2. 一条用例一个判断。 用例名应该是一句可证伪的话,例如「退款金额不能超过实付金额」,而不是「测试退款」。
  3. 覆盖禁止路径。 合法迁移要测,非法迁移也要测。只测成功路径的状态机,最容易被一个新状态打穿。

注入时间有一个额外好处:测试可以精确控制边界。例如优惠券在「23:59:59」有效、「00:00:00」失效,用真实时间几乎无法稳定测试,注入时间则可以停在任意一刻。

内核测试的数量不必多。一个判断一条用例,几十条就能覆盖一个交易内核的主要边界。相比之下,五个端各写一套端到端测试,数量是几百条,却仍然抓不住「三个端各自舍入」这类问题。

内核的接口设计

内核函数应该窄、纯、可组合:

  • 窄:一个函数只回答一个问题。orderTotal(lines) 只算总额,不负责格式化、不负责校验商品是否存在。
  • 纯:同样的输入永远得到同样的输出。不读全局配置,不依赖当前时间,不修改传入对象。
  • 可组合:小函数能拼成大流程。lineFromSku、orderTotal、canTransition 可以组合成一个下单校验流程,而不需要一个大而全的 createOrder。

接口还要避免「布尔参数」。calculateTotal(lines, true) 里的 true 是什么,调用方看不出来。改成两个函数或一个枚举,可读性和可测性都会更好。

错误处理也属于接口设计。内核应该返回结果对象,而不是抛异常:{ ok: true, total } 或 { ok: false, reason }。异常适合程序错误,不适合业务拒绝;把「库存不足」写成异常,调用方很容易漏捕获。

迁移到共享内核

已经分叉的代码怎么收回?不要一次性重写,按三步走:

  1. 先加测试。 在三个端各写一组测试,固定当前行为。测试会暴露它们其实并不一致,这正是迁移的依据。
  2. 抽出一个端。 选用户量最小或实现最接近内核的端,先切到共享内核,观察一个版本。
  3. 逐端切换。 每切一个端,删除该端的旧实现,避免两套逻辑并存。并存期间必须有开关,出问题能切回。

迁移的退出条件是「旧实现被删除」,不是「新实现上线」。两套逻辑长期并存,迟早会有人改错一份。

如果某个端的实现确实无法共享(例如依赖平台特有的加密库),把它留在适配层,并在内核里定义接口。接口共享,实现不共享,仍然比三份业务判断各自演化好。

内核的文档与示例

内核代码要配「可执行的文档」:每个导出函数至少一个示例,示例本身是测试。

文档写「按比例分摊优惠」没有用,读者仍然不知道余数给谁。示例写清楚输入两行、优惠 10 分、第一行得 6 分、第二行得 4 分,读者立刻明白规则。

示例还会暴露接口设计问题。如果写一个示例需要构造十个参数,接口就太宽;如果示例需要 mock 三个全局对象,函数就不纯。

内核的 README 不需要长,三部分足够:分层规则、每个函数的示例、常见错误。剩下的交给测试用例名,它们本来就是最好的行为说明。

一个最小内核清单

如果从零开始,一个交易客户端的内核可以先只放这些:

  • 金额:整数分、加减、按比例分摊、舍入规则。
  • 订单:状态枚举、合法迁移、终态判断。
  • 幂等:幂等键的生成规则与复用条件。
  • 券:可用性判定、叠加顺序、门槛计算。
  • 库存:可售判断、限购判断。
  • 时间:业务日计算、超时判定。

这六组判断覆盖了交易端大多数「错了要人工介入」的场景。页面结构、动画、平台调用都不放进来。

清单要短,是因为内核的维护成本随规模上升。每加一个模块,都要问:它是否与端无关?是否有稳定输入输出?错了是否需要人工介入?三个答案都是「是」,才值得放进来。

内核之外,protocol 放数据结构,adapter 放平台调用,ui 放界面。四层各司其职,共享才不会变成第二个应用。

一个反例:把 UI 判断放进内核

「按钮什么时候可点」看起来像业务判断,其实一半是界面判断。把整个判断放进 core,内核就会依赖「按钮」「弹窗」这些概念。

正确的切法是:core 回答「这笔订单现在能不能支付」,返回 { allowed, reason };ui 决定按钮是置灰、隐藏还是显示提示。前者跨端一致,后者各端自便。

切分点找对了,内核会变小,界面会变灵活。切分点找错了,内核会被界面需求拖着走,最后谁也不敢改。

一个可操作的检验方法:把内核代码读给一个不做前端的同学听。如果他能听懂「这笔订单现在不能支付,因为库存不足」,说明判断是业务语言;如果必须解释「因为按钮处于 loading 态」,说明界面概念泄漏进了内核。

内核说业务语言,界面说交互语言。两种语言混在一起,测试和复用都会变得困难。

小结

共享内核的要点不是「共享多少」,而是「共享哪一层」。

  • core 放与端无关的判断,protocol 放契约,adapter 放平台差异,ui 放界面。
  • 依赖只能向下,层级违规与循环依赖都要能自动检查。
  • 共享判据:与端无关、错了要人工介入、输入输出稳定。

内核越小,越容易被五个端同时信任。

阅读导航:上一节:1.1 五类端形态与一致性承诺 · 下一节:1.3 能力探测与渐进增强 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查