LINE株式会社は、2023年10月1日にLINEヤフー株式会社になりました。LINEヤフー株式会社の新しいブログはこちらです。 LINEヤフー Tech Blog

Blog


Qodana(IntelliJ IDEAのCode Inspection)のCIへの組み込み ~ Kotlinのコード品質を高めるために ~

LINE株式会社OA SREチームのhasebeです。
今回の記事ではQodanaについて、導入した背景、導入するにあたってのテクニック、得られた効果などを紹介したいと思います。

背景

私の所属する開発4センターでは、近年、サーバーサイドの言語としてKotlinを採用しています。(LINE社全体としても同じ傾向があるように思います。)
理由については今回の記事では深堀りはいたしません。簡単にいうと、昔からJavaを採用しており、Javaのエコシステムが社内に整っているためです。

われわれ開発4センター以外でも、海外や国内においてKotlinをサーバサイドで利用するケースを見聞きする機会が増えてきました。ですが、歴史的理由からもJavaにくらべてKotlinの(とくにサーバサイドの)エンジニア人口はまだまだ少なく、弊社においてもKotlin未経験のまま入社いただくこともままあります (社内転籍のケースも含めて)。
そのため、入社してからまずはKotlinについて学ぶ必要があります。

Kotlin自体は公式ドキュメントが簡潔にわかりやすく充実していることと、IntelliJ IDEA(IDE)のサポートが非常に強力なため、入社後わりとすぐに書けるようになっている人がほとんどです。
(どちらかというと、Springフレームワークの学習コストのほうが高く感じています)

IntelliJ IDEAの強力なサポート

Intellij IDEAがKotlinらしい書き方を教えてくれます。
例えばですが、Kotlinにはエルビス演算子というものがあり、nullのときに○○をするといった処理を手短に書くことができます。

// from: https://kotlinlang.org/docs/idioms.html#execute-a-statement-if-null
 
val values = ...
val email = values["email"] ?: throw IllegalStateException("Email is missing!")

この文法を知らず、仮に以下のように書いたとします。

val values = ...
val email = values["email"]
if (email == null) {
    throw IllegalStateException("Email is missing!")
}

このとき、IntelliJ IDEA上で以下のようにきちんと指摘がされます。このとき、cmd+alt+enterを押すだけでIntellij IDEAが一発でコードを直してくれます。

他にも、以下のような指摘をしてくれたりします。(ここで紹介するのはほんの一例にすぎません)

Rule Type
説明
Convert to primary constructor

必要がないならセカンダリコンストラクタじゃなく、プライマリコンストラクタとして書きましょうという指摘。

// BAD
class User {
    val name: String
 
    constructor(name: String) {
        this.name = name
    }
}
 
// GOOD
class User(val name: String) {
}
Redundant nullable return type

不必要にnullable typeとして定義してしまっているという指摘。
(コードからnon-nullであることが確実に保証されている)

// BAD
fun greeting(user: String): String? = "Hello, $user!"
 
// GOOD
fun greeting(user: String): String = "Hello, $user!"
Redundant SAM constructor

不必要にSAMコンストラクタを使用してしまっているという指摘。

fun foo(other: Runnable) {}
 
// BAD
fun main() {
    foo(Runnable { println("Hi!") })
}
 
// GOOD
fun main() {
    foo( { println("Hi!") })
}
Constructor parameter is never used as a property

不必要にpropertyとして定義してしまっているという指摘。
(propertyとして使わないなら、var or valを消しましょう)

// BAD
class Task(val name: String) {
    init {
        print("Task created: $name")
    }
}
 
// GOOD
class Task(name: String) {
    init {
        print("Task created: $name")
    }
}
Redundant diagnostic suppression

不必要な@Suppress。

// BAD
fun doSmth(@Suppress("UNUSED_PARAMETER") used: Int) { println(used) }
 
// GOOD
fun doSmth(used: Int) {
  println(used)
}
Redundant receiver-based 'let' call

不必要なlet。

// BAD
fun test(s: String?): Int? = s?.let { it.length }
 
// GOOD
fun test(s: String?): Int? = s?.length
Possibly blocking call in non-blocking context

non-blockingに書かないと行けない場所でblockしてしまっているという指摘。

// BAD
suspend fun exampleFun() {
    Thread.sleep(100) // Error: blocking method call inside suspend function
}
 
// GOOD
suspend fun exampleFun() {
    delay(100)
}
Call chain on collection type can be simplified

より簡潔に書くことができるという指摘。

// BAD
fun main() {
    listOf(1, 2, 3).filter { it > 1 }.count()
}
 
// GOOD
fun main() {
    listOf(1, 2, 3).count { it > 1 }
}

課題

前述したように、簡潔でわかりやすいドキュメントやIntelliJ IDEAのおかげで学習コストはあまりかからずに済んでいます。

ただし、しばしばIntelliJ IDEAの指摘事項に気づかず、そのままPull Request(PR)を送ってしまうことがあります。特に、(当たり前ではありますが)Kotlin&IntelliJ IDEAの経験が浅い人ほどこの傾向があります。PR上でレビュワーがIntelliJ IDEAと同じ指摘をコメントでする結果となり、レビュワー/レビュイー双方にとって無駄な手間となってしまっていました。
こういったものはCIとして実行/検知できるようにすべきです。

...と、かれこれ5年くらいこんなことを思っており、CI 上でIntelliJ IDEAをCLI実行することでInspectionだけするようにしてみたり(参照: https://www.jetbrains.com/help/idea/command-line-code-inspector.html#options)と試行錯誤をしていたのですが、イマイチうまくいきませんでした。(実行時間が長すぎたり、実行に失敗したり)
そんな中、昨年、JetBrainsからQodanaがリリースされました。

なお、Kotlinにはdetektという静的解析ツールがあります。これも非常にいいツールであり、我々も使用しています。
Intellij IDEAはdetektでは検出できないような内容を指摘してくれることがあるため、両方の指摘を修正することが大事だと考えています。
(どちらのツールでも検出される内容もある)

Qodanaとは

IntelliJ IDEAにて実行しているInspection(先程の例のような指摘事項)を、CI上で実行可能にするツールです。まさに我々が求めていたツールそのものです。
詳しくは公式サイトをご覧ください。

現在はまだEAPなので発展途上ではありますが、十分実用的に稼働できています。日本国内でこそまだまだ情報が少ないのですが、ドキュメントが比較的充実しているので特に問題ありません。
稀に新しく追加されたルールの挙動がおかしい...といったことはありますが、メリットのほうが大幅に上回っています。もしも前述した課題に共感できる人/チームならば、オススメです。

正式版が出た際には有料になりますが、この価格ならアリだなと思っています。(無料のCommunityプランでも問題なさそう)
https://www.jetbrains.com/ja-jp/qodana/buy/

Qodanaの動かし方 on Jenkins

我々のチームではCIとしてJenkinsを利用しています。Jenkinsで動かすにあたって色々と高速化等の工夫をしたので、それについて紹介します。
なお、QodanaはDocker Imageとして提供されているため、Docker Imageを動かすことができるCI環境なら動かすことができると思います。GitHub Actionsでも動きます。
ここで紹介する高速化等の工夫は他のCI環境でも活かせると思われます。

ref: https://github.com/marketplace/actions/qodana-scan

Step1: qodana.yamlを用意

repositoryのrootに、qodana.yamlを用意しておきます。
Qodanaのオススメに従おうということで、以下のような設定をしておきます。なお、特定のruleや特定のディレクトリだけ除外したいケースがあると思いますが、そのような設定もこのYAML上に書きます。

version: 1.0
profile:
  name: qodana.recommended

なお、このqodana.recommendedのinspection profileの中身はコレです。
https://github.com/JetBrains/qodana-profiles/blob/main/.idea/inspectionProfiles/qodana.recommended.full.xml

Step2: ミニマムに動かす

まずはQuick Start通りに動かしてみます。
Jenkinsfileを使ってCIのジョブを定義しています。違反が1つでもあったらCIを落としたいため、"--fail-threshold 0"を指定しています。

stage("Qodana") {
    when { changeRequest() }
    steps {
        // protobuf/gRPCのコードを生成する (protobuf/gRPCを使っていないなら不要)
        sh './gradlew clean generateProto'
  
        // Qodana実行
        sh '''
            docker run --rm \
                -v $(pwd)/:/data/project/ \
                jetbrains/qodana-jvm:latest \
                --save-report \
                --fail-threshold 0
        '''
    }
}

動くには動くのですが、実行が完了するのに20分近くもかかってしまいます。少々時間がかかりすぎです。このままでは実用的に稼働させるのは少々難しいです。Qodanaを高速化していく必要があります。
なお、規模の小さいrepositoryならば、数分で完了します。もしもこの記事をご覧になっている人の環境でも実行に時間がかかる場合は、以降のセクションを参考にしてみてください。

Step3: PR上にて変更があったfileだけを対象にして動かす

Qodanaには"–changes"オプションがあります。これは、uncommitなファイルのみを対象に検査をするオプションです。検査する対象のファイルが減るため、このオプションを利用すると動作が高速化します。
これを応用して、以下のようにJenkinsfileを変更しました。この結果、実行時間が15分ほどに短縮されました(5分短縮)。

stage("Qodana") {
    when { changeRequest() }
    environment {
        // ref: https://stackoverflow.com/questions/72317831/git-commit-envvar-incorrect-for-pipelines-that-merge-master-first
        CURRENT_HEAD = "${sh(returnStdout: true, script: 'git rev-parse HEAD')}"
    }
    steps {
        // protobuf/gRPCのコードを生成する (protobuf/gRPCを使っていないなら不要)
        sh './gradlew clean generateProto'
 
        // qodanaの--changesというoptionを使うと、git index上にあるfileのみを検査の対象にすることができる。
        // 高速化のためにPRによって変更されたfileのみを対象にしたいため、このようなテクニックを使います。
        sh '''
            git fetch origin $CHANGE_TARGET
            CHANGE_TARGET_HEAD=$(cat .git/refs/remotes/origin/$CHANGE_TARGET)
            git reset --soft $CHANGE_TARGET_HEAD
        '''
 
        // Qodana実行
        sh '''
            docker run --rm \
                -v $(pwd)/:/data/project/ \
                jetbrains/qodana-jvm:latest \
                --save-report \
                --changes \
                --fail-threshold 0
        '''
    }
    post {
        always {
            // git resetしているので、念の為元に戻しとく
            sh 'git reset $CURRENT_HEAD'
        }
    }
}

このアプローチがうまくワークしたため、PR上の変更があったfileだけを対象にするオプションを作ってみてはどうか?とQodana側に提案をしてみました。(Jenkinsfile上でgit reset等をしたくなかったため、本家側で隠蔽して欲しかったのです)
https://youtrack.jetbrains.com/issue/QD-3416/Add-an-option-to-analyze-only-Pull-Request-changes

提案してから10日後に、Qodana側でpull request modeという名で実装してくれました。
https://github.com/JetBrains/qodana-action/releases/tag/v2022.1.1

ただし、このpull request modeはqodana-cli上に実装されたため、Docker Imageを直接使っている環境では現在のところ使えません。
qodana-cliをJenkinsにダウンロードすればいいのですが、不必要にJenkinsにバイナリをダウンロードしたくなかったため、結局git reset等をJenkinsfile側で実行しています。

Step4: Cacheを活用する

Qodanaの実行ログを見ていると、maven repositoryからの依存ライブラリのダウンロードにかなりの時間がかかっていることがわかります。

そこで、Cacheを活用するようにしました。以下のドキュメントをもとに、Jenkinsfileを次のように書き直しました。
https://www.jetbrains.com/help/qodana/qodana-jvm-docker-techs.html#Cache+dependencies

社内のS3互換のストレージにCacheを保存しておき、そこからとってくるようにします。

stage("Qodana") {
    when { changeRequest() }
    environment {
        // ref: https://stackoverflow.com/questions/72317831/git-commit-envvar-incorrect-for-pipelines-that-merge-master-first
        CURRENT_HEAD = "${sh(returnStdout: true, script: 'git rev-parse HEAD')}"
        S3_ACCESS_KEY = credentials("...")
        S3_SECRET_KEY = credentials("...")
    }
    steps {
        // protobuf/gRPCのコードを生成する (protobuf/gRPCを使っていないなら不要)
        sh './gradlew clean generateProto'
 
        // qodanaの--changesというoptionを使うと、git index上にあるfileのみを検査の対象にすることができる。
        // 高速化のためにPRによって変更されたfileのみを対象にしたいため、このようなテクニックを使います。
        sh '''
            git fetch origin $CHANGE_TARGET
            CHANGE_TARGET_HEAD=$(cat .git/refs/remotes/origin/$CHANGE_TARGET)
            git reset --soft $CHANGE_TARGET_HEAD
        '''
 
        // cache dirの用意
        sh '''
            rm -rf build/qodana
            mkdir -p build/qodana/cache
        '''
 
        // restore cache (社内のS3互換のストレージから)
        sh '''
            EXIST_CACHE=$(s3cmd --access_key="$S3_ACCESS_KEY" --secret_key="$S3_SECRET_KEY" --host-bucket="%(bucket)s.example.com" ls "s3://BUCKET_NAME/qodana-cache.tar.gz")
            if [ -n "$EXIST_CACHE" ]; then
                s3cmd --access_key="$S3_ACCESS_KEY" --secret_key="$S3_SECRET_KEY" --host-bucket="%(bucket)s.example.com" get "s3://BUCKET_NAME/qodana-cache.tar.gz" build/qodana/cache.tar.gz
                tar -xzf build/qodana/cache.tar.gz
            fi
        '''
 
        // Qodana実行
        sh '''
            docker run --rm \
                -u $(id -u):$(id -g) \
                -v $(pwd)/:/data/project/ \
                -v $(pwd)/build/qodana/cache/:/data/cache/ \
                jetbrains/qodana-jvm:latest \
                --save-report \
                --changes \
                --fail-threshold 0
        '''
    }
    post {
        always {
            // git resetしているので、念の為元に戻しとく
            sh 'git reset $CURRENT_HEAD'
        }
    }
}

上記の処理にCacheの更新をいれてもいいのですが、Cacheをtarに固めてuploadするのに5分ほどかかってしまいます。そこで、Cacheの更新に関してはCIとは別にmain branch上でデイリーで実行しておくようにしました。

stage("Qodana daily at main branch") {
    environment {
        S3_ACCESS_KEY = credentials("...")
        S3_SECRET_KEY = credentials("...")
    }
    steps {
        // protobuf/gRPCのコードを生成する (protobuf/gRPCを使っていないなら不要)
        sh './gradlew clean generateProto'
 
        // cache dirの用意
        sh '''
            rm -rf build/qodana
            mkdir -p build/qodana/cache
        '''
 
        // Qodana実行
        sh '''
            docker run --rm \
                -u $(id -u):$(id -g) \
                -v $(pwd)/:/data/project/ \
                -v $(pwd)/build/qodana/cache/:/data/cache/ \
                jetbrains/qodana-jvm:latest \
                --save-report
        '''
 
        // update cache
        sh '''
            tar --exclude "gradle/daemon" -czf build/qodana/cache.tar.gz build/qodana/cache
            s3cmd --access_key="$S3_ACCESS_KEY" --secret_key="$S3_SECRET_KEY" \
                --host-bucket="%(bucket)s.example.com" put \
                build/qodana/cache.tar.gz "s3://BUCKET_NAME/qodana-cache.tar.gz"
        '''
    }
}

こうすることで、実行結果が5分ほどになりました(10分の短縮)。
これでだいぶCIで実行するのに実用的な速度になってきました。

Step5: その他

その他、以下のような設定もいれています。

  • Qodanaは実行結果をHTMLで出力してくれます。このHTMLを社内のS3互換のストレージにアップロードするようにしています
  • また、Qodanaは実行結果をSARIFとしても出力してくれます。JenkinsのWarnings Next GenerationプラグインがSARIFに対応しているため、この設定もいれています。
  • 途中からこういった静的解析を導入すると、既存の違反しているコードのせいでCIが落ちてしまって辛いため、baseline機能を使っています。

これらを反映すると、以下のようなJenkinsfileになります。

stage("Qodana") {
    when { changeRequest() }
    environment {
        // ref: https://stackoverflow.com/questions/72317831/git-commit-envvar-incorrect-for-pipelines-that-merge-master-first
        CURRENT_HEAD = "${sh(returnStdout: true, script: 'git rev-parse HEAD')}"
        S3_ACCESS_KEY = credentials("...")
        S3_SECRET_KEY = credentials("...")
    }
    steps {
        // protobuf/gRPCのコードを生成する (protobuf/gRPCを使っていないなら不要)
        sh './gradlew clean generateProto'
 
        // qodanaの--changesというoptionを使うと、git index上にあるfileのみを検査の対象にすることができる。
        // 高速化のためにPRによって変更されたfileのみを対象にしたいため、このようなテクニックを使います。
        sh '''
            git fetch origin $CHANGE_TARGET
            CHANGE_TARGET_HEAD=$(cat .git/refs/remotes/origin/$CHANGE_TARGET)
            git reset --soft $CHANGE_TARGET_HEAD
        '''
 
        // cache&result dirの用意
        sh '''
            rm -rf build/qodana
            mkdir -p build/qodana/cache
            mkdir -p build/qodana/results
        '''
 
        // restore cache (社内のS3互換のストレージから)
        sh '''
            EXIST_CACHE=$(s3cmd --access_key="$S3_ACCESS_KEY" --secret_key="$S3_SECRET_KEY" --host-bucket="%(bucket)s.example.com" ls "s3://BUCKET_NAME/qodana-cache.tar.gz")
            if [ -n "$EXIST_CACHE" ]; then
                s3cmd --access_key="$S3_ACCESS_KEY" --secret_key="$S3_SECRET_KEY" --host-bucket="%(bucket)s.example.com" get "s3://BUCKET_NAME/qodana-cache.tar.gz" build/qodana/cache.tar.gz
                tar -xzf build/qodana/cache.tar.gz
            fi
        '''
 
        // Qodana実行
        sh '''
            docker run --rm \
                -u $(id -u):$(id -g) \
                -v $(pwd)/:/data/project/ \
                -v $(pwd)/build/qodana/cache/:/data/cache/ \
                -v $(pwd)/build/qodana/results/:/data/results/ \
                jetbrains/qodana-jvm:latest \
                --baseline path/to/baseline.sarif.json \
                --save-report \
                --changes \
                --fail-threshold 0
        '''
    }
    post {
        always {
            // git resetしているので、念の為元に戻しとく
            sh 'git reset $CURRENT_HEAD'
 
            // warnings next generationプラグイン
            recordIssues(
                    enabledForFailure: true,
                    tools: [sarif(name: "Qodana", pattern: "build/qodana/results/qodana.sarif.json")]
            )
 
            // 結果を社内のS3互換のストレージにupload
            sh '''
                s3cmd --access_key="$S3_ACCESS_KEY" --secret_key="$S3_SECRET_KEY" \
                    --host-bucket="%(bucket)s.example.com" put \
                    --acl-public --recursive \
                    build/qodana/results/report/ \
                    "s3://BUCKET_NAME/qodana-report/$JOB_NAME/$BUILD_NUMBER/"
            '''
        }
    }
}

また、Intellij IDEAで使用するinspection profileをqodana.recommendedにすることもオススメします。

ここからdownloadし、設定することができます。repositoryの".idea/inspectionProfiles"以下に設定をcommit&pushし、チームメンバーのIntelliJ IDEA上にも適用されるようにすると良いでしょう。定期的に更新されるので、適宜更新を追従しておくと良いです。
https://github.com/JetBrains/qodana-profiles/blob/main/.idea/inspectionProfiles/qodana.recommended.full.xml

複数チームに普及させるために行ったこと

ここからは少しQodanaの話とはそれます。

SREチームとして、こういった良さそうなプラクティスはなるべく多くのチームに導入していきたいと考えています。
ただし、先程説明してきたようなゴテゴテと書いているJenkinsfileをそのまま他のチームに横展開していくのはなかなかにコストがかかります。そこで、Jenkinsのshared libraryとして実装し、以下のような記述を書くだけで実現できるようにしました。

stage("Qodana") {
    when { changeRequest() }
    steps {
        // protobuf/gRPCのコードを生成する (protobuf/gRPCを使っていないなら不要)
        sh './gradlew clean generateProto'
        scanPullRequestByQodana(
                s3Bucket: "ci-cache",
                s3AccessKeyCredentialId: "...",
                s3SecretKeyCredentialId: "..."
        )
    }
}
stages {
    stage("Qodana daily at main branch") {
        steps {
         // protobuf/gRPCのコードを生成する (protobuf/gRPCを使っていないなら不要)
            sh './gradlew clean generateProto'
            scanBranchByQodana(
                    s3Bucket: "ci-cache",
                    s3AccessKeyCredentialId: "...",
                    s3SecretKeyCredentialId: "..."
            )
        }
    }
}

効果

定量的には効果測定はできておりませんが、当初の以下の目的については達成することができました。(長年やりたいと思っていたことが実現できて嬉しいです)

ただし、しばしばIntelliJ IDEAの指摘事項に気づかず、そのままPRを送ってしまうことがあります。PR上でレビュワーがIntelliJ IDEAと同じ指摘をコメントでする結果となり、レビュワー/レビュイー双方にとって無駄な手間となってしまっていました。

こういったものはCIとして実行/検知できるようにすべきです。

私自身はKotlinとIntellij IDEAでの開発をしてからだいぶ経つのですが、それでもQodanaに助けられることがあるため、導入してよかったなと思います。

まとめ

私の組織におけるQodanaの導入についてご紹介しました。
似たようにKotlin & IntelliJ IDEAを使って開発している人たちがいましたら、ぜひ導入してみてください。