B 站大型開播平臺重構

本期作者

趙書彬

嗶哩嗶哩高級開發工程師

王清培

嗶哩嗶哩資深開發工程師

傅志超

嗶哩嗶哩資深開發工程師

郭宇霆

嗶哩嗶哩資深開發工程師

朱德江

嗶哩嗶哩資深開發工程師

1. 背景

"凡事預則立,不預則廢。"——《禮記 · 中庸》

在文章的開頭,我們可以先來了解一下直播業務的大致業務架構。將直播業務簡單分爲兩大類場景 " 看播 "、" 開播 ",前者主要面向 C 端觀看用戶,後者主要面向 B 端開播主播。主播通過" 開播工具 "的開播產品功能,經由" 開播平臺 " 完成一系列開播動作,最後將媒體信息採集推送到多媒體服務器,C 端觀看用戶就可以從 CDN 看到直播的視頻流內容。

從數據流向來講,"開播" 場景是產生數據和觸發關鍵事件的源頭。這些數據或事件會涉及多個領域,如安全合規信息、房間信息、主播信息、開播場次信息、安全審計信息、多媒體信息等。

打個不太準確的比喻。開播系統對於直播平臺的重要性,等同於訂單系統對於交易平臺的重要性。開播工具作爲播端功能入口,直接面向官方開播工具(直播姬、粉版大加號、三方工具如 OBS 開播)的用戶以及內部平臺方的用戶(其他業務線、產品 & 運營),對開播體驗負責。開播平臺在其中的職責,是向開播工具和其他平臺方提供開播相關的平臺化業務能力,如開關播、開通直播間、切換分區等。

同時,開播平臺與同級的業務平臺一起協作,才能支撐起完整的開播工具產品能力,如語聊房業務需要開播工具管理平臺(開播工具類支持)、主播互動平臺(主播互動能力支持)、流媒體服務端共同參與才能完成,從不同的維度幫助開播工具生態完善化。

圖片

一些涉及到的業務 / 技術名詞,在此我們也做出列舉並做出簡單介紹:

Lu5ZoK

1.1 現狀和挑戰

直播開播系統,伴隨着 B 站直播的成長貫穿始終。

發展初期所有的直播業務基本都在一套 php 代碼裏完成,包括開播部分。之後的直播高速發展中,很多模塊已經順利完成遷移。

開播部分也嘗試過遷移,但是未能成功完成。還不太幸運的出了比較嚴重的線上故障。(這給後面的再次重構積累了寶貴的經驗。)

1.1.1 債務清單

1.1.2 遺留系統特徵

業界對遺留系統的普遍定義中有 4 個關鍵字:舊、過時、重要、仍在使用。

圖片

事實並非完全如此:有些系統時間雖長,但如果一直堅持現代化的開發方式,在代碼質量、架構合理性、測試策略、DevOps 等方面都保持先進性,這樣的系統就像陳年的老酒一樣,歷久彌香。而有些系統雖然剛剛開發完成,但如果在上述幾個方面都做得不好,我們也可以把它叫做遺留系統。遺留系統在維護成本、合規性、安全性、集成性等方面都會給企業造成巨大的負擔,但同時也蘊含着豐富的數據和業務資產。我們應該對遺留系統進行現代化,讓它重新煥發青春。

顯然在知曉了舊開播系統有諸多歷史債務後,我們可以認爲它確實是一個搖搖欲墜的遺留系統。而我們本次的目標,就是將開播平臺這個重要的遺留系統進行重構,讓它 "煥發新生",並讓他在可預見的未來中都維持現代化系統的標準。

1.2 安全生產

在開播系統的維護、迭代、演進中,我們也致力於系統的 " 安全生產 " 問題:

2. 開播系統架構演進

每個士兵在上戰場前必須清楚的明白,他這場小小的戰鬥在大局中起的作用。——伯納德 · L · 蒙哥馬利(英國)

2.1 審視:問題出在哪裏?

在着手進行改造升級之前,不妨先從整體業務的迭代流程和已有架構中找到問題,以確定真正值得樹立的目標,避免陷入 "只見樹木不見森林" 的狹小視野中

我們不難發現,這個日積月累的遺留系統當中,它的業務研發流程種種令人難以忽視的問題:業務知識、業務架構的認識遺失、產研語言的不統一等等。

2.1.1 業務知識與業務架構的生命週期

開播域作爲播端的核心業務域,由於其悠久的歷史和維護團隊同學的變更,在幾經周折後,領域知識已經處於混沌狀態。這種情況下,顯然比起遺留代碼和不合理的實現邏輯而言,更大的 bug 可能最終會發生在人身上,也就是我們對業務知識本身的認識:對業務知識缺乏瞭解,往往是拖慢業務迭代甚至是釀成線上事故的罪魁禍首。

對於業務架構的認知遺失,則會導致業務域內職責的混亂:“這個新增業務是否應該由我們負責?”。落實到開發者身上就變成了應用架構的混亂:“這個業務我們到底應該寫在哪個微服務裏?”

最明顯最集中的問題會爆發在端到端用例中:戰略設計上一個實體上業務行爲的不清晰往往代表着一個甚至多個端到端用例的認知缺失,映射到戰術實現上就會演變成災難性的 "需求引入變更時,未考慮到某個用戶用例",最終在上線前的驗收環節甚至是上線後,發現這個需求的引入導致了 bug 的產生。

我們當然可以把這種事故歸結爲 “歷史遺留問題”,但是對於功能的使用者而言,這種糟糕體驗會直接讓平臺被貼上“不專業” 的負面標籤。對平臺本身而言,這種災難性的錯誤堆砌也只會讓系統不斷熵增,複雜程度愈發不可收拾,最終花費在處理問題、歷史代碼考古上的人力一增再增缺無濟於事。

這部分無疑是開播重構項目中,最迫切需要解決的問題。

圖片

2.1.2 描述語言不統一

在業務人員和產品的角度來看,"開播" 這個用例往往和各端開發人員所說的 "開播" 又有着某種微妙的差別。業務視角下的開播,往往是用戶一次完整的開播體驗,比如,打開移動直播姬,調整好各種用戶設置,點擊開播,最終看到自己的畫面被正確投放到 b 站直播間,並且可以完成後續和觀衆的互動。

而在技術視角下的開播,"開播" 是各執行方的橫切面組成的:客戶端完成最直接的 ui/ux 互動、直播服務端進行用戶請求校驗、視頻流和直播業務數據的協調、視頻雲負責接收用戶的上行視頻流;每一方對 "開播" 的這個詞解釋就產生了差異:客戶端進入到直播界面並點擊開播叫開播,服務端的開播接口被調用了也被視爲開播,視頻流被推送到視頻雲上行服務器的時候也可能被視爲開播。

泛泛而談的話,各方的解釋都沒有太大問題,但是這樣的解釋無法確切指定它在業務裏處於哪一部分,會造成什麼結果。最終呈現在一位新進入技術團隊的同學的眼中可能是這樣的場景:

舉例

客服:主播反饋線上無法開播。【問題平臺】PC 直播姬; 【一級分類】開播; 【二級分類】無法開播; 【問題描述】主播反饋進入移動直播姬開播界面後,點擊開播後,不能正常推流;

開發 1:是不是開播了多次?

客服:不是,主播開播了一次

開發 1:那可以讓用戶重試

開發 2:是不是視頻雲推流服務出了問題?@視頻雲

客服:用戶已經重試了,還是不能正常開播(其實是在另一臺設備上已經推流了,還在嘗試使用其他設備推流)

開發 3:視頻雲看到用戶推流是正常的 (推流監控圖)

開發 1:哦,原來是重複開播了

假設我是一位團隊新成員,在看到最終輸出的 "重複開播" 結論之前,得到的都是點狀的信息沒有完整的用例以供參考,難以理解線上問題的癥結在何處。如果這個時候甚至沒有文檔來描述開播領域相關業務,或者是開播流程的場景快照,那更是一場新人的災難——可能需要專門請教團隊中熟悉開播領域的資深開發爲他進行講解才能瞥見開播業務的一隅,且授課效果還要取決於講述人的結構化敘述能力,這是我們從效率考量上不願意見到的。

可以舉一個貼近實際開發人員的例子,"請教了 3 位同事才知道了開播記錄是怎麼產生的"、"請教了 3 位同事才本地構建成功",諸如此類的尷尬在日常工作中屢見不鮮的,實際上這類問題只會對程序員瞭解業務和編碼的積極性,以及商業化產品開發落地的效率起反作用。

2.1.3 對程序員的 "人文關懷"

一個貼近實際開發人員的例子,"請教了 3 位同事才知道了開播記錄是怎麼產生的"、"請教了 3 位同事才本地構建歷史服務成功",諸如此類的尷尬在日常工作中屢見不鮮的,實際上這類問題只會對程序員瞭解業務和編碼的積極性、產品開發落地的效率起反作用。

要解決這種效率抑或積極性問題,還是需要解決根源上的 " 知識共享 " 問題。

2.2 引入領域驅動設計

在前文敘述問題的時候,熟悉的讀者可能就已經想到了某個熱度經久不衰的架構思想:領域驅動設計。是的,我們在開播平臺的重構中決定使用這種方式來解決現有的諸多痛點。依靠領域驅動設計的設計思想,通過事件風暴建立領域模型,合理劃分領域邏輯和物理邊界,建立與現實世界相映射的領域對象和服務架構圖,定義符合 DDD 分層架構思想的代碼結構模型,保證業務模型與代碼模型的一致性。

相對的,對於最終的效果,也是可以預期到的:

2.3 領域驅動視角看開播

回到 "開播" 這個待解決的問題域本身,對開播業務中最核心的 "開播" 用例,核心的業務問題包括以下幾點需要明確:

也有一些非功能性的考慮:

首先,我們可以採用事件驅動開發方法,結合領域驅動設計中的事件風暴方法論,來梳理開播用例中的關鍵事件和參與者:

事件風暴的核心流程就是由用戶執行了命令,從而產生了事件。基於這個事件的結果,與之前相同或是其他的用戶會執行另一個命令,產生新類型的事件,以此類推。而順序是按照業務邏輯而定的。所以我們在整理開播涉及的時間風暴時,工作流如下:

  1. 確定用例的發起者,即主語。在開播場景中,可以是主播或者運營。

  2. 確定主語的動作,比如 "開啓直播"。

  3. 確定動作的流程中涉及的命令以及執行命令產生的事件、後果,例如 "新場次已被主播創建"。

  4. 補充流程中涉及的業務知識,例如 "房間"、各種各樣的 "檢查規則" 以及外部系統。

  5. 當一個完整的業務流程通過上述方式寫完之後,對於每個用戶,命令,事件進行組合,就能獲得聚合,用事件風暴的描述開播場次創建就是 "主播在場次聚合上進行了創建場次操作,導致了新場次創建事件",此事件發生後,用戶會在房間聚合上執行房間開播狀態流轉操作的新命令。

事件風暴中 "定義領域模型" 是最重要的一步,這一步需要瞭解實際業務形態後團隊內大量討論,從而達成共識。在這個階段中我們提煉了諸多的業務表現並且屏蔽技術實現細節,提取出了關鍵的實體、值對象、聚合根。緊接着就可以着手對事件風暴中的概念進行進一步的歸納。

通過以上步驟,我們可以清晰地梳理出開播用例中的關鍵事件參與者,爲後續的設計和開發工作奠定基礎。

業務描述拆解成主語和動詞的形式後,可以發現 " 房間 "和" 場次 "是這個問題域的兩個主要元素。在領域驅動設計中,需要將這兩個支撐域進行集成,最終形成" 開播域 "的基本解決方案。爲了確保開播業務流程的完整性,還需要將" 安全管控 "、" 分區 "、" 賬號 " 等子域或外部系統的知識參與其中,並將其作爲業務規則和值對象等等的形式進行表達。

根據近一年的業務現狀,我們參考領域驅動設計模式,進行了領域上下文的劃分:

8nv89B

明確領域上下文和解決域的劃分後,緊接着就可以進行 DDD 指導下的解決域戰術落地了。

領域劃分落實到戰術上的一個方案就是微服務,微服務將直接作爲開播域這個核心域與其他子域的實際界限。

在下文中,我們會講述如何將當前的 PHP 遺留服務,這個不滿足領域驅動設計的開發架構,演進爲受領域驅動設計指導的、貼合業務的、使用 Golang 搭建的整潔架構。

3. 開發架構

設計不只是感觀,設計就是產品的工作方式。——史蒂夫 · 喬布斯

開門見山地講,經過了多年積累後的舊版開播的遺留代碼是工程導向的,裏面不乏炫技的代碼、大段冗長而缺乏業務註釋的代碼,這讓 "開播" 這個重要的業務領域在技術實現上,與業務方的實際描述漸行漸遠。每當我們提及一些業務場景,都需要絞盡腦汁才能回想起這個場景到底與哪些代碼有一些聯繫。這樣的開發架構和技術實現方式,不論對團隊的知識共享還是對業務的正常迭代,都是一筆不可忽視的成本。

同時,由於陳年代碼經手多代程序員,導致代碼風格不統一領域邏輯和 UI 邏輯耦合的情況幾乎隨處可見,DAO 代碼更是可能隨時出現在各個層次,這樣的耦合對可拓展性和可測試性都帶來了不小的麻煩。

本次重構在戰術落地層面所面臨的挑戰,就是如何在保證業務邏輯幾乎不變的情況下,讓業務描述與代碼實現更貼切、認知負荷更低從而加強業務知識的地位,以及如何優雅地解耦原本雜亂耦合的各層次代碼,讓他們變得整潔、可測試、可拓展

3.1 設計模式

我們不妨先管中窺豹,看一個簡化版的新舊版本開播的時序圖對比:

圖片

圖片

很顯然,前者的描述充斥着純技術屬性的描述,大部分篇幅集中於諸如 area_id、uid 之類的屬性,難以直接和實際業務中的描述對應上。

而後者的描述則是有業務上的主客體描述的,如 " 房間是否屬於該用戶 "、" 分區是否允許該房間開播 "。在代碼的編排描述上,很容易就可以看出,後者的可理解性要比前者高出一截,這便引出了下文要討論的話題:新舊版本開播服務的設計模式。

3.1.1 舊版設計模式:事務腳本

事務腳本模式也叫做麪條代碼或者膠水代碼;它有一些顯著的特點:面向過程,易於編寫,難以應對變更,複雜事務腳本可讀性低 & 可維護性低。顯然,舊版 php 開播代碼在多年缺乏系統性維護的業務迭代後,已經幾乎退化爲這樣的模式。

根據 Fowler 在 PoEAA 中對事務腳本的描述,我們用上文的舊版開播進行分析。初次讀這段代碼的體驗,可能是如下的:

  1. 從表示層 / 服務層獲得輸入(開播請求的一些參數,比如 room_id、uid)

  2. 中間有大量的過程是用來做單純的獲取某一條數據(area_info 分區信息)

  3. 獲取對這些獲取的單一數據進行某些字段的判斷,或者多個單條數據聯合判斷(如,檢查房間開播狀態、檢查分區狀態是否爲 online)

  4. 之後調用其他系統或者存儲數據到數據庫(多次更新房間的多條信息,live_start_time/area_id)

  5. 過程中,不斷將單條操作後新增的數據合併到響應值中

開播這個動作,在舊版代碼中乍一看,是一個過程驅動,由許多僅有技術含義的動作完成的純技術操作,缺乏了對業務的基本感知和描述。這樣的模式,對業務中的場景業務歸納能力較弱,當我們提到某個場景時,往往需要把這些生硬的代碼在腦海中轉譯一次,才能對應上業務方的實際描述。

當我們擁有了更多動作時,就會有若干過程需要做相似的動作,通常就要使多個過程中包含某些相同的代碼,這些類似的副本會讓應用程序變成一張極度雜亂無章的網。

換一個角度,從 OOP 的角度上看該模式,實體的概念並不能完全表現,甚至只是充當了業務邏輯層數據訪問層之間的輔助角色,只空有屬性,沒有行爲。這樣實體在 業務行爲上難以和代碼實現 對應,更難以複用。

3.1.2 新版設計模式:領域模型

反之,對比起原來的事務腳本模式,我們的新服務中,包含有多個有血有肉的對象,比如房間賬號

對於應用服務需要完成的開播用例而言,相比起純過程的各個字段和子過程的串聯,更關心每一個對象應該做出什麼行爲。

圖片

圖片

3.2 戰術設計

3.2.1 戰術設計的思考:引入六邊形架構

既然是遺留系統現代化演進,我們不妨先提一些工程質量方面的提升預期:

業務領域的邊界更加清晰、更好的可擴展性、對測試的友好支持、更容易實施 DDD...

看到既定目標,再結合領域驅動設計指導的前情提要,相信對此熟悉的讀者已經會心一笑了,解決方案呼之欲出:六邊形架構。

3.2.2 領域模型與六邊形架構

怎麼寫開發架構相對整潔、看起來就可測試的代碼?相信大家或多或少都瞭解過 " 六邊形架構 "、" 整潔架構 "或者" 洋蔥架構 "。我們再來稍微複習一下它的定義:

六邊形架構,也被稱爲端口和適配器架構(Ports and Adapters Architecture),是由 Alistair Cockburn 於 2005 年首次提出的。這個架構模式的主要目標是將應用程序的核心業務邏輯與外部依賴分離開來,從而提高可測試性、可維護性和可擴展性。

在六邊形架構中,應用程序被劃分爲以下幾個關鍵部分:

通過將應用程序核心與外部依賴分離,六邊形架構提供了以下優勢:

本次我們新搭建的開播平臺,遵循了端口和適配器的架構風格,將服務拆分爲了以下的層次:

解決的問題

3.2.3 模塊設計

按照上文的開發架構設計,本次新開播服務的代碼分包結構代碼實現如下。

圖片

因爲 Golang 本身 OOP 的鴨子類型特性和諸多原因,我們的編碼風格顯得沒有那麼嚴格,選擇了相對鬆散的代碼分包結構。大致區分爲了領域層、防腐層、應用程序層、倉儲 / 基礎設施層

Domain 領域層

作爲領域驅動設計指導下的工程,我們的首要目標就是保障領域邏輯的正確性

最核心的領域層,svc/pkg/domain 包含了領域服務和各個領域對象的 interface 契約聲明具體實現。開播涉及到的領域對象都在這裏集中實現。

在開播的戰略設計中,我們提到了幾個上下文,在這裏會作爲聚合或對象的方式進行實現,他們的關係如下:

圖片

Facade 防腐層

對應設計中的 Transporter Layer 外部請求適配器,適配外部用例:

用例上,開播和用於調試開播的請求,都需要在用例層面適配他們,所以自然需要適配器來適配他們的 grpc 請求,以及考慮到今後多種接口形式的接入( http or mq),如果之後 grpc 定義出現變更,或者新請求形式的接入,不會對 Application 層的用例帶來滲透和影響。

設計原則

需要提供多組外部適配器,適配各種場景的開播請求(理論上可能是 grpc/http/mq ...,本文僅限於直播姬開播的場景),並轉化爲應用程序層可接受的用例級別請求。並且作爲防腐層,不應該有過多業務邏輯,僅實現必要的特定端到端場景的 UI 邏輯。

對外契約:

Application 應用程序層

應用層作爲場景用例的主體部分,充當了實體、聚合、領域服務的膠水層,將房間、場次、賬號的行爲集成到一起,最終形成 "直播姬開播" 用例的業務邏輯。最終,每一個用例會對應 application 的一個接口,如 "直播姬開播"、"直播姬關播"、"後臺開播"、"後臺關播" 等等,包裝成用例提供到外部。

Repository / Infrastructure 倉儲 / 基礎設施

對應 Data Source Adapters 內部資源適配器。同樣的,六邊形架構中,對下游依賴的約束也是依靠 接口與適配器 這一風格進行解耦和契約。

設計原則

3.3 測試驅動開發 TDD

當露營結束離開的時候,要打掃營地,讓它比你來的時候更乾淨。—— 童子軍原則,《97 Things Every Programmer Should Know》

3.3.1 動機

從項目角度出發,可以提供持續的項目進度反饋。開播平臺重構作爲一個大型項目,需要從業務和項目的量化成一個個可操作的任務寫到 to-do list,然後使用測試驅動編碼,可以在每一個預期用例完成後進行標記,那麼我們的工作目標將變得非常清晰,因爲工期、待辦事項、難點都非常明確,可以在持續細微的反饋中有意識地做一些適當的調整,比如添加新的任務,刪除冗餘的測試;還有一點更加讓人振奮,可以知道大概什麼時候可以完工,對開發進度可以更精確的把握。

從工程角度出發,可以確保代碼質量,也保障重構的安全性。一個軟件的自動化測試,可以從內部表達這個軟件的質量,我們通常管它叫做內建質量(Build Quality In)。開發人員如果忽視編寫自動化測試,就放棄了將質量內建到軟件(也就是自己證明自己質量)的機會,把質量的控制完全託付給了測試人員。這種靠人力去保證質量的方式,永遠也不可能代表 "技術先進性"。在用例級別保障了內建質量後,倘若將來有一天需要重構,由於有全面的測試套件作爲保障,開發人員可以放心地對代碼進行優化、改進結構或增加新功能,而不用擔心引入潛在的問題。

3.3.2 實踐

一句話來概括就是先設計用例,再寫代碼。

鑑於六邊形架構符合 端口與適配器 風格的契約,我們很容易知道:

那麼以下的 TDD 工作流就應該被遵守:

  1. 先明確 interface 的能力,定義所需的行爲,並編寫可讀性良好的註釋文檔來聲明它們的作用;

  2. 根據上一步的契約,編寫 interface 的測試用例

  3. 實現 interface 的業務邏輯,並實現接口以使其能夠通過。

  4. 業務邏輯測試,並使用上文編寫的用例進行測試,驗證預期行爲是否在待測的 interface 中產生。

  5. 根據結果調整代碼,直到可以通過測試用例。

由於六邊形架構中,接口與其實現天然存在接縫(seam),對於某個業務邏輯中對 Repository 甚至領域對象的情況,我們也可以輕鬆通過 mock 的方式進行依賴處理。

以房間聚合的開播狀態流轉作爲舉例:

圖片

第一步,我們根據 "開播狀態流轉" 這個領域對象的動作,進行需求分析,得出該動作的目的就是 "將房間狀態流轉爲開播中",一些關聯的知識就包括," 必須聲明開播時間 "、" 狀態流轉爲開播的同時需要與一場直播綁定 "、" 開播的房間必須已經選擇了分區 "。明確需求後,從業務邏輯中得到想要的用例可以得到如下的用例:

  1. 完全符合預期,開播的動作中包含所選的開播時間、場次、分區,所以房間狀態可以流轉爲開播

  2. 空操作,不合法輸入,失敗

  3. 沒有聲明開播時間,不合法輸入,失敗

  4. 沒有選擇分區,不合法輸入,失敗

  5. 沒有綁定一場直播,不合法輸入,失敗

  6. Repository 倉儲方法調用錯誤,失敗

同時也可以注意到,在開播狀態流轉中,我們只關注依賴的 Repository 的倉儲方法是否失敗,而不關心它如何實現的、爲何失敗的。因爲這對於房間對象而言並不是職責範圍內的知識,而是倉儲方法的職責範圍,所以在這個場景下,我們只關心倉儲是否交付成功即可。

簡單舉例,測試用例代碼如下:

圖片

第二步,根據這些已知用例實現 "房間狀態流轉爲開播",也就是實現 IRoomStatus 這個對象的 ChangeRoomStatusToStartLive 方法。

圖片

第三步,運行第一步編寫的測試用例,查看是否符合預期。對於領域對象具體依賴的 Repository,由於我們事先在六邊形架構中聲明瞭依賴的 interface 契約,所以可以較爲簡單地使用 mock 處理這些依賴。

圖片

第四步,運行完整的測試用例集合,如果不符合預期,則重回第二步,開始新一輪的修改和測試流程。

至此,一套完整的 UTDD 流程就良好地運作起來了,在實際的開發過程中,我們的每一個領域對象、倉儲方法、基礎設施的實現流程都是按照該流程進行的,在很大程度上保障了新開播的內建質量。

對於更爲大型的場景,比如 application 層對開播接口的測試,本質上在六邊形架構中也可以將集成的多個領域對象通過端口 - 適配器的解耦,將涉及的領域對象直接進行 mock,從而以較低的心智成本編寫出可讀性較高的集成測試,一個典型的集成測試集合如下:

圖片

在 TDD 思想的指導和開發流程下,我們的新服務整體單元測試覆蓋率達到了 70+%,部分關鍵領域邏輯的覆蓋率達到 100%。

如此的覆蓋率,不論在業務理解層面還是內建質量方面都產生了莫大的幫助——不必擔心一些改動導致的重要影響無法被開發者捕捉到,這無疑在未來的業務迭代和進一步重構中都會起到關鍵作用。

4 安全的系統遷移

兵馬未動,糧草先行。——《孫子兵法》

一艘巨輪建造完成後終究需要下水,而往往船下水的方案設計是先於船體本身的建造的。開播能被稱爲遺留系統,那麼它背後的歷史邏輯和技術債務一定不容小覷,我們對新開播系統 "完工下水" 這件事,顯然就要謹慎對待了,從新開播的實現本身、到中間的開發執行和驗證,以及最後的部署灰度,都需要進行細緻的考慮,保證這艘新船能順利接觸水面。

前期對業務邏輯進行最細緻的歸納,這其中包括了代碼的逐行校對每個邏輯分支的業務邏輯梳理,甚至也包括了 PHP 和 Golang 基礎組件的源碼對比

中期在代碼編寫的過程中逐步明確 "檢查點" 和事件溯源的全貌,設計並完善了驗證方案:流量複製事件溯源,並構建完善的新舊開播檢查點對比系統,保證關鍵的邏輯節點上,新舊服務的表現完全一致。

後期在服務部署灰度策略上,也做了最周密的準備,包括網關級別萬分位的灰度放量規則和業務級別的重要房間退避方案。

4.1 業務邏輯

業務邏輯通常是最沒有邏輯的東西。—— Martin Fowler,《企業應用架構模式》

4.1.1 歷史邏輯

面對已存在多年的業務邏輯,不論它是否容易閱讀、我們是否熟悉跨語言的寫法,都應該心存敬畏,逐個分支、逐個業務場景進行盤點,最終形成對此業務場景的正確理解。

面對這種高準確度要求的表達訴求,我們選擇了已有的接口自動化測試用例結合手動端到端驗證 + 逐行閱讀對比代碼的方式進行梳理驗證,最後以時序圖的方式,將舊開播服務的 PHP 實現邏輯呈現到施工方案中。

(涉及具體業務流程,僅展示縮略圖)

既然是 "重構",我們選擇儘量保持原有的邏輯流和數據流,先將主邏輯大部分遷移完成,再進行下一步的改造。

所以在新版本的重構中,涉及的業務邏輯流,實際上並沒有過大的改變,從而保障了邏輯分支在端到端表現上可以完全一致。

4.1.2 轉化漏斗圖

針對上述邏輯分支衆多的用例場景,我們也嘗試使用最直觀的圖形形式,展現給對開播領域不甚熟悉的研發同學,甚至是產運同學進行參考,最終選擇了漏斗圖的形式。

最頂層爲開播接口的入口,對應直播姬點擊 "開始直播" 按鈕後對服務端開播接口的請求。而後的一系列漏斗層,則代表了服務端的行爲,中途不斷有檢查項攔截不符合開播條件的請求,直到底部的成功開播。

4.2 流量複製 & 事件溯源

以上文歸納的 "業務邏輯" 爲指導,我們着手構建了一套爲開播業務邏輯遷移量身打造的流量複製和數據驗證方案。

作爲核心場景,開播日均承載的流量大,且邏輯流具有不確定性:不同的開播賬號、開播場景,甚至是網絡環境,都可能會導致會走入某一個上述複雜的開播邏輯分支中,可能有業務邏輯拒絕開播直接中斷開播流程的情況,也有可能發生內部錯誤但繼續執行開播流程的情況。所以如何在衆多的業務分支中識別出新舊開播服務的數據流和邏輯流完全一致,是本次工程中的難點之一。

對此,我們設計了一套 " 流量複製 "和" 事件溯源 " 的驗證方案。

4.2.1 流量複製

在舊版和新版開播正式進行切換之前,必須保證新版舊版開播邏輯和數據鏈路和業務邏輯一致,爲此我們設計了 "流量複製" 和 "事件溯源 / 對賬" 的機制。

對於複製過來的一組流量,我們期望它是冪等的,不能對下游數據產生任何影響。

在上文開發架構中提到,Repository 和 Infrastructure 在六邊形架構中,可以通過不同的方式實現契約,那麼對於 "不真實執行" 這一實現方式,是天然可以實現支持的——新增一組 "假寫" 的適配器即可。

爲保證這些檢查點在新舊服務完全一致,在驗證方案中設計了以下三個階段:

  1. 舊服務進行開播事件上報

  2. 主要邏輯仍然由舊服務處理,網關服務複製流量到新服務。新服務只執行冪等邏輯,不進行真實的寫操作。新舊服務均上報關鍵事件檢查點,統一在數據平臺進行每一條請求的檢查。

  3. 重構部分的關鍵事件檢查點驗證完成後,舊服務不再上報,而新服務切換爲真實寫入的模式,並且繼續保留關鍵事件上報能力。

4.2.2 事件溯源

藉助戰略設計章節中的 " 事件風暴 " 整理出的關鍵路徑和事件,稍加整理就可以得到一組關鍵事件鏈路,藉助事件溯源(Event Sourcing)的思想,我們可以將開播流程中的重要節點上報並持久化

根據事件風暴和業務邏輯的時序圖,我們設定了以下關鍵事件檢查點:

根據這些檢查點,我們在新舊版本的開播代碼中進行改造,在對應的點位埋樁進行數據上報。由於他們可以被聚合在同一條 trace 下,所以針對每一條開播接口的請求,都可以被完整地記錄在案。

從事件溯源中,我們也可以獲取到一個意料之中的收穫:開播鏈路在服務端鏈路的業務可觀測性

4.3 自動化測試

4.3.1 UTDD 單元測試 & 集成測試

如 3.3(測試驅動開發)部分所述,新開播服務在開發時就採用了 TDD 工作流,單測覆蓋率 70% 以上,關鍵邏輯的行覆蓋率達到 100%。

單元測試覆蓋率檢查集成到 CI 中,保證後續業務迭代質量。

圖片

4.3.2 ATDD 測試共建自動化測試用例

在本次重構中,我們與測試團隊持續合作,共建了 200 + 條開播接口的自動化集成測試用例,覆蓋了大部分的請求參數檢查、用戶身份和狀態、特殊開播場景、安全管控策略、分區和場次狀態等正常和異常用例,並對對應預期接口返回結果、數據和消息寫入結果等檢查。同時在自動化測試中引入 diff 能力,相同參數輸入下新舊服務接口響應進行對比,覆蓋 80% 以上開播場景。

整個重構的遷移過程中,我們通過接口自動化測試,發現並修復問題 10 + 個。

4.4 部署計劃

整體上線(包括流量複製 & 實際灰度階段)分爲了三個階段:

整個部署發佈不同階段,都嚴格制定 SOP 按照計劃執行,避免遺漏或切換過程中對線上開播服務的影響:

4.5 結果

在精細的驗證計劃、部署計劃和嚴格的流程把控下,開播在整個遷移過程中未出現任何事故

其中一些驗證操作的功效是很直觀的:

同時我們在一個月時間內,逐步進行了精細到單個用戶粒度 - 萬分位 - 千分位 - 十分位 - 全量的灰度,在途中也優化了 10 + 性能問題

最終順利全量上線。

5 生產配套

“君之所以明者,兼聽也;其所以暗者,偏信也。”——漢 · 王符《潛夫論 · 明暗》

一個運作良好的系統首先必須具備良好的可觀測性,倘若都無法觀測到各個零件運作是否良好,又談何算得上一輛好車。

對於開播這種不容有失的系統,萬萬不可寫完代碼就萬事大吉。我們需要更加謹慎地將系統的運作狀態觀測納入設計考慮,讓觀測變得更加直觀,使潛在的系統性風險可以快速暴露,也便於在緊急情況下做出恰當的決策。

5.1 系統監控

對於開播服務的整體鏈路,我們通過前文的事件溯源上報方案結合司內的監控解決方案,對開播成功、開播拒絕的情況進行了上報統計,對開播整體大盤的開播成功率、被拒絕開播的原因和發生率形成直觀感受。

若開播系統出現了某種業務異動,比如被拒絕開播的突增,我們可以藉助監控大盤和告警體系在第一時間感知到。

5.2 系統排障

伴隨着 "事件溯源" 體系的建設,自然可以衍生出衆多提升系統可觀測性的輔助工具。這些工具在未來的業務運維和業務迭代過程中可以節省大量的人力。

如可以實時驗證是否指定房間是否滿足開播條件的 "模擬開播":

以及針對每一條歷史開播請求可以追溯關鍵事件,排查開播爲何成功 / 失敗的 "開播事件問診":

6 結果

回顧文章開篇時提到的歷史債務上來,我們從業務層面和技術層面來進行一些簡單的覆盤。

6.1 業務收益

知識共享:在開播平臺重構的一系列工作中,首當其衝的是對開播歷史邏輯的完整梳理,這無疑提高了產研對開播業務的理解程度,降低溝通成本;在過程中,我們也已與產品溝通了衆多不曾關注到的功能細節,幫助產品更好地建設開播工具生態。伴隨着產研對業務知識的理解成本降低,一些客訴問題的排查也會變得容易起來——從前一些只有代碼編寫者才能描述的邊緣情況,現在更容易被產品甚至熟悉的運營所得知,進而減低對開播功能的疑惑,最終使產研協作效率提升

開發提效:在 PHP 舊服務的開發過程中,用例梳理、PHP 代碼晦澀的 Coding 過程、複雜代碼的反覆 Review、PHP 的遠古工具鏈使用都會佔用大量的開發時間;相較舊版,新版開播接口不存在這些歷史包袱,極大提高了開發效率

業務 SRE:"開播事件溯源" 提供的接口請求級別的問診能力,不同於以往排查開播問題時需要手動翻閱每一條關鍵日誌,新版本的一鍵查詢溯源記錄能力可以大大降低研發的問題排查成本。

6.2 系統性風險優化

在過去,開播系統運行於 "房間服務" 的 PHP 服務之中,該服務除了承載開播業務,也承載了大量和直播有關的周邊業務接口;

從技術角度,跨語言的遷移解決了較多的風險:

圖片

從業務角度看,也提高了業務的系統穩定性:

6.3 技術資產

一個好的技術項目,不僅需要達成業務和技術上的硬性目標,還需要有所積累和成長。我們在開播重構的旅途中,也摸索出了一套行之有效、可複用的觀測模式和遷移模式。

6.3.1 更細粒度的業務可觀測

上文中提到的業務鏈路可觀測,沉澱後也成爲了開播問診臺的通用事件溯源功能。

可查看某個房間過往不同開播場次,過程 & 結果關鍵事件的數據信息,更快的定位到線上每一次具體開播的情況。

6.3.2 可複用的遷移模式

經過本次開播接口遷移的歷練,開播平臺獲得了可複用的 PHP 轉 Go 的工程經驗,我們也可以嘗試用 DDD 的觀點來總結:

圖片

一些沉澱的能力如下:

那麼套用回 “開播平臺遷移” 這個問題域彙總,我們可以得到以下的解法:

一個完整的迭代可能是這樣的:確保項目組內產研認知一致後,按照 TDD 方法編寫出初版代碼;通過衆多測試用例後,進行流量複製和事件溯源,通過關鍵事件對比保障關鍵檢查點和數據鏈路完全一致,最終按照 SOP 進行上線。如果中途發現了修改點,需要回退到初版代碼編寫,乃至同一領域知識的步驟進行項目組認知的對齊。

通過業務流的完整評估,再有嚴謹的工程驗證計劃保障,在事實上極大降低了出現嚴重遷移事故的概率(開播遷移過程中未出現 PX 以上事故)。

7 後日談:可演進的 “遺留” 系統

重構和微服務的締造者,軟件開發領域的泰斗,Martin Fowler 曾經說過這樣一句話:

Let's face it, all we are doing is writing tomorrow's legacy software today.

是的,可以毫不誇張地說,你現在所寫的每一行代碼,都是未來的遺留系統。這聽上去有點讓人沮喪,但卻是血淋淋的事實,一個軟件系統的生命週期終歸會符合業務演進的客觀規律。

不過大可不必氣餒,回到我們在引言中談到的遺留系統定義,有些系統時間雖長,但如果一直堅持現代化的開發方式,在代碼質量、架構合理性、測試策略、DevOps 等方面都保持先進性,就算將來需要進行架構的進一步演進,這樣 "整潔" 的老系統也會幫助我們規避衆多的問題,甚至可以讓演進週期縮短、演進風險降低。

相信我們今天在開播平臺遷移中花費的心血和留下的基石,終會爲 "歷久彌新" 的系統打下基礎。

參考:

[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) 遺留系統現代化實戰, 極客時間. Available at: https://time.geekbang.org/column/intro/100111101 (Accessed: 22 November 2023).

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/V9vP1FuaK3Dg3UXjwZUlkA