设计模式实战(四):建造者模式——优雅地构造复杂对象

写在前面

上一篇工厂解决"造哪个"。这篇解决另一个常见痛点:对象很复杂,怎么造得清爽

想象一个对象有十几个字段,大多可选。你见过那种构造函数吗——new Order(cust, addr, null, null, true, null, "CNY", 0, ...),调用方数参数能数瞎。这就是伸缩构造函数反模式(Telescoping Constructor),建造者模式就是它的解药。


一、问题:伸缩构造函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 反模式:为了覆盖各种可选组合,构造函数越开越多
public class Mail
{
    public Mail(string from, string to) { ... }
    public Mail(string from, string to, string subject) { ... }
    public Mail(string from, string to, string subject, string body) { ... }
    public Mail(string from, string to, string subject, string body, IEnumerable<string> cc) { ... }
    public Mail(string from, string to, string subject, string body, IEnumerable<string> cc, bool html) { ... }
    // ... 还能继续开
}

// 调用方噩梦:这个 true 是啥?两个 null 又是啥?
var m = new Mail("a@x.com", "b@y.com", "Hi", null, null, true);
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
这坨代码的病:
   参数多,位置易错(传反了编译器不报)
   一堆 null 占位,可读性极差
   新增字段  又要开构造函数,或改所有现有构造函数
   难以表达"必填 vs 可选"

  另一种"病":用无参构造 + 一堆 setter
    var m = new Mail();
    m.From = ...; m.To = ...; m.Subject = ...;
   对象在"半成品"状态就被别人看到(多线程下尤其危险)
   字段可变,无法做不可变对象

二、建造者:分步构造 + 链式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
意图:把复杂对象的构造与它的表示分离。
     分步设置,最后一步统一"产出",保证产出的对象是完整、一致的。

  结构:
    Director/客户端 → 调 Builder 的一系列设置方法(链式)→ 最后 Build() 得对象
    产出过程对象不可见(半成品状态不外泄)

  好处:
    ✓ 参数有名字(.Subject("Hi") 比 null 位置清晰)
    ✓ 必填/可选分明,可在 Build() 时校验
    ✓ 产出的对象可以是不可变的
    ✓ 加字段 = 加一个方法,老调用不受影响(开闭)

三、手写一个 fluent builder

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 目标对象:不可变(只读字段)
public sealed class Mail
{
    public string From { get; }
    public string To { get; }
    public string Subject { get; }
    public string Body { get; }
    public IReadOnlyList<string> Cc { get; }
    public bool IsHtml { get; }

    private Mail(string from, string to, string subject, string body,
                 IReadOnlyList<string> cc, bool isHtml)
    {
        From = from; To = to; Subject = subject; Body = body; Cc = cc; IsHtml = isHtml;
    }

    // 建造者(嵌套类,唯一能造 Mail 的途径)
    public class Builder
    {
        private string _from = "", _to = "", _subject = "", _body = "";
        private List<string> _cc = new();
        private bool _isHtml;

        public Builder From(string v)   { _from = v; return this; }
        public Builder To(string v)     { _to = v; return this; }
        public Builder Subject(string v){ _subject = v; return this; }
        public Builder Body(string v)   { _body = v; return this; }
        public Builder Cc(string v)     { _cc.Add(v); return this; }
        public Builder AsHtml()         { _isHtml = true; return this; }

        public Mail Build()
        {
            if (string.IsNullOrEmpty(_from) || string.IsNullOrEmpty(_to))
                throw new InvalidOperationException("From/To 必填");
            return new Mail(_from, _to, _subject, _body, _cc, _isHtml);
        }
    }
}

// 用:链式调用,一目了然
var mail = new Mail.Builder()
    .From("a@x.com")
    .To("b@y.com")
    .Subject("Hi")
    .Body("正文...")
    .Cc("boss@z.com")
    .AsHtml()
    .Build();   // 校验通过才产出完整对象
