原創 得物技術 - 陳子煜
團隊歸屬于后方業務支撐部門,組內的項目都以pc中后臺應用為主。對比移動端應用,代碼庫比較龐大,業務邏輯也相對復雜。在持續的迭代過程中,我們發現當前的代碼倉庫仍然有不少可以優化的點:
21年前端平臺決定技術棧統一遷移到React生態,后續平臺的基礎建設也都圍繞React展開,這就使得商家使用Vue生態做開發的系統面臨技術棧遷移的難題,將業務邏輯和UI框架節藕變得異常重要。
隨著代碼量和團隊成員的增加,應用里風格迥異的代碼也越來越多。為了能夠持續迅速的進行迭代,團隊急需一套統一的頂層代碼架構設計方案。
隨著業務變得越來越復雜,在迅速的迭代過程中團隊需要頻繁地對功能進行回歸,因此我們對于自動化單測用例的訴求也變的越來越強烈。
為了完成以上的優化,四組對現有的應用架構做了一次重構,而重構的核心就是整潔架構。
整潔架構(The clean architecture)是由 Robert C. Martin (Uncle Bob)在2012年提出的一套代碼組織的理念,其核心主要是依據各部分代碼作用的不同將其拆分成不同的層次,在各層次間制定了明確的依賴原則,以達到以下目的:
為了實現以上目的,整潔架構把應用劃分成了entities、use cases、interface adapters(MVC、MVP等)、Web/DB等至少四層。這套架構除了分層之外,在層與層之間還有一個非常明確的依賴關系,外層的邏輯依賴內層的邏輯。
entities封裝了企業級的業務邏輯和規則。entities沒有什么固定的形式,無論是一個對象也好,是一堆函數的集合也好,唯一的標準就是能夠被企業的各個應用所復用。
entities封裝了企業里最通用的一部分邏輯,而應用各自的業務邏輯就都封裝在use case里面。日常開發中最常見的對于某個模型的crud操作就屬于usecase這一層。
這一層類似于膠水層,需要負責內圈的entity和use case同外圈的external interfaces之間的數據轉化。需要把外層服務的數據轉化成內層entity和usecase可以消費的數據,反之亦然。如上面圖上畫的,這一層有時候可能很簡單(一個轉化函數), 有時候可能復雜到包含一整個MVC/MVP的架構。
我們需要依賴的外部服務,第三方框架,以及需要糊的頁面UI都歸屬在這一層。這一層完全不感知內圈的任何邏輯,所以無論這一層怎么變(ui變化),都不應該影響到內圈的應用層邏輯(usecase)和企業級邏輯(entity)。
在整潔架構的原始設計中,并不是強制一定只能寫這么四層,根據業務的需要還可以拆分的更細。不過無論怎么拆,都需要遵守前面提到的從外至內的依賴原則。即entity作為企業級的通用邏輯,不能依賴任何模塊。而外層的ui等則可以使用usecase、entity。
前面介紹了當前代碼庫目前的一些具體問題,而整潔架構的理念正好可以幫助我們優化代碼可維護性。
作為前端,我們的業務邏輯不應該依賴視圖層(ui框架及其生態),同時應當保證業務邏輯的獨立性和可復用性(usecase & entity)。最后,作為數據驅動的端應用,要保證應用視圖渲染和業務邏輯等不受數據變動的影響(adapter & entity)。
根據以上的思考,我們對“整潔架構”做了如下落地。
對于前端應用來說,在entity層我們只需要將服務端的生數據做一層簡單的抽象,生成一個貧血對象給后續的渲染和交互邏輯使用。
interface IRawOrder {
amount: number
barCode: string
orderNo: string
orderType: string
skuId: number
deliveryTime: number
orderTime: number
productImg: string
status: number
}
export default function buildMakeOrder({
formatTimestamp,
formatImageUrl,
}: {
formatTimestamp: (timestamp: number, format?: string) => string
formatImageUrl: (
image: string,
config?: { width: number; height: number },
) => string
}) {
return function makeOrder(raw?: IRawOrder) {
if (!raw || !raw.orderNo) {
Monitor.warn('臟數據')
return null;
}
return {
amount: raw.amount,
barCode: raw.barCode,
orderNo: raw.orderNo,
orderType: raw.orderType,
skuId: raw.skuId,
status: raw.status,
statusDescription: selectStatusDescription(raw.status),
deliveryTime: formatTimestamp(raw.deliveryTime),
orderTime: formatTimestamp(raw.orderTime),
productImg: formatImageUrl(raw.productImg),
}
}
}
function selectStatusDescription(status: number): string {
switch (status) {
case 0:
return '待支付'
case 1:
return '待發貨'
case 2:
return '待收貨'
case 3:
return '已完成'
default:
return ''
}
}
以上是商家后臺訂單模型的entity工廠函數,工廠主要負責對服務端返回的生數據進行加工處理,讓其滿足渲染層和邏輯層的要求。除了抽象數據之外,可以看到在entity工廠還對數據進行了校驗,將臟數據、不符合預期的數據全部處理掉或者進行兜底(具體操作要看業務場景)。
有一點需要注意的是,在設計entity的時候(尤其是基礎entity)需要考慮復用性。舉個例子,在上面orderEntity的基礎上,我們通過簡單的組合就可以生成一個虛擬商品訂單entity:
import { makeOrder } from '@/entities'
export default function buildMakeVirtualOrder() {
return function makeVirtualOrder(raw?: IRawPresaleOrder) {
const order = makeOrder(raw)
if(! order || !raw.virtualOrderType) {
Monitor.warn('臟數據')
return null
}
return {
...order,
virtualOrderType: raw.virtualOrderType,
virtualOrderDesc: selectVirtualOrderDesc(raw.virtualOrderType)
}
}
}
如此一來,我們就通過entity層達到了2個目的:
usecase這一層即是圍繞entity展開的一系列crud操作,以及為了頁面渲染做的一些聯動(通過ui store實現)。由于當前架構的原因(沒有bff層),usecase還可能承擔部分微服務串聯的工作。
舉個例子,商家后臺訂單頁面在渲染前有一堆準備邏輯:
現在大致的實現是:
{
mounted() {
const { subType } = this.$route.query
/*
7-15行處理了幾種分支鏈路場景下對subType的賦值問題
*/
if (Number(subType) === 0 || subType) {
this.subType = subType.toString()
} else {
if (this.user.merchant.typeId === 4) {
this.subType = this.tabType.cross
} else {
this.subType = this.tabType.ordinarySpot
}
}
/*
getAllLogisticsCarrier有沒有對subType賦值呢?光看這段代碼完全不確定
*/
this.getAllLogisticsCarrier()
/*
21-22行又多出來一個分支需要對subType進行再次賦值
*/
if (this.isPersonPermission && !this.crossUser) {
this.subType = this.tabType.warehouse
}
},
getAllLogisticsCarrier() {
let getCarrier = API.getAllLogisticsCarrier
if (this.crossUser) {
getCarrier = API.getOrderShipAllLogistics
}
getCarrier({}).then(res => {
if (res.code === 200) {
const options = []
.......... // 給options賦值
this.options2 = options
}
})
},
}
我們能看到7-15、24-125行對this.subType進行了賦值。但由于我們無法確定20行的函數是否也對this.subType進行了賦值,所以光憑mounted函數的代碼我們并不能完全確定subType的值究竟是什么,需要跳轉到getAllLogisticsCarrier函數確認。這段代碼在這里已經做了簡化,實際的代碼像getAllLogisticsCarrier這樣的調用還有好幾個,要想搞清楚邏輯就得把所有函數全看一遍,代碼的可讀性一般。同時,由于函數都封裝在ui組件里,因此要想給函數覆蓋單測的話也需要一些改造。
為了解決問題,我們將這部分邏輯都拆分到usecase層:
// prepare-order-page.ts
import { tabType } from '@/constants'
interface IParams {
subType?: number
merchantType: number
isCrossUser: boolean
isPersonPermission: boolean
}
/*
做依賴倒置主要是為了方便后續的單測和復用
*/
export default function buildPrepareOrderPage({
queryLogisticsCarriers,
}: {
queryLogisticsCarriers: () => Promise<{ carriers: ICarrires }>
}) {
return async function prepareOrderPage(params: IParams) {
const activeTab = selectActiveTab(params)
const { carriers } = queryLogisticsCarriers(params.isCrossUser)
return {
activeTab,
carriers,
}
}
}
function selectActiveTab({
subType,
isCrossUser,
isPersonPermission,
merchantType,
}: IParams) {
if (isPersonPermission && !isCrossUser) {
return tabType.warehouse
}
if (Number(subType) === 0 || subType) {
return subType.toString()
}
if (merchantType === 4) {
return tabType.cross
}
return tabType.ordinarySpot
}
// query-logistics-carriers
export default function buildQueryLogisticsCarriers({
fetchAllLogisticsCarrier,
fetchOrderShipAllLogistics,
}: {
fetchAllLogisticsCarrier: () => Promise<{ data: {carriers: ICarrires }}>
fetchOrderShipAllLogistics: () => Promise<{ data: {carriers: ICarrires }}>
}) {
return async function queryLogisticsCarriers(isCrossUser: boolean) {
if (isCrossUser) {
return fetchAllLogisticsCarrier()
}
return fetchOrderShipAllLogistics()
}
}
// index.vue
{
mounted() {
const {activeTab, carriers} = prepareOrderPage(params)
this.subType = activeTab;
this.options = buildCarrierOptions(carriers) // 將carries轉換成下拉框option
}
}
首先,可以看到所有usecase一定是一個純函數,不會存在副作用的問題。
其次,prepareOrderPage usecase專門為訂單頁定制,拆分后一眼就能看出來訂單頁的準備工作需要干決定選中的tab和拉取供應商列表兩件事情。而另一個拆分出來的queryLogisticsCarriers則是封裝了商家后臺跨境、國內兩種邏輯,后續無論跨境還是國內的邏輯如何變更,其影響范圍被限制在了queryLogisticsCarriers函數,我們需要對其進行功能回歸;而對于prepareOrderPage來說,queryLogisticsCarriers只是() => Promise<{ carriers: ICarrires }>的一個實現而已,其內部調用queryLogisticsCarriers的邏輯完全不受影響,不需要進行回歸。
最后,而由于我們做了依賴倒置,我們可以非常容易的給usecase覆蓋單測:
import buildPrepareOrderPage from '@/utils/create-goods';
function init() {
const queryLogisticsCarriers = jest.fn();
const prepareOrderPage = buildPrepareOrderPage({ queryLogisticsCarriers });
return {
prepareOrderPage,
queryLogisticsCarriers,
};
}
describe('訂單頁準備邏輯', () => {
it('當用戶是國內商家且在入倉白名單上,在打開訂單頁時,默認打開入倉tab', async () => {
const { prepareOrderPage } = init();
const params = {
merchantType: 2
isCrossUser: false
isPersonPermission: true
}
const { activeTab } = await prepareOrderPage(params)
expect(activeTab).toEqual({tabType.warehouse});
});
it('當用戶是跨境商家,在打開訂單頁時,默認打開跨境tab', async () => {
const { prepareOrderPage } = init();
const params = {
merchantType: 4
isCrossUser: true
isPersonPermission: true
}
const { activeTab } = await prepareOrderPage(params)
expect(activeTab).toEqual({tabType.cross});
});
......
});
單測除了進行功能回歸之外,它的描述(demo里使用了Given-When-Then的格式,由于篇幅的原因,關于單測的細節在后續的文章再進行介紹)對于了解代碼的邏輯非常非常非常有幫助。由于單測和代碼邏輯強行綁定的緣故,我們甚至可以將單測描述當成一份實時更新的業務文檔。
除了方便寫單測之外,在通過usecase拆分完成之后,ui組件真正成為了只負責“ui”和監聽用戶交互行為的組件,這為我們后續的React技術棧遷移奠定了基礎;通過usecase我們也實現了很不錯的模塊化,對于使用比較多的一些entity,他的crud操作可以通過獨立的usecase具備了在多個頁面甚至應用間復用的能力。
上面usecase例子中的fetchAllLogisticsCarrier就是一個adapter,這一層起到的作用是將外部系統返回的數據轉化成entity,并以一種統一的數據格式返回回來。
這一層很核心的一點即是可以依賴entity的工廠函數,將接口返回的數據轉化成前端自己設計的模型數據,保證流入usecase和ui層的數據都是經過處理的“干凈數據”。除此之外,通常在這一層我們會用一種固定的數據格式返回數據,比如例子中的 {success: boolean, data?: any}。這樣做主要是為了抹平對接多個系統帶來的差異性,同時減少多人協作時的溝通成本。
type Request = (url: string, params: Record<string, any>) => Promise<any>;
import makeCarrier from '@/entities/makeCarrier'
export default function buildFetchAllLogisticsCarrier({request}: {request: Request}) {
return async function fetchAllLogisticsCarrier() {
// TODO: 異常處理
const response = await request('/fakeapi', info)
if (!response || !resposne.code === 200) {
return {
success: false
}
}
return {
success: true,
data: {
carriers: response.list?.map(makeCarrier)
}
}
}
}
通過Adapter + entity的組合,我們基本形成了前端應用和后端服務之間的防腐層,使得前端可以在完全不清楚接口定義的情況下完成ui渲染、usecase等邏輯的開發。在服務端產出定義后,前端只需要將實際接口返回適配到自己定義的模型(通過entity)即可。這一點對前端的測試周提效非常非常非常重要,因為防腐層的存在,我們可以在測試周完成需求評審之后根據prd的內容設計出業務模型,并以此完成需求開發,在真正進入研發周后只需要和服務端對接完成adapter這一層的適配即可。
在實踐過程中,我們發現在對接同一個系統的時候(對商家來說就是stark服務)各個adapter對于異常的處理幾乎一模一樣(上述的11-15行),我們可以通過Proxy對其進行抽離實現復用。當然,后續我們也完全有機會根據接口定義來自動生成adapter。
在經過前面的拆分之后,無論咱們的UI層用React還是Vue來寫,要做的工作都很簡單了:
由于entity已經做了過濾和適配處理,所以在ui層我們可以放心大膽的用,不需要再寫一堆莫名其妙的判斷邏輯。另外由于entity是由前端自己定義的模型,無論開發過程中服務端接口怎么變,受影響的都只有entity工廠函數,ui層不會受到影響。
最后,在ui層我們還剩下令人頭痛的技術棧遷移問題。整個團隊目前使用vue的項目有10個,按迭代頻率和項目規模遷移的方案可以分為兩類:
通過整潔架構我們形成了統一的編碼規范,在前端應用標準化的道路上邁下了堅實的一步??梢灶A見的是整個標準化的過程會非常漫長,我們會陸續往標準中增加新的規范使其更加完善,短期內在規劃中的有:
后續在標準逐漸穩定之后,我們也期望基于穩定的規范進行一些工程化的實踐(比如根據mooncake文檔自動生成adapter層、基于usecase實現功能開關等),敬請期待。
The Clean Architecture:https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
Module Federation:https://webpack.js.org/concepts/module-federation/
Anti-corruption Layer pattern:https://docs.microsoft.com/en-us/azure/architecture/patterns/anti-corruption-layer
?
*文/陳子煜
關注得物技術,每周一三五晚 18:30 更新技術干貨
要是覺得文章對你有幫助的話,歡迎評論轉發點贊~
|