ブログ

ErrorProne Custom Plugin を書く

ソフトウェアエンジニアの iwami です。
この記事では、Java で書かれたコードベースの品質を維持する施策の一つとして ErrorProne を使って独自のルールを定義し、コードベース特有のコンベンションやアンチパターンについて規約の強制・警告・提案をさせてみようと思います。
目次

ErrorProne とは

ErrorProne は javac のプラグインとして動作する Java の静的解析ツールで、コンパイル時によくある問題を見つけてコンパイルエラー・警告として報告してくれます。Google の Java ビルドシステムで使われており、オープンソース化されたものです。
checkstylegoogle-java-format など構文的な規約を検査・自動修正する linter/ formatter と違い、コンパイラの持つ AST (抽象構文木) やシンボルなどの情報を使って、より意味的な問題の検知・修正の提案などができます。
定義済みの Bug Patterns だけでも多くの一般的なバグやアンチパターンを検知でき、その直し方まで提案できるものもあります。例えば Java 標準ライブラリの間違いやすい仕様や誤った使い方による潜在的なバグなどの解説もされていて、使うだけでバグを減らせるだけでなくとても勉強になります。
ビルドシステム毎の導入方法は Installation に詳しい説明があります。NullAway などサードパーティ製のルールを追加することもできます。

BugPattern の使い方

ErrorProne では独自のルールを Plugin checks として実装することができます。執筆時点で設定の例は紹介されていますが詳細な説明はないので API のドキュメント (error-prone check api 2.3.4 API) を読んで使います。多くの場合、定義済みの実装 (com.google.errorprone.bugpatterns) を参考にして、近いものから少ない差分で作る事ができると思います。
例によれば、@BugPattern を付けて BugChecker を実装したクラスを ServiceLoader に読み込ませると動くようです。BugChecker は AST の Visitor として振る舞い、都度メッセージや修正などのを含む Description を返します。AST は javac plugin の API の型をそのまま使っているようなので、com.sun.source.tree に仕様があります。各種 Matcher などのユーティリティが用意されているので都度探して使うと良さそうです。ユニットテストには CompilationTestHelperBugCheckerRefactoringTestHelper が使えます。
試しに、カスタムプラグイン、そのユニットテスト、それを使ってコンパイルするサンプルコードを書いてみます。簡単な例として、Lombok の @lombok.Data と @lombok.Builder が一度に付いたクラスを検知してみます。(全フィールドが mutable なデータクラスは Builder のほぼ利点がなく、混乱しやすいからです。)
ここでは custom plugin とそれを使ってコンパイルするコードを同じコードベースに配置し、Gradle と Bazel で設定を書いてみます。ほぼ同じ設定でリポジトリを分けて参照する事もできます。
コードはこちら。github.com/flywheel-jp/techblog_201912_errorprone

@AutoService(BugChecker.class)
@BugPattern(
    name = "LombokDataAndBuilder",
    linkType = BugPattern.LinkType.CUSTOM,
    link = "https://projectlombok.org/features/Value",
    tags = BugPattern.StandardTags.FRAGILE_CODE,
    summary = "@lombok.Builder and @lombok.Data rarely need to be attached to a class at once.",
    explanation =
        "@lombok.Builder may be useless for fully mutable @lombok.Data class. "
            + "Consider to make the class immutable, or omit @lombok.Builder for fully mutable @lombok.Data class.",
    severity = BugPattern.SeverityLevel.WARNING)
@SuppressWarnings("serial")
public class LombokDataAndBuilder extends BugChecker implements BugChecker.ClassTreeMatcher {
  private static final Matcher<AnnotationTree> IS_DATA = Matchers.isType("lombok.Data");
  private static final Matcher<AnnotationTree> IS_BUILDER = Matchers.isType("lombok.Builder");
  @Override
  public Description matchClass(ClassTree tree, VisitorState state) {
    List<? extends AnnotationTree> annotations = tree.getModifiers().getAnnotations();
    return annotations.stream()
        .filter(ann -> IS_BUILDER.matches(ann, state))
        .findAny()
        .flatMap(
            builderAnn ->
                annotations.stream()
                    .filter(ann -> IS_DATA.matches(ann, state))
                    .findAny()
                    .map(
                        dataAnn ->
                            buildDescription(tree)
                                .addFix(SuggestedFix.replace(dataAnn, "@lombok.Value"))
                                .addFix(SuggestedFix.delete(builderAnn))
                                .build()))
        .orElse(Description.NO_MATCH);
  }
}
@RunWith(JUnit4.class)
public class LombokDataAndBuilderTest {
  private final CompilationTestHelper compilationTestHelper =
      CompilationTestHelper.newInstance(LombokDataAndBuilder.class, getClass());
  @Test
  public void test() {
    compilationTestHelper
        .addSourceLines(
            "Test.java",
            "// BUG: Diagnostic contains: LombokDataAndBuilder",
            "@lombok.Data @lombok.Builder class Test {}")
        .doTest();
  }
}

Bazel のビルド設定はそれぞれ次のようになります。

