Статьи

Неудачный эксперимент: улучшение шаблона Builder

Иногда вы хотите попробовать что-то новое. Как и на прошлой неделе, когда я хотел реализовать конструктор для объекта домена. В Java, чтобы вы не удивились, почему примеры кода в этом посте такие длинные.

Обоснование необходимости создания компоновщиков для доменных объектов выглядит примерно так. Ты хочешь:

  • доменные объекты, которые никогда не находятся в несовместимом состоянии
  • неизменяемые доменные объекты, предпочтительно
  • чтобы избежать  «телескопирования» конструкторов,  принимающих все комбинации необязательных полей на вашем объекте заранее
  • хороший (свободный) API для создания ваших доменных объектов

Конечно, вы можете просто использовать классы case в Scala   с именованными параметрами и называть это днем. Увы, такого дня не было.

На плечах гигантов

Очевидно, что модель строителя была любима многими, кто  больше меня . На самом деле, оригинальное   описание « Банды четырех» датируется 1995 годом.

Но здесь мы находимся в 2013 году. Допустим, мы хотим, чтобы доменные объекты моделировали заказы пиццы. Обычное решение выглядит примерно так, используя статический внутренний класс для реализации компоновщика:

public class PizzaOrderOldStyle {




private int size;
private boolean pepperoni;
private boolean chicken;
private boolean mushroom;
private boolean peppers;
private String cheese;
private String sauce;
private String orderFor;




public static Builder pizzaOrder(int size, String sauce, String orderFor) 
{
return new PizzaOrderOldStyle.Builder(size, sauce, orderFor);
} 




public static class Builder {




private int size;
private boolean pepperoni;
private boolean chicken;
private boolean mushroom;
private boolean peppers;
String cheese;
private String sauce;
private String orderFor;




public Builder(int size, String sauce, String orderFor) {
this.size = size;
this.sauce = sauce;
this.orderFor = orderFor;
}




public Builder withPepperoni() {
this.pepperoni = true;
return this;
}




public Builder withChicken() {
this.chicken = true;
return this;
}




public Builder withMushroom() {
this.mushroom = true;
return this;
}




public Builder withPeppers() {
this.peppers = true;
return this;
}




public Builder withCheese(String cheese) {
this.cheese = cheese;
return this;
}




public PizzaOrderOldStyle build() {
return new PizzaOrderOldStyle(this);
}
}




private PizzaOrderOldStyle(Builder builder) {
size = builder.size;
pepperoni = builder.pepperoni;
chicken = builder.chicken;
mushroom = builder.mushroom;
peppers = builder.peppers;
cheese = builder.cheese;
sauce = builder.sauce;
orderFor = builder.orderFor;
}




// Omitted getters
// Omitted equals/hashCode
}

Это довольно просто. Конструктор для  PizzaOrderOldStyle является частным и принимает экземпляр компоновщика. В этом конструкторе поля объекта домена инициализируются значениями из компоновщика. Только  Builder может быть создан пользователем API, и он принимает необязательные значения напрямую. В  with*() методах на строитель разоблачить  Fluent API  путем возврата  this. Так как у получающегося объекта домена нет сеттеров, он фактически неизменен после того, как возвращается из  build().

Создание объекта домена теперь так же просто, как:

pizzaOrder(10, "tomato", "Sander")
.withPepperoni()
.withMushroom()
.withCheese("parmesan").build();

(Я добавил статический удобный метод  pizzaOrder для создания экземпляра компоновщика)

Проблемы

Хотя приведенное выше решение кажется разумным, оно содержит некоторые недостатки. Наиболее очевидным является то, что этот шаблон просто переносит изменчивость в  Builder класс. Если вы не разделяете компоновщиков между потоками (что кажется разумным), мы можем с этим смириться. Больше всего меня беспокоит то, что у нас есть три (!) Места, где перечислены все свойства доменной модели. Во-первых, определения полей повторяются внутри  Builder класса. Во-вторых, приватный конструктор копирует каждое из свойств модели домена. Слишком много мест, чтобы облажаться. На самом деле, я только через модульный тест обнаружил, что забыл скопировать  cheese свойство из компоновщика в объект домена.

Конечно, есть несколько  плагинов IDE  или других форм генераторов кода, которые могут автоматизировать генерацию сборщика. Генерация кода, однако, открывает совершенно другую банку червей.