1
2
3
4
5
对比开头的噩梦:
  - 每个参数有名字,传不错位置
  - 必填校验在 Build() 里,半成品不会泄漏
  - 产出的 Mail 不可变,天然线程安全
  - 加字段 = Builder 加个方法,老代码不动

四、record 让建造者更简洁

如果对象不需要复杂校验,C# record + with 能省掉一大半样板:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// record 自带"with 表达式",本质就是个轻量建造者
public record MailSettings(string From, string To, string Subject = "",
                           string Body = "", bool IsHtml = false);

// 用:必填位参 + 可选命名参数 + with 覆盖
var m = new MailSettings("a@x.com", "b@y.com") with { Subject = "Hi", IsHtml = true };

// 或要链式体验,用 record 写个轻量 builder
public record MailBuilder(string From, string To)
{
    public string Subject { get; init; } = "";
    public string Body { get; init; } = "";
    public bool IsHtml { get; init; }
}
var b = new MailBuilder("a@x.com", "b@y.com") { Subject = "Hi", IsHtml = true };
1
2
3
4
5
6
7
record + with / init:
  ✓ 极少样板代码
  ✓ 不可变
  ✓ 命名清晰

  适合:对象不复杂、校验少。
  对象复杂、需要严格分步和校验时,还是用上面的经典 builder。

五、.NET 里建造者无处不在

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
你天天见 builder,认出来了吗:

  EF Core
    modelBuilder.Entity<Order>()
        .HasKey(o => o.Id)
        .HasOne(o => o.Customer)
        .WithMany(c => c.Orders);   // ModelBuilder  builder

  ASP.NET Core
    var builder = WebApplication.CreateBuilder(args);
    builder.Services.AddControllers();
    builder.Services.AddSwagger();
    var app = builder.Build();       // 经典分步构造 + Build()

  配置:
    configurationBuilder.AddJsonFile("app.json")
                        .AddEnvironmentVariables()
                        .Build();

  字符串:
    new StringBuilder().Append("a").Append("b").ToString();  // 最朴素的 builder

  它们共同点:链式设置 + 最后一步产出。
  这就是建造者模式的"语言级习惯"

六、建造者 vs 工厂

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
常被搞混,区别其实清楚:

  工厂:一步产出对象
    var order = OrderFactory.Create(type);
     关心"造哪个",一次到位

  建造者:分步产出对象
    var order = new OrderBuilder().For(cust).With(items).Express().Build();
     关心"怎么配置这一个",逐步装配

  选择:
    对象创建简单、有几种类型  工厂
    对象字段多、可选参数多、要分步配置  建造者
    两者可配合:工厂返回一个 builder,让你再细调

七、何时用 / 何时别用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
该用建造者:
  ✓ 字段多(5+),尤其大多是可选
  ✓ 构造需要分步、有顺序、要校验
  ✓ 想要产出的对象不可变
  ✓ 配置类、DSL 式 API(Fluent 接口)

别用:
  ✗ 字段少、必填为主 → 普通构造函数 + record 就够
  ✗ 对象本身就该可变、简单 → 别硬套 builder 增加样板
  ✗ 只有一两种组合 → 工厂更直接

  信号:当你写构造函数写到参数列表比方法体还长、
       调用方需要数着位置传 null —— 就该上建造者了。

八、小结

  • 问题:伸缩构造函数(参数越开越多)和 setter 半成品泄漏
  • 建造者模式:分步链式设置 + 最后 Build() 产出,保证完整、可校验、可不可变
  • 手写:嵌套 Builder 类,每个字段一个返回 this 的方法,Build() 时校验
  • 现代 C#record + with/init 能省掉大部分样板(简单场景)
  • .NET 里随处可见:EF Core ModelBuilder、WebApplication、配置、StringBuilder
  • vs 工厂:工厂一步造"哪个",建造者分步配"这一个"
  • 信号:构造参数列表长到要数 null,就上建造者

下一篇讲装饰器模式——怎么不改原类、透明地给对象叠加新功能(日志/缓存/重试)。