Введение
В этой статье я собираюсь показать вам, как можно выполнять маппинг полиморфных объектов JSON используя JPA и Hibernate.
Поскольку Hibernate не поддерживает JSON нативно, то для достижения этой цели я буду использовать библиотеку Hibernate Types.
Полиморфные типы
Предположим, что у нас есть следующая иерархия классов DiscountCoupon
:
DiscountCoupon
является базовым для конкретных классов AmountDiscountCoupon
и PercentageDiscountCoupon
, которые определяют два различных способа скидки на цену данной сущности Book.
Сущность Book
отображается следующим образом:
@Entity(name = "Book")
@Table(name = "book")
public class Book {
@Id
@GeneratedValue
private Long id;
@NaturalId
@Column(length = 15)
private String isbn;
@Column(columnDefinition = "jsonb")
private List<DiscountCoupon> coupons = new ArrayList<>();
}
Заметим, что нам необходимо сделать маппинг (списка) List
купонов со столбцом JSON в базе данных, и для этого нужен пользовательский тип, который может обрабатывать полиморфные объекты.
JsonType
по умолчанию отлично работает с конкретными классами, но при использовании дженерика List
фактический тип теряется, если мы не передадим его в базу данных во время записи.
Маппинг полиморфных объектов JSON с Jackson DefaultTyping и Hibernate
Одним из решений является определение JsonType
, который позволяет нам работать с классами, которые не имеют явного конкретного типа, как в случае с абстрактными классами или интерфейсами.
В нашем случае DiscountCoupon
является абстрактным классом, следовательно, он не может быть инстанцирован Jackson, поэтому нам необходимо знать точный тип класса ссылки на объект DiscountCoupon
, который мы должны инстанцировать при загрузке JSON-колонки из базы данных.
По этой причине мы можем использовать следующий пользовательский JsonType:
ObjectMapper objectMapper = new ObjectMapperWrapper().getObjectMapper();
properties.put(
"hibernate.type_contributors",
(TypeContributorList) () -> Collections.singletonList(
(typeContributions, serviceRegistry) ->
typeContributions.contributeType(
new JsonType(
objectMapper.activateDefaultTypingAsProperty(
objectMapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.OBJECT_AND_NON_CONCRETE,
"type"
),
ArrayList.class
) {
@Override
public String getName() {
return "json-polymorphic-list";
}
}
)
)
);
json-polymorphic-list
кастомизирует дженерик JsonType и предоставляет пользовательский Jackson ObjectMapper
, который использует стратегию DefaultTyping.OBJECT_AND_NON_CONCRETE
.
Когда json-polymorphic-list
зарегистрирован, нам нужно просто добавить его в свойство coupons:
@Type(type = "json-polymorphic-list")
@Column(columnDefinition = "jsonb")
private List<DiscountCoupon> coupons = new ArrayList<>();
Теперь, при персистировании сущности Book:
entityManager.persist(
new Book()
.setIsbn("978-9730228236")
.addCoupon(
new AmountDiscountCoupon("PPP")
.setAmount(new BigDecimal("4.99"))
)
.addCoupon(
new PercentageDiscountCoupon("Black Friday")
.setPercentage(BigDecimal.valueOf(0.02))
)
);
Более подробно о том, как можно настроить Jackson ObjectMapper
, который используется в проекте Hibernate Types, читайте в этой статье.
Hibernate генерирует следующие операторы SQL INSERT
:
INSERT INTO book (
coupons,
isbn,
id
)
VALUES (
[
{
"type":"com.vladmihalcea.hibernate.type.json.polymorphic.AmountDiscountCoupon",
"name":"PPP",
"amount":4.99
},
{
"type":"com.vladmihalcea.hibernate.type.json.polymorphic.PercentageDiscountCoupon",
"name":"Black Friday",
"percentage":0.02
}
],
978-9730228236,
1
)
Обратите внимание, что Jackson вставил свойство type
в объекты DiscountCoupon
JSON. Атрибут type
будет использоваться Jackson при получении сущности Book
, поскольку базовый JSON-объект должен быть заполнен соответствующим типом подкласса DiscountCoupon
.
При загрузке сущности Book
мы видим, что она загружает объекты DiscountCoupon
должным образом:
Book book = entityManager.unwrap(Session.class)
.bySimpleNaturalId(Book.class)
.load("978-9730228236");
Map<String, DiscountCoupon> topics = book.getCoupons()
.stream()
.collect(
Collectors.toMap(
DiscountCoupon::getName,
Function.identity()
)
);
assertEquals(2, topics.size());
AmountDiscountCoupon amountDiscountCoupon =
(AmountDiscountCoupon) topics.get("PPP");
assertEquals(
new BigDecimal("4.99"),
amountDiscountCoupon.getAmount()
);
PercentageDiscountCoupon percentageDiscountCoupon =
(PercentageDiscountCoupon) topics.get("Black Friday");
assertEquals(
BigDecimal.valueOf(0.02),
percentageDiscountCoupon.getPercentage()
);
Маппинг полиморфных JSON-объектов с помощью Jackson JsonTypeInfo
Другой подход заключается в использовании Jackson @JsonTypeInfo
для определения свойства дискриминатора, которое Jackson может использовать при восстановлении Java-объекта из его базового JSON-значения.
Для этого нам нужно определить свойство getType
в DiscountCoupon
и обеспечить маппинг между значениями свойства type
и связанными классами DiscountCoupon
с помощью аннотации @JsonSubTypes
:
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "type"
)
@JsonSubTypes({
@JsonSubTypes.Type(
name = "discount.coupon.amount",
value = AmountDiscountCoupon.class
),
@JsonSubTypes.Type(
name = "discount.coupon.percentage",
value = PercentageDiscountCoupon.class
),
})
public abstract class DiscountCoupon implements Serializable {
private String name;
public DiscountCoupon() {
}
public DiscountCoupon(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME)
public abstract String getType();
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof DiscountCoupon)) return false;
DiscountCoupon that = (DiscountCoupon) o;
return Objects.equals(getName(), that.getName());
}
@Override
public int hashCode() {
return Objects.hash(getName());
}
}
Методы equals
и hashCode
необходимы механизму dirty checking Hibernate, чтобы узнать, когда вы изменяете купоны, и запустить инструкцию UPDATE.
AmountDiscountCoupon
имплементирует метод getType
и определяет то же значение дискриминатора, что и DiscountCoupon
, отображаемое с помощью аннотации @JsonSubTypes.Type
.
public class AmountDiscountCoupon extends DiscountCoupon {
public static final String DISCRIMINATOR = "discount.coupon.amount";
private BigDecimal amount;
public AmountDiscountCoupon() {
}
public AmountDiscountCoupon(String name) {
super(name);
}
public BigDecimal getAmount() {
return amount;
}
public AmountDiscountCoupon setAmount(BigDecimal amount) {
this.amount = amount;
return this;
}
@Override
public String getType() {
return DISCRIMINATOR;
}
}
PercentageDiscountCoupon
также имплементирует метод getType
и определяет то же значение дискриминатора, которое было использовано связанной аннотацией @JsonSubTypes.Type
в базовом классе DiscountCoupon
:
public class PercentageDiscountCoupon extends DiscountCoupon {
public static final String DISCRIMINATOR = "discount.coupon.percentage";
private BigDecimal percentage;
public PercentageDiscountCoupon() {
}
public PercentageDiscountCoupon(String name) {
super(name);
}
public BigDecimal getPercentage() {
return percentage;
}
public PercentageDiscountCoupon setPercentage(BigDecimal amount) {
this.percentage = amount;
return this;
}
@Override
public String getType() {
return DISCRIMINATOR;
}
}
Теперь сущность Book может использовать дженерик JsonType
, поскольку Java-объекты DiscountCoupun
могут быть инстанцированы Jackson с помощью доступного маппинга @JsonTypeInfo
:
@Entity(name = "Book")
@Table(name = "book")
@TypeDef(name = "json", typeClass = JsonType.class)
public static class Book {
@Id
@GeneratedValue
private Long id;
@NaturalId
@Column(length = 15)
private String isbn;
@Type(type = "json")
@Column(columnDefinition = "jsonb")
private List<DiscountCoupon> coupons = new ArrayList<>();
}
А при сохранении той же сущности Book Hibernate будет генерировать следующий SQL-запрос INSERT
:
INSERT INTO book (
coupons,
isbn,
id
)
VALUES (
[
{
"name":"PPP",
"amount":4.99,
"type":"discount.coupon.amount"
},
{
"name":"Black Friday",
"percentage":0.02,
"type":"discount.coupon.percentage"
}
],
978-9730228236,
1
)
Круто, правда?
Заключение
Маппинг полиморфных JSON-объектов достаточно прост с проектом Hibernate Types. Поскольку вы способны кастомизировать Jackson ObjectMapper
так, как вам захочется, с помощью этого подхода можно решать самые разные задачи.
Материал подготовлен в рамках курса «Java Developer. Professional».
Всех желающих приглашаем на бесплатное demo-занятие «Пример реактивного приложения на Java Spring Boot». Продолжаем разрабатывать систему получения курса валюты. Разберемся с тем, что такое реактивное программирование и переведем один из микросервисов на реактивные рельсы. >> РЕГИСТРАЦИЯ
js605451
Всё то же самое делается ещё проще через написание своего AttributeConverter, в котором точно так же можно какой угодно кастомизированный ObjectMapper использовать.
isicju
возможно цель статьи была направленна только на рекламу курсов.
Donquih0te
Точнее перевод статьи
PocketM
Эта либа плюется гиганским рекламным баннером в логи. Причем в тестах он не показывается, чтобы сразу это не заметили и успели до прода довести.
Автор либы весьма своеобразный. Дважды подумайте, предже чем импортировать в проект эту библиотеку