WikiWiki
首页
Java开发
Java面试
Linux手册
  • AI相关
  • Python Flask
  • Pytorch
  • youlo8
SEO
uniapp小程序
Vue前端
work
数据库
软件设计师
入门指南
首页
Java开发
Java面试
Linux手册
  • AI相关
  • Python Flask
  • Pytorch
  • youlo8
SEO
uniapp小程序
Vue前端
work
数据库
软件设计师
入门指南
  • API工具笔记
  • Java基础
  • SpringAI开发问答系统
  • 前后端分离架构
  • 工作流Activity7
  • 微服务架构

框架

一、SpringBoot整合

1. 数据库编程的依赖

<!-- 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>

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

检查配置是否正确在test文件测试 没报错即通过

@Autowired
DataSource dataSource;

@Test
void getConnection() throws Throwable {
    dataSource.getConnection();
}

2. Mybatis实现数据库编程

在一个项目中,除了关联表,绝大部分的数据表都至少需要实现以下功能:

  • 插入1条数据
  • 批量插入数据
  • 根据id删除某1条数据
  • 根据若干个id批量删除某些数据
  • 根据id修改数据
  • 统计当前表中数据的数量
  • 根据id查询数据详情
  • 查询当前表中的数据列表

在项目的根包下创建config.MybatisConfiguration配置类(添加了@Configuration注解的类),在此类上配置@MapperScan注解 指定接口文件所在的根包

package cn.tedu.csmall.product.config;

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

@MapperScan("cn.tedu.csmall.product.mapper")
@Configuration
public class MybatisConfiguration {
}

在application.properties中添加配置:

# Mybatis相关配置
mybatis.mapper-locations=classpath:mapper/*.xml
# 支持驼峰命名法-yml
  configuration:
    mapUnderscoreToCamelCase: true

并且,在src/main/resources下创建名为mapper的文件夹。

编写POJO类,先在项目中添加依赖:

<!-- Lombok的依赖项,主要用于简化POJO类的编写 -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.20</version>
    <scope>provided</scope>
</dependency>

在任何POJO类上,可以添加@Data注解,则此框架(Lombok)会自动在编译期生成:

  • 各属性对应的Setters & Getters
  • 基于各属性的hashCode()与equals()
  • 基于各属性的toString()

另外,所有POJO类都应该实现Serializable接口

在项目的根包下创建`pojo.entity.xxxxx中声明与数据表对应的各属性

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;

}

在项目的根包下创建mapper.AlbumMapper接口,并在接口中添加“插入1条数据”的抽象方法

在各Mapper接口上添加@Repository注解(防止自动装配报错)

  • 与添加@Mapper注解的本质不同,添加@Mapper注解是为了标识此接口是Mybatis框架应该处理的接口,添加@Repository注解是为了引导IntelliJ IDEA作出正确的判断
package cn.tedu.csmall.product.mapper;
import cn.tedu.csmall.product.pojo.entity.Album;
/**
 * 处理相册数据的Mapper接口
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Repository
public interface AlbumMapper {
    /**
     * 插入相册数据
     *
     * @param album 相册数据
     * @return 受影响的行数
     */
    int insert(Album album);

}

阿里巴巴的参考:

  • 获取单个对象的方法用 get 做前缀
  • 获取多个对象的方法用 list 做前缀
  • 获取统计值的方法用 count 做前缀
  • 插入的方法用 save/insert 做前缀
  • 删除的方法用 remove/delete 做前缀
  • 修改的方法用 update 做前缀

在src/main/resource/mapper下,并在此文件中配置以上接口

抽象方法的对应代码:

<?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">←这里需要对应mapper中的文件
    <!-- int insert(Album album); -->
    <insert id="insert">
        INSERT INTO pms_album (
            name, description, sort
        ) VALUES (
            #{name}, #{description}, #{sort}
        )
    </insert>
</mapper>
mapper数据库语句编写规范

插入数据时获取自动编号的id

在<insert>标签上,可以配置useGeneratedKeys="true"和keyProperty="属性名",将可以获取自动编号的id值,并由Mybatis自动赋值到参数对象的属性(keyProperty配置的值)上

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

提示:如果表的id不是自动编号的,则插入数据时必须由方法的调用者给出id值,所以,对于方法的调用者而言,id值是已知的,则不需要配置这2个属性。

mapper中增删改查语句示例

批量插入数据

int insertBatch(List<Album> albums);
<!-- int insertBatch(List<Album> albums); -->
<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>

根据id删除数据

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

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

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

修改数据

int update(Album album);
<!-- int update(Album album); -->
<update id="update">
    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>

统计数据的数量

int count();

在设计“查询”的抽象方法时,关于返回值类型,只需要保证所设计的返回值类型足够“装得下”所需的查询结果即可。

<!-- int count(); -->
<select id="count" resultType="int">
    SELECT count(*) FROM pms_album
</select>

注意:每个<select>标签必须配置resultType或resultMap这2个属性中的其中1个

根据id查询数据的详情

创建pojo.vo.AlbumStandardVO类型,在此类型中设计与查询的字段列表匹配的属性

AlbumStandardVO getStandardById(Long id);
<!-- 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>

注意:每个<select>标签必须配置resultType或resultMap这2个属性中的其中1个。

查询数据列表

创建pojo.vo.AlbumListItemVO类型,在此类型中设计与查询的字段列表匹配的属性

List<AlbumListItemVO> list();
<!-- List<AlbumListItemVO> list(); -->
<select id="list" resultType="cn.tedu.csmall.product.pojo.vo.AlbumListItemVO">
    SELECT id, name, description, sort FROM pms_album ORDER BY sort DESC, id DESC
</select>

注意:每个<select>标签必须配置resultType或resultMap这2个属性中的其中1个。

<resultMap>与<sql>标签

Mybatis框架在处理查询时,会自动的将列名与属性名完全一致的数据进行封装(例如查询结果集中列名为name的值会自动封装到返回值对象的name属性中),如果名称不一致,则不会自动封装!

通常,建议通过<resultMap>标签来配置列名与属性名的对应关系,以指导Mybatis如何处理结果集。

另外,还建议使用<sql>标签来封装查询的字段列表,并通过<include>标签来引用封装的查询字段列表,例如:

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

<sql id="StandardQueryFields">
    <if test="true">
        id, name, parent_id, depth, keywords, sort, icon, enable, is_parent, is_display
    </if>
</sql>

<resultMap id="StandardResultMap" type="cn.tedu.csmall.product.pojo.vo.CategoryStandardVO">
    <id column="id" property="id"/>
    <result column="name" property="name"/>
    <result column="parent_id" property="parentId"/>
    <result column="depth" property="depth"/>
    <result column="keywords" property="keywords"/>
    <result column="sort" property="sort"/>
    <result column="icon" property="icon"/>
    <result column="enable" property="enable"/>
    <result column="is_parent" property="isParent"/>
    <result column="is_display" property="isDisplay"/>
</resultMap>

注意:配置<select>标签时,如果使用resultMap属性,则此属性的值必须是<resultMap>的id值!如果使用resultType属性,则此属性的值必须是返回结果类型的全限定名!

添加管理员--Mapper层

当添加管理员时,还应该执行相关的检查,例如:

  • 用户名必须唯一(在任何系统中,登录时使用的凭证必须唯一)
  • 手机号码必须唯一(暂时用不上,但合理)
  • 电子邮箱必须唯一(暂时用不上,但合理)

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

@Repository
public interface AdminMapper {
    int insert(Admin admin);
    int countByUsername(String username);
    int countByPhone(String phone);
    int countByEmail(String email);
}
INSERT INTO ams_admin (除了id和2个时间以外的字段列表) VALUES (匹配的值列表)
SELECT count(*) FROM ams_admin WHERE username=?
SELECT count(*) FROM ams_admin WHERE phone=?
SELECT count(*) FROM ams_admin WHERE email=?

删除管理员--Mapper层

删除管理员,需要Mapper实现:

  • 根据id删除管理员数据
  • 根据id查询管理员数据(用于检查尝试删除的数据是否存在)

MySQL数据类型与Java类中的数据类型的对应关系

MySQL数据类型Java类中的数据类型
tinyint / smallint / intInteger
bigintLong
char / varchar / text系列String
datetimeLocalDateTime
Mybatis的占位符中使用的名称

在使用Mybatis框架时,配置的SQL语句中的参数可以使用占位符来表示,例如:

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

以上SQL语句中的#{id}就是占位符。

事实上,当抽象方法的参数只有1个时,在占位符中的名称是完全自由编写的,因为Mybatis框架会自动的查找那唯一参数值代入到SQL执行过程中!当抽象方法的参数超过1个时,在占位符中名称不可以是随意的名称,如果使用的名称错误,则可能出现类似以下错误:

org.mybatis.spring.MyBatisSystemException: nested exception is org.apache.ibatis.binding.BindingException: Parameter 'passwordxxx' not found. Available parameters are [password, id, param1, param2]

在错误提示信息中,已经明确指出:Available parameters are [password, id, param1, param2],即“可用的参数名称是[password, id, param1, param2]”,则配置的SQL语句可以是:

<update id="updatePasswordById">
    update ams_admin set password=#{password} where id=#{id}
</update>

或者:

<update id="updatePasswordById">
    update ams_admin set password=#{param2} where id=#{param1}
</update>

其实,以上是在较高版本的框架中的执行效果,在较低版本的框架中,可用的参数通常是[arg0, arg1, param1, param2],在这种情况下,在占位符中使用id或password这样的名称也是不可被识别的!只能使用arg系列参数,或param系列参数,如果使用arg系列参数,则使用arg0表示抽象方法的第1个参数,使用arg1表示抽象方法的第2个参数,使用arg2表示抽象方法的第3个参数,以此类推,即使在错误提示信息中并没有arg2、arg3等,也可以正常使用!如果使用param系列参数,与使用arg系列的方式基本相同,区别在于param系列的是使用param1表示第1个参数的。

之所以较低版本的参数没有名字,是因为Java语言在编译时,会丢失局部的量的名称,包括局部变量、方法的参数,所以,尽管设计抽象方法时明确的指定了方法的参数名称,但是编译时会丢失,所以,在最终运行时将无法根据SQL语句中配置的例如#{id}、#{password}这种名称的占位符找到参数!在较高版本的框架中,加入了编译期的干预,使得抽象方法的参数名称被保留了下来,所以,可以直接使用例如#{id}、#{password}这种名称的占位符!

在较低版本的框架中,为了保证能够使用#{id}、#{password}这种名称的占位符,需要在抽象方法的各参数前添加@Param注解,以配置参数的名称,例如:

int updatePasswordById(@Param("id") Long id, @Param("password") String password);

则配置的SQL语句中的占位符中必须使用注解中配置的名称!

目前,由于新老版本的框架都有人使用,为了避免出现问题,建议无论使用什么版本,只要方法的参数超过1个,都使用@Param注解来指定参数名称!

以上规则也适用于动态SQL中的<foreach>标签的collection属性,关于此属性的值,如果抽象方法的参数只有1个,当参数类型是数组或可变参数时,取值为array,如果参数类型是List,取值为list,如果抽象方法的参数超过1个,使用@Param配置参数名,则collection属性的值就是@Param注解配置的参数名!

Mybatis的占位符的格式

在Mybatis中,配置SQL语句时,参数可以使用#{}格式的占位符表示,也可以使用${}格式的占位符来表示!

对于以下查询:

<select id="getStandardById" resultMap="StandardResultMap">
    SELECT
        <include refid="StandardQueryFields" />
    FROM
        ams_admin
    WHERE
        id=#{id}
</select>

无论是使用#{}还是使用${}格式的占位符,执行效果是相同的!

对于以下查询:

<select id="getLoginInfoByUsername" resultMap="LoginResultMap">
    SELECT
        <include refid="LoginQueryFields" />
    FROM
        ams_admin
    WHERE
        username=#{username}
</select>

使用#{}格式的占位符时,是可以正常使用的,使用${}格式的占位符,会出现如下错误:

Caused by: java.sql.SQLSyntaxErrorException: Unknown column 'root' in 'where clause'

其实,以上问题,可以通过调用方法时,在传入的参数值两端添加单引号来解决,例如:

@Test
void getLoginInfoByUsername() {
    String username = "'liucangsong'";
    Object queryResult = mapper.getLoginInfoByUsername(username);
    log.debug("根据用户名【{}】查询数据详情完成,查询结果:{}", username, queryResult);
}

本质上,使用#{}占位符时,Mybatis在通过JDBC底层实现时,使用了预编译的处理,所以,占位符的位置只可能是个值,不可能是字段名或别的,所以不需要使用一对单引号框住;使用${}占位符时,并没有使用预编译,而只是将参数值拼接到SQL语句中执行,所以,对于非数值型的参数值,需要使用一对单引号框住!

由于使用#{}占位符时,是预编译的,所以,完全不存在SQL注入的风险,而${}占位符是先拼接SQL再编译并执行的,拼接的参数值有可能改变语义,所以,存在SQL注入的风险!

当然,${}格式的占位符虽然有不少缺点,但是,它可以表示SQL语句中的任何片段,而#{}只能表示某个值!而且,关于SQL注入,其实要想实现SQL注入,传入的值也是需要满足许多特征性条件的,只要在执行SQL语句之前使用正则表达式进行验证,就可以避免出现SQL注入!

Mybatis的缓存机制

Mybatis框架默认是有2套缓存机制的,分别称之一级缓存和二级缓存。

Mybatis框架的一级缓存也称之为“会话(Session)缓存”,默认是开启的,且无法关闭!

一级缓存必须保证多次的查询操作满足:同一个SqlSession、同一个Mapper、执行相同的SQL查询、使用相同的参数。

关于一级缓存的典型表现可以通过测试以下代码进行观察:

@Slf4j
@SpringBootTest
public class MybatisCacheTests {

    @Autowired
    SqlSessionFactory sqlSessionFactory;

    @Test
    void l1Cache() {
        SqlSession sqlSession = sqlSessionFactory.openSession();
        BrandMapper brandMapper = sqlSession.getMapper(BrandMapper.class);

        Long id = 1L;
        log.debug("开始第【1】次执行根据ID【1】查询品牌详情……");
        BrandStandardVO result1 = brandMapper.getStandardById(id);
        log.debug("第【1】查询结果的hashCode()值为:{}", result1.hashCode());
        log.debug("开始第【2】次执行根据ID【1】查询品牌详情……");
        BrandStandardVO result2 = brandMapper.getStandardById(id);
        log.debug("第【2】查询结果的hashCode()值为:{}", result2.hashCode());

        log.debug("第【1】次的查询结果与第【2】的查询结果进行对比,结果:{}", result1 == result2);

        id = 2L;
        log.debug("开始第【1】次执行根据ID【2】查询品牌详情……");
        BrandStandardVO result3 = brandMapper.getStandardById(id);
        log.debug("第【1】查询结果的hashCode()值为:{}", result3.hashCode());
        log.debug("开始第【2】次执行根据ID【2】查询品牌详情……");
        BrandStandardVO result4 = brandMapper.getStandardById(id);
        log.debug("第【2】查询结果的hashCode()值为:{}", result4.hashCode());

        id = 1L;
        log.debug("开始第【3】次执行根据ID【1】查询品牌详情……");
        BrandStandardVO result5 = brandMapper.getStandardById(id);
        log.debug("第【3】查询结果的hashCode()值为:{}", result5.hashCode());
    }

}

一级缓存会因为以下任意一种原因而消失:

  • 调用SqlSession对象的clearCache()方法,将清除当前会话中此前产生的所有一级缓存数据
  • 当前执行了任何写操作(增 / 删 / 改),无论任何数据有没有发生变化,都会清空此前产生的缓存数据

演示代码:

@Slf4j
@SpringBootTest
public class MybatisCacheTests {

    @Autowired
    SqlSessionFactory sqlSessionFactory;

    @Test
    void l1Cache() {
        SqlSession sqlSession = sqlSessionFactory.openSession();
        BrandMapper brandMapper = sqlSession.getMapper(BrandMapper.class);

        Long id = 1L;
        log.debug("开始第【1】次执行根据ID【1】查询品牌详情……");
        BrandStandardVO result1 = brandMapper.getStandardById(id);
        log.debug("第【1】查询结果的hashCode()值为:{}", result1.hashCode());
        log.debug("开始第【2】次执行根据ID【1】查询品牌详情……");
        BrandStandardVO result2 = brandMapper.getStandardById(id);
        log.debug("第【2】查询结果的hashCode()值为:{}", result2.hashCode());

        log.debug("第【1】次的查询结果与第【2】的查询结果进行对比,结果:{}", result1 == result2);

        id = 2L;
        log.debug("开始第【1】次执行根据ID【2】查询品牌详情……");
        BrandStandardVO result3 = brandMapper.getStandardById(id);
        log.debug("第【1】查询结果的hashCode()值为:{}", result3.hashCode());
        log.debug("开始第【2】次执行根据ID【2】查询品牌详情……");
        BrandStandardVO result4 = brandMapper.getStandardById(id);
        log.debug("第【2】查询结果的hashCode()值为:{}", result4.hashCode());

        // log.debug("即将调用SqlSession对象的clearCache()方法清除缓存……");
        // sqlSession.clearCache();
        // log.debug("已经清除以前产生的缓存数据!");

        log.debug("即将执行写操作……");
        Brand brand = new Brand();
        brand.setId(1L);
        brand.setName("华为2022");
        brandMapper.update(brand);
        log.debug("执行写操作,完成!");

        id = 1L;
        log.debug("开始第【3】次执行根据ID【1】查询品牌详情……");
        BrandStandardVO result5 = brandMapper.getStandardById(id);
        log.debug("第【3】查询结果的hashCode()值为:{}", result5.hashCode());

        id = 2L;
        log.debug("开始第【3】次执行根据ID【2】查询品牌详情……");
        BrandStandardVO result6 = brandMapper.getStandardById(id);
        log.debug("第【3】查询结果的hashCode()值为:{}", result6.hashCode());
    }

}

Mybatis框架的二级缓存也称之为“namespace缓存”,是作用于某个namespace的,具体 表现为:无论是否为同一个SqlSession,只要执行的是相同的Mapper的查询,且查询参数相同,就可以应用二级缓存。

在使用Spring Boot与Mybatis的项目中,二级缓存默认是全局开启的,但各namespace默认并未开启,如果需要在namespace中开启二级缓存,需要在XML文件中添加<cache/>标签,则表示当前XML中所有查询都开启了二级缓存!

需要注意:使用二级缓存时,需要保证查询结果的类型实现了Serializable接口!

另外,还可以在<select>标签上配置useCache属性,以配置“是否使用缓存”,此属性的默认值为true,表示“使用缓存”。

当应用二级缓存后,在日志上会提示[Cache Hit Ratio],表示“当前namespace缓存命中率”。

与一级缓存相同,只需要发生任何写操作,都会自动清除缓存数据!

Mybatis在查询数据时,会优先尝试从二级缓存中查询是否存在缓存数据,如果命中,将直接返回,如果未命中,则尝试从一级缓存中查询是否存在缓存数据,如果命中,将返回,如果仍未命中,将执行数据库查询。

二级缓存的示例代码:

@Autowired
BrandMapper brandMapper;

@Test
void l2Cache() {
    Long id = 1L;
    log.debug("开始第【1】次执行根据ID【1】查询品牌详情……");
    BrandStandardVO result1 = brandMapper.getStandardById(id);
    log.debug("第【1】查询结果的hashCode()值为:{}", result1.hashCode());

    log.debug("开始第【2】次执行根据ID【1】查询品牌详情……");
    BrandStandardVO result2 = brandMapper.getStandardById(id);
    log.debug("第【2】查询结果的hashCode()值为:{}", result2.hashCode());

    log.debug("即将执行写操作……");
    Brand brand = new Brand();
    brand.setId(6L);
    brand.setName("微软2022");
    brandMapper.update(brand);
    log.debug("执行写操作,完成!");

    log.debug("开始第【3】次执行根据ID【1】查询品牌详情……");
    BrandStandardVO result3 = brandMapper.getStandardById(id);
    log.debug("第【3】查询结果的hashCode()值为:{}", result3.hashCode());

    log.debug("开始第【4】次执行根据ID【1】查询品牌详情……");
    BrandStandardVO result4 = brandMapper.getStandardById(id);
    log.debug("第【4】查询结果的hashCode()值为:{}", result4.hashCode());

    log.debug("开始第【5】次执行根据ID【1】查询品牌详情……");
    BrandStandardVO result5 = brandMapper.getStandardById(id);
    log.debug("第【5】查询结果的hashCode()值为:{}", result5.hashCode());
}

提示:一定要在XML中添加<cache>才能够使用二级缓存。

3. 密码加密

用户注册时(管理员添加新的账号信息时)的密码必须经过某种算法进行编码,并将得到的结果存储到数据库,而不允许将原始密码直接存储到数据库中

对于需要存储下来的密码,且只需要验证原密码是否匹配,不需要(不允许)通过逆向运算根据密文得到原文时,应该使用消息摘要算法对密码原文进行编码处理!

消息摘要算法的典型特征有:

  • 消息相同时,摘要必然相同
  • 使用固定的某种消息摘要算法时,无论消息的长度多少,摘要的长度是固定的
  • 消息不同时,摘要极大概率不会相同
    • 理论上,必然存在N个不同的消息,通过编码,得到的摘要是完全相同的
    • 由于摘要的种类也非常多,设计得非常好的算法会使得这种概率非常低
  • 所有消息摘要算法都是不可以逆向运算的

消息摘要算法的结果通常会使用十六进制数来表示,通常,运算结果相同简单的消息摘要算法的结果长度也有32位十六进制数组成,还原成二进制数就是128个二进制数组成,这样的算法称之“128位算法”,同理,如果某个结果是由64个十六进制数组成,还原成二进制就是256个二进制数,则称之为“256位算法”。

常见的消息算法有:

  • MD(Message Digest)系列
    • MD2(128位,不推荐) / MD4(128位,不推荐) / MD5(128位)
  • SHA(Secure Hash Algorithm)家族
    • SHA-1(160位,不推荐) / SHA-256(256位) / SHA-384(384位) / SHA-512(512位)

在Spring Boot项目中,已有DigestUtils工具类,提供了使用MD5算法进行编码的API:

关于消息摘要算法的破解:所有消息摘要算法都是不可逆向运算的,尝试将消息摘要运算结果运算还原出原本数据的思路本身就是错误的!一个成熟可靠的消息摘要算法,理论上,极难找出2个不同的原始数据对应相同的摘要,如果出现了这样的情况,称之“碰撞”,关于消息摘要算法的破解,本身是研究碰撞,与逆向运算无关!

如果需要根据使用消息算法编码得到的密文还原出密码的原文,常见做法是使用穷举法,记录各原文与密文的对应关系,当需要“破解”时,本质上是在执行查询操作!

原文(消息)密文(摘要)
000000670b14728ad9902aecba32e22fa4f6bd
11111196e79218965eb72c92a549dd5a330112
222222e3ceb5881a0a1fdaad01296d7554868d
3333331a100d2c0dab19c4430e7d73762b3423
44444473882ab1fa529d7273da0db6b49cc4f3
5555555b1b68a9abf4d2cd155c81a9225fd158

其实,这样的做法能够“破解”的密码是非常有限的!假设密码可以使用所有的可打印字符(共计95种),如果密码的长度只有1位,则可以有95种不同的密码,如果密码的长度有2位,则可以有95 x 95种不同的密码,以此类推,如果密码的长度有6位,则可以有735,091,890,625种(7350亿种)不同的密码,如果密码的长度有8位,则可以有6,634,204,312,890,625种(约6634万亿种)不同的密码,所以,如果需要反查出任何8位长度的密码,则需要记录下6600多万亿条数据才可以!

所以,最有效的保证密码安全的做法是要求用户使用安全强度更高的密码,例如密码至少有8位,且密码中必须同时包含字母、数字、标点符号等。

为了进一步提高密码的安全性,在编码过程中,还应该使用“盐值”,盐值是一个自定义的字符串,它应该与原密码一并作为消息摘要算法的被运算数据,则对于算法而言,被运算数据就不再只是简单的密码原文而已,则密码原文与盐值组合的结果,例如:

String salt = "75ifDV8rqHLfrFw87TFylDGSLGah";
String rawPassword = "000000";
String encodedPassword = DigestUtils
            .md5DigestAsHex((salt + rawPassword + salt).getBytes());
System.out.println("原文:" + rawPassword);
System.out.println("密文:" + encodedPassword);

不过,尽管加盐可以非常有效的防止密码被反查,但是,如果算法、运算参数(例如盐值等)、密文同时被泄露,依然可能会被穷举式的暴力破解。

目前,更推荐使用BCrypt算法对密码加密进行处理,这种算法默认就使用了随机的盐,并且,将盐值作为加密结果的一部分,以此保证密文是可以被验证的!更重要的是,BCrypt算法被设计为非常非常慢的算法,

总的来说,要最大限度的保证密码安全,应该:

  • 要求用户使用安全强度更高的密码
  • 加盐
  • 多重加密
  • 使用安全系数更强的算法
    • 综合以上做法

4. Lombok框架

Lombok框架的主要作用是通过注解可以在编译期生成某些代码,例如Setters & Getters、hashCode()与equals()、toString()方法等,可以简化开发。

<!-- Lombok的依赖项,主要用于简化POJO类的编写 -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.20</version>
    <scope>provided</scope>
</dependency>

Lombok的常用注解有:

  • @Data:添加在类上,可在编译期生成全部属性对应的Setters & Getters、hashCode()与equals()、toString(),使用此注解时,必须保证当前类的父类存在无参数构造方法
  • @Setter:可以添加在属性上,将仅作用于当前属性,也可以添加在类上,将作用于类中所有属性,用于生成对应的Setter方法
  • @Getter:同上,用于生成对应的Getter方法
  • @EqualsAndHashCode:添加在类上,用于生成规范的equals()和hashCode(),关于equals()方法,如果2个对象的所有属性的值完全相同,则返回true,否则返回false,关于hashCode()也是如此,如果2个对象的所有属性的值完全相同,则生成的HashCode值相同,否则,不应该相同
  • @ToString:添加在类上,用于生成全属性对应的toString()方法
  • @Slf4j:添加在类上,用于日志输出 log.info(“---------”)

日志显示级别

  • trace:跟踪信息,可能包含不一定关注,但是包含了程序执行流程的信息
  • debug:调试信息,可能包含一些敏感内容,比如关键数据的值
  • info:一般信息
  • warn:警告信息
  • error:错误信息

日志的显示级别配置

logging.level.cn.tedu.csmall=error

日志占位符的使用方法

int x = 1;
int y = 2;
log.info("{}+{}={}", x, y, x + y);

5. Spring Boot的Profile配置

Profile配置

在Spring Boot中,它允许使用application-自定义名称.properties作为Profile配置文件的文件名,这类配置文件默认是不加载的

在application.properties的同级路径下创建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.properties中,根据以上配置文件的名称(application-dev.properties)来激活以上配置文件:

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

如果application.properties与被激活的Profile配置中存在同名的属性,配置值却不相同,在执行时,将以Profile配置为准

YAML配置

Spring Boot项目中,使用.properties和.yml配置是等效的YAML语法中,其典型特征是:

  • 如果属性名中有小数点,则可以改为冒号,并且,冒号的右侧应该换行且缩进2个空格
    • 在IntelliJ IDEA中编辑YAML语法的配置时,会自动将按下的TAB键的内容转换成2个空格
  • 如果多个属性名称中有相同的部分,不必(也不可)重复配置,只需要保持正确的缩进即可
  • 属性名与属性值之间使用1个冒号和1个空格进行分隔
  • 对于纯数值类型的属性值,可能需要使用双引号框住
  • 也能识别例如xx.xx.xx这类属性名

例如:在.properties中的配置为:

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

在.yml中则配置为:

spring:
  datasource:
    username: root
    password: root

注意:YAML的解析相对更加严格,如果在此类配置文件中出现了错误的语法,甚至只是一些不应该出现的字符,都会导致解析失败!并且,如果直接复制粘贴整个文件,还可能出现乱码问题!

yml在线转换工具:https://www.toyaml.com/index.html

6. Service业务层编写

根包下创建service.IxxxxService接口 并在IAlbumService中设计“添加相册”的抽象方法:

关于Service的抽象方法的声明原则:

  • 返回值类型:仅以操作成功为前提来设计返回值类型
    • 操作失败将通过抛出异常来表示
  • 方法名称:自定义的、规范的,无其它约束
  • 参数列表:根据客户端将提交的请求参数来设计,如果参数数量较多,且具有相关性,则应该封装
public interface IAlbumService {
    void addNew(AlbumAddNewDTO albumAddNewDTO);
}

根包下创建service.impl.xxxxServiceImpl类,实现以上接口,并在类上添加@Service注解:

@Slf4j
@Service
public class AlbumServiceImpl implements IAlbumService {
    public AlbumServiceImpl() {
        log.debug("创建业务对象:AlbumServiceImpl");
    }
}

接下来,在项目的根包下创建pojo.dto.xxxxAddNewDTO类,用于封装客户端将提交的请求参数

根据id删除数据

void delete(Long id);
public void delete(Long id) {
    // 调用Mapper对象的getStandardById()执行查询
    // 判断查询结果是否为null
    // 是:无此id对应的数据,将不允许执行删除操作,则抛出异常
    
    // 调用Mapper对象的deleteById()方法执行删除
}

查询数据列表

List<AlbumListItemVO> list();
@Override
public List<AlbumListItemVO> list() {
    log.debug("开始处理【查询相册列表】的业务,无参数");
    return albumMapper.list();
}

添加管理员--Service层

@Transactional
public interface IAdminService {
    void addNew(AdminAddNewDTO adminAddNewDTO);
}
@Service
public class AdminServiceImpl implements IAdminService {
    
    @Autowired 
    private AdminMapper adminMapper;
    
    @Override 
    public void addNew(AdminAddNewDTO adminAddNewDTO) {
        // 调用adminMapper的int countByUsername(String username)执行统计查询
        // 判断统计结果是否大于0
        // 是:抛出异常(ERR_CONFLICT:用户名被占用)
        
        // 调用adminMapper的int countByPhone(String phone)执行统计查询
        // 判断统计结果是否大于0
        // 是:抛出异常(ERR_CONFLICT:手机号码被占用)
        
        // 调用adminMapper的int countByEmail(String email)执行统计查询
        // 判断统计结果是否大于0
        // 是:抛出异常(ERR_CONFLICT:电子邮箱被占用)
        
        // 创建Admin对象
        // 通过BeanUtils.copyProperties()将参数对象中的各属性复制到Admin对象中
        // 补全Admin对象的属性值:loginCount >>> 0
        // TODO 从参数对象中取出密码,进行加密,再存入到Admin对象中
        // 调用adminMapper的int insert(Admin admin)方法,执行插入管理员数据,获取返回值
        // 判断以上返回的受影响行数是否不等于1
        // 是:抛出异常(ERR_INSERT:服务器忙)
    }
}

删除管理员--Service层

void delete(Long id);
public void delete(Long id) {
    // 调用adminMapper的AdminStandardVO getStandardById(Long id)方法执行查询
    // 判断查询结果是否为null
    // 是:抛出异常(ERR_NOT_FOUND)
    
    // 调用adminMapper的int deleteById(Long id)执行删除,并获取返回的行数
    // 判断返回的行数是否不为1
    // 是:抛出异常(ERR_DELETE:服务器忙)
}

在IAdminService中添加抽象方法:

void setEnable(Long id); // enable=1
void setDisable(Long id); // enable=0

在AdminServiceImpl中实现方法:

public void setEnable(Long id) {
    updateEnableById(id, 1);
}

public void setDisable(Long id) {
    updateEnableById(id, 0)
}

private void updateEnableById(Long id, Integer enable) {
    String[] s = {"禁用", "启用"};
    // 判断id是否为1
    // 是:抛出异常(ERR_NOT_FOUND:s[enable] 管理员失败)
    // 根据id查询管理员详情
    // 判断查询结果是否为null
    // 是:抛出异常(ERR_NOT_FOUND)
    // 判断查询结果中的enable与方法参数enable是否相同
    // 是:抛出异常(ERR_CONFLICT)
    // 创建Admin对象,并封装id和enable这2个属性的值
    // 调用update()方法执行更新,并获取返回的行数
    // 判断返回的行数是否不为1
    // 是:抛出异常(ERR_UPDATE)
}

添加管理员时确定此管理员的角色

当某个软件系统设计了“权限”的概念时,添加用户时必须确定此用户的权限,在基于RBAC的设计中,添加用户时必须确定此用户的角色,进而可以关联到某些权限,否则,如果没有确定此用户的角色,会导致新增的用户没有权限,可能无法进行相关操作!

要补充“添加管理员时确定此管理员的角色”,需要:

  • 在“添加管理员”的界面中,补充显示“角色列表”的下拉菜单,以显示出当前系统中的所有角色
  • 在“添加管理员”的客户端代码中,提交请求时,需要提交所选择的“角色”
  • 在服务器端的业务实现类中,添加管理员时,当插入管理员数据之后,还需要向“管理员与角色的关联表”中插入数据
  • 在服务器端的业务实现类中,删除管理员时,还需要将“管理员与角色的关联表”中的相关数据一并删除

对于服务器端而言,具体需要实现的有:

  • 新增RoleMapper接口,需要实现“查询角色列表”功能,并且,还需要开发至控制器层
  • 新增AdminRoleMapper接口,需要实现“批量插入”功能,“根据管理员id删除数据”功能

7.Web层编写

@RequestMapping

在Spring MVC框架中,可以在处理请求的方法上添加@RequestMapping注解,以配置请求路径与处理请求的方法的映射关系。此注解还可以添加在控制器类上,作为当前类中每个请求路径的统一前缀

注意:@RequestMapping("/")和@RequestMapping("")不是等效的!???

通常,建议:以获取数据为主要目的的请求,应该限制为GET方式,除此以外,都应该限制为POST方式。

Spring MVC框架还定义了已经限制了请求方式的、与@RequestMapping类似的注解,包括:

  • @GetMapping
  • @PostMapping
  • @PutMapping
  • @DeleteMapping
  • @PatchMapping

处理“添加数据”的请求

// http://localhost:8080/add-new?name=相册001&description=相册001的简介&sort=199
    @RequestMapping("/add-new")
    public String addNew(AlbumAddNewDTO albumAddNewDTO) {
        log.debug("开始处理【添加相册】的请求,参数:{}", albumAddNewDTO);
        try {
            albumService.addNew(albumAddNewDTO);
            log.debug("添加数据成功!");
            return "添加相册成功!";
        } catch (RuntimeException e) {
            log.debug("添加数据失败!");
            return "添加相册失败!";
        }
    }

根据id删除

@ApiOperation("根据id删除相册")
@ApiOperationSupport(order = 200)
@ApiImplicitParam(name = "id", value = "相册id", required = true, dataType = "long")
@PostMapping("/{id:[0-9]+}/delete")
public JsonResult delete(@Range(min = 1, message = "删除相册失败,尝试删除的相册的ID无效!")
                         @PathVariable Long id) {
    log.debug("开始处理【根据id删除相册】的请求,参数:{}", id);
    albumService.delete(id);
    return JsonResult.ok();
}

查询数据列表

// http://localhost:8080/albums
@ApiOperation("查询相册列表")
@ApiOperationSupport(order = 420)
@GetMapping("")
public JsonResult list() {
    log.debug("开始处理【查询相册列表】的请求,无参数");
    List<AlbumListItemVO> list = albumService.list();
    return JsonResult.ok(list);
}

关于@RequestBody

在Spring MVC项目中(包括添加了spring-boot-starter-web依赖项的Spring Boot项目),可以在处理请求的方法的参数列表中,在某参数上添加@RequestBody注解。

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

{
    'name': '测试相册名称',
    'description': '测试相册简介',
    'sort': 188
}

如果客户端提交的请求参数不是对象格式时,将出现以下错误:

Resolved [org.springframework.web.HttpMediaTypeNotSupportedException: Content type 'application/x-www-form-urlencoded;charset=UTF-8' not supported]

当请求参数没有添加@RequestBody注解时,则客户端提供的请求参数必须是FormData格式的,例如:

name=测试相册名称&description=测试相册简介&sort=188

如果请求不是FormData格式的,则服务器端将接收不到请求参数,各参数值将为null。

关于RESTful

RESTful是一种软件的设计风格(不是规定,也不是规范)。

RESTFUL是一种网络应用程序的设计风格和开发方式,基于HTTP,可以使用XML格式定义或JSON格式定义。RESTFUL适用于移动互联网厂商作为业务接口的场景,实现第三方OTT调用移动网络资源的功能,动作类型为新增、变更、删除所调用资源。

RESTful的典型表现有:

  • 一定是响应正文的
    • 服务器端处理完请求后将响应数据,不会由服务器响应页面到客户端
  • 通常会将具有唯一性的请求参数设计到URL中,成为URL的一部分
    • https://blog.csdn.net/gghbf45219/article/details/1045245854
    • https://blog.csdn.net/m4465sfd46/article/details/1042276671
  • 严格区分4种请求方式,在许多业务系统中并不这样设计
    • 尝试增加数据,使用POST请求方式
    • 尝试删除数据,使用DELETE请求方式
    • 尝试修改数据,使用PUT请求方式
    • 尝试查询数据,使用GET请求方式

Spring MVC框架很好的支持了RESTful风格的设计,当需要在URL中使用变量值时,可以使用{自定义名称}作为占位符,并且,在方法的参数列表中,自定义参数接收此变量值,在参数前还需要添加@PathVariable注解,例如:

// http://localhost:8080/album/9527/delete
@RequestMapping("/{id}/delete")
public String delete(@PathVariable String id) {
    String message = "尝试删除id值为【" + id + "】的相册";
    log.debug(message);
    return message;
}

注意:通常会将占位符中的名称和方法的参数名称保持一致,例如以上的{id}和String id都使用id作为名字,如果因为某些原因无法保持一致,则需要配置@PathVariable注解的参数,此注解参数值与占位符中的名称一致即可,方法的参数名称就不重要了。

在开发实践中,可以将处理请求的方法的参数类型设计为期望的类型,例如将id设计为Long类型的,但是,如果这样设计,必须保证请求中的参数值是可以被正确转换为Long类型的,否则会出现400错误!

为了尽量保证匹配的准确性、保证参数值可以正常转换,在设计占位符时,可以在占位符名称右侧添加冒号,并在冒号右侧使用正则表达式来限制占位符的值的格式,例如:

@RequestMapping("/{id:[0-9]+}/delete")

注意,一旦使用正则表达式后,多个不冲突的占位符的设计是允许共存在的,例如:

// http://localhost:8080/album/9527/delete
@RequestMapping("/{id:[0-9]+}/delete")
public String delete(@PathVariable Long id) {
    String message = "尝试删除id值为【" + id + "】的相册";
    log.debug(message);
    return message;
}

// http://localhost:8080/album/hello/delete
@RequestMapping("/{name:[a-z]+}/delete")
public String delete(@PathVariable String name) {
    String message = "尝试删除名称值为【" + name + "】的相册";
    log.debug(message);
    return message;
}

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

// http://localhost:8080/album/hello/delete
@RequestMapping("/{name:[a-z]+}/delete")
public String delete(@PathVariable String name) {
    String message = "尝试删除名称值为【" + name + "】的相册";
    log.debug(message);
    return message;
}

// http://localhost:8080/album/test/delete
@RequestMapping("/test/delete")
public String delete() {
    String message = "尝试测试删除相册";
    log.debug(message);
    return message;
}

最后,关于RESTful风格的URL设计,如果没有明确的要求,或没有更好的选择,可以设计为:

  • 获取数据列表:/数据类型的复数
    • 例如:/albums
  • 根据id获取数据:/数据类型的复数/id值
    • 例如:/albums/1
  • 根据id对数据执行某种操作:/数据类型的复数/id值/命令
    • 例如:/albums/1/delete

8. 异常编写与处理

通常,自定义异常应该继承自RuntimeException,其原因主要有:

  • 所有RuntimeException不会受到try...catch或throw / throws语法的约束,更适合结合Spring MVC框架的统一处理异常机制来处理
  • 基于Spring JDBC的事务管理,默认将基于RuntimeException进行回滚

则在项目的根包下创建ex.ServiceException类,继承自RuntimeException

Spring MVC框架的统一处理异常的机制

编写统一处理异常的方法,对控制器类中处理请求的方法抛出的异常进行处理,关于统一处理异常的方法:

  • 注解:必须添加@ExceptionHandler注解
  • 访问权限:应该使用public
  • 返回值类型:参考处理请求的方法
  • 方法名称:自定义
  • 参数列表:必须有1个异常类型的参数,表示Spring MVC框架调用处理请求的方法时捕获到的异常,并且,可以按需添加HttpServletRequest、HttpServletResponse等少量特定类型的参数,不可以随意添加其它参数

在项目的根包下创建ex.handler.GlobalExceptionHandler类,在此类上添加@RestControllerAdvice注解,并在类中添加处理异常的方法:

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() {
        log.debug("创建全局异常处理器对象:GlobalExceptionHandler");
    }
    @ExceptionHandler
    public String handleServiceException(ServiceException e) {
        log.debug("处理请求的方法抛出了ServiceException,将统一处理");
        return e.getMessage();
    }
}

服务器端响应的结果

根包下创建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;
    private ServiceCode(Integer value) {
        this.value = value;
    }
    public Integer getValue() {
        return value;
    }
}

JsonResult类中,添加自定义的静态方法,用于快速创建类的对象,并且,通过静态方式的名称(例如ok、fail等)更好的表现语义,必要的情况下,还可以对这些方法重载,使得方法的调用更加灵活:

package cn.tedu.csmall.product.web;
import cn.tedu.csmall.product.ex.ServiceException;
import lombok.Data;
import java.io.Serializable;
@Data
public class JsonResult implements Serializable {
    /**
     * 状态码
     */
    private Integer state;
    /**
     * 操作失败时的描述文本
     */
    private String message;
    /**
     * 操作成功时响应的数据
     */
    private Object data;

    public static JsonResult ok() {
        JsonResult jsonResult = new JsonResult();
        jsonResult.state = ServiceCode.OK.getValue();
        return jsonResult;
    }
    public static JsonResult fail(ServiceException e) {
        return fail(e.getServiceCode(), e.getMessage());
    }
    public static JsonResult fail(ServiceCode serviceCode, String message) {
        JsonResult jsonResult = new JsonResult();
        jsonResult.state = serviceCode.getValue();
        jsonResult.message = message;
        return jsonResult;
    }
    public static JsonResult ok(Object data) {
    JsonResult jsonResult = new JsonResult();
    jsonResult.state = ServiceCode.OK.getValue();
    jsonResult.data = data;
    return jsonResult;
	}	
}

调整ServiceException类型的构造方法,应该在抛出异常时,除了封装异常的描述文本(message),还应该定义此异常对应的业务状态码:

public class ServiceException extends RuntimeException {
    private ServiceCode serviceCode;
    public ServiceException(ServiceCode serviceCode, String message) {
        super(message);
        this.serviceCode = serviceCode;
    }
    public ServiceCode getServiceCode() {
        return serviceCode;
    }
}

并且,在业务层中,抛出异常时,要封装以上数据(业务状态码和描述文本),例如:

if (count > 0) {
    // 是:相册名称已经被占用,添加相册失败,抛出异常
    String message = "添加相册失败,相册名称已经被占用!";
    log.debug(message);
    throw new ServiceException(ServiceCode.ERR_CONFLICT, message);
}

后续,在处理异常时,将返回JsonResult类型的对象

9.Knife4j框架

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

  • 添加依赖 注意:本次使用的Knife4j的版本必须基于Spring Boot的版本在2.6之前(2.6及更高版本不可用)

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

    knife4j.enable=true
    
  • 添加配置类,配置类代码为(注意:可能需要修改包名):

    package cn.tedu.csmall.product.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.product.controller";
        /**
         * 分组名称
         */
        private String groupName = "product";
        /**
         * 主机名
         */
        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:8080/doc.html 查看在线API文档

相关注解

  • @Api:添加在控制器类上,通过此注解的tags属性,可以指定模块名称,并且,可以在模块名称前自行添加数字序号,以实现排序效果,框架会根据各控制器类上@Api注解的``tags`属性值进行升序排列

  • @ApiOperation:添加在处理请求的方法上,通过此注解的value属性,可以指定业务名称

  • @ApiOperationSupport:添加在处理请求的方法上,通过此注解的order属性(数值型),可以指定排序序号,框架会根据此属性值升序排列

  • @ApiModelProperty:添加在POJO类型的属性上,通过此注解的value属性可以配置请求参数的名称,通过此注解的required属性可以配置是否必须提交此请求参数(注意:此项配置值并不具备任何检查效果)

  • @ApiImplicitParam:添加在处理请求的方法上,适用于请求参数不是POJO类型时,必须配置此注解的name属性,取值为请求参数的名称,表示当前注解是对哪个请求参数进行配置,然后,通过此注解的value属性可以配置请求参数的名称,通过此注解的required属性可以配置是否必须提交此请求参数(注意:此项配置值并不具备任何检查效果)。通过此注解的dataType属性配置请求参数的数据类型,常见取值有:string、long等

  • @ApiImplicitParams:添加在处理请求的方法上,适用于请求参数不是POJO类型,且需要配置的参数的数量超过1个时,需要配置此注解的value属性,值是@ApiImplicitParam注解的数组类型,例如:

    @ApiImplicitParams({
        @ApiImplicitParam(),
        @ApiImplicitParam(),
        @ApiImplicitParam()
    })
    

10.关于跨域访问

跨域访问:客户端与服务器端不在同一台服务器上。

在默认情况下,不允许发送跨域访问请求,如果发送,在浏览器的控制台中则会提示如下错误:

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

如果使用的是FireFox浏览器,提示信息如下所示:

已拦截跨源请求:同源策略禁止读取位于 http://localhost:9080/albums/add-new 的远程资源。(原因:CORS 头缺少 'Access-Control-Allow-Origin')。状态码:200。

当服务器端允许来自跨域的客户端发送请求时,在Spring Boot项目中,需要使用配置类实现WebMvcConfigurer接口,并重写addCorsMappings()方法进行配置。

则在csmall-product项目中,在根包下创建config.WebMvcConfiguration类,在类上添加@Configuration注解,并实现WebMvcConfigurer接口,重写addCorsMappings()方法:

package cn.tedu.csmall.product.config;

import lombok.extern.slf4j.Slf4j;
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
 */
@Slf4j
@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {

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

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

11. Validation框架

添加依赖

在pom.xml中添加spring-boot-starter-validation依赖项:

<!-- Spring Boot Validation的依赖项,用于检查请求参数的基本格式 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

检查封装的请求参数

在控制器中,对于封装类型的请求参数,应该先在请求参数之前添加@Valid或@Validated注解,表示将需要对此请求参数的格式进行检查,例如:

@ApiOperation("添加相册")
@ApiOperationSupport(order = 100)
@PostMapping("/add-new")
//                       ↓↓↓↓↓↓ 以下是新添加的注解
public JsonResult addNew(@Valid AlbumAddNewDTO albumAddNewDTO) {
    log.debug("开始处理【添加相册】的请求,参数:{}", albumAddNewDTO);
    albumService.addNew(albumAddNewDTO);
    log.debug("添加相册成功!");
    return JsonResult.ok();
}

然后,在此封装类型中,在需要检查的属性上,添加检查注解,例如可以添加@NotNull注解,此注解表示“不允许为null值”,例如:

@Data
public class AlbumAddNewDTO implements Serializable {

    /**
     * 相册名称
     */
    @ApiModelProperty(value = "相册名称", required = true)
    @NotNull // 新添加的注解
    private String name;
    
	// 暂不关心其它代码   
}

重启项目,如果客户端提交请求时,未提交name请求参数,就会响应400错误,并且,在服务器端的控制台会提示错误:

2022-11-01 11:27:45.398  WARN 15104 --- [nio-9080-exec-3] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.validation.BindException: org.springframework.validation.BeanPropertyBindingResult: 1 errors<EOL>Field error in object 'albumAddNewDTO' on field 'name': rejected value [null]; codes [NotNull.albumAddNewDTO.name,NotNull.name,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [albumAddNewDTO.name,name]; arguments []; default message [name]]; default message [不能为null]]

处理检查不通过时的异常

由于检查未通过时会抛出org.springframework.validation.BindException异常,则可以在全局异常处理器中,添加对此异常的处理,以避免响应400错误到客户端,而是改为响应一段JSON数据:

@ExceptionHandler
public JsonResult handleBindException(BindException e) {
    log.debug("开始处理BindException");
    StringJoiner stringJoiner = new StringJoiner(",", "请求参数格式错误,", "!!!");
    List<FieldError> fieldErrors = e.getFieldErrors();
    for (FieldError fieldError : fieldErrors) {
        String defaultMessage = fieldError.getDefaultMessage();
        stringJoiner.add(defaultMessage);
    }
    return JsonResult.fail(ServiceCode.ERR_BAD_REQUEST, stringJoiner.toString());
}

当请求参数可能出现多种错误时,也可以选择“快速失败”的机制,它会使得框架只要发现错误,就停止检查其它规则,这需要在配置类中进行配置,则在项目的根包下创建config.ValidationConfiguration类并配置:

package cn.tedu.csmall.product.config;
import cn.tedu.csmall.product.controller.AlbumController;
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;
/**
 * 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() // 开始配置
                .failFast(true) // 快速失败,即检查请求参数时,一旦发现某个参数不符合规则,则视为失败,并停止检查(剩余未检查的部分将不会被检查)
                .buildValidatorFactory()
                .getValidator();
    }
}

使用这种做法,当客户端提交的请求参数格式错误时,最多只会发现1种错误,则处理异常的代码可以调整为:

@ExceptionHandler
public JsonResult handleBindException(BindException e) {
    log.debug("开始处理BindException");
    String defaultMessage = e.getFieldError().getDefaultMessage();
    return JsonResult.fail(ServiceCode.ERR_BAD_REQUEST, defaultMessage);
}

检查未封装的请求参数

当处理请求的方法的参数是未封装的(例如Long id等),检查时,需要:

  • 在当前控制器类上添加@Validated注解
  • 在需要检查的请求参数上添加检查注解
@Slf4j
@Validated // 新添加的注解
@RestController
@RequestMapping("/albums")
@Api(tags = "04. 相册管理模块")
public class AlbumController {
    // http://localhost:8080/albums/9527/delete
    @ApiOperation("根据id删除相册")
    @ApiOperationSupport(order = 200)
    @ApiImplicitParam(name = "id", value = "相册id", required = true, dataType = "long")
    @PostMapping("/{id:[0-9]+}/delete")
    //        新添加的注解 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
    public String delete(@Range(min = 1, message = "删除相册失败,尝试删除的相册的ID无效!")
                             @PathVariable Long id) {
        String message = "尝试删除id值为【" + id + "】的相册";
        log.debug(message);
        return message;
    }
}

当请求参数不符合以上@Range(min =1)的规则时(例如请求参数值为0或负数),默认情况下会出现500错误,在服务器端控制台可以看到以下异常信息:

javax.validation.ConstraintViolationException: delete.id: 删除相册失败,尝试删除的相册的ID无效!

则需要在全局异常处理器中,添加新的处理异常的方法,用于处理以上异常:

@ExceptionHandler
public JsonResult handleConstraintViolationException(ConstraintViolationException e) {
    log.debug("开始处理ConstraintViolationException");
    StringJoiner stringJoiner = new StringJoiner(",");
    Set<ConstraintViolation<?>> constraintViolations = e.getConstraintViolations();
    for (ConstraintViolation<?> constraintViolation : constraintViolations) {
        stringJoiner.add(constraintViolation.getMessage());
    }
    return JsonResult.fail(ServiceCode.ERR_BAD_REQUEST, stringJoiner.toString());
}

关于检查注解

在javax.validation.constraints和org.hibernate.validator.constraints都有大量的检查注解,常用的检查注解有:

  • @NotNull:不允许为null

  • @Range:此注解有min和max属性,分别通过@Min和@Max实现,且min的默认值为0,max的默认值为long类型的最大值,此注解只能添加在整型的数值类型上,用于设置取值区间

  • @NotEmpty:不允许为空字符串,即长度为0的字符串,此注解只能添加在字符串类型的参数上

  • @NotBlank:不允许为空白,即不允许是仅由空格、TAB制表位、换行符等空白字符组成的值,此注解只能添加在字符串类型的参数上

  • @Pattern:此注解有regexp属性,可通过此属性配置正则表达式,在检查时将根据正则表达式所配置的规则进行检查,此注解只能添加在字符串类型的参数上

  • //  判断字符串空用这个
        @NotBlank(message="姓名必须输入!")
        private String name;
        @NotBlank
        @Length(min=18,max=19,message="身份证长度必须在18-19之间")
        private String card;
        @NotNull
        @Past(message="日期必须必须是当天之前")
        //@Future
    //  前台传递日期字符,自动转换成日期对象
        @DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss")
    //  日期对象输出到前台,自动格式化展示
        @JsonFormat(pattern="yyyy-MM-dd HH:mm:ss",timezone = "GMT+8")
        private Date date;
    //  判断基本类型空用这个
        @NotNull(message="年龄必须输入!")
        @Max(message="最大年龄28岁!",value=28)
        @Min(message="最小年龄18岁!",value=18)
        private Integer age;
        @NotBlank
    //  string,numeric大小判断
        @Range(min=1,max=100,message="weight只能在1-100之间")
    //  数组,集合大小判断
    //  @Size(max=100, min=1,message="size只能在1-100之间")
        @Digits(integer=3,fraction=2,message="weight只能为数字,整数3位以下,小数保留2位")
        private String weight;
        @NotNull
        @AssertTrue(message="性别只能填男!")
        //@AssertFalse
        private Boolean  sex;
    //  判断集合空用这个
        @NotEmpty(message="集合不能为空!")
        List<String> list;
        @Null(message="该字段不能设值!")
        //@NotNull
        private Object tmp;
        @NotBlank
        @Pattern(regexp="^[150[0-9]+]{11}",message="电话格式有问题!")
        private String phone;
        @NotBlank
        @Email(message="email格式不正确!")
        private String email;
        @DecimalMin(value="18",message="dicimal不能小于18!")
        @DecimalMax(value="20",message="dicimal不能大于20!")
        private BigDecimal dicimal;
    ***************************************************************************************************************
    //被注释的元素,值必须是一个字符串,不能为null,且调用trim()后,长度必须大于0
    @NotBlank(message = "")
    
    //被注释的元素,值不能为null,但可以为"空",用于基本数据类型的非空校验上,而且被其标注的字段可以使用 @size、@Max、@Min 等对字段数值进行大小的控制
    @NotNull(message = "")
    
    //被注释的的元素,值不能为null,且长度必须大于0,一般用在集合类上面
    @NotEmpty(message = "")
    
    //被注释的元素必须符合指定的正则表达式。
    @Pattern(regexp = "", message = "")
    
    //被注释的元素的大小必须在指定的范围内。
    @Size(min =, max =)
    
    //被注释的元素,值必须是一个数字,且值必须大于等于指定的最小值
    @Min(value = long以内的值, message = "")
    
    //被注释的元素,值必须是一个数字,且值必须小于等于指定的最大值
    @Max(value = long以内的值, message = "")
        
    //被注释的元素,值必须是一个数字,其值必须大于等于指定的最小值
    @DecimalMin(value = 可以是小数, message = "")
    
    //被注释的元素,值必须是一个数字,其值必须小于等于指定的最大值
    @DecimalMax(value = 可以是小数, message = "")
    
    //被注释的元素,值必须为null
    @Null(message = "")
    
    //被注释的元素必须是一个数字,其值必须在可接受的范围内
    @Digits(integer =, fraction =)
    
    //被注释的元素,值必须为true
    @AssertTrue(message = "")
    
    //被注释的元素,值必须为false
    @AssertFalse(message = "")
    
    //被注释的元素必须是一个过去的日期
    @Past(message = "")
    
    //被注释的元素必须是一个将来的日期
    @Future(message = "")
    
    //被注释的元素必须是电子邮件地址
    @Email(regexp = "", message = "")
    

注意:除了@NotNull注解以外,其它注解均不检查请求参数为null的情况,例如在某个请求参数上配置了@NotEmpty,当提交的请求参数为null时将通过检查(视为正确),所以,当某个请求参数需要配置为不允许为null时,必须使用@NotNull,且以上不冲突的多个注解可以同时添加在同一个请求参数上!

12. Spring JDBC的事务管理

事务(Transaction):是关系型数据库中一种能够保障多个写操作(增、删、改)要么全部成功,要么全部失败的机制。

在基于Spring JDBC的项目中,只需要在业务方法上添加@Transactional注解,即可使得此方法是事务性的。

@Override
@Transactional // 新添加的注解
public void addNew(CategoryAddNewDTO categoryAddNewDTO) {
    // 暂不关心方法内部代码
}

在编程式事务管理过程中,需要先开启事务(BEGIN),然后执行数据操作,当全部完成,需要提交事务(COMMIT),如果失败,则回滚事务(ROLLBACK)。

在基于Spring JDBC的项目中,只需要使用声明式事务即可,也就是只需要在方法上添加@Transactional注解即可。

Spring JDBC实现事务管理大致是:

开启事务:Begin
try {
	执行事务方法,即数据访问操作
	提交事务:Commit
} catch (RuntimeException e) {
	回滚事务:Rollback
}

所以,Spring JDBC在事务管理中,默认将基于RuntimeException进行回滚,可以通过@Transactional的rollbackFor或rollbackForClassName属性来修改,例如:

@Transactional(rollbackFor = {NullPointerException.class, IndexOutOfBoundsException.class})
@Transactional(rollbackForClassName = {"java.lang.NullPointerException", "java.lang.IndexOutOfBoundsException"})

还可以通过noRollbackFor或noRollbackForClassName属性来配置对于哪些异常不回滚。

其实,@Transactional注解可以添加在:

  • 接口上
    • 将作用于实现了此接口的类中的所有业务方法
  • 接口中的业务方法上
    • 将作用于实现了此接口的类中的重写的当前业务方法
  • 实现类上
    • 将作用于当前类中所有业务方法
  • 实现类中的业务方法上
    • 将仅作用于添加了注解的业务方法

提示:如果在业务类和业务方法上都添加了@Transactional,却配置了相同名称但不同值的注解参数,将以业务方法上的配置为准。

在应用此注解时,由于这是一种声明式事务管理,推荐添加在接口上,或接口中的业务方法上。

理论上,如果将@Transactional添加在接口上,可能有点浪费,毕竟并不是每个业务方法都需要是事务性的。

注意:由于Spring JDBC在处理事务管理时,使用了基于接口的代理模式,所以,业务方法的内部调用时(同一个业务类对象的A方法调用了B方法),被调用方法相当于是“无事务的”,另外,如果某方法不是接口中声明的业务方法,只是实现类自行添加的方法,无论将@Transactional添加在哪里,都是无效的!

13. 定时计划任务

计划任务:设定某种规则(通常是与时间相关的规则),当满足规则时,自动执行任务,并且,此规则可能是周期性的满足,则任务也会周期性的执行。

在Spring Boot项目中,需要在配置类上添加@EnableScheduling注解,以开启计划任务,否则,当前项目中所有计划任务都是不允许执行的!

在任何组件类中,自定义方法,在方法上添加@Scheduled注解,则此方法就是计划任务,通过此注解的参数可以配置计划任务的执行规律。

在根包下创建config.ScheduleConfiguration类,在此类上添加@EnableScheduling注解,以开启计划任务:

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类,在类上添加@Component注解,并在类中自定义计划任务方法:

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
    private IBrandService brandService;

    public CacheSchedule() {
        log.debug("创建计划任务对象:CacheSchedule");
    }

    // 关于@Schedule注解的参数配置
    // fixedRate:执行频率,将按照上一次开始执行的时间来计算下一次的执行时间,以毫秒值为单位
    // fixedDelay:执行间隔时间,即上次执行结束后再过多久执行下一次,以毫秒值为单位
    // cron:使用1个字符串,其中包括6~7个值,各值之间使用1个空格进行分隔
    // >> 在cron的字符串中各值依次表示:秒 分 时 日 月 周(星期) [年]
    // >> 以上各值都可以使用通配符
    // >> 使用星号(*)表示任意值
    // >> 使用问号(?)表示不关心具体值,问号只能用于“日”和“周(星期)”
    // >> 例如:"56 34 12 15 11 ? 2022"表示“2022年11月15日12:34:56,无视当天星期几”
    // >> 以上各值,可以使用“x/x”格式的值,例如:在分钟对应的位置设置“1/5”,则表示当分钟值为1时执行,且每间隔5分钟执行1次
    @Scheduled(fixedRate = 1 * 60 * 1000)
    public void rebuildBrandCache() {
        log.debug("开始执行【重建品牌缓存】计划任务…………");
        brandService.rebuildCache();
        log.debug("本次【重建品牌缓存】计划任务执行完成!");
    }

}

提示:以上计划任务需要在业务逻辑层补充“重建品牌缓存”的功能,在IBrandService中添加:

/**
 * 重建品牌缓存
 */
void rebuildCache();

并在BrandServiceImpl中实现:

@Override
public void rebuildCache() {
    log.debug("开始处理【重建品牌缓存】的业务,无参数");
    log.debug("准备删除Redis缓存中的品牌数据……");
    brandRedisRepository.deleteAll();
    log.debug("删除Redis缓存中的品牌数据,完成!");

    log.debug("准备从数据库中读取品牌列表……");
    List<BrandListItemVO> list = brandMapper.list();
    log.debug("从数据库中读取品牌列表,完成!");

    log.debug("准备将品牌列表写入到Redis缓存……");
    brandRedisRepository.save(list);
    log.debug("将品牌列表写入到Redis缓存,完成!");

    log.debug("准备将各品牌详情写入到Redis缓存……");
    for (BrandListItemVO brandListItemVO : list) {
        Long id = brandListItemVO.getId();
        BrandStandardVO brandStandardVO = brandMapper.getStandardById(id);
        brandRedisRepository.save(brandStandardVO);
    }
    log.debug("将各品牌详情写入到Redis缓存,完成!");
}

二、Spring Security

1.依赖*

在csmall-passport项目中添加依赖项:

<!-- Spring Boot Security的依赖项,用于处理认证与授权 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

当添加了以上依赖项后,当前项目会:

  • 此依赖项中包含BCryptPasswordEncoder类,可以用于处理密码加密
  • 所有请求都是必须通过认证的,在没有通过认证之前,任何请求都会被重定向到Spring Security内置的登录页面
    • 可以使用user作为用户名,使用启动项目时随机生成的UUID密码来登录
    • 当登录成功后,会自动重定向到此前尝试访问的页面
    • 当登录成功后,所有GET的异步请求允许访问,但POST的异步请求不允许访问(403错误)

当添加依赖后,在浏览器中尝试访问时还可能出现以下错误:

org.springframework.security.web.firewall.RequestRejectedException: The request was rejected because the header value "Idea-c968a669=03021799-4633-4321-9d0d-11b7ee08f588; username=黄汉升; JSESSIONID=120F9329E0CE7AF9E052A302EFE494F2" is not allowed.

此错误是浏览器的问题导致的,更换浏览器即可。

2. BCrypt加密算法

BCrypt算法是用于对密码进行加密处理的,在spring-boot-starter-security中包含了BCryptPasswordEncoder,可以实现编码、验证:

import org.junit.Test;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

public class BCryptTests {

    @Test
    public void encode() {
        // 创建BCryptPasswordEncoder对象,用于加密密码
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

        // 原始密码
        String rawPassword = "123456";
        System.out.println("原文:" + rawPassword);

        // 循环生成并打印50次加密后的密码
        for (int i = 0; i < 50; i++) {
            String encodedPassword = passwordEncoder.encode(rawPassword);
            System.out.println("密文:" + encodedPassword);
        }
    }

    @Test
    public void matches() {
        // 创建BCryptPasswordEncoder对象,用于验证密码
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

        // 原始密码
        String rawPassword = "123456";

        // 已加密的密码(通常从数据库中检索)
        String encodedPassword = "$2a$10$H7neseWrkpdCQiW6R4bJyeXaU.nowsFZZz.iO4HCLzFScz.FdpDSG";

        // 验证原始密码与已加密密码是否匹配
        boolean matches = passwordEncoder.matches(rawPassword, encodedPassword);
        
        // 打印原始密码、已加密密码和验证结果
        System.out.println("原文:" + rawPassword);
        System.out.println("密文:" + encodedPassword);
        System.out.println("验证:" + matches);
    }
}

BCrypt算法默认使用了随机盐值,所以,即使使用相同的原文,每次编码产生的密文都是不同的!

BCrypt算法被刻意设计为慢速的,所以,可以非常有限的避免穷举式的暴力破解!

3. 配置类*

在Spring Boot项目中,在根包下创建config.SecurityConfiguration类,作为Spring Security的配置类,需要继承自WebSecurityConfigurerAdapter类,并重写其中的方法进行配置:

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;
/**
 * Spring Security配置类
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Slf4j
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 如果不调用父类方法,默认所有请求都不需要通过认证,可以直接访问
        // super.configure(http);

        // 白名单
        String[] urls = {
            // 一个* 号代表一个层级,**号代表多个层级  所有路径必须使用/作为第一字符
            //以下为API文档的路径
                "/favicon.ico",
                "/doc.html",
                "/**/*.js",
                "/**/*.css",
                "/swagger-resources",
                "/v2/api-docs"
        };

        // 将防止伪造跨域攻击的机制禁用
        http.csrf().disable();

        // 提示:关于请求路径的配置,如果同一路径对应多项配置规则,以第1次配置的为准
        http.authorizeRequests() // 管理请求授权
                .mvcMatchers(urls) // 匹配某些路径
                .permitAll() // 直接许可,即可不需要通过认证即可访问
                .anyRequest() // 除了以上配置过的以外的其它所有请求
                .authenticated(); // 要求是“已经通过认证的”

        // 启用登录表单
        // 当未认证时:
        // -- 如果启用了表单,会自动重定向到登录表单
        // -- 如果未启用表单,则会提示403错误
        http.formLogin();
    }

}

4.关于伪造的跨域攻击

伪造的跨域攻击(CSRF)主要是基于服务器端对浏览器的信任,在多选项卡的浏览器中,如果在X选项卡中登录,在Y选项卡中的访问也会被视为“已登录”。

在Spring Security框架中,默认开启了“防止伪造的跨域攻击”的机制,其基本做法就是在POST请求中,要求客户端提交其随机生成的一个UUID值,例如,(在没有禁用防止伪造跨域攻击时)在Spring Security的登录页面中有:

<input name="_csrf" type="hidden" value="b6dc65f8-e0cf-4907-bdaf-a5f19b759f93" />

以上代码中的value值就是一个UUID值,是前次GET请求时由服务器端响应的,服务器端会要求客户端携带此UUID来访问,否则,就会将请求视为伪造的跨域攻击行为!

5.如何登录账号*

默认情况下,Spring Security框架提供了默认的用户名user和启动时随机生成UUID密码,如果需要自定义登录账号,可以自定义类,实现UserDetailsService接口,重写接口中的如下方法:

UserDetails loadUserByUsername(String username);

Spring Security框架在处理认证时,会自动根据提交的用户名(用户在登录表单中输入的用户名)来调用以上方法,以上方法应该返回匹配的用户详情(UserDetails类型的对象),接下来,Spring Security会自动根据用户详情(UserDetails对象)来完成认证过程,例如判断密码是否正确等。

可以在根包下创建security.UserDetailsServiceImpl类,在类上添加@Service注解,实现UserDetailsService接口,重写接口中定义的抽象方法:

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框架自动调用UserDetailsServiceImpl中的loadUserByUsername方法,参数:{}", s);
        // 假设正确的用户名 / 密码分别是 root / 1234
        if ("root".equals(s)) {
            UserDetails userDetails = User.builder()
                    .username("root")
                    .password("1234")
                    .disabled(false)
                    .accountLocked(false) // 此项目未设计“账号锁定”的机制,固定为false
                    .accountExpired(false) // 此项目未设计“账号过期”的机制,固定为false
                    .credentialsExpired(false) // 此项目未设计“凭证锁定”的机制,固定为false
                    .authorities("暂时给出的假的权限标识") // 权限
                    .build();
            return userDetails;
        }
        return null;
    }

}

完成后,重启项目,首先,可以在启动日志中看到,Spring Security框架不再生成随机的UUID密码。

在Spring Security处理认证时,还会自动装配Spring容器中的密码编码器(PasswordEncoder),如果Spring容器中并没有密码编码器,则无法验证密码是否正确,当使用了正确的用户名尝试登录时,服务器端将报告错误:

java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"

可以在SecurityConfiguration中添加@Bean方法,来配置所需的密码编码器:

@Bean
public PasswordEncoder passwordEncoder() {
    return NoOpPasswordEncoder.getInstance();
}

提示:以上使用的密码编码器是无操作的密码编码器(No Operation),不会对密码进行加密处理,是不推荐的,所以,此类被声明为已过期,在IntelliJ IDEA中,此类的名称会有删除线。

当添加了密码编码器后,再次启用项目,尝试登录:

  • 当用户名错误时,会提示UserDetailsService返回了是null
  • 当用户名正确,但密码错误时,会提示登录失败
  • 当用户名、密码均正确时,将成功登录

也可以将以上NoOpPasswordEncoder换成BCryptPasswordEncoder,例如:

@Bean
public PasswordEncoder passwordEncoder() {
    // return NoOpPasswordEncoder.getInstance();
    return new BCryptPasswordEncoder();
}

如果修改,则UserDetails对象中封装的密码也必须是与此密码编码器符合的,即必须是BCrypt算法加密的结果,例如:

@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
    log.debug("Spring Security框架自动调用UserDetailsServiceImpl中的loadUserByUsername方法,参数:{}", s);
    // 假设正确的用户名 / 密码分别是 root / 1234
    if ("root".equals(s)) {
        UserDetails userDetails = User.builder()
                .username("root")
                .password("$2a$10$DoQQSh9eAxDRVKADzQ.Q8Oa4QqcpMUR9UmKyptop3i0mwsdfS.wyC")
                
            	// 后续代码没有调整……

6. 使用数据库中的管理员账号登录*

只需要保证在UserDetailsServiceImpl类中,返回的是数据库中对应的管理员信息即可!

所以,需要在Mapper层实现“根据用户名查询用户的登录信息”的功能,需要执行的SQL语句大致是:

SELECT id, username, password, enable FROM ams_admin WHERE username=?

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


在AdminMapper.java接口中添加抽象方法:

AdminLoginInfoVO getLoginInfoByUsername(String username);

在AdminMapper.xml中配置:


在AdminMapperTests中测试:


完成后,调整UserDetailsServiceImpl中的实现:

  • 如果数据库中没有匹配的管理员信息,可以返回null(或抛出异常等)
  • 如果数据库中存在匹配的管理员信息,则用于封装UserDetails对象,并返回此对象

具体代码:

package cn.tedu.csmall.passport.security;

import cn.tedu.csmall.passport.mapper.AdminMapper;
import cn.tedu.csmall.passport.pojo.vo.AdminLoginInfoVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.BadCredentialsException;
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 {

    @Autowired
    private AdminMapper adminMapper;

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        log.debug("Spring Security框架自动调用UserDetailsServiceImpl中的loadUserByUsername方法,参数:{}", s);
        AdminLoginInfoVO admin = adminMapper.getLoginInfoByUsername(s);
        log.debug("从数据库中根据用户名【{}】查询管理员信息,结果:{}", s, admin);
        if (admin == null) {
            log.debug("没有与用户名【{}】匹配的管理员信息,即将抛出BadCredentialsException", s);
            String message = "登录失败,用户名不存在!";
            throw new BadCredentialsException(message);
        }

        UserDetails userDetails = User.builder()
                .username(admin.getUsername())
                .password(admin.getPassword())
                .disabled(admin.getEnable() == 0)
                .accountLocked(false) // 此项目未设计“账号锁定”的机制,固定为false
                .accountExpired(false) // 此项目未设计“账号过期”的机制,固定为false
                .credentialsExpired(false) // 此项目未设计“凭证锁定”的机制,固定为false
                .authorities("暂时给出的假的权限标识") // 权限
                .build();
        log.debug("即将向Spring Security框架返回UserDetails对象:{}", userDetails);
        return userDetails;
    }

}

7. 使用前后端分离的登录模式*

目前的登录是由Spring Security提供了登录表单,然后由自定义的UserDetailsServiceImpl获取对应的用户信息,并由Spring Security完后后续的认证过程,以此来实现的,这不是前后端分离的开发模式,因为依赖于Spring Security提供的登录表单,例如csmall-web-client或其它客户端根本没有办法像服务器端发送登录请求!

要实现前后端分离的登录模式,需要:

  • 使用控制器接收来自客户端的登录请求
    • 创建AdminLoginDTO封装客户端提交的用户名、密码
    • 所设计的登录请求的URL必须添加到“白名单”
  • 使用Service处理登录认证
    • 调用AuthenticationManager的authenticate()方法处理认证
      • 可以通过重写配置类中的authenticationManagerBean()方法,并添加@Bean注解来得到

【AdminLoginDTO】

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

@Data
public class AdminLoginDTO implements Serializable {

    private String username;
    private String password;

}

【SecurityConfiguration】

@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
    return super.authenticationManagerBean();
}

【IAdminService】

void login(AdminLoginDTO adminLoginDTO);

【AdminServiceImpl】

@Autowired
private AuthenticationManager authenticationManager;

@Override
public void login(AdminLoginDTO adminLoginDTO) {
    log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);
    Authentication authentication
            = new UsernamePasswordAuthenticationToken(
                    adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
    authenticationManager.authenticate(authentication);
}

【AdminController】

// http://localhost:9081/admins/login
@ApiOperation("管理员登录")
@ApiOperationSupport(order = 50)
@PostMapping("/login")
public JsonResult<Void> login(AdminLoginDTO adminLoginDTO) {
    log.debug("开始处理【管理员登录】的请求,参数:{}", adminLoginDTO);
    adminService.login(adminLoginDTO);
    return JsonResult.ok();
}

注意:强烈建议禁用Spring Security的登录表单!

完成后,重启项目,可以通过API文档向 http://localhost:9081/admins/login 提交请求,如果用户名或密码错误,都会导致403错误,如果用户名和密码均正确,则会响应state为20000的JSON结果。

最后,在全局异常处理器中,补充对相关异常的处理:

@ExceptionHandler({
        InternalAuthenticationServiceException.class, // AuthenticationServiceException >>> AuthenticationException
        BadCredentialsException.class // AuthenticationException
})
public JsonResult<Void> 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<Void> handleDisabledException(DisabledException e) {
    log.debug("捕获到DisabledException");
    String message = "登录失败,此账号已经被禁用!";
    return JsonResult.fail(ServiceCode.ERR_UNAUTHORIZED_DISABLED, message);
}

@ExceptionHandler
public String handleThrowable(Throwable e) {
    String message = "你有异常没有处理,请根据服务器端控制台的信息,补充对此类异常的处理!!!";
    e.printStackTrace();
    return message;
}

8. 关于“登录”的判断标准

在Spring Security框架中,对于“登录”(通过认证)的判断标准是:在SecurityContext(Security上下文)中是否存在Authentication对象(认证信息),如果存在,Spring Security框架会根据Authentication对象识别用户的身份、权限等,如果不存在,则视为“未登录”。

在默认情况下,Spring Security框架也是基于Session来处理(读写)用户的信息的。

9. 关于Session

HTTP协议本身是无状态协议,无法保存用户信息,即:某客户端第1次访问了服务器端,可能产生了某些数据,此客户端再次访问服务器端时,服务器端无法识别出这个客户端是此前曾经来访的客户端。

为了能够识别客户端的身份,当某客户端第1次向服务器端发起请求时,服务器端将向客户端响应一个JSESSIONID数据,其本质是一个UUID数据,在客户端后续的访问中,客户端会自动携带此JSESSIONID,以至于服务器端能够识别此客户端的身份。同时,在服务器端,还是一个Map结构的数据,此数据是使用JSESSIONID作为Key的,所以,每个客户端在服务器端都有一个与之对应在的在此Map中的Value,也就是Session数据!

提示:UUID是全球唯一的,从设计上,它能够保证在同一时空中的唯一性。

由于Session的运作机制,决定了它必然存在缺点:

  • 默认不适用于集群或分布式系统,因为Session是内存中的数据,所以,默认情况下,Session只存在于与客户端交互的那台服务器上,如果使用了集群,客户端每次请求的服务器都不是同一台服务器,则无法有效的识别客户端的身份
    • 可以通过共享Session等机制解决
  • 不适合长时间保存数据,因为Session是内存中的数据,并且,所有来访的客户端在服务器端都有对应的Session数据,就必须存在Session清除机制,如果长期不清除,随着来访的客户端越来越多,将占用越来越多的内存,服务器将无法存储这大量的数据,通常,会将Session设置为15分钟或最多30分钟清除

10. Token

Token:票据、令牌

由于客户端种类越来越多,目前,主流的识别用户身份的做法都是使用Token机制,Token可以理解为“票据”,例如现实生活中的“火车票”,某客户端第1次请求服务器,或执行登录请求,则可视为“购买火车票”的行为,当客户端成功登录,相当于成功购买了火车票,客户端的后续访问应该携带Token,相当于乘坐火车需要携带购票凭证,则服务器端可以识别客户端的身份,相当于火车站及工作人员可以识别携带了购买凭证的乘车人。

与Session最大的区别在于:Token是包含可识别的有效信息的!对于需要获取信息的一方而言,只需要具备读取Token信息的能力即可。

Session机制中客户端需要携带的JSESSIONID本身上是UUID,此数据只具有唯一性,并不是有意义的数据,真正有意义的数据是服务器端内存中的Session数据。

所以,Token并不需要占用较多的内存空间,是可以长时间,甚至非常长时间保存用户信息的!

11. JWT

JWT:JSON Web Token

JWT是一种使用JSON格式来组织数据的Token。

12. 生成与解析JWT*

需要添加依赖项:

<!-- JJWT(Java JWT) -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

关于生成JWT与解析JWT的示例代码:

package cn.tedu.csmall.passport;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.junit.jupiter.api.Test;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;

public class JwtTests {

    String secretKey = "a9F8ujGFDhjgvfd3SA90ukEDS";

    @Test
    public void generate() {
        Date date = new Date(System.currentTimeMillis() + 5 * 60 * 1000);

        Map<String, Object> claims = new HashMap<>();
        claims.put("id", 9527);
        claims.put("username", "liucangsong");

        String jwt = Jwts.builder()
                // Header
                .setHeaderParam("alg", "HS256")
                .setHeaderParam("typ", "JWT")
                // Payload
                .setClaims(claims)
                // Signature
                .setExpiration(date)
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
        System.out.println(jwt);

        // eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTUyNywiZXhwIjoxNjY3ODc3ODg5LCJ1c2VybmFtZSI6ImxpdWNhbmdzb25nIn0.Txpj_kcLpkpUoEYA94pLCM3H807UnOEqN_r0c005I44
        // eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTUyNywiZXhwIjoxNjY3ODc5MDM2LCJ1c2VybmFtZSI6ImxpdWNhbmdzb25nIn0.gMlHQiSbbWnf5cIBi0p4V9bz05QHRaq3rNC8e_4yfpE
        // eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTUyNywiZXhwIjoxNjY3ODc5ODAxLCJ1c2VybmFtZSI6ImxpdWNhbmdzb25nIn0.fjPvR0ibgNKoTp6U-1fCOcMoAVMRkAQ1yr4C2fvf6YQ
    }

    @Test
    public void parse() {
        String jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTUyNywiZXhwIjoxNjY3ODc5ODAxLCJ1c2VybmFtZSI6ImxpdWNhbmdzb25nIn0.fjPvR0ibgNKoTp6U-1fCOcMoAVMRkAQ1yr4C2fvf6YQ";

        Claims claims = Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(jwt)
                .getBody();

        Long id = claims.get("id", Long.class);
        String username = claims.get("username", String.class);

        System.out.println("id = " + id);
        System.out.println("username = " + username);
    }

}

当尝试解析JWT时,可能会出现以下错误:

  • 如果JWT已过期,会抛出ExpiredJwtException,例如:
io.jsonwebtoken.ExpiredJwtException: JWT expired at 2022-11-08T11:24:49Z. Current time: 2022-11-08T11:38:01Z, a difference of 792152 milliseconds.  Allowed clock skew: 0 milliseconds.
  • 如果JWT数据有误,会抛出MalformedJwtException,例如:
io.jsonwebtoken.MalformedJwtException: Unable to read JSON value: {"alg":"HS7#�$�uB'
  • 如果JWT签名不匹配,会抛出SignatureException,例如:
io.jsonwebtoken.SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.

13. 登录成功时返回JWT*

在处理登录时,当用户登录成功,应该向客户端返回JWT数据,以至于客户端下次提交请求时,可以携带JWT来访问服务器端!

首先,需要在通过认证(登录成功)后,生成JWT数据,并返回!在Spring Security框架中,AuthenticationManager调用authenticate()方法时,如果通过认证,会返回Authentication接口类型的对象,本质上是UsernamePasswordAuthenticationToken类型,此类型中的pricipal属性就是通过认证的用户信息,也是UserDetailsService中的loadUserByUsername()方法返回的结果,例如:

UsernamePasswordAuthenticationToken [
	Principal=org.springframework.security.core.userdetails.User [
		Username=root, 
		Password=[PROTECTED], 
		Enabled=true, 
		AccountNonExpired=true, 
		credentialsNonExpired=true, 
		AccountNonLocked=true, 
		Granted Authorities=[暂时给出的假的权限标识]
	], 
	Credentials=[PROTECTED], 
	Authenticated=true, 
	Details=null, 
	Granted Authorities=[暂时给出的假的权限标识]
]

所以,可以在处理认证的代码后再添加读取认证结果、生成JWT的代码:

@Override
public void login(AdminLoginDTO adminLoginDTO) {
    log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);
    // 执行认证
    Authentication authentication
            = new UsernamePasswordAuthenticationToken(
                    adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
    Authentication authenticateResult
            = authenticationManager.authenticate(authentication);
    log.debug("认证通过,认证管理器返回:{}", authenticateResult);

    // 从认证结果中获取所需的数据,将用于生成JWT
    Object principal = authenticateResult.getPrincipal();
    log.debug("认证结果中的当事人类型:{}", principal.getClass().getName());
    User user = (User) principal;
    String username = user.getUsername();

    // 生成JWT数据时,需要填充装到JWT中的数据
    Map<String, Object> claims = new HashMap<>();
    // claims.put("id", 9527);
    claims.put("username", username);
    // 以下是生成JWT的固定代码
    String secretKey = "a9F8ujGDhjgFvfEd3SA90ukDS";
    Date date = new Date(System.currentTimeMillis() + 5 * 24 * 60 * 60 * 1000L);
    String jwt = Jwts.builder()
            // Header
            .setHeaderParam("alg", "HS256")
            .setHeaderParam("typ", "JWT")
            // Payload
            .setClaims(claims)
            // Signature
            .setExpiration(date)
            .signWith(SignatureAlgorithm.HS256, secretKey)
            .compact();
    log.debug("生成的JWT:{}", jwt);
}

接下来,需要将IAdminService接口中定义的“登录”方法的返回值类型修改为String:

/**
 * 管理员登录
 *
 * @param adminLoginDTO 封装了管理员的用户名和密码的对象
 * @return 登录成功后生成的匹配的JWT
 */
String login(AdminLoginDTO adminLoginDTO);

并且修改其实现,并返回JWT。

然后,调整控制器中处理登录请求的方法:

// http://localhost:9081/admins/login
@ApiOperation("管理员登录")
@ApiOperationSupport(order = 50)
@PostMapping("/login")
public JsonResult<String> login(AdminLoginDTO adminLoginDTO) {
    log.debug("开始处理【管理员登录】的请求,参数:{}", adminLoginDTO);
    String jwt = adminService.login(adminLoginDTO);
    return JsonResult.ok(jwt);
}

完成后,重启项目,在API文档中调试,使用正确的用户名、密码登录,响应结果中将包含对应的JWT数据,并且,此JWT数据可以在此前编写的测试方法中尝试解析(注意:务必保证生成JWT和解析JWT使用的secretKey是相同的)。

14. 识别客户端的身份*

基于Spring Security框架的特征“依据SecurityContext中的认证信息来判定当前是否已经通过认证”,所以,客户端应该在得到JWT之后,携带JWT向服务器端提交请求,而服务器端应该尝试解析此JWT,并且从中获取用户信息,用于创建认证对象,最后,将认证对象存入到SecurityContext中,剩下的就可以交由框架进行处理了,例如判断是否已经通过认证等。

由于若干个不同的请求都需要识别客户端的身份(即解析JWT、创建认证对象、将认证对象存入到SecurityContext),所以,应该通过能够统一处理的组件来处理JWT,同时,此项任务必须在Spring Security的过滤器之前执行,则此项任务只能通过自定义过滤器来处理!

则在项目的根包下创建filter.JwtAuthorizationFilter类:

package cn.tedu.csmall.passport.filter;

import io.jsonwebtoken.Claims;
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.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.Collection;
import java.util.List;

/**
 * <p>JWT过滤器</p>
 *
 * <p>此JWT的主要作用:</p>
 * <ul>
 *     <li>获取客户端携带的JWT,惯用做法是:客户端应该通过请求头中的Authorization属性来携带JWT</li>
 *     <li>解析客户端携带的JWT,并创建出Authentication对象,存入到SecurityContext中</li>
 * </ul>
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {

    public static final int JWT_MIN_LENGTH = 113;

    public JwtAuthorizationFilter() {
        log.info("创建过滤器对象:JwtAuthorizationFilter");
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        log.debug("JwtAuthorizationFilter开始执行过滤……");
        // 获取客户端携带的JWT
        String jwt = request.getHeader("Authorization");
        log.debug("获取客户端携带的JWT:{}", jwt);

        // 检查是否获取到了基本有效的JWT
        if (!StringUtils.hasText(jwt) || jwt.length() < JWT_MIN_LENGTH) {
            // 对于无效的JWT,直接放行,交由后续的组件进行处理
            log.debug("获取到的JWT被视为无效,当前过滤器将放行……");
            filterChain.doFilter(request, response);
            return;
        }

        // 尝试解析JWT
        log.debug("获取到的JWT被视为有效,准备解析JWT……");
        String secretKey = "a9F8ujGDhjgFvfEd3SA90ukDS";
        Claims claims = Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(jwt)
                .getBody();

        // 获取JWT中的管理员信息
        String username = claims.get("username", String.class);

        // 处理权限信息
        List<GrantedAuthority> authorities = new ArrayList<>();
        GrantedAuthority authority = new SimpleGrantedAuthority("这是一个假权限");
        authorities.add(authority);

        // 创建Authentication对象
        Authentication authentication
                = new UsernamePasswordAuthenticationToken(
                        username, null, authorities);

        // 将Authentication对象存入到SecurityContext
        log.debug("向SecurityContext中存入认证信息:{}", authentication);
        SecurityContextHolder.getContext().setAuthentication(authentication);

        // 过滤器链继续向后传递,即:放行
        log.debug("JWT过滤器执行完毕,放行!");
        filterChain.doFilter(request, response);
    }

}

并且,为了保证此过滤器在Spring Security的过滤器之前执行,还应该在SecurityConfiguration中,先自动装配此过滤器对象:

@Autowired
private JwtAuthorizationFilter jwtAuthorizationFilter;

然后,在void configurer(HttpSecurity http)方法中补充配置:

// 将JWT过滤器添加到Spring Security框架的过滤器链中
http.addFilterBefore(jwtAuthorizationFilter, 
                     	UsernamePasswordAuthenticationFilter.class);

至此,简单的登录处理已经完成,客户端或API文档可以通过 /admins/login 登录,以获取JWT数据,并且,在后续的访问中,如果携带了JWT数据,将可以正常访问,否则,将无权访问!

目前,还存在需要解决的问题:

  • 生成和解析JWT的secretKey不应该分别定义在2个类中
  • 解析JWT可能出现异常,但尚未处理
  • 认证信息中的“当事人”是使用username表示的,不包含此管理员的id,不便于实现后续的需求
  • 认证信息中权限目前是假数据
  • 前端还没有结合起来

15. 前端登录*

在前端的登录页面中,当服务器端响应登录成功后,应该将服务器端响应的JWT数据保存下来,可以使用localStorage来保存数据,例如:

  let url = 'http://localhost:9081/admins/login';
  console.log('url = ' + url);
  let formData = this.qs.stringify(this.ruleForm);
  console.log('formData = ' + formData);
  this.axios.post(url, formData).then((response) => {
    let responseBody = response.data;
    if (responseBody.state == 20000) {
      this.$message({
        message: '登录成功!',
        type: 'success'
      });
      let jwt = responseBody.data;
      console.log('登录成功,服务器端响应JWT:' + jwt);
      localStorage.setItem('jwt', jwt);  // 使用localStorage保存数据
      console.log('已经将JWT保存到localStorage');
    } else {
      console.log(responseBody.message);
      this.$message.error(responseBody.message);
    }
  });

后续,当需要此数据时,可以通过localStorage.getItem(key)来获取此前存入的数据。

在需要携带JWT的请求中,可以调用axios对象的create()方法来配置请求头,并使用此方法返回的axios对象向服务器端提交请求,例如:

  loadAdminList() {
  console.log('loadAdminList');
  console.log('在localStorage中的JWT数据:' + localStorage.getItem('jwt'));
  let url = 'http://localhost:9081/admins';
  console.log('url = ' + url);
  this.axios
      // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓  以下是携带JWT提交请求的关键代码  ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
      .create({'headers': {'Authorization': localStorage.getItem('jwt')}})
      .get(url).then((response) => {
    let responseBody = response.data;
    this.tableData = responseBody.data;
  });
}

需要注意:当客户端的异步请求定义了请求头中的Authorization时,在服务器端,在SecurityConfiguration类的void configurer(HttpSecurity http)方法中,需要添加以下配置:

http.cors();

否则客户端将出现跨域错误!

16. 关于复杂请求的PreFlight*

PreFight:预检

当客户端提交的请求自定义了请求头,且请求头中的属性不是常规属性时(例如Authorization就不是常规属性),这类请求会被视为复杂请求,就会触发预检(PreFlight)机制,浏览器会自动向对应的URL提交一个OPTIONS类型的请求,如果此请求被正常响应(即HTTP响应码为200),才可以正常提交原本的请求,否则,视为预检失败,会提示跨域错误。

需要注意:预检是基于浏览器缓存的,如果某个请求对应的URL曾经预检通过,则后续再次提交请求时不会执行预检!

在服务器端的SecurityConfiguration中,在重写的void configurer(HttpSecurity http)方法中,配置请求认证时,对所有OPTIONS请求直接放行,即可解决预检不通过导致的跨域错误,例如:

http.authorizeRequests()

    // ↓↓↓↓↓ 对所有OPTIONS请求直接放行 ↓↓↓↓↓
    .mvcMatchers(HttpMethod.OPTIONS, "/**")
    .permitAll()

    .mvcMatchers(urls)
    .permitAll()
    .anyRequest()
    .authenticated();

或者,更简单一点,直接调用参数http的cors()方法,则Spring Security会自动启用一个CorsFilter,这是Spring Security专门用于处理跨域问题的过滤器,也会对OPTIONS请求放行,所以,实现的效果是完全相同的!

注意:以上解决方案并不能取代目前使用WebMvcConfiguration解决的跨域问题!

17. 使用配置文件自定义JWT参数*

生成和解析JWT都需要使用到secretKey,并且,这2处使用到的secretKey值必须是完全相同的!所以,应该使用一个公共的位置来配置secretKey的值,由于此值应该允许被客户(软件的使用者)修改,则应该将此值定义的配置文件中(不推荐定义在某个类)。

则可以在application-dev.yml中添加自定义配置:

# 当前项目中的自定义配置
csmall:
  # JWT相关配置
  jwt:
    # 生成和解析JWT时使用的secretKey
    secret-key: a9F8ujGDhjgFvfEd3SA90ukDS
    # JWT的有效时长,以分钟为单位
    duration-in-minute: 14400

提示:当在.yml或.properties中添加配置后(无论是否为自定义配置),当加载时,会将这些配置读取到Spring框架内置的Environment对象中,另外,操作系统的配置和JVM配置也会自动读取到Environment中,且配置文件中的配置的优先级是最低的(会被覆盖),使用@Value读取值,其实是从Environment中读取的,并不是直接从配置文件中读取的!

添加配置后,在生成JWT时使用,即在AdminServiceImpl中声明2个全局属性,通过@Value注解为这2个属性注入配置的值:

@Value("${csmall.jwt.secret-key}")
private String secretKey;
@Value("${csmall.jwt.duration-in-minute}")
private long durationInMinute;

在生成JWT时,就可以直接使用这2个属性了!

另外,在JwtAuthorizationFilter也应该使用同样的做法,应用secretKey的配置!

18. 处理解析JWT时可能出现的异常*

当前项目中使用过滤器解析JWT,而过滤器是JAVA EE项目中最早接收到请求的组件,此时其它组件(例如Controller)均未开始处理此请求,所以,如果过滤器在解析JWT时出现异常,Controller是无法“知晓”的,则全局异常处理器也无法处理这些异常,只能在过滤器中使用try...catch语法处理。

处理异常后的响应应该是JSON格式的,当前项目中一直在使用JsonResult表示响应的结果,但是,由于过滤器解析JWT时,Spring MVC的相关组件尚未运行,无法自动将JsonResult对象转换成JSON格式的字符串,所以,需要先在项目中添加依赖项,用于将对象转换成JSON格式的字符串:

<!-- fastjson:实现对象与JSON的相互转换 -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.75</version>
</dependency>

然后,在ServiceCode中添加新的枚举值:

public enum ServiceCode {

    // 此前已有的枚举值
    
    // ↓↓↓↓↓  新增的枚举值  ↓↓↓↓↓
    /**
     * 错误:JWT签名错误
     */
    ERR_JWT_SIGNATURE(60000),
    /**
     * 错误:JWT数据格式错误
     */
    ERR_JWT_MALFORMED(60100),
    /**
     * 错误:JWT已过期
     */
    ERR_JWT_EXPIRED(60200);
    
    // 其它代码

最后,在JwtAuthorizationFilter中,使用try...catch包裹解析JWT的代码,并处理相关异常:

// 尝试解析JWT
log.debug("获取到的JWT被视为有效,准备解析JWT……");
response.setContentType("application/json; charset=utf-8");
Claims claims = null;
try {
    claims = Jwts.parser()
            .setSigningKey(secretKey)
            .parseClaimsJws(jwt)
            .getBody();
} catch (SignatureException e) {
    log.debug("解析JWT时出现SignatureException");
    String message = "非法访问!";
    JsonResult<Void> jsonResult = JsonResult.fail(
        								ServiceCode.ERR_JWT_SIGNATURE, message);
    String jsonResultString = JSON.toJSONString(jsonResult);
    PrintWriter writer = response.getWriter();
    writer.println(jsonResultString);
    return;
} catch (MalformedJwtException e) {
    log.debug("解析JWT时出现MalformedJwtException");
    String message = "非法访问!";
    JsonResult<Void> jsonResult = JsonResult.fail(
        								ServiceCode.ERR_JWT_MALFORMED, message);
    String jsonResultString = JSON.toJSONString(jsonResult);
    PrintWriter writer = response.getWriter();
    writer.println(jsonResultString);
    return;
} catch (ExpiredJwtException e) {
    log.debug("解析JWT时出现ExpiredJwtException");
    String message = "登录信息已过期,请重新登录!";
    JsonResult<Void> jsonResult = JsonResult.fail(
        								ServiceCode.ERR_JWT_EXPIRED, message);
    String jsonResultString = JSON.toJSONString(jsonResult);
    PrintWriter writer = response.getWriter();
    writer.println(jsonResultString);
    return;
} catch (Throwable e) {
    log.debug("解析JWT时出现Throwable,需要开发人员在JWT过滤器补充对异常的处理");
    e.printStackTrace();
    String message = "你有异常没有处理,请根据服务器端控制台的信息,补充对此类异常的处理!!!";
    PrintWriter writer = response.getWriter();
    writer.println(message);
    return;
}

19. 将登录的管理员的id封装到认证信息中*

在根包下创建security.AdminDetails类,继承自User类,并在类中扩展声明Long id属性:

package cn.tedu.csmall.passport.security;

import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;

import java.util.Collection;

/**
 * 管理员详情类,是Spring Security框架的loadUserByUsername()的返回结果
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Setter
@Getter
@ToString(callSuper = true)
@EqualsAndHashCode
public class AdminDetails extends User {

    private Long id;

    public AdminDetails(Long id,
                        String username,
                        String password,
                        boolean enabled,
                        Collection<? extends GrantedAuthority> authorities) {
        super(username, password, enabled, true,
                true, true, authorities);
        this.id = id;
    }

}

在UserDetailsService中,当需要返回UserDetails对象时,返回以上自定义的对象:

@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private AdminMapper adminMapper;

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        log.debug("Spring Security框架自动调用UserDetailsServiceImpl中的loadUserByUsername方法,参数:{}", s);
        AdminLoginInfoVO admin = adminMapper.getLoginInfoByUsername(s);
        log.debug("从数据库中根据用户名【{}】查询管理员信息,结果:{}", s, admin);
        if (admin == null) {
            log.debug("没有与用户名【{}】匹配的管理员信息,即将抛出BadCredentialsException", s);
            String message = "登录失败,用户名不存在!";
            throw new BadCredentialsException(message);
        }

        
        List<GrantedAuthority> authorities = new ArrayList<>();
        GrantedAuthority authority = new SimpleGrantedAuthority("这是一个假权限");
        authorities.add(authority);

        AdminDetails adminDetails = new AdminDetails(
                admin.getId(),
                admin.getUsername(),
                admin.getPassword(),
                admin.getEnable() == 1,
                authorities);
        log.debug("即将向Spring Security框架返回UserDetails对象:{}", adminDetails);
        return adminDetails;
    }

}

至此,当用户成功登录后,AuthenticationManager的authenticate()返回的认证信息中的当事人(Principal)就是以上返回的AdminDetails,其中是包含id和用户名等信息的!在处理认证后,可以得到这些信息,并用于生成JWT。

在AdminServiceImpl的login()方法:

// 从认证结果中获取所需的数据,将用于生成JWT
Object principal = authenticateResult.getPrincipal();
log.debug("认证结果中的当事人类型:{}", principal.getClass().getName());
AdminDetails adminDetails = (AdminDetails) principal;
String username = adminDetails.getUsername();
Long id = adminDetails.getId(); // 新增

// 生成JWT数据时,需要填充装到JWT中的数据
Map<String, Object> claims = new HashMap<>();
claims.put("id", id);  // 新增
claims.put("username", username);

至此,当用户成功登录后,得到的JWT中是包含了id和username的!

要将id和username同时封装到认证信息中,由于认证信息中的当事人只是1个数据,如果要将id和username这2个数据都封装进去,就需要自定义类,在类定义这2个属性,此类的对象将是最终存入到认证信息中的当事人:

package cn.tedu.csmall.passport.security;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginPrincipal implements Serializable {

    private Long id;
    private String username;

}

在JwtAuthorizationFilter中:

// 获取JWT中的管理员信息
String username = claims.get("username", String.class);
Long id = claims.get("id", Long.class); // 新增

// 处理权限信息
// 省略相关代码

// 创建Authentication对象
LoginPrincipal loginPrincipal = new LoginPrincipal(id, username);  // 新增
Authentication authentication
        = new UsernamePasswordAuthenticationToken(
            loginPrincipal, null, authorities);
//          ↑↑↑↑↑ 调整 ↑↑↑↑↑

// 将Authentication对象存入到SecurityContext
log.debug("向SecurityContext中存入认证信息:{}", authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);

20. 在处理请求时识别当前登录的用户身份*

在任何处理请求的方法的参数列表中,都可以添加@AuthenticationPrincipal LoginPrincipal loginPrincipal参数,Spring Security框架会自动从上下文(SecurityContext)中获取认证信息中的当事人,作为此参数的值!所以,在处理请求时,可以知晓当前登录的用户的id、username,例如:

// http://localhost:9081/admins
@ApiOperation("查询管理员列表")
@ApiOperationSupport(order = 420)
@GetMapping("")
public JsonResult<List<AdminListItemVO>> list(
    	// ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓  新增  ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
        @ApiIgnore @AuthenticationPrincipal LoginPrincipal loginPrincipal) {
    log.debug("开始处理【查询管理员列表】的请求,无参数");
    log.debug("当前登录的当事人:{}", loginPrincipal);
    List<AdminListItemVO> list = adminService.list();
    return JsonResult.ok(list);
}

提示:以上@ApiIgnore(忽略)注解用于避免API文档中提示要求客户端提交id和username。

21.处理权限*

需要在处理认证(登录)时,根据用户名查询管理员详情时,一并查询出此管理员的权限信息,需要执行的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='wangkejing';

要保证执行认证时能够查询到管理员的基本信息和权限,需要:

  • 在AdminLoginInfoVO中添加属性,表示此管理员的权限
  • AdminMapper.java接口中的抽象方法不必调整
  • 在AdminMapper.xml中调整SQL语句,及如何封装查询结果

先在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对多的查询中,List属性需要使用collection标签来配置 -->
<!-- collection标签的property属性:封装查询结果的类型中的属性名,即List的属性名 -->
<!-- collection标签的ofType属性:List的元素数据类型,取值为类型的全限定名 -->
<!-- collection标签的子级:如何将查询结果中的数据封装成ofType类型的对象 -->
<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="String">
        <constructor>
            <arg column="value"/>
        </constructor>
    </collection>
</resultMap>

接下来,应该将管理员的权限存入到SecurityContext中!需要:

  • 在UserDetailsServiceImpl中返回的对象中需要包含真实的权限信息
  • 在AdminServiceImpl中,认证通过后,从返回的结果中获取权限信息,并将其转换为JSON格式的字符串,存入到JWT中
  • 在JwtAuthorizationFilter中,解析JWT成功后,获取权限信息对应的JSON字符串,并将其反序列化为Collection<? extends GrantedAuthority>格式,并存入到Authentication中,进而存入到SecurityContext中

以上全部完成后,就可以开始配置权限了!需要先在配置类(强烈建议SecurityConfiguration)上添加@EnableGlobalMethodSecurity(prePostEnabled = true)注解,以启用方法级别的权限检查!然后,可以选择将配置检查的配置添加在控制器中处理请求的方法上(其实也可以添加在其它组件的自定义方法上),例如:

@ApiOperation("添加管理员")
@ApiOperationSupport(order = 100)
@PreAuthorize("hasAuthority('/ams/admin/add-new')") // 新增
@PostMapping("/add-new")
public JsonResult<Void> addNew(AdminAddNewDTO adminAddNewDTO) {
    log.debug("开始处理【添加管理员】的请求,参数:{}", adminAddNewDTO);
    adminService.addNew(adminAddNewDTO);
    return JsonResult.ok();
}

23. 单点登录(SSO)*

SSO:Single Sign On,单点登录,表现为客户端只需要在某1个服务器上通过认证,其它服务器也可以识别此客户端的身份!

单点登录的实现手段主要有2种:

  • 使用Session机制,并共享Session

    • spring-boot-starter-data-redis结合spring-session-data-redis
  • 使用Token机制

    • 各服务器需要有同样的解析JWT的代码

当前,csmall-passport中已经使用JWT,则可以在csmall-product项目中也添加Spring Security框架和解析JWT的代码,则csmall-product项目也可以识别用户的身份、检查权限。

需要做的事:

  • 添加依赖

    • spring-boot-starter-security
    • jjwt
    • fastjson
  • 复制ServiceCode覆盖csmall-product原本的文件

  • 复制GlobalExceptionHandler覆盖csmall-product原本的文件

  • 复制application-dev.yml中关于JWT的secretKey的配置

    • 关于JWT有效时长的配置,可以复制,但暂时用不上
  • 复制LoginPrincipal到csmall-product中,与csmall-passport相同的位置

  • 复制JwtAuthorizationFilter

  • 复制SecurityConfiguration

    • 删除PasswordEncoder的@Bean方法
    • 删除AuthenticationManager的@Bean方法
    • 应该删除白名单中的 /admins/login

三. Spring框架

1.1. Spring框架的作用

Spring框架主要解决了创建对象、管理对象的问题。

1.2. Spring框架的依赖项

当项目中需要使用Spring框架时,需要添加的依赖项是:spring-context

1.3. Spring框架创建对象的做法

Spring框架创建对象有2种做法:

  • 在任何配置类(添加了@Configuration)中,自定义方法,返回某种类型的(你需要的)对象,并在方法上添加@Bean注解

    • 此方式创建出来的对象,在Spring容器中的名称就是方法名称
    • 此方法应该是public
    • 此方法的返回值类型,是你期望Spring框架管理的数据的类型
    • 此方法的参数列表,应该为空
    • 此方法的方法体,应该是自行设计的,没有要求
  • 配置组件扫描,并在组件类上添加组件注解

    • 此方式创建出来的对象,在Spring容器中的名称默认是将类名首字母改为小写

      • 例如:类名是AdminController,则对象在Spring容器中的名称为adminController
      • 此规则仅适用于类名的第1个字母大写,且第2个字母小写的情况,如果不符合此规则,则对象在Spring容器中的名称就是类名
      • 可以通过组件注解的参数来指定名称
    • 在任何配置类上,添加@ComponentScan,当加载此配置类时,就会激活组件扫描

    • 可以配置@ComponentScan的参数,此参数应该是需要被扫描的根包(会扫描所配置的包,及其所有子孙包),且此注解参数的值是数组类型的

      • 例如:

      • @ComponentScan("cn.tedu")
        
    • 如果没有配置@ComponentScan的参数中的根包,则组件扫描的范围就是当前类的包及其子孙包

    • 需要在各组件类上添加组件注解,才会被创建对象,常见的组件注解有:

      • @Component:通用注解
      • @Controller:控制器类的注解
        • @RestController:仅添加Spring MVC框架后可使用
        • @ControllerAdvice:仅添加Spring MVC框架后可使用
        • @RestControllerAdvice:仅添加Spring MVC框架后可使用
      • @Service:Service这种业务类的注解
      • @Repository:处理数据源中的数据读写的类的注解
    • 以上4种组件注解在Spring框架作用范围之内是完全等效的

    • 在Spring框架中,还有@Configuration注解,也是组件注解的一种,但是Spring对此注解的处理更加特殊(Spring框架对配置类使用了代理模式)

对于这2种创建对象的做法,通常:

  • 如果是自定义的类,优先使用组件扫描的做法来创建对象
  • 如果不是自定义的类,无法使用组件扫描的做法,只能在配置类中通过@Bean方法来创建对象

当Spring成功的创建了对象后,会将对象保存在Spring应用程序上下文(ApplicationContext)中,后续,当需要这些对象时,可以从Spring应用程序上下文中获取!

由于Spring应用程序上下文中持有大量对象的引用,所以,Spring应用程序上下文也通常被称之为“Spring容器”。

1.4. Spring框架管理的对象的作用域

默认情况下,Spring框架管理的对象都是单例的!

单例:在任何时间点,某个类的对象最多只有1个!

可以在类上添加@Scope("prototype")使得被Spring管理的对象是“非单例的”。

提示:@Scope注解还可以配置为@Scope("singleton"),此singleton表示“单例的”,是默认的。

默认情况下,Spring框架管理的单例的对象是“预加载的”,相当于设计模式中的单例模式的“饿汉式单例模式”。

提示:可以在类上添加@Lazy注解,使得此对象是“懒加载的”,相当于“懒汉式单例模式”,只会在第1次需要获取对象时才把对象创建出来!

注意:Spring框架并不是使用了设计模式中的“单例模式”,只是从对象的管理方面,对象的作用域表现与单例模式的极为相似而已。

1.5. 自动装配

自动装配:当某个量需要值时,Spring框架会自动的从容器中找到合适的值,为这个量赋值。

自动装配的典型表现是在属性上添加@Autowired注解,例如:

@RestController
public class AlbumController {

    // ↓↓↓↓↓  自动装配的典型表现  ↓↓↓↓↓
    @Autowired
    private IAlbumService albumService;
    
}

或者:

@RestController
public class AlbumController {

    private IAlbumService albumService;
    
    //                     ↓↓↓↓↓↓↓  自动装配  ↓↓↓↓↓↓↓
    public AlbumController(IAlbumService albumService) {
        this.albumService = albumService;
    }
    
}

提示:Spring创建对象时需要调用构造方法,如果类中仅有1个构造方法(如上所示),Spring会自动调用,如果这唯一的构造方法是有参数的,Spring也会自动从容器中找到合适的对象来调用此构造方法,如果容器没有合适的对象,则无法创建!如果类中有多个构造方法,默认情况下,Spring会自动调用添加了@Autowired注解的构造方法,如果多个构造方法都没有添加此注解,则Spring会自动调用无参数的构造方法,如果也不存在无参数构造方法,则会报错!

或者:

@RestController
public class AlbumController {

    private IAlbumService albumService;
    
    @Autowired
    //                          ↓↓↓↓↓↓↓  自动装配  ↓↓↓↓↓↓↓
    public void setAlbumService(IAlbumService albumService) {
        this.albumService = albumService;
    }
    
}

另外,在配置类中的@Bean方法也可以在需要的时候自行添加参数,如果Spring容器中有合适的值,Spring也会从容器中找到值来调用方法。

关于“合适的值”,Spring对于@Autowired的处理机制是:查找在Spring容器中匹配类型的对象的数量:

  • 1个:直接装配,且装配成功
  • 0个:取决于@Autowired注解的required属性
    • true(默认值):装配失败,在加载时即报错
    • false:放弃装配,则此量的值为null,在接下来的使用过程中可能导致NPE(NullPointerException)
  • 超过1个:取决于是否存在某个Spring Bean(Spring容器中的对象)的名称与当前量的名称匹配
    • 存在:成功装配
    • 不存在:装配失败,在加载时即报错

关于通过名称匹配:

  • 默认情况下,要求量(全局变量、方法参数等)的名称与对象在Spring容器中名称完全相同,视为匹配
  • 可以在量(全局变量、方法参数等)的声明之前添加@Qualifier注解,通过此注解参数来指定名称,以匹配某个Spring容器的对象
    • @Qualifier注解是用于配合自动装配机制的,单独使用没有意义

其实,还可以使用@Resource注解实现自动装配,但不推荐!

Spring框架对@Resource注解的自动装配机制是:先根据名称再根据类型来实现自动装配。

@Resource是javax包中的注解,根据此注解的声明,此注解只能添加在类上、属性上、方法上,不可以添加在构造方法上、方法的参数上。

1.6. 关于IoC与DI

IoC:Inversion of Control,控制反转,即将对象的创建、管理的权力(控制能力)交给框架

DI:Dependency Injection,依赖注入,即为依赖项注入值

Spring框架通过 DI 实现/完善 了IoC。

1.7. 关于Spring AOP

AOP:面向切面的编程。

注意:AOP并不是Spring框架特有的技术,只是Spring框架很好的支持了AOP。

AOP主要用于:日志、安全、事务管理、自定义的业务规则

在项目中,数据的处理流程大致是:

添加品牌:请求 ------> Controller ------> Service ------> Mapper ------> DB

添加类别:请求 ------> Controller ------> Service ------> Mapper ------> DB

删除品牌:请求 ------> Controller ------> Service ------> Mapper ------> DB

其实,各请求提交到服务器端后,数据的处理流程是相对固定的!

假设存在某个需求,无论是添加品牌、添加类别、删除品牌,或其它请求的处理,都需要在Service组件中执行相同的任务,应该如何处理?

例如,假设需要实现“统计所有Service中的业务方法的执行耗时”,首先,需要添加依赖项:

<!-- Spring Boot AOP的依赖项 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

然后,在根包下创建aop.TimerAspect类,作为切面类,必须添加@Aspect注解,由于是通过Spring来实现AOP,所以,此类还应该交由Spring管理,它应该是个组件类,则再补充添加@Component注解,并在类中自定义方法,且通过注解来配置方法何时执行:

package cn.tedu.csmall.product.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Slf4j
@Aspect
@Component
public class TimerAspect {

    public TimerAspect() {
        log.debug("创建切面对象:TimerAspect");
    }

    // 【AOP的核心概念】
    // 连接点(JoinPoint):数据处理过程中的某个时间节点,可能是调用了方法,或抛出了异常
    // 切入点(PointCut):选择一个或多个连接点的表达式
    // 通知(Advice):在选择到的连接点执行的代码
    // 切面(Aspect):是包含了切入点和通知的模块
    // ----------------------------------------------------------
    // 【通知注解】
    // @Before注解:表示“在……之前”,且方法应该是无参数的
    // @After注解:表示“在……之后”,无论是否抛出异常,或是否返回结果,都会执行,且方法应该是无参数的
    // @AfterReturning注解:表示“在返回结果之后”,且方法的参数是JoinPoint和返回值
    // @AfterThrowing注解:表示“在抛出异常之后”,且方法的参数是JoinPoint和异常对象
    // @Around注解:表示“包裹”,通常也称之为“环绕”,且方法的参数是ProceedingJoinPoint
    // ----------------------------------------------------------
    // @Around开始
    // try {
    //     @Before
    //     表达式匹配的方法
    //     @AfterReturning
    // } catch (Throwable e) {
    //     @AfterThrowing
    // } finally {
    //     @After
    // }
    // @Around结束
    // ----------------------------------------------------------
    // 注解中的execution内部配置表达式,以匹配上需要哪里执行切面代码
    // 表达式中,星号(*)是通配符,可匹配1次任意内容
    // 表达式中,2个连接的小数点(..)也是通配符,可匹配0~n次,只能用于包名和参数列表
    @Around("execution(* cn.tedu.csmall.product.service.*.*(..))")
    //                 ↑ 此星号表示需要匹配的方法的返回值类型
    //                   ↑ ---------- 根包 ----------- ↑
    //                                                  ↑ 类名
    //                                                    ↑ 方法名
    //                                                      ↑↑ 参数列表
    public Object timer(ProceedingJoinPoint pjp) throws Throwable {
        log.debug("执行了TimeAspect中的方法……");

        log.debug("【{}】类型的对象调用了【{}】方法,参数值为【{}】",
                pjp.getTarget().getClass().getName(),
                pjp.getSignature().getName(),
                pjp.getArgs());

        long start = System.currentTimeMillis();
        // 注意:必须获取调用此方法的返回值,作为当前切面方法的返回值
        // 注意:必须抛出调用此方法的异常,不可以使用try...catch捕获并处理
        Object result = pjp.proceed(); // 相当于执行了匹配的方法,即业务方法
        long end = System.currentTimeMillis();

        log.debug("执行耗时:{}毫秒", end - start);

        return result;
    }

}

四. Spring MVC框架

1.1. Spring MVC框架的作用

Spring MVC框架主要解决了接收请求、响应结果的相关问题。

1.2. Spring MVC框架的依赖项

当项目中需要使用Spring MVC框架时,需要添加的依赖项是:spring-webmvc

1.3. 配置请求路径

通过@RequestMapping系列注解可以配置请求路径

1.4. 限制请求方式

通过@RequestMapping注解的method属性限制请求方式,例如:

@RequestMapping(value = "/login", method = RequestMethod.POST)

或者,直接使用衍生的注解,例如@GetMapping、@PostMapping。

1.5. 接收请求参数

可以在处理请求的方法的参数列表中自由设计请求参数,可以:

  • 将各请求参数逐一列举出来,表现为方法的多个参数,例如:

    public JsonResult<Void> login(String username, String password) { ... }
    
  • 将各请求参数封装到自定义的类型中,使用自定义的类型作为方法的参数

关于请求参数的注解:

  • @RequestParam:此注解可以用于:修改请求参数的名称(没有太多实用价值),强制要求必须提交此参数(可以通过Validation框架的@NotNull实现同样效果),设置请求参数的默认值(适用于允许客户端不提交此请求参数时)
  • @PathVariable:当URL中设计了占位符参数时,必须在对应的方法参数上添加此注解,以表示此参数的值来自URL中的占位符位置的值,如果占位符中的名称与方法参数名称不匹配,可以通过此注解的参数来配置
  • @RequestBody:当方法的参数添加了此注解时,客户端提交的请求参数必须是对象格式的,当方法的参数没有添加此注解时,客户端提交的请求参数必须是FormData格式的

1.6. 响应结果

默认情况下,处理请求的方法的返回值将表示“处理响应结果的视图组件的名称,及相关的数据”,在Spring MVC中,有一种内置的返回值类型是ModelAndView,不是前后端分离的做法!

在处理请求的方法上,可以添加@ResponseBody注解,当添加此注解后,处理请求的方法的返回值将表示“响应的数据”,不再由服务器端决定视图组件,这种做法也叫做“响应正文”!这是前后端分离的做法!

@ResponseBody注解可以添加在处理请求的方法上,将作用于当前方法,也可以添加在控制器类上,将作用于控制器类中所有处理请求的方法!

控制器类需要添加@Controller注解,才是控制器类,或者,也可以改为添加@RestController,此注解是由@Controller和@ResponseBody组合而成的!所以,添加@RestController后,当前控制器类中所有处理请求的方法都是“响应正文”的!

当控制器处理请求需要响应正文时,Spring MVC框架会根据处理请求的方法的返回值类型,来决定使用某个MessageConverter(消息转换器),来将返回值转换为响应到客户端的数据,不同的返回值类型对应不同的消息转换器,例如,返回值类型是String时,Spring MVC框架将使用StringHttpMessageConverter,如果某个返回值类型是Spring MVC框架没有对应的消息转换器的,且当前项目添加了jackson-databind依赖项后,会自动使用此依赖项中的消息转换器,而jackson-databind中的消息转换器会将方法返回的结果转换为JSON格式的字符串!另外,如果当前项目是使用XML来配置Spring MVC框架的,还需要添加<annotation-driven/>标签以开启“注解驱动”,如果是使用注解进行配置的,则需要在配置类上添加@EnableWebMvc注解,如果是在Spring Boot中应用Spring MVC,不需要此配置!

1.7. 处理异常

添加了@ExceptionHandler注解的方法,就是处理异常的方法。

处理异常的方法到底处理哪种异常,由@ExceptionHandler注解参数或方法的参数中的异常类型来决定!如果@ExceptionHandler注解没有配置参数,由方法的参数中的异常类型决定,如果@ExceptionHandler注解配置了参数,由以注解参数中配置的类型为准!

处理异常的方法可以声明在控制器类,将只作用于当前控制器类中的方法抛出的异常!

通常,建议将处理异常的方法声明在专门的类中,并在此类上添加@ControllerAdvice注解,当添加此注解后,此类中特定的方法(例如处理异常的方法)将作用于每次处理请求的过程中!如果处理异常后的将“响应正文”,也可以在处理异常的方法上添加@ResponseBody注解,或在当前类上添加@ResponseBody,或使用@RestControllerAdvice取代@ControllerAdvice和@ResponseBody。

1.8. Spring MVC框架的核心执行流程

image-20221019114229471

强烈建议抽时间看扩展视频教程!

五. Spring Boot框架

1.1. Spring Boot框架的作用

Spring Boot框架主要解决了统一管理依赖项与简化配置相关的问题。

统一管理依赖项:在开发实践中,项目中需要使用到的依赖项可能较多,在没有Spring Boot时,可能需要自行添加若干个依赖项,并且,需要自行保证这些依赖项的版本不会发生冲突,或保证不存在不兼容的问题,Spring Boot提供了starter依赖项(依赖项的名称中有starter字样),这种依赖项包含了使用某个框架时可能涉及的一系列依赖项,并处理好了各依赖项的版本相关问题,保证各依赖项的版本兼容、不冲突!例如spring-boot-starter-web就包含了Spring MVC的依赖项(spring-webmvc)、jackson-databind、tomcat等。

简化配置:Spring Boot默认完成了各项目中最可预测的配置,它是一种“约定大于配置”的思想,当然,这些配置也都是可以修改的,例如通过在application.properties中添加指定属性名的配置。

1.2. Spring Boot框架的依赖项

当项目中需要使用Spring Boot框架时,需要添加的基础依赖项是:spring-boot-starter。

此基础依赖项被其它各带有starter字样的依赖项所包含,所以,通常不必显式的添加此基础依赖项。

1.3. 典型的应用技巧

关于日志

在spring-boot-starter中已经包含spring-boot-starter-logging,所以,在添加任何starter依赖后,在项目中均可使用日志。

关于配置文件

Spring Boot项目默认在src/main/resources下已经准备好了配置文件,且Spring Boot默认识别的配置文件的文件名是application,其扩展名可以是.properties或.yml。

此配置文件会被Spring Boot自动读取,框架指定属性名称的各配置值会被自动应用,自定义的配置需要通过Environment对象或通过@Value注解自行读取并应用,所有不是指定属性名称的配置都是自定义配置。

其实,读取.properties配置文件是Spring框架做到的功能,而.yml配置是Spring框架并不支持的,但是,在Spring Boot中,添加了解析.yml文件的依赖项,所以,在Spring Boot中既可以使用.properties也可以使用.yml。

另外,Spring Boot还很好的支持了Profile配置(也是Spring框架做到的功能)。

六. 注解

注解所属框架作用
@ComponentScanSpring添加在配置类上,开启组件扫描。
如果没有配置包名,则扫描当前配置类所在的包,
如果配置了包名,则扫描所配置的包及其子孙包
@ComponentSpring添加在类上,标记当前类是组件类,可以通过参数配置Spring Bean名称
@ControllerSpring添加在类上,标记当前类是控制器组件类,用法同@Component
@ServiceSpring添加在类上,标记当前类是业务逻辑组件类,用法同@Component
@RepositorySpring添加在类上,标记当前类是数据访问组件类,用法同@Component
@ConfigurationSpring添加在类上,仅添加此注解的类才被视为配置类,通常不配置注解参数
@BeanSpring添加在方法上,标记此方法将返回某个类型的对象,
且Spring会自动调用此方法,并将对象保存在Spring容器中
@AutowiredSpring添加在属性上,使得Spring自动装配此属性的值
添加在构造方法上,使得Spring自动调用此构造方法
添加在Setter方法上,使得Spring自动调用此方法
@QualifierSpring添加在属性上,或添加在方法的参数上,
配合自动装配机制,用于指定需要装配的Spring Bean的名称
@ScopeSpring添加在组件类上,或添加在已经添加了@Bean注解的方法上,
用于指定作用域,注解参数为singleton(默认)时为“单例”,注解参数为prototype时为“非单例”
@LazySpring添加在组件类上,或添加在已经添加了@Bean注解的方法上,
用于指定作用域,当Spring Bean是单例时,注解参数为true(默认)时为“懒加载”,注解参数为false时为“预加载”
@ValueSpring添加在属性上,或添加在被Spring调用的方法的参数上,用于读取Environment中的属性值,为对象的属性或方法的参数注入值
@ResourceSpring此注解是javax包中的注解,
添加在属性上,使得Spring自动装配此属性的值,
通常不推荐使用此注解
@ResponseBodySpring MVC添加在方法上,标记此方法是“响应正文”的,
添加在类上,标记此类中所有方法都是“响应正文”的
@RestControllerSpring MVC添加在类上,标记此类是一个“响应正文”的控制器类
@RequestMappingSpring MVC添加在类上,也可以添加在处理请求的方法上,
通常用于配置请求路径
@GetMappingSpring MVC添加在方法上,是将请求方式限制为GET的@RequestMapping
@PostMappingSpring MVC添加在方法上,是将请求方式限制为POST的@RequestMapping
@DeleteMappingSpring MVC添加在方法上,是将请求方式限制为DELETE的@RequestMapping
@PutMappingSpring MVC添加在方法上,是将请求方式限制为PUT的@RequestMapping
@RequestParamSpring MVC添加在请求参数上,可以:
1. 指定请求参数名称
2. 要求必须提交此参数
3. 指定请求参数的默认值
@PathVariableSpring MVC添加在请求参数上,用于标记此参数的值来自URL中的占位符,如果URL中的占位符名称与方法的参数名称不同,需要配置此注解参数来指定URL中的占位符名称
@RequestBodySpring MVC添加在请求参数上,用于标记此参数必须是对象格式的参数,如果未添加此注解,参数必须是FormData格式的
@ExceptionHandlerSpring MVC添加在方法上,标记此方法是处理异常的方法,可以通过配置注解参数来指定需要处理的异常类型,如果没有配置注解参数,所处理的异常类型取决于方法的参数列表中的异常类型
@ControllerAdviceSpring MVC添加在类上,标记此类中特定的方法将作用于每次处理请求的过程中
@RestControllerAdviceSpring MVC添加在类上,是@ControllerAdvice和@ResponseBody的组合注解
@MapperScanMybatis添加在配置类上,用于指定Mapper接口的根包,Mybatis将根据此根包执行扫描,以找到各Mapper接口
@MapperMybatis添加在Mapper接口上,用于标记此接口是Mybatis的Mapper接口,如果已经通过@MapperScan配置能够找到此接口,则不需要使用此注解
@ParamMybatis添加在Mapper接口中的抽象方法的参数上,用于指定参数名称,当使用此注解指定参数名称后,SQL中的#{} / ${}占位符中的名称必须是此注解指定的名称,通常,当抽象方法的参数超过1个时,强烈建议在每个参数上使用此注解配置名称
@SelectMybatis添加在Mapper接口的抽象方法上,可以通过此注解直接配置此抽象方法对应的SQL语句(不必将SQL语句配置在XML文件中),用于配置SELECT类的SQL语句,但是,非常不推荐这种做法
@InsertMybatis同上,用于配置INSERT类的SQL语句
@UpdateMybatis同上,用于配置UPDATE类的SQL语句
@DeleteMybatis同上,用于配置DELETE类的SQL语句
@TransactionalSpring JDBC推荐添加的业务接口上,用于标记此接口中所有方法都是事务性的,或业务接口中的抽象方法上,用于此方法是事务性的
@SpringBootApplicationSpring Boot添加在类上,用于标记此类是Spring Boot的启动类,每个Spring Boot项目应该只有1个类添加了此注解
@SpringBootConfigurationSpring Boot通常不需要显式的使用,它是@SpringBootApplication的元注解之一
@SpringBootTestSpring Boot添加在类上,用于标记此类是加载Spring环境的测试类
@ValidSpring Validation添加在方法的参数上,标记此参数需要经过Validation框架的检查
@ValidatedSpring Validation添加在方法的参数上,标记此参数需要经过Validation框架的检查;添加在类上,并结合方法上的检查注解(例如@NotNull等)实现对未封装的参数的检查
@NotNullSpring Validation添加在需要被检查的参数上,或添加在需要被检查的封装类型的属性上,用于配置“不允许为null”的检查规则
@NotEmptySpring Validation使用位置同@NotNull,用于配置“不允许为空字符串”的检查规则
@NotBlankSpring Validation使用位置同@NotNull,用于配置“不允许为空白”的检查规则
@PatternSpring Validation使用位置同@NotNull,用于配置正则表达式的检查规则
@RangeSpring Validation使用位置同@NotNull,用于配置“数值必须在某个取值区间”的检查规则
@ApiKnife4j添加在控制器类上,通过此注解的tags属性配置API文档中的模块名称
@ApiOperationKnife4j添加在控制器类中处理请求的方法上,用于配置业务名称
@ApiOperationSupportKnife4j添加在控制器类中处理请求的方法上,通过此注解的order属性配置业务显示在API文档中时的排序序号
@ApiModelPropertyKnife4j添加在封装的请求参数类型中的属性上,用于配置请求参数的详细说明,包括:名称、数据类型、是否必须等
@ApiImplicitParamKnife4j添加在控制器类中处理请求的方法上,用于配置请求参数的详细说明,包括:名称、数据类型、是否必须等
@ApiImplicitParamsKnife4j添加在控制器类中处理请求的方法上,如果需要通过@ApiImplicitParam注解配置的参数超过1个,则必须将多个@ApiImplicitParam注解作为此注解的参数
@ApiIgnoreKnife4j添加在请求参数上,用于标记API文档中将不关心此参数
@EnableGlobalMethodSecuritySpring Security添加在配置类上,用于开启全局的方法级别的权限控制
@PreAuthorizeSpring Security添加在方法上,用于配置权限
@AuthenticationPrincipalSpring Security添加在方法的参数上,且此参数应该是Security上下文中的认证信息中的当事人类型,用于为此参数注入值
@DataLombok添加在类上,将在编译期生成此类中所有属性的Setter、Getter方法,及hashCode()、equals()、toString()方法
@SetterLombok添加在类上,将在编译期生成此类中所有属性的Setter方法,也可以添加在类的属性上,将在编译期生成此属性的Setter方法
@GetterLombok添加在类上,将在编译期生成此类中所有属性的Getter方法,也可以添加在类的属性上,将在编译期生成此属性的Getter方法
@EqualsAndHashcodeLombok添加在类上,将在编译期生成基于此类中所有属性的hashCode()、equals()方法
@ToStringLombok添加在类上,将在编译期生成基于此类中所有属性的toString()方法
@NoArgConstructorLombok添加在类上,将在编译期生成此类的无参数构造方法
@AllArgsConstructorLombok添加在类上,将在编译期生成基于此类中所有属性的全参构造方法

d15

最近更新:: 2025/8/21 13:52
Contributors: yanpeng_
Prev
SpringAI开发问答系统
Next
工作流Activity7