-實戰篇- Vue - Node-js 從 0 到 1 實現自動化部署工具

最近寫了一個自動化部署的 npm 包 zuo-deploy[1],只需點擊一個按鈕,就可以執行服務器部署腳本,完成功能更新迭代。客戶端使用 Vue + ElementUI,服務 koa + socket + koa-session 等。基礎功能代碼 300 行不到,已開源在 github。zuoxiaobai/zuo-deploy 歡迎 Star、Fork。這裏介紹下具體實現細節、思路。

目錄結構

├── bin # 命令行工具命令
│   ├── start.js # zuodeploy start 執行入口
│   └── zuodeploy.js # zuodeploy 命令入口,在 package.json 的 bin 屬性中配置
├── docImages # README.md 文檔圖片 
├── frontend # 客戶端頁面/前端操作頁面(koa-static 靜態服務指定目錄)
│   └── index.html # Vue + ElementUI + axios + socket.io
├── server # 服務端
│   ├── utils
│   │   ├── logger.js # log4js 
│   │   └── runCmd.js # node child_process spawn(執行 shell 腳本、pm2 服務開啓)
│   └── index.js # 主服務(koa 接口、靜態服務 + socket + 執行 shell 腳本)
├── .eslintrc.cjs # eslint 配置文件 + prettier
├── args.json # 用於 pm2 改造後,跨文件傳遞端口、密碼參數
├── CHANGELOG.md # release 版本功能迭代記錄
├── deploy-master.sh # 用於測試,當前目錄開啓服務偶,點擊部署按鈕,執行該腳本
├── index.js # zuodeploy start 執行文件,用於執行 pm2 start server/index.js 主服務 
├── package.json # 項目描述文件,npm 包名、版本號、cli 命令名稱、
├── publish.sh # npm publish(npm包) 發佈腳本
└── README.md # 使用文檔

前後端技術棧、相關依賴

基礎功能實現思路

最初目標:前端頁面點擊部署按鈕,可以直接讓服務器執行部署,並將部署 log 返回給前端

怎麼去實現?

技術棧確定:

考慮到前端頁面的部署問題,可以與 koa server 服務放到一起,使用 koa-static 開啓靜態文件服務,支持前端頁面訪問

這裏不使用前端工程化 @vue/cli ,直接使用靜態 html,通過 cdn 引入 vue 等

1. 客戶端 Vue+ElementUI+axios

前端服務我們放到 frontend/index.html,koa-static 靜態服務直接指向 frontend 目錄就可以訪問頁面了

核心代碼如下:

注意:cdn 鏈接都是 // 相對路徑,需要使用 http 服務打開頁面,不能以普通的 File 文件形式打開!可以等到後面 koa 寫好後,開啓服務再訪問

<head>
  <title>zuo-deploy</title>
  <!-- 導入樣式 -->
  <link rel="stylesheet" href="//unpkg.com/element-plus/dist/index.css" />
  <!-- 導入 Vue 3 -->
  <script src="//unpkg.com/vue@next"></script>
  <!-- 導入組件庫 -->
  <script src="//unpkg.com/element-plus"></script>
  <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>

<body>
  <div id="app" style="margin:0 20px;">
    <el-button type="primary" @click="deploy">部署</el-button>
    <div>
      <p>部署日誌:</p>
      <div class="text-log-wrap">
        <pre>{{ deployLog }}</pre>
      </div>
    </div>
  </div>
  <script>
    const app = {
      data() {
        return {
          deployLog: '點擊按鈕進行部署',
        }
      },
      methods: {
        deploy() {
          this.deployLog = '後端部署中,請稍等...'
          axios.post('/deploy')
            .then((res) ={
              // 部署完成,返回 log
              console.log(res.data);
              this.deployLog = res.data.msg
            })
            .catch(function (err) {
              console.log(err);
            })
        }
      }
    }

    Vue.createApp(app).use(ElementPlus).mount('#app')
  </script>
</body>

2. 服務端 koa+koa-router+koa-static

koa 開啓 http server,寫 deploy 接口處理。koa-static 開啓靜態服務

// server/index.js
const Koa = require("koa");
const KoaStatic = require("koa-static");
const KoaRouter = require("koa-router");
const path = require("path");

const app = new Koa();
const router = new KoaRouter();

