[Nuxt 系列 06] 不停机更新:基于 PM2

2,556 阅读5分钟

[Nuxt系列03]发布上线之旅 文末已经提及,要基于 PM2 实现 nuxt 项目的不停机更新,只要不使 pm2 短时间内 watch 过多文件变更。

上一篇文章中的做法(监听所有 nuxtdist/* 下的文件变更),其弊端也就在于此,pm2 因为大量文件变更而频繁重启项目,造成了长时间的空档期(502 状态)。那就不要再监听 nuxtdist/ 文件夹下的文件不就可以了?

首先,

修改 pm2 配置文件 pm2.config.json

{
  "apps": [
    {
     "name": "mynuxtdemo",
      "script": "./server/index.js",
      "instances": 4, 
      "max_memory_restart": "500M",
      "watch": [
        "./server"
      ],
      // "ignore_watch": [
      //   "./nuxtdist/dist/client"
      // ],
      //...
    }
  ]
}

现在,我们只监听 server/ 文件夹下文件的变更了,这时如果 我们上传了新的打包物料,要如何使 pm2 重启项目呢?

增加版本控制文件 version.js

在项目根目录下增加该文件,目的是想通过它来实现一定程度上的对打包产出文件的版本控制,同时让 pm2 通过监听它的变更来实现不同版本之间的快速切换。

想要达到的目标:当非服务端代码(/server/*),即仅前端 UI 更新时,就可以提前将新版本的物料(这里主要是 /nuxtdist 下的打包资源)部署到服务器和 oss 上,最后再更新一下服务器上的这个文件,pm2 监听变更,只要通过一次自动重启就可以切换到新的版本,从而避免了长时间处于“宕机状态”。

const versionLs = [
  'v1.0.0', 
  'v1.0.1', 
]
const version = 'v1.0.1';

module.exports = {
  version,
  versionLs,
};

可以看到上面代码对外暴露了两个变量,一个是历史版本的记录 versionLs,一个是当前要发布的版本 version

再次 pm2 配置文件 pm2.config.json,将 version.js 加入监听列表。

{
  "apps": [
    {
      //...
      "watch": [
        "./server",
        "./version.js"
      ],
      //...
    }
  ]
}

接下来通过这两个变量来实现区分版本的 nuxtdist/ 输出及 对应的服务器和 oss 代码上传脚本。

nuxt.config.js 中对于打包相关参数的配置

version.js 中引入 version 参数,让它分别作用于打包资源输出目录 buildDir模板对静态资源引用的 publicPath 上。

const version = require('./version').version;
const buildDirTest = process.env.DEMO_ENV === 'test' 
  ? `nuxt${process.env.DEMO_ENV}/${version}` 
  : `nuxt${process.env.DEMO_ENV}`;

module.exports = {
  mode: 'universal',
  buildDir: process.env.DEMO_ENV === 'prod' ? `nuxtdist/${version}` : buildDirTest,
  env: {
    DEMO_ENV: process.env.DEMO_ENV,
    NODE_ENV: process.env.NODE_ENV,
  },
  build: {
    extractCSS: process.env.DEMO_ENV === 'prod',
    publicPath: process.env.DEMO_ENV === 'prod' ? `https://static.xxxx.com.cn/nuxtstatic/${version}/` : '/_nuxt/',
  },
  // ...
}

此时,假设前后有两个版本,分别是 v1.0.0v1.0.1,我们执行了两次 npm run build,在 /nuxtdist/* 文件夹下将形成这样的目录结构:

|-- nuxtdist/
|  |-- v1.0.0/
|  |-- v1.0.1/
|

然后,

重构资源文件上传脚本

首先,重构并封装 ftp 和 oss 上传脚本的主体代码,并修复上个版本中的 bug(服务器中 topPath 的目录必须存在)。

  1. ftp
// uploader/utils/uploaderFtp.js
const ftp = require('ftp');
const log = require('single-line-log').stdout;
const streamFactory = require('./streamFactory');
const privateLog = require('./logstyle');

class ftpUploader {
  /**
   * ftpUploader
   * @param {Object} ftpConfig 
   * @example { 
   *  host: '111.11.11.11', 
   *  port: 21, 
   *  user: your username, 
   *  password: your pwd,
   * }
   * @param {Object} uploadConfig 
   * @example { 
   *  dirpath: path.resolve(__dirname, './test'),   
   *  destpath: 'server dir', 
   *  exclude: ['files you dont want to upload'],
   *  version,
   *  env: online|test|any msg
   * }
   */
  constructor(ftpConfig, uploadConfig) {
    this.client = new ftp();

    this.ftpConfig = ftpConfig;
    this.uploadConfig = uploadConfig;
    this.version = uploadConfig.version || 'unset';
    this.env = uploadConfig.env || 'online';

    this.streamList = [];
    this.mkDirList = [];
    this.streamAmount = 0;

    this.init();
  }

  init() {
    this.client.connect(this.ftpConfig);
    const { streamList, mkDirList, streamAmount } = streamFactory({
      dirpath: this.uploadConfig.dirpath,
      filename: this.uploadConfig.filename || '',
      destpath: this.uploadConfig.destpath,
      type: 'ftp',
      exclude: this.uploadConfig.exclude,
    });
    this.streamList = streamList;
    this.mkDirList = mkDirList;
    this.streamAmount = streamAmount;
    this.start();
    this.error();
  }

  start() {
    const _this = this;
    this.client.on('ready', function(err) {
      if(err) { throw err; }
      privateLog.loginok(`${_this.env}:connection successful. current version: ${_this.version}`);
      
      // 创建可能不存在的文件夹
      if(!_this.streamList.length) {
        privateLog.warn('there is nothing to upload!');
        _this.client.end();
        return;
      }
      const l1 = _this.mkDirList.length;
      for(let i = 0; i < l1; i ++) {
        const dirPath = _this.mkDirList[i];
        _this.client.mkdir(dirPath, false, (err) => {
          err
            ? privateLog.warn(`创建${dirPath}文件夹失败-已存在/未知错误-${err}`)
            : privateLog.ok(`创建${dirPath}成功`);
        });
      }
      // 然后开始上传文件
      const l2 = _this.streamList.length;
      for(let j = 0; j < l2; j ++) {
        const fileItem = _this.streamList[j];
        _this.client.put(fileItem.stream, fileItem.destpath, false, (err) => {
          if (err) { 
            privateLog.warn(`上传${fileItem.destpath}失败${err}`); 
            return;
          }
          log(privateLog.oksty(`已上传-${j + 1}/${_this.streamAmount}`));
          (j + 1 === _this.streamAmount) && _this.client.end();
        });
      }
    });
  }

  error() {
    this.client.on('error', function(err) {
      if(err) {
        console.warn(err, 'ftp error');
        process.exit();
      }
    });
  }
}

