2026/11/2 13:44:01

C#建造者模式实战:从构造函数参数到链式Builder与校验

C#建造者模式实战:从构造函数参数到链式Builder与校验 构造函数参数写到第七个的时候我一般会先停手回头看看这个类到底想干什么。在 C# 里建造者模式Builder Pattern就是这种“停手”之后最常被拿起来的方案之一把一个复杂对象的装配过程从构造函数里剥出来拆成一步一步带语义的调用最后由一个Build()一次性交付成品。它解决的问题不复杂——参数太多、可选组合太杂、构造过程有顺序依赖、校验逻辑散落各处——但真要落到 C# 这门语言里写法上的坑比想象中多。这篇文章面向的是已经能写类、能写接口但在“这个对象到底该怎么造”上反复纠结的开发者也适合想把团队里那套半吊子 Builder 收拾干净的人。下面我按实际项目里的推进顺序从为什么用、结构怎么拆、代码怎么写、C# 特性怎么借力、踩过哪些坑一路讲到什么时候压根不该用它。1. 为什么在 C# 里还要专门聊建造者模式1.1 从一段被参数淹没的构造函数说起先看一段几乎每个项目都出现过的代码形态。某个报表导出任务早期只有模板名和数据源两个参数后来加了列筛选、加了格式、加了是否带表头、加了分页大小、加了超时、加了重试次数最后变成这样var task new ExportTask( monthly-sales, ds_finance, new[] { region, amount, date }, ExportFormat.Xlsx, true, 50000, TimeSpan.FromMinutes(3), 2);这段代码能跑但没人愿意读第二遍。true是什么2是重试次数还是并发数50000是行数上限还是字节数调用方每次都得跳回定义处对照参数顺序改一个参数位置就得全局搜。更麻烦的是大部分调用场景里这些参数只有两三个非默认值剩下全是“随便给个值占位”于是仓库里到处是false、0、null、TimeSpan.Zero这类填充物。这就是建造者模式最经典的入场信号构造参数数量超过人的短期记忆容量且存在大量可选组合。注意我强调的是“可选组合”如果所有参数都必填且固定老老实实写构造函数反而更清楚别为了模式而模式。1.2 建造者模式到底解决了哪几类痛点我把实际遇到的痛点归成四类你可以对照自己的代码看看中了几条。第一类是参数顺序脆弱。位置参数的本质是把语义绑在序号上一旦签名变动编译期能查出一部分但同类型参数的互换两个string、两个int编译期完全无感运行起来才发现模板名填成了数据源名。第二类是构造过程的顺序依赖。有些对象的装配是有先后逻辑的比如必须先设置数据源才能校验列名是否存在于该数据源必须先确定格式才能决定分页策略。把这些塞进构造函数要么在构造器里写一大坨if要么把校验推迟到使用时刻错误暴露得太晚。第三类是校验时机分散。构造函数里校验一半属性 setter 里校验一半Execute()里再兜底校验一半最后谁也不知道哪条规则在哪一层生效。第四类是同一套装配流程要产出不同形态的成品。比如内部调试用的导出任务要带详细日志、要限制行数对外交付的导出任务要压缩、要加密水印。两者的装配步骤几乎一样只是某几步的实现不同。这时候把流程固定下来、把步骤实现下放就非常划算。1.3 它和工厂模式、对象初始化器的边界在哪这三个东西经常被混着讲我用一句话区分工厂管“造哪一个”建造者管“怎么一步步造”对象初始化器管“造完以后填什么”。工厂方法返回的是“某一族中的具体类型”调用方不关心构造细节建造者返回的通常是同一个类型但装配过程被显式拆开调用方能控制每一步。而 C# 的对象初始化器new Foo { A 1, B 2 }只是语法糖它没有顺序约束、没有过程校验、也没法在中间插入逻辑本质上还是“先构造再赋值”。所以判断标准很清楚如果你的对象只需要“填字段”用初始化器或者构造函数就够了如果装配过程本身有逻辑、有顺序、有中间状态才轮到建造者上场。我在评审代码时经常看到有人给一个只有三个属性的 DTO 写了个两百行 Builder那不是设计那是自我感动。2. 建造者模式的核心结构拆解2.1 四个角色与职责划分教科书上的标准结构是四个角色产品Product、抽象建造者Builder、具体建造者ConcreteBuilder、指挥者Director。我按 C# 的落地习惯重新描述一遍它们的实际职责。产品类是最终交付的对象它的关键特征是“构造完成后应当尽量不可变”。这一点很多人忽略结果 Builder 造完对象外面还能随手改属性那 Builder 里做的校验全白费。我的做法是产品类的所有属性只读构造函数标记为internal或private只对同程序集内的 Builder 开放。抽象建造者定义装配步骤的契约。注意它是“步骤”的契约不是“属性赋值”的契约。SetFormat(...)是属性赋值UseCompression()是步骤后者语义更强、更容易在内部做联动。具体建造者持有中间状态实现每一步并在Build()里做最终校验和产品构造。中间状态用什么存我倾向用可变字段因为 Builder 本身就该是可变的只是它不该被共享。指挥者负责编排步骤顺序。它的价值在于“同一套流程产出不同配置”如果只有一个固定流程指挥者就是纯多余的中间层直接砍掉。2.2 指挥者该不该保留这是我被问得最多的一个问题。我的答案分三种情况。如果装配流程只有一条固定路径且调用方永远按同样顺序调用那 Director 是负担。你会写一个只有Construct()一个方法的类里面依次调五个Set然后调用方为了用它还得再传一次 Builder代码行数不减反增。如果装配流程存在多条预设路径比如“快速模式”“完整模式”“离线模式”Director 就很有价值它把“模式”这个概念固化成代码调用方只需要选模式不需要知道每一步。如果装配流程需要调用方灵活介入那就不该用 Director改用链式调用让调用方自己决定顺序。这时候 Director 的角色其实被Build()的校验逻辑接管了——校验会告诉你顺序错了但不强制你怎么走。实话说我在业务代码里用 Director 的次数远少于链式 BuilderDirector 更多出现在框架层、SDK 层这类“流程固定、调用方不该操心”的地方。2.3 链式调用背后的接口设计取舍链式调用的写法很讨喜Builder.WithX().WithY().Build()。但它有个隐藏的设计问题——返回值类型写什么。如果每个方法都返回IExportTaskBuilder接口那调用顺序就完全自由但代价是调用方拿不到具体类型也没法做编译期的顺序约束。如果返回具体类型ExportTaskBuilder链式更顺滑但接口的抽象意义就被架空了。还有第三种玩法用泛型自引用CRTP 风格BuilderTBuilder where TBuilder : BuilderTBuilder每个方法返回TBuilder。这套写法的好处是继承友好——子类 Builder 链式调用后返回的仍然是子类类型不会退化成基类。代价是类型签名变长新手看着发怵而且一旦泛型参数写错编译器报的错能把人看懵。我的取舍原则是只在一个程序集内部用、没有扩展需求就直接返回具体类型简单直接要给外部继承扩展才上泛型自引用。中间态返回接口我基本不用因为它两头不讨好。3. 从零实现一个可落地的建造者3.1 场景设定与需求梳理假设我们在做一个数据导出模块需要构造一个导出任务对象。需求整理如下必填项模板标识、数据源标识。选填项导出列集合、导出格式默认 Xlsx、是否包含表头默认 true、最大行数默认 10000、超时时间默认 2 分钟、失败重试次数默认 1。约束一导出列为空时表示导出全部列此时不能在构建阶段报错。约束二最大行数必须大于 0且不能超过 100 万保护下游。约束三格式为 Csv 时必须包含表头否则下游解析器无法识别列。约束四超时时间至少 10 秒。这些约束里约束三是典型的“跨字段校验”构造函数里硬写会让参数表更乱而放到 Builder 的Build()里就非常自然。3.2 产品类与建造者接口定义先写产品类。核心思路是属性全只读构造函数internal所有校验前置到 Builder 里完成产品类自身只做最基本的不变式检查。public enum ExportFormat { Xlsx, Csv, Json } public sealed class ExportTask { public string Template { get; } public string DataSource { get; } public IReadOnlyListstring Columns { get; } public ExportFormat Format { get; } public bool IncludeHeader { get; } public int MaxRows { get; } public TimeSpan Timeout { get; } public int RetryCount { get; } internal ExportTask( string template, string dataSource, IReadOnlyListstring columns, ExportFormat format, bool includeHeader, int maxRows, TimeSpan timeout, int retryCount) { Template template; DataSource dataSource; Columns columns; Format format; IncludeHeader includeHeader; MaxRows maxRows; Timeout timeout; RetryCount retryCount; } }这里IReadOnlyListstring是有意为之。如果公开Liststring外部拿到引用后可以继续Add产品就变成可变对象了。传进来之前我在 Builder 里做一次ToArray()拷贝切断引用。接口定义部分我倾向于把它写得“动词化”一些public interface IExportTaskBuilder { IExportTaskBuilder FromTemplate(string template); IExportTaskBuilder FromDataSource(string dataSource); IExportTaskBuilder WithColumns(params string[] columns); IExportTaskBuilder AsFormat(ExportFormat format); IExportTaskBuilder WithHeader(bool include); IExportTaskBuilder LimitRows(int maxRows); IExportTaskBuilder WithTimeout(TimeSpan timeout); IExportTaskBuilder WithRetry(int count); ExportTask Build(); }AsFormat、WithTimeout这种命名比SetFormat读起来更顺尤其在链式调用里连成一句话的时候。3.3 具体建造者与最终校验实现类的重点全在Build()里。前面所有步骤只负责收集状态不做任何校验校验统一收口——这是我最坚持的一条规则因为分散校验最难维护。public sealed class ExportTaskBuilder : IExportTaskBuilder { private string? _template; private string? _dataSource; private string[]? _columns; private ExportFormat _format ExportFormat.Xlsx; private bool _includeHeader true; private int _maxRows 10000; private TimeSpan _timeout TimeSpan.FromMinutes(2); private int _retryCount 1; public IExportTaskBuilder FromTemplate(string template) { _template template; return this; } public IExportTaskBuilder FromDataSource(string dataSource) { _dataSource dataSource; return this; } public IExportTaskBuilder WithColumns(params string[] columns) { _columns columns; return this; } public IExportTaskBuilder AsFormat(ExportFormat format) { _format format; return this; } public IExportTaskBuilder WithHeader(bool include) { _includeHeader include; return this; } public IExportTaskBuilder LimitRows(int maxRows) { _maxRows maxRows; return this; } public IExportTaskBuilder WithTimeout(TimeSpan timeout) { _timeout timeout; return this; } public IExportTaskBuilder WithRetry(int count) { _retryCount count; return this; } public ExportTask Build() { if (string.IsNullOrWhiteSpace(_template)) throw new InvalidOperationException(模板标识不能为空。); if (string.IsNullOrWhiteSpace(_dataSource)) throw new InvalidOperationException(数据源标识不能为空。); if (_maxRows 0 || _maxRows 1_000_000) throw new ArgumentOutOfRangeException(nameof(_maxRows), 最大行数需在 1 到 1000000 之间。); if (_timeout TimeSpan.FromSeconds(10)) throw new ArgumentOutOfRangeException(nameof(_timeout), 超时时间不得少于 10 秒。); if (_retryCount 0 || _retryCount 5) throw new ArgumentOutOfRangeException(nameof(_retryCount), 重试次数需在 0 到 5 之间。); if (_format ExportFormat.Csv !_includeHeader) throw new InvalidOperationException(Csv 格式必须包含表头否则下游无法解析列。); var columns _columns is null || _columns.Length 0 ? Array.Emptystring() : (string[])_columns.Clone(); return new ExportTask( _template!, _dataSource!, columns, _format, _includeHeader, _maxRows, _timeout, _retryCount); } }这段代码里有几个细节值得单独说。第一_columns用Clone()而不是直接赋值防止调用方持有的数组被后续修改影响产品。第二用_template!的 null 抑制符是在校验之后逻辑上安全但如果哪天有人把校验顺序改了这里就会静默出错所以我更推荐把校验结果直接赋给局部变量让编译器帮忙盯着。提示Build()里的异常类型选择也有讲究。参数本身非法用ArgumentOutOfRangeException跨字段约束冲突用InvalidOperationException这样上层捕获时能区分“调用方传错了”和“对象状态不允许”。3.4 客户端调用与流式改造调用侧写起来是这样var task new ExportTaskBuilder() .FromTemplate(monthly-sales) .FromDataSource(ds_finance) .WithColumns(region, amount, date) .AsFormat(ExportFormat.Csv) .LimitRows(20_000) .WithTimeout(TimeSpan.FromMinutes(5)) .Build();对比最开始那串位置参数可读性差距是断崖式的。每个值的含义都写在方法名上参数顺序可以随便调新增参数不影响已有调用点。如果你想让调用更省事可以在产品类上加一个静态入口public static IExportTaskBuilder CreateBuilder() new ExportTaskBuilder();或者在 Builder 上加一个“预设”把常见组合固化public static ExportTask CreateMonthlySalesTask() { return new ExportTaskBuilder() .FromTemplate(monthly-sales) .FromDataSource(ds_finance) .AsFormat(ExportFormat.Xlsx) .Build(); }这就是 Director 的轻量版——一个静态工厂方法比单独建一个 Director 类好维护得多也不会让调用方多写一行new Director(builder).Construct()。4. C# 语言特性下的建造者变体4.1 用对象初始化器替代简单场景C# 有对象初始化器很多“看起来需要 Builder”的场景其实不需要。判断标准我总结成一条如果所有赋值都是独立的、没有跨字段校验、没有顺序依赖就别写 Builder。var task new ExportTask { Template monthly-sales, DataSource ds_finance, Format ExportFormat.Xlsx };这种写法的问题是产品类必须可写牺牲了不可变性。如果你的项目能接受这一点比如它只是个内部 DTO那用初始化器省下的代码量相当可观。但如果这个对象要跨层传递、要被缓存、要被多线程共享那还是老老实实走 Builder 保持不可变。我见过一种折中写法属性 setter 标记为init这样对象初始化器能用初始化完之后就锁死。public string Template { get; init; } ;init访问器是 C# 9 引入的它完美解决了“既要初始化器语法又要不可变”的矛盾。缺点是它不支持 Builder 那套跨字段校验——init只能在赋值时做单字段检查跨字段规则还是得靠IValidatableObject或者外部校验器。所以我的经验是字段少、规则简单上init字段多、规则复杂上 Builder。4.2 record 与 with 表达式的适用边界record加with表达式是另一条路。它天生不可变复制修改也方便public record ExportTask( string Template, string DataSource, IReadOnlyListstring Columns, ExportFormat Format, bool IncludeHeader, int MaxRows, TimeSpan Timeout, int RetryCount); var baseTask new ExportTask(t1, ds1, Array.Emptystring(), ExportFormat.Xlsx, true, 10000, TimeSpan.FromMinutes(2), 1); var debugTask baseTask with { MaxRows 100, RetryCount 0 };这个写法在“基于已有配置做小改动”的场景下非常好用代码量比 Builder 少一个数量级。但它有两个硬伤一是主构造函数参数一多with表达式的可读性仍然依赖调用方知道参数名二是with会做浅拷贝Columns这种引用类型字段在多个副本间共享改一个影响所有——除非你把它换成ImmutableArraystring。我用record的经验是参数在 5 个以内、集合字段用不可变类型、不需要过程校验用 record with超过这个规模还是 Builder 更稳。4.3 泛型自引用建造者与继承友好设计前面提过 CRTP 风格的泛型 Builder这里给个具体形态。它的价值主要在框架层——当你需要一个基类 Builder又希望子类链式调用不丢类型时才值得这么写。public abstract class BuilderBaseTBuilder where TBuilder : BuilderBaseTBuilder { protected abstract TBuilder Self { get; } public TBuilder WithRetry(int count) { // 赋值逻辑 return Self; } public TBuilder WithTimeout(TimeSpan timeout) { // 赋值逻辑 return Self; } } public sealed class ExportTaskBuilder : BuilderBaseExportTaskBuilder { protected override ExportTaskBuilder Self this; }这套写法的代价很直观类型签名变长、每个方法都得调Self、调试时栈里多一层。收益是子类调WithRetry返回的仍然是ExportTaskBuilder链式不会断。如果你的项目里 Builder 不存在继承关系别用这套纯属自找麻烦。4.4 不可变对象与 Builder 的配合最后强调一个容易被忽略的点Builder 的价值上限取决于产品类的不可变程度。如果产品类暴露了可变属性Builder 里辛苦做的校验在对象交付后随时可能被绕过。我的做法是在产品类上加一个轻量防御public IReadOnlyListstring Columns { get; }配合 Builder 里的Clone()基本能挡住大部分误用。如果团队里有人习惯性写task.Columns.Add(...)编译期就会报错这比运行时排查方便得多。另外集合字段建议用IReadOnlyListT而不是IEnumerableT——后者无法保证多次遍历结果一致可能是延迟执行的查询在序列化场景里会出诡异问题。5. 常见坑与排查实录5.1 链式调用返回 this 还是新实例这是最容易埋雷的地方。返回this意味着 Builder 是可变共享对象返回新实例意味着每次链式调用都产生一个副本。两种做法各有适用面。返回this的问题在于如果 Builder 被重复使用上一次的状态会残留。我遇到过一段代码Builder 被声明成了static readonly然后用它构建了两个不同配置的任务第二个任务莫名其妙带上了第一个的列配置。排查了两小时才发现是状态污染。返回新实例不可变 Builder能根治这个问题代价是每次调用都分配对象高频场景下有 GC 压力。折中做法是Builder 保持可变但在Build()之后调用Reset()或者干脆用完即弃不要跨方法传递 Builder 实例。注意绝对不要把 Builder 放进静态字段、单例容器或者缓存里。Builder 生命周期就是“从new到Build()这一小段”超出这个范围就是设计错误。5.2 建造者复用导致的状态污染状态污染的具体表现有很多种我列几个真实见过的一种是数组被直接持有。WithColumns(params string[] columns)直接把传进来的数组赋给_columns调用方后续改了这个数组Build 出来的对象内容也跟着变。解法是入口处Clone()。另一种是默认值被意外覆盖。某个步骤内部为了“智能处理”把用户没设置的字段填了默认值用户后来显式设置时发现设置不生效——因为内部逻辑判断了“已有值就不覆盖”。这种隐式逻辑最难查原则是Builder 的每一步只做“记录意图”不做“推断意图”。还有一种更隐蔽Builder 内部用了static缓存来存中间结果本意是优化结果多线程下串了数据。这类问题只在压测时暴露常规单测根本测不出来。5.3 线程安全与并发构建Builder 本身不要求线程安全因为它的正确用法就是单线程顺序调用。但如果你的代码把 Builder 传进了并行流程比如Parallel.For里每个迭代都往里塞配置那必须加锁或者每个线程一个 Builder。我的建议是后者每个构建任务一个 Builder 实例。这比加锁简单得多也避免了锁竞争。如果怕忘可以把 Builder 设计成“用完即废”的形态不给它任何复用的机会——比如把Build()之后的所有 setter 都置为抛异常用一个_built标志位控制。这套防御在团队协作里特别管用因为总有人会想“我先 Build 一个个试试不行再改改”。5.4 常见问题速查表把上面说的问题整理成一张表方便排查时对照现象大概率原因处理方式第二次构建的对象带上次的配置Builder 被复用或放入静态字段用完即弃或 Build 后置失效标志构建后修改集合影响已构建对象集合引用被直接持有入口处拷贝产品暴露IReadOnlyList必填项校验没生效校验写在 setter 里为空的字段没触发 setter校验统一收口到Build()异常类型混乱上层无法分类处理校验里混用ArgumentException和Exception按“参数非法 / 状态冲突”两类区分链式调用断链编译不过方法返回类型写成接口或基类返回具体类型或上泛型自引用继承后链式调用返回基类类型基类方法返回基类改用泛型自引用TBuilder多线程下配置串号Builder 被共享每线程一个实例禁止共享6. 实战场景与性能权衡6.1 复杂配置对象的构建建造者模式最舒服的落地场景就是“配置对象”。这类对象的特点是字段多、默认值多、存在跨字段约束而且往往要序列化成配置文件或者网络请求体。我做过一个内部调度模块任务配置有二十多个字段包含并发度、优先级、超时、重试、依赖任务列表、失败策略、告警渠道等等。如果走构造函数调用点会惨不忍睹如果走属性赋值校验就完全失控。最后用的是 Builder 加一条“配置模板”的路子把常用的几种配置固化成静态方法调用方从模板出发做微调。var config JobConfigBuilder .HighThroughput() // 预设并发 16、超时 30 分钟、重试 3 次 .WithAlert(ops-channel) // 只改需要改的部分 .Build();这套写法的关键设计是预设方法返回的是 Builder不是成品。这样既能复用预设又保留了微调空间。如果预设直接返回成品那就变成工厂了灵活性反而下降。6.2 测试数据构造器这是 Builder 在工程实践里的另一个高价值场景但很多人没意识到。单元测试里构造测试对象如果每个用例都写一遍完整的构造函数测试代码会迅速膨胀而且一旦产品类新增字段所有测试全要改。测试专用 Builder 的写法有个固定套路所有字段给合理默认值测试只设置关心的字段。public sealed class ExportTaskStub { private string _template test-template; private string _dataSource test-ds; private ExportFormat _format ExportFormat.Xlsx; private int _maxRows 1000; public ExportTaskStub WithFormat(ExportFormat format) { _format format; return this; } public ExportTaskStub WithMaxRows(int rows) { _maxRows rows; return this; } public ExportTask Build() new ExportTask( _template, _dataSource, Array.Emptystring(), _format, true, _maxRows, TimeSpan.FromMinutes(2), 1); }好处是测试用例只表达“这个用例关心什么”其余字段全部默认测试意图一目了然。而且产品类新增字段时只需要在这一个地方补默认值所有测试自动适配。这套东西在测试驱动开发里几乎是标配值得单独建一个TestBuilders目录来放。6.3 何时不该用建造者模式最后必须说说反面。建造者模式不是没有成本它的成本在于多一个类、多一层间接、多一处需要同步维护的字段清单。产品类每加一个字段Builder 要加一个方法、加一个字段、加一次赋值、可能还要加一条校验——四个地方要动。如果你的产品类还在高频变动期这套维护成本会让人抓狂。以下几种情况我会果断放弃 Builder字段少于四个且全部必填。直接构造函数参数名靠 IDE 提示就够加 Builder 纯属增加认知负担。对象创建后允许自由修改。既然可变那 Builder 的“构建期校验”就没有意义用对象初始化器加一个Validate()方法更实际。构建逻辑只是简单赋值没有顺序和约束。这种情况用record加with代码量能少三分之二。性能敏感的路径。Builder 每次链式调用都有方法调用开销Build()里还有拷贝和校验。如果一个对象要在循环里创建百万次这套开销不能忽略。实测下来简单 Builder 构建一百万次比直接构造函数慢三到五倍量级不大但也不能无视。这种场景我一般会提供一个“快速构造”的静态方法内部直接走构造函数绕过 Builder。说到底建造者模式的定位是“用一点结构性成本换取构造过程的可读性、可校验性和可演进性”。这笔账划不划算取决于你的对象复杂度。我个人的判断线是字段超过 5 个、存在可选组合、存在跨字段约束——三条中占两条就值得上 Builder只占一条先试试init属性和record一条都不占构造函数挺好。再补一个我踩过的细节。有次给一个高频构建的对象加了 Builder上线后发现 GC 压力上升查下来是链式调用每步都返回this本身没问题但 Builder 里的Liststring在每次Build()时都ToArray()生成新数组一百万次构建就是一百万个数组。后来改成“调用方传数组就直接用、传集合才转换”压力立刻降下来。这类优化不复杂但在写 Builder 的时候就得想清楚——拷贝是为了安全安全是有代价的代价该不该付要看对象被谁持有。这个判断没有标准答案只能结合具体调用链去看。