从"一键部署"到"可观测、可定制的发布流":我如何打造一个企业级部署工具
大多数公司都用着各个 Git 厂商的自动构建、发布的 CI/CD 系统。仅需按一下,代码就自动构建、打包、上传、发布,然后我们就可以泡杯咖啡☕️,享受生活。
然而你知道这种 CI/CD 是如何实现的吗?下面我来教你实现一个企业级的 CI/CD 系统
- 源码 : github.com/beixiyo/dep...
- NPM 包 : www.npmjs.com/package/@jl...

📖 阅读本文,你将收获什么?
这篇文章不是一份枯燥的说明文档,而是一次沉浸式的开发之旅
跟随我的思路,你将具体了解到:
- "钩子"的扩展性:如何通过生命周期钩子(Hooks)打造一个高可扩展的系统
- 像乐高一样组合功能:将复杂流程拆解为独立、可复用的模块
- 如何回滚代码:代码崩了,可迅速回到之前的版本
- 如何实现美观的命令行
- 如何为 CLI 工具构建优雅的"交互模式":让冰冷的命令行充满"人情味"
需要考虑的问题
部署流程中充满了不确定性:
- ❓网络突然抖动怎么办
- ❓多台服务器如何管理
- ❓部署到一半出错了怎么回滚
- ❓我想在部署前后发个通知,又该如何实现
正是这些"灵魂拷问"驱动我开发了这个开源项目。它不仅仅是一个一键部署的脚本,我更愿意称之为一个 可观测、可定制的自动化发布工作流引擎。
✨ 核心功能亮点
在深入技术细节之前,来看看要实现哪些功能:
- 🔨 全自动化流程: 自动完成本地构建、压缩、上传、远程部署和清理。
- 🌐 多服务器支持: 支持并发或串行地将项目部署到多台服务器。
- 🤝 灵活的交互模式: 在部署的每个关键节点暂停,由你确认后继续,让用户自己掌控。
- 💾 智能备份与回滚: 自动备份历史版本,并控制备份数量,为快速回滚提供保障。
- 🎣 丰富的生命周期钩子: 在构建、压缩、上传、部署等各个阶段,你都可以注入自己的逻辑(例如,发送飞书/钉钉通知)。
- ⚠️ 结构化错误处理: 提供统一的错误码和错误类型,非常适合与 CI/CD 系统集成。
🚀 探索之旅:核心挑战与解决方案
💡如何让冰冷的命令行"听懂人话"?
一键部署 听起来很酷,但也意味着"一键到底",中间过程完全失控。如果我只是想构建一下,看看产物对不对,或者我只想部署到某一台测试机上,怎么办?我需要一个"暂停"按钮。
这些功能都可以通过 inquirer
来实现,下面是一个简单的例子:

typescript
import inquirer from 'inquirer'
const { shouldContinue } = await inquirer.prompt([
{
type: 'confirm',
name: 'shouldContinue',
message: '是否继续执行构建?',
default: true,
},
])
if (shouldContinue) {
console.log('继续执行构建')
}
else {
console.log('停止构建')
}
通过这种方式,我们赋予了自动化流程一个"人性化"的交互界面,让开发者在享受自动化的同时,也能拥有完全的控制权。
💡如何让一个部署工具变得"可插拔"?
用户的需求是千变万化的。
- 用户 A 想在部署成功后发一条钉钉消息。
- 用户 B 希望在部署前,先调用一个内部 API 来锁定版本。
- 用户 C 使用的是私有的代码仓库,需要特殊的认证方式。
如果我把这些功能全部硬编码到代码里,它会变得越来越臃肿,难以维护。我需要一种机制,让用户可以注入自己的逻辑到部署流程中。
所以可以通过 Hook 的方式暴露每个生命周期,让用户能自己 DIY
bash
构建 -> 压缩 -> 连接 -> 上传 -> 部署 -> 清理
我提供了覆盖整个生命周期的钩子,如
onBeforeBuild
onAfterBuild
onBeforeCompress
onAfterCompress
onBeforeConnect
- ...
通过一个通用的 executeHook
函数来调用 Hook 并提供上下文
typescript
import { DeployError, DeployErrorCode }. from './types'
/** 定义通用的上下文接口 */
export interface HookContext {
opts: DeployOpts
stage: string // 当前阶段,如 'build', 'upload'
// ... 其他信息
}
export async function executeHook<T extends HookContext>(
hook: ((context: T) => Promise<void> | void) | undefined,
context: T
): Promise<void> {
// 只有当用户传递了这个钩子时才执行
if (hook) {
try {
await hook(context) // 执行用户自定义的逻辑
}
catch (error) {
// 捕获钩子内部的错误,并包装成统一的 DeployError
throw new DeployError(
DeployErrorCode.UNKNOWN_ERROR,
`Hook execution failed in stage ${context.stage}`,
error
)
}
}
}
在主流程的每个阶段,我们都会像这样调用 executeHook
typescript
// ... 构建阶段 ...
await executeHook(opts.onBeforeBuild, buildContext)
await build(opts.buildCmd)
await executeHook(opts.onAfterBuild, buildContext)
// ... 压缩阶段 ...
await executeHook(opts.onBeforeCompress, compressContext)
await startZip(opts)
await executeHook(opts.onAfterCompress, compressContext)
// ... etc ...
💡 如何安全、稳定地连接到远程服务器?
-
专业的 SSH 库 :我们选择了久经考验的
ssh2
库,它为 Node.js 提供了稳定、功能丰富的 SSH2 客户端。 -
灵活的认证方式 :我们支持密码和私钥两种认证方式。在生产环境中,强烈推荐使用私钥,因为它比密码更安全。
typescriptimport { Client } from 'ssh2' const { readFileSync } = require('node:fs') const { homedir } = require('node:os') const { resolve } = require('node:path') const client = new Client() client.connect({ host: '192.168.1.100', username: 'root', // ====================== // * 使用密码或者 ssh 私钥 // ====================== password: 'your-server-password', privateKey: readFileSync(resolve(homedir(), '.ssh/id_rsa'), 'utf-8'), })
-
自动重试机制 :网络抖动是常有的事。为了对抗这种不确定性,我们内置了失败重试 机制。当连接或上传失败时,它会自动尝试,默认重试 3 次。这个次数可以通过
uploadRetryCount
选项进行配置。typescript// src/connectAndUpload.ts 伪代码 const uploadTasks = opts.connectInfos.map(connectInfo => retryTask( () => attemptUploadToServer(connectInfo), // 尝试连接并上传 opts.uploadRetryCount // 默认为 3 ) ) /** * 失败后自动重试请求 * @param task 任务函数,返回一个 Promise * @param maxCount 剩余重试次数,默认 3 */ export async function retryTask<T>( task: () => Promise<T>, maxCount = 3 ): Promise<T> { return task() .then(res => res) .catch(async (err) => { // 捕获错误 const remainingRetries = maxCount - 1 if (remainingRetries <= 0) { console.error('任务失败,重试次数已耗尽:', err) throw new DeployError( DeployErrorCode.UPLOAD_RETRY_EXHAUSTED, '重试次数耗尽', err ) } else { console.warn(`任务失败,剩余重试次数: ${remainingRetries}`, err) // 等待一小段时间再重试,避免立即重试导致连续失败 await new Promise(resolve => setTimeout(resolve, 300)) return retryTask(task, remainingRetries) } }) }
通过这种设计,我们将连接的复杂性封装起来,为用户提供了一个既简单又可靠的接口。
💡 如何高效地压缩与上传?
将成百上千的构建产物(HTML, CSS, JS 文件等)传输到服务器,如果逐个传输,会非常缓慢且效率低下。
解决方案:
-
先压缩,再上传 :我们将所有构建产物打包成一个
.tar.gz
文件。这是一种在 Linux/macOS 环境下非常标准的格式,它能将文件归档(tar
)和压缩(gzip
)合二为一。 -
追求极致的压缩率 :我们使用了
archiver
这个库,并默认开启了最高的压缩等级(level: 9
)。这意味着会花费稍多一点的 CPU 时间来换取更小的文件体积,从而显著减少网络传输时间。对于部署流程来说,这是一个非常划算的"交易"。typescript// src/startZip.ts const archive = archiver('tar', { gzip: true, gzipOptions: { level: 9 } // 最高的压缩等级 })
-
大文件优化上传 :我们使用 SFTP 的
fastPut
方法,它为大文件传输做了优化。 -
友好的进度提示:无论是本地压缩还是远程上传,我们都提供了实时进度条。用户可以清晰地看到当前进度、传输速度和文件大小,极大地提升了大型项目部署时的用户体验。
-
并发上传 :当需要部署到多台服务器时,默认会启用并发模式(
concurrent: true
),同时向所有服务器上传文件,最大化地利用网络带宽,缩短部署总时长。
💡 如何设计一个"不容易崩"的远程部署策略?
"部署"操作是整个流程中最危险的一步。如何用新的文件替换旧的文件,同时保证服务的连续性?如果新版本有问题,如何快速回滚?
解决方案:
-
默认的原子化部署命令 :我们提供了一个经过精心设计的默认部署命令,可以通过 deployCmd 配置自定义
bash# 1. 进入工作目录 cd /remote/workdir # 2. 干净利落地删除旧版本 rm -rf /remote/project # 3. 重新创建项目目录 mkdir -p /remote/project # 4. 将压缩包解压到新的项目目录 tar -xzf /remote/dist.tar.gz -C /remote/project # 5. 清理远程的压缩包 rm -rf /remote/dist.tar.gz
这种
先删除,再创建
的策略非常简单直接。它的好处是能确保每次部署都是一个全新的、干净的环境,不会有旧文件的残留。缺点是在rm
和tar
之间的短暂瞬间,服务是不可用的。对于大多数前端项目来说,这个窗口期极短,几乎可以忽略不计。 -
智能备份与回滚:万一线上版本出了问题怎么办?我们提供了备份机制作为"后悔药"。
- 在上传新版本时,我们会将当前的压缩包(也就是上一个稳定版)以时间戳命名 ,并复制到你指定的
remoteBackupDir
备份目录中。 - 你只需要找到上一个版本的压缩包,重新部署一次,即可完成回滚。
- 为了防止备份文件无限堆积,我们还提供了
maxBackupCount
选项(默认为 5),它会自动清理最旧的备份,只保留最近的几个版本。
- 在上传新版本时,我们会将当前的压缩包(也就是上一个稳定版)以时间戳命名 ,并复制到你指定的
-
自定义部署逻辑 :对于需要"零停机"部署的复杂应用(例如通过
symlink
软链接切换版本),你可以通过customDeploy
回调函数,完全接管部署逻辑,实现你自己的高级部署策略。
💡 如何打造一个"话痨"又"贴心"的日志系统?
一个自动化工具,如果执行过程像个"黑盒",会让人非常没有安全感。我们需要提供丰富、实时、清晰的反馈,但又要避免无意义的刷屏。
解决方案:
-
为信息"上色" :我们使用
kleur
库,为不同类型的日志赋予了不同的颜色。蓝色
用于流程说明绿色
用于成功状态黄色
用于警告红色
用于错误
这让用户能在一瞥之间就抓住关键信息。
-
"魔法"一样的单行进度条 :在压缩和上传大文件时,如果为每一小块数据都打印一行日志,屏幕会瞬间被占满。我们的秘诀是利用
process.stdout.write
和回车符\r
。\r
的作用是让光标回到当前行的行首。当我们打印新的进度时,它会覆盖掉旧的进度,从而实现在同一行内不断更新的效果,干净又优雅。typescript// src/logger.ts 伪代码 class Logger { progress(config) { // ... 计算进度 ... const message = `\r${prefix} ${progressBar} ${progressText}` // 直接写入标准输出流,而不换行 process.stdout.write(message) // 只有当进度完成时,才输出一个换行符 if (current >= total) { process.stdout.write('\n') } } }
-
结构化的日志 :除了颜色,我们还通过
title
、divider
、stage
和table
等方法,将部署流程结构化地展示出来,让整个过程清晰明了。
🛠️ 如何使用?
安装依赖
bash
npm install @jl-org/deploy
# 或
yarn add @jl-org/deploy
# 或
pnpm add @jl-org/deploy
创建脚本
scripts/deploy.cjs
js
// @ts-check
const { deploy } = require('@jl-org/deploy')
const { resolve } = require('node:path')
const { homedir } = require('node:os')
const { readFileSync } = require('node:fs')
deploy({
// ======================
// 🔗 SSH 连接信息
// ======================
connectInfos: [
{
name: 'server-1', // 服务器名称(可选,用于日志显示)
host: '192.168.1.100',
port: 22,
username: 'root',
password: 'password',
// 如果使用私钥登录,不使用密码登录的话
// privateKey: readFileSync(resolve(homedir(), '.ssh/id_rsa'), 'utf-8'),
}
],
// ======================
// 🔨 本地构建配置
// ======================
buildCmd: 'npm run build', // 构建命令
distDir: resolve(__dirname, '../dist'), // 构建产物目录
skipBuild: false, // 是否跳过构建步骤
// 📦 压缩文件配置
zipPath: resolve(__dirname, '../dist.tar.gz'), // 本地压缩文件路径
// ======================
// 🌐 远程服务器配置
// ======================
remoteZipPath: '/home/dist.tar.gz', // 远程压缩文件路径
remoteUnzipDir: '/home/test-project', // 远程解压目录
// ======================
// 💾 备份配置(可选)
// ======================
remoteBackupDir: '/home/test-project-backup', // 远程备份目录
maxBackupCount: 5, // 保留最近的备份数量
// ======================
// ⚙️ 其他选项
// ======================
needRemoveZip: true, // 是否需要删除本地压缩文件
uploadRetryCount: 3, // 上传失败重试次数
interactive: false, // 禁用交互模式(默认)
concurrent: true // 并发部署(默认)
})
运行
bash
node scripts/deploy.cjs
然后,就去泡杯咖啡吧!☕️