Annotation ProcessorでSenchaのModelを自動生成する

@kawanoshinobu は普段はとっても温厚そうです。
でもキレるとPCの電源を引き抜いたりします。こわいです。そんな @kowanoshinobu に 「ワレ、いつもSenchaを教えてやっとるやんけ。なんか書けや!」と言われました。こわいです。

そんな訳で

この記事はSencha Advent Calendar 2012の11日目です。JavaScriptはそんなに好きではないので、そんなに好きではないJavaの話をします。

SenchaにはModelというクラスが存在します。その名の通りMVCのModelです。SenchaではModelクラスを使ってJSONのデシリアライズをします。

クライアントサイドから見ればModelでも、サーバーサイドから見ればViewです。サーバーサイドでもやっぱりModelっぽいクラスを作って、それをJSONにシリアライズします。Mapだと型がObjectになるから嫌いです。

サーバーサイドでModelを作ってクライアントサイドでModelを作りました。困りました。DRYじゃないです。DRYじゃないのは悪です。例外も一杯ありますがこれは悪です。

なのでAnnotation Processorを使って、JavaのModelからSenchaのModelを自動生成してみることにしました。

Annotation Processorって何?

JDK 6から入ったコンパイル時にJava構文木を弄ったりする仕組みです。例えば僕が好きなLombokというライブライリはこれでgetterやsetterを自動生成したりしています。

Rubyとかでツールを作っても良いのですが、ツールだと自動生成し忘れる可能性があります。それにAnnotation Processorなら構文木を直接触れるので、「staticなフィールドは自動生成しない」とか「Javaの継承関係をSenchaでも維持する」とか色々出来ます。ScalaなんかだとCompiler Pluginというもっと強力な仕組みも存在します。

あ、ちなみにこのエントリではAnnotation Processor使ってこんなこと出来るよーに留めるので詳しい解説はしません。Annotation Processorについての解説はこちらの連載がとても詳しく書いてます。

いきなり完成系

例えばこんなJavaのModelを作ってコンパイルすると

package localhost.sample;

import localhost.annotation.Model;

@Model(namespace="localhost.generate", dir="web/smartdevice/localhost/generate/";
public class Sample {
	private String name;
	private int value;

        @Field(exclude=true)
        private String excludedField;
}

勝手にこんなJavaScriptファイルを作ります。

// auto-generated at Fri Nov 09 22:30:37 JST 2012

/**
 * CAUTION: 
 *   Don't modify this file!
 *   This file is automatically generated.
 */
Ext.define('localhost.generate.AbstractSample', {
    extend: 'Ext.data.Model',
    config: {
         fields: [
             { name: 'name', type: 'string' },
             { name: 'value', type: 'int' },
         ]
    }
});

実際はこのクラスを継承して使います。変更を加え先から自動生成で上書かれてしまっては困りますもんね。コンフリクトするのでバージョン管理もしません。

アノテーションを作る

Annotation Processorなのでアノテーションが無いと始まりません。今回は@Modelと@Fieldの二つのアノテーションを作ります。@Modelはクラスに付けれて@Fieldはフィールドに付けることが出来ます。@Fieldは省略可能です。

@Model

package localhost.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface Model {
        // クラス名のprefix
	String prefix() default "Abstract";
        // 出力先
	String dir();
        // 名前空間
	String namespace();
}

@Field

package localhost.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.SOURCE)
public @interface Field {
    // フィールド名(省略時はJavaのフィールド名)
    String name() default "";
    // データの種類(省略時はJavaのフィールドの型から自動判別)
    String type() default "";
    // 除外フラグ
    boolean exclude() default false;
}

Annotation Processorを作る

AbstarctProcessorを継承したクラスを作ります。
こいつがエントリーポイントです。

package localhost.apt;

@SupportedSourceVersion(SourceVersion.RELEASE_7)
@SupportedAnnotationTypes("localhost.annotation.Model")
public class SenchaModelGen extends AbstractProcessor {
    @Override
    public boolean process(Set<? extends TypeElement> annotations,
            RoundEnvironment roundEnv) {
        for (TypeElement annotation : annotations) {
            for (Element element : roundEnv.getElementsAnnotatedWith(annotation)) {
                // @Modelが付いてるクラスだけを取ってこれた

                assert element instanceof TypeElement;
                TypeElement typeElement = (TypeElement) element;

                try {
                    // @Modelを取得
                    Model model = element.getAnnotation(Model.class);
                    
                    // ここにSenchaのModelを生成する処理を書く
                } catch (Exception e) {
                    this.processingEnv.getMessager().printMessage(Kind.ERROR, e.getMessage(), typeElement);
                }
            }
        }
        return true;
    }

で、例えばModelを作る条件は
「アクセス修飾子がPublicで具象クラスであること」とか

        public boolean isGenerable() {
            if (this.element.getKind() != ElementKind.CLASS) {
                return false;
            }
            if (!this.element.getModifiers().contains(Modifier.PUBLIC)) {
                return false;
            }
            if (this.element.getModifiers().contains(Modifier.ABSTRACT)) {
                return false;
            }
            return true;
        }

フィールドの型は「boolean型なら'boolean'で、byte型やshort型やint型やchar型だったら'int'」だとか

        private String getFieldType() {
            if (field != null && !field.type().isEmpty()) {
                return field.type();
            } else {
                TypeKind typeKind = this.element.asType().getKind();
                switch(typeKind) {
                case BOOLEAN:
                    return "boolean";

                case BYTE:
                case SHORT:
                case INT:
                case LONG:
                case CHAR:
                    return "int";

                case FLOAT:
                case DOUBLE:
                    return "float";

                default:
                    return "";
                }
            }
        }

構文木とか大袈裟なことを言っていますが、ひとつひとつの処理は簡単です。そんなことをしながら最終的なJavaScriptファイルを作成します。

        public void generate(PrintWriter writer) {
            this.generateModelHeader(writer, this.model, this.element);

            for (VariableElement fieldElement : ElementFilter.fieldsIn(this.element.getEnclosedElements())) {
                FieldGenerator generator = new FieldGenerator(this.processingEnv, this.model, fieldElement);
                if (generator.isGenerable()) {
                    generator.generate(writer);
                }
            }

            this.generateModelFotter(writer, model, element);
        }

        private void generateModelHeader(PrintWriter writer, Model model, TypeElement element) {
            String namespace = this.getNameSpace();
            String modelName = this.getModelName();
            String absoluteModelName = namespace + (namespace.isEmpty() ? "" : ".") + modelName;

            writer.println("// auto-generated at " + new Date());
            writer.println("");
            writer.println("/**");
            writer.println(" * CAUTION: ");
            writer.println(" *   Don't modify this file!");
            writer.println(" *   This file is automatically generated.");
            writer.println(" */");
            writer.println("Ext.define('" + absoluteModelName + "', {");
            writer.println("    extend: 'Ext.data.Model',");
            writer.println("    config: {");
            writer.println("         fields: [");
        }

        private void generateModelFotter(PrintWriter writer, Model model, TypeElement element) {
            writer.println("         ]");
            writer.println("    }");
            writer.println("});");
        }

わりと愚直ですね。そのうちvelocityテンプレートとかにするつもりです。

仕上げ

最後にsrc/META-INF/javax.annotation.processing.Processorというファイルを作って、その中にAnnotation Processorの在処を書きます。

localhost.apt.SenchaModelGen

後はjar化したものをクラスパスに通してコンパイルすれば、先程のJavaScriptファイルが自動生成されます。

まとめ

雰囲気だけを伝えるつもりだったのでかなーり端折りました。結局言いたかったのは「同じこと二度書くの面倒だよねー」と、それに対するJavaでの一つのアプローチ方法の紹介です。こういったものはどんどん自動化して楽したいですね。

遊びで作ったものなのでまだまだ未完成品ですが、ソースコード一式をgithubに上げておきます。将来的にはサーバサイドのバリデーションからクライアントサイドバリデーションを作ったり、逆にSenchaのModelの永続化命令をJava側のModelで受けたりしたいですね。

というわけで、明日は shuhei aoyama さんで「Proxyのはなし」です。