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

в 13:47, , рубрики: android development, aop, aspectj, java, Блог компании Яндекс, Программирование, Разработка под android

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

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

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

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

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

Выводы

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

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

Автор: Яндекс

Источник

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


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