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 |
必要がないならセカンダリコンストラクタじゃなく、プライマリコンストラクタとして書きましょうという指摘。
|
Redundant nullable return type |
不必要にnullable typeとして定義してしまっているという指摘。
|
Redundant SAM constructor |
不必要にSAMコンストラクタを使用してしまっているという指摘。
|
Constructor parameter is never used as a property |
不必要にpropertyとして定義してしまっているという指摘。
|
Redundant diagnostic suppression |
不必要な@Suppress。
|
Redundant receiver-based 'let' call |
不必要なlet。
|
Possibly blocking call in non-blocking context |
non-blockingに書かないと行けない場所でblockしてしまっているという指摘。
|
Call chain on collection type can be simplified |
より簡潔に書くことができるという指摘。
|
課題
前述したように、簡潔でわかりやすいドキュメント群や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を使って開発している人たちがいましたら、ぜひ導入してみてください。