專案介紹
定價車業務是天貓汽車行業最核心的業務之一,解決的是消費者在4S店買車無法砍價到最低價格的問題,天貓可以從主機廠(貨源方)BD一個最低的一口價,用戶通過在天貓下定金單,到線下門店通過電子憑證核銷、付尾款,最終履約提車的交易模式,完成在線購買新車的完整的交易鏈路倍訓,培養“上天貓,開新車”的心智,其整個鏈路涉及到了復雜的交易鏈路、門店新零售的核銷和POS付尾款或車秒貸金融、網商銀行的墊資鏈路、匯金和FP的保證金凍結、分傭(到平臺和門店)、代扣等等,整體鏈路非常長而復雜,有大量的邏輯分支,需要平臺小二、貨源方、門店、用戶的多次配置和互動,且涉及到資金和交易,對業務邏輯的正確性有著極高的要求,
定價車業務從7月中KO到9.9上線,中間僅有不到一個月的開發時間,團隊里幾個開發和測驗同學帶著幾個外包基本上是一整個月“007”完成了這個業務的上線,并且取得了一定的業務結果,但是在這個程序中,由于時間太緊,全鏈路需要從0到1,作業量太大,而且業務需求在不停的變化,導致了這個專案的設計缺少了架構上和代碼質量上的思考,基本上都是膠水代碼,帶來的大量的維護成本、健壯性差、業務的準確性無法保障,對未來變化的回應速度會比較慢,但更嚴重的問題是,因為代碼量大、質量比較差,極有可能會因為未來的一次評估不到位的變更導致線上故障,甚至產生資損,而這個是我們無論怎么壓測也無法解決的穩定性問題,急需要解決,
從雙11之后開始,我們淘系行業團隊啟動了一個架構升級戰役,就是為了能通過架構的改造升級,解決行業團隊里大量的長尾應用維護成本高,業務邏輯不清晰,穩定性無法保障的問題,而定價車業務,作為一個汽車行業的核心業務,有很高的業務價值,同時在復雜度上也是行業數一數二,所以被選為第一個改造的業務,而通過這次改造,我們希望能沉淀設計模式和實戰經驗,為未來其他業務的改造打好基礎,
業務邏輯簡介
定價車的核心業務邏輯是以天貓汽車為主體開設天貓旗艦店,然后通過統一貨源方授權代理特定車型,另一方面,與門店代理商簽署協議,通過旗艦店+線下門店的方式代銷代理的車型,在這個業務模式下,業務流程需要旗艦店、門店、TP方、貨源方的參與,流程包括資訊流、物流、資金流在各方之間的流轉,

一個簡化版的業務流程圖如下:

幾個核心節點:
- 定價車車型管理:涉及到車型寶貝的配置、分傭的設定、可售賣門店的設定、倉庫的配置等,
- 下單鏈路:通過電子憑證多階段訂單的方式,解決在線付少量定金,但是定價全款的模式,同時會生成一個定價車的自有訂單,
- 核銷電子憑證:生成尾款訂單,同時給天貓分傭,
- 墊付尾款:對接了網商銀行的大額支付功能,在簽約后由門店墊資到貨源方,貨源方才可以發貨
- 到店支付尾款:用云POS支付尾款,同時給門店分傭,
重點問題
定價車第一版代碼的幾個核心問題包括:
重Service層:定價車代碼有及其重的Service(HSF)層,幾乎所有的業務邏輯都實作在了7個Service中,每個Service最多有20-30個介面,依賴了10-20個外部依賴,每個介面實作都是長長的腳本代碼,且Service間有互相呼叫的問題,具體案例代碼太長不展示了,大家可以想象到,這個直接導致的問題就是代碼理解成本高:當一個檔案的代碼超過200行左右時,其功能就已經不“純粹”了,很難去理解他的核心邏輯,也無法從檔案名和方法名上就理解一個檔案是做什么的,這個增加了理解成本和除錯對接成本,在定價車業務中,最大的一個檔案有近3000行代碼,且有較深的呼叫鏈路,光去看一遍代碼就已經是很有挑戰的事情了,更不要說去理解業務含義,
邏輯重復:因為每個方法都是獨立的流水賬,也就導致了同一個業務邏輯很有可能出現在多個地方,前期業務剛開始的時候還好,但是到了后期當業務邏輯變更時,很有可能會漏掉某個實作,導致故障,在定價車業務中,關于分傭比例校驗的邏輯在2處出現、尾款金額計算出現在3個地方、而最重要的分傭金額計算也出現了3個地方,可以想象未來的一次分傭邏輯變更有可能會導致資損,
“貧血”域模型:對于絕大部分域物件,從回傳給前端的資料、到DbMapper使用的出入參都是同一個貧血DO物件,僅包含資料,導致業務邏輯散落在Service層中,一個最常見的問題就是定價車訂單的狀態流轉,類似的狀態判斷和推進邏輯代碼散落在多個地方:
FpcarOrderDO fpcarOrderDO = orderDOList.get(0);
if (fpcarOrderDO.getVechileStatus() < FpcarOrderDO.VECHILE_RELEASED) {
result.setError(ErrorInfoEnum.UPDATE_ERROR);
result.setMsgInfo("訂單還沒有發車!");
return result;
}
if (!(fpcarOrderDO.getVechileStatus() < FpcarOrderDO.VECHILE_ARRIVE_WAREHOUSE)) {
result.setError(ErrorInfoEnum.UPDATE_ERROR);
result.setMsgInfo("車已到倉!");
return result;
}
fpcarOrderDO.setVechileStatus(FpcarOrderDO.VECHILE_ARRIVE_WAREHOUSE);
fpcarOrderDO.setArriveWarehouseTime(new Date());
同時由于業務前臺展示形態和后臺存盤格式不一致,導致有大量的轉化邏輯,而由于使用了基本資料格式,業務邏輯里有很多“魔法值”,導致代碼很難理解且容易出錯,下面的一段代碼就是計算門店傭金的邏輯:
//計算分傭金額
String commissionBmount = "0";
BigDecimal amount = new BigDecimal(fpcarConfigDO.getStoreCommissionAmount());
if (2 == fpcarConfigDO.getCommissionType()) {
//基于分傭基數,按比例分傭
BigDecimal total = new BigDecimal(fpcarConfigDO.getCommissionBasePrice());
//total是分,amount是比例的數字,轉換成元要除以10000
BigDecimal commionB = total.multiply(amount).divide(new BigDecimal("10000"), 2,
BigDecimal.ROUND_HALF_UP);
commissionBmount = commionB.toString();
} else {
//如果固定金額的,則直接按配置的金額扣傭(需要將分轉換為元)
BigDecimal commionB = new BigDecimal(fpcarConfigDO.getStoreCommissionAmount()).
divide(new BigDecimal("100"), 2, BigDecimal.ROUND_HALF_UP);
commissionBmount = commionB.toString();
}
如果沒有注釋,很難看出來commissionType == 2是什么意思,為啥要除以10000和100等等,而這里面又涉及到了金額的各種轉化,在其他的代碼里也有很多yuanToFen和fenToYuan之類的轉化,在這里就不一一展示了,
可測驗性差、無UT覆寫:由于絕大部分方法都或多或少的直接依賴了DAO和一些二方服務介面,導致這些代碼基本上不可被單元測驗,只能集成測驗,而集成測驗由于外部依賴的不可靠性,資料的不完備等不可控因素,導致測驗效率極低,這個問題的直接后果就是定價車的代碼是沒有被測驗覆寫過的代碼,未來代碼變更也沒有明確的回歸用例,代碼質量無法保障,出現問題無法被及時發現,
除了以上的代碼問題之外,一個最大的“風格”上的問題,但又直接嚴重影響了開發效率的問題是:除非你很了解整個業務邏輯和呼叫鏈路,否則一個人很難從現在的代碼上理解完整的業務流程,從代碼層面能看到的僅僅是近百個介面,但每個介面是在什么時候被呼叫到的、為什么被呼叫到、以及哪個上游呼叫到的都很難說清楚,這就像是在看一本小說,但是所有的章節都完全打散無序,你只能從字里行間試圖去拼裝一個故事,可想而知其難度和糟糕的體驗,更別說試圖去改變書中的內容,這個風格問題,在簡單的CRUD應用中也許影響還不是很大,但是在這種擁有復雜流程,分支邏輯的業務流程中,對未來的維護成本和穩定性來說也許是致命的,
升級方案 - Aslan Framework + SDK
(一)框架簡介
Aslan Framework是我們淘系行業架構小組【南晏、逸翰、瑜進、玄麟、殷浩】研發出來的一套DDD+CQRS的框架,運行在Spring Boot/Pandora Boot之上,實作了一套基于六邊形架構(Hexagonal Architecture)的DDD規范,通過不同分層的POM隔離和依賴關系降低了核心業務邏輯和膠水邏輯之間的關聯度(關于六邊形架構的分解可以參考我之前的文章),而通過CQRS規范,我們做到了對外介面層(HSF、MTOP、TOP、HTTP等)和內部業務邏輯(Application層)的完整解耦,讓內部邏輯邊界清晰可見,
其次,為了解決行業應用常見的依賴沖突、代碼可測驗性差等問題,我們將行業依賴的大部分外部二方依賴都封裝了基于Spring Boot Starter的防腐層,形成了一個整體的面向垂直業務的SDK層,其價值是讓垂直業務接入橫向能力更簡單,其中,防腐層的Facade介面讓業務Application層不再對集團的各個二方包產生依賴,而僅僅依賴了干凈的介面和DTO類,確保了核心業務邏輯無外部依賴,干凈可測驗;統一的POM管理確保引入的依賴之間沒有二方包版本沖突,由SDK維護團隊統一解決和確保不同Starter包之間的兼容性;統一的starter配置管理可以確保同一個應用中的不同的業務可以共存不沖突【進展中,定價車一期不需要】,
最后,結合了CQRS架構、用例驅動等理念,我們產出了一套業務流程可視化的方案,通過Application層的流程模塊化,第一次做到了業務身份隔離、流程可視化,讓“代碼即檔案”,今天哪怕是你手里沒有一個PRD,也可以通過代碼注解找到業務流程的前后關系,能夠知道在業務流程中每一步都能做什么、誰在做、哪個業務在做,
下面,我詳細介紹一下我們怎么將定價車業務重構的,
(二)目錄及模塊規范化
1.模塊劃分問題
carcenter應用是一個老的Spring MVC應用,其Maven模塊比較符合傳統業務應用常見的模塊規范,除了應用需要的模塊之外,只分為carcenter-client和carcenter-core兩個模塊,截圖如下:

其中,client為對外提供的二方包,core為介面的實作,這種打包的問題在于所有的核心代碼都在core里,無法做到分層隔離,在寫代碼時很容易導致Bean間的回圈依賴,
2.目錄結構問題
再看一下其中模塊的目錄結構,截圖如下:

這里面能看出來一個更大的問題,那就是carcenter的目錄結構是根據 “檔案功能” 拆分的,由于carcenter應用包含了很多個不同的業務(直租、專車專用、車秒貸等等),當這些業務都放在一起時,很難從多個不同的目錄里面抽離出來哪些代碼是僅屬于定價車業務的,另外的一個大風險是按照這種目錄結構劃分,很有可能出現定價車和其他業務共用一個工具類的情況,
簡單來說,這種client / core的模塊的劃分和按功能劃分目錄結構的方法,很不利于業務之間的隔離,而未來因為其他業務的變更很有可能會導致定價車業務出故障(BTW,這種問題在行業應用里經常會出現),
3.基于DDD的模塊和目錄規范
基于DDD架構,我們將整個應用的代碼結構重新按照業務做了劃分,并且在每個業務里劃分出了client、domain、application、infrastructure和interface這5個模塊,結構如下:

其中,carcenter2-start是Pandora Boot的啟動包,相當于一個啟動類的容器,而定價車相關的所有代碼都放在了fpcar這個Maven模塊中,bidcar則是另一個業務“暗標”的代碼,不同的業務之間沒有pom依賴關系,徹底的做到了業務之間代碼的隔離,fpcar下面的模塊功能如下:
client:包含了對外介面、DTO、CQRS用到的Command、Query、Event,以及一些無業務屬性的值物件,
domain:核心業務邏輯的承載,包括了物體(Entity)和聚合根(Aggregate)、有業務屬性的值物件(Domain Primitive)、域服務(Domain Service)、和Repository的介面類,Domain只依賴Client、外部二方包的Facade介面,不依賴任何其他框架(包括Spring),這就確保了Domain層是完整可單測的,
application:業務流程的封裝,包括了應用服務(Application Service)、DTO轉化器等傳統Application層包含的東西,同時在這次架構升級中,引入了業務流程可視化的元素,詳細在后面講,Application層在原則上只做業務流程的編排,不做核心業務邏輯,所有判斷必須下沉到Domain層,
infrastructure:主要是Repository的實作、DAO、資料映射物件DO(Data Object),DO和Entity的轉化類等,Infrastructure只依賴資料庫、Tair等跟存盤相關的依賴,以及Domain層,不依賴Application層,
interface:對外介面類的實作,包括HSF、MTOP、TOP、定時任務、訊息等等多種來源,通過CQRS的架構,我們做到了interface和application之間的解耦,所以interface只依賴了中間件和client包,
interface-old:這個是為了做到無縫遷移用的,在新應用中不需要,interface-old實作了老業務client包的功能,確保介面和資料一致性,但是HSF版本升為2.0,這樣業務的遷移成本就極大的降低了,
all:all只是一個打包POM,引入了application、infrastructure、interface和interface-old這幾個依賴,
對于所有外部依賴,業務側代碼僅需要在client包引入第三方facade,在應用最外部引入第三方starter/SDK即可,Spring框架的依賴注入和SpringBoot的AutoConfiguration會保證真實業務中會呼叫到必須的服務,
所有的包依賴關系如下圖:

4.namespace/目錄結構和命名規范
命名和目錄結構可能是編碼里最“燒腦”的問題,所以我們制定了一套規范,讓同學們能從namespace中就知道其功能,另一個核心是讓不同的業務可以做到完全隔離:
前綴:com.(tmall|taobao).(應用名).(子業務名).(垂直業務域名 | 流程名)
其中,天貓業務用com.tmall,淘寶業務用com.taobao
應用名:比如,carcenter2
子業務名:比如,定價車 fpcar
垂直業務域名 | 流程名:比如,定價車倉庫 warehouse、定價車交易 trade、定價車入駐 entry
例子:定價車倉庫域 com.tmall.carcenter2.fpcar.warehouse
例子:定價車交易流程 com.tmall.carcenter2.fpcar.trade
例子:定價車入駐流程 com.tmall.carcenter2.fpcar.entry
完整namespace:(前綴).(功能)
domain:域物件,主要是Entity、Enum、Constant
repository:Repository介面
repository.impl:Repository實作類
repository.converter:DataConverter類(DO <-> Entity)
data:資料庫映射DO物件、Mybatis Mapper介面類
types、command、query、event、dto:ValueObject類、Command、Event、Query類、DTO類
application:業務流程
module: 流程Workflow模塊類
assembler:DTO Assembler物件
service:HSF介面類
service.impl:HSF介面實作類
message:訊息Listener類
controller:Controller類
這么做會出現的問題是一些公共的模塊,比如Utils類應該放到哪里?這個結構的原則是公共功能也完全不共享,寧可copy出一份一摸一樣的,這樣一個業務的全部代碼一定會在fpcar這個父目錄下,真正可以做到“拎包即走”,
(三)沉淀業務邏輯 - Domain層
在傳統架構中,核心業務邏輯是寫在Service層的,每個Service的方法代表了一個具體的操作或查詢,導致核心業務邏輯散亂在多個大檔案中,很難被理解,所以第二步我們需要做的事情是抽象核心業務邏輯到Domain層,
但首先我們先看一下為什么原架構中核心業務邏輯會散亂,以下是一個比較簡單,但是又比較典型的案例,具體的業務邏輯是在定金訂單支付后,收到支付訊息后去更新訂單狀態和一些欄位,僅僅展現了部分代碼:
// FpcarDealService.class
@Override
public Result<String> paidOrder(BizOrderDO bizOrderDO, PayOrderDO payOrderDO) {
// (1)
Result<String> result = new Result<>();
result.setSuccess(true);
result.setObject("ok");
// (1)
try {
// (2)
//定價車標:auto_fixPrice=1-定金訂單; auto_fixPrice=2-尾款訂單
String orderFlag = bizOrderDO.getAttribute(FPCAR_ORDER_TAG_NAME);
if (StringUtils.equalsIgnoreCase(orderFlag, FPCAR_ORDER1_TAG)) {
// (3)
Map<String, Object> paraMap = new HashMap<>();
paraMap.put("earnestOrderId", bizOrderDO.getBizOrderId());
List<FpcarOrderDO> orderDOList = fpcarOrderDAO.queryList(paraMap);
if (!CollectionUtils.isEmpty(orderDOList)) {
FpcarOrderDO fpcarOrderDO = orderDOList.get(0);
// (4)
//生成自有的訂單號,供某些環節中使用
fpcarOrderDO.setFpOrderId(FpcarOrderDO.FPORDER_PREFIX + fpcarOrderDO.getEarnestOrderId());
//定金支付時間
fpcarOrderDO.setEarnestOrderPayTime(payOrderDO.getPayTime());
//定金訂單支付成功狀態
fpcarOrderDO.setupStatus(FpcarOrderDO.EARNEST_ORDER_PAID);
// (3)
fpcarOrderDAO.update(fpcarOrderDO);
}
}
}
// (1)
catch (Exception ex) {
logger.error("createOrderException=", ex);
result.setSuccess(false);
result.setObject(null);
}
return result;
}
在上面這段代碼里:
(1):屬于回傳給呼叫方的Result和try/catch兜底,屬于介面層(interface)邏輯,不是業務邏輯
(2):判斷邏輯強依賴了訂單BizOrderDO的實作,而這個判斷邏輯有可能散落在多個地方,造成重復,同樣的,這個不屬于核心業務邏輯,屬于設施層(infrastructure)邏輯,針對于訂單具體實作的邏輯需要被隔離開,
(3):查詢和儲存強依賴了DAO的具體實作,很難測驗且和資料庫底層強耦合,同樣屬于infrastructure邏輯
(4):真正的業務邏輯,但是在操作FpcarOrderDO這個“貧血”模型,
從上面可以看出來,之所以傳統的Service層非常臃腫,其中一個原因在于傳統的Service層除了核心業務邏輯之外,同時包含了介面、設施、第三方等等邏輯,導致了業務邏輯強耦合第三方邏輯,無法有效的隔離和測驗,另一個原因是FpcarOrderDO物件實際上是一個”貧血“域模型,也就是說該物件僅包含資料,不包含行為,導致行為代碼散落在多個Service方法里,第一個問題我們在后面解決,這里我們先解決第二個問題,
用充血域模型承載核心業務邏輯
為了承載核心業務邏輯,我們新建了一個領域模型,叫FpcarOrder,省略后代碼如下:
/**
* 定價車訂單
*/
@Data
public class FpcarOrder implements Aggregate<FpcarOrderId> {
/**
* fpOrderId前綴
*/
public static final String FPORDER_PREFIX = "fpcar_";
/**
* 定價車訂單ID
*/
private FpcarOrderId id;
/**
* 自有購買單號
*/
private String fpOrderId;
/**
* 定金訂單狀態
*/
private FpcarOrderStatus orderStatus = FpcarOrderStatus.UNKNOWN;
/**
* 定金訂單id
*/
private BizOrderId earnestOrderId;
/**
* 定金訂單支付成功時間
*/
private Date earnestOrderPayTime;
/**
* 支付定金訂單
* @param payTime 支付時間
*/
public void payEarnestOrder(Date payTime) {
//生成自有的訂單號,供某些環節中使用
this.setFpOrderId(FPORDER_PREFIX + this.getEarnestOrderId().getValue());
//定金支付時間
this.setEarnestOrderPayTime(payTime);
//定金訂單支付成功狀態
this.setOrderStatus(FpcarOrderStatus.EARNEST_ORDER_PAID);
}
}
幾個重點:
FpcarOrder實作了Aggregate,這個是阿斯蘭框架的一個DDD的規范,顯式宣告FpcarOrder是一個聚合根,且ID欄位是FpcarOrderId強型別,(關于聚合根(Aggregate Root)和物體(Entity)的關系和理論在另外一篇文章會詳細介紹)
FpcarOrderId、BizOrderId、以及FpcarOrderStatus都是Value Object或Enum強型別(原始代碼里都是long或int),通過強型別聚合校驗邏輯,且確保不會出現賦值到錯誤欄位的問題,(關于為何要用強型別,參考我的其他文章)
payEarnestOrder方法封裝了對核心業務邏輯:幾個欄位的賦值和狀態流轉,
沉淀核心業務邏輯到Entity后,并且加入Repository(見下文)之后,呼叫方邏輯變成了:
// 查詢
FpcarOrder fpcarOrder = orderRepository.findByEarnestOrderId(bizOrder.getBizOrderId());
// 更新狀態
fpcarOrder.payEarnestOrder(payOrder.getPayTime());
// 保存
orderRepository.save(fpcarOrder);
雖然上面的呼叫貌似僅僅是把上面(4)里面的邏輯改到了Entity里,但這個操作最大的意義是所有FpcarOrder物件相關的賦值、狀態流轉代碼都集中到一個類里面,不單單有助于降低代碼重復,而且解決了未來業務邏輯變化時,業務“流程”不需要跟著變化的問題,
(四) 隔離持久化邏輯 - Infrastructure層
在原有的代碼中,由于業務邏輯直接操作了貧血DO模型和DAO,導致邏輯中跟持久化相關的邏輯很重;同時因為貧血模型無法保證資料的完整性和一致性,很容易導致bug,在前面我們通過封裝了一個FpcarOrder的Entity類,把所有行為相關的邏輯全部封裝到Entity里,可以避免直接在業務邏輯中直接處理資料持久化,使用方只需要呼叫Repository.save方法,Repository的具體實作規范比較復雜,參考我之前的一篇文章,在這里不再贅述,
使用了Repository模式帶來的一個好處是,原來很多復雜的、容易出錯的資料轉化邏輯,現在都可以有專門的類負責封裝,參考之前所說的計算門店傭金的邏輯:
//計算分傭金額
String commissionBmount = "0";
BigDecimal amount = new BigDecimal(fpcarConfigDO.getStoreCommissionAmount());
if (2 == fpcarConfigDO.getCommissionType()) {
//基于分傭基數,按比例分傭
BigDecimal total = new BigDecimal(fpcarConfigDO.getCommissionBasePrice());
//total是分,amount是比例的數字,轉換成元要除以10000
BigDecimal commionB = total.multiply(amount).divide(new BigDecimal("10000"), 2,
BigDecimal.ROUND_HALF_UP);
commissionBmount = commionB.toString();
} else {
//如果固定金額的,則直接按配置的金額扣傭(需要將分轉換為元)
BigDecimal commionB = new BigDecimal(fpcarConfigDO.getStoreCommissionAmount()).
divide(new BigDecimal("100"), 2, BigDecimal.ROUND_HALF_UP);
commissionBmount = commionB.toString();
}
這段代碼之所以復雜的最核心原因是,在專案初期設計時,把分傭固定金額(分)和比例(百分比)這兩個完全不同型別的數值存到了同一個資料庫欄位(String型別)里,然后通過另一個commissionType的欄位做了區分,導致了大量的格式轉化邏輯,在業務上線之后,已經很難再去修改底層資料儲存的邏輯了,只能通過上層的封裝降低對底層邏輯的依賴和復雜度,
在使用Entity+Repository模式改造時,我們把分傭計算邏輯封裝到了一個Value Object中:
@Value
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class CommissionConfig {
private static final Money THRESHOLD_AMOUNT = new Money("1000");
private static final BigDecimal HUNDRED_PERCENT = new BigDecimal("100");
/**
* 分傭方式:1-固定金額; 2-按比例扣傭
*/
private CommissionType commissionType;
/**
* 按比例分傭時的價格基數
* 這個欄位只有commissionType = 2 時 才生效
*/
private Money commissionBasePrice;
/**
* 車源方分傭給 旗艦店 的 金額
* 這個欄位只有commissionType = 1 時 才生效
* NOTE:不要直接取這個欄位!用getSourceCommission()
*/
private Money sourceCommissionAmount;
/**
* 車源方分傭給 旗艦店 的 比例,百分比
* 這個欄位只有commissionType = 2 時 才生效
* NOTE:不要直接取這個欄位!用getSourceCommission()
*/
private BigDecimal sourceCommissionPercent;
/**
* 車源方分傭給 門店 的 金額
* 這個欄位只有commissionType = 1 時 才生效
* NOTE:不要直接取這個欄位!用getStoreCommission()
*/
private Money storeCommissionAmount;
/**
* 車源方分傭給 門店 的 比例,百分比
* 這個欄位只有commissionType = 2 時 才生效
* NOTE:不要直接取這個欄位!用getStoreCommission()
*/
private BigDecimal storeCommissionPercent;
/**
* 獲取平臺應該分傭的金額
*/
public Money getSourceCommission() {
// 如果 金額 和 比例 都是0,回傳0
if (getSourceCommissionAmount().isZero() && getSourceCommissionPercent().compareTo(BigDecimal.ZERO) == 0) {
return Money.ZERO;
}
//計算分傭金額
switch (commissionType) {
case FixedAmount:
return getSourceCommissionAmount();
case Percentage:
BigDecimal multiplier = getSourceCommissionPercent().divide(HUNDRED_PERCENT, RoundingMode.HALF_EVEN);
return getCommissionBasePrice().multiply(multiplier);
}
return Money.ZERO;
}
/**
* 獲取門店應該分傭的金額
*/
public Money getStoreCommission() {
// 如果 金額 和 比例 都是0,回傳0
if (getStoreCommissionAmount().isZero() && getStoreCommissionPercent().compareTo(BigDecimal.ZERO) == 0) {
return Money.ZERO;
}
//計算分傭金額
switch (commissionType) {
case FixedAmount:
return getStoreCommissionAmount();
case Percentage:
BigDecimal multiplier = getStoreCommissionPercent().divide(HUNDRED_PERCENT, RoundingMode.HALF_EVEN);
return getCommissionBasePrice().multiply(multiplier);
}
return Money.ZERO;
}
private static boolean isValid(Integer commissionType, Money baseMoney,
Money sourceMoney, BigDecimal sourcePercent,
Money storeMoney, BigDecimal storePercent, Money itemPrice) {
if (commissionType != 1 && commissionType != 2) {
return false;
}
// 1000元以內價格 或 分傭基數為0的 或 分傭金額<0的 有問題
if (itemPrice.isLessThanOrEqualTo(THRESHOLD_AMOUNT)
|| baseMoney.isLessThanOrEqualTo(Money.ZERO)
|| sourceMoney.isLessThan(Money.ZERO)
|| storeMoney.isLessThan(Money.ZERO)) {
return false;
}
CommissionType type = CommissionType.valueOf(commissionType);
if (type == CommissionType.FixedAmount) {
//天貓分傭、門店分傭固定金額 取值應該在 0-傭金計算基數 范圍內
if (sourceMoney.add(storeMoney).isGreaterThan(baseMoney)) {
return false;
}
} else if (type == CommissionType.Percentage) {
//天貓分傭、門店分傭對應比例 取值應該在 0-100 范圍內
if (sourcePercent.add(storePercent).compareTo(HUNDRED_PERCENT) > 0) {
return false;
}
}
return true;
}
}
然后在FpcarConfig中只需要呼叫CommissionConfig的計算邏輯:
@Data
public class FpcarConfig implements Aggregate<FpcarConfigId> {
/**
* 分傭資訊物件封裝
*/
private CommissionConfig commissionConfig;
/**
* 獲取平臺應該分傭的金額
*/
public Money getSourceCommission() {
if (commissionConfig != null) {
return commissionConfig.getSourceCommission();
}
return Money.ZERO;
}
/**
* 獲取門店應該分傭的金額
*/
public Money getStoreCommission() {
if (commissionConfig != null) {
return commissionConfig.getStoreCommission();
}
return Money.ZERO;
}
}
通過新建一個轉化器,可以解決FpcarConfig和FpcarConfigDO之間的轉化邏輯:
@Mapper(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
uses = {IdConverters.class, MoneyConverter.class})
public interface FpcarConfigDataConverter {
FpcarConfigDataConverter INSTANCE = Mappers.getMapper(FpcarConfigDataConverter.class);
@InheritInverseConfiguration
FpcarConfig fromData(FpcarConfigDO data);
FpcarConfigDO toData(FpcarConfig entity);
@AfterMapping
default void fillCommissionConfig(FpcarConfigDO configDO, @MappingTarget FpcarConfig entity) {
Money baseMoney = Money.fromCents(Long.valueOf(configDO.getCommissionBasePrice()));
Money sourceMoney = Money.fromCents(Long.valueOf(configDO.getSourceCommissionAmount()));
BigDecimal sourcePercent = new BigDecimal(configDO.getSourceCommissionAmount());
Money storeMoney = Money.fromCents(Long.valueOf(configDO.getStoreCommissionAmount()));
BigDecimal storePercent = new BigDecimal(configDO.getStoreCommissionAmount());
Money itemPrice = Money.fromCents(configDO.getItemPrice());
CommissionConfig commissionConfig =
CommissionConfig.buildFromDO(configDO.getCommissionType(), baseMoney, sourceMoney, sourcePercent,
storeMoney, storePercent, itemPrice);
entity.setCommissionConfig(commissionConfig);
}
@AfterMapping
default void fillDOCommission(FpcarConfig entity, @MappingTarget FpcarConfigDO configDO) {
CommissionConfig comm = entity.getCommissionConfig();
configDO.setCommissionType(comm.getCommissionType().getValue());
configDO.setCommissionBasePrice(comm.getCommissionBasePrice().getCents() + "");
if (comm.getCommissionType() == CommissionType.FixedAmount) {
configDO.setSourceCommissionAmount(comm.getSourceCommissionAmount().getCents() + "");
configDO.setStoreCommissionAmount(comm.getStoreCommissionAmount().getCents() + "");
} else if (comm.getCommissionType() == CommissionType.Percentage) {
configDO.setSourceCommissionAmount(comm.getSourceCommissionPercent().toString());
configDO.setStoreCommissionAmount(comm.getStoreCommissionPercent().toString());
}
}
}
呼叫方在使用時只需要,而不再需要關注具體的儲存方式:
fpcarConfig.getSourceCommission()
而最重要的是,以上的所有邏輯,基本上都可以100%做到單元測驗覆寫,而不依賴底層的儲存,這些對于系統的穩定性都是極其重要的,
(五) 業務流程模塊化、可視化 - Application層
如同前文提到,對于定價車這種復雜的業務,我們在代碼組織上最大的問題是如何去管理所有的介面和事件,用前面的那個paidOrder方法舉例,單純從一個介面上很難看出上游的呼叫方是誰,以及什么時候、為什么被呼叫到,這個在理解代碼的時候是比較困難的一件事,同時,當FpcarDealService有超過20個類似的方法時,未來的理解和維護成本,會隨著業務的發展變得越來越高,所以針對這個問題,我們根據CQRS的思想做了一個模塊化方案,
業務流程模塊化
在DDD的架構內,Application層的核心是組織業務”流程“,定價車的業務流程主要分為2部分:1.入駐/管理、2.交易,為了讓未來的維護方能快速理解這兩個流程,我們在代碼結構上對原有的幾個Service做了拆解和重新組織,如下(entry是入駐流程、trade是交易流程):

其中,我們將代碼結構拆為兩個層級:
Flow:代表業務流程
Module:代表業務流程中的某個節點
具體案例代碼如下:
@BizFlow(name = "定價車交易流程", version = "1.0.0")
public class FpcarTradeFlow {}
@BizFlowModule(flow = FpcarTradeFlow.class, name = "定價車定金訂單支付模塊", parents = {CreateOrderModule.class}, role = "用戶")
public class PayEarnestOrderModule {
@Autowired
private FpcarOrderRepository orderRepository;
@EventHandler(name = "定金支付成功")
public void handle(@NotNull FpcarEarnestOrderPaidEvent event) {
BizOrderDTO bizOrder = event.getBizOrder();
PayOrderDTO payOrder = event.getPayOrder();
//先檢查大訂單是否存在了
FpcarOrder fpcarOrder = orderRepository.findByEarnestOrderId(bizOrder.getBizOrderId());
if (fpcarOrder == null) {
throw new IllegalStateException("定價車訂單還未創建過");
}
// 更新狀態
fpcarOrder.payEarnestOrder(payOrder.getPayTime());
//保存
orderRepository.save(fpcarOrder);
}
}
在上面的代碼里,我們通過 @BizFlow 和 @BizFlowModule 這兩個注解,對PayEarnestOrderModule這個模塊做了“檔案”化,每個模塊代表了業務流程的一部分,其中,flow 引數表示了該模塊對應的Flow流程、name 提供了該模塊的檔案、parents 是該模塊的前置模塊,role 是該模塊的操作方,
那這些資訊該如何自動化的識別呢?在我們自己的開發者后臺中,會根據代碼中的注解自動生成一個業務流程圖:


這個的好處是哪怕一個新同學完全沒看過需求PRD檔案,也能通過代碼、注解及其生成的流程圖,快速上手一個復雜的業務,同時,哪怕未來的代碼變化了但是檔案沒跟上,我們也可以從最新的代碼中快速理解一個業務流程,做到”代碼即檔案“,
模塊化的一些規范
在這個流程中,我們經常需要回答的問題是如何合理的劃分不同的模塊?傳統的一些簡單的Service,是根據領域模型來劃分邊界,一般來說要包含“增、刪、改、查”,以及一些針對領域物件的操作,但是在DDD架構下,很多增刪改查和操作的邏輯應該都抽象到了對應的Entity和Repository中,而Application Service層的邏輯更多的是流程的封裝,也就是說通常需要跨多個領域物體,在這個架構下傳統的Service劃分方式將不再適用,這時候我們需要有一套合理的規范來約束我們的模塊劃分,在這里我們依賴了單一職責原則(Single Responsibility Principle,SRP),
SRP的定義其實很多人都會誤解,SRP的概念不是一個代碼模塊/類應該“只做一件事情”,而是一個代碼模塊應該“只因為一件事情而改變”,也就是說,我們在判斷一個模塊是否符合SRP原則的時候,只要去看這個類是否會因為多件事而改變,如果我們用SRP原則去看一些傳統的Service,能明顯的看到幾乎所有的Service都不符合SRP的原則,因為一般來說,一個領域模型的增、刪、改、查基本上都在業務流程中的不同部分,會因為多個需求的變化而變化,
所以在做模塊化的程序中,我們需要考慮的是如何讓一個模塊能符合SRP,同時盡可能的提升模塊的內聚性,降低模塊間的關聯性,在定價車業務中,我們采用的一個規范是根據前端頁面的操作來劃分模塊,也就是說一個頁面上的所有操作都內聚到一個模塊中,例子如下:
針對于業務流程中的用戶核銷后提交資料和墊付車款的需求,


代碼如下:
@BizFlowModule(flow = FpcarTradeFlow.class, name = "門店墊資資訊", parents = {CreateOrderModule.class}, role = "門店")
public class StoreSubmitInvestDataModule {
@Autowired
private FpcarOrderRepository orderRepository;
@Autowired
private PartnerRepository partnerRepository;
@Autowired
private MyBankFacade mybankFacade;
@CommandHandler(name = "門店墊資")
public FpcarInvestDTO handleInvest(@NotNull FpcarInvestCommand cmd) {
// 省略
}
@CommandHandler(name = "門店提交資訊")
public Boolean handleSubmitData(@NotNull FpcarSubmitDataCommand cmd) {
// 省略
}
@QueryHandler(name = "查詢門店墊資資訊")
public FpcarInvestDTO queryInvestInfo(@NotNull FpcarInvestQuery query) {
// 省略
}
}
可以看出來,在這個步驟中,該頁面如果有任何需要修改的邏輯,全部都可以在這一個Module類中實作變更,而不需要任何其他Module的變更,符合了SRP原則,
CQRS引數顯性化
在上面的代碼里,有幾個和傳統Service引數不一樣的地方:
第一,所有Module的公開方法的入參都是Command、Query或者Event,這是我們采用CQRS的代碼規范,例子如下:
// 門店提交資料指令
@Data
public class FpcarSubmitDataCommand implements Command<Boolean> {
private Long storeId;
private Long id;
private String investData;
}
// 其中T是該指令應該的回傳值
public interface Command<T> {}
這個跟傳統的Service介面的方式有明顯的差異:
Result<Boolean> submitData(Map<String, Object> paramsMap); // (1)
或
Result<Boolean> submitData(Long storeId, Long id, String investData); // (2)
在傳統的方法中,入參一般有以下兩種方式:
(1)通用的大Map,可以保證介面兼容性,但需要在代碼里解決Map決議和例外處理的問題,
(2)固定引數,但如果未來的需求變更,可能需要介面變更,造成可能的介面不兼容,通常需要通過增加介面方式向前兼容,
我們通過對所有的入參做強型別顯性化包裝,可以確保1.介面的兼容、以及2.引數的可拓展性,同時,通過對多個引數的顯性封裝,我們能更明確的理解該方法的“目的性”,不需要再僅僅通過方法名去理解方法的目的,
第二,在公開方法上我們增加了@CommandHandler、@QueryHandler、和@EventHandler注解,這些注解不僅僅是起到了一定的檔案作用,同時在啟動時,Aslan框架會對所有標記了注解的方法做入參/出參匹配校驗,確保方法的出參符合預期(這個功能后續可以考慮改為靜態編譯時執行),具體的實作比較復雜不在此處贅述,因為有了注解和規范,關于該模塊的方法資訊可以上報到研發平臺,供開發快速了解每個模塊的方法,如下:

第三(可選),增加了注解的方法,Aslan框架在啟動時會注冊到一個注冊中心,讓呼叫方更加簡單的去使用該方法,而不必太關注該方法所在的具體模塊,同樣的案例如下,為了做到和原有HSF介面兼容,我們做了一次轉換:
// 原來的HSF介面
public Result<Boolean> submitData(Map<String, Object> paramsMap) {
Result<Boolean> result = new Result<>();
try {
Long storeId = getStoreId(paramsMap);
String investData = (String) paramsMap.get("investData");
Long id = (Long) paramsMap.get("id");
// 省略部分校驗代碼
FpcarSubmitDataCommand cmd = new FpcarSubmitDataCommand();
cmd.setStoreId(storeId);
cmd.setId(id);
cmd.setInvestData(investData);
Boolean success = commandBus.dispatch(cmd); // (1)
result.setObject(success);
} catch (FpcarException fpe) {
result.setSuccess(false);
result.setObject(false);
result.setErrorMessage(fpe.getMessage());
} catch (Exception e) {
result.setError(ErrorInfoEnum.SERVICE_NOT_USE);
}
return result;
}
在上面的代碼里,(1)的commandBus可以通過全域注冊查找該Command對應的CommandHandler,然后通過dispatch來間接呼叫該Handler,這種呼叫方式避免了需要直接引入StoreSubmitInvestDataModule這個依賴,也就是說可以讓Interface層和Application層解耦,CommandBus/EventBus/QueryBus的dispatch方法都是同步呼叫,相當于直接參考,另外提供的dispatchAsync方法,可以通過傳入一個自定義Executor,實作異步呼叫,回傳CompletableFuture,在此不再贅述,
通過CommandBus這種間接呼叫的方式可能有一定的理解成本,業務開發需要對自己的理解做評估,然后選擇采用CommandBus,如果不用CommandBus,直接呼叫Module方法也是一樣的,如下:
@Autowired
StoreSubmitInvestDataModule storeSubmitInvestDataModule;
// ...
FpcarSubmitDataCommand cmd = new FpcarSubmitDataCommand();
Boolean success = storeSubmitInvestDataModule.handleSubmitData(cmd);
(六)隔離第三方依賴 - 防腐層Facade
任何一個復雜業務都不可能不依賴第三方服務,在定價車業務里,我們依賴了UIC、IC、交易、網商、新零售、FP、匯金、訊息等第三方服務,擁有大量的第三方依賴帶來很多問題:
除錯對接成本很高:每個依賴方都有完全不同的接入方法,有些復雜依賴(如網商,需要加密解密,決議XML等)的從0開始的對接和除錯驗證成本可能要幾周,
第三方服務升級會導致不兼容或沖突:每年各種中間件、第三方依賴升級所需的作業量巨大,特別是當出現不兼容升級或沖突出現時,需要對代碼進行升級和測驗,產生大量的成本,
依賴了第三方資料格式:當你在業務邏輯里依賴了第三方的資料格式時,會導致你的代碼對第三方產生強依賴,讓你的代碼變得僵硬,特別是當第三方資料格式不可變時,
無法單測:直接依賴了第三方服務介面,就如同直接依賴DAO一樣,會導致業務邏輯無法單元測驗,只能集成測驗,而在很多情況下集成測驗的成本極高,比如絕大部分的第三方缺少穩定的日常環境,而在預發聯調的風險又極高,
為了解決這些問題,讓我們自己的代碼邏輯可單測且不依賴第三方,我們對一些常用的第三方依賴相關的邏輯做了一次封裝,并且利用了Spring Boot Starter的特性,通過AutoConfiguration解決了依賴配置的問題,呼叫方只需要引入一個Starter POM包即可,這個封裝的Starter就是我們的防腐層,
一個標準Starter的組成
Facade:新的介面和出參入參的DTO
Core:具體和第三方依賴的對接實作,以及AutoConfiguration相關的配置
在一個正常的代碼里,業務邏輯應該只依賴新的Facade,然后在應用的最外層依賴相關的Starter POM即可,starter自帶的spring.factories和AutoConfiguration會自動把對應的實作在運行時注入,
舉個上面分傭的例子,原有的代碼里需要直接呼叫匯金的介面,需要直接依賴匯金的服務和DTO:
// 構建入參
BizRequestDTO bizRequestDTO = new BizRequestDTO();
// 省略各種引數拼裝和校驗邏輯
ReturnWrapper<BizResponseDTO> returnWrapper = iBizGwService.submit(bizRequestDTO);
通過封裝了一層Facade層,我們可以隔離匯金的介面:
@BizFlowModule(flow = FpcarTradeFlow.class, name = "定價車分傭A模塊", parents = {UseEarnestOrderModule.class}, role = "用戶")
public class CommissionAModule {
@Autowired
private HuijinFacade huijinFacade;
@EventHandler(name = "定價車分傭A事件")
public void handleEarnestOrderSuccess(@NotNull FpcarCommissionAEvent event) {
// ...
HuijinRequestDTO request = new FpcarSourceHuijinRequestBuilder()
.fpcarConfig(fpcarConfig)
.fpcarOrder(fpcarOrder)
.user(user)
.build();
final Result<HuijinBizResponseDTO> commissionResult = huijinFacade.submit(request);
}
}
在上面的代碼中,業務代碼不再直接依賴匯金的介面和出入參,而是依賴我們自定義的、可控的Facade和DTO,并且由于該Facade只是一個介面類,可以隨意的Mock和Stab,讓該方法可控可測驗,
除了提供Mock之外,一個防腐層能提供更多的功能如:快取、兜底、限流、多業務身份隔離等功能,具體的實作規范會在我后面的一篇文章里詳細解釋,
老業務遷移方案
最后,我們在遷移代碼之后,需要考慮如何做老業務的遷移,考慮到修改前端的成本比較高也暫時沒有人力去完成,整體的遷移方案希望能做一個成本最低的方案,我們對定價車整體的遷移方案設計為以下兩個階段:
第一階段 - 新應用,介面簽名不變,HSF V2.0
在第一個階段,遷移成本最低的方案是確保所有的對外HSF介面都保持簽名不變,僅僅是HSF版本變化,如下:

定價車業務的前臺介面都是由carcenter-front應用來提供,然后通過HSF呼叫到carcenter上,其HSF介面定義在carcenter-client二方包里,
在遷移后的carcenter2上,fpcar-interface-old模塊重新實作了carcenter-client的介面,但是其介面實作只相當于引數拼裝后轉發到application層,然后把結果重新轉化為原有的資料格式,案例如下:
@HSFProvider(serviceVersion = "${old.hsf.version}") // old.hsf.version = 2.0.0
public class FpcarPartnerServiceImpl implements FpcarPartnerService {
@Autowired
private QueryBus queryBus;
private FpcarConverters converters = FpcarConverters.INSTANCE;
@Override
public Result<List<FpcarPartnerDO>> queryList(Map<String, Object> paraMap) {
PartnerPagedQuery query = PartnerPagedQuery.fromMap(paraMap);
Page<PartnerDTO> queryResult = queryBus.dispatch(query);
Result<List<FpcarPartnerDO>> result = new Result<>();
List<FpcarPartnerDO> list = queryResult.getContent().stream().map(converters::fromDTO).collect(Collectors.toList());
result.setObject(list);
return result;
}
}
在前臺應用carcenter-front中,逐漸切流的方法有很多,最簡單的就是配置一個開關通過白名單或用戶ID或商家ID,通過配置兩個不同版本的HSFConsumer來切流,
第二階段 - 切換到新介面、新實作
第二個階段需要一定的前臺改造,但是能有效的減少代碼轉換邏輯和維護老介面的成本,邏輯如下:

能看出來,新老介面的底層都呼叫了application層的業務邏輯,所以整體遷移的風險不大,只是前臺適配的作業量,這一步需要每個業務按需執行,
轉載請註明出處,本文鏈接:https://www.uj5u.com/ruanti/232676.html
標籤:其他
上一篇:Faster RCNN原理介紹
