领域驱动设计整理——实体和值对象设计

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/lijingyao8206/article/details/50493983

实体

引言

在领域驱动设计里,实体的设计可以说是通用语言的核心,也是最开始在模型划分中需要考虑的。怎么样设计实体和怎么样划分限界上下文同样重要。实体的概念就是要保证通用语言的完整性。领域驱动让设计实体的关注点从数据的属性和表的关联转化到了富有行为的领域概念上。

实体是具有可变性的,这是一个和值对象比较明显的区分,也即实体是可以持续得变化,持续得修改,并且具有唯一的标识。在设计实体的时候需要跳出CRUD的设计思维。把关注重点从数据模型设计转移到实体模型上。实体是能够表达什么概念,具有哪些行为,领域范围是哪些。实体的唯一标识是用来区分实体的,在实体的整个生命周期中这个唯一标识都是不变的。

设计实体

实体设计中,需要先确定实体的唯一标识。在Java的实体设计中,可以借助框架来实现唯一标识。这里先不讨论具体实现细节。设计唯一标识其实可以有多种方式。

  1. 用户输入唯一标识,程序再根据输入生成可识别的数值和符号,这种方式不方便修改生成规则,而且也会存在输入冲突。
  2. 应用程序生成,比如java自带的UUID生成器。apache的Commons的id生成组件。
  3. 持久化机制,DB的序列值Sequence或者自增主键。
  4. 其他上下文提供唯一标识,比如本地上下文也有一个本地的User,然后用全局的比如登录系统的用户id作为本地User实体的主键,这种需要谨慎使用,尽量保证系统的自治性。
  5. 委派标识。很多情况下,唯一标识都是和领域概念无关的,客户端也无需关注这个标识。这时候就可以用一些ORM工具来处理,写一个抽象父类,来专门做id生成。其他子类的实体只需要关注自己领域的模型和行为即刻。
    具体实现可以用Hibernate或者Jpa这些工具。如下是一个用jpa生成自增主键的父类。
package com.lijingyao.bookrent.entity;

import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.MappedSuperclass;

/**
 * Created by lijingyao on 15/12/20 13:14.
 */
@MappedSuperclass
public abstract class LayerSuperType {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    protected Long getId() {
        return id;
    }

    protected void setId(Long id) {
        this.id = id;
    }
}

实体就可以不用关注id生成,如下,是一个代表用户的实体。

package com.lijingyao.bookrent.entity;

import com.sun.istack.internal.NotNull;

import java.util.Calendar;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;

/**
 * Created by lijingyao on 15/12/20 12:48.
 */
@Entity
@Table(
        name = "br_user",
        indexes = {@Index(name = "IDX_USER_NAME", columnList = "name", unique = false)})
public class User extends LayerSuperType {

    @NotNull
    @Column(name = "name")
    private String name;


    @Column(name = "utc_create")
    private Calendar utcCreate;

    @Column(name = "utc_modified")
    private Calendar utcModified;


    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Calendar getUtcCreate() {
        return utcCreate;
    }

    public void setUtcCreate(Calendar utcCreate) {
        this.utcCreate = utcCreate;
    }

    public Calendar getUtcModified() {
        return utcModified;
    }

    public void setUtcModified(Calendar utcModified) {
        this.utcModified = utcModified;
    }
}

定义实体

定义实体需要先理解了领域的通用语言。因为实体需要在表达完整的通用语言基础上再对实体的属性进行定义,然后还需定义实体的唯一标识。
在定义实体时还需要清楚,哪些行为是属于这个实体的,哪些职责是本实体应该具备的。在实体的属性验证过程也应该回归实体。调用验证的过程也不一定是到了持久化这一步之后才进行,前置验证一样可以把验证行为回归到实体。
现在一些流行的开源框架也支持了多种实体验证方式,比如可以用JPA的注解来进行验证,这种是属于延迟验证,但回归到实体的本质和行为上,验证本身也是实体行为的一环。

值对象

概念和特性

值对象值不变的对象,也就是说有特定含义的表达。所以值对象没有唯一标识,也作为反映通用语言的一种方式,就像领域驱动中的一个部件。值对象相对实体概念上更加简单,但关键点是一个领域概念是设计成实体还是值对象。
在设计选择的时候可以针对值对象的特征进行比较:
1. 值对象是用来度量或者描述了领域中的一件东西。
2. 可作为不变量(不变性)
3. 将不同的相关属性组合成一个概念整体
4. 可以和其他值对象进行相等比较(属性相等就是同一个对象)
5. 不会对协作对象造成副作用(无副作用)
其实,在整理值对象的概念时,往往会在实体上设计很多值对象,在java中最常见的就是用枚举来表示和实现。枚举的不变性可以方便表达很多概念。值对象的设计是为了内聚得表达通用语言的一个概念。在领域建模的时候,对于一些不需要唯一标识的概念就可以设计成值对象。比如我们有一个租书一同,现在描述这样一个概念,对于一个实体“书”,如果有一组这样的概念“文学书”约定文学书最多只能借出1个月,科技类的书最多可以租借十天。这里的文学书是一个不变的概念,那么对于书籍的分类就可以设计成一组值对象。对于值对象的管理和生成最好利用工厂方法或者Builder的设计模式。构造函数就初始化好值对象有利于概念的整体性。
对于第五点的无副作用行为,其实结合Java8的lambda表达式,或者说函数式编程能更好的理解,函数式编程就是无副作用的,因为它不会改变对象内部的状态,同样,值对象也不会改变其他实体的状态,只是用来输出一组概念。

在上下文中集成

集成值对象要保持最小化集成和最少职责。如果值对象需要依赖上游上下文的聚合。假设还是刚才的租书的场景,书籍管理系统是在一个上下文中,出租书籍上下文中需要表示一个如下概念:书籍类别(bookType)+ 出租记录(record)可以定义一个“XX类Top10 租售书籍”。这个概念就可以设计成一个值对象-BestRent。这里的BestRent并不包含Book中的type属性。而是通过bookType 这样一个自己拥有的属性来表明一个书的类型。如下是一个简单的值对象:

package com.lijingyao.bookrent.vo;

/**
 * Created by lijingyao on 15/12/26 15:00.
 */
public class BestRent {

    /**
     * The type of a book.category see {@link BookType}
     */
    private String bookType;

    /**
     * the rent number of one book.
     */
    private Integer topNum;

    public BestRent() {
    }

    public BestRent(String bookType, Integer topNum) {
        this.bookType = bookType;
        this.topNum = topNum;
    }

    public BestRent topNScience(Integer topNum) {
        if (null == topNum) {
            throw new IllegalArgumentException("topNum may not be null.");
        }
        return new BestRent(BookType.SCIENCE.name(), topNum);
    }

    public BestRent top10Science() {
        return new BestRent(BookType.SCIENCE.name(), 10);
    }

    public void setBookType(String bookType) {
        this.bookType = bookType;
    }

    public void setTopNum(Integer topNum) {
        this.topNum = topNum;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        BestRent bestRent = (BestRent) o;

        if (bookType != null ? !bookType.equals(bestRent.bookType) : bestRent.bookType != null) return false;
        return !(topNum != null ? !topNum.equals(bestRent.topNum) : bestRent.topNum != null);

    }

    @Override
    public int hashCode() {
        int result = bookType != null ? bookType.hashCode() : 0;
        result = 31 * result + (topNum != null ? topNum.hashCode() : 0);
        return result;
    }
}

通过值对象就不需要关注Book领域的业务和BookType了。这个值对象保持了无副作用性,因为它不会改变任何领域对象的状态,也不需要唯一标识。一个固定的name和一个固定的bookType就可以定义一个不变的概念,比如”自然科学类的Top10”就可以通过top10Science方法来获得。
同样需要注意到,值对象最好实现equals和hashCode方法。对于一组概念描述的值对象来说,只要其表达完整概念的属性值相同,值对象就是相同的。这就能保证top10Science和topNScience(10)需要返回相同的对象。这就是值对象的不变性。

标准类型(Standard Type)

标准类型是标识某些事物的描述对象的表示方式。比如表示货币类型,标准类型可以用RMB,JPY,AID,USD等货币类型。系统中建立标准类型可以统一通信标准,防止有人拼写错误,或者用非标准的描述对象(比如临时的String对象)来表示标准概念。
对于标准类型,在定义概念的上下文中可以建模成实体,在消费方可以建模成值对象。如果消费方的上下文没有必要维护一个描述类型对象的生命周期,那么就可以将其建模成值对象。或者为了方便维护,将标准类型放到单独的限界上下文中。消费方只需要关注标准类型中自己需要用到的属性就可以了。这也是为了尽量最小化集成。
对于有限集合的标准类型可以建模成枚举类。后面会介绍一种结合策略模式的方式去维护值对象的行为。

