失礼しました...
はじめに
オークファンの大きめのデータを担当するバックエンドシステムでは Kotlin + Spring Boot + Gradle の構成がよく採用されています。少し前まではこの構成で作成されるのは、バッチプログラムや Web API がほとんどでしたが、最近さらに Vue.js を追加して UI を持った Single Page Application (SPA) の Web アプリも作成するようになってきました。
Spring Boot のプロジェクトを Spring Initializr で作成すると、Gradle Wrapper が付属しています。これにより、開発環境では JDK のみ用意するだけでプロジェクトのテストやビルドなどをお手軽に実行できていました。
ところが、JavaScript フレームワークである Vue.js がプロジェクトに追加されたことにより、別途 Node.js 環境を用意しなければいけなくなってしまい、これが結構面倒なうえに、プロジェクトメンバーに開発環境を構築してもらう際にトラブルが発生することが少なくありませんでした。
このような場合には Docker にご登場いただくのが一般的ですが、ここではあえて Docker を使わない縛りを課し、すでにプロジェクトに入っている Gradle での問題解決方法を開示していこうと思います。
個人的には Maven や Gradle (Ant は〇ソ...) 支配下の Java や Kotlin でこれまで戦うことが多かったので、npm (yarn は使いやすいですね...) の支配する不慣れな Node.js に対して有利に戦えるといいなと思ったのが発端でした。
プロジェクトの前提は以下としました。
- 開発環境に必要なのは JDK のみ
- Node.js まわりで必要なものは Gradle Plugin for Node で調達
- 幅広い OS (ここでは macOS、Linux、Windows を想定) で開発可能
今回のサンプルプロジェクトの構築は以下の順序で進めていきます。
- Spring Boot プロジェクトの作成
- Spring Boot プロジェクトのサブプロジェクト化
- Vue.js サブプロジェクトの追加
- Spring Boot と Vue.js のサブプロジェクト間の連携
- プロジェクトのビルドと実行
サンプルでは Spring Boot と Vue.js を使用しますが、他のものであっても Gradle と Node.js のプロジェクトであれば今回の方法は適用可能なはずです。
コマンドは macOS や Linux の書式で記載しますが、パスの指定をスラッシュ (/
) からバックスラッシュ (\
) に変更すれば Windows でも実行可能です。
Spring Boot プロジェクトの作成
まず Spring Boot の雛形プロジェクトを Spring Initializr で生成します。
https://start.spring.io/ に Web ブラウザでアクセスし、以下のように選択します。(Group
や Artifact
などは適宜変更いただいて問題ありません。)
GENERATE
ボタンをクリックしてプロジェクトをダウンロードして展開します。
$ unzip spring-boot-vue-app.zip
プロジェクトのディレクトリ構成は以下のようになっています。
初期の Gradle の設定ファイル settings.gradle.kts
と build.gradle.kts
は以下の内容になっています。
rootProject.name = "spring-boot-vue-app"
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id("org.springframework.boot") version "2.5.7"
id("io.spring.dependency-management") version "1.0.11.RELEASE"
kotlin("jvm") version "1.5.31"
kotlin("plugin.spring") version "1.5.31"
}
group = "io.aucfan.sample"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_11
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs = listOf("-Xjsr305=strict")
jvmTarget = "11"
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
雛形プロジェクトはフラットに 1 つのプロジェクトのみで構成されていますが、これを以下のサブプロジェクトに分割していきます。
spring-boot-vue-app
├── web-flux-server (Spring Boot サブプロジェクト)
│ ├── build.gradle.kts
│ └── src
├── web-vue2-ui (Vue.js サブプロジェクト)
│ └── build.gradle.kts
├── build.gradle.kts
└── settings.gradle.kts
Spring Boot プロジェクトのサブプロジェクト化
初期の Spring Boot プロジェクトを web-flux-server
サブプロジェクトに移動します。以下のように web-flux-server
ディレクトリを作成し、src
ディレクトリを作成したディレクトリ下に移動します。(設定ファイルは YAML 形式で記述したいので、こっそり application.properties
を application.yml
に変更しています。)
settings.gradle.kts
の末尾にサブプロジェクトの情報を追記します。
rootProject.name = "spring-boot-vue-app"
include(
"web-flux-server",
)
プロジェクト直下の build.gradle.kts
を以下の内容と、サブプロジェクト下の web-flux-server/build.gradle.kts
に分割します。変更の概要は次のとおりです。
-
org.springframework.boot
とio.spring.dependency-management
のプラグインの行の末尾にapply false
を追記 -
group
とversion
の定義をallprojects
内に移動 -
dependencies
以降をsubprojects
内に移動 -
java.sourceCompatibility
行をsubprojects
内に移動 -
repositories
をsubprojects
内にもコピー -
subprojects
内で Kotlin 関連のプラグインをapply
-
web-flux-server
ディレクトリ内にサブプロジェクト用のbuild.gradle.kts
を作成 - 親プロジェクト
build.gradle.kts
のsubprojects
から Spring 関連のdependencies
とtasks
をweb-flux-server/build.gradle.kts
へ移動 -
web-flux-server/build.gradle.kts
で Spring 関連のプラグインをapply
-
web-flux-server/build.gradle.kts
に WebFlux や開発用、デプロイ用のdependencies
とtasks
を追加
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
// apply false を付加してデフォルトでは術式を発動させないようにする
id("org.springframework.boot") version "2.5.7" apply false
id("io.spring.dependency-management") version "1.0.11.RELEASE" apply false
kotlin("jvm") version "1.5.31"
kotlin("plugin.spring") version "1.5.31"
}
allprojects {
group = "io.aucfan.sample"
version = "0.0.1-SNAPSHOT"
}
repositories {
mavenCentral()
}
// サブプロジェクト共通設定
subprojects {
// Kotlin 関連のプラグインを発動させる
apply(plugin = "kotlin")
apply(plugin = "org.jetbrains.kotlin.plugin.spring")
java.sourceCompatibility = JavaVersion.VERSION_11
repositories {
mavenCentral()
}
dependencies {
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
}
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs = listOf("-Xjsr305=strict")
jvmTarget = "11"
}
}
tasks.withType<Jar> {
// JAR ファイル名の基本部分が <プロジェクト名>-<サブプロジェクト名> となるように設定
archiveBaseName.set(listOf(rootProject.name, project.name).joinToString("-"))
}
}
// Spring 関連のプラグインを発動させる
apply(plugin = "org.springframework.boot")
apply(plugin = "io.spring.dependency-management")
dependencies {
// サブプロジェクトでは developmentOnly がそのままでは呼び出せないので強制召喚
val developmentOnly = configurations.getByName("developmentOnly")
// WebFlux に必要
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
// 開発ツール
developmentOnly("org.springframework.boot:spring-boot-devtools")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
tasks.withType<Test> {
useJUnitPlatform()
}
// JAR に起動スクリプトを埋め込んで単体で実行可能にする
tasks.withType<org.springframework.boot.gradle.tasks.bundling.BootJar> {
launchScript()
}
Vue.js サブプロジェクトの追加
Spring Boot プロジェクトをサブプロジェクトに移動できたので、同様に Vue.js サブプロジェクトも追加していきます。
本来であればこちらは Node.js の領域なのですが、Gradle 領域を展開していきます。
Node.js まわりを Gradle で管理するために Gradle Plugin for Node を導入します。
以下のように web-vue2-ui
ディレクトリを作成し、サブプロジェクト用の build.gradle.kts
を作成します。
settings.gradle.kts
に web-vue2-ui
サブプロジェクトを追加します。
rootProject.name = "spring-boot-vue-app"
include(
"web-flux-server",
"web-vue2-ui",
)
親プロジェクト直下の build.gradle.kts
に Gradle Plugin for Node を apply false
で追加します。
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id("org.springframework.boot") version "2.5.7" apply false
id("io.spring.dependency-management") version "1.0.11.RELEASE" apply false
// Gradle Plugin for Node を追加 (デフォルトでは発動させない)
id("com.github.node-gradle.node") version "3.1.1" apply false
kotlin("jvm") version "1.5.31"
kotlin("plugin.spring") version "1.5.31"
}
// (後略)
Vue.js サブプロジェクト用の web-vue2-ui/build.gradle.kts
に以下を記述します。ここでは、web-vue2-ui/.cache/
ディレクトリ下に Node.js、npm、yarn がダウンロードされて配置されるように設定しています。
import com.github.gradle.node.NodeExtension
import com.github.gradle.node.npm.proxy.ProxySettings
// Gradle Plugin for Node を発動させる
apply(plugin = "com.github.node-gradle.node")
// サブプロジェクトでは node が呼び出せないので強制召喚
configure<NodeExtension> {
download.set(true)
version.set("14.18.1")
npmVersion.set("6.14.15")
yarnVersion.set("1.22.17")
distBaseUrl.set("https://nodejs.org/dist")
npmInstallCommand.set("ci")
workDir.set(file("${project.projectDir}/.cache/nodejs"))
npmWorkDir.set(file("${project.projectDir}/.cache/npm"))
yarnWorkDir.set(file("${project.projectDir}/.cache/yarn"))
nodeProjectDir.set(file("${project.projectDir}"))
nodeProxySettings.set(ProxySettings.SMART)
}
Node.js、npm、yarn の本体が .cache
ディレクトリに取得されるように Gradle Plugin for Node 経由で yarn コマンドを素振りしておきます。
$ ./gradlew :web-vue2-ui:yarn
以下のように nodejs
、npm
、yarn
ディレクトリが設定したとおりに作成され、Node.js まわりを閉じ込めることに成功しました。
これで yarn
コマンドが呼び出せるようになったので、Vue.js プロジェクトを作成していきます。今回は Vue CLI を使用して Vue.js 2 のシンプルなプロジェクトを作成してみます。一般的には Vue CLI は global
にインストールすることがほとんどだと思いますが、ここでは使い捨てのプロジェクト内にインストールします。
以下のように web-vue2-ui
ディレクトリに入って、yarn
コマンドで使い捨てプロジェクトを作成します。
(Windows の場合は yarn
のディレクトリが少し異なり、.cache\yarn\yarn-v1.22.17\yarn
となるようです。)
$ cd web-vue2-ui
$ .cache/yarn/yarn-v1.22.17/bin/yarn init
yarn init v1.22.17
question name (web-vue2-ui): spring-boot-vue-app-ui
question version (1.0.0): 0.0.1
question description: Spring Boot + Vue application
question entry point (index.js):
question repository url:
question author:
question license (MIT):
question private:
success Saved package.json
作成された使い捨てプロジェクトに Vue CLI を追加します。前述のとおり、ふつうは yarn global add @vue/cli
とするのですが、Vue CLI が Gradle 領域の外に逃げてしまうので、ここでは global
を指定していません。
$ .cache/yarn/yarn-v1.22.17/bin/yarn add @vue/cli
一時的に vue
コマンドが使用できるようになったので、以下のようにシンプルな Vue.js 2 のプロジェクトを作成します。
$ ./node_modules/.bin/vue create spring-boot-vue-app-ui
Vue CLI v4.5.15
? Please pick a preset: Default ([Vue 2] babel, eslint)
? Pick the package manager to use when installing dependencies: Yarn
web-vue2-ui/spring-boot-vue-app-ui
下にプロジェクトが生成されたので、1 階層上に移動させます。その際に不必要になった一時的な使い捨てプロジェクト用の node_modules
、package.json
、yarn.lock
は削除してしまいます。また、作成された Vue.js プロジェクト内に Git 用の隠しディレクトリ .git
が作成されてしまうので、こちらは移動せずに削除し、.gitignore
ファイルのみ移動しています。
$ rm -rf node_modules package.json yarn.lock
$ mv spring-boot-vue-app-ui/* .
$ mv spring-boot-vue-app-ui/.gitignore .
$ rm -rf spring-boot-vue-app-ui
.cache
ディレクトリを Git 管理対象外にするために、web-vue2-ui/.gitignore
の方に定義を追加しておきます。
.DS_Store
node_modules
/dist
# 以下を追記
/.cache
# (後略)
最終的に web-vue2-ui
サブプロジェクトのディレクトリ構成は以下のようになります。 (作成した Vue.js プロジェクトによって構成は変わる場合があります。)
vue
コマンドで生成された package.json
の内容は以下です。
{
"name": "spring-boot-vue-app-ui",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"core-js": "^3.6.5",
"vue": "^2.6.11"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^6.2.2",
"vue-template-compiler": "^2.6.11"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "babel-eslint"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}
開発用のサーバーを起動して動作を確認しておきます。
$ .cache/yarn/yarn-v1.22.17/bin/yarn serve
Web ブラウザで http://localhost:8080/ にアクセスすると以下の画面が表示されます。
この後で起動する Spring Boot の Web サーバーもデフォルトでは 8080
ポートを使用するので、衝突を避けるために開発サーバを Ctrl + C
で停止しておきます。(./gradlew :web-vue2-ui:yarn_serve
でも開発サーバーは起動できますが、Ctrl + C
でプロセスが停止できないのでここでは使用していません。)
Spring Boot と Vue.js のサブプロジェクト間の連携
親プロジェクトのディレクトリに戻り、Git リポジトリの初期化を実行しておきます。
$ cd ..
$ git init
web-vue2-ui/package.json
を以下のように変更します。
{
"name": "spring-boot-vue-app-ui",
"version": "0.0.1",
"private": true,
"scripts": {
"serve": "vue-cli-service serve --port 8081 --watch --mode development",
"build": "vue-cli-service build --dest ../web-flux-server/src/main/resources/static/",
"lint": "vue-cli-service lint"
},
(中略)
}
vue-cli-service serve --port 8081 --watch --mode development
の部分で Vue.js の開発サーバーを設定しています。--port 8081
でポート番号を 8080
から 8081
に変更して帳を下ろしています。これは前述のとおり Spring Boot の WebFlux サーバーもデフォルトでは 8080
ポートを使用するので、衝突を避けるためです。--watch
でソースファイルの変更を監視するようにし、--mode development
で開発環境であることを設定しています。
vue-cli-service build --dest ../web-flux-server/src/main/resources/static/
の部分では Vue.js のビルドファイルの出力先を、デフォルトの dist
から Spring Boot サブプロジェクトのリソースディレクトリに変更しています。
また、親プロジェクトのバージョンに合わせて、version
を 0.0.1
に変更しています。
親プロジェクトの .gitignore
に以下を追加して、Vue.js ビルドファイルを Git 管理対象外にしておきます。
# (前略)
# Vue.js build files
/web-flux-server/src/main/resources/static/
Gradle Plugin for Node 経由で Vue.js サブプロジェクトをビルドしてみます。
$ ./gradlew :web-vue2-ui:yarn_build
Spring Boot サブプロジェクト側の web-flux-server/src/main/resources/static/
ディレクトリ下に Web ページ用のファイルが生成されます。
Vue.js サブプロジェクトがビルドできたので、Gradle タスク間の依存関係を設定します。
まず、yarn build
の前に yarn install
が実行されるようにします。(毎回 yarn install
を実行する必要はないのですが、すでに必要なパッケージがインストールされている場合は何もしないので、ここでは気にせずに設定してしまいます。)
import com.github.gradle.node.NodeExtension
import com.github.gradle.node.npm.proxy.ProxySettings
apply(plugin = "com.github.node-gradle.node")
// サブプロジェクトでは node が呼び出せないので強制召喚
configure<NodeExtension> {
download.set(true)
version.set("14.18.1")
npmVersion.set("6.14.15")
yarnVersion.set("1.22.17")
distBaseUrl.set("https://nodejs.org/dist")
npmInstallCommand.set("ci")
workDir.set(file("${project.projectDir}/.cache/nodejs"))
npmWorkDir.set(file("${project.projectDir}/.cache/npm"))
yarnWorkDir.set(file("${project.projectDir}/.cache/yarn"))
nodeProjectDir.set(file("${project.projectDir}"))
nodeProxySettings.set(ProxySettings.SMART)
}
// 以下を追記
// yarn build 前に yarn install を実行する (Gradle Plugin for Node 経由の実行なので _ を付加)
tasks.getByName("yarn_build") {
dependsOn("yarn_install")
}
次に、Spring Boot サブプロジェクトでリソースが処理される前に Vue.js サブプロジェクトをビルドするようにします。
import org.springframework.boot.gradle.tasks.bundling.BootJar
apply(plugin = "org.springframework.boot")
apply(plugin = "io.spring.dependency-management")
dependencies {
// サブプロジェクトでは developmentOnly がそのままでは呼び出せないので強制召喚
val developmentOnly = configurations.getByName("developmentOnly")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
developmentOnly("org.springframework.boot:spring-boot-devtools")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
tasks.withType<Test> {
useJUnitPlatform()
}
tasks.withType<BootJar> {
launchScript()
}
// 以下を追記
// リソース処理の前に Vue.js サブプロジェクトのビルドを実行
tasks.withType<ProcessResources> {
dependsOn(":web-vue2-ui:yarn_build")
}
これだけでは Spring Boot サブプロジェクト内のリソースに配置されたファイルを公開できないので、IndexHandler.kt
と IndexRouterConfiguration.kt
ファイルを以下のように作成します。
IndexHandler.kt
には以下の内容を記述します。
package io.aucfan.sample.spring.boot.vue
import org.springframework.beans.factory.annotation.Value
import org.springframework.core.io.Resource
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.server.ServerRequest
import org.springframework.web.reactive.function.server.ServerResponse
import org.springframework.web.reactive.function.server.bodyValueAndAwait
@Component
class IndexHandler {
@Value("classpath:/static/index.html")
private lateinit var indexHtml: Resource
suspend fun index(request: ServerRequest): ServerResponse =
ServerResponse.ok()
.contentType(MediaType.TEXT_HTML)
.bodyValueAndAwait(indexHtml)
}
IndexRouterConfiguration.kt
には以下を記述します。
package io.aucfan.sample.spring.boot.vue
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.io.ClassPathResource
import org.springframework.web.reactive.function.server.RouterFunctions
import org.springframework.web.reactive.function.server.coRouter
@Configuration
class IndexRouterConfiguration {
@Bean
fun indexRouter(indexHandler: IndexHandler) = coRouter {
GET("/", indexHandler::index)
}
fun staticResourceRouter() = RouterFunctions.resources("/**", ClassPathResource("static/"))
}
プロジェクトのビルドと実行
以上で準備ができたので、親プロジェクトのディレクトリで (テストを省略して) ビルドを実行します。
$ ./gradlew clean build -x test
Spring Boot サブプロジェクトの web-flux-server
の build/libs/
ディレクトリ下に spring-boot-vue-app-web-flux-server-0.0.1-SNAPSHOT.jar
が具現化されます。
spring-boot-vue-app-web-flux-server-0.0.1-SNAPSHOT.jar
を JVM に流し込んで実行してみます。
$ cd web-flux-server/build/libs/
$ java -jar spring-boot-vue-app-web-flux-server-0.0.1-SNAPSHOT.jar
Web ブラウザで http://localhost:8080/ にアクセスすると以下の画面が再度表示されます。(この時点では前述の Vue.js の開発用サーバーは http://localhost:8081/ でのアクセスに変更されています。)
ここまでのプロジェクト構築では Spring Boot の WebFlux サーバーに何の Web API も実装されていません。ここから Web API を Kotlin で実装し、Vue.js から axios で利用するように開発を進めていくことになります。
おわりに
今回ご紹介したプロジェクトで作成された Web アプリをデプロイする最もシンプルな方法は以下のような手順になります。
- 開発環境に JDK を導入
- プロジェクトを
git clone
- プロジェクトのディレクトリで
./gradlew clean build -x test
- 作成された JAR を稼働環境に配置
- 稼働環境に JRE を導入
- JAR を実行 (サービス化も可能)
実際のプロジェクトではさらに、JAR ファイルの外側から読み込む環境依存の設定ファイルの配置や、Docker イメージにして自動デプロイなどもろもろ追加されていきます。
このように Node.js 関連の儀式を明示的に行うことなく、Gradle 領域内にうまく閉じ込めることができたので、今までどおり心穏やかにデプロイ作業を進めることができています。(とはいえ開発時は Node.js 領域内に入って戦うことになるのですが...)
他にも以下のようなメリットがあるので、今後の SPA プロジェクトでも適している場面があれば採用していこうと思います。
- 開発環境構築時も同様に Node.js 環境を別途用意する必要がない
- Vue.js 2 から Vue.js 3 や Nuxt.js、React、Next.js などに変更する場合も別領域サブプロジェクトを展開して対応可能
- Node.js から見ると領域は外界と隔絶されているので、UI のサブプロジェクト内では異なる Node.js のバージョンを採用可能
最後に...
劇場版「呪術廻戦 0」
12 月 24 日公開です。ぜひ。