1. 程式人生 > 其它 >設計模式:積分兌換系統的設計與實現

設計模式:積分兌換系統的設計與實現

積分是一種常見的營銷手段,很多產品都會用它來促進消費、增加使用者粘性。那應該怎麼才能實現一個積分系統呢?也就是怎麼做產品設計呢?

(1)首先,一定不要自己一個人悶頭想。一方面,這樣做很難想全面。另一方面,從零開始設計也比較浪費時間。

  • 我們可以找幾個類似的產品,比如淘寶,看看它們是如何設計積分系統的,然後借鑑到我們的產品中。
  • 籠統地來講,積分系統無外乎就兩個大的功能點,一個是賺取積分,另一個是消費積分。賺取積分功能包括積分賺取渠道,比如下訂單、每日簽到、評論等;還包括積分兌換規則,比如訂單金額與積分的兌換比例,每日簽到贈送多少積分等。消費積分功能包括積分消費渠道,比如抵扣訂單金額、兌換優惠券、積分換購、參與活動扣積分等;還包括積分兌換規則,比如多少積分可以換算成抵扣訂單的多少金額,一張優惠券需要多少積分來兌換等等。
  • 上面只是一些非常籠統、粗糙的功能需求。在實際情況中,肯定還有一些業務細節需要考慮,比如積分的有效期問題。對於這些業務細節,還是那句話,悶頭拍腦袋想是想不全面的。以防遺漏,我們還是要有方法可尋。那除了剛剛講的“借鑑”的思路之外,還可以通過產品的**線框圖、使用者用例(user case)**或者叫做使用者故事(user story)來細化業務流程,挖掘一些比較細節的、不容易想到的功能點。
  • 比如使用者用例。使用者用例有點型別單元測試用例。它側重情景化,其實就是模擬使用者如何使用產品,描述使用者在一個特定的應用場景裡的一個完整的業務操作流程。所以,它包含更多的細節,且更加容易被人理解。比如,有關積分有效期的使用者用例,我們可以進行如下的設計:
    • 使用者在獲取積分的時候,會告知積分的有效期;
    • 使用者在使用積分的時候,會優先使用快過期的積分;
    • 使用者在查詢積分明細的時候,會顯示積分的有效期和狀態(是否過期);
    • 使用者在查詢總可用積分的時候,會排除掉過期的積分。

(2)通過上面講的方法,我們就可以將功能需求大致弄清楚了。積分系統的需求總結如下:

  • 積分賺取和兌換規則
    • 積分的賺取渠道包括:下訂單、每日簽到、評論等。
    • 積分兌換規則可以是比較通用的。比如,簽到送 10 積分。再比如,按照訂單總金額的10% 兌換成積分,也就是 100 塊錢的訂單可以積累 10 積分。除此之外,積分兌換規則也可以是比較細化的。比如,不同的店鋪、不同的商品,可以設定不同的積分兌換比例。
    • 對於積分的有效期,我們可以根據不同渠道,設定不同的有效期。積分到期之後會作廢;在消費積分的時候,優先使用快到期的積分。
  • 積分消費和兌換規則
    • 積分的消費渠道包括:抵抗訂單金額、兌換優惠券、積分換購、參與活動扣積分等。
    • 我們可以根據不同的消費渠道,設定不同的積分兌換規則。比如,積分換算成消費抵扣金額的比例是 10%,也就是 10 積分可以抵扣 1 塊錢;100 積分可以兌換 15 塊錢的優惠券等。
  • 積分及其明細查詢:查詢使用者的總積分,以及賺取積分和消費積分的歷史記錄。

系統設計

面向物件設計聚集在程式碼層面(主要是針對類),那系統設計就是聚集在架構層面(主要是針對模組)。針對業務系統,其設計步驟如下

第一步:合理的將功能劃分到不同模組

面向物件設計的本質就是把合適的程式碼放在合適的類中。合理的劃分程式碼可以實現程式碼的高內聚、低耦合,類與類之間的互動簡單清晰,程式碼整體結構一目瞭然,那程式碼的質量不會差到哪裡去。類比面向物件設計,系統設計實際上就是把合適的功能放到合適的模組中。合理的劃分模組也可以做到模組層面的高內聚、低耦合,架構整潔清晰

對於前面羅列的所有功能點,我們有下面三種模組劃分方法。

(1)積分賺取渠道及兌換規則、消費渠道以及兌換規則的管理和維護,不劃分到積分系統中,而是放到更上層的營銷系統中,這樣積分系統就會變得非常簡單,只需要負責增加積分、減少積分,查詢積分明細這幾個工作。

舉個例子解釋一下。比如,使用者通過下訂單賺取積分。訂單系統通過非同步傳送訊息或者同步呼叫介面的方式,告知營銷系統訂單交易成功。營銷系統根據拿到的訂單資訊,查詢訂單對應的積分兌換規則(兌換比例、有效期等),計算得到訂單可兌換的積分數量,然後呼叫積分系統的介面給使用者增加積分。

(2)積分賺取渠道以及兌換規則、消費渠道以及兌換規則的管理和維護,分散在各個相關業務系統中,比如訂單系統、評論系統、簽到系統、換購商城、優惠券系統等。還是剛剛那個下訂單賺取積分的例子,在這種情況下,使用者下訂單成功之後,訂單系統根據商品對應的積分兌換比例,計算所能兌換的積分數量,然後直接呼叫積分系統給使用者增加積
分。

(3)所有的功能都劃分到積分系統中,包括積分賺取渠道及兌換規則、消費渠道及兌換規則的管理和維護。還是同樣的例子,使用者下訂單成功之後,訂單系統直接告知積分系統訂單交易成功,積分系統根據訂單資訊查詢積分兌換規則,給使用者增加積分。

怎麼判斷哪種模組劃分合理呢?

  • 實際上,我們可以反過來通過看它是否符號高內聚、低耦合特性來判斷。如果一個功能的修改或者新增,經常要跨團隊、跨專案、跨系統才能完成,那說明模組劃分的不夠合理,職責不夠清晰,耦合過於嚴重。
  • 除此之外,為了避免業務知識的耦合,讓下層系統更加通用,一般來講,我們不希望下層系統(也就是被呼叫的系統)包含太多上層系統(也就是呼叫系統)的業務資訊,但是,可以接受上層系統包含下層系統的業務資訊。比如,訂單系統、優惠券系統、換購商城等作為呼叫積分系統的上層系統,可以包含一些積分相關的業務資訊。但是,反過來,積分系統中最好不要包含太多跟訂單、優惠券、換購等相關的資訊。
  • 所以,綜合考慮,我們更傾向於第一種和第二種模組劃分方式。但是,不管選擇這兩種的的哪一種,積分系統所負責的工作是一樣的,只包含積分的增減查詢以及積分明細的記錄和查詢。

第二步:設計模組和模組之間的互動關係

在面向物件設計中,類設計好之後,我們需要設計類之間的互動關係。類比到系統設計,系統職責劃分好之後,接下來就是設計系統之間的互動,也就是確定有哪些系統跟積分系統之間有互動以及如何進行互動。

比較常見的系統之間的互動關係有兩種,一種是同步介面呼叫,另一種是利用訊息中介軟體非同步呼叫。第一種方式簡單直接,第二種方式解耦效果更好。

比如,使用者下訂單成功之後,訂單系統推送一條訊息到訊息中介軟體,營銷系統訂閱訂單成功訊息,觸發執行相應的積分兌換邏輯。這樣訂單系統就跟營銷系統完全解耦,訂單系統不需要知道任何跟積分有關的邏輯,而影響系統也不需要直接跟訂單系統互動。

除此之外,上下層系統之間的呼叫傾向同步介面,同層之間的呼叫傾向於非同步訊息呼叫。比如,比如,營銷系統和積分系統是上下層關係,它們之間就比較推薦使用同步介面呼叫。

第三步:設計模組的介面、資料庫、業務模型

接下來就是模組本身如何設計了。實際上,業務系統本身的設計無外乎有這樣三方面的工作要做:介面設計、資料庫設計和業務模型設計。也就是說,這個系統怎麼實現

如何實現一個遵從設計原則的積分兌換系統?

業務開發包括哪些工作

實際上,我們平時做業務系統的設計與開發,無外乎有這樣三方面的工作要做:介面設計、資料庫設計和業務模型設計(也就是業務邏輯)。

資料庫和介面的設計非常重要,一旦設計好並投入使用之後,這兩部分都不能輕易改動。改動資料庫表結構,需要涉及資料的遷移和適配。改動介面,需要推動介面的使用者作相應的程式碼修改。這兩種情況,即便是微小的改動,執行起來都會非常麻煩。因此,我們在設計介面和資料庫的時候,一定要多花點心思和時間,切不可過於隨意。相反,業務邏輯程式碼側重內部實現,不涉及被外部依賴的介面,也不包含持久化的資料,所以對改動的容忍性更大。

針對積分系統,我們先來看,如何設計資料庫。

資料庫的設計比較簡單。實際上,我們只需要一張記錄積分流水明細的表就可以了。表中記錄積分的賺取和消費流水。使用者積分的各種統計資料,比如總積分、總可用積分等,都可以通過這張表來計算得到。

接下來,我們再來看,如何設計積分系統的介面。

介面設計要符合單一職責原則,粒度越小通用性越好。但是,介面粒度太小也會帶來一些問題。比如,一個功能的實現要呼叫多個小介面,一方面如果介面呼叫走網路(特別是公網),多次遠端介面呼叫會影響效能;另一方面,本來應該在一個介面中完成的原子操作,現在分拆成多個小介面來完成,就可能會涉及到分散式事務的資料一致性問題(一個介面執行成功了,但另一個介面執行失敗了)。所以,為了兼顧易用性和效能,我們可以借鑑facade(外觀)模式,在職責單一的細粒度介面之上,再封裝一層粗粒度的介面給外部使用。

對於積分系統來說,我們需要設計如下這樣幾個介面。

最後,我們來看業務模型的設計。

從程式碼實現的角度來說,大部分業務系統的開發都可以分為Controller、Service、Repository三層。Controller層負責介面暴露,Repositorty負責資料讀寫,Service負責核心業務邏輯,也就是這裡說的業務模型。

另外,還有兩種開發模式,基於貧血模型的傳統開發模型和基於充血模型的DDD開發模式。前者是一種面向過程的程式設計分格,後者是一種面向物件的程式設計風格。不管是DDD還是iOOP,高階開發模式的存在一般都是為了應對複雜系統,應對系統的複雜性。對於我們要開發的積分系統來說,因為業務相對比較簡單,所以,選擇簡單的基於貧血模型的傳統開發模式就足夠了。

從開發的角度來說,我們可以把積分系統作為一個獨立的專案,來獨立開發,也可以跟其他業務程式碼(比如營銷系統)放到同一個專案中進行開發。從運維的角度來說,我們可以將它們跟其他業務一塊部署,也可以作為一個微服務獨立部署。具體選擇哪種開發和部署方式,我們可以參考公司當前的技術架構來決定。

實際上,積分系統業務比較簡單,程式碼量也不多,建議將它更營銷系統放到一個專案中開發部署。只要我們做好程式碼的模組化和解耦,讓積分相關的業務程式碼跟其他業務程式碼之間邊界清晰,沒有太多耦合,後期如果我們要將它拆分為獨立的專案來開發部署,那也並不困難。

為什麼要分 MVC 三層開發?

大部分業務系統的開發都可以分為三層:Contoller 層、Service 層、Repository 層。那為什麼要這麼做呢??很多業務都比較簡單,一層程式碼搞定所有的資料讀取、業務邏輯、介面暴露不好嗎。原因如下:

(1)分層能夠起到程式碼複用的作用

  • 同一個Repository可能會被多個Service來呼叫,同一個Service可能會被多個Controller呼叫。
  • 比如,UserService 中的 getUserById() 介面封裝了通過 ID 獲取使用者資訊的邏輯,這部分邏輯可能會被UserController 和 AdminController 等多個 Controller使用。如果沒有 Service 層,每個 Controller 都要重複實現這部分邏輯,顯然會違反 DRY原則

(2)分層能起到隔離變化的作用

  • 分層體現了一種抽象和封裝的設計思想。比如,Repository 層封裝了對資料庫訪問的操作,提供了抽象的資料訪問介面。基於介面而非實現程式設計的設計思想,Service 層使用Repository 層提供的介面,並不關心其底層依賴的是哪種具體的資料庫。當我們需要替換資料庫的時候,比如從 MySQL 到 Oracle,從 Oracle 到 Redis,只需要改動 Repository層的程式碼,Service 層的程式碼完全不需要修改
  • 除此之外,Controller、Service、Repository 三層程式碼的穩定程度不同、引起變化的原因不同,所以分成三層來組織程式碼,能有效地隔離變化。比如,Repository 層基於資料庫表,而資料庫表改動的可能性很小,所以 Repository 層的程式碼最穩定,而 Controller 層提供適配給外部使用的介面,程式碼經常會變動。分層之後,Controller 層中程式碼的頻繁改動並不會影響到穩定的 Repository 層

(3)分層能起到隔離關注點的作用:Repository 層只關注資料的讀寫。Service 層只關注業務邏輯,不關注資料的來源。Controller 層只關注與外界打交道,資料校驗、封裝、格式轉換,並不關心業務邏輯。三層之間的關注點不同,分層之後,職責分明,更加符合單一職責原則,程式碼的內聚性更好。

(4)分層能提高程式碼的可測試性。單元測試不依賴不可控的外部元件,比如資料庫。分層之後,Repsitory層的程式碼通過依賴注入的方式供Service層使用,當要測試包含核心業務邏輯的Service層的時候,我們可以用mock的資料來源替代真實的資料庫,注入到Service層程式碼中

(5)分層能應對系統的複雜性。所有的程式碼都放在一個類中,那這個類的程式碼就會因為需要的迭代而無限膨脹。我們知道,當一個類或者一個函式的程式碼過多之後,可讀性、可維護性就會變差。那我們就要想辦法拆分。拆分有水平拆分和垂直拆分兩個方向。水平方向基於業務來做拆分,就是模組化;垂直方向基於流程來做拆分,就是這裡說的分層

還是那句話,不管是分層、模組化,還是 OOP、DDD,以及各種設計模式、原則和思想,都是為了應對複雜系統,應對系統的複雜性。對於簡單系統來說,其實是發揮不了作用的,就是俗話說的“殺雞焉用牛刀”。

BO、VO、Entity 存在的意義是什麼?

我們提到,針對 Controller、Service、Repository 三層,每層都會定義相應的資料物件,它們分別是 VO(View Object)、BO(Business Object)、Entity,例如 UserVo、UserBo、UserEntity。在實際的開發中,VO、BO、Entity可能存在大量的重複欄位,甚至三者包含的欄位完全一樣。在開發的過程中,我們經常需要重複定義三個幾乎一樣的類,顯然是一種重複勞動

相對於每層定義各自的資料物件來說,是不是定義一個公共的資料物件會更好些呢?

不,更推薦每層都定義各自的資料物件。原因如下:

  • VO、BO、Entity 並非完全一樣。比如,我們可以在 UserEntity、UserBo 中定義Password 欄位,但顯然不能在 UserVo 中定義 Password 欄位,否則就會將使用者的密碼暴露出去。
  • VO、BO、Entity三個類雖然程式碼重複,但功能語義不重複,從職責上講是不一樣的。所以,也不能算違背DRY原則。如果合併為同一個類,那也會存在後期因為需求的變化而需要再拆分的問題。
  • 為了儘量減少每層之間的耦合,把職責邊界劃分明確,每層都會維護自己的資料物件,層與層之間通過介面互動。資料從下一層傳遞到上一層的時候,將下一層的資料物件轉換成上一層的資料物件,再繼續處理。雖然這樣的設計稍微有點繁瑣,每層都需要定義各自的資料物件,需要做資料物件之間的轉換,但是分層清晰。對於非常大的專案來說,結構清晰是第一位的

既然VO、BO、Entity不能合併,那如何解決程式碼重複的問題呢?

從設計的角度來說,VO、BO、Entity的設計思路並不違反DRY原則,為了分層清晰、減少耦合,多維護幾個類的成本也並不是不能接受的。但是,如果你真的有程式碼潔癖,對於程式碼重複的問題,我們也有辦法來解決。

  • 我們知道,繼承可以解決程式碼重複的問題。我們可以將公共的欄位定義在類中,讓VO、BO、Entity都繼承這個父類,各自只定義特有的欄位。因為這裡的繼承層次很淺,也不復雜,所以使用繼承並不會影響程式碼的可讀性和可維護性。後期如果因為業務的需要,有些欄位需要從父類移動到組合,或者從子類提取到父類,程式碼改起來也不復雜
  • 第二個方法就是,組合。組合也可以拒絕程式碼重複的問題,所以這裡我們還可以將公共的欄位抽取出來到公共的類中,VO、BO、Entity通過組合關係來複用這個類的程式碼

程式碼重複問題解決了,那不同分層之間的資料物件該如何相互轉換呢?

當下一層的資料通過介面呼叫傳遞到上一層之後,我們需要將它轉換成上一層對應的資料物件型別。比如,Service層從Repository層獲取到Entity之後,將其轉換為BO,再繼續業務邏輯的處理,所以,這個開發的過程會涉及到“Entity到BO”和“BO到VO”這兩種轉化。

  • 最簡單的轉化方式是手動複製。自己寫程式碼在兩個物件之間,一個欄位一個欄位的賦值。但這樣的做法顯然是沒有技術含量的低階勞動。Java 中提供了多種資料物件轉化工具,比如BeanUtils、Dozer 等,可以大大簡化繁瑣的物件轉化工作。
  • 如果你是用其他程式語言來做開發,也可以借鑑 Java 這些工具類的設計思路,自己在專案中實現物件轉化工具類。

VO、BO、Entity 都是基於貧血模型的,而且為了相容框架或開發庫(比如 MyBatis、Dozer、BeanUtils),我們還需要定義每個欄位的 set 方法。這些都違背 OOP 的封裝特性,會導致資料被隨意修改。那到底該怎麼辦好呢?

  • Entity 和 VO 的生命週期是有限的,都僅限在本層範圍內。而對應的Repository 層和 Controller 層也都不包含太多業務邏輯,所以也不會有太多程式碼隨意修改資料,即便設計成貧血、定義每個欄位的 set 方法,相對來說也是安全的。
  • 不過,Service 層包含比較多的業務邏輯程式碼,所以 BO 就存在被任意修改的風險了。但是,設計的問題本身就沒有最優解,只有權衡。為了使用方便,我們只能做一些妥協,放棄BO 的封裝特性,由程式設計師自己來負責這些資料物件的不被錯誤使用。
  • 總結