Lombokをカスタマイズして独自のAST変換

## はじめに

### コレは何？
[Lombok](https://projectlombok.org/index.html)をカスタマイズして、アノテーションの指定による引数チェックを行うライブラリを作成した際のメモ兼宣伝です。
ソースは[GitHub](https://github.com/develhack/develhacked-lombok)に置いてあります。

### 動機
1. メソッドの先頭に毎回、引数チェックの処理を記述する事に疲弊した。
1. 他のメンバーが作成したAPIのリファレンスに、引数に指定出来る値の範囲が記載されていないくて立腹した。
1. 他のメンバーが作成したAPIの、リファレンスに書かれている内容と実装とが異なっていて絶望した。
1. 自分の作成したAPIのリファレンスを見直したら、引数に指定出来る値の範囲が記載されていないくて反省した。
1. 自分の作成したAPIの、リファレンスに書かれている内容と実装とが異なっていて猛省した。
1. [JSR-305とFindBugs](http://findbugs.sourceforge.net/findbugs2.html#jsr305)を試してみたが、業務アプリケーションにはあまり適合しない[^unconformable]と思った。
1. Lombokでnullチェック以外のアサーションも出来れば良いと思った。
1. なら作ってみよう。

### 方針
* 本家側の更新を楽に取り込むために、既存のコードは極力変更しない。
* デバッグの容易性を重視し[^undebuggable]、実行時のライブラリ依存を許容する。
* 本家が提供するアノテーション（`@Setter`とか`@Data`とか）との組み合わせが困難[^uncombinable]なので、最低限の代替手段を提供する。

### 制限事項
* 前述の通り、本家が提供するアノテーションとは組み合わせられません（残念の極み）。
* この記事を書いている時点では、本家の[v1.16.4](https://github.com/rzwitserloot/lombok/releases/tag/v1.16.4)をベースにしているため、Eclipse 4.5 (Mars)で色々と不具合がある模様です。

## 本家が用意してくれているビルドスクリプトの基本的な使い方
以下の手順は全て、リポジトリのルートディレクトリで行う前提です。
予め[本家のリポジトリ](https://github.com/rzwitserloot/lombok.git)をそのままcloneするか、Forkしたリポジトリをcloneしておいて下さい。
また、Lombokのビルドには[Ant](http://ant.apache.org/)が必要になりますので、予めダウンロードしてパスを通しておいて下さい。


### Eclipseにインポート
私はEclipseを常用しているので、下記コマンドでEclipseにインポート出来る形式（プロジェクト形式）に変換した後にインポートしました。
なお、プロジェクト名を`lombok`から変更する場合は、`build.xml`の`eclipse`ターゲットを修正しておく必要があります[^diff]。

```shell-session
ant eclipse
```

### ビルド
下記コマンドでJARを生成できます。
なお、プロジェクト名を`lombok`から変更した場合は`build.xml`や`buildScripts/website.ant.xml`等を色々と修正する必要があります[^diff]。

```shell-session
ant dist
```

### テスト
> 私の環境では[一部の設定を修正](https://github.com/develhack/develhacked-lombok/commit/22feb9cd423695aba045db57811ce0c989e1aefb)しないとECJ[^ecj]を使用したテストがちゃんと動きませんでした。

以下のいずれかのコマンドを実行し、テストを行うJDKのバージョンを指定します。
なお、前述の手順でEclipseのプロジェクト形式に変換してある場合、Eclipseからテストを実行するための`RunLombokTest <JDK名>.launch`というファイルが生成されます。

```shell-session:OpenJDK6
ant setupJavaOpenJDK6TestEnvironment
```
```shell-session:OpenJDK7
ant setupJavaOpenJDK7TestEnvironment
```
```shell-session:OracleJDK7
ant setupJavaOracle7TestEnvironment
```
```shell-session:OracleJDK8
ant setupJavaOracle8TestEnvironment
```

JDKのバージョンを指定した後に、以下のコマンドによりテストを実行します。
なお、JDKのバージョンを切り替えてテストを行う場合は、前述のコマンドにより再度JDKのバージョンを指定した後に以下のコマンドを実行します。

```shell-session
ant test
```

### Eclipseでの動作確認
下記コマンドを実行する事により`LombokizedEclipse.launch`というファイルがプロジェクト直下に生成され、メニューの`Run As...`から、このプロジェクト（で`ant dist`で生成したJAR）を適用したEclipseを実行出来るようになります。

```shell-session
ant eclipseForDebugging
```

![LombokizedEclipse.png](https://qiita-image-store.s3.amazonaws.com/0/11179/b7583495-1335-71ea-b285-ba587d65990a.png)

### その他にやった事
* 独自の設定項目を追加するために[ConfigurationKeys.java](https://github.com/develhack/develhacked-lombok/blob/master/src/core/lombok/ConfigurationKeys.java)に定義を追記[^addition]しました。
* 何故か`test/core/src/lombok/LombokTestSource.java`だけがLombokを使用しないとコンパイル出来ないコードとなっていたので修正しました（テストコードなのでリソースリークの可能性は気にしない）。
* 成果物を[Bintray](https://bintray.com/)にアップロードするために[gradle-bintray-plugin](https://github.com/bintray/gradle-bintray-plugin)を使用したかったので、かなり強引な`build.gradle`を作成しました。


## 実装方法

### アノテーションを定義する
処理対象の起点となるアノテーションを定義します。

私の場合は、[別のライブラリ](https://github.com/develhack/develhack-core)としてアノテーションとチェックロジックを実装したため、当該のライブラリを`buildScript/ivy.xml`の`dependencies`に追加しました。

なお、私の定義したアノテーションには[`RetentionPolicy.RUNTIME`](http://docs.oracle.com/javase/8/docs/api/java/lang/annotation/RetentionPolicy.html#RUNTIME)を指定してあります[^retentionpolicy]が、特に理由が無ければ[`RetentionPolicy.SOURCE`](http://docs.oracle.com/javase/8/docs/api/java/lang/annotation/RetentionPolicy.html#SOURCE)を指定する方が良さ気です。

### ハンドラを実装する
JavacとECJ[^ecj]とではASTの構造が微妙に異なり、APIは完全に別物です。
そのため、[Javac向けのハンドラ](https://github.com/develhack/develhacked-lombok/tree/master/src/core/com/develhack/lombok/javac/handlers)と[ECJ向けのハンドラ](https://github.com/develhack/develhacked-lombok/tree/master/src/core/com/develhack/lombok/eclipse/handlers)をそれぞれ実装する必要があります[^handlers]。

私の場合は、メンテナンスしやすい様に、Javac向けのハンドラとECJ向けのハンドラとで極力同じ構造にしました。
なお、ECJ向けのハンドラでは、Javadocコメントを扱う事が出来ない模様です。

### テストする
`/test/transform/resource`の下にある各サブディレクトリに、AST変換前のソースと、AST変換後のソースの期待値[^delombok]やコンパイル時に出力されるメッセージの期待値等を配置します。

<dl>
  <dt><code>before</code></dt>
  <dd>AST変換前のソース</dd>
  <dt><code>after-delombok</code></dt>
  <dd>Javac向けのハンドラによってAST変換された後のソースの期待値</dd>
  <dt><code>after-ecj</code></dt>
  <dd>ECJ向けのハンドラによってAST変換された後のソースの期待値</dd>
  <dt><code>messages-delombok</code></dt>
  <dd>Javacを使用してコンパイルした際に出力されるメッセージの期待値</dd>
  <dt><code>messages-ecj</code></dt>
  <dd>ECJを使用してコンパイルした際に出力されるメッセージの期待値</dd>
  <dt><code>messages-idempotent</code></dt>
  <dd><code>after-delombok</code>にあるソースをコンパイルした際に出力されるメッセージの期待値</dd>
</dl>

私の場合は、既存コードは基本的に修正しない方針（既存のソースはテストされている前提）としたので、[自作のハンドラのみをテストするためのテストケース](https://github.com/develhack/develhacked-lombok/tree/master/test/transform/src/com/develhack/lombok/transform)を作成して`build.xml`の`test`ターゲットを修正しました。


## ハマった事

### アノテーションの単純名
`com.develhack.annotation.assertion.NonNull`というアノテーションを定義したのですが、対応するハンドラが実行されませんでした。
どうやらLombokはアノテーションの単純名とハンドラの実装とを対応付けて管理している様で、Lombokが元々提供している`lombok.NonNull`と単純名が重複していたのが原因でした。
アノテーション名を`com.develhack.annotation.assertion.Nonnull`（nが小文字）に変更した所、期待した動作となりました。

### ECJでのフィールド追加
`EclipseAnnotationHandler#handle()`内でASTにフィールドを追加しようとしたら残念な結果になりました。
ECJでフィールドを追加する場合は`EclipseAnnotationHandler#handle()`ではなく`EclipseAnnotationHandler#preHandle()`で行う必要がある模様です。

## 所感

* Lombokステキ。
* AST変換も黒魔術だけど、バイトコード変換よりは白いと思う。
* [バイトコード変換の仕組みが標準化されている](http://docs.oracle.com/javase/8/docs/api/java/lang/instrument/Instrumentation.html)んだから、AST変換の仕組みも標準化されれば良いと思った。

[^unconformable]: 上限値の指定や下限値の指定、範囲の指定等が出来ない。
[^undebuggable]: Lombokによって生成されたステートメントはソースコード上に現れないためデバッグが困難。
[^uncombinable]: 本家の実装が、各ハンドラ内で`@Nonnull`と`@Nullable`の指定有無をそれぞれ（正規表現によるcase-insensitiveなマッチングで）判定しているため、それ以外のアノテーションに対応しようとすると修正箇所が非常に多くなってしまう。
[^diff]: 修正した箇所の全量は[初回コミットの差分](https://github.com/develhack/develhacked-lombok/commit/a87046d43e4ea6878b04701228ba5c6e39f341d9)参照（追加したハンドラも含まれているけど・・・）。
[^addition]: 最初は別のクラスとして定義しようとしたが、そっちのほうが修正箇所が多くなってしまいそうだった。
[^retentionpolicy]: Lombokと組み合わせなくてもリフレクション等で使用出来るようにしておきたかった。
[^ecj]: Eclipse Compiler for Java
[^handlers]: ぶっちゃけ、Javac向けのハンドラとECJ向けのハンドラの両者を実装するのは苦行です・・・。
[^delombok]: AST変換はソースコードを変換する訳ではないので、正確には「変換後のASTをソースコードで表現した場合はこうなっているハズ」の期待値。