戰(zhàn)術(shù)模式--實(shí)體

在問(wèn)題空間中存在很多具有固有身份的概念妇菱,通常情況下,這些概念將建模為實(shí)體暴区。

實(shí)體是具有唯一標(biāo)識(shí)的概念赊颠,找到領(lǐng)域中的實(shí)體并對(duì)其進(jìn)行建模是非常重要的環(huán)節(jié)吏口。如果理解一個(gè)概念是一個(gè)實(shí)體,就應(yīng)該追問(wèn)領(lǐng)域?qū)<蚁嚓P(guān)的細(xì)節(jié),比如概念生命周期挪蹭、核心數(shù)據(jù)、具體操作忌穿、不變規(guī)則等笙隙;從技術(shù)上來(lái)說(shuō),我們可以應(yīng)用實(shí)體相關(guān)模式和實(shí)踐隔心。

1 理解實(shí)體

一個(gè)實(shí)體是一個(gè)唯一的東西白群,并且可以在相當(dāng)長(zhǎng)的一段時(shí)間內(nèi)持續(xù)變化。

實(shí)體是一個(gè)具有身份和連貫性的概念硬霍。

  • 身份 是一個(gè)重要的領(lǐng)域概念帜慢,應(yīng)該顯示建模以提高其在領(lǐng)域中的表達(dá)性。
  • 連貫性 指通過(guò)唯一身份來(lái)讓某個(gè)概念唯卖,在生命周期的各階段被發(fā)現(xiàn)粱玲、被更新甚至被刪除。

一個(gè)實(shí)體就是一個(gè)獨(dú)立的事物拜轨。每個(gè)實(shí)體都擁有一個(gè) 唯一標(biāo)識(shí)符 (也就是身份)抽减,并通過(guò) 標(biāo)識(shí) 與和 類(lèi)型 對(duì)實(shí)體進(jìn)行區(qū)分開(kāi)。通常情況下橄碾,實(shí)體是可變的卵沉,也就是說(shuō),他的狀態(tài)隨著時(shí)間發(fā)生變化堪嫂。

唯一身份標(biāo)識(shí)可變性特征 將實(shí)體對(duì)象和值對(duì)象區(qū)分開(kāi)來(lái)偎箫。

由于從數(shù)據(jù)建模出發(fā),通常情況下皆串,CRUD 系統(tǒng)不能創(chuàng)建出好的業(yè)務(wù)模型淹办。在使用 DDD 的情況下,我們會(huì)將數(shù)據(jù)模型轉(zhuǎn)化成實(shí)體模型恶复。

從根本上說(shuō)怜森,實(shí)體主要與身份有關(guān)速挑,它關(guān)注“誰(shuí)”而非 “什么”。

2 實(shí)現(xiàn)實(shí)體

大多數(shù)實(shí)體都有類(lèi)似的特征副硅,因此存在一些設(shè)計(jì)和實(shí)現(xiàn)上的技巧姥宝,其中包括唯一標(biāo)識(shí)、屬性恐疲、行為腊满、驗(yàn)證等。

在實(shí)體設(shè)計(jì)早期培己,我們刻意將關(guān)注點(diǎn)放在能體現(xiàn)實(shí)體 唯一性屬性行為 上碳蛋,同時(shí)還將關(guān)注如何對(duì)實(shí)體進(jìn)行查詢。

2.1 唯一標(biāo)識(shí)

有時(shí)省咨,實(shí)體具有明確的自然標(biāo)識(shí)肃弟,可以通過(guò)對(duì)概念的建模來(lái)實(shí)現(xiàn);有時(shí)零蓉,可能沒(méi)有已存的自然標(biāo)識(shí)笤受,將由應(yīng)用程序生成并分配一個(gè)合理的標(biāo)識(shí),并將其用于數(shù)據(jù)存儲(chǔ)敌蜂。

  • 值對(duì)象 作為實(shí)體的唯一標(biāo)識(shí)箩兽,能夠更好的表達(dá)領(lǐng)域概念。
  • 標(biāo)識(shí)具有穩(wěn)定性 在為實(shí)體分配標(biāo)識(shí)后紊册,我們絕對(duì)不允許對(duì)其進(jìn)行修改比肄。如果鍵改變了,那么系統(tǒng)中所有引用該鍵的地方都需要同步更新囊陡,通常情況下芳绩,這是不可能做到的。不然撞反,將導(dǎo)致嚴(yán)重的業(yè)務(wù)問(wèn)題妥色。
2.1.1 自然鍵作為唯一標(biāo)識(shí)

在考慮實(shí)體身份時(shí),首先考慮該實(shí)體所在問(wèn)題空間是否已經(jīng)存在唯一標(biāo)識(shí)符遏片,這些標(biāo)識(shí)符被稱(chēng)為自然鍵嘹害。

通常情況下,以下幾類(lèi)信息可以作為自然鍵使用:

  • 身份證號(hào)
  • 國(guó)家編號(hào)
  • 稅務(wù)編號(hào)
  • 書(shū)籍 ISBN
  • ...

在使用時(shí)吮便,我們通常使用值對(duì)象模式對(duì)自然鍵進(jìn)行建模笔呀,然后為實(shí)體添加一個(gè)構(gòu)造函數(shù),并在構(gòu)造函數(shù)中完成唯一標(biāo)識(shí)的分配髓需。

首先许师,需要對(duì)書(shū)籍 ISBN 值對(duì)象建模:

@Value
public class ISBN {
    private String value;
}

然后,對(duì) Book 實(shí)體建模:

@Data
public class Book {
    private ISBN id;

    public Book(ISBN isbn){
        this.setId(isbn);
    }

    public ISBN getId(){
        return this.id;
    }

    private void setId(ISBN id){
        Preconditions.checkArgument(id != null);
        this.id = id;
    }
}

Book 在構(gòu)造函數(shù)中完成 id 的賦值,之后便不會(huì)修改微渠,以保護(hù)實(shí)體標(biāo)識(shí)的穩(wěn)定性搭幻。

自然鍵,在實(shí)際研發(fā)中逞盆,很少使用檀蹋。特別是在需要用戶手工輸入的情況下,難免會(huì)造成輸入錯(cuò)誤云芦。對(duì)標(biāo)識(shí)的修改會(huì)導(dǎo)致引用失效俯逾,因此,我們很少使用用戶提供的唯一標(biāo)識(shí)舅逸。通常情況下纱昧,會(huì)將用戶輸入作為實(shí)體屬性,這些屬性可以用于對(duì)象匹配堡赔,但是我們并不將這樣的屬性作為唯一身份標(biāo)識(shí)。

2.1.2 應(yīng)用程序生成唯一標(biāo)識(shí)

當(dāng)問(wèn)題域中沒(méi)有唯一標(biāo)識(shí)時(shí)设联,我們需要決定標(biāo)識(shí)生成策略并生成它善已。

最常見(jiàn)的生成方式包括自增數(shù)值、全局唯一標(biāo)識(shí)符(UUID离例、GUID等)以及字符串等换团。

自增數(shù)值

數(shù)字通常具有最小的空間占用,非常利于持久化宫蛆,但需要維護(hù)分配 ID 的全局計(jì)數(shù)器艘包。

我們可以使用全局的靜態(tài)變量作為全局計(jì)數(shù)器,如:

public final class NumberGenerator {
    private static final AtomicLong ATOMIC_LONG = new AtomicLong(1);

    public static Long nextNumber(){
        return ATOMIC_LONG.getAndIncrement();
    }
}

但是耀盗,但應(yīng)用崩潰或重啟時(shí)想虎,靜態(tài)變量就會(huì)丟失它的值,這意味著會(huì)生成重復(fù)的 ID叛拷,從而導(dǎo)致業(yè)務(wù)問(wèn)題舌厨。為了糾正這個(gè)問(wèn)題,我們需要利用全局持久化資源構(gòu)建計(jì)數(shù)器忿薇。

我們可以使用 Redis 或 DB 構(gòu)建自己的全局計(jì)數(shù)器裙椭。

基于 Redis inc 指令的全局計(jì)數(shù)器:

@Component
public class RedisBasedNumberGenerator {
    private static final String NUMBER_GENERATOR_KEY = "number-generator";

    @Autowired
    private RedisTemplate<String, Long> redisTemplate;

    public Long nextNumber(){
        return this.redisTemplate.boundValueOps(NUMBER_GENERATOR_KEY)
                .increment();
    }
}

基于 DB 樂(lè)觀鎖的全局計(jì)數(shù)器:
首先,定義用于生成 Number 的表結(jié)構(gòu):

create table tb_number_gen
(
    id bigint auto_increment primary key,
    `version` bigint not null,
    type varchar(16) not null,
    current_number bigint not null
 );
create unique index 'unq_type' on tb_number_gen ('type');

然后署浩,使用樂(lè)觀鎖完成 Number 生成邏輯:

@Component
public class DBBasedNumberGenerator {
    private static final String NUMBER_KEY = "common";
    private JdbcTemplate jdbcTemplate;

