Статьи

Инверсия (связывания) управления в Java

Что такое инверсия контроля? А что такое внедрение зависимостей? Эти типы вопросов часто встречаются с примерами кода, расплывчатыми объяснениями и тем, что было определено в StackOverflow как « некачественные ответы» .

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

Причина в том, что мы не четко определили, что такое контроль. Как только мы понимаем, что мы инвертируем, концепция инверсии управления и внедрения зависимости фактически не является вопросом, который нужно задавать. На самом деле это становится следующим:


Инверсия управления = Зависимость (состояние) Впрыск + Ввод резьбы + Продолжение (функция) Впрыск

Чтобы объяснить это, давайте сделаем немного кода. И да, очевидная проблема использования кода для объяснения инверсии контроля повторяется, но, терпите меня, ответ всегда был прямо перед вашими глазами.

Одним из явных примеров использования инверсии управления / внедрения зависимостей является шаблон хранилища, позволяющий избежать передачи соединения. Вместо следующего:

public class NoDependencyInjectionRepository implements Repository<Entity> {

  public void save(Entity entity, Connection connection) throws SQLException {
    // Use connection to save entity to database
  }
}

Внедрение зависимостей позволяет повторно реализовать хранилище как:

public class DependencyInjectionRepository implements Repository<Entity> {

  @Inject Connection connection;

  public void save(Entity entity) throws SQLException {
    // Use injected connection to save entity to database
  }
}

Теперь вы видите проблему, которую мы только что решили?

Если вы думаете: «Теперь я могу изменить,  connection чтобы сказать вызовы REST»,  и это все легко изменить, ну, вы были бы рядом.

Чтобы увидеть, была ли проблема решена, не смотрите на реализацию. Вместо этого посмотрите на интерфейс. Код вызова клиента получен из:

repository.save(entity, connection);

к следующему:

repository.save(entity);

Мы удалили соединение клиентского кода, чтобы обеспечить  connection вызов метода. Удаляя связь, мы можем заменить другую реализацию хранилища (опять же, скучные старые новости, но потерпите меня):

public class WebServiceRepository implements Repository<Entity> {

  @Inject WebClient client;

  public void save(Entity entity) {
    // Use injected web client to save entity
  }
}

С клиентом можно продолжать вызывать метод точно так же:

repository.save(entity);

Клиент не знает, что хранилище теперь вызывает микросервис для сохранения объекта, а не напрямую обращается к базе данных. (На самом деле, клиент знает, но мы скоро к этому придем.)

Итак, подняв это на абстрактный уровень относительно метода:

R method(P1 p1, P2 p2) throws E1, E2

// with dependency injection becomes

@Inject P1 p1;
@Inject P2 p2;
R method() throws E1, E2

Связь клиента с аргументами метода удаляется путем внедрения зависимости.

Теперь вы видите четыре другие проблемы сцепления?

На этом этапе я предупреждаю вас, что вы никогда не будете снова смотреть на код, как только я покажу вам проблемы со связью. Это точка в Матрице, где я спрашиваю вас, хотите ли вы принять красную или синюю таблетку. Когда я покажу вам, как далеко вниз по кроличьей норе находится эта проблема, пути назад уже не будет  —  рефакторинг фактически не нужен, и есть проблемы в основах логики моделирования и информатики (хорошо, большое утверждение, но читайте дальше — я могу » иначе говоря).

Итак, вы выбрали красную таблетку.

Давайте подготовим вас.

Чтобы определить четыре дополнительные проблемы связывания, давайте снова посмотрим на абстрактный метод:

@Inject P1 p1;
@Inject P2 p2;
R method() throws E1, E2

// and invoking it
try {
  R result = object.method();
} catch (E1 | E2 ex) {
  // handle exception
}

Что связано с клиентским кодом?

  • Тип возврата

  • Название метода

  • Обработка исключений

  • Поток, предоставленный методу

Внедрение зависимостей позволило мне изменить объекты, требуемые методом, без изменения клиентского кода, вызывающего метод. Однако, если я хочу изменить свой метод реализации:

  • Изменение типа возврата

  • Смена имени

  • Создание нового исключения (в приведенном выше случае переключения в репозиторий микросервисов, исключение HTTP, а не исключение SQL)

  • Использование другого потока (пула) для выполнения метода, отличного от потока, предоставленного клиентским вызовом

Это включает в себя « рефакторинг » всего клиентского кода для моего метода. Почему вызывающая сторона должна диктовать связь, когда реализация выполняет тяжелую работу по выполнению функциональности? На самом деле мы должны инвертировать связь, чтобы реализация могла диктовать сигнатуру метода (а не вызывающего).

Скорее всего, это то, на что ты смотришь на меня, как будто Нео в Матрице говорит «да»? Позвольте реализациям определить их сигнатуры методов? Но разве не весь ОО-принцип о переопределении и реализации определений сигнатур абстрактных методов? И это просто хаос, потому что как я вызываю метод, если его тип возвращаемого значения, имя, исключения, аргументы постоянно меняются по мере развития реализации?

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

Итак, давайте пройдемся по пяти точкам соединения (тип возврата, имя метода, аргументы, исключения, вызывающий поток) метода и разделим их.

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

Далее, давайте займемся названием метода.

Разделение имени метода

Многие языки, включая Java-лямбды, разрешают или имеют функции первоклассных граждан языка. Создавая ссылку на функцию для метода, нам больше не нужно знать имя метода, чтобы вызвать метод:

Runnable f1 = () -> object.method();

// Client call now decoupled from method name
f1.run()

