多項目應用開發架構和多進程間開發構建流程優化分析

隨着業務複雜度的上升,前端項目不管是從代碼量上,還是從依賴關係上都會爆炸式增長。對於單頁面應用或者多應用項目來說,各個應用之間的關係也會更加複雜,多個應用之間如何配合,如何維護相互關係?公共庫版本如何管理?如何兼顧開發體驗和上線構建效率?這些話題隨着前端業務的發展,逐漸浮出水面。

這篇文章我就以一個成熟的大型項目爲例,從其中一個優化點延伸,談一談 前端現代化開發和架構設計方式的思考和經驗。 當然,每一種風格的項目組織方式都各有特點, 如何在這些不同架構下,打造順暢的開發構建流程,持續優化提效是一個非常值得深入的話題。

多項目應用開發架構設計

對於一個大型複雜的業務,如果我們將所有業務邏輯開發放在同一個 Git 倉庫下,那麼長久以往會導致項目臃腫不堪,難以維護。針對於此,歷史上我們習慣將這個“超級 Git 倉庫”,分散成多個小的 Git 倉庫,一個項目拆分成多個應用。但是這樣的做法並沒有解決多個應用之間的耦合和公共邏輯的重複。甚至極端的場景下,如果應用之間具有強關聯,這樣的多倉庫設計勢必造成開發調試和上線的痛苦。

針對上述背景,目前更加現代化的前端管理風格和架構設計主要有兩種(基於 Git submodule 能力的方案不再本文考慮範圍之內):

  • 基於 Webpack 多入口的項目拆分和多應用打包構建
  • 基於 Monorepo 風格的項目設計

這兩種解決方案都保留了這個唯一的“超級 Git 倉庫”,但是它們的設計思想卻有不同。我們可以簡單理解爲:

  • 「基於 Webpack 多入口的項目拆分和多應用打包構建」更加適合於業務應用項目,在多項目內聚的前提下,保證了開發和調試的便利性,同時可以拆分構建和打包流程,顯著提高效率
  • 「基於 Monorepo 風格的項目組織」似乎更加適合庫的編寫,使用 Lerna 這種工具的 Monorepo 方案帶有鮮明的發版能力和強烈的工程庫風格。這種模式下,依然具有上述提到的開發和調試便利性,構建和打包的原子性,可謂「你喜歡的樣子我都有」

這兩種解決方案的原理和實現這裏我不再贅述,感興趣的讀者可以訂閱頻道,後續我會詳細解析,而本篇將會繼續從另一種角度來持續深入。

Monorepo VS Multirepo

由於下文涉及到的場景採用了 Monorepo 風格的管理方式,且這是我個人非常推崇的方案,因此這裏我稍微介紹一下相關概念。對於相關概念已經有過了解的讀者,可以直接跳過這部分,進行下部分的閱讀。

現代管理和組織代碼的方式主要分爲兩種:

  • Multirepo
  • Monorepo

顧名思義,Multirepo 就是將應用按照模塊分別管理在不同的倉庫中;而 Monorepo 就是將應用中所有的模塊全部一股腦放在同一個項目中,不再需要在單獨發包、測試,且所有代碼都在一個項目中管理,在開發階段能夠更早地復現 bugs,暴露問題,更方便進行調試。

這是項目代碼在組織上的不同哲學: 一種倡導分而治之,一種倡導集中管理。 究竟是把雞蛋放在同一個籃子裏,還是倡導多元化,這就要根據團隊的風格以及面臨的實際場景進行選型。

我試圖從 Multirepo 和 Monorepo 兩種處理方式的各自弊端說起,希望給讀者更多的參考和建議。

對於 Multirepo,存在以下問題:

  • 開發調試以及版本更新效率低下
  • 團隊技術選型分散,有可能不同的庫實現風格存在較大差異
  • Changelog 梳理困難,issue 管理混亂(對於開源庫來說)

而 Monorepo 缺點也非常明顯:

  • 庫體積超大,目錄結構複雜度上升
  • 需要使用維護 Monorepo 的工具,這就意味着學習成本

社區上的經典選型案例:

  • Babel 和 React 都是典型的 Monorepo

