四阶段笔记

day01
1. 安装Vue CLI
Vue CLI是Vue框架的客户端工具,创建Vue项目、运行Vue项目都需要事先安装此工具。

安装Vue CLI的命令:

npm install -g @vue/cli
以上命令执行完后,只要没有提示错误(Err或Error字样),即可视为成功!

当Vue CLI安装完成后,可以通过以下命令查看版本号并检查是否安装成功:

vue -V
2. 创建项目
在命令提示符窗口中,执行vue create 项目名称的命令,就可以创建项目,创建出来的项目会在命令提示符窗口中提示的位置(即:敲命令时左侧提示的位置)。

例如:创建jsd2206-csmall-web-client-teacher项目:

vue create jsd2206-csmall-web-client-teacher
需要注意:执行以上命令后,会有一点卡顿,此时不要反复按回车,接下来,需要选择创建选项,分别是:

Manually select features
Babel / Vuex / Router
2.x
直接回车
In package.json
直接回车
当创建完成后,可以使用IntelliJ IDEA打开此项目,并且,在IntelliJ IDEA的Terminal(终端)面板中,可以执行启动项目的命令:

npm run serve
3. 关于Vue脚手架项目
Vue脚手架项目是一个单页面的应用,即整个项目中只有1个html页面,它认为这个页面是由若干个视图组合而成的,每个视图都只是该页面中的一个部分,并且,都是可以被替换的!

项目的文件夹结构:

[.idea]:仅当使用IntelliJ IDEA打开此项目后,才会有这个文件夹,是IntelliJ IDEA管理项目时使用的,无需关注此文件

[node_modules]:此项目中使用的各个库的文件,注意:通常,提交到GIT的项目代码中并不包含此文件夹,需要先执行npm install命令,则会自动下载此项目中的各个库的文件,然后才可以运行项目

[public]:此项目的静态资源文件夹,通常用于存放图片、自行编写的js、自行编写的css等文件,此文件夹下的资源的访问路径都是根路径

public/favicon.ico:此项目的图标文件,此文件必须在这个位置,且必须是这个文件名

public/index.html:此项目中唯一的html文件,也是项目打开页面的入口

[src]:源文件的文件夹

[src/assets]:资源文件夹,此处的资源文件应该是不随程序运行而发生变化的

[src/components]:视图组件文件夹,此文件夹下的视图组件通常是被视为封装的视图,且将会被其它视图调用

[src/router]:此项目中配置路径的文件所在的文件夹

src/router/index.js:默认的路由配置文件

[src/stroe]:此项目的配置全局共享变量的文件所在的文件夹

src/store/index.js:默认的配置全局共享变量的文件,此处声明的变量,在任何一个视图组件中均可使用

[views]:一般的视图组件所在的文件夹

src/App.vue:默认绑定到index.html中的<div id="app"></div>的视图组件,可简单理解为页面的入口,此视图组件不需要配置路由,默认就会显示

src/main.js:此项目的主配置文件,通常,在项目中安装了软件之后,都需要在此文件中补充配置

.gitignore:使用GIT时的忽略文件清单,即:用于配置哪些文件不会提交到Git

package.json:项目的配置文件,例如配置了此项目的依赖项等

package-lock.json:锁定的配置文件,不需要,也不建议手动修改此文件中的任何内容

4. 关于视图组件
在Vue脚手架项目中,以.vue为作文件名后缀的,就是视图组件!

在视图组件中,源代码主要有3个部分:

<template>:设计界面的源代码部分,此标签下可以使用HTML或相关技术(例如Element UI)来设计界面

注意:在<template>标签下,只能有1个直接子标签!

<script>:编写JavaScript代码

<style>:编写CSS代码

在设计界面时,可以使用<router-view/>表示此视图组件不确定的内容!例如在App.vue中就使用了这个标签,此标签将显示的内容取决于URL(地址栏中的网址)。

5. 路由
在Vue脚手架项目中,使用“路由”来配置URL与视图组件的对应关系。

通过src/router/index.js可以配置路由。

核心代码是:

import HomeView from '../views/HomeView.vue'

const routes = [
  {
    path: '/',
    name: 'home',
    component: HomeView
  },
  {
    path: '/about',
    name: 'about',
    component: () => import('../views/AboutView.vue')
  }
]
以上配置中,path表示路径,name表示名称,可以不必配置,component表示视图组件。

关于component的值,可以使用静态导入的方式来确定,例如HomeView,也可以使用import()函数导入,例如以上关于/about的配置。

通常,在每个项目中,只有1个视图组件是静态导入的。

6. 安装Element UI
在终端中执行以下命令安装Element UI:

npm i element-ui -S
安装完成后,需要在src/main.js中添加配置:

import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';

Vue.use(ElementUI);
至此,在项目中的任何一个视图组件中都可以直接使用Element UI,不需要额外的声明或引用!

7. 安装axios
在终端执行安装axios的命令:

npm i axios -S
安装完成后,在main.js中添加配置:

import axios from 'axios';
Vue.prototype.axios = axios;
在Vue CLI项目中,使用axios时,在then()的回调内部,不可以使用匿名函数,必须使用箭头函数,例如:

this.axios.post(url, data).then((response) => {
    
});
完整代码示例:

this.axios.post(url, this.ruleForm).then((response) => {
    // console.log(response);
    if (response.data == 1) {
      // console.log('登录成功!');
      this.$message({
        message: '登录成功!',
        type: 'success'
      });
    } else if (response.data == 2) {
      // console.log('登录失败,用户名错误!');
      this.$message.error('登录失败,用户名错误!');
    } else {
      // console.log('登录失败,密码错误!');
      this.$message.error('登录失败,密码错误!');
    }
  });
8. 关于跨域问题
默认情况下,不允许向别的服务提交异步请求,例如,在http://localhost:9000服务上,向http://localhost:8080提交异步请求,这是不允许的!

在基于Spring Boot的项目中,要允许跨域访问,可以在启动类上实现WebMvcConfigurer接口,并重写addCorsMappings()方法:

@ServletComponentScan
@SpringBootApplication
public class CoolsharkApplication implements WebMvcConfigurer {

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

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowCredentials(true)
                .allowedHeaders("*")
                .allowedMethods("*")
                .allowedOriginPatterns("*")
                .maxAge(3600);
    }
}
关于后端服务中处理登录请求的相关代码:

@RequestMapping("/login")
public int login(@RequestBody User user, HttpSession session, HttpServletResponse response){
    System.out.println("客户端提交的用户信息:" + user);
    User u = mapper.selectByUsername(user.getUsername());
    if (u!=null){
        if (u.getPassword().equals(user.getPassword())){
            if (user.getRem() != null && user.getRem() == true){
                Cookie c1 = new Cookie("username",user.getUsername());
                Cookie c2 = new Cookie("password",user.getPassword());
                response.addCookie(c1);
                response.addCookie(c2);
            }
            //把登录成功的用户对象保存到会话里面
            session.setAttribute("user",u);
            System.out.println("登录成功");
            return 1;
        } else {
            System.out.println("密码错误");
            return 3;
        }
    } else {
        System.out.println("用户名错误");
        return 2;
    }
}
day02
1. 嵌套路由
当某个显示在<router-view/>位置的视图组件中也设计了<router-view/>,则出现了<router-view/>的嵌套,在配置路由时,需要使用嵌套路由!

在配置router/index.js中的routes数组时,数组元素即是一个个的路由对象,这些路由对象都是应用于App.vue中的<router-view/>的!如果需要某个视图显示在另一个视图的<router-view/>中(例如添加相册的视图组件需要显示到HomeView的<router-view/>中),需在HomeView的路由对象中配置children属性,这个children属性的配置方法与routes完全相同!

1. 创建项目
创建项目的参数如下:

![image-20220921141445816](C:\Users\TEDU\Desktop\每日笔记\成恒笔记\images\DAY02\image-20220921141445816.png)

提示:如果创建项目时的URL不可用,可以尝试在 https://start.spring.io 和 https://start.springboot.io 之间切换。

创建过程中,可以不勾选任何依赖项(后续添加的效果也是相同的)。

2. 创建数据库与数据表
创建mall_pms数据库:

create database mall_pms;
在IntelliJ IDEA中,展开右侧的Database面板,选择New > Data Source > MySQL / MariaDB,并在弹出的窗口中配置:

![image-20220921142606614](C:\Users\TEDU\Desktop\每日笔记\成恒笔记\images\DAY02\image-20220921142606614.png)

然后,将mall_pms_jsd2206.sql的代码全部复制到Console中,并全选、执行,即可创建数据表。

3. 调整pom.xml
建议将父级项目(spring-boot-starter-parent)的版本调整为2.5.9或其它2.5.x系列的版本号。

4. 关于数据库编程
Java语言是通过JDBC技术实现数据库编程的,但是,JDBC技术的应用相对比较繁琐,且编码步骤相对固定,所以,通常使用框架技术来实现,这些框架技术大多可以简化JDBC编程,使得实现数据库编程的代码更加简洁。

常见的数据库编程框架有:Mybatis(主流)、Spring Data JPA、Hibernate等。

5. 使用Mybatis框架
使用Mybatis框架之前,需要添加相关依赖项:

<!-- Mybatis整合Spring Boot的依赖项 -->
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.2.2</version>
</dependency>
<!-- MySQL的依赖项 -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>
在Spring Boot项目中,当添加了数据库编程的依赖项后,启动项目时,会自动读取连接数据库的配置参数值,如果没有配置,则会启动失败,例如:

***************************
APPLICATION FAILED TO START
***************************

Description:

Failed to configure a DataSource: 'url' attribute is not specified and no embedded datasource could be configured.

Reason: Failed to determine a suitable driver class
在Spring Boot项目中,在src/main/resources下默认已经存在application.properties配置文件,Spring Boot项目在启动时会自动读取此文件中的配置信息,如果配置信息中的属性名是特定的,Spring Boot还会自动应用这些属性值。

则在application.properties中添加配置

# 连接数据库的配置
spring.datasource.url=jdbc:mysql://localhost:3306/mall_pms?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=root
找到项目中默认已经创建出来的测试类,在其中添加测试连接数据库的方法,并执行:

package cn.tedu.csmall.product;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import javax.sql.DataSource;

@SpringBootTest
class CsmallProductApplicationTests {

    @Test
    void contextLoads() {
    }

    @Autowired
    DataSource dataSource;

    @Test
    void testConnection() throws Throwable {
        dataSource.getConnection();
        System.out.println("连接数据库成功!");
    }

}
6. 关于SQL语句
以pms_album(相册)表为例。

插入数据的SQL语句大致是:

INSERT INTO pms_album (
    name, description, sort, gmt_create, gmt_modified
) VALUES (
    '华为Mate50的相册', '暂无简介', 200, null, null
);
批量插入数据的SQL语句大致是:

INSERT INTO pms_album 
(name, description, sort, gmt_create, gmt_modified) 
VALUES 
('华为Mate50的相册', '暂无简介', 200, null, null),
('华为Mate40的相册', '暂无简介', 200, null, null),
('华为Mate30的相册', '暂无简介', 200, null, null);
删除数据的SQL语句大致是:

DELETE FROM pms_album WHERE id=1;
批量删除数据的SQL语句大致是:

DELETE FROM pms_album WHERE id=1 OR id=3 OR id=5;
DELETE FROM pms_album WHERE id IN (1,3,5);
更新数据的SQL语句大致是:

UPDATE pms_album SET name='新的名称', description='新的简介' WHERE id=1;
统计查询的SQL语句大致是(例如:查询表中的数据的数量):

SELECT count(*) FROM pms_album;
根据id查询数据详情的SQL语句大致是:

SELECT id, name, description, sort, gmt_craete, gmt_modified 
FROM pms_album 
WHERE id=1;
查询(所有)数据的列表的SQL语句大致是:

SELECT id, name, description, sort, gmt_craete, gmt_modified
FROM pms_album
ORDER BY id;
day03
1. 关于实体类
实体类是POJO的其中一种。

POJO:Plain Ordinary Java Object,简单的Java对象。

在项目中,如果某个类的作用就是声明若干个属性,并且添加Setter & Getter方法等,并不编写其它的功能性代码,这样的类都称之POJO,用于表示项目中需要处理的数据。

以pms_album为例,这张数据表应该有与之对应的实体类,在数据表中的字段类型与Java中的属性的数据类型的对应关系是:

MySQL中的数据类型    Java中的数据类型
tinyint / smallint / int    Integer
bigint    Long
char / varchar / text系列    String
datetime    LocalDateTime
decimal    BigDecimal
关于POJO类,其编写规范是:

所有属性都应该是私有的

所有属性都应该有对应的、规范名称的Setter、Getter方法

必须重写equals()和hashCode(),并保证:

如果两个对象的各属性值完全相同,则equals()对比结果为true,且hashCode()值相同

如果两个对象存在属性值不同的,则equals()对比结果为false,且hashCode()值不同

如果两个对象的hashCode()相同,则equals()对比结果必须为true

如果两个对象的hashCode()不同,则equals()对比结果必须为false

必须实现Serializable接口

建议重写toString()方法,输出各属性的值

在项目中使用Lombok框架,可以实现:添加注解,即可使得Lombok在项目的编译期自动生成一些代码(例如Setter & Getter)。

关于Lombok框架的依赖项:

<!-- Lombok的依赖项,主要用于简化POJO类的编写 -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.20</version>
    <scope>provided</scope>
</dependency>
在POJO类上添加Lombok框架的@Data注解,可以在编译期生成:

规范的Setter & Getter

规范的hashCode()与equals()

包含各属性与值的toString()

则在项目的根包下创建pojo.entity.Album类为:

package cn.tedu.csmall.product.pojo.entity;

import lombok.Data;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
 * 相册
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Data
public class Album implements Serializable {

    /**
     * 记录id
     */
    private Long id;

    /**
     * 相册名称
     */
    private String name;

    /**
     * 相册简介
     */
    private String description;

    /**
     * 自定义排序序号
     */
    private Integer sort;

    /**
     * 数据创建时间
     */
    private LocalDateTime gmtCreate;

    /**
     * 数据最后修改时间
     */
    private LocalDateTime gmtModified;

}
注意:当使用了Lombok后,由于源代码中并没有Setter & Getter方法,所以,当编写代码时,IntelliJ IDEA不会提示相关方法,并且,即使强行输入调用这些方法的代码,还会报错,但是,并不影响项目的运行!为了解决此问题,强烈推荐安装Lombok插件!

2. 通过Mybatis实现数据库编程
2.1. 关于Mybatis框架
Mybatis是目前主流的解决数据库编程相关问题的框架,主要是简化了数据库编程。

Mybatis框架的基础依赖项的artifactId是:mybatis。

Mybatis框架虽然可以不依赖于Spring等其它框架,但是,直接使用比较麻烦,需要自行编写大量的配置,所以,通常结合Spring一起使用,需要添加的依赖项的artifactId是:mybatis-spring。

在Spring Boot项目中,直接添加mybatis-spring-boot-starter将包含以上依赖项,和其它必要的、常用的依赖项。

Mybatis框架简化数据库编程的表现为:你只需要定义访问数据的抽象方法,并配置此抽象方法映射的SQL语句即可!

2.2. 关于抽象方法
使用Mybatis框架时,访问数据的抽象方法必须定义在接口中!因为Mybatis框架是通过“接口代理”的设计模式,生成了接口的实现对象!

关于Mybatis的抽象方法所在的接口,通常使用Mapper作为名称的最后一个单词!

则可以在项目的根包下创建mapper.AlbumMapper接口,例如:

public interface AlbumMapper {
}
关于抽象方法:

返回值类型:如果要执行的SQL操作是增、删、改类型的,使用int作为返回值类型,表示“受影响的行数”,不建议使用void,如果要执行的SQL操作是查询类型的,只需要保证返回值类型可以封装必要的结果即可

方法名称:自定义的,但推荐遵循规范,不要使用重载

参数列表:取决于需要执行的SQL语句需要哪些参数,在抽象方法中,可以将这些参数一一列举出来,也可以将这些参数封装到自定义类中,使用自定义类作为抽象方法的参数

抛出异常:无

关于抽象方法命名参考(来自《阿里巴巴Java开发手册》):

获取单个对象的方法用 get 做前缀

获取多个对象的方法用 list 做前缀

获取统计值的方法用 count 做前缀

插入的方法用 save / insert 做前缀

删除的方法用 remove / delete 做前缀

修改的方法用 update 做前缀。

例如:插入相册的抽象方法可以设计为:

int insert(Album album);
另外,还需要使得Mybatis框架能明确这个接口是数据访问接口,可以采取的做法有:

【不推荐】在接口上添加@Mapper注解

每个数据访问接口上都需要此注解

【推荐】在配置类上添加@MapperScan注解,并配置数据访问接口所在的包

在根包(含子孙包)下的任何添加了@Configuration注解的类都是配置类

只需要一次配置,各数据访问接口不必添加@Mapper注解

则在根包下创建config.MybatisConfiguration类,配置@MapperScan:

package cn.tedu.csmall.product.config;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;

/**
 * Mybatis的配置类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Configuration
@MapperScan("cn.tedu.csmall.product.mapper")
public class MybatisConfiguration {

    public MybatisConfiguration() {
        System.out.println("创建配置类:MybatisConfiguration");
    }

}
2.3. 关于配置SQL语句
在Spring Boot中,整合了Mybatis框架后,可以在数据访问接口的抽象方法上使用@Insert等注解来配置SQL语句,这种做法是不推荐的!

提示:在不是Spring Boot项目中,需要额外的配置,否则,将不识别抽象方法上的@Insert注解。

不推荐使用@Insert等注解配置SQL语句的主要原因有:

长篇的SQL语句不易于阅读

不便于实现特殊的配置

部分配置不易于复用

不便于实现与DBA(Database Administrator)协作

建议使用XML文件来配置SQL语句,这类XML文件需要有固定的、特殊的声明部分,推荐通过复制粘贴得到此文件,或从 http://doc.canglaoshi.org/config/Mapper.xml.zip 下载得到。

在src/main/resources下创建mapper文件夹,并将以上压缩包中的SomeMapper.xml复制到此mapper文件夹中:

![image-20220922114740601](C:\Users\TEDU\Desktop\每日笔记\成恒笔记\images\DAY03\image-20220922114740601.png)

关于XML文件的配置:

根标签必须是<mapper>

在<mapper>标签上必须配置namespace属性,此属性的值是接口的全限定名(包名与类名)

在<mapper>标签内部,使用<insert> / <delete> / <update> / <select>标签来配置增 / 删 / 改 / 查的SQL语句

各配置SQL语句的标签必须配置id属性,取值为对应的抽象方法的名称

各配置SQL语句的标签内部是配置SQL语句的

SQL语句不需要使用分号表示结束

不可以随意添加注释

在配置<select>标签时,必须配置resultType或resultMap这2个属性中的其中1个

例如:配置为:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="cn.tedu.csmall.product.mapper.AlbumMapper">

    <!-- int insert(Album album); -->
    <insert id="insert">
        INSERT INTO pms_album (
            name, description, sort
        ) VALUES (
            #{name}, #{description}, #{sort}
        )
    </insert>

</mapper>
另外,还需要在application.properties中配置XML文件所在的位置:

# 配置Mybatis的XML文件的位置
mybatis.mapper-locations=classpath:mapper/*.xml
至此,关于“插入相册数据”的功能已经开发完成!

2.4. 测试
在Spring Boot项目中,当需要编写测试时,可以在src/test/java下的根包下创建测试类,并在类中编写测试方法。

则在测试的根包下创建mapper.AlbumMapperTests测试类:

package cn.tedu.csmall.product.mapper;

import cn.tedu.csmall.product.pojo.entity.Album;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class AlbumMapperTests {

    @Autowired
    AlbumMapper mapper;

    @Test
    void testInsert() {
        Album album = new Album();
        album.setName("测试相册001");
        album.setDescription("测试简介001");
        album.setSort(99); // 注意:取值必须是 [0, 255]

        int rows = mapper.insert(album);
        System.out.println("插入数据完成,受影响的行数=" + rows);
    }

}
如果此前没有正确的配置@MapperScan,在执行测试时,将出现以下错误:

Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'cn.tedu.csmall.product.mapper.AlbumMapper' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)}
如果出现以下原因的操作错误:

在XML文件中,根标签<mapper>的namespace属性值配置有误

在XML文件中,配置SQL语句的<insert>或类似标签的id属性值配置有误

在application.properties配置文件中,没有正确的配置XML文件的位置

将出现以下错误:

org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): cn.tedu.csmall.product.mapper.AlbumMapper.insert
2.5. 练习:插入属性模板数据
属性模板表:pms_attribute_template

首先,在根包下的pojo.entity包中创建AttributeTemplate实体类:

package cn.tedu.csmall.product.pojo.entity;

import lombok.Data;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
 * 属性模板
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Data
public class AttributeTemplate implements Serializable {

    /**
     * 记录id
     */
    private Long id;

    /**
     * 属性模板名称
     */
    private String name;

    /**
     * 属性模板名称的拼音
     */
    private String pinyin;

    /**
     * 关键词列表,各关键词使用英文的逗号分隔
     */
    private String keywords;

    /**
     * 自定义排序序号
     */
    private Integer sort;

    /**
     * 数据创建时间
     */
    private LocalDateTime gmtCreate;

    /**
     * 数据最后修改时间
     */
    private LocalDateTime gmtModified;

}
然后,在根包下的mapper包中创建AttributeTemplateMapper接口,并在接口中添加抽象方法:

package cn.tedu.csmall.product.mapper;

import cn.tedu.csmall.product.pojo.entity.AttributeTemplate;
import org.springframework.stereotype.Repository;

/**
 * 处理属性模板数据的Mapper接口
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Repository
public interface AttributeTemplateMapper {

    /**
     * 插入属性模板数据
     *
     * @param attributeTemplate 属性模板数据
     * @return 受影响的行数
     */
    int insert(AttributeTemplate attributeTemplate);

}
然后,在src/main/resources/mapper通过粘贴得到AttributeTemplateMapper.xml文件,在此文件中配置以上抽象方法映射的SQL语句:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="cn.tedu.csmall.product.mapper.AttributeTemplateMapper">

    <!-- int insert(AttributeTemplate attributeTemplate); -->
    <insert id="insert">
        INSERT INTO pms_attribute_template (
            name, pinyin, keywords, sort
        ) VALUES (
            #{name}, #{pinyin}, #{keywords}, #{sort}
        )
    </insert>

</mapper>
最后,在src/test/java的根包下创建mapper.AttributeTemplateMapperTests测试类,编写并执行测试方法:

package cn.tedu.csmall.product.mapper;

import cn.tedu.csmall.product.pojo.entity.Album;
import cn.tedu.csmall.product.pojo.entity.AttributeTemplate;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class AttributeTemplateMapperTests {

    @Autowired
    AttributeTemplateMapper mapper;

    @Test
    void testInsert() {
        AttributeTemplate attributeTemplate = new AttributeTemplate();
        attributeTemplate.setName("测试数据002");
        attributeTemplate.setPinyin("ceshishuju002");
        attributeTemplate.setKeywords("测试关键词列表002");
        attributeTemplate.setSort(99); // 注意:取值必须是 [0, 255]

        int rows = mapper.insert(attributeTemplate);
        System.out.println("插入数据完成,受影响的行数=" + rows);
    }

}
2.6. 插入数据时获取自动编号的id值
在XML文件中,在<insert>标签上配置useGeneratedKeys="true"和keyProperty="属性名"这2个属性,就可获取插入的新数据的自动编号的主键值!例如:

<!-- int insert(Album album); -->
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
    INSERT INTO pms_album (
        name, description, sort
    ) VALUES (
        #{name}, #{description}, #{sort}
    )
</insert>
当成功插入数据后,Mybatis会自动获取自动编号的主键值,并封装到参数对象album的id属性(由keyProperty指定)中,例如:

@Test
void testInsert() {
    Album album = new Album();
    album.setName("测试相册005");
    album.setDescription("测试简介005");
    album.setSort(99); // 注意:取值必须是 [0, 255]

    System.out.println("插入数据之前,参数=" + album);
    int rows = mapper.insert(album);
    System.out.println("插入数据完成,受影响的行数=" + rows);
    System.out.println("插入数据之后,参数=" + album);
}
以上的执行结果大概是:

插入数据之前,参数=Album(id=null, name=测试相册005, description=测试简介005, sort=99, gmtCreate=null, gmtModified=null)
插入数据完成,受影响的行数=1
插入数据之后,参数=Album(id=8, name=测试相册005, description=测试简介005, sort=99, gmtCreate=null, gmtModified=null)
2.7. 根据id删除相册数据
需要执行的SQL语句大致是:

DELETE FROM pms_album WHERE id=?
在AlbumMapper接口中添加抽象方法:

/**
 * 根据id删除相册数据
 * @param id 相册id
 * @return 受影响的行数
 */
int deleteById(Long id);
在AlbumMapper.xml中配置以上抽象方法映射的SQL语句:

<!-- int deleteById(Long id); -->
<delete id="deleteById">
    DELETE FROM pms_album WHERE id=#{id}
</delete>
完成后,AlbumMapperTests测试类中编写并执行测试方法:

@Test
void testDeleteById() {
    Long id = 1L;
    int rows = mapper.deleteById(id);
    System.out.println("删除数据完成,受影响的行数=" + rows);
}
2.8. 练习:根据id删除属性模板数据
2.9. 统计相册表中数据的数量
需要执行的SQL语句大致是:

SELECT count(*) FROM pms_album
在AlbumMapper接口中添加抽象方法:

int count();
在AlbumMapper.xml中配置以上抽象方法映射的SQL语句:

<select id="count" resultType="int">
    SELECT count(*) FROM pms_album
</select>
完成后,AlbumMapperTests测试类中编写并执行测试方法:

@Test
void testCount() {
    int count = mapper.count();
    System.out.println("统计数据完成,数量=" + count);
}
2.10. 统计属性模板表中数据的数量
作业
补全所有数据表的:

插入数据功能,要求完成测试

注意:pms_spu、pms_sku这2张表的id不是自动编号的,所以,在插入数据时,必须提供id字段的值,并且,在配置XML中的<insert>时,不需要配置useGeneratedKeys和keyProperty属性

根据id删除数据

day04
2. 通过Mybatis实现数据库编程
2.11. 根据id查询相册详情(续)
则在项目的根包下创建pojo.vo.AlbumStandardVO类:

package cn.tedu.csmall.product.pojo.vo;

import lombok.Data;

import java.io.Serializable;

/**
 * 相册的标准VO类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Data
public class AlbumStandardVO implements Serializable {

    /**
     * 记录id
     */
    private Long id;

    /**
     * 相册名称
     */
    private String name;

    /**
     * 相册简介
     */
    private String description;

    /**
     * 自定义排序序号
     */
    private Integer sort;

}
在AlbumMapper接口中添加抽象方法:

/**
 * 根据id查询相册标准信息
 *
 * @param id 相册id
 * @return 匹配的相册的标准信息,如果没有匹配的数据,则返回null
 */
AlbumStandardVO getStandardById(Long id);
在AlbumMapper.xml中配置以上抽象方法映射的SQL语句:

<!-- AlbumStandardVO getStandardById(Long id); -->
<select id="getStandardById" resultType="cn.tedu.csmall.product.pojo.vo.AlbumStandardVO">
    SELECT id, name, description, sort
    FROM pms_album
    WHERE id=#{id}
