alicdn邊緣節點不穩定導致頁面崩潰問題

記得要微笑發表於2022-12-29

問題概述

某工作日,線上某使用者向客服專員反饋沒法正常訪問“檢視報價頁面”,頁面內容沒有呈現。客服專員收到反饋後,將問題轉交給SRE處理。很奇怪的是,SRE訪問生產環境“檢視報價頁面”顯示正常,為了進一步分析定位問題,SRE向使用者申請了遠端操作,將將一些具有價值的資訊記錄下來,主要有以下兩個方面:

  • 使用者訪問“檢視報價頁面”存在樣式和字型檔案沒有載入成功;

    img-20221227231329.png

  • 沒有載入成功的字型和樣式檔案的請求域名並不是公司的,而是公網免費的域名(at.alicdn.com、g.alicdn.com);

分析與定位

透過上述資訊,可以知道使用者與SRE訪問頁面的差異,SRE訪問“檢視報價頁面”可以正常獲取所有資源,而使用者無法獲取部分字型和樣式檔案。根據瀏覽器載入渲染原理,部分字型和樣式載入失敗大機率不會導致頁面DOM無法呈現,無法下結論之時,不妨先假設字型和樣式檔案影響到了DOM渲染。

當無法從表象分析出線上問題原因時,第一步需要在開發環境或者測試環境復現問題場景,然後排查從請求資源到頁面渲染的執行過程。

問題的引入點:域名解析

在復現場景之前,需要先知道訪問成功和失敗之間的差異。透過收集到的資訊來看,請求域名解析的IP有明顯不同:

  • 正常訪問資源,DNS域名解析
Request URLRemote Address
https://at.alicdn.com/t/font_1353866_klyxwbettba.css121.31.31.251:443
https://g.alicdn.com/de/prismplayer/2.9.21/skins/default/aliplayer-min.css119.96.90.252:443
https://at.alicdn.com/t/font_2296011_yhl1znqn0gp.woff2121.31.31.251:443
https://at.alicdn.com/t/font_1353866_klyxwbettba.woff2?t=1639626666505121.31.31.251:443
  • 生產環境請求資源失敗,DNS域名解析

    • at.alicdn.com116.153.65.231
    • g.alicdn.com211.91.241.230

使用者和SRE所處地區不同,訪問資源時域名解析命中的邊緣節點服務也會不同,而at.alicdn.comg.alicdn.com是公網免費的CDN域名,某些邊緣節點服務穩定性不夠,拉取不到資源也是可能發生的。

問題根本原因:模組載入

開發環境與測試環境復現差異

修改本地hosts,新增使用者域名解析的地址對映,在測試環境和開發環境嘗試復現。兩個環境均不能獲取到字型和樣式檔案,測試環境(https://ec-hwbeta.casstime.com)頁面內容沒有呈現(復現成功),開發環境頁面內容正常呈現(復現失敗),分析開始陷入衚衕。

開發環境:

img-20221227231351.png

測試環境:

img-20221227231405.png

這時候就要開始分析了,兩個環境復現問題的差異點在哪裡?

不難發現,兩個環境最主要的區別在於yarn startyarn build的區別,也就是構建配置的區別。

開發環境

1、create-react-app關鍵構建配置

  • 啟用style-loader,預設透過style標籤將樣式注入到html中;
  • 不啟用MiniCssExtractPlugin.loader分離樣式和OptimizeCSSAssetsPlugin壓縮樣式;
  • 啟用optimization.splitChunks程式碼分割;
  • 啟用optimization.runtimeChunk抽離webpack執行時程式碼;
const getStyleLoaders = (cssOptions, preProcessor) => {
  const loaders = [
    isEnvDevelopment && require.resolve('style-loader')
    isEnvProduction && {
      loader: MiniCssExtractPlugin.loader,
      // css is located in `static/css`, use '../../' to locate index.html folder
      // in production `paths.publicUrlOrPath` can be a relative path
      options: paths.publicUrlOrPath.startsWith('.')
        ? { publicPath: '../../' }
        : {},
    },
  ].filter(Boolean);
  
  return loaders;
}

module: {
  rules: [
    {
      oneof: [
        {
          test: cssModuleRegex,
          use: getStyleLoaders({
            importLoaders: 1,
            sourceMap: isEnvProduction && shouldUseSourceMap,
            modules: {
              getLocalIdent: getCSSModuleLocalIdent,
            },
          }),
        },
        {
          test: sassModuleRegex,
          use: getStyleLoaders(
            {
              importLoaders: 3,
              sourceMap: isEnvProduction && shouldUseSourceMap,
              modules: {
                getLocalIdent: getCSSModuleLocalIdent,
              },
            },
            'sass-loader'
          ),
        },
      ]
    }
  ]
}
  
optimization: {
  minimize: isEnvProduction,
  minimizer: [
      // 壓縮css
    new OptimizeCSSAssetsPlugin({
      cssProcessorOptions: {
        parser: safePostCssParser,
        map: shouldUseSourceMap
          ? {
              // `inline: false` forces the sourcemap to be output into a
              // separate file
              inline: false,
              // `annotation: true` appends the sourceMappingURL to the end of
              // the css file, helping the browser find the sourcemap
              annotation: true,
            }
          : false,
      },
    })
  ],
  // Automatically split vendor and commons
  // https://twitter.com/wSokra/status/969633336732905474
  splitChunks: {
    chunks: 'all',
    name: false,
  },
  // Keep the runtime chunk separated to enable long term caching
  runtimeChunk: {
    name: entrypoint => `runtime-${entrypoint.name}`,
  },  
}

img-20221227231424.png

css-loader在解析樣式表中@importurl()過程中,如果index.module.scss中使用@import 引入第三方樣式庫aliplayer-min.css@import aliplayer-min.css部分和index.module.scss中其餘部分將會被分離成兩個module,然後分別追加到樣式陣列中,陣列中的每個”樣式項“將被style-loader處理使用style標籤注入到html

img-20221227231434.png

img-20221227231443.png

img-20221227231453.png

img-20221227231504.png

2、執行鏈路

開發環境的構建配置基本清楚,再來看看執行流程。執行yarn start啟用本地服務,localhost:3000訪問“檢視報價頁面”。首先會經過匹配路由,然後react-loadable呼叫webpack runtime中載入chunk的函式__webpack_require__.e,該函式會根據入參chunkId使用基於promise實現的script請求對應chunk,返回Promise<pending>。如果Promise.all()存在一個Promise<pending>轉變成Promise<rejected>,那麼Promise.all的執行結果就是Promise<rejected>。因為css chunk是透過style標籤注入到html中,所以__webpack_require__.e只需要載入js chunk,當所有的js chunk都請求成功時,Promise.all的執行結果就是Promise<fulfilled>fulfilled狀態會被react-loadable中的then捕獲,更新元件內部狀態值,觸發重新渲染,執行render函式返回jsx element物件。因此,內容區域正常顯示。

img-20221227231514.png

img-20221227235144.png

生產環境

1、create-react-app關鍵構建配置

  • 不啟用style-loader,預設動態建立link標籤注入樣式;
  • 啟用了MiniCssExtractPlugin.loader分離樣式;
  • 啟用optimization.splitChunks程式碼分割;
  • 為了更好的利用瀏覽器強快取,設定optimization.runtimeChunk,分離webpack runtime
const getStyleLoaders = (cssOptions, preProcessor) => {
  const loaders = [
    isEnvDevelopment && require.resolve('style-loader')
    isEnvProduction && {
      loader: MiniCssExtractPlugin.loader,
      // css is located in `static/css`, use '../../' to locate index.html folder
      // in production `paths.publicUrlOrPath` can be a relative path
      options: paths.publicUrlOrPath.startsWith('.')
        ? { publicPath: '../../' }
        : {},
    },
  ].filter(Boolean);
  
  return loaders;
}

module: {
  rules: [
    {
      oneof: [
        {
          test: cssModuleRegex,
          use: getStyleLoaders({
            importLoaders: 1,
            sourceMap: isEnvProduction && shouldUseSourceMap,
            modules: {
              getLocalIdent: getCSSModuleLocalIdent,
            },
          }),
        },
        {
          test: sassModuleRegex,
          use: getStyleLoaders(
            {
              importLoaders: 3,
              sourceMap: isEnvProduction && shouldUseSourceMap,
              modules: {
                getLocalIdent: getCSSModuleLocalIdent,
              },
            },
            'sass-loader'
          ),
        },
      ]
    }
  ]
}

optimization: {
  minimize: isEnvProduction,
  minimizer: [],
  // Automatically split vendor and commons
  // https://twitter.com/wSokra/status/969633336732905474
  splitChunks: {
    chunks: 'all',
    name: false,
  },
  // Keep the runtime chunk separated to enable long term caching
  runtimeChunk: {
    name: entrypoint => `runtime-${entrypoint.name}`,
  },
},

plugins: [
  // Generates an `index.html` file with the <script> injected.
  new HtmlWebpackPlugin(
    Object.assign(
      {},
      {
        inject: true,
        template: paths.appHtml,
      },
      isEnvProduction
        ? {
            minify: {
              removeComments: true,
              collapseWhitespace: true,
              removeRedundantAttributes: true,
              useShortDoctype: true,
              removeEmptyAttributes: true,
              removeStyleLinkTypeAttributes: true,
              keepClosingSlash: true,
              minifyJS: true,
              minifyCSS: true,
              minifyURLs: true,
            },
          }
        : undefined
    )
  ),
  // Inlines the webpack runtime script. This script is too small to warrant
  // a network request.
  // https://github.com/facebook/create-react-app/issues/5358
  isEnvProduction &&
    shouldInlineRuntimeChunk &&
      // 將執行時程式碼內聯注入到html中
    new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/runtime-.+[.]js/]),
]

設定optimization.runtimeChunk,將webpack runtime(執行時程式碼,管理chunk依賴關係和載入)單獨打包出來,這樣就不會因為某個chunk的變更導致依賴該chunkchunk也變更(檔名hash改變),從而導致瀏覽器快取失效。

img-20221227231625.png

因為啟用了MiniCssExtractPlugin.loader分離樣式,@import "aliplayer-min.css"將被分離到一個css chunk中,所以aliplayer-min.css請求鏈有三級

img-20221227231650.png)

img-20221227231702.png

2、執行鏈路

在分析執行鏈路之前,先將生產環境構建配置中的程式碼壓縮功能註釋掉,方便閱讀和除錯原始碼

optimization: {
  minimize: false, // 改成false,禁用壓縮
  minimizer: [],
  // Automatically split vendor and commons
  // https://twitter.com/wSokra/status/969633336732905474
  splitChunks: {
    chunks: 'all',
    name: false,
  },
  // Keep the runtime chunk separated to enable long term caching
  runtimeChunk: {
    name: entrypoint => `runtime-${entrypoint.name}`,
  },
},

plugins: [
  // Generates an `index.html` file with the <script> injected.
  new HtmlWebpackPlugin(
    Object.assign(
      {},
      {
        inject: true,
        template: paths.appHtml,
      },
      isEnvProduction
        ? {
            minify: {
              removeComments: true,
              // collapseWhitespace: true,
              // removeRedundantAttributes: true,
              // useShortDoctype: true,
              // removeEmptyAttributes: true,
              // removeStyleLinkTypeAttributes: true,
              // keepClosingSlash: true,
              // minifyJS: true, // 不壓縮注入到html中的js
              // minifyCSS: true, // 不壓縮注入到html中的css
              // minifyURLs: true,
            },
          }
        : undefined
    )
  ),
]

執行yarn build,得到構建產物,在build目錄下啟用服務http-server -p 3000。為了跨域訪問測試環境服務,本地安裝nginx配置反向代理,localhost:4444埠訪問“檢視報價頁面”即可在本地訪問,跟測試環境一樣。

server {
    listen       4444;
  server_name  localhost;

  location /maindata {
      proxy_pass https://ec-hwbeta.casstime.com;
  }
  location /market {
      proxy_pass https://ec-hwbeta.casstime.com;
  }
  location /agentBuy {
      proxy_pass https://ec-hwbeta.casstime.com;
  }
  location /mall {
      proxy_pass https://ec-hwbeta.casstime.com;
  }
  location /inquiryWeb {
      proxy_pass https://ec-hwbeta.casstime.com;
  }
  location /cart {
      proxy_pass https://ec-hwbeta.casstime.com;
  }
  location /msg {
      proxy_pass https://ec-hwbeta.casstime.com;
  }
  location /webim {
      proxy_pass https://ec-hwbeta.casstime.com;
  }
  location /pointshop {
      proxy_pass https://ec-hwbeta.casstime.com;
  }
  location /partycredit {
      proxy_pass https://ec-hwbeta.casstime.com;
  }

  location / {
      proxy_pass http://127.0.0.1:3000;
  }
}

當使用者訪問“檢視報價頁面”時,首先經過匹配路由,然後react-loadable呼叫webpack執行時載入chunk的函式__webpack_require__.e,該函式會根據入參chunkId使用基於promise實現的linkscript請求對應chunk,返回Promise<pending>。如果Promise.all()中存在一個Promise<pending>轉變成Promise<rejected>,那麼Promise.all的執行結果就是Promise<rejected>。由於其中有一個包含@import "aliplayer-min.css"css chunk請求失敗了,所以Promise.all的執行結果就是Promise<rejected>rejected狀態會被react-loadable中的catch捕獲,更新元件內部狀態值,觸發重新渲染,執行render函式返回null。因此,內容區域顯示空白。

注:使用link載入css chunk,如果css chunk@import url()請求失敗,那麼會觸發$link.onerror回撥函式

img-20221227233554.png

img-20221227233604.png

img-20221227233613.png

img-20221227233628.png

原因

至此,問題的根本原因已經明瞭了。由於生產環境構建將cssjs拆分成一個個chunk,執行時函式在根據chunkId載入資源時,其中存在一個含@import "aliplayer-min.css"css chunk載入失敗,導致整個Promise.all執行結果為Promise<rejected>,致使react-loadable高階元件中catch捕獲到rejected後,更新state,重新渲染,執行render函式返回null,頁面內容顯示空白。

解決方案

在解決該問題之前,需要先摸清楚問題修改的範圍有多大,畢竟引用alicdn靜態資源的工程可能不止一個。在gitlab全域性搜尋發現,涉及工程有十幾個。如果每一個引用的連結手動去改,很容易改漏,因此我準備寫一個命令列工具,敲一個命令就可以搞定全部連結替換。

初始化命令列專案

建立一個結構,如下所示:

+ kennel-cli
  + cmds
    + dowmload-alicdn.js
  - index.js

然後,在根資料夾中初始化:

$ npm init -y  # This will create a package.json file

配置bin

開啟你的package.json並定義將在可執行檔案和起點檔案上使用的名稱:

"bin": {
  "kennel-cli": "index.js"
},

然後,使用以下命令告訴 npmindex.js是一個 Node.js 可執行檔案 #!/usr/bin/env node(必須指定執行環境,不然執行會報錯):

#!/usr/bin/env node
'use strict'

// The rest of the code will be here...
console.log("Hello world!")

除錯應用程式

我們可以對 NPM 說,您當前開發的應用程式是一個全域性應用程式,因此我們可以在我們的檔案系統中的任何地方測試它:

$ npm link  # Inside the root of your project

然後,您已經可以從計算機上的任何路徑執行您的應用程式:

$ kennel-cli     # Should print "Hello world" on your screen

載入所有命令

修改index.js檔案,使用yargs.commandDir函式載入此資料夾中的每個命令(下面的示例)。

#!/usr/bin/env node
"use strict";

const { join } = require("path");
require("yargs")
  .usage("Usage: $0 <command> [options]")
  .commandDir(join(__dirname, "cmds"))
  .demandCommand(1)
  .example("kennel-cli download-alicdn")
  .help()
  .alias("h", "help").argv; // 最後一定要.argv,不然命令執行不會有任何反應

實現一個命令

在資料夾 cmds 中的一個檔案中指定了一個命令。它需要匯出一些命令配置。例如:

const { join } = require("path");
const fs = require("fs");

exports.command = "download-alicdn";

exports.desc = "將引入的阿里雲靜態資原始檔下載到本地專案";

exports.builder = {};

exports.handler = (argv) => {
  // 執行命令的回撥
  downloadAlicdn();
};

/**
 * @description 讀取public/index.html
 * @returns
 */
function readHtml() {
  // 不能使用__dirname,因為__dirname表示當前執行檔案所在的目錄,如果在某工程執行該命令,__dirname指的就是download-alicdn.js存放的目錄
  const htmlURL = join(process.cwd(), "public/index.html");
  // 同步讀取,本地讀取會很快
  return fs.readFileSync(htmlURL).toString();
}

/**
 * @description 替換alicdn靜態資源
 * @param {*} source
 */
async function replaceAlicdn(source) {
  // node-fetch@3是ESM規範的庫,不能使用require,因此這兒使用import()動態引入
  const fetch = (...args) =>
    import("node-fetch").then(({ default: fetch }) => fetch(...args));
  const reg = /(https|http):\/\/(at|g).alicdn.com\/.*\/(.*\.css|.*\.js)/;
  const fontReg = /\/\/(at|g).alicdn.com\/.*\/(.*\.woff2|.*\.woff|.*\.ttf)/;

  const fontDir = join(process.cwd(), "public/fonts");
  const staticDir = (suffix) => join(process.cwd(), `public/${suffix}`);

  let regRet = source.match(reg);
  while (regRet) {
    const [assetURL, , , file] = regRet;
    // 請求資源
    let content = await fetch(assetURL).then((res) => res.text());
    let fontRet = content.match(fontReg);
    while (fontRet) {
      const [curl, , cfile] = fontRet;
      // @font-face {
      //   font-family: "cassmall"; /* Project id 1353866 */
      //   src: url('//at.alicdn.com/t/font_1353866_klyxwbettba.woff2?t=1639626666505') format('woff2'),
      //        url('//at.alicdn.com/t/font_1353866_klyxwbettba.woff?t=1639626666505') format('woff'),
      //        url('//at.alicdn.com/t/font_1353866_klyxwbettba.ttf?t=1639626666505') format('truetype');
      // }
      const childContent = await fetch("https:" + curl).then((res) =>
        res.text()
      );
      if (fs.existsSync(fontDir)) {
        fs.writeFileSync(join(fontDir, cfile), childContent);
      } else {
        fs.mkdirSync(fontDir);
        fs.writeFileSync(join(fontDir, cfile), childContent);
      }
      content = content.replace(fontReg, "../fonts/" + cfile);
      fontRet = content.match(fontReg);
    }
    const suffix = file.split(".")[1];
    const dir = staticDir(suffix);
    if (fs.existsSync(dir)) {
      fs.writeFileSync(join(dir, file), content);
    } else {
      fs.mkdirSync(dir);
      fs.writeFileSync(join(dir, file), content);
    }
    source = source.replace(reg, `./${suffix}/${file}`);
    regRet = source.match(reg);
  }

  fs.writeFileSync(join(process.cwd(), "public/index.html"), source);
}

async function downloadAlicdn() {
  // 1、獲取public/index.html模板字串
  // 2、正則匹配alicdn靜態資源連結,並獲取連結內容寫入到本地,引用連結替換成本地引入
  // 3、如果alicdn css資源內部還有引入alicdn的資源,也需要下載替換引入連結
  // https://at.alicdn.com/t/font_1353866_klyxwbettba.css
  // https://g.alicdn.com/de/prismplayer/2.9.21/skins/default/aliplayer-min.css
  const retHtml = readHtml();
  await replaceAlicdn(retHtml);
}

實際專案測試

download-alicdn.gif

相關文章