JavaWeb(三)

11-后端Web实战(自己完成)

需求:班级管理

以下内容全为自己实现:

1、分页查询

xml 复制代码
<select id="list" resultType="com.itheima.pojo.Clazz">
        select
        c.*,
        e.name masterName,
        (case when begin_date &gt; now() then '未开班' when now() &gt; end_date then '已结课' else  '在读中' end ) status
        from clazz c left join emp e on c.master_id = e.id
        <where>
            <if test="name != null and name != ''">
                c.name like concat('%',#{name},'%')
            </if>
            <if test="begin != null an`在这里插入代码片`d end != null">
                and c.end_date between #{begin} and #{end}
            </if>
        </where>
        order by c.update_time desc
    </select>

xml不能写,用&gt;代替

2、查询所有员工

3、新增班级

json 复制代码
  "name": "JavaEE就业166期",
  "room": "101",
  "beginDate": "2023-06-01",
  "endDate": "2024-01-25",
  "masterId": 7,
  "subject": 1
}

🐛新增班级的班主任下拉菜单里没有数据,原因是empcontroller没有list这个路径,添加一下就好。

java 复制代码
/**
     * 查询所有的员工
     */
    @GetMapping("/list")
    public Result list(){
        log.info("查询所有的员工数据");
        List<Emp> empList = empService.list();
        return Result.success(empList);
    }

service层调用的是mapper层findAll这个方法,

java 复制代码
@Override
    public List<Emp> list() {
        return empMapper.findAll();
    }

解决了,

☀️页面刚开始加载就会条用list路径,把员工数据加载到前端,新增班级的班主任下拉菜单才会有员工数据。

4、根据id查询班级

这个是编辑班级功能里的数据回显,

5、修改班级信息

6、删除班级

班级有学生的话不能删除,

需求:学员管理

1、查询所有班级

其实就是新增学员时,班级下拉菜单的班级展示,已完成

2、条件分页查询

🐛数据库查询出来的列名,要和实体类属性名对应,否则封装不上。

xml 复制代码
<select id="list" resultType="com.itheima.pojo.Student">
        select s.* ,c.name from student s left join clazz c on s.clazz_id = c.id

不然就会,

要按照接口文档起别名,才能被实体类的属性封装,

正确sql如下:

sql 复制代码
        select s.* ,c.name clazzName from student s left join clazz c on s.clazz_id = c.id

解决了,

3、添加学员

json 复制代码
{
	"name": "阿大",
	"no": "2024010801",
	"gender": 1,
	"phone": "15909091235",
	"idCard": "159090912351590909",
	"isCollege": 1,
	"address": "昌平回龙观",
	"degree": 4,
	"graduationDate": "2024-01-01",
	"clazzId": 9
}

4、根据id查询

其实就是编辑功能的查询回显,

5、修改学员

6、删除学员

7、违纪处理

新的功能,

接口描述:

其实就是在sql语句中,更新违纪次数和违纪分数,

java 复制代码
/**
     * 违纪处理
     */
    @Update("update student set violation_count = violation_count + 1 , violation_score = violation_score + #{score} , update_time = now() where id = #{id}")
    void updateViolation(Integer id, Integer score);

🐛原来pom.xml中加上

xml 复制代码
 <plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <parameters>true</parameters>
    </configuration>
</plugin>

后,参数列表中就不用加@Param了。

需求:学员信息统计

1、班级人数统计

返回信息样式:

①新建实体类与返回格式一一对应;

②调用学生mapper层方法统计班级人数,返回格式用map加list集合

③mapper层返回之后再在service层取出班级列表和人数列表(与返回前端格式一一对应)

2、学生学历统计

返回格式样例:

跟统计班级人数不同,返回格式就是json,只是sql语句复杂了点,

通过 SQL 的 CASE WHEN 表达式对学历字段做枚举值转换,将结果映射为 Map 集合返回,每行数据封装为一个 {name, value} 键值对,最终化为 JSON 响应给前端。

sql 复制代码
<!--学员学历统计-->
    <select id="countStudentDegreeData" resultType="java.util.Map">
        select
            (case degree
                when 1 then '初中'
                when 2 then '高中'
                when 3 then '大专'
                when 4 then '本科'
                when 5 then '硕士'
                when 6 then '博士'
                else '其他' end)  name,
            count(*) value
        from student group by degree
        order by degree
    </select>

需求:功能完善

  • 删除部门时:如果部门下有员工,则不允许删除该部门,并给前端提示错误信息:对不起,当前部门下有员工,不能直接删除!
java 复制代码
@Override
    public void deleteById(Integer id) {
        // 查询员工表该部门有员工的话,不能删
        Integer count = deptMapper.countByDeptId(id);
        if (count > 0) {
            throw new RuntimeException("该部门有员工,不能删除");
        }

        deptMapper.deleteById(id);
    }

12-后端Web实战(登录认证)

在前面的课程中,我们已经实现了部门管理、员工管理的基本功能,但是大家会发现,我们并没有登录,就直接访问到了Tlias智能学习辅助系统的后台。 这是不安全的,所以我们今天的主题就是登录认证。最终要实现的效果是:

如果用户名密码错误,不允许登录系统。

如果用户名和密码都正确,则登录成功,可以访问系统。

1. 登录功能

1.1 需求

在登录界面中,我们可以输入用户的用户名以及密码,然后点击 "登录" 按钮就要请求服务器,服务端判断用户输入的用户名或者密码是否正确。如果正确,则返回成功结果,前端跳转至系统首页面。

1.2 接口描述

我们参照接口文档中的 其他接口 -> 登录接口

1.3 思路分析

  • 怎么样才算登录成功了呢?
    • 用户名和密码都输入正确,登录成功
    • 否则,登录失败
  • 登录功能的本质是什么?
    • 查询
    • 根据用户名和密码查询员工信息

1.4 功能开发

🤖

复制代码
AI提示词:
你是一名java开发工程师,现需要基于 SpringBoot+Mybatis 实现员工登录的基本功能,开发一个基本的登录接口,基本信息如下:
1. 接口请求路径 /login ,请求方式post 
2. 接口请求参数有:用户名 username, 密码 password,为json格式的数据 {"username":"admin", "password":"123456"}
3. 接口响应数据:json格式,具体的数据格式如下:
{
    "code": 1,
    "msg": "success",
    "data": {
        "id": 1,
        "username": "songjiang",
        "name": "宋江",
        "token": "..."
    }
}
4. 数据库表为 emp, 对应的实体类为Emp,已存在,对应的表结构为:
create table emp (
    id          int unsigned primary key auto_increment comment 'ID,主键',
    username    varchar(20)                  not null comment '用户名',
    password    varchar(32) default '123456' not null comment '密码',
    name        varchar(10)                  not null comment '姓名',
    gender      tinyint unsigned             not null comment '性别, 1:男, 2:女',
    phone       char(11)                     not null comment '手机号',
    job         tinyint unsigned             null comment '职位, 1:班主任,2:讲师,3:学工主管,4:教研主管,5:咨询师',
    salary      int unsigned                 null comment '薪资',
    image       varchar(300)                 null comment '头像',
    entry_date  date                         null comment '入职日期',
    dept_id     int unsigned                 null comment '关联的部门ID',
    create_time datetime                     null comment '创建时间',
    update_time datetime                     null comment '修改时间',
    constraint emp_pk unique (phone),
    constraint username unique (username)
) comment '员工表';

1). 准备实体类 LoginInfo, 封装登录成功后, 返回给前端的数据 。

java 复制代码
/**
 * 登录成功结果封装类
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginInfo {
    private Integer id; //员工ID
    private String username; //用户名
    private String name; //姓名
    private String token; //令牌
}

2). 定义LoginController

java 复制代码
@Slf4j
@RestController
public class LoginController {

    @Autowired
    private EmpService empService;

    @PostMapping("/login")
    public Result login(@RequestBody Emp emp){
        log.info("员工来登录啦 , {}", emp);
        LoginInfo loginInfo = empService.login(emp);
        if(loginInfo != null){
            return Result.success(loginInfo);
        }
        return Result.error("用户名或密码错误~");
    }

}

3). EmpService接口中增加 login 登录方法

java 复制代码
/**
 * 登录
 */
LoginInfo login(Emp emp);

4). EmpServiceImpl 实现login方法

java 复制代码
@Override
public LoginInfo login(Emp emp) {
    Emp empLogin = empMapper.getUsernameAndPassword(emp);
    if(empLogin != null){
        LoginInfo loginInfo = new LoginInfo(empLogin.getId(), empLogin.getUsername(), empLogin.getName(), null);
        return loginInfo;
    }
    return null;
}

5). EmpMapper增加接口方法

java 复制代码
/**
 * 根据用户名和密码查询员工信息
 */
@Select("select * from emp where username = #{username} and password = #{password}")
Emp getUsernameAndPassword(Emp emp);

我们已经完成了基础登录功能的开发与测试,在我们登录成功后就可以进入到后台管理系统中进行数据的操作。

但是当我们在浏览器中新的页面上输入地址:http://localhost:90,发现没有登录仍然可以进入到后端管理系统页面。

而真正的登录功能应该是:登陆后才能访问后端系统页面,不登陆则跳转登陆页面进行登陆。

为什么会出现这个问题?其实原因很简单,就是因为针对于我们当前所开发的部门管理、员工管理以及文件上传等相关接口来说,我们在服务器端并没有做任何的判断,没有去判断用户是否登录了。所以无论用户是否登录,都可以访问部门管理以及员工管理的相关数据。所以我们目前所开发的登录功能,它只是徒有其表。而我们要想解决这个问题,我们就需要完成一步非常重要的操作:登录校验。

那接下来,我们就进入到今天课程内容的第二章节,登录校验功能的实现。

2. 登录校验

什么是登录校验?

所谓登录校验,指的是我们在服务器端接收到浏览器发送过来的请求之后,首先我们要对请求进行校验。先要校验一下用户登录了没有 ,如果用户已经登录了,就直接执行对应的业务操作就可以了;如果用户没有登录,此时就不允许他执行相关的业务操作,直接给前端响应一个错误的结果,最终跳转到登录页面,要求他登录成功之后,再来访问对应的数据。

2.1 思路

了解完什么是登录校验之后,接下来我们分析一下登录校验大概的实现思路。

首先我们在宏观上先有一个认知:

前面在讲解HTTP协议的时候,我们提到HTTP协议是无状态协议。什么又是无状态的协议?

所谓无状态,指的是每一次请求都是独立的,下一次请求并不会携带上一次请求的数据。而浏览器与服务器之间进行交互,基于HTTP协议也就意味着现在我们通过浏览器来访问了登陆这个接口,实现了登陆的操作,接下来我们在执行其他业务操作时,服务器也并不知道这个员工到底登陆了没有。因为HTTP协议是无状态的,两次请求之间是独立的,所以是无法判断这个员工到底登陆了没有。

那应该怎么来实现登录校验的操作呢?具体的实现思路可以分为两部分:

  1. 在员工登录成功后,需要将用户登录成功的信息存起来,记录用户已经登录成功的标记
  2. 在浏览器发起请求时,需要在服务端进行统一拦截,拦截后进行登录校验。

✅想要判断员工是否已经登录,我们需要在员工登录成功之后,存储一个登录成功的标记,接下来在每一个接口方法执行之前,先做一个条件判断,判断一下这个员工到底登录了没有。如果是登录了,就可以执行正常的业务操作,如果没有登录,会直接给前端返回一个错误的信息,前端拿到这个错误信息之后会自动的跳转到登录页面。

我们程序中所开发的查询功能、删除功能、添加功能、修改功能,都需要使用以上套路进行登录校验。此时就会出现:相同代码逻辑,每个功能都需要编写,就会造成代码非常繁琐。

为了简化这块操作,我们可以使用一种技术:统一拦截技术

通过统一拦截的技术,我们可以来拦截浏览器发送过来的所有的请求,拦截到这个请求之后,就可以通过请求来获取之前所存入的登录标记,在获取到登录标记且标记为登录成功,就说明员工已经登录了。如果已经登录,我们就直接放行(意思就是可以访问正常的业务接口了)。

我们要完成以上操作,会涉及到web开发中的两个技术:

  1. 会话技术:用户登录成功之后,在后续的每一次请求中,都可以获取到该标记。
  2. 统一拦截技术:过滤器Filter、拦截器Interceptor

下面我们先学习会话技术,然后再学习统一拦截技术。

2.2 会话技术

介绍了登录校验的大概思路之后,我们先来学习下会话技术。

2.2.1 介绍

什么是会话?

  • 在我们日常生活当中,会话指的就是谈话、交谈。
  • 在web开发当中,会话指的就是浏览器与服务器之间的一次连接,我们就称为一次会话。

✅在用户打开浏览器第一次访问服务器的时候,这个会话就建立了,直到有任何一方断开连接,此时会话就结束了。在一次会话当中,是可以包含多次请求和响应的。

比如:打开了浏览器来访问web服务器上的资源(浏览器不能关闭、服务器不能断开)

  • 第1次:访问的是登录的接口,完成登录操作
  • 第2次:访问的是部门管理接口,查询所有部门数据
  • 第3次:访问的是员工管理接口,查询员工数据

只要浏览器和服务器都没有关闭,以上3次请求都属于一次会话当中完成的。

需要注意的是:会话是和浏览器关联的,当有三个浏览器客户端和服务器建立了连接时,就会有三个会话。同一个浏览器在未关闭之前请求了多次服务器,这多次请求是属于同一个会话。比如:1、2、3这三个请求都是属于同一个会话。当我们关闭浏览器之后,这次会话就结束了。而如果我们是直接把web服务器关了,那么所有的会话就都结束了。

📚问ai梳理概念:

举两个例子

同一个 HTTP 连接,多个会话

  • 浏览器长连接不断,先登录 A 用户(JSESSIONID‑A),清除 Cookie 再登录 B 用户(JSESSIONID‑B)。
  • 底层还是同一条 HTTP/TCP 连接,但 Session 已经换成另一个。

多次 HTTP 连接,同一个会话

  • 关闭浏览器标签页 → HTTP/TCP 连接全部断开;
  • 只要浏览器没有删除 JSESSIONID Cookie,重新发起新的 HTTP 请求(新连接),带上旧的JSESSIONID,服务器拿到原来的 HttpSession。
  • 会话销毁时机:调用session.invalidate()、或者超时过期,不是断开 HTTP 连接就销毁。

知道了会话的概念了,接下来我们再来了解下会话跟踪。

会话跟踪 :一种维护浏览器状态的方法,服务器需要识别多次请求是否来自于同一浏览器,以便在同一次会话的多次请求间共享数据

服务器会接收很多的请求,但是服务器是需要识别出这些请求是不是同一个浏览器发出来的。比如:1和2这两个请求是不是同一个浏览器发出来的,3和5这两个请求不是同一个浏览器发出来的。如果是同一个浏览器发出来的,就说明是同一个会话。如果是不同的浏览器发出来的,就说明是不同的会话。而识别多次请求是否来自于同一浏览器的过程,我们就称为会话跟踪。

我们使用会话跟踪技术就是要完成在同一个会话中多个请求之间进行共享数据

为什么要共享数据呢? 由于HTTP是无状态协议,在后面请求中怎么拿到前一次请求生成的数据呢?此时就需要在一次会话的多次请求之间进行数据共享

⭐️会话跟踪技术有三种:

  1. Cookie(客户端会话跟踪技术):数据存储在客户端浏览器当中
  2. Session(服务端会话跟踪技术):数据存储在储在服务端
  3. 令牌技术
2.2.2 会话跟踪方案

上面我们介绍了什么是会话,什么是会话跟踪,并且也提到了会话跟踪 3 种常见的技术方案。接下来,我们就来对比一下这 3 种会话跟踪的技术方案,来看一下具体的实现思路,以及它们之间的优缺点。

2.2.2.1 方案一:Cookie

cookie 是客户端会话跟踪技术,它是存储在客户端浏览器的,我们使用 cookie 来跟踪会话,我们就可以在浏览器第一次发起请求来请求服务器的时候,我们在服务器端来设置一个cookie。

比如第一次请求了登录接口,登录接口执行完成之后,我们就可以设置一个cookie,在 cookie 当中我们就可以来存储用户相关的一些数据信息。比如我可以在 cookie 当中来存储当前登录用户的用户名,用户的ID。

⭐️

1️⃣服务器端 在给客户端 在响应数据的时候,会自动的将 cookie 响应给浏览器,2️⃣浏览器 接收到响应回来的 cookie 之后,会自动的将 cookie 的值存储在浏览器本地。3️⃣接下来在后续的每一次请求 当中,都会将浏览器 本地所存储的 cookie 自动地携带到服务端。

接下来在服务端我们就可以获取到 cookie 的值。我们可以去判断一下这个 cookie 的值是否存在,如果不存在这个cookie,就说明客户端之前是没有访问登录接口的;如果存在 cookie 的值,就说明客户端之前已经登录完成了。这样我们就可以基于 cookie 在同一次会话的不同请求之间来共享数据。

我刚才在介绍流程的时候,用了 3 个自动:

  • 服务器会 自动 的将 cookie 响应给浏览器。
  • 浏览器接收到响应回来的数据之后,会 自动 的将 cookie 存储在浏览器本地。
  • 在后续的请求当中,浏览器会 自动 的将 cookie 携带到服务器端。

为什么这一切都是自动化进行的?

是因为 cookie 它是 HTTP 协议当中所支持的技术,而各大浏览器厂商都支持了这一标准。在 HTTP 协议官方给我们提供了一个响应头和请求头:

  • 响应头 Set-Cookie :设置Cookie数据的
  • 请求头 Cookie:携带Cookie数据的

代码测试:

java 复制代码
@Slf4j
@RestController
public class SessionController {

    //设置Cookie
    @GetMapping("/c1")
    public Result cookie1(HttpServletResponse response){
        response.addCookie(new Cookie("login_username","itheima")); //设置Cookie/响应Cookie
        return Result.success();
    }
        
    //获取Cookie
    @GetMapping("/c2")
    public Result cookie2(HttpServletRequest request){
        Cookie[] cookies = request.getCookies();
        for (Cookie cookie : cookies) {
            if(cookie.getName().equals("login_username")){
                System.out.println("login_username: "+cookie.getValue()); //输出name为login_username的cookie
            }
        }
        return Result.success();
    }
}    

A. 访问c1接口,设置Cookie,http://localhost:8080/c1

我们可以看到,设置的cookie,通过响应头Set-Cookie 响应给浏览器,并且浏览器会将Cookie,存储在浏览器端。

B. 访问c2接口 http://localhost:8080/c2,此时浏览器会自动的将Cookie携带到服务端,是通过**请求头Cookie** ,携带的。

优缺点:

  • 优点:HTTP协议中支持的技术(像Set-Cookie 响应头的解析以及 Cookie 请求头数据的携带,都是浏览器自动进行的,是无需我们手动操作的)
  • 缺点:
    • 移动端APP(Android、IOS)中无法使用Cookie
    • 不安全,用户可以自己禁用Cookie
    • Cookie不能跨域

跨域介绍:

  • 现在的项目,大部分都是前后端分离的,前后端最终也会分开部署,前端部署在服务器 192.168.150.200 上,端口 80,后端部署在 192.168.150.100上,端口 8080
  • 我们打开浏览器直接访问前端工程,访问url:http://192.168.150.200/login.html
  • 然后在该页面发起请求到服务端,而服务端所在地址不再是localhost,而是服务器的IP地址192.168.150.100,假设访问接口地址为:http://192.168.150.100:8080/login
  • 那此时就存在跨域操作了,因为我们是在 http://192.168.150.200/login.html 这个页面上访问了http://192.168.150.100:8080/login 接口
  • 此时如果服务器设置了一个Cookie,这个Cookie是不能使用的,因为Cookie无法跨域

⭐️区分跨域的维度(三个维度有任何一个维度不同,那就是跨域操作):

  • 协议
  • IP/协议
  • 端口

举例:

2.2.2.2 方案二:Session

前面介绍的时候,我们提到Session,它是服务器端会话跟踪技术,所以它是存储在服务器端的。而 Session 的底层其实就是基于我们刚才所介绍的 Cookie 来实现的。

  • 获取Session

    如果我们现在要基于 Session 来进行会话跟踪,浏览器在第一次请求服务器的时候,我们就可以直接在服务器当中来获取到会话对象Session。如果是第一次请求Session ,会话对象是不存在的,这个时候服务器会自动的创建一个会话对象Session 。而每一个会话对象Session ,它都有一个ID(示意图中Session后面括号中的1,就表示ID),我们称之为 Session 的ID。

  • 响应Cookie (JSESSIONID)

    接下来,服务器端在给浏览器响应数据的时候,它会将 Session 的 ID 通过 Cookie 响应给浏览器。其实在响应头当中增加了一个 Set-Cookie 响应头。这个 Set-Cookie 响应头对应的值是不是cookie? cookie 的名字是固定的 JSESSIONID 代表的服务器端会话对象 Session 的 ID。浏览器会自动识别这个响应头,然后自动将Cookie存储在浏览器本地。

  • 查找Session

    接下来,在后续的每一次请求当中,都会将 Cookie 的数据获取出来,并且携带到服务端。接下来服务器拿到JSESSIONID这个 Cookie 的值,也就是 Session 的ID。拿到 ID 之后,就会从众多的 Session 当中来找到当前请求对应的会话对象Session。

    这样我们是不是就可以通过 Session 会话对象在同一次会话的多次请求之间来共享数据了?好,这就是基于 Session 进行会话跟踪的流程。

代码测试

java 复制代码
@Slf4j
@RestController
public class SessionController {

    @GetMapping("/s1")
    public Result session1(HttpSession session){
        log.info("HttpSession-s1: {}", session.hashCode());

        session.setAttribute("loginUser", "tom"); //往session中存储数据
        return Result.success();
    }

    @GetMapping("/s2")
    public Result session2(HttpServletRequest request){
        HttpSession session = request.getSession();
        log.info("HttpSession-s2: {}", session.hashCode());

        Object loginUser = session.getAttribute("loginUser"); //从session中获取数据
        log.info("loginUser: {}", loginUser);
        return Result.success(loginUser);
    }
}

A. 访问 s1 接口,http://localhost:8080/s1

请求完成之后,在响应头中,就会看到有一个Set-Cookie的响应头,里面响应回来了一个Cookie,就是JSESSIONID,这个就是服务端会话对象 Session 的ID。

B. 访问 s2 接口,http://localhost:8080/s2

接下来,在后续的每次请求时,都会将Cookie的值,携带到服务端,那服务端呢,接收到Cookie之后,会自动的根据JSESSIONID的值,找到对应的会话对象Session。

那经过这两步测试,大家也会看到,在控制台中输出如下日志:

两次请求,获取到的Session会话对象的hashcode是一样的,就说明是同一个会话对象。而且,第一次请求时,往Session会话对象中存储的值,第二次请求时,也获取到了。 那这样,我们就可以通过Session会话对象,在同一个会话的多次请求之间来进行数据共享了。

📚Session:

Session 数据存在服务器;浏览器只存一个 SessionId(cookie)

优缺点

  • 优点:Session是存储在服务端的,安全
  • 缺点:
    • 服务器集群环境下无法直接使用Session
    • 移动端APP(Android、IOS)中无法使用Cookie
    • 用户可以自己禁用Cookie
    • Cookie不能跨域

PS:Session 底层是基于Cookie实现的会话跟踪,如果Cookie不可用,则该方案,也就失效了。

服务器集群环境为何无法使用Session?

  • 首先第一点,我们现在所开发的项目,一般都不会只部署在一台服务器上,因为一台服务器会存在一个很大的问题,就是单点故障。所谓单点故障,指的就是一旦这台服务器挂了,整个应用都没法访问了。

  • 所以在现在的企业项目开发当中,最终部署的时候都是以集群的形式来进行部署,也就是同一个项目它会部署多份。比如这个项目我们现在就部署了 3 份。

  • 而用户在访问的时候,到底访问这三台其中的哪一台?其实用户在访问的时候,他会访问一台前置的服务器,我们叫负载均衡服务器,我们在后面项目当中会详细讲解。目前大家先有一个印象负载均衡服务器,它的作用就是将前端发起的请求均匀的分发给后面的这三台服务器。

  • 此时假如我们通过 session 来进行会话跟踪,可能就会存在这样一个问题。用户打开浏览器要进行登录操作,此时会发起登录请求。登录请求到达负载均衡服务器,将这个请求转给了第一台 Tomcat 服务器。

Tomcat 服务器接收到请求之后,要获取到会话对象session。获取到会话对象 session 之后,要给浏览器响应数据,最终在给浏览器响应数据的时候,就会携带这么一个 cookie 的名字,就是 JSESSIONID ,下一次再请求的时候,是不是又会将 Cookie 携带到服务端?

好。此时假如又执行了一次查询操作,要查询部门的数据。这次请求到达负载均衡服务器之后,负载均衡服务器将这次请求转给了第二台 Tomcat 服务器,此时他就要到第二台 Tomcat 服务器当中。根据JSESSIONID 也就是对应的 session 的 ID 值,要找对应的 session 会话对象。

我想请问在第二台服务器当中有没有这个ID的会话对象 Session, 是没有的。 此时是不是就出现问题了?我同一个浏览器发起了 2 次请求,结果获取到的不是同一个会话对象,这就是Session这种会话跟踪方案它的缺点,在服务器集群环境下无法直接使用Session。

📚总结:

现在企业都是集群部署项目,所以用session进行会话跟踪用得少。

大家会看到上面这两种传统的会话技术,在现在的企业开发当中是不是会存在很多的问题。 为了解决这些问题,在现在的企业开发当中,基本上都会采用第三种方案,通过令牌技术来进行会话跟踪。接下来我们就来介绍一下令牌技术,来看一下令牌技术又是如何跟踪会话的。

2.2.2.3 方案三 - 令牌技术

这里我们所提到的令牌,其实它就是一个用户身份的标识,看似很高大上,很神秘,其实本质就是一个字符串。

如果通过令牌技术来跟踪会话,我们就可以在浏览器发起请求。在请求登录接口的时候,如果登录成功,我就可以生成一个令牌,令牌就是用户的合法身份凭证 。接下来我在响应数据的时候,我就可以直接将令牌响应给前端

接下来我们在前端程序当中接收到令牌之后,就需要将这个令牌存储起来。这个存储可以存储在 cookie 当中,也可以存储在其他的存储空间(比如:localStorage)当中。

接下来,在后续的每一次请求当中,都需要将令牌携带到服务端 。携带到服务端之后,接下来我们就需要来校验令牌的有效性。如果令牌是有效的,就说明用户已经执行了登录操作,如果令牌是无效的,就说明用户之前并未执行登录操作。

此时,如果是在同一次会话的多次请求之间,我们想共享数据,我们就可以将共享的数据存储在令牌当中就可以了。

Token 整体是字符串;但内部可以藏 JSON

优缺点

  • 优点:
    • 支持PC端、移动端
    • 解决集群环境下的认证问题
    • 减轻服务器的存储压力(无需在服务器端存储)
  • 缺点:需要自己实现(包括令牌的生成、令牌的传递、令牌的校验)

最容易混淆的点:JWT 不需要在数据库 / 内存存一份完整 token,但服务器依然可以校验真假,靠的是【签名】,不是靠比对保存的令牌副本。
✅针对于这三种方案,现在企业开发当中使用的最多的就是第三种令牌技术进行会话跟踪。而前面的这两种传统的方案,现在企业项目开发当中已经很少使用了。所以在我们的课程当中,我们也将会采用令牌技术来解决案例项目当中的会话跟踪问题。

JWT令牌最典型的应用场景就是登录认证:

  1. 在浏览器发起请求来执行登录操作,此时会访问登录的接口,如果登录成功之后,我们需要生成一个jwt令牌,将生成的 jwt令牌返回给前端。
  2. 前端拿到jwt令牌之后,会将jwt令牌存储起来。在后续的每一次请求中都会将jwt令牌携带到服务端。
  3. 服务端统一拦截请求之后,先来判断一下这次请求有没有把令牌带过来,如果没有带过来,直接拒绝访问,如果带过来了,还要校验一下令牌是否是有效。如果有效,就直接放行进行请求的处理。

在JWT登录认证的场景中我们发现,整个流程当中涉及到两步操作:

  1. 在登录成功之后,要生成令牌
  2. 每一次请求当中,要接收令牌对令牌进行校验

稍后我们再来学习如何来生成jwt令牌,以及如何来校验jwt令牌。

2.3 JWT令牌

前面我们介绍了基于令牌技术来实现会话追踪。这里所提到的令牌就是用户身份的标识,其本质就是一个字符串。令牌的形式有很多,我们使用的是功能强大的 JWT令牌。

2.3.1 介绍

JWT的组成: (JWT令牌由三个部分组成,三个部分之间使用英文的点来分割)

  • 第一部分:Header(头), 记录令牌类型、签名算法等。 例如:{"alg":"HS256","type":"JWT"}
  • 第二部分:Payload(有效载荷),携带一些自定义信息、默认信息等。 例如:{"id":"1","username":"Tom"}
  • 第三部分:Signature(签名),防止Token被篡改、确保安全性。将header、payload,并加入指定秘钥,通过指定签名算法计算而来。

签名的目的就是为了防jwt令牌被篡改,而正是因为jwt令牌最后一个部分数字签名的存在,所以整个jwt 令牌是非常安全可靠的。一旦jwt令牌当中任何一个部分、任何一个字符被篡改了,整个令牌在校验的时候都会失败,所以它是非常安全可靠的。
📚

  • Header 头部:记录加密算法、令牌类型,告诉后端用什么算法校验签名。
  • Payload 载荷:存储用户 id、过期时间等业务信息;只是编码,可被解码查看,不能放敏感数据
  • Signature 签名:后端用秘钥 对头部 + 载荷计算得到;校验 token 有没有被篡改、是不是本服务器签发。
    密钥:是后端自己定义的一串字符串,不是 JWT 生成出来的。 来源:人为编写

JWT是如何将原始的JSON格式数据,转变为字符串的呢?

  • 其实在生成JWT令牌时,会对JSON格式的数据进行一次编码:进行base64编码
  • Base64:是一种基于64个可打印的字符来表示二进制数据的编码方式。既然能编码,那也就意味着也能解码。所使用的64个字符分别是A到Z、a到z、 0- 9,一个加号,一个斜杠,加起来就是64个字符。任何数据经过base64编码之后,最终就会通过这64个字符来表示。当然还有一个符号,那就是等号。等号它是一个补位的符号
  • 需要注意的是Base64是编码方式,而不是加密方式。
2.3.2 生成和校验

简单介绍了JWT令牌以及JWT令牌的组成之后,接下来我们就来学习基于Java代码如何生成和校验JWT令牌。

1). 首先我们先来实现JWT令牌的生成。要想使用JWT令牌,需要先引入JWT的依赖:

xml 复制代码
<!-- JWT依赖-->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

在引入完JWT来赖后,就可以调用工具包中提供的API来完成JWT令牌的生成和校验。工具类:Jwts

2). 生成JWT代码实现:

java 复制代码
@Test
public void testGenJwt() {
    Map<String, Object> claims = new HashMap<>();
    claims.put("id", 10);
    claims.put("username", "itheima");

    String jwt = Jwts.builder() //开启 JWT 令牌构建器
    	.signWith(SignatureAlgorithm.HS256, "aXRjYXN0")//使用 HS256 算法,密钥字符串aXRjYXN0,用来生成最后的签名字段 Signature
        .addClaims(claims) //把数据放进 Payload 载荷部分
        .setExpiration(new Date(System.currentTimeMillis() + 12 * 3600 * 1000))//设置过期时间
        .compact();//把 Header、Payload、Signature 拼接,生成最终一整串 JWT 字符串

    System.out.println(jwt);
}

📚密钥可以用base64编码,直接搜:base64编码器,参考https://www.toolhelper.cn/EncodeDecode/Base64

运行测试方法:

java 复制代码
eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZXhwIjoxNjcyNzI5NzMwfQ.fHi0Ub8npbyt71UqLXDdLyipptLgxBUg_mSuGJtXtBk

输出的结果就是生成的JWT令牌,,通过英文的点分割对三个部分进行分割,我们可以将生成的令牌复制一下,然后打开JWT的官网,将生成的令牌直接放在Encoded位置,此时就会自动的将令牌解析出来。

第一部分解析出来,看到JSON格式的原始数据,所使用的签名算法为HS256。

第二个部分是我们自定义的数据,之前我们自定义的数据就是id,还有一个exp代表的是我们所设置的过期时间。

由于前两个部分是base64编码,所以是可以直接解码出来。但最后一个部分并不是base64编码,是经过签名算法计算出来的,所以最后一个部分是不会解析的。

3). 实现了JWT令牌的生成,下面我们接着使用Java代码来校验JWT令牌(解析生成的令牌):

java 复制代码
@Test
public void testParseJwt() {
    Claims claims = Jwts.parser()//获取 JWT 解析器,准备校验、解析 toke
    	.setSigningKey("aXRjYXN0")//传入服务端密钥,用来重新计算签名,做签名校验
        .parseClaimsJws("eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MTAsInVzZXJuYW1lIjoiaXRoZWltYSIsImV4cCI6MTcwMTkwOTAxNX0.N-MD6DmoeIIY5lB5z73UFLN9u7veppx1K5_N_jS9Yko")
        .getBody();//取出 Payload 载荷,封装成Claims对象,里面就存着 id、username、过期时间等数据
    System.out.println(claims);
}

📚

parseClaimsJws("完整jwt字符串")

  • 把传过来的 token 按.拆成 header、payload、signature
  • 使用密钥重新计算签名,和 token 携带的签名对比,校验是否被篡改
  • 同时校验是否已经过期
  • 校验失败直接抛出异常

运行测试方法:

java 复制代码
{id=10, username=itheima, exp=1701909015}

令牌解析后,我们可以看到id和过期时间,如果在解析的过程当中没有报错,就说明解析成功了。

下面我们做一个测试:把令牌header中的数字9变为8,运行测试方法后发现报错:

结论:篡改令牌中的任何一个字符,在对令牌进行解析时都会报错,所以JWT令牌是非常安全可靠的。

我们继续测试:修改生成令牌的时指定的过期时间,修改为1分钟。

java 复制代码
@Test
public void genJwt(){
    Map<String, Object> claims = new HashMap<>();
    claims.put("id", 10);
    claims.put("username", "itheima");

    String jwt = Jwts.builder().signWith(SignatureAlgorithm.HS256, "aXRjYXN0")
        .addClaims(claims)
        .setExpiration(new Date(System.currentTimeMillis() + 60 * 1000)) //有效期60s
        .compact();
    System.out.println(jwt);
    //输出结果:eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZXhwIjoxNjczMDA5NzU0fQ.RcVIR65AkGiax-ID6FjW60eLFH3tPTKdoK7UtE4A1ro
}

@Test
public void parseJwt(){
    Claims claims = Jwts.parser()
        .setSigningKey("aXRjYXN0")//指定签名密钥
        .parseClaimsJws("eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZXhwIjoxNjczMDA5NzU0fQ.RcVIR65AkGiax-ID6FjW60eLFH3tPTKdoK7UtE4A1ro")
        .getBody();

    System.out.println(claims);
}

等待1分钟之后运行测试方法发现也报错了,说明:JWT令牌过期后,令牌就失效了,解析的为非法令牌。

通过以上测试,我们在使用JWT令牌时需要注意:

  • JWT校验时使用的签名秘钥,必须和生成JWT令牌时使用的秘钥是配套的。
  • 如果JWT令牌解析校验时报错,则说明 JWT令牌被篡改 或 失效了,令牌非法。

☀️

2.3.3 登录时下发令牌

JWT令牌的生成和校验的基本操作我们已经学习完了,接下来我们就需要在案例当中通过JWT令牌技术来跟踪会话。具体的思路我们前面已经分析过了,主要就是两步操作:

  1. 生成令牌
  • 在登录成功之后来生成一个JWT令牌,并且把这个令牌直接返回给前端
  1. 校验令牌
  • 拦截前端请求,从请求中获取到令牌,对令牌进行解析校验

那我们首先来完成:登录成功之后生成JWT令牌,并且把令牌返回给前端。

实现步骤:

  1. 引入JWT工具类:在项目工程下创建 com.itheima.util 包,并把提供JWT工具类复制到该包下(可以借助AI来实现)
java 复制代码
package com.itheima.utils;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

import java.util.Date;
import java.util.Map;

public class JwtUtils {

    private static String signKey = "SVRIRUlNQQ==";
    private static Long expire = 43200000L;

    /**
     * 生成JWT令牌
     * @return
     */
    public static String generateJwt(Map<String,Object> claims){
        String jwt = Jwts.builder()
                .addClaims(claims)
                .signWith(SignatureAlgorithm.HS256, signKey)
                .setExpiration(new Date(System.currentTimeMillis() + expire))
                .compact();
        return jwt;
    }

    /**
     * 解析JWT令牌
     * @param jwt JWT令牌
     * @return JWT第二部分负载 payload 中存储的内容
     */
    public static Claims parseJWT(String jwt){
        Claims claims = Jwts.parser()
                .setSigningKey(signKey)
                .parseClaimsJws(jwt)
                .getBody();
        return claims;
    }
}
  1. 完善 EmpServiceImpl中的 login 方法逻辑, 登录成功,生成JWT令牌并返回
java 复制代码
@Override
public LoginInfo login(Emp emp) {
    Emp empLogin = empMapper.getUsernameAndPassword(emp);
    if(empLogin != null){
        //1. 生成JWT令牌
        Map<String,Object> dataMap = new HashMap<>();
        dataMap.put("id", empLogin.getId());
        dataMap.put("username", empLogin.getUsername());
        
        String jwt = JwtUtils.generateJwt(dataMap);
        LoginInfo loginInfo = new LoginInfo(empLogin.getId(), empLogin.getUsername(), empLogin.getName(), jwt);
        return loginInfo;
    }
    return null;
}

服务器响应的JWT令牌存储在本地浏览器哪里了呢?

  • 在当前案例中,JWT令牌存储在浏览器的本地存储空间 localstorage中 了。 localstorage 是浏览器的本地存储,在移动端也是支持的。

    我们在发起一个查询部门数据的请求,此时我们可以看到在请求头中包含一个token(JWT令牌),后续的每一次请求当中,都会将这个令牌携带到服务端。

2.4 过滤器Filter

刚才通过浏览器的开发者工具,我们可以看到在后续的请求当中,都会在请求头中携带JWT令牌到服务端,而服务端需要统一拦截所有的请求,从而判断是否携带的有合法的JWT令牌。

那怎么样来统一拦截到所有的请求校验令牌的有效性呢?这里我们会学习两种解决方案:

  1. Filter过滤器
  2. Interceptor拦截器

我们首先来学习过滤器Filter。

2.4.1 Filter快速入门

什么是Filter?

  • Filter表示过滤器,是 JavaWeb三大组件(Servlet、Filter、Listener)之一。
  • 过滤器可以把对资源的请求拦截下来,从而实现一些特殊的功能
    • 使用了过滤器之后,要想访问web服务器上的资源,必须先经过滤器,过滤器处理完毕之后,才可以访问对应的资源。
  • 过滤器一般完成一些通用的操作,比如:登录校验、统一编码处理、敏感字符处理等。

下面我们通过Filter快速入门程序掌握过滤器的基本使用操作:

  • 第1步,定义过滤器 :定义一个类,实现 Filter 接口,并重写其所有方法。
  • 第2步,配置过滤器:Filter类上加 @WebFilter 注解,配置拦截资源的路径。引导类上加 @ServletComponentScan 开启Servlet组件支持。

要实现三个方法:

1). 定义过滤器

java 复制代码
public class DemoFilter implements Filter {
    //初始化方法, web服务器启动, 创建Filter实例时调用, 只调用一次
    public void init(FilterConfig filterConfig) throws ServletException {
        System.out.println("init ...");
    }

    //拦截到请求时,调用该方法,可以调用多次
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {
        System.out.println("拦截到了请求...");
    }

    //销毁方法, web服务器关闭时调用, 只调用一次
    public void destroy() {
        System.out.println("destroy ... ");
    }
}
  • init方法:过滤器的初始化方法。在web服务器启动的时候会自动的创建Filter过滤器对象,在创建过滤器对象的时候会自动调用init初始化方法,这个方法只会被调用一次。
  • doFilter方法 :这个方法是在每一次拦截到请求之后都会被调用,所以这个方法是会被调用多次的,每拦截到一次请求就会调用一次doFilter()方法
  • destroy方法: 是销毁的方法。当我们关闭服务器的时候,它会自动的调用销毁方法destroy,而这个销毁方法也只会被调用一次。

2). 配置过滤器

在定义完Filter之后,Filter其实并不会生效,还需要完成Filter的配置,Filter的配置非常简单,只需要在Filter类上添加一个注解:@WebFilter,并指定属性urlPatterns,通过这个属性指定过滤器要拦截哪些请求

java 复制代码
@WebFilter(urlPatterns = "/*") //配置过滤器要拦截的请求路径( /* 表示拦截浏览器的所有请求 )
public class DemoFilter implements Filter {
    //初始化方法, web服务器启动, 创建Filter实例时调用, 只调用一次
    public void init(FilterConfig filterConfig) throws ServletException {
        System.out.println("init ...");
    }

    //拦截到请求时,调用该方法,可以调用多次
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {
        System.out.println("拦截到了请求...");
    }

    //销毁方法, web服务器关闭时调用, 只调用一次
    public void destroy() {
        System.out.println("destroy ... ");
    }
}

当我们在Filter类上面加了@WebFilter注解之后,接下来我们还需要在启动类上面加上一个注解@ServletComponentScan,通过这个@ServletComponentScan注解来开启SpringBoot项目对于Servlet组件的支持。

java 复制代码
@ServletComponentScan //开启对Servlet组件的支持
@SpringBootApplication
public class TliasManagementApplication {
    public static void main(String[] args) {
        SpringApplication.run(TliasManagementApplication.class, args);
    }
}

重新启动服务,打开浏览器,执行部门管理的请求,可以看到控制台输出了过滤器中的内容:

注意事项:在过滤器Filter中,如果不执行放行操作,将无法访问后面的资源。 放行操作:chain.doFilter(request, response);

java 复制代码
  //拦截到请求时,调用该方法,可以调用多次
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {
        System.out.println("拦截到了请求...");

        chain.doFilter(servletRequest, servletResponse);
    }

☀️

2.4.2 登录校验过滤器
2.4.2.1 分析

过滤器Filter的快速入门以及使用细节我们已经介绍完了,接下来最后一步,我们需要使用过滤器Filter来完成案例当中的登录校验功能。

我们先来回顾下前面分析过的登录校验的基本流程:

  • 要进入到后台管理系统,我们必须先完成登录操作,此时就需要访问登录接口login。
  • 登录成功之后,我们会在服务端生成一个JWT令牌,并且把JWT令牌返回给前端,前端会将JWT令牌存储下来。
  • 在后续的每一次请求当中,都会将JWT令牌携带到服务端,请求到达服务端之后,要想去访问对应的业务功能,此时我们必须先要校验令牌的有效性
  • 对于校验令牌的这一块操作,我们使用登录校验的过滤器,在过滤器当中来校验令牌的有效性。如果令牌是无效的,就响应一个错误的信息,也不会再去放行访问对应的资源了。如果令牌存在,并且它是有效的,此时就会放行去访问对应的web资源,执行相应的业务操作。

大概清楚了在Filter过滤器的实现步骤了,那在正式开发登录校验过滤器之前,我们思考两个问题:

  1. 所有的请求,拦截到了之后,都需要校验令牌吗 ?
  • 答案:登录请求例外
  1. 拦截到请求后,什么情况下才可以放行,执行业务操作 ?
  • 答案:有令牌,且令牌校验通过(合法);否则都返回未登录错误结果
2.4.2.2 具体流程

我们要完成登录校验,主要是利用Filter过滤器实现,而Filter过滤器的流程步骤:

基于上面的业务流程,我们分析出具体的操作步骤:

  1. 获取请求url
  2. 判断请求url中是否包含login,如果包含,说明是登录操作,放行
  3. 获取请求头中的令牌(token)
  4. 判断令牌是否存在,如果不存在,响应 401
  5. 解析token,如果解析失败,响应 401
  6. 放行
2.4.2.3 代码实现

在 com.itheima.filter 包下创建TokenFilter,具体代码如下:

java 复制代码
package com.itheima.filter;

import com.itheima.utils.JwtUtils;
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.HttpStatus;
import org.springframework.util.StringUtils;
import java.io.IOException;

/**
 * 令牌校验过滤器
 */
@Slf4j
@WebFilter(urlPatterns = "/*")
public class TokenFilter implements Filter {

