重構:改善餓了麼交易系統的設計思路

我在2017年5月加入餓了麼的交易部門,前後負責搜索、訂單、超時、賠付、條約、交付、金額計算以及評價等系統,後期開始作些總體系統升級的工做。
這篇文章成型於交易系統重構一期以後,主要是反思其過程當中作決策的思路,我沒有使用「架構」這個詞語,是由於它給人的感覺充滿權利和神祕感,談論「架構」讓人有一種正在進行責任重大的決策或者深度技術分析的感受。前端


如畢玄在系統設計的套路這篇文章裏所提:程序員

回顧了下本身作過的幾個系統的設計,發現如今本身在作系統設計的時候確實是會按照一個套路去作,這個套路就是:系統設計的目的->系統設計的目標->圍繞目標的核心設計->圍繞核心設計造成的設計原則->各子系統,模塊的詳細設計

在進行系統設計時,摸清楚目的,並造成可衡量的目標是第一步。數據庫

"Soft" ware

Software拆開來分別是soft ware,即靈活的產品 -- 鮑勃大叔

重構前的交易系統初版的代碼能夠追溯到8年前,這期間也經歷過拆解重構,17年我來到時,主要系統是這樣:編程

系統名稱 主要功能
Bos c端訂單管理:用戶詳情、列表
Nevermore b端訂單管理:商戶接單等
Booking 購物車、下單
Eos 訂單中心
Loki 訂單取消、退
Blink 訂單配送履約、商戶訂單中心

這套系統馱着業務從百萬級訂單跑到了千萬級訂單,從壓測表現來看,它能夠再支撐業務多翻幾倍的量,也就是說若是沒有啥變化,它能夠繼續穩定運行着,但若是發生點變化呢,答案可能就不這麼確定了。設計模式

在我入職的這兩年裏,系統承載的業務迭增變化:從單一的餐飲外賣到與新零售及品牌餐飲三方並行,又從到家模式衍生至到店,隨之而來的是業務持續不斷的差別化定製,還有並行上線的要求。另外一面,隨着公司組織架構變化,有的項目須要三地協同推動才能完成,溝通協做成本翻倍提高。幾方面結合起來,致使開發沒有精力對大部分系統的演進都進行完善的規劃。架構

幾個月前,業務提了一個簡單的需求:對交易的評價作自動審覈並進行相應的處罰。當時評價核心「域模型」是這樣的:框架

設計自身的優劣這裏暫不進行討論,只是舉例說明爲了知足這個訴求,會涉及多個評價子模塊要改動,開發評估下來的工做量遠遠超出了預期,業務方對此不滿意,相似的衝突在其餘系統裏也常常出現。但實際上,團隊裏沒人偷懶,和以前同樣努力工做,只是無論投入了多少我的時間,救了多少次火,加了多少次班,產出始終上不去,由於開發大部分時間都在系統的修修補補上,而不是真正完成實際的新功能,一直在拆東牆補西牆,周而往復。編程語言

爲何會致使這樣的結果,我想應該是由於大部分系統已經演變到很難響應需求的變動了,業務認爲的小小變動,對開發來講都是系統的一次大手術,但系統本不該該往這個方向發展的,它和hardware有着巨大的區別就在於:變動對軟件來講應該是簡單靈活的。ide

因此咱們思考設計的核心目標:**「採用好的軟件架構來節省項目構建和維護的人力成本,讓每一次變動都短小簡單,易於實施,而且避免缺陷,用最小的成本,最大程度地知足功能性和靈活性的要求」。函數

Source code is the design

提到軟件設計,你們腦殼裏可能會想到一幅幅結構清晰的架構圖,認爲關於軟件架構的全部奧祕都隱藏在圖裏了,但經歷過一些項目後發現,這每每是不夠的。Jack Reeves在1992年發表了一篇論文《源代碼即設計》,他在文中提出一個觀點:

高層結構的設計不是完整的軟件設計,它只是細節設計的一個結構框架。在嚴格地驗證高層設計方面,咱們的能力是很是有限的。詳細設計最終會對高層設計形成的影響至少和其餘的因素同樣多(或者應該容許這種影響)。對設計的各個方面進行改進,是一個應該貫穿整個設計週期的過程

在踩過一些坑以後,這種強調詳細設計重要性的觀點在我看來很實在接地氣,簡單來講:「自頂向下的設計一般是不靠譜的,編碼便是設計過程的一部分」,我的認爲:系統設計應該是從下到上,隨着抽象層次的提高,不斷演化而獲得良好的高層設計。

編程範式

從下向上,那就應該從編碼開始審視,餓了麼交易系統最開始是由Python編寫,Python足夠靈活,能夠很是快速的產出mvp的系統版本,這也和當時的公司發展狀態相關: 產品迭代迅速,新項目的壓力很大。

最近此次重構,順應集團趨勢,咱們使用Java來進行編寫,不過在這以前有一個小插曲:17年末,由於預估到當前系統框架在單量到達下一個量級時會遇到瓶頸,因此針對一些新業務逐漸開始使用Go語言編寫,但在這個過程裏,常常會聽到一些言論:用go來寫業務不舒服。爲何會不舒服?大體是由於沒有框架,沒有泛型,沒有try catch,確實,在解決業務問題的這個大的上下文中,go語言不是最優的選擇,但語法簡單,能夠極大程度的避免普通程序員出錯的機率。

那麼Python呢,任何事物都有雙刃劍,雖然Python具備強表達力,可是靈活性也把不少人慣壞了,代碼寫的糙,動態語言寫太多坑也多,容易出錯,在大項目上的工程管理和維護上有必定劣勢,因此rails做者提到:「靈活性被過度高估——約束纔是解放」也有必定道理

爲避免引發語言戰,這裏不過多討論,只是想引出:我從C++寫到Go,又從Python寫到Java,在這個過程裏體會到--編程範式也許是學習任何一門編程語言時要理解的最重要的術語,簡單來講它是程序員看待程序應該具備的觀點,但卻容易被忽視。交易老系統的代碼,無論是針對什麼業務邏輯,幾乎都是OPP一杆到底,相似的代碼在系統裏隨處可見。

咱們好像徹底遺忘了OOP,這項古老的技藝被淡化了,我這裏不是說必定要OOP就是完美的,準確來講我是「面向問題」範式的擁躉者,好比,Java從骨子裏就是要OOP,可是業務流程不必定須要OOP。一些交易業務就是第一步怎麼樣,第二步怎麼樣,採起OPP的範式就是好的解法。這時,弄很複雜的類設計有時並沒必要要,反而還會帶來麻煩

此外,同一個問題還能夠拆解爲不一樣的層次,不一樣的層次可使用各自適合的方式。好比高層的能夠OOP,具體到某個執行邏輯裏能夠用FP,好比:針對訂單的金額計算,咱們用Go寫了一版FP的底層計算服務,性能高、語法簡單以及出錯少等是語言附帶的優勢,核心仍是由於該類問題自身適合。

然而,當面向整個交易領域時,針對繁複多樣的業務場景,合理運用OOP的設計思想已經被證實確實能夠支撐起復雜龐大的軟件設計,因此咱們做出第一個決策:採用以OOP爲主的「混合」範式。

原則和模式

The difference between a bad programmer and a good one is whether he considers his code or his
data structures more important. Bad programmers worry about the code. Good programmers worry about data structures and their relationships. -- Linus Torvalds

無論是採用哪一種編程範式、編程語言,構造出來的基礎模塊就像蓋樓的磚頭,若是磚頭質量很差,最終大樓也不會牢固,引用裏的一大段話,relationships纔是我最想強調的:我理解它是指類之間的交互關係,「關係」的好壞一般等價於軟件設計的優劣,設計很差的軟件結構大都有些共同特徵:

  • 僵化性:難以對軟件進行改動,通常會引起連鎖改動,好比下單時增長一個新的營銷類型,訂單中心和相關上下游都要感知到並去作改動
  • 脆弱性:簡單的改動會引起其餘意想不到的問題,甚至概念徹底不相關
  • 牢固性:設計中有對其餘系統有用的部分,可是拆出來的風險和成本很高,好比訂單中心針對外賣場景的支付能力並不能支持會員卡等虛擬商品的支付需求
  • 沒必要要的複雜性:這個一般是指過分設計
  • 晦澀性:隨時間演化,模塊難以理解,代碼愈來愈難讀懂,好比購物車階段的核心代碼已經長成了一個近千行的大函數
  • ...

