Web后端基础-Spring分层解耦

  • 前面讲解Web前端开发的时候,我们学习了前端网页开发的三剑客HTML、CSS、JS,通过这三项技术,我们就可以制作前端页面了。 那最终,这些个页面资料,我们就可以部署在服务器上,然后打开浏览器就可以直接访问服务器上部署的前端页面了。
  • 而像HTML、CSS、JS 以及图片、音频、视频等这些资源,我们都称为静态资源。 所谓静态资源,就是指在服务器上存储的不会改变的数据,通常不会根据用户的请求而变化。
  • 那与静态资源对应的还有一类资源,就是动态资源。那所谓动态资源,就是指在服务器端上存储的,会根据用户请求和其他数据动态生成的,内容可能会在每次请求时都发生变化。比如:Servlet、JSP等(负责逻辑处理)。而Servlet、JSP这些技术现在早都被企业淘汰了,现在在企业项目开发中,都是直接基于Spring框架来构建动态资源。
  • 而对于我们java程序开发的动态资源来说,我们通常会将这些动态资源部署在Tomcat,这样的Web服务器中运行。 而浏览器与服务器在通信的时候,基本都是基于HTTP协议的。

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

  • BS架构:Browser/Server,浏览器/服务器架构模式。客户端只需要浏览器,应用程序的逻辑和数据都存储在服务端。
    • 优点:维护方便
    • 缺点:体验一般
  • CS架构:Client/Server,客户端/服务器架构模式。需要单独开发维护客户端。
    • 优点:体验不错
    • 缺点:开发维护麻烦

入门程序

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

  • 新建SpringBoot项目

  • 代码:在项目的包内新建立controller包,并编写HelloController

java 复制代码
@RestController //标识当前是一个请求处理类
public class HelloController {

    @RequestMapping("/hello")
    public String hello(String name){
        System.out.println("Hello Controller ... hello : " + name);
        return "Hello " + name + "~";
    }
  • 项目启动后在浏览器输入地址,并在地址上附带上参数即可看到返回页面的内容

入门解析

  • 完成了SpringBootWeb的入门程序,并且测试通过。 在入门程序中,只需要一个main方法就可以将web应用启动起来了,然后就可以打开浏览器访问了。那接下来我们需要明确两个问题:

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

      因为在创建springboot项目的时候,选择了web开发的起步依赖 spring-boot-starter-web。而spring-boot-starter-web依赖,又依赖了spring-boot-starter-tomcat,由于maven的依赖传递特性,那么在我们创建的springboot项目中也就已经有了tomcat的依赖,这个其实就是springboot中内嵌的tomcat。 这个tomcat里面是有线程池的,他在不断轮询等待请求打入并放入Serlet容器给Controller层,进入SpringBoot中,TomCat的运行逻辑大致入下方时序图所示:

    sequenceDiagram autonumber actor Browser as 浏览器 box LightBlue Tomcat 服务器层 (Web 容器) participant Connector as Connector<br/>(连接器) participant Executor as Executor<br/>(线程池) participant Container as Container<br/>(路由与 Servlet 容器) end box LightGreen 后端应用层 participant Servlet as DispatcherServlet<br/>(业务逻辑入口) end %% ---- 1. 网络接入与线程分配 ---- Browser->>Connector: 发送 HTTP 请求 (建立 TCP 连接) Connector->>Executor: 接入连接,申请分派工作线程 Executor->>Container: 线程接管,进入容器链路 (Engine->Host->Context->Wrapper) %% ---- 2. 路由分发与业务黑盒 ---- Container->>Servlet: Wrapper 匹配成功,调用 Servlet 规范的 service() 方法 Note over Servlet: Controller接受对应Servlet容器,执行后续所有业务逻辑、数据库查询等 Servlet-->>Container: 业务处理完毕,响应数据已写入 Response 对象 %% ---- 3. 网络响应与归还线程 ---- Container-->>Executor: 容器内部处理完毕,交还控制流给工作线程 Executor-->>Connector: 找到原 TCP 连接对应的 Socket Note over Connector: 将数据包装、翻译为 HTTP 响应报文 Connector-->>Browser: 吐回数据 (完成 HTTP 响应)

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

HTTP协议

HTTP概述

介绍

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

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

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

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

特点

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

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

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

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

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

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

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

  • 缺点: 多次请求间不能共享数据

  • 优点: 速度快

  • 请求之间无法共享数据会引发的问题:

    • 如:京东购物。加入购物车和去购物车结算是两次请求

    • 由于HTTP协议的无状态特性,加入购物车请求响应结束后,并未记录加入购物车是何商品

    • 发起去购物车结算的请求后,因为无法获取哪些商品加入了购物车,会导致此次请求无法正确展示数据

