Введение

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

Комментарии (0)