Turborepo 核心概念 - 任務 Caching

Turborepo 核心概念 - 任務 Caching

幾乎大部分 JavaScript 或是 TypeScript 程式碼都有 package.json 腳本, 像是 build, testlint。 在 Turborepo 把他們稱作 tasks (任務)

Turborepo 可以快取這些任務的結果跟 logs 使一些太花時間的任務可以大幅提升速度。

沒找到對應的 Cache 時

每個任務都有其 inputs 輸入outputs 輸出

像是 build 可能是用 原始檔案 作為其 input, 並用打包後的檔案以及 stdout / stderr (標準輸出) 中 的 log 紀錄 作為 output。

lint 或是 test 也可能是用 原始檔案 作為其 input, 並以 stdout / stderr (標準輸出) 的 log 作為 output。

接下來我們用 build 作為例子來使用 Turborepo

  1. Turborepo 會評估 build 用到的 input (預設是專案中所有沒有被 gitignored 的檔案。) 並將他們轉換成 hash

  2. 我們可以查看本地端檔案系統中的 cache, cache 會被放在用 hash 命名的資料夾底下 (e.g../node_modules/.cache/turbo/78awdk123)。

  3. 如果 Turborepo 沒有找到對應的 hash 產出, 就會執行該任務。

  4. 一旦任務結束,Turborepo 會儲存所有 outputs (包含檔案跟 log) 到 cache 裏。

Turborepo 會使用多種資訊來建立 hash , 像是 原始檔案,環境變數 甚至是 依賴套件的原始檔。

有找到對應的 Cache 時

假設我們重新執行任務但沒有異動任何 input:

因為 input 沒有改變所以 hash 會相同 (e.g. 78awdk123)。

  1. Turborepo 會在 cache 中找到相同名稱的 hash 資料夾 (e.g. ./node_modules/.cache/turbo/78awdk123)。

  2. Turborepo 會直接重用 cache 的結果,不會執行該任務。 就是輸出儲存的 logs 到標準輸出, 以及重新使用儲存的 output 檔案到我們預期的檔案位置。

  3. 從 cache 中重用檔案跟 logs 幾乎不用花時間。 它可以讓你的建構時間從幾分鐘或是幾小時縮減到幾秒或是幾毫秒。

根據程式碼依賴圖的形狀和粒度,具體結果會有所不同, 但大多數團隊發現, 使用 Turborepo 的 Cache 可以使整個月的構建時間縮短約 40-85%。

調整 Cache Outputs 參數

使用 pipeline,你可以配置整個 Turborepo 的緩存設定。

要覆蓋預設的 cache output 行為, 可以配置 glob 列表到 pipeline.<task>.outputs

所有匹配 glob 的檔案都會被視作該任務的產出。

{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      "outputs": ["dist/**", ".next/**"],
      "dependsOn": ["^build"]
    },
    "test": {
      "outputs": [], // leave empty to only cache logs
      "dependsOn": ["build"]
    }
  }
}

如果該任務並不會產生任何檔案 (像是 Jest 的單元測試), 可以將 outputs 設置成 空陣列 (就是 []), Turborepo 只會 cache 輸出的 log。

當執行 turbo run build test 時, Turborepo 會執行你的 buildtest 腳本, 並 cache 他們的 output 進 ./node_modules/.cache/turbo

Cache ESLint 的進階訣竅: 你可以在 ESLint 的指令前面加上 TIMING=1 參數, 產生漂亮的 terminal output (包含沒有 error 的時候)。 可以到 ESLint 的文件裡面看詳細

調整 Cache Inputs 參數

當專案中任何文件有異動,會被視為該專案已被改變。 然而針對某些任務,我們可能只希望有相關的檔案異動時才去重跑。 透過標註 inputs 讓我們可以定義哪些檔案與哪個任務有相關。

例如說, 下面的 test 任務, 只需要在 src/test/ 中的 .tsx 或是 .ts 檔案跟上次執行不同的時候執行。

