一不小心手动实现了k8s的自动化构建

背景

由于公司需要对公司内部的软件需要对外部署,对于前端的部署,需要一套部署的方案。尝试了写了一个适配多模块可配置的部署脚本,本文对实现的过程进行一个记录。

目的

脚本采用的是node,前端同学的首选。脚本的目的就是实现k8s中的自动化部署中的部分功能

熟悉k8s的同学或许了解在k8s的自动构建过程中,大概实现了以下几个步骤

  • 从指定的代码仓库中获取代码
  • 执行npm i或者npm ci(如果项目依赖内网npm还需要在这之前设置一下内网镜像)
  • 执行npm build
  • 将生成的静态文件放在指定的文件内

我们的node脚本就需要实现以上功能即可。

实现过程

第一步,从指定的代码仓库中获取代码

从指定仓库获取代码,需要怎么指定。

我们平时克隆代码的时候执行的是git clone xxx,xxx就是指定的代码地址,如果我们模仿一下node index.js xxx,这样的话如果需要克隆多个后面就会很长,不太美观。

这里我选择通过写入配置文件的方式进行clone。并且提供两种clone方式。

  1. 给一个全量的仓库配置文件,通过手动选择其中的仓库进行clone。
  2. 创建一个默认的配置,对里面所有的git地址进行clone。

创建一个config.js和一个customeConfig.js文件

// config.js 全量仓库
export default [{
    url: 'https://github.com/yourProject1.git', // 代码地址
    modulesName: 'yourProject1', // 项目简称或者标识
    desc: '项目一' // 项目描述
},{
    url: 'https://github.com/yourProject2.git',
    modulesName: 'yourProject2', 
    desc: '项目二'
},{
    url: 'https://github.com/yourProject3.git',
    modulesName: 'yourProject3', 
    desc: '项目三'
},{
    url: 'https://github.com/yourProject4.git',
    modulesName: 'yourProject4', 
    desc: '项目四'
}]
// customConfig.js 不在全量仓库里面的地址,全部下载
export default [{
    url: 'https://github.com/yourProject1.git', // 代码地址
    modulesName: 'yourProject1', // 项目简称或者标识
    desc: '项目一' // 项目描述
},{
    url: 'https://github.com/yourProject2.git',
    modulesName: 'yourProject2', 
    desc: '项目二'
}]

使用者如果需要clone的仓库不在config.js里面,可以直接修改customConfig.js里面的文件。

我们还是分步骤实现

是 选择config中的clone
选择需要clone的仓库地址
否 根据customConfig中的clone
选择clone方式 chooseDownLoadType
选择项目clone?
chooseGit
downLoadGit

根据上面流程图,我们需要实现三个方法

  • chooseDownLoadType(); // 选择克隆方式
  • chooseGit(); // 选择克隆仓库
  • downLoadGit(); // 实现git clone

Talking is cheap show me your code 话不多说直接实现代码吧

这里通过inquirer实现控制台的交互效果,具体使用方法可以查看npm官网的使用方法 www.npmjs.com/package/inq…

  • 实现chooseDownLoadType()
function chooseDownLoadType () {
    inquirer.prompt([{
        type: "list",
        name: "preset",
        message: "请选择克隆的方式",
        choices: ["选择项目clone", "根据customConfig配置clone"]
    }]).then(choice => {
        if(choice.preset === '选择项目clone') { // 按照config文件clone
            chooseGit(); // 选择git地址
        }else { // 按照customConfig文件配置
            downLoadGits = customConfig.map(res => res.url); // 收集地址
            downLoadGit(); // 下载git
        }
    })
}
  • 实现chooseGit()
function chooseGit () {
    inquirer.prompt([{
        type: "checkbox",
        name: "gits",
        message: "请选择需要克隆的项目地址:",
        choices: config.map(res => {
            return {
                name: `${res.modulesName}_(${res.desc})`,
                ...res
            }
        }),
        default: [] // 默认选中的git
    }]).then(choice => {
        // 收集地址
        choice.gits.forEach(git => {
            const moduleName = git.split("_")[0]; // 获取name
            if(config.map(conf => conf.modulesName).includes(moduleName)) {
                downLoadGits.push(config.find(gitConf => gitConf.modulesName === moduleName).url);
            }
        })
        downLoadGit();
    })
}

详细代码如下:

import { createRequire } from "module";
const require = createRequire(import.meta.url);
import inquirer from "inquirer";
import config from "./config.js";
import customConfig from "./customConfig.js";

let downLoadGits = [] // 收集需要clone的地址

function chooseDownLoadType () {
    inquirer.prompt([{
        type: "list",
        name: "preset",
        message: "请选择克隆的方式",
        choices: ["选择项目clone", "根据customConfig配置clone"]
    }]).then(choice => {
        if(choice.preset === '选择项目clone') { // 按照config文件clone
            chooseGit(); // 选择git地址
        }else { // 按照customConfig文件配置
            downLoadGits = customConfig.map(res => res.url); // 收集地址
            downLoadGit(); // 下载git
        }
    })
}

function chooseGit () {
    inquirer.prompt([{
        type: "checkbox",
        name: "gits",
        message: "请选择需要克隆的项目地址:",
        choices: config.map(res => {
            return {
                name: `${res.modulesName}_(${res.desc})`,
                ...res
            }
        }),
        default: [] // 默认选中的git
    }]).then(choice => {
        // 收集地址
        choice.gits.forEach(git => {
            const moduleName = git.split("_")[0]; // 获取name
            if(config.map(conf => conf.modulesName).includes(moduleName)) {
                downLoadGits.push(config.find(gitConf => gitConf.modulesName === moduleName).url);
            }
        })
        downLoadGit();
    })
}

function downLoadGit () {
    console.log('需要下载的git', downLoadGits)
}

function main () {
    chooseDownLoadType();
}

main()

测试一下,运行node index.js 直接回车选择第一种方式

image.png

代码运行到chooseGit()这个方法了,这时根据提示通过空格()选择需要clone的项目地址,通过键盘的上下移动。这里我们选择前面两个然后回车下一步。

image.png

这时代码就到downLoadGit()这里了。需要下载的地址已经打印出来了

image.png

再测试第二种,运行node index.js

image.png

这里就直接把customConfig里面所有的仓库地址都打印出来了。

image.png

怎么指定已经实现了,后面就是根据地址下载了,我们再实现downLoadGit()这个比较重要的一步

execa是一个可以通过js实现shell脚本的npm依赖包,后面的npm install和npm build都会通过这个实现。具体使用方法可以查看npm官网的使用方法www.npmjs.com/package/exe…。

  • 实现downLoadGit()
async function downLoadGit () {
    console.log('需要下载的git', downLoadGits);
    if(fs.existsSync(PATH)) { // 如果目录存在则删除
        clearFolder(PATH)
    }
    await fs.mkdirSync(PATH);
    downLoadGits.forEach(async gitUrl => {
        let childProcess = execa("git", ["clone", gitUrl, "--progress"], {
            cwd: `${PATH}` // 执行的目录也就是git clone需要运行的目录
        })
        childProcess.stderr.pipe(process.stderr); // 将clone进度在控制台输出
        try {
            const result = await childProcess;
            if(result.exitCode === 0) {
                console.log('克隆完成')
            }
        } catch (error) {
            console.log('克隆失败', error)
        }
    })
}

这里需要fs模块创建和删除目录,PATH为代码存放的目录

import { createRequire } from "module";
const require = createRequire(import.meta.url);
import inquirer from "inquirer";
import config from "./config.js";
import customConfig from "./customConfig.js";
const fs = require('fs');
let downLoadGits = [] // 收集需要clone的地址
let PATH = 'gits' // clone的代码存放的目录
    

删除代码的目录的实现

function clearFolder (path) {
    let files = [];
    if (fs.existsSync(path)) {  // 是否存在目录
        files = fs.readdirSync(path); // 读取目录下的目录和文件
        files.forEach(file => {
            let curPath = `${path}/${file}`; // 拼接路径
            if(fs.statSync(curPath).isDirectory()) { // 如果是文件夹就递归遍历
                clearFolder(curPath)
            }else {
                fs.unlinkSync(curPath); // 不是文件夹是文件直接删除
            }
        })
        fs.rmdirSync(path) // 清空文件夹
    }
}

到这里,基本上实现了git clone代码的功能,执行node index.js测试图一下,经过一波选择。

image.png

index.js完整代码

import { createRequire } from "module";
const require = createRequire(import.meta.url);
import inquirer from "inquirer";
import config from "./config.js";
import customConfig from "./customConfig.js";
import { execa } from 'execa';
const fs = require('fs');

let downLoadGits = [] // 收集需要clone的地址
let PATH = 'gits' // clone的代码存放的目录

function chooseDownLoadType () {
   inquirer.prompt([{
       type: "list",
       name: "preset",
       message: "请选择克隆的方式",
       choices: ["选择项目clone", "根据customConfig配置clone"]
   }]).then(choice => {
       if(choice.preset === '选择项目clone') { // 按照config文件clone
           chooseGit(); // 选择git地址
       }else { // 按照customConfig文件配置
           downLoadGits = customConfig.map(res => res.url); // 收集地址
           downLoadGit(); // 下载git
       }
   })
}

function chooseGit () {
   inquirer.prompt([{
       type: "checkbox",
       name: "gits",
       message: "请选择需要克隆的项目地址:",
       choices: config.map(res => {
           return {
               name: `${res.modulesName}_(${res.desc})`,
               ...res
           }
       }),
       default: [] // 默认选中的git
   }]).then(choice => {
       // 收集地址
       choice.gits.forEach(git => {
           const moduleName = git.split("_")[0]; // 获取name
           if(config.map(conf => conf.modulesName).includes(moduleName)) {
               downLoadGits.push(config.find(gitConf => gitConf.modulesName === moduleName).url);
           }
       })
       downLoadGit();
   })
}

function clearFolder (path) {
   let files = [];
   if (fs.existsSync(path)) {
       files = fs.readdirSync(path);
       files.forEach(file => {
           let curPath = `${path}/${file}`
           if(fs.statSync(curPath).isDirectory()) {
               clearFolder(curPath)
           }else {
               fs.unlinkSync(curPath);
           }
       })
       fs.rmdirSync(path)
   }
}

async function downLoadGit () {
   console.log('需要下载的git', downLoadGits);

   if(fs.existsSync(PATH)) { // 如果目录存在则删除
       clearFolder(PATH)
   }
   await fs.mkdirSync(PATH);
   downLoadGits.forEach(async gitUrl => {
       let childProcess = execa("git", ["clone", gitUrl, "--progress"], {
           cwd: `${PATH}` // shell执行的目录
       })
       childProcess.stderr.pipe(process.stderr); // 将clone进度在控制台输出
       try {
           const result = await childProcess;
           if(result.exitCode === 0) {
               console.log('克隆完成')
           }
       } catch (error) {
           console.log('克隆失败', error)
       }
   })
}

function main () {
   chooseDownLoadType();
}

main()

需要注意的一些问题

  1. clone的地址必须得有权限。
  2. 有些仓库设置了SSL验证,clone的时候需要git config --global http.sslVerify false这样设置一下。
  3. 为什么clone的时候需要先删除一下目录,因为如果存在相同目录clone会失败,删除再clone这种操作类似build打包的时候对dist文件夹的处理。

第二步,实现执行npm install

第一步的工作已经把主要的原理实现了,接下来的工作相信大部分同学都知道怎么去实现了。

把大象装冰箱的步骤:打开冰箱门,把大象放进去。这里也分两步

  • 找出执行npm install的路径
  • 执行npm install

实现思路:

新建一个build.js文件,定义一个数组installBuildPaths来存放路径,一般执行npm命令行的文件路径需要满足该路径下存在package.json这个文件。

let installBuildPaths = []

实现一个方法findPackagePath()去递归刚才存放代码的路径PATH下面的所有文件和目录,查找存在package.json这个文件的路径并保存。为什么需要递归是考虑了多模块的情况。

function findPackagePaths (files, paths) {
    if(files.length === 0) return;
    files.filter(file => {
        return fs.statSync(joinPath([...paths, file])).isDirectory() // 返回目录路径
    }).forEach(file => {
        const path = joinPath([...paths, file]);
        const packageJsonPath = `${path}/package.json`;
        let existPackageJson = fs.existsSync(packageJsonPath);
        if(existPackageJson) {
            installBuildPaths.push(path);
        }else {
            let childrenFiles = fs.readdirSync(path);
            findPackagePaths(childrenFiles, [...paths, file]);
        }
    })
}

// 拼接路径
function joinPath (pathArr) {
    return `${pathArr.join('/')}`;
}

遍历installBuildPaths里面的路径去执行npm install,最好按顺序执行npm install。

function npmInstall () {
    run(installBuildPaths.shift());
}
    
async function run (path) {
    if(path) {
        let childInstallProcess = execa("npm", ["install", "--loglevel", "silly"], {
            cwd: `${path}/`
        })
        childInstallProcess.stderr.pipe(process.stderr);
        try {
            const result = await childInstallProcess;
            if(result.exitCode === 0) {
                console.log(`${result.stdout}`)
                console.log(`install 成功`)
            }
        } catch (error) {
            console.log('install 失败', error)
        }
    }
}

