day10 Java操作数据库
计划
- JDBC基础入门
- JDBC查询与增删改
- SQL注入与预编译SQL
- MyBatis快速入门
- MyBatis注解写法与XML映射
- SpringBoot配置文件与数据库连接池
笔记
1.JDBC概述
JDBC是什么
- JDBC:Java DataBase Connectivity,使用Java语言操作关系型数据库的一套API
- 本质:Sun公司定义的一套接口规范,数据库厂商提供驱动jar包实现接口
- 执行原理:Java程序调用JDBC接口,真正执行的是数据库驱动中的实现类
Java操作数据库技术
| 技术 | 说明 |
|---|---|
| JDBC | 最底层、最基础的数据库操作技术 |
| MyBatis | 基于JDBC封装的持久层框架,简化数据库开发 |
| MyBatisPlus | 在MyBatis基础上进一步增强 |
| Hibernate / SpringDataJPA | ORM思想更强的持久层框架 |
2.JDBC查询数据
查询需求
基于JDBC实现用户登录,本质是执行一条带用户名和密码条件的查询语句:
1 | select * from user where username = 'daqiao' and password = '123456'; |
准备数据表
1 | create table user( |
JDBC查询步骤
1 | Connection conn = DriverManager.getConnection( |
ResultSet结果集
- ResultSet:封装DQL查询语句返回的结果
- next():移动光标到下一行,有数据返回true,没有数据返回false
- getXxx(…):获取当前行指定列的数据,推荐使用列名获取
3.预编译SQL与SQL注入
静态SQL与预编译SQL
| 写法 | 示例 | 问题 |
|---|---|---|
| 静态SQL | username = 'daqiao' |
参数写死,灵活性差 |
| 字符串拼接SQL | 拼接用户输入 | 容易产生SQL注入 |
| 预编译SQL | username = ? |
安全、性能更高,推荐使用 |
PreparedStatement
1 | PreparedStatement pstmt = conn.prepareStatement( |
SQL注入
- SQL注入:通过控制输入内容,修改原本SQL语句含义,达到绕过校验或攻击服务器的目的
- 典型场景:登录功能中,如果使用字符串拼接SQL,输入特殊内容可能使条件恒成立
- 解决方式:使用预编译SQL,用户输入会被当成普通参数值处理,不再改变SQL结构
4.JDBC增删改数据
执行DML语句
1 | Connection conn = DriverManager.getConnection( |
executeQuery与executeUpdate
| 方法 | 执行语句 | 返回值 |
|---|---|---|
| executeQuery() | DQL查询语句 | ResultSet结果集 |
| executeUpdate() | DML增删改语句 | 影响的记录数 |
5.MyBatis概述
MyBatis是什么
- MyBatis:一款优秀的持久层框架,用于简化JDBC开发
- 持久层:数据访问层,负责操作数据库
- 框架:半成品软件,提供通用基础代码,提高开发效率
JDBC的不足
- 数据库连接信息硬编码在Java代码中
- 查询结果解析和对象封装繁琐
- 每次操作都要创建和关闭连接,资源浪费明显
MyBatis的改进
- 数据库连接信息放到SpringBoot配置文件中
- 查询结果由MyBatis自动映射为Java对象
- 底层使用数据库连接池复用连接
6.MyBatis快速入门
使用步骤
- 创建SpringBoot工程,引入
mybatis-spring-boot-starter和mysql-connector-j - 创建数据库表,并创建与表字段对应的实体类
- 在配置文件中配置数据库连接信息
- 编写Mapper接口,并在接口上添加
@Mapper - 选择注解方式或XML方式编写SQL
- 在业务代码或测试代码中注入Mapper接口,调用方法操作数据库
核心关系
| 组成 | 作用 | 复习重点 |
|---|---|---|
| 实体类 | 封装表中一行数据 | 属性名尽量与字段名一致 |
| Mapper接口 | 定义数据库操作方法 | 方法名表达清楚要执行的操作 |
| SQL语句 | 真正操作数据库 | 可写在注解中,也可写在XML中 |
| 配置文件 | 配置数据源和MyBatis行为 | 数据库连接四要素、日志输出 |
Mapper接口基本模板
1 | @Mapper |
细节:
- Mapper接口不需要自己写实现类,MyBatis运行时会生成代理对象
@Mapper的作用是让MyBatis识别该接口,并把代理对象交给Spring IOC容器管理- DML语句可以用
int作为返回值,表示影响的记录数 - 查询多条数据用
List<实体类>,查询单条数据用实体类
7.MyBatis注解写法
适用场景
注解写法适合简单SQL,例如单表的基础增删改查。优点是直观、文件少;缺点是复杂SQL写在Java注解中可读性差。
查询
1 | @Select("select * from user") |
细节:
@Select用于写查询语句- 多条件查询时,建议使用
@Param给参数命名,SQL中通过#{参数名}取值 - 查询结果字段名与实体类属性名一致时,MyBatis可以自动封装
删除
1 | @Delete("delete from user where id = #{id}") |
细节:
#{id}不是字符串拼接,而是预编译占位符- 返回值
int表示删除了几条记录
新增
1 | @Insert("insert into user(username, password, name, age) values(#{username}, #{password}, #{name}, #{age})") |
细节:
- 参数是对象时,
#{username}表示调用对象的getUsername()取值 - 自增主键字段通常不需要手动插入
- 字段顺序要与values中的参数顺序保持一致
修改
1 | @Update("update user set username = #{username}, password = #{password}, name = #{name}, age = #{age} where id = #{id}") |
细节:
- 修改语句必须注意
where条件,缺少条件会更新整张表 - 修改对象必须包含用于定位数据的主键值
8.MyBatis XML映射写法
适用场景
XML写法适合复杂SQL,例如多条件动态查询、多表查询、复杂字段映射。优点是SQL集中、可读性更好;缺点是接口和XML之间要严格对应。
XML开发规范
| 规范 | 要求 |
|---|---|
| 文件位置 | XML映射文件与Mapper接口包路径保持一致 |
| 文件名称 | XML文件名与Mapper接口名一致,如UserMapper.xml |
| namespace | 必须写Mapper接口的全限定名 |
| SQL的id | 必须与Mapper接口方法名一致 |
| resultType | 查询返回单条记录要封装成的实体类类型 |
XML基本模板
1 | <?xml version="1.0" encoding="UTF-8" ?> |
查询写法
1 | <select id="findAll" resultType="com.zhang.pojo.User"> |
细节:
resultType写的是单条记录封装成什么类型,不是List<User>- 如果Mapper方法返回
List<User>,XML中仍然写resultType="com.zhang.pojo.User" id必须和接口方法名一致,否则MyBatis找不到对应SQL
增删改写法
1 | <insert id="insert"> |
细节:
<insert>、<update>、<delete>标签一般不需要写resultType- DML执行后返回影响行数,Mapper接口方法可以写
int - XML和注解不要同时给同一个方法配置SQL,否则会冲突或造成理解混乱
9.注解写法与XML写法对比
| 对比点 | 注解写法 | XML写法 |
|---|---|---|
| SQL位置 | Mapper接口方法上 | resources下的Mapper.xml中 |
| 适合场景 | 简单增删改查 | 复杂SQL、动态SQL、多表查询 |
| 优点 | 写法直接,文件少 | SQL更清晰,便于维护复杂语句 |
| 缺点 | SQL复杂时可读性差 | 配置规则多,需要接口和XML对应 |
| 选择建议 | 入门和简单SQL优先使用 | 复杂业务SQL优先使用 |
10.MyBatis参数传递细节
#{...}与${...}
| 符号 | 本质 | 结果 | 使用建议 |
|---|---|---|---|
#{...} |
参数占位符 | 会替换为?,走预编译SQL |
传递普通参数时强烈推荐 |
${...} |
字符串拼接 | 直接拼接到SQL语句中 | 只有动态表名、字段名等特殊场景才考虑 |
为什么推荐#{...}
- 可以防止SQL注入
- 可以使用预编译SQL,提高性能
- 参数值会被当成普通数据处理,不会改变SQL结构
参数来源
| Mapper方法参数 | SQL中取值方式 |
|---|---|
| 单个简单参数 | #{参数名}或#{任意名}通常都能取到 |
| 多个简单参数 | 推荐使用@Param("名称")后通过#{名称}取值 |
| 对象参数 | 通过#{属性名}取对象属性值 |
11.数据库连接池
连接池作用
- 数据库连接池是一个容器,负责分配和管理数据库连接
- 程序启动时,连接池中会提前创建一定数量的Connection对象
- 执行SQL时从连接池获取连接,执行完毕后归还连接池
- 空闲过久的连接可以被自动释放,减少连接泄露风险
连接池优点
- 资源重用
- 提升系统响应速度
- 避免频繁创建和销毁连接
- 降低数据库连接遗漏风险
常见连接池
| 连接池 | 说明 |
|---|---|
| Hikari | SpringBoot默认连接池,性能好 |
| Druid | 阿里巴巴开源连接池,功能强大 |
| C3P0 | 早期常见连接池 |
| DBCP | Apache提供的连接池 |
12.SpringBoot配置文件
properties与yml
| 配置文件 | 格式 | 特点 |
|---|---|---|
| application.properties | key=value |
简单直接,配置多时层级不够清晰 |
| application.yml / application.yaml | 缩进表示层级 | 简洁清晰,以数据为中心,推荐使用 |
yml语法规则
- 大小写敏感
- 数值前必须有空格作为分隔符
- 使用缩进表示层级关系
- 缩进不能使用Tab,只能使用空格
- 相同层级左侧对齐
#表示注释- 以0开头的值建议使用引号包裹,避免被识别为八进制
数据源配置模板
1 | spring: |
MyBatis日志配置
1 | mybatis: |
开启后,控制台可以看到MyBatis执行的SQL语句、参数和结果,方便排查数据库操作问题。
13.复习时容易忽略的细节
- Mapper接口只定义方法,不写实现类,实现类由MyBatis动态代理生成
@Mapper不能忘,否则Mapper接口不会被MyBatis识别并交给Spring管理- 注解和XML是两种SQL配置方式,同一个方法不要两边都写SQL
- XML中的
namespace必须等于Mapper接口全限定名 - XML中SQL标签的
id必须等于Mapper接口的方法名 - 查询集合时,XML的
resultType仍写集合中单个元素的类型 - DML语句返回
int可以知道影响了几条记录 - 修改和删除一定要注意
where条件,避免影响整张表 - 普通参数传递优先使用
#{...},不要为了省事使用${...} - 开启MyBatis日志后,可以在控制台观察最终执行的SQL和参数
14.核心知识点总结
| 知识点 | 说明 |
|---|---|
| JDBC | Java操作关系型数据库的底层API |
| DriverManager | 获取数据库连接 |
| Connection | 数据库连接对象 |
| PreparedStatement | 预编译SQL执行对象 |
| ResultSet | 查询结果集对象 |
| executeQuery() | 执行DQL查询语句 |
| executeUpdate() | 执行DML增删改语句 |
| SQL注入 | 通过输入改变SQL语义的攻击方式 |
| MyBatis | 简化JDBC开发的持久层框架 |
| @Mapper | 声明Mapper接口,交给MyBatis和Spring管理 |
| 注解写法 | 通过@Select、@Insert、@Update、@Delete在接口方法上写SQL |
| #{…} | 参数占位符,生成预编译SQL |
| ${…} | 字符串拼接符,存在注入风险 |
| XML映射 | 将SQL写在XML中,适合复杂SQL |
| Hikari | SpringBoot默认数据库连接池 |
| application.yml | SpringBoot推荐配置文件格式 |
day1 IO流
day1
计划
- 今天学完IO
笔记
1.File
- File的3个构造方法
- 表示什么:路径,可以表示文件或文件夹
- 常用方法
- 判断,获取
- 创建,删除
- 遍历
- 不能读写数据,读写需要用到IO流
2.IO流
1.字节流
- 计算机数据的最小单位是字节
- 字节流可以操作任何文件
- 字节流读取文本文件,需要读取为
byte[],指定编码方式转为字符串,否则中文字符会出现乱码。 - 字节流没有缓冲区,可以用byte数组来做缓冲区,提升读取的效率
2.字符流
- 字符集有ASCII,GBK,Unicode等
- ASCII英文占用1字节
- GBK兼容ASCII,英文1字节,中文2字节。英文字节首位为0,中文高位字节首位为1,由此区分中英文
- Unicode编码方式多样
- UTF-8,1-4字节表示字符,英文1,中文3
- UTF-16,2或4字节表示字符
- UTF-32,4字节表示字符
- 字符串转为
bite[]可以指定编码方式,bite[]构建字符串也可以指定编码。
- 字符流底层通过解码器按指定字符集将字节转换为字符。以 UTF-8 为例,解码器会识别字节头,决定本次读取 1~4 个字节拼成一个字符。
- 字符流可以在定义时指定编码格式,未指定就会用默认编码。
- 字符流的输入,输出流都有缓冲区
- 输入流,会先填满缓冲区,优先读取缓冲区,读完缓冲区后才会再取数据填满缓冲区。即使文件清空,也会先读完缓冲区的数据。
- 输出流,会先存到缓冲区,如果不调用
flush()或close(),数据会一直卡在内存中,直到缓冲区满了才会写入文件。
day12 员工管理实战
day12
计划
- 多表关系设计(一对一、一对多、多对多)
- 多表查询SQL(内连接、外连接、子查询)
- 员工列表查询功能实现
- 分页查询(原始方式 vs PageHelper插件)
- 动态SQL实现条件查询
笔记
1.多表关系设计
三种表关系
| 关系 | 说明 | 实现方式 | 示例 |
|---|---|---|---|
| 一对多 | 一个部门下有多个员工 | 多的一方添加外键字段 | dept(1) → emp(N) |
| 一对一 | 一个用户对应一张身份证 | 任意一方加外键+UNIQUE | user(1) ↔ user_card(1) |
| 多对多 | 一个学生可选多门课程 | 建立中间表,两个外键 | student ↔ course |
一对多(部门与员工)
部门表(父表/一方):
1 | CREATE TABLE dept ( |
员工表(子表/多方):
1 | create table emp( |
一对一(用户基本信息与身份信息)
1 | -- 用户基本信息表 |
要点: 外键设置为 UNIQUE 即可实现一对一。
多对多(学生与课程)
1 | -- 中间表:学生选课关系 |
要点: 中间表至少包含两个外键字段,分别关联两方主键。
物理外键 vs 逻辑外键
| 对比项 | 物理外键(foreign key) | 逻辑外键 |
|---|---|---|
| 概念 | 使用foreign key定义外键关联 | 在业务层逻辑中解决关联 |
| 优点 | 保证数据完整性和一致性 | 不影响增删改效率,适合分布式 |
| 缺点 | 影响增删改效率,不适合分布式/集群 | 需要业务层保证数据一致性 |
| 使用 | 学习阶段使用 | 企业开发首选 |
结论: 实际开发中基本都使用逻辑外键,物理外键很少使用。
2.多表查询
笛卡尔积
多表查询时,不加条件会产生笛卡尔积(两表所有记录的组合),需要通过连接条件消除无效数据。
1 | -- 笛卡尔积(180条 = 30员工 × 6部门) |
内连接
隐式内连接:
1 | select 字段列表 from 表1, 表2 where 连接条件; |
显式内连接:
1 | select 字段列表 from 表1 inner join 表2 on 连接条件; |
要点:
- 内连接只查询两表交集部分的数据
- 没有部门的员工(dept_id为NULL)不会被查询出来
inner关键字可省略
外连接
| 类型 | 语法 | 说明 |
|---|---|---|
| 左外连接 | left join ... on ... |
查询左表所有数据 + 右表匹配数据 |
| 右外连接 | right join ... on ... |
查询右表所有数据 + 左表匹配数据 |
1 | -- 左外连接:查询所有员工(包括无部门的)及其部门名称 |
要点:
- 外连接保留某一方所有数据,即使不满足连接条件
- 左外连接和右外连接可通过交换表的位置互相转换
- 员工列表查询应使用左外连接(保留所有员工)
子查询
SQL中嵌套select语句,称为子查询。
| 类型 | 结果 | 常用操作符 | 示例场景 |
|---|---|---|---|
| 标量子查询 | 一行一列(单值) | =, >, <, >=, <= |
查询最早入职的员工 |
| 列子查询 | 一列多行 | in, not in |
查询某部门的所有员工 |
| 行子查询 | 一行多列 | =, in |
查询与某人薪资和职位相同的员工 |
| 表子查询 | 多行多列 | 作为临时表 | 查询每部门最高薪资的员工 |
标量子查询示例:
1 | -- 查询最早入职的员工 |
列子查询示例:
1 | -- 查询"教研部"和"咨询部"的所有员工 |
行子查询示例:
1 | -- 查询与"李忠"的薪资及职位都相同的员工 |
表子查询示例:
1 | -- 获取每个部门中薪资最高的员工 |
3.员工列表查询实现
3.1 实体类
Emp员工实体类:
1 | @Data |
PageResult分页结果封装类:
1 | @Data |
3.2 基础实现(左外连接多表查询)
Mapper接口:
1 | @Mapper |
要点:
- 使用
left join保留所有员工(包括dept_id为NULL的) - SQL中给部门名称起别名
deptName,与Java属性名对应 - 开启驼峰映射后,
dept_name→deptName自动转换
4.分页查询
4.1 原始方式
Mapper接口:
1 | @Mapper |
Service实现:
1 | public PageResult page(Integer page, Integer pageSize) { |
分页公式: 开始索引 = (当前页码 - 1) × 每页条数
4.2 PageHelper分页插件(推荐)
1. 引入依赖:
1 | <dependency> |
2. application.yml配置:
1 | pagehelper: |
3. Mapper接口(只需一条SQL):
1 | @Mapper |
4. Service实现:
1 | public PageResult page(Integer page, Integer pageSize) { |
PageHelper核心要点:
PageHelper.startPage(page, pageSize)之后的第一条SQL会被自动分页- PageHelper自动执行两条SQL:①
count(0)查总数 ②limit查分页数据 - SQL结尾不要加分号(;),否则可能导致分页失效
Page对象继承自List,可用getTotal()和getResult()获取分页信息
5.条件分页查询
5.1 多参数接收
Controller:
1 | @GetMapping |
注意: @DateTimeFormat 用于将字符串日期转换为 LocalDate 对象。
5.2 动态SQL(XML映射文件)
EmpMapper.xml:
1 | <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" |
Mapper接口:
1 | @Mapper |
5.3 动态SQL标签说明
| 标签 | 作用 | 示例 |
|---|---|---|
<if> |
条件成立时拼接SQL | <if test="name != null"> |
<where> |
自动添加where关键字,去除多余的and/or | 包裹多个<if>条件 |
<set> |
UPDATE语句中去除尾部多余逗号 | - |
<foreach> |
遍历集合/数组,常用于in查询 | in (1,2,3) |
<where> 标签的作用:
- 自动在SQL后追加
where关键字 - 自动去除第一个条件前多余的
and或or - 如果没有任何条件,不追加
where
5.4 参数封装优化
当请求参数较多时,可以封装为实体类:
EmpQueryParam参数类:
1 | @Data |
Controller简化为:
1 | @GetMapping |
Mapper接口简化为:
1 | List<Emp> list(EmpQueryParam param); // 传递整个参数对象 |
6.核心知识点总结
| 知识点 | 说明 |
|---|---|
| 一对多 | 多方加外键,关联一方主键(如emp.dept_id → dept.id) |
| 一对一 | 任意一方加外键+UNIQUE约束 |
| 多对多 | 建立中间表,两个外键分别关联 |
| 物理外键 | foreign key,保证完整性但影响性能,少用 |
| 逻辑外键 | 业务层处理关联,企业开发首选 |
| 笛卡尔积 | 多表不加条件的组合,需通过连接条件消除 |
| 内连接 | 只查两表交集,inner join |
| 左外连接 | 保留左表所有数据,left join |
| 右外连接 | 保留右表所有数据,right join |
| 标量子查询 | 结果为单值,用 = 比较 |
| 列子查询 | 结果为一列多行,用 in 比较 |
| 行子查询 | 结果为一行多列,用 = 比较 |
| 表子查询 | 结果为多行多列,作为临时表 |
| LIMIT分页 | limit 起始索引, 每页条数 |
| PageHelper | MyBatis分页插件,自动实现count+limit |
| PageHelper.startPage | 紧跟其后的第一条SQL自动分页 |
| 动态SQL | <if> 条件拼接、<where> 智能处理 |
| @DateTimeFormat | 字符串日期 → LocalDate |
| 参数封装 | 多参数封装为实体类,简化方法签名 |
day11 部门管理实战
day11
计划
- 前后端分离开发规范与REST风格
- 工程搭建与基础代码结构
- 部门管理CRUD功能实现
- 参数接收方式详解
- MyBatis结果映射与驼峰命名
- Nginx部署与前后端联调
- Logback日志技术
笔记
1.前后端分离开发
前后端分离
将工程拆分为前端工程和后端工程,各自独立开发、独立部署。前端通过异步请求获取数据,后端根据接口文档提供数据。
开发流程
- 需求分析 → 理解产品原型和需求文档
- 接口定义 → 查阅接口文档,明确地址、参数、响应
- 前后端并行开发 → 各自按接口文档实现
- 接口测试 → 使用Apifox/Postman等工具测试后端接口
- 前后端联调 → 前端请求后端接口,验证整体功能
2.RESTful风格
传统URL vs RESTful
| 操作 | 传统URL | RESTful URL |
|---|---|---|
| 查询 | /user/getById?id=1 GET |
/users/1 GET |
| 新增 | /user/saveUser POST |
/users POST |
| 修改 | /user/updateUser POST |
/users PUT |
| 删除 | /user/deleteUser?id=1 GET |
/users/1 DELETE |
RESTful核心规则
- URL定位资源:使用名词复数形式(如
/depts、/emps) - HTTP动词描述操作:GET查询、POST新增、PUT修改、DELETE删除
- 一句话总结:通过URL定位资源,通过HTTP请求方式描述操作
3.工程搭建
依赖引入
1 | <dependencies> |
application.yml配置
1 | spring: |
包结构
1 | com.zhang |
4.统一响应结果Result
1 | @Data |
5.部门管理CRUD
5.1 查询部门列表
Controller:
1 | @GetMapping("/depts") |
**Service:**调用Mapper查询所有部门。
Mapper:
1 | @Select("select id, name, create_time, update_time from dept") |
5.2 删除部门(根据ID)
请求方式: DELETE
请求路径: /depts?id=1
1 | @DeleteMapping("/depts") |
Mapper:
1 | @Delete("delete from dept where id=#{id}") |
5.3 新增部门
请求方式: POST
请求路径: /depts
请求参数: JSON格式 {"name":"研发部"}
1 | @PostMapping("/depts") |
**Service:**补全createTime和updateTime
1 | public void insert(Dept dept) { |
Mapper:
1 | @Insert("insert into dept(name,create_time,update_time) values(#{name},#{createTime},#{updateTime})") |
5.4 根据ID查询部门
请求方式: GET
请求路径: /depts/1(路径参数)
1 | @GetMapping("/depts/{id}") |
5.5 修改部门
请求方式: PUT
请求路径: /depts
请求参数: JSON格式 {"id":1,"name":"研发部"}
1 | @PutMapping("/depts") |
**Service:**更新updateTime
1 | public void update(Dept dept) { |
6.参数接收方式
6.1 简单参数接收
| 方式 | 代码示例 | 说明 |
|---|---|---|
| HttpServletRequest | request.getParameter("id") |
繁琐,需手动转换,不推荐 |
| @RequestParam | @RequestParam("id") Integer id |
指定参数名,推荐 |
| 形参同名 | Integer id(参数名与形参名一致时) |
最简洁,推荐 |
1 | // 方式一:@RequestParam |
6.2 JSON参数接收
使用@RequestBody注解,Spring自动将JSON数据反序列化为Java对象。
1 | public Result save(@RequestBody Dept dept) |
注意:JSON的key必须与实体类的属性名一致。
6.3 路径参数接收
使用@PathVariable注解,从URL路径中获取参数。
1 | @GetMapping("/depts/{id}") |
7.请求方式映射注解
| 注解 | 对应请求方式 | 用途 |
|---|---|---|
| @GetMapping | GET | 查询数据 |
| @PostMapping | POST | 新增数据 |
| @PutMapping | PUT | 修改数据 |
| @DeleteMapping | DELETE | 删除数据 |
| @RequestMapping | 所有方式 | 可通过method属性限定 |
推荐:使用@GetMapping等衍生注解,比@RequestMapping更简洁、语义更清晰。
类级别@RequestMapping
将公共路径抽取到类上,方法上的路径与类上的路径拼接形成完整请求路径。
1 | @RequestMapping("/depts") |
8.MyBatis结果映射
问题:属性名与字段名不一致
实体类属性(createTime)与数据库字段(create_time)命名风格不同,导致MyBatis无法自动封装。
三种解决方案
| 方案 | 代码示例 | 说明 |
|---|---|---|
| 手动映射 | @Results({@Result(column="create_time",property="createTime")}) |
灵活但繁琐 |
| SQL起别名 | select create_time createTime from dept |
简单直接 |
| 驼峰映射 | 配置map-underscore-to-camel-case: true |
推荐 |
驼峰命名配置
在application.yml中开启自动驼峰命名映射:
1 | mybatis: |
映射规则:create_time → createTime,update_time → updateTime
9.Nginx与前后端联调
Nginx核心功能
| 功能 | 说明 |
|---|---|
| Web服务器 | 托管静态资源(HTML、CSS、JS、图片) |
| 反向代理 | 转发客户端请求到后端服务器 |
| 负载均衡 | 将请求分发到多台后端服务器 |
请求流程
1 | 浏览器 → Nginx(:90) → 后端Tomcat(:8080) → Nginx → 浏览器 |
- 浏览器访问
http://localhost:90/api/depts - Nginx接收到请求,根据
/api/前缀匹配规则 - Nginx重写路径:
/api/depts→/depts - Nginx将请求转发到
http://localhost:8080/depts - 后端处理完返回数据,Nginx将结果返回给浏览器
Nginx关键配置
1 | server { |
配置说明:
| 配置项 | 作用 |
|---|---|
location / |
匹配所有路径,返回前端页面 |
try_files |
支持前端路由刷新不404 |
location ^~ /api/ |
精确匹配API前缀,不再检查正则 |
rewrite |
去掉/api/前缀,重写路径 |
proxy_pass |
转发请求到后端服务器 |
为什么使用Nginx
- 安全:后端Tomcat集群不直接暴露给前端
- 灵活:后端增减服务器对前端无感知
- 负载均衡:便于实现多服务器负载均衡
10.Apifox接口测试
Apifox是集成了API文档、调试、Mock、测试的协作平台。
使用步骤
- 创建项目,导入或编写接口文档
- 在”接口管理”中定义接口(路径、方法、参数、响应)
- 在”接口调试”中发送请求测试
- 在”Mock服务”中生成模拟数据供前端开发
- 支持GET/POST/PUT/DELETE等所有请求方式
11.Logback日志
为什么使用日志框架
| System.out.println | Logback |
|---|---|
| 硬编码,不灵活 | 可通过配置文件控制 |
| 只能输出到控制台 | 支持输出到文件 |
| 无日志级别 | 支持debug/info/warn/error级别 |
| 不便于维护 | 灵活配置格式和输出位置 |
Logback入门
1. 引入依赖(SpringBoot已内置)
2. 配置文件logback.xml
1 | <?xml version="1.0" encoding="UTF-8"?> |
3. 获取Logger对象
1 | // 方式一:声明Logger对象 |
4. 记录日志
1 | log.debug("调试信息"); |
日志级别
| 级别 | 说明 | 优先级 |
|---|---|---|
| trace | 追踪(很少使用) | 低 |
| debug | 调试 | ↓ |
| info | 运行信息 | ↓ |
| warn | 警告 | ↓ |
| error | 错误 | 高 |
规则:设置的级别越高,输出的日志越少。如设置info级别,则debug和trace级别不会输出。
12.核心知识点总结
| 知识点 | 说明 |
|---|---|
| 前后端分离 | 前端独立工程、后端独立工程,通过接口文档协作 |
| RESTful风格 | URL定位资源(复数名词),HTTP动词描述操作 |
| @GetMapping | GET请求查询数据 |
| @PostMapping | POST请求新增数据 |
| @PutMapping | PUT请求修改数据 |
| @DeleteMapping | DELETE请求删除数据 |
| @RequestBody | 接收JSON格式参数 |
| @RequestParam | 接收URL查询参数 |
| @PathVariable | 接收URL路径参数 |
| @RequestMapping | 类级别公共路径抽取 |
| Result | 统一响应封装类 |
| map-underscore-to-camel-case | 下划线自动转驼峰映射 |
| Nginx | 静态服务器+反向代理+负载均衡 |
| Apifox | 接口文档编写与测试工具 |
| Logback | 日志框架,支持控制台和文件输出 |
| 日志级别 | debug < info < warn < error |
day14 员工管理实战-删除修改员工,异常处理,员工统计
计划
- 删除员工(批量删除,一个接口兼容单个/多个)
- 修改员工(先查询回显 + 再保存修改)
- 异常处理(全局异常处理器 @RestControllerAdvice)
- 员工信息统计(职位统计 + 性别统计)
笔记
1.删除员工
需求分析
勾选列表复选框,点击”批量删除”按钮,删除这一批员工信息;也可以只勾选一个,仅删除一个员工。
结论:只需开发一个功能接口,批量删除接口天然包含”只删除一个”的场景,无需单独开发删除单个的接口。
接口要求: 请求路径 /emps、请求方式 DELETE、请求参数为逗号拼接的 id(如 ids=1,2,3)。
Controller 接收参数的两种方式
方式一:数组接收(默认可直接封装到数组中)
1 | @DeleteMapping |
方式二:集合接收(封装到 List 集合,需加 @RequestParam)
1 | @DeleteMapping |
推荐使用集合方式(List),因为基于集合操作其中的元素更加方便(如增删改查遍历)。本项目实际使用数组方式 Integer[] ids。
Service 层(事务控制)
删除员工既要删 emp 表中的基本信息,又要删 emp_expr 表中工作经历信息,多次操作数据库必须加事务控制。
1 | @Transactional |
Mapper 层(<foreach> 实现 in 查询)
EmpMapper.xml:
1 | <!--批量删除员工信息--> |
EmpExprMapper.xml: 同理按 emp_id in (...) 批量删除工作经历。
<foreach> 属性回顾:
| 属性 | 说明 |
|---|---|
collection |
集合名称(方法参数名) |
item |
遍历出来的每一项 |
open |
遍历开始前拼接的片段(如 () |
close |
遍历结束后拼接的片段(如 )) |
separator |
每项之间的分隔符(如 ,) |
注意:
open、close、separator属于字符串拼接,不要写成#{open}的形式。
2.修改员工
修改分为两步:
- 查询回显:根据ID查询员工详细信息,回填到页面表单
- 保存修改:用户修改后提交,更新数据库
2.1 查询回显
思路
回显时既要查出员工基本信息,又要查出该员工的工作经历信息(一对多)。
方式一:两次查询 —— 分别查 emp 和 emp_expr,再在 Service 中组装(项目中有注释掉的代码)。
方式二:一条SQL多表查询 + resultMap 手动封装(推荐,本项目使用)
1 | select e.*, |
resultMap 封装一对多结果
EmpMapper.xml:
1 | <!--自定义结果集 ResultMap--> |
要点:
- 一对多查询结果必须用
<resultMap>手动封装 <resultMap>中<id>标签映射主键(用于去重),<result>映射普通字段<collection>用于封装一对多中的”多”,ofType指定集合元素的类型- SQL 中给子表字段起别名(如
ee_id),与 resultMap 的column对应,避免与主表字段冲突 - 联查的员工基本信息在
e.*中,工作经历通过别名列封装进exprList
resultType vs resultMap 如何选择
| 场景 | 选择 |
|---|---|
| 查询返回字段名与实体属性名能直接对应 | resultType |
| 字段名与属性名对应不上,或实体属性复杂(如一对多、嵌套) | resultMap 手动封装 |
Controller / Service
1 | @GetMapping("/{id}") |
2.2 保存修改
实现思路(三步)
- 根据ID更新员工基本信息(动态SQL,只更新有值的字段)
- 根据员工ID删除旧的工作经历信息
- 新增新的工作经历信息
为什么要”先删旧再增新”? 页面提交的是编辑后的整份工作经历列表,无法精确得知哪些是被删改的,最简单可靠的方式就是全删旧 + 全增新。
Service 实现
1 | @Transactional |
动态SQL更新(<set> + <if>)
EmpMapper.xml:
1 | <!--根据ID更新员工信息--> |
<set> 标签作用: 自动去掉更新语句中最后一个多余的逗号(<where> 是去掉多余的 and/or)。
数字类型(Integer)判空只需
!= null,字符串类型要同时判空字符串!= ''。
Controller:
1 | @PutMapping |
3.异常处理
问题分析
修改员工时手机号重复(违反唯一约束),服务端抛异常返回框架默认的错误JSON,而不是我们约定的统一响应结果 Result,导致前端无法解析。
未做任何异常处理时,异常在分层架构中的传递:Mapper → Service → Controller → 框架,最终由框架返回不符合规范的JSON。
两种解决方案
| 方案 | 做法 | 评价 |
|---|---|---|
| 方案一 | 所有Controller所有方法 try…catch | 代码臃肿,不推荐 |
| 方案二 | 全局异常处理器 | 简单、优雅,推荐 |
全局异常处理器
步骤:
- 定义一个类,类上加
@RestControllerAdvice—— 代表全局异常处理器 - 定义一个方法捕获异常,方法上加
@ExceptionHandler,通过 value 属性(或方法形参)指定捕获的异常类型
1 | @RestControllerAdvice |
关键注解:
@RestControllerAdvice=@ControllerAdvice+@ResponseBody,处理异常方法的返回值会转换为 JSON 响应给前端@ExceptionHandler:指定可以捕获哪种类型的异常
项目中的增强版:处理具体异常(DuplicateKeyException)
本项目在全局异常处理器中重载了两个方法,Spring会按异常类型精确匹配,优先调用参数类型最匹配的处理方法(更精确的异常会覆盖通用 Exception 的处理)。
1 | @Slf4j |
要点: 出现异常后,全局异常处理器将异常捕获并统一包装成 Result 返回,前端可以正常解析并提示错误信息。
4.员工信息统计
报表(图形)制作主要靠前端引入 ECharts 等组件,服务端只负责提供数据。
官网:https://echarts.apache.org/zh/index.html
4.1 职位统计
接口
GET /report/empJobData,返回封装好的 JobOption{jobList, dataList},分别对应 ECharts 的 X 轴分类和数据。
JobOption 封装类
1 | @Data |
Controller / Service
1 | @RestController |
1 | @Service |
Mapper(查询结果封装为 Map)
1 | @MapKey("pos") // 指定返回Map的唯一标识字段【也可以不指定】 |
EmpMapper.xml:
1 | <!-- 统计各个职位的员工人数 --> |
要点:
- 查询结果每行是一个 Map,
resultType="java.util.Map",字段别名即 Map 的 key(pos、total) @MapKey用于指定返回 Map 集合中每条记录的标识字段(可不指定)
4.2 性别统计
接口
GET /report/empGenderData,返回 List<Map>(ECharts 饼图需要的数据格式)。
Mapper
1 | @MapKey("name") |
EmpMapper.xml:
1 | <!-- 统计员工的性别信息 --> |
SQL 函数总结
| 函数 | 语法 | 作用 |
|---|---|---|
| case语法一 | case when cond1 then res1 [when cond2 then res2] else res end |
条件成立取对应值,都不成立取 else |
| case语法二(等值匹配) | case expr when val1 then res1 [when val2 then res2] else res end |
expr 等于 val1 取 res1,以此类推 |
| if | if(条件, 条件为true取值, 条件为false取值) |
三元判断,如 if(gender=1,'男','女') |
| ifnull | ifnull(expr, val1) |
expr 不为 null 取自身,否则取 val1 |
5.核心知识点总结
| 知识点 | 说明 |
|---|---|
| 批量删除设计 | 一个接口兼容单个/多个,无需分开开发 |
| 数组接收参数 | Integer[] ids,前端参数名与形参名一致即可 |
| 集合接收参数 | @RequestParam List<Integer> ids,推荐使用 |
<foreach> |
动态拼接 in 查询,collection/item/open/close/separator |
| 修改员工两步 | ① 根据ID查询回显 ② 保存修改 |
| 查询回显SQL | left join emp_expr 一次查出基本信息+工作经历 |
| resultMap + collection | 一对多结果手动封装,ofType 指定集合元素类型 |
| resultType vs resultMap | 字段能对上用 resultType;字段对不上或结构复杂用 resultMap |
动态更新 <set> |
自动去掉最后一个多余的逗号 |
| 更新经历三步 | 更新基本信息 + 删除旧经历 + 新增新经历 |
| @RestControllerAdvice | 全局异常处理器 = @ControllerAdvice + @ResponseBody |
| @ExceptionHandler | 指定捕获的异常类型,可按异常类型重载多个处理方法 |
| DuplicateKeyException | 主键/唯一约束冲突异常,可提取重复值提示”xxx已存在” |
| 报表开发模式 | 前端用 ECharts,后端只提供数据 |
| JobOption | 职位统计封装类(jobList + dataList) |
| @MapKey | 指定返回Map集合的标识字段(可不指定) |
| case 函数 | 流程控制,两种语法(条件式/等值式) |
| if 函数 | if(条件, true值, false值),如性别转男/女 |
| ifnull 函数 | expr 为 null 时取默认值 |
| count + group by | 统计场景的核心 SQL 组合 |
day13 员工管理实战-事务管理,文件上传
day13
计划
- 新增员工功能(保存emp表 + 批量保存工作经历)
- 事务管理(@Transactional、rollbackFor、propagation)
- 文件上传(前端三要素、本地存储、阿里云OSS)
笔记
1.新增员工
需求分析
新增员工时,表单中既要录入员工基本信息,又要录入工作经历信息:
- 员工基本信息 → 存入
emp表 - 工作经历信息 → 存入
emp_expr表(一个员工可有多段经历)
接口要求: 请求路径 /emps、请求方式 POST、请求参数 JSON格式(@PostMapping + @RequestBody)
Emp实体类增加工作经历属性
1 | @Data |
保存员工基本信息(主键返回)
EmpMapper:
1 | // 主键返回:将数据库自动生成的主键回填到emp对象的id属性中 |
为什么需要主键返回? 保存员工后还要保存其工作经历,而工作经历表的 emp_id 字段需要关联员工的ID。@Options(useGeneratedKeys = true, keyProperty = "id") 会在insert执行后,把自增主键值回填到 emp.getId()。
批量保存工作经历(<foreach>标签)
EmpExprMapper接口:
1 | @Mapper |
EmpExprMapper.xml:
1 | <insert id="insertBatch"> |
<foreach> 标签常用属性:
| 属性 | 说明 |
|---|---|
collection |
集合名称(方法参数名) |
item |
遍历出来的元素/项 |
separator |
每次遍历之间的分隔符(如逗号) |
open |
遍历开始前拼接的片段 |
close |
遍历结束后拼接的片段 |
Service完整逻辑
1 | @Override |
2.事务管理
为什么需要事务
新增员工涉及两次数据库操作(插emp表 + 插emp_expr表)。如果保存员工成功、保存经历失败,就会造成数据不完整不一致。事务能让多个操作要么全部成功,要么全部失败。
事务概念与操作
- 概念: 一组操作的集合,是不可分割的工作单位,要么同时成功,要么同时失败
- SQL层面三步操作:
- 开启事务:
start transaction;/begin; - 全部成功:
commit; - 有失败:
rollback;
- 开启事务:
注意:MySQL默认事务是自动提交的,执行一条DML语句会立即隐式提交。
Spring事务管理:@Transactional注解
作用: 方法执行前开启事务,执行完毕提交事务,出现异常则回滚事务。
1 | @Transactional |
可加的位置:
| 位置 | 作用 |
|---|---|
| 方法上 | 当前方法交给Spring事务管理 |
| 类上 | 当前类所有方法都进行事务管理 |
| 接口上 | 接口下所有实现类的所有方法都进行事务管理 |
为什么放在业务层? 一个业务功能可能包含多个数据访问操作,在业务层控制事务可以将多个操作控制在同一个事务范围内。
事务日志配置
1 | logging: |
rollbackFor属性(异常回滚)
默认规则: 只有出现**运行时异常(RuntimeException)**才回滚事务;抛出编译时异常(Exception)不会回滚。
1 | // 模拟编译时异常:事务没有回滚,数据保存成功 |
解决: 通过 rollbackFor 指定回滚的异常类型。
1 | @Transactional(rollbackFor = Exception.class) // 所有异常都回滚 |
结论:
- 默认只有
RuntimeException会回滚 - 需要回滚其他异常时,用
rollbackFor属性指定
propagation属性(事务传播行为)
概念: 一个事务方法被另一个事务方法调用时,这个事务方法应该如何进行事务控制。
| 属性值 | 含义 | 使用场景 |
|---|---|---|
| REQUIRED(默认) | 需要事务,有则加入,无则新建 | 大部分情况 |
| REQUIRES_NEW | 总是创建新事务 | 不希望事务相互影响(如记日志) |
| SUPPORTS | 有则加入,无则无事务运行 | 少用 |
| NOT_SUPPORTED | 无事务状态下运行 | 少用 |
| MANDATORY | 必须有事务,否则抛异常 | 少用 |
| NEVER | 必须没有事务,否则抛异常 | 少用 |
案例:新增员工时无论成功失败都记录日志
问题:save方法有事务,insertLog默认REQUIRED会加入同一事务 → 主事务回滚时日志也被回滚,日志没记上。
解决: 让insertLog独立开启新事务。
1 | @Service |
两个必须掌握的传播行为:
- REQUIRED: 大部分情况下使用,加入已有事务
- REQUIRES_NEW: 不希望事务互相影响时使用,如记录日志(无论业务成功与否日志都要记上)
事务四大特性(ACID)
| 特性 | 英文 | 说明 |
|---|---|---|
| 原子性 | Atomicity | 事务是不可分割的最小单元,要么全部成功,要么全部失败 |
| 一致性 | Consistency | 事务完成时,数据都必须处于一致状态 |
| 隔离性 | Isolation | 多个并发事务之间相互隔离,互不干扰 |
| 持久性 | Durability | 事务提交或回滚后,对数据的改变是永久的 |
3.文件上传
简介
将本地图片、视频、音频等文件上传到服务器,供其他用户浏览或下载。
前端上传页面三要素
| 要素 | 要求 | 原因 |
|---|---|---|
| file域 | 表单必须有 <input type="file"> |
用于选择文件 |
| POST方式 | method="post" |
上传文件通常较大,POST才能传输 |
| enctype | enctype="multipart/form-data" |
普通编码格式不适合传输二进制大文件 |
1 | <form action="/upload" method="post" enctype="multipart/form-data"> |
后端接收(MultipartFile)
1 | @Slf4j |
注意: 方法形参名必须与请求参数名一致。不一致时用 @RequestParam("file") MultipartFile image 绑定。
MultipartFile常用方法:
| 方法 | 作用 |
|---|---|
getOriginalFilename() |
获取原始文件名 |
transferTo(File dest) |
将接收的文件转存到磁盘 |
getSize() |
获取文件大小(字节) |
getBytes() |
获取文件内容的字节数组 |
getInputStream() |
获取文件内容的输入流 |
本地存储
问题1:文件名重复会覆盖。 解决:用UUID生成唯一文件名。
1 | // 生成唯一文件名:UUID + 原文件后缀 |
问题2:大文件上传报错。 SpringBoot默认单个文件最大1M,需配置:
1 | spring: |
本地存储的缺点: 不安全(磁盘损坏文件丢失)、容量有限、无法直接访问。
阿里云OSS(云存储)
OSS是什么: 阿里云对象存储服务(Object Storage Service),海量、安全、低成本、高可靠的云存储服务,通过网络随时存储和调用文件。
SDK: Software Development Kit(软件开发工具包),包含依赖jar包和代码示例。
使用第三方服务的通用思路:
- 准备工作:注册账号、开通服务、创建Bucket、创建AccessKey
- 参照官方SDK示例编写入门程序
- 集成到项目中
关键概念:
| 概念 | 说明 |
|---|---|
| Bucket | 存储空间,存储文件的容器,所有文件必须隶属于某个Bucket |
| Object | 存储的对象(就是文件) |
| endpoint | Bucket对应的访问域名 |
| region | Bucket所属地域(如cn-beijing) |
| AccessKey | 访问凭证(ID + Secret),通过环境变量配置 |
1. 引入依赖:
1 | <dependency> |
2. 环境变量配置AccessKey:
1 | set OSS_ACCESS_KEY_ID=你的ID |
3. 集成工具类AliyunOSSOperator:
1 | @Component |
4. UploadController改造:
1 | @PostMapping("/upload") |
4.配置参数优化(@Value vs @ConfigurationProperties)
问题: endpoint、bucketName等参数写死在Java代码中,测试/生产环境切换不便维护。
方式一:@Value注解逐个注入
1 | @Value("${aliyun.oss.endpoint}") |
1 | aliyun: |
适用: 一两个属性、不需要复用。
方式二:@ConfigurationProperties批量绑定(推荐)
三个步骤:
- 创建实体类,属性名与配置key一致
- 将实体类交给IOC容器管理(
@Component) - 实体类上添加
@ConfigurationProperties(prefix="...")指定前缀
1 | @Data |
1 | @Component |
对比:
| 方式 | 优点 | 缺点 |
|---|---|---|
| @Value | 简单直接 | 配置项多时繁琐,不便维护复用 |
| @ConfigurationProperties | 批量注入,便于维护复用 | 需要额外建实体类 |
5.核心知识点总结
| 知识点 | 说明 |
|---|---|
| @PostMapping + @RequestBody | 接收JSON格式的新增请求 |
| @Options(useGeneratedKeys, keyProperty) | 主键返回,自增主键回填到对象id属性 |
<foreach> |
批量插入,collection/item/separator/open/close |
| CollectionUtils.isEmpty | 判断集合是否为空 |
| 事务 | 一组操作的集合,要么全部成功要么全部失败 |
| @Transactional | 开启事务管理,异常自动回滚 |
| 事务注解位置 | 业务层方法/类/接口上 |
| rollbackFor | 默认只回滚RuntimeException,rollbackFor=Exception.class回滚所有异常 |
| REQUIRED | 默认传播行为,有事务则加入 |
| REQUIRES_NEW | 总是创建新事务,互不影响(如记日志) |
| ACID | 原子性、一致性、隔离性、持久性 |
| 上传三要素 | file域 + POST + enctype=”multipart/form-data” |
| MultipartFile | 接收上传文件,transferTo转存磁盘 |
| UUID文件名 | 解决文件名重复覆盖问题 |
| max-file-size | SpringBoot默认单文件最大1M,需配置调大 |
| 阿里云OSS | 对象存储服务,解决本地存储的缺点 |
| SDK | 软件开发工具包,含依赖和示例代码 |
| @Value | 单个属性注入配置 |
| @ConfigurationProperties | 批量绑定配置到实体类,便于维护复用 |
day15-2 登录认证
实战总结:登录认证(登录功能 + 登录校验)
本文是对《12-后端Web实战(登录认证)》的总结,记录”登录功能徒有其表 → 登录校验闭环”的完整链路。
项目地址:project/tlias-web-management(SpringBoot + MyBatis + JWT + Filter/Interceptor)
对应讲义:讲义/12-后端Web实战(登录认证).md
一、本节要解决的核心问题
上一节做完基础登录后,会出现一个安全漏洞:浏览器直接访问 http://localhost:90,不登录也能进后台。
原因:HTTP 是无状态协议,每次请求相互独立,服务器根本不知道”这次请求的员工登没登录过”。而部门管理、员工管理等接口在服务端没有任何登录判断。
1 | 登录功能(徒有其表) ──► 需要登录校验(闭环) |
登录校验的实现思路拆成两块:
| 关键点 | 作用 | 用到的技术 |
|---|---|---|
| ① 存登录标记 | 登录成功后留一个”已登录”凭证,后续请求能带上 | 会话技术(令牌 JWT) |
| ② 统一拦截 | 拦下所有请求,统一校验标记,避免每个接口都写一遍 | Filter 过滤器 / Interceptor 拦截器 |
为什么不能在每个 Controller 方法里手写校验?——相同逻辑会重复出现在所有增删改查接口里,代码繁琐。统一拦截是标准做法。
二、会话技术:三种方案对比
1. 概念澄清
- 会话:浏览器与服务器之间的一次连接(从打开浏览器第一次访问,到任一方断开)。一次会话可包含多次请求响应。
- 会话跟踪:服务器识别”多次请求是否来自同一浏览器”,从而在同一次会话的多次请求间共享数据。
- 为什么要共享数据?因为 HTTP 无状态,后一次请求拿不到前一次请求生成的数据。
2. 三种方案横向对比
| 方案 | 存储位置 | 底层依赖 | 优点 | 缺点 |
|---|---|---|---|---|
| Cookie | 客户端浏览器 | HTTP 协议自带(Set-Cookie 响应头 / Cookie 请求头) | 浏览器自动收发,无需手写 | 移动端 APP 不可用;不安全(可禁用);不能跨域 |
| Session | 服务端 | 底层基于 Cookie(JSESSIONID) | 数据在服务端,相对安全 | 集群环境下无法直接用;移动端不可用;可被禁用;不能跨域 |
| 令牌 Token | 客户端(如 localStorage) | 自己实现生成/传递/校验 | 支持 PC + 移动端;解决集群认证;不占服务端存储 | 需自己实现整套逻辑 |
企业开发首选第三种令牌技术,前两种已很少使用。本项目也采用令牌。
3. 跨域速记
三个维度(协议 / IP(域名) / 端口)任意一个不同就是跨域。Cookie 无法跨域,这是 Cookie/Session 方案在前后端分离部署时失效的直接原因。
1 | http://192.168.150.200/login.html → https://192.168.150.200/login 协议不同 → 跨域 |
4. 集群环境为何 Session 失效
部署多份 Tomcat + 负载均衡时:登录请求落到 Tomcat1 创建 Session,下一次查询请求可能被分到 Tomcat2,Tomcat2 里没有这个 JSESSIONID 对应的 Session → 会话丢失。令牌方案把凭证存在客户端,每次请求自带,天然避开这个问题。
三、JWT 令牌
1. 是什么
JWT(JSON Web Token):一种简洁、自包含的字符串格式,用于通信双方以 JSON 格式安全传输信息,靠数字签名保证可靠性。
- 简洁:就是一个字符串,可直接放请求参数或请求头。
- 自包含:可在令牌里存自定义数据(如 id、username),不用查服务端。
- 本质:把原始 JSON 数据安全封装成字符串,传输时基于 base64 编码(编码 ≠ 加密)。
2. 三段组成(用 . 分隔)
1 | eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MTAsInVzZXJuYW1lIjoiaXRoZWltYSIsImV4cCI6MTcwMTkwOTAxNX0.N-MD6DmoeIIY5lB5z73UFLN9u7veppx1K5_N_jS9Yko |
| 部分 | 内容 | 举例 |
|---|---|---|
| Header | 令牌类型 + 签名算法 | {"alg":"HS256","type":"JWT"} |
| Payload | 自定义信息 + 默认信息(如 exp 过期时间) | {"id":1,"username":"Tom","exp":...} |
| Signature | Header + Payload + 秘钥,按指定算法计算,防篡改 | 签名串 |
关键结论:篡改令牌任意一个字符,解析都会报错;令牌过期后解析也报错(非法)。
3. 生成与校验(讲义示例)
依赖:
1 | <dependency> |
生成 + 校验:
1 | // 生成 |
两条铁律:
- 校验用的签名秘钥,必须和生成时配套。
- 解析报错 = 令牌被篡改 或 已过期 = 非法。
四、登录时下发令牌(项目实现)
1. 整体流程
1 | 浏览器 服务端 |
2. 关键类与代码
| 类/所在层 | 全限定类名 | 作用 |
|---|---|---|
| pojo | com.zhang.pojo.LoginInfo |
登录成功响应封装:id、username、name、token |
| utils | com.zhang.utils.JwtUtils |
JWT 工具类:generateToken 生成、parseToken 解析 |
| controller | com.zhang.controller.LoginController |
/login 接口入口,返回 Result.success(loginInfo) 或 Result.error(...) |
| service | com.zhang.service.impl.EmpServiceImpl |
login(Emp):根据用户名密码查库 → 生成 JWT → 组装 LoginInfo |
| mapper | com.zhang.mapper.EmpMapper |
getByUsernamePassword(Emp):按 username + password 查询员工 |
LoginInfo(响应封装):
1 | @Data |
JwtUtils(项目版本,方法名是 generateToken / parseToken):
1 | public class JwtUtils { |
LoginController:
1 | @PostMapping("/login") |
EmpServiceImpl.login(查库 + 生成 token):
1 | @Override |
前端拿到 token 后存
localStorage(不是 cookie),后续每个请求在请求头token字段带上。
五、统一拦截方案一:Filter 过滤器
1. 是什么
Filter 是 JavaWeb 三大组件(Servlet、Filter、Listener)之一,能把对资源的请求拦下来做通用操作(登录校验、统一编码、敏感字符处理等)。要先经过 Filter,才能访问后面的资源。
2. 入门三步
- 定义类实现
Filter接口,重写init/doFilter/destroy。 - 类上加
@WebFilter(urlPatterns = "/*")配置拦截路径。 - 启动类加
@ServletComponentScan开启 Servlet 组件支持。
三个方法:
| 方法 | 调用时机 | 调用次数 |
|---|---|---|
init |
web 服务器启动创建 Filter 实例时 | 1 次 |
doFilter |
每次拦截到请求 | 多次 |
destroy |
web 服务器关闭时 | 1 次 |
放行:chain.doFilter(request, response); —— 不调用就到不了后面的资源。
3. 执行流程(放行前 / 放行后)
1 | public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) { |
4. 拦截路径配置
| 拦截路径 | urlPatterns | 含义 |
|---|---|---|
| 具体路径 | /login |
只拦截 /login |
| 目录拦截 | /emps/* |
拦截 /emps 下所有资源 |
| 拦截所有 | /* |
拦截所有资源 |
5. 过滤器链
多个 Filter 形成链,一个接一个执行;放行后回来时顺序相反。注解配置的 Filter,执行顺序按类名字符串自然排序(AbcFilter 先于 DemoFilter)。
6. 登录校验六步(项目类:TokenFilter,全限定类名 com.zhang.filter.TokenFilter)
1 | 1. 获取请求 URI |
1 | // 项目 TokenFilter 核心逻辑(注意:本项目中此 Filter 未启用,见"项目现状") |
六、统一拦截方案二:Interceptor 拦截器
1. 是什么
Interceptor 是 Spring 框架提供的,动态拦截 Controller 方法的执行。作用和 Filter 类似,但只拦 Spring 环境内的资源。
2. 入门两步
- 定义类实现
HandlerInterceptor,重写三个方法,加@Component。 - 写配置类
WebConfig implements WebMvcConfigurer,重写addInterceptors注册拦截器。必须加@Configuration
三个方法:
| 方法 | 调用时机 | 返回值含义 |
|---|---|---|
preHandle |
目标方法执行前 | true 放行;false 不放行(Controller 不执行) |
postHandle |
目标方法执行后 | — |
afterCompletion |
视图渲染完毕后,最后执行 | — |
3. 注册配置(项目类:WebConfig,全限定类名 com.zhang.config.WebConfig)
1 | @Configuration |
4. 拦截路径配置
| 拦截路径 | 含义 | 能匹配 | 不能匹配 |
|---|---|---|---|
/* |
一级路径 | /depts、/login | /depts/1 |
/** |
任意级路径 | /depts、/depts/1、/depts/1/2 | — |
/depts/* |
/depts 下一级 | /depts/1 | /depts/1/2、/depts |
/depts/** |
/depts 下任意级 | /depts、/depts/1、/depts/1/2 | /emps/1 |
排除登录请求有两种写法:① 拦截器里
uri.contains("login")判断放行(项目用法);② 配置里excludePathPatterns("/login")。
5. 登录校验 Interceptor(项目类:TokenInterceptor,全限定类名 com.zhang.interceptor.TokenInterceptor)
逻辑与 Filter 完全一致,只是返回 boolean:
1 | @Component |
七、Filter vs Interceptor 对比(高频考点)
| 对比项 | Filter 过滤器 | Interceptor 拦截器 |
|---|---|---|
| 接口规范 | 实现 jakarta.servlet.Filter |
实现 HandlerInterceptor |
| 拦截范围 | 拦截所有资源(包括静态资源、JSP 等) | 只拦 Spring 环境中的资源(Controller) |
| 归属 | JavaWeb 三大组件(Servlet 规范) | Spring 框架提供 |
| 配置方式 | @WebFilter + @ServletComponentScan |
WebMvcConfigurer.addInterceptors |
| 核心方法 | doFilter(放行 chain.doFilter) |
preHandle 返回 boolean |
| 执行时机 | DispatcherServlet 之前 | DispatcherServlet 之后、Controller 之前 |
执行顺序(重要):
1 | 浏览器 → Filter(放行前) → DispatcherServlet → Interceptor.preHandle → Controller |
本项目二选一用了 Interceptor,Filter 仅作示例保留(见下)。
八、项目现状速查(重点!)
复习时最容易把”示例代码”和”项目真正生效的校验链路”搞混,这里专门列清。
1. 实际生效的校验链路
1 | 请求 → TokenInterceptor.preHandle (校验 token) → Controller → Service → Mapper |
- 校验组件:
TokenInterceptor(com.zhang.interceptor.TokenInterceptor,加了@Component) - 注册位置:
WebConfig(com.zhang.config.WebConfig,addPathPatterns("/**")注册拦截器)
2. Filter 是”保留示例”,未启用
TokenFilter(com.zhang.filter.TokenFilter)里 @WebFilter(urlPatterns = "/*") 被注释掉了,也没有 @Component,所以不会生效。讲义里也明确:”登录校验的过滤器和拦截器,我们只需要使用其中的一种就可以了。”
3. 项目与讲义的命名差异
| 讲义命名 | 项目命名 | 说明 |
|---|---|---|
JwtUtils.generateJwt |
JwtUtils.generateToken |
同功能,方法名不同 |
JwtUtils.parseJWT |
JwtUtils.parseToken |
同功能,方法名不同 |
empMapper.getUsernameAndPassword |
empMapper.getByUsernamePassword |
同 SQL,方法名不同 |
LoginInfo 字段 token |
同 | 一致 |
4. 项目涉及文件清单
| 层 | 全限定类名 / 类 | 本节做了什么 |
|---|---|---|
| pojo | com.zhang.pojo.LoginInfo |
新增:登录响应封装(id、username、name、token) |
| utils | com.zhang.utils.JwtUtils |
新增:JWT 工具类(generateToken、parseToken) |
| controller | com.zhang.controller.LoginController |
新增:POST /login 接口 |
| service | com.zhang.service.impl.EmpServiceImpl |
新增 login(Emp):查库 → 生成 JWT → 组装 LoginInfo |
| mapper | com.zhang.mapper.EmpMapper |
新增 getByUsernamePassword(Emp):按用户名密码查询 |
| interceptor | com.zhang.interceptor.TokenInterceptor |
新增:token 校验拦截器(当前生效) |
| filter | com.zhang.filter.TokenFilter |
新增:token 校验过滤器(未启用,仅示例) |
| config | com.zhang.config.WebConfig |
新增:注册 TokenInterceptor,拦截 /** |
九、易错点 & 复习提醒
生成 token 时取 id 的来源:项目
EmpServiceImpl.login里写的是map.put("id", emp.getId()),但emp是请求体对象,登录时前端不会传 id,所以这里的 id 实际是 null。更稳的写法是用查询结果e.getId()(讲义示例就是这么写的)。不影响 token 是否生成,但 token 载荷里的 id 会丢。登录请求必须放行:拦截器/过滤器第一件事就是判断
uri.contains("/login")放行,否则永远登不进去拿不到 token。token 的传递字段是请求头
token,不是Authorization,也不是 cookie。前后端要约定一致(本项目前端在token头里带)。401 状态码:未登录、token 为空、token 解析失败,都返回
SC_UNAUTHORIZED(401)。前端拿到 401 会自动跳登录页。JWT 秘钥必须配套:生成和解析用同一个
signKey,换了就全部失效。JWT 不是加密:Payload 是 base64 编码,能直接解码出来,所以别往里放密码等敏感信息。签名只是防篡改,不是防查看。
Filter 和 Interceptor 不要同时启用做同一件事:本项目 Filter 注释掉了,只用 Interceptor。两个都开会导致重复校验。
@WebFilter方式需要@ServletComponentScan;如果用@Component+FilterRegistrationBean则不需要。本项目 Interceptor 走的是 Spring 的WebMvcConfigurer注册方式。拦截路径
/*vs/**:Filter 用/*(Servlet 规范);Interceptor 用/**(Spring Ant 风格)。两者含义不同,别混。
十、一图回顾整条链路
1 | ┌───────── 登录 ─────────┐ |
day15-1 AI完成 班级管理与学员管理-总结
实战总结:班级管理、学员管理、学员信息统计
本文是对《11-后端Web实战(自己完成)》这个实战任务的总结,记录从需求分析、接口设计到代码实现的全过程。
项目地址:project/tlias-web-management(SpringBoot + MyBatis + PageHelper + Lombok)
一、任务概述
上一个实战任务已经完成了员工管理的增删改查和员工信息统计。本任务要求在此基础上,为培训机构系统补齐剩余模块:
- 班级管理:班级列表(条件分页)、添加班级、查询回显、修改班级、删除班级
- 学员管理:学员列表(条件分页)、添加学员、查询回显、修改学员、删除学员、违纪处理
- 学员信息统计:班级人数统计、学员学历统计
- 功能完善:删除部门时,若部门下存在员工则禁止删除
总共新增/修改了 16 个接口点,全部严格对照接口文档开发。
二、总体构思
1. 开发前的三个准备
| 步骤 | 内容 | 目的 |
|---|---|---|
| ① 读接口文档 | 讲义/接口文档.md |
明确每个接口的路径、请求方式、参数格式、响应结构,这是前后端联调的契约 |
| ② 分析已有项目结构 | 查看 controller / service / mapper / pojo 各层 | 复用已有的代码风格、工具类和组件,不引入讲义外的语法 |
| ③ 分析数据库表 | clazz、student 表结构 |
字段名、类型、唯一约束(后续动态 SQL 和统计函数都要用到) |
2. 严格遵守已有分层架构
1 | Controller (接收请求/响应 Result) |
复用的现成组件:Result(统一响应)、PageResult<T>(分页结果)、PageHelper(分页插件)、GlobalExceptionHandler(全局异常处理器)、@Slf4j 日志。
3. 按模块逐个开发,每个模块都是”老套路”
每个模块的开发顺序统一为:分页/列表查询 → 新增 → 根据ID查询 → 修改 → 删除,与之前员工管理的开发顺序完全一致,只是业务数据不同。
三、实体类与自定义异常
1. Clazz(班级)
1 | @Data |
2. Student(学员)
1 | @Data |
说明:
masterName、status、clazzName这三个字段在数据库表中不存在,是联查或计算出来的扩展字段,用于响应前端。
3. StudentCountData(班级人数统计结果)
仿照已有的 EmpJobData(职位统计用 jobList + dataList),班级人数统计也封装成一个对象:
1 | @Data |
4. BusinessException(自定义业务异常)
1 | package com.zhang.exception; |
四、班级管理模块(/clazzs)
1. 条件分页查询 GET /clazzs
接口要点:
- 参数:
name(模糊)、begin/end(匹配结课时间 end_date)、page、pageSize - 响应每行包含
masterName(班主任姓名)和status(班级状态)
实现思路:status 不是一个数据库字段,需要根据当前时间计算:
1 | 当前时间 > 结课时间 → 已结课 |
Mapper.xml(联查员工表拿班主任姓名 + 动态条件):
1 | <select id="list" resultType="com.zhang.pojo.Clazz"> |
Service(分页 + 在 Java 里算状态):
1 | @Override |
为什么不直接在 SQL 里用
CASE WHEN算状态?两种都可以,选 Java 计算是因为:状态计算逻辑简单清晰、可读性好,且分页结果已经拿到内存里,遍历赋值即可,无需改动 SQL。
2. 添加班级 POST /clazzs
Controller 用 @RequestBody Clazz 接收 JSON,Service 里补 createTime、updateTime 后插入:
1 | <insert id="insert" useGeneratedKeys="true" keyProperty="id"> |
3. 根据ID查询 GET /clazzs/{id}、修改 PUT /clazzs
- 查询:
select ... from clazz where id = #{id},用resultType直接映射(开启了map-underscore-to-camel-case,下划线字段自动转驼峰)。 - 修改:动态 SQL
<set>+<if>,只更新非空字段,update_time在 Service 里刷新。
4. 删除班级 DELETE /clazzs/{id} —— 删除校验
需求:班级下关联了学员则不允许删除,提示 “对不起, 该班级下有学生, 不能直接删除”。
实现思路(讲义提示:自定义异常 + 全局异常处理器):
1 | @Override |
StudentMapper 里新增统计方法:
1 | <select id="countByClazzId" resultType="java.lang.Integer"> |
5. 查询所有班级 GET /clazzs/list
给”新增学员”页面下拉框用,直接 select ... from clazz,无分页、无联查,返回 List<Clazz>。
五、学员管理模块(/students)
1. 条件分页查询 GET /students
- 参数:
name(模糊)、degree(学历)、clazzId(班级)、page、pageSize - 响应每行包含
clazzName(班级名称,联查班级表)
1 | <select id="list" resultType="com.zhang.pojo.Student"> |
2. 新增 / 根据ID查询 / 修改
- 新增:
violation_count、violation_score数据库有默认值 0,不需要插入。 - 修改:动态
<set>更新非空字段,clazzId、degree等数字字段用<if test="xxx != null">判断。 - 注意:
no、phone、id_card在表上有唯一约束,重复插入/修改会抛DuplicateKeyException,已被已有的全局异常处理器捕获并返回 “xxx已存在”。
3. 批量删除 DELETE /students/{ids} —— 路径参数接收数组
与员工删除(查询参数)的区别:学员删除的 id 是放在路径里的,格式 /students/1,2,3。
1 | @DeleteMapping("/{ids}") |
知识点:Spring 底层会把逗号分隔的路径参数
"1,2,3"通过转换服务自动拆分成Integer[],和查询参数ids=1,2,3接收成数组是同一个机制。
Mapper 里用 <foreach> 拼 id in (...):
1 | <delete id="deleteByIds"> |
4. 违纪处理 PUT /students/violation/{id}/{score}
需求:违纪处理一次,违纪次数 +1,违纪扣分 + 前端传入的分数。
直接在 SQL 里基于原值自增,一条 update 搞定:
1 | void violation(@Param("id") Integer id, @Param("score") Integer score); |
1 | <update id="violation"> |
注意:因为方法有多个参数(id、score),Mapper 接口上必须用
@Param指定参数名,否则 MyBatis 无法识别#{id}、#{score}。
六、学员信息统计(/report)
1. 班级人数统计 GET /report/studentCountData
需求:统计每个班级的人数,返回 { clazzList: [班级名...], dataList: [人数...] }(格式同员工职位统计)。
Mapper(联查班级表取班级名,按班级分组):
1 | <select id="countStudentClazz" resultType="java.util.Map"> |
Service(用 Stream 把 List<Map> 拆成两个 List 封装进 StudentCountData):
1 | @Override |
注意强转:
count(*)在 MyBatis 里返回的是Long,所以取出来要(Long)强转;名字取出来是 Object,用.toString()。
2. 学员学历统计 GET /report/studentDegreeData
需求:按学历分组统计人数,返回 [{name: '初中', value: 5}, ...](格式同员工性别统计)。
用 CASE WHEN 把 degree 数字映射成中文学历(这是之前员工职位统计用过的语法):
1 | <select id="countStudentDegree" resultType="java.util.Map"> |
七、功能完善:删除部门校验
需求:删除部门时,如果部门下有员工,则不允许删除,提示 “对不起,当前部门下有员工,不能直接删除!”
在已有的 DeptServiceImpl.deleteById 中加一层判断(需要注入 EmpMapper):
1 | @Override |
EmpMapper.xml 新增:
1 | <select id="countByDeptId" resultType="java.lang.Integer"> |
八、关键技术点总结
1. 自定义异常 + 全局异常处理器的完整链路
1 | Service 抛 BusinessException("对不起, 该班级下有学生, 不能直接删除") |
在已有的 GlobalExceptionHandler 中新增一个处理方法(Spring 会自动选择最匹配的异常处理器):
1 | //处理自定义业务异常 |
为什么比 try…catch 好?业务代码里不用到处写 try…catch,异常统一收敛到一处,代码干净优雅。
2. 分页:PageHelper 插件
1 | PageHelper.startPage(page, pageSize); // ① 开启分页(下一行查询自动拼接 limit) |
3. resultType vs resultMap
- 字段能直接对应(下划线自动转驼峰)→ 用
resultType(本任务大部分查询都够用)。 - 字段对应不上 / 需要手动嵌套封装(如员工带工作经历)→ 用
resultMap。 - 本任务班级列表、学员列表都是简单联查取别名,用 resultType + 列别名即可,不需要 resultMap。
4. 动态 SQL 的四个标签
| 标签 | 用途 | 本任务应用 |
|---|---|---|
<where> |
自动处理首个条件的 and | 班级/学员分页查询 |
<set> |
动态更新,自动去掉末尾逗号 | 修改班级/学员 |
<if> |
条件判断(字符串判空、数字判 null) | 所有动态 SQL |
<foreach> |
拼接 in 集合 | 批量删除学员 |
5. 统计函数的两种写法
| 函数 | 语法 | 用途 |
|---|---|---|
CASE WHEN |
case expr when val1 then res1 else res end |
学历数字 → 中文映射 |
IF() |
if(条件, 真值, 假值) |
员工性别 1/2 → 男/女 |
6. 两个细节坑
- 多参数 Mapper 方法必须加
@Param:如违纪处理violation(@Param("id") ..., @Param("score") ...),否则报Parameter 'id' not found。 count(*)返回类型是 Long:从 Map 中取值做算术或封装时要注意强转。
九、接口清单总览
| # | 模块 | 路径 | 方式 | 说明 |
|---|---|---|---|---|
| 1 | 班级管理 | /clazzs |
GET | 班级列表(条件分页,含 masterName/status) |
| 2 | 班级管理 | /clazzs |
POST | 添加班级 |
| 3 | 班级管理 | /clazzs/{id} |
GET | 根据ID查询班级 |
| 4 | 班级管理 | /clazzs |
PUT | 修改班级 |
| 5 | 班级管理 | /clazzs/{id} |
DELETE | 删除班级(班级下有学生禁止删) |
| 6 | 班级管理 | /clazzs/list |
GET | 查询所有班级(学员下拉框) |
| 7 | 员工管理 | /emps/list |
GET | 查询全部员工(班主任下拉框) |
| 8 | 学员管理 | /students |
GET | 学员列表(条件分页,含 clazzName) |
| 9 | 学员管理 | /students |
POST | 添加学员 |
| 10 | 学员管理 | /students/{id} |
GET | 根据ID查询学员 |
| 11 | 学员管理 | /students |
PUT | 修改学员 |
| 12 | 学员管理 | /students/{ids} |
DELETE | 批量删除学员(路径逗号数组) |
| 13 | 学员管理 | /students/violation/{id}/{score} |
PUT | 违纪处理 |
| 14 | 数据统计 | /report/studentCountData |
GET | 班级人数统计 |
| 15 | 数据统计 | /report/studentDegreeData |
GET | 学员学历统计 |
| 16 | 部门管理 | /depts |
DELETE | 删除部门(部门下有员工禁止删) |
day16 面向切面编程
实战总结:AOP 面向切面编程
本文是对《13-后端Web进阶(AOP)》的总结,记录 AOP 的核心概念、通知类型、切入点表达式以及在项目中的实战应用(操作日志记录)。
项目地址:project/tlias-web-management(SpringBoot + AOP)
对应讲义:讲义/13-后端Web进阶(AOP).md
一、AOP 核心概念
1. 什么是 AOP
AOP (Aspect Oriented Programming) 即面向切面编程,是一种通过分离横切关注点(如日志、安全、事务等)来提高代码模块化程度的编程范式。
核心优势:
- 代码无侵入:在不修改原有业务代码的前提下,对功能进行增强。
- 减少重复代码:将通用逻辑(如日志记录、权限校验)抽取出来,避免在每个业务方法中重复编写。
- 提高开发效率与维护性:通用逻辑集中管理,修改时只需改一处。
2. 核心术语
| 术语 | 英文 | 描述 | 举例 |
|---|---|---|---|
| 连接点 | JoinPoint | 可以被 AOP 控制的方法 | 业务层的 list(), delete() 方法 |
| 通知 | Advice | 共性功能(重复逻辑) | 记录方法执行耗时的代码 |
| 切入点 | Pointcut | 匹配连接点的条件,决定在哪些方法上应用通知 | execution(* com.zhang.service.*.*(..)) |
| 切面 | Aspect | 通知 + 切入点的组合 | 日志切面 = 记录耗时逻辑 + 匹配业务方法的规则 |
| 目标对象 | Target | 被增强的原始对象 | DeptServiceImpl 实例 |
关系总结:在切入点指定的连接点上,执行通知定义的逻辑,这个组合形成了切面,作用于目标对象。
二、通知类型详解
1. 五种通知类型
| 类型 | 注解 | 执行时机 | 说明 |
|---|---|---|---|
| 前置通知 | @Before |
目标方法执行前 | 常用于参数校验、权限检查 |
| 后置通知 | @After |
目标方法执行后(无论是否异常) | 常用于释放资源、清理操作 |
| 返回后通知 | @AfterReturning |
目标方法执行后(无异常时) | 常用于获取返回值、成功日志 |
| 异常通知 | @AfterThrowing |
目标方法执行后(有异常时) | 常用于异常处理、失败日志 |
| 环绕通知 | @Around |
目标方法执行前和后 | 功能最强大,可控制方法是否执行,获取返回值 |
2. 执行顺序表
正常执行(无异常)
1 | 1. @Around (前置部分) |
异常执行(有异常)
1 | 1. @Around (前置部分) |
注意:
@Around环绕通知中,若在pjp.proceed()前后的代码,当目标方法抛出异常时,proceed()后的代码不会执行(除非捕获异常)。
3. 代码示例
1 | @Aspect |
4. 通知优先级 (@Order)
当多个切面或多个通知匹配同一个方法时,执行顺序由 @Order 注解控制:
- 值越小,优先级越高
- 前置通知:优先级高的先执行
- 后置通知:优先级高的后执行(先进后出,类似栈结构)
1 | @Aspect |
三、切入点表达式
1. 两种匹配方式
| 方式 | 语法 | 适用场景 |
|---|---|---|
| execution | execution(modifiers-pattern? ret-type-pattern declaring-type-pattern? name-pattern(param-pattern) throws-pattern?) |
基于方法签名(类名、方法名、参数)匹配 |
| @annotation | @annotation(annotation-type) |
基于自定义注解匹配 |
2. execution 表达式详解
语法结构:
1 | execution(访问修饰符? 返回值 包名.类名.?方法名(参数) throws 异常?) |
通配符:
*:匹配任意单个字符..:匹配任意数量的字符或包?:表示该项可选
示例:
| 表达式 | 含义 |
|---|---|
execution(* com.zhang.service.*.*(..)) |
匹配 com.zhang.service 包下所有类的所有方法 |
execution(* com.zhang.service.impl.DeptServiceImpl.*(..)) |
匹配 DeptServiceImpl 类的所有方法 |
execution(* com.zhang.service.*.*(String, Integer)) |
匹配参数为 String 和 Integer 的方法 |
execution(void com.zhang.service.*.delete(*)) |
匹配返回值为 void,方法名为 delete,一个参数的方法 |
3. @annotation 注解匹配
适用场景:当需要增强的方法名无规律时,通过自定义注解来标记哪些方法需要被拦截。
实现步骤:
- 创建自定义注解
- 在需要增强的方法上添加该注解
- 切面类中使用
@annotation匹配
代码示例:
1 | // 1. 创建自定义注解 |
四、项目实战:操作日志记录
1. 需求分析
需求:记录 Tlias 系统中所有增、删、改操作的日志,便于后期数据追踪。
日志内容:
- 操作人 ID
- 操作时间
- 操作类名
- 操作方法名
- 方法参数
- 返回值
- 执行耗时
技术选型:
- 通知类型:
@Around环绕通知(需要获取方法执行前后的信息) - 切入点:
@annotation注解匹配(增删改方法名无统一前缀)
2. 项目实现代码
① 自定义注解 @LogOperation
1 | // 文件:com.zhang.anno.LogOperation |
② 日志实体类 OperateLog
1 | // 文件:com.zhang.pojo.OperateLog |
③ Mapper 接口 OperateLogMapper
1 | // 文件:com.zhang.mapper.OperateLogMapper |
④ 切面类 OperationLogAspect(核心)
1 | // 文件:com.zhang.aop.OperationLogAspect |
⑤ 获取当前用户 ID(CurrentHolder)
由于 AOP 切面无法直接获取 HttpServletRequest 中的登录信息,需要通过 ThreadLocal 在拦截器和切面之间传递数据。
1 | // 文件:com.zhang.utils.CurrentHolder |
在登录拦截器中设置用户 ID:
1 | // TokenInterceptor.preHandle() 方法中 |
⑥ 在 Controller 中添加注解
1 | // 文件:com.zhang.controller.DeptController |
五、项目文件清单
| 层 | 全限定类名 | 作用 |
|---|---|---|
| anno | com.zhang.anno.LogOperation |
自定义注解,标记需要记录日志的方法 |
| pojo | com.zhang.pojo.OperateLog |
日志实体类 |
| mapper | com.zhang.mapper.OperateLogMapper |
日志持久化接口 |
| aop | com.zhang.aop.OperationLogAspect |
AOP 切面类,核心逻辑 |
| utils | com.zhang.utils.CurrentHolder |
ThreadLocal 工具类,传递用户 ID |
六、易错点 & 复习提醒
1. 环绕通知的 proceed() 调用
- 必须调用
pjp.proceed()才能执行原始方法,否则原始方法不会执行。 - 必须返回
proceed()的返回值,否则原始方法的返回值会丢失。 - 当原始方法抛出异常时,
proceed()后的代码不会执行(除非用 try-catch 包裹)。
2. 返回值空指针风险
1 | // 错误写法 |
对于 void 返回值的方法,result 可能为 null,需要做空判断。
3. ThreadLocal 内存泄漏
CurrentHolder 使用 ThreadLocal 时,必须在使用完毕后调用 remove() 方法清理,否则会造成内存泄漏。
4. 切入点表达式书写规范
- 优先使用接口:
execution(* com.zhang.service.DeptService.*(..))比使用实现类更好,提高可扩展性。 - 缩小匹配范围:避免使用
..匹配所有包,应尽可能精确指定包路径。 - 注释清晰:在切面类中注释该切面的功能,方便后期维护。
5. AOP 与事务的关系
Spring 的 @Transactional 注解底层也是基于 AOP 实现的。在使用自定义 AOP 时,需要注意事务注解的顺序,确保数据一致性。
6. 切面类必须是 Spring Bean
切面类必须添加 @Component 或其他注解,使其成为 Spring 管理的 Bean,否则切面不会生效。
七、一图回顾 AOP 流程
1 | ┌──────────────── AOP 执行流程 ────────────────┐ |