信息实体对象
在对象模型的设计中,有一个普遍的业务就是信息实体类的设计。如果不把“信息实体对象”这个普遍业务来探讨说明清楚,那么你将在数据模型与对象模型之间摇摆不定,最终在不断尝试领域驱动设计感到困难后敬而远之。
实体对象(Entity Object)
从哲学概念的角度来讲,客观存在并可相互区别的事物就是实体。简单理解为世间万物都是实体。在软件设计中,实体又做了更具体的应用。
比如在实体关系图中,一个实体就是对一类事物的抽象。不同实体之间是通过实体类型进行区分的,一般表现为实体名称:用户、角色、桌子、板凳、屋子等等。如果只理解为实体名称是不具严谨的,因为它的全程为实体类型名称,所以对物的抽象是对一类事物的抽象,而不是对名词的抽象。
实体类是概念性的数据密集型类 - 它们的主要目的是存储数据并提供对这些数据的访问。在许多情况下,实体类是持久的,这意味着数据是长久存在的,并需要存储于文件或数据库中。
这段话又强调了另一件事情,就是实体需要持久化。
信息实体对象
今天要讨论的重点是信息实体对象。再过去大家谈论领域模型时,基本再说领域模型的标志是充血模型。这种想法使得信息实体模型在你的设计中很难生存并使你很痛苦。为了信息实体对象能很好地让你接受,再次强调对象概念:领域模型的基础是对象模型,对象是由属性和方法组成的。
信息实体对象基本表现为表单数据对象,这类实体的基本特征就是属性很多,几乎没有方法。常见操作也就是 CRUD 和数据校验。如下图:
看到这,感觉表单数据不再是领域模型或者不在是大家口中相传的那样“软件核心复杂性应对之道”,其实这种思维就是对领域建模和对象建模的误解,为了解开这份误解开始对表单数据的对象建模吧。
Form 对象
Form 就是表单或者表格的意思。比如人口普查时让大家会填一个普查表格,这其实就是在填写一份表单。在 JS 中 Form 是个对象,他包含提交和重置操作方法。如:$("#form").submit()
和 $("#form").reset()
,由此可以感觉到在 JS 中 Form 是个对象模型。
表单数据
在看到表单页面时,我们都应该有一个最基本的认识,表单对象是对表单数据的包装,然后表单数据通常又是一个实体对象,由此我们可以看出表单视图其实是在编辑实体对象。现在我们可以把上图的表单设计出一个领域模型:
class Project {
#id: string
#title: string
#startDate: Date
#endDate: Date
#goal: string
#standard: string
#clients: string[]
#invites: string[]
#weight: number
#scope: number
}
就目前来看这个 Project 模型不具备任何操作方法,对于这种无操作方法的模型如果直接称之为贫血模型是不严谨的。
补充:
我一直认为在谈论贫血模型时,应该谈论的是由于失血症状而导致的贫血。失血症状简单表达为:本来属于一个人身上的血液流失掉了,是从身上流失掉的。而不是看到一盆猪血时,感觉自己的血流失掉了,然后非要把这盆猪血灌到自己身上去。
在很长一段时间里大家对贫血模型的认识还不够严谨,通常来说大家认为只有属性并且很少或没有业务逻辑的模型是贫血模型是不严谨的。识别贫血模型有一个重要的指标是:模型自身需要处理的业务逻辑是否外漏。
如果模型本身没有需要处理的业务逻辑,只包含属性那么他不应该称为贫血模型。
如果模型自身需要处理业务逻辑但是没有处理,而是由调用者来处理本属于模型本身的业务逻辑时就应该称之为贫血模型。
数据校验
考虑一个问题,领域对象的数据校验是属于领域对象本身要做的事情吗?
我在填写人口普查表时,负责普查的工作人员告诉我需要填写的内容。我填写完成后,仔细地检查了一遍,发现没有问题提交给工作人员。工作人员仔细得检查了一下告诉我有几个字段没有填写,然后我又立即把这几个字段填写完成。最后她又检查了一下发现没有问题并提交给了她。
在处理表单数据校验时,通常使用的是外部校验方式,比如:在 Java 开发中提供的 validation-api
框架,以及在大多数的前端 UI 组件库中提供的 Form 组件也是外部包装校验,而不是在数据本身上提供校验。
操作方法
就目前来看 Project 模型中不需要操作方法,而且校验也不由 Project 本身来完成,那它会包含操作方法吗?
如果此时 Project 对象已经能够满足业务需求,确实不需要任何操作方法了。但是随着对领域的深入了解,模型也是会有变化的。比如我们要为 Project 对象添加一个创建时间字段如下:
class Project {
// ...
#createdTime: Date
}
这个时候涉及到 createdTime
字段的赋值问题,可能稍有不慎整个模型就变成了贫血模型。
法医在判断一个人的真实年龄时,可以通过活体的肩、肘、腕、髋、膝、踝六大关节 X 线片上所示的骨骼发育情况,准确地推断个体年龄,将骨龄与实际年龄的误差控制在 ±1 岁范围。
这段话对于认识 createdTime
字段的赋值有着非常重要的帮助。
思考一个问题,在现实中人的骨龄是外部设置的,还是人在初始化时内部设置的?
外部设置:
class Person {
#createdTime: Date
public get createdTime() {
return this.#createdTime
}
public set createdTime(createdTime: Date) {
this.#createdTime = createdTime
}
}
内部设置:
class Person {
#createdTime: Date
public constructor() {
this.#createdTime = new Date()
}
public get createdTime() {
return this.#createdTime
}
}
你有没有见过一个人出生以后,可以随便地更换一套全身的新骨头?所以 createdTime
是在第一次初始化时被赋值的,并且是内部赋值。
对于需要持久化的实体对象来说,ORM 框架需要对象提供无参构造器,因为 ORM 框架每次都需要通过无参构造器实例化对象。那么像 this.#createdTime = new Date()
在 constructor
方法内的操作会重复赋值。一般会将实体对象生命周期中第一次需要做的事情放在一个自定义的有业务意义的方法里。如下:
class Person {
#createdTime: Date
public get createdTime() {
return this.#createdTime
}
public create() {
if (this.#createdTime !== undefined) {
throw new Error("Initialized")
}
this.#createdTime = new Date()
}
}
回归到完整的 Project 对象模型:
class Project {
#id: string
#title: string
#startDate: Date
#endDate: Date
#goal: string
#standard: string
#clients: string[]
#invites: string[]
#weight: number
#scope: number
#createdTime: Date
// getters & setters ...
public create() {
// ...
this.#createdTime = new Date()
}
}
信息实体对象是一种常见的业务,比如:文章、用户信息、店铺信息、客户信息、账户信息、各种表格等信息对象的 CRUD 。
对于一直在试图追求凡事都需要设计成富有行为的充血模型,模型本身没有的行为也要给他设计出来一个行为的小伙伴是非常痛苦的。
不要认为领域模型凡事都需要动作方法来操作,比如:
class Project {
#title: string
#goal: string
// ...
public update(request : ProjectRequest) {
this.#title = request.title
this.#goal = request.goal
// ...
}
}
对象被设计成由属性和方法构成,那么你考虑模型时也要考虑属性和方法联合使用。在认识属性时有一个误区,那就是 field 和 property 的区别,这个问题留在后面单独去讨论。
总结
在很多时候,认为领域模型或者对象模型应该是通过行为操作方法进行更新数据,属性只是提供读操作。然而对于侧重于信息的实体对象来说更多的还是 CURD 的操作。
又通过补充说明对贫血模型做了更详细的解释,希望能够感受到从自身失血的情景,从而认识到实体对象对自身业务封装的必要性。
随后又通过一个填写普查表的故事来说明表单数据的校验是否属于实体自身的功能。最后又为 Project 实体模型设计了第一次初始化时的 create
方法。
补充: 主动与被动对面向对象建模设计有一定的影响,需要去思考这个问题。