[Skr-Shop]購物車之架構設計

大愚Talk發表於2020-04-06

來還債了,希望大家在疫情中都是平安的,回來的時候公司也還在!


skr shop是一群底層碼農,由於被工作中的專案折磨的精神失常,加之由於程式設計師的自傲:別人設計的系統都是一坨shit,我的設計才是宇宙最牛逼,於是乎決定要做一個只設計不編碼的電商設計手冊。

專案地址:https://github.com/skr-shop/manuals

在上一篇文章 購物車設計之需求分析 描述了購物車的通用需求。本文重點則在如何實現上進行架構上的設計(業務+系統架構)。

說明

架構設計可以分為三個層面:

  • 業務架構
  • 系統架構
  • 技術架構

快速簡單的說明下三個架構的意思;當我們拿到購物車需求時,我們說用Golang來實現,儲存用Redis;這描述的是技術架構;我們對購物車程式碼專案進行程式碼分層,設計規範,以及依賴系統的規劃這叫系統架構;

那業務架構是什麼呢?業務架構本質上是對系統架構的文字語言描述;什麼意思?我們拿到一個需求首先要跟需求方進行溝通,建立統一的認知。比如:規範名詞(購物車中說的商品與商品系統中商品的含義是不同的);建立大家都能明白的模型,購物車、使用者、商品、訂單這些實體之間的互動,以及各自具備什麼功能。

在業務架構分析上有很多方法論,比如:領域驅動設計,但是它並不是唯一的業務架構分析方法,也並不是說最好的。適合你的就是最好的。我們常用的實體關係圖、UML圖也屬於業務架構領域;

這裡需要強點一點的是,不管你用什麼方式來建模設計,有設計總比沒設計強,其次一定要將建模的內容體現到你的程式碼中去。

本文在業務架構上的分析藉助了 DDD (領域驅動設計)思想;還是那句話適合的就是最好的

業務架構

通過前面的需求分析,我們已經明確我們的購物車要幹什麼了。先來看一下一個典型的使用者操作購物車過程。

使用者旅程
使用者旅程

在這個過程中,使用者使用購物車這個載體完成了商品的購買流程;不斷流動的資料是商品,購物車這個載體是穩定的。這是我們系統中的穩定點與變化點。

商品的流動方式可能多種多樣,比如從不同地方加入購物車,不同方式加入購物車,生命週期在購物車中也不一樣;但是這個流程是穩定的,一定是先讓購物車中存在商品,然後才能去結算產生訂單。

商品在購物車中的生命週期如下:

過程
過程

按照這個過程,我們來看一下每個階段對應的操作。

過程對應的操作
過程對應的操作

這裡注意一點,加車前這個操作其實我們可以放到購物車的新增操作中,但是由於這部分是非常不穩定且多變的。我們將其獨立出來,方便後續進行擴充套件而不影響相對比較穩定的購物車階段。

上面這三個階段,按照DDD中的概念,應該叫做實體,他們整體構成了購物車這個域;今天我們先不講這些概念,就先略過,後面有機會單獨發文講解。

加車前

通過流程分析,我們總結出了系統需要具備的操作介面,以及這些介面對應的實體,現在我們先來看加車前主要要做些什麼;

加車前其實主要就是對準備加入的購物車商品進行各個緯度的校驗,檢查是否滿足要求。

在讓使用者加車前,我們首先解決的是使用者從哪裡賣,然後進行驗證?因為同一個商品從不同渠道購買是存在不同情況的,比如:小米手機,我們是通過秒殺買,還是通過好友眾籌買,或者商城直接購買,價格存在差異,但是實際上他是同一個商品;

第二個問題是是否具備購買資格,還是上面說的,秒殺、眾籌這個加車操作,不是誰都可以新增的,得現有資格。那麼資格的檢查也是放到這裡;

第三個問題是對這個購買的商品進行商品屬性上的驗證,如是否上下架,有庫存,限購數量等等。

而且大家會發現,這裡的驗證條件可能是非常多變的。如何構建一個方便擴充套件的程式碼呢?

加車的驗證
加車的驗證

