【DDD】業務建模實踐 —— 發布帖子


本文是基於上一篇‘業務建模戰術’的實踐,主要講解‘發表帖子’場景的業務建模,包括:業務建模、業務模型、示例代碼;示例代碼會使用java編寫,文末附有github地址。相比於《領域驅動設計》原書中的航運系統例子,社交服務系統的業務場景對於大家更加熟悉,相信更好理解。本文是【DDD】系列文章的其中一篇,其他可參考:使用領域驅動設計思想實現業務系統

Round-I

業務建模

  在大家的常識中,每個人都有自己的觀點,並可以發表自己的觀點,在社區中便表現為:發布帖子。那么誰發布帖子呢? 很明顯是帖子作者,於是我們便可以得到如下描述:帖子作者發布帖子。從上述描述中,我們很容易得到如下幾個實體及其行為:帖子作者(PostAuthor)、帖子(Post),而PostAuthor擁有‘發布帖子’的業務行為,我們記着:posting()。

  我們再細化現有的模型中的實體。作為實體,必須有一個唯一業務標識,我們為PostAuthor添加一個'作者編號'(authorId),為Post添加一個‘帖子編號’(postId);PostAuthor可能還存在其他一些屬性,我們暫且不管,因為對於我們現在的業務場景無關緊要;再看Post實體,很明顯帖子有‘帖子標題’(postTitle)、‘帖子正文’(postContent)、‘帖子發布時間’(postingTime)等等。同時,從業務上要求:帖子發帖時間即為帖子發布動作發生的時間點,帖子的作者是不能為空的,帖子的內容不能為空,帖子的標題可以為空。

      此外,為了減少垃圾帖對社區的負面影響,對於‘帖子內容'強制要求帖子內容長度必須不少於16個漢字,字母和數字等同於漢字處理。

  總結可以得到‘發布帖子’場景的業務模型描述:

  • 帖子作者發布帖子,帖子標題可以為空,帖子內容不能為空,且不少於16個漢字。 

  鑒於PostAuthor和Post關系緊密,我們姑且將其和Post放到同一個模塊,但是Post看上去並不是PostAuthor的根實體,有點怪怪的感覺,暫且不追究。

 

業務模型

  綜上,於是我們得到如下的業務模型:

  

代碼示例

  據此寫出簡單的示例代碼: 

 1 /**
 2  * 帖子實體
 3  * @author DAOQIDELV
 4  * @CreateDate 2017年9月16日
 5  *
 6  */
 7 public class Post {
 8     
 9     /**
10     * 帖子id
11     */
12     private long id;
13     /**
14      *帖子作者
15      */  
16     private long authorId;    
17     /**
18      * 帖子標題
19      */
20     private String title;
21     /**
22      * 帖子源內容
23      */
24     private String sourceContent;
25     /**
26      * 發帖時間
27      */
28     private Timestamp postingTime;
29     
30     public Post(long authorId, String title, String sourceContent) {
31         this.setAuthorId(authorId);
32         this.setTitle(title);
33         this.setSourceContent(sourceContent);
34         this.postingTime = new Timestamp(System.currentTimeMillis());
35     }
36     
37     /**
38      * @param authorId the authorId to set
39      */
40     public void setAuthorId(long authorId) {
41         Assert.isTrue(authorId > 0, "Post's authorId must greater than ZERO.");
42         this.authorId = authorId;
43     }
44     
45     /**
46      * @param sourceContent the sourceContent to set
47      */
48     public void setSourceContent(String sourceContent) {
49         Assert.isTrue(!StringUtils.isEmpty(sourceContent), "Post's sourceContent must NOT be empty.");
50         this.sourceContent = sourceContent;
51     }
52 
53         //ignore some setter/getter
54     
55 }
Post.java
 1 /**
 2  * 帖子作者
 3  * @author DAOQIDELV
 4  * @CreateDate 2017年9月16日
 5  *
 6  */
 7 public class PostAuthor {
 8     
 9     private long id;
10     
11     public PostAuthor(long id) {
12         this.id = id;
13     }
14     /**
15      * 發布帖子
16      * @param title
17      * @param sourceContent
18      * @return Post 發布得到的帖子
19      */
20     public Post posting(String title, String sourceContent) throws BusinessException {
21         if(sourceContent.length() < 16) {
22             throw new BusinessException(ExceptionCode.POST_SOURCE_CONTENT_AT_LEAST_SIXTEEN_WORDS);
23         }
24         Post post = new Post(this.id, title, sourceContent);
25         return post;
26     }
27         
28        //ignore some setter/getter
29 }
PostAuthor.java

 

構造方法

  在代碼示例中,我們為Post實體提供了一個私有的構造函數,其中只完成了postingTime的初始化,我們的目的是為了限制創建一個空內容的帖子,因為這在實際的業務場景中是不存在的,因此模型也不應當提供這樣的構造函數;

  另外,還提供了一個包含(authorId,title,sourceContent)的public構造函數,從當前的情形來看是夠用了,如果后續有業務場景需要更多的屬性傳入構造函數,則我們可以重載實現,但是通常來講,構造函數的入參最好不要超過五個,否則不利於代碼閱讀,當然特殊業務場景下除外;

  最后,在構造函數中,我們並沒有直接使用“this.title = title;”的寫法,而是使用了這種寫法:"this.setTitle(title);",好處在於我們希望Post實體成為一個“自治的實體”,簡單來講就是可以自我檢查 / 自我維護的實體,使用setter的方式,讓我們可以再setter方法中加入一些規則判定,使得外部傳入的參數滿足一定的規格,具體可以參考下一節中的解釋。這種處理方式被Martin Fowler稱之為“自封裝性”,參考了《實現領域驅動設計》P184。

setter方法

  接上一節中提到的“自治實體”,我們希望setter方法提供一種校驗機制,確保外部傳入的參數滿足實體的一定規格,也就是具體的業務邏輯。比如說Post實體中的setSourceContent(String sourceContent)方法,業務規則要求:帖子的正文不能為空,因此在Post.setSourceContent(sourceContent)中加入了Assert斷言,確保這一規則被強制實現。

  參考自Eric Evans的《領域驅動設計》P154。

posting方法

  PostAuthor實體現在有了一個業務方法:posting(),它接受具體帖子信息屬性,最后返回一個Post實體,很明顯PostAuthor現在作為一個Post實體的factory存在。

  posting方法對外聲明會throw出一個BusinessException異常,BusinessException異常是自定義的業務異常大類,該異常會統一交由框架統一異常處理,這樣確保domain領域的干凈。posting方法中判定“帖子內容小於16個字”時,便會向上層拋出異常碼為2000的BusinessException,

Round-II

業務建模

  我們繼續往下探尋業務實體。在討論中,我們發現發布帖子時,通常需要選擇關聯哪些話題,且一次性可以關聯多個話題,最多可以關聯五個話題,且話題不能重復加入。我們嘗試用一句話將上述模型表述出來:

  • 帖子可以加入不多於五個話題,且話題不能重復加入。

  這樣就引入了一個新的實體:話題(Topic),話題持有唯一業務標識,話題應當擁有這些常見的屬性:話題名稱(title)、話題簡介(description)、話題創建者(authorId)、話題創建時間(creatingTime)等;其中,話題名稱、話題內容和話題創建者均不能為空。由於這些屬性和‘創建話題’的case相關,在這里我們不做過多討論。

  那么Post和Topic的關系是什么呢?肯定不是“聚合”的關系,因為即使Topic消失了,但是Post還在;也不會是“組合”關系,Topic並不是由許多的Post組合而成的;正如業務建模中的描述:Post關聯多個話題,所以Post和Topic之間是1:N的關聯關系。那么我們是否應當讓Post持有多個Topic呢?顯然太重了,Topic是一個實體,實體的狀態/屬性值是會發生改變的,Topic狀態發生改變就會引起Post實體內容改變,因此,我們可以有如下兩種處理方式:

  1. 引入一個值對象TopicId,讓Post關聯多個TopicId,這個值對象只有一個屬性值:topicId,表征Topic的唯一標識值;這是《實現領域驅動設計》中的實現方式;
  2. 引入一個值對象TopicPost,表征“話題相關的帖子”,這個值對象可以有更多的屬性值。

  對比下來,方案2擴展性會更好,且表達能力更強,同時TopicPost這個值對象還可以應用到Topic實體中,在Topic相關的業務case中,會有找到一個Topic下關聯的帖子列表的場景,這時候會發現TopicPost便大有用處了。

  那么TopicPost和Post之間還是關聯關系嗎?顯然不是。一個Post被刪除,那么與其相關的TopicPost值對象也就不復存在了,因此Post持有多個TopicPost,他們是組合關系。

  那么TopicPost是屬於Topic模塊(module)還是屬於Post模塊,很簡單,它以“Topic”打頭,因此我們將TopicPost放入Topic模塊中。

  再來看業務行為,我們將“帖子加入話題”這個業務行為命名為joinTopics(String topics),字面上‘帖子’是主語,顯然joinTopics是Post的業務方法,在這個業務方法中,我們實現業務規則:帖子可以加入的話題數不能多於五個。

  上述關於TopicPost值對象引入的討論非常典型,在兩個實體存在關聯關系時,可以參考建模。

業務模型 

  綜合上面的討論,我們得到最新的業務模型如下:

 

示例代碼

  PostAuthor.java沒有變化,更改Post.java,新增Topic.java和TopicPost.java:

 

 1 public class Post {
 2       ......  
 3     /**
 4      * 將帖子關聯話題 
 5      * @param topicIds 話題集合
 6      */
 7     public void joinTopics(String topicIds) throws BusinessException{
 8         if(StringUtils.isEmpty(topicIds)) {
 9             return;
10         }
11         String[] topicIdArray = topicIds.split(CommonConstants.COMMA);
12         for(int i=0; i<topicIdArray.length; i++) {
13             TopicPost topicPost = new TopicPost(Long.valueOf(topicIdArray[i]), this.getId());
14             this.topics.add(topicPost);
15             if(topicSize() > MAX_JOINED_TOPICS_NUM) {
16                 throw new BusinessException(ExceptionCode.ONE_POST_MOST_JOIN_INTO_FIVE_TOPICS);
17             }
18         }
19     }
20     
21     /**
22      * 獲取本帖子加入的話題數,參考jdk collection的api設計
23      * @return 本帖子加入的話題數
24      */
25     public int topicSize() {
26         return this.topics.size();
27     }
28       ......
29 }
Post.java

 

 1 /**
 2  * @author DAOQIDELV
 3  * @CreateDate 2017年9月16日
 4  *
 5  */
 6 public class Topic {
 7     /**
 8      * 話題id
 9      */
10     private long id;
11     /**
12      * 話題標題
13      */
14     private String title;
15     /**
16      * 話題描述
17      */
18     private String description;
19     /**
20      * 話題創建時間
21      */
22     private Timestamp createTime;
23     /**
24      * 話題下的帖子列表
25      */
26     private Set<TopicPost> posts;
27 
28         ......
29 }
Topic.java
 1 /**
 2  * @author DAOQIDELV
 3  * @CreateDate 2017年9月16日
 4  *
 5  */
 6 public class TopicPost {
 7     
 8     private long postId;
 9     
10     private long topicId;
11     
12     public TopicPost(long topicId, long postId) {
13         this.setPostId(postId);
14         this.setTopicId(topicId);
15     }
16     
17     @Override
18     public boolean equals(Object anObject) {
19         if (this == anObject) {
20             return true;
21         }
22         if(anObject instanceof TopicPost) {
23             if(this.postId == ((TopicPost)anObject).getPostId()
24                     && this.topicId == ((TopicPost)anObject).getTopicId()) {
25                 return true;
26             }
27         } 
28         return false;    
29     }    
30     
31     @Override
32     public int hashCode() {
33         return Long.hashCode(this.postId+this.topicId);
34     }
35 
36 }
TopicPost.java

 

 組合關系集合化處理

  如前面業務建模分析,Post持有多個TopicPost,他們是組合關系。代碼中Post持有一個TopicPost類型的Set,最初這個topics屬性沒有被初始化,導致在每次用到它的時候都要去判空,代碼冗余度高,可讀性差,后面參考java集合的api設計,將topics這個Set直接在聲明時就完成初始化,后續使用就沒有后顧之憂了。這一個模式可以參考Martin Fowler的《重構》P208。

  ‘帖子加入話題’的場景只涉及到了topics集合的元素添加,所以,這里暫且定義了兩個api:joinTopics(), topicSize(),至於topics的查詢、刪除操作在后續的建模場景中會逐一涉及到。

改寫TopicPost的equals和hashCode方法

  一個帖子不能重復加入某個話題,因此我們使用了HashSet這種數據結構,HashSet中的元素是不能重復的,所以在向HashSet中add元素時,HashSet會判定元素是否相等(== || equals),默認的Object方法equals實現既是比較兩個對象是否相等(==),這會帶來一個問題:兩個在業務上表征同一個帖子加入同一個話題的TopicPost對象會被認為是不相等的兩個對象,因此我們需要重寫TopicPost的equals和hashCode方法,使之和業務模型保持一致。

  equals()的實現參考了java.lang.String的寫法;hashCode()的重寫最佳實踐參考了《重構》P185:讀取equals()使用的所有字段的hash碼,然后對他們進行按位異或操作。hashCode()的目標是保證在對該對象進行集合類api操作時,能保證較好的散列性,因此只要能達到這個目標就行,鑒於TopicPost中equals方法是用到的topicId和postId均為Long型,也可以簡單地這么實現:Long.hashCode(this.postId + this.topicId)。

Round-III

業務建模

  為確保社區帖子的質量,以及滿足國家法律法規的要求,我們需要對帖子標題和內容進行敏感詞、廣告語、色情、暴力等內容過濾,這些內容過濾即需要調用第三方服務完成,又需要經過社區系統自己積累的敏感詞和廣告詞過濾,對於帖子標題和帖子正文,只要其中一項被過濾服務發現異常,則該帖子就需要被列入待審核隊列,等待運營人員審核完成后才能對外發布。我們嘗試使用一句話來描述上述case:

  •   帖子標題和內容通過內容過濾后方能發布,如果未能通過內容過濾,則需要經過運營審核之后才能發布。

  可以看到上面場景中出現了一個新的短語“內容過濾”,它不是一個實體,看上去像是一個業務行為,是對帖子的標題和內容進行內容過濾,那么我們是不是直接放置到Post實體上呢? 由於這個內容過濾的行為比較復雜,且涉及到第三方過濾服務的調用,且對帖子標題和帖子內容的過濾邏輯類似,因此我們有必要將“內容過濾”從帖子實體中抽離出來,變成一個領域服務,我們將之命名為:ContentFilter。

  考慮到存在多場景多規則的過濾,且只要一個命中一個過濾規則,就可以認為該帖子審核不通過,因此可以使用‘責任鏈模式’來設計。關於責任鏈模式的實現和優缺點這里不再贅述,可參考網上資料,或直接看代碼即可。

  另外,我們發現上面的case描述中,還出現了‘內容過濾未通過’的字樣,表明Post需要有一個屬性來表征內容過濾的結果,考慮到可見case中存在帖子被用戶刪除等狀態,我們記為“帖子狀態”(staus),字典定義為:00 -- 已發布;01 -- 待運營審核;99 -- 已刪除。

  上面雖然已經創建了一個領域服務專門來承擔‘內容過濾’的職責,但是這個業務行為仍然應當屬於Post實體,因此我們為Post增加一個filt()業務行為,在這個業務行為中,會去調用“過濾標題”(filtTitle()) 和 “過濾正文”(filtMainBody())l兩個業務方法完成。而filtTitle()和filtMainBody()則會將具體的內容過濾工作委托給上面提到的責任鏈模式實現的領域服務ContentFilter。

  由於ContentFilter這一領域服務采用責任鏈模式實現,類較多,如果放置到post module下面,不利於閱讀,且考慮到后續的評論等場景可能使用到這個服務,故將其單獨建立module,取名為:domain.service.contentfilter。

業務模型

  綜上,我們可以得到如下業務模型:

 

示例代碼

