一、背景
在 Monorepo 大倉模式中,我們把組件放在共享目錄下,就能通過源碼引入的方式實現組件共享。越來越多的應用愿意走進大倉,正是為了享受這種組件復用模式帶來的開發便利。這種方式可以滿足大部分代碼復用的訴求,但對于復雜業務組件而言,無論是功能的完整性,還是質量的穩定性都有著更高的要求。?源碼引入的組件提供方一旦發生變更,其所有使用方都需要重新拉取 master 代碼,然后構建發布才能使用新功能,這一特性對物料組件、工具組件以及那些對新功能敏感度較低的業務組件來說是可以接受的,但對于新功能敏感度高的復雜業務組件來說,功能更新的不及時會直接面臨著資損風險。?這類復雜組件也往往面臨著頻繁且快速的迭代發布,這樣一來對于組件使用方而言不光需要訂閱組件更新,而且需要做到及時發布升級才能規避風險,因此只用源碼引入的方式來共享復雜業務組件是耗費精力且不合適的。
Webpack5 的 MF(Module Federation,模塊聯邦)有著動態集成多個構建的特性能夠規避上述更新的問題。但同樣也是把雙刃劍,一旦遠程組件提供方發掛了,其所有使用方也就不能正常使用,問題所造成的影響面也會被進一步放大。從分布式風險轉化為集中式風險后,權限管控、依賴關系、業務埋點各方面都需要考慮清楚,對組件的能力要求更高。?而且 MF 遠程組件本地開發代理復雜,無插件情況下本地至少要啟兩個服務進行調試,對電腦配置有一定要求,總的來說有一定上手成本。
那么,有沒有一種共享方式能夠保留兩者的優點,又能對缺點進行規避。本文就基于這個目的從以下兩點展開討論:
- 對于共享復雜業務組件,如何做好權限控制、數據埋點以及平穩降級。
- 如何規避 MF 遠程組件的穩定性風險、解決組件源碼依賴發布更新等問題,保證穩定性的同時,降低本地開發門檻。
二、大倉下組件共享方式
Monorepo 大倉模式下跨應用共享組件的方式有很多,常用的是源碼引入、模塊聯邦兩種方式。本文不對這兩種方式的原理展開介紹和討論,先簡單介紹下這兩種方式在大倉下的使用方法。
源碼引入組件
這種方式能解決大倉下大部分組件復用的需求,代碼復用的便利性也是大家愿意走進大倉的原因之一。
組件提供
為了區分其他組件,可以在 /業務域/_share/remote-components 目錄下開發遠程組件。dx 是內部大倉的 CLI,cc 命令可以快速生成一個組件模板。
// 區分普通組件,新增一個remote-components組件目錄
cd remote-components && dx cc order-detail
同樣是 Monorepo,大倉組件的創建方式和 Lerna 新建物料組件類似。借助腳手架根據填寫的內容就能生成模版,可以編寫單測去自測組件變更,能一定程度的保證組件的健壯性,避免出現破壞性升級的問題。生成之后的模板目錄結構如下圖。
組件使用
依賴注入、源碼引用
- package.json 引入依賴,配置?
workspace:*
,構建時動態去取_share/
目錄下最新版本的組件資源。若從穩定性考慮,也可以固定版本號。
/** package.json */
"@demo/order-detail": "workspace:*"/** 業務組件 */
import OrderDetail from '@demo/order-detail'<OrderDetail {...props} />
總結
優點:
- 開發便捷,本地只需啟一個應用就能開發,調試方便。
- 若組件迭代發掛了,只會影響當前發布的應用,不影響其他使用方,能正常使用該組件,對普通組件和一些對新功能不敏感的業務組件來說是合適的。
缺點:
- 對新功能敏感度較高的復雜業務組件而言,使用方如果要更新版本需要重新拉代碼構建部署,信息同步、發布投入成本較高。
- 由于大倉特性,代碼變更權限很難做到管控,非組件提供方也能修改代碼,組件 Owner 需要嚴格 CR 變更。
MF遠程組件
umi 4.0.48 +?支持在 umi config 使用 MF 配置來使用 MF 的功能。umi4 可以直接從@umijs/max
導出defineConfig
,也可以使用@umijs/plugins/dist/mf
插件去支持配置 MF 屬性,本質也是對 WebPack Plugin 的封裝,屬性是類似的。不一樣的點在于?Host 不再需要通過配置 Exposes 將組件一個個的暴露出去,而是約定暴露 Exposes 目錄下的組件,十分方便。
需要注意的是,該特性用到了 ES2021 的 Top-Level await,所以瀏覽器必須支持該特性。比如谷歌 Chrome 瀏覽器要在 89 版本以上。?
/** 方法一:使用umijs/max導出的defineConfig */
import { defineConfig } from '@umijs/max';export default defineConfig({// 已經內置 Module Federation 插件, 直接開啟配置即可mf: {remotes: [{name: `remote${MFCode}`,aliasName: 'APP_A',entry: 'xxx/remote.js',},],// 配置 MF 共享的模塊shared,},
});
/** 方法二:使用umijs/plugins/dist/mf的插件 */
import { defineConfig } from 'umi';export default defineConfig({plugins: ['@umijs/plugins/dist/mf'], // 引入插件mf: {remotes: [{name: `remote${MFCode}`,aliasName: 'APP_A',entry: 'xxx/remote.js',},],// 配置 MF 共享的模塊shared,},
});
組件提供
用了該插件后,可在正常目錄結構(/pages)下開發代碼,約定在 Exposes 目錄下新建對應組件引用,然后將其暴露出去。
之前
現在
組件使用
使用方也在 Config 配置?MF,可配置多個 Host,自己也能當 Host。然后使用umijs/max
的safeRemoteComponent?
異步注冊組件。
//config.tsconst APP_A_ENTRIES = {PROD: 'https://prod-a-env.com/xxxx/remote.js',DEV: 'https://dev-a-env.com/xxxx/remote.js',PRE: 'https://pre-a-env.com/xxxx/remote.js',TEST: 'https://test-a-env.com/xxxx/remote.js',
}const APP_B_ENTRIES = {PROD: 'https://prod-b-env.com/xxxx/remote.js',DEV: 'https://dev-b-env.com/xxxx/remote.js',PRE: 'https://pre-b-env.com/xxxx/remote.js',TEST: 'https://test-b-env.com/xxxx/remote.js',
}mf: {name: `remote${DemoCode}`,library: { type: 'window', name: `remote${DemoCode}` },remotes: [{/** app-A遠程組件 */name: `remote${aMFCode}`,aliasName: 'appA',keyResolver: getEnv(),entries: ORDER_ENTRIES,},/** app-B遠程組件 */{name: `remote${bMFCode}`,aliasName: 'appB',keyResolver: getEnv(),entries: IM_ENTRIES,},],shared},
- 在 moduleSpecifier 配置使用的遠程組件,規則為 Guest Remotes 配置的?
${aliasName}
和 Host Exposes 目錄下的組件名。 - 在 FallbackComponent 配置遠程組件加載失敗的兜底。
- 在 LoadingElement 配置加載遠程組件的過度狀態。
總結
優點:
- 非源碼依賴,Host 組件更新,所有使用者都能馬上同步新版本使用到新功能,節省了訂閱發布的投入。
- 權限隔離,有 Host 應用權限才能開發組件。
缺點:
- 雖然 umi 已經能夠集成代理了,需要注意資源跨域問題,但開發仍需要至少本地啟兩個項目。
- 如果 Host 發掛了,所有使用者的對應功能都受影響了。
三、最佳實踐
簡單介紹完兩種大倉組件共享方式,進入本文的正題。
- 權限管控:復雜業務組件有著完整的功能,內部往往會請求很多接口,接口就伴隨著權限分配的問題,如何不申請組件主系統權限就能將組件集成到自己的系統中。
- 埋點上報:前端 APM 平臺能夠記錄用戶行為進行上報,用于數據分析。不做任何處理會上報到組件主系統的應用中,組件使用方無法在自己的應用監控中接受這部分埋點數據。
- 平穩降級:質量問題是重中之重,作為復雜業務組件的使用方不關注組件具體業務邏輯的,但是需要考慮系統的整體穩定性不受引入的組件所影響。
業務權限控制
首先要確認系統權限的結構,大部分系統只用了系統權限校驗,不過一些系統還有服務端的權限校驗。
系統權限原理(401)
通過系統唯一編碼去匹配接口 Header 頭中的系統碼字段的方式去綁定權限組。如下圖所示,左圖是用來配置系統菜單和分配角色的平臺,右圖是沒有匹配權限的接口就會報 401 狀態碼。
同樣的,也是根據系統碼去請求菜單,渲染菜單,這些邏輯大部分都是 umi 樣板間(plugin-proRoute/service/menu)里實現了,可以在 src/.umi 下看到具體實現邏輯,注入 Backstagecode 的邏輯還是需要自己在 Request 配置里實現。
業務權限原理(432)
一些系統除了系統權限外還保留業務權限校驗,此校驗通過 Redis 匹配用戶登陸態進行鑒權。沒有匹配權限就會報 432 狀態碼。
其原理圖如下,可通過 getTicketAuth 接口將登陸態寫入 Redis,第一張圖為 B 平臺,依賴 A 系統登陸。第二張圖為改造后,不再依賴 A 系統登陸,原理還是比較好理解的,就不展開了。?
?
Request方案
根據權限原理可以知道,權限管控問題的核心就是去考慮清楚什么時候該用什么系統碼,而我們塞系統碼的任務都是由 Request 來做的。所以接下來我們先了解下常用的 Request 方案,如果組件雙方的 Request 方式不一致怎么解決。
- proRequest,通過內部 @xx/umi-request引入。
已經停止維護了,但是一些早期遷移的應用都還在使用 proRequest。App 入口或者 umi config 中配置 proRequest 屬性。
//config.tsexport default defineConfig({// 其他配置proRequest: {},})//app.tsx
export const proRequest = {prefix: proxyFix,envConfig: {},headers: {backstageCode,},successCodes: [200, '200'],
};
- Request?、基于 Request 的 crud 庫,通過 @umijs/Max 引入。
目前比較常用的 Request,有 crud 的方法,新遷移的應用都使用這個 Request,后續新應用也優先使用這個方法。
通過 Curd API 為 umi 的 Request 提供能力。
//utilsimport { AxiosRequestConfig, request } from '@umijs/max';
import initCrudApiClass from '@/utils/api';const CrudService = initCrudApiClass<AxiosRequestConfig>(({ url, ...config }) =>request(url as string, config).then((res) => res.data),
);CrudService.registerApiOptions('default', {mapping: {paramsType: {read: 'data',remove: 'data',queryList: 'data',queryPage: 'data',},},
});
通過請求配置攔截器去配置 Headers。
// app.tsxexport const request: RequestRuntimeConfig = {baseURL: proxyFix,// 請求攔截器requestInterceptors: [(c: RequestConfig) => {/** 一些配置 */Object.assign(c.headers, {/** 其他配置 */backstageCode,});return c;},],//響應攔截器responseInterceptors: [(res) => {/** 一些配置 */return res;},],// 錯誤配置errorConfig: {errorHandler: (error) => {return errorhandlerCallback(error as ResponseError);},},
};
- Axios?、基于 Axios 的?crud 庫,源碼依賴。
原生支持,可以自適應 Request 配置。
功能集成在 utils 包中,需要單獨源碼引入。
"@xxx/utils": "workspace:*"
通過請求配置攔截器去新增headers,會自動獲取backstageCode,支持傳遞去修改
// src/app.tsximport { RuntimeConfig } from '@umijs/max';/*** @param instance - axios 實例,采用原生方式進行配置即可* @param setOptions - 配置函數*/
export const configRequest: RuntimeConfig['configRequest'] = (instance, setOptions) => {instance.interceptors.request.use((c) => {// 默認攜帶了兩個請求頭:accessToken、backstageCodeObject.assign(c.headers as object, {backstageCode,});return c;});setOptions({errorResponseHandler(error) {return undefined;},});
};
組件雙方的 Request 不一致怎么解決
系統 A 的 Reuqest 用的是 umijs/max 的,系統 B 的 Request 用的是 ProRequest。
上面 2 個原理搞清楚了,這個問題也就迎刃而解。
- 首先,在業務組件中動態初始化 Request 配置,不能用 app.tsx 的配置,接收組件使用方傳過來的系統碼動態注冊 Request 實例。
// 可以通過動態注冊的方式初始化request,使用UmiRequest.requestInit方法。//被用作遠程組件時,從遠端拿到系統碼,通過api改寫headers配置enum BackstageCode {APP_A: 'CODE_A',APP_B: 'CODE_B',APP_C: 'CODE_C'}UmiRequest.requestInit({prefix: proxyFix,headers: {backstageCode: BackstageCode[props.code],},});
- 然后在提供遠程組件時把依賴提供出去,使用方也不需要去安裝其他版本的 Request。
// config.tsmf: {name: `remote${mfName}`,library: { type: "window", name: `remote${mfName}` },shared: {/** 其他依賴 */'@du/umi-request': {singleton: true,eager: true,}}}
權限管控最佳實踐
下面的方案都是在跑的方案,都能正常使用,各有優劣,按需使用。
- 方案一:權限管控在組件提供方。
組件使用方不需要關心頁面權限,但訪問頁面的人需要申請 Host 系統的權限。
對組件提供者很友好,對頁面使用者很不友好,需要申請多個系統權限。
- 方案二:權限管控在組件使用方,將接口配置在自己的天網子系統下,改寫系統碼,需要注意資源跨域問題。
訪問頁面的人對權限無感知,但對開發者無論是組件使用方還是提供方都要做更多的處理。使用者需要關心頁面權限,并及時配置,組件提供方要感知是哪個系統在用組件,并把 Request 配置及時修改,不然就走到組件主系統的權限里去了。?總結一句就是所有工作量都來到了組件維護者這邊,不過不用擔心,掌握上面說到的幾點原理就能游刃有余地處理權限問題。
埋點上報
數據上報 SDK 也都支持系統碼作為上報應用,同理可在 monitor.monitorInit 注冊實例時傳遞系統碼作為參數。
- 支持使用方通過傳遞 Source 或者上報配置給組件。
- Host 根據 Source 幫助 Guest 維護上報配置,配置維護在 Host。
- Host 根據 Guest 的傳遞的自定義配置,直接集成配置進行上報。
- 也可通過接口調用維度去分析數據。
降級方式
- 對于發掛的應用做到自動降級。
- FallbackComponent
前面說到 umi 支持配置遠程組件降級方案,將源碼依賴的組件傳給 SafeRemoteComponent 的 FallbackComponent 屬性,當遠程組件掛載失敗可以直接加載本地組件用作降級。
import { safeRemoteComponent } from '@umijs/max';
import { Spin } from 'poizon-design';
import { SharedOrderDetail } from '@xxx/order-detail'
import React from 'react';const MFOrderDetail = safeRemoteComponent<React.FC<Props>>({moduleSpecifier: 'Demo/OrderDetail',/** 將源碼依賴的組件 */fallbackComponent: <SharedOrderDetail {...props} />,loadingElement: <Spin></Spin>,
});const OrderDetailModule: React.FC<Props> = (props) => <MFOrderDetail key={props.name} {...props} />export default OrderDetailModule;
- 開關
對于遠程組件掛載成功,但是功能不能正常使用的可用下面的方法。
對于新功能未達到業務要求需要支持手動回退版本的降級。
使用前端配置平臺開關,開關開啟走 MF 組件,開關關閉走源碼引入組件,后續可用主干研發模式替代,也可通過監控告警閾值去做到自動降級。?
四、源碼依賴結合MF模式
先源碼引入后MF
在 _share/remote-components 目錄下進行業務組件開發, 之后在子應用 Expose 目錄下通過源碼引入的方式使用組件,再暴露出去。用源碼依賴的方式注入 MF 暴露的組件中,可以適配自動降級方案,代碼片段如下。
先MF后源碼引入
在子應用編寫組件,通過 Expose 方式提供遠程組件,使用 Webpack Plugin 復制文件或者 Pre-Commit Hooks 的方式將組件代碼同步至 Share 目錄下,這樣能夠利用源碼依賴不會自動更新版本的特性用作降級,優先使用實時更新的 MF 遠程組件,降級使用源碼引入的大倉組件,而且這個方法也能夠管控開發權限。
五、未來&總結
未來
結合主干研發模式
新邏輯使用 MF,老邏輯使用源碼依賴。
import FWIns from '@/config/fw-config';const fw = FWIns.init({branchName: 'feature-base-main-xxx-xxx',
});await fw.feature(async () => {/** 新邏輯,使用MF*/<MFComponent />},async () => {/** 老邏輯,使用源碼依賴*/<SharedComponent />},
);
需要開發一些插件
- 為了提升開發效率,需要一個將子應用的業務代碼同步至是 Share 目錄下的 WebPack 插件或者 Git Hooks。
- 目前接入 MF 不管是 Host 還是 Guest 都需要在 umi config 配置一些東西,這些配置大部分是重復的,可以通過插件方式注入,降低接入成本。
- 源碼依賴大文件對構建速度有影響,需進一步比對構建產物進行優化。
總結
本文首先介紹了兩種大倉下常用的共享組件方式,進行優劣勢的分析,并對其大倉內外的用法進行比對。
- 源碼引入:開發便捷,調試方便,組件穩定性較高;但對于復雜業務組件代碼成本較高,開發權限管控較難。
- Module Federation:動態集成,節省訂閱發布成本,權限隔離;過于依賴組件 Host 穩定性,調試較復雜。
然后對于共享復雜業務組件的一些注意事項提出解決方案。
- 權限管控:組件權限可以管控在使用方也可以管控在提供方。如果管控在使用方,可以通過系統碼去動態初始化 Request 實例,對于組件雙方 Request 方式不一致,可通過 MF Shared 依賴的方式解決。
- 埋點上報:同樣的,通過接收系統碼去實例化監控 SDK,不做任何處理就上報到組件得主系統的應用中。
- 平穩降級:可以使用 FallbackComponent 對加載遠程組件失敗的情況做到自動降級,對于遠程組件加載成功,功能發掛了或者新功能未達到業務要求的支持手動回退版本的降級。可利用源碼依賴不會自動更新版本的特性用作開關,也可使用主干研發模式的能力去做降級。
最后聊了如何在大倉下基于源碼依賴結合模塊聯邦的方式實現共享組件。
- 先源碼引入后 MF:在 Share 目錄下開發業務代碼,在子應用 Expose 目錄下通過源碼引入使用組件,再暴露出去供使用者使用。
- 先 MF 后源碼引入:在子應用正常目錄下開發組件,通過 Expose 方式提供遠程組件,編譯時將業務代碼同步至 Share 目錄下。組件使用者可編寫開關優先使用 MF 組件,再利用源碼依賴不會自動更新版本的特性將源碼依賴版本用作降級。
*文/昌禾
本文屬得物技術原創,更多精彩文章請看:得物技術官網
未經得物技術許可嚴禁轉載,否則依法追究法律責任!