三份金额代码,三种舍入
一个交易功能铺到三个端后,团队发现退款金额偶尔差 1 分。排查后发现三个端各写了一份「按比例分摊优惠」的实现:
- Web 用浮点数计算后四舍五入。
- iOS 先转
Decimal再截断。 - Android 用整数分计算,但分摊余数给了第一行。
三份代码都「看起来对」,但边界处理不同。更麻烦的是,修复时要在三个仓库各改一次,还要保证改法一致。
这类问题的根因不是缺测试,而是核心判断没有唯一实现。共享内核要解决的正是这件事:把与端无关的判断收在一处,让端只负责适配。
四层模型
共享不是二元的「共享/不共享」,而是四层:
| 层 | 放什么 | 可以依赖 | 典型例子 |
|---|---|---|---|
core | 纯判断、纯计算、状态机 | 只能依赖 core | 金额分摊、订单状态迁移、券判定 |
protocol | 数据结构、序列化、契约 | core、protocol | 订单 DTO、事件 schema |
adapter | 平台能力封装 | 前三层 | HTTP、存储、支付 SDK、埋点 |
ui | 端上界面与交互 | 任意层 | 页面、组件、路由 |
规则只有一条:依赖只能向下,不能向上,也不能跨层回头。core 不知道 adapter 存在,protocol 不知道 ui 存在。
这条规则听起来教条,但它挡住了最常见的一种腐化:某个端为了省事,在 core 里直接调用平台 API。一旦发生,core 就只能在那个端运行,共享名存实亡。
共享的判据
不是所有代码都值得共享。三个判据:
- 判断是否与端无关。 金额、状态、规则与端无关;手势、动画、权限申请与端有关。
- 错了是否要人工介入。 资金与状态错误值得共享,样式差异不值得。
- 是否有稳定的输入输出。 能写成纯函数、能用测试固定行为的,适合共享;依赖全局状态与生命周期的,不适合。
按这三条,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 一旦被多个端依赖,它就有了版本问题。这里不需要引入完整的语义化版本工具链,但必须约定三条:
- 内核只做加法。 新增函数、新增字段可以;改变已有函数的含义、删除字段不行。确需破坏性变更时,新增一个函数名,旧函数保留一个版本周期。
- 行为变更必须带测试。 内核的每个行为都有测试固定,改行为等于改测试。评审时先看测试 diff,再看实现 diff。
- 内核不引用端。 内核的依赖列表里出现平台名,就是版本分裂的开始。
window、document、UIApplication、wx都不该出现在core。
内核发布节奏可以比端慢。端每周发版,内核每月发一次,中间用 protocol 层兼容。慢不是问题,问题是内核在没有测试的情况下悄悄变化。
三个边界案例
时区。 内核应该只处理时间戳与偏移量,不处理「今天」「昨天」这类本地概念。展示层根据设备时区格式化,业务层根据平台时区计算业务日。把 Date 的本地方法放进内核,会让同一个时间戳在五个端得到五种「今天」。
货币。 金额用整数最小单位加币种,不用浮点。跨币种比较必须显式换算,不能直接比数值。内核可以提供 Money 类型,但不应内置汇率——汇率是数据,不是规则。
排序。 内核可以定义排序键与稳定性要求,但不应依赖平台的默认排序。不同端的字符串比较规则不同,中文、日文、表情符号的结果可能不一致。需要跨端一致的排序,必须在 core 里写明确的比较函数。
这三个案例的共同点是:内核只处理确定的部分,把与环境相关的部分留给适配层。
把审计接进流水线
依赖审计只有接进 CI 才有效。三条门槛:
- 出现层级违规时,构建失败,而不是警告。
- 出现循环依赖时,构建失败;如果是历史遗留,加入显式豁免清单,并写清理期限。
- 内核包的依赖列表变化时,要求评审。
审计脚本不需要复杂,读取模块清单、跑 auditGraph、有输出就退出非零即可。关键是规则与代码同源:审计用的层级定义,应该和 core 代码里使用的类型来自同一处,避免规则与实现各写一份。
豁免清单要小且可审计。一个永远不清理的豁免清单,等于没有规则。
共享与复用的区别
共享(share)与复用(reuse)经常被当成同义词,但在多端架构里它们的目标不同:
- 共享关注一致性:同一份判断只实现一次,避免结果分叉。
- 复用关注效率:同一段代码被多处调用,减少重复劳动。
共享一定带来复用,复用不一定带来共享。把一段 UI 工具函数复制到五个端,是复用代码片段,不是共享判断;把金额分摊抽成内核,才是共享。
判断标准仍然是「错了会怎样」。金额分摊错了要退款,必须共享;日期格式化错了只是显示难看,可以各端复用同一套实现,也可以各写一份,只要格式符合本地习惯。
一个团队如果只追求复用率,会把页面组件、平台判断、HTTP 客户端都塞进共享包,最后得到前面那个失控的 shared。追求一致性,才会问「哪些判断必须唯一」。
内核测试的写法
内核测试有三条要求:
- 不依赖环境。 不打网络、不读文件、不读系统时间。时间、随机数、ID 生成器都通过参数注入。
- 一条用例一个判断。 用例名应该是一句可证伪的话,例如「退款金额不能超过实付金额」,而不是「测试退款」。
- 覆盖禁止路径。 合法迁移要测,非法迁移也要测。只测成功路径的状态机,最容易被一个新状态打穿。
注入时间有一个额外好处:测试可以精确控制边界。例如优惠券在「23:59:59」有效、「00:00:00」失效,用真实时间几乎无法稳定测试,注入时间则可以停在任意一刻。
内核测试的数量不必多。一个判断一条用例,几十条就能覆盖一个交易内核的主要边界。相比之下,五个端各写一套端到端测试,数量是几百条,却仍然抓不住「三个端各自舍入」这类问题。
内核的接口设计
内核函数应该窄、纯、可组合:
- 窄:一个函数只回答一个问题。
orderTotal(lines)只算总额,不负责格式化、不负责校验商品是否存在。 - 纯:同样的输入永远得到同样的输出。不读全局配置,不依赖当前时间,不修改传入对象。
- 可组合:小函数能拼成大流程。
lineFromSku、orderTotal、canTransition可以组合成一个下单校验流程,而不需要一个大而全的createOrder。
接口还要避免「布尔参数」。calculateTotal(lines, true) 里的 true 是什么,调用方看不出来。改成两个函数或一个枚举,可读性和可测性都会更好。
错误处理也属于接口设计。内核应该返回结果对象,而不是抛异常:{ ok: true, total } 或 { ok: false, reason }。异常适合程序错误,不适合业务拒绝;把「库存不足」写成异常,调用方很容易漏捕获。
迁移到共享内核
已经分叉的代码怎么收回?不要一次性重写,按三步走:
- 先加测试。 在三个端各写一组测试,固定当前行为。测试会暴露它们其实并不一致,这正是迁移的依据。
- 抽出一个端。 选用户量最小或实现最接近内核的端,先切到共享内核,观察一个版本。
- 逐端切换。 每切一个端,删除该端的旧实现,避免两套逻辑并存。并存期间必须有开关,出问题能切回。
迁移的退出条件是「旧实现被删除」,不是「新实现上线」。两套逻辑长期并存,迟早会有人改错一份。
如果某个端的实现确实无法共享(例如依赖平台特有的加密库),把它留在适配层,并在内核里定义接口。接口共享,实现不共享,仍然比三份业务判断各自演化好。
内核的文档与示例
内核代码要配「可执行的文档」:每个导出函数至少一个示例,示例本身是测试。
文档写「按比例分摊优惠」没有用,读者仍然不知道余数给谁。示例写清楚输入两行、优惠 10 分、第一行得 6 分、第二行得 4 分,读者立刻明白规则。
示例还会暴露接口设计问题。如果写一个示例需要构造十个参数,接口就太宽;如果示例需要 mock 三个全局对象,函数就不纯。
内核的 README 不需要长,三部分足够:分层规则、每个函数的示例、常见错误。剩下的交给测试用例名,它们本来就是最好的行为说明。
一个最小内核清单
如果从零开始,一个交易客户端的内核可以先只放这些:
- 金额:整数分、加减、按比例分摊、舍入规则。
- 订单:状态枚举、合法迁移、终态判断。
- 幂等:幂等键的生成规则与复用条件。
- 券:可用性判定、叠加顺序、门槛计算。
- 库存:可售判断、限购判断。
- 时间:业务日计算、超时判定。
这六组判断覆盖了交易端大多数「错了要人工介入」的场景。页面结构、动画、平台调用都不放进来。
清单要短,是因为内核的维护成本随规模上升。每加一个模块,都要问:它是否与端无关?是否有稳定输入输出?错了是否需要人工介入?三个答案都是「是」,才值得放进来。
内核之外,protocol 放数据结构,adapter 放平台调用,ui 放界面。四层各司其职,共享才不会变成第二个应用。
一个反例:把 UI 判断放进内核
「按钮什么时候可点」看起来像业务判断,其实一半是界面判断。把整个判断放进 core,内核就会依赖「按钮」「弹窗」这些概念。
正确的切法是:core 回答「这笔订单现在能不能支付」,返回 { allowed, reason };ui 决定按钮是置灰、隐藏还是显示提示。前者跨端一致,后者各端自便。
切分点找对了,内核会变小,界面会变灵活。切分点找错了,内核会被界面需求拖着走,最后谁也不敢改。
一个可操作的检验方法:把内核代码读给一个不做前端的同学听。如果他能听懂「这笔订单现在不能支付,因为库存不足」,说明判断是业务语言;如果必须解释「因为按钮处于 loading 态」,说明界面概念泄漏进了内核。
内核说业务语言,界面说交互语言。两种语言混在一起,测试和复用都会变得困难。
小结
共享内核的要点不是「共享多少」,而是「共享哪一层」。
core放与端无关的判断,protocol放契约,adapter放平台差异,ui放界面。- 依赖只能向下,层级违规与循环依赖都要能自动检查。
- 共享判据:与端无关、错了要人工介入、输入输出稳定。
内核越小,越容易被五个端同时信任。
阅读导航:上一节:1.1 五类端形态与一致性承诺 · 下一节:1.3 能力探测与渐进增强 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。