领域驱动设计(缩写是DDD)不是一种技术也不是一种方法论。DDD提供了一个关于实践以及用于作出关注和加快软件项目处理复杂领域设计决定术语的结构。正如Eric Evans和Martin Fowler描述的那样,领域对象就是放置验证规则和业务逻辑的地方。
Eric Evans:
领域层(或者模型层):负责展示业务内容,业务场景信息,以及业务规则。反映业务场景的状态在这里进行控制和使用,即使是存储的技术细节都应委托给基础设施。这一层是商业软件的核心。
Martin Fowler:
应该放在领域对象中的逻辑就是领域逻辑 -- 验证,计算,业务规则 -- 不管你喜欢怎么称呼它。
把全部的验证放到Domain对象导致了要和庞大而又复杂的领域对象一起工作。就个人而言,我更喜欢把领域验证解耦分成验证器组件以便可以在任何时候进行重用以及能基于上下文和用户操作的想法。
正如Martin Fowler在伟大的文章:语境验证 中写道的那样。
我看到人们通常做的一件事就是为对象开发验证程序。这些程序有着各种各样实现的方式,可能是在对象之内或者在对象之外,可能返回一个布尔值或者抛出一个异常来表示失败。但我经常在想的一件事,是使人们犯错的原因是例如一个isValid方法暗示他们在独立的上下文中考虑对象的验证性。
我觉得在绑定上下文中考虑验证会更为有效 -- 典型的是你想执行的一个操作。这个订单是否可以填写,这位旅客是否可以登记进入酒店。所以用像isValidForCheckIn而不是像isValid这样的方法。
Action验证建议
在此文章中我们将会实现一个简单的ItemValidator接口,其中你需要实现返回ValidationResult类型的validate方法。ValidationResult是一个包含了已被验证的项目以及Messages对象的对象。后者包括对错误,警告,和依赖于执行上下文信息验证状态的累积。
验证器是在任何需要的地方都能轻松重用的解耦组件。通过这种方式,全部需要验证检查的依赖都可以轻松地注入。例如,仅仅是用于UserDomainService中检查给定邮箱的用户是否在数据库中。
每个上下文(操作)都可以进行验证器解耦。所以如果UserCreate操作和UserUpdate操作或者其他的操作(UserActivate,UserDelete,AdCampaignLaunch等)也有解耦的组件的话,那么验证器就会快速增长。
每个操作验证器都应该有对应的操作模型,且此操作模型只有允许进行操作的字段。为了创建用户,需要以下字段:
UserCreateModel:
{
"firstName": "John",
"lastName": "Doe",
"email": "john.doe@gmail.com",
"password": "MTIzNDU="
}
为了更新用户,以下externalId,firstName和lastName这些字段也是允许的。externalId用于用户身份验证并且只有firstName和lastName是允许修改的。
UserUpdateModel:
{
"externalId": "a55ccd60-9d82-11e5-9f52-0002a5d5c51b",
"firstName": "John Updated",
"lastName": "Doe Updated"
}
可以共享字段整个验证,firstName最大的长度通常是255个字符。
在验证的过程中,不仅要描述第一个触发到的错误,还要包括遇到的全部问题的清单。例如,以下三个问题有可能在同时发生,并且可以在执行过程中相应地进行纪录:
非法地址格式 [错误]
用户邮箱必须是唯一的 [错误]
密码太短 [错误]
为了获得那样的验证,需要一些像验证状态构建器的东西,出于此目的,我们引入了Messages。消息(Messages)是多年前我从我杰出导师听到的一个概念,当时他为了支持验证以及其他各种各样可以用它来解决的东西时介绍了Messages,因为Message不仅仅只是为了验证。
注意接下来的部分我们将会使用Scata来说明它的实现。万一你不是一位Scala专家,也不要觉得害怕因为不管怎样Scala都是很容易明白的。
在上下文验证中的消息
消息是一个代表了验证状态构建器的对象。它提供了一种在验证过程中收集错误、警告和提示消息的简单方式。每一个Messages对象都有一个内部Messages对象的集合并且也可以有指向parentMessages对象的引用。
消息对象就是一个有type,messageText,key(可选,用于支持被识别器识别的特殊输入的验证),和提供了可以构建组合消息树的childMessages的对象。
消息可以是以下类型中的一种:
Information (提示)
Warning (警告)
Error (错误)
类似这种结构的消息允许我们迭代构建,以及可以基于前一个消息状态做出下一个操作的决定。例如,在用户创建的过程中执行的验证:
@Component
class UserCreateValidator @Autowired (private val entityDomainService: UserDomainService) extends ItemValidator[UserCreateEntity] {
Asserts.argumentIsNotNull(entityDomainService)
private val MAX_ALLOWED_LENGTH = 80
private val MAX_ALLOWED_CHARACTER_ERROR = s"must be less than or equal to $MAX_ALLOWED_LENGTH character"
override def validate(item: UserCreateEntity): ValidationResult[UserCreateEntity] = {
Asserts.argumentIsNotNull(item)
val validationMessages = Messages.of
validateFirstName (item, validationMessages)
validateLastName (item, validationMessages)
validateEmail (item, validationMessages)
validateUserName (item, validationMessages)
validatePassword (item, validationMessages)
ValidationResult(
validatedItem = item,
messages = validationMessages
)
}
private def validateFirstName(item: UserCreateEntity, validationMessages: Messages) {
val localMessages = Messages.of(validationMessages)
val fieldValue = item.firstName
ValidateUtils.validateLengthIsLessThanOrEqual(
fieldValue,
MAX_ALLOWED_LENGTH,
localMessages,
UserCreateEntity.FIRST_NAME_FORM_ID.value,
MAX_ALLOWED_CHARACTER_ERROR
)
}
private def validateLastName(item: UserCreateEntity, validationMessages: Messages) {
val localMessages = Messages.of(validationMessages)
val fieldValue = item.lastName
ValidateUtils.validateLengthIsLessThanOrEqual(
fieldValue,
MAX_ALLOWED_LENGTH,
localMessages,
UserCreateEntity.LAST_NAME_FORM_ID.value,
MAX_ALLOWED_CHARACTER_ERROR
)
}
private def validateEmail(item: UserCreateEntity, validationMessages: Messages) {
val localMessages = Messages.of(validationMessages)
val fieldValue = item.email
ValidateUtils.validateEmail(
fieldValue,
UserCreateEntity.EMAIL_FORM_ID,
localMessages
)
ValidateUtils.validateLengthIsLessThanOrEqual(
fieldValue,
MAX_ALLOWED_LENGTH,
localMessages,
UserCreateEntity.EMAIL_FORM_ID.value,
MAX_ALLOWED_CHARACTER_ERROR
)
if(!localMessages.hasErrors()) {
val doesExistWithEmail = this.entityDomainService.doesExistByByEmail(fieldValue)
ValidateUtils.isFalse(
doesExistWithEmail,
localMessages,
UserCreateEntity.EMAIL_FORM_ID.value,
"User already exists with this email"
)
}
}
private def validateUserName(item: UserCreateEntity, validationMessages: Messages) {
val localMessages = Messages.of(validationMessages)
val fieldValue = item.username
ValidateUtils.validateLengthIsLessThanOrEqual(
fieldValue,
MAX_ALLOWED_LENGTH,
localMessages,
UserCreateEntity.USERNAME_FORM_ID.value,
MAX_ALLOWED_CHARACTER_ERROR
)
if(!localMessages.hasErrors()) {
val doesExistWithUsername = this.entityDomainService.doesExistByUsername(fieldValue)
ValidateUtils.isFalse(
doesExistWithUsername,
localMessages,
UserCreateEntity.USERNAME_FORM_ID.value,
"User already exists with this username"
)
}
}
private def validatePassword(item: UserCreateEntity, validationMessages: Messages) {
val localMessages = Messages.of(validationMessages)
val fieldValue = item.password
ValidateUtils.validateLengthIsLessThanOrEqual(
fieldValue,
MAX_ALLOWED_LENGTH,
localMessages,
UserCreateEntity.PASSWORD_FORM_ID.value,
MAX_ALLOWED_CHARACTER_ERROR
)
}
}
看这里面的代码,你会发到使用了ValidateUtils。这些工具函数用于填充预定义的消息对象。你可以在Github上的ValidataeUtils找到它的实现代码。
在邮箱验证中,首先验证的是调用ValidateUtils.validateEmail(…来检查邮箱是否有效,还调用了ValidateUtils.validateLengthIsLessThanOrEqual(…来检查邮箱的长度是否有效。当做完了这两验证,就会执行检查这个邮箱是否已经赋给了某个用户,仅当前一个邮箱验证条件通过后才会以 if(!localMessages.hasErrors()) { … 结束。这种方式可以避免昂贵的数据库调用。这只是UserCreateValidator其中的一部分。完整的源代码可以在这里找到。
注意其中一个突显的验证参数是:UserCreateEntity.EMAIL_FORM_ID。这个参数把验证状态与一个指定输入的ID关联起来。
在前面的例子,下一个操作的决定依赖于Messages对象是否有错误(使用hasErrors方法)这一事实。可以轻松检查是否有“WARNING”信息并且如果需要的话可以重试。
可以注意到localMessages使用的方式。本地消息是像其他任何被创建的消息一样,但多了parentMessages。也就是说,要有一个指向当前验证状态的唯一引用(如此示例中的emailValidation),以便只检查emailValidation上下文有没错误的localMessages.hasErrors能被调用。同样当一条消息添加到localMessages时,它同时也添加到了parentMessages以及全部存在UserCreateValidation上下文中的验证消息。
既然我们已经看过操作中的Messages,接下来的章节我们将会关注ItemValidator。
ItemValidator - 可重用的验证组件
ItemValidator是一个强制开发人员实现需要返回ValidationResult类型的validate方法的简单特质(接口)。
ItemValidator:
trait ItemValidator[T] {
def validate(item:T) : ValidationResult[T]
}
ValidationResult:
case class ValidationResult[T: Writes](
validatedItem : T,
messages : Messages
) {
Asserts.argumentIsNotNull(validatedItem)
Asserts.argumentIsNotNull(messages)
def isValid :Boolean = {
!messages.hasErrors
}
def errorsRestResponse = {
Asserts.argumentIsTrue(!this.isValid)
ResponseTools.of(
data = Some(this.validatedItem),
messages = Some(messages)
)
}
}
当诸如UserCreateValidator这样的ItemValidator作为依赖注入组件实现时,那么就可以注入ItemValidator对象并且可以在任何需要UserCreate操作验证的对象中进行重用。
验证执行后,将会检查验证是否成功。如果是,用户数据将会持久化到数据库,如果不是那么将会返回包含验证错误的接口响应。
在下一节中我们将会看到怎样在RESTful接口响应中展示验证错误以及怎样通过执行操作状态和接口用户进行沟通。
统一接口响应 - 简单的用户交互
在我们创建用户的用例中,当用户操作验证成功后,验证的结果需要展示给RESTful接口用户。最好的方式是有一个只有上下文将会被切换(用JSON的话,就是data)统一的接口响应。通过统一的响应,可以轻易将错误展示给接口用户。
统一响应结构:
{
"messages" : {
"global" : {
"info": [],
"warnings": [],
"errors": []
},
"local" : []
},
"data":{}
}
统一响应结构中有两层消息,global和local。local消息是为特定输入而解耦出来的消息,如“用户名太长,最多允许80个字符”。global消息是反映整个页面数据状态的消息,如“直到被审批后才能激活用户”。local和global都有error,warning和information这三级。data的值指向上下文。当创建用户时data字段会包含用户数据,而当获得一系列用户时data字段将会是一个用户数组。
通过这种结构化的响应,可以轻易创建负责显示错误、警告和提示信息的客户端UI处理器。global消息可以在页面的顶部显示,因为他们与全局API状态相关,而local消息则可以靠近特定的输入(字段)显示,因为他们与字段的值直接关联。可以用红色表示错误,黄色表示警告,蓝色表示提示。
例如,在基于客户端app的AngularJS中,我们有两种负责处理local和global响应消息的指令,以便仅有这两个处理器才能以一贯的方式处理全部的响应。
针对local消息的指令需要应用到一个父指向拥有全部消息的实际元素的元素。
localmessages.direcitive.js :
(function() {
'use strict';
//字数超出最大允许值,服务器可能拒绝保存!
//只好少贴一点代码,有需要可查看源文章
})();
针对global消息的指令包含在根布局文档(index.html)中并且需要注册一个处理全部全局消息的事件。
globalmessages.directive.js:
(function() {
'use strict';
angular
.module('reactiveClient')
.directive('globalMessagesValidationDirective', globalMessagesValidationDirective);
//字数超出最大允许值,服务器可能拒绝保存!
//只好少贴一点代码,有需要可查看源文章
})();
一个更复杂的例子,考虑以下包含了local消息的响应:
{
"messages" : {
"global" : {
"info": [],
"warnings": [],
"errors": []
},
"local" : [
{
"inputId" : "email",
"errors" : ["User already exists with this email"],
"warnings" : [],
"info" : []
}
]
},
"data":{
"firstName": "John",
"lastName": "Doe",
"email": "john.doe@gmail.com",
"password": "MTIzNDU="
}
}
以上的响应可以导致如下的显示:
另一方面,响应中有global消息的:
{
"messages" : {
"global" : {
"info": ["User successfully created."],
"warnings": ["User will not be available for login until is activated"],
"errors": []
},
"local" : []
},
"data":{
"externalId": "a55ccd60-9d82-11e5-9f52-0002a5d5c51b",
"firstName": "John",
"lastName": "Doe",
"email": "john.doe@gmail.com"
}
}
客户端可以更突出地显示这些消息:
在上面的例子中,可以看到如何通过同样的处理器处理任何请求中统一的响应结构。
结论
在大型项目中应用验证会变得困惑,验证规则在项目代码中随处可见。保持验证的一致性和良好的结构可以使得这些更简单、可重用。
你可以在以下两个不同的样板版本中找到这些想法的实现:
Standard Play 2.3, Scala 2.11.1, Slick 2.1, Postgres 9.1, Spring Dependency Injection
Reactive non-blocking Play 2.4, Scala 2.11.7, Slick 3.0, Postgres 9.4, Guice Dependency Injection
在此文章中,我已经展示了我关于如何支持深度、可重用上下文验证并且简单地呈现给用户的建议。我希望可以帮到你一劳永逸地解决合适验证和错误处理的挑战。请在下面自由地留下你的评论以及分享你的想法。