Edited at

パッケージ管理していなかった既存システムに後付けで Gradle を導入した話


この資料について

JJUG CCC 2019 Spring の表題セッションの発表資料を Qiita にて公開したものです (twitter: #ccc_a2)。

image.png

内容は上記のスライドとほぼ同じですので、見やすい方で見ていただければと思います。


はじめに


今でこそ一般化している Maven や Gradle 等ですが...


  • 2004 : Maven 1.0

  • 2005 : Maven 2.0

  • 2006 : Ivy 1.4.1 が Apache Incubator に入る


    • ※ Ivy = Scala (sbt) などでも使われているパッケージ管理ツール



  • 2007 : Gradle の Initial release

  • 2010 : Maven 3.0

弊社 エムスリー(株) の創業は 2000 年 (J2SE こと Java 2 あたりの時代)。

Maven, Gradle, Ivy がまだ無い時代から Java を使っていました。


Maven, Gradle がない時代に Java をフル活用した結果...

git clone すると以下のようなファイルが...

lib/app/hibernate-validator-4.3.0.Final.jar

lib/app/aws-java-sdk-core-1.10.27.jar
lib/app/aws-java-sdk-s3-1.10.27.jar
lib/app/generator-runtime-0.3.0-20050901.1909.jar
lib/app/xalan.jar
lib/app/crimson.jar
... 以下延々と続く ...

歴史とともに利用ライブラリが増えた結果、182 個の .jar が転がっていた。


.jar を直接管理するオペレーション

このようなプロセスでメンテナンスしている状態だった:

適当なところから手作業で jar をダウンロードして Git に入れるという闇のオペレーションの図


ライブラリのバージョンアップのつらみ

例: 新機能を使うために aws-java-sdk のバージョンを上げたい

aws-java-sdk 系は複数のライブラリに間接依存

182 個の .jar をどうバージョンアップするべきか分からない

AWS の新しい機能を使うのを諦める :cry:


間接依存のつらみ

ある jar を使っていないので消したい

もしかしたら、いずれかの jar がそれに依存しているかも...

不要 jar の掃除ができない

JVM バージョンアップや脆弱性対応などの足かせに :cry:


各種ツールを使えないつらみ

脆弱性情報をチェックしたい

定期的に目検で 182 個の .jar と脆弱性情報を照合

しんどい & 限界がある

放置しがちになってしまう

Gradle, maven 等であれば OWASP dependency-check plugin で自動化できるのに...


こうなってしまっていた背景

当該システム以外は maven, Gradle 導入済みであるのに対して...


  • 当該システムは創業時からのものであり、特に歴史が長い

  • Ant のビルドスクリプトがガッツリ組まれており、一括リプレースはつらい

  • 普段の開発作業は問題なく回るので... (※ ライブラリ周りの闇を無視している限りは)

こういった事情でパッケージ管理ツールが入っていない事例は世の中にもあるのではないでしょうか...。


パッケージ管理ツール(Gradle, maven など)を入れましょう!

とはいえ、既存のプロジェクトにどうやって導入するのか...?


  • 作業の負担が大きくなりがちな点をどうするのか?

  • 入れることによる副作用や影響をどうするのか?

→ 次章へ


パッケージ管理ツール導入の戦略

〜 歴史あるシステムの改善の 実現可能性のある スコープ定義 〜


パッケージ管理ツールの導入での課題

既存のシステムへの後付け導入では、課題がいろいろ現れる:


  • ツール導入前後で jar が変わってしまうことを良しとするか?

  • 再入手困難な jar や central と一致しない jar をどうするか?

  • ビルドプロセスにも手を入れるか?

課題に振り回されないために、ポリシーがあったほうが良い。


弊社事例で設定したポリシー


  • パッケージ管理ツール導入前後で、.jar ファイルに差分を出さないようにする


    • アプリへの影響懸念・テスト負担を抑えるため

    • 差分を回避不能なものは、無害な差分しかないことを確かめる (手法は後述)

    • 棚卸しや ver up は次の改善のスコープにする



  • ビルドプロセスも手を入れない


    • まずはパッケージ管理を改善する!



パッケージ管理ツールの恩恵を得ることにスコープを絞り、

小さく・意味のあるチャレンジをする状況を作り出した。


パッケージ管理ツール導入前の状態

適当なところから手作業で jar をダウンロードして Git に入れるという闇のオペレーションの図


パッケージ管理ツール導入でこうなった

jar のダウンロードだけを Gradle 化した図


Git に .jar を上げることの是非

:thumbsup: メリット:


  • Ant などからの移行が容易

  • 常に確実に同じ jar ファイルを使える

:thumbsdown: デメリット:


  • Ant が残る


    • Ant で真に困り事が発生したらその時にリプレース




  • .jar の更新を繰り返すとレポジトリが肥大化するであろう


    • そのうち Git submodule なりに分離する




レガシー改善の戦略

真に 困っていること変えるメリットがあること を見極める

そのために必要な 必要十分な最小スコープ を考える

小さいチャレンジ で目的を達成する (コスパ高, 失敗してもダメージ小)

「レガシーだから全部抹殺するしかない!!!」ではなく、

今ある良い点や変える必要がない点は踏襲しつつ改善。


テクニック(戦術)パート


パッケージ管理ツール導入テクニック: Gradle を後付け導入する方法

〜 後付け導入でありがちな諸課題への具体的な対処方法 〜


Gradle or Maven

今回のような用途には Gradle の方がオススメ:


  • 柔軟性: Groovy や Kotlin DSL で柔軟な処理を記述可能


    • 歴史的なプロジェクトの構成に maven は対応できないことがある



  • 調査性: Groovy Javadoc のほうが読みやすい (主観)

  • 生産性: IDE のデバッガをフル活用できる


    • ブラックボックス的に格闘しなくて済む



  • 再現性: Gradle wrapper があるため、動作の再現性を(比較的)確保しやすい

※ 一般的には Maven の方が優れている事柄もある


Gradle によるファイルコピー

既存ビルドシステムを活かしつつ Gradle 導入するため、

以下のファイルを Gradle で所定のディレクトリにコピーしたい:


  • コンパイル時・実行時に依存する jar

  • 単体テスト時に依存する jar

  • Source jar

また、 .classpath ファイル(後述)も自動でメンテナンスしたい。


Gradle で jar ファイルをダウンロード

意外と簡単:

def compileJarCopyTo = 'development/lib/app'

task downloadJars() {
doLast {
// コピー先にある *.jar を消す
delete fileTree(dir: compileJarCopyTo, include: '*.jar')

// コピーする
copy {
from configurations.compile
into compileJarCopyTo
}
}
}


copy task

コピー対象 jar の指定や include, exclude なども出来て便利:

copy {

// testCompile = test で依存する jar をコピー
from configurations.testCompile

into testCompileJarCopyTo

// 指定したパターンに該当する jar は無視
exclude 'jmockit-*.jar'
}


Source jar のコピーもやれば出来る

Source jar をコピーするためには、これらゴリゴリ操作するコードを書く:

import org.gradle.jvm.JvmLibrary

import org.gradle.language.base.artifact.SourcesArtifact
import org.gradle.api.artifacts.query.ArtifactResolutionQuery
import org.gradle.internal.component.external.model.DefaultModuleComponentIdentifier

( org.gradle.internal も現れてしまっていますが... )

→ 先人の Gradle 2.x 向けのコードを参考に、Gradle 5.x 対応版を
Copy source jar files into directory (Qiita) へ投稿してあります


.classpath ファイルの生成

IDE に jar やソースコードを読み込ませるための設定ファイル。

もともと Eclipse 用の形式だが、InteliJ IDEA にも対応している。

せっかくなのでこのファイルも Gradle でメンテナンスさせたい。

Gradle の eclipse plugin を適用するだけで生成できるが...


.classpath 絶対パス問題

Plugin が生成する .classpath 中のパスは絶対パス。

環境依存のパスになってしまい、Git などで共有できず不便。

解決策:

- 各自の手元で gradle eclipseClasspath して .classpath を都度生成する

- :thumbsdown: 全員が毎回実行する手間が発生

- :thumbsdown: ビルドの再現性が下がる (手元では動いたのに!現象)

- 相対パス表記の .classpath を Gradle で生成し、jar ファイルと共に commit する

- :thumbsup: Jar ファイルのコピーなどとセットでまとめて実行するだけで OK


.classpath を相対パス表記で生成する

eclipse plugin の pathVariables '.': projectDir ではなく、参考サイト などにあるように plugin のフックを使って拡張すると良い:

eclipse.classpath.file {

whenMerged { classpath ->
classpath.entries.findAll { entry -> entry.kind == 'lib' }.each {
it.path = it.path.replaceFirst("${projectDir.absolutePath}/", '')
it.exported = false
}
}
}


eclipse.classpath.filewhenMerged

whenMerged.classpath ファイルの内容を自由に制御できる

例えば:


  • jar ファイルの参照先のパスを任意のロジックで書き換える


    • 弊社事例では、一部ファイルだけ異なるディレクトリに置いている




  • classpath.entries.sort.classpath の並び順を制御


    • JMockit のようなロード順依存するライブラリ対策で有用




  • classpath.getEntries().removeAll { return (条件式) }


    • IDE に読み込ませたくない jar の除外が可能




jar のファイル名の変化

手作業管理の jar ファイル名と、Gradle 由来の jar はファイル名が一致しないことが多い。例えば:


  • 手動管理のファイル名: crimson.jar

  • Gradle でのダウンロード結果: crimson-1.1.3.jar

ファイル名決め打ちで参照している場合に問題が出る。

特に以下がありがち:


  • Ant などのビルドツールの classpath におけるファイル名の決め打ち

  • プログラムの起動時の JVM の classpath オプションでのファイル名の決め打ち


jar のファイル名が変わることへの対処策


  1. (おすすめしない) 後処理で旧来のファイル名になるようにリネームする



    • :thumbsdown: リネーム元の group, artifact 名が変わった場合に事故る


    • :thumbsdown: 新しい jar が増えたときの対応に困る



  2. 個別のライブラリを意識しないようにする



    • :thumbsup: 特定ディレクトリ以下の jar をすべて読み込むだけで OK な設計にする


      • Ant, JVM classpath (>= 6), シェルなどの * ワイルドカードを活用


      • * の解釈がそれぞれ微妙に異なる点には要注意





後者は手間ではあるが、一過性の手間なので対応してしまったほうが長期的に良い。


パッケージ管理ツール導入テクニック: 諸事情ある jar の管理

〜 歴史の彼方からやってきた jar ファイルを扱うための手法 〜


古い jar ファイルにありがちなこと


  • jar を再入手できたがバイナリが一致しない



    • SNAPSHOT

    • SNAPSHOT なのに差し替えリリースされている

    • Maven repository 間で違うバイナリが置いてある

    • 経緯不明の独自パッチ :imp:



  • 公式ページが消えており、jar やソースを再入手できない


jar のバイナリ不一致の原因を確かめる

不一致は放置せずに、具体的な差分を確認すると良い:


  • パッケージ管理ツール導入の影響範囲・リスクを限定できる

  • テストすべき範囲やテスト手法も明らかになる

筆者は Gradle 化前後で jar ファイルの SHA-1 ハッシュを出し、

ハッシュが一致しない jar について具体的な差分を調べた。


jar ファイルの差分の調べ方

jar ファイルは zip であり、展開すると以下のファイルが出てくる:



  1. .class ファイル (Java バイトコード)


  2. META-INF などのメタデータファイル

  3. その他、プロパティファイルなどのリソースファイル

.class 以外はほとんどテキスト比較ツールで比較できる。

lucene-analyzers-2.4.0META-INF/MANIFEST.MF で差分が出たときの例:

8c8

< Implementation-Version: 2.4.0 701827 - 2008-10-05 16:46:27
> Implementation-Version: 2.4.0 701827 - 2008-10-05 16:44:47


jar の差分の調べ方: class ファイル

.class ファイルは Java バイトコードのバイナリである。

JDK に入っている javap -c でテキスト表現に変換することで、

テキスト比較ツールで比較可能になる。

guice-servlet-1.0.jar で差分が出たときの例:

2c2

< final class com.google.inject.servlet.ServletScopes$1 implements com.google.inject.Scope {
> class com.google.inject.servlet.ServletScopes$1 implements com.google.inject.Scope {

(final を足した jar で差し替えられている)


再入手不能なライブラリ

当該ライブラリの利用停止以外の対策:


  • (:imp: 非推奨) Central 等の公開レポジトリにアップロードする



    • :thumbsdown: ライセンスや道義的に好ましくない


    • :thumbsdown: 公知の jar と一致する確証のない jar を公開することに



  • (:thinking: 微妙) 対象のライブラリの .java ファイルを直接使う



    • :thumbsdown: ソースを直接使うため、独自改造や密結合が起きがち


    • :thumbsdown: ライセンスによっては問題あり



  • (おすすめ) 内部用のプライベートなレポジトリで管理



    • :thumbsup: 権利問題が起きにくい


    • :thumbsup: 当該ライブラリの利用状況の把握や削除がやりやすい




プライベートレポジトリ

再入手不能 jar に限らず、内製のライブラリの管理などでも

プライベートなレポジトリがあると便利。

よくある構築方法:


  1. ローカル上の maven レポジトリを file:// で参照する


    • マシン間の同期が必要

    • ビルドの再現性を損ないがち

    • jar の紛失リスクやバックアップの手間もある




  2. NexusArtifactory などのレポジトリサーバーを動かす (便利)


    • 詳細なセットアップ方法については触れないが、新規でセットアップするならば Nexus の方が楽




プライベートレポジトリの利用


  1. 世の中に存在するライブラリは、今までどおり maven central などから取得

  2. プライベートなものは、プライベートレポジトリから取得

Gradle ならば以下のようにするだけで実現できる:

repositories {

maven {
url 'http://central.maven.org/maven2/'
} // mavenCentral() という短縮記法はあえて使っていない (後述)

maven {
url "http://プライベートなレポジトリのURL"
}
}


レポジトリへ jar をアップロード

メタデータを Maven の XML (pom.xml) 形式で書いて jar と共にアップロードする。

一見とっつきにくいかもしれないが、今回の目的に絞れば簡単:

<project>

<modelVersion>4.0.0</modelVersion>

<!-- この jar 自体の識別情報 -->
<groupId>com.m3</groupId> <artifactId>m3generator</artifactId>
<version>1.0.0</version>

<!-- この jar が依存する jar の情報 -->
<dependencies>
<dependency>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
<version>1.2.17</version>
</dependency>
...


(Artifactory) Auto generated POM には注意

Artifactory が POM を自動生成してくれるので便利に見えるが...

<dependencies> が空だったり間違っていたりすることが多い

アップロードした jar の依存ライブラリの情報を得られなくなる

パッケージ管理ツールによる依存関係管理のメリットが失われる

自動生成に丸投げせずに <dependencies> をちゃんと書こう。


レポジトリ間で重複するライブラリ

同名だが一致しない(差分がある)ライブラリがある場合、

どちらが読み込まれるか明示的に制御すべし:


  • プライベートレポジトリへ別 version でアップロード

  • または、Gradle 側で制御する (下記)

repositories {

maven { // Maven central
url 'http://central.maven.org/maven2/'

content {
excludeGroup 'com.sun.jmx' // このグループをここから取得しない
excludeModule 'jdom', 'jdom' // このライブラリをここから取得しない
}
// 上記記述をするため mavenCentral() といった短縮記法は使っていない
}


最後に


Next Step

パッケージ管理ツールを入れたら特にやっておきたいこと:


  • Version 衝突の検知

  • classpath 上の重複の検知

  • OWASP dependency check による脆弱性情報のチェック

JJUG 2018 Spring のスライドでより深く説明しています:


  • タイトル: Spring Boot と一般ライブラリの折り合いのつけかた



※ 資料の後半は Spring Framework 関係なく使える情報です


まとめ

今回、パッケージ管理ツールを入れた際のプロセス:


  • ゴールを定義


    • パッケージ管理でどう良くなるかのビジョンを描く



  • 戦略を構築


    • 実現のために何をするか・何をあえて「しない」のか



  • テクニックで課題を解決: 今回言及した各種工夫


    • Gradle の活用

    • jar の差分チェック

    • プライベートレポジトリの活用



「闇が深くて手がつけられない」とも思える状況でも、

適切なスコープを設定し、技術で実現する ことで前に進める。