天天看點

火山引擎A/B測試平台的實驗管理重構與DDD實踐

作者:閃念基因

本次分享的主題是火山引擎數智平台VeDI旗下的A/B測試平台 DataTester 實驗管理架構更新與DDD實踐。這裡說明的一點是,代碼的第一目标肯定是滿足産品需求,能夠滿足産品需求的代碼都是好代碼。而本文中對代碼的好壞的評價完全是從架構的視角,結合代碼的可讀性、可維護性與可擴充性去分析的。

火山引擎A/B測試平台的實驗管理重構與DDD實踐

文|王言鑫 火山引擎DataTester團隊

在一個産品或者代碼倉庫的發展過程中,如果不對代碼的品質加以控制、不引入原則與規範的限制、不及時的采取手段,那麼随着時間的流逝,大概的發展軌迹将會如下圖所示。

火山引擎A/B測試平台的實驗管理重構與DDD實踐
  • 早期

在項目的早期疊代非常迅速,一個需求可能一周就可以完成開發測試與上線,研發效率也保持在較高的水準。此時一切還都是有序的狀态。

  • 中期

随着功能的疊代,子產品與子產品之間、功能與功能之間可能會出現關聯與複用的邏輯,如果不加以重構,可能就慢慢變成了技術債。加上人員投入增加與人員流動,新人可能對原來的設計思路并不了解,會出現僅看代碼無法了解功能的情況,認知負荷開始上升,慢慢的會發現雖然投入的人力增加了,但是研發的效率開始越來越慢。系統混亂開始慢慢增加。

  • 後期

雖然效率降低,但是功能的疊代還在進行。但即使隻是一天就能搞定的小需求,涉及到的改動也會有多處,且不确定要改多少個地方才能保證系統的正常運作。此時整個系統的認知負荷已經過載,僅僅寫好代碼還不夠,還需要清晰地了解曆史代碼的功能邏輯,否則稍加不慎就會引入oncall或者投訴。随着oncall的增多,研發的人力又被占用,進一步降低了研發效率,需要額外的時間償還技術債。此時系統已經變得非常混亂,即将變為無序狀态。

  • 末期

随着混亂的進一步惡化,團隊的戰鬥力幾乎歸零,僅能夠維護現有功能,新增需求很難在短時間内完成開發上線。産品的發展技術陷入停滞,效率幾乎降為零。此時系統已經變為完全混亂的狀态。

火山引擎A/B測試平台的實驗管理重構與DDD實踐

在 DataTester 項目早期,由于需求簡單直接,功能估期基本準确。但是随着産品規模擴大、場景複雜度增加,能明顯感覺到功能的開發依賴和需要考慮的東西越來越多。

下面簡單羅列了功能子產品與系統熵遞增的關系。可以看出從最初的程式設計實驗,到後邊的可視化與多連接配接實驗,又到後邊的父子實驗、push實驗,再到最後的内外合并,整個系統的複雜程度越來越高,如果不及時采取措施,那麼後續的維護與擴充将會耗費非常非常多的人力。

火山引擎A/B測試平台的實驗管理重構與DDD實踐

回顧軟體工程的曆史發展,包括面向對象、微服務以及各種領域模型等,它們都代表了針對系統複雜性的不同應對政策。正如John Ousterhout教授在他的著作《A Philosophy of Software Design》中所強調的,複雜性可以定義為那些使得軟體變得難以了解和修改的因素,而軟體技術的發展史也是與“複雜度”鬥争的曆史。

火山引擎A/B測試平台的實驗管理重構與DDD實踐

那到底什麼是複雜度?John Ousterhout教授在書中明确指出,複雜度是指那些使得軟體難以了解和修改的因素。複雜的系統通常具備三個明顯特征,由John教授抽象為以下三個方面:

  1. 變更放大(Change amplification): 這指的是看似簡單的變更需要在許多不同地方進行代碼修改。在此情況下,開發者可能未能及時地進行代碼重構或提取公共邏輯。相反,他們可能采用了快速複制粘貼的方式來開發代碼,以節省時間和減小影響已存在的穩定子產品的風險。然而,當需求變化時,就需要在多個地方進行代碼修改。
  2. 認知負荷(Cognitive load): 這表示系統的學習和了解成本相當高,是以降低了開發人員的生産效率。高認知負荷意味着開發者需要花費更多的時間和精力來了解系統的結構和工作方式。
  3. 未知的未知(Unknown unknowns): 這意味着開發者不知道必須修改哪些代碼才能確定系統正常運作,也不知道對代碼的更改是否會引發線上問題。這是複雜性中最令人頭疼的表現之一,因為它帶來了不确定性和風險。

