资讯详情

资讯详情

用Gradle统一管理Spring Boot + Vue前端构建,一条命令产出可部署jar

做前后端分离项目尤其是 Spring Boot Vue 这套组合时我见过太多团队把构建流程搞得像“手工流水线”后端在 IDEA 里点一下 Gradle 打包前端再开个终端 npm install、npm run build最后手动把 dist 目录拖到后端资源文件夹里。本地还好一上 CI 就全靠脚本串联今天缺个锁文件明天版本不对后天产物漏拷问题一个接一个。这篇文章就是来聊这件事的。核心思路很简单把 Vue 前端当成 Gradle 工程里的一个模块让 Gradle 统一管理依赖安装、前端构建、后端编译、产物合并最终一条命令输出可直接部署的 jar。我会把工程结构、插件选型、关键配置、生命周期绑定、常见坑全部展开适合正在做前后端分离项目、想统一构建流程的 Java 和前端开发者参考。1. 为什么要做统一构建先承认“分开构建”的代价1.1 分开构建时我最常遇到的三个问题第一是环境不一致。前端同学本地 Node 18CI 上 Node 14npm install 出来一堆警告甚至直接编译失败后端同学本地 Gradle 8.7服务器上 Gradle 6.9同样一份代码打包结果可能不一样。版本不锁死构建结果就不可复现这是我们最终被逼着做统一构建的最直接原因。第二是产物传递靠“人肉”。前端每次改完代码要把 dist 拷贝到后端 static 目录或者上传到某个共享目录让后端去取。拷贝容易漏文件目录结构容易错位赶上多人协作更是灾难。时间一长根本没人能说清楚线上运行的到底是哪次前端构建的产物。第三是返工成本高。分开构建意味着“前端构建”和“后端构建”是两套节奏经常出现后端接口改完了前端还没构建前端构建完了后端又换了版本。两个团队联调时问题永远出现在“边界”上——谁先构建、谁后构建、谁来合并这些规则写进文档没人看写进脚本又维护不动。1.2 统一构建到底要解决什么统一构建不是要把前端技术栈改成 Java而是把“构建”这个动作收敛到一个入口。我给自己定的目标是一条命令完成前端依赖安装、前端打包、后端编译、产物合并版本全部锁定Node 版本、npm 版本、Gradle 版本都由工程定义最终产物只有一个要么是一个包含前端静态资源的 Spring Boot jar要么是一组可发布的 dist 文件加后端包本地开发仍然保留热更新和前后端分离的爽感不被统一构建拖累。这套方案对个人项目和团队项目都适用。对个人而言省去手动拷贝的重复劳动对团队而言CI 里只需要跑gradle build其它不用关心。2. 工程结构设计与方案选型2.1 用 Gradle 多模块管理前后端统一构建的前提是“一个仓库、一个 Gradle 工程”。我的建议是采用多模块结构后端一个模块前端一个模块根项目只做统筹。my-app/ ├── backend/ │ ├── build.gradle │ └── src/main/java/... ├── frontend/ │ ├── package.json │ ├── package-lock.json │ ├── vite.config.js │ └── src/... ├── build.gradle ├── settings.gradle └── gradle.properties根目录的 settings.gradle 里把两个模块加入进来rootProject.name my-app include backend include frontend这里有个细节frontend 模块其实不写 Java 代码只是一个“壳”用来承载 Node 插件和 npm 任务。这个壳不需要 java 插件用base插件就够了// frontend/build.gradle plugins { id base }为什么不用 java 插件因为前端模块没有源码需要编译成 class加 java 插件反而会让 Gradle 多跑一些没用的任务比如 javadoc、jar没什么好处。2.2 Node 插件选型com.github.node-gradle.node要在 Gradle 里跑 npm 命令本质上就两种方式一是用Exec任务直接执行本机 npm二是用 Node 插件。第一种看着简单但跨平台是个大坑Windows 上命令是npm.cmdLinux 上是npm写死一个就不行。另外它无法控制 Node 版本环境差异问题依然存在。我选的是社区最主流的com.github.node-gradle.node插件版本用 7.0.2。选它的理由有三点可以指定 Node 和 npm 版本并自动下载统一环境提供NpmTask等任务类型能直接接入 Gradle 的增量构建机制支持缓存npm 依赖和 Node 二进制都可以复用CI 里显著提速。插件挂到 frontend 模块后模块能识别的任务就多了npmInstall、npm_run_build等一系列 npm 相关任务但我们要做的是不直接用这些默认任务而是自定义一两个真正适合项目的任务原因后面细说。2.3 为什么不建议用 Shell 脚本串前端后端我和不少同事聊过他们说“用脚本构建不也挺好吗”。我承认脚本很灵活但它的缺陷恰恰是统一构建最想解决的脚本本身不可移植Windows 和 Linux 往往要维护两套脚本难以感知依赖变化不会自动做增量构建每次都是全量跑脚本跑失败之后中间状态很难恢复排查问题全靠日志。而 Gradle 自带的依赖关系、任务编排、增量构建这套机制天生就是为“多步骤构建”设计的。前端 npm install、npm run build本质就是两个“任务”放 Gradle 里就是两个 Task能够被精确控制执行时机和输入输出。这才是统一构建的核心价值。3. 配置落地让 Gradle 真正“构建”前端3.1 固定 Node 与 npm 版本并配置国内镜像在 frontend/build.gradle 里我这样配置 Node 插件plugins { id base id com.github.node-gradle.node version 7.0.2 } node { version 20.11.1 npmVersion 10.2.4 download true distBaseUrl https://mirrors.cloud.tencent.com/nodejs-release/ }download true表示如果本机找不到对应版本的 Node插件会自行下载。这里的distBaseUrl可以换成国内镜像地址否则第一次下载 Node 二进制会非常慢还容易超时。npm 的下载源也需要处理。我在 frontend 目录放了一个.npmrc文件registryhttps://mirrors.cloud.tencent.com/npm/用镜像源之后npm install从几十兆每秒的库拉包速度快得不是一点半点。注意.npmrc要提交到版本库团队成员和 CI 才能保持一致。这里有一个容易忽略的点Gradle wrapper 本身下载也受网络影响。第一次./gradlew会自动下载 Gradle 发行包我也遇到过卡住的情况。解决办法是在gradle/wrapper/gradle-wrapper.properties里把 distributionUrl 换成镜像地址distributionUrlhttps\://mirrors.cloud.tencent.com/gradle/gradle-8.7-bin.zip3.2 使用 npm ci 而不是 npm installnpm install 有一个槽点它会根据 package.json 的宽松版本范围自动更新依赖哪怕没有人为改版本也可能把某个依赖升级到新版本导致 lock 文件与实际安装不一致。为了避免这种不可控我建议统一使用npm ci。npm ci会严格按照 package-lock.json 安装依赖速度快结果可复现。如果 package.json 与 lock 文件不一致它直接报错反而能逼着团队把版本管理做好。在 Gradle 里我没有使用插件默认的npmInstall任务而是用NpmTask自定义一个npmCi任务import com.github.gradle.node.npm.task.NpmTask tasks.register(npmCi, NpmTask) { args [ci] inputs.files(package.json, package-lock.json) outputs.dir(node_modules) }声明了inputs和outputs后Gradle 只有在 package.json 或 package-lock.json 发生变化时才会重新执行 npm ci。否则它会认为node_modules还在直接跳过这个特性对 CI 提速非常友好。3.3 给前端构建任务绑定生命周期接下来定义真正的前端构建任务。我的前端脚本里有一个build命令比如 Vite 项目的build: vite build构建产物默认输出到dist也可以改到build/dist取决于你的 Vite 配置。在 frontend/build.gradle 里tasks.register(npmBuild, NpmTask) { dependsOn npmCi args [run, build] inputs.dir(src) inputs.file(package.json) inputs.file(package-lock.json) inputs.file(vite.config.js) outputs.dir(layout.projectDirectory.dir(dist)) }然后挂到 frontend 模块的 build 生命周期上tasks.named(build) { dependsOn npmBuild }这样当你在根目录执行gradle :frontend:build时Gradle 会自动完成npm ci - npm run build。“build 生命周期”在这里是模块级概念它表示“这个模块要产出它的构建产物”前端模块的产物就是 dist 目录。3.4 前端产物合并进 Spring Boot Jar前后端最终要么分别部署要么合并部署。如果是合并部署最简单的位置就是 Spring Boot 的静态资源目录。Spring Boot 默认扫描classpath:/static下的文件作为静态资源所以我们要做的是让后端的资源处理阶段依赖前端构建并把 frontend/dist 目录内容复制进后端构建产物的 static 目录。在 backend/build.gradle 里// backend/build.gradle plugins { id java id org.springframework.boot version 3.2.4 } def frontendDist layout.projectDirectory.dir(../frontend/dist) sourceSets { main { resources { srcDir(frontendDist) { include **/* } } } } processResources { dependsOn :frontend:npmBuild }processResources是 Spring Boot 打包 jar 时的资源处理任务让它依赖:frontend:npmBuild再通过 sourceSets 把前端 dist 目录作为额外资源目录这样执行gradle :backend:bootJar时会把 dist 下的index.html、assets/**全部打进 jar 的static目录。这里要注意一点dist 目录必须存在于资源路径中否则构建不会失败但运行 jar 后访问页面会 404。为了稳妥我在 processResources 任务里加一个校验processResources { dependsOn :frontend:npmBuild doFirst { if (!frontendDist.getAsFile().get().exists()) { throw new GradleException(前端产物目录不存在请先执行 frontend:npmBuild) } } }一个典型的输出结果是my-app.jar └── BOOT-INF/classes/ ├── static/ │ ├── index.html │ └── assets/ │ ├── index-xxxx.js │ └── index-xxxx.css └── application.properties部署时直接java -jar my-app.jar浏览器访问http://localhost:8080/就能看到前端页面了。3.5 如果前后端要分开部署呢有些场景下前端需要交给 Nginx 托管后端只提供 API。这种情况不需要把 dist 打进 jar只需要用 Gradle 把前端模块独立产出 dist 目录即可。我在 CI 里通常会把frontend/dist归档为一个 artifact丢给发布系统去处理。对应到配置就是只保留 frontend 模块的 build 任务backend 模块不依赖前端产物。Gradle 多模块的好处就在这里模块之间解耦构建时通过依赖关系任意组合。一种部署方式是用 bootJar 合并一种是前后端分离 Nginx 托管两者可以在同一个工程里共存只是 CI 触发不同的任务组合。4. 本地开发模式热更新与统一构建不冲突4.1 本地开发继续用前后端分离统一构建主要用于 CI 和生产环境本地开发我强烈建议继续保持前端 dev server 与后端独立启动。为什么因为前端热更新体验无法被替代改一行代码浏览器立即刷新的效率远比重新打 jar 再访问要高得多。本地开发时我习惯这样起服务后端在 IDEA 里直接跑 Spring Boot 的 main 方法端口 8080前端在 frontend 目录执行npm run devVite 默认开 5173 端口。也就是说本地开发几乎不依赖 Gradle 里的前端构建任务这也是合理的。Gradle 管的是“可复现的构建”而本地开发是“最快的反馈循环”两件事不要混在一起。4.2 Vite 代理配置解决跨域和 token 传递前端 dev server 跑在 5173后端接口在 8080跨域问题首当其冲。我一般不在后端开 CORS 全局配置而是把跨域挡在“开发代理”这一层生产环境前后端同源访问看代码也更干净。在 vite.config.jsimport { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这里有一点容易被坑如果接口请求头里带了Authorization需要确认代理没有把请求头丢掉。changeOrigin: true只改 Host不影响 Authorization正常使用没问题。登录、token 这类逻辑我都是统一在 axios 拦截器里处理请求前加 token响应 401 时跳登录。因为开发时走了代理、生产时同源前端代码里不需要关心环境差异这算是前后端分离项目里值得坚持的一个原则。4.3 后端静态资源访问是否影响热更新很多团队做合并部署后会尝试直接访问http://localhost:8080看页面。Spring Boot 会把内置的静态资源映射到根路径所以你确实能通过 8080 访问到前端打包产物。但我不建议把这当作日常开发方式因为这样看到的是构建产物不是热更新状态。如果你特别希望调试时前后端合在一起跑也有一个折中方案后端启动时把前端 dev server 的地址配到静态资源映射但我实际用下来意义不大还会引入额外的配置复杂度。我的建议很简单本地开发分开跑统一构建留给可复现场景。5. 生产构建里那些防不胜防的坑5.1 下载慢、超时镜像配置要一次性到位网络上常见的错误信息有Could not install Gradle distribution from https://services.gradle.org/distributions/gradle-8.7-bin.zip这是 wrapper 下载 Gradle 发行包失败。解决办法我在前面提过把 distributionUrl 换成腾讯镜像。Node 二进制下载同理配置distBaseUrl。npm 依赖安装失败就配.npmrc的 registry。这三层镜像建议一次性配好不要等构建挂掉再去查。这里我踩过的坑是镜像地址本身也可能有延迟差异不同镜像服务对某些版本的同步速度不一样。如果某个依赖在镜像源里找不到可以先让本机 npm 走官方源确认版本号再回到镜像源重试。一般一个团队固定用一个镜像源就好换来换去反而会让 lock 文件产生无谓的差异。5.2 前端页面刷新 404可能是路由模式问题前端用 Vue Router 的 history 模式时路由路径是/home、/detail/123这种真实地址。用户刷新时浏览器会直接请求/home而后端静态资源映射里并没有这个路径Spring Boot 默认会返回 404。解决的方式是加一个 SPA fallback非 API 且非静态资源的路径全部转发到index.html。我用一个简单的 Controller 实现Controller public class SpaForwardController { GetMapping(value {/, /{path:[^\\.]*}, /**/{path:[^\\.]*}}) public String forward() { return forward:/index.html; } }这个正则的核心是[^\\.]*即路径中不包含点号。这样/api/**不会被兜底静态资源assets/index-xxx.js也不会被兜底只有像/home、/detail/123这类前端路由才会转发到index.html。如果你的前端还涉及一些特殊路径比如播放 m3u8 视频流的接口注意不要被这个 Controller 误伤。m3u8 这类带点号的路径不会匹配上面的正则所以相对安全。5.3 Vite 的 base 路径问题打包后布局异常、资源 404在很多项目里其实是 base 路径配置的问题。Vite 默认 base 是/如果你的应用部署在域名根路径就没问题但部署在子路径比如https://example.com/admin/下资源路径就全乱套了。如果采用 Spring Boot jar 合并部署建议保持 base 为/应用部署在根路径最简单。如果非得部署在子路径就需要设置export default defineConfig({ base: /admin/ })同时后端如果有转发逻辑也可能需要配合调整。我的建议是能部署在根路径就部署根路径子路径会让很多相对路径问题变得复杂。5.4 clean 与缓存策略Gradle 的clean任务会清掉build目录。如果前端产物目录在build下面那 clean 之后全量重建很正常。但前端node_modules如果也在工程目录下clean 不要把它清掉否则每次构建都要重新下载依赖CI 耗时会非常难看。frontend 模块我建议把 node_modules 目录设置为 Gradle 的构建缓存目录同时不要放进 clean 范围。另外CI 上可以把整个~/.gradle缓存目录和node_modules目录缓存起来。Gradle 构建缓存命中之后一次前端构建从“重新 npm ci 重新 vite build”变成“直接跳过依赖安装只做 vite 增量构建”速度可以快 3-5 倍。5.5 常见问题速查表为了更直观我把平时最容易遇到的情况整理成一张表问题现象可能原因解决办法Gradle wrapper 下载失败distributionUrl 直连官方库超时换成腾讯镜像地址Node 二进制下载失败插件没有配置 distBaseUrl配置为https://mirrors.cloud.tencent.com/nodejs-release/npm install 太慢registry 走官方源配置.npmrc使用腾讯镜像前端构建每次全量跑没有声明 inputs/outputs给 NpmTask 配置 inputs 和 outputs前端刷新页面 404history 路由没有 fallback增加 SPA 转发 Controllerjar 包内没有前端页面processResources 未依赖 frontend 构建加dependsOn :frontend:npmBuild打包后资源 404Vite base 路径不对根据部署路径设置 base6. 扩展到 CI/CD 流水线的建议6.1 一套配置既跑合并部署也跑分离部署我之前说过统一构建可以拆成两条链路一个是gradle :backend:bootJar产出可运行 jar适合合并部署一个是gradle :frontend:build产出 dist适合 Nginx 托管。这两条链路在 CI 里可以按需触发。在 Jenkins 或 GitLab CI 里我的做法是写两个任务构建发布 jar 的任务执行 bootJar 并归档只构建前端资源的话执行:frontend:build并把 dist 目录归档。这样开发和运维的交接物非常明确不会出现一组脚本前后矛盾的问题。6.2 缓存与增量构建是 CI 提速的关键CI 机器上最容易被低估的是缓存配置。我建议至少缓存三样东西~/.gradle/cachesGradle 依赖和构建缓存~/.gradle/wrapper/distsGradle 发行包frontend/node_modules前端依赖。一旦这些缓存就位第二次构建的耗时能明显降下来。还有一点Gradle daemon 默认会常驻内存CI 容器如果是一次性环境建议设置org.gradle.daemonfalse和org.gradle.paralleltrue避免 daemon 资源泄漏同时利用多模块并行构建。6.3 版本关联前端包版本和后端 jar 版本在同一个仓库里天然同步。我在 CI 里会统一设置构建号比如${BUILD_NUMBER}然后让 Gradle 用这个值作为版本号version System.getenv(BUILD_NUMBER) ?: 1.0.0这样每次构建的 jar 都对应一个唯一的版本前端资源也在同一代码提交下构建排查线上问题时能快速定位是哪一次构建、哪一批代码。最后再分享一个小技巧如果你用这套方案跑了一段时间会发现真正省时间的不是那几条 Gradle 配置而是把“构建边界”想清楚了前端模块只管产 dist后端模块只管产 jar合并动作放在资源处理阶段而不是靠人肉拷贝。边界清楚之后CI 流水线、本地开发、甚至以后想引入 Docker 镜像构建都只需要在同一个工程上做加法。我把这个配置沉淀成了一个内部脚手架每次新项目直接复制工程结构就能跑通。具体到你自己的项目我建议先别急着上这套完整方案而是先把 frontend 模块搭起来让 Gradle 能稳定跑出 dist再逐步把产物合并和后端模块接上。前端构建链路先通了后面都是增量优化。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →