一不小心手动实现了k8s的自动化构建
背景
由于公司需要对公司内部的软件需要对外部署,对于前端的部署,需要一套部署的方案。尝试了写了一个适配多模块可配置的部署脚本,本文对实现的过程进行一个记录。
目的
脚本采用的是node,前端同学的首选。脚本的目的就是实现k8s中的自动化部署中的部分功能
熟悉k8s的同学或许了解在k8s的自动构建过程中,大概实现了以下几个步骤
- 从指定的代码仓库中获取代码
- 执行npm i或者npm ci(如果项目依赖内网npm还需要在这之前设置一下内网镜像)
- 执行npm build
- 将生成的静态文件放在指定的文件内
我们的node脚本就需要实现以上功能即可。
实现过程
第一步,从指定的代码仓库中获取代码
从指定仓库获取代码,需要怎么指定。
我们平时克隆代码的时候执行的是git clone xxx
,xxx
就是指定的代码地址,如果我们模仿一下node index.js xxx
,这样的话如果需要克隆多个后面就会很长,不太美观。
这里我选择通过写入配置文件的方式进行clone。并且提供两种clone方式。
- 给一个全量的仓库配置文件,通过手动选择其中的仓库进行clone。
- 创建一个默认的配置,对里面所有的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里面的文件。
我们还是分步骤实现
根据上面流程图,我们需要实现三个方法
- 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
直接回车选择第一种方式
代码运行到
chooseGit()
这个方法了,这时根据提示通过空格()选择需要clone的项目地址,通过键盘的上下移动。这里我们选择前面两个然后回车下一步。
这时代码就到
downLoadGit()
这里了。需要下载的地址已经打印出来了
再测试第二种,运行
node index.js
这里就直接把customConfig里面所有的仓库地址都打印出来了。
怎么指定已经实现了,后面就是根据地址下载了,我们再实现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
测试图一下,经过一波选择。
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()
需要注意的一些问题
- clone的地址必须得有权限。
- 有些仓库设置了SSL验证,clone的时候需要
git config --global http.sslVerify false
这样设置一下。- 为什么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
最后的输出是一样的。
第三步,实现执行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
看看,如果控制台能看到打包出来的文件那就说明成功了。
第四步,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
看下效果
这样我们完整的一个项目的构建流程就走完了,然后继续下一个项目,然后再继续执行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);
脚本后续还需不需要优化看具体使用情况吧。