在問(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í):
- 計(jì)算節(jié)點(diǎn)當(dāng)前時(shí)間脂新,以毫秒記;
- 計(jì)算節(jié)點(diǎn)的 IP 地址粗梭;
- 虛擬機(jī)中工廠對(duì)象實(shí)例的對(duì)象標(biāo)識(shí)争便;
- 虛擬機(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)題:
- 事件創(chuàng)建時(shí)热幔,需要知道持久化實(shí)體的 ID。
- 如果將實(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 不同,online 和 offline 具有明確的業(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)方法 createTeacher 和 createStudent 具有更多的業(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)題刻帚。
上圖潦嘶,將 OrderItem 和 ProductInfo 與 Order 進(jìn)行分布式部署,在獲取 Oder 時(shí)會(huì)導(dǎo)致大量的 RPC 調(diào)用崇众,降低系統(tǒng)性能掂僵。
正確的部分方案為:
2.6.2 可以分布多個(gè)實(shí)體
對(duì)于多個(gè)實(shí)體間,進(jìn)行分布式部署顷歌,可以將壓力進(jìn)行分散锰蓬,大大增加系統(tǒng)性能。
這種部署方式是推薦方式眯漩。
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ò)誤。