☰
C# 建造者模式实战:从构造参数爆炸到链式组装与不可变对象
2026/11/2 9:08:47 网站建设 项目流程

上周接手一个导出模块的维护,打开代码先愣了一下:一个构造函数挂了十一个参数,其中三个是bool,两个是string,还有一个IReadOnlyList<string>藏在最后。调用处长这样: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 IReadOnlyList<string> Columns { get; } public int PageSize { get; } public bool IncludeSummary { get; } public string Footer { get; } internal ReportDocument(string title, string owner, IReadOnlyList<string> 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 List<string> _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 List<string> _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那一行,还算好定位,但在某些场景下(比如数据来自配置文件,想一次性收集所有错误)就不合适了。这时候可以改成收集模式:把错误塞进一个List<string> _errors,Build()时一次性抛出聚合异常ArgumentException带上所有问题。我在处理批量导入的模块里用过这种写法,用户能一次看到全部字段错误,体验比一个个改好得多。

还有一个常见的坑是链式方法的返回类型。如果建造者有继承层次,父类的WithXxx返回BaseBuilder,子类再链下去就会丢失子类方法。解决办法是用 C# 的“奇异递归模板模式”:public class BaseBuilder<T> where T : BaseBuilder<T>,所有方法返回T,子类class SubBuilder : BaseBuilder<SubBuilder>。这个技巧在写通用组件库时很有用,但业务代码里我基本不用,因为继承本身就是需要极力避免的东西。

3.3 不可变记录类型:轻量场景的替代方案

C# 9.0 之后的record提供了一个“半建造者”的能力:with表达式能做浅拷贝并修改指定属性,写法极其简洁。

public sealed record ReportSpec( string Title, string Owner, IReadOnlyList<string> Columns, int PageSize = 36, bool IncludeSummary = false, string Footer = "") { public static ReportSpec Default => new("未命名报表", "system", Array.Empty<string>()); }

用法:

var spec = ReportSpec.Default with { Title = "月度统计", Owner = "ops", PageSize = 40 };

这玩意儿在参数不超过五六个、不需要跨字段校验的时候,比手写建造者省事得多。但它有三个明确的短板,必须拎清楚:第一,with只做浅拷贝,列表字段依然是同一个引用,你没法在修改时顺手做“去重”或者“排序”这种加工;第二,构造器里能做的校验只有字段级,跨字段规则(比如“启用合计至少三列”)塞不进去,除非用init访问器加一个私有的Validate方法在构造末尾调用;第三,record的构造函数是公开的,调用方依然可以直接 new 出一个非法实例,而建造者的价值之一就是把构造入口收窄。

所以我的判断线是:纯配置、无约束、无加工,用record+with;有约束、有推导、要收口,用建造者。两者也能混用——建造者内部持有record,每次WithXxx返回一个新的record,Build()时校验。这种写法在函数式风格的项目里挺常见,代价是每次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)得到 36,CalcPageRows(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 IReadOnlyList<string> Columns { get; init; } = Array.Empty<string>(); 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 List<string> _columns = new(); private bool _summary; private bool _pageNumber = true; private readonly List<string> _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 == 36

4.4 与依赖注入和工厂的配合方式

建造者本身是可变对象,所以绝对不能注册成单例。正确的注册方式是瞬时(Transient)或作用域(Scoped),前者用于每次调用都要新建的场景,后者用于一次请求内复用同一个建造者。

services.AddTransient<IExportSpecBuilder, 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()做快照
子类建造者的链式中断了父类方法返回父类型用泛型自引用BaseBuilder<T>,或干脆不用继承
单元测试里断言困难产品属性是private set且没暴露产品加ToString重写或提供只读属性
大量建造者类导致代码膨胀给每个 DTO 都配了一个建造者只对超过 6 参数或有约束的对象使用
打印出来最后一行被切每页行数按理论上限计算留 5% 安全余量,参考 4.2 的算法

5.2 几个踩过的具体坑

第一个坑是建造者复用导致的脏状态。早期我把SearchQueryBuilder注册成单例(当时觉得它无状态,现在想想真是糊涂),结果两个并发请求互相污染了查询条件,一个用户的过滤条件跑到另一个用户的查询里去了。排查花了整整一个下午,因为单看日志每条 SQL 都合法,只是条件不对。教训很直接:只要类里有private可变字段,就老老实实按瞬时注册,别想着省那点分配。

第二个坑是**Build()里没有做快照**。有一版实现里,Build()直接把_columns这个List<string>塞给产品,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.Throws<AggregateException>(() => 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就够了;如果超过八条,说明这个对象承担的职责太多,该考虑拆分了。

我在实际项目里形成的习惯是,新建建造者的时候顺手在类顶部写一行注释,说明“为什么这个对象需要建造者”。写不出来或者只能写出“参数比较多”的,基本可以直接砍掉。这条规则帮我拦掉了不少为了形式而形式的代码,也让接手的人能快速判断该不该在这里继续加东西。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询