整個加車過程,重要的就是根據來源來區分不同的驗證。我們有兩種選擇方式。

方式一:通過策略模式+門面模式的方式來搞定。策略就是根據不同的加車來源進行不同的驗證,門面就是根據不同的來源封裝一個個策略;

方式二:通過責任鏈模式,但是這裡需要有一個變化,這個鏈在執行過程中,可以選擇跳過某些節點,比如:秒殺不需要庫存、也不需要眾籌的驗證;

通過綜合的分析我選擇了責任鏈的模式。貼一下核心程式碼

// 每個驗證邏輯要實現的介面
type Handler interface {
	Skipped(in interface{}) bool // 這裡判斷是否跳過
	HandleRequest(in interface{}) error // 這裡進行各種驗證
}

// 責任鏈的節點 type RequestChain struct { Handler Next *RequestChain }

複製程式碼

// 設定handler func (h *RequestChain) SetNextHandler(in *RequestChain) *RequestChain { h.Next = in return in } 複製程式碼

關於設計模式,大家可以看我小夥伴的github:https://github.com/TIGERB/easy-tips/tree/master/go/src/patterns

購物車

說完了加車前,現在來看購物車這一部分。我們在之前曾討論過,購物車可能會有多種形態的,比如:儲存多個商品一起結算,某個商品立即結算等。因此購物車一定會根據渠道來進行購物車型別的選擇。

這部分的操作相對是比較穩定的。我們挑幾個比較重要的操作來講一下思路即可。

加入購物車

通過把條件驗證的前置,會發現在進行加車操作時,這部分邏輯已經變得非常的輕量了。要做的主要是下面幾個部分的邏輯。

加入購物車
加入購物車

這裡有幾個取巧的地方,首先是獲取商品的邏輯,由於在前面驗證的時候也會用到,因此這裡前面獲取後會通過引數的方式繼續往後傳遞,因此這裡不需要在讀庫或者呼叫服務來獲取;

其次這裡需要把當前使用者現有購物車資料獲取到,然後將新增的這個商品新增進來。這是一個類似合併操作,原來這個商品是存在,相當於數量加一;需要注意這個商品跟現存的商品有沒有父子關係,有沒有可能加入後改變了某個活動規則,比如:原來買了2個送1個贈品,現在再新增了一個變成3個,送2個贈品;

注意:這裡的新增並不是在購物車直接改數量,可能就是在列表、詳情頁直接新增新增。

通過將合併後的購物車資料,通過營銷活動檢查確認ok後,直接回寫到儲存中。

合併購物車

為什麼會有合併購物車這個操作?因為一般電商都是准許遊客身份進行操作的,因此當使用者登入後需要將二者進行合併。

這裡的合併很多部分的邏輯是可以與加入購物車複用的邏輯。比如:合併後的資料都需要檢查是否合法,然後覆寫回儲存中。因此大家可以看到這裡的關聯性。設計的方法在某種程度上要通用。

購物車列表

購物車列表這是一個非常重要的介面,原則上購物車介面會提供兩種型別,一種簡版,一種完全版本;

簡版的列表介面主要是用在類似PC首頁右上角之類獲取簡單資訊;完全版本就是在購物車列表中會用到。

在實際實現中,購物車絕不僅僅是一個讀取介面那麼簡單。因為我們都知道不管是商品資訊、活動資訊都是在不斷的發生變化。因此每次的讀取介面必然需要檢查當前購物車中資料的合法性,然後發現不一致後需要覆寫原儲存的資料。

購物車列表
購物車列表

也有一些做法會在每個介面都去檢查資料的合法性,我建議為了效能考慮,部分介面可以適當放寬檢查,在獲取列表時再進行完整的檢查。比如新增介面,我只會檢測我新增的商品的合法性,絕不會對整個購物車進行檢查。因為該操作之後一般都會呼叫列表操作,那麼此時還會進行校驗,二者重複操作,因此只取後者。

結算

結算包括兩部分,結算頁的詳情資訊與提交訂單。結算頁可以說是在購物車列表上的一個包裝,因為結算頁與列表頁最大的不同是需要使用者選擇配送地址(虛擬商品另說),此時會產生更明確的價格資訊,其他基本一致。因此在設計購物車列表介面的時候,一定要考慮充分的通用性。

