猫好きモバイルアプリケーション開発者記録

Dockerを使ってWindowsとMacで共通設定ファイルを利用した開発環境を構築する

| Comments

Dockerはバージョン1.8よりDocker Toolboxと呼ばれるものが公式に配布されるようになり、 WindowsおよびMacの双方でDockerクライアントは然ることながら、docker-machineが利用できるようになりました。 従来の boot2docker が docker-machine に置き換わることになります。 WindowsでもDockerクライアントが利用できるようになったということで、以前紹介した「SSHを使ってWindowsからDockerコンテナ内のコマンドを直接実行する」のような小細工をする必要もなくなったということになります。

さて、突然ですがDockerの最大の売りとは何でしょうか。 ポータビリティに優れている点、プラットフォームに依存しないアプリケーションの実行、といった点が代表的な利点かと思いますが、 この利点を普段の開発環境構築において利用してみようというのが今回のテーマです。

私の職場では、Windowsマシンを利用している方とMacを利用している方の両方が存在します。 どちらか一方に寄せてくれれば大変ありがたいのですが、プロジェクトによってはWindows用のローカル開発環境しか用意されていないこともあるので、 どちらか一方に寄せるというのも少々難しい問題となっています。 大抵のケースではMacにParallelsを導入して回避しているのですが、できれば各OS上で動かせると開発はしやすいですよね。

そこでDockerの出番ということになります。 今回はWindowsとMacで共通の設定ファイルを利用した開発環境を構築する上で留意するべきポイントやテクニックを紹介します。

1. コンテナ化する部分とコンテナ化しない部分を決定する

Dockerを利用するのであればすべてコンテナ化したいところなのですが、 IDEでデバッグする利便性を考慮すると、Tomcatサーバのようなアプリケーションサーバ部分はそのままにしておきたいです。 そうなると、HTTPサーバ(Apache、Nginxなど)とデータベース(MySQL、PostgreSQLなど)のみコンテナ化する必要があります。

2. ディレクトリ構成は同じにしておく

WindowsとMacで共通の開発環境を構築するためには、ディレクトリ構成を同じにしておく必要があります。 例えば私の場合は、以下のように各ユーザディレクトリ配下に共通のディレクトリ構成を作成しています。

1
2
3
(User Directory) - Development - Projects - (Project Name) - tomcat - log
                                                           - httpd - log
                                                           - sources - (ソースファイルを格納するディレクトリ)

とはいえ、ユーザディレクトリ名はユーザごとに異なるため、 絶対パスを記述しなくてはいけない場合に $HOME の環境変数を直接取得することができないと少々困ります。 そういう場合は、WindowsとMacでそれぞれ全ユーザの共有ディレクトリが存在するため、そこへシンボリックリンクを作成し、 その共有ディレクトリ経由のパスを記述します。以下に例を記述します。

1
2
3
4
5
6
7
<Windows>
Users - Public - Development - Projects - (Project Name : symbolic link)
※上記のProject NameがUser DirectoryにあるProject Nameディレクトリのシンボリックリンクとなる

<Mac>
Users - Shared - Development - Projects - (Project Name : symbolic link)
※上記のProject NameがUser DirectoryにあるProject Nameディレクトリのシンボリックリンクとなる

実はVista以降のWindowsとMacはドライブレターの有無の違いはあれど、 上記のとおり殆ど同じディレクトリ構成を取ることが可能なのです。 違いがあるのは、共有ディレクトリの名称くらいです。

3. Private Docker Repositoryを構築する

こちらは特に重要なポイントとなります。 Private Docker Repositoryがないと長時間のビルドを利用者に強要してしまうことになるのと、msysGitの絶対パス問題(後述で紹介します)が災いして、共通のDockerfileを利用できないケースが出てきてしまうため、可能な限りPrivate Docker Repositoryを用意してください。 Private Docker Repositoryの構築についてはクラスメソッドさんのブログで詳しく解説されています。

1つ注意としては、Dockerはプライベートネットワーク上に存在する他のサーバに対して直接アクセスすることができません。 (やろうと思えばできるかもしれませんが、docker-machine上のOSのIPアドレスを同一ネットワーク上に属させたり、ルーティングの設定をしたりという結構面倒な作業が必要になるかと思いますのでオススメはしません)
そのため、社内の開発機に構築したところで、その開発機がグローバルからアクセスできないようだと直接利用することができないため注意です。 グローバルからアクセス可能な箇所にPrivate Docker Repositoryサーバを構築し、サーバ側にIP制限やBASIC認証を掛けて利用しましょう。

4. シェルスクリプトを利用してWindows用とMac用の処理を記述する

共通のディレクトリ構成を作成したところで、どうしても回避できない環境差異は存在してしまいます。 そのため、シェルスクリプトを利用してWindows用とMac用の処理を関数内でラップしてしまいましょう。 (ちなみに、Windowsでシェルスクリプトを実行する場合はGit for Windowsに付属してくるGit Bashを利用します。Docker Toolboxをインストールするときに合わせてインストールされます)
具体的にWindowsとMacで処理を分けなければならないのは以下の点です。

4-1. ユーザ共有ディレクトリの取得

こちらは前述のとおり、ユーザ共有ディレクトリの名称が異なるため、 WindowsとMacで返却する名称を変更する必要があります。

4-2. シンボリックリンクの作成

Windowsで本物のシンボリックリンクを作成するためにはmklinkコマンドを利用する必要があります。 Git Bashのlnではシンボリックリンクの作成は行わず、擬似的にコピーを作成するだけになるためです。 また、シンボリックリンクと似たような機能にジャンクションポイントというものがありますが、 こちらはDockerコンテナ内から参照できないため利用不可です。

4-3. 絶対パスの記述がWindowsの場合は異なる

通常だと「/Users」のように絶対パスは記述しますが、Git BashにおいてはmsysGitの仕様上、「//Users」と記述をしなくてはいけません。 もし「/Users」とGit Bashで記述すると、Git for Windowsのインストールディレクトリからの相対パス(デフォルトだと/Program Files/Git/Users)になってしまいます。

4-4. ホストOS側のIPアドレスの取得

ここでいうホストOSというのは、WindowsローカルまたはMacローカルのマシンのIPアドレスのことです。(docker-machine ip で取得できるIPアドレスとは異なります) このIPアドレスは、Dockerコンテナ内からホストOS側で起動しているTomcat等と連携する場合に必要になります。 WindowsとMacではネットワークコマンドが根本的に異なるため、こちらもシェルスクリプトで処理を分ける必要があります。

4-5. Docker環境変数の設定方法

WindowsのGit Bashで eval $(docker-machine env マシン名) を実行すると、
「On Windows, please specify either ‘cmd’ or ‘powershell’ with the —shell flag.」
というように怒られてしまいます。こちらについても対処を行う必要があります。

4-6. Windowsでシェルスクリプトをプログラム内から呼び出す

Macでは特に問題ありませんが、WindowsだとGit Bashを経由しないとシェルスクリプトは実行できません。 関連付けの設定を敢えて用意するのでもいいですが、いちいちそれを利用者に行わせるのは面倒なので、 専用のバッチファイルを用意して対処します。


それでは、上記の問題に対処したラッパー関数を紹介します。


OS判別用共通関数 (この関数は全スクリプトで参照されていることにする)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#---------------------------------------
# OS種別名を取得する。
#
# [Arguments]
# なし
#
# [Return]
# OS名 (osx or win)
#---------------------------------------
function os_name {

    # WindowsのmsysGit経由で実行されているかどうかを調べる
    uname | grep -E '^MINGW' > /dev/null 2>&1

    # WindowsのmsysGit経由の場合
    if [[ $? -eq 0 ]]; then

        # Windowsとして出力する
        echo 'win'

    # OSXの場合
    elif [[ $(uname) = 'Darwin' ]]; then

        # OSXとして出力する
        echo 'osx'

    else

        # サポート外プラットフォームとして出力する
        echo 'unknown'

    fi

}

4-1. ユーザ共有ディレクトリの取得

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#---------------------------------------
# ユーザ共有ディレクトリを取得する。
#
# [Arguments]
# なし
#
# [Return]
# ユーザ共有ディレクトリパス
#---------------------------------------
function share_dir {

    # OS別処理
    case $(os_name) in

    # OSX
    osx)
        echo "$(cd ${HOME}/../ && pwd)/Shared"
        ;;

    # Windows
    win)
        echo "$(cd ${HOME}/../ && pwd)/Public"
        ;;

    # その他
    *)
        echo "Unknown platform"
        ;;

    esac

}

4-2. シンボリックリンクの作成・削除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#---------------------------------------
# シンボリックリンクを作成する。
#
# [Arguments]
# 1 : リンク名称
# 2 : リンクターゲット
#
# [Return]
# なし
#---------------------------------------
function mklink {

    # Windowsの場合
    if [[ $(os_name) = 'win' ]]; then

        # ドライブレターをWindows形式に変換する
        local work_link=$(echo "$1" | sed -E 's;^/([a-zA-Z]);\1:;g')
        local work_target=$(echo "$2" | sed -E 's;^/([a-zA-Z]);\1:;g')

        # ターゲットがディレクトリの場合
        if [[ -d "$2" ]]; then

            # ディレクトリのシンボリックリンクを作成する
            cmd <<< "mklink /D \"${work_link//\//\\}\" \"${work_target//\//\\}\"" > /dev/null

        else

            # ファイルのシンボリックリンクを作成する
            cmd <<< "mklink \"${work_link//\//\\}\" \"${work_target//\//\\}\"" > /dev/null

        fi

    else

        # lnコマンドでシンボリックリンクを作成する
        ln -s "$2" "$1"

    fi

}


#---------------------------------------
# シンボリックリンクを削除する。
#
# [Arguments]
# 1 : リンクパス
#
# [Return]
# なし
#---------------------------------------
function rmlink {

    # こちらは敢えて関数化する必要はないが、mklinkとの対比のために用意しておく
    rm -f "$1"

}

紹介している関数mklinkではlnコマンドと引数が逆なので、
分かりづらい場合は上記関数を修正してみてください。

また、1点Windowsの問題として、mklinkコマンドは「管理者として実行」を行わないと実行できません。 Git Bashで管理者として実行を行うためにはどうすればいいのでしょうか。 簡単な方法としては、以下のような「管理者として実行」を行うためのスクリプト(sudo.sh)を用意します。

シェルスクリプトを管理者として実行するためのスクリプト (sudo.sh)

1
2
3
4
5
6
7
8
9
10
11
12
# Windowsの場合
if [[ $(os_name) = 'win' ]]; then

    # 管理者権限で実行する
    powershell -Command "Start-Process \"C:\Program Files\Git\bin\bash.exe\" -argumentlist \"--login -i $1\" -Verb runas | Out-Null"

else

    # そのまま実行する
    . $1

fi

上記スクリプトを利用して、以下のように実行します。

1
./sudo.sh (管理者として実行したいシェルスクリプトファイル)

こうすることで、mklinkコマンドをシェルスクリプト内で実行することが可能になります。 (実行時に管理者として実行するかどうかの確認ダイアログが表示されます)

4-3. 絶対パスの記述を変換する関数

以下のような関数を使って、引数に含まれる絶対パスを修正します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#---------------------------------------
# Dockerで利用するファイルパスを取得する。
#
# Windows版Dockerで、かつmsysGit経由で実行している場合のみ有効となる。
# msysGit経由の場合、単一の開始スラッシュはmsysGitインストールディレクトリからの相対パスとなるため、
# 開始スラッシュを 2 つにする必要がある。
#
# [Arguments]
# 1 : 変換するファイルパス (絶対パス)
#
# [Return]
# Dockerで利用するファイルパス
#---------------------------------------
function docker_path {

    # Windowsの場合
    if [[ $(os_name) = 'win' ]]; then

        # 単一スラッシュで開始するパラメータをスラッシュ2つに変更する
        echo "$@" | sed -E 's;^/([a-zA-Z_\-\.]);//\1;g' | sed -E 's; /([a-zA-Z_\-\.]); //\1;g'

    else

        # そのまま出力する
        echo "$@"

    fi

}

絶対ファイルパスを利用する箇所で以下のように利用します。

1
docker run -it -v $(docker_path /c/Users/kkoudev/Temp):/Temp debian:wheezy bash

-vのように、途中にスペースが入らない場合は先頭のパスだけが対象になります。

また、docker execなどで外部から引数を渡してコマンドを実行したい場合、 どの部分が絶対パスかコマンドによるので判らないかと思いますが、以下のように指定することで引数に含まれた絶対パスを全て変換してくれます。

1
docker exec -it imlib2-image $(docker_path "$@")

4-4. ホストOS側のIPアドレスの取得

ホストOS側のIPが複数ある場合は、最初にヒットするIPアドレスを返却するようになっています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#---------------------------------------
# HostのIPを取得する
#
# [Arguments]
# なし
#
# [Return]
# HostのIP
#---------------------------------------
function host_ip {

    # OS別処理
    case $(os_name) in

    # OSX
    osx)
        ipconfig getifaddr $(netstat -rn -f inet | awk '/^default/{print $6; exit}')
        ;;

    # Windows
    win)
        ipconfig | grep -a IPv4 | head -n 1 | awk -F ': ' '{print $2}'
        ;;

    # その他
    *)
        echo "Unknown platform"
        ;;

    esac

}

4-5. Docker環境変数の設定方法

実はこちらは —shell オプションを明示すれば特別処理を分ける必要はありません。
以下のように指定します。

1
eval $(docker-machine env --shell=sh (マシン名))

4-6. Windowsでシェルスクリプトをプログラム内から呼び出す

以下のようなバッチファイル(bash.bat)を用意します。

1
2
3
@echo off

"C:\Program Files\Git\bin\bash.exe" --login %*

シェルスクリプトをプログラム内から実行する場合は、以下のコマンドを実行します。

1
bash.bat (実行するシェルスクリプトのファイルパス)

以上、紹介した関数があれば大抵のプラットフォーム差異は吸収することが可能です。

5. 設定ファイルはホスト側にあるソースファイルリポジトリから参照する

各コンテナ起動時に、ホスト側のボリュームをマウントし、ソースファイルリポジトリで管理されている Apacheの設定ファイル等を参照できるようにしておきます。 このとき、Apacheの設定ファイル内でログ出力先をマウントしたホスト側ボリュームとしておけば、 わざわざコンテナ内に入らなくてもログを閲覧できます。
以下はコンテナ起動時のコマンドとApacheの設定ファイルにおける記述例です。
(ここまでに紹介した関数を利用しています)

<起動コマンド>

1
2
3
4
docker run -p 80:80 -p 443:443 -d \
--add-host=docker.host:$(host_ip) \
-v $(docker_path $(share_dir)/Development/Projects/(Project Name)):/(Project Name) httpd-2.2 \
$(docker_path /opt/httpd-worker/bin/httpd) -f $(docker_path /(Project Name)/sources/server/conf/apache.local/httpd.conf) -DFOREGROUND

<Apacheの設定ファイルの一部分>

1
2
3
ServerRoot /(Project Name)/sources/server
PidFile /var/run/httpd.pid
ErrorLog /(Project Name)/httpd/log/error_log

※(Project Name)には任意の名称を入れてください

こちらは最初に紹介したディレクトリ構成が元となっています。 /(Project Name) でコンテナ内からプロジェクト配下のディレクトリを全てアクセスできるようにしておくことで ホスト側からソースファイルリポジトリで管理されている設定ファイルを読み込んでサーバを起動することができます。

また、—add-hostを利用することで、IPアドレスではなくホスト名で設定ファイル内からアクセスを行うことが可能になります。 Dockerコンテナ内のApacheからホスト側で起動しているTomcatへ連携する場合は、 ここで定義した名前をApache設定ファイル内に記述して連携します。

まとめ

上記で紹介したテクニックとポイントを抑えておけば
共通の設定ファイルを利用してWindowsとMacの開発環境を構築することが可能になります。
WindowsとMacが混在しているような現場では大いに役立つと思いますので、是非活用してみてください。

SSHを使ってWindowsからDockerコンテナ内のコマンドを直接実行する

| Comments

Dockerを使い始めて最初にひっかかるポイントとしては「WindowsにはDockerクライアントがない」という点です。 それは boot2docker を導入したところで同じで、Windows上では docker コマンドを結局直接実行することができません。 どの記事をみても、Windowsで利用する場合は boot2docker ssh してゲストOS(dockerコマンドを実行するための軽量Linux OS)から実行するようなことが紹介されています。 (次期Windows Serverに docker.exe が付属することが最近発表されましたが、まだいつになるかわかりません)

これではせっかくdockerを導入しようにも「Macを利用しないといけないのか」というお話になり、 (Macにはdockerクライアントがあるので、dockerコマンドを直接OSX上で実行出来ます) 大多数いるWindowsユーザを考慮すると、結局開発環境が構築できないから利用をあきらめる、みたいになっている人も多いかもしれません。

もちろん、ApacheやNginxのようなサーバを立ち上げるという用途であるなら、
boot2docker sshしてゲストOS上でコマンドを叩いてサーバを立ち上げるような用途でも問題なくそのサーバへアクセス出来ますが、
問題はコンテナ上のコマンドを直接実行したい場合です。

例えば以前記事にさせて頂いた「Imlib2」は、Linux上でしか現状利用できません。
このようなミドルウェアを利用したいケースをカバーできるとしたら docker なわけですが、 Windows上でdockerコマンドを直接実行できなければWindowsでは利用出来ないのも同じです。

では、そのようなケースをカバーするにはどうすればいいのか。 それについてご紹介します。

boot2dockerのインストールとコマンド実行用イメージの作成

まずは boot2docker のインストールです。
インストールは公式サイトのとおり、セットアップ用のexeファイルをダウンロード&実行してインストールします。

インストール後に、コマンドプロンプトを立ち上げ、以下のコマンドを実行してゲストOSへログインします。

1
2
3
boot2docker init
boot2docker up
boot2docker ssh

ゲストOSへログインしましたら、イメージを作成します。
この辺りは詳しくは紹介しませんが、
例として以下のようなDockerfileを記述しておきます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
FROM tianon/centos:5.8

MAINTAINER kkoudev

# Install general commands
RUN yum groupinstall -y "Japanese Support"
RUN yum install -y lrzsz wget vim vim-enhanced openssh openssh-server openssh-clients man mlocate zip unzip rsync perl sysstat bzip2-devel
RUN yum install -y gcc gcc-c++ make cmake libtool patch ncurses-devel

# Lang & Locale
RUN echo 'LANG="ja_JP.UTF-8"' > /etc/sysconfig/i18n
RUN cp -f /usr/share/zoneinfo/Asia/Tokyo /etc/localtime

# Update
RUN yum update -y bash openssl

# Install EPEL
RUN rpm -ivh --force http://ftp-srv2.kddilabs.jp/Linux/distributions/fedora/epel/5/x86_64/epel-release-5-4.noarch.rpm

# Install Tools
RUN yum --enablerepo=epel install -y git ImageMagick GraphicsMagick

# local ldconfig
RUN echo "/usr/local/lib" >> /etc/ld.so.conf.d/local.conf
RUN echo "/usr/local/lib64" >> /etc/ld.so.conf.d/local.conf
RUN /sbin/ldconfig

最新の boot2docker (現時点ではv1.3.1)であれば最初から C:\Users配下のディレクトリがゲストOSにマウントされており、 /c/Users といったパスでWindows側のファイルへアクセスすることが可能です。
そのため、今回は Dockerfileを作成した後に、C:\Users\Public\Tempのようなディレクトリを作成しておき、 そこにDockerfileを置いておきます。
その上で、以下のコマンドをゲストOSで実行して「test/command」というイメージを作成してみます。

1
2
cd /c/Users/Public/Temp
docker build -t test/command .

これで新しいイメージ「test/command」を作成できました。

SSHサーバを立ち上げる

Dockerクライアントが無いのであればどうすればいいのか、 といいますと、実は単純な話で、SSHサーバをコンテナ内で立ち上げてWindows側からリモートでコンテナに対してコマンドを実行すればいいわけです。 これであれば、Dockerクライアントがなくてもコマンドを実行できます。 WindowsでもGit for Windowsをインストールすると ssh のコマンドが付いてきますので、これを利用します。


01. SSH公開鍵と秘密鍵を作成する

まず、Git for Windowsをインストールします。
こちらも公式サイトからダウンロードし、全てデフォルトの設定のままインストールします。
インストール後、C:\Program Files (x86)\Git\bin を環境変数PATHへ登録しておきます。
PATHへ登録後、以下のコマンドをコマンドプロンプト上で実行します。

1
2
mkdir %USERPROFILE%¥.ssh
ssh-keygen -t rsa

パスワードは全て無しで公開鍵と秘密鍵を作成します。
すると、%USERPROFILE%¥.ssh ディレクトリに「id_rsa」と「id_rsa.pub」ファイルが作成されます。

02. SSH公開鍵の登録とSSHサーバの起動

SSH公開鍵を作成したあとは、作成した鍵をコンテナ内へインストールしてあげる必要があります。
そこで、まずは以下のコマンドをゲストOSで実行し、コンテナ内へログインします。

1
docker run -it -v /c/Users:/c/Users test/command /bin/bash

上記のコマンドでログインすると、コンテナ内でも /c/Users へアクセスできるようになっていますので、 %USERPROFILE%¥.ssh¥id_rsa.pubを以下のコマンドでインストールします。(%USERPROFILE%はWindowsの環境変数のため、当然コンテナ内からは使えないので以下のように直接ユーザディレクトリを指定します)

1
2
3
4
mkdir ~/.ssh
cat /c/Users/kkoudev/.ssh/id_rsa.pub >> authorized_keys
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys

コンテナから exit でログアウトし、作業した内容を反映するために再度イメージを作り直します。

1
docker commit `docker ps -lq` test/command

これで作成した公開鍵をインストールしたイメージが作成されました。 準備は完了です。

コンテナ内でSSHサーバを立ち上げるには、以下のようにゲストOSにてdockerコマンドを実行します。

1
docker run -itd -p 22 -v /c/Users/Public/Temp:/HostTemp --name test-container test/command /usr/sbin/sshd -D

これで、コンテナ内の22番ポートが、Windows側の49153以上のポート番号へ割り当てられます。 実際にどのポート番号へ割り当てられたかは docker ps をしてみると、 「0.0.0.0:49153->22/tcp」と表示されるので、ここで判断します。

Windows側からコンテナに対してコマンドを実行してみる

いよいよ本番です。 Windows側からアクセスするには、
上記の手順で確認した割り当て先ポート番号(49153)と
ゲストOSのIPが必要になります。 ゲストOSのIPは以下のコマンドをコマンドプロンプトで実行することで確認できます。

1
boot2docker ip 2>nul

特にバッティング等していなければ 192.168.59.103 が返却されるかと思います。
そして、以下のようなバッチファイルを作成します。
(ここではファイル名を container_command.bat と仮定します)

1
2
3
4
5
6
7
8
9
10
11
12
13
@echo off

rem #---------------------------------------------------------
rem # 各種変数を定義する
rem #
rem # DOCKER_SSH_HOST : コンテナへのSSHアクセス用ホストIP
rem # DOCKER_SSH_PORT : コンテナへのSSHアクセス用ポート番号
rem #---------------------------------------------------------
set DOCKER_SSH_HOST=192.168.59.103
set DOCKER_SSH_PORT=49153

rem # コマンドを実行する
ssh -o "StrictHostKeyChecking=no" -o "UserKnownHostsFile=/dev/null" -o "LogLevel=QUIET" -i %USERPROFILE%\.ssh\id_rsa -p %DOCKER_SSH_PORT% root@%DOCKER_SSH_HOST% "%*"

これでコンテナに対してコマンドを実行する準備が出来ました。
試しにImageMagickを実行してみたいと思います。

ImageMagickはいわずもがな画像変換を行うツールですが、 コンテナ内で画像を変換してどのようにWindows側へ画像を返すのでしょうか。 そのためには、上記のSSHサーバ起動時にマウントした /c/Users/Public/Temp:/HostTemp のディレクトリを利用します。
つまり、C:\Users\Public\Temp に変換したい画像ファイルを予めコピーしておき、 コンテナ内では /HostTemp というパスで参照および書き込みを行うことで、変換結果をWindows側へ返すことが可能になるわけです。

このことを考慮した上で、Windows側で実際にコンテナ内のコマンドを実行してみます。 例として、Windows側のマウントディレクトリであるC:\Users\Public\Tempに sample.jpg を配置し、 これをコンテナ内のImageMagickを利用して PNG へ変換します。 さきほど作成した container_command.bat を使用し、以下のコマンドをWindows側のコマンドプロンプトで実行してみましょう。

1
container_command.bat convert /HostTemp/sample.jpg /HostTemp/sample.png

実行してみると、C:\Users\Public\Temp に sample.png が作成されていることがわかるかと思います。 当然ImageMagick以外のコマンドも実行することが可能です。

まとめ

このように、Windowsでも少し工夫をすることでコンテナ内のコマンドを簡単に実行できるようになります。 そのためこの方法を使えばLinuxでしか動かないImlib2のようなミドルウェアをWindowsで実行することが出来るようになります。

また、実際にこの方法を利用してプロジェクト等の開発環境を構築する場合は手動で行うと大変面倒なので、 接続に使用するSSH公開鍵をインストール済みのコンテナを用意し、 イメージの作成とSSHサーバの起動を自動的に行うスクリプトを作成することをオススメします。

一点注意として、SSHでリモートコマンドを実行している関係上、どうしても動作速度はネイティブでコマンドを実行するよりも遅くなってしまいます。 あくまで本番環境同等の機能をWindowsでも実現したい場合に利用する手段の1つという程度の認識にしておいた方が無難かと思われます。

Intellij IDEA 13.1.4においてGradleのWebリソースディレクトリがクリアされてしまう不具合

| Comments

Intellij IDEA 13.1.4においてGradleプラグインのデグレが発生しました。
内容としては、マルチプロジェクトを利用している場合にWARプロジェクトのWebリソースディレクトリ(ex. src/main/webapp) が 1つのプロジェクトを除いて全てクリアされてしまうというものです。 13.1がリリースされたばかりのときに同じようにArtifactsが 1 つのプロジェクト分しか作成されないという不具合がありましたが、 今回はこれのWebリソースディレクトリ版の不具合といったところです。

手動で設定すれば動くようになるけど、再度Gradleプロジェクトのリフレッシュをするとまたしてもクリアされてしまうという酷いもの。 さすがにこれでは使い物にならないので、バグ報告をしたところ数週間経ってようやく修正されました。 13.1.5にて修正版が反映されるようですが、13.1.4の状態でも以下のファイルを反映することでこの不具合が解消されます。

<修正パッチ>
ここからダウンロード

このファイルをダウンロードし、

1
(Intellij IDEA 13のインストール先)/plugins/gradle/lib

上記ディレクトリへコピーすることで解消されます。

これで問題なく動作することを確認できましたが、 どうもIntellij IDEAのGradle機能はデグレが多いのと、マルチプロジェクトについては毎回動作確認をしていないと思わせるような印象があります…。 今後はこのようなクリティカルな不具合がないことを期待したいところです。

Gradle 1.xから2.0へ移行する際に気をつけるべき変更点

| Comments

7/1にGradle 2.0がリリースされました。
複数の非推奨メソッドの削除が行われたため、 1.xで動作していたものが、2.0から動かなくなるケースが出てきました。 全てのケースを紹介しているとキリがないので、 今回は特に大きいと思われる 2 点の変更点のみ紹介させて頂きます。

変数の直接宣言の非サポート

これは1.10くらいのときから警告が出るようになっていましたが、 具体的には以下のように変数を宣言するのが無効になりました。

1
2
project.myProperty = 'some value'
assert myProperty == 'some value'

これを解決するには、 extの名前空間で囲むか、defキーワードを指定してローカル変数として宣言します。

1
2
3
4
5
6
7
// ローカル変数として定義するか
def myProperty = 'some value'
assert myProperty == 'some value'

// extの名前空間内で宣言するか
ext.myProperty == 'some value'
assert myProperty == 'some value'

EclipseとIntellij IDEAでprovidedな依存関係指定時の変更

provided指定の方法は以前の記事でも何度か紹介させて頂きましたが、 以前までは以下のように指定していました。

1
2
eclipse.classpath.plusConfigurations += project(':base').configurations.provided
idea.module.scopes.PROVIDED.plus     += project(':base').configurations.provided

1.xまではこのままでよかったのですが、 2.0からは += 演算子で configurations の指定を追加する場合、 以下のように指定します。

1
2
eclipse.classpath.plusConfigurations += [project(':base').configurations.provided]
idea.module.scopes.PROVIDED.plus     += [project(':base').configurations.provided]

1.xでもこの指定で動作するので、1.xをまだ利用している場合でもこちらの指定に変更するべきでしょう。 これについては、2.0ではタスク実行時に「You can’t change configuration ‘(追加変数名)’ because it is already resolved!」 といったようなエラーメッセージが表示されるようになりましたので、 このエラーが表示されたら上記記載に変更してみましょう。

まとめ

今回は1.xから2.0へ移行する際にハマるであろうポイントのみ紹介させて頂きました。
リリースノートにはもっと詳細に紹介されていますので、 詳しい内容についてはこちらをご覧ください。

Imlib2とImage::Imlib2のJPEGヒント対応版を作成しました

| Comments

久々に作ったものをGithubへ公開しました。
元ネタは、よくImageMagickと比較される Imlib2 です。
これのJPEGヒント対応版が欲しくなったので探したのですが、どこにも見当たらなかったのでいっそのこと自分で作りました。
(対応についてはSmallLightのソースを参考にさせて頂きました)

JPEGヒント(scale denom)とは

まずJPEGヒントとは何かというと、ImageMagickやImlib2で利用しているlibjpeg ver.7以降で利用することの出来る scale_denom というスケーリング手法の俗称です。
これは簡単に言うと予めリサイズ後のサイズがわかっている場合に、元画像ロード時にメモリを効率よく確保しつつリサイズを高速化する手法です。
ImageMagickではこの機能をサポートしており、「-define jpeg:size=640x480」といったオプションをつけることで利用することができます。
よく「ImageMagickは遅い」と言われるのですが、このJPEGヒントを利用することで10倍近くの高速化が測れます。

ところが、Imlib2にはこのオプションが存在せず、APIにもそれをサポートしたものがありません。 APIの設計上、JPEGのみに特化した設計にはなっていないというのも理由の1つかも知れませんが、 JPEGヒントがないImlib2は単純なリサイズですとJPEGヒントを利用したImageMagickと同等か少し遅くなってしまうため、ベースの速度が速いのにこのままでは非常に勿体ないわけです。

JPEGヒントをサポートしたImlib2の利用

そこで今回、Imlib2を拡張してJPEGヒントが利用できるように実装しました。
実際に利用する場合はPerlのライブラリであるImage::Imlib2を経由して利用します。
このImage::Imlib2もJPEGヒントに対応させるために少し拡張しております。
実際の利用イメージとしては以下のような感じです。

1
2
3
4
5
6
7
use Image::Imlib2;

# 元サイズ1920x1200の画像を使用
my $scaledImage = Image::Imlib2->load_scale('sample.jpg', 640, 480);
$scaledImage = $scaledImage->create_scaled_image(640, 480);
$scaledImage->set_quality(100);
$scaledImage->save('sample_resized.jpg');

「load_scale」というメソッドを追加しており、これを利用することでロード時にリサイズ後のサイズを指定します。 あとは普通にリサイズして書き出してみます。 これだけで、このサイズの場合は元のJPEGヒントを利用しない場合に比べて 1.5 倍ほど高速化しております。

チューニング対応したImageMagickとの比較

それでは、ImageMagickと速度比較してみます。
対等に比較を行うために、まずImageMagickの高速化を図ります。
具体的には、以下のオプションを指定して予めコンパイルしておきます。

01. 高速化のためのImageMagickのコンパイルオプション

01. —disable-openmp を指定してOpenMPを無効にする
→これが有効になっていると、マルチスレッド実行時に異常にメモリを消費して重くなる

02. —with-quantum-depth=8 を指定してRPG分解能を 8 bitにする。
→通常はこれで十分に24/32 bitの画像を処理できます。この指定をしなくても、convert 実行時に -depth 8 をオプションとして渡してもOK

特に 01 が重要になります。
画像のサムネイル作成を高速化する場合、最初に出てくる発想としてはマルチスレッドで実行することがあげられますが、 OpenMPはそのマルチスレッドによる実行時に異常な負荷をあげる原因となっているからです。 02は -depth 8 を convert 実行時に指定しても良いと思いますが、コマンド実行時に特に意識させずに高速化したいのであればコンパイル時に指定してしまいましょう。

02. libjpeg-turboの導入

ImageMagickもImlib2も通常はlibjpegを利用しますが、このlibjpegよりも更に高速に処理ができる libjpeg-turbo というライブラリがあります。 これを利用することで更に 1.3 倍ほど高速になります。 導入方法は簡単で、こちらより最新版のRPMをダウンロードしてインストールします。 すると、/opt 配下へインストールされますので、以下のようにして共有ライブラリファイルとして認識させます。

1
2
echo /opt/libjpeg-turbo/lib64 > /etc/ld.so.conf.d/libjpeg-turbo.conf
ldconfig

これでインストール済みのImageMagickとImlib2が libjpeg-turbo の方を利用するようになります。

03. 実効速度の比較

準備が完了したところで、実効速度を比較してみます。
今回の検証では 1920x1200 のJPEG画像を 640x480 へ 1000 回リサイズした場合の速度差を比較しています。
具体的には以下の内容のシェルスクリプトを作成して速度比較してみました。

<ImageMagick>

1
2
3
4
5
6
7
8
#!/bin/bash

time /bin/bash << TIMEEND
for (( i = 0; i < 1000; i++ ))
do
    convert -define jpeg:size=640x480 -quality 100 -scale 640x480 sample.jpg sample_resized.jpg
done
TIMEEND

<Imlib2>
シェルスクリプトから呼び出すPerlスクリプト (resize.pm とします)

1
2
3
4
5
6
7
8
9
#!/usr/bin/env perl

use strict;
use Image::Imlib2;

my $scaledImage = Image::Imlib2->load_scale('sample.jpg', 640, 480);
$scaledImage = $scaledImage->create_scaled_image(640, 480);
$scaledImage->set_quality(100);
$scaledImage->save('sample_resized.jpg');

実行するシェルスクリプト

1
2
3
4
5
6
7
8
#!/bin/bash

time /bin/bash << TIMEEND
for (( i = 0; i < 1000; i++ ))
do
    `dirname $0`/resize.pm
done
TIMEEND

結果

<ImageMagick>

1
2
3
real    1m12.344s
user    0m58.140s
sys     0m8.317s

<Imlib2>

1
2
3
real    0m52.510s
user    0m36.622s
sys     0m9.565s

チューニングしたImageMagickと比較しても、JPEGヒントを利用したImlib2はかなり速いことがわかります。 画質についても素人目には違いが判りません。

まとめ

Imlib2はこのように速度が非常に速いことが利点ですが、 欠点としてはWindowsやMacへ導入するとなると少々面倒くさいので、開発時のデバッグが容易に出来ません。(その点、ImageMagickは簡単に導入できます) また、GIF画像の書き出しもサポートしていないので、GIF画像を扱う必要がある場合はImageMagickと併用するなどして対応する必要があります。 そのため、GIF以外の画像(特にJPEG画像)を多く扱い、速度を追求する必要が有る場合はImlib2の利用をお勧めします。

開発時にWindowsやMacで利用する場合は頑張ってImlib2をインストールするのもありですが、 どちらかといえばImageMagickとImlib2を同じインターフェースで利用出来るクラスを作成して、環境ごとに切り替えする方がいいかもしれません。 これについてはJavaのクラスとして近々作成予定ですので、完成次第展開いたします。

今回作成したJPEGヒント対応版のImlib2とImage::Imlib2については以下よりダウンロードしてください。

Imlib2
Image::Imlib2

Gradleでリソースファイルをフィルタリングする場合の注意点

| Comments

Gradleでリソースファイルのフィルタリングを行う例は検索サイトで調べると結構多くヒットするかと思います。 ですが、その殆どが今回紹介する注意点について考慮されていないスクリプト記述をしています。 具体的に何を注意しなければならないのでしょうか。 以下にリソースフィルタリングの例を紹介します。

問題となるスクリプト

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
 * ベースロジック
 */
project(':base') {

    apply plugin: 'java'
    apply plugin: 'eclipse'
    apply plugin: 'idea'


    processResources {

        from(sourceSets.main.resources.srcDirs) {

            // トークンを置換する
            filter(
                org.apache.tools.ant.filters.ReplaceTokens,
                tokens: [
                    'version': '1.0.0',
                    'db_ip': '127.0.0.1'
                ]
            )

        }

    }


}

上記のスクリプトは、baseプロジェクトに含まれるリソースファイル(JARやWARに含まれるソースコード以外のファイル)に対して 「@version@」と「@db_ip@」の指定が含まれていた場合にそれぞれを指定の値に置換するという指定です。
一見これは上手く動作しますし、問題ないケースもあります。

しかし、この指定は見ての通りリソースファイル全てに対して行われれます
これはどういうことなのかというと、リソースファイルがテキストファイルであれば何ら問題はないのですが、 バイナリファイルが含まれているとそれすらも置換対象となってしまうのです!

例えばWebサイトシステムでは、ExcelファイルのテンプレートとなるExcelを予めJARやWARに含めて、 それをプログラム内で利用するケースがよくあるかと思います。 その場合、リソースファイルであるExcelファイルすらも置換対象となり、ファイルが盛大に壊れます。 Mavenでフィルタリングを行うときはバイナリファイルは対象外となるため、それを期待して利用するととんでもない目に遭うわけです。

解決方法

解決策としては、以下のように対象となる拡張子を指定することで回避します。 gradle.propertiesに拡張子を定義しておくと管理しやすいです。

[gradle.properties]

1
2
# カンマ区切りで定義しておく
replace.extensions=xml,conf,dicon

[build.gradle]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
 * ベースロジック
 */
project(':base') {

    apply plugin: 'java'
    apply plugin: 'eclipse'
    apply plugin: 'idea'


    processResources {

        // フィルタ対象リソース拡張子分繰り返す
        getProperty('replace.extensions').tokenize(',').each {

            // 対象ファイルの場合
            filesMatching("**/*.$it") {

                // トークンを置換する
                filter(
                    org.apache.tools.ant.filters.ReplaceTokens,
                    tokens: [
                        'version': '1.0.0',
                        'db_ip': '127.0.0.1'
                    ]
                )

            }

        }

    }

}

filesMatchingを利用するのがミソになります。 拡張子を build.gradle へ直接記載する場合は以下のような書き方でも動作します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
 * ベースロジック
 */
project(':base') {

    apply plugin: 'java'
    apply plugin: 'eclipse'
    apply plugin: 'idea'


    processResources {

        from(sourceSets.main.resources.srcDirs) {

            // 対象拡張子のみコピー
            include 'xml'
            include 'conf'
            include 'dicon'

            // トークンを置換する
            filter(
                org.apache.tools.ant.filters.ReplaceTokens,
                tokens: [
                    'version': '1.0.0',
                    'db_ip': '127.0.0.1'
                ]
            )

        }

        // 残りのファイルをコピー
        from(sourceSets.main.resources.srcDirs) {

            exclude 'xml'
            exclude 'conf'
            exclude 'dicon'

        }

    }

}

ただ、この書き方は推奨いたしません。 なぜなら、上記の拡張子指定を動的記述すると上手く動作しないためです。 せっかくなので紹介しておきます。

悪い例

※ replace.extensions が gradle.properties に定義されていることとする

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/**
 * ベースロジック
 */
project(':base') {

    apply plugin: 'java'
    apply plugin: 'eclipse'
    apply plugin: 'idea'


    processResources {

        from(sourceSets.main.resources.srcDirs) {

            // フィルタ対象リソース拡張子分繰り返す
            getProperty('replace.extensions').tokenize(',').each {

                // 対象拡張子のみコピー
                include it

            }

            // トークンを置換する
            filter(
                org.apache.tools.ant.filters.ReplaceTokens,
                tokens: [
                    'version': '1.0.0',
                    'db_ip': '127.0.0.1'
                ]
            )

        }

        // 残りのファイルをコピー
        from(sourceSets.main.resources.srcDirs) {

            // フィルタ対象リソース拡張子分繰り返す
            getProperty('replace.extensions').tokenize(',').each {

                // 対象拡張子のみ除外
                exclude it

            }

        }

    }

}

これだと同じ拡張子を指定しているにも関わらず、上手く動作しません。
どうやら processResources において from 指定を行う場合、 同じパスに対して include と exclude の指定が被ると上手く動作せず、リソースファイルが全てJARやWARから消失します。 (ただし、直接記述すると何故か期待通りに動作する) おそらく動的記述した場合と静的記述した場合でスクリプトの解釈順に違いが出るのだと思いますが、 無理に考えても仕方ないのでこの書き方はやめましょう。

まとめ

Gradleは便利ですが、Mavenのときと同じ挙動を期待するとこのような思わぬ落とし穴があります。 processResourcesでフィルタリングを行う場合は filesMatching、またはfilesNotMatching を利用し、対象ファイルの拡張子を限定してからフィルタリングするようにしましょう。

Gradleで陥りやすい問題点の解決策TIPS集

| Comments

今回はGradleでよくハマるであろうポイントを集めたTIPSを11個紹介します。

01. 依存関係のバージョンが指定されたものにならない

MavenからGradleへ移行した場合、おそらく誰もが最初に陥る問題かと思います。 端的に言うと、Mavenの pom.xml で指定したままの依存関係の設定をそのまま build.gradle へ移したとしても 最終的に取得される依存関係は殆どのケースで同じにはなりません。 これは「推移的依存関係」によって同じライブラリが存在した場合に優先されるバージョンがMavenとGradleでは異なるために起こります。

推移的依存関係とは簡単に説明すると、ある依存関係がさらに依存する関係のことを言います。 MavenでもGradleでもそれらを自動的に取得しようとしますが、 それらの中で使っている依存関係のgroupIdもartifactIdも同じだがバージョンが異なる場合、 Javaでは同じライブラリの異なるバージョンを同じアプリ内へ共存させることが出来ませんから、 どちらかを優先させる必要が出てきます。

Mavenの場合は推移的依存関係における階層が高い方が優先されます。 しかしGradleでは、バージョンが一番高いものが優先されます。 そのため、Gradleでは利用しない依存関係をひたすら exclude 指定で除外していく必要が出てきます。

MavenからGradleへ移行する場合に依存関係のバージョンを含めて全く同じにしたい場合は、 以下のように現在の依存関係の階層構造を出力してそれを比較して合わせていけばよいです。

<Mavenで依存関係階層構造を出力>

1
2
# 対象となるプロジェクトの pom.xml のあるディレクトリへ移動して実行
mvn dependency:tree

<Gradleで依存関係階層構造を出力>

1
2
# baseプロジェクトの依存関係階層構造を出力する
gradle :base:dependencies

とはいえ、これがとにかく面倒です。 例えば自身で dependencies ブロックへ指定した依存関係が他の依存関係の推移的依存関係になっている場合、 exclude指定を行っても一見除去されないように見えたり(or 指定したバージョンにならなかったり)と、結構ハマります。 根気よく exclude 指定で不要なバージョンを除去して合わせていきましょう。

02. EclipseのWTPで起動した場合に他のプロジェクトを参照しているとその参照しているプロジェクトの依存関係を読み込まない

これも人によってはハマるポイントかと思います。 Eclipse上ではコンパイルエラーが出ていないのに、WTPでTomcatを起動すると参照しているプロジェクトの依存関係が無いと言われてしまう(ClassNotFoundExceptionが発生してしまう)現象です。 これは eclipse-wtp のプラグインの読み込み指定を行っていない場合に起こります。 どういうことなのかと言いますと、この例が起こるのは以下のように build.gradle を記述したときに起こります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
/**
 * Gradleによるプロジェクトビルドスクリプト。
 *
 * @author Kou
 */
