第一节 O:对象的世界

目前,面向对象的编程范式统治了编程语言的世界。除了纯粹支持结构式编程的C、纯粹支持函数式编程的Haskell、Erlang、Lisp、Clojure等语言之外,绝大多数现代编程语言如Java、C#、C++、Swift、Kotlin、Go、Scala等都是面向对象的。

1. 类、对象和值

面向对象的编程语言将类(Class)对象(Object)作为程序的基本构建块,应用程序就是由相互调用的多个对象结合而成。

1.1 对象

Grady Booch在他的名著《Object-Oriented Analysis and Design with Applications》中对对象给出了这样的定义:

一个对象是一个具有状态、行为和标识符的实体。结构和行为类似的对象定义在它们共同的类中。

  • 状态(State)

    对象的状态包括这个对象的所有属性(Property,通常是静态的),以及每个属性的当前值(通常是动态的)。

    例如矩形(Rectangle)这个具有长(length)宽(width)两个属性。某一个长3米,宽2米的矩形是一个具体对象。这个矩形的长度和宽度就是它的当前状态。

    对象所拥有的属性由它所属的类(Class)定义,通常是静态的,这意味着在对象的生命周期中一般不会动态添加新的属性(但是有些编程语言支持动态添加属性)。而对象的属性值由对象而不是类持有,在对象的生命周期中一般是可以改变的(例如员工的职位这个属性,在他的职业生涯中是会改变的),所以说属性值通常是动态的。当然也有一些属性值在对象的生命周期中不会改变,例如订单对象的订单号,这时它就是静态的。

    系统中的所有对象都封装了某种状态,系统的所有状态都由对象所封装。

    Java中,属性以实例字段(Instance Field)的形式存在,一般是private的,可以(但不必须)通过publicgetter/setter方法暴露为外部可见的属性。如果只有setter方法,就是只读属性;如果只有getter方法,就是只写属性;两者都有,就是可读写属性。

  • 行为(Behavior)

    行为是对象在状态改变和消息传递方面的动作和反应的方式。换言之:对象的行为代表了它外部可见的活动。

    对象的行为包括这个对象的所有操作(Operation)。一个操作代表了一个类提供给它的对象的一种服务。一个操作是某种动作,一个对象调用另一个对象定义的操作,目的是获得反应(获得操作的返回值或发生副作用——修改对象的内部状态)。

    Java中,操作用方法(Method)的形式来声明。方法是类的成员函数,可以包含0到多个形式参数。当一个对象调用另一个对象的方法时,我们说一个对象向另一个对象传递了一个消息,消息内容包括了方法名和参数列表。

    方法的执行结果由方法的参数和对象的状态共同决定。例如账户Account类的取款方法credit(int amount),其执行结果既受到方法参数取款金额amount的影响,又受到Account对象的当前余额属性balance影响。因此可以说:

    一个对象的行为是它的状态以及施加在它上面的操作的函数。操作(方法)执行结果由操作的参数值和对象的当前状态(属性值)共同决定。

    一个对象的状态代表了它的行为的累积效果。例如账户的余额和冻结状态代表了对该账户对象多次调用存款、取款、冻结和解冻操作之后的总结果。

  • 标识符(Identity)

    Khoshafina和Copeland提出了这样的标识符定义:

    标识符是对象的一个属性,它区分这个对象与其他所有对象。

    每个对象的唯一标识符(不一定是名称)是在整个对象的生命周期中都被保持的,即使对象的状态改变了,只要标识符不变,就还是原来的对象。

1.2 对象和值

对象的属性值可以是简单(例如矩形Rectangle对象的width属性值是一个简单的数字),也可以是对另一个外部对象的引用(例如账户Account对象的owner属性,引用了另一个外部对象:一个Owner类的实例)。数字、枚举、字符等等,都是简单值。字符串、枚举、日期等等数据类型,虽然在Java中以对象的形式实现,但本质上可以视为简单值。

因此对象的状态可以人为划分为两部分:一部分是由值类型的所有属性组成的内部状态,另一部分是由对象类型的所有属性组成的外部引用

1.3 类和对象

