Spring Boot

1. 关于Spring Boot

Spring Boot是Spring官方的一个产品,其本质上是一个基于Maven的、以Spring框架作为基础的进阶框架,很好的支持了主流的其它框架,并默认完成了许多的配置,其核心思想是“约定大于配置”。

2. 创建Spring Boot工程

在IntelliJ IDEA中,在创建向导中选择Spring Initializer即可开始创建Spring Boot工程,在创建向导的界面中,需要关注的部分有:

  • Group Id
  • Artifact Id

以上2个值会共同构成一个Package name,如果Artifact Id的名字中有减号,在Package name中会去除,推荐手动添加小数点进行分隔。

由于Spring Boot官方更新版本的频率非常高,在创建项目时,随便选取某个版本均可,当项目创建成功后,推荐打开pom.xml,将<parent>中的<version>(即Spring Boot父项目的版本)改成熟悉的版本,例如:2.5.9

在创建过程中,还可以在创建向导的界面中勾选所需要依赖项,如果创建时没有勾选,也可以在创建工程之后手动在pom.xml中添加。

3. Spring Boot工程的结构

由于Spring Boot工程本质上就是一个Maven工程,所以,目录结构基本上没有区别。

与普通Maven工程最大的不同在于:Spring Boot工程在src\main\javasrc\test\java下默认已经存在Package,是创建项目时指定的Package,需要注意:此Package已经被配置为Spring执行组件扫描的根包,所以,在编写代码时,所有的组件类都必须放在此包或其子孙包中!通常,推荐将所有的类(及接口)都创建在此包及其子孙包下。

src\main\java下的根包下,默认就已经存在某个类,其类名是创建项目时指定的Artifact与Application单词的组合,例如BootDemoApplication,此类中有main()方法,执行此类的main()就会启动整个项目,如果当前项目是Web项目,还会自动将项目部署到Web服务器并启动服务器,所以,此类通常也称之为“启动类”。

在启动类上,默认添加了@SpringBootApplication注解,此注解的元注解中包含@SpringBootConfiguration,而@SpringBootConfiguration的元注解中包含@Configuration,所以,启动类本身也是配置类!所以,允许将@Bean方法写在此类中,或者某些与配置相关的注解也可以添加在此类上!

src\test\java下的根包下,默认就已经存在某个类,其类名是在启动类的名称基础上添加了Tests单词的组合,例如BootDemoApplicationTests,此类默认没有添加public权限,甚至其内部的默认的测试方法也是默认权限的,此测试类上添加了@SpringBootTest注解,其元注解中包含@ExtendWith(SpringExtension.class),与使用spring-test时的@SpringJUnitTest注解中的元注解相同,所以,@SpringBootTest注解也会使得当前测试类在执行测试方法之前是加载了Spring环境的,在实际编写测试时,可以通过自动装配得到任何已存在于Spring容器中的对象,在各测试方法中只需要关注被测试的目标即可。

pom.xml中,默认已经添加了spring-boot-starterspring-boot-starter-test依赖,分别是Spring Boot的基础依赖基于Spring Boot的测试的依赖

另外,如果在创建工程时,勾选依赖项时选中了Web项,在src\main\resources下默认就已经创建了statictemplates文件夹,如果没有勾选Web则没有这2个文件夹,可以后续自行补充创建。

src\main\resources文件夹下,默认就已经存在application.properties文件,用于编写配置,Spring Boot会自动读取此文件(利用@PropertySource注解)。

小结:

  • 创建项目后默认的Package不要修改,避免出错
  • 在编码过程中,自行创建的所有类、接口均放在默认的Package或其子孙包中
  • src\main\java下默认已存在XxxApplication是启动类,执行此类中的main()方法就会启动整个项目
  • 启动类本身也是配置类
  • 配置都应该编写到src\main\resources下的application.properties中,Spring Boot会自动读取
  • 测试类也必须放在src\test\java下的默认Package或其子孙包中
  • 在测试类上添加@SpringBootTest注解,则其中的测试方法执行之前会自动加载Spring环境及当前项目的配置,可以在测试类中使用自动装配

4. 在Spring Boot工程中使用Mybatis

需要添加相关依赖项:

  • mysql-connector-java
  • mybatis-spring-boot-starter

其依赖的代码为:

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.2.2</version>
</dependency>

说明:在Spring Boot工程,许多依赖项都是不需要显式的指定版本号的,因为在父项目中已经对这些依赖项的版本进行了管理(配置版本号),如果一定需要使用特定的版本,也可以自行添加<version>节点进行配置

说明:在依赖项的源代码中,当<scope>的值为runtime时,表示此依赖项是运行过程中需要的,但是,在编译时并不需要参与编译

需要注意:当添加了以上数据库编程的依赖后,如果启动项目,将失败!

因为添加了数据库编程的依赖项后,Spring Boot就会尝试自动装配数据源(DataSource)等对象,装配时所需的连接数据库的配置信息(例如URL、登录数据库的用户名和密码)应该是配置在application.properties中的,但是,如果尚未配置,就会导致失败!

关于连接数据库的配置信息,Spring Boot要求对应的属性名是:

# 连接数据库的URL
spring.datasource.url=???
# 登录数据库的用户名
spring.datasource.username=???
# 登录数据库的密码
spring.datasource.password=???

在配置时,也必须使用以上属性名进行配置,则Spring Boot会自动读取这些属性对应的值,用于创建数据源对象!

例如,配置为:

# 连接数据库的URL
spring.datasource.url=jdbc:mysql://localhost:3306/mall_ams?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
# 登录数据库的用户名
spring.datasource.username=root
# 登录数据库的密码
spring.datasource.password=1234

由于Spring Boot在启动时只是加载以上配置,并不会实际的连接到数据库,所以,当以上配置存在时,启动就不会报错,但是,无法检验以上配置的值是否正确!

可以在测试类中添加测试方法,尝试连接数据库,以检验以上配置值是否正确:

@SpringBootTest
class BootDemoApplicationTests {

    @Autowired
    DataSource dataSource;

    @Test
    void testGetConnection() throws Exception {
        System.out.println(dataSource.getConnection());
    }

}

如果以上测试通过,则表示配置值无误,可以正确连接到数据库,如果测试失败,则表示配置值错误,需检查配置值及本地环境(例如MySQL是否启动、是否已创建对应的数据库等)。

5. 关于Profile配置

在Spring Boot中,对Profile配置有很好的支持,开发人员可以在src\main\resources下创建更多的配置文件,这些配置文件的名称应该是application-???.properties(其中的???是某个名称,是自定义的)。

例如:

  • 仅在开发环境中使用的配置值可以写在application-dev.properties
  • 仅在测试环境中使用的配置值可以写在application-test.properties
  • 仅在生产环境(项目上线的环境)中使用的配置值可以写在application-prod.properties

当把配置写在以上这类文件后,Spring Boot默认并不会应用以上这些文件中的配置,当需要应用某个配置时,需要在application.properties中激活某个Profile配置,例如:

# 激活Profile配置
spring.profiles.active=dev

提示:以上配置值中的dev是需要激活的配置文件的文件名后缀,当配置为dev时,就会激活application-dev.properties,同理,如果以上配置值为test,就会激活application-test.properties

6. 关于YAML配置

Spring Boot也支持使用YAML配置,在开发实践中,YAML的配置也使用得比较多。

YAML配置就是把原有的.properties配置的扩展改为yml

YAML配置原本并不是Spring系列框架内置的配置语法,如果在项目中需要使用这种语法进行配置,解析这类文件需要添加相关依赖,在Spring Boot中默认已添加此依赖。

在YAML配置中,原本在.properties的配置表现为使用多个小数点分隔的配置将改为换行使用2个空格缩进的语法,换行前的部分使用冒号表示结束,最后的属性名与值之间使用冒号和1个空格进行分隔,如果有多条属性在.properties文件中属性名有重复的前缀,在yml中不必也不能重复写。

例如,原本在.properties中配置为:

spring.datasource.username=root
spring.datasource.password=123456

则在yml文件中配置为:

spring:
  datasource:
    username: root
    password: 123456

提示:在IntelliJ IDEA中编写yml时,当需要缩进2个空格时,仍可以使用键盘上的TAB键进行缩进,IntelliJ IDEA会自动将其转换为2个空格。

无论是.properties还是yml,只是配置文件的扩展名和文件内部的配置语法有区别,对于Spring Boot最终的执行其实没有任何表现上的不同。

7. 使用Druid数据库连接池

Druid数据库连接是阿里巴巴团队研发的,在Spring Boot项目中,如果需要显式的指定使用此连接池,首先,需要在项目中添加依赖:

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid</artifactId>
    <version>1.1.20</version>
</dependency>

当添加了此依赖,在项目中需要应用时,需要在配置文件中指定spring.datasource.type属性,取值为以上依赖项的jar包中的DruidDataSource类型的全限定名。

例如,在yml中配置为:

# Spring系列框架的配置
spring:
  # 连接数据库的相关配置
  datasource:
    # 使用的数据库连接池类型
    type: com.alibaba.druid.pool.DruidDataSource

8. 编写持久层(数据访问层)代码

数据持久化:在开发领域中,讨论数据时,通常指定是正在执行或处理的数据,这些数据都是在内存中的,而内存(RAM)的特征包含”一旦断电,数据将全部丢失“,为了让数据永久保存下来,通常会将数据存储到能够永久存储数据的介质中,通常是计算机的硬盘,硬盘上的数据都是以文件的形式存在的,所以,当需要永久保存数据时,可以将数据存储到文本文件中,或存储到XML文件中,或存储到数据库中,这些保存的做法就是数据持久化,而文本文件、XML文件都不利于实现增删改查中的所有数据访问操作,而数据库是实现增删改查这4种操作都比较便利的,所以,一般在讨论数据持久化时,默认指的都是使用数据库存储数据。

在项目中,会将代码(各类、接口)划分一些层次,各层用于解决不同的问题,其中,持久层就是用于解决数据持久化问题的,甚至,简单来说,持久层对应的就是数据库编程的相关文件或代码。

目前,使用Mybatis技术实现持久层编程,需要:

  • 编写一次性的基础配置
    • 使用@MapperScan指定接口所在的Base Package
    • 指定配置SQL语句的XML文件的位置
  • 编写每个数据访问功能的代码
    • 在接口中添加必须的抽象方法
      • 可能需要创建相关的POJO类
    • 在XML文件中配置抽象方法映射的SQL语句

关于一次性的配置,@MapperScan注解需要添加在配置类上,有2种做法:

  • 直接将此注解添加在启动类上,因为启动类本身也是配置类
  • 自行创建配置类,在此配置类上添加@MapperScan

如果采用以上的第2种做法,则应该在src\main\java的根包下,创建config.MybatisConfig类,并在此类使用@MapperScan注解:

package cn.tedu.boot.demo.config;

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

@Configuration
@MapperScan("cn.tedu.boot.demo.mapper")
public class MybatisConfig {
}

另外,关于指定配置SQL语句的XML文件的位置,需要在application.yml(或application.properties)中配置mybatis.mapper-locations属性,例如:

# Mybatis相关配置
mybatis:
  # 用于配置SQL语句的XML文件的位置
  mapper-locations: classpath:mapper/*.xml

基于以上的配置值,还应该在src/main/resources下自行创建名为mapper的文件夹。

至此,关于使用Mybatis实现数据库编程的一次性配置结束!

接下来,可以使用任何你已知的Mybatis使用方式实现所需的数据访问。

目前,设定目标为:最终实现”添加管理员账号“的功能。则在数据访问层需要做到:

  • 插入管理员数据
    • 创建cn.tedu.boot.demo.entity.Admin
    • cn.tedu.boot.demo.mapper包(不存在,则创建)下创建AdminMapper接口,并在接口中声明int insert(Admin admin);方法
    • src/main/resources/mapper文件夹下通过粘贴得到AdminMapper.xml文件,在此文件中配置与以上抽象方法映射的SQL语句
    • 编写完成后,应该及时测试,测试时,推荐在src/test/java的根包下创建mapper.AdminMapperTests测试类,并在此类中编写测试方法
  • 根据用户名查询管理员数据
    • 后续,在每次插入数据之前,会调用此功能进行查询,以此保证”重复的用户名不会被添加到数据库中“
      • 即便在数据表中用户名已经添加了unique,但是,不应该让程序执行到此处
    • AdminMapper接口中添加Admin getByUsername(String username);方法
    • AdminMapper.xml文件中添加与以上抽象方法映射的SQL语句
    • 编写完成后,应该及时测试
  • 其它问题暂不考虑,例如在ams_admin中,其实phoneemail也是设置了unique的,如果完整的实现,则还需要添加根据phone查询管理员的功能,和根据email查询管理员的功能,在不实现这2个功能的情况下,后续进行测试和使用时,应该不使用重复的phoneemail值来测试或执行

9. 关于业务逻辑层(Service层)

业务逻辑层是被Controller直接调用的层(Controller不允许直接调用持久层),通常,在业务逻辑层中编写的代码是为了保证数据的完整性和安全性,使得数据是随着我们设定的规则而产生或发生变化。

通常,在业务逻辑层的代码会由接口和实现类组件,其中,接口被视为是必须的

  • 推荐使用基于接口的编程方式
  • 部分框架在处理某些功能时,会使用基于接口的代理模式,例如Spring JDBC框架在处理事务时

在接口中,声明抽象方法时,仅以操作成功为前提来设计返回值类型(不考虑失败),如果业务在执行过程可能出现某些失败(不符合所设定的规则),可以通过抛出异常来表示!

关于抛出的异常,通常是自定义的异常,并且,自定义异常通常是RuntimeException的子类,主要原因:

  • 不必显式的抛出或捕获,因为业务逻辑层的异常永远是抛出的,而控制器层会调用业务逻辑层,在控制器层的Controller中其实也是永远抛出异常的,这些异常会通过Spring MVC统一处理异常的机制进行处理,关于异常的整个过程都是固定流程,所以,没有必要显式抛出或捕获
  • 部分框架在处理某些事情时,默认只对RuntimeException的子孙类进行识别并处理,例如Spring JDBC框架在处理事务时

所以,在实际编写业务逻辑层之前,应该先规划异常,例如先创建ServiceException类:

package cn.tedu.boot.demo.ex;

public class ServiceException extends RuntimeException {
    
}

接下来,再创建具体的对应某种“失败”的异常,例如,在添加管理员时,可能因为“用户名已经存在”而失败,则创建对应的UsernameDuplicateException异常:

package cn.tedu.boot.demo.ex;

public class UsernameDuplicateException extends ServiceException {
    
}

另外,当插入数据时,如果返回的受影响行数不是1时,必然是某种错误,则创建对应的插入数据异常:

package cn.tedu.boot.demo.ex;

public class InsertException extends ServiceException {
    
}

关于抽象方法的参数,应该设计为客户端提交的数据类型或对应的封装类型,不可以是数据表对应的实体类型!如果使用封装的类型,这种类型在类名上应该添加某种后缀,例如DTO或其它后缀,例如:

package cn.tedu.boot.demo.pojo.dto;

public class AdminAddNewDTO implements Serializable {
    private String username;
    private String password;
    private String nickname;
    private String avatar;
    private String phone;
    private String email;
    private String description;
    // Setters & Getters
    // hashCode(), equals()
    // toString()
}

然后,在cn.tedu.boot.demo.service包下声明接口及抽象方法:

package cn.tedu.boot.demo.service;

public interface IAdminService {
    void addNew(AdminAddNewDTO adminAddNewDTO);
}

并在以上service包下创建impl子包,再创建AdminServiceImpl类:

package cn.tedu.boot.demo.service.impl;

@Service // @Component, @Controller, @Repository
public class AdminServiceImpl implements IAdminService {
    
    @Autowired
    private AdminMapper adminMapper;
    
    @Override
    public void addNew(AdminAddNewDTO adminAddNewDTO) {
        // 通过参数获取用户名
        // 调用adminMapper的Admin getByUsername(String username)方法执行查询
        // 判断查询结果是否不为null
        // -- 是:表示用户名已经被占用,则抛出UsernameDuplicateException
        
        // 通过参数获取原密码
        // 通过加密方式,得到加密后的密码encodedPassword
        // 暂时不加密,写为String encodedPassword = adminAddNewDTO.getPassword();
        
        // 创建当前时间对象now > LocalDateTime.now()
        
        // 创建Admin对象
        // 补全Admin对象的属性值:通过参数获取username,nickname……
        // 补全Admin对象的属性值:password > encodedPassword
        // 补全Admin对象的属性值:isEnable > 1
        // 补全Admin对象的属性值:lastLoginIp > null
        // 补全Admin对象的属性值:loginCount > 0
        // 补全Admin对象的属性值:gmtLastLogin > null
        // 补全Admin对象的属性值:gmtCreate > now
        // 补全Admin对象的属性值:gmtModified > now
        // 调用adminMapper的insert(Admin admin)方法插入管理员数据,获取返回值
        
        // 判断以上返回的结果是否不为1,抛出InsertException异常
    }
    
}

以上业务代码的实现为:

package cn.tedu.boot.demo.service.impl;

import cn.tedu.boot.demo.entity.Admin;
import cn.tedu.boot.demo.ex.InsertException;
import cn.tedu.boot.demo.ex.UsernameDuplicateException;
import cn.tedu.boot.demo.mapper.AdminMapper;
import cn.tedu.boot.demo.pojo.dto.AdminAddNewDTO;
import cn.tedu.boot.demo.service.IAdminService;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;

@Service
public class AdminServiceImpl implements IAdminService {

    @Autowired
    private AdminMapper adminMapper;

    @Override
    public void addNew(AdminAddNewDTO adminAddNewDTO) {
        // 通过参数获取用户名
        String username = adminAddNewDTO.getUsername();
        // 调用adminMapper的Admin getByUsername(String username)方法执行查询
        Admin queryResult = adminMapper.getByUsername(username);
        // 判断查询结果是否不为null
        if (queryResult != null) {
            // 是:表示用户名已经被占用,则抛出UsernameDuplicateException
            throw new UsernameDuplicateException();
        }

        // 通过参数获取原密码
        String password = adminAddNewDTO.getPassword();
        // 通过加密方式,得到加密后的密码encodedPassword
        String encodedPassword = password;

        // 创建当前时间对象now > LocalDateTime.now()
        LocalDateTime now = LocalDateTime.now();

        // 创建Admin对象
        Admin admin = new Admin();
        // 补全Admin对象的属性值:通过参数获取username,nickname……
        admin.setUsername(username);
        admin.setNickname(adminAddNewDTO.getNickname());
        admin.setAvatar(adminAddNewDTO.getAvatar());
        admin.setPhone(adminAddNewDTO.getPhone());
        admin.setEmail(adminAddNewDTO.getEmail());
        admin.setDescription(adminAddNewDTO.getDescription());
        // 以上这些从一个对象中把属性赋到另一个对象中,还可以使用:
        // BeanUtils.copyProperties(adminAddNewDTO, admin);
        // 补全Admin对象的属性值:password > encodedPassword
        admin.setPassword(encodedPassword);
        // 补全Admin对象的属性值:isEnable > 1
        admin.setIsEnable(1);
        // 补全Admin对象的属性值:lastLoginIp > null
        // 补全Admin对象的属性值:loginCount > 0
        admin.setLoginCount(0);
        // 补全Admin对象的属性值:gmtLastLogin > null
        // 补全Admin对象的属性值:gmtCreate > now
        admin.setGmtCreate(now);
        // 补全Admin对象的属性值:gmtModified > now
        admin.setGmtModified(now);
        // 调用adminMapper的insert(Admin admin)方法插入管理员数据,获取返回值
        int rows = adminMapper.insert(admin);

        // 判断以上返回的结果是否不为1,抛出InsertException异常
        if (rows != 1) {
            throw new InsertException();
        }
    }

}

以上代码未实现对密码的加密处理!关于密码加密,相关的代码应该定义在别的某个类中,不应该直接将加密过程编写在以上代码中,因为加密的代码需要在多处应用(添加用户、用户登录、修改密码等),并且,从分工的角度上来看,也不应该是业务逻辑层的任务!所以,在cn.tedu.boot.demo.util(包不存在,则创建)下创建PasswordEncoder类,用于处理密码加密:

package cn.tedu.boot.demo.util;

@Component
public class PasswordEncoder {
    
    public String encode(String rawPassword) {
        return "aaa" + rawPassword + "aaa";
    }
    
}

完成后,需要在AdminServiceImpl中自动装配以上PasswordEncoder,并在需要加密时调用PasswordEncoder对象的encode()方法。

10. 使用Lombok框架

在编写POJO类型(包括实体类、VO、DTO等)时,都有统一的编码规范,例如:

  • 属性都是私有的
  • 所有属性都有对应的Setter & Getter方法
  • 应该重写equals()hashCode()方法,以保证:如果2个对象的字面值完全相同,则equals()对比结果为true,且hashCode()返回值相同,如果2个对象的字面值不相同,则equals()对比结果为false,且hashCode()返回值不同
  • 实现Serializable接口

另外,为了便于观察对象的各属性值,通常还会重写toString()方法。

由于以上操作方式非常固定,且涉及的代码量虽然不难,但是篇幅较长,并且,当类中的属性需要修改时(包括修改原有属性、或增加新属性、删除原有属性),对应的其它方法都需要修改(或重新生成),管理起来比较麻烦。

使用Lombok框架可以极大的简化这些操作,此框架可以通过注解的方式,在编译期来生成Setters & Getters、equals()hashCode()toString(),甚至生成构造方法等,所以,一旦使用此框架,开发人员就只需要在类中声明各属性、实现Serializable、添加Lombok指定的注解即可。

在Spring Boot中,添加Lombok依赖,可以在创建项目时勾选,也可以后期自行添加,依赖项的代码为:

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

完成后,在各POJO类型中,将不再需要在源代码添加Setters & Getters、equals()hashCode()toString()这些方法,只需要在POJO类上添加@Data注解即可!

当添加@Data注解,且删除相关方法后,由于源代码中没有相关方法,则调用了相关代码的方法可能会报错,但是,并不影响程序运行!

为了避免IntelliJ IDEA判断失误而提示了警告和错误,推荐安装Lombok插件,可参考:

再次提示:无论是否安装插件,都不影响代码的编写和运行!

11. Slf4j日志框架

在开发实践中,不允许使用System.out.println()或类似的输出语句来输出显示关键数据(核心数据、敏感数据等),因为,如果是这样使用,无论是在开发环境,还是测试环境,还是生产环境中,这些输出语句都将输出相关信息,而删除或添加这些输出语句的操作成本比较高,操作可行性低。

推荐的做法是使用日志框架来输出相关信息!

当添加了Lombok依赖后,可以在需要使用日志的类上添加@Slf4j注解,然后,在类的任意中,均可使用名为log的变量,且调用其方法来输出日志(名为log的变量也是Lombok框架在编译期自动补充的声明并创建对象)!

在Slf4j日志框架中,将日志的可显示级别根据其重要程度(严重程度)由低到高分为:

  • trace:跟踪信息
  • debug:调试信息
  • info:一般信息,通常不涉及关键流程和敏感数据
  • warn:警告信息,通常代码可以运行,但不够完美,或不规范
  • error:错误信息

在配置文件中,可以通过logging.level.包名.类名来设置当前类的日志显示级别,例如:

logging.level.cn.tedu.boot.demo.service.impl.AdminServiceImpl: info

当设置了显示的日志级别后,仅显示设置级别和更重要的级别的日志,例如,设置为info时,只显示infowarnerror,不会显示debugtrace级别的日志!

当输出日志时,通过log变量调用trace()方法输出的日志就是trace级别的,调用debug()方法输出的日志就是debug()级别的,以此类推,可调用的方法还有info()warn()error()

在开发实践中,关键数据和敏感数据都应该通过trace()debug()进行输出,在开发环境中,可以将日志的显示级别设置为trace,则会显示所有日志,当需要交付到生产环境中时,只需要将日志的显示级别调整为info即可!

默认情况下,日志的显示级别是info,所以,即使没有在配置文件中进行正确的配置,所有info、warn、error级别的日志都会输出显示。

在配置时,属性名称中的logging.level部分是必须的,在其后,必须写至少1级包名,例如:

logging.level.cn: trace

以上配置表示cn包及其子孙包下的所有类中的日志都按照trace级别进行显示!

在开发实践中,属性名称通常配置为logging.level.项目根包,例如:

logging.level.cn.tedu.boot.demo: trace

在使用Slf4j时,通过log调用的每种级别的方法都被重载了多次(各级别对应除了方法名称不同,重载的次数和参数列表均相同),推荐使用的方法是参数列表为(String format, Object... arguments)的,例如:

public void trace(String format, Object... arguments);
public void debug(String format, Object... arguments);
public void info(String format, Object... arguments);
public void warn(String format, Object... arguments);
public void error(String format, Object... arguments);

以上方法中,第1个参数是将要输出的字符串的模式(模版),在此字符串中,如果需要包含某个变量值,则使用{}表示,如果有多个变量值,均是如此,然后,再通过第2个参数(是可变参数)依次表示各{}对应的值,例如:

log.debug("加密前的密码:{},加密后的密码:{}", password, encodedPassword);

使用这种做法,可以避免多变量时频繁的拼接字符串,另外,日志框架会将第1个参数进行缓存,以此提高后续每一次的执行效率。

在开发实践中,应该对程序执行关键位置添加日志的输出,通常包括:

  • 每个方法的第1行有效语句,表示代码已经执行到此方法内,或此方法已经被成功调用
    • 如果方法是有参数的,还应该输出参数的值
  • 关键数据或核心数据在改变之前和之后
    • 例如对密码加密时,应该通过日志输出加密前和加密后的密码
  • 重要的操作执行之前
    • 例如尝试插入数据之前、修改数据之前,应该通过日志输出相关值
  • 程序走到某些重要的分支时
    • 例如经过判断,走向抛出异常之前

其实,Slf4j日志框架只是日志的一种标准,并不是具体的实现(感觉上与Java中的接口有点相似),常见有具体实现了日志功能的框架有log4j、logback等,为了统一标准,所以才出现了Slf4j,同时,由于log4j、logback等框架实现功能并不统一,所以,Slf4j提供了对主流日志框架的兼容,在Spring Boot工程中,spring-boot-starter就已经依赖了spring-boot-starter-logging,而在此依赖下,通常包括Slf4j、具体的日志框架、Slf4j对具体日志框架的兼容。

12. 密码加密

【这并不是Spring Boot框架的知识点】

对密码进行加密,可以有效的保障密码安全,即使出现数据库泄密,密码安全也不会受到影响!为了实现此目标,需要在对密码进行加密时,使用不可逆的算法进行处理!

通常,不可以使用加密算法对密码进行加密码处理,从严格定义上来看,所有的加密算法都是可以逆向运算的,即同时存在加密和解密这2种操作,加密算法只能用于保证传输过程的安全,并不应该用于保证需要存储下来的密码的安全!

哈希算法都是不可逆的,通常,用于处理密码加密的算法中,典型的是一些消息摘要算法,例如MD5、SHA256或以上位数的算法。

消息摘要算法的主要特征有:

  • 消息相同时,摘要一定相同
  • 某种算法,无论消息长度多少,摘要的长度是固定的
  • 消息不同时,摘要几乎不会相同

在消息摘要算法中,以MD5为例,其运算结果是一个128位长度的二进制数,通常会转换成十六进制数显示,所以是32位长度的十六进制数,MD5也被称之为128位算法。理论上,会存在2的128次方种类的摘要结果,且对应2的128次方种不同的消息,如果在未超过2的128次方种消息中,存在2个或多个不同的消息对应了相同的摘要,则称之为:发生了碰撞。一个消息摘要算法是否安全,取决其实际的碰撞概率,关于消息摘要算法的破解,也是研究其碰撞概率。

存在穷举消息和摘要的对应关系,并利用摘要在此对应关系进行查询,从而得知消息的做法,但是,由于MD5是128位算法,全部穷举是不可能实现的,所以,只要原始密码(消息)足够复杂,就不会被收录到所记录的对应关系中去!

为了进一步提高密码的安全性,在使用消息摘要算法进行处理时,通常还会加盐!盐值可以是任意的字符串,用于与密码一起作为被消息摘要算法运算的数据即可,例如:

@Test
public void md5Test() {
    String rawPassword = "123456";
    String salt = "kjfcsddkjfdsajfdiusf8743urf";
    String encodedPassword = DigestUtils.md5DigestAsHex(
            (salt + salt + rawPassword + salt + salt).getBytes());
    System.out.println("原密码:" + rawPassword);
    System.out.println("加密后的密码:" + encodedPassword);
}

加盐的目的是使得被运算数据变得更加复杂,盐值本身和用法并没有明确要求!

甚至,在某些用法或算法中,还会使用随机的盐值,则可以使用完全相同的原消息对应的摘要却不同!

推荐了解:预计算的哈希链、彩虹表、雪花算法。

为了进一步保证密码安全,还可以使用多重加密,即反复调用消息摘要算法。

除此以外,还可以使用安全系数更高的算法,例如SHA-256是256位算法,SHA-384是384位算法,SHA-512是512位算法。

一般的应用方式可以是:

public class PasswordEncoder {

    public String encode(String rawPassword) {
        // 加密过程
        // 1. 使用MD5算法
        // 2. 使用随机的盐值
        // 3. 循环5次
        // 4. 盐的处理方式为:盐 + 原密码 + 盐 + 原密码 + 盐
        // 注意:因为使用了随机盐,盐值必须被记录下来,本次的返回结果使用$分隔盐与密文
        String salt = UUID.randomUUID().toString().replace("-", "");
        String encodedPassword = rawPassword;
        for (int i = 0; i < 5; i++) {
            encodedPassword = DigestUtils.md5DigestAsHex(
                    (salt + encodedPassword + salt + encodedPassword + salt).getBytes());
        }
        return salt + encodedPassword;
    }

    public boolean matches(String rawPassword, String encodedPassword) {
        String salt = encodedPassword.substring(0, 32);
        String newPassword = rawPassword;
            for (int i = 0; i < 5; i++) {
                newPassword = DigestUtils.md5DigestAsHex(
                        (salt + newPassword + salt + newPassword + salt).getBytes());
        }
        newPassword = salt + newPassword;
        return newPassword.equals(encodedPassword);
    }

}

13. 控制器层开发

Spring MVC是用于处理控制器层开发的,在使用Spring Boot时,在pom.xml中添加spring-boot-starter-web即可整合Spring MVC框架及相关的常用依赖项(包含jackson-databind),可以将已存在的spring-boot-starter直接改为spring-boot-starter-web,因为在spring-boot-starter-web中已经包含了spring-boot-starter

先在项目的根包下创建controller子包,并在此子包下创建AdminController,此类应该添加@RestController@RequestMapping(value = "/admins", produces = "application/json; charset=utf-8")注解,例如:

@RestController
@RequestMapping(values = "/admins", produces = "application/json; charset=utf-8")
public class AdminController {
    
}

由于已经决定了服务器端响应时,将响应JSON格式的字符串,为保证能够响应JSON格式的结果,处理请求的方法返回值应该是自定义的数据类型,则从此前学习的spring-mvc项目中找到JsonResult类及相关类型,复制到当前项目中来。

接下来,即可在AdminController中添加处理“增加管理员”的请求:

@Autowired
private IAdminService adminService;

// 注意:暂时使用@RequestMapping,不要使用@PostMapping,以便于直接在浏览器中测试
// http://localhost:8080/admins/add-new?username=root&password=1234
@RequestMapping("/add-new") 
public JsonResult<Void> addNew(AdminAddNewDTO adminAddNewDTO) {
    adminService.addNew(adminAddNewDTO);
    return JsonResult.ok();
}

完成后,运行启动类,即可启动整个项目,在spring-boot-starter-web中,包含了Tomcat的依赖项,在启动时,会自动将当前项目打包并部署到此Tomcat上,所以,执行启动类时,会执行此Tomcat,同时,因为是内置的Tomcat,只为当前项目服务,所以,在将项目部署到Tomcat时,默认已经将Context Path(例如spring_mvc_war_exploded)配置为空字符串,所以,在启动项目后,访问的URL中并没有此前遇到的Context Path值。

当项目启动成功后,即可在浏览器的地址栏中输入网址进行测试访问!

注意:如果是未添加的管理员账号,可以成功执行结束,如果管理员账号已经存在,由于尚未处理异常,会提示500错误。

关于处理异常,应该先在State中确保有每种异常对应的枚举值,例如本次需要补充InsertException对应的枚举值:

public enum State {

    OK(200),
    ERR_USERNAME(201),
    ERR_PASSWORD(202),
    ERR_INSERT(500); // 新增的枚举值

    // 原有其它代码

}

然后,在cn.tedu.boot.demo.controller下创建handler.GlobalExceptionHandler类,用于统一处理异常,例如:

package cn.tedu.boot.demo.controller.handler;

import cn.tedu.boot.demo.ex.ServiceException;
import cn.tedu.boot.demo.ex.UsernameDuplicateException;
import cn.tedu.boot.demo.web.JsonResult;
import cn.tedu.boot.demo.web.State;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ServiceException.class)
    public JsonResult<Void> handleServiceException(ServiceException e) {
        if (e instanceof UsernameDuplicateException) {
            return JsonResult.fail(State.ERR_USERNAME, "用户名错误!");
        } else {
            return JsonResult.fail(State.ERR_INSERT, "插入数据失败!");
        }
    }

}

完成后,重新启动项目,当添加管理员时的用户名没有被占用时,将正常添加,当用户名已经被占用时,会根据处理异常的结果进行响应!

由于在统一处理异常的机制下,同一种异常,无论是在哪种业务中出现,处理异常时的描述信息都是完全相同的,也无法精准的表达错误信息,这是不合适的!另外,基于面向对象的“分工”思想,关于错误信息(异常对应的描述信息),应该是由Service来描述,即“谁抛出谁描述”,因为抛出异常的代码片段是最了解、最明确出现异常的原因的!

为了更好的描述异常的原因,应该在自定义的ServiceException和其子孙类异常中添加基于父类的全部构造方法(5个),然后,在AdminServiceImpl中,当抛出异常时,可以在异常的构造方法中添加String类型的参数,对异常发生的原因进行描述,例如:

@Override
public void addNew(AdminAddNewDTO adminAddNewDTO) {
    // ===== 原有其它代码 =====
    
    // 判断查询结果是否不为null
    if (queryResult != null) {
        // 是:表示用户名已经被占用,则抛出UsernameDuplicateException
        log.error("此账号已经被占用,将抛出异常");
        throw new UsernameDuplicateException("添加管理员失败,用户名(" + username + ")已经被占用!");
    }

    // ===== 原有其它代码 =====

    // 判断以上返回的结果是否不为1,抛出InsertException异常
    if (rows != 1) {
        throw new InsertException("添加管理员失败,服务器忙,请稍后再次尝试!");
    }
}

最后,在处理异常时,可以调用异常对象的getMessage()方法获取抛出时封装的描述信息,例如:

@ExceptionHandler(ServiceException.class)
public JsonResult<Void> handleServiceException(ServiceException e) {
    if (e instanceof UsernameDuplicateException) {
        return JsonResult.fail(State.ERR_USERNAME, e.getMessage());
    } else {
        return JsonResult.fail(State.ERR_INSERT, e.getMessage());
    }
}

完成后,再次重启项目,当用户名已经存在时,可以显示在Service中描述的错误信息!

最后,当添加成功时,响应的JSON数据例如:

{
    "state":200,
    "message":null,
    "data":null
}

当用户名冲突,添加失败时,响应的JSON数据例如:

{
    "state":201,
    "message":"添加管理员失败,用户名(liuguobin)已经被占用!",
    "data":null
}

可以看到,无论是成功还是失败,响应的JSON中都包含了不必要的数据(为null的数据),这些数据属性是没有必要响应到客户端的,如果需要去除这些不必要的值,可以在对应的属性上使用注解进行配置,例如:

@Data
public class JsonResult<T> implements Serializable {

    // 状态码,例如:200
    private Integer state;
    // 消息,例如:"登录失败,用户名不存在"
    @JsonInclude(JsonInclude.Include.NON_NULL)
    private String message;
    // 数据
    @JsonInclude(JsonInclude.Include.NON_NULL)
    private T data;
    
    // ===== 原有其它代码 =====
    
}

则响应的JSON中只会包含不为null的部分。

此注解还可以添加在类上,则作用于当前类中所有的属性,例如:

@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class JsonResult<T> implements Serializable {

    // ===== 原有其它代码 =====
    
}

即使添加在类上,也只对当前类的3个属性有效,后续,当响应某些数据时,data属性可能是用户、商品、订单等类型,这些类型的数据中为null的部分依然会被响应到客户端去,所以,还需要对这些类型也添加相同的注解配置!

以上做法相对比较繁琐,可以在application.properties / application.yml中添加全局配置,则作用于当前项目中所有响应时涉及的类,例如在properties中配置为:

spring.jackson.default-property-inclusion=non_null

yml中配置为:

spring:
  jackson:
    default-property-inclusion: non_null

注意:当你需要在yml中添加以上配置时,前缀属性名可能已经存在,则不允许出现重复的前缀属性名,例如以下配置就是错误的:

spring:
  profiles:
    active: dev
spring: # 此处就出现了相同的前缀属性名,是错误的
  jackson:
    default-property-inclusion: non_null

正确的配置例如:

spring:
  profiles:
    active: dev
  jackson:
    default-property-inclusion: non_null

最后,以上配置只是“默认”配置,如果在某些类型中还有不同的配置需求,仍可以在类或属性上通过@JsonInclude进行配置。

14. Validation框架

当客户端向服务器提交请求时,如果请求数据出现明显的问题(例如关键数据为null、字符串的长度不在可接受范围内、其它格式错误),应该直接响应错误,而不是将明显错误的请求参数传递到Service!

关于判断错误,只有涉及数据库中的数据才能判断出结果的,都由Service进行判断,而基本的格式判断,都由Controller进行判断。

Validation框架是专门用于解决检查数据基本格式有效性的,最早并不是Spring系列的框架,目前,Spring Boot提供了更好的支持,所以,通常结合在一起使用。

在Spring Boot项目中,需要添加spring-boot-starter-validation依赖项,例如:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

在控制器中,首先,对需要检查数据格式的请求参数添加@Valid@Validated注解(这2个注解没有区别),例如:

@RequestMapping("/add-new")
public JsonResult<Void> addNew(@Validated AdminAddNewDTO adminAddNewDTO) {
    adminService.addNew(adminAddNewDTO);
    return JsonResult.ok();
}

真正需要检查的是AdminAddNewDTO中各属性的值,所以,接下来需要在此类的各属性上通过注解来配置检查的规则,例如:

@Data
public class AdminAddNewDTO implements Serializable {

    @NotNull // 验证规则为:不允许为null
    private String username;
    
    // ===== 原有其它代码 =====
    
}

重启项目,通过不提交用户名的URL(例如:http://localhost:8080/admins/add-new)进行访问,在浏览器上会出现400错误页面,并且,在IntelliJ IDEA的控制台会出现以下警告:

2022-06-07 11:37:53.424  WARN 6404 --- [nio-8080-exec-8] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [
org.springframework.validation.BindException: 
org.springframework.validation.BeanPropertyBindingResult: 1 errors<EOL>Field error in object 'adminAddNewDTO' on field 'username': rejected value [null]; codes [NotNull.adminAddNewDTO.username,NotNull.username,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [adminAddNewDTO.username,username]; arguments []; default message [username]]; default message [不能为null]]

从警告信息中可以看到,当验证失败时(不符合所使用的注解对应的规则时),会出现org.springframework.validation.BindException异常,则自行处理此异常即可!

如果有多个属性需要验证,则多个属性都需要添加注解,例如:

@Data
public class AdminAddNewDTO implements Serializable {

    @NotNull
    private String username;

    @NotNull
    private String password;
    
    // ===== 原有其它代码 =====
    
}

首先,在State中添加新的枚举:

public enum State {

    OK(200),
    ERR_USERNAME(201),
    ERR_PASSWORD(202),
    ERR_BAD_REQUEST(400), // 新增
    ERR_INSERT(500);
 
    // ===== 原有其它代码 =====
}

然后,在GlobalExceptionHandler中添加新的处理异常的方法:

@ExceptionHandler(BindException.class)
public JsonResult<Void> handleBindException(BindException e) {
    return JsonResult.fail(State.ERR_BAD_REQUEST, e.getMessage());
}

完成后,再次重启项目,继续使用为null的用户名提交请求时,可以看到异常已经被处理,此时,响应的JSON数据例如:

{
    "state":400,
    "message":"org.springframework.validation.BeanPropertyBindingResult: 2 errors\nField error in object 'adminAddNewDTO' on field 'username': rejected value [null]; codes [NotNull.adminAddNewDTO.username,NotNull.username,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [adminAddNewDTO.username,username]; arguments []; default message [username]]; default message [不能为null]\nField error in object 'adminAddNewDTO' on field 'password': rejected value [null]; codes [NotNull.adminAddNewDTO.password,NotNull.password,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [adminAddNewDTO.password,password]; arguments []; default message [password]]; default message [不能为null]"
}

关于错误提示信息,以上内容中出现了不能为null的字样,是默认的提示文本,可以通过@NotNull注解的message属性进行配置,例如:

@Data
public class AdminAddNewDTO implements Serializable {

    @NotNull(message = "添加管理员失败,请提交用户名!")
    private String username;

    @NotNull(message = "添加管理员失败,请提交密码!")
    private String password;
    
    // ===== 原有其它代码 =====
    
}

然后,在处理异常时,通过异常信息获取自定义的提示文本:

@ExceptionHandler(BindException.class)
public JsonResult<Void> handleBindException(BindException e) {
    BindingResult bindingResult = e.getBindingResult();
    String defaultMessage = bindingResult.getFieldError().getDefaultMessage();
    return JsonResult.fail(State.ERR_BAD_REQUEST, defaultMessage);
}

再次运行,在不提交用户名和密码的情况下,会随机的提示用户名或密码验证失败的提示文本中的某1条。

在Validation框架中,还有其它许多注解,用于进行不同格式的验证,例如:

  • @NotEmpty:只能添加在String类型上,不许为空字符串,例如""即视为空字符串
  • @NotBlank:只能添加在String类型上,不允许为空白,例如普通的空格可视为空白,使用TAB键输入的内容也是空白,(虽然不太可能在此处出现)换行产生的空白区域也是空白
  • @Size:限制大小
  • @Min:限制最小值
  • @Max:限制最大值
  • @Range:可以配置minmax属性,同时限制最小值和最大值
  • @Pattern:只能添加在String类型上,自行指定正则表达式进行验证
  • 其它

以上注解,包括@NotNull是允许叠加使用的,即允许在同一个参数属性上添加多个注解!

以上注解均可以配置message属性,用于指定验证失败的提示文本。

通常:

  • 对于必须提交的属性,都会添加@NotNull
  • 对于数值类型的,需要考虑是否添加@Range(则不需要使用@Min@Max
  • 对于字符串类型,都添加@Pattern注解进行验证

15. 解决跨域问题

在使用前后端分离的开发模式下,前端项目和后端项目可能是2个完全不同的项目,并且,各自己独立开发,独立部署,在这种做法中,如果前端直接向后端发送异步请求,默认情况下,在前端会出现类似以下错误:

Access to XMLHttpRequest at 'http://localhost:8080/admins/add-new' from origin 'http://localhost:8081' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

以上错误信息的关键字是CORS,通常称之为“跨域问题”。

在基于Spring MVC框架的项目中,当需要解决跨域问题时,需要一个Spring MVC的配置类(实现了WebMvcConfigurer接口的类),并重写其中的方法,以允许指定条件的跨域访问,例如:

package cn.tedu.boot.demo.config;

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

@Configuration
public class SpringMvcConfig implements WebMvcConfigurer {

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

}

16. 关于客户端提交请求参数的格式

通常,客户端向服务器端发送请求时,请求参数可以有2种形式,第1种是直接通过&拼接各参数与值,例如:

// FormData
// username=root&password=123456&nickname=jackson&phone=13800138001&email=jackson@baidu.com&description=none
let data = 'username=' + this.ruleForm.username
              + '&password=' + this.ruleForm.password
              + '&nickname=' + this.ruleForm.nickname
              + '&phone=' + this.ruleForm.phone
              + '&email=' + this.ruleForm.email
              + '&description=' + this.ruleForm.description;

第2种方式是使用JSON语法来组织各参数与值,例如:

let data = {
    'username': this.ruleForm.username, // 'root'
    'password': this.ruleForm.password, // '123456'
    'nickname': this.ruleForm.nickname, // 'jackson'
    'phone': this.ruleForm.phone, // '13800138001'
    'email': this.ruleForm.email, // 'jackson@baidu.com'
    'description': this.ruleForm.description // 'none'
};

具体使用哪种做法,取决于服务器端的设计:

  • 如果服务器端处理请求的方法中,在参数前添加了@RequestBody,则允许使用以上第2种做法(JSON数据)提交请求参数,不允许使用以上第1种做法(使用&拼接)
  • 如果没有使用@RequestBody,则只能使用以上第1种做法

17. 处理登录

17.1. 开发流程

正常的项目开发流程大致是:

  • 先整理出当前项目涉及的数据的类型
    • 例如:电商类包含用户、商品、购物车、订单等
  • 再列举各种数据类型涉及的数据操作
    • 例如:用户类型涉及注册、登录等
  • 再挑选相对简单的数据类型先处理
    • 简单的易于实现,且可以积累经验
  • 在各数据类型涉及的数据操作中,大致遵循增、查、删、改的开发顺序
    • 只有先增,还可能查、删、改
    • 只有查了以后,才能明确有哪些数据,才便于实现删、改
    • 删和改相比,删一般更加简单,所以先开发删,再开发改
  • 在开发具体的数据操作时,应该大致遵循持久层 >> 业务逻辑层 >> 控制器层 >> 前端页面的开发顺序

17.2. 管理员登录-持久层

17.2.1. 创建或配置

如果是整个项目第1次开发持久层,在Spring Boot项目中,需要配置:

  • 使用@MapperScan配置接口所在的根包
  • 在配置文件中通过mybatis.mapper-locations配置XML文件的位置

如果第1次处理某种类型数据的持久层访问,需要:

  • 创建接口
  • 创建XML文件

本次需要开发的“管理员登录”并不需要再做以上操作

17.2.2. 规划需要执行的SQL语句

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

select * from ams_admin where username=?

由于在ams_admin表中有大量字段,同时,不允许使用星号表示字段列表,则以上SQL语句应该细化为:

select id, username, password, nickname, avatar, is_enable from ams_admin where username=?

提示:理论上,还应该查出login_count,当登录成功后,还应该更新login_countgmt_last_login等数据,此次暂不考虑。

17.2.3. 在接口中添加抽象方法(含创建必要的VO类)

提示:所有的查询结果,都应该使用VO类,而不要使用实体类,根据阿里的开发规范,每张数据表中都应该有idgmt_creategmt_modified这3个字段,而gmt_creategmt_modified这2个字段都是用于特殊情况下排查问题的,一般情况下均不会使用,所以,如果使用实体类,必然存在多余的属性,同时,由于不使用星号作为字段列表,则一般也不会查询这2个字段的值,会导致实体类对象中永远至少存在2个属性为null

根据以上提示,以前已经写好的getByUsername()是不规范的,应该调整已存在此方法,本次并不需要添加新的抽象方法。

则先创建cn.tedu.boot.demo.pojo.vo.AdminSimpleVO类,添加此次查询时需要的属性:

package cn.tedu.boot.demo.pojo.vo;

@Data
public class AdminSimpleVO implements Serializable {
    private Long id;
    private String username;
    private String password; 
    private String nickname; 
    private String avatar;
    private Integer isEnable;
}

然后,在AdminMapper接口文件中,将原有的Admin getByUsername(String username);改为:

AdminSimpleVO getByUsername(String username);

注意:一旦修改了原有代码,则调用了原方法的代码都会出现错误,包括:

  • 测试
  • 业务逻辑层的实现类

应该及时修改错误的代码,但是,由于此时还未完成SQL配置,所以,相关代码暂时并不能运行。

17.2.4. 在XML中配置SQL

AdminMapper.xml中,需要调整:

  • 删除<sql>中不必查询的字段,注意:此处的字段列表最后不要有多余的逗号
  • 修改<resultMap>节点的type属性值
  • <resultMap>节点下,删除不必要的配置
<select id="getByUsername" resultMap="BaseResultMap">
    select
        <include refid="BaseQueryFields" />
    from
         ams_admin
    where
         username=#{username}
</select>

<sql id="BaseQueryFields">
    <if test="true">
        id,
        username,
        password,
        nickname,
        avatar,
        is_enable
    </if>
</sql>

<resultMap id="BaseResultMap" type="cn.tedu.boot.demo.pojo.vo.AdminSimpleVO">
    <id column="id" property="id" />
    <result column="username" property="username" />
    <result column="password" property="password" />
    <result column="nickname" property="nickname" />
    <result column="avatar" property="avatar" />
    <result column="is_enable" property="isEnable" />
</resultMap>

17.2.5. 编写并执行测试

此次并不需要编写新的测试,使用原有的测试即可!

注意:由于本次是修改了原“增加管理员”就已经使用的功能,应该检查原功能是否可以正常运行。

17.3. 管理员登录-业务逻辑层

17.3.1. 创建

如果第1次处理某种类型数据的业务逻辑层访问,需要:

  • 创建接口
  • 创建类,实现接口,并在类上添加@Service注解

本次需要开发的“管理员登录”并不需要再做以上操作

17.3.2. 在接口中添加抽象方法(含创建必要的DTO类)

在设计抽象方法时,如果参数的数量超过1个,且多个参数具有相关性(是否都是客户端提交的,或是否都是控制器传递过来的等),就应该封装!

在处理登录时,需要客户端提交用户名和密码,则可以将用户名、密码封装起来:

package cn.tedu.boot.demo.pojo.dto;

@Data
public class AdminLoginDTO implements Serializable {
    private String username;
    private String password;
}

IAdminService中添加抽象方法:

AdminSimpleVO login(AdminLoginDTO adminLoginDTO);

17.3.3. 在实现类中设计(打草稿)业务流程与业务逻辑(含创建必要的异常类)

此次业务执行过程中,可能会出现:

  • 用户名不存在,导致无法登录
  • 用户状态为【禁用】,导致无法登录
  • 密码错误,导致无法登录

关于用户名不存在的问题,可以自行创建新的异常类,例如,在cn.tedu.boot.demo.ex包下创建UserNotFoundException类表示用户数据不存在的异常,继承自ServiceException,且添加5款基于父类的构造方法:

package cn.tedu.boot.demo.ex;

public class UserNotFoundException extends ServiceException {
    // 自动生成5个构造方法
}

再创建UserStateException表示用户状态异常:

package cn.tedu.boot.demo.ex;

public class UserStateException extends ServiceException {
    // 自动生成5个构造方法
}

再创建PasswordNotMatchException表示密码错误异常:

package cn.tedu.boot.demo.ex;

public class PasswordNotMatchException extends ServiceException {
    // 自动生成5个构造方法
}

登录过程大致是:

public AdminSimpleVO login(AdminLoginDTO adminLoginDTO) {
    // 通过参数得到尝试登录的用户名
    // 调用adminMapper.getByUsername()方法查询
    // 判断查询结果是否为null
    // 是:表示用户名不存在,则抛出UserNotFoundException异常
    
    // 【如果程序可以执行到此步,则可以确定未抛出异常,即查询结果不为null】
    // 【以下可视为:存在与用户名匹配的管理员数据】
    // 判断查询结果中的isEnable属性值是否不为1
    // 是:表示此用户状态是【禁用】的,则抛出UserStateException异常
    
    // 【如果程序可以执行到此步,表示此用户状态是【启用】的】
    // 从参数中取出此次登录时客户端提交的密码
    // 调用PasswordEncoder对象的matches()方法,对客户端提交的密码和查询结果中的密码进行验证
    // 判断以上验证结果
    // true:密码正确,视为登录成功
    // -- 将查询结果中的password、isEnable设置为null,避免响应到客户端
    // -- 返回查询结果
    // false:密码错误,视为登录失败,则抛出PasswordNotMatchException异常
}

17.3.4. 在实现类中实现业务

AdminServiceImpl中重写接口中新增的抽象方法:

@Override
public AdminSimpleVO login(AdminLoginDTO adminLoginDTO) {
    // 日志
    log.debug("即将处理管理员登录的业务,尝试登录的管理员信息:{}", adminLoginDTO);
    // 通过参数得到尝试登录的用户名
    String username = adminLoginDTO.getUsername();
    // 调用adminMapper.getByUsername()方法查询
    AdminSimpleVO queryResult = adminMapper.getByUsername(username);
    // 判断查询结果是否为null
    if (queryResult == null) {
        // 是:表示用户名不存在,则抛出UserNotFoundException异常
        log.warn("登录失败,用户名不存在!");
        throw new UserNotFoundException("登录失败,用户名不存在!");
    }

    // 【如果程序可以执行到此步,则可以确定未抛出异常,即查询结果不为null】
    // 【以下可视为:存在与用户名匹配的管理员数据】
    // 判断查询结果中的isEnable属性值是否不为1
    if (queryResult.getIsEnable() != 1) {
        // 是:表示此用户状态是【禁用】的,则抛出UserStateException异常
        log.warn("登录失败,此账号已经被禁用!");
        throw new UserNotFoundException("登录失败,此账号已经被禁用!");
    }

    // 【如果程序可以执行到此步,表示此用户状态是【启用】的】
    // 从参数中取出此次登录时客户端提交的密码
    String rawPassword = adminLoginDTO.getPassword();
    // 调用PasswordEncoder对象的matches()方法,对客户端提交的密码和查询结果中的密码进行验证
    boolean matchResult = passwordEncoder.matches(rawPassword, queryResult.getPassword());
    // 判断以上验证结果
    if (!matchResult) {
        // false:密码错误,视为登录失败,则抛出PasswordNotMatchException异常
        log.warn("登录失败,密码错误!");
        throw new PasswordNotMatchException("登录失败,密码错误!");
    }

    // 密码正确,视为登录成功
    // 将查询结果中的password、isEnable设置为null,避免响应到客户端
    queryResult.setPassword(null);
    queryResult.setIsEnable(null);
    // 返回查询结果
    log.debug("登录成功,即将返回:{}", queryResult);
    return queryResult;
}

17.3.5. 编写并执行测试

AdminServiceTests中添加测试:

@Sql({"classpath:truncate.sql", "classpath:insert_data.sql"})
@Test
public void testLoginSuccessfully() {
    // 测试数据
    String username = "admin001";
    String password = "123456";
    AdminLoginDTO adminLoginDTO = new AdminLoginDTO();
    adminLoginDTO.setUsername(username);
    adminLoginDTO.setPassword(password);
    // 断言不会抛出异常
    assertDoesNotThrow(() -> {
        // 执行测试
        AdminSimpleVO adminSimpleVO = service.login(adminLoginDTO);
        log.debug("登录成功:{}", adminSimpleVO);
        // 断言测试结果
        assertEquals(1L, adminSimpleVO.getId());
        assertNull(adminSimpleVO.getPassword());
        assertNull(adminSimpleVO.getIsEnable());
    });
}

@Sql({"classpath:truncate.sql"})
@Test
public void testLoginFailBecauseUserNotFound() {
    // 测试数据
    String username = "admin001";
    String password = "123456";
    AdminLoginDTO adminLoginDTO = new AdminLoginDTO();
    adminLoginDTO.setUsername(username);
    adminLoginDTO.setPassword(password);
    // 断言会抛出UserNotFoundException
    assertThrows(UserNotFoundException.class, () -> {
        // 执行测试
        service.login(adminLoginDTO);
    });
}

@Sql({"classpath:truncate.sql", "classpath:insert_data.sql"})
@Test
public void testLoginFailBecauseUserDisabled() {
    // 测试数据
    String username = "admin005"; // 通过SQL脚本插入的此数据,is_enable为0
    String password = "123456";
    AdminLoginDTO adminLoginDTO = new AdminLoginDTO();
    adminLoginDTO.setUsername(username);
    adminLoginDTO.setPassword(password);
    // 断言会抛出UserStateException
    assertThrows(UserStateException.class, () -> {
        // 执行测试
        service.login(adminLoginDTO);
    });
}

@Sql({"classpath:truncate.sql", "classpath:insert_data.sql"})
@Test
public void testLoginFailBecausePasswordNotMatch() {
    // 测试数据
    String username = "admin001";
    String password = "000000000000000000";
    AdminLoginDTO adminLoginDTO = new AdminLoginDTO();
    adminLoginDTO.setUsername(username);
    adminLoginDTO.setPassword(password);
    // 断言会抛出PasswordNotMatchException
    assertThrows(PasswordNotMatchException.class, () -> {
        // 执行测试
        service.login(adminLoginDTO);
    });
}

17.4. 管理员登录-控制器层

17.4.1. 创建

如果是整个项目第1次开发控制器层,需要:

  • 创建统一处理异常的类
    • 添加@RestControllerAdvice
  • 创建统一的响应结果类型及相关类型
    • 例如:JsonResultState

如果第1次处理某种类型数据的控制器层访问,需要:

  • 创建控制器类
    • 添加@RestController
    • 添加@RequestMapping

本次需要开发的“管理员登录”并不需要再做以上操作

17.4.2. 添加处理请求的方法,验证请求参数的基本有效性

AdminLoginDTO的各属性上添加验证基本有效性的注解,例如:

package cn.tedu.boot.demo.pojo.dto;

import lombok.Data;

import javax.validation.constraints.NotNull;
import java.io.Serializable;

@Data
public class AdminLoginDTO implements Serializable {
    
    @NotNull(message = "登录失败,请提交用户名!") // 新增
    private String username;
    
    @NotNull(message = "登录失败,请提交密码!") // 新增
    private String password;
    
}

AdminController中添加处理请求的方法:

@RequestMapping("/login") // 暂时使用@RequestMapping,后续改成@PostMapping
public JsonResult<AdminSimpleVO> login(@Validated AdminLoginDTO adminLoginDTO) {
    AdminSimpleVO adminSimpleVO = adminService.login(adminLoginDTO);
    return JsonResult.ok(adminSimpleVO);
}

17.4.3. 处理异常(按需)

先在State中添加新创建的异常对应枚举:

public enum State {

    OK(200),
    ERR_USERNAME(201),
    ERR_PASSWORD(202),
    ERR_STATE(203), // 新增
    ERR_BAD_REQUEST(400),
    ERR_INSERT(500);
    
    // ===== 原有其它代码 =====
}

GlobalExceptionHandlerhandleServiceException()方法中添加更多分支,针对各异常进行判断,并响应不同结果:

@ExceptionHandler(ServiceException.class)
public JsonResult<Void> handleServiceException(ServiceException e) {
    if (e instanceof UsernameDuplicateException) {
        return JsonResult.fail(State.ERR_USERNAME, e.getMessage());
    } else if (e instanceof UserNotFoundException) {				// 从此行起,是新增的
        return JsonResult.fail(State.ERR_USERNAME, e.getMessage());
    } else if (e instanceof UserStateException) {
        return JsonResult.fail(State.ERR_STATE, e.getMessage());
    } else if (e instanceof PasswordNotMatchException) {
        return JsonResult.fail(State.ERR_PASSWORD, e.getMessage());	// 新增结束标记
    } else {
        return JsonResult.fail(State.ERR_INSERT, e.getMessage());
    }
}

17.4.4. 测试

启动项目,暂时通过 http://localhost:8080/admins/login?username=admin001&password=123456 类似的URL测试访问。注意:在测试访问之前,必须保证数据表中的数据状态是符合预期的。

17.5. 管理员登录-前端页面

18. 控制器层的测试

关于控制器层,也可以写测试方式进行测试,在Spring Boot项目中,可以使用MockMvc进行模拟测试,例如:

package cn.tedu.boot.demo.controller;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;

@SpringBootTest
@AutoConfigureMockMvc // 自动配置MockMvc
public class AdminControllerTests {

    @Autowired
    MockMvc mockMvc; // Mock:模拟

    @Sql({"classpath:truncate.sql", "classpath:insert_data.sql"})
    @Test
    public void testLoginSuccessfully() throws Exception {
        // 准备测试数据,不需要封装
        String username = "admin001";
        String password = "123456";
        // 请求路径,不需要写协议、服务器主机和端口号
        String url = "/admins/login";
        // 执行测试
        // 以下代码相对比较固定
        mockMvc.perform( // 执行发出请求
                MockMvcRequestBuilders.post(url) // 根据请求方式决定调用的方法
                .contentType(MediaType.APPLICATION_FORM_URLENCODED) // 请求数据的文档类型,例如:application/json; charset=utf-8
                .param("username", username) // 请求参数,有多个时,多次调用param()方法
                .param("password", password)
                .accept(MediaType.APPLICATION_JSON)) // 接收的响应结果的文档类型,注意:perform()方法到此结束
                .andExpect( // 预判结果,类似断言
                        MockMvcResultMatchers
                                .jsonPath("state") // 预判响应的JSON结果中将有名为state的属性
                                .value(200)) // 预判响应的JSON结果中名为state的属性的值,注意:andExpect()方法到此结束
                .andDo( // 需要执行某任务
                        MockMvcResultHandlers.print()); // 打印日志
    }

}

执行以上测试时,并不需要启动当前项目即可测试。

在执行以上测试时,响应的JSON中如果包含中文,可能会出现乱码,需要在配置文件(application.propertiesapplication.yml这类文件)中添加配置。

.properties文件中:

server.servlet.encoding.force=true
server.servlet.encoding.charset=utf-8

.yml文件中:

server:
  servlet:
    encoding:
      force: true
      charset: utf-8

posted @ 2022-06-29 21:58  约拿小叶  阅读(74)  评论(0编辑  收藏  举报