Привет! Меня зовут Григорий Юрков, и я уже несколько лет работаю в инфраструктурной команде Яндекс Маркета. Два года назад мы начали разрабатывать свой легковесный DI-фреймворк Scout, который предоставляет выразительный Kotlin DSL. Он не генерирует код, а делает всю работу в рантайме.
Недавний переход с compile-time-библиотеки Dagger 2 на нашу привёл к замедлению старта приложения. Подробнее об этом и о том пути, который мы прошли от идеи до публикации в опенсорс, можно прочитать в статье моего коллеги Александра Миронычева.
В этой статье мы будем подробно рассматривать то, как применение байт-кода помогло сохранить скорость на том же уровне и спасти проект по миграции на Scout.
Весь код в этой статье упрощён для понимания. Также не стоит забывать, что библиотека постоянно изменяется и улучшается. Если вы хотите посмотреть настоящий код, который представлен в библиотеке, то прошу в наш GitHub.
Почему стало медленнее
Чтобы вникнуть в проблему, которая возникла перед нами, предлагаю немного познакомиться с библиотекой. Вот так выглядит самый простой пример кода на Scout:
class MyClass
val scope = scope("my-scope") {
factory<String> { "my-string" }
factory<MyClass> { MyClass() }
}
fun main() {
println(scope.get<String>()) // "my-string"
println(scope.get<MyClass>()) // MyClass
}
Мы создаём factory
для двух типов — String
и MyClass
. Эти factory
лежат в общем Scope
под названием my-scope
. С помощью get()
мы можем получить указанные типы, используя зарегистрированные в Scope
фабрики.
Простейшая реализация Scope
выглядела бы так:
typealias Key = Class<*>
object Keys {
@JvmStatic fun create(keyClass: Class<*>): Key = keyClass
}
class Scope {
internal val factories = HashMap<Key, () -> Any?>()
inline fun <reified T> factory(noinline factory: () -> T) {
val key: Key = Keys.create(T::class.java)
factories[key] = factory
}
inline fun <reified T> get() {
val key: Key = Keys.create(T::class.java)
return scope.factories[key].invoke()
}
}
Этот код сильно упрощён, но смысл остаётся тот же: мы просто берем HashMap и по классу складываем туда наши factory, а потом по тому же классу достаём и аллоцируем объект.
Такой код работает сильно медленнее Dagger 2, и тому есть две основные причины:
Мы используем
Class
в качестве ключа.Вызов
T::class.java
триггеритclass loading
.
С первым вроде всё понятно: HashMap
постоянно вызывает hashCode()
и equals()
, чтобы класть и доставать объекты. Для Class
эти функции не такие быстрые, как хотелось бы.
Со вторым всё интереснее. Когда мы вызываем MyClass::class.java
, чтобы получить Class
, мы триггерим загрузку этого класса в оперативную память. Это означает, что Java Virtual Machine (или Android Runtime) нужно прочитать класс из JAR- или DEX-файла и запарсить его. Этот процесс очень долгий, и мы бы хотели его избежать как минимум на этапе создания factory
.
Как сделать быстрее
Мы можем убить двух птиц одним камнем, заменив ключи нашей мапы на Int
. Тогда мы избавимся от HashMap
в пользу чего-то более быстрого, а также избавимся от class loading
. Осталось самое сложное: как заменить Class
на Int
?
Первое, что пришло в голову, — написать плагин на компилятор Kotlin, чтобы он заменял Class
на Int
. Но, во-первых, было совершенно непонятно, как подходить к этой проблеме. Во-вторых, мы опасались, что будем сильно зависеть от версии Kotlin и его внутреннего API, а это непременно доставит нам хлопот в будущем.
Тогда мне пришла в голову идея: а что, если заменитьtypealias Key = Class<>
на typealias Key = Int
и кидать ошибку в функции create
?
Попробуем изменить код:
typealias Key = Int // Замена с Class<*> на Int
object Keys {
@JvmStatic fun create(keyClass: Class<*>): Key {
throw NotImplementedError() // Кидаем ошибку
}
}
// Scope остался неизменным
class Scope {
internal val factories = HashMap<Key, () -> Any?>()
inline fun <reified T> factory(noinline factory: () -> T) {
val key: Key = Keys.create(T::class.java)
factories[key] = factory
}
inline fun <reified T> get() {
val key: Key = Keys.create(T::class.java)
return scope.factories[key].invoke()
}
}
Вы спросите: «А зачем кидать ошибку? Как тогда это должно работать?»
Логика проста: мы напишем процессор, который после компиляции кода в байт-код будет заменять вызовы Key.create
на константы, и исключение не будет кидаться вообще. А если уж оно и произойдёт, то это будет служить маркером того, что процессор отработал неверно.
Теперь посмотрим на байт-код нашего примера, преобразованный в Java-код для наглядности:
class MainKt {
public static final Scope scope = new Scope();
static {
int key;
// Это байт-код для factory<String>
key = Keys.create(String.class); // Сейчас этот метод кинет исключение, если всё оставить как есть
scope.factories.put(key, new Lambda1());
// А это байт-код для factory<MyClass>
key = Keys.create(MyClass.class);
scope.factories.put(key, new Lambda2());
}
public static void main(String[] args) {
int key;
// Это вывод строки
key = Keys.create(String.class);
System.out.println(scope.factories.get(key).invoke());
// А это вывод MyClass
key = Keys.create(MyClass.class);
System.out.println(scope.factories.get(key).invoke());
}
}
Теперь, когда процессор будет проходиться по байт-коду, он будет встречать вызовы типа Keys.create(SomeClass.class)
и заменять их на константы. Условимся, что String
— это 1, а MyClass
— это 2. В итоге получаем следующий изменённый байт-код:
class MainKt {
public static final Scope scope = new Scope();
static {
int key;
// Это байт-код для factory<String>
key = 1;
scope.factories.put(key, new Lambda1());
// А это байт-код для factory<MyClass>
key = 2;
scope.factories.put(key, new Lambda2());
}
public static void main(String[] args) {
int key;
// Это вывод строки
key = 1;
System.out.println(scope.factories.get(key).invoke());
// А это вывод MyClass
key = 2;
System.out.println(scope.factories.get(key).invoke());
}
}
Вызовов метода create
нигде не осталось, так как мы всё заменили на константы. Исключения не кидаются, и всё работает!
А дальше я расскажу, как написать такой процессор и правильно его применять.
Как устроен байт-код
Байт-код — стандартное промежуточное представление, в которое компьютерная программа может быть переведена автоматическими средствами. Так говорит «Википедия». В случае JVM он хранится в .class
-файлах, которые чаще всего можно найти упакованными в JAR-файлы (по сути, это обычный ZIP-архив).
Прежде чем перейдём к устройству байт-кода, расскажу о том, что такое обратная польская запись (ОПЗ). Дело в том, что байт-код работает по такому же принципу. Если вы знаете, что это такое, то можете пропустить эту часть.
Обратная польская запись
Допустим, у нас есть простое математическое выражение:
(1 + 2) * (3 + 4)
Минус такой записи в том, что у разных операторов — разные приоритеты (поэтому и нужны скобки). Также разные операторы могут быть разных типов: префиксными, бинарными, постфиксными. Ещё отдельно от операторов существуют функции. Логично, что в таком виде компьютеру будет сложно вычислять значение выражения. Поэтому представим его в виде ОПЗ:
1 2 + 3 4 + *
Все операторы и функции пишутся после операндов, и теперь у них одинаковый приоритет (поэтому скобки не нужны).
Исполнить такое выражение можно с помощью обычной стековой машины. Алгоритм следующий:
Идём по математическому выражению с начала.
Если встречаем константу,
Tо кладем её в стек.
Иначе, если встречаем оператор или функцию (в ОПЗ это, в принципе, одни и те же вещи),
То достаём из стека нужное количество операндов, выполняем операцию и кладём в стек получившееся значение.
В конце в стеке остаётся наш результат.
Вернёмся к байт-коду.
JVM — это навороченная стековая машина, которая читает подряд инструкции, а потом кладёт в стек или достаёт из него значения. Приведу пример:
System.out.println(1 + 2 * 3);
Напишем байт-код этого Java-кода (он может не полностью соответствовать тому, что генерирует Java). После каждой инструкции я буду выводить стек, чтобы было понятно, как исполняется байт-код.
// Стек: пустой
GETFIELD java.lang.System.out // Загружаем значение из статического поля в стек
// Стек: PrintStream
LDC 2 // Загружаем константы в стек
LDC 3
// Стек: PrintStream, 2, 3
IMULT // Выполняем умножение и кладём результат в стек
// Стек: PrintStream, 6
LDC 1 // Загружаем единичку
// Стек: PrintStream, 6, 1
IADD // Выполняем сложение
// Стек: PrintStream, 7
INVOKEVIRTUAL println // Вызываем нестатический метод println на объекте PrintStream с аргументом 7
// Стек остался пустым, а в консоли напечаталось число 7
Этот код познакомил нас со следующими базовыми инструкциями:
LDC
— загрузка константы в стек.IMULT
,IADD
— операции сложения и умножения. БукваI
в начале означает операцию над целыми числами. Для double есть аналогичные с буквойD
.INVOKEVIRTUAL
— вызов нестатического метода. Для вызова статического используетсяINVOKESTATIC
.GETFIELD
— получение нестатического поля. Для статического естьGETSTATIC
.
Также хочется отметить такие немаловажные инструкции, как LOAD
и STORE
. Первая загружает переменную в стек, а вторая выгружает из стека в переменную.
Многие из инструкций используют пул констант (не путайте с пулом строк — у них вообще разные назначения). Это обычная таблица, которая содержит в себе все константы текущего класса. Сюда можно включить строки, числа, ссылки на методы, ссылки на классы.
Например, INVOKESTATIC 42
означает, что в пуле констант под номером 42 лежит ссылка на метод. Так JVM определяет, какой метод нужно вызвать. Для простоты обычно сразу пишут название метода, чтобы не нужно было лезть в пул констант.
Такая же история и с LDC
. У неё тоже единственный аргумент — индекс на пул констант, по которому она сможет определить, какую константу загрузить в стек.
Этих базовых знаний будет достаточно, чтобы написать наш процессор.
Как пропатчить байт-код
После того как мы разобрались с устройством байт-кода, нужно понять, какие инструкции нам надо заменить. Итак, у нас есть следующий код:
Keys.create(SomeClass.class);
Если посмотреть на байт-код, то тут Java или Kotlin генерируют всего две инструкции:
LDC SomeClass
INVOKESTATIC Keys.create
В LDC
передаётся ссылка на класс, поэтому она загружает в стек наш Class
-объект, а INVOKESTATIC
просто вызывает метод create
. Вместо этого мы хотим загрузить обычную Int
-константу. Для этого подойдёт SIPUSH
-инструкция:
SIPUSH 42
Это такая же инструкция, как и LDC
, которая загружает константу в стек, но она не использует ссылку на пул констант, а сразу же после себя содержит значение. Минус в том, что её аргумент ограничен 2 байтами, поэтому значения не могут быть больше 2^15 (32 768), но нам пока этого хватит.
Во всей этой магии по замене инструкций нам сильно поможет библиотека BCEL (Byte Code Engineering Library). Она предоставляет очень удобный API, поэтому я выбрал именно её. Сначала напишем метод, который пробежится по всем методам класса и достанет оттуда связанный список инструкций:
fun modifyClass(file: File) {
// Парсим .class-файл
val parser = ClassParser(file.path)
val javaClass = parser.parse()
val classGen = ClassGen(javaClass)
// Получаем пул констант
val constantPoolGen = classGen.constantPool
for (method in classGen.methods) {
val methodGen = MethodGen(method, classGen.className, constantPoolGen)
modifyMethod(methodGen.instructionList, constantPoolGen)
// Сохраняем изменения в методе
classGen.replaceMethod(method, methodGen.method)
}
// Сохраняем изменения в классе
classGen.javaClass.dump(FileOutputStream(file))
}
Теперь реализуем modifyMethod()
, который будет заменять инструкции:
fun modifyMethod(
list: InstructionList,
constantPoolGen: ConstantPoolGen
) {
var instruction: InstructionHandle? = null
while (true) {
// Перебираем все инструкции — в этой библиотеке они представлены связанным списком
instruction = (if (instruction == null) list.start else instruction.next) ?: break
// Первая инструкция — это LDC
val ldc = instruction.instruction as? LDC ?: continue
// Убеждаемся, что LDC-инструкция загружает константу класса
val objectType = ldc.getValue(constantPoolGen) as? ObjectType ?: continue
// Узнаём имя этого класса
val className = objectType.className ?: continue
// Следующей инструкцией должна быть INVOKESTATIC
val invokestatic = instruction.next?.instruction as? INVOKESTATIC ?: continue
// Убеждаемся, что мы вызываем метод Keys.create(Class<*>): Int
if (invokestatic.getLoadClassType(constantPoolGen).className != "Keys") continue
if (invokestatic.getName(constantPoolGen) != "create") continue
if (invokestatic.getSignature(constantPoolGen) != "(Ljava/lang/Class)I") continue
// Сигнатура (Ljava/lang/Class)I означает, что метод принимает один аргумент с типом Class, а возвращает Int
// Определяем индекс для нашего класса: просто берём следующий свободный
var index = indexMap[className]
if (index == null) {
index = indexMap.size
indexMap[className] = index // Сохраняем индекс под текущее имя класса
}
// Удаляем инструкцию LDC, заменяем её на инструкцию NOP, которая ничего не делает
instruction.instruction = InstructionConst.NOP
// Заменяем INVOKESTATIC-инструкцию на SIPUSH, которая будет пушить в стек наш индекс
instruction.next.instruction = SIPUSH(index.toShort())
}
}
Наш процессор готов!
Однако теперь возникает вопрос: откуда нам взять .class
-файлы для их изменения? С JVM всё просто: JAR-файл содержит нужные .class
-файлы. А что касается Android, то внутри APK у нас лежат .dex
-файлы. Это тоже байт-код, но не Java, а Dalvik. Он компилируется из байт-кода Java, и в этом случае нам нужно вставить процессор перед этим шагом.
Как интегрировать процессор в систему сборки
Поскольку мы, как и многие разработчики, используем систему сборки Gradle, внедрим наш процессор именно в неё.
Чтобы начать работу с Gradle, достаточно понять: основная единица работы в нём — это Task
. Таски могут быть связаны: например, задача компиляции байт-кода Dalvik зависит от задачи компиляции Kotlin в байт-код Java. Все задачи в Gradle объединены в проекты, но мы их называем просто модулями.
Акцентируем внимание на таске compileKotlin
. Он обрабатывает наши .kt
-файлы (исходники Kotlin), создавая .class
-файлы (байт-код JVM), а это именно то, что нам нужно.
В корневом файле build.gradle.kts
добавим следующий код:
allprojects {
// Проходимся по всем таскам всех проектов.
tasks.configureEach {
// Отфильтровываем все таски с названием compileKotlin
if (name == "compileKotlin") {
// doLast вызывает лямбду сразу после того,
// как таск compileKotlin закончит своё выполнение
doLast {
// Проходимся по всем выходным папкам таска compileKotlin
// Там и должны лежать наши .class-файлы
outputs.files.forEach { output ->
output.walk()
.filter { file -> file.isFile && file.extension == "class" } // Фильтруем все .class-файлы
.forEach { file ->
// Изменяем наши классы методом, который мы описали ранее
// Этот метод должен лежать где-то в модуле buildSrc, чтобы он был виден во всех Gradle-скриптах
modifyClass(file)
}
}
}
}
}
}
Теперь наш процессор будет запускаться всегда, когда мы компилируем код Kotlin. Неважно, для чего нам это понадобится — для компиляции Android или JVM-приложения, — он всегда будет запускаться и модифицировать байт-код.
Результаты
Вы спросите, как наши изменения повлияли на скорость?
Это скриншот перформанс-тестов Android-приложения Яндекс Маркета. После переноса всего проекта на Int-ключи мы вернулись к прежним значениям времени старта приложения. На скриншоте также видно предыдущую попытку ускорения, о которой можно прочитать в первой статье про Scout.
Работа с байт-кодом оказалась увлекательным занятием. Он достаточно прост для внесения изменений и не так страшен, как чистый ассемблер. JVM проверяет байт-код и в случае ошибки в генерации выдаёт исключение VerifyError
с детальным описанием возникшей проблемы.
Я надеюсь, что этот материал поможет вам впоследствии рассматривать свои задачи через призму байт-кода, который, как видно на нашем примере, может оказаться весьма полезным инструментом.
Комментарии (36)
alexejisma
01.11.2023 07:25Работает ли scout корректно с generic классами?
rpuxaa1 Автор
01.11.2023 07:25Нет, так как ключ основан на
java.lang.Class
в котором нет информации о generic. Можно использовать котлиновскийKType
, который сохраняет не только дженерики но и даже nullable был тип или нет. Мы когда-то пробовали использовать его, но получали ухудшения по сравнению с обычным классом. С приходом байткод процессора,KType
можно так же успешно заменять наint
, но в планах такого у нас пока нет.
slonopotamus
01.11.2023 07:25Когда мы вызываем MyClass::class.java, чтобы получить Class, мы триггерим загрузку этого класса в оперативную память.
Непонятно. Зачем регистрировать а DI классы, которые вы не будете загружать? Или вы хотите избежать их загрузки на старте, а вместо этого получать фризы в рандомные моменты работы приложения?
rpuxaa1 Автор
01.11.2023 07:25Чтобы получить даже один объект из графа нужно его полностью проинициализировать, иначе непонятно, если ли внутри графа фабрика нужного типа или нет. Dagger это делает при компиляции, а manual DI (в том числе и Scout) делают это в рантайме.
В момент инициализации мы складываем вMap
парыClass
-Factory
. А так как ключ у нас является классом, то он как раз и триггерит class loadingslonopotamus
01.11.2023 07:25Ну вот я и спрашиваю. Вы ж в процессе работы приложения рано или поздно из графа все объекты повытаскиваете. И рано или поздно потратите время на загрузку классов. Но либо это происходит на старте, когда юзер готов к тому что "падажжите, мы грузимся", либо это будет происходить посреди работы приложения и делать фризы.
olku
01.11.2023 07:25DI это ленивый паттерн. Мы же теперь транзитивных зависимостей столько в проект затаскиваем, что не дай бог они все инстанцируются.
rpuxaa1 Автор
01.11.2023 07:25Нет, далеко не все повытаскиваем.
И лучше как раз сделать быстрый старт, а потом лениво грузить классы - так юзер не заметит лаговslonopotamus
01.11.2023 07:25Нет, далеко не все повытаскиваем.
Зачем тогда их вообще было класть туда?
так юзер не заметит лагов
Непонятно. Придут волшебные гномики и сделают загрузку классов мгновенной?
rpuxaa1 Автор
01.11.2023 07:25Ну так юзер может использовать не все фичи приложения, например. Поэтому и не понадобятся все зависимости.
Непонятно. Придут волшебные гномики и сделают загрузку классов мгновенной?
Потому что мы размазываем инициализацию на все время пользования приложением. Условно, много просадок по 20мс не ощутится так же как одна просадка в одну секунду.
slonopotamus
01.11.2023 07:25Условно, много просадок по 20мс не ощутится так же как одна просадка в одну секунду.
Ну это смотря что за приложение. Я не думаю что вам бы понравилось играть в игру, смотреть видео или слушать музыку с рандомными просадками по 20мс.
Lucker216
01.11.2023 07:25Хорошая статья, но главный вопрос по scout остался открытым
Когда полная поддержка KMP? А то koin кажется единственный, кто работает и с kotlin multipltform и даже с compose multiplatform
acelost
01.11.2023 07:25+1Обязательно сообщим, когда поддержим kmp. Сейчас это приоритетное направление развития
Lucker216
01.11.2023 07:25Благодарю за ответ, а то уже думал начать ковырять kotlin-inject и пытаться его завести на мультиплатформе, а то koin имеет проблему с верификацией
К слову в статье это не указано, но коин тоже имеет возможность тестирования в unit-testacelost
01.11.2023 07:25Для верификации Scout достаточно написать один unit-тест и забыть о верификации на долгие месяцы. Koin же требует указывать в тесте каждый новый модуль (либо я не знаю о какой-то важной части этой библиотеки)
klee0kai
01.11.2023 07:25Пару вопросов:
Как поддерживается в такой схеме подключения кэширование тасок в Gradle?
Как я понял, у вас реализация на основе BCEL? сравнивали ли с AspectJ ? И как я понимаю в любом случае планируете переписать на KSP ?
И как я понимаю, чтоб добиться бОльшей оптимизации можно было бы использовать SparseArray ?rpuxaa1 Автор
01.11.2023 07:25Как поддерживается в такой схеме подключения кэширование тасок в Gradle?
Процессор выполняется не в отдельной таске, а с помощью
doLast
на таске компиляции котлина, поэтому кэширование работает само по себе без лишних танцев с бубном (хотя я пытался сначала сделать отдельной таской и ничего не получилось)Как я понял, у вас реализация на основе BCEL?
Все верно
Сравнивали ли с AspectJ?
Нет. Даже не задумывался
И как я понимаю в любом случае планируете переписать на KSP?
Нет, а как он нам тут может помочь? Я лично жду выхода Kotlin 2.0, где обещали стабильный API для плагинов на компилятор. Возможно, перепишу потом процессор через него - так мы уйдем от зависимости на систему сборки.
И как я понимаю, чтоб добиться бОльшей оптимизации можно было бы использовать SparseArray?
SparceArray
это оптимизация по памяти, но не по скорости, так как он использует бинарный поиск по капотом. Были попытки написать свою мапу, но ее скорость очень сильно зависела от версии JDK и не было очевидно, быстрее она или нет. В итоге пока что оставили HashMap, может потом придумаем, на что можно заменить.
klee0kai
01.11.2023 07:25И еще вопрос, получается у вас нельзя расширять скоупы от модуля к модулю в многомодульном проекте gradle? так как константы Int просто так не передашь между подпроектами в монорепе?
rpuxaa1 Автор
01.11.2023 07:25Можно. Главное подключить скрипт в корневой
build.gradle
, о чем в я статье и говорю
amberovsky
01.11.2023 07:25Мы для таких целей (не-джава) используем прокси класс-обёртку/функцию или статический метод. У вас так нельзя?
Maccimo
01.11.2023 07:25Для
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
Очень удобный???!!!
Да вы троллите!rpuxaa1 Автор
01.11.2023 07:25Я слышал, что на собеседованиях в Яндекс есть алгоритмическая секция ;]
И причем тут алгоритмическая секция?
возьмите хотя бы ASM или модный-молодёжный Byte Buddy.
Я пытался сначала сделать через них, но я не нашел удобных инструментов для замены байткода.
Очень удобный???!!!
Я так написал, потому что для текущей задачи, мне нужно было получить массив инструкций (или связанный список), что BCEL мне и предоставил, а предыдущие библиотеки - нет.
arTk_ev
Зачем использовать фреймворк DI, который жрет перф и требует правки байт-кода?
Он выполняет примитивную функцию, которую может заменить ServiceLess на несколько строк кода или фабрики.
acelost
Фреймворк не требует правки байт-кода, но правка байт-кода его ускоряет – это важная деталь. Про ServiceLess никогда не слышал, даже нагуглить не получается с ходу. Что это за зверь?
arTk_ev
https://sergeyteplyakov.blogspot.com/2013/03/di-service-locator.html
Имел в виду ServiceLocator. Примитивная реализация DI, никаких расходов.
Просто ни разу в практике не приходилось встречаться с задачами, где бы понадобилась сложная реализация DI со сложными зависимостями.
rpuxaa1 Автор
Так Scout и является ServiceLocator. Сервисы надо хранить в
Map
и доставать их по какому-то ключу. Это и замедлило наше приложениеarTk_ev
Теперь понятно, спасибо.
develmax
ServiceLocator это антипаттерн, усложняет unit-тестирование и добавляет лишнюю зависимость на конкретную реализацию DI SL
rpuxaa1 Автор
А как он усложняет Unit тестирование, если в Unit тестировании нету DI
develmax
То что нужно перед запуском теста подготовить этот SL, в зависимости от того как написан код, SL может создавать проблемы или делать невозможным параллельное выполнение тестов.