← 開発ノート

Gradle で複数の Vue アプリを Spring Boot JAR にまとめる

この記事の目次

複数の Vue アプリを Spring Boot JAR に含めるには、各アプリに専用の npm ci とビルドタスクを用意し、生成ファイルを build/ に集めてから bootJar に追加します。あるアプリのインストールタスクを他のアプリで共用したり、生成したバンドルを src/main/resources に直接書き込んだりしないでください。

この実行可能な例では、/project1/index.html と /project2/index.html で二つのアプリを配信します。再現環境は JDK17.0.8.1、Gradle8.4、Node24.13.0/npm11.6.2、Boot3.5.7、Vue3.5.44、Vite8.3.5 です。再現用に固定したバージョンであり、現在の本番依存関係の推奨ではありません。Vite の実行環境要件と、選んだ Boot リリースの要件をそれぞれ確認してください。

処理の流れ

ビルドしたリソースを JAR に格納する流れ

各 Vue アプリがそれぞれ成果物をビルドします。Gradle Sync タスクがその出力だけを集め、bootJar が別々の静的パスに配置します。

アプリごとのタスクとパッケージ化

空のプロジェクトに次のファイルを保存します。各アプリはそれぞれ package.json、ロックファイル、node_modules、dist を持ちます。prepare.sh は共通の HTML/JS を二番目のアプリにコピーし、両方のロックファイルを作成します。

settings.gradle

rootProject.name = 'multi-frontend'

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.5.7'
}
group = 'example'
version = '1.0.0'
repositories { mavenCentral() }
dependencies {
    implementation platform('org.springframework.boot:spring-boot-dependencies:3.5.7')
    implementation 'org.springframework.boot:spring-boot-starter-web'
}

def apps = ['project1', 'project2']
def npmCommand = System.getProperty('os.name').toLowerCase().contains('windows') ? 'npm.cmd' : 'npm'
def frontendBuilds = apps.collect { app ->
    def frontend = layout.projectDirectory.dir("frontend/${app}")
    def install = tasks.register("${app}NpmCi", Exec) {
        workingDir frontend
        commandLine npmCommand, 'ci', '--no-audit', '--no-fund'
        inputs.files(frontend.file('package.json'), frontend.file('package-lock.json'))
        outputs.dir(frontend.dir('node_modules'))
    }
    tasks.register("${app}Build", Exec) {
        dependsOn install
        workingDir frontend
        commandLine npmCommand, 'run', 'build'
        inputs.files(fileTree(frontend) {
            exclude 'node_modules/**', 'dist/**'
        })
        outputs.dir(frontend.dir('dist'))
    }
}
def collectFrontend = tasks.register('collectFrontend', Sync) {
    dependsOn frontendBuilds
    into layout.buildDirectory.dir('frontend-resources')
    apps.each { app ->
        from("frontend/${app}/dist") { into "static/${app}" }
    }
}
tasks.named('bootJar') {
    dependsOn collectFrontend
    from(collectFrontend) { into 'BOOT-INF/classes' }
}

src/main/java/example/Application.java

package example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

frontend/project1/package.json

{
  "name": "project1",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "vite build --base=/project1/"
  },
  "dependencies": {
    "vue": "3.5.44"
  },
  "devDependencies": {
    "vite": "8.3.5"
  }
}

frontend/project2/package.json

{
  "name": "project2",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "vite build --base=/project2/"
  },
  "dependencies": {
    "vue": "3.5.44"
  },
  "devDependencies": {
    "vite": "8.3.5"
  }
}

frontend/project1/index.html

<!doctype html>
<html lang="en"><head><meta charset="UTF-8"><title>Vue build example</title></head>
<body><div id="app"></div><script type="module" src="/src/main.js"></script></body></html>

frontend/project1/src/main.js

import { createApp, h } from 'vue';
createApp({ render: () => h('h1', `Vue app at ${import.meta.env.BASE_URL}`) }).mount('#app');

prepare.sh

#!/bin/sh
set -eu
mkdir -p frontend/project2/src
cp frontend/project1/index.html frontend/project2/index.html
cp frontend/project1/src/main.js frontend/project2/src/main.js
for app in project1 project2; do
    (cd "frontend/$app" && npm install --package-lock-only --ignore-scripts --no-audit --no-fund)
done

この例を初めて準備するときに prepare.sh を実行し、生成された両方の package-lock.json をコミットします。それ以降の CI ビルドでは保存済みのロックファイルに対して npm ci を実行します。ビルドのたびに再生成しないでください。

ビルドと実行

PATH から Node/npm を利用できる状態で、プロジェクトのルートから実行します。

node --version
npm --version
sh prepare.sh
gradle --no-daemon clean bootJar
java -jar build/libs/multi-frontend-1.0.0.jar

既存の Wrapper ベースのプロジェクトでは、./gradlew から選択済みの Gradle バージョンを使います。起動後に http://localhost:8080/project1/index.html と http://localhost:8080/project2/index.html を開くと、それぞれ Vue app at /project1/ と Vue app at /project2/ が表示されます。

Vite の base オプションは各アプリのアセット URL を設定します。サーバー側の SPA ルーティングは設定しません。この例は明示的な index.html URL を使います。history-router の URL に直接移動する場合や別のコンテキストパスを使う場合には、追加設定が必要です。

実際の JAR 内のパスを確認する

BOOT-INF/classes/static/project1/index.html
BOOT-INF/classes/static/project1/assets/...
BOOT-INF/classes/static/project2/index.html
BOOT-INF/classes/static/project2/assets/...

アプリごとのビルド後、collectFrontend が Sync を使って現在の出力だけを集めます。bootJar がそれを実行可能 JAR のクラスパス配下に追加します。Gradle のファイル処理と Boot のアーカイブ作成を参照してください。

この設定で両方のアプリをビルドしてパッケージ化し、実行中の JAR から HTML と JS を HTTP で配信できました。その後、アプリの public 内のファイルを削除し、clean なしで JAR を再ビルドして旧ファイルが残らないことを確認しました。両アプリのロックファイルも個別に確認しました。

既存のプロジェクトに適用する

  • apps リストをディレクトリに合わせ、各ロックファイルを用意します。
  • 生成される static/project1 と static/project2 の名前空間を手書きのリソースと分けます。
  • Vue SFC を使う場合は、そのアプリに合うプラグインと Vite 設定を追加します。この小さな例は Vue のレンダー関数を使います。
  • 対象は実行可能な bootJar であり、通常の jar ではありません。デプロイする成果物を確認します。

既存のアプリでは、パッケージ化とは別に Spring Boot の Java と Jakarta 移行の基準を確認してください。Batch ジョブも動く場合は Batch リポジトリ移行と再起動の確認に従います。JAR からフロントエンドのリソースを配信できても、永続化されたジョブデータの検証にはなりません。

リリースに基づく自動化は GitHub のリリースとアセットの選択、数値比較器の入力契約を参照してください。POSIX sh 関数ガイドでは準備スクリプトのインタープリター選択を扱います。

コメント

GitHubで見る ↗