    @Override
    public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) resp;
        //1. 获取请求url。
        String url = request.getRequestURL().toString();

        //2. 判断请求url中是否包含login,如果包含,说明是登录操作,放行。
        if(url.contains("login")){ //登录请求
            log.info("登录请求 , 直接放行");
            chain.doFilter(request, response);
            return;
        }

        //3. 获取请求头中的令牌(token)。
        String jwt = request.getHeader("token");

        //4. 判断令牌是否存在,如果不存在,返回错误结果(未登录)。
        if(!StringUtils.hasLength(jwt)){ //jwt为空
            log.info("获取到jwt令牌为空, 返回错误结果");
            response.setStatus(HttpStatus.SC_UNAUTHORIZED);
            return;
        }

        //5. 解析token,如果解析失败,返回错误结果(未登录)。
        try {
            JwtUtils.parseJWT(jwt);
        } catch (Exception e) {
            e.printStackTrace();
            log.info("解析令牌失败, 返回错误结果");
            response.setStatus(HttpStatus.SC_UNAUTHORIZED);
            return;
        }

        //6. 放行。
        log.info("令牌合法, 放行");
        chain.doFilter(request , response);
    }

}

登录校验的过滤器我们编写完成了,接下来我们就可以重新启动服务来做一个测试:

  • 测试1:未登录是否可以访问部门管理页面
    首先关闭浏览器,重新打开浏览器,在地址栏中输入:http://localhost:90
    由于用户没有登录,登录校验过滤器返回错误信息,前端页面根据返回的错误信息结果,自动跳转到登录页面了

💩

http://localhost:90可以访问,

请求其它业务才被拦截

  • 测试2:先进行登录操作,再访问部门管理页面
    登录校验成功之后,可以正常访问相关业务操作页面
2.4.3 Filter详解

Filter过滤器的快速入门程序我们已经完成了,接下来我们就要详细的介绍一下过滤器Filter在使用中的一些细节。主要介绍以下3个方面的细节:

  1. 过滤器的执行流程
  2. 过滤器的拦截路径配置
  3. 过滤器链
2.4.3.1 执行流程

首先我们先来看下过滤器的执行流程:

过滤器当中我们拦截到了请求之后,如果希望继续访问后面的web资源,就要执行放行操作,放行就是调用 FilterChain对象当中的doFilter()方法,在调用doFilter()这个方法之前所编写的代码属于放行之前的逻辑

在放行后访问完 web 资源之后还会回到过滤器当中,回到过滤器之后如有需求还可以执行放行之后的逻辑,放行之后的逻辑我们写在doFilter()这行代码之后。

测试代码:

java 复制代码
@WebFilter(urlPatterns = "/*") 
public class DemoFilter implements Filter {
    
    @Override //初始化方法, 只调用一次
    public void init(FilterConfig filterConfig) throws ServletException {
        System.out.println("init 初始化方法执行了");
    }
    
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        
        System.out.println("DemoFilter   放行前逻辑.....");

        //放行请求
        filterChain.doFilter(servletRequest,servletResponse);

        System.out.println("DemoFilter   放行后逻辑.....");
        
    }

    @Override //销毁方法, 只调用一次
    public void destroy() {
        System.out.println("destroy 销毁方法执行了");
    }
}

启动之后运行测试:

☀️

2.4.3.2 拦截路径

执行流程我们搞清楚之后,接下来再来介绍一下过滤器的拦截路径,Filter可以根据需求,配置不同的拦截资源路径:

下面我们来测试"拦截具体路径":

java 复制代码
@WebFilter(urlPatterns = "/login")  //拦截/login具体路径
public class DemoFilter implements Filter {
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        System.out.println("DemoFilter   放行前逻辑.....");

        //放行请求
        filterChain.doFilter(servletRequest,servletResponse);

        System.out.println("DemoFilter   放行后逻辑.....");
    }


    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        Filter.super.init(filterConfig);
    }

    @Override
    public void destroy() {
        Filter.super.destroy();
    }
}
2.4.3.3 过滤器链

最后我们在来介绍下过滤器链,什么是过滤器链呢?所谓过滤器链指的是在一个web应用程序当中,可以配置多个过滤器,多个过滤器就形成了一个过滤器链。

比如:在我们web服务器当中,定义了两个过滤器,这两个过滤器就形成了一个过滤器链。

而这个链上的过滤器在执行的时候会一个一个的执行,会先执行第一个Filter,放行之后再来执行第二个Filter,如果执行到了最后一个过滤器放行之后,才会访问对应的web资源。

访问完web资源之后,按照我们刚才所介绍的过滤器的执行流程,还会回到过滤器当中来执行过滤器放行后的逻辑,而在执行放行后的逻辑的时候,顺序是反着的。

先要执行过滤器2放行之后的逻辑,再来执行过滤器1放行之后的逻辑,最后在给浏览器响应数据。

过滤器链上过滤器的执行顺序:注解配置的Filter,优先级是按照过滤器类名(字符串)的自然排序。 比如:

  • AbcFilter
  • DemoFilter 这两个过滤器来说,AbcFilter 会先执行,DemoFilter会后执行。

过滤器链上过滤器的执行顺序:注解配置的Filter,优先级是按照过滤器类名(字符串)的自然排序。 比如:

  • AbcFilter
  • DemoFilter
    这两个过滤器来说,AbcFilter 会先执行,DemoFilter会后执行。

☀️

2.5 拦截器Interceptor

2.5.1 快速入门

什么是拦截器?

  • 是一种动态拦截方法调用的机制,类似于过滤器。
  • 拦截器是Spring框架中提供的,用来动态拦截控制器方法的执行。
  • 拦截器的作用:拦截请求,在指定方法调用前后,根据业务需要执行预先设定的代码。

    在拦截器当中,我们通常也是做一些通用性的操作,比如:我们可以通过拦截器来拦截前端发起的请求,将登录校验的逻辑全部编写在拦截器当中。在校验的过程当中,如发现用户登录了(携带JWT令牌且是合法令牌),就可以直接放行,去访问spring当中的资源。如果校验时发现并没有登录或是非法令牌,就可以直接给前端响应未登录的错误信息。

下面我们通过快速入门程序,来学习下拦截器的基本使用。拦截器的使用步骤和过滤器类似,也分为两步:

  1. 定义拦截器
  2. 注册配置拦截器

1). 自定义拦截器

实现HandlerInterceptor接口,并重写其所有方法

java 复制代码
//自定义拦截器
@Component
public class DemoInterceptor implements HandlerInterceptor {
    //目标资源方法执行前执行。 返回true:放行    返回false:不放行
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        System.out.println("preHandle .... ");
        
        return true; //true表示放行
    }

    //目标资源方法执行后执行
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        System.out.println("postHandle ... ");
    }

    //视图渲染完毕后执行,最后执行
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        System.out.println("afterCompletion .... ");
    }
}

注意:

  • preHandle方法:目标资源方法执行前执行。 返回true:放行 返回false:不放行
  • postHandle方法:目标资源方法执行后执行
  • afterCompletion方法:视图渲染完毕后执行,最后执行

2). 注册配置拦截器

在 com.itheima下创建一个包,然后创建一个配置类 WebConfig, 实现 WebMvcConfigurer 接口,并重写 addInterceptors 方法

java 复制代码
@Configuration  
public class WebConfig implements WebMvcConfigurer {

    //自定义的拦截器对象
    @Autowired
    private DemoInterceptor demoInterceptor;

    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
       //注册自定义拦截器对象
        registry.addInterceptor(demoInterceptor).addPathPatterns("/**");//设置拦截器拦截的请求路径( /** 表示拦截所有请求)
    }
}

重新启动SpringBoot服务,打开Apifox测试:

可以看到控制台输出的日志:

接下来我们再来做一个测试:将拦截器中返回值改为false

使用Apifox,再次点击send发送请求后,没有响应数据,说明请求被拦截了没有放行

☀️

2.5.2 令牌校验Interceptor

讲解完了拦截器的基本操作之后,接下来我们需要完成最后一步操作:通过拦截器来完成案例当中的登录校验功能。

登录校验的业务逻辑以及操作步骤我们前面已经分析过了,和登录校验Filter过滤器当中的逻辑是完全一致的。现在我们只需要把这个技术方案由原来的过滤器换成拦截器interceptor就可以了。

1). TokenInterceptor

在 com.itheima.interceptor 包下创建 TokenInterceptor

java 复制代码
@Slf4j
@Component
public class TokenInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //1. 获取请求url。
        String url = request.getRequestURL().toString();

        //2. 判断请求url中是否包含login,如果包含,说明是登录操作,放行。
        if(url.contains("login")){ //登录请求
            log.info("登录请求 , 直接放行");
            return true;
        }

        //3. 获取请求头中的令牌(token)。
        String jwt = request.getHeader("token");

        //4. 判断令牌是否存在,如果不存在,返回错误结果(未登录)。
        if(!StringUtils.hasLength(jwt)){ //jwt为空
            log.info("获取到jwt令牌为空, 返回错误结果");
            response.setStatus(HttpStatus.SC_UNAUTHORIZED);
            return false;
        }

        //5. 解析token,如果解析失败,返回错误结果(未登录)。
        try {
            JwtUtils.parseJWT(jwt);
        } catch (Exception e) {
            e.printStackTrace();
            log.info("解析令牌失败, 返回错误结果");
            response.setStatus(HttpStatus.SC_UNAUTHORIZED);
            return false;
        }

        //6. 放行。
        log.info("令牌合法, 放行");
        return true;
    }

}

2). 配置拦截器

java 复制代码
@Configuration  
public class WebConfig implements WebMvcConfigurer {
    //拦截器对象
    @Autowired
    private TokenInterceptor tokenInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
       //注册自定义拦截器对象
        registry.addInterceptor(tokenInterceptor).addPathPatterns("/**");
    }
}

登录校验的拦截器编写完成后,接下来我们就可以重新启动服务来做一个测试: (关闭登录校验Filter过滤器)

  • 测试1:未登录是否可以访问部门管理页面
    首先关闭浏览器,重新打开浏览器,在地址栏中输入:http://localhost:90
    由于用户没有登录,校验机制返回错误信息,前端页面根据返回的错误信息结果,自动跳转到登录页面了
  • 测试2:先进行登录操作,再访问部门管理页面
    登录校验成功之后,可以正常访问相关业务操作页面
    到此我们也就验证了所开发的登录校验的拦截器也是没问题的。登录校验的过滤器和拦截器,我们只需要使用其中的一种就可以了。

2.5.3 Interceptor详解

拦截器的入门程序完成之后,接下来我们来介绍拦截器的使用细节。拦截器的使用细节我们主要介绍两个部分:

  1. 拦截器的拦截路径配置
  2. 拦截器的执行流程
2.5.4 拦截路径

首先我们先来看拦截器的拦截路径的配置,在注册配置拦截器的时候,我们要指定拦截器的拦截路径,通过addPathPatterns("要拦截路径")方法,就可以指定要拦截哪些资源。

在入门程序中我们配置的是/**,表示拦截所有资源,而在配置拦截器时,不仅可以指定要拦截哪些资源,还可以指定不拦截哪些资源,只需要调用excludePathPatterns("不拦截路径")方法,指定哪些资源不需要拦截。

java 复制代码
@Configuration  
public class WebConfig implements WebMvcConfigurer {

    //拦截器对象
    @Autowired
    private DemoInterceptor demoInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //注册自定义拦截器对象
        registry.addInterceptor(demoInterceptor)
                .addPathPatterns("/**")//设置拦截器拦截的请求路径( /** 表示拦截所有请求)
                .excludePathPatterns("/login");//设置不拦截的请求路径
    }
}

java 复制代码
/*配置类*/
@Configuration
public class WebConfig implements WebMvcConfigurer {

    //自定义的拦截器对象
    @Autowired
    private TokenInterceptor tokenInterceptor;


    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(tokenInterceptor)
                .addPathPatterns("/**")//设置拦截器拦截的请求路径( /** 表示拦截所有请求)
                .excludePathPatterns("/login");//设置不拦截的请求路径
    }
}

⭐️

