【领域驱动设计】.NET实践:实体、值对象和数据传输对象
在DDD中,实体(Entity)、值对象(Value Object)和服务(Service)是领域模型的基本元素;而数据传输对象(Data Transferring Object,DTO)只负责保存数据,以便数据在层与层之间进行传递,这是前两者与DTO的主要区别。
理解实体与值对象
实体是我们在做开发的时候经常遇见的领域对象,比如上文成绩管理系统中的“学生”就是一个实体,因为在业务处理的过程中,我们必须对“学生”进行区分,“课程成绩”是针对某个学生的概念。由于学生有可能同名同姓,因此不能将姓名作为区分学生的标准,在现实生活中,我们通常是使用“学号”来区分学生。实体是一种领域对象,在特定的上下文(Context)中,我们需要关心的不仅仅是“它是什么”,要更深入地知道“它是哪个”,因此,实体需要有自己的唯一标识符。而值对象又是什么呢?再举一个例子:去超市买东西,假设身上只有几张10元的现金,在结账的时候,掏出了一张10元现金给了收银台,你当然不会去理会刚刚拿来买东西的10元究竟是钱包里的哪张10元,此时,现金就是值对象。从这个例子可以看出,在特定的上下文中,某些领域对象我们只需要知道“它是什么”即可,此时,区分对象的个体变得毫无意义。
我们往往会不经意地将系统中本应该被定义为值对象的领域对象定义为实体,这种思维定势来自“数据库驱动设计”,因为在做数据表设计时,我们都会在数据表上添加一个PrimaryKey(主键)以区分各条不同的记录。并不是说数据库驱动设计不好,只是这样会使得系统也必须为本不是实体的对象提供标识的维护与管理,这样会影响系统性能。其实并没有一个绝对的标准去判断哪些领域对象应该被视为实体,哪些应该被定义为值对象,实体与值对象的区分需要具体情况具体分析,这就要求软件人员具有一定的设计经验,根据自己的经验作出正确的判断。仍然以上文的“现金”为例,在上文所述的应用场景里,它是值对象,但如果是在一个验钞系统中,它是实体,因为系统需要对个体进行追踪记录,以区分“究竟哪张现金”是伪钞。
.NET里的实体与值对象设计
事实上,.NET Framework已经对实体与值对象作出了设计。熟悉.NET的朋友都知道,CLR同时支持引用类型和值类型,引用类型内存分配在托管堆里,值类型内存分配在栈里。对于引用类型,系统除了保存其值外,还保存了诸如reference(引用,或者理解为“指针”)、synchronization root等信息。由于reference的存在,CLR就可以通过这个reference来确定一个对象,这明显符合DDD中对实体的定义,reference可以看成是在CLR系统中,对象的唯一标识符。而值类型对象呢?它自然没有这样的标识,因为系统仅仅关心它所保存的值。
有关“值类型对象”可以被看成“值对象”的证据,请参见这里。在这篇文章中,Jimmy Nilsson同时也指出了LINQ to SQL在支持值对象方面的缺陷,看来.NET在DDD方面的支持还需要进一步加强。
数据传输对象及其应用
数据传输对象(DTO)的主要任务是传输数据,因此它不会去牵涉任何领域逻辑处理。由于DTO的主要任务是数据传输,因此它具有如下的特性:1、DTO是可以被序列化(Serializable)的,它不能保存一些上下文相关的数据,比如句柄或者引用;2、DTO的定义是简单的,它所能保存的都是一些基本数据类型的数据,或者是一些简单的类(比如String或者DateTime等)的实例,或者是其它的DTO;3、DTO不包含任何业务逻辑。由于DTO需要在各个层中传输,因此需要隐藏业务逻辑的细节,DTO也自然不会包含对业务逻辑的处理过程;4、可以通过装载器(assembler)/拆卸器(disassembler)来实现领域对象与DTO之间的数据转换。
DTO不是领域对象的映射。出于业务处理封装的需要,领域对象不能“穿梭”于应用系统的各个层面,根据DDD的“富领域模型”观点,领域对象已经不仅仅是POCO,它还具有业务处理能力。如果应用系统各层都能够访问领域对象,那么领域对象的业务处理逻辑也将被暴露在外,这样做的缺点有以下两方面:1、违反封装性需求。假设我们在用户界面层使用领域对象,那么用户界面层也将能够操作领域对象上的业务处理逻辑,而这些操作本不应该由用户界面来完成;2、某些领域对象中的处理逻辑所完成的任务可能比较单一,一个完整的逻辑需要依靠事务来完成,由于领域模型外部对领域的了解几乎为零,所以根本无法得知业务逻辑的正确处理方式。在一无所知的情况下调用领域对象中的处理逻辑是件危险的事情。
仍然以上文的学生成绩系统为例,假设我们查询到了某个学生在某个学科上的期考成绩,我们需要将这个成绩显示在用户界面上,那么我们就会通过领域对象来查到这个成绩数据,根据该数据组建一个DTO并传给应用层,下面的代码片段简单的展示了这一过程(代码中用了仓储、规约,这些内容将在后续章节中介绍)。
- // 界面层
- public class From1 : Windows.Forms.Form
- {
- private void Form_Load(object sender, System.EventArgs e)
- {
- StudentApplication app = new StudentApplication();
- StudentMarkData data = app.GetMark(studentId, courseName);
- this.showMarkControl.DataSource = data;
- this.showMarkControl.Bind();
- }
- }
- // 应用层
- public class StudentApplication
- {
- public StudentMarkData GetMark(Guid studentId, string courseName)
- {
- CourseNameEqualsSpecification specification =
- new CourseNameEqualsSpecification(courseName);
- CourseRepository courseRepository = new CourseRepository();
- StudentRepository studentRepository = new StudentRepository();
- Student student = studentRepository.FindByKey(studentId);
- Course course = courseRepository.FindBySpec(specification);
- Mark mark = student.GetMark(course);
- // 如下的代码从领域对象Student、Course和Mark组装StudentMarkData
- // 数据,并将数据返回。
- StudentMarkData data = new StudentMarkData();
- data.StudentId = studentId;
- data.StudentName = student.ToString();
- data.CourseName = course.Name;
- data.Mark = mark.CourseMark;
- return data;
- }
- }
- // 领域层
- public class Course : IEntity
- {
- public string Name { get; set; }
- #region IEntity Members
- /// <summary>
- /// 读取或设置科目的编号。
- /// </summary>
- public Guid Id { get; set; }
- #endregion
- }
- public class Student : IEntity
- {
- public Student()
- {
- this.DayOfBirth = DateTime.Now;
- }
- /// <summary>
- /// 读取或设置学生的姓氏。
- /// </summary>
- public string LastName { get; set; }
- /// <summary>
- /// 读取或设置学生的名字。
- /// </summary>
- public string FirstName { get; set; }
- /// <summary>
- /// 读取或设置学生的成绩列表。
- /// </summary>
- public IList<Mark> Marks { get; set; }
- /// <summary>
- /// 计算学生某科的成绩。
- /// </summary>
- /// <param name="course">科目</param>
- /// <returns>成绩</returns>
- public Mark GetMark(Course course)
- {
- var query = from mark in this.Marks
- where mark.Course.Equals(course)
- select mark;
- return mark.First();
- }
- #region IEntity Members
- /// <summary>
- /// 读取或设置学生的编号。
- /// </summary>
- public Guid Id { get; set; }
- #endregion
- }
- // 公共层(基础结构)
- public interface IDataObject : ISerializable
- {
- DataObjectStatus DataObjectStatus { get; set; }
- Guid DataObjectId { get; set; }
- }
- [Serializable]
- [XmlRoot]
- public class StudentMarkData : IDataObject
- {
- [XmlAttribute]
- public Guid StudentId { get; set; }
- [XmlElement]
- public string StudentName { get; set; }
- [XmlElement]
- public string CourseName { get; set; }
- [XmlElement]
- public float Mark { get; set; }
- public DataObjectStatus DataObjectStatus { get; set; }
- public Guid DataObjectId { get; set; }
- public StudentMarkData()
- {
- this.DataObjectId = Guid.NewGuid();
- }
- public StudentMarkData(SerializationInfo info, StreamingContext context)
- : this()
- {
- StudentId = info.GetValue("StudentId", typeof(Guid));
- StudentName = info.GetValue("StudentName", typeof(string));
- CourseName = info.GetValue("CourseName", typeof(string));
- Mark = info.GetValue("Mark", typeof(float));
- }
- #region ISerializable Members
- public void GetObjectData(SerializationInfo info, StreamingContext context)
- {
- info.AddValue("StudentId", StudentId);
- info.AddValue("StudentName", StudentName);
- info.AddValue("CourseName", CourseName);
- info.AddValue("Mark", Mark);
- }
- #endregion
- }
在上面的代码示例中,用户界面层的数据源是一个DTO,而不是领域对象。从分层架构的角度考虑,领域对象位于应用服务器上,为应用系统提供了业务处理的服务。让领域对象跨越服务器边界通过网络传输到客户端,这样做明显不合理。有关用户界面层如何处理DTO以及自动化UI的话题,将在后续文章中简述。