Пролог

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

Рекомендации по дизайну

Объекты-значения определяются своими значениями атрибутов и являются одним из ключевых строительных блоков в архитектуре проектирования на основе моделей. Объекты-значения не имеют концептуальной идентичности и описывают объект таким, какой он есть.

Эвристика дизайна #1. Два объекта-значения равны, если их атрибуты имеют одинаковое значение.

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

Эвристика дизайна #2. Объекты-значения должны быть неизменяемыми по своей природе.

Рассматривайте объект значения как неизменяемый и не присваивайте ему никакой идентификации. Это дает нам возможность упростить дизайн и оптимизировать производительность. Но зачем нужно делать его неизменяемым, и повысит ли он ценность проекта и упростит ли задачу? Здесь нужно понять цель.

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

Рассмотрим приведенный ниже пример. Здесь мы создали объект учетной записи в processSavings() и передали его в checkValid(). В checkValid() мы по ошибке обновили тип учетной записи и, наконец, передали ссылку на учетную запись в postAccount() для обработки.

Но есть одна ошибка, которую мы сделали, мы обновили тип счета, что не было намерением, и поэтому сберегательный счет был ошибочно преобразован в текущий и обработан.

public class Account{
   String accountNumber;
   String accountType;  
   public Account(String accountNumber, String accountType){
       this.accountNumber =accountNumber;
       this.accountType=accountType;
   }   
   public String getAccountNumber(){
      return this.accountNumber;
   }
   public void setAccountNumber(String accountNumber){
      this.accountNumber=accountNumber
   }
   public String getAccountType(){
      return this.accountType;
   }
   public void setAccountType(String accountType){
      this.accountType=accountType;
   }
}
public class TestImmutability {
  public void processSavings(){
    Account account = new Account("a0123", "savings");
    checkValid(account);
    postAccount(account);
    /////.....
  }
  public boolean checkValid(Account account){
    // run valid checks
    account.setAccountType("current"); 
    return true;  
  }
}

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

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

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

Проектирование объекта-значения как неизменяемого — это случай следования общему правилу. Если значение атрибута изменяется, мы создаем другой объект значения вместо изменения существующего. Но могут быть случаи, когда объекты значений могут быть спроектированы как изменяемые.

Эвристика дизайна #3: объекты-значения могут изменяться из соображений производительности.

Если он является изменяемым, убедитесь, что он не используется совместно, так как это приведет к неожиданным результатам и проблемам целостности в модели. Факторы, определяющие изменяемые реализации объектов-значений (цитируя DDD Эрика Эванса):

  1. Если значение часто меняется.
  2. Если создание или удаление объекта дорого.
  3. Если замена будет мешать кластеризации.

Проектирование объекта неизменяемого значения

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

public class Account {
   private final int id;
   private final String type;
   private final String currency;
   private final LocalDateTime createdDate;
   private final List<Entry> entries;
   private Account(
    int id,
    String type,
    String currency,
    LocalDateTime createdDate,
    List<Entry> entries) {
     this.id = id;
     this.type = type;
     this.currency = currency;
     this.createdDate = createdDate;
     this.entries = entries;
   }
    
    public int getId() { 
      return id; 
    }
    public String getType() { 
      return type; 
    }
    public String getCurrency() { 
      return currency; 
    }
    public LocalDateTime getCreatedDate() { 
      return createdDate; 
    }
    public List<Entry> getEntries() { 
      return Collections.unmodifiableList(entries);
    }
   
    public String toString() {
      return "Account{"
      + "id=" + id
      + ", type=" + type
      + ", currency=" + currency
      + ", createdDate=" + createdDate
      + ", entries=" + entries
      + "}";
    }
     
    public int hashCode() {
      return Objects.hash(id,type,currency,createdDate,entries);
    }
    public boolean equals(Object another) {
      if (this == another) return true;
        return another instanceof ImmutableAccount
          && id == another.id
          && type.equals(another.type)
          && currency.equals(another.currency)
          && createdDate.equals(another.createdDate)
          && entries.equals(another.entries);
    }
}

Это достигает нашей цели, но если мы присмотримся, то увидим много шаблонного кода, такого как getter, constructor, toString(), equals() и hashcode(). Кроме того, мы должны повторять один и тот же утомительный процесс для каждого класса данных, монотонно создавая новое поле для каждого фрагмента данных, создавая equals(), hashCode() и методы toString(); и создание конструктора, который принимает каждое поле.

Эвристика проектирования #4. Используйте автоматический генератор кода для объектов-значений везде, где это возможно, для обработки стандартного кода.