在拦截器中除了可以设置/**拦截所有资源外,还有一些常见拦截路径设置:

2.5.5 执行流程

介绍完拦截路径的配置之后,接下来我们再来介绍拦截器的执行流程。通过执行流程,大家就能够清晰的知道过滤器与拦截器的执行时机。

  • 当我们打开浏览器来访问部署在web服务器当中的web应用时,此时我们所定义的过滤器会拦截到这次请求。拦截到这次请求之后,它会先执行放行前的逻辑,然后再执行放行操作。而由于我们当前是基于springboot开发的,所以放行之后是进入到了spring的环境当中,也就是要来访问我们所定义的controller当中的接口方法。
  • Tomcat并不识别所编写的Controller程序,但是它识别Servlet程序,所以在Spring的Web环境中提供了一个非常核心的Servlet:DispatcherServlet(前端控制器),所有请求都会先进行到DispatcherServlet,再将请求转给Controller。
  • 当我们定义了拦截器后,会在执行Controller的方法之前,请求被拦截器拦截住。执行preHandle()方法,这个方法执行完成后需要返回一个布尔类型的值,如果返回true,就表示放行本次操作,才会继续访问controller中的方法;如果返回false,则不会放行(controller中的方法也不会执行)。
  • 在controller当中的方法执行完毕之后,再回过来执行postHandle()这个方法以及afterCompletion() 方法,然后再返回给DispatcherServlet,最终再来执行过滤器当中放行后的这一部分逻辑的逻辑。执行完毕之后,最终给浏览器响应数据。

以上就是拦截器的执行流程。通过执行流程分析,大家应该已经清楚了过滤器和拦截器之间的区别,其实它们之间的区别主要是两点:

  • 接口规范不同:过滤器需要实现Filter接口,而拦截器需要实现HandlerInterceptor接口。
  • 拦截范围不同:过滤器Filter会拦截所有的资源,而Interceptor只会拦截Spring环境中的资源。

📚总结:浏览器请求 → Filter 过滤器(放行前逻辑) → 放行 → DispatcherServlet(SpringMVC

核心 Servlet,Tomcat 只认识 Servlet,不认识 Controller) → Interceptor 拦截器

preHandle ()

13-后端Web进阶(AOP)

接下来我们进入到AOP的学习。 AOP也是spring框架的第二大核心,我们先来学习AOP的基础。

什么是AOP?

AOP:Aspect Oriented Programming(面向切面编程、面向方面编程),其实说白了,面向切面编程就是面向特定方法编程。

那什么又是面向方法编程呢,为什么又需要面向方法编程呢?

来,我们举个例子做一个说明:

比如,我们这里有一个项目,项目中开发了很多的业务功能。然而有一些业务功能执行效率比较低,执行耗时较长,我们需要针对于这些业务方法进行优化。 那首先第一步就需要定位出执行耗时比较长的业务方法,再针对于业务方法再来进行优化。

此时我们就需要统计当前这个项目当中每一个业务方法的执行耗时。那么统计每一个业务方法的执行耗时该怎么实现?

可能多数人首先想到的就是在每一个业务方法运行之前,记录这个方法运行的开始时间。在这个方法运行完毕之后,再来记录这个方法运行的结束时间。拿结束时间减去开始时间,不就是这个方法的执行耗时吗。

而这个功能如果通过AOP来实现,我们只需要单独定义下面这一小段代码即可,不需要修改原始的任何业务方法即可记录每一个业务方法的执行耗时。

所以,AOP的优势主要体现在以下四个方面:

  • 减少重复代码:不需要在业务方法中定义大量的重复性的代码,只需要将重复性的代码抽取到AOP程序中即可。
  • 代码无侵入:在基于AOP实现这些业务功能时,对原有的业务代码是没有任何侵入的,不需要修改任何的业务代码。
  • 提高开发效率
  • 维护方便

AOP是一种思想,而在Spring框架中,对这种思想进行了实现,那我们要学习的就是Spring AOP。

☀️

1.1 AOP入门

在了解了什么是AOP后,我们下面通过一个快速入门程序,体验下AOP的开发,并掌握Spring中AOP的开发步骤。

  • 需求:统计部门管理各个业务层方法执行耗时。
  • 原始方式:
    在原始的实现方式中,我们需要在业务层的也一个方法执行执行,获取方法运行的开始时间; 然后运行原始的方法逻辑; 最后在每一个方法运行结束时,获取方法运行结束时间,计算执行耗时。
  • SpringAOP实现步骤:

为演示方便,可以直接导入资料中提供的springboot-aop-quickstart项目工程

1). 导入依赖:在 pom.xml 文件中导入 AOP 的依赖

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

2). 编写AOP程序:针对于特定方法根据业务需要进行编程

java 复制代码
@Component
@Aspect //当前类为切面类
@Slf4j
public class RecordTimeAspect {

    @Around("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")
    public Object recordTime(ProceedingJoinPoint pjp) throws Throwable {
        //记录方法执行开始时间
        long begin = System.currentTimeMillis();

        //执行原始方法
        Object result = pjp.proceed();

        //记录方法执行结束时间
        long end = System.currentTimeMillis();

        //计算方法执行耗时
        log.info("方法执行耗时: {}毫秒",end-begin);
        return result;
    }
}
java 复制代码
 log.info("方法{}执行耗时: {}毫秒",pjp.getSignature(),end-begin);

重新启动SpringBoot服务,打开浏览器访问部门管理的功能进行测试:

我们可以看到,在控制台中输出了方法的执行耗时:

我们通过AOP入门程序完成了业务方法执行耗时的统计,那其实AOP的功能远不止于此,常见的应用场景如下:

  • 记录系统的操作日志
  • 权限控制
  • 事务管理:我们前面所讲解的Spring事务管理,底层其实也是通过AOP来实现的,只要添加@Transactional注解之后,AOP程序自动会在原始方法运行前先来开启事务,在原始方法运行完毕之后提交或回滚事务
    这些都是AOP应用的典型场景。

通过入门程序,我们也应该感受到了AOP面向切面编程的一些优势:

  • 代码无侵入:没有修改原始的业务方法,就已经对原始的业务方法进行了功能的增强或者是功能的改变
  • 减少了重复代码
  • 提高开发效率
  • 维护方便

☀️

1.2 AOP核心概念

通过SpringAOP的快速入门,感受了一下AOP面向切面编程的开发方式。下面我们再来学习AOP当中涉及到的一些核心概念。

  • 连接点 :JoinPoint,可以被AOP控制的方法(暗含方法执行时的相关信息)
    • 连接点指的是可以被aop控制的方法。例如:入门程序当中所有的业务方法都是可以被aop控制的方法。
    • 在SpringAOP提供的JoinPoint当中,封装了连接点方法在执行时的相关信息。(后面会有具体的讲解)
  • 通知 :Advice,指哪些重复的逻辑,也就是共性功能(最终体现为一个方法)
    • 在入门程序中是需要统计各个业务方法的执行耗时的,此时我们就需要在这些业务方法运行开始之前,先记录这个方法运行的开始时间,在每一个业务方法运行结束的时候,再来记录这个方法运行的结束时间。
    • 是在AOP面向切面编程当中,我们只需要将这部分重复的代码逻辑抽取出来单独定义。抽取出来的这一部分重复的逻辑,也就是共性的功能。
  • 切入点 :PointCut,匹配连接点的条件,通知仅会在切入点方法执行时被应用。
    • 在通知当中,我们所定义的共性功能到底要应用在哪些方法上?此时就涉及到了切入点pointcut概念。切入点指的是匹配连接点的条件。通知仅会在切入点方法运行时才会被应用。
    • 在aop的开发当中,我们通常会通过一个切入点表达式来描述切入点(后面会有详解)。

切入点:实际被AOP控制的方法

切入点一定是连接点,但连接点不一定是切入点(连接点范围更大)

  • 假如:切入点表达式改为DeptServiceImpl.list(),此时就代表仅仅只有list这一个方法是切入点。只有list()方法在运行的时候才会应用通知。

  • 切面 :Aspect,描述通知与切入点的对应关系(通知+切入点)

    当通知和切入点结合在一起,就形成了一个切面。通过切面就能够描述当前aop程序需要针对于哪个原始方法,在什么时候执行什么样的操作。

而切面所在的类,称之为切面类(被@Aspect注解标识的类)。

  • 目标对象:Target,通知所应用的对象
    目标对象指的就是通知所应用的对象,我们就称之为目标对象。
    ☀️

AOP的核心概念我们介绍完毕之后,接下来我们再来分析一下我们所定义的通知是如何与目标对象结合在一起,对目标对象当中的方法进行功能增强的。

📚总结来说:

就是controller调用的是 动态代理对象进行了功能增强的list方法

Spring的AOP底层是基于动态代理技术来实现的,也就是说在程序运行的时候,会自动的基于动态代理技术为目标对象生成一个对应的代理对象。在代理对象当中就会对目标对象当中的原始方法进行功能的增强。

SpringAOP 旨在管理bean对象的过程中,主要通过底层的动态代理机制,对特定的方法进行编程 。

2. AOP进阶

AOP的基础知识学习完之后,下面我们对AOP当中的各个细节进行详细的学习。主要分为4个部分:

  1. 通知类型
  2. 通知顺序
  3. 切入点表达式

我们先来学习第一部分通知类型。

2.1 通知类型

在入门程序当中,我们已经使用了一种功能最为强大的通知类型:Around环绕通知。

java 复制代码
@Component
@Aspect //当前类为切面类
@Slf4j
public class TimeAspect {

    @Around("execution(* com.itheima.service.impl.DeptServiceImpl.*(..))")
    public Object recordTime(ProceedingJoinPoint pjp) throws Throwable {
        //记录方法执行开始时间
        long begin = System.currentTimeMillis();
        //执行原始方法
        Object result = pjp.proceed();
        //记录方法执行结束时间
        long end = System.currentTimeMillis();
        //计算方法执行耗时
        log.info("方法执行耗时: {}毫秒",end-begin);
        return result;
    }
}

只要我们在通知方法上加上了@Around注解,就代表当前通知是一个环绕通知。

📚总结来说:

就是控制 通知方法目标方法 前还是后执行,有异常后还是无异常后执行。

下面我们通过代码演示,来加深对于不同通知类型的理解:

java 复制代码
@Slf4j
@Component
@Aspect
public class MyAspect1 {
    //前置通知
    @Before("execution(* com.itheima.service.*.*(..))")
    public void before(JoinPoint joinPoint){
        log.info("before ...");

    }

    //环绕通知
    @Around("execution(* com.itheima.service.*.*(..))")
    public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        log.info("around before ...");

        //调用目标对象的原始方法执行
        Object result = proceedingJoinPoint.proceed();
        
        //原始方法如果执行时有异常,环绕通知中的后置代码不会在执行了
        
        log.info("around after ...");
        return result;
    }

    //后置通知
    @After("execution(* com.itheima.service.*.*(..))")
    public void after(JoinPoint joinPoint){
        log.info("after ...");
    }

    //返回后通知(程序在正常执行的情况下,会执行的后置通知)
    @AfterReturning("execution(* com.itheima.service.*.*(..))")
    public void afterReturning(JoinPoint joinPoint){
        log.info("afterReturning ...");
    }

    //异常通知(程序在出现异常的情况下,执行的后置通知)
    @AfterThrowing("execution(* com.itheima.service.*.*(..))")
    public void afterThrowing(JoinPoint joinPoint){
        log.info("afterThrowing ...");
    }
}

重新启动SpringBoot服务,进行测试:

1). 没有异常情况下:

使用 Apifox 测试查询所有部门数据

查看idea中控制台日志输出:

程序没有发生异常的情况下,@AfterThrowing标识的通知方法不会执行。

2). 出现异常情况下:

修改DeptServiceImpl业务实现类中的代码: 添加异常

java 复制代码
@Slf4j
@Service
public class DeptServiceImpl implements DeptService {
    @Autowired
    private DeptMapper deptMapper;

    @Override
    public List<Dept> list() {

        List<Dept> deptList = deptMapper.list();
        //模拟异常
        int num = 10/0;
        return deptList;
    }
    
    //省略其他代码...
}

重新启动SpringBoot服务,测试发生异常情况下通知的执行:

查看idea中控制台日志输出

程序发生异常的情况下:

  • @AfterReturning标识的通知方法不会执行,@AfterThrowing标识的通知方法执行了
  • @Around环绕通知中原始方法调用时有异常,通知中的环绕后的代码逻辑也不会在执行了 (因为原始方法调用已经出异常了)

五种常见的通知类型,我们已经测试完毕了,此时我们再来看一下刚才所编写的代码,有什么问题吗?

java 复制代码
//前置通知
@Before("execution(* com.itheima.service.*.*(..))")

//环绕通知
@Around("execution(* com.itheima.service.*.*(..))")
  
//后置通知
@After("execution(* com.itheima.service.*.*(..))")

//返回后通知(程序在正常执行的情况下,会执行的后置通知)
@AfterReturning("execution(* com.itheima.service.*.*(..))")

//异常通知(程序在出现异常的情况下,执行的后置通知)
@AfterThrowing("execution(* com.itheima.service.*.*(..))")

我们发现啊,每一个注解里面都指定了切入点表达式,而且这些切入点表达式都一模一样。此时我们的代码当中就存在了大量的重复性的切入点表达式,假如此时切入点表达式需要变动,就需要将所有的切入点表达式一个一个的来改动,就变得非常繁琐了。

怎么来解决这个切入点表达式重复 的问题? 答案就是:抽取

Spring提供了@PointCut注解,该注解的作用是将公共的切入点表达式抽取出来,需要用到时引用该切入点表达式即可。

java 复制代码
@Slf4j
@Component
@Aspect
public class MyAspect1 {

    //切入点方法(公共的切入点表达式)
    @Pointcut("execution(* com.itheima.service.*.*(..))")
    private void pt(){}

    //前置通知(引用切入点)
    @Before("pt()")
    public void before(JoinPoint joinPoint){
        log.info("before ...");

    }

    //环绕通知
    @Around("pt()")
    public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        log.info("around before ...");

        //调用目标对象的原始方法执行
        Object result = proceedingJoinPoint.proceed();
        //原始方法在执行时:发生异常
        //后续代码不在执行

        log.info("around after ...");
        return result;
    }

    //后置通知
    @After("pt()")
    public void after(JoinPoint joinPoint){
        log.info("after ...");
    }

    //返回后通知(程序在正常执行的情况下,会执行的后置通知)
    @AfterReturning("pt()")
    public void afterReturning(JoinPoint joinPoint){
        log.info("afterReturning ...");
    }

    //异常通知(程序在出现异常的情况下,执行的后置通知)
    @AfterThrowing("pt()")
    public void afterThrowing(JoinPoint joinPoint){
        log.info("afterThrowing ...");
    }
}

需要注意的是:当切入点方法使用private修饰时,仅能在当前切面类中引用该表达式, 当外部其他切面类中也要引用当前类中的切入点表达式,就需要把private改为public,而在引用的时候,具体的语法为:

java 复制代码
@Slf4j
@Component
@Aspect
public class MyAspect2 {
    //引用MyAspect1切面类中的切入点表达式
    @Before("com.itheima.aspect.MyAspect1.pt()")
    public void before(){
        log.info("MyAspect2 -> before ...");
    }
}

2.2 通知顺序

讲解完了Spring中AOP所支持的5种通知类型之后,接下来我们再来研究通知的执行顺序。

当在项目开发当中,我们定义了多个切面类 ,而多个切面类中多个切入点都匹配到了同一个目标方法 。此时当目标方法在运行的时候,这多个切面类当中的这些通知方法都会运行。

此时我们就有一个疑问,这多个通知方法到底哪个先运行,哪个后运行? 下面我们通过程序来验证(这里呢,我们就定义两种类型的通知进行测试,一种是前置通知@Before,一种是后置通知@After)

定义多个切面类:

java 复制代码
@Slf4j
@Component
@Aspect
public class MyAspect2 {
    //前置通知
    @Before("execution(* com.itheima.service.*.*(..))")
    public void before(){
        log.info("MyAspect2 -> before ...");
    }

    //后置通知
    @After("execution(* com.itheima.service.*.*(..))")
    public void after(){
        log.info("MyAspect2 -> after ...");
    }
}
java 复制代码
@Slf4j
@Component
@Aspect
public class MyAspect3 {
    //前置通知
    @Before("execution(* com.itheima.service.*.*(..))")
    public void before(){
        log.info("MyAspect3 -> before ...");
    }

    //后置通知
    @After("execution(* com.itheima.service.*.*(..))")
    public void after(){
        log.info("MyAspect3 ->  after ...");
    }
}
java 复制代码
@Slf4j
@Component
@Aspect
public class MyAspect4 {
    //前置通知
    @Before("execution(* com.itheima.service.*.*(..))")
    public void before(){
        log.info("MyAspect4 -> before ...");
    }

    //后置通知
    @After("execution(* com.itheima.service.*.*(..))")
    public void after(){
        log.info("MyAspect4 -> after ...");
    }
}

重新启动SpringBoot服务,测试通知的执行顺序:

备注:

  1. 把DeptServiceImpl实现类中模拟异常的代码删除或注释掉。
  2. 注释掉其他切面类(把@Aspect注释即可),仅保留MyAspect2、MyAspect3、MyAspect4 ,这样就可以清晰看到执行的结果,而不被其他切面类干扰。

使用 Apifox 测试查询所有部门数据。

查看idea中控制台日志输出

⭐️

  • 通过以上程序运行可以看出在不同切面类中,默认按照切面类的类名字母排序
    • 目标方法前的通知方法:字母排名靠前的先执行
    • 目标方法后的通知方法:字母排名靠前的后执行

如果我们想控制通知的执行顺序有两种方式:

  1. 修改切面类的类名(这种方式非常繁琐、而且不便管理)
  2. 使用Spring提供的@Order注解

使用@Order注解,控制通知的执行顺序:

java 复制代码
@Slf4j
@Component
@Aspect
@Order(2)  //切面类的执行顺序(前置通知:数字越小先执行; 后置通知:数字越小越后执行)
public class MyAspect2 {
    //前置通知
    @Before("execution(* com.itheima.service.*.*(..))")
    public void before(){
        log.info("MyAspect2 -> before ...");
    }

    //后置通知 
    @After("execution(* com.itheima.service.*.*(..))")
    public void after(){
        log.info("MyAspect2 -> after ...");
    }
}
java 复制代码
@Slf4j
@Component
@Aspect
@Order(3)  //切面类的执行顺序(前置通知:数字越小先执行; 后置通知:数字越小越后执行)
public class MyAspect3 {
    //前置通知
    @Before("execution(* com.itheima.service.*.*(..))")
    public void before(){
        log.info("MyAspect3 -> before ...");
    }

    //后置通知
    @After("execution(* com.itheima.service.*.*(..))")
    public void after(){
        log.info("MyAspect3 ->  after ...");
    }
}
java 复制代码
@Slf4j
@Component
@Aspect
@Order(1) //切面类的执行顺序(前置通知:数字越小先执行; 后置通知:数字越小越后执行)
public class MyAspect4 {
    //前置通知
    @Before("execution(* com.itheima.service.*.*(..))")
    public void before(){
        log.info("MyAspect4 -> before ...");
    }

    //后置通知
    @After("execution(* com.itheima.service.*.*(..))")
    public void after(){
        log.info("MyAspect4 -> after ...");
    }
}

重新启动SpringBoot服务,测试通知执行顺序:

通知的执行顺序大家主要知道两点即可:

  1. 不同的切面类当中,默认情况下通知的执行顺序是与切面类的类名字母排序是有关系的
  2. 可以在切面类上面加上@Order注解,来控制不同的切面类通知的执行顺序

2.3 切入点表达式

从AOP的入门程序到现在,我们一直都在使用切入点表达式来描述切入点。下面我们就来详细的介绍一下切入点表达式的具体写法。

切入点表达式:描述切入点方法的一种表达式

  • 作用:主要用来决定项目中的哪些方法需要加入通知
  • 常见形式:
    • execution(......):根据方法的签名来匹配
    • @annotation(......) :根据注解匹配
      首先我们先学习第一种最为常见的execution切入点表达式。
2.3.1 execution

execution主要根据方法的返回值、包名、类名、方法名、方法参数等信息来匹配,语法为:

java 复制代码
execution(访问修饰符?  返回值  包名.类名.?方法名(方法参数) throws 异常?)

其中带?的表示可以省略的部分

  • 访问修饰符:可省略(比如: public、protected)
  • 包名.类名: 可省略(不建议省略)
  • throws 异常:可省略(注意是方法上声明抛出的异常,不是实际抛出的异常)
java 复制代码
@Before("execution(void com.itheima.service.impl.DeptServiceImpl.delete(java.lang.Integer))")

可以使用通配符描述切入点

  • * :单个独立的任意符号,可以通配任意返回值、包名、类名、方法名、任意类型的一个参数,也可以通配包、类、方法名的一部分
  • ..多个连续的任意符号 ,可以通配任意层级的包,或任意类型、任意个数的参数

⭐️切入点表达式的语法规则

  1. 方法的访问修饰符可以省略
  2. 返回值可以使用*号代替(任意返回值类型)
  3. 包名可以使用号代替,代表任意包(一层包使用一个
  4. 使用...配置包名,标识此包以及此包下的所有子包
  5. 类名可以使用*号代替,标识任意类
  6. 方法名可以使用*号代替,表示任意方法
  7. 可以使用 * 配置参数,一个任意类型的参数
  8. 可以使用... 配置参数,任意个任意类型的参数

切入点表达式示例

  • 省略方法的修饰符号
java 复制代码
execution(void com.itheima.service.impl.DeptServiceImpl.delete(java.lang.Integer))
  • 使用*代替返回值类型
java 复制代码
execution(* com.itheima.service.impl.DeptServiceImpl.delete(java.lang.Integer))
  • 使用代替包名(一层包使用一个
java 复制代码
execution(* com.itheima.*.*.DeptServiceImpl.delete(java.lang.Integer))
  • 使用...省略包名
java 复制代码
execution(* com..DeptServiceImpl.delete(java.lang.Integer))  
  • 使用*代替类名
java 复制代码
execution(* com..*.delete(java.lang.Integer))
  • 使用*代替方法名
java 复制代码
execution(* com..*.*(java.lang.Integer))
  • 使用 * 代替参数
java 复制代码
execution(* com.itheima.service.impl.DeptServiceImpl.delete(*))
  • 使用...省略参数
java 复制代码
execution(* com..*.*(..))

注意事项:

  • 根据业务需要,可以使用 且(&&)、或(||)、非(!) 来组合比较复杂的切入点表达式。
java 复制代码
execution(* com.itheima.service.DeptService.list(..)) || execution(* com.itheima.service.DeptService.delete(..))

切入点表达式的书写建议:

  • 所有业务方法名在命名时尽量规范,方便切入点表达式快速匹配。如:查询类方法都是 find 开头,更新类方法都是update开头
java 复制代码
//业务类
@Service
public class DeptServiceImpl implements DeptService {
    
    public List<Dept> findAllDept() {
       //省略代码...
    }
    
    public Dept findDeptById(Integer id) {
       //省略代码...
    }
    
    public void updateDeptById(Integer id) {
       //省略代码...
    }
    
    public void updateDeptByMoreCondition(Dept dept) {
       //省略代码...
    }
    //其他代码...
}
  • //匹配DeptServiceImpl类中以find开头的方法
java 复制代码
execution(* com.itheima.service.impl.DeptServiceImpl.find*(..))
  • 描述切入点方法通常基于接口描述,而不是直接描述实现类,增强拓展性
java 复制代码
execution(* com.itheima.service.DeptService.*(..))
  • 在满足业务需要的前提下,尽量缩小切入点的匹配范围。如:包名匹配尽量不使用 ...,使用 * 匹配单个包
java 复制代码
execution(* com.itheima.*.*.DeptServiceImpl.find*(..))

切入点表达式书写建议:

  • 所有业务方法名在命名时尽量规范,方便切入点表达式快速匹配。如:findXxx,updateXxx。
  • 描述切入点方法通常基于接口描述,而不是直接描述实现类,增强拓展性。
  • 在满足业务需要的前提下,尽量缩小切入点的匹配范围。如:包名尽量不使用...,使用 * 匹配单个包。

☀️

2.3.2 @annotation

已经学习了execution切入点表达式的语法。那么如果我们要匹配多个无规则的方法,比如:list()和 delete()这两个方法。这个时候我们基于execution这种切入点表达式来描述就不是很方便了。而在之前我们是将两个切入点表达式组合在了一起完成的需求,这个是比较繁琐的。

我们可以借助于另一种切入点表达式 @annotation 来描述这一类的切入点,从而来简化切入点表达式的书写。

实现步骤:

  1. 编写自定义注解
  2. 在业务类要做为连接点的方法上添加自定义注解

自定义注解:LogOperation

java 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogOperation{
}

业务类:DeptServiceImpl

java 复制代码
@Slf4j
@Service
public class DeptServiceImpl implements DeptService {
    @Autowired
    private DeptMapper deptMapper;

    @Override
    @LogOperation //自定义注解(表示:当前方法属于目标方法)
    public List<Dept> list() {
        List<Dept> deptList = deptMapper.list();
        //模拟异常
        //int num = 10/0;
        return deptList;
    }

    @Override
    @LogOperation //自定义注解(表示:当前方法属于目标方法)
    public void delete(Integer id) {
        //1. 删除部门
        deptMapper.delete(id);
    }


    @Override
    public void save(Dept dept) {
        dept.setCreateTime(LocalDateTime.now());
        dept.setUpdateTime(LocalDateTime.now());
        deptMapper.save(dept);
    }

    @Override
    public Dept getById(Integer id) {
        return deptMapper.getById(id);
    }

    @Override
    public void update(Dept dept) {
        dept.setUpdateTime(LocalDateTime.now());
        deptMapper.update(dept);
    }
}

切面类

java 复制代码
@Slf4j
@Component
@Aspect
public class MyAspect6 {
    //针对list方法、delete方法进行前置通知和后置通知

    //前置通知
    @Before("@annotation(com.itheima.anno.LogOperation)")
    public void before(){
        log.info("MyAspect6 -> before ...");
    }
    
    //后置通知
    @After("@annotation(com.itheima.anno.LogOperation)")
    public void after(){
        log.info("MyAspect6 -> after ...");
    }
}

重启SpringBoot服务,测试查询所有部门数据,查看控制台日志:

到此我们两种常见的切入点表达式我已经介绍完了。

  • execution切入点表达式(优先考虑)
    • 根据我们所指定的方法的描述信息来匹配切入点方法,这种方式也是最为常用的一种方式
    • 如果我们要匹配的切入点方法的方法名不规则,或者有一些比较特殊的需求,通过execution切入点表达式描述比较繁琐
  • annotation 切入点表达式
    • 基于注解的方式来匹配切入点方法。这种方式虽然多一步操作,我们需要自定义一个注解,但是相对来比较灵活。我们需要匹配哪个方法,就在方法上加上对应的注解就可以了

根据业务需要,可以使用 && ,||,! 来组合比较复杂的切入点表达式。

📚

连接点:获取目标方法的相关信息

3. AOP案例

SpringAOP的相关知识我们就已经全部学习完毕了。最后我们要通过一个案例来对AOP进行一个综合的应用。

3.1 需求

需求:将案例(Tlias智能学习辅助系统)中增、删、改相关接口的操作日志记录到数据库表中

  • 就是当访问部门管理和员工管理当中的增、删、改相关功能接口时,需要详细的操作日志,并保存在数据表中,便于后期数据追踪。

操作日志信息包含:

  • 操作人、操作时间、执行方法的全类名、执行方法名、方法运行时参数、返回值、方法执行时长

所记录的日志信息包括当前接口的操作人是谁操作的,什么时间点操作的,以及访问的是哪个类当中的哪个方法,在访问这个方法的时候传入进来的参数是什么,访问这个方法最终拿到的返回值是什么,以及整个接口方法的运行时长是多长时间。

3.2 分析

  • 问题1:项目当中增删改相关的方法是不是有很多?
    • 很多
  • 问题2:我们需要针对每一个功能接口方法进行修改,在每一个功能接口当中都来记录这些操作日志吗?
    • 这种做法比较繁琐

以上两个问题的解决方案:可以使用AOP解决(每一个增删改功能接口中要实现的记录操作日志的逻辑代码是相同)。

可以把这部分记录操作日志的通用的、重复性的逻辑代码抽取出来定义在一个通知方法当中,我们通过AOP面向切面编程的方式,在不改动原始功能的基础上来对原始的功能进行增强。目前我们所增强的功能就是来记录操作日志,所以也可以使用AOP的技术来实现。使用AOP的技术来实现也是最为简单,最为方便的。

  • 问题3:既然要基于AOP面向切面编程的方式来完成的功能,那么我们要使用 AOP五种通知类型当中的哪种通知类型?
    • 答案:环绕通知 @Around。因为所记录的操作日志当中包括:操作人、操作时间,访问的是哪个类、哪个方法、方法运行时参数、方法的返回值、方法的运行时长。方法返回值,是在原始方法执行后才能获取到的。方法的运行时长,需要原始方法运行之前记录开始时间,原始方法运行之后记录结束时间。通过计算获得方法的执行耗时。基于以上的分析我们确定要使用Around环绕通知。
  • 问题4:最后一个问题,切入点表达式我们该怎么写?
    • 答案:使用 @annotation 来描述切入点表达式。要匹配业务接口当中所有的增删改的方法,而增删改方法在命名上没有共同的前缀或后缀。此时如果使用execution切入点表达式也可以,但是会比较繁琐。 当遇到增删改的方法名没有规律时,就可以使用 @annotation切入点表达式

3.3 步骤

简单分析了一下大概的实现思路后,接下来我们就要来完成案例了。案例的实现步骤其实就两步:

  • 准备工作
    1. 引入AOP的起步依赖
    2. 导入资料中准备好的数据库表结构,并引入对应的实体类
  • 编码实现(基于AI实现)
    1. 自定义注解@LogOperation
    2. 定义切面类,完成记录操作日志的逻辑

3.4 代码实现

🤖请帮我基于Spring AOP中的环绕通知 @Around **实现记录系统所有增、删、改功能接口的操作日志。**具体信息如下:

  1. 日志信息包含:操作人、操作时间、执行方法的全类名、执行方法名、方法运行时参数、返回值、方法执行时长
  2. 功能接口所在包为 com.itheima.controller
  3. 日志表为 operate_log 表,对应的实体类为 OperateLog。 具体表结构如下: create table operate_log( id int unsigned primary key auto_increment comment 'ID',
    operate_emp_id int unsigned comment '操作人ID', operate_time datetime
    comment '操作时间', class_name varchar(100) comment '操作的类名', method_name
    varchar(100) comment '操作的方法名', method_params varchar(1000) comment
    '方法参数', return_value varchar(2000) comment '返回值', cost_time int
    comment '方法执行耗时, 单位:ms' ) comment '操作日志表';
  4. 并且已经提供了OperateLogMapper接口来操作 operate_log, 并在其中已经定义好了 insert 方法用来保存日志数据.

1). 准备工作

  • 在 pom.xml 中引入AOP的依赖
xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
  • 创建数据库表结构
sql 复制代码
-- 操作日志表
create table operate_log(
    id int unsigned primary key auto_increment comment 'ID',
    operate_emp_id int unsigned comment '操作人ID',
    operate_time datetime comment '操作时间',
    class_name varchar(100) comment '操作的类名',
    method_name varchar(100) comment '操作的方法名',
    method_params varchar(1000) comment '方法参数',
    return_value varchar(2000) comment '返回值, 存储json格式',
    cost_time int comment '方法执行耗时, 单位:ms'
) comment '操作日志表';
  • 引入资料中准备的实体类
java 复制代码
package com.itheima.pojo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class OperateLog {
    private Integer id; //ID
    private Integer operateEmpId; //操作人ID
    private LocalDateTime operateTime; //操作时间
    private String className; //操作类名
    private String methodName; //操作方法名
    private String methodParams; //操作方法参数
    private String returnValue; //操作方法返回值
    private Long costTime; //操作耗时
}
  • 引入资料中准备的日志操作Mapper接口
    OperateLogMapper
java 复制代码
package com.itheima.mapper;

import com.itheima.pojo.OperateLog;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface OperateLogMapper {
    
    //插入日志数据
    @Insert("insert into operate_log (operate_emp_id, operate_time, class_name, method_name, method_params, return_value, cost_time) " +
            "values (#{operateEmpId}, #{operateTime}, #{className}, #{methodName}, #{methodParams}, #{returnValue}, #{costTime});")
    public void insert(OperateLog log);
    
}

1). 自定义注解 @LogOperation

java 复制代码
/**
 *  自定义注解,用于标识哪些方法需要记录日志
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogOperation {
}

2). 定义AOP记录日志的切面类

java 复制代码
import com.itheima.anno.LogOperation;
import com.itheima.mapper.OperateLogMapper;
import com.itheima.pojo.OperateLog;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.Arrays;

@Aspect
@Component
public class OperationLogAspect {

    @Autowired
    private OperateLogMapper operateLogMapper;

    // 环绕通知
    @Around("@annotation(log)")
    public Object around(ProceedingJoinPoint joinPoint, LogOperation log) throws Throwable {
        // 记录开始时间
        long startTime = System.currentTimeMillis();
        // 执行方法
        Object result = joinPoint.proceed();
        // 当前时间
        long endTime = System.currentTimeMillis();
        // 耗时
        long costTime = endTime - startTime;

        // 构建日志对象
        OperateLog operateLog = new OperateLog();
        operateLog.setOperateEmpId(getCurrentUserId()); // 需要实现 getCurrentUserId 方法
        operateLog.setOperateTime(LocalDateTime.now());
        operateLog.setClassName(joinPoint.getTarget().getClass().getName());
        operateLog.setMethodName(joinPoint.getSignature().getName());
        operateLog.setMethodParams(Arrays.toString(joinPoint.getArgs()));
        operateLog.setReturnValue(result.toString());
        operateLog.setCostTime(costTime);

        // 插入日志
        operateLogMapper.insert(operateLog);
        return result;
    }
    
    // 示例方法,获取当前用户ID
    private int getCurrentUserId() {
        // 这里应该根据实际情况从认证信息中获取当前登录用户的ID
        return 1; // 示例返回值
    }
}

3). 在需要记录的日志的Controller层的方法上,加上注解 @LogOperation

java 复制代码
@RestController
@RequestMapping("/clazzs")
public class ClazzController {

    @Autowired
    private ClazzService clazzService;

    /**
     * 新增班级
     */
    @LogOperation
    @PostMapping
    public Result save(@RequestBody Clazz clazz){
        clazzService.save(clazz);
        return Result.success();
    }
}    

📚总结:

  • 定义注解:自定义@Log,用于标记要记录日志的方法。
  • 编写切面:AOP 环绕通知拦截带@Log的方法,收集类名、参数、返回值、耗时,把日志存入数据库。
  • 添加注解:在需要记录日志的接口方法上写上@Log。

重启SpringBoot服务,测试操作日志记录功能:

打开浏览器,针对于员工的数据、部门的数据进行增删改之后。我们打开数据库表结构可以来看一下:

我们会看到,在数据库表中,就清晰的记录了谁、什么时间点、调用了哪个类的哪个方法、传入了什么参数、返回了什么数据,都清晰的记录在数据库中了。

3.5 连接点

我们前面在讲解AOP核心概念的时候,我们提到过什么是连接点,连接点可以简单理解为可以被AOP控制的方法。

我们目标对象当中所有的方法是不是都是可以被AOP控制的方法。而在SpringAOP当中,连接点又特指方法的执行。

在Spring中用JoinPoint抽象了连接点,用它可以获得方法执行时的相关信息,如目标类名、方法名、方法参数等。

  • 对于@Around通知,获取连接点信息只能使用ProceedingJoinPoint类型
  • 对于其他四种通知,获取连接点信息只能使用JoinPoint,它是ProceedingJoinPoint的父类型

3.6 获取当前登录员工

  • 员工登录成功后,哪里存储着当前登录员工的信息? 给客户端浏览器下发的jwt令牌中
  • 如何从JWT令牌中获取当前登录用户的信息呢? 获取请求头中传递的jwt令牌,并解析
  • TokenFilter 中已经解析了令牌的信息,如何传递给AOP程序、Controller、Service呢? ThreadLocal
3.6.1 ThreadLocal
  • ThreadLocal 并不是一个Thread,而是Thread的局部变量。
  • ThreadLocal为每个线程提供一份单独的存储空间,具有线程隔离的效果,不同的线程之间不会相互干扰。
  • 常见方法:
    • public void set(T value) 设置当前线程的线程局部变量的值
    • public T get() 返回当前线程所对应的线程局部变量的值
    • public void remove() 移除当前线程的线程局部变量
3.6.2 记录当前登录员工

📚

过滤器解析id,放到threadlocal,

aop程序中threadlocal中获取id。

ps:最后记得删掉,释放空间

具体操作步骤:

  1. 定义ThreadLocal操作的工具类,用于操作当前登录员工ID。
    在 com.itheima.utils 引入工具类 CurrentHolder
java 复制代码
package com.itheima.utils;

public class CurrentHolder {

    private static final ThreadLocal<Integer> CURRENT_LOCAL = new ThreadLocal<>();

    public static void setCurrentId(Integer employeeId) {
        CURRENT_LOCAL.set(employeeId);
    }

    public static Integer getCurrentId() {
        return CURRENT_LOCAL.get();
    }

    public static void remove() {
        CURRENT_LOCAL.remove();
    }
}
  1. 在TokenFilter中,解析完当前登录员工ID,将其存入ThreadLocal(用完之后需将其删除)
java 复制代码
package com.itheima.filter;

import com.itheima.utils.CurrentHolder;
import com.itheima.utils.JwtUtils;
import io.jsonwebtoken.Claims;
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;

@Slf4j
@WebFilter(urlPatterns = "/*")
public class TokenFilter implements Filter {
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        //1. 获取请求的url地址
        String uri = request.getRequestURI(); // /employee/login
        //String url = request.getRequestURL().toString(); // http://localhost:8080/employee/login

        //2. 判断是否是登录请求, 如果url地址中包含 login, 则说明是登录请求, 放行
        if (uri.contains("login")) {
            log.info("登录请求, 放行");
            filterChain.doFilter(request, response);
            return;
        }

        //3. 获取请求中的token
        String token = request.getHeader("token");

        //4. 判断token是否为空, 如果为空, 响应401状态码
        if (token == null || token.isEmpty()) {
            log.info("token为空, 响应401状态码");
            response.setStatus(401); // 响应401状态码
            return;
        }

        //5. 如果token不为空, 调用JWtUtils工具类的方法解析token, 如果解析失败, 响应401状态码
        try {
            Claims claims = JwtUtils.parseJWT(token);
            Integer empId = Integer.valueOf(claims.get("id").toString());
            CurrentHolder.setCurrentId(empId);
            log.info("token解析成功, 放行");
        } catch (Exception e) {
            log.info("token解析失败, 响应401状态码");
            response.setStatus(401);
            return;
        }

        //6. 放行
        filterChain.doFilter(request, response);

        //7. 清空当前线程绑定的id
        CurrentHolder.remove();
    }
}
  1. 在AOP程序中,从ThreadLocal中获取当前登录员工的ID。
java 复制代码
package com.itheima.aop;

import com.itheima.anno.LogOperation;
import com.itheima.mapper.OperateLogMapper;
import com.itheima.pojo.OperateLog;
import com.itheima.utils.CurrentHolder;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.Arrays;

@Aspect
@Component
public class OperationLogAspect {

    @Autowired
    private OperateLogMapper operateLogMapper;

    // 环绕通知
    @Around("@annotation(log)")
    public Object around(ProceedingJoinPoint joinPoint, LogOperation log) throws Throwable {
        // 记录开始时间
        long startTime = System.currentTimeMillis();
        // 执行方法
        Object result = joinPoint.proceed();
        // 当前时间
        long endTime = System.currentTimeMillis();
        // 耗时
        long costTime = endTime - startTime;

        // 构建日志对象
        OperateLog operateLog = new OperateLog();
        operateLog.setOperateEmpId(getCurrentUserId()); // 需要实现 getCurrentUserId 方法
        operateLog.setOperateTime(LocalDateTime.now());
        operateLog.setClassName(joinPoint.getTarget().getClass().getName());
        operateLog.setMethodName(joinPoint.getSignature().getName());
        operateLog.setMethodParams(Arrays.toString(joinPoint.getArgs()));
        operateLog.setReturnValue(result.toString());
        operateLog.setCostTime(costTime);

        // 插入日志
        operateLogMapper.insert(operateLog);
        return result;
    }

    // 示例方法,获取当前用户ID
    private int getCurrentUserId() {
        return CurrentHolder.getCurrentId();
    }
}

代码优化完毕之后,我们重新启动服务测试。就可以看到,可以获取到不同的登录用户信息了。

在同一个线程/同一个请求中,进行数据共享就可以使用 ThreadLocal。

4号员工就是吴用

☀️

作业:完成日志列表查询功能,找不到参考代码,这次是独立自己完成。

controller

java 复制代码
package com.itheima.controller;

import com.itheima.pojo.PageResult;
import com.itheima.pojo.Result;
import com.itheima.service.OperateLogService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RequestMapping("/log")
@RestController
public class OperateLogController {

    @Autowired
    private OperateLogService operateLogService;

    @GetMapping("/page")
    public Result page(@RequestParam(defaultValue = "1") Integer page ,
                       @RequestParam(defaultValue = "10")Integer pageSize) {
        log.info("分页查询操作日志, 页码,页大小: {},{}", page, pageSize);
        PageResult pageResult = operateLogService.page(page, pageSize);
        return Result.success(pageResult);
    }

}

注意请求路径

serviceimpl:

java 复制代码
package com.itheima.service.impl;

import com.github.pagehelper.Page;
import com.github.pagehelper.PageHelper;
import com.itheima.mapper.OperateLogMapper;
import com.itheima.pojo.OperateLog;
import com.itheima.pojo.PageResult;
import com.itheima.service.OperateLogService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class OperateLogServiceImpl implements OperateLogService {

    @Autowired
    private OperateLogMapper operateLogMapper;

    @Override
    public PageResult page(Integer page, Integer pageSize) {
        PageHelper.startPage(page, pageSize);
        List<OperateLog> logList = operateLogMapper.page(page, pageSize);
        Page<OperateLog> p = (Page<OperateLog>) logList;
        return new PageResult(p.getTotal(), p.getResult());
    }

}

mapper:

java 复制代码
@Select("select o.* , e.name operateEmpName from operate_log o join emp e on o.operate_emp_id = e.id")
    List<OperateLog> page(Integer page, Integer pageSize);
}

注意使用外连接查询,

注意响应参数的名字,给查询出来的列起别名

结果:

14-后端Web进阶(SpringBoot原理)

这一章内容好晦涩难懂,没怎么看视频,先自己看笔记过一遍,以后有不懂再回来补

在前面十多天的课程当中,我们学习的都是web开发的技术使用,都是面向应用层面的,我们学会了怎么样去用。而我们今天所要学习的是web后端开发的最后一个篇章springboot原理篇,主要偏向于底层原理。

我们今天的课程安排包括这么三个部分:

  1. 配置优先级

  2. Bean的管理

  3. 剖析Springboot的底层原理

  4. 配置优先级

    在我们前面的课程当中,我们已经讲解了SpringBoot项目当中支持的三类配置文件:

  • application.properties
  • application.yml
  • application.yaml

在SpringBoot项目当中,我们要想配置一个属性,可以通过这三种方式当中的任意一种来配置都可以,那么如果项目中同时存在这三种配置文件,且都配置了同一个属性,如:Tomcat端口号,到底哪一份配置文件生效呢?

  • application.properties
java 复制代码
server.port=8081
  • application.yml
java 复制代码
server:
   port: 8082
  • application.yaml
java 复制代码
server:
   port: 8082

我们启动SpringBoot程序,测试下三个配置文件中哪个Tomcat端口号生效:

  • properties、yaml、yml三种配置文件同时存在。 配置好了,启动服务,测试一下:

    properties、yaml、yml三种配置文件,优先级最高的是properties

  • yaml、yml两种配置文件同时存在

    yaml、yml 两种配置文件,优先级最高的是yml。

    配置文件优先级排名(从高到低):

  1. properties配置文件
  2. yml配置文件
  3. yaml配置文件

✅注意事项:虽然springboot支持多种格式配置文件,但是在项目开发时,推荐统一使用一种格式的配置。(yml是主流)

在SpringBoot项目当中除了以上3种配置文件外,SpringBoot为了增强程序的扩展性,除了支持配置文件的配置方式以外,还支持另外两种常见的配置方式:

  1. Java系统属性配置 (格式: -Dkey=value)
java 复制代码
-Dserver.port=9000
  1. 命令行参数 (格式:--key=value)
java 复制代码
--server.port=10010

📚

  • 1、Java 系统属性:‑Dkey=value
    给谁:JVM(Java 虚拟机)
    "系统" 指的是 Java 运行系统,也就是 JVM。
    -D 是 JVM 的原生参数,JVM 一启动就接收这一组键值对存起来,整个 Java 程序都可以读取。
    SpringBoot 只是去 JVM 里面把这个值拿过来用。
    名字拆解:
    Java(Java 平台) + 系统(JVM 运行环境) + 属性(key‑value 键值)
    👉 Java 系统属性。
  • 2、命令行参数:--key=value
    给谁:SpringBoot 框架
    JVM 根本不认识--xxx,JVM 只会把它当做普通字符串传给 main 方法的String\[\] args。
    然后 SpringBoot 框架自己解析 args 数组,识别--开头的配置,覆盖 yml。
    名字拆解:
    命令行(敲启动命令的时候传入) + 参数(传给 main 函数的参数)
    👉 命令行参数。
  • 通俗大白话
    Java 系统属性 -D:跟 JVM 说话,给虚拟机递参数。
    命令行参数 --:JVM 收到之后转手丢给 main 方法,Spring 自己去解析处理。

那在idea当中运行程序时,如何来指定Java系统属性和命令行参数呢?

  • 编辑启动程序的配置信息

    打开之后,选择 Modify options , 选择 Add VM options, Program arguments

重启服务,同时配置Tomcat端口(application.properties、系统属性、命令行参数),测试哪个Tomcat端口号生效:

说明,命令行参数的优先级时最高的,同时配置的情况下,命令行参数的配置项生效。

删除命令行参数配置,重启SpringBoot服务:

五种配置方式的优先级: 命令行参数 > 系统属性参数 > properties参数 > yml参数 > yaml参数

思考:如果项目已经打包上线了,这个时候我们又如何来设置Java系统属性和命令行参数呢?

java -Dserver.port=9000 -jar XXXXX.jar --server.port=10010

下面我们来演示下打包程序运行时指定Java系统属性和命令行参数:

  1. 执行maven打包指令package,把项目打成jar文件
  2. 使用命令:java -jar 方式运行jar文件程序。
    同时设置Java系统属性和命令行参数

注意事项:

  • Springboot项目进行打包时,需要引入插件 spring-boot-maven-plugin (基于官网骨架创建项目,会自动添加该插件)

在SpringBoot项目当中,常见的属性配置方式 有5种, 3种配置文件,加上2种外部属性的配置(Java系统属性、命令行参数)。通过以上的测试,我们也得出了优先级(从低到高):

  • application.yaml(忽略)
  • application.yml
  • application.properties
  • java系统属性(-Dxxx=xxx)
  • 命令行参数(--xxx=xxx)

⚠️区分开,不要混淆:

  1. -Dxxx=xxx:设置 Java 系统属性,交给 JVM,不会进入 main 方法的 args 数组。读取:System.getProperty("xxx")
  2. --xxx=xxx:Spring 命令行参数,属于 main 函数的 args 数组,JVM 不识别,Spring 自己解析,它不是 Java 系统属性。
    容易搞混的点
  • -D:JVM 原生,Java 系统属性
  • --:Spring 自己的,命令行参数(main 的 args),不属于 Java 系统属性

2. Bean的管理

在前面的课程当中,我们已经讲过了我们可以通过Spring当中提供的注解@Component以及它的三个衍生注解(@Controller、@Service、@Repository)来声明IOC容器中的bean对象,同时我们也学习了如何为应用程序注入运行时所需要依赖的bean对象,也就是依赖注入DI。

我们今天主要学习IOC容器中Bean的其他使用细节,主要学习以下三方面:

  1. bean的作用域配置
  2. 管理第三方的bean对象

接下来我们先来学习第一方面,Bean的作用域。

2.1 Bean的作用域

在前面我们提到的IOC容器当中,默认bean对象是单例的 (只有一个实例对象)。在Spring中支持五种作用域,后三种在web环境才生效:

知道了bean的5种作用域了,我们要怎么去设置一个bean的作用域呢?

  • 可以借助Spring中的@Scope注解来进行配置作用域

    1). 测试一
  • 控制器
java 复制代码
//默认bean的作用域为:singleton (单例)
@RestController
@RequestMapping("/depts")
public class DeptController {

    @Autowired
    private DeptService deptService;

    public DeptController(){
        System.out.println("DeptController constructor ....");
    }

    //省略其他代码...
}
  • 测试类
java 复制代码
@SpringBootTest
class SpringbootWebConfig2ApplicationTests {

    @Autowired
    private ApplicationContext applicationContext; //IOC容器对象

    //bean的作用域
    @Test
    public void testScope(){
        for (int i = 0; i < 10; i++) {
            DeptController deptController = applicationContext.getBean(DeptController.class);
            System.out.println(deptController);
        }
    }
}

重启SpringBoot服务,运行测试方法,查看控制台打印的日志:

注意事项:

  • IOC容器中的bean默认使用的作用域:singleton (单例)
  • 默认singleton的bean,在容器启动时被创建,可以使用@Lazy注解来延迟初始化(延迟到第一次使用时)

2). 测试二

修改控制器DeptController代码:

java 复制代码
@Scope("prototype") //bean作用域为非单例
@RestController
@RequestMapping("/depts")
public class DeptController {

    @Autowired
    private DeptService deptService;

    public DeptController(){
        System.out.println("DeptController constructor ....");
    }

    //省略其他代码...
}

重启SpringBoot服务,再次执行测试方法,查看控制吧打印的日志:

✅注意事项:

  • prototype的bean,每一次使用该bean的时候都会创建一个新的实例

  • 实际开发当中,绝大部分的Bean是单例的,也就是说绝大部分Bean不需要配置scope属性
    📍

  • 默认singleton的bean,在容器启动时被创建,可以使用@Lazy注解来延迟初始化(延迟到第一次使用时)

  • prototype的bean,每一次使用该bean的时候都会创建一个新的实例。

  • 实际开发当中,绝大部分的bean是单例的,也就是说绝大部分bean不需要配置scope属性

📚自己总结:

  • 默认@Scope("singleton")单例:容器启动就造 1 个对象;@Lazy实现延迟创建。
  • @Scope("prototype")多例:每次获取都新建对象。
  • 项目绝大部分 Bean 直接使用默认单例,不需要配置。

2.2 第三方Bean

学习完bean的获取、bean的作用域之后,接下来我们再来学习第三方bean的配置。

之前我们所配置的bean,像controller、service,dao三层体系下编写的类,这些类都是我们在项目当中自己定义的类(自定义类)。当我们要声明这些bean,也非常简单,我们只需要在类上加上@Component以及它的这三个衍生注解(@Controller、@Service、@Repository),就可以来声明这个bean对象了。

但是在我们项目开发当中,还有一种情况就是这个类它不是我们自己编写的, 而是我们引入的第三方依赖当中提供的,那么此时我们是无法使用 @Component 及其衍生注解来声明bean的, 此时就需要使用@Bean注解来声明bean 了。

演示1:

  • 启动类中直接声明这个Bean。比如:我们可以将我们之前使用的阿里云OSS操作的工具类,基于@Bean注解的方式来声明Bean。
java 复制代码
import com.itheima.utils.AliyunOSSOperator;
import com.itheima.utils.AliyunOSSProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.ServletComponentScan;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableScheduling;

@ServletComponentScan
@EnableScheduling
@SpringBootApplication
public class TliasWebManagementApplication {

    public static void main(String[] args) {
        SpringApplication.run(TliasWebManagementApplication.class, args);
    }

    @Bean
    public AliyunOSSOperator aliyunOSSOperator(AliyunOSSProperties ossProperties) {
        return new AliyunOSSOperator(ossProperties);
    }
}
  • 测试:
java 复制代码
package com.itheima;

import cn.hutool.core.io.FileUtil;
import com.itheima.utils.AliyunOSSOperator;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.io.File;
import java.util.List;
import java.util.Set;

@SpringBootTest
class TliasWebManagementApplicationTests {

    @Autowired
    private AliyunOSSOperator aliyunOSSOperator;

    @Test
    void testUploadFiles() throws Exception {
        // 上传文件, 本地文件 C:\Users\deng\Pictures\6.jpg
        byte[] content = FileUtil.readBytes(new File("C:\\Users\\deng\\Pictures\\6.jpg"));
        String url = aliyunOSSOperator.upload(content, "6.jpg");
        System.out.println(url);
    }

    @Test
    void testListFiles() throws Exception {
        // 获取文件列表
        List<String> objectNameList = aliyunOSSOperator.listFiles();
        objectNameList.forEach(System.out::println);
    }

    @Test
    void testDelFiles() throws Exception {
        // 删除文件
        aliyunOSSOperator.deleteFile("2024/06/43b48632-3a05-4e1d-a00d-96f2d57b2a84.jpg");
    }

}

演示2:

若要管理的第三方 bean 对象,建议对这些bean进行集中分类配置,可以通过 @Configuration 注解声明一个配置类。【推荐】

java 复制代码
package com.itheima.config;

import com.itheima.utils.AliyunOSSOperator;
import com.itheima.utils.AliyunOSSProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class OSSConfig {
    @Bean
    public AliyunOSSOperator aliyunOSSOperator(AliyunOSSProperties ossProperties) {
        return new AliyunOSSOperator(ossProperties);
    }
}

📍

  • 通过@Bean注解的name 或 value属性可以声明bean的名称,如果不指定,默认bean的名称就是方法名。
  • 如果第三方bean需要依赖其他bean对象,直接在bean定义方法中设置形参即可,容器会根据类型自动装配。

📚自己总结:

  • 场景:第三方依赖的类,源码不在自己项目里,不能加@Component/@Service等注解,用@Bean把它交给 IOC 容器管理。

  • 两种写法

    • 方式 1:直接写在启动类里面,不推荐;
    • 方式 2【推荐】 :新建配置类,类上加@Configuration,在类内部写@Bean方法。
  • @Bean规则

    • Bean 默认名称:方法名,也可以通过name/value自定义名字。
    • 如果这个第三方 Bean 需要依赖别的 Bean,直接把依赖写为方法形参,Spring 自动完成依赖注入。
  • 使用:@Autowired直接注入这个第三方 Bean 即可使用。

3. SpringBoot原理

经过前面10多天课程的学习,大家也会发现基于SpringBoot进行web程序的开发是非常简单、非常高效的。

SpringBoot使我们能够集中精力地去关注业务功能的开发而不用过多地关注框架本身的配置使用 。而我们前面所讲解的都是面向应用层面的技术,接下来我们开始学习SpringBoot的原理,这部分内容偏向于底层的原理分析。

在剖析SpringBoot的原理之前,我们先来快速回顾一下我们前面所讲解的Spring家族的框架。

Spring是目前世界上最流行的Java框架,它可以帮助我们更加快速、更加容易的来构建Java项目。而在Spring家族当中提供了很多优秀的框架,而所有的框架都是基于一个基础框架的SpringFramework(也就是Spring框架)。而前面我们也提到,如果我们直接基于Spring框架进行项目的开发,会比较繁琐。

这个繁琐主要体现在两个地方:

  1. 在pom.xml中依赖配置比较繁琐,在项目开发时,需要自己去找到对应的依赖,还需要找到依赖它所配套的依赖以及对应版本,否则就会出现版本冲突问题。
  2. 在使用Spring框架进行项目开发时,需要在Spring的配置文件中做大量的配置 ,这就造成Spring框架入门难度较大,学习成本较高。
  • 基于Spring存在的问题,官方在Spring框架4.0版本之后,又推出了一个全新的框架:SpringBoot
  • 通过 SpringBoot来简化Spring框架的开发(是简化不是替代)。我们直接基于SpringBoot来构建Java项目,会让我们的项目开发更加简单,更加快捷。

