建造者模式实战:告别“十参数构造函数“的数据导出任务

一句话定位:将一个复杂对象的构建与它的表示分离,使同样的构建过程可以创建不同的表示------把"参数怎么组装"从"对象怎么使用"中解放出来。

一、实战场景

报表系统里有一个核心对象:数据导出任务(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 个方法的调用顺序。

四、应用要点与注意事项

  1. 何时使用:对象的构造参数 ≥ 5 个、存在大量可选参数、或调用方经常需要"只设置部分参数"时,Builder 能显著提升可读性。典型场景:HTTP 请求配置、任务/作业定义、查询条件组装、测试数据工厂。
  2. 与构造器/工厂方法的边界:构造器适合参数少且必填的情况;工厂方法适合"按类型创建";Builder 适合"参数多、分步组装"。三者不冲突,甚至可以叠加(工厂返回 Builder 创建的成品)。
  3. Build() 时校验:把"缺数据源""格式非法"这类错误提前到构建阶段暴露,比运行时才发现强得多。这是 Builder 模式常被忽略的价值。
  4. 产品尽量不可变 :构建完成后,产品对象的属性应只读(init / 私有 set),避免对象在使用途中被偷偷修改,导致调度行为不一致。
  5. C# 特有的替代品 :如果只是"参数多"而组装逻辑不复杂,record + with 表达式也能胜任部分场景。但 Builder 在"分步构建 + 最终校验 + 链式可读"上仍然不可替代。

五、小结

建造者模式把"复杂对象的组装过程"从业务代码中剥离出来,让代码从"读不进去的十参数调用"变成"一眼能懂的分步流水线"。在报表导出、任务编排、测试数据构造这类"对象复杂、形态多样"的场景里,它是提升可维护性最立竿见影的模式之一。

相关推荐
SensorFlow1 小时前
ClickHouse 埋点事件表:先验证时间与重复,再谈漏斗看板
后端
SL_staff1 小时前
合规性折旧:当知识资产因主权缺位在审计中‘功能性清零’
java·设计模式·开源
Dreams_l2 小时前
基于SpringBoot实现的抽奖系统(一)
java·spring boot·后端
余槐i2 小时前
rollbackFor 不设置,Checked 异常不回滚:Spring 默认规则与两行修复的取舍
java·spring boot·后端·spring·事务管理
孙启超2 小时前
【AI开发之Rust】第 17 课:项目总览与核心架构 —— AI 助手 Rust 核心从 0 到 1
开发语言·后端·rust
EatFan3 小时前
JunoYi 框架实践:Spring Boot 项目为什么拆成 framework、module、server 三层?
java·spring boot·后端·framework·module·模块化·junoyi
汉堡大王95273 小时前
一张图三句需求,我用 Trae Work 做了一块能看日出日落和月相的天文机械表
前端·后端·github
mudtools3 小时前
在.NET现有系统中快速集成飞书任务分配能力
后端·c#·.net
知守观3 小时前
ThreadLocal + 异步线程导致用户数据串号:一次跨请求数据泄漏的完整复盘
后端