Строитель (Builder)

Строитель (Builder)

Дмитрий Бахтенков

Строитель

Строитель - это порождающий паттерн проектирования, позволяющий пошагово создавать сложные объекты. Этот паттерн стоит использовать, когда необходимо получать объекты с различными вариациями полей.

Проблема

Рассмотрим несколько вариантов решения проблемы - создания сущности с разными вариациями наборов полей.

Перегрузка конструкторов

Механизм перегрузки конструкторов позволяет создать несколько разных вариаций конструкторов с разными наборами параметров. Например:

public class Product
    {
        // наборы свойств для использования
        public string Property1 { get; set; }
        public string Property2 { get; set; }
        public string Property3 { get; set; }
        public string Property4 { get; set; }
        public string Property5 { get; set; }
        
        // конструктор для первого случая

        public Product(string property1, string property2, string property3)
        {
            Property1 = property1;
            Property2 = property2;
            Property3 = property3;
        }

        // конструктор для второго случая
        public Product(string property4, string property5)
        {
            Property4 = property4;
            Property5 = property5;
        }

        // конструктор для третьего случая
        public Product(string property1, string property2, string property3, string property4, string property5)
        {
            Property1 = property1;
            Property2 = property2;
            Property3 = property3;
            Property4 = property4;
            Property5 = property5;
        }
    }

Однако этот способ имеет несколько недостатков:

  1. Отсутствие гибкости - для нового набора полей, который не был предусмотрен изначально, нужно будет делать новый конструктор
  2. Отсутствие удобства - разработчику при создании экземпляра класса Product придётся подбирать нужный конструктор - через подсказки IDE или же лезть в реализацию класса. Это не очень удобно

Необязательные параметры в конструкторе

Если много конструкторов - плохо, то почему бы не создать один конструктор с набором необязательных параметров?

Попробуем:

public class Product
{
    // наборы свойств для использования
    public string Property1 { get; set; }
    public string Property2 { get; set; }
    public string Property3 { get; set; }
    public string Property4 { get; set; }
    public string Property5 { get; set; }

    public Product(string property1 = null,
        string property2 = null,
        string property3 = null,
        string property4 = null,
        string property5 = null)
    {
        Property1 = property1;
        Property2 = property2;
        Property3 = property3;
        Property4 = property4;
        Property5 = property5;
    }
}

В таком случае, разные наборы полей можно инициализировать с помощью соответствующих параметров конструктора, например:

var p = new Product(property1 = "1", property3 = "3");

Этот способ также имеет недостаток: очень много параметров у одного конструктора. Представьте, насколько ухудшится читабельность при десятке таких параметров!

Реализация паттерна "Строитель"

Существует две реализации данного паттерна - классическа и "Fluent Builder". Обе реализации являются достаточно простыми.

В каждой реализации есть:

  • Класс, который мы будем строить - назовём его Product
  • Класс-строитель, пусть будет Builder
  • Также может присутствовать класс, который управляет процессом создания - назовём его Director. Он используется, если нам необходимо установить конкретный набор и порядок полей класса Product

Для большей гибкости можно также создать интерфейс или абстрактный класс и сделать несколько реализаций строителя.

Классическая реализация

Концепция

На диаграмме UML классическая реализация выглядит следующим образом:

  • Класс Product - это класс, который мы будем строить
public class Product
{
    public string Property1 { get; set; }
    public string Property2 { get; set; }
    public string Property3 { get; set; }
    public string Property4 { get; set; }
    public string Property5 { get; set; }
}
  • Абстрактный класс Builder описывает методы, с помощью которых можно построить продукт, но не реализовывает их:
public abstract class Builder
{
    // объект, который мы будем строить
    protected Product product;

    protected Builder()
    {
        // инициализируем объект в конструкторе
        product = new Product();
    }

    // методы для построения разных частей объекта
    public abstract void BuildProp1();
    public abstract void BuildProp2();
    public abstract void BuildProp3();
    public abstract void BuildProp4();
    public abstract void BuildProp5();

    // метод получения построенного продукта
    public Product GetProduct()
    {
        return product;
    }
}
  • Класс ConcreteBuilder унаследован от Builder и реализует его методы:
public class ConcreteBuilder : Builder
{
    public override void BuildProp1()
    {
        product.Property1 = "property 1 value";
    }

    public override void BuildProp2()
    {
        product.Property2 = "property 2 value";
    }

    public override void BuildProp3()
    {
        product.Property3 = "property 3 value";
    }

    public override void BuildProp4()
    {
        product.Property4 = "property 4 value";
    }

