Внесу и свой вклад в тренд темного программирования.
Многим из вас знакома дилемма: использовать ли DI в своем проекте или нет.
Поводы перехода на DI:
- создание развитой системы авто-тестов
- повторное использование кода в различном окружении, в том числе в различных проектах
- использование 3rd-party библиотек, построенных на DI
- изучение DI
Доводы не использовать DI:
- усложнение понимания кода (поначалу)
- необходимость конфигурирования контекста
- изучение DI
Допустим, у нас есть большой рабочий проект, принято решение: переводить на DI. Разработчики чувствуют свой потенциал, уровень мидихлориан в крови зашкаливает.
Путь тебя ждет тернистый и долгий, мой юный падаван.
Если проект большой и в нем много разработчиков, одним коммитом вряд ли удастся сделать такой рефакторинг. Поэтому мы используем несколько плохих практик, упростив переход, а затем от них избавимся.
С чего начать
DI имеет замечательную особенность вскрывать архитектурные косяки в коде, поэтому есть смысл провести подготовительную работу. В концепции DI все классы условно можно разделить на две категории – назовем их сервисами и бинами. Первые существуют как правило в единственном экземпляре в рамках контекста и привязаны к нему. Вторые хранят в себе сами обрабатываемые данные и могут ссылаться на другие бины, но не на сервисы. Иногда бывают смешанные вариации:
import org.jetbrains.annotations.Nullable;
public class Jedi {
private long id;
private String name;
@Nullable
private Long masterId;
// fields, constructors, getters/setters, equals, hashCode, toString, etc...
public long getId() {
return id;
}
public String getName() {
return name;
}
@Nullable
public Long getMasterId() {
return masterId;
}
@Nullable
public Jedi getMaster() {
if (masterId == null) {
return null;
}
return DBJedi.getJedi(masterId);
}
}
Порядочный Джедай метод getMaster уберет вообще или же перенесет в другой класс (сервис). В итоге класс Jedi станет просто бином с данными. Если перенос метода по какой-либо причине сейчас невозможен (например, от него зависит код, недоступный для рефакторинга), можно его объявить deprecated и пока что оставить (как вариант – объявить версию, в которой этот метод будет удален, как это делают разработчики Guava).
Теперь разберемся с DBJedi:
public class DBJedi {
public static Jedi getJedi(long id) {
DataSource dataSource = ConnectionPools.getDataSource("jedi");
Jedi jedi;
// magic
return jedi;
}
}
Подобный класс логично переделать в классический singleton, например, так:
import javax.sql.DataSource;
public class DBJedi {
private static final DBJedi instance = new DBJedi();
private final ConnectionPools connectionPools;
private DBJedi() {
this.connectionPools = ConnectionPools.getInstance();
}
public static DBJedi getInstance() {
return instance;
}
public Jedi getJedi(long id) {
DataSource dataSource = connectionPools.getDataSource("jedi");
Jedi jedi;
// magic
return jedi;
}
}
В результате мы получим более стройную и читаемую структуру кода (весьма спорный факт, конечно). Если довести начатое до конца, в целом переход на DI можно сделать по стандартным гайдам.
Но если Вы — Ситх, то наверняка остались классы (в нашем примере – класс Jedi с методом getMaster), которые по-хорошему не переводятся стандартным способом.
Теперь нужно еще раз подумать о целесообразности прикручивания DI. Если желание все же осталось — продолжаем.
Примеры будут преимущественно на Guice, частично продублированы на Spring. Насчет выбора фреймворка — выбирайте тот, который лучше знаете.
Плохая практика 1 – сохраняем статическую ссылку на Injector
В какой-то момент встанет вопрос – где взять инстанс инжектора, чтобы вытащить синглтоны? Заведем утилитный класс:
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.TestOnly;
import com.google.inject.Injector;
// import com.google.common.base.Preconditions; // guava
public class InjectorUtil {
private static volatile Injector injector;
public static void setInjector(@NotNull Injector injector) {
// Preconditions.checkNotNull(injector);
// Preconditions.checkState(InjectorUtil.injector == null, "Injector already initialized");
InjectorUtil.injector = injector;
}
@TestOnly
public static void rewriteInjector(@NotNull Injector injector) {
// Preconditions.checkNotNull(injector);
InjectorUtil.injector = injector;
}
@Deprecated // use fair injection, Sith!
@NotNull
public static Injector getInjector() {
// Preconditions.checkState(InjectorUtil.injector != null, "Injector not initialized");
return InjectorUtil.injector;
}
}
Для Spring код будет аналогичен, только вместо Injector — ApplicationContext. Либо еще один вариант:
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import javax.inject.Named;
@Named
public class ApplicationContextUtil implements ApplicationContextAware {
private static volatile ApplicationContext applicationContext;
public void setApplicationContext(ApplicationContext applicationContext) {
ApplicationContextUtil.applicationContext = applicationContext;
}
@Deprecated
public static ApplicationContext getApplicationContext() {
// Preconditions.checkState(applicationContext != null);
return applicationContext;
}
}
Теперь наши синглтоны можно переписать так:
@javax.inject.Singleton
@javax.inject.Named // пригодится для Spring component-scan, в Guice не требуется
public class DBJedi {
private final ConnectionPools connectionPools;
@javax.inject.Inject
public DBJedi(ConnectionPools connectionPools) {
this.connectionPools = connectionPools;
}
@Deprecated
public static DBJedi getInstance() {
return InjectorUtil.getInjector().getInstance(DBJedi.class);
}
public Jedi getJedi(long id) {
DataSource dataSource = connectionPools.getDataSource("jedi");
Jedi jedi;
// ...
return jedi;
}
}
Обращаю внимание, что используются аннотации JSR-330, пакет javax.inject. Используя их, можно впоследствии с большей легкостью перейти с одного DI на другой, в идеальном случае — вообще абстрагироваться от конкретного фреймворка (при условии JSR-330-совместимости). Аннотация Named позволит не делать запись bean в spring-context.xml, если в xml-конфигурации все-таки подразумевается такая запись, аннотацию следует убрать.
Плохая практика 2 – Bean Factory
Если класс является бином с данными, но при этом обращается к singleton-объектам, можно сделать класс-фабрику:
public class Jedi {
private long id;
private String name;
@Nullable
private Long masterId;
private final DBJedi dbJedi;
private Jedi(long id, String name, @Nullable masterId, DBJedi dbJedi) {
this.id = id;
this.name = name;
this.masterId = masterId;
this.dbJedi = dbJedi;
}
//...
public long getId() {
return id;
}
public String getName() {
return name;
}
@Nullable
public Long getMasterId() {
return masterId;
}
@Nullable
public Jedi getMaster() {
if (masterId == null) {
return null;
}
return dbJedi.getJedi(masterId);
}
@Singleton
@Named
public static class Factory {
private final DBJedi dbJedi;
@Inject
public Factory(DBJedi dbJedi) {
this.dbJedi = dbJedi;
}
@Deprecated // refactor Jedi class to simple bean, Sith!
public Jedi create(long id, String name, @Nullable masterId) {
return new Jedi(id, name, masterId, dbJedi);
}
}
}
Плохая практика 3 — циклические зависимости
В нашем примере между классами DBJedi и Jedi.Factory образуется циклическая зависимость. При попытке создать эти объекты в runtime мы получим ошибку DI-контейнера, например, StackOverflowError. Тут на помощь приходит интерфейс Provider:
import javax.inject.Singleton;
import javax.inject.Named;
import javax.inject.Inject;
import javax.inject.Provider;
import javax.sql.DataSource;
@Singleton
@Named
public class DBJedi {
private final ConnectionPools connectionPools;
private final Provider<Jedi.Factory> jediFactoryProvider;
@Inject
public DBJedi(ConnectionPools connectionPools, Provider<Jedi.Factory> jediFactoryProvider) {
this.connectionPools = connectionPools;
this.jediFactoryProvider = jediFactoryProvider;
}
@Deprecated
public static DBJedi getInstance() {
return InjectorUtil.getInjector().getInstance(DBJedi.class);
}
public Jedi getJedi(long id) {
DataSource dataSource = connectionPools.getDataSource("jedi");
// ...
final Jedi.Factory jediFactory = jediFactoryProvider.get();
return jediFactory.create(id, name, masterId);
}
}
Верно отметить, что generic-декларации недоступны посредством Reflection. Что касается Guice и Spring, они оба читают байт-код класса и таким образом получают generic-тип.
Пишем тесты
В testng есть замечательная аннотация Guice, упрощающая тестирование кода. Для Spring — артефакт org.springframework:spring-test.
Сделаем тест для наших классов:
import org.testng.annotations.*;
import com.google.inject.Injector;
import com.google.inject.AbstractModule;
@Guice(modules = JediTest.JediTestModule.class)
public class JediTest {
private static final long JEDI_QUI_GON_ID = 12;
private static final long JEDI_OBI_WAN_KENOBI_ID = 22;
@Inject
private Injector injector;
@Inject
private DBJedi dbJedi;
@BeforeClass
public void setInjector() {
InjectorUtil.rewriteInjector(injector);
}
@Test
public void testJedi() {
final Jedi obiWan = dbJedi.getJedi(JEDI_OBI_WAN_KENOBI_ID);
final Jedi master = obiWan.getMaster();
Assert.assertEquals(master.getId(), JEDI_QUI_GON_ID);
}
public static class JediTestModule extends AbstractModule {
@Override
public void configure() {
// реализация ConnectionPools опущена, т.к. эту цепочку можно продолжать бесконечно
bind(ConnectionPools.class).toInstance(new ConnectionPools("pools.properties"));
}
}
}
Что в итоге
А в итоге у нас возможны два исхода. Первый — остановиться на достигнутом. Так случилось в одном из моих проектов, целиком его перевести на честный DI не удалось, в нем было много legacy-кода. Думаю, эта ситуация знакома многим. Можно немного ее улучшить, например, заменив статическое поле в InjectorUtil на ThreadLocal, таким образом решив проблему concurrent-тестирования с разным DI-окружением в одном статическом пространстве.
public class InjectorUtil {
private static final ThreadLocal<Injector> threadLocalInjector =
new InheritableThreadLocal<Injector>();
private InjectorUtil() {
}
/**
* Get thread local injector for current thread
*
* @return
* @throws IllegalStateException if not set
*/
@NotNull
public static Injector getInjector() throws IllegalStateException {
final Injector Injector = threadLocalInjector.get();
if (Injector == null) {
throw new IllegalStateException("Injector not set for current thread");
}
return Injector;
}
/**
* Set Injector for current thread
*
* @param Injector
* @throws java.lang.IllegalStateException if already set
*/
public static void setInjector(@NotNull Injector injector) throws IllegalStateException {
if (injector == null) {
throw new NullPointerException();
}
if (threadLocalInjector.get() != null) {
throw new IllegalStateException("Injector already set for current thread");
}
threadLocalInjector.set(injector);
}
/**
* Rewrite Injector for current thread, even if already set
*
* @param injector
* @return previous value if was set
*/
public static Injector rewriteInjector(@NotNull Injector injector) {
if (injector == null) {
throw new NullPointerException();
}
final Injector prevInjector = threadLocalInjector.get();
threadLocalInjector.set(injector);
return prevInjector;
}
/**
* Remove Injector from thread local
*
* @return Injector if was set, else null
*/
public static Injector removeInjector() {
final Injector prevInjector = threadLocalInjector.get();
threadLocalInjector.remove();
return prevInjector;
}
}
Второй — довести дело до конца. В нашем примере сначала избавимся от метода Jedi.getMaster, тогда Jedi превратится в простой bean. После этого убираем класс Jedi.Factory. Исчезнет и циклическая зависимость. В итоге не станет и самого класса InjectorUtil. Проекты без такого класса — реальность. Не обязательно проходить все эти этапы, но, напомню, мы говорим про ситуацию именно legacy-проекта, в новом проекте такой проблемы можно избежать с самого начала.
На самом деле, и это еще не все. Если проект, который вы переводите на DI — общая библиотека, есть смысл абстрагироваться и от самого DI, но это уже тема отдельного поста.
May the --force be with you.
Автор: seregamorph