如何在Spring Data REST和JPA之间保持双向关系?

39

如果您在使用Spring Data REST时遇到OneToManyManyToOne关系,则PUT操作会在“非拥有”实体上返回200,但实际上不会持久化已连接的资源。

示例实体:

@Entity(name = 'author')
@ToString
class AuthorEntity implements Author {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id

    String fullName

    @ManyToMany(mappedBy = 'authors')
    Set<BookEntity> books
}


@Entity(name = 'book')
@EqualsAndHashCode
class BookEntity implements Book {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id

    @Column(nullable = false)
    String title

    @Column(nullable = false)
    String isbn

    @Column(nullable = false)
    String publisher

    @ManyToMany(fetch = FetchType.LAZY, cascade = [CascadeType.ALL])
    Set<AuthorEntity> authors
}

如果你使用PagingAndSortingRepository将它们包装起来,你可以GET一个Book,在书籍上访问authors链接,并使用作者的URI进行PUT以关联。但不能反向操作。

如果你在一个Author上进行GET并在其books链接上进行PUT,则响应返回200,但关系不会被持久化。

这是期望的行为吗?


4
双向关联在对象模型中需要手动维护,通常是在设置器内完成。Spring Data REST 默认使用字段访问方式。这意味着关联的另一端不会自动反映出关联变化。您是否尝试在@PrePersist/@PreUpdate方法中触发同步?或者切换到属性访问方式? - Oliver Drotbohm
1
我也赞成解决双向关联。一个“作者”可以存在而没有“书籍”。可以使用存储库方法“Set<Book> BookRepository.findByAuthor(Author author)”检索“作者”的“书籍”。这样可以简化模型,因为您不必手动维护引用。 - Oliver Drotbohm
我可以想象一个库接口,你需要找到一个作者,然后查看它的书。如果没有双向关联,这将无法支持。如果不是DATA REST意图的话,我不喜欢管理实体关联。感谢您的输入,针对书籍作者进行过滤似乎是最好的方法。尽可能保持单向性。 :) - keaplogik
4个回答

62

简述

解决这个问题的关键不在于 Spring Data REST(因为你可以很容易地在你的场景中使其工作),而是要确保你的模型在关联的两端保持同步。

问题

这里出现的问题源于 Spring Data REST 基本上修改了你的 AuthorEntity 的 books 属性。但它本身并没有反映在 BookEntity 的 authors 属性中。这必须手动解决,这不是 Spring Data REST 提出的限制,而是 JPA 一般的工作方式。你可以通过手动调用 setter 并尝试持久化结果来重现错误的行为。

如何解决?

如果不能移除双向关联(请看下面我为什么建议这样做),使它无法工作的唯一方法就是确保关联的更改在两端都得到反映。通常人们在添加书籍时手动将作者添加到 BookEntity 中来处理这个问题:

class AuthorEntity {

  void add(BookEntity book) {

    this.books.add(book);

    if (!book.getAuthors().contains(this)) {
       book.add(this);
    }
  }
}
如果您想确保从另一侧进行的更改也能传播,那么还必须在BookEntity方面添加附加的if子句。如果没有if,那么这两种方法将不断相互调用。默认情况下,Spring Data REST使用字段访问,因此实际上没有可以将此逻辑放入其中的方法。一种选择是切换到属性访问并将逻辑放入setter中。另一个选项是使用用@PreUpdate/@PrePersist注释的方法,迭代实体并确保修改反映在两侧。如您所见,这使领域模型变得非常复杂。通常情况下,尽可能避免使用双向关系,并退而求其次,使用存储库获取构成关联背面的所有实体,则会简化问题。判断应该削减哪一面的一个好启发是考虑哪一面的关联对于您正在建模的领域真正核心和至关重要。在您的情况下,我认为作者不存在任何由她编写的书籍是完全可以的。相反,一个没有作者的书籍根本没有太多意义。因此,我会保留BookEntity中的authors属性,但在BookRepository中引入以下方法:
interface BookRepository extends Repository<Book, Long> {

  List<Book> findByAuthor(Author author);
}

是的,这需要所有以前只需调用author.getBooks()的客户端现在都要使用存储库进行操作。但好的一面是,您已经从域对象中删除了所有不必要的内容,并为书籍到作者之间创建了清晰的依赖方向。书籍依赖于作者,但反之则不然。


6
你的答案的“模板”应在stackoverflow上得到标准化:P!很棒! - geoand
2
如果我理解正确的话,您建议删除所有的OneToMany关系,只保留它们的ManyToOne对应关系? 这将是我对实体建模方式真正巨大的改变... - JR Utily
1
不,我不建议按注释使用任何东西。我建议不要使用双向关联。事实上,我甚至没有提到任何注释,所以我不完全明白你是如何得出那种解释的。剪切关联的哪一侧高度取决于用例。请重新阅读提到证明启发式的部分。 - Oliver Drotbohm
3
补充一下 JPA 规范的参考,在“JSR-000317 Java(tm) Persistence 2.0”规范的 PDF 版本中第42页(第2.9章),指出:“请注意,应用程序要负责维护运行时关系的一致性,例如,确保双向关系的“一”和“多”方在应用程序运行时更新关系时相互一致。” - JanneK
2
@OliverGierke 有趣的回复。我并不完全相信双向关联。让我们考虑一个Ticket拥有一个List<Payment>。我们可以争论一张票也可以存在没有付款,但是没有相关联的票据的付款就没有多大意义。所以我会在Payment中保留ticket属性。但是这样如何创建bean验证规则,例如简单的“支付总额不应大于票价”?你会把它放在Payment上吗?这是一个好的做法吗?谢谢。 - drenda
显示剩余2条评论

10
当我通过REST API将包含双向映射的POJO(包括@OneToMany和@ManyToOne)作为JSON发送时,遇到了类似的问题,数据被保存在父实体和子实体中,但外键关系没有建立。这是因为双向关联需要手动维护。
JPA提供了一个注释@PrePersist,可以用来确保在实体持久化之前执行带有该注释的方法。由于JPA先将父实体插入数据库,然后再插入子实体,因此我包含了一个使用@PrePersist注释的方法,它会迭代子实体列表并手动设置父实体。
在您的情况下,应该像这样:
class AuthorEntitiy {
    @PrePersist
    public void populateBooks {
        for(BookEntity book : books)
            book.addToAuthorList(this);   
    }
}

class BookEntity {
    @PrePersist
    public void populateAuthors {
        for(AuthorEntity author : authors)
            author.addToBookList(this);   
    }
}

在这之后你可能会遇到无限递归错误,为了避免这种情况,请用@JsonManagedReference注释你的父类和@JsonBackReference注释你的子类。这个解决方案对我有用,希望对你也有用。


面对相同的问题,但是 @PrePersist 对我不起作用,因为关联字段在 @PrePersist 回调中返回 null,并且其中没有设置子/父级。 - guneetgstar

1

这个链接有一个非常好的教程,可以帮助你解决递归问题:双向关系

我能够使用 @JsonManagedReference 和 @JsonBackReference,它们非常有效。


0

我相信通过在 @RepositoryEventHandler 中添加 @BeforeLinkSave 处理程序,也可以利用它来交叉链接实体之间的双向关系。这对我来说似乎是有效的。

@Component
@RepositoryEventHandler
public class BiDirectionalLinkHandler {

    @HandleBeforeLinkSave
    public void crossLink(Author author, Collection<Books> books) {
        for (Book b : books) {
            b.setAuthor(author);
        }
    }
}

注意: @HandlBeforeLinkSave函数是基于第一个参数进行调用的,如果在您的Author类等价物中有多个关系,则第二个参数应该为Object,并且您需要在该方法中测试不同的关系类型。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接