主要在Post實體增加了status屬性,並增加業務行為方法filt(),增加了PostStatus這樣一個枚舉類,增加了責任鏈模式實現的contentFilter 領域服務組件,代碼摘要如下:

 1 public class Post {
 2     ......
 3     /**
 4      * 帖子狀態
 5      * NOTE:使用enum實現,限定status的字典值
 6      * @see com.dqdl.eco.domain.model.post.PostStatus
 7      */
 8     private PostStatus status;
 9 
10     /**
11      * 過濾帖子內容,內容過濾通過,則置status為‘00’,否則置為‘01’
12      * @param titleFilter 標題用內容過濾器
13      * @param mainBodyfilter 正文用內容過濾器
14      */
15     public void filt(PostTitleContentFilterChain titleFilter, PostMainBodyContentFilterChain mainBodyfilter) {
16         if(!this.filtTitle(titleFilter)) {
17             this.setStatus(PostStatus.WAIT_VERIFY);
18         } else if(!this.filtContent(mainBodyfilter)) {
19             this.setStatus(PostStatus.WAIT_VERIFY);
20         } else {
21             this.setStatus(PostStatus.HAS_POSTED);
22         }
23     }
24     
25     /**
26      * 過濾標題
27      * @param filter
28      * @return
29      */
30     private boolean filtTitle(PostTitleContentFilterChain filter) {        
31         if(StringUtils.isEmpty(this.getTitle())) {
32             return true;
33         }
34         return filter.filtTitle(this);        
35     }
36     
37     /**
38      * 過濾正文
39      * @param filter
40      * @return
41      */
42     private boolean filtContent(PostMainBodyContentFilterChain filter) {        
43         return filter.filtMainBody(this);            
44     }
45     ......
46 
47 }
Post.java
 1 /**
 2  * @author DAOQIDELV
 3  * @CreateDate 2017年9月17日
 4  * 帖子狀態枚舉類
 5  */
 6 public enum PostStatus {
 7     /**
 8      * 已發布
 9      */
10     HAS_POSTED("00","已發布"), 
11     
12     /**
13      * 等待運營審核
14      */
15     WAIT_VERIFY("01", "等待運營審核"), 
16     
17     /**
18      * 已刪除
19      */
20     HAS_DELETED("99", "已刪除"); 
21     
22     private String code;
23     
24     private String description;
25     
26     PostStatus(String code, String description) {
27         this.code = code;
28         this.description = description;
29     }    
30 
31     /**
32      * @return the code
33      */
34     public String getCode() {
35         return code;
36     }    
37 
38     /**
39      * @return the description
40      */
41     public String getDescription() {
42         return description;
43     }
44 
45     
46     /* (non-Javadoc)
47      * @see java.lang.Enum#toString()
48      */
49     @Override
50     public String toString() {
51         StringBuffer sb = new StringBuffer();
52         sb.append(super.toString())
53             .append(": code=").append(this.getCode())
54             .append(", decsription=").append(this.getDescription());
55         return sb.toString();
56     }
57 }
PostStatus.java

  PostStatus使用枚舉類的好處在於:‘狀態’的字典值是有限的,且要求狀態值必須是合法的值,因此使用枚舉類能夠達到上述強制限定的目的。

  Post對外公布的只有filt()這樣一個業務行為方法,filtTitle()和filtMainBody()這兩個業務行為方法設置為private,通常我們並沒有需求只過濾‘帖子標題’或者’帖子正文‘,所以沒有必要將這兩個方法單獨放出去。

 1 /**
 2  * @author DAOQIDELV
 3  * @CreateDate 2017年9月17日
 4  * 帖子正文內容過濾器 責任鏈。正文可能有圖片,故包含圖片過濾
 5  */
 6 public class PostMainBodyContentFilterChain {
 7     
 8     private Set<ContentFilter> contentFilters;
 9     
10     public PostMainBodyContentFilterChain() {
11         TextContentFilter localTextContentFilter = new LocalTextContentFilter();
12         TextContentFilter remoteTextContentFilter = new RemoteTextContentFilter();
13         ImageContentFilter imageContentFilter = new ImageContentFilter();
14         contentFilters.add(localTextContentFilter); //優先校驗本地的敏感詞
15         contentFilters.add(remoteTextContentFilter);
16         contentFilters.add(imageContentFilter);
17     }
18     
19     /**
20      * 過濾標題
21      * @param post
22      * @return
23      *  true —— 通過
24      *  false —— 未通過
25      */
26     public boolean filtMainBody(Post post) {
27         for(Iterator<ContentFilter> it = contentFilters.iterator(); it.hasNext();) {
28             if(!it.next().filtContent(post.getSourceContent())) {
29                 return false;
30             }
31         }
32         return true;
33     }
34 
35 }
PostMainBodyContentFilterChain.java
 1 /**
 2  * @author DAOQIDELV
 3  * @CreateDate 2017年9月17日
 4  * 帖子標題內容過濾器 責任鏈。標題沒有圖片,故不涉及圖片過濾
 5  */
 6 public class PostTitleContentFilterChain {
 7     
 8     private Set<ContentFilter> contentFilters;
 9     
10     public PostTitleContentFilterChain() {
11         TextContentFilter localTextContentFilter = new LocalTextContentFilter();
12         TextContentFilter remoteTextContentFilter = new RemoteTextContentFilter();
13         contentFilters.add(localTextContentFilter); //優先校驗本地的敏感詞
14         contentFilters.add(remoteTextContentFilter);
15     }
16     
17     /**
18      * 過濾標題
19      * @param post
20      * @return
21      *  true —— 通過
22      *  false —— 未通過
23      */
24     public boolean filtTitle(Post post) {
25         for(Iterator<ContentFilter> it = contentFilters.iterator(); it.hasNext();) {
26             if(!it.next().filtContent(post.getTitle())) {
27                 return false;
28             }
29         }
30         return true;
31     }
32 
33 }
PostTitleContentFilterChain.java
 1 /**
 2  * @author DAOQIDELV
 3  * @CreateDate 2017年9月17日
 4  * 內容過濾器抽象類
 5  */
 6 public abstract class ContentFilter {
 7     
 8     private ContentFilter nextContentFilter;
 9     
10     public abstract boolean filtContent(Object content);
11 
12     /**
13      * @return the nextContentFilter
14      */
15     public ContentFilter getNextContentFilter() {
16         return nextContentFilter;
17     }
18 
19     /**
20      * @param nextContentFilter the nextContentFilter to set
21      */
22     public void setNextContentFilter(ContentFilter nextContentFilter) {
23         this.nextContentFilter = nextContentFilter;
24     }
25 }
ContentFilter.java
 1 /**
 2  * @author DAOQIDELV
 3  * @CreateDate 2017年9月17日
 4  * 圖片過濾器
 5  */
 6 public class ImageContentFilter extends ContentFilter {
 7 
 8     /* (non-Javadoc)
 9      * @see com.dqdl.eco.domain.model.post.ContentFilter#filtContent(com.dqdl.eco.domain.model.post.Post)
10      */
11     @Override
12     public boolean filtContent(Object content) {
13         // TODO 調用第三方的圖片過濾服務實現,已經超過了domain的范疇,此處略去。后續單列文章講解如何和第三方服務交互。
14         return true;
15     }
16 }
ImageContentFilter.java
 1 /**
 2  * @author DAOQIDELV
 3  * @CreateDate 2017年9月17日
 4  * 文本過濾器抽象類
 5  */
 6 public abstract class TextContentFilter extends ContentFilter {
 7 
 8     /* (non-Javadoc)
 9      * @see com.dqdl.eco.domain.model.post.ContentFilter#filtContent(com.dqdl.eco.domain.model.post.Post)
10      */
11     @Override
12     public abstract boolean filtContent(Object content);
13 
14 }
TextContentFilter.java
 1 /**
 2  * @author DAOQIDELV
 3  * @CreateDate 2017年9月17日
 4  * 本地文本過濾器
 5  */
 6 public class LocalTextContentFilter extends TextContentFilter {
 7     /**
 8      * 本地敏感詞集合
 9      */
10     private Set<String> sensitiveWords = new HashSet<String>();
11     
12     public  LocalTextContentFilter() {
13         sensitiveWords.add("NND");
14         sensitiveWords.add("奶奶個熊");
15     }
16     
17 
18     /* (non-Javadoc)
19      * @see com.dqdl.eco.domain.model.post.TextContentFilter#filtContent(com.dqdl.eco.domain.model.post.Post)
20      */
21     @Override
22     public boolean filtContent(Object content) {
23         Assert.isTrue(content instanceof String, "LocalTextContentFilter filtContent's paramter must be String.");
24         for(String sensitiveWord : sensitiveWords) {
25             if(((String)content).contains(sensitiveWord)) {
26                 return false;
27             }
28         }
29         return true;
30     }
31 
32 }
LocalTextContentFilter.java
 1 /**
 2  * @author DAOQIDELV
 3  * @CreateDate 2017年9月17日
 4  * 遠程文本過濾器,需要調用第三方服務實現
 5  */
 6 public class RemoteTextContentFilter extends TextContentFilter {
 7 
 8     /* (non-Javadoc)
 9      * @see com.dqdl.eco.domain.model.post.TextContentFilter#filtContent(com.dqdl.eco.domain.model.post.Post)
10      */
11     @Override
12     public boolean filtContent(Object content) {
13         // TODO 調用第三方的圖片過濾服務實現,已經超過了domain的范疇,此處略去。后續單列文章講解如何和第三方服務交互。
14         return true;
15     }
16 
17 }
RemoteTextContentFilter.java

  注意到:在Post.filt(PostTitleContentFilterChain titleFilter, PostMainBodyContentFilterChain mainBodyfilter)中,將PostTitleContentFilterChain和PostMainBodyContentFilterChain 顯式傳入該方法,為什么不直接在Post定義這兩個FilterChain的實例變量呢?原因在於后續我們會將domain service作為@Compenent組件交給Spring ioc容器來管理,但是domain entity卻不會交給Spring管理,因此無法完成@AutoWire,所以使用方法參數傳入的方式表征了Post對兩個FilterChain的依賴關系。

  上述實例代碼中,ContentFilter這一領域服務組件,並沒有全部實現,對於依賴與第三方過濾服務的部分直接返回了true,這涉及到如何與第三方服務交互的問題,我們會在后續的blog中專題介紹。

Summarize

最終模型

匯總上面建模過程中對模型的描述,我們得到如下:

  • 帖子作者發布帖子,帖子標題可以為空,帖子內容不能為空,且不少於16個漢字;
  • 帖子可以加入不多於五個話題,且話題不能重復加入;
  • 帖子標題和內容通過內容過濾后方能發布,如果未能通過內容過濾,則需要經過運營審核之后才能發布。

業務模型圖可以參考Round-III中的模型圖。

建模經驗

嘗試用一句完整的話說描述業務場景

  仔細觀察,我們發現最終得到的業務模型描述其實和產品經理的PRD契合度非常高,但是產品經理的PRD描述很多適合缺少主語,或者語言分散,需要我們通過建模,將語言重新組織,找到業務實體,找到業務行為,找到業務規則,並反映在模型上。反過來想,我們的業務模型對於產品經理/業務專家來講也不會陌生,因為我們都是使用統一的業務模型語言進行描述,大家都能懂才對。

小步快走,逐步迭代

  ’發布帖子‘這個業務場景我們經歷了三輪迭代,最終得到了一個較完整的業務模型,每一輪迭代,我們都將焦點聚集在一個case,落實模型之后,再繼續往前推進。文章描述起來比較啰嗦,實際建模過程中,可能很快就能完成三輪迭代,得到較滿意的模型,但是實際的建模過程應當是和文章描述一樣的。

有用的模式

  1. 具有關聯關系的實體,可以為這種關聯關系引入一個值對象,從而降低實體間的耦合,比如:在Post和Topic之間引入了TopicPost值對象;
  2. 實體 / 值對象 之間存在組合 / 聚合關系,在設計對外的api時,可以參考jdk中集合的api設計,達到高內聚的目標,這在《重構》這本書中被稱為“封裝集合(Encapsulate Collection)”,比如:Post實體中的topics話題集便參考Set對外提供api;
  3. 配合第2點的實現,通常需要重寫equals()和hashCode()方法,最佳實踐可以參考示例代碼;
  4. 極力推薦使用合適的設計模式,提高代碼的擴展性和可維護性,比如:contentFilter領域服務組件便采用了“責任鏈模式”來實現;
  5. 當實體依賴於領域服務時,可以講領域服務作為實體業務方法的參數傳入,尤其是當領域服務被ioc容器管理時。比如:contentFilter領域服務組件作為Post的filt()方法的參數傳入。

源碼

  此業務建模的demo已上傳至github,歡迎下載和討論,但拒絕被用於任何商業用途。

  github地址:https://github.com/daoqidelv/community-ddd-demo/tree/posting

  branch:posting 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM