04-Web 后端基础(基础知识)

在此非常感谢黑马的涛哥!

  • SpringBootWeb 入门
  • Http 协议
  • SpringBootWeb 案例
  • 分层解耦

那在前面讲解 Web 前端开发的时候,我们学习了前端网页开发的三剑客 HTML、CSS、JS,通过这三项技术,我们就可以制作前端页面了。 那最终,这些个页面资料,我们就可以部署在服务器上,然后打开浏览器就可以直接访问服务器上部署的前端页面了。

而像 HTML、CSS、JS 以及图片、音频、视频等这些资源,我们都称为静态资源。 所谓静态资源,就是指在服务器上存储的不会改变的数据,通常不会根据用户的请求而变化。

那与静态资源对应的还有一类资源,就是动态资源。那所谓动态资源,就是指在服务器端上存储的,会根据用户请求和其他数据动态生成的,内容可能会在每次请求时都发生变化。比如:Servlet、JSP 等(负责逻辑处理)。而 Servlet、JSP 这些技术现在早都被企业淘汰了,现在在企业项目开发中,都是直接基于 Spring 框架来构建动态资源。

而对于我们 java 程序开发的动态资源来说,我们通常会将这些动态资源部署在 Tomcat,这样的 Web 服务器中运行。 而浏览器与服务器在通信的时候,基本都是基于 HTTP 协议的。

那上述所描述的这种浏览器/服务器的架构模式呢,我们称之为:BS 架构

  • BS 架构:Browser/Server,浏览器/服务器架构模式。客户端只需要浏览器,应用程序的逻辑和数据都存储在服务端。

    • 优点:维护方便
    • 缺点:体验一般
  • CS 架构:Client/Server,客户端/服务器架构模式。需要单独开发维护客户端。

    • 优点:体验不错
    • 缺点:开发维护麻烦

那前面我们已经学习了静态资源开发技术,包括:HTML、CSS、JS 以及 JS 的高级框架 Vue,异步交互技术 Axios。 那那接下来呢,我们就要来学习动态资料开发技术,而动态资源开发技术中像早期的 Servlet、JSP 这些个技术早都被企业淘汰了,现在企业开发主流的就是基于 Spring 体系中的框架来开发这些动态资源。 所以,我们今天的课程内容内,分为以下四个部分:

  • SpringBootWeb 入门
  • HTTP 协议
  • SpringBootWeb 案例
  • 分层解耦

SpringBootWeb 入门

那接下来呢,我们就要来讲解现在企业开发的主流技术 SpringBoot,并基于 SpringBoot 进行 Web 程序的开发 。

概述

在没有正式的学习 SpringBoot 之前,我们要先来了解下什么是 Spring。

我们可以打开 Spring 的官网(https://spring.io),去看一下 Spring 的简介:Spring makes Java simple。

Spring 的官方提供很多开源的项目,我们可以点击上面的 projects,看到 spring 家族旗下的项目,按照流行程度排序为:

Spring 发展到今天已经形成了一种开发生态圈,Spring 提供了若干个子项目,每个项目用于完成特定的功能。而我们在项目开发时,一般会偏向于选择这一套 spring 家族的技术,来解决对应领域的问题,那我们称这一套技术为 spring 全家桶

而 Spring 家族旗下这么多的技术,最基础、最核心的是 SpringFramework。其他的 spring 家族的技术,都是基于 SpringFramework 的,SpringFramework 中提供很多实用功能,如:依赖注入、事务管理、web 开发支持、数据访问、消息服务等等。

而如果我们在项目中,直接基于 SpringFramework 进行开发,存在两个问题:

  • 配置繁琐
  • 入门难度大

所以基于此呢,spring 官方推荐我们从另外一个项目开始学习,那就是目前最火爆的 SpringBoot。 通过 springboot 就可以快速的帮我们构建应用程序,所以 springboot 呢,最大的特点有两个 :

  • 简化配置
  • 快速开发

Spring Boot 可以帮助我们非常快速的构建应用程序、简化开发、提高效率 。

而直接基于 SpringBoot 进行项目构建和开发,不仅是 Spring 官方推荐的方式,也是现在企业开发的主流。

入门程序

需求

需求:基于 SpringBoot 的方式开发一个 web 应用,浏览器发起请求/hello 后,给浏览器返回字符串 “Hello xxx ~”。

开发步骤

第 1 步:创建 SpringBoot 工程,并勾选 Web 开发相关依赖

第 2 步:定义 HelloController 类,添加方法 hello,并添加注解

1). 创建 SpringBoot 工程(需要联网)

基于 Spring 官方骨架,创建 SpringBoot 工程。

基本信息描述完毕之后,勾选 web 开发相关依赖。

[!TIP]
SpringBoot 官方提供的脚手架,里面只能够选择 SpringBoot 的几个最新的版本,如果要选择其他相对低一点的版本,可以在 springboot 项目创建完毕之后,修改项目的 pom.xml 文件中的版本号。

点击 Create 之后,就会联网创建这个 SpringBoot 工程,创建好之后,结构如下:

注意:在联网创建过程中,会下载相关资源(请耐心等待)

**2). **定义 HelloController 类,添加方法 hello,并添加注解

com.itheima 这个包下新建一个类:HelloController

HelloController 中的内容,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.itheima;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController //标识当前类是一个请求处理类
public class HelloController {

@RequestMapping("/hello") //标识请求路径
public String hello(String name){
System._out_.println("HelloController ... hello: " + name);
return "Hello " + name;
}

}

3). 运行测试

运行 SpringBoot 自动生成的引导类 (标识有 @SpringBootApplication 注解的类)

打开浏览器,输入 http://localhost:8080/hello?name=itheima

常见问题

大伙儿在下来联系的时候,联网基于 spring 的脚手架创建 SpringBoot 项目,偶尔可能会因为网内网络的原因,链接不上 SpringBoot 的脚手架网站,此时会出现如下现象:

此时可以使用阿里云提供的脚手架,网址为:https://start.aliyun.com

然后按照项目创建的向导,一步一步的创建项目即可。

入门解析

那在上面呢,我们已经完成了 SpringBootWeb 的入门程序,并且测试通过。 在入门程序中,我们发现,我们只需要一个 main 方法就可以将 web 应用启动起来了,然后就可以打开浏览器访问了。

那接下来我们需要明确两个问题:

**1). **为什么一个 main 方法就可以将 Web 应用启动了?

因为我们在创建 springboot 项目的时候,选择了 web 开发的起步依赖 spring-boot-starter-web。而 spring-boot-starter-web 依赖,又依赖了 spring-boot-starter-tomcat,由于 maven 的依赖传递特性,那么在我们创建的 springboot 项目中也就已经有了 tomcat 的依赖,这个其实就是 springboot 中内嵌的 tomcat。

而我们运行引导类中的 main 方法,其实启动的就是 springboot 中内嵌的 Tomcat 服务器。 而我们所开发的项目,也会自动的部署在该 tomcat 服务器中,并占用 8080 端口号 。

[!TIP]
起步依赖:

HTTP 协议

HTTP 概述

介绍

HTTP:Hyper Text Transfer Protocol(超文本传输协议),规定了浏览器与服务器之间数据传输的规则。

  • http 是互联网上应用最为广泛的一种网络协议
  • http 协议要求:浏览器在向服务器发送请求数据时,或是服务器在向浏览器发送响应数据时,都必须按照固定的格式进行数据传输

如果想知道 http 协议的数据传输格式有哪些,可以打开浏览器,点击 F12 打开开发者工具,点击 Network(网络) 来查看

浏览器向服务器进行请求时,服务器按照固定的格式进行解析:

服务器向浏览器进行响应时,浏览器按照固定的格式进行解析:

而我们学习 HTTP 协议,就是来学习请求和响应数据的具体格式内容。

特点

我们刚才初步认识了 HTTP 协议,那么我们在看看 HTTP 协议有哪些特点:

  • 基于 TCP 协议: 面向连接,安全

TCP 是一种面向连接的(建立连接之前是需要经过三次握手)、可靠的、基于字节流的传输层通信协议,在数据传输方面更安全

  • 基于请求-响应模型: 一次请求对应一次响应(先请求后响应)

请求和响应是一一对应关系,没有请求,就没有响应

  • HTTP 协议是无状态协议: 对于数据没有记忆能力。每次请求-响应都是独立的

无状态指的是客户端发送 HTTP 请求给服务端之后,服务端根据请求响应数据,响应完后,不会记录任何信息。

  • 请求之间无法共享数据会引发的问题:
    • 如:京东购物。加入购物车和去购物车结算是两次请求
    • 由于 HTTP 协议的无状态特性,加入购物车请求响应结束后,并未记录加入购物车是何商品
    • 发起去购物车结算的请求后,因为无法获取哪些商品加入了购物车,会导致此次请求无法正确展示数据
  • 具体使用的时候,我们发现京东是可以正常展示数据的,原因是 Java 早已考虑到这个问题,并提出了使用会话技术(Cookie、Session)来解决这个问题。具体如何来做,我们后面课程中会讲到。

刚才提到 HTTP 协议是规定了请求和响应数据的格式,那具体的格式是什么呢? 接下来,我们就来详细剖析。

HTTP 协议又分为:请求协议和响应协议

HTTP 请求协议

介绍

  • **请求协议:**浏览器将数据以请求格式发送到服务器。包括:**请求行、请求头 、请求体
  • GET 方式的请求协议:

  • 请求行(以上图中红色部分) :HTTP 请求中的第一行数据。由:请求方式资源路径协议/版本 组成(之间使用空格分隔)

    • 请求方式:GET
    • 资源路径:/brand/findAll?name=OPPO&status=1
      • 请求路径:/brand/findAll
      • 请求参数:name=OPPO&status=1
        • 请求参数是以 key=value 形式出现
        • 多个请求参数之间使用 & 连接
      • 请求路径和请求参数之间使用 ? 连接
    • 协议/版本:HTTP/1.1
  • 请求头(以上图中黄色部分) :第二行开始,上图黄色部分内容就是请求头。格式为 key: value 形式

    • http 是个无状态的协议,所以在请求头设置浏览器的一些自身信息和想要响应的形式。这样服务器在收到信息后,就可以知道是谁,想干什么了

    • 常见的 HTTP 请求头有:

      image-20260605130805536

      image-20260605131039065

      image-20260605131147709

      image-20260605131824407

举例说明:服务端可以根据请求头中的内容来获取客户端的相关信息,有了这些信息服务端就可以处理不同的业务需求。
比如:

  • 不同浏览器解析 HTML 和 CSS 标签的结果会有不一致,所以就会导致相同的代码在不同的浏览器会出现不同的效果
  • 服务端根据客户端请求头中的数据获取到客户端的浏览器类型,就可以根据不同的浏览器设置不同的代码来达到一致的效果(这就是我们常说的浏览器兼容问题)
  • 请求体 :存储请求参数
    • GET 请求的请求参数在请求行中,故不需要设置请求体

POST 方式的请求协议:

  • 请求行(以上图中红色部分):包含请求方式、资源路径、协议/版本

    • 请求方式:POST
    • 资源路径:/brand
    • 协议/版本:HTTP/1.1
  • 请求头(以上图中黄色部分)

  • 请求体(以上图中绿色部分) :存储请求参数

    • 请求体和请求头之间是有一个空行隔开(作用:用于标记请求头结束)

GET 请求和 POST 请求的区别:

**区别方式**
**GET请求**
**POST请求**
请求参数
请求参数在请求行中。
例:/brand/findAll?name=OPPO&status=1
请求参数在请求体中
请求参数长度
请求参数长度有限制(浏览器不同限制也不同)
请求参数长度没有限制
安全性
安全性低。原因:请求参数暴露在浏览器地址栏中。
安全性相对高

获取请求数据

Web 服务器(Tomcat)对 HTTP 协议的请求数据进行解析,并进行了封装(HttpServletRequest),并在调用 Controller 方法的时候传递给了该方法。这样,就使得程序员不必直接对协议进行操作,让 Web 开发更加便捷。

代码演示如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@RestController
public class RequestController {

_/**_
_ * 请求路径 http://localhost:8080/request?name=Tom&age=18_
_ * @param request_
_ * @return_
_ */_
_ _@RequestMapping("/request")
public String request(HttpServletRequest request){
//1.获取请求参数 name, age
String name = request.getParameter("name");
String age = request.getParameter("age");
System._out_.println("name = " + name + ", age = " + age);

//2.获取请求路径
String uri = request.getRequestURI();
String url = request.getRequestURL().toString();
System._out_.println("uri = " + uri);
System._out_.println("url = " + url);

//3.获取请求方式
String method = request.getMethod();
System._out_.println("method = " + method);

//4.获取请求头
String header = request.getHeader("User-Agent");
System._out_.println("header = " + header);
return "request success";
}

}

最终输出内容如下所示:

image-20260605131540501

HTTP 响应协议

格式介绍

  • 响应协议:服务器将数据以响应格式返回给浏览器。包括:**响应行 **、响应头 、响应体

  • 响应行(以上图中红色部分):响应数据的第一行。响应行由 协议及版本响应状态码状态码描述 组成

    • 协议/版本:HTTP/1.1
    • 响应状态码:200
    • 状态码描述:OK
  • 响应头(以上图中黄色部分):响应数据的第二行开始。格式为 key:value 形式

    • http 是个无状态的协议,所以可以在请求头和响应头中设置一些信息和想要执行的动作,这样,对方在收到信息后,就可以知道你是谁,你想干什么
    • 常见的 HTTP 响应头有:

    image-20260605131707105

    image-20260605132442682

    • 响应体(以上图中绿色部分): 响应数据的最后一部分。存储响应的数据

    • 响应体和响应头之间有一个空行隔开(作用:用于标记响应头结束)

      image-20260605132623004

    • 设置响应数据

      Web服务器对HTTP协议的响应数据进行了封装(HttpServletResponse),并在调用Controller方法的时候传递给了该方法。这样,就使得程序员不必直接对协议进行操作,让Web开发更加便捷。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.itheima;

import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.io.IOException;

@RestController
public class ResponseController {

@RequestMapping("/response")
public void response(HttpServletResponse response) throws IOException {
//1.设置响应状态码
response.setStatus(401);
//2.设置响应头
response.setHeader("name","itcast");
//3.设置响应体
response.setContentType("text/html;charset=utf-8");
response.setCharacterEncoding("utf-8");
response.getWriter().write("<h1>hello response</h1>");
}

@RequestMapping("/response2")
public ResponseEntity<String> response2(HttpServletResponse response) throws IOException {
return ResponseEntity
._status_(401)
.header("name","itcast")
.body("<h1>hello response</h1>");
}

}

浏览器访问测试:

[!TIP]
响应状态码 和 响应头如果没有特殊要求的话,通常不手动设定。服务器会根据请求处理的逻辑,自动设置响应状态码和响应头。

image-20260605133132919

image-20260605133207444

SpringBootWeb 案例

需求说明

需求:基于 SpringBoot 开发 web 程序,完成用户列表的渲染展示

当在浏览器地址栏,访问前端静态页面(http://localhost:8080/usre.html)后,在前端页面上,会发送 ajax 请求,请求服务端(http://localhost:8080/list),服务端程序加载 user.txt 文件中的数据,读取出来后最终给前端页面响应 json 格式的数据,前端页面再将数据渲染展示在表格中。

代码实现

1). 准备工作:再创建一个 SpringBoot 工程,并勾选 web 依赖、lombok 依赖

2). 准备工作:引入资料中准备好的数据文件 user.txt,以及 static 下的前端静态页面

这些文件,在提供的资料中,已经提供了直接导入进来即可。

3). 准备工作:定义封装用户信息的实体类。

com.itheima 下再定义一个包 pojo,专门用来存放实体类。 在该包下定义一个实体类 User:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.itheima.pojo;

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

_/**_
_ * 封装用户信息_
_ */_
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
private Integer id;
private String username;
private String password;
private String name;
private Integer age;
private LocalDateTime updateTime;
}

3). 开发服务端程序,接收请求,读取文本数据并响应

由于在案例中,需要读取文本中的数据,并且还需要将对象转为 json 格式,所以这里呢,我们在项目中再引入一个非常常用的工具包 hutool。 然后调用里面的工具类,就可以非常方便快捷的完成业务操作。

  • pom.xml 中引入依赖
1
2
3
4
5
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.27</version>
</dependency>
  • com.itheima 包下新建一个子包 controller,在其中创建一个 UserController
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import cn.hutool.core.io.IoUtil;
import cn.hutool.json.JSONConfig;
import cn.hutool.json.JSONUtil;
import com.itheima.pojo.User;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

@RestController
public class UserController {

@RequestMapping("/list")
public String list(){
//1.加载并读取文件 src下面的java包和resources包编译后会放在同一级目录下也就是类路径下,可以通过字节码对象获取到类加载器(this.getClass().getClassLoader())来获取文件 getResourceAsStream是类路径下的某一个资源文件的url对象, 这里获取一个输入流 指定resources下的文件名即可
InputStream in = this.getClass().getClassLoader().getResourceAsStream("user.txt");

ArrayList<String> lines = IoUtil._readLines_(in, StandardCharsets._UTF_8_, new ArrayList<>());

//2.解析数据,封装成对象 --> 集合
List<User> userList = lines.stream().map(line -> {
String[] parts = line.split(",");
Integer id = Integer.parseInt(parts[0]);
String username = parts[1];
String password = parts[2];
String name = parts[3];
Integer age = Integer.parseInt(parts[4]);
LocalDateTime updateTime = LocalDateTime.parse(parts[5], DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));

return new User(id, username, password, name, age, updateTime);
}).collect(Collectors.toList());

//3.响应数据
//return JSONUtil._toJsonStr_(userList, JSONConfig._create_().setDateFormat("yyyy-MM-dd HH:mm:ss"));
//自动将list集合转化为json格式的数据
return userList;
}

}

**4). 启动服务测试,访问:**http://localhost:8080/user.html

@ResponseBody

(ResponseBody将controller返回值直接作为响应体的数据响应,返回值是对象/集合 - >Json ->响应)

前面我们学习过 HTTL 协议的交互方式:请求响应模式(有请求就有响应)。那么 Controller 程序呢,除了接收请求外,还可以进行响应。

在我们前面所编写的 controller 方法中,都已经设置了响应数据。

controller 方法中的 return 的结果,怎么就可以响应给浏览器呢?

答案:使用 @ResponseBody 注解

@ResponseBody注解:

  • 类型:方法注解、类注解
  • 位置:书写在 Controller 方法上或类上
  • 作用:将方法返回值直接响应给浏览器,如果返回值类型是实体对象/集合,将会转换为 JSON 格式后在响应给浏览器

但是在我们所书写的 Controller 中,只在类上添加了 @RestController 注解、方法添加了 @RequestMapping 注解,并没有使用 @ResponseBody 注解,怎么给浏览器响应呢?

这是因为,我们在类上加了 @RestController 注解,而这个注解是由两个注解组合起来的,分别是:@Controller 、@ResponseBody。 那也就意味着,我们在类上已经添加了 @ResponseBody 注解了,而一旦在类上加了 @ResponseBody 注解,就相当于该类所有的方法中都已经添加了 @ResponseBody 注解。

提示:前后端分离的项目中,一般直接在请求处理类上加 @RestController 注解,就无需在方法上加 @ResponseBody 注解了。

image-20260605135354783

问题分析

上述案例的功能,我们虽然已经实现,但是呢,我们会发现案例中:解析文本文件中的数据,处理数据的逻辑代码,给页面响应的代码全部都堆积在一起了,全部都写在 controller 方法中了。

当前程序的这个业务逻辑还是比较简单的,如果业务逻辑再稍微复杂一点,我们会看到 Controller 方法的代码量就很大了。

  • 当我们要修改操作数据部分的代码,需要改动 Controller
  • 当我们要完善逻辑处理部分的代码,需要改动 Controller
  • 当我们需要修改数据响应的代码,还是需要改动 Controller

这样呢,就会造成我们整个工程代码的复用性比较差,而且代码难以维护。 那如何解决这个问题呢?其实在现在的开发中,有非常成熟的解决思路,那就是分层开发。

分层解耦

三层架构

介绍

在我们进行程序设计以及程序开发时,尽可能让每一个接口、类、方法的职责更单一些(单一职责原则)。

单一职责原则:一个类或一个方法,就只做一件事情,只管一块功能。
这样就可以让类、接口、方法的复杂度更低,可读性更强,扩展性更好,也更利于后期的维护。

我们之前开发的程序呢,并不满足单一职责原则。下面我们来分析下之前的程序:

那其实我们上述案例的处理逻辑呢,从组成上看可以分为三个部分:

  • 数据访问:负责业务数据的维护操作,包括增、删、改、查等操作。
  • 逻辑处理:负责业务逻辑处理的代码。
  • 请求处理、响应数据:负责,接收页面的请求,给页面响应数据。

按照上述的三个组成部分,在我们项目开发中呢,可以将代码分为三层,如图所示:

  • Controller:控制层。接收前端发送的请求,对请求进行处理,并响应数据。
  • Service:业务逻辑层。处理具体的业务逻辑。
  • Dao:数据访问层(Data Access Object),也称为持久层。负责数据访问操作,包括数据的增、删、改、查。

基于三层架构的程序执行流程,如图所示:

  • 前端发起的请求,由 Controller 层接收(Controller 响应数据给前端)
  • Controller 层调用 Service 层来进行逻辑处理(Service 层处理完后,把处理结果返回给 Controller 层)
  • Serivce 层调用 Dao 层(逻辑处理过程中需要用到的一些数据要从 Dao 层获取)
  • Dao 层操作文件中的数据(Dao 拿到的数据会返回给 Service 层)

思考:按照三层架构的思想,如果要对业务逻辑(Service 层)进行变更,会影响到 Controller 层和 Dao 层吗?
答案:不会影响。 (程序的扩展性、维护性变得更好了)

代码拆分

我们使用三层架构思想,来改造下之前的程序:

  • 控制层包名:com.itheima.controller
  • 业务逻辑层包名:com.itheima.service
  • 数据访问层包名:com.itheima.dao

1). 控制层:接收前端发送的请求,对请求进行处理,并响应数据

com.itheima.controller 中创建 UserController 类,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.itheima.controller;

import com.itheima.pojo.User;
import com.itheima.service.UserService;
import com.itheima.service.impl.UserServiceImpl;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;

@RestController
public class UserController {

private UserService userService = new UserServiceImpl();

@RequestMapping("/list")
public List<User> list(){
//1.调用Service
List<User> userList = userService.findAll();
//2.响应数据
return userList;
}

}

**2). 业务逻辑层:**处理具体的业务逻辑

com.itheima.service 中创建 UserSerivce 接口,代码如下:

1
2
3
4
5
6
7
8
9
10
package com.itheima.service;

import com.itheima.pojo.User;
import java.util.List;

