Kengo's blog

Technical articles about original projects, JVM, Static Analysis and TypeScript.

Gradle/Kotlinで開発する私的ベストプラクティス2022

こちらのエントリーが素敵だなと思ったので、最近書いてるKotlinプロジェクトのベストプラクティスをまとめてみます。一部はJavaプロジェクトにおいても利用できるはずです。

zenn.dev

基本方針

  • 参加障壁を下げる。OSSプロジェクトでもプロプライエタリ・ソフトウェアプロジェクトでも、新しい開発者が参加するコストを下げることには大きな意義がある。
  • 環境差異を吸収する。javaにPATHが通ってさえいればOSに関係なくビルドが通るようにする。
  • プロジェクト固有ルールを作らない。Conventional CommitsやKeep a changelogなど、ひろく世に使われているルールを採用する。

Gradleを設定する

Spotlessを使う

コードのフォーマットはformatterに任せて人間は細かいことを考えない、というのが不特定多数が参加するソフトウェアプロジェクトのあるべき姿だと考えています。ここを妙にこだわるとエディタ縛りだとかタブ幅だとかのいわゆる”地雷”の多い話題を避けて通れませんし、プロジェクト固有のルールができて敷居が高くなり保守コストが高くなるという課題もあります。

逆に言えば、フォーマットをツールに任せれば、人間はエディタやOSの選択の自由を享受できます。

Gradleの場合、最もシームレスに使えるツールはSpotlessでしょう。MarkdownとKotlin、Kotlin Buildscriptすべてのフォーマットをこのツールで管理できます。

github.com

pre-merge buildでは spotlessCheck タスクを使ってフォーマットを確認します。このタスクは通常 check タスクから依存されていますので、深いことを考えずに ./gradlew build すれば充分です。

Git hookを導入する

commit時にSpotlessを自動実行するには、git hookを使います。git hookの設定にはghooksを使っていたのですが、脆弱性対応含む更新が止まっており*1使いにくい状態です。後述するsemantic-releaseを使うのであれば、NodeJSにPATHが通っている前提でhuskyを使っても良いでしょう。

JVM toolchainを使う

現時点でJavaには8,11,17という3つのLTSリリースがあります。誰でも簡単にビルドできるプロジェクトを作るためには、どのバージョンにJAVA_HOME環境変数が通っていても問題なくビルドできるべきです。このためのアプローチには「Java 8で動くようにする」と「JVM toolchainを使う」の2択があります。

既にJava 8をサポートしていないGoogle Errorproneやサポート終了を予告しているApache Camelのようなツールもありますので、JVM toolchainを使って「JAVA_HOMEがJava 8を指していても常にJava 11あるいは17を使ってビルドする」方が現時点でしょう。最新のKotlinプラグインならtoolchainの利用は容易です:

kotlin {
    jvmToolchain {
        (this as JavaToolchainSpec).languageVersion.set(JavaLanguageVersion.of(17))
    }
}

.gitattributes を書く

GitHubを使っている場合、人間に差分をレビューさせたくないファイルがあれば linguist-generated=true を指定しておきます。

./gradlew init で生成されるファイルを参考に、Windows向けの改行コード設定 *.bat text eol=crlf を加えても良いでしょう。

*.bat text eol=crlf
gradlew linguist-generated=true
gradlew.bat linguist-generated=true

settings.gradle.kts を書く

公式ドキュメントで言及されているとおり、設定ファイルをプロジェクトルートに置くことが強く推奨されています。 ./gradlew initでプロジェクトを作っていれば自動的に作成されているはずです。

最低限rootProject.nameを設定しておきます。これでどのようなフォルダ名が使われていても同じプロジェクト名になります。 またビルドスキャンをする予定があるなら、このタイミングで設定をしておくと良いでしょう。

plugins { id("com.gradle.enterprise") version "3.8.1" }

rootProject.name = "foo-bar"

gradleEnterprise {
  buildScan {
    termsOfServiceUrl = "https://gradle.com/terms-of-service"
    termsOfServiceAgree = "yes"
  }
}