SpringBoot框架之所以使用起来更简单更快捷,是因为SpringBoot框架底层提供了两个非常重要的功能:一个是起步依赖,一个是自动配置

  • 通过SpringBoot所提供的起步依赖,就可以大大的简化pom文件当中依赖的配置,从而解决了Spring框架当中依赖配置繁琐的问题。
  • 通过自动配置的功能就可以大大的简化框架在使用时bean的声明以及bean的配置。我们只需要引入程序开发时所需要的起步依赖,项目开发时所用到常见的配置都已经有了,我们直接使用就可以了。

📚通俗解释 + 例子

1、起步依赖(starter)

⭐️作用 :把一堆相关 jar 包打包在一起,不用自己一个个写版本、一个个引入依赖。

🍎例子:

❌做 web 开发,Spring 原生要手动引入 spring‑web、spring‑mvc、tomcat 等一大堆依赖,还要自己管理版本。

✅SpringBoot 直接引入:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring‑boot‑starter‑web</artifactId> </dependency>

引入这一个 starter,web 需要的所有 jar 全部自动导入,版本由 SpringBoot 统一管理,不用手写版本号。

2、自动配置(AutoConfiguration)

⭐️作用 :引入 starter 之后,SpringBoot 自动帮你创建好一批 Bean 放到 IOC 容器,我们不用自己写@Bean、不用写大量 xml 配置,直接 @Autowired 拿来用。

🍎例子:

引入spring‑boot‑starter‑web起步依赖。

  • SpringBoot 自动配置:自动创建 DispatcherServlet、内置 Tomcat 服务器等 Bean;
  • 我们不用自己 new、不用写配置,直接写 Controller 就可以接收请求。

🍎再举 MyBatis 例子:

引入mybatis‑spring‑boot‑starter

自动帮你创建 SqlSession、Mapper 代理对象这些 Bean,我们直接写 Mapper 接口就能用,不用手动配置 SqlSessionFactory。

3.1 起步依赖

假如我们没有使用SpringBoot,用的是Spring框架进行web程序的开发,此时我们就需要引入web程序开发所需要的一些依赖。

当我们引入了 spring-boot-starter-web 之后,maven会通过依赖传递特性,将web开发所需的常见依赖都传递下来。

所以,起步依赖的原理就是Maven的依赖传递

  • 在SpringBoot给我们提供的这些起步依赖当中,已提供了当前程序开发所需要的所有的常见依赖(官网地址:https://docs.spring.io/spring-boot/docs/2.7.7/reference/htmlsingle/#using.build-systems.starters)。
  • 比如:springboot-starter-web,这是web开发的起步依赖,在web开发的起步依赖当中,就集成了web开发中常见的依赖:json、web、webmvc、tomcat等。我们只需要引入这一个起步依赖,其他的依赖都会自动的通过Maven的依赖传递进来。

3.2 自动配置

SpringBoot的自动配置就是当spring容器 启动后,一些配置类、bean对象就自动存入到了IOC容器中 ,不需要我们手动去声明,从而简化了开发,省去了繁琐的配置操作。

比如,在我们前面讲解AOP记录日志的那个案例中,我们要将一个对象转为json,直接注入一个Gson,然后就可以直接使用了。而我们在我们整个项目中,也并未配置Gson这个类型的bean,为什么可以直接注入使用呢? 原因就是因为这个bean,springboot中已经帮我们自动配置完毕了,我们是可以直接使用的。

那接下来,我们就要来解析,SpringBoot中到底是如何完成自动配置的。

3.2.1 实现方案

我们知道了什么是自动配置之后,接下来我们就要来剖析自动配置的原理。解析自动配置的原理就是分析在 SpringBoot项目当中,我们引入对应的依赖之后,是如何将依赖jar包当中所提供的bean以及配置类直接加载到当前项目的SpringIOC容器当中的

接下来,我们就直接通过代码来分析自动配置原理。

  • 准备工作:在Idea中导入"资料\03. 自动配置原理" 下的 itheima-utils 工程
  • 在SpringBoot项目 spring-boot-web-config 工程中,通过坐标引入itheima-utils依赖
    1、引入的 itheima-utils 中配置如下:
java 复制代码
@Component
public class TokenParser {
    public void parse(){
        System.out.println("TokenParser ... parse ...");
    }
}

2、在测试类中,添加测试方法

java 复制代码
@SpringBootTest
public class AutoConfigurationTests {
    @Autowired
    private ApplicationContext applicationContext;

    @Test
    public void testTokenParse(){
        System.out.println(applicationContext.getBean(TokenParser.class));
    }

    //省略其他代码...
}

3、执行测试方法

异常信息描述: 没有com.example.TokenParse类型的bean

说明:在Spring容器中没有找到com.example.TokenParse类型的bean对象

思考:引入进来的第三方依赖当中的bean以及配置类为什么没有生效?

  • 原因在我们之前讲解IOC的时候有提到过,在类上添加@Component注解来声明bean对象时,还需要保证@Component注解能被Spring的组件扫描到。
  • SpringBoot项目中的@SpringBootApplication注解,具有包扫描的作用,但是它只会扫描启动类所在的当前包以及子包。
  • 当前包:com.itheima, 第三方依赖中提供的包:com.example(扫描不到)

那么如何解决以上问题的呢?

  • 方案1:@ComponentScan 组件扫描
  • 方案2:@Import 导入(使用@Import导入的类会被Spring加载到IOC容器中)
3.2.1.1 方案一

@ComponentScan组件扫描

java 复制代码
@SpringBootApplication
@ComponentScan({"com.itheima","com.example"}) //指定要扫描的包
public class SpringbootWebConfigApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringbootWebConfigApplication.class, args);
    }
}

重新执行测试方法,控制台日志输出:

👀大家可以想象一下,如果采用以上这种方式来完成自动配置,那我们进行项目开发时,当需要引入大量的第三方的依赖,就需要在启动类上配置N多要扫描的包,这种方式会很繁琐。而且这种大面积的扫描性能也比较低。

缺点:

  1. 使用繁琐
  2. 性能低

结论:SpringBoot中并没有采用以上这种方案。

3.2.1.2 方案二

@Import导入

  • 导入形式主要有以下几种:
    1. 导入普通类
    2. 导入配置类
    3. 导入ImportSelector接口实现类

1). 使用@Import导入普通类:

java 复制代码
@Import(TokenParser.class) //导入的类会被Spring加载到IOC容器中
@SpringBootApplication
public class SpringbootWebConfigApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringbootWebConfigApplication.class, args);
    }
}

重新执行测试方法,控制台日志输出:

2). 使用@Import导入配置类:

  • 配置类
java 复制代码
@Configuration
public class HeaderConfig {
    @Bean
    public HeaderParser headerParser(){
        return new HeaderParser();
    }

    @Bean
    public HeaderGenerator headerGenerator(){
        return new HeaderGenerator();
    }
}
  • 启动类
java 复制代码
@Import(HeaderConfig.class) //导入配置类
@SpringBootApplication
public class SpringbootWebConfig2Application {
    public static void main(String[] args) {
        SpringApplication.run(SpringbootWebConfig2Application.class, args);
    }
}
  • 测试类
java 复制代码
@SpringBootTest
public class AutoConfigurationTests {
    @Autowired
    private ApplicationContext applicationContext;

    @Test
    public void testHeaderParser(){
        System.out.println(applicationContext.getBean(HeaderParser.class));
    }

    @Test
    public void testHeaderGenerator(){
        System.out.println(applicationContext.getBean(HeaderGenerator.class));
    }
    
    //省略其他代码...
}

执行测试方法:

3). 使用@Import导入ImportSelector接口实现类:

  • ImportSelector接口实现类
java 复制代码
public class MyImportSelector implements ImportSelector {
   public String[] selectImports(AnnotationMetadata importingClassMetadata) {
       //返回值字符串数组(数组中封装了全限定名称的类)
       return new String[]{"com.example.HeaderConfig"};
   }
}
  • 启动类
java 复制代码
@Import(MyImportSelector.class) //导入ImportSelector接口实现类
@SpringBootApplication
public class SpringbootWebConfig2Application {
    public static void main(String[] args) {
        SpringApplication.run(SpringbootWebConfig2Application.class, args);
    }
}

执行测试方法:

我们使用@Import注解通过这三种方式都可以导入第三方依赖中所提供的bean或者是配置类。

思考:如果基于以上方式完成自动配置,当要引入一个第三方依赖时 ,是不是还要知道第三方依赖中有哪些配置类和哪些Bean对象

  • 答案:是的。 (对程序员来讲,很不友好,而且比较繁琐)

思考:当我们要使用第三方依赖,依赖中到底有哪些bean和配置类,谁最清楚?

  • 答案:第三方依赖自身最清楚。

⭐️结论:我们不用自己指定要导入哪些bean对象和配置类了,让第三方依赖它自己来指定。

怎么让第三方依赖自己指定bean对象和配置类?

  • 比较常见的方案就是第三方依赖给我们提供一个注解,这个注解一般都以@EnableXxxx开头的注解,注解中封装的就是@Import注解

4). 使用第三方依赖提供的 @EnableXxxxx注解

  • 第三方依赖中提供的注解
java 复制代码
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Import(MyImportSelector.class)//指定要导入哪些bean对象或配置类
public @interface EnableHeaderConfig { 
}
  • 在使用时只需在启动类上加上@EnableXxxxx注解即可
java 复制代码
@EnableHeaderConfig  //使用第三方依赖提供的Enable开头的注解
@SpringBootApplication
public class SpringbootWebConfig2Application {
    public static void main(String[] args) {
        SpringApplication.run(SpringbootWebConfig2Application.class, args);
    }
}

执行测试方法:

以上四种方式都可以完成导入操作,但是第4种方式会更方便更优雅,而这种方式也是SpringBoot当中所采用的方式。

💩不懂不懂

3.2.2 原理分析
3.2.2.1 源码跟踪

前面我们讲解了在项目当中引入第三方依赖之后,如何加载第三方依赖中定义好的bean对象以及配置类,从而完成自动配置操作。那下面我们通过源码跟踪的形式来剖析下SpringBoot底层到底是如何完成自动配置的。

源码跟踪技巧:

在跟踪框架源码的时候,一定要抓住关键点,找到核心流程。一定不要从头到尾一行代码去看,一个方法的去研究,一定要找到关键流程,抓住关键点,先在宏观上对整个流程或者整个原理有一个认识,有精力再去研究其中的细节。

要搞清楚SpringBoot的自动配置原理,要从SpringBoot启动类上使用的核心注解@SpringBootApplication开始分析:

在@SpringBootApplication注解中包含了:

  • 元注解(不再解释)

  • @SpringBootConfiguration

  • @EnableAutoConfiguration

  • @ComponentScan

  • 我们先来看第一个注解:@SpringBootConfiguration

@SpringBootConfiguration注解上使用了@Configuration,表明SpringBoot启动类就是一个配置类。

@Indexed注解,是用来加速应用启动的(不用关心)。

  • 接下来再先看@ComponentScan注解:

@ComponentScan注解是用来进行组件扫描的,扫描启动类所在的包及其子包下所有被@Component及其衍生注解声明的类。

SpringBoot启动类,之所以具备扫描包功能,就是因为包含了@ComponentScan注解。

  • 最后我们来看看@EnableAutoConfiguration注解(自动配置核心注解):

    使用@Import注解,导入了实现ImportSelector接口的实现类。
    AutoConfigurationImportSelector类是ImportSelector接口的实现类。

AutoConfigurationImportSelector类中重写了ImportSelector接口的selectImports()方法:

selectImports()方法底层调用getAutoConfigurationEntry()方法,获取可自动配置的配置类信息集合

getCandidateConfigurations方法的功能: 获取所有基于

META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件中配置类的集合

META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件这两个文件在哪里呢?

  • 通常在引入的起步依赖中,都有包含以上文件
    在前面在给大家演示自动配置的时候,我们直接在测试类当中注入了一个叫gson的bean对象,进行JSON格式转换。虽然我们没有配置bean对象,但是我们是可以直接注入使用的。原因就是因为在自动配置类当中做了自动配置。到底是在哪个自动配置类当中做的自动配置呢?我们通过搜索来查询一下。

在META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 配置文件中指定了第三方依赖Gson的配置类:GsonAutoConfiguration

打开上面的第三方依赖中提供的 GsonAutoConfiguration 类:

在GsonAutoConfiguration类上,添加了注解@AutoConfiguration,通过查看源码,可以明确:GsonAutoConfiguration 类是一个配置。

看到这里,大家就应该明白为什么可以完成自动配置了,原理就是在配置类中定义一个@Bean标识的方法,而Spring会自动调用配置类中使用@Bean标识的方法,并把方法的返回值注册到IOC容器中。

自动配置源码小结

自动配置原理源码入口就是 @SpringBootApplication 注解,在这个注解中封装了3个注解,分别是:

  • @SpringBootConfiguration
    • 声明当前类是一个配置类
  • @ComponentScan
    • 进行组件扫描(SpringBoot中默认扫描的是启动类所在的当前包及其子包)
  • @EnableAutoConfiguration
    • 封装了@Import注解(Import注解中指定了一个ImportSelector接口的实现类)
      在实现类重写的selectImports()方法,读取当前项目下所有依赖jar包中META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports两个文件里面定义的配置类(配置类中定义了@Bean注解标识的方法)。

当SpringBoot程序启动时,就会加载配置文件当中所定义的配置类,并将这些配置类信息(类的全限定名)封装到String类型的数组中,最终通过@Import注解将这些配置类全部加载到Spring的IOC容器中,交给IOC容器管理。

最后呢给大家抛出一个问题:在

META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

文件中定义的配置类非常多,而且每个配置类中又可以定义很多的bean,那这些bean都会注册到Spring的IOC容器中吗? 答案:并不是。

在声明bean对象时,上面有加一个以 @Conditional

开头的注解,这种注解的作用就是按照条件进行装配,只有满足条件之后,才会将bean注册到Spring的IOC容器中(下面会详细来讲解)
💩不知道有什么用,笔记和视频都没看,看不了一点

3.2.2.2 @Conditional

我们在跟踪SpringBoot自动配置的源码的时候,在自动配置类声明bean的时候,除了在方法上加了一个@Bean注解以外,还会经常用到一个注解,就是以Conditional开头的这一类的注解。以Conditional开头的这些注解都是条件装配的注解。下面我们就来介绍下条件装配注解。

@Conditional注解:

  • 作用:按照一定的条件进行判断,在满足给定条件后才会注册对应的bean对象到Spring的IOC容器中。
  • 位置:方法、类
  • @Conditional本身是一个父注解,派生出大量的子注解:
    • @ConditionalOnClass:判断环境中有对应字节码文件,才注册bean到IOC容器。
    • @ConditionalOnMissingBean:判断环境中没有对应的bean(类型或名称),才注册bean到IOC容器。
    • @ConditionalOnProperty:判断配置文件中有对应属性和值,才注册bean到IOC容器。

下面我们通过代码来演示下Conditional注解的使用:

  • @ConditionalOnClass注解
java 复制代码
@Configuration
public class HeaderConfig {

    @Bean
    @ConditionalOnClass(name="io.jsonwebtoken.Jwts")//环境中存在指定的这个类,才会将该bean加入IOC容器
    public HeaderParser headerParser(){
        return new HeaderParser();
    }
    
    //省略其他代码...
}
  • pom.xml
java 复制代码
<!--JWT令牌-->
<dependency>
     <groupId>io.jsonwebtoken</groupId>
     <artifactId>jjwt</artifactId>
     <version>0.9.1</version>
</dependency>
  • 测试类
java 复制代码
@SpringBootTest
public class AutoConfigurationTests {
    @Autowired
    private ApplicationContext applicationContext;

    @Test
    public void testHeaderParser(){
        System.out.println(applicationContext.getBean(HeaderParser.class));
    }
    
    //省略其他代码...
}

执行testHeaderParser()测试方法:

SpringBoot在调用@Bean标识的headerParser()前,IOC容器中是没有HeaderParser类型的bean,所以HeaderParser对象正常创建,并注册到IOC容器中。

  • 再次修改@ConditionalOnMissingBean注解
java 复制代码
@Configuration
public class HeaderConfig {

    @Bean
    @ConditionalOnMissingBean//不存在指定类型的bean,才会将该bean加入IOC容器
    public HeaderParser headerParser(){
        return new HeaderParser();
    }
    
    //省略其他代码...
}

执行testHeaderParser()测试方法:

  • @ConditionalOnProperty注解(这个注解和配置文件当中配置的属性有关系)
    先在application.yml配置文件中添加如下的键值对:
java 复制代码
name: itheima

在声明bean的时候就可以指定一个条件

java 复制代码
@ConditionalOnProperty
@Configuration
public class HeaderConfig {

    @Bean
    @ConditionalOnProperty(name ="name",havingValue = "itheima")//配置文件中存在指定属性名与值,才会将bean加入IOC容器
    public HeaderParser headerParser(){
        return new HeaderParser();
    }

    @Bean
    public HeaderGenerator headerGenerator(){
        return new HeaderGenerator();
    }
}

执行testHeaderParser()测试方法:

修改@ConditionalOnProperty注解: havingValue的值修改为"itheima2"

java 复制代码
@Bean
@ConditionalOnProperty(name ="name",havingValue = "itheima2")//配置文件中存在指定属性名与值,才会将bean加入IOC容器
public HeaderParser headerParser(){
        return new HeaderParser();
}

再次执行testHeaderParser()测试方法:

因为 application.yml 配置文件中,不存在: name:

itheima2,所以HeaderParser对象在IOC容器中不存在

我们再回头看看之前讲解SpringBoot源码时提到的一个配置类:GsonAutoConfiguration

最后再给大家梳理一下自动配置原理:

自动配置的核心就在@SpringBootApplication注解上,SpringBootApplication这个注解底层包含了3个注解,分别是:

  • @SpringBootConfiguration
  • @ComponentScan
  • @EnableAutoConfiguration

@EnableAutoConfiguration这个注解才是自动配置的核心。

  • 它封装了一个@Import注解,Import注解里面指定了一个ImportSelector接口的实现类。
  • 在这个实现类中,重写了ImportSelector接口中的selectImports()方法。
  • 而selectImports()方法中会去读取两份配置文件,并将配置文件中定义的配置类做为selectImports()方法的返回值返回,返回值代表的就是需要将哪些类交给Spring的IOC容器进行管理。
  • 那么所有自动配置类的中声明的bean都会加载到Spring的IOC容器中吗? 其实并不会,因为这些配置类中在声明bean时,通常都会添加@Conditional开头的注解,这个注解就是进行条件装配。而Spring会根据Conditional注解有选择性的进行bean的创建。
  • @Enable 开头的注解底层,它就封装了一个注解 import 注解,它里面指定了一个类,是 ImportSelector 接口的实现类。在实现类当中,我们需要去实现 ImportSelector 接口当中的一个方法 selectImports 这个方法。这个方法的返回值代表的就是我需要将哪些类交给 spring 的 IOC容器进行管理。
  • 此时它会去读取两份配置文件,一份儿是 spring.factories,另外一份儿是 autoConfiguration.imports。而在 autoConfiguration.imports 这份儿文件当中,它就会去配置大量的自动配置的类。
  • 而前面我们也提到过这些所有的自动配置类当中,所有的 bean都会加载到 spring 的 IOC 容器当中吗?其实并不会,因为这些配置类当中,在声明 bean 的时候,通常会加上这么一类@Conditional 开头的注解。这个注解就是进行条件装配。所以SpringBoot非常的智能,它会根据 @Conditional 注解来进行条件装配。只有条件成立,它才会声明这个bean,才会将这个 bean 交给 IOC 容器管理。

💩一点没看

3.2.3 自定义starter
3.2.3.1 分析

前面我们解析了SpringBoot中自动配置的原理,下面我们就通过一个自定义starter案例来加深大家对于自动配置原理的理解。首先介绍一下自定义starter的业务场景,再来分析一下具体的操作步骤。

所谓starter指的就是SpringBoot当中的起步依赖。在SpringBoot当中已经给我们提供了很多的起步依赖了,我们为什么还需要自定义 starter 起步依赖?

这是因为在实际的项目开发当中,我们可能会用到很多第三方的技术,并不是所有的第三方的技术官方都给我们提供了与SpringBoot整合的starter起步依赖,但是这些技术又非常的通用,在很多项目组当中都在使用。

业务场景:

  • 我们前面案例当中所使用的阿里云OSS对象存储服务,现在阿里云的官方是没有给我们提供对应的起步依赖的,这个时候使用起来就会比较繁琐,我们需要引入对应的依赖。我们还需要在配置文件当中进行配置,还需要基于官方SDK示例来改造对应的工具类,我们在项目当中才可以进行使用。
  • 大家想在我们当前项目当中使用了阿里云OSS,我们需要进行这么多步的操作。在别的项目组当中要想使用阿里云OSS,是不是也需要进行这么多步的操作,所以这个时候我们就可以自定义一些公共组件,在这些公共组件当中,我就可以提前把需要配置的bean都提前配置好。将来在项目当中,我要想使用这个技术,我直接将组件对应的坐标直接引入进来,就已经自动配置好了,就可以直接使用了。我们也可以把公共组件提供给别的项目组进行使用,这样就可以大大的简化我们的开发。
    在SpringBoot项目中,一般都会将这些公共组件封装为SpringBoot当中的starter,也就是我们所说的起步依赖。

而在springboot中,官方提供的起步依赖 或 第三方提供的起步依赖,基本都会包含两个模块,如下所示:

其中,spring-boot-starter 或 xxx-spring-boot-starter 这个模块主要是依赖管理的功能。 而 spring-boot-autoconfigure 或 xxxx-spring-boot-autoconfigure 主要是起到自动配置的作用,自动配置的核心代码就在这个模块中编写。

SpringBoot官方starter命名: spring-boot-starter-xxxx

第三组织提供的starter命名: xxxx-spring-boot-starter

而自动配置模块的核心,就是编写自动配置的核心代码,然后将自动配置的核心类,配置在核心的配置文件 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 中。 配置如下:

SpringBoot官方的自动配置依赖 spring-boot-autoconfiure

中就提供了配置类,并且也提供了springboot会自动读取的配置文件。当SpringBoot项目启动时,会读取到META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports配置文件中的配置类并加载配置类,生成相关bean对象注册到IOC容器中。

结果:我们可以直接在SpringBoot程序中使用自动配置的bean对象。

