自动化部署前端项目到服务器

1,783 阅读4分钟

1.明确需求

进行开发前需要首先明确需求,根据常见的前端部署流程总结为以下流程: 根据部署流程明确自动化部署的需求:

2.开发前准备

2.1 导入依赖模块

由于需要实现文件压缩、模拟form表单提交、loading效果、友好提示效果、文件读写、文件上传,因此至少需要以下模块:

  • compressing 模块(支持压缩文件夹,支持zip压缩)
  • child_process 模块 (可以用来创建一个子进程,在js里面调用shell命令)
  • fs 模块 (可用于与文件系统进行交互)
  • form-data 模块 (将form表单元素的name与value进行组合,实现表单数据的序列化,从而减少表单元素的拼接,提高工作效率)
  • Ora 模块 (命令行环境的loading效果及显示各种状态的图标)
  • Chalk 模块(修改控制台中字符串的样式,包括字体样式、字体颜色、背影颜色)
  • http 模块(创建http服务器、客户端)
2.2 如何实现规范

为实现需求中的解耦合理与逻辑清晰/灵活,需要关注整体程序逻辑,这里选择封装相关功能实现,并在主程序中自由调度(可灵活调用、关闭、修改相关功能),并对于当前所执行的功能给与提示,以保证功能实现的完整性和异常提示。

到这里就完成了对程序功能构建的梳理工作,下面进入项目实现。

3.功能实现

3.1 打包构建

使用child_process模块调用shell命令,进行本地打包构建,其中env代表不同的构建环境,可从不同的执行脚本中获取到。

由于package.json scripts下面配置如下: "upload:server": "node build/deploy.js --sit",可以根据自己需要灵活配置。

const env = process.argv[process.argv.length - 1].replace('--', ''); // 用于获取构建脚本环境参数
childProcess.exec(`npm run build:${env}`, function(error, stdout, stderr) {
  if (error) {
    console.log('exec error: ' + error);
  }
  compress();
});
3.2 本地压缩文件、功能提示

项目打包构建完成后,执行功能提示、压缩操作,此处使用compressing、ora模块,用于对产物文件夹进行zip压缩及功能性提示

const spinner = ora('开始打包……').start(); 
spinner.text = '打包完成,开始压缩';
  // 压缩命令
  // 此处第一个参数为要打包的目录, 第二个参数是打包后的文件名
  compressing.zip
    .compressDir('web/', 'web.tar.gz')
    .then(() => {
      setTimeout(() => {
        spinner.text = '压缩完成,开始上传';
        upload();
      }, 300);
    })
    .catch(handleError);
3.3 上传压缩后的文件到文件服务器

文件服务器搭建方式:

如何构建简易版的文件服务器

使用说明:

  • Form-data表单模拟文件上传
  • 发送post请求上传文件到服务器
  • 获取接口返回code,用于下载上传到文件服务器的文件
<form action="/ishare/profile" method="post" enctype="multipart/form-data">
  <input type="file" name="avatar" />
  <button type="submit">submit</button>
</form>
  • form表单中entype属性可以用来控制对表单数据发送前如何进行编码
  • multipart/form-data不对字符编码,用于发送二进制的文件,其他两种类型(text/plain、application/x-www-form-urlencoded)不能用于发送文件;
  • type=file 用于文件上传
  • name 用于获取文件名

大致实现过程如下:

var FormData = require('form-data');
var formData = new FormData();
formData.append(
    'avatar',
    fs.createReadStream(path.resolve(__dirname, '../web.tar.gz'))
  ); //'file'是服务器接受的key
  var headers = formData.getHeaders(); //这个不能少
  var request = http.request(
    {
      method: 'post',
      host: config.remotePath,
      path: '/ishare/profile',
      headers: headers
    },
    function(res) {
      var str = '';
      res.on('data', function(buffer) {
        str += buffer; //用字符串拼接
      });
      res.on('end', () => {
        if (str) {
          const result = JSON.parse(str);
          const uploaded = result.uploaded;
          const fileCode = uploaded.substring(uploaded.indexOf('/') + 1);
          spinner.text = '上传完成,开始从服务器获取文件';
          getFile(fileCode);
        }
      });
    }
  );
  formData.pipe(request);
3.4 在远程服务器上获取上传的文件

压缩文件上传到文件服务器后,通过接口返回的fileCode,调用远程服务器部署好的接口服务。

接口服务主要做以下几件事:

  • 避免连接无端服务器操作,减少开墙、不稳定因素
  • 下载上传成功的文件到服务器指定位置
  • 配置化配置服务器和目标文件目录地址
  • 不重复备份原始文件
  • 删除静态资源文件目录
  • 解压新的压缩文件
3.5 获取fileCode和sessionTick(避免用户恶意调用接口)并下载文件到服务器指定位置
app.get('/getFile', function(req, res) {
  const fileCode = req.query.code;
  const sessionTick = req.query.sessionTick;
  getFileByCode({fileCode: fileCode,sessionTick: sessionTick}, res);
});

 async function getFileByCode(obj, res) {
  if (obj.sessionTick && obj.sessionTick !== '522314cc-f038-4abf-bb36-adc912e506e2') {
    res.msg = 'sessionTick missed or error';
    res.code = '0';
    res.status = 200;
    res.json(res);
    return
  }
  console.log('sessionTick missed');
  await dirExists(`./${config.filePath}`);
  let httpStream = request({
    method: 'GET',
    url: `http://${config.remotePath}/ishare/${obj.fileCode}`
  });
  let writeStream = fs.createWriteStream(`./${config.filePath}/${config.fileName}`);
  // 联接Readable和Writable
  httpStream.pipe(writeStream);

  let totalLength = 0;

  // 当获取到第一个HTTP请求的响应获取
  httpStream.on('response', response => {
    console.log('response headers is: ', response.headers);
  });

  httpStream.on('data', chunk => {
    totalLength += chunk.length;
    console.log('recevied data size: ' + totalLength + 'KB');
  });
  // 下载完成
  writeStream.on('close', () => {
    console.log('download finished');
    executeShell(res);
  });
}

其中dirExists用于判断指定路径是否存在,不存在就创建

/**
 * 路径是否存在,不存在则创建
 * @param {string} dir 路径
 */
async function dirExists(dir) {
  let isExists = await getStat(dir)
  //如果该路径且不是文件,返回true
  if (isExists && isExists.isDirectory()) {
    return true
  } else if (isExists) {
    //如果该路径存在但是文件,返回false
    return false
  }
  //如果该路径不存在
  let tempDir = path.parse(dir).dir //拿到上级路径
  //递归判断,如果上级目录也不存在,则会代码会在此处继续循环执行,直到目录存在
  let status = await dirExists(tempDir)
  let mkdirStatus
  if (status) {
    mkdirStatus = await mkdir(dir)
  }
  return mkdirStatus
}
/**
 * 读取路径信息
 * @param {string} path 路径
 */
function getStat(path) {
  return new Promise((resolve, reject) => {
    fs.stat(path, (err, stats) => {
      if (err) {
        resolve(false);
      } else {
        resolve(stats);
      }
    });
  });
}

/**
 * 创建路径
 * @param {string} dir 路径
 */
function mkdir(dir) {
  return new Promise((resolve, reject) => {
    fs.mkdir(dir, err => {
      if (err) {
        resolve(false);
      } else {
        resolve(true);
      }
    });
  });
}
3.6 执行deploy.sh并备份服务器前端资源

其中executeShell函数主要用于执行shell命令sh deploy.sh ,通过传递的目标文件目录备份原始文件

async function executeShell(res) {
  await dirExists(`${config.projectPath}/${config.projectName}/${config.filePath}`);
  childprocess.exec(`sh deploy.sh ${config.projectName} ${config.filePath} ${config.fileName}`, function(error, stdout, stderr) {
    if (error) {
      result.msg = `执行脚本deploy.sh报错,错误信息为: ${error}`;
      result.code = '1';
      result.status = 500;
      res.json(result);
      console.log('exec error: ' + error);
    } else {
      console.log('执行脚本deploy.sh完成');
      copy(res);
    }
  });
}

deploy.sh代码实现如下:

#!/bin/sh
nowtime=$(date "+%Y%m%d")
echo $nowtime

cd /home/bankdplyop/${1}
rm -rf ${3}
tar -cf ${2}$nowtime.tar.gz ${2}

其中1{1}、{2}、3指执行shell命令时传递的参数,此处执行的命令为:shdeploy.sh{3}指执行shell命令时传递的参数,此处执行的命令为: sh deploy.sh {config.projectName} config.filePath{config.filePath} {config.fileName}

config文件用于自由化配置目标文件地址、项目源文件地址及一些远程服务器相关参数

module.exports = {
  sit: {
    remotePath: '29.2.221.176', //上传的远程服务器的目录
    filePath: 'web',
    fileName: 'web.tar.gz',
    projectName: 'iris_front',
    projectPath: '/home/bankdplyop',
    host: '0.0.0.0', //远程主机
    port: 3000 //服务器端口号
  }
};

3.7 删除静态资源文件目录并解压下载的文件
  • 拷贝下载完成的文件到待部署的项目所在地
  • 执行replace.sh脚本
  • 提示用户发布完成,请测试
async function copy(res) {
  const destFilePath = path.resolve(__dirname, `${config.projectPath}/${config.projectName}/${config.fileName}`);
  const filePath = path.resolve(__dirname, `./${config.filePath}/${config.fileName}`);
  await dirExists(`${config.projectPath}/${config.projectName}`);
  fs.writeFile(destFilePath, fs.readFileSync(filePath), function(err) {
    if (err) {
      result.msg = '文件拷贝失败';
      result.code = '1';
      result.status = 500;
      res.json(result);
    };
   executeReplaceShell(res);
  });
}

其中executeReplaceShell(res),当文件拷贝完成时,执行删除和解压操作

async function executeReplaceShell(res) {
  console.log(
    'replace shell start: ' +
      `${config.projectName}/${config.filePath}/${config.fileName}`
  )
  try {
    childprocess.exec(
      `sh replace.sh ${config.projectName} ${config.filePath} ${config.fileName} `,
      function (error, stdout, stderr) {
        if (error) {
          result.msg = `替换脚本replace.sh报错,错误信息为: ${error}`
          result.code = '1'
          result.status = 500
          res.json(result)
        } else {
          result.msg = '发布完成,请测试'
          result.code = '0'
          result.status = 200
          res.json(result)
        }
      }
    )
  } catch (e) {
    console.log(e)
  }
}

replace.sh代码实现如下:

cd /home/bankdplyop/${1}
ls ${2}
rm -rf ${2}
unzip ${3}

其中1{1}、{2}、3指执行shell命令时传递的参数,此处执行的命令为:shreplace.sh{3}指执行shell命令时传递的参数,此处执行的命令为: sh replace.sh {config.projectName} config.filePath{config.filePath} {config.fileName}

至此完成了整个自动化发布流程。

使用

项目根目录直接运行即可

npm run upload:server
总结

前端自动化部署是一件非常有意义的功能,记得最开始代码上传服务器,都需要手动进行操作,真得是好不麻烦,希望感兴趣的同学,如果公司内部没有一套完整的自动化部署流程,可以自己玩玩看。里面还有很多可以完善的点,希望大家多多交流,提一些宝贵意见。