Coder Social home page Coder Social logo

blog's People

Contributors

hexmox avatar

Watchers

 avatar  avatar

blog's Issues

webpack4可预测持久化缓存方案探索

前段时间发布一个基于webpack的前端工程时,对生成的增量发布文件列表感到迷惑。

假设业务有两个页面A、B,其webpack入口分别为{a,b}.js

本次发布仅对a.js进行了修改,理想的发布的文件列表应该为:

  • a.html
  • a.[hash].js

但实际上却是:

  • a.html
  • b.html
  • a.[hash].js
  • b.[hash].js
  • vendor.[hash].js

迷惑不解,于是花了点时间去探索webpack生成文件哈希值的奥秘。

前言

探索webpack caching策略之前,首先要明白配合HTTP缓存的前端代码部署发布策略。推荐阅读:

大公司里怎样开发和部署前端代码?

读完后就会明白,现阶段比较成熟的持久化缓存方案就是使静态资源的文件名包含其内容的hash值,并在静态资源服务器配置HTTP缓存规则。基于此可用做到增量发布及很好地利用HTTP缓存能力。

那么webpack提供了什么配置来影响生成的文件名哈希值呢?

output.filename

output.filename可以指定构建结果输出的文件名,其中提供了三个关于哈希值的占位符:

  • [hash]:The hash of the module identifier
    基于每次构建的compilation对象的所有内容计算而来
  • [chunkhash]:The hash of the chunk content
    基于每一个chunk根据自身的内容计算而来
  • [contenthash]:The hash of the content of a file, which is different for each asset
    基于每一个生成文件(asset)内容计算而来

Tips:
可以通过[hash:16]指定哈希值的长度
可以通过output.hashDigestLength指定默认哈希值的长度

补充:
可能有读者不理解什么是chunk, asset, module,请查看官方文档

那么问题来了:

  • 生成环境中究竟使用哪一个占位符呢?
  • CommonChunk/Dynamic Import技术会怎么影响文件hash值
  • 不同级别(业务、依赖)代码的不同操作(新增、修改、删除)会怎么影响hash值以及它们是合理的吗?
  • 如若不合理怎么将hash值的变动降到最小呢?

接下来以一个典型的实践Demo来探索下:

文中所有demo都可以在Github地址找到,基于:

  • webpack v4.25.1
  • Node v8.16.0
  • Mac OS 10.14.5

场景及实践

以下demo基于常见的代码拆包优化,它会

  • 将node_modules依赖包抽离成一个包vendor.xxx.js
  • 将2个entry以上依赖的包抽离成一个包common.xxx.js

配置为:

// webpack.config.js
// ...
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendor: {
          test: /node_modules/,
          chunks: 'all',
          name: 'vendor'
        },
        common: {
          test: /.js$/,
          name: 'common',
          minChunks: 2
        }
      }
    }
  }
// ...

[hash]

假设目前仅有一个页面入口a.js,使用[hash]的占位符进行打包输出:

// a.js
import 'lodash';
// webpack.config.js
module.exports = {
  entry: {
    a: './src/pages/a.js'
  },
  output: {
    filename: '[name].[hash].js'
    // ...
  },
};

打包结果为:

                         Asset      Size  Chunks             Chunk Names
     a.ce71fc0e3be0265f3764.js  1.47 KiB       0  [emitted]  a
vendor.ce71fc0e3be0265f3764.js  69.8 KiB       1  [emitted]  vendor

很奇怪,vendora文件的hash值竟然是一致的,当我随意在a.js加上一行代码后,vendor的文件名也随之变化了,无法有效利用HTTP缓存,这显然不是我们想要的。

[chunkhash]

为了使vendora能拥有不同的hash值,我们将[hash]改为[chunkhash]使之文件hash值根据自身chunk来计算。

// webpack.config.js
module.exports = {
  output: {
    filename: '[name].[chunkhash].js'
    // ...
  },
};

运行结果为:

                         Asset      Size  Chunks             Chunk Names
     a.b4a6f84d92c2cd8e68e2.js  1.47 KiB       0  [emitted]  a
vendor.1f01c1b51cfa1b8560ea.js  69.8 KiB       1  [emitted]  vendor

效果不错,如果任意修改a.js,那么vendor文件应该不会改变,然而事实却不是这样。
如果对a.js模块的修改,导致原有抽离到vendor的modules的id有所变化的话:

// a.js
import '../common/util';
import 'lodash';
                         Asset      Size  Chunks             Chunk Names
     a.c90b7ef837be6067c4db.js  1.51 KiB       0  [emitted]  a
vendor.67cb27b8f3bcb258a6b8.js  69.8 KiB       1  [emitted]  vendor

其实对比前后的vendor结果只是webpack生成的runtime代码(也就是module的id)轻微的不一致而已:

image

image

关于webpack runtime的细节可以自行google

optimization.runtimeChunk

为了解决runtime的影响,我们根据webpack的caching文档中指出可以使用使用配置抽离runtime代码:

// webpack.config.js
// ...
  optimization: {
    runtimeChunk: 'single',
// ...
                          Asset       Size  Chunks             Chunk Names
      a.025a679cc53a17ae5e20.js  112 bytes       0  [emitted]  a
runtime.761a672aee47740d7b60.js   1.42 KiB       1  [emitted]  runtime
 vendor.e0a3e1f04b6066968af9.js   69.8 KiB       2  [emitted]  vendor

重复在a.js添加util依赖后,再次编译结果为:

                          Asset       Size  Chunks             Chunk Names
      a.757fd6c1c697ab9fa7ea.js  152 bytes       0  [emitted]  a
runtime.761a672aee47740d7b60.js   1.42 KiB       1  [emitted]  runtime
 vendor.42d139ff34a38fa36e79.js   69.8 KiB       2  [emitted]  vendor

很遗憾,看了下runtime.xxx.js的内容,只是将runtime顶部的代码进行了抽离而已(所以前后都没有变化),modules的id变化还是使得vendor的hash值发生了变化。

optimization.moduleIds

解决方案可以通过optimization.moduleIds配置来告诉webpack使用文件路径的hash值来作为module的id,而不是自增的id。这样将vendor的相关的id固定下来,就不会改变hash值了。

// webpack.config.js
// ...
  optimization: {
    moduleIds: 'hashed',
// ...
                          Asset       Size  Chunks             Chunk Names
      a.39f663c39a00b0a0d3ed.js  127 bytes       0  [emitted]  a
runtime.761a672aee47740d7b60.js   1.42 KiB       1  [emitted]  runtime
 vendor.43f6930938e9626aee08.js   69.8 KiB       2  [emitted]  vendor

重复在a.js添加util依赖后,再次编译结果为:

                          Asset       Size  Chunks             Chunk Names
      a.0fea31ce608e3a986832.js  177 bytes       0  [emitted]  a
runtime.761a672aee47740d7b60.js   1.42 KiB       1  [emitted]  runtime
 vendor.43f6930938e9626aee08.js   69.8 KiB       2  [emitted]  vendor

到此,js文件hash值的行为已经符合我们的预期了。但真实的项目往往会更为复杂,接下来我们增加CSS模块。

处理CSS

按照官方指引我们添加了CSS的处理:

// webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
// ...
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader,
          },
          'css-loader',
        ],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      // 注意这里为contenthash,因为官方文档说明了其他两个是不管用的
      filename: '[name].[contenthash].css'
    }),
  ]
// ...

先在上一次基础上打包,结果为:

                          Asset       Size  Chunks             Chunk Names
      a.4f71dbbe92135e074143.js  177 bytes       0  [emitted]  a
runtime.526e924c24f031d6c058.js   1.42 KiB       1  [emitted]  runtime
 vendor.43f6930938e9626aee08.js   69.8 KiB       2  [emitted]  vendor

对比了a.4f71dbbe92135e074143.jsa.0fea31ce608e3a986832.jsruntime.526e924c24f031d6c058.jsruntime.761a672aee47740d7b60.js
可以发现在打包内容完全不变的情况下,改变了webpack配置也会使chunkhash改变(猜测a与runtime chunk的某些配置属性变化了,后续可以更加深入chunkhash的生成原理,这里对于实际场景来说,项目稳定后改变构建的场景是非常少的)。

然后我们简单的添加a.css并在a.js依赖进来:

// a.css
* {
  margin: 0;
  padding: 0;
}
// a.js
// ...
import './a.css';

打包后结果为:

                          Asset       Size  Chunks             Chunk Names
      a.a2e96b7b9cacef0a192b.js  212 bytes       0  [emitted]  a
     a.ad59b1cfef38946db95f.css   33 bytes       0  [emitted]  a
runtime.526e924c24f031d6c058.js   1.42 KiB       1  [emitted]  runtime
 vendor.43f6930938e9626aee08.js   69.8 KiB       2  [emitted]  vendor

现在我们将a.css作些简单改变,再打包一次,预期只有a.css的hash值会改变,结果a.js的hash值(因为chunkhash是基于整个chunk的内容的)也变了:

                          Asset       Size  Chunks             Chunk Names
     a.d8e08571019679820f98.css   63 bytes       0  [emitted]  a
      a.fd7d396d5654ca01204b.js  212 bytes       0  [emitted]  a
runtime.526e924c24f031d6c058.js   1.42 KiB       1  [emitted]  runtime
 vendor.43f6930938e9626aee08.js   69.8 KiB       2  [emitted]  vendor

这是我们不希望看到的,官方的issue提供了解决方案。

[contenthash]

将webpack配置稍微修改下:

// webpack.config.js
// ...
  output: {
    filename: '[name].[contenthash].js',
// ...

更改a.css的前后两次的运行结果对比为:

                          Asset       Size  Chunks             Chunk Names
      a.0d13a673fbf7e0fb82c9.js  212 bytes       0  [emitted]  a
     a.a99fa96ecdccdf2c269e.css   34 bytes       0  [emitted]  a
runtime.ffaf452731b773b5b33e.js   1.42 KiB       1  [emitted]  runtime
 vendor.94725a7abb26f12e6126.js   69.8 KiB       2  [emitted]  vendor
                          Asset       Size  Chunks             Chunk Names
      a.0d13a673fbf7e0fb82c9.js  212 bytes       0  [emitted]  a
     a.d8e08571019679820f98.css   63 bytes       0  [emitted]  a
runtime.ffaf452731b773b5b33e.js   1.42 KiB       1  [emitted]  runtime
 vendor.94725a7abb26f12e6126.js   69.8 KiB       2  [emitted]  vendor

只改变了a.css的hash值,完美。

增加页面入口

接下来我们新增一个页面入口b.js,来探索下页面之间会不会有影响:

// webpack.config.js
// ...
  entry: {
    // ...
    b: './src/pages/b.js',
  },
  optimization: {
    // ...
    splitChunks: {
      minSize: 1, // 为了cacheGroups.common可拆包成功
      cacheGroups: {
        vendor: {
          // ...
          priority: 2,
        },
        common: {
          test: /\.js$/,
          chunks: 'all',
          name: 'common',
          priority: 1,
        }
      }
    }
  },
// ...

这里新增了一个公共的chunk common,尽可能贴近生产环境。尝试不同模块的修改都符合预期,很棒:

  • 修改a.css -> 仅变化a.xxx.css
  • 修改公用的common/util.js -> 仅变化common.xxx.js
  • 修改b.js(引入多一个模块) -> 仅变化b.xxx.js

增加异步加载

当页面功能变得越来越复杂臃肿时,特别是SPA,我们常常会通过dynamic import的方式,拆分异步模块来解决:

// a.js
// ...
import('../async/a.async').then((a) => a.log());
// a.async.js
export const log = () => console.log('a async')

对比前后两次的构建结果,发现变更的不仅是a.js的hash值,runtime.xxx.js的hash值也因为新增了部分异步加载的runtime代码变化了:

                          Asset       Size  Chunks             Chunk Names
     a.a99fa96ecdccdf2c269e.css   34 bytes       3  [emitted]  a
      a.f27987a0484118d434b9.js  174 bytes       3  [emitted]  a
     b.a99fa96ecdccdf2c269e.css   34 bytes       4  [emitted]  b
      b.eb62980a9e5aa5b97832.js  226 bytes       4  [emitted]  b
 common.584c299f230dbafc55e8.js  100 bytes       0  [emitted]  common
runtime.ffaf452731b773b5b33e.js   1.42 KiB       1  [emitted]  runtime
 vendor.94725a7abb26f12e6126.js   69.8 KiB       2  [emitted]  vendor
                          Asset       Size  Chunks             Chunk Names
      5.a8b36218e4d816632f48.js  171 bytes       5  [emitted]  
      a.a21c9b5ef7c674164ee4.js  224 bytes       3  [emitted]  a
     a.a99fa96ecdccdf2c269e.css   34 bytes       3  [emitted]  a
     b.a99fa96ecdccdf2c269e.css   34 bytes       4  [emitted]  b
      b.eb62980a9e5aa5b97832.js  226 bytes       4  [emitted]  b
 common.584c299f230dbafc55e8.js  100 bytes       0  [emitted]  common
runtime.0eba89b4f3878dd2ec40.js   2.18 KiB       1  [emitted]  runtime
 vendor.94725a7abb26f12e6126.js   69.8 KiB       2  [emitted]  vendor

同样的在a.js中再增加一个a2.async.js,结果runtime.xxx.js还是因为新增了chunk信息变更了:

                          Asset       Size  Chunks             Chunk Names
      5.a8b36218e4d816632f48.js  171 bytes       5  [emitted]  
      6.17a8c61193ab8db1915d.js  172 bytes       6  [emitted]  
     a.a99fa96ecdccdf2c269e.css   34 bytes       3  [emitted]  a
      a.cf4d753b92b883744875.js  274 bytes       3  [emitted]  a
     b.a99fa96ecdccdf2c269e.css   34 bytes       4  [emitted]  b
      b.eb62980a9e5aa5b97832.js  226 bytes       4  [emitted]  b
 common.584c299f230dbafc55e8.js  100 bytes       0  [emitted]  common
runtime.6b834e964b97efa45c8d.js   2.21 KiB       1  [emitted]  runtime
 vendor.94725a7abb26f12e6126.js   69.8 KiB       2  [emitted]  vendor

image

这种页面A的改动引起页面B需要加载变更的runtime的方式不科学。实际上更改起来也很简单,只需要在配置即可:

// webpack.config.js
// ...
optimization: {
    runtimeChunk: true,
// ...

改动之后验证就没有问题了。更改了hash的只有a.xxx.jsruntime~a.xxx.js
但细心的同学可能发现了,异步加载的chunk文件名又变成了自增id的模式,尽管我尝试将配置改为:

// webpack.config.js
// ...
optimization: {
    // ...
    chunkIds: 'named',
// ...

也没有作用。因为webpack.NamedChunkPlugin只处理那些有名字的chunk,而异步加载的chunk是默认没有名字的。解决方式有两种:

  1. 自定制webpack.NamedChunkPlugin
// webpack.config.js
// ...
  plugins: [
    new webpack.NamedChunksPlugin((chunk) => {
      if (chunk.name) { 
        return chunk.name; 
      } 
      return [...chunk._modules].map(m => path.relative(m.context, m.request)).join("_"); 
    }),
// ...
  1. 使用magic comment自己标识异步模块的name
// a.js
// ...
import(/* webpackChunkName: "a_async" */'../async/a.async').then((a) => a.log());
import(/* webpackChunkName: "a2_async" */'../async/a2.async').then((a2) => a2.log());

至此已经基本实现webpack4的可预测持久化缓存了,Perfect!

总结

本文探索了基于webpack4的长效缓存实践经验,对于更新迭代频繁的实际业务来说,运用这些优化经验能节省不少的网络流量成本开支,且用户能获得更好的体验。

  • output.filename使用[contenthash]占位符
  • CSS Extract的插件配置文件名使用[contenthash]占位符
  • optimization.runtimeChunk置为true为每一个入口抽离runtime chunk
  • optimization.moduleIds置为hashed稳定module Id防止影响到公共chunk
  • 定制webpack.NamedChunkPlugin插件稳定异步module Id防止异步chunk互相影响

参考链接

移动端适配方案之结合rem&vw

这个是老生常谈的问题了,只要你开发过移动端就一定会遇到各种大小屏幕的适配问题。随便google了一下移动端适配方案,一堆现有的文章总结,那为何要再写一篇这样的文章呢,因为看了一下好几篇总结都没提到rem和vw结合的方案,最近试了一下这种方案感觉不错记录一下。

业务主流适配方案

目前业界主流的移动端适配方案应该就以下几种:

  • px + %

以较小宽度(如320px)的视觉稿做参考,垂直方向的高度和间距使用定值,水平方向混合使用定值和百分比或者利用弹性布局,最终达到“当手机屏幕变化时,横向拉伸或者填充空白的效果”。

  • media query

  • viewport缩放

  • rem

  • vw/vh

就不一一具体解释了,相关解释可以看文章末尾的参考链接。

不太完美的效果

团队一直以来适配的方案都是rem,近来用户机型统计数据兼容vw的越来越大,故而可以尝试vw了,这里给出实践效果。

vw

// postcss.config.js
module.exports = {
  parser: 'postcss-scss',
  plugins: {
    // ...其他插件
    'postcss-px-to-viewport': {
      viewportWidth: 375,
      viewportUnit: 'vw',
      selectorBlackList: ['.ignore-vw'],
      minPixelValue: 1,
      mediaQuery: false
    }
  }
};

其实这个就是等比例缩放适配,精准还原视觉稿的效果。

px2vw

可以看到大屏(ipad)可显示的内容更少了,这是设计不能接受的。

rem + media query(当然结合JS计算也是可以的)

// postcss.config.js
module.exports = {
  parser: 'postcss-scss',
  plugins: {
    // ...其他插件
    'postcss-pxtorem': {
      rootValue: 14,
      unitPrecision: 5,
      propList: ['font', 'font-size', 'line-height', 'letter-spacing'],
      selectorBlackList: [
        'html',
        '.download-wording' // app引导高度固定
      ],
      replace: true,
      mediaQuery: false,
      minPixelValue: 1
    }
  }
};
html {
  @media only screen and (max-width: 340px) {
    font-size: 12px !important;
  }

  @media only screen and (min-width: 340px) {
    font-size: 14px !important;
  }

  @media only screen and (min-width: 600px) {
    font-size: 16px !important;
  }
}

这边注意看到postcss-pxtorem插件配置中有一个propList属性,仅仅对部分文本相关的CSS属性做px到rem单位的转换,否则如果所有都转换的话本质上跟vw没区别,都是等比例缩放。那么效果看起来也还行:

px2rem

但是这就对开发者的编码意识提出了要求,比如以下场景:

px2rem-2

可以看到顶部离封面图中间是存在灰线的,这个是因为底部容器margin-top写死px没有被转换成rem的缘故。还有左右容器高度对齐实现不一致,一个用height,一个用line-height撑开也会出现适配问题等等。

结合rem&vw

既然上面两个方案都有各自的问题,那么我们可不可以结合他俩呢,把html的font-size设置成vw单位,然后用媒体查询设置一个最小和最大值,自动把所有px转rem。

看起来效果不错的样子:

video_2018-12-28_205315 gif

参考:

记一次React直出服务CPU阶梯性上升的排查

记一次React直出服务CPU阶梯性上升的排查

前段日子,忙忙碌碌地搞了个基于React&Redux的同构直出服务,终于上线了。没想到上线的运营问题更让人爆炸,各种监控问题及性能问题,被leader问起来也只能给个不太好看的数据或者模糊的回答。作为一个前端伪全栈工程师,在如何打造高可用,高性能的服务上确实欠缺一些实践经验,还需努力学习。

前言

由于身处于鹅厂,各种监控体系较为健全,且团队在Node上有着较深厚的积累,写代码的时候不需要操心太多。直出服务上线前就接入了各类基础监控,比如:

  • 服务器单机属性监控(CPU,内存,IO等)
  • monitor自定义量监控
  • 直出服务日志
  • ...

一开始直出服务灰度上线运行后,CPU偶尔会出现高负载告警,频率大概是两、三天一次。后面迫于进度压力全量时,由于机器数量不是很足,出现的频率更高了一点。当然还有一些其他小问题,这里不予讨论。

cpu

从上面两张图可以看出CPU呈现阶梯性上升的趋势(高处直接降为0的情况是我手动重启处理的表现)。

排查过程

只看到表象

在服务灰度过程中,第一次CPU过高告警,因为是现网环境我略微感到紧张马上登录内部运维系统重启服务或者上机器把进程给kill掉了(类似pm2等工具会有进程保护机制,一台机器上运行若干个服务进程,kill掉CPU负载高的子进程会自行重启)。在后续回溯问题的过程中,先查看的是关于流量的监控:

cpu 11 19

上面是某日CPU平均值24小时图,可以看到10点左右CPU使用率有一次迅猛上升,并维持在一个较高的值。结合当时的进入量上报图:

11 19

上面是当日内部安全系统扫描的量,可以看到服务刚上线不久,收到现网流量后,安全系统就开始疯狂扫描服务了。当时刚发生的时候,结合两个图初步认为是因为是请求量过大导致的CPU上升(当时做过简单压测React同构服务性能较差,并且灰度期间部署的机器还少)。

被其他问题所干扰

实际上灰度时服务问题也比较多,当时影响CPU上升的因素主要还有内存泄漏(本篇暂时不深入讨论,后面会有另一篇文章)。由于是同构应用,服务器运行的代码中夹杂着经过构建编译生成的前端代码,有部分依赖的开源模块代码也没看过,有的协同者工作经验不足甚至没有这样的意识,讲道理还是很慌的。有几次CPU告警确实是因为内存爆掉引起服务频繁GC导致的。

27 11 21

上图是某机器内存使用大小24小时图,明显可以得到内存已经爆掉的结论(机器上还运行着其他服务),后面内存的断崖式下降是重启操作,而且能看到重启后也迅猛上升。

注意到阶梯性上升后

解决掉一些灰度出现的内存,性能问题后,服务扩大灰度比例并稳定运行了一段时间,这时候终于发现了上面所说的CPU阶梯性上升的现象。所幸可以隔两三天重启一次服务暂时hold住,并且Nginx层做了一层降级异步的逻辑,要不然我可能就要失业了。

cpu 2

调整下心态慢慢思考这个现象,Node服务引起CPU高的常见原因无非就是:

  1. 内存泄漏导致频繁GC
  2. 阻塞IO
  3. setTimeout之类的定时器
  4. 复杂计算逻辑

根据上图结合当日的内存使用大小、磁盘IO图来看,明显不是内存和IO引起的,且就算单单从CPU图来看,也与请求量大小的监控图不匹配。所以比较大的可能是第3、4点原因,继续深究。

第一次尝试:Code Review

像这类CPU上升一个阶梯后基本没有再下降过,看起来真的很想是触发了某个隐藏的setTimeout或者setInterval逻辑导致吃掉一部分CPU资源。所以一开始我就在代码中全局搜setTimeout/setInterval/setImmediate之类的(当然还搜了process.nextTick):

default

但是Review完后并没有什么收获,要不就是放在componentDidMount里走SSR的话逻辑不会运行,要不就是拆成异步包加载的逻辑。从监控图感觉第三点是最有可能,难道这部分逻辑会在依赖的开源模块中吗?那这个就很蛋疼了,毕竟看第三方的模块代码比较耗时且不一定有收获(我引入的模块我还是会评估一下的)。既然这条路暂时走不通,就换一个思路吧。

第二次尝试:本地复现

早期压测的时候,就没有复现过CPU阶梯性上升的情况。从监控图看,每个阶梯CPU上升时所经历的时间也是不确定的,非常像是由于请求参数数据不同触发了某个隐藏逻辑导致的。所以看起来只能从现网的流量日志中捞取数据来重现,毕竟我们已经知道CPU上升大概的时间点。虽然线上的流量比较大,但是捞取上升时间点左右2分钟的数据还是可以接受的,于是我们上机器把日志捞下来(日志目前的能力相对弱一点,其实有日志系统但不是很好用):

# cd /path/to/server/logs/
# 不太熟悉linux,应该有更高效的捞取方法,请多多指教
grep "2018-12-19T01:0[1-5]:00" | grep -o -E "GET /course/[0-9]+" | grep "[0-9]+" | uniq > ./temp

拉取下来后,写一个简单的脚本:

const path = require('path');
const fs = require('fs');
const http = require('http');

function request(cid) {
  return new Promise(resolve => {
    http
      .get(
        {
          port: 6001,
          path: `/course/${cid}`,
          headers: {
            Host: 'm.ke.qq.com',
            'User-Agent':
              'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1'
          }
        },
        res => {
          resolve();
        }
      )
      .on('error', e => {
        console.error(e);
      })
      .end();
  });
}

fs.readFile(path.resolve(__dirname, 'temp'), 'utf-8', (err, content) => {
  if (err) {
    throw err;
  }

  const cids = content.split(/,|\r|\n|\r\n/).filter(x => !!x);
  const execute = () => {
    console.log('left ', cids.length);
    if (!cids.length) {
      return;
    }

    const cid = cids.shift();
    console.log('requesting cid', cid);
    request(cid).then(() => {
      execute();
    });
  };

  execute();
});

奈何尝试了很多次也没重现,包括使用了一些压测工具(比如ab等)。

第三次尝试:调试工具

其实上一步已经非常接近真相,奈何陷入迷局看不清。CPU问题已经持续几周了,每几天重启一次服务也不是办法,既然本地无法重现,那就直接上服务器调试吧!!!

参考一些文章之后,先打算用linux系统分析工具perf看下(其实对perf不是非常了解,仅仅是通过搜索结果简单学习了下),因为对Node进程本身没有任何影响:

# 不知道为何我们的机器perf不支持--call-graph
perf top --call-graph -p [pid]
perf record -g -p [pid]

但是得到的结果没有很大的参考价值,google了一下finish_task_switch像是CPU上下文切换之类的操作:
perf

于是我简单得出perf无法分析到Node进程内部函数维度的调用消耗(别的文章实践貌似可以),还是node官方的profile手段靠谱,看来不得不发布一次了,于是我改了服务的启动命令进行了灰度发布:

# args指内部Server框架的其他参数
node --prof [...args] app.js

稳定运行一天之后,果然在这台机器复现了CPU阶梯性上升的现象。于是将数据从服务器拉下来分析:

isolate_log

# 处理上面的log文件得到
node --prof-process isolate-0xnnnnnnnnnnnn-v8.log > processed.txt

process1

process2

  • Builtin: StringPrototypeReplace指的就是调用了String类型的replace方法
  • components/course.js标注出了吃CPU的源码位置

分析到这里,就可以明显的看出引起CPU的函数调用是哪个了,回去源码一看,那个函数调用了很多次replace呀,不过第一个参数都是正则,灵机一动立马去google了一下正则、CPU关键词,果然得到了结论——Catastrophic Backtracking

在参考资料里面的文章提到:

Nested quantifiers are repeated or alternated tokens inside a group that is itself repeated or alternated. These almost always lead to catastrophic backtracking.

翻译过来就是:嵌套的分组中存在多个带有重复限定词的标记且此分组本身也带有重复限定词时,几乎总是会发生灾难性回溯

而带着这个解释查看这个函数使用的正则表达式,立马揪出很可以的一条:

// 注意看([^>a-zA-Z0-9_:\.\-]+[^>]*)*
const REGEXP_ATTR_TAG = /<(\/)*([a-zA-Z0-9_:\.\-]+)([^>a-zA-Z0-9_:\.\-]+[^>]*)*>/gi;

https://regex101.com/上试了一下,确实会发生这样的事情:

regex101

终于找到问题所在,太好了,立马修复了一个版本发布了。剩下的就是找出现网能复现的请求了,再回去机器上捞日志,不可能没有呀,结果偶然对匹配计算了一下数量:

default

觉得不对呀,为什么有个进程匹配的数量是0呢?随即一想就通了,请求一过来这个进程立马就吃满CPU卡死了,而本机日志的写是异步的,所以一旦发生就不可能写下来。[大哭]这大概就是没去日志系统捞日志的后果吧,比较机器上的日志服务是一个独立进程,不受服务进程的影响。接下来就简单了,既然服务日志不行,那去捞一下Nginx日志不就得了,果然复现了。给一个现网的数据例子:

regex101-2

总结

2018年了Node也不是当年刚出来的被诟病的玩具语言了,其本身及相关的生态中有非常多的调试工具,面对此类问题我也总结了一个简单的方法思路,仅供参考,当然能不写出这样的代码(上面出问题的正则也是历史包袱)才是最好的,好好做Code Review吧。

summary

参考资料:

React服务端渲染与同构实践

前两年服务端渲染和同构的概念火遍了整个前端界,几乎所有关于前端的分享会议都有提到。在这年头,无论你选择什么技术栈,不会做个服务端渲染可能真的快混不下去了!最近刚好实现了个基于React&Redux的同构直出应用,赶紧写个文章总结总结压压惊。

前言

在了解实践过程之前,让我们先明白几个概念(非新手可直接跳过)。

什么是服务端渲染(Server-Side Rendering)

服务端渲染,又可以叫做后端渲染或直出。

早些年前,大部分网站都使用传统的MVC架构进行后端渲染,就是实现一个Controller,处理请求时在服务端拉取到数据Model,使用模版引擎结合View渲染出页面,比如Java + Velocity、PHP等。但随着前端脚本JS的发展,拥有更强大的交互能力后,前后端分离的概念被提出,也就是拉取数据和渲染的操作由前端来完成。

关于前端渲染还是后端渲染之争,可以看文章后面的参考链接,这里不做讨论。这里照搬后端渲染的优势:

  • 更好的首屏性能,不需要提前先下载一堆CSS和JS后才看到页面
  • 更利于SEO,蜘蛛可以直接抓取已渲染的内容

什么是同构应用(Isomorphic)

同构,在本文特指服务端和客户端的同构,意思是服务端和客户端都可以运行的同一套代码程序。

SSR同构也是在Node这门服务端语言兴起后,使得JS可以同时运行在服务端和浏览器,使得同构的价值大大提升:

  • 提高代码复用率
  • 提高代码可维护性

基于React&Redux的考虑

其实Vue和React都提供了SSR相关的能力,在决定在做之前我们考虑了一下使用哪种技术栈,之所以决定使用React是因为对于团队来说,统一技术栈在可维护性上显得比较重要:

  • 已有一套基于React的UI
  • 已有基于React&Redux的脚手架
  • 已在React直出上有一定的实践经验(仅限于组件同构,Controller并不通用)

React提供了一套将Virtual DOM输出为HTML文本的API

Redux提供了一套将reducers同构复用的解决方案

方案与实践

首先先用脚手架生成了基于React&Redux的异步工程目录:

- dist/ # 构建结果
	- xxx.html
	- xxx_[md5].js
	- xxx_[md5].css
- src/ # 源码入口
	- assets/
		- css/ # 全局CSS
		- template.html # 页面模版
	- pages/ # 页面源码目录 
		- actions.js # 全局actions
		- reducers.js # 全局reducers
		- xxx/ # 页面名称目录
			- components/ # 页面级别组件
			- index.jsx # 页面主入口
			- reducers.js # 页面reducers
			- actions.js # 页面actions
	- components/ # 全局级别组件
- webpack.config.js
- package.json
- ...

可以看到,现有的异步工程,构建会使用web-webpack-plugin将所有src/pages/xxx/index.js当做入口为每个页面编译出异步html、js和css文件。

1. 添加Node Server

既然要做直出,首先需要一个Web Server吧,可以使用Koa,这里我们采用了团队自研基于KoaIMServer(作者是开源工具whistle的作者,用过whistle的我表示已经离不开它了),Server工程目录如下:

- server/
	- app/
		- controller/ # controllers
			- indexReact.js # 通用React直出Controller
		- middleware/ # 中间件
		- router.js   # 路由设置
	- config/
		- config.js # 项目配置
	- lib/ # 内部依赖库
	- dispatch.js # 启动入口
	- package.json
	- ...

由于是一个多页面应用(非SPA),上文提到之前团队的实践中Controller逻辑并不是通用的,也就是说只要业务需求新增一个页面那么就得手写多一个Controller,而且这些Controllers都存在共性逻辑,每个请求过来都要经历:

  1. 根据页面reducer创建store
  2. 拉取首屏数据
  3. 渲染结果
  4. ...(其他自定义钩子)

那我们为什么不实现一个通用的Controller将这些逻辑都同构了呢:

// server/app/controller/indexReact.js
const react = require('react');
const { renderToString } = require('react-dom/server');
const { createStore, applyMiddleware } = require('redux');
const thunkMiddleware = require('redux-thunk').default;
const { Provider } = require('react-redux');

async function process(ctx) {
  // 创建store
  const store = createStore(
    reducer/* 1.同构的reducer */, 
    undefined, 
    applyMiddleware(thunkMiddleware)
  );
  
  // 拉取首屏数据
  /* 2.同构的component静态方法getPreloadState */
  const preloadedState = await component.getPreloadState(store)
    .then(() => {
      return store.getState();
    });
  
  // 渲染html
  /* 2.同构的component静态方法getHeadFragment */
  const headEl = component.getHeadFragment(store);
  const contentEl = react.createElement(Provider, { store }, react.createElement(component));
  ctx.type = 'html';
  /* 3.基于页面html编译的模版函数template */
  ctx.body = template({
	preloadedState,
    head: renderToString(headEl),
    html: renderToString(contentEl),
  });
}

module.exports = process;

上述代码相当于将处理过程钩子化了,只要同构代码提供相应的钩子即可。

当然,还得根据页面生成相应的路由:

// server/app/router.js
const config = require('../config/config');
const indexReact = require('./controler/indexReact');

module.exports = app => {
  // 需要直出页面路由配置
  const { routes } = config;

  // IMServer会调用此方法,传入koa-router实例
  return router => {
    Object.entries(routes).forEach(([name, v]) => {
      const { pattern } = v;

      router.get(
        name, // 目录名称xxx
        pattern, // 目录路由配置,比如'/course/:id'
        indexReact
      );
    });
  };
};

至此服务端代码已基本完成。

2. 同构构建打通

上一步服务端代码依赖了几份同构代码。

  • 页面数据纯函数reducer.js
  • 页面组件主入口component.js
  • 基于web-webpack-plugin生成的页面xxx.html再编译的模版函数template

我选择了通过构建编译出这些文件,而不是在服务端引入babel-register来直接引入前端代码,是因为我想保留更高的自由度,即构建可以做更多babel-register做不了的事情。

// webpack-ssr.config.js
const fs = require('fs');
const path = require('path');
const glob = require('glob');
const write = require('write');
const webpack = require('webpack');
const FilterPlugin = require('filter-chunk-webpack-plugin');
const { rootDir, serverDir, resolve } = require('./webpack-common.config');
const ssrConf = require('./server/ssr.config');

const { IgnorePlugin } = webpack;

const componentsEntry = {};
const reducersEntry = {};
glob.sync('src/pages/*/').forEach(dirpath => {
  const dirname = path.basename(dirpath);
  const options = { realpath: true };
  componentsEntry[dirname] = glob.sync(`${dirpath}/isomorph.{tsx,ts,jsx,js}`, options)[0];
  reducersEntry[dirname] = glob.sync(`${dirpath}/reducers.{tsx,ts,jsx,js}`, options)[0];
});
const ssrOutputConfig = (o, dirname) => {
  return Object.assign({}, o, {
    path: path.resolve(serverDir, dirname),
    filename: '[name].js',
    libraryTarget: 'commonjs2'
  });
};
const ssrExternals = [/assets\/lib/];
const ssrModuleConfig = {
  rules: [
    {
      test: /\.(css|scss)$/,
      loader: 'ignore-loader'
    },
    {
      test: /\.jsx?$/,
      loader: 'babel-loader?cacheDirectory',
      include: [path.resolve(rootDir, 'src'), path.resolve(rootDir, 'node_modules/@tencent')]
    },
    {
      test: /\.(gif|png|jpe?g|eot|woff|ttf|pdf)$/,
      loader: 'file-loader'
    }
  ]
};

const ssrPages = Object.entries(ssrConf.routes).map(([pagename]) => {
  return `${pagename}.js`;
});

const ssrPlugins = [
  new IgnorePlugin(/^\.\/locale$/, /moment$/),
  new FilterPlugin({
    select: true,
    patterns: ssrPages
  })
];

const ssrTemplatesDeployer = assets => {
  Object.entries(assets).forEach(([name, asset]) => {
    const { source } = asset;

    // ssr template
    if (/.html$/.test(name)) {
      const content = source()
        // eslint-disable-next-line
        .replace(/(<head[^>]*>)/, '$1${head}')
        .replace(
          /(<\/head>)/,
          // eslint-disable-next-line
          "<script>window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(/</g, '\\u003c')}</script>$1"
        )
        .replace(/(<div[^>]*id="react-body"[^>]*>)/, '$1${html}'); // eslint-disable-line

      write.sync(path.join(serverDir, 'templates', name), content);
    }
  });
};
const devtool = 'source-map';

function getSSRConfigs(options) {
  const { mode, output } = options;

  return [
    {
      mode,
      entry: componentsEntry,
      output: ssrOutputConfig(output, 'components'),
      resolve,
      devtool,
      target: 'node',
      externals: ssrExternals,
      module: ssrModuleConfig,
      plugins: ssrPlugins
    },
    {
      mode,
      entry: reducersEntry,
      output: ssrOutputConfig(output, 'reducers'),
      resolve,
      devtool,
      target: 'node',
      externals: ssrExternals,
      module: ssrModuleConfig,
      plugins: ssrPlugins
    }
  ];
}

module.exports = {
  ssrTemplatesDeployer,
  getSSRConfigs
};

上述代码将Controller需要的同构模块和文件打包到了server/目录下:

src/
	- pages/
		- xxx
			- template.html # 页面模版
			- reducers.js # 页面reducer入口
			- isomorph.jsx # 页面服务端主入口
server/
	- components/
		- xxx.js
	- reducers/
		- xxx.js
	- templates
		- xxx.html # 在Node读取并编译成模版函数即可

webpack-ssr

3. 实现同构钩子

还需要在同构模块中实现通用Controller约定。

// src/pages/xxx/isomorph.tsx
import * as React from 'react';
import { bindActionCreators, Store } from 'redux';
import * as actions from './actions';
import { AppState } from './reducers';
import Container, { getCourceId } from './components/Container';

Object.assign(Container, {
  getPreloadState(store: Store<AppState>) {
    type ActionCreatorsMap = {
      fetchCourseInfo: (x: actions.CourseInfoParams) => Promise<any>;
    };

    const cid = getCourceId();
    const { fetchCourseInfo } = bindActionCreators<{}, ActionCreatorsMap>(actions, store.dispatch);

    return fetchCourseInfo({ course_id: cid })
  },

  getHeadFragment(store: Store<AppState>) {
    const cid = getCourceId();
    const { courseInfo } = store.getState();
    const { name, summary, agency_name: agencyName } = courseInfo.data;
    const keywords = ['腾讯课堂', name, agencyName].join(',');
    const canonical = `//ke.qq.com/course/${cid}`;

    return (
      <>
        <title>{name}</title>
        <meta name="keywords" content={keywords} />
        <meta name="description" itemProp="description" content={summary} />
        <link rel="canonical" href={canonical} />
      </>
    );
  },
});

export default Container;

至此同构已基本打通。

4. 异步入口&容灾

剩下来就好办了,在异步JS入口中使用ReactDOM.hydrate

// src/pages/xxx/index.tsx
import * as React from 'react';
import { hydrate } from 'react-dom';
import { applyMiddleware, compose, createStore } from 'redux';
import thunkMiddleware from 'redux-thunk';
import { Provider } from 'react-redux';
import reducers from './reducers';
import Container from './components/Container';
import './index.css';

let store;
const preloadState = window.__PRELOADED_STATE__;

if (process.env.NODE_ENV === 'production') {
  store = createStore(reducers, preloadState, applyMiddleware(thunkMiddleware));
} else {
  store = createStore(
    reducers,
    preloadState,
    compose(
      applyMiddleware(thunkMiddleware),
      window.devToolsExtension ? window.devToolsExtension() : (f: any) => f
    )
  );
}

hydrate(
  <Provider store={store}>
    <Container />
  </Provider>,
  window.document.getElementById('react-body')
);

hydrate() Same as render(), but is used to hydrate a container whose HTML contents were rendered by ReactDOMServer. React will attempt to attach event listeners to the existing markup.

React expects that the rendered content is identical between the server and the client. It can patch up differences in text content, but you should treat mismatches as bugs and fix them.

容灾是指当服务端因为某些原因挂掉的时候,由于我们还有构建生成xxx.html异步页面,可以在nginx层上做一个容灾方案,当上层Svr出现错误时,降级异步页面。

踩坑

  • 无法同构的业务逻辑

像因为生命周期的不同要在componentDidMount绑定事件,不能在服务端能执行到的地方访问DOM API这些大家都应该很清楚了,其实大概只需要实现最主要几个同构的基础模块即可:

  1. 访问location模块
  2. 访问cookie模块
  3. 访问userAgent模块
  4. request请求模块
  5. localStorage、window.name这种只能降级处理的模块(尽量避免在首屏逻辑使用到它们)

当然我要说的还有一些依赖客户端能力的模块,比如wx的sdk,qq的sdk等等。

这里稍微要提一下的是,我最初设计的时候想尽可能不破坏团队现有的编码习惯,像location、cookie之类的这些模块方法在每次请求过来的时候,拿到的值应该是不一样的,如何实现这一点是参考TSW的做法:https://tswjs.org/doc/api/global,Node的domain模块使得这类设计成为可能。

但是依旧要避免模块局部变量的写法(有关这部分内容,我另写了一篇文章可做参考

  • 使用ignore-loader忽略掉依赖的css文件
  • core-js包导致内存泄漏
    {
      test: /\.jsx?$/,
      loader: 'babel-loader?cacheDirectory',
      // 干掉babel-runtime,其依赖core-js源码对global['__core-js_shared__']操作引起内存泄漏
      options: {
        babelrc: false,
        presets: [
          ['env', {
            targets: {
              node: true
            }
          }],
          'stage-2',
          'react'
        ],
        plugins: ['syntax-dynamic-import']
      },
      include: [
        path.resolve(rootDir, 'src'),
        path.resolve(rootDir, 'node_modules/@tencent')
      ]
    }

这部分core-js的上的issue也有说明为什么要这么做:

babel/babel-loader#152

其实在node上es6的特性是都支持了的,打包出的同构模块需要尽可能的精简。

后续思考

  • 可以看齐Nextjs

这整个设计其实把构建能力抽象出来,钩子可配置化后,就可以成为一个直出框架了。当然也可以像Nextjs那样实现一些Document等组件来使用。

  • 发布的不便利性

当前设计由于Server的代码依赖了构建出来的同构模块,在日常开发中,前端做一些页面修改是经常发生的事,比如修改一些事件监听,而这时候因为js, css资源MD5值的变化,导致template.html变化,故而导致server包需要发布,如果业务有有多节点,都要一一无损重启。肯定是有办法做到发布代码而不用重启Node服务的。

  • 性能问题(TODO)

以上就是本文的所有内容,请多多指教,欢迎交流(文中代码基本都是经过删减的)~

参考资料:

webpack——可选依赖打包实践

近期在维护一个Web侧的SDK,遇到一个可选依赖打包的场景颇为头疼,仅在此记录下探索过程。

前言

在Node中,我们常常会包一层try catch写出这样的代码来实现降级逻辑:

try {
  const a = require('a');
} catch(e) {
  // 模块a不存在的降级逻辑
}

但在前端存在webpack打包的场景中,也有几种方案可以实现类似效果

try-catch

将Node的代码原封不动的使用webpack打包也是可以实现这个效果的。不过会出现Warnings:

WARNING in ./src/pages/xxx/index.js
Module not found: Error: Can't resolve 'a' in '/workspace/projects/xx/src/pages/xxx'
 @ ./src/pages/xxx/index.js

当然如果你不包裹一层try-catch就会导致构建失败了,这一层try-catch的语义分析,webpack做到了什么程度呢?

假如现在有源码

[
  () => require('a'), 
  () => require('b')
].some(fn => {
  try {
    this.reportor = fn();
    return true;
  } catch (e) {}
});

那么webpack的语义分析就没用了...
如果需要尝试依赖超过3次的降级包,那为了避免嵌套地狱,try-catch包裹就只能这么写了:

function tryGet(x = 0) {
  try {
    switch(x) {
    case 0:
      return require('a');
    case 1:
      return require('b');
    case 2:
      return require('c');
    }
  } catch(e) {
    return x >= 2 ? null : tryGet(x + 1);
  }
}

require.resolveWeak

webpack提供了require.resolveWeak的API来实现弱依赖打包。

[
  // 兼容webpack,需要业务先行引入上报库
  () => __webpack_modules__[require.resolveWeak('a')],
  () => __webpack_modules__[require.resolveWeak('b')],
  // 兼容fis3
  () => jsRequire('a'),
  () => jsRequire('b'),
].some((fn, i) => {
  try {
    this.x = fn();
    return !!this.x;
  } catch (e) {
    console.warn('xxx');
  }
});

这种方式的缺陷就是业务代码中必须将模块ab先依赖进来,如果仅仅是node_modules中存在模块,则__webpack_modules__中不会有打包的模块,是一种相对较弱的optional require。

__non_webpack_require__

按照官方文档的说法,实际上会将源码:

__non_webpack_require__('a')

生成:

require('a')

以交由其他模块加载器处理,也有很大的局限性。

小结

以上是基于不动构建配置的前提下,实现可选依赖打包的实践,请多多指教!

如果不基于这个前提当然也有很多种实现,比如可以自行判断package.json的dependecies再结合externals配置来做、自行实现__webpack_require__的模板等等。

参考链接:

Nodejs动态表格大文件下载实践

Nodejs动态表格大文件下载实践

前言

最近优化了几个新人写出的动态表格文件下载接口的性能瓶颈,感觉非常有必要总结一篇文章作为文档来抛转引玉,以促进大家学习一起写出更专业的代码。

HTTP文件下载

讲具体问题之前需要先了解一些HTTP基础,下面简单介绍一下用Nodejs&Koajs怎么实现文件下载

参考:

rfc2616 19.5.1 Content-Disposition

rfc1806

Nodejs Stream

简单下载

最简单的情况就是服务器上文件系统已经存在了某个文件,客户端请求下载直接把文件读了吐回去即可:

import Koa from 'koa';
import Router from 'koa-router';
import * as fs from 'fs/promises';

const app = new Koa();
const router = new Router();

router.get('/download/simple', async (ctx) => {
  const file = await fs.readFile(`${__dirname}/1.txt`, 'utf-8');
  ctx.set({
    'Content-Disposition': `attachment; filename=1.txt`,
  });
  ctx.body = file;
});

app.use(router.routes());
app.listen(80);

设置一下Content-Disposition头部为attachment是关键,告诉浏览器应该下载这个文件

流式下载

简单下载在碰到大文件的情景就不够用了,因为Node无法将大文件一次性读取到进程内存里。这时候用流来解决:

router.get('/download/stream', async (ctx) => {
  const file = fs.createReadStream(`${__dirname}/1.txt`);
  ctx.set({
    'Content-Disposition': `attachment; filename=1.txt`,
  });
  ctx.body = file;
});

此例子不设置Content-Disposition头部也是会下载的,因为Content-Type被设置为了application/octet-stream,浏览器认为其是一个二进制流文件所以默认下载处理了。

进度显示

当下载的文件特别大时,上个例子Content-Length正确设置时浏览器下载条里就能正常显示进度了,为了方便我们使用程序模拟一下:

router.get('/download/progress', async (ctx) => {
  const { enable } = ctx.query;
  const buffer = await fsp.readFile(`${__dirname}/1.txt`);
  const stream = new PassThrough();
  const l = buffer.length;
  const count = 4;
  const size = Math.floor(l / count);
  const writeQuarter = (i = 0) => {
    const start = i * size;
    const end = i === count - 1 ? l : (i + 1) * size;
    stream.write(buffer.slice(start, end));

    if (end === l) {
      stream.end();
    } else {
      setTimeout(() => writeQuarter(i + 1), 3000);
    }
  };

  if (!!enable) {
    ctx.set({
      'Content-Length': `${l}`,
    });
  }

  ctx.set({
    'Content-Type': 'plain/txt',
    'Content-Disposition': `attachment; filename=1.txt`,
    Connection: 'keep-alive',
  });
  ctx.body = stream;
  writeQuarter();
});

这里利用了PassThrough流来替代fs.createReadStream,故Koa不再知道文件大小和类型,并将文件分为4份,每份间隔3秒发送来模拟大文件下载。

当参数enable为真时,设置了Content-Length则会显示进度,否则不显示:

image-20211229192434845

断点续传

下载文件特别大时,常常也会因为网络不稳定导致下载中途断开而失败,这时候可以考虑支持断点续传:

function getStartPos(range = '') {
  var startPos = 0;
  if (typeof range === 'string') {
    var matches = /^bytes=([0-9]+)-$/.exec(range);
    if (matches) {
      startPos = Number(matches[1]);
    }
  }
  return startPos;
}

router.get('/download/partial', async (ctx) => {
  const range = ctx.get('range');
  const start = getStartPos(range);
  const stat = await fsp.stat(`${__dirname}/1.txt`);
  const stream = fs.createReadStream(`${__dirname}/1.txt`, {
    start,
    highWaterMark: Math.ceil((stat.size - start) / 4),
  });

  stream.on('data', (chunk) => {
    console.log(`Readed ${chunk.length} bytes of data.`);
    stream.pause();
    setTimeout(() => {
      stream.resume();
    }, 3000);
  });

  console.log(`Start Pos: ${start}.`);
  if (start === 0) {
    ctx.status = 200;
    ctx.set({
      'Accept-Ranges': 'bytes',
      'Content-Length': `${stat.size}`,
    });
  } else {
    ctx.status = 206;
    ctx.set({
      'Content-Range': `bytes ${start}-${stat.size - 1}/${stat.size}`,
    });
  }

  ctx.set({
    'Content-Type': 'application/octet-stream',
    'Content-Disposition': `attachment; filename=1.txt`,
    Connection: 'keep-alive',
  });
  ctx.body = stream;
});

让我们来试验一下(Chrome默认下载工具不支持断点续传)

curl -v http://127.0.0.1/download/partial -o 1.txt

此时我们趁传输间隙,将服务进程停止。这时可以看到1.txt文件仅仅只传了18 bytes

image-20211229192750949

我们将服务恢复,恢复下载

curl -v http://127.0.0.1/download/partial -o 1.txt -C -

image-20211229193007181

可以看到剩下的部分也分4次传完了

动态表格

在了解完上述关于文件下载实现的基础后,我们来看一个实际问题:根据请求参数条件读取数据库的某张表的全部记录并导出为表格。

参考:

exceljs

瓶颈

// Controller.js
const sequelize = new Sequelize(name, user, password, {
  dialect: 'mysql',
  host,
  port,
});
const model = sequelize.import('/path/to/model');
const { rows } = await model.findAndCountAll({
  where: conditions,
  attributes: ['f_user_id'],
  group: 'f_user_id',
});

const list = await Promise.all(
  rows.map((item) => {
    const { f_user_id } = item;
    const userRows = await model.findAll({
      where: { ...conditions, f_user_id },
      // ordering, eager loading, ...
    });

    // formating userRows -> userData

    return userData;
  })
);

const headers = ['ID', /*...*/];
const sheetData = [headers, ...list];

ctx.attachment(`${sheetName}.xlsx`);
ctx.body = await exportXlsx(sheetName, sheetData);
// xlsx
const ExcelJS = require('exceljs');
const fs = require('fs');

module.exports = {
  exportXlsx: async (name = 'sheet', data) => {
    const tempFilePath = `./xlsx/${Date.now()}.xlsx`;
    const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({ 
      filename: tempFilePath 
    }); // 创建一个流式写入器
    const sheet = workbook.addWorksheet('My Sheet'); // 添加工作表
    const { length } = data;
    for (let i = 0; i < length; i++) {
      sheet.addRow(data[i]);
    }
    sheet.commit(); // 提交工作表
    await workbook.commit(); // 交工作簿,即写入文件
    return fs.createReadStream(tempFilePath);
  },
};

多数人业务初期做需求时,考虑到数据量还不是很多,排期紧任务重,都像上面这样实现:

  • 不考虑数据量

当数据库表记录数超过2w时,内存就已经承受不住导致Node进程退出了

  • 没有考虑内存限制

找个成熟的exceljs库,但却没有用其提供的流API

  • 数据查询逻辑实现完全不考虑性能

拿到ORM库就是调用查询,完全不考虑SQL查询并发数

优化

分段处理

最简单的策略就是将几w条数据库数据按每组1w条分组,分批次处理,有很多优秀的开源库以供使用比如async

简单代码示意:

let total = await model.count(/* ... */)
let page = 0;
const tasks = [];
const size = 10000;
while (total > 0) {
  tasks.push(() => queryModel({
    limit: size,
    offset: size * page
  }))
  page++;
  total -= size;
}
await async.series(tasks)

减少SQL查询数

源码中出现先group by查询出去重的f_user_id后,再来并发查询某一用户的所有记录。

这里应该用SQL中的IN先查完再匹配处理:

model.findAll({
  where: {
    ...conditions,
    f_user_id: rows.map(x => `${x.f_user_id}`)
  }
})

流处理

在上面的xlsx.js文件中,是先输出一个文件再使用fs.createReadStream流输出

exceljs库提供了API来实现流写:

const workbook = new Excel.stream.xlsx.WorkbookWriter(options);const sheet = workbook.addWorksheet('My Sheet');// .,,ctx.body = workbook.stream;

更多

当然除了上述提到的优化手段,还有离线生成、缓存等等诸多优化手段可用,这里不再展开

总结

文件导出是最常见的需求之一,把功能实现好是专业素质最好的体现。

此文篇幅有限,原理性的细节如Exceljs的依赖里对xlsx规范的zip流处理等等大家可以自行去了解一番

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.