    @Autowired
    public void setDataSource(DataSource dataSource){
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    public Long nextNumber(){
        do {
            try {
                Long number = nextNumber(NUMBER_KEY);
                if (number != null){
                    return number;
                }
            }catch (Exception e){
                // 樂(lè)觀鎖更新失敗揉燃,進(jìn)行重試
//                LOGGER.error("opt lock failure to generate number, retry ...");
            }
        }while (true);
    }

    /**
     * 表結(jié)構(gòu):
     * create table tb_number_gen
     * (
     *  id bigint auto_increment primary key,
     *  `version` bigint not null,
     *  type varchar(16) not null,
     *  current_number bigint not null
     * );
     * add unique index 'unq_type' on tb_number_gen ('type');
     *
     * @param type
     * @return
     */
    private Long nextNumber(String type){
        NumberGen numberGen = jdbcTemplate.queryForObject(
                "select id, type, version, current_number as currentNumber " +
                        "from tb_number_gen " +
                        "where type = '" + type +"'",
                NumberGen.class);

        if (numberGen == null){
            // 不存在時(shí),創(chuàng)建新記錄
            int result = jdbcTemplate.update("insert into tb_number_gen (type, version, current_number) value ('" + type +" ', '0', '1')");
            if (result > 0){
                return 1L;
            }else {
                return null;
            }
        }else {
            // 存在時(shí)筋栋,使用樂(lè)觀鎖 version 更新記錄
            int result = jdbcTemplate.update("update tb_number_gen " +
                    "set version = version + 1," +
                    "current_number = current_number + 1 " +
                    "where " +
                    "id = " + numberGen.getId() + " " +
                    " and " +
                    "version = " + numberGen.getVersion()
            );
            // 更新成功炊汤,說(shuō)明從讀取到更新這段時(shí)間,數(shù)據(jù)沒(méi)有發(fā)生變化,numberGen 有效婿崭,結(jié)果為 number + 1
            if (result > 0){
                return numberGen.getCurrentNumber() + 1;
            }else {
                // 更新失敗拨拓,說(shuō)明從讀取到更新這段時(shí)間,數(shù)據(jù)發(fā)生變化氓栈,numberGen 無(wú)效渣磷,獲取 number 失敗
                return null;
            }
        }
    }

    @Data
    class NumberGen{
        private Long id;
        private String type;
        private int version;
        private Long currentNumber;
    }
}

全局唯一標(biāo)識(shí)符

GUID 生成非常方便,并且自身就保障是唯一的授瘦,不過(guò)在持久化時(shí)會(huì)占用更多的存儲(chǔ)空間醋界。這些額外的空間相對(duì)來(lái)說(shuō)微不足道,因此對(duì)大多數(shù)應(yīng)用來(lái)說(shuō)提完,GUID 是默認(rèn)方法形纺。

有很多算法可以生成全局唯一的標(biāo)識(shí),如 UUID徒欣、GUID 等逐样。

生成策略,需要參考很多因子打肝,以產(chǎn)生唯一標(biāo)識(shí):

  1. 計(jì)算節(jié)點(diǎn)當(dāng)前時(shí)間脂新,以毫秒記;
  2. 計(jì)算節(jié)點(diǎn)的 IP 地址粗梭;
  3. 虛擬機(jī)中工廠對(duì)象實(shí)例的對(duì)象標(biāo)識(shí)争便;
  4. 虛擬機(jī)中由同一個(gè)隨機(jī)數(shù)生成器生成的隨機(jī)數(shù)

但,我們沒(méi)有必要自己寫(xiě)算法構(gòu)建唯一標(biāo)識(shí)断医。Java 中的 UUID 是一種快速生成唯一標(biāo)識(shí)的方法滞乙。

@Component
public class UUIDBasedNumberGenerator {

    public String nextId(){
        return UUID.randomUUID().toString();
    }
}

如果對(duì)性能有很高要求的場(chǎng)景,可以將 UUID 實(shí)例緩存起來(lái)鉴嗤,通過(guò)后臺(tái)線程不斷的向緩存中添加新的 UUID 實(shí)例斩启。

@Component
public class UUIDBasedPoolNumberGenerator {
    private static final Logger LOGGER = LoggerFactory.getLogger(UUIDBasedPoolNumberGenerator.class);

    private final BlockingQueue<String> idQueue = new LinkedBlockingQueue<>(100);
    private Thread createThread;

    /**
     * 直接從隊(duì)列中獲取已經(jīng)生成的 ID
     * @return
     */
    public String nextId(){
        try {
            return idQueue.take();
        } catch (InterruptedException e) {
            LOGGER.error("failed to take id");
            return null;
        }
    }

    /**
     * 創(chuàng)建后臺(tái)線程,生成 ID 并放入到隊(duì)列中
     */
    @PostConstruct
    public void init(){
        this.createThread = new Thread(new CreateTask());
        this.createThread.start();
    }

    /**
     * 銷(xiāo)毀線程
     */
    @PreDestroy
    public void destroy(){
        this.createThread.interrupt();
    }

    /**
     * 不停的向隊(duì)列中放入 UUID
     */
    class CreateTask implements Runnable{

        @Override
        public void run() {
            while (!Thread.currentThread().isInterrupted()){
                try {
                    idQueue.put(UUID.randomUUID().toString());
                } catch (InterruptedException e) {
                    LOGGER.error("failed to create uuid");
                }
            }
        }
    }
}

當(dāng)在瀏覽器中創(chuàng)建一個(gè)實(shí)體并提交回多個(gè)后端 API 時(shí)醉锅,GUID 就會(huì)非常有用浇垦。如果沒(méi)有 ID 后端服務(wù)將無(wú)法對(duì)相同實(shí)體進(jìn)行識(shí)別。這時(shí)荣挨,最好使用 JavaScript 在客戶端創(chuàng)建一個(gè) GUID 來(lái)解決男韧。

在瀏覽器中生成 GUID,可以有效控制提交數(shù)據(jù)的冪等性默垄。

字符串

字符串常用于自定義 ID 格式此虑,比如基于時(shí)間戳、多特征組合等口锭。

如下例訂單唯一標(biāo)識(shí):

public class OrderIdUtils {

    public static String createOrderId(String day, String owner, Long number){
        return String.format("%s-%s-%s", day, owner, number);
    }
}

一個(gè)訂單 ID 由日期朦前、所有者和序號(hào)三者組成介杆。

對(duì)于標(biāo)識(shí),使用 String 來(lái)維護(hù)并不是很好的方法韭寸,無(wú)法對(duì)其生成策略春哨、具體格式進(jìn)行有效限制。使用一個(gè)值對(duì)象會(huì)更加合適恩伺。

@Value
public class OrderId {
    private final String day;
    private final String owner;
    private final Long number;

    public OrderId(String day, String owner, Long number) {
        this.day = day;
        this.owner = owner;
        this.number = number;
    }

    public String getValue(){
        return String.format("%s-%s-%s", getDay(), getOwner(), getNumber());
    }

    @Override
    public String toString(){
        return getValue();
    }
}

相比之下赴背,OrderId 比 String 擁有更強(qiáng)的表達(dá)力。

2.1.3 持久化存儲(chǔ)生成唯一標(biāo)識(shí)

將唯一標(biāo)識(shí)的生成委派給持久化機(jī)制是最簡(jiǎn)單的方案晶渠。我們從數(shù)據(jù)庫(kù)獲取的序列總是遞增凰荚,結(jié)果總是唯一的。

大多數(shù)數(shù)據(jù)庫(kù)(如 MySQL)都原生支持 ID 的生成褒脯。我們把新建實(shí)體傳遞到數(shù)據(jù)訪問(wèn)框架便瑟,在事務(wù)成功完成后,實(shí)體便有了 ID 標(biāo)識(shí)番川。

一個(gè)使用 JPA 持久化的實(shí)例如下:
首先到涂,定義 Entity 實(shí)體:

@Data
@Entity
public class Person {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private Date birthAt;
}

實(shí)體類(lèi)上添加 @Entity 注解標(biāo)記為實(shí)體;@Id 標(biāo)記該屬性為標(biāo)識(shí)颁督;@GeneratedValue(strategy = GenerationType.IDENTITY) 說(shuō)明使用數(shù)據(jù)庫(kù)自增主鍵生成方式养盗。
然后,定義 PersonRepository :

public interface PersonRepository extends JpaRepository<Person, Long> {
}

PersonRepository 繼承于 JpaRepository适篙,具體的實(shí)現(xiàn)類(lèi)會(huì)在運(yùn)行時(shí)由 Spring Data Jpa 自動(dòng)創(chuàng)建,我們只需直接使用即可箫爷。

@Service
public class PersonApplication {
    @Autowired
    private PersonRepository personRepository;

    public Long save(Person person){
        this.personRepository.save(person);
        return person.getId();
    }
}

在成功調(diào)用 save(person) 后嚷节,JPA 框架負(fù)責(zé)將數(shù)據(jù)庫(kù)生成的 ID 綁定到 Person 的 id 屬性上,person.getId() 方法便能獲取 id 信息虎锚。

性能可能是這種方法的一個(gè)缺點(diǎn)硫痰。

2.1.4 使用另一個(gè)限界上下文提供的唯一標(biāo)識(shí)

通過(guò)集成上下文,可以從另一個(gè)限界上下文中獲取唯一標(biāo)識(shí)窜护。但一般不會(huì)直接使用其他限界上下文的標(biāo)識(shí)效斑,而是需要將其翻譯成本地限界上下文的概念。

這也是比較常見(jiàn)的一種策略柱徙。例如缓屠,在用戶成功注冊(cè)后,系統(tǒng)自動(dòng)為其生成唯一名片护侮,此時(shí)敌完,名片唯一標(biāo)識(shí)便可以直接使用用戶 ID。

當(dāng)用戶注冊(cè)成功后羊初,User 限界上下文將發(fā)布 UserRegisteredEvent 事件滨溉。

@Value
public class UserRegisteredEvent {
    private final UserId userId;
    private final String userName;
    private final Date birthAt;
}

Card 限界上下文,從 MQ 中獲取 UserRegisteredEvent 事件,并將 UserId 翻譯成本地的 CardId晦攒,然后基于 CardId 進(jìn)行業(yè)務(wù)處理闽撤。具體如下:

@Component
public class UserEventHandler {

    @EventListener
    public void handle(UserRegisteredEvent event){
        UserId userId = event.getUserId();
        CardId cardId = new CardId(userId.getValue());
        ...
    }
}
2.1.5 唯一標(biāo)識(shí)生成時(shí)間

實(shí)體唯一標(biāo)識(shí)的生成既可以發(fā)生在對(duì)象創(chuàng)建的時(shí)候,也可以發(fā)生在持久化對(duì)象的時(shí)候脯颜。

標(biāo)識(shí)生成時(shí)間:

  • 及早標(biāo)識(shí) 生成和賦值發(fā)生在持久化實(shí)體之前哟旗。
  • 延遲標(biāo)識(shí) 生成和賦值發(fā)生在持久化實(shí)體的時(shí)候。

在某些情況下伐脖,將標(biāo)識(shí)生成延遲到實(shí)例持久化會(huì)有些問(wèn)題:

  1. 事件創(chuàng)建時(shí)热幔,需要知道持久化實(shí)體的 ID。
  2. 如果將實(shí)體放入 Set 中讼庇,會(huì)因?yàn)闆](méi)有 ID绎巨,從而導(dǎo)致邏輯錯(cuò)誤。

相比之下蠕啄,及早生成實(shí)體標(biāo)識(shí)是比較推薦的做法场勤。

2.1.6 委派標(biāo)識(shí)

有些 ORM 框架,需要通過(guò)自己的方式來(lái)處理對(duì)象標(biāo)識(shí)歼跟。

為了解決這個(gè)問(wèn)題和媳,我們需要使用兩種標(biāo)識(shí),一種為領(lǐng)域使用哈街,一種為 ORM 使用留瞳。這個(gè)在 ORM 使用的標(biāo)識(shí),我們稱(chēng)為委派標(biāo)識(shí)骚秦。

委派標(biāo)識(shí)和領(lǐng)域中的實(shí)體標(biāo)識(shí)沒(méi)有任何關(guān)系她倘,委派標(biāo)識(shí)只是為了迎合 ORM 而創(chuàng)建的。
對(duì)于外界來(lái)說(shuō)作箍,我們最好將委派標(biāo)識(shí)隱藏起來(lái)硬梁,因?yàn)槲蓸?biāo)識(shí)并不是領(lǐng)域模型的一部分,將委派標(biāo)識(shí)暴露給外界可能造成持久化漏洞胞得。

首先荧止,我們需要定義一個(gè)公共父類(lèi) IdentitiedObject,用于對(duì)委派標(biāo)識(shí)進(jìn)行集中管理阶剑。

@MappedSuperclass
public class IdentitiedObject {
    @Setter(AccessLevel.PRIVATE)
    @Getter(AccessLevel.PRIVATE)
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long _id;
}

委派標(biāo)識(shí)的 setter 和 getter 都是 private 級(jí)別跃巡,禁止程序?qū)ζ溥M(jìn)行修改(JPA 框架通過(guò)反射對(duì)其進(jìn)行訪問(wèn))。然后牧愁,定義 IdentitiedPerson 實(shí)體類(lèi):

@Data
@Entity
public class IdentitiedPerson extends IdentitiedObject{
    @Setter(AccessLevel.PRIVATE)
    private PersonId id;
    private String name;
    private Date birthAt;

    private IdentitiedPerson(){

    }

    public IdentitiedPerson(PersonId id){
        setId(id);
    }
}

IdentitiedPerson 實(shí)體以 PersonId 作為自己的業(yè)務(wù)標(biāo)識(shí)瓷炮,并且只能通過(guò)構(gòu)造函數(shù)對(duì)其進(jìn)行賦值。這樣在隱藏委派標(biāo)識(shí)的同時(shí)递宅,完成了業(yè)務(wù)建模娘香。

領(lǐng)域標(biāo)識(shí)不需要作為數(shù)據(jù)庫(kù)的主鍵苍狰,但大多數(shù)情況下,需要設(shè)置為唯一鍵烘绽。

2.1.7 本地標(biāo)識(shí)和全局標(biāo)識(shí)

在聚合邊界內(nèi)淋昭,我們可以將縮短后的標(biāo)識(shí)作為實(shí)體的本地標(biāo)識(shí)。而作為聚合根的實(shí)體需要全局的唯一標(biāo)識(shí)安接。

聚合內(nèi)部實(shí)體翔忽,只能通過(guò)聚合根進(jìn)行間接訪問(wèn)。因此盏檐,只需保障在聚合內(nèi)部具有唯一性即可歇式。
例如,聚合根 Order 擁有一個(gè) OrderItem 的集合胡野,對(duì)于 OrderItem 的訪問(wèn)必須通過(guò) Order 聚合根材失,因此,OrderItem 只需保障局部唯一即可硫豆。

@Value
public class OrderItemId {
    private Integer value;
}

@Data
@Entity
public class OrderItem extends IdentitiedObject{
    @Setter(AccessLevel.PRIVATE)
    private OrderItemId id;
    private String productName;
    private Integer price;
    private Integer count;

    private OrderItem(){

    }

    public OrderItem(OrderItemId id, String productName, Integer price, Integer count){
        setId(id);
        setProductName(productName);
        setPrice(price);
        setCount(count);
    }
}

OrderItemId 為 Integer 類(lèi)型龙巨,由 Order 完成其分配。

@Entity
public class Order extends IdentitiedObject{
    @Setter(AccessLevel.PRIVATE)
    private OrderId id;

    @OneToMany
    private List<OrderItem> items = Lists.newArrayList();

    public void addItem(String productName, Integer price, Integer count){
        OrderItemId itemId = createItemId();
        OrderItem item = new OrderItem(itemId, productName, price, count);
        this.items.add(item);
    }

    private OrderItemId createItemId() {
        Integer maxId = items.stream()
                .mapToInt(item->item.getId().getValue())
                .max()
                .orElse(0);
        return new OrderItemId(maxId + 1);
    }
}

createItemId 方法獲取現(xiàn)有 OrderItem 集合中最大的 id熊响,并通過(guò)自增的方式旨别,生成新的 id,從而保證在 Order 范圍內(nèi)的唯一性汗茄。相反秸弛,聚合根 Order 需要進(jìn)行全局訪問(wèn),因此洪碳,OrderId 需要全局唯一递览。

@Value
public class OrderId {
    private final String day;
    private final String owner;
    private final Long number;

    public OrderId(String day, String owner, Long number) {
        this.day = day;
        this.owner = owner;
        this.number = number;
    }

    public String getValue(){
        return String.format("%s-%s-%s", getDay(), getOwner(), getNumber());
    }

    @Override
    public String toString(){
        return getValue();
    }
}

2.2 實(shí)體行為

實(shí)體專(zhuān)注于身份和連續(xù)性,如果將過(guò)多的職責(zé)添加到實(shí)體上偶宫,容易使實(shí)體變的臃腫。通常需要將相關(guān)行為委托給值對(duì)象和領(lǐng)域服務(wù)环鲤。

2.2.1 將行為推入值對(duì)象

值對(duì)象可合并纯趋、可比較和自驗(yàn)證,并方便測(cè)試冷离。這些特征使其非常適用于承接實(shí)體的行為吵冒。

在一個(gè)分期付款的場(chǎng)景中,我們需要將總金額按照分期次數(shù)進(jìn)行拆分西剥,如果發(fā)生不能整除的情況痹栖,將剩下的金額合并到最后一筆中。

@Entity
@Data
public class Loan {
    private Money total;

    public List<Money> split(int size){
        return this.total.split(size);
    }
}

其中瞭空,核心的查分邏輯在值對(duì)象 Money 中揪阿。

public class Money implements ValueObject {
    public static final String DEFAULT_FEE_TYPE = "CNY";
    @Column(name = "total_fee")
    private Long totalFee;
    @Column(name = "fee_type")
    private String feeType;
    private static final BigDecimal NUM_100 = new BigDecimal(100);

    private Money() {
    }

    private Money(Long totalFee, String feeType) {
        Preconditions.checkArgument(totalFee != null);
        Preconditions.checkArgument(StringUtils.isNotEmpty(feeType));
        Preconditions.checkArgument(totalFee.longValue() > 0);
        this.totalFee = totalFee;
        this.feeType = feeType;
    }

    public static Money apply(Long totalFee){
        return apply(totalFee, DEFAULT_FEE_TYPE);
    }

    public static Money apply(Long totalFee, String feeType){
        return new Money(totalFee, feeType);
    }


    private void checkInput(Money money) {
        if (money == null){
            throw new IllegalArgumentException("input money can not be null");
        }
        if (!this.getFeeType().equals(money.getFeeType())){
            throw new IllegalArgumentException("must be same fee type");
        }
    }

    public List<Money> split(int count){
        if (getTotalFee() < count){
            throw new IllegalArgumentException("total fee can not lt count");
        }
        List<Money> result = Lists.newArrayList();
        Long pre = getTotalFee() / count;
        for (int i=0; i< count; i++){
            if (i == count-1){
                Long fee = getTotalFee() - (pre * (count - 1));
                result.add(Money.apply(fee, getFeeType()));
            }else {
                result.add(Money.apply(pre, getFeeType()));
            }
        }
        return result;
    }
}

可見(jiàn)疗我,通過(guò)將功能推到值對(duì)象,不僅避免了實(shí)體 Loan 的臃腫南捂,而且通過(guò)值對(duì)象 Money 的封裝吴裤,大大增加了重用性。

2.2.2 將行為推入領(lǐng)域服務(wù)

領(lǐng)域服務(wù)沒(méi)有標(biāo)識(shí)溺健、沒(méi)有狀態(tài)麦牺,對(duì)邏輯進(jìn)行封裝。非常適合承接實(shí)體的行為鞭缭。

我們看一個(gè)秘密加密需求:

@Entity
@Data
public class User {
    private String password;

    public boolean checkPassword(PasswordEncoder encoder, String pwd){
        return encoder.matches(pwd, password);
    }

    public void changePassword(PasswordEncoder encoder, String pwd){
        setPassword(encoder.encode(pwd));
    }
}

其中 PasswordEncoder 為領(lǐng)域服務(wù)

public interface PasswordEncoder {

    /**
     * 秘密編碼
     */
    String encode(CharSequence rawPassword);

    /**
     * 驗(yàn)證密碼有效性
     * @return true if the raw password, after encoding, matches the encoded password from
     * storage
     */
    boolean matches(CharSequence rawPassword, String encodedPassword);
}

通過(guò)將密碼加密和驗(yàn)證邏輯推到領(lǐng)域服務(wù)剖膳,不僅降低了實(shí)體 User 的臃腫,還可以使用策略模式對(duì)加密算法進(jìn)行靈活替換岭辣。

2.2.3 重視行為命名

實(shí)體是業(yè)務(wù)操作的承載者吱晒,行為命名代表著很強(qiáng)的領(lǐng)域概念,需要使用通用語(yǔ)言中的動(dòng)詞易结,應(yīng)極力避免 setter 方式的命名規(guī)則枕荞。

假設(shè),一個(gè)新聞存在 上線下線 兩個(gè)狀態(tài)搞动。

public enum NewsStatus {
    ONLINE, // 上線
    OFFLINE; // 下線
}

假如直接使用 setter 方法躏精,上線和下線兩個(gè)業(yè)務(wù)概念很難表達(dá)出來(lái),從而導(dǎo)致概念的丟失鹦肿。

@Entity
@Data
public class News {
    @Setter(AccessLevel.PRIVATE)
    private NewsStatus status;
    /**
     * 直接的 setter 無(wú)法表達(dá)業(yè)務(wù)含義
     * @param status
     */
    public void setStatus(NewsStatus status){
        this.status = status;
    }
}

setStatus 體現(xiàn)的是數(shù)據(jù)操作矗烛,而非業(yè)務(wù)概念。此時(shí)箩溃,我們需要使用具有業(yè)務(wù)含義的方法命名替代 setter 方法瞭吃。


@Entity
@Data
public class News {
    @Setter(AccessLevel.PRIVATE)
    private NewsStatus status;

    public void online(){
        setStatus(NewsStatus.ONLINE);
    }

    public void offline(){
        setStatus(NewsStatus.OFFLINE);
    }
}

與 setStatus 不同,onlineoffline 具有明確的業(yè)務(wù)含義涣旨。

2.2.4 發(fā)布領(lǐng)域事件

在實(shí)體行為成功執(zhí)行之后歪架,常常需要將變更通知給其他模塊或系統(tǒng),以觸發(fā)后續(xù)流程霹陡。因此和蚪,需要向外發(fā)布領(lǐng)域事件。

發(fā)布領(lǐng)域事件烹棉,最大的問(wèn)題是攒霹,在實(shí)體中如何獲取發(fā)布事件接口 DomainEventPublisher 。常見(jiàn)的有以下幾種模式:

  • 作為業(yè)務(wù)方法的參數(shù)進(jìn)行傳遞浆洗。
  • 通過(guò) ThreadLocal 與線程綁定催束。
  • 將事件暫存在實(shí)體中,在持久化完成后伏社,獲取并發(fā)布抠刺。

首先塔淤,我們需要定義事件相關(guān)接口。

DomainEvent:定義領(lǐng)域事件矫付。

public interface DomainEvent<ID, E extends Entity<ID>> {
    E getSource();

    default String getType() {
        return this.getClass().getSimpleName();
    }
}

DomainEventPublisher:用于發(fā)布領(lǐng)域事件凯沪。

public interface DomainEventPublisher {
    <ID, EVENT extends DomainEvent> void publish(EVENT event);

    default <ID, EVENT extends DomainEvent> void publishAll(List<EVENT> events) {
        events.forEach(this::publish);
    }
}

DomainEventSubscriber: 事件訂閱器,用于篩選待處理事件买优。

public interface DomainEventSubscriber<E extends DomainEvent> {
    boolean accept(E e);
}

DomainEventHandler: 用于處理領(lǐng)域事件妨马。

public interface DomainEventHandler<E extends DomainEvent> {
    void handle(E event);
}

DomainEventHandlerRegistry : 對(duì) DomainEventSubscriber 和 DomainEventHandler 注冊(cè)。

public interface DomainEventHandlerRegistry {
    default <E extends DomainEvent>void register(DomainEventSubscriber<E> subscriber, DomainEventHandler<E> handler){
        register(subscriber, new DomainEventExecutor.SyncExecutor(), handler);
    }

    default <E extends DomainEvent>void register(Class<E> eventCls, DomainEventHandler<E> handler){
        register(event -> event.getClass() == eventCls, new DomainEventExecutor.SyncExecutor(), handler);
    }

    default <E extends DomainEvent>void register(Class<E> eventCls, DomainEventExecutor executor, DomainEventHandler<E> handler){
        register(event -> event.getClass() == eventCls, executor, handler);
    }

    <E extends DomainEvent>void register(DomainEventSubscriber<E> subscriber, DomainEventExecutor executor, DomainEventHandler<E> handler);

    <E extends DomainEvent> void unregister(DomainEventSubscriber<E> subscriber);

    <E extends DomainEvent> void unregisterAll(DomainEventHandler<E> handler);
}

DomainEventBus: 繼承自 DomainEventPublisher 和 DomainEventHandlerRegistry杀赢, 提供事件發(fā)布和訂閱功能烘跺。

public interface DomainEventBus extends DomainEventPublisher, DomainEventHandlerRegistry{
}

DomainEventExecutor: 事件執(zhí)行器,指定事件執(zhí)行策略脂崔。

public interface DomainEventExecutor {
    Logger LOGGER = LoggerFactory.getLogger(DomainEventExecutor.class);

    default <E extends DomainEvent>  void submit(DomainEventHandler<E> handler, E event){
        submit(new Task<>(handler, event));
    }

    <E extends DomainEvent> void submit(Task<E> task);

    @Value
    class Task<E extends DomainEvent> implements Runnable{
        private final DomainEventHandler<E> handler;
        private final E event;

        @Override
        public void run() {
            try {
                this.handler.handle(this.event);
            }catch (Exception e){
                LOGGER.error("failed to handle event {} use {}", this.event, this.handler, e);
            }

        }
    }

    class SyncExecutor implements DomainEventExecutor{
        @Override
        public <E extends DomainEvent> void submit(Task<E> task) {
            task.run();
        }
    }
}

作為業(yè)務(wù)方法的參數(shù)進(jìn)行傳遞 是最簡(jiǎn)單的策略滤淳,具體如下:

public class Account extends JpaAggregate {

    public void enable(DomainEventPublisher publisher){
        AccountEnabledEvent event = new AccountEnabledEvent(this);
        publisher.publish(event);
    }
}

這種實(shí)現(xiàn)方案雖然簡(jiǎn)單,但是很瑣碎砌左,每次都需要傳遞 DomainEventPublisher 參數(shù)脖咐,無(wú)形中提高了調(diào)用方的復(fù)雜性。