apply plugin: 'eclipse'
apply plugin: 'idea'

/**
 * ベースロジック
 */
project(':base') {

    apply plugin: 'java'
    apply plugin: 'eclipse'
    apply plugin: 'idea'

    dependencies {

        // Struts
        compile(
                'taglibs:standard:1.1.2',
                'org.apache.struts:struts-core:1.3.10',
                'org.apache.struts:struts-tiles:1.3.10',
                'org.apache.struts:struts-taglib:1.3.10',
                'org.apache.struts:struts-el:1.3.10',
                'org.apache.struts:struts-extras:1.3.10',
        )

        // Solrj
        compile(
                'org.slf4j:slf4j-api:1.6.1',
                'org.slf4j:slf4j-log4j12:1.6.1',
                'org.apache.solr:solr-solrj:4.4.0',
        )

        // Solr Core
        compile('org.apache.solr:solr-core:4.4.0') {
            exclude group: 'org.slf4j', module: 'slf4j-jdk14'
        }

        // Test関係
        testCompile(
                'junit:junit:4.1.1',
        )

    }

}


/**
 * 管理サイト
 */
project(':admin') {

    apply plugin: 'java'
    apply plugin: 'eclipse'
    apply plugin: 'idea'
    apply plugin: 'eclipse-wtp'
    apply plugin: 'war'

    // WARファイル名
    archivesBaseName = 'admin'

    dependencies {

        compile project(':base')

    }

    eclipse {

        // Dynamic Web Projectの設定
        wtp {

            component {

                // EclipseのWTPで起動する場合のコンテキストパス
                contextPath = '/admin'

            }

            facet {

                facet name: 'java', version: getProperty('java.version')
                facet name: 'jst.web', version: getProperty('java.servlet.version')

            }

        }

    }

}

上記の例では、base プロジェクトに eclipse-wtp の指定がないことがわかります。 EclipseのWTPで起動するのは WAR プロジェクトのみです。 そのため、base プロジェクトのような通常のJavaプロジェクトは eclipse-wtp とは関係ないだろうと思いがちなのですが、 ここが落とし穴で、依存関係となるプロジェクトも含めて eclipse-wtp のプラグイン指定を行わないと、 たとえEclipse上でコンパイルエラーが出ていなくてもWTP起動時に読み込みの対象とならないのです。 なので、EclipseのWTPで起動させる場合は必ず WAR プロジェクト以外のプロジェクトにも eclipse-wtp のプラグインを読み込むよう指定してください。 (subprojects のブロックで指定すれば各プロジェクトごとにいちいち指定する必要がなくなるので記述量が減ります)

03. プロパティの参照が出来ない場合がある

主にGradleで利用するプロパティは gradle.properties に記述する方法や、 コマンド実行時に -P オプションで指定するのが一般的です。 そのプロパティをスクリプト内で参照する場合、以下の 2 つの参照方法があります。

1. プロパティ名の先頭に $ をつけてダブルクォーテーションで囲む
2. getPropertyメソッドにプロパティ名を指定して取得する

記述が短いので $ をつけて参照する方法を選んでしまいがちですが、 以下のようにドットやハイフンを含むプロパティ名の場合は参照できません。

× java.version=1.6
× java-version=1.6
○ java_version=1.6

これは Bash をはじめとしたシェルスクリプトの変数と同じ仕様です。 Javaのpropertiesファイルは慣例的にドット区切りでプロパティ名を記述する傾向があります。 なので少なからずこの問題に陥る方もいらっしゃるかと思います。

じゃあ、ドットやハイフンを使った名前は利用できないのか?というと、そういうわけではありません。 上記の 2 の方法である getPropertyメソッドを利用すればドットやハイフンを含んだプロパティ名を参照できます。

そのため、私が普段スクリプトを記述する場合は、 コマンド実行時に -P オプションで指定したプロパティは 1 の方法で参照するようにし、 gradle.properties で指定されたプロパティは 2 の方法で参照するように区別しています。 その方がスクリプトを見たときにどこから指定されたプロパティを参照しようとしているのかが判断しやすいです。

04. hasPropertyメソッドが常に false を返してしまう

これもGradle初心者は陥りやすいかと思います。 -P オプションでプロパティを指定している場合で、 かつそのプロパティが必須ではない場合、プロパティの有無をスクリプト内で判断しないとエラーになってしまいます。 そこで、以下のように書いてみます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def     test

// 使用するプロファイル名が指定されている場合
if (hasProperty('propertyTest')) {

    // 使用するプロファイル名を設定する
    test = "$propertyTest"

} else {

    // デフォルトのプロファイル名を設定する
    test = 'defaultTest'

}

この場合、常に hasProperty メソッドは失敗してしまいます。 hasPropertyメソッドを何も指定せずに呼び出すと TaskのhasPropertyメソッドがコールされてしまい、 実際にプロジェクトに設定されたプロパティを判断できなくなる場合があります。 そこで、以下のように指定すると正常に判定することが出来ます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def     test

// 使用するプロファイル名が指定されている場合
if (project.hasProperty('propertyTest')) {

    // 使用するプロファイル名を設定する
    test = "$propertyTest"

} else {

    // デフォルトのプロファイル名を設定する
    test = 'defaultTest'

}

上記のように「project.hasProperty」をコールすることで正常にプロパティ有無を判定することが出来るようになります。

05. 環境ごとにリソースファイルを切り替えたい

Mavenでは使用するリソースファイルを複数指定することが可能で、 更にはビルド時にプロファイルを指定することでそれを環境ごとに切り替えることが出来ます。 これをGradleで実現するには様々な方法がありますが、ここでは私が普段利用しているスクリプト例を紹介します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/**
 * Gradleによるプロジェクトビルドスクリプト。
 *
 * @author Kou
 */
apply plugin: 'eclipse'
apply plugin: 'idea'


/**
 * サブプロジェクト全体の共通設定。
 *
 */
subprojects {

    apply plugin: 'java'
    apply plugin: 'eclipse'
    apply plugin: 'eclipse-wtp'
    apply plugin: 'idea'

    sourceSets {

        def     useProfileName     // 指定環境名称

        // 使用するプロファイル名が指定されている場合
        if (project.hasProperty('profileName')) {

            // 使用するプロファイル名を設定する
            useProfileName = "$profileName"

        } else {

            // デフォルトのプロファイル名を設定する
            useProfileName = 'local'

        }


        // メインリソースの指定
        main.resources {

            srcDirs 'src/main/resources'
            srcDirs "src/profile.$useProfileName/resources"

        }

    }

    eclipse {

        classpath.defaultOutputDir = file('/target/classes')                // クラスファイル出力先

    }

}

上記の「sourceSets」のブロック内がリソースを環境ごとに切り替える処理になります。 この build.gradle のプロジェクト設定は前回も紹介したとおりIDEでも利用される設定となります。 そのため、ここでは環境指定が無い場合はローカル環境の設定であるものと見なしています。

では、他の環境指定を行うにはいつ行えばいいのかというと、これはビルド時に指定します。 WARやJARのビルドを行うときに -P オプションでプロパティを与えて読み込む環境を指定します。

1
gradle --daemon -PprofileName=development war

こうすることで、「src/main/resources」「src/profile.development/resources」ディレクトリにあるリソースファイルが WARに追加されるようになります。

06. testCompileで依存関係となるプロジェクトを指定した場合にそのプロジェクトの testCompile を参照してくれない

これはどうやら仕様(?)のようで、 compileの場合は「compile project(‘:base’)」といったようにプロジェクトを指定するとその指定したプロジェクトの依存関係を継承しますが、 testCompileの場合は「testCompile project(‘:base’)」と指定しても、どういうわけか継承しないようです。 この問題に対するスマートな解決方法というのは現時点ではあまりないのですが、 強いてあげるとすれば、以下のように各プロジェクトごとに testCompile の依存関係を指定してあげることで ある程度管理しやすくなります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/**
 * Gradleによるプロジェクトビルドスクリプト。
 *
 * @author Kou
 */
apply plugin: 'eclipse'
apply plugin: 'idea'

/**
 * 依存関係を定義する
 */
ext {

    libs = {

        // テストで利用する依存関係
        testCompile: [
            'junit:junit:4.4',
            'org.easymock:easymock:3.2',
            'org.hamcrest:hamcrest-all:1.3',
        ],

    }

}


/**
 * ベースロジック
 */
project(':base') {

    dependencies {

        // Test関係
        testCompile libs.testCompile

    }

}


/**
 * 管理サイト
 */
project(':admin') {

    apply plugin: 'war'

    // WARファイル名
    archivesBaseName = 'admin'

    dependencies {

        compile project(':base')

        // Test関係
        testCompile libs.testCompile

    }

}

このように、拡張プロパティでテストの依存関係をまとめた変数を宣言し、 そこに利用する依存関係を全て記述すれば比較的管理のしやすい記述ができます。 これについてはもっとスマートな記述方法が今後登場しましたらそのときに再度紹介させていただきます。

07. EclipseのWTPを利用している場合に provided や providedCompile の指定が効かない

これは以前紹介させて頂いたEclipseのGradleプラグインである「Gradle Integration for Eclipse」のプラグインの仕様のようで、 このプラグインは自前で作成した provided の指定やWARプラグインを指定した場合のみ利用できる providedCompile の指定をしたとしても、 WTPでAPサーバを起動したときに WEB-INF/lib のディレクトリへ強制的に全ての依存関係ライブラリがコピーされてしまいます。要は provided であることを考慮してくれません。 この問題に対する公式な解決方法としては、Eclipseの設定から「Gradle」→「WTP」を選択し、そこに除去するプラグインを記述すれば WEB-INF/lib ディレクトリへコピーされなくなる、とのことです。 せっかくGradleを利用しているのに手動で設定するという点からもなんとも腑に落ちないですが…現状このプラグインを利用している以上はこうするしかないようです。

もう1つの解決策としては、「Gradle Integration for Eclipse」プラグインでインポートせず、 「gradle eclipse」コマンドを自前で実行してEclipseのプロジェクト設定ファイルを作成し、 作成後のプロジェクトをインポートするという方法です。 この方法であれば provided も考慮してプロジェクト設定を作成してくれます。 ただし、手動でコマンドを実行しなければならないという点がなんともスマートではないことと、 build.gradle を変更した場合(依存関係などを追加した場合)に再度コマンドを実行し直さなければなりません。 「Gradle Integration for Eclipse」プラグインを利用している場合はこのあたりを自動でやってくれます。

provided で除去するのは殆どの場合 servlet-api や jsp-api ですので、 腑に落ちない気持ちはありつつも、「Gradle Integration for Eclipse」プラグインを使用し、 Eclipseの設定で除去してしまう方法で利用していく方が無難かと思われます。

08. 複数のMavenリポジトリを定義する

参照するMavenリポジトリは repositories ブロック内で以下のように定義します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
 * サブプロジェクト全体の共通設定。
 *
 */
