添加网络控制器功能
按照清单 4-8 的示例创建 UsersController 类。
package com.apress.users;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.net.URI;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@AllArgsConstructor
@RestController
@RequestMapping("/users")
public class UsersController {
private SimpleRepository<User,Integer> userRepository;
@GetMapping
public ResponseEntity<Iterable<User>> getAll(){
return ResponseEntity.ok(this.userRepository.findAll());
}
@GetMapping("/{id}")
public ResponseEntity<User> findUserById(@PathVariable Integer id){
return ResponseEntity.of(this.userRepository.findById(id));
}
@RequestMapping(method = {RequestMethod.POST,RequestMethod.PUT})
public ResponseEntity<User> save(@RequestBody @Valid User user){
User result = this.userRepository.save(user);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(user)
.toUri();
return ResponseEntity.created(location).body(this.userRepository.findById(result.id()).get());
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Integer id){
this.userRepository.deleteById(id);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handleValidationExceptions(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach((error) -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
errors.put("time", LocalDateTime.now().format(java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME));
return errors;
}
@ExceptionHandler(HttpMessageNotReadableException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String,Object> handleHttpMessageNotReadableException(HttpMessageNotReadableException ex){
Map<String,Object> errors = new HashMap<>();
errors.put("code",HttpStatus.BAD_REQUEST.value());
errors.put("message",ex.getMessage());
errors.put("time", LocalDateTime.now().format(java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME));
return errors;
}
}
列表 4-8 源代码:src/main/java/apress/com/users/UsersController.java
UsersController 类现在应该很熟悉了。我们正在使用基于注解的编程来创建一个网络控制器,以响应任何 /users 端点的请求。请注意,save 方法对 User 类使用了 @Valid 注解。实际上,验证不会被触发,这是因为首先是对象的构造,然后才是验证。如果你还记得,我们的新 User 是记录类型,并且在紧凑构造函数中有一些验证;这意味着如果字段在对象中不符合要求,构造函数会返回错误。requireNonNull 调用或模式匹配器逻辑,如果逻辑失败,构造函数将抛出 IllegalArgumentException,这将导致 HttpMessageNotReadableException,并调用 handleHttpMessageNotReadableException。
在应用准备好时添加用户
按照清单 4-9 的示例创建 UserConfiguration 类。
package com.apress.users;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class UserConfiguration {
@Bean
ApplicationListener<ApplicationReadyEvent> init(SimpleRepository userRepository) {
return applicationReadyEvent -> {
User ximena = User.builder()
.email("ximena@email.com")
.name("Ximena")
.password("aw2s0meR!")
.active(true)
.role(UserRole.USER)
.build();
userRepository.save(ximena);
User norma = User.builder()
.email("norma@email.com")
.name("Norma")
.password("aw2s0meR!")
.active(true)
.role(UserRole.USER)
.role(UserRole.ADMIN)
.build();
userRepository.save(norma);
};
}
}
用户配置文件的列表 4-9src/main/java/apress/com/users/UserConfiguration.java
UserConfiguration 类被标记为 @Configuration 注解,这意味着 Spring Boot 会执行任何 @Value、@Bean 等的声明。在这种情况下,我们有一个 @Bean,它将创建一个 ApplicationListener,这意味着它将在应用程序准备好时执行。此外,请注意它将 SimpleRepository 接口作为参数,而目前我们只有 UserRepository 的实现,因此它将在这里被注入。这个方法创建了两个用户并将它们保存到我们的数据库中,但到底是哪个数据库?H2 还是 PostgreSQL? 如果没有声明属性(如驱动程序、用户名和密码),自动配置将默认使用 H2;但如果我们提供了驱动程序、用户名和密码,自动配置将使用这些值,并尝试创建 DataSource 对象以连接到数据库并执行任何 SQL 语句。
接下来,打开 application.properties 文件,并添加清单 4-10 中所示的内容。
# H2
spring.h2.console.enabled=true
# DataSource
spring.datasource.generate-unique-name=false
spring.datasource.name=test-db
列表 4-10 源文件:src/main/resources/application.properties
以下是 application.properties 文件中新增的属性:
- 此属性使我们能够在开发环境中使用 /h2-console 端点,该端点提供一个简易的用户界面,用于操作 H2 引擎的内存数据库。默认情况下该属性为 false,但我们将其启用。
- 默认情况下,Spring Boot 会自动生成数据库名称,因此将此属性设置为 false 可以停止这一逻辑。
- 这个属性将我们的数据库命名为 test-db,无论使用什么数据库引擎。
本章稍后我们将讨论一些对项目非常重要的属性。
数据库的初始化
Spring Boot 的一个重要特性是,当你使用 spring-boot-starter-jdbc 启动器时,可以添加 SQL 文件,这些文件如果在类路径中被找到,将会自动执行。它们必须遵循特定的命名规则。在这种情况下,schema.sql 文件用于执行创建、删除等数据库操作,而 data.sql 文件则用于包含所有的 INSERT 或 UPDATE 语句。你可以有多个 schema 文件,甚至可以被数据库引擎识别,例如可以有 schema-h2.sql 和 schema-postgresql.sql;data-{engine}.sql 文件也是如此。要使用此功能,重要的是设置 spring.sql.init.platform 属性,并选择 h2、postgresql、mysql、oracle、hsqldb 等数据库。
接下来,按照清单 4-11 的示例创建 schema.sql 文件。我们不需要 data.sql 文件,因为在 UserConfiguration 类中已经创建了一些用户。
DROP TABLE IF EXISTS USERS CASCADE;
CREATE TABLE USERS
(
EMAIL VARCHAR(255) NOT NULL UNIQUE,
NAME VARCHAR(100) NOT NULL,
GRAVATAR_URL VARCHAR(255) NOT NULL,
PASSWORD VARCHAR(255) NOT NULL,
USER_ROLE VARCHAR(5) ARRAY NOT NULL DEFAULT ARRAY ['INFO'],
ACTIVE BOOLEAN NOT NULL,
PRIMARY KEY (ID)
);
列表 4-11 源文件:src/main/resources/schema.sql
列表 4-11 展示了应用程序启动时将执行的 SQL 语句。正如你所见,这个语句非常简单。
接下来,创建 index.html 文件(这只是为了在访问 / 端点时渲染一些内容)。你可以从其他项目中复制,并将其放置或创建在 src/main/resources/static 文件夹中。请记住,在使用 Spring Boot 的 Web 应用程序中,如果在 static(或 public/)文件夹中找到 index.html 文件,它将被渲染。现在我们准备好了!
用户应用测试
现在我们需要再次测试我们的应用程序。请创建清单 4-12 中所示的 UsersHttpRequestTests 类。
package com.apress.users;
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.client.TestRestTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import java.util.Arrays;
import java.util.Collection;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class UsersHttpRequestTests {
@Value("${local.server.port}")
private int port;
private final String BASE_URL = "http://localhost:";
private final String USERS_PATH = "/users";
@Autowired
private TestRestTemplate restTemplate;
@Test
public void indexPageShouldReturnHeaderOneContent() throws Exception {
assertThat(this.restTemplate.getForObject(BASE_URL + port,
String.class)).contains("Simple Users Rest Application");
}
@Test
public void usersEndPointShouldReturnCollectionWithTwoUsers() throws Exception {
Collection<User> response = this.restTemplate.
getForObject(BASE_URL + port + USERS_PATH, Collection.class);
assertThat(response.size()).isGreaterThan(1);
}
@Test
public void shouldRetrunErrorWhenPostBadUserForm() throws Exception {
assertThatThrownBy(() -> {
User user = User.builder()
.email("bademail")
.name("Dummy")
.active(true)
.password("aw2s0")
.build();
}).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Password must be at least 8 characters long and contain at least one number, one uppercase, one lowercase and one special character");
}
@Test
public void userEndPointPostNewUserShouldReturnUser() throws Exception {
User user = User.builder()
.email("dummy@email.com")
.name("Dummy")
.password("aw2s0meR!")
.active(true)
.role(UserRole.USER)
.build();
User response = this.restTemplate.postForObject(BASE_URL + port + USERS_PATH,user,User.class);
assertThat(response).isNotNull();
assertThat(response.email()).isEqualTo(user.email());
Collection<User> users = this.restTemplate.
getForObject(BASE_URL + port + USERS_PATH, Collection.class);
assertThat(users.size()).isGreaterThanOrEqualTo(2);
}
@Test
public void userEndPointDeleteUserShouldReturnVoid() throws Exception {
this.restTemplate.delete(BASE_URL + port + USERS_PATH + "/norma@email.com");
Collection<User> users = this.restTemplate.
getForObject(BASE_URL + port + USERS_PATH, Collection.class);
assertThat(users.size()).isLessThanOrEqualTo(2);
}
@Test
public void userEndPointFindUserShouldReturnUser() throws Exception{
User user = this.restTemplate.getForObject(BASE_URL + port + USERS_PATH + "/1",User.class);
assertThat(user).isNotNull();
assertThat(user.email()).isEqualTo("ximena@email.com");
}
}
列表 4-12 源代码:src/test/java/apress/com/users/UsersHttpRequestTests.java
列表 4-12 展示了我们用户应用程序的测试。与之前的版本唯一不同的是用户实例的创建。在运行代码之前,请先分析这些测试。
当你准备好后,可以在你的 IDE 中运行测试,或者通过执行以下命令来进行测试:
./gradlew clean test
..
UsersHttpRequestTests > userEndPointFindUserShouldReturnUser() PASSED
UsersHttpRequestTests > userEndPointDeleteUserShouldReturnVoid() PASSED
UsersHttpRequestTests > shouldRetrunErrorWhenPostBadUserForm() PASSED
UsersHttpRequestTests > indexPageShouldReturnHeaderOneContent() PASSED
UsersHttpRequestTests > userEndPointPostNewUserShouldReturnUser() PASSED
UsersHttpRequestTests > usersEndPointShouldReturnCollectionWithTwoUsers() PASSED
..
但是等等……我们的测试怎么了?
我们的测试通过了,但这是怎么做到的呢?数据存储在哪里?如果我们声明了两个驱动程序,使用的是哪个数据库引擎?你知道答案吗?你有没有注意到我们缺少了什么?
我们缺少连接参数!缺少 URL、用户名、密码和数据库驱动!我们需要使用这些参数来设置 DataSource(JdbcTemplate 依赖于 DataSource,因此必须进行声明)。
通常,我们会在命令行、环境变量或 application.properties/yaml 文件中声明这些参数,格式大致如下:
spring.datasource.url=jdbc:h2:mem:test-db;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
spring.datasource.username=sa
spring.datasource.password=
spring.datasource.driver-class-name=org.h2.Driver
多亏了 Spring Boot 的自动配置功能,我们不再需要手动设置这些。它会自动检测到我们有两个驱动程序,但没有设置连接参数,因此会使用 H2 嵌入式驱动程序来设置默认值,并为 JdbcTemplate 类创建 DataSource,执行 schema.sql 脚本,完成了!现在我们有一个完全功能的应用程序,使用的是 H2 内存数据库引擎。
这个数据库初始化(执行 schema.sql 和 data.sql)仅在嵌入式数据库如 H2、HSQL 和 Derby 中进行。如果我们希望无论使用什么数据库引擎都能进行初始化,就需要将 spring.sql.init.mode 属性设置为 always。
启动用户应用程序
让我们运行应用程序,看看一些端点的实际效果。您可以通过 IDE 运行它,或者在终端中使用以下命令:
./gradlew bootRun
你应该在控制台输出中看到如下内容:
..
o.s.b.a.h2.H2ConsoleAutoConfiguration : H2 console available at '/h2-console'. Database available at 'jdbc:h2:mem:test-db'
..
这表明 /h2-console 端点可用,因此可以通过访问 http://localhost:8080/h2-console 在浏览器中打开它。请参见图 4-2。
请确保 JDBC URL 字段填写以下值,然后点击连接按钮:
jdbc:h2:mem:test-db;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
连接后,您将看到 USERS 表及其数据,如图 4-3 所示。
如果你在浏览器中访问 http://localhost:8080/users 这个端点,你应该会看到图 4-4 中展示的 JSON 响应。
使用 PostgreSQL 数据库
为了使用 PostgreSQL,我们将使用 Docker Compose,正如在清单 4-13 中所示。
version: "3"
services:
postgres:
image: postgres
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test-db
ports:
- 5432:5432
列表 4-13 docker-compose.yaml
使用 Docker Compose 是我们最好的选择,这样可以避免安装多个外部服务,更加方便,并且实际上可以模拟使用容器的真实场景。请在终端中执行以下命令:
docker compose up
[+] Running 1/1
Container users-postgres-1 Started
现在,我们需要为 PostgreSQL 添加一个新的 SQL 脚本。请创建如清单 4-14 所示的 schema-postgresql.sql 脚本。
DROP TABLE IF EXISTS USERS CASCADE;
CREATE TABLE USERS
(
ID SERIAL NOT NULL,
EMAIL VARCHAR(255) NOT NULL UNIQUE,
NAME VARCHAR(100) NOT NULL,
GRAVATAR_URL VARCHAR(255) NOT NULL,
PASSWORD VARCHAR(255) NOT NULL,
USER_ROLE VARCHAR[] NOT NULL,
ACTIVE BOOLEAN NOT NULL,
PRIMARY KEY (ID)
);
列表 4-14 源文件:src/main/resources/schema-postgresql.sql
列表 4-14 展示了 PostgreSQL 的模式版本。请注意,ID 从 AUTO_INCREMENT 更改为 SERIAL,而 USER_ROLE 则从 ARRAY 更改为 VARCHAR[]声明。
接下来,将当前的 schema.sql 文件重命名为 schema-h2.sql。这一点非常重要!然后,打开你的 application.properties 文件,并将其内容替换为列表 4-15 中的内容。
# H2
# spring.h2.console.enabled=true
# DataSource
spring.datasource.generate-unique-name=false
spring.datasource.name=test-db
# SQL init
spring.sql.init.mode=always
spring.sql.init.platform=postgresql
# Postgresql
spring.datasource.url=jdbc:postgresql://localhost:5432/test-db
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.datasource.driver-class-name=org.postgresql.Driver
列表 4-15 源文件:src/main/resources/application.properties
让我们来分析一下新的 application.properties 文件:
- 我们为 spring.h2.console.enable 属性添加了注释,现在不再需要它了。
- 新的 # SQL 初始化部分将 spring.sql.init.mode 属性设置为 always,这意味着它会初始化数据库并查找 schema.sql 和 data.sql 文件。然而,我们将 schema.sql 文件重命名为 schema-h2.sql,并添加了 schema-postgresql,因此我们需要指定应用程序所需的平台;在这种情况下,我们将 spring.sql.init.platform 设置为 postgresql,这意味着它将执行 schema-postgresql.sql 脚本。
- 新的 # Postgresql 部分展示了运行 Postgres 引擎所需的所有属性(使用 Docker Compose)。这些变量可以省略,也可以使用环境变量。
如果你再次执行清单 4-14 中的测试,它们应该能够顺利通过;而当你运行应用程序时,应该会得到相同的结果。
我的复古应用程序:使用 Spring Boot JDBC
现在让我们看看如何在我的复古应用中使用 JDBC。在这种情况下,我们不会为类模型使用记录类型,但欢迎您自行尝试。再次建议您从 Spring Initializr(https://start.spring.io)创建一个空项目,并从那里开始。在下载并解压项目后,您可以将其导入到您喜欢的 IDE 中。如果您觉得修改现有代码很自在,那也没问题。图 4-5 展示了我们将在本节中开发的结构和代码。
请确保在“组”字段中输入 com.apress,在“工件”和“名称”字段中输入 myretro。下载项目后,解压缩并导入到您喜欢的 IDE 中,打开 build.gradle 文件,并用列表 4-16 中的内容替换其内容。
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.3'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.apress'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
runtimeOnly 'org.postgresql:postgresql'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
列表 4-16 build.gradle 文件
正如您在清单 4-16 中看到的,我们使用了 spring-boot-starter-jdbc 启动依赖项、postgresql 以及一个新的 spring-boot-docker-compose 启动依赖项。请注意,我们没有使用 H2 依赖项。此外,spring-boot-docker-compose 依赖项将仅在开发时使用。当我们运行应用程序时,我们将讨论这个新依赖项的功能。这是 Spring Boot 3.1 中的一项新特性。
接下来,创建一个包含以下领域/模型类的板块包:CardType、Card 和 RetroBoard(请参见列表 4-17、4-18 和 4-19)。
package com.apress.myretro.board;
public enum CardType {
HAPPY,MEH,SAD
}
src/main/java/apress/com/myretro/board/CardType.java
package com.apress.myretro.board;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.UUID;
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
public class Card {
private UUID id;
@NotBlank
private String comment;
@NotNull
private CardType cardType;
private UUID retroBoardId;
}
src/main/java/apress/com/myretro/board/Card.java
package com.apress.myretro.board;
import jakarta.validation.constraints.NotBlank;
import lombok.*;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
public class RetroBoard {
private UUID id;
@NotBlank(message = "A name must be provided")
private String name;
@Singular
private Map<UUID,Card> cards = new HashMap<>();
}
src/main/java/apress/com/myretro/board/RetroBoard.java
请注意,列表 4-19 中的 cards 字段现在是 Map 类型,这使得通过 UUID 查找 Card 对象变得更加简单。
接下来,创建持久化包,其中包含 SimpleRepository 接口(参见清单 4-20)以及 RetroBoardRowMapper 和 RetroBoardRepository 类(分别参见清单 4-21 和 4-22)。
package com.apress.myretro.persistence;
import java.util.Optional;
public interface SimpleRepository <D,ID>{
Optional<D> findById(ID id);
Iterable<D> findAll();
D save(D d);
void deleteById(ID id);
}
src/main/java/apress/com/myretro/persistence/SimpleRepository.java
package com.apress.myretro.persistence;
import com.apress.myretro.board.Card;
import com.apress.myretro.board.CardType;
import com.apress.myretro.board.RetroBoard;
import org.springframework.jdbc.core.RowMapper;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
public class RetroBoardRowMapper implements RowMapper<RetroBoard> {
@Override
public RetroBoard mapRow(ResultSet rs, int rowNum) throws SQLException {
RetroBoard retroBoard = new RetroBoard();
retroBoard.setId(UUID.fromString(rs.getString("id")));
retroBoard.setName(rs.getString("name"));
Map<UUID, Card> cards = new HashMap<>();
do {
Card card = new Card();
card.setId(UUID.fromString(rs.getString("card_id")));
card.setComment(rs.getString("comment"));
card.setCardType(CardType.valueOf(rs.getString("card_type")));
card.setRetroBoardId(retroBoard.getId());
cards.put(card.getId(), card);
} while (rs.next() && retroBoard.getId().equals(UUID.fromString(rs.getString("id"))));
retroBoard.setCards(cards);
return retroBoard;
}
}
src/main/java/apress/com/myretro/persistence/RetroBoardRowMapper.java
package com.apress.myretro.persistence;
import com.apress.myretro.board.Card;
import com.apress.myretro.board.RetroBoard;
import lombok.AllArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.sql.Types;
import java.util.*;
@AllArgsConstructor
@Repository
public class RetroBoardRepository implements SimpleRepository<RetroBoard, UUID> {
private JdbcTemplate jdbcTemplate;
@Override
public Optional<RetroBoard> findById(UUID uuid) {
String sql = """
SELECT r.ID AS id, r.NAME, c.ID AS card_id, c.CARD_TYPE AS card_type, c.COMMENT AS comment
FROM RETRO_BOARD r
LEFT JOIN CARD c ON r.ID = c.RETRO_BOARD_ID
WHERE r.ID = ?
""";
List<RetroBoard> results = jdbcTemplate.query(sql, new Object[]{uuid}, new int[]{Types.OTHER}, new RetroBoardRowMapper());
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
@Override
public Iterable<RetroBoard> findAll() {
String sql = """
SELECT r.ID AS id, r.NAME, c.ID AS card_id, c.CARD_TYPE, c.COMMENT
FROM RETRO_BOARD r
LEFT JOIN CARD c ON r.ID = c.RETRO_BOARD_ID
""";
return jdbcTemplate.query(sql, new RetroBoardRowMapper());
}
@Override
@Transactional
public RetroBoard save(RetroBoard retroBoard) {
if (retroBoard.getId() == null) {
retroBoard.setId(UUID.randomUUID());
}
String sql = "INSERT INTO RETRO_BOARD (ID, NAME) VALUES (?, ?)";
jdbcTemplate.update(sql, retroBoard.getId(), retroBoard.getName());
Map<UUID, Card> mutableMap = new HashMap<>(retroBoard.getCards());
for (Card card : retroBoard.getCards().values()) {
card.setRetroBoardId(retroBoard.getId());
card = saveCard(card);
mutableMap.put(card.getId(), card);
}
retroBoard.setCards(mutableMap);
return retroBoard;
}
@Override
@Transactional
public void deleteById(UUID uuid) {
String sql = "DELETE FROM CARD WHERE RETRO_BOARD_ID = ?";
jdbcTemplate.update(sql, uuid);
sql = "DELETE FROM RETRO_BOARD WHERE ID = ?";
jdbcTemplate.update(sql, uuid);
}
private Card saveCard(Card card) {
if (card.getId() == null) {
card.setId(UUID.randomUUID());
}
String sql = "INSERT INTO CARD (ID, CARD_TYPE, COMMENT, RETRO_BOARD_ID) VALUES (?, ?, ?, ?)";
jdbcTemplate.update(sql, card.getId(), card.getCardType().name(), card.getComment(), card.getRetroBoardId());
return card;
}
}
src/main/java/apress/com/myretro/persistence/RetroBoardRepository.java
列表 4-20、4-21 和 4-22 展示了项目之前版本中的熟悉类。请注意,我们使用了@Repository 注解,它提供了所有必要的 DAO 支持,包括将技术特定异常(如 SQLException)转换为更易理解的形式,这样在发生错误时更容易了解情况。
如果你仔细查看清单 4-22 中的 save(RetroBoard)方法,你会发现需要在不同的语句中保存 Card,这意味着我们有两个表:一个是 RetroBoard,另一个是 Card,Card 必须与 RetroBoard 关联。此外,请注意我们使用了@Transactional 注解,这是一种将事务添加到应用程序中的声明性方式。该注解基于面向切面编程(AOP),并负责事务的流程,包括开始事务、提交和必要时的回滚。
在您全面分析了之前的类之后,请创建一个异常包,包含以下类:CardNotFoundException、RetroBoardNotFoundException 和 RetroBoardResponseEntityExceptionHandler。请参考列表 4-23、4-24 和 4-25。
package com.apress.myretro.exception;
public class CardNotFoundException extends RuntimeException{
public CardNotFoundException() {
super("Card Not Found");
}
public CardNotFoundException(String message) {
super(String.format("Card Not Found: {}", message));
}
public CardNotFoundException(String message, Throwable cause) {
super(String.format("Card Not Found: {}", message), cause);
}
}
列表4-23 src/main/java/apress/com/myretro/exception/CardNotFoundException.java
package com.apress.myretro.exception;
public class RetroBoardNotFoundException extends RuntimeException{
public RetroBoardNotFoundException(){
super("RetroBoard Not Found");
}
public RetroBoardNotFoundException(String message) {
super(String.format("RetroBoard Not Found: {}", message));
}
public RetroBoardNotFoundException(String message, Throwable cause) {
super(String.format("RetroBoard Not Found: {}", message), cause);
}
}
列表 4-24 源码:src/main/java/apress/com/myretro/exception/RetroBoardNotFoundException.java
package com.apress.myretro.exception;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
@ControllerAdvice
public class RetroBoardResponseEntityExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(value
= { CardNotFoundException.class,RetroBoardNotFoundException.class })
protected ResponseEntity<Object> handleNotFound(
RuntimeException ex, WebRequest request) {
Map<String, Object> response = new HashMap<>();
response.put("msg","There is an error");
response.put("code",HttpStatus.NOT_FOUND.value());
response.put("time", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-mm-dd HH:mm:ss")));
Map<String, String> errors = new HashMap<>();
errors.put("msg",ex.getMessage());
response.put("errors",errors);
return handleExceptionInternal(ex, response,
new HttpHeaders(), HttpStatus.NOT_FOUND, request);
}
}
列表 4-25 源代码:src/main/java/apress/com/myretro/exception/RetroBoardResponseEntityExceptionHandler.java
异常包与之前的版本相同。请花点时间查看这些类,它们非常简单易懂。
接下来,按照清单 4-26 的示例,创建包含 RetroBoardService 类的服务包。
package com.apress.myretro.service;
import com.apress.myretro.board.Card;
import com.apress.myretro.board.RetroBoard;
import com.apress.myretro.persistence.RetroBoardRepository;
import com.apress.myretro.persistence.SimpleRepository;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.UUID;
@AllArgsConstructor
@Service
public class RetroBoardService {
SimpleRepository<RetroBoard,UUID> retroBoardRepository;
public RetroBoard save(RetroBoard domain) {
return this.retroBoardRepository.save(domain);
}
public RetroBoard findById(UUID uuid) {
return this.retroBoardRepository.findById(uuid).get();
}
public Iterable<RetroBoard> findAll() {
return this.retroBoardRepository.findAll();
}
public void delete(UUID uuid) {
this.retroBoardRepository.deleteById(uuid);
}
public Iterable<Card> findAllCardsFromRetroBoard(UUID uuid) {
return this.findById(uuid).getCards().values();
}
public Card addCardToRetroBoard(UUID uuid, Card card){
RetroBoard retroBoard = this.findById(uuid);
if (card.getId() == null) {
card.setId(UUID.randomUUID());
}
retroBoard.getCards().put(card.getId(),card);
this.save(retroBoard);
return card;
}
public Card findCardByUUID(UUID uuid,UUID uuidCard){
RetroBoard retroBoard = this.findById(uuid);
return retroBoard.getCards().get(uuidCard);
}
public Card saveCard(UUID uuid,Card card){
RetroBoard retroBoard = this.findById(uuid);
retroBoard.getCards().put(card.getId(),card);
this.save(retroBoard);
return card;
}
public void removeCardByUUID(UUID uuid,UUID cardUUID){
RetroBoard retroBoard = this.findById(uuid);
retroBoard.getCards().remove(cardUUID);
this.save(retroBoard);
}
}
列表 4-26 源代码:src/main/java/apress/com/myretro/service/RetroBoardService.java
RetroBoardService 类现在增加了额外的方法,帮助我们与整个应用程序进行更深入的交互,包括 RetroBoard 及其卡片。这个类非常简单明了。需要注意的是,这个类带有 @Service 注解,使其成为一个 Spring bean,并将被注入到我们的 web 控制器中。
接下来,按照清单 4-27 的示例创建网络包和 RetroBoardController 类。
package com.apress.myretro.web;
import com.apress.myretro.board.Card;
import com.apress.myretro.board.RetroBoard;
import com.apress.myretro.service.RetroBoardService;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.net.URI;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@AllArgsConstructor
@RestController
@RequestMapping("/retros")
public class RetroBoardController {
private RetroBoardService retroBoardService;
@GetMapping
public ResponseEntity<Iterable<RetroBoard>> getAllRetroBoards(){
return ResponseEntity.ok(retroBoardService.findAll());
}
@PostMapping
public ResponseEntity<RetroBoard> saveRetroBoard(@Valid @RequestBody RetroBoard retroBoard){
RetroBoard result = retroBoardService.save(retroBoard);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{uuid}")
.buildAndExpand(result.getId().toString())
.toUri();
return ResponseEntity.created(location).body(result);
}
@GetMapping("/{uuid}")
public ResponseEntity<RetroBoard> findRetroBoardById(@PathVariable UUID uuid){
return ResponseEntity.ok(retroBoardService.findById(uuid));
}
@GetMapping("/{uuid}/cards")
public ResponseEntity<Iterable<Card>> getAllCardsFromBoard(@PathVariable UUID uuid){
return ResponseEntity.ok(retroBoardService.findAllCardsFromRetroBoard(uuid));
}
@PutMapping("/{uuid}/cards")
public ResponseEntity<Card> addCardToRetroBoard(@PathVariable UUID uuid,@Valid @RequestBody Card card){
Card result = retroBoardService.addCardToRetroBoard(uuid,card);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{uuid}/cards/{uuidCard}")
.buildAndExpand(uuid.toString(),result.getId().toString())
.toUri();
return ResponseEntity.created(location).body(result);
}
@GetMapping("/{uuid}/cards/{uuidCard}")
public ResponseEntity<Card> getCardByUUID(@PathVariable UUID uuid,@PathVariable UUID uuidCard){
return ResponseEntity.ok(retroBoardService.findCardByUUID(uuid,uuidCard));
}
@PutMapping("/{uuid}/cards/{uuidCard}")
public ResponseEntity<Card> updateCardByUUID(@PathVariable UUID uuid,@PathVariable UUID uuidCard, @RequestBody Card card){
return ResponseEntity.ok(retroBoardService.saveCard(uuid,card));
}
@ResponseStatus(HttpStatus.NO_CONTENT)
@DeleteMapping("/{uuid}/cards/{uuidCard}")
public void deleteCardFromRetroBoard(@PathVariable UUID uuid,@PathVariable UUID uuidCard){
retroBoardService.removeCardByUUID(uuid,uuidCard);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, Object> handleValidationExceptions(MethodArgumentNotValidException ex) {
Map<String, Object> response = new HashMap<>();
response.put("msg","There is an error");
response.put("code",HttpStatus.BAD_REQUEST.value());
response.put("time", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach((error) -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
response.put("errors",errors);
return response;
}
}
列表 4-27 源代码:src/main/java/apress/com/myretro/web/RetroBoardController.java
RetroBoardController 类将处理 /retros 和 /retros/{uuid}/cards 这两个端点。正如你所看到的,这个类与之前的版本变化不大。请分析它,然后继续。
接下来,创建配置包,并按照清单 4-28、4-29 和 4-30 的示例,分别实现 UsersProperties、MyRetroProperties 和 MyRetroConfiguration 类。
package com.apress.myretro.config;
import lombok.Data;
@Data
public class UsersProperties {
String server;
Integer port;
String username;
String password;
}
4-28 src/main/java/apress/com/myretro/config/UsersProperties.java
package com.apress.myretro.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix="service")
@Data
public class MyRetroProperties {
UsersProperties users;
}
4-29 src/main/java/apress/com/myretro/config/MyRetroProperties.java
package com.apress.myretro.config;
import com.apress.myretro.board.Card;
import com.apress.myretro.board.CardType;
import com.apress.myretro.board.RetroBoard;
import com.apress.myretro.service.RetroBoardService;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.ApplicationListener;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.ArrayList;
import java.util.UUID;
@EnableConfigurationProperties({MyRetroProperties.class})
@Configuration
public class MyRetroConfiguration {
@Bean
ApplicationListener<ApplicationReadyEvent> ready(RetroBoardService retroBoardService) {
return applicationReadyEvent -> {
UUID retroBoardId = UUID.fromString("9dc9b71b-a07e-418b-b972-40225449aff2");
RetroBoard retroBoard = RetroBoard.builder()
.id(retroBoardId)
.name("Spring Boot Conference")
.card(UUID.fromString("bb2a80a5-a0f5-4180-a6dc-80c84bc014c9"),Card.builder().id(UUID.fromString("bb2a80a5-a0f5-4180-a6dc-80c84bc014c9")).comment("Spring Boot Rocks!").cardType(CardType.HAPPY).build())
.card(UUID.fromString("f9de7f11-5393-4b5b-8e9d-10eca5f50189"),Card.builder().id(UUID.randomUUID()).comment("Meet everyone in person").cardType(CardType.HAPPY).build())
.card(UUID.fromString("6cdb30d6-43f2-42b7-b0db-f3acbc53d467"),Card.builder().id(UUID.randomUUID()).comment("When is the next one?").cardType(CardType.MEH).build())
.card(UUID.fromString("9de1f7f9-2470-4c8d-86f2-371203620fcd"),Card.builder().id(UUID.randomUUID()).comment("Not enough time to talk to everyone").cardType(CardType.SAD).build())
.build();
retroBoardService.save(retroBoard);
};
}
}
4-30 src/main/java/apress/com/myretro/config/MyRetroConfiguration.java
请注意,在列表 4-28 中,我们将第 3 章的 UsersConfiguration.java 文件重命名为 UsersProperties.java(这样更合理)。在列表 4-30 中,我们使用 ApplicationListener 向 MyRetroConfiguration 添加了一些初始数据,当应用程序准备好时,它将执行 ready()方法中的所有代码。在这个方法(ready())中,我们将 RetroBoardService 作为参数,这将由 Spring 框架进行注入。
在我们之前的版本中,我们有一个建议,因此请创建名为 advice 的包和 RetroBoardAdvice 类,如清单 4-31 所示。
package com.apress.myretro.advice;
import com.apress.myretro.board.RetroBoard;
import com.apress.myretro.exception.RetroBoardNotFoundException;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import java.util.Optional;
import java.util.UUID;
@Slf4j
@Component
@Aspect
public class RetroBoardAdvice {
@Around("execution(* com.apress.myretro.persistence.RetroBoardRepository.findById(..))")
public Object checkFindRetroBoard(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
log.info("[ADVICE] {}", proceedingJoinPoint.getSignature().getName());
Optional<RetroBoard> retroBoard = (Optional<RetroBoard>) proceedingJoinPoint.proceed(new Object[]{
UUID.fromString(proceedingJoinPoint.getArgs()[0].toString())
});
if (retroBoard.isEmpty())
throw new RetroBoardNotFoundException();
return retroBoard;
}
}
4-31 src/main/java/apress/com/myretro/advice/RetroBoardAdvice.java
请记住,RetroBoardAdvice 类会拦截 findById 方法,如果找不到对应的内容,将抛出 RetroBoardNotFoundException 异常,这个异常将由我们的控制器建议类 RetroBoardResponseEntityExceptionHandler 进行处理。
接下来,打开 application.properties 文件,并添加列表 4-32 中所示的内容。
# DataSource
spring.datasource.generate-unique-name=false
spring.datasource.name=test-db
# SQL init
spring.sql.init.mode=always
列表 4-32 源文件:src/main/resource/application.properties
列表 4-32 显示我们现在使用 spring.sql.init.mode,这样它就可以读取 schema.sql 文件。接下来,按照列表 4-33 的示例创建 schema.sql 文件。
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
DROP TABLE IF EXISTS CARD CASCADE;
DROP TABLE IF EXISTS RETRO_BOARD CASCADE;
CREATE TABLE CARD
(
ID UUID DEFAULT uuid_generate_v4() NOT NULL,
CARD_TYPE VARCHAR(5) NOT NULL,
COMMENT VARCHAR(255),
RETRO_BOARD_ID UUID,
PRIMARY KEY (ID)
);
CREATE TABLE RETRO_BOARD
(
ID UUID DEFAULT uuid_generate_v4() NOT NULL,
NAME VARCHAR(255),
PRIMARY KEY (ID)
);
ALTER TABLE IF EXISTS CARD
列表 4-33 的第一行启用了一个函数(uuid_generate_v4()),该函数生成我们所需的 UUID。我们需要两个表,一个用于 RetroBoard,另一个用于 Card,并在这两个表之间建立一对多的关系。
接下来,在项目的根目录中创建一个名为 docker-compose.yaml 的文件,内容与清单 4-34 中所示相同。
version: "3"
services:
postgres:
image: postgres
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test-db
ports:
- 5432:5432
正如您所见,这个文件与用户应用中的文件完全相同。我们需要使用 PostgreSQL 数据库,因此一切都已准备就绪。
运行我的复古应用程序
现在是时候运行我们的应用程序了。您可以通过您的 IDE 或使用以下命令来启动它:
./gradle bootRun
..
.s.b.d.c.l.DockerComposeLifecycleManager : Using Docker Compose file ...
..
..
关于我们的 docker-compose 文件有了新的信息,但到底发生了什么呢?稍后会详细说明。
如果你打开一个浏览器(http://localhost:8080/retros)或在另一个终端窗口中执行 curl 命令,以下代码展示了如何使用 jq 工具(https://stedolan.github.io/jq/)来打印 curl 命令的结果:
curl -s http://localhost:8080/retros | jq .
[
{
"id": "9dc9b71b-a07e-418b-b972-40225449aff2",
"name": "Spring Boot Conference",
"cards": {
"2ca35157-63eb-4950-ac10-fc75ab828fcb": {
"id": "2ca35157-63eb-4950-ac10-fc75ab828fcb",
"comment": "Meet everyone in person",
"cardType": "HAPPY",
"retroBoardId": "9dc9b71b-a07e-418b-b972-40225449aff2"
},
"bb2a80a5-a0f5-4180-a6dc-80c84bc014c9": {
"id": "bb2a80a5-a0f5-4180-a6dc-80c84bc014c9",
"comment": "Spring Boot Rocks!",
"cardType": "HAPPY",
"retroBoardId": "9dc9b71b-a07e-418b-b972-40225449aff2"
},
"b0b993c7-83a3-4ab8-9a15-d9b160228da4": {
"id": "b0b993c7-83a3-4ab8-9a15-d9b160228da4",
"comment": "When is the next one?",
"cardType": "MEH",
"retroBoardId": "9dc9b71b-a07e-418b-b972-40225449aff2"
},
394676ba-8609-4677-8ef1-420851139410
"id": "394676ba-8609-4677-8ef1-420851139410",
"comment": "Not enough time to talk to everyone",
"cardType": "SAD",
"retroBoardId": "9dc9b71b-a07e-418b-b972-40225449aff2"
}
}
}
]
等等……这又是什么情况?
你有没有注意到我们从未指定数据源的连接参数?实际上,我们有一个 spring-boot-docker-compose 依赖,它自动配置了我们的应用程序。它通过执行 docker compose up 在后台的 docker 镜像中运行,并根据我们的 docker-compose.yaml 文件创建了正确参数的数据源。这个功能真是太棒了!当然,这一切只在开发阶段发生,我们不再需要额外的步骤来创建基础设施。我们将在接下来的章节中继续采用这种开发方法。
概要
在本章中,您了解了 Spring 框架的数据访问是如何与 Spring Boot 协同工作的,以及 Spring Boot 是如何帮助配置大多数数据访问组件的,比如 DataSource 和 JdbcTemplate。
你在这一章中学到了以下内容:
- 你需要指定 SQL 语句并直接与结果集进行操作。
- Spring Framework 数据访问提供了像 JdbcTemplate 这样的简化类,旨在消除处理连接、会话、事务、异常等时的繁琐代码。
- Spring Boot 可以帮助您初始化嵌入式数据库引擎,例如 H2、HSQL 和 Derby,无需提供任何连接参数来配置数据源。
- 您可以在依赖项中使用多个驱动程序,但需要提供要连接的驱动程序的连接参数,并通过 schema.sql 或 data.sql 来初始化数据库。
- Spring Boot 3.1 的新特性允许您添加一个 docker-compose.yaml 文件并声明服务。通过添加 spring-boot-docker-compose 启动器依赖,Spring Boot 会自动配置所有必要的参数,以根据 docker-compose.yaml 中声明的环境变量设置数据源。
在第五章中,我们将深入探讨 Spring Data 项目,以及 Spring Boot 是如何帮助我们进行配置的。