通過(guò) ThreadLocal 與線程綁定 將 EventPublisher 綁定到線程上下文中汇歹,在使用時(shí)屁擅,直接通過(guò)靜態(tài)方法獲取并進(jìn)行事件發(fā)布。

public class Account extends JpaAggregate {
    public void enable(){
        AccountEnabledEvent event = new AccountEnabledEvent(this);
        DomainEventPublisherHolder.getPubliser().publish(event);
    }
}

DomainEventPublisherHolder 實(shí)現(xiàn)如下:

public class DomainEventPublisherHolder {
    private static final ThreadLocal<DomainEventBus> THREAD_LOCAL = new ThreadLocal<DomainEventBus>(){
        @Override
        protected DomainEventBus initialValue() {
            return new DefaultDomainEventBus();
        }
    };

    public static DomainEventPublisher getPubliser(){
        return THREAD_LOCAL.get();
    }

    public static DomainEventHandlerRegistry getHandlerRegistry(){
        return THREAD_LOCAL.get();
    }
}

將事件暫存在實(shí)體 是比較推薦的方法产弹,具有很大的靈活性派歌。

public class Account extends JpaAggregate {
    public void enable(){
        AccountEnabledEvent event = new AccountEnabledEvent(this);
        registerEvent(event);
    }
}

registerEvent 方法在 AbstractAggregate 類(lèi)中,將 Event 暫存到 events 中痰哨。

@MappedSuperclass
public abstract class AbstractAggregate<ID> extends AbstractEntity<ID> implements Aggregate<ID> {
    private static final Logger LOGGER = LoggerFactory.getLogger(AbstractAggregate.class);

    @JsonIgnore
    @QueryTransient
    @Transient
    @org.springframework.data.annotation.Transient
    private final transient List<DomainEventItem> events = Lists.newArrayList();

    protected void registerEvent(DomainEvent event) {
        events.add(new DomainEventItem(event));
    }

    protected void registerEvent(Supplier<DomainEvent> eventSupplier) {
        this.events.add(new DomainEventItem(eventSupplier));
    }

    @Override
    @JsonIgnore
    public List<DomainEvent> getEvents() {
        return Collections.unmodifiableList(events.stream()
                .map(eventSupplier -> eventSupplier.getEvent())
                .collect(Collectors.toList()));
    }

    @Override
    public void cleanEvents() {
        events.clear();
    }


    private class DomainEventItem {
        DomainEventItem(DomainEvent event) {
            Preconditions.checkArgument(event != null);
            this.domainEvent = event;
        }

        DomainEventItem(Supplier<DomainEvent> supplier) {
            Preconditions.checkArgument(supplier != null);
            this.domainEventSupplier = supplier;
        }

        private DomainEvent domainEvent;
        private Supplier<DomainEvent> domainEventSupplier;

        public DomainEvent getEvent() {
            if (domainEvent != null) {
                return domainEvent;
            }
            DomainEvent event = this.domainEventSupplier != null ? this.domainEventSupplier.get() : null;
            domainEvent = event;
            return domainEvent;
        }
    }
}

完成暫存后胶果,在成功持久化后,進(jìn)行事件發(fā)布斤斧。

// 持久化實(shí)體
this.aggregateRepository.save(a);
if (this.eventPublisher != null){
    // 對(duì)實(shí)體中保存的事件進(jìn)行發(fā)布
    this.eventPublisher.publishAll(a.getEvents());
    // 清理事件
    a.cleanEvents();
}
2.2.5 變化跟蹤

跟蹤變化最實(shí)用的方法是領(lǐng)域事件和事件存儲(chǔ)早抠。當(dāng)命令操作執(zhí)行完成后,系統(tǒng)發(fā)出領(lǐng)域事件撬讽。事件的訂閱者可以接收發(fā)生在模型上的事件蕊连,在接收事件后,訂閱方將事件保存在事件存儲(chǔ)中锐秦。

變化跟蹤咪奖,通常與事件存儲(chǔ)一并使用盗忱,稍后詳解酱床。

2.3 實(shí)體驗(yàn)證

除了身份標(biāo)識(shí)外,使用實(shí)體的一個(gè)重要需求是保證他們是自驗(yàn)證趟佃,并總是有效的扇谣。盡管實(shí)體具有生命周期昧捷,其狀態(tài)不斷變化,我們需要保證在整個(gè)變化過(guò)程中罐寨,實(shí)體總是有效的靡挥。

驗(yàn)證的主要目的在于檢查實(shí)體的正確性,檢查對(duì)象可以是某個(gè)屬性鸯绿,也可以是整個(gè)對(duì)象跋破,甚至是多個(gè)對(duì)象的組合。

即便領(lǐng)域?qū)ο蟮母鱾€(gè)屬性都是合法的瓶蝴,也不能表示該對(duì)象作為一個(gè)整體是合法的毒返;同樣,單個(gè)對(duì)象合法也并不能保證對(duì)象組合是合法的舷手。

2.3.1 屬性合法性驗(yàn)證

可以使用自封裝來(lái)驗(yàn)證屬性拧簸。

自封裝性要求無(wú)論以哪種方式訪問(wèn)數(shù)據(jù),即使從對(duì)象內(nèi)部訪問(wèn)數(shù)據(jù)男窟,都必須通過(guò) getter 和 setter 方法盆赤。
一般情況下,我們可以在 setter 方法中歉眷,對(duì)屬性進(jìn)行合法性驗(yàn)證牺六,比如是否為空、字符長(zhǎng)度是否符合要求姥芥、郵箱格式是否正確等兔乞。

@Entity
public class Person extends JpaAggregate {
    private String name;
    private Date birthDay;

    public Person(){

    }

    public Person(String name, Date birthDay) {
        setName(name);
        setBirthDay(birthDay);
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        // 對(duì)輸入?yún)?shù)進(jìn)行驗(yàn)證
        Preconditions.checkArgument(StringUtils.isNotEmpty(name));
        this.name = name;
    }

    public Date getBirthDay() {
        return birthDay;
    }

    public void setBirthDay(Date birthDay) {
        // 對(duì)輸入?yún)?shù)進(jìn)行驗(yàn)證
        Preconditions.checkArgument(birthDay != null);
        this.birthDay = birthDay;
    }
}

在構(gòu)造函數(shù)中胶征,我也仍需調(diào)用 setter 方法完成屬性賦值客情。

2.3.2 驗(yàn)證整個(gè)對(duì)象

要驗(yàn)證整個(gè)實(shí)體,我們需要訪問(wèn)整個(gè)對(duì)象的狀態(tài)----所有對(duì)象屬性涝缝。

驗(yàn)證整個(gè)對(duì)象台囱,主要用于保證實(shí)體滿足不變性條件淡溯。不變條件來(lái)源于明確的業(yè)務(wù)規(guī)則,往往需要獲取對(duì)象的整個(gè)狀態(tài)以完成驗(yàn)證簿训。

  • 延遲驗(yàn)證 就是一種到最后一刻才進(jìn)行驗(yàn)證的方法咱娶。
  • 驗(yàn)證過(guò)程應(yīng)該收集所有的驗(yàn)證結(jié)果,而不是在一開(kāi)始遇到非法狀態(tài)就拋出異常强品。
  • 當(dāng)發(fā)現(xiàn)非法狀態(tài)時(shí)膘侮,驗(yàn)證類(lèi)將通知客戶方或記錄下驗(yàn)證結(jié)果以便稍后使用。
@Entity
public class Person extends JpaAggregate {
    private String name;
    private Date birthDay;
    @Override
    public void validate(ValidationHandler handler){
        if (StringUtils.isEmpty(getName())){
            handler.handleError("Name can not be empty");
        }
        if (getBirthDay() == null){
            handler.handleError("BirthDay can not be null");
        }
    }
}

其中 ValidationHandler 用于收集所有的驗(yàn)證信息的榛。

public interface ValidationHandler {
    void handleError(String msg);
}

有時(shí)候琼了,驗(yàn)證邏輯比領(lǐng)域?qū)ο蟊旧淼淖兓€快,將驗(yàn)證邏輯嵌入在領(lǐng)域?qū)ο笾袝?huì)使領(lǐng)域?qū)ο蟪袚?dān)太多的職責(zé)。此時(shí)雕薪,我們可以創(chuàng)建一個(gè)單獨(dú)的組件來(lái)完成模型驗(yàn)證昧诱。在 Java 中設(shè)計(jì)單獨(dú)的驗(yàn)證類(lèi)時(shí),我們可以將該類(lèi)放在和實(shí)體同樣的包中所袁,將屬性的 getter 方法生命為包級(jí)別盏档,這樣驗(yàn)證類(lèi)便能訪問(wèn)這些屬性了。

假如燥爷,我們不想將驗(yàn)證邏輯全部放在 Person 實(shí)體中蜈亩。可以新建 PersonValidator

public class PersonValidator implements Validator {
    private final Person person;

    public PersonValidator(Person person) {
        this.person = person;
    }

    @Override
    public void validate(ValidationHandler handler) {
        if (StringUtils.isEmpty(this.person.getName())){
            handler.handleError("Name can not be empty");
        }
        if (this.person.getBirthDay() == null){
            handler.handleError("BirthDay can not be null");
        }
    }
}

然后前翎,在 Person 中調(diào)用 PersonValidator:

@Entity
public class Person extends JpaAggregate {
    private String name;
    private Date birthDay;

    @Override
    public void validate(ValidationHandler handler){
        new PersonValidator(this).validate(handler);
    }
}

這樣將最大限度的避免 Person 的臃腫勺拣。

2.3.3 驗(yàn)證對(duì)象組合

相比之下,驗(yàn)證對(duì)象組合會(huì)復(fù)雜很多鱼填,也比較少見(jiàn)药有。最常用的方式是把這種驗(yàn)證過(guò)程創(chuàng)建成一個(gè)領(lǐng)域服務(wù)。

領(lǐng)域服務(wù)苹丸,我們稍后詳解愤惰。

2.4 關(guān)注行為,而非數(shù)據(jù)

實(shí)體應(yīng)該面向行為赘理,這意味著實(shí)體應(yīng)該公開(kāi)領(lǐng)域行為宦言,而不是公開(kāi)狀態(tài)。

專(zhuān)注于實(shí)體行為非常重要商模,它使得領(lǐng)域模型更具表現(xiàn)力奠旺。通過(guò)對(duì)象的封裝特性,其狀態(tài)只能被封裝它的實(shí)例進(jìn)行操作施流,這意味著任何修改狀態(tài)的行為都屬于實(shí)體响疚。

專(zhuān)注于實(shí)體行為,需要謹(jǐn)慎公開(kāi) setter 和 getter 方法瞪醋。特別是 setter 方法忿晕,一旦公開(kāi)將使?fàn)顟B(tài)更改直接暴露給用戶,從而繞過(guò)領(lǐng)域概念直接對(duì)狀態(tài)進(jìn)行更新银受。

典型的還是 News 上下線案例践盼。

@Entity
@Data
public class News {
    @Setter(AccessLevel.PRIVATE)
    private NewsStatus status;

    public void online(){
        setStatus(NewsStatus.ONLINE);
    }

    public void offline(){
        setStatus(NewsStatus.OFFLINE);
    }

    /**
     * 直接的 setter 無(wú)法表達(dá)業(yè)務(wù)含義
     * @param status
     */
    private void setStatus(NewsStatus status){
        this.status = status;
    }
}

2.5 實(shí)體創(chuàng)建

當(dāng)我們新建一個(gè)實(shí)體時(shí),希望通過(guò)構(gòu)造函數(shù)來(lái)初始化足夠多的狀態(tài)宾巍。這樣咕幻,一方面有助于表明該實(shí)體的身份,另一方面可以幫助客戶端更容易的查找該實(shí)體顶霞。

2.5.1 構(gòu)造函數(shù)

如果實(shí)體的不變條件要求該實(shí)體所包含的對(duì)象不能為 null肄程,或者由其他狀態(tài)計(jì)算所得,那么這些狀態(tài)需要作為參數(shù)傳遞給構(gòu)造函數(shù)。構(gòu)造函數(shù)對(duì)實(shí)體變量賦值時(shí)绷耍,它把操作委派給實(shí)例變量的 setter 方法,這樣便保證了實(shí)體變量的自封裝性鲜侥。

見(jiàn) Person 實(shí)例褂始,將無(wú)參構(gòu)造函數(shù)設(shè)為 private,以服務(wù)于框架描函;通過(guò) public 暴露所有參數(shù)的構(gòu)造函數(shù)崎苗,并調(diào)用 setter 方法對(duì)實(shí)體有效性進(jìn)行驗(yàn)證。

@Entity
public class Person extends JpaAggregate {
    private String name;
    private Date birthDay;

    private Person(){

    }

    public Person(String name, Date birthDay) {
        setName(name);
        setBirthDay(birthDay);
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        // 對(duì)輸入?yún)?shù)進(jìn)行驗(yàn)證
        Preconditions.checkArgument(StringUtils.isNotEmpty(name));
        this.name = name;
    }

    public Date getBirthDay() {
        return birthDay;
    }

    public void setBirthDay(Date birthDay) {
        // 對(duì)輸入?yún)?shù)進(jìn)行驗(yàn)證
        Preconditions.checkArgument(birthDay != null);
        this.birthDay = birthDay;
    }
}
2.5.2 靜態(tài)方法

對(duì)于使用一個(gè)實(shí)體承載多個(gè)類(lèi)型的場(chǎng)景舀寓,我們可以使用實(shí)體上的靜態(tài)方法胆数,對(duì)不同類(lèi)型進(jìn)行不同構(gòu)建。

@Setter(AccessLevel.PRIVATE)
@Entity
public class BaseUser extends JpaAggregate {
    private UserType type;
    private String name;

    private BaseUser(){

    }

    public static BaseUser createTeacher(String name){
        BaseUser baseUser = new BaseUser();
        baseUser.setType(UserType.TEACHER);
        baseUser.setName(name);
        return baseUser;
    }

    public static BaseUser createStudent(String name){
        BaseUser baseUser = new BaseUser();
        baseUser.setType(UserType.STUDENT);
        baseUser.setName(name);
        return baseUser;
    }

}

相對(duì)互墓,構(gòu)造函數(shù)必尼,靜態(tài)方法 createTeachercreateStudent 具有更多的業(yè)務(wù)含義。

2.5.3 工廠

對(duì)于那些非常復(fù)雜的創(chuàng)建實(shí)體的情況篡撵,我們可以使用工廠模式判莉。

這個(gè)不僅限于實(shí)體,對(duì)于復(fù)雜的實(shí)體育谬、值對(duì)象券盅、聚合都可應(yīng)用工廠。并且膛檀,此處所說(shuō)的工廠锰镀,也不僅限于工廠模式,也可以使用 Builder 模式咖刃∮韭總之,就是將復(fù)雜對(duì)象的創(chuàng)建與對(duì)象本身功能進(jìn)行分離嚎杨,從而完成對(duì)象的瘦身胡桃。

2.6 分布式設(shè)計(jì)

分布式系已經(jīng)成為新的標(biāo)準(zhǔn),我們需要在新標(biāo)準(zhǔn)下磕潮,思考對(duì)領(lǐng)域設(shè)計(jì)的影響翠胰。

2.6.1 不要分布單個(gè)實(shí)體

強(qiáng)烈建議不要分布單個(gè)實(shí)體。在本質(zhì)上自脯,這意味著一個(gè)實(shí)體應(yīng)該被限制成單個(gè)有界上下文內(nèi)部的單個(gè)領(lǐng)域模型中的單個(gè)類(lèi)(或一組類(lèi))之景。

假如,我們將單實(shí)體的不同部分分布在一個(gè)分布式系統(tǒng)之上膏潮。為了實(shí)現(xiàn)實(shí)體的一致性锻狗,可能需要全局事務(wù)保障,大大增加了系統(tǒng)的復(fù)雜度。要加載這個(gè)實(shí)體的話轻纪,查詢多個(gè)不同系統(tǒng)也是一種必然油额。分布式系統(tǒng)中的網(wǎng)絡(luò)開(kāi)銷(xiāo)將會(huì)放大,從而導(dǎo)致嚴(yán)重的性能問(wèn)題刻帚。

單實(shí)體分布式部署

上圖潦嘶,將 OrderItem 和 ProductInfo 與 Order 進(jìn)行分布式部署,在獲取 Oder 時(shí)會(huì)導(dǎo)致大量的 RPC 調(diào)用崇众,降低系統(tǒng)性能掂僵。

正確的部分方案為:

image
2.6.2 可以分布多個(gè)實(shí)體

對(duì)于多個(gè)實(shí)體間,進(jìn)行分布式部署顷歌,可以將壓力進(jìn)行分散锰蓬,大大增加系統(tǒng)性能。

分布多個(gè)實(shí)體

這種部署方式是推薦方式眯漩。

3 實(shí)體建模模式

建模模式有利于提升實(shí)體的表達(dá)性和可維護(hù)性芹扭。

3.1 妥善處理唯一標(biāo)識(shí)

唯一標(biāo)識(shí)是實(shí)體的身份,在完成分配后赦抖,絕對(duì)不允許修改冯勉。

對(duì)于程序生成:

@Data
public class Book {
    private ISBN id;

    private Book(){
        
    }

    public Book(ISBN isbn){
        this.setId(isbn);
    }

    public ISBN getId(){
        return this.id;
    }

    private void setId(ISBN id){
        Preconditions.checkArgument(id != null);
        this.id = id;
    }
}

由構(gòu)造函數(shù)傳入 id,并將 setter 方法設(shè)置為私有摹芙,以避免被改變灼狰。

對(duì)于持久化生成:

@Data
@MappedSuperclass
public abstract class JpaAggregate extends AbstractAggregate<Long> {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Setter(AccessLevel.PRIVATE)
    @Column(name = "id")
    private Long id;


    @Override
    public Long getId() {
        return id;
    }
}

使用 private 屬性和 setter 方法,避免被修改浮禾。同時(shí)提供 public 的 getter 方法交胚,用于獲取生成的 id。

3.2 使用 Specification 進(jìn)行規(guī)格建模

Specification 也稱(chēng)規(guī)格模式盈电,主要針對(duì)領(lǐng)域模型中的描述規(guī)格進(jìn)行建模蝴簇。

規(guī)范模式是一種軟件設(shè)計(jì)模式,可用于封裝定義所需對(duì)象狀態(tài)的業(yè)務(wù)規(guī)則匆帚。這是一種非常強(qiáng)大的方法熬词,可以減少耦合并提高可擴(kuò)展性,以選擇與特定條件匹配的對(duì)象子集吸重。這些規(guī)格可以使用邏輯運(yùn)算符組合互拾,從而形成復(fù)合規(guī)范。

規(guī)格 Specification 模式是將一段領(lǐng)域知識(shí)封裝到一個(gè)單元中嚎幸,稱(chēng)為規(guī)格颜矿。然后,在不同的場(chǎng)景中重用嫉晶。主要有三種這樣的場(chǎng)景:

  • 數(shù)據(jù)檢索 是從存儲(chǔ)中獲取數(shù)據(jù)骑疆,查找與規(guī)范匹配的記錄田篇。
  • 內(nèi)存中驗(yàn)證 是指檢查某個(gè)對(duì)象是否符合規(guī)格描述。
  • 創(chuàng)建新對(duì)象的場(chǎng)景非常罕見(jiàn)箍铭,我們暫且忽略泊柬。

這很有用,因?yàn)樗试S你避免域知識(shí)重復(fù)诈火。當(dāng)向用戶顯示數(shù)據(jù)時(shí)兽赁,相同的規(guī)格類(lèi)可用于驗(yàn)證傳入數(shù)據(jù)和從數(shù)據(jù)庫(kù)中過(guò)濾數(shù)據(jù)。

在了解完 Specification 的特征 后柄瑰,我們需要一個(gè)框架,它提供了 Specification 相關(guān) API剪况,既能從存儲(chǔ)中檢索數(shù)據(jù)教沾,也能對(duì)內(nèi)存對(duì)象進(jìn)行驗(yàn)證。

在這译断,我們使用 Querydsl 進(jìn)行構(gòu)建授翻。

一個(gè) News 實(shí)體,存在兩種狀態(tài)孙咪,一個(gè)是用戶自己設(shè)置的 NewsStatus堪唐,用于標(biāo)記當(dāng)前是上線還是下線狀態(tài);一個(gè)是管理員設(shè)置的 NewsAuditStatus翎蹈,用于標(biāo)記當(dāng)前是審核通過(guò)還是審核拒絕狀態(tài)淮菠。只有在用戶設(shè)置為上線同時(shí)管理員審核通過(guò),該 News 才可顯示荤堪。

首先合陵,我們先定義規(guī)則。

public class NewsPredicates {

    /**
     * 獲取可顯示規(guī)則
     * @return
     */
    public static PredicateWrapper<News> display(){
        return new Display();
    }

    /**
     * 可顯示規(guī)則
     */
    static class Display extends AbstractPredicateWrapper<News>{

        protected Display() {
            super(QNews.news);
        }

        @Override
        public Predicate getPredicate() {
            Predicate online = QNews.news.status.eq(NewsStatus.ONLINE);
            Predicate passed = QNews.news.auditStatus.eq(NewsAuditStatus.PAASED);

            return new BooleanBuilder()
                    .and(online)
                    .and(passed);
        }
    }
}

該規(guī)則可以應(yīng)用于內(nèi)存對(duì)象澄阳。


@Entity
@Data
@QueryEntity
public class News {
    @Setter(AccessLevel.PRIVATE)
    private NewsAuditStatus auditStatus;

    @Setter(AccessLevel.PRIVATE)
    private NewsStatus status;

    /**
     * 判斷是否是可顯示的
     * @return
     */
    public boolean isDisplay(){
        return NewsPredicates.display().accept(this);
    }
}

同時(shí)拥知,該規(guī)則也可以用于數(shù)據(jù)檢索。

public interface NewsRepository extends Repository<News, Long>, QuerydslPredicateExecutor<News> {

    /**
     * 查找可顯示的信息
     * @param pageable
     * @return
     */
    default Page<News> getDispaly(Pageable pageable){
        return findAll(NewsPredicates.display().getPredicate(), pageable);
    }
}

可顯示規(guī)則全部封裝于 NewsPredicates 中碎赢,如果規(guī)則發(fā)生變化低剔,只需對(duì) NewsPredicates 進(jìn)行調(diào)整即可。

3.3 使用 Enum 簡(jiǎn)化狀態(tài)模式

實(shí)體擁有自己的生命周期肮塞,往往會(huì)涉及狀態(tài)管理襟齿。對(duì)狀態(tài)建模是實(shí)體建模的重要部分。

管理實(shí)體狀態(tài)枕赵,狀態(tài)設(shè)計(jì)模式具有很大的誘惑蕊唐。

比如一個(gè)簡(jiǎn)單的審核流程。

graph TB
已提交--通過(guò)-->審核通過(guò)
已提交--修改-->已提交
已提交--拒絕-->審核拒絕
審核拒絕--修改-->已提交

使用狀態(tài)模式如下:

首先烁设,定義狀態(tài)接口替梨。

public interface AuditStatus {
    AuditStatus pass();
    AuditStatus reject();
    AuditStatus edit();
}

該接口中包含所有操作钓试。然后,定義異常類(lèi)副瀑。

public class StatusNotSupportedException extends RuntimeException{
}

在當(dāng)前狀態(tài)不允許執(zhí)行某些操作時(shí)弓熏,直接拋出異常,以中斷流程糠睡。然后挽鞠,定義各個(gè)狀態(tài)類(lèi),如下:

SubmittedStatus

public class SubmittedStatus implements AuditStatus{
    @Override
    public AuditStatus pass() {
        return new PassedStatus();
    }

    @Override
    public AuditStatus reject() {
        return new RejectedStatus();
    }

    @Override
    public AuditStatus edit() {
        return new SubmittedStatus();
    }
}

PassedStatus

public class PassedStatus implements AuditStatus{
    @Override
    public AuditStatus pass() {
        throw new StatusNotSupportedException();
    }

    @Override
    public AuditStatus reject() {
        throw new StatusNotSupportedException();
    }

    @Override
    public AuditStatus edit() {
        throw new StatusNotSupportedException();
    }
}

RejectedStatus

public class RejectedStatus implements AuditStatus{
    @Override
    public AuditStatus pass() {
        throw new StatusNotSupportedException();
    }

    @Override
    public AuditStatus reject() {
        throw new StatusNotSupportedException();
    }

    @Override
    public AuditStatus edit() {
        return new SubmittedStatus();
    }
}

但狈孔,狀態(tài)模式導(dǎo)致大量的模板代碼信认,對(duì)于簡(jiǎn)單業(yè)務(wù)場(chǎng)景顯得有些冗余。同時(shí)太多的狀態(tài)類(lèi)為持久化造成了不少麻煩均抽。此時(shí)嫁赏,我們可以使用 Enum 對(duì)其進(jìn)行簡(jiǎn)化。

public enum AuditStatusEnum {
    SUBMITED(){
        @Override
        public AuditStatusEnum pass() {
            return PASSED;
        }

        @Override
        public AuditStatusEnum reject() {
            return REJECTED;
        }

        @Override
        public AuditStatusEnum edit() {
            return SUBMITED;
        }
    },
    PASSED(){

    },
    REJECTED(){
        @Override
        public AuditStatusEnum edit() {
            return SUBMITED;
        }
    };

    public AuditStatusEnum pass(){
        throw new StatusNotSupportedException();
    }

    public AuditStatusEnum reject(){
        throw new StatusNotSupportedException();
    }

    public AuditStatusEnum edit(){
        throw new StatusNotSupportedException();
    }
}

AuditStatusEnum 與 之前的狀態(tài)模式功能完全一致油挥,但代碼要緊湊的多潦蝇。

另外,使用顯示建模也是一種解決方案深寥。這種方式會(huì)為每個(gè)狀態(tài)創(chuàng)建一個(gè)類(lèi)攘乒,通過(guò)類(lèi)型檢測(cè)機(jī)制嚴(yán)格控制能操作的方法,但對(duì)于存儲(chǔ)有些不大友好惋鹅,在實(shí)際開(kāi)發(fā)中则酝,使用的不多。

3.4 使用業(yè)務(wù)方法和 DTO 替換 setter

之前提過(guò)闰集,實(shí)體不應(yīng)該繞過(guò)業(yè)務(wù)方法堤魁,直接使用 setter 對(duì)狀態(tài)進(jìn)行修改。

如果業(yè)務(wù)方法擁有過(guò)長(zhǎng)的參數(shù)列表返十,在使用上也會(huì)導(dǎo)致一定的混淆妥泉。最常見(jiàn)策略是,使用 DTO 對(duì)業(yè)務(wù)所需數(shù)據(jù)進(jìn)行傳遞洞坑,并在業(yè)務(wù)方法中調(diào)用 getter 方法獲取對(duì)于數(shù)據(jù)盲链。

@Entity
@Data
public class User {
    private String name;
    private String nickName;
    private Email email;
    private Mobile mobile;
    private Date birthDay;

    private String password;

    public boolean checkPassword(PasswordEncoder encoder, String pwd){
        return encoder.matches(pwd, password);
    }

    public void changePassword(PasswordEncoder encoder, String pwd){
        setPassword(encoder.encode(pwd));
    }

    public void update(String name, String nickName, Email email, Mobile mobile, Date birthDay){
        setName(name);
        setNickName(nickName);
        setEmail(email);
        setMobile(mobile);
        setBirthDay(birthDay);
    }

    public void update(UserDto userDto){
        setName(userDto.getName());
        setNickName(userDto.getNickName());
        setEmail(userDto.getEmail());
        setMobile(userDto.getMobile());
        setBirthDay(userDto.getBirthDay());
    }
}

3.5 使用備忘錄或 DTO 處理數(shù)據(jù)顯示

實(shí)體存儲(chǔ)的數(shù)據(jù),往往需要讀取出來(lái)迟杂,在 UI 中顯示刽沾,或被其他系統(tǒng)使用。

實(shí)體作為領(lǐng)域概念排拷,不允許脫離領(lǐng)域?qū)硬嗬欤?UI 中直接使用。此時(shí)监氢,我們需要使用備忘錄或 DTO 模式布蔗,將實(shí)體與數(shù)據(jù)解耦藤违。

3.6 避免副作用方法

方法的副作用,是指一個(gè)方法的執(zhí)行纵揍,如果在返回一個(gè)值之外還導(dǎo)致某些“狀態(tài)”發(fā)生變化顿乒,則稱(chēng)該方法產(chǎn)生了副作用。

根據(jù)副作用概念泽谨,我們可以提取出兩類(lèi)方法:

  • Query 方法 有返回值璧榄,但不改變內(nèi)部狀態(tài)。
  • Command 方法 沒(méi)有返回值吧雹,但會(huì)改變內(nèi)部狀態(tài)骨杂。

在實(shí)際開(kāi)發(fā)中,需要對(duì)兩者進(jìn)行嚴(yán)格區(qū)分雄卷。

在 Application 中搓蚪,Command 方法需要開(kāi)啟寫(xiě)事務(wù);Query 方法只需開(kāi)啟讀事務(wù)即可龙亲。

@Service
public class NewsApplication extends AbstractApplication {
    @Autowired
    private NewsRepository repository;

    @Transactional(readOnly = false)
    public Long createNews(String title, String content){
        return creatorFor(this.repository)
                .instance(()-> News.create(title, content))
                .call()
                .getId();
    }

    @Transactional(readOnly = false)
    public void online(Long id){
        updaterFor(this.repository)
                .id(id)
                .update(News::online)
                .call();
    }

    @Transactional(readOnly = false)
    public void offline(Long id){
        updaterFor(this.repository)
                .id(id)
                .update(News::offline)
                .call();
    }

    @Transactional(readOnly = false)
    public void reject(Long id){
        updaterFor(this.repository)
                .id(id)
                .update(News::reject)
                .call();
    }

    @Transactional(readOnly = false)
    public void pass(Long id){
        updaterFor(this.repository)
                .id(id)
                .update(News::pass)
                .call();
    }

    @Transactional(readOnly = true)
    public Optional<News> getById(Long id){
        return this.repository.getById(id);
    }

    @Transactional(readOnly = true)
    public Page<News> getDisplay(Pageable pageable){
        return this.repository.getDispaly(pageable);
    }
}

其中陕凹,有一個(gè)比較特殊的方法悍抑,創(chuàng)建方法鳄炉,由于采用的是數(shù)據(jù)庫(kù)生成主鍵策略,需要將生成的主鍵返回搜骡。

3.7 使用樂(lè)觀鎖進(jìn)行并發(fā)控制

實(shí)體主要職責(zé)是維護(hù)業(yè)務(wù)不變性拂盯,當(dāng)多個(gè)用戶同時(shí)修改一個(gè)實(shí)體時(shí),會(huì)將事情復(fù)雜化记靡,從而導(dǎo)致業(yè)務(wù)規(guī)則的破壞谈竿。

對(duì)此,需要在實(shí)體上使用樂(lè)觀鎖進(jìn)行并發(fā)控制摸吠,保障只有一個(gè)用戶更新成功空凸,從而保護(hù)業(yè)務(wù)不變性。

Jpa 框架自身便提供了對(duì)樂(lè)觀鎖的支持寸痢,只需添加 @Version 字段即可呀洲。

@Getter(AccessLevel.PUBLIC)
@MappedSuperclass
public abstract class AbstractEntity<ID> implements Entity<ID> {
    private static final Logger LOGGER = LoggerFactory.getLogger(AbstractEntity.class);

    @Version
    @Setter(AccessLevel.PRIVATE)
    @Column(name = "version", nullable = false)
    private int version;
}

4 總結(jié)

  • 實(shí)體是問(wèn)題域中具有唯一身份的領(lǐng)域概念。
  • 與值對(duì)象不同啼止,實(shí)體的相等性嚴(yán)格基于唯一標(biāo)識(shí)道逗。
  • 實(shí)體具有明確的生命周期。
  • 在實(shí)體生命周期中献烦,需要嚴(yán)格遵從業(yè)務(wù)不變性條件滓窍。
  • 應(yīng)該將實(shí)體定位為值對(duì)象的容器,把行為推到值對(duì)象和領(lǐng)域服務(wù)中巩那,從而避免實(shí)體的臃腫吏夯。
  • 實(shí)體可以提供屬性此蜈、對(duì)象、對(duì)象組等多種驗(yàn)證規(guī)則锦亦,從而保護(hù)業(yè)務(wù)舶替。
  • 實(shí)體的唯一標(biāo)識(shí),可以來(lái)自領(lǐng)域概念杠园、程序生成顾瞪、存儲(chǔ)生成等。
  • 規(guī)格模式是處理實(shí)體規(guī)則描述的一大利器抛蚁。
  • 樂(lè)觀鎖的使用陈醒,將大大減少并發(fā)導(dǎo)致的業(yè)務(wù)錯(cuò)誤。
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末瞧甩,一起剝皮案震驚了整個(gè)濱河市钉跷,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌肚逸,老刑警劉巖爷辙,帶你破解...
    沈念sama閱讀 221,198評(píng)論 6 514
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異朦促,居然都是意外死亡膝晾,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,334評(píng)論 3 398
  • 文/潘曉璐 我一進(jìn)店門(mén)务冕,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)血当,“玉大人,你說(shuō)我怎么就攤上這事禀忆‰瘢” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 167,643評(píng)論 0 360
  • 文/不壞的土叔 我叫張陵箩退,是天一觀的道長(zhǎng)离熏。 經(jīng)常有香客問(wèn)我,道長(zhǎng)戴涝,這世上最難降的妖魔是什么滋戳? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 59,495評(píng)論 1 296
  • 正文 為了忘掉前任,我火速辦了婚禮喊括,結(jié)果婚禮上胧瓜,老公的妹妹穿的比我還像新娘。我一直安慰自己郑什,他們只是感情好府喳,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,502評(píng)論 6 397
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著蘑拯,像睡著了一般钝满。 火紅的嫁衣襯著肌膚如雪兜粘。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 52,156評(píng)論 1 308
  • 那天弯蚜,我揣著相機(jī)與錄音孔轴,去河邊找鬼。 笑死碎捺,一個(gè)胖子當(dāng)著我的面吹牛路鹰,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播收厨,決...
    沈念sama閱讀 40,743評(píng)論 3 421
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼晋柱,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了诵叁?” 一聲冷哼從身側(cè)響起雁竞,我...
    開(kāi)封第一講書(shū)人閱讀 39,659評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎拧额,沒(méi)想到半個(gè)月后碑诉,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 46,200評(píng)論 1 319
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡侥锦,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,282評(píng)論 3 340
  • 正文 我和宋清朗相戀三年进栽,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片捎拯。...
    茶點(diǎn)故事閱讀 40,424評(píng)論 1 352
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡泪幌,死狀恐怖盲厌,靈堂內(nèi)的尸體忽然破棺而出署照,到底是詐尸還是另有隱情,我是刑警寧澤吗浩,帶...
    沈念sama閱讀 36,107評(píng)論 5 349
  • 正文 年R本政府宣布建芙,位于F島的核電站,受9級(jí)特大地震影響懂扼,放射性物質(zhì)發(fā)生泄漏禁荸。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,789評(píng)論 3 333
  • 文/蒙蒙 一阀湿、第九天 我趴在偏房一處隱蔽的房頂上張望赶熟。 院中可真熱鬧,春花似錦陷嘴、人聲如沸映砖。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 32,264評(píng)論 0 23
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)邑退。三九已至竹宋,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間地技,已是汗流浹背蜈七。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 33,390評(píng)論 1 271
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留莫矗,地道東北人飒硅。 一個(gè)月前我還...
    沈念sama閱讀 48,798評(píng)論 3 376
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像作谚,于是被迫代替她去往敵國(guó)和親狡相。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,435評(píng)論 2 359

推薦閱讀更多精彩內(nèi)容