产品代码都给你看了,可别再说不会DDD(七):实体与值对象

ddd · 浏览次数 : 151

小编点评

**实体和值对象的区别** | 特征 | 实体对象 | 值对象 | |---|---|---| | 可变性 | 可变 | 不可变 | | setter 方法 | 有 | 无 | | 等价性 | 相等 | 不相等 | | 面值 | 是 | 是 | | 标识 | 实体类 | 值对象类 | | 可用性 | 可用 | 不可用 | **不可变性实体的编码处理** 为了实现不可变性,对于不可变的属性,通常不会使用setter方法来设置其值。相反,在新的对象中创建和赋值该属性。例如,对于Address对象,我们可以使用一个新的Address对象来存储详细地址,并在该对象中设置所有需要修改的属性的值。 **参考资料** * com.mryqr/core/member/domain/IdentityCard.java * com.mryqr/core/common/domain/Address.java

正文

这是一个讲解DDD落地的文章系列,作者是《实现领域驱动设计》的译者滕云。本文章系列以一个真实的并已成功上线的软件项目——码如云https://www.mryqr.com)为例,系统性地讲解DDD在落地实施过程中的各种典型实践,以及在面临实际业务场景时的诸多取舍。

本系列包含以下文章:

  1. DDD入门
  2. DDD概念大白话
  3. 战略设计
  4. 代码工程结构
  5. 请求处理流程
  6. 聚合根与资源库
  7. 实体与值对象(本文)
  8. 应用服务与领域服务
  9. 领域事件
  10. CQRS

案例项目介绍

既然DDD是“领域”驱动,那么我们便不能抛开业务而只讲技术,为此让我们先从业务上了解一下贯穿本文章系列的案例项目 —— 码如云(不是马云,也不是码云)。如你已经在本系列的其他文章中了解过该案例,可跳过。

码如云是一个基于二维码的一物一码管理平台,可以为每一件“物品”生成一个二维码,并以该二维码为入口展开对“物品”的相关操作,典型的应用场景包括固定资产管理、设备巡检以及物品标签等。

在使用码如云时,首先需要创建一个应用(App),一个应用包含了多个页面(Page),也可称为表单,一个页面又可以包含多个控件(Control),比如单选框控件。应用创建好后,可在应用下创建多个实例(QR)用于表示被管理的对象(比如机器设备)。每个实例均对应一个二维码,手机扫码便可对实例进行相应操作,比如查看实例相关信息或者填写页面表单等,对表单的一次填写称为提交(Submission);更多概念请参考码如云术语

在技术上,码如云是一个无代码平台,包含了表单引擎、审批流程和数据报表等多个功能模块。码如云全程采用DDD完成开发,其后端技术栈主要有Java、Spring Boot和MongoDB等。

码如云的源代码是开源的,可以通过以下方式访问:

码如云源代码:https://github.com/mryqr-com/mry-backend

实体与值对象

在本系列的上一篇聚合根与资源库中,我们讲到了聚合根的设计与实现,事实上聚合根本身即是一种实体(Entity),在本文中我们将对实体以及与之相对立的值对象(Value Object)做展开讲解。

在对聚合根的深入分析中,我们发现其中存在两种类型的对象,一种是具有生命周期的对象(比如成员(Member)),另一种是只起描述作用的对象(比如地址(Address)),前者称为实体,后者称为值对象,充分认识这两种对象之间的区别,对DDD落地有着举足轻重的作用。我们希望达到的目的是,将尽量多的概念建模为值对象,因为值对象比实体更加简单。

实体的生命周期意味着实体具有从产生到消亡的整个过程,这个过程往往比较漫长。比如,在码如云中,成员(Member)对象的生命周期可能超过几年甚至几十年的时间。相比之下,值对象不存在生命周期可言。为了讲解更加直观,让我们来分别看看值对象和实体的例子。在码如云中,地址(Address)即是一个值对象:

//Address

@Value
@Builder
@AllArgsConstructor(access = PRIVATE)
public class Address {
    private final String province; //省份
    private final String city; //城市
    private final String district; //区县
    private final String address; //详细地址

    //......此处省略更多代码

}   

源码出处:com/mryqr/core/common/domain/Address.java

聚合根成员(Member)则是一个实体对象:

//Member

@Getter
@Document(MEMBER_COLLECTION)
@TypeAlias(MEMBER_COLLECTION)
@NoArgsConstructor(access = PRIVATE)
public class Member extends AggregateRoot {
    private String name;//名字
    private Role role;//角色
    private String mobile;//手机号
    private String email;//邮箱
    private IdentityCard identityCard;//身份证
    
    //...此处省略更多代码

}

源码出处:com/mryqr/core/member/domain/Member.java

咋一看,实体和值对象似乎没有什么区别,都是Java对象而已,但事实上,实体和值对象在唯一标识、相等性和可变性等方面均存在很大的区别。

唯一标识

值对象的“描述性作用”也意味着它无需唯一标识(即ID)即可完成其使命,而实体则恰恰相反。在本例中,值对象Address没有ID,而实体Member的唯一标识则存在于其父类AggregateRootid字段中:

//AggregateRoot

@Getter
public abstract class AggregateRoot implements Identified {
    private String id;//聚合根ID
    private String tenantId;//租户ID

    //...此处省略更多代码

}

源码出处:com/mryqr/core/common/domain/AggregateRoot.java

更多关于AggregateRoot的内容,请参考本系列的聚合根与资源库一文。

在DDD中,所有的聚合根都是实体对象,但并不是所有的实体都是聚合根,不过从实践上来看了,绝大多数的实体对象都是聚合根。因此,在DDD项目中最常见的情况是:作为实体对象的聚合根包含了大量的值对象。

对于聚合根而言,由于已经是领域模型中的顶层对象,其唯一标识应该是全局唯一的;而对于聚合根下的其他实体而言,由于其作用范围被限制在了聚合根内部,因此对应的唯一标识在聚合根下唯一即可。比如,在码如云中,一个应用(App)包含了多个页面(Page),App是聚合根,PageApp下的实体,App的ID必须全局唯一,而Page的ID在其所属的App下唯一即可。

实体的唯一标识可以有多种方式生成,有些业务数据天然即是唯一标识,比如对于人员来说,身份证号即可直接用于唯一标识。不过需要注意的是,只有那些不变的业务字段才能用于唯一标识,否则,当这些业务字段发生更新时,所有引用它的地方都需要做相应更新。更多的时候,我们建议采用一个无业务含义的ID作为唯一标识,比如UUID或者通过雪花算法生成的ID等,又由于UUID的无序性在大数据量场景下可能存在性能问题,因此我们更偏向于雪花算法ID。

有些技术框架可以设置延后对实体ID的生成,比如Hibernate和数据库自增ID等,在DDD中,我们强烈建议不要采用这些方式,因为这些方式所创建出来的实体对象直到保存到数据库的最后一刻都是非法的,更好的方式是在新建实体时即为之设置ID。

在码如云中,我们通过雪花算法为聚合根生成ID,并且在构造函数中完成了对ID的赋值,以达到在新建时即为ID赋值的目的。比如,在Member对象的其中一个构造函数中,我们调用了newMemberId()为新成员生成ID:

//Member

//创建Member
public Member(String name, String mobile, User user) {
    super(newMemberId(), user);
    this.name = name;
    this.mobile = mobile;
    
    //...此处省略更多代码
}

//通过雪花算法生成成员ID
public static String newMemberId() {
    return "MBR" + newSnowflakeId();
}

源码出处:com/mryqr/core/member/domain/Member.java

