ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

前端自动化部署实战:DeployGo一键构建上传工具解析

前端自动化部署实战:DeployGo一键构建上传工具解析 做了快十年前端我发现自己最烦的从来不是写页面而是每次发版前那套固定流程登录服务器检查磁盘、本地跑构建、等着打包、开SFTP工具、手拖文件上去、再刷新缓存。这些步骤里每一步单看都简单可只要哪天网站有点问题第一反应就是怀疑自己是不是漏传了某个文件。后来我实在忍不了了花了一晚上写了套一键构建前端项目并上传到服务器的工具起名叫DeployGo从此发版变成了一条命令的事。这篇分享就围绕DeployGo讲透这件事它为什么值得做核心流程怎么设计关键模块怎么落地以及我在真实项目里踩过哪些坑。如果你也是一个人维护前端项目的开发者或者在一个没有专职运维的小团队里干活这篇内容可以直接当参考抄。1. 手动部署的重复劳动传统发布流程到底浪费了多少时间1.1 拆解一次传统发布的全部步骤在把DeployGo写出来之前我先把自己日常发版的完整动作挨个列了一遍不列不知道列完才发现一个看起来也没多复杂的流程里藏着这么多步打开命令行确认当前分支是release还是main拉一遍最新代码手动执行npm install万一依赖有变化这一步还要等很久执行构建命令比如npm run build等着vite或者webpack把代码打完确认构建产物在dist目录里生成了有时候还得检查文件大小对不对打开FileZilla或者JetBrains自带的Deployment面板手动连接服务器输入密码或者密钥定位到本地dist目录再定位到远程网站根目录把dist下所有文件选中、拖拽上传等待上传完成期间不能切走界面否则某个文件就可能传漏上传结束后到服务器上执行rm -rf清缓存或者其他清理命令。以我自己的项目为例一次发布光手工操作就要10到15分钟。这还只是顺利的情况。如果哪一步忘了执行比如忘记先pull最新代码把旧代码打包传上去了那出了问题再回滚、再排查时间成本翻倍。我见过不少前端同事的解决方案是记住这些步骤然后每次都小心一点。但人不是机器在这种重复操作上出错的概率远比自己想象的高。尤其是到傍晚急着发版的时候手一抖漏掉某个静态资源线上样式瞬间错乱这种事故我经历过不止一次。1.2 为什么我有Jenkins和GitLab CI还是写了DeployGo肯定有人会说现在不是有Jenkins、GitLab CI/CD、GitHub Actions这些自动化工具吗为什么还要自己写一个说实话这些工具我都用过。公司级别的项目有专职运维配置流水线那的确很爽。但放到个人项目、小团队项目或者客户现场的内网服务器上这些重型方案往往杀鸡用牛刀。先说Jenkins它的优势是可视化、插件生态丰富但劣势也明显需要一台常驻的服务器或容器来跑Jenkins服务本身需要维护插件版本Pipeline脚本写起来并不轻松。对于只有一台业务服务器的个人开发者来说这种投入明显不值当。GitLab CI/GitHub Actions这类云端CI优势是集成在代码仓库里但前提是你的代码托管在对应平台上而且Runner要么用官方托管的要么需要自己搭。很多外包项目或者企业内部项目代码放在内网GitLab上根本没有外网访问权限官方Runner根本用不了自建Runner又是一堆维护工作。这时候就出现了一个空档不需要常驻服务不需要额外基础设施能在我本地一条命令完成构建上传的轻量工具。DeployGo定位就是填补这个空档的。它的设计目标很明确零常驻依赖、配置一次终身使用、一条命令跑完发布。它的存在不是为了替代CI/CD而是覆盖CI/CD管不到的最后一公里或者说在项目还没资格上CI/CD的时候先自动化起来。2. DeployGo核心设计一条简单命令背后的完整链路2.1 配置驱动把项目差异和逻辑代码彻底解耦DeployGo从设计之初就遵循一个原则代码逻辑和项目配置分离。我不会为每个项目都改一遍上传代码而是把差异全放进一个配置文件里。这个文件默认叫deploy.config.js放在项目根目录结构长这样module.exports { // 默认构建命令 buildCommand: npm run build, // 构建产物目录 distDir: dist, // 环境配置可以配置多个 envs: { prod: { host: 120.xx.xx.xx, port: 22, username: www, privateKey: ~/.ssh/id_rsa, remoteDir: /var/www/my-site, // 部署前是否远程备份 backup: true, backupDir: /var/www/backups }, staging: { host: 192.168.1.10, port: 22, username: deploy, password: xxx, remoteDir: /home/deploy/www/test-site, backup: false } } };这个文件承担了所有每个项目都不一样的信息服务器地址、登录方式、远程目录、构建命令、产物目录。核心代码永远只需要读取这份配置然后按流程执行。配置驱动带来的直接好处是把DeployGo分享给团队其他人时他们不需要读源代码只需要改这个配置文件就能接入自己维护的项目。有新人加入把这份配置丢给他他马上就能独立发版。我特意把password和privateKey都支持了但个人强烈建议使用密钥登录。密码方式虽然方便但在配置里明文保存是个隐患而且部分服务器出于安全策略会直接禁用密码登录。用密钥的话既安全又不用每次输密码。2.2 一条命令激活的完整发布链路用户视角下DeployGo的用法极简deploygo --env prod但这条命令背后是一个有严格顺序的执行链路每一步都不可颠倒配置校验读取deploy.config.js检查必填字段是否齐全环境名是否存在构建目录是否配置。这一步能在正式操作之前拦截掉大量低级错误。本地构建通过spawn调用系统的shell执行构建命令。这里不是简单地执行完就结束而是要实时捕获构建输出并检查子进程退出码。退出码非0意味着构建失败立刻终止后续流程。构建产物完整性检查检查产物目录是否存在关键入口文件如index.html是否生成文件数量是否异常。这一步是我实践后加上的有次构建静默失败dist里只有空的目录结构差点传上去。上传准备在本地对产物目录做一次文件清单扫描记录文件路径、大小、最后修改时间。这个清单是后续上传和校验的依据。远程发布通过SFTP协议连接服务器先按配置决定是否备份远程旧版本再执行上传。我采用的方式不是直接覆盖而是先上传到临时目录确认无误后执行原子切换避免传输中途线上文件不全。清理与确认上传完成后删除远程临时文件输出本次发布的文件总数、总大小、耗时等统计信息。这六步里的每一步都在我实战过程中沉淀过对应的问题。比如第5步的原子切换就是因为我曾经因为直接覆盖线上目录导致上传到一半时用户访问到了文件不完整的网站。2.3 传输协议选型为什么是SFTP而不是SCP或HTTP在实现上传功能的时候我对比过几种方案最终选了SFTP。这里说下我的考量。SCP是最常见的Linux远程拷贝命令简单、速度快但它的缺点是传输过程不透明一旦中断很难接续而且它只能做文件传输不能方便地做远程目录的列举和删除操作。DeployGo需要在上传前检查远程目录结构、上传后进行文件校验SCP在这方面的能力很弱。HTTP上传需要服务器额外部署一个接收端比如写个Node服务或者PHP脚本这违背了DeployGo零额外依赖的初衷。而且多一个接收端就多一个安全攻击面除非项目本身已经有上传接口否则不推荐。SFTP基于SSH天然具备传输加密绝大多数Linux服务器默认开启SSH服务不需要再装任何额外软件。它支持文件读写、目录列举、重命名、删除等全套文件操作完全满足DeployGo的需求。Node生态里ssh2-sftp-client这个库封装得相当完善API设计简洁错误处理也清晰。最终我选定了ssh2-sftp-client作为底层的传输模块经过几个项目实测它在传输稳定性、断线处理、速度方面都表现合格。3. 从零搭建DeployGo关键模块的实现与业务取舍3.1 CLI入口与命令解析DeployGo的工程结构并不复杂核心入口就是一个Node脚本。我在package.json里通过bin字段注册了命令行命令{ name: deploygo, version: 1.0.0, bin: { deploygo: bin/deploygo.js } }bin/deploygo.js顶部加上Node的shebang#!/usr/bin/env node const { Command } require(commander); const deploy require(../lib/deploy); const program new Command(); program .name(deploygo) .description(一键构建前端项目并上传到服务器) .version(1.0.0) .option(-c, --config path, 配置文件路径, deploy.config.js) .option(-e, --env name, 部署环境如 prod/staging, prod) .option(-d, --dry-run, 只构建不上传) .option(-k, --skip-build, 跳过构建直接上传已有产物) .parse(process.argv); const options program.opts(); deploy(options).catch((err) { console.error([DeployGo] 部署失败:, err.message); process.exit(1); });commander这个库让命令行参数解析变得非常省心支持--env、--dry-run这些常用选项。我特意加了一个--dry-run参数它只执行构建不做上传方便在调试构建流程时快速验证避免误操作影响到线上。--skip-build则是为了支持那种代码没变只是想重新上传一遍产物的场景避免重复构建浪费时间。3.2 构建子进程如何拿到一份可靠的构建产物构建逻辑是DeployGo的基础保障。如果构建出来的产物本身就是坏的那后面上传再稳定也没有意义。我使用Node的child_process.spawn来执行构建命令而不是exec。区别在于spawn默认不带shell包装参数传递更安全也更容易处理大体积的标准输出。vite、webpack这类构建工具通常会输出带颜色的进度信息直接捕获会得到一堆ANSI转义字符处理起来体验不好。const { spawn } require(child_process); function runBuild(buildCommand) { return new Promise((resolve, reject) { // 支持 npm run build 这类带参数的命令 const parts buildCommand.split(/\s/); const cmd parts[0]; const args parts.slice(1); console.log([DeployGo] 开始构建: ${buildCommand}); const child spawn(cmd, args, { stdio: inherit, shell: process.platform win32 }); child.on(close, (code) { if (code 0) { console.log([DeployGo] 构建成功); resolve(); } else { reject(new Error(构建执行失败退出码 ${code})); } }); child.on(error, (err) { reject(new Error(无法执行构建命令: ${err.message})); }); }); }注意这里有一个细节在Windows平台需要设置shell: true因为npm在Windows下是一个npm.cmd文件不通过shell无法直接执行。这个小坑我一开始没注意导致同事在Windows机器上运行时报无法识别npm命令后来加了平台判断才解决。构建完成不等于产物可靠。我加了一个validateDist函数检查产物目录是否存在、index.html是否生成、目录是否为空以及文件数量是否在一个合理范围内。比如一个简单的Vue项目构建产物一般不会少于几十个文件如果扫描出来只有两三个文件那大概率是构建出问题了这时候直接中断更稳妥。3.3 上传机制产物同步与远程文件处理上传是整个工具最核心的部分也是我迭代次数最多的模块。它的目标是把本地产物目录的内容完整同步到服务器指定目录。我最初的做法是暴力上传递归遍历本地目录把所有文件一股脑上传到远程对应路径。但很快发现这带来几个问题第一远程目录里旧的、本季度已删除的文件永远残留着日积月累服务器上堆满了垃圾文件。第二如果本地删除了某个图片远程旧文件还指向它用户访问时就会得到404。第三无法得知每次发布实际动了多少文件出问题时不知道从何查起。后来我把上传升级成了同步。同步的核心逻辑分三步扫描本地产物目录得到期望的文件清单列出远程目录的现有文件清单做差集远程有、本地没有的标记为待删除本地有、远程没有或哈希不一致的标记为待上传。删除是个危险操作所以我加了两个保险默认开启远程备份删除前把旧文件转移到备份目录同时支持配置backup: false来关闭自动备份给有特殊需求的场景留了口子。文件哈希比对这里我没有对每个文件都计算MD5那样大文件会非常慢。我采用的折中方案是先比文件大小大小不一致的直接重新上传大小一致的文件再比mtime时间戳。虽然理论上可能出现大小一样但内容不同的极端情况但在前端构建产物的场景中这个概率小到可以忽略而性能收益是实打实的。const path require(path); const SftpClient require(ssh2-sftp-client); const fg require(fast-glob); async function syncDist(sftp, localDir, remoteDir) { // 1. 获取本地文件清单 const localFiles await fg(**/*, { cwd: localDir, dot: true, onlyFiles: true }); // 2. 获取远程文件清单 const remoteFiles await sftp.list(remoteDir).catch(() []); // 3. 找出需要上传的文件 const toUpload []; for (const file of localFiles) { const localPath path.join(localDir, file); const remotePath path.posix.join(remoteDir, file); const localStat await fsp.stat(localPath); let needUpload true; try { const remoteStat await sftp.stat(remotePath); needUpload remoteStat.size ! localStat.size; } catch (e) { needUpload true; } if (needUpload) toUpload.push({ localPath, remotePath }); } // 4. 执行上传 for (const item of toUpload) { await sftp.mkdir(path.posix.dirname(item.remotePath), true); await sftp.fastPut(item.localPath, item.remotePath); } return toUpload.length; }fastPut是ssh2-sftp-client提供的高效上传方法比逐个createWriteStream的方式速度更快内部做了并发和流处理优化。对于几百个静态文件的前端项目通常几秒就能传完。3.4 发布策略临时目录加原子切换如果只是单纯把文件传到服务器上那DeployGo充其量是个高级版FTP工具。真正让它适合线上环境的关键是我后来加上去的一个设计临时目录加原子切换。具体做法是远程服务器上真正对外提供服务的目录是/var/www/my-site我不会直接把文件传进这个目录而是先传到它的兄弟目录/var/www/my-site-tmp。传输完成并确认文件数一致后执行两步操作# 第一步把当前生产目录改名成备份目录 mv /var/www/my-site /var/www/my-site-old # 第二步把临时目录改成生产目录 mv /var/www/my-site-tmp /var/www/my-site # 第三步删除备份目录 rm -rf /var/www/my-site-old这样的好处是在整个发布过程中网站目录始终是完整的。Nginx和用户根本感知不到发布的中间状态。就算上传到一半网络断了影响到的也只是my-site-tmp这个临时目录线上毫发无损。这个设计在发布大体积项目时尤其有价值。我参与过一个后台管理系统首屏构建产物带SourceMap有200多MB直接往线上目录传传1分钟线上就有1分钟处于半新半旧的状态用户刷新到一半就可能看到样式错乱。改成临时目录加原子切换之后再也不存在这个问题了。代价是需要确保服务器上对网站根目录有足够的写权限因为涉及到mv和rm -rf操作。所以DeployGo连接服务器时使用的用户最好对网站根目录有完整的读写权限否则会在切换目录时报Permission denied。4. 实测中最高频的失败场景与完整排查链路4.1 构建成功但传上去的是旧文件这是我遇到过的第一个看起来完全没道理的bug。DeployGo执行的时候构建步骤正常通过日志也显示上传了若干文件但线上访问时页面还是旧版本。排查过程是这样的我先在本地确认dist目录里的index.html确实是新版检查后发现本地没问题到服务器上手动查看线上目录里的index.html内容也是新的但浏览器里访问还是旧的。这时候我意识到可能是缓存问题于是给Nginx配了一个html文件不缓存、静态资源带hash缓存的规则才解决。最终根因落在缓存策略上。前端项目的构建产物里index.html这个入口文件是不带指纹的但它的内容是每次构建都变化的而assets目录下的js/css文件通常带hash指纹文件名变化了旧的缓存本来就不会命中。问题就出在很多Nginx配置对html文件也设置了较长的Cache-Control导致浏览器拿着旧的页面入口去加载旧的资源。DeployGo本身不会造成这个问题但它让发布频率变高了也就更容易暴露缓存配置的问题。我给Nginx加了一段调整location / { index index.html; try_files $uri $uri/ /index.html; } # html文件不缓存或短缓存 location ~* \.html$ { add_header Cache-Control no-cache, no-store, must-revalidate; } # 带hash的静态资源长缓存 location ~* \.(js|css|png|jpg|jpeg|gif|svg|woff2?)$ { expires 30d; add_header Cache-Control public, immutable; }这段配置配合DeployGo之后每次发版用户刷新拿到新的html再根据新的文件名加载新的静态资源完美衔接。4.2 上传中断导致线上页面样式全部丢失这个事故让我给DeployGo加上了原子切换过程印象非常深刻。当时项目体积比较大构建产物有小200MB。我直接用最原始的方案往线上目录传文件传到一半家里WiFi断了。重连后发现线上网站彻底崩了因为传到一半的旧目录里index.html是新文件它引用的JS和CSS文件名是新hash但大部分新资源还没传上去浏览器打开页面后加载不到对应的文件直接白屏。事后总结经验直接往生产目录覆盖传输是高风险操作尤其在网络不稳定和产物体积大的场景下。修复方案就是上文提到的临时目录加原子切换。这个事故之后DeployGo的安全性才真正达到我可以放心交付给团队使用的水平。顺带一提这种先传临时目录再原子切换的思路和很多服务器部署方案里rsync配合--delete实现平滑更新的原理是一致的。但rsync在Windows环境下的支持不如Node脚本友好而且DeployGo还要做环境管理、备份策略这些rsync本身不具备的事。4.3 服务器拒绝连接或权限不足DeployGo在团队内部推广时遇到最多的一类问题是连接相关账号连不上、目录进不去、文件没权限。排查这类问题我一般按这个链路走先确认网络层通不通ping服务器IPSSH端口默认22在防火墙上是否放行确认SSH服务本身正常手动执行ssh userhost是否能登录确认认证方式如果用密钥检查本地密钥路径是否正确公钥是否已经加到服务器的~/.ssh/authorized_keys里私钥文件的权限是否是600确认远程目录存在且有写权限ls -ld /var/www/my-site看目录owner是不是DeployGo连接的用户如果用到mv/rm这类操作确认该用户在目录父级也有写权限以及SELinux没有拦着。权限问题里最隐蔽的一个是/var/www父目录权限。哪怕网站目录本身是www用户所有但如果/var/www这个父目录对其他用户没有写权限DeployGo就无法在这个层级创建临时目录。后来我把配置里直接指定了临时目录的位置确保它建在网站目录的同一父级下并提示用户确认该父级权限。4.4 SourceMap泄露源码的隐患有一次发布完我在控制台里偶然看到sources面板里出现了完整的Vue源码文件才意识到构建配置里开着SourceMap而这些映射文件被一并传上了服务器。对于公司内部后台系统这可能不是致命问题但对于面向公网的产品这等于把未混淆的源码直接暴露给了所有人。任何有点经验的人都能通过开发者工具的Sources面板还原出你的业务逻辑、API路径甚至一些硬编码的密钥。DeployGo在同步阶段默认会跳过*.map文件除非用户在配置里通过keepSourceMap: true显式打开。同时在文档里我加了一条明确建议生产环境应该关闭SourceMap或者仅在错误监控系统需要时单独上传到监控平台而不是留在网站目录里。我个人的做法是构建时用vite build --sourcemap false既减少产物体积又避免泄露问题。如果确实需要线上定位问题优先依赖Sentry这类错误监控系统收集的堆栈信息而不是开SourceMap。5. DeployGo在实际项目中的用法与下一步扩展5.1 一份可以直接抄的配置模板前面零散地提到了配置项这里贴一份我在真实项目里正在用的完整模板可以直接作为起点const path require(path); module.exports { // 构建命令 buildCommand: npm run build, // 构建产物目录 distDir: dist, // 部署前先执行本地测试可以配 lint/unit test preCommands: [npm run lint], envs: { dev: { host: 10.0.0.5, port: 22, username: deploy, privateKey: path.join(process.env.HOME, .ssh/id_rsa), remoteDir: /data/www/dev-site, backup: false }, prod: { host: 120.xx.xx.xx, port: 22, username: www, privateKey: path.join(process.env.HOME, .ssh/id_rsa), remoteDir: /data/www/prod-site, backup: true, backupDir: /data/backups/prod-site, // 是否跳过sourcemap exclude: [**/*.map] } } };使用方式也顺手复习一遍# 部署到生产环境 deploygo --env prod # 部署到dev环境 deploygo --env dev # 只构建不上传验证构建流程 deploygo --env prod --dry-run # 跳过构建直接上传已有dist目录 deploygo --env prod --skip-build在真实使用中我还会在package.json里加几个script别名这样连deploygo这个命令都不用记{ scripts: { deploy:prod: deploygo --env prod, deploy:dev: deploygo --env dev } }之后发版只需要执行npm run deploy:prod简单到不能再简单。5.2 DeployGo在CI/CD流水线里能扮演什么角色虽然DeployGo诞生之初是为了解决没有CI/CD环境的困境但这不代表它只能用于本地。实际上它完全可以作为CI/CD流水线的最后一步存在。举个例子在GitLab CI/CD里Runner可以执行一个job专门负责部署。之前要写一套完整的Pipeline脚本配合Docker镜像构建现在可以简化为在Runner上安装DeployGo然后在deploy这个stage里执行deploy: stage: deploy image: node:18 script: - npm install -g deploygo - deploygo --env prod only: - tags当然这种用法的前提是Runner能访问到DeployGo的npm包或其构建产物并且Runner到生产服务器的SSH链路是通的。对于很多自建内网GitLab的场景这比维护一套完整的Docker镜像构建发布流水线要轻量得多。我个人是这么划分边界的如果有专职运维和成熟的DevOps平台直接用Jenkins或者Kubernetes那套即可如果团队规模不大、服务器三五台以内DeployGo这层自动化已经能覆盖大部分发布需求没必要杀鸡用牛刀。5.3 下一步计划回滚、通知与多服务器发布DeployGo当前版本已经能满足我的日常需求但实际使用中我仍然在规划一些扩展点。第一个是版本回滚。当前备份机制是把旧目录移到备份目录如果需要回滚得手动登录服务器把备份目录改名回去。下一步我打算在DeployGo里加一个--rollback参数自动列出备份目录下所有带时间戳的备份指定一个恢复一行命令完成切换。第二个是发布通知。发布完成后往钉钉或者企业微信群里发一条消息附带本次发布的文件数、文件总大小、耗时以及发布人。对于团队协作来说这个信息共享很有价值。目前我是在本地的shell环境里手动divert通知体验一般。第三个是多服务器并行发布。现在DeployGo一次只能发布到一台服务器。如果以后项目需要横向扩展有多台服务器同时提供服务就需要DeployGo支持传入一组服务器配置并行发布到所有机器上并且等全部成功后再返回结果。这个功能本身不难主要难点在于多机发布时的失败回滚协调机制我还在设计阶段。我自己的态度是工具的生命力在于解决真实的重复劳动。只要发布流程还存在重复DeployGo就有持续迭代的价值。这些小项目里的工具积累看起来不如新框架、新特性那样光鲜但实实在在地让每周末的发布变成了一件轻轻松松的事这才是工具真正的意义。
返回列表