策略模式实现值对象设计

在构建一个值对象的时候,需要考虑到不变性的概念,可以隐藏对象本身的Setters方法。只有通过构造函数才能使用委派给自己的属性进行设置。
现在考虑对于上文所描述的书籍类型进行值对象设计。这里的书籍类型是一种有限集合的值对象,所以我们可以用Java的Enum来设计。
这里简单说一下,使用策略模式是因为不同的值对象,或者说有限集合的值对象对于某种固定的行为操作(比如本例中的获取TopN的广告语)有着不同的实现。并且结合Java8的lambda表达式,就可以更好地定义我们的StrategyHandler。值对象的不同Handler,就可以定义成不同的Function,从而简化代码实现。
这里假设有一个获取图书广告语的RESTful接口。资源的Controller如下

package com.lijingyao.bookrent.controllers;

import com.lijingyao.bookrent.service.RentService;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;


@RestController
@RequestMapping("/books")
public class BookResource {

    @Autowired
    private RentService rentService;

    @RequestMapping(value = "/{type}/adv", method = {RequestMethod.GET})
    public ResponseEntity getBestSellBookAdv(@PathVariable("type") String type, @RequestParam("num") Integer num) {
        return new ResponseEntity(rentService.getBookAdvByType(type, num), HttpStatus.OK);
    }

}

controller直接通过service获取资源数据。如果存在不同类别的书籍,那么service内部就需要有一个switch 来实现,最简单粗暴的方式就是:


public String getBookAdvByType(String type, Integer num) {
        if (type.equals("SCIENCE")) {
            return "自然科学 图书的畅销Top :" + num.toString();
        }else if(type.equals("NOVEL")){
            return "小说 图书的畅销Top :" + num.toString();
        }else{
            ...
        }
        return "没有找到此类别图书广告语.";
    }   

这种方式可以发现,对于以后扩展书籍类型的话,需要修改Service的实现,也就不符合开放闭合原则。如果借助枚举类来管理书籍类型,再用函数式编程处理广告语产生的行为。那么Service的代码就可以写成:

   package com.lijingyao.bookrent.service;

import com.lijingyao.bookrent.service.vo.BookType;

import org.springframework.stereotype.Service;

/**
 * Created by lijingyao on 15/12/26 14:37.
 */
@Service
public class RentService {


    public String getBookAdvByTypeStrategy(String type, Integer num) {
        BookType bookType = BookType.valueOf(type);
        if (null == bookType) {
            return "There is no advertise of this kind of book.";
        }
        return bookType.bestRentOf(num);
    }


}

行为bookType.bestRentOf 的实现交给了具体的策略。策略的Handler,也就是枚举类如下:

  package com.lijingyao.bookrent.service.vo;

import java.util.function.Function;

/**
 * Created by lijingyao on 15/12/20 13:43.
 */
public enum BookType {


    NOVEL((topNum) -> BestRentUtils.advOfBestRent("小说", topNum)),
    SCIENCE(((topNum) -> BestRentUtils.advOfBestRent("自然科学", topNum))),
    TECHNOLOGY(((topNum) -> BestRentUtils.advOfBestRent("科学技术", topNum)));

    private Function<Integer, String> strategy;

    public String bestRentOf(Integer topNum) {
        return strategy.apply(topNum);
    }


    BookType(Function<Integer, String> strategys) {
        this.strategy = strategys;
    }

}

启动Springboot 输入:http://localhost:8080/books/NOVEL/adv?num=20
就可以看到结果。”小说 图书的畅销Top :20”。这里的处理只是简单的字符串拼接,在具体的上下文中,对于值对象会有更为复杂的计算逻辑。那么就可以把每个单独的又有共性的策略抽取出来,每一个值对象都会有自己的Strategy类来封装内部的实现。如果要扩展策略,也只要新增一个策略实现,以及在枚举中添加一组类型即可。

结语

无论是值对象还是实体,在设计持久化的时候,需要先根据领域模型来设计数据模型,而不是根据数据模型来设计领域模型。这是一种DDD的思维方式,所以要尽量避免数据模型从领域模型中泄露给客户端。下一篇文章会总结下领域服务和应用服务。

展开阅读全文

没有更多推荐了,返回首页