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

Байт-код — это просто! Как сделать DI по-настоящему быстрым

Уровень сложностиСредний
Время на прочтение10 мин
Количество просмотров14K
Всего голосов 25: ↑23 и ↓2+27
Комментарии41

Комментарии 41

Зачем использовать фреймворк DI, который жрет перф и требует правки байт-кода?

Он выполняет примитивную функцию, которую может заменить ServiceLess на несколько строк кода или фабрики.

Фреймворк не требует правки байт-кода, но правка байт-кода его ускоряет – это важная деталь. Про ServiceLess никогда не слышал, даже нагуглить не получается с ходу. Что это за зверь?

https://sergeyteplyakov.blogspot.com/2013/03/di-service-locator.html

Имел в виду ServiceLocator. Примитивная реализация DI, никаких расходов.

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

Так Scout и является ServiceLocator. Сервисы надо хранить в Map и доставать их по какому-то ключу. Это и замедлило наше приложение

Теперь понятно, спасибо.

ServiceLocator это антипаттерн, усложняет unit-тестирование и добавляет лишнюю зависимость на конкретную реализацию DI SL

А как он усложняет Unit тестирование, если в Unit тестировании нету DI

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

Привет. Крутая статья.

А как модификация байткода влияет на время сборки проекта?

В маркете я замерял - получалось секунд 10 на монолитный модуль. А вообще процессор опциональный, его можно выключать для debug сборок.

Нет, так как ключ основан на java.lang.Class в котором нет информации о generic. Можно использовать котлиновский KType, который сохраняет не только дженерики но и даже nullable был тип или нет. Мы когда-то пробовали использовать его, но получали ухудшения по сравнению с обычным классом. С приходом байткод процессора, KType можно так же успешно заменять на int, но в планах такого у нас пока нет.

Понял. Спасибо!

Когда мы вызываем MyClass::class.java, чтобы получить Class, мы триггерим загрузку этого класса в оперативную память.

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

Чтобы получить даже один объект из графа нужно его полностью проинициализировать, иначе непонятно, если ли внутри графа фабрика нужного типа или нет. Dagger это делает при компиляции, а manual DI (в том числе и Scout) делают это в рантайме.

В момент инициализации мы складываем в Map пары Class - Factory. А так как ключ у нас является классом, то он как раз и триггерит class loading

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

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

Нет, далеко не все повытаскиваем.
И лучше как раз сделать быстрый старт, а потом лениво грузить классы - так юзер не заметит лагов

Нет, далеко не все повытаскиваем.

Зачем тогда их вообще было класть туда?

так юзер не заметит лагов

Непонятно. Придут волшебные гномики и сделают загрузку классов мгновенной?

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

Непонятно. Придут волшебные гномики и сделают загрузку классов мгновенной?

Потому что мы размазываем инициализацию на все время пользования приложением. Условно, много просадок по 20мс не ощутится так же как одна просадка в одну секунду.

Условно, много просадок по 20мс не ощутится так же как одна просадка в одну секунду.

Ну это смотря что за приложение. Я не думаю что вам бы понравилось играть в игру, смотреть видео или слушать музыку с рандомными просадками по 20мс.

Нет, я говорю про обычные мобильные приложения.

Если уж приводить аналогию с играми, то какой смысл грузить все уровни на старте, если можно разбить загрузку между уровнями.

Хорошая статья, но главный вопрос по scout остался открытым

Когда полная поддержка KMP? А то koin кажется единственный, кто работает и с kotlin multipltform и даже с compose multiplatform

Обязательно сообщим, когда поддержим kmp. Сейчас это приоритетное направление развития

Благодарю за ответ, а то уже думал начать ковырять kotlin-inject и пытаться его завести на мультиплатформе, а то koin имеет проблему с верификацией
К слову в статье это не указано, но коин тоже имеет возможность тестирования в unit-test

Для верификации Scout достаточно написать один unit-тест и забыть о верификации на долгие месяцы. Koin же требует указывать в тесте каждый новый модуль (либо я не знаю о какой-то важной части этой библиотеки)

Пару вопросов:
Как поддерживается в такой схеме подключения кэширование тасок в Gradle?
Как я понял, у вас реализация на основе BCEL? сравнивали ли с AspectJ ? И как я понимаю в любом случае планируете переписать на KSP ?
И как я понимаю, чтоб добиться бОльшей оптимизации можно было бы использовать SparseArray ?

Как поддерживается в такой схеме подключения кэширование тасок в Gradle?

Процессор выполняется не в отдельной таске, а с помощью doLast на таске компиляции котлина, поэтому кэширование работает само по себе без лишних танцев с бубном (хотя я пытался сначала сделать отдельной таской и ничего не получилось)

Как я понял, у вас реализация на основе BCEL? 

Все верно

Сравнивали ли с AspectJ?

Нет. Даже не задумывался

И как я понимаю в любом случае планируете переписать на KSP?

Нет, а как он нам тут может помочь? Я лично жду выхода Kotlin 2.0, где обещали стабильный API для плагинов на компилятор. Возможно, перепишу потом процессор через него - так мы уйдем от зависимости на систему сборки.

И как я понимаю, чтоб добиться бОльшей оптимизации можно было бы использовать SparseArray?

SparceArray это оптимизация по памяти, но не по скорости, так как он использует бинарный поиск по капотом. Были попытки написать свою мапу, но ее скорость очень сильно зависела от версии JDK и не было очевидно, быстрее она или нет. В итоге пока что оставили HashMap, может потом придумаем, на что можно заменить.

И еще вопрос, получается у вас нельзя расширять скоупы от модуля к модулю в многомодульном проекте gradle? так как константы Int просто так не передашь между подпроектами в монорепе?

Можно. Главное подключить скрипт в корневой build.gradle, о чем в я статье и говорю

Это используется в приложении, поставляемом пользователям?

Да, в Яндекс Маркете под андроид.

Мы для таких целей (не-джава) используем прокси класс-обёртку/функцию или статический метод. У вас так нельзя?

Для каких целей? Не джава это что конкретно?

Планетарка зачетная

Для Class эти функции не такие быстрые, как хотелось бы.

У Class и equals() и hashCode() наследуются от java.lang.Object.
Куда уж быстрее-то?

Прежде чем перейдём к устройству байт-кода, расскажу о том, что такое обратная польская запись (ОПЗ).

Вспоминается анекдот № 301205. Шучу, если что.

System.out.println(1 + 2 * 3);

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

И не инструкцией ldc, а инструкцией bipush.

IMULT

«T» здесь лишнее.

Для этого подойдёт SIPUSH-инструкция: ... Минус в том, что её аргумент ограничен 2 байтами, поэтому значения не могут быть больше 2^15 (32 768)

Я  слышал, что на собеседованиях в Яндекс есть алгоритмическая секция ;]

For short, from -32768 to 32767, inclusive
JLS 17 § 4.2.1

 

Во всей этой магии по замене инструкций нам сильно поможет библиотека BCEL (Byte Code Engineering Library).

BCEL в 2023 году?
Закопайте стюардессу и возьмите хотя бы ASM или модный-молодёжный Byte Buddy.

Она предоставляет очень удобный API

Очень удобный???!!!
Да вы троллите!

Я  слышал, что на собеседованиях в Яндекс есть алгоритмическая секция ;]

И причем тут алгоритмическая секция?

возьмите хотя бы ASM или модный-молодёжный Byte Buddy.

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

Очень удобный???!!!

Я так написал, потому что для текущей задачи, мне нужно было получить массив инструкций (или связанный список), что BCEL мне и предоставил, а предыдущие библиотеки - нет.

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

В ASM есть пакет org.objectweb.asm.tree, который делает именно то, что вы хотите. Он позволяет получить список инструкций байткода в методе, обработать его нужным образом и затем сохранить изменённый class-файл.

NB: Этот пакет нужно подключать в проект как отдельную зависимость, org.ow2.asm:asm-tree:<версия>, в дополнение к основному org.ow2.asm:asm:<версия>.

Для примера можем взять упрощённую задачу из этой статьи. Будем заменять статические вызовы scout.definition.Keys::create(Class<?>) на числовые константы. Константы будем присваивать в порядке появления вызова метода в байткоде.

Hidden text
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.Type;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.InsnList;
import org.objectweb.asm.tree.IntInsnNode;
import org.objectweb.asm.tree.LdcInsnNode;
import org.objectweb.asm.tree.MethodInsnNode;
import org.objectweb.asm.tree.MethodNode;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.HashMap;
import java.util.ListIterator;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;

public class BytecodePatcher {

    static class MethodPatcher extends MethodNode {

        private static final String CLASS_NAME = "scout/definition/Keys";

        private static final String METHOD_NAME = "create";

        private static final String METHOD_DESCRIPTOR = "(Ljava/lang/Class;)I";

        private final MethodVisitor methodVisitor;

        private final AtomicInteger typeCounter;

        private final Map<String, Integer> typeMap;

        public MethodPatcher(
            int api,
            int access,
            String name,
            String descriptor,
            String signature,
            String[] exceptions,
            AtomicInteger typeCounter,
            Map<String, Integer> typeMap,
            MethodVisitor methodVisitor
        ) {
            super(api, access, name, descriptor, signature, exceptions);
            this.methodVisitor = methodVisitor;
            this.typeCounter = typeCounter;
            this.typeMap = typeMap;
        }

        @Override
        public void visitEnd() {
            super.visitEnd();
            patchCode(this.instructions);
            this.accept(this.methodVisitor);
        }

        private void patchCode(InsnList instructions) {
            ListIterator<AbstractInsnNode> iterator = instructions.iterator();
            while (iterator.hasNext()) {
                int sort;
                AbstractInsnNode current = iterator.next();
                if (current instanceof LdcInsnNode ldc && ldc.cst instanceof Type type
                    && ((sort = type.getSort()) == Type.OBJECT || sort == Type.ARRAY)
                    && isFactoryMethodCall(current.getNext())
                ) {
                    iterator.remove();

                    current = iterator.next();
                    assert
                        current instanceof MethodInsnNode
                        : "Internal error: MethodInsnNode expected, but got " + current.getClass() + " instead!";

                    int opcode;
                    int typeIndex = getTypeIndex(type.getDescriptor());
                    if (typeIndex <= Byte.MAX_VALUE) {
                        opcode = Opcodes.BIPUSH;
                    } else if (typeIndex <= Short.MAX_VALUE) {
                        opcode = Opcodes.SIPUSH;
                    } else {
                        throw new RuntimeException("typeIndex overflow: " + typeIndex);
                    }
                    iterator.set(new IntInsnNode(opcode, typeIndex));
                }
            }
        }

        private boolean isFactoryMethodCall(AbstractInsnNode instruction) {
            return
                instruction instanceof MethodInsnNode methodInsn
                && methodInsn.getOpcode() == Opcodes.INVOKESTATIC
                && CLASS_NAME.equals(methodInsn.owner)
                && METHOD_NAME.equals(methodInsn.name)
                && METHOD_DESCRIPTOR.equals(methodInsn.desc);
        }

        private int getTypeIndex(String typeDescriptor) {
            return typeMap.computeIfAbsent(typeDescriptor, unused -> typeCounter.getAndIncrement());
        }
    }

    static class ClassPatcher extends ClassVisitor {

        private final AtomicInteger typeCounter = new AtomicInteger();

        private final Map<String, Integer> typeMap = new HashMap<>();

        protected ClassPatcher(int api, ClassVisitor classVisitor) {
            super(api, classVisitor);
        }

        public Map<String, Integer> getTypeMap() {
            return this.typeMap;
        }

        @Override
        public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
            MethodVisitor methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions);
            return new MethodPatcher(
                this.api, access, name, descriptor, signature, exceptions, this.typeCounter, this.typeMap, methodVisitor
            );
        }

    }

    private static byte[] patch(byte[] classBytes) {
        ClassReader classReader = new ClassReader(classBytes);
        ClassWriter classWriter = new ClassWriter(classReader, 0);
        ClassPatcher classPatcher = new ClassPatcher(Opcodes.ASM9, classWriter);
        classReader.accept(classPatcher, 0);

        dumpTypeMapping(classPatcher.getTypeMap());

        return classWriter.toByteArray();
    }

    private static void dumpTypeMapping(Map<String, Integer> typeMap) {
        System.out.println(" +-------+----------------------------------+");
        System.out.println(" | Index | Type                             |");
        System.out.println(" +-------+----------------------------------+");

        System.out.println(typeMap
            .entrySet()
            .stream()
            .sorted(Comparator.comparingInt(Map.Entry::getValue))
            .map(entry -> String.format(" | % 5d | %-32s |", entry.getValue(), entry.getKey()))
            .collect(Collectors.joining("\n +-------+----------------------------------+\n")));

        System.out.println(" +-------+----------------------------------+");
    }

    public static void main(String... args) throws IOException {
        if (args.length < 1) {
            System.err.println("Usage:");
            System.err.println("  java BytecodePatcher <source class file> [<patched class file>]");
            System.err.println();
            System.exit(-1);
        }

        byte[] classBytes = Files.readAllBytes(Path.of(args[0]));
        byte[] patchedBytes = patch(classBytes);

        if (args.length > 1) {
            Files.write(Path.of(args[1]), patchedBytes);
        } else {
            System.out.println("No output file specified!");
        }
    }
}

Всего полторы сотни строк, не считая импортов и обвязки.

Выглядит, будто пытались уйти от высокопроизводительного DI фреймворка на свой, потому что свой не требует кодогенерации, и в итоге запилили свою кодогенерацию, но в рантайме. А в чём смысл?

У нас нету кодогенерации вообще, тем более в рантайме. Мы заменяем байткод при компиляции - это намного быстрее чем кодогенерация графа. А еще эта штука опциональна, ее можно выключить для дебаг сборок.

А Micronaut не подходит?

Зарегистрируйтесь на Хабре, чтобы оставить комментарий