読者です 読者をやめる 読者になる 読者になる

GeekFactory

int128.hatenablog.com

Gradleを開発プロジェクトで使うときのこと

Gradle G* Advent Calendar 2013

G* Advent Calendar 2013の5日目です。今日はGradleを開発プロジェクトで使う時に考えることを書いてみます。いわゆるビルド職人の仕事をゆるふわに考えてみたいと思います。

対象はJVMベースのWebアプリとバッチアプリとします。記述言語はJavaを想定していますが、Groovyでもだいたい同じです。

プロジェクトの構造

本稿では以下の前提で話を進めます。

  • プロジェクトは複数のアプリから構成されます。
  • アプリは複数のモジュールから構成されます。
  • アプリはそれぞれ異なるサーバで実行されます。

どのモジュールをどのアプリに含めてどのサーバに配置するか、といった依存関係はすぐに複雑化してしまいます。Gradleのマルチプロジェクト構成を使うことで、依存関係やタスクを整理できます。

アプリ 依存するモジュール
ほげほげWebアプリ ・ほげほげWebアプリ
・モジュールA
ふがふがバッチアプリ ・ふがふがバッチアプリ
・モジュールB

ここでアプリと呼んでいるモジュールは出荷用の設定を含みます。WebアプリであればWEB-INFであったり、バッチアプリであればシェルスクリプトであったりします。

当初のマルチプロジェクト構成は下記のようなものでした。

  • ルートプロジェクト
    • モジュールAのプロジェクト
    • モジュールBのプロジェクト
    • ...
    • ほげほげWebアプリ
    • ふがふがバッチアプリ

しかし、デプロイやサーバサイドテストのように、プロダクト開発とは直接関係のないプロジェクトが出てきたため、ルートプロジェクトを分離することにしました。最終的に、マルチプロジェクト構成は下記のようになりました。

  • プロダクト開発のルートプロジェクト
    • モジュールAのプロジェクト
    • モジュールBのプロジェクト
    • ...
    • ほげほげWebアプリ
    • ふがふがバッチアプリ
    • データベーススキーマのプロジェクト(DDLやマスターデータ)
    • ユニットテストのヘルパークラスのプロジェクト
    • 統合テストのプロジェクト
  • デプロイやテストのルートプロジェクト
    • デプロイのプロジェクト
    • スモークテストのプロジェクト
    • パフォーマンステストのプロジェクト
    • 受け入れテストのプロジェクト

ここでは前半のプロジェクトは自動化された工程、後半のプロジェクトは人手を介する工程と分類しています。

前者はソースコードが変更されるたびにCI(Continuous Integration)を回すためのもので、問題をすぐ発見できるように自動化して早く完了させる必要があります。

一方で、後者はデプロイや自動化できないテストが含まれます。人手を介するゆえにテストを頻繁に回せないものは後者に分類します。パフォーマンステストはインフラのボトルネック解析やチューニング作業が必要だし、受け入れテストも評価者の評価をもらう部分は自動化できません。しかし、所要時間の測定や一定の受け入れ条件チェックは自動化できるので、前者に分類できるでしょう。

一見すると機能テストと非機能テストの違いのように見えますが、自動化できるかどうかによるライフサイクルの違いです。実際の開発プロジェクトでは費用対効果から自動化できない作業が多いので、理屈通りにはいかないと思います。

Continuous Delivery本にヒントが書いてあります。

プロジェクトの設定

Gradle Wrapperの設定

開発メンバのPCやビルドサーバに個別にGradleをインストールすると手間がかかるため、Gradle Wrapperで一元管理した方がよいでしょう。後からGradleをバージョンアップする時もbuild.gradleを変更するだけで済みます。

Gradleに限った話ではありませんが、インターネットから直接ダウンロードすると重いので、Artifactoryなどでキャッシュさせておくと快適です。

プロジェクトの共通設定

プロダクト開発のルートプロジェクトでは、下記のようなプロパティを設定するとよいでしょう。

デプロイやテストのルートプロジェクトでは、下記のようなプロパティを設定するとよいでしょう。

具体的には、ルートプロジェクトのbuild.gradleで、allprojectsやsubprojectsに書いた内容はサブプロジェクトに継承されます。

description 'プロダクト開発のルートプロジェクト'

allprojects {
  // ルートプロジェクトを含む全プロジェクトに適用
  apply plugin: 'project-report'
}

subprojects {
  // サブプロジェクトに適用
  apply plugin: 'java'
  apply plugin: 'findbugs'
  sourceCompatibility = 1.7
  tasks.withType(AbstractCompile) each {
    it.options.with {
      encoding = 'UTF-8'
      deprecation = true
    }
  }
}

ルートプロジェクトのgradle.propertiesに書いたプロパティはサブプロジェクトに継承されます。

mavenRepositoryUrl=http://192.168.1.1:8080/artifactory

springFrameworkVersion=3.2.5.RELEASE
slf4jVersion=1.7.5
junitVersion=4.11
アプリのプロジェクト

アプリをサーバに配置するには、出荷単位の成果物を決める必要があります。Webアプリであればwarファイルであったり、バッチアプリであればexecutable jarやzipであったりします。

まず、Webアプリあればwarプラグインを適用します。プロジェクトの成果物は自動的にwarファイルになります。warに含めたいモジュールには依存関係を設定します。依存関係にprovidedCompileを指定するとwarには出荷されません。

apply plugin: 'war'

description 'ほげほげWebアプリ'

dependencies {
  // プロダクトコードの依存関係
  compile project(':モジュールA')
  compile project(':モジュールB')
  // warには出荷しない依存関係
  providedCompile 'javax:javaee-api:6.0'
}

バッチアプリのプロジェクトは成果物をexecutable jarにするかzipにします。zipにする場合はartifacts archivesを指定します。applicationプラグインを適用すると起動用のシェルスクリプトが同梱されます。

apply plugin: 'application'

description 'ふがふがバッチアプリ'

dependencies {
  // プロダクトコードの依存関係
  compile project(':モジュールA')
  compile project(':モジュールC')
}

mainClassName = 'com.example.HogeMain'

// zipで出荷する
artifacts {
  archives distZip
}

// zipのlibフォルダに依存jarを入れる
configurations {
  distLibs {
    extendsFrom runtime
  }
}

applicationDistribution.with {
  from(project.configurations.distLibs) {
    into 'lib'
  }
}

// シェルスクリプトのJVM引数を設定する
startScripts {
  classpath.add(files('resources'))
  classpath.add(project.configurations.distLibs)
}
モジュールのプロジェクト

jarプラグインを適用して、成果物をjarにまとめます。また、必要な依存関係を設定します。

apply plugin: 'jar'

description 'モジュールA'

dependencies {
  testCompile project(':ユニットテストのヘルパークラスのプロジェクト')
  testCompile 'junit:junit:${junitVersion}'
}
IDEの設定

一般にIDEの設定をソースコードリポジトリに入れるのは良くないとされていますが、チームやツールの成熟度によると思います。

私自身はあるIDEを使えと強制されるのは嫌だし、IDEの設定を強制されるのも嫌です。しかし、チーム開発では開発者によって環境の違いがあると多くの問題が発生します。環境起因の問題は往々にしてビルド職人のせいにされるため、ビルド職人としては、あらかじめIDEの設定を一元管理することで予防線を張っておきたいところです。という対立が起こります。

一つの解決策としては、ビルドスクリプトEclipseプラグインやIDEAプラグインを適用して、開発者が自分でIDEの設定を生成できるようにすることです。これでうまくいかない場合はIDEの設定をソースコードリポジトリに入れておくしかありません。

タスクの依存関係

全プロジェクトのビルド

デフォルトでは gradle build を実行すると全プロジェクトがビルドされます。ここでいうビルドは、ソースコードコンパイルだけでなくテストやインスペクションも含まれます。buildで実行されるタスクはドキュメントのJavaプラグインの章に書いてありますが、概ね以下のようなものです。

マルチプロジェクト構成では、サブプロジェクトでそれぞれ上記のタスクを実行するだけでなく、結果を集約するタスクが必要になります。集約タスクは以下のようなものです。

  • 全プロジェクトのソースコードに対してJavaDocを生成する。
  • 全プロジェクトのソースコードに対してインスペクションを実行する。
  • 全プロジェクトのソースコードに対してメトリクスレポートを生成する。
  • 全プロジェクトのテスト結果を集約し、レポートを生成する。

集約タスクを実現する方法はいろいろありますが、一例を下記に示します。

task buildSubprojects {
  description '全プロジェクトをビルドする'
  dependsOn subprojects*.tasks.build
  dependsOn javadocSubprojects
  dependsOn testReportsSubprojects
}

task javadocSubprojects(type: Javadoc) {
  description '全プロジェクトのJavaDocを生成する'
  source subprojects*.sourceSets.main.allJava
  destinationDir = file("${buildDir}/javadoc")
  classpath = files(subprojects*.sourceSets.main.compileClasspath)
  options.encoding = 'UTF-8'
}

テスト結果を集約してレポートを生成するタスクの一例を示します。

allprojects {
  ext {
    testResultsDir = new File(buildDir, 'test-results')
    testReportsDir = new File(buildDir, 'reports/tests')
  }
}

task testResultsSubprojects(type: Copy) {
  description '全プロジェクトのテスト結果をルートプロジェクトにコピーする'
  dependsOn subprojects*.tasks.test
  from subprojects.collect { project -> project.testResultsDir }
  into testResultsDir
}

task testReportsSubprojects {
  description '全プロジェクトのテストレポートを生成する'
  dependsOn testResultsSubprojects
  inputs.dir testResultsDir
  outputs.dir testReportsDir
  doFirst {
    new org.gradle.api.internal.tasks.testing.junit.report.DefaultTestReport(testResultsDir: testResultsDir, testReportDir: testReportsDir).generateReport()
  }
}

デプロイ

クラウドならgit pushでデプロイできるかもしれませんが、オンプレミスではSSHのコマンド実行やファイル転送を組み合わせることになるでしょう。GradleからシームレスにSSHを使うには Gradle SSH Plugin が便利です。

という流れにつなげようと思ったのですが、そろそろ息切れしてきたので続きはいつか書きます。全然書き足りないw