    public override void BuildProp5()
    {
        product.Property5 = "property 5 value";
    }
}
  • Класс Director с помощью метода Construct() создаёт и использует экземпляр продукта в правильном порядке:
public class Director
{
    private readonly Builder _builder;

    public Director(Builder builder)
    {
        _builder = builder;
    }

    public void Construct()
    {
        // выполняем различные этапы построения
        _builder.BuildProp1();
        _builder.BuildProp3();
        _builder.BuildProp5();

        var product = _builder.GetProduct();
        Console.WriteLine($"Продукт построен. Свойство 1: {product.Property1}, Свойство 3: {product.Property3}, Свойство 5: {product.Property5}");
    }
}

Попробуем использовать класс Director в методе Main:

static void Main(string[] args)
{
    var director = new Director(new ConcreteBuilder());
    director.Construct();
}

И запустим программу:

Разные классы могут по разному использовать Builder. Подобных Director классов может быть много.

Пример

Разработчикам часто приходится отправлять различные http-запросы, с разными методами, заголовками, телом и т.д. Для возможности динамического создания различных запросов может использоваться данный паттерн.

Рассмотрим UML-диаграмму:

Класс HttpRequest будет описывать запрос, который можно отправить. У него есть заголовки, тело и т.д.

public class HttpRequest
{
    // метод запроса
    public HttpMethod Method { get; set; }
    // url запроса
    public string Url { get; set; }
    // данные из формы
    public Dictionary<string, string> FormData { get; set; } = new();
    // данные application/json
    public string JsonData { get; set; }
    // заголовки запроса
    public Dictionary<string, string> Headers { get; set; } = new();
    // строковое представление параметров запроса
    public string Query { get; set; }
}

Абстрактный класс HttpRequestBuilder описывает возможности для построения запроса:

public abstract class HttpRequestBuilder
{
    protected readonly HttpRequest HttpRequest;

    public HttpRequestBuilder()
    {
        HttpRequest = new HttpRequest();
    }

    public abstract void SetMethod();
    public abstract void SetUrl(string url);
    // метод для установки заголовка авторизации
    public abstract void SetAuth(string auth);
    public abstract void SetForm(Dictionary<string, string> data);
    public abstract void SetJson(object data);
    public abstract void SetQuery(string query);
    public abstract void AddHeader(string key, string value);
    public abstract void SetHeaders(Dictionary<string, string> headers);

    public HttpRequest Build()
    {
        return HttpRequest;
    }

Конкретные классы GetHttpRequestBuilder и PostHttpRequestBuilder реализуют абстрактные методы, учитывая особенности методов get и post:

public class GetHttpRequestBuilder : HttpRequestBuilder
{
    public override void SetMethod()
    {
        HttpRequest.Method = HttpMethod.Get;
    }

    public override void SetUrl(string url)
    {
        HttpRequest.Url = url;
    }

    public override void SetAuth(string auth)
    {
        HttpRequest.Headers["Authorization"] = auth;
    }

    public override void SetForm(Dictionary<string, string> data)
    {
        // кидаем исключение, так как у get-запросов не может быть тела
        throw new Exception("У get-запроса не может быть тела");
    }

    public override void SetJson(object data)
    {
        // кидаем исключение, так как у get-запросов не может быть тела
        throw new Exception("У get-запроса не может быть тела");
    }

    public override void SetQuery(string query)
    {
        HttpRequest.Query = query;
    }

    public override void AddHeader(string key, string value)
    {
        HttpRequest.Headers[key] = value;
    }

    public override void SetHeaders(Dictionary<string, string> headers)
    {
        HttpRequest.Headers = headers;
    }
}
public class PostHttpRequestBuilder : HttpRequestBuilder
{
    public override void SetMethod()
    {
        HttpRequest.Method = HttpMethod.Post;
    }

    public override void SetUrl(string url)
    {
        HttpRequest.Url = url;
    }

    public override void SetAuth(string auth)
    {
        HttpRequest.Headers["Authorization"] = auth;
    }

    public override void SetForm(Dictionary<string, string> data)
    {
        HttpRequest.FormData = data;
    }

    public override void SetJson(object data)
    {
        HttpRequest.JsonData = JsonSerializer.Serialize(data);
    }

    public override void SetQuery(string query)
    {
        HttpRequest.Query = query;
    }

    public override void AddHeader(string key, string value)
    {
        HttpRequest.Headers[key] = value;
    }

