[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.0
和 v1.0.1
,我们执行了两次 npm run build
,在 /nuxtdist/* 文件夹下将形成这样的目录结构:
|-- nuxtdist/
| |-- v1.0.0/
| |-- v1.0.1/
|
然后,
重构资源文件上传脚本
首先,重构并封装 ftp 和 oss 上传脚本的主体代码,并修复上个版本中的 bug(服务器中 topPath 的目录必须存在)。
- 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;
- 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;
- 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
指令实现线上的快速回退。