  • 具体使用的时候,我们发现京东是可以正常展示数据的,原因是Java早已考虑到这个问题,使用的是会话技术(Cookie、Session)来解决这个问题。

刚才提到HTTP协议是规定了请求和响应数据的格式,那具体的格式是什么呢? HTTP协议又分为:请求协议和响应协议

HTTP请求协议

介绍

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

  • GET方式的请求协议:

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

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

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

    • 常见的HTTP请求头有:

      请求头 含义
      Host 表示请求的主机名
      User-Agent 浏览器版本。 例如:Chrome浏览器的标识类似Mozilla/5.0 ...Chrome/79 ,IE浏览器的标识类似Mozilla/5.0 (Windows NT ...)like Gecko
      Accept 表示浏览器能接收的资源类型,如text/image/*或者*/*表示所有;
      Accept-Language 表示浏览器偏好的语言,服务器可以据此返回不同语言的网页;
      Accept-Encoding 表示浏览器可以支持的压缩类型,例如gzip, deflate等。
      Content-Type 请求主体的数据类型
      Content-Length 数据主体的大小(单位:字节)

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

    比如:

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

    • GET请求的请求参数在请求行中,故不需要设置请求体
  • POST方式的请求协议:

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

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

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

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

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

获取请求数据

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

  • 实际上即使是在SpringBoot中也是直接可以通过注解@RequestController来获取请求数据的对象,并解析这个对象,代码演示如下:

    java 复制代码
    @RestController //标识当前是一个请求处理类
    public class HelloController {
        @RequestMapping("/request")
        public String request(HttpServletRequest request){
            //1.获取请求参数 name, age
            String name = request.getParameter("name");
            String age = request.getParameter("age");
            System.out.println("name = " + name + ", age = " + age);
    
            //2.获取请求路径
            String uri = request.getRequestURI();
            String url = request.getRequestURL().toString();
            System.out.println("uri = " + uri);
            System.out.println("url = " + url);
    
            //3.获取请求方式
            String method = request.getMethod();
            System.out.println("method = " + method);
    
            //4.获取请求头
            String header = request.getHeader("User-Agent");
            System.out.println("header = " + header);
            return "request success";
        }
    }

HTTP响应协议

格式介绍

  • 响应协议:服务器将数据以响应格式返回给浏览器。包括:响应行 、响应头 、响应体
  • 响应行(以上图中红色部分):响应数据的第一行。响应行由协议及版本响应状态码状态码描述组成

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

    • http是个无状态的协议,所以可以在请求头和响应头中设置一些信息和想要执行的动作,这样,对方在收到信息后,就可以知道你是谁,你想干什么
    • 常见的HTTP响应头有:
    css 复制代码
    Content-Type:表示该响应内容的类型,例如text/html,image/jpeg ;
    
    Content-Length:表示该响应内容的长度(字节数);
    
    Content-Encoding:表示该响应压缩算法,例如gzip ;
    
    Cache-Control:指示客户端应如何缓存,例如max-age=300表示可以最多缓存300秒 ;
    
    Set-Cookie: 告诉浏览器为当前页面所在的域设置cookie ;
  • 响应体(以上图中绿色部分): 响应数据的最后一部分。存储响应的数据

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

响应状态码

状态码分类 说明
1xx 响应中 --- 临时状态码。表示请求已经接受,告诉客户端应该继续请求或者如果已经完成则忽略
2xx 成功 --- 表示请求已经被成功接收,处理已完成
3xx 重定向 --- 重定向到其它地方,让客户端再发起一个请求以完成整个处理
4xx 客户端错误 --- 处理发生错误,责任在客户端,如:客户端的请求一个不存在的资源,客户端未被授权,禁止访问等
5xx 服务器端错误 --- 处理发生错误,责任在服务端,如:服务端抛出异常,路由出错,HTTP版本不支持等

设置响应数据

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

    java 复制代码
    @RestController //标识当前是一个请求处理类
    public class HelloController {
        @RequestMapping("/response")
        public void response(HttpServletResponse response) throws IOException {
            //1.设置响应状态码
            response.setStatus(401);
            //2.设置响应头
            response.setHeader("name","itcast");
            //3.设置响应体
            response.setContentType("text/html;charset=utf-8");
            response.setCharacterEncoding("utf-8");
            response.getWriter().write("<h1>hello response</h1>");
        }
    
        @RequestMapping("/response2")
        public ResponseEntity<String> response2(HttpServletResponse response) throws IOException {
            return ResponseEntity
                    .status(401)
                    .header("name","itcast")
                    .body("<h1>hello response</h1>");
       }
    }
  • 这种直接操作响应对象的方式在实际业务中比较少用,这里直接设置了状态码,浏览器无论访问是否成功,都会返回401错误码,这种写法只有在拦截一些非法请求时才会这么做

