Как стать автором
Обновить

Упарываемся по максимуму: от ORM до анализа байткода

Время на прочтение36 мин
Количество просмотров5.3K

Как известно, настоящий программист в своей жизни должен сделать 3 вещи: создать свой язык программирования, написать свою операционную систему и сделать свой ORM. И если язык я написал уже давно (возможно, расскажу как-нибудь в другой раз), а ОС еще ждет впереди, то про ORM я хочу поведать прямо сейчас. А если точнее, то даже не про сам ORM, а про реализацию одной маленькой, локальной и, как изначально казалось, совсем простой фичи.


Мы с вами вместе пройдем весь путь от радости нахождения простого решения до горечи осознания его хрупкости и некорректности. От использования исключительно публичного API до грязных хаков. От "почти без рефлекшена", до "по колено в интерпретаторе байт-кода".


Кому интересно как анализировать байт-код, какие сложности это в себе таит и какой потрясающий результат можно получить в итоге, добро пожаловать под кат.


Содержание


1 — С чего все началось.
2-4 — На пути к байткоду.
5 — Кто такой байткод.
6 — Сам анализ. Именно ради этой главы все и затевалось и именно в ней самые кишочки.
7 — Что еще можно допилить. Мечты, мечты…
Послесловие — Послесловие.


UPD: Сразу после публикации были потеряны части 6-8 (ради которых все и затевалось). Пофиксил.



Часть первая. Проблема


Представим, что у нас есть простенькая схема. Существует клиент, у него есть несколько счетов. Один из них является дефолтным. Так же у клиента может быть несколько сим-карт и у каждой симки счет может быть явно задан, а может использоваться дефолтный клиентский.



Вот как эта модель описывается у нас в коде (опуская геттеры/сеттеры/конструкторы/...).


@JdbcEntity(table = "CLIENT")
public class Client {

    @JdbcId
    private Long id;

    @JdbcColumn
    private String name;

    @JdbcJoinedObject(localColumn = "DEFAULTACCOUNT")
    private Account defaultAccount;
}

@JdbcEntity(table = "ACCOUNT")
public class Account {

    @JdbcId
    private Long id;

    @JdbcColumn
    private Long balance;

    @JdbcJoinedObject(localColumn = "CLIENT")
    private Client client;
}

@JdbcEntity(table = "CARD")
public class Card {

    @JdbcId
    private Long id;

    @JdbcColumn
    private String msisdn;

    @JdbcJoinedObject(localColumn = "ACCOUNT")
    private Account account;

    @JdbcJoinedObject(localColumn = "CLIENT")
    private Client client;
}

В самом ORM у нас наложено требование на отсутствие проксей (мы должны создать инстанс именно этого класса) и единственный запрос. Соответственно, вот какой sql отправляется в базу при попытке получить карту.


select CARD.id           id,
       CARD.msisdn       msisdn,
       ACCOUNT_2.id      ACCOUNT_2_id,
       ACCOUNT_2.balance ACCOUNT_2_balance,
       CLIENT_3.id       CLIENT_3_id,
       CLIENT_3.name     CLIENT_3_name,
       CLIENT_1.id       CLIENT_1_id,
       CLIENT_1.name     CLIENT_1_name,
       ACCOUNT_4.id      ACCOUNT_4_id,
       ACCOUNT_4.balance ACCOUNT_4_balance
from CARD
  left outer join CLIENT CLIENT_1 on CARD.CLIENT = CLIENT_1.id
  left outer join ACCOUNT ACCOUNT_2 on CARD.ACCOUNT = ACCOUNT_2.id
  left outer join CLIENT CLIENT_3 on ACCOUNT_2.CLIENT = CLIENT_3.id
  left outer join ACCOUNT ACCOUNT_4 on CLIENT_1.DEFAULTACCOUNT = ACCOUNT_4.id;

Уупс. Клиент и счет задублировались. Правда, если подумать, то это вполне объяснимо — фреймворк ведь не знает что клиент карты и клиент счета карты — это один и тот же клиент. А запрос надо сгенерить статически и только один (помните ограничение в единственность запроса?).


Кстати, ровно по этой же причине здесь вообще нет полей Card.account.client.defaultAccount и Card.client.defaultAccount.client. Только мы знаем что client и client.defaultAccount.client всегда совпадают. А фреймворк не знает, для него это произвольная ссылка. И что делать в таких случаях не очень понятно. Я знаю 3 варианта:


  1. Явно описывать инварианты в аннотациях.
  2. Делать рекурсивные запросы (with recursive / connect by).
  3. Забить.

Угадайте, какой вариант выбрали мы? Правильно. В итоге, все рекурсивные филды сейчас вообще не заполняются и там всегда null.


Но если присмотреться, то за дублированием можно увидеть второю проблему, и она гораздо хуже. Мы ведь что хотели? Номер и баланс карты. А что получили? 4 джойна и 10 колонок. И эта штука растет экспоненциально! Ну т.е. мы реально имеем ситуацию, когда сначала ради красоты и целостности полностью описываем модель на аннотациях, а потом, ради 5 полей, идет запрос на 15 джойнов и 150 колонок. И вот в этот момент становится реально страшно.



Часть вторая. Рабочее, но неудобное решение


Сразу же напрашивается простое решение. Надо тащить только те колонки, которые будут использоваться! Легко сказать. Самый очевидный вариант (написать селект руками) мы отбросим сразу. Ну не затем же мы описывали модель, чтобы не использовать ее. Довольно давно был сделан специальный метод — partialGet. Он, в отличии от простого get, принимает List<String> — имена полей, которые надо заполнить. Для этого сначала надо прописать алиасы таблицам


@JdbcJoinedObject(localColumn = "ACCOUNT", sqlTableAlias = "a")
private Account account;

@JdbcJoinedObject(localColumn = "CLIENT", sqlTableAlias = "c")
private Client client;

А потом наслаждаться результатом.


List<String> requiredColumns = asList("msisdn", "c_a_balance", "a_balance");
String query = cardMapper.getSelectSQL(requiredColumns, DatabaseType.ORACLE);
System.out.println(query);

select CARD.msisdn msisdn,
       c_a.balance c_a_balance,
       a.balance   a_balance
from CARD
  left outer join ACCOUNT a on CARD.ACCOUNT = a.id
  left outer join CLIENT c on CARD.CLIENT = c.id
  left outer join ACCOUNT c_a on c.DEFAULTACCOUNT = c_a.id;

И вроде бы все хорошо, но, на самом деле, нет. Вот как оно будет использовано в реальном коде.


Card card = cardDAO.partialGet(cardId, "msisdn", "c_a_balance", "a_balance");
...
...
...
много много кода
...
...
...
long clientId = card.getClient().getId();//ой, NPE. А что, id клиента не был заполнен?!

И получается что пользоваться partialGet сейчас можно только если расстояние между ним и использованием результата только несколько строчек. А вот если результат уходит далеко или, не дай бог, передается внутрь какого-то метода, то понять потом какие поля у него заполнены, а какие нет, уже крайне сложно. Более того, если где-то случился NPE, то еще надо понять действительно ли это из базы null вернулся, или же мы просто данный филд и не заполняли. В общем, очень ненадежно.


Можно, конечно, просто написать еще один объект со своим маппингом специально под запрос, или же вообще полностью руками весь селект и собрать его в какой-нибудь Tuple. Собственно, реально сейчас в большинстве мест именно так и делаем. Но все-таки хотелось бы и не писать селекты руками, и не дублировать маппинг.



Часть третья. Удобное, но нерабочее решение.


Если еще немного подумать, то довольно быстро в голову приходит ответ — надо использовать интерфейсы. Тогда достаточно просто объявить


public interface MsisdnAndBalance {
    String getMsisdn();
    long getBalance();
}

И использовать


MsisdnAndBalance card = cardDAO.partialGet(cardId, ...);

