skr shop是一群底層碼農(nóng),由于被工作中的項(xiàng)目折磨的精神失常,加之由于程序員的自傲:別人設(shè)計(jì)的系統(tǒng)都是一坨shit,我的設(shè)計(jì)才是宇宙最牛逼,于是乎決定要做一個(gè)只設(shè)計(jì)不編碼的電商設(shè)計(jì)手冊。
在上一篇文章 購物車設(shè)計(jì)之需求分析 描述了購物車的通用需求。本文重點(diǎn)則在如何實(shí)現(xiàn)上進(jìn)行架構(gòu)上的設(shè)計(jì)(業(yè)務(wù) 系統(tǒng)架構(gòu))。
說明
架構(gòu)設(shè)計(jì)可以分為三個(gè)層面:
-
業(yè)務(wù)架構(gòu)
-
系統(tǒng)架構(gòu)
-
技術(shù)架構(gòu)
快速簡單的說明下三個(gè)架構(gòu)的意思;當(dāng)我們拿到購物車需求時(shí),我們說用Golang來實(shí)現(xiàn),存儲用Redis;這描述的是技術(shù)架構(gòu);我們對購物車代碼項(xiàng)目進(jìn)行代碼分層,設(shè)計(jì)規(guī)范,以及依賴系統(tǒng)的規(guī)劃這叫系統(tǒng)架構(gòu);
那業(yè)務(wù)架構(gòu)是什么呢?業(yè)務(wù)架構(gòu)本質(zhì)上是對系統(tǒng)架構(gòu)的文字語言描述;什么意思?我們拿到一個(gè)需求首先要跟需求方進(jìn)行溝通,建立統(tǒng)一的認(rèn)知。比如:規(guī)范名詞(購物車中說的商品與商品系統(tǒng)中商品的含義是不同的);建立大家都能明白的模型,購物車、用戶、商品、訂單這些實(shí)體之間的互動(dòng),以及各自具備什么功能。
在業(yè)務(wù)架構(gòu)分析上有很多方法論,比如:領(lǐng)域驅(qū)動(dòng)設(shè)計(jì),但是它并不是唯一的業(yè)務(wù)架構(gòu)分析方法,也并不是說最好的。適合你的就是最好的。我們常用的實(shí)體關(guān)系圖、UML圖也屬于業(yè)務(wù)架構(gòu)領(lǐng)域;
這里需要強(qiáng)點(diǎn)一點(diǎn)的是,不管你用什么方式來建模設(shè)計(jì),有設(shè)計(jì)總比沒設(shè)計(jì)強(qiáng),其次一定要將建模的內(nèi)容體現(xiàn)到你的代碼中去。
本文在業(yè)務(wù)架構(gòu)上的分析借助了DDD(領(lǐng)域驅(qū)動(dòng)設(shè)計(jì))思想;還是那句話適合的就是最好的。
業(yè)務(wù)架構(gòu)
通過前面的需求分析,我們已經(jīng)明確我們的購物車要干什么了。先來看一下一個(gè)典型的用戶操作購物車過程。
用戶旅程在這個(gè)過程中,用戶使用購物車這個(gè)載體完成了商品的購買流程;不斷流動(dòng)的數(shù)據(jù)是商品,購物車這個(gè)載體是穩(wěn)定的。這是我們系統(tǒng)中的穩(wěn)定點(diǎn)與變化點(diǎn)。
商品的流動(dòng)方式可能多種多樣,比如從不同地方加入購物車,不同方式加入購物車,生命周期在購物車中也不一樣;但是這個(gè)流程是穩(wěn)定的,一定是先讓購物車中存在商品,然后才能去結(jié)算產(chǎn)生訂單。
商品在購物車中的生命周期如下:
過程按照這個(gè)過程,我們來看一下每個(gè)階段對應(yīng)的操作。
過程對應(yīng)的操作這里注意一點(diǎn),加車前這個(gè)操作其實(shí)我們可以放到購物車的添加操作中,但是由于這部分是非常不穩(wěn)定且多變的。我們將其獨(dú)立出來,方便后續(xù)進(jìn)行擴(kuò)展而不影響相對比較穩(wěn)定的購物車階段。
上面這三個(gè)階段,按照DDD中的概念,應(yīng)該叫做實(shí)體,他們整體構(gòu)成了購物車這個(gè)域;今天我們先不講這些概念,就先略過,后面有機(jī)會(huì)單獨(dú)發(fā)文講解。
加車前
通過流程分析,我們總結(jié)出了系統(tǒng)需要具備的操作接口,以及這些接口對應(yīng)的實(shí)體,現(xiàn)在我們先來看加車前主要要做些什么;
加車前其實(shí)主要就是對準(zhǔn)備加入的購物車商品進(jìn)行各個(gè)緯度的校驗(yàn),檢查是否滿足要求。
在讓用戶加車前,我們首先解決的是用戶從哪里賣,然后進(jìn)行驗(yàn)證?因?yàn)橥粋€(gè)商品從不同渠道購買是存在不同情況的,比如:小米手機(jī),我們是通過秒殺買,還是通過好友眾籌買,或者商城直接購買,價(jià)格存在差異,但是實(shí)際上他是同一個(gè)商品;
第二個(gè)問題是是否具備購買資格,還是上面說的,秒殺、眾籌這個(gè)加車操作,不是誰都可以添加的,得現(xiàn)有資格。那么資格的檢查也是放到這里;
第三個(gè)問題是對這個(gè)購買的商品進(jìn)行商品屬性上的驗(yàn)證,如是否上下架,有庫存,限購數(shù)量等等。
而且大家會(huì)發(fā)現(xiàn),這里的驗(yàn)證條件可能是非常多變的。如何構(gòu)建一個(gè)方便擴(kuò)展的代碼呢?
加車的驗(yàn)證整個(gè)加車過程,重要的就是根據(jù)來源來區(qū)分不同的驗(yàn)證。我們有兩種選擇方式。
方式一:通過策略模式 門面模式的方式來搞定。策略就是根據(jù)不同的加車來源進(jìn)行不同的驗(yàn)證,門面就是根據(jù)不同的來源封裝一個(gè)個(gè)策略;
方式二:通過責(zé)任鏈模式,但是這里需要有一個(gè)變化,這個(gè)鏈在執(zhí)行過程中,可以選擇跳過某些節(jié)點(diǎn),比如:秒殺不需要庫存、也不需要眾籌的驗(yàn)證;
通過綜合的分析我選擇了責(zé)任鏈的模式。貼一下核心代碼
// 每個(gè)驗(yàn)證邏輯要實(shí)現(xiàn)的接口type Handler interface { Skipped(in interface{}) bool // 這里判斷是否跳過 HandleRequest(in interface{}) error // 這里進(jìn)行各種驗(yàn)證}
// 責(zé)任鏈的節(jié)點(diǎn)type RequestChain struct { Handler Next *RequestChain}
// 設(shè)置handlerfunc (h *RequestChain) SetNextHandler(in *RequestChain) *RequestChain { h.Next = in return in} 關(guān)于設(shè)計(jì)模式,大家可以看我小伙伴的github:https://github.com/TIGERB/easy-tips/tree/master/go/src/patterns
購物車
說完了加車前,現(xiàn)在來看購物車這一部分。我們在之前曾討論過,購物車可能會(huì)有多種形態(tài)的,比如:存儲多個(gè)商品一起結(jié)算,某個(gè)商品立即結(jié)算等。因此購物車一定會(huì)根據(jù)渠道來進(jìn)行購物車類型的選擇。
這部分的操作相對是比較穩(wěn)定的。我們挑幾個(gè)比較重要的操作來講一下思路即可。
加入購物車
通過把條件驗(yàn)證的前置,會(huì)發(fā)現(xiàn)在進(jìn)行加車操作時(shí),這部分邏輯已經(jīng)變得非常的輕量了。要做的主要是下面幾個(gè)部分的邏輯。
加入購物車這里有幾個(gè)取巧的地方,首先是獲取商品的邏輯,由于在前面驗(yàn)證的時(shí)候也會(huì)用到,因此這里前面獲取后會(huì)通過參數(shù)的方式繼續(xù)往后傳遞,因此這里不需要在讀庫或者調(diào)用服務(wù)來獲??;
其次這里需要把當(dāng)前用戶現(xiàn)有購物車數(shù)據(jù)獲取到,然后將添加的這個(gè)商品添加進(jìn)來。這是一個(gè)類似合并操作,原來這個(gè)商品是存在,相當(dāng)于數(shù)量加一;需要注意這個(gè)商品跟現(xiàn)存的商品有沒有父子關(guān)系,有沒有可能加入后改變了某個(gè)活動(dòng)規(guī)則,比如:原來買了2個(gè)送1個(gè)贈(zèng)品,現(xiàn)在再添加了一個(gè)變成3個(gè),送2個(gè)贈(zèng)品;
注意:這里的添加并不是在購物車直接改數(shù)量,可能就是在列表、詳情頁直接添加添加。
通過將合并后的購物車數(shù)據(jù),通過營銷活動(dòng)檢查確認(rèn)ok后,直接回寫到存儲中。
合并購物車
為什么會(huì)有合并購物車這個(gè)操作?因?yàn)橐话汶娚潭际菧?zhǔn)許游客身份進(jìn)行操作的,因此當(dāng)用戶登錄后需要將二者進(jìn)行合并。
這里的合并很多部分的邏輯是可以與加入購物車復(fù)用的邏輯。比如:合并后的數(shù)據(jù)都需要檢查是否合法,然后覆寫回存儲中。因此大家可以看到這里的關(guān)聯(lián)性。設(shè)計(jì)的方法在某種程度上要通用。
購物車列表
購物車列表這是一個(gè)非常重要的接口,原則上購物車接口會(huì)提供兩種類型,一種簡版,一種完全版本;
簡版的列表接口主要是用在類似PC首頁右上角之類獲取簡單信息;完全版本就是在購物車列表中會(huì)用到。
在實(shí)際實(shí)現(xiàn)中,購物車絕不僅僅是一個(gè)讀取接口那么簡單。因?yàn)槲覀兌贾啦还苁巧唐沸畔?、活?dòng)信息都是在不斷的發(fā)生變化。因此每次的讀取接口必然需要檢查當(dāng)前購物車中數(shù)據(jù)的合法性,然后發(fā)現(xiàn)不一致后需要覆寫原存儲的數(shù)據(jù)。
購物車列表也有一些做法會(huì)在每個(gè)接口都去檢查數(shù)據(jù)的合法性,我建議為了性能考慮,部分接口可以適當(dāng)放寬檢查,在獲取列表時(shí)再進(jìn)行完整的檢查。比如添加接口,我只會(huì)檢測我添加的商品的合法性,絕不會(huì)對整個(gè)購物車進(jìn)行檢查。因?yàn)樵摬僮髦笠话愣紩?huì)調(diào)用列表操作,那么此時(shí)還會(huì)進(jìn)行校驗(yàn),二者重復(fù)操作,因此只取后者。
結(jié)算
結(jié)算包括兩部分,結(jié)算頁的詳情信息與提交訂單。結(jié)算頁可以說是在購物車列表上的一個(gè)包裝,因?yàn)榻Y(jié)算頁與列表頁最大的不同是需要用戶選擇配送地址(虛擬商品另說),此時(shí)會(huì)產(chǎn)生更明確的價(jià)格信息,其他基本一致。因此在設(shè)計(jì)購物車列表接口的時(shí)候,一定要考慮充分的通用性。
這里另外一個(gè)需要注意的是:立即購買,我們也會(huì)通過結(jié)算頁接口來實(shí)現(xiàn),但是內(nèi)部其實(shí)還是會(huì)調(diào)用添加接口,將商品添加到購物車中;有三個(gè)需要注意的地方,首先是這個(gè)添加操作是服務(wù)內(nèi)部完成的,對于服務(wù)調(diào)用方是不需要感知這個(gè)加入操作的存在;其次是這個(gè)購物車在Redis中的Key是獨(dú)立于普通購物車的,否則二者的商品耦合在一起非常難于操作處理;最后立即購買的購物車要考慮賬號多終端登錄的時(shí)候,彼此數(shù)據(jù)不能互相影響,這里可以用每個(gè)端的uuid來作為購物車的標(biāo)記避免這種情況。
購物車的最后一步是生成訂單,這一步最要緊的是需要給購物車加鎖,避免提交過程中數(shù)據(jù)被篡改,多說一句,很多人寫的Redis分布式鎖代碼都存在缺陷,大家一定要注意原子性的問題,這類文章網(wǎng)絡(luò)上很多不再贅述。
加鎖成功之后,我們這里有多種做法,一種是按照DB涉及組織數(shù)據(jù)開始寫表,這適用于業(yè)務(wù)量要求不大,比如訂單每秒下單量不超過2000K的;那如果你的系統(tǒng)并發(fā)要求非常高怎么辦?
其實(shí)也很簡單,高性能的三大法寶之一:異步;我們提交的時(shí)候直接將數(shù)據(jù)快照寫入MQ中,然后通過異步的方式進(jìn)行消費(fèi)處理,可以通過通過控制消費(fèi)者的數(shù)量來提升處理能力。這種方法雖然性能提升,但是復(fù)雜度也會(huì)上升,大家需要根據(jù)自己的實(shí)際情況來選擇。
關(guān)于業(yè)務(wù)架構(gòu)的設(shè)計(jì),到此告一段落,接下來我們來看系統(tǒng)架構(gòu)。
系統(tǒng)架構(gòu)
系統(tǒng)結(jié)構(gòu)主要包含,如何將業(yè)務(wù)架構(gòu)映射過來,以及輸出對應(yīng)輸入?yún)?shù)、輸出參數(shù)的說明。由于輸入、輸出針對各自業(yè)務(wù)來確定的,而且沒有什么難度,我們這里就只說如何將業(yè)務(wù)架構(gòu)映射到系統(tǒng)架構(gòu),以及系統(tǒng)架構(gòu)中最核心的Redis數(shù)據(jù)結(jié)構(gòu)選擇以及存儲的數(shù)據(jù)結(jié)構(gòu)設(shè)計(jì)。
代碼結(jié)構(gòu)
下面的代碼目錄是按照Golang來進(jìn)行設(shè)計(jì)的。我們來看看如何將上面的業(yè)務(wù)架構(gòu)映射到代碼層面來。
├── addproducts.go├── cartlist.go├── mergecart.go├── entity│ ├── cart│ │ ├── add.go│ │ ├── cart.go│ │ └── list.go│ ├── order│ │ ├── checkout.go│ │ ├── order.go│ │ └── submit.go│ └── precart├── event│ └── sendorder.go├── facade│ ├── activity.go│ └── product.go└── repo 外層有entity、event、facade、repo這四個(gè)目錄,職責(zé)如下:
entity: 存放的是我們前面分析的購物領(lǐng)域的三個(gè)實(shí)體;所有主要的操作都在這三個(gè)實(shí)體上;
event: 這是用來處理產(chǎn)生的事件,比如剛剛說的如果我們提交訂單采用異步的方式,那么該目錄就該完成的是如何把數(shù)據(jù)發(fā)送到MQ中去;
facade: 這兒目錄是干嘛的呢?這主要是因?yàn)槲覀兊姆?wù)還需要依賴像商品、營銷活動(dòng)這些服務(wù),那么我們不應(yīng)該在實(shí)體中直接調(diào)用它,因?yàn)榈谌娇赡艽嬖谧儎?dòng),或者有增加、減少,我們在這里進(jìn)行以下簡單的封裝(設(shè)計(jì)模式中的門面模式);
repo: 這個(gè)目錄從某種程度上可以理解為Model層,在整個(gè)領(lǐng)域服務(wù)中,如果與持久化打交道,都通過它來完成。
最后外層的幾個(gè)文件,就是我們所提供的領(lǐng)域服務(wù),供應(yīng)用層來進(jìn)行調(diào)用的。
為了保證內(nèi)容的緊湊,我這里放棄了對整個(gè)微服務(wù)的目錄介紹,只單獨(dú)介紹了領(lǐng)域服務(wù),后續(xù)會(huì)單獨(dú)成文介紹下微服務(wù)的整個(gè)系統(tǒng)架構(gòu)。
通過上面的劃分,我們完成了兩件事情:
-
業(yè)務(wù)架構(gòu)分析的結(jié)構(gòu)在系統(tǒng)代碼中都有映射,他們彼此體現(xiàn)。這樣最大的好處是,保證設(shè)計(jì)與代碼的一致性,看了文檔你就知道對應(yīng)的代碼在哪里;
-
每個(gè)目錄各自的關(guān)注點(diǎn)都進(jìn)行了分離,更內(nèi)聚,更容易開發(fā)與維護(hù)。
Redis存儲
現(xiàn)在來看,我們選擇Redis作為購物商品數(shù)據(jù)的存儲,我們要解決兩個(gè)問題,一是我們需要存哪些數(shù)據(jù)?二是我們用什么結(jié)構(gòu)來存?
網(wǎng)絡(luò)上很多寫購物車的都是只保存一個(gè)商品id,真實(shí)場景是很難滿足需求的。你想想,一個(gè)商品id如何記住用戶選擇的贈(zèng)品?用戶上次選擇的活動(dòng)?以及購買的商品渠道?
綜合比較通用的場景,我給出一個(gè)參考結(jié)構(gòu):
// 購物車數(shù)據(jù)type ShoppingData struct { Item []*Item `json:"item"` UpdateTime int64 `json:"update_time"` Version int32 `json:"version"`}
// 單個(gè)商品item元素type Item struct { ItemId string `json:"item_id"` ParentItemId string `json:"parent_item_id,omitempty"` // 綁定的父item id OrderId string `json:"order_id,omitempty"` // 綁定的訂單號 Sku int64 `json:"sku"` Spu int64 `json:"spu"` Channel string `json:"channel"` Num int32 `json:"num"` Status int32 `json:"status"` TTL int32 `json:"ttl"` // 有效時(shí)間 SalePrice float64 `json:"sale_price"` // 記錄加車時(shí)候的銷售價(jià)格 SpecialPrice float64 `json:"special_price,omitempty"` // 指定價(jià)格加購物車 PostFree bool `json:"post_free,omitempty"` // 是否免郵 Activities []*ItemActivity `json:"activities,omitempty"` // 參加的活動(dòng)記錄 AddTime int64 `json:"add_time"` UpdateTime int64 `json:"update_time"`}
// 活動(dòng)type ItemActivity struct { ActID string `json:"act_id"` ActType string `json:"act_type"` ActTitle string `json:"act_title"`} 重點(diǎn)說一下Item這個(gè)結(jié)構(gòu),item_id這個(gè)字段是標(biāo)記購物車中某個(gè)商品的唯一標(biāo)記,因?yàn)槲覀冎罢f過,同一個(gè)sku由于渠道不同,那么在購物車中會(huì)是兩個(gè)不同的item;接下來的parent_item_id字段是用來標(biāo)記父子關(guān)系的,這里將可能存在的樹結(jié)構(gòu)轉(zhuǎn)成了順序結(jié)構(gòu),我們不管是父商品還是子商品,都采用順序存儲,然后通過這個(gè)字段來進(jìn)行關(guān)聯(lián);有些同學(xué)可能會(huì)奇怪,為什么會(huì)存order id這個(gè)字段呢?大家關(guān)注下自己的日常業(yè)務(wù),比如:再來一單、定金預(yù)售等,這種一定是與某個(gè)訂單相關(guān)聯(lián)的,不管是為了資格驗(yàn)證還是數(shù)據(jù)統(tǒng)計(jì)。剩下的字段都是一些非常常規(guī)的字段,就不在一一介紹了;
字段的類型,大家根據(jù)自己的需要進(jìn)行修改。
接下來該說怎么選擇Redis的存儲結(jié)構(gòu)了,Redis常用的Hash Table、集合、有序集合、鏈表、字符串五種,我們一個(gè)個(gè)來分析。
首先購車一定有一個(gè)key來標(biāo)記這個(gè)購物車屬于哪個(gè)用戶的,為了簡化,我們的key假設(shè)是:uid:cart_type。
我們先來看如果用Hash Table;我們添加時(shí),需要用到如下命令:HSET uid:cart_type sku ShoppingData;看起來沒問題,我們可以根據(jù)sku快速定位某個(gè)商品然后進(jìn)行相關(guān)的修改等,但是注意,ShoppingData是一個(gè)json串,如果用戶購物車中有非常多的商品,我們用HGETALL uid:cart_type獲取到的時(shí)間復(fù)雜度是O(n),然后代碼中還需要一一反序列化,又是O(n)的復(fù)雜度。
如果用集合,也會(huì)遇到類似的問題,每個(gè)購物車看做一個(gè)集合,集合中的每個(gè)元素是 ShoppingData ,取到代碼中依然需要逐一反序列化(反序列化是成本),關(guān)于有序集合與鏈表就不在分析,大家可以按照上面的思路去嘗試下問題所在。
看起來我們沒得選,只有使用String,那我們來看一下String的契合度是什么樣子。首先SET uid:cart_type ShoppingDataArr;我們把購物車所有的數(shù)據(jù)序列化成一個(gè)字符串存儲,每次取出來的時(shí)間復(fù)雜度是O(1),序列化、反序列化都只需要一次??磥硎欠浅2诲e(cuò)的選擇。但是在使用中大家還是有幾點(diǎn)需要注意。
-
單個(gè)Value不能太大,要不然就會(huì)出現(xiàn)大key問題,所以一般購物車有上限限制,比如item不能超過多少個(gè);
-
對redis的操作性能提升上來了,但是代碼的就是修改單個(gè)item時(shí)的不便,必須每次讀取全部然后找到對應(yīng)的item進(jìn)行修改;這里我們可以把從redis中的數(shù)據(jù)讀取出來后,在內(nèi)存中構(gòu)建一個(gè)HashTable,來減少每次遍歷的復(fù)雜度;
網(wǎng)上也看到很多Redis數(shù)據(jù)結(jié)構(gòu)組合使用來保存購物車數(shù)據(jù)的,但是無疑增加了網(wǎng)絡(luò)開銷,相比起來還是String最經(jīng)濟(jì)劃算。
總結(jié)
至此對于購物車的實(shí)現(xiàn)設(shè)計(jì)算是完結(jié)了,其中關(guān)于訂單表的設(shè)計(jì)會(huì)單獨(dú)放到訂單模塊去講。
對于整個(gè)購物車服務(wù),雖然沒有寫的詳細(xì)到某個(gè)具體的接口,但是分析到這一步,我相信大家心中都是有溝壑的,能夠結(jié)合自己的業(yè)務(wù)去實(shí)現(xiàn)它。
文中有些很有意思的地方,建議大家動(dòng)手去做做看,有任何問題,我們隨時(shí)交流。
-
改編版的責(zé)任鏈模式
-
Redis的分布式事務(wù)鎖實(shí)現(xiàn)
接下來終于要到訂單部分的設(shè)計(jì)了,希望大家繼續(xù)關(guān)注我們。