Статьи

Инъекция зависимости: синтаксический сахар по функциональному составу

Цитата Dependency Injection Демистифицирована :

«Внедрение зависимостей» — это понятие за 5 центов стоимостью 25 долларов.
* Джеймс Шор, 22 марта 2006 г.

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

Сеттеры, аннотации и конструкторы

Spring bean или EJB — это объект Java. Однако, если вы посмотрите внимательно, большинство бинов фактически не сохраняют состояния после создания. Вызов методов в bean-компоненте Spring редко изменяет состояние этого bean-компонента. В большинстве случаев bean-компоненты — это просто удобные пространства имен для множества процедур, работающих в аналогичном контексте. Мы не изменяем состояние CustomerService при вызове invoice() , мы просто делегируем другому объекту, который в конечном итоге вызовет базу данных или веб-сервис. Это уже далеко от объектно-ориентированного программирования (о чем я здесь говорил). Таким образом, по сути, у нас есть процедуры (мы перейдем к функциям позже) в многоуровневой иерархии пространств имен: пакеты и классы, к которым они принадлежат. Обычно эти процедуры вызывают другие процедуры. Вы можете сказать, что они вызывают методы для зависимостей bean-компонентов, но мы уже узнали, что bean-компоненты — ложь, это всего лишь группы процедур.

При этом давайте посмотрим, как вы можете настроить bean-компоненты. В моей карьере у меня были эпизоды с сеттерами (и тоннами <property name="..."> в XML), @Autowired на полях и, наконец, внедрение в конструктор. Читайте также: Почему инъекция по конструктору должна быть предпочтительной? , Так что у нас обычно есть объект, который имеет неизменные ссылки на свои зависимости:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
Hello Rajeev, @Component
class PaymentProcessor {
  
    private final Parser parser;
    private final Storage storage;
  
    @Autowired
    public PaymentProcessor(Parser parser, Storage storage) {
        this.parser = parser;
        this.storage = storage;
    }
  
    void importFile(Path statementFile) throws IOException {
            try(Stream<string> lines = Files.lines(statementFile)) {
                lines
                        .map(parser::toPayment)
                        .forEach(storage::save);
            }
    }
  
}
  
  
@Component
class Parser {
    Payment toPayment(String line) {
        //om-nom-nom...
    }
}
  
  
@Component
class Storage {
  
    private final Database database;
  
    @Autowired
    public Storage(Database database) {
        this.database = database;
    }
  
    public UUID save(Payment payment) {
        return this.database.insert(payment);
    }
}
  
  
class Payment {
    //...
}</string>

Возьмите файл с банковскими выписками, проанализируйте каждую отдельную строку в объекте Payment и сохраните его. Как скучно, как вы можете получить. Теперь давайте немного проведем рефакторинг. Прежде всего, я надеюсь, вы знаете, что объектно-ориентированное программирование — это ложь. Не потому, что это просто набор процедур в пространствах имен так называемых классов (надеюсь, вы не пишете программное обеспечение таким образом). Но поскольку объекты реализованы как процедуры с неявным параметром this , когда вы видите: this.database.insert(payment) он фактически скомпилирован во что-то вроде этого: Database.insert(this.database, payment) . Не веришь мне?

1
2
3
4
5
6
7
8
9
$ javap -c Storage.class
...
  public java.util.UUID save(com.nurkiewicz.di.Payment);
    Code:
       0: aload_0
       1: getfield      #2                  // Field database:Lcom/nurkiewicz/di/Database;
       4: aload_1
       5: invokevirtual #3                  // Method com/nurkiewicz/di/Database.insert:(Lcom/nurkiewicz/di/Payment;)Ljava/util/UUID;
       8: areturn

Хорошо, если вы нормальный человек, это не доказательство для вас, поэтому позвольте мне объяснить. aload_0 (представляющий this ) с последующим getfield #2 this.database в стек операндов. aload_1 первый параметр метода ( Payment ) и, наконец, вызывает invokevirtual процедуры Database.insert (здесь присутствует некоторый полиморфизм, не относящийся к данному контексту). Таким образом, мы фактически вызвали двухпараметрическую процедуру, в которой первый параметр автоматически заполнялся компилятором и назывался… this . На стороне вызываемого this допустимо и указывает на экземпляр Database .

Забудь об объектах

Давайте сделаем все это более явным и забудем об объектах:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
class ImportDependencies {
  
    public final Parser parser;
    public final Storage storage;
      
    //...
  
}
  
static void importFile(ImportDependencies thiz, Path statementFile) throws IOException {
    Files.lines(statementFile)
            .map(thiz.parser::toPayment)
            .forEach(thiz.storage::save);
}

Это безумие! Обратите внимание, что процедура importFile теперь находится за пределами PaymentProcessor , который я фактически переименовал в ImportDependencies (простите за public модификатор для полей). importFile может быть static потому что все зависимости явно указаны в thiz контейнере, не подразумеваются с помощью переменных this и instance — и могут быть реализованы где угодно. На самом деле мы просто переделали то, что уже происходит за кулисами во время компиляции. На этом этапе вы можете задаться вопросом, зачем нам нужен дополнительный контейнер для зависимостей, а не просто передавать их напрямую. Конечно, это бессмысленно

1
2
3
4
5
static void importFile(Parser parser, Storage storage, Path statementFile) throws IOException {
    Files.lines(statementFile)
            .map(parser::toPayment)
            .forEach(storage::save);
}

На самом деле, некоторые люди предпочитают передавать зависимости явно бизнес-методам, как описано выше, но это не главное. Это просто еще один шаг в трансформации.

Карринг

Для следующего шага нам нужно переписать нашу функцию в Scala:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
object PaymentProcessor {
  
  def importFile(parser: Parser, storage: Storage, statementFile: Path) {
    val source = scala.io.Source.fromFile(statementFile.toFile)
    try {
      source.getLines()
        .map(parser.toPayment)
        .foreach(storage.save)
    } finally {
      source.close()
    }
  }
  
}

Это функционально эквивалентно, так что не так много, чтобы сказать. Просто обратите внимание на то, как importFile() принадлежит object , так что он чем-то похож на static методы в singleton в Java. Далее мы сгруппируем параметры :

1
def importFile(parser: Parser, storage: Storage)(statementFile: Path) { //...

Это имеет все значение. Теперь вы можете указать все зависимости постоянно или лучше, сделайте это только один раз:

1
2
3
4
5
val importFileFun: (Path) => Unit = importFile(parser, storage)
  
//...
  
importFileFun(Paths.get("/some/path"))

Строка выше может фактически быть частью настройки контейнера, где мы связываем все зависимости вместе. После установки мы можем использовать importFileFun где угодно, не зная о других зависимостях. Все, что у нас есть, это функция (Path) => Unit , как в paymentProcessor.importFile(path) в самом начале.

Функции полностью вниз

Мы по-прежнему используем объекты как зависимости, но если вы посмотрите внимательно, нам не нужны ни parser ни storage . Что нам действительно нужно, так это функция , которая может анализировать ( parser.toPayment ), и функция, которая может хранить ( storage.save ). Давайте снова проведем рефакторинг:

01
02
03
04
05
06
07
08
09
10
def importFile(parserFun: String => Payment, storageFun: Payment => Unit)(statementFile: Path) {
  val source = scala.io.Source.fromFile(statementFile.toFile)
  try {
    source.getLines()
      .map(parserFun)
      .foreach(storageFun)
  } finally {
    source.close()
  }
}

Конечно, мы можем сделать то же самое с Java 8 и лямбдами, но синтаксис более многословен. Мы можем предоставить любую функцию для анализа и хранения, например, в тестах мы можем легко создавать заглушки. Да, и кстати, мы только что превратились из объектно-ориентированной Java в функциональную композицию, а не в объекты вообще. Конечно, есть еще побочные эффекты, например, загрузка файла и сохранение, но давайте оставим это так. Или, чтобы сделать сходство между внедрением зависимостей и составом функций еще более поразительным, посмотрите эквивалентную программу на Haskell:

1
2
3
4
5
6
7
let parseFun :: String -> Payment
let storageFun :: Payment -> IO ()
let importFile :: (String -> Payment) -> (Payment -> IO ()) -> FilePath -> IO ()
  
let simpleImport = importFile parseFun storageFun
// :t simpleImport
// simpleImport :: FilePath -> IO ()

Прежде всего, монада IO необходима для управления побочными эффектами. Но видите ли вы, как importFile высшего порядка importFile принимает три параметра, но мы можем предоставить только два и получить simpleImport ? Это то, что мы называем внедрением зависимостей в Spring или EJB. Но без синтаксиса сахар.