И все. Ничего лишнего больше не вызвать. Более того, с переходом на котлин/десятку/ломбок даже от этого страшного типа можно будет избавиться. Но здесь все еще опущен самый главный момент. Какие аргументы нужно передавать в partialGet? Стринги, как раньше, уже не хочется, ибо слишком велик риск ошибиться и написать не те филды, которые нужны. А хочется чтобы можно было как-нибудь так


MsisdnAndBalance card = cardDAO.partialGet(cardId, MsisdnAndBalance.class);

Или еще лучше на котлине через reified generics


val card = cardDAO.paritalGet<MsisdnAndBalance>(cardId)

Эхх, ляпота. Собственно, весь дальший рассказ — это именно реализация данного варианта.



Часть четвертая. На пути к байткоду


Ключевая проблема заключается в том, что из интерфейса идут методы, а аннотации стоят над филдами. И нам нужно по методам найти эти самые филды. Первая и самая очевидная мысль — использовать стандартную Java Bean конвенцию. И для тривиальных свойств это даже работает. Но получается очень нестабильно. Например, стоит переименовать метод в интерфейсе (через идеевский рефакторинг), как все моментально разваливается. Идея достаточно умная чтобы переименовать методы в классах-реализациях, но недостаточно чтобы понять что это был геттер и надо переименовать еще и само поле. А еще подобное решение приводит к дублированию полей. Например, если мне в моем интерфейсе нужен метод getClientId(), то я не могу его реализовать единственно правильным способом


public class Client implements HasClientId {
    private Long id;

    @Override 
    public Long getClientId() {
        return id;
    }
}

public class Card implements HasClientId {
    private Client client;

    @Override 
    public Long getClientId() {
        return client.getId();
    }
}

А вынужден я дублировать поля. И в Client тащить и id, и clientId, а в карте рядом с клиентом иметь явно clientId. И еще следить чтобы все это не разъехалось. Более того, хочется чтобы работали еще и геттеры с нетривиальной логикой, например


public class Card implements HasBalance {
    private Account account;
    private Client client;

    public long getBalance() {
        if (account != null)
            return account.getBalance();
        else
            return client.getDefaultAccount().getBalance();
    }
}

Так что вариант с поисками по именам отпадает, нужно что-то более хитрое.


Следующий вариант был совсем безумный и прожил в моей голове недолго, но для полноты истории все же опишу и его. На этапе парсинга мы можем создать пустую сущность и просто по очереди писать какие-нибудь значения в филды, а после этого дергать геттеры и смотреть изменилось что они возвращают или нет. Так мы увидим что от записи в поле name значение getClientId не меняется, а вот от записи id — меняется. Более того, здесь автоматически поддерживается ситуация когда геттер и филд разных типов (типа isActive() = i_active != 0). Но здесь есть как минимум три серьезные проблемы (может и больше, но дальше уже не думал).


  1. Очевидным требованием к сущности при таком алгоритме является возврат "одного и того же" значения из геттера если "соответствующий" филд не менялся. "Одного и того же" — с точки зрения выбранного нами оператора сравнения. == им, очевидно, быть не может (иначе перестанет работать какой-нибудь getAsInt() = Integer.parseInt(strField)). Остается equals. Значит, если геттер возвращает какую-нибудь пользовательскую сущность, генерируемую по филдам при каждом вызове, то у нее обязан быть переопределен equals.
  2. Сжимающие отображения. Как в примере с int -> boolean выше. Если мы будем проверять на значениях 0 и 1, то изменение увидим. Но вот если на 40 и 42, то оба раза получим true.
  3. Могут быть сложные конвертеры в геттерах, рассчитывающие на определенные инварианты в филдах (например, спец. формат строки). А на наших сгенеренных данных они будут кидать исключения.

Так что в целом вариант тоже не рабочий.


В процессе обсуждения всего этого дела я, изначально в шутку, произнес фразу "ну нафиг, проще в байткод посмотреть, там все написано". На тот момент я даже не продполагал что эта идея поглотит меня, и как далеко все зайдет.



Часть пятая. Что такое байткод и как он работает


new #4, dup, invokespecial #5, areturn
Если вы понимаете, что здесь написано и что данный код делает, то можете сразу переходить к следующей части.


Disclaimer 1. К сожалению, для понимания дальнейшего рассказа требуется хотя бы базовое понимание того, как выглядит джавовый байткод, так что напишу про это пару абзацев. Ни в коей мере не претендую на полноту.


Disclaimer 2. Речь пойдет исключительно про тела методов. Ни про constant pool, ни про структуру класса в целом, ни даже про сами декларации методов, я не скажу ни слова.


Главное, что нужно понимать про байткод — это ассемблер к стековой виртуальной машине Java. Это значит что аргументы для инструкций берутся со стека и возвращаемые значения из инструкций кладутся обратно на стек. С этой точки зрения можно сказать, что байткод записан в обратной польской нотации. Помимо стэка в методе есть еще массив локальных переменных. При входе в метод в него записывается this и все аргументы этого метода, и в процессе выполнения там же хранятся локальные переменные. Вот простенький пример.


public class Foo {
    private int bar;

    public int updateAndReturn(long baz, String str) {
        int result = (int) baz;
        result += str.length();
        bar = result;
        return result;
    }
}

Я буду писать комментарии в формате


# [(<local_variable_index>:<actual_value>)*], [(<value_on_stack>)*]

Вершина стэка слева.


public int updateAndReturn(long, java.lang.String);
Code:
   # [0:this, 1:long baz, 3:str], ()
   0: lload_1                                                    
   # [0:this, 1:long baz, 3:str], (long baz)
   1: l2i                                                        
   # [0:this, 1:long baz, 3:str], (int baz)
   2: istore        4                                            
   # [0:this, 1:long baz, 3:str, 4:int baz], ()
   4: iload         4                                            
   # [0:this, 1:long baz, 3:str, 4:int baz], (int baz)
   6: aload_3                                                    
   # [0:this, 1:long baz, 3:str, 4:int baz], (str, int baz)
   7: invokevirtual #2 // Method java/lang/String.length:()I     
   # [0:this, 1:long baz, 3:str, 4:int baz], (length(str), int baz)
  10: iadd                                                       
  # [0:this, 1:long baz, 3:str, 4:int baz], (length(str) + int baz)
  11: istore        4                                            
  # [0:this, 1:long baz, 3:str, 4:length(str) + int baz], ()
  13: aload_0                                                    
  # [0:this, 1:long baz, 3:str, 4:length(str) + int baz], (this)
  14: iload         4                                            
  # [0:this, 1:long baz, 3:str, 4:length(str) + int baz], (length(str) + int baz, this)
  16: putfield      #3 // Field bar:I                            
  # [0:this, 1:long baz, 3:str, 4:length(str) + int baz], (), сумма записана в поле bar
  19: iload         4                                            
  # [0:this, 1:long baz, 3:str, 4:length(str) + int baz], (length(str) + int baz)
  21: ireturn                                                    
  # возвратили int с вершины стэка, там как раз была наша сумма

Всего инструкций очень много. Полный список нужно смотреть в шестой главе JVMS, на википедии есть краткий пересказ. Большое количество инструкций дублируют друг друга для разных типов, (например, iload для инта и lload для лонга). Так же для работы с 4 первыми локальными переменными выделены свои инструкции (в примере выше, например, есть lload_1 и он не принимает аргументов вообще, но еще есть просто lload, он будет принимать аргументом номер локальной переменной. В примере выше есть подобный iload).


Глобально нас будут интересовать следующие группы инструкций:


  1. *load*, *store* — чтение/запись локальной переменной
  2. *aload, *astore — чтение/запись элемента массива по индексу
  3. getfield, putfield — чтение/запись поля
  4. getstatic, putstatic — чтение/запись статического поля
  5. checkcast — каст между объектными типами. Нужен т.к. на стэке и в локальных переменных лежат типизированные значения. Например, выше был l2i для каста long -> int.
  6. invoke* — вызов метода
  7. *return — возврат значения и выход из метода


