Нетривиальные проблемы с generic’ами и возможные решения

в 9:52, , рубрики: generic, hibernate, java, spring

Привет всем! Любой программист, хоть немного знающий Java работал с такой штукой, как generic. Эта фича появилась аж в 5-ой версии Java и сегодня я хотел бы рассказать о некоторых нетривиальных проблемах, связанных с обобщенными типами, с которыми я сталкивался, а также о том почему они возникают и как их можно решить. В этой статье также будут затронуты всеми (не)любимые Hibernate и Spring.

Но начну я с объяснения некоторых тонкостей generic'ов, которые не всегда понимают новички в мире Java. Если вы опытный разработчик, то можете не читать первые два пункта.

1) Зачем нужен wildcard, extend, super

Wildcard (?) используется во время подстановки в обобщенный класс. Означает он, что нам не важно каким будет параметризованный тип или, если wildcard используется вкупе с ключевыми словами super и extend, то нам важно только, чтобы параметризованный тип был родительским или расширял определенный класс. Приведу понятный пример, как это используется на практике.

public void foobar(Map<String, Object> ms) {
    ...
}

Если мы захотим передать в метод переменную типа Map<String, Number>, то ничего не получиться (почему — расскажу в следующем пункте), но если метод будет объявлен так, то нам это удастся.

public void foobar(Map<String, ?> ms) {
    ...
}

То есть во втором случае мы говорим, о том что нам не важно какого типа будет лежать value в мапе. Но это накладывает и свои ограничения, теперь у значений мапы мы сможем вызывать только методы класса Object. Если мы знаем, что значениями в этой мапе могут быть только объекты класса Number, то мы можем переписать сигнатуру метода так.

public void foobar(Map<String, ? extend Number> ms) {
    ...
}

Теперь у значений мапы нам доступны методы класса Number. Возникает вопрос, зачем же нам тогда ключевое слово super? Оно говорит о том, что параметризованный тип будет родителем для определенного класса, но это не дает возможность полиморфизма — вызывать метод какого-либо класса, кроме базового для всех — Object. Опять же приведу пример.

List<? extends Number> list = new ArrayList<Number>();
List<? extends Number> list = new ArrayList<Integer>();
List<? extends Number> list = new ArrayList<Double>();

Все три объявления допустимы, так как и Integer, и Double наследуется от Number. В любом из трех случаев мы сможем получать из list'а переменную ссылочного типа на Number и вызывать методы этого класса. Другое дело, чтобы в первом случае по этой ссылке будут лежать Number и его наследники, во втором Ineger и его наследники, в третьем Double и его наследники. А теперь, как вы думаете, что мы можем записать в list с таким объявлением? Если вы ответили — Number и его любого наследника, то вы ошиблись. Ответ — ничего! Причина этому в том, что объявленный так лист на самом деле может быть как листом Number, так и листом Integer, Double, да и вообще любого наследника Number и поэтому неизвестно какой именно тип там храниться и что именно туда можно записать. Рассмотрим ситуацию с ключевым словом super.

List<? super Integer> list = new ArrayList<Integer>();
List<? super Integer> list = new ArrayList<Number>();
List<? super Integer> list = new ArrayList<Object>();

В такой ситуации все обстоит с точностью наоборот. Мы можем без приведения типов присовить значение из листа только ссылочной переменной типа Object, но зато запись в лист доступна для всех наследников типа Integer.

2)

List<Number> list = new ArrayList<Integer>(). 

Нет? Почему?!

Иногда, особенно, когда используешь стороннюю библиотеку (у меня такое часто возникало с библиотекой JasperReports), руки так и чешутся сделать подобное присвоение. И когда компилятор отказывается такое собирать, сразу же нахлынывает праведное негодование. В чем проблема? Почему? Как же полиморфизм?! Ведь кажется, в чем собственно проблема? В переменную-ссылку на лист Number записывается лист Integer, при этом Integer является прямым наследниками от типа Number и все его методы ему доступны, следовательно при получения элемента из коллекции проблем возникнуть не должно. Но они возникают, и если вы внимательно читали про ключевое слово super, то уже должны были понять почему.
Суть такова — лист Number и лист Integer это все-таки разные объекты. Лист Number подразумевает запись в него Number (а следовательно и Double, Float и прочего), чего, конечно, лист Integer делать не должен.

Но как говорится, если сильно хочется, то можно того, чего и нельзя!

List<Number> list = (List)new ArrayList<Integer>();

То есть, мы просто затерли информацию о типе изначального листа, получив так называемый класс с «сырым» типом, который уже можно присвоить к чему угодно. Проделывать такой трюк не рекомендуется.

3) Generic и Spring (или почему нужно вайрить по интерфейсу!)

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


@MappedSuperclass
public abstract class BaseLinkEntity<S extends BaseEntity, T extends BaseEntity> extends BaseAuditableEntity {

    @JoinColumn(name = "SOURCE_ID", nullable = false)
    @ManyToOne(fetch = FetchType.LAZY)
    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    protected S source;

    @JoinColumn(name = "TARGET_ID", nullable = false)
    @ManyToOne(fetch = FetchType.LAZY)
    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    protected T target;
}

public interface LinkService<L extends BaseLinkEntity>{
    List<L> getAllBySourceId(UUID id);
    List<L> getAllByTargetId(UUID id);
}

public abstract class BaseLinkService<L extends BaseLinkEntity> implements LinkReportService<L> {
    protected BaseLinkRepository<L> linkRepository;

    @Required
    public void setLinkRepository(BaseLinkRepository<L> linkRepository) {
        this.linkRepository = linkRepository;
    }

    @Override
    @Transactional(readOnly = true)
    public List<L> getAllBySourceId(UUID id) {
        return linkRepository.findBySourceId(id);
    }

    @Override
    @Transactional(readOnly = true)
    public List<L> getAllByTargetId(UUID id) {
        return linkRepository.findByTargetId(id);
    }

}

public class CardLinkServiceImpl extends BaseLinkReportService<CardLink> {
    @Override
    @Autowired
    @Qualifier("cardLinkReportRepository")
    public void setLinkRepository(BaseLinkRepository<CardLink> linkRepository) {
        super.setLinkRepository(linkRepository);
    }
}

public class MyClass{
	@Autowired
    private LinkService<CardLink> cardLinkServiceImpl;
}

Почти стандартный подход спринга (кроме промежуточного базового класса) — связка интерфейс-реализация. Сделано было для удобства работы с таблицами в БД, в которых сущности связаны связью многие ко многим. И вроде все хорошо. Но тут мне понадобилось в CardLinkServiceImpl вкорячить пару специфичных методов для данных линков. Чтобы не плодить промежуточных интерфейсов изначально я добавил их прямо в CardLinkServiceImpl и решил в нужном месте вайрить его прямо по классу. Итог: бин в контейнере не был найден.

Немножко порывшись в интернете причина такого поведения была найдена. Во-первых, Spring 3 не умеет вайрить классы с generic, но в проекте использовался Spring 4ой версии. Вторая причина в том, что в спринг частенько создаются прокси для классов, которые он кидает себе в контейнер.

Спринг много где использует AOP — аспектно-ориентированное программирование. При таком подходе некоторая логика не реализуется напрямую в методе, а навешивается на него средствами aop'а в спринге. Для реализации этого подхода на низком уровне, спринг в рантайме меняет байт-код классов, добавляя в них нужную логику, при этом он создаёт прокси-объект, в котором затёрта информация о изначальном объекте, но остаётся информация о его интерфейсах.

В данном случаем aop используется тут для управления транзакциями (аннотация @Transactional). В итоге мне пришлось выносить специфичные методы в отдельный интерфейс и вайрить уже по нему.

4) Generic и Hibernate 5.2.1

А теперь проблема в Hibernate, чем-то похожая на описанную в третьем пункте, но больше выглядящую, как баг. Немножко кода:


public class Card{
	@JoinColumn(name = "KIND_ID")
    @ManyToOne(fetch = FetchType.LAZY)
    protected DocumentKind documentKind; //наследуется от BaseEntity, внутри которого есть поле Id
}

public class CardLink extends BaseLinkEntity<Card, Card>{
}

@NoRepositoryBean
public interface BaseLinkRepository<T extends BaseLinkEntity> extends JpaRepository<T, UUID>, JpaSpecificationExecutor<T> {
    Page<T> findBySourceId(UUID sourceId, Pageable pageRequest);

    Page<T> findByTargetId(UUID targetId, Pageable pageRequest);
}

public interface CardLinkReportRepository extends BaseLinkRepository<CardLink>{
    List<CardLink> findByTargetDocumentKindId(UUID documentKindId);
}

Спринг не сможет создать реализацию CardLinkReportRepository из-за того, что хибернейт не сможет найти свойство documentKind. Фактически он будет искать его не в том объекте. Чтобы понять что к чему, мне пришлось долго дебажить и ковыряться в исходниках хибернейта. Видно, что возможность работать с обобщенными типами в него закладывалась, но реализована была как-то кривовато. Я попытаюсь в двух словах объяснить суть, но чтобы лучше понять вы можете сами поисследовать класс MetamodelImpl (стоит обратить внимание на методы buildMetamodel(Iterator persistentClasses, Set mappedSuperclasses, SessionFactoryImplementor sessionFactory, boolean ignoreUnsupported) и buildEntityType(PersistentClass persistentClass, MetadataContext context)) и класс AttributeFactory (метод buildAttribute(AbstractManagedType ownerType, Property property)).

При построении метамодели хибернейт сначала заносит туда все классы (buildEntity), и только после этого записывает в entity их атрибуты (аналогия в классах — поля) в методе метод — buildAttribute. Дело в том, что сущность в метамодели для объекта BaseLinkEntity создаётся в единственном экземпляре и конкретный тип атрибута (generic поля) определяется всего один раз в методе buildAttribute. Потом же, когда метод JPA репозитория ищет поля documentKind, он ищет поле в классе, который проставился при построении атрибута. Сам тип берется из контекста с которым у меня уже не хватило сил разобраться (где и в какой момент времени он создаётся). Вот и получается, что в полях BaseLinkEntity мы искать можем, а вот в специфичном типе generic'а только для одной сущности из всего множества в приложении.
Самое интересное (и пока непонятное для меня), что если переписать так, то все заработает.


@Query("SELECT link FROM CardLink link WHERE link.target.documentKind.id = ?1")
public interface CardLinkReportRepository extends BaseLinkRepository<CardLink>{
    List<CardLink> findByTargetDocumentKindId(UUID documentKindId);
}

Автор: kobaeugenea

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js