← 返回首页
Java学习#Java

19.SpringBoot整合模板、数据库与安全认证

2026-10-20·2 次浏览
19.SpringBoot整合模板、数据库与安全认证

Spring Boot:从学生页面到数据库与登录权限

做一个学生管理网站,先要把学生名单显示出来;名单不能一直写在程序里,所以还要从数据库查询;有了管理页面,又要区分普通用户和管理员。页面展示、数据访问和安全校验,就是这篇文章要逐步解决的三个问题。

Spring Boot 可以把这些技术接进同一个 Spring 应用。我们先用 Thymeleaf 做出页面,再分别学习 JdbcTemplate 和 Spring Data JPA 怎样操作数据库,最后使用 Spring Security 完成登录和角色权限。每一步先弄清它解决什么问题,再写代码观察结果。

示例使用 Java 17、Spring Boot 3.4.5,对应 Spring Framework 6、Spring Security 6,Servlet 与 JPA 类型使用 jakarta 包。后面的依赖、配置和 API 按这套版本配合使用。

1. 先让 Spring Boot 项目运行起来

1.1 自动配置解决什么问题

一个 Spring Web 项目要用 Controller 处理请求、用模板显示数据、用连接池连接数据库。手动搭建时,需要把这些组件和它们的依赖关系一项项配置好,多个项目又经常重复做相同的工作。

Spring Boot 在 Spring 的基础上提供自动配置。它会检查项目依赖、配置项以及容器里已有的 Bean,在条件满足时准备常见组件。比如 Web starter 提供 Spring MVC 与内嵌 Tomcat;添加模板依赖后,可以使用默认的模板解析配置。

这里的“自动”是有条件的。数据库驱动、连接地址和账号等信息仍然需要准备;只有引入一个库,并不代表框架能猜出所有业务配置。我们自己提供相应 Bean 时,一些默认配置还会让位。这样既能少做重复工作,也保留了定制入口。

下面从一个 Maven 项目开始。依赖统一放在 pom.xml 的 dependencies 中,后面逐步增加;除非明确说替换,已经用到的依赖继续保留。

1.2 准备依赖与启动入口

父配置负责统一依赖版本,Java 版本设为 17:

xml 复制代码
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.4.5</version>
    <relativePath/>
</parent>

<properties>
    <java.version>17</java.version>
</properties>

先加入 Web starter。starter 可以理解为某项功能的一组配套依赖入口,不必自己逐个拼出所有底层依赖。

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

再在 pom.xml 的 build 中加入启动插件。通过 Spring Initializr 创建的项目通常已经有这段配置;手动创建的项目需要检查。

xml 复制代码
<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

有了插件,就能使用 mvn spring-boot:run 启动应用,也能通过打包生成可执行 JAR。依赖负责提供类库,插件负责构建和运行等工作,两者放置的位置不同。

启动类放在 com.example.bootnotes 包中:

java 复制代码
package com.example.bootnotes;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class BootNotesApplication {

    public static void main(String[] args) {
        // 创建 Spring 容器,并启动内嵌 Web 服务器。
        SpringApplication.run(BootNotesApplication.class, args);
    }
}

SpringApplication.run 创建 Spring 容器,并启动内嵌服务器。@SpringBootApplication 包含自动配置与组件扫描等功能;后面的类放在启动类所在包或它的子包下,就能按默认规则被发现。

现在项目已经有接收请求的环境了,不过还没有页面。下面先不连接数据库,用两名学生的数据把页面显示过程走通。

2. Thymeleaf:把后端数据填进 HTML

2.1 什么是模板引擎,什么时候会用

静态 HTML 中写着“张三”,浏览器每次打开就看到“张三”。如果姓名来自数据库,我们就需要先查询,再把查询结果填进 HTML。模板引擎负责的正是这一步:保留页面结构,把里面的数据位置替换为本次查询得到的值。

Thymeleaf 是 Java 模板引擎。它通过 HTML 中的 th:* 属性和表达式读取后端数据,生成完整页面;此外也支持 XML、JavaScript 和 CSS 等模板模式。这里先学习最常用的 HTML。

在前后端不分离的项目中,浏览器收到的是服务器渲染后的 HTML。JSP 和 Thymeleaf 都可以承担这种视图层工作,例如内部管理后台、名单展示和表单页面。Thymeleaf 是 Spring Boot 开发中的热门选择,配合内嵌容器与可执行 JAR 比较方便。已有 JSP 项目仍有使用场景,使用时需要核对容器及打包方式的支持情况。

Thymeleaf 还有一个方便设计页面的特点:模板本身仍然是 HTML,直接打开时也能看到预先写好的示例内容。比如:

html 复制代码
<p th:text="${student.name}">小明</p>

直接打开这段 HTML,显示的是“小明”。经过服务器处理后,th:text 会把文字换成实际学生姓名。直接预览不会执行 Controller,也不会查询数据库。

JSP 常借助 EL 等语法取值;Thymeleaf 使用表达式和模板属性。因此,接入依赖之后,还要学会告诉模板“数据叫什么、读取哪个属性”。

2.2 加入依赖,确定模板位置

在已有 Web 依赖之外,添加:

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

Spring Boot 默认到 src/main/resources/templates 查找模板。Controller 返回视图名 index 时,就会找到 index.html。

application.yml 可以配置模板目录与后缀:

yaml 复制代码
spring:
  thymeleaf:
    prefix: classpath:/templates/
    suffix: .html
    cache: false

classpath:/templates/ 表示运行时类路径下的模板目录,.html 是后缀。两者和 index 拼起来,就能定位模板。前缀与后缀本来就有这些默认值,可以省略;这里写出是为了看清解析过程。

cache: false 让开发时修改模板更容易生效,生产环境通常保留默认缓存。后面还会增加数据源等配置,它们要合并到同一个 spring: 节点中,不能反复声明多个同名顶层节点。

2.3 先认识 StudentVO 和 Model

页面要显示学号、姓名、年龄,我们先用一个对象装好这三项数据。这里叫 StudentVO:VO 常指 View Object,是项目中表示展示数据的命名约定,并不是 Spring 强制要求的后缀。

展示对象只放页面需要的字段。以后数据库实体还有其他内部信息时,不必全部交给前端。现在先定义这份展示数据:

java 复制代码
package com.example.bootnotes;

public class StudentVO {

    private final Long id;
    private final String name;
    private final Integer age;

    public StudentVO(Long id, String name, Integer age) {
        this.id = id;
        this.name = name;
        this.age = age;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public Integer getAge() {
        return age;
    }
}

构造器在创建时填好数据,getter 供模板读取。比如 ${student.name} 会读取对应的姓名属性,在这个类里就是通过 getName() 取得。

有了展示对象,还需要给模板一个可以找到它的名字。Spring MVC 的 Model 就负责把“名字与值”交给当前页面。它是框架提供的接口,完整名称是 org.springframework.ui.Model;控制器声明 Model model 参数后,框架会在调用方法时传入,不需要我们自己创建。

下面的 Controller 准备两名学生,再分别给单个对象、输入框文字和名单起名:

java 复制代码
package com.example.bootnotes;

import java.util.List;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class StudentController {

    @GetMapping("/index")
    public String index(Model model) {
        List<StudentVO> list = List.of(
                new StudentVO(1L, "张三", 18),
                new StudentVO(2L, "李四", 20)
        );

        // 左边的名称,就是模板里使用的名称。
        model.addAttribute("student", list.get(0));
        model.addAttribute("value", "springboot");
        model.addAttribute("list", list);

        // 返回视图名,由 Thymeleaf 解析 index.html。
        return "index";
    }
}

StudentVO 负责装学生字段,Model 负责把这些数据以名称交给模板。例如 model.addAttribute("list", list) 中,左边的 "list" 是模板查找的名字,右边才是真正的集合。

@Controller 标记页面控制器;@GetMapping("/index") 让它处理这个地址的 GET 请求;返回的 "index" 是视图名。这里若改成 @RestController,字符串通常会直接写进响应体,浏览器会看到 index 这几个字符,而不是页面。

2.4 text、value、each:先看一处,再放回完整页面

后端已经给数据起好了名字,模板就按名字读取。先看三个常见需求。

显示一名学生的学号,用 th:text 替换标签中的文字:

html 复制代码
<!-- student 对应 Model 里的名称,id 对应 StudentVO 的学号属性。 -->
<span th:text="${student.id}">1</span>

给输入框回填内容,用 th:value 设置它的值。这里读取前面放入 Model 的 value,渲染后输入框显示 springboot:

html 复制代码
<input type="text" th:value="${value}" value="示例课程">

名单是一组学生,需要重复生成多行,这时使用 th:each:

html 复制代码
<!-- 每次从 list 取出一名学生,用 student 表示当前这一名。 -->
<tr th:each="student : ${list}">
    <td th:text="${student.id}">1</td>
    <td th:text="${student.name}">张三</td>
    <td th:text="${student.age}">18</td>
</tr>

冒号右边 ${list} 是整个集合,左边 student 是本轮取出的对象。每取出一个对象,就生成一行 tr;循环内的 student 不会把集合本身改掉。

把这几处放回完整的 HTML,就能直接看到页面结构。在 templates 下建立 index.html:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>学生列表</title>
</head>
<body>
    <h1>学生列表</h1>

    <!-- text 替换标签中的文字;这里单独演示一个学生的学号。 -->
    <p th:if="${student != null}">
        第一名学生的学号:
        <span th:text="${student.id}">1</span>
    </p>

    <!-- value 设置输入框的值。 -->
    <p>
        课程名称:
        <input type="text" th:value="${value}" value="示例课程">
    </p>

    <table>
        <thead>
        <tr>
            <th>ID</th>
            <th>姓名</th>
            <th>年龄</th>
        </tr>
        </thead>
        <tbody>
        <!-- each 每取出一个学生,就生成一行。 -->
        <tr th:each="student : ${list}">
            <td th:text="${student.id}">1</td>
            <td th:text="${student.name}">张三</td>
            <td th:text="${student.age}">18</td>
        </tr>
        </tbody>
    </table>

    <p th:if="${#lists.isEmpty(list)}">暂无学生</p>
</body>
</html>

启动后访问 http://localhost:8080/index,表格显示张三和李四,输入框显示 springboot。代码先在 Controller 准备数据,再由 Model 交给模板,最后 Thymeleaf 生成 HTML 给浏览器。

页面还用了 th:if:当名单为空时显示“暂无学生”,而不是强行读取第一项。取值表达式要写完整的 ${student.name},不能漏掉 $、引号或花括号。

th:text 默认转义 HTML。假如姓名里出现 <script>,它会作为文字显示,不当作脚本执行。普通文本继续用 th:text,不随意改成不转义的 th:utext。

2.5 request 域:本次请求中的数据

名单已经能显示出来了。接下来要分清:这些数据只是供当前页面使用,还是需要下一次请求继续使用。

request 域对应一次 HTTP 请求,可以把它理解为本次请求携带的属性集合。Servlet API 用 setAttribute 保存、用 getAttribute 读取。例如在声明了 HttpServletRequest request 的处理方法里:

java 复制代码
StudentVO student = new StudentVO(1L, "张三", 18);

// 以 student 为名,保存到当前请求。
request.setAttribute("student", student);

// 同一次请求中的后续处理可以按名称读取。
StudentVO saved = (StudentVO) request.getAttribute("student");
System.out.println(saved.getName()); // 张三

HttpServletRequest 来自 jakarta.servlet.http,也可以像 Model 一样声明为 Controller 方法参数,由框架传入。

前面使用 Model,是通过 Spring MVC 向视图提供本次渲染的数据。JSP 视图通常把模型数据暴露为请求属性,Thymeleaf 则在模板使用的数据环境中读取它们;写页面时使用 Model 即可,不需要再重复调用 request.setAttribute。Model 本身也不是 HttpServletRequest。

转发继续处理同一次请求,因此 request 属性还能用;重定向则让浏览器重新发请求,新的 request 不会自动带上旧 request 的属性。普通 Model 数据也不会因为返回了 redirect 就自动永久保存;重定向临时传值可以另用 URL 参数或 flash 属性。

2.6 session 域:后续请求仍要使用的数据

如果希望用户提交姓名后,刷新页面还能显示“你好,小明”,数据就要跨请求保存。这时可以使用 HttpSession。

session 域对应同一个会话中的多次请求。浏览器通常通过会话 Cookie 关联服务器上的 Session,因此不同会话可以保存不同的访客姓名,不是所有用户共用一份数据。

下面单独增加 VisitorController,保存姓名后重定向回名单页:

java 复制代码
package com.example.bootnotes;

import jakarta.servlet.http.HttpSession;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
public class VisitorController {

    @PostMapping("/visitor")
    public String remember(
            @RequestParam String name,
            HttpSession session) {
        // 重定向后会产生新请求;Session 中的值仍然可以读取。
        session.setAttribute("visitor", name);
        return "redirect:/index";
    }
}

在 index.html 的 body 内加入表单和显示区域:

html 复制代码
<form th:action="@{/visitor}" method="post">
    <label>
        访客姓名:
        <input type="text" name="name" required>
    </label>
    <button type="submit">记住姓名</button>
</form>

<p th:if="${session.visitor != null}">
    你好,<span th:text="${session.visitor}">访客</span>
</p>

填写“小明”并提交后,服务器先把姓名保存到 session,再发出重定向。浏览器重新请求 /index,虽然已经是另一次请求,模板仍能通过 ${session.visitor} 读到“小明”。session 前缀就是在说明从会话取值。

后续 Java 处理也可以读取;不再需要时删除该属性:

java 复制代码
// 在后续请求的方法中,session 仍由框架按当前会话提供。
String visitor = (String) session.getAttribute("visitor");

// 删除访客姓名,不影响会话里的其他属性。
session.removeAttribute("visitor");

调用 session.invalidate() 会让整个会话失效;会话超时也会清除数据。Session 适合保存少量会话信息,不能把它当成永久数据库。

对比 request 域 session 域
保存 request.setAttribute session.setAttribute
读取 request.getAttribute session.getAttribute
有效范围 当前请求,包括同一次请求的转发 同一会话的多次请求
重定向之后 不自动保留旧请求属性 同一会话中仍可读取
本文用途 当前学生名单与页面提示 记住访客姓名

这样,名单每次查询后交给当前页面,访客姓名保存在 session,各自的用途就分清了。这里的姓名只是显示偏好,并没有实现登录认证;真正的账号登录放到后面的安全部分讲。

3. JdbcTemplate:把学生名单存进数据库

3.1 为什么使用 JdbcTemplate

目前名单写在 Java 代码中,增加一个学生就要改程序。把名单保存到数据库后,页面只需要查询,新增和修改也能独立完成。

直接写 JDBC,常常要重复获取连接、创建语句、绑定参数、遍历结果集和关闭资源。JdbcTemplate 是 Spring 提供的轻量 JDBC 封装:我们写 SQL,说明每一行如何转成对象,它负责执行过程中的重复工作,并与 Spring 的连接和事务管理配合。

它适合想直接控制 SQL 的业务,例如学生筛选和人数统计。MyBatis 也能控制 SQL,语句可以写在 XML 或注解中;JdbcTemplate 通常把 SQL 写在 Java 方法内。两者都有适用场景,不能仅因为 JdbcTemplate 属于 Spring 就认为查询性能一定更高。

3.2 准备依赖、表与连接

加入 JDBC starter 和 MySQL 驱动:

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <scope>runtime</scope>
</dependency>

在 MySQL 中准备数据库和学生表:

sql 复制代码
CREATE DATABASE IF NOT EXISTS boot_notes
    CHARACTER SET utf8mb4;

USE boot_notes;

CREATE TABLE student (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(50) NOT NULL,
    age INT
);

INSERT INTO student(name, age)
VALUES ('张三', 18), ('李四', 20);

id 由数据库自增生成,name 不允许为空,age 可以为空。上面的两条初始化数据只插入一次;重复执行会产生重复名单。

然后把连接信息合并进 application.yml 的 spring 节点。下面假设 study 账号已经能够访问该数据库:

yaml 复制代码
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/boot_notes?characterEncoding=UTF-8
    username: study
    password: your_password

DataSource 是应用获取数据库连接的入口,Boot 根据这些信息配置它,再在条件满足时提供 JdbcTemplate。驱动通常可以根据 URL 识别,不必再写驱动类名。连接失败时先检查数据库、账号权限和地址,不要先改查询代码。

3.3 数据库对象和页面展示对象分开

数据库操作使用 Student,表示学生记录;页面继续使用 StudentVO,表示需要展示的字段。两者现在字段相同,职责仍不同:以后表里增加内部字段,不必同步展示出去。

Student 需要无参构造器、getter 和 setter,后面的行映射与更新都要用到:

java 复制代码
package com.example.bootnotes;

public class Student {

    private Long id;
    private String name;
    private Integer age;

    public Student() {
    }

    public Student(Long id, String name, Integer age) {
        this.id = id;
        this.name = name;
        this.age = age;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Integer getAge() {
        return age;
    }

    public void setAge(Integer age) {
        this.age = age;
    }
}

年龄使用 Integer,可以保留“没有填写”的 null。StudentVO 只负责读取展示内容,Student 则还要接收数据库与业务操作产生的修改。

3.4 先定义 Repository 接口

Repository 是数据访问层的常见叫法。先在接口里列出学生管理需要的操作,再在实现类中写 SQL。这样 Controller 和 Service 不必知道具体语句:

java 复制代码
package com.example.bootnotes;

import java.util.List;
import java.util.Optional;

public interface StudentRepository {

    List<Student> findAll();

    Optional<Student> findById(Long id);

    int insert(Student student);

    int update(Student student);

    int deleteById(Long id);

    long count();
}

Optional<Student> 表示“可能有学生,也可能查不到”,调用方要处理不存在的情况。增删改返回 int,表示受影响行数;它不是新生成的学号,也不能不看结果就认定成功。

3.5 实现类先准备 JdbcTemplate 和行映射

创建 JdbcStudentRepository。先放构造器和行映射,下面几个方法再依次加进这个类内部:

java 复制代码
package com.example.bootnotes;

import java.util.List;
import java.util.Optional;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;

@Repository
public class JdbcStudentRepository implements StudentRepository {

    private final JdbcTemplate jdbcTemplate;

    // 告诉 JdbcTemplate:数据库的一行怎样变成一个学生对象。
    private final RowMapper<Student> rowMapper = (rs, rowNum) ->
            new Student(
                    rs.getLong("id"),
                    rs.getString("name"),
                    rs.getObject("age", Integer.class)
            );

    public JdbcStudentRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }
}

@Repository 把这个类交给 Spring 管理;只有一个构造器时,不写 @Autowired 也能注入 JdbcTemplate。

RowMapper 表示“一行结果怎样变成一个对象”。这里每读到一行,就取出 id、name、age,创建一个 Student。年龄使用 getObject("age", Integer.class) 是为了保留 SQL NULL;如果使用 getInt,还需要 wasNull 才能区分空值和 0。

3.6 查询列表与单个学生

先把 findAll 加进实现类:

java 复制代码
@Override
    public List<Student> findAll() {
        String sql = "SELECT id, name, age FROM student ORDER BY id";
        return jdbcTemplate.query(sql, rowMapper);
    }

query 执行查询,对每一行调用 rowMapper,最后返回列表。ORDER BY id 保证按学号排序;没有查到记录时返回空列表,所以页面可以显示“暂无学生”。

再按主键查一个学生:

java 复制代码
@Override
    public Optional<Student> findById(Long id) {
        String sql = "SELECT id, name, age FROM student WHERE id = ?";
        List<Student> students = jdbcTemplate.query(sql, rowMapper, id);

        // 主键查询最多一行;查不到时明确返回 Optional.empty()。
        return students.stream().findFirst();
    }

问号是参数位置,后面的 id 绑定到这里,不需要自己给数字或字符串加引号。主键最多对应一行,所以把列表的第一项转换为 Optional:有记录就返回对象,没有记录就返回 Optional.empty()。

3.7 新增、修改和删除

新增时,数据库生成学号,我们只传姓名和年龄:

java 复制代码
@Override
    public int insert(Student student) {
        String sql = "INSERT INTO student(name, age) VALUES (?, ?)";
        return jdbcTemplate.update(sql, student.getName(), student.getAge());
    }

update 不仅能执行 UPDATE,也能执行 INSERT 和 DELETE。这里返回 1,通常表示成功插入一行,并不是新学号为 1;需要取得自增主键时,可以另外使用 KeyHolder。

修改时要同时知道新内容和目标学号:

java 复制代码
@Override
    public int update(Student student) {
        String sql = "UPDATE student SET name = ?, age = ? WHERE id = ?";
        return jdbcTemplate.update(
                sql, student.getName(), student.getAge(), student.getId()
        );
    }

参数按问号顺序对应:姓名、年龄、学号。WHERE id 只选中目标学生;遗漏条件会修改所有记录。SQL 与用户输入分开绑定,也能避免把输入里的引号等内容当成 SQL 结构。

删除同样先限定主键:

java 复制代码
@Override
    public int deleteById(Long id) {
        return jdbcTemplate.update("DELETE FROM student WHERE id = ?", id);
    }

调用 deleteById(1L) 只删除学号 1;返回 0 表示这次没有删除匹配记录。批量操作前也要先确认条件,不能把删除条件留空。

3.8 统计人数,以及五种常用方法

最后在实现类中加入 count:

java 复制代码
@Override
    public long count() {
        return jdbcTemplate.queryForObject(
                "SELECT COUNT(*) FROM student", Long.class
        );
    }

COUNT(*) 只返回一行数字,适合 queryForObject。Long.class 告诉框架把这一列转成 Long;空表的统计结果也是一行,值为 0。

到这里 Repository 已经包含常见 CRUD。为了能在别的业务中选对方法,再分别看 JdbcTemplate 提供的五种常用操作。下面的片段放在已经注入 JdbcTemplate 的方法里,按各自需求使用。

execute:执行不需要查询结果的语句。 例如创建一张独立的演示表:

java 复制代码
// 使用独立的小表演示,不改变学生表的结构。
jdbcTemplate.execute(
        "CREATE TABLE IF NOT EXISTS jdbc_demo (id BIGINT PRIMARY KEY)"
);

调用之后可以在数据库查看 jdbc_demo。它不读取学生列表,也不改变 student 表;常见业务系统的表结构通常另由迁移脚本管理。

update:执行一次增删改,返回影响行数。 例如把学号 1 的年龄改成 19:

java 复制代码
int rows = jdbcTemplate.update(
        "UPDATE student SET age = ? WHERE id = ?", 19, 1L
);
System.out.println(rows); // 学号 1 存在时,通常影响 1 行。

更新后重新查询该学生,就能看到新年龄。返回值描述操作影响了多少行,不是把更新后的整条记录返回。

batchUpdate:同一条 SQL 绑定多组参数。 例如导入王五和赵六:

java 复制代码
List<Object[]> rows = List.of(
        new Object[]{"王五", 19},
        new Object[]{"赵六", 21}
);

int[] results = jdbcTemplate.batchUpdate(
        "INSERT INTO student(name, age) VALUES (?, ?)", rows
);

每个 Object[] 对应一条学生记录,元素顺序对应 SQL 中的两个问号。返回数组记录每次执行的更新计数;有些驱动也会返回“成功但具体行数未知”的标记,不能要求所有环境都打印 [1, 1]。

批量提交不等于自动具备“全部成功或全部撤销”的业务保证;有这个要求时,还要把调用放入合适的事务。

query:把多行查询结果转为列表。 例如查询年龄大于等于 18 的学生:

java 复制代码
List<Student> students = jdbcTemplate.query(
        "SELECT id, name, age FROM student WHERE age >= ? ORDER BY id",
        (rs, rowNum) -> new Student(
                rs.getLong("id"),
                rs.getString("name"),
                rs.getObject("age", Integer.class)
        ),
        18
);

SQL 先筛选年龄,再对每一行执行映射。这里把 RowMapper 展开,是为了看清它在参数中的位置;在 JdbcStudentRepository 内可以直接复用已有 rowMapper。

queryForObject:要求结果恰好一行。 再用人数统计观察:

java 复制代码
Long total = jdbcTemplate.queryForObject(
        "SELECT COUNT(*) FROM student", Long.class
);
System.out.println(total); // 还没有增删数据时为 2。

代码旁的 2 以只有初始化名单、没有执行前面增删操作为前提。若已经导入其他学生,结果也会相应改变。

普通 SELECT 查询零行时,queryForObject 会抛 EmptyResultDataAccessException;查出多行时会抛 IncorrectResultSizeDataAccessException。“零行”与“有一行但值为 SQL NULL”也不同。需要自然表示找不到记录时,可以像 findById 那样使用 query,再转成 Optional。

3.9 把数据库查询接回页面

Repository 已经会查询,接下来让页面调用它。先增加 Service:业务层在这里组织操作,Controller 不直接编写 SQL。当前只是取得名单,后续需要业务校验或组合操作时,也有明确的位置。

java 复制代码
package com.example.bootnotes;

import java.util.List;
import org.springframework.stereotype.Service;

@Service
public class StudentService {

    private final StudentRepository studentRepository;

    public StudentService(StudentRepository studentRepository) {
        this.studentRepository = studentRepository;
    }

    public List<Student> list() {
        return studentRepository.findAll();
    }
}

再用下面的版本替换前面的 StudentController。查询得到 Student 后,逐个转换为 StudentVO,再交给页面:

java 复制代码
package com.example.bootnotes;

import java.util.List;
import java.util.ArrayList;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class StudentController {

    private final StudentService studentService;

    public StudentController(StudentService studentService) {
        this.studentService = studentService;
    }

    @GetMapping("/index")
    public String index(Model model) {
        List<Student> students = studentService.list();
        List<StudentVO> list = new ArrayList<>();

        // 数据库记录转为展示对象,只选择页面需要的字段。
        for (Student student : students) {
            list.add(new StudentVO(
                    student.getId(), student.getName(), student.getAge()
            ));
        }

        // 空表也可以正常显示,不直接调用 list.get(0)。
        model.addAttribute("student", list.isEmpty() ? null : list.get(0));
        model.addAttribute("value", "springboot");
        model.addAttribute("list", list);
        return "index";
    }
}

index.html 不需要跟着改,因为它仍然按学号、姓名、年龄读取属性。现在修改数据库中的姓名,再刷新 /index,就能看到新的名单;空表时也能正常显示提示。

这次请求的数据流是:

text 复制代码
浏览器请求 /index
    → Controller 调用 StudentService
    → Service 调用 StudentRepository
    → JdbcStudentRepository 执行 SQL,返回 Student 列表
    → Controller 转成 StudentVO,通过 Model 交给页面
    → Thymeleaf 生成 HTML,返回浏览器

这也给出了排错顺序:先检查数据库记录,再检查查询得到的对象,接着看 Model 属性名,最后核对模板表达式。每一步都有明确输入和结果,比一开始就在 HTML 中猜问题更容易定位。

4. Spring Data JPA:用实体完成常见数据库操作

4.1 从手写 SQL 到对象关系映射

JdbcTemplate 已经能完成学生管理。不过,对很多实体都写一遍 INSERT、按主键查询和 UPDATE,会产生相似的代码。希望直接调用 save、findById 等方法时,可以学习 Spring Data JPA。

它需要先说明“Student 对应哪张表、哪个字段对应主键”,再提供围绕实体的数据操作。把 Java 对象与关系表建立对应关系,叫 ORM,也就是对象关系映射。

Spring Data 是一组数据访问项目。关系型数据库常用 Spring Data JPA;Redis 则有 Spring Data Redis。它们有各自适用的数据模型,不是把同一份 JPA 配置换个地址就都能连接。

4.2 分清 JPA、Hibernate 与 Spring Data JPA

JPA 是持久化规范,现在对应 Jakarta Persistence。规范约定怎么映射和操作实体,本身还需要具体实现。它主要包含三方面内容:

  1. 映射元数据:使用注解或 XML 描述类与表、属性与列的关系。
  2. 持久化 API:例如 EntityManager,约定保存、查询和删除实体等操作。
  3. JPQL:面向实体和属性编写查询,而不是直接写数据库表名与列名。

Hibernate 是实现 JPA 规范的框架,负责管理实体状态、生成 SQL 和执行数据库操作。

Spring Data JPA 则在 JPA 之上提供 Repository 支持,让我们通过继承接口和约定方法名,减少常规数据访问代码。下面用的 Boot starter 默认整合 Hibernate,所以应用中这三者会一起出现。

text 复制代码
调用 Repository.save(student)
    → Spring Data JPA 提供 Repository 的运行时实现
    → 使用 JPA API 处理实体
    → Hibernate 生成并执行 SQL
    → 数据写入数据库

“只写接口”并不是任何方法名都能执行:框架已有 CRUD 和符合命名规则的查询可以自动提供实现;更复杂的需求仍要写 JPQL、原生 SQL 或自定义实现。

JdbcTemplate 以我们写的 SQL 为中心,JPA 以受管理的实体为中心。它们都能使用 Spring 的数据源和事务,可以在同一项目共存;学习时先把各自的用法分清,避免在同一操作中来回混用。

4.3 配置 JPA,并给 Student 添加映射

沿用前面的 MySQL 驱动、连接和 student 表,再加入 JPA starter:

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

把下面的 jpa 节点合并进 spring 配置:

yaml 复制代码
spring:
  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: true
    open-in-view: false

ddl-auto: validate 只检查实体和表是否匹配,不会替你创建缺失的表。show-sql: true 便于观察框架发出的 SQL。open-in-view: false 不让页面渲染依赖一直打开的数据库访问环境,数据应该在业务处理阶段准备好。

这里继续使用手动创建的 student 表,不要为了消除报错就把配置改成 create 或 create-drop;这些选项可能重建表。正式项目通常使用数据库迁移工具维护结构。

现在替换 Student 的定义,StudentVO 保持不变。下面给出完整实体,方便对照注解、构造器和属性:

java 复制代码
package com.example.bootnotes;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "student")
public class Student {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(name = "name", nullable = false, length = 50)
    private String name;
    @Column(name = "age")
    private Integer age;

    public Student() {
    }

    public Student(Long id, String name, Integer age) {
        this.id = id;
        this.name = name;
        this.age = age;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Integer getAge() {
        return age;
    }

    public void setAge(Integer age) {
        this.age = age;
    }
}

@Entity 表示这是 JPA 实体;@Table 指定 student 表。@Id 标识主键,@GeneratedValue 指定生成策略;IDENTITY 在这个 MySQL 示例中对应表的自增主键,不是所有数据库都必须使用这个策略。

@Column 描述普通属性与列的对应。name 的长度是 50、不能为 NULL,要与已有表一致。它描述映射和表结构约束,不能替代 HTTP 参数校验。

这些注解放在字段上,JPA 就通过字段访问数据。无参构造器供框架创建实体,带参构造器供业务创建新学生;不要因为平时只调用带参构造器就删掉无参版本。

4.4 继承 JpaRepository,先用现成查询

定义一个新接口 JpaStudentRepository,与 JDBC 版本区分:

java 复制代码
package com.example.bootnotes;

import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;

public interface JpaStudentRepository extends JpaRepository<Student, Long> {

    // 按实体属性解析:年龄大于等于参数值,并按 id 升序。
    List<Student> findByAgeGreaterThanEqualOrderByIdAsc(Integer age);
}

JpaRepository<Student, Long> 中,Student 是实体类型,Long 是主键类型。继承后就有 save、findById、findAll、count、deleteById 等方法,不必自己写实现类。

接口里额外声明的方法,按实体属性解析:AgeGreaterThanEqual 表示年龄大于等于参数,OrderByIdAsc 表示学号升序。这里的 Age 和 Id 对应 Java 属性,拼错通常会在 Repository 创建时被发现。

4.5 在 Service 里新增和修改学生

下面单独创建 JpaStudentService,观察 JPA 的用法。JDBC 的 StudentService 继续保留,两个服务名称不同,不会因为都查学生就互相替换:

java 复制代码
package com.example.bootnotes;

import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class JpaStudentService {

    private final JpaStudentRepository repository;

    public JpaStudentService(JpaStudentRepository repository) {
        this.repository = repository;
    }

    public Student create(String name, Integer age) {
        // id 为 null,表示这是等待新增的学生。
        return repository.save(new Student(null, name, age));
    }

    public List<Student> adults() {
        return repository.findByAgeGreaterThanEqualOrderByIdAsc(18);
    }

    @Transactional
    public void rename(Long id, String name) {
        Student student = repository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("学生不存在"));

        student.setName(name);
        // 当前事务中的受管理实体发生改变,提交时会同步到数据库。
    }
}

新增时传入 null 学号,再调用 save;数据库生成主键后,可以从返回的实体取得 id。后续操作优先使用 save 返回的对象。

rename 先在事务里查到学生,再修改姓名。这个对象受当前 JPA 持久化环境管理,事务提交时,Hibernate 检查到属性变化,会发出 UPDATE;这叫脏检查。任意 new 一个对象再调用 setter,并不会自己写进数据库。

@Transactional 把查找和修改放在事务中。这个注解的常见用法依赖 Spring 代理,因此应由其他组件调用注入的服务;在同一个类中写 this.rename,通常不会经过事务代理。

下面的调用片段放在已经注入 jpaStudentService 和 jpaStudentRepository 的方法中,按顺序执行:

java 复制代码
Student student = jpaStudentService.create("王五", 17);
System.out.println(student.getId()); // 数据库生成的新主键,具体数字以实际数据为准。

List<Student> adults = jpaStudentService.adults();
// 初始数据中会查到张三、李四;17 岁的王五不在这个结果中。

jpaStudentService.rename(student.getId(), "王五同学");
Student updated = jpaStudentRepository.findById(student.getId())
        .orElseThrow();
System.out.println(updated.getName()); // 王五同学

jpaStudentRepository.deleteById(student.getId());
// 删除后,再按这个 id 查询会得到 Optional.empty()。

王五先以 17 岁被保存,所以成年人查询不会包含他;改名后按 id 重新查询,得到“王五同学”;删除后再查,则得到 Optional.empty()。这就把新增、查询、修改、删除连成了一次可观察的过程。

如果想让名单页面改由 JPA 查询,可以把 StudentService 替换为下面的版本。Controller 的 Student→StudentVO 转换和模板保持原样:

java 复制代码
package com.example.bootnotes;

import java.util.List;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;

@Service
public class StudentService {

    private final JpaStudentRepository repository;

    public StudentService(JpaStudentRepository repository) {
        this.repository = repository;
    }

    public List<Student> list() {
        // 与 JDBC 的 ORDER BY id 保持相同的展示顺序。
        return repository.findAll(Sort.by("id").ascending());
    }
}

这里只替换数据来源,没有改变页面字段。要固定列表顺序,就显式指定排序,不能依赖没有 ORDER BY 的默认返回顺序。

4.6 用 JPQL 表达查询条件

查询条件多了,方法名可能越来越长。这时可以在 JpaStudentRepository 中增加 @Query,用查询语句表达同一个需求。先添加 import:

java 复制代码
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

再把方法放进接口内部:

java 复制代码
@Query("select s from Student s where s.age >= :age order by s.id")
List<Student> findAdults(@Param("age") Integer age);

这里的 Student 是实体名,s.age 和 s.id 是实体属性;JPQL 不直接使用 student 表名。:age 是命名参数,通过 @Param 与方法参数对应。

它和前面的长方法名都能查询成年学生,只是表达方式不同。需要数据库专属功能或复杂报表时,也可以写原生 SQL。学会 JPA 并不意味着不用理解实际执行的 SQL。

接下来,学生名单已经既能展示,也能从数据库读取。下面给网站增加账号登录,决定谁可以看名单、谁可以进入管理页。

5. Spring Security:先登录,再判断能访问什么

学生列表可以显示了,不过现在只要知道地址就能访问。如果网站还有管理页面,就不能让每个人都直接进入。这时需要解决两个问题:来的人是谁,以及他能操作什么。

安全框架可以统一处理这些规则,Spring Security 和 Shiro 都属于这一类工具。这里选择 Spring Security,继续在学生网站上增加登录和角色管理。

5.1 认证、授权和攻击防护分别做什么

认证是确认身份。例如用户提交用户名和密码,系统核对成功后记住他的登录状态。

授权是检查权限。例如普通用户能看学生名单,管理员还能进入管理页。账号密码正确,只说明登录成功,不代表能访问所有页面。

攻击防护处理常见的恶意请求。例如 CSRF 防护用于降低他人借助用户现有登录状态,诱使浏览器发起非本人意愿操作的风险。它和“密码是否正确”解决的是不同问题。

Spring Security 的 Servlet Web 安全机制主要通过过滤器链工作:请求进入 Controller 之前,先经过一组负责认证、授权等工作的 Servlet Filter。这里的 Filter 不是 Spring MVC 的 HandlerInterceptor;后者在 MVC 处理流程里工作,不能简单当作同一套机制。

先不用一次理解每个过滤器。我们从默认登录开始,观察一次请求发生什么,再配置自己的访问规则。

5.2 加入依赖,观察默认登录

在已有依赖中加入:

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

保持前面的 StudentController 和 index 模板,重新启动,再访问 http://localhost:8080/index。在尚未定义自己的安全配置和账号服务时,Spring Boot 会配置默认的安全行为,未登录请求会转到 /login。

默认用户名是 user,开发用随机密码会输出到启动日志中:

text 复制代码
Using generated security password: 一段随机生成的密码

复制本次启动的密码,登录后就能访问页面。随机密码会随重新启动而变化,不能拿上次的日志密码继续尝试。

练习时也可以在 application.yaml 中配置固定账号。下面的配置合并到已有的 spring 节点下,不要重复建立几个同名顶层节点:

yaml 复制代码
spring:
  security:
    user:
      name: student
      password: study123
      roles: USER

重启后用 student / study123 登录。这里的 USER 是角色,后面会用它判断能访问哪些页面。这个固定密码只用于当前本地演示。

这一步依赖的是默认账号配置。后面定义了自己的 UserDetailsService 后,默认账号来源会被替换,不能认为配置里的 student、内存里的 user、数据库里的账号会自动合并。

5.3 一次用户名密码登录,经过哪些步骤

默认登录表单把用户名和密码提交到 /login。处理这次提交的主要是 UsernamePasswordAuthenticationFilter,它读取登录表单内容并发起认证,并不独自完成所有数据库查询和密码校验。

后续会遇到几个框架类型,先说明它们的分工:

  • AuthenticationManager 是认证入口,负责把认证请求交给合适的认证提供者处理。
  • UserDetailsService 按用户名加载账号信息。可以从内存取,也可以从数据库取。
  • UserDetails 是 Spring Security 认识的账号信息,包含用户名、已保存的密码、权限和启用状态等。
  • PasswordEncoder 负责密码编码和匹配。注册时生成要保存的散列,登录时用 matches 核对输入密码与已保存的散列。

在本文的用户名密码方案中,过程可以这样看:

text 复制代码
提交用户名和密码
    → 认证过滤器接收登录请求
    → AuthenticationManager 委托认证提供者
    → UserDetailsService 查找账号
    → PasswordEncoder 核对密码
    → 认证成功,保存登录状态
    → 后续请求按配置检查角色和访问权限

认证成功后,本例通过 Session 保留安全上下文,后续请求会恢复登录身份,所以查看下一个页面时不用再输入密码。这里只用 Session 表单登录;前后端分离项目也可以采用其他认证方式,但不是这段示例的前提。

知道这些类型的职责后,再看配置就容易了:我们要配置的是“哪些地址需要什么身份”,再提供“账号从哪里来”。

5.4 配置页面访问规则

现在有两个页面:/index 展示学生列表,/admin 是管理页。要求 USER、ADMIN 都能看列表,只有 ADMIN 能进入管理页。

创建 SecurityConfiguration,让 Spring 扫描到它:

java 复制代码
package com.example.bootnotes;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfiguration {

    @Bean
    public PasswordEncoder passwordEncoder() {
        // 保存密码时计算散列,登录时用同一套算法核对。
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http)
            throws Exception {
        http.authorizeHttpRequests(authorize -> authorize
                // 具体规则放在前面,避免被后面的宽泛规则先匹配。
                .requestMatchers("/admin", "/admin/**").hasRole("ADMIN")
                .requestMatchers("/index", "/visitor")
                    .hasAnyRole("USER", "ADMIN")
                .anyRequest().authenticated()
        );

        http.formLogin(form -> form
                // 本例为了方便观察,登录后统一进入学生页面。
                .defaultSuccessUrl("/index", true)
                .permitAll()
        );

        // 使用默认退出处理;保留默认 CSRF 防护。
        http.logout(Customizer.withDefaults());
        return http.build();
    }
}

这个类里定义了两个由 Spring 管理的对象。

第一个是 PasswordEncoder,实际使用 BCryptPasswordEncoder。后面创建账号和验证密码都使用这一套算法,而不是一处存明文、一处拿散列来比。

第二个是 SecurityFilterChain,表示当前网站应用的安全过滤规则。HttpSecurity 由框架提供,我们在它上面配置规则,最后调用 build 得到过滤器链。方法声明 throws Exception,是因为构建过程中的部分配置操作可能抛出异常。

