前言
內部開發部署
當前開發部署流程中,主要藉助git-lab ci
+ docker compose
實現,大致流程如下:
- 基於
dev
創建目標功能分支,完成功能實現和本地測試 - 測試穩定後,提交合並至
dev
分支,觸發dev
對應runner
,實現開發服務器部署更新 -
dev
分支測試通過後,提交合並至test
分支,觸發test
對應runner
,實現測試服務器部署更新 - 測試完成,提交合並至
prod
分支(或master
),觸發prod
對應runner
,實現生產服務器部署更新
Tips: 可通過tag
管理不同runner
以上可應對多數場景,但對於以下情形仍有不足:
- 依賴於
git-lab
,且服務器安裝git-lab-runner
,簡單項目配置較繁瑣 - 對於部分陳舊項目,運維部署較繁瑣
- 無法在客戶服務器安裝
git-lab-runner
,此時手動部署、更新將產生大量重複勞動
爲何升級
針對上一版本(終端執行版本),存在以下痛點:
- 顯示效果差 無法提供良好、直觀的展示效果
- 功能高度耦合 沒有實現對 服務器、項目、配置等功能的解耦
- 不支持快速修改 無法快速修改、調整項目配置
- 不支持並行處理 無法支持項目的並行部署
- 自由度低 僅對應前端項目,沒有提供更高的自由度
新版升級點
- 提供可視化界面,操作便捷
- 支持服務器、執行任務、任務實例的統一管理
- 支持任務實例的快速修改、並行執行、重試、保存
- 支持更加友好的信息展示(如:任務耗時統計、任務狀態記錄)
- 支持上傳文件、文件夾
- 支持自定義本地編譯、清理命令
- 支持遠端前置命令、後置命令批量順序執行
- 支持僅執行遠端前置命令,用於觸發某些自動化腳本
How to use
下載並安裝
查看使用幫助
- 點擊查看使用幫助
創建任務並執行
-
創建服務器(支持密碼、密鑰)
- 點擊
Create Task
創建任務(本地編譯-->上傳文件夾-->編譯並啓動容器)
-
任務結束後可保存
執行保存的任務實例
-
選擇需要的任務點擊運行
Just do it
技術選型
鑑於上一版本(終端執行版本)的痛點,提供一個實時交互、直觀的用戶界面尤爲重要。
考慮到SSH連接、文件壓縮、上傳等操作,需要Node提供支持,而交互場景可通過瀏覽器環境實現。
因此不妨使用Electron來構建,並實現對跨平臺的支持(Windows、Mac OS/ Mac ARM OS)。
程序需持久化保存數據,這裏選用nedb數據庫實現。
Embedded persistent or in memory database for Node.js, nw.js, Electron and browsers, 100% JavaScript, no binary dependency. API is a subset of MongoDB's and it's plenty fast.
技術棧:Vue + Ant Design Vue + Electron + Node + nedb
功能設計
爲便於功能解耦,設計實現三個模塊:
- 服務器(保存服務器連接信息)
- 任務執行(連接服務器並完成相應命令或操作)
- 任務實例(任務保存爲實例,便於再次快速運行)
各模塊功能統計如下:
任務執行模塊
這裏主要整理任務隊列的實現思路,對其他功能感興趣可在評論區進行討論😘。
任務隊列實現
任務隊列實現應保持邏輯簡潔、易擴展的設計思路
任務隊列需要支持任務的並行執行、重試、快速修改、刪除等功能,且保證各任務執行、相關操作等相互隔離。
考慮維護兩個任務隊列實現:
-
待執行任務隊列
(新創建的任務需要添加至待執行隊列) -
執行中任務隊列
(從待執行隊列中取出任務,並依次加入執行中任務隊列,進行執行任務)
由於待執行任務隊列
需保證任務添加的先後順序,且保存的數據爲任務執行的相關參數,則Array<object>
可滿足以上需求。
考慮執行中任務隊列
需要支持任務添加、刪除等操作,且對運行中的任務無強烈順序要求,這裏選用{ taskId: { status, logs ... } ... }
數據結構實現。
因數據結構不同,這裏分別使用 List、Queue 命名兩個任務隊列
// store/modules/task.js
const state = {
pendingTaskList: [],
executingTaskQueue: {}
}
Executing Task
頁面需根據添加至待執行任務隊列
時間進行順序顯示,這裏使用lodash
根據對象屬性排序後返回數組實現。
// store/task-mixin.js
const taskMixin = {
computed: {
...mapState({
pendingTaskList: state => state.task.pendingTaskList,
executingTaskQueue: state => state.task.executingTaskQueue
}),
// executingTaskQueue sort by asc
executingTaskList () {
return _.orderBy(this.executingTaskQueue, ['lastExecutedTime'], ['asc'])
}
}
}
視圖無法及時更新
由於執行中任務隊列
初始狀態沒有任何屬性,則添加新的執行任務時Vue無法立即完成對其視圖的響應式更新,這裏可參考深入響應式原理,實現對視圖響應式更新的控制。
// store/modules/task.js
const mutations = {
ADD_EXECUTING_TASK_QUEUE (state, { taskId, task }) {
state.executingTaskQueue = Object.assign({}, state.executingTaskQueue,
{ [taskId]: { ...task, status: 'running' } })
},
}
任務實現
爲區分mixin中函數及後續功能維護便捷,mixin中函數均添加_前綴
該部分代碼較多,相關實現在之前的文章中有描述,這裏不在贅述。
可點擊task-mixin.js查看源碼。
// store/task-mixin.js
const taskMixin = {
methods: {
_connectServe () {},
_runCommand () {},
_compress () {},
_uploadFile () {}
// 省略...
}
}
任務執行
任務執行流程按照用戶選擇依次執行:
- 提示任務執行開始執行,開始任務計時
- 執行服務器連接
- 是否存在遠端前置命令,存在則依次順序執行
- 是否開啓任務上傳,開啓則依次進入5、6、7,否則進進入8
- 是否存在本地編譯命令,存在則執行
- 根據上傳文件類型(文件、文件夾),是否開啓備份,上傳至發佈目錄
- 是否存在本地清理命令,存在則執行
- 是否存在遠端後置命令,存在則依次順序執行
- 計時結束,提示任務完成,若該任務爲已保存實例,則更新保存的上次執行狀態
Tip:
- 每個流程完成後,會添加對應反饋信息至任務日誌中進行展示
- 某流程發生異常,會中斷後續流程執行,並給出對應錯誤提示
- 任務不會保存任務日誌信息,僅保存最後一次執行狀態與耗時
// views/home/TaskCenter.vue
export default {
watch: {
pendingTaskList: {
handler (newVal, oldVal) {
if (newVal.length > 0) {
const task = JSON.parse(JSON.stringify(newVal[0]))
const taskId = uuidv4().replace(/-/g, '')
this._addExecutingTaskQueue(taskId, { ...task, taskId })
this.handleTask(taskId, task)
this._popPendingTaskList()
}
},
immediate: true
}
},
methods: {
// 處理任務
async handleTask (taskId, task) {
const { name, server, preCommandList, isUpload } = task
const startTime = new Date().getTime() // 計時開始
let endTime = 0 // 計時結束
this._addTaskLogByTaskId(taskId, '⚡開始執行任務...', 'primary')
try {
const ssh = new NodeSSH()
// ssh connect
await this._connectServe(ssh, server, taskId)
// run post command in preCommandList
if (preCommandList && preCommandList instanceof Array) {
for (const { path, command } of preCommandList) {
if (path && command) await this._runCommand(ssh, command, path, taskId)
}
}
// is upload
if (isUpload) {
const { projectType, localPreCommand, projectPath, localPostCommand,
releasePath, backup, postCommandList } = task
// run local pre command
if (localPreCommand) {
const { path, command } = localPreCommand
if (path && command) await this._runLocalCommand(command, path, taskId)
}
let deployDir = '' // 部署目錄
let releaseDir = '' // 發佈目錄或文件
let localFile = '' // 待上傳文件
if (projectType === 'dir') {
deployDir = releasePath.replace(new RegExp(/([/][^/]+)$/), '') || '/'
releaseDir = releasePath.match(new RegExp(/([^/]+)$/))[1]
// compress dir and upload file
localFile = join(remote.app.getPath('userData'), '/' + 'dist.zip')
if (projectPath) {
await this._compress(projectPath, localFile, [], 'dist/', taskId)
}
} else {
deployDir = releasePath
releaseDir = projectPath.match(new RegExp(/([^/]+)$/))[1]
localFile = projectPath
}
// backup check
let checkFileType = projectType === 'dir' ? '-d' : '-f' // check file type
if (backup) {
this._addTaskLogByTaskId(taskId, '已開啓遠端備份', 'success')
await this._runCommand(ssh,
`
if [ ${checkFileType} ${releaseDir} ];
then mv ${releaseDir} ${releaseDir}_${dayjs().format('YYYY-MM-DD_HH:mm:ss')}
fi
`, deployDir, taskId)
} else {
this._addTaskLogByTaskId(taskId, '提醒:未開啓遠端備份', 'warning')
await this._runCommand(ssh,
`
if [ ${checkFileType} ${releaseDir} ];
then mv ${releaseDir} /tmp/${releaseDir}_${dayjs().format('YYYY-MM-DD_HH:mm:ss')}
fi
`, deployDir, taskId)
}
// upload file or dir (dir support unzip and clear)
if (projectType === 'dir') {
await this._uploadFile(ssh, localFile, deployDir + '/dist.zip', taskId)
await this._runCommand(ssh, 'unzip dist.zip', deployDir, taskId)
await this._runCommand(ssh, 'mv dist ' + releaseDir, deployDir, taskId)
await this._runCommand(ssh, 'rm -f dist.zip', deployDir, taskId)
} else {
await this._uploadFile(ssh, localFile, deployDir + '/' + releaseDir, taskId)
}
// run local post command
if (localPostCommand) {
const { path, command } = localPostCommand
if (path && command) await this._runLocalCommand(command, path, taskId)
}
// run post command in postCommandList
if (postCommandList && postCommandList instanceof Array) {
for (const { path, command } of postCommandList) {
if (path && command) await this._runCommand(ssh, command, path, taskId)
}
}
}
this._addTaskLogByTaskId(taskId, `🎉恭喜,所有任務已執行完成,${name} 執行成功!`, 'success')
// 計時結束
endTime = new Date().getTime()
const costTime = ((endTime - startTime) / 1000).toFixed(2)
this._addTaskLogByTaskId(taskId, `總計耗時 ${costTime}s`, 'primary')
this._changeTaskStatusAndCostTimeByTaskId(taskId, 'passed', costTime)
// if task in deploy instance list finshed then update status
if (task._id) this.editInstanceList({ ...task })
// system notification
const myNotification = new Notification('✔ Success', {
body: `🎉恭喜,所有任務已執行完成,${name} 執行成功!`
})
console.log(myNotification)
} catch (error) {
this._addTaskLogByTaskId(taskId, `❌ ${name} 執行中發生錯誤,請修改後再次嘗試!`, 'error')
// 計時結束
endTime = new Date().getTime()
const costTime = ((endTime - startTime) / 1000).toFixed(2)
this._addTaskLogByTaskId(taskId, `總計耗時 ${costTime}s`, 'primary')
this._changeTaskStatusAndCostTimeByTaskId(taskId, 'failed', costTime)
console.log(error)
// if task in deploy instance list finshed then update status
if (task._id) this.editInstanceList({ ...task })
// system notification
const myNotification = new Notification('❌Error', {
body: `🙃 ${name} 執行中發生錯誤,請修改後再次嘗試!`
})
console.log(myNotification)
}
}
}
}
總結
此次使用electron
對終端執行版本的前端自動化部署工具進行了重構,實現了功能更強、更加快捷、自由的跨平臺應用。
由於當前沒有Mac
環境,無法對Mac
端應用進行構建、測試,請諒解。歡迎大家對其編譯和測試,可通過github構建、測試。
🔔項目和文檔中仍有不足,歡迎指出,一起完善該項目。
🎉該項目已開源至 github
,歡迎下載使用,後續會完善更多功能 🎉 源碼及項目說明
喜歡的話別忘記 star 哦😘,有疑問🧐歡迎提出 pr 和 issues ,積極交流。
後續規劃
待完善
- 備份與共享
- 項目版本及回滾支持
- 跳板機支持
不足
- 因當前遠端命令執行,使用非交互式shell,所以使用
nohup
、&
命令會導致該任務持續runing
(沒有信號量返回)