Даже сейчас мы можем передать различные реализации метода вокруг внедрения зависимости:

@Inject Runnable f1;
void clientCode() {
  f1.run(); // to invoke the injected method
}

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

Далее рассмотрим исключения из метода.

Метод Исключения Разделение

Используя вышеописанную технику ввода функций , мы внедряем функции для обработки исключений:

Runnable f1 = () -> {
  @Inject Consumer<E1> h1;
  @Inject Consumer<E2> h2;
  try {
    object.method();
  } catch (E1 e1) {
    h1.accept(e1);
  } catch (E2 e2) {
    h2.accept(e2);
  }
}

// Note: above is abstract pseudo code to identify the concept (and we will get to compiling code shortly)

Теперь исключения больше не являются проблемой вызывающего клиента. Внедренные методы теперь обрабатывают исключения, освобождая вызывающую сторону от необходимости обрабатывать исключения.

Далее давайте займемся вызывающим потоком.

Разделение вызывающей нити метода

Используя сигнатуру асинхронной функции и Executorвставляя, мы можем отделить поток, вызывающий метод реализации, от метода, предоставленного вызывающей стороной:

Runnable f1 = () -> {
  @Inject Executor executor;
  executor.execute(() -> {
    object.method();
  });
}

Внедрив соответствующий  Executorметод, мы можем вызвать метод реализации из любого пула потоков, который нам необходим. Чтобы повторно использовать вызывающий поток клиента, мы просто используем синхронный Exectutor:

Executor synchronous = (runnable) -> runnable.run();

Итак, теперь мы можем отделить поток для выполнения реализующего метода из потока вызывающего кода.

Но без возвращаемого значения, как мы передаем состояние (объекты) между методами? Давайте объединим все это вместе с внедрением зависимости.

Инверсия управления (муфта)

Давайте объединим вышеприведенные шаблоны вместе с внедрением зависимостей, чтобы получить ManagedFunction:

public interface ManagedFunction {
  void run();
}

public class ManagedFunctionImpl implements ManagedFunction {

  @Inject P1 p1;
  @Inject P2 p2;
  @Inject ManagedFunction f1; // other method implementations to invoke
  @Inject ManagedFunction f2;
  @Inject Consumer<E1> h1;
  @Inject Consumer<E2> h2;
  @Inject Executor executor;

  @Override
  public void run() {
    executor.execute(() -> {
      try {
        implementation(p1, p2, f1, f2);
      } catch (E1 e1) {
        h1.accept(e1);
      } catch (E2 e2) {
        h2.accept(e2);
    });
  }

  private void implementation(
    P1 p1, P2 p2, 
    ManagedFunction f1, ManagedFunction f2
  ) throws E1, E2 {
    // use dependency inject objects p1, p2
    // invoke other methods via f1, f2
    // allow throwing exceptions E1, E2
  }
}

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

@Inject ManagedFunction function;
public void clientCode() {
  function.run();
}

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

  • Нет возвращаемого типа из методов (всегда есть небольшое ограничение  void, однако необходимое для асинхронного кода)
  • Имя реализующего метода может измениться, так как оно обернуто ManagedFunction.run() 

  • Параметры больше не требуются ManagedFunction. Они внедряются в зависимости, что позволяет методу реализации выбирать, какие параметры (объекты) ему требуются

  • Исключения обрабатываются с помощью инъекций Consumers. Реализующий метод теперь может определять, какие исключения он генерирует, требуя только разных  Consumers внедренных. Вызывающий код клиента не знает, что реализующий метод теперь может выдавать  HTTPException вместо  SQLException . Кроме того, на  Consumers самом деле может быть реализовано путем  ManagedFunctions  введения исключения.

  • Внедрение метода Executor позволяет реализующему методу определять свой поток выполнения, задавая  Executor для внедрения. Это может привести к повторному использованию вызывающего потока клиента или запуску реализации отдельного потока или пула потоков

Все пять точек соединения метода его вызывающей стороной теперь разъединены.

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

Кроме того, поскольку вызывающая сторона не связывается, нет необходимости в рефакторинге кода. Реализация изменяется, а затем настраивается в своем соединении (инъекции) с остальной частью системы. Код вызова клиента больше не нуждается в рефакторинге.

Таким образом, внедрение зависимостей решило только 1/5 проблемы связи методов. Для чего-то, что является настолько успешным только для решения 20 процентов проблемы, это показывает, насколько проблематичным является соединение метода на самом деле.

Реализация вышеуказанных шаблонов создаст больше кода, чем это стоит в ваших системах. Вот почему OfficeFloor с открытым исходным кодом является «истинной» инверсией системы управления и была составлена ​​таким образом, чтобы уменьшить нагрузку на этот код. Это было экспериментом в вышеупомянутых концепциях, чтобы увидеть, легче ли создавать и поддерживать реальные системы с «истинной» инверсией управления. 

Резюме

Итак, в следующий раз, когда вы дойдете до кнопки / команды Refactor, поймите, что это вызвано соединением метода, который пристально смотрит нам в лицо каждый раз, когда мы пишем код.

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

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

  • Высокая связь : у методов есть пять аспектов связи с клиентским кодом вызова

  • Низкая согласованность : поскольку обработка исключений и типов возврата из методов начинает размывать ответственность методов с течением времени, постоянные изменения и ярлыки могут быстро ухудшить согласованность реализации метода, чтобы начать обработку логики вне его ответственности

Поскольку мы стремимся к слабой связи и высокой связности, наш самый фундаментальный строительный блок (   method и  function) может фактически идти вразрез с нашими основными принципами программирования.