module.exports = ftpUploader;

  1. oss
// uploader/utils/uploaderOss.js
const log = require('single-line-log').stdout;
const streamFactory = require('./streamFactory');
const privateLog = require('./logstyle');

class ossUploader {
  /**
   * @param {Object} aliossConfig 
   * @example { 
   *  region: 'oss-cn-hangzhou',
   *  accessKeyId: 'your keyid', 
   *  accessKeySecret: 'you keysecret', 
   *  bucket: 'your bucket' 
   * }
   * @param {Object} uploadConfig 
   * @example { 
   *  dirpath: path.resolve(__dirname, './test'), 
   *  destpath: 'your destpath belong to the current bucket', 
   *  exclude: ['files you dont want to upload'], 
   *  version,
   * }
   */
  constructor(aliossConfig, uploadConfig) {
    const OSS = require('ali-oss');
    this.client = new OSS(aliossConfig);
    this.dirpath = uploadConfig.dirpath;
    this.destpath = uploadConfig.destpath;
    this.exclude = uploadConfig.exclude || [];
    this.version = uploadConfig.version || '';
    this.streamList = [];
    this.streamAmount = 0;
    this.uploadedAmount = 0;

    this.start();
  }

  start() {
    const { streamList, streamAmount } = streamFactory({
      dirpath: this.dirpath,
      destpath: this.destpath,
      type: 'oss',
      exclude: this.exclude,
    });
    this.streamList = streamList;
    this.streamAmount = streamAmount;

    if(!this.streamList.length) {
      privateLog.warn('there is nothing to upload!');
      process.exit();
      return;
    }
    
    const l = streamList.length;
    for(let i = 0; i < l; i ++) {
      const fileItem = streamList[i];
      this.putStream(fileItem.destpath, fileItem.stream);
    }
  }
 
  putStream(filename, stream) {
    this.client.putStream(filename, stream).then(res => {
      this.uploadedAmount ++;
      log(privateLog.oksty(`已上传-${this.uploadedAmount}/${this.streamAmount}-${res.name}`));
      (this.uploadedAmount === this.streamAmount) && log(privateLog.oksty(`${this.version}上传完成`));
    }).catch(err => {
      console.warn(err, 'putStream error');
      process.exit();
    });
  }
}

module.exports = ossUploader;

  1. stream
const fs = require('fs');
const path = require('path');
const mkDirList = [];
const streamList = [];
let streamAmount = 0;
/**
 * 生成stream流列表
 * @param {Object} options
 *   @param {String} dirpath 上传目标的本地根目录
 *   @param {String} destpath 上传目标的服务器目录
 *   @param {String} type ftp|oss 
 *   @param {Array} exclude 不上传的文件/文件夹
 */

function streamFactory(dirpath, destpath, type = 'ftp', exclude) {
  const isFile = fs.statSync(dirpath).isFile();
  if(isFile) { // 如果只有一个文件
    const stream = fs.createReadStream(dirpath);
    const filename = dirpath.split('\\').pop();
    streamList.push({ stream, destpath: `${destpath}/${filename}` });
    streamAmount++;
    return;
  }

  const files = fs.readdirSync(dirpath);
  const l = files.length;
  for(let i = 0; i < l; i++) {
    const filename = files[i];
    if(exclude.includes(filename)) { return; }

    const filepath = path.resolve(dirpath, filename);
    const isDir = fs.statSync(filepath).isDirectory();
    const isFile = fs.statSync(filepath).isFile();
    if(isFile) {
      const stream = fs.createReadStream(filepath);
      streamList.push({ stream, destpath: `${destpath}/${filename}` });
      streamAmount++;
    }else if (isDir) {
      mkDirList.push(`${destpath}/${filename}`);
      streamFactory(filepath, `${destpath}/${filename}`, type, exclude);
    }
  }
}

module.exports = function(options) {
  const { dirpath, destpath, type, exclude } = options;

  // 拆分 destpath 代表的目录层级,用以创建该目录层级下可能不存在的文件夹
  const destTopDirArr = destpath.split('/').filter(item => item);
  const l = destTopDirArr.length;
  for(let i = 0; i < l; i++) {
    mkDirList.push(`${mkDirList[i - 1] || ''}/${destTopDirArr[i]}`);
  }

  streamFactory(dirpath, destpath, type, exclude);
  return {
    mkDirList,
    streamList,
    streamAmount,
  }
};

上传文件至服务器的脚本 ftp.js

引入 version.js 中对外暴露的参数,用它确定上传哪个版本的打包产物,并在终端里输出一些提示信息。

通过命令行参数 online|test 区分 生产|测试 。

const path = require('path');
const FtpUploader = require('./utils/uploaderFtp');
const version = require('../version').version;
const { ftpOnlineConfig } = require('./utils/config');

const argvArr = process.argv;
const env = argvArr[argvArr.length - 1];

const uploadOnlineConfig = {
  dirpath: path.resolve(__dirname, `../nuxtdist/${version}`),
  destpath: `/nuxtdist/${version}`,
  exclude: ['client'],
  version,
  env: 'online',
};
const uploadTestConfig = {
  dirpath: path.resolve(__dirname, `../nuxtbeta/${version}`),
  destpath: `/nuxt/nuxtbeta/${version}`,
  exclude: [],
  version,
  env: 'test',
};

const ftpConfig = env === 'online' ? ftpOnlineConfig : ftpTestConfig;
const uploadConfig = env === 'online' ? uploadOnlineConfig : uploadTestConfig;

new FtpUploader(ftpConfig, uploadConfig);

上传文件至 oss 的脚本 oss.js

同样,引入 version.js 中对外暴露的参数,用它确定上传哪个版本的打包产物,并终端里输出一些提示信息。

const path = require('path');
const version = require('../version').version;
const ossUploader = require('./utils/uploaderOss');
const { ossConfig } = require('./utils/config');

const uploadConfig = {
  dirpath: path.resolve(__dirname, `../nuxtdist/${version}/dist/client`), 
  destpath: `/nuxtstatic/${version}`,  
  exclude: [],
  version,
};

new ossUploader(ossConfig, uploadConfig);

最终,在服务器和 oss 中都将长期存储如下形式目录结构的文件

|-- nuxtdist/
|  |-- v1.0.0/
|  |-- v1.0.1/
|  |-- v...
|

有了以上的部署脚本,我们还需要

定义一个 deploy 指令

通过执行 npm run deploy 指令更新服务器上的 version.js,实现 UI 版本的切换。

{
  "scripts": {
    "deploy": "node uploader/deploy.js online",
    "deploytest": "node uploader/deploy.js test"
  }
}

deploy.js

const path = require('path');
const FtpUploader = require('./utils/uploaderFtp');
const version = require('../version').version;
const { ftpOnlineConfig, ftpTestConfig } = require('./utils/config');
const argvArr = process.argv;
const env = argvArr[argvArr.length - 1];

const uploadOnlineConfig = {
  dirpath: path.resolve(__dirname, '../version.js'),
  destpath: '',
  exclude: [],
  version,
  env: 'deployonline',
};
const uploadTestConfig = {
  dirpath: path.resolve(__dirname, '../version.js'),
  destpath: '/nuxtdemo',
  exclude: [],
  version,
  env: `deploytest`,
};

const ftpConfig = env === 'online' ? ftpOnlineConfig : ftpTestConfig;
const uploadConfig = env === 'online' ? uploadOnlineConfig : uploadTestConfig;

new FtpUploader(ftpConfig, uploadConfig);


以上,对于只有几个人的小团队来说,并不需要太多“繁重的”基础设施就可以达成目的,只要遵守必要的约定即可。而当进行版本迭代时,一旦发现本次的线上版本有问题,就可以通过修改 version.js 中的 version 参数为上一个版本号,执行 npm run deploy 指令实现线上的快速回退。