纵观我的经验,绝大多数 Java 数据类都是按照我的许多同事(包括我自己)编写的方式编写的。数百小时的人力来修复那些愚蠢到根本不应该存在的错误。有时它是臭名昭著的 NullPointerExceptions,有时它们与一致性有关——部分彼此之间的均匀协议。
这是关于可靠和一致对象的两个中的第一个。在这里,我将向您展示潜在的解决方案,而不需要一些复杂的东西,例如不变性,只是一个有助于避免这种痛苦的秘诀,而无需重新考虑编写对象的各个方面。
问题
如果我们制作一个简单的可序列化对象,它非常简单,根本不需要修改,在业务逻辑中没有任何意义,我们就没有问题。但是,例如,如果您制作数据库表示对象,您可能会遇到一些问题。
假设我们有帐户。每个帐户都有一个id
、status
和email
。可以通过电子邮件验证帐户。当状态为时,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 时很容易出错。为了避免这种情况,我们可以:
- 验证字段是否为空或为空,并检查字段之间的约定。我建议在
Constructors
and中这样做setters
。 - 使用
java.util.Optional
每个可为空的字段来避免 NPE。 - 在负责的类中创建复杂的突变作为方法。例如,为了验证一个帐户,我们有一个方法
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));
}
结论
在编写不仅仅是可序列化对象的类时,最好进行一些验证和一致性检查。一开始需要做更多的工作,但将来会为您节省大量时间和精力。要做到这一点:
- 为每个构造函数和设置器字段创建验证。
- 使用
java.utill.Optional
. - 将复杂的突变放置在适当的位置 – 到负责的类本身。
您可以在GitHub 上找到完整的工作示例。