subprojects {

    apply plugin: 'java'
    apply plugin: 'eclipse'
    apply plugin: 'eclipse-wtp'
    apply plugin: 'idea'

    repositories {

        maven {

            url getProperty('project.maven.repository.url.01')

        }
        mavenCentral()

    }

}

任意のリポジトリを複数定義する場合は、mavenブロックに url パラメータを複数記述するかと思いがちですが、 そのような記述は出来ません。 以下のように記述します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
 * サブプロジェクト全体の共通設定。
 *
 */
subprojects {

    apply plugin: 'java'
    apply plugin: 'eclipse'
    apply plugin: 'eclipse-wtp'
    apply plugin: 'idea'

    repositories {

        maven {

            url getProperty('project.maven.repository.url.01')

        }
        maven {

            url getProperty('project.maven.repository.url.02')

        }
        mavenCentral()

    }

}

上記のように、mavenブロックを複数宣言することでURLを複数指定できます。

09. アーカイブタスクで exclude 指定が効かない

これも人によってはAntやMavenのときの記述に慣れてしまっているが故に勘違いしてしまいがちなのですが、 Zipなどのアーカイブタスクで exclude 指定をした場合に効かないように見えてしまう場合が有ります。

例として、前回紹介したbatchのパッケージを作成するタスクにおいて一部のファイルをZIPファイルへ含めないようにしたいと思います。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
 * バッチ
 */
project(':batch') {

    /**
     * リリース用パッケージの作成を行う。
     *
     */
    task distribution(dependsOn: 'jar', type: Zip) {

        into ('batch/bin') {

            from 'bin'
            exclude 'bin/test.sh'   // test.sh を除去する指定

        }


        into ('batch/libs') {

            from 'build/libs/batch.jar'
            from configurations.compile

        }


    }

}

上記では、binディレクトリの中身にある test.sh のコピーを除去する想定で記述しました。 しかし、実際にこの状態でアーカイブを作成すると、 test.sh ファイルはZIPの中に含まれてしまいます。 AntやMavenの場合だと、exclude の指定を行えばそれだけで対象となるパスのファイルが除去されましたが、 Gradleの場合だと、以下のように指定する必要があります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
 * バッチ
 */
project(':batch') {

    /**
     * リリース用パッケージの作成を行う。
     *
     */
    task distribution(dependsOn: 'jar', type: Zip) {

        into ('batch/bin') {

            from ('bin') {

                exclude 'test.sh'   // test.sh を除去する指定

            }

        }


        into ('batch/libs') {

            from 'build/libs/batch.jar'
            from configurations.compile

        }


    }

}

上記のように記述することで bin/test.sh ファイルをコピーしないようになります。 Gradleのアーカイブタスクにおける exclude 指定は、このようにコピーする対象となるディレクトリの from ブロック内に exclude 指定を記述する必要があります。

10. ブランチ切り替えを頻繁に行った場合に他のブランチのクラスファイルが残る場合がある

これは主にJenkinsでJavaビルドを行う場合に起こることが多いのですが、JenkinsではジョブのSCMのブランチ切り替えを頻繁に行うかと思います。 そうなると、例えばあるブランチにはあって、あるブランチにはないクラスファイルというものが当然のように出てきます。 そして、そのクラスファイルの一部が残ることでビルドエラーが発生する場合があります。 これを回避するには、以下のように「clean」タスクをビルド前に実行するように指定することで回避することが出来ます。(尤も、これはGradleに限らずMavenでも同じです)

1
gradle --daemon clean war

clean タスクを実行すると更新する必要がないクラスも再コンパイルするようになってしまいますので、 ブランチ切り替えを行わないような例では開発スピードをあげるために、あえて clean を指定しないというのも手かと思います。 とはいえ、本番環境へリリースするWARやJARをビルドする場合は、万が一のことを考えて clean タスクを指定してビルドすることをお勧めします。

11. 突然動作がおかしくなった場合にキャッシュをクリアする方法

例えば、MavenリポジトリのURLを変更したなど、 さまざまな理由でキャッシュが残った場合に動作がおかしくなってエラーが発生する場合が有ります。 その場合、Gradleの作業用ディレクトリを削除すれば正常に動作する場合が有ります。 ユーザのホームディレクトリにある「.gradle」というディレクトリを削除することでキャッシュをクリアすることが出来ます。 ただしこの場合、Mavenの依存関係を一から取得し直しになるために、初回のビルド時に時間がかかります。 あくまで原因不明のエラー回復のための最終手段と思ってください。

まとめ

今回は私がGradleを利用した上でハマるであろうと考えたポイントを 11 個紹介させていただきました。 AntやMavenに慣れた人たちからすると、Gradleは最初のうちは少々取っ付きにくいものであるため、 このようにいくつかハマってしまうポイントが発生します。 Gradleをこれから使ってみようと思う皆様の参考になれば幸いです。

Gradleによるビルドとパッケージ作成

| Comments

前回の続きです。 Gradleのプロジェクトの設定が終わっただけでは当然ながら終わりません。 次はビルドが必要になります。 プロジェクト構成は前回の例をそのまま踏襲するとして、 実際にビルドをするときのコマンドを紹介します。

WARとJARのビルド

WARのビルドは以下のコマンドで行います。 (build.gradle ファイルのあるディレクトリへ移動してコマンドを実行します)

1
gradle --daemon war

なんとこれだけでWARのビルドが出来ます。 非常に簡潔です。 Gradleコマンドは立ち上がりが遅いのですが、上記のように —daemon というオプションを指定することでバックグラウンドに常駐するようになり、2回目以降のコマンドの立ち上がりが早くなります。

さて、前回のプロジェクト構成ですと、WARプロジェクトではないbatchプロジェクトが存在していたかと思います。 batchの場合はWARではなくJARとして作成する必要がありますので、上記のコマンドでは当然ビルドされません。 以下のコマンドでビルドします。

1
gradle --daemon :batch:jar

「:batch:jar」という指定は batch プロジェクトの jar タスクを実行するという指定になります。 WARのビルドの場合、build.gradle に記述されているWARプロジェクトを全て自動的に走査してビルドしてくれますが、 JARの指定を行う場合、WARプロジェクト自体もJARビルドが出来てしまうため、「gradle —daemon jar」としてしまうと 不要なプロジェクトに対してまでJARビルドが走ってしまいます。そのため、上記のようにプロジェクトを指定してビルドしています。

また、gradleの実行タスクはひとまとめにすることが出来ますので、WARビルドとbatchプロジェクトのJARビルドを合わせると以下のようになります。

1
gradle --daemon war :batch:jar

リリース用パッケージの作成

次はリリース用のパッケージの作成についてです。 デプロイ時はAPサーバにそのままWARをコピーすれば良いのですが、 必要なファイルを全てひとまとめに圧縮して対象サーバへ転送し、ファイルを展開するような例も多いかと思います。 また、batchファイルについてはWARと違い、依存関係となるライブラリも全てコピーしてあげなくてはいけません。

Mavenだと maven-assembly-plugin というXMLに記述した構成でパッケージを作成するプラグインがありましたが、 これもXMLファイルに設定を記述していくので記述量が増えがちで若干面倒な感じがありました。 GradleではWARやJARの他にZIPやTARを生成するタスクも用意されており、 これらを組み合わせることで maven-assembly-plugin と同等の機能を実現できます。

具体的にbatchパッケージを作成する例を紹介しますと、batchプロジェクトの設定に以下のようにタスクを定義します。 (設定は前回のものを踏襲)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
 * バッチ
 */
project(':batch') {

    // クラスパスへ provided を追加
    sourceSets.main.compileClasspath     += project(':base').configurations.provided
    sourceSets.test.compileClasspath     += project(':base').configurations.provided
    sourceSets.test.runtimeClasspath     += project(':base').configurations.provided
    eclipse.classpath.plusConfigurations += project(':base').configurations.provided
    idea.module.scopes.PROVIDED.plus     += project(':base').configurations.provided

    dependencies {

        compile project(':base')

    }


    /**
     * リリース用パッケージの作成を行う。
     *
     */
    task distribution(dependsOn: 'jar', type: Zip) {

        into ('batch/bin') {

            from 'bin'

        }

        into ('batch/libs') {

            from 'build/libs/batch.jar'
            from configurations.compile

        }

    }

}

上記にある distribution というタスクが新しく追加したタスクになります。 「type: Zip」という指定からもわかるように、このタスクではZIPファイルを作成します。 次に、dependsOn という指定がありますが、これは distribution タスクの実行が指定されたときに distributionタスク実行前に実行するタスクの指定になります。 ここでは jar タスクが指定されていますので batchプロジェクトのjar ビルドが先に実行されるということになります。 そして、distribution タスク内に記述されたディレクトリ構成でZIPファイルが作成されるという仕組みです。 into というブロックの引数に ZIP ファイルのルートからのパスを記述し、 そのブロック内に from という指定でコピーするファイルを指定するという感じです。

上記の例で実際に作成されるZIPファイルとその中身は以下のようになります。

batch - bin  - (batch/binに含まれているファイル。シェルスクリプト等)
        libs - batch.jar
               batchプロジェクトの依存関係となるJARファイル全て
               (configurations.compile 変数にコンパイル時に全ての依存関係ファイルへのパスが含まれている)

ちなみに、build/libs/batch.jar ファイルは jar タスクでビルド後に作成されるJARファイルになります。 (デフォルトだとbatchプロジェクトのディレクトリからの相対パスで build/libs/batch.jar として作成されます) 見ても判る通り、maven-assembly-pluginに比べて記述量が遥かに少ないことが判ります。 このようにしてリリース用パッケージも簡単に作ることが出来ます。

以上をまとめると、最終的にWARとJARおよびbatchパッケージを作成するコマンドは以下のようになります。

1
gradle --daemon war :batch:distribution

まとめ

このように、Gradleだとビルドとパッケージの作成も非常に簡単にできます。 ここまでの話ですとMavenを利用する場合に比べての優位性というのは有るには有るが、敢えて移行する必要まであるか?と感じられている方は多いかも知れません。 実際Mavenでも十分な例は多いです。

ただ、MavenとGradleが決定的に違うところは、 前者は「設定ファイルを記述する」ビルドツールであり、 後者は「スクリプトを記述する」ビルドツールであることです。 つまり、後者は println を使って途中経過を出力することが可能なので、デバッグも容易です。 Mavenのように正しい設定ありきで内部の処理の動きを追えないツールと比べると大いに勝っている利点であると私は考えています。

また、ここまで紹介していませんでしたが、 GradleはGradle自体をインストールしていない環境でもJavaさえあれば実行できる gradlew という仕組みもあります。 これも Maven にはない大きな利点です。

次回はこれらの点の紹介も含めて、Gradleを利用する際によく利用するであろうTIPS集をいくつか紹介していきたいと思います。

GradleでEclipseとIntellij IDEAの開発環境を作る

| Comments

ちょっと前まではMavenでプロジェクトを構築している人も多かったですが、 Android StudioがGradleを採用してからはGradleによるプロジェクト構築にシフトしている人も増えてきました。 とはいえ、Web業界で古いシステムを保守・運用している方だとまだまだMavenメインの利用者が多いかと思いますので、 Mavenからの移行も含めた観点で記載して行きます。

Mavenと比較したGradleの利点

Mavenと比較したGradleの利点を簡単にまとめると以下のような感じです。
(この記事を執筆時のGradleの最新バージョンは 1.11 です)


  • 記述量が圧倒的に少ない
  • 記述言語がGroovyなのでJavaも使える。そのため比較的なんでも記述できる
  • AntやMavenの機能も利用できる。特にAntはほぼすべての機能を利用可能。MavenはMavenプラグイン以外は殆ど利用可能
  • IDE(EclipseやIntellij IDEA)の設定も細かく記述でき、そこからIDEの設定ファイルを生成できる

まだまだありますが、代表的な利点はこんな感じです。


プロジェクトの準備

では早速ですが、各種設定をGradleで記述するための準備をしていきます。 今回の例では、以下のようなプロジェクト構成とします。


プロジェクト名 説明 コンテキストパス
base ベースロジックを含んだJavaプロジェクト なし
batch バッチシステム。baseプロジェクトを依存関係とする なし
admin 管理サイト。baseプロジェクトを依存関係とする /admin
front ユーザが閲覧するサイト。baseプロジェクトを依存関係とする /

また、各プロジェクトはMavenプロジェクトと同じで、以下のような一般的なWebアプリの構成にしておきます。

1
2
3
4
5
6
7
8
9
10
11
12
13
base - src - main - java
                  - resources

batch - src - main - java
                   - resources

admin - src - main - java
                   - resources
                   - webapp

front - src - main - java
                   - resources
                   - webapp

このディレクトリ構成にしておくと、余計な設定を記述する必要がないので便利です。
そして、Gradleにおいて設定ファイルを記述する場合、「build.gradle」というファイル名で設定ファイルを作成し、 この例のように複数プロジェクトがある場合は、Gradleのサブプロジェクトという機能を利用します。 サブプロジェクト機能を利用するには、「settings.gradle」というファイルを更に用意し、 build.gradleから定数値を外部ファイルから読み込みたい場合は、gradle.propertiesというファイルを用意します。 以上を踏まえた上で、ファイルとディレクトリ構成としては以下のようになります。

1
2
3
4
5
6
7
project - base
        - batch
        - admin
        - front
        - build.gradle
        - gradle.properties
        - settings.gradle

settings.gradle の記述

まず、settings.gralde を記述していきます。

1
2
3
4
include base
include batch
include admin
include front

これは project ディレクトリをメインのプロジェクトとしており、 その配下にある include で指定したディレクトリをサブプロジェクトとして扱うという指定になります。 以下のように指定することも可能です。

1
include base, batch, admin, front

gradle.properties の記述

以下の定数値を用意します。

1
2
3
4
5
6
7
8
# Mavenの自分サーバ用リポジトリ
project.maven.repository.url=http://192.168.1.200/maven

# Javaのバージョン
java.version=1.6

# Servletのバージョン
servlet.version=2.5

今回はこれらの値のみ外部ファイルへ記述します。

build.gradle の記述

それでは、メインとなる build.gradle の記述をしていきます。
以下のような内容になります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
/**
 * Gradleによるプロジェクトビルドスクリプト。
 *
 * @author Kou
 */
apply plugin: 'eclipse'
apply plugin: 'idea'


/**
 * サブプロジェクト全体の共通設定。
 *
 */
subprojects {

    apply plugin: 'java'
    apply plugin: 'eclipse'
    apply plugin: 'eclipse-wtp'
    apply plugin: 'idea'

    repositories {

        maven {

            url getProperty('project.maven.repository.url')

        }
        mavenCentral()

    }

    configurations {

        // providedのコンパイル用ライブラリパス
        provided

    }

    // Javaのバージョンを指定
    sourceCompatibility = getProperty('java.version')
    targetCompatibility = getProperty('java.version')

    eclipse {

        classpath.defaultOutputDir = file('/target/classes')                // クラスファイル出力先

    }

}


/**
 * ベースロジック
 */
project(':base') {

    // クラスパスへ provided を追加
    sourceSets.main.compileClasspath     += configurations.provided
    sourceSets.test.compileClasspath     += configurations.provided
    sourceSets.test.runtimeClasspath     += configurations.provided
    eclipse.classpath.plusConfigurations += configurations.provided
    idea.module.scopes.PROVIDED.plus     += configurations.provided

    dependencies {

        // Java API
        provided(
                'javax.servlet:servlet-api:2.5',
                'javax.servlet.jsp:jsp-api:2.0',
        )

        // Struts
        compile(
                'taglibs:standard:1.1.2',
                'org.apache.struts:struts-core:1.3.10',
                'org.apache.struts:struts-tiles:1.3.10',
                'org.apache.struts:struts-taglib:1.3.10',
                'org.apache.struts:struts-el:1.3.10',
                'org.apache.struts:struts-extras:1.3.10',
        )

        // Solrj
        compile(
                'org.slf4j:slf4j-api:1.6.1',
                'org.slf4j:slf4j-log4j12:1.6.1',
                'org.apache.solr:solr-solrj:4.4.0',
        )

        // Solr Core
        compile('org.apache.solr:solr-core:4.4.0') {
            exclude group: 'org.slf4j', module: 'slf4j-jdk14'
        }

        // Test関係
        testCompile(
                'junit:junit:4.1.1',
        )

    }

}


/**
 * 管理サイト
 */
project(':admin') {

    apply plugin: 'war'

    // WARファイル名
    archivesBaseName = 'admin'

    // クラスパスへ provided を追加
    sourceSets.main.compileClasspath     += project(':base').configurations.provided
    sourceSets.test.compileClasspath     += project(':base').configurations.provided
    sourceSets.test.runtimeClasspath     += project(':base').configurations.provided
    eclipse.classpath.plusConfigurations += project(':base').configurations.provided
    idea.module.scopes.PROVIDED.plus     += project(':base').configurations.provided

    dependencies {

        compile project(':base')

    }

    eclipse {

        // Dynamic Web Projectの設定
        wtp {

            component {

                // EclipseのWTPで起動する場合のコンテキストパス
                contextPath = '/admin'

            }

            facet {

                facet name: 'java', version: getProperty('java.version')
                facet name: 'jst.web', version: getProperty('java.servlet.version')

            }

        }

    }

}


/**
 * バッチ
 */
project(':batch') {

    // クラスパスへ provided を追加
    sourceSets.main.compileClasspath     += project(':base').configurations.provided
    sourceSets.test.compileClasspath     += project(':base').configurations.provided
    sourceSets.test.runtimeClasspath     += project(':base').configurations.provided
    eclipse.classpath.plusConfigurations += project(':base').configurations.provided
    idea.module.scopes.PROVIDED.plus     += project(':base').configurations.provided

    dependencies {

        compile project(':base')

    }

}


/**
 * フロントサイト
 */
project(':front') {

    apply plugin: 'war'

    // WARファイル名
    archivesBaseName = 'ROOT'

    // クラスパスへ provided を追加
    sourceSets.main.compileClasspath     += project(':base').configurations.provided
    sourceSets.test.compileClasspath     += project(':base').configurations.provided
    sourceSets.test.runtimeClasspath     += project(':base').configurations.provided
    eclipse.classpath.plusConfigurations += project(':base').configurations.provided
    idea.module.scopes.PROVIDED.plus     += project(':base').configurations.provided

    dependencies {

        compile project(':base')

    }

    eclipse {

        // Dynamic Web Projectの設定
        wtp {

            component {

                // EclipseのWTPで起動する場合のコンテキストパス
                contextPath = '/'

            }

            facet {

                facet name: 'java', version: getProperty('java.version')
                facet name: 'jst.web', version: getProperty('java.servlet.version')

            }

        }

    }

}

基本はこれだけでOKです。 Mavenの pom.xml と比較すると記述量が大分少なくなっていることがわかります。 また、Mavenの場合は、各プロジェクトのディレクトリ配下に pom.xml を記述する必要がありましたが、 Gradleでは1つのスクリプト内にすべての設定を記述できます。(敢えて分けて記述することも出来ます)

では、1つ1つ解説していきます。

サブプロジェクト全体の設定

各サブプロジェクト全体の共通設定は subprojects というブロック内で指定します。 ここから1つ1つ見て行きましょう。

01. プラグインの指定

先頭に apply plugin という指定がありますが、 これはどのプラグインを利用するかという指定です。 「Javaのimportのように単純に使いたいメソッドやクラスとかを呼び出すために定義しているだけだろう」と思われる方もいるかもしれませんが、 Gradleにおいては少し意味合いが違って、apply pluginを記述した段階でビルド動作に影響を与えます

1
2
3
4
apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'eclipse-wtp'
apply plugin: 'idea'

この4つの指定では、各サブプロジェクトが
・「Javaプロジェクトである」
・「Eclipseプロジェクトである」
・「Eclipse WTPプロジェクトである」
・「Intellij IDEAプロジェクトである」
ということをGradle側へ伝えています。 この記述をすることで、各サブプロジェクトごとにEclipseやIntellij IDEAの設定ファイルを生成することが出来るようになります。

02. providedコンパイルの設定

「configurations」というブロックがあります。ここではプロジェクトで利用する設定パラメータを定義できます。 後述する依存関係の記述に関係しますが、providedコンパイルを実現するために、ここで provided というパラメータを宣言しています。 実は、Gradleでは Maven のような provided コンパイルの動作を標準ではサポートしていません。 とはいえ、簡単に同様の処理を実現することが出来ます。そのための準備として、ここにパラメータを宣言します。

03. Javaのバージョンの設定

「sourceCompatibility」「targetCompatibility」という記述がありますが、 これはビルド時に使用されるJavaのバージョン指定になります。 これを記述しない場合、インストールされているJDKのバージョンでビルドされます。 また、ここで指定したバージョンは、EclipseのJavaのバージョン指定にも利用されます。 基本的には環境依存がないようにバージョン指定をしておくことをオススメします。

04. Eclipseでコンパイルしたクラスファイルの出力先の設定

「eclipse」というブロック内に、「classpath.defaultOutputDir」というプロパティがあります。 ここに指定されたディレクトリにコンパイルしたクラスファイルが出力されます。 指定しない場合は、各プロジェクトのディレクトリ直下に「bin」というディレクトリが作成され、そこに出力されます。

依存関係の記述

今回の例では、baseプロジェクトに利用する依存関係をすべて定義し、 各サブプロジェクトにおいては base プロジェクトそのものを依存関係として定義しています。 これはGradleに詳しくない方でも上記スクリプトを見ても大体予想がついているかと思いますが、 「dependencies」という部分で定義します。 dependenciesではMavenリポジトリにあるライブラリを指定することが出来ます。 指定の形式としては、以下のようになります。

1
compile 'groupId:artifactId:version'

groupId、artifactId、versionをコロン(:)区切りで指定します。 しかし、Servlet APIのJARなど、中にはWARビルド時にWAR内へ含めたくないライブラリもあります。 その場合は以下のような指定が必要になります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
configurations {

    // providedのコンパイル用ライブラリパス
    provided

}

dependencies {

    provided 'javax.servlet:servlet-api:2.5'

}

// クラスパスへ provided を追加
sourceSets.main.compileClasspath     += project(':base').configurations.provided
sourceSets.test.compileClasspath     += project(':base').configurations.provided
sourceSets.test.runtimeClasspath     += project(':base').configurations.provided
eclipse.classpath.plusConfigurations += project(':base').configurations.provided    // Eclipse用のクラスパス
idea.module.scopes.PROVIDED.plus     += project(':base').configurations.provided    // Intellij IDEA用の Provided クラスパス

このような指定をすることで、 依存関係をリポジトリから取得しつつも、WARには含めないようにすることが出来ます。 今回は base プロジェクトに宣言された provided な依存関係を base プロジェクトを依存関係とする各プロジェクトへ追加するため 「project(‘:base’).configurations.provided」を追加していますが、 そのプロジェクトのみで provided の依存関係を定義する場合は「configurations.provided」を各クラスパスへ += で追加するようにしてください。

WARの設定

今回はTomcatを利用することを前提としていますので、 その場合の設定です。 まず、EclipseでWTPを利用する場合の設定が以下になります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
eclipse {

    // Dynamic Web Projectの設定
    wtp {

        component {

            // EclipseのWTPで起動する場合のコンテキストパス
            contextPath = '/'

        }

        facet {

            facet name: 'java', version: getProperty('java.version')
            facet name: 'jst.web', version: getProperty('java.servlet.version')

        }

    }

}

各ブロック名を見れば勘のいい方は大体理解できるかと思います。 Eclipseの動的Webプロジェクトの必要な設定を上記の設定で記述しています。 また、実際のWARファイル名は以下の指定になります。

1
2
3
4
5
6
7
8
9
10
11
/**
 * フロントサイト
 */
project(':front') {

    apply plugin: 'war'

    // WARファイル名
    archivesBaseName = 'ROOT'

}

ここで指定された名前がWARファイル名になります。(拡張子は不要)
Tomcatの場合だと、server.xmlに記述をしない場合はWARファイル名がそのままコンテキストパスになりますので
(ROOTという名前は例外として / になる) ここでWARファイル名を指定します。

一見するとEclipseの方で設定しているからWARファイル名は勝手に設定してくれればいいと思う方もいるかもしれませんが、 Eclipseの設定はあくまでEclipseにおける設定なので、 実際にWARを作成時に使われる設定値ではありません。

実際にIDEで読み込んでみる

あとは実際にIDEでGradle Projectとしてインポートすれば完成です。
Gradle Projectとしてインポートするためには、Eclipseの場合はEclipse Marketplaceにある「Gradle Integration for Eclipse」プラグインをインストールしてください。
このプラグインを利用すると、リモートリポジトリから取得した依存関係の参照パスを ローカルPCのパスとしてEclipse設定ファイルへ記述しなくなるため Eclipseプロジェクトの設定ファイルを複数人で共有するプロジェクトにおいては最適です。

Gradle Integration for Eclipseプラグインをインストールしたら
このプラグインのEclipse設定からソースファイルのエンコーディングの設定(-Dfile.encoding=UTF-8)を追加するのを忘れないでください。
そのままだとWindowsのデフォルトエンコーディングがMS932のため問題が出ます。

Intellij IDEAにおいては、12以上のバージョンを利用している場合はJetGradleというIDEのエンジンが build.gradleの内容を解析してプロジェクトの作成をしてくれます。
ただし、Intellij IDEAのartifactsやfacetの設定はすべてを自動ではやってくれないので一部手動で行う必要があるのと、 上記で記述した provided 指定したライブラリについては、JetGradle側で export 設定を何故か行わないため
ここも手動でチェックをつけてあげる必要が出てきます。
13.1からは自動でartifactsやfacetも生成してくれますが、artifactsは複数プロジェクトに対応していないのかROOTコンテキストのプロジェクト分しか作成されません。
Gradle側のIntellij IDEA対応はあまり熱心ではないみたいなのでこの辺はいずれ対応してくれるであろうことを期待しましょう。

【2014/04/28追記】 Intellij IDEAでartifactsが作成されない不具合がEAP版の13.1 135.760で修正されました。 早々に試したい方はこちらからダウンロードできます。

まとめ

以上、早足で説明しましたが、 導入としてはこれくらいの知識があれば利用できます。 次回は実際にデプロイを行うときの手順を Mavenからの移行の観点を交えて説明していきたいと思います。

EXISTSとSQLの高速化について

| Comments

SQL高速化についてはいろんなサイトで取り上げられているので 今更取り上げる必要はないかと思っていましたが、 ふと最近仕事をしている中でハマっている人が多いポイントであると感じたため 改めて書いてみることにしました。

EXISTSが速いという誤解

EXISTSについて書かれたサイトを見ると、 「速い」というような記述を見かけることが多いかと思います。 しかし、これはあくまでサブクエリを組んだ場合に、INやイコールを使って比較するときと比べて速い場合が多いというだけであり、 EXISTSが速いというわけでは決してありません。 ハッキリ言ってしまうと、EXISTSを使うクエリは基本的に遅いです。

これは正確に言うと、EXISTSを利用するケースにおいて相関サブクエリが使われていることが原因で遅くなっています。 相関サブクエリとはどういうものか、以下にメンバー情報を格納した MEMBER テーブルと、 各メンバーごとのアクセスログを記録した ACCESS_LOG テーブルを例に説明してみます。

<MEMBERテーブル>

カラム名 内容
MEMBER_ID メンバーID。プライマリキー
NAME メンバー名

<ACCESS_LOGテーブル>

カラム名 内容
MEMBER_ID メンバーID。プライマリキー1
DATE 記録日時。プライマリキー2
IP IPアドレス
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
SELECT
    MEMBER.MEMBER_ID,
    MEMBER.NAME
FROM
    MEMBER
WHERE
    EXISTS (
    SELECT
        *
    FROM
        ACCESS_LOG
    WHERE
        ACCESS_LOG.MEMBER_ID = MEMBER.MEMBER_ID  /* サブクエリでメインクエリのIDを参照している */
    AND
        ACCESS_LOG.DATE >= '2013-08-01'
    AND
        ACCESS_LOG.DATE < '2013-09-01'
    )

上記のように、メインとなるクエリのIDをサブクエリで参照しているクエリを相関サブクエリと言います。 この相関サブクエリが遅い理由は、 上記でいう「MEMBERテーブルのレコード数×ACCESS_LOGテーブルのレコード数」の数だけ比較処理が実行されてしまうからです。 (EXISTS句を利用しているので、正確には一致するレコードが見つかればそこで比較処理は終了しますが、 見つからなかった場合は「MEMBERテーブルのレコード数×ACCESS_LOGテーブルのレコード数」の比較処理が実行されてしまいます)

これは各テーブルのレコード数が増えれば増えるほど、処理が一気に重くなることを示しています。 特にこの例でいうアクセスログというものは、分単位でレコードが増えるようなデータとなりますので、 システム規模が大きいとレコード数は1000万以上になることが予想されます。

この場合、MEMBERテーブルのレコード数が 3000 件ほどであったとしても、 アクセスログが1000万件あるとしたら、「3000×1000万=300億」となり、膨大な数の比較を行うことになります。 300億という比較回数が重たいということは誰の目から見ても明らかでしょう。

EXISTS(というより相関サブクエリ)は見た目としてはわかりやすい記述ではありますが、 このようにレコードの組み合わせの数だけ比較処理が実行されてしまうため、 非常に効率が悪い処理ということになります。

EXISTSをINNER JOINに置き換える

これを解決するための手段が、INNER JOINへの置き換えになります。 すべてのEXISTSはINNER JOINへ置き換え可能です。(余程特殊な記述でなければ) なぜINNER JOINへ置き換えると速くなるのかは、以下のSQL例を元に説明します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
SELECT
    MEMBER.MEMBER_ID,
    MEMBER.NAME
FROM
    MEMBER
INNER JOIN (
    SELECT
        DISTINCT ACCESS_LOG.MEMBER_ID
    FROM
        ACCESS_LOG
    WHERE
        ACCESS_LOG.DATE >= '2013-08-01'
    AND
        ACCESS_LOG.DATE < '2013-09-01'
) LOG_TEMP
ON
    LOG_TEMP.MEMBER_ID = MEMBER.MEMBER_ID

このクエリが先述の相関サブクエリを利用したクエリと何が違うのかと言いますと、 まず INNER JOIN の中にクエリが組まれています。 これはWHERE句に記述されたサブクエリとは違い、メインクエリよりも先に実行されます。 テンポラリテーブルと同じと言えばわかる人にはわかるかと思います。

なので、まずINNER JOINの中で、日付範囲を絞って該当するアクセスログのメンバーIDを重複を除去して取得します。 この時点で、結構な数の絞り込みが完了しています。(この1000万件が2011年からのアクセスログだとしたら、数十万件程度には絞れていることになります) その絞り込みが完了した結果と INNER JOIN すると、相関サブクエリを利用する場合と比べて劇的に比較回数が抑えられます。

処理時間はサーバの処理速度にもよるのであくまで目安ですが、 先述の相関サブクエリを利用したクエリで30分〜1時間応答が返ってこなかったクエリも、 後述のINNER JOINへ置き換えた形に直すことで、1分以内に結果を取得することが出来ます。 INNER JOINの形はデータ量が増えても比較回数が増えにくいため、非常に強力です。

NOT EXISTSの置き換え

EXISTSについてはINNER JOINへの置き換えが可能であることがわかりましたが、 NOT EXISTSについてはどうなるでしょうか。 「存在しない」ことを調べるわけですから、互いに存在する条件で取り出すINNER JOINでは表現することが出来ません。 NOT EXISTSと同等の結果を JOIN で表現するには、 LEFT JOINで結合し、WHERE句でIDが null かどうかを判定することで NOT EXISTS と同等の判定を行うことが出来ます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
SELECT
    MEMBER.MEMBER_ID,
    MEMBER.NAME
FROM
    MEMBER
LEFT JOIN (
    SELECT
        DISTINCT ACCESS_LOG.MEMBER_ID
    FROM
        ACCESS_LOG
    WHERE
        ACCESS_LOG.DATE >= '2013-08-01'
    AND
        ACCESS_LOG.DATE < '2013-09-01'
) LOG_TEMP
ON
    LOG_TEMP.MEMBER_ID = MEMBER.MEMBER_ID
WHERE
    LOG_TEMP.MEMBER_ID is null /* ACCESS_LOGに含まれないメンバーIDを調べる */

この場合も、NOT EXISTSを利用する場合と比べて、 先にLEFT JOIN内のサブクエリが実行されるため(先に絞り込みを行って件数を減らした上で LEFT JOIN をしているので)高速になります。 条件がない絞り込みの場合でも、MySQLの場合であればJOINの方が最適化されやすい傾向にあるので、 その場合であってもNOT EXISTSよりも速いことが多いです。

まとめ

このように、普段EXISTSを始めとした相関サブクエリをさりげなく使っている場面は多いかと思いますが、 システムのデータ量が増えれば増えるほど速度低下を招くクエリであるためオススメできません。 記述的には少し記述量が増える場合もありますが、それに見合った速度を得ることが出来ますので、EXISTSは積極的にJOINへ置き換えることをお勧め致します。