Lombok, AutoValue, Immutables и Java Records — это некоторые из широко используемых инструментов для обработки такого шаблонного кода. для объектов значения домена. Но какой из них является правильным инструментом? Трудно принять решение, если смотреть на их назначение сверху, потому что так или иначе они делают ту же работу, но ищут детализированные факты, а потом принимают решение.

Lombok, AutoValue, Immutables, Java Records

Lombok, AutoValue и Immutables используют стандартную обработку аннотаций Java для обработки общей генерации кода для классов данных. Давайте посмотрим на пример. Предположим, что у нас есть объект-значение Account, как показано ниже, и посмотрите, как он представлен в каждом из них.

Ломбок:

@Value
@Builder
public class Account {
    private int id;
    private String type;
    private String currency;
    private LocalDateTime createdDate;
    private List<Entry> entries;
}

Автозначение:

@AutoValue
public abstract class Account {

    public abstract int id();
    public abstract String type();
    public abstract String currency();
    public abstract LocalDateTime createdDate();

    @AutoValue.Builder
    public abstract static class Builder {
        public abstract Builder setId(int id);
        public abstract Builder setType(String type);
        public abstract Builder setCurrency(String currency);
        public abstract Builder setCreatedDate(LocalDateTime createdDate);
        public abstract Account build();
    }
}

Неизменяемые:

@Value.Immutable
@Value.Style(jdkOnly = true)
public interface Account {
    int getId();
    String getType();
    String getCurrency();
    LocalDateTime getCreatedDate();
    List<Entry> getEntries();
}

Java-запись:

public record Account(int id,
    String type,
    String currency,
    LocalDateTime createdDate,
    List<Entry> entries) {}

Но есть несколько тонких различий в том, как они с этим справляются.

  1. Lombok использует нестандартный подход к генерации кода, также называемый взломом компилятора java, что делает его уязвимым и может не поддерживаться в будущем с эволюцией java. Кроме того, Lombok во время выполнения зависит от библиотек для работы с байтовым кодом, таких как asm. С другой стороны, AutoValue и Immutables не используют такие хаки и зависимости, генерируют код из определенного шаблона и используют стандартную аннотацию java.
  2. AutoValue выглядит немного многословно, в то время как Lombok и Immutables выглядят очень скудными и чистыми. AutoValue также является строго неизменным, что означает отсутствие возможности определения изменяемых объектов.
  3. Порядок полей в конструкторе зависит от источника в AutoValue, тогда как в Immutables можно определить порядок полей в конструкторе.
  4. Учетная запись определяется как интерфейс только с геттерами в Immutables. Объявляя объект-значение в качестве интерфейса, мы инкапсулируем объект-значение от любых побочных эффектов и строим его как модель, раскрывающую четкое намерение, что это неизменяемый класс, и клиент будет иметь доступ только к значениям. Это УТП неизменяемых. Lombok и AutoValue не поддерживают его.
  5. Java Records очень чистые. Лично я всегда рекомендую использовать это вместо любой сторонней библиотеки или инструментов. Но на данный момент есть несколько ограничений. Записи — это неизменяемые и прозрачные носители данных, и вы никак не можете изменить данные в записи. Lombok и Immutables определяют способы объявления класса как изменяемого. Еще одно ограничение Record на данный момент заключается в том, что он находится в режиме предварительного просмотра в Java 14 и не будет доступен для использования до Java 17.

Эвристика дизайна #5: проектируйте объект-значение как интерфейс для достижения истинной неизменности.

Полный код, сравнивающий функции Lombok, AutoValue и Immutables, можно найти здесь: https://github.com/rohsin47/lombok-autovalue-immutables

В таблице ниже сравниваются функции Lombok, AutoValue и Immutables по различным параметрам.

Заключение

В идеале объекты-значения должны быть безопасны для типов, безопасны для нулей, поточно-безопасны, невидимы для API (инкапсулированы), не имеют никаких зависимостей во время выполнения и должны иметь незначительные затраты на производительность.

Lombok и Immutables — хорошие альтернативы для создания неизменяемых объектов значений, но для реализации рекомендуется использовать только стабильные функции Lombok, такие как @Value, @Builder и @Data, и избегать экспериментальных функций. С другой стороны, количество настроек и оптимизаций, которых можно достичь с помощью Immutables, впечатляет.

Такие функции, как автоматическая обработка нулевых атрибутов, обеспечение обязательных атрибутов, объявление построителя, определение класса данных модификатора, а также поддержка сериализации JSON для привязок Джексона, делают Immutable лучшим выбором для объектов значений.

Ссылки: