李成笔记网

专注域名、站长SEO知识分享与实战技巧

精通Spring Boot 3 : 4. Spring Boot SQL 数据访问指南 (2)

添加网络控制器功能

按照清单 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 是如何帮助我们进行配置的。

发表评论:

控制面板
您好,欢迎到访网站!
  查看权限
网站分类
最新留言