Gradleを使ったWebアプリケーションのさくさく開発(環境ごとの設定ファイル管理)

ちょうどキクタローさんが「Challenge Java EE ! - maven-war-pluginで環境ごとの設定ファイルを管理してみた」を書かれていたので、便乗してGradleでやってみようと思いました。

前置き

Gradleで設定情報を外出しして環境ごとに切り分ける方法については、すでにいくつかの方法が紹介されています。

今回は、Gradleの動的にタスクを定義できるという特徴を生かして、手間や重複が少なく、環境が増えた場合でも管理しやすい方法を検討してみたので、紹介したいと思います。簡単に言うと、環境設定ファイル(hoge.properties)を作るだけで、その設定にしたがったwarを作るGradleタスク(hogeWar)を動的に定義する方法です。

目標

今回、簡単のため、ローカル(local)と本番(production)の2つの環境があるケースを想定します。

この中で環境ごとに異なる設定値は、以下のようなweb.xml(一部)の中のurl-patternの部分とします。

    <servlet>
        <servlet-name>hello</servlet-name>
        <servlet-class>sample.servlet.HelloServlet</servlet-class>
    </servlet>
    <servlet-mapping>
        <servlet-name>hello</servlet-name>
        <url-pattern>/hello</url-pattern> <!-- この部分 -->
    </servlet-mapping>

localとproductionで、以下のようにwarファイル名や設定ファイルの中身が異なる別々のwarを作成できるようにするのが、目標です。

  warファイル名 url-pattern
local gradle-sample-webapp-local-1.0.war /hello-local
production gradle-sample-webapp-1.0.war /hello

さて、設定情報の切り分けでは、以下の項目をどうするかが検討材料だと思います。

  • 環境ごとの設定情報の定義をどのように管理するか。
    • 例えば、環境ごとに個別のファイルを用意するとか、GroovyならConfigSlurperを使う、とか。
  • 環境ごとに切り分けないとならないパラメータを含む設定ファイル(今回の例であればweb.xml)をどのように管理するか。
    • 例えば、環境名のフォルダを作り、その下にその環境のファイルを作る(local/web.xml, production/web.xml)、など。
  • どの環境をつかってビルドするかをどのように指定するか。
    • 例えば、ビルド時のプロパティや環境変数で指定する、など。

今回の方法では、以下のようにします。

  • 環境ごとの設定情報の定義は、propertiesファイルとして管理する。local用のlocal.propertiesと、production用のproduction.propertiesを作成。環境ごとに依存した設定情報は、これらのファイルにすべて集約する。
  • 環境ごとに切り分けないとならないパラメータを含む設定ファイルは一つにする。今回の例で言うと、local用のweb.xmlとproduction用のweb.xmlをそれぞれ個別に作ることはせずに、Ant filterのような機能を使い、ファイル中のパラメータを置換する方式を採用します。
  • どの環境を使ってビルドするかは、local用のlocalWar、production用のproductionWarタスクを動的に作成し、それぞれのタスクを実行するようにします。

プロジェクトのレイアウト

新たに、環境ごとの設定情報(propertiesファイル)を置いておく、src/main/configソースフォルダを作りました。

それぞれのpropertiesファイルは以下の通りです。環境ごとに異なる部分「だけ」切り出すのがポイント。

local.properties
warAppendix=local
helloUrl=/hello-local
production.properties
warAppendix=
helloUrl=/hello

src/main/webapp/web.xmlの修正

置き換えができるように置換したい箇所を${helloUrl}としてプレースホルダ化しました。

    <servlet>
        <servlet-name>hello</servlet-name>
        <servlet-class>sample.servlet.HelloServlet</servlet-class>
    </servlet>
    <servlet-mapping>
        <servlet-name>hello</servlet-name>
        <url-pattern>${helloUrl}</url-pattern>
    </servlet-mapping>

build.gradleの修正

以下を追加しました(なお、完全なbuild.gradleは https://github.com/tq-jappy/gradle-sample-webapp にあります)

fileTree(dir: 'src/main/config', include: '**/*.properties').each { file ->
    def env = file.name.substring(0, file.name.lastIndexOf('.'))

    task("${env}War", type: War) {
        def props = new Properties()
        file.withInputStream { stream ->
            props.load(stream)
        }

        doFirst {
            copy {
                from("src/main/webapp")
                into "${buildDir}/additionalWebInf"
                expand(props)
            }
        }

        appendix props.warAppendix
        exclude('WEB-INF/web.xml')
        webInf { from("${buildDir}/additionalWebInf") }
    }
}

少しずつ解説。

fileTree(dir: 'src/main/config', include: '**/*.properties').each { file ->
    def env = file.name.substring(0, file.name.lastIndexOf('.'))

    task("${env}War", type: War) {

src/main/config以下にある*.propertiesファイルに対して、propertiesファイル名から拡張子を覗いた値(local, production)などを変数envにセットし、動的にlocalWar, productionWarタスクを停止しています。他に、例えばci.propertiesファイルがあれば、ciWarタスクも定義されます。

        def props = new Properties()
        file.withInputStream { stream ->
            props.load(stream)
        }

propertiesファイルを読み込みます。

            copy {
                from("src/main/webapp")
                into "${buildDir}/additionalWebInf"
                expand(props)
            }

src/main/webappの下にあるファイルを、${buildDir}/additionalWebInf以下にコピーします。その際に、expand(props)とすることで、propsの値を展開してコピーするようにしています。つまり、このタイミングで、src/main/webapp以下のweb.xml中にある${helloUrl}はそれぞれの設定値に変換されてコピーされます。

そして、(順序は逆転しますが)下の方にある

        exclude('WEB-INF/web.xml')
        webInf { from("${buildDir}/additionalWebInf") }

で、WEB-INFディレクトリを追加で指定することにより、変換後のweb.xmlがwarに含まれるようになります。なおここで、exclude('WEB-INF/web.xml')を指定しているのは、warの中にweb.xmlのエントリがダブって登録されるのを回避するための処理です。

        appendix props.warAppendix

ファイル名も環境に応じて変更します。appendixは、GradleのWarタスクでwarファイル名のappendixを指定する時のプロパティです。

さて、こんな感じでbuild.gradleを作成してGradleタスク一覧を見ると…、

localWar, productionWarという二つのタスクが追加されています。別の環境用のwarを作りたい場合も、src/main/configの下にpropertiesファイルを作って、ファイル名と同じタスクを実行するだけでOK。

既存のwarタスクを上書きする。

localWar, productionWarを実行すればきちんとlocal環境用のwar, production環境用のwarが作成されるのでよいのですが、標準のwarタスクを実行すると、${helloUrl}プレースホルダが置き換わらないので、標準のwarタスクを実行した場合にも、productionWarタスクと同等の処理を実行するようにします。

そのための記述は以下になります。

task war(overwrite: true, dependsOn: ['classes']) << {
    tasks.productionWar.execute()
}

overwrite: trueで、タスクの上書きを宣言しています。

また、tasks.xxx.execute()で他のタスクを実行できますが、依存するタスクは実行されないので、warタスクが依存するclassesタスクを明示的に指定しなければならない点に注意。

まとめ

これまで行なってきた設定ファイル(web.xmlとかSeasar2の*.diconファイルとか)を環境の数だけ個別に持つ方法だと、一つの設定ファイルを変更した時に、他のすべてのファイルも併せて変更しないとならないので、扱う環境の数が増えてくると面倒でした(といっても、これまで経験した中での最大は4環境なんですが…)

環境ごとの定義情報はその異なる部分「だけ」を一つのファイルに集約しておくことで、そういった面倒な管理から少しだけ解放される気がしました。

また、環境を追加する場合も、src/main/configの下に*.propertiesを作成だけで、動的にその環境用のwarを作るための新しいタスクが追加されるので、この辺りはGradleならではの仕掛けだなぁと試してみて感じました。副次的な効果として、環境の追加や削除の際に、他の設定ファイルやビルドスクリプト(build.gradle)がまったく影響を受けないのもメンテナンスしやすくてよいのではないかと。

実際に、2か月くらい前からこの方法を元に環境ごとの依存情報を管理してwarを作るようにしているのですが、なかなか便利につかえています。WEB-INF以外の場所の設定ファイルとか、単なるプレースホルダで対応できないようなケースはまたの機会に紹介したいと思います。