Одна из ключевых особенностей работы в Яндексе — это свобода выбора технологий. В Авто.ру, где я работаю, нам приходится поддерживать большой пласт исторических решений, поэтому любая новая технология или библиотека встречается двумя вопросами коллег:

— Насколько это увеличит дистрибутив?
— Как это поможет нам писать меньше и эффективнее?



Сейчас мы используем RxJava, Dagger 2, Retrolambda и AspectJ. И если о первых трёх технологиях слышал каждый разработчик, а многие даже применяют их у себя, то о четвёртой знают только хардкорные джависты, пишущие большие серверные проекты и разного рода энтерпрайзы.

Передо мной стояла цель ответить на эти два вопроса и обосновать использование AOP-методологии в Android-проекте. А это значит — написать код и показать наглядно, как аспектно-ориентированное программирование поможет нам ускорить и облегчить работу разработчиков. Но обо всём по порядку.



Начнём с азов


Хотим обернуть все запросы к API в трай-кетч, и чтоб никогда не падало! А ещё логи! А ещё...
Пфф… Пишем семь строчек кода и вуаля.
abstract aspect NetworkProtector { // аспектный класс, по умолчанию синглтон

    abstract pointcut myClass(); // срез, он же — поиск мест внедрения нижележащих инструкций

    Response around(): myClass() && execution(* executeRequest(..)) { // встраиваемся «вместо» методов executeRequest
        try {
            return proceed(); // выполняем само тело метода, перехваченного around'ом
        } catch (NetworkException ex) {
            Response response = new Response(); // если сервер не умеет в обработку ошибок...
            response.addError(new Error(ex)); // …ну или сетевой слой написан, мягко говоря, не очень
            return response;
        }
    }
}


Легко, правда? А теперь немного терминологии, без неё дальше никак.

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

Первое, с чего разработчик начинает постигать дзен, — поиск однородностей. Если два класса делают сколько-нибудь похожую работу, например оперируют одним и тем же объектом, — они однородны. Когда n сущностей абсолютно одинаково взаимодействуют с внешним миром — они однородны. Всё это можно описать срезами (pointcut) и начать увлекательный путь к просвещению.

Второе, без чего не обходится ни один просветлённый инженер. Приходится в уме предусматривать все возможные комбинации случайных однородностей. Под ваши условия могут подпасть объекты, вовсе не относящиеся к теме и ситуации. Просто вы не учли, что под каким-то хитрым углом они выглядят похоже. Это научит вас писать устойчивые паттерны.

Лучше всего начать описание срезов с аннотаций. И, честно говоря, лучше ими же закончить. Это прекрасный и очевидный подход, пришедший из пятой джавы. Именно аннотации скажут непросвещённому инженеру, что в этом классе творится какая-то запредельная магия. Именно аннотации являются вторым сердцем Spring-фреймворка, которые разруливает AspectJ под капотом. Этим же путём идут все современные большие проекты — AndroidAnnotations, Dagger, ButterKnife. Почему? Очевидность и лаконичность, Карл. Очевидность и лаконичность.

oop and aop

Инструментарий


Поговорим отдельно и коротко про наш разработческий арсенал. В среде Android великое множество инструментов и методологий, архитектурных подходов и различных компонентов. Здесь и миниатюрные библиотеки-хелперы, и монструозные комбайны типа Realm. И относительно небольшие, но серьёзные Retrofit, Picasso.
Применяя в своих проектах всё это многообразие, мы адаптируем не только свой код под новые архитектурные аспекты и библиотеки. Мы апгрейдим и свой собственный скилл, разбираясь и осваивая новый инструмент. И чем этот инструмент больше, тем серьёзнее приходится переучиваться.

Наглядно эту адаптацию демонстрирует набирающий популярность Kotlin, который требует не столько освоения себя как инструмента, сколько изменения подхода к архитектуре и структуре проекта в целом. Сахарные примеси аспектного подхода в этом языке (я сейчас намекаю на экстеншен методов и полей) добавляют нам гибкости в построении бизнес-логики и перзистенции, но притупляют понимание процессов. Чтобы «видеть», как будет работать код на устройстве, в голове приходится интерпретировать не только видимый сейчас код, но и подмешивать в него инструкции и декораторы извне.

Та же ситуация, когда речь заходит об АОП.

Выбор проблем и решений


Конкретная ситуация диктует нам набор подходящих и возможных (или не очень) решений. Мы можем искать решение у себя в голове, опираясь на собственный опыт и знания. Или же обратиться за помощью, если знаний недостаточно для решения какой-то конкретной задачи.
Пример вполне очевидной и простой «задачи» — сетевой слой. Нам понадобится:
  • Изолировать сетевой слой. (Retrofit)
  • Обеспечить прозрачное общение с UI-слоем. (Robospice, RxJava)
  • Предоставить полиморфный доступ. (EventBus)

И если раньше вы не работали с RxJava или EventBus, решение этой задачи обернётся массой подводных граблей. Начиная от синхронизации и заканчивая lifecycle.

Пару лет назад мало кто из Android-девелоперов знал про Rx, а сейчас он набирает такую популярность, что скоро может стать обязательным пунктом в описании вакансий. Так или иначе, мы всегда развиваем себя и адаптируемся к новым технологиям, удобным практикам, модным веяниям. Как говорится, мастерство приходит с опытом. Даже если на первый взгляд они не особо и нужны были :)

Новые горизонты, или зачем нужен АОП?


В аспектной среде мы видим кардинально новое понятие — однородность. Сразу в примерах и без лишних слов. Но не будем далеко отходить от Android'a.

public class MyActivityImpl extends Activity {

    protected void onCreate(Bundle savedInstanceState) {
        TransitionProvider.overrideWindowTransitionsFor(this);

        super.onCreate(savedInstanceState);
        this.setContentView(R.layout.activity_main);

        Toolbar toolbar = ToolbarProvider.setupToolbar(this);
        this.setActionBar(toolbar);

        AnalyticsManager.register(this);
    }
}


Подобный бойлерплейт мы пишем чуть ли не в каждом экране и фрагменте. Отдельные процедуры могут быть определены в провайдерах, презентарах или интеракторах. А могут «толпиться» прямо в системных коллбэках.
Чтобы всё это приобрело красивый и системный (от слова «систематизировать») вид, сперва хорошенько подумаем вот над чем: как нам изолировать такую логику? Хорошим решением здесь будет написать несколько отдельных классов, каждый из которых станет отвечать за свой маленький кусочек.

Сначала изолируем поведение тулбара
public aspect ToolbarDecorator {

    pointcut init(): execution(* Activity+.onCreate(..)) && // тело метода в любом наследнике Activity
                             @annotation(StyledToolbarAnnotation); // только с аннотацией над классом или методом

    after() returning: init() { // не будем стайлить тулбар, если onCreate крашнулся
        Activity act = thisJoinPoint.getThis();
        Toolbar toolbar = setupToolbar(act);
        act.setActionBar(toolbar);
    }
}


Теперь избавимся от переопределения анимаций активити
public aspect TransitionDecorator                          {

    pointcut init(TransitionAnnotation t): @within(t) && // аннотация мастхэв
                            execution(* Activity+.onCreate(..)); // уже видели

    before(TransitionAnnotation transition): init(transition) {
        Activity act = thisJoinPoint.getThis();
        registerState(transition);
        overrideWindowTransitionsFor(act);
    }
}


И, наконец — выкинем аналитику в отдельный класс
public aspect AnalyticsInjector {
    private static final String API_KEY = “…”;

    pointcut trackStart(): execution(* Activity+.onCreate(..)) &&
                                 @annotation(WithAnalyticsInit);

    after(): returning: trackStart() {
        Context context = thisJoinPoint.getThis();
        YandexMetrica.activate(context, API_KEY);
        Adjust.onCreate(new AdjustConfig(context, “…”, PROD));
    }
}



Ну вот и всё. Мы получили чистый и компактный код, где каждая порция однородной функциональности красиво изолирована и пристёгивается только туда, где она явно нужна, а не в каждый класс, посмевший отнаследоваться от Activity.
Финальный вид:
@StyledToolbarAnnotation
@TransitionAnnotation(TransitionType.MODAL)
@WithContentViewLayout(R.layout.activity_main) // ну прямо как AndroidAnnotations! \m/
public class MyActivityImpl extends Activity {

    @WithAnalyticsInit
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        /* ... */
    }
}


В данном примере наши аннотации являются источником однородности и с их помощью мы можем «пристегнуть» дополнительную функциональность буквально в любом нужном месте. Комбинируя аннотации, самодокументируемые названия и смекалку, мы ищем или декларируем однородности вдоль всего объектного кода, как бы аугментируя и декорируя его нужными инструкциями.

