かんがるーさんの日記

最近自分が興味をもったものを調べた時の手順等を書いています。今は Spring Boot をいじっています。

Spring Boot 1.4.x の Web アプリを 1.5.x へバージョンアップする ( その3 )( Run 'All Tests' with Coverage 実行時に出るエラーを解消する )

概要

記事一覧はこちらです。

Spring Boot 1.4.x の Web アプリを 1.5.x へバージョンアップする ( その1 )( 概要 ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • Run ‘All Tests’ with Coverage 実行時に出るエラーの解消

参照したサイト・書籍

目次

  1. java.lang.IllegalStateException: Failed to load ApplicationContext
  2. 次回は。。。

手順

java.lang.IllegalStateException: Failed to load ApplicationContext

java.lang.IllegalStateException: Failed to load ApplicationContext の下に出力されるメッセージの内、Caused by: から始まるエラーメッセージは以下のものでした。

  • Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'webSecurityConfig': Unsatisfied dependency expressed through method 'setContentNegotationStrategy' parameter 0; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'org.springframework.boot.autoconfigure.web.WebMvcAutoConfiguration$EnableWebMvcConfiguration': Invocation of init method failed; nested exception is java.lang.IllegalStateException: Found unexpected validator configuration. A Spring Boot MVC validator should be registered as bean named 'mvcValidator' and not returned from WebMvcConfigurer.getValidator()
  • Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'org.springframework.boot.autoconfigure.web.WebMvcAutoConfiguration$EnableWebMvcConfiguration': Invocation of init method failed; nested exception is java.lang.IllegalStateException: Found unexpected validator configuration. A Spring Boot MVC validator should be registered as bean named 'mvcValidator' and not returned from WebMvcConfigurer.getValidator()
  • Caused by: java.lang.IllegalStateException: Found unexpected validator configuration. A Spring Boot MVC validator should be registered as bean named 'mvcValidator' and not returned from WebMvcConfigurer.getValidator()

Spring Boot MVC validator は mvcValidator という名前の Bean で登録されているべきなのに、WebMvcConfigurer.getValidator() が返さない、ということのようです。

例外に出力されているソースを追ってみると、org.springframework.boot.autoconfigure.web.WebMvcAutoConfiguration.EnableWebMvcConfiguration#afterPropertiesSet でこのメッセージを出力していることが確認できます。getValidator() == null でないと Assert に引っかかります。

f:id:ksby:20170516055744p:plain

getValidator() は org.springframework.web.servlet.config.annotation.DelegatingWebMvcConfiguration#getValidator のことで、以下のコードです。

f:id:ksby:20170516060229p:plain

configurers.getValidator() は org.springframework.web.servlet.config.annotation.WebMvcConfigurerComposite#getValidator のことで、以下のコードです。このメソッドが null を返さないのでエラーになるようですね。

f:id:ksby:20170516060625p:plain

デバッガで確認してみると、確かに null ではなく LocalValidatorFactoryBean を返しています。

f:id:ksby:20170516061605p:plain

f:id:ksby:20170516061206p:plain

自分で実装したコードを見ると、確かに Validator インターフェースの Bean は mvcValidator ではなく validator という名前にしています。

f:id:ksby:20170516062010p:plain

そして validator Bean を ksbysample.webapp.lending.config.WebMvcConfig#getValidator で返しています。

f:id:ksby:20170516062154p:plain

Spring Boot 1.3.x の Web アプリを 1.4.x へバージョンアップする ( その20 )( 気になった点を修正する ) の記事で ValidationMessages_ja_JP.properties をやめて messages.properties に1本化するために書いたものでした。

ここまでの調査から対応方法を考えると、以下のようにすれば良い気がします。

  • ksbysample.webapp.lending.config.WebMvcConfig#getValidator は null を返さないといけないようなので、ksbysample.webapp.lending.config.WebMvcConfig 自体は削除する。
  • validator Bean ではなく mvcValidator Bean にする。

“mvcValidator” で検索してみると org.springframework.boot.autoconfigure.web.WebMvcAutoConfiguration.EnableWebMvcConfiguration#mvcValidator に mvcValidator Bean を取得して mvcValidator Bean を生成する?、というよく分からないコードがありました。

f:id:ksby:20170516063841p:plain

Spring Boot 1.4.6 を使用している時のコードに戻してみると、org.springframework.boot.autoconfigure.web.WebMvcAutoConfiguration.EnableWebMvcConfiguration#mvcValidator は 1.5.3 と同じでした。

f:id:ksby:20170516070741p:plain

org.springframework.boot.autoconfigure.web.WebMvcAutoConfiguration.EnableWebMvcConfiguration#afterPropertiesSet は存在しませんでした。

おそらく 1.4 の頃から mvcValidator という名前の Bean として登録しておけばよいだけで、WebMvcConfigurerAdapter の継承クラスで getValidator メソッドを Override する必要はなかった、のではないかと思います。

上に書いた2点の対応方法で良さそうなので、コードを修正します。

まず src/main/java/ksbysample/webapp/lending/config/WebMvcConfig.java を削除します。

次に src/main/java/ksbysample/webapp/lending/config/ApplicationConfig.javaリンク先の内容 に変更します。

最後に以下のクラスのフィールド private final Validator validator;private final Validator mvcValidator; に変更します。

  • src/main/java/ksbysample/webapp/lending/web/springmvcmemo/BeanValidationGroupController.java
  • src/test/java/ksbysample/webapp/lending/values/validation/ValuesEnumValidatorTest.java

コードを修正したら clean タスク → Rebuild Project → build タスクを実行してみます。今度は “BUILD SUCCESSFUL” が表示されて成功しました。

f:id:ksby:20170517002651p:plain

Project Tool Window の src/test から「Run ‘All Tests’ with Coverage」も実行してみます。こちらも無事全てのテストが成功しました。

f:id:ksby:20170517003210p:plain

Hibernate Validator を使用しているところで messages.properties に記述した日本語のメッセージが使用されるか確認します。

bootRun で Tomcat を起動した後、Hibernate Validator の @NotBlank アノテーションで入力チェックをしている http://localhost:8080/springMvcMemo/beanValidationGroup にアクセスします。

f:id:ksby:20170520143025p:plain

「データ更新」ボタンをクリックすると、日本語のメッセージ「必須の入力項目です。」が表示されました。

f:id:ksby:20170520143232p:plain

mvcValidator Bean をコメントアウトしてから Tomcat を再起動して同じ操作をしてみると、

f:id:ksby:20170520143455p:plain

今度は英語のメッセージが表示されました。mvcValidator Bean を定義するだけで messages.properties の日本語メッセージが使用されるようになるようです。

f:id:ksby:20170520143722p:plain

次回は。。。

1.5 系ではこう書くべきという点があるか確認し、変更した方がよいところを修正します。

ソースコード

ApplicationConfig.java

    @Bean
    public Validator mvcValidator() {
        LocalValidatorFactoryBean localValidatorFactoryBean = new LocalValidatorFactoryBean();
        localValidatorFactoryBean.setValidationMessageSource(this.messageSource);
        return localValidatorFactoryBean;
    }
  • メソッド名を validatormvcValidator に変更します。

履歴

2017/05/17
初版発行。
2017/05/20
* http://localhost:8080/springMvcMemo/beanValidationGroup での動作確認を追加しました。

Spring Boot 1.4.x の Web アプリを 1.5.x へバージョンアップする ( その2 )( build.gradle の修正 )

概要

記事一覧はこちらです。

Spring Boot 1.4.x の Web アプリを 1.5.x へバージョンアップする ( その1 )( 概要 ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • build.gradle の修正

参照したサイト・書籍

目次

  1. 1.5.x ブランチの作成
  2. Spring Initializr で 1.5.3 のプロジェクトを作成する
  3. build.gradle を修正して build してみる
  4. 次回は。。。

手順

1.5.x ブランチの作成

  1. master から 1.5.x ブランチを、1.5.x から feature/130-issue ブランチを作成します。

Spring Initializr で 1.5.3 のプロジェクトを作成する

  1. IntelliJ IDEA のメインメニューから「File」-「Close Project」を選択して「Welcome to IntelliJ IDEA」ダイアログに戻ります。

  2. 「Welcome to IntelliJ IDEA」ダイアログで「Create New Project」をクリックします。

    f:id:ksby:20170513204210p:plain

  3. 「New Project」ダイアログが表示されます。画面左側のリストから「Spring Initializr」を選択した後、「Next」ボタンをクリックします。

    f:id:ksby:20170513204405p:plain

  4. 次の画面が表示されます。「Type」で「Gradle Project」を選択した後、「Next」ボタンをクリックします。

    f:id:ksby:20170513204637p:plain

  5. 次の画面が表示されます。画面中央上の「Spring Boot」で「1.5.3」を選択してから ksbysample-webapp-lending プロジェクトで使用している以下の項目をチェックした後、「Next」ボタンをクリックします。

    f:id:ksby:20170513204953p:plain f:id:ksby:20170513205100p:plain f:id:ksby:20170513205253p:plain f:id:ksby:20170513205434p:plain f:id:ksby:20170513205553p:plain

  6. 次の画面が表示されます。「Project location」を “C:\project-springboot\demo” に変更した後、「Finish」ボタンをクリックします。

    f:id:ksby:20170513210129p:plain

  7. 「Import Module from Gradle」ダイアログが表示されます。「Create directories for empty content roots automatically」をチェックした後、「OK」ボタンをクリックします。

    f:id:ksby:20170513210308p:plain

これでプロジェクトが作成されて以下の build.gradle が作成されました。

buildscript {
    ext {
        springBootVersion = '1.5.3.RELEASE'
    }
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'

version = '0.0.1-SNAPSHOT'
sourceCompatibility = 1.8

repositories {
    mavenCentral()
}


dependencies {
    compile('org.springframework.boot:spring-boot-starter-data-redis')
    compile('org.springframework.boot:spring-boot-starter-freemarker')
    compile('org.springframework.boot:spring-boot-starter-mail')
    compile('org.springframework.boot:spring-boot-starter-security')
    compile('org.springframework.session:spring-session')
    compile('org.springframework.boot:spring-boot-starter-thymeleaf')
    compile('org.springframework.boot:spring-boot-starter-web')
    runtime('org.springframework.boot:spring-boot-devtools')
    compileOnly('org.projectlombok:lombok')
    testCompile('org.springframework.boot:spring-boot-starter-test')
}

1.4.4 で生成した時と異なる点は特にありませんでした。今回は何も反映しません。

build.gradle を修正して build してみる

  1. build.gradle を リンク先の内容 に変更します。

  2. Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。今回は正常に更新できました。

  3. clean タスク実行 → Rebuild Project 実行 を実行します。こちらも正常に終了しました。

  4. build タスクを実行します。が、大量の java.lang.IllegalStateException が発生してテストが全く終わりませんでした。。。

    f:id:ksby:20170513235010p:plain

  5. Project Tool Window の src/test から「Run ‘All Tests’ with Coverage」も実行してみます。

    こちらも java.lang.IllegalStateException: Failed to load ApplicationContext というエラーメッセージが出てテストは失敗しました。ほとんどのテストが失敗していたので、途中で中断しています。

    f:id:ksby:20170513235559p:plain

次回は。。。

Run ‘All Tests’ with Coverage 実行時に出る java.lang.IllegalStateException: Failed to load ApplicationContext のエラーの解消→ build タスクの再実行(エラーが出れば解消します)の順で進める予定です。

ソースコード

build.gradle

group 'ksbysample'
version '1.5.3-RELEASE'

buildscript {
    ext {
        springBootVersion = '1.5.3.RELEASE'
    }
    repositories {
        jcenter()
        maven { url "http://repo.spring.io/repo/" }
        maven { url "https://plugins.gradle.org/m2/" }
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
        // for Error Prone ( http://errorprone.info/ )
        classpath("net.ltgt.gradle:gradle-errorprone-plugin:0.0.10")
        // for Grgit
        classpath("org.ajoberstar:grgit:1.9.2")
        // Gradle Download Task
        classpath("de.undercouch:gradle-download-task:3.2.0")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'idea'
apply plugin: 'org.springframework.boot'
apply plugin: 'de.undercouch.download'
apply plugin: 'groovy'
apply plugin: 'net.ltgt.errorprone'
apply plugin: 'checkstyle'
apply plugin: 'findbugs'

sourceCompatibility = 1.8
targetCompatibility = 1.8

task wrapper(type: Wrapper) {
    gradleVersion = '2.13'
}

[compileJava, compileTestGroovy, compileTestJava]*.options*.compilerArgs = ['-Xlint:all,-options,-processing,-path']
compileJava.options.compilerArgs += ['-Xep:RemoveUnusedImports:WARN']

// for Doma 2
// JavaクラスとSQLファイルの出力先ディレクトリを同じにする
processResources.destinationDir = compileJava.destinationDir
// コンパイルより前にSQLファイルを出力先ディレクトリにコピーするために依存関係を逆転する
compileJava.dependsOn processResources

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

configurations {
    // for Doma 2
    domaGenRuntime
}

checkstyle {
    configFile = file("${rootProject.projectDir}/config/checkstyle/google_checks.xml")
    toolVersion = '7.7'
    sourceSets = [project.sourceSets.main]
}

findbugs {
    toolVersion = '3.0.1'
    sourceSets = [project.sourceSets.main]
    ignoreFailures = true
    effort = "max"
    excludeFilter = file("${rootProject.projectDir}/config/findbugs/findbugs-exclude.xml")
}

tasks.withType(FindBugs) {
    reports {
        xml.enabled = false
        html.enabled = true
    }
}

repositories {
    jcenter()
}

dependencyManagement {
    imports {
        mavenBom("io.spring.platform:platform-bom:Brussels-SR2") {
            bomProperty 'guava.version', '21.0'
        }
    }
}

bootRepackage {
    mainClass = 'ksbysample.webapp.lending.Application'
    excludeDevtools = true
}

dependencies {
    def jdbcDriver = "org.postgresql:postgresql:42.0.0"
    def spockVersion = "1.1-groovy-2.4"
    def domaVersion = "2.16.0"
    def lombokVersion = "1.16.16"
    def errorproneVersion = "2.0.15"

    // dependency-management-plugin によりバージョン番号が自動で設定されるもの
    // Appendix A. Dependency versions ( http://docs.spring.io/platform/docs/current/reference/htmlsingle/#appendix-dependency-versions ) 参照
    compile("org.springframework.boot:spring-boot-starter-web")
    compile("org.springframework.boot:spring-boot-starter-thymeleaf")
    compile("org.thymeleaf.extras:thymeleaf-extras-springsecurity4")
    compile("org.thymeleaf.extras:thymeleaf-extras-java8time")
    compile("org.springframework.boot:spring-boot-starter-data-jpa")
    compile("org.springframework.boot:spring-boot-starter-freemarker")
    compile("org.springframework.boot:spring-boot-starter-mail")
    compile("org.springframework.boot:spring-boot-starter-security")
    compile("org.springframework.boot:spring-boot-starter-data-redis")
    compile("org.springframework.boot:spring-boot-starter-amqp")
    compile("org.springframework.boot:spring-boot-devtools")
    compile("org.springframework.session:spring-session")
    compile("org.springframework.retry:spring-retry")
    compile("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
    compile("com.fasterxml.jackson.dataformat:jackson-dataformat-xml")
    compile("com.google.guava:guava")
    compile("org.apache.commons:commons-lang3")
    compile("org.codehaus.janino:janino")
    testCompile("org.springframework.boot:spring-boot-starter-test")
    testCompile("org.springframework.security:spring-security-test")
    testCompile("org.yaml:snakeyaml")
    testCompile("org.mockito:mockito-core")

    // dependency-management-plugin によりバージョン番号が自動で設定されないもの、あるいは最新バージョンを指定したいもの
    runtime("${jdbcDriver}")
    compile("com.integralblue:log4jdbc-spring-boot-starter:1.0.1")
    compile("org.simpleframework:simple-xml:2.7.1")
    compile("com.univocity:univocity-parsers:2.4.1")
    testCompile("org.dbunit:dbunit:2.5.3")
    testCompile("com.icegreen:greenmail:1.5.4")
    testCompile("org.assertj:assertj-core:3.7.0")
    testCompile("com.jayway.jsonpath:json-path:2.2.0")
    testCompile("org.spockframework:spock-core:${spockVersion}") {
        exclude module: "groovy-all"
    }
    testCompile("org.spockframework:spock-spring:${spockVersion}") {
        exclude module: "groovy-all"
    }
    testCompile("com.google.code.findbugs:jsr305:3.0.2")

    // for lombok
    compileOnly("org.projectlombok:lombok:${lombokVersion}")
    testCompileOnly("org.projectlombok:lombok:${lombokVersion}")

    // for Doma
    compile("org.seasar.doma:doma:${domaVersion}")
    domaGenRuntime("org.seasar.doma:doma-gen:${domaVersion}")
    domaGenRuntime("${jdbcDriver}")

    // for Error Prone ( http://errorprone.info/ )
    errorprone("com.google.errorprone:error_prone_core:${errorproneVersion}")
    compileOnly("com.google.errorprone:error_prone_annotations:${errorproneVersion}")
}

bootRun {
    jvmArgs = ['-Dspring.profiles.active=develop']
}

test {
    jvmArgs = ['-Dspring.profiles.active=unittest']
}

// for Doma-Gen
task domaGen << {
    // まず変更が必要なもの
    def rootPackageName  = 'ksbysample.webapp.lending'
    def daoPackagePath   = 'src/main/java/ksbysample/webapp/lending/dao'
    def dbUrl            = 'jdbc:postgresql://localhost/ksbylending'
    def dbUser           = 'ksbylending_user'
    def dbPassword       = 'xxxxxxxx'
    def tableNamePattern = '.*'
    // おそらく変更不要なもの
    def importOfComponentAndAutowiredDomaConfig = "${rootPackageName}.util.doma.ComponentAndAutowiredDomaConfig"
    def workDirPath      = 'work'
    def workDaoDirPath   = "${workDirPath}/dao"

    // 作業用ディレクトリを削除する
    clearDir("${workDirPath}")

    // 現在の Dao インターフェースのバックアップを取得する
    copy() {
        from "${daoPackagePath}"
        into "${workDaoDirPath}/org"
    }

    // Dao インターフェース、Entity クラスを生成する
    ant.taskdef(resource: 'domagentask.properties',
            classpath: configurations.domaGenRuntime.asPath)
    ant.gen(url: "${dbUrl}", user: "${dbUser}", password: "${dbPassword}", tableNamePattern: "${tableNamePattern}") {
        entityConfig(packageName: "${rootPackageName}.entity", useListener: false)
        daoConfig(packageName: "${rootPackageName}.dao")
        sqlConfig()
    }

    // 生成された Dao インターフェースを作業用ディレクトリにコピーし、
    // @ComponentAndAutowiredDomaConfig アノテーションを付加する
    copy() {
        from "${daoPackagePath}"
        into "${workDaoDirPath}/replace"
        filter {
            line -> line.replaceAll('import org.seasar.doma.Dao;', "import ${importOfComponentAndAutowiredDomaConfig};\nimport org.seasar.doma.Dao;")
                    .replaceAll('@Dao', '@Dao\n@ComponentAndAutowiredDomaConfig')
        }
    }

    // @ComponentAndAutowiredDomaConfig アノテーションを付加した Dao インターフェースを
    // dao パッケージへ戻す
    copy() {
        from "${workDaoDirPath}/replace"
        into "${daoPackagePath}"
    }

    // 元々 dao パッケージ内にあったファイルを元に戻す
    copy() {
        from "${workDaoDirPath}/org"
        into "${daoPackagePath}"
    }

    // 作業用ディレクトリを削除する
    clearDir("${workDirPath}")

    // 自動生成したファイルを git add する
    addGit()
}

task downloadCssFontsJs << {
    def staticDirPath   = 'src/main/resources/static'
    def workDirPath     = 'work'
    def adminLTEVersion     = '2.2.0'
    def jQueryVersion       = '2.1.4'
    def fontAwesomeVersion  = '4.3.0'
    def ioniconsVersion     = '2.0.1'
    def html5shivJsVersion  = '3.7.2'
    def respondMinJsVersion = '1.4.2'

    // 作業用ディレクトリを削除する
    clearDir("${workDirPath}")

    // Bootstrap & AdminLTE Dashboard & Control Panel Template
    downloadAdminLTE("${adminLTEVersion}", "${jQueryVersion}", "${workDirPath}", "${staticDirPath}")

    // Font Awesome Icons
    downloadFontAwesome("${fontAwesomeVersion}", "${workDirPath}", "${staticDirPath}")

    // Ionicons
    downloadIonicons("${ioniconsVersion}", "${workDirPath}", "${staticDirPath}")

    // html5shiv.js
    downloadHtml5shivJs("${html5shivJsVersion}", "${workDirPath}", "${staticDirPath}")

    // respond.min.js
    downloadRespondMinJs("${respondMinJsVersion}", "${workDirPath}", "${staticDirPath}")

    // fileinput.min.js ( v4.2.7 )
    downloadBootstrapFileInputMinJs("${workDirPath}", "${staticDirPath}")

    // 作業用ディレクトリを削除する
    clearDir("${workDirPath}")

    // 追加したファイルを git add する
    addGit()
}

task printClassWhatNotMakeTest << {
    def srcDir = new File("src/main/java")
    def excludePaths = [
            "src/main/java/ksbysample/webapp/lending/Application.java"
            , "src/main/java/ksbysample/webapp/lending/config"
            , "src/main/java/ksbysample/webapp/lending/cookie"
            , "src/main/java/ksbysample/webapp/lending/dao"
            , "src/main/java/ksbysample/webapp/lending/entity"
            , "src/main/java/ksbysample/webapp/lending/exception"
            , "src/main/java/ksbysample/webapp/lending/helper/download/booklistcsv"
            , "src/main/java/ksbysample/webapp/lending/helper/download/DataDownloadHelper.java"
            , "src/main/java/ksbysample/webapp/lending/helper/page/PagenationHelper.java"
            , "src/main/java/ksbysample/webapp/lending/security/LendingUser.java"
            , "src/main/java/ksbysample/webapp/lending/security/RoleAwareAuthenticationSuccessHandler.java"
            , "src/main/java/ksbysample/webapp/lending/service/calilapi/response"
            , "src/main/java/ksbysample/webapp/lending/service/file/BooklistCSVRecord.java"
            , "src/main/java/ksbysample/webapp/lending/service/openweathermapapi"
            , "src/main/java/ksbysample/webapp/lending/service/queue/InquiringStatusOfBookQueueMessage.java"
            , "src/main/java/ksbysample/webapp/lending/util/doma"
            , "src/main/java/ksbysample/webapp/lending/util/velocity/VelocityUtils.java"
            , "src/main/java/ksbysample/webapp/lending/values/validation/ValuesEnum.java"
            , "src/main/java/ksbysample/webapp/lending/view/BookListCsvView.java"
            , "src/main/java/ksbysample/webapp/lending/web/.+/.+Service.java"
            , "src/main/java/ksbysample/webapp/lending/webapi/common/CommonWebApiResponse.java"
            , "src/main/java/ksbysample/webapp/lending/webapi/weather"
    ]
    def excludeFileNamePatterns = [
            ".*EventListener.java"
            , ".*Dto.java"
            , ".*Form.java"
            , ".*Values.java"
    ]

    compareSrcAndTestDir(srcDir, excludePaths, excludeFileNamePatterns)
}

/* -----------------------------------------------------------------------------
 * メソッド定義部
 ---------------------------------------------------------------------------- */
void clearDir(String dirPath) {
    delete dirPath
}

void addGit() {
    def grgit = org.ajoberstar.grgit.Grgit.open(dir: project.projectDir)
    grgit.add(patterns: ['.'])
}

void downloadAdminLTE(String adminLTEVersion, String jQueryVersion, String workDirPath, String staticDirPath) {
    download {
        src "https://codeload.github.com/almasaeed2010/AdminLTE/zip/v${adminLTEVersion}"
        dest new File("${workDirPath}/download/AdminLTE-${adminLTEVersion}.zip")
    }
    copy {
        from zipTree("${workDirPath}/download/AdminLTE-${adminLTEVersion}.zip")
        into "${workDirPath}/unzip"
    }
    copy {
        from "${workDirPath}/unzip/AdminLTE-${adminLTEVersion}/bootstrap/css"
        into "${staticDirPath}/css"
    }
    copy {
        from "${workDirPath}/unzip/AdminLTE-${adminLTEVersion}/bootstrap/fonts"
        into "${staticDirPath}/fonts"
    }
    copy {
        from "${workDirPath}/unzip/AdminLTE-${adminLTEVersion}/bootstrap/js"
        into "${staticDirPath}/js"
    }
    copy {
        from "${workDirPath}/unzip/AdminLTE-${adminLTEVersion}/dist/css"
        into "${staticDirPath}/css"
    }
    copy {
        from "${workDirPath}/unzip/AdminLTE-${adminLTEVersion}/dist/js"
        into "${staticDirPath}/js"
    }
    copy {
        from "${workDirPath}/unzip/AdminLTE-${adminLTEVersion}/plugins/jQuery/jQuery-${jQueryVersion}.min.js"
        into "${staticDirPath}/js"
    }
    delete "${staticDirPath}/js/pages"
    delete "${staticDirPath}/js/demo.js"
}

void downloadFontAwesome(String fontAwesomeVersion, String workDirPath, String staticDirPath) {
    download {
        src "http://fortawesome.github.io/Font-Awesome/assets/font-awesome-${fontAwesomeVersion}.zip"
        dest new File("${workDirPath}/download/font-awesome-${fontAwesomeVersion}.zip")
    }
    copy {
        from zipTree("${workDirPath}/download/font-awesome-${fontAwesomeVersion}.zip")
        into "${workDirPath}/unzip"
    }
    copy {
        from "${workDirPath}/unzip/font-awesome-${fontAwesomeVersion}/css/font-awesome.min.css"
        into "${staticDirPath}/css"
    }
    copy {
        from "${workDirPath}/unzip/font-awesome-${fontAwesomeVersion}/fonts"
        into "${staticDirPath}/fonts"
    }
}

void downloadIonicons(String ioniconsVersion, String workDirPath, String staticDirPath) {
    download {
        src "https://codeload.github.com/driftyco/ionicons/zip/v${ioniconsVersion}"
        dest new File("${workDirPath}/download/ionicons-${ioniconsVersion}.zip")
    }
    copy {
        from zipTree("${workDirPath}/download/ionicons-${ioniconsVersion}.zip")
        into "${workDirPath}/unzip"
    }
    copy {
        from "${workDirPath}/unzip/ionicons-${ioniconsVersion}/css/ionicons.min.css"
        into "${staticDirPath}/css"
    }
    copy {
        from "${workDirPath}/unzip/ionicons-${ioniconsVersion}/fonts"
        into "${staticDirPath}/fonts"
    }
}

void downloadHtml5shivJs(String html5shivJsVersion, String workDirPath, String staticDirPath) {
    download {
        src "https://oss.maxcdn.com/html5shiv/${html5shivJsVersion}/html5shiv.min.js"
        dest new File("${workDirPath}/download/html5shiv.min.js")
    }
    copy {
        from "${workDirPath}/download/html5shiv.min.js"
        into "${staticDirPath}/js"
    }
}

void downloadRespondMinJs(String respondMinJsVersion, String workDirPath, String staticDirPath) {
    download {
        src "https://oss.maxcdn.com/respond/${respondMinJsVersion}/respond.min.js"
        dest new File("${workDirPath}/download/respond.min.js")
    }
    copy {
        from "${workDirPath}/download/respond.min.js"
        into "${staticDirPath}/js"
    }
}

void downloadBootstrapFileInputMinJs(String workDirPath, String staticDirPath) {
    download {
        src "https://github.com/kartik-v/bootstrap-fileinput/zipball/master"
        dest new File("${workDirPath}/download/kartik-v-bootstrap-fileinput.zip")
    }
    copy {
        from zipTree("${workDirPath}/download/kartik-v-bootstrap-fileinput.zip")
        into "${workDirPath}/unzip"
    }
    copy {
        from "${workDirPath}/unzip/kartik-v-bootstrap-fileinput-883d8b6/js/fileinput.min.js"
        into "${staticDirPath}/js"
    }
    copy {
        from "${workDirPath}/unzip/kartik-v-bootstrap-fileinput-883d8b6/js/fileinput_locale_ja.js"
        into "${staticDirPath}/js"
    }
    copy {
        from "${workDirPath}/unzip/kartik-v-bootstrap-fileinput-883d8b6/css/fileinput.min.css"
        into "${staticDirPath}/css"
    }
}

def compareSrcAndTestDir(srcDir, excludePaths, excludeFileNamePatterns) {
    def existFlg
    def testDirAndTestClassNameList = [
            ["src/test/java", "Test.java"]
            , ["src/test/groovy", "Test.groovy"]
    ]

    for (srcFile in srcDir.listFiles()) {
        String srcFilePath = (srcFile.toPath() as String).replaceAll("\\\\", "/")
        existFlg = false

        for (exclude in excludePaths) {
            if (srcFilePath =~ /^${exclude as String}/) {
                existFlg = true
                break
            }
        }
        if (existFlg == true) continue

        for (exclude in excludeFileNamePatterns) {
            if (srcFilePath =~ /${exclude as String}/) {
                existFlg = true
                break
            }
        }
        if (existFlg == true) continue

        if (srcFile.isDirectory()) {
            compareSrcAndTestDir(srcFile, excludePaths, excludeFileNamePatterns)
        } else {
            def nextFlg = false
            for (testDirAndTestClassName in testDirAndTestClassNameList) {
                def testDir = testDirAndTestClassName[0]
                def testClassName = testDirAndTestClassName[1]

                String testFilePath = srcFilePath.replaceFirst(/^src\/main\/java/, testDir)
                        .replaceFirst(/\.java$/, testClassName)
                def testFile = new File(testFilePath)
                if (testFile.exists()) {
                    nextFlg = true
                    break
                }
            }
            if (nextFlg) continue

            println(srcFilePath)
        }
    }
}
  • Spring Boot のバージョンアップ対応として以下の点を変更します。
    • version '1.4.6-RELEASE'version '1.5.3-RELEASE' に変更します。
    • buildscript の以下の点を変更します。
      • springBootVersion = '1.4.6.RELEASE'springBootVersion = '1.5.3.RELEASE' に変更します。
    • dependencyManagement の以下の点を変更します。
      • mavenBom("io.spring.platform:platform-bom:Athens-SR5")mavenBom("io.spring.platform:platform-bom:Athens-SR5") に変更します。
  • ライブラリを最新バージョンにアップデートするために以下の点を変更します。
    • def spockVersion = "1.1-groovy-2.4-rc-4"def spockVersion = "1.1-groovy-2.4" に変更します。
    • testCompile("org.assertj:assertj-core:3.6.2")testCompile("org.assertj:assertj-core:3.7.0") に変更します。

履歴

2017/05/14
初版発行。

Spring Boot 1.4.x の Web アプリを 1.5.x へバージョンアップする ( その1 )( 概要 )

概要

記事一覧はこちらです。

  • 「Spring Boot で書籍の貸出状況確認・貸出申請する Web アプリケーションを作る」で作成した Web アプリケーション ( ksbysample-webapp-lending ) の Spring Boot のバージョンを 1.4.6 → 1.5.3 へバージョンアップします。
  • 進め方は以下の方針とします。
    • Git のブランチは 1.5.x を作成して、そちらで作業します。Spring Boot のバージョンと合わせます。
    • 最初に build.gradle を修正します。
      • Spring Boot のバージョン番号を 1.5.3 に、Spring IO Platform の BOM を Brussels-SR2 にします。
      • Spring Initializr で 1.5.3 のプロジェクトを作成して、修正した方がよさそうな点があれば反映します。
      • ライブラリは最新バージョンにアップデートします。
    • プロジェクトを build し直してエラーが出る点があれば修正し、まずはここまでで動くようにします。
    • その後で 1.5 系ではこう書くべきという点があるか確認し、変更した方がよいところを修正します。

 
1.5 のリリースノートはこちらです。

Spring Boot 1.5 Release Notes
https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-1.5-Release-Notes

履歴

2017/05/13
初版発行。

Spring Boot 1.4.x の Web アプリを 1.5.x へバージョンアップする ( 大目次 )

  1. その1 ( 概要 )
  2. その2 ( build.gradle の修正 )
  3. その3 ( Run ‘All Tests’ with Coverage 実行時に出るエラーを解消する )
  4. その4 ( 1.4系 → 1.5系で変更された点を修正する )
  5. その5 ( Thymeleaf を 2.1.5 → 3.0.6 へバージョンアップする )
  6. その6 ( Thymeleaf を 2.1.5 → 3.0.6 へバージョンアップする2 )
  7. その7 ( Gradle を 2.13 → 3.5 へバージョンアップし、FindBugs Gradle Plugin が出力する大量のログを抑制する )
  8. その8 ( logback-develop.xml, logback-unittest.xml, logback-product.xml の設定を logback-spring.xml と application.properties に移動してファイルを削除する )
  9. 番外編 ( static メソッドをモック化してテストするには? )
  10. その9 ( Spring Boot を 1.5.3 → 1.5.4 にバージョンアップする )
  11. 番外編 ( Groovy + JUnit4 でテストを書いてみる、Groovy SQL を使ってみる )
  12. その10 ( 起動時の spring.profiles.active のチェック処理を Set.contains を使用した方法に変更する )
  13. その11 ( build.gradle への PMD の導入 )
  14. その12 ( build.gradle への PMD の導入2 )
  15. その13 ( jar ファイルを作成して動作確認する )
  16. その14 ( request, response のログを出力する RequestAndResponseLogger クラスを修正する )
  17. その15 ( -XX:+ExitOnOutOfMemoryError と -XX:+CrashOnOutOfMemoryError オプションのどちらを指定すべきか? )
  18. 感想

Spring Boot 1.3.x の Web アプリを 1.4.x へバージョンアップする ( 感想 )

記事一覧はこちらです。

  • 1.3系 → 1.4 系へのバージョンアップでは動かなくなるということはあまりなくて、どちらかと言うと書き方がいろいろ変わるので、1.4 系の書き方に変更するのが主な対応になる、という感じでした。

    • @Autowired アノテーションによるフィールドインジェクションからコンストラクタインジェクションへ。
    • @GetMapping, @PostMapping の新設。
    • テストのアノテーションの変更。モックも作りやすくなりました。今回書いていないものもありますが、本当にテストの部分は大きく変わりました。
  • Velocity が非推奨になったので FreeMarker に変えましたが、テキスト形式のテンプレートの場合には Thymeleaf 3 より FreeMarker の方が機能が揃っていて便利な気がします。ただし、デフォルトで数値は3桁毎に “,” 区切りになる等、まだ気付いていない点があるような気がしていて、使用時はちょっと注意が必要そうです。

  • CheckStyle, FindBugs を入れてみましたが、フォーマットやコードに問題があるところを指摘してくれるので便利でした。ただし Gradle を 3 系の最新版へバージョンアップすると FindBugs Plugin が警告メッセージを大量に出力するのが難点です。Gradle 3 へ上げようと思っていましたが、これのために止めました。FindBugs って今開発が止まっているんですよね。FindBugs を外して Gradle を 3 系へバージョンアップすべきか迷います。。。

  • ErrorProne は FindBugs では指摘してくれないコード上の問題点や、JDK 9 にした場合の問題点を報告してくれるので、こちらも今後は入れていきたいと思います。ただし lombok と相性が悪いんですよね。。。 バージョン 2.0.19 まで出ていますが、しばらく 2.0.15 で止めたままになる気がします。

  • 以下の2つを今回入れましたが、こちらも便利でした。今後も入れていきます。

次は 1.5.x へのバージョンアップをやる予定です。Thymeleaf も 3 に上げます。1.4.x → 1.5.x はそんなに大きな変更点はなさそうなので、短期間で終わるはず!(と言いつつ、たぶん寄り道するんだろうな。。。)

Spring Boot 1.3.x の Web アプリを 1.4.x へバージョンアップする ( その27 )( Thymeleaf parser-level comment blocks で @thymesVar のコメント文が HTML に出力されないようにする )

概要

記事一覧はこちらです。

Spring Boot 1.3.x の Web アプリを 1.4.x へバージョンアップする ( その26 )( jar ファイルを作成して動作確認する2 ) の続きです。

  • 今回の手順で確認できるのは以下の内容です。
    • Thymeleaf 3 で何が変わったのか確認したくて Tutorial: Using Thymeleaf を読んだのですが、Thymeleaf parser-level comment blocks というものがあることに今さら気づきました(Thymeleaf 3 の新機能ではありません)。
    • @thymesVar のコメント文を HTML に出力しないようにするために使えそうなので試してみます。

参照したサイト・書籍

  1. Tutorial: Using Thymeleaf - 11.2. Thymeleaf parser-level comment blocks
    http://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html#thymeleaf-parser-level-comment-blocks

目次

  1. @thymesVar のコメント文を HTML に出力しないようにする
  2. Alt+Enter で @thymesVar のコメント文を自動補完できました。。。

手順

@thymesVar のコメント文を HTML に出力しないようにする

IntelliJ IDEA Ulitimate Editon では <!-- @thymesVar id="beanValidationGroupForm" type="ksbysample.webapp.lending.web.springmvcmemo.BeanValidationGroupForm" --> のようなコメント文を書いておくと、Thymeleaf テンプレート内で変数の補完が効くようになります。

コメント文を書いていないと、以下の画像の赤枠の部分で Ctrl+Enter を押しても何も表示されませんが、

f:id:ksby:20170513063646p:plain

<!-- @thymesVar id="beanValidationGroupForm" type="ksbysample.webapp.lending.web.springmvcmemo.BeanValidationGroupForm" -->のコメントを付けると、Ctrl+Enter を押すと候補が表示されます。

f:id:ksby:20170513064014p:plain

ただし HTML を出力した時にこのコメントがそのまま残ります。

f:id:ksby:20170513072219p:plain

HTML を見た時に Thymeleaf が使われていることが分からないようにしたくて、出力されない方法をずっと探していたんですよね。。。

そこで見つけたのが Thymeleaf parser-level comment blocks です。Thymeleaf テンプレート内で記述するコメントの書き方を HTML のコメント文 <!-- ... --> ではなく <!--/* ... */--> にすれば、補完も効くし、HTML には出力されないようになります。

<!--/* @thymesVar id="beanValidationGroupForm" type="ksbysample.webapp.lending.web.springmvcmemo.BeanValidationGroupForm" */--> に変えても候補は表示されます。

f:id:ksby:20170513065545p:plain

そしてコメントは HTML には出力されません。

f:id:ksby:20170513072424p:plain

以下のファイル内の <!-- @thymesVar ... --><!--/* @thymesVar ... */--> に修正します。

  • src/main/resources/templates/sessionsample/confirm.html
  • src/main/resources/templates/sessionsample/first.html
  • src/main/resources/templates/sessionsample/next.html
  • src/main/resources/templates/springmvcmemo/beanValidationGroup.html
  • src/main/resources/templates/textareamemo/display.html
  • src/main/resources/templates/textareamemo/index.html

Alt+Enter で @thymesVar のコメント文は自動補完できました。。。

IntelliJ IDEA Ulitimate Editon では Thymeleaf テンプレート上で Alt+Enter を押すことで <!--/*@thymesVar ... */--> のコメント文を自動補完できることに気づきました。

まず <!--/*@thymesVar ... */--> のコメント文がない変数には赤波下線が表示されます。

f:id:ksby:20170513134103p:plain

赤波下線が表示されている変数にカーソルを移動した後、Alt+Enter を押してコンテキストメニューを表示した後「Declare external variable in comment annotation」を選択します。

f:id:ksby:20170513134959p:plain

<!--/*@thymesVar ... */--> のコメント文が補完されます。type="" の中は空でクラスの補完メニューが出ますので、入力します。

f:id:ksby:20170513135338p:plain

履歴

2017/05/13
初版発行。
2017/05/13
* <!--/*@thymesVar ... */--> のコメント文の自動補完について追記しました。

Spring Boot 1.3.x の Web アプリを 1.4.x へバージョンアップする ( 番外編 )( Thymeleaf 3 へのバージョンアップを試してみる2 )

概要

記事一覧はこちらです。

Spring Boot 1.3.x の Web アプリを 1.4.x へバージョンアップする ( 番外編 )( Thymeleaf 3 へのバージョンアップを試してみる ) の続きです。Thymeleaf 3 ten-minute migration guide に書かれている新機能を試してみます。

今回も試してみるだけでコミットはしません。

参照したサイト・書籍

  1. Thymeleaf 3 ten-minute migration guide
    http://www.thymeleaf.org/doc/articles/thymeleaf3migration.html

  2. Tutorial: Using Thymeleaf
    http://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html

目次

  1. SpELコンパイラを有効にする
  2. Fragment Expressions
  3. The No-Operation token
  4. Decoupled Template Logic
  5. 最後に

手順

SpELコンパイラを有効にする

前回、「参照したサイト・書籍」にリンクを書いたのに設定するのを忘れていたので、Spring Boot 1.4+Thymeleaf 3.0でSpELコンパイラを有効にしてパフォーマンスを向上させよう!! の記事に従い SpELコンパイラを有効にします。

src/main/java/ksbysample/webapp/lending/config/WebMvcConfig.java を以下のように変更します。

@Configuration
public class WebMvcConfig extends WebMvcConfigurerAdapter {

    ..........

    /**
     * Thymeleaf 3 のパフォーマンスを向上させるために SpEL コンパイラを有効にする
     *
     * @param templateEngine {@link SpringTemplateEngine} オブジェクト
     */
    @Autowired
    public void configureThymeleafSpringTemplateEngine(SpringTemplateEngine templateEngine) {
        templateEngine.setEnableSpringELCompiler(true);
    }

}
  • configureThymeleafSpringTemplateEngine メソッドを追加します。

Fragment Expressions

Fragment Expressions は ~{ ... :: ... } の形式でかなり自由に Thymeleaf テンプレートファイルに記載された HTML の一部を取得できるようになるような機能ですが、これを使ってヘッダーの共通化等がかなりやりやすくなっています。

例えば head タグ内の共通部分を定義するのに、これまでは共通部分を別の html ファイルに記述して th:replace で読み込んでいました。

src/main/resources/templates/booklist/booklist.html では以下のように記述して、

<head>
    <meta charset="UTF-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <title>貸出希望書籍 CSV ファイルアップロード</title>
    <!-- Tell the browser to be responsive to screen width -->
    <meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport"/>
    <link th:replace="common/head-cssjs"/>
    <!-- Bootstrap File Input -->
    <link href="/css/fileinput.min.css" rel="stylesheet" type="text/css"/>

    <style type="text/css">
        <!--
        .callout ul li {
            margin-left: -30px;
        }
        -->
    </style>
</head>

<link th:replace="common/head-cssjs"/> で読み込んでいる src/main/resources/templates/common/head-cssjs.html は以下のように記述しています。

    <!-- Bootstrap 3.3.4 -->
    <link href="/css/bootstrap.min.css" rel="stylesheet" type="text/css"/>
    <!-- Font Awesome Icons -->
    <link href="/css/font-awesome.min.css" rel="stylesheet" type="text/css"/>
    <!-- Ionicons -->
    <link href="/css/ionicons.min.css" rel="stylesheet" type="text/css"/>
    <!-- Theme style -->
    <link href="/css/AdminLTE.min.css" rel="stylesheet" type="text/css"/>
    <!-- AdminLTE Skins. Choose a skin from the css/skins
         folder instead of downloading all of them to reduce the load. -->
    <link href="/css/skins/_all-skins.min.css" rel="stylesheet" type="text/css"/>
    
    <!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
    <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
    <!--[if lt IE 9]>
        <script src="/js/html5shiv.min.js"></script>
        <script src="/js/respond.min.js"></script>
    <![endif]-->

    <style type="text/css">
        <!--
        .jp-gothic {
            font-family: Verdana, "游ゴシック", YuGothic, "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
        }
        ..........
        -->
    </style>

これで画面を表示すると以下の HTML が出力されます。

<head>
    <meta charset="UTF-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <title>貸出希望書籍 CSV ファイルアップロード</title>
    <!-- Tell the browser to be responsive to screen width -->
    <meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport"/>
        <!-- Bootstrap 3.3.4 -->
    <link href="/css/bootstrap.min.css" rel="stylesheet" type="text/css"/>
    <!-- Font Awesome Icons -->
    <link href="/css/font-awesome.min.css" rel="stylesheet" type="text/css"/>
    <!-- Ionicons -->
    <link href="/css/ionicons.min.css" rel="stylesheet" type="text/css"/>
    <!-- Theme style -->
    <link href="/css/AdminLTE.min.css" rel="stylesheet" type="text/css"/>
    <!-- AdminLTE Skins. Choose a skin from the css/skins
         folder instead of downloading all of them to reduce the load. -->
    <link href="/css/skins/_all-skins.min.css" rel="stylesheet" type="text/css"/>
    
    <!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
    <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
    <!--[if lt IE 9]>
        <script src="/js/html5shiv.min.js"></script>
        <script src="/js/respond.min.js"></script>
    <![endif]-->

    <style type="text/css">
        <!--
        .jp-gothic {
            font-family: Verdana, "游ゴシック", YuGothic, "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
        }
        ..........
        -->
    </style>

    <!-- Bootstrap File Input -->
    <link href="/css/fileinput.min.css" rel="stylesheet" type="text/css"/>

    <style type="text/css">
        <!--
        .callout ul li {
            margin-left: -30px;
        }
        -->
    </style>
</head>

これが Fragment Expressions の機能を利用すると head タグの共通部分を全て別ファイルに定義して、差分の箇所だけ各 html ファイルに記述できるようになります

例えば src/main/resources/templates/common/head.html に head タグの共通部分を以下のように記述します。

<head th:fragment="common_header(title, links, style)">
    <meta charset="UTF-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <title th:replace="${title}">画面のタイトル</title>
    <!-- Tell the browser to be responsive to screen width -->
    <meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport"/>
    <!-- Bootstrap 3.3.4 -->
    <link href="/css/bootstrap.min.css" rel="stylesheet" type="text/css"/>
    <!-- Font Awesome Icons -->
    <link href="/css/font-awesome.min.css" rel="stylesheet" type="text/css"/>
    <!-- Ionicons -->
    <link href="/css/ionicons.min.css" rel="stylesheet" type="text/css"/>
    <!-- Theme style -->
    <link href="/css/AdminLTE.min.css" rel="stylesheet" type="text/css"/>
    <!-- AdminLTE Skins. Choose a skin from the css/skins
         folder instead of downloading all of them to reduce the load. -->
    <link href="/css/skins/_all-skins.min.css" rel="stylesheet" type="text/css"/>

    <!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
    <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
    <!--[if lt IE 9]>
    <script src="/js/html5shiv.min.js"></script>
    <script src="/js/respond.min.js"></script>
    <![endif]-->

    <!-- ここに各htmlで定義された link タグが追加される -->
    <th:block th:replace="${links}"/>

    <style type="text/css">
        <!--
        .jp-gothic {
            font-family: Verdana, "游ゴシック", YuGothic, "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
        }
        ..........
        -->
    </style>

    <!-- ここに各htmlで定義された style タグが追加される -->
    <th:block th:replace="${style}"/>
</head>

src/main/resources/templates/booklist/booklist.html を以下のように修正します。変更したい title タグや、追加したい link, style タグだけ記述します。

<head th:replace="~{common/head :: common_header(~{::title}, ~{::link}, ~{::style})}">
    <title>貸出希望書籍 CSV ファイルアップロード</title>

    <!-- Bootstrap File Input -->
    <link href="/css/fileinput.min.css" rel="stylesheet" type="text/css"/>

    <style type="text/css">
        <!--
        .callout ul li {
            margin-left: -30px;
        }
        -->
    </style>
</head>

これで画面を表示すると以下の HTML が出力されます。src/main/resources/templates/booklist/booklist.html に記述した title, link, style タグの内容が反映されています。この例では link, style タグはそれぞれ1つしか記述していませんが、例えば link タグを2つ書けば、2つとも反映されます。

<head>
    <meta charset="UTF-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <title>貸出希望書籍 CSV ファイルアップロード</title>
    <!-- Tell the browser to be responsive to screen width -->
    <meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport"/>
    <!-- Bootstrap 3.3.4 -->
    <link href="/css/bootstrap.min.css" rel="stylesheet" type="text/css"/>
    <!-- Font Awesome Icons -->
    <link href="/css/font-awesome.min.css" rel="stylesheet" type="text/css"/>
    <!-- Ionicons -->
    <link href="/css/ionicons.min.css" rel="stylesheet" type="text/css"/>
    <!-- Theme style -->
    <link href="/css/AdminLTE.min.css" rel="stylesheet" type="text/css"/>
    <!-- AdminLTE Skins. Choose a skin from the css/skins
         folder instead of downloading all of them to reduce the load. -->
    <link href="/css/skins/_all-skins.min.css" rel="stylesheet" type="text/css"/>

    <!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
    <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
    <!--[if lt IE 9]>
    <script src="/js/html5shiv.min.js"></script>
    <script src="/js/respond.min.js"></script>
    <![endif]-->

    <!-- ここに各htmlで定義された link タグが追加される -->
    <link href="/css/fileinput.min.css" rel="stylesheet" type="text/css"/>

    <style type="text/css">
        <!--
        .jp-gothic {
            font-family: Verdana, "游ゴシック", YuGothic, "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
        }
        ..........
        -->
    </style>

    <!-- ここに各htmlで定義された style タグが追加される -->
    <style type="text/css">
        <!--
        .callout ul li {
            margin-left: -30px;
        }
        -->
    </style>
</head>

src/main/resources/templates/booklist/booklist.html の head タグの中に何も書かないと、

<head th:replace="~{common/head :: common_header(~{::title}, ~{::link}, ~{::style})}">
</head>

エラー画面が表示されました。。。

f:id:ksby:20170511000328p:plain

ログを見ると org.thymeleaf.exceptions.TemplateInputException: Error resolving fragment: "${title}": template or fragment could not be resolved (template: "common/head" - line 4, col 12) というログが出力されていました。head タグの中に title タグを記述しなかったので、引数の ~{::title} が空になりエラーになったようです。

Fragment Expressions と同じく新規に追加された The No-Operation token の機能を利用して src/main/resources/templates/common/head.html を以下のように修正します。?: _ を付けると、値がセットされていなければ th:replace の処理が行われなくなります。

<head th:fragment="common_header(title, links, style)">
    <meta charset="UTF-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <title th:replace="${title} ?: _">画面のタイトル</title>
    <!-- Tell the browser to be responsive to screen width -->
    <meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport"/>
    <!-- Bootstrap 3.3.4 -->
    <link href="/css/bootstrap.min.css" rel="stylesheet" type="text/css"/>
    <!-- Font Awesome Icons -->
    <link href="/css/font-awesome.min.css" rel="stylesheet" type="text/css"/>
    <!-- Ionicons -->
    <link href="/css/ionicons.min.css" rel="stylesheet" type="text/css"/>
    <!-- Theme style -->
    <link href="/css/AdminLTE.min.css" rel="stylesheet" type="text/css"/>
    <!-- AdminLTE Skins. Choose a skin from the css/skins
         folder instead of downloading all of them to reduce the load. -->
    <link href="/css/skins/_all-skins.min.css" rel="stylesheet" type="text/css"/>

    <!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
    <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
    <!--[if lt IE 9]>
    <script src="/js/html5shiv.min.js"></script>
    <script src="/js/respond.min.js"></script>
    <![endif]-->

    <!-- ここに各htmlで定義された link タグが追加される -->
    <th:block th:replace="${links} ?: _"/>

    <style type="text/css">
        <!--
        .jp-gothic {
            font-family: Verdana, "游ゴシック", YuGothic, "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
        }
        ..........
        -->
    </style>

    <!-- ここに各htmlで定義された style タグが追加される -->
    <th:block th:replace="${style} ?: _"/>
</head>

今度は画面が表示されて、

f:id:ksby:20170511002844p:plain

以下の HTML が出力されました。title タグは head.html の記述がそのまま出力されて、th:block で書いていたところには何も出力されていません。

<head>
    <meta charset="UTF-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <title>画面のタイトル</title>
    <!-- Tell the browser to be responsive to screen width -->
    <meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport"/>
    <!-- Bootstrap 3.3.4 -->
    <link href="/css/bootstrap.min.css" rel="stylesheet" type="text/css"/>
    <!-- Font Awesome Icons -->
    <link href="/css/font-awesome.min.css" rel="stylesheet" type="text/css"/>
    <!-- Ionicons -->
    <link href="/css/ionicons.min.css" rel="stylesheet" type="text/css"/>
    <!-- Theme style -->
    <link href="/css/AdminLTE.min.css" rel="stylesheet" type="text/css"/>
    <!-- AdminLTE Skins. Choose a skin from the css/skins
         folder instead of downloading all of them to reduce the load. -->
    <link href="/css/skins/_all-skins.min.css" rel="stylesheet" type="text/css"/>

    <!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
    <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
    <!--[if lt IE 9]>
    <script src="/js/html5shiv.min.js"></script>
    <script src="/js/respond.min.js"></script>
    <![endif]-->

    <!-- ここに各htmlで定義された link タグが追加される -->
    

    <style type="text/css">
        <!--
        .jp-gothic {
            font-family: Verdana, "游ゴシック", YuGothic, "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
        }
        ..........
        -->
    </style>

    <!-- ここに各htmlで定義された style タグが追加される -->
    
</head>

また <th:block th:replace="${style} ?: _"/> は The EMPTY fragment を利用して <th:block th:replace="${style} ?: ~{}"/> と書いても結果は同じになります。

Fragment Expressions の詳細は以下のリンク先の Issue, Tutorial に書かれています。

共通部分をまとめることができ、かつ部分的な変更もしやすくなるので、かなり便利な機能だと思います。

The No-Operation token

The No-Operation token は記述した条件にマッチしなければ th:* の内容が反映されない機能です。

既に Fragment Expressions に The No-Operation token の例を記述しましたが、別の例を書いてみます。

画面右上の図書館の選択状態を表示するために、src/main/resources/templates/common/mainparts.html に以下のように記述していますが、

                <!-- Navbar Right Menu -->
                <div class="navbar-custom-menu">
                    <p class="navbar-text" th:classappend="${#strings.startsWith(@libraryHelper.getSelectedLibrary(), '※')} ? 'noselected-library' : 'selected-library'" th:text="${@libraryHelper.getSelectedLibrary()}">※図書館が選択されていません</p>
                    <ul class="nav navbar-nav">
                        <li><a href="/logout">ログアウト</a></li>
                    </ul>
                </div>
                <!-- /.navbar-custom-menu -->

これを以下のように変更します。@libraryHelper.getSelectedLibrary() が値を返さなければ、HTML の ※図書館が選択されていません をそのまま表示します。

                <!-- Navbar Right Menu -->
                <div class="navbar-custom-menu">
                    <p class="navbar-text"
                       th:with="selectedLibrary=${@libraryHelper.getSelectedLibrary()}"
                       th:classappend="${selectedLibrary} ? 'selected-library' : 'noselected-library'"
                       th:text="${selectedLibrary} ?: _">※図書館が選択されていません</p>
                    <ul class="nav navbar-nav">
                        <li><a href="/logout">ログアウト</a></li>
                    </ul>
                </div>
                <!-- /.navbar-custom-menu -->

@libraryHelper.getSelectedLibrary() の実装である src/main/java/ksbysample/webapp/lending/helper/library/LibraryHelper.java は現在以下のコードですが、

@Component
public class LibraryHelper {

    ..........

    /**
     * @return ???
     */
    public String getSelectedLibrary() {
        String result;
        LibraryForsearch libraryForsearch = libraryForsearchDao.selectSelectedLibrary();
        if (libraryForsearch == null) {
            result = "※図書館が選択されていません";
        } else {
            result = "選択中:" + libraryForsearch.getFormal();
        }

        return result;
    }

}

getSelectedLibrary メソッドを以下のように変更します。図書館が未選択なら null を返します。

    public String getSelectedLibrary() {
        String result = null;
        LibraryForsearch libraryForsearch = libraryForsearchDao.selectSelectedLibrary();
        if (libraryForsearch != null) {
            result = "選択中:" + libraryForsearch.getFormal();
        }

        return result;
    }

library_search テーブルをクリアして検索対象図書館登録画面を表示すると、「※図書館が選択されていません」のメッセージが表示されます。

f:id:ksby:20170511013520p:plain

図書館を選択すると、選択された図書館が表示されます。

f:id:ksby:20170511013726p:plain

Decoupled Template Logic

Decoupled Template Logic は HTML から Thymeleaf の記述を分離して、HTML ファイル自体には th:* タグがないままに出来る機能です。

サンプルを作ろうと思いましたが、th:* の記述を HTML に直接書いてもそんなに邪魔にならないと自分では思っているのと、th:* を記述するところに id や class 等で指定しやすくしておかないと別ファイルから HTML 本体への位置指定が書きにくそうに思えたので、止めました。今の自分ではこの機能を使うことはないという印象です。

最後に

Thymeleaf 3 でいろいろ便利になっている印象を受けました。Thymeleaf 3 ten-minute migration guide だけでなく Tutorial も読んでみようと思います。

履歴

2017/05/11
初版発行。