</select>
完成后,AlbumMapperTests测试类中编写并执行测试方法:

@Test
void testGetStandardById() {
    Long id = 5L;
    Object result = mapper.getStandardById(id);
    System.out.println("根据id=" + id + "查询标准信息完成,结果=" + result);
}
2.12. 练习:根据id查询属性模板详情
2.12. 练习:根据id查询品牌详情
提示:品牌表是pms_brand。

2.13. 关于<resultMap>
当Mybatis处理查询的结果集时,会自动将列名(Column)与属性名(Property)相同的数据进行封装,例如,将查询结果集中名为name的数据封装到对象的name属性中,并且,默认情况下,对于列名与属性名不同的数据,不予处理!

提示:查询结果集中的列名(Column)默认是字段名(Field),而字段名是设计数据表时指定的。

在XML文件中,可以配置<resultMap>标签,此标签的作用就是:指导Mybatis将查询结果集中的数据封装到对象中。

建议通过<resultMap>标签配置列名与属性名的对应关系,例如:

<resultMap id="StandardResultMap" type="cn.tedu.csmall.product.pojo.vo.BrandStandardVO">
    <result column="product_count" property="productCount"/>
    <result column="comment_count" property="commentCount"/>
    <result column="positive_comment_count" property="positiveCommentCount"/>
</resultMap>
提示:在单表查询时,列名与属性名本来就相同的部分,可以不必在<resultMap>进行配置。

然后,在<select>标签上,不再配置resultType,而改为配置resultMap,且此属性的值就是<resultMap>标签的id值,例如:

<!-- BrandStandardVO getStandardById(Long id); -->
<select id="getStandardById" resultMap="StandardResultMap">
    暂不关心SQL语句部分
</select>
配置的<resultMap>是可以复用的,即:如果另一个<select>查询的结果也使用相同的VO类进行封装,则另一个<select>也配置相同的resultMap即可。

由于<resultMap>对应特定的VO类,而VO类是与字段列表对应的,所以,如果多个<select>复用了同一个<resultMap>,那这些<select>查询的字段列表必然是相同的,则可以通过<sql>和<include>来复用字段列表,例如:

<!-- BrandStandardVO getStandardById(Long id); -->
<select id="getStandardById" resultMap="StandardResultMap">
    SELECT
        <include refid="StandardQueryFields"/>
    FROM
        pms_brand
    WHERE
        id=#{id}
</select>

<sql id="StandardQueryFields">
    id, name, pinyin, logo, description,
    keywords, sort, sales, product_count, comment_count,
    positive_comment_count, enable
</sql>
2.14. 查询相册列表
需要执行的SQL语句大致是:

SELECT id, name, description, sort FROM pms_album ORDER BY id DESC
在许多数据的查询功能中,查询详情(标准信息)和查询列表时,需要查询的字段列表很可能是不同的,所以,应该使用不同的VO类(为了避免后续维护添加字段导致的调整,即使当前查询详情和查询列表的字段完全相同,也应该使用不同的VO类)!

在根包下创建pojo.vo.AlbumListItemVO类:


在AlbumMapper接口中添加抽象方法:

List<AlbumListItemVO> list();
在AlbumMapper.xml中配置SQL语句:

<select id="list" resultMap="ListResultMap">
    SELECT <include refid="ListQueryFields"/> 
    FROM pms_album 
    ORDER BY id DESC
</select>

<sql id="ListQueryFields">
    id, name, description, sort
</sql>

<resultMap id="ListResultMap" type="cn.tedu.csmall.product.pojo.vo.AlbumListItemVO">
</resultMap>
在AlbumMapperTests中编写并执行测试:

@Test
void testList() {
    List<?> list = mapper.list();
    System.out.println("查询列表完成,列表中的数据的数量=" + list.size());
    for (Object item : list) {
        System.out.println(item);
    }
}
2.14. 练习:查询属性模板列表
2.15. 动态SQL:<foreach>
Mybatis的动态SQL机制表现为:根据参数的不同,生成不同的SQL语句。

例如需要实现:根据若干个id批量删除相册数据。

需要执行的SQL语句大致是:

DELETE FROM pms_album WHERE id=? OR id=? ... OR id=?
或:

DELETE FROM pms_album WHERE id IN (?, ?, ... ?);
首先,在AlbumMapper接口中添加抽象方法,可以是:

int deleteByIds(List<Long> ids);
也可以是:

int deleteByIds(Long[] ids);
还可以是:

int deleteByIds(Long... ids);
提示:可变参数的本质仍是一个数组!

然后,在AlbumMapper.xml中配置SQL语句:

<!-- int deleteByIds(Long[] ids); -->
<delete id="deleteByIds">
    DELETE FROM pms_album WHERE id IN (
        <foreach collection="array" item="id" separator=",">
            #{id}
        </foreach>
    )
</delete>
或:

<!-- int deleteByIds(Long[] ids); -->
<delete id="deleteByIds">
    DELETE FROM pms_album WHERE 
    <foreach collection="array" item="id" separator=" OR ">
        id=#{id}
    </foreach>
</delete>
关于<foreach>标签的属性配置:

collection:表示被遍历的参数列表,如果抽象方法的参数只有1个,当参数类型是List集合类型时,当前属性取值为list,当参数类型是数组类型时,当前属性取值为array

item:用于指定遍历到的各元素的变量名,并且,在<foreach>的子级,使用#{}时的名称也是当前属性指定的名字

separator:用于指定遍历过程中各元素的分隔符(或字符串等)

完成后,在AlbumMapperTests中编写并执行测试:

@Test
void testDeleteByIds() {
    Long[] ids = {1L, 3L, 5L, 7L, 9L};
    int rows = mapper.deleteByIds(ids);
    System.out.println("批量删除数据完成,受影响的行数=" + rows);
}
2.16. 练习:批量插入相册数据
需要执行的SQL语句大致是:

INSERT INTO pms_album (name, description, sort) VALUES (?,?,?), (?,?,?), ... (?,?,?);
在AlbumMapper接口中添加抽象方法:

int insertBatch(List<Album> albumList);
在AlbumMapper.xml中配置SQL语句:

<!-- int insertBatch(List<Album> albumList); -->
<insert id="insertBatch" useGeneratedKeys="true" keyProperty="id">
    INSERT INTO pms_album (name, description, sort) VALUES
    <foreach collection="list" item="album" separator=",">
        (#{album.name}, #{album.description}, #{album.sort})
    </foreach>
</insert>
在AlbumMapperTests中编写并执行测试:

@Test
void testInsertBatch() {
    List<Album> albumList = new ArrayList();
    for (int i = 1; i <= 10; i++) {
        Album album = new Album();
        album.setName("批量插入的测试相册名称" + i);
        album.setDescription("批量插入的测试相册简介" + i);
        album.setSort(66);
        albumList.add(album);
    }
    
    int rows = mapper.insertBatch(albumList);
    System.out.println("批量插入数据完成,受影响的行数=" + rows);
}
2.17. 练习:批量删除属性模板数据
2.18. 练习:批量插入属性模板数据
2.19. 动态SQL:<if>
假设需要修改相册表中的数据,需要执行的SQL语句大致是:

UPDATE pms_album SET name=?, description=?, sort=? WHERE id=?
如果按照以上SQL来设计抽象方法,则抽象方法大致是:

int updateById(Album album);
并且,配置以上抽象方法映射的SQL语句:

<!-- int updateById(Album album); -->
<update id="updateById">
    UPDATE
        pms_album
    SET
        name=#{name},
        description=#{description},
        sort=#{sort}
    WHERE
        id=#{id}
</update>
如果采取以上做法,就无法实现“只修改部分字段的值”!例如:只修改sort时,如果在Album对象中只封装了id和sort属性值,则name和description这2个属性的值就是null,在执行SQL语句时,将会把表中原有数据的name和description字段的值更新为null,这是不符合原本的需求的!

期望的执行效果应该是:传入了对应的值,则更新对应字段的值,对于没有传入参数的部分,也不更新表中对应字段的数据!

如果要实现以上效果,则需要使用动态SQL中的<if>,这个标签的作用就是对参数进行判断的!

<!-- int updateById(Album album); -->
<update id="updateById">
    UPDATE
        pms_album
    <set>
        <if test="name != null">
            name=#{name},
        </if>
        <if test="description != null">
            description=#{description},
        </if>
        <if test="sort != null">
            sort=#{sort},
        </if>
    </set>
    WHERE
        id=#{id}
</update>
2.20. 动态SQL:其它
在Mybatis中,<if>标签并没有对应的类似Java中的else标签!如果需要实现类似Java中if ... else ...的效果,可以使用2个条件完全相反的<if>标签,例如:

<if test="name != null">
    某代码
</if>
<if test="name == null">
    另外一段代码
</if>
以上做法的缺点在于:实际上执行了2次条件的判断,在性能略微有浪费。

或者,使用<choose>系列标签,以实现类似if ... else ...的效果:

<choose>
    <when test="判断条件">
        满足条件时的SQL片段
    </when>
    <otherwise>
        不满足条件时的SQL片段
    </otherwise>
</choose>
3. SLF4j日志
在Spring Boot项目中,spring-boot-starter依赖项中已经包含日志框架!

在Spring Boot项目中,当添加了Lombok依赖项后,可以在任何类上添加@Slf4j注解,则Lombok会在编译期声明一个名为log的日志对象变量,此变量可以调用相关方法来输出日志!

package cn.tedu.csmall.product;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

@Slf4j
@SpringBootTest
public class Slf4jTests {

    @Test
    void testLog() {
        log.info("输出了一条日志……");
    }

}
SLF4j的日志的可显示级别,根据信息的重要程度,从不重要到严重依次是:

trace

debug

info:一般信息

warn:警告信息

error:错误信息

调用log变量来输出日志时,可以使用以上级别对应的方法,则可以输出对应级别的日志!

在Spring Boot项目中,日志的默认显示级别是info,则默认情况下只会显示info及更加严重的级别的日志!如果需要修改日志的显示级别,需要在application.properties中配置logging.level的属性,例如:

# 日志的显示级别
logging.level.cn.tedu.csmall=info
注意:在配置以上属性时,必须在logging.level右侧加上要配置显示级别的包的名称,此包名就是配置日志显示级别的根包。

作业
补全mall_pms数据库中所有数据表的以下数据访问功能,要求均有对应的测试:

插入数据功能

注意:pms_spu、pms_sku这2张表的id不是自动编号的,所以,在插入数据时,必须提供id字段的值,并且,在配置XML中的<insert>时,不需要配置useGeneratedKeys和keyProperty属性

批量插入数据功能

根据id删除数据

根据若干个id批量删除数据

根据id修改数据

统计表中数据的数量

根据id查询数据详情

查询数据列表

推荐开发优先级:相册(pms_album) > 属性模板(pms_attribute_template) > 品牌(pms_brand) > 属性(pms_attribute) > 类别(pms_category) > 其它

开发进度要求:

9月26日前完成:相册、属性模板、品牌、属性、类别

9月30日前完成:全部

提交截止时间:2022-09-30 23:00

day05
3. SLF4j日志(续)
输出日志的各个方法都被重载了多次,建议使用的方法例如:

void trace(String var1);

void trace(String var1, Object... var2);
提示:以上是trace方法,其它级别的日志也有完全相同参数列表的方法。

以上的第2个方法适用于在输出的日志中添加变量的值,在第1个字符串参数中,各变量均使用{}表示,然后,通过第2个参数依次传入各变量对应的值即可,例如:

int x = 1;
int y = 2;
log.trace("{}+{}={}", x, y, x + y);
使用以上方式输出时,会将字符串部分进行缓存(是一种预编译的做法),在执行时,并不会出现拼接字符串的情况,所以,在执行效率方面,比传统的System.out.println()的要高很多!

4. 关于Profile配置
在配置中,许多配置值会因为当前环境不同,需要配置不同的值,例如,在开发环境中,日志的显示级别可以是trace这种较低级别的,而在测试环境、生产环境(项目部署上线并运行)中可能需要改为其它值,再例如,连接数据库配置的相关参数,在不同环境中,可能使用不同的值……如果在application.properties中反复修改大量配置值,是非常不方便的!

Spring框架支持Profile配置(个性化配置),Spring Boot简化了Profile配置的使用,它支持使用application-xxx.properties作为配置文件的名称,其中,xxx部分是完全自定义的名称,你可以针对不同的环境,编写一组配置文件,这些配置文件中配置了相同的属性,但是值不同,例如:

application-dev.properties(暂定为“开发”环境使用的配置文件)

# 连接数据库的配置
spring.datasource.url=jdbc:mysql://localhost:3306/mall_pms?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=root

# 日志的显示级别
logging.level.cn.tedu.csmall=trace
application-test.properties(暂定为“测试”环境使用的配置文件)

# 连接数据库的配置
spring.datasource.url=jdbc:mysql://192.168.1.100:3306/mall_pms?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
spring.datasource.username=test
spring.datasource.password=test123

# 日志的显示级别
logging.level.cn.tedu.csmall=debug
application-prod.properties(暂定为“生产”环境使用的配置文件)

# 连接数据库的配置
spring.datasource.url=jdbc:mysql://192.168.1.255:3306/mall_pms?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
spring.datasource.username=prod
spring.datasource.password=s3ctet@tedu.cn

# 日志的显示级别
logging.level.cn.tedu.csmall=info
以上这些不在application.properties中的配置,默认是不生效的!需要在application.properties中显式的激活后,才会生效!例如:

# 激活Profile配置
spring.profiles.active=prod
小结:

appliation.properties是始终加载的配置

application-xxx.properties是需要通过application.properties中的spring.profiles.active属性来激活的

application-xxx.properties文件名中的xxx是自定义的名称,也是spring.profiles.active属性的值

需要自行评估哪些配置会因为环境不同而配置不同的值

5. 关于YAML配置
YAML是一种使用.yml作为扩展名的配置文件,这类配置文件在Spring框架中默认是不支持的,需要添加额外的依赖项,在Spring Boot项目中,默认已经集成了相关依赖项,所以,在Spring Boot项目中可以直接使用。

在Spring Boot项目中,可以使用.properties配置,也可以使用.yml配置。

相对.properties配置,YAML的配置语法为:

在.properties配置中,属性名使用小数点分隔的,改为使用冒号结束,并从下一行开始,缩进2个空格

属性名与属性值之间使用1个冒号和1个空格进行分隔

多个不同的配置属性中,如果属性名中有相同的部分,可以不必重复配置,只需要将不同的部分缩进在相同位置即可

如果某个属性值是纯数字的,但需要是字符串类型,可以使用一对单引号框住

例如在.properties中配置为:

spring.datasource.username=root
spring.datasource.password=1234
在.yml中则配置为:

spring:
  datasource:
    username: root
    password: '1234'
注意:每换行后,需要缩进2个空格,在IntelliJ IDEA中,编写.yml文件时,IntelliJ IDEA会自动将按下的TAB键的字符替换为2个空格。

提示:如果.yml文件出现乱码(通常是因为复制粘贴文件导致的),则无法解析,项目启动时就会报错,此时,应该保留原代码(将代码复制到别处),删除报错的配置文件,并重新创建新文件,将保留下来的原代码粘贴回新文件即可。

5. 关于业务逻辑
业务逻辑:数据的处理应该有一定的处理流程,并且,在此流程中,可能需要制定某些规则,如果不符合规则,不应该允许执行相关的数据访问!这套流程及相关逻辑则称之为业务逻辑。

例如:当用户尝试注册时,通常要求用户名(或类似的唯一标签,例如手机号码等)需要是“唯一的”,在执行插入数据(将用户信息插入到数据表中)之前,应该先检查用户名是否已经被占用。

业务逻辑层的主要价值是设计业务流程,及业务逻辑,以保证数据的完整性和安全性。

在代码的设计方面,业务逻辑层将使用Service作为名称的关键词,并且,应该先自定义业务逻辑层接口,再自定义类实现此接口,实现类的名称应该在接口名称的基础上添加Impl后缀。例如,处理相册数据的业务逻辑接口的名称应该是IAlbumService或AlbumService,其实现类的名称则是`AlbumServiceImpl。

6. 业务:添加相册
先在项目的根包下创建service.IAlbumService接口:

public interface IAlbumService {
    
}
然后,在service包下创建impl.AlbumServiceImpl类,实现以上接口,并且在类上添加@Service注解:

@Service
public class AlbumServiceImpl implements IAlbumService {
    
}
提示:在类上添加了@Autowired注解后,当启动项目或执行任何一个Spring Boot测试时,都会自动创建以上类的对象,并且,可以使用@Autowired实现属性的“自动赋值”。

由于实体类不适合作为业务方法参数,来表示“客户端将提交的数据”,所以,应该使用专门的POJO类型作为业务方法的参数类型!当前项目中使用DTO作为此类POJO的后缀!

则在根包下创建pojo.dto.AlbumAddNewDTO类:

package cn.tedu.csmall.product.pojo.dto;

import lombok.Data;

import java.io.Serializable;

/**
 * 添加相册的DTO类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Data
public class AlbumAddNewDTO implements Serializable {

    /**
     * 相册名称
     */
    private String name;

    /**
     * 相册简介
     */
    private String description;

    /**
     * 自定义排序序号
     */
    private Integer sort;

}
接下来,需要在接口中定义“添加相册”的抽象方法:

/**
 * 添加相册
 *
 * @param albumAddNewDTO 相册数据
 */
void addNew(AlbumAddNewDTO albumAddNewDTO);
提示1:业务方法的参数应该是自定义的POJO类型

提示2:业务方法的名称是自定义的

提示3:业务方法的返回值类型,是仅以“成功”为前提来设计的,不考虑失败的情况,因为失败将通过抛出异常来表现

接下来,在AlbumServiceImpl中重写抽象方法:

@Autowired
AlbumMapper albumMapper;

@Override
public void addNew(AlbumAddNewDTO albumAddNewDTO) {
    // 从参数albumAddNewDTO中获取尝试添加的相册名称
    // 检查此相册名称是否已经存在:调用Mapper对象的countByName()方法,判断结果是否不为0
    // 是:名称已存在,不允许创建,抛出异常
    
    // 创建Album对象
    // 调用Album对象的setName()方法来封装数据:来自参数albumAddNewDTO
    // 调用Album对象的setDescription()方法来封装数据:来自参数albumAddNewDTO
    // 调用Album对象的setSort()方法来封装数据:来自参数albumAddNewDTO
    // 调用Mapper对象的insert()方法,插入相册数据
}
所以,在实际以上业务之前,需要先补充“检查相册名称是否已经存在”的查询功能,此查询功能需要执行的SQL语句大致是:

SELECT count(*) FROM pms_album WHERE name=?
则在AlbumMapper接口中添加抽象方法:

/**
 * 根据相册名称,统计数据的数量
 *
 * @param name 相册名称
 * @return 此名称的相册数据的数量
 */
int countByName(String name);
并在AlbumMapper.xml中配置以上抽象方法映射的SQL语句:

<!-- int countByName(String name); -->
<select id="countByName" resultType="int">
    SELECT count(*) FROM pms_album WHERE name=#{name}
</select>
完成后,还应该在AlbumMapperTests中测试:

@Test
void testCountByName() {
    String name = "测试相册001";
    int count = mapper.countByName(name);
    System.out.println("根据名称【" + name + "】统计数据完成,数量=" + count);
}
当补全Mapper层的查询功能后,再实现Service的方法,即AlbumServiceImpl中的addNew()方法:

package cn.tedu.csmall.product.service.impl;

import cn.tedu.csmall.product.mapper.AlbumMapper;
import cn.tedu.csmall.product.pojo.dto.AlbumAddNewDTO;
import cn.tedu.csmall.product.pojo.entity.Album;
import cn.tedu.csmall.product.service.IAlbumService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

/**
 * 处理相册数据的业务实现类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Service
public class AlbumServiceImpl implements IAlbumService {

    @Autowired
    AlbumMapper albumMapper;

    public AlbumServiceImpl() {
        System.out.println("创建业务实现类对象:AlbumServiceImpl");
    }

    @Override
    public void addNew(AlbumAddNewDTO albumAddNewDTO) {
        // 从参数albumAddNewDTO中获取尝试添加的相册名称
        String name = albumAddNewDTO.getName();
        // 检查此相册名称是否已经存在:调用Mapper对象的countByName()方法,判断结果是否不为0
        int count = albumMapper.countByName(name);
        if (count != 0) {
            // 是:名称已存在,不允许创建,抛出异常
            throw new RuntimeException();
        }

        // 创建Album对象
        Album album = new Album();
        // 将参数DTO中的数据复制到Album对象中
        BeanUtils.copyProperties(albumAddNewDTO, album);
        // 调用Mapper对象的insert()方法,插入相册数据
        albumMapper.insert(album);
    }

}
完成后,在src/test/java的根包下创建service.AlbumServiceTests测试类,编写并执行测试:

package cn.tedu.csmall.product.service;

import cn.tedu.csmall.product.pojo.dto.AlbumAddNewDTO;
import cn.tedu.csmall.product.service.impl.AlbumServiceImpl;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class AlbumServiceTests {

    @Autowired
    IAlbumService service;

    @Test
    void contextLoads() {
        System.out.println(service);
    }

    @Test
    void testAddNew() {
        AlbumAddNewDTO albumAddNewDTO = new AlbumAddNewDTO();
        albumAddNewDTO.setName("测试相册名称001");
        albumAddNewDTO.setDescription("测试相册简介001");
        albumAddNewDTO.setSort(88);

        try {
            service.addNew(albumAddNewDTO);
            System.out.println("添加相册成功!");
        } catch (RuntimeException e) {
            System.out.println("添加相册失败,相册名称已经被占用!");
        }
    }

}
7. 关于控制器
在编写控制器相关代码之前,需要在项目中添加spring-boot-starter-web依赖项。

spring-boot-starter-web是基于(包含)spring-boot-starter的,所以,添加spring-boot-starter-web后,就不必再显式的添加spring-boot-starter了,则可以将原本的spring-boot-starter改成spirng-boot-starter-web即可,例如:

<!-- Spring Boot的Web依赖项,包含基础依赖项 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
spring-boot-starter-web包含了一个内置的Tomcat,当启动项目时,会自动将当前项目编译、打包并部署到此Tomcat上。

在项目中,控制器表现为各个Controller,是由Spring MVC框架实现的相关数据处理功能,以上添加的spring-boot-starter-web包含了Spring MVC框架的依赖项(spring-webmvc)。

控制器的主要作用是接收请求,并响应结果。

8. 控制器:添加相册
在项目的根包下创建controller.AlbumController类,并在类上添加@RestController注解:

@RestController
public class AlbumController {
    
}
并在控制器类中定义处理请求的方法:

@Autowired
IAlbumService albumService;

// http://localhost:8080/album/add-new?name=TestAlbumName001&description=TestAlbumDescription001&sort=77
@RequestMapping("/album/add-new")
public String addNew(AlbumAddNewDTO albumAddNewDTO) {
    try {
        albumService.addNew(albumAddNewDTO);
        return "添加相册成功!";
    } catch (RuntimeException e) {
        return "添加相册失败,尝试添加的相册名称已经被占用!";
    }
}
完成后,重启项目,在浏览器中可以通过 http://localhost:8080/album/add-new?name=TestAlbumName001&description=TestAlbumDescription001&sort=77 测试访问。

作业:
实现:添加属性模板,业务规则为“属性模板的名称必须唯一”

思考题:

Java语言中异常的继承结构

RuntimeException有什么特殊之处

什么是捕获,什么是抛出,怎么样操作才算是处理异常

day06
9. 关于异常
在Java语言中,异常的继承结构大致是:

Throwable
-- Error
-- -- OutOfMemoryError(OOM)
-- Exception
-- -- IOException
-- -- -- FileNotFoundException
-- -- RuntimeException
-- -- -- NullPointerException(NPE)
-- -- -- IllegalArgumentException
-- -- -- ClassNotFoundException
-- -- -- ClassCastException
-- -- -- ArithmeticException
-- -- -- IndexOutOfBoundsException
-- -- -- -- ArrayIndexOutOfBoundsException
-- -- -- -- StringIndexOutOfBoundsException
如果调用的某个方法抛出了非RuntimeException,则必须在源代码中使用try...catch或throws语法,否则,源代码将报错!而RuntimeException不会受到这类语法的约束!

在项目中,如果需要通过抛出异常来表示某种“错误”,应该使用自定义的异常类型,否则,可能与框架或其它方法抛出的异常相同,在处理时,会模糊不清(不清楚异常到底是显式的抛出的,还是调用其它方法时由那些方法抛出的)!同时,为了避免抛出异常时有非常多复杂的语法约束,通常,自定义的异常都会是RuntimeException的子孙类异常。

另外,抛出异常时,应该对出现异常的原因进行描述,所以,在自定义异常类中,应该添加带String message参数的构造方法,且此构造方法需要调用父类的带String message参数的构造方法。

则在项目的根包下创建ex.ServiceException异常类,继承自RuntimeException,例如:

package cn.tedu.csmall.product.ex;

/**
 * 业务异常
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
public class ServiceException extends RuntimeException {

    public ServiceException(String message) {
        super(message);
    }

}
然后,在Service中,就抛出此类异常,并添加对于错误的描述文本,例如:

if (count != 0) {
    // 是:名称已存在,不允许创建,抛出异常
    throw new ServiceException("添加相册失败,尝试添加的相册名称已经被占用!");
}
在Controller中,将调用Service中的方法,可以使用try..catch包裹这段代码,对异常进行捕获并处理,例如:

try {
    albumService.addNew(albumAddNewDTO);
    return "添加相册成功!";
} catch (ServiceException e) {
    return e.getMessage();
}
10. Spring MVC框架的统一处理异常机制
由于Service在处理业务,如果视为”失败“,将抛出异常,并且,抛出时还会封装”失败“的描述文本,而Controller每次调用Service的任何方法时,都会使用try..catch进行捕获并处理,并且,处理的代码都是相同的(暂时是return e.getMessage();),这样的做法是非常固定的,导致在Controller中存在大量的try...catch(处理任何请求,调用Service时都是这样的代码)。

Spring MVC提供了统一处理异常的机制,它可以使得Controller不再处理异常,改为抛出异常,而Spring MVC在调用Controller处理请求时,会捕获Controller抛出的异常并尝试处理。

关于处理异常的方法:

访问权限:应该是public

返回值类型:参考处理请求的方法

方法名称:自定义

参数列表:至少需要添加1个异常类型的参数,表示你希望处理的异常,也是Spring MVC框架调用Controller的方法时捕获到的异常

注解:@ExceptionHandler

如果将处理异常的方法定义在某个Controller中,仅作用于当前Controller中所有处理请求的方法,对别的Controller中处理请求的方法是不生效的!Spring MVC建议将处理异常的代码写在专门的类中,并且,在类上添加@RestControllerAdvice注解,当添加此注解后,此类中处理异常的代码将作用于整个项目每次处理请求的过程中

允许存在多个处理异常的方法,只要这些方法处理的异常类型不直接冲突即可

即:不允许2个处理异常的方法都处理同一种异常

即:允许多个处理异常的方法中处理的异常存在继承关系,例如A方法处理NullPointerException,B方法处理RuntimeException

在实际处理时,推荐添加一下对Throwable处理的方法,以避免某些异常没有被处理,导致响应500错误。

关于处理异常的类,暂定为:

package cn.tedu.csmall.product.ex.handler;

import cn.tedu.csmall.product.ex.ServiceException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
 * 全局异常处理器
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    public GlobalExceptionHandler() {
        System.out.println("创建全局异常处理器对象:GlobalExceptionHandler");
    }

    @ExceptionHandler
    public String handleServiceException(ServiceException e) {
        log.debug("捕获到ServiceException:{}", e.getMessage());
        return e.getMessage();
    }

    @ExceptionHandler
    public String handleThrowable(Throwable e) {
        log.debug("捕获到Throwable:{}", e.getMessage());
        e.printStackTrace(); // 强烈建议
        return "服务器运行过程中出现未知错误,请联系系统管理员!";
    }

}
11. 关于控制器类Controller
在Spring MVC框架,使用控制器(Controller)来接收请求、响应结果。

在根包下的任何一个类,添加了@Controller注解,就会被视为控制器类。

在默认情况下,控制器类中处理请求的方法,响应的结果是”视图组件的名称“,即:控制器对请求处理后,将返回视图名称,Spring MVC还会根据视图名称来确定视图组件,并且,由此视图组件来响应!这不是前后端分离的做法!

提示:如果需要了解传统的Spring MVC不使用前后端分离的做法,可以参考扩展视频教程《基于XML配置的Spring MVC框架》。

可以在处理请求的方法上添加@ResponseBody注解,则此方法处理请求后,返回的值就是响应到客户端的数据!这种做法通常称之为”响应正文“。

@ResponseBody注解还可以添加在控制器类上,则此控制器类中所有处理请求的方法都将是”响应正文“的!

另外,还可以使用@RestController取代@Controller和@ResponseBody,关于@RestController的源代码:

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Controller
@ResponseBody
public @interface RestController {
    @AliasFor(
        annotation = Controller.class
    )
    String value() default "";
}
可以看到,在@RestController的源代码上,添加了@Controller和@ResponseBody,所以,可以把@RestController称之为”组合注解“,而@Controller和@ResponseBody可以称之为@RestController的”元注解“。

与之类似的,在Spring MVC框架中,添加了@ControllerAdvice注解的类中的特定方法,将可以作用于每次处理请求的过程中,但是,仅仅只使用@ControllerAdvice时,并不是前后端分离的做法,还应该结合@ResponseBody一起使用,或,直接改为使用@RestControllerAdvice,关于@RestControllerAdvice的源代码片段:

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ControllerAdvice
@ResponseBody
public @interface RestControllerAdvice {
    // 暂不关心内部源代码   
}
12. 关于控制器类中处理请求的方法
关于处理请求的方法:

访问权限:应该使用public

返回值类型:暂时使用String

方法名称:自定义

参数列表:按需设计,即需要客户端提交哪些请求参数,在此方法的参数列表中就设计哪些参数,如果参数的数量有多个,并且多个参数具有相关性,则可以封装,并使用封装的类型作为方法的参数,另外,可以按需添加Spring容器中的其它相关数据作为参数,例如HttpServletRequest、HttpServletResponse、HttpSession等

异常:如果有,全部抛出

注解:需要通过@RequestMapping系列注解配置请求路径

13. 关于@RequestMapping
在Spring MVC框架中,@RequestMapping的主要作用是:配置请求路径与处理请求的方法的映射关系。

此注解可以添加在控制类上,也可以添加在处理请求的方法上。

通常,会在控制器类和处理请求的方法上都配置此注解,例如:

@RestController
@RequestMapping("/albums")
public class AlbumController {

    @RequestMapping("/add-new")
    public String addNew(AlbumAddNewDTO albumAddNewDTO) {
        // 暂不关心方法内部代码
    }

}
以上配置的路径将是:http://主机名:端口号/类上配置路径/方法上配置的路径,即:http://localhost:8080/albums/add-new

并且,在使用`@RequestMapping配置路径时,路径值两端多余的 / 是会被自动处理的,在类上的配置值和方法上的配置值中间的 / 也是自动处理的,例如,以下配置是等效的:

类上的配置值    方法上的配置值
/albums    /add-new
/albums    add-new
/albums/    /add-new
/albums/    add-new
albums    /add-new
albums    add-new
albums/    /add-new
albums/    add-new
尽管以上8种组合配置是等效的,但仍推荐使用第1种。

在@RequestMapping注解的源代码中,有:

@AliasFor("path")
String[] value() default {};
以上源代码表示在此注解中存在名为value的属性,并且,此属性的值类型是String[],例如,你可以配置@RequestMapping(value = {"xxx", "zzz"}),此属性的默认值是{}(空数组)。

在所有注解中,value是默认的属性,所以,如果你需要配置的注解参数是value属性,且只配置这1个属性时,并不需要显式的指定属性名!例如:

@RequestMapping(value = {"xxx", "zzz"})
@RequestMapping({"xxx", "zzz"})
以上2种配置方式是完全等效的!

在所有注解中,如果某个属性的值是数组类型的,但是,你只提供1个值(也就是数组中只有1个元素),则这个值并不需要使用大括号框住!例如:

@RequestMapping(value = {"xxx"})
@RequestMapping(value = "xxx")
以上2种配置方式是完全等效的!

在源代码中,关于value属性的声明上还有@AliasFor("path"),它表示”等效于“的意思,也就是说,value属性与另一个名为path的属性是完全等效的!

在@RequestMapping的源代码中,还有:

RequestMethod[] method() default {};
以上属性的作用是配置并限制请求方式,例如,配置为:

@RequestMapping(value = "/add-new", method = RequestMethod.POST)
按照以上配置,以上请求路径只允许使用POST方式提交请求!

强烈建议在正式运行的代码中,明确的配置并限制各请求路径的请求方式!

另外,在Spring MVC框架中,还定义了基于@RequestMapping的相关注解:

@GetMapping

@PostMapping

@PutMapping

@DeleteMapping

@PatchMapping

所以,在开发实践中,通常:在控制器类上使用@RequestMapping配置请求路径的前缀部分,在处理请求的方法上使用@GetMapping、@PostMapping这类限制了请求方式的注解。

14. 根据id删除相册
目前,在Mapper层已经实现了此功能!则只需要开发Service层和Controller层。

在IAlbumService接口中添加抽象方法:

void delete(Long id);
在AlbumServiceImpl类中实现以上方法:

@Override
public void delete(Long id) {
    log.debug("开始处理【删除相册】的业务,参数:{}", id);
    // 调用Mapper对象的getDetailsById()方法执行查询
    AlbumStandardVO queryResult = albumMapper.getStandardById(id);
    // 判断查询结果是否为null
    if (queryResult == null) {
        // 是:无此id对应的数据,抛出异常
        String message = "删除相册失败,尝试访问的数据不存在!";
        log.warn(message);
        throw new ServiceException(message);
    }

    // 调用Mapper对象的deleteById()方法执行删除
    log.debug("即将删除相册数据……");
    albumMapper.deleteById(id);
    log.debug("删除相册,完成!");
}
在AlbumServiceTests类中编写并执行测试:

@Test
void testDelete() {
    Long id = 14L;

    try {
        service.delete(id);
        System.out.println("删除相册成功!");
    } catch (ServiceException e) {
        System.out.println(e.getMessage());
    }
}
在AlbumController中添加处理请求的方法:

// http://localhost:8080/albums/delete?id=1
@RequestMapping("/delete")
public String delete(Long id) {
    log.debug("开始处理【删除相册】的请求,参数:{}", id);
    albumService.delete(id);
    return "OK";
}
作业
请完成以下功能的Service层、Controller层,并且,Service层需要完成测试:

根据id删除属性模板,业务规则:数据必须存在

添加品牌,业务规则:品牌名称必须唯一

根据id删除品牌,业务规则:数据必须存在

添加类别,业务规则:类别名称必须唯一

根据id删除类别,业务规则:数据必须存在

day07
15. 在Spring MVC中接收请求参数
如果客户端提交的请求参数数量较少,且参数之间没有相关性,则可以选择将各请求参数声明为处理请求的方法的参数,并且,参数的类型可以按需设计。

如果客户端提交的请求参数略多(达到2个或以上),且参数之间存在相关性,则应该将这些参数封装到自定义的POJO类型中,并且,使用此POJO类型作为处理请求的方法的参数,同样,POJO类中的属性的类型可以按需设计。

关于请求参数的值:

如果客户端没有提交对应名称的请求参数,则方法的参数值为null

如果客户端提交了对应名称的请求参数,但是没有值,则方法的参数值为空字符串(""),如果方法的参数是需要将字符串转换为别的格式,但无法转换,则参数值为null,例如声明为Long类型时

如果客户端提交对应名称的请求参数,且参数有正确的值,则方法的参数值为就是请求参数值,如果方法的参数是需要将字符串转换为别的格式,但无法转换,则会抛出异常

另外,还推荐将某些具有唯一性的(且不涉及隐私)参数设计到URL中,使得这些参数值是URL的一部分!例如:

https://blog.csdn.net/weixin_407563/article/details/854745877
https://blog.csdn.net/qq_3654243299/article/details/847462823
Spring MVC框架支持在设计URL时,使用{名称}格式的占位符,实际处理请求时,此占位符位置是任意值都可以匹配得到!

例如,将URL设计为:

@RequestMapping("/delete/{id}")
在处理请求的方法的参数列表中,用于接收占位符的参数,需要添加@PathVariable注解,例如:

public String delete(@PathVariable Long id) {
    // 暂不关心方法内部的实现   
}
如果{}占位符中的名称,与处理请求的方法的参数名称不匹配,则需要在@PathVariable注解上配置占位符中的名称,例如:

@RequestMapping("/delete/{albumId}")
public String delete(@PathVariable("albumId") Long id) {
    // 暂不关心方法内部的实现   
}
在配置占位符时,可以在占位符名称的右侧,可以添加冒号,再加上正则表达式,对占位符的值的格式进行限制,例如:

@RequestMapping("/delete/{id:[0-9]+}")
如果按照以上配置,仅当占位符位置的值是纯数字才可以匹配到此URL!

并且,多个不冲突有正则表达式的占位符配置的URL是可以共存的!例如:

@RequestMapping("/delete/{id:[a-z]+}")
以上表示的是“占位符的值是纯字母的”,是可以与以上“占位符的值是纯数字的”共存!

另外,某个URL的设计没有使用占位符,与使用了占位符的,是允许共存的!例如:

@RequestMapping("/delete/test")
Spring MVC会优先匹配没有使用占位符的URL,再尝试匹配使用了占位符的URL。

16. 关于RESTful
RESTful也可以简称为REST,是一种设计软件的风格。

RESTful既不是规定,也不是规范。

RESTful风格的典型表现主要有:

处理请求后是响应正文的

将具有唯一性的参数值设计在URL中

根据请求访问数据的方式,使用不用的请求方式

如果尝试添加数据,使用POST请求方式

如果尝试删除数据,使用DELETE请求方式

如果尝试修改数据,使用PUT请求方式

如果尝试查询数据,使用GET请求方式

这种做法在复杂的业务系统中并不适用

在设计RESTful风格的URL时,建议的做法是:

查询某数据的列表:/数据类型的复数,并使用GET请求方式,例如/albums

查询某1个数据(通常根据id):/数据类型的复数/id值,并使用GET请求方式,例如/albums/9527

对某1个数据进行操作(增、删、改):/数据类型的复数/id值/操作,并使用POST请求方式,,例如/albums/9527/delete

17. 关于响应正文的结果
通常,需要使用自定义类,作为处理请求的方法、处理异常的方法的返回值类型。

响应到客户端的数据中,应该包含“业务状态码”,以便于客户端迅速判断操作成功与否,为了规范的管理业务状态码的值,在根包下创建web.ServiceCode枚举类型:

package cn.tedu.csmall.product.web;

/**
 * 业务状态码的枚举
 * 
 * @author java@tedu.cn
 * @version 0.0.1
 */
public enum ServiceCode {

    OK(20000),
    ERR_NOT_FOUND(40400),
    ERR_CONFLICT(40900);

    private Integer value;

    ServiceCode(Integer value) {
        this.value = value;
    }

    public Integer getValue() {
        return value;
    }

}
并且,修改ServiceException异常类的代码,要求此类异常的对象中包含业务状态码,可以通过构造方法进行限制:

package cn.tedu.csmall.product.ex;

import cn.tedu.csmall.product.web.ServiceCode;

/**
 * 业务异常
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
public class ServiceException extends RuntimeException {

    private ServiceCode serviceCode;

    public ServiceException(ServiceCode serviceCode, String message) {
        super(message);
        this.serviceCode = serviceCode;
    }

    public ServiceCode getServiceCode() {
        return serviceCode;
    }
}
然后,原本业务逻辑层中抛出异常的代码将报错,需要在创建异常对象时传入业务状态码参数,例如:

throw new ServiceException(ServiceCode.ERR_CONFLICT,
                    "添加相册失败,尝试添加的相册名称已经被占用!");
接下来,还需要使用自定义类型,表示响应到客户端的数据,则在根包下创建web.JsonResult类:

package cn.tedu.csmall.product.web;

import lombok.Data;

import java.io.Serializable;

@Data
public class JsonResult implements Serializable {

    /**
     * 业务状态码的值
     */
    private Integer state;
    /**
     * 操作失败时的提示文本
     */
    private String message;

    public static JsonResult ok() {
        JsonResult jsonResult = new JsonResult();
        jsonResult.state = ServiceCode.OK.getValue();
        return jsonResult;
    }

    public static JsonResult fail(ServiceCode serviceCode, String message) {
        JsonResult jsonResult = new JsonResult();
        jsonResult.state = serviceCode.getValue();
        jsonResult.message = message;
        return jsonResult;
    }

}
在实际处理请求和异常时,Spring MVC框架会将方法返回的JsonResult对象转换成JSON格式的字符串。

例如:处理请求的代码:

@RequestMapping("/add-new")
public JsonResult addNew(AlbumAddNewDTO albumAddNewDTO) {
    log.debug("开始处理【添加相册】的请求,参数:{}", albumAddNewDTO);
    albumService.addNew(albumAddNewDTO);
    return JsonResult.ok();
}
例如:处理异常的代码:

@ExceptionHandler
public JsonResult handleServiceException(ServiceException e) {
    log.debug("捕获到ServiceException:{}", e.getMessage());
    return JsonResult.fail(e.getServiceCode(), e.getMessage());
}
通常,不需要在JSON结果中包含为null的属性,所以,可以在application.properties / application.yml中进行配置:

application.properties配置示例

# JSON结果中将不包含为null的属性
spring.jackson.default-property-inclusion=non_nul
application.yml配置示例

# Spring相关配置
spring:
  # Jackson框架相关配置
  jackson:
    # JSON结果中是否包含为null的属性的默认配置
    default-property-inclusion: non_null
18. 实现前后端交互
目前,后端(服务器端)项目使用默认端口8080,建议调整,可以通过配置文件中的server.port属性来指定。

例如,在application-dev.yml中添加配置:

# 服务端口
server:
  port: 9080
再次启动项目,通过启动时的日志,可以看到后端项目启动在9080端口。

然后,还需要在后端项目中配置允许跨域访问,则需要在实现了WebMvcConfigurer接口的配置类中,通过重写addCorsMappings()方法进行配置!

则在根包下创建config.WebMvcConfiguration类,实现WebMvcConfigurer接口,添加@Configuration注解,并重写方法配置允许跨域访问:

package cn.tedu.csmall.product.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * Spring MVC的配置类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {

    public WebMvcConfiguration() {
        System.out.println("创建配置类:WebMvcConfiguration");
    }

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOriginPatterns("*")
                .allowedMethods("*")
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(3600);
    }

}
day08
19. 创建passport项目
创建新的项目,相关参数:

项目仓库:https://gitee.com/qschengheng2022/jsd2206-csmall-passport-teacher.git

项目名称:jsd2206-csmall-passport-teacher

Group:cn.tedu

Artifact:csmall-passport

Package:cn.tedu.csmall.passport

当项目创建成功后,需要先创建本项目所需的数据库mall_ams,然后导入SQL脚本,以创建数据表,及导入测试使用的数据,并在IntelliJ IDEA中配置Database面板。

接下来,调整当前项目的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.5.9</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <!-- 当前项目的参数 -->
    <groupId>cn.tedu</groupId>
    <artifactId>csmall-passport</artifactId>
    <version>0.0.1</version>

    <!-- 属性配置 -->
    <properties>
        <java.version>1.8</java.version>
    </properties>

    <!-- 当前项目使用的依赖项 -->
    <!-- scope > test:此依赖项仅用于测试,则此依赖项不会被打包,并且这些依赖项在src/main中不可用 -->
    <!-- scope > runtime:此依赖项仅在运行时需要,即编写代码时并不需要此依赖项 -->
    <!-- scope > provided:在执行(启动项目,或编译源代码)时,需要运行环境保证此依赖项一定是存在的 -->
    <dependencies>
        <!-- Spring Boot的Web依赖项,包含基础依赖项 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- Mybatis整合Spring Boot的依赖项 -->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.2.2</version>
        </dependency>
        <!-- MySQL的依赖项 -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <!-- Lombok的依赖项,主要用于简化POJO类的编写 -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.20</version>
            <scope>provided</scope>
        </dependency>
        <!-- Spring Boot测试的依赖项 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

</project>
当添加以上依赖项后,项目暂时无法正常启动,也无法通过任何测试,必须配置连接数据库的相关参数!

先将application.properties重命名为application.yml,并创建出application-dev.yml,在application-dev.yml中配置:

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/mall_ams?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Chongqing
    username: root
    password: root
并且,在application.yml激活application-dev.yml中的配置:

spring:
  profiles:
    active: dev
完成后,可以执行默认的测试类下的contextLoads()测试方法,应该可以通过测试。然后,在测试类中添加:

@Autowired
DataSource dataSource;

@Test
void testGetConnection() throws Exception {
    dataSource.getConnection();
    System.out.println("当前配置可以成功的连接到数据库!");
}
20. 添加管理员--Mapper层
使用Mybatis实现数据库编程时,必须要编写的配置:

在配置类上使用@MapperScan配置接口文件的根包

在配置文件中使用mybatis.mapper-locations属性配置XML文件的位置

则先在根包下创建config.MybatisConfiguration类,在类上添加@Configuration注解,并在类上添加@MapperScan("cn.tedu.csmall.passport.mapper"),例如:

package cn.tedu.csmall.passport.config;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;

@Configuration
@MapperScan("cn.tedu.csmall.passport.mapper")
public class MybatisConfiguration {
}
在application.yml中添加配置:

mybatis:
  mapper-locations: classpath:mapper/*.xml
接下来,在根包下创建pojo.entity.Admin实体类:

package cn.tedu.csmall.passport.pojo.entity;

import lombok.Data;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
 * 管理员的实体类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Data
public class Admin implements Serializable {

    /**
     * 数据id
     */
    private Long id;

    /**
     * 用户名
     */
    private String username;

    /**
     * 密码(密文)
     */
    private String password;

    /**
     * 昵称
     */
    private String nickname;

    /**
     * 头像URL
     */
    private String avatar;

    /**
     * 手机号码
     */
    private String phone;

    /**
     * 电子邮箱
     */
    private String email;

    /**
     * 描述
     */
    private String description;

    /**
     * 是否启用,1=启用,0=未启用
     */
    private Integer enable;

    /**
     * 最后登录IP地址(冗余)
     */
    private String lastLoginIp;

    /**
     * 累计登录次数(冗余)
     */
    private Integer loginCount;

    /**
     * 最后登录时间(冗余)
     */
    private LocalDateTime gmtLastLogin;

    /**
     * 数据创建时间
     */
    private LocalDateTime gmtCreate;

    /**
     * 数据最后修改时间
     */
    private LocalDateTime gmtModified;

}
在根包下创建mapper.AdminMapper接口,并在接口中添加抽象方法:

/**
 * 处理管理员数据的Mapper接口
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Repository
public interface AdminMapper {

    /**
     * 插入管理员数据
     *
     * @param admin 管理员数据
     * @return 受影响的行数
     */
    int insert(Admin admin);
    
}
在src/main/resources下创建mapper文件夹,并在mapper文件夹下粘贴得到AdminMapper.xml,配置以上抽象方法对应的SQL语句:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="cn.tedu.csmall.passport.mapper.AdminMapper">

    <!-- int insert(Admin admin); -->
    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO ams_admin (
            username, password, nickname, avatar, phone,
            email, description, enable, last_login_ip, login_count,
            gmt_last_login
        ) VALUES (
            #{username}, #{password}, #{nickname}, #{avatar}, #{phone},
            #{email}, #{description}, #{enable}, #{lastLoginIp}, #{loginCount},
            #{gmtLastLogin}
        )
    </insert>
    
</mapper>
完成后,在src/test/java的根包下创建mapper.AdminMapperTests测试类,编写并执行测试:

@Slf4j
@SpringBootTest
public class AdminMapperTests {

    @Autowired
    AdminMapper mapper;

    @Test
    void testInsert() {
        Admin admin = new Admin();
        admin.setUsername("wangkejing");
        admin.setPassword("123456");
        admin.setPhone("13800138001");
        admin.setEmail("wangkejing@baidu.com");

        log.debug("插入数据之前,参数:{}", admin);
        int rows = mapper.insert(admin);
        log.debug("插入数据完成,受影响的行数:{}", rows);
        log.debug("插入数据之后,参数:{}", admin);
    }
    
}
在编写Service层之前,可以先分析一下“添加管理员”的业务规则,目前,应该设置的规则有:

用户名不允许重复

手机号码不允许重复

电子邮箱不允许重复

其它规则,暂不考虑

以上“不允许重复”的规则,都可以通过统计查询来实现,例如:

select count(*) from ams_admin where username=?;
select count(*) from ams_admin where phone=?;
select count(*) from ams_admin where email=?;
则在AdminMapper接口中添加:

/**
 * 根据用户名统计管理员的数量
 *
 * @param username 用户名
 * @return 匹配用户名的管理员的数据
 */
int countByUsername(String username);

/**
 * 根据手机号码统计管理员的数量
 *
 * @param phone 手机号码
 * @return 匹配手机号码的管理员的数据
 */
int countByPhone(String phone);

/**
 * 根据电子邮箱统计管理员的数量
 *
 * @param email 电子邮箱
 * @return 匹配电子邮箱的管理员的数据
 */
int countByEmail(String email);
并在AdminMapper.xml中配置以上3个抽象方法对应的3个<select>标签:

<!-- int countByUsername(String username); -->
<select id="countByUsername" resultType="int">
    SELECT count(*) FROM ams_admin WHERE username=#{username}
</select>

<!-- int countByPhone(String phone); -->
<select id="countByPhone" resultType="int">
    SELECT count(*) FROM ams_admin WHERE phone=#{phone}
</select>

<!-- int countByEmail(String email); -->
<select id="countByEmail" resultType="int">
    SELECT count(*) FROM ams_admin WHERE email=#{email}
</select>
最后,在AdminMapperTests中编写并执行测试:

@Test
void testCountByUsername() {
    String username = "wangkejing";
    int count = mapper.countByUsername(username);
    log.debug("根据用户名【{}】统计管理员账号的数量:{}", username, count);
}

@Test
void testCountByPhone() {
    String phone = "13800138001";
    int count = mapper.countByPhone(phone);
    log.debug("根据手机号码【{}】统计管理员账号的数量:{}", phone, count);
}

@Test
void testCountByEmail() {
    String email = "wangkejing@baidu.com";
    int count = mapper.countByEmail(email);
    log.debug("根据电子邮箱【{}】统计管理员账号的数量:{}", email, count);
}
21. 添加管理员--Service层
在根包下创建pojo.dto.AdminAddNewDTO类,封装需要由客户端提交的参数:

package cn.tedu.csmall.passport.pojo.dto;

import lombok.Data;

import java.io.Serializable;

/**
 * 添加管理员的DTO类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Data
public class AdminAddNewDTO implements Serializable {

    /**
     * 用户名
     */
    private String username;

    /**
     * 密码(原文)
     */
    private String password;

    /**
     * 昵称
     */
    private String nickname;

    /**
     * 头像URL
     */
    private String avatar;

    /**
     * 手机号码
     */
    private String phone;

    /**
     * 电子邮箱
     */
    private String email;

    /**
     * 描述
     */
    private String description;

    /**
     * 是否启用,1=启用,0=未启用
     */
    private Integer enable;

}
在根包下创建service.IAdminService接口,并在接口中添加抽象方法:

public interface IAdminService {
    void addNew(AdminAddNewDTO adminAddNewDTO);
}
在根包下创建service.impl.AdminServiceImpl类,实现以上接口,并在类上添加@Service注解,在类中自动装配AdminMapper对象:

@Service
public class AdminServiceImpl implements IAdminService {
    @Autowired
    AdminMapper adminMapper;
}
在实现接口中的方法之前,需要将前序项目(csmall-product)的ServiceCode和ServiceException复制到当前项目相同的位置。

并在以上实现类中实现接口中的方法:

public void addNew(AdminAddNewDTO adminAddNewDTO) {
    // 从参数对象中获取username
    // 调用adminMapper的countByUsername()方法执行统计查询
    // 判断统计结果是否不等于0
    // 是:抛出异常
    
    // 从参数对象中获取手机号码
    // 调用adminMapper的countByPhone()方法执行统计查询
    // 判断统计结果是否不等于0
    // 是:抛出异常
    
    // 从参数对象中获取电子邮箱
    // 调用adminMapper的countByEmail()方法执行统计查询
    // 判断统计结果是否不等于0
    // 是:抛出异常
    
    // 创建Admin对象
    // 通过BeanUtils.copyProperties()方法将参数对象的各属性值复制到Admin对象中
    // 调用adminMapper的insert()方法插入数据
}
实际代码为:

package cn.tedu.csmall.passport.service.impl;

import cn.tedu.csmall.passport.ex.ServiceException;
import cn.tedu.csmall.passport.mapper.AdminMapper;
import cn.tedu.csmall.passport.pojo.dto.AdminAddNewDTO;
import cn.tedu.csmall.passport.pojo.entity.Admin;
import cn.tedu.csmall.passport.service.IAdminService;
import cn.tedu.csmall.passport.web.ServiceCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

/**
 * 处理管理员数据的业务实现类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Slf4j
@Service
public class AdminServiceImpl implements IAdminService {

    @Autowired
    AdminMapper adminMapper;

    public AdminServiceImpl() {
        System.out.println("创建业务实现类:AdminServiceImpl");
    }

    @Override
    public void addNew(AdminAddNewDTO adminAddNewDTO) {
        log.debug("开始处理【添加管理员】的业务,参数:{}", adminAddNewDTO);
        log.debug("即将检查用户名是否被占用……");
        {
            // 从参数对象中获取username
            String username = adminAddNewDTO.getUsername();
            // 调用adminMapper的countByUsername()方法执行统计查询
            int count = adminMapper.countByUsername(username);
            // 判断统计结果是否不等于0
            if (count != 0) {
                // 是:抛出异常
                String message = "添加管理员失败,用户名【" + username + "】已经被占用!";
                log.debug(message);
                throw new ServiceException(ServiceCode.ERR_CONFLICT, message);
            }
        }

        log.debug("即将检查手机号码是否被占用……");
        {
            // 从参数对象中获取手机号码
            String phone = adminAddNewDTO.getPhone();
            // 调用adminMapper的countByPhone()方法执行统计查询
            int count = adminMapper.countByPhone(phone);
            // 判断统计结果是否不等于0
            if (count != 0) {
                // 是:抛出异常
                String message = "添加管理员失败,手机号码【" + phone + "】已经被占用!";
                log.debug(message);
                throw new ServiceException(ServiceCode.ERR_CONFLICT, message);
            }
        }

        log.debug("即将检查电子邮箱是否被占用……");
        {
            // 从参数对象中获取电子邮箱
            String email = adminAddNewDTO.getEmail();
            // 调用adminMapper的countByEmail()方法执行统计查询
            int count = adminMapper.countByEmail(email);
            // 判断统计结果是否不等于0
            if (count != 0) {
                // 是:抛出异常
                String message = "添加管理员失败,电子邮箱【" + email + "】已经被占用!";
                log.debug(message);
                throw new ServiceException(ServiceCode.ERR_CONFLICT, message);
            }
        }

        // 创建Admin对象
        Admin admin = new Admin();
        // 通过BeanUtils.copyProperties()方法将参数对象的各属性值复制到Admin对象中
        BeanUtils.copyProperties(adminAddNewDTO, admin);
        // TODO 从Admin对象中取出密码,进行加密处理,并将密文封装回Admin对象中
        // 补全Admin对象中的属性值:loginCount >>> 0
        admin.setLoginCount(0);
        // 调用adminMapper的insert()方法插入数据
        log.debug("即将插入管理员数据,参数:{}", admin);
        adminMapper.insert(admin);
    }

}
完成后,在src/test/java下的根包下创建service.AdminServiceTests测试类,编写并执行测试(在测试方法中记得使用try...catch):

package cn.tedu.csmall.passport.service;

import cn.tedu.csmall.passport.ex.ServiceException;
import cn.tedu.csmall.passport.pojo.dto.AdminAddNewDTO;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@Slf4j
@SpringBootTest
public class AdminServiceTests {

    @Autowired
    IAdminService service;

    @Test
    void testAddNew() {
        AdminAddNewDTO adminAddNewDTO = new AdminAddNewDTO();
        adminAddNewDTO.setUsername("wangkejing3");
        adminAddNewDTO.setPassword("123456");
        adminAddNewDTO.setPhone("13800138003");
        adminAddNewDTO.setEmail("wangkejing3@baidu.com");

        try {
            service.addNew(adminAddNewDTO);
            log.debug("添加管理员成功!");
        } catch (ServiceException e) {
            log.debug("{}", e.getMessage());
        }
    }
}
22. 添加管理员--Controller层
由于非常推荐自行指定服务端口,则在application-dev.yml中添加配置:

# 服务端口
server:
  port: 9081
将前序项目中的JsonResult类复制到当前项目同样位置。

在根包下创建controller.AdminController类,添加@RestController注解和@RequestMapping("/admins")注解,并在类中添加处理请求的方法:

package cn.tedu.csmall.passport.controller;

import cn.tedu.csmall.passport.pojo.dto.AdminAddNewDTO;
import cn.tedu.csmall.passport.service.IAdminService;
import cn.tedu.csmall.passport.web.JsonResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequestMapping("/admins")
public class AdminController {

    @Autowired
    IAdminService adminService;

    public AdminController() {
        log.info("创建控制器类:AdminController");
    }

    // http://localhost:9081/admins/add-new?username=aa&phone=bb&email=cc
    @RequestMapping("/add-new")
    public JsonResult addNew(AdminAddNewDTO adminAddNewDTO) {
        log.debug("开始处理【添加管理员】的请求,参数:{}", adminAddNewDTO);
        adminService.addNew(adminAddNewDTO);
        return JsonResult.ok();
    }

}
在application.yml中添加配置,使得为null的属性不会显示在响应的JSON数据中:

spring:
  # Jackson框架相关配置
  jackson:
    # JSON结果中是否包含为null的属性的默认配置
    default-property-inclusion: non_null
【处理异常】

完成后,重启项目,可以通过 http://localhost:9081/admins/add-new?username=aa&phone=bb&email=cc 测试访问。

23. 关于Knife4j框架
Knife4j是一款基于Swagger 2的在线API文档框架。

使用Knife4j,需要:

添加Knife4j的依赖

当前建议使用的Knife4j版本,只适用于Spring Boot 2.6以下版本,不含Spring Boot 2.6

在主配置文件(application.yml)中开启Knife4j的增强模式

必须在主配置文件中进行配置,不要配置在个性化配置文件中

添加Knife4j的配置类,进行必要的配置

必须指定控制器的包

关于依赖项的代码:

<!-- Knife4j Spring Boot:在线API -->
<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-spring-boot-starter</artifactId>
    <version>2.0.9</version>
</dependency>
在application.yml中添加配置:

knife4j:
  enable: true
在根包下创建config.Knife4jConfiguration配置类:

package cn.tedu.csmall.passport.config;

import com.github.xiaoymin.knife4j.spring.extension.OpenApiExtensionResolver;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2WebMvc;

/**
 * Knife4j配置类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Slf4j
@Configuration
@EnableSwagger2WebMvc
public class Knife4jConfiguration {

    /**
     * 【重要】指定Controller包路径
     */
    private String basePackage = "cn.tedu.csmall.passport.controller";
    /**
     * 分组名称
     */
    private String groupName = "passport";
    /**
     * 主机名
     */
    private String host = "http://java.tedu.cn";
    /**
     * 标题
     */
    private String title = "酷鲨商城在线API文档--管理员管理";
    /**
     * 简介
     */
    private String description = "酷鲨商城在线API文档--管理员管理";
    /**
     * 服务条款URL
     */
    private String termsOfServiceUrl = "http://www.apache.org/licenses/LICENSE-2.0";
    /**
     * 联系人
     */
    private String contactName = "Java教学研发部";
    /**
     * 联系网址
     */
    private String contactUrl = "http://java.tedu.cn";
    /**
     * 联系邮箱
     */
    private String contactEmail = "java@tedu.cn";
    /**
     * 版本号
     */
    private String version = "1.0.0";

    @Autowired
    private OpenApiExtensionResolver openApiExtensionResolver;

    public Knife4jConfiguration() {
        log.debug("加载配置类:Knife4jConfiguration");
    }

    @Bean
    public Docket docket() {
        String groupName = "1.0.0";
        Docket docket = new Docket(DocumentationType.SWAGGER_2)
                .host(host)
                .apiInfo(apiInfo())
                .groupName(groupName)
                .select()
                .apis(RequestHandlerSelectors.basePackage(basePackage))
                .paths(PathSelectors.any())
                .build()
                .extensions(openApiExtensionResolver.buildExtensions(groupName));
        return docket;
    }

    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title(title)
                .description(description)
                .termsOfServiceUrl(termsOfServiceUrl)
                .contact(new Contact(contactName, contactUrl, contactEmail))
                .version(version)
                .build();
    }

}
完成后,启动项目,通过 http://localhost:9081/doc.html 即可访问在线API文档!

关于Knife4j的在线API文档,可以通过一系列注解来配置此文件的显示:

@Api:添加在控制器类上,通过此注解的tags属性,可以指定模块名称,并且,在指定名称时,建议在名称前添加数字作为序号,Knife4j会根据这些数字将各模块升序排列,例如:

@Api(tags = "01. 管理员管理模块")
@ApiOpearation:添加在控制器类中处理请求的方法上,通过此注解的value属性,可以指定业务/请求资源的名称,例如:

@ApiOperation("添加管理员")
@ApiOperationSupport:添加在控制器类中处理请求的方法上,通过此注解的order属性(int),可以指定排序序号,Knife4j会根据这些数字将各业务/请求资源升序排列,例如:

@ApiOperationSupport(order = 100)
24. 关于@RequestBody
在Spring MVC框架中,在处理请求的方法的参数前:

当添加了@RequestBody注解,则客户端提交的请求参数必须是对象格式的,例如:

{
    "name": "小米11的相册",
    "description": "小米11的相册的简介",
    "sort": 88
}
如果客户端提交的数据不是对象,而是FormData格式的,在接收到请求时将报错:

org.springframework.web.HttpMediaTypeNotSupportedException: Content type 'application/x-www-form-urlencoded;charset=UTF-8' not supported
当没有添加@RequestBody注解,则客户端提交的请求参数必须是FormData格式的,例如:

name=小米11的相册&description=小米11的相册的简介&sort=88
如果客户端提交的数据不是FormData格式的,而是对象,则无法接收到参数(不会报错,控制器中各参数值为null)

另外,Knife4j框架的调试界面中,如果是对象格式的参数(使用了@RequestBody),将不会显示各请求参数的输入框,而是提供一个JSON字符串供编辑,如果是FormData格式的参数(没有使用@RequestBody),则会显示各请求参数对应的输入框。

通常,更建议使用FormData格式的请求参数!则在控制器处理请求的方法的参数上不需要添加@RequestBody注解!

在Vue脚手架项目中,为了更便捷的使用FormData格式的请求参数,可以在项目中使用qs框架,此框架的工具可以轻松的将JavaScript对象转换成FormData格式!

则在前端的Vue脚手架项目中,先安装qs:

npm i qs -S
然后,在main.js中添加配置:

import qs from 'qs';

Vue.prototype.qs = qs;
最后,在提交请求之前,可以将对象转换成FormData格式,例如:

let formData = this.qs.stringify(this.ruleForm);
console.log('formData:' + formData);
day09
25. 关于Knife4j框架(续)
如果处理请求时,参数是封装的POJO类型,需要对各请求参数进行说明时,应该在此POJO类型的各属性上使用@ApiModelProperty注解进行配置,通过此注解的value属性配置请求参数的名称,通过requeired属性配置是否必须提交此请求参数(并不具备检查功能),例如:

@Data
public class AlbumAddNewDTO implements Serializable {

    /**
     * 相册名称
     */
    @ApiModelProperty(value = "相册名称", example = "小米10的相册", required = true)
    private String name;

    /**
     * 相册简介
     */
    @ApiModelProperty(value = "相册简介", example = "小米10的相册的简介", required = true)
    private String description;

    /**
     * 排序序号
     */
    @ApiModelProperty(value = "排序序号", example = "98", required = true)
    private Integer sort;

}
需要注意,@ApiModelProperty还可以用于配置响应时的各数据!

对于处理请求的方法的参数列表中那些未封装的参数(例如String、Long),需要在处理请求的方法上使用@ApiImplicitParam注解来配置参数的说明,并且,必须配置name属性,此属性的值就是方法的参数名称,使得此注解的配置与参数对应上,然后,再通过value属性对参数进行说明,还要注意,此属性的required属性表示是否必须提交此参数,默认为false,即使是用于配置路径上的占位符参数,一旦使用此注解,required默认也会是false,则需要显式的配置为true,另外,还可以通过dataType配置参数的数据类型,如果未配置此属性,在API文档中默认显示为string,可以按需修改为int、long等。例如:

@ApiImplicitParam(name = "id", value = "相册id", required = true, dataType = "long")
@PostMapping("/{id:[0-9]+}/delete")
public JsonResult delete(@PathVariable Long id) {
    // 暂不关心方法内部的代码
}
如果处理请求的方法上有多个未封装的参数,则需要使用多个@ApiImplicitParam注解进行配置,并且,这多个@ApiImplicitParam注解需要作为@ApiImplicitParams注解的参数,例如:

@ApiImplicitParams({
    @ApiImplicitParam(xxx),
    @ApiImplicitParam(xxx),
    @ApiImplicitParam(xxx)
})
26. 关于检查请求参数
在编写服务器端项目时,当接收到请求参数时,必须第一时间对各请求参数的基本格式进行检查!

需要注意:既然客户端(例如网页)已经检查了请求参数,服务器端应该再次检查,因为:

客户端的程序是运行在用户的设备上的,存在程序被篡改的可能性,所以,提交的数据或执行的检查是不可信的

在前后端分离的开发模式下,客户端的种类可能较多,例如网页端、手机端、电视端,可能存在某些客户端没有检查

升级了某些检查规则,但是,用户的设备上,客户端软件没有升级(例如手机APP还是此前的版本)

以上原因都可能导致客户端没有提交必要的数据,或客户端的检查不完全符合服务器端的要求!所以,对于服务器端而言,客户端提交的所有数据都是不可信的!则服务器端需要对请求参数进行检查!

即使服务器端已经对所有请求参数进行了检查,各个客户端仍应该检查请求参数,因为:

能更早的发现明显错误的数据,对应的请求将不会提交到服务器端,能够减轻服务器端的压力

客户端的检查不需要与服务器端交互,当出现错误时,能及时得到反馈,对于用户的体验更好

27. 通过Validation框架检查请求参数的基本格式
27.1. 添加依赖
Spring Validation框架可用于在服务器端检查请求参数的基本格式(例如是否提交了请求参数、字符串的长度是否正确、数字的大小是否在允许的区间等)。

首先,添加依赖项:

<!-- Spring Boot Validation,用于检查请求参数的基本格式 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
27.2. 检查封装在POJO中的请求参数
如果请求参数使用自定义的POJO类型进行封装,当需要检查这些请求参数的基本格式时,需要:

在处理请求的方法的参数列表中,在POJO类型前添加@Validated或@Valid注解,表示需要通过Spring Validation框架对此POJO类型封装的请求参数进行检查

在POJO类型的属性上,使用检查注解来配置检查规则,例如@NotNull注解就表示“不允许为null”,即客户端必须提交此请求参数

所有检查注解都有message属性,配置此属性,可用于向客户端响应相关的错误信息。

由于Spring Validation验证请求参数格式不通过时,会抛出异常,所以,可以在全局异常处理器中对此类异常进行处理!

先在ServiceCode中添加对应的枚举值:

ERR_BAD_REQUEST(40000)
然后,在全局异常处理器中添加对org.springframework.validation.BindException的处理:

@ExceptionHandler
public JsonResult handleBindException(BindException e) {
    log.debug("捕获到BindException:{}", e.getMessage());
    // 以下2行代码,如果有多种错误时,将随机获取其中1种错误的信息,并响应
    // String message = e.getFieldError().getDefaultMessage();
    // return JsonResult.fail(ServiceCode.ERR_BAD_REQUEST, message);
    // ===============================
    // 以下代码,如果有多种错误时,将获取所有错误信息,并响应
    StringBuilder stringBuilder = new StringBuilder();
    List<FieldError> fieldErrors = e.getFieldErrors();
    for (FieldError fieldError : fieldErrors) {
        stringBuilder.append(fieldError.getDefaultMessage());
    }
    return JsonResult.fail(ServiceCode.ERR_BAD_REQUEST, stringBuilder.toString());
}
27.3. 检查时快速失败
可以发现,Spring Validation在检查请求参数格式时,如果检查不通过,会记录下相关的错误,然后,继续进行其它检查,直到所有检查全部完成,才会返回错误信息!

检查全部的错误,相对更加消耗服务器资源,可以通过配置,使得检查出错时直接结束并返回错误!

package cn.tedu.csmall.product.config;

import lombok.extern.slf4j.Slf4j;
import org.hibernate.validator.HibernateValidator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.validation.Validation;

/**
 * Spring Validation的配置类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Slf4j
@Configuration
public class ValidationConfiguration {

    public ValidationConfiguration() {
        log.debug("创建配置类:ValidationConfiguration");
    }

    @Bean
    public javax.validation.Validator validator() {
        return Validation.byProvider(HibernateValidator.class)
                .configure() // 开始配置Validator
                .failFast(true) // 快速失败,即检查请求参数发现错误时直接视为失败,并不向后继续检查
                .buildValidatorFactory()
                .getValidator();
    }

}
27.4. 常用检查注解
关于检查注解,常用的有:

@NotNull:不允许为null,适用于所有类型的请求参数

@NotEmpty:不允许为空字符串(长度为0的字符串),仅适用于字符串类型的请求参数

此注解不检查是否为null,即请求参数为null将通过检查

此注解可以与@NotNull同时使用

@NotBlank:不允许为空白(形成空白的主要有:空格、TAB制表位、换行等),仅适用于字符串类型的请求参数

此注解不检查是否为null,即请求参数为null将通过检查

此注解可以与@NotNull同时使用

@Pattern:要求被检查的请求参数必须匹配某个正则表达式,通过此注解的regexp属性可以配置正则表达式,仅适用于字符串类型的请求参数

此注解不检查是否为null,即请求参数为null将通过检查

此注解可以与@NotNull同时使用

@Range:要求被检查的数值型请求参数必须在某个数值区间范围内,通过此注解的min属性可以配置最小值,通过此注解的max属性可以配置最大值,仅适用于数值类型的请求参数

此注解不检查是否为null,即请求参数为null将通过检查

此注解可以与@NotNull同时使用

另外,在org.hibernate.validator.constraints和javax.validation.constraints包还有其它检查注解。

27.5. 检查基本值的请求参数
如果请求参数是一些基本值,没有封装(例如String、Integer、Long),则需要将检查注解添加在请求参数上,例如:

@Deprecated
@ApiOperation("删除相册【测试2】")
@ApiOperationSupport(order = 910)
@ApiImplicitParam(name = "id", value = "相册id", paramType = "query")
@PostMapping("/delete/test2")
// ===== 重点关注以下方法参数上的注解 =====
public String deleteTest(@Range(min = 1, message = "测试删除相册失败,id值必须是1或更大的有效整数!") Long id) {
    log.debug("【测试】开始处理【删除相册】的请求,这只是一个测试,没有实质功能!");
    return "OK";
}
然后,还需要在控制器类上添加@Validated注解,以上方法参数前的检查注解才会生效!如果后续运行时没有通过此检查,Spring Validation框架将抛出ConstraintViolationException类型的异常,例如:

javax.validation.ConstraintViolationException: deleteTest.id: 测试删除相册失败,id值必须是1或更大的有效整数!
则在全局异常处理器中添加处理以上异常的方法:

@ExceptionHandler
public JsonResult handleConstraintViolationException(ConstraintViolationException e) {
    log.debug("捕获到ConstraintViolationException:{}", e.getMessage());
    StringBuilder stringBuilder = new StringBuilder();
    Set<ConstraintViolation<?>> constraintViolations = e.getConstraintViolations();
    for (ConstraintViolation<?> constraintViolation : constraintViolations) {
        stringBuilder.append(constraintViolation.getMessage());
    }
    return JsonResult.fail(ServiceCode.ERR_BAD_REQUEST, stringBuilder.toString());
}
28. 显示相册列表--Mapper层
查询相册列表的功能此前已经实现!

29. 显示相册列表--Service层
在IAlbumService接口中添加抽象方法:

/**
 * 查询相册列表
 *
 * @return 相册列表
 */
List<AlbumListItemVO> list();
在AlbumServiceImpl类中实现以上方法:

@Override
public List<AlbumListItemVO> list() {
    log.debug("开始处理【查询相册列表】的业务");
    return albumMapper.list();
}
在AlbumServiceTests中编写并执行测试:

@Test
void testList() {
    List<?> list = service.list();
    System.out.println("查询列表完成,列表中的数据的数量=" + list.size());
    for (Object item : list) {
        System.out.println(item);
    }
}
30. 显示相册列表--Controller层
服务器端处理请求后响应的都是JsonResult对象,但是,目前,此类型中并不足以表示“响应到客户端的数据”,需要在类中补充新的属性,用于封装响应到客户端的数据!

则先调整JsonResult类:

package cn.tedu.csmall.product.web;

import cn.tedu.csmall.product.ex.ServiceException;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

import java.io.Serializable;

@Data
public class JsonResult<T> implements Serializable {

    /**
     * 业务状态码
     */
    @ApiModelProperty("业务状态码")
    private Integer state;
    /**
     * 操作失败时的提示文本
     */
    @ApiModelProperty("操作失败时的提示文本")
    private String message;
    /**
     * 操作成功时的响应数据
     */
    @ApiModelProperty("操作成功时的响应数据")
    private T data;

    public static JsonResult<Void> ok() {
        // JsonResult jsonResult = new JsonResult();
        // jsonResult.state = ServiceCode.OK.getValue();
        // jsonResult.message = null;
        // jsonResult.data = null;
        // return jsonResult;
        return ok(null);
    }

    public static <T> JsonResult<T> ok(T data) {
        JsonResult jsonResult = new JsonResult();
        jsonResult.state = ServiceCode.OK.getValue();
        jsonResult.message = null;
        jsonResult.data = data;
        return jsonResult;
    }

    public static JsonResult<Void> fail(ServiceException e) {
        // JsonResult jsonResult = new JsonResult();
        // jsonResult.state = e.getServiceCode().getValue();
        // jsonResult.message = e.getMessage();
        // return jsonResult;
        return fail(e.getServiceCode(), e.getMessage());
    }

    public static JsonResult<Void> fail(ServiceCode serviceCode, String message) {
        JsonResult jsonResult = new JsonResult();
        jsonResult.state = serviceCode.getValue();
        jsonResult.message = message;
        return jsonResult;
    }

}
然后,在AlbumController中添加处理请求的方法:

// http://localhost:9080/albums
@ApiOperation("查询相册列表")
@ApiOperationSupport(order = 420)
@GetMapping("")
public JsonResult<List<AlbumListItemVO>> list() {
    log.debug("开始处理【查询相册列表】的请求");
    List<AlbumListItemVO> list = albumService.list();
    return JsonResult.ok(list);
}
完成后,重启项目,通过在线API文档的调试功能可以测试访问。

作业
完成以下功能,最终通过在线API文档的调试功能可以测试访问即可:

显示品牌列表

显示属性模板列表

显示管理员列表(在passport项目中)

day10
31. 关于Spring框架
31.1. Spring框架的作用
Spring框架主要解决了创建对象、管理对象的相关问题。

创建对象,例如:

User user = new User();
管理对象:Spring会在创建对象之后,完成必要的属性赋值等操作,并且,还会持有所创建的对象的引用,由于持久大量对象的引用,所以,Spring框架也通常被称之为“Spring容器”。

32.2. Spring框架创建对象的做法
Spring框架创建对象有2种做法,第1种是通过配置类中的@Bean方法,第2种是通过组件扫描。

关于@Bean方法:在任何配置类中,自定义返回对象的方法,并在方法上添加@Bean注解,则Spring会自动调用此方法,并且,获取此方法返回的对象,将对象保存在Spring容器中,例如:

@Configuration
public class BeanFactory {
    
    @Bean
    public LocalDateTime localDateTime() {
        return LocalDateTime.now();
    }
    
    // 如果使用这种做法,则AlbumController不必使用组件扫描的做法
    @Bean
    public AlbumController albumController() {
        return new AlbumController();  
    }
    
    // 如果使用这种做法,则AlbumServiceImpl不必使用组件扫描的做法
    @Bean
    public AlbumServiceImpl albumServiceImpl() {
        return new AlbumServiceImpl();  
    }
    
}
关于组件扫描:需要通过@ComponentScan注解来指定扫描的根包,则Spring框架会在此根包下查找组件,并且创建这些组件的对象。

根包:某个包及其子孙包,例如,指定的根包是cn.tedu.csmall.product,则cn.tedu.csmall.product、cn.tedu.csmall.product.controller、cn.tedu.csmall.product.service.impl都属于根包的范围之内!

组件:在Spring框架中,添加了@Component及其衍生注解的,都是组件!常见的组件注解有:

@Component:通用组件注解

@Controller:应该添加在控制器类上

@Service:应该添加在处理业务逻辑的类上

@Repository:应该添加在处理数据访问(直接与数据源交互)的类上

@Configuration:应该添加在配置类上

以上5个组件注解,除了@Configuration以外,另外4个在功能、用法、执行效果方面,在Spring框架的作用范围内是完全相同的,只是语义不同!Spring框架在处理@Configuration注解时,会使用CGLib的代理模式来创建对象,并且,被Spring实际使用的是代理对象。

提示:在Spring Boot项目中,启动类上的注解@SpringBootApplication,此注解的元注解包含@ComponentScan注解,所以,Spring Boot项目启动时就会执行组件扫描,扫描的根包就是启动类所在的包!

在开发实践中,如果需要创建非自定义类(例如Java官方的类,或其它框架中的类)的对象,必须使用@Bean方法,毕竟你不能在别人声明的类上添加组件注解,如果需要创建自定义类的对象,则优先使用组件扫描的做法,因为这种做法更加简单!

32.3. Spring管理的对象的作用域
Spring管理的对象默认是“单例”的,则在整个程序的运行过程中,随时可以获取或访问Spring容器中的“单例”对象!

注意:Spring并没有实际使用单例模式!

单例:单一实例(单一对象),即:在任意时间,某个类的对象最多只有1个!

如果需要Spring管理某个对象采取“非单例”的模式,可以通过@Scope("prototype")注解来实现!

提示:如果是通过@Bean方法创建对象,则@Scope("prototype")注解添加在@Bean方法上,如果是通过组件扫描创建对象,则@Scope("prototype")注解添加在组件类上。

如果没有Spring框架,自己手动实现单例效果,大致需要:

public class Singleton {
    private static Singleton instance = new Singleton();
    
    private Singleton() {}
    
    public static Singleton getInstance() {
        return instance;
    }
}
public class Singleton {
    private static final Object lock = new Object();
    private static Singleton instance;
    
    private Singleton() {}
    
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (lock) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}
Spring管理的单例对象,默认情况下预加载的!可以通过@Lazy注解配置为懒加载的!

提示:如果是通过@Bean方法创建对象,则@Lazy注解添加在@Bean方法上,如果是通过组件扫描创建对象,则@Lazy注解添加在组件类上。

32.4. 自动装配机制
Spring的自动装配机制表现为:当Spring管理的类的属性需要被自动赋值,或Spring调用的方法的参数需要值时,Spring会自动从容器中找到合适的值,为属性 / 参数自动赋值!

当类的属性需要值时,可以在属性上添加@Autowired注解。

关于被Spring调用的方法,主要表现为:构造方法、配置类中的@Bean方法等。

关于调用构造方法:

如果类中存在无参数构造方法(无论是否存在其它构造方法),Spring会自动调用无参数构造方法

如果类中仅有1个构造方法,Spring会自动尝试调用,且,如果此构造方法有参数,Spring会自动尝试从容器中查找合适的值用于调用此构造方法

如果类中有多个构造方法,且都是有参数的,Spring不会自动调用任何构造方法,且会报错

如果希望Spring调用特定的构造方法,应该在那一个构造方法上添加@Autowired注解

关于在属性上使用@Autowired时的提示:Field injection is not recommended,其意思是“字段注入是不推荐的”,因为,开发工具认为,你有可能在某些情况下自行创建当前类的对象,例如自行编写代码:AlbumController albumController = new AlbumController();,由于是自行创建的对象,Spring框架在此过程中是不干预的,则类的属性IAlbumService albumService将不会由Spring注入值,如果此时你也没有为这个属性赋值,则这个的属性就是null,如果还执行类中的方法,就可能导致NPE(NullPointerException),这种情况可能发生在单元测试中。开发工具建议使用构造方法注入,即使用带参数的构造方法,且通过构造方法为属性赋值,并且类中只有这1个构造方法,在这种情况下,即使自行创建对象,由于唯一的构造方法是带参数的,所以,创建对象时也会为此参数赋值,不会出现属性没有值的情况,所以,通过构造方法为属性注入值的做法被认为是安全的,是建议使用的做法!但是,在开发实践,通常并不会使用构造方法注入属性的值,因为,属性的增、减都需要调整构造方法,并且,如果类中需要注入值的属性较多,也会导致构造方法的参数较多,不是推荐的!

关于合适的值:Spring框架会查找容器中匹配类型的对象的数量:

0个:无法装配,需要判断@Autowired注解的required属性:

true:在加载Spring时直接报错NoSuchBeanDefinitionException

false:放弃自动装配,且尝试自动装配的属性将是默认值

1个:直接装配,且装配成功

超过1个:尝试按照名称来匹配,如果均不匹配,则在加载Spring时直接报错NoUniqueBeanDefinitionException,按照名称匹配时,要求被装配的变量名与Bean Name保持一致

关于Bean Name:每个Spring Bean都有一个Bean Name,如果是通过@Bean方法创建的对象,则Bean Name就是方法名,或通过@Bean注解参数来指定名称,如果是通过组件扫描的做法来创建的对象,则Bean Name默认是将类名首字母改为小写的名称(例如,类名为AlbumServiceImpl,则Bean Name为albumServiceImpl)(此规则只适用于类名中第1个字母大写、第2个字母小写的情况,如果不符合此情况,则Bean Name就是类名),Bean Name也可以通过@Component等注解的参数进行配置,或者,你还可以在需要装配的属性上使用@Qualifier注解来指定装配哪个Bean Name对应的Spring Bean。

另外,在处理属性的自动装配上,还可以使用@Resource注解取代@Autowired注解,@Resource是先根据名称尝试装配,再根据类型装配的机制!

day11
32. 删除管理员--Mapper层
删除管理员需要执行的SQL大致是:

delete from ams_admin where id=?
在删除之前,还应该检查数据是否存在,可以通过以下SQL查询来实现检查:

select count(*) from ams_admin where id=?
select * from ams_admin where id=?
首先,在pojo.vo包下创建AdminStandardVO类:

package cn.tedu.csmall.passport.pojo.vo;

import lombok.Data;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
 * 管理员的标准VO类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Data
public class AdminStandardVO implements Serializable {

    /**
     * 数据id
     */
    private Long id;

    /**
     * 用户名
     */
    private String username;

    /**
     * 昵称
     */
    private String nickname;

    /**
     * 头像URL
     */
    private String avatar;

    /**
     * 手机号码
     */
    private String phone;

    /**
     * 电子邮箱
     */
    private String email;

    /**
     * 描述
     */
    private String description;

    /**
     * 是否启用,1=启用,0=未启用
     */
    private Integer enable;

    /**
     * 最后登录IP地址(冗余)
     */
    private String lastLoginIp;

    /**
     * 累计登录次数(冗余)
     */
    private Integer loginCount;

    /**
     * 最后登录时间(冗余)
     */
    private LocalDateTime gmtLastLogin;

}
然后,在AdminMapper.java中添加:

/**
 * 根据id删除管理员数据
 *
 * @param id 管理员id
 * @return 受影响的行数
 */
int deleteById(Long id);

/**
 * 查询管理员列表
 *
 * @return 管理员列表
 */
List<AdminListItemVO> list();
在AdminMapper.xml中配置:

<!-- int deleteById(Long id); -->
<delete id="deleteById">
    DELETE FROM ams_admin WHERE id=#{id}
</delete>

<!-- AdminStandardVO getStandardById(Long id); -->
<select id="getStandardById" resultMap="StandardResultMap">
    SELECT
        <include refid="StandardQueryFields" />
    FROM
        ams_admin
    WHERE
        id=#{id}
</select>

<!-- List<AdminListItemVO> list(); -->
<select id="list" resultMap="ListResultMap">
    SELECT
        <include refid="ListQueryFields" />
    FROM
        ams_admin
    ORDER BY
        enable DESC, id
</select>

<sql id="StandardQueryFields">
    <if test="true">
        id, username, nickname, avatar, phone,
        email, description, enable, last_login_ip, login_count,
        gmt_last_login
    </if>
</sql>

<sql id="ListQueryFields">
    <if test="true">
        id, username, nickname, avatar, phone,
        email, description, enable, last_login_ip, login_count,
        gmt_last_login
    </if>
</sql>

<resultMap id="StandardResultMap" type="cn.tedu.csmall.passport.pojo.vo.AdminStandardVO">
    <id column="id" property="id"/>
    <result column="username" property="username"/>
    <result column="nickname" property="nickname"/>
    <result column="avatar" property="avatar"/>
    <result column="phone" property="phone"/>
    <result column="email" property="email"/>
    <result column="description" property="description"/>
    <result column="enable" property="enable"/>
    <result column="last_login_ip" property="lastLoginIp"/>
    <result column="login_count" property="loginCount"/>
    <result column="gmt_last_login" property="gmtLastLogin"/>
</resultMap>
完成后,在AdminMapperTests中编写并执行测试:

@Test
void testDeleteById() {
    Long id = 11L;
    int rows = mapper.deleteById(id);
    System.out.println("删除数据完成,受影响的行数=" + rows);
}

@Test
void testGetStandardById() {
    Long id = 1L;
    Object result = mapper.getStandardById(id);
    System.out.println("根据id=" + id + "查询标准信息完成,结果=" + result);
}
33. 删除管理员--Service层
在IAdminService接口中添加抽象方法:

/**
 * 删除管理员
 *
 * @param id 尝试删除的管理员的id
 */
void delete(Long id);
在AdminServiceImpl中实现:

@Override
public void delete(Long id) {
    log.debug("开始处理【删除管理员】的业务,参数:{}", id);
    // 根据参数id查询管理员数据
    AdminStandardVO queryResult = adminMapper.getStandardById(id);
    // 判断管理员数据是否不存在
    if (queryResult == null) {
        String message = "删除管理员失败,尝试访问的数据不存在!";
        log.warn(message);
        throw new ServiceException(ServiceCode.ERR_NOT_FOUND, message);
    }

    // 执行删除管理员
    adminMapper.deleteById(id);
}
在AdminServiceTests中编写并执行测试:

@Test
void testDelete() {
    Long id = 9L;

    try {
        service.delete(id);
        System.out.println("删除成功!");
    } catch (ServiceException e) {
        System.out.println(e.getMessage());
    }
}
34. 删除管理员--Controller层
在AdminController中添加处理请求的方法:

// http://localhost:8080/admins/9527/delete
@ApiOperation("删除管理员")
@ApiOperationSupport(order = 200)
@ApiImplicitParam(name = "id", value = "管理员id", required = true, dataType = "long")
@PostMapping("/{id:[0-9]+}/delete")
public JsonResult<Void> delete(@PathVariable Long id) {
    log.debug("开始处理【删除管理员】的请求,参数:{}", id);
    adminService.delete(id);
    return JsonResult.ok();
}
完成后,重启项目,通过Knife4j在线API文档进行测试访问。

35. 删除管理员--前端
36. 完善添加管理员
添加管理员时,还需要为管理员分配某些(某种)角色,进而使得该管理员具备某些操作权限!

为管理分配角色的本质是向ams_admin_role这张表中插入数据!同时,需要注意,每个管理员账号可能对应多种角色,所以,需要执行的SQL语句大致是:

insert into ams_admin_role (admin_id, role_id) values (?, ?), (?, ?) ... (?, ?);
在pojo.entity包下创建AdminRole实体类:

package cn.tedu.csmall.passport.pojo.entity;

import lombok.Data;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
 * 管理员与角色的关联数据的实体类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Data
public class AdminRole implements Serializable {

    /**
     * 数据id
     */
    private Long id;

    /**
     * 管理员id
     */
    private Long adminId;
    
    /**
     * 角色id
     */
    private Long roleId;

    /**
     * 数据创建时间
     */
    private LocalDateTime gmtCreate;

    /**
     * 数据最后修改时间
     */
    private LocalDateTime gmtModified;

}
在mapper包下创建AdminRoleMapper接口,并在接口中添加抽象方法:

package cn.tedu.csmall.passport.mapper;

import cn.tedu.csmall.passport.pojo.entity.AdminRole;
import org.springframework.stereotype.Repository;

import java.util.List;

/**
 * 处理管理员与角色的关联数据的Mapper接口
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Repository
public interface AdminRoleMapper {

    /**
     * 批量插入管理员与角色的关联数据
     *
     * @param adminRoleList 管理员与角色的关联数据的列表
     * @return 受影响的行数
     */
    int insertBatch(List<AdminRole> adminRoleList);
}
在src/main/resources/mapper文件夹下通过复制粘贴得到AdminRoleMapper.xml文件,在此文件中配置以上抽象方法映射的SQL语句:

<mapper namespace="xx.xx.xx.xx.AdminRoleMapper">

    <!-- int insertBatch(List<AdminRole> adminRoleList); -->
    <insert id="insertBatch" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO ams_admin_role (admin_id, role_id) VALUES 
        <foreach collection="list" item="adminRole" separator=",">
            (#{adminRole.adminId}, #{adminRole.roleId})
        </foreach>
    </insert>

</mapper>
完成后,在src/test/java下的根包下创建mapper.AdminRoleMapperTests测试类,编写并执行测试:

package cn.tedu.csmall.passport.mapper;

import cn.tedu.csmall.passport.pojo.entity.AdminRole;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.ArrayList;
import java.util.List;

@Slf4j
@SpringBootTest
public class AdminRoleMapperTests {

    @Autowired
    AdminRoleMapper mapper;

    @Test
    void testInsertBatch() {
        List<AdminRole> adminRoleList = new ArrayList<>();
        for (int i = 1; i <= 3; i++) {
            AdminRole adminRole = new AdminRole();
            adminRole.setAdminId(100L);
            adminRole.setRoleId(i + 0L);
            adminRoleList.add(adminRole);
        }

        int rows = mapper.insertBatch(adminRoleList);
        log.debug("批量插入数据完成!受影响的行数={}", rows);
    }
    
}
完成Mapper层的补充后,先在AdminAddNewDTO中添加新的属性,表示“客户端将提交到服务器端的,添加管理员时选择的若干个角色id”:

/**
 * 尝试添加的管理员的角色id列表
 */
private Long[] roleIds;
然后,在AdminServiceImpl类中,补充声明:

@Autowired
AdminRoleMapper adminRoleMapper;
并在addNew()方法的最后补充:

// 调用adminRoleMapper的insertBatch()方法插入关联数据
Long[] roleIds = adminAddNewDTO.getRoleIds();
List<AdminRole> adminRoleList = new ArrayList<>();
for (int i = 0; i < roleIds.length; i++) {
    AdminRole adminRole = new AdminRole();
    adminRole.setAdminId(admin.getId());
    adminRole.setRoleId(roleIds[i]);
    adminRoleList.add(adminRole);
}
adminRoleMapper.insertBatch(adminRoleList);
完成后,在AdminServiceTests中原有的测试中,在测试数据上添加封装roleIds属性的值,并进行测试:

@Test
void testAddNew() {
    AdminAddNewDTO adminAddNewDTO = new AdminAddNewDTO();
    adminAddNewDTO.setUsername("wangkejing6");
    adminAddNewDTO.setPassword("123456");
    adminAddNewDTO.setPhone("13800138006");
    adminAddNewDTO.setEmail("wangkejing6@baidu.com");
    adminAddNewDTO.setRoleIds(new Long[]{3L, 4L, 5L}); // ===== 新增 =====

    try {
        service.addNew(adminAddNewDTO);
        log.debug("添加管理员成功!");
    } catch (ServiceException e) {
        log.debug("{}", e.getMessage());
    }
}
37. 基于Spring JDBC框架的事务管理
事务:Transaction,是数据库中的一种能够保证多个写操作要么全部成功,要么全部失败的机制!

在基于Spring JDBC的数据库编程中,在业务方法上添加@Transactional注解,即可使得这个业务方法是“事务性”的!

假设,存在某银行转账的操作,转账时需要执行的SQL语句大致是:

UPDATE 存款表 SET 余额=余额-50000 WHERE 账号='国斌老师';
UPDATE 存款表 SET 余额=余额+50000 WHERE 账号='苍松老师';
以上的转账操作就涉及多次数据库的写操作,如果由于某些意外原因(例如停电、服务器死机等),导致第1条SQL语句成功执行,但是第2条SQL语句未能成功执行,就会出现数据不完整的问题!使用事务就可以解决此问题!

关于@Transationcal注解,可以添加在:

业务实现类的方法上

仅作用于当前方法

业务实现类上

将作用于当前类中所有方法

业务接口的抽象方法上

仅作用于当前方法

无论是哪个类重写此方法,都将是事务性的

业务接口上

将作用于当前接口中所有抽象方法

无论是哪个类实现了此接口,重写的所有方法都是将是事务性的

day12
37. 基于Spring JDBC框架的事务管理(续)
在执行数据访问操作时,数据库有一个“自动提交”的机制。

事务的本质是会先将“自动提交”关闭,当业务方法执行结束之后,再一次性“提交”。

在事务中,涉及几个概念:

开启事务:BEGIN

提交事务:COMMIT

回滚事务:ROLLBACK

在基于Spring JDBC的程序设计中,通过@Transactional注解即可使得业务方法是事务性的,其实现过程大致是:

开启事务
try {
    执行业务方法
    提交事务
} catch (RuntimeException e) {
    回滚事务
}
可以看到,Spring JDBC框架在处理事务时,默认将根据RuntimeException进行回滚!

提示:可以配置@Transactional注解的rollbackFor或rollbackForClassName属性来指定回滚的异常类型,即根据其它类型的异常来回滚,例如:

@Transactional(rollbackFor = {IOException.class})
@Transactional(rollbackForClassName = {}"java.io.IOException"})
另外,还可以通过noRollbackFor或noRollbackForClassName属性用于指定不回滚的异常!

建议在业务方法中执行了任何增、删、改操作后,都获取受影响的行数,并判断此值是否符合预期,如果不符合,应该及时抛出RuntimeException或其子孙类异常!

补充:Spring JDBC框架在实现事务管理时,使用到了Spring AOP技术及基于接口的代理模式,由于使用了基于接口的代理模式,所以,如果将@Transactional注解添加在实现类中自定义的方法(不是重写的接口中的抽象方法)上,是错误的做法!

最后,可自行补充相关知识点:事务的ACID特性,事务的传播,事务的隔离。

38. 完善删除管理员
由于添加管理员时,在ams_admin_role表中插入了数据,在删除管理员时,也应该将ams_admin_role表中对应的数据删除!

删除ams_admin_role表中某管理员的数据需要执行的SQL语句大致是:

DELETE FROM ams_admin_role WHERE admin_id=?
则在AdminRoleMapper接口中添加抽象方法:

/**
 * 根据管理员id删除管理员与角色的关联数据
 *
 * @param adminId 管理员id
 * @return 受影响的行数
 */
int deleteByAdminId(Long adminId);
并在AdminRoleMapper.xml中配置SQL语句:

<!-- int deleteByAdminId(Long adminId); -->
<delete id="deleteByAdminId">
    DELETE FROM ams_admin_role WHERE admin_id=#{adminId}
</delete>
完成后,在AdminRoleMapperTests中编写并执行测试:

@Test
void testDeleteByAdminId() {
    Long adminId = 7L;
    int rows = mapper.deleteByAdminId(adminId);
    log.debug("删除数据完成!受影响的行数={}", rows);
}
当Mapper层的开发完成后,在AdminServiceImpl中的delete()方法最后补充调用以上方法即可:

// 执行删除此管理员与角色的关联数据
rows = adminRoleMapper.deleteByAdminId(id);
if (rows < 1) {
    String message = "删除管理员失败,服务器忙,请稍后再次尝试!";
    log.warn(message);
    throw new ServiceException(ServiceCode.ERR_DELETE, message);
}
完成后,删除管理员的功能将暂时全部完成!

但是,需要注意:不要使用错误的测试数据进行测试!

显示角色列表
在“添加管理员”的界面中,需要将角色列表显示出来,则用户(软件的使用者)可以在界面上选择“添加管理员”时此管理员的角色。

关于“显示角色列表”的Mapper层

需要执行的SQL语句大致是:

SELECT * FROM ams_role ORDER BY sort DESC, id
则需要在pojo.vo包中创建RoleListItemVO类:

package cn.tedu.csmall.passport.pojo.vo;

import lombok.Data;

import java.io.Serializable;

/**
 * 角色的列表项的VO类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Data
public class RoleListItemVO implements Serializable {

    private Long id;
    private String name;
    private String description;
    private Integer sort;

}
然后,在mapper包中创建RoleMapper接口,并添加抽象方法:

package cn.tedu.csmall.passport.mapper;

import cn.tedu.csmall.passport.pojo.vo.RoleListItemVO;
import org.springframework.stereotype.Repository;

import java.util.List;

/**
 * 处理角色数据的Mapper接口
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Repository
public interface RoleMapper {

    /**
     * 查询角色列表
     *
     * @return 角色列表
     */
    List<RoleListItemVO> list();
    
}
然后,在src/main/resources/mapper文件夹下,通过复制粘贴得到RoleMapper.xml文件,在此文件中配置以上抽象方法映射的SQL语句:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="cn.tedu.csmall.passport.mapper.RoleMapper">

    <!-- List<RoleListItemVO> list(); -->
    <select id="list" resultMap="ListResultMap">
        SELECT
            <include refid="ListQueryFields" />
        FROM
            ams_role
        ORDER BY
            sort DESC, id
    </select>

    <sql id="ListQueryFields">
        <if test="true">
            id, name, description, sort
        </if>
    </sql>

    <resultMap id="ListResultMap" type="cn.tedu.csmall.passport.pojo.vo.RoleListItemVO">
        <id column="id" property="id"/>
        <result column="name" property="name"/>
        <result column="description" property="description"/>
        <result column="sort" property="sort"/>
    </resultMap>

</mapper>
完成后,在src/test/java下的根包下创建mapper.RoleMapperTests测试类,在此类中编写并执行测试:

package cn.tedu.csmall.passport.mapper;

import cn.tedu.csmall.passport.pojo.entity.Admin;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

@Slf4j
@SpringBootTest
public class RoleMapperTests {

    @Autowired
    RoleMapper mapper;

    @Test
    void testList() {
        List<?> list = mapper.list();
        System.out.println("查询列表完成,列表中的数据的数量=" + list.size());
        for (Object item : list) {
            System.out.println(item);
        }
    }

}
关于“显示角色列表”的Service层

在service包下创建IRoleService接口,并在接口中添加抽象方法:

/**
 * 处理角色数据的业务接口
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Transactional
public interface IRoleService {

    /**
     * 查询角色列表
     *
     * @return 角色列表
     */
    List<RoleListItemVO> list();
    
}
然后,在service.impl包下创建RoleServiceImpl实现类,实现以上接口,并在类上添加@Service注解,在类中声明RoleMapper属性并自动装配:

/**
 * 处理角色数据的业务实现类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Slf4j
@Service
public class RoleServiceImpl implements IRoleService {
    
    @Autowired
    RoleMapper roleMapper;

    @Override
    public List<RoleListItemVO> list() {
        log.debug("开始处理【查询角色列表】的业务");
        return roleMapper.list();
    }
    
}
完成后,在src/test/java下的根包下创建service.RoleServiceTests测试类,在此类中编写并执行测试:

package cn.tedu.csmall.passport.service;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

@Slf4j
@SpringBootTest
public class RoleServiceTests {

    @Autowired
    IRoleService service;

    @Test
    void testList() {
        List<?> list = service.list();
        System.out.println("查询列表完成,列表中的数据的数量=" + list.size());
        for (Object item : list) {
            System.out.println(item);
        }
    }

}
关于“显示角色列表”的Controller层

在controller包下创建RoleController类,在类上添加@RestController和@RequestMapping("/roles")注解,在类中声明并自动装配IRoleService属性,然后,在类中添加处理请求的方法:

package cn.tedu.csmall.passport.controller;

import cn.tedu.csmall.passport.pojo.vo.RoleListItemVO;
import cn.tedu.csmall.passport.service.IRoleService;
import cn.tedu.csmall.passport.web.JsonResult;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@Slf4j
@Api(tags = "02. 角色管理模块")
@RestController
@RequestMapping("/roles")
public class RoleController {

    @Autowired
    IRoleService roleService;

    public RoleController() {
        log.info("创建控制器类:RoleController");
    }

    // http://localhost:9081/roles
    @ApiOperation("查询角色列表")
    @ApiOperationSupport(order = 420)
    @GetMapping("")
    public JsonResult<List<RoleListItemVO>> list() {
        log.debug("开始处理【查询角色列表】的请求");
        return JsonResult.ok(roleService.list());
    }

}
完成后,重启项目,可以通过在线API文档测试访问。

启动或禁用管理员
管理的启用状态是通过数据表中的enable字段的值来控制的,所以,启用、禁用功能的本质是修改此字段的值!

在开发功能时,应该在Mapper层开发一个能够修改所有字段的值的功能,则此功能可以适用于当前表的任何修改功能!

关于Mapper层

先在AdminMapper接口中添加抽象方法:

int updateById(Admin admin);
然后在AdminMapper.xml中配置SQL:

<!-- int updateById(Admin admin); -->
<update id="updateById">
    UPDATE ams_admin
    <set>
        <if test="username != null">
            username=#{username},
        </if>
        <if test="password != null">
            password=#{password},
        </if>
        ...
    </set>
    WHERE id=#{id}
</update>
完成后,在AdminMapperTests中编写并执行测试:


关于Service层

关于Controller层

day13
40. 启动或禁用管理员(续)
关于Controller层

由于Service设计了2个业务方法,分别用于启用和禁用,在控制器层,应该也设计2个方法,分别用于处理启用管理员的请求和禁用管理员的请求,则客户端在提交请求时,不需要提交enable属性的值!

则在AdminController中添加处理请求的方法:

// http://localhost:9081/admins/9527/enable
@ApiOperation("启用管理员")
@ApiOperationSupport(order = 310)
@ApiImplicitParam(name = "id", value = "管理员id", required = true, dataType = "long")
@PostMapping("/{id:[0-9]+}/enable")
public JsonResult<Void> setEnable(@PathVariable Long id) {
    log.debug("开始处理【启用管理员】的请求,参数:{}", id);
    adminService.setEnable(id);
    return JsonResult.ok();
}

// http://localhost:9081/admins/9527/disable
@ApiOperation("禁用管理员")
@ApiOperationSupport(order = 311)
@ApiImplicitParam(name = "id", value = "管理员id", required = true, dataType = "long")
@PostMapping("/{id:[0-9]+}/disable")
public JsonResult<Void> setDisable(@PathVariable Long id) {
    log.debug("开始处理【禁用管理员】的请求,参数:{}", id);
    adminService.setDisable(id);
    return JsonResult.ok();
}
41. 关于Spring Security框架
Spring Security主要解决了认证与授权的相关问题。

Spring Security的基础依赖项是spring-security-core,在Spring Boot项目中,通常添加spring-boot-starter-security这个依赖项,它包含了spring-security-core,并且,还自动执行了一系列配置!默认的配置效果有:

所有请求都是必须通过认证的

如果未认证,同步请求将自动跳转到 /login,是框架自带的登录页,非跨域的异步请求将响应 403 错误

提供了默认的登录信息,用户名为 user,密码是启动项目是随机生成的,在启动日志中可以看到

当登录成功后,会自动重定向到此前访问的URL

当登录成功后,可以执行所有同步请求,所有异步的POST请求都暂时不可用

可以通过 /logout 退出登录

42. 关于BCrypt算法
当添加了Spring Security相关的依赖项后,此依赖项中将包含BCryptPasswordEncoder工具类,是一个使用BCrypt算法的密码编码器,它实现了PasswordEncoder接口,并重写了接口中的String encode(String rawPassword)方法,用于对密码原文进行编码(加密),及重写了boolean matches(String rawPassword, String encodedPassword)方法,用于验证密码原文与密文是否对应。

BCrypt算法会自动使用随机的盐值进行加密处理,所以,当反复对同一个原文进行加密处理,每次得到的密文都是不同的,但这并不影响验证密码!

BCrypt算法被设计为是一种慢速运算的算法,可以一定程度上避免或缓解密码被暴力破解(使用循环进行穷举的破解)。

43. 关于Spring Security的基本配置
package cn.tedu.csmall.passport.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Slf4j
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    // @Bean
    public PasswordEncoder passwordEncoder() {
        log.debug("创建@Bean方法定义的对象:PasswordEncoder");
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 【配置白名单】
        // 在配置路径时,星号是通配符
        // 1个星号只能匹配任何文件夹或文件的名称,但不能跨多个层级
        // 例如:/*/test.js,可以匹配到 /a/test.js 和 /b/test.js,但不可以匹配到 /a/b/test.js
        // 2个连续的星号可以匹配若干个文件夹的层级
        // 例如:/**/test.js,可以匹配 /a/test.js 和 /b/test.js 和 /a/b/test.js
        String[] urls = {
                "/doc.html",
                "/**/*.js",
                "/**/*.css",
                "/swagger-resources",
                "/v2/api-docs"
        };

        http.csrf().disable(); // 禁用CSRF(防止伪造的跨域攻击)

        http.authorizeRequests() // 对请求执行认证与授权
                .antMatchers(urls) // 匹配某些请求路径
                .permitAll() // (对此前匹配的请求路径)不需要通过认证即允许访问
                .anyRequest() // 除以上配置过的请求路径以外的所有请求路径
                .authenticated(); // 要求是已经通过认证的

        http.formLogin(); // 开启表单验证,即视为未通过认证时,将重定向到登录表单,如果无此配置,则直接响应403
    }

}
44. 关于登录的账号
默认情况下,Spring Security使用user作为用户名,使用随机的UUID作为密码来登录!如果需要自行指定登录账号,需要自定义一个组件类,实现UserDetailsService接口,此接口中定义了UserDetails loadUserByUsername(String username),在处理认证时,当用户(使用者)输入了用户名、密码并提交,Spring Security就会自动使用用户在表单中输入的用户名来调用loadUserByUsername()方法,作为开发者,应该重写此方法,并根据用户名来返回匹配的UserDetails对象,此对象中应该包含用户的相关信息,例如密码等,当Spring Security得到调用loadUserByUsername()返回的UserDetails对象后,会自动处理后续的认证过程,例如验证密码是否匹配等。

例如,在根包下创建security.UserDetailsServiceImpl类:

package cn.tedu.csmall.passport.security;

import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        log.debug("Spring Security调用了loadUserByUsername()方法,参数:{}", s);
        // 暂时使用模拟数据来处理登录认证,假设正确的用户名和密码分别是root和123456
        if ("root".equals(s)) {
            UserDetails userDetails = User.builder()
                    .username("root")
                    .password("123456")
                    .accountExpired(false)
                    .accountLocked(false)
                    .disabled(false)
                    .authorities("这是一个山寨的权限标识") // 权限,注意,此方法的参数不可以为null,在不处理权限之前,可以写一个随意的字符串值
                    .build();
            log.debug("即将向Spring Security返回UserDetails对象:{}", userDetails);
            return userDetails;
        }
        log.debug("此用户名【{}】不存在,即将向Spring Security返回为null的UserDetails值", s);
        return null;
    }

}
另外,Spring Security在执行认证时,需要使用到密码编码器(PasswordEncoder),则在SecurityConfiguration配置类中添加:

@Bean
public PasswordEncoder passwordEncoder() {
    log.debug("创建@Bean方法定义的对象:PasswordEncoder");
    return NoOpPasswordEncoder.getInstance(); // 无操作的密码编码器,即:不会执行加密处理
}
提示:一旦启动项目时,Spring Security从Spring容器中找到了UserDetailsService接口类型的对象,则默认的用户名和随机的密码都不会再使用(启动项目中也不会再看到随机的临时密码)。

day14
44. 关于登录的账号(续)
当Spring容器中存在密码编码器时,在Spring Security处理认证时会自动调用!

本质上,是调用了密码编码器的以下方法:

boolean matches(CharSequence rawPassword, String encodedPassword);
也就是说,Spring Security会使用用户提交的密码作为以上方法的第1个参数,使用UserDetails对象中的密码作为以上方法的第2个参数,然后根据调用以上方法返回的boolean结果来判断此用户是否能通过密码验证!

所以,如果配置了BCryptPasswordEncoder,则返回的UserDetails对象中的密码必须是BCrypt密文,例如:

@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
    log.debug("Spring Security调用了loadUserByUsername()方法,参数:{}", s);
    if ("root".equals(s)) {
        UserDetails userDetails = User.builder()
                .username("root")
                // ====== 重点是以下这一行代码中的密文 ======
                .password("$2a$10$nO7GEum8P27F8S0EGEHryel7m89opm/AMdaqMBk.qdsdIpE/SWFwe")
                .accountExpired(false)
                .accountLocked(false)
                .disabled(false)
                .authorities("这是一个山寨的权限标识")
                .build();
        log.debug("即将向Spring Security返回UserDetails对象:{}", userDetails);
        return userDetails;
    }
    log.debug("此用户名【{}】不存在,即将向Spring Security返回为null的UserDetails值", s);
    return null;
}
45. 使用数据库中的管理员账号信息来登录
Spring Security在处理认证时,会自动调用用UserDetailsService接口中的UserDetails loadUserByUsername(String username)方法,此方法返回的结果将决定此用户是否能够成功登录,此用户的信息应该来自数据库的管理员表中的数据!

则需要通过数据库查询来实现“根据用户名查询用户登录时所需要的相关信息”!需要执行的SQL语句大致是:

select id, username, password, enable from ams_admin where username=?
要实现以上查询,应该先在pojo.vo包下创建AdminLoginInfoVO类:

package cn.tedu.csmall.passport.pojo.vo;

import lombok.Data;

import java.io.Serializable;

/**
 * 管理员的登录VO类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Data
public class AdminLoginInfoVO implements Serializable {

    /**
     * 数据id
     */
    private Long id;

    /**
     * 用户名
     */
    private String username;

    /**
     * 密码(密文)
     */
    private String password;

    /**
     * 是否启用,1=启用,0=未启用
     */
    private Integer enable;

}
然后,在AdminMapper.java中添加抽象方法:

/**
 * 根据用户名查询管理员的登录信息
 *
 * @param username 用户名
 * @return 匹配的管理员详情,如果没有匹配的数据,则返回null
 */
AdminLoginInfoVO getLoginInfoByUsername(String username);
并在AdminMapper.xml中配置以上抽象方法映射的SQL语句:

<!-- AdminLoginInfoVO getLoginInfoByUsername(String username); -->
<select id="getLoginInfoByUsername" resultMap="LoginResultMap">
    SELECT
        <include refid="LoginQueryFields" />
    FROM
        ams_admin
    WHERE
        username=#{username}
</select>

<sql id="LoginQueryFields">
    <if test="true">
        id, username, password, enable
    </if>
</sql>

<resultMap id="LoginResultMap" type="cn.tedu.csmall.passport.pojo.vo.AdminLoginInfoVO">
    <id column="id" property="id"/>
    <result column="username" property="username"/>
    <result column="password" property="password"/>
    <result column="enable" property="enable"/>
</resultMap>
最后,在AdminMapperTests中编写并执行测试:

@Test
void testGetLoginInfoByUsername() {
    String username = "root";
    Object result = mapper.getLoginInfoByUsername(username);
    System.out.println("根据username=" + username + "查询登录信息完成,结果=" + result);
}
完成后,调整UserDetailsServiceImpl中的方法实现,根据是否查询到管理员信息来决定是否返回有效的UesrDetails对象,并且,当查询到管理员信息时,将查询到的信息封装到UserDetails对象中并返回:

@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
    log.debug("Spring Security调用了loadUserByUsername()方法,参数:{}", s);
    AdminLoginInfoVO loginInfo = adminMapper.getLoginInfoByUsername(s);
    log.debug("从数据库查询与用户名【{}】匹配的管理员信息:{}", s, loginInfo);

    if (loginInfo != null) {
        UserDetails userDetails = User.builder()
                .username(loginInfo.getUsername())
                .password(loginInfo.getPassword())
                .accountExpired(false)
                .accountLocked(false)
                .disabled(loginInfo.getEnable() == 0)
                .authorities("这是一个山寨的权限标识") // 权限,注意,此方法的参数不可以为null,在不处理权限之前,可以写一个随意的字符串值
                .build();
        log.debug("即将向Spring Security返回UserDetails对象:{}", userDetails);
        return userDetails;
    }

    log.debug("此用户名【{}】不存在,即将向Spring Security返回为null的UserDetails值", s);
    return null;
}
至此,重启项目,通过Spring Security的登录表单,可以使用数据库中正确的管理员信息实现登录。

注意:如果数据库中的某些管理员数据是错误的(例如密码不是BCrypt密文、enable字段为null),则不能使用这些错误的数据尝试登录,应该修复这些错误的数据,或删除这些错误的数据!

46. 前后端分离的登录认证
目前,可以通过Spring Security的登录表单来实现认证,但是,这并不是前后端分离的做法,如果需要实现前后端分离的登录认证,需要:

禁用Spring Security的登录表单

使用控制器接收客户端提交的登录请求

需要将此请求的URL添加到“白名单”

在控制器处理登录请求时,调用Service对象处理登录认证

在Service实现类中处理登录认证

调用AuthenticationManager对象的authenticate()方法,将由Spring完成认证

调用authentication()方法时,需要传入用户名、密码,则Spring Security框架会自动调用UserDetailsService对象的loadUserByUsername()方法,并自动处理后续的认证(判断密码、enable等)

禁用Spring Security的登录表单

在SecurityConfiguration的void configurer(HttpSecurity http)方法中,不再调用http.formLogin()即可。

使用控制器接收客户端提交的登录请求

先在pojo.dto包中创建AdminLoginDTO类:

package cn.tedu.csmall.passport.pojo.dto;

import lombok.Data;

import java.io.Serializable;

/**
 * 管理员登录的DTO类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Data
public class AdminLoginDTO implements Serializable {

    /**
     * 用户名
     */
    private String username;

    /**
     * 密码(原文)
     */
    private String password;

}
在AdminController中添加处理请求的方法:

// http://localhost:9081/admins/login
@PostMapping("/login")
public JsonResult login(AdminLoginDTO adminLoginDTO) {
    log.debug("开始处理【管理员登录】的请求,参数:{}", adminLoginDTO);
    // TODO 调用Service处理登录
    return null;
}
并且,在SecurityConfiguration中,将 /admins/login 添加到“白名单”中。

在控制器处理登录请求时,调用Service对象处理登录认证

在IAdminService中添加处理登录认证的抽象方法:

/**
 * 管理员登录
 *
 * @param adminLoginDTO 封装了管理员的登录信息的对象
 */
void login(AdminLoginDTO adminLoginDTO);
在AdminServiceImpl中实现以上抽象方法:

public void login(AdminLoginDTO adminLoginDTO) {
    log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);
    // TODO 调用AuthenticationManager对象的authenticate()方法处理认证
}
回到AdminController中,可以补充调用Service的代码:

// http://localhost:9081/admins/login
@PostMapping("/login")
public JsonResult login(AdminLoginDTO adminLoginDTO) {
    log.debug("开始处理【管理员登录】的请求,参数:{}", adminLoginDTO);
    adminService.login(adminLoginDTO);
    return JsonResult.ok();
}
在Service实现类中处理登录认证

首先,需要在SecurityConfiguration配置类(继承自WebSecurityConfigurerAdapter)中重写authenticationManager()或authenticationManagerBean()方法,例如:

@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
    log.debug("创建@Bean方法定义的对象:AuthenticationManager");
    return super.authenticationManagerBean();
}
然后,在AdminServiceImpl类中就可以自动装配AuthenticationManager对象了:

@Autowired
AuthenticationManager authenticationManager;
并且,调用此对象的方法执行认证:

@Override
public void login(AdminLoginDTO adminLoginDTO) {
    log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);
    // 调用AuthenticationManager对象的authenticate()方法处理认证
    Authentication authentication
            = new UsernamePasswordAuthenticationToken(
                    adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
    authenticationManager.authenticate(authentication);
    log.debug("执行认证成功");
}
Spring Security在执行认证时,如果不通过(可能是用户名不存在、密码错误、账号已经被禁用等),都会抛出更种异常,如果通过认证,则程序会继续向后执行。

测试访问

以上全部完成后,重启项目,通过在线API文档可以测试访问。

处理异常

在业务状态码的枚举类型中,添加新的枚举值:

public enum ServiceCode {

    OK(20000),
    ERR_BAD_REQUEST(40000),
    ERR_UNAUTHORIZED(40100), // ===== 新增 =====
    ERR_UNAUTHORIZED_DISABLED(40101), // ===== 新增 =====
    ERR_NOT_FOUND(40400),
    ERR_CONFLICT(40900),
     // 省略后续代码   
}
在全局异常处理器中,补充对相关异常的处理:

@ExceptionHandler({
        InternalAuthenticationServiceException.class,
        BadCredentialsException.class
})
public JsonResult handleAuthenticationException(AuthenticationException e) {
    log.debug("捕获到AuthenticationException");
    log.debug("异常类型:{}", e.getClass().getName());
    log.debug("异常信息:{}", e.getMessage());
    String message = "登录失败,用户名或密码错!";
    return JsonResult.fail(ServiceCode.ERR_UNAUTHORIZED, message);
}

@ExceptionHandler
public JsonResult handleDisabledException(DisabledException e) {
    log.debug("捕获到DisabledException:{}", e.getMessage());
    String message = "登录失败,此管理员账号已经被禁用!";
    return JsonResult.fail(ServiceCode.ERR_UNAUTHORIZED_DISABLED, message);
}
注意:尽管此时已经可以通过在线API文档尝试登录,并且可以得到预期的反馈,但是,这并不是真正意义的“登录成功”,因为还没有处理通过认证后保存用户信息(例如将用户信息存储到Session中)!

48. 关于Session
HTTP协议是无状态的协议,即:从协议本身并没有约定需要保存用户状态!表现为:某个客户端访问了服务器之后,后续的每一次访问,服务器都无法识别出这是前序访问的客户端!

在传统的解决方案中,可以从技术层面来解决服务器端识别客户端并保存相关数据的问题,例如使用Session机制。

Session的本质是保存在服务器端的内存中的类似Map结构的数据,每个客户端都有一个属于自己的Key,在服务器端有对应的Value,就是Session。

关于客户端提交请求时的Key:当某客户端第1次向服务器端提交请求时,并没有可用的Key,所以并不携带Key来提交请求,当服务器端发现客户端没有携带Key时,就会响应一个Key到客户端,客户端会将这个Key保存下来,并在后续的每一次请求中自动携带这个Key。并且,服务器端为了保证各个Key不冲突,会使用UUID算法来生成各个Key。由于这些Key是用于访问Session数据的,所以,一般称之为Session ID。

基于Session的特点,在使用时,可能存在一些问题:

不能直接用于集群甚至分布式系统

可以通过共享Session技术来解决

将占用服务器端的内存,则不宜长时间保存

49. 关于Token
Token:票据,令牌

Token机制是目前主流的取代Session用于服务器端识别客户端身份的机制。

Token就类似于现实生活中的“火车票”,当客户端向服务器端提交登录请求时,就类似于“买票”的过程,当登录成功后,服务器端会生成对应的Token并响应到客户端,则客户端就拿到了所需的“火车票”,在后续的访问中,客户端携带“火车票”即可,并且,服务器端有“验票”机制,能够根据客户端携带的“火车票”识别出客户端的身份。

50. 关于JWT
JWT:JSON Web Token,是使用JSON格式来组织多个属性于值,主要用于Web访问的Token。

JWT的本质就是只一个字符串,是通过算法进行编码后得到的结果。

在项目中,如果需要生成、解析JWT,需要添加相关依赖项,能够实现生成、解析JWT的工具包较多,可以自由选择,可参考:https://jwt.io/libraries?language=Java

例如,在pom.xml中添加:

<!-- JJWT(Java JWT) -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>
day15
50. 关于JWT(续)
JWT是不安全的,因为在不知道secretKey的情况下,任何JWT都是可以解析出Header、Payload部分的,这2部分的数据并没有做任何加密处理,所以,如果JWT数据被暴露,则任何人都可以从中解析出Header、Payload中的数据!

至于JWT中的secretKey,及生成JWT时使用的算法,是用于对Header、Payload执行签名算法的,JWT中的Signature是用于验证JWT真伪的。

当然,如果你认为有必要的话,可以自行另外使用加密算法,将Payload中应该封装的数据先加密,再用于生成JWT!

另外,如果JWT数据被泄露,他人使用有效的JWT是可以正常使用的!所以,通常,在相对比较封闭的操作系统(例如智能手机的操作系统)中,JWT的有效时间可以设置得很长,但是,不太封闭的操作系统(例如PC端的操作系统)中,JWT的有效时间应该相对较短。

所以,在JWT时,需要注意:

根据你所需的安全性,来设置JWT的有效时间

不要在JWT中存放敏感数据,例如:手机号码、身份证号码、明文密码

如果一定要在JWT中存放敏感数据,应该自行使用加密算法处理过后再用于生成JWT

51. 登录成功时生成并响应JWT
在使用JWT的项目,用户登录就相当于现实生活乘车之前购买火车票的过程,所以,当用户登录成功时,需要生成对应的JWT数据,并响应到客户端。

首先,需要修改IAdminService接口中处理登录的抽象方法的声明,将返回值类型改为String,表示将返回成功登录的JWT数据:

/**
 * 管理员登录
 *
 * @param adminLoginDTO 封装了管理员的登录信息的对象
 * @return 成功登录的JWT数据
 */
String login(AdminLoginDTO adminLoginDTO);
然后,在AdminServiceImpl实现类中,也修改重写的方法的声明,并且,在登录成功后,生成、返回JWT数据:

log.debug("准备生成JWT数据");
Map<String, Object> claims = new HashMap<>();
// claims.put("id", null); // 向JWT中封装id
claims.put("username", adminLoginDTO.getUsername()); // 向JWT中封装username

String secretKey = "kns439a}fdLK34jsmfd{MF5-8DJSsLKhJNFDSjn";
Date expirationDate = new Date(System.currentTimeMillis() + 10 * 24 * 60 * 60 * 1000);
String jwt = Jwts.builder()
        .setHeaderParam("alg", "HS256")
        .setHeaderParam("typ", "JWT")
        .setClaims(claims)
        .setExpiration(expirationDate)
        .signWith(SignatureAlgorithm.HS256, secretKey)
        .compact();
log.debug("返回JWT数据:{}", jwt);
return jwt;
提示:以上代码并不是最终版本。

最后,在AdminController中,还需要响应JWT数据:

// http://localhost:9081/admins/login
@PostMapping("/login")
public JsonResult<String> login(AdminLoginDTO adminLoginDTO) {
    log.debug("开始处理【管理员登录】的请求,参数:{}", adminLoginDTO);
    String jwt = adminService.login(adminLoginDTO);
    return JsonResult.ok(jwt);
}
完成后,重启项目,可以在API文档中测试访问,当登录成功后,响应的结果大致是:

{
  "state": 20000,
  "data": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NjY0ODk1NjMsInVzZXJuYW1lIjoic3VwZXJfYWRtaW4ifQ.T5wnIVFk-AhvxPETloDsSgx46vdV45Y3BRk1_0oc3CM"
}
关于处理认证的细节

当调用了AuthenticationManager对象的authenticate()方法,且通过认证后,此方法将返回Authentication接口类型的对象,此对象的具体类型是UsernamePasswordAuthenticationToken,此对象中包含名为Principal(当事人)的属性,值为UserDetailsService对象中loadUserByUsername()返回的对象!

另外,目前在UserDetailsServiceImpl中返回的UserDetails接口类型的对象是User类型的,此类型没有id属性,如果需要向JWT中封装id甚至其它属性,必须自定义类,继承自User或实现UserDetails接口,在自定义类中补充声明所需的属性,并在UserDetailsServiceImpl中返回自定义类的对象,则处理认证通过后,返回的Authentication中的Principal就是自定义类的对象!

在security包中创建AdminDetails类,继承自User对其进行扩展:

@Setter
@Getter
@EqualsAndHashCode
@ToString(callSuper = true)
public class AdminDetails extends User {

    private Long id;

    public AdminDetails(String username, String password, boolean enabled,
                        Collection<? extends GrantedAuthority> authorities) {
        super(username, password, enabled,
                true, true, true,
                authorities);
    }

}
在UserDetailsServiceImpl中,调整为返回AdminDetails类型的对象:

@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
    log.debug("Spring Security调用了loadUserByUsername()方法,参数:{}", s);
    AdminLoginInfoVO loginInfo = adminMapper.getLoginInfoByUsername(s);
    log.debug("从数据库查询与用户名【{}】匹配的管理员信息:{}", s, loginInfo);

    if (loginInfo == null) {
        log.debug("此用户名【{}】不存在,即将抛出异常");
        String message = "登录失败,用户名不存在!";
        throw new BadCredentialsException(message);
    }
    
    // ===== 以下是调整的内容 =====
    List<GrantedAuthority> authorities = new ArrayList<>();
    GrantedAuthority authority = new SimpleGrantedAuthority("这是一个山寨的权限标识");
    authorities.add(authority);

    AdminDetails adminDetails = new AdminDetails(
            loginInfo.getUsername(), loginInfo.getPassword(),
            loginInfo.getEnable() == 1, authorities);
    adminDetails.setId(loginInfo.getId());

    log.debug("即将向Spring Security返回UserDetails接口类型的对象:{}", adminDetails);
    return adminDetails;
}
经过以上调整,当AuthenticationManager执行authenticate()认证方法后,如果登录成功,返回的Authentication中的Principal就是以上返回的AdminDetails对象,则可以从中获取id、username等数据,用于生成JWT数据,则在AdminServiceImpl中的login()方法中:

@Override
public String login(AdminLoginDTO adminLoginDTO) {
    log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);
    // 调用AuthenticationManager对象的authenticate()方法处理认证
    Authentication authentication
            = new UsernamePasswordAuthenticationToken(
                    adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
    Authentication authenticateResult
            = authenticationManager.authenticate(authentication);
    log.debug("执行认证成功,AuthenticationManager返回:{}", authenticateResult);
    Object principal = authenticateResult.getPrincipal();
    log.debug("认证结果中的Principal数据类型:{}", principal.getClass().getName());
    log.debug("认证结果中的Principal数据:{}", principal);
    AdminDetails adminDetails = (AdminDetails) principal;

    log.debug("准备生成JWT数据");
    Map<String, Object> claims = new HashMap<>();
    claims.put("id", adminDetails.getId()); // 向JWT中封装id
    claims.put("username", adminDetails.getUsername()); // 向JWT中封装username

    String secretKey = "kns439a}fdLK34jsmfd{MF5-8DJSsLKhJNFDSjn";
    Date expirationDate = new Date(System.currentTimeMillis() + 10 * 24 * 60 * 60 * 1000);
    String jwt = Jwts.builder()
            .setHeaderParam("alg", "HS256")
            .setHeaderParam("typ", "JWT")
            .setClaims(claims)
            .setExpiration(expirationDate)
            .signWith(SignatureAlgorithm.HS256, secretKey)
            .compact();
    log.debug("返回JWT数据:{}", jwt);
    return jwt;
}
至此,当客户端向服务器端提交登录请求,且登录成功后,将得到服务器端响应的JWT数据,此JWT中包含了id和username。

解析JWT
当客户端已经登录成功并得到JWT,相当于现实生活中某人已经成功购买到了火车票,接下来,此人应该携带火车票去乘车,在程序中,就表现为:客户端应该携带JWT向服务器端提交请求。

关于客户端携带JWT数据,业内惯用的做法是客户端应该将JWT放在请求头(Request Headers)中名为Authorization的属性中。

在服务器端,通常使用过滤器组件来解析JWT数据。

在项目的根包下创建JwtAuthorizationFilter:

package cn.tedu.csmall.passport.filter;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

/**
 * JWT认证过滤器
 *
 * <p>Spring Security框架会自动从SecurityContext读取认证信息,如果存在有效信息,则视为已登录,否则,视为未登录</p>
 * <p>当前过滤器应该尝试解析客户端可能携带的JWT,如果解析成功,则创建对应的认证信息,并存储到SecurityContext中</p>
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {

    public static final int JWT_MIN_LENGTH = 100;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        // 尝试获取客户端提交请求时可能携带的JWT
        String jwt = request.getHeader("Authorization");
        log.debug("接收到JWT数据:{}", jwt);

        // 判断是否获取到有效的JWT
        if (!StringUtils.hasText(jwt) || jwt.length() < JWT_MIN_LENGTH) {
            // 直接放行
            log.debug("未获取到有效的JWT数据,将直接放行");
            filterChain.doFilter(request, response);
            return;
        }

        // 尝试解析JWT,从中获取用户的相关数据,例如id、username等
        log.debug("将尝试解析JWT……");
        String secretKey = "kns439a}fdLK34jsmfd{MF5-8DJSsLKhJNFDSjn";
        Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
        Long id = claims.get("id", Long.class);
        String username = claims.get("username", String.class);
        log.debug("从JWT中解析得到数据:id={}", id);
        log.debug("从JWT中解析得到数据:username={}", username);

        // 将根据从JWT中解析得到的数据来创建认证信息
        List<GrantedAuthority> authorities = new ArrayList<>();
        GrantedAuthority authority = new SimpleGrantedAuthority("这是一个山寨的权限标识");
        authorities.add(authority);
        Authentication authentication = new UsernamePasswordAuthenticationToken(
                username, null, authorities);

        // 将认证信息存储到SecurityContext中
        SecurityContext securityContext = SecurityContextHolder.getContext();
        securityContext.setAuthentication(authentication);

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

}
完成后,还需要在SecurityConfiguration中自动装配自定义的JWT过滤器:

@Autowired
JwtAuthorizationFilter jwtAuthorizationFilter;
并在configurer()方法中补充:

// 将自定义的JWT过滤器添加在Spring Security框架内置的过滤器之前
http.addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class);
52. 关于认证信息中的Principal
关于SecurityContext中的认证信息,应该包含当事人(Principal)和权限(Authorities),其中,当事人(Principal)被声明为Object类型的,则可以使用任意数据类型作为当事人!

在使用了Spring Security框架的项目中,当事人的数据是可以被注入到处理请求的方法中的!所以,使用哪种数据作为当事人,主要取决于“你在编写控制器中处理请求的方法时,需要通过哪些数据来区分当前登录的用户”。

通常,使用自定义的数据类型作为当事人,并在此类型中封装关键数据,例如id、username等。

则在security包下创建LoginPrincipal类:

@Data
public class LoginPrincipal implements Serializable {
    private Long id;
    private String username;
}
在JWT过滤器创建认证信息时,使用以上类型的对象作为认证信息中的当事人:

LoginPrincipal loginPrincipal = new LoginPrincipal(); // 新增
loginPrincipal.setId(id); // 新增
loginPrincipal.setUsername(username); // 新增

// 注意:以下调用构造方法时,第1个参数是以上创建的对象
Authentication authentication = new UsernamePasswordAuthenticationToken(
        loginPrincipal, null, authorities);
完成后,在当前项目任何控制器中任何处理请求的方法上,都可以添加@AuthenticationPrincipal LoginPrincipal loginPrincipal参数(与原有的其它参数不区分先后顺序),此参数的值就是以上过滤器中存入到认证信息中的当事人,所以,可以通过这种做法,在处理请求时识别当前登录的用户:

@ApiOperation("删除管理员")
@ApiOperationSupport(order = 200)
@ApiImplicitParam(name = "id", value = "管理员id", required = true, dataType = "long")
@PostMapping("/{id:[0-9]+}/delete")
public JsonResult<Void> delete(@PathVariable Long id,
        // ===== 以下是新增的方法参数 =====
        @ApiIgnore @AuthenticationPrincipal LoginPrincipal loginPrincipal) {
    log.debug("开始处理【删除管理员】的请求,参数:{}", id);
    log.debug("当前登录的当事人:{}", loginPrincipal); // 新增,可以控制台观察数据
    adminService.delete(id);
    return JsonResult.ok();
}
53. 关于CORS与PreFlight
如果客户端向服务器端提交请求,在跨域的前提下,如果提交的请求配置了请求头中的非典型参数,例如配置了Authorization,此请求会被视为“复杂请求”,则会要求执行“预检”(PreFlight),如果预检不通过,则会导致跨域请求错误!

关于预检,浏览器会自动向服务器端提交OPTIONS类型的请求执行预检,为了确保预检通过,不影响处理正常的请求,需要在SecurityConfiguration的configurer()方法中对预检请求放行,可以采取的解决方案有:

http.authorizeRequests()
                .antMatchers(urls)
                .permitAll()

                // 以下2行代码是用于对预检的OPTIONS请求直接放行的
                .antMatchers(HttpMethod.OPTIONS, "/**")
                .permitAll()

                .anyRequest()
                .authenticated();
或者,也可以:

http.cors(); // 启用Spring Security框架的处理跨域的过滤器,此过滤器将放行跨域请求,包括预检的OPTIONS请求
则客户端可以携带复杂请求头进行访问:

loadAdminList() {
  console.log('loadAdminList ...');
  let url = 'http://localhost:9081/admins';
  console.log('url = ' + url);
  this.axios
      .create({
        'headers': {
          'Authorization': localStorage.getItem('jwt')
        }
      })
      .get(url).then((response) => {
    let responseBody = response.data;
    console.log(responseBody);
    this.tableData = responseBody.data;
  });
}
作业
在“类别”表(pms_category)中,存在名为parent_id的字段,表示“父级类别”,
例如存在id=1的类别名为“家电”,则名为“冰箱”的类别的parent_id值就应该是1,
所以,“家电”是一级类别,而“冰箱”是二级类别,另外,
所有一级类别的parent_id值为0,现要求实现:

Mapper层:根据父级类别查询子级类别列表

Service层:同上,无特别的业务规则

Controller层:同上

前端页面:显示所有一级类别的列表,暂不关心子级类别的显示

在前序作业中,已经完成“添加属性”的功能,且已知每个“属性”都归属于某个“属性模板”,现要求实现:

Mapper层:根据“属性模板”查询“属性”列表

Service层:同上,无特别的业务规则

Controller层:同上

前端页面:显示某个“属性模板”中的属性列表,关于“属性模板”的id,可暂时写成一个固定值

此作业请于本周日(10月16日)23:00之前提交到作业系统。

day16
54. 关于SecurityContext中的认证信息
Spring Security框架是根据SecurityContext中是否存在认证信息来判断用户是否已经登录。

关于SecurityContext,是通过ThreadLocal进行处理的,所以,是线程安全的,每个客户端对应的SecurityContext中的信息是互不干扰的。

另外,SecurityContext中的认证信息是通过Session存储的,所以,一旦向SecurityContext中存入了认证信息,在后续一段时间(Session的有效时间)的访问中,即使不携带JWT,也是允许访问的,会被视为“已登录”。如果认为这样的表现是不安全的,可以在JWT过滤器中,在刚刚接收到请求时,就直接清除SecurityContext中的信息(主要是认证信息):

// 清除SecurityContext中原有的数据(认证信息)
SecurityContextHolder.clearContext();
53. 自定义配置
在处理JWT时,无论是生成JWT,还是解析JWT,都需要使用同一个secretKey,则应该将此secretKey定义在某个类中作为静态常量,或定义在配置文件(application.yml或等效的配置文件)中,由于此值是允许被软件的使用者(甲方)自行定义的,所以,更推荐定义在配置文件中。

则在application-dev.yml中添加自定义配置:

# 自定义配置
csmall:
  jwt:
    secret-key: kns439a}fdLK34jsmfd{MF5-8DJSsLKhJNFDSjn
提示:在配置文件中的自定义属性,应该在属性名称上添加统一的、自定义的前缀,例如以上使用到的csmall,以便于与其它的属性区分开来。

接下来,可以在需要使用以上配置值的类中,通过@Value注解将以上配置值注入到某个全局属性中,例如:

@Value("${csmall.jwt.secret-key}")
String secretKey;
提示:以上使用的@Value注解可以读取当前项目中的全部环境变量,将包括:操作系统的环境变量、JVM的环境变量、各配置文件中的配置。并且,@Value注解可以添加在全局属性上,也可以添加在被Spring自动调用的方法的参数上。

54. 处理解析JWT时的异常
在JWT过滤器中,解析JWT时可能会出现异常,异常的类型主要有:

SignatureException

MalformedJwtException

ExpiredJwtException

由于解析JWT是发生成过滤器中的,而过滤器是整个Java EE体系中最早接收到请求的组件(此时,控制器等其它组件均未开始执行),所以,此时出现的异常不可以使用Spring MVC的全局异常处理器进行处理。

提示:Spring MVC的全局异常处理器在控制器(Controller)抛出异常之后执行。

只能通过最原始的try...catch...语法捕获并处理异常,处理时,需要使用到过滤器方法的第2个参数HttpServletResponse response来向客户端响应错误信息。

为了便于封装错误信息,应该使用JsonResult来封装相关信息,由于需要自行将JsonResult格式的对象转换成JSON格式的数据,所以,需要在pom.xml添加能够实现对象与JSON格式字符串相互转换的依赖,例如可以添加fastjson依赖:

<!-- fastjson:实现对象与JSON的相互转换 -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.75</version>
</dependency>
然后,在ServiceCode中添加一些新的业务状态码:

public enum ServiceCode {

    // 前序代码
    
    ERR_JWT_SIGNATURE(60000),
    ERR_JWT_MALFORMED(60000),
    ERR_JWT_EXPIRED(60002),
    ERR_UNKNOWN(99999);
    
    // 后续代码
    
}
再开始处理异常,例如:

// 尝试解析JWT
log.debug("将尝试解析JWT……");
Claims claims = null;
try {
    claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
} catch (SignatureException e) {
    String message = "非法访问!";
    JsonResult jsonResult = JsonResult.fail(ServiceCode.ERR_JWT_SIGNATURE, message);
    String jsonResultString = JSON.toJSONString(jsonResult);
    PrintWriter writer = response.getWriter();
    writer.println(jsonResultString);
    writer.close();
    return;
} catch (MalformedJwtException e) {
    String message = "非法访问!";
    JsonResult jsonResult = JsonResult.fail(ServiceCode.ERR_JWT_MALFORMED, message);
    String jsonResultString = JSON.toJSONString(jsonResult);
    PrintWriter writer = response.getWriter();
    writer.println(jsonResultString);
    writer.close();
    return;
} catch (ExpiredJwtException e) {
    String message = "登录已过期,请重新登录!";
    JsonResult jsonResult = JsonResult.fail(ServiceCode.ERR_JWT_EXPIRED, message);
    String jsonResultString = JSON.toJSONString(jsonResult);
    PrintWriter writer = response.getWriter();
    writer.println(jsonResultString);
    writer.close();
    return;
} catch (Throwable e) {
    e.printStackTrace(); // 重要
    String message = "服务器忙,请稍后再次尝试!";
    JsonResult jsonResult = JsonResult.fail(ServiceCode.ERR_UNKNOWN, message);
    String jsonResultString = JSON.toJSONString(jsonResult);
    PrintWriter writer = response.getWriter();
    writer.println(jsonResultString);
    writer.close();
    return;
}
注意:强烈推荐在最后补充处理Throwable异常,以避免某些异常未被考虑到,并且,在处理Throwable时,应该执行e.printStackTrace(),则出现未预测的异常时,可以通过控制台看到相关信息,并在后续补充对这些异常的精准处理!

55. 处理授权
首先,需要调整现有的AdminMapper接口中的AdminLoginInfoVO getLoginInfoByUsername(String username)方法,此方法应该返回参数用户名匹配的管理员信息,信息中应该包含权限!

则需要执行的SQL语句大致是:

SELECT
    ams_admin.id,
    ams_admin.username,
    ams_admin.password,
    ams_admin.enable,
    ams_permission.value
FROM
    ams_admin
LEFT JOIN ams_admin_role ON ams_admin.id=ams_admin_role.admin_id
LEFT JOIN ams_role_permission ON ams_admin_role.role_id=ams_role_permission.role_id
LEFT JOIN ams_permission ON ams_role_permission.permission_id=ams_permission.id
WHERE
    username='root';
则需要调整AdminLoginInfoVO类,添加新的属性,用于封装查询到的权限信息:

private List<String> permissions;
然后调整AdminMapper.xml中的相关配置:

<!-- AdminLoginInfoVO getLoginInfoByUsername(String username); -->
<select id="getLoginInfoByUsername" resultMap="LoginResultMap">
    SELECT
        <include refid="LoginQueryFields"/>
    FROM
        ams_admin
    LEFT JOIN ams_admin_role ON ams_admin.id=ams_admin_role.admin_id
    LEFT JOIN ams_role_permission ON ams_admin_role.role_id=ams_role_permission.role_id
    LEFT JOIN ams_permission ON ams_role_permission.permission_id=ams_permission.id
    WHERE
        username=#{username}
</select>

<sql id="LoginQueryFields">
    <if test="true">
        ams_admin.id,
        ams_admin.username,
        ams_admin.password,
        ams_admin.enable,
        ams_permission.value
    </if>
</sql>

<!-- 当涉及1个多查询时,需要使用collection标签配置List集合类型的属性 -->
<!-- collection标签的property属性:类中List集合的属性的名称 -->
<!-- collection标签的ofType属性:类中List集合的元素类型的全限定名 -->
<!-- collection标签的子级:需要配置如何创建出一个个元素对象 -->
<!-- constructor标签:将通过构造方法来创建对象 -->
<!-- constructor标签子级的arg标签:配置构造方法的参数 -->
<resultMap id="LoginResultMap" type="cn.tedu.csmall.passport.pojo.vo.AdminLoginInfoVO">
    <id column="id" property="id"/>
    <result column="username" property="username"/>
    <result column="password" property="password"/>
    <result column="enable" property="enable"/>
    <collection property="permissions" ofType="java.lang.String">
        <constructor>
            <arg column="value"/>
        </constructor>
    </collection>
</resultMap>
完成后,可以通过AdminMapperTests中原有的测试方法直接测试,测试结果例如:

根据username=fanchuanqi查询登录信息完成,结果=AdminLoginInfoVO(id=5, username=fanchuanqi, password=$2a$10$N.ZOn9G6/YLFixAOPMg/h.z7pCu6v2XyFDtC4q.jeeGm/TEZyj15C, enable=0, permissions=[/pms/picture/read, /pms/picture/add-new, /pms/picture/delete, /pms/picture/update, /pms/album/read, /pms/album/add-new, /pms/album/delete, /pms/album/update])
接下来,在UserDetailsServiceImpl中,向返回的AdminDetails中封装真实的权限数据:

@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
    log.debug("Spring Security调用了loadUserByUsername()方法,参数:{}", s);
    AdminLoginInfoVO loginInfo = adminMapper.getLoginInfoByUsername(s);
    log.debug("从数据库查询与用户名【{}】匹配的管理员信息:{}", s, loginInfo);

    if (loginInfo == null) {
        log.debug("此用户名【{}】不存在,即将抛出异常");
        String message = "登录失败,用户名不存在!";
        throw new BadCredentialsException(message);
    }

    // ===== 以下是此次调整的内容 =====
    List<GrantedAuthority> authorities = new ArrayList<>();
    for (String permission : loginInfo.getPermissions()) {
        GrantedAuthority authority = new SimpleGrantedAuthority(permission);
        authorities.add(authority);
    }

    AdminDetails adminDetails = new AdminDetails(
            loginInfo.getUsername(), loginInfo.getPassword(),
            loginInfo.getEnable() == 1, authorities);
    adminDetails.setId(loginInfo.getId());
}
经过以上调整后,在AdminServiceImpl处理登录的login()方法中,认证返回的结果的当事人(Principal)中就包含管理员的权限信息了!

day17
1. 单点登录(SSO)
SSO:Single Sign On,即:单点登录

单点登录表现为:在集群或分布式系统中,客户端在其中的某1个服务器登录,后续的请求被分配到其它服务器处理时,其它服务器也能识别用户的身份。

单点登录的实现方案有:

共享Session

把所有客户端的Session数据存储到专门的服务器上,其它任何服务器需要识别客户端身份时,都从这个专门的服务器上去查找、读取Session数据

缺点:Session的有效期不宜过长

优点:编码简单,读取Session数据基本上没有额外牺牲性能

Token

当某客户端登录成功,服务器端将响应Token到客户端,在后续的访问中,客户端自行携带Token数据来访问任何服务器,且任何服务器都具备解析此Token的功能,即可识别客户端的身份

JWT(JSON Web Token)也是Token的一种

缺点:编写代码略难,需要频繁解析JWT,需要牺牲一部分性能来进行解析

优点:可以长时间有效

2. 实现SSO
目前,在csmall-passport项目中已经现实了认证与授权,只要客户端能携带有效的JWT,则服务器端可以识别客户端的身份!

在csmall-product项目中,只需要添加Spring Security框架的依赖项,并添加认证相关代码,就可以实现“客户端在csmall-passport登录后,在csmall-product上也可以识别用户的身份”!

需要从csmall-passport中复制到csmall-product中的代码有:

复制相关依赖项

spring-boot-starter-security

jjwt

fastjson

复制application-dev.yml中关于JWT的自定义配置

LoginPrincipal

ServiceCode(更新文件,在passport中添加了一些新的业务状态码,在product中也将需要使用到)

JwtAuthorizationFilter

SecurityConfiguration

删除PasswordEncoder的@Bean方法

删除AuthenticationManager的@Bean方法

删除configurer()方法中“白名单”中的 "/admins/login" 路径

GlobalExceptionHandler(更新文件,处理“无操作权限”相关异常)

在前端项目中,保证除了登录的每个请求都添加了请求头中的JWT即可。

本项目基于Spring Security和JWT实现了SSO(单点登录)。

关于Redis
Redis是一款使用K-V结构的基于内存的NoSQL非关系型数据库。

内存是计算机的硬件系统中,除了CPU/GPU内置的缓存以外,存取效率最高的存储设备。

关于Redis的基本使用
当安装并启动了Redis服务后,在操作系统的终端中,通过redis-cli命令可以登录Redis客户端,当登录成功后,操作提示符将变为 127.0.0.1:6379>。

当已经登录Redis客户端后,可以通过 exit 命令退出,则会回到操作系统的终端。

当已经登录Redis客户端后,可以通过 ping 命令检查Redis服务是否仍处于可用状态。

当已经登录Redis客户端后,可以通过 shutdown 命令停止Redis服务。

在操作系统的终端中,可以通过 redis-server 启动Redis服务。

当已经登录Redis客户端后,可以通过 set key value 命令向Redis中存入数据,例如:

set name liucangsong
当已经登录Redis客户端后,可以通过 get key 命令从Redis中取出曾经存入的数据,例如:

get name
与Java语言中的Map相同,在Redis中的Key也是唯一的,所以,当通过 set key value 存入数据时,如果Key不存在,则是新增数据的操作,如果Key已经存在,则是修改数据的操作。

当已经登录Redis客户端后,可以通过 keys Key的名称或带通配符的模式 命令查询Redis中的相关Key的列表,例如:

keys email
keys *
关于更多Redis的命令操作,可查阅资料,例如:

Redis 常用操作命令,非常详细! - 知乎 (zhihu.com) (https://zhuanlan.zhihu.com/p/47692277)

关于Redis编程
在Spring Boot项目中,要实现Redis编程,应该添加相关的依赖:

<!-- Spring Data Redis,用于实现Redis编程 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Redis编程主要通过RedisTemplate工具来实现,应该通过配置类的@Bean方法返回此类型的对象,并在需要使用Redis编程时自动装配此对象!

则在根包下创建config.RedisConfiguration配置类:

package cn.tedu.csmall.product.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;

import java.io.Serializable;

/**
 * Redis的配置类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Slf4j
@Configuration
public class RedisConfiguration {

    public RedisConfiguration() {
        log.debug("创建配置类对象:RedisConfiguration");
    }


    @Bean
    public RedisTemplate<String, Serializable> redisTemplate(
            RedisConnectionFactory redisConnectionFactory) {
        log.debug("创建@Bean方法定义的对象:RedisTemplate");
        RedisTemplate<String, Serializable> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        redisTemplate.setKeySerializer(RedisSerializer.string());
        redisTemplate.setValueSerializer(RedisSerializer.json());
        return redisTemplate;
    }

}
关于RedisTemplate的基本使用,可以在src/test/java的根包下创建RedisTests测试类,在此类中自动装配RedisTemplate并测试使用:

package cn.tedu.csmall.product;

import cn.tedu.csmall.product.pojo.entity.Brand;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.ListOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;

@Slf4j
@SpringBootTest
public class RedisTests {

    @Autowired
    RedisTemplate<String, Serializable> redisTemplate;

    @Test
    void setValue() {
        String key = "name";
        String value = "国斌老师";

        ValueOperations<String, Serializable> ops
                = redisTemplate.opsForValue(); // 只要是对字符串类型的Value进行操作,必须调用opsForValue()方法得到相应的操作器
        ops.set(key, value);
        log.debug("已经向Redis中写入Key为【{}】的数据:{}", key, value);
    }

    @Test
    void getValue() {
        String key = "name";

        ValueOperations<String, Serializable> ops = redisTemplate.opsForValue();
        Serializable value = ops.get(key);
        log.debug("已经从Redis中取出Key为【{}】的数据:{}", key, value);
    }

    @Test
    void setObjectValue() {
        String key = "brand1";
        Brand brand = new Brand();
        brand.setId(1L);
        brand.setName("测试品牌");

        ValueOperations<String, Serializable> ops = redisTemplate.opsForValue();
        ops.set(key, brand);
        log.debug("已经向Redis中写入Key为【{}】的数据:{}", key, brand);
    }

    @Test
    void getObjectValue() {
        String key = "brand1";

        ValueOperations<String, Serializable> ops = redisTemplate.opsForValue();
        Serializable value = ops.get(key);
        log.debug("已经从Redis中取出Key为【{}】的数据:{}", key, value);
        log.debug("取出的数据的类型是:{}", value.getClass().getName());
    }

    @Test
    void getNull() {
        String key = "hahahaha";

        ValueOperations<String, Serializable> ops = redisTemplate.opsForValue();
        Serializable value = ops.get(key);
        log.debug("已经从Redis中取出Key为【{}】的数据:{}", key, value);
    }

    @Test
    void keys() {
        String keyPattern = "*";
        Set<String> keys = redisTemplate.keys(keyPattern);
        log.debug("查询当前Redis中所有的Key,Key的数量:{}", keys.size());
        for (String key : keys) {
            log.debug("key = {}", key);
        }
    }

    @Test
    void delete() {
        String key = "name";
        Boolean result = redisTemplate.delete(key);
        log.debug("删除Key为【{}】的数据,结果:{}", key, result);
    }

    @Test
    void deleteX() {
        String keyPattern = "*";
        Set<String> keys = redisTemplate.keys(keyPattern);
        Long count = redisTemplate.delete(keys);
        log.debug("删除多条数据【Keys={}】完成,删除的数据的数量:{}", keys, count);
    }

    @Test
    void setList() {
        List<Brand> brands = new ArrayList<>();
        for (int i = 1; i <= 8; i++) {
            Brand brand = new Brand();
            brand.setId(i + 0L);
            brand.setName("测试品牌" + i);
            brands.add(brand);
        }

        String key = "brands";
        ListOperations<String, Serializable> ops = redisTemplate.opsForList(); // 得到List集合的操作器
        for (Brand brand : brands) {
            ops.rightPush(key, brand);
        }
        log.debug("向Redis中写入列表数据完成,Key为【{}】,写入的列表为:{}", key, brands);
    }

    @Test
    void listSize() {
        String key = "brands";
        ListOperations<String, Serializable> ops = redisTemplate.opsForList();
        Long size = ops.size(key);
        log.debug("在Redis中Key为【{}】的列表的长度为:{}", key, size);
    }

    @Test
    void listRange() {
        String key = "brands";
        long start = 0;
        long end = -1;
        ListOperations<String, Serializable> ops = redisTemplate.opsForList();
        List<Serializable> list = ops.range(key, start, end);
        log.debug("从Redis中读取Key为【{}】的列表,start={},end={},获取到的列表长度为:{}",
                key, start, end, list.size());
        for (Serializable item : list) {
            log.debug("{}", item);
        }
    }

}
关于存取字符串类型的数据,直接存、取即可。

关于存取对象型的数据,由于已经将值的序列化器配置为JSON(redisTemplate.setValueSerilizer(RedisSerializer.json())),在处理过程中,框架会自动将对象序列化成JSON字符串、将JSON字符串反序列化成对象,所以,对于Redis而言,操作的仍是字符串,所以,在存取对象型数据时,使用的API与存取字符串完全相同。

关于列表(List)类型的数据,首先,需要理解Redis中列表的数据结构,是一个先进后出、后进先出的栈结构:

![image-20221017174048618](C:\Users\TEDU\Desktop\每日笔记\成恒笔记\images\DAY17\image-20221017174048618.png)

并且,在Redis的List中,允许从左右两端操作列表(请将栈想像为横着的):

![image-20221017174153129](C:\Users\TEDU\Desktop\每日笔记\成恒笔记\images\DAY17\image-20221017174153129.png)

![image-20221017174203181](C:\Users\TEDU\Desktop\每日笔记\成恒笔记\images\DAY17\image-20221017174203181.png)

在读取List中的数据时,相关的API需要指定start和end,也就是读取整个列表中的某个区间的数据,无论是start还是end,都表示需要读取的数据的位置下标,例如传入的是5和10,就表示从下标为5的位置开始读取,直至下标为10的数据。在整个List中,第1个数据的下标为0,并且,Redis中的List元素都有正向的和反向的下标,正向的是从0开始递增的,反向下标是以最后一个元素作为-1,并且向前递减的:

![image-20221017174501682](C:\Users\TEDU\Desktop\每日笔记\成恒笔记\images\DAY17\image-20221017174501682.png)

所以,如果需要读取如以上图例的列表的全部数据,start值可以为0或-8,end值可以为7或-1,通常,读取全部数据推荐使用start为0,且end为-1。

day18
61. 在项目中使用Redis
由于Redis的存取效率非常高,在开发实践中,通常会将一些数据从关系型数据库(例如MySQL)中读取出来,并写入到Redis中,后续,当需要访问相关数据时,将优先从Redis中读取所需的数据,以此,可以提高数据的读取效率,并且,对一定程度的保护关系型数据库。

一旦使用Redis后,相关的数据就会同时存在于关系型数据和Redis中,即同一个数据有2份或更多(如果你使用了更多的Redis服务或其它数据处理技术),则可能出现数据不同步的问题!例如,当修改了或删除了关系型数据库中的数据,那Redis中的数据应该如何处理?同时更新?还是无视数据的变化?如果最终出现了关系型数据库和Redis中的数据不同的问题,则称之为“数据一致性问题”。

关于数据可能存在不一致的问题,首先,你必须知道,并不是所有的数据都必须同步,也就是说,当关系型数据库中的数据变化后,如果Redis中的数据没有同步发生变化,则Redis中的数据可以视为是“不准确的”,这个问题在许多应用场景中是可以接受的!例如热门话题的排行榜,或车票的余票数量、商品的库存余量等。

通常,应该Redis的前提应该是:

高频率访问的数据

例如热门榜单

修改频率非常低的数据

例如电商平台中商品的类别

对数据的“准确性”(一致性)要求不高的

例如商品的库存余量

62. 应用Redis
在项目中应用Redis主要需要实现:

将数据从MySQL中读出

已经由Mapper实现

【XX时】向Redis中写入

当需要读取数据时,将原本的从MySQL中读取数据改为从Redis中读取

推荐创建专门用于读写Redis的组件,则在项目的根包下创建repo.IBrandRedisRepository接口:

public interface IBrandRedisRepository {}
并在项目的根包下创建repo.impl.BrandRedisRepositoryImpl类,实现以上接口,并在类上添加@Repository注解:

@Repository
public class BrandRedisRepositoryImpl implements IBrandRedisRepository {}
然后,在IBrandRedisRepository接口中添加抽象方法:

package cn.tedu.csmall.product.repo;

import cn.tedu.csmall.product.pojo.vo.BrandListItemVO;
import cn.tedu.csmall.product.pojo.vo.BrandStandardVO;

import java.util.List;

/**
 * 处理品牌缓存的数据访问接口
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
public interface IBrandRedisRepository {

    /**
     * 品牌数据项在Redis中的Key前缀
     */
    String BRAND_ITEM_KEY_PREFIX = "brand:item:";
    /**
     * 品牌列表在Redis中的Key
     */
    String BRAND_LIST_KEY = "brand:list";

    /**
     * 向Redis中写入品牌数据
     *
     * @param brandStandardVO 品牌数据
     */
    void save(BrandStandardVO brandStandardVO);

    /**
     * 向Redis中写入品牌列表
     *
     * @param brands 品牌列表
     */
    void save(List<BrandListItemVO> brands);

    /**
     * 从Redis中读取品牌数据
     *
     * @param id 品牌id
     * @return 匹配的品牌数据,如果没有匹配的数据,则返回null
     */
    BrandStandardVO get(Long id);

    /**
     * 从Redis中读取品牌列表
     *
     * @return 品牌列表
     */
    List<BrandListItemVO> list();

    /**
     * 从Redis中读取品牌列表
     *
     * @param start 读取数据的起始下标
     * @param end   读取数据的截止下标
     * @return 品牌列表
     */
    List<BrandListItemVO> list(long start, long end);

}
并在BrandRedisRepositoryImpl中实现以上方法:

package cn.tedu.csmall.product.repo.impl;

import cn.tedu.csmall.product.pojo.vo.BrandListItemVO;
import cn.tedu.csmall.product.pojo.vo.BrandStandardVO;
import cn.tedu.csmall.product.repo.IBrandRedisRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.ListOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;

/**
 * 处理品牌缓存的数据访问实现类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Slf4j
@Repository
public class BrandRedisRepositoryImpl implements IBrandRedisRepository {

    @Autowired
    RedisTemplate<String, Serializable> redisTemplate;

    public BrandRedisRepositoryImpl() {
        log.debug("创建处理缓存的数据访问对象:BrandRedisRepositoryImpl");
    }

    @Override
    public void save(BrandStandardVO brandStandardVO) {
        log.debug("准备向Redis中写入数据:{}", brandStandardVO);
        String key = getItemKey(brandStandardVO.getId());
        redisTemplate.opsForValue().set(key, brandStandardVO);
    }

    @Override
    public void save(List<BrandListItemVO> brands) {
        String key = getListKey();
        ListOperations<String, Serializable> ops = redisTemplate.opsForList();
        for (BrandListItemVO brand : brands) {
            ops.rightPush(key, brand);
        }
    }

    @Override
    public BrandStandardVO get(Long id) {
        String key = getItemKey(id);
        Serializable serializable = redisTemplate.opsForValue().get(key);
        if (serializable != null) {
            if (serializable instanceof BrandStandardVO) {
                return (BrandStandardVO) serializable;
            }
        }
        return null;
    }

    @Override
    public List<BrandListItemVO> list() {
        long start = 0;
        long end = -1;
        return list(start, end);
    }

    @Override
    public List<BrandListItemVO> list(long start, long end) {
        String key = getListKey();
        ListOperations<String, Serializable> ops = redisTemplate.opsForList();
        List<Serializable> list = ops.range(key, start, end);
        List<BrandListItemVO> brands = new ArrayList<>();
        for (Serializable item : list) {
            brands.add((BrandListItemVO) item);
        }
        return brands;
    }

    private String getItemKey(Long id) {
        return BRAND_ITEM_KEY_PREFIX + id;
    }

    private String getListKey() {
        return BRAND_LIST_KEY;
    }

}
完成后,在src/test/java的根包下创建repo.BrandRedisRepositoryTests测试类,编写并执行测试:

package cn.tedu.csmall.product.repo;

import cn.tedu.csmall.product.pojo.entity.Brand;
import cn.tedu.csmall.product.pojo.vo.BrandListItemVO;
import cn.tedu.csmall.product.pojo.vo.BrandStandardVO;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.ArrayList;
import java.util.List;

@Slf4j
@SpringBootTest
public class BrandRedisRepositoryTests {

    @Autowired
    IBrandRedisRepository repository;

    @Test
    void testSave() {
        BrandStandardVO brand = new BrandStandardVO();
        brand.setId(1L);
        brand.setName("华为");

        repository.save(brand);
        log.debug("向Redis中写入数据完成!");
    }

    @Test
    void testSaveList() {
        List<BrandListItemVO> brands = new ArrayList<>();
        for (int i = 1; i <= 8; i++) {
            BrandListItemVO brand = new BrandListItemVO();
            brand.setId(i + 0L);
            brand.setName("测试品牌" + i);
            brands.add(brand);
        }

        repository.save(brands);
        log.debug("向Redis中写入列表数据完成!");
    }

    @Test
    void testGet() {
        Long id = 10000L;
        Object result = repository.get(id);
        log.debug("从Redis中读取【id={}】的数据,结果:{}", id, result);
    }

    @Test
    void testList() {
        List<?> list = repository.list();
        log.debug("从Redis中读取列表,列表中的数据的数量:{}", list.size());
        for (Object item : list) {
            log.debug("{}", item);
        }
    }

    @Test
    void testListRange() {
        long start = 2;
        long end = 5;
        List<?> list = repository.list(start, end);
        log.debug("从Redis中读取列表,列表中的数据的数量:{}", list.size());
        for (Object item : list) {
            log.debug("{}", item);
        }
    }

}
关于在项目中应用Redis,首先考虑何时将MySQL中的数据读取出来并写入到Redis中!常见的策略有:

直接尝试从Redis中读取数据,如果Redis中无此数据,则从MySQL中读取并写入到Redis

从运行机制上,类似单例模式中的懒汉式

当项目启动时,就直接从MySQL中读取数据并写入到Redis

从运行机制上,类似单例模式中的饿汉式

这种做法通常称之为“缓存预热”

当使用缓存预热的处理机制时,需要使得某段代码是项目启动时就自动执行的,可以自定义组件类实现AppliacationRunner接口,重写其中的run()方法,此方法将在项目启动完成之后自动调用

【技能描述】
【了解/掌握/熟练掌握】开发工具的使用,包括:Eclipse、IntelliJ IDEA、Git、Maven;
【了解/掌握/熟练掌握】Java语法,【理解/深刻理解】面向对象编程思想,【了解/掌握/熟练掌握】Java SE API,包括:String、日期、IO、反射、线程、网络编程、集合、异常等;
【了解/掌握/熟练掌握】HTML、CSS、JavaScript前端技术,并【了解/掌握/熟练掌握】前端相关框架技术及常用工具组件,包括:jQuery、Bootstrap、Vue脚手架、Element UI、axios、qs、富文本编辑器等;
【了解/掌握/熟练掌握】MySQL的应用,【了解/掌握/熟练掌握】DDL、DML的规范使用;
【了解/掌握/熟练掌握】数据库编程技术,包括:JDBC、数据库连接池(commons-dbcp、commons-dbcp2、Hikari、druid),及相关框架技术,例如:Mybatis Plus等;
【了解/掌握/熟练掌握】主流框架技术的规范使用,例如:SSM(Spring,Spring MVC, Mybatis)、Spring Boot、Spring Validation、Spring Security等;
【理解/深刻理解】Java开发规范(参考阿里巴巴的Java开发手册);
【了解/掌握/熟练掌握】基于RESTful的Web应用程序开发;
【了解/掌握/熟练掌握】基于Spring Security与JWT实现单点登录;

day19
计划任务
在Spring Boot项目中,在任何组件类中,自定义方法,并在方法上添加@Scheduled注解,并通过此注解配置计划任务的执行周期或执行时间,则此方法就是一个计划任务方法。

在Spring Boot项目,计划任务默认是禁用的,需要在配置类上添加@EnableScheduling注解以开启项目中的计划任务。

则在根包下创建config.ScheduleConfiguration类:

package cn.tedu.csmall.product.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;

/**
 * 计划任务配置类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Slf4j
@Configuration
@EnableScheduling
public class ScheduleConfiguration {

    public ScheduleConfiguration() {
        log.debug("创建配置类对象:ScheduleConfiguration");
    }
    
}
另外,在根包下创建schedule.CacheSchedule类,作为处理缓存的计划任务类:

package cn.tedu.csmall.product.schedule;

import cn.tedu.csmall.product.service.IBrandService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

/**
 * 处理缓存的计划任务类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Slf4j
@Component
public class CacheSchedule {

    @Autowired
    IBrandService brandService;

    public CacheSchedule() {
        log.debug("创建计划任务类对象:CacheSchedule");
    }

    // 关于@Scheduled注解的属性配置:
    // fixedRate:每间隔多少毫秒执行一次
    // fixedDelay:上次执行结束后,过多少毫秒执行一次
    // cron:使用一个字符串,其中包含6~7个值,每个值之间使用1个空格进行分隔
    // >> 在cron的字符串的各值分别表示:秒 分 时 日 月 周(星期) [年]
    // >> 例如:cron = "56 34 12 2 1 ? 2035",则表示2035年1月2日12:34:56将执行此计划任务,无论这一天是星期几
    // >> 以上各值都可以使用通配符,使用星号(*)则表示任意值,使用问号(?)表示不关心具体值,并且,问号只能用于“周(星期)”和“日”这2个位置
    // >> 以上各值,可以使用“x/x”格式的值,例如,分钟对应的值使用“1/5”,则表示当分钟值为1的那一刻开始执行,往后每间隔5分钟执行一次
    @Scheduled(fixedRate = 5 * 60 * 1000)
    public void rebuildCache() {
        log.debug("开始执行处理缓存的计划任务……");
        brandService.rebuildCache();
        log.debug("处理缓存的计划任务执行完成!");
    }

}
以上代码需要在IBrandService中添加“重建缓存”的方法:

/**
 * 重建品牌数据缓存
 */
void rebuildCache();
并在BrandServiceImpl中实现:

@Override
public void rebuildCache() {
    log.debug("删除Redis中原有的品牌数据");
    brandRedisRepository.deleteAll();

    log.debug("从MySQL中读取品牌列表");
    List<BrandListItemVO> brands = brandMapper.list();

    log.debug("将品牌列表写入到Redis");
    brandRedisRepository.save(brands);

    log.debug("逐一根据id从MySQL中读取品牌详情,并写入到Redis");
    for (BrandListItemVO item : brands) {
        BrandStandardVO brand = brandMapper.getStandardById(item.getId());
        brandRedisRepository.save(brand);
    }
}
需要注意,对于周期性的计划任务,首次执行是在项目即将完成启动时,所以,也可以实现类似ApplicationRunner的效果,所以,使用周期性的计划任务也可以实现缓存预热,并且保持周期性的更新缓存!

由于计划任务是在专门的线程中处理的,与普通的处理请求、处理数据的线程是并行的,所以需要关注线程安全问题。

使用Mybatis拦截器处理gmt_create和gmt_modified字段的值
在每张数据表中,都有gmt_create、gmt_modified这2个字段(是在阿里的开发规范上明确要求的),这2个字段的值是有固定规则的,例如gmt_create的值就是INSERT这条数据时的时间,而gmt_modified的值就是每次执行UPDATE时更新的时间,由于这是固定的做法,可以使用Mybatis拦截器进行处理,即每次执行SQL语句之前,先判断是否为INSERT / UPDATE类型的SQL语句,如果是,再判断SQL语句是否处理了相关时间,如果没有,则修改原SQL语句,得到处理了相关时间的新SQL语句,并放行,使之最终执行的是修改后的SQL语句。

关于此拦截器的示例:

package cn.tedu.csmall.product.interceptor.mybatis;

import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.plugin.*;

import java.lang.reflect.Field;
import java.sql.Connection;
import java.time.LocalDateTime;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * <p>基于MyBatis的自动更新"最后修改时间"的拦截器</p>
 *
 * <p>需要SQL语法预编译之前进行拦截,则拦截类型为StatementHandler,拦截方法是prepare</p>
 *
 * <p>具体的拦截处理由内部的intercept()方法实现</p>
 *
 * <p>注意:由于仅适用于当前项目,并不具备范用性,所以:</p>
 *
 * <ul>
 * <li>拦截所有的update方法(根据SQL语句以update前缀进行判定),无法不拦截某些update方法</li>
 * <li>所有数据表中"最后修改时间"的字段名必须一致,由本拦截器的FIELD_MODIFIED属性进行设置</li>
 * </ul>
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Slf4j
@Intercepts({@Signature(
        type = StatementHandler.class,
        method = "prepare",
        args = {Connection.class, Integer.class}
)})
public class InsertUpdateTimeInterceptor implements Interceptor {
    /**
     * 自动添加的创建时间字段
     */
    private static final String FIELD_CREATE = "gmt_create";
    /**
     * 自动更新时间的字段
     */
    private static final String FIELD_MODIFIED = "gmt_modified";
    /**
     * SQL语句类型:其它(暂无实际用途)
     */
    private static final int SQL_TYPE_OTHER = 0;
    /**
     * SQL语句类型:INSERT
     */
    private static final int SQL_TYPE_INSERT = 1;
    /**
     * SQL语句类型:UPDATE
     */
    private static final int SQL_TYPE_UPDATE = 2;
    /**
     * 查找SQL类型的正则表达式:INSERT
     */
    private static final String SQL_TYPE_PATTERN_INSERT = "^insert\\s";
    /**
     * 查找SQL类型的正则表达式:UPDATE
     */
    private static final String SQL_TYPE_PATTERN_UPDATE = "^update\\s";
    /**
     * 查询SQL语句片段的正则表达式:gmt_modified片段
     */
    private static final String SQL_STATEMENT_PATTERN_MODIFIED = ",\\s*" + FIELD_MODIFIED + "\\s*=";
    /**
     * 查询SQL语句片段的正则表达式:gmt_create片段
     */
    private static final String SQL_STATEMENT_PATTERN_CREATE = ",\\s*" + FIELD_CREATE + "\\s*[,)]?";
    /**
     * 查询SQL语句片段的正则表达式:WHERE子句
     */
    private static final String SQL_STATEMENT_PATTERN_WHERE = "\\s+where\\s+";
    /**
     * 查询SQL语句片段的正则表达式:VALUES子句
     */
    private static final String SQL_STATEMENT_PATTERN_VALUES = "\\)\\s*values?\\s*\\(";

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 日志
        log.debug("准备拦截SQL语句……");
        // 获取boundSql,即:封装了即将执行的SQL语句及相关数据的对象
        BoundSql boundSql = getBoundSql(invocation);
        // 从boundSql中获取SQL语句
        String sql = getSql(boundSql);
        // 日志
        log.debug("原SQL语句:{}", sql);
        // 准备新SQL语句
        String newSql = null;
        // 判断原SQL类型
        switch (getOriginalSqlType(sql)) {
            case SQL_TYPE_INSERT:
                // 日志
                log.debug("原SQL语句是【INSERT】语句,准备补充更新时间……");
                // 准备新SQL语句
                newSql = appendCreateTimeField(sql, LocalDateTime.now());
                break;
            case SQL_TYPE_UPDATE:
                // 日志
                log.debug("原SQL语句是【UPDATE】语句,准备补充更新时间……");
                // 准备新SQL语句
                newSql = appendModifiedTimeField(sql, LocalDateTime.now());
                break;
        }
        // 应用新SQL
        if (newSql != null) {
            // 日志
            log.debug("新SQL语句:{}", newSql);
            reflectAttributeValue(boundSql, "sql", newSql);
        }

        // 执行调用,即拦截器放行,执行后续部分
        return invocation.proceed();
    }

    public String appendModifiedTimeField(String sqlStatement, LocalDateTime dateTime) {
        Pattern gmtPattern = Pattern.compile(SQL_STATEMENT_PATTERN_MODIFIED, Pattern.CASE_INSENSITIVE);
        if (gmtPattern.matcher(sqlStatement).find()) {
            log.debug("原SQL语句中已经包含gmt_modified,将不补充添加时间字段");
            return null;
        }
        StringBuilder sql = new StringBuilder(sqlStatement);
        Pattern whereClausePattern = Pattern.compile(SQL_STATEMENT_PATTERN_WHERE, Pattern.CASE_INSENSITIVE);
        Matcher whereClauseMatcher = whereClausePattern.matcher(sql);
        // 查找 where 子句的位置
        if (whereClauseMatcher.find()) {
            int start = whereClauseMatcher.start();
            int end = whereClauseMatcher.end();
            String clause = whereClauseMatcher.group();
            log.debug("在原SQL语句 {} 到 {} 找到 {}", start, end, clause);
            String newSetClause = ", " + FIELD_MODIFIED + "='" + dateTime + "'";
            sql.insert(start, newSetClause);
            log.debug("在原SQL语句 {} 插入 {}", start, newSetClause);
            log.debug("生成SQL: {}", sql);
            return sql.toString();
        }
        return null;
    }

    public String appendCreateTimeField(String sqlStatement, LocalDateTime dateTime) {
        // 如果 SQL 中已经包含 gmt_create 就不在添加这两个字段了
        Pattern gmtPattern = Pattern.compile(SQL_STATEMENT_PATTERN_CREATE, Pattern.CASE_INSENSITIVE);
        if (gmtPattern.matcher(sqlStatement).find()) {
            log.debug("已经包含 gmt_create 不再添加 时间字段");
            return null;
        }
        // INSERT into table (xx, xx, xx ) values (?,?,?)
        // 查找 ) values ( 的位置
        StringBuilder sql = new StringBuilder(sqlStatement);
        Pattern valuesClausePattern = Pattern.compile(SQL_STATEMENT_PATTERN_VALUES, Pattern.CASE_INSENSITIVE);
        Matcher valuesClauseMatcher = valuesClausePattern.matcher(sql);
        // 查找 ") values " 的位置
        if (valuesClauseMatcher.find()) {
            int start = valuesClauseMatcher.start();
            int end = valuesClauseMatcher.end();
            String str = valuesClauseMatcher.group();
            log.debug("找到value字符串:{} 的位置 {}, {}", str, start, end);
            // 插入字段列表
            String fieldNames = ", " + FIELD_CREATE + ", " + FIELD_MODIFIED;
            sql.insert(start, fieldNames);
            log.debug("插入字段列表{}", fieldNames);
            // 定义查找参数值位置的 正则表达 “)”
            Pattern paramPositionPattern = Pattern.compile("\\)");
            Matcher paramPositionMatcher = paramPositionPattern.matcher(sql);
            // 从 ) values ( 的后面位置 end 开始查找 结束括号的位置
            String param = ", '" + dateTime + "', '" + dateTime + "'";
            int position = end + fieldNames.length();
            while (paramPositionMatcher.find(position)) {
                start = paramPositionMatcher.start();
                end = paramPositionMatcher.end();
                str = paramPositionMatcher.group();
                log.debug("找到参数值插入位置 {}, {}, {}", str, start, end);
                sql.insert(start, param);
                log.debug("在 {} 插入参数值 {}", start, param);
                position = end + param.length();
            }
            if (position == end) {
                log.warn("没有找到插入数据的位置!");
                return null;
            }
        } else {
            log.warn("没有找到 ) values (");
            return null;
        }
        log.debug("生成SQL: {}", sql);
        return sql.toString();
    }


    @Override
    public Object plugin(Object target) {
        // 本方法的代码是相对固定的
        if (target instanceof StatementHandler) {
            return Plugin.wrap(target, this);
        } else {
            return target;
        }
    }

    @Override
    public void setProperties(Properties properties) {
        // 无须执行操作
    }

    /**
     * <p>获取BoundSql对象,此部分代码相对固定</p>
     *
     * <p>注意:根据拦截类型不同,获取BoundSql的步骤并不相同,此处并未穷举所有方式!</p>
     *
     * @param invocation 调用对象
     * @return 绑定SQL的对象
     */
    private BoundSql getBoundSql(Invocation invocation) {
        Object invocationTarget = invocation.getTarget();
        if (invocationTarget instanceof StatementHandler) {
            StatementHandler statementHandler = (StatementHandler) invocationTarget;
            return statementHandler.getBoundSql();
        } else {
            throw new RuntimeException("获取StatementHandler失败!请检查拦截器配置!");
        }
    }

    /**
     * 从BoundSql对象中获取SQL语句
     *
     * @param boundSql BoundSql对象
     * @return 将BoundSql对象中封装的SQL语句进行转换小写、去除多余空白后的SQL语句
     */
    private String getSql(BoundSql boundSql) {
        return boundSql.getSql().toLowerCase().replaceAll("\\s+", " ").trim();
    }

    /**
     * <p>通过反射,设置某个对象的某个属性的值</p>
     *
     * @param object         需要设置值的对象
     * @param attributeName  需要设置值的属性名称
     * @param attributeValue 新的值
     * @throws NoSuchFieldException   无此字段异常
     * @throws IllegalAccessException 非法访问异常
     */
    private void reflectAttributeValue(Object object, String attributeName, String attributeValue) throws NoSuchFieldException, IllegalAccessException {
        Field field = object.getClass().getDeclaredField(attributeName);
        field.setAccessible(true);
        field.set(object, attributeValue);
    }

    /**
     * 获取原SQL语句类型
     *
     * @param sql 原SQL语句
     * @return SQL语句类型
     */
    private int getOriginalSqlType(String sql) {
        Pattern pattern;
        pattern = Pattern.compile(SQL_TYPE_PATTERN_INSERT, Pattern.CASE_INSENSITIVE);
        if (pattern.matcher(sql).find()) {
            return SQL_TYPE_INSERT;
        }
        pattern = Pattern.compile(SQL_TYPE_PATTERN_UPDATE, Pattern.CASE_INSENSITIVE);
        if (pattern.matcher(sql).find()) {
            return SQL_TYPE_UPDATE;
        }
        return SQL_TYPE_OTHER;
    }

}
Mybatis拦截器必须注册后才能生效!可以在配置类(或任何组件类)中:

@Autowired
private List<SqlSessionFactory> sqlSessionFactoryList;

@PostConstruct // 使得此方法在调用了构造方法、完成了属性注入之后自动执行
public void addInterceptor() {
    InsertUpdateTimeInterceptor interceptor = new InsertUpdateTimeInterceptor();
    for (SqlSessionFactory sqlSessionFactory : sqlSessionFactoryList) {
        sqlSessionFactory.getConfiguration().addInterceptor(interceptor);
    }
}
晚课01
1. 处理密码加密
用户的密码必须被加密后再存储到数据库,否则,就存在用户账号安全问题。

用户使用的原始密码通常称之为“原文”或“明文”,经过算法的运算,得到的结果通常称之为“密文”。

在处理密码加密时,不可以使用任何加密算法,因为所有加密算法都是可以被逆向运算的,也就是说,当密文、算法、加密参数作为已知条件的情况下,是可以根据密文计算得到原文的!

提示:加密算法通常是用于保障数据传输过程的安全的,并不适用于存储下来的数据安全!

对存储的密码进行加密处理,通常使用消息摘要算法!

消息摘要算法的特点:

消息(原文、原始数据)相同,则摘要相同

无论消息多长,每个算法的摘要结果长度固定

消息不同,则摘要极大概率不会相同

注意:消息摘要算法是不可逆向运算的算法!即你永远不可能根据摘要(密文)逆向计算得到消息(原文)!

常见的消息摘要算法有:

SHA系列:SHA-1、SHA-256、SHA-384、SHA-512

MD家族:MD2、MD4、MD5

Spring框架内有DigestUtils的工具类,提供了MD5的API,例如:

package cn.tedu.csmall.product;

import org.junit.jupiter.api.Test;
import org.springframework.util.DigestUtils;

public class MessageDigestTests {

    @Test
    public void testMd5() {
        String rawPassword = "123456";
        String encodedPassword = DigestUtils
                .md5DigestAsHex(rawPassword.getBytes());
        System.out.println("原文:" + rawPassword);
        System.out.println("密文:" + encodedPassword);
        System.out.println();

        rawPassword = "123456";
        encodedPassword = DigestUtils
                .md5DigestAsHex(rawPassword.getBytes());
        System.out.println("原文:" + rawPassword);
        System.out.println("密文:" + encodedPassword);
        System.out.println();

        rawPassword = "1234567890ABCDEFGHIJKLMN";
        encodedPassword = DigestUtils
                .md5DigestAsHex(rawPassword.getBytes());
        System.out.println("原文:" + rawPassword);
        System.out.println("密文:" + encodedPassword);
    }

}
以上测试的运行结果为:

原文:123456
密文:e10adc3949ba59abbe56e057f20f883e

原文:123456
密文:e10adc3949ba59abbe56e057f20f883e

原文:1234567890ABCDEFGHIJKLMN
密文:41217c45889b5378c3dad3879d7bfac9
提示:在项目中添加commons-codec的依赖项,可以使用更多消息摘要算法的API。

未完待续!

晚课02
处理密码加密(续)
在算法的学术领域中,如果算法的计算结果的长度是固定,会根据结果是由多少位二进制数来组成,来确定是这多少位的算法,以MD5算法为例,其计算结果是由128个二进制数组成的,所以,MD5算法是128位算法,通常,会将二进制结果转换成十六进制来表示,所以,会是32位长度的十六进制数!

常见的消息摘要算法中,MD系列的都是128位算法,SHA-1是160位算法,SHA-256是256位算法,SHA-384是384位算法,SHA-512是512位算法。

理论上来说,如果某个消息摘要算法的结果只是1位(1个二进制数),最多使用2 + 1个不同的原文,必然发生“碰撞”(即完全不同的原文对应相同的摘要),同理,如果算法的结果有2位(2个二进制数组成),最多使用4 + 1个不同的原文必然后发生碰撞,如果算法的结果有3位,最多使用8 + 1个不同的原文必然发生碰撞,而MD5是128位算法,理论上,最多需要使用2的128次方 + 1个不同的原文才能保证必然发生碰撞!

2的128次方的值是:340282366920938463463374607431768211456。

当使用MD5处理密码加密时,理论上,需要尝试340282366920938463463374607431768211456 + 1个不同的原密码,才能试出2个不同的原密码都可以登录同一个账号!由于需要尝试的次数太多,按照目前的计算机的算力,这是不可能实现的!所以,大致可以视为“找不到2个不同的原文对应相同的结果”。

通过,对于使用消息摘要算法处理密码加密的结果,如果需要破解,只能尽可能的穷举原密码(消息/原文)与加密后的密码(摘要/密文)之间的对应关系,当执行“破解”时,从记录下来的结果中进行搜索即可!例如:

原文    密码
0000    4a7d1ed414474e4033ac29ccb8653d9b
0001    25bbdcd06c32d477f7fa1c3e4a91b032
0002    fcd04e26e900e94b9ed6dd604fed2b64
......    
9999    fa246d0262c3925617b0c72bb20eeb1d
aaaa    74b87337454200d4d33f80c4663dc5e5
aaab    4c189b020ceb022e0ecc42482802e2b8
......    
zzzz    02c425157ecd32f259548b33402ff6d3
00000    dcddb75469b4b4875094e14561e573d8
目前,在网络上也有许多平台提供了这种机制的“破解”!而这些平台收录的原文密文对应关系不可能特别多,假设允许使用在密码中的字符有80种,则8位长度(含以下长度)的密码有约1677万亿种,大多平台不可能收录!

所以,只要原密码足够复杂,此原密码与密文的对应关系大概率是没有被“破解”平台收录的,则不会被破解!

在编程时,为保证密码安全,应该做到:

要求用户使用安全强度更高的原始密码

在处理加密的过程中,使用循环实现多重加密

使用位数更长的算法

加盐

综合以上做法

晚课03
Mybatis中的#{}和${}格式的占位符
在使用Mybatis时,在SQL语句中的参数,可以使用#{}或${}格式的占位符。

当配置的SQL语句如下时:

SELECT
    <include refid="StandardQueryFields" />
FROM
    ams_admin
WHERE
    id=#{id}
以上SQL语句中的参数,无论使用#{}还是${},执行效果完全相同。

当配置的SQL语句如下时:

SELECT
    <include refid="LoginQueryFields"/>
FROM
    ams_admin
WHERE
    username=#{username}
以上SQL语句中的参数,使用#{}格式的占位符时可以正常执行,使用${}格式的占位符将执行出错,错误信息例如:

java.sql.SQLSyntaxErrorException: Unknown column 'fanchuanqi' in 'where clause'
; bad SQL grammar []; nested exception is java.sql.SQLSyntaxErrorException: Unknown column 'fanchuanqi' in 'where clause'
其实,在SQL语句中,除了关键字、数值、特定位置的字符或字符串以外,只要没有使用特殊符号框住,SQL语句中的其它内容都会被视为“字段名”,例如:

SELECT
    id, username, password, enable
FROM
    `ams_admin`
WHERE
    username=fanchuanqi
提示:使用一对单撇符号框住的就是自定义名称

提示:使用一对单引号框住的都是字符串

在使用Mybatis时,如果SQL语句中的参数使用#{}格式的占位符,会进行预编译的处理,如果使用的是${}格式的占位符,则不会预编译。

提示:计算机能够直接识别并执行的只有机器语言,即二进制语言,所有其它编程语言编写的源代码都需要经过编译、解释,转换成机制语言才可以被识别并执行。在执行编译之前,通常都还有词法分析、语义分析等过程。由于语义分析是在编译之前执行的,所以,一旦执行到了编译,则SQL语句的“意思”不会再发生变化!

以以下SQL语句为例:

SELECT
    <include refid="LoginQueryFields"/>
FROM
    ams_admin
WHERE
    username=#{username}
由于使用的是#{}格式的占位符,则#{username}会被识别成参数,经过预编译(先编译,再传值,再执行)处理后,无论在此处传入什么值,都会被认为是参数,语义不会发生变化!

如果使用的是${}格式的占位符,则会先将参数值代入到SQL语句中,然后再执行编译!

所以,使用#{}格式的占位符,不必关心参数值的数据类型问题,并且,没有SQL注入的风险,因为在编译之前就已经确定了此SQL语句的语义,无论传的值是什么样的,语义都不会改变!但是,这种格式的占位符只能表示某个值,不能表示SQL语句中的其它部分!

使用${}格式的占位符,需要关心参数值的数据类型问题,如果参数的值是字符串类型的,必须在值的两侧添加单引号,这种做法存在SQL注入的风险,因为传入的参数值可能会改变语义!需要注意,这种格式的占位符可以表示SQL语句中的任何片段!

另外,对于SQL注入,应该有正确的认识,不需要盲目拒绝!

SELECT * FROM user WHERE username='?' AND password='?'

username: root
password: secret

SELECT * FROM user WHERE username='root' AND password='secret'

username: root
password: a' OR 'a'='a

SELECT * FROM user WHERE username='root' AND password='a' OR 'x'='x' OR 'a'='a'
 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
Spring MVC是一个基于Java的Web应用框架,用于构建企业级Java Web应用程序。它是Spring框架的一部分,提供了一种模型-视图-控制器(Model-View-Controller,MVC)架构,并且可以与其他Spring项目(如Spring Boot)无缝集成。 以下是一些关键概念和要点,可以帮助你进行Spring MVC开发笔记的整理: 1. DispatcherServlet: Spring MVC的核心组件,负责接收并分发客户端请求,将请求发送给相应的控制器进行处理。 2. 控制器(Controller): 控制器负责处理用户请求,并返回相应的模型和视图。通过使用注解或实现特定接口来定义控制器类。 3. 模型(Model): 模型代表应用程序中的数据结构,通常是通过POJO(Plain Old Java Objects)表示。控制器可以使用模型来存储、检索和操作数据。 4. 视图(View): 视图负责渲染模型的数据,并将其呈现给用户。可以是JSP页面、Thymeleaf模板、HTML文件等等。 5. 请求映射(Request Mapping): 通过使用@RequestMapping注解,可以将URL请求映射到相应的控制器方法中。 6. 数据绑定(Data Binding): Spring MVC提供了数据绑定功能,可以将请求参数自动绑定到控制器方法的参数中,简化了参数处理的过程。 7. 视图解析器(View Resolver): 视图解析器用于将控制器返回的逻辑视图名称解析为实际的视图。可以配置多个视图解析器,以支持不同类型的视图解析。 8. 拦截器(Interceptor): 拦截器可以在请求处理的各个阶段进行拦截,并执行相应的操作,例如身份验证、日志记录等。 9. 表单处理(Form Handling): Spring MVC提供了一组表单标签和表单处理功能,用于简化表单的验证和数据绑定过程。 10. 文件上传(File Upload): Spring MVC提供了对文件上传的支持,可以轻松地处理文件上传的操作。 这些是Spring MVC的一些基本概念和要点,希望对你整理笔记有所帮助。如果有更具体的问题或需求,请随时提问。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

曲悦丹田

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值