在自定义一个起步依赖starter的时候,按照规范需要定义两个模块:

  1. starter模块(进行依赖管理把程序开发所需要的依赖都定义在starter起步依赖中
  2. autoconfigure模块(自动配置)

将来在项目当中进行相关功能开发时,只需要引入一个起步依赖就可以了,因为它会将autoconfigure自动配置的依赖给传递下来。

3.2.3.2 需求

上面我们简单介绍了自定义starter的场景,以及自定义starter时涉及到的模块之后,接下来我们就来完成一个自定义starter的案例。

  • 需求:自定义aliyun-oss-spring-boot-starter,完成阿里云OSS操作工具类 AliyunOSSOperator 的自动配置。
  • 目标:引入起步依赖引入之后,要想使用阿里云OSS,注入AliyunOSSOperator 直接使用即可。

之前我们的用法:

1). 在pom.xml中引入阿里云oss的所有依赖

xml 复制代码
<!--阿里云OSS-->
<dependency>
    <groupId>com.aliyun.oss</groupId>
    <artifactId>aliyun-sdk-oss</artifactId>
    <version>3.17.4</version>
</dependency>
<dependency>
    <groupId>javax.xml.bind</groupId>
    <artifactId>jaxb-api</artifactId>
    <version>2.3.1</version>
</dependency>
<dependency>
    <groupId>javax.activation</groupId>
    <artifactId>activation</artifactId>
    <version>1.1.1</version>
</dependency>
<!-- no more than 2.3.3-->
<dependency>
    <groupId>org.glassfish.jaxb</groupId>
    <artifactId>jaxb-runtime</artifactId>
    <version>2.3.3</version>
</dependency>

2). application.yml 中配置阿里云OSS的配置信息

yaml 复制代码
#阿里云oss配置
aliyun:
  oss:
    endpoint: https://oss-cn-beijing.


aliyuncs.com
    bucketName: java422-web-ai

3). 定义实体类封装配置信息

java 复制代码
package com.itheima.utils;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Data
@Component
@ConfigurationProperties(prefix = "aliyun.oss")
public class AliyunOSSProperties {
    private String endpoint;
    private String bucketName;
}

4). 定义工具类AliyunOSSOperator

java 复制代码
package com.itheima.utils;

import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.common.auth.CredentialsProviderFactory;
import com.aliyun.oss.common.auth.EnvironmentVariableCredentialsProvider;
import com.aliyun.oss.model.OSSObjectSummary;
import com.aliyun.oss.model.ObjectListing;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.io.ByteArrayInputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;

@Component
public class AliyunOSSOperator {

    @Autowired
    private AliyunOSSProperties aliyunOSSProperties;

    /**
     * 文件上传
     */
    public String upload(byte[] content, String originalFilename) throws Exception {
        String endpoint = aliyunOSSProperties.getEndpoint();
        String bucketName = aliyunOSSProperties.getBucketName();

        // 从环境变量中获取访问凭证。运行本代码示例之前,请确保已设置环境变量OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。
        EnvironmentVariableCredentialsProvider credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();

        // 填写Object完整路径,例如202406/1.png。Object完整路径中不能包含Bucket名称。
        //获取当前系统日期的字符串,格式为 yyyy/MM
        String dir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM"));
        //根据原始文件名originalFilename, 生成一个新的不重复的文件名
        String newFileName = UUID.randomUUID().toString() + originalFilename.substring(originalFilename.lastIndexOf("."));
        String objectName = dir + "/" + newFileName;

        // 创建OSSClient实例。
        OSS ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);

        //文件上传
        try {
            ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(content));
        } finally {
            if (ossClient != null) {
                ossClient.shutdown();
            }
        }

        return endpoint.split("//")[0] + "//" + bucketName + "." + endpoint.split("//")[1] + "/" + objectName;
    }


    /**
     * 查询文件列表
     */
    public List<String> listFiles() throws Exception {
        String endpoint = aliyunOSSProperties.getEndpoint();
        String bucketName = aliyunOSSProperties.getBucketName();
        // 从环境变量中获取访问凭证。运行本代码示例之前,请确保已设置环境变量OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。
        EnvironmentVariableCredentialsProvider credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();
        // 指定前缀,例如exampledir/object。
        String keyPrefix = null;

        // 创建OSSClient实例。
        OSS ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);

        try {
            // 列举文件。如果不设置keyPrefix,则列举存储空间下的所有文件。如果设置keyPrefix,则列举包含指定前缀的文件。
            ObjectListing objectListing = ossClient.listObjects(bucketName, keyPrefix);
            List<OSSObjectSummary> sums = objectListing.getObjectSummaries();
            if(sums != null && !sums.isEmpty()){
                return sums.stream().map(OSSObjectSummary::getKey).collect(Collectors.toList());
            }
        } finally {
            if (ossClient != null) {
                ossClient.shutdown();
            }
        }
        return null;
    }

    /**
     * 删除指定对象
     */
    public void deleteFile(String objectName) throws Exception {
        String endpoint = aliyunOSSProperties.getEndpoint();
        String bucketName = aliyunOSSProperties.getBucketName();

        // 从环境变量中获取访问凭证。运行本代码示例之前,请确保已设置环境变量OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。
        EnvironmentVariableCredentialsProvider credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();
        // 创建OSSClient实例。
        OSS ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);

        try {
            // 删除文件或目录。如果要删除目录,目录必须为空。
            ossClient.deleteObject(bucketName, objectName);
        } finally {
            if (ossClient != null) {
                ossClient.shutdown();
            }
        }
    }

}

5). 其他地方要使用阿里云OSS,注入工具类,再使用

java 复制代码
@Slf4j
@RestController
public class UploadController {
    @Autowired
    private AliyunOSSOperator aliyunOSSOperator;

    /**
     * 文件上传
     */
    @PostMapping("/upload")
    public Result upload(MultipartFile file) throws Exception {
        log.info("上传文件:{}",file.getOriginalFilename());
        //调用aliyun OSS进行文件上传
        String url = aliyunOSSOperator.upload(file.getBytes(), file.getOriginalFilename());
        //返回结果
        return Result.success(url);
    }
}

我们可以看到,在项目中使用阿里云OSS,需要这么五步操作,而阿里云OSS这个云服务还是非常常见的,很多项目中都要使用。

大家再思考,现在我们使用阿里云OSS,需要做这么几步,将来大家在开发其他的项目的时候,你使用阿里云OSS,这几步你要不要做?当团队中其他小伙伴也在使用阿里云OSS的时候,步骤 不也是一样的。

所以这个时候我们就可以制作一个公共组件(自定义starter)。starter定义好之后,将来要使用阿里云OSS进行文件上传,只需要将起步依赖引入进来之后,就可以直接注入 AliyunOSSOperator 使用了。

3.2.3.3 实现

需求明确了,接下来我们再来分析一下具体的实现步骤:

  • 第1步:创建自定义starter模块 aliyun-oss-spring-boot-starter(进行依赖管理)
    • 把阿里云OSS所有的依赖统一管理起来
  • 第2步:创建autoconfigure模块 aliyun-oss-spring-boot-autoconfigure
    • 在starter中引入autoconfigure (我们使用时只需要引入starter起步依赖即可)
  • 第3步:在autoconfigure模块aliyun-oss-spring-boot-autoconfigure中完成自动配置
    1. 定义一个自动配置类,在自动配置类中将所要配置的bean都提前配置好
    2. 定义配置文件,把自动配置类的全类名定义在配置文件(META-INF/spring/xxxx.imports)中

我们分析完自定义阿里云OSS自动配置的操作步骤了,下面我们就按照分析的步骤来实现自定义starter。

首先我们先来创建两个Maven模块:

1). 创建 aliyun-oss-spring-boot-starter

选择springboot的版本,不需要勾选任何的依赖。直接点击 create 创建项目。

创建完starter模块后,删除多余的文件,只保留一个pom.xml文件。最终保留内容如下:

pom.xml 中的配置如下:

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.8</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <groupId>com.aliyun.oss</groupId>
    <artifactId>aliyun-oss-spring-boot-starter</artifactId>
    <version>0.0.1-SNAPSHOT</version>

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

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>
    </dependencies>

</project>

2). 创建 aliyun-oss-spring-boot-autoconfigure 模块

选择Springboot的版本,不用勾选任何依赖。

创建完starter模块后,删除多余的文件,只保留 src 和 pom.xml 。最终保留内容如下:

该模块的pom.xml内容如下:

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.8</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <groupId>com.aliyun.oss</groupId>
    <artifactId>aliyun-oss-spring-boot-autoconfigure</artifactId>
    <version>0.0.1-SNAPSHOT</version>

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

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>
    </dependencies>

</project>

按照我们之前的分析,是需要在starter模块中来引入autoconfigure这个模块的。打开starter模块中的pom文件:

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.8</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <groupId>com.aliyun.oss</groupId>
    <artifactId>aliyun-oss-spring-boot-starter</artifactId>
    <version>0.0.1-SNAPSHOT</version>

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

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>com.aliyun.oss</groupId>
            <artifactId>aliyun-oss-spring-boot-autoconfigure</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>
    </dependencies>

</project>

前两步已经完成了,接下来是最关键的就是第三步:在aliyun-oss-spring-boot-autoconfigure模块当中来完成自动配置操作。

我们将之前案例中所使用的阿里云OSS部分的代码直接拷贝到autoconfigure模块下,然后进行改造就行了。

拷贝过来后,还缺失一些相关的依赖,需要把相关依赖也拷贝过来:

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.8</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <groupId>com.aliyun.oss</groupId>
    <artifactId>aliyun-oss-spring-boot-autoconfigure</artifactId>
    <version>0.0.1-SNAPSHOT</version>

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

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>

        <!--阿里云OSS-->
        <dependency>
            <groupId>com.aliyun.oss</groupId>
            <artifactId>aliyun-sdk-oss</artifactId>
            <version>3.17.4</version>
        </dependency>
        <dependency>
            <groupId>javax.xml.bind</groupId>
            <artifactId>jaxb-api</artifactId>
            <version>2.3.1</version>
        </dependency>
        <dependency>
            <groupId>javax.activation</groupId>
            <artifactId>activation</artifactId>
            <version>1.1.1</version>
        </dependency>
        <!-- no more than 2.3.3-->
        <dependency>
            <groupId>org.glassfish.jaxb</groupId>
            <artifactId>jaxb-runtime</artifactId>
            <version>2.3.3</version>
        </dependency>

    </dependencies>

</project>

那此时,大家思考下,在类上添加的 @Component 注解还有用吗?

答案:没用了。 在SpringBoot项目中,并不会去扫描com.aliyun.oss这个包,不扫描这个包那类上的注解也就失去了作用。

@Component注解不需要使用了,可以从类上删除了。

1). 删除 AliyunOSSOperator 工具类上的 @Component 注解 和 @Autowired 注解。

2). 删除 AliyunOSSProperties 实体类上的 @Component 注解。

删除后报红色错误,暂时不理会,后面再来处理。

3). 既然不能用 @Component 注解声明bean,那就需要按照 starter 的定义规范,定义一个自动配置类,在自动配置类中声明bean。

下面我们就要定义一个自动配置类 AliOSSAutoConfiguration 了,在自动配置类当中来声明 AliOSSOperator 的bean对象。

具体代码如下:

java 复制代码
package com.aliyun.oss;

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableConfigurationProperties(AliyunOSSProperties.class)
public class AliyunOSSAutoConfiguration {
    
    @Bean
    public AliyunOSSOperator aliyunOSSOperator(AliyunOSSProperties aliyunOSSProperties) {
        return new AliyunOSSOperator(aliyunOSSProperties);
    }
    
}

AliyunOSSOperator 的代码中需要增加一个有参构造,将 AliyunOSSProperties 对象传递给工具类。代码改造如下:

4). 在 aliyun-oss-spring-boot-autoconfigure 模块中的resources下,新建自动配置文件 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

将自动配置类的全类名,配置在文件中,这样在springboot启动的时候,就会加载到这份文件,并加载到其中的配置类了。

配置内容如下:

java 复制代码
com.aliyun.oss.AliyunOSSAutoConfiguration

到此呢,这个 aliyun-oss-spring-boot-stater 就定义好了,哪里要想使用,就可以直接导入依赖,直接注入使用了。

3.2.3.4 测试

阿里云OSS的starter我们刚才已经定义好了,接下来我们就来做一个测试。

今天的课程资料当中,提供了一个自定义starter的测试工程。我们直接打开文件夹,里面有一个测试工程。测试工程就是

springboot-autoconfiguration-test,我们只需要将测试工程直接导入到Idea当中即可。

测试前准备:

1). 在导入的test工程中引入阿里云starter依赖

java 复制代码
<dependency>
    <groupId>com.aliyun.oss</groupId>
    <artifactId>aliyun-oss-spring-boot-starter</artifactId>
    <version>0.0.1-SNAPSHOT</version>
</dependency>

2). 在导入的test工程中的 application.yml 中配置阿里云OSS的配置信息

java 复制代码
aliyun:
  oss:
    endpoint: https://oss-cn-beijing.aliyuncs.com
    bucketName: java422-web-ai

3). 在test工程中的 UploadController 类编写代码

java 复制代码
package com.itheima.controller;

import com.aliyun.oss.AliyunOSSOperator;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
public class UploadController {
    
    @Autowired
    private AliyunOSSOperator aliyunOSSOperator;
    
    @PostMapping("/upload")
    public String upload(MultipartFile image) throws Exception {
        //上传文件到阿里云 OSS
        String url = aliyunOSSOperator.upload(image.getBytes(), image.getOriginalFilename());
        return url;
    }
    
}

编写完代码后,我们启动当前的SpringBoot测试工程,使用Apifox工具进行文件上传:

这样,我们就完成了starter的定义。在其他项目中要想使用,引入依赖,配置一下阿里云OSS的信息,就可以直接注入是用了 。

💩一点没看

14-后端Web进阶(Maven高级)

15-前端Web实战(Vue工程化+ElementPlus)

  1. Vue工程化
    前面我们在介绍Vue的时候,我们讲到Vue是一款用于构建用户界面的渐进式JavaScript框架 。(官方:https://cn.vuejs.org/
    那在前面的课程中,我们已经学习了Vue的基本语法、表达式、指令,并基于Vue的核心包,完成了Vue的案例。 那今天呢,我们要来讲解的基于Vue进行整站开发。

1.1 介绍

在前面的课程中,我们学习了HTML、CSS、JS、Axios、Vue等技术,并基于完成了一些前端开发的案例 。我们目前的前端开发中,当我们需要使用一些资源时,例如:vue.js,和 axios.js 文件,都是直接再工程中导入的 ,如下图所示:

但是上述开发模式存在如下问题:

  • 不规范:每次开发都是从零开始,比较麻烦
  • 难复用:多个页面中的组件共用性不好
  • 难维护:js、图片等资源没有规范化的存储目录,没有统一的标准,不方便维护

所以现在企业开发中更加讲究前端工程化方式的开发,主要包括如下4个特点:

  • 模块化:将js和css等,做成一个个可复用模块
  • 组件化:我们将UI组件,css样式,js行为封装成一个个的组件,便于管理
  • 规范化:我们提供一套标准的规范的目录接口和编码规范,所有开发人员遵循这套规范
  • 自动化:项目的构建,测试,部署全部都是自动完成

所以对于前端工程化说白了,就是在企业级的前端项目开发中,把前端开发所需要的工具、技术、流程、经验进行规范化和标准化。从而统一开发规范、提升开发效率,降低开发难度、提高复用等等。接下来我们就需要学习vue的官方提供的脚手架帮我们完成前端的工程化。

1.2 环境准备

1.2.1 介绍
  • 介绍:create-vue是Vue官方提供的最新的脚手架工具,用于快速生成一个工程化的Vue项目。
  • create-vue提供了如下功能:
    • 统一的目录结构
    • 本地调试
    • 热部署
    • 单元测试
    • 集成打包上线
  • 而要想使用create-vue来创建vue项目,则必须安装依赖环境:NodeJS
1.2.2 NodeJS安装

安装包,在课程资料中已经提供了。如下:通过网盘分享的文件:node-v18.20.4-x64.msi
链接: https://pan.baidu.com/s/1OpuIOg11xpKYGxDLQOrtRg 提取码: mh9m
--来自百度网盘超级会员v1的分享

1). 双击msi文件,勾选我接受

2). 选择安装到一个,没有中文,没有空格 的目录下(新建一个文件夹NodeJS)

3). 点击 Next,下一步下一步的安装即可。

4). 验证NodeJS的环境变量

NodeJS 安装完毕后,会自动配置好环境变量,我们验证一下是否安装成功,通过: node -v

5). 配置npm的全局安装路径

使用 管理员身份 运行命令行,在命令行中,执行如下指令:

npm config set prefix "D:\develop\NodeJS"

注意:D:\develop\NodeJS 这个目录是NodeJS的安装目录 !!!

注意:D:\develop\NodeJS 这个目录是NodeJS的安装目录 !!!

注意:D:\develop\NodeJS 这个目录是NodeJS的安装目录 !!!

一定要换成自己的!!!

6). 切换为淘宝镜像,加速下载:

java 复制代码
npm config set registry https://registry.npmmirror.com

之前下过,版本挺新的,就不重复下了。

配置下全局安装路径

配置下淘宝镜像

1.2.3 npm介绍
  • npm:Node Package Manager,是NodeJS的软件包管理器。

    在开发前端项目的过程中,我们需要相关的依赖,就可以直接通过 npm install xxx 命令,直接从远程仓库中将依赖直接下载到本地了。

1.3 Vue项目创建

1.3.1 项目创建

创建一个工程化的Vue项目,执行命令:npm create vue@3.3.4

详细步骤说明:

  • Project name:------------------》项目名称,默认值:vue-project,可输入想要的项目名称。
  • Add TypeScript? ----------------》是否加入TypeScript组件?默认值:No。
  • Add JSX Support? --------------》是否加入JSX支持?默认值:No。
  • Add Vue Router...--------------》是否为单页应用程序开发添加Vue Router路由管理组件?默认值:No。
  • Add Pinia ...----------------------》是否添加Pinia组件来进行状态管理?默认值:No。
  • Add Vitest ...---------------------》是否添加Vitest来进行单元测试?默认值:No。
  • Add an End-to-End ...-----------》是否添加端到端测试?默认值No。
  • Add ESLint for code quality? ---》是否添加ESLint来进行代码质量检查?默认值:No。

提示:执行上述指令,将会安装并执行 create-vue,它是 Vue 官方的项目脚手架工具

项目创建完成以后,进入vue-project01 项目目录,执行命令安装当前项目的依赖:npm install

创建项目以及安装依赖的过程,都是需要联网的。【如果网络不太好,可能会造成依赖下载不完整报错,继续再次执行 命令安装。】

1.3.2 项目结构

我们可以使用VsCode直接打开这个Vue项目。

这是我们创建的第一个项目结构,接下来呢,我们来介绍一下这个项目的结构。如图所示:

在上述的目录中,我们以后操作的最多的目录,就是src目录,因为我们需要在这个目录下来编写前端代码。

1.3.3 启动项目
  • 方式一:命令行
    启动项目,我们可以在命令行中执行命令:npm run dev,就可以启动Vue项目了。
  • 方式二:Vscode图形化界面
    点击NPM脚本中的dev后的运行按钮,就可以启动项目。
    启动起来之后,我们就可以访问前端Vue项目了,访问路径:http://localhost:5173

1.4 Vue项目开发流程

如下图:

📚

index.html 提供容器,

main.js 做桥梁,

App.vue 根组件渲染挂载到 html 的 div 上。

其中*.vue是Vue项目中的组件文件,在Vue项目中也称为单文件组件(SFC,Single-File Components)。Vue 的单文件组件会将一个组件的逻辑 (JS),模板 (HTML) 和样式 (CSS) 封装在同一个文件里(*.vue

1.5 API风格

  • Vue的组件有两种不同的风格:组合式API选项式API
  • 组合式API:是Vue3提供的一种基于函数的组件编写方式,通过使用函数来组织和复用组件的逻辑。它提供了一种更灵活、更可组合的方式来编写组件。代码形式如下:
html 复制代码
<script setup>
import { ref, onMounted } from 'vue';
const count = ref(0); //声明响应式变量

function increment(){ //声明函数
   count.value++;
}

onMounted(() => { //声明钩子函数
  console.log('Vue Mounted....'); 
})
</script>

<template>
   <input type="button" @click="increment"> Api Demo1 Count : {{ count }}
</template>

<style scoped>
   
</style>
  • setup:是一个标识,告诉Vue需要进行一些处理,让我们可以更简洁的使用组合式API。
  • ref():接收一个内部值,返回一个响应式的ref对象此对象只有一个指向内部值的属性 value
  • onMounted():在组合式API中的钩子方法,注册一个回调函数,在组件挂载完成后执行。
  • 选项式API
    选项式API:可以用包含多个选项的对象来描述组件的逻辑,如:data,methods,mounted等。选项定义的属性都会暴露在函数内部的this上,它会指向当前的组件实例。
html 复制代码
<script>
export default{
   data() {
      return {
         count: 0
      }
   },
   methods: {
      increment: function(){
         this.count++
      }
   },
   mounted() {
      console.log('vue mounted.....');
   }
}
</script>

<template>
  <input type="button" @click="increment">Api Demo1 Count :  {{ count }}
</template>

<style scoped>

</style>

在Vue中的组合式API 使用时,是没有this对象的,this对象是undefined。

📚工程化项目推荐使用组合式API

1.6 案例

在Vue项目中,基于组合式API完成用户列表数据渲染。 要求:在页面加载完毕之后,发送异步请求,加载数据,渲染表格。

最终实现效果:

代码实现如下:

在 src 下定义 views 目录,在 views 目录中定义文件 UserList.vue, 然后,我们就可以将之前实现的Vue案例中的代码,基于Vue工程化的形式来实现一遍。 (之前实现的这个页面,在资料中已经提供了:1.工程化-案例-素材.html)

1). 把原来定义的CSS样式代码,拷贝到 <style></style> 标签中。

2). 把页面的<div id="app"></div> 中的页面展示的html标签代码,拷贝到 <template></template> 标签中。

3). 将页面的JS代码,按照组合式API的形式,在 <script></script> 中定义出来。

4). 而由于在这个案例中,需要用到axios来发送异步请求,所以还需要安装 axios。

具体代码如下:

views/UserList.vue 代码如下:

html 复制代码
<script setup>
//引入ref
import { ref, onMounted } from 'vue'
import axios from 'axios'

//声明数据变量 userList, name, gender, job
const userList = ref([])
const name = ref('')
const gender = ref('')
const job = ref('')

//声明函数,基于axios查询数据
const search = () => {
  //发送请求
  axios.get(`https://web-server.itheima.net/emps/list?name=${name.value}&gender=${gender.value}&job=${job.value}`).then(res => {
    //将查询到的数据赋值给userList
    userList.value = res.data.data
  })
}

//定义钩子函数 onMounted
onMounted(() => {
  //调用search函数
  search()
})
</script>

<template>
  <div id="center">
    姓名: <input type="text" name="name" v-model="name">

    性别:
    <select name="gender" v-model="gender">
      <option value="1">男</option>
      <option value="2">女</option>
    </select>

    职位:
    <select name="job" v-model="job">
      <option value="1">班主任</option>
      <option value="2">讲师</option>
      <option value="3">其他</option>
    </select>

    <input class="btn" type="button" value="查询" @click="search">
  </div>
  
  <table>
    <tr>
      <th>序号</th>
      <th>姓名</th>
      <th>头像</th>
      <th>性别</th>
      <th>职位</th>
      <th>入职时间</th>
      <th>更新时间</th>
    </tr>

    <!-- v-for 用于列表循环渲染元素 -->
    <tr v-for="(user, index) in userList" :key="user.id">
      <td>{{index + 1}}</td>
      <td>{{user.name}}</td>
      <td> <img :src="user.image"> </td>
      <td>
        <span v-if="user.gender == 1">男</span>
        <span v-else-if="user.gender == 2">女</span>
        <span v-else>其他</span>
      </td>
      <td>
        <span v-show="user.job == 1">班主任</span>
        <span v-show="user.job == 2">讲师</span>
        <span v-show="user.job != 1 && user.job != 2">其他</span>
      </td>
      <td>{{user.entrydate}}</td>
      <td>{{user.updatetime}}</td>
    </tr>
  </table>
</template>

<style scoped>
  table,th,td {
    border: 1px solid #000;
    border-collapse: collapse;
    line-height: 50px;
    text-align: center;
  }

  #center,table {
    width: 60%;
    margin: auto;
  }

  #center {
    margin-bottom: 20px;
  }

  img {
    width: 50px;
  }

  input,select {
    width: 17%;
    padding: 10px;
    margin-right: 30px;
    border: 1px solid #ccc;
    border-radius: 4px;
  }

  .btn {
    background-color: #ccc;
  }
</style>

然后在 App.vue 中,将 UserList.vue 引入进来。

html 复制代码
<script setup>
import UserList from './views/user/UserList.vue'
</script>

<template>
   <UserList></UserList>
</template>

<style scoped>

</style>

最终展示形式如下:

☀️

2. ElementPlus

2.1 介绍

Element:是饿了么公司前端开发团队提供的一套基于 Vue3 的网站组件库,用于快速构建网页。

Element 提供了很多组件(组成网页的部件)供我们使用。例如 超链接、按钮、图片、表格等等。

官方网站:https://element-plus.org/zh-CN/#/zh-CN

如下图所示就是我们开发的页面和ElementUI提供的效果对比:可以发现ElementUI提供的各式各样好看的按钮。

ElementPlus的学习方式和我们之前的学习方式不太一样,对于ElementPlus,我们作为一个后台开发者,只需要学会如何从 ElementPlus 的官网拷贝组件到我们自己的页面中,并且做一些修改即可。 我们主要学习的是ElementPlus中提供的常用组件,至于其他组件同学们可以通过我们这几个组件的学习掌握到ElementPlus的学习技巧,然后课后自行学习。

2.2 快速入门

准备工作:

1). 将资料中提供的基础工程(资料/04. ElementPlus基础工程/vue-project02.zip),解压到工作目录中,使用VSCode将其打开。

2). 参照官方文档,安装ElementPlus的组件库(在当前工程的目录下),执行如下命令:【这一步可以不做,已经安装好了】

右击package,在集成终端中打开

html 复制代码
npm install element-plus@2.4.4 --save

3). 在main.js 中引入ElementPlus组件库 (参照官方文档),最终 main.js 中代码如下:

javascript 复制代码
import { createApp } from 'vue'
import App from './App.vue'

import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'

const app = createApp(App)
app.use(ElementPlus)

app.mount('#app')

制作组件:

1). 访问ElementPlus的官方文档,查看对应的组件源代码。

2). 在 src下创建 views 目录,在 views 目录下,创建Element.vue组件文件,复制组件代码,调整成自己想要的 。

html 复制代码
<script setup>

</script>

<template>
  <el-row class="mb-4">
    <el-button>Default</el-button>
    <el-button type="primary">Primary</el-button>
    <el-button type="success">Success</el-button>
    <el-button type="info">Info</el-button>
    <el-button type="warning">Warning</el-button>
    <el-button type="danger">Danger</el-button>
  </el-row>
</template>

<style scoped>

</style>

3). 在根组件 app.vue 中引入Element.vue

html 复制代码
<script setup>
import Element from './views/Element.vue'
</script>

<template>
  <Element></Element>
</template>

<style scoped>

</style>

2.3 常见组件

2.3.1 表格组件

用于展示多条结构类似的数据, 可对数据进行排序、筛选、对比或其他自定义操作。

接下来我们通过代码来演示。

首先我们需要来到 ElementPlus 的组件库中,找到表格组件,如下图所示:

然后在 Element.vue 组件中继续制作表格。组件中,需要注意的是,我们组件包括了3个部分,如果官方有除了<template>部分之外的<style><script>都需要复制。具体操作如下图所示:

整体代码如下所示(绿色背景部分代码为本次增加的代码):

html 复制代码
<script setup>
const tableData = [
  {date: '2016-05-03', name: 'Tom', address: 'No. 189, Grove St, Los Angeles'},
  {date: '2016-05-02', name: 'Tom', address: 'No. 189, Grove St, Los Angeles'},
  {date: '2016-05-04', name: 'Tom', address: 'No. 189, Grove St, Los Angeles'},
  {date: '2016-05-01', name: 'Tom', address: 'No. 189, Grove St, Los Angeles'}
]
</script>

<template>

  <!-- Button按钮 -->
  <el-row class="mb-4">
    <el-button>Default</el-button>
    <el-button type="primary">Primary</el-button>
    <el-button type="success">Success</el-button>
    <el-button type="info">Info</el-button>
    <el-button type="warning">Warning</el-button>
    <el-button type="danger">Danger</el-button>
  </el-row>

  <br>

  <!-- Table表格 -->
  <el-table :data="tableData" border style="width: 100%">
    <el-table-column prop="date" label="Date" width="180" />
    <el-table-column prop="name" label="Name" width="180" />
    <el-table-column prop="address" label="Address" />
  </el-table>
</template>

<style scoped>

</style>

此时回到浏览器,我们页面呈现如下效果:

Table表格组件,属性说明:

  • data: 主要定义table组件的数据模型
  • prop: 定义列的数据应该绑定data中定义的具体的数据模型
  • label: 定义列的标题
  • width: 定义列的宽度

2.3.2 分页条组件

Pagination: 分页组件,主要提供分页工具条相关功能。其展示效果图下图所示:

默认情况下,ElementPlus的组件是英文的,如果希望使用中文语言,可以在 main.js 中做如下配置:

javascript 复制代码
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
app.use(ElementPlus, {locale: zhCn})

javascript 复制代码
import { createApp } from 'vue'
import App from './App.vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'

import zhCn from 'element-plus/dist/locale/zh-cn'
import './assets/main.css'

const app = createApp(App)

app.use(ElementPlus, {locale: zhCn})

app.mount('#app')

接下来我们通过代码来演示功能。

首先在官网找到分页组件,我们选择带背景色分页组件,如下图所示:

然后复制代码到我们的 Element.vue 组件文件的template中,在 <template> </template> 拷贝如下代码:

html 复制代码
  <el-pagination
    v-model:current-page="currentPage4"
    v-model:page-size="pageSize4"
    :page-sizes="[100, 200, 300, 400]"
    layout="total, sizes, prev, pager, next, jumper"
    :total="400"
    @size-change="handleSizeChange"
    @current-change="handleCurrentChange"
  />

<script> </script> 中拷贝如下代码:

javascript 复制代码
import { ref } from 'vue'

const currentPage4 = ref(4)
const pageSize4 = ref(100)

const handleSizeChange = (val) => {
  console.log(`${val} items per page`)
}
const handleCurrentChange = (val) => {
  console.log(`current page: ${val}`)
}

目前,整个 Element.vue 的文件内容如下(绿色背景部分代码为本次增加的代码):

html 复制代码
<script setup>
import { ref } from 'vue'

const tableData = [
  {date: '2016-05-03', name: 'Tom', address: 'No. 189, Grove St, Los Angeles'},
  {date: '2016-05-02', name: 'Tom', address: 'No. 189, Grove St, Los Angeles'},
  {date: '2016-05-04', name: 'Tom', address: 'No. 189, Grove St, Los Angeles'},
  {date: '2016-05-01', name: 'Tom', address: 'No. 189, Grove St, Los Angeles'}
]

const currentPage4 = ref(4)
const pageSize4 = ref(100)

const handleSizeChange = (val) => {
  console.log(`${val} items per page`)
}
const handleCurrentChange = (val) => {
  console.log(`current page: ${val}`)
}
</script>

<template>

  <!-- Button按钮 -->
  <el-row class="mb-4">
    <el-button>Default</el-button>
    <el-button type="primary">Primary</el-button>
    <el-button type="success">Success</el-button>
    <el-button type="info">Info</el-button>
    <el-button type="warning">Warning</el-button>
    <el-button type="danger">Danger</el-button>
  </el-row>

  <br>

  <!-- Table表格 -->
  <el-table :data="tableData" border style="width: 100%">
    <el-table-column prop="date" label="Date" width="180" />
    <el-table-column prop="name" label="Name" width="180" />
    <el-table-column prop="address" label="Address" />
  </el-table>
  
  <br>

  <el-pagination
    v-model:current-page="currentPage4"
    v-model:page-size="pageSize4"
    :page-sizes="[100, 200, 300, 400]"
    layout="total, sizes, prev, pager, next, jumper"
    :total="400"
    @size-change="handleSizeChange"
    @current-change="handleCurrentChange"
  />
</template>

<style scoped>

</style>

打开浏览器,查看页面效果如下:

⭐️

Pagination 分页组件的属性 如下:

对于分页组件我们需要关注的是如下几个重要属性(可以通过查阅官网组件中最下面的组件属性详细说明得到):

  • background: 添加背景颜色,也就是上图蓝色背景色效果。
  • layout: 分页工具条的布局,其具体值包含sizes, prev, pager, next, jumper, total 这些值
  • total: 数据的总数量

对于分页组件,除了上述几个属性,还有2个非常重要的事件我们需要去学习:

  • size-change : pageSize 改变时会触发
  • current-change :currentPage 改变时会触发
2.3.3 对话框组件

在保留当前页面状态的情况下,告知用户并承载相关操作。

首先我们需要在ElementPlus官方找到Dialog组件,如下图所示:

然后复制如下代码到我们的组件文件 Element.vue 的 <template></template> 模块中:

html 复制代码
  <el-button @click="dialogTableVisible = true">
    打开对话框
  </el-button>

  <el-dialog v-model="dialogTableVisible" title="Shipping address">
    <el-table :data="tableData">
      <el-table-column property="date" label="Date" width="150" />
      <el-table-column property="name" label="Name" width="200" />
      <el-table-column property="address" label="Address" />
    </el-table>
  </el-dialog>

然后复制如下代码到我们的组件文件 Element.vue 的 <script></script> 模块中:

javascript 复制代码
const dialogTableVisible = ref(false)

最终,完成的 Element.vue 的代码如下:

html 复制代码
<script setup>
import { ref } from 'vue'

const tableData = [
  {date: '2016-05-03', name: 'Tom', address: 'No. 189, Grove St, Los Angeles'},
  {date: '2016-05-02', name: 'Tom', address: 'No. 189, Grove St, Los Angeles'},
  {date: '2016-05-04', name: 'Tom', address: 'No. 189, Grove St, Los Angeles'},
  {date: '2016-05-01', name: 'Tom', address: 'No. 189, Grove St, Los Angeles'}
]

const currentPage4 = ref(4)
const pageSize4 = ref(100)

const handleSizeChange = (val) => {
  console.log(`${val} items per page`)
}
const handleCurrentChange = (val) => {
  console.log(`current page: ${val}`)
}

// Dialog对话框
const dialogTableVisible = ref(false)
</script>

<template>

  <!-- Button按钮 -->
  <el-row class="mb-4">
    <el-button>Default</el-button>
    <el-button type="primary">Primary</el-button>
    <el-button type="success">Success</el-button>
    <el-button type="info">Info</el-button>
    <el-button type="warning">Warning</el-button>
    <el-button type="danger">Danger</el-button>
  </el-row>

  <br>

  <!-- Table表格 -->
  <el-table :data="tableData" border style="width: 100%">
    <el-table-column prop="date" label="Date" width="180" />
    <el-table-column prop="name" label="Name" width="180" />
    <el-table-column prop="address" label="Address" />
  </el-table>

  <br>

  <el-pagination
    v-model:current-page="currentPage4"
    v-model:page-size="pageSize4"
    :page-sizes="[100, 200, 300, 400]"
    layout="total, sizes, prev, pager, next, jumper"
    :total="400"
    @size-change="handleSizeChange"
    @current-change="handleCurrentChange"
  />

  <br>

  <el-button @click="dialogTableVisible = true">
    打开对话框
  </el-button>

  <el-dialog v-model="dialogTableVisible" title="Shipping address">
    <el-table :data="tableData">
      <el-table-column property="date" label="Date" width="150" />
      <el-table-column property="name" label="Name" width="200" />
      <el-table-column property="address" label="Address" />
    </el-table>
  </el-dialog>

</template>

<style scoped>

</style>

打开浏览器,最终的页面效果如下:

Dialog对话框组件使用的关键点,就是控制其显示与隐藏。 通过 v-model 给定的boolean值,来控制Dialog的显示与隐藏。

2.3.4 表单组件

Form 表单:由输入框、选择器、单选框、多选框等控件组成,用以收集、校验、提交数据。

表单在我们前端的开发中使用的还是比较多的,接下来我们学习这个组件,与之前的流程一样,我们首先需要在ElementPlus的官方找到对应的组件示例:如下图所示:

然后复制如下代码到我们的组件文件 Element.vue 的 <template></template> 模块中:

html 复制代码
<!-- Form 表单 -->
  <el-form :inline="true" :model="formInline" class="demo-form-inline">
    <el-form-item label="Approved by">
      <el-input v-model="formInline.user" placeholder="Approved by" clearable />
    </el-form-item>

    <el-form-item label="Activity zone">
      <el-select v-model="formInline.region" placeholder="Activity zone" clearable>
        <el-option label="Zone one" value="shanghai" />
        <el-option label="Zone two" value="beijing" />
      </el-select>
    </el-form-item>

    <el-form-item label="Activity time">
      <el-date-picker v-model="formInline.date" type="date" placeholder="Pick a date" clearable/>
    </el-form-item>
    
    <el-form-item>
      <el-button type="primary" @click="onSubmit">Query</el-button>
    </el-form-item>
  </el-form>

然后复制如下代码到我们的组件文件 Element.vue 的 <script></script>模块中:

javascript 复制代码
// Form表单
const formInline = ref({
  user: '',
  region: '',
  date: '',
})

const onSubmit = () => {
  console.log('submit!')
}

最终,完成的 Element.vue 的代码如下:

html 复制代码
<script setup>
import { ref } from 'vue'

const tableData = [
  {date: '2016-05-03', name: 'Tom', address: 'No. 189, Grove St, Los Angeles'},
  {date: '2016-05-02', name: 'Tom', address: 'No. 189, Grove St, Los Angeles'},
  {date: '2016-05-04', name: 'Tom', address: 'No. 189, Grove St, Los Angeles'},
  {date: '2016-05-01', name: 'Tom', address: 'No. 189, Grove St, Los Angeles'}
]

const currentPage4 = ref(4)
const pageSize4 = ref(100)

const handleSizeChange = (val) => {
  console.log(`${val} items per page`)
}
const handleCurrentChange = (val) => {
  console.log(`current page: ${val}`)
}

// Dialog对话框
const dialogTableVisible = ref(false)

// Form表单
const formInline = ref({
  user: '',
  region: '',
  date: '',
})

const onSubmit = () => {
  console.log('submit!')
}
</script>

<template>

  <!-- Button按钮 -->
  <el-row class="mb-4">
    <el-button>Default</el-button>
    <el-button type="primary">Primary</el-button>
    <el-button type="success">Success</el-button>
    <el-button type="info">Info</el-button>
    <el-button type="warning">Warning</el-button>
    <el-button type="danger">Danger</el-button>
  </el-row>

  <br>

  <!-- Table表格 -->
  <el-table :data="tableData" border style="width: 100%">
    <el-table-column prop="date" label="Date" width="180" />
    <el-table-column prop="name" label="Name" width="180" />
    <el-table-column prop="address" label="Address" />
  </el-table>

  <br>

  <el-pagination
    v-model:current-page="currentPage4"
    v-model:page-size="pageSize4"
    :page-sizes="[100, 200, 300, 400]"
    layout="total, sizes, prev, pager, next, jumper"
    :total="400"
    @size-change="handleSizeChange"
    @current-change="handleCurrentChange"
  />

  <br>

  <el-button @click="dialogTableVisible = true">
    打开对话框
  </el-button>

  <el-dialog v-model="dialogTableVisible" title="Shipping address">
    <el-table :data="tableData">
      <el-table-column property="date" label="Date" width="150" />
      <el-table-column property="name" label="Name" width="200" />
      <el-table-column property="address" label="Address" />
    </el-table>
  </el-dialog>

  <br><br>

  <!-- Form 表单 -->
  <el-form :inline="true" :model="formInline" class="demo-form-inline">
    <el-form-item label="Approved by">
      <el-input v-model="formInline.user" placeholder="Approved by" clearable />
    </el-form-item>

    <el-form-item label="Activity zone">
      <el-select v-model="formInline.region" placeholder="Activity zone" clearable>
        <el-option label="Zone one" value="shanghai" />
        <el-option label="Zone two" value="beijing" />
      </el-select>
    </el-form-item>

    <el-form-item label="Activity time">
      <el-date-picker v-model="formInline.date" type="date" placeholder="Pick a date" clearable/>
    </el-form-item>
    
    <el-form-item>
      <el-button type="primary" @click="onSubmit">Query</el-button>
    </el-form-item>
  </el-form>

</template>

<style scoped>

</style>

打开浏览器,查看页面效果:

2.4 案例

需求:基于ElementPlus组件库制作如下页面,并异步获取数据,完成页面展示。

1). 准备工作

由于在案例中,我们需要在vue项目中使用Axios,需要安装axios,需要在当前项目的目录下执行如下命令:

html 复制代码
npm install axios

2). 编码实现

在views目录下,再定义一个文件 EmpList.vue,具体代码实现如下:

html 复制代码
<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'

const searchEmp = ref({
  name: '',
  gender: '',
  job: '',
})

onMounted(() => {
  search();
})

const search = async () => {
  const url = `https://web-server.itheima.net/emps/list?name=${searchEmp.value.name}&gender=${searchEmp.value.gender}&job=${searchEmp.value.job}`
  const result = await axios.get(url)
  tableData.value = result.data.data
}

const clear = () => {
  searchEmp.value = { name: '', gender: '', job: '' }
  search();
}

let tableData = ref([])
</script>

<template>
  <div id="center">
    <el-form :inline="true" :model="searchEmp" class="demo-form-inline">
      <el-form-item label="姓名">
        <el-input v-model="searchEmp.name" placeholder="请输入姓名" clearable />
      </el-form-item>
      <el-form-item label="性别">
        <el-select v-model="searchEmp.gender" placeholder="请选择" clearable>
          <el-option label="男" value="1" />
          <el-option label="女" value="2" />
        </el-select>
      </el-form-item>
      <el-form-item label="职位">
        <el-select v-model="searchEmp.job" placeholder="请选择" clearable>
          <el-option label="班主任" value="1" />
          <el-option label="讲师" value="2" />
          <el-option label="咨询师" value="3" />
        </el-select>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="search">查询</el-button>
        <el-button type="primary" @click="clear">清空</el-button>
      </el-form-item>
    </el-form>
    <br>

    <!-- 表格 -->
    <el-table :data="tableData" border style="width: 100%; ">
      <el-table-column prop="id" label="ID" width="80" align="center" />
      <el-table-column prop="name" label="姓名" width="100" align="center" />
      <el-table-column label="头像" width="120" align="center">
        <template #default="scope">
          <img :src="scope.row.image" width="50">
        </template>
      </el-table-column>
      <el-table-column prop="gender" label="性别" width="120" align="center">
        <template #default="scope">
          {{ scope.row.gender == 1 ? '男' : '女' }}
        </template>
      </el-table-column>
      <el-table-column label="职位" width="180" align="center">
        <template #default="scope">
          <span v-if="scope.row.job == 1">班主任</span>
          <span v-else-if="scope.row.job == 2">讲师</span>
          <span v-else-if="scope.row.job == 3">咨询师</span>
          <span v-else>其他</span>
        </template>
      </el-table-column>
      <el-table-column prop="entrydate" label="入职日期" width="180" align="center" />
      <el-table-column prop="updatetime" label="更新时间" align="center" />
    </el-table>
  </div>
</template>

<style scoped>
#center {
  width: 70%;
  margin: auto;
  margin-top: 100px;
}
</style>

在 App.vue 中引入 EmpList.vue 文件

html 复制代码
<script setup>
  import EmpList from './views/EmpList.vue'
</script>

<template>
  <EmpList></EmpList>
</template>

<style scoped>

</style>

3). 打开浏览器,查看页面效果

💩代码是复制粘贴的,没看视频,太细节了,没什么耐心看。

相关推荐
罗超驿4 天前
SpringMVC请求参数获取与响应处理|全套注解详解
springmvc·springboot·javaweb·请求参数·后端开发
万亿少女的梦1681 个月前
基于Spring Boot、Vue.js与MySQL的流浪猫狗救助系统设计与实现
vue.js·spring boot·mysql·javaweb·权限管理
折哥的程序人生 · 物流技术专研2 个月前
Java面试通关⑦:JavaWeb网络核心全集
网络协议·http·javaweb·校招·前后端交互·java面试·社招
郑洁文3 个月前
基于Javaweb的高校网上订餐系统
javaweb·毕设·高校网上订餐系统
初学小白...4 个月前
JavaWeb
javaweb·web
一只大袋鼠4 个月前
SpringMVC 框架中的拦截器
java·springmvc·javaweb·拦截器
一只大袋鼠4 个月前
SpringMVC全局异常处理
java·开发语言·springmvc·javaweb
一只大袋鼠4 个月前
JavaWeb四种文件上传方式(下篇)
java·开发语言·springmvc·javaweb
一只大袋鼠4 个月前
JavaWeb四种文件上传方式(上篇)
java·开发语言·servlet·javaweb