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等方法来返回生成器
  • 生成器可以强制对象隐式转换为实体对象本身
  • 可以用户将生成器作为函数传递给相关函数。
  • 单个生成器可以将对象共享给多个子生成器,通过使用基类和链式方法,能够很方便从一个生成器转到另外一个生成器。
  • 通过递归泛型,可以实现流式生成器的继承。

   需要注意的是生成器的应用场景,如果一个对象非常简单,只需要简单的参数,或者对象就能实例化,那么就没必要使用生成器模式了。不能为了使用设计模式而生搬硬套,要根据场景使用。

 

参考