ByteWeaver в Open Source: инструментирование байт-кода Java во имя великого блага

от автора

Про байт‑код написано уже немало. Он везде, и никого этим не удивить: его генерирует компилятор, переупаковывает система сборки, «портит» обфускатор и изредка читают программисты. Естественно, для работы с байт‑кодом есть немало инструментов, которые используются в разных областях и на разных платформах. Среди них и 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.

Байт-код не так сложен, как машинный код. Вот, для понимания, как выглядит «было и стало» на примере кода небольшого класса:

class Example {    fun execute(runnable: Runnable): Int {        try {            println("Going to run")            runnable.run()        } catch (ex: Throwable) {            println("What a Terrible Failure")        }        return 0    } }

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

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

Схожая ситуация и при работе с 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. Чтобы работать с ним, надо выполнить некоторые операции.

  • Подключаем плагин, выполняя следующую команду:

plugins {     id 'ru.ok.byteweaver' }
  • Конфигурируем плагин. Указываем, какие варианты сборки есть, какие инструменты будут подключены:

byteweaver {     debug {         srcFiles += 'byteweaver/notification-log.conf'         srcFiles += 'byteweaver/proxy-toast-for-tests.conf'         srcFiles += 'byteweaver/rx-npe.conf'     }     profile {         srcFiles += 'byteweaver/auto-trace.conf'     }     release {         srcFiles += 'byteweaver/notification-log.conf'         srcFiles += 'byteweaver/auto-trace.conf'         srcFiles += 'byteweaver/rx-npe.conf'     } }

При этом надо указать, какое именно будет инструментирование в debug, profile и release. Надо отметить, что файлы конфигурации (и все последующие команды) пишутся на языке ByteWeaver.

  • Определяем классы, на которые будем воздействовать. Например:

    • любой класс, который расширяет view;

    • любой класс, который реализует runnable;

    • любой класс из пакета ru.ok.android с помеченными аннотациями. 

class io.reactivex.rxjava3.internal.operators.single.SingleFromCallable { class * extends android.view.View {  class * extends java.lang.Runnable {  class * {  @SomeAnnotation class ru.ok.android.* {

При этом мы также можем использовать import, что позволяет отказаться от дублирования.

import ru.ok.android.app.NotificationsLogger; import java.lang.String;
  • Определяем методы, которые будем инструментировать. Например:

    • все наследники Activity, метод onCreate;

    • все Runnable, метод Run;

    • все классы, все методы и так далее.

class * extends android.app.Activity {    void onCreate(android.os.Bundle) {    class * extends java.lang.Runnable {    void run() {    class * {    @ru.ok.android.commons.os.AutoTraceCompat    * *(***) {    class * {    * *(***) {
  • Вставляем код в начало/конец. Например, во все методы, аннотированные AutoTraceCompat, в любом классе мы в начале ставим вызов TraceCompat.beginTraceSection(trace), а в конце — TraceCompat.endSection.

class * {    @ru.ok.android.commons.os.AutoTraceCompat    * *(***) {        before void TraceCompat.beginTraceSection(trace);        after void TraceCompat.endSection();    } }
  • Для примера, «до и после добавления кода» будет выглядеть так:

public class Main {    @AutoTraceCompat    public static void main(String[] args) {        System.out.println("Hellow World");    } }

После:

public class Main {    @AutoTraceCompat    public static void main(String[] args) {        TraceCompat.beginTraceSection("Main.main(String[])");        System.out.println("Hellow World");        TraceCompat.endSection();    } }
  • Заменяем вызовы. Например, в методе subscribeActual класса SingleFromCallable вызовы callable.call(), которые возвращают Object, заменим на вызовы RxNpeChecker.checkCallableCall(self).

Примеры реальных преобразований в проде

Мы активно используем патчинг байт-кода у себя в production-среде. Для наглядности разберём несколько примеров.

«Поимка» тостов

Один из вариантов использования ByteWeaver — отлавливание тостов. Тосты (Toast) — системные уведомления, носящие исключительно информирующий характер и не требующие каких‑либо действий от пользователя. Один из распространённых примеров тост‑уведомлений — уведомление о получении прав разработчика.

Чтобы отлавливать тосты, в любом классе и в любом методе вызовы Toast.show() меняем на ToastWatcher.show(self).

class * {    * *(***) {        void Toast.show() {            replace void ToastWatcher.show(self);        }    } }
object ToastWatcher {    var listener: (tpast: Toast) -> Unit = {}      fun show(toast: Toast) {        listener(toast)        toast.show()    } }

После этого мы пишем ToastWatcher с методом show. То есть в итоге мы не влияем на основную функциональность, но дополнительно подвешиваем listener(toast). Важно, что это статический метод (@JvmStatic), как и все методы, которые мы планируем добавлять.

Логирование нотификаций

Здесь речь не о пушах, а о том, что разные библиотеки могут показывать уведомления. Мы хотим отслеживать всех, кто пытается что‑то отображать в шторке уведомлений Android — с этой задачей мы столкнулись, когда нотификаций в нашем приложении стало слишком много и это начало негативно влиять на пользовательский опыт.

Чтобы отловить все нотификации, мы сделали следующее. В любом классе вызовы NotificationManager.notify мы заменили на NotificationsLogger.

class * {    * *(***) {        void NotificationManager.notify(int, Notification) {            replace void NotificationsLogger.logNotify(self, 0, 1);        }    } }

Далее NotificationsLogger всё переправляет в LogNotificationsUtil, благодаря чему журналирует функциональность, не влияя на неё.

public class NotificationsLogger {      @KeepName    public static void logNotify(NotificationManager manager, String tag, int id, Notification notification) {        LogNotificationsUtil.logNotification(notification, trace());        manager.notify(tag, id, notification);    } }

Затем LogNotificationsUtil, в зависимости от флажка, отслеживает и собирает всю информацию об уведомлении и его отправителе. 

public final class LogNotificationsUtil {    public static void logNotification(Notification notification, String codeSrc) {        if (!Env.get(PushEnv.class).PUSH_LOG_NOTIFICATIONS_ENABLED()) {            return;        }        long ltime = System.currentTimeMillis();        final OneLogItem.Builder builder = OneLogItem.builder()                .setCollector(Collectors.OK_MOBILE_APPS_OPERATIONS)                .setType(Type.OPERATIONS_SUCCESS)                .setDatum(1, codeSrc)                .setOperation("notification_notify")                .setCustom("ltime", ltime);          if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {            builder.setDatum(0, notification.getChannelId());        }        builder.build().log();    } }

Поиск багов

Не так давно мы столкнулись со следующей ситуацией — в 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.

public final class SingleFromCallable<T> extends Single<T> {      final Callable<? extends T> callable;      public SingleFromCallable(Callable<? extends T> callable) {        this.callable = callable;    }      @Override    protected void subscribeActual(SingleObserver<? super T> observer) {        try {            value = Objects.requireNonNull(callable.call(), "The callable returned a null value");        } catch (Throwable ex) {            Exceptions.throwIfFatal(ex);            RxJavaPlugins.onError(ex);            return;        }    } }

Чтобы сделать это сообщение полезным и интерпретируемым, мы решили обогатить его, добавив дополнительную информацию. Для этого заменили вызовы Callable.call на RxNpeChecker.

class io.reactivex.rxjava3.internal.operators.single.SingleFromCallable {    * subscribeActual(***) {        java.lang.Object java.util.concurrent.Callable.call() {            replace java.lang.Object ru.ok.android.utils.RxNpeChecker.checkCallableCall(self);        }    } }

RxNpeChecker, в свою очередь, делает вызов Callable, но с другим Exception, в котором значительно больше полезной информации.

public class RxNpeChecker {    @SuppressWarnings("unused") // used from generated bytecode    public static Object checkCallableCall(Callable callable) throws Exception {        final Object result = callable.call();        if (result == null) {            throw new NullPointerException("The callable returned a null value: " + callable);        }        return result;    } }

Благодаря этому мы смогли идентифицировать, что null value вернул callable l90.b

Далее уже можно локализовать источник ошибки без ByteWeaver. Для этого мы смотрим, кто такой l90.b, и видим, что это некая ExternalSyntheticLambda1 в RxApiClient. А в RxApiClient видно, что null возвращает один из методов API.

@Singleton public final class RxApiClient {    private final ApiClient delegate;    private final Scheduler scheduler;      @Inject    public RxApiClient(@NonNull ApiClient delegate) {        this.delegate = delegate;        this.scheduler = Schedulers.io();    }      @NonNull    public <T> Single<T> execute(@NonNull ApiExecutableRequest<T> request) {        return Single.fromCallable(() -> delegate.execute(request))                .subscribeOn(scheduler);    } }

Чтобы найти конкретный метод, используя код на Java, дополнительно журналируем и начинаем добавлять больше информации об API-шном методе. 

@Singleton public final class RxApiClient {    private final ApiClient delegate;    private final Scheduler scheduler;      @Inject    public RxApiClient(@NonNull ApiClient delegate) {        this.delegate = delegate;        this.scheduler = Schedulers.io();    }      @NonNull    public <T> Single<T> execute(@NonNull ApiExecutableRequest<T> request) {        return Single.fromCallable(() -> delegate.execute(request))                .subscribeOn(scheduler);    }      @NonNull    private <T> T executeNonNull(@NonNull ApiExecutableRequest<T> request) throws IOException, ApiException {        final T result = delegate.execute(request);        if (result == null) {            final String msg = "Parsed api value was null."                    + " Request: " + request                    + ", method: " + ApiRequests.extractLogTag(request)                    + ", parser: " + request.getOkParser();            throw new NullPointerException(msg);        }        return result;    } }

В итоге после простых манипуляций мы смогли точно локализовать источник наших «проблем»:

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 — можете протестировать инструмент и начать работу с ним в своих проектах прямо сейчас. 

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


ссылка на оригинал статьи https://habr.com/ru/articles/845872/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *