- CMS
- 后端
- 前端
【免费下载链接】webiny-js
Open-source, self-hosted CMS platform on AWS serverless (Lambda, DynamoDB, S3). TypeScript framework with multi-tenancy, lifecycle hooks, GraphQL API, and AI-assisted development via MCP server. Built for developers at large organizations.
导读
本文深入解读 Webiny 开源仓库中的架构决策记录 ADR-007:Transformations produce new values。该 ADR 为 Webiny 项目确立了“数据处理/转换函数一律返回新对象、绝不修改输入”的编码准则,是项目在资产交付(Asset Delivery)、选项解析(Option Parsing)与值归一化(Value Normalization)等模块中遵循的核心约束。读完本文,你将理解这条规则背后的动机、它在 Webiny 源码中的落地形态(含normalizeImageOptions、Asset.withProps()、normalizeToAsset()三个典型实现),以及如何用测试验证“输入不被修改”这一不可变约定,并能在自己的代码中直接复用这套模式。
一、背景:为什么数据转换函数必须“返回新值”
1.1 两种风格的对比
函数对数据的转换存在两种风格:
- 就地修改(mutation):函数直接修改传入的对象(例如
delete options.width; options.format = parsed),调用方持有的变量在函数执行后“悄然变化”。 - 返回新值(return new object):函数不触碰输入,而是构造并返回一个全新的对象。
从代码长度看,就地修改通常更短、写起来更快。但 ADR-007 明确指出,这种做法带来三类问题:
- 数据流难以追踪:调用方的变量在函数返回后不再是原来的值,属性和内容“凭空出现或消失”,阅读代码时很难判断某个变量在什么时间点变成了什么。
- 隐含耦合:多个函数按顺序修改同一个对象时,后一个函数的输入依赖于前一个函数“改了哪些字段”,调用顺序错误就会产生难以排查的 bug。
- 共享引用陷阱:如果输入对象同时被其他代码持有,就地修改会把副作用扩散到所有持有该引用的地方。
1.2 决策内容
因此 ADR-007 的决策是:所有对数据进行转换的函数都必须返回新对象,而不是修改输入。输入一律视为只读,输出一律是全新的值。该规则适用于:
- 选项解析 / 归一化函数(option parsing / normalization functions)
- 领域对象转换(如
withProps、clone) - 值归一化与转换器(value normalizers and converters)
这本质上是一种“函数式、只读输入”的编码纪律,与 Webiny 项目其他 ADR 一脉相承:例如 ADR-006:Deprecate incrementally 中为兼容旧数据而保留的ImageValue -> Asset转换路径,正是通过 ADR-007 所规定的“只读输入 + 返回新对象”的归一化函数实现的。
二、落地实现一:normalizeImageOptions——把原始查询串解析为类型化选项
2.1 源码位置与职责
normalizeImageOptions位于 packages/api-file-manager/src/features/assetDelivery/assetTypes/image/normalizeImageOptions.ts,负责把来自 HTTP 查询参数的原始字符串(width、quality、format、crop、aspectRatio、focal等)解析为类型化的ImageRequestOptions对象。其类型定义在 imageTypes.ts 中:
export interface ImageRequestOptions { original?: boolean; // 是否请求原图 width?: number; // 目标宽度(像素) quality?: number; // 编码质量(1–100) format?: ImageFormat; // 具体输出格式(已从 "auto" 解析完成) crop?: AssetCrop; // 请求级裁剪(0–1 边距,优先于资源级裁剪) aspectRatio?: number; // 目标宽高比(宽 / 高) focal?: { x: number; y: number }; // 0–1 归一化焦点,配合 aspectRatio 裁切时保持画面主体 }2.2 返回新值而非原地修改
ADR-007 中明确记载,该函数过去是就地修改options的(delete options.width; options.format = parsed),重构后改为返回全新的ImageRequestOptions对象。当前源码完全遵循这一约定:函数内部创建一个新的result对象,逐个字段解析后挂载,最后整体返回,全程不触碰传入的query对象:
export const normalizeImageOptions = ( query: Record<string, any>, acceptHeader: string | undefined ): ImageRequestOptions => { const result: ImageRequestOptions = { original: "original" in query }; const width = query.width ? parseInt(query.width, 10) : NaN; if (!Number.isNaN(width) && width > 0) { result.width = width; } // quality、format、crop、aspectRatio、focal 同理…… return result; };注意其典型的“白名单 + 守卫式挂载”写法:只有解析成功且语义合法的值才会被写入新对象,非法输入(width: "abc"、负数宽度、畸形 crop 等)不会污染结果,而是直接省略对应字段。
2.3 测试如何锁定“输入不被修改”
该函数的测试位于 packages/api-file-manager/tests/features/assetDelivery/normalizeImageOptions.test.ts,其中专门有一条用例验证不可变约定:
it("does not mutate the input query", () => { const query = { width: "800", quality: "75", format: "webp" }; const original = { ...query }; normalizeImageOptions(query, undefined); expect(query).toEqual(original); });这条用例是 ADR-007 决策的直接测试化体现:先对输入做浅拷贝快照,调用函数后断言输入与快照完全一致。测试还覆盖了大量解析细节,可作为参数取值范围与默认行为的权威参考:
- width:合法正整数被解析为数字;
"abc"、"0"、"-10"一律被丢弃(返回undefined)。 - quality:会被
clampQuality钳制到 1–100,如"150"→ 100,"0"→ 1。 - format:支持显式格式(
webp保留,gif被丢弃);format=auto时依据Accept头解析(image/avif优先于image/webp),无匹配时返回undefined。 - crop:
"0.1,0.2,0.1,0.05"解析为{top,left,bottom,right}边距;越界值钳制到 0–1;全零、位数不足或字符串畸形的 crop 会被丢弃。 - aspectRatio:支持
"16:9"冒号记法与"1.5"小数记法,非法值丢弃。 - focal:
"0.3,0.7"解析为{x:0.3, y:0.7},坐标钳制到 0–1。
这套测试不仅验证了“不修改输入”,也验证了“非法输入不会以字符串形式残留到新对象上”,与源码的守卫式写法互为印证。
三、落地实现二:Asset.withProps()/clone()——不可变领域对象
3.1 源码位置与实现
Asset类是 API 文件管理模块中图片交付流程的领域对象,位于 packages/api-file-manager/src/delivery/AssetDelivery/Asset.ts。它的核心不可变 API 是withProps与clone:
export class Asset { protected readonly props: AssetData; constructor(props: AssetData) { this.props = props; } clone() { return this.withProps(structuredClone(this.props)); } withProps(props: Partial<AssetData>) { const newAsset = new Asset({ ...this.props, ...props }); newAsset.contentsReader = this.contentsReader; newAsset.outputStrategy = this.outputStrategy; return newAsset; } // getId / getTenant / getKey / getSize / getContentType / getExtension 等只读访问器 }实现要点:
props被声明为readonly,从构造起就不允许在类内部被重新赋值,从类型层面封死了就地修改的可能。withProps()绝不修改原实例:它通过展开运算符{ ...this.props, ...props }构造一份合并后的新数据,再创建新的Asset实例返回。原始实例的props保持原样。- 为了让新实例保持可用的交付能力,
withProps会同步复制contentsReader(内容读取器)与outputStrategy(输出策略)这两个运行时依赖;clone()则利用structuredClone深拷贝props后走同一条withProps路径,得到一份完全独立的副本。
3.2 该模式带来的工程收益
withProps/clone是典型的“返回新值”风格在领域对象上的应用:调用链可以安全地链式派生(例如先withProps({key: newKey})再withProps({size: newSize})),每一步都产生新实例,任何一步出错都不会污染前面的中间结果。Asset的只读访问器(getId、getKey等)与不可变更新器(withProps)组合,恰好构成“读方法不改、写方法返回新对象”的经典不可变对象形态。
四、落地实现三:normalizeToAsset()——面向旧数据的兼容归一化
4.1 源码位置与职责
normalizeToAsset位于 packages/website-builder-sdk/src/asset/normalize.ts,是 ADR-006(增量弃用旧数据)与 ADR-007 共同作用的产物:它把任意历史遗留的输入形态(旧版 Website Builder 的扁平edit字段、6.x 时代无image子对象的文件值、以及已经统一化的新结构)归一化为新的WebinyAsset,且不修改输入:
export function normalizeToAsset(input: unknown): Asset | null { if (!isObject(input)) { return null; } const mimeType = asString(input.mimeType) ?? ""; const asset = buildBase(input, mimeType); // 全新对象 const category = getAssetCategory(mimeType); // 按 MIME 前缀分桶:image / video / document // 根据是否已有 image/document/video 子对象,走 legacy 或已统一结构两条分支, // 最终通过 syncImageDimensions 把尺寸镜像到根级,返回新的 asset。 return syncImageDimensions(asset); }其关键设计在源码注释中有明确体现:为了兼容 6.4 前端读取根级width/height的习惯,syncImageDimensions会把image.width/height同步到资源根级——这一步同样是在新对象上完成,不触碰输入。底层辅助函数(asNumber、asString、isObject)保证了对任意不可信输入的安全降级:解析失败只返回undefined,绝不抛错。
4.2 测试验证“任意旧输入 -> 新值”
测试位于 packages/website-builder-sdk/src/asset/normalize.test.ts,覆盖了三类输入形态:
- 旧版扁平值(含
edit):normalizeToAsset(legacy)把edit.crop映射为新结构的image.crop,把edit.hotspot映射为image.focalPoint,同时保留alt、尺寸等信息,产出全新的统一对象。 - 6.x 扁平值(无
edit、无image子对象):normalizeToAsset自动补出image: { width, height },让旧数据无需存储迁移即可被新代码渲染。 - 已经统一化的资源:
normalizeToAsset具备幂等性——对格式良好的新资源传入返回与输入相等的新对象;对视频资源则完整保留video.autoplay、video.poster;对null、undefined、字符串、数字等非法输入一律返回null。
测试还通过assetImageFromLegacyEdit单独验证了“hotspot -> focalPoint、crop/alt/caption 保留”的映射细节,与 ADR-006 中“保留旧类型、用归一化函数透明转换,旧数据无需重新保存即可工作”的决策相互印证。
五、权衡与适用边界
ADR-007 在 Consequences 一节对收益与代价做了清晰的评估:
5.1 正面收益
- 数据流可追踪:每个变量在其生命周期内只持有“一个值”,函数返回后变量内容不会悄悄变化,排查问题时无需回溯“这个对象是什么时候被谁改的”。
- 更易调试与测试:函数可以当作纯函数对待——相同的输入必然得到相同的输出(前提是内部无隐藏状态),因此可以写出“输入快照 + 调用 + 断言相等”式的测试(如 2.3 节所示)。
- 无共享引用惊吓:调用方无需担心自己的对象被他人修改,传参可以放心地把对象交给函数。
5.2 代价与边界
- 更多对象分配:每次转换都新建对象,会带来额外 GC 压力。
- 在热路径上的取舍:文档明确指出,内层循环、高频操作中这一点可能产生影响;而 Webiny 中大多数转换发生在请求级处理(request-level processing)这一层,分配开销可以忽略不计。
这给出了该规则的适用范围判断标准:开销敏感的内层循环可以做例外评估,但业务请求链路中的转换函数应当一律遵守“返回新值”。这也是把该规则写成 ADR(架构决策记录)而非硬性 lint 规则的原因——它既是一种强默认约束,又保留了在明确测量到瓶颈时的权衡空间。
六、如何在你的代码中复用该模式
结合以上源码,可以提炼出一套可直接复用的落地清单:
- 函数签名上承诺“输入只读”:在 JSDoc 注释中写明“Returns a new object; does not mutate the input”(如
normalizeImageOptions.ts顶部注释所示)。 - 先建新对象,再守卫式挂载字段:对每个可解析字段采用“解析成功才写入”的白名单写法,非法输入直接省略,而不是把原始字符串留在结果里。
- 领域对象用
readonly+ 返回新实例:如Asset那样将内部状态声明为readonly,提供withProps/clone这类“返回新实例”的更新方法,必要时复制运行时依赖到新实例。 - 写一条“输入不被修改”的测试:这是把 ADR 决策固化为可执行约束的最小成本方式——浅拷贝快照、调用函数、断言
toEqual。 - 涉及旧数据兼容时与 ADR-006 配合:保留旧类型、用归一化函数(如
normalizeToAsset)透明转换,让旧数据无需迁移即可工作,同时严格保持输入只读。
结语
ADR-007 是 Webiny 在“数据转换”上的核心架构约定:输入只读、输出全新。它既不是激进函数式教条的照搬,也不是一刀切的性能禁令,而是一条针对请求级业务处理场景的强默认约束——用一次对象分配换取数据流的可追踪性、可测试性与共享引用安全。通过normalizeImageOptions、Asset.withProps()、normalizeToAsset()三个实现及其测试,你可以直接观察这条 ADR 在真实项目中的完整生命周期:决策 → 落地 → 测试锁定 → 权衡边界。
- CMS
- 后端
- 前端
【免费下载链接】webiny-js
Open-source, self-hosted CMS platform on AWS serverless (Lambda, DynamoDB, S3). TypeScript framework with multi-tenancy, lifecycle hooks, GraphQL API, and AI-assisted development via MCP server. Built for developers at large organizations.
相关推荐
plannotator 架构决策记录(ADR)实践指南:从 ADR-0001 到 007 的决策治理体系
plannotator 架构决策记录(ADR)实践指南:从 ADR 0001 到 007 的决策治理体系 导读 本文围绕 plannotator 仓库中的 AD
ESLint getter-return 规则详解:强制 Getter 必须返回值
ESLint getter return 规则详解:强制 Getter 必须返回值 getter return 是 ESLint 内置的一条 problem 类
开发工具Lint静态分析代码质量ArkAnalyzer返回语句:函数返回值分析
ArkAnalyzer返回语句:函数返回值分析 引言 在ArkTS语言开发中,函数返回值分析是静态程序分析的关键环节。ArkAnalyzer作为面向ArkTS语
静态分析开发工具OpenHarmony
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考