java_plugin(
    name = "CustomCheckPlugin",
    srcs = glob(["*.java"]),
    visibility = ["//visibility:public"],
    deps = [
        ":auto_service",
        "@maven//:com_google_errorprone_error_prone_annotation",
        "@maven//:com_google_errorprone_error_prone_check_api",
        "@maven//:com_google_errorprone_javac",
    ],
)
java_test(
    name = "LombokDataAndBuilderTest",
    srcs = ["LombokDataAndBuilderTest.java"],
    data = ["@maven//:com_google_errorprone_javac"],
    jvm_flags = ["-Xbootclasspath/p:$(location @maven//:com_google_errorprone_javac)"],
    runtime_deps = ["@maven//:org_projectlombok_lombok"],
    deps = [
        "//custom-checks/src/main/java:CustomCheckPlugin",
        "@maven//:com_google_errorprone_error_prone_test_helpers",
    ],
)

Gradle のビルド設定は次のようになります。

configurations {
    testJavac {
        transitive false
    }
}
dependencies {
    compileOnly 'com.google.errorprone:error_prone_check_api:2.3.4'
    compileOnly 'com.google.auto.service:auto-service-annotations:1.0-rc6'
    annotationProcessor 'com.google.auto.service:auto-service:1.0-rc6'
    testJavac 'com.google.errorprone:javac:9+181-r4173-1'
    testImplementation 'com.google.errorprone:error_prone_test_helpers:2.3.4'
    testRuntimeOnly 'org.projectlombok:lombok:1.18.10'
}
test {
    jvmArgs "-Xbootclasspath/p:${configurations.testJavac.singleFile}"
}

テストのための jvm 引数に ErrorProne の javac を渡す必要があることに注意して下さい。
Bazel では java のターゲットの plugins に、Gradle では errorprone dependency を追加するだけで利用できます。ビルドを実行すると次のように警告が出ます。

$ bazelisk build //src/main/java:Example
INFO: Analyzed target //src/main/java:Example (1 packages loaded, 24 targets configured).
INFO: Found 1 target...
INFO: From Building src/main/java/libExample.jar (1 source file) and running annotation processors (AnnotationProcessorHider$AnnotationProcessor):
src/main/java/Example.java:3: warning: [LombokDataAndBuilder] @lombok.Builder and @lombok.Data rarely need to be attached to a class at once.
class Example {}
^
    (see https://projectlombok.org/features/Value)
  Did you mean '@lombok.Value' or to remove this line?
Target //src/main/java:Example up-to-date:
  bazel-bin/src/main/java/libExample.jar
INFO: Elapsed time: 2.137s, Critical Path: 2.04s
INFO: 2 processes: 2 worker.
INFO: Build completed successfully, 3 total actions
$ gradle :compileJava
> Task :compileJava
/Users/iwami/git/techblog_201912_errorprone/src/main/java/Example.java:3: warning: [LombokDataAndBuilder] @lombok.Builder and @lombok.Data rarely need to be attached to a class at once.
class Example {}
^
    (see https://projectlombok.org/features/Value)
  Did you mean '@lombok.Value' or to remove this line?
1 warning
BUILD SUCCESSFUL in 2s
3 actionable tasks: 3 executed

コードレビューでの活用

ErrorProne はコンパイルエラーで CI を失敗させてルールを強制することもできますが、警告は見逃したり無視したりできてしまいます。このエラーメッセージを自動的にコードレビューで共有すると、問題点や修正について議論しやすいだけでなく、既知の問題についてレビュワーが言及する必要がなくなりコードレビューを効率化できます。
ErrorProne の、(というよりは javac の) エラーメッセージを (雑に) GitHub PR にレビューとして送る reviewdog を使った例を載せておきます。

bazelisk build 2>/tmp/err
REVIEWDOG_GITHUB_API_TOKEN=<GITHUB_TOKEN> reviewdog -efm='%A%f:%l: %t%[\^:]\*: %m' -efm='%Z' -efm='%Z\\d\+ %[A-Za-z]\+' -efm='%+C\.\*' -name=javac -reporter=github-pr-review < /tmp/err

CI から実行すると次のように解説へのリンクを含むレビューコメントが付きます。(画像は今回作ったメッセージではありません。)
ボットによるコードレビューコメント
コードレビューは基本的に差分に注目するため、コードベースで継続することで真価を発揮しますが、コードレビュー自体の品質を維持するのは案外大変です。レビュワーにもある種のスキルや知識、慣れが必要で見つけられる問題の範囲にも偏りがあります。コミュニケーションコストも高く、時間もさることながら、批判的なコメントをするにも言葉を選びます。機械的なコメントは一定の品質を担保でき、角も立ちにくいので、不和を招いたり妥協したりするリスクが低減できるように思います。

まとめ・感想

Plugin を書くための情報が少なくビルドの設定は分かり辛かったですが、パターン自体は API も分かり易く、数ある実装を真似して簡単に実装することができました。一度設定すれば簡単に足していく事ができると思います。
該当するコードを書いたときに機械的に知らせることができるので、長いスタイルガイドやコンベンションを自然言語で共有するより正確で、事前知識が必要ないのでコードベースの学習コストは低く維持できる点が良いと感じました。
ErrorProne には BugPattern 以外に Refaster というコードのリファクタリングパターンをテンプレートコードで定義できる面白い仕組みがあります。BugPattern を Refaster テンプレートで書くといったことはできないようですが、AST を扱う必要もなくテンプレートの可読性も高いので使ってみたくあります。