— Насколько это увеличит дистрибутив?
— Как это поможет нам писать меньше и эффективнее?
Сейчас мы используем 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. Почему? Очевидность и лаконичность, Карл. Очевидность и лаконичность.
Инструментарий
Поговорим отдельно и коротко про наш разработческий арсенал. В среде 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 означает доступ к полю на запись, а в конце — паттерн метода-сеттера
}
В более строгих ситуациях можно вообще отказаться собирать проект, если разработчик явно «халтурит» или пытается видоизменить поведение там, где этого делать явно не следует.
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)
schroeder
30.06.2016 17:03+3Знаете, это наверное ужаснейшая статья, которую я читал за последнее время. Где хоть какие то пояснения что происходит?
archinamon
30.06.2016 17:16Что конкретно вызывает вопросы? Может, я помогу разобраться в комментариях? :)
MaximChistov
30.06.2016 17:18ну я вообще аоп не понимал, пока не стал в тырпыпрайзе полноценном работать(не бекенд сайта)) так что имхо тут только имея опыт можно понять, а нафига оно нужно
schroeder
30.06.2016 17:24+1я не андроид разработчик, но с явой я знаком весьма не плохо. Извините, но все что стоит после «О нестандартных аспектах применения аспектов.» мне абсолютно не понятно.
archinamon
30.06.2016 17:37Эта часть описывает взаимодействия с кодом на уровне команд компилятору. Другими словами, AspectJ позволяет описывать правила вхождений/исключений каких-либо директив в java-исходниках и компилятор будет ругаться при сборке проекта, если эти правила были нарушены разработчиком :)
При этом, компилятор не просто смотрит на код, как на текст, а так же анализирует контекст класса/объекта, области видимости и вызова методов, обработку ошибок. И также позволяет настраивать порядок внедрения инъекций :)
archinamon
30.06.2016 17:15Для тех, кому лень читать или не понятны какие-либо магические штуки, о которых я рассказываю — можно послушать и посмотреть мой доклад в Яндексе на Droid Party, где я подробно рассказываю материал этой статьи :)
DarkOrion
30.06.2016 17:52+6«И если о первых трёх технологиях слышал каждый разработчик, а многие даже применяют их у себя, то о четвёртой знают только хардкорные джависты, пишущие большие серверные проекты и разного рода энтерпрайзы.»
Это чувство, когда ты используешь AspectJ в каждом втором проекте, а о первых трех даже не слышал.
johhy13
30.06.2016 23:52+3Я не экстрасенс — но с аоп у вас будут проблемы -сначала все ОК! Через годик пожалауйста напишите как у вас с аоп!
chEbba
02.07.2016 13:37Это скорее проблема языка, нет никаких проблем решить те же проблемы стандартными шаблонами, но скудность синтаксиса и малая выразительность ведут к громоздкости такого кода, отсюда все это программирование на аннотациях
voddan
02.07.2016 23:52«Сахарные примеси аспектного подхода в (Kotlin)» — такого ругательства я еще не слышал. Ни к сахару, ни к аспектам функции-расширения Kotlin особого отношения не имеют, IMO.
archinamon
03.07.2016 21:17-1Экстенды методов и полей не вставляются в целевой класс, а значит это именно «синтаксический сахар». Но они связывают код конкретного класса (и дают доступ к приватным полям/методам) и самой программы — а это аспектный подход. :)
voddan
03.07.2016 21:31Под «синтаксическим сахаром» обычно понимается конструкции которые дублируют что-то еще, существующее в языки и/или абстракции, которые «протекают». Функции-расширения не могут быть заменены обычными функциями (в отличие от C#) и не являются методами из-за отличающихся правил диспатча (в отличие от Swift). Это отдельная логическая сущность в Kotlin которая не может быть выражена через остальные примитивы языка.
Расширения не дают доступ к приватным членам класса из вне, пожалуйста проверь это утверждение на кодеarchinamon
04.07.2016 19:09Насчёт приватного доступа вы правы, я что-то напутал :)
Тем не менее, это синтаксический сахар в своём чистом виде — по сути, обычные статические методы, которые первым аргументом принимают целевой класс. Без статического импорта нужного метода/поля эти экстенды работать не будут и в целевом классе не появятся :)voddan
04.07.2016 19:38+1Кажется мы расходимся в определении «сахара».
Замечу что если под «не сахарными» расширениями понимать например то, что есть в Swift, где расширение не сильно отличается от метода по свойствам и видно глобально, то получается забавная картина. Swift не имеет аналога Kotlin DSL ровно из-за глобальности их функций-расширений, то есть «хардкорные» фичи проигрывают «сахару» по функциональности. Постройка древовидных DSL очень важная фича Kotlin, и уж точно не является сахаром, так как обеспечивает статические гарантии структуры.
willykolepniy
03.07.2016 21:03Скажите пожалуйста, насколько я понимаю, оверхеда в райнтайме нет? У нас просто генерируется дополнительный байт код во время компиляции?
archinamon
03.07.2016 21:13Именно. Однако, некоторые инструкции генерируют рантайм-проверки, избыток и нагруженность которых может значительно замедлять работу программы.
voddan
03.07.2016 21:39Я так понимаю речь идет о not-null ассертах. Кто-то замерял на сколько замедляется выполнение программы с ними? JIT вполне способен убрать лишние проверки из исполнения, если например они дублируют друг друга. К тому же если я правильно понимаю как JVM оптимизации работают, код с проверками может работать быстрее так как JVM не нужно эмулировать NPE в случае нулевых ссылок.
archinamon
04.07.2016 19:19NonNull-проверки действительно очень быстро работают и не замедляют основное выполнение. Я имел в виду более сложные вещи: директивы cflow/cflowbelow и динамическая директива if(expr), которая в рантайме выполняется на каждом джоинпоинте.
Но можно выстрелить в ногу еще более изощрённо. Например, написать срез, в область видимости которого попадает каждый метод каждого класса и уже внутри джоинпоинтов выполняются runtime-проверки на наличие аннотаций над методами. В таком сценарии время выполнения методов будет сильно провисать.
К слову, на последних версиях андроида уже нет JIT'a. Среда выполнения ART прекомпилирует код приложения при установке и, поэтому, никаких дополнительных оптимизаций уже не будет в процессе выполнения.voddan
04.07.2016 19:28А, извиняюсь, не до конца понял исходный вопрос.
Про JIT я неточно выразился. Имелся в виду любой оптимизирующий компилятор который работает через границы модулей.
rerf2010rerf
11.11.2016 14:36+1Я не физик, но мнение о книжках вроде упомянутого «Гиперпространства» имею. На мой взгляд, от них больше вреда, чем пользы. Поскольку нормально объяснить квантовую физику, ОТО, да даже и СТО, не говоря уж о более современных вещах без математики невозможно, то авторы начинают городить такой дикий многоэтажный огород отдалённых аналогий, что разглядеть за ними физическую суть невозможно. В итоге, после чтения таких книжек возникает дикая каша в голове, без какого бы то ни было реального понимания, зато уж иллюзия понимания и причастности к некой «понимающей элите» возникает несомненно. А главное, возникает иллюзия, что всё это научно-популярное словоблудие без единой формулы — это и есть настоящая физика, а значит и ощущение, что «я тоже так могу, щас так же накидаю кучу наукообразных слов в одну кучу, и вот вам ещё десяток теорий». Отсюда и такие вот теории, как в этом посте.
Знаю всё это на своём опыте, тоже когда-то начитался популярных книжек и думал, что что-то понял. Потом отучился 4 года на мехмате и понял на физике, что всё, что я понимал, вернее думал, что понимал, о квантовой механике, было полным бредом. До других областей физики так и не добрался. По моему, если уж пытаться объяснять основы современной физики, то делать это с математикой, хотя бы на самом простом из необходимых для нормального понимания уровне, но не проще. Или не пытаться вообще.
Например, тут уже упоминали книги Сасскинда «Теоретический минимум» — вот это, на мой взгляд, хорошая научно-популярная книга, которая действительно поможет разобраться. А объяснения свёрнутых измерений через муравьёв на шланге и подобные нужно отправлять в мусорку не читая, чтобы мозг не засорять.archinamon
05.07.2016 15:22Нельзя, т.к. некоторые директивы напрямую вносят эти проверки. Но можно написать срезы, которые не порождают эти проверки :) В основном, проверки вставляются туда, где нет возможность статически выявить типы и приходится эту функцию переносить в рантайм. Например, проверки аннотаций над аргументами/методом/классом чаще всего разрешаются в рантайме, если описывать их в срезе. Это можно обойти, если использовать механизмы маркировки и расширения дерева родителей класса.
gkislin
06.07.2016 15:40RxJava, Dagger 2, Retrolambda и AspectJ. И если о первых трёх технологиях слышал каждый разработчик, а многие даже применяют их у себя, то о четвёртой знают только хардкорные джависты
это только у меня с точностью до наоборот? RxJava правда еще немного читал, но Dagger 2, Retrolambda — ни разу нигде не встречал.
archinamon
06.07.2016 16:28+1Эти библиотеки распространены, преимущественно, в мобильной разработке :) Хотя даггер, вроде как, и в обычной джаве тоже применяется активно.
Scf
Мы применяли AOP/AspectJ одно время. Потом отказались, т.к. он значительно увеличивает время компиляции, ну и поддержка AOP в эклипсе была реализована, скажем так, не очень. Оказалось, что писать логгирование и обработку ошибок в нужных местах вручную не так уж и сложно :-)
Интересно использование AOP вместо самодельных правил для checkstyle… Выглядит, во всяком случае, короче.
archinamon
На данный момент плагин поддержки AspectJ выжимает менее 10 секунд на полную компиляцию и внедрение инъекций в java-код довольно большого проекта. Мне кажется, проблем с этим быть не должно :)