    • 有响应体
    • 返回相应失败的状态码

@RequestMapping是怎么后台接受不同参数的

  • Spring 的@RequestMapping能够"要什么给什么",靠的是 Java 反射 + 策略模式 ,具体流程为:
    • 反射扫描 :Spring 启动并拦截到请求后,利用反射机制查看你方法括号里写了什么参数类型(和注解)。
    • 工人领任务(策略模式) :Spring 内部有一群实现了 HandlerMethodArgumentResolver(参数解析器)接口的"工人"。主管拿着你的参数挨个问,对应的工人会主动站出来认领
    • 调用原生 API :干活的工人转头就会去调用 request.getParameter()request.getHeader()原生 API 把数据拔出来。
  • Spring 帮我们做的"脏活累活"
    • 自动拆包 :你将接受参数写为 String name,它就从httpServletRequest对象中只帮你挑出 name
    • 类型转换 :把原生的 String 字符串默默转成你需要的 intBoolean 等类型。
    • 对象封装 :你写一个实体类(如 User user),它就从httpServletRequest对象中自动把多条数据组装成对象。
    • JSON 解析 :配合 @RequestBody,自动把前端发来的 JSON 文本反序列化为 Java 对象。

SpringBootWeb案例

  • 用这个案例来说明什么是三层架构,什么是解耦解耦,为啥要解耦,如何解耦
  • 需求:基于SpringBoot开发web程序,完成用户列表的渲染展示

当在浏览器地址栏,访问前端静态页面(http://localhost:8080/usre.html) 后,在前端页面上,会发送ajax请求,请求服务端(http://localhost:8080/list),服务端程序加载 user.txt 文件中的数据,读取出来后最终给前端页面响应json格式的数据,前端页面再将数据渲染展示在表格中。(因为还没有学数据库所以现用文本文件和IO操作来模仿数据库操作)

代码实现

1). 准备工作:再创建一个SpringBoot工程,并勾选web依赖、lombok依赖。 2). 准备工作:引入资料中准备好的数据文件user.txt,以及static下的前端静态页面

  • 定义一个包 pojo,专门用来存放实体类。 在该包下定义一个实体类User:
java 复制代码
package org.example.pojo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class User {

    private Integer id;
    private String username;
    private String password;
    private String name;
    private String age;
    private LocalDateTime updateTime;

}

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

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

  • pom.xml中引入依赖
xml 复制代码
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.27</version>
</dependency>
  • 新建一个子包controller,在其中创建一个UserController
java 复制代码
package org.example.controller;

import cn.hutool.core.io.IoUtil;
import org.example.pojo.User;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;

@RestController
public class UserController {
    /*
    * 处理前端ajax请求获取用户列表json字符串数据
    *   前端请求:http://localhost:8080/list
    *   后端响应:json字符串数据'[{id:xx,name:xx,age:xx,updateTime:xx},{id:xx,name:xx,age:xx,updateTime:xx},...]'的json数组给前端JavaScript解析
    *   spring会将controller方法返回的值List<User>自动转换为json字符串返回
    * */
    @RequestMapping ("/list")
    public List<User> list() throws IOException {
        List<User> userList = new ArrayList<>();
        // 1. 读取user.txt文件,获取用户列表数据封装到一个List<String>,文件中每一行封装成一个Sring
        try(
            /*
            * 获取输入流
            *   UserController.class.getClassLoader().getResourceAsStream()用于获取类路径下的资源文件
            *       UserController.class ------ 获取字节码对象
            *       .getClassLoader() ------ 顺藤摸瓜找"加载器",通过找到的类加载器的位置找到当前类所在的磁盘路径
            *       .getResourceAsStream(...) ------ 让加载器去这个位置读文件并获取到Stream流对象
            * Web工程静态资源部署路径
            *   父工程类路径:target/classes
            *   写代码时存放在src/main/resources下,打包后会自动拷贝部到target/classes下
            *   所以这里我们的路径只需要填写user.txt即可
            *
            * */
            
            InputStream in = this.getClass().getClassLoader().getResourceAsStream("user.txt");
        ) {
            // 2. 遍历文本转换的Stream对象中的8个String,将每个String转换为User对象封装到List<User>中
            ArrayList<String> userStrList = IoUtil.readUtf8Lines(in, new ArrayList<String>());
            for (String str : userStrList) {
                String[] split = str.split(",");
                User user = new User(
                        Integer.parseInt(split[0])
                        , split[1]
                        , split[2]
                        , split[3]
                        , split[4]
                        , LocalDateTime.parse(split[5], DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
                );
                userList.add(user);
            }
        }
        return userList;
    }
}

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

@ResponseBody

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

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

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

答案:使用@ResponseBody注解

@ResponseBody注解:

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

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

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

可以看到@Controller和ResponseBody都是RestController的元注解(类似于继承)

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

问题分析

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

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

