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

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 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…




    org.brooth.jeta.samples.command_handler.commands.GreetingCommand
    org.brooth.jeta.samples.command_handler.commands.ExitCommand

парсим!


public class CommandProcessor {
    private Map 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 handlers = new HashMap<>();

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

    private void collectHandlers() {
        Metasitory metasitory = new MapMetasitory("");
        List> 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


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


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

    @Override
    public List> getTypeCollection(Class annotation) {
        if(annotation == org.brooth.jeta.samples.command_handler.Command.class) {
            List> result = new ArrayList>(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, MapMetasitoryContainer.Context> get() {
    Map, 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() {
                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> annotationList) — мастер использует любую аннотацию из списка.
  • usesAll(Set> annotationList) — мастер использует все аннотации из списка.

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


Controller


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


public class TypeCollectorController {
    private Collection> metacodes;

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

    public List> getTypes(Class 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

Комментарии (2)

  • 2 марта 2017 в 10:50

    –1

    Я правильно понял что вместо того чтобы использовать Map вы изобрели свой велосипед?
  • 2 марта 2017 в 14:56

    0

    Думаю, что интерес не появится.
    Лично я пишу, что-то подобное ради удовольствия, когда немного хочу отвлечься от основной работы.

    Обычно среди «библиотек» есть 2 типа плохих методов: слишком сложные (чтобы в них разобраться) или сильно простые (которые проще написать самому). У вас второй случай.

    Когда-то, лет 10 назад я написал свой набор методов на javascript, и думал, что теперь весь мир изменится. Я показывал их преподавателям, друзьям, сокурсникам со словами «вот, берите и используйте». И знаете что? Эта библиотека нафиг никому не была нужна.

    Вы сильно переоценили свой «фреймворк». Эпитеты типа «никакой Магии» явно не для такого уровня статей. Посмотрите на свой творение более самокритично.

    То, как организовали классы (архитектуру, концепцию) — это удобно вам, но может быть не удобно другим (мы все думаем немного по-разному). И одно дело, когда перед тобой признанный фреймворк, который знают все и под который подстроены другие фреймворки — это одно, здесь можно и потерпеть. И совсем другое дело, это ваша библиотека.

© Habrahabr.ru