Builder模式是创建型模式,它用来构建比较复杂的对象,这些对象无法通过单一的构造函数来实现,比如要构造一个类似HTML这样的具有嵌套结构的对象,这个类或许由其他几个类或者对象构成,或者具有一些特殊的构建逻辑。
Builder这里翻译参照GoF翻译为生成器模式,通常用来建造复杂的对象,下面用几个例子来说明,这些例子只是用来说明生成器模式,在实际应用中还要考虑其他因素。
场景
假设我们需要构建一个组件用来显示web页面。一个Web页面可能包含一个或者多个段落,或者其他组件,要构建一个段落,通常可以简单用字符串拼接,比如下面这个代码就构建了一个p段落。
var hello = "hello";
var sb = new StringBuilder();
sb.Append("<p>");
sb.Append(hello);
sb.Append("</p>");
WriteLine(sb);
非常简单,现在,加入我们要构建一个无序列表ul,也比简单,方法如下
var words = new[] {"hello", "world"};
sb.Clear();
sb.Append("<ul>");
foreach (var word in words)
{
sb.AppendFormat("<li>{0}</li>", word);
}
sb.Append("</ul>");
WriteLine(sb);
非常简单,但是,这不够OOP,对于每一个HTML元素,我们可以定义一个对象HtmlElement来表示,并且存储一些信息
public class HtmlElement
{
public string Name, Text;
public List<HtmlElement> Elements = new List<HtmlElement>();
public HtmlElement() { }
public HtmlElement(string name, string text)
{
Name = name;
Text = text;
}
}
在每一个元素内,还可以包含其他子元素,通过定义对象,上面的代码可以改写为
var words = new[] { "hello", "world" };
var tag = new HtmlElement("ul", null);
foreach (var word in words)
{
tag.Elements.Add(new HtmlElement("li", word));
}
Console.WriteLine(tag);
上面的代码比之前使用StringBuilder方式手动拼接更容易控制,另外也比较面向对象。然而用这种方式构建HtmlElement仍然不够方便,特别是当我们对其包含的其他对象Elements进行初始化时仍然需要进行而外操作,我如我们要知道对象包含了Elements,并对Elements集合进行操作,这也不符合SOLID原则。所以就引出了生成器模式。
简单生成器
生成器模式就是简单的将对象的构造外包(outsource)给单独的类来完成。第一次外包尝试如下:
class HtmlBuilder
{
protected readonly string rootName;
protected HtmlElement root = new HtmlElement();
public HtmlBuilder(string rootname)
{
rootName = rootname;
root.Name = rootname;
}
public void AddChild(string childName, string childText)
{
var e = new HtmlElement(childName, childText);
root.Elements.Add(e);
}
public override string ToString()
{
return root.ToString();
}
}
这个生成器能更好的来完成HTML的构建任务,在HtmlBuilder的构造函数中,root用来表示根对象。如果传入“ul”,那就是一个无序列表,如果传入“p"就代表一个段落。在内部,根对象以HtmlElement对象表示,这里我们仍然将跟对象的名称保存下来,以便后续可能的地方会用到。
这里直接提供了AddChild方法,使得我们不用直接去对Child集合进行操作,目前这里的AddChild方法返回的是void。经过上述改造之后,代码变为:
var builder = new HtmlBuilder("ul");
builder.AddChild("li", "hello");
builder.AddChild("li", "world");
Console.WriteLine(builder.ToString());
代码简洁多了,注意到AddChild返回的是void类型,在大多数情况下,一次AddChild操作并不能完成对象构建,所以我们希望返回一个有用的值来进行后续的操作。
流式生成器(Fluent Builder)
流式(Fluent)方法我们在LINQ里面用到过很多,比如LINQ查询,每次操作都会返回一个IQueryable对象,进而可以进一步操作,在这里我们只需要将AddChild方法的返回值由void改为建造器本身,就能进行流式生成(Fluent Builder)了。
public HtmlBuilder AddChild(string childName, string childText)
{
var e = new HtmlElement(childName, childText);
root.Elements.Add(e);
return this;
}
现在,上述的代码可以进行链式操作了。
var builder = new HtmlBuilder("ul");
builder.AddChild("li", "hello").AddChild("li", "world");
Console.WriteLine(builder.ToString());
这里,返回this的技巧,在很多地方非常有用,使得我们能够一条语句链式操作就能完成很多需要分几句写的操作。
沟通意图(Communicating Intent)
我们已经有了一个可以链式构造的HtmlBuilder,但是用户在没看源代码之前,如何知道生成器的使用方法呢?一个比较好的方式是当用户想建造对象时,能够知道并且容易使用我们提供的生成器方法,比如我们不要让用户去使用构造函数,而是在HtmlElement里提供一个返回生成器的方法。
class HtmlElement
{
public string Name, Text;
public List<HtmlElement> Elements = new List<HtmlElement>();
internal HtmlElement() { }
internal HtmlElement(string name, string text)
{
Name = name;
Text = text;
}
public static HtmlBuilder Create(string name)
{
return new HtmlBuilder(name);
}
}
对HtmlElement的修改有两处,第一处是隐藏构造函数,这里我们将HtmlElement的构造函数改为了internal,表示在该程序集下可用(改成protected不行,否则在HtmlBuilder里面无法访问HtmlElement的构造函数)。让用户无法使用构造函数来生成HtmlElement对象,第二处是,我们隐藏了对象建造的细节,将对象构造的细节外包给了我们之前写好的生成器。
现在,整个代码变为了
HtmlBuilder builder = HtmlElement
.Create("ul")
.AddChild("li", "hello")
.AddChild("li", "world");
Console.WriteLine(builder.ToString());
可以看到,我们强迫用户使用我们提供的Create方法来创建了一个HtmlElement的生成器HtmlBuilder,因为HtmlElement的构造函数已经被我们隐藏了。
现在还有一个地方,我们其实希望Create返回的对象能够被当作HtmlElement来使用,而不是HtmlBuilder,但是又能作为HtmlBuilder来使用,所以这里用到了隐式转换,在HtmlBuilder中,我们增加隐式转换为HtmlElement的方法。
public static implicit operator HtmlElement(HtmlBuilder builder)
{
return builder.root;
}
在HtmlElement中添加以上方法,它可以将Create产生的HtmlBuilder可以隐式转化为HtmlElement使用。这也符合直觉,当使用HtmlElement.Create 返回的HtmlBuilder对象,在构造完成之后,可以直接作为HtmlElement使用,非常好,现在类型可以改为HtmlElement了:
HtmlElement builder = HtmlElement
.Create("ul")
.AddChild("li", "hello")
.AddChild("li", "world");
Console.WriteLine(builder.ToString());
关于隐式转换,可以看这篇文章,这个特性在某些情况下非常有用。
现在Create方法返回的类型其实是HtmlBuilder,没有办法让用户直接知道也可以当作HtmlElement来用,这其中存在隐式转换,所以我们有必要在HtmlBuilder中提供方法来返回一个HtmlElement对象。
public HtmlElement Build()
{
return root;
}
这样,在上面的构造中,最后调用Build()方法,明确返回了HtmlElement对象(不调用Build方法返回的是HtmlBuilder对象,但是可以通过隐式转换为HtmlElement)。所以我们在添加隐式转换后,一定要提供显示转换的方法,这样更完整。
HtmlElement builder = HtmlElement
.Create("ul")
.AddChild("li", "hello")
.AddChild("li", "world")
.Build();
Console.WriteLine(builder.ToString());
组合生成器(Composite Builder)
接下来用例子说明如何使用多个生成器来建造单个对象,假如我们要记录一个人的一些信息。
public class Person
{
public string StreetAddress, PostCode, City;
public string CompanyName, Position;
public int AnualIncome;
}
这个Person对象两类信息:住址和工作相关信息。是否需要两个生成器来分别构造住址和工作信息?怎样提供构造方法最合适?为了实现这一问题,我们新建了一个组合生成器PersonBuilder。
public class PersonBuilder
{
protected Person person;
public PersonBuilder()
{
person = new Person();
}
protected PersonBuilder(Person p)
{
person = p;
}
public PersonAddressBuilder Lives { get { return new PersonAddressBuilder(person); } }
public PersonJobBuilder Works { get { return new PersonJobBuilder(person); } }
public static implicit operator Person(PersonBuilder pb)
{
return pb.person;
}
}
这个PersonBuilder是一个组合生成器,他比之前的HtmlBuilder简单生成器要复杂一些。
- 在生成器中,person对象作为待建造的对象的引用,访问类型为protected,这个用来给子生成器来引用,需要注意的是这种方法只适合引用类型。
- Lives和Works是两个子生成器,分别用来构造住址和工作。
- operator Person跟之前一样,是一个隐式转换,用来将生成器PersonBuilder隐式转换为Person
还有个地方需要注意,在PersonBuilder只提供了一个默认无参构造函数,在函数中实例化了Person对象,另外一个构造器提供了一个protected的参数为Person的构造函数,这个构造函数不是给调用者使用的,而是给继承PersonBuilder的子生成器来使用的。
现在来看子生成器的实现。
public class PersonAddressBuilder : PersonBuilder
{
public PersonAddressBuilder(Person person) : base(person)
{
}
public PersonAddressBuilder At(string streetAddress)
{
person.StreetAddress = streetAddress;
return this;
}
public PersonAddressBuilder WithPostcode(string postCode)
{
person.PostCode = postCode;
return this;
}
public PersonAddressBuilder In(string city)
{
person.City = city;
return this;
}
}
PersonAddressBuilder提供了流式方法来建造Person的地址信息,需要注意该方法继承自PersonBuilder,意味着该方法也能访问Lives和Works对象的方法。在其构造函数中存储了已经建造好的对象引用,所以在使用这些子生成器时,实际上都是建造的同一个Person对象。
需要特别注意的是,在构造函数中,其调用了基类的构造函数。如果不调用,则子类就会默认调用基类的无参构造函数,从而自动创建不必要的Person对象实例。
PersonJobBuilder的实现方法类似,这里就不列出来了。
var pb = new PersonBuilder();
Person p = pb
.Lives
.At("Pudong District")
.In("Shanghai")
.WithPostcode("200003")
.Works
.At("ECNU")
.AsA("Students")
.Earning(123);
Console.WriteLine(builder.ToString());
在上述代码中,使用PersonBuilder的Lives生成器建造了住址相关信息,然后又使用Works生成器建造了工作相关信息,整个建造过程非常优雅。完全没有直接在Person里面的构造函数里传入非常多字段的ugly做法,下图为生成器的UML图,可以在Visual Studio 2019中查看。
上述这种做法有一个缺点,那就是PersonBuilder无法扩展,且必须依赖和知道PersonJobBuilder和PersonAddressBuilder的存在,因为这个两个子类是其字段,基类以来子类这个是Bad Smell。并且如果要增加一个新的Builder,比如PersonEarningBuilder,则需要修改PersonBuilder源码来添加字段,这就违反了OCP开闭原则。
生成器参数
在前面的例子中,唯一强迫(Coerce)用户使用生成器的方式是隐藏实体对象的构造器。在有些场景下,我们明确需要用户跟生成器进行交互。
比如下面这个例子,我们需要定义一个发送Email的程序。Email的实体如下:
public class Email
{
public string From, To, Subject, Body;
public override string ToString()
{
return $"{nameof(From)}:{From} {nameof(To)}:{To} {nameof(Subject)}:{Subject} {nameof(Body)}:{Body} ";
}
}
对应的EamilBuilder生成器如下:
public class EmailBuilder
{
private readonly Email email;
public EmailBuilder(Email e) => email = e;
public EmailBuilder From(string f)
{
email.From = f;
return this;
}
public EmailBuilder To(string to)
{
email.To = to;
return this;
}
public EmailBuilder Subject(string s)
{
email.Subject = s;
return this;
}
public EmailBuilder Body(string b)
{
email.Body = b;
return this;
}
}
我们不需要用户直接跟Email交互,所以,这里在Builder里,并没有提供返回Email的信息,其实返不返回无所谓,Email是引用对象,构造函数里传进去的是引用。
接下来为了强制在发送Email的时候,使用EamilBuilder,EmailService的定义如下:
public class EmailService
{
private void SendEmailInternal(Email email)
{
//TODO:
Console.WriteLine($"Eamil was sended{email.ToString()}");
}
public void SendEmail(Action<EmailBuilder> builder)
{
var email = new Email();
builder(new EmailBuilder(email));
SendEmailInternal(email);
}
}
可以看到,SendEmail方法的参数为Action函数(Function),这个函数的参数为EmailBuilder,而不是实体,这里通过函数来让用户使用EmailBuilder来建造Email。
var es = new EmailService();
es.SendEmail(x => x.From("foo@bar.com")
.To("bar@barz.com")
.Subject("Hello")
.Body("hello,how are you!"));
使用方法如上,可以看出,这里的API强制用户使用EmailBuilder生成器。
流式生成器的继承问题
在前面PersonBuilder示例中,其包含了两个子类PersonAddressBuilder和PersonJobBuilder,现在问题是如果我们想继承自PersonAddressBuilder新建一个PersonHomeAddressBuilder用来表示家庭地址(这里只是说明),当一个流式生成器(Fluent builder)继承自另一个流式生成器就可能出现问题,比如下面这个例子,我们要构建Person对象的个人信息:
public class Person
{
public string Name;
public string NickName;
public class Builder : PersonInfoBuilder
{
internal Builder() { }
}
public static Builder New => new Builder();
}
很自然,可以定义一个抽象的PersonBuilder:
public abstract class PersonBuilder
{
protected Person person = new Person();
public Person Build()
{
return person;
}
}
以及PersonInfoBuilder和PersonExtraInfoBuilder两个子生成器:
public class PersonInfoBuilder : PersonBuilder
{
public PersonInfoBuilder Called(string called)
{
person.Name = called;
return this;
}
}
public class PersonExtraInfoBuilder : PersonInfoBuilder
{
public PersonExtraInfoBuilder AlsoCalled(string nick)
{
person.NickName = nick;
return this;
}
}
现在我们希望使用流式生成器来初始化下面信息:
Person p2 = Person.New
.Called("zhangsan")
.AlsoCalled("er gou")
.Build();
发现编译不过,因为Called返回的是PersonInfoBuilder,里面根本没有AlsoCalled的方法,这里可以对比之前我们写的PersonBuilder,那里是将两个子类作为字段Lives,Works访问其他子生成器的,这里不行。
这里有个技巧,我们可以将PersonInfoBuilder定义为如下递归的泛型类型。
public class PersonInfoBuilder<SELF> : PersonBuilder where SELF : PersonInfoBuilder<SELF>
{
public SELF Called(string called)
{
person.Name = called;
return (SELF)this;
}
}
泛型类型SELF,继承自PersonInfoBuilder<SELF>,简而言之,该泛型类型SELF,必须继承自当前类自身。这看起来很奇怪,但却是是一种非常流行的CRTP (C++中的Curiously Recurring Template Pattern),特别的,当Foo<Bar>这个类型,只有在Foo继承自Bar时才能成立。
在流式建造中,最大的问题在于return this,他返回的是本身正在使用的类,即使当前使用的是基类的方法。只有通过泛型类型SELF,将类型扩散到外层。现在再来看PersonExtraInfoBuilder,
public class PersonExtraInfoBuilder<SELF> : PersonInfoBuilder<PersonExtraInfoBuilder<SELF>>
where SELF : PersonExtraInfoBuilder<SELF>
{
public SELF AlsoCalled(string nick)
{
person.NickName = nick;
return (SELF)this;
}
}
现在,假设我们有个PersonBirthdayBuilder,该如写呢?是写成PersonInfoBuilder<PersonExtraInfoBuilder<PersonBirthDateBuilder<SELF>>>吗?错了,应该写成如下,要一层一层的递归。
public class PersonBirthDayBuilder<SELF> : PersonExtraInfoBuilder<PersonBirthDayBuilder<SELF>>
where SELF : PersonBirthDayBuilder<SELF>
{
public SELF Birth(DateTime d)
{
person.DateOfBirth = d;
return (SELF)this;
}
}
现在,Person如何获得一个生成器呢,这里先写个内部类Builder,他继承自最叶子子类PersonBirthDayBuilder,代码如下:
public class Person
{
public string Name;
public string NickName;
public DateTime DateOfBirth;
public class Builder : PersonBirthDayBuilder<Builder>
{
internal Builder() { }
}
public static Builder New => new Builder();
}
现在Person的建造方法如下,现在不会报错了,能正常运行了。
Person p = Person.New
.Called("zhangsan")
.AlsoCalled("er gou")
.Birth(DateTime.Now)
.Build();
总结
Builder模式最大的用处就是将对象的建造与他自身分离,将对象创建过程委托给外部类,让这些类来构造整个复杂的对象,或者一系列对象。建造者模式有以下特点:
- 生成器模式通常使用流式接口,使得能通过单行语句链式构建复杂对象,这是通过在构建方法中返回this实现的
- 为了强迫用户使用生成器,我们通常隐藏实体的构造函数,然后定义静态的Create、Make、New等方法来返回生成器
- 生成器可以强制对象隐式转换为实体对象本身
- 可以用户将生成器作为函数传递给相关函数。
- 单个生成器可以将对象共享给多个子生成器,通过使用基类和链式方法,能够很方便从一个生成器转到另外一个生成器。
- 通过递归泛型,可以实现流式生成器的继承。
需要注意的是生成器的应用场景,如果一个对象非常简单,只需要简单的参数,或者对象就能实例化,那么就没必要使用生成器模式了。不能为了使用设计模式而生搬硬套,要根据场景使用。
参考