Spring Boot ウェブアプリにフロントエンド技術を含める webpack ビルド

## はじめに

Spring Boot で構築するウェブアプリケーションに、ES Modules、babel や Sass/CSS コンバートなどのフロントエンド技術を含めてビルドする TIPS を書いてみました。普段はバックエンドばかりで [webpack](https://webpack.js.org/) よく分からないよ、という方の参考にもなるかもしれません（自分ですが…!）

この記事中の全てのソースコードは以下のリポジトリーから参照できます。なるべくバックエンドとフロントエンド担当の方がリポジトリーを共有して作業できることを心がけました。

> https://github.com/h1romas4/springboot-template-web

また、Gitpod からビルドして動作する様子も確認できます。

[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io#https://github.com/h1romas4/springboot-template-web)

```
# （Gitpod ターミナルで）Spring Boot アプリケーション起動
./gradlew bootRun
# （別なターミナルで）webpack の watch 開始
./graldew watch
```

## 初期設定

### Spring Boot プロジェクトのビルドとディレクトリー構成

プロジェクトは Spring Boot の `spring-boot-starter-web` と `spring-boot-starter-thymeleaf` で構成し、ビルドは Gradle を用いています。

また、多くのフロントエンド系技術は Node.js ランタイムに依存するため、Gradle で Node.js をラップする `com.github.node-gradle.node` を使います。

このプラグインは指定バージョンの Node.js をプロジェクトローカルに自動的に導入できるので CI を組む時にも便利です。（Heroku などでも動作します）

`build.gradle` は次のようになります。

> https://github.com/h1romas4/springboot-template-web/blob/master/build.gradle

```gradle
// build.gradle 抜粋
plugins {
    id 'org.springframework.boot' version '2.2.6.RELEASE'
    id "com.github.node-gradle.node" version "2.2.3"
}

apply plugin: 'java'
apply plugin: 'io.spring.dependency-management'
apply plugin: 'com.github.node-gradle.node'

node {
    version = '12.16.2'
    // プロジェクトローカルに OS に応じた nodejs を自動ダウンロード
    // 具体的にはプロジェクトルート .gradle/nodejs 配下に配置される
    download = true
}

bootRun {
    // スタティックファイルの変更を直接反映させる
    //  Thymeleaf .html や .css/.js などの修正が即反映
    sourceResources sourceSets.main
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-devtools'
}
```

Spring Boot（starter-web） 標準のディレクトリ構成は次のようになります。Node.js/npm を構成するために `package.json` を追加しています。

```
src/main/
    java/
    # resources 配下のファイルがデプロイされる
    resources/static
        css/
        fonts/
        images/
        js/
    resources/templates
        index.html
build.gradle
package.json # 追加
```

TIPS はこのディレクトリ構成を前提として記載していますので、異なる場合は適宜読み替えてください。

### フロントエンド系ソースをビルドするために Node.js のパッケージを追加する

`package.json` に使いたいビルド系ツール（`devDependencies`）と依存パッケージ（`dependencies`）を記述します。

このサンプルの依存パッケージは [Baootstrap](https://getbootstrap.com/) と [Vue.js](https://jp.vuejs.org/index.html) と [axios](https://github.com/axios/axios) としています。（適宜変更してください）　ビルド系ツールの定義部分には、何をするものなのかソースコードにコメントを入れてみました。

> https://github.com/h1romas4/springboot-template-web/blob/master/package.json

```javascript
// package.json 抜粋
{
    // ビルド系依存
    "devDependencies": {
        // babel: ES6以降の JavaScript ソースをブラウザー ECMAScript5 で動作させる
        "@babel/core": "^7.9.0",
        "@babel/preset-env": "^7.9.5",
        "babel-loader": "^8.1.0",
        // webpack: ES Modules バンドル及び .vue、CSS/Sass ビルド用
        "webpack": "^4.42.1",
        "webpack-cli": "^3.3.11",
        // nodejs のみでフロントエンド開発する場合に使う httpd サーバー（後述）
        "webpack-dev-server": "^3.10.3",
        // プロダクションと開発で webpack.config を分ける（後述）
        "webpack-merge": "^4.2.2",
        // webpack: Vue.js の .vue モジュールをバンドル
        "vue-loader": "^15.9.1",
        "vue-template-compiler": "^2.6.11",
        // webpack: .css は JS にバンドルしない
        "mini-css-extract-plugin": "^0.9.0",
        "css-loader": "^3.5.2",
        // webpack: 画像やフォントも JS にバンドルしない
        "file-loader": "^6.0.0",
        // webpack: Sass ビルド
        "sass": "^1.26.3",
        "sass-loader": "^8.0.2"
    },
    // JavaScript ソースから依存するパッケージ・ライブラリー
    "dependencies": {
        // HTTP クライアント
        "axios": "^0.19.2",
        // bootstrap
        "bootstrap": "^4.4.1",
        // bootstrap の依存
        "open-iconic": "^1.1.1",
        "popper.js": "^1.16.1",
        "jquery": "^3.4.1",
        // Vue.js ライブラリー
        "vue": "^2.6.11"
    },
}
```

また、`package.json` に、フロントエンドのビルドを行う webpack コマンドが呼べるように `script` を追加します。

```javascript
// package.json 抜粋
{
    "scripts": {
        "webpack": "webpack",
    }
}
```

続けて `packege.json` の `script` を Gradle から呼べるように `build.gradle` に分かりやすいラッパータスクを定義します。

```gradle
// build.gradle 抜粋
task webpack(dependsOn: ['npm_run_webpack'])
```

ビルドの実行は次のように行います。

```
./gradlew npmInstall # 初回
./gradlew webpack
```

もちろんクライアントPC に `build.gradle` で指定したバージョンと同じ Node.js が導入されているのであれば、

```
npm install # 初回
npm run webpack
```

でも同様の結果を得ることができます。（フロントエンドのみを担当する方はこちらを使うことができます）

## TIPS: フロントエンド系のソースコードを Spring Boot の構造に合わせてビルドする webpack 例

CSS(Sass)や JavaScript のソースはビルド対象となりますので、`resources/static` に直接置かずに `src/main` に上げ、ビルド後の結果を `resources/static` に配置するように webpack ビルドを組みます。

webpack 初めてだよという方は処理の流れを、

* webpack はビルドを定義する `webpack.config.js` の `entry` キーに設定された `.js` を起点に処理を追っていき `import` をみつけたら指定されているモジュールを合体（バンドル）
    * `import` の指定がパスではなくモジュール名であれば `package.json` 内に指定した同名モジュールを `node_modules` ディレクトリから検索してバンドル
* バンドル前に import する拡張子に応じて `rule` に設定されたプログラムを呼び出し変換（ビルド）
* `import` を追っていく中で `.js` だけでなく `.css` や CSS からリンクされている画像ファイルなどのリソースも `.js` にバンドルできる機能も有する。

とざっくり思っておくと読みやすいかもしれません。

このビルドでは CSS は全画面でひとつを共有（もちろん ＠import によるモジュール分割は可能）、JavaScript は各画面でファイル分割という構成を想定しています。 

`.css` は JavaScript にバンドルすると画面がパタつくので、`.js` にはバンドルせずに `MiniCssExtractPlugin` プラグインを利用し単一の`.css` ファイルとして出力する方針としています。

ソースコードの構成:

```
src/main/
    java/
    css/
        # .css もしくは .sass
        style.css
        images/
            # CSS から url('./images/image.png') で参照する画像
            image.png
    js/
        common/
            # ここで bootstrap 系と .css を import して webpack 対象に
            common.js
        components/
            # Vue.js のコンポーネント
            hello.vue
        # 画面ごとの JavaScript
        screen1.js
        screen2.js
        screen3.js
    resources/
        templates/
            index.html
# 追加
webpack.config.js
```

このビルドを行うための `webpack.config.js` は次のようになります。コメントを入れましたので適宜修正してお使いください。

```javascript
// webpack.config.js 抜粋
const webpack = require('webpack');
const path = require('path');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  mode: 'production',
  entry: {
    // 共通系の JavaScript ライブラリーは vendor.bundle.js としてひとつにまとめる
    // .css は bootstrap の .css と合成するため vendor に含めておく
    vendor: [
      'vue',
      'axios',
      'jquery',
      'popper.js',
      'bootstrap',
      './src/main/css/style.css'
    ],
    // 画面ごとの .js は分割して出力する（この設定のファイルを起点にバンドル開始）
    screen1: './src/main/js/screen1.js',
    screen2: './src/main/js/screen2.js',
    screen3: './src/main/js/screen3.js',
  },
  output: {
    // ビルドの出力先を /src/main/resources/static にする
    path: path.join(__dirname, '/src/main/resources/static'), // eslint-disable-line
    // Spring Boot のコンテキストパス（/）を設定する
    publicPath: "/",
    // JavaScript は js/ 配下に配置
    filename: 'js/[name].bundle.js'
  },
  optimization: {
    // 共通 JavaScript/CSS を vendor という名前で出力する
    splitChunks: {
      cacheGroups: {
        vender: {
          name: 'vendor',
          chunks: 'initial'
        }
      }
    }
  },
  plugins: [
    // bootstrap 特有の設定
    // bootstrap 内の JavaScript が $/jQuery 名でグローバルスコープの jQuery を
    // 参照するためコンテキストに入れてあげて解決する
    new webpack.ProvidePlugin({
      $: 'jquery',
      jQuery: 'jquery'
    }),
    // .vue をビルド・バンドルするプラグイン
    new VueLoaderPlugin(),
    // .css は JavaScript にバンドルせずにファイル出力するプラグイン
    //  css/ 配下に出力
    new MiniCssExtractPlugin({
      filename: 'css/[name].bundle.css',
    }),
  ],
  module: {
    rules: [
      {
        // .js は babel を通してブラウザーで動作する JavaScript に変換
        test: /\.js$/,
        exclude: /node_modules/,
        use: [{
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env']
          }
        }]
      },
      {
        // .vue の Vue.js コンポーネントをビルドしてバンドル
        test: /\.vue$/,
        loader: 'vue-loader'
      },
      {
        // .css|.sass ファイルをビルドして css/ ディレクトリに出力
        test: /\.(sa|c)ss$/,
        use: [
          // 各ローダーは定義の下から順に適用される（Sass-> CSS -> Extract）
          // https://webpack.js.org/concepts/loaders/#configuration
          {
            loader: MiniCssExtractPlugin.loader,
            options: {
              publicPath: '/css',
            },
          },
          // .css 内の URL パスなどをそれぞれの publicPath に合わせてくれる
          "css-loader",
          // .sass のビルド
          "sass-loader"
        ]
      },
      {
        // bootstrap などで使われるフォントファイルは fonts/ ディレクトリーに
        // ファイル出力
        test: /\.(ttf|otf|eot|woff|woff2)$/,
        use: [{
          loader: 'file-loader',
          options: {
            name: "[name].[ext]",
            outputPath: 'fonts/',
            publicPath: '/fonts'
          }
        }]
      },
      {
        // 画像ファイルは images/ ディレクトリーにファイル出力
        test: /\.(png|jpg|svg)$/,
        use: [{
          loader: 'file-loader',
          options: {
            name: "[name].[ext]",
            outputPath: 'images/',
            publicPath: '/images'
          }
        }]
      },
    ]
  },
  resolve: {
    // .js と .vue 拡張子は import で付いてなくても解決
    extensions: ['.js', '.vue'],
    // import するモジュールでパス付きでないものは npm の node_modules に入ってる
    modules: [
      "node_modules"
    ],
    alias: {
      // import Vue from 'vue'; は Product 用の ES Modules 版を使う
      'vue$': 'vue/dist/vue.esm.js'
    }
  }
};
```

各画面から import する `common/common.js` などの分かりやすい部分に次の記述を追加して、bootstrap 系の依存と作成した .css をバンドルするようにします。（`entry` から辿れる .js のどこかに一度書けば OK です）

```javascript
// common.js 抜粋
// for bootstrap
import 'bootstrap/dist/css/bootstrap.css'
import 'open-iconic/font/css/open-iconic-bootstrap.css';
import $ from 'jquery';
// for common css
import '../../css/style.css';
```

各画面の JavaScript は次のようになります。

```javascript
// screen1.js 抜粋（各画面）
// common.js を import
import './common/common.js'
// 各画面で使いたいライブラリーを import
import Vue from 'vue';
import axios from 'axios';
// Vue の .vue コンポーネントをインポートする例
// webpack.config の resolve に .vue が入っているので拡張子は不要 
import hello from './components/hello'
```

この状態でビルドすると、Spring Boot がリソースとして認識する `resources/static` 配下に、次のようにファイルが出力されアプリケーションから読み込めます。（これらの出力ファイルは `.gitignore` しておいても良いかもしれません）

```
resources/static
    css/
        # bootstrap の .css + css/style.css（もしくは .sass のビルド結果）
        vendor.bundle.css
    fonts/
        # モジュール（bootstrap）が参照するフォントファイル
        open-iconic.woff
        ...
    images/
        # モジュール（.css など）が参照する画像ファイル
        150x150.png
        # モジュール（bootstrap）が参照する画像ファイル
        open-iconic.svg
    js/
        # vendor 指定したライブラリーがひとつにバンドルされた共通 .js
        vendor.bundle.js
        # 各画面用の .js
        screen1.bundle.js
        screen2.bundle.js
        screen3.bundle.js
```

配置されたファイルを読むための `resources/templates/*.html` は次のようになります。

```html
<!doctype html>
<!-- screen1.html 例 -->
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <!-- 全 .css がバンドルされた vendor.bundle.css を指定 -->
  <!-- 画像パスなどは webpack（各 publicPath 設定） により自動的に解決されている -->
  <link href="/css/vendor.bundle.css"
        th:href="@{/css/vendor.bundle.css}" rel="stylesheet" />
</head>
<body>
  <!-- 共通ライブラリーがバンドルされた vendor.bundle.js -->
  <!-- 各画面で同一ファイルとなるためブラウザーキャッシュが効く -->
  <script src="/js/vendor.bundle.js" th:src="@{/js/vendor.bundle.js}"></script>
  <!-- 各画面の .js がバンドルされた screen1.bundle.js -->
  <script src="/js/screen1.bundle.js" th:src="@{/js/home.bundle.js}"></script>
</body>
</html>
```

HTML 属性で Thymeleaf の `th` なし `href` や `src` 属性にもパスを記述することで、次項の Java 環境なしでのフロントエンド開発が可能になります。 

## TIPS: Node.js 環境のみでフロントエンド開発を行う webpack-dev-server

フロントエンド側の開発を行う場合は、バックエンド側のプログラムの動作を気にせずに自由に好きな画面を開いて開発したいです。このような時は開発用の `webpack.dev.js` を追加して [webpack-dev-server](https://github.com/webpack/webpack-dev-server) を起動して作業すると便利です。

```javascript
// webpack.dev.js 抜粋
const merge = require('webpack-merge');
const path = require('path');
const common = require('./webpack.config.js');

// 本家の webpack.config.js に設定をマージ（オーバーライド）
module.exports = merge(common, {
  // ブラウザー開発者ツール用に JavaScript のソースマップを出力
  devtool: 'source-map',
  // 開発用サーバーを 9080 ポートで起動
  devServer: {
    contentBase: [
        // .html のパス（コンテントルート）を指定
        // webpack でビルドされる JS/CSS などは修正がウォッチされ、
        // ビルド結果がメモリー上に展開され contentBase と合成される
        path.join(__dirname, '/src/main/resources/templates')
    ],
    // .html ファイルの修正も反映対象にする
    watchContentBase: true,
    port: 9080,
    // ブラウザーを自動起動
    open: true,
    openPage: "screen1.html"
  },
  resolve: {
    alias: {
      // webpack.config.js の設定を上書き
      // import Vue from 'vue'; で開発版を読ませることで Vue のデバッグが可能になる
      'vue$': 'vue/dist/vue.js'
    }
  }
});
```

`package.json` の `script` に `server` を次のように追加します。

```javascript
// package.json 抜粋
{
  "scripts": {
    "webpack": "webpack",
    // 作成した webpack.dev.js をコンフィグに指定
    "server": "webpack-dev-server --config webpack.dev.js"
  }
}
```

次のコマンドでサーバーが 9080 ポートで起動し `.js` `.css` の修正が自動的にビルドされ反映されます。

```
npm run server
```

## TIPS: API 呼び出し時に開発用 .json を返す

開発中のフロントエンドから API をたたく処理をかく場合、バックエンド不在のまま開発に都合の良い .json を返したいことがあります。

```javascript
// screent1.js 抜粋
new Vue({
  methods: {
    ajax: function() {
      // ここで http リクエスト（バックエンドがいない…!）
      axios.get('/api/v1/home').then((res) => {
        this.items = res.data;
      });
    }
  }
});
```

このような場合は `webpack-dev-server` の設定で、内部的使われている [Express](https://expressjs.com/ja/) サーバーの動作を定義することにより、ファイルで配置した .json を返すことができるようになります。

```javascript
// webpack.dev.js 抜粋
module.exports = merge(common, {
  devServer: {
    before: function(app) {
      // /api でアクセスがきたら
      app.use('/api', function (req, res) {
        // Content-Type 設定
        res.type('application/json')
        // /src/test/js/json 配下にある .json を返却（ディレクトリトラバーサル注意）
        res.sendFile(path.join(__dirname, '/src/test/js/json', req.originalUrl + ".json"))
      })
    },
  },
});
```

返したい JSON をソースの `test` ディレクトリーに次のように配置します。

```
src/test/js/json
    /api/v1/
        home.json
        ...
```

API のエンドポイントがファイルシステムだけで表現できない場合は、[Expressのドキュメント](https://expressjs.com/en/guide/routing.html)を参考に、疑似 API サーバーをつくってあげると便利だと思います。

## 終わりに

リポジトリーのほうはさらに Gradel/Node.js の組み合わせでビルドする定義なども入っていますので、必要であれば確認していただければと思います。なお、このリポジトリは、本文書よりライブラリやビルドの依存関係を上げられる範囲でアップデート・メンテナンスしている場合があります。

> https://github.com/h1romas4/springboot-template-web

おかしな部分を発見しましたら、プルリクなどいけだけると嬉しいです。