    public override void SetHeaders(Dictionary<string, string> headers)
    {
        HttpRequest.Headers = headers;
    }
}

Класс HttpClient использует вариации строителей, чтобы создать запрос и отправить его :

public class HttpClient
{
    public void SendGet(string url, Dictionary<string, string> headers = null, string query = null)
    {
        var builder = new GetHttpRequestBuilder();
        builder.SetMethod();
        builder.SetUrl(url);

        // если строка query не пуста
        if (!string.IsNullOrEmpty(query))
        {
            builder.SetQuery(query);
        }

        // если заголовки переданы
        if (headers is not null)
        {
            builder.SetHeaders(headers);
        }

        var request = builder.Build();

        // имитируем отправку http-запроса
        Console.WriteLine($"Запрос отправлен. Метод: {request.Method}. Url: {request.Url + "?" + request.Query}");
    }

    public void SendJsonPost(string url, Dictionary<string, string> headers = null, object data = null)
    {
        var builder = new PostHttpRequestBuilder();
        builder.SetMethod();
        builder.SetUrl(url);

        // если data не пуста
        if (data is not null)
        {
            builder.SetJson(data);
        }

        // если заголовки переданы
        if (headers is not null)
        {
            builder.SetHeaders(headers);
        }

        var request = builder.Build();

        // имитируем отправку http-запроса
        Console.WriteLine($"Запрос отправлен. Метод: {request.Method}. Url: {request.Url}. Data: {request.JsonData}");
    }
}
ВАЖНО! В C# есть встроенные реализации HttpRequestMessage и HttpClient. Этот код создан только для примера.
Примечательно, что класс StringBuilder в .NET также является примером паттерна "Строитель". С помощью методов AppendLine(), AppendJoin() и др. мы как бы "строим" строку, а вызов метода ToString() возвращает нам готовый результат.

Fluent Builder

Данная реализация паттерна очень похожа на классическую, но она позволяет "строить" различные объекты с помощью цепочек вызовов, а не простых методов, например -

var product = new Builder().WithProp1().WithProp2()...

Концепция:

Рассмотрим диаграмму UML реализации Fluent Builder:

Также, как и в классической реализации, на диаграмме присутствует класс Product, описывающий создаваемую сущность:

public class Product
{
    public string Property1 { get; set; }
    public string Property2 { get; set; }
    public string Property3 { get; set; }
    public string Property4 { get; set; }
    public string Property5 { get; set; }
}

Однако обратите внимание, что все методы строителя возвращают... объект строителя (в данном случае объект самого себя). В коде это пишется следующим образом: return this;

public class FluentBuilder
{
    private readonly Product _product;

    public FluentBuilder()
    {
        _product = new Product();
    }

    public FluentBuilder WithProp1(string value)
    {
        _product.Property1 = value;
        return this;
    }

    public FluentBuilder WithProp2(string value)
    {
        _product.Property2 = value;
        return this;
    }

    public FluentBuilder WithProp3(string value)
    {
        _product.Property3 = value;
        return this;
    }

    public FluentBuilder WithProp4(string value)
    {
        _product.Property4 = value;
        return this;
    }

    public FluentBuilder WithProp5(string value)
    {
        _product.Property5 = value;
        return this;
    }

    public Product Build()
    {
        return _product;
    }
}

Такой способ позволяет классу Director, использующему строитель, делать красивые цепочки вызовов:

public class Director
{
    private readonly FluentBuilder _builder;

    public Director(FluentBuilder builder)
    {
        _builder = builder;
    }

    public void Construct()
    {
        // "строим" объект с помощью цепочки вызовов
        var product = _builder
            .WithProp1("Prop 1")
            .WithProp3("Prop 3")
            .WithProp5("Prop 5")
            .Build();

        Console.WriteLine($"Продукт построен. Свойство 1: {product.Property1}, Свойство 3: {product.Property3}, Свойство 5: {product.Property5}");
    }
}

Используем это в методе Main:

static void Main(string[] args)
{
    var director = new Director(new FluentBuilder());
    director.Construct();
}

Вывод программы:

Пример:

Представим, что мы разработчики новой игры, где есть различные игровые объекты - элементы окружения. Этим игровым объектам можно задавать различные события (касание предмета, уничтожение предмета), название, координаты и другие данные. Также у нас будет локация, которую этими объектами мы будем заполнять. У объектов могут иметься разные наборы свойств, и паттерн "Строитель" отлично подойдёт для решения этой проблемы.

На UML-диаграмме наш модуль игровых объектов будет выглядеть следующим образом:

Рассмотрим каждый класс.

GameObject описывает игровой объект с помощью большого набора различных свойств:

public class GameObject
{
    // можно ли взаимодействовать с элементом
    public bool IsInteractive { get; set; }
    // двигается элемент
    public bool IsMovable { get; set; }
    // событие при движении элемента
    public Action Move { get; set; }
    // видимость элемента
    public bool IsVisible { get; set; }
    // название элемента
    public string Name { get; set; }
    // событие при касании с элементом
    public Action TouchEvent { get; set; }
    // событие уничтожения элемента
    public Action DestroyEvent { get; set; }
    // расположение элемента по X
    public double CoordX { get; set; }
    // расположение элемента по Y
    public double CoordY { get; set; }
    // расположение элемента по Z
    public double CoordZ { get; set; }
}

Класс GameObjectBuilder задаёт методы для заполнения полей игрового объекта:

public class GameObjectBuilder
{
    private GameObject _gameObject;

    public GameObjectBuilder()
    {
        _gameObject = new GameObject();
    }

    public GameObjectBuilder IsInteractive()
    {
        _gameObject.IsInteractive = true;
        return this;
    }

    public GameObjectBuilder IsVisible()
    {
        _gameObject.IsVisible = true;
        return this;
    }

    public GameObjectBuilder SetName(string name)
    {
        _gameObject.Name = name;
        return this;
    }

    public GameObjectBuilder SetLocation(double x, double y, double z)
    {
        _gameObject.CoordX = x;
        _gameObject.CoordY = y;
        _gameObject.CoordZ = z;
        return this;
    }

    public GameObjectBuilder SetMove(Action action)
    {
        _gameObject.IsMovable = true;
        _gameObject.Move += action;
        return this;
    }

    public GameObjectBuilder AddTouchEvent(Action action)
    {
        _gameObject.TouchEvent += action;
        return this;
    }

    public GameObjectBuilder AddDestroyEvent(Action action)
    {
        _gameObject.DestroyEvent += action;
        return this;
    }

    public GameObject Build()
    {
        return _gameObject;
    }
}

В классе Place, в методе Construct мы применяем GameObjectBuilder для создания элементов окружения:

public class Place
{
    public void Construct()
    {
        // строим игровой объект "Кролик"
        var bunny = new GameObjectBuilder()
            .SetName("Кролик")
            .IsInteractive()
            .IsVisible()
            .SetLocation(10, 10, 10)
            .SetMove(() => Console.WriteLine("Кролик прыгает"))
            .AddDestroyEvent(() => Console.WriteLine("Кролик умер"))
            .AddTouchEvent(() => Console.WriteLine("Кролик озлобленно огрызнулся"))
            .Build();

        // строим игровой объект "чай"
        var tea = new GameObjectBuilder()
            .IsInteractive()
            .IsVisible()
            .SetLocation(110, 112, 111)
            .SetName("TESS (не реклама)")
            .AddDestroyEvent(() => Console.WriteLine("Чай выпит"))
            .AddTouchEvent(() => Console.WriteLine("Чай подобран"))
            .Build();

        Console.WriteLine("Локация загружена");
        Console.WriteLine($"Кролик расположен в координатах {bunny.CoordX} {bunny.CoordY} {bunny.CoordZ}");
        bunny.Move?.Invoke();
        bunny.TouchEvent?.Invoke();

        Console.WriteLine($"Чай расположен в координатах {tea.CoordX} {tea.CoordY} {tea.CoordZ}");
        tea.DestroyEvent?.Invoke();
    }
}

Попробуем создать нашу локацию в методе Main:

static void Main(string[] args)
{
    var place = new Place();
    place.Construct();
}

Вывод программы:

ВАЖНО: Автор статьи не является разработчиком игр. Данный пример был придуман для того чтобы показать вам этот паттерн, но автор уверен, что этот пример не является лучшим в разработке игр.
В .NET есть язык запросов к данным под названием LINQ, Методы расширения LINQ являются отличным примером паттерна Fluent Builder

Вывод:

Мы научились использовать паттерн Builder для создания разных вариаций крупных объектов. Этот паттерн имеет следующие преимущества:

  • Возможность внедрения дополнительной логики в методы установки значений
  • Разделение данных и их создания
  • Отсутствие больших конструкторов и использование методов строителя повышает читаемость кода

По наблюдениям автора, чаще всего используется реализация Fluent Builder за счёт своей гибкости и читаемости.

Исходный код доступен на гитхабе.

Спасибо за внимание! С вами был Flex Code

Report Page