{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    // ... omitted for brevity

    "test": {
      // A workspace's `test` task depends on that workspace's
      // own `build` task being completed first.
      "dependsOn": ["build"],
      "outputs": [],
      // A workspace's `test` task should only be rerun when
      // either a `.tsx` or `.ts` file has changed.
      "inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts"]
    }
  }
}

關閉 Caching

有的時候,你真的不想產生 cache, 像是你在使用 next dev 或是 react-scripts start 開啟 live reloading。

加上 --no-cache 在任意指令後面可以取消 cache:

# Run `dev` npm script in all workspaces in parallel,
# but don't cache the output
turbo run dev --parallel --no-cache

注意,--no-cache 雖然可以取消 cache 產生, 但是並沒有取消 cache 讀取。 如果你想要取消 cache 讀取可以使用 --force

你也可以取消特定任務的 cache, 透過將 pipeline.<task>.cache 設定成 false

{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "dev": {
      "cache": false
    }
  }
}

調整檔案異動觸發 Cache 的規則

針對某些任務, 你可能不希望因為不相關的檔案異動導致不能使用到 cache。

例如,更新 README.md 可能不需要重新觸發 test 任務。 你可以使用 inputs 來限制 turbo 需要考慮哪些檔案。

在這個情況下, test 任務會不會使用 cache 就只需要考慮相關的 .ts 或是 .tsx 檔案。

{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    // ...other tasks
    "test": {
      "outputs": [], // leave empty to only cache logs
      "dependsOn": ["build"],
      "inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts"]
    }
  }
}

package.json 永遠都會被視作任務的 input。 因為任務本身被寫在 package.json 裏,作為腳本的 key。 當你更改package.json,所有已產生的 cache output 都會失效。

如果你希望所有的任務都依賴於某個特定檔案, 你可以在 globalDependencies 陣列中指定。

{
  "$schema": "https://turbo.build/schema.json",
+  "globalDependencies": [".env"],
  "pipeline": {
    // ...other tasks
    "test": {
      "outputs": [], // leave empty to only cache logs
      "dependsOn": ["build"],
      "inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts"]
    }
  }
}

turbo.json 永遠會被視作全域依賴。 如果你異動了 turbo.json,所有已產生的 cache 就會失效。

調整根據環境變量產生 cache 的相關規則

如果你會在編譯時將環境變數直接寫進編譯工具(例如 Next.js 或 Create React App),那切記要告知 turbo 這些參數。 否則,可能會導致編譯時使用錯誤的環境變數!

你透過環境變數的數值可以控制 turbo 的 cache:

  • 將環境變數加入到 pipeline 設定中的 env, 就可以影響每個任務的 cache fingerprint。

  • 在所有的任務中,任何名字中帶有 THASH 的環境變數都會影響 cache fingerprint。

{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      // env vars will impact hashes of all "build" tasks
      "env": ["SOME_ENV_VAR"],
      "outputs": ["dist/**"]
    },

    // override settings for the "build" task for the "web" app
    "web#build": {
      "dependsOn": ["^build"],
      "env": [
        // env vars that will impact the hash of "build" task for only "web" app
        "STRIPE_SECRET_KEY",
        "NEXT_PUBLIC_STRIPE_PUBLIC_KEY",
        "NEXT_PUBLIC_ANALYTICS_ID"
      ],
      "outputs": [".next/**"]
    }
  }
}

宣告環境變數在 dependsOn 並配置 $ 前綴已經被 deprecated 了。

你可以宣告環境變數在 globalEnv 陣列裏, 所有的任務都會關注他來產生 cache:

{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      // env vars will impact hashes of all "build" tasks
      "env": ["SOME_ENV_VAR"],
      "outputs": ["dist/**"]
    },

    // override settings for the "build" task for the "web" app
    "web#build": {
      "dependsOn": ["^build"],
      "env": [
        // env vars that will impact the hash of "build" task for only "web" app
        "STRIPE_SECRET_KEY",
        "NEXT_PUBLIC_STRIPE_PUBLIC_KEY",
        "NEXT_PUBLIC_ANALYTICS_ID"
      ],
      "outputs": [".next/**"],
    },
  },
