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

Перевод legacy-проекта на Dependency Injection. Путь Ситха

Время на прочтение8 мин
Количество просмотров13K
Внесу и свой вклад в тренд темного программирования.
Многим из вас знакома дилемма: использовать ли 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.
Теги:
Хабы:
Всего голосов 29: ↑23 и ↓6+17
Комментарии6

Публикации