Можем ли мы сделать лучше?

Слабая попытка

Видя эту проблему, я задумался. Что если застройщик — это просто фасад поверх доменного объекта? Он может использовать поля объекта домена в качестве «промежуточного» хранилища, не раскрывая объект домена в целом до его готовности. Java позволяет нестатическим внутренним классам связываться с закрытым состоянием внешнего объекта. Так что это было хорошей отправной точкой:

public class PizzaOrder {




private int size;
private boolean pepperoni;
private boolean chicken;
private boolean mushroom;
private boolean peppers;
private String cheese;
private String sauce;
private String orderFor;




private PizzaOrder() {
// Prevent direct instantiation
}




public static Builder pizzaOrder(int size, String sauce, String orderFor) 
{
return new PizzaOrder().new Builder(size, sauce, orderFor);
}




public class Builder {
private AtomicBoolean build = new AtomicBoolean(false);




public Builder(int _size, String _sauce, String _orderFor) {
size = _size;
sauce = _sauce;
orderFor = _orderFor;
}




public Builder withPepperoni() {
throwIfBuild();
pepperoni = true;
return this;
}




public Builder withChicken() {
throwIfBuild();
chicken = true;
return this;
}




public Builder withMushroom() {
throwIfBuild();
mushroom = true;
return this;
}




public Builder withPeppers() {
throwIfBuild();
peppers = true;
return this;
}




public Builder withCheese(String _cheese) {
throwIfBuild();
cheese = _cheese;
return this;
}




public PizzaOrder build() {
if (build.compareAndSet(false, true)) {
// check consistency here... 
return PizzaOrder.this;
} else {
throw new IllegalStateException("Build may only be called once!");
}
}
private void throwIfBuild() {
if (build.get()) {
throw new IllegalStateException("Cannot modify builder after calling build()");
}
}
}




// Omitted getters
// Omitted equals/hashCode 
}

Интересно, что представленный API идентичен, так как мы снова предлагаем статическую удобную функцию, pizzaOrder скрывающую слегка прикольную вещь  new PizzaOrder().new Builder(..) , необходимую для создания экземпляра компоновщика:

pizzaOrder(10, "basil", "Fred")
.withPepperoni()
.withMushroom()
.withCheese("mozzarella").build();

Хотя с синтаксической точки зрения API не изменился, с точки зрения использования существуют различия. После вызова к  build() любому другому вызову застройщик приводит к исключению. Это должно быть так, поскольку лежащие в основе поля являются фактическими полями объекта домена. Мы не хотим, чтобы они изменились после завершения строительства. Я использовал,  AtomicBoolean чтобы «запечатать» конструктор и базовый объект домена. Сравните это с оригинальным подходом, где вы можете создавать столько раз, сколько хотите, с одним  Builder экземпляром. Является ли это хорошо или плохо спорно. На практике это не имеет большого значения, так как вы, как правило, используете конструктор только один раз.

Отказ строителя?

So why do I call this a failed experiment? First of all, I expected this solution to be more concise than the original. It isn’t. Check the linecounts in this gist. Indeed, the fields are not enumerated three times. On the other hand, we have to add bookkeeping to manage the state of the builder façade to each with*() method. It’s easy to forget the call to throwIfBuild and if you do this threatens the immutability of the domain object.

Second, non-nested inner classes keep an implicit reference to their outer containing objects. This means that the builder object itself may prevent the domain object from being garbage-collected. Retaining a reference to the builder in the original pattern doesn’t have this problem, since inner static classes are instantiated without an outer instance and hence don’t point to an outer instance.

Pattern failure?

So the result is not groundbreaking. Still, it’s a nice variation on the builder pattern. Here’s thegist containing this post’s code if you want to play around with it. More than anything, it reminds us that design patterns only serve to point out weaknesses in languages. Creating and maintaining builders for every domain object is just too much hassle. Languages like Scala andC# are much better equipped in this regard.

One improvement I’ve been thinking of is to use a nested empty instance of the domain class in the builder to store the data. We can gradually build up the object by modifying its private fields until we return it from build(). In this variation the builder can be made static again. In fact, while writing this I decided to implement this variation and it looks like the cleanest approach so far.

Leave a comment if you see other improvements that I missed!