Часть шестая. Главная


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


Нужно, имея на руках экземпляр java.lang.reflect.Method, получить список всех нестатических филдов (и текущего, и всех вложенных объектов), чтения которых (напрямую или транзитивно) будут внутри данного метода.

Например, для такого метода


public long getBalance() {
    Account acc;
    if (account != null)
        acc = account;
    else
        acc = client.getDefaultAccount();
    return acc.getBalance();
}

Нужно получить список из двух филдов: account.balance и client.defaultAccount.balance.


Я буду писать, по-возможности, обобщенное решение. Но в паре мест придется использовать знание об исходной задаче для решения неразрешимых, в общем случае, проблем.


Для начала нужно получить сам байткод тела метода, но напрямую через джаву это сделать нельзя. Но т.к. изначально метод существует внутри какого-то класса, то проще получить сам класс. Глобально я знаю два варианта: вклиниться в процесс загрузки классов и перехватывать уже прочитанный byte[] там, либо просто найти сам файл ClassName.class на диске и прочитать его. Перехват загрузки на уровне обычной библиотеки не сделать. Нужно либо подключать javaagent, либо использовать кастомный ClassLoader. В любом случае, требуются дополнительные действия по настройке jvm/приложения, а это неудобно. Можно поступить проще. Все "обычные" классы всегда находятся в одноименном файле с расширением ".class", путь к которому — это пакет класса. Да, так не получится найти динамически добавленные классы или классы, загруженные каким-нибудь кастомным класслоадером, но нам это нужно для модели jdbc, так что можно с уверенностью сказать что все классы будут запакованы "дефолтным способом" в джарники. Итого:


private static InputStream getClassFile(Class<?> clazz) {
    String file = clazz.getName().replace('.', '/') + ".class";

    ClassLoader cl = clazz.getClassLoader();
    if (cl == null)
        return ClassLoader.getSystemResourceAsStream(file);
    else
        return cl.getResourceAsStream(file);
}

Ура, массив байт прочитали. Что будем делать с ним дальше? В принципе в джаве для чтения/записи байткода есть несколько библиотек, но для самой низкоуревневой работы обычно используется ASM. Т.к. он заточен под высокую производительность и работу налету, то основным там является visitor API — асм последовательно читает класс и дергает соответствующие методы


public abstract class ClassVisitor {
    public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {...}
    public FieldVisitor visitField(int access, String name, String desc, String signature, Object value) {...}
    public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {...}
    ...
}

public abstract class MethodVisitor {
    protected MethodVisitor mv;
    public MethodVisitor(final int api, final MethodVisitor mv) {
        ...
        this.mv = mv;
    }

    public void visitJumpInsn(int opcode, Label label) {
        if (mv != null) {
            mv.visitJumpInsn(opcode, label);
        }
    }
    ...
}

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


В дополнение к основному апи из коробки есть еще Tree API. Если Core API является аналогом SAX парсера, то Tree API — это аналог DOM. Мы получаем объект, внутри которого хранится вся информация о классе/методе и можем анализировать ее как хотим с прыжками в любое место. По сути, этот апи является реализациями *Visitor, которые внутри методов visit* просто сохраняют информацию. Примерно все методы там выглядят так:


public class MethodNode extends MethodVisitor {
    @Override
    public void visitJumpInsn(final int opcode, final Label label) {
        instructions.add(new JumpInsnNode(opcode, getLabelNode(label)));
    }
    ...
}

Теперь мы, наконец-то, можем загрузить метод для анализа.


private static class AnalyzerClassVisitor extends ClassVisitor {

    private final String getterName;
    private final String getterDesc;

    private MethodNode methodNode;

    public AnalyzerClassVisitor(Method getter) {
        super(ASM6);
        this.getterName = getter.getName();
        this.getterDesc = getMethodDescriptor(getter);
    }

    public MethodNode getMethodNode() {
        if (methodNode == null)
            throw new IllegalStateException();
        return methodNode;
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
        //Проверяем что это именно наш метод
        if (!name.equals(getterName) || !desc.equals(getterDesc))
            return null;
        return new AnalyzerMethodVisitor(access, name, desc, signature, exceptions);
    }

    private class AnalyzerMethodVisitor extends MethodVisitor {

        public AnalyzerMethodVisitor(int access, String name, String desc, String signature, String[] exceptions) {
            super(ASM6, new MethodNode(ASM6, access, name, desc, signature, exceptions));
        }

        @Override
        public void visitEnd() {
            //Данный метод вызывается в самом конце, после него других вызовов MethodVisitor уже не будет
            if (methodNode != null)
                throw new IllegalStateException();
            methodNode = (MethodNode) mv;
        }
    }
}

Полный код чтения метода.

Возвращается не напрямую MethodNode, а обертка с парой доп. полей, т.к. они нам позже тоже понадобятся. Точка входа (и единственный публичный метод) — readMethod(Method): MethodInfo.


public class MethodReader {

    public static class MethodInfo {
        private final String internalDeclaringClassName;
        private final int classAccess;
        private final MethodNode methodNode;

        public MethodInfo(String internalDeclaringClassName, int classAccess, MethodNode methodNode) {
            this.internalDeclaringClassName = internalDeclaringClassName;
            this.classAccess = classAccess;
            this.methodNode = methodNode;
        }

        public String getInternalDeclaringClassName() {
            return internalDeclaringClassName;
        }

        public int getClassAccess() {
            return classAccess;
        }

        public MethodNode getMethodNode() {
            return methodNode;
        }
    }

    public static MethodInfo readMethod(Method method) {
        Class<?> clazz = method.getDeclaringClass();
        String internalClassName = getInternalName(clazz);

        try (InputStream is = getClassFile(clazz)) {
            ClassReader cr = new ClassReader(is);
            AnalyzerClassVisitor cv = new AnalyzerClassVisitor(internalClassName, method);
            cr.accept(cv, SKIP_DEBUG | SKIP_FRAMES);
            return new MethodInfo(internalClassName, cv.getAccess(), cv.getMethodNode());
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    private static InputStream getClassFile(Class<?> clazz) {
        String file = clazz.getName().replace('.', '/') + ".class";

        ClassLoader cl = clazz.getClassLoader();
        if (cl == null)
            return ClassLoader.getSystemResourceAsStream(file);
        else
            return cl.getResourceAsStream(file);
    }

    private static class AnalyzerClassVisitor extends ClassVisitor {

        private final String className;
        private final String getterName;
        private final String getterDesc;

        private MethodNode methodNode;
        private int access;

        public AnalyzerClassVisitor(String internalClassName, Method getter) {
            super(ASM6);
            this.className = internalClassName;
            this.getterName = getter.getName();
            this.getterDesc = getMethodDescriptor(getter);
        }

        public MethodNode getMethodNode() {
            if (methodNode == null)
                throw new IllegalStateException();
            return methodNode;
        }

        public int getAccess() {
            return access;
        }

        @Override
        public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
            if (!name.equals(className))
                throw new IllegalStateException();

            this.access = access;
        }

        @Override
        public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
            if (!name.equals(getterName) || !desc.equals(getterDesc))
                return null;
            return new AnalyzerMethodVisitor(access, name, desc, signature, exceptions);
        }

        private class AnalyzerMethodVisitor extends MethodVisitor {

            public AnalyzerMethodVisitor(int access, String name, String desc, String signature, String[] exceptions) {
                super(ASM6, new MethodNode(ASM6, access, name, desc, signature, exceptions));
            }

            @Override
            public void visitEnd() {
                if (methodNode != null)
                    throw new IllegalStateException();
                methodNode = (MethodNode) mv;
            }
        }
    }
}

Самое время заняться непосредственно анализом. Как его делать? Первая мысль — смотреть все инструкции getfield. У каждого getfield статически написано какой это филд и какого класса. Можно считать за необходимые все филды нашего класса, к которым был доступ. Но, к сожалению, это не работает. Первая проблема здесь заключается в том, что происходит захват лишнего.


class Foo {
    private int bar;
    private int baz;

    public int test() {
        return bar + new Foo().baz;
    }
}

При таком алгоритме мы посчитаем, что филд baz нужен, хотя, на самом деле, нет. Но на эту проблему еще можно было бы и забить. Но что делать с методами?


public class Client implements HasClientId {
    private Long id;

    public Long getId() {
        HasClientId obj = this;
        return obj.getClientId();
    }

    @Override
    public Long getClientId() {
        return id;
    }
}

Если искать вызовы методов так же, как мы ищем чтение филдов, то getClientId мы не найдем. Ибо здесь нет вызова Client.getClientId, а есть только вызов HasClientId.getClientId. Можно, конечно, считать используемыми все методы на текущем классе, всех его суперклассах и всех интерфейсах, но это уже совсем перебор. Так можно случайно и toString захватить, а в нем распечатка вообще всех филдов.


Более того, мы ведь хотим чтобы вызовы геттеров у вложенных объектов тоже работали


public class Account {
    private Client client;

    public long getClientId() {
        return client.getId();
    }
}

И здесь вызов метода Client.getId к классу Account вообще никак не относится.


При большом желании еще можно какое-то время попридумывать хаки под частные случаи, но довольно быстро приходит понимание, что "так дела не делаются" и нужно полноценно следить за потоком исполнения и перемещением данных. Интересовать нас должны те и только те getfield, которые вызываются либо непосредственно на this, либо на каком-нибудь филде от this. Вот пример:


class Client {
    public long id;
}

class Account {
    public long id;
    public Client client;
    public long test() {
        return client.id + new Account().id;
    }
}

class Account {
  public Client client;

  public long test();
    Code:
       0: aload_0
       1: getfield      #2                  // Field client:LClient;
       4: getfield      #3                  // Field Client.id:J
       7: new           #4                  // class Account
      10: dup
      11: invokespecial #5                  // Method "<init>":()V
      14: getfield      #6                  // Field id:J
      17: ladd
      18: lreturn
}

  • 1: getfield нам интересен потому что в момент его выполнения на вершине стэка будет лежать this, загруженный туда инструкцией aload_0.
  • 4: getfield — потому что он будет вызываться на клиенте, возвращенном из предыдущего 1: getfield, и, транзативно, на this.
  • 14: getfield нам не интересен. Т.к. не смотря на то, что что это филд текущего класса (Account), вызывается он не на this, а на новом объекте, созданном и положенном на стэк в 7: new.

Итого, после анализа данного метода видно что филд Account.client.id используется, а Account.id — нет. Это был пример про филды, с методами больше нюансов, но в целом примерно то же самое.


В этот момент опускаются руки — надо писать полноценный интерпретатор, детектить паттерны не выйдет, ведь между aload_0 и getfield может быть масса кода с перекладыванием этого несчастного this в локалы, касты, засовывания его в методы и возврат из них. Короче, боль. Это плохая новость. Но есть и хорошая — такой интерпретатор уже написан! И не где-нибудь, а прямо в асме. И интерпретирует он именно MethodNode, полученный нами ранее (вот же неожиданность). В потоковом режиме интерпретировать нельзя, т.к. могут быть джампы (циклы/условия/исключения) и хорошо бы не перечитывать метод после каждого.


Интерпретатор состоит из двух частей:


public class Analyzer<V extends Value> {
    public Analyzer(final Interpreter<V> interpreter) {...}
    public Frame<V>[] analyze(final String owner, final MethodNode m) {...}
}

Analyzer уже написан и в нем (и в Frame, но про него позже) реализован сам интерпретатор. Именно он идет последовательно по инструкциям, кладет значения в локалы, читает их оттуда, модифицирует стэк, совершает прыжки если в методе есть цилкы/условия/etc.


public abstract class Interpreter<V extends Value> {
    public abstract V newValue(Type type);
    public abstract V newOperation(AbstractInsnNode insn) throws AnalyzerException;
    public abstract V copyOperation(AbstractInsnNode insn, V value) throws AnalyzerException;
    public abstract V unaryOperation(AbstractInsnNode insn, V value) throws AnalyzerException;
    public abstract V binaryOperation(AbstractInsnNode insn, V value1, V value2) throws AnalyzerException;
    public abstract V ternaryOperation(AbstractInsnNode insn, V value1, V value2, V value3) throws AnalyzerException;
    public abstract V naryOperation(AbstractInsnNode insn, List<? extends V> values) throws AnalyzerException;
    public abstract void returnOperation(AbstractInsnNode insn, V value, V expected) throws AnalyzerException;
    public abstract V merge(V v, V w);
}

Параметр V — это наш класс, в котором можно хранить любую информацию, ассоциированную с конкретным значением. Analyzer в процессе анализа будет вызывать соответствующие методы для каждой инструкции, передавать на вход значения, которые сейчас лежат на вершине стэка и будут использованы данной инструкцией, а нам остается вернуть новое значение. Например, getfield принимает на вход один аргумент — объект, чье поле будет вычитано, и возвращает значение этого поля. Соответственно, будет вызван unaryOperation(AbstractInsnNode insn, V value): V, где мы сможем проверить к чьему филду идет доступ и вернуть разные значения в зависимости от этого. В примере выше на 1: getfield мы вернем Value, в котором будет написано "это поле client, типа Client и нам интересно трэкать дальнейшие доступы к нему", а в 14: getfield скажем "аргумент — это какой-то неизвестный объект, так что возвращаем скучный инт и плевать на него".


Отдельно хочу обратить внимание на метод merge(V v, V w): V. Он вызывается не для конкретных инструкций, а когда поток исполнения снова попадает в место, где он уже был. Например:


public long getBalance() {
    Account acc;
    if (account != null)
        acc = account;
    else
        acc = client.getDefaultAccount();
    return acc.getBalance();
}

Здесь к вызову Account.getBalance() можно попасть двумя разными путями. Но инструкция-то одна. И на вход она принимает единственное значение. Какое из двух? Именно на этот вопрос и призван отвечать метод merge.


Что нам осталось перед самым главным шагом — написанием SuperInterpreter extends Interpreter<SuperValue>? Правильно. Написать этот самый SuperValue. Посколько цель у нас — найти все используемые филды и филды филдов, то логичным выглядит хранение именно пути по филдам. Но поскольку в одно и то же место можно прийти разными путями и с разными объектами, то непосредственно в качестве значения мы будем хранить множество уникальных путей.


public class Value extends BasicValue {
    private final Set<Ref> refs;

    private Value(Type type, Set<Ref> refs) {
        super(type);
        this.refs = refs;
    }
}

public class Ref {
    private final List<Field> path;
    private final boolean composite;

    public Ref(List<Field> path, boolean composite) {
        this.path = path;
        this.composite = composite;
    }
}

По поводу странного флага composite. Нет ни желания, ни необходимости анализировать абсолютно все. Например, тип String лучше воспринимать неделимым. И даже если в нашем методе есть вызов String.length(), то правильнее считать, что использовано было именно основное поле name, а не name.value.length. Кстати, length — это вообще не филд, а свойство массива, получаемое отдельной инструкцией arraylength. Нам надо обрабатывать ее отдельно? Нет! Самое время первый раз вспомнить ради чего все делается — мы хотим понять какие объекты надо грузить из базы. Соответственно, минимальной и неделимой сущностью являются те объекты, которые грузятся из базы целиком, а не через джойн по филдам. Например, это Date, String, Long, и абсолютно плевать что у них внутри довольно сложная структура. Кстати, сюда же идут все типы, для которых у нас есть конвертер. Например может быть написано


class Persion {
    @JdbcColumn(converter = CustomJsonConverter.class)
    private PassportInfo passportInfo;
}

И тогда внутрь этого PassportInfo тоже смотреть не надо. Он целиком либо будет притянут, либо нет. Так вот, флаг composite отвечает именно за это. Надо нам заглядывать внутрь или же нет.


Полностью
public class Ref {

    private final List<Field> path;
    private final boolean composite;

    public Ref(List<Field> path, boolean composite) {
        this.path = path;
        this.composite = composite;
    }

    public List<Field> getPath() {
        return path;
    }

    public boolean isComposite() {
        return composite;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Ref ref = (Ref) o;
        return Objects.equals(path, ref.path);
    }

    @Override
    public int hashCode() {
        return Objects.hash(path);
    }

    @Override
    public String toString() {
        if (path.isEmpty())
            return "<[this]>";
        else
            return "<" + path.stream().map(Field::getName).collect(joining(".")) + ">";
    }

    public static Ref thisRef() {
        return new Ref(emptyList(), true);
    }

    public static Optional<Ref> childRef(Ref parent, Field field, Configuration configuration) {
        if (!parent.isComposite())
            return empty();
        if (parent.path.contains(field))//пока можно не обращать внимание, дальше объясню
            return empty();

        List<Field> path = new ArrayList<>(parent.path);
        path.add(field);
        return of(new Ref(path, configuration.isCompositeField(field)));
    }

    public static Optional<Ref> childRef(Ref parent, Ref child) {
        if (!parent.isComposite())
            return empty();
        if (child.path.stream().anyMatch(parent.path::contains))//оно же, объясню позже
            return empty();

        List<Field> path = new ArrayList<>(parent.path);
        path.addAll(child.path);
        return of(new Ref(path, child.composite));
    }
}

public class Value extends BasicValue {

    private final Set<Ref> refs;

    private Value(Type type, Set<Ref> refs) {
        super(type);
        this.refs = refs;
    }

    public Set<Ref> getRefs() {
        return refs;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        if (!super.equals(o)) return false;
        Value value = (Value) o;
        return Objects.equals(refs, value.refs);
    }

    @Override
    public int hashCode() {
        return Objects.hash(super.hashCode(), refs);
    }

    @Override
    public String toString() {
        return "(" + refs.stream().map(Object::toString).collect(joining(",")) + ")";
    }

    public static Value typedValue(Type type, Ref ref) {
        return new Value(type, singleton(ref));
    }

    public static Optional<Value> childValue(Value parent, Value child) {
        Type type = child.getType();
        Set<Ref> fields = parent.refs.stream()
                .flatMap(p -> child.refs.stream().map(c -> childRef(p, c)))
                .filter(Optional::isPresent)
                .map(Optional::get)
                .collect(toSet());
        if (fields.isEmpty())
            return empty();
        return of(new Value(type, fields));
    }

    public static Optional<Value> childValue(Value parent, FieldInsnNode childInsn, Configuration configuration) {
        Type type = Type.getType(childInsn.desc);
        Field child = resolveField(childInsn);
        Set<Ref> fields = parent.refs.stream()
                .map(p -> childRef(p, child, configuration))
                .filter(Optional::isPresent)
                .map(Optional::get)
                .collect(toSet());
        if (fields.isEmpty())
            return empty();
        return of(new Value(type, fields));
    }

    public static Value mergeValues(Collection<Value> values) {
        List<Type> types = values.stream().map(BasicValue::getType).distinct().collect(toList());
        if (types.size() != 1) {
            String typesAsString = types.stream().map(Type::toString).collect(joining(", ", "(", ")"));
            throw new IllegalStateException("could not merge " + typesAsString);
        }

        Set<Ref> fields = values.stream().flatMap(v -> v.refs.stream()).distinct().collect(toSet());
        return new Value(types.get(0), fields);
    }

    public static boolean isComposite(BasicValue value) {
        return value instanceof Value && value.getType().getSort() == Type.OBJECT && ((Value) value).refs.stream().anyMatch(Ref::isComposite);
    }
}

Ну что ж, вся подготовительная работа завершена. Поехали!


public class FieldsInterpreter extends BasicInterpreter {

Чтобы полностью не писать поддержку всех команд, можно относледоваться от BasicInterpreter. Он работает с BasicValue (именно поэтому можно было заметить, что мой Value был extends BasicValue) и реализует простую работу с типами.


public class BasicValue implements Value {
    public static final BasicValue UNINITIALIZED_VALUE = new BasicValue(null);
    public static final BasicValue INT_VALUE = new BasicValue(Type.INT_TYPE);
    public static final BasicValue FLOAT_VALUE = new BasicValue(Type.FLOAT_TYPE);
    public static final BasicValue LONG_VALUE = new BasicValue(Type.LONG_TYPE);
    public static final BasicValue DOUBLE_VALUE = new BasicValue(Type.DOUBLE_TYPE);
    public static final BasicValue REFERENCE_VALUE = new BasicValue(Type.getObjectType("java/lang/Object"));
    public static final BasicValue RETURNADDRESS_VALUE = new BasicValue(Type.VOID_TYPE);

    private final Type type;