gradle.properties を書く

公式ドキュメントをもとに設定しておきます。Java併用時に google-java-format を使う場合、少なくとも Java 17では--add-exportsの指定が必要です。

org.gradle.caching=true
org.gradle.configureondemand=true
org.gradle.jvmargs=-XX:+HeapDumpOnOutOfMemoryError -Xmx1G --add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
org.gradle.parallel=true

ドキュメンテーションコメントにはDokkaを使う

私はあまり語れるほど使っていないのですが、KotlinプロジェクトではKDocをドキュメンテーションコメントに使い、KDocからの文書生成にDokkaを使います。設定も単純なので特に迷うことなく使えるはずです。

github.com

build タスクに必要なタスクすべてを実行させる

Gradleプロジェクトでは基本的に、 ./gradlew build によってプロジェクトのビルドと検証に必要なタスクがすべて実行されるべきです。assemble タスクによって成果物が作られ、 check タスクによってすべてのテストや検証が実行される状態を保つことで、これを実現できます。これらのタスクの役割は ライフサイクルタスクとしてドキュメントに説明されています。

ただSpotlessを使っている場合、手元では spotlessApply も実行したいがCI環境ではspotlessApply を実行させたくない、というケースがあるかもしれません。

これにはDSLによりCI環境の場合に分岐する、CI環境では -x オプションにより特定タスクをスキップする、手元で ./gradlew spotlessApply を明示的に実行させる、などの手法があります。プロジェクト固有ルールを作らないという方針と、標準では build タスクが spotlessApply タスクに依存していない現状を踏まえると、手元で ./gradlew spotlessApply を明示的に実行するのが良いと考えています。

とはいえ人間に「push前に./gradlew spotlessApplyを実行してね」というプロジェクト固有ルールを作りたくはないので、前述のGit hookに実行させる方針を採ることになるでしょう。

GitHub Actionsを設定する

ごく一般的なワークフロー設定は以下のようになります。 なおWindowsでビルドする場合は、--no-daemonオプションでGradle daemonを無効化する必要があります

jobs:
  build:
    strategy:
      matrix:
        os: ['windows-latest', 'ubuntu-latest']
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v2
      - uses: gradle/wrapper-validation-action@v1
      - run: ./gradlew build --no-daemon
      - uses: actions/upload-artifact@v2
        if: always()
        with:
          name: reports (${{ matrix.os }})
          path: build/reports

Dependabotを設定する

現時点では buildSrc プロジェクトの依存は更新対象に入っていません。以下のように明示的に指定する必要があります。

version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "daily"
    commit-message:
      prefix: "build"
  - package-ecosystem: "gradle"
    directory: "/"
    schedule:
      interval: "daily"
    commit-message:
      prefix: "fix"
  - package-ecosystem: "gradle"
    directory: "/buildSrc"
    schedule:
      interval: "daily"
    commit-message:
      prefix: "build"

逆に言えば、ビルドに用いるツールの依存を buildSrc プロジェクトに集めることで、ビルドに用いるツールとプロジェクトそのものの依存とを分けて扱うことができます。上記設定ではプロジェクトそのものの依存にのみ fix 接頭辞を用いることで、semantic-releaseによるリリースを発火させています。

semantic-release を使う

以下の記事で以前紹介したので詳細は省きます。なお今なら拙作GradleプラグインがあるのでGradleでもsemantic-releaseのフル機能を用いて開発できます。

blog.kengo-toda.jp

PATHにNode最新のLTSが通っている状態にする必要がありますが、これはnvmやasdfを使うことになるでしょう。最新のactions/setup-node@v2はnode-version-fileでファイルからNodeのバージョンを読み込めます:

- uses: actions/setup-node@v2
  with:
    node-version-file: '.nvmrc'
    cache: 'npm'
- run: |
     npm ci
     npx semantic-release

以上です。なおJava用ではありますが、ビルドの細かい話やGitHub Actionsの使い方については以下の本にも書いてますのでご参考まで。

zenn.dev

*1:PRは作ったんですが見てもらえてなさそう