关于cdn:alicdn边缘节点不稳定导致页面崩溃问题

问题概述

某工作日,线上某用户向客服专员反馈没法失常拜访“查看报价页面”,页面内容没有出现。客服专员收到反馈后,将问题转交给SRE解决。很奇怪的是,SRE拜访生产环境“查看报价页面”显示失常,为了进一步剖析定位问题,SRE向用户申请了近程操作,将将一些具备价值的信息记录下来,次要有以下两个方面:

  • 用户拜访“查看报价页面”存在款式和字体文件没有加载胜利;

  • 没有加载胜利的字体和款式文件的申请域名并不是公司的,而是公网收费的域名(at.alicdn.com、g.alicdn.com);

剖析与定位

通过上述信息,能够晓得用户与SRE拜访页面的差别,SRE拜访“查看报价页面”能够失常获取所有资源,而用户无奈获取局部字体和款式文件。依据浏览器加载渲染原理,局部字体和款式加载失败大概率不会导致页面DOM无奈出现,无奈下结论之时,无妨先假如字体和款式文件影响到了DOM渲染。

当无奈从表象剖析出线上问题起因时,第一步须要在开发环境或者测试环境复现问题场景,而后排查从申请资源到页面渲染的执行过程。

问题的引入点:域名解析

在复现场景之前,须要先晓得拜访胜利和失败之间的差别。通过收集到的信息来看,申请域名解析的IP有显著不同:

  • 失常拜访资源,DNS域名解析
Request URL Remote Address
https://at.alicdn.com/t/font_1353866_klyxwbettba.css 121.31.31.251:443
https://g.alicdn.com/de/prismplayer/2.9.21/skins/default/aliplayer-min.css 119.96.90.252:443
https://at.alicdn.com/t/font_2296011_yhl1znqn0gp.woff2 121.31.31.251:443
https://at.alicdn.com/t/font_1353866_klyxwbettba.woff2?t=1639626666505 121.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)页面内容没有出现(复现胜利),开发环境页面内容失常出现(复现失败),剖析开始陷入胡同。

开发环境:

测试环境:

这时候就要开始剖析了,两个环境复现问题的差别点在哪里?

不难发现,两个环境最次要的区别在于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}`,
  },  
}

css-loader在解析样式表中@importurl()过程中,如果index.module.scss中应用@import 引入第三方款式库aliplayer-min.css@import aliplayer-min.css局部和index.module.scss中其余部分将会被拆散成两个module,而后别离追加到款式数组中,数组中的每个”款式项“将被style-loader解决应用style标签注入到html

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对象。因而,内容区域失常显示。

生产环境

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扭转),从而导致浏览器缓存生效。

因为启用了MiniCssExtractPlugin.loader拆散款式,@import "aliplayer-min.css"将被拆散到一个css chunk中,所以aliplayer-min.css申请链有三级

)

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回调函数

起因

至此,问题的根本原因曾经明了了。因为生产环境构建将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);
}

理论我的项目测试

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理