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

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

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的分佈式事務鎖實現

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

個人公衆號:dayuTalk
在這裏插入圖片描述
聯繫郵箱:[email protected]

GitHub:https://github.com/helei112g

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章