Обробка анотацій в процесі компіляції

magicМетапрограмування — вид програмування, пов'язаний зі створенням програм, які породжують інші програми як результат своєї роботи (зокрема, на стадії компіляції їх вихідного коду), або програм, які змінюють себе під час виконання.

Анотації, як інструмент метапрограммирования з'явилися разом з релізом Java 5 в далекому 2004 році. Разом з ними з'явився інструментарій Annotation Processing Tool, на зміну якому прийшла специфікація JSR 269 або Pluggable Annotation Processing API. Що цікаво, цієї специфікації без малого 10 років, але свою популярність Android розробці вона почала набувати тільки зараз.

Про можливості, які відкриває ця специфікація ми поговоримо трохи пізніше (буде мнооого коду), а спершу, чи не хочете поговорити про компіляції Java коду?

Пара слів про Javac

Весь процес компіляції контролюється інструментарієм з пакету com.sun.tools.javac і, згідно специфікаціям OpenJDK, в загальному випадку, виглядає так:
  • Parse — компілятор обробляє вхідний потік на послідовність лексем і формує абстрактне синтаксичне дерево (AST) за допомогою інструментів з пакету com.sun.tools.javac.parser.*
  • Enter — на даному етапі здійснюється прохід по синтаксичному древу і створюється таблиця символів. Варто відзначити, що цей процес складається з двох фаз: на першій здійснюється прохід по AST з фази Parse, на другий прохід по всім залежностями (інтерфейси, супертипы, параметри).
  • Annotation processing — про цій фазі, власне, і піде мова надалі.
  • Attribute — велика частина контекстно-залежних операцій виконуються під час цієї фази: дозвіл імен, перевірка типів, обчислення констант.
  • Flow — на даному етапі відбувається перевірка потоку даних, досяжності всіх ділянок коду, що всі перехватываемые виключення оброблені, що final змінні виставляються один раз і т. д.
  • Desugar — видалення синтаксичного цукру, заміна внутрішніх класів, розгортання foreach циклів.
  • Generate — генерація .class файлів

Метапрограмиирование в Android

Де ж в Android розробці нам може допомогти метапрограмування? Багато хто з вас вже знають відповідь на це питання — це чимала кількість бібліотек, які так чи інакше пропонують рішення з инжектированию компонентів, встановлення слухачів і багато іншого.
public class MainActivity extends AppCompatActivity {

@Bind(R. id.fab)
private FloatingActionButton mFab;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R. layout.ac_main);
mFab.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
}
});
ButterKnife.bind(this);
}

}

Які проблеми є в цьому коді? По-перше, він не збереться! Процес компіляції буде перерваний помилкою
Error:(15, 34) error: @Bind fields must not be or private static. (moscow.droidcon2015.activity.MainActivity.mFab)

Відмінно, прибираємо private і все, код збирається. Але тим самим ми порушуємо один з основоположних принципів ООПінкапсуляцію. По-друге, при запуску, програма впаде NPE, тому що поле mFab ініціалізується в момент виклику ButterKnife.bind(this). По-третє, Proguard може вирізати класи, згенеровані бібліотекою ButterKnife. Ви можете сказати, що це все надумані проблеми і вони всі вирішуються протягом п'яти хвилин. Безумовно, це так, але було б здорово — позбавити себе від необхідності думати про ці можливі проблеми.

Вперед! До тяжких речовин!

Отже, давайте вже почнемо винаходити велосипед! Перше, що нам потрібно, як не дивно, сама анотація, яку ми згодом будемо обробляти:
@Retention(RetentionPolicy.SOURCE)
@Target({ElementType.FIELD})
public @interface BindView {
int value();
}

RetentionPolicy.SOURCE значить що ця анотація доступна тільки у вихідних кодах (що нас повністю влаштовує) і достукатися до неї через рефлексію не вийде. ElementType.FIELD каже що анотація застосовна тільки до полів класу.

Далі нам потрібно створити сам процесор і прописати його в окремому файлі:
src/main/resources/META-INF.services/javax.annotation.processing.Processor
Вмістом цього файлу є один рядок, що містить повне ім'я класу підключається процесора:
moscow.droidcon2015.processor.DroidConProcessor

DroidConProcessor.java
@SupportedAnnotationTypes({"moscow.droidcon2015.processor.BindView"})
public class DroidConProcessor extends AbstractProcessor {

private final Map<TypeElement, BindViewVisitor> mVisitors = new HashMap<>();

@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
if (annotations.isEmpty()) {
return false;
}

final Set<? extends Element> elements = roundEnv.getElementsAnnotatedWith(BindView.class);
for (final Element element : elements) { // element == MainActivity.mFab
final TypeElement object = (TypeElement) element.getEnclosingElement(); // object == MainActivity
BindViewVisitor visitor = mVisitors.get(object);
if (visitor == null) {
visitor = new BindViewVisitor(processingEnv, object);
mVisitors.put(object, visitor);
}
element.accept(visitor, null);
}

for (final BindViewVisitor visitor : mVisitors.values()) {
visitor.brewJava();
}

return true;
}

@Override
public SourceVersion getSupportedSourceVersion() {
return SourceVersion.latestSupported();
}

}


Як би не було парадоксально, але сам процесор ми помічаємо анотацією, яка говорить про те, які анотації може обробляти процесор. Не складно здогадатися, що основним методом є метод process. Першим параметром є безліч анотацій зі списку підтримуваних нашим процесором, які були виявлені на перших двох фазах компіляції. Другий параметр — оточення компілятора. По-хорошому, в реалізації методу ми повинні пройти по всьому безлічі знайдених анотацій і обробити їх усі, але в даному випадку процесор у нас підтримує лише одну єдину анотацію, тому ми опрацюємо її «в лоб». Розглянемо метод process по кроках:
  • Перевіряємо, що знайдена хоча б анотація з підтримуваних
  • Отримуємо в оточення безліч всіх елементів, позначених анотацією @BindView
  • Проходимо по даній множині. Як ми пам'ятаємо, анотація може бути застосована лише до поля класу, відповідно, метод element.getEnclosingElement() повертає об'єкт класу, в якому поле містить.
  • Створюємо клас-відвідувач для кожного об'єкта, що містить позначені поля
  • Застосовуємо наш відвідувач до кожного поля
  • Після того як всі відвідувачі відпрацювали, ми генеруємо кінцеві класи вихідного коду
BindViewVisitor.java
public class BindViewVisitor extends ElementScanner7<Void, Void> {

private final CodeBlock.Builder mFindViewById = CodeBlock.builder();

private final Trees mTrees;

private final Messager mLogger;

private final Filer mFiler;

private final TypeElement mOriginElement;

private final TreeMaker mTreeMaker;

private final Names mNames;

public BindViewVisitor(ProcessingEnvironment env, TypeElement element) {
super();
mTrees = Trees.instance(env);
mLogger = env.getMessager();
mFiler = env.getFiler();
mOriginElement = element;
final JavacProcessingEnvironment javacEnv = (JavacProcessingEnvironment)env;
mTreeMaker = TreeMaker.instance(javacEnv.getContext());
mNames = Names.instance(javacEnv.getContext());
}

}


Подивимося тепер в клас, в якому виконується вся основна робота. Перше, за що чіпляється око — ElementScanner7. Це реалізація інтерфейсу ElementVisitor, а 7 — мінімальна версії JDK, який ми хочемо використовувати. Пройдемося по полях (точніше за їх типами):
  • CodeBlock.Builder — це частина бібліотеки javapoet від хлопців з Square, яка створена щоб спростити генерацію коду.
  • Trees — клас із пакету com.sun.source.util, що дозволяє звертатися до AST.
  • Messager — логгер. Можна виводити повідомлення в процесі компіляції або перервати процес, якщо послати повідомлення з пріоритетом ERROR.
  • Filer — клас, що дозволяє створювати файли вихідного коду в поточній пісочниці компілятора. Знає де саме розмістити файл у файловій системі. Наприклад, для gradle це build/intermediates/classes.
  • TreeMaker — клас із пакету com.sun.tools.javac.tree, який відповідає абсолютно за всю магію, яка буде відбуватися далі! Він же використовується в першій фазі компіляції для побудови AST.
  • Names — клас із пакету com.sun.tools.javac.util, який перетворює імена елементів у конструкції AST.
Як ви пам'ятаєте, ми застосували ElementVisitor до поля класу, означає метод, який нас цікавить visitVariable
@Override
public Void visitVariable(VariableElement field, Void aVoid) {
((JCTree) mTrees.getTree(field)).accept(new TreeTranslator() {
@Override
public void visitVarDef(JCTree.JCVariableDecl jcVariableDecl) {
super.visitVarDef(jcVariableDecl);
jcVariableDecl.mods.flags &= ~Flags.PRIVATE;
}
});
final BindView bindView = field.getAnnotation(BindView.class);
mFindViewById.addStatement("(($T) this).$L = ($T) findViewById($L)",
ClassName.get(mOriginElement), field.getSimpleName(), ClassName.get(field.asType()), bindView.value());
return super.visitVariable(field, aVoid);
}


Невеликий відступ, щоб зрозуміти що буде далі: класи з javax.lang.model.element (VariableElement, TypeElement, і т. д.) — це, скажімо так, високорівнева абстракція над AST. З допомогою класу Trees ми отримуємо низькорівневу абстракцію, натравливаем на неї реалізацію TreeVisitor'а і потрапляємо в метод visitVarDef у параметрах якого знаходиться AST представлення нашого поля (JCTree.JCVariableDecl). Далі брудний хак — прибираємо біля поля прапор private. Так, так, ми порушуємо принцип інкапсуляції, але робимо це на рівні компілятора (де нам вже, в принципі, побоку що відбувається). На рівні вихідного коду інкапсуляція зберігається: IDE не дасть звертатися до поля ззовні, а статичний аналізатор спокійно відрапортує про відсутність проблем з цим полем. Додаємо в CodeBlock.Builder вираз для ініціалізації поля і все.

Генеруємо файл вихідного коду

Після того, як ми відвідали всі поля нашого класу, необхідно згенерувати файл вихідного коду.
brewJava
public void brewJava() {
final TypeSpec typeSpec = TypeSpec.classBuilder(mOriginElement.getSimpleName() + "$$Proxy") // MainActivity$$Proxy
.addModifiers(Modifier.ABSTRACT)
.superclass(ClassName.get(mOriginElement.getSuperclass())) // extends AppCompatActivity
.addOriginatingElement(mOriginElement)
.addMethod(MethodSpec.methodBuilder("setContentView")
.addAnnotation(Override.class)
.addModifiers(Modifier.PUBLIC)
.addParameter(TypeName.INT, "layoutResId")
.addStatement("super.setContentView(layoutResId)")
.addCode(mFindViewById.build()) // findViewById...
.build())
.build();
final JavaFile javaFile = JavaFile.builder(mOriginElement.getEnclosingElement().toString(), typeSpec)
.addFileComment("Generated by DroidCon processor, do not modify")
.build();
try {
final JavaFileObject sourceFile = mFiler.createSourceFile(
javaFile.packageName + "." + typeSpec.name, mOriginElement);
try (final Writer writer = new BufferedWriter(sourceFile.openWriter())) {
javaFile.writeTo(writer);
}
// TODO: MAGIC
} catch (IOException e) {
mLogger.printMessage(Diagnostic.Kind.ERROR, e.getMessage(), mOriginElement);
}
}


Всю роботу по генерації вихідного коду взяла на себе бібліотека javapoet. Безумовно, можна було б обійтися без неї, але тоді весь ісходник довелося б генерувати вручну з допомогою конткатенации рядків, що, погодьтеся, не дуже зручно. На цьому етапі закінчують усі творці бібліотек, подібних ButterKnife. Ми отримали файл класу, який потім знаходимо з допомогою рефлексії і, з її ж допомогою, смикаємо відповідний метод, який виконує корисну роботу. Але я обіцяв, що ми позбудемося цієї необхідності!

We need to go deeper!

TODO: MAGIC
JCTree.JCExpression selector = mTreeMaker.Ident(mNames.fromString(javaFile.packageName));
selector = mTreeMaker.Select(selector, mNames.fromString(typeSpec.name));
((JCTree.JCClassDecl) mTrees.getTree(mOriginElement)).extending = selector;


Так! Три рядки. Що ж у них відбувається:
  • Вибираємо один з вузлів AST. У нашому випадку — пакет, в якому лежить згенерований клас.
  • Йдемо вглиб дерева і вибираємо наступний вузол — сам згенерований клас.
  • початкового елемента (MainActivity) міняємо властивість extending, яке, власне, означає від чогось успадкований цей клас.
Ще більш простою мовою — ми вбудовуємо згенерований клас в ієрархію спадкування:
MainActivity extends MainActivity$$Proxy extends AppCompatActivity

MainActivity$$Proxy.java
// Generated by DroidCon processor, do not modify
package moscow.droidcon2015.activity;

import android.support.design.widget.FloatingActionButton;
import android.support.v7.app.AppCompatActivity;
import java.lang.Override;

abstract class MainActivity$$Proxy extends AppCompatActivity {
@Override
public void setContentView(int layoutResId) {
super.setContentView(layoutResId);
((MainActivity) this).mFab = (FloatingActionButton) findViewById(2131492965);
}
}


MainActivity.class (decompiled)
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package moscow.droidcon2015.activity;

import android.os.Bundle;
import android.support.design.widget.FloatingActionButton;
import android.view.View;
import android.view.View.OnClickListener;
import moscow.droidcon2015.activity.MainActivity$$Proxy;

public class MainActivity extends MainActivity$$Proxy {
FloatingActionButton mFab;

public MainActivity() {
}

protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
this.setContentView(2130968600);
this.mFab.setOnClickListener(new OnClickListener() {
public void onClick(View v) {
}
});
}
}



Висновок

На жаль, у межах однієї статті неможливо розповісти про всі тонкощі Annotation processing'а й ті скарби, що лежать всередині com.sun.tools.javac.*. Що ще більше засмучує, це повна відсутність якої-небудь документації по цих скарбів і відсутність сумісності між релізами. Далі звучатимуть страшні слова: щоб забезпечити підтримку компілятора java7 і java8 потрібно буде використовувати рефлексію в процесі компіляції! Від це поворот! Правда? Але ще раз повторю — це відноситься тільки до com.sun.tools.javac.

За мотивами DroidCon

Читати статтю зручніше, попутно гортаючи презентацию.
Репозиторій проекту тут.

Відповіді на питання:
  • Це не дослідницька завдання. Все це вже активно працює в ряді проектів.
  • Перевага цього підходу перед модифікацією байткода бібліотеками начебто ASM в тому, що обробка анотацій виконується у момент компіляції а не після і можливість вихопити помилку компіляції а не рантайма, імхо, набагато краще.
  • можна Подивитися в бібліотеці DroidKit. andkulikov, документація, безсумнівно, з'явиться. Коли? When is done. =)


хардкору!

visitMethodDef
@Override
public void visitMethodDef(JCTree.JCMethodDecl methodDecl) {
super.visitMethodDef(methodDecl);
methodDecl.body.stats = com.sun.tools.javac.util.List.<JCTree.JCStatement>of(
mTreeMaker.Try(
mTreeMaker.Block(0, methodDecl.body.stats),
com.sun.tools.javac.util.List.<JCTree.JCCatch>nil(),
mTreeMaker.Block(0, com.sun.tools.javac.util.List.<JCTree.JCStatement>of(
mTreeMaker.Exec(mTreeMaker.Apply(
com.sun.tools.javac.util.List.<JCTree.JCExpression>nil(),
ident(mPackageName, mHelperClassName, "update"),
com.sun.tools.javac.util.List.of(
mTreeMaker.Literal(TypeTag.CLASS, mColumnName),
mTreeMaker.Select(mTreeMaker.Ident(mNames._this), mNames.fromString(mFieldName)),
mTreeMaker.Select(mTreeMaker.Ident(mNames._this), mNames.fromString(mPrimaryKey.call()))
)
))
))
)
);
}


Ось такий ось страшний на перший погляд код всього лише модифікує код методу сетера таким чином, щоб зміни записувалися відразу в БД.

штани плавно перетворюються...
// 
public void setText(String text) {
mText = text;
}

// стало
public void setText(String text) {
try {
this.mText = text;
} finally {
Foo$$SQLiteHelper.update("text", this.mText, this.mId);
}
}



Джерела



Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.