# SpringBoot Web 案例

  • 开发流程

    我们在进行功能开发时,都是根据如下流程进行:

    image-20220904125004138

    1. 查看页面原型明确需求

      • 根据页面原型和需求,进行表结构设计、编写接口文档 (已提供)
    2. 阅读接口文档

    3. 思路分析

    4. 功能接口开发

      • 就是开发后台的业务功能,一个业务功能,我们称为一个接口
    5. 功能接口测试

      • 功能开发完毕后,先通过 Postman 进行功能接口测试,测试通过后,再和前端进行联调测试
    6. 前后端联调测试

      • 和前端开发人员开发好的前端工程一起测试

# 会话跟踪技术

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

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

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

    image-20230112101901417

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

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

    • 服务器会 自动 的将 cookie 响应给浏览器。

    • 浏览器接收到响应回来的数据之后,会 自动 的将 cookie 存储在浏览器本地。

    • 在后续的请求当中,浏览器会 自动 的将 cookie 携带到服务器端。

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

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

    • 响应头 Set-Cookie :设置 Cookie 数据的

    • 请求头 Cookie:携带 Cookie 数据的

    image-20230112101804878

    代码测试

    @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

    image-20230112105410076

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

    image-20230112105538131

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

    image-20230112105658486

    优缺点

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

    跨域介绍:

    ​ <img src="../../JavaWeb/day12-SpringBootWeb 登录认证 / 讲义 /assets/image-20230112103840467.png" alt="image-20230112103840467" style="zoom:80%;" />

    • 现在的项目,大部分都是前后端分离的,前后端最终也会分开部署,前端部署在服务器 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 / 协议
    • 端口

    只要上述的三个维度有任何一个维度不同,那就是跨域操作

    举例:

    http://192.168.150.200/login.html ----------> https://192.168.150.200/login [协议不同,跨域]

    http://192.168.150.200/login.html ----------> http://192.168.150.100/login [IP 不同,跨域]

    http://192.168.150.200/login.html ----------> http://192.168.150.200:8080/login [端口不同,跨域]

    http://192.168.150.200/login.html ----------> http://192.168.150.200/login [不跨域]

# Session

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

    • 获取 Session

      image-20230112105938545

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

    • 响应 Cookie (JSESSIONID)

      image-20230112110441075

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

    • 查找 Session

      image-20230112101943835

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

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

    代码测试

    @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

    image-20230112111004447

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

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

    image-20230112111137207

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

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

    image-20230112111328117

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

    优缺点

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

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

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

    ​ <img src="../../JavaWeb/day12-SpringBootWeb 登录认证 / 讲义 /assets/image-20230112112557480.png" alt="image-20230112112557480" style="zoom:67%;" />

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

    image-20230112112740131

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

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

      image-20230112113558810

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

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

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

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

# 令牌技术 (JWT)

  • # 介绍

    JWT 全称:JSON Web Token (官网:https://jwt.io/)

    • 定义了一种简洁的、自包含的格式,用于在通信双方以 json 数据格式安全的传输信息。由于数字签名的存在,这些信息是可靠的。

      简洁:是指 jwt 就是一个简单的字符串。可以在请求参数或者是请求头当中直接传递。

      自包含:指的是 jwt 令牌,看似是一个随机的字符串,但是我们是可以根据自身的需求在 jwt 令牌中存储自定义的数据内容。如:可以直接在 jwt 令牌中存储用户的相关信息。

      简单来讲,jwt 就是将原始的 json 数据格式进行了安全的封装,这样就可以直接基于 jwt 在通信双方安全的进行信息传输了。

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

    • 第一部分:Header (头), 记录令牌类型、签名算法等。 例如:

    • 第二部分:Payload (有效载荷),携带一些自定义信息、默认信息等。 例如:

    • 第三部分:Signature (签名),防止 Token 被篡改、确保安全性。将 header、payload,并加入指定秘钥,通过指定签名算法计算而来。

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

    image-20230106085442076

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

    其实在生成 JWT 令牌时,会对 JSON 格式的数据进行一次编码:进行 base64 编码

    Base64:是一种基于 64 个可打印的字符来表示二进制数据的编码方式。既然能编码,那也就意味着也能解码。所使用的 64 个字符分别是 A 到 Z、a 到 z、 0- 9,一个加号,一个斜杠,加起来就是 64 个字符。任何数据经过 base64 编码之后,最终就会通过这 64 个字符来表示。当然还有一个符号,那就是等号。等号它是一个补位的符号

    需要注意的是 Base64 是编码方式,而不是加密方式。

    image-20230112114319773

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

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

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

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

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

    • # 生成和校验

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

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

    <!-- JWT 依赖 -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
        <version>0.9.1</version>
    </dependency>

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

    工具类:Jwts

    生成 JWT 代码实现:

    @Test
    public void genJwt(){
        Map<String,Object> claims = new HashMap<>();
        claims.put("id",1);
        claims.put("username","Tom");
        
        String jwt = Jwts.builder()
            .setClaims(claims) // 自定义内容 (载荷)          
            .signWith(SignatureAlgorithm.HS256, "itheima") // 签名算法        
            .setExpiration(new Date(System.currentTimeMillis() + 24*3600*1000)) // 有效期   
            .compact();
        
        System.out.println(jwt);
    }

    运行测试方法:

    eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZXhwIjoxNjcyNzI5NzMwfQ.fHi0Ub8npbyt71UqLXDdLyipptLgxBUg_mSuGJtXtBk
    

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

    image-20230106190950305

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

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

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

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

    @Test
    public void parseJwt(){
        Claims claims = Jwts.parser()
            .setSigningKey("itheima")// 指定签名密钥(必须保证和生成令牌时使用相同的签名密钥)  
    	    .parseClaimsJws("eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZXhwIjoxNjcyNzI5NzMwfQ.fHi0Ub8npbyt71UqLXDdLyipptLgxBUg_mSuGJtXtBk")
            .getBody();
        System.out.println(claims);
    }

    运行测试方法:

    {id=1, exp=1672729730}
    

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

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

    原 header: eyJhbGciOiJIUzI1NiJ9

    修改为: eyJhbGciOiJIUzI1NiJ8

    image-20230106205045658

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

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

    @Test
    public void genJwt(){
        Map<String,Object> claims = new HashMap<>();
        claims.put(“id”,1);
        claims.put(“username”,Tom);
        String jwt = Jwts.builder()
            .setClaims(claims) // 自定义内容 (载荷)          
            .signWith(SignatureAlgorithm.HS256, “itheima”) // 签名算法        
            .setExpiration(new Date(System.currentTimeMillis() + 60*1000)) // 有效期 60 秒   
            .compact();
        
        System.out.println(jwt);
        // 输出结果:eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZXhwIjoxNjczMDA5NzU0fQ.RcVIR65AkGiax-ID6FjW60eLFH3tPTKdoK7UtE4A1ro
    }
    @Test
    public void parseJwt(){
        Claims claims = Jwts.parser()
            .setSigningKey("itheima")// 指定签名密钥
    .parseClaimsJws("eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZXhwIjoxNjczMDA5NzU0fQ.RcVIR65AkGiax-ID6FjW60eLFH3tPTKdoK7UtE4A1ro")
            .getBody();
        System.out.println(claims);
    }

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

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

    • JWT 校验时使用的签名秘钥,必须和生成 JWT 令牌时使用的秘钥是配套的。

    • 如果 JWT 令牌解析校验时报错,则说明 JWT 令牌被篡改 或 失效了,令牌非法。

    • # 登录下发令牌

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

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

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

    JWT 令牌怎么返回给前端呢?此时我们就需要再来看一下接口文档当中关于登录接口的描述(主要看响应数据):

    • 响应数据

      参数格式:application/json

      参数说明:

      名称类型是否必须默认值备注其他信息
      codenumber必须响应码,1 成功;0 失败
      msgstring非必须提示信息
      datastring必须返回的数据,jwt 令牌

      响应数据样例:

      {
        "code": 1,
        "msg": "success",
        "data": "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoi6YeR5bq4IiwiaWQiOjEsInVzZXJuYW1lIjoiamlueW9uZyIsImV4cCI6MTY2MjIwNzA0OH0.KkUc_CXJZJ8Dd063eImx4H9Ojfrr6XMJ-yVzaWCVZCo"
      }
    • 备注说明

      用户登录成功后,系统会自动下发 JWT 令牌,然后在后续的每次请求中,都需要在请求头 header 中携带到服务端,请求头的名称为 token ,值为 登录时下发的 JWT 令牌。

      如果检测到用户未登录,则会返回如下固定错误信息:

      {
      	"code": 0,
      	"msg": "NOT_LOGIN",
      	"data": null
      }

    解读完接口文档中的描述了,目前我们先来完成令牌的生成和令牌的下发,我们只需要生成一个令牌返回给前端就可以了。

    实现步骤:

    1. 引入 JWT 工具类
      • 在项目工程下创建 com.itheima.utils 包,并把提供 JWT 工具类复制到该包下
    2. 登录完成后,调用工具类生成 JWT 令牌并返回

    JWT 工具类

    public class JwtUtils {
        private static String signKey = "itheima";// 签名密钥
        private static Long expire = 43200000L; // 有效时间
        /**
         * 生成 JWT 令牌
         * @param claims JWT 第二部分负载 payload 中存储的内容
         * @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)// 指定令牌 Token
                    .getBody();
            return claims;
        }
    }

    登录成功,生成 JWT 令牌并返回

    @RestController
    @Slf4j
    public class LoginController {
        // 依赖业务层对象
        @Autowired
        private EmpService empService;
        @PostMapping("/login")
        public Result login(@RequestBody Emp emp) {
            // 调用业务层:登录功能
            Emp loginEmp = empService.login(emp);
            // 判断:登录用户是否存在
            if(loginEmp !=null ){
                // 自定义信息
                Map<String , Object> claims = new HashMap<>();
                claims.put("id", loginEmp.getId());
                claims.put("username",loginEmp.getUsername());
                claims.put("name",loginEmp.getName());
                // 使用 JWT 工具类,生成身份令牌
                String token = JwtUtils.generateJwt(claims);
                return Result.success(token);
            }
            return Result.error("用户名或密码错误");
        }
    }

    重启服务,打开 postman 测试登录接口:

    image-20230106212805480

    打开浏览器完成前后端联调操作:利用开发者工具,抓取一下网络请求

    image-20230106213419461

    登录请求完成后,可以看到 JWT 令牌已经响应给了前端,此时前端就会将 JWT 令牌存储在浏览器本地。

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

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

    image-20230106213910049

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

    image-20230106214331443

# Filter Interceptor (令牌的统一拦截校验)

# Filter

  • # 快速入门

    什么是 Filter?

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

    image-20230112120955145

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

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

    定义过滤器

    // 定义一个类,实现一个标准的 Filter 过滤器的接口
    public class DemoFilter implements Filter {
        @Override // 初始化方法,只调用一次
        public void init(FilterConfig filterConfig) throws ServletException {
            System.out.println("init 初始化方法执行了");
        }
        @Override // 拦截到请求之后调用,调用多次
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
            System.out.println("Demo 拦截到了请求...放行前逻辑");
            // 放行
            chain.doFilter(request,response);
        }
        @Override // 销毁方法,只调用一次
        public void destroy() {
            System.out.println("destroy 销毁方法执行了");
        }
    }
    • init 方法:过滤器的初始化方法。在 web 服务器启动的时候会自动的创建 Filter 过滤器对象,在创建过滤器对象的时候会自动调用 init 初始化方法,这个方法只会被调用一次。

    • doFilter 方法:这个方法是在每一次拦截到请求之后都会被调用,所以这个方法是会被调用多次的,每拦截到一次请求就会调用一次 doFilter () 方法。

    • destroy 方法: 是销毁的方法。当我们关闭服务器的时候,它会自动的调用销毁方法 destroy,而这个销毁方法也只会被调用一次。

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

    @WebFilter(urlPatterns = "/*") // 配置过滤器要拦截的请求路径( /* 表示拦截浏览器的所有请求 )
    public class DemoFilter implements Filter {
        @Override // 初始化方法,只调用一次
        public void init(FilterConfig filterConfig) throws ServletException {
            System.out.println("init 初始化方法执行了");
        }
        @Override // 拦截到请求之后调用,调用多次
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
            System.out.println("Demo 拦截到了请求...放行前逻辑");
            // 放行
            chain.doFilter(request,response);
        }
        @Override // 销毁方法,只调用一次
        public void destroy() {
            System.out.println("destroy 销毁方法执行了");
        }
    }

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

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

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

    image-20230112121205697

    注意事项:

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

  • # 拦截路径

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

    拦截路径urlPatterns 值含义
    拦截具体路径/login只有访问 /login 路径时,才会被拦截
    目录拦截/emps/*访问 /emps 下的所有资源,都会被拦截
    拦截所有/*访问所有资源,都会被拦截
  • # 过滤器链

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

    image-20230107084730393

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

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

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

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

  • # 登录校验 - Filter

    • # 分析

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

    image-20230107095010089

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

    • 要进入到后台管理系统,我们必须先完成登录操作,此时就需要访问登录接口 login。

    • 登录成功之后,我们会在服务端生成一个 JWT 令牌,并且把 JWT 令牌返回给前端,前端会将 JWT 令牌存储下来。

    • 在后续的每一次请求当中,都会将 JWT 令牌携带到服务端,请求到达服务端之后,要想去访问对应的业务功能,此时我们必须先要校验令牌的有效性。

    • 对于校验令牌的这一块操作,我们使用登录校验的过滤器,在过滤器当中来校验令牌的有效性。如果令牌是无效的,就响应一个错误的信息,也不会再去放行访问对应的资源了。如果令牌存在,并且它是有效的,此时就会放行去访问对应的 web 资源,执行相应的业务操作。

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

    1. 所有的请求,拦截到了之后,都需要校验令牌吗?

      • 答案:登录请求例外
    2. 拦截到请求后,什么情况下才可以放行,执行业务操作?

      • 答案:有令牌,且令牌校验通过 (合法);否则都返回未登录错误结果
    • # 具体流程

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

    image-20230112122130564

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

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

    分析清楚了以上的问题后,我们就参照接口文档来开发登录功能了,登录接口描述如下:

    • 基本信息

      请求路径:/login
      
      请求方式:POST
      
      接口描述:该接口用于员工登录Tlias智能学习辅助系统,登录完毕后,系统下发JWT令牌。 
      
    • 请求参数

      参数格式:application/json

      参数说明:

      名称类型是否必须备注
      usernamestring必须用户名
      passwordstring必须密码

      请求数据样例:

      {
      	"username": "jinyong",
          "password": "123456"
      }
    • 响应数据

      参数格式:application/json

      参数说明:

      名称类型是否必须默认值备注其他信息
      codenumber必须响应码,1 成功;0 失败
      msgstring非必须提示信息
      datastring必须返回的数据,jwt 令牌

      响应数据样例:

      {
        "code": 1,
        "msg": "success",
        "data": "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoi6YeR5bq4IiwiaWQiOjEsInVzZXJuYW1lIjoiamlueW9uZyIsImV4cCI6MTY2MjIwNzA0OH0.KkUc_CXJZJ8Dd063eImx4H9Ojfrr6XMJ-yVzaWCVZCo"
      }
    • 备注说明

      用户登录成功后,系统会自动下发 JWT 令牌,然后在后续的每次请求中,都需要在请求头 header 中携带到服务端,请求头的名称为 token ,值为 登录时下发的 JWT 令牌。

      如果检测到用户未登录,则会返回如下固定错误信息:

      {
      	"code": 0,
      	"msg": "NOT_LOGIN",
      	"data": null
      }

    登录校验过滤器:LoginCheckFilter

    @Slf4j
    @WebFilter(urlPatterns = "/*") // 拦截所有请求
    public class LoginCheckFilter implements Filter {
        @Override
        public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {
            // 前置:强制转换为 http 协议的请求对象、响应对象 (转换原因:要使用子类中特有方法)
            HttpServletRequest request = (HttpServletRequest) servletRequest;
            HttpServletResponse response = (HttpServletResponse) servletResponse;
            //1. 获取请求 url
            String url = request.getRequestURL().toString();
            log.info("请求路径:{}", url); // 请求路径:http://localhost:8080/login
            //2. 判断请求 url 中是否包含 login,如果包含,说明是登录操作,放行
            if(url.contains("/login")){
                chain.doFilter(request, response);// 放行请求
                return;// 结束当前方法的执行
            }
            //3. 获取请求头中的令牌(token)
            String token = request.getHeader("token");
            log.info("从请求头中获取的令牌:{}",token);
            //4. 判断令牌是否存在,如果不存在,返回错误结果(未登录)
            if(!StringUtils.hasLength(token)){
                log.info("Token不存在");
                Result responseResult = Result.error("NOT_LOGIN");
                // 把 Result 对象转换为 JSON 格式字符串 (fastjson 是阿里巴巴提供的用于实现对象和 json 的转换工具类)
                String json = JSONObject.toJSONString(responseResult);
                response.setContentType("application/json;charset=utf-8");
                // 响应
                response.getWriter().write(json);
                return;
            }
            //5. 解析 token,如果解析失败,返回错误结果(未登录)
            try {
                JwtUtils.parseJWT(token);
            }catch (Exception e){
                log.info("令牌解析失败!");
                Result responseResult = Result.error("NOT_LOGIN");
                // 把 Result 对象转换为 JSON 格式字符串 (fastjson 是阿里巴巴提供的用于实现对象和 json 的转换工具类)
                String json = JSONObject.toJSONString(responseResult);
                response.setContentType("application/json;charset=utf-8");
                // 响应
                response.getWriter().write(json);
                return;
            }
            //6. 放行
            chain.doFilter(request, response);
        }
    }

    在上述过滤器的功能实现中,我们使用到了一个第三方 json 处理的工具包 fastjson。我们要想使用,需要引入如下依赖:

    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>1.2.76</version>
    </dependency>

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

    测试前先把之前所编写的测试使用的过滤器,暂时注释掉。直接将 @WebFilter 注解给注释掉即可。

    • 测试 1:未登录是否可以访问部门管理页面

      首先关闭浏览器,重新打开浏览器,在地址栏中输入:http://localhost:9528/#/system/dept

      由于用户没有登录,登录校验过滤器返回错误信息,前端页面根据返回的错误信息结果,自动跳转到登录页面了

      image-20230105085212629

    • 测试 2:先进行登录操作,再访问部门管理页面

      登录校验成功之后,可以正常访问相关业务操作页面

      image-20230107102922550

# Interceptor

  • # 快速入门

    什么是拦截器?

    • 是一种动态拦截方法调用的机制,类似于过滤器。
    • 拦截器是 Spring 框架中提供的,用来动态拦截控制器方法的执行。

    拦截器的作用:

    • 拦截请求,在指定方法调用前后,根据业务需要执行预先设定的代码。

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

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

    1. 定义拦截器

    2. 注册配置拦截器

    ** 自定义拦截器:** 实现 HandlerInterceptor 接口,并重写其所有方法

    // 自定义拦截器
    @Component
    public class LoginCheckInterceptor 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 方法:视图渲染完毕后执行,最后执行

    注册配置拦截器:实现 WebMvcConfigurer 接口,并重写 addInterceptors 方法

    @Configuration  
    public class WebConfig implements WebMvcConfigurer {
        // 自定义的拦截器对象
        @Autowired
        private LoginCheckInterceptor loginCheckInterceptor;
        
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
           // 注册自定义拦截器对象
            registry.addInterceptor(loginCheckInterceptor).addPathPatterns("/**");// 设置拦截器拦截的请求路径( /** 表示拦截所有请求)
        }
    }

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

    image-20230107105224741

    image-20230107105415120

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

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

    image-20230107105815511

    • # 拦截路径

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

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

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

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

    拦截路径含义举例
    /*一级路径能匹配 /depts,/emps,/login,不能匹配 /depts/1
    /**任意级路径能匹配 /depts,/depts/1,/depts/1/2
    /depts/*/depts 下的一级路径能匹配 /depts/1,不能匹配 /depts/1/2,/depts
    /depts/**/depts 下的任意级路径能匹配 /depts,/depts/1,/depts/1/2,不能匹配 /emps/1
    • # 执行流程

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

      image-20230107112136151

      • 当我们打开浏览器来访问部署在 web 服务器当中的 web 应用时,此时我们所定义的过滤器会拦截到这次请求。拦截到这次请求之后,它会先执行放行前的逻辑,然后再执行放行操作。而由于我们当前是基于 springboot 开发的,所以放行之后是进入到了 spring 的环境当中,也就是要来访问我们所定义的 controller 当中的接口方法。

      • Tomcat 并不识别所编写的 Controller 程序,但是它识别 Servlet 程序,所以在 Spring 的 Web 环境中提供了一个非常核心的 Servlet:DispatcherServlet(前端控制器),所有请求都会先进行到 DispatcherServlet,再将请求转给 Controller。

      • 当我们定义了拦截器后,会在执行 Controller 的方法之前,请求被拦截器拦截住。执行 preHandle() 方法,这个方法执行完成后需要返回一个布尔类型的值,如果返回 true,就表示放行本次操作,才会继续访问 controller 中的方法;如果返回 false,则不会放行(controller 中的方法也不会执行)。

      • 在 controller 当中的方法执行完毕之后,再回过来执行 postHandle() 这个方法以及 afterCompletion() 方法,然后再返回给 DispatcherServlet,最终再来执行过滤器当中放行后的这一部分逻辑的逻辑。执行完毕之后,最终给浏览器响应数据。

      接下来我们就来演示下过滤器和拦截器同时存在的执行流程:

      • 开启 LoginCheckInterceptor 拦截器
      @Component
      public class LoginCheckInterceptor implements HandlerInterceptor {
          @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 .... ");
          }
      }
      @Configuration  
      public class WebConfig implements WebMvcConfigurer {
          // 拦截器对象
          @Autowired
          private LoginCheckInterceptor loginCheckInterceptor;
          @Override
          public void addInterceptors(InterceptorRegistry registry) {
              // 注册自定义拦截器对象
              registry.addInterceptor(loginCheckInterceptor)
                      .addPathPatterns("/**")// 拦截所有请求
                      .excludePathPatterns("/login");// 不拦截登录请求
          }
      }
      • 开启 DemoFilter 过滤器
      @WebFilter(urlPatterns = "/*") 
      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   放行后逻辑.....");
          }
      }

      重启 SpringBoot 服务后,清空日志,打开 Postman,测试查询部门:

      image-20230107113653871

      image-20230107114008004

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

      • 接口规范不同:过滤器需要实现 Filter 接口,而拦截器需要实现 HandlerInterceptor 接口。
      • 拦截范围不同:过滤器 Filter 会拦截所有的资源,而 Interceptor 只会拦截 Spring 环境中的资源。
    • 登录校验 - Interceptor 和登录校验 Filter 过滤器当中的逻辑是完全一致的