authorizeHttpRequests 配置请求授权。这里按照声明顺序匹配规则,匹配到适用规则就按它处理,所以更具体的 /admin 放在兜底规则前面:

  • requestMatchers 选择地址;这里同时列出 /admin 和 /admin/**,覆盖管理入口与下级路径。
  • hasRole("ADMIN") 要求管理员角色。
  • hasAnyRole("USER", "ADMIN") 表示拥有其中任意一个角色即可。
  • anyRequest().authenticated() 表示其余请求也要先登录,不会自动变成公开资源。

hasRole 和 hasAnyRole 默认使用 ROLE_ 前缀。因此传 ADMIN,实际检查的是 ROLE_ADMIN;后面用 roles("ADMIN") 创建账号时,框架会加此前缀,不要写成 roles("ROLE_ADMIN")。

formLogin 开启表单登录,没有指定自定义登录页面时就使用默认页面。defaultSuccessUrl("/index", true) 中,第一个参数是成功后地址,true 表示本例每次登录成功都去这里;改为 false 时,存在先前保存的请求等条件下,会优先恢复原先要访问的地址。

登录配置里的 permitAll 允许访问相关登录入口,否则还没登录就打不开登录页。它不会把 /admin 也开放给所有人。

logout(Customizer.withDefaults()) 使用默认退出行为。Customizer 是提供默认配置的工具,这里不用自己再写一套退出处理。整个配置没有关闭 CSRF,因此退出操作应通过带令牌的 POST 表单提交。

5.5 先用两个内存账号验证规则

规则已经有了,还需要提供账号。先用内存账号把登录和授权跑通,避免一开始就把数据库连接问题也混进来。

创建单独的 MemoryAccountConfiguration:

java 复制代码
package com.example.bootnotes;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class MemoryAccountConfiguration {

    @Bean
    public UserDetailsService userDetailsService(PasswordEncoder encoder) {
        var user = User.withUsername("user")
                .password(encoder.encode("user123"))
                .roles("USER")
                .build();

        var admin = User.withUsername("admin")
                .password(encoder.encode("admin123"))
                .roles("ADMIN")
                .build();

        return new InMemoryUserDetailsManager(user, admin);
    }
}

这里 User 是 Spring Security 提供的账号构建工具,不是学生实体。withUsername 设置用户名,password 接收已编码密码,roles 设置角色,build 生成 UserDetails。

var 是 Java 的局部变量类型推断:user 和 admin 的实际类型由右侧表达式确定,不是取消了类型检查。

InMemoryUserDetailsManager 保存这两个账号,并实现 UserDetailsService。它能根据登录表单里的用户名,把对应账号交给认证流程。重新启动时会重新创建,修改内存不会持久保存到数据库。

本阶段使用 user / user123、admin / admin123。上一小节 application.yaml 的 student 不再是当前账号来源。

5.6 增加管理页和退出入口

在 src/main/resources/templates 中新增 admin.html:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>管理页</title>
</head>
<body>
    <h1>管理员页面</h1>
    <p>只有 ADMIN 角色可以访问这里。</p>
    <p><a th:href="@{/index}">返回学生列表</a></p>
</body>
</html>

然后用 AdminController 把 /admin 映射到这个模板。Controller 只负责返回页面,是否允许进入,由前面的安全规则先判断:

java 复制代码
package com.example.bootnotes;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class AdminController {

    @GetMapping("/admin")
    public String admin() {
        return "admin";
    }
}

在已有 index.html 的 body 中补上管理入口和退出表单:

html 复制代码
<p><a th:href="@{/admin}">进入管理页面</a></p>

<form th:action="@{/logout}" method="post">
    <button type="submit">退出登录</button>
</form>

这个例子里两个角色都能看到管理链接,便于观察“看见链接”和“有权访问”是两回事。真实页面可以按角色显示不同入口,但服务器端权限校验仍然要保留,否则用户直接输入地址就可能绕过界面限制。

重启后分别尝试:

当前状态 访问 /index 访问 /admin
未登录 转到登录页 转到登录页
user,角色 USER 显示学生列表 返回 403,表示没有权限
admin,角色 ADMIN 显示学生列表 显示管理页

每次换账号前先退出,或者换一个独立的浏览器会话。否则浏览器还带着之前的 Session,观察到的可能还是上一个账号的权限。

5.7 为什么退出使用 POST,还需要 CSRF 令牌

登录状态通过浏览器会话保持时,浏览器发请求可能自动带上会话 Cookie。仅凭“带着 Cookie”不足以证明修改操作真的是用户在本站主动提交的。

CSRF 防护会让这类请求携带一个与当前安全上下文配套的令牌,并在服务端检查。本文的 Thymeleaf 与 Spring Security 集成后,使用 th:action 的 POST 表单会加入相应隐藏字段。渲染后的结构类似:

html 复制代码
<!-- 由框架生成,具体 value 每次会话或请求可能不同。 -->
<input type="hidden" name="_csrf" value="实际生成的令牌">

这个字段由框架生成,不要把示意中的 value 原样写进模板。可以在浏览器查看渲染后的表单来确认实际令牌。

退出表单和前面保存昵称的 /visitor 表单都使用了 th:action,所以接入安全框架后仍能正常提交。自己写普通表单或用 JavaScript 发 POST 时,则要按对应方式携带令牌;遇到 403 应先检查令牌与会话,而不是直接关闭 CSRF。

退出后再访问 /index,会重新要求登录。至此,页面权限和登录流程已经跑通,下一步只替换账号来源。

6. 数据库认证:让账号从数据表中读取

内存账号适合演示,但新增用户、修改角色、禁用账号都不能每次改 Java 代码再重启。实际网站通常把账号保存到数据库,登录时按用户名查询。

我们保留 SecurityConfiguration、页面和 Controller,替换提供账号的部分。本节移除 MemoryAccountConfiguration,改用 DatabaseUserDetailsService,不要把两套 UserDetailsService 同时注册在同一个示例中。

6.1 建立 account 表,明确账号数据的含义

学生业务资料与登录账号是两件事。Student 表示学生信息,Account 表示谁能登录、拥有什么角色,不因为页面展示学生就把登录密码也放进 StudentVO。

在前面相同的数据库中创建 account 表:

sql 复制代码
CREATE TABLE account (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(50) NOT NULL UNIQUE,
    password VARCHAR(100) NOT NULL,
    role VARCHAR(20) NOT NULL,
    enabled BOOLEAN NOT NULL
);

username 设置唯一约束,避免一个用户名对应多个账号。password 保存 BCrypt 散列,不保存原始密码;role 在这个简单例子里只存一个角色,取 USER 或 ADMIN;enabled 控制账号是否允许登录。

只存一个角色是为了先讲清流程。需要一个人同时拥有多个角色时,再设计账号与角色的关联表,不必在入门例子里先搭完整权限系统。

6.2 用 Account 映射账号表

沿用上一章的 JPA 依赖和数据源配置,创建实体 Account:

java 复制代码
package com.example.bootnotes;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "account")
public class Account {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 50)
    private String username;

    @Column(nullable = false, length = 100)
    private String password;

    @Column(nullable = false, length = 20)
    private String role;

    @Column(nullable = false)
    private boolean enabled;

    protected Account() {
    }

    public Account(
            String username,
            String password,
            String role,
            boolean enabled) {
        this.username = username;
        this.password = password;
        this.role = role;
        this.enabled = enabled;
    }

    public Long getId() {
        return id;
    }

    public String getUsername() {
        return username;
    }

    public String getPassword() {
        return password;
    }

    public String getRole() {
        return role;
    }

    public boolean isEnabled() {
        return enabled;
    }
}

这里的映射方法与 Student 相同:主键自增,username 唯一,字段长度与建表语句对应。无参构造器供 JPA 创建实体,带参构造器用于初始化账号。

这个类不是登录表单,也不是页面输出对象。后面读取出的密码只交给认证框架使用,不应返回给页面或接口调用方。

6.3 AccountRepository 只负责查询账号

创建 AccountRepository:

java 复制代码
package com.example.bootnotes;

import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface AccountRepository extends JpaRepository<Account, Long> {

    Optional<Account> findByUsername(String username);
}

Spring Data JPA 根据 findByUsername 生成按 username 属性查询的实现。Optional 表示“可能有账号,也可能查不到”,这样下一步能明确处理用户名不存在的情况。

注意,Repository 本身不负责完成登录。它只是查询工具;把查询结果接入 Spring Security,还需要实现 UserDetailsService。

6.4 把数据库账号交给 Spring Security

创建 DatabaseUserDetailsService:

java 复制代码
package com.example.bootnotes;

import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class DatabaseUserDetailsService implements UserDetailsService {

    private final AccountRepository accountRepository;

    public DatabaseUserDetailsService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException {
        Account account = accountRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("账号不存在"));

        // 将业务实体转成 Spring Security 能识别的用户信息。
        return User.withUsername(account.getUsername())
                .password(account.getPassword())
                .roles(account.getRole())
                .disabled(!account.isEnabled())
                .build();
    }
}

@Service 让它成为 Spring Bean。构造器注入 AccountRepository 后,框架调用 loadUserByUsername 时,就能查询数据库。

先按用户名查 Account,查不到就抛 UsernameNotFoundException,让认证流程判定失败;查到后,把业务实体转换为框架认识的 UserDetails。这里两种对象的职责要分清:Account 对应数据表,UserDetails 服务于安全认证。

转换中的三个地方尤其容易写错。

首先,password 传入的是数据库里已经保存的散列,不要再次调用 encoder.encode。每次登录都把旧散列再编码一遍,会变成另一份数据,当然无法与用户输入的原密码匹配。

其次,roles(account.getRole()) 传 USER 或 ADMIN,方法会加上 ROLE_ 前缀。因此表里不要再存 ROLE_USER。若项目使用的是完整权限名,则应按相应方式使用 authorities,不能混淆两种参数约定。

最后,实体 enabled=true 表示允许使用,但构建器的 disabled=true 表示禁用,所以这里要取反:disabled(!account.isEnabled())。少了这个取反,账号启用状态就会颠倒。

6.5 为什么没有自己写校验密码的 Controller

此时仍用原来的默认登录页,提交地址仍是 /login,不需要再写一个同名 POST Controller。表单认证过滤器已经接管了这个请求。

loadUserByUsername 只接收用户名,不接收用户本次输入的密码。它只负责读取“这个账号应该具有什么信息”。随后认证提供者用 PasswordEncoder.matches,把本次输入的密码和返回的散列进行核对,同时检查账号状态。

因此,完整过程是:默认登录表单 → 认证过滤器 → 认证管理器与提供者 → DatabaseUserDetailsService → AccountRepository → account 表。账号加载后,框架校验密码和状态;成功才建立登录状态,接着按 SecurityConfiguration 中的角色规则控制页面访问。

这样替换后,页面、访问规则、登录表单都不用变,只是同一个认证流程改为读取数据库账号。

6.6 初始化两个能实际登录的演示账号

表里有用户名还不够,必须保存与 BCryptPasswordEncoder 匹配的密码散列。不要直接用 SQL 把 user123 填进 password 列,也不要随意复制一段无法确定原密码的散列。

为了方便本地练习,使用 CommandLineRunner 初始化两个账号。它是 Spring Boot 提供的启动回调接口,应用启动过程中会调用它的 run 方法;下面的 lambda 就是该方法要执行的逻辑。

再加 @Profile("demo"):只有主动启用 demo 环境时,整个配置类才生效。这样平时启动不会自动创建固定密码账号。

java 复制代码
package com.example.bootnotes;

import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
@Profile("demo")
public class DemoAccountConfiguration {

    @Bean
    public CommandLineRunner initAccounts(
            AccountRepository repository,
            PasswordEncoder encoder) {
        // Spring 容器准备好后,再执行这段初始化逻辑。
        return args -> {
            if (repository.findByUsername("user").isEmpty()) {
                repository.save(new Account(
                        "user", encoder.encode("user123"), "USER", true
                ));
            }

            if (repository.findByUsername("admin").isEmpty()) {
                repository.save(new Account(
                        "admin", encoder.encode("admin123"), "ADMIN", true
                ));
            }
        };
    }
}

这里复用 SecurityConfiguration 中的 PasswordEncoder。账号不存在时才插入,避免每次启动都重置已有密码。保存的是 encode 的返回值,而不是 user123 或 admin123。

本地执行下面的命令,启用 demo 环境:

powershell 复制代码
mvn spring-boot:run "-Dspring-boot.run.profiles=demo"

启动前要先创建 account 表。前面的 ddl-auto: validate 只检查表结构,不会替你建表;如果检查失败,CommandLineRunner 也不能替代建表步骤。

启动后查询:

sql 复制代码
SELECT username, password, role, enabled
FROM account;

应看到 user、admin 两个账号,password 类似 $2a$... 或 $2b$...,而不是明文。BCrypt 编码含随机盐,因此对同一个原始密码多次 encode 通常得到不同结果;验证应使用 matches,不能比较两次 encode 的字符串是否相等。

这段初始化器供单实例本地练习,正式账号不使用这里的固定密码。初始化一次后,不带 demo 环境重启也能登录,因为账号已经保存在表里。

6.7 修改角色,观察新登录的权限

先退出旧会话,再分别使用 user / user123、admin / admin123 登录。USER 只能进入列表,ADMIN 还能进入管理页,结果应与内存版相同。

然后把 user 的角色改为 ADMIN:

sql 复制代码
UPDATE account
SET role = 'ADMIN'
WHERE username = 'user';

退出后重新登录,user 也能访问 /admin。这次没有修改安全配置或页面,而是修改数据库中的角色,新的认证结果就随之变化,说明数据库账号已经接入。

为什么强调重新登录?因为本例把认证结果保存在 Session 中。已有会话通常继续使用之前的角色信息,不会每次页面请求都重新查询 account 表。要让角色修改立即影响所有旧会话,需要另行设计会话失效或权限刷新。

接着把账号禁用:

sql 复制代码
UPDATE account
SET enabled = FALSE
WHERE username = 'user';

退出后再登录,应被拒绝。这里验证的是“禁用账号不能建立新的登录”,并不表示数据库一改,已经登录的会话就会自动下线。

练习完成后恢复原角色和启用状态:

sql 复制代码
UPDATE account
SET role = 'USER', enabled = TRUE
WHERE username = 'user';

从内存账号换到数据库账号的关键,就是实现 UserDetailsService,把账号查询接上框架提供的认证过程。业务查询、密码匹配和页面授权各有职责,出问题时也就能逐步定位。

7. 遇到问题时,沿着请求过程检查

7.1 返回了 index,浏览器却只看到字符串

先看 Controller 是不是用了 @RestController 或方法上加了 @ResponseBody。它们表示把返回值写进响应正文,字符串 index 就会被当作内容。返回模板视图的示例使用的是 @Controller。

如果报模板找不到,检查 Thymeleaf starter、src/main/resources/templates/index.html 和返回的视图名。本文返回 index,依赖默认前后缀解析,不是直接返回服务器磁盘路径。

7.2 页面能打开,但数据没有显示

沿着“Controller 放了什么 → 模板取了什么 → 对象能不能读取属性”来查。比如放入 list,模板就用 ${list};每次遍历的 student,要有 getName 等访问方法。

再分清数据范围。Model 为本次模板提供数据;直接使用 request 属性时考虑是否仍是同一请求;存入 Session 后,模板用 ${session.visitor}。重定向产生新请求,不能指望上一次 request 中的数据自动保留。

7.3 数据库启动失败,或者 JPA 报表结构不匹配

先确认数据库启动、库已创建,再检查连接地址、账号权限和驱动依赖。连接成功后,才轮到表名、列名、列类型与实体映射。

本例使用 ddl-auto=validate,所以缺表会启动失败,不会自动创建。也不要为了消除报错就直接改成 create:它可能重建表,应先理解数据处理行为。

7.4 查询结果或修改结果与预期不同

JdbcTemplate 先看 SQL 条件、传入参数与影响行数。queryForObject 要求恰好一行;没有数据和多于一行都不能直接当作普通 null 返回。

JPA 先看操作是否处于预期事务、实体是否受管理,以及 Hibernate 实际执行的 SQL。单独 new 一个对象再 setName,不等于已经修改数据库;示例中的 rename 是先在事务内查询实体,再修改它。

入门时先分别运行 JDBC 和 JPA 的修改示例,避免同一事务里交错修改同一条数据。JPA 的持久化上下文与刷新时机会让这种混用增加理解难度。

7.5 登录失败,或者提交后返回 403

先明确当前使用默认账号、内存账号还是数据库账号。提供数据库服务后还拿配置里的默认账号尝试,往往是账号来源弄错了。

数据库认证检查用户名、enabled、角色值与密码散列。密码错误属于认证失败;已登录但角色不够、POST 缺少有效 CSRF 令牌,都可能表现为不能继续访问,原因却不同。

修改角色或禁用状态后,退出旧会话再测试新的登录。想判断旧会话会不会立即失效,要单独检查会话管理策略,不能用一次数据库更新代替验证。

8. 官方资料

── 完 ──

评论

还没有评论,来说点什么
发表评论
0 / 1000