1. 概述

本文将深入探讨 Spring Data REST 中实体关系的处理方式。我们将重点关注 Spring Data REST 为仓库暴露的关联资源,涵盖所有可定义的关系类型。

为简化环境配置,示例中使用 H2 嵌入式数据库。所需依赖列表可参考我们的 Spring Data REST 入门 文章。

2. 一对一关系

2.1. 数据模型

定义两个实体类 LibraryAddress,通过 @OneToOne 注解建立一对一关系。关联由 Library 端维护:

@Entity
public class Library {

    @Id
    @GeneratedValue
    private long id;

    @Column
    private String name;

    @OneToOne
    @JoinColumn(name = "address_id")
    @RestResource(path = "libraryAddress", rel="address")
    private Address address;
    
    // 标准构造器、getter/setter
}
@Entity
public class Address {

    @Id
    @GeneratedValue
    private long id;

    @Column(nullable = false)
    private String location;

    @OneToOne(mappedBy = "address")
    private Library library;

    // 标准构造器、getter/setter
}

@RestResource 注解是可选的,用于自定义接口。

⚠️ 务必确保每个关联资源名称唯一,否则会抛出 JsonMappingException 异常,提示 "Detected multiple association links with same relation type! Disambiguate association."

关联名称默认为属性名,可通过 @RestResourcerel 属性自定义:

@OneToOne
@JoinColumn(name = "secondary_address_id")
@RestResource(path = "libraryAddress", rel="address")
private Address secondaryAddress;

若在 Library 类中添加上述 secondaryAddress 属性,会导致两个名为 address 的资源冲突。可通过指定不同的 rel 值或省略 RestResource 注解(此时资源名默认为 secondaryAddress)解决。

2.2. 仓库定义

将实体暴露为资源,需为每个实体创建继承 CrudRepository 的仓库接口:

public interface LibraryRepository extends CrudRepository<Library, Long> {}
public interface AddressRepository extends CrudRepository<Address, Long> {}

2.3. 创建资源

首先创建一个 Library 实例:

curl -i -X POST -H "Content-Type:application/json" 
  -d '{"name":"My Library"}' http://localhost:8080/libraries

API 返回 JSON 对象:

{
  "name" : "My Library",
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/libraries/1"
    },
    "library" : {
      "href" : "http://localhost:8080/libraries/1"
    },
    "address" : {
      "href" : "http://localhost:8080/libraries/1/libraryAddress"
    }
  }
}

💡 Windows 下使用 curl 需转义双引号:

-d "{\"name\":\"My Library\"}"

响应显示关联资源已暴露在 libraries/{libraryId}/address 接口。

创建关联前,GET 请求该接口将返回空对象。

要添加关联,需先创建 Address 实例:

curl -i -X POST -H "Content-Type:application/json" 
  -d '{"location":"Main Street nr 5"}' http://localhost:8080/addresses

POST 请求返回包含 Address 记录的 JSON:

{
  "location" : "Main Street nr 5",
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/addresses/1"
    },
    "address" : {
      "href" : "http://localhost:8080/addresses/1"
    },
    "library" : {
      "href" : "http://localhost:8080/addresses/1/library"
    }
  }
}

2.4. 建立关联

持久化两个实例后,可通过关联资源建立关系。使用 HTTP PUT 方法,支持 text/uri-list 媒体类型,请求体包含要绑定的资源 URI。

由于 Library 是关联拥有方,我们为图书馆添加地址:

curl -i -X PUT -d "http://localhost:8080/addresses/1" 
  -H "Content-Type:text/uri-list" http://localhost:8080/libraries/1/libraryAddress

成功返回状态码 204。可通过检查 addresslibrary 关联资源验证:

curl -i -X GET http://localhost:8080/addresses/1/library

应返回名为 "My Library"Library JSON 对象。

解除关联 时,需对关联拥有方的关联资源调用 DELETE 方法:

curl -i -X DELETE http://localhost:8080/libraries/1/libraryAddress

3. 一对多关系

通过 @OneToMany@ManyToOne 注解定义一对多关系,可添加可选的 @RestResource 自定义关联资源。

3.1. 数据模型

为展示一对多关系,添加新实体 Book,表示与 Library 关系中的"多"端:

@Entity
public class Book {

    @Id
    @GeneratedValue
    private long id;
    
    @Column(nullable=false)
    private String title;
    
    @ManyToOne
    @JoinColumn(name="library_id")
    private Library library;
    
    // 标准构造器、getter/setter
}

Library 类中添加关系:

public class Library {
 
    //...
 
    @OneToMany(mappedBy = "library")
    private List<Book> books;
 
    //...
 
}

3.2. 仓库定义

创建 BookRepository

public interface BookRepository extends CrudRepository<Book, Long> { }

3.3. 关联资源操作

向图书馆添加书籍 时,需先通过 /books 集合资源创建 Book 实例:

curl -i -X POST -d "{\"title\":\"Book1\"}" 
  -H "Content-Type:application/json" http://localhost:8080/books

POST 请求响应:

{
  "title" : "Book1",
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/books/1"
    },
    "book" : {
      "href" : "http://localhost:8080/books/1"
    },
    "bookLibrary" : {
      "href" : "http://localhost:8080/books/1/library"
    }
  }
}

响应显示关联接口 /books/{bookId}/library 已创建。

现在 将书籍与之前创建的图书馆关联,向关联资源发送包含图书馆资源 URI 的 PUT 请求:

curl -i -X PUT -H "Content-Type:text/uri-list" 
-d "http://localhost:8080/libraries/1" http://localhost:8080/books/1/library

通过 GET 请求图书馆的 /books 关联资源 验证书籍

curl -i -X GET http://localhost:8080/libraries/1/books

返回的 JSON 包含 books 数组:

{
  "_embedded" : {
    "books" : [ {
      "title" : "Book1",
      "_links" : {
        "self" : {
          "href" : "http://localhost:8080/books/1"
        },
        "book" : {
          "href" : "http://localhost:8080/books/1"
        },
        "bookLibrary" : {
          "href" : "http://localhost:8080/books/1/library"
        }
      }
    } ]
  },
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/libraries/1/books"
    }
  }
}

解除关联 时,对关联资源使用 DELETE 方法:

curl -i -X DELETE http://localhost:8080/books/1/library

4. 多对多关系

通过 @ManyToMany 注解定义多对多关系,可添加 @RestResource 自定义。

4.1. 数据模型

为展示多对多关系,添加新实体 Author,与 Book 建立关系:

@Entity
public class Author {

    @Id
    @GeneratedValue
    private long id;

    @Column(nullable = false)
    private String name;

    @ManyToMany(cascade = CascadeType.ALL)
    @JoinTable(name = "book_author", 
      joinColumns = @JoinColumn(name = "book_id", referencedColumnName = "id"), 
      inverseJoinColumns = @JoinColumn(name = "author_id", 
      referencedColumnName = "id"))
    private List<Book> books;

    // 标准构造器、getter/setter
}

Book 类中添加关联:

public class Book {
 
    //...
 
    @ManyToMany(mappedBy = "books")
    private List<Author> authors;
 
    //...
}

4.2. 仓库定义

创建管理 Author 实体的仓库接口:

public interface AuthorRepository extends CrudRepository<Author, Long> { }

4.3. 关联资源操作

与前文类似,建立关联前需先创建资源

/authors 集合资源发送 POST 请求创建 Author 实例:

curl -i -X POST -H "Content-Type:application/json" 
  -d "{\"name\":\"author1\"}" http://localhost:8080/authors

添加第二个 Book 记录:

curl -i -X POST -H "Content-Type:application/json" 
  -d "{\"title\":\"Book 2\"}" http://localhost:8080/books

Author 记录执行 GET 请求查看关联 URL:

{
  "name" : "author1",
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/authors/1"
    },
    "author" : {
      "href" : "http://localhost:8080/authors/1"
    },
    "books" : {
      "href" : "http://localhost:8080/authors/1/books"
    }
  }
}

现在使用接口 authors/1/books 通过 PUT 方法 创建两个 Book 记录与 Author 记录的关联。该方法支持 text/uri-list 媒体类型,可接收多个 URI。

发送多个 URI 时需用换行符分隔:

curl -i -X PUT -H "Content-Type:text/uri-list" 
  --data-binary @uris.txt http://localhost:8080/authors/1/books

uris.txt 文件包含书籍 URI(每行一个):

http://localhost:8080/books/1
http://localhost:8080/books/2

验证两本书是否关联到作者,向关联接口发送 GET 请求:

curl -i -X GET http://localhost:8080/authors/1/books

响应如下:

{
  "_embedded" : {
    "books" : [ {
      "title" : "Book 1",
      "_links" : {
        "self" : {
          "href" : "http://localhost:8080/books/1"
        }
      //...
      }
    }, {
      "title" : "Book 2",
      "_links" : {
        "self" : {
          "href" : "http://localhost:8080/books/2"
        }
      //...
      }
    } ]
  },
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/authors/1/books"
    }
  }
}

解除关联 时,向关联资源 URL 后追加 {bookId} 并发送 DELETE 请求:

curl -i -X DELETE http://localhost:8080/authors/1/books/1

5. 使用 TestRestTemplate 测试接口

创建测试类注入 TestRestTemplate 实例,并定义所需常量:

@RunWith(SpringRunner.class)
@SpringBootTest(classes = SpringDataRestApplication.class, 
  webEnvironment = WebEnvironment.DEFINED_PORT)
public class SpringDataRelationshipsTest {

    @Autowired
    private TestRestTemplate template;

    private static String BOOK_ENDPOINT = "http://localhost:8080/books/";
    private static String AUTHOR_ENDPOINT = "http://localhost:8080/authors/";
    private static String ADDRESS_ENDPOINT = "http://localhost:8080/addresses/";
    private static String LIBRARY_ENDPOINT = "http://localhost:8080/libraries/";

    private static String LIBRARY_NAME = "My Library";
    private static String AUTHOR_NAME = "George Orwell";
}

5.1. 测试一对一关系

创建 @Test 方法:通过 POST 请求保存 LibraryAddress 对象,然后通过 PUT 请求向关联资源保存关系,最后通过 GET 请求同一资源验证关系建立:

@Test
public void whenSaveOneToOneRelationship_thenCorrect() {
    Library library = new Library(LIBRARY_NAME);
    template.postForEntity(LIBRARY_ENDPOINT, library, Library.class);
   
    Address address = new Address("Main street, nr 1");
    template.postForEntity(ADDRESS_ENDPOINT, address, Address.class);
    
    HttpHeaders requestHeaders = new HttpHeaders();
    requestHeaders.add("Content-type", "text/uri-list");
    HttpEntity<String> httpEntity 
      = new HttpEntity<>(ADDRESS_ENDPOINT + "/1", requestHeaders);
    template.exchange(LIBRARY_ENDPOINT + "/1/libraryAddress", 
      HttpMethod.PUT, httpEntity, String.class);

    ResponseEntity<Library> libraryGetResponse 
      = template.getForEntity(ADDRESS_ENDPOINT + "/1/library", Library.class);
    assertEquals("library is incorrect", 
      libraryGetResponse.getBody().getName(), LIBRARY_NAME);
}

5.2. 测试一对多关系

创建 @Test 方法:保存一个 Library 实例和两个 Book 实例,向每个 Book 对象的 /library 关联资源发送 PUT 请求,验证关系保存成功:

@Test
public void whenSaveOneToManyRelationship_thenCorrect() {
    Library library = new Library(LIBRARY_NAME);
    template.postForEntity(LIBRARY_ENDPOINT, library, Library.class);

    Book book1 = new Book("Dune");
    template.postForEntity(BOOK_ENDPOINT, book1, Book.class);

    Book book2 = new Book("1984");
    template.postForEntity(BOOK_ENDPOINT, book2, Book.class);

    HttpHeaders requestHeaders = new HttpHeaders();
    requestHeaders.add("Content-Type", "text/uri-list");    
    HttpEntity<String> bookHttpEntity 
      = new HttpEntity<>(LIBRARY_ENDPOINT + "/1", requestHeaders);
    template.exchange(BOOK_ENDPOINT + "/1/library", 
      HttpMethod.PUT, bookHttpEntity, String.class);
    template.exchange(BOOK_ENDPOINT + "/2/library", 
      HttpMethod.PUT, bookHttpEntity, String.class);

    ResponseEntity<Library> libraryGetResponse = 
      template.getForEntity(BOOK_ENDPOINT + "/1/library", Library.class);
    assertEquals("library is incorrect", 
      libraryGetResponse.getBody().getName(), LIBRARY_NAME);
}

5.3. 测试多对多关系

测试 BookAuthor 实体的多对多关系:保存一个 Author 记录和两个 Book 记录,向 /books 关联资源发送包含两个书籍 URI 的 PUT 请求,验证关系建立:

@Test
public void whenSaveManyToManyRelationship_thenCorrect() {
    Author author1 = new Author(AUTHOR_NAME);
    template.postForEntity(AUTHOR_ENDPOINT, author1, Author.class);

    Book book1 = new Book("Animal Farm");
    template.postForEntity(BOOK_ENDPOINT, book1, Book.class);

    Book book2 = new Book("1984");
    template.postForEntity(BOOK_ENDPOINT, book2, Book.class);

    HttpHeaders requestHeaders = new HttpHeaders();
    requestHeaders.add("Content-type", "text/uri-list");
    HttpEntity<String> httpEntity = new HttpEntity<>(
      BOOK_ENDPOINT + "/1\n" + BOOK_ENDPOINT + "/2", requestHeaders);
    template.exchange(AUTHOR_ENDPOINT + "/1/books", 
      HttpMethod.PUT, httpEntity, String.class);

    String jsonResponse = template
      .getForObject(BOOK_ENDPOINT + "/1/authors", String.class);
    JSONObject jsonObj = new JSONObject(jsonResponse).getJSONObject("_embedded");
    JSONArray jsonArray = jsonObj.getJSONArray("authors");
    assertEquals("author is incorrect", 
      jsonArray.getJSONObject(0).getString("name"), AUTHOR_NAME);
}

6. 总结

本文演示了在 Spring Data REST 中处理不同类型关系的方法。完整示例代码可在 GitHub 获取。


原始标题:Working with Relationships in Spring Data REST | Baeldung