當前位置: 妍妍網 > 碼農

領域驅動點播直播彈幕業務合並設計實踐

2024-04-26碼農

架構師(JiaGouX)

我們都是架構師!
架構未來,你來不來?

為什麽要做DDD

業務成長之痛

你的業務是否存在以下問題?

  • 每個業務強依賴幾個牛逼的領域工程專家維護,接受新業務或新人上手時經驗無法復用,需要重新熟悉幾周甚至幾個月。

  • 一些叠代不頻繁的業務場景自己半年前的程式碼也很難看懂,偶爾叠代一下要熟悉很久,需要深入到編碼細節才能理解業務過程。

  • 一旦出現計畫交接痛不欲生,在不足文件、沒有人講的情況下效率極低,得把之前別人的坑重新踩一遍,透過經驗培養出新的領域工程專家。

  • 隨著需求變多計畫越來越臃腫,復雜度指數增加,事故頻發,開發效率不斷下降。

  • 上面的問題可以歸結為,缺少系統設計過的微服務框架和業務開發模式、缺少研發規範、程序導向編程。

    在業務早期階段,一個人長期負責一個計畫,系統復雜度也不高。上述問題在這一階段不重要,過度強調設計模式和規範反而會降低效率,影響業務快速啟動。

    然而當業務逐漸趨向成熟,出現部份業務必須多人合作,同時一個人又需要負責多個業務的情形下,高效上手、交接、維護、協作成了必選項,以上問題亟待解決。

    復雜度增長模式

    業務程式碼的復雜度是怎麽增加的呢?隨著業務持續叠代,程式碼實作業務能力、基礎元件、中介軟體的復用,從而降低系統長期叠代產生的復雜度復雜度增加可能出現的三種常見模式:

    耦合模式

    隨著業務需求增加,程式碼復雜度指數提升。

    業務邏輯無法內聚,後面寫的功能需要在之前不相關的程式碼模組裏加些特殊邏輯。

    發展一段時間之後,新寫一個需求沒有人能搞清楚系統中哪裏會出點bug,每改一點都需要對系統進行全域測試。導致無法保證穩定性、無法持續叠代,整體呈混沌狀態。

    煙囪模式

    隨著業務需求增加,程式碼復雜度線性提升。

    業務邏輯無法復用,每次新需求都要從頭寫一整套,出現大量復制貼上的重復程式碼。

    能低效支持業務叠代,但很難做微服務以及業務範圍的基礎元件升級、業務模組升級。因為分布在垂直煙囪結構當中,每一個功能模組都獨立評估、更新、測試、監控,代價過高。同時交接、多人協作成本巨大。

    叠代幾年之後,業務邏輯、歷史坑、特殊邏輯會積累很多。交接時前團隊不可能在一次講全,接手團隊也不可能把每一塊細節程式碼全看一遍。無法快速有效獲取系統的整體認知,只能靠探地雷式重新踩坑來獲得領域知識。

    復用模式

    隨著業務需求增加,程式碼復雜度對數提升。

    如果希望隨著業務復雜度增加程式碼復雜度增長低於線性,那麽必須透過復用相對靜態的基建來承接動態的業務需求。

    DDD的核心目標是解決軟體開發中的業務復雜度,透過架構分層、領域拆分和建模等手段實作關註點分離,提升程式碼自身的表達和約束能力,從而提升整體效率。

    這裏的具體思路是:

  • 透過戰略設計來劃分領域上下文,實作領域的內聚和對外解耦。

  • 微服務架構選型上基於DDD的分層架構和六邊形架構來清晰定義職責邊界,層級間實作可插拔。

  • 領域建模上抽象出可復用、符合業務長期叠代方向的業務模型。

  • 這樣,透過相對靜態的領域劃分、微服務架構和業務模型來實作業務能力、基礎元件、中介軟體的復用來承接不斷動態叠代的業務需求,從而降低系統長期叠代產生的復雜度。

    業務架構設計

    業務背景

    在具體介紹業務套用前先來看一下業務背景。因歷史原因,點播和直播的彈幕平台由不同團隊獨立設計開發,業務邏輯、整體架構、程式碼框架、中介軟體、公共庫等方面都有較大差異。一定程度上的重復建設導致了研發、產品、運維等多個團隊都存在效能浪費。

    基於以上契機,我們根據DDD的戰略設計來整合點播、直播兩側的彈幕平台,依據職能來劃分業務子領域並定義領域上下文。在微服務架構層面,透過DDD的戰術設計來實作框架結構統一、多層級關註點分離、領域模型充血。最終落地建設具備統一平台能力、服務於多業務場景的彈幕平台。

    設計思路

    合並前的業務架構

    合並後的業務架構

    閘道器層

    整合後的彈幕平台閘道器在協定上統一了核心模組的DTO模型,當前直播側的app和web閘道器服務做訊息透傳。

    同理,移動端和web端的彈幕訊息也由直播模組做透傳轉發不做解析。這樣最終實作彈幕業務邏輯在各端內聚收攏。

    業務層

    除基礎的發送、展示能力外,彈幕業務領域也包含活動特效,投票、評分等互動元件,直播側的彈幕表情,以及相關的策略和底層能力。

    在業務層根據戰略設計,拆分出互動彈幕服務、彈幕活動平台等幾個微服務,同時支持點播和直播業務。

    原直播業務的基礎功能、表情等業務模組逐步合並到點播側,實作領域內聚。

    平台元件層

    直播側的彈幕系統同時承載著整體直播業務的房間訊息廣播的通道職責,例如送禮,使用者權益升級等。這裏我們對業務和長鏈廣播通道做了微服務拆分,新的設計中直播長鏈廣播模組下沈到直播側平台元件層,保留給其他業務發送房間廣播的通道能力。

    此外,透過業務層的領域合並,實作了對外部平台元件的呼叫收斂。

    業務層戰略設計

    彈幕推薦

    面向C端的展示場景,包含彈幕個人化推薦的多組模型索引,數據更新、回刷,淘汰策略等。

    核心互動

    面向C端的互動場景,如展示和發送。

    彈幕基礎

    視訊稿件維度的整體彈幕資訊稱為彈幕池,包含稿件id,彈幕池狀態,計數,分段上限等。

    彈幕的基礎資訊包括內容,狀態,顏色,字型,位置,時間點,發送人id,內容位等。

    這個通用子體包含上述彈幕池和彈幕的領域模型,以及充血模型的狀態校驗、數據強相關的業務邏輯等。

    只有通用子體的領域模型允許共享給其他子體,需要嚴格定義準入邊界,防止無序擴充套件。

    活動特效

    彈幕的展示特效,如跨晚煙花等。包含C端展示,以及創作端、營運配置能力。

    互動玩法

    基於彈幕形式拓展出的,如投票、評分、關註卡片、預約卡片等。包含C端展示和創作端配置能力。

    策略管控

    套用於多個場景的底層策略能力,如內容聚集辨識、同質化資訊打薄等,以離線計算為主。

    營運後台

    面向內部的彈幕管理後台,主要包含查詢、資訊變更、活動配置等能力。

    核心收益

    透過業務架構的合並,團隊獲得的收益是:

  • 透過子領域合並從煙囪模式最佳化到了復用模式。例如跨年晚會等場景點播、直播需要開發同樣的特效能力,可減少一半開發量。

  • 實作了彈幕領域下點播和直播的能力復用,如投票、評分等基礎業務元件能力,以及底層策略辨識、內容管控能力。

  • 降低產研運多團隊業務認知復雜度,降低日常維護、管控、投放成本。

  • 微服務架構設計

    工具選擇

    這裏我們先調研了DDD,六邊形架構,CQRS,Event Sourcing等架構模式。最終我們選擇微服務框架的主體采用六邊形架構和DDD的結合,同時將CQRS和DDD的一些核心概念套用到編碼規範層面。

    設計模式
    套用維度
    說明
    DDD分層架構 微服務框架,DTO/DO/PO模型分離,領域服務拆分(如有必要) 透過拆分模型強制實作邏輯分層。
    六邊形架構 微服務框架,結合DDD數據模型實作分層 和DDD結合,比原生的分層架構層級間隔離更強制、更徹底。
    CQRS 介面設計規範 拆分讀寫模型更適合B端的復雜業務鏈路,對C端業務來說有些over design。因為kratos天生帶job,大部份業務不缺柔性設計。套用到服務介面/分層的對外介面層面即可。
    充血模型、顯示設計、聲明式設計 設計、程式碼編寫規範 顯示設計、聲明式設計能顯著增加程式碼可讀性。充血模型大大提升復用能力。
    領域模型定義,Entity, Value Object, Aggregation, Aggregation Root 設計、編碼層面作為good to have, 不強制要求 對於C端業務,業務模型的復雜程度還好。DDD本身需要團隊大量精力去在微服務架構、程式碼風格等方面建立共識,這塊適合作為good to have降低團隊理解成本。

    基於Kratos的六邊形架構改造

    理想的六邊形架構

    六邊形架構的核心是,將系統的輸入端(Driving Side)和輸出端(Driven Side)透過可插拔設計和業務領域層隔離開,從而實作領域層只關註業務邏輯。

    Kratos的結合和妥協

    Kratos腳手架的基礎設定是,同一個業務根據水平分層拆分出interface, job, service三個微服務,分別負責閘道器、異步訊息消費、業務邏輯。部份業務在實際演進中還額外拆分了admin和dao兩個微服務,負責管理後台和數據讀取。

    根據水平層級拆分微服務,在業務早期提供了快速啟動上手等優勢,同時將異步任務獨立拆分出job服務也讓系統不缺少柔性設計。

    然而隨著業務演進,這套架構出現了以下問題:

    1. 同一個業務領域內邏輯無法內聚,經常出現在interface, job, service中出現職責重復的業務模組。

    2. 缺少部門、公司範圍內的統一程式碼結構,各業務套用kratos的方式不盡相同,對interface, job, service的邊界定義不統一。

    3. 長鏈路出現非必要的節點多跳,在資源使用、維護管理上都帶來額外成本。

    同時,基於kratos套用六邊形架構時的問題是沒法實作介面的可插拔。核心業務邏輯被透過同步、異步的方式拆分在service, job兩個微服務中,沒法內聚在一個微服務的領域層裏。那怎麽辦?

    歷史上,我們出現了兩種模式。一種是到處抄程式碼,問題顯而易見。一種是抽象出common包,但導致了無序擴充套件、缺少約束的問題:

    一開始,interface, job, service共用一些common包裏面的數據模型和dao層方法,這沒什麽問題。

    後來隨著業務擴充套件,業務範圍內出現了2個interface,8個service,5個job共用common包下的數據模型和持久層程式碼,這就問題很大。

    每次改了common包下的東西不知道發哪些服務,導致了一些線上問題;同時因為缺少邊界劃分,common包內的程式碼不斷膨脹,難以治理。

    那有沒有解決辦法?

    改造:common包、內部介面

    我們透過common包的改造和定義領域內部介面的方式來解決這些問題。

    首先透過DDD的戰略設計劃分子領域,允許子領域內部的common包共享業務模型和持久層方法。業務公有的common包內只允許出現如constants, utils等靜態程式碼。

    子領域內的common包可共享

    1. DO和PO物件,以及充血模型的內建的成員函式。(這也是使用充血模型的好處之一,在這個場景下貧血模型只能共享業務程式碼,或者重復寫。)

    2. repo和gateway層級,對底層infra或介面進行封裝。一個子領域內的service和job可以共享數據儲存,但對子領域外的套用隔離。

    子領域內的common包不可共享

    1. Service層的核心業務邏輯。如果同一個業務功能既可以同步觸發也可以異步出發,則讓job呼叫service介面。

    2. Dao層的數據聚合、封裝邏輯。

    子領域內的介面共享

    在service的api層拆分出獨立的內部領域服務僅供job使用,並和對外介面隔離。例如:

    介面api範例程式碼

    service Activity {rpc AddDmActivity(AddDmActivityReq)returns(AddDmActivityResp); //添加活動// ...}service Internal {rpc FlushDmActivityCache(FlushDmActivityCacheReq)returns(FlushDmActivityCacheResp); //刪除活動緩存// ...}

    這些領域內部介面只允許同一個領域的job呼叫,所以可以接受不遵循聲明式設計的命名方式,以執行邏輯命名。對外介面則嚴格不允許。

    代分碼層結構

    這個微服務架構結合了DDD的分層架構和六邊形架構。它主要達成以下目標:

    各層級明確定義職責邊界,防止層級間的耦合和職責泄露。

    透過數據模型的套用範圍進行層級間的強隔離。

    層級介面要求

    每個層級透過Interface統一定義對其他層級暴露的公開介面,Handler層呼叫定義在Service層的介面,Dao層面向Service層設計介面並實作,實作六邊形架構中driving side和driven side的可插拔設計。

    在Service層和Dao層定義的介面中,輸入輸出統一使用DO,從而保證領域層只使用DO物件,核心業務邏輯不會被其他層級的復雜度入侵。

    領域模型要求(充血模型)

    DO物件不允許出現extra, json這類開放式、弱校驗的數據形式。欄位定義必須清晰、可校驗。

    在DO的成員函式中定義validate方法。在mapper進行DTO/PO物件轉換成DO物件,或自身更新時強制進行校驗,這樣可以保證領域物件永遠處於合法狀態。

    DO的成員函式中也包含強數據相關的業務邏輯,實作充血復用。

    代分碼包結構

    業務子領域結構

    微服務結構

    程式碼範例

    子領域業務概況

    介紹一下簡化版的業務情況以方便理解:

    彈幕活動(Activity)是領域的核心物件,標識一次特效活動。

    每次活動可以選擇一種投放型別,例如全站投放、按直播間id投放、按視訊id投放、按up主id投放等,透過Type標識。

    選擇一種投放型別後可以配置多個投放維度(ActivityDimension),例如投放型別是視訊,則可以細化選擇每個視訊下面具體生效的時間段。

    每次活動需要選取1中特效,定義在動態資源(DynamicResource)中。每種不同的特效型別所需要的資源配置不盡相同。

    最終形成的對映關系是:Activity → ActivityDimension 一對多,一次活動可以投放多個維度。Activity → DynamicResource 多對一,一次活動僅能配置一種資源,但同一種資源可以給多個活動使用。

    DDD的一個重要理念是,希望透過領域模型來分擔一部份業務邏輯,實作復用並降低領域層業務介面的復雜度。需要盡量避免entity成為單純的數據容器,mapper只進行欄位對映不包含業務能力。以下主要透過動態資源(DynamicResource)的entity和mapper程式碼設計來舉例展開。

    entity程式碼範例

    在領域模型層,動態資源(DynamicResource)的型別是Interface,其他欄位均為primitive或struct。設計新的特效型別的核心需求點就是創新性,和以前的特效必須有所不同,因而沒法用統一的struct來定義。在Activity中我們把各種不同的特效抽象出DynamicResource這個Interface,再用每種特效獨立的struct來實作。

    在DTO中為了使通訊協定具備可延伸性,動態資源欄位使用了string型別的序列化json來傳輸。在領域層的業務程式碼中同樣使用json表述復雜模型顯然會大幅增加寫bug的機率,因而需要具體定義,並在DTO到DO的轉換mapper中具體解析出來。

    此外,entity中的領域模型包含Validate, HasUserBlackList等成員方法,使自身的合法性校驗以及其他基礎業務能力內聚,避免分散在領域層Service的業務程式碼中造成不統一以及領域層職責過重。

    type Activity struct { ID int64 Name string classifyId int32 // 資源模版id State ActivityState // 狀態 Type DimensionType // 投放型別 Dimensions []*ActivityDimension // 投放維度 DynamicResource resource.DynamicResource // 動態資源// 其他業務欄位// ... Ctime common.Time Mtime common.Time}func (act *Activity) Validate() error {// 驗證activity和各個Dimension的欄位對映關系for _, d := range act.Dimensions {if act.ID != d.ActivityId || act.State != d.State || act.Type != d.Type {return ecode.Error(ecode.ParamInvalid, "dimension和activity欄位不匹配") } }return act.DynamicResource.Validate()}func (act *Activity) HasUserBlackList() bool {return act. classifyId == resource. classifyActivityIcon}func (act *Activity) HasCidBlackList() bool {return act.Type == dmActivity.Dimension_DimensionAll && act. classifyId == resource. classifyActivityIcon}

    mapper程式碼範例

    一個業務程式碼中容易出現的情形是,mapper機械式地進行同名欄位的對映比配,帶來了額外的開發量卻沒有有效分擔業務復雜度。理想情況下,mapper應該和entity類似,以內聚的方式承擔部份輕量級的業務邏輯,從而實作領域層的關註點分離。

    以下程式碼將彈幕活動(Activity)的DTO轉化成DO。它先呼叫parseResource方法,根據資源模版id( classifyId)將DynamicResource從DTO中的json結構體對映到具體型別,完成部份基礎欄位校驗並透過反射對公有欄位進行賦值。然後補充剩余欄位的對映並校驗返回,這樣使得從mapper中返回,即將傳入領域層的DO模型一定保持合法。

    mapper主體

    func ActivityFromDTO(dto *pb.DmActivity)(*entity.Activity, error){ dynamicResource, err := parseResource(dto. classifyId, dto.GetResource())if err != nil {return nil, err } act := &entity.Activity{ ID: dto.GetId(), Name: dto.GetName(), classifyId: dto.Get classifyId(), State: entity.ActivityState(dto.GetState()), Type: entity.DimensionType(dto.GetType()), Dimensions: ActivityDimensionListFromDTO(dto.GetDimension()), DynamicResource: dynamicResource,// 其他業務欄位// ... Ctime: dto.GetCtime(), Mtime: dto.GetMtime(), }// 校驗數據完整性if err = act.Validate(); err != nil {return nil, err }return act, nil}

    模糊型別到具體型別

    func parseResource( classifyId int32, resourceMeta string)(res resource.DynamicResource, err error){ tmp, has := classifyResourceMap[ classifyId]if !has { err = ecode.Error(ecode.NothingFound, " classifyResourceMap中不存在此 classifyId")return }if len(resourceMeta) == 0 { err = ecode.Error(ecode.NothingFound, "resource欄位為空")return } t := reflect.TypeOf(tmp).Elem() ptr := reflect.New(t) ptr.Elem().FieldByName(" classifyId").SetInt(int64( classifyId)) res, ok := ptr.Interface().(resource.DynamicResource)if !ok { err = ecode.Error(ecode.NothingFound, " classifyResourceMap reflect interface error")return }if err = json.Unmarshal([]byte(resourceMeta), res); err != nil {return }return}

    適用場景 & 難點 & 收益 & 成本

    領域驅動設計顯然不是銀彈,它有典型的適用場景和優缺點,同時也有較高的落地成本。實際開發中需要根據自己業務的形態和發展階段參考DDD的思想,並在工具層面進行trade off,避免因為某一個流行理論是這樣說的就要原封不動去照搬實踐。

    適用場景

  • 業務復雜、鏈路流程深,需要拆分關註點來降低理解成本

  • 希望在多渠道來源的散點式的需求中沈澱平台能力,以提升效率和可延伸性

  • 業務需要持續叠代

  • 業務領域內需要多人分工協作

  • 不適用場景

  • 業務相對發展成熟,偶爾叠代大多數時間只需維護

  • 業務本身復雜度低,主要挑戰來自於高並行、高可用、強一致性等工程效能問題

  • 業務目標、形態頻繁變更,沒法形成長期規劃

  • 經常發生研發團隊的組織架構調整

  • 難點

  • 團隊內:需要布道,統一認知難。一旦出現交接或組織架構調整,傳承理念有難度

  • 向上:說明價值困難,沒有短期、實際、可量化收益,不好忽悠老板投資源

  • 平級:統一語言、梳理領域事件都需要領域專家(通常是產品、營運等)參與,需要跨團隊價值認可並構建良好的合作關系

  • 收益

  • 基於六邊形架構和DDD,基本實作了層級內部的邏輯內聚以及不同層級間的關註點分離

  • 例如:在新的結構下,多活、資料庫遷移等工作可以只停留在DAO層,避免入侵業務層

  • 結合了kratos的同步、異步服務拆分,解決了歷史演進中的無序程式碼共享、復制貼上和缺少規範的問題

  • 在團隊內統一認知,對業務領域劃分、分包結構及部份編碼規範建立共識

  • 充血模型把數據校驗和基礎業務方法收攏到模型內部,降低了業務層的理解和叠代成本。將部份code review、測試中發現的問題左移到編碼階段解決,從根本上提升效率和穩定性

  • 成本

  • 程式碼中大量DTO, DO, PO的轉換mapper需要額外工作量

  • 建立共識的成本高,需要較為完備的設計文件和可執行的編碼規範

  • 程式碼改造成本高,需要較好的協作模式來解決過渡期業務需求開發產生額外成本

  • 充血模型對設計者的技術能力、業務理解、溝通交流都有要求,需要團隊能力培養和梯隊建設

  • 展望

    領域服務拆分

    上面的框架是基於一個子領域下只有一個handler, service, dao層級的情況。再進階一步,當遇到流量較小的偏b端服務時,業務邏輯復雜但介面流量不大,此時拆分微服務會顯著增加運維成本。但領域層獨立之後業務邏輯復雜度仍然過重,怎麽辦?

    可以透過垂直拆分領域服務的方式進一步拆分拆分復雜度。

    例如下面這個訂單系統的服務架構:在套用層分為負責下單和訂單狀態相關的兩個Handler;在領域層分為訂單、支付、配送三個Service;在持久層分為訂單、使用者、支付、配送四個Dao。

    程式碼結構一致性的規模效應

    前面的大部份收益集中在一個業務、團隊內部。在部門、公司層面套用同一套微服務架構,可以達成規模效應獲得更多的拓展空間和收益。

    一些具體的例子:

  • 形成包含統一規範、風格和最佳實踐的標準化程式碼結構,不同業務間的理解和開發經驗可快速平移。

  • 面向各層級的公開介面,提供標準化的單元測試框架。

  • 基礎中介軟體升級和業務隔離,更容易做統一方案。

  • 透過decorator實作中介軟體方法內嵌在每個層級的對外介面中。例如:分層的標準化監控面板、錯誤治理和trace標記等。線上問題定位、服務治理更加快捷清晰。

  • 用gpt實作自動化Mapper構建

    DDD分層帶來的一個成本增加項是,每次新定義一個領域物件需要寫很多mapper。它們大部份邏輯是簡單的欄位對映,同時也包含一部份層級間的改造。

    這部份工作可以使用gpt開發工具實作80%的自動編寫,開發人員再去補充特殊校驗邏輯並檢驗。

    如喜歡本文,請點選右上角,把文章分享到朋友圈
    如有想了解學習的技術點,請留言給若飛安排分享

    因公眾號更改推播規則,請點「在看」並加「星標」 第一時間獲取精彩技術分享

    ·END·

    相關閱讀:

    作者:孫嘉岐

    來源:嗶哩嗶哩技術

    版權申明:內容來源網路,僅供學習研究,版權歸原創者所有。如有侵權煩請告知,我們會立即刪除並表示歉意。謝謝!

    架構師

    我們都是架構師!

    關註 架構師(JiaGouX),添加「星標」

    獲取每天技術幹貨,一起成為牛逼架構師

    技術群請 加若飛: 1321113940 進架構師群

    投稿、合作、版權等信箱: [email protected]