    • 当我们要修改操作数据部分的代码,需要改动Controller
    • 当我们要完善逻辑处理部分的代码,需要改动Controller
    • 当我们需要修改数据响应的代码,还是需要改动Controller
  • 这就会造成我们整个工程代码的复用性比较差,而且代码难以维护和拓展。 那如何解决这个问题呢?其实在现在的开发中,有非常成熟的解决思路,那就是分层开发。

分层解耦

三层架构

介绍

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

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

  • 之前开发的程序,并不满足单一职责原则。下面我们来分析下之前的程序:
  • 那其实上述案例的处理逻辑呢,从组成上看可以分为三个部分:
    • 数据访问:负责业务数据的维护操作,包括增、删、改、查等操作。
    • 逻辑处理:负责业务逻辑处理的代码。
    • 请求处理、响应数据:负责,接收页面的请求,给页面响应数据。
  • 按照上述的三个组成部分,在我们项目开发中呢,可以将代码分为三层。
    • Controller:控制层。接收前端发送的请求,对请求进行处理,并响应数据。
    • Service:业务逻辑层。处理具体的业务逻辑。
    • Dao:数据访问层(Data Access Object),也称为持久层。负责数据访问操作,包括数据的增、删、改、查。
  • 基于三层架构的程序执行流程
    • 前端发起的请求,由Controller层接收(Controller响应数据给前端)
    • Controller层调用Service层来进行逻辑处理(Service层处理完后,把处理结果返回给Controller层)
    • Serivce层调用Dao层(逻辑处理过程中需要用到的一些数据要从Dao层获取)
    • Dao层操作文件中的数据(Dao拿到的数据会返回给Service层)

思考:按照三层架构的思想,如果要对业务逻辑(Service层)进行变更,会影响到Controller层和Dao层吗?

答案:不会影响。 (程序的扩展性、维护性变得更好了)

代码拆分

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

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

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

  • org.example.controller 中创建UserController类,代码如下:
java 复制代码
package org.example.controller;

import org.example.pojo.User;
import org.example.service.IUserService;
import org.example.service.impl.UserServiceImpl;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.IOException;
import java.util.List;

@RestController
public class UserController {
    private IUserService userList = new UserServiceImpl(); // 依赖注入
    @RequestMapping ("/list")
    public List<User> list() throws IOException {

        return userList.getUserList();
    }
}

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

  • org.example.service中创建UserSerivce接口,代码如下:
java 复制代码
package org.example.service;

import org.example.pojo.User;
import java.io.IOException;
import java.util.List;

public interface IUserService {
    List<User> getUserList() throws IOException;
}
  • org.example.service.impl 中创建UserSerivceImpl实现类,代码如下:
java 复制代码
package org.example.service.impl;

import org.example.dao.IUserDao;
import org.example.dao.impl.UserDaoImpl;
import org.example.pojo.User;
import org.example.service.IUserService;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;

public class UserServiceImpl implements IUserService {

    private IUserDao userDao = new UserDaoImpl();

    @Override
    public List<User> getUserList() throws IOException {
        List<String> userStrList = userDao.getData();// 调用
        List<User> userList = new ArrayList<>();
        for (String str : userStrList) {
            String[] split = str.split(",");
            User user = new User(
                    Integer.parseInt(split[0])
                    , split[1]
                    , split[2]
                    , split[3]
                    , split[4]
                    , LocalDateTime.parse(split[5], DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
            );
            userList.add(user);
        }

        return userList;
    }
}

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

  • org.example.dao中创建UserDao接口,代码如下:
java 复制代码
package org.example.dao;

import java.io.IOException;
import java.util.List;

public interface IUserDao {
    List<String> getData() throws IOException;
}
  • org.example.dao.impl 中创建UserDaoImpl接口,代码如下:
java 复制代码
package org.example.dao.impl;

import cn.hutool.core.io.IoUtil;
import org.example.dao.IUserDao;
import org.example.pojo.User;

import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;

public class UserDaoImpl implements IUserDao {
    @Override
    public List<String> getData() throws IOException {
        List<User> userList = new ArrayList<>();
        try(
            InputStream in = this.getClass().getClassLoader().getResourceAsStream("user.txt");
        ) {
           return IoUtil.readUtf8Lines(in, new ArrayList<String>());
        }
    }
}
  • 具体的请求调用流程:

三层架构的好处:

1.复用性强

2.便于维护

3.利用扩展

分层解耦

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