Благодаря аннотациям сохраняется понимание процессов, происходящих в коде. Новичок сразу смекнёт, что здесь подкапотная магия. Самодокументируемость может позволить нам легко управлять служебными инструментами — логированием, профилированием. Инструментирование Java-кода можно легко настроить на поиск по вхождениям ключевых слов в именах классов, методов или полей, доступ и использование которых хотим отследить.

О нестандартных аспектах применения аспектов.

Большие команды часто выстраивают строгий flow коммита, по которому код проходит множество этапов. Здесь могут быть тестовые сборки на CI, инспекция кода, обкатка тестами, pull-request. Количество итераций в этом процессе можно сократить без потери качества путём введения статического анализа кода, для которого вовсе не обязательно устанавливать дополнительное ПО, заставлять разработчика изучать lint-репорты или выносить этот кейс на сторону того же svc.

Достаточно описать директивы компилятору, который сумеет сам определить, что именно в нашем коде делается «неправильно» или «потенциально плохо».

Простенькая проверка на запись филда вне метода-сеттера
public aspect AccessVerifier {

    declare warning : fieldSet() && within(ru.yandex.example.*)
                    : "writing field outside setter" ;

    pointcut fieldSet(): set(!public * *) && !withincode(* set*(..));
    // set означает доступ к полю на запись, а в конце — паттерн метода-сеттера
}



В более строгих ситуациях можно вообще отказаться собирать проект, если разработчик явно «халтурит» или пытается видоизменить поведение там, где этого делать явно не следует.

Проверка на отлов NPE и вызов конструктора за пределами билд-метода
public aspect AnalyticsVerifier {

    declare error : handler(NullPointerException+) // декларация try-catch блока с обработкой NPE
                    && withincode(* AnalyticsManager+.register(..))
                  : "do not handle NPE in this method";

    declare error : call(AnalyticsManager+.new(..))
                    && !cflow(static AnalyticsManager.build(..))
                  : "you should not call constructor outside a AnalyticsManager.build() method";
}

Магическое слово «cflow» — это захват всех вложенных вызовов на любой глубине в пределах выполнения целевого метода. Не слишком очевидная, но очень мощная штука.


Мне важен порядок! А вдруг что-то отработает не вовремя?
public aspect StrictVerifyOrder {

    // сначала инжекторы/декораторы, потом проверяем что да как
    declare precedence: *Injector, *Decorator, *Verifier, *;
    // не обязательно писать названия целиком, кругом паттерны!
}

Просто об этом часто спрашивают :) Да, можно ручками настроить «важность» и очерёдность каждого отдельного аспекта.
Но не стоит пихать это в каждый класс, иначе порядок получится непредсказуемый (ваш кэп!).


Выводы


Любая задача решается наиболее удобными инструментами. Я выделил несколько простых повседневных задач, которые могут быть легко решены с помощью аспектно-ориентированного подхода к разработке. Это не призыв отказаться от ООП и осваивать что-то другое, скорее наоборот! В умелых руках АОП гармонично расширяет объектную структуру, удачно разрешая задачи изоляции, дедупликации кода, легко справляясь с копипастой, мусором, невнимательностью при использовании проверенных решений.

