構文解析ライブラリを使ってJavaソースのマイグレーションをする

こんにちは、唐揚げとか油物が食べたくなっている齋藤です。

今日は構文解析ライブラリを使ってJavaソースのマイグレーションを行います。

はじめに

JavaScriptをお使いの皆様には馴染みが深い話かと思いますが
軽くなぜこの記事で書いたソースを書こうと思ったのか、というところから。

JavaScriptでは現状、babelやwebpackなど
JavaScriptのASTを走査して色々やるツールがあります。

いくつか例を挙げてみます。

このような色々なツールが発達しております。
また、気軽にASTが見られるAST Explorerなども存在しており
エコシステムが発達しているように見受けられます。

僕自身、CoffeeScriptをdecafjsでトランスパイルした結果のJSを
マイグレーションするためにbabelのtransformerを使ってtranspileしたことがあります。

というわけで、Javaでも自動的にマイグレーションしたい、という気持ちになりました。

現在Javaでは自動的なシンボルのリネームまでは大体対応してくれます。
が、アプリケーションやフレームワークの変更に関して
ツールでマイグレーションする方法はあまり使われているイメージがありません。
一応こんなツールもあるんですが・・・(htmlのレポートが吐かれるそう。)

というわけで、今回はJava ASTを操作してマイグレーションをしてみます。
今回は以下のライブラリを使ってやってみました。
netbeansのライブラリを使ってみようとしたけど、いまいち動いている様子がなかったので諦めました。
動かし方知ってる人いたら教えてください。

今回は2つのライブラリを使って
Springでよく見るControllerの以下のようなソースをマイグレーションしてみます。

これが

import static org.springframework.web.bind.annotation.RequestMethod.GET;  

import org.springframework.http.MediaType;  
import org.springframework.web.bind.annotation.RequestMapping;  
import org.springframework.web.bind.annotation.RequestMethod;  

public class Controller {  

    @RequestMapping(method = RequestMethod.GET)  
    public String getWithoutPath() {  
        return "get";  
    }  

    @RequestMapping(path = "get", method = {GET})  
    public String get() {  
        return "get";  
    }  

    @RequestMapping(value = "get", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE)  
    public String getWithPathByValue() {  
        return "get";  
    }  

    @RequestMapping(method = RequestMethod.POST)  
    public String postWithoutPath() {  
        return "post";  
    }  

    @RequestMapping(path = "post", method = RequestMethod.POST)  
    public String post() {  
        return "post";  
    }  

    @RequestMapping(value = "post", method = {  
        RequestMethod.POST  
    })  
    public String postWithPathByValue() {  
        return "post";  
    }  

    @RequestMapping(method = RequestMethod.DELETE)  
    public String deleteWithoutPath() {  
        return "delete";  
    }  

    @RequestMapping(path = "delete", method = RequestMethod.DELETE)  
    public String delete() {  
        return "delete";  
    }  

    @RequestMapping(value = "delete", method = {  
        RequestMethod.DELETE  
    })  
    public String deleteWithPathByValue() {  
        return "delete";  
    }  

    @RequestMapping(method = RequestMethod.PUT)  
    public String putWithoutPath() {  
        return "put";  
    }  

    @RequestMapping(path = "put", method = RequestMethod.PUT)  
    public String put() {  
        return "put";  
    }  

    @RequestMapping(value = "put", method = {  
        RequestMethod.PUT  
    })  
    public String putWithPathByValue() {  
        return "put";  
    }  

    @RequestMapping(method = RequestMethod.PATCH)  
    public String patchWithoutPath() {  
        return "patch";  
    }  

    @RequestMapping(path = "patch", method = RequestMethod.PATCH)  
    public String patch() {  
        return "patch";  
    }  

    @RequestMapping(value = "patch", method = {  
        RequestMethod.PATCH  
    })  
    public String patchWithPathByValue() {  
        return "patch";  
    }  

}  

こんな感じになってほしい

public class Controller {  

    @GetMapping  
    public String getWithoutPath() {  
        return "get";  
    }  

    @GetMapping("get")  
    public String get() {  
        return "get";  
    }  

    @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE, value = "get")  
    public String getWithPathByValue() {  
        return "get";  
    }  
    ...省略  
}  

これはRequestMappingからSpring4.3で追加された合成アノテーションに変換したい、という気持ちです。

マイグレーションの方針としては
RequestMappingにhttp methodが一つだけ指定されている物を対象に
マイグレーションを行います。

Eclipse JDT Coreを使ったマイグレーション

コンストラクタ内部でParserのセットアップを行なって
migrateメソッドでJavaソースを受け取ってマイグレーションしたJavaソースを吐き出す、といった形にしています。

public class Migrator {  
    private ASTParser parser;  

    public Migrator() {  
        ASTParser parser = ASTParser.newParser(AST.JLS8);  
        parser.setKind(ASTParser.K_COMPILATION_UNIT);  
        Hashtable<String, String> options = JavaCore.getOptions();  
        JavaCore.setComplianceOptions(JavaCore.VERSION_1_8, options);  
        parser.setCompilerOptions(options);  
        this.parser = parser;  
    }  

    public String migrate(String source) {  
        parser.setSource(source.toCharArray());  
        CompilationUnit node = (CompilationUnit) parser.createAST(null);  
        // 変更を検知しておく  
        node.recordModifications();  

        AST ast = node.getAST();  
        ImportRepository repository = new ImportRepository(node.imports(), ast);  
        // 実際のマイグレーション処理  
        node.accept(new Modifier(ast, repository));  

        // ASTの変更をソースに適用させる。  
        Document document = new Document(source);  
        TextEdit edit = node.rewrite(document, null);  
        try {  
            edit.apply(document);  
        } catch (MalformedTreeException | BadLocationException e) {  
            throw new RuntimeException(e);  
        }  
        return document.get();  
    }  
}  

ね、簡単でしょ?
と言いたいところなのですが、実際のマイグレーション処理は
Scalaが書きたくなったぐらいには辛い気持ちになったのでリンクだけ置いておきます。
Javaslangも使ってみたけど辛い・・・
あ、Javaslangじゃなくてvavrって名前になったそうですね。

実際のマイグレーション処理

今回は使っていませんが
Import文に関する処理はImportRewriteとか使うと楽みたいですね。
今度書く機会があったらそちらを試してみます。

Spoonでマイグレーションしてみる!!

Eclipse JDT Coreを使って書いた時より
めっちゃ楽です!!めっちゃ楽!!

処理の大まかな流れは以下の通りです。

  1. メソッド定義から対象のアノテーションを探す
  2. アノテーションがあったら以下の条件を満たすかどうかで書き換える。書き換えない場合は以降の処理はしない。
    • methodパラメータがhttp method一つだけ与えられている
    • GET, POST, DELETE, PUT, PATCHのいずれかである。
  3. アノテーションのシンボルを変える。
  4. methodパラメータ以外をアノテーションのパラメータに渡すようにする。

実際のマイグレーション処理です。

public class Processor extends AbstractProcessor<CtMethod<?>> {  

    @Override  
    public void process(CtMethod<?> element) {  
        Optional<CtAnnotation<? extends Annotation>> annotation = element.getAnnotations()  
            .stream()  
            .filter(a -> a.getAnnotationType().getQualifiedName()  
                .equals("org.springframework.web.bind.annotation.RequestMapping"))  
            .findFirst();  
        annotation.ifPresent(a -> {  
            Map<String, CtExpression> values = a.getValues();  
            CtExpression<?> e = values.get("method");  
            if (e instanceof CtFieldRead<?>  
                    || (e instanceof CtNewArray<?> && ((CtNewArray<?>) e).getElements().size() == 1)) {  
                CtFieldRead<?> method = (e instanceof CtNewArray<?>)  
                        ? (CtFieldRead) ((CtNewArray<?>) e).getElements().get(0) : (CtFieldRead) e;  
                String string = method.toString();  
                CtTypeReference<Annotation> r = element.getFactory().createTypeReference();  

                if (string.equals("RequestMethod.GET")) {  
                    r.setSimpleName("GetMapping");  
                } else if (string.equals("RequestMethod.POST")) {  
                    r.setSimpleName("PostMapping");  
                } else if (string.equals("RequestMethod.DELETE")) {  
                    r.setSimpleName("DeleteMapping");  
                } else if (string.equals("RequestMethod.PUT")) {  
                    r.setSimpleName("PutMapping");  
                } else if (string.equals("RequestMethod.PATCH")) {  
                    r.setSimpleName("PatchMapping");  
                } else  
                    return;  
                r.setPackage(  
                        element.getFactory().createPackageReference()  
                            .setSimpleName("org.springframework.web.bind.annotation"));  
                Map<String, CtExpression> newValues =  
                        values.entrySet().stream().filter(entry -> !entry.getKey().equals("method"))  
                            .collect(Collectors.toMap(Entry::getKey, Entry::getValue));  
                if (newValues.size() == 1 && newValues.containsKey("path")) {  
                    CtExpression ex = newValues.get("path");  
                    newValues.remove("path");  
                    newValues.put("value", ex);  
                }  
                a.setValues(newValues);  
                a.setAnnotationType(r);  
            }  
        });  
    }  

}  

基本的にクラスパスに指定のクラスが存在することを前提に書くようなツールのようなので
今回はクラスパスに存在しない前提でコードを書いているので少しお茶を濁しています。

Spoonではマイグレーションしたファイルを別ディレクトリに配置する形になっており
起動の方法が少し異なります。

ソースの以下のようなソースですね。

これで大体動くんですが、Javaのアノテーションのvalueの値はキーを省略できる仕様があるのですが
Spoonは対応していません。
なのでここでダーディハックを加えます。
~この程度なら許される。~

    @Test  
    public void test() {  
        SpoonAPI api = new Launcher() {  
            // prettyprintをすげ替える。  
            @Override  
            public PrettyPrinter createPrettyPrinter() {  
                return new SimpleMemberPrettyPrinter(getEnvironment());  
            }  
        };  
        api.getEnvironment().setAutoImports(true);  
        api.getEnvironment().setNoClasspath(true);  
        api.getEnvironment().setComplianceLevel(8);  
        api.addInputResource("src/test/resources/OldController.java");  
        api.addProcessor(new Processor());  
        api.prettyprint();  
        api.setSourceOutputDirectory("target/spooned");  
        api.run();  
    }  

    private class SimpleMemberPrettyPrinter extends DefaultJavaPrettyPrinter {  

        private PrinterHelper printer;  

        private ElementPrinterHelper elementPrinterHelper;  


        public SimpleMemberPrettyPrinter(Environment env) {  
            super(env);  
            try {  
                // 子クラスから見えない。  
                Field field = DefaultJavaPrettyPrinter.class.getDeclaredField("printer");  
                field.setAccessible(true);  
                Object printer = field.get(this);  
                this.printer = (PrinterHelper) printer;  
            } catch (IllegalArgumentException | IllegalAccessException | NoSuchFieldException | SecurityException e) {  
                throw new RuntimeException(e);  
            }  
            elementPrinterHelper = getElementPrinterHelper();  
        }  

        @Override  
        public <A extends Annotation> void visitCtAnnotation(CtAnnotation<A> annotation) {  
            elementPrinterHelper.writeAnnotations(annotation);  
            printer.write("@");  
            scan(annotation.getAnnotationType());  
            Map<String, CtExpression> values = annotation.getValues();  
            int size = values.size();  
            if (size == 1 && values.containsKey("value")) {  
                printer.write('(');  
                elementPrinterHelper.writeAnnotationElement(annotation.getFactory(),  
                        annotation.getValues().get("value"));  
                printer.write(')');  
            } else if (size > 0) {  
                try (ListPrinter lp = printer.createListPrinter("(", ", ", ")")) {  
                    for (Entry<String, CtExpression> e : values.entrySet()) {  
                        lp.printSeparatorIfAppropriate();  
                        printer.write(e.getKey() + " = ");  
                        elementPrinterHelper.writeAnnotationElement(annotation.getFactory(), e.getValue());  
                    }  
                }  
            }  
        }  
    }  

終わりに

Eclipse JDT Coreでの処理は辛いところもあるのですが
Spoonと比べると柔軟に書けることもある、といった感じで
処理する対象によって変えた方がいいかもしれません。

また、今回は使わなかったのですが
SpoonではArchitecture Checkingとして
コードを検証する処理が書かれています。

サンプルではTreeSetのコンストラクタを使っていない検証処理とテストメソッドはtestから始まることを検証する処理が書かれています。
こういうのを使ってアーキテクチャのチェックを行う処理をテストとして書くのは面白いかもしれませんね。

今回は動かせなかったnetbeansでの処理で知ったのですが
netbeansはCompiler Tree APIをいじるする形で驚きでした。

お腹が空いたのでポテトとか唐揚げ食べたい。

次書くとしたら、junit4⇨5のマイグレーションとか書いてみたい。

参考にしたサイト