  • 如果说我们需要更换实现类,比如由于业务的变更,UserServiceImpl 不能满足现有的业务需求,我们需要切换为 UserServiceImpl2 这套实现,就需要修改Contorller的代码,需要创建 UserServiceImpl2 的实现new UserServiceImpl2() Service中调用Dao,也是类似的问题。这种呢,我们就称之为层与层之间 耦合 了。 那什么是耦合呢 ?

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

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

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

高内聚: 指的是一个模块中各个元素之间的联系的紧密程度,如果各个元素(语句、程序段)之间的联系程度越高,则内聚性越高,即 "高内聚"。

低耦合: 指的是软件中各个层、模块之间的依赖关联程序越低越好。 目前层与层之间是存在耦合的,Controller耦合了Service、Service耦合了Dao。而 高内聚、低耦合的目的是使程序模块的可重用性、移植性大大增强。

目前层与层之间是存在耦合的,Controller耦合了Service、Service耦合了Dao。而高内聚、低耦合的目的是使程序模块的可重用性、移植性大大增强。 那最终我们的目标,就是做到层与层之间,尽可能的降低耦合,甚至解除耦合。

解耦思路

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

那应该怎么解耦呢

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

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

我们的解决思路是:

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

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

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

那问题来了,我们如何将对象交给容器管理呢? 程序运行时,容器如何为程序提供依赖的对象呢? 我们想要实现上述解耦操作,就涉及到Spring中的两个核心概念:

  • 控制反转: Inversion Of Control,简称IOC 。对象的创建控制权由程序自身转移到外部(容器),这种思想称为控制反转。
    • 对象的创建权由程序员主动创建转移到容器(由容器创建、管理对象)。这个容器称为:IOC容器或Spring容
  • 依赖注入: Dependency Injection,简称DI 。容器为应用程序提供运行时,所依赖的资源,称之为依赖注入。
    • 程序运行时需要某个资源,此时容器就为其提供这个资源。
    • 例:EmpController程序运行时需要EmpService对象,Spring容器就为其提供并注入EmpService对象。
  • bean对象: IOC容器中创建、管理的对象,称之为:bean对象。

IOC&DI入门

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

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

A. UserDaoImpl

java 复制代码
package org.example.dao.impl;

import cn.hutool.core.io.IoUtil;
import org.example.dao.IUserDao;
import org.example.pojo.User;
import org.springframework.stereotype.Repository;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;

@Component // 让spring框架创建当前类对象,并放入spring容器内存空间中
public class UserDaoImpl implements IUserDao {
    @Override
    public List<String> getData() throws IOException {
        List<User> userList = new ArrayList<>();
        try(
            InputStream in = this.getClass().getClassLoader().getResourceAsStream("user.txt");
        ) {
           return IoUtil.readUtf8Lines(in, new ArrayList<String>());
        }
    }
}

B. UserServiceImpl

java 复制代码
package org.example.service.impl;

import org.example.dao.IUserDao;
import org.example.pojo.User;
import org.example.service.IUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;

@Component
public class UserServiceImpl implements IUserService {
    @Autowired
    private IUserDao userDao;

    @Override
    public List<User> getUserList() throws IOException {
        List<String> userStrList = userDao.getData();// 调用
        List<User> userList = new ArrayList<>();
        for (String str : userStrList) {
            String[] split = str.split(",");
            User user = new User(
                    Integer.parseInt(split[0])
                    , split[1]
                    , split[2]
                    , split[3]
                    , split[4]
                    , LocalDateTime.parse(split[5], DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
            );
            userList.add(user);
        }

        return userList;
    }
}

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

A. UserServiceImpl

java 复制代码
import org.example.pojo.User;
import org.example.service.IUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;

@Component
public class UserServiceImpl implements IUserService {
    @Autowired
    private IUserDao userDao;

