Что будет, если возвращать в setter-методе не void, а this, т.е. использовать прием Fluent Interface?
Теперь перепишем типовой кусок кода и получим такое:
Для сравнения – как тот же код выглядит без использования приёма:
Как видно, в новом способе у нас меньше кода, мы не объявляем и не ссылаемся на локальные переменные, в целом когнитивная нагрузка ниже, а смысл — тот же. В коде появилась иерархичность, которая отражает вложенность объектов, поля которых мы заполняем. Визуально это тоже хорошо видно, если, например, мы собираем JSON-сериализуемый объект, что является очевидным плюсом при разработке REST API:
Программисты, знакомые с Lombok, могут резонно спросить — а почему бы не взять вместо этого обычный Builder? Конечно, можно. Тот же код будет выглядеть почти так же, но с большим количеством вызовов builder() и build():
В целом использование Builder может быть оправданно для immutable-объектов как альтернатива передачи большого множества параметров в конструктор, но для mutable POJO в этом нет особого смысла. Кроме того, совместимость Builder-подхода сильно ограничена по сравнению с fluent setter в ряде фреймворков и библиотек.
Как оказалось, целый ряд современных фреймворков поддерживает такие методы доступа. Ниже привожу известные мне, с которыми лично работал, т.е. список может быть далеко не полным.
Переделать @Data-класс на использование такого подхода очень просто.
Для одного класса — добавляем аннотацию @Accessors(chain = true):
Для пакета/модуля/проекта — добавляем один параметр в lombok.config:
Начиная с прошлого года Lombok plugin поставляется с IDEA по умолчанию, т.е. курируется самой JetBrains. Плагин прекрасно распознает подобные конфигурации (как через аннотации, так и через конфиги), анализ кода работает корректно:
Jackson, библиотека для сериализации JSON, XML и не только, не требует дополнительных настроек для работы с бинами, сеттеры которых возвращают this. Сериализация и десериализация работает без каких-либо проблем.
jOOQ, фреймворк, который генерирует классы модели из схемы базы данных, может генерировать классы с fluent setter'ами. Требуется явное выставление параметра конфигурации, т.к. это генерация, а не анализ.
Hibernate, фреймворк доступа к базам данных, позволяет использовать классы с предложенной нотацией как Entity. Не требуется дополнительная настройка.
Spring и Spring Boot (а также производные проекты вроде spring-data, spring-data-rest) содержат тонны логики, построенной на reflection и анализе структур классов. Здесь и классы конфигурации, и разбор ResultSet'ов через BeanPropertyRowMapper, и прочее. Основной класс, который отвечает за анализ свойств бинов в spring — BeanUtils, не требует для setter-методов return this. Т.е. всё работает, что называется, из коробки.
Mapstruct, библиотека для авто-генерации классов конвертации из одного класса в другой, делает анализ соответствующих структур классов. Не требует дополнительных настроек, распознает setter методы, возвращающие this.
Разработчики языка Kotlin решили все те проблемы, которые пытается разрешить предложенный подход в Java. Там есть и data-классы, и именованная передача множества параметров в конструкторы и в методы. Но, кроме этого, в Kotlin есть синтаксический сахар для доступа к свойствам объектов, которые при обращении (как чтении, так и записи) фактически являются вызовами соответствующих getter и setter-методов. Kotlin лояльно относится к setter-методам, которые возвращают this вместо void (сиреневый курсив):
Т.е. мы в Kotlin коде можем использовать вызовы setter'ов в Java-моделях как присваивания свойств. Слава Kotlin!
Есть спека JavaBeans, которая явным образом нигде не говорит, что setter-методы должны возвращать именно void, хотя все примеры, конечно, написаны именно так.
Но, на самом деле, далеко не все так радужно.
Основной класс в JDK, который отвечает за анализ структуры Java Beans — java.beans.Introspector, не распознает setter-методы, если они возвращают не void.
Выше упомянутый java.beans.Introspector активно используется в Swing и элементах Java EE. Например, если класс JSP тега пытается возвращать this в своих методах, – это вызовет ошибку в Runtime. И это, пожалуй, самая большая проблема с этим подходом — подобный баг не предотвратит ни компиляция, ни юнит-тест, разве что если это хороший интеграционный или e2e-тест.
Из тихих ошибок также могут быть проблемы, если вы используете apache common-beanutils, библиотеку работы с бинами (например, копирование свойств из одного бина в другой). По умолчанию, копирование свойств бина просто игнорирует свойства с setter'ами возвращающими this, но есть класс расширения FluentPropertyBeanIntrospector, который добавляет такой анализ. Засада в том, что в этом классе есть баг, к которому предложены два решения: 1, 2. Но даже после попытки обсуждения этой темы в списках рассылки этот вопрос так и остается открытым. Сам проект в его нынешнем виде похож на заброшенный даже несмотря на его относительно высокую популярность.
Я использую этот подход в течение нескольких последних лет работы. Преимущественно это были REST-API, ведь они полны классов моделей данных и DTO-шек, а именно в таких классах сильнее всего чувствуется эффект от приема. Если проект на этапе разработки – тоже хорошо, меньше шанс сломать то, что работало правильно. Фреймворки вроде Spring Boot — отлично, но если вы работаете на традиционном стеке Java EE / swing, скорее всего это вам не подойдет.
Если нет хороших интеграционных тестов — очень осторожно. В случае Lombok есть гибкость настройки — можно начать с одного класса, пакета или, к примеру, нового модуля. Можно поступить и наоборот — определить режим chained-setter'ов на уровень всего проекта, а для конкретного пакета (типа JSP-тегов, которые не дружат с концепцией) – отключить:
В любом случае в существующем проекте лучше не делать это одним махом, а действовать итеративно.
В случае, если мы наследуем setter'ы из базового класса, возникает проблема.
Мы теперь не можем написать так:
Здесь есть 4 возможных решения:
Возможно, слишком смело назвать использование fluent setter'ов новым трендом. Но очевидно формируется лагерь технологий, которые идут вразрез с традиционной конвенцией. Я свой выбор сделал. И не случалось еще такого, чтобы после внедрения подхода он выпиливался назад.
Примеры кода
public class SimplePojo {
private String value;
public String getValue() {
return value;
}
public SimplePojo setValue(String value) {
this.value = value;
return this;
}
// equals, hashCode, toString
}
Теперь перепишем типовой кусок кода и получим такое:
private static AssignmentGroupedActivitiesResource create() {
return new AssignmentGroupedActivitiesResource()
.setGrouping(new UserActivitiesGroupingResource()
.setAlignmentScore(1)
.setFocusScore(0)
.setAdvancedGroups(Arrays.asList(
new ProductivityGroupResource()
.setSectionName("Development")
.setColor("#2196f3")
.setSpentTime(5L),
new ProductivityGroupResource()
.setSectionName("Chat")
.setColor("#E502FA")
.setSpentTime(1L)
))
.setPeriodLong(10L)
.setTotalTrackedTime(7L)
.setIntensityScore(2));
}
Для сравнения – как тот же код выглядит без использования приёма:
private static AssignmentGroupedActivitiesResource create() {
ProductivityGroupResource group1 = new ProductivityGroupResource();
group1.setSectionName("Development");
group1.setColor("#2196f3");
group1.setSpentTime(5L);
ProductivityGroupResource group2 = new ProductivityGroupResource();
group2.setSectionName("Chat");
group2.setColor("#E502FA");
group2.setSpentTime(1L);
UserActivitiesGroupingResource grouping = new UserActivitiesGroupingResource();
grouping.setAlignmentScore(1);
grouping.setFocusScore(0);
grouping.setAdvancedGroups(Arrays.asList(group1, group2));
grouping.setPeriodLong(10L);
grouping.setTotalTrackedTime(7L);
grouping.setIntensityScore(2);
AssignmentGroupedActivitiesResource assignmentGroupedActivities = new AssignmentGroupedActivitiesResource();
assignmentGroupedActivities.setGrouping(grouping);
return assignmentGroupedActivities;
}
Как видно, в новом способе у нас меньше кода, мы не объявляем и не ссылаемся на локальные переменные, в целом когнитивная нагрузка ниже, а смысл — тот же. В коде появилась иерархичность, которая отражает вложенность объектов, поля которых мы заполняем. Визуально это тоже хорошо видно, если, например, мы собираем JSON-сериализуемый объект, что является очевидным плюсом при разработке REST API:
А почему не Lombok Builder?
Программисты, знакомые с Lombok, могут резонно спросить — а почему бы не взять вместо этого обычный Builder? Конечно, можно. Тот же код будет выглядеть почти так же, но с большим количеством вызовов builder() и build():
private static AssignmentGroupedActivitiesResource create() {
return AssignmentGroupedActivitiesResource.builder()
.grouping(UserActivitiesGroupingResource.builder()
.alignmentScore(1)
.focusScore(0)
.advancedGroups(Arrays.asList(
ProductivityGroupResource.builder()
.sectionName("Development")
.color("#2196f3")
.spentTime(5L)
.build(),
ProductivityGroupResource.builder()
.sectionName("Chat")
.color("#E502FA")
.spentTime(1L)
.build()
))
.periodLong(10L)
.totalTrackedTime(7L)
.intensityScore(2)
.build())
.build();
}
В целом использование Builder может быть оправданно для immutable-объектов как альтернатива передачи большого множества параметров в конструктор, но для mutable POJO в этом нет особого смысла. Кроме того, совместимость Builder-подхода сильно ограничена по сравнению с fluent setter в ряде фреймворков и библиотек.
Кто поддерживает?
Как оказалось, целый ряд современных фреймворков поддерживает такие методы доступа. Ниже привожу известные мне, с которыми лично работал, т.е. список может быть далеко не полным.
Lombok
Переделать @Data-класс на использование такого подхода очень просто.
Для одного класса — добавляем аннотацию @Accessors(chain = true):
import lombok.Data;
import lombok.experimental.Accessors;
@Data
@Accessors(chain = true)
public class DataPojo {
private String value;
}
Для пакета/модуля/проекта — добавляем один параметр в lombok.config:
lombok.accessors.chain=true
Уточнение про Lombok fluent vs chain
Важно различать fluent и chain в терминологии Lombok. Параметр
(равно как и @Accessors(fluent = true)) имеет почти ровно тот же эффект, только генерируемый setter-метод не имеет префикса set:
lombok.accessors.fluent=true
(равно как и @Accessors(fluent = true)) имеет почти ровно тот же эффект, только генерируемый setter-метод не имеет префикса set:
public SimplePojo value(String value) {
this.value = value;
return this;
}
IDEA (Lombok plugin)
Начиная с прошлого года Lombok plugin поставляется с IDEA по умолчанию, т.е. курируется самой JetBrains. Плагин прекрасно распознает подобные конфигурации (как через аннотации, так и через конфиги), анализ кода работает корректно:
Jackson
Jackson, библиотека для сериализации JSON, XML и не только, не требует дополнительных настроек для работы с бинами, сеттеры которых возвращают this. Сериализация и десериализация работает без каких-либо проблем.
jOOQ
jOOQ, фреймворк, который генерирует классы модели из схемы базы данных, может генерировать классы с fluent setter'ами. Требуется явное выставление параметра конфигурации, т.к. это генерация, а не анализ.
Hibernate
Hibernate, фреймворк доступа к базам данных, позволяет использовать классы с предложенной нотацией как Entity. Не требуется дополнительная настройка.
Spring и Spring Boot
Spring и Spring Boot (а также производные проекты вроде spring-data, spring-data-rest) содержат тонны логики, построенной на reflection и анализе структур классов. Здесь и классы конфигурации, и разбор ResultSet'ов через BeanPropertyRowMapper, и прочее. Основной класс, который отвечает за анализ свойств бинов в spring — BeanUtils, не требует для setter-методов return this. Т.е. всё работает, что называется, из коробки.
Mapstruct
Mapstruct, библиотека для авто-генерации классов конвертации из одного класса в другой, делает анализ соответствующих структур классов. Не требует дополнительных настроек, распознает setter методы, возвращающие this.
Kotlin
Разработчики языка Kotlin решили все те проблемы, которые пытается разрешить предложенный подход в Java. Там есть и data-классы, и именованная передача множества параметров в конструкторы и в методы. Но, кроме этого, в Kotlin есть синтаксический сахар для доступа к свойствам объектов, которые при обращении (как чтении, так и записи) фактически являются вызовами соответствующих getter и setter-методов. Kotlin лояльно относится к setter-методам, которые возвращают this вместо void (сиреневый курсив):
Т.е. мы в Kotlin коде можем использовать вызовы setter'ов в Java-моделях как присваивания свойств. Слава Kotlin!
Что говорит спека?
Есть спека JavaBeans, которая явным образом нигде не говорит, что setter-методы должны возвращать именно void, хотя все примеры, конечно, написаны именно так.
Но, на самом деле, далеко не все так радужно.
Основной класс в JDK, который отвечает за анализ структуры Java Beans — java.beans.Introspector, не распознает setter-методы, если они возвращают не void.
Что можем сломать?
Выше упомянутый java.beans.Introspector активно используется в Swing и элементах Java EE. Например, если класс JSP тега пытается возвращать this в своих методах, – это вызовет ошибку в Runtime. И это, пожалуй, самая большая проблема с этим подходом — подобный баг не предотвратит ни компиляция, ни юнит-тест, разве что если это хороший интеграционный или e2e-тест.
Из тихих ошибок также могут быть проблемы, если вы используете apache common-beanutils, библиотеку работы с бинами (например, копирование свойств из одного бина в другой). По умолчанию, копирование свойств бина просто игнорирует свойства с setter'ами возвращающими this, но есть класс расширения FluentPropertyBeanIntrospector, который добавляет такой анализ. Засада в том, что в этом классе есть баг, к которому предложены два решения: 1, 2. Но даже после попытки обсуждения этой темы в списках рассылки этот вопрос так и остается открытым. Сам проект в его нынешнем виде похож на заброшенный даже несмотря на его относительно высокую популярность.
Когда использовать
Я использую этот подход в течение нескольких последних лет работы. Преимущественно это были REST-API, ведь они полны классов моделей данных и DTO-шек, а именно в таких классах сильнее всего чувствуется эффект от приема. Если проект на этапе разработки – тоже хорошо, меньше шанс сломать то, что работало правильно. Фреймворки вроде Spring Boot — отлично, но если вы работаете на традиционном стеке Java EE / swing, скорее всего это вам не подойдет.
Как мигрировать
Если нет хороших интеграционных тестов — очень осторожно. В случае Lombok есть гибкость настройки — можно начать с одного класса, пакета или, к примеру, нового модуля. Можно поступить и наоборот — определить режим chained-setter'ов на уровень всего проекта, а для конкретного пакета (типа JSP-тегов, которые не дружат с концепцией) – отключить:
В любом случае в существующем проекте лучше не делать это одним махом, а действовать итеративно.
Проблема с наследованием
В случае, если мы наследуем setter'ы из базового класса, возникает проблема.
public class IdPojo {
private long id;
public long getId() {
return id;
}
public IdPojo setId(long id) {
this.id = id;
return this;
}
// equals, hashCode, toString
}
public class SubPojo extends IdPojo {
private String value;
public String getValue() {
return value;
}
public SubPojo setValue(String value) {
this.value = value;
return this;
}
// equals, hashCode, toString
}
Мы теперь не можем написать так:
public static SubPojo create() {
return new SubPojo()
.setId(1) // returns IdPojo
.setValue("value"); // compilation failure
}
Здесь есть 4 возможных решения:
- Зачастую нам нужно получить на выходе объект супер-класса, а не самого класса, поэтому можно написать в обратном порядке и вернуть супер-тип:
public static IdPojo create() { return new SubPojo() .setValue("value") .setId(1); }
- В супер-классе объявить конструктор, который в виде исключения принимает базовые поля — подходит, если полей не много. Не забываем, что нам также может требоваться конструктор без параметров для десериализации.
public IdPojo() { } public IdPojo(long id) { this.id = id; } ... public SubPojo() { } public SubPojo(long id) { super(id); }
- Объявить generic-тип на самого себя и возвращать T вместо this:
public class IdPojo<T extends IdPojo<T>> { ... public T setId(long id) { this.id = id; return self(); } private T self() { return (T) this; } // equals, hashCode, toString } public class SubPojo extends IdPojo<SubPojo> { ... }
- Переопределить методы в наследнике
public class SubPojo extends IdPojo { ... @Override public SubPojo setId(long id) { return (SubPojo) super.setId(id); }
Выводы
- ✅ Снижение когнитивной нагрузки
- ✅ Меньше локальных переменных (придумывание названий локальных переменных считается одной из главных проблем программирования)
- ✅ Иерархичность кода, компактный вид
- ❌ Несовместимость с консервативным стеком
- ❌ Тихие ошибки
Возможно, слишком смело назвать использование fluent setter'ов новым трендом. Но очевидно формируется лагерь технологий, которые идут вразрез с традиционной конвенцией. Я свой выбор сделал. И не случалось еще такого, чтобы после внедрения подхода он выпиливался назад.
Примеры кода