public interface UserService {

public List<User> findAll();

}

com.itheima.service.``impl 中创建 UserSerivceImpl 接口,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package com.itheima.service.impl;

import com.itheima.dao.UserDao;
import com.itheima.dao.impl.UserDaoImpl;
import com.itheima.pojo.User;
import com.itheima.service.UserService;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.stream.Collectors;

public class UserServiceImpl implements UserService {

private UserDao userDao = new UserDaoImpl();

@Override
public List<User> findAll() {
List<String> lines = userDao.findAll();
List<User> userList = lines.stream().map(line -> {
String[] parts = line.split(",");
Integer id = Integer._parseInt_(parts[0]);
String username = parts[1];
String password = parts[2];
String name = parts[3];
Integer age = Integer._parseInt_(parts[4]);
LocalDateTime updateTime = LocalDateTime._parse_(parts[5], DateTimeFormatter._ofPattern_("yyyy-MM-dd HH:mm:ss"));
return new User(id, username, password, name, age, updateTime);
}).collect(Collectors._toList_());
return userList;
}
}

3). 数据访问层:负责数据的访问操作,包含数据的增、删、改、查

com.itheima.dao 中创建 UserDao 接口,代码如下:

1
2
3
4
5
6
7
8
9
package com.itheima.dao;

import java.util.List;

public interface UserDao {

public List<String> findAll();

}

com.itheima.dao.impl 中创建 UserDaoImpl 接口,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.itheima.dao.impl;

import cn.hutool.core.io.IoUtil;
import com.itheima.dao.UserDao;

import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

public class UserDaoImpl implements UserDao {
@Override
public List<String> findAll() {
InputStream in = this.getClass().getClassLoader().getResourceAsStream("user.txt");
ArrayList<String> lines = IoUtil._readLines_(in, StandardCharsets._UTF_8_, new ArrayList<>());
return lines;
}
}

具体的请求调用流程:

[!TIP]
三层架构的好处:

  1. 复用性强
  2. 便于维护
  3. 利用扩展

image-20260605140136581

分层解耦

问题分析

由于我们现在在程序中,需要什么对象,直接 new 一个对象 new UserServiceImpl()

如果说我们需要更换实现类,比如由于业务的变更,UserServiceImpl 不能满足现有的业务需求,我们需要切换为 UserServiceImpl2 这套实现,就需要修改 Contorller 的代码,需要创建 UserServiceImpl2 的实现 new UserServiceImpl2()

Service 中调用 Dao,也是类似的问题。这种呢,我们就称之为层与层之间 耦合 了。 那什么是耦合呢 ?

首先需要了解软件开发涉及到的两个概念:内聚和耦合。

  • **内聚:**软件中各个功能模块内部的功能联系。
  • **耦合:**衡量软件中各个层/模块之间的依赖、关联的程度。

软件设计原则:高内聚低耦合。

高内聚**:**指的是一个模块中各个元素之间的联系的紧密程度,如果各个元素(语句、程序段)之间的联系程度越高,则内聚性越高,即 “高内聚”。
**低耦合:**指的是软件中各个层、模块之间的依赖关联程序越低越好。

目前层与层之间是存在耦合的,Controller 耦合了 Service、Service 耦合了 Dao。而 高内聚、低耦合的目的是使程序模块的可重用性、移植性大大增强。

那最终我们的目标呢,就是做到层与层之间,尽可能的降低耦合,甚至解除耦合。

解耦思路

之前我们在编写代码时,需要什么对象,就直接 new 一个就可以了。 这种做法呢,层与层之间代码就耦合了,当 service 层的实现变了之后, 我们还需要修改 controller 层的代码。

那应该怎么解耦呢?

1). 首先不能在 EmpController 中使用 new 对象。代码如下:

此时,就存在另一个问题了,不能 new,就意味着没有业务层对象(程序运行就报错),怎么办呢?

我们的解决思路是:

  • 提供一个容器,容器中存储一些对象(例:UserService 对象)
  • Controller 程序从容器中获取 UserService 类型的对象

2). 将要用到的对象交给一个容器管理。

3). 应用程序中用到这个对象,就直接从容器中获取

那问题来了,我们如何将对象交给容器管理呢? 程序运行时,容器如何为程序提供依赖的对象呢?

我们想要实现上述解耦操作,就涉及到 Spring 中的两个核心概念:

[!TIP]

  • 控制反转**:** Inversion Of Control,简称 IOC。对象的创建控制权由程序自身转移到外部(容器),这种思想称为控制反转。

    • 对象的创建权由程序员主动创建转移到容器(由容器创建、管理对象)。这个容器称为:IOC 容器或 Spring 容器。
  • 依赖注入**:** Dependency Injection,简称 DI。容器为应用程序提供运行时,所依赖的资源,称之为依赖注入。

    • 程序运行时需要某个资源,此时容器就为其提供这个资源。
    • 例:EmpController 程序运行时需要 EmpService 对象,Spring 容器就为其提供并注入 EmpService 对象。
  • **bean 对象:**IOC 容器中创建、管理的对象,称之为:bean 对象。

IOC&DI 入门

1). 将 Service 及 Dao 层的实现类,交给 IOC 容器管理

在实现类加上 @Component 注解,就代表把当前类产生的对象交给 IOC 容器管理。

A. UserDaoImpl

1
2
3
4
5
6
7
8
9
**@Component**
public class UserDaoImpl implements UserDao {
@Override
public List<String> findAll() {
InputStream in = this.getClass().getClassLoader().getResourceAsStream("user.txt");
ArrayList<String> lines = IoUtil._readLines_(in, StandardCharsets._UTF_8_, new ArrayList<>());
return lines;
}
}

B. UserServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
**@****Component**
public class UserServiceImpl implements UserService {

private UserDao userDao;

@Override
public List<User> findAll() {
List<String> lines = userDao.findAll();
List<User> userList = lines.stream().map(line -> {
String[] parts = line.split(",");
Integer id = Integer._parseInt_(parts[0]);
String username = parts[1];
String password = parts[2];
String name = parts[3];
Integer age = Integer._parseInt_(parts[4]);
LocalDateTime updateTime = LocalDateTime._parse_(parts[5], DateTimeFormatter._ofPattern_("yyyy-MM-dd HH:mm:ss"));
return new User(id, username, password, name, age, updateTime);
}).collect(Collectors._toList_());
return userList;
}
}

2). 为 Controller 及 Service 注入运行时所依赖的对象

A. UserServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component
public class UserServiceImpl implements UserService {

**@Autowired**
private UserDao userDao;

@Override
public List<User> findAll() {
List<String> lines = userDao.findAll();
List<User> userList = lines.stream().map(line -> {
String[] parts = line.split(",");
Integer id = Integer._parseInt_(parts[0]);
String username = parts[1];
String password = parts[2];
String name = parts[3];
Integer age = Integer._parseInt_(parts[4]);
LocalDateTime updateTime = LocalDateTime._parse_(parts[5], DateTimeFormatter._ofPattern_("yyyy-MM-dd HH:mm:ss"));
return new User(id, username, password, name, age, updateTime);
}).collect(Collectors._toList_());
return userList;
}
}

B. UserController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
public class UserController {

**@Autowired**
private UserService userService;

@RequestMapping("/list")
public List<User> list(){
//1.调用Service
List<User> userList = userService.findAll();
//2.响应数据
return userList;
}

}

启动服务,运行测试。 打开浏览器,地址栏直接访问:http://localhost:8080/user.html 。 依然正常访问,就说明入门程序完成了。 已经完成了层与层之间的解耦。

image-20260605153257379

IOC 详解

通过 IOC 和 DI 的入门程序呢,我们已经基本了解了 IOC 和 DI 的基础操作。接下来呢,我们学习下 IOC 控制反转和 DI 依赖注入的细节。

4.3.4.1 Bean 的声明

前面我们提到 IOC 控制反转,就是将对象的控制权交给 Spring 的 IOC 容器,由 IOC 容器创建及管理对象。IOC 容器创建的对象称为 bean 对象。

在之前的入门案例中,要把某个对象交给 IOC 容器管理,需要在类上添加一个注解:@Component

而 Spring 框架为了更好的标识 web 应用程序开发当中,bean 对象到底归属于哪一层,又提供了 @Component 的衍生注解:

注解
说明
位置
@Component
声明bean的基础注解
不属于以下三类时,用此注解
@Controller
@Component的衍生注解
标注在控制层类上
@Service
@Component的衍生注解
标注在业务层类上
@Repository
@Component的衍生注解
标注在数据访问层类上(由于与mybatis整合,用的少)

那么此时,我们就可以使用 @Service 注解声明 Service 层的 bean。 使用 @Repository 注解声明 Dao 层的 bean。 代码实现如下:

Service 层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
**@Service**
public class UserServiceImpl implements UserService {

private UserDao userDao;

@Override
public List<User> findAll() {
List<String> lines = userDao.findAll();
List<User> userList = lines.stream().map(line -> {
String[] parts = line.split(",");
Integer id = Integer._parseInt_(parts[0]);
String username = parts[1];
String password = parts[2];
String name = parts[3];
Integer age = Integer._parseInt_(parts[4]);
LocalDateTime updateTime = LocalDateTime._parse_(parts[5], DateTimeFormatter._ofPattern_("yyyy-MM-dd HH:mm:ss"));
return new User(id, username, password, name, age, updateTime);
}).collect(Collectors._toList_());
return userList;
}
}

Dao 层:

1
2
3
4
5
6
7
8
9
**@Repository**
public class UserDaoImpl implements UserDao {
@Override
public List<String> findAll() {
InputStream in = this.getClass().getClassLoader().getResourceAsStream("user.txt");
ArrayList<String> lines = IoUtil._readLines_(in, StandardCharsets._UTF_8_, new ArrayList<>());
return lines;
}
}

[!TIP]
注意 1:声明 bean 的时候,可以通过注解的 value 属性指定 bean 的名字,如果没有指定,默认为类名首字母小写。
注意 2:使用以上四个注解都可以声明 bean,但是在 springboot 集成 web 开发中,声明控制器 bean 只能用 @Controller。

4.3.4.2 组件扫描

[!TIP]
问题:使用前面学习的四个注解声明的 bean,一定会生效吗?
答案:不一定。(原因:bean 想要生效,还需要被组件扫描)

  • 前面声明 bean 的四大注解,要想生效,还需要被组件扫描注解 @ComponentScan 扫描。
  • 该注解虽然没有显式配置,但是实际上已经包含在了启动类声明注解 @SpringBootApplication 中,默认扫描的范围是启动类所在包及其子包。

所以,我们在项目开发中,只需要按照如上项目结构,将项目中的所有的业务类,都放在启动类所在包的子包中,就无需考虑组件扫描问题。

image-20260605154059225

DI 详解

上一小节我们讲解了控制反转 IOC 的细节,接下来呢,我们学习依赖注解 DI 的细节。

依赖注入,是指 IOC 容器要为应用程序去提供运行时所依赖的资源,而资源指的就是对象。

在入门程序案例中,我们使用了 @Autowired 这个注解,完成了依赖注入的操作,而这个 Autowired 翻译过来叫:自动装配。

@Autowired 注解,默认是按照类型进行自动装配的(去 IOC 容器中找某个类型的对象,然后完成注入操作)

入门程序举例:在 EmpController 运行的时候,就要到 IOC 容器当中去查找 EmpService 这个类型的对象,而我们的 IOC 容器中刚好有一个 EmpService 这个类型的对象,所以就找到了这个类型的对象完成注入操作。

@Autowired 用法

@Autowired 进行依赖注入,常见的方式,有如下三种:

1). 属性注入

1
2
3
4
5
6
7
8
@RestController
public class UserController {

//方式一: 属性注入
@Autowired
private UserService userService;

}
  • 优点:代码简洁、方便快速开发。
  • 缺点:隐藏了类之间的依赖关系、可能会破坏类的封装性。

2). 构造函数注入

1
2
3
4
5
6
7
8
9
10
11
12
@RestController
public class UserController {

//方式二: 构造器注入
private final UserService userService;

@Autowired //如果当前类中只存在一个构造函数, @Autowired可以省略
public UserController(UserService userService) {
this.userService = userService;
}

}
  • 优点:能清晰地看到类的依赖关系、提高了代码的安全性。
  • 缺点:代码繁琐、如果构造参数过多,可能会导致构造函数臃肿。
  • 注意:如果只有一个构造函数,@Autowired 注解可以省略。(通常来说,也只有一个构造函数)

3). setter 注入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
_/**_
_ * 用户信息Controller_
_ */_
@RestController
public class UserController {

//方式三: setter注入
private UserService userService;

@Autowired
public void setUserService(UserService userService) {
this.userService = userService;
}

}
  • 优点:保持了类的封装性,依赖关系更清晰。
  • 缺点:需要额外编写 setter 方法,增加了代码量。

[!TIP]
在项目开发中,基于 @Autowired 进行依赖注入时,基本都是第一种和第二种方式。(官方推荐第二种方式,因为会更加规范)但是在企业项目开发中,很多的项目中,也会选择第一种方式因为更加简洁、高效(在规范性方面进行了妥协)。

注意事项

那如果在 IOC 容器中,存在多个相同类型的 bean 对象,会出现什么情况呢?

在下面的例子中,我们准备了两个 UserService 的实现类,并且都交给了 IOC 容器管理。 代码如下:

此时,我们启动项目会发现,控制台报错了:

出现错误的原因呢,是因为在 Spring 的容器中,UserService 这个类型的 bean 存在两个,框架不知道具体要注入哪个 bean 使用,所以就报错了。

如何解决上述问题呢?Spring 提供了以下几种解决方案:

  • @Primary
  • @Qualifier
  • @Resource

方案一:使用 @Primary 注解

当存在多个相同类型的 Bean 注入时,加上 @Primary 注解,来确定默认的实现。

1
2
3
4
**@Primary**
@Service
public class UserServiceImpl implements UserService {
}

方案二:使用 @Qualifier 注解

指定当前要注入的 bean 对象。 在 @Qualifier 的 value 属性中,指定注入的 bean 的名称。 @Qualifier 注解不能单独使用,必须配合 @Autowired 使用。

1
2
3
4
5
6
@RestController
public class UserController {

**@Qualifier("userServiceImpl")**
@Autowired
private UserService userService;

方案三:使用 @Resource 注解

是按照 bean 的名称进行注入。通过 name 属性指定要注入的 bean 的名称。

1
2
3
4
5
@RestController
public class UserController {

**@Resource(name = "****userServiceImpl****")**
private UserService userService;

[!TIP]
面试题:@Autowird 与 @Resource 的区别

  • @Autowired 是 spring 框架提供的注解,而 @Resource 是 JDK 提供的注解
  • @Autowired 默认是按照类型注入,而 @Resource 是按照名称注入

image-20260605154844986

image-20260605155048701

附录:常见状态码

image-20260606091557078

什么是REST风格呢?

  • REST(Representational State Transfer),表述性状态转换,它是一种软件架构风格。

传统URL风格如下:

我们看到,原始的传统URL呢,定义比较复杂,而且将资源的访问行为对外暴露出来了。而且,对于开发人员来说,每一个开发人员都有自己的命名习惯,就拿根据id查询用户信息来说的,不同的开发人员定义的路径可能是这样的:getByIdselectByIdqueryByIdloadById… 。 每一个人都有自己的命名习惯,如果都按照各自的习惯来,一个项目组,几十号或上百号人,那最终开发出来的项目,将会变得难以维护,没有一个统一的标准。

基于REST风格URL如下:

其中总结起来,就一句话:通过URL定位要操作的资源,通过HTTP动词(请求方式)来描述具体的操作。

在REST风格的URL中,通过四种请求方式,来操作数据的增删改查。

  • GET : 查询
  • POST :新增
  • PUT : 修改
  • DELETE :删除

我们看到如果是基于REST风格,定义URL,URL将会更加简洁、更加规范、更加优雅

image-20260606091844171

13-后端 Web 进阶(AOP)

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

什么是 AOP?

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

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

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

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

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

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

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

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

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

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

AOP 基础

AOP 入门

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

  • 需求:统计部门管理各个业务层方法执行耗时。
  • 原始方式:

在原始的实现方式中,我们需要在业务层的也一个方法执行执行,获取方法运行的开始时间; 然后运行原始的方法逻辑; 最后在每一个方法运行结束时,获取方法运行结束时间,计算执行耗时。

  • SpringAOP 实现步骤:

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

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

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@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;
}
}

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

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

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

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

这些都是 AOP 应用的典型场景。

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

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

image-20260608170246738

AOP 核心概念

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

  • **连接点:**JoinPoint,可以被 AOP 控制的方法(暗含方法执行时的相关信息)

    • 连接点指的是可以被 aop 控制的方法。例如:入门程序当中所有的业务方法都是可以被 aop 控制的方法。
    • 在 SpringAOP 提供的 JoinPoint 当中,封装了连接点方法在执行时的相关信息。(后面会有具体的讲解)
  • **通知:**Advice,指哪些重复的逻辑,也就是共性功能(最终体现为一个方法)

    • 在入门程序中是需要统计各个业务方法的执行耗时的,此时我们就需要在这些业务方法运行开始之前,先记录这个方法运行的开始时间,在每一个业务方法运行结束的时候,再来记录这个方法运行的结束时间。
    • 是在 AOP 面向切面编程当中,我们只需要将这部分重复的代码逻辑抽取出来单独定义。抽取出来的这一部分重复的逻辑,也就是共性的功能。
  • **切入点:**PointCut,匹配连接点的条件,通知仅会在切入点方法执行时被应用。

    • 在通知当中,我们所定义的共性功能到底要应用在哪些方法上?此时就涉及到了切入点 pointcut 概念。切入点指的是匹配连接点的条件。通知仅会在切入点方法运行时才会被应用。
    • 在 aop 的开发当中,我们通常会通过一个切入点表达式来描述切入点(后面会有详解)。
    • 假如:切入点表达式改为 DeptServiceImpl.list(),此时就代表仅仅只有 list 这一个方法是切入点。只有 list()方法在运行的时候才会应用通知。
  • 切面:Aspect,描述通知与切入点的对应关系(通知 + 切入点)

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

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

  • 目标对象:Target,通知所应用的对象

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

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

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

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

AOP 进阶

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

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

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

通知类型

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@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 注解,就代表当前通知是一个环绕通知。

**Spring AOP 通知类型**
@Around
环绕通知,此注解标注的通知方法在目标方法前、后都被执行
@Before
前置通知,此注解标注的通知方法在目标方法前被执行
@After
后置通知,此注解标注的通知方法在目标方法后被执行,无论是否有异常都会执行
@AfterReturning
返回后通知,此注解标注的通知方法在目标方法后被执行,有异常不会执行
@AfterThrowing
异常后通知,此注解标注的通知方法发生异常后执行

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@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 业务实现类中的代码: 添加异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@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 环绕通知中原始方法调用时有异常,通知中的环绕后的代码逻辑也不会在执行了 (因为原始方法调用已经出异常了)

[!TIP]
在使用通知时的注意事项:

  • @Around 环绕通知需要自己调用 ProceedingJoinPoint.proceed() 来让原始方法执行,其他通知不需要考虑目标方法执行
  • @Around 环绕通知方法的返回值,必须指定为 Object,来接收原始方法的返回值,否则原始方法执行完毕,是获取不到返回值的。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//前置通知
@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 注解,该注解的作用是将公共的切入点表达式抽取出来,需要用到时引用该切入点表达式即可。

image-20260608175226359

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@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,而在引用的时候,具体的语法为:

1
2
3
4
5
6
7
8
9
10
@Slf4j
@Component
@Aspect
public class MyAspect2 {
//引用MyAspect1切面类中的切入点表达式
@Before("com.itheima.aspect.MyAspect1.pt()")
public void before(){
log.info("MyAspect2 -> before ...");
}
}

image-20260608175406671

通知顺序

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

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

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

定义多个切面类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@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 ...");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@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 ...");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@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 注解,控制通知的执行顺序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@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 ...");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@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 ...");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@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 服务,测试通知执行顺序:

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

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

切入点表达式

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

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

  • 作用:主要用来决定项目中的哪些方法需要加入通知

  • 常见形式:

    • execution(……):根据方法的签名来匹配
    • @annotation(……) :根据注解匹配

首先我们先学习第一种最为常见的 execution 切入点表达式。

execution

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

1
execution(访问修饰符?  返回值  包名.类名.?方法名(方法参数) throws 异常?)

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

  • 访问修饰符:可省略(比如: public、protected)
  • 包名.类名: 可省略
  • throws 异常:可省略(注意是方法上声明抛出的异常,不是实际抛出的异常)

示例:

1
@Before("execution(void com.itheima.service.impl.DeptServiceImpl.delete(java.lang.Integer))")

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

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

切入点表达式的语法规则:

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

切入点表达式示例

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

注意事项:

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

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

  • 所有业务方法名在命名时尽量规范,方便切入点表达式快速匹配。如:查询类方法都是 find 开头,更新类方法都是 update 开头
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//业务类
@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 开头的方法
1
execution(* com.itheima.service.impl.DeptServiceImpl.find*(..))
  • 描述切入点方法通常基于接口描述,而不是直接描述实现类,增强拓展性
1
execution(* com.itheima.service.DeptService.*(..))
  • 在满足业务需要的前提下,尽量缩小切入点的匹配范围。如:包名匹配尽量不使用 …,使用 * 匹配单个包
1
execution(* com.itheima.*.*.DeptServiceImpl.find*(..))

切入点表达式书写建议:

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

image-20260608183739393

@annotation

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

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

实现步骤:

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

自定义注解LogOperation

1
2
3
4
@Target(ElementType.METHOD)  #方法中生效
@Retention(RetentionPolicy.RUNTIME) #运行时
public @interface LogOperation{
}

业务类DeptServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@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);
}
}

切面类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@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 切入点表达式

    • 基于注解的方式来匹配切入点方法。这种方式虽然多一步操作,我们需要自定义一个注解,但是相对来比较灵活。我们需要匹配哪个方法,就在方法上加上对应的注解就可以了

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

AOP 案例

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

需求

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

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

操作日志信息包含:

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

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

分析

  • 问题 1:项目当中增删改相关的方法是不是有很多?

    • 很多
  • 问题 2:我们需要针对每一个功能接口方法进行修改,在每一个功能接口当中都来记录这些操作日志吗?

    • 这种做法比较繁琐

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

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

  • 问题 3:既然要基于 AOP 面向切面编程的方式来完成的功能,那么我们要使用 AOP 五种通知类型当中的哪种通知类型?

    • 答案:环绕通知 @Around。因为所记录的操作日志当中包括:操作人、操作时间,访问的是哪个类、哪个方法、方法运行时参数、方法的返回值、方法的运行时长。方法返回值,是在原始方法执行后才能获取到的。方法的运行时长,需要原始方法运行之前记录开始时间,原始方法运行之后记录结束时间。通过计算获得方法的执行耗时。基于以上的分析我们确定要使用 Around 环绕通知。
  • 问题 4:最后一个问题,切入点表达式我们该怎么写?

    • 答案:使用 @annotation 来描述切入点表达式。要匹配业务接口当中所有的增删改的方法,而增删改方法在命名上没有共同的前缀或后缀。此时如果使用 execution 切入点表达式也可以,但是会比较繁琐。 当遇到增删改的方法名没有规律时,就可以使用 @annotation 切入点表达式

步骤

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

  • 准备工作

    1. 引入 AOP 的起步依赖
    2. 导入资料中准备好的数据库表结构,并引入对应的实体类
  • 编码实现(基于 AI 实现)

    1. 自定义注解 @LogOperation
    2. 定义切面类,完成记录操作日志的逻辑

代码实现

[!TIP]
请帮我基于 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 的依赖
1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
  • 创建数据库表结构
1
2
3
4
5
6
7
8
9
10
11
-- 操作日志表
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 '操作日志表';
  • 引入资料中准备的实体类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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

1
2
3
4
5
6
7
/**
* 自定义注解,用于标识哪些方法需要记录日志
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogOperation {
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestController
@RequestMapping("/clazzs")
public class ClazzController {

@Autowired
private ClazzService clazzService;

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

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

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

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

连接点

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

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

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

  • 对于 @Around 通知,获取连接点信息只能使用 ProceedingJoinPoint 类型

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

获取当前登录员工

  • 员工登录成功后,哪里存储的有当前登录员工的信息? 给客户端浏览器下发的 jwt 令牌中
  • 如何从 JWT 令牌中获取当前登录用户的信息呢? 获取请求头中传递的 jwt 令牌,并解析
  • TokenFilter 中已经解析了令牌的信息,如何传递给 AOP 程序、Controller、Service 呢?ThreadLocal

ThreadLocal

  • ThreadLocal** **并不是一个 Thread,而是 Thread 的局部变量。
  • ThreadLocal 为每个线程提供一份单独的存储空间,具有线程隔离的效果,不同的线程之间不会相互干扰。

  • 常见方法:
    • public void set(T value)`` 设置当前线程的线程局部变量的值
    • public T get()`` 返回当前线程所对应的线程局部变量的值
    • public void remove()`` 移除当前线程的线程局部变量

记录当前登录员工

具体操作步骤:

  1. 定义 ThreadLocal 操作的工具类,用于操作当前登录员工 ID。

com.itheima.utils 引入工具类 CurrentHolder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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(用完之后需将其删除)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
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。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
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_();
}
}

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

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

image-20260608193804414

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

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

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

  1. 配置优先级
  2. Bean 的管理
  3. 剖析 Springboot 的底层原理

配置优先级

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

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

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

  • application.properties
1
server.port=8081
  • application.yml
1
2
server:
port: 8082
  • application.yaml
1
2
server:
port: 8082

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

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

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

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

[!TIP]
yaml、yml 两种配置文件,优先级最高的是 yml。
配置文件优先级排名(从高到低):

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  1. 执行 maven 打包指令 package,把项目打成 jar 文件

  1. 使用命令:java -jar 方式运行 jar 文件程序。

同时设置 Java 系统属性和命令行参数

仅设置 Java 系统属性

[!TIP]
注意事项:

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

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

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

Bean 的管理

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

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

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

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

image-20260608194537361

Bean 的作用域

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

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

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

1). 测试一

  • 控制器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//默认bean的作用域为:singleton (单例)
@RestController
@RequestMapping("/depts")
public class DeptController {

@Autowired
private DeptService deptService;

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

//省略其他代码...
}
  • 测试类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@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 服务,运行测试方法,查看控制台打印的日志:

[!TIP]
注意事项:

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

2). 测试二

修改控制器 DeptController 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Scope("prototype") //bean作用域为非单例
@RestController
@RequestMapping("/depts")
public class DeptController {

@Autowired
private DeptService deptService;

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

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

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

[!TIP]
注意事项:

  • prototype 的 bean,每一次使用该 bean 的时候都会创建一个新的实例
  • 实际开发当中,绝大部分的 Bean 是单例的,也就是说绝大部分 Bean 不需要配置 scope 属性

[!TIP]

  • 默认 singleton 的 bean,在容器启动时被创建,可以使用 @Lazy 注解来延迟初始化(延迟到第一次使用时)
  • prototype 的 bean,每一次使用该 bean 的时候都会创建一个新的实例。
  • 实际开发当中,绝大部分的 bean 是单例的,也就是说绝大部分 bean 不需要配置 scope 属性。

image-20260608202459026

第三方 Bean

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

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

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

演示 1:

  • 在启动类中直接声明这个 Bean。比如:我们可以将我们之前使用的阿里云 OSS 操作的工具类,基于 @Bean 注解的方式来声明 Bean。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 # (别人写的文件只读加不了上面四个注解,自己new一个通过@bean加入到ioc容器中)
public AliyunOSSOperator aliyunOSSOperator(AliyunOSSProperties ossProperties) {
return new AliyunOSSOperator(ossProperties);
}
}
  • 测试:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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 注解声明一个配置类。【推荐】

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 #(加上@bean注解后,自动将方法的返回值交给ioc容器管理)
public AliyunOSSOperator aliyunOSSOperator(AliyunOSSProperties ossProperties) {
return new AliyunOSSOperator(ossProperties);
}
}

[!TIP]

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

image-20260608203237953

image-20260608203501855

image-20260608203543377

image-20260608203629630

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 的配置。我们只需要引入程序开发时所需要的起步依赖,项目开发时所用到常见的配置都已经有了,我们直接使用就可以了。

简单回顾之后,接下来我们来学习下 SpringBoot 的原理。其实学习 SpringBoot 的原理就是来解析 SpringBoot 当中的起步依赖与自动配置的原理。我们首先来学习 SpringBoot 当中起步依赖的原理。

起步依赖

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

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

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

[!TIP]

  • 在 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 的依赖传递进来。

自动配置

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

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

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

实现方案

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

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

  • 准备工作:在 Idea 中导入"资料\03. 自动配置原理" 下的 itheima-utils 工程
  • 在 SpringBoot 项目 spring-boot-web-config 工程中,通过坐标引入 itheima-utils 依赖

1、引入的 itheima-utils 中配置如下:

1
2
3
4
5
6
@Component
public class TokenParser {
public void parse(){
System.out.println("TokenParser ... parse ...");
}
}

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

1
2
3
4
5
6
7
8
9
10
11
12
@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 容器中)
方案一

@ComponentScan 组件扫描

1
2
3
4
5
6
7
@SpringBootApplication
@ComponentScan({"com.itheima","com.example"}) //指定要扫描的包
public class SpringbootWebConfigApplication {
public static void main(String[] args) {
SpringApplication.run(SpringbootWebConfigApplication.class, args);
}
}

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

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

  1. 使用繁琐
  2. 性能低

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

方案二

image-20260608223452647

@Import 导入

  • 导入形式主要有以下几种:
    1. 导入普通类
    2. 导入配置类(配置类里面配置的所有bean都会导入到ioc容器中)
    3. 导入 ImportSelector 接口实现类(批量导入)

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

1
2
3
4
5
6
7
@Import(TokenParser.class) //导入的类会被Spring加载到IOC容器中
@SpringBootApplication
public class SpringbootWebConfigApplication {
public static void main(String[] args) {
SpringApplication.run(SpringbootWebConfigApplication.class, args);
}
}

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

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

  • 配置类
1
2
3
4
5
6
7
8
9
10
11
12
@Configuration
public class HeaderConfig {
@Bean
public HeaderParser headerParser(){
return new HeaderParser();
}

@Bean
public HeaderGenerator headerGenerator(){
return new HeaderGenerator();
}
}
  • 启动类
1
2
3
4
5
6
7
@Import(HeaderConfig.class) //导入配置类
@SpringBootApplication
public class SpringbootWebConfig2Application {
public static void main(String[] args) {
SpringApplication.run(SpringbootWebConfig2Application.class, args);
}
}
  • 测试类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@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 接口实现类
1
2
3
4
5
6
public class MyImportSelector implements ImportSelector {
public String[] selectImports(AnnotationMetadata importingClassMetadata) {
//返回值字符串数组(数组中封装了全限定名称的类)
return new String[]{"com.example.HeaderConfig"};
}
}
  • 启动类
1
2
3
4
5
6
7
@Import(MyImportSelector.class) //导入ImportSelector接口实现类
@SpringBootApplication
public class SpringbootWebConfig2Application {
public static void main(String[] args) {
SpringApplication.run(SpringbootWebConfig2Application.class, args);
}
}

执行测试方法:

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

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

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

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

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

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

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

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

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

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

执行测试方法:

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

原理分析

源码跟踪

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

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

要搞清楚 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() 方法:

String[] 数组封装的是需要导入到ioc容器的类的全类名

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

image-20260609093323200

configurations中表示要配置哪些类,exclusions表示要排出去哪些类

getAutoConfigurationEntry()方法通过调用 getCandidateConfigurations(annotationMetadata, attributes)方法获取在配置文件中配置的所有自动配置类的集合

getCandidateConfigurations 方法的功能:
获取所有基于 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件中配置类的集合

最终SpringBoot在启动的时候通过加载这份配置文件,会将这份配置文件配置的所有类加载出来封装到list集合中,最终转化为一个数组,然后将数组当中所有的类通过import注解直接导入到了ioc容器中,成为ioc容器的bean,在项目中要想使用这个bean直接autowried 注入即可使用

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 容器管理。

image-20260609094348579

image-20260609094512300image-20260609094549093

最后呢给大家抛出一个问题:在 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件中定义的配置类非常多,而且每个配置类中又可以定义很多的 bean,那这些 bean 都会注册到 Spring 的 IOC 容器中吗?
答案:并不是。 在声明 bean 对象时,上面有加一个以 @Conditional 开头的注解,这种注解的作用就是按照条件进行装配,只有满足条件之后,才会将 bean 注册到 Spring 的 IOC 容器中(下面会详细来讲解)

@Conditional

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

@Conditional 注解:

  • 作用:按照一定的条件进行判断,在满足给定条件后才会注册对应的 bean 对象到 Spring 的 IOC 容器中。

  • 位置:方法、类

  • @Conditional 本身是一个父注解,派生出大量的子注解:

    • @ConditionalOnClass:判断环境中有对应字节码文件,才注册 bean 到 IOC 容器。
    • @ConditionalOnMissingBean:判断环境中没有对应的 bean(类型或名称),才注册 bean 到 IOC 容器。
    • @ConditionalOnProperty:判断配置文件中有对应属性和值,才注册 bean 到 IOC 容器。

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

  • @ConditionalOnClass****注解
1
2
3
4
5
6
7
8
9
10
11
@Configuration
public class HeaderConfig {

@Bean
@ConditionalOnClass(name="io.jsonwebtoken.Jwts")//环境中存在指定的这个类,才会将该bean加入IOC容器
public HeaderParser headerParser(){
return new HeaderParser();
}

//省略其他代码...
}
  • pom.xml
1
2
3
4
5
6
<!--JWT令牌-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
  • 测试类
1
2
3
4
5
6
7
8
9
10
11
12
@SpringBootTest
public class AutoConfigurationTests {
@Autowired
private ApplicationContext applicationContext;

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

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

执行 testHeaderParser()测试方法:

[!TIP]
因为 io.jsonwebtoken.Jwts 字节码文件在启动 SpringBoot 程序时已存在,所以创建 HeaderParser 对象并注册到 IOC 容器中。

  • @ConditionalOnMissingBean 注解
1
2
3
4
5
6
7
8
9
10
11
@Configuration
public class HeaderConfig {

@Bean
@ConditionalOnMissingBean //不存在该类型的bean,才会将该bean加入IOC容器
public HeaderParser headerParser(){
return new HeaderParser();
}

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

执行 testHeaderParser()测试方法:

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

  • 再次修改**@ConditionalOnMissingBean 注解**
1
2
3
4
5
6
7
8
9
10
11
@Configuration
public class HeaderConfig {

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

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

执行 testHeaderParser()测试方法:

  • @ConditionalOnProperty****注解(这个注解和配置文件当中配置的属性有关系)

先在 application.yml 配置文件中添加如下的键值对:

1
name: itheima

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
public class HeaderConfig {

@Bean
@ConditionalOnProperty(name ="name",havingValue = "itheima")//配置文件中存在指定属性名与值,才会将bean加入IOC容器 (判断配置项中的name属性值,是否等于itheima,等于就会创建这个bean)
public HeaderParser headerParser(){
return new HeaderParser();
}

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

执行 testHeaderParser()测试方法:

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

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

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

因为 application.yml 配置文件中,不存在: name: itheima2,所以 HeaderParser 对象在 IOC 容器中不存在

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

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

[!TIP]
自动配置的核心就在 @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 容器管理。

image-20260609110939129

image-20260609111120742

自定义 starter

分析

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

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

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

业务场景:

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

在 SpringBoot 项目中,一般都会将这些公共组件封装为 SpringBoot 当中的 starter,也就是我们所说的起步依赖。

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

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

[!TIP]
**SpringBoot 官方 starter 命名: **spring-boot-starter-xxxx
**第三组织提供的 starter 命名: ** xxxx-spring-boot-starter

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

[!TIP]
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 自动配置的依赖给传递下来。

需求

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

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

之前我们的用法:

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!--阿里云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 的配置信息

1
2
3
4
5
_#阿里云oss配置_
aliyun:
oss:
endpoint: https://oss-cn-beijing.aliyuncs.com
bucketName: java422-web-ai

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

1
2
3
4
5
6
7
8
9
10
11
12
13
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
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,注入工具类,再使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@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 使用了。

image-20260609111658520

实现

image-20260609111812856

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

  • 第 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 中的配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
_<?_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 模块后,删除多余的文件,只保留 srcpom.xml 。最终保留内容如下:

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
_<?_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 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
_<?_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 模块下,然后进行改造就行了。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
_<?_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 对象。

具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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) #AliyunOSSProperties通过这个enableconfigurationOnproperties可以将这个 类导入到ioc容器中底层是用到了import注解

public class AliyunOSSAutoConfiguration {

@Bean
public AliyunOSSOperator aliyunOSSOperator(AliyunOSSProperties aliyunOSSProperties) {
return new AliyunOSSOperator(aliyunOSSProperties);
}

}

image-20260609113322704

AliyunOSSProperties通过这个enableconfigurationOnproperties可以将这个 类导入到ioc容器中底层是用到了import注解

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

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

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

配置内容如下:

1
com.aliyun.oss.AliyunOSSAutoConfiguration

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

测试

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

今天的课程资料当中,提供了一个自定义 starter 的测试工程。我们直接打开文件夹,里面有一个测试工程。测试工程就是 springboot-autoconfiguration-test,我们只需要将测试工程直接导入到 Idea 当中即可。

测试前准备:

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

1
2
3
4
5
<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 的配置信息

1
2
3
4
aliyun:
oss:
endpoint: https://oss-cn-beijing.aliyuncs.com
bucketName: java422-web-ai

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 的信息,就可以直接注入是用了 。

总结

image-20260609132606944

image-20260609132728187