HerokuにSpringBoot(WebFlux)でホストするSPAをデプロイする

# 概要

Spring BootのWebFluxを利用してSPAをホストする方法をまとめてみました。
HerokuのみでSPAアプリをホストするときの参考になれば。

成果物はGitHubにあります。
https://github.com/seijikohara/springboot2-vue3-heroku

この記事の内容をベースにしているHerokuのアプリも作成してみました。
R2DBC / Flyway / Kotest / SpringMockk / Vue3 / TypeScirptで構築してHeroku（の無料プラン）で公開しています。

- https://web-dev-tools.herokuapp.com/ (無料プランなので起動に30秒ほどがかかることあります)
    - ソース: https://github.com/seijikohara/web-dev-tools


# 利用するフレームワーク・ライブラリ

## ビルドツール

- [Gradle](https://docs.gradle.org/current/userguide/userguide.html)
  - Kotlin DSL

### サーバサイド

- [Java 11](https://openjdk.java.net/projects/jdk/11/)
- [Kotlin](https://kotlinlang.org/)
- [Spring Boot 2](https://spring.io/projects/spring-boot)
    - [WebFlux](https://docs.spring.io/spring-framework/docs/current/reference/html/web-reactive.html#webflux)

### フロントエンド

VueやReactなどお好きなもを。
ここではVue3を利用しました。

# プロジェクトの作成

[Spring Initializr]
(https://start.spring.io/#!type=gradle-project&language=kotlin&platformVersion=2.3.5.RELEASE&packaging=jar&jvmVersion=11&groupId=com.example&artifactId=demo&name=demo&description=Demo%20project%20for%20Spring%20Boot&packageName=com.example.demo&dependencies=devtools,webflux,configuration-processor)でGradleプロジェクトを生成しましょう。

## `build.gradle.kts`の中身

生成された`build.gradle.kts`を変更していきます。
HerokuでのSlugビルド時にnpmを実行してVue3のSPAをビルドするために、[com.github.node-gradle.node
](https://plugins.gradle.org/plugin/com.github.node-gradle.node)プラグインを導入しました。

`herokuStage`タスクがSlugコンパイル時に実行される想定となります。
Kotlinのバージョンアップをしたりして、最終的に下記の様になりました。

```kotlin
import com.moowork.gradle.node.npm.NpmTask
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    id("org.springframework.boot") version "2.3.5.RELEASE"
    id("io.spring.dependency-management") version "1.0.10.RELEASE"
    id("com.github.node-gradle.node") version "2.2.4"
    kotlin("jvm") version "1.4.10"
    kotlin("plugin.spring") version "1.4.10"
}

group = "com.example"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_11

configurations {
    compileOnly {
        extendsFrom(configurations.annotationProcessor.get())
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
    implementation("org.springframework.boot:spring-boot-starter-webflux")
    developmentOnly("org.springframework.boot:spring-boot-devtools")
    annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
    testImplementation("org.springframework.boot:spring-boot-starter-test") {
        exclude(group = "org.junit.vintage", module = "junit-vintage-engine")
    }
}

tasks.withType<Test> {
    useJUnitPlatform()
}

tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs = listOf("-Xjsr305=strict")
        jvmTarget = "11"
    }
}


/**
 * Node.js
 */

node {
    version = "12.19.0"
    npmVersion = "6.14.8"
    download = true
}

val npmInstallDependencies by tasks.registering(NpmTask::class) {
    setArgs(listOf("install"))
    setExecOverrides(closureOf<ExecSpec> {
        setWorkingDir("./frontend")
    })
}

val npmRunBuild by tasks.registering(NpmTask::class) {
    // Before buildWeb can run, installDependencies must run
    dependsOn(npmInstallDependencies)

    setArgs(listOf("run", "build", "--", "--dest", "../src/main/resources/static"))
    setExecOverrides(closureOf<ExecSpec> {
        setWorkingDir("./frontend")
    })
}


/**
 * Heroku
 */

val herokuStageBuildFrontend by tasks.registering {
    group = "heroku"
    dependsOn(npmRunBuild)
    doLast {
        delete("frontend/node_modules") // npm run buildを実行して不要になったnode_modulesディレクトリを削除してSlugサイズを稼ぐ
    }
}

val herokuStageBuild by tasks.registering {
    group = "heroku"
    dependsOn("bootJar")
    mustRunAfter(herokuStageBuildFrontend) // フロントエンドのビルドファイルをboot-jarに含めるため先に実行させる
}

val herokuStage by tasks.registering {
    group = "heroku"
    dependsOn(herokuStageBuild)
    dependsOn(herokuStageBuildFrontend)
}
```

# Vue CLIでVue3のフロントエンドを作成

今度は[Vue CLI](https://cli.vuejs.org/)を使ってSPAのフロントエンドを作成します。
`Use history mode for router?`は`No`にしてください。

```console
$ vue create frontend

Vue CLI v4.5.8
? Please pick a preset: Manually select features
? Check the features needed for your project: Choose Vue version, Babel, TS, Router, Vuex, CSS Pre-processors, Linter
? Choose a version of Vue.js that you want to start the project with 3.x (Preview)
? Use class-style component syntax? No
? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? Yes
? Use history mode for router? (Requires proper server setup for index fallback in production) No
? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): Sass/SCSS (with dart-sass)
? Pick a linter / formatter config: Prettier
? Pick additional lint features: Lint on save
? Where do you prefer placing config for Babel, ESLint, etc.? In package.json
? Save this as a preset for future projects? No
```

## Gradleでフロントエンドをビルド

`./gradlew npmRunBuild`コマンドを実行してフロントエンドをビルドしてみます。
ビルドしたファイルは`src/main/resources/static`に出力されます。`.gitignore`でコミットの対象とならない様にしておきましょう。

# `/`を`/index.html`にフォワードする

このままでは、Webアプリを表示させるために`https://heroku-app.com/index.html`と言った様に、URLに`index.html`の指定が必要です。
`https://heroku-app.com/`で動作する様にしてみます。

- `application.properties`を作成する。

```properties:src/main/resources/application.properties
application.index-file=classpath:/static/index.html
```

- `application.properties`で指定した`index.html`の`Resource`を保持する`@Configuration`を作成する。

```kotlin:com/example/demo/config/ApplicationConfig.kt
@Configuration
@EnableConfigurationProperties(ApplicationProperties::class)
class ApplicationConfig

@ConstructorBinding
@ConfigurationProperties(prefix = "application")
data class ApplicationProperties(
    val indexFile: Resource,
)
```

- `index.html`へフォワードするためのハンドラーを用意する。

```kotlin:com.example.demo.handler.IndexHandler
@Component
class IndexHandler(
    @Autowired private val applicationProperties: ApplicationProperties 
) {

    fun getIndexHtml(request: ServerRequest): Mono<ServerResponse> {
        return ServerResponse.ok()
            .contentType(MediaType.TEXT_HTML)
            .bodyValue(applicationProperties.indexFile)
    }

}
```

- `/`の時に`IndexHandler`がレスポンスを返すルーティングを設定する。

```kotlin:com.example.demo.config.RoutingConfig
@Configuration
class RoutingConfig(
    @Autowired private val indexHandler: IndexHandler,
) {

    @Bean
    fun apiRouter() = router {
        accept(MediaType.ALL).nest {
            GET("/", indexHandler::getIndexHtml)
        }
    }

}
```

# ローカルでSpring Bootを起動してみる

Spring Bootを起動し、`src/main/resources/static`に出力されたファイルが参照できるか確認してみます。

```console:SpringBootを起動
$ ./gradlew bootRun
```

このタスクは完了せずにずっと動きます。
ログに`Netty started on port(s): 8080`が出てきたら、SpringBootの起動は完了しているのでブラウザで http://localhost:8080 へアクセスしてみます。
Vue CLIで作成したWelcomeページが表示されていれば成功です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59517/1bfa908f-cb30-0833-a321-6df47edcc810.png)

# Herokuにデプロイするための設定ファイルを作成

Web Dynoで実行するコマンドをProcfileに記述します。

```text:Procfile
web: java $JAVA_OPTS -jar build/libs/*.jar --server.port=$PORT com.example.demo.DemoApplication
```

次に、Herokuの設定情報である`app.json`を作成します。
__`GRADLE_TASK`の項目が重要で、Slugコンパイル時に実行するGradleのタスクを設定しています。__
__GradleのBuildpackを利用した場合、SpringBootの使用を検知すると、デフォルトでは`build -x test`が実行されます。__

```json:app.json
{
  "name": "springboot2-vue3-heroku",
  "description": "Demo springboot2-vue3-heroku",
  "repository": "https://github.com/seijikohara/springboot2-vue3-heroku",
  "env": {
    "GRADLE_TASK": "herokuStage"
  },
  "addons": [
    {
      "plan": "papertrail"
    }
  ],
  "stack": "heroku-20",
  "buildpacks": [
    {
      "url": "heroku/gradle"
    }
  ]
}
```

今回はJava11を利用したいため、`system.properties`を作成し、Herokuが利用するJavaのバージョンを指定します。

```properties:system.properties
java.runtime.version=11
```

# Herokuでアプリを起動する

`app.json`を用意したため、Heroku Buttonで簡単にデプロイできます。
実際にHerokuにデプロイして動作することを試してみてください。
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/seijikohara/springboot2-vue3-heroku/tree/main)

# 課題

- `/` -> `index.html`のルーティングしかできないので、SPAでHistoryモードが使えない…
    - 解決策募集中です。

# 参考

- WebFlux
    - [Static Content in Spring WebFlux](https://www.baeldung.com/spring-webflux-static-content)
    - [Spring Webflux, How to forward to index.html to serve static content](https://stackoverflow.com/questions/45147280/spring-webflux-how-to-forward-to-index-html-to-serve-static-content)
- Heroku
    - [Deploying Gradle Apps on Heroku](https://devcenter.heroku.com/articles/deploying-gradle-apps-on-heroku#verify-that-your-build-file-is-set-up-correctly)