Циклическая зависимость, не надо бороться, надо дизайнить
Каждый java разработчик который работал с достаточно нетривиальным проектом на spring рано или поздно сталкивался с подобным логом при старте приложения.
***************************
APPLICATION FAILED TO START
***************************
Description:
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| service1 defined in file [C:\Users\d.starakojev\IdeaProjects\demo\target\classes\com\example\demo\Service1.class]
↑ ↓
| service3 defined in file [C:\Users\d.starakojev\IdeaProjects\demo\target\classes\com\example\demo\Service3.class]
↑ ↓
| service2 defined in file [C:\Users\d.starakojev\IdeaProjects\demo\target\classes\com\example\demo\Service2.class]
└─────┘
Action:
Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans. As a last resort, it may be possible to break the cycle automatically by setting spring.main.allow-circular-references to true.
Разберемся откуда это берется и что с этим делать на примере эволюции простенького сервиса.
Бины создаются в 2 этапа
1. создать объект
2. внедрить зависимости
(настройки и прочее опущу)
Проблема справедлива для инжекта в конструктор (целевой способ), потому что оба этапа объединятся и ситуация становится патовой.
Если шаги разделены, то проблем нет, так как связи можно делать сколь угодно сложными и запутанными, после того как все объекты созданы.
Но способ с инжектом в конструктор является целевым ни просто так по нескольким причинам
1. снижение зависимости от фреймворка
а. не нужны приключения с рефлексией при инжекте в поле, и любой объект можно ез проблем создать руками
б. не нужно делать специальных методов при инжекте через сеттер
2. снижаются возможности к получению невалидного объекта (который на пол пути создания)
3. из коробки работает контроль циклических зависимостей
4. сама система заставляет нас думать об архитектуре сразу, а не когда будет уже поздно
Рецепт 1: кладем на декомпозицию и SOLID (а именно Single responsibility)
Изначально у нас есть быстро сделанные сервисы
1. UserService — отвечает за все что касается пользователей
2. NosificationService — отвечает за отправку сообщений пользователям
3. NotificationService — просто отправляет письма на email
@Service
public class UserService {
/**
* Такая незамысловатая логики выписывания токенов
*/
public String login(UUID id) {
String token = "token " + getInfo(id)
tokenRepo.put(id, token);
return token;
}
public User getInfo(UUID id) {
return new User(); // логика поиска
}
}
@Service
public class TokenRepo {
public void put (UUID userId, String token) {
// локига сохранения
}
}
@Service
@RequiredArgsConstructor
public class NotificationService {
private final UserService userService;
private final NotificationSender notificationSender;
public void send(String message, UUID userId) {
User user = userService.getInfo(userId);
notificationSender.send(message, user.getEmail());
}
}
@Service
public class NotificationSender {
public void send(String message, String email) {
// логика отправки сообщения
}
}Выглядит не особо красиво, но работает, до тех пор пока мы например не решим отправлять уведомления о входе в систему
@Service
@RequiredArgsConstructor
public class UserService {
private final NotificationService notificationService; // новая зависимость
/**
* Такая незамысловатая логики выписывания токенов
*/
public String login(UUID id) {
String token = "token " + getInfo(id);
notificationService.send("login ", id); // новая логика
return token;
}
public User getInfo(UUID id) {
return new User(); // логика поиска
}
}Циклическая зависимость в самом чистом виде при запуске
***************************
APPLICATION FAILED TO START
***************************
Description:
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| notificationService defined in file [C:\Users\d.starakojev\IdeaProjects\demo\target\classes\com\example\demo\bad\NotificationService.class]
↑ ↓
| userService defined in file [C:\Users\d.starakojev\IdeaProjects\demo\target\classes\com\example\demo\bad\UserService.class]
└─────┘Конечно же в боевом коде эта цепочка может достигать десятков звеньев, но способ лечения простой: ищем того кто на себя слишком много взял и делим на 2 части, в нашем случае из UserService выделяем AuthService, а в самом UserService оставляем только ответственность за доступ к информации о пользователях
@Service
@RequiredArgsConstructor
public class AuthService {
private final NotificationService notificationService;
private final UserService userService;
/**
* Такая незамысловатая логики выписывания токенов
*/
public String login(UUID id) {
String token = "token " + userService.getInfo(id);
notificationService.send("login ", id);
return token;
}
}
@Service
@RequiredArgsConstructor
public class UserService {
public User getInfo(UUID id) {
return new User(); // логика поиска
}
}
Как показывает практика, следования этому принципу достаточно, чтобы не сталкивать с циклическими зависимостями очень долго.
Рецепт 2: просто жди ответ
На самом приложений без циклических зависимостей не существует, они просто хорошо заметены под ковер (в JVM и фреймворки).
Например: Любая функция с возвращаемым значением, автоматически создает у нас циклическую зависимость.
public static void main(String[] args) {
String foo = method();
System.out.println(foo);
}
public static String method() {
return "Hello";
}Если нарисовать только «компоненты» и их зависимости в классическом понимании, когда один компонент знает адрес другого, то все выглядит складно

Но если на эту же схему наложить движение данных и управляющих сигналов, то тайное становится явным.

Почему это не становится проблемой? потому что на самом деле все исполняется примерно как на картинке ниже, на самом деле наши «компоненты» друг с другом не общаются и друг о друге ничего не знают, всю работу делает JVM и позволяет нам игнорировать этот аспект.

Рецепт 3: просто добавь асинхрон
Разовьем пример выше, мы теперь хотим не просто нотификацию, а поддержать обратную связь, например если пользователь напишет со своего email что это не он, тогда мы должны его разлогинить.
Такие задачи уже выходят из зоны ответственности JVM, и она за нас ничего не решит, придется выкручиваться самостоятельно.
Вариантов реализации много, и функционально все будут работать правильно, но нам этого не достаточно, нужно чтобы решение было устойчивым, гибким и понятным, а именно чтобы сохранялась инкапсуляция и разделение ответственности.
в AuthService добавляем метод unLogin, потому что этот сервис логинит — ему и разлогинивать
Добавляем NotificationResponseListener, потому что принимать письма это принципиально новая работа для нашей системы
В NotificationService добавляем логику сопоставления запроса с ответом, потому что он формирует запрос и нам всем логичны и привычно, чтобы получать ответ в том же месте, куда его отправили, для этого нам так же понадобится хранить отправленные сообщения, по этому добавляем класс MessageRepo
@Service
@RequiredArgsConstructor
public class AuthService {
private final NotificationService notificationService;
private final UserService userService;
private final TokenRepo tokenRepo;
public String login(UUID id) {
String token = "token " + userService.getInfo(id);
tokenRepo.put(id, token);
notificationService.send("login ", id);
return token;
}
public void unLogin(UUID id) {
tokenRepo.remove(id);
}
}
@Service
@RequiredArgsConstructor
public class NotificationResponseListener {
private final NotificationService notificationService;
public void onMessage(Response response) {
notificationService.handleResponse(response);
}
}
@Service
@RequiredArgsConstructor
public class NotificationService {
private final UserService userService;
private final MessageRepo messageRepo;
private final NotificationSender notificationSender;
public void send(String messagePayload, UUID userId) {
User user = userService.getInfo(userId);
Message message = new Message(UUID.randomUUID(), messagePayload, user.getEmail());
messageRepo.save(message);
notificationSender.send(message);
}
public void handleResponse(Response response) {
RequestResponseBundle bundle = match(response);
// вызвать логику разлогина
}
private RequestResponseBundle match(Response response) {
Message sourceMessage = messageRepo.getMessage(response.getSourceMessageId());
return new RequestResponseBundle(sourceMessage, response);
}
}А теперь вся соль в том, как вызвать логику разлогина?
Если притащим в NotificationService AuthService снова получим циклическую зависимость,
да и не положено NotificationService знать про авторизацию.
Тут на самом деле остается 2 варианта, вполне рабочих
Добавить параметром Callback при отправке сообщения, и вызывать его в случае если пришел негативный ответ от пользователя.
Реализовать генерацию события о том что пришел негативный ответ от пользователя
(тут относительно первого способа появляется необходимость в регистрации слушателя и связь становится неявной, но зато событие могут слушать и другие сервисы)
И в том и в другом случае появляется необходимость в новом компоненте, который будет получать событие от NotificationService и вызывать нужный метод у AuthService.
Но какой бы способ мы не выбрали, циклическая зависимость никуда не денется.

Она просто заметается под явление «позднее связывание», зеленая стрелка появляется не на этапе компиляции, а уже в рантайме.
В то же время EventListenerRegistrar можно после страта контекста выбрасывать в помойку за ненадобностью.
И это не плохо, так устроен мир)
Этой темы коснусь дальше.
P.S. Отдельная история как эти события сделать персистентными и не терять при рестартах приложения.
Тут важно начать с того, что приложение это не просто автомобиль, который мы купили/сделали, а дальше остается только кататься да чинить.
Приложение это еще и сырье, и чертеж, и завод (и завод для завода), и дорога.
На разных этапах своего жизненного цикла оно принимает разные формы с совершенно разной архитектурой.
Просто посмотрим на жизненный цикл spring boot приложения:
Притянуть библиотеки
Скомпилировать исходники
Упаковать образ
Запустить JVM
Поднять окружение Java
Запустить ядро спринга
Прочитать конфигрурации фабрики
Построить фабрику
Прочитать конфигурации контекста
Построить контекст
Настроить контекст
Установить соединения с интеграциями
Донастроить контекст
Приступить к выполнению полезной работы
На любом из этих этапов есть точки расширения и необходимость вернуться к предыдущим шагам или динамически модифицировать следующие, что в итоге формирует очень страшную спагетину из зависимостей. При чем как устроено большинство этапов для целевой работы приложения абсолютно не важно, по этому там и есть элемент хаоса.
Больше стрелок богу стрелок
Добавим детализации.
Добавляем Mailbox — в нашем случае как внешнюю систему, в которой пользователь работает с письмами.
Добавим красных стрелок, которые будут отражать направление движения данных в тех случаях, когда оно не совпадает с направлением владельца связи.

Соль в том, что белую стрелку, которая представляет собой «зависимость по контракту», мы можем развернуть, а вот направление движения данных никак.
Для примера, развернем белую стрелку между NotificationService и UserService при помощи DependencyInjection и динамического скоупа.
public class AuthService {
private final NotificationService notificationService;
private final ActiveUserInjector activeUserInjector; // новая зависимость
private final UserService userService;
private final TokenRepo tokenRepo;
public String login(UUID id) {
String token = "token " + userService.getInfo(id);
tokenRepo.put(id, token);
activeUserInjector.injectActiveUser(id); // новая логика
notificationService.sendToActiveUser("login ");
return token;
}
}
public class ActiveUserInjector {
private final UserService userService;
private final NotificationService notificationService;
public void injectActiveUser(UUID id) {
notificationService.setActiveUser(userService.getInfo(id));
}
}
public class NotificationService {
private final ThreadLocal activeUser = new ThreadLocal<>(); // меняем UserService на юзера
private final MessageRepo messageRepo;
private final NotificationSender notificationSender;
public void sendToActiveUser(String messagePayload) {
Message message = new Message(UUID.randomUUID(), messagePayload, activeUser.get().getEmail());
messageRepo.save(message);
notificationSender.send(message);
}
public void setActiveUser(User user) { // добавляем для упрщения инжекта
activeUser.set(user);
}
} 
Конечно, реализация носит исключительно концептуальный характер для демонстрации того, что стрелки «отношений» можно вертеть как угодно, но направление движения данных осталось неизменным.
Так же подсвечу, что о что мы сделали с разворотом стрелки, это не инверсия зависимостей из SOLID.
Мы физически убрали зависимость NotificationService от UserService, SOLID же говорит о том, что не стоит завязывать на реализацию зависимости. Достигается это за счет выделения интерфейса, стрелки зависимости по контракту разворачиваются автоматически.
Таким образом мы подошли с новому виду стрелки «направления передачи управления».
И в данном случае все они смотрят в разные стороны.
1. белая — зависимость по контракту
2. красная — направление движения данных
3. фиолетовая — направления передачи управления
(если красная или фиолетовая стрелка совпадает с белой, она не отрисовывается, дабы не загромождать диаграмму)

Выводы
Архитектура сервиса не статична в течение жизненного цикла.
Зависимость — это явление, которое находится в состоянии суперпозиции, и то как она выглядит, зависит от того с какой точки зрения и в какое время мы смотрим.
На один и тот же процесс или сервис можно нарисовать множество диаграмм, и нужно четко понимать что, для кого и с какой целью мы рисуем
Циклически зависимости по своей сути не зло с которым нужно бороться любой ценой, а неотъемлемая часть устройства мира, которую нужно готовить осознанно
Многие привычные нам понятия вроде методов, если достаточно глубоко капнуть, взаимодействуют друг с другом совершенно иначе чем чем нам удобно думать.
Я сюда ни философствовать пришел, что делать?
Поддерживать баланс между разделением ответственности и инкапсуляцией логики
Внимательно относиться к асинхрону и колбэкам — это та область где циклическая зависимость является нормой, но готовить красиво придется самим
При получении ошибки поднятия контекста из-за циклической зависимости, нужно методично пройтись по всей цепочке
а. сделать позднее связывание (инжект через setter или в поле) там где есть асинхрон
б. распилить толстый класс на несколько там где ответственность расплывается
в. в случае проблем в кишках, главное не подгонять бизнес логику под фрейворк, а зависимости вероятно прийдется доставать руками, рекомендуемым способом на том этапе где вы пытаетесь влезть
Habrahabr.ru прочитано 3583 раза
