了解如何以最少的努力使 Java 类更加一致

纵观我的经验,绝大多数 Java 数据类都是按照我的许多同事(包括我自己)编写的方式编写的。数百小时的人力来修复那些愚蠢到根本不应该存在的错误。有时它是臭名昭著的 NullPointerExceptions,有时它们与一致性有关——部分彼此之间的均匀协议。

这是关于可靠和一致对象的两个中的第一个。在这里,我将向您展示潜在的解决方案,而不需要一些复杂的东西,例如不变性,只是一个有助于避免这种痛苦的秘诀,而无需重新考虑编写对象的各个方面。

问题

如果我们制作一个简单的可序列化对象,它非常简单,根本不需要修改,在业务逻辑中没有任何意义,我们就没有问题。但是,例如,如果您制作数据库表示对象,您可能会遇到一些问题。

假设我们有帐户。每个帐户都有一个idstatusemail。可以通过电子邮件验证帐户。当状态为时,CREATED我们不希望电子邮件被填写。但是当它是VERIFIED或时ACTIVE,必须填写电子邮件。

帐户状态

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
}

在这个对象的整个生命周期中,我们根本不控制字段内容。Null 可以设置为任何字段,或者,例如,““.

主要问题是这个类什么都不负责,并且可以以我们实例化它的任何方式使用。例如,这里我们创建了一个包含所有空字段且没有错误的实例:

@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 时很容易出错。为了避免这种情况,我们可以:

  1. 验证字段是否为空或为空,并检查字段之间的约定。我建议在Constructorsand中这样做setters
  2. 使用java.util.Optional每个可为空的字段来避免 NPE。
  3. 在负责的类中创建复杂的突变作为方法。例如,为了验证一个帐户,我们有一个方法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;
    }

}

从这个测试中可以看出,当 status 为 时,无法使用空字段创建它或设置空电子邮件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. 为每个构造函数和设置器字段创建验证。
  2. 使用java.utill.Optional.
  3. 将复杂的突变放置在适当的位置 – 到负责的类本身。

您可以在GitHub 上找到完整的工作示例。