这里执行的是npm install --loglevel silly目的是为了打印出更详细的信息,避免控制台出现await等待的焦虑。

执行脚本的main()

function main () {
    findPackagePaths(files, [PATH]);
    npmInstall();
}

main();

执行node build.js最终安装完成就是这样,和平常执行npm install最后的输出是一样的。

image.png

第三步,实现执行npm build

这里我们选择直接install完就build

npmBuild()实现

async function run (path) {
    if(path) {
        let childInstallProcess = execa("npm", ["install", "--loglevel", "silly"], {
            cwd: `${path}/`
        })
        childInstallProcess.stderr.pipe(process.stderr);
        try {
            const result = await childInstallProcess;
            if(result.exitCode === 0) {
                console.log(`${result.stdout}`)
                console.log(`install 成功`);
                console.log(`--------开始执行npm run build-------`);
                npmBuild(path);
            }
        } catch (error) {
            console.log('install 失败', error)
        }
    }
}
    
async function npmBuild (path) {
    let childBuildProcess = execa("npm", ["run", "build"], {
        cwd: `${path}/`
    })
    childBuildProcess.stderr.pipe(process.stderr);
    try {
        const result = await childBuildProcess;
        if(result.exitCode === 0) {
            console.log(`${result.stdout}`)
            console.log(`build 成功`);
        }
    } catch (error) {
        console.log('build 失败', error)
    }
}

我们执行一下node build.js看看,如果控制台能看到打包出来的文件那就说明成功了。

image.png

第四步,copy文件到指定目录

现在静态文件有了,就只剩最后一步了,将打包出来的文件放入指定的目录当中,在k8s里面这一步就是将静态文件放在镜像里面。

这里面我们需要获取项目打包的输出静态文件的目录,需要读取项目里面的配置文件,一般默认是在dist目录,有些项目可能设置了输出路径就需要获取打包文件的配置,例如我的项目是react,就需要获取build.confing.js这个配置文件。

实现putDistToAimFolder()STATIC是存放静态文件的根目录,PATH是存放代码的根目录,这里需要借助fs-extra模块复制文件夹。

function putDistToAimFolder (path) {
    const aimFolder = joinPath([STATIC, path.split(`${PATH}/`)[1]]);
    let config = {};
    // 这里需要找到项目中的打包配置文件找到打包后的静态文件,我这里是react项目
    // 如果没找到默认dist目录
    // 其他项目可以视情况而定 这里需要完善一下
    if(fs.existsSync(`./${path}/build.config.js`)) {
        config = require(`./${path}/build.config.js`);
        config.outputDir = config.outputDir || `dist`;
    }else {
        config.outputDir = `dist`;
    }
     
    try {
        fs.copy(`${path}/${config.outputDir}`, aimFolder);
        console.log('静态文件已存放到指定目录');
    } catch (error) {
        console.log('文件复制失败', error);
    }
}

再执行完npmBuild()后执行putDistToAimFolder();

async function npmBuild (path) {
    let childBuildProcess = execa("npm", ["run", "build"], {
        cwd: `${path}/`
    })
    childBuildProcess.stderr.pipe(process.stderr);
    try {
        const result = await childBuildProcess;
        if(result.exitCode === 0) {
            console.log(`${result.stdout}`);
            console.log(`build 成功`);
            console.log(`-----------copy文件到指定目录-------------`);
            putDistToAimFolder(path);
        }
    } catch (error) {
        console.log('build 失败', error)
    }
}

执行一下node build.js 看下效果

image.png

image.png

这样我们完整的一个项目的构建流程就走完了,然后继续下一个项目,然后再继续执行npmInstall()这个方法即可。

打完收工贴一下完整的代码

import { createRequire } from "module";
const require = createRequire(import.meta.url);
import chalk from 'chalk';
import { execa } from 'execa';
var fs = require("fs-extra"); 
const PATH = 'gits';
const STATIC = 'static';

let files = fs.readdirSync(PATH);
let installBuildPaths = [];

