Spring Boot REST API 最佳实践 - 第二章

在本章节教程中,我将介绍我们在实现创建和更新 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 行为是,客户端只需发送 titleurl。然后,我们将自动生成 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(...) 方法?

我倾向于创建一个带有 titleurl 属性的新 CreateBookmarkCommand 类,并将其作为入参调用 BookmarkService.create(...) 方法。这似乎没有必要,因为在这种情况下,CreateBookmarkRequestCreateBookmarkCommand 完全相同。

但是,如果只有通过身份验证的用户才能调用此 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"
   }'

在目前的实现中,有几处可以改进:

  • 异常处理 - 我们将在本系列的后续章节中讨论这个问题。
  • 插入和更新 createdAtupdatedAt 的值。

在当前的实现中,我们手动设置了 createdAtupdatedAt 值,如下:

bookmark.setCreatedAt(Instant.now());

bookmark.setUpdatedAt(Instant.now());

不过,我们可以利用 JPA 和 Spring Data JPA 的一些特性,在插入或更新实体时自动设置这些值,而不是手动设置这些值。

使用 @PrePersist 和 @PreUpdate

我们可以使用 JPA 的 @PrePersist@PreUpdate 注解来自动设置 createdAtupdatedAt 值,如下:

@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 注解来自动设置 createdAtupdatedAt 值,如下所示:

@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/