    @Override
    public List<User> getUserList() throws IOException {
        List<String> userStrList = userDao.getData();// 调用
        List<User> userList = new ArrayList<>();
        for (String str : userStrList) {
            String[] split = str.split(",");
            User user = new User(
                    Integer.parseInt(split[0])
                    , split[1]
                    , split[2]
                    , split[3]
                    , split[4]
                    , LocalDateTime.parse(split[5], DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
            );
            userList.add(user);
        }
        return userList;
    }
}

B. UserController

java 复制代码
package org.example.controller;

import org.example.pojo.User;
import org.example.service.IUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.io.IOException;
import java.util.List;

@RestController
public class UserController {
    private IUserService userList; // 依赖注入
    @RequestMapping ("/list")
    public List<User> list() throws IOException {

        return userList.getUserList();
    }
}

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

IOC详解

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

Bean的声明

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

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

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

注解 说明 位置
@Component 声明bean的基础注解 不属于以下三类时,用此注解
@Controller @Component的衍生注解 标注在控制层类上
@Service @Component的衍生注解 标注在业务层类上
@Repository @Component的衍生注解 标注在数据访问层类上(由于与mybatis整合,用的少)
  • 这些衍生注解都是将@Component作为了元注解,固都有@Component的功能

Service层:

java 复制代码
package org.example.service.impl;

import org.example.dao.IUserDao;
import org.example.pojo.User;
import org.example.service.IUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;

// @Component
@Service // @Component衍生注解,具有语义化,专门给Service层使用的Ioc注解
public class UserServiceImpl implements IUserService {
    @Autowired
    private IUserDao userDao;

    @Override
    public List<User> getUserList() throws IOException {
        List<String> userStrList = userDao.getData();// 调用
        List<User> userList = new ArrayList<>();
        for (String str : userStrList) {
            String[] split = str.split(",");
            User user = new User(
                    Integer.parseInt(split[0])
                    , split[1]
                    , split[2]
                    , split[3]
                    , split[4]
                    , LocalDateTime.parse(split[5], DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
            );
            userList.add(user);
        }

        return userList;
    }
}

Dao层:

java 复制代码
package org.example.dao.impl;

import cn.hutool.core.io.IoUtil;
import org.example.dao.IUserDao;
import org.example.pojo.User;
import org.springframework.stereotype.Repository;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;

// @Component // 让spring框架创建当前类对象,并放入spring容器内存空间中
@Repository // @Component衍生注解,具有语义化,专门给Dao层使用的Ioc注解
public class UserDaoImpl implements IUserDao {
    @Override
    public List<String> getData() throws IOException {
        List<User> userList = new ArrayList<>();
        try(
            InputStream in = this.getClass().getClassLoader().getResourceAsStream("user.txt");
        ) {
           return IoUtil.readUtf8Lines(in, new ArrayList<String>());
        }
    }
}

注意1:声明bean的时候,可以通过注解的value属性指定bean的名字,如果没有指定,默认为类名首字母小写。

注意2:使用以上四个注解都可以声明bean,但是在springboot集成web开发中,声明控制器bean只能用@Controller。

组件扫描

问题:使用前面学习的四个注解声明的bean,一定会生效吗?

答案:不一定。(原因:bean想要生效,还需要被组件扫描)

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

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

但如果没扫描到可能会提示找不到bean对象,如果连对应的controller都没有被扫描到的话,会出现后端可以启动成功但返回给前端的响应状态码为404

DI详解

遇到多个Bean对象怎么办

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

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

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

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

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

那如果在IOC容器中,存在多个相同类型的bean对象,会出现什么情况呢? 在下面的例子中,我们准备了两个UserService的实现类,并且都交给了IOC容器管理。直接在Intellij IDEA中奖UserServiceImp类复制一份到同一个包中并起别名


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

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

  • @Primary
  • @Qualifier
  • @Resource

方案一:使用@Primary注解

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

java 复制代码
@Primary // 添加Primary
@Service
public class UserServiceImpl implements UserService {
}

方案二:使用@Qualifier注解

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

java 复制代码
@RestController
public class UserController {

    @Qualifier("userServiceImpl")// 指定要注入bean对应类的名称
    @Autowired
    private UserService userService;

方案三:使用@Resource注解

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

java 复制代码
@RestController
public class UserController {
        
    @Resource(name = "userServiceImpl") 
    private UserService userService;

面试题:@Autowird 与 @Resource的区别

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

在Junit测试中使用依赖注入

  • 学了Spring之后可以利用依赖注入对之前学过的Junnit测试进行升级,将测试和测试对象进行解耦

  • 如下添加@SpringBootTest 注解,就可以对Junnit测试进行升级了,要用到的注释如下方所示

    注解 作用
    @SpringBootTest 启动完整的 Spring 容器,加载所有 Bean
    @Autowired Spring 自动注入 IUserService 实例(解耦核心)
    @Test 标记方法为 JUnit 5 测试用例,通过断言验证业务逻辑
  • 对比:之前模块用 new UserServiceImpl() 手动创建对象,现在由 Spring 容器通过 DI(依赖注入)自动装配 ------ 这就是"解耦"的核心价值。

java 复制代码
package org.example;

import org.example.pojo.User;
import org.example.service.IUserService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.io.IOException;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest  // 启动 Spring Boot 应用上下文(加载 WebApplication)
class WebApplicationTests {