    public BasicValue(final Type type) {
        this.type = type;
    }
}

Это позволит нам не только наслаждаться обилием кастов ((Value)basicValue) в коде, но и писать только значимую часть с нашими композитными значениями, а всю тривиальную логику (вида "инструкция iconst возвращает инт") не писать.


Начнем с newValue. Этот метод отвечает за первичное создание значение, когда они появляются не как результат инструкции, а "из воздуха". Это параметры метода, this и исключение в блоке catch. В принципе, нас бы устроил дефолтный вариант, если бы не одно но. Для объектных типов BasicInterpreter вместо BasicValue(actualType) возвращает просто BasicValue.REFERENCE_VALUE. А нам хотелось бы иметь реальный тип.


@Override
public BasicValue newValue(Type type) {
    if (type != null && type.getSort() == OBJECT)
        return new BasicValue(type);
    return super.newValue(type);
}

Теперь entry point. Весь наш анализ начинается с this. Соответственно, нужно как-нибудь сказать, что там, где лежит this, на самом деле не BasicValue(actualType), а Value.typedValue(actualType, Ref.thisRef()). Вообще, как я уже писал выше, изначально значение this устанавливается через вызов newValue, только вот неизвестно какой именно. И даже на сам тип опираться нельзя, т.к. один из аргументов метода может быть такого же типа, как и this. Получается что напрямую записать this правильным мы не можем. Ок, можно выкрутиться. Известно, что при вызове метода this всегда идет как локальная переменная номер 0. Напрямую с локальными переменными ничего делать нельзя, их можно только читать и записывать. А это значит, что мы можем перехватить чтение данной переменной и вернуть не оригинальное значение, а правильное. Ну и на всякий случай стоит проверить что данная переменная не была перезаписана.


@Override
public BasicValue copyOperation(AbstractInsnNode insn, BasicValue value) throws AnalyzerException {
    if (wasUpdated || insn.getType() != VAR_INSN || ((VarInsnNode) insn).var != 0) {
        return super.copyOperation(insn, value);
    }

    switch (insn.getOpcode()) {
        case ALOAD:
            return typedValue(value.getType(), thisRef());
        case ISTORE:
        case LSTORE:
        case FSTORE:
        case DSTORE:
        case ASTORE:
            wasUpdated = true;
    }

    return super.copyOperation(insn, value);
}

Идем дальше. Мержить значения довольно просто. Если они разных типов, то кидаем исключение, если одно нам интересно, а другое — нет, то берем интересное. Если интересны оба, то объединяем все пути.


@Override
public BasicValue merge(BasicValue v, BasicValue w) {
    if (v.equals(w))
        return v;
    if (v instanceof Value || w instanceof Value) {
        if (!Objects.equals(v.getType(), w.getType())) {
            if (v == UNINITIALIZED_VALUE || w == UNINITIALIZED_VALUE)
                return UNINITIALIZED_VALUE;
            throw new IllegalStateException("could not merge " + v + " and " + w);
        }

        if (v instanceof Value != w instanceof Value) {
            if (v instanceof Value)
                return v;
            else
                return w;
        }

        return mergeValues(asList((Value) v, (Value) w));
    }
    return super.merge(v, w);
}

Теперь вот какой вопрос. А любая ли операция является "хорошей"? Все ли мы можем делать с интересующими нас объектами? На самом деле нет. Мы не можем сохранять их никуда в глобально доступный стейт. В противном случае уже невозможно будет проконтролировать использование, т.к. оно может быть за пределами анализируемого метода. Инструкций, позволяющих потерять наше значение всего 3 (я сейчас не считаю передачу в аргументы функций): putfield, putstatic, aastore. Здесь сразу стоит оговориться. Из этих инструкций только putstatic (запись в статический филд) теряет наш объект гарантированно. Ибо даже если филд приватный, ничто не мешает мне обратиться к нему из соседнего метода. А вот с putfield и aastore интереснее. В общем случае, если наш объект был записан в нестатический филд или в массив, то действительно неизвестно что будет дальше. Этот объект (чей был филд) или массив могут уйти куда угодно и там с ними будет сделано что угодно. И если в объекте мы хотя бы знаем что это за филд, то в массиве индекс может вычисляться динамически и узнать мы его не сможем. Однако, есть частный случай — если объект или массив являются локальными.


public class Account {
    private Client client;
    public Long getClientId() {
        return Optional.ofNullable(client).map(Client::getId).orElse(null);
    }
}

Не смотря на то, что здесь происходит запись клиента в филд (внутри ofNullable создается новый инстанс Optional и client записывается к нему в поле value), утечки здесь не происходит и проследить полный путь можно. Теоретически. На практике этого пока нет. Кстати, конкретно данный пример не будет работать не только из-за ofNullable(client), но еще и из-за map(Client::getId), но про это позже.


Ну а сейчас нам нужно просто запретить putfield, putstatic и aastore.


@Override
public BasicValue binaryOperation(AbstractInsnNode insn, BasicValue value1, BasicValue value2) throws AnalyzerException {
    if (insn.getOpcode() == PUTFIELD && Value.isComposite(value2)) {
        throw new IllegalStateException("could not trace " + value2 + " over putfield");
    }
    return super.binaryOperation(insn, value1, value2);
}

@Override
public BasicValue ternaryOperation(AbstractInsnNode insn, BasicValue value1, BasicValue value2, BasicValue value3) throws AnalyzerException {
    if (insn.getOpcode() == AASTORE && Value.isComposite(value3)) {
        throw new IllegalStateException("could not trace " + value3 + " over aastore");
    }
    return super.ternaryOperation(insn, value1, value2, value3);
}

@Override
public BasicValue unaryOperation(AbstractInsnNode insn, BasicValue value) throws AnalyzerException {
    if (Value.isComposite(value)) {
        switch (insn.getOpcode()) {
            case PUTSTATIC: {
                throw new IllegalStateException("could not trace " + value + " over putstatic");
            }
            ...
        }
    }

    return super.unaryOperation(insn, value);
}

Еще есть каст. В байткоде для него есть инструкция checkcast. С нашей точки зрения у каста может быть два применения: возврат потерянного типа и уточнение типа. Первое — это конструкции вида


Client client1 = ...;
Object objClient = client1;
Client client2 = (Client) objClient;

Здесь на третьей строчке не смотря на наличие каста, на уровне конкретного объекта уточнения типа не происходит. У нас же в значении записан исходный тип объекта, и от того, что мы переложили клиента из переменной client1 в objClient, информация не потерялась. Соответственно, здесь при checkcast достаточно просто проверить что этот тип и так уже был.


А вот с уточнением интереснее.


class Foo {
    private List<?> list;

    public void trimToSize() {
        ((ArrayList<?>) list).trimToSize();
    }
}

Обновить тип у значения не проблема. Но гораздо важнее здесь определиться в том, может ли вообще отличаться реальный класс, от того, который написан в декларации филда. Если считать, что такое возможно, то возникнут очень большие проблемы при поддержке вызовов методов, потому что в джаве почти все методы виртуальные, и будет совершенно непонятно, какой метод реально будет вызван. Можем ли мы сказать что тип филда всегда идеально точен? Конечно же, можем! Самое время снова вспомнить про исходную задачу. Нам нужно понять, чтения из каких филдов будут внутри конкретных методов для того, чтобы понять, надо ли их заполнять при запросе в базу или же можно оставить дефолтные null/0/false. И здесь все просто. Композитный объект — это джойн


@JdbcJoinedObject(localColumn = "CLIENT")
private Client client;

И мы точно знаем, что в таких местах никакого полифорфизма нет, ORM гарантированно создаст инстанс именно того класса, который написан у филда. Теперь с чистой совестью можно написать checkcast


@Override
public BasicValue unaryOperation(AbstractInsnNode insn, BasicValue value) throws AnalyzerException {
    if (Value.isComposite(value)) {
        switch (insn.getOpcode()) {
            ...
            case CHECKCAST: {
                Class<?> original = reflectClass(value.getType());
                Type targetType = getObjectType(((TypeInsnNode) insn).desc);
                Class<?> afterCast = reflectClass(targetType);
                if (afterCast.isAssignableFrom(original)) {
                    return value;
                } else {
                    throw new IllegalStateException("type specification not supported");
                }
            }
        }
    }

    return super.unaryOperation(insn, value);
}

Переходим к центральной инструкции — getfield. Тут есть один тонкий момент — как глубоко копать?


class Foo {
    private Foo child;
    public Foo test() {
        Foo loopedRef = this;
        while (ThreadLocalRandom.current().nextBoolean()) {
            loopedRef = loopedRef.child;
        }
        return loopedRef;
    }
}

Очевидно, что рано или поздно из цикла мы выйдем. Но какие филды считать использованными? child, child.child, child.child.child? Когда останавливаться? Очевидно, что в общем случае это тоже неразрешимо. Прекрасный момент, чтобы в очередной раз вспомнить про оригинальную задачу. Как я уже писал в первой части,


все рекурсивные филды сейчас вообще не заполняются и там всегда null.

Так что здесь заполненным будет только первый child, а все последующие в любом случае null, а, значит, и анализировать их не надо. Внимательные читатели должны были заметить в методах Ref.childRef условие


if (parent.path.contains(field))
    return empty();

Оно именно про это. Если мы через данный филд уже проходили, то путь не считаем.


Сейчас самое время ввести новый термин "интересующий нас филд". Список именно таких филдов в конечном счете мы должны получить. Хочу обратить внимание, что это не любой некомпозитный филд в конце цепочки композитных объектов. Например, в нашей задаче, если над филдом нет никаких аннотаций (ни @JdbcJoinedObject, ни @JdbcColumn), то даже если этот филд используется, нам на это плевать. ORM все равно не будет выгребать его из базы.


Итого, в обработке getfield нужно получить новое значение как доступ к филду у базового значения, затем проверить на отсутствие рекурсии. Если конечный филд нас интересует, то запомнить его, а если значение является композитным, то сохранить его для дальнейшего анализа. Сказано — сделано.


@Override
public BasicValue unaryOperation(AbstractInsnNode insn, BasicValue value) throws AnalyzerException {
    if (Value.isComposite(value)) {
        switch (insn.getOpcode()) {
            ...
            case GETFIELD: {
                Optional<Value> optionalFieldValue = childValue((Value) value, (FieldInsnNode) insn, configuration);
                if (!optionalFieldValue.isPresent())
                    break;

                Value fieldValue = optionalFieldValue.get();

                if (configuration.isInterestingField(resolveField((FieldInsnNode) insn))) {
                    context.addUsedField(fieldValue);
                }

                if (Value.isComposite(fieldValue)) {
                    return fieldValue;
                }
                break;
            }
            ...
        }
    }

    return super.unaryOperation(insn, value);
}

Мы почти у цели. Осталась последняя, но не по значению, инструкция. Точнее не инструкция, а группа инструкций invoke*. До сих пор весь анализ шел только вокруг филдов, но, тем не менее, в исходной задаче хотелось поддержать и вызов методов. Чтобы работало, например:


public long getClientId() {
    return getClient().getId();
}

Но прежде, чем мы разберемся с вызовами методов, нужно доделать последнюю подготовительную вещь. В получившейся модели один филд не может быть одновременно и композитным, и интересующим нас. И это вполне логично. Нет смысла сначала считать филд используемым, а потом все равно лезть внутрь и анализировать его потроха. Зачем тогда сам филд? И на всех приведенных выше примерах такой подход работал отлично. Но есть одно исключение. Если из метода возвращается композитный объект.


class Account implements HasClient {
    @JdbcJoinedObject
    private Client client;
    public Client getClient() {
        return client;
    }
}

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


public static class Result {
    private final Set<Value> usedFields;
    private final Value returnedCompositeValue;
}

Как узнать это значение? Не просто, а очень просто. Нужно перебрать все инструкции возврата и посмотреть что они возвращают. Строго говоря, т.к. композитным может быть только объект (в терминологии джавы, в смысле — ссылочный тип), а не примитивы, то достаточно было бы проверить только areturn, но, на всякий случай, я проверяю все *return. От MethodNode (это тот, который вышел из асмового Tree API) у нас есть массив всех инструкций метода. От анализатора есть массив фреймов. Фрейм — это состояние локальных переменных и стэка перед выполнением текущей инструкции. Помните, я писал пример байткода и подробно описывал локальные переменные и стэк в комментариях? Вот это ровно фреймы и были. Теперь нам достаточно знаний чтобы собрать возвращенное композитное значение.


private static Value getReturnedCompositeValue(Frame<BasicValue>[] frames, AbstractInsnNode[] insns) {
    Set<Value> resultValues = new HashSet<>();

    for (int i = 0; i < insns.length; i++) {
        AbstractInsnNode insn = insns[i];
        switch (insn.getOpcode()) {
            case IRETURN:
            case LRETURN:
            case FRETURN:
            case DRETURN:
            case ARETURN:
                BasicValue value = frames[i].getStack(0);
                if (Value.isComposite(value)) {
                    resultValues.add((Value) value);
                }
                break;
        }
    }

    if (resultValues.isEmpty())
        return null;

    return mergeValues(resultValues);
}

Ну а сам analyzeField выглядит так


public static Result analyzeField(Method method, Configuration configuration) {
    if (Modifier.isNative(method.getModifiers()))
        throw new IllegalStateException("could not analyze native method " + method);

    MethodInfo methodInfo = readMethod(method);

    MethodNode mn = methodInfo.getMethodNode();
    String internalClassName = methodInfo.getInternalDeclaringClassName();
    int classAccess = methodInfo.getClassAccess();

    Context context = new Context(method, classAccess);
    FieldsInterpreter interpreter = new FieldsInterpreter(context, configuration);
    Analyzer<BasicValue> analyzer = new Analyzer<>(interpreter);
    try {
        analyzer.analyze(internalClassName, mn);
    } catch (AnalyzerException e) {
        throw new RuntimeException(e);
    }
    Frame<BasicValue>[] frames = analyzer.getFrames();
    AbstractInsnNode[] insns = mn.instructions.toArray();

    Value returnedCompositeValue = getReturnedCompositeValue(frames, insns);

    return new Result(context.getUsedFields(), returnedCompositeValue);
}

И вот теперь, наконец-то, можно приступить к финальному рывку. invoke*. Всего инструкций для вызовов методов 5 штук:


  1. invokespecial — служит для вызова конкретного инстансного метода. Используется для вызова конструктора, приватных методов, методов суперкласса (это если внутри метода написать super.call()).
  2. invokevirtual — обычный вызов обычного инстансного метода. Реальный метод при этом определяется исходя из реального класса объекта. Важно, чтобы сам метод изначально был определен в классе.
  3. invokeinterface — то же самое, что и invokevirtual, с единственным отличием — метод должен быть определен в интерфейсе.
  4. invokestatic — вызов статического метода
  5. invokedynamic — специальная инструкция, добавленная в 7 джаве в рамках JSR 292. Если у первых четырех инвоуков логика диспатча зашита в JVM, то в invokedynamic она реализуется пользователем в джава коде (именно поэтому он и называется dynamic). Если совсем грубо, то в параметр там передается ссылка на джавовый метод (+ его аргументы), в котором как раз логика диспатча и написана. Кому интересно, рекомендую посмотреть доклад Владимира Иванова Invokedynamic: роскошь или необходимость?.

Сразу оговорюсь, все дальнейшие рассуждения касаются только вызовов, где композитные значения передаются внутрь вызываемых методов, в противном случае анализ не требуется. С invokedynamic одновременно и проще, и сложнее всего. Можно сказать, что раз это полностью динамический диспатч, да еще и написанный в пользовательском коде, то проанализировать ничего нельзя (банально, мы не знаем какой метод будет вызван), и использовать invokedynamic мы запрещаем. Это простое решение, и я "реализовал" именно его. В принципе, есть пара мыслей как можно попытаться поддержать даже invokedynamic, но про это в самом конце.


Идем дальше. Теоретически, довольно легко сделать поддержку передачи композитных значений как параметров. В принципе, у нас для этого уже почти все есть. Помните, как мы определяем исходное значение this, просто читая локальную переменную 0? Собственно, если нас интересует какой-то параметр, то нужно просто сохранить его индекс в конструкторе FieldsInterpreter и в copyOperation следить за еще одной ячейкой. Более того, сам внешний интерфейс MethodAnalyzer.analyzeFields лучше переделать с "анализируй метод относительно использования филдов this" на "анализируй метод относительно использования филдов данных параметров" (где this — частный случай). Ничего сложного в этом нет, просто нужно сделать. Тем не менее, на текущий момент, так нельзя. И не только потому что хотелось поскорее написать этот пост, но и потому что было замечено что в реальности композитные объекты очень редко уходят куда-нибудь параметрами. А если и уходят, то там все равно будут сохранены в филд (какой-нибудь Optional.ofNullable(client)). Так что сама по себе данная фича дает не так уж и много.


Итого, invokestatic тоже отвалился (т.к. в параметрах нельзя, а this у него нет). Остаются invokespecial, invokevirtual и invokeinterface. Важно понять, а какой же метод на самом деле будет вызван. По сути, надо реализовать тот же самый диспатч, который уже реализован в jvm. С invokespecial все хорошо, там логика диспатча зависит только от самой инструкции и не зависит от реального класса объекта, на котором этот метод вызывается. А вот с invokevirtual и invokeinterface интереснее. В общем случае, без знания реального класса объекта вызываемый метод неизвестен.


public String objectToString(Object obj) {
    return obj.toString();
}

public static java.lang.String objectToString(java.lang.Object);
  Code:
     0: aload_0
     1: invokevirtual #104                // Method java/lang/Object.toString:()Ljava/lang/String;
     4: areturn

Здесь, очевидно, неизвестно какой метод (какого класса) будет вызван. Однако, если в очередной раз вспомнить исходную задачу, то все сразу упрощается. Мы ведь для чего вообще анализируем методы и хотим определить используемые филды? Чтобы понять какие из них необходимо заполнять автоматически внутри ORM. А ORM написан нами же, и мы прекрасно знаем что он будет создавать объекты именно того класса, который написан как тип филда. Так что получается что даже invokevirtual и invokeinterface мы можем обработать.


Ура! Вызываемый метод нашли. Что дальше? А дальше просто анализируем его рекурсивно, после чего сохраняем к себе используемые значения (только не забыв что они считались относительно того композитного значения, которое ушло в качестве this в метод), а возвращенное композитное значение (опять же, не забыв про иерархию) записываем к себе как результат вызова метода. И все!


    @Override
    public BasicValue naryOperation(AbstractInsnNode insn, List<? extends BasicValue> values) throws AnalyzerException {
        Method method = null;
        Value methodThis = null;

        switch (insn.getOpcode()) {
            case INVOKESPECIAL: {...}
            case INVOKEVIRTUAL: {...}
            case INVOKEINTERFACE: {
                if (Value.isComposite(values.get(0))) {
                    MethodInsnNode methodNode = (MethodInsnNode) insn;

                    Class<?> objectClass = reflectClass(values.get(0).getType());

                    Method interfaceMethod = resolveInterfaceMethod(reflectClass(methodNode.owner), methodNode.name, getMethodType(methodNode.desc));

                    method = lookupInterfaceMethod(objectClass, interfaceMethod);
                    methodThis = (Value) values.get(0);
                }

                List<?> badValues = values.stream().skip(1).filter(Value::isComposite).collect(toList());
                if (!badValues.isEmpty())
                    throw new IllegalStateException("could not pass " + badValues + " as parameter");
                break;
            }
            case INVOKESTATIC:
            case INVOKEDYNAMIC: {
                List<?> badValues = values.stream().filter(Value::isComposite).collect(toList());
                if (!badValues.isEmpty())
                    throw new IllegalStateException("could not pass " + badValues + " as parameter");
                break;
            }
        }

        if (method != null) {
            MethodAnalyzer.Result methodResult = analyzeFields(method, configuration);

            for (Value usedField : methodResult.getUsedFields()) {
                childValue(methodThis, usedField).ifPresent(context::addUsedField);
            }

            if (methodResult.getReturnedCompositeValue() != null) {
                Optional<Value> returnedValue = childValue(methodThis, methodResult.getReturnedCompositeValue());
                if (returnedValue.isPresent()) {
                    return returnedValue.get();
                }
            }
        }

        return super.naryOperation(insn, values);
    }

Напоследок хочется сказать пару слов по поводу самого диспатча. Там написано много кода, но сложного в нем ничего нет. По сути, просто открываешь шестую главу JVMS на нужной инструкции и переписываешь 1 в 1. Там в описании каждой команды подробно написан пошаговый алгоритм поиска нужного метода. Единственный момент — алгоритмическая сложность получившегося дела. Честно скажу, вообще не анализировал, но очень похоже что там и экспонента есть. Тем не менее, за проблему не считаю т.к. зависимость там от глубины иерархии, а мы все-таки имеем дело с энтитями, а в них иерархия даже на 2 класса — уже большая редкость. Помимо этого все выполняется один раз на старте приложения, после чего кэшируется, так что время работы не сильно критично. В любом случае, будет тормозить — перепишем. Кому все же интересно, смотрите классы ResolutionUtil и LookupUtil.


ВСЁ!



Часть седьмая. Что еще можно доделать


Как известно, после первых 80% результата и 20% времени работы остаются еще 20% результата и 80% работы. Собственно, что же еще можно добавить, чтобы было не круто, а очень круто?


  • Таки поддержать передачу композитных значений в параметрах методов. Про это я уже писал и ничего сложного там нет.


  • Разрешить сохранять значения в филды и массивы. С массивами сложнее (т.к. индексы неизвестны и надо следить за самим массивом), но вот хотя бы с филдами можно разобраться. Собственно, суть в локальных объектах. Нет ничего плохого в сохранении в филд объекта, который является локальным относительно анализируемого метода.


    public class Account {
      private Client client;
      public Long getClientId() {
          return Optional.ofNullable(client).map(Client::getId).orElse(null);
      }
    }

    Можно проследить что объект Optional, созданный внутри ofNullable на момент выхода из getClientId уже уничтожен, а клиент, записанный к нему в филд value уже прочитан и использован. В принципе, это довольно сильно похоже на уже имеющийся returnedCompositeValue — по сути, это тоже значение, утекающее за границу анализируемого метода. Можно, например, при записи в филд помечать основной объект (чей это филд) как композитный и при выходе из метода следить чтобы все композитные объекты были либо уничтожены, либо возвращены. Главная сложность будет в необходимости написания своего мини-хипа. Если до сих пор все значения хранились либо на стэке, либо в локальных переменных, то информацию о том, что "в филде value объекта Optional@1234 лежит Client@5678" сейчас хранить негде.


  • Поддержать invokedynamic, хотя бы для частных случаев. Если бы через indy делалась только совсем кастомная пользовательская логика, то можно было бы и забить. Но проблема в том, что все больше стандартная джава компилируется с использованием данной инструкции. Так, лямбды компилируются именно через нее. Более того, начиная с девятки конкатенация строк тоже компилируется через invokedynamic. Глобально есть два варианта поддержки. Можно делать точечные хаки. Например, мы знаем что для лямбд в качестве бутстрап метода используется java.lang.invoke.LambdaMetafactory.metafactory. Можно детектить именно его, а затем, подсмотрев текущую реализацию, пытаться предугадать что именно он вернет. А для конкатенации java.lang.invoke.StringConcatFactory.makeConcat/makeConcatWithConstants. С реализацией здесь даже проще. Мы знаем что просто у всех аргументов будет вызван toString(). И, теоретически, подобное решение, скорее всего, будет работать. Не смотря на то, что это является деталями реализации, я не думаю, что оркал/кто-нибудь другой решит данное поведение изменить. Ведь в таком случае старые скомпилированные классы перестанут работать на новых jvm, а такое в джаве не принято. Так что старые версии, скорее всего, работать будут. А для новых в любом случае придется постепенно добавлять поддержку в новых местах. Но это в теории. А на практике, я думаю, не надо объяснять чем плохо такое решение и почему так все же делать не стоит. Альтернативным же вариантом была бы попытка поддержать автоматически большинство indy инструкций. Как вообще они работают? При первой встрече с indy рантайм вызывает бутстрап метод, который возвращает специальный объект — CallSite. Внутри этого колсайта написано что же на самом деле нужно вызывать. Так, например, в лябмдах внутри LambdaMetafactory.metafactory происходит генерация класса со статическим методом getValue, который как раз и возвращает инстанс необходимого функционального интерфейса. А колсайт слинкован на вызов этого getValue. И сам бутстрап метод (вместе с аргументами) прописан статически. Более того, он, очевидно, должен быть stateless. Соответственно, можно его просто вызвать! Ну а потом как-нибудь выковырить из колсайта то, что будет вызвано на самом деле. Сами CallSite могут быть ConstantCallSite, MutableCallSite и VolatileCallSite. И если с mutable и volatile ничего поделать нельзя, там честная динамика и реальный метод неизвестен, то с ConstantCallSite можно попробовать. Но главная проблема в таком решении в строчке "как-нибудь выковырить из колсайта". Ой, не факт, что это вообще возможно. А если и возможно, то вот это уже похоже на совсем внутренности VM, которые могут быть изменены в любой момент.




Послесловие


Кто-то скажет, что это перебор. И ради какого-то несчастного partialGet городить такое не имеет смысла. Возможно, он даже будет прав. Тем не менее, мне хотелось показать, что в самом байткоде ничего страшного нет, и что с его помощью можно делать такие штуки, которые в "стандартной джаве" даже не снились.


Кому интересно, полный код лежит здесь.

Теги:
Хабы:
+15
Комментарии10

Публикации

Изменить настройки темы

Истории

Работа

Java разработчик
352 вакансии

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн