前言
美團外賣2013年11月開始起步,随後高速發展,不斷重新整理多項行業記錄。截止至2018年5月19日,日訂單量峰值已超過2000萬,是全球規模最大的外賣平台。業務的快速發展對技術支撐提出了更高的要求:為線上使用者提供高穩定的服務體驗,保障全鍊路業務和系統高可用運作的同時,要提升多入口業務的研發速度,推進App系統架構的合理演化,進一步提升跨部門跨地域團隊之間的協作效率。
而另一方面随着使用者數與訂單數的高速增長,美團外賣逐漸有了流量平台的特征,兄弟業務紛紛嘗試接入美團外賣進行推廣和釋出,期望提供統一标準化服務平台。是以,基礎能力标準化,推進多端複用,同時輸出成熟穩定的技術服務平台,一直是我們技術團隊追求的核心目标。
多端複用的端
這裡的“端”有兩層意思:
- 其一是相同業務的多入口
美團外賣在iOS下的業務入口有三個,『美團外賣』App、『美團』App的外賣頻道、『大衆點評』App的外賣頻道。
值得一提的是:由于使用者畫像與産品政策差異,『大衆點評』外賣頻道與『美團』外賣頻道和『美團外賣』雖經曆技術棧融合,但業務形态差別較大,暫不考慮上層業務的複用,故這篇文章主要介紹美團系兩大入口的複用。
在2015年外賣C端合并之前,美團系的兩大入口由兩個不同的團隊研發,雖然使用者感覺的互動界面幾乎相同,但功能實作層面的代碼風格和技術棧都存在較大差異,同一需求需要在兩端重複開發顯然不合理。是以,我們的目标是相同功能,隻需要寫一次代碼,做一次估時,其他端隻需做少量的适配工作。
- 其二是指平台上各個業務線
外賣不同兄弟業務線都依賴外賣基礎業務,包括但不限于:地圖定位、登入綁定、網絡通道、異常處理、工具UI等。考慮到标準化的範疇,這些基礎能力也是需要多端複用的。
圖1 美團外賣的多端複用的目标
關于元件化
提到多端複用,不免與元件化産生聯系,可以說元件化是多端複用的必要條件之一。大多數公司口中的“元件化”僅僅做到代碼分庫,使用Cocoapods的Podfile來管理,再在主工程把各個子庫的版本号聚合起來。但是能設計一套合理的分層架構,理清依賴關系,并有一整套工具鍊支撐元件發版與內建的相對較少。否則元件化隻會導緻包體積增大,開發效率變慢,依賴關系複雜等副作用。
整體思路
A. 多端複用概念圖
圖2 多端複用概念圖
多端複用的目标形态其實很好了解,就是将原有主工程中的代碼抽出獨立元件(Pods),然後各自工程使用Podfile依賴所需的獨立元件,獨立元件再通過podspec間接依賴其他獨立元件。
B. 準備工作
确認多端所依賴的基層庫是一緻的,這裡的基層庫包括開源庫與公司内的技術棧。
iOS中常用開源庫(網絡、圖檔、布局)每個功能基本都有一個庫業界壟斷,這一點是iOS相對于Android的優勢。公司内也存在一些對開源庫二次開發或自行研發的基礎庫,即技術棧。不同的大組之間技術棧可能存在一定差異。如需要複用的端之間存在差異,則需要重構使得技術棧統一。(這裡建議重構,不建議适配,因為如果做的不夠徹底,後續很大可能需要填坑。)
就美團而言,美團平台與點評平台作為公司兩大App,曆史積澱厚重。自2015年底合并以來,為了共建和沉澱公共服務,減少重複造輪子,提升研發效率,對上層業務方提供統一标準的高穩定基礎能力,兩大平台的底層技術棧也在不斷融合。而美團外賣作為較早實踐獨立App,同時也是依托于兩大平台App的大業務方,在外賣C端合并後的1年内,我們也做了大量底層技術棧統一的必要工作。
C. 方案選型
在演進式設計與計劃式設計中的抉擇。
演進式設計指随着系統的開發而做設計變更,而計劃式設計是指在開發之前完全指定系統架構的設計。演進的設計,同樣需要遵循架構設計的基本準則,它與計劃的設計唯一的差別是設計的目标。演進的設計提倡滿足客戶現有的需求;而計劃的設計則需要考慮未來的功能擴充。演進的設計推崇盡快地實作,追求快速确定解決方案,快速編碼以及快速實作;而計劃的設計則需要考慮計劃的周密性,架構的完整性并保證開發過程的有條不紊。
美團外賣iOS用戶端,在多端複用的立項初期面臨着多個關鍵點:頻道入口與獨立應用的複用,外賣平台的搭建,兄弟業務的接入,點評外賣的協作,以及架構遷移不影響現有業務的開發等等,是以權衡後我們使用“演進式架構為主,計劃式架構為輔”的設計方案。不強求曆史代碼一下達到終極完美架構,而是循序漸進一步一個腳印,滿足現有需求的同時并保留一定的擴充性。
演進式架構推動複用
術語解釋
- Waimai:特指『美團外賣』App,泛指那些獨立App形式的業務入口,一般為project。
- Channel:特指『美團』App中的外賣頻道,泛指那些以頻道或者Tab形式內建在主App内的業務入口,一般為Pods。
- Special:指将Waimai中的業務代碼與原有工程分離出來,讓業務代碼成為一個Pods的形态。
- 下沉:即下沉到下層,這裡的“下層”指架構的基層,一般為平台層或通用層。“下沉”指将不同上層庫中的代碼統一并移動到下層的基層庫中。
在這裡先貼出動态的架構演進過程,讓大家有一個宏觀的概念,後續再對不同節點的經曆做進一步描述。
圖3 演進式架構動态圖
原始複用架構
如圖4所示,在過去一兩年,因為技術棧等原因我們隻能采用比較保守的代碼複用方案。将獨立業務或工具類代碼沉澱為一個個“Kit”,也就是粒度較小的元件。此時分層的概念還比較模糊,并且以往的工程因曆史包袱導緻耦合嚴重、邏輯複雜,在将UGC業務剝離後發現其他的業務代碼無法輕易的抽出。(此時的代碼複用率隻有2.4%。)
鑒于之前的準備工作已經完成,多端基礎庫已經一緻,于是我們不再采取保守政策,豐富了一些元件化通信、解耦與過渡的手段,在分層架構上開始發力。
圖4 原始複用架構
業務複用探索
在技術棧已統一,基礎層已對齊的背景下,我們挑選外賣核心業務之一的Store(即商家容器)開始了在業務複用上的探索。如圖5所示,大緻可以了解為“二合一,一分三”的思路,我們從代碼風格和開發思路上對兩邊的Store業務進行對齊,在此過程中順勢将業務類與技術(功能)類的代碼分離,一些通用Domain也随之分離。随着一個個元件的拆分,我們的整體複用度有明顯提升,但開發效率卻意外的受到了影響。多庫開發在版本的釋出與內建中增加了很多人工操作:依賴沖突、lock檔案沖突等問題都阻礙了我們的開發效率進一步提升,而這就是之前“關于元件化”中提到的副作用。
于是我們将自動發版與自動內建提上了日程。自動內建是将“元件開發完畢到功能合入工程主體打出測試包”之間的一系列操作自動化完成。在這之前必須完成一些前期鋪墊工作——殼工程分離。
圖5 商家容器下沉時期
殼工程分離
如圖6所示,殼工程顧名思義就是将原來的project中的代碼全部拆出去,得到一個空殼,僅僅保留一些工程配置選項和依賴庫管理檔案。
為什麼說殼工程是自動內建的必要條件之一?
因為自動內建涉及版本号自增,需要機器修改工程配置類檔案。如果在建立二進制的過程中有新業務PR合入,會造成commit樹分叉大機率産生沖突導緻內建失敗。抽出殼工程之後,我們的殼隻關心配置選項修改(很少),與依賴版本号的變化。業務代碼的正常PR流程轉移到了各自的業務元件git中,以此來杜絕人工與機器的沖突。
圖6 殼工程分離
殼工程分離的意義主要有如下幾點:
- 讓職能更加明确,之前的綜合層身兼數職過于繁重。
- 為自動內建鋪路,避免業務PR與機器沖突。
- 提升效率,後續Pods往Pods移動代碼比proj往Pods移動代碼更快。
- 『美團外賣』向『美團』開發環境靠齊,降低适配成本。
圖7 殼工程分離階段圖
圖7的第一張圖到第二張圖就是上文提到的殼工程分離,将“Waimai”所有的業務代碼打包抽出,移動到過渡倉庫Special,讓原先的“Waimai”成為殼。
第二張圖到第三張圖是Pods庫的内部消化。
前一階段相當于簡單粗暴的實體代碼移動,後一階段是對Pods内整塊代碼的梳理與分庫。
内部消化對齊
在前文“多端複用概念圖”的部分我們提到過,所謂的複用是讓多端的project以Pods的方式接入統一的代碼。我們相容考慮保留一端代碼完整性,降低回接成本,決定分Subpods使用階段性合入達到平滑遷移。
圖8 代碼下沉方案
圖8描述了多端相同子產品内的代碼具體是如何統一的。此時因為已經完成了殼工程分離,是以業務代碼都在“Special”這樣的過渡倉庫中。
“Special”和“Channel”兩端的子產品統一大緻可分為三步:平移 → 下沉 → 回接。(前提是此子產品的業務上已經确定是完全一緻。)
平移階段是保留其中一端“Special”代碼的完整性,以自上而下的平移方式将代碼檔案拷貝到另一端“Channel”中。此時前者不受任何影響,後者的代碼因為新檔案拷貝和原有代碼存在重複。此時将舊檔案重命名,并深度優先周遊新檔案的依賴關系補齊檔案,最終使得編譯通過。然後将舊檔案中的部分差異代碼加到新檔案中做好一定的差異化管理,最後删除舊檔案。
下沉階段是将“Channel”處理後的代碼解耦并獨立出來,移動到下層的Pods或下層的SubPods。此時這裡的代碼是既支援“Special”也支援“Channel”的。
回接階段是讓“Special”以Pods依賴的形式引用之前下沉的子產品,引用後删除平移前的代碼檔案。(如果是在版本的間隙完成固然最好,否則需要考慮平移前的代碼檔案在這段時間的diff。)
實際操作中很難在有限時間内處理完一個完整的子產品(例如訂單子產品)下沉到Pods再回接。于是選擇将大子產品分成一個個子子產品,這些子子產品平滑的下沉到SubPods,然後“Special”也隻引用這個統一後的SubPods,待一個子產品完全下沉完畢再拆出獨立的Pods。
再總結下大量代碼下沉時如何保證風險可控:
- 聯合PM,先進行業務梳理,特殊差異要标注出來。
- 使用OClint的提前掃描依賴,做到心中有數,精準估時。
- 以“Special”的代碼風格為基準,“Channel”在對齊時僅做加法不做減法。
- “Channel”對齊工作不影響“Special”,并且回接時工作量很小。
- 分疊代包,QA資源提前協調。
中間件層級壓平
經過前面的“内部消化”,Channel和Special中的過渡代碼逐漸被分發到合适的元件,如圖9所示,Special隻剩下AppOnly,Channel也隻剩下ChannelOnly。于是Special消亡,Channel變成打包工程。
AppOnly和ChannelOnly 與其他業務元件層級壓平。上層隻留下兩個打包工程。
圖9 中間件層級壓平
平台層建設
如圖10所示,下層是外賣基礎庫,WaimaiKit包含衆多細分後的平台能力,Domain為通用模型,XunfeiKit為對智能語音二次開發,CTKit為對CoreText渲染架構的二次開發。
針對平台适配層而言,在差異化收斂與依賴關系梳理方面發揮重要角色,這兩點在下問的“衍生問題解決中”會有詳細解釋。
外賣基礎庫加上平台适配層,整體構成了我們的外賣平台層(這是邏輯結構不是實體結構),提供了60餘項通用能力,支援無差異調用。
圖10 外賣平台層的建設
多端通用架構
此時我們把基層元件與開源元件梳理并補充上,達到多端通用架構,到這裡可以說真正達到了多端複用的目标。
圖11 多端通用架構完成
由上層不同的打包工程來控制實際需要的元件。除去兩個打包工程和兩個Only元件,下面的元件都已達到多端複用。對比下“Waimai”與“Channel”的業務架構圖中兩個黑色圓圈的部分。
圖12 “Waimai”的業務架構
圖13 “Channel”的業務架構
衍生問題解決
差異問題
A.需求本身的差異
三種解決政策:
- 對于文案、數值、等一兩行代碼的差異我們使用 運作時宏(動态擷取proj-identifier)或預編譯宏(custome define)直接在方法中進行if else判斷。
- 對于方法實作的不同 使用Glue(膠水層),protocol提供相同的方法聲明,用來給外部調用,在不同的載體中寫不同的方法實作。
- 對于較大差異例如兩邊WebView容器不一樣,我們建多個檔案采用檔案級預編譯,可預編譯正常.m檔案或者Category。(例如WMWebViewManeger_wm.m&WMWebViewManeger_mt.m、UITableView+WMEstimated.m&UITableView+MTEstimated.m)
進一步優化政策:
用上述三種政策雖然完成差異化管理,但差異代碼散落在不同元件内難以收斂,不便于管理。有了平台适配層之後,我們将差異化判斷收斂到适配層内部,對上層提供無差異調用。元件開發者在開發中不用考慮宿主差異,直接調用用通用接口。差異的判斷或者後續優化在接口内部處理外部不感覺。
圖14給出了一個平台适配層提供通用接口修改後的例子。
圖14 平台适配層接口示例
B.多端節奏差異
實際場景中除了需求的差異還有可能出現多端進版節奏的差異,這類差異問題我們使用分支管理模型解決。
前提條件既然要多端複用了,那需求的大方向還是會希望多端統一。一般較多的場景是:多端中A端功能最少,B端功能基本算是是A端的超集。(沒有絕對的超集,A端也會有較少的差異點。)在外賣的業務中,“Channel”就是這個功能較少的一端,“Waimai”基本是“Channel”的超集。
兩端的差異大緻分為了這5大類9小類:
- 需求兩端相同(1.1、提測上線時間基本相同;1.2、“Waimai”比“Channel”早3天提測 ;1.3、“Waimai”比“Channel”晚3天提測)。
- 需求“Waimai”先進版,“Channel”下一版進 (2.1、頻道下一版就上;2.2、頻道下兩版本後再上)。
- 需求“Waimai”先進版,“Channel”不需要。
- 需求“Channel”先進版,“Waimai”下一版進(4.1、需要改動通用部分;4.2、隻改動“ChannelOnly”的部分)。
- 需求“Channel”先進版,“Waimai”不需要(隻改動“ChannelOnly”的部分)。
圖15 最複雜場景下的分支模型
也不用過多糾結,圖15是最複雜的場景,實際場合中很難遇到,目前的我們的業務隻遇到1和2兩個大類,最多2條線。
編譯問題
以往的開發方式初次全量編譯5分鐘左右,之後就是差量編譯很快。但是抽成元件後,随着部分子庫版本的切換間接的增加了pod install的次數,此時高頻率的3分鐘、5分鐘會讓人難以接受。
于是在這個節點我們采用了全二進制依賴的方式,目标是在日常開發中直接引用編譯後的産物減少編譯時間。
圖16 使用二進制的依賴方式
如圖所示三個.a就是三個subPods,分了三種Configuration:
- debug/ 下是 deubg 設定編譯的 x64 armv7 arm64。
- release/ 下是 release 設定編譯的 armv7 arm64。
- dailybuild/ 下是 release + TEST=1編譯的 armv7 arm64。
- 預設(在檔案夾外的.a)是 debug x64 + release armv7 + release arm64。
這裡有一個問題需要解決,即引用二進制帶來的弊端,顯而易見的就是将編譯期的問題帶到了運作期。某個宏修改了,但是編譯完的二進制代碼不感覺這種改動,并且依賴版本不比對的話,原本的方法缺失編譯錯誤,就會帶到運作期發生崩潰。解決此類問題的方法也很簡單,就是在所有的打包工程中都配置了打包自動切換源碼。二進制僅僅用來在開發中獲得更高的效率,一旦打提測包或者釋出包都會使用全源碼重新編譯一遍。關于切源碼與切二進制是由環境變量控制拉取不同的podspec源。
并且在開發中我們支援源碼與二進制的混合開發模式,我們給某個binary_pod修飾的依賴庫加上标簽,或者使用.patch檔案,控制特定的庫拉源碼。一般情況下,開發者将與自己目前需求相關聯的庫拉源碼便于Debug,不關聯的庫拉二進制跳過編譯。
依賴問題
如圖17所示,外賣有多個業務元件,公司也有很多基礎Kit,不同業務元件或多或少會依賴幾個Kit,是以極易形成網狀依賴的局面。而且依賴的版本号可能不一緻,易出現依賴沖突,一旦遇到依賴沖突需要對某一元件進行修改再重新發版來解決,很影響效率。解決方式是使用平台适配層來統一維護一套依賴庫版本号,上層業務元件僅僅關心平台适配層的版本。
圖17 平台适配層統一維護依賴
當然為了避免引入平台适配層而增加過多無用依賴的問題,我們将一些依賴較多且使用頻度不高的Kit抽出subPods,支援可選的方式引入,例如IM元件。
再者就是pod install 時依賴分析慢的問題。對于殼工程而言,這是所有依賴庫彙聚的地方,依賴關系寫法若不科學極易在analyzing dependency中耗費大量時間。Cocoapods的依賴分析用的是
Molinillo算法,連結中介紹了這個算法的實作方式,是一個具有前向檢察的回溯算法。這個算法本身是沒有問題的,依賴層級深隻要依賴寫的合理也可以達到秒開。但是如果對依賴樹葉子節點的版本号控制不夠嚴密,或中間出現了循環依賴的情況,會導緻回溯算法重複執行了很多壓棧和出棧操作耗費時間。美團針對此類問題的做法是維護一套“去依賴的podspec源”,這個源中的dependency節點被清空了(下圖中間)。實際的所需依賴的全集在殼工程Podfile裡平鋪,統一維護。這麼做的好處是将之前的樹狀依賴(下圖左)壓平成一層(下圖右)。
圖18 依賴數的壓平
效率問題
前面我們提到了自動內建,這裡展示下具體的使用方式。美團釋出工程組自行研發了一套
HyperLoop發版內建平台。當某個元件在建立二進制之前可自行選擇內建的目标,如果多端複用了,那隻需要在發版建立二進制的同時勾選多個內建的目标。發版後會自行進行一系列檢查與測試,最終将代碼合入主工程(修改對應殼工程的依賴版本号)。
圖19 HyperLoop自動發版自動內建
圖20 主工程commit message的變化
以上是“Waimai”的commit對比圖。第一張圖是以往的開發方式,能看出工程配置的commit與業務的commit交錯堆砌。第二張圖是進行殼工程分離後的commit,能看出每條message都是改了某個依賴庫的版本号。第三張圖是使用自動內建後的commit,能看出每條message都是畫風統一且機器串行送出的。
這裡又衍生出另一個問題,當我們用殼工程引Pods的方式替代了project集中式開發之後,我們的代碼修改散落到了不同的元件庫内。想看下主工程6.5.0版本和6.4.0版本的diff時隻能看到所有依賴庫版本号的diff,想看commit和code diff時必須挨個去元件庫檢視,在三輪提測期間這樣類似的操作每天都會重複多次,很不效率。
于是我們開發了atomic diff的工具,主要原理是調git stash的接口得到版本号diff,再通過版本号和對應的倉庫位址深度周遊commit,再深度周遊commit對應的檔案,最後彙總,得到整體的代碼diff。
圖21 atomic diff彙總後的commit message
整套工具鍊對多端複用的支撐
上文中已經提到了一些自動化工具,這裡整理下我們工具鍊的全景圖。
圖22 整套工具鍊
- 在準備階段,我們會用OClint工具對compile_command.json檔案進行處理,對将要修改的元件提前掃描依賴。
- 在依賴庫拉取時,我們有binary_pod.rb腳本裡通過對源的控制達到二進制與去依賴的效果,美團釋出工程組維護了一套ios-re-sankuai.com的源用于存儲remove dependency的podspec.json檔案。
- 在依賴同步時,會通過sync_podfile定時同步主工程最新Podfile檔案,來對依賴庫全集的版本号進行維護。
- 在開發階段,我們使用Podfile.patch工具一鍵對二進制/源碼、遠端/本地代碼進行切換。
- 在引用本地代碼開發時,子庫的版本号我們不太關心,隻關心主工程的版本号,我們使用beforePod和AfterPod腳本進行依賴過濾以防止依賴沖突。
- 在代碼送出時,我們使用git squash對多條相同message的commit進行擠壓。
- 在建立PR時,以往需要一些網頁端手動操作,填寫大量Reviewers,現在我們使用MTPR工具一鍵完成,或者根據個人喜好使用Chrome插件。
- 在功能合入master之前,會有一些jenkins的job進行檢測。
- 在發版階段,使用Hyperloop系統,一鍵發版操作簡便。
- 在發版之後,可選擇自動內建和聯合內建的方式來打包,打包産物會自動上傳到美團的“搶鮮”内測平台。
- 在問題跟蹤時,如果需要檢視主工程各個版本号間的commit message和code diff,我們有atomic diff工具深度周遊各個倉庫并彙總結果。
感想總結
- 多端複用之後對PM-RD-QA都有較大的變化,我們代碼複用率由最初的2.4%達到了84.1%,讓更多的PM投入到了新需求的吞吐中,但研發效率提升增大了QA的工作量。一個大的嘗試需要RD不斷與PM和QA保持溝通,選擇三方都能接受的最優方案。
- 厘清主次關系,技術架構等最終是為了支撐業務,如果一個架構設計的美如畫天衣無縫,但是落實到自己的業務中确不能發揮理想效果,或引來抱怨一片,那這就是個失敗的設計。并且在實際開發中技術類代碼修改盡量選擇版本間隙合入,如果與業務開發的同學産生沖突時,都要給業務同學讓路,不能影響原本的版本疊代速度。
- 時刻對 “不合理” 和 “重複勞動”保持敏感。新增一個埋點常量要去改一下平台再發個版是否成本太大?一處訂單狀态的需求為什麼要修改首頁的Kit?實際開發中遇到别扭的地方多增加一些思考而不是硬着頭皮過去,并且手動重複兩次以上的操作就要思考有沒有自動化的替代方案。
- 一旦決定要做,在一些關鍵節點決不能手軟。例如某個節點為了不Block别人,加班不可避免。在大量代碼改動時也不用過于緊張,有提前預估,有Case自測,還有QA的三輪回歸來保障,保持專注,放手去做就好。
原文釋出時間:06月29日
原文作者:美團技術團隊
本文來源
掘金如需轉載請緊急聯系作者