数据的正确性和执行特定领域的业务规则的能力是软件开发的几个方面之一,几乎任何项目都是如此。由于很难想象任何不需要某种验证的非hello-world应用程序,解决这个问题对整个项目的成功至关重要。
当然,这样的核心概念必然会影响整体架构,所采取的任何方法都应确保只使用有效数据在整个代码中执行相关的业务操作。因此,所选设计应与业务逻辑无缝集成,并提供合理的保护级别。此外,从开发,维护和用户体验的角度来看,软件通常需要在发生验证错误时进行报告。
我们与Zbyszek Artemiuk和Przemek Rafalski一起讨论了这个主题,在最后的文章中,我们想提出一种方法来实现对域驱动设计精神保持的简单应用程序的验证。
所提供的代码库仅指与此讨论相关的类。完整的项目可在此处获得。
应用案例
我们的小项目的主要功能是通过指定的电子邮件地址和密码注册用户。代表我们领域的相关基本模型是用户实体:
public class User {
private final UserId id; private final Email email; private final Password password;
public User(UserId id, Email email, Password password) { this.id = id; this.email = email; this.password = password; }
|
与包含电子邮件和密码概念的值对象:
public class Email {
private final String email;
public Email(String email) { this.email = email; }
public class Password {
private final String value;
public Password(String value) { this.value = value; } }
|
最后一个重要功能是能够通过电子邮件地址值查找用户。为此,让我们引入另一个类:public interface Users { void add(User user); }
|
从我们的应用程序角度来看,我们将分别公开两个REST端点,用于接受用户注册请求和返回在给定id下注册的用户的电子邮件地址。
@RestController public class UsersEndpoint {
private final Users users; private final IdGenerator idGenerator;
public UsersEndpoint(Users users, IdGenerator idGenerator) { this.users = users; this.idGenerator = idGenerator; }
@PostMapping("/users") public ResponseEntity<UserCreationResponse> handleCreateUserRequest(@RequestBody UserCreationRequest request) { User newUser = new User(idGenerator.id(), new Email(request.email), new Password(request.password)); users.add(newUser); return ResponseEntity.status(HttpStatus.CREATED).body(UserCreationResponse.success(newUser.getId().toString())); }
@GetMapping("/users/{id}") public ResponseEntity<String> getUserEmailById(@PathVariable String id) { return ResponseEntity.ok(String.valueOf(usersFinder.findUserEmailById(UserId.of(id)))); }
public static class UserCreationRequest { public String email; public String password; }
|
业务规则:验证
对数据正确性的要求非常明显 - 应用程序只能接受具有指定格式良好的电子邮件地址的注册请求。此外,由于它应该唯一地标识某个用户,我们不能允许两个用户共享一个实例。为了保护我们心爱的用户免受对其密码的暴力攻击,我们希望他们只使用强大的密码,根据应用程序定义的策略 - 它的长度不得少于5个字符。
除了保护我们的业务规则之外,我们还希望在处理注册请求时发生验证失败的信息。
让我们首先实现实际的验证逻辑,因为它可能在任何项目中都是优先考虑的。
由于我们希望将验证逻辑保持在与它相关的域对象的附近,让我们使用各个构建块的构造函数开始我们的实现,从我们的值对象 - Password和Email类开始。
密码验证
验证密码的强度不需要任何其他上下文数据,除了注册请求中传递的值。然后,我们的实现包含在一个简单的PasswordValidator类,如果验证不通过抛出PasswordTooWeakException,如下所示:
public class PasswordValidator { public void validate(String value) { if (value == null || value.length() < 5) { throw new PasswordTooWeakException()); } } } public class PasswordTooWeakException extends ValidationException { public PasswordTooWeakException() { super("password too weak"); } }
在Password值对象中使用: public class Password {
private final String value; private final PasswordValidator validator = new PasswordValidator();
public Password(String value) { validator.validate(value);
this.value = value; }
|
这样,我们就部分地满足了我们对电子邮件的要求。作为其唯一性的优秀项目实际上反映了与包含User实体更相关的限制,而不是与电子邮件地址本身相关的限制。
用户验证
由于我们需要检查给定的电子邮件地址是否尚未注册到任何现有用户,因此我们的验证逻辑显然需要对存储库进行一些访问。将这种依赖放在User实体类中并在构造函数中执行基于存储库的验证似乎不是一个好主意 。值得庆幸的是,我们可以使用最简单的创建模式之一来缓解它 - 工厂。在创建复杂对象,参与依赖关系管理等时,使用此模式。此外,在DDD中,它通常还负责执行验证。这听起来像是我们场景的完美搭配!
public class UserFactory {
private final Users users; private final IdGenerator idGenerator; private final EmailUniquenessValidator validator;
public UserFactory(Users users, IdGenerator idGenerator) { validator = new EmailUniquenessValidator(users); this.users = users; this.idGenerator = idGenerator; }
public User create(Password password, Email email) { validator.validate(email);
return new User(idGenerator.id(), email, password); }
|
我们就可以在EmailUniquenessValidator类中实现必要的验证逻辑:
public class EmailUniquenessValidator {
private final Users users;
public EmailUniquenessValidator(Users users) { this.users = users; }
public void validate(Email email) { if (!users.isUniqueEmail(email)){ throw new NotUniqueEmailAddress(email); }
|
public class NotUniqueEmailAddress extends ValidationException { public NotUniqueEmailAddress(Email email) { super(String.format("Not unique email address - '%s'", email)); } }
|
为简单起见,我们只是限制对接口的更改,因为实际的实现与我们的讨论无关。
public interface Users {
void add(User user);
boolean isUniqueEmail(Email email); }
|
处理验证异常
我们不想明确地抛出异常,而是将这个决定委托给一些外部处理程序。一种方法是使该validate方法接受附加参数。
至于我们新类的责任,我们只希望它能够接受验证异常。然后让我们创建以下ValidationExceptionHandler接口来表达:
public interface ValidationExceptionHandler { void add(ValidationException e); }
|
我们当前的需求揭示了两个必需的实现: 一个用于收集错误的目的:
public class AggregatingValidationExceptionHandler implements ValidationExceptionHandler {
private final List<RuntimeException> errors = new ArrayList<>();
@Override public void add(ValidationException e) { this.errors.add(e); }
public boolean hasErrors() { return !errors.isEmpty(); }
public List<String> getErrors() { return errors.stream().map(Throwable::getMessage).collect(Collectors.toList()); }
}
|
而另一个,它将立即重新抛出第一个添加的异常,就像我们最初的方法:public class ThrowingValidationExceptionHandler implements ValidationExceptionHandler {
@Override public void add(ValidationException e) { throw e; } }
|
现在转向validators,由于它的简单性,就使用PasswordValidator。我们新定义的验证异常处理程序的用法如下面的代码片段所示:
public class PasswordValidator {
public void validate(String value, ValidationExceptionHandler validationExceptionHandler) { if (value == null || value.length() < 5) { validationExceptionHandler.add(new PasswordTooWeakException()); } } }
|
通过这种实现,我们ValidationExceptionHandler负责产生错误的情况。让我们将新方法参数更高一级地传递给Password类。public class Password {
private final String value; private final PasswordValidator validator = new PasswordValidator();
public Password(String value, ValidationExceptionHandler validationExceptionHandler) { validator.validate(value, validationExceptionHandler);
this.value = value; } }
|
由于我们的更改,我们无法再确保任何实例化Password对象都是有效的,因为它取决于所使用的实际实现ValidationExceptionHandler,这不是我们希望的。值得庆幸的是,为了提供向后兼容的行为,我们实现了一个ThrowingValidationExceptionHandler 适合作为默认处理程序的类。对于非构造相关的场景,验证操作需要移动到单独的方法,接受处理程序作为参数。作为风格偏好的问题,让我们为构造和验证添加静态方法:
public class Password { //... private Password(String value) { Password.test(value, new ThrowingValidationExceptionHandler()); this.value = value; }
public static Password of(String value) { return new Password(value); }
public static void test(String password, ValidationExceptionHandler validationExceptionHandler) { new PasswordValidator().validate(password, validationExceptionHandler); }
|
控制器
从我们在用户注册端点的逻辑流程的角度来看,我们首先要确保在尝试创建任何域对象之前可以使用指定的数据。我们来介绍一下这种方法:
@RestController public class UsersEndpoint {
//...
@PostMapping("/users") public ResponseEntity<UserCreationResponse> handleCreateUserRequest(@RequestBody UserCreationRequest request) { List<String> errors = validateRequest(request); if (errors.isEmpty()) { User newUser = saveNewUser(request.email, request.password); return ResponseEntity.status(HttpStatus.CREATED).body(UserCreationResponse.success(newUser.getId().toString())); } else { return ResponseEntity.status(BAD_REQUEST).body(UserCreationResponse.failure(errors)); } }
private List<String> validateRequest(UserCreationRequest request) { AggregatingValidationExceptionHandler validationExceptionHandler = new AggregatingValidationExceptionHandler();
Email.test(request.email, validationExceptionHandler); Password.test(request.password, validationExceptionHandler); if (validationExceptionHandler.hasErrors()) { return validationExceptionHandler.getErrors(); }
userFactory.test(Email.of(request.email), validationExceptionHandler); if (validationExceptionHandler.hasErrors()) { return validationExceptionHandler.getErrors(); }
return Collections.emptyList(); }
private User saveNewUser(String email, String password) { User user = userFactory.create(Password.of(password), Email.of(email)); users.add(user); return user; }
//... }
|
在这里,我们可以清楚地看到新添加的test 方法的用法,以及收集异常处理程序。这回答了收集所有发生的验证错误的要求,并允许我们仅在实际没有发生错误时才创建域对象。
在初始测试期间,我们的存储状态不包含指定地址,但是在构建时,其状态已经更改,验证不再会通过,可能会引发并发问题,具有悲观锁定的事务虽然可能解决这个问题,但并不总是符合条件。好消息是,这种情况可以被认为是非常罕见的,并不是真的那么危险,因为可能发生的最糟糕的事情是不会创建对象并且ValidationException需要处理类型化的异常。
另一个问题是执行两次验证,这可能会对性能造成重大影响。对于我们的例子中PasswordValidator和EmailValidator验证所需的计算显然可以忽略不计。相反,EmailUniquenesValidator由于它可以访问外部资源(可能是具有非最佳连接特性的远程DB),因此可能会导致我们麻烦。
总结
正如我们所见,在我们的领域对象周围实现领域验证规则并不是那么麻烦,并且使用工厂和访问者模式,甚至可以说它是容易的。此外,将这两个方面保持在实体周围有助于理解域规则。
在使用方面,将验证逻辑推送到尽可能低的级别可确保只创建有效的领域对象,从而使开发人员免于采取防御措施。它还有助于项目维护,防止验证逻辑在应用程序的几个不同层中传播。尽管多次执行检查或存在乐观锁定问题的可能性存在缺点,但我们相信这种方法允许我们在业务逻辑和验证代码之间划清界限,以一种整洁和优雅的方式这样做。我们希望您会发现它对您的项目有用!