電通総研 テックブログ

電通総研が運営する技術ブログ

KotlinとGradleで始めるモダンなビルド環境

みなさんこんにちは、電通国際情報サービス(ISID)コーポレート本部 システム推進部の佐藤太一です。

本日は最新のGradle(2022/08現在)を使いこなしながらKotlinでJavaのアプリケーションをビルドするスクリプトを書く際に、知っておくと便利なノウハウをまとめてご紹介します。

はじめに

この記事では、ソースコードをユーザーが利用できる環境にデプロイできるアプリケーションに変換する工程をビルドプロセスと呼ぶこととします。

Javaで実装されたアプリケーションをビルドするためのツールとしては、古くはmakeから始まり、Antを経由して現代ではMavenやGradleといったツールが広く利用されています。

この記事では、自分がコントロールできる範囲では宣言的に記述でき、必要に応じてアドホックなコードを書いていけるGradleを使ったビルドプロセスにおけるノウハウを紹介します。

記事の執筆環境

この記事が前提とする環境について軽く説明しておきましょう。

まず、OSはWindows10 Proで、バージョンは21H2です。 Windows環境でアプリケーションをインストールするために、パッケージマネージャーとしてscoopを使っています。 そして、Java 17を使います。

記事の主題はGradleの使い方ですので、Windows以外のOSでも有効なものです。 Windows以外の環境をお使いなら次に続く環境のセットアップ手順については読み飛ばしてください。

scoopのセットアップ

scoopはpowershellだけで様々なツールをインストールできるパッケージマネージャーの一種です。 この記事で紹介するツールはscoopを使ってインストールしますので、もし未導入なら導入をお願いします。

まずは、PowerShellの実行ポリシーを変えます。

scoopのインストールのためには、インターネット上にあるサーバからシェルスクリプトをダウンロードしてきて実行できるようにします。cf. about_Execution_Policies

PowerShellを起動して以下のコマンドを実行します。

 Set-ExecutionPolicy RemoteSigned -Scope CurrentUser

次にインストールスクリプトをダウンロードしつつ実行します。

irm get.scoop.sh | iex

これでscoopのインストールは終わりです。

Javaのセットアップ

scoopでJavaをインストールする前にバケットを追加します。

scoop bucket add java

このバケットには様々なOpenJDKのビルドが入っているのですけども、ここではopenjdk17を使います。

scoop install openjdk17

openjdkを指定すると、記事執筆時点ではJava 18がインストールされましたのでメジャーバージョンを明示的に指定しています。

Gradleのセットアップ

次はGradleをインストールしましょう。

scoop instlal gradle

Gradleのインストールが完了したらセットアップは終了です。

サンプルアプリケーションについて

この記事ではユーザーインターフェースのあるウェブアプリケーションと、ユーザーインターフェースのないバッチアプリケーションが、単一のgitリポジトリ内に共存しているものを想定しています。

典型的な構造ですが、様々なやり方でビルドスクリプトを記述できます。この記事では私が実施しているプロジェクトにおける構成において得られた知見をもとに説明します。

ルートプロジェクトの実装

まずは、プロジェクト全体を格納するためのディレクトリを作成しましょう。

ここからは、この記事内でシェルコマンドを実行するよう説明している部分では、必ずこのルートディレクトリで実行してください。

作るプロジェクトは、説明のために demo-project とします。作ったdemo-projectディレクトリの中で、以下のコマンドを実行して最小限のプロジェクトを作成します。

gradle init --type basic --dsl kotlin --project-name demo-project --incubating

最小限とはいえ、Gradle Wrapperとなるシェルスクリプトやgit用の設定ファイルが生成されていますね。

その中から、不要な build.gradle.kts を削除してください。

ウェブアプリケーションプロジェクトの実装

次はSpring Bootを使ったウェブアプリケーションをビルドする環境を作ってみましょう。

ビルドスクリプトの作成

demo-projectディレクトリにwebディレクトリを作成します。次に、以下の内容でbuild.gradle.ktsを作成します。

plugins {                                                          // 1.
  id("java")
  id("org.springframework.boot") version "2.7.2"
  id("io.spring.dependency-management") version "1.0.13.RELEASE"
}

group = "com.example"                                              // 2.
version = "0.0.1-SNAPSHOT"                                         // 3.

repositories.mavenCentral()                                        // 4.

dependencies {                                                     // 5.
  implementation("org.springframework.boot:spring-boot-starter")
}

java.toolchain.languageVersion.set(JavaLanguageVersion.of(17))     // 6.

tasks.withType<JavaCompile>().configureEach {                      // 7.
  options.encoding = "UTF-8"
}

このビルドスクリプトについて説明しておきましょう。

  1. このプロジェクトで利用するプラグインを列挙しています。
  2. ビルド成果物として出力されるアーティファクト(JARファイル)のグループIDとなる部分の宣言です。基本的には会社のドメイン名を逆にしたものを使っておきましょう。
  3. ビルド成果物のバージョン番号を記述しています。バージョン番号の管理についてはこの後、もう少し詳しく説明します。
  4. 依存ライブラリをダウンロードする先を宣言しています。ここではMavenのデフォルトアーティファクトリポジトリであるMaven Central Repositoryを指定しています。
  5. このプロジェクトが依存するライブラリを列挙しています。 io.spring.dependency-managementによって機能性が拡張されているため、バージョン番号の指定がありません。
    • org.springframework.boot:spring-boot-starter がSpring Bootの本体です。
  6. このビルドスクリプトで利用するコンパイラやランタイムのバージョン番号を指定しています。
    • これは、Gradleを実行しているJavaランタイムのバージョンが開発者ごとにズレていても、コンパイルやテストに使うJavaランタイムは統一できるということです。ビルドの再現性が高まりますので必ず設定しましょう。
    • この機能を使うと、必要に応じてGradleがビルド済みのJDKを自動的にダウンロードしてくれます。
    • デフォルトではAdoptiumを使います。
  7. このビルドスクリプト内に定義されているタスクを型指定で検索しています。

このビルドスクリプトがルートプロジェクトの一部であることを記述しておきましょう。

demo-project直下にあるsettings.gradle.ktsinclude("web")を追記します。 その結果以下のようになるでしょう。

/*
 * This file was generated by the Gradle 'init' task.
 *
 * The settings file is used to specify which projects to include in your build.
 *
 * Detailed information about configuring a multi-project build in Gradle can be found
 * in the user manual at https://docs.gradle.org/7.4.2/userguide/multi_project_builds.html
 * This project uses @Incubating APIs which are subject to change.
 */

rootProject.name = "demo-project"

include("web") // ルートプロジェクトに含めるために追記した部分

コメントアウトされている部分に意味はないので消してしまって構いません。

ビルドスクリプトが正しく記述できているか確認しておきましょう。以下のコマンドを実行します。

gradle tasks --group=application 

Application tasksとして bootRun が出力されていればビルドスクリプトは上手く構成されています。

サンプルアプリケーションの実装

ビルドスクリプトを配置したらソースコードを格納するためのディレクトリを作成しましょう。以下のコマンドを実行します。

mkdir web/src/main/java/com/example/web

以下の内容でdemo-project/web/src/main/java/com/example/webディレクトリにMain.java を作成します。

package com.example.web;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Main {
  public static void main(String[] args) {
    SpringApplication.run(Main.class, args);
  }
}

詳細に説明することはしませんが、これはSpring Bootアプリケーションを起動するコードです。

アプリケーションをビルドしてアーティファクトを生成してみましょう。以下のコマンドを実行します。

gradle build

ビルドが成功したら demo-project/web/build/libs ディレクトリの中に web-0.0.1-SNAPSHOT.jar が出力されます。

バージョニング

ビルドプロセスを検討するにあたって、生成物に対してバージョン番号をどのように付与するのかは悩ましい課題です。

開発者のローカル環境でビルドしているなら、リリース作業をする前にbuild.gradle.ktsにあるversionの値をエディタで書き換えてからビルドすればいいでしょう。作業が成功したら変更差分としてコミットしておけば問題ありません。

しかし、再現性のあるビルドを行うならGitHub ActionsのようなCIサーバ上でビルドするのが望ましいでしょう。そうした時、build.gradle.ktsにあるversionの値を書き換えるのは少々やっかいです。

僕が好んで使っているやり方は、gitのタグをプッシュして、その名前をバージョン番号を決める際のパラメータとする方法です。 このやり方をサポートしてくれるGradleのプラグインme.qoomon.git-versioningです。

このプラグインを使うとタグ名やブランチ名をパラメータにしてバージョン番号を簡単に決められます。具体例をお見せしましょう。

plugins {                                                                  
  id("java")
  id("org.springframework.boot") version "2.7.2"
  id("io.spring.dependency-management") version "1.0.13.RELEASE"
  id("me.qoomon.git-versioning") version "6.3.0"                   // 1.
}

group = "com.example"                                                      
version = "0.0.1-SNAPSHOT"                                                 
gitVersioning.apply {                                              // 2.
  refs {
    considerTagsOnBranches = true                                  // 3.
    tag("v(?<version>\\d+[.]\\d+[.]\\d+)") {                       // 4.
      version = "\${ref.version}"                                  // 5.
    }
  }
}

repositories.mavenCentral()

dependencies {
  implementation("org.springframework.boot:spring-boot-starter")
}

java.toolchain.languageVersion.set(JavaLanguageVersion.of(17))

tasks.withType<JavaCompile>().configureEach {
  options.encoding = "UTF-8"
}

先ほどのコードに対してバージョニングするためのプラグインを組み込んでいます。

  1. me.qoomon.git-versioningがバージョニングするためのプラグインIDです。
  2. バージョン番号を決めるversion変数の直後に、gitVersioning.applyブロックを宣言しています。
    • これによって、バージョニングプラグインが動作しなかった時は、最初に定義したバージョン番号が利用されます。
  3. considerTagsOnBranchesをtrueにすることで常にタグを考慮してバージョン番号を決定します。
  4. ここで指定されている正規表現にタグがマッチしている場合にタグをバージョン番号として使います。
  5. バージョン番号をマッチするための正規表現の中にある名前を使って具体的にバージョン番号として採用する範囲を決めています。 ref.versionでは専用の式言語を使って正規表現と一致した部分を取り出しています。 $記号の前に\があるのは、Kotlinの文字列テンプレートとしては解釈されないようにするためです。

プラグインが正しく構成されているか確認してみましょう。以下のコマンドを実行します。

gradle web:version

この時点ではタグを打っていないので、version変数に代入されている0.0.1-SNAPSHOTがバージョン番号として出力されます。

では、以下のコマンドを実行してタグを打った上で、同じようにバージョン番号を出力してみましょう。

git tag v0.1.2
gradle web:version

バージョン番号として 0.1.2 が出力されましたね。

その他のバージョニングプラグイン

タグプッシュを起点にビルドする以外の方式でバージョン番号を決めたい場合には、以下のプラグインについて検討するといいでしょう。

バッチプロジェクトの実装

次はバッチアプリケーションを作ってみましょう。

demo-projectディレクトリにbatchディレクトリを作成します。次に、以下の内容でbuild.gradle.ktsを作成します。

plugins {
  id("application")                                               // 1.
  id("me.qoomon.git-versioning") version "6.3.0"
}

group = "com.example"
version = "0.0.1-SNAPSHOT"
gitVersioning.apply {                                             // 2.
  refs {
    considerTagsOnBranches = true
    tag("v(?<version>\\d+[.]\\d+[.]\\d+)") {
      version = "\${ref.version}"
    }
  }
}

repositories.mavenCentral()

dependencies {
  implementation("com.google.guava:guava:30.1.1-jre")
}

application {
  mainClass.set("com.example.batch.Main")                         // 3.
}

java.toolchain.languageVersion.set(JavaLanguageVersion.of(17)) 

tasks.withType<JavaCompile>().configureEach {
  options.encoding = "UTF-8"
}

ウェブアプリケーションのものと似ていますが、このビルドスクリプト固有の部分について説明しておきましょう。

  1. applicationプラグインは、JavaCLIアプリケーションを実装するための定義を自動的に実施します。
  2. ウェブアプリケーションと同じバージョニングポリシーを採用するので、me.qoomon.git-versioningを導入します。
  3. ここでは、CLIアプリケーションとして起動する際に使うmainメソッドのあるクラスを指定しています。

demo-project直下にあるsettings.gradle.ktsinclude("batch")を追記します。 その結果以下のようになるでしょう。

rootProject.name = "demo-project"

include("web")
include("batch") // ルートプロジェクトに含めるために追記した部分

バッチアプリケーションの実装

ビルドスクリプトを配置したらソースコードを格納するためのディレクトリを作成しましょう。以下のコマンドを実行します。

mkdir batch/src/main/java/com/example/batch

以下の内容でdemo-project/batch/src/main/java/com/example/batchディレクトリにMain.java を作成します。

package com.example.batch;

import com.google.common.base.Strings;

public class Main {
  public static void main(String[] args) {
    System.out.println(Strings.repeat("Hello, ", 2) + "World.");
  }
}

詳細に説明することはしませんが、CLIアプリケーションとして起動するコードです。 この後で説明するFat Jarの動作を確認できるようにGuavaのメソッドを使っています。

Fat/Uber Jarの作り方

Javaの実行バイナリの形式として、依存ライブラリを全て一つのJARファイルに含めてしまう形式の事を、Fat Jar や Uber Jarと呼びます。 ビルド生成物を単一のファイルにすると、その後の取り回しが単純化するのでデプロイプロセスの安定性が高まります。

Gradleでは Gradle Shadow プラグインを使うと簡単にFat/Uber Jarがつくれます。

既に書いたバッチアプリケーションのビルドスクリプトに、このプラグインを導入してみましょう。

plugins {
  id("application")
  id("me.qoomon.git-versioning") version "6.3.0"
  id("com.github.johnrengelman.shadow") version "7.1.2" // プラグイン導入
}

group = "com.example"
version = "0.0.1-SNAPSHOT"
gitVersioning.apply {
  refs {
    considerTagsOnBranches = true
    tag("v(?<version>\\d+[.]\\d+[.]\\d+)") {
      version = "\${ref.version}"
    }
  }
}

repositories.mavenCentral()

dependencies {
    implementation("com.google.guava:guava:30.1.1-jre")
}

application {
  mainClass.set("com.example.batch.Main")
}

java.toolchain.languageVersion.set(JavaLanguageVersion.of(17)) 

tasks.withType<JavaCompile>().configureEach {
  options.encoding = "UTF-8"
}

このビルドスクリプトでは、既にapplicationプラグインが導入されているので、shadowプラグインはそれに相乗りする形で動作します。つまり、プラグインの導入を宣言するだけでFat/Uber Jarが生成されるようになるのです。

では、以下のコマンドでビルドしてみましょう。

gradle batch:build

ビルドが正常終了したら、以下のコマンドを実行して生成された成果物を確認してみましょう。

ls batch/build/libs

大きく膨らんだbatch-0.0.1-SNAPSHOT-all.jarがFat/Uber Jarです。今回は、この中にGuavaおよびその依存ライブラリが全て含まれています。

ビルドにおける共通処理の切り出し

ここまで、ウェブアプリケーションとバッチアプリケーションのビルドスクリプトを作ってきたわけですが、かなりの重複があることにお気づきでしょうか?

オレンジ色の枠で囲った部分が二つのビルドスクリプトにおける重複部分です。 これらはビルド対象プロジェクトが増えるたびに重複していくでしょう。それは、望ましくありません。

ここからは、Gradleのローカルプラグインを使ってビルドスクリプトの共通部分を切り出す方法について説明します。

共通部分をプラグイン化することによって、それぞれのビルドスクリプトはより小さくて分かり易いものになります。また、プロジェクトにおけるバージョニングなどのポリシーを一か所に書くことで、ポリシーの変更があった際に抜け漏れなく対応できます。

ローカルプラグインの作り方

それでは、ローカルプラグインの作り方を説明しましょう。

demo-projectディレクトリにbuildSrcディレクトリを作成します。次に、以下の内容でbuild.gradle.ktsを作成します。

plugins {
  `kotlin-dsl`                        // 1.
}

repositories.gradlePluginPortal()     // 2.

Gradleはルートプロジェクト直下にあるbuildSrcディレクトリをローカルプラグインが実装されているプロジェクトであるとみなします。少し奇妙だと感じるかもしれませんが、慣れてしまいましょう。

  1. このプロジェクトはkotlin-dslを使ってプラグインを記述します。
  2. このプロジェクトはプラグインプロジェクトになるので、依存ライブラリをGradle - Pluginsからダウンロードします。

ローカルプラグインの実装

ビルドスクリプトを配置したらソースコードを格納するためのディレクトリを作成しましょう。以下のコマンドを実行します。

mkdir buildSrc/src/main/kotlin

以下の内容でdemo-project/buildSrc/src/main/kotlinディレクトリにcom.example.commons.gradle.kts を作成します。

plugins {
  id("java")
}

group = "com.example"
version = "0.0.1-SNAPSHOT"

repositories.mavenCentral()

java.toolchain.languageVersion.set(JavaLanguageVersion.of(17)) 

tasks.withType<JavaCompile>().configureEach {
  options.encoding = "UTF-8"
}

ここでは、Gradleの標準機能だけを使って共通化しています。 これで、com.example.commonsプラグインIDとして参照できるローカルプラグインの実装は終わりです。非常に簡単ですね。

ローカルプラグインの使い方

では、ローカルプラグインを使ってウェブアプリケーションのビルドスクリプトを簡略化してみましょう。

まずは、ウェブアプリケーションのビルドスクリプトにローカルプラグインを適用します。

plugins {
  id("com.example.commons")                 // ローカルプラグインの導入
  id("org.springframework.boot") version "2.7.2"
  id("io.spring.dependency-management") version "1.0.13.RELEASE"
  id("me.qoomon.git-versioning") version "6.3.0"
}

gitVersioning.apply {
  refs {
    considerTagsOnBranches = true
    tag("v(?<version>\\d+[.]\\d+[.]\\d+)") {
      version = "\${ref.version}"
    }
  }
}

dependencies {
  implementation("org.springframework.boot:spring-boot-starter")
}

差分を確認してみましょう。

かなり小さくなりましたね。同じことをバッチアプリケーションのビルドスクリプトでもやっておきましょう。

ローカルプラグインにGradleプラグインを組み込む

次は、バージョニングプラグインの適用もローカルプラグインの中でやってみましょう。

まずは、誤った例をお見せします。

この状態でローカルプラグインがビルドされると以下のようなエラーが出力されます。

Invalid plugin request [id: 'me.qoomon.git-versioning', version: '6.3.0']. Plugin requests from precompiled scripts must not include a version number. Please remove the version from the offending request and make sure the module containing the requested plugin 'me.qoomon.git-versioning' is an implementation dependency of project ':buildSrc'.

これは、ローカルプラグインの実装でプラグインを指定する場合には、冒頭のpluginsブロックの中でバージョン番号を指定できないというエラーです。このローカルプラグインからはバージョン番号を削除した上で、buildSrcプロジェクトにおける依存性としてプラグインを宣言するように指示されています。

具体的にどうするのかというと、buildSrcディレクトリ内にあるbuild.gradle.ktsを以下のように修正します。

plugins {
  `kotlin-dsl`
}

repositories.gradlePluginPortal()

dependencies {
  implementation("me.qoomon:gradle-git-versioning-plugin:6.3.0")  // 依存性を追加
}

ここで追加する依存性はアーティファクトID(me.qoomon:gradle-git-versioning-plugin:6.3.0)を指定します。プラグインID(me.qoomon.git-versioning)ではありませんのでご注意ください。

次は、ローカルプラグイン内にバージョニングプラグインを適用します。

plugins {
  id("java")
  id("me.qoomon.git-versioning")                                 // 1.
}

group = "com.example"
version = "0.0.1-SNAPSHOT"
gitVersioning.apply {                                            // 2.
  refs {
    considerTagsOnBranches = true
    tag("v(?<version>\\d+[.]\\d+[.]\\d+)") {
      version = "\${ref.version}"
    }
  }
}

repositories.mavenCentral()

java.toolchain.languageVersion.set(JavaLanguageVersion.of(17)) 

tasks.withType<JavaCompile>().configureEach {
  options.encoding = "UTF-8"
}

注意深く見てほしい2か所について説明します。

  1. ここでは、バージョン番号を明記しない形でプラグインの導入を宣言します。
  2. プラグインが導入されているので、それを使ったバージョン番号の採番処理を記述しています。内容は以前に説明したものと同じですね。

ウェブアプリケーションのビルドスクリプトからバージョン番号の採番を消せました。これで、さらに小さくて読みやすくなりましたね。

plugins {
  id("com.example.commons")
  id("org.springframework.boot") version "2.7.2"
  id("io.spring.dependency-management") version "1.0.13.RELEASE"
}

dependencies {
  implementation("org.springframework.boot:spring-boot-starter")
}

プラグインで変数や関数を定義する前に

最後は複数のプロジェクトで使いまわせる関数や変数をローカルプラグインで定義してみましょう。

定義の前にGradleにおけるプラグインの動作と変数スコープについて少し説明させてください。

まず、Gradleのビルドスクリプトにおける一番外側のスコープ、つまり暗黙のインスタンスProjectです。 pluginsdependenciesといったコードブロックは、Projectクラスのインスタンスに定義されたメソッドをレシーバを指定せずに呼び出しているのです。 そして、GradleのプラグインはProjectインスタンスのメソッドを呼び出したり、動的にメンバを追加することで機能を実現しています。

つまり、複数のプロジェクトで使いまわせる関数や変数をローカルプラグインで定義するというのは、動的なメンバをProjectインスタンスに追加するという事になります。もちろん、dependenciestasksといったスコープに変数や関数を追加することも出来ます。

そして、Gradleプラグインでは動的にメンバを編集する仕組みをExtensionと呼んでいます。

Extensionの実装

それでは、Extensionを使ってプロジェクト固有の変数を追加してみましょう。

以下の内容でdemo-project/buildSrc/src/main/kotlinディレクトリにExtension.kt を作成します。

import org.gradle.api.Project

val Project.env: String
  get() = if (System.getenv("ENV") != null) { System.getenv("ENV").toLowerCase() } else "dev"

Extensionというファイル名はGradleプラグインにおける命名規則によるものです。プラグイン名が自明な場合には、プラグイン名のサフィックスとしてExtensionを指定するのが一般的なようです。 今回は明示的なプラグイン名がないので、単にExtensionとしています。

ここでは、プロジェクトスコープにenvという変数を追加してみました。 これは環境変数ENVを読み取った上で中身があれば、その値を返します。それが無ければdevを返すという処理になっています。

これをバッチアプリケーションのビルドスクリプトで使ってみましょう。

plugins {
  id("com.example.commons")
  id("application")
  id("com.github.johnrengelman.shadow") version "7.1.2"
}

dependencies {
    implementation("com.google.guava:guava:30.1.1-jre")
}

application {
  mainClass.set("com.example.batch.Main")
}

tasks {
  shadowJar {
    archiveClassifier.set("all-" + env)  // ファイル名のサフィックスとしてenvの値を使う。
  }
}

ここでは、shadowプラグインによって出力されるアーティファクト名の一部として、envの値を使うように変更しました。

これによって、ファイル名の一部として環境変数であるENVの値を部分的に使うようになります。

この状態でビルドするとbatch/build/libs/batch-0.0.1-SNAPSHOT-all-dev.jarというJARファイルが出力されるのを確認できます。

環境変数ENVの中身をprodに変えてビルドしてください。ビルド結果としてbatch/build/libs/batch-0.0.1-SNAPSHOT-all-prod.jarというJARファイルが出力されるのを確認できます。

まとめ

このエントリでは、Kotlinを使ってGradleのビルドスクリプトを書く際に知っていると便利なTipsを紹介してきました。ここで紹介したGradleの標準機能や、いくつかのプラグインは私がビルドスクリプトを書く際には必ず利用しているものです。

また、ローカルプラグインプラグインを追加する際のエラーについては検索エンジンにインデックスされるよう少し冗長に書いています。冗長に書いた理由は、同じエラーに遭遇した誰かに届いてほしいからです。私自身は、このエラーを上手く解決できなくてかなり苦しみましたので、そういう方が一人でも減ってほしいという気持ちがあります。

この長文を最後まで読んでいただき本当にありがとうございます。


私たちは同じチームで働いてくれる仲間を探しています。今回のエントリで紹介したような仕事に興味のある方、ご応募をお待ちしています。

執筆:@sato.taichi、レビュー:@higaShodoで執筆されました