摘要:將DCI架構總結成一句話就是:領域物件(Object)在不同的場景(Context)中扮演(Cast)不同的角色(Role),角色之間通過互動(Interactive)來完成具體的業務邏輯。
本文分享自華為雲社群《實現DCI架構》,作者:元閏子 。
前言
在物件導向程式設計的理念裡,應用程式是對現實世界的抽象,我們經常會將現實中的事物建模為程式語言中的類/物件(“是什麼”),而事物的行為則建模為方法(“做什麼”)。物件導向程式設計有三大基本特性(封裝、繼承/組合、多型)和五大基本原則(單一職責原則、開放封閉原則、里氏替換原則、依賴倒置原則、介面分離原則),但知道這些還並不足以讓我們設計出好的程式,於是很多方法論就湧現了出來。
近來最火的當屬領域驅動設計(DDD),其中戰術建模提出的實體、值物件、聚合等建模方法,能夠很好的指導我們設計出符合現實世界的領域模型。但DDD也不是萬能的,在某些應用場景下,按照傳統的戰術建模/物件導向方法設計出來的程式,也會存在可維護性差、違反單一職責原則等問題。
本文介紹的DCI建模方法可以看成是戰術建模的一種輔助,在某些場景下,它可以很好的彌補DDD戰術建模的一些缺點。接下來,我們將會通過一個案例來介紹DCI是如何解決DDD戰術建模的這些缺點的。
本文涉及的程式碼歸檔在github專案:https://github.com/ruanrunxue/DCI-Architecture-Implementation
案例
考慮一個普通人的生活日常,他會在學校上課,也會趁著暑假去公司工作,在工作之餘去公園遊玩,也會像普通人一樣在家吃喝玩樂。當然,一個人的生活還遠不止這些,為了講解方便,本文只針對這幾個典型的場景進行建模示例。
使用DDD建模
按照DDD戰術建模的思路,首先,我們會列出該案例的通用語言:
人、身份證、銀行卡、家、吃飯、睡覺、玩遊戲、學校、學生卡、學習、考試、公司、工卡、上班、下班、公園、購票、遊玩
接著,我們使用戰術建模技術(值物件、實體、聚合、領域服務、資源庫)對通用語言進行領域建模。
DDD建模後的程式碼目錄結構如下:
- aggregate: 聚合 - company.go - home.go - park.go - school.go - entity: 實體 - people.go - vo: 值物件 - account.go - identity_card.go - student_card.go - work_card.go
我們將身份證、學生卡、工卡、銀行卡這幾個概念,建模為值物件(Value Object):
package vo // 身份證 type IdentityCard struct { Id uint32 Name string } // 學生卡 type StudentCard struct { Id uint32 Name string School string } // 工卡 type WorkCard struct { Id uint32 Name string Company string } // 銀行卡 type Account struct { Id uint32 Balance int } ...
接著我們將人建模成實體(Entity),他包含了身份證、學生卡等值物件,也具備吃飯、睡覺等行為:
package entity // 人 type People struct { vo.IdentityCard vo.StudentCard vo.WorkCard vo.Account } // 學習 func (p *People) Study() { fmt.Printf("Student %+v studying\n", p.StudentCard) } // 考試 func (p *People) Exam() { fmt.Printf("Student %+v examing\n", p.StudentCard) } // 吃飯 func (p *People) Eat() { fmt.Printf("%+v eating\n", p.IdentityCard) p.Account.Balance-- } // 睡覺 func (p *People) Sleep() { fmt.Printf("%+v sleeping\n", p.IdentityCard) } // 玩遊戲 func (p *People) PlayGame() { fmt.Printf("%+v playing game\n", p.IdentityCard) } // 上班 func (p *People) Work() { fmt.Printf("%+v working\n", p.WorkCard) p.Account.Balance++ } // 下班 func (p *People) OffWork() { fmt.Printf("%+v getting off work\n", p.WorkCard) } // 購票 func (p *People) BuyTicket() { fmt.Printf("%+v buying a ticket\n", p.IdentityCard) p.Account.Balance-- } // 遊玩 func (p *People) Enjoy() { fmt.Printf("%+v enjoying park scenery\n", p.IdentityCard) }
最後,我們將學校、公司、公園、家建模成聚合(Aggregate),聚合由一個或多個實體、值物件組合而成,組織它們完成具體的業務邏輯:
package aggregate // 家 type Home struct { me *entity.People } func (h *Home) ComeBack(p *entity.People) { fmt.Printf("%+v come back home\n", p.IdentityCard) h.me = p } // 執行Home的業務邏輯 func (h *Home) Run() { h.me.Eat() h.me.PlayGame() h.me.Sleep() } // 學校 type School struct { Name string students []*entity.People } func (s *School) Receive(student *entity.People) { student.StudentCard = vo.StudentCard{ Id: rand.Uint32(), Name: student.IdentityCard.Name, School: s.Name, } s.students = append(s.students, student) fmt.Printf("%s Receive stduent %+v\n", s.Name, student.StudentCard) } // 執行School的業務邏輯 func (s *School) Run() { fmt.Printf("%s start class\n", s.Name) for _, student := range s.students { student.Study() } fmt.Println("students start to eating") for _, student := range s.students { student.Eat() } fmt.Println("students start to exam") for _, student := range s.students { student.Exam() } fmt.Printf("%s finish class\n", s.Name) } // 公司 type Company struct { Name string workers []*entity.People } func (c *Company) Employ(worker *entity.People) { worker.WorkCard = vo.WorkCard{ Id: rand.Uint32(), Name: worker.IdentityCard.Name, Company: c.Name, } c.workers = append(c.workers, worker) fmt.Printf("%s Employ worker %s\n", c.Name, worker.WorkCard.Name) } // 執行Company的業務邏輯 func (c *Company) Run() { fmt.Printf("%s start work\n", c.Name) for _, worker := range c.workers { worker.Work() } fmt.Println("worker start to eating") for _, worker := range c.workers { worker.Eat() } fmt.Println("worker get off work") for _, worker := range c.workers { worker.OffWork() } fmt.Printf("%s finish work\n", c.Name) } // 公園 type Park struct { Name string enjoyers []*entity.People } func (p *Park) Welcome(enjoyer *entity.People) { fmt.Printf("%+v come to park %s\n", enjoyer.IdentityCard, p.Name) p.enjoyers = append(p.enjoyers, enjoyer) } // 執行Park的業務邏輯 func (p *Park) Run() { fmt.Printf("%s start to sell tickets\n", p.Name) for _, enjoyer := range p.enjoyers { enjoyer.BuyTicket() } fmt.Printf("%s start a show\n", p.Name) for _, enjoyer := range p.enjoyers { enjoyer.Enjoy() } fmt.Printf("show finish\n") }
那麼,根據上述方法建模出來的模型是這樣的:
模型的執行方法如下:
paul := entity.NewPeople("Paul") mit := aggregate.NewSchool("MIT") google := aggregate.NewCompany("Google") home := aggregate.NewHome() summerPalace := aggregate.NewPark("Summer Palace") // 上學 mit.Receive(paul) mit.Run() // 回家 home.ComeBack(paul) home.Run() // 工作 google.Employ(paul) google.Run() // 公園遊玩 summerPalace.Welcome(paul) summerPalace.Run()
貧血模型 VS 充血模型(工程派 VS 學院派)
上一節中,我們使用DDD的戰術建模完成了該案例領域模型。模型的核心是People實體,它有IdentityCard、StudentCard等資料屬性,也有Eat()、Study()、Work()等業務行為 ,非常符合現實世界中定義。這也是學院派所倡導的,同時擁有資料屬性和業務行為的充血模型。
然而,充血模型並非完美,它也有很多問題,比較典型的是這兩個:
問題一:上帝類
People這個實體包含了太多的職責,導致它變成了一個名副其實的上帝類。試想,這裡還是裁剪了很多“人”所包含的屬性和行為,如果要建模一個完整的模型,其屬性和方法之多,無法想象。上帝類違反了單一職責原則,會導致程式碼的可維護性變得極差。
問題二:模組間耦合
School與Company本應該是相互獨立的,School不必關注上班與否,Company也不必關注考試與否。但是現在因為它們都依賴了People這個實體,School可以呼叫與Company相關的Work()和OffWork()方法,反之亦然。這導致模組間產生了不必要的耦合,違反了介面隔離原則。
這些問題都是工程派不能接受的,從軟體工程的角度,它們會使得程式碼難以維護。解決這類問題的方法,比較常見的是對實體進行拆分,比如將實體的行為建模成領域服務,像這樣:
type People struct { vo.IdentityCard vo.StudentCard vo.WorkCard vo.Account } type StudentService struct{} func (s *StudentService) Study(p *entity.People) { fmt.Printf("Student %+v studying\n", p.StudentCard) } func (s *StudentService) Exam(p *entity.People) { fmt.Printf("Student %+v examing\n", p.StudentCard) } type WorkerService struct{} func (w *WorkerService) Work(p *entity.People) { fmt.Printf("%+v working\n", p.WorkCard) p.Account.Balance++ } func (w *WorkerService) OffWOrk(p *entity.People) { fmt.Printf("%+v getting off work\n", p.WorkCard) } // ...
這種建模方法,解決了上述兩個問題,但也變成了所謂的貧血模型:People變成了一個純粹的資料類,沒有任何業務行為。在人的心理上,這樣的模型並不能在建立起對現實世界的對應關係,不容易讓人理解,因此被學院派所抵制。
到目前為止,貧血模型和充血模型都有各有優缺點,工程派和學院派誰都無法說服對方。接下來,輪到本文的主角出場了。
DCI架構
DCI(Data,Context,Interactive)架構是一種物件導向的軟體架構模式,在《The DCI Architecture: A New Vision of Object-Oriented Programming》一文中被首次提出。與傳統的物件導向相比,DCI能更好地對資料和行為之間的關係進行建模,從而更容易被人理解。
- Data,也即資料/領域物件,用來描述系統“是什麼”,通常採用DDD中的戰術建模來識別當前模型的領域物件,等同於DDD分層架構中的領域層。
- Context,也即場景,可理解為是系統的Use Case,代表了系統的業務處理流程,等同於DDD分層架構中的應用層。
- Interactive,也即互動,是DCI相對於傳統物件導向的最大發展,它認為我們應該顯式地對領域物件(Object)在每個業務場景(Context)中扮演(Cast)的角色(Role)進行建模。Role代表了領域物件在業務場景中的業務行為(“做什麼”),Role之間通過互動完成完整的義務流程。
這種角色扮演的模型我們並不陌生,在現實的世界裡也是隨處可見,比如,一個演員可以在這部電影裡扮演英雄的角色,也可以在另一部電影裡扮演反派的角色。
DCI認為,對Role的建模應該是面向Context的,因為特定的業務行為只有在特定的業務場景下才會有意義。通過對Role的建模,我們就能夠將領域物件的方法拆分出去,從而避免了上帝類的出現。最後,領域物件通過組合或繼承的方式將Role整合起來,從而具備了扮演角色的能力。
DCI架構一方面通過角色扮演模型使得領域模型易於理解,另一方面通過“小類大物件”的手法避免了上帝類的問題,從而較好地解決了貧血模型和充血模型之爭。另外,將領域物件的行為根據Role拆分之後,模組更加的高內聚、低耦合了。
使用DCI建模
回到前面的案例,使用DCI的建模思路,我們可以將“人”的幾種行為按照不同的角色進行劃分。吃完、睡覺、玩遊戲,是作為人類角色的行為;學習、考試,是作為學生角色的行為;上班、下班,是作為員工角色的行為;購票、遊玩,則是作為遊玩者角色的行為。“人”在家這個場景中,充當的是人類的角色;在學校這個場景中,充當的是學生的角色;在公司這個場景中,充當的是員工的角色;在公園這個場景中,充當的是遊玩者的角色。
需要注意的是,學生、員工、遊玩者,這些角色都應該具備人類角色的行為,比如在學校裡,學生也需要吃飯。
最後,根據DCI建模出來的模型,應該是這樣的:
在DCI模型中,People不再是一個包含眾多屬性和方法的“上帝類”,這些屬性和方法被拆分到多個Role中實現,而People由這些Role組合而成。
另外,School與Company也不再耦合,School只引用了Student,不能呼叫與Company相關的Worker的Work()和OffWorker()方法。
程式碼實現DCI模型
DCI建模後的程式碼目錄結構如下;
- context: 場景 - company.go - home.go - park.go - school.go - object: 物件 - people.go - data: 資料 - account.go - identity_card.go - student_card.go - work_card.go - role: 角色 - enjoyer.go - human.go - student.go - worker.go
從程式碼目錄結構上看,DDD和DCI架構相差並不大,aggregate目錄演變成了context目錄;vo目錄演變成了data目錄;entity目錄則演變成了object和role目錄。
首先,我們實現基礎角色Human,Student、Worker、Enjoyer都需要組合它:
package role // 人類角色 type Human struct { data.IdentityCard data.Account } func (h *Human) Eat() { fmt.Printf("%+v eating\n", h.IdentityCard) h.Account.Balance-- } func (h *Human) Sleep() { fmt.Printf("%+v sleeping\n", h.IdentityCard) } func (h *Human) PlayGame() { fmt.Printf("%+v playing game\n", h.IdentityCard) }
接著,我們再實現其他角色,需要注意的是,Student、Worker、Enjoyer不能直接組合Human,否則People物件將會有4個Human子物件,與模型不符:
// 錯誤的實現 type Worker struct { Human } func (w *Worker) Work() { fmt.Printf("%+v working\n", w.WorkCard) w.Balance++ } ... type People struct { Human Student Worker Enjoyer } func main() { people := People{} fmt.Printf("People: %+v", people) } // 結果輸出, People中有4個Human: // People: {Human:{} Student:{Human:{}} Worker:{Human:{}} Enjoyer:{Human:{}}}
為解決該問題,我們引入了xxxTrait介面:
// 人類角色特徵 type HumanTrait interface { CastHuman() *Human } // 學生角色特徵 type StudentTrait interface { CastStudent() *Student } // 員工角色特徵 type WorkerTrait interface { CastWorker() *Worker } // 遊玩者角色特徵 type EnjoyerTrait interface { CastEnjoyer() *Enjoyer }
Student、Worker、Enjoyer組合HumanTrait,並通過Compose(HumanTrait)方法進行特徵注入,只要在注入的時候保證Human是同一個,就可以解決該問題了。
// 學生角色 type Student struct { // Student同時也是個普通人,因此組合了Human角色 HumanTrait data.StudentCard } // 注入人類角色特徵 func (s *Student) Compose(trait HumanTrait) { s.HumanTrait = trait } func (s *Student) Study() { fmt.Printf("Student %+v studying\n", s.StudentCard) } func (s *Student) Exam() { fmt.Printf("Student %+v examing\n", s.StudentCard) } // 員工角色 type Worker struct { // Worker同時也是個普通人,因此組合了Human角色 HumanTrait data.WorkCard } // 注入人類角色特徵 func (w *Worker) Compose(trait HumanTrait) { w.HumanTrait = trait } func (w *Worker) Work() { fmt.Printf("%+v working\n", w.WorkCard) w.CastHuman().Balance++ } func (w *Worker) OffWork() { fmt.Printf("%+v getting off work\n", w.WorkCard) } // 遊玩者角色 type Enjoyer struct { // Enjoyer同時也是個普通人,因此組合了Human角色 HumanTrait } // 注入人類角色特徵 func (e *Enjoyer) Compose(trait HumanTrait) { e.HumanTrait = trait } func (e *Enjoyer) BuyTicket() { fmt.Printf("%+v buying a ticket\n", e.CastHuman().IdentityCard) e.CastHuman().Balance-- } func (e *Enjoyer) Enjoy() { fmt.Printf("%+v enjoying scenery\n", e.CastHuman().IdentityCard) }
最後,實現People這一領域物件:
package object type People struct { // People物件扮演的角色 role.Human role.Student role.Worker role.Enjoyer } // People實現了HumanTrait、StudentTrait、WorkerTrait、EnjoyerTrait等特徵介面 func (p *People) CastHuman() *role.Human { return &p.Human } func (p *People) CastStudent() *role.Student { return &p.Student } func (p *People) CastWorker() *role.Worker { return &p.Worker } func (p *People) CastEnjoyer() *role.Enjoyer { return &p.Enjoyer } // People在初始化時,完成對角色特徵的注入 func NewPeople(name string) *People { // 一些初始化的邏輯... people.Student.Compose(people) people.Worker.Compose(people) people.Enjoyer.Compose(people) return people }
進行角色拆分之後,在實現Home、School、Company、Park等場景時,只需依賴相應的角色即可,不再需要依賴People這一領域物件:
// 家 type Home struct { me *role.Human } func (h *Home) ComeBack(human *role.Human) { fmt.Printf("%+v come back home\n", human.IdentityCard) h.me = human } // 執行Home的業務邏輯 func (h *Home) Run() { h.me.Eat() h.me.PlayGame() h.me.Sleep() } // 學校 type School struct { Name string students []*role.Student } func (s *School) Receive(student *role.Student) { // 初始化StduentCard邏輯 ... s.students = append(s.students, student) fmt.Printf("%s Receive stduent %+v\n", s.Name, student.StudentCard) } // 執行School的業務邏輯 func (s *School) Run() { fmt.Printf("%s start class\n", s.Name) for _, student := range s.students { student.Study() } fmt.Println("students start to eating") for _, student := range s.students { student.CastHuman().Eat() } fmt.Println("students start to exam") for _, student := range s.students { student.Exam() } fmt.Printf("%s finish class\n", s.Name) } // 公司 type Company struct { Name string workers []*role.Worker } func (c *Company) Employ(worker *role.Worker) { // 初始化WorkCard邏輯 ... c.workers = append(c.workers, worker) fmt.Printf("%s Employ worker %s\n", c.Name, worker.WorkCard.Name) } // 執行Company的業務邏輯 func (c *Company) Run() { fmt.Printf("%s start work\n", c.Name) for _, worker := range c.workers { worker.Work() } fmt.Println("worker start to eating") for _, worker := range c.workers { worker.CastHuman().Eat() } fmt.Println("worker get off work") for _, worker := range c.workers { worker.OffWork() } fmt.Printf("%s finish work\n", c.Name) } // 公園 type Park struct { Name string enjoyers []*role.Enjoyer } func (p *Park) Welcome(enjoyer *role.Enjoyer) { fmt.Printf("%+v come park %s\n", enjoyer.CastHuman().IdentityCard, p.Name) p.enjoyers = append(p.enjoyers, enjoyer) } // 執行Park的業務邏輯 func (p *Park) Run() { fmt.Printf("%s start to sell tickets\n", p.Name) for _, enjoyer := range p.enjoyers { enjoyer.BuyTicket() } fmt.Printf("%s start a show\n", p.Name) for _, enjoyer := range p.enjoyers { enjoyer.Enjoy() } fmt.Printf("show finish\n") }
模型的執行方法如下:
paul := object.NewPeople("Paul") mit := context.NewSchool("MIT") google := context.NewCompany("Google") home := context.NewHome() summerPalace := context.NewPark("Summer Palace") // 上學 mit.Receive(paul.CastStudent()) mit.Run() // 回家 home.ComeBack(paul.CastHuman()) home.Run() // 工作 google.Employ(paul.CastWorker()) google.Run() // 公園遊玩 summerPalace.Welcome(paul.CastEnjoyer()) summerPalace.Run()
寫在最後
從前文所描述的場景中,我們可以發現傳統的DDD/物件導向設計方法在對行為進行建模方面存在著不足,進而導致了所謂的貧血模型和充血模型之爭。
DCI架構的出現很好的彌補了這一點,它通過引入角色扮演的思想,巧妙地解決了充血模型中上帝類和模組間耦合問題,而且不影響模型的正確性。當然,DCI架構也不是萬能的,在行為較少的業務模型中,使用DCI來建模並不合適。
最後,將DCI架構總結成一句話就是:領域物件(Object)在不同的場景(Context)中扮演(Cast)不同的角色(Role),角色之間通過互動(Interactive)來完成具體的業務邏輯。
參考
1、The DCI Architecture: A New Vision of Object-Oriented Programming, Trygve Reenskaug & James O. Coplien
2、軟體設計的演變過程, _張曉龍_
3、Implement Domain Object in Golang, _張曉龍_
4、DCI: 程式碼的可理解性, chelsea
5、DCI in C++, MagicBowen