一句话定位:将一个复杂对象的构建与它的表示分离,使同样的构建过程可以创建不同的表示------把"参数怎么组装"从"对象怎么使用"中解放出来。
一、实战场景
报表系统里有一个核心对象:数据导出任务(ExportTask)。一个真实的导出任务,需要描述的东西非常多:
- 数据源(从哪个报表/查询取数)
- 过滤条件(时间范围、地区、渠道......可零到多个)
- 输出格式(Excel / PDF / CSV)
- 目标位置(保存到哪里:FTP / 对象存储 / 邮件附件)
- 是否通知(完成后发邮件/站内信)
- 定时计划(立即执行 or 每天凌晨执行)
如果直接堆构造函数,会变成灾难:
csharp
var task = new ExportTask(
dataSource: "sales_report",
filters: new List<Filter> { ... },
format: ExportFormat.Excel,
target: "s3://bucket/reports",
notifyOnComplete: true,
schedule: "0 0 2 * * ?",
...
// 还有可能继续加第 8、9、10 个参数
);
这种代码的问题是:调用方必须记住每个参数的顺序和含义,稍不留神就传错;参数一旦超过五六个,阅读和修改都极为痛苦;而且很多参数是可选的(这次不通知、下次不排计划),调用方被迫传一堆"默认值"。
建造者模式的做法:把"组装参数"变成一个链式、可读的流程 ,每一步都像在填表,最后 Build() 产出完整对象。
二、设计思路
ExportTaskBuilder(建造者)
│
├─ UseDataSource("sales_report") // 选择数据源
├─ AddFilter(timeRange) // 追加过滤条件(可多次)
├─ UseFormat(Excel) // 选择格式
├─ SaveTo("s3://...") // 目标位置
├─ NotifyOnComplete() // 开启完成通知
├─ ScheduleDaily("02:00") // 设置定时计划
│
└─ Build() ────────────────▶ ExportTask(产品)
关键点:
- 每个方法都返回
this,支持链式调用; - 可选参数通过"是否调用对应方法"来表达,调用方只写关心的部分;
Build()时做最终校验(比如"没有数据源不能导出"),把非法状态挡在创建阶段。
三、代码实现(.NET 10 / C# 14)
3.1 产品:ExportTask(不可变,构建后只读)
csharp
namespace Reports.Export;
public enum ExportFormat
{
Excel,
Pdf,
Csv
}
public sealed record Filter(string Field, string Operator, string Value);
public sealed class ExportTask
{
private ExportTask(
string dataSource,
IReadOnlyList<Filter> filters,
ExportFormat format,
string targetLocation,
bool notifyOnComplete,
string? cronSchedule)
{
DataSource = dataSource;
Filters = filters;
Format = format;
TargetLocation = targetLocation;
NotifyOnComplete = notifyOnComplete;
CronSchedule = cronSchedule;
}
public string DataSource { get; }
public IReadOnlyList<Filter> Filters { get; }
public ExportFormat Format { get; }
public string TargetLocation { get; }
public bool NotifyOnComplete { get; }
public string? CronSchedule { get; }
// 产品类内部持有建造者,把"构建细节"收拢在一处
public sealed class Builder
{
private string _dataSource = default!;
private readonly List<Filter> _filters = [];
private ExportFormat _format = ExportFormat.Excel;
private string _targetLocation = "./output";
private bool _notifyOnComplete;
private string? _cronSchedule;
public Builder UseDataSource(string dataSource)
{
_dataSource = dataSource;
return this;
}
public Builder AddFilter(Filter filter)
{
_filters.Add(filter);
return this;
}
public Builder UseFormat(ExportFormat format)
{
_format = format;
return this;
}
public Builder SaveTo(string location)
{
_targetLocation = location;
return this;
}
public Builder NotifyOnComplete()
{
_notifyOnComplete = true;
return this;
}
public Builder ScheduleDaily(string hhmm)
{
// 把"每天 02:00"翻译成 Cron 表达式,调用方不用懂 Cron
var (h, m) = (int.Parse(hhmm[..2]), int.Parse(hhmm[3..]));
_cronSchedule = $"0 {m} {h} * * ?";
return this;
}
public ExportTask Build()
{
if (string.IsNullOrWhiteSpace(_dataSource))
throw new InvalidOperationException("必须指定数据源(UseDataSource)");
return new ExportTask(
_dataSource,
[.. _filters], // 集合表达式拷贝,防止外部修改
_format,
_targetLocation,
_notifyOnComplete,
_cronSchedule);
}
}
}
3.2 使用示例:三种不同形态的导出任务
csharp
namespace Reports.Export;
public sealed class ExportScheduler
{
public async Task ScheduleDailySalesReportAsync(CancellationToken ct = default)
{
// 任务一:每天凌晨导出全国销售日报到 S3,完成后发通知
var dailyTask = new ExportTask.Builder()
.UseDataSource("sales_daily")
.AddFilter(new Filter("date", ">=", "today-1"))
.AddFilter(new Filter("region", "=", "CN"))
.UseFormat(ExportFormat.Excel)
.SaveTo("s3://reports/daily")
.NotifyOnComplete()
.ScheduleDaily("02:00")
.Build();
await EnqueueAsync(dailyTask, ct);
}
public async Task ExportAdhocAsync(CancellationToken ct = default)
{
// 任务二:临时导出,不做定时、不发通知
var adhocTask = new ExportTask.Builder()
.UseDataSource("sales_daily")
.UseFormat(ExportFormat.Csv)
.SaveTo("s3://reports/adhoc")
.Build();
await EnqueueAsync(adhocTask, ct);
}
private async Task EnqueueAsync(ExportTask task, CancellationToken ct)
{
// 真实项目:写入任务队列(如 Hangfire / Quartz / 消息队列)
Console.WriteLine($"""
任务入队:
数据源: {task.DataSource}
过滤条件: {task.Filters.Count} 个
格式: {task.Format}
目标: {task.TargetLocation}
通知: {task.NotifyOnComplete}
定时: {task.CronSchedule ?? "立即执行"}
""");
await Task.CompletedTask;
}
}
3.3 进阶:Director(导演)复用"固定套路"
如果团队里"每日报表"的组装步骤完全固定,可以把这套流程抽成 Director,让调用方一行代码就能拿到标准任务:
csharp
namespace Reports.Export;
public sealed class ReportDirector
{
public ExportTask CreateDailyReportTask()
=> new ExportTask.Builder()
.UseDataSource("sales_daily")
.AddFilter(new Filter("region", "=", "CN"))
.UseFormat(ExportFormat.Excel)
.SaveTo("s3://reports/daily")
.NotifyOnComplete()
.ScheduleDaily("02:00")
.Build();
public ExportTask CreateWeeklyReportTask()
=> new ExportTask.Builder()
.UseDataSource("sales_weekly")
.AddFilter(new Filter("region", "=", "CN"))
.UseFormat(ExportFormat.Pdf)
.SaveTo("s3://reports/weekly")
.ScheduleDaily("06:00")
.Build();
}
这样业务层就变成了:
csharp
var task = new ReportDirector().CreateDailyReportTask();
Director 的价值:把"组合步骤"也变成可复用的知识,新同事不用再研究 6 个方法的调用顺序。
四、应用要点与注意事项
- 何时使用:对象的构造参数 ≥ 5 个、存在大量可选参数、或调用方经常需要"只设置部分参数"时,Builder 能显著提升可读性。典型场景:HTTP 请求配置、任务/作业定义、查询条件组装、测试数据工厂。
- 与构造器/工厂方法的边界:构造器适合参数少且必填的情况;工厂方法适合"按类型创建";Builder 适合"参数多、分步组装"。三者不冲突,甚至可以叠加(工厂返回 Builder 创建的成品)。
- Build() 时校验:把"缺数据源""格式非法"这类错误提前到构建阶段暴露,比运行时才发现强得多。这是 Builder 模式常被忽略的价值。
- 产品尽量不可变 :构建完成后,产品对象的属性应只读(
init/ 私有 set),避免对象在使用途中被偷偷修改,导致调度行为不一致。 - C# 特有的替代品 :如果只是"参数多"而组装逻辑不复杂,
record+with表达式也能胜任部分场景。但 Builder 在"分步构建 + 最终校验 + 链式可读"上仍然不可替代。
五、小结
建造者模式把"复杂对象的组装过程"从业务代码中剥离出来,让代码从"读不进去的十参数调用"变成"一眼能懂的分步流水线"。在报表导出、任务编排、测试数据构造这类"对象复杂、形态多样"的场景里,它是提升可维护性最立竿见影的模式之一。