導緻複雜性的原因可以概括為兩個方面:依賴性與模糊性。過多的外部依賴導緻功能變更的放大,并會增加認知負荷,而資訊的模糊會增加未知的未知。而這些表象又會反過來提升系統複雜性,以此往複加速系統的“衰敗”。

火山引擎A/B測試平台的實驗管理重構與DDD實踐

系統從有序到無序是必然的,那隻能任由代碼變壞而束手無策嗎?

幸運的是答案是否定的。軟體工程已經發展了60多年,我們遇到的問題,前輩們肯定也遇到過,我們有充分的理論和方法來對抗系統的逐漸混亂。如下圖所示,雖然系統複雜度上升是無法避免的,但是适時的重構可以減緩系統混亂的速度。

火山引擎A/B測試平台的實驗管理重構與DDD實踐

随着時間的推移,DataTester 開發經曆了多個階段的發展,每個階段都伴随着不同的技術、方法和挑戰,每個階段也有各自的主要沖突與次要沖突。

團隊的發展過程中,也需要适時的進行組織架構調整,以适應新環境新的挑戰。隻有變化才是唯一不變的東西。和團隊管理也非常類似,在這個不斷變化的環境中,适時的重構變得至關重要。

重構是指在不改變軟體外部行為的前提下,對代碼内部結構進行調整和優化的過程,目的是提高代碼的可讀性、可維護性和性能。在不同階段,重構都有其獨特的意義和價值。

火山引擎A/B測試平台的實驗管理重構與DDD實踐

比如在 DataTester 疊代初期,我們的目标可能是盡快上線功能,提高産品競争力,那麼此時應優先業務疊代。而随着回報越來越多、需求越來越多,會有更多新的功能上線。

沒有人可以預知未來會有什麼功能加入,會有什麼業務場景,是以如果不能随着産品的疊代及時調整代碼與架構,那麼混亂的速度增加是必然的。

産品的傳遞需要從人力、時間與品質三個次元去進行評估,其中的時間即經常所說的“能不能按期傳遞”。産品的研發與上線需要PM\BE\FE\UX\QA一起協力,而這裡主要關注BE視角遇到的一些問題。每個雙周都是對一些工作進行估期,但是排期卻很難進行準确評估。

導緻該問題的原因可以分為以下幾類:

  • PRD描述不夠周全,往複讨論無形中拉長了開發周期
  • 技術方案考慮不夠嚴謹,忽略了一些相容與适配問題
  • 曆史包袱導緻新功能的開發,需要在很多地方做适配與調整,并且會影響其它功能

上述第三個問題的出現,就意味着代碼中的”壞味道“已經很嚴重了。評估出來的工作量和實際的工作量大相徑庭也是在意料之中的。如果這時候的開發同學對原有功能了解的不夠深入,那麼結果可想而知。樂觀的情況下,新功能的開發隻需要完成該子產品需要的開發工作,這就對代碼的封裝與隔離性要求非常高。

火山引擎A/B測試平台的實驗管理重構與DDD實踐

那麼既然重構如此重要,那為什麼沒有被重視或者沒有及時執行呢?我們可以嘗試從常見的理由來發掘深層次的原因,可歸為以下三類:

不是我不想做,而是不知道怎麼做

  • 代碼腐化嚴重,缺少相關規範的沉澱與指導
  • 人員流動導緻原始設計思路無法繼承

不是我不想做,而是别人都是這樣做的

  • 業務耦合嚴重,無法進行封裝與隔離

不是我不想做,而是沒有時間做

  • 缺少長遠視角,認為重構是浪費時間的事情,對無業務幫助
  • 重構短期無法從業務側看到明顯的收益
  • 代碼品質未受到重視

随着混亂的增加,團隊生産力也持續下降,趨向于零。當生産力下降時,管理層就隻有一件事可做了:增加更多人手到項目中,期望提升生産力。對于可以拆解的任務,增加人力确實可以縮短傳遞時間提升效率。

但對于複雜的系統,新人并不熟悉系統的設計,他們搞不清楚什麼樣的修改符合設計意圖,什麼樣的修改違背設計意圖。而且,他們以及團隊中的其他人都背負着提升生産力的可怕壓力。于是,他們會制造更多的混亂,驅動生産力向零那端不斷下降。

是以,可以說補充人力在一定條件下是可以提升整體的進度與效率,但這并不絕對,特别是對于混亂的系統。

火山引擎A/B測試平台的實驗管理重構與DDD實踐
火山引擎A/B測試平台的實驗管理重構與DDD實踐

網上有一張比較有意思的圖檔,如下,評價代碼品質的唯一标準即code review會議室中,每分鐘傳出的WTF次數。

The only valid measurement of code quality: WTFs/min.
火山引擎A/B測試平台的實驗管理重構與DDD實踐

當然代碼的好壞要從可擴充、可維護、可測試、可讀性等多方面綜合考慮。雖然有很多規範與法則,但是如果過度設計也并不見得是好的代碼。是以在業務需求與規範之間如何權衡,也是一門“藝術”。

代碼是思維活動的産物,不同的開發者有着不同的思維模式,是以需要好的原則與規範的限制。"道法術器"是古代中國哲學思想中的概念,常用于描述宇宙和人生的基本原理和法則。那是不是也可以用于指導軟體的開發呢?

對于軟體的架構設計,同樣可以從以下四個層級進行思考,從上到下依次遞進:

火山引擎A/B測試平台的實驗管理重構與DDD實踐

"道"是指宇宙萬物的根本原則,也可以了解為事物運作的規律和基本法則。不以人的主觀意願而轉移。熵增定律可以被視為一條使整個宇宙變得絕望的法則,它被了解為事物結構不可避免的逐漸衰退。熵增定律無法避免,就像生老病死無法避免,但是我們可以通過一些手段,延緩“最終無序”的到來。

"法"是指宇宙和人生的治理法則與方法論,對應到代碼開發中可以歸類為一些經典的原則與思想。軟體工程經過60多年的發展,沉澱了很多有指導意義的方法論。比如SOLID原則、各種設計模式,以及大道至簡的架構設計思想:抽象、封裝與隔離。這些方法論都可以助理我們進行代碼重構,及時降低系統的複雜程度。

"術"指的是技能、技術和實踐方法。在軟體開發中,"術"可以表示程式設計技術、架構的使用、代碼架構等。方法論往往都是思想上的指導,不同的人可能也有着不同的了解,真正落地的時候還需要一些業務架構與程式設計範式,比如有領域驅動設計、MVC架構、依賴注入與面向對象等。這些原則都可以幫我們更好的進行代碼分層與依賴反轉,進而實作高内聚、低耦合的業務代碼。

"器"是指工具和資源,用于實踐和應用"道法術"的原則。在軟體開發中,"器"可以包括開發工具、版本控制系統、自動化測試工具等,采用微服務架構可以更好的實作功能的隔離,而單元測試與CI/CD則可以更好的加速功能的疊代與系統的重構。

無論是方法論層面還是工具層面,目前都已經很成熟了。在寫代碼的時候多加一步思考,在功能完成之後對業務代碼進行适當的重構,就會達到很好的效果,也應當成為一種習慣。
火山引擎A/B測試平台的實驗管理重構與DDD實踐

下面将羅列一些項目中實際存在問題,這些雖然從規範與架構視角看是有問題的,但是從滿足業務需求的視角看都是好的代碼。其實大家都能意識到這些代碼存在的潛在問題(不是我不想做,而是别人都是這樣做的),但是子產品”牽一發動全身“,且不斷有新的業務加進來,不是簡單的改動就能完成的,是以”壞味道“隻會慢慢惡化。

/ 無業務分層 /

目前python的後端代碼沒有層級關系,整體屬于标準的過程式代碼,一個功能函數可能成百上千行,所有的功能都在一個函數裡面堆積完成。雖然做過一些功能函數的拆分,但是整體還是過程式的邏輯處理。業務邏輯的封裝與隔離幾乎沒有。

/ 循環/重複查庫 /

目前在koi中,django的使用大大友善了外部資料的擷取,但是也導緻了外部調用的泛濫。比如在不同的函數中可能都需要Application得資料,但是傳參隻傳了app_id,那麼就很可能導緻再一次查表的操作,這種邏輯在koi中是非常多的。另一方面由于django的封裝很容易讓大家忽略這是一個外部調用,是以很容易寫出在循環中查庫的場景。

/ 邏輯備援/分散 /

