GradleのJavaアプリをJenkinsで1人CIするためのJenkinsfileを書いてみた。
Githubにプッシュ時に、AWS上のJenkinsのパイプラインジョブが動いて、テストが成功したら同じくAWS上のTomcatにデプロイするみたいな感じで使ってます。

Declarative Pipeline

いままではJenkinsfileを

node {
  ....
}

のように書いてましたが、Jenkinsの公式サイトを見ると
これはScripted Pipelinesの記法であり、
Pipeline Pluginのバージョン2.5移行からは

pipeline {
  ....  
}

のように書くDeclarative Pipelineという記法が導入されて、
そっちのほうがシンプルでわかりやすく書けるよ!ということだったので書き直してみました。
確かにすっきりしたし(特に最後のメールおくるとことか、デプロイするとことか)、
いざとなったら従来のScripted Pipelinesもミックスできるのでいい感じです。

追加したプラグイン

Jenkins初期設定時のSuggested Pluginに入っていないもの

  • Checkstyle Plugin(v3.47) - Checkstyeの結果収集用
  • FindBugs Plugin(v4.69) - Findbugsのレポート生成用
  • PMD Plugin(v3.46) - PMDのレポート生成用
  • DRY Plugin(v2.46) - CPD(重複コードチェック)のレポート生成用
  • Step Counter Plugin(v2.0.0) - ソースコードのステップ数を集計してくれる
  • Task Scanner Plugin(v4.50) - ソースコード中のTODOとかを一覧化してくれる
  • Javadoc Plugin(v1.4) - JavaDoc生成用
  • Warnings Plugin(v4.60) - ジョブ実行時の警告メッセージを収集してくれる
  • JaCoCo Plugin(v2.2.0) - テストカバレッジのレポート生成用

Jenkinsfile

GithubからWebhookでJenkinsのパイプラインジョブを実行する。
パイプラインジョブではGithubのJenkinsfileを使う。
ジョブの流れは下記。デプロイは静的コード解析とテストが成功したときだけ実行する。
image

Jenkinsfile
pipeline {
    agent any
    // 定数や変数を定義する
    environment {
        reportDir = 'build/reports'
        javaDir = 'src/main/java'
        resourcesDir = 'src/main/resources'
        testReportDir = 'build/test-results/test'
        jacocoReportDir = 'build/jacoco' 
        javadocDir = 'build/docs/javadoc'
        libsDir = 'build/libs'
        appName = 'SampleApp'
        appVersion = '1.0.0'
    }

    // stagesブロック中に一つ以上のstageを定義する
    stages {
        stage('事前準備') {
            // 実際の処理はstepsブロック中に定義する
            steps {
                deleteDir()

                // このJobをトリガーしてきたGithubのプロジェクトをチェックアウト
                checkout scm

                // ジョブ失敗の原因調査用にJenkinsfileとbuild.gradleは最初に保存する
                archiveArtifacts "Jenkinsfile"
                archiveArtifacts "build.gradle"

                // scriptブロックを使うと従来のScripted Pipelinesの記法も使える
                script {
                    // Permission deniedで怒られないために実行権限を付与する
                    if(isUnix()) {
                        sh 'chmod +x gradlew'
                    }
                }
                gradlew 'clean'
            }
        }

        stage('コンパイル') {
            steps {
                gradlew 'classes testClasses'
            }

            // postブロックでstepsブロックの後に実行される処理が定義できる
            post {
                // alwaysブロックはstepsブロックの処理が失敗しても成功しても必ず実行される
                always {

                    // JavaDoc生成時に実行するとJavaDocの警告も含まれてしまうので
                    // Javaコンパイル時の警告はコンパイル直後に収集する
                    step([

                        // プラグインを実行するときのクラス指定は完全修飾名でなくてもOK
                        $class: 'WarningsPublisher',

                        // Job実行時のコンソールから警告を収集する場合はconsoleParsers、
                        // pmd.xmlなどのファイルから収集する場合はparserConfigurationsを指定する。
                        // なおparserConfigurationsの場合はparserNameのほかにpattern(集計対象ファイルのパス)も指定が必要
                        // パーサ名は下記プロパティファイルに定義されているものを使う
                        // https://github.com/jenkinsci/warnings-plugin/blob/master/src/main/resources/hudson/plugins/warnings/parser/Messages.properties
                        consoleParsers: [
                            [parserName: 'Java Compiler (javac)'],
                        ],
                        canComputeNew: false,
                        canResolveRelativesPaths: false,
                        usePreviousBuildAsReference: true
                    ])
                }
            }
        }

        stage('静的コード解析') {
            steps {
                // 並列処理の場合はparallelメソッドを使う
                parallel(
                    '静的コード解析' : {
                        gradlew 'check -x test'

                        // dirメソッドでカレントディレクトリを指定できる
                        dir(reportDir) {
                            step([
                                $class: 'CheckStylePublisher',
                                pattern: "checkstyle/*.xml"
                            ])
                            step([
                                $class: 'FindBugsPublisher',
                                pattern: "findbugs/*.xml"
                            ])
                            step([
                                $class: 'PmdPublisher',
                                pattern: "pmd/*.xml"
                            ])
                            step([
                                $class: 'DryPublisher',
                                pattern: "cpd/*.xml"
                            ])

                            archiveArtifacts "checkstyle/*.xml"
                            archiveArtifacts "findbugs/*.xml"
                            archiveArtifacts "pmd/*.xml"
                            archiveArtifacts "cpd/*.xml"
                        }
                    },
                    'ステップカウント': {
                        // レポート作成
                        // outputFileとoutputFormatを指定するとエクセルファイルも作成してくれる
                        stepcounter outputFile: 'stepcount.xls', outputFormat: 'excel', settings: [
                            [key:'Java', filePattern: "${javaDir}/**/*.java"],
                            [key:'SQL', filePattern: "${resourcesDir}/**/*.sql"],
                            [key:'HTML', filePattern: "${resourcesDir}/**/*.html"],
                            [key:'JS', filePattern: "${resourcesDir}/**/*.js"],
                            [key:'CSS', filePattern: "${resourcesDir}/**/*.css"]
                        ]
                        // 一応エクセルファイルも成果物として保存する
                        archiveArtifacts "stepcount.xls"
                    },
                    'タスクスキャン': {
                        step([
                            $class: 'TasksPublisher',
                            pattern: './**',
                            // 集計対象を検索するときに大文字小文字を区別するか
                            ignoreCase: true,
                            // 優先度別に集計対象の文字列を指定できる
                            // 複数指定する場合はカンマ区切りの文字列を指定する
                            high: 'System.out.System.err',
                            normal: 'TODO,FIXME,XXX',
                        ])
                    },
                    'JavaDoc': {
                        gradlew 'javadoc -x classes'
                        step([
                            $class: 'JavadocArchiver',
                            // Javadocのindex.htmlがあるフォルダのパスを指定する
                            javadocDir: "${javadocDir}",
                            keepAll: true
                        ])
                    }
                )
            }

            post {
                always {
                   // JavaDocの警告を収集
                    step([
                        $class: 'WarningsPublisher',
                        consoleParsers: [
                            [parserName: 'JavaDoc Tool']
                        ],
                        canComputeNew: false,
                        canResolveRelativesPaths: false,
                        usePreviousBuildAsReference: true
                    ])
                }
            }
        }


        stage('テスト') {
            steps {
                gradlew 'test jacocoTestReport -x classes -x testClasses'

                junit "${testReportDir}/*.xml"
                archiveArtifacts "${testReportDir}/*.xml"

                // カバレッジレポートを生成(テストクラスを除外)
                step([
                    $class: 'JacocoPublisher',
                    execPattern: "${jacocoReportDir}/*.exec",
                    exclusionPattern: '**/*Test.class'
                ])
            }
        }

        stage('デプロイ') {
            // whenブロックでstageを実行する条件を指定できる
            when {
                // 静的コード解析とテスト失敗時はデプロイしない
                expression {currentBuild.currentResult == 'SUCCESS'}
            }

            steps {
                gradlew 'jar'
                archiveArtifacts "${libsDir}/${appName}-${appVersion}.jar"
                gradlew 'war'
                archiveArtifacts "${libsDir}/${appName}-${appVersion}.war"
                deploy warDir: libsDir, appName: appName, appVersion: appVersion
            }
        }
    }

    // stagesブロックと同じレベルにpostブロックを定義すると
    // 全てのstage処理が終わった後の処理の定義が可能    
    post {
        always {
            // 最後にワークスペースの中身を削除
            deleteDir()
        }
        // 連続で成功しているとき以外は自分宛にメールを送信

        // 結果が前回と変わった時
        changed {
            sendMail("${currentBuild.previousBuild.result} => ${currentBuild.currentResult}")
        }
        // 失敗した時
        failure {
            sendMail(currentBuild.currentResult)
        }
        // 不安定な時(主にテスト失敗時)
        unstable {
            sendMail(currentBuild.currentResult)
        }
    }
}


// Gradlewコマンドを実行する
def gradlew(command) {
    if(isUnix()) {
        sh "./gradlew ${command} --stacktrace"
    } else {
        bat "./gradlew.bat ${command} --stacktrace"
    }
}

// デプロイする
// args.warDir warの格納ディレクトリ 
// args.appName アプリ名
// args.appVersion アプリのバージョン
def deploy(Map args) {
    // 秘密鍵のパス ※Tomcatサーバにファイル転送するので事前にJenkinsサーバのどこかに秘密鍵を格納しておく必要がある
    def keyDir = '/var/lib/jenkins/.ssh/xxx'
    // Tomcatサーバのアドレスとユーザ名
    def webServerAddress = 'ecX-XX-XXX-X-X.xx-xxxx-x.xxxxxxxx'
    def webServerUser = 'hoge-user'
    def webServer = "${webServerUser}@${webServerAddress}"

    def srcWar = "${args.appName}-${args.appVersion}.war"
    def destWar = "${args.appName}.war"

    // ファイル転送してTomcatのwebappsにwarを配置する
    sh "sudo -S scp -i ${keyDir} ./${args.warDir}/${srcWar} ${webServer}:/home/ec2-user"
    sh "sudo -S ssh -i ${keyDir} ${webServer} \"sudo cp /home/ec2-user/${srcWar} /usr/share/tomcat8/webapps/${destWar}\""
}

// メールをGmailに送信する
def sendMail(result) {
    mail to: "xxxxxxxx@gmail.com",
        subject: "${env.JOB_NAME} #${env.BUILD_NUMBER} [${result}]",
        body: "Build URL: ${env.BUILD_URL}.\n\n"
}

躓いたこと

  • 各プラグインともChangelogに「パイプライン対応したよ!」とは書いてあるが、 具体的は書き方は明記していないことが多いので、 各プラグインのGithubでソースコード(主に「なんちゃらPublisher」クラス)を見ながら、Jenkinsfileを書いた。
  • currentBuildオブジェクトの使い方がよくわからなかったが、Jenkinsのパイプラインジョブ > 設定 > Pipeline Syntax > Global Variables Reference に詳しく載っていた。 image

image

image

  • カバレッジレポートはbuild.gradleのjacocoTestReportタスクでカバレッジレポート対象外にしていてもJenkinsのほうではうまく除外されなかったので、Jenkinsfileのほうでも対象外設定をした。
  • JenkinsからGmailにメールする場合、Jenkins > Jenkinsの管理 > システムの設定 > E-mail通知で下記のような設定が必要だった。

image

image

build.gradle

Jenkins自体は、Gradleのコマンドを実行して出力結果をもとにレポートを生成するだけなので、
GradleのJavaアプリのbuild.gradleで下記処理が実行できるようになっている必要があります。
またJenkinsでGradleをインストールしなくていいようにGradleラッパーを作成しておきます。

  • checkstyle
  • findbugs
  • pmd
  • cpd(重複コードチェック)
  • test
  • jacocoReport
  • jar
  • war

例えばこんな

build.gradle
apply plugin: 'java'
apply plugin: 'war'
apply plugin: 'checkstyle'
apply plugin: 'findbugs'
apply plugin: 'pmd'
apply plugin: 'jacoco'

ext {
    appVersion = '1.0.0'
    appName = 'SampleApp'
    javaVersion = 1.8
    defaultEncoding = 'UTF-8'
}

sourceCompatibility = javaVersion
targetCompatibility  = javaVersion
tasks.withType(AbstractCompile)*.options*.encoding = defaultEncoding
tasks.withType(GroovyCompile)*.groovyOptions*.encoding = defaultEncoding
mainClassName = 'jp.takumon.sapmleapp.App'

repositories {
    mavenCentral()
}

dependencies {
    // 依存ライブラリを記載 

    compile group: 'junit', name: 'junit', version: '4.12'
}

jar {
    baseName = appName
    version =  appVersion
}

war {
    baseName = appName
    version =  appVersion
}

checkstyle {
    // 失敗しても後続の処理を継続させる
    ignoreFailures = true
    sourceSets = [sourceSets.main]
    toolVersion = '7.6.1'
}

findbugs {
    // 失敗しても後続の処理を継続させる
    ignoreFailures = true
    sourceSets = [sourceSets.main]
    toolVersion = "3.0.1"
}

pmd {
    // 失敗しても後続の処理を継続させる
    ignoreFailures = true
    sourceSets = [sourceSets.main]
}

tasks.withType(Pmd) {
    reports {
      xml.enabled = true
    }
}

// CPD(重複コードチェック処理)をCheckタスクに追加
check.doLast {
    File outputDir = new File("$reportsDir/cpd/")
    outputDir.mkdirs()

    ant.taskdef(
        name: 'cpd', 
        classname: 'net.sourceforge.pmd.cpd.CPDTask',
        classpath: configurations.pmd.asPath)

    ant.cpd(
        minimumTokenCount: '100',
        format: 'xml',
        encoding: defaultEncoding,
        outputFile: new File(outputDir, 'cpd.xml')
    ) {
        fileset(dir: "src/main/java") {
            include(name: '**/*.java')
        }
    }
}

javadoc {
    failOnError = false
    // 好みのレベルで
    options.memberLevel = JavadocMemberLevel.PRIVATE
}

test {
    // 失敗しても後続の処理を継続させる
    ignoreFailures = true
    reports {
        junitXml.enabled = true
    }
}

jacoco {
    toolVersion = '0.7.5.201505241946'
}

jacocoTestReport {
    reports {
      xml.enabled = true
    }

    // カバレッジレポートからテストクラスを除外
    afterEvaluate { 
        classDirectories = files(classDirectories.files.collect {
            fileTree(dir: it, exclude: ['**/*Test.class']) 
        })
    }
}

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

以上。