他們的 issue 和 pull request 都集中到唯一的項目中,changelog 可以簡單地從一份 commit 列表梳理出來。我們參看 React 項目倉庫,從其目錄結構即可看出其強烈的 Monorepo 風格:

react-16.2.0/
  packages/
    react/
    react-art/
    react-.../

因此, reactreact-dom 在 npm 上是兩個不同的庫,他們只不過在 react 項目中通過 Monorepo 的方式進行管理。

而著名的 rollup 目前是 Multirepo 組織。

對於 Monorepo 和 Multirepo,選擇了 Monorepo 的 babel 貢獻了文章: Why is Babel a Monorepo? 該文章思想,前文已經有所指出,這裏不再展開。

Monorepo 風格在構建中的挑戰

上述對於 Monorepo 的優缺點分析主要是針對於其管理風格本身來說的。作爲工程師,我們還是要在實踐中總結和發現問題,比如我還要補充 Monorepo 實際落地之後,會面臨的兩個挑戰:

  • Monorepo 項目過大,導致每次上線構建流程過長,存在不必要的時間成本消耗
  • Monorepo 項目子應用和依賴在開發階段存在互相「干擾」的損耗

先說第一個挑戰點,對於一個 Monorepo 項目來說,雖然可以在開發階段單獨構建打包,但是在整個項目上線時,卻需要全量構建。舉例來說,一個 Monorepo 項目包含應用:App1,App2,App3,Dependecies。當我們對 App1 進行改動時,因爲所有應用都在同一個 Git 倉庫中,導致上線時 App2 和 App3 仍然需要重新構建,這種構建顯然是不必要的(App1,App2,App3 不同應用應該互相獨立),這和 Multirepo 相比,這無疑增加了上線構建成本。這裏需要注意的是: 如果 Dependecies 改動,那麼所有依賴 Dependecies 的項目比如 App1,App2,App3 的重新構建是必要且必須的。

這種“缺陷”我們往往使用「增量構建」的方案來優化。這個話題很有意思,比如涉及到「如何能找出每次提交的改動點所對應的原子構建任務」,我們這裏暫不展開,依然回到本文的主題上。

再說第二個挑戰點,「Monorepo 項目子應用和依賴在開發階段存在互相干擾的損耗」並不好理解,但正是本篇文章一個非常核心的輸出之一。接下來,我們通過下一部分,從一個案例來說起,幫助大家體會,並一起找到優化方案。

多進程間構建流程優化

前端構建流程的本質其實是一個個 NodeJS 任務,也因此是逃離不了進程或者線程的概念。Webpack,Babel,NPM Script 這些我們耳熟能詳的工具和腳本都是一個獨立或相互關聯的進程任務。這裏需要大家明白一個「不間斷進程」概念,我使用 continuous processes 來表達。其實很簡單,比如 @babel/cli 提供了 watch mode 選項:

npx babel script.js --watch --out-file script-compiled.js

這個 watch 選項可以監聽文件(夾)的實時變動,並在有變動時重新對目標文件(夾)進行編譯。因此這個編譯進程是掛起的,持續的,更多內容可以看筆者之前的文章 從構建進程間緩存設計 談 Webpack5 優化和工作原理 。類似的場景在 Webpack 當中也非常常見。

對於複雜的前端構建過程,當這些任務進程交織在一起,產生流水關係時,就會變非常有趣,請繼續閱讀。

一個多項目應用開發架構下的瑕疵

我們的中後臺項目「Monstro」採用了經典的 Monorepo 結構,項目組織如下:

其中,package.json 中字端,

"workspaces": [
    "packages/*",
    "apps/*"
],

也暗示了項目中:apps 目錄內是 Monorepo 下每一個單獨的子應用,這些應用可以單獨發版,單獨構建,子應用之間相對獨立;packages 目錄內是公共依賴,被 apps 目錄內所有子應用引用。

簡要說明一下這種組織架構的優勢:

  • 不同子應用之間構建環節獨立,每個子應用存在自己的 package.json 文件,可以在項目根目錄下通過 NPM Script: yarn start ${appName}yarn build ${appName} 結合 --scope 選項,進行獨立開發調試和構建
  • 不同子應用之間可以共同依賴 packages 內的公共依賴、公共組件、公共腳本

我們從開發流程來說起:當在根目錄下進行 yarn start app1 時,會啓動 appName 爲 app1 的項目,瀏覽器代開 locahost:3000 端口進行開發調試。這一系列過程是如何串聯起來的呢? yarn start app1 對應的腳本定義於 packages/script/* 目錄當中,其內容簡要爲:

process.env.NODE_ENV = 'development'

const [app] = process.argv.slice(2)

const config = {
  stdio: 'inherit',
  env: {
    ...process.env
  }
}

spawn.sync('monstro-scripts', ['clean'], config)
spawn('monstro-scripts', ['prebuild', '--watch'], config)
spawn(
  'npx',
  ['lerna', 'exec', 'npm', 'run', 'start', '--scope', `@monstro/app-${app}`],
  config
)

代碼很好理解,其實在 start 腳本中我們做了三件事情:

  • 串行執行 clean 腳本,進行一次新的構建前處理
  • clean 執行完後,並行執行 prebuild 腳本,對 pacakges 目錄中各個依賴項進行 babel 編譯,並傳遞 watch 參數
  • clean 執行完後,並行執行 app1 scope 內的 npm run start ,注意這個 npm run start 對應的 NPM Script 定義在 apps/app1/packages.json

其中代碼中 monstro-scripts 命令行預先定義在 packages/scripts 的 package.json 文件中:

"bin": {
    "monstro-scripts": "bin/monstro-scripts.js"
},

保證形如 spawn.sync('monstro-scripts', ['命令名稱'], 參數) 的腳本能夠正常執行。

讓我們來逐一分析:

開發者敲入 yarn start app1 後,先執行 clean 腳本,clean 腳本執行構建結果清理工作:

import rimraf from 'rimraf'


rimraf.sync('node_modules/.cache')
rimraf.sync('packages/*/lib')
rimraf.sync('apps/*/build')
rimraf.sync('apps/*/node_modules/.cache')

同時執行 spawn('monstro-scripts', ['prebuild', '--watch'], config) 腳本,prebuild 過程實際上是使用 @babel/cli 對依賴目錄 packages 內 src 目錄內容進行編譯,原地輸出到 lib 目錄中:

const args = process.argv.slice(2)

const packages = glob
  .sync(path.resolve(process.cwd(), 'packages/*'))
  .filter(name => readdirSync(name).includes('src'))

for (const pkg of packages) {
  spawn(
    'npx',
    [
      'babel',
      path.resolve(`${pkg}`, 'src'),
      '--out-dir',
      path.resolve(`${pkg}`, 'lib'),
      '--copy-files',
      '--config-file',
      path.resolve(__dirname, '../configs/babel.config.js'),
      '-x',
      ['.es6', '.js', '.es', '.jsx', '.mjs', '.ts', '.tsx'].join(','),
      ...args
    ],
    {
      stderr: 'inherit',
      env: {
        ...process.env,
        NODE_ENV: process.env.NODE_ENV || 'production'
      }
    }
  )
}

其中關於 Babel 的配置我們採用了 react-app 這個預設:

presets: [['react-app', { flow: false, typescript: true }]]

依然是同時執行:

spawn(
  'npx',
  ['lerna', 'exec', 'npm', 'run', 'start', '--scope', `@monstro/app-${app}`],
  config
)

這一步使用了 lerna exec 命令,該命令可以在每個包目錄下(apps/*)執行任意命令,我們到 apps/app1(@monstro/app-${app}) 下執行了 npm run start ,對應 app1 的 start 腳本定義在 apps/app1/packages.json 中:

"scripts": {
    "build": "react-scripts build",
    "start": "react-scripts start"
},

由此可知,我們最終是使用了 create-react-app 提供的 react-scripts 腳本完成了項目的開發構建,create-react-app 提供的 react-scripts 最終會打開瀏覽器,呈現應用內容,啓動持續化進程,監聽應用依賴樹上的任何變動,隨時進行重新構建。

總結一下, 一個 start 腳本構建流程如圖:

整體來看,這套流程架構兼顧了各子應用的獨立性,也充分尊重了子應用之間和依賴的關聯性,從而達到了較高的開發調試效率。在較長一段時間內,穩定爲中後臺系統賦能,支持一體化的開發、編譯、上線流程。

直到有一天收到開發者 A 同學的反饋:有時候短時間內連續開啓多個應用,會造成較高的內存佔用,電腦持續發熱並伴隨有較大風扇噪音。

這雖然是偶發的狀況,但是仍然得到了我們的重視。還原場景如:我先開發第一個應用 app1: yarn start app1 ,接着開發第二個應用: yarn start app2 ,再開發第二個應用: yarn start app3 ...

分析問題本質 找到優化方案

多應用同時開發時的內存成本持續上升的原因是什麼呢?

我們將上述過程通過兩個應用的啓動來進行演示:

關鍵點在於 prebuild 這一步。回顧一下 start 腳本中對於 prebuild 任務的啓動:

spawn('monstro-scripts', ['prebuild', '--watch'], config)

這裏使用 Babel 編譯 packages 下內容時,我們使用了 @babel/cli 的 --watch 這一參數。用前文說法, watch 模式的開啓將會創建一個可持續進程,監聽 packages 下文件內容的變動,並即時將編譯結果輸出到原地 lib 目錄中。

我們知道,對於每一個應用,我們使用了 react-script 構建開發應用,create-react-app 中 react-script 會內置 Webpack 配置,參看其源碼,可以找到內置 Webpack 配置的部分內容,配置有 webpack-dev-server 來幫助開發者啓動本地服務用於開發:

watchOptions: {
  ignored: ignoredFiles(paths.appSrc),
},

源碼: webpackDevServer.config.js

簡要對源碼進行說明: ignoredFiles(paths.appSrc) 是一個匹配項目 node_modules 的正則表達式,意味着 create-react-app 在持續性進程重新構建中會顯式地忽略 node_modules 目錄的變動。

module.exports = function ignoredFiles(appSrc) {
  return new RegExp(
    `^(?!${escape(
      path.normalize(appSrc + '/').replace(/[\\]+/g, '/')
    )}).+/node_modules/`,
    'g'
  );
};

這麼做的原因主要是考慮到監聽 node_modules 全量內容時的性能損耗的性價比。畢竟在 create-react-app 早期在全量監聽 node_modules 時,某些系統(OS X)上會偶現 CPU 使用率過高的問題。具體 issues:

目前 create-react-app 對於監聽 node_modules 這件事情所採用的策略非常聰(雞)明(賊):如果 node_modules 加入一個新的依賴包,仍然會被監聽到,從而觸發 create-react-app 重新構建,這個是依賴 WatchMissingNodeModulesPlugin 插件實現的,在 create-react-app 源碼文件 webpack.config.js 中:

isEnvDevelopment &&
new WatchMissingNodeModulesPlugin(paths.appNodeModules),

總之,create-react-app 中 react-script 腳本使用了 webpack-dev-server,這樣也同樣開啓了一個可持續進程,監聽當前應用上依賴樹關係的任何變動,以便隨時重新進行構建。

距離“破案”越來越近了。我們想,當我們在已經啓動 app1 並觸發 webpack-dev-server watch 監聽後,再次啓動 app2,app2 的 start 流程不可避免地進行 prebuild 腳本,使得 packages 目錄下產生了變動,這個變動反過來會影響 app1,被 app1 所對應的 webpack-dev-server 進程捕獲到變動,進而重新構建 app1(我們這裏默認所有的業務項目都依賴了 packages 目錄內容,實際上這也是 99% 的場景)。這樣循環下去,如果我們同時開啓 K 個應用,當再次開啓 K + 1 個應用時( yarn start ${appK+1} ),因爲不可避免地觸發了 packages 目錄變動, 前面 K 個應用都將會同時重新構建 。這就意味着更大的內存消耗。

如下圖,我們以開啓第四個應用項目爲例:

我把這個問題稱之爲——「多應用多持續性進程間的構建消耗」問題。

互斥鎖和鎖競爭的解決之道

如何解決這個「多應用多持續性進程間的構建消耗」問題呢?首先,create-react-app 中 react-script 腳本的 webpack-dev-server 的 watch 配置一定是我們預期當中的:因爲我們希望在應用項目中,有相關文件改動,即重新構建。其次 react-script 腳本由 create-react-app 封裝,且不暴露配置 webpack-dev-server 的能力,同時 eject create-react-app 是我們永遠不想做的, 因此改動 react-script 腳本的思路不可行

關鍵當然是在 prebuild 流程,我們再次提及問題核心是:第一次啓動 app1 之後,經過 prebuild,我們已經產出了編譯後的 packages/*/lib目錄,因此後續啓動的所有應用都不需要再次觸發 prebuild。思路如此,對應圖示爲:

但是 start 腳本是統一的,我們該如何改造呢?僞代碼如下:

process.env.NODE_ENV = 'development'

const [app] = process.argv.slice(2)

const config = {
  stdio: 'inherit',
  env: {
    ...process.env
  }
}

spawn.sync('monstro-scripts', ['clean'], config)

if (prebuild 過程已經成功執行 !== true) {
    spawn('monstro-scripts', ['prebuild', '--watch'], config)
}

spawn(
  'npx',
  ['lerna', 'exec', 'npm', 'run', 'start', '--scope', `@monstro/app-${app}`],
  config
)

我們給 spawn('monstro-scripts', ['prebuild', '--watch'], config) 加上了一個判斷條件,在已經成功 prebuild 後,跳過後續所有 prebuild 流程。但是 prebuild 過程已經成功執行 這個變量應該如何設計呢?

每一個 start 腳本對應一個不同且獨立的持續性進程任務,因此 prebuild 過程已經成功執行 這個變量應該能夠被不同進程都訪問到,這是一個典型的多進程間通信問題。 歷數 IPC 的幾種方式,其實都並不完全適合我們的場景。其實針對我們的問題,似乎用一個 文件鎖 更好。以開源庫 jsonfile 爲例,我們把 prebuild 結果標記在一個 json 文件中,似乎是一個合適的選擇。僞代碼:

const jsonfile = require('jsonfile')

process.env.NODE_ENV = 'development'

const [app] = process.argv.slice(2)

const config = {
  stdio: 'inherit',
  env: {
    ...process.env
  }
}

spawn.sync('monstro-scripts', ['clean'], config)

if (jsonfile.readFileSync(file).status !== 'success') {
    spawn('monstro-scripts', ['prebuild', '--watch'], config)
    jsonfile.writeFileSync(file, {status: 'success'})
}

spawn(
  'npx',
  ['lerna', 'exec', 'npm', 'run', 'start', '--scope', `@monstro/app-${app}`],
  config
)

此時 start 腳本流程如圖:

這裏插一個細節,初期設計我們認爲文件鎖狀態應該有 3 種:

{
    status: 'success'/'running'/'fail'
}

如果 prebuild 流程正在構建或構建失敗,仍然要繼續執行 spawn('monstro-scripts', ['prebuild', '--watch'], config) 。事實上,這是完全沒有必要的,因爲 Babel 的編譯是一個持續性進程,開啓 watch 選項,這樣開發者可以始終在編譯進行中和編譯失敗中得到信息,進行修復。文件鎖內容完全可以樂觀更新,而後續樂觀可行性保障由開發者負責。

但卻還有另外一個重要問題需要考慮:在初期架構設計中,每個應用的啓動,都使用了 Babel 持續性編譯進程,進行 watch 監聽,這樣當開發者手動殺死(Ctrl + C)一個終端進程後:比如不再需要 app1 的開發,殺死 app1 後,就沒有任何應用能夠監聽 packages 的變化了。理想狀態下,我們需要啓動另外一個應用進程,去監聽着 packages 文件夾的變動,進而觸發 packages 的持續編譯。

如何理解呢?請參考上圖,在我們新改進的流程中:app1 的啓動中執行了 spawn('monstro-scripts', ['prebuild', '--watch'], config) ,後續的 appN 不再有 prebuild 過程,也就不在監聽 packages 文件夾的變動,此時,如果開發者手動殺死(Ctrl + C)第一個應用(即 app1),那麼開發者再對 packages 內代碼進行改動,就不會觸發 Babel 編譯,任何應用都不在有相應,此時狀態如下:

如何解決這個問題?這就涉及到了 競爭鎖 的概念。

鎖競爭常出現在多線程編程中,熟悉 Java 併發機制的讀者可能對這個概念並不陌生。簡單來說,同一個進程裏線程是數共享的,當各個線程訪問數據資源時會出現競爭狀態,即數據幾乎同步會被多個線程佔用,造成數據混論,即所謂的線程不安全。那怎麼解決多線程問題,就是鎖了。

切換到另一種語言,Python 提供的對線程控制的對象,其中包括有互斥鎖、可重入鎖、死鎖等。互斥鎖概念,是用來保證共享數據操作的完整性。這個標記用來保證在任一時刻,只能有一個線程訪問該對象。

按照這個思路,我們進行擴展,通過互斥鎖和鎖競爭,實現這樣的機制:第一個應用啓動時,在 prebuild 階段對該進程的終止進行監聽,在監聽到 Babel 持續性進程終止時,改寫文件鎖內容 status 爲 available;同時之後的每一個應用啓動時,都加入輪詢腳本,輪詢內容即爲對文件鎖 status 值的查詢,一旦查詢到 status === 'available' ,說明相關監聽 Babel 編譯的進程結束,需要“我”來接管。具體操作是:將 status 值置爲 'success',同時開啓 prebuild 流程( spawn('monstro-scripts', ['prebuild', '--watch'], config) )。整個過程概括爲:

  • 第一個應用負責 prebuild,負責 Babel 持續性進程來監聽 packages 目錄的改動。同時監聽該進行的終止事件
  • 第一個應用一旦監聽到進程終止,則改寫文件鎖 status 狀態爲 available,釋放 prebuild 流程控制權
  • 其他應用通過啓動時的輪詢機制,競爭被釋放的 prebuild 流程控制權
  • 其他應用誰先競爭到 prebuild 流程控制權,就通過改寫文件鎖 status 狀態爲 success,進行鎖定

流程如下圖:

工程從來不只是個技術問題

上述使用「鎖」的方案雖然稍顯複雜,但似乎能夠從技術上給出較徹底完備的解法了。可是在項目工程上,這真是我想要的麼?

讓我們回到問題的最初始:「start 這個腳本開啓兩個子進程,其中對 packages 目錄進行 watch 並增量編譯的腳本會影響並觸發 create-react-app 進程的重新構建。在多應用同時開發的情況下,這種影響是指數疊加的,從而導致了內存的重複消耗」。這是項目已用的設計,我不禁要想,「將 start 腳本中的 create-react-app 進程和 babel 增量編譯進程解耦,似乎是很自然而然的做法」。如下圖:

這樣的啓動流程排除了 babel 進程和 create-react-app 進程之間的相互干擾,從根源上解決了問題。但是它的「副作用」是:需要開發者在啓動應用時,先執行 yarn prebuild 的 script,再執行 yarn start appX 。相比於之前的「一鍵無腦啓動」,多了一個終端 tab 和腳本執行過程,且要求開發者知曉這麼做的目的以及意義:當修改 packages 目錄下內容,並由於各種原因中斷 prebuild babel 進程後,開發者要知道需要重啓 yarn prebuild 進程。這些「信息量」我們可以通過 README 來進行說明和指導,並在喪失 prebuild 進程持續執行時,進行中斷友好提示。相比上述純技術向的「鎖」方案,這樣的設計更「取巧」。我認爲,工程從來不只是個技術問題,不鑽牛角尖,多角度思考,往往有「四兩撥千斤」的效用。

實際上,當初將 prebuild babel watch 進程作爲 start 進程的子進程設計也是有一定道理的,這裏不再展開(這是一個設計取捨問題)。

總結

這篇文章討論了兩個核心問題:

  • 多項目應用開發架構設計
  • 多進程多應用間構建流程優化設計

對於大型複雜應用的開發和構建設計——這一話題,我們結合實際生產中的項目,分析並給出了一個較爲“完美”的方案。在這個方案的基礎上,論證並解決了 Monorepo 化的項目在遇見多進程複雜構建流程時的一個“小尷尬”。整個過程中,爲了發現問題,解決問題,我們深入剖析了 create-react-app 和 Webpack 的源碼及設計,同時討論了持續化進程,最終通過互斥鎖和鎖競爭找到了靈感,實現了迭代和優化。

當然,這其中也涉及到很多其他有趣的問題,大型複雜應用的開發和構建關聯到基建的方方面面,爲此我們會持續輸出這方面的技術經驗和心得,請大家訂閱內容。

Happy coding!

相關文章