company_banner

Теория и практика AOP. Как мы это делаем в Яндексе

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

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



    Сейчас мы используем 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, *;
        // не обязательно писать названия целиком, кругом паттерны!
    }
    

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


    Выводы


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

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

    Яндекс

    480,00

    Как мы делаем Яндекс

    Поделиться публикацией
    Комментарии 26
      0

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


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

        0
        На данный момент плагин поддержки AspectJ выжимает менее 10 секунд на полную компиляцию и внедрение инъекций в java-код довольно большого проекта. Мне кажется, проблем с этим быть не должно :)
        +3
        Знаете, это наверное ужаснейшая статья, которую я читал за последнее время. Где хоть какие то пояснения что происходит?
          0
          Что конкретно вызывает вопросы? Может, я помогу разобраться в комментариях? :)
            0
            ну я вообще аоп не понимал, пока не стал в тырпыпрайзе полноценном работать(не бекенд сайта)) так что имхо тут только имея опыт можно понять, а нафига оно нужно
              +1
              я не андроид разработчик, но с явой я знаком весьма не плохо. Извините, но все что стоит после «О нестандартных аспектах применения аспектов.» мне абсолютно не понятно.
                0
                Эта часть описывает взаимодействия с кодом на уровне команд компилятору. Другими словами, AspectJ позволяет описывать правила вхождений/исключений каких-либо директив в java-исходниках и компилятор будет ругаться при сборке проекта, если эти правила были нарушены разработчиком :)
                При этом, компилятор не просто смотрит на код, как на текст, а так же анализирует контекст класса/объекта, области видимости и вызова методов, обработку ошибок. И также позволяет настраивать порядок внедрения инъекций :)
            0
            Для тех, кому лень читать или не понятны какие-либо магические штуки, о которых я рассказываю — можно послушать и посмотреть мой доклад в Яндексе на Droid Party, где я подробно рассказываю материал этой статьи :)
              +6
              «И если о первых трёх технологиях слышал каждый разработчик, а многие даже применяют их у себя, то о четвёртой знают только хардкорные джависты, пишущие большие серверные проекты и разного рода энтерпрайзы.»

              Это чувство, когда ты используешь AspectJ в каждом втором проекте, а о первых трех даже не слышал.
                +3
                Я не экстрасенс — но с аоп у вас будут проблемы -сначала все ОК! Через годик пожалауйста напишите как у вас с аоп!
                  0
                  Я поставлю напоминалку на год вперёд :)
                  0
                  Это скорее проблема языка, нет никаких проблем решить те же проблемы стандартными шаблонами, но скудность синтаксиса и малая выразительность ведут к громоздкости такого кода, отсюда все это программирование на аннотациях
                    0
                    «Сахарные примеси аспектного подхода в (Kotlin)» — такого ругательства я еще не слышал. Ни к сахару, ни к аспектам функции-расширения Kotlin особого отношения не имеют, IMO.
                      –1
                      Экстенды методов и полей не вставляются в целевой класс, а значит это именно «синтаксический сахар». Но они связывают код конкретного класса (и дают доступ к приватным полям/методам) и самой программы — а это аспектный подход. :)
                        0
                        Под «синтаксическим сахаром» обычно понимается конструкции которые дублируют что-то еще, существующее в языки и/или абстракции, которые «протекают». Функции-расширения не могут быть заменены обычными функциями (в отличие от C#) и не являются методами из-за отличающихся правил диспатча (в отличие от Swift). Это отдельная логическая сущность в Kotlin которая не может быть выражена через остальные примитивы языка.

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

                            Замечу что если под «не сахарными» расширениями понимать например то, что есть в Swift, где расширение не сильно отличается от метода по свойствам и видно глобально, то получается забавная картина. Swift не имеет аналога Kotlin DSL ровно из-за глобальности их функций-расширений, то есть «хардкорные» фичи проигрывают «сахару» по функциональности. Постройка древовидных DSL очень важная фича Kotlin, и уж точно не является сахаром, так как обеспечивает статические гарантии структуры.
                      0
                      Скажите пожалуйста, насколько я понимаю, оверхеда в райнтайме нет? У нас просто генерируется дополнительный байт код во время компиляции?
                        0
                        Именно. Однако, некоторые инструкции генерируют рантайм-проверки, избыток и нагруженность которых может значительно замедлять работу программы.
                          0
                          Я так понимаю речь идет о not-null ассертах. Кто-то замерял на сколько замедляется выполнение программы с ними? JIT вполне способен убрать лишние проверки из исполнения, если например они дублируют друг друга. К тому же если я правильно понимаю как JVM оптимизации работают, код с проверками может работать быстрее так как JVM не нужно эмулировать NPE в случае нулевых ссылок.
                            0
                            NonNull-проверки действительно очень быстро работают и не замедляют основное выполнение. Я имел в виду более сложные вещи: директивы cflow/cflowbelow и динамическая директива if(expr), которая в рантайме выполняется на каждом джоинпоинте.
                            Но можно выстрелить в ногу еще более изощрённо. Например, написать срез, в область видимости которого попадает каждый метод каждого класса и уже внутри джоинпоинтов выполняются runtime-проверки на наличие аннотаций над методами. В таком сценарии время выполнения методов будет сильно провисать.

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

                              Про JIT я неточно выразился. Имелся в виду любой оптимизирующий компилятор который работает через границы модулей.
                            0
                            а можно сконфигурировать компилятор aspectJ, чтобы эти проверки не добавлялись?
                              0
                              Нельзя, т.к. некоторые директивы напрямую вносят эти проверки. Но можно написать срезы, которые не порождают эти проверки :) В основном, проверки вставляются туда, где нет возможность статически выявить типы и приходится эту функцию переносить в рантайм. Например, проверки аннотаций над аргументами/методом/классом чаще всего разрешаются в рантайме, если описывать их в срезе. Это можно обойти, если использовать механизмы маркировки и расширения дерева родителей класса.
                          0
                          RxJava, Dagger 2, Retrolambda и AspectJ. И если о первых трёх технологиях слышал каждый разработчик, а многие даже применяют их у себя, то о четвёртой знают только хардкорные джависты
                          это только у меня с точностью до наоборот? RxJava правда еще немного читал, но Dagger 2, Retrolambda — ни разу нигде не встречал.
                            +1
                            Эти библиотеки распространены, преимущественно, в мобильной разработке :) Хотя даггер, вроде как, и в обычной джаве тоже применяется активно.

                          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                          Самое читаемое