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

來還債了,但願你們在疫情中都是平安的,回來的時候公司也還在!git


skr shop是一羣底層碼農,因爲被工做中的項目折磨的精神失常,加之因爲程序員的自傲:別人設計的系統都是一坨shit,個人設計纔是宇宙最牛逼,因而乎決定要作一個只設計不編碼的電商設計手冊。

項目地址:https://github.com/skr-shop/m...程序員

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

說明

架構設計能夠分爲三個層面:golang

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

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

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

在業務架構分析上有不少方法論,好比:領域驅動設計,可是它並非惟一的業務架構分析方法,也並非說最好的。適合你的就是最好的。咱們經常使用的實體關係圖、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/eas...

購物車

說完了加車前,如今來看購物車這一部分。咱們在以前曾討論過,購物車可能會有多種形態的,好比:存儲多個商品一塊兒結算,某個商品當即結算等。所以購物車必定會根據渠道來進行購物車類型的選擇。

這部分的操做相對是比較穩定的。咱們挑幾個比較重要的操做來說一下思路便可。

加入購物車

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

加入購物車

這裏有幾個取巧的地方,首先是獲取商品的邏輯,因爲在前面驗證的時候也會用到,所以這裏前面獲取後會經過參數的方式繼續日後傳遞,所以這裏不須要在讀庫或者調用服務來獲取;

其次這裏須要把當前用戶現有購物車數據獲取到,而後將添加的這個商品添加進來。這是一個相似合併操做,原來這個商品是存在,至關於數量加一;須要注意這個商品跟現存的商品有沒有父子關係,有沒有可能加入後改變了某個活動規則,好比:原來買了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的分佈式事務鎖實現

接下來終於要到訂單部分的設計了,但願你們繼續關注咱們。

相關文章
相關標籤/搜索