+  "globalEnv": [
+    "GITHUB_TOKEN" // env var that will impact the hashes of all tasks,
+  ]
}

自動包含環境變量

為了確保跨環境獲得正確的 cache, Turborepo 會自動推斷我們使用的框架, 並自動包含該框架會使用到的公開環境變數 來建立的應用程式的 cache key。

turbo.json, 你可以安心的省略框架特定的的公開環境變數:

{
  "pipeline": {
    "build": {
      "env": [
-       "NEXT_PUBLIC_EXAMPLE_ENV_VAR"
      ]
    }
  }
}

注意,這個自動偵測跟包含只有在 Turborepo 成功推斷出你的應用程式使用什麼框架時才有用。 Turborepo 可以偵測的框架與環境變數如下:

  • Astro: PUBLIC_*

  • Blitz: NEXT_PUBLIC_*

  • Create React App: REACT_APP_*

  • Gatsby: GATSBY_*

  • Next.js: NEXT_PUBLIC_*

  • Nuxt.js: NUXT_ENV_*

  • RedwoodJS: REDWOOD_ENV_*

  • Sanity Studio: SANITY_STUDIO_*

  • Solid: VITE_*

  • Vue: VUE_APP_*

  • SvelteKit: VITE_*

  • Vite: VITE_*

有些例外沒有標記在上述列表上。 基於諸多原因, 即便我們在編譯過程中沒有用到, 部分 CI 系統 (包含 Vercel) 也會配置這些帶有前綴的環境變數。 這會使異動變的無法預判 - 使得每次編譯都會註銷 Turborepo 的 cache。

針對這點,Turborepo 使用 TURBO_CI_VENDOR_ENV_KEY 變數,讓 Turborepo 不去推斷這些環境變數。 例如,Vercel 設置了 NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA。 這個數值每次編譯都會改變, 所以 Vercel 會設置 TURBO_CI_VENDOR_ENV_KEY="NEXT_PUBLIC_VERCEL_" 用於排除那些變數。

幸運的, 你在其他建構系統才需要考慮這些問題, 當你在 Vercel 上使用 Turborepo 時不用考慮這些。

關於 monorepos 的註記

只有在某個任務使用到的框架, 有使用到的環境變數才會被包含到 cache key。

換句話說,假設使用 Next.js,其包含環境變數的 cache key, 只會存在在被偵測到使用 Next.js 的工作區。 其他工作區的任務則不受影響。

例如,假設有個 monorepo 有三個工作區: 一個 Next.js 專案, 一個 Create React App 專案, 一個 TypeScript 專案。

每個專案都有 build 腳本, 且應用程式部分都依賴於 TypeScript 專案。

讓我們假設這個 Turborepo 有標準的 turbo.json pipeline 可以讓他們按照順序編譯:

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"]
    }
  }
}

在 1.4 版之後, 當你執行 turbo run build 時, Turborepo 在構建 TypeScript 專案時, 不會考慮任何編譯時期的環境變量。

但是,在構建 Next.js 應用時, Turborepo 將推斷 NEXT_PUBLIC_ 開頭的環境變量 可能會改變 .next 資料夾的輸出, 因此在做 hash 運算時會包含這些變量。 同樣,在計算 Create React App 的構建腳本的 hash 時, 將包括以 REACT_APP_PUBLIC_ 開頭的所有編譯時期環境變量。

eslint-config-turbo

我們可以使用 eslint-config-turbo 輔助我們偵測沒用到的依賴套件被打包, 且幫助我們確定 Turborepo 的 cache 有被正確的分配在多個環境,

雖然自動包含環境變量應該能處理大多數情況和大多數框架, 但這個 ESLint 配置 將為使用其他構建時間 inline 環境變量的團隊提供即時反饋。 這也幫助支援那些無法使用自動偵測的框架的團隊。

為了啟用, 我們要讓根目錄的 eslintrc 繼承 eslint-config-turbo

{
  // Automatically flag env vars missing from turbo.json
  "extends": ["turbo"]
}

為了進行更多控制, 我們可以下載 eslint-plugin-turbo 並配置在 plugin 裏, 並添加我們需要的規則:

{
  "plugins": ["turbo"],
  "rules": {
    // Automatically flag env vars missing from turbo.json
    "turbo/no-undeclared-env-vars": "error"
  }
}

當你在程式碼中使用非框架相關的環境變量且沒有被標注在 turbo.json時,plugin 就會進行警告。

沒辦法捕捉到的環境變量

雖然 Turborepo 都運行在我們想執行的任務之前, 但確實有可能在 turbo 已經計算完 hash 之後, 才建立或是異動環境變數。

例如以下 package.json

{
  "scripts": {
    "build": "NEXT_PUBLIC_GA_ID=UA-00000000-0 next build",
    "test": "node -r dotenv/config test.js"
  }
}

在這個例子, turbo 在調用 build 之前,已經做完 hash 的運算了, 所以無法發現 NEXT_PUBLIC_GA_ID=UA-00000000-0 環境變量, 因此無法區分 cache 是否有包含該變量, 在 dotenv 配置的環境變量也是會有這個問題。

所以要小心在調用 turbo 之前要配置好所有環境變量!

強制覆寫 cache

如果你想要 turbo 不要去讀 cache 或是強制重新計算 cache, 可以在指令後面加上 --force

# Run `build` npm script in all workspaces,
# ignoring cache hits.
turbo run build --force

注意到 --force 關閉了 cache 讀取但沒有關閉 cache 產生, 如果你想要關閉 cache 產生,使用 --no-cache

Logs

turbo 不只是會 cache 任務的 output, 他同樣會紀錄 terminal 的 output (就是 stdout/stderr), 並將它記錄在 <package>/.turbo/run-<command>.log。 當 turbo 判斷可以使用 cache 時, 他就會將紀錄的 output 直接再拿出來用。

Hashing

現在,你可能想知道 turbo 是如何決定該任務有 cache 還是 沒 cache。

首先,turbo 會使用當前專案的全域狀態來建立 hash:

  • 所有檔名符合標註在 globalDependencies 的 glob pattern, 以及環境變數。

所有名字帶有 THASH 的環境變量。 (像是 STRIPE_PUBLIC_THASH_SECRET_KEY, 但沒有 STRIPE_PUBLIC_KEY)

然後,它加入更多跟當前工作區任務有關的變因:

  • 將工作區中所有有進到版本控制的檔案內容進行 hash, 或者如果有提供 input glob 的話,將符合的檔案內容進行 hash。

  • 所有內部依賴關係的 hash

  • 在 pipeline 中指定的 output

  • 在根目錄 package.json 的 lockfile 中標註的, 所有 dependencies、devDependencies 和 optionalDependencies 的版本

  • 工作區任務的名稱

  • 標記在 pipeline.<task-or-package-task>.dependsOn 的環境變量名稱與其對應的環境變量數值。

一旦 turbo 在執行某個工作區的任務, 它就會在 cache(本地和遠端)中檢查是否有匹配的 hash。

如果匹配,則會跳過執行該任務, 將 cache 的 output 移動或下載到指定位置, 並重現之前記錄的 log。

如果 cache(本地或遠端)中沒有任何與計算出的 hash 匹配的項目, 則 turbo 將在本地端執行任務, 然後輸出 cache 並使用 hash 作為其索引。

在任務執行中,可以從環境變量 TURBO_HASH 得到當前 hash, 這個值在 output 或 Dockerfile 用來打 label 時很有用。

自 turbo v0.6.10 起, 使用 npmpnpm 時, turbo 的 hash 算法會略有不同。 使用這些套件管理, turbo 會將在每個工作區任務的 lockfile 內容包含進 hash 算法。 不會像當前的 yarn 那樣分析出所有依賴關係的集合。

Did you find this article valuable?

Support Hello Kayac by becoming a sponsor. Any amount is appreciated!