Spring Boot REST API 最佳实践 - 第二章
在本章节教程中,我将介绍我们在实现创建和更新 API 端点时应遵循的一些最佳实践。
- Spring Boot REST API 最佳实践 - 第一章:实现 Get Collection API
- Spring Boot REST API 最佳实践 - 第二章:实现 Create 和 Update API(本文)
- Spring Boot REST API 最佳实践 - 第三章:实现 FindById 和 DeleteById API
- Spring Boot REST API 最佳实践 - 第四章:REST API 的异常处理
本文是 Spring Boot REST API 最佳实践 - 第一章 的续章。因此,如果你还没有阅读,请先阅读第一章。我们将在第一章中实现的代码基础上构建 API。
你可以在此 GitHub 仓库中找到本教程中的示例代码。
实现 POST /api/bookmarks API 端点
我们可以考虑按如下方式实现 POST /api/bookmarks API
端点:
package com.sivalabs.bookmarks.api.controllers;
import com.sivalabs.bookmarks.domain.BookmarkService;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/bookmarks")
class BookmarkController {
private final BookmarkService bookmarkService;
//...
//...
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
BookmarkDTO create(@RequestBody @Validated BookmarkDTO bookmark) {
return bookmarkService.create(bookmark);
}
}
乍一看,这可能没什么问题。
- 我们没有使用 JPA 实体来绑定请求 payload 或返回响应。
- 当成功创建书签资源时,我们返回正确的状态码
201
。
如果我们使用 springdoc-openapi 生成 Open API 文档,那么这段代码预期的请求 payload 如下:
{
"id": 0,
"title": "",
"url": "",
"createdAt": ""
}
当我看到请求 payload 时,我有一堆问题:
- 是在客户端生成
ID
并在 payload 中发送,还是在服务器端生成? - 如果我在请求 payload 中包含
id
,且数据库存在id
值相同的记录,是会覆盖书签详细信息,还是忽略id
并创建一个新书签? - 是应该客户端添加
createdAt
字段,还是由服务器使用记录插入数据库的时间戳? - 如果我为
createdAt
设置一个未来的日期会发生什么?
之所以会出现这些问题,是因为我们的约束中没有明确规定。
我们想要的实际 API 行为是,客户端只需发送 title
和 url
。然后,我们将自动生成 id
并使用当前时间戳作为 createdAt
值。
为了避免混淆并更清楚地说明预期的 payload 是什么,最好为这个特定的 API 端点创建一个请求类,如下所示:
package com.sivalabs.bookmarks.api.models;
import jakarta.validation.constraints.NotEmpty;
public record CreateBookmarkRequest(
@NotEmpty(message = "Title is required")
String title,
@NotEmpty(message = "URL is required")
String url) {
}
下一个问题是,我们应该返回 BookmarkDTO
还是 ResponseEntity<BookmarkDTO>
?
我更倾向于使用 ResponseEntity
作为返回类型:
- 可以针对不同类型的异常或验证错误发送不同的 HTTP 状态码。
- 可以添加 Header。
基本上,如果你想对响应进行更精细的控制,你可以选择使用 ResponseEntity
,否则你可以直接返回响应对象。
现在我们对 Controller 方法的实现有了一定的了解。那么 Service 的实现呢?
我们应该将 CreateBookmarkRequest
作为入参调用 BookmarkService.create(...)
方法吗?还是从 CreateBookmarkRequest
创建 BookmarkDTO
对象,然后作为入参调用 BookmarkService.create(...)
方法?
我倾向于创建一个带有 title
和 url
属性的新 CreateBookmarkCommand
类,并将其作为入参调用 BookmarkService.create(...)
方法。这似乎没有必要,因为在这种情况下,CreateBookmarkRequest
和 CreateBookmarkCommand
完全相同。
但是,如果只有通过身份验证的用户才能调用此 API 端点。那么,我们可能需要在 BookmarkService.create(...)
方法的入参中加入 createdBy
属性,而 CreateBookmarkRequest
中没有这个属性。因此,为了将各层的职责分开,我会使用一个单独的 command 对象。
package com.sivalabs.bookmarks.domain;
public record CreateBookmarkCommand(String title, String url) {}
下面是 POST /api/bookmarks
API 端点的最终实现。
BookmarkService.java
:
@Service
@Transactional(readOnly = true)
public class BookmarkService {
private final BookmarkRepository repo;
//...
//...
@Transactional
public BookmarkDTO create(CreateBookmarkCommand cmd) {
Bookmark bookmark = new Bookmark();
bookmark.setTitle(cmd.title());
bookmark.setUrl(cmd.url());
bookmark.setCreatedAt(Instant.now());
return BookmarkDTO.from(repo.save(bookmark));
}
}
要从 Bookmark
实体创建 BookmarkDTO
实例,我们要创建一个静态方法,如下所示:
package com.sivalabs.bookmarks.domain;
import java.time.Instant;
public record BookmarkDTO(
Long id,
String title,
String url,
Instant createdAt
) {
static BookmarkDTO from(Bookmark bookmark) {
return new BookmarkDTO(bookmark.getId(),
bookmark.getTitle(),
bookmark.getUrl(),
bookmark.getCreatedAt()
);
}
}
BookmarkController.java
:
package com.sivalabs.bookmarks.api.controllers;
@RestController
@RequestMapping("/api/bookmarks")
class BookmarkController {
private final BookmarkService bookmarkService;
//...
//...
@PostMapping
ResponseEntity<BookmarkDTO> create(@RequestBody @Validated CreateBookmarkRequest request) {
CreateBookmarkCommand cmd = new CreateBookmarkCommand(request.title(), request.url());
BookmarkDTO bookmark = bookmarkService.create(cmd);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/api/bookmarks/{id}")
.buildAndExpand(bookmark.id()).toUri();
return ResponseEntity.created(location).body(bookmark);
}
}
现在,启动应用程序,并使用 CURL 调用 API 端点,如下:
$ curl --location 'http://localhost:8080/api/bookmarks' \
--header 'Content-Type: application/json' \
--data '{
"title": "SivaLabs blog",
"url": "https://sivalabs.in"
}'
// response
{"id":17,"title":"SivaLabs blog","url":"https://sivalabs.in","createdAt":"2023-08-23T04:24:17.975268Z"}
实现 PUT /api/bookmarks/{id} API 端点
我们将遵循类似的模式,实现用于更新书签的 PUT /api/bookmarks/{id}
API 端点。
UpdateBookmarkCommand.java
:
package com.sivalabs.bookmarks.domain;
public record UpdateBookmarkCommand(
Long id,
String title,
String url) {
}
BookmarkNotFoundException.java
:
package com.sivalabs.bookmarks.domain;
public class BookmarkNotFoundException extends RuntimeException {
public BookmarkNotFoundException(Long id) {
super(String.format("Bookmark with id=%d not found", id));
}
public static BookmarkNotFoundException of(Long id) {
return new BookmarkNotFoundException(id);
}
}
BookmarkService.java
:
package com.sivalabs.bookmarks.domain;
@Service
@Transactional(readOnly = true)
public class BookmarkService {
private final BookmarkRepository repo;
//...
//...
@Transactional
public void update(UpdateBookmarkCommand cmd) {
Bookmark bookmark = repo.findById(cmd.id())
.orElseThrow(() -> BookmarkNotFoundException.of(cmd.id()));
bookmark.setTitle(cmd.title());
bookmark.setUrl(cmd.url());
bookmark.setUpdatedAt(Instant.now());
repo.save(bookmark);
}
}
UpdateBookmarkRequest.java
:
package com.sivalabs.bookmarks.api.models;
import jakarta.validation.constraints.NotEmpty;
public record UpdateBookmarkRequest(
@NotEmpty(message = "Title is required")
String title,
@NotEmpty(message = "URL is required")
String url) {
}
BookmarkController.java
:
package com.sivalabs.bookmarks.api.controllers;
@RestController
@RequestMapping("/api/bookmarks")
class BookmarkController {
private final BookmarkService bookmarkService;
@PutMapping("/{id}")
void update(@PathVariable(name = "id") Long id,
@RequestBody @Validated UpdateBookmarkRequest request) {
UpdateBookmarkCommand cmd = new UpdateBookmarkCommand(id, request.title(), request.url());
bookmarkService.update(cmd);
}
}
你可以尝试使用 CURL 调用该端点,如下:
$ curl -v --location --request PUT 'http://localhost:8080/api/bookmarks/17' \
--header 'Content-Type: application/json' \
--data '{
"title": "SivaLabs - TechBlog",
"url": "https://www.sivalabs.in"
}'
在目前的实现中,有几处可以改进:
- 异常处理 - 我们将在本系列的后续章节中讨论这个问题。
- 插入和更新
createdAt
和updatedAt
的值。
在当前的实现中,我们手动设置了 createdAt
和 updatedAt
值,如下:
bookmark.setCreatedAt(Instant.now());
bookmark.setUpdatedAt(Instant.now());
不过,我们可以利用 JPA 和 Spring Data JPA 的一些特性,在插入或更新实体时自动设置这些值,而不是手动设置这些值。
使用 @PrePersist 和 @PreUpdate
我们可以使用 JPA 的 @PrePersist
和 @PreUpdate
注解来自动设置 createdAt
和 updatedAt
值,如下:
@Entity
class Bookmark {
...
...
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", insertable = false)
private Instant updatedAt;
@PreUpdate
@PrePersist
public void updateTimeStamps() {
updatedAt = Instant.now();
if (createdAt == null) {
createdAt = Instant.now();
}
}
}
使用 Spring Data JPA 的 @CreatedDate 和 @LastModifiedDate
你还可以使用 Spring Data JPA 的 @CreatedDate
和 @LastModifiedDate
注解来自动设置 createdAt
和 updatedAt
值,如下所示:
@Entity
class Bookmark {
...
...
@Column(name = "created_at", nullable = false, updatable = false)
@CreatedDate
private Instant createdAt;
@Column(name = "updated_at", insertable = false)
@LastModifiedDate
private Instant updatedAt;
}
Hibernate 也为类似目的提供了 @CreationTimestamp
和 @UpdateTimestamp
注解。但我更喜欢使用上述两种方法中的一种,以保持代码独立于底层持久化实现框架。
使用哪种 Java 数据类型用于在数据库中存储日期或时间戳?
可以参考此 StackOverflow 答案,了解哪种 Java 数据类型更适合用于在数据库中存储日期或时间戳值。
使用 RestAssured 和 Testcontainers 测试 API 端点
为 API 端点编写测试,如下:
package com.sivalabs.bookmarks.api.controllers;
import com.sivalabs.bookmarks.domain.BookmarkDTO;
import com.sivalabs.bookmarks.domain.BookmarkService;
import com.sivalabs.bookmarks.domain.CreateBookmarkCommand;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.matchesRegex;
import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.nullValue;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Testcontainers
class BookmarkControllerTests {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>(DockerImageName.parse("postgres:15.4-alpine"));
@LocalServerPort
private Integer port;
@Autowired
private BookmarkService bookmarkService;
@BeforeEach
void setUp() {
RestAssured.port = port;
}
@Test
void shouldCreateBookmarkSuccessfully() {
given().contentType(ContentType.JSON)
.body(
"""
{
"title": "SivaLabs blog",
"url": "https://sivalabs.in"
}
""")
.when()
.post("/api/bookmarks")
.then()
.statusCode(201)
.header("Location", matchesRegex(".*/api/bookmarks/[0-9]+$"))
.body("id", notNullValue())
.body("title", equalTo("SivaLabs blog"))
.body("url", equalTo("https://sivalabs.in"))
.body("createdAt", notNullValue())
.body("updatedAt", nullValue());
}
@Test
void shouldUpdateBookmarkSuccessfully() {
CreateBookmarkCommand cmd = new CreateBookmarkCommand("SivaLabs blog", "https://sivalabs.in");
BookmarkDTO bookmark = bookmarkService.create(cmd);
given().contentType(ContentType.JSON)
.body(
"""
{
"title": "SivaLabs - Tech Blog",
"url": "https://www.sivalabs.in"
}
""")
.when()
.put("/api/bookmarks/{id}", bookmark.id())
.then()
.statusCode(200);
}
}
现在,你可以使用 ./mvnw test
命令行运行测试。
我们将在本系列的 第三章 中了解如何实现 FindById 和 DeleteById API 端点。
总结
在 “Spring Boot REST API 最佳实践系列的第二章” 中,我们学习了如何通过遵循一些最佳实践来实现创建和更新资源的 API 端点。
你可以在此 GitHub 仓库中找到本教程的示例代码。
参考:https://www.sivalabs.in/spring-boot-rest-api-best-practices-part-2/