Синхронизация двунаправленных (bidirectional) отношений

В этой статье рассмотрим, как поддерживать согласованность ссылок между двунаправленными отношениями.
Как известно, отношения между @Entity-классами бывают:

  • однонаправленные (unidirectional): когда только у одного из двух классов есть ссылка на другой класс. А обратной ссылки нет.
  • двунаправленные (bidirectional): когда классы ссылаются друг на друга взаимно.

В двунаправленных отношениях возникает проблема согласованности ссылок. Допустим, конкретный объект Comment ссылается на конкретный Topic. В то время как этот топик не содержит в своей коллекции этот Comment (по ошибке программиста). Что же должен делать Hibernate при сохранении Comment? Сохранить его в данном топике (физически это будет означать внешний ключ в таблице comment, ссылающийся на данную запись в таблице topic)? Или же нет?

Вообще для разрешения подобных противоречий в Hibernate есть понятие owning side (главная сторона). Неглавная сторона помечается свойством mappedBy, ее ссылки имеют второстепенную важность. А главная сторона имеет приоритет.

Но несмотря на некоторую способность Hibernate разруливать противоречия с помощью owning side, все же можно столкнуться с ошибками, если не поддерживать синхронизацию вручную.

Поэтому ниже приведем методы синхронизации, которые всегда надо добавлять в двусторонние (bidirectional) отношения и пользоваться ими во избежание ошибок. Рассмотрим все отношения:

  • ManyToOne (+OneToMany)
  • OneToOne
  • ManyToMany

ManyToOne и OneToMany

Это топик с комментариями. Каждый комментарий относится ровно к одному топику, а топик содержит коллекцию комментариев.

Сначала структура в базе:

Это таблицы в базе OneToMany и ManyToOne
Это таблицы в базе, comment имеет внешний ключ topic_id, ссылающийся на топик

Ниже показаны классы. При этом owning side — Comment, ссылки в классе Comment имеют приоритет. А коллекция в topic помечена с помощью mappedBy.

Класс Comment:

@Entity
public class Comment {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private long id;
    private String text;
    @ManyToOne(fetch = FetchType.LAZY)
    private Topic topic;

   // getters/setters/constructors
}

Класс Topic:

@Entity
public class Topic {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private long id;
    private String title;

    @OneToMany(mappedBy = "topic", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Comment> comments = new ArrayList<>();

    public void addComment(Comment comment){
        this.comments.add(comment);
        comment.setTopic(this);
    }
    public void removeComment(Comment comment){
        this.comments.remove(comment);
        comment.setTopic(null);
    }
    
   // getters/setters/constructors
}

Как видите, класс Topic ссылается на Comment, а Comment — на Topic, что означает двунаправленное отношение.

Методы синхронизации находятся в классе Topic: это addComment() и removeComment().

Этими методами нужно пользоваться при добавлении/удалении комментариев в коллекцию. Они не только регулируют состав коллекции, но и присваивают комментарию правильную ссылку на топик.

Пример использования — создание топика с комментариями:

Topic topic = new Topic("Topic1");
Comment comment1 = new Comment("comment1");
Comment comment2 = new Comment("comment1");
topic.addComment(comment1);
topic.addComment(comment2);
topicRepository.save(topic);

Удаление комментария из топика:

Topic topic = topicRepository.getOne(-1l);
topic.removeComment(topic.getComments().get(0));

С помощью метода removeComment() мы удалили его из коллекции и обнулили его ссылку на топик.

Комментарий будет при этом удален и из базы, поскольку включена настройка orphanRemoval=true.

OneToOne

Рассмотрим классы User и UseDetails, которые имеют отношение OneToOne.

Схема в базе
Схема в базе OneToOne: user_details.id ссылается на user.id

Класс User:

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private long id;
    private String name;
    @OneToOne(
            mappedBy = "user",
            cascade = CascadeType.ALL,
            orphanRemoval = true,
            fetch = FetchType.LAZY
    )
    private UserDetails details;

     public void setDetails(UserDetails details) {
        if (details == null) {
            if (this.details != null) {
                this.details.setUser(null);
            }
        }
        else {
            details.setUser(this);
        }
        this.details = details;
    }

   // getters/setters/constructors

Класс UserDetails:

@Entity
public class UserDetails {
    @Id
    private long id;
    private String phone;
    @OneToOne(fetch = FetchType.LAZY)
    @MapsId
    @JoinColumn(name = "id")
    private User user;

   // getters/setters/constructors
}

Пример использования методов

Добавление UserUserDetails):

User user = new User();
user.setName("Vasya");
UserDetails details = new UserDetails();
details.setPhone("345345");
user.setDetails(details);
userRepository.save(user);

Выше мы устанавливаем userUserDetails. Поскольку setDetails() — синхронизирующий метод,  то в нем UserDetails-у также присваивается наш user. 

В базе сохраняется как user, так и details, потому что (о каскадах) cascade = CascadeType.ALL, а значит в том числе CascadeType.PERSIST.

Отсоединение UserDetails (и удаление UserDetails):

User user = userRepository.findByName("Petr");
user.setDetails(null);
userRepository.save(user);

Тут тоже null устанавливается взаимно, details перестает ссылаться на user и подлежит удалению (благодаря orphanRemoval=true).

ManyToMany

Теперь рассмотрим отношение ManyToMany. А именно авторов и книги — у одного автора может быть много книг, и у каждой книги может быть несколько авторов.

Схема в базе
Схема в базе

Главной стороной (owning side) будет Author. Здесь же и методы синхронизации addBook(), removeBook():

Класс Author:

@Entity
public class Author {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;
    private String name;
    public Author(String name){
        this.name=name;
    }
    @ManyToMany (cascade = {
        CascadeType.PERSIST,
                CascadeType.MERGE
    })
    @JoinTable(name = "author_book",
            joinColumns = @JoinColumn(name = "author_id"),
            inverseJoinColumns = @JoinColumn(name = "book_id")
    )
    private Set<Book> books=new HashSet<>();

    public void addBook(Book book){
        this.books.add(book);
        book.getAuthors().add(this);
    }

    public void removeBook(Book book){
        this.books.remove(book);
        book.getAuthors().remove(this);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o)
            return true;

        if (!(o instanceof Author)) return false;

        return id != null && id.equals(((Author) o).getId());
    }

    @Override
    public int hashCode() {
        return 31;
    }

    // getters/setters/constructors
}

В методах addBook(), removeBook() не только добавляется (удаляется) книга в коллекцию, но и на противоположной стороне в книгу добавляется (удаляется) автор.

CascadeType.REMOVE уже не используется, он противопоказан в ManyToMany отношениях.

Класс Book:

@Entity
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;
    private String name;

    @ManyToMany(mappedBy = "books")
    private Set<Author> authors=new HashSet<>();

    @Override
    public boolean equals(Object o) {
        if (this == o)
            return true;

        if (!(o instanceof Author)) return false;

        return id != null && id.equals(((Book) o).getId());
    }

    @Override
    public int hashCode() {
        return 31;
    }

   // getters/setters/constructors
}
Поскольку коллекции — Set, переопределены методы equals() и hashCode() — одинаковыми считаются @Entity с одинаковыми id. Если эти методы убрать, то в конкретных примерах ниже ничего не сломается, но хорошей практикой является их переопределять.

Пример использования

Добавим автора с книгами в базу:

Author author = new Author("a1");
author.addBook(new Book("b1"));
author.addBook(new Book("b2"));
authorRepository.save(author);

Отсоединим книгу от автора и удалим ее из базы:

Book book = bookRepository.findByName("b1");
author.removeBook(book);
bookRepository.delete(book);

Итоги

Все двунаправленные отношения необходимо синхронизировать.

Исходный код примеров есть на GitHub.

 

Синхронизация двунаправленных (bidirectional) отношений: 5 комментариев

  1. А что будет если их не синхронизировать, я просто никогда не видел каких либо проблем если нет синхронизации. По моему hibernate сам справляется с подобной синхронизацией и проставляет противоположную связь.

    1. может где-то и срабатывает, но отсутствие проблем не гарантируется, как я понимаю.

  2. Не могли бы Вы прокомментировать следующие вопросы:
    1) в ч.3 (ManyToMany), почему методы синхронизации добавлены только на главной стороне — для Author, а для Book их нет?
    2) в ч.3 (ManyToMany), с чем связано, что переопределенный метод hashCode() возвращает 31? Т.е. понятно, что все экземпляры данного класса попадут в область памяти под одним и тем же индексом, вычисленной данной хеш-функцией (поправьте, если ошибаюсь). Но почему именно число 31, а не какое-либо другое простое целое или функция? Встречал такое несколько раз, но пояснения не нашел 🙁
    3) в ч.1 (ManyToOne и OneToMany), почему в классе-сущности Topic, содержащей в качестве поля коллекцию List с комментариями, не переопределены методы equals() и hashCode(), как по аналогии с классами-сущностями в ч.3 (ManyToMany)? Т.е. равны только в том случае, если это тот же самый объект, или по уникальному идентификатору (id), и хеш-функция, возвращающая простое целое?

    Заранее признателен.

    1. 1. Можно и для Book добавить, если нужно (просто добавить add/remove по аналогии, ничего больше менять не потребуется).
      3. equals/hashCode используется, только если помещаем объект в Set/Map, для List это неважно.
      2. Случайно, в статье это значения не имеет. Лучше, конечно, иначе задать хэш-функцию. С константой 31 решение неэффективное. (Да, число 31 используется, но как множитель, а как константа оно совсем бессмысленное)

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *