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

在本章节教程中,我将介绍我们在实现 REST API 时应遵循的一些最佳实践,和开发人员常犯的一些错误以及如何避免这些错误。

在第一章中,我们将实现第一个 API 端点,即获取资源列表。我们将了解开发人员常犯的一些错误以及如何避免这些错误。

创建 Spring Boot 应用

首先,访问 https://start.springboot.io,选择 Spring WebValidationSpring Data JPAPostgreSQL DriverFlyway MigrationTestcontainers starter,创建一个 Spring Boot 应用程序。

我们的示例应用及其简单,但却是按真实应用程序中遵循的相同实践进行操作。

本教程中的示例代码,可以在 GitHub 中找到。

我们要构建的 REST API 是用来管理书签(bookmark)的。书签(bookmark)包含 idtitleurlcreatedAtupdatedAt 属性。

创建 Bookmark 实体

创建 JPA 实体 Bookmark,如下:

package com.sivalabs.bookmarks.domain;

import jakarta.persistence.*;
import java.time.Instant;

@Entity
@Table(name = "bookmarks")
class Bookmark {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(nullable = false)
    private String title;
    @Column(nullable = false)
    private String url;
    @Column(name = "created_at", nullable = false, updatable = false)
    private Instant createdAt;
    @Column(name = "updated_at", insertable = false)
    private Instant updatedAt;

    // 构造函数、get、set 方法省略
}

请注意,该实体类不是 public 的,因此其可见性仅限于 com.sivalabs.bookmarks.domain 包。

创建 Flyway 迁移脚本

我们将使用 Flyway 进行数据库迁移。要了解有关 Flyway 的更多信息,请查阅《Spring Boot Flyway 数据库迁移教程》。

src/main/resources/db/migration 目录下创建以下迁移脚本。

V1__init.sql

create table bookmarks
(
  id         bigserial primary key,
  title      varchar not null,
  url        varchar not null,
  created_at timestamp,
  updated_at timestamp
);

INSERT INTO bookmarks(title, url, created_at) VALUES
('How (not) to ask for Technical Help?','https://sivalabs.in/how-to-not-to-ask-for-technical-help', CURRENT_TIMESTAMP),
('Announcing My SpringBoot Tips Video Series on YouTube','https://sivalabs.in/announcing-my-springboot-tips-video-series', CURRENT_TIMESTAMP),
('Kubernetes - Exposing Services to outside of Cluster using Ingress','https://sivalabs.in/kubernetes-ingress', CURRENT_TIMESTAMP),
('Kubernetes - Blue/Green Deployments','https://sivalabs.in/kubernetes-blue-green-deployments', CURRENT_TIMESTAMP),
('Kubernetes - Releasing a new version of the application using Deployment Rolling Updates','https://sivalabs.in/kubernetes-deployment-rolling-updates', CURRENT_TIMESTAMP),
('Getting Started with Kubernetes','https://sivalabs.in/getting-started-with-kubernetes', CURRENT_TIMESTAMP),
('Get Super Productive with Intellij File Templates','https://sivalabs.in/get-super-productive-with-intellij-file-templates', CURRENT_TIMESTAMP),
('Few Things I learned in the HardWay in 15 years of my career','https://sivalabs.in/few-things-i-learned-the-hardway-in-15-years-of-my-career', CURRENT_TIMESTAMP),
('All the resources you ever need as a Java & Spring application developer','https://sivalabs.in/all-the-resources-you-ever-need-as-a-java-spring-application-developer', CURRENT_TIMESTAMP),
('GoLang from a Java developer perspective','https://sivalabs.in/golang-from-a-java-developer-perspective', CURRENT_TIMESTAMP),
('Imposing Code Structure Guidelines using ArchUnit','https://sivalabs.in/impose-architecture-guidelines-using-archunit', CURRENT_TIMESTAMP),
('SpringBoot Integration Testing using TestContainers Starter','https://sivalabs.in/spring-boot-integration-testing-using-testcontainers-starter', CURRENT_TIMESTAMP),
('Creating Yeoman based SpringBoot Generator','https://sivalabs.in/creating-yeoman-based-springboot-generator', CURRENT_TIMESTAMP),
('Testing REST APIs using Postman and Newman','https://sivalabs.in/testing-rest-apis-with-postman-newman', CURRENT_TIMESTAMP),
('Testing SpringBoot Applications','https://sivalabs.in/spring-boot-testing', CURRENT_TIMESTAMP)
;

创建 Repository

创建 BookmarkRepository 接口,如下:

package com.sivalabs.bookmarks.domain;

import org.springframework.data.jpa.repository.JpaRepository;

interface BookmarkRepository extends JpaRepository<Bookmark, Long> {
}

创建 BookmarkService

创建 BookmarkService,它是一个带事务的 service,暴露在 domain 包之外。

package com.sivalabs.bookmarks.domain;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)
public class BookmarkService {
    private final BookmarkRepository repo;

    BookmarkService(BookmarkRepository repo) {
        this.repo = repo;
    }

}

注意事项:

要把我们的组件设计成一个高内聚的组件,隐藏内部执行细节,以下几点非常重要:

  1. 注意,Bookmark 实体和 BookmarkRepository 不是 public 的。它们是包私有范围的类/接口。它们只应由 BookmarkService 使用,并对 com.sivalabs.bookmarks.domain 之外的包不可见。
  2. BookmarkService 是一个带事务的 service 层组件,将被 web 层或其他 service 组件使用。BookmarkService 的注解为 @Transactional(readOnly = true),这意味着所有 public 方法都是事务性的,只允许对数据库进行只读操作。对于需要执行插入/更新/删除数据库操作的方法,我们可以通过在方法上添加 @Transactional 注解来覆盖这种只读行为。

使用 Testcontainers 在本地运行应用程序

Spring Boot 3.1.0 引入了对 Testcontainers 的支持,我们可以用它来编写集成测试和本地开发。

在生成应用程序时,我们选择了 PostgreSQL DriverTestcontainers starter。因此,生成的应用程序将在 src/test/java 目录下有一个 TestApplication.java 文件,内容如下:

import org.springframework.boot.SpringApplication;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;

@TestConfiguration(proxyBeanMethods = false)
public class TestApplication {

  @Bean
  @ServiceConnection
  PostgreSQLContainer<?> postgresContainer() {
    return new PostgreSQLContainer<>(DockerImageName.parse("postgres:15.4-alpine"));
  }

  public static void main(String[] args) {
    SpringApplication
            .from(Application::main)
            .with(TestApplication.class)
            .run(args);
  }
}

我们可以通过在 IDE 运行 TestApplication.java 或在命令行中运行 ./mvnw spring-boot:test-run,在本地启动应用程序。

现在,我们已经完成了所有基本代码的设置,可以开始实现 API 端点了。让我们从实现获取所有书签的 API 端点开始。

实现 GET /api/bookmarks API 端点

我们可以按如下方式实现 GET /api/bookmarks API 端点:

先是 serivce,BookmarkService.java

@Service
@Transactional(readOnly = true)
public class BookmarkService {
    private final BookmarkRepository repo;

    BookmarkService(BookmarkRepository repo) {
        this.repo = repo;
    }

    public List<Bookmark> findAll() {
        return repo.findAll();
    }
}

然后,创建 BookmarkController,实现 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;

    BookmarkController(BookmarkService bookmarkService) {
        this.bookmarkService = bookmarkService;
    }

    @GetMapping
    List<Bookmark> findAll() {
        return bookmarkService.findAll();
    }
}

你可能会在很多教程和示例中看到这种实现方式,但这是一种不好的实现方式。

这种实现方式存在问题:

  1. 我们直接将数据库实体作为 REST API 响应暴露,在大多数情况下这是一种不好的做法。如果我们必须对实体进行任何更改,那么 API 响应格式也会随之改变,这可能并不可取。因此,我们应该创建一个 DTO,只公开 API 所需的字段。
  2. 如果我们获取数据只是为了返回给客户端,那么最好使用 DTO 投影,而不是加载实体。
  3. findAll() 方法将加载表中的所有记录,如果记录数以百万计,则可能导致 OutOfMemoryException 异常。如果表中的新数据是不断增加的,建议使用分页。

因此,让我们使用分页和 DTO 投影重新实现这个 API。

创建一个 PagedResult 类,表示通用的分页查询结果,如下:

package com.sivalabs.bookmarks.domain;

import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;

public record PagedResult<T>(
        List<T> data,
        long totalElements,
        int pageNumber,
        int totalPages,
        @JsonProperty("isFirst") boolean isFirst,
        @JsonProperty("isLast") boolean isLast,
        @JsonProperty("hasNext") boolean hasNext,
        @JsonProperty("hasPrevious") boolean hasPrevious) {}

创建 BookmarkDTO record 如下:

package com.sivalabs.bookmarks.domain;

import java.time.Instant;

public record BookmarkDTO(
        Long id,
        String title,
        String url,
        Instant createdAt) {}

现在,让我们在 BookmarkRepository 中添加一个方法,使用分页和 DTO 投影来检索书签,如下:

interface BookmarkRepository extends JpaRepository<Bookmark, Long> {
    @Query("""
               SELECT
                new com.sivalabs.bookmarks.domain.BookmarkDTO(b.id, b.title, b.url, b.createdAt)
               FROM Bookmark b
            """)
    Page<BookmarkDTO> findBookmarks(Pageable pageable);
}

创建一个类来封装所有查询参数,如下:

public record FindBookmarksQuery(int pageNo, int pageSize) {}

如果将来想给 API 增加过滤和排序功能,使用 FindBookmarksQuery 这个封装类将非常方便。

现在,更新 BookmarkService 如下:

@Service
@Transactional(readOnly = true)
public class BookmarkService {
    private final BookmarkRepository repo;
  
    BookmarkService(BookmarkRepository repo) {
      this.repo = repo;
    }
  
    public PagedResult<BookmarkDTO> findBookmarks(FindBookmarksQuery query) {
        Sort sort = Sort.by(Sort.Direction.DESC, "createdAt");
        //from user POV, page number starts from 1, but for Spring Data JPA page number starts from 0.
        int pageNo = query.pageNo() > 0 ? query.pageNo() - 1 : 0;
        Pageable pageable = PageRequest.of(pageNo, query.pageSize(), sort);
        Page<BookmarkDTO> page = repo.findBookmarks(pageable);
        return new PagedResult<>(
                page.getContent(),
                page.getTotalElements(),
                page.getNumber() + 1, // 页码从 1 开始
                page.getTotalPages(),
                page.isFirst(),
                page.isLast(),
                page.hasNext(),
                page.hasPrevious()
        );
    }
}

最后,更新 BookmarkController 如下:

@RestController
@RequestMapping("/api/bookmarks")
class BookmarkController {
    private final BookmarkService bookmarkService;

    BookmarkController(BookmarkService bookmarkService) {
        this.bookmarkService = bookmarkService;
    }

    @GetMapping
    PagedResult<BookmarkDTO> findBookmarks(
            @RequestParam(name = "page", defaultValue = "1") Integer pageNo,
            @RequestParam(name = "size", defaultValue = "10") Integer pageSize) {
        FindBookmarksQuery query = new FindBookmarksQuery(pageNo, pageSize);
        return bookmarkService.findBookmarks(query);
    }
}

现在,如果运行应用程序并访问 http://localhost:8080/api/bookmarks API 端点,就会得到如下响应:

{
    "isFirst": true,
    "isLast": false,
    "hasNext": true,
    "hasPrevious": false,
    "totalElements": 15,
    "pageNumber": 1,
    "totalPages": 2,
    "data": [
      {
        "id": 1,
        "title": "SivaLabs blog",
        "url": "https://wwww.sivalabs.in",
        "createdAt": "2023-08-22T10:24:58.956786"
      },
      ...
      ...
    ]
}

使用 RestAssured 和 Testcontainers 测试 API 端点

接下来,为我们的 API 端点编写一个自动化测试。我们将使用 RestAssured 来调用 API 端点,使用 Testcontainers 来配置 PostgreSQL 数据库。

我们应始终确保数据库处于已知状态,以便编写可预测的断言。因此,创建 src/test/resources/test_data.sql 文件,内容如下:

TRUNCATE TABLE bookmarks;
ALTER SEQUENCE bookmarks_id_seq RESTART WITH 1;

INSERT INTO bookmarks(title, url, created_at) VALUES
('How (not) to ask for Technical Help?','https://sivalabs.in/how-to-not-to-ask-for-technical-help', CURRENT_TIMESTAMP),
('Announcing My SpringBoot Tips Video Series on YouTube','https://sivalabs.in/announcing-my-springboot-tips-video-series', CURRENT_TIMESTAMP),
('Kubernetes - Exposing Services to outside of Cluster using Ingress','https://sivalabs.in/kubernetes-ingress', CURRENT_TIMESTAMP),
('Kubernetes - Blue/Green Deployments','https://sivalabs.in/kubernetes-blue-green-deployments', CURRENT_TIMESTAMP),
('Kubernetes - Releasing a new version of the application using Deployment Rolling Updates','https://sivalabs.in/kubernetes-deployment-rolling-updates', CURRENT_TIMESTAMP),
('Getting Started with Kubernetes','https://sivalabs.in/getting-started-with-kubernetes', CURRENT_TIMESTAMP),
('Get Super Productive with Intellij File Templates','https://sivalabs.in/get-super-productive-with-intellij-file-templates', CURRENT_TIMESTAMP),
('Few Things I learned in the HardWay in 15 years of my career','https://sivalabs.in/few-things-i-learned-the-hardway-in-15-years-of-my-career', CURRENT_TIMESTAMP),
('All the resources you ever need as a Java & Spring application developer','https://sivalabs.in/all-the-resources-you-ever-need-as-a-java-spring-application-developer', CURRENT_TIMESTAMP),
('GoLang from a Java developer perspective','https://sivalabs.in/golang-from-a-java-developer-perspective', CURRENT_TIMESTAMP),
('Imposing Code Structure Guidelines using ArchUnit','https://sivalabs.in/impose-architecture-guidelines-using-archunit', CURRENT_TIMESTAMP),
('SpringBoot Integration Testing using TestContainers Starter','https://sivalabs.in/spring-boot-integration-testing-using-testcontainers-starter', CURRENT_TIMESTAMP),
('Creating Yeoman based SpringBoot Generator','https://sivalabs.in/creating-yeoman-based-springboot-generator', CURRENT_TIMESTAMP),
('Testing REST APIs using Postman and Newman','https://sivalabs.in/testing-rest-apis-with-postman-newman', CURRENT_TIMESTAMP),
('Testing SpringBoot Applications','https://sivalabs.in/spring-boot-testing', CURRENT_TIMESTAMP)
;

现在,我们可以在测试方法中添加注解 @Sql("/test-data.sql"),以便在运行测试之前执行指定的 SQL 脚本。

接着,编写 API 测试代码,如下:

package com.sivalabs.bookmarks.api.controllers;

import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
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.springframework.test.context.jdbc.Sql;
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.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;

    @BeforeEach
    void setUp() {
        RestAssured.port = port;
    }

    @Test
    @Sql("/test-data.sql")
    void shouldGetBookmarksByPage() {
        given().contentType(ContentType.JSON)
                .when()
                .get("/api/bookmarks?page=1&size=10")
                .then()
                .statusCode(200)
                .body("data.size()", equalTo(10))
                .body("totalElements", equalTo(15))
                .body("pageNumber", equalTo(1))
                .body("totalPages", equalTo(2))
                .body("isFirst", equalTo(true))
                .body("isLast", equalTo(false))
                .body("hasNext", equalTo(true))
                .body("hasPrevious", equalTo(false));
    }
}

现在,运行测试,可以看到 Testcontainers 启动了一个 PostgreSQL 数据库,并且 Spring Boot 在测试运行时自动配置使用该数据库。

我们将在本系列的《Spring Boot REST API 最佳实践 - 第二章》中了解如何实现创建和更新书签的 API 端点。

总结

在 Spring Boot REST API 最佳实践系列的第一章中,我们学习了如何通过遵循一些最佳实践(如分页和 DTO 投影)来实现 API 端点,从而返回资源集合。

你可以在此 GitHub 仓库中找到本教程的示例代码。


参考:https://www.sivalabs.in/spring-boot-rest-api-best-practices-part-1/