JavaRush /Java блог /Random /Кофе-брейк #127. Как с минимальными усилиями сделать клас...

Кофе-брейк #127. Как с минимальными усилиями сделать классы Java более согласованными

Статья из группы Random
Источник: Hackernoon По моему опыту, подавляющее большинство классов данных Java написано так, как будто их писали мои коллеги, включая меня. Сотни человеко-часов уходят на исправление настолько нелепых ошибок, которых вообще не должно было быть. Иногда это пресловутые NullPointerExceptions, иногда они связаны с консистентностью — согласованностью данных друг с другом. Сейчас я покажу вам простое решение, которое поможет избежать подобных ошибок без пересмотра написания каждого объекта. Кофе-брейк #127. Как с минимальными усилиями сделать классы Java более согласованными - 1

Суть проблемы

Если мы создадим простой сериализуемый объект, который не модифицирован и не имеет смысла в бизнес-логике, то у нас не возникнет ошибка. Но если вы создадите, например, объекты представления базы данных, у вас могут появиться некоторые проблемы. Допустим, у нас есть Accounts. У каждого аккаунта есть id, status и email. Аккаунты верифицируются по электронной почте. Когда статус аккаунта CREATED (создан), мы не ждем, что почта обязательно будет проверена. Но когда статус меняется на VERIFIED (проверен) или ACTIVE (активный), то это означает, что верификация по электронной почте уже прошла. Кофе-брейк #127. Как с минимальными усилиями сделать классы Java более согласованными - 2

public class Account {

    private String id;
    private AccountStatus status;
    private String email;

    public Account(String id, AccountStatus status, String email) {
        this.id = id;
        this.status = status;
        this.email = email;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public AccountStatus getStatus() {
        return status;
    }

    public void setStatus(AccountStatus status) {
        this.status = status;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

}
Перечисление для полей status:

public enum AccountStatus {
    CREATED,
    VERIFIED,
    ACTIVE
На протяжении всей жизни этого объекта мы не контролируем содержимое полей. Для любого поля могут быть установлены даже нули, или, например, "". Основная проблема заключается в том, что этот класс ни за что не отвечает и может использоваться любым способом, которым мы его создаем. Например, здесь мы создаем экземпляр с нулями во всех полях и без ошибок:

@Test
void should_successfully_instantiate_and_validate_nothing() {
    // given
    var result = new Account(null, null, null);

    // when //then
    assertThat(result.getId()).isNull();
    assertThat(result.getEmail()).isNull();
    assertThat(result.getStatus()).isNull();
}
Также тут мы устанавливаем статус ACTIVE, который не может существовать без верификации по email. В итоге у нас появится много ошибок бизнес-логики из-за этой несогласованности, включая NullPointerException и многое другое.

@Test
void should_allow_to_set_any_state_and_any_email() {
    // given
    var account = new Account("example-id", CREATED, "");

    // when
    account.setStatus(ACTIVE);
    account.setEmail(null); // Any part of code in this project can change the class as it wants to. No consistency

    // then
    assertThat(account.getStatus()).isEqualTo(ACTIVE);
    assertThat(account.getEmail()).isBlank();
}

Решение

Как видите, при работе с Accounts легко ошибиться, если объект представляет собой просто шаблон без проверки согласованности. Чтобы избежать этого, мы можем:
  • Проверить поля на NULL или пустое значение и проверить контракт между полями. Я предлагаю сделать это в Constructors и setters.

  • Использовать java.util.Optional для каждого поля, допускающего значение NULL, чтобы избежать ошибки NullPointerException.

  • Создавать сложные мутации в виде методов в ответственном классе. Например, для проверки учетной записи у нас есть метод verify, поэтому у нас есть полный контроль над мутацией при проверке аккаунта.

Вот согласованная версия класса Account, для проверки я использую apache commons-lang:

public class Account {

    private String id;
    private AccountStatus status;
    private Optional<String> email;

    public Account(String id, AccountStatus status, Optional<String> email) {
        this.id = notEmpty(id);
        this.status = notNull(status);
        this.email = checkEmail(notNull(email));
    }

    public void verify(Optional<String> email) {
        this.status = VERIFIED;
        this.email = checkEmail(email);
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = notEmpty(id);
    }

    public AccountStatus getStatus() {
        return status;
    }

    public Optional<String> getEmail() {
        return email;
    }

    public void setEmail(Optional<String> email) {
        this.email = checkEmail(email);
    }

    private Optional<String> checkEmail(Optional<String> email) {
        isTrue(
                email.map(StringUtils::isNotBlank).orElse(false) || this.status.equals(CREATED),
                "Email must be filled when status %s",
                this.status
        );
        return email;
    }

}
Как видно из этого теста, его невозможно создать с пустыми полями или задать пустой адрес электронной почты, если статус ACTIVE.

@Test
void should_validate_parameters_on_instantiating() {
    assertThatThrownBy(() -> new Account("", CREATED, empty())).isInstanceOf(IllegalArgumentException.class);
    assertThatThrownBy(() -> new Account("example-id", null, empty())).isInstanceOf(NullPointerException.class);
    assertThatThrownBy(() -> new Account("example-id", ACTIVE, empty()))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage(format("Email must be filled when status %s", ACTIVE));
}
Вот проверка аккаунта. Он проверяет его так же, как создание экземпляра с ошибочным статусом:

@Test
void should_verify_and_validate() {
    // given
    var email = "example@example.com";
    var account = new Account("example-id", CREATED, empty());

    // when
    account.verify(of(email)); // Account controls its state's consistency and won't be with the wrong data

    // then
    assertThat(account.getStatus()).isEqualTo(VERIFIED);
    assertThat(account.getEmail().get()).isEqualTo(email);
    assertThatThrownBy(
            () -> account.verify(empty()) // It's impossible to verify account without an email
    )
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage(format("Email must be filled when status %s", VERIFIED));

}
Если ваш аккаунт в статусе ACTIVE, то попробуйте установить для него пустой адрес электронной почты. Конечно, это невозможно и мы получим такой результат:

@Test
void should_fail_when_set_empty_email_for_activated_account() {
    // given
    var activatedAccount = new Account("example-id", ACTIVE, of("example@example.com"));

    // when // then
    assertThatThrownBy(() -> activatedAccount.setEmail(empty()))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage(format("Email must be filled when status %s", ACTIVE));
}

Заключение

При написании классов, которые представляют собой нечто большее, чем сериализуемые объекты, рекомендуется провести проверку на валидацию и согласованность данных друг с другом. Это займет немного больше времени в начале работы, но сэкономит вам много нервов в будущем. Для этого:
  1. Создайте проверки для каждого поля constructor и setter.
  2. Используйте java.util.Optional.
  3. Разместите сложные мутации в нужном месте — в самом ответственном классе.
Вы можете найти полный рабочий пример изложенного на GitHub.
Комментарии (1)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Thomas Bergersen Уровень 1
26 мая 2022
хорошая статья