Fluent setter. Нарушаем конвенцию

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.
Что будет, если возвращать в setter-методе не void, а this, т.е. использовать прием Fluent Interface?
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. Параметр
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 возможных решения:
  1. Зачастую нам нужно получить на выходе объект супер-класса, а не самого класса, поэтому можно написать в обратном порядке и вернуть супер-тип:
        public static IdPojo create() {
            return new SubPojo()
                    .setValue("value")
                    .setId(1);
        }
    
  2. В супер-классе объявить конструктор, который в виде исключения принимает базовые поля — подходит, если полей не много. Не забываем, что нам также может требоваться конструктор без параметров для десериализации.
        public IdPojo() {
        }
    
        public IdPojo(long id) {
            this.id = id;
        }
    ...
        public SubPojo() {
        }
    
        public SubPojo(long id) {
            super(id);
        }
    
  3. Объявить 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> {
    ...
    }
    
  4. Переопределить методы в наследнике
    public class SubPojo extends IdPojo {
    ...
        @Override
        public SubPojo setId(long id) {
            return (SubPojo) super.setId(id);
        }
    


Выводы


  • ✅ Снижение когнитивной нагрузки
  • ✅ Меньше локальных переменных (придумывание названий локальных переменных считается одной из главных проблем программирования)
  • ✅ Иерархичность кода, компактный вид
  • ❌ Несовместимость с консервативным стеком
  • ❌ Тихие ошибки

Возможно, слишком смело назвать использование fluent setter'ов новым трендом. Но очевидно формируется лагерь технологий, которые идут вразрез с традиционной конвенцией. Я свой выбор сделал. И не случалось еще такого, чтобы после внедрения подхода он выпиливался назад.

Примеры кода
Источник: https://habr.com/ru/company/miro/blog/577478/


Интересные статьи

Интересные статьи

Кто бы что ни говорил, но я считаю, что изобретение велосипедов — штука полезная. Использование готовых библиотек и фреймворков, конечно, хорошо, но порой стоит их отложить и создать ...
Сравнивать CRM системы – дело неблагодарное. Очень уж сильно они отличаются в целях создания, реализации, в деталях.
Среди советов по улучшению юзабилити интернет-магазина, которые можно встретить в инете, один из явных лидеров — совет «сообщайте посетителю стоимость доставки как можно раньше».
«Битрикс» — кошмар на костылях. Эта популярная характеристика системы среди разработчиков и продвиженцев ныне утратила свою актуальность.
От скорости сайта зависит многое: количество отказов, брошенных корзин. Согласно исследованию Google, большинство посетителей не ждёт загрузки больше 3 секунд и уходит к конкурентам. Бывает, что сайт ...