    /**
     * 由 Spring 容器自动注入 IUserService 的实现类对象
     * (实际注入的是 @Primary 标记的 UserServiceImpl)
     */
    @Autowired
    private IUserService userService;  // ← 依赖注入,不需要 new!

    /**
     * 测试 1:验证 Spring 上下文能正常启动,
     * 并且 userService 已被成功注入。
     */
    @Test
    @DisplayName("上下文加载 - 验证 Spring 容器启动 & Bean 注入")
    void contextLoads() {
        // 断言:userService 不为 null,证明 DI 注入成功
        assertNotNull(userService, "Spring 容器应成功注入 userService");
        System.out.println("✅ Spring 容器启动成功,userService 已注入: " + userService.getClass().getSimpleName());
    }

    /**
     * 测试 2:调用 service 层获取用户列表,验证数据读取和解析。
     *
     * <p>数据来源:{@code src/main/resources/user.txt}(8 条用户数据)
     */
    @Test
    @DisplayName("获取用户列表 - 验证 DAO 读文件 → Service 解析 → 返回 List<User>")
    void testGetUserList() throws IOException {
        // 1. 调用 service 方法
        List<User> userList = userService.getUserList();

        // 2. 断言结果不为 null
        assertNotNull(userList, "用户列表不应为 null");

        // 3. 断言有 8 条数据
        assertEquals(8, userList.size(), "user.txt 中有 8 条用户数据");

        // 4. 打印查看实际数据
        System.out.println("========== 用户列表(共 " + userList.size() + " 条)==========");
        userList.forEach(user -> System.out.println("  " + user));
    }

    /**
     * 测试 3:验证第一条用户数据的各个字段解析正确。
     *
     * <p>user.txt 第 1 行:{1,daqiao,1234567890,大乔,22,2024-07-15 15:05:45}
     */
    @Test
    @DisplayName("字段解析 - 验证第一条数据的每个字段正确解析")
    void testFirstUserFields() throws IOException {
        List<User> userList = userService.getUserList();
        User firstUser = userList.get(0);

        // 逐一验证每条字段
        assertEquals(1, firstUser.getId(), "ID 应为 1");
        assertEquals("daqiao", firstUser.getUsername(), "用户名应为 daqiao");
        assertEquals("1234567890", firstUser.getPassword(), "密码应为 1234567890");
        assertEquals("大乔", firstUser.getName(), "姓名应为 大乔");
        assertEquals("22", firstUser.getAge(), "年龄应为 22");

        System.out.println("✅ 第一条用户数据字段解析正确: " + firstUser);
    }

    /**
     * 测试 4:验证空/边界情况 ------ 列表不为空。
     */
    @Test
    @DisplayName("边界验证 - 确保用户列表非空")
    void testUserListNotEmpty() throws IOException {
        List<User> userList = userService.getUserList();

        assertFalse(userList.isEmpty(), "用户列表不应为空");
        assertTrue(userList.size() > 0, "至少应有 1 条用户数据");
        System.out.println("✅ 用户列表非空,共 " + userList.size() + " 条数据");
    }
}
  • 测试全部通过,可以看到测试的同时也启动了Spring,说明我们的@SpringBootTest 注解起作用了

三种依赖注入的方式

属性注入
  • 就是我们之前介绍的注入方式,是最常见、最简洁 的写法,直接在字段上加 @Autowired
java 复制代码
@RestController
public class UserController {

    @Autowired
    private IUserService userService;  // Spring 自动注入

    @RequestMapping("/list")
    public List<User> list() throws IOException {
        return userService.getUserList();
    }
}
  • 测试中的属性注入
java 复制代码
@SpringBootTest
class WebApplicationTests {

    @Autowired
    private IUserService userService;  // 字段注入

    @Test
    void testGetUserList() throws IOException {
        List<User> list = userService.getUserList();
        assertEquals(8, list.size());
    }
}
  • 多实现类时的解决方案 当接口有多个实现类(如 UserServiceImplUserServiceImpl2),Spring 不知道注入哪个,有三种解决方式:

    方案 注解 说明
    方案1 @Primary 在其中一个实现类上标记,优先注入
    方案2 @Autowired + @Qualifier("beanName") 指定具体 Bean 名字
    方案3 @Resource(name = "beanName") Jakarta EE 注解,一个注解搞定方案
构造函数注入
  • 官方推荐方式 。字段声明为 final,通过构造函数传入,对象创建后不可变。(防止在多线程环境下Bean对象被修改,导致线程不安全)

    • 方式 A:手动写构造函数 + @Autowired
    java 复制代码
    @RestController
    public class UserController {
    
        private final IUserService userService;  // ← final,不可变
    
