Как не писать лишнего. Без магии

в 7:21, , рубрики: androjeta, java, javax.annotation.processing, jeta, metaprogramming, open source, Программирование, Разработка под android, метки: ,

img
Недавно я опубликовал свою первую статью на Хабре. И первый блин прилетел мне прямо в голову. 12к просмотров и плюс 4 звезды на гитхабе… Ладно, сам виноват, не надо было заниматься ерундой на уроках русского языка и литературы. Если я правильно понял, то проблема заключалась в том, что я сразу перешел к сути. Вывалил все в лоб. Не познакомился с родителями, так сказать. А что за Jeta такая, как она работает, что происходит за сценой? Магия какая я то… Никому ведь не нужна магия в проектах, так?

"От куда у тебя уверенность, что твоя библиотека вообще кому-то нужна?" спросит среднестатистический хаброчанин. Оттуда, что каждый день, вешая очередную аннотацию или просто смотря на код, я думаю "Боже, это прекрасно!". Кто от такого откажется?

Ладно, давайте сначала и по порядку.

Jeta — фреймворк для генерации исходного кода по аннотациям, построенный на javax.annotation.processing. Что из себя представляет Annotation Processing можно почитать, например, тут или тут. Если вкратце — это плохо задокументированная технология, доступная с Java 1.5 (но лучше 1.6), которая позволяет пройтись по AST вашего проекта, вашим же процессором, обработать ваши аннотации угодным Вам способом. И сделать все это непосредственно перед компиляцией. На этом построены такие монстры как dagger, dagger 2, jeta, android annotations и другие. По моему мнению, Java Annotation Processing сильно недооцененная технология, а для таких рефлекшн-фобов как я — так вообще единственный способ пометапрограммировать. Благо, с появлением Android, ситуация начала меняться. Самое время приобщиться к прекрасному!

Вот какие цели я преследовал во время работы над проектом:

  • Удобство написания кастомных annotation-процессоров.
  • Нахождение ошибок во время компиляции (по возможности).
  • Каждый компонент фреймворка должен быть заменяем. Не нравится как работает FooController? Напиши свою реализацию! И не забудь поделиться с сообществом, pull request-ы приветствуются!
  • Скорость работы. Хотелось минимизировать overhead насколько это возможно — тем самым лично забить гвоздь в голову крышку гроба Java Reflection.

А ведь это еще не всё, "батарейки в комплекте"! Все что надо для комфортной работы — уже написано: Dependecy Injection, Event Bus, Validators и др. Все в соответствии с принципами описанными выше. А еще, из коробки доступны Collectors. Именно на их примере мы будем разбираться с тем, как устроен фреймворк.

Spherical cow

Предположим, в нашем проекте есть обработчик событий. Сейчас не важно, что за события, это могут быть push-сообщения, состояния state-machine-ы или команды от пользователя. О! а давайте это будут команды от пользователя. Тем более, что тема написания чат-ботов сейчас актуальна.

Итак, нам нужны обработчики:

public interface CommandHandler {
    void handle();
}

public class GreetingCommand implements CommandHandler {
    @Override
    public void handle() {
        System.out.print("Hi! How are you?");
    }
}

public class ExitCommand implements CommandHandler {
    @Override
    public void handle() {
        System.out.print("Bye!");
        System.exit(0);
    }
}

Процессор:

public class CommandProcessor {
   private Map<String, CommandHandler> handlers = new HashMap<>();

   public void loop() {
        System.out.println("Input command. Type 'exit' to finish.");
        Scanner input = new Scanner(System.in);
        while (true) {
            String command = input.next();
            CommandHandler handler = handlers.get(command);
            if(handler == null)
                System.out.println("Unknown command '" + command + "'. Try again");
            else
                handler.handle();
        }
    }

    public static void main(String[] args) {
        new CommandProcessor().loop();
    }
}

Теперь нам нужно связать команды пользователя с соответствующими обработчиками. Я знаю, что мода на XML поутихла, но тем не менее, именно с помощью XML большинство программистов решают подобные задачи. Что ж, XML так XML..

<?xml version="1.0" encoding="utf-8" ?>
<handlers>
    <handler command="greet">org.brooth.jeta.samples.command_handler.commands.GreetingCommand</hanler>
    <handler command="exit">org.brooth.jeta.samples.command_handler.commands.ExitCommand</hanler>
</handlers>

парсим!

public class CommandProcessor {
    private Map<String, CommandHandler> handlers = new HashMap<>();

    public CommandProcessor() {
        parseHandlers();
    }

    private void parseHandlers() {
        try {
            DocumentBuilder documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
            Document document = documentBuilder.parse("handlers.xml");
            NodeList nodes = document.getDocumentElement().getElementsByTagName("handler");
            for (int i = 0; i < nodes.getLength(); i++) {
                Node node = nodes.item(i);
                handlers.put(node.getAttributes().getNamedItem("command").getTextContent(),
                        (CommandHandler) Class.forName(node.getTextContent()).newInstance());
            }
        } catch (Exception e) {
            throw new RuntimeException("Failed to parse handlers.xml", e);
        }
    }

    public void loop() {...}
    public static void main(String[] args) {...}
}

Запускаем, проверяем!

Input command. Type 'exit' to finish.
greet
Hi! How are you?
fine!
Unknown command 'fine!'. Try again
exit
Bye!

Работает, отлично! Давайте еще что-нибудь напишем! Будем выводить текущее время с помощью команды time!

public class TimeCommand implements CommandHandler {
    @Override
    public void handle() {
        System.out.println("It's " + new SimpleDateFormat("HH:mm").format(new Date()));
    }
}

Запускаем..

Input command. Type 'exit' to finish.
time
Unknown command 'time'. Try again

Чёрт! Ладно, нет необходимости нервничать. Сейчас быстро добавлю новый хендлер в handers.xml и перезапущу. Делов то! Это же не реальный Enterprise проект, который собирается 5 минут и еще столько же запускается! Ну вы поняли...

Jeta in action

И что нам предлагает Jeta? Jeta предлагает collectors! Хендлеры будут автоматически находиться во время компиляции, я гарантирую это!

Подключаем библиотеку (build.gradle):

buildscript {
    repositories {
        maven {
            url 'https://plugins.gradle.org/m2/'
        }
    }
    dependencies {
        classpath 'net.ltgt.gradle:gradle-apt-plugin:0.9'
    }
}

apply plugin: 'java'
apply plugin: 'net.ltgt.apt'

repositories {
    jcenter()
}

dependencies {
    apt 'org.brooth.jeta:jeta-apt:+'
    compile 'org.brooth.jeta:jeta:+'
}

Создаем аннотацию Command и вешаем на наши хендлеры:

@Target(TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Command {
    String value();
}

@Command("exit")
public class ExitCommand implements CommandHandler {...}

@Command("greet")
public class GreetingCommand implements CommandHandler {...}

@Command("time")
public class TimeCommand implements CommandHandler {...}

Дорабатываем CommandProcessor:

@TypeCollector(Command.class)
public class CommandProcessor {
    private Map<String, CommandHandler> handlers = new HashMap<>();

    public CommandProcessor() {
        //parseHandlers();
        collectHandlers();
    }

    private void collectHandlers() {
        Metasitory metasitory = new MapMetasitory("");
        List<Class<?>> types = new TypeCollectorController(metasitory, getClass()).getTypes(Command.class);
        for (Class handlerClass : types) {
            try {
                Command command = (Command) handlerClass.getAnnotation(Command.class);
                handlers.put(command.value(), (CommandHandler) handlerClass.newInstance());
            } catch (Exception e) {
                throw new RuntimeException("Failed to collect handlers", e);
            }
        }
    }

    private void parseHandlers() {...}
    public void loop() {...}
    public static void main(String[] args) {...}
}

И...

Input command. Type 'exit' to finish.
greet
Hi! How are you?
fine
Unknown command 'fine'. Try again
time
It's 16:28
exit
Bye!

Behind the scenes

runtime
Я обещал без магии? Итак, по порядку:

Master

Тут ничего сложного, в контексте фреймворка, мастер — это класс для которого генерируется метокод. В нашем случает — это CommandProcessor, т.к. он использует аннотацию @TypeCollector.

Metacode

Метакод — сгенерированный (для мастера) класс. Он располагается в том же пакете, где и его мастер (спокойствие, не физически), и имеет составное имя: <Master name> + "_Metacode". В нашем примере это CommandProcessor_Metacode:

public class CommandProcessor_Metacode implements Metacode<CommandProcessor>, TypeCollectorMetacode {
    @Override
    public Class<CommandProcessor> getMasterClass() {
        return CommandProcessor.class;
    }

    @Override
    public List<Class<?>> getTypeCollection(Class<? extends Annotation> annotation) {
        if(annotation == org.brooth.jeta.samples.command_handler.Command.class) {
            List<Class<?>> result = new ArrayList<Class<?>>(3);
            result.add(org.brooth.jeta.samples.command_handler.commands.ExitCommand.class);
            result.add(org.brooth.jeta.samples.command_handler.commands.TimeCommand.class);
            result.add(org.brooth.jeta.samples.command_handler.commands.GreetingCommand.class);
            return result;
        }
        return null;
    }
}

Metasitory

Странное название, знаю. Но говорить каждый раз "Metacode Repository" тоже не хочется.

Metasitory, как не трудно догадаться, хранилище ссылок на метакод. Хотя, на рисунке это и выглядит как DB2, не стоит бояться, по умолчанию это — IdentityHashMap (впрочем, как упоминалось в начале, вы можете написать реализацию на DB2. Только пожалуйста, без pull request-ов). Если точнее, дефолтная Metasitory реализация — MapMetasitory. Это вы могли заметить в исходном коде CommandProcessor-а. MapMetasitory использует так называемые MetasitoryContainer-ы, которые, как и Metacode, генерируются автоматически во время компиляции. А вот они уже хранят контексты с метакодом в IdentityHashMap:

public class MetasitoryContainer implements MapMetasitoryContainer {
  @Override
  public Map<Class<?>, MapMetasitoryContainer.Context> get() {
    Map<Class<?>, MapMetasitoryContainer.Context> result = new IdentityHashMap<>();
    result.put(org.brooth.jeta.samples.command_handler.CommandProcessor.class,
        new MapMetasitoryContainer.Context(
            org.brooth.jeta.samples.command_handler.CommandProcessor.class,
            new org.brooth.jeta.Provider<org.brooth.jeta.samples.command_handler.CommandProcessor_Metacode>() {
                public org.brooth.jeta.samples.command_handler.CommandProcessor_Metacode get() {
                    return new org.brooth.jeta.samples.command_handler.CommandProcessor_Metacode();
            }},
            new Class[] {
                org.brooth.jeta.collector.TypeCollector.class
            }));
    return result;
  }
}

Контекст состоит из трех полей: Класс мастера, Metacode Provider (создает экземпляры метакода) и список используемых аннотаций. Такого набора достаточно для поиска по Criteria:

Criteria

Тут все понятно, с помощью Criteria описывается запрос к Metasitory. В текущей версии (2.3) поддерживается поиск по следующим критериям:

  • masterEq(Class<?> masterClass) — поиск метакода по его мастеру (а класс мастера является ключом IdentityHashMap, т.е. быстро).
  • masterEqDeep(Class<?> masterClass) — поиск метакода не только для мастера но и для его потомков (вызвали один раз в базовом классе и забиыли).
  • usesAny(Set<Class<? extends Annotation>> annotationList) — мастер использует любую аннотацию из списка.
  • usesAll(Set<Class<? extends Annotation>> annotationList) — мастер использует все аннотации из списка.

В нашем примере достаточно masterEq — т.к. нам интересен только CommandProcessor_Metacode.

Controller

Последний элемент (и кстати говоря необязательный) — контроллер. Вы обращаетесь к нужному контроллеру, он, с помощью Criteria, запрашивает у Metasitory соответствующий Metacode и "дергает" необходимые методы. Возможно делает еще какие-нибудь преобразования или проверки, все зависит от реализации. В нашем примере мы использовали TypeCollectorController (также фигурирует в исходном коде CommandProcessor-а):

public class TypeCollectorController {
    private Collection<Metacode<?>> metacodes;

    public TypeCollectorController(Metasitory metasitory, Class<?> masterClass) {
        metacodes = metasitory.search(new Criteria.Builder()
                .masterEq(masterClass)
                .build());
    }

    public List<Class<?>> getTypes(Class<? extends Annotation> annotation) {
        assert annotation != null;
        if (metacodes.isEmpty())
            return null;
        return ((TypeCollectorMetacode) metacodes.iterator().next()).getTypeCollection(annotation);
    }
}

Nuff Said

P.S.

Если на этот раз к библиотеке проявится интерес, следующая статья будет о Jeta Dependency Injection. Там будет о чем рассказать.

Не пишите лишнего, удачи!

Исходный код примера
Официальный сайт
Jeta на GitHub

Автор: brooth

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js