对象所拥有的属性和操作由对象所属的定义。对象是存在于时间和空间中的具体实体,而类仅代表一种抽象,即一个对象的本质类是对象的模板,对象是类的实例。类定义所有同类对象的共同特征,包括它的对象实例所能够拥有的属性和操作,而具体的对象实例持有自己特有的属性值。同一个类的所有对象拥有相同的属性集,但它们的属性值可以各不相同。

类之间可以存在继承关系。子类继承了超类的所有特征(属性和操作),同时可以再添加自己特有的属性和操作,还可以改变(不鼓励)或修饰超类的操作。所谓改变,是指在子类中完全重写操作的实现代码,将超类的代码覆盖掉。所谓修饰,是指子类的操作代码在调用超类操作代码之前/之后/前后,添加更多的代码。

有些编程语言允许子类继承多个超类,但Java只允许继承一个超类。

下面是一个账户(Account)类的类图:

在账户Account类中有三个私有的属性,代表它持有的两项数据:

  • locked: 账户是否已经被冻结
  • balance:账户的当前余额
  • owner:账户的持有人,指向Owner类的一个实例对象。

还有一些公开的操作:

  • isLocked():表明账户是否已被冻结
  • getBalance():获取账户的当前余额
  • credit():取出
  • debit():存入
  • lock():冻结账户
  • unlock():解冻账户

这些方法或者修改对象的状态(字段值),或者执行结果受对象的状态的影响。举例来说,lock()unlock()会修改locked字段的值,debit()credit()会修改balance字段的值,而credit()的执行结果受balance的值的影响,debit()credit()的执行结果都收到locked的值的影响。

package yang.yu.tdd.bank;

//被测对象
public class Account {

    private boolean locked = false;

    private int balance = 0;

    private Owner owner;

    public Account(Owner owner) {
      this.owner = owner;
    }

    public boolean isLocked() {
        return locked;
    }

    public int getBalance() {
        return balance;
    }

    public void debit(int amount) {
        if (locked) {
            throw new AccountLockedException();
        }
        if (amount <= 0) {
            throw new InvalidAmountException();
        }
        balance += amount;
    }

    public void credit(int amount) {
        if (locked) {
            throw new AccountLockedException();
        }
        if (amount <= 0) {
            throw new InvalidAmountException();
        }
        if (amount > balance) {
            throw new BalanceInsufficientException();
        }
        balance -= amount;
    }

    public void lock() {
        locked = true;
    }

    public void unlock() {
        locked = false;
    }
}

1.4 数据只是实现业务规则的辅助手段

从对象的用户的角度来说,我们真正关注的是对象的行为(方法),而不是它持有的数据(属性值)。与数据相比,行为更加重要。数据存在的目的只是影响方法的执行结果。从上面的例子来看:我们根本不在乎Account对象中的balance字段的值是多少,甚至不关心Account类中是否存在balance字段,我们真正在乎的是:一个Account对象,无论历经多少次存取,只要未被冻结且取款总额不大于存款总额,就可以取款成功,否则取款失败。我们在Account类中定义balance字段的目的,只是为了实现这个业务规则的辅助手段。如果有其他方式可以达到同样的目标,我们完全可以不用在Account类中定义balance这个字段。可以说,以数据库为中心的增删改查开发范式在大多数情况下都是不合适的,以领域模型为中心的领域驱动设计才是更合适的开发范式。数据库是细枝末节,领域模型才是软件开发的核心。

2. 类之间的关系

对象是程序的基本构件块,但多数时候,要完成一项功能,单个对象往往力不从心,而需要多个对象之间的协作。对象和对象之间要结合在一起,形成更高层次的结构体,以实现比单个对象的单个方法更高粒度的功能。因此,相互协作的对象之间存在着各种各样的关系。

思考一下下面的问题:

  • 花和花瓣是什么关系?
  • 党和党员是什么关系?
  • 鸟类和动物是什么关系?
  • 蜜蜂和花是什么关系?

总的来说,类与类之间存在三种类型的基本关系。