採起合適的範式後,咱們須要向上抽一個層次,來關注代碼之上的邏輯,多年軟件工程的發展沉澱下來了一些基本原則和模式,並被證實能夠指導咱們如何把數據和函數封裝起來,而後再把它們組織起來成爲程序。

SOLID

有人將這些原則從新排列下順序,將首字母組成SOLID,分別是:SRP、OCP、LSP、ISP、DIP。這裏針對其中幾個原則來舉些例子。

SRP(單一職責):這個原則很簡單,即任何一個軟件模塊都應該只對一類用戶負責,因此代碼和數據應該由於和某一類用戶關係緊密而被組織到一塊兒。實際上咱們大部分的工做就是在發現職責,而後拆開他們。

我認爲該原則的核心在於用戶的定義,18年去聽Qcon時,聽到俞軍的分享,其中一段正好能夠拿來詮釋什麼是用戶,俞軍說:「用戶不是人,是需求的集合」。在咱們重構的過程當中,曾經對交易系統裏的交付環節有過爭論,目前餓了麼支持商家自配和平臺託管以及選擇配送(好比跑腿),這幾類配送的算價方式,配送邏輯,和使用場景都不同,因此咱們基於此作了拆解,一開始你們都認同這種分解方式。

但後來商戶羣體調整了,新零售商戶和餐飲商戶進行分拆,對應着業務方的運營方式也開始出現差別,致使在每一個配送方式下也有了不一樣訴求,伴隨這些變化,最後咱們選擇作了第二次拆解。

對於單一職責,這裏有個小tips:你們若是實在很差分析的話,能夠多觀察那些由於分支合併而產生衝突的代碼,由於這極可能是由於針對不一樣需求,你們同時改了同一個模塊

DIP(依賴倒置):有人說依賴反轉是OOP和OPP的分水嶺,由於在過程化設計裏所建立的依賴關係,策略是依賴於細節的--也就是高層依賴於底層,但這一般會讓策略由於細節改變而受到影響,舉個例子:在外賣場景下,一旦用戶由於某些緣由收不到餐了,商戶會賠代金券安撫用戶,此時OPP能夠這樣作:

而過一陣子,由於代金券一般不能跨店使用,平臺想讓用戶繼續復購,就想經過賠付通用紅包來挽留,這個時候就須要改動老的代碼,經過增長對紅包賠付邏輯的依賴,才能夠來知足訴求。
但若是換個方式,採用DIP的話,問題也許能夠被更優雅的解決了:

固然這個示例是簡化後的版本,實際工做裏還有不少更加複雜的場景存在,但本質都是同樣:採用OOP倒置了策略對細節的依賴,使細節依賴於抽象,而且經常是客戶擁有服務接口,這個過程當中的核心是須要咱們作好抽象。

OCP(開閉原則):若是仔細分析,會發現這個原則實際上是咱們一開始定的系統設計的目標,也是其餘原則最終想達成的目的,好比:經過SRP,把每一個業務線的模塊拆解出來,將變更隔離,可是平臺還要作必定的抽象,將核心業務流程沉澱下來,並開放出去每一個業務線本身定義,這時候就又會應用到DIP。

其餘的幾個原則就不舉例子了,固然除了SOLID,還有其餘類型的原則,好比IoC賣交易平臺舉例子,商戶向用戶賣飯,一手交錢一手交貨,因此,基本上來講用戶和商戶必需強耦合(必需見面)。這個時候,餓了麼平臺出來作擔保,用戶把錢先墊到平臺,平臺讓商家接單而後出餐,用戶收到餐後,平臺再把錢打給商家。這就是反轉控制,買賣雙方把對對方的直接依賴和控制,反轉到了讓對方來依賴一個標準的交易模型的接口

能夠發現只要總結規律,總會出現這樣或那樣的原則,但每一個的原則的使用都不是一勞永逸的--須要不斷根據實際的需求變化作代碼調整,原則也不是萬金油,不能無條件使用,不然會由於過度遵循也會帶來沒必要要的複雜性,好比常常見到一些使用了工廠模式的代碼,裏面一個new其實就是違反了DIP,因此適度便可。

演進到模式

這裏的模式就是咱們常說的設計模式,用演進這個詞,是由於我以爲模式不是起點,而是設計的終點。《設計模式》這本書的內容不是做者的發明創造,而是其從大量實際的系統裏提取出來的,它們大都是早已存在並已經普遍使用的作法,只不過沒有被系統的梳理。換句話說,只要遵循前面敘述的某些原則,這些模式徹底可能會天然在系統代碼中體現出來,在《敏捷軟件開發》這本書裏,就特地有一個章節,描述了一段代碼隨着調整慢慢演進到了觀察者模式的過程。

擁有模式當然是好的,好比搜索系統裏,經過Template Method模式,定義一套完整的搜索參數解析模版,只須要增長配置就能夠定製不一樣的查詢訴求。這裏最想強調的是不要設計模式驅動編程,拿交易系統裏的狀態機來舉例子(狀態機簡直太常見了,簡單如家裏使用的檯燈,都有一個開和關的狀態,只是交易場景下會更加複雜),在餐飲外賣交易有以下的狀態流轉模型:

實現這樣的一個有限狀態機,最直接的方式是使用嵌套switch/case語句,簡略的代碼好比:

public class Order {
    // States
    public static final int ACCEPT = 5;
    public static final int SETTLED = 9;
    ..
    // Events
    public static final int ARRIVED = 1; // 訂單送達

    public void event(int event) {
        switch (state) {
            case ACCEPT:
                switch (event) {
                    case ARRIVED:
                        state = SETTLED;
                        //to do action
                        break
                    case 

                 }
        }  
    }
}

由於是簡寫了流程,因此上面的代碼看起來仍是挺能接受的,可是對於訂單狀態這麼複雜的狀態機,這個switch/case語句會無限膨脹,可讀性不好,另外一個問題是狀態的邏輯和動做沒有拆開,《設計模式》提供了一個State 模式,具體作法是這樣:

這個模式確實分離了狀態機的動做和邏輯,可是隨着狀態的增長,不斷增長State的類會讓系統變得異常複雜,並且對OCP的支持也很差:對切換狀態這個場景,新增類會引發狀態切換類的修改,最不能忍受的是這個方式會把整個狀態機的邏輯隱藏在零散的代碼裏。

舊版的交易系統就使用的是解釋遷移表來實現的,簡化版本是這樣的:

# 完結訂單
add_transition(trigger=ARRIVED,
               src=ACCEPT,
               dest=SETTLED,
               on_start=_set_order_settled_at,
               set_state=_set_state_with_record, // 變動狀態
               on_end=_push_to_transcore)
...

# 引擎
def event_fire(event, current_state):
    for transition in transitions:
        if transition.on_start == current_state && transition.trigger == event:
            transition.on_start()
            current_state = transition.dest
            transition.on_end()

這個版本很是容易理解,狀態邏輯集中在一塊兒,也沒有和動做耦合起來,擴展性也比較強,惟一缺點的話是遍歷的時間,但也能夠經過字典表來優化,但它整體帶來的好處更加明顯。

不過隨着業務發展,交易系統須要同時支持多套狀態機,意味着會出現多個遷移表,並且還有根據業務作擴展定製的需求,這套解決方案會致使代碼編寫變得複雜起來,咱們在重構時採用了二級編排+流程引擎的方式來優化了這個問題,只是不在咱們討論的範圍內,這裏只想強調第二個決策:代碼上要靈活經過設計原則分析問題,再經過合適的設計模式解決問題,不能設計模式驅動編程,好比有時候一個全局變量就能夠替代所謂的單例模式。