Мы пишем один коротенький класс в десяток строк и внедряем его в два десятка других классов через прозрачные и простые «якоря» или условия. При этом затраты на описание устойчивых аспектных классов тем ниже, чем быстрее мы сами адаптируемся к поиску и применению однородностей в своём коде.
Поделиться с друзьями
-->

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


  1. Scf
    30.06.2016 16:57

    Мы применяли AOP/AspectJ одно время. Потом отказались, т.к. он значительно увеличивает время компиляции, ну и поддержка AOP в эклипсе была реализована, скажем так, не очень. Оказалось, что писать логгирование и обработку ошибок в нужных местах вручную не так уж и сложно :-)


    Интересно использование AOP вместо самодельных правил для checkstyle… Выглядит, во всяком случае, короче.


    1. archinamon
      30.06.2016 17:09

      На данный момент плагин поддержки AspectJ выжимает менее 10 секунд на полную компиляцию и внедрение инъекций в java-код довольно большого проекта. Мне кажется, проблем с этим быть не должно :)


  1. schroeder
    30.06.2016 17:03
    +3

    Знаете, это наверное ужаснейшая статья, которую я читал за последнее время. Где хоть какие то пояснения что происходит?


    1. archinamon
      30.06.2016 17:16

      Что конкретно вызывает вопросы? Может, я помогу разобраться в комментариях? :)


      1. MaximChistov
        30.06.2016 17:18

        ну я вообще аоп не понимал, пока не стал в тырпыпрайзе полноценном работать(не бекенд сайта)) так что имхо тут только имея опыт можно понять, а нафига оно нужно


      1. schroeder
        30.06.2016 17:24
        +1

        я не андроид разработчик, но с явой я знаком весьма не плохо. Извините, но все что стоит после «О нестандартных аспектах применения аспектов.» мне абсолютно не понятно.


        1. archinamon
          30.06.2016 17:37

          Эта часть описывает взаимодействия с кодом на уровне команд компилятору. Другими словами, AspectJ позволяет описывать правила вхождений/исключений каких-либо директив в java-исходниках и компилятор будет ругаться при сборке проекта, если эти правила были нарушены разработчиком :)
          При этом, компилятор не просто смотрит на код, как на текст, а так же анализирует контекст класса/объекта, области видимости и вызова методов, обработку ошибок. И также позволяет настраивать порядок внедрения инъекций :)


  1. archinamon
    30.06.2016 17:15

    Для тех, кому лень читать или не понятны какие-либо магические штуки, о которых я рассказываю — можно послушать и посмотреть мой доклад в Яндексе на Droid Party, где я подробно рассказываю материал этой статьи :)


  1. DarkOrion
    30.06.2016 17:52
    +6

    «И если о первых трёх технологиях слышал каждый разработчик, а многие даже применяют их у себя, то о четвёртой знают только хардкорные джависты, пишущие большие серверные проекты и разного рода энтерпрайзы.»

    Это чувство, когда ты используешь AspectJ в каждом втором проекте, а о первых трех даже не слышал.


  1. johhy13
    30.06.2016 23:52
    +3

    Я не экстрасенс — но с аоп у вас будут проблемы -сначала все ОК! Через годик пожалауйста напишите как у вас с аоп!


    1. archinamon
      03.07.2016 21:19

      Я поставлю напоминалку на год вперёд :)


  1. chEbba
    02.07.2016 13:37

    Это скорее проблема языка, нет никаких проблем решить те же проблемы стандартными шаблонами, но скудность синтаксиса и малая выразительность ведут к громоздкости такого кода, отсюда все это программирование на аннотациях


  1. voddan
    02.07.2016 23:52

    «Сахарные примеси аспектного подхода в (Kotlin)» — такого ругательства я еще не слышал. Ни к сахару, ни к аспектам функции-расширения Kotlin особого отношения не имеют, IMO.


    1. archinamon
      03.07.2016 21:17
      -1

      Экстенды методов и полей не вставляются в целевой класс, а значит это именно «синтаксический сахар». Но они связывают код конкретного класса (и дают доступ к приватным полям/методам) и самой программы — а это аспектный подход. :)


      1. voddan
        03.07.2016 21:31

        Под «синтаксическим сахаром» обычно понимается конструкции которые дублируют что-то еще, существующее в языки и/или абстракции, которые «протекают». Функции-расширения не могут быть заменены обычными функциями (в отличие от C#) и не являются методами из-за отличающихся правил диспатча (в отличие от Swift). Это отдельная логическая сущность в Kotlin которая не может быть выражена через остальные примитивы языка.

        Расширения не дают доступ к приватным членам класса из вне, пожалуйста проверь это утверждение на коде


        1. archinamon
          04.07.2016 19:09

          Насчёт приватного доступа вы правы, я что-то напутал :)
          Тем не менее, это синтаксический сахар в своём чистом виде — по сути, обычные статические методы, которые первым аргументом принимают целевой класс. Без статического импорта нужного метода/поля эти экстенды работать не будут и в целевом классе не появятся :)


          1. voddan
            04.07.2016 19:38
            +1

            Кажется мы расходимся в определении «сахара».

            Замечу что если под «не сахарными» расширениями понимать например то, что есть в Swift, где расширение не сильно отличается от метода по свойствам и видно глобально, то получается забавная картина. Swift не имеет аналога Kotlin DSL ровно из-за глобальности их функций-расширений, то есть «хардкорные» фичи проигрывают «сахару» по функциональности. Постройка древовидных DSL очень важная фича Kotlin, и уж точно не является сахаром, так как обеспечивает статические гарантии структуры.


  1. willykolepniy
    03.07.2016 21:03

    Скажите пожалуйста, насколько я понимаю, оверхеда в райнтайме нет? У нас просто генерируется дополнительный байт код во время компиляции?


    1. archinamon
      03.07.2016 21:13

      Именно. Однако, некоторые инструкции генерируют рантайм-проверки, избыток и нагруженность которых может значительно замедлять работу программы.


      1. voddan
        03.07.2016 21:39

        Я так понимаю речь идет о not-null ассертах. Кто-то замерял на сколько замедляется выполнение программы с ними? JIT вполне способен убрать лишние проверки из исполнения, если например они дублируют друг друга. К тому же если я правильно понимаю как JVM оптимизации работают, код с проверками может работать быстрее так как JVM не нужно эмулировать NPE в случае нулевых ссылок.


        1. archinamon
          04.07.2016 19:19

          NonNull-проверки действительно очень быстро работают и не замедляют основное выполнение. Я имел в виду более сложные вещи: директивы cflow/cflowbelow и динамическая директива if(expr), которая в рантайме выполняется на каждом джоинпоинте.
          Но можно выстрелить в ногу еще более изощрённо. Например, написать срез, в область видимости которого попадает каждый метод каждого класса и уже внутри джоинпоинтов выполняются runtime-проверки на наличие аннотаций над методами. В таком сценарии время выполнения методов будет сильно провисать.

          К слову, на последних версиях андроида уже нет JIT'a. Среда выполнения ART прекомпилирует код приложения при установке и, поэтому, никаких дополнительных оптимизаций уже не будет в процессе выполнения.


          1. voddan
            04.07.2016 19:28

            А, извиняюсь, не до конца понял исходный вопрос.

            Про JIT я неточно выразился. Имелся в виду любой оптимизирующий компилятор который работает через границы модулей.


      1. rerf2010rerf
        11.11.2016 14:36
        +1

        Я не физик, но мнение о книжках вроде упомянутого «Гиперпространства» имею. На мой взгляд, от них больше вреда, чем пользы. Поскольку нормально объяснить квантовую физику, ОТО, да даже и СТО, не говоря уж о более современных вещах без математики невозможно, то авторы начинают городить такой дикий многоэтажный огород отдалённых аналогий, что разглядеть за ними физическую суть невозможно. В итоге, после чтения таких книжек возникает дикая каша в голове, без какого бы то ни было реального понимания, зато уж иллюзия понимания и причастности к некой «понимающей элите» возникает несомненно. А главное, возникает иллюзия, что всё это научно-популярное словоблудие без единой формулы — это и есть настоящая физика, а значит и ощущение, что «я тоже так могу, щас так же накидаю кучу наукообразных слов в одну кучу, и вот вам ещё десяток теорий». Отсюда и такие вот теории, как в этом посте.
        Знаю всё это на своём опыте, тоже когда-то начитался популярных книжек и думал, что что-то понял. Потом отучился 4 года на мехмате и понял на физике, что всё, что я понимал, вернее думал, что понимал, о квантовой механике, было полным бредом. До других областей физики так и не добрался. По моему, если уж пытаться объяснять основы современной физики, то делать это с математикой, хотя бы на самом простом из необходимых для нормального понимания уровне, но не проще. Или не пытаться вообще.
        Например, тут уже упоминали книги Сасскинда «Теоретический минимум» — вот это, на мой взгляд, хорошая научно-популярная книга, которая действительно поможет разобраться. А объяснения свёрнутых измерений через муравьёв на шланге и подобные нужно отправлять в мусорку не читая, чтобы мозг не засорять.


        1. archinamon
          05.07.2016 15:22

          Нельзя, т.к. некоторые директивы напрямую вносят эти проверки. Но можно написать срезы, которые не порождают эти проверки :) В основном, проверки вставляются туда, где нет возможность статически выявить типы и приходится эту функцию переносить в рантайм. Например, проверки аннотаций над аргументами/методом/классом чаще всего разрешаются в рантайме, если описывать их в срезе. Это можно обойти, если использовать механизмы маркировки и расширения дерева родителей класса.


  1. gkislin
    06.07.2016 15:40

    RxJava, Dagger 2, Retrolambda и AspectJ. И если о первых трёх технологиях слышал каждый разработчик, а многие даже применяют их у себя, то о четвёртой знают только хардкорные джависты
    это только у меня с точностью до наоборот? RxJava правда еще немного читал, но Dagger 2, Retrolambda — ни разу нигде не встречал.


    1. archinamon
      06.07.2016 16:28
      +1

      Эти библиотеки распространены, преимущественно, в мобильной разработке :) Хотя даггер, вроде как, и в обычной джаве тоже применяется активно.