第一种类型是一般-特殊关系,表示“是一种(is a)”关系。例如,鸟类是一种动物。而动物是一种生物。就鸟类与动物的关系而言,动物是一般类型,鸟类是特殊类型;动物是鸟类的泛化(Generalization,鸟类是动物的特化(Specialization。就动物和生物的关系而言,生物是一般类型,动物是特殊类型;生物是动物的泛化,动物是生物的泛化。

第二种关系类型是整体-部分关系,表示“组成(part of)”关系。例如花瓣是花的组成部分,发动机是飞机的组成部分,家庭成员是家庭的组成部分,党员是党的组成部分。依据关系的强弱,整体-部分关系可划分为两种子类型:一种是聚合(Aggregation关系,另一种是组合(Composition关系。聚合关系比较弱,分别代表整体和部分的两种对象都有各自独立的生命周期。例如党和党员的关系就是一种聚合关系,党和党员各自有独立的生命周期:有的党员死亡、离开或加入了别的党了,党还在;党解散了,党员个人还在。党员可以随时加入或离开党,也可以加入别的党,党员对党来说具有一定的独立性。而花和花瓣的关系是一种组合关系。花瓣是花的组成部分,缺少了花瓣,花就是不完整的;没有了花,就没有花瓣存在。花瓣的生命周期从属于花,没有任何独立性。

第三种关系是关联(Association,代表两个类之间存在一种语义上的依赖关系。例如蜜蜂和花的关系。花由蜜蜂为其授粉,而蜜蜂以花蜜为食。关联关系在语义上是最弱的。确定类之间的关联关系通常是在分析和早期设计的活动。随着分析和设计的深入,通常会将关联关系明晰化为更加具体的关系,如继承、聚合和组合等。

还有第四种关系:依赖(Dependency关系。依赖关系表明,一个类以某种方式依赖于另一个类。如果类A依赖于类B,当B在设计上发生了改变时,可能会影响到类A。

关联有方向性、多重性等性质。

  • 方向性:关联是有方向的。可以是单向(unidirectional)的,也可以是双向(bidirectional)的。对于类A和B,如果类A有一个类型为B的单值或多值属性,但是类B没有相应的类型为A的单值或多值属性,就说类A有一个到类B的单向关联。如果类B同时有一个类型为A的单值或多值属性,那么就说类A和类B之间存在双向关联。
  • 多重性:类与类之间的关联可以是单值(single-valued)的,也可以是多值(multivalued)的。如果类A有一个类型为类B的属性,那么,类A到类B的关联就是单值关联。如果类A有一个容器类型(集合、列表或数组等)的属性,且容器的元素类型为类B,那么,类A到类B的关联就是多值关联。

class-diagram1

3. 领域模型

对象模型中有一部分与技术完全无关,纯粹用于描述问题域中的业务实体和业务逻辑的类,它们组成了领域模型(domain model)。领域模型主要由实体(Entity,又称为引用对象)值对象(Value Object)组成。

3.1 实体(Entity,又名引用对象Reference Object)

领域模型中,最关键的一组类是实体类。实体类用于建模问题域中的关键概念,例如顾客(Customer)、订单(Order)、账户(Account)等。实体拥有独立的生命周期,通常是有状态的,在实体的生命周期中,其属性值可以发生多次改变。

实体通过标识符(ID)来进行识别,属性值的变化无关紧要。类型和标识符相同的两个实体视为同一个实体,即使其余的属性值完全不同。标识符不同的两个实体视为不同的实体,即使其余的属性值都相同。实体的重点是通过ID表明它“是谁”,而不是通过属性表明它“是什么样子”

为了思考什么是实体,思考下面的问题:

  • 五岁的我和现在的我是同一个人吗?
  • 一个人杀了人之后逃走了,为了逃避罪责,他染了发,整了容,改姓埋名,甚至做了变形手术。后来他被抓到了,他还是不是以前犯罪的那个人,需不需要承担杀人的后果?

上面问题的答案都是肯定的:是同一个人。人是一种实体类型。年龄、发色、相貌、姓名、甚至性别的变化都不影响他是同一个人。可以认为每一个人都有一个隐含的标识符,唯一标识了他的身份,把一个人和另一个人区分开。注意:姓名和身份证号码都不是必然的标识符,因为这些属性都是可以更改的。

实体的关键特征是:

  • 拥有一个唯一且不变的标识符。通过标识符来识别相同的实体对象和区分不同的实体对象。
  • 拥有独立的生命周期。
  • 在实体生命周期中其状态(属性值)可以发生变化。

3.2 值对象(Value Object)

领域模型中还存在一类对象,它们用于描述领域实体的某个方面,而本身没有概念标识,我们关注的是它“是什么样”而不关心它“是谁”,我们把这种类型的对象成为值对象。它们本质上是“披着对象外衣的值”

值对象的例子是电子邮箱Email、金额Money和订单项目OrderLine等。值对象可以包含单个属性,例如Email类包括一个属性address,也可以包括两个或多个属性,例如Money类包括amountcurrency两个属性,OrderLine类包括quantity、priceproduct属性。值对象的属性类型可以是简单值,也可以是其他值对象,甚至是实体。例如值对象OrderLine类的price属性类型是值对象Money,而product属性类型是实体类Product

值对象用于描述它所从属的实体的某个方面的属性。如同我们可以用简单值182来描述某个人(实体)的身高(以厘米表示)一样,我们可以5美元(即amount = 5, curreny = USDMoney值对象)来表示某个订单(实体)的金额。

在领域模型中,独立存在的简单值和值对象都没有意义。单独存在的数字“182”没有意义,除非给赋值给人这个实体对象的身高属性,表明“这个人的身高是182”;单独存在的Money类型值对象“5美元”也没有意义,除非被赋值给订单实体的金额属性,表示“这个订单的金额是5美元”。

值对象和简单值(数字、字符串、枚举等)具有同等的地位,低于实体的地位。和简单值一样,值对象是实体的内部状态的一部分,位于实体的边界之内,不具有独立的生命周期。

值对象没有概念标识,其意义完全体现在它的属性上。所有的“5美元”都是相同的,区分“这个5美元”和“那个5美元”没有意义。相同类型的两个值对象,如果它们的属性值完全相同,就可以认为是等同的,可以相互替换;只要有一个属性值不同,就认为是不同的两个值对象。我们愿意交换相同面值的两张美元(值对象)因为它们是等价的;但绝对不愿意交换相同体重的两个婴儿(实体),因为每个人都只想要自己的孩子。

值对象在本质上是不可变的。改变了属性值的值对象实质上不再是原来的值对象,而是另一个值对象。因此对于实体的值对象属性来说,没有修改,只有替换。如果订单的金额从5美元改成了7美元,就是将原来的5美元扔掉,换上一个7美元,而不是将原来的那个5美元的amount属性值改成7。应该这样编码:

order.setPrice(new Money(7, Currency.USD));

而不是这样编码:

order.getPrice().setAmount(7);

实际上,Money类根本不应该提供setAmount()setCurrency()等方法。Money类应该是只读的,通过构造函数或静态工厂方法在对象创建时对属性赋值,不提供用于修改属性值的setter方法,从而保证Money类是不可变的。

值对象的关键特征是:

  • 没有唯一标识符。其意义完全体现在其属性上。
  • 依附于实体对象而存在,是实体的内部状态的组成部分,没有独立的生命周期。
  • 其状态(属性值)不可变。

3.3 建模实体和值对象

有一个简单的测试可以用于区分实体和值对象。如果在领域语义中的一个领域对象可以用一个简单的词汇直接提及,例如“员工张三”,“订单31456”,那么这些对象通常可以建模为实体。如果总是需要将某个领域对象称为“某某的某某”,例如“员工张三的住址”,“订单31456的金额”,这样的称呼强烈暗示这些对象是用来描述另一些对象的某些方面的属性的,那么这些对象通常应该建模为值对象。

即使是像金额、Email等等可以用一个简单的数字或字符串表示的属性,也应该建模为一个值对象。将它们建模为值对象一方面丰富了领域模型的内涵,另一方面可以对数据进行校验,防止引入非法的值,还可以在值对象上面定义各种方法。例如可以在Email类的构造函数或工厂方法中校验email字符串,当这个字符串不符合email规范时抛出异常。这样就可以保证每一个email都是格式正常的。

如果对于将某个领域概念建模为实体还是值对象暂时没有把握,那么首先尝试将领域对象建模为值对象。随着分析设计的进一步深入,拥有足够信息之后,再来决定是否将值对象升格为实体。将所有领域对象一股脑建模为实体是很多人常犯的错误。

如果错误地将值对象建模为实体,就必须跟踪它的生命周期,这会大大增加编程的复杂度,而且容易违背业务领域的语义。例如,值对象(不管是单值的还是多值的)是它所在的实体的一部分,其生命周期由实体的生命周期来决定。这意味着当我们删除实体时,它拥有的值对象(不管是单值的货多值的)应该会自动一并删除,不需要人工编程实现(JPA保证了这一点)。一个例子是订单删除时所有的订单项目会一并删除。如果将订单项目建模为实体,就做不到这一点。我们必须手工删除所有的订单条目,而且必须在删除订单之前,以免违背引用完整性。另一个例子是,如果将订单条目建模为值对象,当订单条目有增删时,我们只需要在订单对象上用新的订单条目集合替换掉原来的订单条目集合,不需要区分哪些条目是新增的,哪些是保留的,还有哪些删除了(JPA也会自动实现这一点);但如果将订单条目建模为实体,就必须手工编码来处理这个问题了:将新集合和旧集合比较,找出哪些是新增的条目,将它们添加到仓储中,找出哪些是要删除的条目,从仓储中将它们删除。这些人工编码不但繁琐而易于出错,而且不符合业务领域语义,在业务和技术之间出现裂缝。

更加严重的问题是所谓“别名问题”。举个例子,我们定义一个实体类Address(地址),包含了省、市、区县、街道和门牌号等可变属性。同时定义了实体类Employee(员工),它包含一个类型为AddresshomeAddress属性。如果员工A和B曾经住在一起,我们创建了一个地址C,代表他们的当前居住地址,并将它同时赋值给两个员工的homeAddress属性。如果将来有一天,员工A搬到了别的地方,修改了他的居住地址中的某些属性,就会导致员工B的居住地址也改了,变成和A的地址一模一样!原因就是因为两者指向(引用)同一个实体,而不是各自拥有独立的地址!因此,不应该将Address建模为实体,而应该变魔为不可变的值对象。这样,员工A和B就各自拥有自己的居住地址,而不是引用一个外部地址实体。

3.4 值和关联

在企业应用中,真正需要持久化的就是领域模型中的实体对象的状态。

实体的状态包括它的全部属性。实体中的属性(Property)可以分为两大类:

  • 值属性(Attribute):属性类型是值(包括简单值如数值、字符串、布尔值、枚举、日期等,或值对象如EmailMoneyAddressOrderLine等)或值容器(元素内容是值的ListSetMap或数组等)。
  • 关联属性(Association):属性类型是实体或实体容器(元素内容是实体对象的ListSetMap或数组等)。

值是实体对象的内部组成部分,而关联指向的是外部的另一个或一批实体。值位于实体的边界之内,关联位于实体的边界之外。实体与值对象之间的关系是组合(Composition)关系,而实体与实体之间的关系是关联、聚合或继承关系。

在UML的类图中,值对象可以以类的形式出现:

也可以作为属性类型出现在类的内部,指明类的Attribute的类型:

由于值对象OrderLine结构更加复杂,内部还拥有指向实体Product对象的关联属性,上图中仍然用类的形式呈现它。而MoneyAddress就不再单独作为类出现,而是作为实体中的Attribute的类型,例如OrdertotalPrice属性和OrderLineunitPrice属性的类型为值对象MoneyCustomershippingAddress属性的类型是值对象Address

4. 总结

在对象模型中,纯粹代表业务领域概念、与具体软件技术无关的子集称为领域模型,用于建模业务领域的概念、规则和逻辑。一般而言,只有领域模型中的领域对象才需要进行持久化。

领域模型中的领域对象可以分成两类:实体和值对象。实体拥有唯一标识符,是领域模型中最重要的对象。实体有独立的生命周期,在其存活期间,其余的属性可以发生变化,但标识符不变。值对象没有标识符,它的意义全部体现在其属性上。值对象作为实体的内部属性的类型,用于标识实体某些方面的特征。值对象没有独立的生命周期,而是从属于它所属实体的生命周期。

实体和值对象的属性可以分类两类:类型为简单值或值对象的单值或多值的Attribute,和类型为其他实体的单值或多值的Association。本书后面将它们分别称为值属性关联属性。这两者都需要持久化到外部媒体中,以便在系统重启时可以重建整个对象图——包括每个实体的内部状态以及实体之间的关联关系。

results matching ""

    No results matching ""