豐富的領域含義

一旦你想解說美,而不提擁有這種特質的東西,那麼就徹底沒法解釋清楚了

用個不那麼貼切的說法,若是前面說的是針對靜態問題的策略,如今咱們須要討論面對動態問題的解決辦法:即便沒有風,人們也不會以爲一片樹葉是穩定的,因此人們定義穩定的時候和變動的頻繁度無關,而是和變動須要的成本有關,由於吹一口氣,樹葉就會隨之搖擺了。咱們除了要寫好當前代碼,讓其足夠清晰合理,還要能寫好應對需求變化的「樹葉」代碼。

面向業務變化的設計首先就是要理解業務的核心問題,進而進行拆解劃分爲各個子領域,DDD--也就是領域驅動設計,已經被證實是一個很好的切入點。這裏不是把它看成技術來學習,而是做爲指導開發的方法論,成爲第三個決策,而且我我的仍處在初級階段,因此只說一些理解深入的點。

通用語言

設計良好的架構在行爲上對系統還有一個最重要的做用:就是明確的顯式的反映系統設計的意圖,簡單來講,在你拉下某些服務的代碼的時候,大概掃一眼就能夠以爲:嗯,這個「看起來」就像一個交易系統的應用。咱們不能嘴上在談論業務邏輯,手上卻敲出另外一份模樣的代碼,簡單來講,不能見人說人話,見鬼說鬼話。能夠對比一下這兩類分包的方式,哪個更容易理解:

發現領域通用語言的目的之一是能夠經過抓住領域內涵來應該需求變動,這個須要不少客觀條件,好比團隊裏有一個領域專家。但沒有的時候,咱們也能夠向內求解,**我有次看到一位在丁香園工做的程序員朋友,購買了一大批醫學的書籍,不用去問,我就猜他必定是成了DDD的教徒。

針對這個點,咱們此次重構時還作了些讓「源代碼即設計」的工做:領域元素可視化,當系統領域內的一些概念已經和產品達成一致以後,便增長約定好的註解,代碼編譯時即可以掃描並收集起來發送給前端,用於畫圖。

回到前面提到的評價域模型,後來在和產品屢次溝通後意識到,產品沒有但願評價這麼多種類,對它來講商品也好、騎手也好,都屬於被評價的對象,從領域模型來看,以前的設計更可能是面對場景,而不是面對行爲,因此合理的域模型應該是:

限界上下文

這個在咱們平時開發過程當中會很常見。拿用戶系統舉例:一個User的Object,若是是從用戶自身的視角來看,就能夠登錄、登出,修改暱稱;若是是從其餘普通用戶來看,就只能看看暱稱之類的;若是從後臺管理員來看,就能夠註銷或者踢出登錄。這時就須要界定一個Scope,來講明如今的User究竟是哪一個Scope,這其實就是DDD中限界上下文的理念。

限界上下文能夠很好的隔離相同事物的不一樣內涵,經過嚴格規範能夠進入上下文的對象模型,從而保護業務抽象行爲的一致性,回到交易領域,餓了麼是最開始支持超級會員玩法的,爲了支持對應的結算訴求,須要接入交易系統來完成這個業務,咱們經過分解問題域來下降複雜度,這個時候就對應切割爲會員域和交易域,爲了保護超會卡在進入交易領域的時候,不擾亂交易內部的業務邏輯,咱們作了一次映射:

切分

當全部代碼完成以後,隨着程序增加,會有愈來愈多的人蔘與進來,爲了方便協做,就必須把這些代碼劃分紅一些方便我的或者團隊維護的組。根據軟件變動速度不一樣,能夠把上文提到的代碼化爲幾個組件:

  • Extension:擴展包,這裏存放着前面提到的業務定製包,面向對象的思想,最核心的貢獻在於經過多態,容許插件化的切換一段程序的邏輯,其實軟件開發技術發展的歷史就是一個想法設法方便的增長插件,從而建立一個可擴展,可維護的系統架構的過程。
  • Domain: 領域包,存放着具有領域通用語言的核心業務包,它最爲穩定。
  • Business:業務包,存放着具體的業務邏輯,它和Domain包的區別在於,可能Domain包會提供一個people.run()的方法,他會用這個方法去跑着送外賣,或者去健身。
  • Infra: 基礎設置包,存放這對數據庫及各類中間件的依賴,他們都屬於業務邏輯以外的細節。

而後是分層依賴,Martin Flower已經提供了一套經典的分層封裝的模式,拿簡化的訂單模塊舉例:

然而若是有的同窗避免作各類類型的轉換,不想嚴格遵照分層依賴,以爲一些查詢(這裏指Query,Query != Read)能夠直接繞過領域層,這樣就變成了CQRS模式:

可是最理想的仍是下面這種方式,領域層做爲核心業務邏輯,不該該依賴基礎設施的細節,經過這種方式,代碼的可測性也會提高上去

單體程序的組件拆分完畢後,再向上一層,咱們開始關注四個核心服務:Booking被分拆爲Cart、Buy、Calculate,Eos被分拆爲Procee、Query、Timeout,Blink一部分和商戶訂單相關的功能被分拆到Process、Query,和物流交付的部分單獨成一塊Delivery,最後交易的核心服務拆解成下圖:

系統名稱 主要功能
Bos c端訂單管理:用戶詳情、列表
Nevermore b端訂單管理:商戶接單等
Cart 購物車
Buy 下單
Calculate 金額計算
Process 訂單處理,管理訂單生命週期
Query 訂單查詢
Timeout 超時中心
Loki 訂單取消、退
Delivery 交付中心

到目前,算上這個切分的方式,加起來一共就四個決策,其實也不必分序列,它們核心都是圍繞着軟件靈活性這個目標,從程序範式到組件編寫,最後再到分層,咱們主動選擇或避開的一些教條限制,因此業務架構從某種意義上來說,也是在某種領域中限制程序員的一些行爲,讓他往咱們所但願的規範方向編碼。從而達到整個系統的靈活可靠。

"No Silver Bullet"

「個體和交互賽過過程和工具」,敏捷宣言第一條

目前系統架構是什麼樣子並不重要,由於它可能會隨着時間還會拆解成其餘模樣,重要的是,咱們要認識到對於如何建造一個靈活的交易系統——沒有銀彈。

若是仔細觀察的話,會發現當前系統裏仍有不少問題等着被解決。好比一些橫跨型變動:系統鏈路裏會由於某個服務的接口增長了字段,而致使上下游跟着一塊兒改。更爲尷尬的是,原本咱們拆分服務就是爲了解耦合,但有時還會出現服務發佈依賴的現象。系統演進是一場持久的戰爭,「個體和交互賽過過程和工具」,人才是勝利的核心因素。

過去的兩年裏,咱們沒有中止過思考和實踐,常常能夠看到交易團隊內部成員的爭執,小到一個接口字段變動,大到領域之間的邊界,你們爲拿到一個合理的技術方案作了不少討論,這讓我想起《禪與摩托車維修藝術》裏所提到的良質,有人點評說:關於良質,程序員可能有這樣的經歷——寫出了一段絕妙的代碼,你會以爲「不是你寫出了代碼,這段代碼一直存在,而你發現了它」。

本文做者:盛赫,花名白茶,就任於阿里本地生活中臺研發部,多年交易系統建設開發經驗,目前轉入營銷領域繼續探索。

參考書籍

《軟件設計的哲學》--John Ousterhout
《禪與摩托維修藝術》--Robert M.Pirsig
《領域驅動設計》--Eric Evans
《敏捷軟件開發》--Uncle Bob
《架構整潔之道》--Uncle Bob
《極客與團隊》--Brian W.FItzapatrick


本文做者:中間件小哥

原文連接

本文爲雲棲社區原創內容,未經容許不得轉載。

相關文章
相關標籤/搜索