Generics + Spring: Да пребудет с вами сила

    Однажды в одном далёком, далёком банке ...


    Доброго времени суток, хабр. Сегодня наконец-то вновь дошли руки написать сюда. Но в отличие от предыдущих туториалов — статей сегодня хотелось бы поделиться своим опытом и показать мощь такого механизма как дженерики, который вместе с магией спринга становится ещё сильнее. Сразу хочу предупредить, что для понимания статьи нужно знать основы спринга и иметь представления о дженериках большие чем просто “Дженерики это, ну, то что в ArrayList в ковычках указываем”.

    Эпизод 1:


    Начнём с того, что на работе у меня стояла задача примерно таким образом: имелось большое количество денежных переводов с определенным количеством общих полей. Помимо этого каждому из переводов соответствовали классы — запросы для перевода из одного состояния в другое и перенаправления на другое апи. Соответственно были билдеры, которые и занимались преобразованием.

    Проблему с общими полями я решил просто — наследованием. Таким образом у меня появились классы:

    public class Transfer {
        private TransferType transferType;
        ...
    }
        
    public enum TransferType {
          INTERNAL, SWIFT, ...;
    }
        
    public class InternalTransfer extends Transfer {
        ...
    }
        
    public class BaseRequest {
        ...
    }
        
    public class InternalRequest extends BaseRequest {
        ...
    }    
    
    ...

    Эпизод 2:


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

        @AllArgsConstructor
        public class TransferController<T extends Transfer> {
        
            private final TransferService<T> service;
            
            public CheckResponse checkTransfer(@RequestBody @Valid T payment) {
                return service.checkTransfer(payment);
            }
            
            ...
        }
    
    
        public class InternalTransferController extends TransferController<InternalTransfer> {
        
            public InternalTransferController(TransferService<InternalTransfer> service) {
                super(service);
            }
            
        }
    

    Ну и собственно сервис:

    public interface TransferService<T extends Transfer> {
        
        CheckResponse checkTransfer(T payment);
        
        ApproveResponse approveTransfer(T payment);
            
        ...
        
    }

    Таким образом проблема копипаста сводилась только к вызову суперконструктора, а в сервисе мы вообще её лишились.

    Но!

    Эпизод 3:


    Внутри сервиса всё ещё стояла проблема:
    В зависимости от типа перевода нужно было вызывать различные билдеры:

    RequestBuilder builder;
        
    switch (type) {
        case INTERNAL: {
            builder = beanFactory.getBean(InternalRequestBuilder.class);
            break;
        }
        case SWIFT: {
            builder = beanFactory.getBean(SwiftRequestBuilder.class);
            break;
        }
        default: {
            log.info("Unknown payment type");
            throw new UnknownPaymentTypeException();
        }
    }
    

    обобщенный интерфейс билдера:

    public interface RequestBuilder<T extends BaseRequest, U extends Transfer> {
        
          T createRequest(U transfer);
    }
    

    Для оптимизации тут подошёл фабричный метод, в итоге switch/case — ы оказываются в отдельном классе. Вроде стало получше, но проблема осталась прежней — при добавлении нового перевода придётся модифицировать код, да и громоздкий switch/case меня не устраивал.

    Эпизод 4:


    Каков был выход? Вначале мне пришло на ум определять тип переводов по имени класса и вызывать нужный билдер с помощью рефлексии, что заставило бы разработчиков, которые будут работать с проектом соответствовать определенным требованиям по наименования своих классов. Но было лучшее решение. Пораскинув мозгами можно придти к тому, что основной аспект бизнес — логики приложения — это сами переводы. Т е если не будет их, не будет и всего остального. Так почему бы не завязать всё на этом? Достаточно лишь немного модифицировать наши классы. И снова на помощь приходят дженерики.

    Классы запросов:

    public class BaseRequest<T extends Transfer> {
        ...
    }
        
    public class InternalRequest extends BaseRequest<InternalTransfer>  {
        ...
    }
    

    И интерфейс билдера:

    public interface RequestBuilder<T extends Transfer> {
        
        BaseRequest<T> createRequest(T transfer);
            
    }
    

    А вот тут становится всё интереснее. Мы сталкиваемся с особенностью дженериков которая практически нигде не упоминается и используется в основном во фреймворках и библиотеках. Ведь в качестве BaseRequest мы можем подставить его наследника, который соответствует типу T, т е:

    public class InternalRequestBuilder implements RequestBuilder<InternalTransfer> {
        
            @Override
            public InternalRequest createRequest(InternalTransfer transfer) {
                return InternalRequest.builder()
                        ...
                        .build();
            }
    }

    В данный момент мы добились неплохого улучшения нашей архитектуры приложения. Но проблему switch/case — ов это пока так и не решило. Или …?

    Эпизод 5:


    Вот тут то в дело и вступает магия спринга.

    Дело в том, что в у нас есть возможность получить массив имен бинов соответствующих нужному типу с помощью метода getBeanNamesForType(ResolvableType type). И в классе ResolvableType имеется статический метод forClassWithGenerics(Class<?> clazz, Class<?>… generics), куда в качестве первого параметра нужно передать класс(интерфейс) который в качестве дженерика использует второй параметр и возвращает соответствующий тип. Т е:

    ResolvableType type = ResolvableType.forClassWithGenerics(RequestBuilder.class, transfer.getClass());

    Возвращает следующее:

    RequestBuilder<InternalTransfer>

    А теперь ещё немного магии — дело в том, что если заавтовайрить лист, с интерфейсом в качестве дженерика, то в нём будут содержаться все его реализации:

    private final List<RequestBuilder<T>> builders;


    Нам остаётся только пройтись по нему и найти соответствующий с помощью проверки на инстанс:

    builders.stream()
                    .filter(b -> type.isInstance(b))
                    .findFirst()
                    .get();


    Аналогично этому варианту ещё есть возможность заавтовайрить ApplicationContext либо BeanFactory, и вызвать у них метод getBeanNamesForType() куда передать наш тип в качестве параметра. Но это считается признаком дурного тона и в данной архитектуре в этом нет необходимости (отдельное спасибо zolt85 за комментарий).
    В итоге наш фабричный метод приобретает следующий вид:

        @Component
        @AllArgsConstructor
        public class RequestBuildersFactory<T extends Transfer> {
        
            public BaseRequest<T> transferToRequest(T transfer) {
        
                ResolvableType type = ResolvableType.forClassWithGenerics(RequestBuilder.class, transfer.getClass());
                RequestBuilder<T> builder = builders.stream()
                    .filter(b -> type.isInstance(b))
                    .findFirst()
                    .get();
        
                return builder.createRequest(transfer, stage);
        
            }
        
        }

    Эпизод 6: Заключение


    Таким образом у нас получился мини — фреймворк с продуманной архитектурой, обязующей всех разработчиков ей придерживаться. И что немаловажно мы избавились от громоздкого switch/case и добавление новых переводов никак не затронет уже существующие классы, что не может не радовать.

    PS:
    Данная статья не призывает использовать дженерики где только можно и нельзя, но с её помощью хочется поделиться тем, какие мощные механизмы и архитектуры они позволяют создавать.

    Благодарности:
    Отдельное спасибо Sultansoy, без которого данная архитектура не была бы доведена до ума и, скорее всего, не было бы этой статьи.

    Ссылки:
    Исходный код на github
    • +12
    • 5,6k
    • 5
    Поделиться публикацией
    Комментарии 5
      0
      Джошуа Блох советует в данном случае со switch использовать внутренний класс-стратегию внутри вашего enum, чтобы избавиться от switch.
        0
        Не спорю, данный вариант тоже подходит, но суть архитектуры в том, что она завязана на дженериках и трансферах, а в случае, который советует Блох получается сильная привязка к enum — у, чего мне совсем не хотелось, т к данное поле изначально не являлось обязательным для бизнес логики.
        0

        Проблема также решается без Spring с помощью Java Service Providers.
        https://docs.oracle.com/javase/tutorial/ext/basics/spi.html
        Можно динамически подгружать реализации некоторого интерфейса и его билдера и выбирать их по какой-нибудь метаинформации без switch (итерируя доступные в рантайме и выбирая нужную).

          0
          Посмею сделать небольшое замечание, по поводу автовайринга ApplicationContext-а в фабрику. Это, как заверяют евангелисты, признак плохого тона. Не должен отдельный компонент знать про весь контекст. Думаю фабрику можно переписать иначе:

              @SuppressWarnings("unchecked")
              @Component
              public class RequestBuildersFactoryImpl implements RequestBuildersFactory {
              
                  @Setter(onMethod = @__(@Autowired))
                  private List<RequestBuilder> builders;
              
                  public <T extends Transfer> BaseRequest<T> transferToRequest(T transfer) {
              
                      ResolvableType type = ResolvableType.forClassWithGenerics(RequestBuilder.class, transfer.getClass());
          
                      RequestBuilder<T> builder = builders.stream().filter(b -> type::isInstance).findFirst().get();
              
                      return builder.createRequest(transfer, stage);
                  }    
              }
          


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

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

          Самое читаемое