# 异常处理

  • 三层架构处理异常的方案:

    • Mapper 接口在操作数据库的时候出错了,此时异常会往上抛 (谁调用 Mapper 就抛给谁),会抛给 service。
    • service 中也存在异常了,会抛给 controller。
    • 而在 controller 当中,我们也没有做任何的异常处理,所以最终异常会再往上抛。最终抛给框架之后,框架就会返回一个 JSON 格式的数据,里面封装的就是错误的信息,但是框架返回的 JSON 格式的数据并不符合我们的开发规范。
  • 那么在三层构架项目中,出现了异常,该如何处理?

    • 方案一:在所有 Controller 的所有方法中进行 try…catch 处理
      • 缺点:代码臃肿(不推荐)
    • 方案二:全局异常处理器
      • 好处:简单、优雅(推荐)

    image-20230107122904214

  • # 全局异常处理器

    我们该怎么样定义全局异常处理器?

    • 定义全局异常处理器非常简单,就是定义一个类,在类上加上一个注解 @RestControllerAdvice,加上这个注解就代表我们定义了一个全局异常处理器。
    • 在全局异常处理器当中,需要定义一个方法来捕获异常,在这个方法上需要加上注解 @ExceptionHandler。通过 @ExceptionHandler 注解当中的 value 属性来指定我们要捕获的是哪一类型的异常。
    @RestControllerAdvice
    public class GlobalExceptionHandler {
        // 处理异常
        @ExceptionHandler(Exception.class) // 指定能够处理的异常类型
        public Result ex(Exception e){
            e.printStackTrace();// 打印堆栈中的异常信息
            // 捕获到异常之后,响应一个标准的 Result
            return Result.error("对不起,操作失败,请联系管理员");
        }
    }

    @RestControllerAdvice = @ControllerAdvice + @ResponseBody

    处理异常的方法返回值会转换为 json 后再响应给前端

    重新启动 SpringBoot 服务,打开浏览器,再来测试一下添加部门这个操作,我们依然添加已存在的 "就业部" 这个部门:

    image-20230112131232032

    image-20230112131135272

    此时,我们可以看到,出现异常之后,异常已经被全局异常处理器捕获了。然后返回的错误信息,被前端程序正常解析,然后提示出了对应的错误提示信息。

    以上就是全局异常处理器的使用,主要涉及到两个注解:

    • @RestControllerAdvice // 表示当前类为全局异常处理器
    • @ExceptionHandler // 指定可以捕获哪种类型的异常进行处理

# AOP

# 事务管理

  • 事务是一组操作的集合,它是一个不可分割的工作单位。事务会把所有的操作作为一个整体,一起向数据库提交或者是撤销操作请求。所以这组操作要么同时成功,要么同时失败。

    怎么样来控制这组操作,让这组操作同时成功或同时失败呢?此时就要涉及到事务的具体操作了。

    事务的操作主要有三步:

    1. 开启事务(一组操作开始前,开启事务):start transaction /begin ;
    2. 提交事务(这组操作全部成功后,提交事务):commit ;
    3. 回滚事务(中间任何一个操作出现异常,回滚事务):rollback ;
  • 简单的回顾了事务的概念以及事务的基本操作之后,接下来我们看一个事务管理案例:解散部门 (解散部门就是删除部门)

    需求:当部门解散了不仅需要把部门信息删除了,还需要把该部门下的员工数据也删除了。

    步骤:

    • 根据 ID 删除部门数据
    • 根据部门 ID 删除该部门下的员工
  • ** 即使程序运行抛出了异常,部门依然删除了,但是部门下的员工却没有删除,造成了数据的不一致。** 原因分析

    原因:

    • 先执行根据 id 删除部门的操作,这步执行完毕,数据库表 dept 中的数据就已经删除了。
    • 执行 1/0 操作,抛出异常
    • 抛出异常之前,下面所有的代码都不会执行了,根据部门 ID 删除该部门下的员工,这个操作也不会执行 。

    此时就出现问题了,部门删除了,部门下的员工还在,业务操作前后数据不一致。

    而要想保证操作前后,数据的一致性,就需要让解散部门中涉及到的两个业务操作,要么全部成功,要么全部失败 。 那我们如何,让这两个操作要么全部成功,要么全部失败呢 ?

    那就可以通过事务来实现,因为一个事务中的多个业务操作,要么全部成功,要么全部失败。

    此时,我们就需要在 delete 删除业务功能中添加事务。

    ![image-20230107141652636](../../JavaWeb/day13-SpringBootWeb AOP / 讲义 /assets/image-20230107141652636.png)

    在方法运行之前,开启事务,如果方法成功执行,就提交事务,如果方法执行的过程当中出现异常了,就回滚事务。

    思考:开发中所有的业务操作,一旦我们要进行控制事务,是不是都是这样的套路?

    答案:是的。

    所以在 spring 框架当中就已经把事务控制的代码都已经封装好了,并不需要我们手动实现。我们使用了 spring 框架,我们只需要通过一个简单的注解 @Transactional 就搞定了。

    # Transactional 注解

    @Transactional 作用:就是在当前这个方法执行开始之前来开启事务,方法执行完毕之后提交事务。如果在这个方法执行的过程当中出现了异常,就会进行事务的回滚操作。

    @Transactional 注解:我们一般会在业务层当中来控制事务,因为在业务层当中,一个业务功能可能会包含多个数据访问的操作。在业务层来控制事务,我们就可以将多个数据访问操作控制在一个事务范围内。

    @Transactional 注解书写位置:

    • 方法
      • 当前方法交给 spring 进行事务管理
      • 当前类中所有的方法都交由 spring 进行事务管理
    • 接口
      • 接口下所有的实现类当中所有的方法都交给 spring 进行事务管理

    接下来,我们就可以在业务方法 delete 上加上 @Transactional 来控制事务 。

    @Slf4j
    @Service
    public class DeptServiceImpl implements DeptService {
        @Autowired
        private DeptMapper deptMapper;
        @Autowired
        private EmpMapper empMapper;
        
        @Override
        @Transactional  // 当前方法添加了事务管理
        public void delete(Integer id){
            // 根据部门 id 删除部门信息
            deptMapper.deleteById(id);
            
            // 模拟:异常发生
            int i = 1/0;
            // 删除部门下的所有员工信息
            empMapper.deleteByDeptId(id);   
        }
    }

    在业务功能上添加 @Transactional 注解进行事务管理后,我们重启 SpringBoot 服务,使用 postman 测试:

    ![image-20230107143339917](../../JavaWeb/day13-SpringBootWeb AOP / 讲义 /assets/image-20230107143339917.png)

    添加 Spring 事务管理后,由于服务端程序引发了异常,所以事务进行回滚。

    ![image-20230107144312892](../../JavaWeb/day13-SpringBootWeb AOP / 讲义 /assets/image-20230107144312892.png)

    ![image-20230107143720961](../../JavaWeb/day13-SpringBootWeb AOP / 讲义 /assets/image-20230107143720961.png)

    说明:可以在 application.yml 配置文件中开启事务管理日志,这样就可以在控制看到和事务相关的日志信息了

    #spring 事务管理日志
    logging:
      level:
        org.springframework.jdbc.support.JdbcTransactionManager: debug
  • # 事务进阶

    前面我们通过 spring 事务管理注解 @Transactional 已经控制了业务层方法的事务。接下来我们要来详细的介绍一下 @Transactional 事务管理注解的使用细节。我们这里主要介绍 @Transactional 注解当中的两个常见的属性:

    1. 异常回滚的属性:rollbackFor
    2. 事务传播行为:propagation
  • rollbackFor

    • 默认情况下,只有出现 RuntimeException (运行时异常) 才会回滚事务。

      假如我们想让所有的异常都回滚,需要来配置 @Transactional 注解当中的 rollbackFor 属性,通过 rollbackFor 这个属性可以指定出现何种异常类型回滚事务。

      @Slf4j
      @Service
      public class DeptServiceImpl implements DeptService {
          @Autowired
          private DeptMapper deptMapper;
          @Autowired
          private EmpMapper empMapper;
          
          @Override
          @Transactional(rollbackFor=Exception.class)
          public void delete(Integer id){
              // 根据部门 id 删除部门信息
              deptMapper.deleteById(id);
              
              // 模拟:异常发生
              int num = id/0;
              // 删除部门下的所有员工信息
              empMapper.deleteByDeptId(id);   
          }
      }
  • propagation

    • @Transactional 注解当中的第二个属性 propagation,这个属性是用来配置事务的传播行为的。

      什么是事务的传播行为呢?

      • 就是当一个事务方法被另一个事务方法调用时,这个事务方法应该如何进行事务控制。

      例如:两个事务方法,一个 A 方法,一个 B 方法。在这两个方法上都添加了 @Transactional 注解,就代表这两个方法都具有事务,而在 A 方法当中又去调用了 B 方法。

      ![image-20230112152543953](../../JavaWeb/day13-SpringBootWeb AOP / 讲义 /assets/image-20230112152543953.png)

      所谓事务的传播行为,指的就是在 A 方法运行的时候,首先会开启一个事务,在 A 方法当中又调用了 B 方法, B 方法自身也具有事务,那么 B 方法在运行的时候,到底是加入到 A 方法的事务当中来,还是 B 方法在运行的时候新建一个事务?这个就涉及到了事务的传播行为。

      我们要想控制事务的传播行为,在 @Transactional 注解的后面指定一个属性 propagation,通过 propagation 属性来指定传播行为。接下来我们就来介绍一下常见的事务传播行为。

      属性值含义
      REQUIRED【默认值】需要事务,有则加入,无则创建新事务
      REQUIRES_NEW需要新事务,无论有无,总是创建新事务
      SUPPORTS支持事务,有则加入,无则在无事务状态中运行
      NOT_SUPPORTED不支持事务,在无事务状态下运行,如果当前存在已有事务,则挂起当前事务
      MANDATORY必须有事务,否则抛异常
      NEVER必须没事务,否则抛异常

      事务的传播行为我们只需要掌握两个:REQUIRED、REQUIRES_NEW。

      • REQUIRED :大部分情况下都是用该传播行为即可。

      • REQUIRES_NEW :当我们不希望事务之间相互影响时,可以使用该传播行为。比如:下订单前需要记录日志,不论订单保存成功与否,都需要保证日志记录能够记录成功。

# AOP 基础

  • AOP 也是 spring 框架的第二大核心,我们先来学习 AOP 的基础。

  • # 2.1 AOP 概述

什么是 AOP?

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

那什么又是面向方法编程呢,为什么又需要面向方法编程呢?来我们举个例子做一个说明:

比如,我们这里有一个项目,项目中开发了很多的业务功能。

![image-20230112154547523](../../JavaWeb/day13-SpringBootWeb AOP / 讲义 /assets/image-20230112154547523.png)

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

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

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

<img src="../../JavaWeb/day13-SpringBootWeb AOP / 讲义 /assets/image-20230112154605206.png" alt="image-20230112154605206" style="zoom:80%;" />

以上分析的实现方式是可以解决需求问题的。但是对于一个项目来讲,里面会包含很多的业务模块,每个业务模块又包含很多增删改查的方法,如果我们要在每一个模块下的业务方法中,添加记录开始时间、结束时间、计算执行耗时的代码,就会让程序员的工作变得非常繁琐。

<img src="../../JavaWeb/day13-SpringBootWeb AOP / 讲义 /assets/image-20230112154627546.png" alt="image-20230112154627546" style="zoom:80%;" />

而 AOP 面向方法编程,就可以做到在不改动这些原始方法的基础上,针对特定的方法进行功能的增强。

AOP 的作用:在程序运行期间在不修改源代码的基础上对已有方法进行增强(无侵入性:解耦)

我们要想完成统计各个业务方法执行耗时的需求,我们只需要定义一个模板方法,将记录方法执行耗时这一部分公共的逻辑代码,定义在模板方法当中,在这个方法开始运行之前,来记录这个方法运行的开始时间,在方法结束运行的时候,再来记录方法运行的结束时间,中间就来运行原始的业务方法。

<img src="../../JavaWeb/day13-SpringBootWeb AOP / 讲义 /assets/image-20230112154530101.png" alt="image-20230112154530101" style="zoom:80%;" />

而中间运行的原始业务方法,可能是其中的一个业务方法,比如:我们只想通过 部门管理的 list 方法的执行耗时,那就只有这一个方法是原始业务方法。 而如果,我们是先想统计所有部门管理的业务方法执行耗时,那此时,所有的部门管理的业务方法都是 原始业务方法。 那面向这样的指定的一个或多个方法进行编程,我们就称之为 面向切面编程。

那此时,当我们再调用部门管理的 list 业务方法时啊,并不会直接执行 list 方法的逻辑,而是会执行我们所定义的 模板方法 , 然后再模板方法中:

  • 记录方法运行开始时间
  • 运行原始的业务方法(那此时原始的业务方法,就是 list 方法)
  • 记录方法运行结束时间,计算方法执行耗时

<img src="../../JavaWeb/day13-SpringBootWeb AOP / 讲义 /assets/image-20230112155813944.png" alt="image-20230112155813944" style="zoom:80%;" />

不论,我们运行的是那个业务方法,最后其实运行的就是我们定义的模板方法,而在模板方法中,就完成了原始方法执行耗时的统计操作 。(那这样呢,我们就通过一个模板方法就完成了指定的一个或多个业务方法执行耗时的统计)

而大家会发现,这个流程,我们是不是似曾相识啊?

对了,就是和我们之前所学习的动态代理技术是非常类似的。 我们所说的模板方法,其实就是代理对象中所定义的方法,那代理对象中的方法以及根据对应的业务需要, 完成了对应的业务功能,当运行原始业务方法时,就会运行代理对象中的方法,从而实现统计业务方法执行耗时的操作。

其实,AOP 面向切面编程和 OOP 面向对象编程一样,它们都仅仅是一种编程思想,而动态代理技术是这种思想最主流的实现方式。而 Spring 的 AOP 是 Spring 框架的高级技术,旨在管理 bean 对象的过程中底层使用动态代理机制,对特定的方法进行编程 (功能增强)。

AOP 的优势:

  1. 减少重复代码
  2. 提高开发效率
  3. 维护方便
  • AOP 的功能远不止于此,常见的应用场景如下:

  • 记录系统的操作日志

  • 权限控制

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

# AOP 核心概念

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

​ 连接点指的是可以被 aop 控制的方法。例如:入门程序当中所有的业务方法都是可以被 aop 控制的方法。

​ ![image-20230112160708474](../../JavaWeb/day13-SpringBootWeb AOP / 讲义 /assets/image-20230112160708474.png)

​ 在 SpringAOP 提供的 JoinPoint 当中,封装了连接点方法在执行时的相关信息。(后面会有具体的讲解)

2. 通知:Advice,指哪些重复的逻辑,也就是共性功能(最终体现为一个方法)

​ 在入门程序中是需要统计各个业务方法的执行耗时的,此时我们就需要在这些业务方法运行开始之前,先记录这个方法运行的开始时间,在每一个业务方法运行结束的时候,再来记录这个方法运行的结束时间。

​ 但是在 AOP 面向切面编程当中,我们只需要将这部分重复的代码逻辑抽取出来单独定义。抽取出来的这一部分重复的逻辑,也就是共性的功能。

​ <img src="../../JavaWeb/day13-SpringBootWeb AOP / 讲义 /assets/image-20230112160852883.png" alt="image-20230112160852883" style="zoom:80%;" />

3. 切入点:PointCut,匹配连接点的条件,通知仅会在切入点方法执行时被应用

​ 在通知当中,我们所定义的共性功能到底要应用在哪些方法上?此时就涉及到了切入点 pointcut 概念。切入点指的是匹配连接点的条件。通知仅会在切入点方法运行时才会被应用。

​ 在 aop 的开发当中,我们通常会通过一个切入点表达式来描述切入点 (后面会有详解)。

​ <img src="../../JavaWeb/day13-SpringBootWeb AOP / 讲义 /assets/image-20230112161131937.png" alt="image-20230112161131937" style="zoom:80%;" />

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

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

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

​ <img src="../../JavaWeb/day13-SpringBootWeb AOP / 讲义 /assets/image-20230112161335186.png" alt="image-20230112161335186" style="zoom:80%;" />

​ 切面所在的类,我们一般称为切面类(被 @Aspect 注解标识的类)

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

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

​ ![image-20230112161657667](../../JavaWeb/day13-SpringBootWeb AOP / 讲义 /assets/image-20230112161657667.png)

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

<img src="../../JavaWeb/day13-SpringBootWeb AOP / 讲义 /assets/image-20230112161821401.png" alt="image-20230112161821401" style="zoom:80%;" />

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

# AOP 进阶

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

  1. 通知类型
  2. 通知顺序
  3. 切入点表达式
  4. 连接点
  • # 1 通知类型

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

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

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

Spring 中 AOP 的通知类型:

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

程序发生异常的情况下:

  • @AfterReturning 标识的通知方法不会执行,@AfterThrowing 标识的通知方法执行了

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

在使用通知时的注意事项:

  • @Around 环绕通知需要自己调用 ProceedingJoinPoint.proceed () 来让原始方法执行,其他通知不需要考虑目标方法执行

  • @Around 环绕通知方法的返回值,必须指定为 Object,来接收原始方法的返回值,否则原始方法执行完毕,是获取不到返回值的。

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

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

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

@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,而在引用的时候,具体的语法为:

全类名。方法名 (),具体形式如下:

@Slf4j
@Component
@Aspect
public class MyAspect2 {
    // 引用 MyAspect1 切面类中的切入点表达式
    @Before("com.itheima.aspect.MyAspect1.pt()")
    public void before(){
        log.info("MyAspect2 -> before ...");
    }
}
  • # 2 通知顺序

在不同切面类中,默认按照切面类的类名字母排序:

  • 目标方法前的通知方法:字母排名靠前的先执行
  • 目标方法后的通知方法:字母排名靠前的后执行

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

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

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

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

# 切入点表达式

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

切入点表达式:

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

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

  • 常见形式:

    1. execution (……):根据方法的签名来匹配

    ![image-20230110214150215](../../JavaWeb/day13-SpringBootWeb AOP / 讲义 /assets/image-20230110214150215.png)

    1. @annotation (……) :根据注解匹配

    ![image-20230110214242083](../../JavaWeb/day13-SpringBootWeb AOP / 讲义 /assets/image-20230110214242083.png)

  • # 1 execution

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

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

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

  • 访问修饰符:可省略(比如: public、protected)

  • 包名。类名: 可省略

  • throws 异常:可省略(注意是方法上声明抛出的异常,不是实际抛出的异常)

示例:

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

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

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

  • .. :多个连续的任意符号,可以通配任意层级的包,或任意类型、任意个数的参数

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

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

切入点表达式示例

  • 省略方法的修饰符号

    execution(void com.itheima.service.impl.DeptServiceImpl.delete(java.lang.Integer))
  • 使用 * 代替返回值类型

    execution(* com.itheima.service.impl.DeptServiceImpl.delete(java.lang.Integer))
  • 使用 * 代替包名(一层包使用一个 *

    execution(* com.itheima.*.*.DeptServiceImpl.delete(java.lang.Integer))
  • 使用 .. 省略包名

    execution(* com..DeptServiceImpl.delete(java.lang.Integer))
  • 使用 * 代替类名

    execution(* com..*.delete(java.lang.Integer))
  • 使用 * 代替方法名

    execution(* com..*.*(java.lang.Integer))
  • 使用 * 代替参数

    execution(* com.itheima.service.impl.DeptServiceImpl.delete(*))
  • 使用 .. 省略参数

    execution(* com..*.*(..))

注意事项:

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

    execution(* com.itheima.service.DeptService.list(..)) || execution(* com.itheima.service.DeptService.delete(..))

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

  • 所有业务方法名在命名时尽量规范,方便切入点表达式快速匹配。如:查询类方法都是 find 开头,更新类方法都是 update 开头

    // 业务类
    @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 开头的方法
    execution(* com.itheima.service.impl.DeptServiceImpl.find*(..))
  • 描述切入点方法通常基于接口描述,而不是直接描述实现类,增强拓展性

    execution(* com.itheima.service.DeptService.*(..))
  • 在满足业务需要的前提下,尽量缩小切入点的匹配范围。如:包名匹配尽量不使用 ..,使用 * 匹配单个包

    execution(* com.itheima.*.*.DeptServiceImpl.find*(..))
  • # 2 @annotation

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

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

实现步骤:

  1. 编写自定义注解

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

自定义注解:MyLog

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyLog {
}

业务类:DeptServiceImpl

@Slf4j
@Service
public class DeptServiceImpl implements DeptService {
    @Autowired
    private DeptMapper deptMapper;
    @Override
    @MyLog // 自定义注解(表示:当前方法属于目标方法)
    public List<Dept> list() {
        List<Dept> deptList = deptMapper.list();
        // 模拟异常
        //int num = 10/0;
        return deptList;
    }
    @Override
    @MyLog  // 自定义注解(表示:当前方法属于目标方法)
    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);
    }
}

切面类

@Slf4j
@Component
@Aspect
public class MyAspect6 {
    // 针对 list 方法、delete 方法进行前置通知和后置通知
    // 前置通知
    @Before("@annotation(com.itheima.anno.MyLog)")
    public void before(){
        log.info("MyAspect6 -> before ...");
    }
    // 后置通知
    @After("@annotation(com.itheima.anno.MyLog)")
    public void after(){
        log.info("MyAspect6 -> after ...");
    }
}

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

![image-20230110224447047](../../JavaWeb/day13-SpringBootWeb AOP / 讲义 /assets/image-20230110224447047.png)

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

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

# 连接点

讲解完了切入点表达式之后,接下来我们再来讲解最后一个部分连接点。我们前面在讲解 AOP 核心概念的时候,我们提到过什么是连接点,连接点可以简单理解为可以被 AOP 控制的方法。

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

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

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

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

# SpringBoot 原理

# 1. 配置优先级

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

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

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

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

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

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

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

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

  1. Java 系统属性配置 (格式: -Dkey=value)

    -Dserver.port=9000
  2. 命令行参数 (格式:--key=value)

    --server.port=10010

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

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

image-20230113162746634

image-20230113162639630

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

image-20230113165006550

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

image-20230113170841253

优先级: 命令行参数 > 系统属性参数 > properties 参数 > yml 参数 > yaml 参数

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

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

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

  1. 执行 maven 打包指令 package,把项目打成 jar 文件
  2. 使用命令:java -jar 方式运行 jar 文件程序

项目打包:

image-20230113172313655

image-20230113172854016

运行 jar 程序:

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

image-20230113172659269

  • 仅设置 Java 系统属性

image-20230113173228232

注意事项:

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

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

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

# 2. Bean 管理

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

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

  1. 如何从 IOC 容器中手动的获取到 bean 对象
  2. bean 的作用域配置
  3. 管理第三方的 bean 对象

# 2.1 获取 Bean

默认情况下,SpringBoot 项目在启动的时候会自动的创建 IOC 容器 (也称为 Spring 容器),并且在启动的过程当中会自动的将 bean 对象都创建好,存放在 IOC 容器当中。应用程序在运行时需要依赖什么 bean 对象,就直接进行依赖注入就可以了。

而在 Spring 容器中提供了一些方法,可以主动从 IOC 容器中获取到 bean 对象,下面介绍 3 种常用方式:

  1. 根据 name 获取 bean

    Object getBean(String name)
  2. 根据类型获取 bean

    <T> T getBean(Class<T> requiredType)
  3. 根据 name 获取 bean(带类型转换)

    <T> T getBean(String name, Class<T> requiredType)

思考:要从 IOC 容器当中来获取到 bean 对象,需要先拿到 IOC 容器对象,怎么样才能拿到 IOC 容器呢?

  • 想获取到 IOC 容器,直接将 IOC 容器对象注入进来就可以了

控制器:DeptController

@RestController
@RequestMapping("/depts")
public class DeptController {
    @Autowired
    private DeptService deptService;
    public DeptController(){
        System.out.println("DeptController constructor ....");
    }
    @GetMapping
    public Result list(){
        List<Dept> deptList = deptService.list();
        return Result.success(deptList);
    }
    @DeleteMapping("/{id}")
    public Result delete(@PathVariable Integer id)  {
        deptService.delete(id);
        return Result.success();
    }
    @PostMapping
    public Result save(@RequestBody Dept dept){
        deptService.save(dept);
        return Result.success();
    }
}

业务实现类:DeptServiceImpl

@Slf4j
@Service
public class DeptServiceImpl implements DeptService {
    @Autowired
    private DeptMapper deptMapper;
    @Override
    public List<Dept> list() {
        List<Dept> deptList = deptMapper.list();
        return deptList;
    }
    @Override
    public void delete(Integer id) {
        deptMapper.delete(id);
    }
    @Override
    public void save(Dept dept) {
        dept.setCreateTime(LocalDateTime.now());
        dept.setUpdateTime(LocalDateTime.now());
        deptMapper.save(dept);
    }
}

Mapper 接口:

@Mapper
public interface DeptMapper {
    // 查询全部部门数据
    @Select("select * from dept")
    List<Dept> list();
    // 删除部门
    @Delete("delete from dept where id = #{id}")
    void delete(Integer id);
    // 新增部门
    @Insert("insert into dept(name, create_time, update_time) values (#{name},#{createTime},#{updateTime})")
    void save(Dept dept);
}

测试类:

@SpringBootTest
class SpringbootWebConfig2ApplicationTests {
    @Autowired
    private ApplicationContext applicationContext; //IOC 容器对象
    // 获取 bean 对象
    @Test
    public void testGetBean(){
        // 根据 bean 的名称获取
        DeptController bean1 = (DeptController) applicationContext.getBean("deptController");
        System.out.println(bean1);
        // 根据 bean 的类型获取
        DeptController bean2 = applicationContext.getBean(DeptController.class);
        System.out.println(bean2);
        // 根据 bean 的名称 及 类型获取
        DeptController bean3 = applicationContext.getBean("deptController", DeptController.class);
        System.out.println(bean3);
    }
}

程序运行后控制台日志:

image-20230113211619818

问题:输出的 bean 对象地址值是一样的,说明 IOC 容器当中的 bean 对象有几个?

答案:只有一个。 (默认情况下,IOC 中的 bean 对象是单例)

那么能不能将 bean 对象设置为非单例的 (每次获取的 bean 都是一个新对象)?

可以,在下一个知识点 (bean 作用域) 中讲解。

注意事项:

  • 上述所说的 【Spring 项目启动时,会把其中的 bean 都创建好】还会受到作用域及延迟初始化影响,这里主要针对于默认的单例非延迟加载的 bean 而言。

# 2.2 Bean 作用域

在前面我们提到的 IOC 容器当中,默认 bean 对象是单例模式 (只有一个实例对象)。那么如何设置 bean 对象为非单例呢?需要设置 bean 的作用域。

在 Spring 中支持五种作用域,后三种在 web 环境才生效:

作用域说明
singleton容器内同名称的 bean 只有一个实例(单例)(默认)
prototype每次使用该 bean 时会创建新的实例(非单例)
request每个请求范围内会创建新的实例(web 环境中,了解)
session每个会话范围内会创建新的实例(web 环境中,了解)
application每个应用范围内会创建新的实例(web 环境中,了解)

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

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

image-20230113214244144

1). 测试一

  • 控制器
// 默认 bean 的作用域为:singleton (单例)
@Lazy // 延迟加载(第一次使用 bean 对象时,才会创建 bean 对象并交给 ioc 容器管理)
@RestController
@RequestMapping("/depts")
public class DeptController {
    @Autowired
    private DeptService deptService;
    public DeptController(){
        System.out.println("DeptController constructor ....");
    }
    // 省略其他代码...
}
  • 测试类
@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 服务,运行测试方法,查看控制台打印的日志:

image-20230114001348839

注意事项:

  • IOC 容器中的 bean 默认使用的作用域:singleton (单例)

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

2). 测试二

修改控制器 DeptController 代码:

@Scope("prototype") //bean 作用域为非单例
@Lazy // 延迟加载
@RestController
@RequestMapping("/depts")
public class DeptController {
    @Autowired
    private DeptService deptService;
    public DeptController(){
        System.out.println("DeptController constructor ....");
    }
    // 省略其他代码...
}

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

image-20230114001736151

注意事项:

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

# 2.3 第三方 Bean

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

之前我们所配置的 bean,像 controller、service,dao 三层体系下编写的类,这些类都是我们在项目当中自己定义的类 (自定义类)。当我们要声明这些 bean,也非常简单,我们只需要在类上加上 @Component 以及它的这三个衍生注解(@Controller、@Service、@Repository),就可以来声明这个 bean 对象了。
但是在我们项目开发当中,还有一种情况就是这个类它不是我们自己编写的,而是我们引入的第三方依赖当中提供的。

在 pom.xml 文件中,引入 dom4j:

<!--Dom4j-->
<dependency>
    <groupId>org.dom4j</groupId>
    <artifactId>dom4j</artifactId>
    <version>2.1.3</version>
</dependency>

dom4j 就是第三方组织提供的。 dom4j 中的 SAXReader 类就是第三方编写的。

当我们需要使用到 SAXReader 对象时,直接进行依赖注入是不是就可以了呢?

  • 按照我们之前的做法,需要在 SAXReader 类上添加一个注解 @Component(将当前类交给 IOC 容器管理)

image-20230114003903285

结论:第三方提供的类是只读的。无法在第三方类上添加 @Component 注解或衍生注解。

那么我们应该怎样使用并定义第三方的 bean 呢?

  • 如果要管理的 bean 对象来自于第三方(不是自定义的),是无法用 @Component 及衍生注解声明 bean 的,就需要用到 **@Bean** 注解。

解决方案 1:在启动类上添加 @Bean 标识的方法

@SpringBootApplication
public class SpringbootWebConfig2Application {
    public static void main(String[] args) {
        SpringApplication.run(SpringbootWebConfig2Application.class, args);
    }
    // 声明第三方 bean
    @Bean // 将当前方法的返回值对象交给 IOC 容器管理,成为 IOC 容器 bean
    public SAXReader saxReader(){
        return new SAXReader();
    }
}

xml 文件:

<?xml version="1.0" encoding="UTF-8"?>
<emp>
    <name>Tom</name>
    <age>18</age>
</emp>

测试类:

@SpringBootTest
class SpringbootWebConfig2ApplicationTests {
    @Autowired
    private SAXReader saxReader;
    // 第三方 bean 的管理
    @Test
    public void testThirdBean() throws Exception {
        Document document = saxReader.read(this.getClass().getClassLoader().getResource("1.xml"));
        Element rootElement = document.getRootElement();
        String name = rootElement.element("name").getText();
        String age = rootElement.element("age").getText();
        System.out.println(name + " : " + age);
    }
    // 省略其他代码...
}

重启 SpringBoot 服务,执行测试方法后,控制台输出日志:

Tom : 18

说明:以上在启动类中声明第三方 Bean 的作法,不建议使用(项目中要保证启动类的纯粹性)

解决方案 2:在配置类中定义 @Bean 标识的方法

  • 如果需要定义第三方 Bean 时, 通常会单独定义一个配置类
@Configuration // 配置类  (在配置类当中对第三方 bean 进行集中的配置管理)
public class CommonConfig {
    // 声明第三方 bean
    @Bean // 将当前方法的返回值对象交给 IOC 容器管理,成为 IOC 容器 bean
          // 通过 @Bean 注解的 name/value 属性指定 bean 名称,如果未指定,默认是方法名
    public SAXReader reader(DeptService deptService){
        System.out.println(deptService);
        return new SAXReader();
    }
}

注释掉 SpringBoot 启动类中创建第三方 bean 对象的代码,重启服务,执行测试方法,查看控制台日志:

Tom : 18

在方法上加上一个 @Bean 注解,Spring 容器在启动的时候,它会自动的调用这个方法,并将方法的返回值声明为 Spring 容器当中的 Bean 对象。

注意事项 :

  • 通过 @Bean 注解的 name 或 value 属性可以声明 bean 的名称,如果不指定,默认 bean 的名称就是方法名。

  • 如果第三方 bean 需要依赖其它 bean 对象,直接在 bean 定义方法中设置形参即可,容器会根据类型自动装配。

关于 Bean 大家只需要保持一个原则:

  • 如果是在项目当中我们自己定义的类,想将这些类交给 IOC 容器管理,我们直接使用 @Component 以及它的衍生注解来声明就可以。

  • 如果这个类它不是我们自己定义的,而是引入的第三方依赖当中提供的类,而且我们还想将这个类交给 IOC 容器管理。此时我们就需要在配置类中定义一个方法,在方法上加上一个 @Bean 注解,通过这种方式来声明第三方的 bean 对象。

# 3.SpringBoot 原理

# 3.1 起步依赖

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

image-20230114173645101

spring-webmvc 依赖:这是 Spring 框架进行 web 程序开发所需要的依赖

servlet-api 依赖:Servlet 基础依赖

jackson-databind 依赖:JSON 处理工具包

如果要使用 AOP,还需要引入 aop 依赖、aspect 依赖

项目中所引入的这些依赖,还需要保证版本匹配,否则就可能会出现版本冲突问题。

如果我们使用了 SpringBoot,就不需要像上面这么繁琐的引入依赖了。我们只需要引入一个依赖就可以了,那就是 web 开发的起步依赖:springboot-starter-web。

image-20230114174805852

为什么我们只需要引入一个 web 开发的起步依赖,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 的依赖传递进来。

结论:起步依赖的原理就是 Maven 的依赖传递。

# 3.2 自动配置

我们讲解了 SpringBoot 当中起步依赖的原理,就是 Maven 的依赖传递。接下来我们解析下自动配置的原理,我们要分析自动配置的原理,首先要知道什么是自动配置。

  • # 3.2.1 概述

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

比如:我们要进行事务管理、要进行 AOP 程序的开发,此时就不需要我们再去手动的声明这些 bean 对象了,我们直接使用就可以从而大大的简化程序的开发,省去了繁琐的配置操作。

下面我们打开 idea,一起来看下自动配置的效果:

  • 运行 SpringBoot 启动类

image-20230114205745221

image-20230114213945851

image-20230114212750007

大家会看到有两个 CommonConfig,在第一个 CommonConfig 类中定义了一个 bean 对象,bean 对象的名字叫 reader。

在第二个 CommonConfig 中它的 bean 名字叫 commonConfig,为什么还会有这样一个 bean 对象呢?原因是在 CommonConfig 配置类上添加了一个注解 @Configuration,而 @Configuration 底层就是 @Component

image-20230114220159619

所以配置类最终也是 SpringIOC 容器当中的一个 bean 对象

在 IOC 容器中除了我们自己定义的 bean 以外,还有很多配置类,这些配置类都是 SpringBoot 在启动的时候加载进来的配置类。这些配置类加载进来之后,它也会生成很多的 bean 对象。

image-20230114221341811

比如:配置类 GsonAutoConfiguration 里面有一个 bean,bean 的名字叫 gson,它的类型是 Gson。

com.google.gson.Gson 是谷歌包中提供的用来处理 JSON 格式数据的。

当我们想要使用这些配置类中生成的 bean 对象时,可以使用 @Autowired 就自动注入了:

import com.google.gson.Gson;
import com.itheima.pojo.Result;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
public class AutoConfigurationTests {
    @Autowired
    private Gson gson;
    @Test
    public void testJson(){
        String json = gson.toJson(Result.success());
        System.out.println(json);
    }
}

添加断点,使用 debug 模式运行测试类程序:

image-20230114222245520

问题:在当前项目中我们并没有声明谷歌提供的 Gson 这么一个 bean 对象,然后我们却可以通过 @Autowired 从 Spring 容器中注入 bean 对象,那么这个 bean 对象怎么来的?

答案:SpringBoot 项目在启动时通过自动配置完成了 bean 对象的创建。

体验了 SpringBoot 的自动配置了,下面我们就来分析自动配置的原理。其实分析自动配置原理就是来解析在 SpringBoot 项目中,在引入依赖之后是如何将依赖 jar 包当中所定义的配置类以及 bean 加载到 SpringIOC 容器中的。

  • # 3.2.2 常见方案

    • # 3.2.2.1 概述

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

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

准备工作:在 Idea 中导入 "资料 \03. 自动配置原理" 下的 itheima-utils 工程

1、在 SpringBoot 项目 spring-boot-web-config2 工程中,通过坐标引入 itheima-utils 依赖

image-20230114224107653

@Component
public class TokenParser {
    public void parse(){
        System.out.println("TokenParser ... parse ...");
    }
}

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

@SpringBootTest
public class AutoConfigurationTests {
    @Autowired
    private ApplicationContext applicationContext;
    @Test
    public void testTokenParse(){
        System.out.println(applicationContext.getBean(TokenParser.class));
    }
    // 省略其他代码...
}

3、执行测试方法

image-20230114225018255

异常信息描述: 没有 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.2.2 方案一

@ComponentScan 组件扫描

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

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

image-20230114231121016

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

缺点:

  1. 使用繁琐
  2. 性能低

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

  • # 3.2.2.3 方案二

@Import 导入

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

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

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

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

image-20230114231709392

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

  • 配置类
@Configuration
public class HeaderConfig {
    @Bean
    public HeaderParser headerParser(){
        return new HeaderParser();
    }
    @Bean
    public HeaderGenerator headerGenerator(){
        return new HeaderGenerator();
    }
}
  • 启动类
@Import(HeaderConfig.class) // 导入配置类
@SpringBootApplication
public class SpringbootWebConfig2Application {
    public static void main(String[] args) {
        SpringApplication.run(SpringbootWebConfig2Application.class, args);
    }
}
  • 测试类
@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));
    }
    
    // 省略其他代码...
}

执行测试方法:

image-20230114233252259

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

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

执行测试方法:

image-20230114234222946

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

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

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

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

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

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

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

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

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

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

执行测试方法:

image-20230114233252259

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

  • # 3.2.3 原理分析

    • # 3.2.3.1 源码跟踪

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

源码跟踪技巧:

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

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

image-20230115001439110

在 @SpringBootApplication 注解中包含了:

  • 元注解(不再解释)
  • @SpringBootConfiguration
  • @EnableAutoConfiguration
  • @ComponentScan

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

image-20230115001950076

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

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

接下来再先看 @ComponentScan 注解:

image-20230115002450993

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

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

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

image-20230115002743115

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

AutoConfigurationImportSelector 类是 ImportSelector 接口的实现类。

image-20230115003242549

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

image-20230115003348288

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

image-20230115003704385

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

image-20230115003903302

getCandidateConfigurations 方法的功能:

获取所有基于 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件、META-INF/spring.factories 文件中配置类的集合

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

  • 通常在引入的起步依赖中,都有包含以上两个文件

image-20230129090835964

image-20230115064329460

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

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

image-20230115005159530

第三方依赖中提供的 GsonAutoConfiguration 类:

image-20230115005418900

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

image-20230115065247287

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

自动配置源码小结

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

  • @SpringBootConfiguration
    • 声明当前类是一个配置类
  • @ComponentScan
    • 进行组件扫描(SpringBoot 中默认扫描的是启动类所在的当前包及其子包)
  • @EnableAutoConfiguration
    • 封装了 @Import 注解(Import 注解中指定了一个 ImportSelector 接口的实现类)
      • 在实现类重写的 selectImports () 方法,读取当前项目下所有依赖 jar 包中 META-INF/spring.factories、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.3.2 @Conditional

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

@Conditional 注解:

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

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

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

执行 testHeaderParser () 测试方法:

image-20230115203748022

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

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

执行 testHeaderParser () 测试方法:

image-20230115211029855

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

再次修改 @ConditionalOnMissingBean 注解:

@Configuration
public class HeaderConfig {
    @Bean
    @ConditionalOnMissingBean(name="deptController2")// 不存在指定名称的 bean,才会将该 bean 加入 IOC 容器
    public HeaderParser headerParser(){
        return new HeaderParser();
    }
    
    // 省略其他代码...
}

执行 testHeaderParser () 测试方法:

image-20230115211351681

因为在 SpringBoot 环境中不存在名字叫 deptController2 的 bean 对象,所以创建 HeaderParser 对象并注册到 IOC 容器中。

再次修改 @ConditionalOnMissingBean 注解:

@Configuration
public class HeaderConfig {
    @Bean
    @ConditionalOnMissingBean(HeaderConfig.class)// 不存在指定类型的 bean,才会将 bean 加入 IOC 容器
    public HeaderParser headerParser(){
        return new HeaderParser();
    }
    
    // 省略其他代码...
}
@SpringBootTest
public class AutoConfigurationTests {
    @Autowired
    private ApplicationContext applicationContext;
    @Test
    public void testHeaderParser(){
        System.out.println(applicationContext.getBean(HeaderParser.class));
    }
    
    // 省略其他代码...
}

执行 testHeaderParser () 测试方法:

image-20230115211957191

因为 HeaderConfig 类中添加 @Configuration 注解,而 @Configuration 注解中包含了 @Component,所以 SpringBoot 启动时会创建 HeaderConfig 类对象,并注册到 IOC 容器中。

当 IOC 容器中有 HeaderConfig 类型的 bean 存在时,不会把创建 HeaderParser 对象注册到 IOC 容器中。而 IOC 容器中没有 HeaderParser 类型的对象时,通过 getBean (HeaderParser.class) 方法获取 bean 对象时,引发异常:NoSuchBeanDefinitionException

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

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

name: itheima

在声明 bean 的时候就可以指定一个条件 @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 () 测试方法:

image-20230115220235511

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

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

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

image-20230115211957191

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

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

image-20230115222128740

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

image-20230115222302753

自动配置的核心就在 @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.4 案例

    # 3.2.4.1 自定义 starter 分析

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

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

    业务场景:

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

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

    image-20230115224939131

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

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

    image-20230115225703863

    Mybatis 提供了配置类,并且也提供了 springboot 会自动读取的配置文件。当 SpringBoot 项目启动时,会读取到 spring.factories 配置文件中的配置类并加载配置类,生成相关 bean 对象注册到 IOC 容器中。

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

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

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

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

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

    需求:自定义 aliyun-oss-spring-boot-starter,完成阿里云 OSS 操作工具类 AliyunOSSUtils 的自动配置。

    目标:引入起步依赖引入之后,要想使用阿里云 OSS,注入 AliyunOSSUtils 直接使用即可。

    之前阿里云 OSS 的使用:

    • 配置文件
    #配置阿里云 OSS 参数
    aliyun:
      oss:
        endpoint: https://oss-cn-shanghai.aliyuncs.com
        accessKeyId: LTAI5t9MZK8iq5T2Av5GLDxX
        accessKeySecret: C0IrHzKZGKqU8S7YQcevcotD3Zd5Tc
        bucketName: web-framework01
    • AliOSSProperties 类
    @Data
    @Component
    @ConfigurationProperties(prefix = "aliyun.oss")
    public class AliOSSProperties {
        // 区域
        private String endpoint;
        // 身份 ID
        private String accessKeyId ;
        // 身份密钥
        private String accessKeySecret ;
        // 存储空间
        private String bucketName;
    }
    • AliOSSUtils 工具类
    @Component // 当前类对象由 Spring 创建和管理
    public class AliOSSUtils {
        @Autowired
        private AliOSSProperties aliOSSProperties;
        /**
         * 实现上传图片到 OSS
         */
        public String upload(MultipartFile multipartFile) throws IOException {
            // 获取上传的文件的输入流
            InputStream inputStream = multipartFile.getInputStream();
            // 避免文件覆盖
            String originalFilename = multipartFile.getOriginalFilename();
            String fileName = UUID.randomUUID().toString() + originalFilename.substring(originalFilename.lastIndexOf("."));
            // 上传文件到 OSS
            OSS ossClient = new OSSClientBuilder().build(aliOSSProperties.getEndpoint(),
                    aliOSSProperties.getAccessKeyId(), aliOSSProperties.getAccessKeySecret());
            ossClient.putObject(aliOSSProperties.getBucketName(), fileName, inputStream);
            // 文件访问路径
            String url =aliOSSProperties.getEndpoint().split("//")[0] + "//" + aliOSSProperties.getBucketName() + "." + aliOSSProperties.getEndpoint().split("//")[1] + "/" + fileName;
            // 关闭 ossClient
            ossClient.shutdown();
            return url;// 把上传到 oss 的路径返回
        }
    }

    当我们在项目当中要使用阿里云 OSS,就可以注入 AliOSSUtils 工具类来进行文件上传。但这种方式其实是比较繁琐的。

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

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

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

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

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

    # 3.2.4.2 自定义 starter 实现

    自定义 starter 的步骤我们刚才已经分析了,接下来我们就按照分析的步骤来完成自定义 starter 的开发。

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

    1). aliyun-oss-spring-boot-starter 模块

    image-20230115234739988

    image-20230115234823134

    创建完 starter 模块后,删除多余的文件,最终保留内容如下:

    image-20230115235429353

    删除 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>2.7.5</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>11</java.version>
    	</properties>
    	
    	<dependencies>
    		<dependency>
    			<groupId>org.springframework.boot</groupId>
    			<artifactId>spring-boot-starter</artifactId>
    		</dependency>
    	</dependencies>
    </project>

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

    image-20230116000302319

    image-20230115235921014

    创建完 starter 模块后,删除多余的文件,最终保留内容如下:

    image-20230116000542905

    删除 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>2.7.5</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>11</java.version>
    	</properties>
    	<dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter</artifactId>
            </dependency>
    	</dependencies>
    </project>

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

    <?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>2.7.5</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>11</java.version>
    	</properties>
    	
    	<dependencies>
    		<!-- 引入 autoconfigure 模块 -->
    		<dependency>
    			<groupId>com.aliyun.oss</groupId>
    			<artifactId>aliyun-oss-spring-boot-autoconfigure</artifactId>
    			<version>0.0.1-SNAPSHOT</version>
    		</dependency>
    		
    		<dependency>
    			<groupId>org.springframework.boot</groupId>
    			<artifactId>spring-boot-starter</artifactId>
    		</dependency>
    	</dependencies>
    </project>

    前两步已经完成了,接下来是最关键的就是第三步:

    在 autoconfigure 模块当中来完成自动配置操作。

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

    image-20230116001622679

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

    <?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>2.7.5</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>11</java.version>
    	</properties>
    	<dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter</artifactId>
            </dependency>
    		<!-- 引入 web 起步依赖 -->
    		<dependency>
    			<groupId>org.springframework.boot</groupId>
    			<artifactId>spring-boot-starter-web</artifactId>
    		</dependency>
    		<!--Lombok-->
    		<dependency>
    			<groupId>org.projectlombok</groupId>
    			<artifactId>lombok</artifactId>
    		</dependency>
    		<!-- 阿里云 OSS-->
    		<dependency>
    			<groupId>com.aliyun.oss</groupId>
    			<artifactId>aliyun-sdk-oss</artifactId>
    			<version>3.15.1</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 注解还有用吗?

    image-20230116002417105

    image-20230116002442736

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

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

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

    image-20230116002747681

    删除 AliOSSUtils 类中的 @Component 注解、@Autowired 注解

    image-20230116003046768

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

    image-20230116003513900

    AliOSSAutoConfiguration 类:

    @Configuration// 当前类为 Spring 配置类
    @EnableConfigurationProperties(AliOSSProperties.class)// 导入 AliOSSProperties 类,并交给 SpringIOC 管理
    public class AliOSSAutoConfiguration {
        // 创建 AliOSSUtils 对象,并交给 SpringIOC 容器
        @Bean
        public AliOSSUtils aliOSSUtils(AliOSSProperties aliOSSProperties){
            AliOSSUtils aliOSSUtils = new AliOSSUtils();
            aliOSSUtils.setAliOSSProperties(aliOSSProperties);
            return aliOSSUtils;
        }
    }

    AliOSSProperties 类:

    /* 阿里云 OSS 相关配置 */
    @Data
    @ConfigurationProperties(prefix = "aliyun.oss")
    public class AliOSSProperties {
        // 区域
        private String endpoint;
        // 身份 ID
        private String accessKeyId ;
        // 身份密钥
        private String accessKeySecret ;
        // 存储空间
        private String bucketName;
    }

    AliOSSUtils 类:

    @Data 
    public class AliOSSUtils {
        private AliOSSProperties aliOSSProperties;
        /**
         * 实现上传图片到 OSS
         */
        public String upload(MultipartFile multipartFile) throws IOException {
            // 获取上传的文件的输入流
            InputStream inputStream = multipartFile.getInputStream();
            // 避免文件覆盖
            String originalFilename = multipartFile.getOriginalFilename();
            String fileName = UUID.randomUUID().toString() + originalFilename.substring(originalFilename.lastIndexOf("."));
            // 上传文件到 OSS
            OSS ossClient = new OSSClientBuilder().build(aliOSSProperties.getEndpoint(),
                    aliOSSProperties.getAccessKeyId(), aliOSSProperties.getAccessKeySecret());
            ossClient.putObject(aliOSSProperties.getBucketName(), fileName, inputStream);
            // 文件访问路径
            String url =aliOSSProperties.getEndpoint().split("//")[0] + "//" + aliOSSProperties.getBucketName() + "." + aliOSSProperties.getEndpoint().split("//")[1] + "/" + fileName;
            // 关闭 ossClient
            ossClient.shutdown();
            return url;// 把上传到 oss 的路径返回
        }
    }

    在 aliyun-oss-spring-boot-autoconfigure 模块中的 resources 下,新建自动配置文件:

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

      com.aliyun.oss.AliOSSAutoConfiguration

    image-20230116004957697

    # 3.2.4.3 自定义 starter 测试

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

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

    image-20230116005530815

    测试前准备:

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

      • 通过依赖传递,会把 autoconfigure 依赖也引入了
      <!-- 引入阿里云 OSS 起步依赖 -->
      <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 配置参数信息(从以前的工程中拷贝即可)

      #配置阿里云 OSS 参数
      aliyun:
        oss:
          endpoint: https://oss-cn-shanghai.aliyuncs.com
          accessKeyId: LTAI5t9MZK8iq5T2Av5GLDxX
          accessKeySecret: C0IrHzKZGKqU8S7YQcevcotD3Zd5Tc
          bucketName: web-framework01
    3. 在 test 工程中的 UploadController 类编写代码

      @RestController
      public class UploadController {
          @Autowired
          private AliOSSUtils aliOSSUtils;
          @PostMapping("/upload")
          public String upload(MultipartFile image) throws Exception {
              // 上传文件到阿里云 OSS
              String url = aliOSSUtils.upload(image);
              return url;
          }
      }

    编写完代码后,我们启动当前的 SpringBoot 测试工程:

    • 随着 SpringBoot 项目启动,自动配置会把 AliOSSUtils 的 bean 对象装配到 IOC 容器中

    image-20230116011039611

    用 postman 工具进行文件上传:

    image-20230116010731914

    通过断点可以看到自动注入 AliOSSUtils 的 bean 对象:

    image-20230116011501201

# Maven 高级

Maven 高级内容包括:

  • 分模块设计与开发
  • 继承与聚合
  • 私服

# 1. 分模块设计与开发

# 1.1 介绍

所谓分模块设计,顾名思义指的就是我们在设计一个 Java 项目的时候,将一个 Java 项目拆分成多个模块进行开发。

1). 未分模块设计的问题

<img src="../../JavaWeb/day15-maven 高级 / 讲义 /assets/image-20230113090241470.png" alt="image-20230113090241470" style="zoom:67%;" />

如果项目不分模块,也就意味着所有的业务代码是不是都写在这一个 Java 项目当中。随着这个项目的业务扩张,项目当中的业务功能可能会越来越多。

假如我们开发的是一个大型的电商项目,里面可能就包括了商品模块的功能、搜索模块的功能、购物车模块、订单模块、用户中心等等。这些所有的业务代码我们都在一个 Java 项目当中编写。

此时大家可以试想一下,假如我们开发的是一个大型的电商网站,这个项目组至少几十号甚至几百号开发人员,这些开发人员全部操作这一个 Java 项目。此时大家就会发现我们项目管理和维护起来将会非常的困难。而且大家再来看,假如在我们的项目当中,我们自己定义了一些通用的工具类以及通用的组件,而公司还有其他的项目组,其他项目组也想使用我们所封装的这些组件和工具类,其实是非常不方便的。因为 Java 项目当中包含了当前项目的所有业务代码,所以就造成了这里面所封装的一些组件会难以复用。

总结起来,主要两点问题:不方便项目的维护和管理、项目中的通用组件难以复用。

2). 分模块设计

分模块设计我们在进行项目设计阶段,就可以将一个大的项目拆分成若干个模块,每一个模块都是独立的。

image-20230113094045299

比如我们可以将商品的相关功能放在商品模块当中,搜索的相关业务功能我都封装在搜索模块当中,还有像购物车模块、订单模块。而为了组件的复用,我们也可以将项目当中的实体类、工具类以及我们定义的通用的组件都单独的抽取到一个模块当中。

如果当前这个模块,比如订单模块需要用到这些实体类以及工具类或者这些通用组件,此时直接在订单模块当中引入工具类的坐标就可以了。这样我们就将一个项目拆分成了若干个模块儿,这就是分模块儿设计。

分模块儿设计之后,大家再来看。我们在进行项目管理的时候,我就可以几个人一组,几个人来负责订单模块儿,另外几个人来负责购物车模块儿,这样更加便于项目的管理以及项目的后期维护。

而且分模块设计之后,如果我们需要用到另外一个模块的功能,我们直接依赖模块就可以了。比如商品模块、搜索模块、购物车订单模块都需要依赖于通用组件当中封装的一些工具类,我只需要引入通用组件的坐标就可以了。

分模块设计就是将项目按照功能 / 结构拆分成若干个子模块,方便项目的管理维护、拓展,也方便模块键的相互调用、资源共享。

# 1.2 实践

# 1.2.1 分析

好,我们明白了什么是分模块设计以及分模块设计的优势之后,接下来我们就来看一下我们之前所开发的案例工程。

我们可以看到在这个项目当中,除了我们所开发的部门管理以及员工管理、登录认证等相关业务功能以外,我们是不是也定义了一些实体类,也就是 pojo 包下存放的一些类,像分页结果的封装类 PageBean、 统一响应结果 Result,我们还定义了一些通用的工具类,像 Jwts、阿里云 OSS 操作的工具类等等。

如果在当前公司的其他项目组当中,也想使用我们所封装的这些公共的组件,该怎么办?大家可以思考一下。

  • 方案一:直接依赖我们当前项目 tlias-web-management ,但是存在两大缺点:

    • 这个项目当中包含所有的业务功能代码,而想共享的资源,仅仅是 pojo 下的实体类,以及 utils 下的工具类。如果全部都依赖进来,项目在启动时将会把所有的类都加载进来,会影响性能
    • 如果直接把这个项目都依赖进来了,那也就意味着我们所有的业务代码都对外公开了,这个是非常不安全的。
  • 方案二:分模块设计

    • 将 pojo 包下的实体类,抽取到一个 maven 模块中 tlias-pojo
    • 将 utils 包下的工具类,抽取到一个 maven 模块中 tlias-utils
    • 其他的业务代码,放在 tlias-web-management 这个模块中,在该模块中需要用到实体类 pojo、工具类 utils,直接引入对应的依赖即可。

    image-20230113095609518

注意:分模块开发需要先针对模块功能进行设计,再进行编码。不会先将工程开发完毕,然后进行拆分。

​ PS:当前我们是为了演示分模块开发,所以是基于我们前面开发的案例项目进行拆分的,实际中都是分模块设计,然后再开发的。

# 1.2.2 实现

思路我们分析完毕,接下来,我们就根据我们分析的思路,按照如下模块进行拆分:

1. 创建 maven 模块 tlias-pojo,存放实体类

A. 创建一个正常的 Maven 模块,模块名 tlias-pojo

<img src="../../JavaWeb/day15-maven 高级 / 讲义 /assets/image-20230113100500382.png" alt="image-20230113100500382" style="zoom: 60%;" /> <img src="../../JavaWeb/day15-maven 高级 / 讲义 /assets/image-20230113100756843.png" alt="image-20230113100756843" style="zoom: 60%;" />

B. 然后在 tlias-pojo 中创建一个包 com.itheima.pojo (和原来案例项目中的 pojo 包名一致)

image-20230113101203524

C. 将原来案例项目 tlias-web-management 中的 pojo 包下的实体类,复制到 tlias-pojo 模块中

image-20230113101216305

D. 在 tlias-pojo 模块的 pom.xml 文件中引入依赖

<dependencies>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.24</version>
    </dependency>
</dependencies>

E. 删除原有案例项目 tlias-web-management 的 pojo 包【直接删除不要犹豫,我们已经将该模块拆分出去了】,然后在 pom.xml 中引入 tlias-pojo 的依赖

<dependency>
    <groupId>com.itheima</groupId>
    <artifactId>tlias-pojo</artifactId>
    <version>1.0-SNAPSHOT</version>
</dependency>

2. 创建 Maven 模块 tlias-utils,存放相关工具类

A. 创建一个正常的 Maven 模块,模块名 tlias-utils

<img src="../../JavaWeb/day15-maven 高级 / 讲义 /assets/image-20230113100500382.png" alt="image-20230113100500382" style="zoom: 60%;" /> <img src="../../JavaWeb/day15-maven 高级 / 讲义 /assets/image-20230113101816151.png" alt="image-20230113101816151" style="zoom:67%;" />

B. 然后在 tlias-utils 中创建一个包 com.itheima.utils (和原来案例项目中的 utils 包名一致)

image-20230113102102376

C. 将原来案例项目 tlias-web-management 中的 utils 包下的实体类,复制到 tlias-utils 模块中

image-20230113102113451

D. 在 tlias-utils 模块的 pom.xml 文件中引入依赖

<dependencies>
    <!--JWT 令牌 -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
        <version>0.9.1</version>
    </dependency>
    <!-- 阿里云 OSS-->
    <dependency>
        <groupId>com.aliyun.oss</groupId>
        <artifactId>aliyun-sdk-oss</artifactId>
        <version>3.15.1</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>
    <!--WEB 开发 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
        <version>2.7.5</version>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.24</version>
    </dependency>
</dependencies>

E. 删除原有案例项目 tlias-web-management 的 utils 包【直接删除不要犹豫,我们已经将该模块拆分出去了】,然后在 pom.xml 中引入 tlias-utils 的依赖

<dependency>
    <groupId>com.itheima</groupId>
    <artifactId>tlias-utils</artifactId>
    <version>1.0-SNAPSHOT</version>
</dependency>

到此呢,就已经完成了模块的拆分,拆分出了 tlias-pojo、tlias-utils、tlias-web-management ,如果其他项目中需要用到 pojo,或者 utils 工具类,就可以直接引入依赖。

# 1.3 总结

1). 什么是分模块设计:将项目按照功能拆分成若干个子模块

2). 为什么要分模块设计:方便项目的管理维护、扩展,也方便模块间的相互调用,资源共享

3). 注意事项:分模块设计需要先针对模块功能进行设计,再进行编码。不会先将工程开发完毕,然后进行拆分

# 2. 继承与聚合

在案例项目分模块开发之后啊,我们会看到 tlias-pojo、tlias-utils、tlias-web-management 中都引入了一个依赖 lombok 的依赖。我们在三个模块中分别配置了一次。

<img src="../../JavaWeb/day15-maven 高级 / 讲义 /assets/image-20230113103714055.png" alt="image-20230113103714055" style="zoom:80%;" />

如果是做一个大型的项目,这三个模块当中重复的依赖可能会很多很多。如果每一个 Maven 模块里面,我们都来单独的配置一次,功能虽然能实现,但是配置是比较繁琐的。

而接下来我们要讲解的 Maven 的继承用来解决这问题的。

# 2.1 继承

我们可以再创建一个父工程 tlias-parent ,然后让上述的三个模块 tlias-pojo、tlias-utils、tlias-web-management 都来继承这个父工程 。 然后再将各个模块中都共有的依赖,都提取到父工程 tlias-parent 中进行配置,只要子工程继承了父工程,依赖它也会继承下来,这样就无需在各个子工程中进行配置了。

image-20230113111557714

  • 概念:继承描述的是两个工程间的关系,与 java 中的继承相似,子工程可以继承父工程中的配置信息,常见于依赖关系的继承。

  • 作用:简化依赖配置、统一管理依赖

  • 实现:

    <parent>
        <groupId>...</groupId>
        <artifactId>...</artifactId>
        <version>...</version>
        <relativePath>....</relativePath>
    </parent>

这是我们在这里先介绍一下什么是继承以及继承的作用,以及在 maven 当中如何来实现这层继承关系。接下来我们就来创建这样一个 parent 父工程,我们就可以将各个子工程当中共有的这部分依赖统一的定义在父工程 parent 当中,从而来简化子工程的依赖配置。接下来我们来看一下具体的操作步骤。

我们在这里先介绍一下什么是继承以及继承的作用,以及在 maven 当中如何来实现这层继承关系。接下来我们就来创建这样一个 parent 父工程,我们就可以将各个子工程当中共有的这部分依赖,统一的定义在父工程 parent 当中,从而来简化子工程的依赖配置。

# 2.1.1 继承关系

# 2.1.1.1 思路分析

我们当前的项目 tlias-web-management,还稍微有一点特殊,因为是一个 springboot 项目,而所有的 springboot 项目都有一个统一的父工程,就是 spring-boot-starter-parent。 与 java 语言类似,Maven 不支持多继承,一个 maven 项目只能继承一个父工程,如果继承了 spring-boot-starter-parent,就没法继承我们自己定义的父工程 tlias-parent 了。

那我们怎么来解决这个问题呢?

那此时,大家可以想一下,Java 虽然不支持多继承,但是可以支持多重继承,比如:A 继承 B, B 继承 C。 那在 Maven 中也是支持多重继承的,所以呢,我们就可以让 我们自己创建的三个模块,都继承 tlias-parent,而 tlias-parent 再继承 spring-boot-starter-parent,就可以了。 具体结构如下:

image-20230113113004727

# 2.1.1.2 实现

1). 创建 maven 模块 tlias-parent ,该工程为父工程,设置打包方式 pom (默认 jar)。

​ <img src="../../JavaWeb/day15-maven 高级 / 讲义 /assets/image-20230113112712232.png" alt="image-20230113112712232" style="zoom:67%;" /> <img src="../../JavaWeb/day15-maven 高级 / 讲义 /assets/image-20230113112810295.png" alt="image-20230113112810295" style="zoom:67%;" />

工程结构如下:

image-20230113120517216

父工程 tlias-parent 的 pom.xml 文件配置如下:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.7.5</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.itheima</groupId>
<artifactId>tlias-parent</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>pom</packaging>

Maven 打包方式:

  • jar:普通模块打包,springboot 项目基本都是 jar 包(内嵌 tomcat 运行)
  • war:普通 web 程序打包,需要部署在外部的 tomcat 服务器中运行
  • pom:父工程或聚合工程,该模块不写代码,仅进行依赖管理

2). 在子工程的 pom.xml 文件中,配置继承关系。

<parent>
    <groupId>com.itheima</groupId>
    <artifactId>tlias-parent</artifactId>
    <version>1.0-SNAPSHOT</version>
    <relativePath>../tlias-parent/pom.xml</relativePath>
</parent>
<artifactId>tlias-utils</artifactId>
<version>1.0-SNAPSHOT</version>

这里是以 tlias-utils 为例,指定了其父工程。其他的模块,都是相同的配置方式。

注意:

  • 在子工程中,配置了继承关系之后,坐标中的 groupId 是可以省略的,因为会自动继承父工程的 。
  • relativePath 指定父工程的 pom 文件的相对位置(如果不指定,将从本地仓库 / 远程仓库查找该工程)。
    • ../ 代表的上一级目录

3). 在父工程中配置各个工程共有的依赖(子工程会自动继承父工程的依赖)。

<dependencies>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.24</version>
    </dependency>
</dependencies>

此时,我们已经将各个子工程中共有的依赖(lombok),都定义在了父工程中,子工程中的这一项依赖,就可以直接删除了。删除之后,我们会看到父工程中配置的依赖 lombok,子工程直接继承下来了。

image-20230113120408661

工程结构说明:

  • 我们当前的项目结构为:

    image-20230113120636912

    因为我们是项目开发完毕之后,给大家基于现有项目拆分的各个模块,tlias-web-management 已经存在了,然后再创建各个模块与父工程,所以父工程与模块之间是平级的。

  • 而实际项目中,可能还会见到下面的工程结构:

    image-20230113120728680

    而在真实的企业开发中,都是先设计好模块之后,再开始创建模块,开发项目。 那此时呢,一般都会先创建父工程 tlias-parent,然后将创建的各个子模块,都放在父工程 parent 下面。 这样层级结构会更加清晰一些。

    PS:上面两种工程结构,都是可以正常使用的,没有一点问题。 只不过,第二种结构,看起来,父子工程结构更加清晰、更加直观。

# 2.1.2 版本锁定

# 2.1.2.1 场景

如果项目中各个模块中都公共的这部分依赖,我们可以直接定义在父工程中,从而简化子工程的配置。 然而在项目开发中,还有一部分依赖,并不是各个模块都共有的,可能只是其中的一小部分模块中使用到了这个依赖。

比如:在 tlias-web-management、tlias-web-system、tlias-web-report 这三个子工程中,都使用到了 jwt 的依赖。 但是 tlias-pojo、tlias-utils 中并不需要这个依赖,那此时,这个依赖,我们不会直接配置在父工程 tlias-parent 中,而是哪个模块需要,就在哪个模块中配置。

而由于是一个项目中的多个模块,那多个模块中,我们要使用的同一个依赖的版本要一致,这样便于项目依赖的统一管理。比如:这个 jwt 依赖,我们都使用的是 0.9.1 这个版本。

image-20230113122213954

那假如说,我们项目要升级,要使用到 jwt 最新版本 0.9.2 中的一个新功能,那此时需要将依赖的版本升级到 0.9.2,那此时该怎么做呢 ?

第一步:去找当前项目中所有的模块的 pom.xml 配置文件,看哪些模块用到了 jwt 的依赖。

第二步:找到这个依赖之后,将其版本 version,更换为 0.9.2。

问题:如果项目拆分的模块比较多,每一次更换版本,我们都得找到这个项目中的每一个模块,一个一个的更改。 很容易就会出现,遗漏掉一个模块,忘记更换版本的情况。

那我们又该如何来解决这个问题,如何来统一管理各个依赖的版本呢?

答案:Maven 的版本锁定功能。

# 2.1.2.2 介绍

在 maven 中,可以在父工程的 pom 文件中通过 <dependencyManagement> 来统一管理依赖版本。

父工程:

<!-- 统一管理依赖版本 -->
<dependencyManagement>
    <dependencies>
        <!--JWT 令牌 -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>
    </dependencies>
</dependencyManagement>

子工程:

<dependencies>
    <!--JWT 令牌 -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
    </dependency>
</dependencies>

注意:

  • 在父工程中所配置的 <dependencyManagement> 只能统一管理依赖版本,并不会将这个依赖直接引入进来。 这点和 <dependencies> 是不同的。

  • 子工程要使用这个依赖,还是需要引入的,只是此时就无需指定 <version> 版本号了,父工程统一管理。变更依赖版本,只需在父工程中统一变更。

# 2.1.2.3 实现

接下来,我们就可以将 tlias-utils 模块中单独配置的依赖,将其版本统一交给 tlias-parent 进行统一管理。

具体步骤如下:

1). tlias-parent 中的配置

<!-- 统一管理依赖版本 -->
<dependencyManagement>
    <dependencies>
        <!--JWT 令牌 -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>
        <!-- 阿里云 OSS-->
        <dependency>
            <groupId>com.aliyun.oss</groupId>
            <artifactId>aliyun-sdk-oss</artifactId>
            <version>3.15.1</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>
</dependencyManagement>

2). tlias-utils 中的 pom.xml 配置

如果依赖的版本已经在父工程进行了统一管理,所以在子工程中就无需再配置依赖的版本了。

<dependencies>
    <!--JWT 令牌 -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
    </dependency>
    <!-- 阿里云 OSS-->
    <dependency>
        <groupId>com.aliyun.oss</groupId>
        <artifactId>aliyun-sdk-oss</artifactId>
    </dependency>
    <dependency>
        <groupId>javax.xml.bind</groupId>
        <artifactId>jaxb-api</artifactId>
    </dependency>
    <dependency>
        <groupId>javax.activation</groupId>
        <artifactId>activation</artifactId>
    </dependency>
    <!-- no more than 2.3.3-->
    <dependency>
        <groupId>org.glassfish.jaxb</groupId>
        <artifactId>jaxb-runtime</artifactId>
    </dependency>
    <!--WEB 开发 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

我们之所以,在 springboot 项目中很多时候,引入依赖坐标,都不需要指定依赖的版本 <version> ,是因为在父工程 spring-boot-starter-parent 中已经通过 <dependencyManagement> 对依赖的版本进行了统一的管理维护。

# 2.1.2.4 属性配置

我们也可以通过自定义属性及属性引用的形式,在父工程中将依赖的版本号进行集中管理维护。 具体语法为:

1). 自定义属性

<properties>
	<lombok.version>1.18.24</lombok.version>
</properties>

2). 引用属性

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>${lombok.version}</version>
</dependency>

接下来,我们就可以在父工程中,将所有的版本号,都集中管理维护起来。

<properties>
    <maven.compiler.source>11</maven.compiler.source>
    <maven.compiler.target>11</maven.compiler.target>
    <lombok.version>1.18.24</lombok.version>
    <jjwt.version>0.9.1</jjwt.version>
    <aliyun.oss.version>3.15.1</aliyun.oss.version>
    <jaxb.version>2.3.1</jaxb.version>
    <activation.version>1.1.1</activation.version>
    <jaxb.runtime.version>2.3.3</jaxb.runtime.version>
</properties>
<dependencies>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>${lombok.version}</version>
    </dependency>
</dependencies>
<!-- 统一管理依赖版本 -->
<dependencyManagement>
    <dependencies>
        <!--JWT 令牌 -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>${jjwt.version}</version>
        </dependency>
        <!-- 阿里云 OSS-->
        <dependency>
            <groupId>com.aliyun.oss</groupId>
            <artifactId>aliyun-sdk-oss</artifactId>
            <version>${aliyun.oss.version}</version>
        </dependency>
        <dependency>
            <groupId>javax.xml.bind</groupId>
            <artifactId>jaxb-api</artifactId>
            <version>${jaxb.version}</version>
        </dependency>
        <dependency>
            <groupId>javax.activation</groupId>
            <artifactId>activation</artifactId>
            <version>${activation.version}</version>
        </dependency>
        <!-- no more than 2.3.3-->
        <dependency>
            <groupId>org.glassfish.jaxb</groupId>
            <artifactId>jaxb-runtime</artifactId>
            <version>${jaxb.runtime.version}</version>
        </dependency>
    </dependencies>
</dependencyManagement>

版本集中管理之后,我们要想修改依赖的版本,就只需要在父工程中自定义属性的位置,修改对应的属性值即可。

面试题: <dependencyManagement><dependencies> 的区别是什么?

  • <dependencies> 是直接依赖,在父工程配置了依赖,子工程会直接继承下来。
  • <dependencyManagement> 是统一管理依赖版本,不会直接依赖,还需要在子工程中引入所需依赖 (无需指定版本)

# 2.2 聚合

分模块设计与开发之后啊,我们的项目被拆分为多个模块,而模块之间的关系,可能错综复杂。 那就比如我们当前的案例项目,结构如下(相对还是比较简单的):

<img src="../../JavaWeb/day15-maven 高级 / 讲义 /assets/image-20230113142520463.png" alt="image-20230113142520463" style="zoom:67%;" />

此时,tlias-web-management 模块的父工程是 tlias-parent,该模块又依赖了 tlias-pojo、tlias-utils 模块。 那此时,我们要想将 tlias-web-management 模块打包,是比较繁琐的。因为在进行项目打包时,maven 会从本地仓库中来查找 tlias-parent 父工程,以及它所依赖的模块 tlias-pojo、tlias-utils,而本地仓库目前是没有这几个依赖的。

所以,我们再打包 tlias-web-management 模块前,需要将 tlias-parent、tlias-pojo、tlias-utils 分别执行 install 生命周期安装到 maven 的本地仓库,然后再针对于 tlias-web-management 模块执行 package 进行打包操作。

那此时,大家试想一下,如果开发一个大型项目,拆分的模块很多,模块之间的依赖关系错综复杂,那此时要进行项目的打包、安装操作,是非常繁琐的。 而我们接下来,要讲解的 maven 的聚合就是来解决这个问题的,通过 maven 的聚合就可以轻松实现项目的一键构建(清理、编译、测试、打包、安装等)。

# 2.2.1 介绍

<img src="../../JavaWeb/day15-maven 高级 / 讲义 /assets/image-20230113151533948.png" alt="image-20230113151533948" style="zoom:80%;" />

  • ** 聚合:** 将多个模块组织成一个整体,同时进行项目的构建。
  • ** 聚合工程:** 一个不具有业务功能的 “空” 工程(有且仅有一个 pom 文件) 【PS:一般来说,继承关系中的父工程与聚合关系中的聚合工程是同一个】
  • ** 作用:** 快速构建项目(无需根据依赖关系手动构建,直接在聚合工程上构建即可)

# 2.2.2 实现

在 maven 中,我们可以在聚合工程中通过 <moudules> 设置当前聚合工程所包含的子模块的名称。我们可以在 tlias-parent 中,添加如下配置,来指定当前聚合工程,需要聚合的模块:

<!--聚合其他模块-->
<modules>
    <module>../tlias-pojo</module>
    <module>../tlias-utils</module>
    <module>../tlias-web-management</module>
</modules>

那此时,我们要进行编译、打包、安装操作,就无需在每一个模块上操作了。只需要在聚合工程上,统一进行操作就可以了。

** 测试:** 执行在聚合工程 tlias-parent 中执行 package 打包指令

image-20230113153347978

那 tlias-parent 中所聚合的其他模块全部都会执行 package 指令,这就是通过聚合实现项目的一键构建(一键清理 clean、一键编译 compile、一键测试 test、一键打包 package、一键安装 install 等)。

# 2.3 继承与聚合对比

  • 作用

    • 聚合用于快速构建项目

    • 继承用于简化依赖配置、统一管理依赖

  • 相同点:

    • 聚合与继承的 pom.xml 文件打包方式均为 pom,通常将两种关系制作到同一个 pom 文件中

    • 聚合与继承均属于设计型模块,并无实际的模块内容

  • 不同点:

    • 聚合是在聚合工程中配置关系,聚合可以感知到参与聚合的模块有哪些

    • 继承是在子模块中配置关系,父模块无法感知哪些子模块继承了自己

# 3. 私服

前面我们在讲解多模块开发的时候,我们讲到我们所拆分的模块是可以在同一个公司各个项目组之间进行资源共享的。这个模块的资源共享,就需要通过我们接下来所讲解的 Maven 的私服来实现。

首先我们先介绍一下什么是私服,以及它的作用是什么。再来介绍一下我们如何将每位模块打包上传到私服,以及从私服当中来下载。

# 3.1 场景

在介绍什么是私服之前,我们先来分析一下同一个公司,两个项目组之间如何基于私服进行资源的共享。

假设现在有两个团队,A 和 B。 A 开发了一个模块 tlias-utils,模块开发完毕之后,将模块打成 jar 包,并安装到了 A 的本地仓库。

<img src="../../JavaWeb/day15-maven 高级 / 讲义 /assets/image-20230113155325805.png" alt="image-20230113155325805" style="zoom:80%;" />

那此时,该公司的 B 团队开发项目时,要想使用 tlias-utils 中提供的工具类,该怎么办呢? 对于 maven 项目来说,是不是在 pom.xml 文件中引入 tlias-utils 的坐标就可以了呢?

<img src="../../JavaWeb/day15-maven 高级 / 讲义 /assets/image-20230113155657972.png" alt="image-20230113155657972" style="zoom:80%;" />

大家可以思考一下,当 B 团队在 maven 项目的 pom.xml 配置文件中引入了依赖的坐标之后,maven 是如何查找这个依赖的? 查找顺序为:

1). 本地仓库:本地仓库中是没有这个依赖 jar 包的。

2). 远程中央仓库:由于该模块时自己公司开发的,远程仓库中也没有这个依赖。

因为目前 tlias-utils 这个依赖,还在 A 的本地仓库中的。 B 电脑上的 maven 项目,是不可能找得到 A 电脑上 maven 本地仓库的 jar 包的。 那此时,大家可能会有一个想法:因为 A 和 B 都会连接中央仓库,我们可以将 A 本地仓库的 jar 包,直接上传到中央仓库,然后 B 从中央仓库中下载 tlias-utils 这个依赖。

<img src="../../JavaWeb/day15-maven 高级 / 讲义 /assets/image-20230113160351850.png" alt="image-20230113160351850" style="zoom:67%;" />

这个想法很美好,但是现实很残酷。这个方案是行不通的,因为中央仓库全球只有一个,不是什么人都可以往中央仓库中来上传 jar 包的,我们是没有权限操作的。

那此时,maven 的私服就出场了,私服其实就是架设在公司局域网内部的一台服务器,就是一种特殊的远程仓库。

有了私服之后,各个团队就可以直接来连接私服了。 A 连接上私服之后,他就可以把 jar 包直接上传到私服当中。我公司自己内部搭建的服务器,我是不是有权限操作呀,把 jar 包上传到私服之后,我让 B 团队的所有开发人员也连接同一台私服。连接上这一台私服之后,他就会根据坐标的信息,直接从私服当中将对应的 jar 包下载到自己的本地仓库,这样就可以使用到依赖当中所提供的一些工具类了。这样我们就可以通过私服来完成资源的共享。

<img src="../../JavaWeb/day15-maven 高级 / 讲义 /assets/image-20230113160713806.png" alt="image-20230113160713806" style="zoom:80%;" />

而如果我们在项目中需要使用其他第三方提供的依赖,如果本地仓库没有,也会自动连接私服下载,如果私服没有,私服此时会自动连接中央仓库,去中央仓库中下载依赖,然后将下载的依赖存储在私服仓库及本地仓库中。

# 3.2 介绍

  • ** 私服:** 是一种特殊的远程仓库,它是架设在局域网内的仓库服务,用来代理位于外部的中央仓库,用于解决团队内部的资源共享与资源同步问题。
  • 依赖查找顺序:
    • 本地仓库
    • 私服仓库
    • 中央仓库
  • ** 注意事项:** 私服在企业项目开发中,一个项目 / 公司,只需要一台即可(无需我们自己搭建,会使用即可)。

image-20230113161116701

# 3.3 资源上传与下载

# 3.3.1 步骤分析

<img src="../../JavaWeb/day15-maven 高级 / 讲义 /assets/image-20230113163307239.png" alt="image-20230113163307239" style="zoom:80%;" />

资源上传与下载,我们需要做三步配置,执行一条指令。

第一步配置:在 maven 的配置文件中配置访问私服的用户名、密码。

第二步配置:在 maven 的配置文件中配置连接私服的地址 (url 地址)。

第三步配置:在项目的 pom.xml 文件中配置上传资源的位置 (url 地址)。

配置好了上述三步之后,要上传资源到私服仓库,就执行执行 maven 生命周期:deploy。

私服仓库说明:

  • RELEASE:存储自己开发的 RELEASE 发布版本的资源。
  • SNAPSHOT:存储自己开发的 SNAPSHOT 发布版本的资源。
  • Central:存储的是从中央仓库下载下来的依赖。

项目版本说明:

  • RELEASE (发布版本):功能趋于稳定、当前更新停止,可以用于发行的版本,存储在私服中的 RELEASE 仓库中。
  • SNAPSHOT (快照版本):功能不稳定、尚处于开发中的版本,即快照版本,存储在私服的 SNAPSHOT 仓库中。

# 3.3.2 具体操作

为了模拟企业开发,这里我准备好了一台服务器(192.168.150.101),私服已经搭建好了,我们可以访问私服测试:http://192.168.150.101:8081

<img src="../../JavaWeb/day15-maven 高级 / 讲义 /assets/image-20230113164217830.png" alt="image-20230113164217830" style="zoom:80%;" />

私服准备好了之后,我们要做如下几步配置:

1. 设置私服的访问用户名 / 密码(在自己 maven 安装目录下的 conf/settings.xml 中的 servers 中配置)

<server>
    <id>maven-releases</id>
    <username>admin</username>
    <password>admin</password>
</server>
    
<server>
    <id>maven-snapshots</id>
    <username>admin</username>
    <password>admin</password>
</server>

2. 设置私服依赖下载的仓库组地址(在自己 maven 安装目录下的 conf/settings.xml 中的 mirrors、profiles 中配置)

<mirror>
    <id>maven-public</id>
    <mirrorOf>*</mirrorOf>
    <url>http://192.168.150.101:8081/repository/maven-public/</url>
</mirror>
<profile>
    <id>allow-snapshots</id>
        <activation>
        	<activeByDefault>true</activeByDefault>
        </activation>
    <repositories>
        <repository>
            <id>maven-public</id>
            <url>http://192.168.150.101:8081/repository/maven-public/</url>
            <releases>
            	<enabled>true</enabled>
            </releases>
            <snapshots>
            	<enabled>true</enabled>
            </snapshots>
        </repository>
    </repositories>
</profile>

3.IDEA 的 maven 工程的 pom 文件中配置上传(发布)地址 (直接在 tlias-parent 中配置发布地址)

<distributionManagement>
    <!-- release 版本的发布地址 -->
    <repository>
        <id>maven-releases</id>
        <url>http://192.168.150.101:8081/repository/maven-releases/</url>
    </repository>
    <!-- snapshot 版本的发布地址 -->
    <snapshotRepository>
        <id>maven-snapshots</id>
        <url>http://192.168.150.101:8081/repository/maven-snapshots/</url>
    </snapshotRepository>
</distributionManagement>

配置完成之后,我们就可以在 tlias-parent 中执行 deploy 生命周期,将项目发布到私服仓库中。

image-20230113164850129

通过日志,我们可以看到,这几个模块打的 jar 包确实已经上传到了私服仓库中(由于当前我们的项目是 SNAPSHOT 版本,所以 jar 包是上传到了 snapshot 仓库中)。

那接下来,我们再来打开私服来看一下:

image-20230113215053410

我们看到,我们项目中的这几个模块,在私服中都有了。 那接下来,当其他项目组的开发人员在项目中,就可以直接通过依赖的坐标,就可以完成引入对应的依赖,此时本地仓库没有,就会自动从私服仓库中下载。

备注说明:

  • 课上演示的时候,为了模拟真实的线上环境,老师使用了一台服务器 192.168.150.101,并在服务器上安装了 maven 的私服。 而这台服务器大家并不能直接访问。

  • 同学们如果要测试使用私服进行资源的上传和下载。可以参照如下步骤,启动给大家准备的本地私服操作:

    • 解压: 资料中提供的压缩包 apache-maven-nexus.zip

    • 进入目录: apache-maven-nexus\nexus-3.39.0-01\bin

    • 启动服务:双击 start.bat

    • 访问服务:localhost:8081

    • 私服配置说明:将上述配置私服信息的 192.168.150.101 改为 localhost