ByteWeaver в Open Source: инструментирование байт-кода во имя великого блага
Про байт‑код написано уже немало. Он везде, и никого этим не удивить: его генерирует компилятор, переупаковывает система сборки, «портит» обфускатор и изредка читают программисты. Естественно, для работы с байт‑кодом есть немало инструментов, которые используются в разных областях и на разных платформах. Среди них и ByteWeaver — инструмент для патчинга байт‑кода во время сборки, который может быть полезен разработчикам под Android.
Меня зовут Александр Асанов. Я Android‑разработчик в OK, Tracer, ByteWeaver. В этой статье я разберу, что такое байт‑код, как и зачем с ним работать, расскажу о ByteWeaver и покажу примеры работы с байт‑кодом.
Что такое байт-код
Байт-код — промежуточное представление Java-кода, которое выполняется виртуальной машиной Java (JVM). При компиляции программы компилятор Java преобразует её в байт-код, представляющий собой набор инструкций, которые виртуальная машина может понять и выполнить. Этот принцип справедлив не только для Java, но и для многих других современных систем, в том числе LLVM.
Алгоритм появления и использования байт-кода следующий:
Разработчик пишет исходный код.
Исходный код Java компилируется в байт-код. В зависимости от языка и платформы это могут быть, например, файлы типа .class, .dex, .ll.
Байт-код преобразуется в машинный код. Стратегии тут могут быть разными: интерпретация байт-кода, just in time, ahead of time.
В дальнейшем мы сосредоточимся на разработке под Android, а значит, нам интересны только байткод JVM и Dalvik.
Байт-код не так сложен, как машинный код. Вот, для понимания, как выглядит «было и стало» на примере кода небольшого класса:
При этом в «было и стало» отчасти сохраняется соответствие — например, тоже есть заголовок функции, вызовы и инструкции.
Отдельно от всего существует обработка исключений — она выполнена не в виде инструкций, а в виде метаданных метода, о перехвате исключения и передачи его по нужной метке позаботится виртуальная машина.
Схожая ситуация и при работе с Dalvik — средой для выполнения компонентов операционной системы Android и пользовательских приложений. Вместе с тем, поскольку Dalvik отличается от Java, байт‑код тоже отличается, но незначительно — в нём всё так же можно увидеть функции, вызовы и инструкции.
На самом деле того, что мы уже видели, достаточно для работы с ByteWeaver, потому что он как раз [SPOILER ALERT] и позволяет вставлять вызовы в начало и конец метода или заменять одни вызовы методов на другие.
Зачем править байт-код
Есть много сценариев, когда манипулирование байт‑кодом будет полезно. Так, патчинг байт‑кода может понадобиться для:
добавления журналов — например, чтобы в последующем передавать их в logcat, tracer или другие системы сбора журналов;
добавления трассировок — например, systrace и через него в тот же tracer;
добавления другого мониторинга;
поиска, а иногда и правки багов;
определения живого и мёртвого кода;
«открытия чёрного ящика», происходящего «под капотом» приложения на уровне кода.
Манипулирование байт‑кодом может понадобиться в разных сценариях.
Оптимизация производительности. Инструменты профилирования и оптимизации производительности часто модифицируют байт‑код, чтобы внедрить код для мониторинга «горячих» участков кода.
Тестирование и отладка. Инструменты могут динамически вставлять средства журналирования и отладки во время выполнения программы.
Аспектно‑ориентированное программирование. Патчинг байт‑кода позволяет реализовать сквозные задачи, такие как журналирование, управление транзакциями и проверка безопасности.
Генерация кода во время выполнения. Отдельные инструменты умеют создавать новые классы во время выполнения программы на основе динамических условий. Это даёт больше гибкости и уменьшает количество дублируемого кода.
Но для патчинга, естественно, нужны соответствующие инструменты.
Инструменты для работы с байт-кодом
Для работы с байт‑кодом есть несколько решений:
ASM — библиотека, которая предоставляет API для манипуляции существующим байт‑кодом и/или генерации нового.
Javassist — фреймворк, который фактически скрывает в себе операции манипулирования байт‑кодом. Разработчик пишет код, который средствами библиотеки транслируется в байт‑код и внедряется в существующие классы.
AspectJ — расширение Java с собственным синтаксисом, которое предназначено для расширения возможностей среды выполнения Java с помощью концепций аспектно‑ориентированного программирования. AspectJ имеет компилятор, который может работать как во время компиляции, так и во время выполнения.
Нюанс в том, что для задач большого проекта вроде ОК каждый из этих инструментов в «чистом виде» не особо подходит:
ASM — низкоуровневое решение;
Javassist — не может работать в Android runtime;
AspectJ — мощный и многофункциональный инструмент, но он может замедлять сборку и требует большого опыта.
ByteWeaver и история его становления
Понимая нюансы и недостатки существующих инструментов для наших сценариев использования на основе библиотеки ASM, мы разработали своё решение, которое в дальнейшем назвали ByteWeaver. Но в текущем виде он появился не сразу, этому предшествовала целая череда событий.
В 1997 года появился первый байт‑код на Java.
В 2003 году появился ASM, который фактически стал стандартом индустрии. Даже сейчас большинство манипуляций с байт‑кодом во многом базируются именно на ASM. Принцип работы ASM прост: на вход — байт‑код, на выход — байт‑код и паттерн visitor, который отлично подходит для преобразования данных. Это позволяет работать с байт‑кодом как с данными.
В 2016 году мы в ОК начали активно прорабатывать и улучшать механизмы работы с журналированием. Ставили перед собой цель прийти к ситуации, при которой, например, в ответ на простейшую команду
log x
можно будет узнать, чему равен X и в каких единицах измерения. Идея была отличной и жизнеспособной, у нас даже появился проработанный прототип. Но из‑за некоторых внутренних обстоятельств от идеи пришлось временно отказаться.В 2018 году у нас появились первые серьёзные наработки в рамках проекта Tracer. В том числе мы реализовали AutoTransform, написали основное ядро преобразований, инструментировали методы жизненного цикла. В решении было много прописанных в коде моментов, но основа уже была заложена.
В 2022 году проект отделили от Tracer и переработали в ByteWeaver. В обновлённой реализации появился новый язык конфигурации, отдельный publishing, новые сценарии использования и не только.
В 2023 году ядро ByteWeaver перевели на новый AGP
transformClassesWith
, и также появились новые сценарии.Сейчас (в 2024 году) доработка инструмента продолжается, поэтому сценариев работы с ним становится ещё больше.
Какой байт-код мы можем править
Возможность правки кода зависит от того, в какой момент выполняется патчинг. Файлы .java и .kt с исходным кодом переводятся в формат .class ещё на самых ранних этапах с помощью компилятора. На этом же этапе gradle добавляет к этим файлам .class зависимости. Таким образом, на вход ByteWeaver попадают файлы уже с зависимостями. То есть, ByteWeaver тоже появляется на ранних этапах сборки и преобразовывает классы в .class.
Далее по циклу динамической сборки обработку выполняет ряд механизмов:
Proguard (R8);
Dex (R8) (получаются файлы .dex);
AGP, который упаковывает файлы в архив и добавляет ресурсы.
Часть цикла выполняется на стороне маркета приложений (преобразование .aab в .apk), но в рамках обзора работы с байт‑кодом её можно опустить.
Если представить процесс сборки статически, то dexclassloader (загрузчик классов в Android, который загружает классы из файлов .jar и .apk, содержащих запись classes.dex
) работает с тремя группами сущностей:
классами приложения (модуль приложения, библиотечные модули);
классами из зависимостей (прямые, транзитивные);
системными классами.
При этом системные классы не относятся к .apk приложения. Соответственно, инструментированы могут быть только классы приложения и классы из зависимостей. Важно, что мы не влияем на ресурсы приложения, а работаем только с вызовами функций.
Здесь надо отметить особое положение константных значений и inline-функций — они «встраиваются» компилятором, и патчить надо именно места, куда они встраиваются.
Как можно править байт-код: пример работы с ByteWeaver
ByteWeaver реализован в виде плагина для Gradle. Чтобы работать с ним, надо выполнить некоторые операции.
Подключаем плагин, выполняя следующую команду:
Конфигурируем плагин. Указываем, какие варианты сборки есть, какие инструменты будут подключены:
При этом надо указать, какое именно будет инструментирование в
debug
,profile
иrelease
. Надо отметить, что файлы конфигурации (и все последующие команды) пишутся на языке ByteWeaver.Определяем классы, на которые будем воздействовать. Например:
любой класс, который расширяет
view
;любой класс, который реализует
runnable
;любой класс из пакета ru.ok.android с помеченными аннотациями.
При этом мы также можем использовать
import
, что позволяет отказаться от дублирования.
Вставляем код в начало/конец. Например, во все методы, аннотированные
AutoTraceCompat
, в любом классе мы в начале ставим вызовTraceCompat.beginTraceSection(trace)
, а в конце —TraceCompat.endSection
.
Заменяем вызовы. Например, в методе
subscribeActual
классаSingleFromCallable
вызовыcallable.call()
, которые возвращаютObject
, заменим на вызовыRxNpeChecker.checkCallableCall(self)
.
Примеры реальных преобразований в проде
Мы активно используем патчинг байт-кода у себя в production-среде. Для наглядности разберём несколько примеров.
«Поимка» тостов
Один из вариантов использования ByteWeaver — отлавливание тостов. Тосты (Toast) — системные уведомления, носящие исключительно информирующий характер и не требующие каких‑либо действий от пользователя. Один из распространённых примеров тост‑уведомлений — уведомление о получении прав разработчика.
Чтобы отлавливать тосты, в любом классе и в любом методе вызовы Toast.show()
меняем на ToastWatcher.show(self)
.
После этого мы пишем ToastWatcher
с методом show
. То есть в итоге мы не влияем на основную функциональность, но дополнительно подвешиваем listener(toast)
. Важно, что это статический метод (@JvmStatic
), как и все методы, которые мы планируем добавлять.
Журналирование уведомлений
Здесь речь не о пушах, а о том, что разные библиотеки могут показывать уведомления. Мы хотим отслеживать всех, кто пытается что‑то отображать в шторке уведомлений Android — с этой задачей мы столкнулись, когда нотификаций в нашем приложении стало слишком много и это начало негативно влиять на пользовательский опыт.
Чтобы отловить все нотификации, мы сделали следующее. В любом классе вызовы NotificationManager.notify
мы заменили на NotificationsLogger
.
Далее NotificationsLogger
всё переправляет в LogNotificationsUtil
, благодаря чему журналирует функциональность, не влияя на неё.
Затем LogNotificationsUtil
, в зависимости от флажка, отслеживает и собирает всю информацию об уведомлении и его отправителе.
Поиск багов
Не так давно мы столкнулись со следующей ситуацией — в Tracer нет ни одной строчки нашего кода, но отображается NullPointerException
. Кто-то вернул в RxJava 3 null, на что RxJava 3 выдала уведомление «The callable returned a null value».
При этом абсолютно не понятно, какой callable когда и почему вернул null — нет никакой информации.
Изначально мы планировали форкать RxJava 3, но после решили воспользоваться ByteWeaver. При изучении кода мы увидели, что сообщение «The callable returned a null value» просто прописано в классе SingleFromCallable
.
Чтобы сделать это сообщение полезным и интерпретируемым, мы решили обогатить его, добавив дополнительную информацию. Для этого заменили вызовы Callable.call
на RxNpeChecker
.
RxNpeChecker
, в свою очередь, делает вызов Callable
, но с другим Exception
, в котором значительно больше полезной информации.
Благодаря этому мы смогли идентифицировать, что null value вернул callable l90.b
.
Далее уже можно локализовать источник ошибки без ByteWeaver. Для этого мы смотрим, кто такой l90.b
, и видим, что это некая ExternalSyntheticLambda1
в RxApiClient
. А в RxApiClient
видно, что null возвращает один из методов API.
Чтобы найти конкретный метод, используя код на Java, дополнительно журналируем и начинаем добавлять больше информации об API-шном методе.
В итоге после простых манипуляций мы смогли точно локализовать источник наших «проблем»:
Parsed api value was null. Request: UserInfoRequest{uids=780917803396}, method: users.getInfo, parser: b80.t@43beec0
Это позволило точечно поправить баги без лишних рисков и глобальных переработок.
Таким образом, мы:
Поймали
RxApiClient
, методusers.getInfo
и сразу три метода из группы Friends:friends.getOnlineV2
,friends.getOutgoingFriendRequests
,friends.invite
(все по разным причинам возвращали null). Всё починили и обложили проверками.Поймали и поправили класс
LocalPhotoEditorFragment
.
При этом нам даже не потребовалось форкать RxJava — в этом сильно помог ByteWeaver.
Обогащение SysTrace для Tracer
Когда мы начали собирать трассировки, то увидели, что в них недостаточно данных и не все из них мы можем добавить вручную (да и не слишком это рационально). Поэтому нам требовалась автоматизация.
Для этого мы сделали следующее. Во всех классах методы, аннотированные @AutoTraceCompat
, будут покрыты трассировками. Если кратко — мы размечаем начало и конец вызова, благодаря чему потом можем смотреть, какие методы вызывались и как работали.
Также покрываем трассировками методы жизненного цикла во всех классах Activity
. Аналогично покрываем методы жизненного цикла во всех классах Fragment
. Помимо этого, покрываем трассировками классы:
Service
;ContentProvider
;View
;Handler
;Handler.Callback
;JobIntentService
;Runnable
.
Также помечаем сигнатуры методов inject(Activity)
и inject(Fragment)
. Это нужно для dagger. Для всех методов в начало мы добавляем TraceCompat.beginTraceSection(trace)
, а в конец — TraceCompat.endSection()
.
Такой патчинг существенно расширяет массив собираемой информации и делает её более полной/интерпретируемой. Для сравнения, достаточно посмотреть на Java Flame Graphs до и после обогащения.
Собираемой информации, как и подробностей, стало существенно больше, причём она наглядная, интерпретируемая и детальная. Это упрощает отслеживание событий и последующую правку возможных багов. Примечательно, что все эти подробности добавлены с помощью ByteWeaver.
Что мы сделали и что делать не стоит
Итого мы:
добавляли логи (logcat, tracer);
добавляли трассировки (systrace, tracer);
приоткрыли чёрный ящик для тестов;
искали и находили баги.
Внедрение изменений и использование ByteWeaver фактически позволило работать с кодом прозрачно и удобно, быстро выявлять события и локализовать источники ошибок. Важно, что «цена» таких нововведений для нас оказалась незначительной — время сборки приложения ОК выросло всего на 5 секунд, что в масштабе нашего продукта вполне допустимо.
При этом есть вещи, которые мы делать не стали и другим не советуем:
Правка багов. С ByteWeaver не надо править баги. Это неочевидно, порождает нежелательные артефакты в stacktrace и при отладке, а также увеличивает риски bus factor.
Генерирование «продуктового» кода. ByteWeaver лучше использовать для работы с «побочным кодом», причём важно не препятствовать его выполнению. Код самого продукта и продуктовую логику затрагивать не стоит — это чревато рисками и ненужными трудностями.
Планы на будущее
Мы не останавливаемся на достигнутом и планируем активно развивать работу с байт-кодом и ByteWeaver.
Сейчас вставка вызова в начало метода позволяет только принимать трассировки, но не позволяет работать с аргументами метода. Мы хотим прийти к ситуации, при которой со вставкой вызова в начало метода будем получать аргументы и даже сможем на них влиять (read-only/read-write).
Также мы хотим, чтобы вызовы в конце метода могли получать результат или exception. В идеале, также хотим получить возможность влиять на эти результаты.
Наряду с этим мы хотим реализовать возможность замены целиком тела методов (с аргументами и результатами), то есть получить возможность использования replace body.
Для поиска методов, которых быть не должно, мы планируем добавить stopship. Так мы хотим ограничить работу с функциями, которые содержат баг или удалены, но продолжают где-то ещё вызываться.
Также хотим добавить немного декомпиляции. Например, чтобы в ответ на
log(x)
получатьlog("x = $x")
.
Выводы на основе нашего опыта
Пройдя довольно долгий путь работы с Android-приложениями, мы смогли сделать несколько ключевых выводов:
Иногда уровня исходного кода недостаточно, чтобы понять, что именно работает не так, почему и с какого момента. Нередко надо «копнуть поглубже».
Знание байт-кода необязательно, но оно помогает искать и исправлять баги, подключать дополнительный мониторинг и реализовывать другие сценарии без необходимости править исходный код.
ByteWeaver — удобный и функциональный инструмент для патчинга байт-кода. Его можно использовать в разных сценариях, в том числе для сбора статистики, поиска и устранения багов, решения специфических задач. Важно, что ByteWeaver уже доступен в Open Source — можете протестировать инструмент и начать работу с ним в своих проектах прямо сейчас.
И да, если вы ещё не работаете с байт-кодом — самое время начать погружение в тему. Это может оказаться сложно, но точно будет увлекательно и полезно.