タグ「Spring」の 記事 13 件中 1 ~ 13 件を表示しています。

アノテーションを定義する方法が面倒な時

Spring Boot でのバリデーション実装に関しては昨日の記事に書いた。 今回は @NotBlank@Size など標準で用意されているアノテーションだけではまかないきれないようなバリデーションを行いたい時にどうするかについて書く。

まず、独自のバリデーションを行いたい場合は自分でアノテーションクラスを作成して Form クラスの対象フィールドに定義するといった方法がある。 これに関しては「Spring バリデーション 独自」などで検索すればいくらでも出てくるのでここでは言及しない。 アノテーションクラスを定義すればアノテーションを付与するだけでどこにでも使えるようになるので便利なのだが、私は以下の理由で後述の方法をとることにした:

  • 普遍的に使うのではなくただ 1 箇所で使いたいだけなのにアノテーションクラスを定義して使うのは面倒
  • RepositoryService などのモデル層が絡むようなバリデーションを使いたい

尚、自分でガリガリ書けば (Spring Validation を使用しなければ) どのようなバリデーションも思いのままだが、ここではそういうことではなくあくまで BindingResult の対象のフィールドにエラーが入った状態で Thymeleaf テンプレートが表示され、対象のフィールドに th:errors="(Form フィールド名)" でエラー表示がされるといったように組み込みのバリデーションと同じ流れで表示されるようにしたい。

BindingResult.rejectValue()

最初 BindingResult.addError() というメソッドを見つけたので何度か試していたのだが、全くうまくいかないので諦めて検索したら正しくは BindingResult.rejectValue() というメソッドを使用するのだという情報を見つけた。 このメソッドは rejectValue({Form フィールド名}, {messages.properties キー}) のようにして任意のタイミングで messages.properties (設定を変更していない場合は ValidationMessages.properties) に定義されているバリデーションエラーメッセージを対象のフィールドに対し設定することができる。 また、バリデーションエラーにプレースホルダがある場合 (例えば「{0} には日付の形式で入力してください。」のような) に rejectValue({Form フィールド名}, {messages.properties キー}, {プレースホルダに渡したい引数の配列}, {デフォルトメッセージ}) のようにして引数を渡すこともできる。

実装例

ここでは例として「同一日付に対する記事は登録できない (但し更新時は自分自身を対象外とする)」というバリデーション実装を行う。 messaegs.properties に以下リソースが定義されているものとする:

validation.date-format={0} は日付形式で入力してください。
validation.date-already-registered=その日付の記事は既に登録されています。

コントローラ側に組み込みのバリデーションを実施した後今回の独自バリデーションを以下のように実装する:

/**
 * 記事を追加する.
 *
 * @param model モデル
 * @param form PostForm
 * @param result BindingResult
 * @param id postId
 * @return template 名
 */
@PostMapping("/posts/add", "/posts/{id}")
fun savePost(model: Model, @Validated @ModelAttribute("form") form: PostForm, result: BindingResult,
        @PathVariable("id") id: Int?): String {
    model.addAttribute("tags", tagService.findAll())

    // 組み込みバリデーションエラーに引っかかった場合
    if (result.hasErrors()) {
        return "/posts/add"
    }

    // 日付の形式が間違っている場合はエラー
    val date: LocalDate
    try {
        date = LocalDate.parse(form.date)
    } catch (e: DateTimeParseException) {
        e.printStackTrace()
        result.rejectValue("date", "validation.date-format", arrayOf("日付"), "")
        return "/posts/add"
    }

    // 既に登録されている年月日の場合はエラー (但し更新時は自分自身を対象にしない)
    val post2 = postService.findByDate(date)
    if (post2 != null && (id == null || id != post2.id)) {
        result.rejectValue("date", "validation.date-already-registered")
        return "/posts/add"
    }

    TODO("保存処理")
    return "redirect:/posts"
}

これで同一日付で記事を登録しようとした時に「その日付の記事は既に登録されています。」といったエラーメッセージが表示される。

導入と properties の統合及び UTF-8 化

まず build.gradle に以下を定義する:

dependencies {
    compile('org.springframework.boot:spring-boot-starter-validation')
}

これですぐに使えるわけだが、その前にバリデータの設定をする。 後述するがバリデーションメッセージに関してはデフォルトで ValidationMessages.properties に定義されているものを使用するのだが、これも後述するがバリデーションメッセージのプレースホルダに適用されるフィールド名は messages.properties のものが使用されるので 2 箇所に書くことになってしまう。 できれば messages.properties に両方書くようにしたい。 更にデフォルトで properties ファイルは UTF-8 エンコーディングになっていない。 これも UTF-8 に変更したい。

これを実現するには以下のような WebMvcConfigurer を実装したコンフィギュレーションファイルを定義する:

@Configuration
class Configuration : WebMvcConfigurer {

    /**
     * バリデータを返す.
     *
     * @return バリデータ
     */
    override fun getValidator(): Validator? {
        val source = ReloadableResourceBundleMessageSource().also {
            it.setBasename("classpath:messages")  // ValidationMessages.properties でなく messages.properties を使用する
            it.setDefaultEncoding("UTF-8")  // エンコーディングとして UTF-8 を使用する
        }
        return LocalValidatorFactoryBean().also { it.setValidationMessageSource(source) }
    }
}

ちなみに Spring 4 以前は WebMvcConfigurerAdapter というインターフェースを使用していたようだが Spring 5 (Spring Boot 2.0) では非推奨となり WebMvcConfigurer を使用するようになった。

messages.properties でなく messages.yaml などというファイルを置くとよろしくやってくれるのかどうか試したのだが駄目だった。 残念。

バリデーション例

例えば以下のように messages.properties に定義してあるものとする:

# フィールド名
date=日付
name=名前
tag=タグ
markdown=Markdown

# バリデーションメッセージ (デフォルトの差し替え)
javax.validation.constraints.NotBlank.message={0} を入力してください。

# カスタムバリデーションメッセージ
validation.max-length={0} は {1} 文字以下で入力してください。
validation.not-selected={0} を選択してください。

javax.validation.constraints.NotBlank.message に関してはバリデーションで使用する @NotBlank アノテーションでのエラー時に表示されるメッセージの差し替えである。 この場合 @NotBlank アノテーションのパッケージ名を含めたクラス名が javax.validation.constraints.NotBlank の為それに .message を加えたものを定義しておくとデフォルトメッセージの差し替えができる。 @NotEmpty@Size なども同様となる。

尚、こういう全体的に適用できるバリデーションメッセージだけでなく項目ごとに個別に指定したいバリデーションエラーメッセージがある。 例えば @Size によるバリデーションエラーは「0 文字以上 64 文字以下」のような表示になってしまうが、多くの場合は「0 文字」は不要で最大桁数のみ通知すればいいはずだ。 こういう場合に任意のプロパティ名でカスタムバリデーションメッセージを定義しておく。

フィールド名に関してはフォームの POST 時に使用する Form インスタンスのフィールド名と同じにしておく。

Form

class PostForm {

    // 必須
    @NotBlank
    var date: String = ""

    // 必須かつ 64 文字以内
    @NotBlank @Size(min = 0, max = 64, message = "{validation.max-length}")
    var name: String = ""

    // 選択必須
    @NotEmpty(message = "{validation.not-selected}")
    var tag: Array<Int> = arrayOf()

    // 必須
    @NotBlank
    var markdown: String = ""
}

上記の例のようにバリデーションエラー時に表示されるカスタムメッセージを messages.properties を使用して指定したい場合は {validation.max-length} のような記法で書く。 {} を付けずに書くと任意の文字列を指定できるが、折角 messages.properties が使えるのにハードコーディングすることもないだろう。

Controller

@Controller
class MyController {

    /**
     * 記事追加画面を表示する.
     *
     * @param model モデル
     * @param form PostForm
     * @return template 名
     */
    @GetMapping("/posts/add")
    fun addPost(model: Model, @ModelAttribute("form") form: PostForm): String = "posts/add"

    /**
     * 記事を追加する.
     *
     * @param model モデル
     * @param form PostForm
     * @param result BindingResult
     * @return template 名
     */
    @PostMapping("/posts/add")
    fun savePost(model: Model, @Validated @ModelAttribute("form") form: PostForm, result: BindingResult): String {
        if (result.hasErrors()) {
            return "/posts/add"
        }
        
        // TODO 登録処理
        return "redirect:/posts"
    }
}
  • 登録画面初期表示時の GET と POST を分ける (Form に対するバリデーション指定の為)
  • BindingResultForm の直後の引数として定義する (※位置が違うと正しく機能しないので注意) とメソッド内で result.hasErrors() でバリデーションエラーの有無を取得できる (BindingResult がないとメソッドの中まで処理が進まずに弾かれてしまう)
  • 登録画面初期表示時の GET のメソッドの方にも Form を含めたほうが良い (フォーム項目の初期データの表示時に Form に直接値をセットすれば良い)
  • FormModel は HTML 内の <form> によって POST される項目か否かで使い分けると良さそうに見える

Thymeleaf

<form th:action="@{/posts/add}" method="POST" th:object="${form}">
    <div>
        <input type="text" id="date" name="date" th:value="*{date}" th:classappend="${#fields.hasErrors('*{date}') ? 'is-invalid' : ''}">
        <div class="invalid-feedback" th:if="${#fields.hasErrors('*{date}')}" th:errors="*{date}"></div>
    </div>
    <div>
        <input type="text" id="name" name="name" th:value="*{name}" th:classappend="${#fields.hasErrors('*{name}') ? 'is-invalid' : ''}">
        <div class="invalid-feedback" th:if="${#fields.hasErrors('*{name}')}" th:errors="*{name}"></div>
    </div>
    <div>
        <select class="custom-select" id="tag" name="tag" multiple="multiple" th:classappend="${#fields.hasErrors('*{tag}') ? 'is-invalid' : ''}">
            <option th:each="t : ${tags}" th:value="${t.id}" th:text="${t.name}" th:selected="${#arrays.contains(form.tag, t.id)}"></option>
        </select>
        <div class="invalid-feedback" th:if="${#fields.hasErrors('*{tag}')}" th:errors="*{tag}"></div>
    </div>
    <div>
        <textarea id="markdown" name="markdown" class="form-control" rows="16" th:classappend="${#fields.hasErrors('*{markdown}') ? 'is-invalid' : ''}">[[*{markdown}]]</textarea>
        <div class="invalid-feedback" th:if="${#fields.hasErrors('*{markdown}')}" th:errors="*{markdown}"></div>
    </div>
    <div>
        <input type="submit" value="保存"/>
    </div>
</form>

<form> の内容を全部書いたので若干見づらいが重要なのは ${#fields.hasErrors('(Form フィールド名)')} でそのフィールドにエラーがあるかどうかが取得でき th:errors="(Form フィールド名)" で対応するエラーメッセージが要素内のテキストノードに格納されるということだ。 コードにも例示したがエラーの有無で入力フォームの見た目を変えたい場合は th:classappend を使用して class 属性を追加して CSS で見た目を変更すれば良い。

ここまで定義した内容で名前以外すべて空、名前は最大文字数をオーバーした状態で submit すると以下のようにエラーメッセージが表示される:

  • 日付 を入力してください。
  • 名前 は 64 文字以下で入力してください。
  • タグ を選択してください。
  • Markdown を入力してください。

Android 開発で未だに完全な Java 8 が使えないということもあり (Stream API でさえも最低ビルドターゲットを引き上げないと使用することができない)、どうしても Java 8 Time API への移行ができずにいにしえの DateCalendar などを駆使してイマイチな日付処理を行う癖がついてしまっていた。 Spring Boot ならばちゃんと Java 8 Time API が使用できるので古い API は捨て去りたい。

しかし Thymeleaf が標準で備えている #dates.format() などのユーティリティメソッドは Date を対象としておりそのままでは LocalDateTime などは使用することができない。 これでは困ってしまうので拡張機能を導入する。

解決法

build.gradle に以下を追加する:

dependencies {
    compile('org.thymeleaf.extras:thymeleaf-extras-java8time:3.0.1.RELEASE')
}

どこでもいいのだが component-scan 対象パッケージのどこかに以下のような @Bean 定義を置く:

@Configuration
class Configuration {

    /**
     * Java 8 Time Dialect を返却する.
     *
     * @return Java 8 Time Dialect
     */
    @Bean
    fun java8TimeDialect() = Java8TimeDialect()
}

これだけで Thymeleaf 上で LocalDateLocalDateTime を扱うことができるようになる。 例えば #date.format() にあたるのは #temporals.format() で以下のように書く:

<div th:text="${#temporals.format(firstDate, 'yyyy/M/d')}"></div>

他の使い方は Thymeleaf - Module for Java 8 Time API compatibility の GitHub に書いてあるので参照すればすぐ分かるだろう。

server.xml の場合

Tomcat はデフォルトで PUT と DELETE のリクエストボディが無効になっているらしく POST と同じような感じでフォームデータを PUT, DELETE してもすべてクリアされてしまう。 これを有効にするには server.xml に以下の様に設定を行う:

<Connector port="8080" protocol="HTTP/1.1" 
           connectionTimeout="20000"
           redirectPort="8443"
           parseBodyMethods="POST,PUT,DELETE"
           URIEncoding="UTF-8" />

Spring Boot 組み込み Tomcat の場合

Spring Boot で Application クラスを実行して組み込みの Tomcat が立ち上がった際もこの設定を有効にしたい。 この場合 application.yaml ではなく @Configuration アノテーションを付けたクラスに @Bean として TomcatServletWebServerFactory を返すメソッドを書く:

@Configuration
class Configuration {

    /**
     * 組み込み Tomcat のデフォルトで PUT, DELETE に Request Body が許可されていないので許可する.
     *
     * @return TomcatServletWebServerFactory
     */
    @Bean
    fun tomcatEmbeddedServletContainerFactory(): TomcatServletWebServerFactory = object : TomcatServletWebServerFactory() {
        override fun customizeConnector(connector: Connector?) {
            super.customizeConnector(connector)
            connector?.parseBodyMethods = "POST,PUT,DELETE"
        }
    }
}

Tomcat の他の設定をいじりたい場合も同じようにここに追記することができるようだ。

JPA の Entity では単一キーである id 列を持たせるような構造を定義することが出来るが SQLite の INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT な列に関して適切な定義がよく分からなかったのでメモ。 例えば以下のようなテーブルがあるとする:

create table comments (
    id integer not null primary key autoincrement,
    name varchar(16) not null,
    body text not null,
    created datetime not null,
    modified datetime not null,
);

このような AUTOINCREMENTPRIMARY KEY があると SQLite は内部的に sqlite_sequence というテーブルに各テーブルのシーケンスを格納するという挙動をする。 sqlite_sequence テーブルの DDL は以下のようになっている:

CREATE TABLE sqlite_sequence (name, seq);

name にはテーブル名、seq には現在のシーケンス値が格納される。 この sqlite_sequence テーブルを Hibernate 側に教えてやればよい。 この場合の Entity 定義は以下のようになる:

@Entity
@Table(name = "comments")
data class Comment(
        @Id
        @GeneratedValue(generator = "sqlite_comments")  // Generator 名 (何でもよい)
        @TableGenerator(
                name = "sqlite_comments",  // @GeneratedValue.generator と合わせる
                table = "sqlite_sequence",  // SQLite のシーケンステーブル名と合わせる
                pkColumnName = "name",  // sqlite_sequence のシーケンスカラム名 (name 固定)
                valueColumnName = "seq",  // sqlite_sequence のシーケンス値名 (seq 固定)
                pkColumnValue = "comments",  // sqlite_sequence.name に格納されている値 (テーブル名)
                initialValue = 1,  // シーケンス初期値. 多くの場合 1
                allocationSize = 1  // AUTO INCREMENT される場合の増減値. 何故かデフォルト 50 になっているので 1 を指定する
        )
        var id: Int? = null,
        var name: String = "",
        var body: String = "",
        @CreatedDate var created: Date = Date(),
        @LastModifiedDate var modified: Date = Date()
)

他のフレームワークには用意されていたりする

テンプレートにおける表示用の加工処理は大体 Thymeleaf が標準で備えているが、例えば CakePHP における Helper だったり Django における独自のテンプレートタグの作成のようにテンプレートの機能だけでは賄いきれない、多くはプレゼンテーション層における HTML への変換のためのロジックを使いたい時がある。 こういう時に Spring Boot における Thymeleaf 上ではどうすればいいのだろうか、というのが今回のテーマである。

勿論 ControllerModel (Form) に対応する HTML タグへの変換処理を書いたりすることはできるが MVC において本来コントローラやモデルでプレゼンテーション層の処理を書くのは好ましくないので避けたいところだ。 いろいろ試行錯誤してみた結果、プレゼンテーション層のヘルパクラスをコンポーネントとして登録して Thymeleaf 側から呼び出すのが一番シンプルな気がした。

ヘルパークラス定義

今回は Markdown で書かれたテキストを HTML に変換したいとする。 以下のようなヘルパークラスを任意のパッケージに定義する:

/**
 * Thymeleaf テンプレート上で使用するヘルパ.
 */
@Component
class Helper {

    /**
     * Markdown を HTML に変換して返す.
     *
     * @param markdown Markdown
     * @return HTML に変換された Markdown
     */
    fun toHtml(markdown: String): String {
        val (parser, renderer) = Parser.builder().build() to HtmlRenderer.builder().build()
        return renderer.render(parser.parse(markdown))
    }
}

@Component アノテーションを付与することにより Spring 管理下のコンポーネントとして機能する。 これを Thymeleaf 上で使用するには以下のように ${@helper.toHtml(xxx)} といった @ を頭につけた記法となる:

<!-- HTML エスケープされないように th:utext を使用する -->
<div class="post-content" th:utext="${@helper.toHtml(post.markdown)}"></div>

後はこういう要件が出てくる度にこの Helper クラスにメソッドを追加していけばよい。

定数の参照はどうする

同じような悩みとして Thymeleaf 上から Kotlin の定数を参照したいというのがある。 一応 Thymeleaf 上で ${T(パッケージ.クラス名).static フィールド名} という記法で任意の static フィールドやメソッドを呼び出すことはできる。 ただ、例えば Kotlin で object を使用して static を表現したとする:

object Consts {
    val DAYS = arrayListOf('月', '火', '水', '木', '金', '土', '日')
}

上記の定数を Thymeleaf 側で参照するには ${T(com.kojion.etc.Consts).INSTANCE.DAYS} のようにしなければならない。 Kotlin の object が Java コード側から見るとシングルトンな INSTANCE という static フィールドを介してアクセスするようになっているので INSTANCE といちいち付けなければならず、あまり綺麗とは言えない。

この場合あえて Kotlin でなく Java で書いてみる:

public class Consts {
    public static final List<String> DAYS = Arrays.asList("月", "火", "水", "木", "金", "土", "日");
}

これで ${T(com.kojion.etc.Consts).DAYS} とアクセスできるので少しシンプルになった。 Thymeleaf 側からは Kotlin でなく Java として見なければならないのが少し辛いところだ。

パッケージ名を書くのも気になる場合は、先程のヘルパークラスと同様にコンポーネントとして登録して定数定義するのがいいのかもしれない:

@Component
object Consts {
    val DAYS = arrayListOf("月", "火", "水", "木", "金", "土", "日")
}

これで Thymeleaf 側から ${@consts.DAYS} でアクセスできるようになった。 object で定義しているので Kotlin 側から定数としてアクセスしたい場合も自然だ。

手軽に入れ物を作る場合便利

Kotlin にはデータクラスという JavaBeans のようにデータを入れることに特化したクラスを簡単に作成するための仕組みがある。 詳しくは公式リファレンスを参照すればよいが、簡単に書くと以下を自動で用意してくれる:

  • equals() / hashCode()
  • "User(name=John, age=42)" 形式の toString()
  • 宣言した順番でプロパティに対応する componentN() 関数
  • copy() 関数

尚、上記の恩恵を受けられるのはプライマリコンストラクタに指定したプロパティのみということに注意が必要である。 クラスの本文に書いたフィールドに関しては一切データクラスの影響を受けない。 以下それを検証する。

以下のような Kotlin というデータクラスが定義されているものとする:

// a, b, c, d の 4 つのプロパティを受け取るプライマリコンストラクタ
data class Kotlin(var a: Int, var b: Int = 0, val c: Int, val d: Int = 0) {
    var e: Int = 0  // ただのフィールド (再代入可)
    val f: Int = 0  // ただのフィールド (再代入不可)
}

この Kotlin クラスを Java 側から使ってみる:

public class Java {
    public static void main(String...args) {
        final Kotlin kotlin = new Kotlin(0, 0, 0, 0);
        kotlin.setA(1);
        kotlin.setB(1);
        kotlin.setE(1);

        // プライマリコンストラクタに指定してあるフィールドのみ toString() の対象になる
        System.out.println(kotlin);  // Kotlin(a=1, b=1, c=0, d=0)

        // プライマリコンストラクタに指定してあるフィールドのインスタンスの equals() がすべて true ならば true
        final Kotlin kotlin2 = new Kotlin(1, 1, 0, 0);
        kotlin2.setE(2);  // 関係ない値を違う値にする
        System.out.println(kotlin2);  // Kotlin(a=1, b=1, c=0, d=0)
        System.out.println(kotlin.equals(kotlin2));

        kotlin2.setA(2);  // 関係ある値を違う値にしてみる
        System.out.println(kotlin.equals(kotlin2));  // false

        // コピーしても関係ない値はコピーされない
        System.out.println(kotlin2.getE());  // 先ほど変更したので 2
        final Kotlin kotlin3 = kotlin2.copy(3, 3, 3, 3);  // Kotlin で呼ぶと任意のプロパティのみ変更可
        System.out.println(kotlin3.getE());  // 2 ではなく初期値の 0
    }
}

デフォルトコンストラクタの作成条件

前述の Kotlin クラスでは引数なしのコンストラクタ、いわゆるデフォルトコンストラクタが作成されない。 データクラスにおけるデフォルトコンストラクタの作成条件はプライマリコンストラクタのすべてのプロパティに初期値が存在することとなっている。 つまり前述の Kotlin クラスを以下のように書き換える:

// a, b, c, d すべてに初期値を設定
data class Kotlin(var a: Int = 0, var b: Int = 0, val c: Int = 0, val d: Int = 0) {
    var e: Int = 0
    val f: Int = 0
}

するとコンストラクタがデフォルトコンストラクタとプライマリコンストラクタの 2 種に増えている:

public class Java {
    public static void main(String...args) {
        final Kotlin kotlin = new Kotlin();  // デフォルトコンストラクタを呼ぶ
        final Kotlin kotlin2 = new Kotlin(1, 2, 3, 4);  // 従来のすべての引数ありコンストラクタも作成される
    }
}

尚、以下のようにデフォルトコンストラクタを明示的に宣言しても良い:

data class Kotlin(var a: Int, var b: Int = 0, val c: Int, val d: Int = 0) {
    constructor(): this(0, 0, 0, 0)  // 明示的なデフォルトコンストラクタ
    var e: Int = 0
    val f: Int = 0
}

JPA Entity ではデフォルトコンストラクタの定義が必要

ここからは Spring Boot での JPA の話である。 例えば以下のようにデータクラスで Entity を定義する:

@Entity
@Table(name = "posts")
data class Post(
        @Id @GeneratedValue
        var id: Int,
        var date: String,
        var name: String,
        var body: String,
        var enabled: Boolean,
        var created: Date,
        var modified: Date
)

これを PostRepository から DB アクセスを行うと以下のエラーが表示される:

org.hibernate.InstantiationException: No default constructor for entity:  : com.kojion.entity.Post

先ほどの教訓から解決法は明らかだ。 以下のようにすべてデフォルト値を指定してやれば良い:

@Entity
@Table(name = "posts")
data class Post(
        @Id @GeneratedValue
        var id: Int = 0,
        var date: String = "",
        var name: String = "",
        var body: String = "",
        var enabled: Boolean = false,
        var created: Date = Date(),
        var modified: Date = Date()
)

toString() が循環呼び出しされてしまう場合データクラスの対象外にする

例えば以下のように相互にアソシエーションを張っている場合に起きる:

// Post は複数の Tag を持つ
@Entity
@Table(name = "posts")
data class Post(
        @Id @GeneratedValue
        var id: Int = 0,
        @ManyToMany
        @JoinTable(name = "posts_tags", joinColumns = [JoinColumn(name="post_id")], inverseJoinColumns = [JoinColumn(name="tag_id")])
        var tags: List<Tag> = arrayListOf()
)

// Tag も複数の Post を持つ
@Entity
@Table(name = "tags")
data class Tag(
        @Id
        @GeneratedValue
        var id: Int = 0,
        @ManyToMany(mappedBy = "tags")
        var posts: List<Post> = arrayListOf()
)

この例だと PostTag が中間テーブル posts_tags を通して多対多のアソシエーションが張られている。 ここで同じように PostRepository から取得した Entity を出力しようとすると java.lang.StackOverflowError: null とクラッシュする。 PosttoString() しようとしてフィールドの List<Tag> に対しても toString() を試み、更に Tag にも子の List<Post> があり……というわけである。

この例の場合 Post が何の Tag を持つかは見たいが Tag が何の Post を持っているかはそこまで見たくない (必要ならば別途取ってくれば良い)。 そこで Tag 側の @ManyToMany 定義されているプロパティをプライマリコンストラクタの範囲から出すことで TagtoString() しようとした時に子の List<Post> を見に行かなくなる:

@Entity
@Table(name = "tags")
data class Tag(
        @Id
        @GeneratedValue
        var id: Int = 0
) {
    @ManyToMany(mappedBy = "tags")
    var posts: List<Post> = arrayListOf()
}

Flyway とは

DB の状態をバージョン管理する為のツールで Spring Boot で簡単に使用することができる。 IntelliJ IDEA で新規プロジェクトを作成する時に依存関係に Flyway を含めると以下が build.gradle に追加される:

dependencies {
    compile('org.flywaydb:flyway-core')
}

Spring Boot の場合 application.yaml に DB 設定を行うことが必須だ。 SQLite の場合以下のように定義しておく:

spring:
  datasource:
    url: jdbc:sqlite:./db.sqlite3
    driverClassName: org.sqlite.JDBC

Flyway のマイグレーションファイルは (クラスパス)/db/migration 以下に V(バージョン番号)__(説明).sql の形式で置く。 バージョン番号は公式的には単調増加自然数 (1, 2, ...) のようだが V1_0_2__(説明).sql のようにするといわゆるマイナーバージョンとリファクタリング番号を記録する (v1.0.2 的な) ことができるようだ。

Spring Boot の場合アプリケーションを実行すると (クラスパス)/db/migration 以下のマイグレーションが自動で走り、今までのマイグレーションの状態は DB の中に flyway_schema_history テーブルが作成されて管理される。 この flyway_schema_history テーブルの中を見てみるとどこまでマイグレーションが適用されているか簡単に確認できる。

Flyway の詳しい解説は Flyway使い方メモが分かりやすかった。 SQL ファイルだけでなく Java コードでマイグレーションを記述することができるらしい。 以下、上記ページで解説されていなかった「既存 DB に対するマイグレーション定義」に関して書く。

既存の DB を元にマイグレーションを行う場合

Flyway のマイグレーションはデフォルトでは DB 設計を一から行って新規で作成するアプリケーションの場合が想定されている。 つまり初めは空のデータベースが用意されており V1__Initial.sql などというファイルを用意し初期テーブルを定義し、その後 V2__Add_delete_flag などといった感じで初期テーブルに変更を加えていくイメージだ。

だが「既にテーブルが定義されている DB に対しマイグレーションを行った場合」 (まだ flyway_schema_history が定義されておらず初期投入扱い) は以下の様にエラーが表示される:

Caused by: org.flywaydb.core.api.FlywayException: Found non-empty schema(s) "main" without schema history table! Use baseline() or set baselineOnMigrate to true to initialize the schema history table.

これを回避するためにベースライン (初期状態のバージョン番号) を Flyway に教える必要がある。 application.yaml に以下を定義する:

spring:
  flyway:
    baseline-on-migrate: true
    baseline-version: 1
    baseline-description: Initial

spring.flyway.baseline-on-migratetrue の場合既存 DB にマイグレーションを行った場合に spring.flyway.baseline-version まで適用済みとみなしてくれる。 つまり前述の例の場合 V1__Initial.sql は実行されずに V2__Add_delete_flag のみが適用される。 spring.flyway.baseline-description に定義した文字列はマイグレーション実行後に flyway_schema_history.description のベースラインまで適用されたことを示す文字列として挿入される。 初期値 (未定義の場合) は << Flyway Baseline >> のようなのでこのままでも問題ないと思われる。

このあたりの設定の解説は Flyway 公式に書いてある (英語)。

最初に注意として JDK は今のところ必ず 1.8 (Java 8) を使用すること。 JDK 9.0 を使用してしまうと以下の手順の中で原因不明のエラーが表示されてしまう。 Kotlin 側のプラグインもまだ 1.8 までしか用意されていない。

まず PostgreSQL の例

Spring Boot やその他 Java EE プロジェクトでデータベースを扱う際は JPA を使用することが多いと思うが、まずここでは IntelliJ IDEA を使用し Spring Boot で PostgreSQL を使用できる環境を作る手順を示す。 新規プロジェクトの Spring Initializr の依存関係で以下を選択する:

  • Web (Spring MVC)
  • Thymeleaf
  • JPA
  • PostgreSQL

この状態でアプリケーションクラスを実行すると以下のエラーとなる:

Failed to auto-configure a DataSource: 'spring.datasource.url' is not specified and no embedded datasource could be auto-configured.

エラーメッセージの通り application.yaml に以下を定義する:

spring:
  datasource:
    url: jdbc:postgresql://localhost/postgres
    driverClassName: org.postgresql.Driver
    username: postgres
    password: postgres

これで実行するとまたエラーとなる:

java.lang.reflect.InvocationTargetException: null
...(中略)...
Caused by: java.sql.SQLFeatureNotSupportedException: org.postgresql.jdbc.PgConnection.createClob() メソッドはまだ実装されていません。

これでは何だか分からないので Stack Overflow に聞いてみると application.yaml に以下の設定を追加すれば良いようだ:

spring:
  jpa:
    properties:
      hibernate:
        temp:
          use_jdbc_metadata_defaults: false
        dialect: org.hibernate.dialect.PostgreSQLDialect

この状態で再度アプリケーションクラスを実行し、先ほどのエラーが表示されずに正しく Tomcat が起動する事を確認する。

SQLite の場合

SQLite の場合 PostgreSQLDialect に値する SQLiteDialect といった実装が最初から用意されていない。 ただ、これに関しては既に作成して Maven リポジトリに上げている方がいらっしゃるのでありがたく使用させていただくことにする。 それと SQLite の JDBC ドライバも必要なので build.gradle に以下を追加する:

dependencies {
    compile('com.enigmabridge:hibernate4-sqlite-dialect:0.1.2')
    runtime('org.xerial:sqlite-jdbc')
}

これに従い application.yaml を以下に書き換える:

spring:
  datasource:
    # SQLite のファイル位置を指定
    url: jdbc:sqlite:./db.sqlite3
    driverClassName: org.sqlite.JDBC
  jpa:
    properties:
      hibernate:
        temp:
          use_jdbc_metadata_defaults: false
        dialect: com.enigmabridge.hibernate.dialect.SQLiteDialect

アプリケーションクラスを実行し、正しく Tomcat が立ち上がることを確認する。

簡単にテストしてみる

JPA の詳細に関しては検索すればいくらでも出てくるのでここには記載しない。 まず該当するテーブルが SQLite 内にあるものとして以下のような感じで Entity クラスを用意する:

@Entity
@Table(name = "post")
data class Post(
        @Id @GeneratedValue(strategy = GenerationType.AUTO) var id: Int = 0,
        var date: String = "",
        var name: String = "",
        var text: String = "",
        var enabled: Boolean = false,
        var created: Date = Date(),
        var modified: Date = Date()
)

Kotlin だと data classEntity が用意できるのでとても便利だ。 toString() を実装しなくてもいい感じにクラス内のデータを出力してくれる。 var にして意味のない初期値を与えなければならないところがちょっと格好悪いが仕方がないところだろうか。

対応する PostRepository クラスを以下のように定義する:

interface PostRepository : JpaRepository<Post, String> {
    fun findById(id: Int): Post
}

定義したリポジトリを使用してコントローラから実行してみる (本来は Service から実行するのが筋だがここでは例のため簡単にする):

@Controller
class SampleController {

    @Autowired
    lateinit var postRepository: PostRepository

    @GetMapping("/")
    fun sample(model: Model): String {
        val post = postRepository.findById(1)  // ID をキーにして 1 件取得
        System.out.println(post)  // Post の中身が出力される
        return "sample"
    }
}

特に意味はないがテンプレートが必要なので /resources/templates/sample.html として以下を用意:

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Sample</title>
</head>
<body>
<p>Hello World!!</p>
</body>
</html>

実行して http://localhost:8080 にアクセスし、標準出力に正しく DB 取得結果が表示されることを確認する。

Spring Web の例は前回行ったので、今回は Spring Batch での Hello World を実施する。

要件とやりたいこと

  • IntelliJ IDEA + Kotlin + Spring Boot 2.0.0 + Gradle
  • 複数の Job を作成しコマンドライン引数で実行を分ける
  • ユニットテストでの実行を行えるようにする

初期設定

IntelliJ IDEA の新規プロジェクトから Spring Initializr を選択。 Gradle Project で言語を Kotlin にしパッケージングは jar を選択。

依存関係のところでは勿論 Batch を入れるのだが Spring Batch はデータベースが使用できるようになっていないと実行できない。 その為対応する JDBC ドライバが必要なのでこれも導入する。 ここでは PostgreSQL がローカルに既にインストールされているものとする。 生成されたプロジェクトの build.gradle の依存関係は以下のようになる:

dependencies {
    compile('org.springframework.boot:spring-boot-starter-batch')
    compile("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    compile("org.jetbrains.kotlin:kotlin-reflect")
    runtime('org.postgresql:postgresql')
    testCompile('org.springframework.boot:spring-boot-starter-test')
    testCompile('org.springframework.batch:spring-batch-test')
}

この時点で Application クラスを実行すると以下のようにエラーが出力される:

Failed to auto-configure a DataSource: 'spring.datasource.url' is not specified and no embedded datasource could be auto-configured.

DB の URL 設定がなされていないというわけで、プロパティファイルより YAML で書いたほうが便宜がいいということですでにある application.properties を削除し application.yaml として以下を定義する:

spring:
  datasource:
  url: jdbc:postgresql://localhost/postgres
  username: postgres
  password: postgres
  driverClassName: org.postgresql.Driver

この状態で同様に実行し、まだバッチの実装をしていないので何も起きないがとりあえず上記のエラーが出ずに正常にアプリケーションの実行が終了することを確認する。

Tasklet の実装

ネットの情報を見てみると CommandLineRunner で実装する方法と Tasklet 若しくは Reader, Processor, Writer で逐次処理をする方法があるようだが、ここでは Spring Batch 公式の Quick Start に記載してある通り Tasklet を使用する。 以下のような BatchConfiguration クラスを定義する:

@Configuration
@EnableBatchProcessing
class BatchConfiguration(val jobBuilderFactory: JobBuilderFactory, val stepBuilderFactory: StepBuilderFactory) {

    @Component
    class Tasklet1 : Tasklet {
        override fun execute(contribution: StepContribution?, chunkContext: ChunkContext?): RepeatStatus {
            System.out.println("Hello World!")
            return RepeatStatus.FINISHED
        }
    }

    @Component
    class Tasklet2 : Tasklet {
        override fun execute(contribution: StepContribution?, chunkContext: ChunkContext?): RepeatStatus {
            System.out.println("Hello World2!")
            return RepeatStatus.FINISHED
        }
    }

    @Bean
    fun step1(): Step? = stepBuilderFactory.get("step1")?.tasklet(Tasklet1())?.build()

    @Bean
    fun step2(): Step? = stepBuilderFactory.get("step2")?.tasklet(Tasklet2())?.build()

    @Bean
    fun job1(): Job? = jobBuilderFactory.get("job1")?.start(step1())?.build()

    @Bean
    fun job2(): Job? = jobBuilderFactory.get("job2")?.start(step2())?.build()
}

従来の Spring だと、この場合の JobBuilderFactoryStepBuilderFactory を DI する為に @Autowired アノテーションを付けていたようだが、最近のバージョンだと付けなくても注入してくれるようだ。 勿論 @Autowired を付けても良い。

この例だと job1()job2() という 2 つのジョブが定義されていることになる。 ジョブは更にステップという単位に分割され順々に実行することができるようだが、ここでは 1 ジョブ 1 ステップ構成ということでそれぞれ step1()step2() を定義している。 また Tasklet の定義はラムダ式でも良いのだが、後述するが Tasklet 単位でのユニットテストを行いたいのであえてクラスとして定義している。

この状態で実行してみると以下のエラーが出る:

java.lang.IllegalStateException: Failed to execute CommandLineRunner
...(中略)...
Caused by: org.postgresql.util.PSQLException: ERROR: relation "batch_job_instance" does not exist

この batch_job_instance というテーブルは何なのだろうか。 調べてみると、どうも Spring Batch がジョブの実行状態を管理するために内部的に生成するメタデータらしい。 このメタデータのテーブルがまだ DB に無いため does not exist と言われてしまうわけだ。

このメタデータを生成したい場合は application.yaml に以下のように書けば良い:

spring:
  batch:
    initialize-schema: always

これで先ほどのように実行すれば問題なく動作するのだが、このメタデータで管理されているのが逆に煩わしく感じる。 多くのプロジェクトの場合ジョブの管理など不要で、実行したい時に実行できればそれでいいはずだ。 以下、このメタデータを使わなくてもバッチを実行できるようにする。

メタデータを使用しないでいい方法

これに関しては Spring Batchのメタデータテーブルを作らせない/使わせないが大変参考になった。 記事を参考に同様の実装を Kotlin で行う。 以下のような MyBatchConfigurer を定義する:

@Component
class MyBatchConfigurer : BatchConfigurer {

    private val transactionManager = ResourcelessTransactionManager()

    private val mapJobRepositoryFactoryBean = MapJobRepositoryFactoryBean(transactionManager).also { it.afterPropertiesSet() }

    private val jobRepository = mapJobRepositoryFactoryBean.`object`!!

    private val jobExplorer = MapJobExplorerFactoryBean(mapJobRepositoryFactoryBean).also { it.afterPropertiesSet() }.`object`!!

    private val jobLauncher = SimpleJobLauncher().also {
        it.setJobRepository(jobRepository)
        it.afterPropertiesSet()
    }

    override fun getJobRepository(): JobRepository = jobRepository

    override fun getJobLauncher(): JobLauncher = jobLauncher

    override fun getJobExplorer(): JobExplorer = jobExplorer

    override fun getTransactionManager(): PlatformTransactionManager = transactionManager
}

この状態で再度実行すると Tasklet1Tasklet2 の実装が呼び出され Hello World! と Hello World2! が出力される。

Tasklet のユニットテストを行う

以下のように @Autowired を使用して注入した Tasklet インスタンスに対して普通に実行してみる:

@RunWith(SpringRunner::class)
@SpringBootTest
class TestApplicationTests() {

    @Autowired
    lateinit var tasklet1: BatchConfiguration.Tasklet1

    @Autowired
    lateinit var tasklet2: BatchConfiguration.Tasklet2

    @Test
    fun tasklet1() {
        tasklet1.execute(null, null)
    }

    @Test
    fun tasklet2() {
        tasklet2.execute(null, null)
    }
}

こうすると以下のように 2 重に実行されてしまう:

Hello World!
Hello World2!
Hello World!
Hello World2!

Spring Boot Batch のデフォルトの挙動としてアプリケーションの main() が実行された時点ですべてのジョブを実行するので、その実行の後にこのテストケースでの各 Tasklet が実行されてしまう。 やりたいのは各 Tasklet の実行だけであり、アプリケーション起動時の全ジョブの実行は不要だ。 この実行を無効化するには application.yaml に以下のように書く:

spring:
  batch:
    job:
      enabled: false  # main() での全ジョブ実行を行わない

再度ユニットテストを実行し Hello World! が 2 重に表示されないことを確認する。

JAR から実行するジョブを分けたい

このアプリを JAR ファイルにする。 IntelliJ IDEA のサイドメニューの Gradle から Tasks -> build -> bootJar を選択すると JAR ファイルがビルドされプロジェクトの build/libs 下に置かれる。 これをコマンドラインから job1() だけ実行したい場合は以下のように呼び出す:

java -jar (JAR ファイルパス) --spring.batch.job.names=job1 --spring.batch.job.enabled=true

コマンドライン引数で application.yaml の設定を一時的に上書きすることができるので、もし先ほどの設定で spring.batch.job.enabled=false が指定されている場合は JAR ファイルを実行してもバッチが実行されないので spring.batch.job.enabled=true を明示的に渡すことで実行できる。

パッケージングを War にした場合

昨日の記事である Spring Boot を Kotlin で Hello Worldパッケージングを Jar でなく War でプロジェクトを新規作成した状態とする。 build.gradle の記述が少し変わるのだが、具体的には以下の部分だ:

apply plugin: 'war'

configurations {
    providedRuntime
}

dependencies {
    // ここをコメントアウトして Application を実行すると組み込みの Tomcat で実行できる
    providedRuntime('org.springframework.boot:spring-boot-starter-tomcat')
}

上記に書いた通り providedRuntime('org.springframework.boot:spring-boot-starter-tomcat') をコメントアウトすれば普通に Jar でビルドし組み込みの Tomcat で実行できる。 開発時はコメントアウトしておくのがいいだろう。 尚 War でプロジェクト作成した場合 ServletInitializer というクラスが増えているのが分かると思うが、これが無いと Tomcat で war ファイルをデプロイしても実行できない。

war ファイル作成

上記の状態で普通に Gradle のビルドを行うと build/libs 直下に war ファイルが作成される。 尚ファイル名は (プロジェクト名)-(Gradle に指定している version).war となるようだ。

Ubuntu 側の用意

今回はローカルの仮想環境に Ubuntu Server 16.04.3 LTS をセットアップしてあるものとする。 後今回は便宜上 Oracle の Java ではなく OpenJDK とする。

sudo apt install apache2 openjdk-8-jdk tomcat8 tomcat8-admin

これで Tomcat がインストールできたので http://(仮想環境の IP):8080/ にアクセスし Tomcat の稼働を意味するスタートページが表示されることを確認する。 仮想環境の場合ネットワークの設定を「ブリッジアダプタ」などホスト PC から IP が見える状態にしておかないといけないので注意する。

tomcat8-admin をインストールしたので http://(仮想環境の IP):8080/manager にアクセスすると Tomcat 管理画面に遷移することができるが、最初の認証の為のユーザを設定しなければならない。

sudo vi /etc/tomcat8/tomcat-users.xml

以下を追記する。 ユーザ名とパスワードは適宜強固なものに変更する。

<tomcat-users>
    ...
    <user username="admin" password="admin" roles="manager-gui,admin-gui"/>
</tomcat-users>

Tomcat を再起動し設定を反映する:

sudo service tomcat8 restart

これで http://(仮想環境の IP):8080/manager にアクセスし設定したユーザ名とパスワードを入力し認証、正しく管理画面が表示されるのを確認する。 更に管理画面上から前述の手順で作成した war ファイルをデプロイし、正しくアプリケーションのページが表示されるのを確認する。

Apache との連携

これは UbuntuでTomcat Apache連携がとても参考になった。 ほぼ記事のオウム返しになって恐縮であるが、まず以下で Tomcat の接続設定を変更する:

sudo vi /etc/tomcat8/server.xml

8080 ポートを閉じて proxy_ajp 連携用の 8009 版ポートを開く:

<!-- コメントアウト
<Connector port="8080" protocol="HTTP/1.1"
           connectionTimeout="20000"
           URIEncoding="UTF-8"
           redirectPort="8443" />
-->

<!-- コメントアウトを外す -->
<Connector port="8009" protocol="AJP/1.3" redirectPort="8443" />

次に sudo vi /etc/apache2/sites-available/000-default.conf し以下追記する:

<Location />
    ProxyPass ajp://localhost:8009/
    Order allow,deny
    Allow from all
</Location>

最後に proxy_ajp を有効にしそれぞれ再起動:

sudo a2enmod proxy_ajp
sudo service tomcat8 restart
sudo service apache2 restart

http://(仮想環境の IP)/(コンテキスト名) にアクセスし、正しく Spring Boot アプリケーションが表示されるのを確認する。 /etc/apache2/conf-available の方に設定を書いて sudo a2enconf xxx する方法もあるが、個人的にはバーチャルホストで設定を分ける時に今回記載した手順のほうが便利だと思った。

Spring Boot は Pleiades 適用済の IntelliJ IDEA でビルドするものとする。

IntelliJ IDEA で「新規 -> プロジェクト」を選択。 プロジェクトの種類は Spring Initializr (typo ではない) を選択。 プロジェクト JDK は今のところ 1.8 で行ったほうがいいようだ。 まだリリースされたばかりの Java 9 だと問題が出ることが多かった。 グループ、成果物は適当に入力。 型として Gradle Project を選択 (Maven でやりたい人は Maven Project を選択)、言語は Kotlin を選択。 パッケージングはこの時点では Jar を選択しておく。 依存関係だがとりあえずは以下の 3 点を入れておけば良い:

  • Core -> DevTools
  • Web -> Web
  • Template Engines -> Thymeleaf

尚、この画面の上部で Spring Boot のバージョンを選択できるのだが 1.5.7 ではなく 2.0.0.BUILD-SNAPSHOT を選択しておくのが良い。 Spring のバージョンが 5.0.x となり Kotlin に対応している。

プロジェクトを作成すると何やら Gradle の設定画面が出るがそのまま「次へ」を選択。 Gradle の依存関係ライブラリの取得が始まるので終わるまで待つ。 終わったらサンプルページを表示するためのコントローラとテンプレートを用意する。 Kotlin ディレクトリのどこかのディレクトリに以下のように SampleController を用意する:

@Controller
class SampleController {
    
    @GetMapping("/")
    fun sample(model: Model): String = "sample"
}

そして resources/templates 下に以下のように sample.html を用意する:

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Sample</title>
</head>
<body>
    <p>Hello World!!</p>
</body>
</html>

この状態で TestApplication.kt を右クリックして実行すると組み込みの Tomcat が起動して http://localhost:8080/ にアクセスすると Hello World!! が表示される。 尚、この時点だとテンプレートファイルを書き換えても TestApplication.kt を実行し直さないとブラウザ上の HTML が書きかわらない。 ホットデプロイに関しては過去記事 Spring Boot + IntelliJ IDEA でホットデプロイを参考に設定してほしい。

IntelliJ で Spring Boot を動作させる場合にホットデプロイの設定でハマる事例が後を絶たないらしく、情報を調べていても Spring Boot のバージョンもまちまちでなかなかこれといった解決策に辿り着かない。 休日に試行錯誤していたがとても苦戦したので備忘。 尚ビルドシステムとしては Gradle を使用することとする。

ちなみに私は Spring Boot は初めてなので理解が怪しいところがあるかもしれない。

新規 Spring Boot プロジェクト

Spring は 10 年以上前から存在する歴史ある Java のフレームワークだが、昔は Spring ですべて賄うというよりは Struts と Spring を組み合わせて構築するといったパターンが多かったように思う。 その後途中あたりから Spring MVC という Web アプリケーションを作成する為のパッケージが出てきていわゆる「XML 地獄」から開放され、その後に更に Spring の各パッケージを適切に組み合わせて簡単に使えるようにしたオールインワンパッケージのようなものが Spring Boot ……だと思う。

IntelliJ IDEA Ultimate で新規 Spring Boot プロジェクトを作成する時は「新規プロジェクトを作成する」から Spring Initializr を選ぶ。 「Spring」の方ではない。 こちらを選択すると Spring Boot ではなく通常の Spring になってしまうと思われる。 また「Initializr」は typo ではなく公式にこのスペルで書かれている。

まず最初の知として現時点では Java 9 は避けるのが望ましい。 どうも Gradle や Spring のバージョンによって Java 9 だと正しく動かないらしく原因不明のエラーに悩まされた。 どうしても使いたいと言うのでない限り Java 8 にしておくのが無難だ。 モダンなコードを書きたいのであれば Kotlin を採用する方法もある。 私は未検証だが Web 上に使用例が幾つかあるし IDE 上でも Kotlin の選択肢が示されている。

依存関係を選ぶ箇所ではとりあえず最小構成として「Web」と「Thymeleaf」を選択しておく。 Thymeleaf (タイムリーフ) は Spring で推奨されているテンプレートエンジンらしい。 JSP を書かされるよりは遥かにいい。

ホットデプロイのハマり箇所

さて、ホットデプロイである。 これが出来ないとソースコードを書き換える度に組み込みの Tomcat を再起動しなければ変更が反映されず非効率な開発を強いられることになる。 下記に自分がハマったことを列挙する。

Spring Loaded でなく Spring Boot DevTools を使う

「Spring Boot ホットデプロイ」で情報を探していると「Spring Loaded」という単語がヒットする。 これは Spring Boot v1.2 以前の 2 年前まで有効だった情報だ。 今は Spring Boot DevTools の方を使用する。 導入は build.gradle に以下を追加すればよい:

dependencies {
    compile('org.springframework.boot:spring-boot-devtools')
}

IntelliJ IDEA 管理下でなければこれだけでも動くのかもしれないが IntelliJ だとまだ足りない。 以下に進む。

自動的にビルドする設定とレジストリ

How to Use Spring Boot Live Reload with IntelliJ を参照 (英語)。 IntelliJ で以下の 2 点を設定する:

  1. Settings -> Build, Execution, Deployment -> Compiler -> Make project automatically にチェックを入れる。これをしていないとソースコードを変更しても自動ビルドされない
  2. SHIFT + CTRL + A で出てくるウィンドウで Registry を検索し compiler.automake.allow.when.app.running にチェックを入れる。尚 CTRL + SHIFT + ALT + / でも Registry を選択できた。

さて、ここまで実行して gradlew.bat bootRun を実行 (もしくは IntelliJ IDEA 上で Gradle タスク実行) してみる。 やはりソースコードもテンプレートも変更が即反映されない。 そこで以下の設定を行う。

IntelliJ 用に自動ビルド時の出力先ディレクトリを調整

デフォルトで Spring Boot DevTools によって /build/classes/main//build/resources/main/ 以下の変更が監視され即反映されるのだが IntelliJ が自動ビルドした時のクラスファイルの保存先がこのディレクトリになっていない。 よって build.gradle に以下の記述を追加する:

apply plugin: 'idea'

idea {
    module {
        inheritOutputDirs = false
        outputDir = file("$buildDir/classes/main/")
    }
}

上記は Gradle のバージョンが 3.x.x の例なのだが Gradle のバージョンを 4.2.1 などに上げるとディレクトリ構造が異なってしまいうまく動かなくなってしまうので注意。 $buildDir/classes/main/ でなく $buildDir/classes/java/main/ などといったように /java サブディレクトリが増えている。 恐らく Kotlin 対応のためだろう。 この先またディレクトリが変更されたとしても /build/classes 以下の構造をよく見て同じように指定してやれば良い。

これでソースコードのホットデプロイはできるようになった。 ホットデプロイに成功した場合はコンソールのログに再デプロイのログが流れるので分かりやすい。

Thymeleaf テンプレートの反映に失敗する場合

特にこれがハマった。 application.propertiesspring.thymeleaf.cache=false を指定するという情報もあるが、そもそも DevTools ではこの設定がデフォルトになっているようで不要のようだ。

結論から言うとコンソールから gradlew.bat bootRun 実行 (または IntelliJ 上からの同様の操作) を行った場合はテンプレートファイルを変更しても全く反映されない。 @SpringBootApplication アノテーションが付与された main() を実行 (つまりクラスファイルを右クリックして実行など) すると同様にビルトインされた Tomcat サーバが起動するが、こちらだと何故かテンプレートファイルが即時反映される。 よく分からないがこういうもののようだ。