アナログ金木犀

つれづれなるまままにつれづれする

RxJava2でonErrorをsubscribeしてないのを検知するCustom Lintを作る

深夜に眠れなかったので、男もすなるCustom Lint作りというものをやってみようかと。

RxJava2でonErrorをsubscribeしてないものを検知するというものを作りました。

きっと探せばあるんでしょうが、始める題材としては良さそうだと思ったのでここはあえて探さずに。

参考

下記を参考に作りました

関係ない話な上たまたまなんですが、上記qiitaのhotchemiさんもamyu君も今同じフロアで働いてたりします。

作り方

上記qiitaにも載ってますが、ここでも。下記手順で作ります。

  1. build.gradleにdependeicies追加
  2. Detector及びIssueを作成
  3. Registoryを作成
  4. Test作成

一つ一つ説明します。

build.gradleにdependeicies追加

build.gradleのdependeiciesに下記を追加しておきます。

    compile 'com.android.tools.lint:lint-api:24.3.1'
    compile 'com.android.tools.lint:lint-checks:24.3.1'
    compile 'com.android.tools.lint:lint:24.3.1'

    testCompile 'junit:junit:4.11'
    testCompile 'com.android.tools.lint:lint-tests:24.3.1'
    testCompile 'com.android.tools:testutils:24.3.1'

実際のソース

Detector及びIssueを作成

Observable, Flowable, Maybe, Single, Completablesubscribe された時にsusbscribe() もしくは subscribe(onNext) の時はNGとします。

まずIssueというものを作っておきます。このlintの説明みたいなものです。

    public static final Issue ISSUE = Issue.create(
            "RxJava2SubscribeOnError",
            "not subscribe onError",
            "must subscribe onError",
            Category.PERFORMANCE,
            7,
            Severity.ERROR,
            new Implementation(
                    RxJava2SubscribeOnErrorDetector.class,
                    Scope.JAVA_FILE_SCOPE
            )
    );

そしてDetectorを作っていきます。

public class RxJava2SubscribeOnErrorDetector extends Detector implements Detector.JavaScanner {

:

    private static final String CLS_FLOWABLE = "io.reactivex.Flowable";
    private static final String CLS_OBSERAVLE = "io.reactivex.Observable";
    private static final String CLS_SINGLE = "io.reactivex.Single";
    private static final String CLS_COMPLETABLE = "io.reactivex.Completable";
    private static final String CLS_OBJECT = "java.lang.Object";
    private static final String CLS_CONSUMER = "io.reactivex.functions.Consumer";
    private static final String CLS_ACTION = "io.reactivex.functions.Action";
    private static final String CLS_CONSUMER_SIMPLE = "Consumer";
    private static final String CLS_ACTION_SIMPLE = "Action";
    private static final String ANONYMOUS_NEW_CONSUMER = "new io.reactivex.functions.Consumer(){}";
    private static final String ANONYMOUS_NEW_ACTION = "new io.reactivex.functions.Action(){}";
    private static final String METHOD_SUBSCRIBE = "subscribe";

    // ---- Implements JavaScanner ----

    @Override
    public List<String> getApplicableConstructorTypes() {
        return Arrays.asList(CLS_FLOWABLE, CLS_OBSERAVLE, CLS_SINGLE, CLS_COMPLETABLE);
    }

    @Override
    public List<String> getApplicableMethodNames() {
        return Collections.singletonList(METHOD_SUBSCRIBE);
    }

    @Override
    public void visitMethod(
            @NonNull JavaContext context,
            @Nullable AstVisitor visitor,
            @NonNull MethodInvocation node
    ) {
        boolean isNG = false;
        StrictListAccessor<Expression, MethodInvocation> expressions = node.astArguments();
        int size = expressions.size();
        if (size == 0) {
            // Any.subscribe() -> NG
            isNG = true;
        } else if (size == 1) {
            // Any.subscribe(Consumer<T> onNext) -> NG
            Expression expression = expressions.first();

            JavaParser.ResolvedClass param1Class = context.getType(expression).getTypeClass();

            while (!param1Class.getName().equals(CLS_OBJECT)) {
                if (param1Class.getName().startsWith(CLS_CONSUMER)
                        || param1Class.getName().startsWith(CLS_CONSUMER_SIMPLE)
                        || param1Class.getName().startsWith(ANONYMOUS_NEW_CONSUMER)
                        || param1Class.getName().startsWith(CLS_ACTION)
                        || param1Class.getName().startsWith(CLS_ACTION_SIMPLE)
                        || param1Class.getName().startsWith(ANONYMOUS_NEW_ACTION)) {
                    isNG = true;
                    break;
                }
                param1Class = param1Class.getSuperClass();
            }
        }
        if (isNG) {
            context.report(ISSUE, node, context.getLocation(node), "you must subscribe onError");
        }
    }
}

実際のソース

  • getApplicableConstructorTypesで上記の5クラスに絞る
  • getApplicableMethodNamessubscribeメソッドに絞る
  • visitMethodにて、先の条件に当てはまるものをNGとしてcontext.reportする

ということをやっています。

Registoryを作成

こういうものを作って

public class LintIssueRegistry extends IssueRegistry {

    public LintIssueRegistry() {
    }

    @Override
    public List<Issue> getIssues() {
        return Collections.singletonList(RxJava2SubscribeOnErrorDetector.ISSUE);
    }
}

実際のソース

build.gradleに下記を追記しておきます。

jar {
    manifest {
        attributes("Lint-Registry": "com.kgmshin.lint.LintIssueRegistry")
    }
}

実際のソース

Test作成

LintDetectorTestを継承してテストを作成します。

public class RxJava2SubscribeOnErrorDetectorTest extends LintDetectorTest {
:

    @Override
    protected Detector getDetector() {
        return new RxJava2SubscribeOnErrorDetector();
    }

    @Override
    protected List<Issue> getIssues() {
        return ImmutableList.of(RxJava2SubscribeOnErrorDetector.ISSUE);
    }

    @Test
    public void testSingleSubscribeNoParam() throws Exception {
        @Language("JAVA") String foo = "" +
                PACKAGE +
                IMPORT +
                "public class Foo {\n" +
                "public void test() {\n" +
                "Single.just(\"test\").subscribe()" +
                "}\n" +
                "}";
        String result = lintProject(java(SOURCE_PATH + "Foo.java", foo));
        assertEquals(
                "src/com/kgmyshin/lint/Foo.java:16: Error: you must subscribe onError [RxJava2SubscribeOnError]\n"
                        + "Single.just(\"test\").subscribe()}\n"
                        + "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n"
                        + "1 errors, 0 warnings\n",
                result
        );
    }
:
}

getDetector()getIssues()で先ほどのDetectorとIssueを返すようにしてあとはゴリゴリテストを書いていくだけです。

実際のソース

gist

途中で何回も貼ってますが、gistにソースを載せています。

追記 2017/1/5 17:36

Testだと動いていたのですが、実際のプロジェクトで使ってみると一部動かなかったので修正しました。上記に紹介しているコードは最新です。

        Single.just("test").subscribe(new Consumer<String>() {
            @Override
            public void accept(String s) throws Exception {
                  :
            }
        });

みたいなコードがある時に、

    @Override
    public void visitMethod(
            @NonNull JavaContext context,
            @Nullable AstVisitor visitor,
            @NonNull MethodInvocation node
    ) {
       :
            Expression expression = expressions.first();
            JavaParser.ResolvedClass param1Class = context.getType(expression).getTypeClass(); // ★
       : 
     }

の★にたいして getSuperClass() した挙動がTest時と実際の時で違うみたいです。

うろ覚えですがTest時はgetName()getSuperClass()を繰り返した時

  1. new io.reactivex.functions.Consumer(){}
  2. Consumer
  3. java.lang.Object

みたいになってたんですが、実際は

  1. new io.reactivex.functions.Consumer(){}
  2. java.lang.Object

ってなってたみたいです。

と、時間がなかったのでなんでそうなるかまでは今回は調べてないですが、以上です。

ちなみに

作ったlintのjarファイルは

~/.android/lint/

に格納されるのですが、module名を lint としてしまうと lint.jarが作られてしまいます。

何が言いたいかというと、結構どこのプロジェクトもmodule名をlintとしているっぽくて、同名のjarファイルができてしまうのでコマンドなどで上書きされないように名前を変えてコピペするようにするか、もしくは最初からモジュール名を変えておくと良いと思いました。