B站大型開播平臺重構(gòu)
1.背景
"凡事預(yù)則立,不預(yù)則廢。"
——《禮記·中庸》
在文章的開頭,我們可以先來了解一下直播業(yè)務(wù)的大致業(yè)務(wù)架構(gòu)。將直播業(yè)務(wù)簡單分為兩大類場景"看播"、"開播",前者主要面向C端觀看用戶,后者主要面向B端開播主播。主播通過"開播工具"的開播產(chǎn)品功能,經(jīng)由"開播平臺"完成一系列開播動作,最后將媒體信息采集推送到多媒體服務(wù)器,C端觀看用戶就可以從CDN看到直播的視頻流內(nèi)容。
從數(shù)據(jù)流向來講,"開播"場景是產(chǎn)生數(shù)據(jù)和觸發(fā)關(guān)鍵事件的源頭。這些數(shù)據(jù)或事件會涉及多個領(lǐng)域,如安全合規(guī)信息、房間信息、主播信息、開播場次信息、安全審計信息、多媒體信息等。
打個不太準(zhǔn)確的比喻。開播系統(tǒng)對于直播平臺的重要性,等同于訂單系統(tǒng)對于交易平臺的重要性。開播工具作為播端功能入口,直接面向官方開播工具(直播姬、粉版大加號、三方工具如OBS開播)的用戶以及內(nèi)部平臺方的用戶(其他業(yè)務(wù)線、產(chǎn)品&運(yùn)營),對開播體驗負(fù)責(zé)。開播平臺在其中的職責(zé),是向開播工具和其他平臺方提供開播相關(guān)的平臺化業(yè)務(wù)能力,如開關(guān)播、開通直播間、切換分區(qū)等。
同時,開播平臺與同級的業(yè)務(wù)平臺一起協(xié)作,才能支撐起完整的開播工具產(chǎn)品能力,如語聊房業(yè)務(wù)需要開播工具管理平臺(開播工具類支持)、主播互動平臺(主播互動能力支持)、流媒體服務(wù)端共同參與才能完成,從不同的維度幫助開播工具生態(tài)完善化。
圖片
一些涉及到的業(yè)務(wù)/技術(shù)名詞,在此我們也做出列舉并做出簡單介紹:
名詞 | 名詞簡述 |
領(lǐng)域驅(qū)動設(shè)計(DDD) | DDD 是 Domain-Driven Design 的縮寫,是 Eric Evans 于 2004 年提出的一種軟件設(shè)計方法和理念。 其主要的思想是,利用確定的業(yè)務(wù)模型來指導(dǎo)業(yè)務(wù)與應(yīng)用的設(shè)計和實現(xiàn)。主張開發(fā)人員與業(yè)務(wù)人員持續(xù)地溝通和模型的持續(xù)迭代式演化,以保證業(yè)務(wù)模型與代碼實現(xiàn)的一致性,從而實現(xiàn)有效管理業(yè)務(wù)復(fù)雜度,優(yōu)化軟件設(shè)計的目的。 |
領(lǐng)域知識 | (領(lǐng)域驅(qū)動設(shè)計概念)指能準(zhǔn)確傳達(dá)業(yè)務(wù)規(guī)則的描述,也是領(lǐng)域中業(yè)務(wù)知識的集中體現(xiàn)。 理想狀態(tài)下,領(lǐng)域?qū)<液途幋a人員對業(yè)務(wù)的認(rèn)知應(yīng)該完全一致,就算不同的人寫代碼也應(yīng)該偏差不大。 |
領(lǐng)域事件(Domain Event) | 領(lǐng)域事件,是在業(yè)務(wù)上真實發(fā)生的客觀事實,這些事實對系統(tǒng)會產(chǎn)生關(guān)鍵影響,是觀察業(yè)務(wù)系統(tǒng)變化的關(guān)鍵點。對于開播而言,"房間已經(jīng)被主播主動流轉(zhuǎn)為開播了"就是一個領(lǐng)域事件。 |
視頻云(直播) | 一般指廣義的直播流媒體業(yè)務(wù),提供主播推流、觀眾拉流的基礎(chǔ)能力。 |
看播 | 一般指廣義的直播觀看業(yè)務(wù)域,涉及進(jìn)房、彈幕互動、禮物打賞等業(yè)務(wù)場景,一般面向C端(觀眾) |
直播姬 | 一般指移動端APP"嗶哩嗶哩直播姬",以及Windows系統(tǒng)下的"嗶哩嗶哩直播姬"應(yīng)用。(注:嗶哩嗶哩粉版APP和Web頁面也提供了開播能力) |
測試驅(qū)動開發(fā)(TDD) | 測試驅(qū)動開發(fā)是一種軟件開發(fā)過程中的應(yīng)用方法,由極限編程倡導(dǎo),以其倡導(dǎo)先寫測試程序,然后編碼實現(xiàn)其功能得名。本文中主要涉及ATDD集成測試驅(qū)動和UTDD單元測試驅(qū)動。 |
戰(zhàn)略設(shè)計 | 戰(zhàn)略設(shè)計也稱為戰(zhàn)略建模,是指對業(yè)務(wù)進(jìn)行高層次的抽象和歸類。主要手段包括理清上下文和進(jìn)行子域的劃分。 |
戰(zhàn)術(shù)設(shè)計 | 戰(zhàn)術(shù)設(shè)計也稱為戰(zhàn)術(shù)建模,是指對特定上下文下的模型進(jìn)行詳細(xì)設(shè)計。我們對開播新的微服務(wù)中各個模塊職責(zé)的編排,就是戰(zhàn)術(shù)設(shè)計的一部分。 |
開播平臺/開播服務(wù)平臺 | 一般指狹義的后端業(yè)務(wù),即提供開播房間狀態(tài)流轉(zhuǎn)、直接對客戶端提供推流信息的服務(wù)端業(yè)務(wù)。提供如開關(guān)播接口、推流地址獲取的通用業(yè)務(wù)接口。 |
開播工具 | 一般指廣義的可進(jìn)行開播的業(yè)務(wù)場景,如直播姬、Web主播中心、粉APP開播等,相較于開播更偏向于端到端場景 |
開播 | 一般指廣義的開播業(yè)務(wù)域,涉及開播、關(guān)播等業(yè)務(wù)場景,一般面向B端(主播) |
事件風(fēng)暴(Event Storming) | 事件風(fēng)暴是一種捕獲行為需求的方法,類似傳統(tǒng)軟件的開發(fā)用例分析。所有人員(領(lǐng)域?qū)<液图夹g(shù)專家) 對業(yè)務(wù)行為進(jìn)行一次發(fā)散,并最終收斂達(dá)到業(yè)務(wù)的統(tǒng)一。 |
事件溯源(Event Sourcing) | 事件溯源是一種用事件日志追溯狀態(tài)的方法,因此事件溯源的關(guān)鍵在于事件日志。對于開播而言只是借用了"溯源"這種思想,用于保證新舊開播鏈路的關(guān)鍵狀態(tài)完全一致。 |
SOP | Standard Operating Procedure,標(biāo)準(zhǔn)作業(yè)程序。本次重構(gòu)過程,發(fā)布、應(yīng)急處理、故障處理都使用此種方式進(jìn)行推進(jìn)。 |
room / room-service | PHP歷史服務(wù),本次被遷移的主角。眾多歷史業(yè)務(wù)在該服務(wù)中。 |
live-streaming / streaming | 新開播微服務(wù),今后承載開播領(lǐng)域主要業(yè)務(wù)的落地實體。 |
app-blink | 開播工具網(wǎng)關(guān)層微服務(wù),直接承載客戶端的請求。 |
1.1 現(xiàn)狀和挑戰(zhàn)
直播開播系統(tǒng),伴隨著B站直播的成長貫穿始終。
發(fā)展初期所有的直播業(yè)務(wù)基本都在一套php代碼里完成,包括開播部分。之后的直播高速發(fā)展中,很多模塊已經(jīng)順利完成遷移。
開播部分也嘗試過遷移,但是未能成功完成。還不太幸運(yùn)的出了比較嚴(yán)重的線上故障。(這給后面的再次重構(gòu)積累了寶貴的經(jīng)驗。)
1.1.1 債務(wù)清單
- 業(yè)務(wù)積累厚:最初的代碼大致是從2017年開始的,要問里面的門道究竟有多少,可能另起一篇文章也難以詳盡。
- 代碼可讀性差:php是弱類型+動態(tài)類型特點,代碼可讀性方面有非常大的挑戰(zhàn)。同時因為涉及到跨語言遷移,需要有機(jī)制能檢查兩邊邏輯和數(shù)據(jù)的一致性。
- 開發(fā)模式陳舊:php代碼在整個開發(fā)架構(gòu)上,也是偏"事務(wù)腳本模式"。多個領(lǐng)域混雜在一起,互相耦合調(diào)用,解耦異常困難。
- 質(zhì)量配套欠缺:單元測試和自動化測試方面也比較缺乏。要想順利完成重構(gòu)遷移,這塊是重要的前置工作。
- 技術(shù)棧滯后:php技術(shù)棧,已經(jīng)不符合公司的整個技術(shù)棧主路線。各種lib、中間件支持方面欠缺,急需技術(shù)棧升級。
1.1.2 遺留系統(tǒng)特征
業(yè)界對遺留系統(tǒng)的普遍定義中有4個關(guān)鍵字:舊、過時、重要、仍在使用。
圖片
事實并非完全如此:有些系統(tǒng)時間雖長,但如果一直堅持現(xiàn)代化的開發(fā)方式,在代碼質(zhì)量、架構(gòu)合理性、測試策略、DevOps 等方面都保持先進(jìn)性,這樣的系統(tǒng)就像陳年的老酒一樣,歷久彌香。而有些系統(tǒng)雖然剛剛開發(fā)完成,但如果在上述幾個方面都做得不好,我們也可以把它叫做遺留系統(tǒng)。遺留系統(tǒng)在維護(hù)成本、合規(guī)性、安全性、集成性等方面都會給企業(yè)造成巨大的負(fù)擔(dān),但同時也蘊(yùn)含著豐富的數(shù)據(jù)和業(yè)務(wù)資產(chǎn)。我們應(yīng)該對遺留系統(tǒng)進(jìn)行現(xiàn)代化,讓它重新煥發(fā)青春。
顯然在知曉了舊開播系統(tǒng)有諸多歷史債務(wù)后,我們可以認(rèn)為它確實是一個搖搖欲墜的遺留系統(tǒng)。而我們本次的目標(biāo),就是將開播平臺這個重要的遺留系統(tǒng)進(jìn)行重構(gòu),讓它"煥發(fā)新生",并讓他在可預(yù)見的未來中都維持現(xiàn)代化系統(tǒng)的標(biāo)準(zhǔn)。
1.2 安全生產(chǎn)
在開播系統(tǒng)的維護(hù)、迭代、演進(jìn)中,我們也致力于系統(tǒng)的"安全生產(chǎn)"問題:
- 如何降低研發(fā)的業(yè)務(wù)認(rèn)知成本、溝通成本,降低復(fù)雜度,從而提高"卡車系數(shù)",保證團(tuán)隊內(nèi)部能保證形成快速backup?
- 如何通過技術(shù)演進(jìn),增加開播系統(tǒng)的可拓展性/魯棒性/可測試性?
- 遷移新系統(tǒng)時,新老系統(tǒng)如何優(yōu)雅安全切換、過程中的新舊系統(tǒng)數(shù)據(jù)是否可以進(jìn)行白盒對比?
2.開播系統(tǒng)架構(gòu)演進(jìn)
每個士兵在上戰(zhàn)場前必須清楚的明白,他這場小小的戰(zhàn)斗在大局中起的作用。——伯納德 · L · 蒙哥馬利(英國)
2.1 審視:問題出在哪里?
在著手進(jìn)行改造升級之前,不妨先從整體業(yè)務(wù)的迭代流程和已有架構(gòu)中找到問題,以確定真正值得樹立的目標(biāo),避免陷入"只見樹木不見森林"的狹小視野中。
我們不難發(fā)現(xiàn),這個日積月累的遺留系統(tǒng)當(dāng)中,它的業(yè)務(wù)研發(fā)流程種種令人難以忽視的問題:業(yè)務(wù)知識、業(yè)務(wù)架構(gòu)的認(rèn)識遺失、產(chǎn)研語言的不統(tǒng)一等等。
2.1.1 業(yè)務(wù)知識與業(yè)務(wù)架構(gòu)的生命周期
開播域作為播端的核心業(yè)務(wù)域,由于其悠久的歷史和維護(hù)團(tuán)隊同學(xué)的變更,在幾經(jīng)周折后,領(lǐng)域知識已經(jīng)處于混沌狀態(tài)。這種情況下,顯然比起遺留代碼和不合理的實現(xiàn)邏輯而言,更大的bug可能最終會發(fā)生在人身上,也就是我們對業(yè)務(wù)知識本身的認(rèn)識:對業(yè)務(wù)知識缺乏了解,往往是拖慢業(yè)務(wù)迭代甚至是釀成線上事故的罪魁禍?zhǔn)住?/p>
對于業(yè)務(wù)架構(gòu)的認(rèn)知遺失,則會導(dǎo)致業(yè)務(wù)域內(nèi)職責(zé)的混亂:“這個新增業(yè)務(wù)是否應(yīng)該由我們負(fù)責(zé)?”。落實到開發(fā)者身上就變成了應(yīng)用架構(gòu)的混亂:“這個業(yè)務(wù)我們到底應(yīng)該寫在哪個微服務(wù)里?”
最明顯最集中的問題會爆發(fā)在端到端用例中:戰(zhàn)略設(shè)計上一個實體上業(yè)務(wù)行為的不清晰往往代表著一個甚至多個端到端用例的認(rèn)知缺失,映射到戰(zhàn)術(shù)實現(xiàn)上就會演變成災(zāi)難性的"需求引入變更時,未考慮到某個用戶用例",最終在上線前的驗收環(huán)節(jié)甚至是上線后,發(fā)現(xiàn)這個需求的引入導(dǎo)致了bug的產(chǎn)生。
我們當(dāng)然可以把這種事故歸結(jié)為“歷史遺留問題”,但是對于功能的使用者而言,這種糟糕體驗會直接讓平臺被貼上“不專業(yè)”的負(fù)面標(biāo)簽。對平臺本身而言,這種災(zāi)難性的錯誤堆砌也只會讓系統(tǒng)不斷熵增,復(fù)雜程度愈發(fā)不可收拾,最終花費(fèi)在處理問題、歷史代碼考古上的人力一增再增缺無濟(jì)于事。
這部分無疑是開播重構(gòu)項目中,最迫切需要解決的問題。
2.1.2 描述語言不統(tǒng)一
在業(yè)務(wù)人員和產(chǎn)品的角度來看,"開播"這個用例往往和各端開發(fā)人員所說的"開播"又有著某種微妙的差別。業(yè)務(wù)視角下的開播,往往是用戶一次完整的開播體驗,比如,打開移動直播姬,調(diào)整好各種用戶設(shè)置,點擊開播,最終看到自己的畫面被正確投放到b站直播間,并且可以完成后續(xù)和觀眾的互動。
而在技術(shù)視角下的開播,"開播"是各執(zhí)行方的橫切面組成的:客戶端完成最直接的ui/ux互動、直播服務(wù)端進(jìn)行用戶請求校驗、視頻流和直播業(yè)務(wù)數(shù)據(jù)的協(xié)調(diào)、視頻云負(fù)責(zé)接收用戶的上行視頻流;每一方對"開播"的這個詞解釋就產(chǎn)生了差異:客戶端進(jìn)入到直播界面并點擊開播叫開播,服務(wù)端的開播接口被調(diào)用了也被視為開播,視頻流被推送到視頻云上行服務(wù)器的時候也可能被視為開播。
泛泛而談的話,各方的解釋都沒有太大問題,但是這樣的解釋無法確切指定它在業(yè)務(wù)里處于哪一部分,會造成什么結(jié)果。最終呈現(xiàn)在一位新進(jìn)入技術(shù)團(tuán)隊的同學(xué)的眼中可能是這樣的場景:
舉例
客服:主播反饋線上無法開播。【問題平臺】PC直播姬; 【一級分類】開播; 【二級分類】無法開播; 【問題描述】主播反饋進(jìn)入移動直播姬開播界面后,點擊開播后,不能正常推流;
開發(fā)1:是不是開播了多次?
客服:不是,主播開播了一次
開發(fā)1:那可以讓用戶重試
開發(fā)2:是不是視頻云推流服務(wù)出了問題?@視頻云
客服:用戶已經(jīng)重試了,還是不能正常開播(其實是在另一臺設(shè)備上已經(jīng)推流了,還在嘗試使用其他設(shè)備推流)
開發(fā)3:視頻云看到用戶推流是正常的 (推流監(jiān)控圖)
開發(fā)1:哦,原來是重復(fù)開播了
假設(shè)我是一位團(tuán)隊新成員,在看到最終輸出的"重復(fù)開播"結(jié)論之前,得到的都是點狀的信息,沒有完整的用例以供參考,難以理解線上問題的癥結(jié)在何處。如果這個時候甚至沒有文檔來描述開播領(lǐng)域相關(guān)業(yè)務(wù),或者是開播流程的場景快照,那更是一場新人的災(zāi)難——可能需要專門請教團(tuán)隊中熟悉開播領(lǐng)域的資深開發(fā)為他進(jìn)行講解才能瞥見開播業(yè)務(wù)的一隅,且授課效果還要取決于講述人的結(jié)構(gòu)化敘述能力,這是我們從效率考量上不愿意見到的。
可以舉一個貼近實際開發(fā)人員的例子,"請教了3位同事才知道了開播記錄是怎么產(chǎn)生的"、"請教了3位同事才本地構(gòu)建成功",諸如此類的尷尬在日常工作中屢見不鮮的,實際上這類問題只會對程序員了解業(yè)務(wù)和編碼的積極性,以及商業(yè)化產(chǎn)品開發(fā)落地的效率起反作用。
2.1.3 對程序員的"人文關(guān)懷"
一個貼近實際開發(fā)人員的例子,"請教了3位同事才知道了開播記錄是怎么產(chǎn)生的"、"請教了3位同事才本地構(gòu)建歷史服務(wù)成功",諸如此類的尷尬在日常工作中屢見不鮮的,實際上這類問題只會對程序員了解業(yè)務(wù)和編碼的積極性、產(chǎn)品開發(fā)落地的效率起反作用。
要解決這種效率抑或積極性問題,還是需要解決根源上的"知識共享"問題。
2.2 引入領(lǐng)域驅(qū)動設(shè)計
在前文敘述問題的時候,熟悉的讀者可能就已經(jīng)想到了某個熱度經(jīng)久不衰的架構(gòu)思想:領(lǐng)域驅(qū)動設(shè)計。是的,我們在開播平臺的重構(gòu)中決定使用這種方式來解決現(xiàn)有的諸多痛點。依靠領(lǐng)域驅(qū)動設(shè)計的設(shè)計思想,通過事件風(fēng)暴建立領(lǐng)域模型,合理劃分領(lǐng)域邏輯和物理邊界,建立與現(xiàn)實世界相映射的領(lǐng)域?qū)ο蠛头?wù)架構(gòu)圖,定義符合DDD分層架構(gòu)思想的代碼結(jié)構(gòu)模型,保證業(yè)務(wù)模型與代碼模型的一致性。
相對的,對于最終的效果,也是可以預(yù)期到的:
- 統(tǒng)一業(yè)務(wù)模型和代碼模型,領(lǐng)域知識全體共享,提升協(xié)助效率;
- 通過邊界劃分將復(fù)雜業(yè)務(wù)領(lǐng)域簡單化,設(shè)計出清晰的領(lǐng)域和應(yīng)用邊界,實現(xiàn)業(yè)務(wù)和技術(shù)統(tǒng)一的架構(gòu)演進(jìn),提高人效,拒絕一加功能排期一個月;
- 通過職責(zé)劃分合理的職責(zé)邊界,降低架構(gòu)腐敗速度;
2.3 領(lǐng)域驅(qū)動視角看開播
回到"開播"這個待解決的問題域本身,對開播業(yè)務(wù)中最核心的"開播"用例,核心的業(yè)務(wù)問題包括以下幾點需要明確:
- 如何確定房間是否可以開始直播。
- 如何讓房間開始直播。
- 如何通知外界房間已經(jīng)開始直播。
也有一些非功能性的考慮:
- 如何使技術(shù)實現(xiàn)貼近現(xiàn)實中的業(yè)務(wù)原貌,從而降低認(rèn)知成本
- 如何提高我們對業(yè)務(wù)和產(chǎn)品的認(rèn)知程度和積極性
- 如何提高開播功能的魯棒性和性能
首先,我們可以采用事件驅(qū)動開發(fā)方法,結(jié)合領(lǐng)域驅(qū)動設(shè)計中的事件風(fēng)暴方法論,來梳理開播用例中的關(guān)鍵事件和參與者:
事件風(fēng)暴的核心流程就是由用戶執(zhí)行了命令,從而產(chǎn)生了事件。基于這個事件的結(jié)果,與之前相同或是其他的用戶會執(zhí)行另一個命令,產(chǎn)生新類型的事件,以此類推。而順序是按照業(yè)務(wù)邏輯而定的。所以我們在整理開播涉及的時間風(fēng)暴時,工作流如下:
- 確定用例的發(fā)起者,即主語。在開播場景中,可以是主播或者運(yùn)營。
- 確定主語的動作,比如"開啟直播"。
- 確定動作的流程中涉及的命令以及執(zhí)行命令產(chǎn)生的事件、后果,例如"新場次已被主播創(chuàng)建"。
- 補(bǔ)充流程中涉及的業(yè)務(wù)知識,例如"房間"、各種各樣的"檢查規(guī)則"以及外部系統(tǒng)。
- 當(dāng)一個完整的業(yè)務(wù)流程通過上述方式寫完之后,對于每個用戶,命令,事件進(jìn)行組合,就能獲得聚合,用事件風(fēng)暴的描述開播場次創(chuàng)建就是"主播在場次聚合上進(jìn)行了創(chuàng)建場次操作,導(dǎo)致了新場次創(chuàng)建事件",此事件發(fā)生后,用戶會在房間聚合上執(zhí)行房間開播狀態(tài)流轉(zhuǎn)操作的新命令。
圖片
事件風(fēng)暴中"定義領(lǐng)域模型"是最重要的一步,這一步需要了解實際業(yè)務(wù)形態(tài)后團(tuán)隊內(nèi)大量討論,從而達(dá)成共識。在這個階段中我們提煉了諸多的業(yè)務(wù)表現(xiàn)并且屏蔽技術(shù)實現(xiàn)細(xì)節(jié),提取出了關(guān)鍵的實體、值對象、聚合根。緊接著就可以著手對事件風(fēng)暴中的概念進(jìn)行進(jìn)一步的歸納。
通過以上步驟,我們可以清晰地梳理出開播用例中的關(guān)鍵事件和參與者,為后續(xù)的設(shè)計和開發(fā)工作奠定基礎(chǔ)。
業(yè)務(wù)描述拆解成主語和動詞的形式后,可以發(fā)現(xiàn)"房間"和"場次"是這個問題域的兩個主要元素。在領(lǐng)域驅(qū)動設(shè)計中,需要將這兩個支撐域進(jìn)行集成,最終形成"開播域"的基本解決方案。為了確保開播業(yè)務(wù)流程的完整性,還需要將"安全管控"、"分區(qū)"、"賬號"等子域或外部系統(tǒng)的知識參與其中,并將其作為業(yè)務(wù)規(guī)則和值對象等等的形式進(jìn)行表達(dá)。
圖片
根據(jù)近一年的業(yè)務(wù)現(xiàn)狀,我們參考領(lǐng)域驅(qū)動設(shè)計模式,進(jìn)行了領(lǐng)域上下文的劃分:
子域 | 能力 | 業(yè)務(wù)子域重點 |
[核心域] 開播域 | 集成開播相關(guān)的所有上下文,形成對開播場景下業(yè)務(wù)用例的實現(xiàn)方案。如開播、關(guān)播等。 | 開播 = 直播場次被創(chuàng)建 + 房間狀態(tài)變?yōu)殚_播 + 開播領(lǐng)域事件被發(fā)出 |
安全管控 = 開播常態(tài)化管控能力 + 應(yīng)急管控能力,如僅允許官方直播間開播 | ||
[通用域] 房間域 | 基礎(chǔ)業(yè)務(wù)能力,僅處理播端房間基礎(chǔ)業(yè)務(wù),如變更房間開播狀態(tài)。 | 房間狀態(tài)的增刪改查 |
發(fā)送開關(guān)播領(lǐng)域事件 | ||
[通用域] 場次域 | 基礎(chǔ)業(yè)務(wù)能力,僅處理開播領(lǐng)域產(chǎn)生的開關(guān)播場次信息 | 開關(guān)播場次信息、子場次信息(同一場直播下不同分區(qū)) |
[支撐子域] 房間管理域 | 保障房間域和開播域之間集成業(yè)務(wù)的業(yè)務(wù)完整性,如房間的推流管理。 | 房間關(guān)鍵依賴對賬:保障新開通房間存在視頻云上行推流地址 |
直播CQRS對賬:保障播端、觀看端的房間基礎(chǔ)信息一致 | ||
視頻云流管理能力封裝 | ||
[支撐子域] 開播安全管控域 | 開通直播間、預(yù)開播、開播、切換分區(qū)、推流管控策略 | 提供可配置、產(chǎn)品化的常態(tài)化策略管控能力,如是否允許某些地區(qū)開播,某分區(qū)是否需要延遲 |
必須具備快速應(yīng)對重要事件的能力。 | ||
外部域 | 視頻云管理 | 視頻云所屬領(lǐng)域,提供上行推流相關(guān)能力。開播主要使用上行推流管理、查詢上行推流地址 |
直播分區(qū) | 看端所屬領(lǐng)域,提供分區(qū)基礎(chǔ)知識。開播僅使用其查詢分區(qū)元信息,作為開播安全管控的輸入 | |
賬號上下文 | 主站賬號所屬領(lǐng)域,提供實名、等級相關(guān)知識。開播需要使用賬號信息、粉絲數(shù)等,作為開播安全管控的輸入 |
明確領(lǐng)域上下文和解決域的劃分后,緊接著就可以進(jìn)行DDD指導(dǎo)下的解決域戰(zhàn)術(shù)落地了。
領(lǐng)域劃分落實到戰(zhàn)術(shù)上的一個方案就是微服務(wù),微服務(wù)將直接作為開播域這個核心域與其他子域的實際界限。
在下文中,我們會講述如何將當(dāng)前的PHP遺留服務(wù),這個不滿足領(lǐng)域驅(qū)動設(shè)計的開發(fā)架構(gòu),演進(jìn)為受領(lǐng)域驅(qū)動設(shè)計指導(dǎo)的、貼合業(yè)務(wù)的、使用Golang搭建的整潔架構(gòu)。
3.開發(fā)架構(gòu)
設(shè)計不只是感觀,設(shè)計就是產(chǎn)品的工作方式。——史蒂夫·喬布斯
開門見山地講,經(jīng)過了多年積累后的舊版開播的遺留代碼是工程導(dǎo)向的,里面不乏炫技的代碼、大段冗長而缺乏業(yè)務(wù)注釋的代碼,這讓"開播"這個重要的業(yè)務(wù)領(lǐng)域在技術(shù)實現(xiàn)上,與業(yè)務(wù)方的實際描述漸行漸遠(yuǎn)。每當(dāng)我們提及一些業(yè)務(wù)場景,都需要絞盡腦汁才能回想起這個場景到底與哪些代碼有一些聯(lián)系。這樣的開發(fā)架構(gòu)和技術(shù)實現(xiàn)方式,不論對團(tuán)隊的知識共享還是對業(yè)務(wù)的正常迭代,都是一筆不可忽視的成本。
同時,由于陳年代碼經(jīng)手多代程序員,導(dǎo)致代碼風(fēng)格不統(tǒng)一、領(lǐng)域邏輯和UI邏輯耦合的情況幾乎隨處可見,DAO代碼更是可能隨時出現(xiàn)在各個層次,這樣的耦合對可拓展性和可測試性都帶來了不小的麻煩。
本次重構(gòu)在戰(zhàn)術(shù)落地層面所面臨的挑戰(zhàn),就是如何在保證業(yè)務(wù)邏輯幾乎不變的情況下,讓業(yè)務(wù)描述與代碼實現(xiàn)更貼切、認(rèn)知負(fù)荷更低從而加強(qiáng)業(yè)務(wù)知識的地位,以及如何優(yōu)雅地解耦原本雜亂耦合的各層次代碼,讓他們變得整潔、可測試、可拓展。
3.1 設(shè)計模式
我們不妨先管中窺豹,看一個簡化版的新舊版本開播的時序圖對比:
圖片
圖片
很顯然,前者的描述充斥著純技術(shù)屬性的描述,大部分篇幅集中于諸如area_id、uid之類的屬性,難以直接和實際業(yè)務(wù)中的描述對應(yīng)上。
而后者的描述則是有業(yè)務(wù)上的主客體描述的,如"房間是否屬于該用戶"、"分區(qū)是否允許該房間開播"。在代碼的編排描述上,很容易就可以看出,后者的可理解性要比前者高出一截,這便引出了下文要討論的話題:新舊版本開播服務(wù)的設(shè)計模式。
3.1.1 舊版設(shè)計模式:事務(wù)腳本
事務(wù)腳本模式也叫做面條代碼或者膠水代碼;它有一些顯著的特點:面向過程,易于編寫,難以應(yīng)對變更,復(fù)雜事務(wù)腳本可讀性低&可維護(hù)性低。顯然,舊版php開播代碼在多年缺乏系統(tǒng)性維護(hù)的業(yè)務(wù)迭代后,已經(jīng)幾乎退化為這樣的模式。
根據(jù)Fowler在PoEAA中對事務(wù)腳本的描述,我們用上文的舊版開播進(jìn)行分析。初次讀這段代碼的體驗,可能是如下的:
- 從表示層/服務(wù)層獲得輸入(開播請求的一些參數(shù),比如room_id、uid)
- 中間有大量的過程是用來做單純的獲取某一條數(shù)據(jù)(area_info分區(qū)信息)
- 獲取對這些獲取的單一數(shù)據(jù)進(jìn)行某些字段的判斷,或者多個單條數(shù)據(jù)聯(lián)合判斷(如,檢查房間開播狀態(tài)、檢查分區(qū)狀態(tài)是否為online)
- 之后調(diào)用其他系統(tǒng)或者存儲數(shù)據(jù)到數(shù)據(jù)庫(多次更新房間的多條信息,live_start_time/area_id)
- 過程中,不斷將單條操作后新增的數(shù)據(jù)合并到響應(yīng)值中
開播這個動作,在舊版代碼中乍一看,是一個過程驅(qū)動,由許多僅有技術(shù)含義的動作完成的純技術(shù)操作,缺乏了對業(yè)務(wù)的基本感知和描述。這樣的模式,對業(yè)務(wù)中的場景業(yè)務(wù)歸納能力較弱,當(dāng)我們提到某個場景時,往往需要把這些生硬的代碼在腦海中轉(zhuǎn)譯一次,才能對應(yīng)上業(yè)務(wù)方的實際描述。
當(dāng)我們擁有了更多動作時,就會有若干過程需要做相似的動作,通常就要使多個過程中包含某些相同的代碼,這些類似的副本會讓應(yīng)用程序變成一張極度雜亂無章的網(wǎng)。
換一個角度,從OOP的角度上看該模式,實體的概念并不能完全表現(xiàn),甚至只是充當(dāng)了業(yè)務(wù)邏輯層和數(shù)據(jù)訪問層之間的輔助角色,只空有屬性,沒有行為。這樣實體在 業(yè)務(wù)行為上難以和代碼實現(xiàn) 對應(yīng),更難以復(fù)用。
3.1.2 新版設(shè)計模式:領(lǐng)域模型
反之,對比起原來的事務(wù)腳本模式,我們的新服務(wù)中,包含有多個有血有肉的對象,比如房間和賬號。
對于應(yīng)用服務(wù)需要完成的開播用例而言,相比起純過程的各個字段和子過程的串聯(lián),更關(guān)心每一個對象應(yīng)該做出什么行為。
圖片
圖片
3.2 戰(zhàn)術(shù)設(shè)計
3.2.1 戰(zhàn)術(shù)設(shè)計的思考:引入六邊形架構(gòu)
既然是遺留系統(tǒng)現(xiàn)代化演進(jìn),我們不妨先提一些工程質(zhì)量方面的提升預(yù)期:
業(yè)務(wù)領(lǐng)域的邊界更加清晰、更好的可擴(kuò)展性、對測試的友好支持、更容易實施DDD...
看到既定目標(biāo),再結(jié)合領(lǐng)域驅(qū)動設(shè)計指導(dǎo)的前情提要,相信對此熟悉的讀者已經(jīng)會心一笑了,解決方案呼之欲出:六邊形架構(gòu)。
3.2.2 領(lǐng)域模型與六邊形架構(gòu)
怎么寫開發(fā)架構(gòu)相對整潔、看起來就可測試的代碼?相信大家或多或少都了解過"六邊形架構(gòu)"、"整潔架構(gòu)"或者"洋蔥架構(gòu)"。我們再來稍微復(fù)習(xí)一下它的定義:
六邊形架構(gòu),也被稱為端口和適配器架構(gòu)(Ports and Adapters Architecture),是由Alistair Cockburn于2005年首次提出的。這個架構(gòu)模式的主要目標(biāo)是將應(yīng)用程序的核心業(yè)務(wù)邏輯與外部依賴分離開來,從而提高可測試性、可維護(hù)性和可擴(kuò)展性。
在六邊形架構(gòu)中,應(yīng)用程序被劃分為以下幾個關(guān)鍵部分:
- 應(yīng)用程序核心:這是應(yīng)用程序的主要業(yè)務(wù)邏輯,它包含了所有的用例和業(yè)務(wù)規(guī)則。核心不依賴于具體的外部組件或技術(shù),因此它是高度可測試的。
- 端口:端口是定義應(yīng)用程序與外部依賴之間的接口。它們定義了應(yīng)用程序需要的功能,但不實現(xiàn)具體的實現(xiàn)細(xì)節(jié)。
- 適配器:適配器是實際實現(xiàn)端口的組件,它們負(fù)責(zé)將外部依賴集成到應(yīng)用程序中。適配器將外部依賴的細(xì)節(jié)隱藏在內(nèi)部,以確保核心業(yè)務(wù)邏輯保持獨(dú)立性。
通過將應(yīng)用程序核心與外部依賴分離,六邊形架構(gòu)提供了以下優(yōu)勢:
- 可測試性:由于核心業(yè)務(wù)邏輯與外部依賴分離,開發(fā)人員可以輕松地編寫單元測試,而無需依賴外部資源。
- 可維護(hù)性:應(yīng)用程序的核心業(yè)務(wù)邏輯保持簡單和獨(dú)立,因此更容易理解和維護(hù)。
- 可擴(kuò)展性:通過添加新的端口和適配器,您可以輕松地擴(kuò)展應(yīng)用程序,以滿足不斷變化的需求。
本次我們新搭建的開播平臺,遵循了端口和適配器的架構(gòu)風(fēng)格,將服務(wù)拆分為了以下的層次:
- Transporter Layer 外部請求適配器,適配外部用例
- Data Source Adapters 內(nèi)部資源適配器,適配內(nèi)部資源(Repository、Infrastructure)
- Application 應(yīng)用程序?qū)樱深I(lǐng)域邏輯為用例,如"用戶使用直播姬開播"
- Domain 領(lǐng)域?qū)樱瑯I(yè)務(wù)邏輯核心,眾多重要邏輯在這里實現(xiàn),如"房間狀態(tài)流轉(zhuǎn)為開播"
圖片
解決的問題
- 在PHP的老設(shè)計中,開播接口的ui/領(lǐng)域內(nèi)業(yè)務(wù)邏輯耦合較重,大量客戶端參數(shù)校驗、特定客戶端返回特定響應(yīng)的邏輯耦合在多個地方,可能是controller,也可能是service,甚至可能是dao層。這部分與領(lǐng)域知識本身無關(guān),在新版本的開播代碼中,需要與應(yīng)用程序?qū)雍皖I(lǐng)域?qū)痈綦x起來,保護(hù)后者的邏輯不受污染。
- 歷史遺留代碼中,DAO耦合在代碼中,對業(yè)務(wù)邏輯本身的可拓展性和可測試性產(chǎn)生了阻礙;新版代碼中也需要將這部分解耦,以便未來技術(shù)演進(jìn)和單元測試的開展。
3.2.3 模塊設(shè)計
按照上文的開發(fā)架構(gòu)設(shè)計,本次新開播服務(wù)的代碼分包結(jié)構(gòu)代碼實現(xiàn)如下。
圖片
因為Golang本身OOP的鴨子類型特性和諸多原因,我們的編碼風(fēng)格顯得沒有那么嚴(yán)格,選擇了相對松散的代碼分包結(jié)構(gòu)。大致區(qū)分為了領(lǐng)域?qū)印⒎栏瘜印?yīng)用程序?qū)印}儲/基礎(chǔ)設(shè)施層
Domain 領(lǐng)域?qū)?/h4>
作為領(lǐng)域驅(qū)動設(shè)計指導(dǎo)下的工程,我們的首要目標(biāo)就是保障領(lǐng)域邏輯的正確性。
最核心的領(lǐng)域?qū)樱瑂vc/pkg/domain包含了領(lǐng)域服務(wù)和各個領(lǐng)域?qū)ο蟮膇nterface契約聲明和具體實現(xiàn)。開播涉及到的領(lǐng)域?qū)ο蠖荚谶@里集中實現(xiàn)。
在開播的戰(zhàn)略設(shè)計中,我們提到了幾個上下文,在這里會作為聚合或?qū)ο蟮姆绞竭M(jìn)行實現(xiàn),他們的關(guān)系如下:
圖片
Facade 防腐層
對應(yīng)設(shè)計中的Transporter Layer 外部請求適配器,適配外部用例:
用例上,開播和用于調(diào)試開播的請求,都需要在用例層面適配他們,所以自然需要適配器來適配他們的grpc請求,以及考慮到今后多種接口形式的接入( http or mq),如果之后grpc定義出現(xiàn)變更,或者新請求形式的接入,不會對 Application 層的用例帶來滲透和影響。
設(shè)計原則
需要提供多組外部適配器,適配各種場景的開播請求(理論上可能是grpc/http/mq ...,本文僅限于直播姬開播的場景),并轉(zhuǎn)化為應(yīng)用程序?qū)涌山邮艿挠美墑e請求。并且作為防腐層,不應(yīng)該有過多業(yè)務(wù)邏輯,僅實現(xiàn)必要的特定端到端場景的UI邏輯。
- 處于防腐層的適配器需要有自己的合法校驗邏輯(必要的靜態(tài)參數(shù)檢查)。
- 防腐層約定上層(controller的 grpc server)以及下層應(yīng)用的(application)的交互協(xié)議,開播中對應(yīng)開播的gRPC請求轉(zhuǎn)換為Application層可接受的請求。
- 歷史邏輯中只與UI相關(guān)的邏輯也需要在這一層收斂。(etc.開播失敗情況下的端上提示、彈窗的相關(guān)返回值組裝)
對外契約:
- 適配網(wǎng)關(guān)層開播接口的請求:需要有靜態(tài)參數(shù)檢查、將請求構(gòu)造為application層可接受的ACL請求。
- 開播返回結(jié)構(gòu)體組裝:將application層開播用例的結(jié)果,根據(jù)端到端的UI邏輯,組裝成業(yè)務(wù)預(yù)期中的返回值(防止客戶端UI相關(guān)的邏輯滲透到下面的層次)
Application 應(yīng)用程序?qū)?/h4>
應(yīng)用層作為場景用例的主體部分,充當(dāng)了實體、聚合、領(lǐng)域服務(wù)的膠水層,將房間、場次、賬號的行為集成到一起,最終形成"直播姬開播"用例的業(yè)務(wù)邏輯。最終,每一個用例會對應(yīng)application的一個接口,如"直播姬開播"、"直播姬關(guān)播"、"后臺開播"、"后臺關(guān)播"等等,包裝成用例提供到外部。
Repository / Infrastructure 倉儲/ 基礎(chǔ)設(shè)施
對應(yīng)Data Source Adapters 內(nèi)部資源適配器。同樣的,六邊形架構(gòu)中,對下游依賴的約束也是依靠 接口與適配器 這一風(fēng)格進(jìn)行解耦和契約。
設(shè)計原則
- 內(nèi)部資源適配器應(yīng)該依照上層契約實現(xiàn)
- 內(nèi)部資源適配器實現(xiàn)的變更不會影響到聲明的契約本身,其交付的能力應(yīng)當(dāng)是不變的
- 倉儲 Repository,按照Domain層協(xié)商的倉儲能力進(jìn)行實現(xiàn),該層只對領(lǐng)域邏輯要求的倉儲能力負(fù)責(zé),如"發(fā)布開播領(lǐng)域事件"
- 基礎(chǔ)設(shè)施層 Infrastructure,領(lǐng)域無關(guān)的基礎(chǔ)設(shè)施,如分布式鎖、上報組件等
3.3 測試驅(qū)動開發(fā) TDD
當(dāng)露營結(jié)束離開的時候,要打掃營地,讓它比你來的時候更干凈。—— 童子軍原則,《97 Things Every Programmer Should Know》
3.3.1 動機(jī)
從項目角度出發(fā),可以提供持續(xù)的項目進(jìn)度反饋。開播平臺重構(gòu)作為一個大型項目,需要從業(yè)務(wù)和項目的量化成一個個可操作的任務(wù)寫到 to-do list,然后使用測試驅(qū)動編碼,可以在每一個預(yù)期用例完成后進(jìn)行標(biāo)記,那么我們的工作目標(biāo)將變得非常清晰,因為工期、待辦事項、難點都非常明確,可以在持續(xù)細(xì)微的反饋中有意識地做一些適當(dāng)?shù)恼{(diào)整,比如添加新的任務(wù),刪除冗余的測試;還有一點更加讓人振奮,可以知道大概什么時候可以完工,對開發(fā)進(jìn)度可以更精確的把握。
從工程角度出發(fā),可以確保代碼質(zhì)量,也保障重構(gòu)的安全性。一個軟件的自動化測試,可以從內(nèi)部表達(dá)這個軟件的質(zhì)量,我們通常管它叫做內(nèi)建質(zhì)量(Build Quality In)。開發(fā)人員如果忽視編寫自動化測試,就放棄了將質(zhì)量內(nèi)建到軟件(也就是自己證明自己質(zhì)量)的機(jī)會,把質(zhì)量的控制完全托付給了測試人員。這種靠人力去保證質(zhì)量的方式,永遠(yuǎn)也不可能代表"技術(shù)先進(jìn)性"。在用例級別保障了內(nèi)建質(zhì)量后,倘若將來有一天需要重構(gòu),由于有全面的測試套件作為保障,開發(fā)人員可以放心地對代碼進(jìn)行優(yōu)化、改進(jìn)結(jié)構(gòu)或增加新功能,而不用擔(dān)心引入潛在的問題。
3.3.2 實踐
一句話來概括就是先設(shè)計用例,再寫代碼。
鑒于六邊形架構(gòu)符合 端口與適配器 風(fēng)格的契約,我們很容易知道:
- 一個端口(interface)提供的能力是什么
- 業(yè)務(wù)邏輯應(yīng)該使用什么端口(interface),他們應(yīng)該在業(yè)務(wù)中表現(xiàn)如何
那么以下的TDD工作流就應(yīng)該被遵守:
- 先明確interface的能力,定義所需的行為,并編寫可讀性良好的注釋文檔來聲明它們的作用;
- 根據(jù)上一步的契約,編寫interface的測試用例。
- 實現(xiàn)interface的業(yè)務(wù)邏輯,并實現(xiàn)接口以使其能夠通過。
- 業(yè)務(wù)邏輯測試,并使用上文編寫的用例進(jìn)行測試,驗證預(yù)期行為是否在待測的interface中產(chǎn)生。
- 根據(jù)結(jié)果調(diào)整代碼,直到可以通過測試用例。
由于六邊形架構(gòu)中,接口與其實現(xiàn)天然存在接縫(seam),對于某個業(yè)務(wù)邏輯中對Repository甚至領(lǐng)域?qū)ο蟮那闆r,我們也可以輕松通過mock的方式進(jìn)行依賴處理。
以房間聚合的開播狀態(tài)流轉(zhuǎn)作為舉例:
圖片
第一步,我們根據(jù)"開播狀態(tài)流轉(zhuǎn)"這個領(lǐng)域?qū)ο蟮膭幼鳎M(jìn)行需求分析,得出該動作的目的就是"將房間狀態(tài)流轉(zhuǎn)為開播中",一些關(guān)聯(lián)的知識就包括,"必須聲明開播時間"、"狀態(tài)流轉(zhuǎn)為開播的同時需要與一場直播綁定"、"開播的房間必須已經(jīng)選擇了分區(qū)"。明確需求后,從業(yè)務(wù)邏輯中得到想要的用例可以得到如下的用例:
- 完全符合預(yù)期,開播的動作中包含所選的開播時間、場次、分區(qū),所以房間狀態(tài)可以流轉(zhuǎn)為開播
- 空操作,不合法輸入,失敗
- 沒有聲明開播時間,不合法輸入,失敗
- 沒有選擇分區(qū),不合法輸入,失敗
- 沒有綁定一場直播,不合法輸入,失敗
- Repository倉儲方法調(diào)用錯誤,失敗
同時也可以注意到,在開播狀態(tài)流轉(zhuǎn)中,我們只關(guān)注依賴的Repository的倉儲方法是否失敗,而不關(guān)心它如何實現(xiàn)的、為何失敗的。因為這對于房間對象而言并不是職責(zé)范圍內(nèi)的知識,而是倉儲方法的職責(zé)范圍,所以在這個場景下,我們只關(guān)心倉儲是否交付成功即可。
簡單舉例,測試用例代碼如下:
圖片
第二步,根據(jù)這些已知用例實現(xiàn)"房間狀態(tài)流轉(zhuǎn)為開播",也就是實現(xiàn) IRoomStatus 這個對象的 ChangeRoomStatusToStartLive 方法。
圖片
第三步,運(yùn)行第一步編寫的測試用例,查看是否符合預(yù)期。對于領(lǐng)域?qū)ο缶唧w依賴的Repository,由于我們事先在六邊形架構(gòu)中聲明了依賴的interface契約,所以可以較為簡單地使用mock處理這些依賴。
圖片
第四步,運(yùn)行完整的測試用例集合,如果不符合預(yù)期,則重回第二步,開始新一輪的修改和測試流程。
至此,一套完整的 UTDD 流程就良好地運(yùn)作起來了,在實際的開發(fā)過程中,我們的每一個領(lǐng)域?qū)ο蟆}儲方法、基礎(chǔ)設(shè)施的實現(xiàn)流程都是按照該流程進(jìn)行的,在很大程度上保障了新開播的內(nèi)建質(zhì)量。
對于更為大型的場景,比如application層對開播接口的測試,本質(zhì)上在六邊形架構(gòu)中也可以將集成的多個領(lǐng)域?qū)ο笸ㄟ^端口-適配器的解耦,將涉及的領(lǐng)域?qū)ο笾苯舆M(jìn)行mock,從而以較低的心智成本編寫出可讀性較高的集成測試,一個典型的集成測試集合如下:
圖片
在TDD思想的指導(dǎo)和開發(fā)流程下,我們的新服務(wù)整體單元測試覆蓋率達(dá)到了70+%,部分關(guān)鍵領(lǐng)域邏輯的覆蓋率達(dá)到100%。
如此的覆蓋率,不論在業(yè)務(wù)理解層面還是內(nèi)建質(zhì)量方面都產(chǎn)生了莫大的幫助——不必?fù)?dān)心一些改動導(dǎo)致的重要影響無法被開發(fā)者捕捉到,這無疑在未來的業(yè)務(wù)迭代和進(jìn)一步重構(gòu)中都會起到關(guān)鍵作用。
4 安全的系統(tǒng)遷移
兵馬未動,糧草先行。
——《孫子兵法》
一艘巨輪建造完成后終究需要下水,而往往船下水的方案設(shè)計是先于船體本身的建造的。開播能被稱為遺留系統(tǒng),那么它背后的歷史邏輯和技術(shù)債務(wù)一定不容小覷,我們對新開播系統(tǒng)"完工下水"這件事,顯然就要謹(jǐn)慎對待了,從新開播的實現(xiàn)本身、到中間的開發(fā)執(zhí)行和驗證,以及最后的部署灰度,都需要進(jìn)行細(xì)致的考慮,保證這艘新船能順利接觸水面。
前期對業(yè)務(wù)邏輯進(jìn)行最細(xì)致的歸納,這其中包括了代碼的逐行校對和每個邏輯分支的業(yè)務(wù)邏輯梳理,甚至也包括了PHP和Golang基礎(chǔ)組件的源碼對比。
中期在代碼編寫的過程中逐步明確"檢查點"和事件溯源的全貌,設(shè)計并完善了驗證方案:流量復(fù)制和事件溯源,并構(gòu)建完善的新舊開播檢查點對比系統(tǒng),保證關(guān)鍵的邏輯節(jié)點上,新舊服務(wù)的表現(xiàn)完全一致。
后期在服務(wù)部署和灰度策略上,也做了最周密的準(zhǔn)備,包括網(wǎng)關(guān)級別萬分位的灰度放量規(guī)則和業(yè)務(wù)級別的重要房間退避方案。
4.1 業(yè)務(wù)邏輯
業(yè)務(wù)邏輯通常是最沒有邏輯的東西。
—— Martin Fowler,《企業(yè)應(yīng)用架構(gòu)模式》
4.1.1 歷史邏輯
面對已存在多年的業(yè)務(wù)邏輯,不論它是否容易閱讀、我們是否熟悉跨語言的寫法,都應(yīng)該心存敬畏,逐個分支、逐個業(yè)務(wù)場景進(jìn)行盤點,最終形成對此業(yè)務(wù)場景的正確理解。
面對這種高準(zhǔn)確度要求的表達(dá)訴求,我們選擇了已有的接口自動化測試用例結(jié)合手動端到端驗證 + 逐行閱讀對比代碼的方式進(jìn)行梳理驗證,最后以時序圖的方式,將舊開播服務(wù)的PHP實現(xiàn)邏輯呈現(xiàn)到施工方案中。
圖片
(涉及具體業(yè)務(wù)流程,僅展示縮略圖)
既然是"重構(gòu)",我們選擇盡量保持原有的邏輯流和數(shù)據(jù)流,先將主邏輯大部分遷移完成,再進(jìn)行下一步的改造。
所以在新版本的重構(gòu)中,涉及的業(yè)務(wù)邏輯流,實際上并沒有過大的改變,從而保障了邏輯分支在端到端表現(xiàn)上可以完全一致。
4.1.2 轉(zhuǎn)化漏斗圖
針對上述邏輯分支眾多的用例場景,我們也嘗試使用最直觀的圖形形式,展現(xiàn)給對開播領(lǐng)域不甚熟悉的研發(fā)同學(xué),甚至是產(chǎn)運(yùn)同學(xué)進(jìn)行參考,最終選擇了漏斗圖的形式。
最頂層為開播接口的入口,對應(yīng)直播姬點擊"開始直播"按鈕后對服務(wù)端開播接口的請求。而后的一系列漏斗層,則代表了服務(wù)端的行為,中途不斷有檢查項攔截不符合開播條件的請求,直到底部的成功開播。
圖片
4.2 流量復(fù)制 & 事件溯源
以上文歸納的"業(yè)務(wù)邏輯"為指導(dǎo),我們著手構(gòu)建了一套為開播業(yè)務(wù)邏輯遷移量身打造的流量復(fù)制和數(shù)據(jù)驗證方案。
作為核心場景,開播日均承載的流量大,且邏輯流具有不確定性:不同的開播賬號、開播場景,甚至是網(wǎng)絡(luò)環(huán)境,都可能會導(dǎo)致會走入某一個上述復(fù)雜的開播邏輯分支中,可能有業(yè)務(wù)邏輯拒絕開播直接中斷開播流程的情況,也有可能發(fā)生內(nèi)部錯誤但繼續(xù)執(zhí)行開播流程的情況。所以如何在眾多的業(yè)務(wù)分支中識別出新舊開播服務(wù)的數(shù)據(jù)流和邏輯流完全一致,是本次工程中的難點之一。
對此,我們設(shè)計了一套"流量復(fù)制"和"事件溯源"的驗證方案。
4.2.1 流量復(fù)制
圖片
在舊版和新版開播正式進(jìn)行切換之前,必須保證新版舊版開播邏輯和數(shù)據(jù)鏈路和業(yè)務(wù)邏輯一致,為此我們設(shè)計了"流量復(fù)制"和"事件溯源/對賬"的機(jī)制。
- 流量復(fù)制:網(wǎng)關(guān)層復(fù)制開播接口請求,分發(fā)給舊服務(wù)和新服務(wù)
- 事件溯源/對賬:
- 開播接口邏輯中的重要事件節(jié)點(包括了開播接口最終返回到端上的響應(yīng)值)上報到數(shù)據(jù)平臺
- 對每一條開播請求的事件進(jìn)行新舊服務(wù)對比
對于復(fù)制過來的一組流量,我們期望它是冪等的,不能對下游數(shù)據(jù)產(chǎn)生任何影響。
在上文開發(fā)架構(gòu)中提到,Repository和Infrastructure在六邊形架構(gòu)中,可以通過不同的方式實現(xiàn)契約,那么對于"不真實執(zhí)行"這一實現(xiàn)方式,是天然可以實現(xiàn)支持的——新增一組"假寫"的適配器即可。
為保證這些檢查點在新舊服務(wù)完全一致,在驗證方案中設(shè)計了以下三個階段:
圖片
圖片
圖片
- 舊服務(wù)進(jìn)行開播事件上報
- 主要邏輯仍然由舊服務(wù)處理,網(wǎng)關(guān)服務(wù)復(fù)制流量到新服務(wù)。新服務(wù)只執(zhí)行冪等邏輯,不進(jìn)行真實的寫操作。新舊服務(wù)均上報關(guān)鍵事件檢查點,統(tǒng)一在數(shù)據(jù)平臺進(jìn)行每一條請求的檢查。
- 重構(gòu)部分的關(guān)鍵事件檢查點驗證完成后,舊服務(wù)不再上報,而新服務(wù)切換為真實寫入的模式,并且繼續(xù)保留關(guān)鍵事件上報能力。
4.2.2 事件溯源
借助戰(zhàn)略設(shè)計章節(jié)中的"事件風(fēng)暴"整理出的關(guān)鍵路徑和事件,稍加整理就可以得到一組關(guān)鍵事件鏈路,借助事件溯源(Event Sourcing)的思想,我們可以將開播流程中的重要節(jié)點上報并持久化。
根據(jù)事件風(fēng)暴和業(yè)務(wù)邏輯的時序圖,我們設(shè)定了以下關(guān)鍵事件檢查點:
圖片
根據(jù)這些檢查點,我們在新舊版本的開播代碼中進(jìn)行改造,在對應(yīng)的點位埋樁進(jìn)行數(shù)據(jù)上報。由于他們可以被聚合在同一條trace下,所以針對每一條開播接口的請求,都可以被完整地記錄在案。
從事件溯源中,我們也可以獲取到一個意料之中的收獲:開播鏈路在服務(wù)端鏈路的業(yè)務(wù)可觀測性。
圖片
4.3 自動化測試
4.3.1 UTDD 單元測試&集成測試
如 3.3(測試驅(qū)動開發(fā))部分所述,新開播服務(wù)在開發(fā)時就采用了 TDD 工作流,單測覆蓋率70%以上,關(guān)鍵邏輯的行覆蓋率達(dá)到100%。
單元測試覆蓋率檢查集成到CI中,保證后續(xù)業(yè)務(wù)迭代質(zhì)量。
圖片
4.3.2 ATDD 測試共建自動化測試用例
在本次重構(gòu)中,我們與測試團(tuán)隊持續(xù)合作,共建了200+條開播接口的自動化集成測試用例,覆蓋了大部分的請求參數(shù)檢查、用戶身份和狀態(tài)、特殊開播場景、安全管控策略、分區(qū)和場次狀態(tài)等正常和異常用例,并對對應(yīng)預(yù)期接口返回結(jié)果、數(shù)據(jù)和消息寫入結(jié)果等檢查。同時在自動化測試中引入diff能力,相同參數(shù)輸入下新舊服務(wù)接口響應(yīng)進(jìn)行對比,覆蓋80%以上開播場景。
圖片
整個重構(gòu)的遷移過程中,我們通過接口自動化測試,發(fā)現(xiàn)并修復(fù)問題10+個。
4.4 部署計劃
整體上線(包括流量復(fù)制&實際灰度階段)分為了三個階段:
- 舊開播服務(wù)事件上報
- 新舊開播服務(wù),線上流量復(fù)制對比
- 新開播服務(wù)正式灰度切流
整個部署發(fā)布不同階段,都嚴(yán)格制定SOP按照計劃執(zhí)行,避免遺漏或切換過程中對線上開播服務(wù)的影響:
圖片
4.5 結(jié)果
在精細(xì)的驗證計劃、部署計劃和嚴(yán)格的流程把控下,開播在整個遷移過程中未出現(xiàn)任何事故。
其中一些驗證操作的功效是很直觀的:
- ATDD 接口自動化發(fā)現(xiàn)差異:10+個
- 流量復(fù)制&事件溯源發(fā)現(xiàn)差異:20+個
- 歷史邏輯&代碼對比發(fā)現(xiàn)差異:30+個
同時我們在一個月時間內(nèi),逐步進(jìn)行了精細(xì)到單個用戶粒度-萬分位-千分位-十分位-全量的灰度,在途中也優(yōu)化了10+性能問題。
圖片
最終順利全量上線。
5 生產(chǎn)配套
“君之所以明者,兼聽也;其所以暗者,偏信也。”——漢·王符《潛夫論·明暗》
一個運(yùn)作良好的系統(tǒng)首先必須具備良好的可觀測性,倘若都無法觀測到各個零件運(yùn)作是否良好,又談何算得上一輛好車。
對于開播這種不容有失的系統(tǒng),萬萬不可寫完代碼就萬事大吉。我們需要更加謹(jǐn)慎地將系統(tǒng)的運(yùn)作狀態(tài)觀測納入設(shè)計考慮,讓觀測變得更加直觀,使?jié)撛诘南到y(tǒng)性風(fēng)險可以快速暴露,也便于在緊急情況下做出恰當(dāng)?shù)臎Q策。
5.1 系統(tǒng)監(jiān)控
對于開播服務(wù)的整體鏈路,我們通過前文的事件溯源上報方案結(jié)合司內(nèi)的監(jiān)控解決方案,對開播成功、開播拒絕的情況進(jìn)行了上報統(tǒng)計,對開播整體大盤的開播成功率、被拒絕開播的原因和發(fā)生率形成直觀感受。
若開播系統(tǒng)出現(xiàn)了某種業(yè)務(wù)異動,比如被拒絕開播的突增,我們可以借助監(jiān)控大盤和告警體系在第一時間感知到。

5.2 系統(tǒng)排障
伴隨著"事件溯源"體系的建設(shè),自然可以衍生出眾多提升系統(tǒng)可觀測性的輔助工具。這些工具在未來的業(yè)務(wù)運(yùn)維和業(yè)務(wù)迭代過程中可以節(jié)省大量的人力。
如可以實時驗證是否指定房間是否滿足開播條件的"模擬開播":
圖片
以及針對每一條歷史開播請求可以追溯關(guān)鍵事件,排查開播為何成功/失敗的"開播事件問診":
圖片
6 結(jié)果
回顧文章開篇時提到的歷史債務(wù)上來,我們從業(yè)務(wù)層面和技術(shù)層面來進(jìn)行一些簡單的復(fù)盤。
6.1 業(yè)務(wù)收益
知識共享:在開播平臺重構(gòu)的一系列工作中,首當(dāng)其沖的是對開播歷史邏輯的完整梳理,這無疑提高了產(chǎn)研對開播業(yè)務(wù)的理解程度,降低溝通成本;在過程中,我們也已與產(chǎn)品溝通了眾多不曾關(guān)注到的功能細(xì)節(jié),幫助產(chǎn)品更好地建設(shè)開播工具生態(tài)。伴隨著產(chǎn)研對業(yè)務(wù)知識的理解成本降低,一些客訴問題的排查也會變得容易起來——從前一些只有代碼編寫者才能描述的邊緣情況,現(xiàn)在更容易被產(chǎn)品甚至熟悉的運(yùn)營所得知,進(jìn)而減低對開播功能的疑惑,最終使產(chǎn)研協(xié)作效率提升。
開發(fā)提效:在PHP舊服務(wù)的開發(fā)過程中,用例梳理、PHP代碼晦澀的Coding過程、復(fù)雜代碼的反復(fù)Review、PHP的遠(yuǎn)古工具鏈?zhǔn)褂枚紩加么罅康拈_發(fā)時間;相較舊版,新版開播接口不存在這些歷史包袱,極大提高了開發(fā)效率。
業(yè)務(wù)SRE:"開播事件溯源"提供的接口請求級別的問診能力,不同于以往排查開播問題時需要手動翻閱每一條關(guān)鍵日志,新版本的一鍵查詢溯源記錄能力可以大大降低研發(fā)的問題排查成本。
6.2 系統(tǒng)性風(fēng)險優(yōu)化
在過去,開播系統(tǒng)運(yùn)行于"房間服務(wù)"的 PHP 服務(wù)之中,該服務(wù)除了承載開播業(yè)務(wù),也承載了大量和直播有關(guān)的周邊業(yè)務(wù)接口;
從技術(shù)角度,跨語言的遷移解決了較多的風(fēng)險:
- 一個相當(dāng)?shù)湫偷陌咐涸鹊目蚣芑?Swoole 二次開發(fā)(Worker 模式),在突發(fā)并發(fā)流量較大時會出現(xiàn)單實例 Worker 滿載的情況,造成請求超時;且由于請求堆積、Worker 釋放和重建、內(nèi)存回收之間存在一定時間差,瞬時 Swoole Worker 進(jìn)程超出內(nèi)存限制導(dǎo)致請求失敗時有發(fā)生。該問題造成了原開播系統(tǒng)的穩(wěn)定性不足,無法支撐直播業(yè)務(wù)快速發(fā)展的需要,構(gòu)成了系統(tǒng)性風(fēng)險。而這種在PHP框架下難以根治的問題,遷移到Golang后就自然不存在了。
- 重構(gòu)后,開播業(yè)務(wù)屬于單獨(dú)的 Go 微服務(wù),性能和可用性上有了大幅提升,接口可用性 SLA 從 99.xx% 提升到 99.99xx%,接口 P99 響應(yīng)速度提高50+%。
- 從語言層面,由于 PHP 本身是弱類型和解釋型語言,在開發(fā)和編譯過程中較難發(fā)現(xiàn)潛在問題,導(dǎo)致研發(fā)自測和測試成本上升,在過去也曾因為這些特性的處理不慎導(dǎo)致開播系統(tǒng)的線上問題;Go服務(wù)中對單元測試、故障測試有較好的支持,可以及時發(fā)現(xiàn)問題。
- PHP 的內(nèi)部工具鏈相對缺少維護(hù),與之對比的 Golang 是公司后端研發(fā)最主流的選型,公司級監(jiān)控、告警、觀測、服務(wù)治理、持續(xù)集成等系統(tǒng)都為 Go 提供了相對更好的支持,這也使得新的開播系統(tǒng)也有更好的可維護(hù)性和事件響應(yīng)能力。
圖片
從業(yè)務(wù)角度看,也提高了業(yè)務(wù)的系統(tǒng)穩(wěn)定性:
- 重構(gòu)過程中,梳理了整個開播鏈路中的服務(wù)、接口、配置、存儲依賴,并將數(shù)十個依賴項區(qū)分為強(qiáng)依賴和弱依賴。
- 對于強(qiáng)依賴場景,梳理了對應(yīng)的失敗表現(xiàn),并編寫了應(yīng)急SOP;對于弱依賴調(diào)用失敗的場景,采用補(bǔ)償任務(wù)等手段處理,不阻塞用戶開播,進(jìn)一步提升了開播系統(tǒng)的可用性。
6.3 技術(shù)資產(chǎn)
一個好的技術(shù)項目,不僅需要達(dá)成業(yè)務(wù)和技術(shù)上的硬性目標(biāo),還需要有所積累和成長。我們在開播重構(gòu)的旅途中,也摸索出了一套行之有效、可復(fù)用的觀測模式和遷移模式。
6.3.1 更細(xì)粒度的業(yè)務(wù)可觀測
上文中提到的業(yè)務(wù)鏈路可觀測,沉淀后也成為了開播問診臺的通用事件溯源功能。
可查看某個房間過往不同開播場次,過程 & 結(jié)果關(guān)鍵事件的數(shù)據(jù)信息,更快的定位到線上每一次具體開播的情況。
6.3.2 可復(fù)用的遷移模式
經(jīng)過本次開播接口遷移的歷練,開播平臺獲得了可復(fù)用的PHP轉(zhuǎn)Go的工程經(jīng)驗,我們也可以嘗試用DDD的觀點來總結(jié):
圖片
一些沉淀的能力如下:
- 業(yè)務(wù)流
- 業(yè)務(wù)邏輯梳理:邏輯分支級別梳理,落實到標(biāo)準(zhǔn)設(shè)計圖上(時序圖、數(shù)據(jù)流圖),分支級別的測試用例覆蓋(判定表、單測、自動化測試)
- 事件溯源:按照事件風(fēng)暴、測試用例標(biāo)定的關(guān)鍵事件,進(jìn)行開播流程中的事件上報,保證請求可完全回溯。
- 工程保障
- 流量復(fù)制/切換:新舊接口的流量復(fù)制和流量控制。
- SOP:嚴(yán)格的部署計劃以及SOP,減少人為因素的不穩(wěn)定性。
那么套用回“開播平臺遷移”這個問題域匯總,我們可以得到以下的解法:
- 統(tǒng)一領(lǐng)域知識:對于產(chǎn)研測需要達(dá)成的共識,一套可讀性高的業(yè)務(wù)邏輯梳理可以滿足訴求。
- 白盒驗證:業(yè)務(wù)邏輯梳理提供單元測試、自動化測試用例;事件溯源和流量復(fù)制共同提供新舊服務(wù)的關(guān)鍵事件上報、數(shù)據(jù)對比。
- 服務(wù)部署:SOP提供嚴(yán)格的準(zhǔn)出和操作步驟;流量復(fù)制/切換提供新舊服務(wù)的切換能力。
- 戰(zhàn)術(shù)落地:借助于上述的所有能力,逐步完善開播系統(tǒng)。
一個完整的迭代可能是這樣的:確保項目組內(nèi)產(chǎn)研認(rèn)知一致后,按照TDD方法編寫出初版代碼;通過眾多測試用例后,進(jìn)行流量復(fù)制和事件溯源,通過關(guān)鍵事件對比保障關(guān)鍵檢查點和數(shù)據(jù)鏈路完全一致,最終按照SOP進(jìn)行上線。如果中途發(fā)現(xiàn)了修改點,需要回退到初版代碼編寫,乃至同一領(lǐng)域知識的步驟進(jìn)行項目組認(rèn)知的對齊。
通過業(yè)務(wù)流的完整評估,再有嚴(yán)謹(jǐn)?shù)墓こ舔炞C計劃保障,在事實上極大降低了出現(xiàn)嚴(yán)重遷移事故的概率(開播遷移過程中未出現(xiàn)PX以上事故)。
7 后日談:可演進(jìn)的“遺留”系統(tǒng)
重構(gòu)和微服務(wù)的締造者,軟件開發(fā)領(lǐng)域的泰斗,Martin Fowler 曾經(jīng)說過這樣一句話:
Let's face it, all we are doing is writing tomorrow's legacy software today.
是的,可以毫不夸張地說,你現(xiàn)在所寫的每一行代碼,都是未來的遺留系統(tǒng)。這聽上去有點讓人沮喪,但卻是血淋淋的事實,一個軟件系統(tǒng)的生命周期終歸會符合業(yè)務(wù)演進(jìn)的客觀規(guī)律。
不過大可不必氣餒,回到我們在引言中談到的遺留系統(tǒng)定義,有些系統(tǒng)時間雖長,但如果一直堅持現(xiàn)代化的開發(fā)方式,在代碼質(zhì)量、架構(gòu)合理性、測試策略、DevOps 等方面都保持先進(jìn)性,就算將來需要進(jìn)行架構(gòu)的進(jìn)一步演進(jìn),這樣"整潔"的老系統(tǒng)也會幫助我們規(guī)避眾多的問題,甚至可以讓演進(jìn)周期縮短、演進(jìn)風(fēng)險降低。
相信我們今天在開播平臺遷移中花費(fèi)的心血和留下的基石,終會為"歷久彌新"的系統(tǒng)打下基礎(chǔ)。
參考:
[1] Vernon, V. (2013) Implementing domain-driven design.
[2] Martraire, C. (2019) Living documentation: Continuous knowledge sharing by design. Boston: Addison-Wesley.
[3] Just enough software architecture: A risk-driven approach. Boulder: Marshall & Brainerd, 2010.
[4] Fowler, M. (2019) Refactoring: Improving the design of existing code. Boston: Addison-Wesley.
[5] Qilin, Y. (2021) 遺留系統(tǒng)現(xiàn)代化實戰(zhàn), 極客時間. Available at: https://time.geekbang.org/column/intro/100111101 (Accessed: 22 November 2023).
本期作者
趙書彬嗶哩嗶哩高級開發(fā)工程師
王清培 嗶哩嗶哩資深開發(fā)工程師
傅志超 嗶哩嗶哩資深開發(fā)工程師
郭宇霆 嗶哩嗶哩資深開發(fā)工程師
朱德江 嗶哩嗶哩資深開發(fā)工程師


































