PHP 開發雜談:對後端開發的思考

(團隊內部技術分享摘要)

目前開發實踐中的問題

  1. 業務邏輯泄露。本應屬於 Service 的業務邏輯泄露到其他各層中(Controller、Repository、View等),而原本內容豐富的 Service 反而變成了貧血類。
  2. 全能Service,主要表現是超多的代碼(如vshop的商品和訂單的Service代碼都在1000行以上)和多方面的功能。例如OrderService,幾乎只要是跟訂單相關的都在此Service中,而沒有進行進一步的精細建模。
  3. 重複功能,表現在(特別是在跨模塊時)重複的Service(如MemberService)和重複的方法。這些重複Service大部分地方一樣,少數地方有區別。
  4. 一些Service中充斥着各種各樣的查詢功能(列表和單記錄查詢),讓這個Service看起來很怪異。
  5. 貧血模型。基本都出現在Service層。多出現在查詢的地方(列表、單記錄)。因爲我們默認約定Controller必須通過Service進行讀寫,而不能直接訪問Repository,而實際上很多查詢操作只需要Repository直接返回的數據即可,Service不需要做任何操作。
  6. 以技術視角劃分模塊和系統。例如,所有的作業任務都放在一個系統(如message-center),而作業必然會出現各自業務邏輯(如會員合併),因而同一套業務邏輯必然會出現在多處。
  7. “前後臺分離”。此處的分離是指按照前後臺站點分離出完全獨立的兩套系統(如一個會員系統,分成了給商戶用的系統member-center和給C端用戶用的系統v-member,導致很多業務邏輯需要在這兩個系統中重複實現)。
  8. 經典的“framework問題”。這個問題幾乎困擾着全公司人,而且還會繼續困擾下去。framework問題的實質是敏捷團隊/公司和傳統架構思維的阻抗,它實際上正印證着康威定律:組織結構決定着軟件架構。該主題將在後面詳細討論。
  9. OO(面向對象)無用論。一般在應聘時,我們都會在簡歷上寫上“良好的OO基礎”,但實際上我們骨子裏是對OO持有懷疑和抵觸的,而且實際開發中也是自覺不自覺地在面向對象框架中進行着面向過程的開發。問題是,爲何我們那麼抵觸OO,那麼喜歡過程式開發?另一個問題是,爲何我們需要擁抱OO,相比於過程式開發能帶來哪些好處?
  10. 二維設計。或者說:“當我拿着MVC這把羊角錘時,全世界都是釘子”。當我們用MVC這一單一設計模式去解決一切問題時,就陷入了二維設計。我們無法立體地看待問題,無論是思考(設計)還是編碼都是在平面上進行着(如Controller -> Service -> Repository的形式化調用)。這種思維方式往往導致形式化的流程和約束,而形式化的東西又往往束縛了人們的思想,進而不再去思考。

相關設計模式和架構概述

  • 控制反轉(IoC) :或說依賴倒置,屬於SOLID五大原則之一(D)。

    描述:高層模塊不應該依賴於低層模塊,二者都應該依賴於抽象。抽象不應當依賴於細節,細節應當依賴於抽象。

    注意這裏提到高層和低層,說明該原則主要是用來解決跨層的依賴問題(例如我們的Service和Repository)。一般,高層需要用到低層的東西(Service使用Repository),對低層產生依賴,那麼當需要替換低層實現時,就需要改動高層代碼。IoC要求,高層不應當依賴於低層實現,低層實現的變更也不應該影響高層。那麼如何做到呢?接口,或說抽象。高層和低層遵守相同的抽象,並唯一據此抽象(接口)通信。

    再從另一個角度理解這個問題,就是將對象的使用和創建分離,使用者(調用者,依賴方)只是使用對象,而不負責創建它,創建工作由外部負責,這樣當需要更換被依賴方的實現時,無需修改依賴方代碼。比如說,我們的Service需要使用Repository,傳統做法是在Service裏面new一個Repository,現在要將原Repository替換成其他的(如原來是Db倉儲,需換成Redis或nosql倉儲),則需要修改所有使用了該Repository的地方。當使用IoC時,由於該Repository是由外部創建的,只需要調整外部.

    這種由原來的內部new變成由外部注入的實現方式,稱爲依賴注入(DI)。Yii框架裏面的構造函數注入和服務容器(\Yii::$app->container)是DI的兩種實現方式。

    IoC是設計原則,DI是該原則的實現方式。

    雖然該原則主要用來解決跨層依賴問題,但他同樣適用於同層之間的解耦,如果這些依賴之間不是高內聚的話。(但是,每當出現同層的低內聚類之間的依賴時(例如聚合之間的調用),首先需要考察是否需要一個更高層次的協調者,例如一個Service)。

    另外需要注意的是,不要濫用IoC。雖然IoC是用來實現鬆耦合的很好方式,但軟件設計中除了“鬆耦合”原則還有“高內聚”原則,高內聚的類之間是可以強耦合的,否則,很容易出現過度設計的問題。

    參見:http://www.tuicool.com/articles/JBRBzqm 《從百草園到三味書屋》,laravel作者著,裏面有很好的示例詮釋了依賴反轉。

  • 六邊形架構(端口-適配器模式)

    工作中,我們經常遇到以下難題:

    • 業務邏輯泄露到各層中,以及業務代碼對輸入輸出的強依賴(請求對象、數據庫存儲對象等),很難乾淨的進行單元測試;
    • 開發時依賴於數據庫、緩存系統的正常運行,一旦這些掛了,就無法開發了;
    • 當需要切換一個底層的技術實現時,需要改動相關業務層代碼。

    六邊形架構的目的:讓程序能夠以一致的方式被用戶、程序、自動化測試、批處理腳本所驅動;並且,可以在與實際運行的設備和數據庫相隔離的情況下開發和測試。

    例如,我們有會員合併的需求,而該需求的出現場景很多:web應用、api、後臺批處理、消息隊列異步處理等,目前,我們是在各場景分別寫了一套合併邏輯,很難維護,而且合併邏輯本身和存儲層是強耦合的,我們無法在忽略存儲層的情況下進行單元測試(實際上,目前的代碼根本無法進行單元測試)。按照六邊形架構,web應用、api、後臺處理都屬於不同的輸入源,這些需要和應用程序本身(會員合併業務)解耦,這些不同的輸入源的不同輸入數據需要通過各自的適配器適配成應用程序能理解的數據格式。適配器和應用程序接口遵守共同的契約(inteface),唯一通過該契約通信。輸出端也是一樣,應用程序本身不依賴於特定的輸出端實現(web、api、單測、數據庫),實現中也是通過各自的適配器根據各自的輸出端技術將應用程序的輸出轉換適配成具體輸出端需要的數據,如html、xml、具體數據庫所需要的。

    當我們將應用程序中對外部的依賴從具體實現改成對接口的依賴,並且將泄露到外圍(控制器、倉儲層等)的業務邏輯封裝進應用程序內部後,就可以很容易進行單測,例如很容易用mocker代替實際的web輸入、存儲層、緩存組件等。

    下面是六邊形架構的圖解:

    image.png

    這裏有兩個層:業務邏輯層 (領域 + 應用程序,目前可簡單理解爲領域層或業務邏輯層),也稱之爲應用程序(內層、內圓); 外圍設施層 (web、單測、數據庫、緩存服務器等,外層、外圓)。內層不依賴於外層的存在而存在(如業務邏輯層在數據庫不可用時應當仍然可通過其他方式代替數據庫來運行)。內層通過暴露端口(api或函數調用)爲外層提供功能(服務)或從外層接收數據——這很像操作系統的端口。端口的表現形式是契約interface(api或函數的入參以及返回值)。外層並不直接和內層打交道,而是通過各自的適配器來實現通信(控制器就是典型的適配器)。適配器將內層輸出轉換適配成其爲之服務的外層設備所需的數據,或將外層設備的輸入數據適配成內層所需要的數據。適配器和內層唯一通過契約(interface)通信。

    舉個例子:電腦需要接收各種外設的輸入進行處理,這裏的電腦就是內層應用程序,各種外設(u盤、手機、網絡等)屬於外層。爲了對外通信,主機上有各種插槽(端口),每個插槽遵循不同的規格。外設通過適配器(如各種數據線和數據轉換設備)和電腦進行數據交換,而適配器需要遵循兩端協議:一端是電腦插口、一端是具體的外設插口。

    IoC是實現六邊形架構的有效手段:內層不應當依賴於外層實現,雙方都應當依賴於接口定義——這也正是IoC的描述。但六邊形架構還有一點:內層的業務邏輯不應當泄露到外層,因爲一旦業務邏輯泄漏到外層,那麼內層就不再是通用的、與外層實現無關的了,也就無法進行多邊適配了。

    另外需要注意的是,這裏用的是“內層”和“外層”,並沒有用上層和下層的說法,這裏強調的是多邊適配,而不是類似網絡模型中的七層架構。

    我們發現,在實際應用中,我們或多或少用到了六邊形架構的東西(如IoC),但爲啥代碼還是難以測試難以維護呢?應用了六邊形的某些東西不代表整個應用遵循六邊形架構,比如我們用DI,但並不是特別清楚爲啥要用DI,以及哪些地方要用哪些地方不需要用,更重要的,我們的業務邏輯並沒有進行很好的封裝與解耦,自然難以維護。

    參考文章:http://blog.csdn.net/zhongjinggz/article/details/43889821

  • GRASP 九大設計模式:

    GRASP系列設計模式主要是用來解決OOD中模塊劃分、職責分配問題,此處我們重點看下信息專家模式和創建者模式。

    • 信息專家模式:將職責分配給擁有履行一個職責所必需信息的類,即信息專家。

      信息專家模式解決的是由誰來承擔該職責問題。首先考察該職責(方法)需要用到的數據(信息)從何而來,一般是將職責分配給主數據源類。

      例如,論壇系統有文章Article類和作者Author類,有發佈文章的職責(publish方法),那麼該職責由哪個類負責呢?先假設由Author類負責(從需求描述“張三發佈一篇文章”來看,貌似屬於Author的職責),像這樣調用:Author::publish(Article)。我們發現,publish內部使用到的數據基本都是從Article來的(除了作者信息),當Article的數據結構有所變化時,同時需要修改Author類,並且在publish內部還需要調用Article類的方法進行業務規則校驗。兩者之間產生了很強的依賴關係,同時違反了SOLID原則中的單一職責、開放關閉等原則。如果將publish方法放到Article類中,那麼所需要的數據都是自足的,校驗規則也是內在的,不需要對外公開,也就保證了修改規則時不影響其他類。

      信息專家模式需要結合高內聚和低耦合模式一起使用。比如實體對象的持久化問題(save()),持久化所用到的數據顯然是實體對象的,按照信息專家模式,save()方法應該在實體類中,但從職責上來說,持久化屬於低層技術實現,不屬於業務邏輯,不應該由實體承擔——我們用單獨的倉儲來負責實體的持久化工作。

    • 創建者模式 :誰應該負責產生類的實例?

      該模式解決的是類的創建職責問題。B包含或聚合A,或直接使用A,則由B來創建A。

      比如文章Article和作者Author,Article實例擁有Author實例的引用,那麼由誰來創建這個Author對象呢?一種可能是通過Article構造函數從外界傳入,由外界創建Author對象,但這樣就將Article的內部細節暴露給了外界。更好的做法是由Article內部自己創建Author對象,隱藏實現細節。

      創建者模式的使用同樣需要結合高內聚低耦合模式。

      再看另一個例子:每當文章發佈後,需要給相關訂閱者發送通知。ArticleService調用Article::publish()後,需獲取訂閱者列表並調用Email::sendMessage()給他們發送郵件。這裏ArticleService使用了Email實例,按照創建者模式,是否應該由ArticleService內部創建Email實例呢?假如是的話,考慮下當以後需要替換Email實現時會發生什麼?此時就需要挨個去找哪裏使用了該Email實例,然後一個一個替換。顯然此種情況需要用控制反轉原則,由外界注入Email實例。

      這兩個例子有何區別?

      前一個例子中,Article和Author屬於聚合關係,是較強的關係,他們共同組成了業務整體,因而可以採用創建者模式,而且也應當使用該模式以隱藏內部細節。後一個例子中,ArticleService和Email純粹是使用關係,是很弱的關係,而且兩者是跨邊界調用(ArticleService屬於領域層,Email屬於基礎設施層),在六邊形架構中,ArticleService在內圓,而Email在外圓,內圓不應當依賴於外圓的實現,因而這裏不能採用創建者模式,而應當採用IoC以保持低耦合。

      在創建者模式的條件列表中,“使用”列在最後,是最弱的關係,實際使用中,如果兩者僅僅是“使用”關係,則要慎用創建者模式。

  • SOLID 原則:

    SOLID原則是面向對象設計和編程中最基本也最重要的五大經典原則(該單詞是該五原則的首字母縮寫):

    • 單一職責原則 :有且只有一個(一類)原因(理由)去改變一個類。

      文章Article有publish()用來發表文章,也有save()用來保存文章到數據庫中。

      現在來考察下save():將文章對象持久化到數據庫。某一天,當持久化策略變了(用mongodb代替mysql),我們需要替換持久化引擎,此時就需要去修改這個save()方法了。“改變持久化策略”顯然和Article沒有直接關係,但卻影響到了Article類,這就違反了單一職責原則。

    • 開放封閉原則 :代碼對擴展開放,對修改封閉。

      文章發佈後,需要給訂閱者發消息。前面的做法是在ArticleService::publish()方法中獲取訂閱者列表,並調用Email::sendMessage()給其發消息。

      現在,有這樣的需求:只給最近三個月看過該作者文章的訂閱者發消息。此時我們需要修改OrderService,在發送之前對每個訂閱者做檢查。再過幾天,又有需求:只給關注了相關欄目的訂閱者發消息…你會發現,隨着需求的每次改動,ArticleService會被沒完沒了地改來改去(實際中我們正在做這樣的事)。這裏對修改是開放的。

      因給訂閱者發消息規則的變動而需要修改ArticleService,這本身違反了單一職責原則(SOLID原則都是互通的,違反其中一個往往也違反其他的)。可以在ArticleService::publish()中發佈一個article-published事件,外部訂閱該事件,這樣可以在事件訂閱端做任何業務擴展(對擴展開放)而不影響這裏的類(對修改封閉)。

    • 里氏替換原則 :一個抽象的任意一個實現,可以用在任何需要該抽象的地方。

      有個IAnimal抽象定義了run()和sound()方法(發聲),下面有實現類:Bird、Earthworm(蚯蚓)、Person,Bird和Person都對sound()做了各自的實現(發鳥聲和人聲),但Earthworm::sound()卻throw了個異常。現在有個AnimalTrainer::train(IAnimal)這樣的調用,想想會發生什麼?當我們傳入Earthworm對象時,其運行結果是未知的,有可能拋異常(如果AnimalTrainer調用了sound的話)。我們通過train(IAnimal)的聲明無法知道它如何使用這個IAnimal,而根據里氏替換原則,Earthworm自然應該被AnimalTrainer正確使用(而不是拋異常),因而這裏Earthworm的實現違背了里氏替換原則。如果實現類既要實現一個抽象,又不想去實現該抽象的某些契約(通過拋異常來抗議),說明你的抽象設計有問題。

    • 接口隔離原則 :在實現接口時,不能強迫去實現沒有用處的方法。

      還是上面的例子,訓練師去訓練蚯蚓發聲是枉然的。Earthworm::sound()是完全沒有用處的,要麼放着空函數什麼都不做,要麼拋異常,這裏的設計就違背了接口隔離原則。

    • 依賴反轉原則
      該原則在前面已經單獨討論過(因爲對六邊形架構太重要了),此處不再贅述。

    參見:http://www.tuicool.com/articles/JBRBzqm 《從百草園到三味書屋》,laravel作者著,裏面對SOLID原則有很好的示例講解。

    下面舉個綜合例子:

    客戶購買商品,下單時,系統需要進行各項校驗。

    假設在OrderProccessor::confirm()中進行訂單校驗(該類維持一個對Order實例的引用)。

    最開始只需要校驗相關商品是否有足夠庫存,我們創建Order::validate()執行這些校驗。由於validate所使用的數據大部分都可從Order對象得到,這符合信息專家模式。Ok。

    某天,接到一個添加校驗規則的需求:校驗下單者是否符合下單規則(只有業主才能下單)。我們需要改Order::validate()方法。然後我們發現訂單校驗規則的變化會導致Order的修改,這違反了單一職責原則和開放封閉原則。於是我們決定將“訂單校驗”業務邏輯抽離出來形成OrderValidator類,由OrderValidator::validate(Order)實現校驗,這樣就隔離了校驗規則改動對訂單類的影響。

    但是我們後來發現,一個validate()方法搞定所有校驗,導致validate()這個方法過於臃腫,於是我們對校驗規則分類後抽離出validateGoodsStock()、validateBuyer()等獨立的方法,然後在validate()中調用這些方法。

    過幾天,又要加個校驗規則:訂單價格是否合法。於是我們又加個validatePrice()方法。雖然說OrderValidator隔離了校驗對Order類的影響,但每加個規則就去改下該類,這違反了開放封閉原則。

    有沒有什麼辦法能夠隔離校驗業務的變動對OrderValidator的影響呢?

    答案是抽象。

    我們抽象出IOrderValidator接口,定義一個validate(Order)契約,然後創建GoodsStockValidator、BuyerValidator、PriceValidator等實現類實現該接口,在其validate(Order)中實現上述種種校驗。然後給OrderProccesor注入一個包含IOrderValidator的集合,在confirm()中順序調用每個驗證器的validate方法。

    現在,需要增加校驗規則,沒問題,創建一個新的IOrderValidator實現類並放到校驗集合中即可——該設計對擴展開放(通過創建新的校驗器類),對修改封閉(不需要修改其他的類)。

    上面的幾個設計模式都是非常基礎非常通用的,是實施OO必須掌握的,它們共同的基礎原則都是“高內聚低耦合”,進行OOD時必須時刻進行這些原則反思。

其他設計/架構模式

  • DDD(領域驅動設計)

    領域驅動設計指出傳統的需求分析和模型設計、代碼編寫是相互割裂的,傳統有需求分析師和系統設計師兩個獨立的職位,這種割裂導致相互之間的不匹配,比如系統設計不能完全反映出需求分析,而代碼又和系統設計割裂,各自有一套自己的私有語言,相互之間很難溝通。

    DDD強調需求分析、建模和編碼的內在統一性,三者(以及執行三者的人)之間使用一致的領域通用語言溝通,因而業務專家、設計師、程序員之間能夠很容易達成共識。

    現實情況是,程序員和業務專家(以產品經理爲代表)之間的溝通要麼存在嚴重的鴻溝,要麼使用非業務(往往是技術性的)語言溝通,背離真正的業務領域概念。程序員很喜歡用技術性語言(甚至直接拿數據庫說事)和別人(哪怕是非技術人員如客服、銷售)溝通,導致各執一詞。一般敏捷團隊往往只有一個產品經理,而有好幾個技術人員,往往會出現以技術性語言主導溝通的場面(如果產品經理本身不注重對團隊的業務語言引導的話)。

    程序員爲何那麼喜歡用技術性語言和別人溝通?一方面,程序員的溝通對象常常也是程序員,技術性語言溝通成本最低;另一方面,他們往往在溝通的同時就在想着實現方案(或者說溝通本身就是對實現方案的描述)。

    然而,技術語言溝通對業務模型的建立有着很嚴重的損害。技術本身和業務是兩個領域的東西,技術語言在現實中最典型的代表就是“數據庫語言”,比如“某個時候將某表的某字段標記爲1”,這於業務本身無任何意義。這種思維導向會讓我們腦海中越過建模而直達存儲實現低層。另一方面,這種技術與業務語言的混雜會讓業務邏輯本身耦合進存儲層的設計中。例如,單從存儲設計(技術實現)上來說,“登錄狀態”應當由單獨的字段來標記,而在業務領域中,“登錄”與“退出登錄”操作會導致另外的狀態變化(存儲設計上表現爲另一個字段),當我們在進行存儲層設計時過多的代入業務邏輯本身(或者毋寧說在業務邏輯描述時過多地代入存儲層的技術實現),我們可能會用另一個字段(存儲着其他的狀態)來代表登錄狀態(並且很自豪地認爲這樣能節約存儲空間——典型的技術主導一切的思維)。這裏的問題是:登錄狀態的存儲實現依賴於業務邏輯,由於業務邏輯是不穩定的(相對於存儲層),因而這裏作爲最底層的存儲層設計也是不穩定的。(實際上會員的登錄狀態存儲就存在這樣的問題)

    DDD強調:

    • 從需求分析到代碼實現到測試的整個過程各人員之間的溝通需要使用一致的無歧義的領域內通用語言。

    • 該通用語言必須能夠準確反映業務需求和領域知識(而不是反映技術實現)。

    對於程序員來說,DDD的這種思想可概括爲:代碼即模型,編碼即設計。我們寫出來的代碼,類與類之間的調用關係,方法、變量的命名都要反映領域通用語言本身。DDD非常強調命名,對於DDD來說,編程本身就是語言活動(不是機器語言)。DDD強調語言的重要性,這語言是人類的語言,而且是某特定領域下的人類語言。

    現實情況是,我們的代碼中充斥着大量的面向數據庫的語言。例如update()、delete()充斥在各種業務代碼中。“將字段A的值更新爲b”是技術(數據庫)語言,不是業務領域語言。更有甚,在控制器層寫個UpdateFields()搞定一切更新操作。

    然而,並不是所有的技術實現都要以當前業務領域語言來詮釋,實際上這也是做不到的。有些技術實現並非屬於業務領域之內,例如持久化存儲、事件系統、消息隊列等,這些不屬於當前業務領域的技術實現當然也就不需要遵守該業務領域通用語言(它們需要遵守的是各自的領域語言規約)。這種業務領域邊界在DDD中叫“限界上下文”,上下文內的東西屬於六邊形架構中的內圓部分,而外部的東西屬於外圓,內外圓通過契約適配通信(適配器)。

    DDD並非泛泛的理論闡述,它有一套詳細的實現體系(方法論),如實體、值對象、聚合、聚合根、領域服務、倉儲、各種設計模式等,此處不做詳細闡述(那得寫成一本厚厚的書,而且DDD本身是很注重實踐的,一百個人就有一百種實現方式,重在掌握其核心思想)。

    這裏提出DDD,重點在於對比我們現在使用的開發/設計模式:面向服務設計以及面向數據庫設計。在DDD中,實體是核心,服務只是輔助,而數據庫則是領域外的基礎設施。

  • CQRS(命令與查詢職責分離)

    命令:會導致實體狀態變化的操作(反映在數據庫上的更新、刪除、插入等);

    查詢:不會導致實體狀態變化的操作。

    CQRS原則:命令中不要有查詢,查詢中不要有命令。例如常做的在修改方法中同時查詢並返回某記錄,這就是違反CQRS的。

    CQRS的目的是爲了應對這樣一個事實:命令模型與查詢模型往往存在很大的不同,想想我們的數據庫設計就很好理解。我們進行數據庫設計時,一般是按照命令模型進行設計的,這和我們腦海中的業務模型比較匹配,而報表則是典型的查詢模型(分析模型),一般情況下,按照命令模型設計的數據表結構是滿足不了查詢模型的報表分析的,因而,爲了出報表,要麼需要寫很複雜的sql,要麼進行數據加工清洗以得出符合條件的查詢模型。顯然,在命令模型上執行分析查詢性能是非常低下的。

    可以在不同層面上使用CQRS:

    1. 傳統意義的讀寫分離。命令和查詢使用不同的庫,但兩個庫中的表結構相同。

    2. 代碼層面分離,存儲層不分離。(有可能採用讀寫分離,但表結構是一樣的)

    3. 代碼和存儲層都分離。這也是嚴格意義上的CQRS。這裏寫表是基於命令模型設計的,讀表是基於查 詢模型設計的,讀和寫是通過事件來同步的(命令端執行完畢後發佈相應事件,查詢端訂閱事件並更新 查詢模型)。在代碼和存儲層進行命令與查詢分離,在兩端各自採用最適合的實現方式,以達到最優設 計和最好的性能。

    嚴格意義上的CQRS實現起來很複雜,要求基礎支撐夠健壯才行。

    我們這裏提出CQRS,一方面是爲了指出以上事實,另一方面,在實踐中我們可以嘗試第二層面的CQRS,以獲得代碼層面帶來的益處,如緩存管理、兩端可採用不同的設計模式(如命令端採用DDD,查詢端採用傳統的MVC)。

相關概念分析

  • 關於Service(Service的含義以及面向服務編程有何問題)

    當我們說“服務”時,我們強調的是其爲外在它方提供功能。服務本身具有動詞性,核心是其功能性,該功能當然由服務提供者提供,但我們並不關心服務提供者本身。服務具有外向性,即服務存在的價值取決於其對他方(而非自身)的價值。因而,當我們以服務爲核心時,我們就必然會以外在旁觀者的角度審視其外在價值。當我們說“消息隊列服務”,我們看重的是消息隊列給我們業務系統帶來異步解耦的好處,而不是消息隊列本身的內部機制。

    這種看待問題的角度對建模是無益的。當提及服務時,總是有個“我”存在(就是那個旁觀者),而建模強調的是達到“無我”的境界,需要消除這個旁觀者,化身爲模型本身,從事物內在角度去思考問題。模型是內在自滿足的,它本身具有特定的行爲,而不是靠各種服務“提供”給它。

    舉個現實的例子。軟件外包公司對外提供軟件外包服務。當我們作爲甲方(也就是旁觀者)接受其提供的服務時,只需要告訴外包公司我們有什麼樣的需求,雙方達成共識,簽訂合同,最終我們從外包公司那裏接收符合合同預期的軟件成品,我們並不關心外包公司內部具體怎樣運作,哪個團隊負責ui設計,哪個負責編程等。但是,對於外包公司本身來說,這種視角是行不通的,它必須設計一套詳細的、健全的公司運作體系以保證其可以提供該服務,也就是說必須對“外包公司”這個概念進行內在建模,以形成一個自運作的實體。

    面向服務編程有什麼問題呢?不是一樣可以對服務內部建模嗎?

    問題是當我們面向服務,以服務爲核心載體時,往往做不到對其內部很好地建模。根本原因在於面向服務的外向型思維和麪向實體的內向型思維是衝突的。我們建模時,只會有一種主導思維,其它只起輔助作用。當我們以服務爲主導時,首先想的是功能,是做什麼的問題;當我們以實體爲主導時,首先想的是誰來做,是主體問題。一種是由功能推導出功能提供者,此處提供者是輔體,功能是主體;一種是由主體推導出主體行爲,行爲是主體的自在要素。

    由於在面向服務編程中,提供者只是輔體,往往容易被忽略,我們並不是太在意誰提供功能,結果就是往往一個類提供了n多個功能,比如一個OrderService提供和訂單相關的一切功能。久而久之,Service(特別是業務中的核心Service如商城的OrderService和GoodsService)會變得異常臃腫,難以維護。混亂的職責分配還會導致業務邏輯泄露,不同的開發者可能會選擇由不同的提供者提供相同或相似的功能,導致一份業務邏輯在多處出現。

    Service有其存在的必要性,但系統建模時不能面向Service本身建模,因爲Service是粗粒度的,而應當面向實體(Entity)。

    那麼,什麼時候該用Service呢?想象一下這樣的場景:張三是一名PHP程序員,負責後端程序開發,但不熟悉前端技術(js、html等)。現在有一項後端接口開發任務,顯然張三是能夠勝任的。現在又有一項網站開發的任務,由於張三不懂前端技術顯然無法獨自完成任務,需要前端工程師李四介入。現在問題來了,由於張三和李四是平級關係,誰也指使不了誰,工作無法開展了。咋辦?需要有新的協調者(如兩人的上司)介入。這個協調者就是服務。協調者本身不負責具體工作執行,而是負責協調、分工、調度以及外交。當網站開發任務中途發現還要其他角色加入(如運維),也是由協調者負責引入。後端、前端、運維只負責各自的工作,而不知曉其他人在做什麼,甚至不知道其他人的存在(如三人各在三個國家)。

    服務不是必須的,並且不負責具體業務實現。服務的職責是在高層次上協調各實體的執行流程,並對外公佈單一功能接口。

    服務不是天生就存在的,是通過向上抽象來獲得的。當本層各實體之間的工作無法協調時,就需要向上抽取一個專門的服務。

    例如銀行轉賬業務,涉及到收方賬戶和付方賬戶,收方賬戶收款,付方賬戶付款,但兩者是平行的,你不能說在收方賬戶的收款方法中調用付方賬戶的付款方法,反之亦不可。這時就需要更高層次的轉賬服務介入了。

    當然,還有一種情況(另一個角度),當我們需要一個本上下文以外的功能時(而我們又不想自己實現它),我們也稱之爲服務。

    關於服務命名:

    我們目前存在大量以Service結尾的類命名,以此標識它是個服務。個人並不建議這種做法。原則上,我們並不能說“某某是一個服務”,而只能說“某某提供什麼服務”。銀行提供借貸款服務,富士康提供代工服務,但我們不能說銀行和富士康是服務——它們屬於服務提供者。服務是功能,我們通常給予Service結尾的類實際是服務提供者,如“郵件服務提供者提供發送郵件的服務”,雖然平時我們都簡稱爲“郵件服務”,但據此簡稱命名爲EmailService並不合適。更合適的做法是直接以服務提供者名稱命名,如Email,或者以該服務提供者主要提供的功能命名,如EventSchedular,據此名字我們知道它主要提供調度服務,而EventService到底是幹什麼的呢?

  • 面向數據庫編程

    當我們接收到業務需求,大致分析完畢後,首先想要做的事情是什麼?表結構設計。

    是的,這是我們大部分人一貫的行事風格,甚至一個程序員技術水平高低全看他表結構設計的好壞。

    但是我們冷靜地想想,數據庫對於業務到底意味着什麼?它不過是數據的一種持久化方式而已,屬於基礎支撐層的東西。

    面向數據庫編程的問題是,我們從一開始就鑽到最底層的細枝末節上,而不是以居高臨下的角度設計和審視業務系統內在的、本質的邏輯規則。這種設計方式,由於沒能深入地設計建模,很容易就事論事,往往迷失在細節森林中。另外,由於數據庫設計和業務建模是同時進行的,表設計中往往會帶進過多的當前業務邏輯。由於業務規則會隨着不同時期需求變化而變化,因而這種表結構是不穩定的。典型的體現是一個字段表示多方面的狀態值,這些狀態之間遵守(目前的)固定的業務規則。數據庫設計不可能完全脫離具體的業務,但一定要儘量識別並減少不穩定業務規則的攝入。

    面向數據庫編程思維是自始至終的,而不僅僅在初期表設計階段。編程過程中,我們習慣將增刪改查字段這種數據庫術語帶進業務代碼中,操作也是直接面向數據庫進行的。

    這種編程方式中,往往沒有實體,就算有,也是僅僅作爲數據傳輸對象(DTO)使用(算不上DAO,因爲連個CRUD的方法都沒有),這些對象往往沒有方法,其屬性基本和數據庫表一一對應。模型(Model)也淪爲數據庫表的對象化表述(DAO),自帶CRUD。

    面向數據庫編程之所以那麼流行是因爲簡單快捷,會敲代碼就能做,完全不用考慮對象建模,而且其弊端在簡單的業務複雜度面前並不會暴露。然而當業務複雜度達到一定規模後,其弊端是致命的。大量的麪條式代碼牽一髮而動全身,業務邏輯零散各地。需求迭代越頻繁,這些弊端就越早暴露,前期的大步流星扯得後面蛋疼不已。

    要想深入地進行業務建模,必須在建模時忘掉數據庫,要意識到數據庫僅僅是持久化存儲的一種手段而已,這樣才能將你的思維從表結構中解放出來,深入到領域模型本身中去。

    面向服務編程和麪向數據庫編程正好是兩個思維極端:前者將全部注意力放在功能(行爲)上,後者則將全部注意力放在數據上。

  • 關於Repository(倉儲層職責以及事務該由誰負責) :

    從分層上來說,倉儲處於業務邊界處,連接業務層和存儲層(基礎設施層)。倉儲是懂領域語言的(但不代表要在裏面實現具體的業務邏輯),另一方面它也懂持久化相關工作。在《實現領域驅動設計》(以下簡稱《實現》)一書中建議將倉儲當做集合使用,並將其定義(接口)放在領域層,將實現(實現類)放在基礎設施層。

    我們先看看集合。集合裏面裝着一組同質(同類)元素(對象),擁有添加(add)、移除(remove)。注意,集合並沒有modify和save操作,因爲我們獲取的是集合中對象的引用,當對象狀態發生變化時,集合中的那個對象是同步變化的。

    《實現》中將倉儲分爲兩類:面向集合的和麪向持久化的。面向集合的倉儲實現嚴格模擬集合的行爲,擁有add、remove方法,沒有save方法,因爲我們獲取的是集合對象引用,其狀態的改變會立即反應到集合中(具體實現上內部會採用讀時複製或寫時複製機制來跟蹤實體狀態的變化)。面向持久化的倉儲除了和前者一樣擁有add和remove方法,還有save方法,即外界需要顯示的調用倉儲的save(Object)方法來保存實體狀態的變更(實際中往往add也被合併到save方法中)。兩者最大的區別在於是否顯示地保存實體變更。

    無論是面向集合還是面向持久化,都要求我們以集合的方式來使用倉儲——這也正是倉儲和數據訪問對象DAO不同之處。倉儲是面向領域對象的,它的職責是將領域對象(聚合)持久化到基礎設施中,以及從基礎設施中獲取數據並還原成領域對象返回。DAO是面向數據表的,一般和數據表一一對應,並自帶CRUD方法——和集合的add、remove方法不同,DAO的CRUD對應的是數據庫的CRUD操作的。

    我們可以恰如其名地理解“倉儲”。它是一個倉庫,我們將貨物交給倉庫管理員,由倉庫保管,至於怎樣保管是倉庫內部的事了。另外我們根據貨物編號(以及其他過濾條件)向倉庫管理員索要指定的貨物,至於怎樣將貨物從倉庫中取出是倉庫內部的事,倉庫 可以自己決定如何保管貨物(如爲了節約空間可能會將貨物拆卸分類存放),但倉庫必須保證取出來的貨物和當時放進去的是一模一樣的(除非不可抗原因如過期了)。

    我們現在的倉儲使用面臨一些問題。最大的問題是倉儲中包含了大量的業務邏輯——這也正是面向數據庫編程所導致的結果,我們的思維直接和數據庫打交道,而倉儲無疑是我們所編寫的離數據庫最近的東西了。我們程序中並沒有領域對象(實體),因而和倉儲打交道的是毫無業務含義的數組。

    (PHP是成也數組,敗也數組,其數組過於靈活強大(雖不見得性能多好),以至於一切都可以用數組表示,面向對象也就變得毫無意義了。很多PHPER並不理解面向對象,認爲其不過是將所有屬性設成private,然後不停地寫getter,setter,無聊透頂,吃力不討好。這種認知和長期主流框架(如spring)的機械導向有很大關係。

    “如果一個類有20個屬性,我豈不是要寫20個getter和20個setter?”這是很多反對OO者喜歡舉的例證。事實是,你的什麼類會有20個屬性?這多半是你的抽象出了問題(實際上這基本是在用面向數據庫的思維進行所謂面向對象編程,OO只是個空殼,而對象本身是和數據庫字段一一映射的,纔會導致這麼多的屬性(數據表有20個字段很正常)。另外,OO本身(特別是DDD)並不提倡過度使用getter和setter,因爲他們多半沒有具體的業務含義,試想一個public的setPrice()到底是什麼意思?設置價格?什麼樣的業務需要單獨setPrice?設計良好的類其屬性狀態一般是自維護的,而不是讓外界來set)。

    OO是一種思想,一種思考問題的方式,而在實現上,則應“合理的纔是存在的”,不可機械搬套。

    不是說PHP的數組不可以用,而是說數組應該作爲“數組”來使用,而不是萬金油。用數組代替對象結構,會造成很大的維護複雜性。)

    如果倉儲本身包含了大量業務邏輯,還不如使用傳統的(也是主流框架自帶的)Model,至少它有“模型”的概念(雖然是面向數據表的),而此處的倉儲則是“四不像”。

    目前的倉儲使用的另一個嚴重問題是在倉儲中實現事務。從倉儲的描述來看,它只是擔任存與取的職責,何以跟事務掛鉤?這還是由面向數據庫編程的思維導致的。當提及事務,我們首先想到的是關係型數據庫中的事務,並且理所當然地認爲此事務即彼事務,那麼既然是數據庫的東西,當然要放到倉儲層了。

    什麼是事務?事務是指需保證一項任務的原子性,任務中的各項操作要麼全部成功,要麼全部失敗(撤銷掉)。事務本身和數據庫沒半毛錢關係。我們說銀行轉賬業務需具有事務性,我們指的是這項業務,而不是業務背後的存儲技術。數據庫層面的事務是將“事務”這個概念應用到數據庫這個具體領域而言的,具體說來是事務中的一系列寫操作要麼全部成功,要麼全部失敗。我們真正需要關注的顯然是業務層的事務性,業務層不具有事務性了,存儲層的事務又有何意義?數據庫事務是對業務事務的低層技術支撐,改天我們不用關係型數據庫了,難道業務就不能有事務性了?

    倉儲和實體的操作都是細粒度的,無法保證整體的事務性,也不應當知曉事務的存在。

    事務應當放在那個能代表單一完整任務的方法中(如應用服務中)。

    將事務放在倉儲層,不可避免地會將業務邏輯帶入倉儲中。

  • 關於Controller(控制器職責及權限系統初步論述):

    控制器層是離用戶最近的層,在“端口適配器”中屬於適配器。和倉儲一樣(倉儲也屬於適配器),控制器也是“腳踏兩隻船的”,一方面它懂用戶的輸入,另一方面它懂業務層的端口(接口)。它將用戶的輸入數據轉換適配成相關業務端口所需要的數據,並將內層業務的輸出數據適配成用戶端所需要的數據。

    控制器所代表的是用戶角度的一項任務(用例任務項)。

    控制器應該是很薄的一層,不應該有任何業務邏輯和流程控制。

    控制器還有另一個功用:權限控制。權限用來控制用戶角度的一項任務能否執行,這和控制器正相呼應。控制器是側重於用戶角度的,它雖然知道用戶角度的一項任務應該交由領域中的誰(實體或服務)去執行,但其本身應當和領域層保持最大限度的解耦。

    (用戶角度的一項任務可能需要跨業務領域的多個領域服務協作完成,此時應當引入應用服務進行跨領域協調,而不應該在控制器中進行協調。)

    關於控制器和權限控制此處舉個例子:會員系統有魔力營銷、羣發和素材管理幾個模塊,他們都需要新增和編輯圖文的功能。我們一般做法是三個地方都指向同一個url(同一個控制器的action)來編輯圖文。現在問題來了:如何進行權限控制?現在張三、李四、王五分別擁有(且僅擁有)魔力營銷、羣發管理和素材管理的編輯權限,如何讓他們都能編輯圖文呢?一種做法是在圖文編輯的action中做這樣的權限控制:“需擁有魔力營銷或羣發管理或素材管理的編輯權限”。如此繁瑣,日後再加一個呢?目前我們正是採用類似這種做法(在數據表中加各種跟路由相關的限制以及url中加各種參數,一堆東西搞得人云裏霧裏)。

    其實我們仔細思考就會發現,雖然三個地方都是編輯圖文,但它們的業務含義是不同的,並且此處僅僅是湊巧三個場景編輯圖文的操作是完全一模一樣的,就讓我們產生錯覺認爲它們是同一件事情。從用戶角度(用例)來說,它們三件事情,編輯圖文是三件事情中的一個環節,它們可以碰巧完全一模一樣,但本質上是不同的(日後可以有不同的需求,如魔力營銷中編輯圖文時有額外的限制等),因而三個場景中的“編輯圖文”是三個用例任務,應該對應三個action(表現在url中是三個url。此處可能有人提出疑問:url作爲統一資源定位符,代表了資源本身,難道同一個資源的url可以不同嗎?url只是代表了資源(的表述),並不是資源本身,資源與url是一對多的關係,就像一個事物可以有多個稱呼一樣(例如不同地方的人對紅薯的稱呼是不同的))。這三個action分別需要魔力營銷、羣發管理和素材管理的編輯權限。

    實際中,控制器所面向的用戶類型大致有這些:web(人)、console(命令行)、外部系統(api調用),控制器分別以web應用、後臺腳本、web服務的姿態呈現。上面談到的權限控制實際是屬於web應用中的業務角色鑑權,但各類型控制器可分別使用各類型的權限控制系統(例如我們常用的api服務調用時的賬號鑑權)。

    還有一點需要注意,我們說應該在控制器層進行權限控制,但這不代表說一定由控制器本身來實現。實際上,一般控制器並不直接執行權限控制,它甚至不知道權限系統的存在。一般我們會在一個統一的地方執行鑑權,但這個地方一定不是權限系統中,這裏是在使用權限系統(權限系統的消費者),屬於權限系統外部。很多權限系統的設計犯了這種錯誤:將權限系統本身和對權限系統的使用混爲一談,在權限系統中耦合了過多的消費者信息(如菜單、路由等)。這裏還需要澄清另一個事實:負責權限系統的團隊往往同時負責部分或全部權限系統消費者的維護,這就很容易將兩者糅合在一起。這樣的團隊一定要認真識別出哪些屬於權限系統本身,哪些屬於消費端,從而最大程度進行解耦。相較於消費端,權限系統應當是個相當穩定的存在,它不應當隨着消費端變化而變化(如消費端改變了菜單、新增了路由等)。

    權限系統是什麼?權限系統定義了某用戶是否擁有某項操作權限,如張三擁有羣發管理權限。通常,爲了維護的便捷性,權限系統都會引入角色的概念,這樣,不是給張三直接授予羣發管理權限,而是創建一個羣發管理員角色,給該角色授予羣發管理權限,然後給張三賦予羣發管理員角色,從而張三便間接擁有了羣發管理的權限。

    自此我們得出權限系統三要素:用戶、角色、權限集。其中角色、權限集還可以做分組處理。

    (本文除非做特殊註明,否則都默認指業務權限系統)

    對權限系統的使用是指:某項用例任務要求操作者(用戶)必須擁有某項權限。用例任務(或其代理)詢問權限系統,權限系統給予是或否的答覆。

    綜上,權限系統消費端如下使用權限系統:

    • 消費端使用權限系統定義的權限集;
    • 消費端詢問權限系統某用戶是否擁有某項權限;

    回到上面魔力營銷的例子。首先在權限系統創建三個權限點:魔力營銷管理、羣發管理、素材管理(具體視情況而定),然後創建推廣專員角色,並授予該角色魔力營銷管理和羣發管理權限。然後給張三這個用戶賦予推廣專員角色,自此張三便擁有以上兩個權限點。魔力營銷和羣發擁有兩個獨立的web控制器,這兩個控制器分別有一個editArticleAction(編輯圖文),兩者調用的領域層對象(如Article實體)是一樣的,但它們需要的權限是不一樣的:一個需要魔力營銷管理權限,另一個需要羣發管理權限。

    (這裏視現實情況,可能將圖文編輯功能做成服務供遠程api調用,兩個action調用同一個ArticleService服務,該服務遠程調用圖文編輯服務)

    有人表示不解:既然兩個都是編輯圖文,用一個單獨的編輯圖文的action不就行了嗎,幹嘛搞兩個,僅僅爲了權限控制?上文已說過,此處兩個編輯圖文是在兩個完全不同的業務場景中出現的,只是恰巧(目前)它們的操作是完全一樣的。哪一天需求變了,要求魔力營銷的編輯圖文操作需要一些額外的數據,你又如何在一個action中搞定?寫個if else?如果再後面需求變得完全不能使用同一套圖文編輯操作了呢?

    我們往往被假象矇騙,當我們發現兩件事物的名稱完全一樣,其行爲表現也完全一樣時,便認爲是同一個事物。初見歐洲人和美國人,因爲第一眼發現的相似處便認爲都是來自一個地方。系統設計中,一定要從業務本身去思考系統,深挖業務表象背後所隱藏的本質。

    至此,我們發現了控制器作爲用戶和業務系統之前橋樑的核心價值:用例層的行爲和業務領域層的行爲並不是一一映射的,因而需要控制器進行解耦與適配。

    前面略微提到了應用服務。我們再次強調一下“服務”的特點:服務本身並不提供實現細節(在領域服務中,這是實體乾的事),服務的主要職責是協調、調度下級單元,以及外交。服務的出現是由於下級之間不協調而產生的(即服務是自下而上抽取出來的)。和領域服務是爲了協調領域實體(以及其它領域服務)一樣,應用服務是爲了協調多個限界上下文(多領域)。和領域服務不同,應用服務屬於應用層,不能包含領域業務。應用服務應當是很薄的一層。應用服務和控制器一起(其實控制器在這裏也可以看成一個很簡單的應用層服務,而專門的應用服務則是爲了處理較複雜的用例到領域模型的適配問題)構成應用層,作爲用例(用戶)和領域模型之間的適配器。

    理解應用層價值的關鍵是要認識到用例角度和領域模型角度是兩個截然不同的視角,看待問題的方式是不同的。產品經理描述業務時往往用的是領域模型視角(至少在DDD中要求這樣),而在人機交互設計時用的是用例視角。比如在博客詳情頁往往需要顯示最近評論和相關博客,這顯然屬於多個領域模型。如果認識不到這兩者的差別,設計出的領域模型往往會被用例模型牽着鼻子走,類便會違反單一職責原則,久而久之,代碼也就偏離的OO初衷。

    技術上,用例模型我們一般用“展現模型”表示,比如Yii的FormModel。有時我們可以用DTO對象作爲展現模型,從多個聚合中組建用例所需的數據。或者我們也可以直接針對用例在倉儲層查詢並組裝用例模型(用例優化查詢)——這聽起來有點怪,因爲正常情況下,倉儲應當返回領域層的聚合對象,而不應當返回應用層的東西。這種做法基於CQRS思想:命令模型和查詢模型的阻抗失配。聚合一般是基於命令模型的,而用例是基於查詢模型的。

  • 關於Entity :

    前面多次提到實體,此處做下集中探討。

    實體是DDD的核心,每個實體對象都有一個唯一標識以區別於其他實體。兩個實體間是否相等取決於它們的標識是否相同,兩個標識不同的實體,縱然所有屬性都相等也是兩個不同的實體。例如有兩個張三,性別、年齡都一樣,但他們仍然是兩個人。這點和值對象不同,兩個屬性完全相同的值對象是相等的。

    實體有生命週期,在生命週期內實體的狀態是可以發生變化的。例如Order實體,在整個購買與售後過程中,訂單的某些狀態會發生變化,但無論怎麼變,它還是同一個訂單。這點又和值對象不同。值對象是不可變的。

    我們可以形象而簡單地理解爲實體對應現實世界的“那個東西”(個體)(雖然這樣並不全面)。一輛車,一條積分交易記錄,都是實體。實體有連續的生命週期。一輛車,從製造出來(new一個對象),到買賣交易(車的屬主字段狀態發生變化),到上路跑(車的行程等屬性不斷髮生變化),到報廢(對象被刪除),雖然車的各種信息不斷地在變化,甚至經過噴漆、改裝等變得“面目全非”,車還是那輛車。值對象就不一樣,值對象是不可變的,一旦屬性發生變化就變成另一個值對象,好比有個Address對象,其有city、stress屬性,city發生變化就會變成一個新的Address對象,相反,兩個Address的city和stress如果完全一樣,則認爲兩個Address對象相等。

    地址信息是不是實體呢?關鍵看你的系統是否需要區分“那一個”地址。當兩條地址信息完全一樣時(對象的屬性完全一樣),是否表示同一個地址呢?如果是,那它就不是實體,而是值對象——我們並不關注“那一個”,只關注它的值。

    我們會發現,一個實體往往對應數據庫中的一條記錄。一般是這樣,但不完全一一對應。這裏重點是不要用數據庫記錄來和實體一一對應,這樣很容易走向面向數據庫編程。謹記:數據庫只是數據存儲工具。

    我們現在的代碼中偶爾也會發現Entity,但實際都是作爲DTO或DAO使用的, 其字段一般是和數據庫表一一對應,而且要麼沒有方法,要麼就是寫操作數據庫的方法。

    Entity是我們建模的基礎和核心,在進行實體建模時,不要去考慮數據庫,而是要考慮現實世界。比如,設計積分系統時,有賬戶類Account。賬戶分爲個人賬戶、公司賬戶和商家賬戶。在數據庫裏面,所有的賬戶都是放在h_integral_account表裏面,通過字段區分是什麼賬戶。以面向數據庫思維設計的話,我們也會有一個Account類,然後通過屬性type標識是哪種賬戶。這顯然是僞OO,用對象來模擬數據庫記錄(數據庫層面上,這種設計是合理的)。如果我們全然拋開數據庫,思維就會從這種桎梏中解放開來。以OO方式來思考,賬戶分爲個人賬戶、公司賬戶、商家賬戶,此處顯然是繼承關係,PersonalAccount、CompanyAccount和MerchantAccount繼承自Account。實際中,公司賬戶和商家賬戶有諸多相似處,那麼可以再往上抽離出“對公賬戶”PublicAccount繼承Account,而CompanyAccount和MerchantAccount繼承PublicAccount。

    我們再看看會員實體。

    數據庫層面,會員信息主要記錄在h_member表,該表有幾十個字段。以面向數據庫方式設計對象的話,Member對象也會有幾十個屬性,然後幾十個getter和setter。然後——然後你們會千萬種吐糟OO如何如何不好用了。

    從面向對象的角度出發,Member是一種身份,其更中性的存在是Person。作爲人Person,只有幾種需要關注的屬性:姓名、身份證、性別、生日。會員Member是會員系統這個業務領域的核心概念,是Person在此係統中扮演的一種角色。

    (“角色”這個概念在OO分析和設計中很重要。往廣義說,世間萬物之名皆是萬物之角色。一個事物在某時間點(或時間段)一定是以某一角色來從事某項活動(這也是四色原型分析的概述)。搞清楚角色的重要性,就不會在程序設計中用一個Person(或Member)來代表一切用戶以及用戶活動。當用戶登錄時他是登陸者,當活動報名時是報名者,當發表文章時是作者。不同的身份有不同的屬性和方法)。

    會員作爲一個核心角色,他應當有哪些屬性和方法呢?這裏存在另一個陷阱:因爲我們是會員組,開發的系統是會員系統,因而貌似一個Member可以搞定一切。

    這裏有兩種方案:要麼直接廢棄掉Member這一說法(因爲它太寬泛因而也太空洞了),直接用更具體的、細粒度的身份;另一種是對“會員”的概念進行嚴格定義,挖掘出狹義會員的概念。個人傾向於第二種方案,因爲畢竟“會員”這個在業務領域是實打實的存在,直接在軟件模型中消抹掉會導致模型和領域不匹配,而且一些地方確實無法用其他概念代替。其實只要我們平時稍微注意下用於,就會發現實際上“會員”還是有比較明晰的邊界的。比如粉絲和會員就是兩個身份。

    我們再看看業主。業主一定是會員(而不能僅僅是個粉絲),因而可以創建個Owner繼承自Member。注意這裏的關係描述用的是“一定”,這種描述是“領域內必然性”,它不一定是亙古不變的,但這種基礎業務邏輯發生變更的機率非常小,因而我們使用繼承關係,而不是其他關係(如聚合、組合等)。相較於聚合和組合,繼承是更加穩定的關係。

    (反過來,如果我們發現A和B僅存在不太穩定的“是一個”的關係,就要慎用繼承,而考慮其他關係(如用type標識其類型)。)

    另外我們看看Owner這個詞。一個詞的含義取決於上下文。Owner的本意是“所有者”,比“業主”更爲寬泛。

    那麼此處是不是要用RoomOwner呢?RoomOwner將我們的關注點帶到Room上,使得Owner不是很突出,而“業主”在會員系統是另一個核心概念,不應當將其命名依附在Room上,況且Owner本身即有業主之意。這裏想說明的是上下文對於名詞理解的重要性,以及核心概念要有核心名字。

    最後再強調一遍:數據庫設計和對象設計是兩種完全不同的設計模式。

  • 關於建模(模型/實體從何而來)

    很多時候我們想去嘗試OO,卻苦於建模——要麼腦海中半天搞不出一個模型出來,要麼怕建出來的模型不符合業務,越搞越混亂,最後不可收拾。想來想去,還不如面向數據庫來得直截了當。

    建模是一個不斷反覆的解構與建構過程。

    最初出來的模型總是很樸素、很不完善的,這屬於正常現象,因爲此時你只認識了當前需求本身,還停留在現象層——而此時的模型恰如其分反映了這點。

    隨着認知的深入(或編碼的進行——沒錯,編碼過程本身就是設計過程,會對模型進行精化、修正),原先樸素模型內部必然會暴露出一些矛盾點,這些矛盾點自然敦促你去進一步重新審視需求和模型。此時,你往往能透過需求現象看到業務領域的某些本質。

    一定要認識到一點:(原始)需求屬於現象,不屬於領域本質。我們誠然要尊重現象,所建模型也要正確支撐現象,但現象不是本質。建模時不能完全圄於當前需求本身。

    要不斷的回到需求本身,防止模型跑得太遠跑偏了。如果所建模型不能正確支撐需求了,那麼需求和模型肯定有一方有問題(往往是模型出了問題)。

    當你在建模的路上“江郎才盡”了,要立馬回來讀讀需求。要麼模型建完了,要麼對需求還沒有完全理解。

    要將需求書面寫出來,而不只是口頭概述。詳盡的寫出來(最好是多人一起深入討論,如果沒有條件,一人也行,後面可組織多人討論),在寫的過程中,你會不斷地發現新的概念,新的問題。實際上,這樣寫需求的同時就是在建模。DDD強調需求分析和模型設計的語言一致性。

    寫需求文檔時,需要注重概念的提煉。由於平時需求描述的不規範性(或需求者本身的概念模糊性——需求提出者往往不是領域專家),原始需求一般會夾雜各種干擾因素,比如在所有場合都使用“用戶”來代表系統使用者,將活動報名人和參加人混爲一談等。如果基於原始需求建模,你會發現整個系統就一個超級的User類。需求分析不但是潛藏概念挖掘過程,還是已知概念的“正名”過程。“粉絲”是什麼,不是什麼,“會員”是什麼又不是什麼,他和“粉絲”又是什麼關係等。

    記住:建模的開端不是畫類圖,而是寫需求分析文檔。你的類圖應當和需求分析文檔大體保持一致,當兩邊有很多概念或邏輯上的不一致時,說明模型有問題。

    [ 注意:原始需求和經過我們自己分析後的需求是有區別的,詳細分析後的需求一般是“現象與本質的統一”, 具體體現上是需求分析/設計文檔和領域模型的統一。注意這裏分析和設計是一體的。]

    另外需要注意:需求分析完成了只代表最核心的模型完成了,不代表建模工作本身完成了。

    需求分析是迭代的,程序設計、建模也是不斷迭代的,從個人實踐來看,即使是一個項目的初版開發也會經歷幾次大的內部重構,而那些所謂在實際編碼之前必須畫出詳盡 UML 圖的,純屬理性主義烏托邦。

  • 關於framework/sdk/vendor (康威定律如何體現在framework的內部矛盾中):

    康威定律 :設計系統的組織,其產生的設計等同於組織之內、組織之間的溝通結構。

    framework的產生基本離不開“早期團隊”。當我們發現兩個獨立的團隊使用同一個framework程序時,基本能斷定他倆曾經在一起過(或者有共同的直接上司)。

    由於一些功能需要各模塊或項目之間公用,我們便將它放到一個單獨的目錄中,然後在各項目中引用。這在一個小型團隊中是沒問題的。但是隨着業務的增長,團隊會分化成多個獨立的團隊,此時,各團隊之間公用的framework便出現所有權問題。公用的代碼要麼沒有人維護,要麼都來修改。還有另一個問題,雖然各團隊現在分開了,但各自仍然認爲那個framework是自己的,自己團隊的公用代碼仍然應該放在那裏面,於是此時framework裏面便充斥着各個團隊的“公用”代碼,雖然這些代碼對其他團隊幾無用處。

    就敏捷型團隊來說,framework並不是很好的公用代碼形式。framework比較適合傳統的團隊,這種團隊隨着業務拆分往往會採用團隊內分組而不是分成完全獨立的團隊。敏捷型團隊講究小型和自管理,團隊之間的溝通並不是很頻繁,因而在系統架構上也應該與之適配,相互之間的代碼儘可能解耦。

    更好的方式是採用composer形式,每個功能作爲獨立的composer,每個composer包有明確的所有者。整個公司有一個私有的composer倉儲,每個團隊管理自己的包(甚至是自己的倉儲),其它團隊可以使用,但不能修改,如果其它團隊覺得需要針對他們團隊做較大修改,可以fork一個獨立分支自行維護。

    對package的要求:

    • 只提供比較單一的功能,對其修改不會導致大面積輻射;
    • 面向接口編程,儘可能保證其對外穩定性。甚至可以在團隊間制定契約(類似PHP-FIG成員間爲統 一編程規範而制定的一系列PSR規約一樣);
    • 應當遵守開源社區的標準版本命名規範,使用者可以選擇使用不同的版本;
    • 可以追溯哪些項目安裝了該package,當發生版本變更時可以進行相應通知。
    • package不僅僅是用來提供通用功能,還可以用來定義契約(接口),即該package只提供接口定義,不提供實現,其他的package或項目可爲之提供實現(這可以在團隊間制定契約)。

    有人擔心這種方式會帶來升級的複雜性:升級package需要通知所有使用者進行項目更新。首先package應該是比較穩定的,如果一個package需要頻繁修改,要麼是實現有問題,要麼是它做得太多了;而且並不是每個package都是被很多團隊大量使用,也不是每次升級都是必須的;另外,如果能追溯哪些項目使用了這個package,則針對必須進行的升級可以按需通知。最後,framework模式同樣存在升級問題,而且由於是一次升級必須升級所有功能,會帶來更大的安全隱患。

    需要時刻注意一點(無論是package模式還是framework模式):哪些功能應當以服務的形式而不是package(或framework)的形式提供。我們使用package直接目的是提取多個項目要用到的功能以公用,但很多時候這些功能以服務的形式提供更適合。比如積分,每個項目都要用到,但相比於做成package,更適合做成獨立的積分系統供其他系統調用。再比如公衆號相關的功能也應該以服務而不是framework來提供。

    目前我們採用sdk來替代framework,其實是換湯不換藥。各團隊之間有個公用的sdk,由公共團隊維護,然後各團隊各自有個自己的sdk。其本質還是framework,即如果哪一天公共團隊不存在了,公共的sdk便成爲麻煩;哪個團隊分裂成多個團隊了,那個團隊的sdk便成爲麻煩。現在之所以沒有出現問題是因爲雲服務目前的組織架構並沒有發生很大的變動。

    framework(或sdk)問題的本質原因在於其強耦合的代碼架構和鬆耦合的敏捷團隊之間的矛盾。framework是非常粗粒度的技術劃分,裏面的代碼之間可能沒有任何關係,僅僅因爲它們都是“公用代碼”就走到一起。更糟糕的是,很多領域業務,因爲多個項目需要用到而跑到了framework中(本應當抽離成獨立的服務)。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章