這裡另外一個需要注意的是:立即購買,我們也會通過結算頁介面來實現,但是內部其實還是會呼叫新增介面,將商品新增到購物車中;有三個需要注意的地方,首先是這個新增操作是服務內部完成的,對於服務呼叫方是不需要感知這個加入操作的存在;其次是這個購物車在Redis中的Key是獨立於普通購物車的,否則二者的商品耦合在一起非常難於操作處理;最後立即購買的購物車要考慮賬號多終端登入的時候,彼此資料不能互相影響,這裡可以用每個端的uuid來作為購物車的標記避免這種情況。

購物車的最後一步是生成訂單,這一步最要緊的是需要給購物車加鎖,避擴音交過程中資料被篡改,多說一句,很多人寫的Redis分散式鎖程式碼都存在缺陷,大家一定要注意原子性的問題,這類文章網路上很多不再贅述。

加鎖成功之後,我們這裡有多種做法,一種是按照DB涉及組織資料開始寫表,這適用於業務量要求不大,比如訂單每秒下單量不超過2000K的;那如果你的系統併發要求非常高怎麼辦?

其實也很簡單,高效能的三大法寶之一:非同步;我們提交的時候直接將資料快照寫入MQ中,然後通過非同步的方式進行消費處理,可以通過通過控制消費者的數量來提升處理能力。這種方法雖然效能提升,但是複雜度也會上升,大家需要根據自己的實際情況來選擇。

關於業務架構的設計,到此告一段落,接下來我們來看系統架構。

系統架構

系統結構主要包含,如何將業務架構對映過來,以及輸出對應輸入引數、輸出引數的說明。由於輸入、輸出針對各自業務來確定的,而且沒有什麼難度,我們這裡就只說如何將業務架構對映到系統架構,以及系統架構中最核心的Redis資料結構選擇以及儲存的資料結構設計。

程式碼結構

下面的程式碼目錄是按照 Golang 來進行設計的。我們來看看如何將上面的業務架構對映到程式碼層面來。

├── 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
複製程式碼

外層有 entityeventfacaderepo這四個目錄,職責如下:

entity: 存放的是我們前面分析的購物領域的三個實體;所有主要的操作都在這三個實體上;

event: 這是用來處理產生的事件,比如剛剛說的如果我們提交訂單採用非同步的方式,那麼該目錄就該完成的是如何把資料傳送到MQ中去;

facade: 這兒目錄是幹嘛的呢?這主要是因為我們的服務還需要依賴像商品、營銷活動這些服務,那麼我們不應該在實體中直接呼叫它,因為第三方可能存在變動,或者有增加、減少,我們在這裡進行以下簡單的封裝(設計模式中的門面模式);

repo: 這個目錄從某種程度上可以理解為 Model層,在整個領域服務中,如果與持久化打交道,都通過它來完成。

最後外層的幾個檔案,就是我們所提供的領域服務,供應用層來進行呼叫的。

為了保證內容的緊湊,我這裡放棄了對整個微服務的目錄介紹,只單獨介紹了領域服務,後續會單獨成文介紹下微服務的整個系統架構。

通過上面的劃分,我們完成了兩件事情:

  1. 業務架構分析的結構在系統程式碼中都有對映,他們彼此體現。這樣最大的好處是,保證設計與程式碼的一致性,看了文件你就知道對應的程式碼在哪裡;

  2. 每個目錄各自的關注點都進行了分離,更內聚,更容易開發與維護。

Redis儲存

現在來看,我們選擇Redis作為購物商品資料的儲存,我們要解決兩個問題,一是我們需要存哪些資料?二是我們用什麼結構來存?

網路上很多寫購物車的都是隻儲存一個商品id,真實場景是很難滿足需求的。你想想,一個商品id如何記住使用者選擇的贈品?使用者上次選擇的活動?以及購買的商品渠道?

綜合比較通用的場景,我給出一個參考結構:

// 購物車資料
type ShoppingData struct {
 Item       []*Item `json:"item"`
 UpdateTime int64   `json:"update_time"`
 Version    int32   `json:"version"`
}

// 單個商品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"`                     // 有效時間
 SalePrice    float64         `json:"sale_price"`              // 記錄加車時候的銷售價格
 SpecialPrice float64         `json:"special_price,omitempty"` // 指定價格加購物車
 PostFree     bool            `json:"post_free,omitempty"`     // 是否免郵
 Activities   []*ItemActivity `json:"activities,omitempty"`    // 參加的活動記錄
 AddTime      int64           `json:"add_time"`
 UpdateTime   int64           `json:"update_time"`
}

// 活動
type ItemActivity struct {
 ActID    string `json:"act_id"`
 ActType  string `json:"act_type"`
 ActTitle string `json:"act_title"`
}
複製程式碼

重點說一下 Item 這個結構,item_id 這個欄位是標記購物車中某個商品的唯一標記,因為我們之前說過,同一個sku由於渠道不同,那麼在購物車中會是兩個不同的item;接下來的 parent_item_id 欄位是用來標記父子關係的,這裡將可能存在的樹結構轉成了順序結構,我們不管是父商品還是子商品,都採用順序儲存,然後通過這個欄位來進行關聯;有些同學可能會奇怪,為什麼會存order id這個欄位呢?大家關注下自己的日常業務,比如:再來一單、定金預售等,這種一定是與某個訂單相關聯的,不管是為了資格驗證還是資料統計。剩下的欄位都是一些非常常規的欄位,就不在一一介紹了;

欄位的型別,大家根據自己的需要進行修改。

接下來該說怎麼選擇Redis的儲存結構了,Redis常用的 Hash Table、集合、有序集合、連結串列、字串 五種,我們一個個來分析。

首先購車一定有一個key來標記這個購物車屬於哪個使用者的,為了簡化,我們的key假設是:uid:cart_type

我們先來看如果用 Hash Table;我們新增時,需要用到如下命令:HSET uid:cart_type sku ShoppingData;看起來沒問題,我們可以根據sku快速定位某個商品然後進行相關的修改等,但是注意,ShoppingData是一個json串,如果使用者購物車中有非常多的商品,我們用 HGETALL uid:cart_type 獲取到的時間複雜度是O(n),然後程式碼中還需要一一反序列化,又是O(n)的複雜度。

如果用集合,也會遇到類似的問題,每個購物車看做一個集合,集合中的每個元素是 ShoppingData ,取到程式碼中依然需要逐一反序列化(反序列化是成本),關於有序集合與連結串列就不在分析,大家可以按照上面的思路去嘗試下問題所在。

看起來我們沒得選,只有使用String,那我們來看一下String的契合度是什麼樣子。首先SET uid:cart_type ShoppingDataArr;我們把購物車所有的資料序列化成一個字串儲存,每次取出來的時間複雜度是O(1),序列化、反序列化都只需要一次。看來是非常不錯的選擇。但是在使用中大家還是有幾點需要注意。

  1. 單個Value不能太大,要不然就會出現大key問題,所以一般購物車有上限限制,比如item不能超過多少個;
  2. 對redis的操作效能提升上來了,但是程式碼的就是修改單個item時的不便,必須每次讀取全部然後找到對應的item進行修改;這裡我們可以把從redis中的資料讀取出來後,在記憶體中構建一個HashTable,來減少每次遍歷的複雜度;

網上也看到很多Redis資料結構組合使用來儲存購物車資料的,但是無疑增加了網路開銷,相比起來還是String最經濟划算。

總結

至此對於購物車的實現設計算是完結了,其中關於訂單表的設計會單獨放到訂單模組去講。

對於整個購物車服務,雖然沒有寫的詳細到某個具體的介面,但是分析到這一步,我相信大家心中都是有溝壑的,能夠結合自己的業務去實現它。

文中有些很有意思的地方,建議大家動手去做做看,有任何問題,我們隨時交流。

  • 改編版的責任鏈模式
  • Redis的分散式事務鎖實現

接下來終於要到訂單部分的設計了,希望大家繼續關注我們。

相關文章