Declarative PipelineでJenkinsfileを書いてみた(Checkstyle,Findbugs,PMD,CPDとか)

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

# Declarative Pipeline
いままではJenkinsfileを

```
node {
  ....
}
```

のように書いてましたが、[Jenkinsの公式サイト](https://jenkins.io/doc/book/pipeline/syntax/)を見ると
これはScripted Pipelinesの記法であり、
Pipeline Pluginのバージョン2.5移行からは

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

# 追加したプラグイン
Jenkins初期設定時のSuggested Pluginに入っていないもの

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



# Jenkinsfile
GithubからWebhookでJenkinsのパイプラインジョブを実行する。
パイプラインジョブではGithubのJenkinsfileを使う。
ジョブの流れは下記。デプロイは静的コード解析とテストが成功したときだけ実行する。
![image](https://qiita-image-store.s3.amazonaws.com/0/49915/56c8e12d-267b-a637-224e-fc8cb3474dfa.png)


```groovy: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](https://qiita-image-store.s3.amazonaws.com/0/49915/0f616d50-c57f-235e-c214-8c8d0d19da97.png)

![image](https://qiita-image-store.s3.amazonaws.com/0/49915/a8ff2dcf-9d85-7c41-1ee0-fb4f83eb3c3c.png)

![image](https://qiita-image-store.s3.amazonaws.com/0/49915/f63b50f5-769d-8ff2-1909-f06aecc07106.png)
 
* カバレッジレポートはbuild.gradleのjacocoTestReportタスクでカバレッジレポート対象外にしていてもJenkinsのほうではうまく除外されなかったので、Jenkinsfileのほうでも対象外設定をした。
* JenkinsからGmailにメールする場合、Jenkins > Jenkinsの管理 > システムの設定 > E-mail通知で下記のような設定が必要だった。

![image](https://qiita-image-store.s3.amazonaws.com/0/49915/85a911bb-091d-996b-36ca-cf0360c65592.png)

* JenkinsからGmailにメールする場合、[安全性の低いアプリがアカウントにアクセスするのを許可する](https://support.google.com/accounts/answer/6010255?hl=ja)の手順に従って許可を有効にする必要があった。

![image](https://qiita-image-store.s3.amazonaws.com/0/49915/2478de55-c383-9a0b-6edb-94b3e8487ff4.png)

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

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

例えばこんな

```groovy: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'
}
```


以上。