function findPackagePaths (files, paths) {
    if(files.length === 0) return;
    files.filter(file => {
        return fs.statSync(joinPath([...paths, file])).isDirectory() // 返回目录路径
    }).forEach(file => {
        const path = joinPath([...paths, file]);
        const packageJsonPath = `${path}/package.json`;
        let existPackageJson = fs.existsSync(packageJsonPath);
        if(existPackageJson) {
            installBuildPaths.push(path);
        }else {
            let childrenFiles = fs.readdirSync(path);
            findPackagePaths(childrenFiles, [...paths, file]);
        }
    })
}

function joinPath (pathArr) {
    return `${pathArr.join('/')}`;
}

async function run (path) {
    if(path) {
        let childInstallProcess = execa("npm", ["install", "--loglevel", "silly"], {
            cwd: `${path}/`
        })
        childInstallProcess.stderr.pipe(process.stderr);
        try {
            const result = await childInstallProcess;
            if(result.exitCode === 0) {
                console.log(`${result.stdout}`)
                console.log(`install 成功`);
                console.log(`--------开始执行npm run build-------`);
                npmBuild(path);
            }
        } catch (error) {
            console.log('install 失败', error)
        }
    }
}

function npmInstall () {
    run(installBuildPaths.shift());
}

async function npmBuild (path) {
    let childBuildProcess = execa("npm", ["run", "build"], {
        cwd: `${path}/`
    })
    childBuildProcess.stderr.pipe(process.stderr);
    try {
        const result = await childBuildProcess;
        if(result.exitCode === 0) {
            console.log(`${result.stdout}`);
            console.log(`build 成功`);
            console.log(`-----------copy文件到指定目录-------------`);
            putDistToAimFolder(path);
        }
    } catch (error) {
        console.log('build 失败', error)
    }
}

function putDistToAimFolder (path) {
    const aimFolder = joinPath([STATIC, path.split(`${PATH}/`)[1]]);
    let config = {};
    // 这里需要找到项目中的打包配置文件找到打包后的静态文件,我这里是react项目
    // 如果没找到默认dist目录
    // 其他项目可以视情况而定 这里需要完善一下
    if(fs.existsSync(`./${path}/build.config.js`)) {
        config = require(`./${path}/build.config.js`);
        config.outputDir = config.outputDir || `dist`;
    }else {
        config.outputDir = `dist`
    }
     
    try {
        fs.copy(`${path}/${config.outputDir}`, aimFolder);
        console.log('静态文件已存放到指定目录');
        console.log(`--------${path}构建完成继续执行下一个--------`);
        npmInstall();
    } catch (error) {
        console.log('文件复制失败', error);
    }
}

function main () {
    findPackagePaths(files, [PATH]);
    npmInstall();
}

main();

需要优化的不足之处

以上代码只是简单的实现了一个自动化构建的过程,如果将这段代码放在服务器上运行,理论上是可以实现简单的自动部署的功能。

当然这里面还有许多需要优化的地方

  • 脚本构建的指令可能需要统一处理,这里没有考虑带参数的情况
  • 对脚本执行失败后的处理,比如npm install安装失败的处理,若安装失败删除node_modules防止下次执行脚本安装失败。
  • 对脚本执行目录的收集,虽然考虑到多模块的构建,但是有些情况还是会漏掉,比如嵌套模块的识别就做不到。
  • 对文件静态文件输出目录的识别还得需要从具体的情况考虑,比如老项目手动构建的webpack配置就需要特殊处理了。

总结

在编写这个脚本的过程中学习了很多npm依赖库的使用,比如inpuirer,脚手架必备工具,有兴趣的可以自己捣鼓一下,还有execa,可以通过javascript执行shell脚本,简直YYDS。

对了,还有个chalk,可以改变控制台输出的文字的颜色,我演示的时候没有用,但是也很简单。

虽然原理很简单,自己实现的过程当中也遇到了不少问题,

  • 有些模块最新版本是支持import导入,有些还是只支持require导入。require怎么和import混用靠的就是以下两行代码,前提是你得设置脚本的package.json文件的 "type": "module",
// 兼容CommonJS
import { createRequire } from "module";
const require = createRequire(import.meta.url);

package.json

{
  "name": "k8s",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "type": "module",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "andy",
  "license": "ISC",
  "dependencies": {
    "chalk": "^5.0.1",
    "execa": "^6.1.0",
    "fs-extra": "^10.1.0",
    "inquirer": "^9.1.1"
  }
}

  • 控制台怎么显示脚本运行的过程,这个execa官方使用说明中有提到过
childInstallProcess.stderr.pipe(process.stderr);

脚本后续还需不需要优化看具体使用情况吧。