[Nuxt系列03]发布上线之旅

1,648 阅读10分钟

千里之行,始于足下。古人总是言简意赅地阐述朴实的道理。很多关于梦想关于计划的事情,总是缺少一个开始,然后又在开始之后缺少一个坚持,最终夭折,无疾而终。好在我们有了一个开始,并慢慢坚持了下来。

随着开发进度的持续推进,终于,我们也要面临项目部署上线的各种问题。怎样持续迭代,怎样控制代码质量,怎样发布,怎样保证应用在线上的稳定运行……又是茫茫多的问题搞得人欲仙欲死呀~

一、从官方文档入手

首先来看一下 Nuxt文档的命令和部署章节

  • nuxt build 利用webpack编译应用,压缩 JS 和 CSS 资源(发布用)
  • nuxt start 以生产模式启动一个Web服务器 (nuxt build 会先被执行)

同时,在我们初始化项目的 package.json 里有如下指令配置:

{
  "build": "nuxt build",
  "start": "cross-env NODE_ENV=production node server/index.js",
}

然后,我们在终端执行 npm run build 指令,会得到如下目录结构的打包资源:

├─ .nuxt/            
│    ├─ components/
│    ├─ dist/ 
│    ├─ views/
│    ├─ App.js 
│    ├─ client.js 
│    ├─ ...
│    └─ ... 

最后执行 npm run start 指令,终端告诉我们:Server listening on http://localhost:3000,浏览器中可以看到,与我们在开发阶段执行 npm run dev 看到的画面将别无二致:

start_img

这一阶段,程序将调用 nuxt.config.js | .nuxt/ | server/ | static/ 等文件(夹)下的代码来支撑整个应用的运行。

此时,整个应用愉快地运行在我们地开发机上,一派欣欣向荣的景象呀!but……此时,我们点了一个超链接,来到了一个新的页面,页面背后的程序在发生各种“化学反应”的同时遇到了一个 bug,于是整个服务崩掉了~~~

试想一下,假如这是在生产环境发生的事件,大概是大型惊悚片现场足可媲美的画面吧。毕竟我们无法保证能够完全 catch 掉所有可能发生的错误,所以必须要解决掉单线程的 node 本身在一定程度上的不稳定性。当然 node 发展至今,生态之内早已有不少成熟的解决方案,要不然可能在多年以前早就可以打出 GG 离场了吧,也轮不到我等新人在这里“指手画脚”呀。所以我们在项目中选择 pm2 来守护进程,以确保程序在线上的相对稳定。

值得一提的是,我经常关注 Awesomes 站内一个叫 前端 TOP 100 的排行,里面网罗了时下流行的或颇具热度的前端技术。目前,Nuxt 排名第 94 位,而 pm2 排名第 56 位,而在一年前,两者都并未位列其中。先不谈这个排行的准确性权威性,但至少足以说明一些问题和趋势。ok,继续回到 pm2~

二、PM2 简介

先扔一个 pm2 的传送门。可以看到首页上醒目地写着一行指令:npm install pm2 -g,仿佛在大声告诉我们:“我能帮你管理你运行在线上的应用并让它活得好好的,快来全局安装我,帮你守护进程吧~”。

ok,照做。完毕后惯例 pm2 -v 查看安装结果。然后,终端来到我们的项目目录下,确保已经执行了 npm run build 指令并成功得到了上文提到的打包资源,运行 pm2 start npm -- run start,就可以将我们的 Nuxt 应用置于 pm2 的掌控之下了。

pm2start

如上图所示,pm2 帮我们启动了一个名为 npm 的 node 进程,模式为 fork,接下来几项分别是版本号、pid、上次更新时间和重载次数、运行状态、cpu和内存使用情况、是否监控项目下的文件变更等信息,以上的大部分信息我们都可以通过 cli 输入相应的指令进行控制。比如通过 pm2 start npm --name mynuxtdemo -- run start,就可以启动一个名为 mynuxtdemo 的应用(为啥不直接 pm2 start ./server/index.js 呢,遍历文档,似乎无法通过 cli 设置环境变量,不会是俺年纪大了眼神儿不好吧...),如图:

pm2startwithname

下面来看一下几个常用的配置项:

# 给你的应用起个名儿吧
--name <app_name>

# 监控项目下的文件变更,一旦出现变更,重启
--watch

# 设置一个允许当前应用占用的内存上限,一旦超过了,重启
--max-memory-restart <200MB>

# 设置日志的输出路径
--log <log_path>

# 为脚本传递额外的参数
-- arg1 arg2 arg3

# cluster模式下开启示例的数量,当设置为 max 时,根据当前主机的 cpu 核数设置
-i <instance_num>

最终,我们可以通过 cli 指令 pm2 start npm --watch -i max --name mynuxtdemo -- run start 来启动应用。以我的电脑为例,将启动一个名为 mynuxtdemo 的 Nuxt 应用,pm2 调用了 node 的 cluster 模块,根据 cpu 核数开启了 8 个实例,同时 pm2 将监听当前项目下的文件,当文件出现变更时,pm2 会尝试重启所有的 8 个实例以实现更新。

pm2cluster

然后我们来看一下在程序意外终止的情况下会发生什么:

pm2reload

此时,我在 server/index.js 下抛出了一个自定义异常 throw 'this is a test error',可以看到 pm2 在程序终止时在不断尝试重启的过程。

ok,至此关于 pm2 的简单介绍即将告一段落,但是每次需要启动程序的时候都需要这样一长串指令的输入也是一件很痛苦的事情,至少对于我这种"老年人记性"是不太能够接受的~

还好 pm2 提供了配置文件的方式来描述我们需要它作出何种行为,以满足我们的预期,就像 gulp | eslint ... 它们那样,同样提供了多种多样的文件格式,总有一款符合你的口味。详情见 PM2 Ecosystem File 章节。这里,我们新建了一个 pm2.config.json 文件来描述我们对 pm2 提出的“要求”:

{
  "apps": [
    {
      "name": "mynuxtdemo",
      "script": "./server/index.js",
      "instances": 4, 
      "max_memory_restart": "500M",
      "watch": [
        "./server",
        "./nuxtdist"
      ],
      "ignore_watch": [
        "./nuxtdist/dist/client"
      ],
      "env": {
        "NODE_ENV": "production",
        "DEMO_ENV": "prod"
      },
      "env_tset": {
        "NODE_ENV": "production",
        "DEMO_ENV": "test"
      },
      "env_dev": {
        "NODE_ENV": "development",
        "DEMO_ENV": "dev"
      },
      "output": "./logs/out.log",
      "error": "./logs/error.log",
      "log_date_format":"YYYY-MM-DD HH:mm CCT",
    }
  ]
}

然后,我们在 package.json 中作如下配置:

{
  "scripts": {
    "dev": "cross-env DEMO_ENV=dev NODE_ENV=development nodemon server/index.js --watch server",
    "build": "cross-env DEMO_ENV=prod NODE_ENV=production nuxt build",
    "start": "cross-env DEMO_ENV=prod NODE_ENV=production node server/index.js",
    "pm2start": "pm2 start pm2.config.json",
    "buildtest": "cross-env DEMO_ENV=test NODE_ENV=production nuxt build",
    "starttest": "cross-env DEMO_ENV=test NODE_ENV=production node server/index.js",
    "pm2test": "pm2 start pm2.config.json --env test"
  }
}

今后,我们只要执行 npm run pm2start 就可以开始愉快地玩耍了~

pm2withconfigfile

当然了,上文所及仅是 pm2 的冰山一角,它还有很多的配置选项可以满足我们这样那样的需求,还请通读文档,并待日后带着问题找答案吧。 下面列几个平时常用的 pm2 指令:

pm2 list
pm2 logs
pm2 show 0
pm2 reload all
pm2 start 0
pm2 delete all
pm2 flush
#...

三、将打包产物上传至线上

终于,在某一天,当前的开发进度渐渐接近尾声了,我们也要尝试让应用运行在生产环境下来一睹其风采了。毕竟对于我们整个团队来说,Nuxt 还是一个陌生的框架,早做准备总没有错。

首先,我们使用 nginx 来作反向代理,Nuxt 文档很贴心地给处理一个示例代码(很久以前似乎是木有的?😢),戳 Nginx 使用nginx作为反向代理 围观。为 nginx 添加本项目的配置文件,比如在 nginx/conf.d/ 下新建一个 mynuxtdemo.conf 文件,并有如下配置:

upstream nuxt_demo {
   server 127.0.0.1:3000;
}

server {
  listen      80;
  server_name test.mynuxtdemo.com;

  gzip            on;
  gzip_types      text/plain application/xml text/css application/javascript;
  gzip_min_length 1000;

  location / {
      proxy_http_version 1.1;

      proxy_redirect                      off;

      proxy_set_header Host $host;
      proxy_set_header X-Real-IP          $remote_addr;
      proxy_set_header X-Forwarded-For    $proxy_add_x_forwarded_for;
      proxy_set_header X-Forwarded-Proto  $scheme;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection "upgrade";
      proxy_set_header X-Nginx-Proxy true;
      proxy_cache_bypass $http_upgrade;

      proxy_pass http://nuxt_demo;
      # ...
  }
}

重启 nginx 使配置生效,然后将打包后的资源(nuxt.config.js | .nuxt/ | server/ | static/...etc)上传至服务器,项目根目录下npm i --production,依赖安装后,继续执行 npm run pm2start,然后在浏览器访问 test.mynuxtdemo.com 就可以访问我们的 nuxt 应用了。

此时,我的手里有了一个生产环境下直达该项目根目录的 ftp 账号,用于项目迭代过程中上传新版本的打包产物。项目小分队们继续做正式上线前的开发工作,某一天在好几个时间段完成了好几个新的功能并伴随着产品同学永不停歇的改这改那的碎碎念,于是 npm run build >> ftp upload >> npm run build >> ftp upload ...。更不能忍受的是,还要不停地把 .nuxt/dist/client/ 下的资源上传到阿里云 oss 上。 当时我的内心是绝望的,来回折腾,效率低下,还容易出错,哭。

总之,遇到问题就要解决问题,此时脑海中闪过那句话:你遇到的 99% 的问题都可以在互联网上找到答案。于是果断投入 npm 和 github 的怀抱寻求解救,并顺利找到答案。经过一番筛选,最终选择了两个插件来作为辅助上传的工具,分别是:ftp 客户端 node 版本阿里云 oss 上传插件。基于这两个插件,并根据我们现阶段的需求,分别编写了 ftp 和 oss 的上传脚本:

// uploader/ftp.js
const fs = require('fs');
const path = require('path');
const ftp = require('ftp');
const log = require('single-line-log').stdout;
// ftp服务器参数配置
const ftp_config_hjxy = {
  host: '1xx.xxx.xxx.xxx',
  port: 21,
  user: "username",
  password: "password"
}

const topPath = '/nuxtdist'; // 服务器目标路径(我们的dist目录配置为 nuxtdist/)
const dirpath = path.resolve(__dirname, '../nuxtdist'); // 本地需要上传的文件夹路径

let streamList = [];
let mkDirList = [];
let streamAmount = 0;
streamFactory(dirpath, topPath);

const c = new ftp();
c.connect(ftp_config_hjxy); //链接ftp服务器
c.on('ready', function (err) {  //准备操作
  if(err) throw err;
  console.log('\x1B[32m√\x1B[39m connection successful.');
  // 先创建可能不存在的文件夹
  mkDirList.forEach((dirPath) => {
    c.mkdir(dirPath, false, (err) => {
      err ? console.log(`\x1B[33m创建文件夹${dirPath}失败--已存在/未知错误-${err}\x1B[0m`) : console.log(`\x1B[32m创建文件夹${dirPath}成功\x1b[39m`);
    });
  });
  // 然后开始上传文件
  streamList.forEach((fileItem, idx) => {
    c.put(fileItem.stream, fileItem.destPath, false, (err) => {
      if (err) { console.log(`上传${fileItem.destPath}失败${err}`); }
      log(`\x1B[32m文件已上传-${idx + 1}/${streamAmount}-\x1b[39m`);
      (idx + 1 === streamAmount) && c.end();
    });
  });
});
c.on('error', function (err) {
  if(err) throw err;
  console.log("err", err);
});

/**
 * 生成stream流列表
 * @param {String} dirpath 
 * @param {String} destPath 
 */
function streamFactory(dirpath, destPath) {
  const files = fs.readdirSync(dirpath);
  files.length && files.forEach((filename) => {
    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) {
      if(filename === 'client') return;
      mkDirList.push(`${destPath}/${filename}`)
      streamFactory(filepath, `${destPath}/${filename}`);
    }
  });
}

这里有一个小 bug,服务器中 topPath 的目录必须存在。由于除了打包产出的资源以外的文件都相对稳定,并且 server/ 下的代码更新需要格外谨慎,所以这里只做打包资源的上传,并剔除了 .nuxt/dist/client/ 下的文件。接下来就需要把 .nuxt/dist/client/ 下的文件上传至 oss 了,思路上与上面 ftp 上传的代码大同小异。

// uploader/oss.js
const OSS = require('ali-oss');
const fs = require('fs');
const path = require('path');
const log = require('single-line-log').stdout;

const client = new OSS({
  region: 'your oss region',
  accessKeyId: 'your oss accessKeyId',
  accessKeySecret: 'your oss accessKeySecret',
  bucket: 'nuxtdemo',
});

const aliyunOss = {
  bucket: 'nuxtdemo',
  site: 'your site addr',
  dirName: '/nuxtclient'
};

const dirpath = path.resolve(__dirname, '../nuxtdist/dist/client');
let streamList = [];
let streamAmount = 0;
streamFactory(dirpath, aliyunOss.dirName);
streamList.forEach((fileItem, idx) => {
  putStream(fileItem.destPath, fileItem.stream);
  log(`\x1B[32m--正在上传${idx + 1}/${streamAmount}--\x1B[39m`);
  (idx + 1 === streamAmount) && log('\x1B[32m上传完成\x1B[39m');
});

/**
 * aliyunoss 流式上传
 * @param {String} filename 上传至oss使用的文件名
 * @param {String} stream 可读的文件流
 */
async function putStream (filename, stream) {
  try {
    await client.putStream(filename, stream);
  } catch (err) {
    throw err;
  }
}

/**
 * 生成stream流列表
 * @param {String} dirpath 本地需要上传的目录位置
 * @param {String} destPath 上传至服务器的目录位置
 */
function streamFactory(dirpath, destPath) {
  const files = fs.readdirSync(dirpath);
  files.length && files.forEach((filename) => {
    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) {
      streamFactory(filepath, `${destPath}/${filename}`);
    }
  });
}

然后,我们在 package.json 中增添如下两条新的配置:

{
  "script": {
    "oss": "node uploader/oss.js",
    "ftp": "node uploader/ftp.js"
  }
}

这样,只需要在打包完毕后,分别执行 npm run ossnpm run ftp 就可以将新的代码安排到服务器了。

考虑到经常要进行 oss 上传,于是又将 oss.js 部分封装成一个 npm oss上传插件,这样以后只需要在需要用到的地方 npm install --save-dev @crazymuyang/alioss-uploader 安装,并增加一个简单的配置文件就可以使用了:

const aliOssUploader = require('@crazymuyang/alioss-uploader');
const path = require('path');
const aliossConfig = {
  region: 'your oss region',
  accessKeyId: 'your oss accessKeyId',
  accessKeySecret: 'your oss accessKeySecret',
  bucket: 'your oss bucket',
};
const uploadConfig = {
  dirpath: path.resolve(__dirname, './test'),  // 将该路径下的文件上传至oss()
  destpath: '/test',  // 将文件上传至bucket下的该路径下
}

const uploader = new aliOssUploader(aliossConfig, uploadConfig);
uploader.start();

四、你以为结束的时候,其实“灾难”刚刚开始

就这样,跌跌撞撞中项目可以勉强上线了。然而,我们发现在 ftp 上传的过程中会进入长时间的 502 状态,此时 pm2 的 watch 功能不断监测到文件变更,同时不断地尝试重启实例,直到最后一个文件上传完毕,整个应用恢复正常。此时,我们执行 pm2 ls 查看会发现 reload 的次数增加了好多次。

这样肯定是不行的呀,于是便采用了一个折中的办法,为 nginx 配置 502 跳转页面(loading),然后在这个页面里通过定时器在一段时间后再跳回到项目域名下。此时,在文件上传过程中就是一个路由来回重定向的过程,用户视角下就是有一段时间应用一直停留在一个 loading 页面。这样有一个很明显的弊端,从 loading 页面回来只能去往首页,而无法跳回之前用户访问的页面。

所以,问题来了:到底怎样才能实现真正地不停机更新呢?其实答案很简单,之所以会长时间处于 502 状态,仅仅是因为短时间内 pm2 监听了大量文件变更,它跟不上趟了。

如何解决,以实现真正的不停机呢?且听下回分解~