Введение
В этой статье я собираюсь показать вам, как можно выполнять маппинг полиморфных объектов 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». Если вам интересно узнать подробнее о формате обучения и программе, познакомиться с преподавателем курса — приглашаем на день открытых дверей онлайн. Регистрация здесь.