不同的校驗函數都堆積到了一起,這就導緻一方面校驗函數的單測很難編寫,另一方面校驗功能難以複用,以至于出現很多校驗邏輯存在重複編寫的情況,導緻邏輯備援。

/ 函數職責複雜 /

接上述例子,在這個校驗函數裡還有業務邏輯或者資料轉換的操作,後續的改動将更加難以維護與測試。資料校驗與業務邏輯應該分開,做好隔離才能友善後續擴充與測試。

/ 未做抽象 /

未做足夠抽象表現為不同實體在做着類似的操作,但是沒有對操作進行統一的封裝與隔離處理,比如下方代碼中實作開啟接口,涉及很多實驗類型的開啟操作,都是通過if else插入自己的邏輯。如果抽象合理的話應該是不同實驗都去實作一個實驗開啟的接口,在主業務流程裡看不到差異化處理,這樣才能做到比較好的業務隔離。将複雜的功能隐藏在簡單的接口後邊,才是更好的抽象。

/ 耦合嚴重 /

目前功能的外部調用與業務邏輯完全混在一起,這裡不作舉例。是以業務邏輯對外部依賴非常嚴重,一個接口的變更或者字段的修改可能就會導緻業務邏輯出現問題。外部依賴應該是服務于業務邏輯的,外部依賴的變更不應影響到業務邏輯,這樣才能實作依賴反轉。

通過重構解決上述提到的問題。基于現有業務場景對實驗管理子產品進行重構,解決問題,建構高内聚低耦合的業務代碼,提升代碼的可讀性、可維護性、可擴充性與可測試性。

通過提高可擴充性,大大縮短後續内部功能開發所需的開發時間;通過封裝實作代碼的複用;通過隔離減少功能間的互相影響,減少bug出現的機率;通過依賴反轉與關注點分離實作高内聚低耦合的業務代碼。

通過架構重構提升整體團隊的架構與規範意識,提升整體技術水準,增強團隊戰隊力。

火山引擎A/B測試平台的實驗管理重構與DDD實踐

接下來将對實驗管理重構的具體工作進行介紹。DDD主要解決代碼“寫在哪裡”的問題,但是具體的實作細節還是需要根據業務的具體場景做相應的處理。本次重構從資料結構定義、業務校驗、業務邏輯與領域對象建構等方面對重構的具體工作進行介紹。

/ 子產品梳理 /

目前産品現有及後續部分新增功能梳理如下圖所示。

火山引擎A/B測試平台的實驗管理重構與DDD實踐

/ 領域模組化 /

按照 DataTester 的實驗功能,可以将實驗域細分為四個領域子產品:日志、實驗、實驗層管理和工作流程。其中實驗層管理和工作流程分别由其它服務子產品接管,是以在實驗倉庫下需要重構與完善的即日志子產品與實驗核心子產品。而層管理僅做了對内部署的适配,對外部署仍未完成适配,是以在此次重構過程中會對層相關的邏輯做一定的功能抽象,友善後續内外統一後的對接。

  • 日志域

日志域主要對外暴露擷取記錄檔的接口,對内提供領域對象的change-tracking能力,生成所需格式的記錄檔檔案。具體的,日志目前有記錄檔和全局操作曆史兩部分。除此之外,期望能夠通過ChangLog域提供的change tracking能力,優化資料庫操作,減少不必要的save與update操作。

  • 實驗域

實驗域相比日志域的業務邏輯更為複雜一些。基于可擴充與可複用的原則,對實驗的功能拆分成三個部分,分别為BaseExperiment、ExperimentExtension與ExperimentPlugin。子產品的拆分其實都是在隔離與複用之間不停權衡的結果,也即DRY原則與開閉原則共同作用的結果。其中BaseExperiment為最基礎的子產品,ExperimentExtension與ExperimentPlugin子產品為主要功能擴充點,接下來會對每個部分負責的功能進行詳細介紹。通過UML類圖可以看出,在“充血模型”下領域實體的業務方法非常豐富,模型的自我表達能力非常強。

火山引擎A/B測試平台的實驗管理重構與DDD實踐
  • BaseExperiment

BaseExperiment功能如其名是實驗最基礎且通用能力,除了常用的實驗與版本的一些操作外,還包括實驗收藏、Demo實驗的特殊處理、郵件通知、版本管理、名額管理與目标閱聽人等部分。

  • 版本管理

