TOC
KINA

KINA-0

Start having fun with KINA right now!

Spring Boot开发(3):Web综合案例 + Spring进阶

CRUD案例,配置文件、登录认证;事务管理、AOP,Spring原理、Maven高级。本文通过tlias智能学习辅助系统这一综合项目全面介绍了Java Web、Spring Boot的进阶知识。首先,准备阶段涵盖了部门的基本操作,如查询、修改和删除。然后,员工管理则详细探讨了分页查询、文件上传(包括本地和阿里云OSS)以及员工的增删改查功能。接着,介绍了配置文件的参数化和登录认证机制,包括JWT令牌和全局异常处理。之后,讨论了Spring的事务管理和面向切面编程(AOP)基本概念。最后,简要阐述了Spring原理和Maven的高级特性,如模块化设计与私服管理,为开发者提供了全面的Spring Boot开发指南。

Web流程

ssm

本项目的接口文档:tlias智能学习辅助系统

1 准备阶段

准备阶段

REST(REpresentational State Transfer,表述性状态转换):一种软件架构风格

RESTful

/* Result.java */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result {
    private Integer code;   // 响应码:1成功,0失败
    private String msg;     // 响应信息:描述字符串
    private Object data;    // 返回的数据

    /**
     * 增删改 成功响应
     */
    public static Result success() {
        return new Result(1, "success", null);
    }

    /**
     * 查询 成功响应
     */
    public static Result success(Object data) {
        return new Result(1, "success", data);
    }

    /**
     * 失败响应
     */
    public static Result error(String msg) {
        return new Result(0, msg, null);
    }
}

前后端联调mac电脑启动前端程序的方法

前后端联调


2 部门管理

  • 一个完整的请求路径 = 类上的@RequestMapping的value属性 + 方法上的 @RequestMapping的value属性
  • @RequestMapping的衍生注解:@GetMapping@PostMapping@DeleteMapping ...
  • 给类添加注解@Slf4j来自动生成日志变量
/* DeptController.java */
@Slf4j
@RestController
@RequestMapping("/depts")
public class DeptController {
    @Autowired
    private DeptService deptService;

    ...
}
/* DeptService.java */
@Service
public interface DeptService { ... }
/* DeptServiceImpl.java */
@Service
public class DeptServiceImpl implements DeptService {
    @Autowired
    private DeptMapper deptMapper;

    ...
}
/* DeptMapper.java */
@Mapper
public interface DeptMapper { ... }
/* Dept.java */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Dept {
    private Integer id;
    private String name;
    private LocalDateTime createTime;
    private LocalDateTime updateTime;
}

2.1 查询部门

查询部门

/* DeptController.java */
@GetMapping
public Result list() {
    log.info("查询全部部门数据");
    List<Dept> deptList = deptService.list();
    return Result.success(deptList);
}
/* DeptService.java */
List<Dept> list();
/* DeptServiceImpl.java */
@Override
public List<Dept> list() {
    return deptMapper.list();
}
/* DeptMapper.java */
@Select("select * from dept")
public List<Dept> list();

2.2 删除部门

删除部门

/* DeptController.java */
@DeleteMapping("/{id}")
public Result delete(@PathVariable Integer id) {
    log.info("根据ID删除部门: {}", id);
    deptService.delete(id);
    return Result.success();
}
/* DeptService.java */
void delete(Integer id);
/* DeptServiceImpl.java */
@Override
public void delete(Integer id) {
    deptMapper.deleteById(id);
}
/* DeptMapper.java */
@Delete("delete from dept where id = #{id}")
void deleteById(Integer id);

稍后实现同时删除该部门下所有员工。

2.3 新增部门

新增部门

/* DeptController.java */
@PostMapping
public Result add(@RequestBody Dept dept) {
    log.info("新增部门: {}", dept);
    deptService.add(dept);
    return Result.success();
}
/* DeptService.java */
void add(Dept dept);
/* DeptServiceImpl.java */
@Override
public void add(Dept dept) {
    dept.setCreateTime(LocalDateTime.now());
    dept.setUpdateTime(LocalDateTime.now());

    deptMapper.insert(dept);
}
/* DeptMapper.java */
@Delete("delete from dept where id = #{id}")
void deleteById(Integer id);

2.4 修改部门

前端要求两个接口:根据ID查询、修改部门

/* DeptController.java */
@GetMapping("/{id}")
public Result getById(@PathVariable Integer id) {
    log.info("获取部门ID: {}", id);
    Dept dept = deptService.getById(id);
    return Result.success(dept);
}

@PutMapping
public Result update(@RequestBody Dept dept) {
    log.info("修改部门: {}", dept);
    deptService.update(dept);
    return Result.success();
}
/* DeptService.java */
Dept getById(Integer id);
void update(Dept dept);
/* DeptServiceImpl.java */
@Override
public Dept getById(Integer id) {
    return deptMapper.getById(id);
}

@Override
public void update(Dept dept) {
    dept.setUpdateTime(LocalDateTime.now());
    deptMapper.update(dept);
}
/* DeptMapper.java */
@Select("select * from dept where id = #{id}")
Dept getById(Integer id);

@Update("update dept set name = #{name}, update_time = #{updateTime} where id = #{id}")
void update(Dept dept);

3 员工管理

/* EmpController.java */
@Slf4j
@RestController
@RequestMapping("/emps")
public class EmpController {
    @Autowired
    private EmpService empService;

    ...
}
/* EmpService.java */
@Service
public interface EmpService { ... }
/* EmpServiceImpl.java */
@Service
public class EmpServiceImpl implements EmpService {
    @Autowired
    private EmpMapper empMapper;

    ...
}
/* EmpMapper.java */
@Mapper
public interface EmpMapper { ... }
/* Emp.java */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Emp {
    private Integer id;
    private String username;
    private String password;
    private String name;
    private Short gender;   // 1男,2女
    private String image;   // url
    private Short job;      // 1班主任,2讲师,3学工主管,4教研主管,5咨询师
    private LocalDate entrytime;
    private Integer deptId;
    private LocalDateTime createTime;
    private LocalDateTime updateTime;
}

3.1 分页查询

分页查询

/* PageBean.java */
// 分页查询结果的封装类
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PageBean {
    private Long total;     // 总记录数
    private List rows;      // 当前页数据列表
}
/* EmpController.java */
// 分页查询
@GetMapping
public Result page(@RequestParam(defaultValue = "1") Integer page,
                   @RequestParam(defaultValue = "10") Integer pageSize) {    // 设置默认值
    log.info("分页查询,参数:{}, {}", page, pageSize);
    PageBean pageBean = empService.page(page, pageSize);
    return Result.success(pageBean);
}
/* EmpService.java */
// 分页查询
PageBean page(Integer page, Integer pageSize);

3.1.1 原始方法

/* EmpServiceImpl.java */
// 分页查询
@Override
public PageBean page(Integer page, Integer pageSize) {
    Long count = empMapper.count();

    Integer start = (page - 1) * pageSize;
    List<Emp> empList = empMapper.page(start, pageSize);

    PageBean pageBean = new PageBean(count, empList);
    return pageBean;
}
/* EmpMapper.java */
// 查询总记录数
@Select("select count(*) from emp")
public Long count();

// 分页查询获取列表数据
@Select("select * from emp limit #{start}, #{pageSize}")
public List<Emp> page(Integer start, Integer pageSize);

3.1.2 PageHelper分页插件

使用PageHelper分页插件,需先引入依赖pagehelper-spring-boot-starter,并指定版本为1.4.6以上。

/* EmpServiceImpl.java */
@Override
public PageBean page(Integer page, Integer pageSize) {
    // 设置分页参数
    PageHelper.startPage(page, pageSize);
    // 执行查询
    List<Emp> empList = empMapper.list();
    Page<Emp> p = (Page<Emp>) empList;      // 查询结果的封装类
    // 封装PageBean对象
    PageBean pageBean = new PageBean(p.getTotal(), p.getResult());
    return pageBean;
}
/* EmpMapper.java */
// 员工信息查询
@Select("select * from emp")
public List<Emp> list();

3.2 条件分页查询

条件分页查询

改写3.1各层代码:条件查询(动态SQL-XML映射文件) + 分页查询(PageHelper分页插件)

/* EmpController.java */
// 条件查询
@GetMapping
public Result page(@RequestParam(defaultValue = "1") Integer page,
                   @RequestParam(defaultValue = "10") Integer pageSize,
                   String name, Short gender,
                   @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate begin,
                   @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end) {    // 设置默认值
    log.info("分页查询,参数:{}, {}, {}, {}, {}, {}", page, pageSize, name, gender, begin, end);
    PageBean pageBean = empService.page(page, pageSize, name, gender, begin, end);
    return Result.success(pageBean);
}
/* EmpService.java */
// 条件查询
PageBean page(Integer page, Integer pageSize, String name, Short gender, LocalDate begin, LocalDate end);
/* EmpServiceImpl.java */
@Override
public PageBean page(Integer page, Integer pageSize, String name, Short gender, LocalDate begin, LocalDate end) {
    // 设置分页参数
    PageHelper.startPage(page, pageSize);
    // 执行查询
    List<Emp> empList = empMapper.list(name, gender, begin, end);
    Page<Emp> p = (Page<Emp>) empList;
    // 封装PageBean对象
    PageBean pageBean = new PageBean(p.getTotal(), p.getResult());
    return pageBean;
}
/* EmpMapper.java */
// 员工信息条件查询
public List<Emp> list(String name, Short gender, LocalDate begin, LocalDate end);
<!-- EmpMapper.xml -->
<mapper namespace="com.hyplus.tlias.mapper.EmpMapper">
    <!-- 条件查询 -->
    <select id="list" resultType="com.hyplus.tlias.pojo.Emp">
        select * from emp
        <where>
            <if test="name != null and name != ''">     <!-- name应既非null对象又非空串 -->
                name like concat('%', #{name}, '%')
            </if>
            <if test="gender != null">
                and gender = #{gender}
            </if>
            <if test="begin != null and end != null">
                and entrydate between #{begin} and #{end}
            </if>
        </where>
        order by update_time desc
    </select>
</mapper>

3.3 删除员工

删除员工

/* EmpController.java */
// 批量删除
@DeleteMapping("/{ids}")
public Result delete(@PathVariable List<Integer> ids) {
    log.info("批量删除操作 ids: {}", ids);
    empService.delete(ids);
    return Result.success();
}
/* EmpService.java */
// 批量删除
void delete(List<Integer> ids);
/* EmpServiceImpl.java */
@Override
public void delete(List<Integer> ids) {
    empMapper.delete(ids);
}
/* EmpMapper.java */
// 批量删除员工
void delete(List<Integer> ids);
<!-- EmpMapper.xml -->
<!-- 批量删除员工 -->
<delete id="delete">
    delete from emp
    where id in 
    <foreach collection="ids" item="id" separator="," open="(" close=")">
        #{id}
    </foreach>
</delete>

3.4 新增员工

新增员工

/* EmpController.java */
// 新增员工
@PostMapping
public Result save(@RequestBody Emp emp) {
    log.info("新增员工 emp: {}", emp);
    empService.save(emp);
    return Result.success();
}
/* EmpService.java */
// 新增员工
void save(Emp emp);
/* EmpServiceImpl.java */
@Override
public void save(Emp emp) {
    emp.setCreateTime(LocalDateTime.now());
    emp.setUpdateTime(LocalDateTime.now());
    empMapper.insert(emp);
}
/* EmpMapper.java */
@Insert("insert into emp (username, name, gender, image, job, entrydate, dept_id, create_time, update_time) " +
        "values (#{username}, #{name}, #{gender}, #{image}, #{job}, #{entrydate}, #{deptId}, #{createTime}, #{updateTime})")
void insert(Emp emp);

3.5 文件上传

3.5.1 简介

文件上传是指将本地图片、视频、音频等文件上传到服务器,供其他用户浏览或下载的过程。文件上传在项目中应用非常广泛,我们经常发微博、发微信朋友圈都用到了文件上传功能。

前端页面三要素:method="post"enctype="multipart/form-data"type="file"

<form action="/upload" method="post" enctype="multipart/form-data">
    姓名:<input type="text" name="username"><br>
    年龄:<input type="text" name="age"><br>
    头像:<input type="file" name="image"><br>
    <input type="submit" value="提交">
</form>

服务端接收文件:MultipartFile

@RestController
public class UploadController {
    @PostMapping("/upload")
    public Result upload(String username, Integer age, 
                         @RequestParam("image") MultipartFile file) { // 名称不一致时需加注解指定请求参数
        return Result.success();
    }
}

上传时会在本地端生成临时文件,上传过程结束后自动被删除。

3.5.2 本地存储

本地存储:在服务端,接收到上传上来的文件之后,将文件存储在本地服务器磁盘中。

@RestController
public class UploadController {
    @PostMapping("/upload")
    public Result upload(String username, Integer age, MultipartFile image) throws IOException {
        // 获取原始文件名
        String originalFilename = image.getOriginalFilename();
        // 构造唯一的文件名:UUID(通用唯一识别码)
        String newFileName = UUID.randomUUID().toString() + originalFilename.substring(originalFilename.lastIndexOf('.'));
        // 将文件保存在服务端目录下
        image.transferTo(new File("~/images/" + newFileName));
        return Result.success();
    }
}

在SpringBoot中,文件上传,默认单个文件允许最大大小为 1M。如果需要上传大文件,可以进行如下配置

# 配置单个文件最大上传大小
spring.servlet.multipart.max-file-size=10MB
# 配置单个请求最大上传大小(一次请求可上传多个文件)
spring.servlet.multipart.max-request-size=100MB
MultipartFile提供的方法 说明
String getOriginalFilename() 获取原始文件名
void transferTo(File dest) 将接收的文件转存到磁盘文件中
long getSize() 获取文件的大小,单位:字节
byte[] getBytes() 获取文件内容的字节数组
InputStream getInputStream() 获取接收到的文件内容的输入流

本地存储的缺点有无法直接访问、磁盘空间限制、磁盘损坏等,因此现在通常使用云存储

3.5.3 阿里云OSS

阿里云是阿里巴巴集团旗下全球领先的云计算公司,也是国内最大的云服务提供商。

阿里云对象存储OSS (Object Storage Service)是一款海量、安全、低成本、高可靠的云存储服务。使用OSS,您可以通过网络随时存储和调用包括文本、图片、音频和视频等在内的各种文件。

OSS

Bucket:存储空间,是用户用于存储对象(Object,文件)的容器,所有的对象都必须隶属于某个存储空间。

SDK(Software Development Kit 的缩写,软件开发工具包):辅助软件开发的依赖(jar包),代码示例等。

OSS流程

OSS流程2

OSS集成的步骤:

  • 引入阿里云OSS上传文件工具类(由官方的示例代码改造而来)
  • 上传图片接口开发
/* AliOSSUtils.java */
@Component
public class AliOSSUtils { /* 去官网复制工具类的示例代码 */ }
/* UploadCoontroller.java */
@RestController
public class UploadController {
    @Autowired
    private AliOSSUtils aliOSSUtils;

    @PostMapping("/upload")
    public Result upload(MultipartFile image) throws IOException {
        log.info("文件上传,文件名:{}", image.getOriginalFilename());
        String url = aliOSSUtils.upload(image); // 调用阿里云OSS工具类,将所上传的文件存入阿里云
        log.info("文件上传成功,文件访问的url: {}", url);
        return Result.success(url);     // 将图片上传完成后的url返回,用于浏览器回显展示
    }
}

3.6 查询回显

查询回显

/* EmpController.java */
// 根据ID查询员工
@GetMapping("/{id}")
public Result getById(@PathVariable Integer id) {
    log.info("根据ID查询员工信息 id: {}", id);
    Emp emp = empService.getById(id);
    return Result.success(emp);
}
/* EmpService.java */
// 根据ID查询员工
Emp getById(Integer id);
/* EmpServiceImpl.java */
@Override
public Emp getById(Integer id) {
    return empMapper.getById(id);
}
/* EmpMapper.java */
// 根据ID查询员工
@Select("select * from emp where id = #{id}")
Emp getById(Integer id);

3.7 修改员工

根据提供的接口文档,所有的修改操作在进行前均需先查询回显

修改员工

/* EmpController.java */
// 修改员工
@PutMapping
public Result update(@RequestBody Emp emp) {
    log.info("更新员工信息:{}", emp);
    empService.update(emp);
    return Result.success();
}
/* EmpService.java */
// 修改员工
void update(Emp emp);
/* EmpServiceImpl.java */
@Override
public void update(Emp emp) {
    emp.setUpdateTime(LocalDateTime.now());
    empMapper.update(emp);
}
/* EmpMapper.java */
// 修改员工
void update(Emp emp);
<!-- EmpMapper.xml -->
<!--更新员工信息 -->
<update id="update">
    update emp
    <set>
        <if test="username != null and username != ''">
            username = #{username},
        </if>
        <if test="password != null">
            password = #{password},
        </if>
        <if test="name != null and name != ''">
            name = #{name},
        </if>
        <if test="gender != null">
            gender = #{gender},
        </if>
        <if test="image != null and image != ''">
            image = #{image},
        </if>
        <if test="job != null">
            job = #{job},
        </if>
        <if test="entrydate != null">
            entrydate = #{entrydate},
        </if>
        <if test="deptId != null">
            dept_id = #{deptId},
        </if>
        <if test="updateTime != null">
            update_time = #{updateTime}
        </if>
    </set>
    where id = #{id}
</update>

4 配置文件

4.1 参数配置化

将Bean中的变量的值配置到配置文件中,再在变量上追加@Value注解指定名称。参数配置化便于参数的统一管理。

参数配置化

当Bean中所有变量在配置文件中前缀一致时还可用以下方式:给类加上注解@ConfigurationProperties并指明参数前缀,保证变量名与配置文件中的名称一致即可。需先引入依赖spring-boot-configuration-processor

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

@Value@ConfigurationProperties的异同:

  • 相同点:都是用来注入外部配置的属性。
  • 不同点:
    • @Value注解只能一个一个的进行外部属性的注入。
    • @ConfigurationProperties可以批量的将外部的属性配置注入到Bean对象的属性中。

4.2 yml配置文件

SpringBoot提供了多种属性配置方式:

  1. application.properties文件
server.port=8080
server.address=127.0.0.1
  1. application.yml/application.yaml文件。基本语法:
    • 大小写敏感
    • 值前边必须有空格,作为分隔符
    • 使用缩进表示层级关系,缩进时,不允许使用Tab键,只能用空格(IDEA中会自动将Tab转换为空格)
    • 缩进的空格数目不重要,只要相同层级的元素左侧对齐即可
    • #表示注释,从这个字符一直到行尾,都会被解析器忽略
server:
 port: 8080
 address: 127.0.0.1
# 对象/Map集合
user:
 name: Akira
 age: 2024
 address: Hangzhou

# 数组/List/Set集合
hobby:
 - java
 - C++
 - python

5 登录认证

5.1 基础登陆功能

基础登陆功能

/* LoginController.java */
@Slf4j
@RestController
public class LoginController {
    @Autowired
    private EmpService empService;

    // 员工登录
    @PostMapping("/login")
    public Result login(@RequestBody Emp emp) {
        log.info("员工登陆:{}", emp);
        Emp e = empService.login(emp);
        return e != null ? Result.success() : Result.error("用户名或密码错误");
    }
}
/* EmpService.java */
// 员工登陆
Emp login(Emp emp);
/* EmpServiceImpl.java */
@Override
public Emp login(Emp emp) {
    return empMapper.getByUsernameAndPassword(emp);
}
/* EmpMapper.java */
// 根据用户名和密码查询用户信息
@Select("select * from emp where username = #{username} and password = #{password}")
Emp getByUsernameAndPassword(Emp emp);

5.2 登录校验

  • 登录标记:用户登录成功后,每一次请求中,都可以获取到该标记。(会话技术)
  • 统一拦截:过滤器Filter、拦截器Interceptor

登录校验

5.2.1 会话技术

会话:用户打开浏览器,访问web服务器的资源,会话建立,直到有一方断开连接,会话结束。在一次会话中可以包含多次请求和响应。

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

会话跟踪方案:

  1. 客户端会话跟踪技术:Cookie

Cookie

  1. 服务端会话跟踪技术:Session

Session

  1. 令牌技术(主流)

令牌技术

5.2.2 JWT令牌

JWT(JSON Web Token,官网:https://jwt.io)定义了一种简洁的、自包含的格式,用于在通信双方以json数据格式安全的传输信息。由于数字签名的存在,这些信息是可靠的。

场景:登录认证

  1. 登录成功后,生成令牌
  2. 后续每个请求,都要携带JWT令牌,系统在每次请求处理之前,先校验令牌,通过后,再处理
  • JWT校验时使用的签名秘钥,必须和生成JWT令牌时使用的秘钥是配套的。
  • 如果JWT令牌解析校验时报错,则说明JWT令牌被篡改或失效了,令牌非法。

组成

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

JWT

Base64:一种基于64个可打印字符(A-Z a-z 0-9+/)来表示二进制数据的编码方式。

要生成JWT令牌,需引入依赖jjwt

/* JwtUtils.java 工具类 */
public class JwtUtils {
    private static String signKey = "hyplus";
    private static Long expire = 43200000L;

    // 生成JWT令牌
    private static String generateJwt(Map<String, Object> claims) {
        String jwt = Jwts.builder()
                .signWith(SignatureAlgorithm.HS256, signKey)   // 签名算法
                .setClaims(claims)  // 自定义内容(载荷)
                .setExpiration(new Date(System.currentTimeMillis() + expire))  // 设置JWT令牌的有效期
                .compact();
        return jwt;
    }

    // 解析JWT令牌
    public static Claims parseJwt(String jwt) {
        Claims claims = Jwts.parser()
                .setSigningKey(signKey)
                .parseClaimsJws(jwt)
                .getBody();
        return claims;
    }
}

改进后的5.1中的登陆系统如下

/* LoginController.java */
// 员工登录
@PostMapping("/login")
public Result login(@RequestBody Emp emp) {
    log.info("员工登陆:{}", emp);
    Emp e = empService.login(emp);

    if (e != null) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("id", e.getId());
        claims.put("name", e.getName());
        claims.put("username", e.getUsername());

        String jwt = JwtUtils.generateJwt(claims);  // JWT中包含了当前登录员工的信息
        return Result.success(jwt);
    }

    return Result.error("用户名或密码错误");
}

5.2.3 Filter过滤器

过滤器(Filter)是Javaweb 三大组件(Servlet、Filter、Listener)之一,可以把对资源的请求拦截下来,从而实现一些特殊的功能。过滤器一般完成一些通用的操作,比如登录校验、统一编码处理、敏感字符处理等。

过滤器链:一个web应用中,可以配置多个过滤器,这多个过滤器就形成了一个过滤器链,各过滤器执行优先级按过滤器类名(字符串)的自然排序。

步骤:

  1. 定义过滤器:定义一个类,实现Filter接口,并重写其方法initdoFilterdestroy(其中initdestroy有默认实现)。
  2. 配置过滤器:Filter类上添加注解@WebFilter并在其中配置拦截资源的路径urlPatterns。启动类上加 @ServletComponentScan开启Servlet组件支持,让Spring可以扫描到。

根据需求配置拦截路径urlPatterns

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

执行流程:请求-->放行前逻辑-->放行-->资源—>放行后逻辑

实现登录校验过滤器的流程:

实现登录校验过滤器的流程

/* LoginCheckFilter.java */
@Slf4j
@WebFilter(urlPatterns = "/*")
public class LoginCheckFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("Initiating...");
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) servletRequest;
        HttpServletResponse resp = (HttpServletResponse) servletResponse;
        // 1. 获取请求url
        String url = req.getRequestURL().toString();
        log.info(url);

        // 2. 判断请求url中是否包含login,若包含则为登录请求,放行
        if (url.contains("login")) {
            log.info("登录操作,放行...");
            filterChain.doFilter(servletRequest, servletResponse);
            return;
        }

        // 3. 非登录请求,则获取请求头中的令牌(token)
        String jwt = req.getHeader("token");

        // 4. 判断令牌是否存在,若不存在(无长度,为空串)则返回错误结果(未登录)
        if (!StringUtils.hasLength(jwt)) {
            log.info("请求头token为空,返回未登录信息");
            Result error = Result.error("NOT_LOGIN");
            // 手动将对象转换为json,写入响应
            String notLogin = JSONObject.toJSONString(error);
            resp.getWriter().write(notLogin);
            return;
        }

        // 5. 解析token,若解析失败,返回错误结果(未登录)
        try {
            JwtUtils.parseJwt(jwt);
        } catch (Exception e) {     // JWT解析失败
            e.printStackTrace();
            log.info("解析令牌失败,返回未登录信息");
            Result error = Result.error("NOT_LOGIN");
            String notLogin = JSONObject.toJSONString(error);
            resp.getWriter().write(notLogin);
            return;
        }

        // 6. 放行
        log.info("令牌合法,放行");
        filterChain.doFilter(servletRequest, servletResponse);
    }

    @Override
    public void destroy() {
        log.info("Destroying...");
    }
}

5.2.4 Interceptor拦截器

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

步骤:

  1. 定义拦截器:实现HandlerInterceptor接口,并重写其所有方法preHandlepostHandleafterCompletion(均有默认实现)。
  2. 注册拦截器:实现WebMvcConfigurer接口并加上注解@Configuration,重写其方法addInterceptors,注入拦截器(使用@Autowired自动装Bean)

根据需要配置拦截路径,方法addPathPatterns配置需要拦截哪些资源,excludePathPatterns配置不需要拦截哪些资源

拦截路径 含义 举例
/* 一级路径 能匹配/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

过滤器Filter与拦截器Interceptor的区别:

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

实现登录校验拦截器的流程同过滤器。

/* LoginCheckInterceptor.java */
@Slf4j
@Component
public class LoginCheckInterceptor implements HandlerInterceptor {
    // 目标资源方法运行前运行,返回true:放行,放回false,不放行
    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception {
        // 1. 获取请求url
        String url = req.getRequestURL().toString();
        log.info(url);
        // 2. 判断请求url中是否包含login,若包含则为登录请求,放行
        if (url.contains("login")) {
            log.info("登录操作,放行...");
            return true;
        }
        // 3. 非登录请求,则获取请求头中的令牌(token)
        String jwt = req.getHeader("token");
        // 4. 判断令牌是否存在,若不存在(无长度,为空串)则返回错误结果(未登录)
        if (!StringUtils.hasLength(jwt)) {
            log.info("请求头token为空,返回未登录信息");
            Result error = Result.error("NOT_LOGIN");
            // 手动将对象转换为json,写入响应
            String notLogin = JSONObject.toJSONString(error);
            resp.getWriter().write(notLogin);
            return false;
        }
        // 5. 解析token,若解析失败,返回错误结果(未登录)
        try {
            JwtUtils.parseJwt(jwt);
        } catch (Exception e) {     // JWT解析失败
            e.printStackTrace();
            log.info("解析令牌失败,返回未登录信息");
            Result error = Result.error("NOT_LOGIN");
            String notLogin = JSONObject.toJSONString(error);
            resp.getWriter().write(notLogin);
            return false;
        }
        // 6. 放行
        log.info("令牌合法,放行");
        return 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...");
    }
}
/* WebConfig.java */
@Configuration      // 配置类
public class WebConfig implements WebMvcConfigurer {
    @Autowired
    private LoginCheckInterceptor loginCheckInterceptor;

    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loginCheckInterceptor).addPathPatterns("/**").excludePathPatterns("/login");
    }
}

5.3 全局异常处理

出现异常时,默认返回的结果不符合规范。常规的异常处理方法为在每一个Controller的方法中进行try...catch处理,代码臃肿。

全局异常处理器:无需单独写try...catch,可全局捕获异常。想实现该处理器类需添加注解@RestControllerAdvice(= @ControllerAdvice + @ResponseBody),方法上添加注解@ExceptionHandler捕获异常。若请求处理正常,则从Controller输出;若请求处理异常,则走向全局异常处理器,统一封装错误信息。

全局异常处理

/* GlobalExceptionHandler.java */
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(Exception.class)  // 捕获所有异常
    public Result ex(Exception ex) {
        ex.printStackTrace();
        return Result.error("对不起,操作失败!请联系管理员!");
    }

6 事务管理

事务(Transaction)是一组操作的集合,是一个不可分割的工作单位,这些操作要么同时成功,要么同时失败。

# 开启事务(一组操作开始前)
start transactions;
begin;

# 提交事务(这组操作全部成功后)
commit;

# 回滚事务(中间任何一个操作出现异常)
rollback;

6.1 Spring事务管理

根据事务的定义,调整本案例中解散部门的方法为删除部门同时删除该部门下的所有员工,并在Service层的方法添加注解@Transactional,将其交给spring进行事务管理。

方法执行前,开启事务;成功执行完毕,提交事务;出现异常,回滚事务。

在Service层类、接口上使用该注解@Transactional时,表示其所属所有方法均进行事务管理。常加在Service层经常使用增删改操作的方法上。

/* DeptServiceImpl.java */
// 新增empMapper
@Autowired
private EmpMapper empMapper;

@Transactional      // 交给spring进行事务管理
@Override
public void delete(Integer id) {
    deptMapper.deleteById(id);  // 根据ID删除部门数据

    empMapper.deleteByDeptId(id);   // 根据ID删除该部门下的员工
}
/* EmpMapper.java */
// 根据部门ID删除该部门下的员工数据
@Delete("delete from emp where dept_id = #{deptId}")
void deleteByDeptId(Integer deptId);

在yml文件中配置spring事务日志开关:

# 开启事务管理日志
logging:
 level:
  org.springframework.jdbc.support.jdbcTransactionManager: debug

6.2 rollbackFor和propagation

rollbackFor属性

默认情况下,只有出现RuntimeException才回滚异常。可以设置rollbackFor属性控制出现何种异常类型时回滚事务,要想出现任何异常时都回滚可设置@Transactional(rollbackFor = Exception.class)

propagation属性

当一个事务方法被另一个事务方法调用时,该事务方法进行事务控制的方式称为事务传播行为。可以设置propagation属性来控制该方法运行时是否需要/支持事务。

@Transactional
public void a() {
    userService.b();
}

@Transactional(propagation = Propagation.REQUIRED)  // 该方法运行时需要事务,有则加入,无则新建
public void b() { ... }

常用的propagation属性值如下表所示:

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

综上,进一步修改解散部门方法:要求解散部门时,无论是成功还是失败,都要记录操作日志。即新增记录日志到数据库表中。需要创建对应的接口及其实现类,具体如下所示:

/* DeptServiceImpl.java */
// 新增deptLogService
@Autowired
private DeptLogService deptLogService;

@Transactional(rollbackFor = Exception.class)   // 设为出现所有异常都进行回滚
@Override
public void delete(Integer id) {
    try {
        deptMapper.deleteById(id);

        empMapper.deleteByDeptId(id);
    } finally {     // 将记录日志部分的代码放在finally中
        DeptLog deptLog = new DeptLog();    // 部门日志表POJO,表参数有create_time、description
        deptLog.setCreateTime(LocalDateTime.now());
        deptLog.setDescription("执行了解散部门的操作,此次解散的是" + id + "号部门");
        deptLogService.insert(deptLog);
    }
}
/* DeptLogService.java */
public interface DeptLogService {
    void insert(DeptLog deptLog);
}
/* DeptLogServiceImpl.java */
public class DeptLogServiceImpl implements DeptLogService {
    @Autowired
    private DeptLogMapper deptLogMapper;

    @Transactional(propagation = Propagation.REQUIRES_NEW)  // 设置为需要新事务,使得不被同时结束
    @Override
    public void insert(DeptLog deptLog) {
        deptLogMapper.insert(deptLog);
    }
}
  • REQUIRED:大部分情况下都是用该传播行为即可。
  • REQUIRES_NEW:当不希望事务之间相互影响时,可以使用该传播行为。比如:下订单前需要记录日志,不论订单保存成功与否,都需要保证日志记录能够记录成功。

7 AOP

AOP(Aspect Oriented Programming,面向切面编程、面向方面编程)即为面向特定方法编程。

7.1 AOP快速入门

动态代理是面向切面编程最主流的实现。而SpringAOP是Spring框架的高级技术,旨在管理bean对象的过程中,主要通过底层的动态代理机制,对特定的方法进行编程。

案例:统计各个业务层方法执行耗时——获取方法运行开始时间;运行原始方法;获取方法运行结束时间并计算执行耗时

  1. 导入依赖:在pom.xml中导入AOP的依赖
<!-- pom.xml -->
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-aop -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
    <version>3.2.1</version>
</dependency>
  1. 编写AOP程序:针对于特定方法根据业务需要进行编程。在类上添加注解@Aspect,在模板方法上添加通知注解,此处用@Around(详见7.3),并设置切入点表达式指定特定方法(详见7.5)
/* TimeAspect.java */
@Slf4j
@Component
@Aspect     //AOP类
public class TimeAspect {
    // 将切入点表达式进行抽取,提高复用性
    @Pointcut("execution(* com.hyplus.tlias.service.*.*())")  // 执行目录下所有接口/类的方法时都会调用!
    private void pt() {}

    // 统计方法运行耗时
    @Around("pt()")       // 引用抽取至pt方法的切入点表达式
    public Object recordTime(ProceedingJoinPoint joinPoint) throws Throwable {
        // 1. 记录开始时间
        long begin = System.currentTimeMillis();

        // 2. 调用原始方法
        Object result = joinPoint.proceed();

        // 3. 获取方法运行结束时间并计算执行耗时
        long end = System.currentTimeMillis();
        log.info(joinPoint.getSignature() + "方法执行耗时:" + (end - begin) + "ms");  // getSignature:获取方法签名

        return result;
    }
}

7.2 核心概念

  • 连接点(Join Point):可以被AOP控制的原始方法/目标方法(暗含方法执行时的相关信息,详见7.6)
  • 通知(Advice):指哪些重复的逻辑,也就是共性功能(最终体现为一个方法)
  • 切入点(Point Cut):匹配连接点的条件,通知仅会在切入点方法执行时被应用(即实际被AOP控制的方法)
  • 切面(Aspect):描述通知与切入点的对应关系(通知+切入点)
  • 目标对象(Target):通知所应用的对象

AOP执行流程

7.3 通知类型

5种常用的通知注解:

注解 类型 说明
@Before 前置通知 此注解标注的通知方法在目标方法前被执行
@Around 环绕通知 此注解标注的通知方法在目标方法前、后都被执行【最常用】
① 需有方法参数来传入目标方法,类型为ProceedingJoinPoint,并要求其调用方法proceed()来运行(更多信息调用详见7.6)
② 返回值必须指定为Object,来接收原始方法的返回值
@After 后置通知 此注解标注的通知方法在目标方法后被执行,无论是否有异常都会执行,又称最终通知
@AfterReturning 返回后通知 此注解标注的通知方法在目标方法后被执行,有异常不会执行
@AfterThrowing 异常后通知 此注解标注的通知方法发生异常后执行

@PointCut:将公共的切入点表达式抽取出来,需要用到时引用该切入点表达式即可("方法名()")。为private时仅能在当前类中被引用,为public时可在外部类中被引用。

7.4 通知顺序

当有多个切面的切入点都匹配到了目标方法,目标方法运行时,多个通知方法都会被执行。

  1. 不同切面类中,默认按照切面类的类名字母排序
    • 目标方法的通知方法:字母排名靠前的执行
    • 目标方法的通知方法:字母排名靠前的执行
  2. 在切面类上添加注解@Order(数字)来控制顺序
    • 目标方法的通知方法:数字小的执行
    • 目标方法的通知方法:数字小的执行

7.5 切入点表达式

切入点表达式:描述切入点方法的一种表达式,主要作用为决定项目中的哪些方法需要加入通知。

@Pointcut("切入点表达式")

7.5.1 execution

execution(...):根据方法签名(方法的返回值、包名、类名、方法名、方法参数等信息)来匹配

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

execution(public void com.hyplus.tilas.mapper.EmpMapper.*(java.lang.Integer))

?的表示可省略的部分:

  • 访问修饰符(比如public、protected等。建议省略)
  • 包名.类名(省略后匹配范围为整个项目,范围过大,因此不建议省略)
  • throws异常(注意是方法上声明抛出的异常,不是实际抛出的异常。通常不指定)

两种通配符

  1. *:一级通配符单个独立的任意符号,可以通配任意返回值、包名、类名、方法名、任意类型的一个参数,也可以通配包、类、方法名的一部分
// 例:通配如下所示包下所有以Service结尾的类中所有以delete开头的单参数方法
execution(* com.*.service.*Service.delete*(*))
  1. ..:多个连续的任意符号,可以通配任意层级的包,或任意类型任意个数的参数。
// 例1:通配com.hyplus目录下所有DeptService类的任意参数方法
execution(* com.hyplus..DeptService.*(..))
// 例2:通配任意方法(慎用)
execution(* *(..))

对于多个execution,可正常使用&&||!来连接:

@Pointcut("execution(* com..service.DeptService.list()) || " +
      "execution(* com..service.DeptService.delete(*))")

书写建议:

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

7.5.2 @annotation

@annotation:用于匹配标识有特定注解的方法

@annotation(注解全类名)
// 例:匹配所有含有MyLog注解的方法
@Pointcut("@annotation(com.hyplus.aop.MyLog)")

若想某方法被匹配,只需给它加上相应的被检测注解即可。

7.6 连接点

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

  • 对于@Around通知,获取连接点信息只能用类ProceedingJoinPoint
/* MyAspectExamples.java */
@Around("pt()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
    log.info("MyAspectExamples around before ...");

    // 1. 获取目标对象的类名
    String className = joinPoint.getTarget().getClass().getName();
    log.info("目标对象的类名: {}", className);

    // 2. 获取目标方法的方法名
    String methodName = joinPoint.getSignature().getName();
    log.info("目标方法的方法名: {}", methodName);

    // 3. 获取目标方法运行时传入的参数
    Object[] args = joinPoint.getArgs();
    log.info("目标方法运行时传入的参数: {}", Arrays.toString(args));

    // 4. 放行:目标方法执行,并获取其返回值
    Object result = joinPoint.proceed();
    log.info("目标方法运行时的返回值: {}", result);

    log.info("MyAspectExamples around after ...");
    return result;
}
  • 对于其他4种通知,获取连接点信息只能用类JoinPoint,其为ProceedingJoinPoint的父类型。
    • 调用方法同上,但某些通知由于其作用时机,无法获得返回值。

7.7 例:记录接口操作日志至数据库表中

将前述案例中增、删、改相关接口的操作日志记录至数据库表中。

日志信息包含:操作人、操作时间、执行方法的全类名、执行方法名、方法运行时参数、返回值、方法执行时长。

思路分析:需要对所有业务类中的增、删、改方法添加统一功能,使用AOP技术最为方便——@Around环绕通知;由于增、删、改方法名没有规律,可以自定义@Log注解完成目标方法匹配。

准备
  • 在案例工程中引入AOP的起步依赖(详见7.1)
  • 导入资料中准备好的数据库表结构,并引入对应的实体类
/* com.hyplus.tlias.pojo.OperateLog.java 表结构对应的POJO */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OperateLog {
    private Integer id;     // ID
    private Integer operateUser;        // 操作人ID
    private LocalDateTime operateTime;  // 操作时间
    private String className;       // 操作类名
    private String methodName;  // 操作方法名
    private String methodParams;    // 操作方法参数
    private String returnValue;     // 操作方法返回值
    private Long costTime;      //操作耗时
}
/* com.hyplus.OperateLogMapper.java */
@Mapper
public interface OperateLogMapper {
    // 插入日志数据
    @Insert("insert into operate_log (operate_user, operate_time, class_name, method_name, method_params, return_value, cost_time) " +
            "values (#{operateUser}, #{operateTime}, #{className}, #{methodName}, #{methodParams}, #{returnValue}, #{costTime})")
    public void insert(OperateLog log);
}
编码
  • 自定义注解@Log
  • 定义切面类,完成记录操作日志的逻辑
/* com.hyplus.tlias.anno.Log.java */
@Retention(RetentionPolicy.RUNTIME)     // 设为运行时生效
@Target(ElementType.METHOD)             // 设为只能添加在方法上
public @interface Log {}

获取当前登录用户的方法:获取request对象,从请求头中获取JWT令牌,解析令牌获取当前用户的ID。详见前文JWT介绍。

/* com.hyplus.tlias.aop.LogAspect.java */
@Slf4j
@Component
@Aspect     // 切面类
public class LogAspect {
    // 直接注入一个Servlet请求Bean来获取JWT令牌(相关概念及工具类见前文JWT介绍)
    @Autowired
    private HttpServletRequest request;

    @Autowired
    private OperateLogMapper operateLogMapper;

    @Around("@annotation(com.hyplus.tlias.anno.Log)")
    public Object recordLog(ProceedingJoinPoint joinPoint) throws Throwable {
        // 获取操作人ID(当前登录员工ID):从令牌获取
        // 获取请求头中的JWT令牌,解析令牌
        String jwt = request.getHeader("token");
        Claims claims = JwtUtils.parseJWT(jwt);
        Integer operateUser = (Integer) claims.get("id");

        // 获取操作时间
        LocalDateTime operateTime = LocalDateTime.now();

        // 获取操作类名
        String className = joinPoint.getTarget().getClass().getName();

        // 获取操作方法名
        String methodName = joinPoint.getSignature().getName();

        // 获取操作方法参数
        Object[] args = joinPoint.getArgs();
        String methodParams = Arrays.toString(args);

        // 调用原始方法运行
        long begin = System.currentTimeMillis();
        Object result = joinPoint.proceed();
        long end = System.currentTimeMillis();

        // 获取方法返回值
        String returnValue = JSONObject.toJSONString(result);

        // 获取操作耗时
        long costTime = end - begin;

        // 记录操作日志
        OperateLog operateLog = new OperateLog(null, operateUser, operateTime, className, 
                                               methodName, methodParams, returnValue, costTime);
        operateLogMapper.insert(operateLog);
        log.info("AOP已记录操作日志: {}", operateLog);

        return result;
    }
}

再给所有需要的方法添加自定义注解@Log即可。


8 Spring原理篇

8.1 配置优先级

如4.2所述,SpringBoot中支持三种格式的配置文件,优先级:properties > yml > yaml

在项目开发时,推荐统一使用一种格式的配置(yml是主流)。

除了配置文件外,SpringBoot还支持Java系统属性-Dserver.port=9000命令行参数--server.port=10010的方式进行属性配置。

Idea中提供了可视化界面来配置:

Idea中提供了可视化界面来配置

打包后配置属性:

  1. 执行maven打包指令package生成jar包
  2. 执行java指令运行jar包,与此同时可执行上述属性配
java -Dserver.port=9000 -jar tlias-web-management-0.8.1-SNAPSHOT.jar --server.port=10019

8.2 Bean管理

8.2.1 获取Bean

默认情况下,Spring项目启动时会把bean都创建好放在IOC容器中。

会受到作用域及延迟初始化影响,本小节主要针对于默认的单例非延迟加载的bean而言。

若想要主动获取这些bean,可通过如下方法:

  1. 根据名称获取bean:Object getBean(String name)
  2. 根据类型获取bean:<T> T getBean(Class<T> requiredType)
  3. 根据名称和类型获取bean:<T> T getBean(String name, Class<T> requiredType)(自带类型转换)
/* 单元测试类 示例 */
// 需注入一个IOC容器对象
@Autowired
private ApplicationContext applicationContext;

@Test
public void testGetBean() {
    // 根据名称获取bean(名称默认为类名首字母小写)
    DeptController bean1 = (DeptController) applicationContext.getBean("deptController"); // 返回Object,需强转
    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);
}

8.2.2 Bean作用域

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

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

可以通过@Scope注解来配置作用域

@Scope("prototype")       // 设置该bean为非单例
@RestController
@RequestMapping("/depts")
public class DeptController { ... }

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

8.2.3 第三方Bean

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

  1. 在启动类中定义方法,将方法返回值交给IOC容器管理,成为IOC容器的Bean对象(不建议)
@SpringBootApplication
public class SpringbootWebConfig2Application {
    // 声明第三方Bean
    @Bean(name = "method1")   // 将方法返回值交给IOC容器管理,成为IOC容器的Bean对象
    public SAXReader saxReader() {
        return new SAXReader();
    }
}
  1. 通过@Configuration注解声明一个配置类,对这些bean进行集中分类配置,其他同上
@Configuration
public class SpringbootWebConfig2Application {
    @Bean(value = "method2")
    public SAXReader saxReader() {
        return new SAXReader();
    }
}

通过@Bean注解的namevalue属性可以声明bean的名称,默认为方法名。

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

8.3 SpringBoot原理1:起步依赖

Spring Boot两大原理:起步依赖、自动配置。

原始的Spring框架进行Web程序开发,需要引入大量依赖
原始的Spring框架

Spring Boot的起步依赖原理就是maven的依赖传递

Spring Boot的起步依赖原理就是maven的依赖传递

8.4 SpringBoot原理2:自动配置

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

8.4.1 扫描方案

  1. @ComponentScan组件扫描(使用繁琐,性能低)
@ComponentScan({"com.example", "com.hyplus"})   // 设定组件扫描的包
@SpringBootApplication
public class SpringbootWebConfig2Application { ... }
  1. @Import导入,使用该注解导入的类会被Spring加载到IOC容器中。导入形式有:
    • 直接导入普通类
    • 直接导入配置类
    • 导入ImporterSelector接口实现类
/* ImporterSelector接口实现类 */
public class MyImportSelector implements ImportSelector {
    public String[] selectImports(AnnotationMetadata importingClassMetadata) {
        return new String[]{"com.example.HeaderConfig"};
    }
}
@Import({TokenParser.class, HeaderConfig.class, MyImportSelector.class})    // 导入
@SpringBootApplication
public class SpringbootWebConfig2Application { ... }
  1. 第三方包提供Enable开头的注解(形如@EnableXxxx),封装@Import注解(更方便、优雅,【被Spring Boot采用】)
/* 第三方包中提供的 @EnableXxxx 格式的注解 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Import(MyImportSelector.class)     // 封装导入注解@Import
public @interface EnableHeaderConfig {
}
@EnableHeaderConfig     // 添加该注解相当于直接使用上述Import
@SpringBootApplication
public class SpringbootWebConfig2Application { ... }

8.4.2 底层原理

@SpringBootApplication注解标识在SpringBoot工程引导类上,是SpringBoot中最最最重要的注解。由三个部分组成:

  1. @SpringBootConfiguration: 该注解与@Configuration注解作用相同,用来声明当前也是一个配置类。
  2. @ComponentScan:组件扫描,默认扫描当前引导类所在包及其子包。
  3. @EnableAutoConfiguration: SpringBoot实现自动化配置的核心注解。

自动化配置的核心注解

自动化配置

如上所示,并非全部装配为IOC容器的Bean,而是添加了@Conditional及其子注释来进行条件装配
@Conditional:在方法、类上使用,按照一定的条件进行判断,在满足给定条件后才会注册对应的bean对象到Spring IOC容器中。
其子注解如下图所示

子注解

此处介绍常用的3个:

  • @ConditionalOnClass:判断环境中是否有对应字节码文件,才注册bean到IOC容器。需使用属性name指定类的全类名,或属性value直接指定字节码文件对象。
@Bean
@ConditionalOnClass(name = "io.jsonwebtoken.jwts")    //当前环境存在指定的这个类时,才声明该bean
public HeaderParser headerParser() { ... }
  • @ConditionalOnMissingBean:判断环境中没有对应的bean,才注册bean到IOC容器。可指定类型(value属性)或名称(name属性)。
@Bean
@ConditionalOnMissingBean   // 当不存在当前类型的bean时,才声明该bean
public HeaderParser headerParser() { ... }
  • @ConditionalOnProperty:判断配置文件中有对应属性name属性)和对应值havingValue属性),才注册bean到IOC容器。
    @Bean
    @ConditionalOnProperty(name = "name", havingValue = "hyplus")   // 配置文件中存在对应的属性和值,才注册bean到IOC容器。
    public HeaderParser headerParser() { ... }

8.4.3 自定义starter

在实际开发中,经常会定义一些公共组件,提供给各个项目团队使用。而在SpringBoot的项目中,一般会将这些公共组件封装为SpringBoot的starter

自定义starter

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

  1. 创建aliyun-oss-spring-boot-starter模块
  2. 创建aliyun-oss-spring-boot-autoconfigure模块,在starter中引入该模块
  3. 在aliyun-oss-spring-boot-autoconfigure模块中的定义自动配置功能,并定义自动配置文件META-INF/spring/xxxx.imports

自定义starter


9 Maven高级

9.1 分模块设计与开发

分模块设计即将项目按照功能拆分成若干个子模块
作用:方便项目的管理维护、扩展,也方便模块间的相互调用,资源共享。

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

分模块设计与开发

9.2 继承

9.2.1 继承关系

继承描述的是两个工程间的关系,子工程可以继承父工程中的配置信息,常见于依赖关系的继承。与Java类继承相似,Maven工程继承只能单继承,但支持多重继承。
作用:简化依赖配置、统一管理依赖
实现:<parent> ... </parent>

继承关系

  1. 创建maven模块tlias-parent,该工程为父工程,设置打包方式为pom
    • jar:普通模块打包,springboot项目基本都是jar包(内嵌tomcat运行)【默认】
    • war:普通web程序打包,需要部署在外部的tomcat服务器中运行
    • pom:父工程或聚合工程,该模块不写代码,仅进行依赖管理

设置打包方式为pom

  1. 子工程的pom.xml文件中,配置继承关系
    • 配置了继承关系之后坐标中的groupId可省略,因为会自动继承父工程的。
    • <relativePath>指定父工程的pom文件的相对位置。若不指定,将从本地仓库/远程仓库查找该工程。

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

  1. 在父工程中配置各个工程共有的依赖,如lombok、各种starter等(子工程会自动继承父工程的依赖)
    • 若父子工程都配置了同一个依赖的不同版本,以子工程的为准。

9.2.2 版本锁定

在maven中,可以在父工程的pom文件中通过<dependencyManagement>来统一管理依赖版本。子工程引入依赖时,无需指定<version>版本号,父工程统一管理。变更依赖版本,只需在父工程中统一变更。

<dependencies><dependencyManagement>的区别:

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

版本锁定

自定义属性/引用属性

自定义属性/引用属性

9.3 聚合

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

聚合

maven中可以通过<modules>设置当前聚合工程所包含的子模块名称。
聚合工程中所包含的模块,在构建时,会自动根据模块间的依赖关系设置构建顺序,与聚合工程中模块的配置书写位置无关。

通过modules标签设置当前聚合工程所包含的子模块名称

继承与聚合……

  • 作用
    • 聚合用于快速构建项目
    • 继承用于简化依赖配置、统一管理依赖
  • 相同点
    • 聚合与继承的pom.xml文件打包方式均为pom,可以将两种关系制作到同一个pom文件中
    • 聚合与继承均属于设计型模块,并无实际的模块内容
  • 不同点
    • 聚合是在聚合工程中配置关系,聚合可以感知到参与聚合的模块有哪些
    • 继承是在子模块中配置关系,父模块无法感知哪些子模块继承了自己

9.4 私服

私服是一种特殊的远程仓库,它是架设在局域网内的仓库服务,用来代理位于外部的中央仓库,用于解决团队内部的资源共享与资源同步问题。

私服在企业项目开发中,一个项目/公司只需1台即可(无需一般人自己搭建,会使用即可)。

依赖查找顺序:本地仓库 → 私服 → 中央仓库

私服

资源版本:

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

资源上传与下载:

资源上传与下载

流程:

  1. 设置私服的访问用户名/密码(maven配置文件settings.xml中的<servers>中配置)

私服的访问用户名/密码

  1. 在IDEA的maven工程的pom文件中配置上传(发布)地址

在IDEA的maven工程的pom文件中配置上传(发布)地址

  1. 设置私服依赖下载的仓库组地址(maven配置文件settings.xml中的<mirrors><profiles>中配置)

设置私服依赖下载的仓库组地址

发表评论