Spring Boot REST API 最佳实践 - 第一章
在本章节教程中,我将介绍我们在实现 REST 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 的异常处理
在第一章中,我们将实现第一个 API 端点,即获取资源列表。我们将了解开发人员常犯的一些错误以及如何避免这些错误。
创建 Spring Boot 应用
首先,访问 https://start.springboot.io,选择 Spring Web
、Validation
、Spring Data JPA
、PostgreSQL Driver
、Flyway Migration
和 Testcontainers
starter,创建一个 Spring Boot 应用程序。
我们的示例应用及其简单,但却是按真实应用程序中遵循的相同实践进行操作。
本教程中的示例代码,可以在 GitHub 中找到。
我们要构建的 REST API 是用来管理书签(bookmark)的。书签(bookmark)包含 id
、title
、url
、createdAt
和 updatedAt
属性。
创建 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;
}
}
注意事项:
要把我们的组件设计成一个高内聚的组件,隐藏内部执行细节,以下几点非常重要:
- 注意,
Bookmark
实体和BookmarkRepository
不是public
的。它们是包私有范围的类/接口。它们只应由BookmarkService
使用,并对com.sivalabs.bookmarks.domain
之外的包不可见。 BookmarkService
是一个带事务的 service 层组件,将被 web 层或其他 service 组件使用。BookmarkService
的注解为@Transactional(readOnly = true)
,这意味着所有public
方法都是事务性的,只允许对数据库进行只读操作。对于需要执行插入/更新/删除数据库操作的方法,我们可以通过在方法上添加@Transactional
注解来覆盖这种只读行为。
使用 Testcontainers 在本地运行应用程序
Spring Boot 3.1.0 引入了对 Testcontainers 的支持,我们可以用它来编写集成测试和本地开发。
在生成应用程序时,我们选择了 PostgreSQL Driver
和 Testcontainers
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();
}
}
你可能会在很多教程和示例中看到这种实现方式,但这是一种不好的实现方式。
这种实现方式存在问题:
- 我们直接将数据库实体作为 REST API 响应暴露,在大多数情况下这是一种不好的做法。如果我们必须对实体进行任何更改,那么 API 响应格式也会随之改变,这可能并不可取。因此,我们应该创建一个 DTO,只公开 API 所需的字段。
- 如果我们获取数据只是为了返回给客户端,那么最好使用 DTO 投影,而不是加载实体。
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/