C# 建造者模式实战:从构造参数爆炸到链式组装与不可变对象

发布时间:2026/11/2 9:08:57
C# 建造者模式实战:从构造参数爆炸到链式组装与不可变对象 上周接手一个导出模块的维护打开代码先愣了一下一个构造函数挂了十一个参数其中三个是bool两个是string还有一个IReadOnlyListstring藏在最后。调用处长这样new ReportDocument(月报, ops, columns, 40, true, false, , null, 3, GBK, false)。改一个参数要来回切换三个文件确认位置谁看谁麻。这就是典型的“构造参数爆炸”而 C# 建造者模式Builder Pattern对付的正是这类问题把一个复杂对象的组装过程从“一次性塞进构造函数”拆成“一步一步搭起来”每一步都能单独校验、单独复用、单独记录。它不属于那种能立刻提升性能的招式但对可维护性的影响相当直接。这篇内容适合两类人看一类是写过工厂模式、但对建造者还停留在“背面试题”阶段的开发者另一类是被长参数列表折磨过、想找一个既有理论支撑又能直接抄作业的落地写法的人。下面从设计取舍开始一路拆到代码实现、参数计算、踩坑排查。1. 先把问题本质摆出来构造函数为什么会变成灾难1.1 一个典型的参数地狱是怎么长出来的任何一坨长参数列表都不是一天长成的。最初的版本往往只有两三个参数后来产品说“加个是否显示合计”于是多了个bool showSummary再后来运营说“要支持不同的页边距”于是多了个int margin再后来财务说“要记录是由谁导出的”于是多了个string owner。每一次改动都很小看起来都不值得重构于是构造函数就这么一轮一轮地膨胀直到某天有人发现new Xxx(a, b, true, false, 0)里那两个bool到底哪个是哪个已经没人说得清了。这个问题在 C# 里有个专门的学名叫图灵完备的布尔参数招贴板翻译得直白一点就是调用方只看得到一串字面量看不到语义。你写出new ReportDocument(title, owner, columns, 40, true, false, , null, 3, GBK, false)编译器不会拦你但任何一个接手的人都要打开定义一个个数过去。更麻烦的是这些参数里有些是必填的有些是可选的有些互相冲突比如既指定了列模板又指定了自定义列而构造函数签名没办法表达这些约束——它只能表达顺序和类型。还有一种更隐蔽的腐烂方式为了“以后可能要用”有人给构造函数加了一堆可选参数int pageSize 40, bool includeSummary false, ...。这看起来挺好调用方可以只传前几个。但可选参数是编译期展开的一旦组件以二进制形式发布调用方那边编译时嵌入的默认值就固化了你后来把默认值改成 30人家重新编译之前还是 40。这个坑在类库开发里踩的人不在少数。1.2 建造者模式真正解决的是“顺序”和“约束”很多人对建造者模式的理解停留在“链式调用看起来很爽”这是把手段当成了目的。它真正干的事情有三件而且是有优先级的。第一件把多步组装过程显式化。一个复杂对象往往不是“传参传进去”的而是“攒出来的”先设标题再挂列再配分页再决定要不要合计。建造者把这套顺序写成方法方法名就是文档SetTitle、AddColumn、EnableSummary读代码的人不需要跳到定义去猜。第二件把校验时机前移。构造函数里做校验抛出的异常堆栈指向new那一行但那一行往往有十几个参数你根本不知道是哪一项不对。建造者可以在每一Set方法里立刻校验也可以把整体校验放在Build()里集中做无论哪种报错信息都能精确到字段名。我在实际项目里习惯用后者各个Set方法只做空值检查跨字段的业务约束比如“启用合计必须至少有 3 列”统一放到Build()里原因是组装中途的状态本来就允许不完整。第三件屏蔽对象的表示细节。调用方最后拿到的ReportDocument可以是完全不可变的构造函数internal甚至private外部只能通过建造者产出。这样一来“半成品对象”就不会在系统里到处乱飞。这一点在多线程环境下价值极高因为不可变对象天然线程安全而通过构造函数拼装出来的可变对象你得时刻操心谁在什么时候改了它。1.3 什么情况下不该动建造者这部分的判断比“怎么用”更重要因为滥用的代价是代码量翻倍。我给自己定的几条判断线是这样的参数总数在 3 个以内直接用构造函数别想太多。参数在 4 到 6 个之间且都是同类型的简单值优先考虑命名参数new Foo(title: x, pageSize: 30)C# 从 4.0 就支持这个很多人忘了。只有当参数超过 6 个、或者存在“可选组合爆炸”、或者组装过程本身有先后依赖时才值得上建造者。还有一种情况要特别警惕对象是纯数据传输对象DTO字段之间完全没有约束关系只是打包快递。这种用对象初始化器new Dto { A 1, B 2 }就够了硬套建造者会写出几十行只管赋值的样板代码收益是负的。判断标准很简单——如果建造者里的每个方法都只是一行赋值没有校验、没有默认值推导、没有转换逻辑那它就是个多余的中间层。真正的分水岭在于“组装逻辑”。当一个对象需要根据输入推导出别的字段比如根据纸张高度和行高算出每页行数、需要在某个步骤依赖前面步骤的结果、或者需要支持多种预置配置月度报表、年度报表、明细报表建造者才开始划算。因为这时候你省下来的不是打字量而是“这套推导规则散落在十个地方”的维护成本。2. 角色划分与设计取舍四个角色在 C# 里怎么落地2.1 经典四角色与现代简化版的差异教科书上的建造者模式有四个角色产品Product、抽象建造者Builder、具体建造者ConcreteBuilder、指挥者Director。用一段话概括它们的职责产品是最终产物抽象建造者定义“有哪些步骤”具体建造者实现“每步怎么做”指挥者负责“按什么顺序调用这些步骤”。这套划分在 1994 年那本书出版的时代是合理的因为当时的设计强调“构建算法可替换”。放到今天的 C# 项目里Director 这个角色是争议最大的。原因很实际绝大多数业务场景下组装顺序是固定的根本不存在“两套不同的指挥逻辑”硬造一个 Director 类出来等于给每个调用点再加一层间接。我自己的取舍是如果存在两种以上明显不同的组装流程保留 Director如果只有一种流程把流程写成静态工厂方法或者建造者上的一个Preset方法。比如报表模块确实存在“月度汇总”和“明细流水”两种组装顺序那我就留一个ReportDirector而如果只是普通的用户信息组装直接在UserBuilder上加个BuildDefault()就完事了没必要为了完整性凑角色。还有一个容易忽略的点抽象建造者接口在很多场景下也是可以砍掉的。接口的价值在于“同一套指挥逻辑能驱动不同的建造者”如果你的项目里只有一种具体建造者接口纯粹是负担。我见过一个项目里为了“符合模式”给每个实体都写了一个IXxxBuilder接口加一个实现类一共十二对全项目没有第二个实现。这种时候接口不是抽象是噪音。2.2 为什么把“构建”和“表示”分离这是建造者模式的设计内核也是它区别于工厂模式的关键。工厂模式关心的是“产出什么类型”建造者关心的是“怎么一步步产出”。举个生活化的类比去快餐店点餐工厂模式相当于你告诉柜台“我要一份套餐A”柜台直接从备好的组合里拿给你建造者模式相当于自助餐台你自己决定先拿主食还是先拿配菜最后拼成一盘。落到代码上分离带来的最直接好处是同一套步骤定义能产出不同表示。比如同样是“设置标题、添加列、配置分页”这套调用TabularReportBuilder产出的是表格型报表ChartReportBuilder产出的是图表型报表两者的产品类型完全不同但指挥者的代码一行都不用改。这种扩展性在报表、文档、配置生成这类“同一份数据多种输出”的场景里非常值钱。不过要提醒一句这个“不同表示”的能力在实际业务里被用到的频率远低于想象。我统计过自己经手的项目真正需要多建造者的不超过三处。大部分时候建造者的价值集中在前面说的“显式化组装过程”和“校验前移”上而不是“多态产出”。想清楚这一点就不会为了追求模式纯度去做过度设计。2.3 不可变产品是建造者最好的搭档C# 从 9.0 开始支持init访问器和记录类型这让“建造者 不可变”的组合变得非常顺。传统写法里产品的属性是get; private set;建造者需要通过internal构造函数或者内部方法把值灌进去。用init之后产品可以写成public string Title { get; init; }建造者在Build()里用对象初始化器一次性构造代码干净很多。但这里有个细节要注意init只能在对象初始化器里赋值意味着建造者必须持有全部字段的临时值然后在Build()里一次性传递。这恰恰是建造者该有的形态——建造者自身是可变的工作台产品是冻结的成品。我见过有人反过来做让建造者持有产品引用、每次Set都直接改产品字段那样产品就不再不可变建造者的意义损失了一大半。另外readonly struct也值得考虑但只用在小而简单的产品上。结构体超过 24 字节左右在 64 位平台上大概是三个引用字段的量级之后复制成本就超过引用传递了这时候老老实实用sealed class。这个阈值不是绝对的跟具体的内存布局和 JIT 优化有关但作为经验值够用。3. 三种落地写法从教科书版到现代版3.1 经典 GOF 写法职责最清晰代码最长先看完整实现产品定义如下。注意构造函数是internal的外部程序集无法直接 new只能走建造者public sealed class ReportDocument { public string Title { get; } public string Owner { get; } public IReadOnlyListstring Columns { get; } public int PageSize { get; } public bool IncludeSummary { get; } public string Footer { get; } internal ReportDocument(string title, string owner, IReadOnlyListstring columns, int pageSize, bool includeSummary, string footer) { Title title; Owner owner; Columns columns; PageSize pageSize; IncludeSummary includeSummary; Footer footer; } public override string ToString() $[{Title}] owner{Owner}, cols{Columns.Count}, page{PageSize}, sum{IncludeSummary}; }抽象建造者定义步骤注意所有方法返回void这是经典版的特征public interface IReportBuilder { void SetTitle(string title); void SetOwner(string owner); void AddColumn(string column); void SetPageSize(int pageSize); void EnableSummary(); void SetFooter(string footer); ReportDocument Build(); }具体建造者持有中间状态Build()里做集中校验public sealed class TabularReportBuilder : IReportBuilder { private string _title 未命名报表; private string _owner system; private readonly Liststring _columns new(); private int _pageSize 36; private bool _includeSummary; private string _footer string.Empty; public void SetTitle(string title) _title string.IsNullOrWhiteSpace(title) ? throw new ArgumentException(标题不能为空, nameof(title)) : title.Trim(); public void SetOwner(string owner) _owner owner ?? system; public void AddColumn(string column) { if (string.IsNullOrWhiteSpace(column)) throw new ArgumentException(列名不能为空, nameof(column)); if (_columns.Contains(column)) throw new InvalidOperationException($列 {column} 重复添加); _columns.Add(column); } public void SetPageSize(int pageSize) _pageSize pageSize is 1 or 200 ? throw new ArgumentOutOfRangeException(nameof(pageSize), 每页行数应在 1 到 200 之间) : pageSize; public void EnableSummary() _includeSummary true; public void SetFooter(string footer) _footer footer ?? string.Empty; public ReportDocument Build() { if (_columns.Count 0) throw new InvalidOperationException(至少需要一个数据列); if (_includeSummary _columns.Count 3) throw new InvalidOperationException(启用合计至少需要 3 个数据列); return new ReportDocument(_title, _owner, _columns.AsReadOnly(), _pageSize, _includeSummary, _footer); } }指挥者负责编排顺序两种预置流程并存public sealed class ReportDirector { private readonly IReportBuilder _builder; public ReportDirector(IReportBuilder builder) _builder builder; public ReportDocument BuildMonthly(string owner) { _builder.SetTitle(月度统计报表); _builder.SetOwner(owner); _builder.AddColumn(日期); _builder.AddColumn(订单数); _builder.AddColumn(成交额); _builder.EnableSummary(); _builder.SetFooter(本报表由系统自动生成); return _builder.Build(); } public ReportDocument BuildDetail(string owner) { _builder.SetTitle(明细流水报表); _builder.SetOwner(owner); _builder.AddColumn(流水号); _builder.AddColumn(发生时间); _builder.AddColumn(金额); _builder.SetPageSize(50); return _builder.Build(); } }这套写法好处是职责边界非常清楚任何一个环节出问题都能快速定位。坏处也明显调用方要写六行才能拿到一个对象而且必须先 new 一个建造者。在 C# 里这个成本是可以优化的方法就是链式。3.2 链式建造者把 API 打磨到可以一行读完链式的本质改动只有一处让所有Set方法返回this。听起来微不足道但它带来的可读性提升是非线性的因为调用点从“六条独立语句”变成了“一个完整的表达式”读者能一眼看出这个对象是怎么攒出来的。public sealed class ReportBuilder { private string _title 未命名报表; private string _owner system; private readonly Liststring _columns new(); private int _pageSize 36; private bool _includeSummary; private string _footer string.Empty; public ReportBuilder WithTitle(string title) { _title string.IsNullOrWhiteSpace(title) ? _title : title.Trim(); return this; } public ReportBuilder WithOwner(string owner) { _owner owner ?? system; return this; } public ReportBuilder WithColumn(string column) { if (!string.IsNullOrWhiteSpace(column) !_columns.Contains(column)) _columns.Add(column); return this; } public ReportBuilder WithColumns(params string[] columns) { foreach (var c in columns) WithColumn(c); return this; } public ReportBuilder WithPageSize(int pageSize) { _pageSize pageSize is 1 and 200 ? pageSize : _pageSize; return this; } public ReportBuilder WithSummary(bool value true) { _includeSummary value; return this; } public ReportBuilder WithFooter(string footer) { _footer footer ?? string.Empty; return this; } public ReportDocument Build() { /* 同前面的校验逻辑 */ } }调用点从此变成var doc new ReportBuilder() .WithTitle(月度统计报表) .WithOwner(ops) .WithColumns(日期, 订单数, 成交额) .WithSummary() .WithFooter(系统自动生成) .Build();读起来就像在念需求文档这是链式最大的价值。但链式有个隐性的坑必须提校验失败了怎么办。上面代码里我把非法值写成了“静默忽略”用原值兜底。这是个有意的取舍——链式调用写在表达式里抛异常会让整行表达式中断堆栈指向.WithColumn那一行还算好定位但在某些场景下比如数据来自配置文件想一次性收集所有错误就不合适了。这时候可以改成收集模式把错误塞进一个Liststring _errorsBuild()时一次性抛出聚合异常ArgumentException带上所有问题。我在处理批量导入的模块里用过这种写法用户能一次看到全部字段错误体验比一个个改好得多。还有一个常见的坑是链式方法的返回类型。如果建造者有继承层次父类的WithXxx返回BaseBuilder子类再链下去就会丢失子类方法。解决办法是用 C# 的“奇异递归模板模式”public class BaseBuilderT where T : BaseBuilderT所有方法返回T子类class SubBuilder : BaseBuilderSubBuilder。这个技巧在写通用组件库时很有用但业务代码里我基本不用因为继承本身就是需要极力避免的东西。3.3 不可变记录类型轻量场景的替代方案C# 9.0 之后的record提供了一个“半建造者”的能力with表达式能做浅拷贝并修改指定属性写法极其简洁。public sealed record ReportSpec( string Title, string Owner, IReadOnlyListstring Columns, int PageSize 36, bool IncludeSummary false, string Footer ) { public static ReportSpec Default new(未命名报表, system, Array.Emptystring()); }用法var spec ReportSpec.Default with { Title 月度统计, Owner ops, PageSize 40 };这玩意儿在参数不超过五六个、不需要跨字段校验的时候比手写建造者省事得多。但它有三个明确的短板必须拎清楚第一with只做浅拷贝列表字段依然是同一个引用你没法在修改时顺手做“去重”或者“排序”这种加工第二构造器里能做的校验只有字段级跨字段规则比如“启用合计至少三列”塞不进去除非用init访问器加一个私有的Validate方法在构造末尾调用第三record的构造函数是公开的调用方依然可以直接 new 出一个非法实例而建造者的价值之一就是把构造入口收窄。所以我的判断线是纯配置、无约束、无加工用recordwith有约束、有推导、要收口用建造者。两者也能混用——建造者内部持有record每次WithXxx返回一个新的recordBuild()时校验。这种写法在函数式风格的项目里挺常见代价是每次With都产生一次分配高频调用的热路径上要注意。4. 实战落地报表导出模块的完整组装4.1 需求拆解与接口边界拿一个实际做过的导出模块举例。需求是这样的系统要把查询结果导出成可打印的文档支持月度汇总和明细流水两种模板用户可选择纸张A4/A5、是否显示合计行、是否加页码页脚。导出前要校验列数、标题、页码范围。文档产出后会被放进一个队列异步渲染。拆下来有三个关注点规格的组装哪些列、什么纸张、要不要合计、规格的校验页数是否合理、列是否冲突、规格的产出交给渲染器。这正是建造者的用武之地。接口我先定成这样注意Build返回的是一个不可变的规格对象而不是渲染器本身。这一步分离很关键——如果建造者直接产出渲染器实例那它就跟 DI 容器和资源生命周期绑死了测试也难写。public interface IExportSpecBuilder { IExportSpecBuilder WithPaper(PaperKind paper); IExportSpecBuilder WithMargins(int top, int bottom); IExportSpecBuilder WithColumns(params string[] columns); IExportSpecBuilder WithSummary(bool on true); IExportSpecBuilder WithPageNumber(bool on true); ExportSpec Build(); }4.2 分页行数的参数计算过程这是整个模块里唯一需要动脑算的地方也是我见过最多人凭感觉填数字的地方。假设用户选了 A4 纸张纵向上下边距各 15mm页眉占 12mm页脚占 10mm。A4 高度 297mm逐项扣减可用总高297 - 15 - 15 267mm扣掉页眉页脚后的正文区267 - 12 - 10 245mm单行高度 18pt换算成毫米18 × 25.4 ÷ 72 ≈ 6.35mm理论可容纳行数245 ÷ 6.35 ≈ 38.58向下取整得 38 行38 行是数学上限但实际不能填 38。原因有三个打印机有不可打印的物理边距通常 4-5mm不同驱动对缩放的舍入不一致再加上最后一行如果刚好卡在边界很可能被切掉一半。所以我在这个基础上打了约 5% 的余量取36 行。这个数字是我实测打印了二十多份不同长度的报表之后定下来的38 行在某个老型号激光打印机上确实出现过最后一行被截的情况。A5 的情况高度 210mm边距各 12mm小纸张边距要收一点页眉 12mm 页脚 10mm正文区 210 - 24 - 22 164mm除以 6.35 得 25.8取 24 行。把这些数字写进建造者作为默认值private static int CalcPageRows(PaperKind paper, int top, int bottom) { const double RowHeightMm 6.35; // 18pt const double HeaderMm 12, FooterMm 10; double total paper PaperKind.A4 ? 297 : 210; double usable total - top - bottom - HeaderMm - FooterMm; double raw usable / RowHeightMm; return Math.Max(10, (int)(raw * 0.95)); // 留 5% 安全余量下限 10 行 }调用CalcPageRows(PaperKind.A4, 15, 15)得到 36CalcPageRows(PaperKind.A5, 12, 12)得到 24跟手算一致。注意这里的 6.35mm 是 18pt 字体的名义行高如果用了行距系数比如 1.2 倍必须乘上去再算否则行数会明显偏大。我一开始就漏了这一步导致导出后文字挤在一起排查了半天以为是页面设置的问题。4.3 完整建造者与校验实现public enum PaperKind { A4, A5 } public sealed class ExportSpec { public string Title { get; init; } ; public PaperKind Paper { get; init; } public int MarginTop { get; init; } public int MarginBottom { get; init; } public IReadOnlyListstring Columns { get; init; } Array.Emptystring(); public int RowsPerPage { get; init; } public bool ShowSummary { get; init; } public bool ShowPageNumber { get; init; } } public sealed class ExportSpecBuilder : IExportSpecBuilder { private string _title 导出报表; private PaperKind _paper PaperKind.A4; private int _marginTop 15, _marginBottom 15; private readonly Liststring _columns new(); private bool _summary; private bool _pageNumber true; private readonly Liststring _errors new(); public IExportSpecBuilder WithTitle(string title) { if (string.IsNullOrWhiteSpace(title)) _errors.Add(标题不能为空); else _title title.Trim(); return this; } public IExportSpecBuilder WithPaper(PaperKind paper) { _paper paper; return this; } public IExportSpecBuilder WithMargins(int top, int bottom) { if (top 5 || top 40) _errors.Add($上边距 {top}mm 超出 5-40 范围); if (bottom 5 || bottom 40) _errors.Add($下边距 {bottom}mm 超出 5-40 范围); _marginTop top; _marginBottom bottom; return this; } public IExportSpecBuilder WithColumns(params string[] columns) { foreach (var c in columns) { if (string.IsNullOrWhiteSpace(c)) { _errors.Add(列名不能为空); continue; } if (_columns.Contains(c, StringComparer.OrdinalIgnoreCase)) { _errors.Add($列 {c} 重复); continue; } _columns.Add(c); } return this; } public IExportSpecBuilder WithSummary(bool on true) { _summary on; return this; } public IExportSpecBuilder WithPageNumber(bool on true) { _pageNumber on; return this; } public ExportSpec Build() { if (_columns.Count 0) _errors.Add(至少需要一个数据列); if (_summary _columns.Count 3) _errors.Add(启用合计至少需要 3 个数据列); if (_errors.Count 0) throw new AggregateException(导出参数校验失败, _errors.Select(e new ArgumentException(e))); return new ExportSpec { Title _title, Paper _paper, MarginTop _marginTop, MarginBottom _marginBottom, Columns _columns.AsReadOnly(), RowsPerPage CalcPageRows(_paper, _marginTop, _marginBottom), ShowSummary _summary, ShowPageNumber _pageNumber }; } }调用侧var spec new ExportSpecBuilder() .WithTitle(月度统计) .WithPaper(PaperKind.A4) .WithColumns(日期, 订单数, 成交额) .WithSummary() .Build(); // spec.RowsPerPage 364.4 与依赖注入和工厂的配合方式建造者本身是可变对象所以绝对不能注册成单例。正确的注册方式是瞬时Transient或作用域Scoped前者用于每次调用都要新建的场景后者用于一次请求内复用同一个建造者。services.AddTransientIExportSpecBuilder, ExportSpecBuilder();如果产品的创建还需要依赖一些服务比如列名要从元数据服务里查我倾向于不把服务塞进建造者而是让建造者在外部被“喂养”好数据再Build。原因是建造者一旦持有服务引用它就不再是纯粹的值组装工测试时要 mock 一堆东西写起来很难受。真需要模板化的时候用一个静态的模板工厂返回预填好的建造者即可public static class ExportTemplates { public static ExportSpecBuilder Monthly(string owner) new ExportSpecBuilder() .WithTitle(${owner} 月度统计) .WithPaper(PaperKind.A4) .WithColumns(日期, 订单数, 成交额) .WithSummary(); }这套东西跟工厂模式不冲突反而经常搭配使用工厂负责“选哪个建造者”建造者负责“怎么搭”。比如根据用户选择的模板 ID 决定用TabularReportBuilder还是ChartReportBuilder这就是一个简单工厂加一个策略表。5. 常见问题与排查技巧实录5.1 常见问题速查表现象大概率原因处理方式Build出来的对象字段全是默认值复用了同一个建造者实例上次Build后没重置状态每次新建建造者或在Build末尾清空中间字段链式调用某一环静默失效该With方法内部走了“非法值忽略”分支改成收集错误Build时统一抛出同一建造者Build两次拿到相同引用Build里返回的是建造者内部的 List 而不是副本用AsReadOnly()或ToArray()做快照子类建造者的链式中断了父类方法返回父类型用泛型自引用BaseBuilderT或干脆不用继承单元测试里断言困难产品属性是private set且没暴露产品加ToString重写或提供只读属性大量建造者类导致代码膨胀给每个 DTO 都配了一个建造者只对超过 6 参数或有约束的对象使用打印出来最后一行被切每页行数按理论上限计算留 5% 安全余量参考 4.2 的算法5.2 几个踩过的具体坑第一个坑是建造者复用导致的脏状态。早期我把SearchQueryBuilder注册成单例当时觉得它无状态现在想想真是糊涂结果两个并发请求互相污染了查询条件一个用户的过滤条件跑到另一个用户的查询里去了。排查花了整整一个下午因为单看日志每条 SQL 都合法只是条件不对。教训很直接只要类里有private可变字段就老老实实按瞬时注册别想着省那点分配。第二个坑是**Build()里没有做快照**。有一版实现里Build()直接把_columns这个Liststring塞给产品ReportDocument.Columns类型是IReadOnlyList看着挺安全。但调用方如果恰好还持有建造者引用再调一次AddColumn之前已经产出的文档列就跟着变了因为底层是同一个 List。这种 Bug 极其隐蔽只在“产出后继续操作建造者”的顺序下才复现。修法很简单Build里一律_columns.ToArray()或AsReadOnly()。第三个坑跟异步有关。有个导出流程是先把规格Build出来扔进队列异步渲染时再读取。因为规格对象是不可变的本来没问题。但渲染器那边为了省事把spec.Columns直接赋给了内部的一个可变集合后面又在别处做了Clear()。不可变对象的价值瞬间归零。后来我加了一条团队约定不可变对象里的集合字段取出后如果要做修改先ToList()复制一份谁改谁的副本。第四个坑是校验抛异常还是返回结果。一开始我在Build()里遇到问题就抛InvalidOperationException结果上层有个批量导出的地方一百条数据里只要有一条参数非法整个批次就中断了。后来改成两种模式并存Build()抛异常用于“编程错误”调用方写错了代码TryBuild(out spec, out errors)用于“数据问题”用户输入不合法需要给提示。这个区分挺重要的把业务错误和程序错误混在一起处理后面一定会乱。5.3 给建造者写单元测试的几个要点建造者的测试比一般业务类好写因为输入输出都很明确。我通常覆盖四类用例正常路径、缺必填项、字段冲突、默认值推导。[Fact] public void Build_WithThreeColumns_EnableSummary_ShouldSucceed() { var spec new ExportSpecBuilder() .WithColumns(a, b, c) .WithSummary() .Build(); Assert.True(spec.ShowSummary); Assert.Equal(3, spec.Columns.Count); } [Fact] public void Build_SummaryWithTwoColumns_ShouldThrow() { var builder new ExportSpecBuilder().WithColumns(a, b).WithSummary(); var ex Assert.ThrowsAggregateException(() builder.Build()); Assert.Contains(至少需要 3 个数据列, ex.InnerExceptions[0].Message); } [Fact] public void Build_A4DefaultMargins_ShouldProduce36Rows() { var spec new ExportSpecBuilder().WithColumns(a).Build(); Assert.Equal(36, spec.RowsPerPage); }第三条测试特别有价值因为它把 4.2 那套计算逻辑钉死了。以后谁调了行高常量测试会立刻红掉而不是等到客户投诉打印排版错位才发现。5.4 什么时候该把建造者拆掉最后说个反向的经验。建造者用久了会形成惯性看到复杂对象就想加一个。我维护过一个模块里面有个OrderBuilder二十多个With方法看起来气势很足。但仔细一看真正在调用点被用到的只有前六个后面十几个是“预留”的从来没被调过而且有几个方法内部逻辑互相矛盾同时WithDiscountRate和WithFixedDiscount会互相覆盖但没有任何校验。这时候正确的操作是减法把没用的方法删掉把互相冲突的改成显式的二选一比如拆成两个子建造者或者用Either风格把只是简单赋值的合并成一个带参数对象的方法。删完之后这个类从 300 行掉到 80 行可读性反而上去了。判断一个建造者是否健康有个很土但好用的指标看Build()里有没有超过三条跨字段校验。如果只有一两条甚至没有说明这对象压根不需要建造者直接record加with就够了如果超过八条说明这个对象承担的职责太多该考虑拆分了。我在实际项目里形成的习惯是新建建造者的时候顺手在类顶部写一行注释说明“为什么这个对象需要建造者”。写不出来或者只能写出“参数比较多”的基本可以直接砍掉。这条规则帮我拦掉了不少为了形式而形式的代码也让接手的人能快速判断该不该在这里继续加东西。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询