版本管理作為實驗比較核心的子產品,也可以看做是基礎能力,因為實驗的本質就是管理一系列不同的差異化配置,然後結合線上流量看效果。和實驗版本相關的功能都可以在該實體中實作或者擴充,比如關閉單個實驗組、可視化實驗下頁面資訊編輯等。其中,白名單和版本息息相關,是以将白名單相關處理邏輯放到該版本管理子產品下進行統一處理。另外對于穿山甲特殊場景,需要加載預置版本配置,這種場景在實際設計的時候需要注意其通用性。

  • 實驗層

實驗層管理部分後續會統一放到實驗倉庫裡面維護。目前實驗中隻有對層的一些簡單操作與校驗功能。

  • 名額管理

名額管理和版本管理類似,都是實驗過程中比較重要的子產品。在該子產品中主要處理名額的增删改查與關聯關系的維護,比如實驗名額關聯關系、實驗與名額組關聯關系等。

  • 目标閱聽人

目标閱聽人也即實驗子產品中的filter條件,用于對請求的使用者做路由配置。由于其涉及到的業務邏輯較多,是以單獨抽出TargetRule實體對這部邏輯進行處理。後續還将負責過濾條件的參數轉換(backend)與一些關聯條件的建立,比如過濾條件與分群、服務端過濾參數的關聯關系等。

/ 業務流程 /

對實驗的主流程進行總結,可以發現任何實驗的操作都可以抽象成三個步驟,即資料校驗、特定邏輯處理與資料持久化。這也為設計可擴充與可插拔的代碼架構提供了可行性。具體的實驗建立的主流程如下圖所示,按功能類型可以大概分為三個部分:validator、process與save。

  • validator對資料進行校驗,如有不符合的資料将會直接傳回錯誤。
  • process處理業務邏輯,包括資料轉換與建構聚合根等操作,出現問題也會直接傳回并報錯。
  • save為最後的持久化邏輯,當資料持久化報錯也會傳回,并取消事務。

在系統分層中,各個子產品負責的主要功能大緻如下圖所示。不同層各司其事完成業務邏輯,下面示例示例代碼為實驗開啟接口對應領域服務的具體實作。

火山引擎A/B測試平台的實驗管理重構與DDD實踐
火山引擎A/B測試平台的實驗管理重構與DDD實踐
  • 去除步驟依賴

在實驗建立的互動上,通常需要幾步完成元資訊的建立,并且在第四步時會将實驗從草稿态轉為調試态。但從restful規範來講,資源的建立、更新與部分更新(狀态修改)應該是通過不同的操作來實作。并且如果将建立的操作與實驗操作的步驟進行綁定,将會大大限制可擴充性。目前koi代碼中已經實作了部分資源建立、更新與步驟的解耦,但是還是存在與步驟強綁定的操作。此次實驗RPC服務重構将徹底摒棄步驟依賴,與步驟進行完全解耦。

具體的業務邏輯變更如下圖所示,可以看出對于實驗建立的過程,可以選擇一次完成所有字段的建立,也可以自行分多個步驟完成整體資料的建構,調試前會對資料完整性進行驗證。

火山引擎A/B測試平台的實驗管理重構與DDD實踐

為了實作上述功能,需要對實驗idl中的字段類型進行調整,将所有的字段除了id均改為optional字段,這樣服務就可以擷取此次接口調用需要更新的字段,除此以外,基于這些資訊還可以實作資料的按需校驗,下面将對自動校驗的實作進行介紹。

  • 自動資料校驗

資料校驗在業務邏輯代碼中作用比較重要,關系整個後續業務邏輯能否正确運作。對參數的校驗根據其具體業務邏輯與場景,可以分為字段校驗、依賴校驗、功能校驗與邏輯校驗四個部分。

  • 字段校驗
  • 比較常見的校驗類型,比如實驗的名稱不能超過多少個字元,實驗類型是不是合法等。
  • 依賴校驗
  • 依賴校驗顧名思義,在業務邏輯中依賴了其它子產品,比如名額,需要校驗下名額是不是合法的等。
  • 功能校驗
  • 功能校驗,比如使用者是否對某個資源有權限,又比如實驗裡面的配置沖突等。
  • 邏輯校驗
  • 邏輯校驗主要是一些具體的業務邏輯,比如父子實驗中子實驗開啟與父實驗結束時,會涉及時間範圍的校驗等。

首先看下通常資料校驗是如何實作的,以下是在tob側老版本feature flag中的校驗方法。如果一次請求包含的實體或者值對象不完整,那麼就會出現很多是否設定某些字段的判斷;且建立需要的校驗與更新所需的校驗需要分開處理無法複用。這種校驗雖然能夠實作校驗的功能,但是新增校驗字段可能需要在建立與更新等校驗函數裡面做同步改動,如果有遺漏就會出現問題。那麼就需要考慮一種機制能夠按需校驗,根據設定字段自動建構建構函數完成對參數的校驗。

火山引擎A/B測試平台的實驗管理重構與DDD實踐

通常校驗邏輯會寫在正式的業務邏輯之前,但考慮到資料有依賴關系以及校驗需要完整的領域模型,是以此次 DataTester 重構将資料校驗作為聚合的一部分。為了實作業務邏輯與資料校驗的完全解耦以及更好的支援後續的擴充,本次通過Validator對象實作了自動校驗機制。

後續會根據實際情況進行優化,拆分出一些不需要外部調用就可以進行判斷的校驗參數,以便能夠做到及時傳回

上面講過,雖然在建立草稿态實驗的流程上會分為N個步驟,但是在實際業務代碼中期望盡量避免和步驟強綁定的校驗,是以會用一種更為通用的方式,即判斷是否在請求參數中設定了某些參數,如果設定了就對該參數進行校驗 (idl中除了id字段,其餘均已改成optional字段)。此時資料的校驗僅為對應子產品資料的校驗,不涉及前後資料關聯等資料的校驗。如果需要自定義一些業務邏輯校驗,可以在各自操作的校驗方法中自行注冊。

除了各個子產品資料需要通過校驗,還需要確定資料前後依賴性合法且相關沖突校驗均通過才可以正常開啟。編輯實驗态實驗同樣需要完成整體合理性校驗。除此之外實驗有調試、開啟、當機與暫停等比較多的狀态流轉接口,每種操作需要的功能校驗可能不盡相同,但是每種校驗所需的功能是确定的,是以隻需要按需注冊校驗函數即可做到功能定義域使用的分離。

在具體實作過程中,通過Validator類實作功能定義與功能調用的隔離,避免了現階段各種校驗功能耦合在一個函數中,而導緻可讀性、可擴充與可測試性差的問題。

  • 按需建構聚合

由于不同的請求對于聚合的建構要求不同,比如實驗的當機隻需要基礎的實驗資訊即可,而實驗的關閉則需要考慮版本與父子實驗的資訊,考慮到性能問題,這裡采用通過配置按需建構,配置的格式如下圖所示:

火山引擎A/B測試平台的實驗管理重構與DDD實踐

通過一個json資料結構對需要建構的子產品進行配置,每增加一個子產品或者子產品的子元素,需要自行在構造函數中做對應的實作,下面通過一個實驗開啟需要配置的子產品示例來做說明。

func GetModuleConfigMapForExpStart() map[string]interface{} {
    return map[string]interface{}{
       constant.ApplicationModule: map[string]interface{}{
          constant.ModuleList: []string{
             constant.ApplicationInfoModule,
          },
       },
       constant.ExperimentModule: map[string]interface{}{
          constant.ModuleList: []string{
             constant.BaseExperimentModule, constant.VersionModule, constant.RunningExpListAtSameLayerModule},
       },
       constant.ExtensionModule: map[string]interface{}{
          constant.ParentChildModule: map[string]interface{}{
             constant.ParentExperimentModule: map[string]interface{}{
                constant.ModuleList: []string{
                   constant.ParentBaseExperimentModule},
             },
          },
          constant.IntelligentModule: map[string]interface{}{
             constant.ModuleList: []string{
                constant.IntelligentTrafficMapModule},
          },
       },
       constant.PluginModule: map[string]interface{}{
          constant.RolloutModule: map[string]interface{}{
             constant.ModuleList: []string{
                constant.RolloutModule,
             },
          },
          constant.ExperimentWorkflowModule: map[string]interface{}{
             constant.ModuleList: []string{
                constant.ExperimentWorkflowModule,
             },
          },
       },
    }
}           
{
    "Application":{
        "ModuleList":[
            "ApplicationInfo"
        ]
    },
    "Experiment":{
        "ModuleList":[
            "BaseExperimentEntity",
            "Version",
            "RunningExpListAtSameLayer"
        ]
    },
    "Extension":{
        "Intelligent":{
            "ModuleList":[
                "TrafficMap"
            ]
        },
        "ParentChild":{
            "ParentExperiment":{
                "ModuleList":[
                    "ParentBaseExperiment"
                ]
            }
        }
    },
    "Plugin":{
        "ExperimentWorkflow":{
            "ModuleList":[
                "ExperimentWorkflow"
            ]
        },
        "Rollout":{
            "ModuleList":[
                "Rollout"
            ]
        }
    }
}           

如上圖所示,此時建構的領域對象包含應用資訊、實驗基礎與擴充插件四個部分。其中應用資訊主要是常用的app_id和product_id等;實驗基礎資訊建構了實驗版本資訊、實驗所在層的traffic資訊與同層實作清單;擴充部分需要父子實驗中父實驗的基本資訊、版本資訊與所在層的traffic map資訊,智能實驗部分需要擷取traffic map資訊;插件子產品中擷取了平滑生效與實驗工作流程兩部分資訊。其中每一個key需要有對應的實作。具體到實作部分,為了統一函數簽名,采用閉包的方式封裝repo實作,舉一個擷取extension子產品的例子如下所示:

type BaseFactory struct {
    repoImpl   domain.IRepository
    baseExp    *base.Experiment
    moduleTree interface{}
    *builder.ParentChildBuilder
    *builder.IntelligentBuilder
    *builder.MultiRoundExperimentBuilder
    *plugin.CanaryControlBuilder
}
func NewBaseFactory(repoImpl domain.IRepository, baseExp *base.Experiment, moduleTree interface{}) *BaseFactory {
    return &BaseFactory{
       repoImpl:                    repoImpl,
       baseExp:                     baseExp,
       moduleTree:                  moduleTree,
       ParentChildBuilder:          builder.NewParentChildBuilder(baseExp, repoImpl, moduleTree),
       IntelligentBuilder:          builder.NewIntelligentBuilder(baseExp, repoImpl, moduleTree),
       MultiRoundExperimentBuilder: builder.NewMultiRoundExperimentBuilder(baseExp, repoImpl, moduleTree),
    }
}
func (b *BaseFactory) BuildExtension(ctx context.Context) (*extension.Extension, error) {
    if b.moduleTree == nil {
       return nil, nil
    }
    entityMap := make(map[string]interface{})
    for k := range b.moduleTree.(map[string]interface{}) {
       err := b.getExtensionModuleMap()[k](ctx, entityMap)
       if err != nil {
          return nil, err
       }
    }
    return extension.NewExtension(b.baseExp, entityMap), nil
}
func (b *BaseFactory) getExtensionModuleMap() map[string]func(ctx context.Context, entityMap map[string]interface{}) (err error) {
    return map[string]func(ctx context.Context, entityMap map[string]interface{}) (err error){
       constant.ParentChildModule: func(ctx context.Context, entityMap map[string]interface{}) (err error) {
          entityMap[constant.ParentChildEntity], err = b.BuildParentChildEntity(ctx)
          return err
       },
       constant.IntelligentModule: func(ctx context.Context, entityMap map[string]interface{}) (err error) {
          entityMap[constant.IntelligentEntity], err = b.BuildIntelligentEntity(ctx)
          return err
       },
       constant.MultiRoundExperimentModule: func(ctx context.Context, entityMap map[string]interface{}) (err error) {
          if !b.baseExp.NeedLoadMultiRoundExperiment() {
             return nil
          }
          entityMap[constant.MultiRoundExperimentEntity], err = b.BuildMultiRoundExpEntity(ctx)
          return err
       },
    }
}           
  • 業務邏輯處理

業務邏輯部分按照實驗域的劃分,分為三個部分。

  • BaseExperiment部分業務邏輯比較簡單,可以視情況增加業務邏輯,原則上這些entity或者value object在建構聚合的時候均已完成建立。
  • ExperimentExtension部分主要是根據不同實驗類型做一些擴充,可以根據實際場景做各自的業務邏輯操作。
  • ExperimentPlugin部分主要是實驗粒度的進階功能擴充,這裡通過職責鍊模式進行擴充,在抽象的接口中完成不同操作下的業務邏輯。

下面用實驗開啟的業務邏輯為示例作說明。由于采用了面向對象的程式設計方式,業務模型也叫程式設計“充血模型”,每個實體都擁有豐富的方法。而對實驗開啟的操作,也按照上面的劃分從三個子產品進行操作,每個子產品直接或者間接的提供了Start方法,進而完成整體的業務。具體到extension與plugin部分,在聚合中依賴統一抽象的接口,不關注其具體實作,具體是程式設計實驗還是可視化實驗,亦或是加入了精準熔斷或者實驗審批,都将通過統一的方法調用完成相應的業務邏輯。

另一方面,由于不同的擴充和插件通過接口的方式做了隔離,後續新增子產品或者修改部分子產品的操作,将會變得非常容易,隻需要關注目前的修改即可,不用擔心影響到其它子產品。

火山引擎A/B測試平台的實驗管理重構與DDD實踐

在業務執行的過程中,整體業務邏輯呈現從上到下、逐級劃分的一個過程。一個功能将層層拆分最終落到一個單一職責的函數或者方法中,這與組織管理有着異曲同工之處。這樣一來函數或者方法将盡可能的複用,單一功能的函數或者方法将更容易測試。這種從上到下的實作方式,和滑鐵盧程式設計風格中所屬的關注資料流動非常類似,上層隻需要做好任務的拆分,底層按照要求實作即可。最終開發者隻需要關注單個功能的實作,将大大降低認知負荷,更容易規避潛在的bug。

我偶然發現了一種極其強大的程式設計哲學,那就是你應該忽略代碼,那隻是計算機要遵循的一大堆指令。相反地,你要專注于資料,弄清楚它如何流動。--《滑鐵盧程式設計風格》

  • 外部服務調用
  • 業務調用

在一些 DataTester 的業務場景中,業務邏輯執行結束後需要調用第三方依賴完成一些操作,對于比較統一的處理比如實時生效需要發送的消息隊列,在領域服務中統一處理接口。而對于一些特殊的case,比如可視化實作在開啟的時候需要調用圈選模拟器建立熱力圖,并把熱力圖的id存到version表中,而其它實驗類型可能又不需要類似的外部操作。為了差異化處理這種業務需求,在領域服務中增加了外部服務調用子產品。

目前有兩種外部服務調用類型,一種是基于業務場景(tob或者internal)的差異化處理,一種是基于實驗類型的差異化處理,相關UML類圖如下所示。後續如果有新的業務場景可以在此處進行擴充。

  • 資料持久化

資料持久化的實作還是還是參考架構規範中的實作形式,将事務放到領域服務層。另外,除了對資料庫的調用,相關依賴方的調用比如微服務間的調用均統一封裝在repo層,如需添加新的依賴隻需進行依賴注入即可,友善統一管理。

火山引擎A/B測試平台的實驗管理重構與DDD實踐

雖然目前這套拆分開起來能夠支撐 DataTester 的業務場景與業務需求,但是并非是一個“終極架構”。很多細枝末節的差異化處理仍存在。随着後續業務場景的持續豐富,目前架構可能還需要做進一步擴充。目前的架構設計預留了一定的伸縮空間,如果後續增加了比較特殊的實驗類型,在實驗建立過程中沒辦法與目前的主流程複用,就可以将相關邏輯上升到extension子產品等。

現在架構重構到了攻堅階段,沒有産品形态的重新設計,沒有産品功能的完全統一, 技術重構就不可能進行到底。已經取得的重構成果還可能得而複失,架構上産生的新的問題也不可能從根本上進行解決,合并也就可能變成了“縫合”。

目前重構工作已經結束并且已經上線到各環境,并高效支撐了數十種實驗類型的日益疊代工作。後續會對領域對象做歸類細化操作,確定能将領域對象作為工具庫使用,通過不同的組合排列實作新的功能,支援新增業務場景與需求;更進一步會将服務能力通過插件進一步開放,沉澱為插件市場,實作中台能力與BP業務方的共赢。

重構效果:

  1. 需求開發效率提升30%
  2. 性能提升約50%

以上就是火山引擎A/B測試平台 DataTester 實驗管理架構更新的實作,希望對大家有所啟發。

火山引擎DataTester作為火山引擎數智平台VeDI旗下的核心産品,源于位元組跳動長期的技術和業務沉澱。目前,DataTester已經服務了上百家企業,包括美的、得到、博西家電、樂刻健身等知名品牌。這些企業在多個業務環節中得益于DataTester的科學決策支援,實作了業務的持續增長和優化。

作者:DataTester

來源-微信公衆号:位元組跳動資料平台

出處:https://mp.weixin.qq.com/s/Ca780IZraMas5PwwiHlaQA

繼續閱讀