失礼しました...
はじめに
オークファンの大きめのデータを担当するバックエンドシステムでは 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 は以下の内容になっています。
settings.gradle.kts
rootProject.name = "spring-boot-vue-app"
build.gradle.kts
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 の末尾にサブプロジェクトの情報を追記します。
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 を追加
build.gradle.kts
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("-"))
}
}
web-flux-server/build.gradle.kts
// 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 サブプロジェクトを追加します。
settings.gradle.kts
rootProject.name = "spring-boot-vue-app"
include(
"web-flux-server",
"web-vue2-ui",
)
親プロジェクト直下の build.gradle.kts に Gradle Plugin for Node を apply false で追加します。
build.gradle.kts
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 がダウンロードされて配置されるように設定しています。
web-vue2-ui/build.gradle.kts
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 の方に定義を追加しておきます。
web-vue2-ui/.gitignore
.DS_Store
node_modules
/dist
# 以下を追記
/.cache
# (後略)
最終的に web-vue2-ui サブプロジェクトのディレクトリ構成は以下のようになります。 (作成した Vue.js プロジェクトによって構成は変わる場合があります。)
vue コマンドで生成された package.json の内容は以下です。
web-vue2-ui/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 を以下のように変更します。
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 を実行する必要はないのですが、すでに必要なパッケージがインストールされている場合は何もしないので、ここでは気にせずに設定してしまいます。)
web-vue2-ui/build.gradle.kts
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 サブプロジェクトをビルドするようにします。
web-flux-server/build.gradle.kts
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<ProcessResourcevvs> {
dependsOn(":web-vue2-ui:yarn_build")
}
これだけでは Spring Boot サブプロジェクト内のリソースに配置されたファイルを公開できないので、IndexHandler.kt と IndexRouterConfiguration.kt ファイルを以下のように作成します。
IndexHandler.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 には以下を記述します。
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 日公開です。ぜひ。
↧