有时,为了一些纯技术上原因,我们需要为值对象设置ID。比如,如果采用通过ORM框架持久化租户(Tenant),则需要将Tenant中的发票地址(invoiceAddress)保存到一张单独的数据库表中,由于数据库表之间需要有外键关联,因此需要将Address继承自一个层超类IdentifiedValueObject,在IdentifiedValueObject中包含有用于数据库表外键关联的id字段。

此时的Tenant实现如下:

//Tenant

@Getter
@Document(TENANT_COLLECTION)
@TypeAlias(TENANT_COLLECTION)
@NoArgsConstructor(access = PRIVATE)
public class Tenant extends AggregateRoot {
    private String name;//租户名称
    private InvoiceTitle invoiceTitle;//发票抬头
    private Address invoiceAddress;//发票地址

    //...此处省略更多代码

}

源码出处:com/mryqr/core/tenant/domain/Tenant.java

层超类IdentifiedValueObject实现如下:

//IdentifiedValueObject

public abstract class IdentifiedValueObject {
    private String id;
}

此时的Address继承自IdentifiedValueObject

//Address

public class Address extends IdentifiedValueObject {
    private final String province;

    //...此处省略更多代码

}

需要强调的是,以上“为值对象设置ID”的做法仅仅是一种技术上的实践,不能将其与业务相混淆,为此我们引入了一个层超类IdentifiedValueObject将与技术相关的内容作为一个单独的关注点来处理,从而实现了技术与业务的隔离。不过,在码如云,由于我们采用了MongoDB,从而避开了ORM,因此不存在本例中的问题。

相等性判断

实体对象通过ID进行相等性判断,而值对象通过其自身携带的属性进行相等性判断。举个例子,对于一对双胞胎而言,每人都是一个实体对象,由于二人的身份证号(唯一标识)是不同的,因此无论二人长得多么的相像,均不能认为是同一个人;相反,对于其中某一人来说,哪怕是整容到面目全非,也依然是同一个人,因为其ID始终没变。又比如,对于常见的值对象货币(Currency)而言,其价值通过其面值决定,因此一张刚从印钞厂出来的崭新百元大钞和一张沾满了细菌的百元纸币是可以等值互换的,因为它们所携带的面值是相同的。

在编码实践上,最显著的区别是值对象需要实现equals()hashCode()方法,而实体则不需要。在码如云中,我们通过Lombok为值对象自动生成equals()hashCode()方法,比如对于存储身份证信息的IdentityCard,其实现为:

//IdentityCard

@Value
@Builder
@AllArgsConstructor(access = PRIVATE)
public class IdentityCard {
    private String number;
    private String name;
}

源码出处:com/mryqr/core/member/domain/IdentityCard.java

其中的@Value注解隐式地为IdentityCard对象实现了equals()hashCode()方法。

可变性

实体和值对象的另一个区别是:实体对象是可变的(Mutable),而值对象是不可变的(Immutable)。对于实体对象而言,我们可以通过调用其上的方法直接更改其状态;而对于值对象而言,如果需要改变其状态,我们只能创建一个新的值对象,然后在新对象中包含改变后的状态。

对实体对象的直接状态变更比较好理解,这里重点讲一讲对值对象的不可变性的编码处理。对于值对象Address,如果我们需要修改其下的详细地址,具体的实现如下:

//Address

//修改详细地址
public Address changeTo(String detailAddress) {
    return Address.builder()
            .province(this.province)
            .city(this.city)
            .district(this.district)
            .address(detailAddress)
            .build();
}

源码出处:com/mryqr/core/common/domain/Address.java

这里,我们并未直接修改Address对象的address属性,而是新建了一个Address对象,然后将无需修改的字段(比如provice)原封不动地拷贝到新对象中,而将需要修改的字段(address)在新对象中设置为传入的最新值,最后返回这个新建的对象。

不可变性要求值对象必须满足以下约束:

  • 不能有共有的setter方法,否则外界可以直接修改其内部的状态
  • 不能有导致内部状态变化的共有方法

值对象的好处

本文一开始就提到我们应该将尽量多的对象建模为值对象,因为它比实体更加的简单,事实上值对象有多种好处。

首先,因为值对象是不可变的,所以不可变对象所拥有的好处值对象都有,比如使得对程序的调试和推理更加的简单,线程安全等。

其次,值对象作为一个概念上的整体(Conceptual Whole),它将与之相关的业务逻辑包含在其内部,不仅体现了内聚性,也增加了业务表达力,而这正是DDD所提倡的,比如对于本文中的Address,你是希望直接操作4个原始字段(provincecitydistrictaddress)呢,还是操作一个Address对象呢?

另外,值对象由于也包含了业务逻辑,因此可以完成自我验证,这样无论何时我们拿到一个值对象时,都可以相信这是一个合法的对象,而不用在值对象之外再做验证。

例如,在码如云中,定位信息被存放在Geopoint值对象中:

@Value
@Builder
@AllArgsConstructor(access = PRIVATE)
public class Geopoint {
    private static final float EARTH_RADIUS_METERS = 6371000;
    private final Float longitude;//经度
    private final Float latitude;//纬度

    public float distanceFrom(Geopoint that) {
        return distanceBetween(this.longitude, this.latitude, that.longitude, that.latitude);
    }

    private float distanceBetween(float lng1, float lat1, float lng2, float lat2) {
        double dLat = Math.toRadians(lat2 - lat1);
        double dLng = Math.toRadians(lng2 - lng1);
        double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
                Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) *
                        Math.sin(dLng / 2) * Math.sin(dLng / 2);
        return (float) (EARTH_RADIUS_METERS * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)));
    }

    public boolean isPositioned() {
        return longitude != null && latitude != null;
    }
}

源码出处:com/mryqr/core/common/domain/Geopoint.java

可以看到,Geopoint将经度longitude和纬度latitude封装在一起,成为一个概念上的整体。外部调用方无需单独处理经度和纬度数据,而是直接通过这个整体性的Geopoint对象即可完成对定位信息的操作。此外,distanceFrom()distanceBetween都是包含业务逻辑的方法,符合“行为饱满的领域对象”原则。再则,通过isPositioned()方法使得Geopoint可以自行完成业务验证。

角色可变

实体和值对象的划分并不是固定不变的,而是根据其所处的限界上下文决定的。一个概念在一个上下文中是一个实体对象,但是在另外的上下文中则可能是一个值对象。比如,对于上文中的货币Currency,在日常的的交易活动中,货币很明显应该被建模为一个值对象,因为在对其抽象之后我们忽略了货币的颜色,编号,新旧程度等属性,而只关注其面值。但是,如果哪天央行要做一个系统来管理每一张货币(比如对每张货币进行位置跟踪),那么则需要根据货币的编号进行管理,此时的货币则变成了一个实体对象。

总结

实体和值对象是领域对象中的两种不同类型的对象,它们在唯一标识、相等性和可变性等方面均存在不同。在DDD项目中,所有的聚合根均是实体,但是在实际建模过程中,由于值对象在不变性等方面的好处,我们应该尽量将业务概念建模为值对象。在下文应用服务与领域服务中,我们将对应用服务和领域服务做详细讲解。

与产品代码都给你看了,可别再说不会DDD(七):实体与值对象相似的内容:

产品代码都给你看了,可别再说不会DDD(七):实体与值对象

这是一个讲解DDD落地的文章系列,作者是《实现领域驱动设计》的译者滕云。本文章系列以一个真实的并已成功上线的软件项目——码如云(https://www.mryqr.com)为例,系统性地讲解DDD在落地实施过程中的各种典型实践,以及在面临实际业务场景时的诸多取舍。 本系列包含以下文章: DDD入门

基于OpenAI的代码编辑器,有点酷有点强!

最近随着OpenAI的一系列大动作,把软件领域搅的天翻地覆。各行各业各领域,都出现了大量新产品。 开发工具领域首当其冲,各种新工具层出不穷,今天TJ就给大家推荐一个全新的开发工具:Cursor 从官网介绍可以看到,Cursor基于OpenAI实现,继承了最新的GPT-4模型,支持Mac、Window

如何3分钟,快速开发一个新功能

背景 关于为什么做这个代码生成器,其实主要有两点: 参与的项目中有很多分析报表需要展示给业务部门,公司使用的商用产品,或多或少有些问题,这部分可能是历史选型导致的,这里撇开不不谈;项目里面也有很多CRUD的功能,而这些功能的实现代码基本上差不多,这些功能都去手写,也比较浪费时间而且效率很低,还可能会

redis系列02---缓存过期、穿透、击穿、雪崩

一、缓存过期 问题产生的原由: 内存空间有限,给缓存设置过期时间,但有些键值运气比较好,每次都没有被我的随机算法选中,每次都能幸免于难,这可不行,这些长时间过期的数据一直霸占着不少的内存空间! 解决方案: redis提供8种策略供应用程序选择,用于我遇到内存不足时该如何决策: * noevictio

6个步骤强化 CI/CD 安全

快速的数字化和越来越多的远程业务运营给开发人员带来了沉重的负担,他们不断面临着更快推出软件的压力。尽管CI/CD 加速了产品发布,但它容易受到网络安全问题的影响,例如代码损坏、安全配置错误和机密管理不善。通过应用最佳实践来保护 CI/CD 流水线,可以确保代码质量、管理风险并保持完整性。鉴于 CI/

从Kafka中学习高性能系统如何设计

相信各位小伙伴之前或多或少接触过消息队列,比较知名的包含Rocket MQ和Kafka,在京东内部使用的是自研的消息中间件JMQ,从JMQ2升级到JMQ4的也是带来了性能上的明显提升,并且JMQ4的底层也是参考Kafka去做的设计。在这里我会给大家展示Kafka它的高性能是如何设计的,大家也可以学习相关方法论将其利用在实际项目中,也许下一个顶级项目就在各位的代码中产生了。

DevOps|AGI : 智能时代研发效能平台新引擎(上)

AGI 的出现,给了我们一个新视角去审视我们做过的系统,尤其是研发效能平台。研发效能平台作为一个工具平台,本质就是提高公司整体产研的效率。AGI 的快速进步大家已经有目共睹,本文就是在项目协同,代码管理、测试、AIOps等方面来探讨 AGI 可以给研发效能平台带来的巨大变化效率提升。拥抱 AGI,吸

用【游乐场】说清楚“硬件、操作系统、跨平台、应用软件、开发语言、代码”的关系

经常有小伙伴对一些计算机技术和概念不太清楚,产生很多误区,甚至张冠李戴,在一起聊天时又很难给对方解释清楚,经过苦思冥想,终于想到一些比喻,能够很好地阐述了“硬件、操作系统、跨平台、应用软件、开发语言、代码”之间的关系。 硬件 陆地(Intel)与海洋(AMD):硬件就像是一个广阔的自然环境,其中In

火山引擎A/B测试平台的实验管理重构与DDD实践

本次分享的主题是火山引擎数智平台VeDI旗下的A/B测试平台 DataTester 实验管理架构升级与DDD实践。这里说明的一点是,代码的第一目标肯定是满足产品需求,能够满足产品需求的代码都是好代码。而本文中对代码的好坏的评价完全是从架构的视角,结合代码的可读性、可维护性与可扩展性去分析的。 在一个

掌握Go的运行时:从编译到执行

> 讲解Go语言从编译到执行全周期流程,每一部分都会包含丰富的技术细节和实际的代码示例,帮助大家理解。 > 关注微信公众号【TechLeadCloud】,分享互联网架构、云服务技术的全维度知识。作者拥有10+年互联网服务架构、AI产品研发经验、团队管理经验,同济本复旦硕,复旦机器人智能实验室成员,阿