router.post("/deploy", async (ctx) ={
  // 執行部署腳本
  let execFunc = () ={};
  try {
    let res =  await execFunc();
    ctx.body = {
      code: 0,
      msg: res,
    };
  } catch (e) {
    ctx.body = {
      code: -1,
      msg: e.message,
    };
  }
});

app.use(new KoaStatic(path.resolve(__dirname, "../frontend")));
app.use(router.routes()).use(router.allowedMethods());
app.listen(7777, () => console.log(`服務監聽 ${7777} 端口`));

將項目跑起來

  1. 在當前項目目錄,執行 npm init 初始化 package.json

  2. npm install koa koa-router koa-static --save 安裝依賴包

  3. node server/index.js 運行項目,注意如果 7777 端口被佔用,需要換一個端口

訪問 http:// 127.0.0.1:7777 就可以訪問頁面,點擊部署就可以請求成功了

3.Node 執行 shell 腳本並輸出 log 到前端

node 內置模塊 child_process 下 spawn 執行 terminal 命令,包括執行 shell 腳本的 sh 腳本文件.sh 命令

下來看一個 demo,新建一個 testExecShell 測試目錄,測試效果

// testExecShell/runCmd.js
const { spawn } = require('child_process');
const ls = spawn('ls'['-lh''/usr']); // 執行 ls -lh /usr 命令

ls.stdout.on('data'(data) ={
  // ls 產生的 terminal log 在這裏 console
  console.log(`stdout: ${data}`);
});

ls.stderr.on('data'(data) ={
  // 如果發生錯誤,錯誤從這裏輸出
  console.error(`stderr: ${data}`);
});

ls.on('close'(code) ={
  // 執行完成後正常退出就是 0 
  console.log(`child process exited with code ${code}`);
});

運行 node testExecShell/runCmd.js 就可以使用 node 執行 ls \-lh /usr,並通過 ls.stdout 接收到 log 信息並打印

回到正題,這裏需要執行 shell 腳本,可以將 ls \-lh /usr 替換爲 sh 腳本文件.sh 即可。下面來試試

// testExecShell/runShell.js
const { spawn } = require('child_process');
const child = spawn('sh'['testExecShell/deploy.sh']); // 執行 sh deploy.sh 命令

child.stdout.on('data'(data) ={
  // shell 執行的 log 在這裏蒐集,可以通過接口返回給前端
  console.log(`stdout: ${data}`);
});

child.stderr.on('data'(data) ={
  // 如果發生錯誤,錯誤從這裏輸出
  console.error(`stderr: ${data}`);
});

child.on('close'(code) ={
  // 執行完成後正常退出就是 0 
  console.log(`child process exited with code ${code}`);
});

創建執行的 shell 腳本,可以先 sh estExecShell/deploy.sh 試試是否有可執行,如果沒執行權限,就添加(chmod +x 文件名)

# /testExecShell/deploy.sh
echo '執行 pwd'
pwd
echo '執行 git pull'
git pull

運行 node testExecShell/runShell.js 就可以讓 node 執行 deploy.sh 腳本了,如下圖

參考:child_process - Node.js 內置模塊筆記 [2]

4.deploy 接口集成執行 shell 腳本功能

修改之前的 deploy 接口,加一個 runCmd 方法,執行當前目錄的 deploy.sh 部署腳本,完成後接口將執行 log 響應給前端

// 新建 server/indexExecShell.js,將 server/index.js 內容拷貝進來,並做如下修改
const rumCmd = () ={
  return new Promise((resolve, reject) ={
    const { spawn } = require('child_process');
    const child = spawn('sh'['deploy.sh']); // 執行 sh deploy.sh 命令

    let msg = ''
    child.stdout.on('data'(data) ={
      // shell 執行的 log 在這裏蒐集,可以通過接口返回給前端
      console.log(`stdout: ${data}`);
      // 普通接口僅能返回一次,需要把 log 都蒐集到一次,在 end 時 返回給前端
      msg += `${data}`
    });

    child.stdout.on('end'(data) ={
      resolve(msg) // 執行完畢後,接口 resolve,返回給前端
    });

    child.stderr.on('data'(data) ={
      // 如果發生錯誤,錯誤從這裏輸出
      console.error(`stderr: ${data}`);
      msg += `${data}`
    });

    child.on('close'(code) ={
      // 執行完成後正常退出就是 0 
      console.log(`child process exited with code ${code}`);
    });
  })
}

router.post("/deploy", async (ctx) ={
  try {
    let res =  await rumCmd(); // 執行部署腳本
    ctx.body = {
      code: 0,
      msg: res,
    };
  } catch (e) {
    ctx.body = {
      code: -1,
      msg: e.message,
    };
  }
});

修改完成後,運行 node server/indexExecShell.js 開啓最新的服務,點擊部署,接口執行正常,如下圖

執行的是當前目錄的 deploy.sh,沒有對應的文件。將上面 testExeclShell/deploy.sh 放到當前目錄再點擊部署

這樣自動化部署基礎功能基本就完成了。

功能優化

1. 使用 socket 實時輸出 log

上面的例子中,普通接口需要等部署腳本執行完成後再響應給前端,如果腳本中包含 git pull、npm run build 等耗時較長的命令,就會導致前端頁面一直沒 log 信息,如下圖

測試 shell

echo '執行 pwd'
pwd
echo '執行 git pull'
git pull
git clone git@github.com:zuoxiaobai/zuo11.com.git # 耗時較長的命令
echo '部署完成'

這裏我們改造下,使用 socket.io[3] 來實時將部署 log 發送給前端

socket.io 分爲客戶端、服務端兩個部分

客戶端代碼

<!-- frontend/indexSocket.html -->
<script src="https://cdn.socket.io/4.4.1/socket.io.min.js"></script>
<script>
  // vue mounted 鉤子裏面鏈接 socket 服務端
  mounted() {
    this.socket = io() // 鏈接到 socket 服務器,發一個 http 請求,成功後轉 101 ws 協議
    // 訂閱部署日誌,拿到日誌,就一點點 push 到數組,顯示到前端
    this.socket.on('deploy-log'(msg) ={
      console.log(msg)
      this.msgList.push(msg)
    })
  },  
</script>

後端 koa 中引入 socket.io 代碼

// server/indexSoket.js
// npm install socket.io --save
const app = new Koa();
const router = new KoaRouter();

// 開啓 socket 服務
let socketList = [];
const server = require("http").Server(app.callback());
const socketIo = require("socket.io")(server);
socketIo.on("connection"(socket) ={
  socketList.push(socket);
  console.log("a user connected"); // 前端調用 io(),即可連接成功
});
// 返回的 socketIo 對象可以用來給前端廣播消息

runCmd() {
  // 部分核心代碼
  let msg = ''
  child.stdout.on('data'(data) ={
    // shell 執行的 log 在這裏蒐集,可以通過接口返回給前端
    console.log(`stdout: ${data}`);
    socketIo.emit('deploy-log'`${data}`) //socket 實時發送給前端
    // 普通接口僅能返回一次,需要把 log 都蒐集到一次,在 end 時 返回給前端
    msg += `${data}`
  });
  // ...
  child.stderr.on('data'(data) ={
    // 如果發生錯誤,錯誤從這裏輸出
    console.error(`stderr: ${data}`);
    socketIo.emit('deploy-log'`${data}`) // socket 實時發送給前端
    msg += `${data}`
  });
}
// app.listen 需要改爲上面加入了 socket 服務的 server 對象
server.listen(7777, () => console.log(`服務監聽 ${7777} 端口`));

我們在之前的 demo 中加入上面的代碼,即可完成 socket 改造,node server/indexSocket.js,打開 127.0.0.1:7777/indexSocket.html,點擊部署,即可看到如下效果。完成 demo 訪問地址 [4]

相關問題

  1. 關於 http 轉 ws 協議,我們可以通過打開 F12 NetWork 面板看前端的 socket 相關連接步驟

ws 這個裏面可以看到 socket 傳的數據

  1. http 請求成功狀態碼一般是 200, ws Status Code 爲 101 Switching Protocols

2. 部署接口添加鑑權

上面只是用接口實現的功能,並沒有加權限控制,任何人知道接口地址後,可以通過 postman 請求該接口,觸發部署。如下圖

爲了安全起見,我們這裏爲接口添加鑑權,前端增加一個輸入密碼登錄的功能。這裏使用 koa-session 來鑑權,只有登錄態才能請求成功

// server/indexAuth.js
// npm install koa-session koa-bodyparser --save
// ..
const session = require("koa-session");
const bodyParser = require("koa-bodyparser"); // post 請求參數解析
const app = new Koa();
const router = new KoaRouter();

app.use(bodyParser()); // 處理 post 請求參數

// 集成 session
app.keys = [`自定義安全字符串`]; // 'some secret hurr'
const CONFIG = {
  key: "koa:sess" /** (string) cookie key (default is koa:sess) */,
  /** (number || 'session') maxAge in ms (default is 1 days) */
  /** 'session' will result in a cookie that expires when session/browser is closed */
  /** Warning: If a session cookie is stolen, this cookie will never expire */
  maxAge: 0.5 * 3600 * 1000, // 0.5h
  overwrite: true /** (boolean) can overwrite or not (default true) */,
  httpOnly: true /** (boolean) httpOnly or not (default true) */,
  signed: true /** (boolean) signed or not (default true) */,
  rolling: false /** (boolean) Force a session identifier cookie to be set on every response. The expiration is reset to the original maxAge, resetting the expiration countdown. (default is false) */,
  renew: false /** (boolean) renew session when session is nearly expired, so we can always keep user logged in. (default is false)*/,
};
app.use(session(CONFIG, app));

router.post("/login", async (ctx) ={
  let code = 0;
  let msg = "登錄成功";
  let { password } = ctx.request.body;
  if (password === `888888`) { // 888888 爲設置的密碼
    ctx.session.isLogin = true;
  } else {
    code = -1;
    msg = "密碼錯誤";
  }
  ctx.body = {
    code,
    msg,
  };
});

router.post("/deploy", async (ctx) ={
  if (!ctx.session.isLogin) {
    ctx.body = {
      code: -2,
      msg: "未登錄",
    };
    return;
  }
  // 有登錄態,執行部署
})

前端相關改動,加一個密碼輸入框、一個登錄按鈕

<!-- frontend/indexAuth.html 注意id="app"包裹 -->
<div class="login-area">
  <div v-if="!isLogin">
    <el-input v-model="password" type="password" style="width: 200px;"></el-input>
     
    <el-button type="primary" @click="login">登錄</el-button>
  </div>
  <div v-else>已登錄</div>
</div>
<script>
data() {
  return {
    isLogin: false,
    password: ''
  }
},
methods: {
  login() {
    if (!this.password) {
      this.$message.warning('請輸入密碼')
      return
    }
    axios.post('/login'{ password: this.password })
      .then((response) ={
        console.log(response.data);
        let { code, msg } = response.data
        if (code === 0) {
          this.isLogin = true
        } else {
          this.$message.error(msg)
        }
      })
      .catch(function (err) {
        console.log(err);
        this.$message.error(err.message)
      })
  }
}
</script>

node server/indexAuth.js,打開 127.0.0.1:7777/indexAuth.html,登錄成功之後才能部署

3. 封裝成一個 npm 包 cli 工具

爲什麼封裝成 npm 包,使用命令行工具開啓服務。主要是簡單易用,如果不使用命令行工具形式,需要三步:

  1. 先下載代碼到服務器

  2. npm install

  3. node index.js 或者 pm2 start index.js -n xxx 開啓服務

改成 npm 包命令行工具形式只需要下面兩步,而且更節省時間

  1. npm install zuo-deploy pm2 -g

  2. 運行 zuodeploy start 會自動使用 pm2 開啓服務

下面先來看一個簡單的例子,創建一個 npm 包並上傳到 npm 官方庫步驟

// index.js
module.exports = {
  name: '寫一個npm包',
  doSomething() {
    console.log('這個npm暴露一個方法')
  }
}
# publish.sh
npm config set registry=https://registry.npmjs.org
npm login # 登陸 ,如果有 OTP, 郵箱會接收到驗證碼,輸入即可
# 登錄成功後,短時間內會保存狀態,可以直接 npm pubish
npm publish # 可能會提示名稱已存在,換個名字,獲取使用作用域包(@xxx/xxx)
npm config set registry=https://registry.npm.taobao.org # 還原淘寶鏡像

到 npmjs.org 搜索對應包就可以看到了

使用該 npm 包,創建 testNpm/index.js

const packageInfo = require('zuoxiaobai-test')

console.log(packageInfo) 
packageInfo.doSomething()

在 testNpm 目錄下 npm init 初始化 package.json,再 npm install zuoxiaobai-test --save; 再 node index.js,執行情況如下圖,調用 npm 包正常

這樣我們就知道怎麼寫一個 npm 包,並上傳到 npm 官方庫了。

下面,我們來看怎麼在 npm 包中集成 cli 命令。舉個例子:在 npm install @vue/cli \-g 後,會在環境變量中添加一個 vue 命令。使用 vue create xx 可初始化一個項目。一般這種形式就是 cli 工具。

一般在 package.json 中有一個 bin 屬性,用於創建該 npm 包的自定義命令

// package.json
"bin"{
    "zuodeploy""./bin/zuodeploy.js"
  },

上的配置意思是:全局安裝 npm install xx -g 後,生成 zuodeploy 命令,運行該命令時,會執行 bin/zuodeploy.js

本地開發時,配置好後,在當前目錄下運行 sudo npm link 即可將 zuodeploy 命令鏈接到本地的環境變量裏。任何 terminal 裏面運行 zuodeploy 都會執行當前項目下的這個文件。解除可以使用 npm unlink

一般 cli 都會使用 commander 來生成幫助文檔,管理指令邏輯,代碼如下

// bin/zuodeploy.js
#!/usr/bin/env node

const { program } = require("commander");
const prompts = require("prompts");

program.version(require("../package.json").version);

program
  .command("start")
  .description("開啓部署監聽服務") // description + action 可防止查找 command拼接文件
  .action(async () ={
    const args = await prompts([
      {
        type: "number",
        name: "port",
        initial: 7777,
        message: "請指定部署服務監聽端口:",
        validate: (value) =>
          value !== "" && (value < 3000 || value > 10000)
            ? `端口號必須在 3000 - 10000 之間`
            : true,
      },
      {
        type: "password",
        name: "password",
        initial: "888888",
        message: "請設置登錄密碼(默認:888888)",
        validate: (value) =(value.length < 6 ? `密碼需要 6 位以上` : true),
      },
    ]);
    require("./start")(args); // args 爲 { port: 7777, password: '888888' }
  });

program.parse();

使用 commander 可以快速管理、生成幫助文檔,分配具體指令的執行邏輯

上面的代碼中,指定了 start 指令,zuodeploy start 執行時會先通過 prompts 以詢問的方式蒐集參數,再執行 bin/start.js

在 start.js 中,我麼可以將 server/index.js 的代碼全部拷貝過去即可完成 zuodeploy start 開啓服務,點擊部署的功能

4. 穩定性提高 - pm2 改造

爲了提升穩定性,我們可以在 start.js 中以代碼的方式執行 pm2 src/index.js 這樣服務更穩定可靠,另外可以再加入 log4js 輸出帶時間戳的 log,這樣有利於排查問題。

最後

將上面零碎的知識點匯聚到一起就是 zuo-deploy 的實現,代碼寫的比較隨意,歡迎 star、fork、提改進 PR!

其他問題

前端 / 客戶端爲什麼只有一個 html 沒有使用工程化

  1. 前端工程化方式組織代碼比較重,沒必要

  2. 這裏功能比較簡單、只有部署按鈕、部署 log 查看區域、鑑權(輸入密碼)區域

  3. 便於部署,直接 koa-static 開啓靜態服務即可訪問,無需打包構建

爲什麼從 type: module 改爲普通的 CommonJS

package.json 裏面配置 type: module 後默認使用 ES Modules,有些 node 方法會有一些問題

雖然可以通過修改文件後綴爲 .cjs 來解決,但文件多了,還不如直接去掉 type: module 使用 node 默認包形式

  1. __dirname 報錯。__dirname 對於 cli 項目來講非常重要。當你需要使用當前項目內文件,而非 zuodeploy start 執行時所在目錄的文件時,需要使用 __dirname

  2. require("../package.json") 改爲 import xx from '../package.json' 引入 JSON 文件時會出錯

參考資料

[1]

zuo-deploy: https://github.com/zuoxiaobai/zuo-deploy

[2]

child_process - Node.js 內置模塊筆記:  http://fe.zuo11.com/node/node-doc.html#child-process

[3]

socket.io: https://socket.io/

[4]

訪問地址: https://github.com/zuoxiaobai/fedemo/tree/master/src/DebugDemo/zuo-deploy 實現 demo

[5]

www.npmjs.com/: https://www.npmjs.com/

[6]

npm 包前面加 @是什麼意思 (vue-cli 與 @vue/cli 的區別): http://www.zuo11.com/blog/2020/7/npm_scope.html

[7]

zuo-deploy -github: https://github.com/zuoxiaobai/zuo-deploy

作者:做前端的左小白

https://juejin.cn/post/7070921715492061214

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Bc18j-4MPIQX4fb_MgP_4Q