        @Autowired  // ← Spring 4.3+ 可省略(仅一个构造函数时)
        public UserController(IUserService userService) {
            this.userService = userService;
        }
    
        @RequestMapping("/list")
        public List<User> list() throws IOException {
            return userService.getUserList();
        }
    }
    • 方式 B:Lombok @RequiredArgsConstructor 简写

    一个注解替代整个构造函数!Lombok 自动为所有 final 字段生成构造函数:

    java 复制代码
    @RestController
    @RequiredArgsConstructor  // ← 一个注解搞定!自动生成构造函数
    public class UserController {
    
        private final IUserService userService;  // ← final 字段
    
        @RequestMapping("/list")
        public List<User> list() throws IOException {
            return userService.getUserList();
        }
    }

    原理 :Lombok 在编译期 自动生成等价于方式 A 的构造函数。 Spring 发现只有一个构造函数时,自动用它注入,连 @Autowired 都可以省略

  • 测试中的构造函数注入

测试类特殊性 :JUnit 5 负责创建测试实例,不认识 Spring 的构造函数参数。 必须额外加 @TestConstructor(autowireMode = AutowireMode.ALL) 告知 Spring 接管构造。 普通 Bean 不需要此注解!

java 复制代码
@SpringBootTest
@TestConstructor(autowireMode = AutowireMode.ALL)  // ← JUnit 测试专属
class ConstructorInjectionTest {

    private final IUserService userService;

    // @Autowired  // 只有一个构造函数时可省略
    public ConstructorInjectionTest(IUserService userService) {
        this.userService = userService;
    }

    @Test
    void testGetUserList() throws IOException {
        assertNotNull(userService);
        assertEquals(8, userService.getUserList().size());
    }
}
css 复制代码
- @RequiredArgsConstructor 简写版测试
java 复制代码
@SpringBootTest
@TestConstructor(autowireMode = AutowireMode.ALL)  // ← JUnit 测试专属
@RequiredArgsConstructor  // Lombok 自动生成构造函数
class ConstructorLombokTest {

    private final IUserService userService;

    @Test
    void testGetUserList() throws IOException {
        assertNotNull(userService);
        assertEquals(8, userService.getUserList().size());
    }
}
Setter 注入
  • 通过 setter 方法注入,字段可以不是 final,可以在运行时动态更换依赖。
java 复制代码
@RestController
public class UserController {

    private IUserService userService;  // ← 非 final

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

    @RequestMapping("/list")
    public List<User> list() throws IOException {
        return userService.getUserList();
    }
}
  • 测试中的 Setter 注入
java 复制代码
@SpringBootTest
class SetterInjectionTest {

    private IUserService userService;

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

    @Test
    void testGetUserList() throws IOException {
        assertNotNull(userService);
        assertEquals(8, userService.getUserList().size());
    }
三种方式对比总表
维度 属性注入 构造函数注入 Setter 注入
注解 @Autowired 在字段上 @Autowired 在构造函数上 @Autowired 在 setter 上
Lombok 简写 ❌ 无 @RequiredArgsConstructor ❌ 无
字段可变性 可修改 final 不可变 可修改
依赖明确度 ⭐⭐ 隐藏 ⭐⭐⭐ 构造函数直接展示 ⭐⭐ 需要看 setter
Spring 官方推荐 ❌ 不推荐 推荐 ⚠️ 可选依赖时用
单元测试友好度 需要反射/Mockito ✅ new 即可传参 ✅ set 即可传参
代码量 最少 中等(Lombok 后最少) 较多
循环依赖检测 启动后才能发现 ✅ 启动时立即发现 启动后才能发现
相关推荐
java1234_小锋1 小时前
Spring Boot 的核心注解 @SpringBootApplication 由哪三个注解组成?
java·spring boot·后端
SimonKing1 小时前
别再自己写脚本了!DeepSeek三秒生成,豆包直接出片
java·后端·程序员
铁皮饭盒1 小时前
Bun + SQLite 10个实用技巧
前端·javascript·后端
飞天狗1112 小时前
零基础JavaWeb入门——第4课:表单处理 —— 浏览器怎么把数据发给服务器
java·开发语言·前端·后端·servlet
Qres8212 小时前
nodejs安装记录
后端·nodejs
IT_陈寒2 小时前
Vue的响应式让我原地裂开,你们也有这情况吗
前端·人工智能·后端
techdashen2 小时前
用 Rust 真正发出 Ping:FFI 类型、newtype 与 MaybeUninit
开发语言·后端·rust
Boop_wu2 小时前
[Spring Cloud] 快速上手nacos
后端·spring·spring cloud
GZ_TOGOGO2 小时前
Spring AI Alibaba 格式化输出
java·人工智能·spring