Skip to content

导入初始工程

本项目主要是用于学习实现权限校验功能,不需要去一步步创建整体的项目工程,直接使用网上一些开源项目做为初始化工程,直接导入到 IDEA 即可。导入步骤:

  1. 将初始工程 pinda-authority 复制到任意没有中文和空格的目录下
  2. 打开 IDEA,选择 Open,选择 pinda-authority 工程目录即可

导入的项目结构如下:

项目模块

工程结构和模块说明

品达通用权限系统项目整体工程结构和模块功能如下:

pinda-authority              # 聚合工程,用于聚合 pd-parent、pd-apps、pd-tools 等模块
├── pd-parent				 # 父工程,nacos 配置及依赖包管理
├── pd-apps					 # 应用目录
|	├── pd-auth				 # 权限服务父工程
|	|	├── pd-auth-entity   # 权限实体
|	|	└── pa-auth-server   # 权限服务
|	└── pd-gateway			 # 网关服务
└── pd-tools				 # 工具工程
|	├── pd-tools-common		 # 基础组件:基础配置类、函数、常量、统一异常处理、undertow服务器
|	├── pd-tools-core		 # 核心组件:基础实体、返回对象、上下文、异常处理、分布式锁、函数、树
|	├── pd-tools-databases	 # 数据源组件:数据源配置、数据权限、查询条件等
|	├── pd-tools-dozer		 # 对象转换:dozer配置、工具
|	├── pd-tools-j2cache	 # 缓存组件:j2cache、redis缓存
|	├── pd-tools-jwt         # JWT组件:配置、属性、工具
|	├── pd-tools-log	     # 日志组件:日志实体、事件、拦截器、工具
|	├── pd-tools-swagger2	 # 文档组件:knife4j文档
|	├── pd-tools-user        # 用户上下文:用户注解、模型和工具,当前登录用户信息注入模块
|	├── pd-tools-validator	 # 表单验证: 后台表单规则验证
|	└── pd-tools-xss		 # xss防注入组件
└── pd-examples              # 本项目涉及相关技术的示例工程

项目服务目前只有两个:网关服务和权限服务

应用端口说明启动命令
pd-gateway8760网关服务java -jar pd-gateway.jar &
pd-auth-server8764权限服务java -jar pd-auth-server.jar &

Tips:

  • 以上启动命令用于 linux 部署环境中,如果是本地开发时,直接使用 IDEA 启动即可
  • 由于本系统是基于当前非常流行的前后端分离的方式开发,其中前端部分是由专门的前端开发人员负责,本示例项目中直接使用。

特别注意:下面使用 pd-tools-xxx 的工具模块的示例,如果是非本项目引入时,需要将其安装到本地仓库

项目实战开发计划说明

通过前面导入的初始项目可以看到,pd-tools 工程中的各个子模块功能基本已完成,这些子模块属于项目的基础组件,为后续开发网关服务和权限服务提供支持,而且有一些子模块在其他项目中也可以复用。由于这些子模块会涉及到一些新技术或者框架,所以计划在本项目主要的权限校验功能实现前,先通读 pd-tools 中的相关模块的代码实现,了解这些新技术或者框架的使用方法,从而了解此模块的作用和开发过程。

学习完这些模块之后,再进行开发后面的网关服务和权限服务了。

Nacos 服务注册和配置中心搭建

更多 Nacos 内容详见《Spring Cloud Alibaba Nacos》笔记的“Spring Cloud Alibaba Nacos Discovery”与“Spring Cloud Alibaba Nacos Config”章节。

本项目使用 Nacos 来作为服务的注册和配置中心。Nacos 是阿里巴巴开源的一款支持服务注册与发现,配置管理以及微服务管理的组件。用来取代以前常用的注册中心(zookeeper、eureka 等等),以及配置中心(spring cloud config、apollo 等等)。Nacos 是同时集成了注册中心和配置中心的功能。

下载与安装

安装和配置过程如下:

  • 第一步:下载 Nacos 安装包(本示例项目使用 NACOS 2.0.3),下载地址:https://github.com/alibaba/nacos/releases
  • 第二步:将下载的 zip 压缩文件解压到任意没有中文和空格的目录下

配置数据存储

Nacos 在存储数据时既可以使用内置数据库存储,也可以通过第三方指定的数据库存储。若要指定使用 MySQL 数据库来存储 Nacos 的相关数据,则需要修改 NACOS_HOME/conf/application.properties 配置文件,配置使用的 MySQL 数据库的数据源信息,这个可以根据实际的 MySQL 数据库进行相应调整,例如 MySQL 的地址、用户名、密码等。

properties
### If use MySQL as datasource:
spring.datasource.platform=mysql

### Count of DB:
db.num=1

### Connect URL of DB:
db.url.0=jdbc:mysql://127.0.0.1:3306/nacos?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&serverTimezone=UTC
db.user.0=root
db.password.0=123456

创建数据库

sql
CREATE DATABASE `nacos` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

执行 NACOS_HOME/conf/nacos-mysql.sql 数据库脚本文件创建 nacos 需要相应表格,完成后可以看到创建了如下表

启动服务

进入 nacos 安装目录,双击 NACOS_HOME/bin/startup.cmd 即可启动服务。也可通过命令行方式指定参数来启动服务:

bash
startup.cmd -m standalone

导入项目配置

访问 Nacos 控制台

新建命名空间 pinda-dev

Notes: 在创建完命名空间后,需要将命名空间的 id 需要替换到项目文件 pd-parent/pom.xml 中对应的 nacos 命名空间的 id。创建命名空间时可以手动指定 ID,最终保持项目与 nacos 配置的 ID 一致即可

点击【配置管理】->【配置列表】,选择 nacos 配置中心的命名空间,点击【导入配置】按钮导入配置文件:pinda-authority-project/document/nacos/nacos_config_export_20220719094029.zip。导入完成后如下:

Redis 服务

在项目开发阶段直接使用 windows 版的 Redis,直接点击 REDIS_HOME/64bit/redis-server.exe 启动 Redis 服务即可使用。

pd-tools-swagger2 模块

pd-tools-swagger2 模块定位为文档组件,前后端开发人员可以查看接口文档,为前后端开发人员的开发统一接口,方便后续的前后端联调对接工作。

swagger 介绍

Swagger 的基础介绍与使用详见《Swagger 接口文档工具》笔记

入门案例

Notes: 案例源码都在本项目的 pd-examples 模块中

  • swagger 入门案例代码详见:pinda-authority-project\pinda-authority\pd-examples\swagger-demo\
  • knife4j 入门案例代码详见:pinda-authority-project\pinda-authority\pd-examples\swagger-knife4j-demo\

模块的使用

pd-tools-swagger2 模块整合了 knife4j 的配置,并且按照 Spring boot starter 的规范在 /resources/META-INF 中提供 spring.factories 文件,内容如下:

properties
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
    com.moon.pinda.swagger2.SwaggerAutoConfiguration

在其他模块(项目)中如果需要使用 swagger 接口文档功能,只需要引入这个 starter 并且在 application.yml 中进行 swagger 的相关配置即可,例如:

yml
pinda:
  swagger:
    enabled: true #是否启用swagger
    docket:
      user:
        title: xx模块
        base-package: xxx.xxx.xxxx
      menu:
        title: xx模块
        base-package: xxx.xxx.xxxx

项目依赖

创建 maven 工程 pd-tools-swagger2-demo 并配置 pom.xml 文件引入 pd-tools-swagger2 的依赖。

xml
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.2.RELEASE</version>
    <relativePath/>
</parent>

<dependencies>
    <!-- 引入自己定义的 swagger 基础模块-->
    <dependency>
        <groupId>com.moon</groupId>
        <artifactId>pd-tools-swagger2</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
</dependencies>

创建接口与实体类

此步骤复用前面的《Swagger 入门案例》章节的代码即可

项目配置

创建 application.yml,配置 swagger 相关属性,注意要与 starter 中的定义的属性值一致

yml
pinda:
  swagger:
    enabled: true # 是否启用swagger
    title: pd-tools-swagger2 在线文档
    description: pd-tools-swagger2 模块测试生成在线文档
    version: 2.0.0
    basePackage: com.moon.examples.tools

创建启动类 ToolsSwaggerApplication

java
@SpringBootApplication
public class ToolsSwaggerApplication {
    public static void main(String[] args) {
        SpringApplication.run(ToolsSwaggerApplication.class, args);
    }
}

执行启动类 main 方法启动项目,访问地址:http://localhost:8080/doc.html

pd-tools-dozer 模块

pd-tools-dozer 模块功能定位为对象转换,其本质就是一个 Spring Boot starter,其他模块可以直接导入此模块就可以直接使用对象转换功能

dozer 介绍

Dozer 是实现两个 Java Bean 进行复制的映射器,它以递归方式将数据从一个对象复制到另一个对象。dozer 是用来对两个对象之间属性转换的工具,在将一个对象的所有属性值转给另一个对象时,就不需要再去写重复的调用 setter 和 getter 方法了。dozer 其实是 Beanutils 的封装

dozer 的 maven 坐标:

xml
<dependency>
    <groupId>com.github.dozermapper</groupId>
    <artifactId>dozer-core</artifactId>
    <version>6.5.0</version>
</dependency>

为了简化使用方式,dozer 还提供了 starter,其 maven 坐标为:

xml
<dependency>
    <groupId>com.github.dozermapper</groupId>
    <artifactId>dozer-spring-boot-starter</artifactId>
    <version>6.5.0</version>
</dependency>

Dozer 入门案例

项目依赖

创建 maven 工程 dozer-demo 并配置 pom.xml 文件引入 dozer 的依赖。

xml
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.2.RELEASE</version>
    <relativePath/>
</parent>

<dependencies>
    <dependency>
        <groupId>com.github.dozermapper</groupId>
        <artifactId>dozer-spring-boot-starter</artifactId>
        <version>6.5.0</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
</dependencies>

创建测试的实体类

  • 创建 UserDTO
java
@Data
public class UserDTO {
    private String userId;
    private String userName;
    private int userAge;
    private String address;
    private String birthday;
}
  • 创建 UserEntity
java
@Data
public class UserEntity {
    private String id;
    private String name;
    private int age;
    private String address;
    private Date birthday;
}

Notes: 为了测试属性不同的名称、不同的类型的时的转换,以上有部分的属性与类型不一致

dozer 配置

在 resources/dozer/ 目录下创建 dozer 的全局配置文件 global.dozer.xml

注:全局配置文件名称可以任意的

xml
<?xml version="1.0" encoding="UTF-8"?>
<mappings xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xmlns="http://dozermapper.github.io/schema/bean-mapping"
          xsi:schemaLocation="http://dozermapper.github.io/schema/bean-mapping
                              http://dozermapper.github.io/schema/bean-mapping.xsd">
    <!-- 全局配置:
        <date-format> 表示日期格式
     -->
    <configuration>
        <date-format>yyyy-MM-dd</date-format>
    </configuration>
</mappings>

在 resources/dozer/ 目录下创建 dozer 的映射文件 biz.dozer.xml。此文件针对实体类之间的映射配置

注:映射文件名称可以任意

xml
<?xml version="1.0" encoding="UTF-8"?>
<mappings xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xmlns="http://dozermapper.github.io/schema/bean-mapping"
          xsi:schemaLocation="http://dozermapper.github.io/schema/bean-mapping
                             http://dozermapper.github.io/schema/bean-mapping.xsd">
    <!-- 描述两个类中属性的对应关系,对于两个类中同名的属性可以不映射 -->
    <mapping date-format="yyyy-MM-dd">
        <class-a>com.moon.examples.dozer.entity.UserEntity</class-a>
        <class-b>com.moon.examples.dozer.dto.UserDTO</class-b>
        <field>
            <a>id</a>
            <b>userId</b>
        </field>
        <field>
            <a>name</a>
            <b>userName</b>
        </field>
        <field>
            <a>age</a>
            <b>userAge</b>
        </field>
    </mapping>

    <!-- 可以使用 map-id 指定映射的标识,在程序中通过此标识来确定使用当前这个映射关系 -->
    <mapping date-format="yyyy-MM-dd" map-id="user">
        <class-a>com.moon.examples.dozer.entity.UserEntity</class-a>
        <class-b>com.moon.examples.dozer.dto.UserDTO</class-b>
        <field>
            <a>id</a>
            <b>userId</b>
        </field>
        <field>
            <a>name</a>
            <b>userName</b>
        </field>
        <field>
            <a>age</a>
            <b>userAge</b>
        </field>
    </mapping>
</mappings>

编写 application.yml 项目配置文件,指定 dozer 的配置文件位置

yml
dozer:
  mapping-files:
    - classpath:dozer/global.dozer.xml
    - classpath:dozer/biz.dozer.xml

功能测试

编写启动类

java
@SpringBootApplication
public class DozerDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DozerDemoApplication.class, args);
    }
}

编写测试方法。注:示例使用 junit 5

java
import com.github.dozermapper.core.Mapper;
import com.moon.examples.dozer.DozerDemoApplication;
import com.moon.examples.dozer.dto.UserDTO;
import com.moon.examples.dozer.entity.UserEntity;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

/**
 * Dozer 基础测试
 */
@SpringBootTest(classes = DozerDemoApplication.class)
public class DozerTest {

    // 使用 Dozer 提供的 Mapper 对象可以完成两个对象之间属性复制
    @Autowired
    private Mapper mapper;

    private UserDTO userDTO = new UserDTO();

    // 初始化测试使用的源对象值
    @BeforeEach
    public void init() {
        userDTO.setUserId("2828");
        userDTO.setUserName("MooNkirA");
        userDTO.setUserAge(21);
        userDTO.setAddress("GK-China");
        userDTO.setBirthday("2010-08-08");
    }

    // 根据类型 .class 进行复制
    @Test
    public void testDozer1() {
        UserEntity user = mapper.map(userDTO, UserEntity.class);
        System.out.println(user);
    }

    // 两个对象之间复制
    @Test
    public void testDozer2() {
        UserEntity user = new UserEntity();
        user.setId("200");
        System.out.println(user);
        mapper.map(userDTO, user);
        System.out.println(user);
    }

    // 根据配置的映射id 进行复制
    @Test
    public void testDozer3() {
        UserEntity user = new UserEntity();
        System.out.println(user);
        mapper.map(userDTO, user, "user");
        System.out.println(user);
    }
}

模块的使用

在 pd-tools-dozer 模块中为了进一步简化操作,封装了一个工具类 DozerUtils,其内部使用的就是 Mapper 对象进行的操作。并且按照 Spring Boot starter 的规范编写 /resources/META-INF/spring.factories 文件,内容如下:

properties
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
    com.moon.pinda.dozer.DozerAutoConfiguration

在配置类 DozerAutoConfiguration 中完成 DozerUtils 对象的创建,这样其他的程序如果需要使用 dozer 进行对象转换,只需要引入此模块的 maven 坐标并且提供对应的映射文件就可以在程序中直接注入 DozerUtils 对象进行操作了。

项目依赖

创建 maven 工程 pd-tools-dozer-demo 并配置 pom.xml 文件引入 pd-tools-dozer 的依赖。

xml
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.2.RELEASE</version>
    <relativePath/>
</parent>

<dependencies>
    <!-- 引入自定义的 dozer 公共模块-->
    <dependency>
        <groupId>com.moon</groupId>
        <artifactId>pd-tools-dozer</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
</dependencies>

创建测试的实体类

此步骤复用前面的《Dozer 入门案例》章节的代码即可

dozer 配置

此步骤复用前面的《Dozer 入门案例》章节的配置即可

Notes: 如果创建的 UserEntity 和 UserDTO 中的属性完全一致,可以不提供映射文件,如果这两个类中的属性存在不一致的情况,需要创建映射文件进行映射,并且还需要在 application.yml 中配置映射文件的位置,例如:

yml
dozer:
  mappingFiles:
    - classpath:dozer/biz.dozer.xml # 指定dozer的映射文件位置

运行测试

创建启动类

java
@SpringBootApplication
public class ToolsDozerApplication {
    public static void main(String[] args) {
        SpringApplication.run(ToolsDozerApplication.class, args);
    }
}

编写测试方法。注:示例使用 junit 5

java
@SpringBootTest
public class DozerToolsTest {

    // 在 pd-tools-dozer 中已经完成了自动配置,可以直接注入
    @Autowired
    private DozerUtils dozerUtils;

    private UserDTO userDTO = new UserDTO();

    // 初始化测试使用的源对象值
    @BeforeEach
    public void init() {
        userDTO.setUserId("123123");
        userDTO.setUserName("MooNkirA");
        userDTO.setUserAge(22);
        userDTO.setAddress("ABC");
        userDTO.setBirthday("2020-08-08");
    }

    // 根据类型 .class 进行复制
    @Test
    public void testDozerUtils() {
        UserEntity user = dozerUtils.map(userDTO, UserEntity.class);
        System.out.println(user);
    }
}

Tips: 其他不同方式复制方法参考 pd-tools-dozer 源码或者入门案例

pd-tools-validator 模块

pd-tools-validator 模块功能定位为后端表单数据校验,其他模块可以直接引入 pd-tools-validator 的 maven 坐标就可以使用其提供的表单校验功能。pd-tools-validator 底层基于 hibernate-validator 实现。

hibernate-validator 介绍与入门案例

hibernate-validator 的基础介绍与使用详见《Spring Boot Validation 参数校验》笔记

模块的使用

pd-tools-validator 的实现和前面的入门案例是一致的,只是没有提供全局异常处理,这是因为不同的系统对于校验结果的处理方式可能不一样,所以需要各个系统自己进行个性化的处理,而 pd-tools-validator 只是提供数据校验功能。

项目依赖

创建 maven 工程 pd-tools-validator-demo 并配置 pom.xml 文件引入 pd-tools-validator 的依赖。

xml
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.2.RELEASE</version>
    <relativePath/>
</parent>

<dependencies>
    <!-- 引入自定义的 dozer 公共模块-->
    <dependency>
        <groupId>com.moon</groupId>
        <artifactId>pd-tools-dozer</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
</dependencies>

创建测试的实体类与控制器

实体类

java
@Data
public class UserDTO {

    @NotNull(message = "用户id不能为空")
    private Integer id;

    @NotEmpty(message = "用户名不能为空")
    @Length(max = 50, message = "用户名长度不能超过50")
    private String username;

    @Max(value = 80, message = "年龄最大为80")
    @Min(value = 18, message = "年龄最小为18")
    private int age;

    @Pattern(regexp = "[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$",
            message = "邮箱格式不正确")
    private String email;
}

控制类,标识 @Validated 注解开启校验功能

java
@RestController
@RequestMapping("/user")
@Validated // 开启校验功能
public class UserController {

    // 简单数据类型校验
    @RequestMapping("/delete")
    public String delete(@NotBlank(message = "id不能为空") String id) {
        System.out.println("delete..." + id);
        return "OK";
    }

    // 对象属性校验
    @RequestMapping("/save")
    public String save(@Validated UserDTO user) {
        System.out.println("save..." + user);
        return "OK";
    }
}

运行测试

创建启动类,并标识 @EnableFormValidator 注解开启校验快速失败模式

java
@SpringBootApplication
@EnableFormValidator // 开启校验快速失败模式
public class ToolsValidatorApplication {

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

}

启动项目,访问地址:http://localhost:8080/user/save,可以看到控制台输出:

注释 @EnableFormValidator 注解,关闭校验快速失败模式,再次测试

pd-tools-xss 模块

pd-tools-xss 模块功能定位为防跨站脚本攻击(XSS),通过对用户在页面输入的 HTML/CSS/JavaScript 等内容进行检验和清理,确保输入内容符合应用规范,保障系统的安全。

XSS 介绍

XSS:跨站脚本攻击(Cross Site Scripting),为不与 CSS 混淆,故将跨站脚本攻击缩写为XSS。XSS 是指恶意攻击者往 Web 页面里插入恶意 Script 代码,当用户浏览该页时,嵌入其中 Web 里面的 Script 代码会被执行,从而达到恶意攻击用户的目的,有点类似于 sql 注入。

XSS 攻击原理:HTML 是一种超文本标记语言,通过将一些字符特殊地对待来区别文本和标记,例如,小于符号(<)被看作是 HTML 标签的开始,<title></title>之间的字符是页面的标题等等。当动态页面中插入的内容含有这些特殊字符时,用户浏览器会将其误认为是插入了 HTML 标签,当这些 HTML 标签引入了一段 JavaScript 脚本时,这些脚本程序就将会在用户浏览器中执行。所以,当这些特殊字符不能被动态页面检查或检查出现失误时,就将会产生 XSS 漏洞。

AntiSamy 介绍

AntiSamy 是 OWASP 的一个开源项目,通过对用户输入的 HTML/CSS/JavaScript 等内容进行检验和清理,确保输入符合应用规范。AntiSamy 被广泛应用于 Web 服务对存储型和反射型 XSS 的防御中。

AntiSamy 的 maven 坐标:

xml
<!-- https://mvnrepository.com/artifact/org.owasp.antisamy/antisamy -->
<dependency>
    <groupId>org.owasp.antisamy</groupId>
    <artifactId>antisamy</artifactId>
    <version>1.6.8</version>
</dependency>

AntiSamy 入门案例

项目依赖

创建 maven 工程 antisamy-demo 并配置 pom.xml 文件引入相关依赖

xml
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.2.RELEASE</version>
    <relativePath/>
</parent>

<dependencies>
    <!-- AntiSamy 核心依赖 -->
    <dependency>
        <groupId>org.owasp.antisamy</groupId>
        <artifactId>antisamy</artifactId>
        <version>1.5.7</version>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
</dependencies>

配置过滤的策略

AntiSamy 对“恶意代码”的过滤依赖于策略文件。策略文件规定了 AntiSamy 对各个标签、属性的处理方法,策略文件定义的严格与否,决定了 AntiSamy 对 XSS 漏洞的防御效果。

在 resources 目录,创建 AntiSamy 的策略文件 antisamy-test.xml(文件名与位置非固定)。策略文件配置内容可以从 antisamy 的 jar 包中获取,包含了几个常用的策略文件:

准备测试的资源

创建实体类

java
@Data
public class User {
    private int id;
    private String name;
    private int age;
}

创建请求控制类

java
@RestController
@RequestMapping("/user")
public class UserController {

    @RequestMapping("/save")
    public String save(User user) {
        System.out.println("UserController save.... " + user);
        return user.getName();
    }
}

在 resources/static 目录中创建简单的表单页面(注意:Spring Boot 规范静态页面固定放置的路径)

html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>AntiSamy 防止 xss 测试</title>
  </head>
  <body>
    <form method="post" action="/user/save">
      id:<input type="text" name="id" /><br />
      name:<input type="text" name="name" /><br />
      age:<input type="text" name="age" /><br />
      <input type="submit" value="submit" />
    </form>
  </body>
</html>

创建启动类

java
@SpringBootApplication
public class AntiSamyApplication {
    public static void main(String[] args) {
        SpringApplication.run(AntiSamyApplication.class, args);
    }
}

以上资源准备后,此时可以启动项目进行访问,但是还没有进行参数的过滤,所以目前如果输入任意参数都可以正常传递到 UserController 中,这在实际项目中是非常不安全的。需要通过过滤器来实现对前端输入的数据进行过滤清理。

Tips: 测试时如果使用 chrome 浏览器,它已经做了 xss 的防范优化,所有可能有部分恶意代码的效果无法展示;使用 ie 浏览器测试就可以展示相关效果

过滤器

  • 创建过滤器,用于过滤所有提交到服务器的请求参数
java
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

/**
 * 自定义过滤器,用于过滤所有提交到服务器的请求参数
 */
public class XssFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        /*
         * 调用 FilterChain 对象放行方法。
         * 注意:这里将 ServletRequest 对象包装成自定义的 XssRequestWrapper 对象
         * XssRequestWrapper 继承 HttpServletRequestWrapper 类,而真正的参数校验与过滤在此包装类中完成
         */
        chain.doFilter(new XssRequestWrapper(httpServletRequest), response);
    }
}

Notes: 通过上面的示例代码可知,目前过滤器发现并没有直接进行请求参数的过滤清理,而是直接放行了,其实真正过滤清理的逻辑是放到另外一个类 XssRequestWrapper 中进行的,当上面的过滤器放行时需要调用 filterChain.doFilter() 方法,此方法需要传入请求 HttpServletRequest 对象,此时可以将当前的 request 对象进行包装,而 XssRequestWrapper 就是 HttpServletRequest 对象的包装类,在过滤器放行时会自动调用包装类中重写的 getParameterValues 方法,因此可以在包装类的 getParameterValues 方法中进行统一的请求参数过滤清理。

  • 创建 XssRequestWrapper 包装类,实现请求参数的过滤清理
java
package com.moon.examples.antisamy.filter;

import org.owasp.validator.html.AntiSamy;
import org.owasp.validator.html.CleanResults;
import org.owasp.validator.html.Policy;
import org.owasp.validator.html.PolicyException;
import org.owasp.validator.html.ScanException;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;

/**
 * HttpServletRequest 包装类,在此类中的进行请求参数的处理
 */
public class XssRequestWrapper extends HttpServletRequestWrapper {

    // 定义 AntiSamy 提供的策略对象 Policy
    private static Policy policy;

    static {
        try {
            // 获得antisamy框架所需的决策文件路径
            String antisayPath = XssRequestWrapper.class.getClassLoader()
                    .getResource("antisamy-test.xml")
                    .getFile();
            // 获取过滤策略对象
            policy = Policy.getInstance(antisayPath);
        } catch (PolicyException e) {
            e.printStackTrace();
        }
    }

    // 通过 AntiSamy 框架过滤请求参数的字符
    public String cleanXss(String text) {
        try {
            AntiSamy antiSamy = new AntiSamy();
            // 扫描字符串,根据策略去清除非法的字符
            CleanResults cleanResults = antiSamy.scan(text, policy);
            // 获取清理后的字符串
            text = cleanResults.getCleanHTML();
        } catch (ScanException | PolicyException e) {
            e.printStackTrace();
        }
        return text;
    }

    /**
     * 构造方法
     *
     * @param request
     */
    public XssRequestWrapper(HttpServletRequest request) {
        super(request);
    }

    /**
     * 此方法的默认行为是在被包装的请求对象上返回 getParameterValues(String name)。
     *
     * @param name
     * @return
     */
    @Override
    public String[] getParameterValues(String name) {
        // 获取原始的请求参数值
        String[] parameterValues = super.getParameterValues(name);
        if (parameterValues == null) {
            return null;
        }

        int length = parameterValues.length;
        // 定义待返回的新的数组
        String[] newArray = new String[length];

        for (int i = 0; i < length; i++) {
            // 获取参数值
            String parameterValue = parameterValues[i];
            // 进行参数处理
            parameterValue = cleanXss(parameterValue);
            // 将处理后的值,放到新的数组中
            newArray[i] = parameterValue;
        }
        return newArray;
    }

    /* **********************************
     * 以下对多种情况进行 xss 攻击处理
     ************************************/

    @Override
    public String getParameter(String paramString) {
        String str = super.getParameter(paramString);
        if (str == null) {
            return null;
        }
        return cleanXss(str);
    }


    @Override
    public String getHeader(String paramString) {
        String str = super.getHeader(paramString);
        if (str == null) {
            return null;
        }
        return cleanXss(str);
    }

    @Override
    public Map<String, String[]> getParameterMap() {
        Map<String, String[]> requestMap = super.getParameterMap();
        for (Map.Entry<String, String[]> me : requestMap.entrySet()) {
            String[] values = me.getValue();
            for (int i = 0; i < values.length; i++) {
                values[i] = cleanXss(values[i]);
            }
        }
        return requestMap;
    }
}

Tips: 当前在进行请求参数过滤时只是在包装类的 getParameterValues 方法中进行了处理,真实项目中可能用户提交的数据在请求头中,也可能用户提交的是 json 数据,所以如果考虑所有情况,可以在包装类中的 getParametergetHeadergetParameterMap 等多个方法中都进行清理处理即可

创建配置类初始化过滤器

为了使上面定义的过滤器生效,需要创建配置类,用于初始化过滤器对象

java
@Configuration
public class AntiSamyConfiguration {
    /**
     * 配置跨站攻击过滤器
     */
    @Bean
    public FilterRegistrationBean filterRegistrationBean() {
        FilterRegistrationBean filterRegistration = new FilterRegistrationBean(new XssFilter());
        filterRegistration.addUrlPatterns("/*"); // 设置过滤器拦截的 url 规则
        filterRegistration.setOrder(1); // 设置过滤器优先级
        return filterRegistration;
    }
}

运行测试

启动项目,页面输入非法数据,可以看到非法数据被清理掉了。

模块的使用

pd-tools-xss 的实现和上面的入门案例是几乎一致的,底层也是基于 AntiSamy 对输入参数进行检验和清理,确保输入符合应用规范。为了方便使用,pd-tools-xss 已经定义为了 starter,其他应用只需要导入其 maven 坐标,不需要额外进行任何配置即可使用。使用示例如下:

项目依赖

创建 maven 工程 pd-tools-xss-demo 并配置 pom.xml 文件引入 pd-tools-xss 的依赖。

xml
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.2.RELEASE</version>
    <relativePath/>
</parent>

<dependencies>
    <dependency>
        <groupId>com.moon</groupId>
        <artifactId>pd-tools-xss</artifactId>
        <version>1.0-SNAPSHOT</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

测试的资源

简单的创建一个控制器即可

java
@RestController
@RequestMapping("/xss")
public class XSSController {
    @GetMapping("/get")
    public String get(String text) {
        return "处理之后的文本内容为:" + text;
    }
}

运行测试

创建项目启动类

java
@SpringBootApplication
public class ToolsXssApplication {
    public static void main(String[] args) {
        SpringApplication.run(ToolsXssApplication.class, args);
    }
}

启动项目,分别访问如下地址:

  • http://127.0.0.1:8080/xss/get?text=正常文字

  • http://127.0.0.1:8080/xss/get?text=<script>alert('123');</script>

可以看到,如果输入的是普通文本则不做任何处理;如果输入的是特殊标签则被清理掉了。

pd-tools-log 模块

pd-tools-log 模块功能定位为日志模块,本质也是一个 starter。提供的日志功能主要有两个方面:

  1. 通过 logback 框架可以在控制台或者日志文件记录日志信息
  2. 拦截用户请求,将操作日志保存到数据库

pd-tools-log 模块涉及到的技术点:

  1. 切面 Aspect、切点 PointCut、通知 Advice
  2. Spring Event 异步监听事件
  3. logback 日志组件
  4. 函数式接口
  5. ThreadLocal

logback 日志框架简介

logback 的基础介绍与使用详见《Java 日志工具类库》笔记中的“Logback”与“Spring Boot 的日志使用”章节

Spring Event

简介

Spring Event 是 Spring 的事件通知机制,可以将相互耦合的代码解耦,从而方便功能的修改与添加。Spring Event 是监听者模式的一个具体实现。

监听者模式包含了监听者 Listener、事件 Event、事件发布者 EventPublish,过程就是 EventPublish 发布一个事件,被监听者捕获到,然后执行事件相应的方法。

Spring Event 的相关 API 在 spring-context 包中。

更多 Spring Event 的基础介绍与使用详见《Spring 笔记-核心功能》中的“自定义事件”章节

Spring Event 入门案例

  • 创建 maven 工程 spring-event-demo 并配置 pom.xml 文件引入相关依赖
xml
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.2.RELEASE</version>
    <relativePath/>
</parent>

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

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</dependency>
</dependencies>
  • 创建 LogDTO 类,用于封装操作日志信息
java
@Data
public class LogDTO {
    private String requestIp; // 操作IP
    private String type; // 日志类型 LogType{OPT:操作类型;EX:异常类型}
    private String userName; // 操作人
    private String description; // 操作描述
}
  • 创建事件类 SysLogEvent,需要继承 org.springframework.context.ApplicationEvent,在构造方法中接收日志实体对象
java
public class SysLogEvent extends ApplicationEvent {
    public SysLogEvent(LogDTO source) {
        super(source);
    }
}
  • 创建事件监听器类 SysLogListener,在类中监听方法上标识 @EventListener 注解
java
@Component
public class SysLogListener {

    @Async // 异步处理
    // @EventListener 标识当前方法为事件监听处理方法,并指定监听的事件类型
    @EventListener(SysLogEvent.class)
    public void saveLog(SysLogEvent event){
        LogDTO logDTO = (LogDTO) event.getSource();
        long id = Thread.currentThread().getId();
        System.out.println("监听到日志操作事件:" + logDTO + " 线程id:" + id);
        // 其它处理逻辑,如将日志信息保存到数据库...
    }
}
  • 创建 Controller,用于发布事件
java
@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private ApplicationContext applicationContext;

    @GetMapping("/getUser")
    public String getUser() {
        // 构造操作日志信息
        LogDTO logInfo = new LogDTO();
        logInfo.setRequestIp("127.0.0.1");
        logInfo.setUserName("admin");
        logInfo.setType("OPT");
        logInfo.setDescription("查询用户信息");

        // 构造事件对象
        ApplicationEvent event = new SysLogEvent(logInfo);

        // 发布日志事件
        applicationContext.publishEvent(event);

        long id = Thread.currentThread().getId();
        System.out.println("发布事件,线程id:" + id);
        return "OK";
    }
}
  • 创建启动类并开启异步处理,启动测试
java
@SpringBootApplication
@EnableAsync // 启用异步处理
public class SpringEventApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringEventApplication.class, args);
    }
}

控制台输出日志,两种日志是不同的线程分别输出

发布事件,线程id:37
监听到日志操作事件:LogDTO(requestIp=127.0.0.1, type=OPT, userName=admin, description=查询用户信息) 线程id:54

将监听类中的监听方法上的 @Async 注释后,重启项目再测试,日志都是同一个线程输出

监听到日志操作事件:LogDTO(requestIp=127.0.0.1, type=OPT, userName=admin, description=查询用户信息) 线程id:33
发布事件,线程id:33

模块开发步骤说明

  1. 定义日志操作事件类 SysLogEvent
  2. 定义 @SysLog 注解,用于在 Controlle 的方法上标注当前方法需要进行操作日志的保存处理
  3. 定义切面类 SysLogAspect
  4. 在切面类 SysLogAspect 中定义切点,拦截 Controller 中添加 @SysLog 注解的方法
  5. 在切面类 SysLogAspect 中定义前置通知,在前置通知方法 recordLog 中收集操作日志相关信息封装为 OptLogDTO 对象并保存到 ThreadLocal
  6. 在切面类 SysLogAspect 中定义后置通知,在后置通知方法 doAfterReturning 中通过 ThreadLocal 获取 OptLogDTO 并继续设置其他的操作信息到 OptLogDTO
  7. 在切面类 SysLogAspect 的后置通知方法 doAfterReturning 中发布事件 SysLogEvent
  8. 定义监听器 SysLogListener,监听日志发布事件 SysLogEvent
  9. 定义配置类 LogAutoConfiguration,用于自动配置切面 SysLogAspect 对象
  10. 定义 starter 所需的 META-INF/spring.factories 文件,并配置自动配置类 LogAutoConfiguration

通过分析 pd-tools-log 的代码可以发现,它只是将日志信息进行收集然后发布日志事件,最后通过监听器来监听日志事件,并没有真正将日志信息保存到数据库。这是因为不同的系统对于操作日志的处理方式可能不同,比如有的系统可能将日志保存到数据库,有的系统可能将日志保存到文件。pd-tools-log 作为通用的日志组件不可能将具体的日志处理逻辑限制死,所以只是将日志信息进行收集,具体的处理方式需要交给使用方应用系统来完成。

模块的使用

项目依赖

创建 maven 工程 pd-tools-log-demo 并配置 pom.xml 文件引入 pd-tools-log 的依赖

xml
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.2.RELEASE</version>
    <relativePath/>
</parent>

<dependencies>
    <dependency>
        <groupId>com.moon</groupId>
        <artifactId>pd-tools-log</artifactId>
        <version>1.0-SNAPSHOT</version>
        <exclusions>
            <!-- 因为 pd-tools-log 模块底层涉及 nacos,此示例实现简单功能,所有将其排除 -->
            <exclusion>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
</dependencies>

日志的配置

在项目的 resources 目录下创建 logback 配置文件 logback-base.xml 和 logback-spring.xml

Notes: 非必须分成两个配置文件,示例只是为了学习拆分配置文件的用法

  • logback-base.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<included>
    <contextName>logback</contextName>
    <!--
		name的值是变量的名称,value的值时变量定义的值
		定义变量后,可以使“${}”来使用变量
	-->
    <property name="log.path" value="E:\\logs\\pinda"/>

    <!-- 彩色日志依赖的渲染类 -->
    <conversionRule
            conversionWord="clr"
            converterClass="org.springframework.boot.logging.logback.ColorConverter"/>
    <conversionRule
            conversionWord="wex"
            converterClass="org.springframework.boot.logging.logback.WhitespaceThrowableProxyConverter"/>
    <conversionRule conversionWord="wEx"
                    converterClass="org.springframework.boot.logging.logback.ExtendedWhitespaceThrowableProxyConverter"/>
    <!-- 彩色日志格式 -->
    <property name="CONSOLE_LOG_PATTERN"
              value="${CONSOLE_LOG_PATTERN:-%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx&#125;&#125;"/>

    <!-- 输出到控制台 -->
    <appender name="LOG_CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <Pattern>${CONSOLE_LOG_PATTERN}</Pattern>
            <!-- 设置字符集 -->
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- 输出到文件 -->
    <appender name="LOG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <!-- 正在记录的日志文件的路径及文件名 -->
        <file>${log.path}/logback.log</file>
        <!--日志文件输出格式-->
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
            <charset>UTF-8</charset>
        </encoder>
        <!-- 日志记录器的滚动策略,按日期,按大小记录 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <!-- 每天日志归档路径以及格式 -->
            <fileNamePattern>${log.path}/info/log-info-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <maxFileSize>100MB</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
            <!--日志文件保留天数-->
            <maxHistory>15</maxHistory>
        </rollingPolicy>
    </appender>
</included>
  • logback-spring.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <!-- 引入其他配置文件 -->
    <include resource="logback-base.xml"/>

    <!-- 开发环境 -->
    <springProfile name="dev">
        <logger name="com.moon" additivity="false" level="debug">
            <appender-ref ref="LOG_CONSOLE"/>
        </logger>
    </springProfile>
    <!-- 生产环境 -->
    <springProfile name="pro">
        <logger name="com.moon" additivity="false" level="info">
            <appender-ref ref="LOG_FILE"/>
        </logger>
    </springProfile>

    <root level="info">
        <appender-ref ref="LOG_CONSOLE"/>
        <appender-ref ref="LOG_FILE"/>
    </root>
</configuration>

项目配置

创建 application.yml 配置文件,配置 pinda.log.enabled 属性开启日志功能

yml
pinda:
  log:
    enabled: true # 启动操作日志功能
logging:
  # 在Spring Boot项目中默认加载类路径下的logback-spring.xml文件
  config: classpath:logback-spring.xml
spring:
  profiles:
    active: dev

日志功能具体实现

  • 创建测试的控制器,在需要日志功能的请求方法上标识 @SysLog
java
@RestController
@RequestMapping("/user")
@Api(tags = "用户控制器")
public class UserController {

    @SysLog("分页查询用户") // 记录操作日志
    @ApiImplicitParams({
            @ApiImplicitParam(name = "pageNum", value = "页码", required = true, type = "Integer"),
            @ApiImplicitParam(name = "pageSize", value = "每页条数", required = true, type = "Integer"),
    })
    @ApiOperation(value = "分页查询用户信息")
    @GetMapping(value = "page/{pageNum}/{pageSize}")
    public String findByPage(@PathVariable Integer pageNum, @PathVariable Integer pageSize) {
        return "OK";
    }
}
  • 创建业务类,编写具体的日志功能实现
java
@Service
@Slf4j
public class LogService {
    // 将日志信息保存到数据库
    public void saveLog(OptLogDTO optLogDTO) {
        // 此处只是将日志信息进行输出,实际项目中可以将日志信息保存到数据库
        log.debug("保存日志信息:" + optLogDTO);
    }
}
  • 关键步骤:创建配置类,在类中手动实例化监听器,传入具体日志实现的函数式接口
java
@Configuration
public class LogAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean // 当容器中不存在 SysLogListener 时才生成实例
    public SysLogListener sysLogListener(LogService logService) {
        // 创建 SysLogListener 对象,传入具体日志实现的 Consumer 接口
        return new SysLogListener((optLogDTO) -> logService.saveLog(optLogDTO));
    }
}

运行测试

创建项目启动类。启动项目,访问地址:http://localhost:8080/user/page/1/10

查看控制日志,可以看到 LogServicesaveLog 方法被调用了

pd-tools-jwt 模块

pd-tools-jwt 模块的功能定位是对于 JWT 令牌相关操作进行封装,为认证、鉴权提供支撑。提供的功能:生成 JWT、解析 JWT

Notes: 权限认证相关的内容详见《授权认证概述》笔记

认证机制介绍

HTTP Basic Auth

HTTP Basic Auth 是一种简单的登录认证方式,Web 浏览器或其他客户端程序在请求时提供用户名和密码,通常用户名和密码会通过 HTTP 头传递。即每次请求时都提供用户的名称和密码。这种方式是先把用户名、冒号、密码拼接起来,并将得出的结果字符串用 Base64 算法编码。

例如,提供的用户名是 bill、口令是 123456,则拼接后的结果就是 bill:123456,然后再将其用 Base64 编码,得到 YmlsbDoxMjM0NTY=。最终将 Base64 编码的字符串发送出去,由接收者解码得到一个由冒号分隔的用户名和口令的字符串。

  • 优点:基本上所有流行的网页浏览器都支持基本认证。
  • 缺点:由于用户名和密码都是 Base64 编码的,而 Base64 编码是可逆的,因此用户名和密码可以认为是明文。所以只有在客户端和服务器主机之间的连接是安全可信的前提下才可以使用。

Cookie-session 认证机制是通过浏览器带上来 Cookie 对象来与服务器端的 session 对象匹配来实现状态管理。

第一次请求认证在服务端创建一个 Session 对象,同时在用户的浏览器端创建了一个 Cookie 对象;当用户关闭浏览器的时候,cookie 会被删除。但可以通过修改 cookie 的 expire time 使 cookie 在一定时间内有效。

  • 优点:相对 HTTP Basic Auth 更加安全。
  • 缺点:这种基于 cookie-session 的认证使应用本身很难得到扩展,随着不同客户端用户的增加,独立的服务器已无法承载更多的用户,而这时候基于 session 认证应用的问题就会暴露出来。

OAuth

OAuth 是一个关于授权(authorization)的开放网络标准。允许用户提供一个令牌,而不是用户名和密码来访问他们存放在特定服务提供者的数据。现在的版本是 2.0 版。严格来说,OAuth2 不是一个标准协议,而是一个安全的授权框架。它详细描述了系统中不同角色、用户、服务前端应用(比如 API),以及客户端(比如网站或移动 App)之间怎么实现相互认证。

OAuth 流程如下图:

优点

  • 快速开发,代码量小,维护工作少。
  • 如果 API 要被不同的 App 使用,并且每个 App 使用的方式也不一样,使用 OAuth2 是个不错的选择。

缺点

  • OAuth2 是一个安全框架,描述了在各种不同场景下,多个应用之间的授权问题。有一定的学习成本。OAuth2 不是一个严格的标准协议,因此在实施过程中更容易出错。

Token Auth

基于 token 的认证鉴权机制类似于 http 协议,也是无状态的。这种方式不需要在服务端去保留用户的认证信息或者会话信息。这就意味着基于 token 认证机制的应用不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利。

这个 token 必须要在每次请求时传递给服务端,它应该保存在请求头中,Token Auth 流程如下图:

优点

  • 支持跨域访问
  • Token 机制在服务端不需要存储 session 信息,Token 自身包含了所有登录用户的信息,只需要在客户端的 cookie 或本地介质存储状态信息
  • 去耦:不需要绑定到一个特定的身份验证方案。Token 可以在任何地方生成,只要在你的 API 被调用的时候,你可以进行 Token 生成调用即可
  • 更适用于移动应用:Cookie 是不被客户端(iOS, Android,Windows 8 等)支持的。
  • 基于标准化:API 可以采用标准化的 JSON Web Token (JWT)。这个标准已经存在多个后端库(.NET, Ruby, Java,Python, PHP)和多家公司的支持(如:Firebase,Google, Microsoft)

缺点

  • 占带宽:正常情况下要比 session_id 更大,需要消耗更多流量,挤占更多带宽,假如网站每月有 10 万次的浏览器,就意味着要多开销几十兆的流量。听起来并不多,但日积月累也是不小一笔开销。实际上,许多人会在 JWT 中存储的信息会更多
  • 无法在服务端注销,因为服务端是无状态的,并没有保存客户端用户登录信息
  • 对于有着严格性能要求的 Web 应用并不理想,尤其对于单线程环境

JWT 入门案例

Notes: 权限认证相关的内容详见《授权认证概述》笔记的“JWT 令牌”章节

本案例中会通过 jjwt 工具类库来生成和解析 JWT 令牌。

项目依赖

创建 maven 工程 jwt-demo 并配置 pom.xml 文件引入相关依赖

xml
<dependencies>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
        <version>0.9.1</version>
    </dependency>
    <dependency>
        <groupId>junit</groupId>
        <artifactId>junit</artifactId>
        <scope>test</scope>
        <version>4.12</version>
    </dependency>
    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>5.1.0</version>
    </dependency>
</dependencies>

不使用签名算法

java
@Test
public void test1() {
    // 用于封装 jwt 的 header 部分
    Map<String, Object> header = new HashMap<>();
    header.put("alg", "none"); // 不使用签名算法
    header.put("typ", "JWT"); // 指定令牌的类型,如果是jwt令牌统一都写为JWT

    // 用于封装 jwt 的 body 部分
    Map<String, Object> body = new HashMap<>();
    body.put("userId", "100");
    body.put("account", "admin");
    body.put("role", "admin");

    // 使用 jjwt 提供的 API 生成 jwt 令牌
    String jwt = Jwts.builder()
            .setHeader(header)
            .setClaims(body)
            .setId("101")
            .compact();
    System.out.println(jwt);

    // 使用 jjwt 提供的 API 解析 jwt 令牌
    Jwt result = Jwts.parser().parse(jwt);
    Header header1 = result.getHeader();
    Object body1 = result.getBody();
    System.out.println(header1);
    System.out.println(body1);
}

测试结果:

eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJyb2xlIjoiYWRtaW4iLCJ1c2VySWQiOiIxMDAiLCJhY2NvdW50IjoiYWRtaW4iLCJqdGkiOiIxMDEifQ.
{typ=JWT, alg=none}
{role=admin, userId=100, account=admin, jti=101}

使用 HS256 签名算法

java
// 通过jjwt生成和解析jwt令牌,使用HS256签名算法
@Test
public void test2() {
    // 定义签名密钥
    String key = "MooNkirA";

    // 用于封装 jwt 的 header 部分
    Map<String, Object> header = new HashMap<>();
    header.put("alg", SignatureAlgorithm.HS256.getValue()); // 使用HS256签名算法
    header.put("typ", "JWT"); // 指定令牌的类型,如果是jwt令牌统一都写为JWT

    // 用于封装 jwt 的 body 部分
    Map<String, Object> body = new HashMap<>();
    body.put("userId", "100");
    body.put("account", "admin");
    body.put("role", "admin");

    // 使用 jjwt 提供的 API 生成 jwt 令牌
    String jwt = Jwts.builder()
            .setHeader(header)
            .setClaims(body)
            .setId("101")
            .signWith(SignatureAlgorithm.HS256, key) // 使用加密密钥
            .compact();
    System.out.println(jwt);

    // 使用 jjwt 提供的 API 解析 jwt 令牌
    Jwt result = Jwts.parser()
            .setSigningKey(key) // 设置解密密钥
            .parse(jwt);
    Header header1 = result.getHeader();
    Object body1 = result.getBody();
    System.out.println(header1);
    System.out.println(body1);
}

测试结果:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoiYWRtaW4iLCJ1c2VySWQiOiIxMDAiLCJhY2NvdW50IjoiYWRtaW4iLCJqdGkiOiIxMDEifQ.SeWJQgDGNdW_BKELWM3y9XJkE408jXsZYFZPsQFFZ30
{typ=JWT, alg=HS256}
{role=admin, userId=100, account=admin, jti=101}

使用 RS256 签名算法

测试前需要确认是否已经执行生成密钥对

java
// 生成一对公钥和私钥,用于RS256算法
@Test
public void test3() throws Exception {
    //自定义随机密码(可以任意修改)
    String password = "MoonZero";
    // 保存的地址(可以任意修改)
    String path = "D:\\code\\pinda-authority-project\\pinda-authority\\pd-examples\\jwt-demo\\src\\main\\resources\\";

    KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
    SecureRandom secureRandom = new SecureRandom(password.getBytes());
    keyPairGenerator.initialize(1024, secureRandom);
    // 生成密钥对
    KeyPair keyPair = keyPairGenerator.genKeyPair();

    byte[] publicKeyBytes = keyPair.getPublic().getEncoded();
    byte[] privateKeyBytes = keyPair.getPrivate().getEncoded();
    // 生成公钥
    FileUtil.writeBytes(publicKeyBytes, path + "pub.key");
    // 生成私钥
    FileUtil.writeBytes(privateKeyBytes, path + "pri.key");
}

// 通过jjwt生成和解析jwt令牌,使用RS256签名算法
@Test
public void test4() throws Exception {
    // 用于封装 jwt 的 header 部分
    Map<String, Object> header = new HashMap<>();
    header.put("alg", SignatureAlgorithm.RS256.getValue()); // 使用RS256签名算法
    header.put("typ", "JWT"); // 指定令牌的类型,如果是jwt令牌统一都写为JWT

    // 用于封装 jwt 的 body 部分
    Map<String, Object> body = new HashMap<>();
    body.put("userId", "100");
    body.put("account", "admin");
    body.put("role", "admin");

    // 使用 jjwt 提供的 API 生成 jwt 令牌
    String jwt = Jwts.builder()
            .setHeader(header)
            .setClaims(body)
            .setId("101")
            .signWith(SignatureAlgorithm.RS256, getPriKey()) // 生成jwt令牌时需要使用私钥
            .compact();
    System.out.println(jwt);

    // 使用 jjwt 提供的 API 解析 jwt 令牌
    Jwt result = Jwts.parser()
            .setSigningKey(getPubKey()) // 解密jwt令牌时需要使用公钥
            .parse(jwt);
    Header header1 = result.getHeader();
    Object body1 = result.getBody();
    System.out.println(header1);
    System.out.println(body1);
}

// 获取私钥
public PrivateKey getPriKey() throws Exception {
    InputStream resourceAsStream = this.getClass().getClassLoader().getResourceAsStream("pri.key");
    DataInputStream dis = new DataInputStream(resourceAsStream);
    byte[] keyBytes = new byte[resourceAsStream.available()];
    dis.readFully(keyBytes);
    PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
    KeyFactory kf = KeyFactory.getInstance("RSA");
    return kf.generatePrivate(spec);
}

// 获取公钥
public PublicKey getPubKey() throws Exception {
    InputStream resourceAsStream = this.getClass().getClassLoader().getResourceAsStream("pub.key");
    DataInputStream dis = new DataInputStream(resourceAsStream);
    byte[] keyBytes = new byte[resourceAsStream.available()];
    dis.readFully(keyBytes);
    X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes);
    KeyFactory kf = KeyFactory.getInstance("RSA");
    return kf.generatePublic(spec);
}

测试结果:

eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJyb2xlIjoiYWRtaW4iLCJ1c2VySWQiOiIxMDAiLCJhY2NvdW50IjoiYWRtaW4iLCJqdGkiOiIxMDEifQ.vDywxEzInx4hN4r3dL47_Vigw8RS0J2KI5kvSwIPrCr0F7TiR-VC41dxsNG1oYA4WasoBIPwIwqF9b2yn5lorQO00rvOFwwYiH5eCgwGM0OLD49wttjdfpV_Rz_WJrpOyw7E_QCyr9X1a4_qUExKQXz4gChBudtEkbV2CBu3bbo
{typ=JWT, alg=RS256}
{role=admin, userId=100, account=admin, jti=101}

模块的使用

pd-tools-jwt 底层是基于 jjwt 进行 jwt 令牌的生成和解析的。为了方便使用,在 pd-tools-jwt 模块中封装了两个工具类:JwtTokenServerUtilsJwtTokenClientUtils

  • JwtTokenServerUtils 主要是提供给权限服务的,类中包含生成 jwt 和解析 jwt 两个方法
  • JwtTokenClientUtils 主要是提供给网关服务的,类中只有一个解析 jwt 的方法

需要注意的是 pd-tools-jwt 并不是 starter,所以如果只是在项目中引入其 maven 坐标并不能直接使用其提供的工具类。需要在启动类上加入 pd-tools-jwt 模块中定义的注解 @EnableAuthServer 或者 @EnableAuthClient

pd-tools-jwt 使用的签名算法为 RS256,需要在应用提供一对公钥和私钥,然后在 application.yml 中进行配置即可。

项目依赖

创建 maven 工程 pd-tools-jwt-demo 并配置 pom.xml 文件引入 pd-tools-jwt 的依赖

xml
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.2.RELEASE</version>
    <relativePath/>
</parent>

<dependencies>
    <dependency>
        <groupId>com.moon</groupId>
        <artifactId>pd-tools-jwt</artifactId>
        <version>1.0-SNAPSHOT</version>
        <exclusions>
            <!-- 因为 pd-tools-jwt 模块底层涉及 nacos,此示例实现简单功能,所有将其排除 -->
            <exclusion>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

项目配置

创建 resources/keys 目录,将前面入门案例通过 RSA 算法生成的公钥和私钥复制到此目录下

创建 application.yml 配置文件,根据 pd-tools-jwt 模块中封装的配置实体类,配置相关令牌的属性

yml
# JWT相关配置
authentication:
  user:
    expire: 3600 # 令牌失效时间
    priKey: keys/pri.key # 私钥
    pubKey: keys/pub.key # 公钥

运行测试

创建启动类并在类上标识 @EnableAuthServer 注解,自动注入 JwtTokenServerUtils 工具类实例

java
@SpringBootApplication
@EnableAuthServer // 启用 jwt 服务端认证功能
public class ToolsJwtApplication {
    public static void main(String[] args) {
        SpringApplication.run(ToolsJwtApplication.class, args);
    }
}

编写测试用例

java
package com.moon.examples.tools.test;

import com.moon.pinda.auth.server.utils.JwtTokenServerUtils;
import com.moon.pinda.auth.utils.JwtUserInfo;
import com.moon.pinda.auth.utils.Token;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

// 使用了 junit5 测试
@SpringBootTest
public class JwtToolsTest {

    @Autowired
    private JwtTokenServerUtils jwtTokenServerUtils;

    // 生成与解析 JWT 令牌测试
    @Test
    public void testBasic() {
        // 模拟查询数据库进行用户名密码校验...
        String userName = "admin";
        String password = "admin123";

        // 如果校验通过,则为客户端生成jwt令牌
        JwtUserInfo jwtUserInfo = new JwtUserInfo();
        jwtUserInfo.setName(userName);
        jwtUserInfo.setOrgId(10L);
        jwtUserInfo.setUserId(1L);
        jwtUserInfo.setAccount(userName);
        jwtUserInfo.setStationId(20L);
        Token token = jwtTokenServerUtils.generateUserToken(jwtUserInfo, null);
        System.out.println("*************** token ***************");
        System.out.println(token);

        // 实际应该是在过滤器中进行jwt令牌的解析
        JwtUserInfo userInfo = jwtTokenServerUtils.getUserInfo(token.getToken());
        System.out.println("*************** JwtUserInfo ***************");
        System.out.println(userInfo);
    }
}

测试结果

*************** token ***************
Token(token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxIiwiYWNjb3VudCI6ImFkbWluIiwibmFtZSI6ImFkbWluIiwib3JnaWQiOjEwLCJzdGF0aW9uaWQiOjIwLCJleHAiOjE2NTg0Nzk5NDh9.KBRKeAtAuZpUN14BiwWVYWatAEYlOacbtd5v61sEzsfMVHW7lK1h84sY6m54Zl1mr0bDgYQ-aTPu3vWZ0EMAffWqtTtn0frTwa22pbIIqMS3YkjA144B9atizBTpniW_35fR5mhd9ykELV-ysMWMshBlt8vt5Xv7ANgWDtUifdA, expire=3600)
*************** JwtUserInfo ***************
JwtUserInfo(userId=1, account=admin, name=admin, orgId=10, stationId=20)

pd-tools-user 模块

pd-tools-user 模块的主要功能是自动注入登录人信息。其他应用可以通过本模块提供的 @LoginUser 注解来注入当前系统登录用户。要实现此功能需要使用到 Spring 提供的参数解析器组件。本模块涉及到的技术点:

  1. 参数解析器
  2. 拦截器

参数解析器介绍

参数解析器入门案例

本案例要实现的功能为:通过在 Controller 的方法参数上标识 @CurrentUser 注解来自动注入当前登录用户对象。

项目依赖

创建 maven 工程 argument-resolver-demo 并配置 pom.xml 文件引入相关依赖

java
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.2.RELEASE</version>
    <relativePath/>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
</dependencies>

功能实现

  • 创建用户实体类
java
@Data
@AllArgsConstructor
public class User {
    private Long id;
    private String username;
}
  • 创建 @CurrentUser 注解,用于标识方法形参,获取用户信息
java
@Target({ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CurrentUser {
}
  • 创建参数解析器类,需要实现 HandlerMethodArgumentResolver 接口
java
public class CurrentUserMethodArgumentResolver implements HandlerMethodArgumentResolver {

    /**
     * 判断当前参数是否需要解析
     */
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        // 判断当前 Controller 的方法形参类型是否为 User 并且标识 @CurrentUser 注解
        return parameter.getParameterType().equals(User.class) && parameter.hasParameterAnnotation(CurrentUser.class);
    }

    /**
     * 当 supportsParameter 方法返回 true 时执行此方法进行参数解析绑定
     */
    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        System.out.println("参数解析器方法执行了...");
        // 实际项目中可能需要从请求头中获取登录用户的令牌然后进行解析,最终封装成 User 对象返回。
        // 这里只模拟硬编码返回用户对象,这样在 Controller 的方法形参就可以直接引用到此 User 对象
        return new User(1L, "admin");
    }
}
  • 创建 Spring MVC 的配置类,用于注册自定义参数解析器
java
@Configuration
public class ArgumentResolverConfiguration implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new CurrentUserMethodArgumentResolver());
    }
}

运行测试

  • 创建 UserController,定义一个请求方法,并在形参标识 @CurrentUser 注解
java
@RestController
@RequestMapping(value = "user")
public class UserController {
    // 获取当前系统登录用户
    @GetMapping
    public String getCurrentUser(@CurrentUser User user) {
        System.out.println("UserController getCurrentUser方法..." + user);
        return user.toString();
    }
}

总结:请求方法的 user 对象的属性成功绑定值,这是因为在 Controller 方法的 User 参数前加入了 @CurrentUser 注解,在访问 Controller 的方法时 Spring 框架会调用自定义的参数解析器的 supportsParameter 方法来判断是否执行 resolveArgument 方法,如果 Controller 方法的参数类型为 User 类型并且加入了 @CurrentUser 注解则执行 resolverArgument 方法,此方法的返回结果将赋值给 Controller 方法中声明的 user 参数,即完成了参数绑定。

模块的使用

pd-tools-user 模块的实现与前面的入门案例是一致的,都是通过自定义参数解析器来为 Controller 的方法注入当前登录用户对象。实现思路如下:

  1. 定义 @LoginUser 注解,用于标注在 Controller 的方法参数上
  2. 自定义拦截器,从请求头中获取用户信息并设置到上下文(通过 ThreadLocal 实现)中
  3. 自定义参数解析器,从上下文中获取用户信息并封装为 SysUser 对象给 Controller 的方法参数
  4. 定义配置类,用于注册自定义拦截器和参数解析器

Notes: pd-tools-user 模块并不是 starter,所以如果要使用其提供的功能,需要在应用的启动类上加入 @EnableLoginArgResolver 注解。

项目依赖

创建 maven 工程 pd-tools-user-demo 并配置 pom.xml 文件引入 pd-tools-user 的依赖

xml
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.2.RELEASE</version>
    <relativePath/>
</parent>

<dependencies>
    <dependency>
        <groupId>com.moon</groupId>
        <artifactId>pd-tools-user</artifactId>
        <version>1.0-SNAPSHOT</version>
        <exclusions>
            <!-- 因为 pd-tools-user 模块底层涉及 nacos,此示例实现简单功能,所有将其排除 -->
            <exclusion>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

开启与注入用户

  • 编写启动类,并在类上使用 @EnableLoginArgResolver 注解开启自动登录用户对象注入
java
@SpringBootApplication
@EnableLoginArgResolver // 开启自动登录用户对象注入
public class UserToolsApplication {
    public static void main(String[] args) {
        SpringApplication.run(UserToolsApplication.class, args);
    }
}
  • 创建 UserController,定义一个请求方法,并在形参标识 @LoginUser 注解
java
@RestController
@RequestMapping(value = "user")
public class UserController {
    // 获取当前系统登录用户
    @GetMapping
    public SysUser getCurrentUser(@LoginUser SysUser user) {
        System.out.println("UserController getCurrentUser方法..." + user);
        return user;
    }
}

运行测试

启动项目,因为 pd-tools-user 模块需要从请求头中获取用户信息,所以需要使用 postman 请求测试:

Tips: 可以通过 debug 断点调试的方式来跟踪程序的执行过程。

pd-tools-j2cache 模块

j2cache 介绍

j2cache 介绍与其他示例详见《Spring Boot 实现缓存功能》笔记的“Spring Boot 整合 j2cache 缓存”章节

j2cache 入门案例

项目依赖

创建 maven 工程 j2cache-demo 并配置 pom.xml 文件引入相关依赖

xml
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.2.RELEASE</version>
    <relativePath/>
</parent>

<dependencies>
    <dependency>
        <groupId>net.oschina.j2cache</groupId>
        <artifactId>j2cache-spring-boot2-starter</artifactId>
        <version>2.8.0-release</version>
    </dependency>
    <dependency>
        <groupId>net.oschina.j2cache</groupId>
        <artifactId>j2cache-core</artifactId>
        <version>2.8.0-release</version>
        <exclusions>
            <exclusion>
                <groupId>org.slf4j</groupId>
                <artifactId>slf4j-simple</artifactId>
            </exclusion>
            <exclusion>
                <groupId>org.slf4j</groupId>
                <artifactId>slf4j-api</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
    </dependency>
</dependencies>

j2cache 配置

  • 创建 application.yml,配置 j2cache 相关内容
yml
# redis 通用配置, 不同的环境,需要配置不同的链接信息,
# 只需要将这段信息复制到具体环境的配置文件中进行修改即可
# 如:复制到pd-auth-server-dev.yml中将数据库名和ip改掉
pinda:
  redis:
    ip: 127.0.0.1
    port: 6379
    password:
    database: 0

spring:
  cache:
    type: GENERIC
  redis:
    host: ${pinda.redis.ip}
    password: ${pinda.redis.password}
    port: ${pinda.redis.port}
    database: ${pinda.redis.database}

j2cache:
  #  config-location: /j2cache.properties
  open-spring-cache: true
  cache-clean-mode: passive
  allow-null-values: true
  redis-client: lettuce #指定redis客户端使用lettuce,也可以使用Jedis
  l2-cache-open: true #开启二级缓存
  broadcast: net.oschina.j2cache.cache.support.redis.SpringRedisPubSubPolicy
  #  broadcast: jgroups
  L1: #指定一级缓存提供者为caffeine
    provider_class: caffeine
  L2: #指定二级缓存提供者为redis
    provider_class: net.oschina.j2cache.cache.support.redis.SpringRedisProvider
    config_section: lettuce
  sync_ttl_to_redis: true
  default_cache_null_object: false
  serialization: fst
caffeine:
  properties: /caffeine.properties # 这个配置文件需要放在项目中
lettuce:
  mode: single
  namespace:
  storage: generic
  channel: j2cache
  scheme: redis
  hosts: ${pinda.redis.ip}:${pinda.redis.port}
  password: ${pinda.redis.password}
  database: ${pinda.redis.database}
  sentinelMasterId:
  maxTotal: 100
  maxIdle: 10
  minIdle: 10
  timeout: 10000

Tips:j2cache 可以有其独立的配置文件格式,所以也可以在项目 application.yml 文件中配置 j2cache 独立配置文件的位置即可

  • 在 resources 目录下创建 caffeine.properties 配置文件
properties
#########################################
# Caffeine configuration
# [name] = size, xxxx[s|m|h|d]
#########################################
default=2000, 2h
rx=50, 2h
rx2=100, 3d

运行测试

创建启动类,并编写测试用例

java
@SpringBootTest
public class J2cacheTest {

    private final String key = "myKey";
    private final String region = "rx";

    // 注入缓存操作对象
    @Autowired
    private CacheChannel cacheChannel;

    @Test
    public void getInfos() {
        // 从缓存中获取数据,需要指定区域region和key
        CacheObject cacheObject = cacheChannel.get(region, key);
        List<String> data = new ArrayList<>();

        if (cacheObject.getValue() == null) {
            // 缓存中没有找到,查询数据库获得
            data.add("info1");
            data.add("info2");
            // 放入缓存
            cacheChannel.set(region, key, data);
        } else {
            // 获取缓存的数据
            data = (List<String>) cacheObject.getValue();
        }

        System.out.println(data);
        // 用于测试查看是否从一级缓存中获取
        // int level = cacheChannel.check(region, key);
        // System.out.println("level: " + level);
    }

    // 清理指定缓存
    @Test
    public void evict() {
        cacheChannel.evict(region, key);
        System.out.println("evict success");
    }

    // 清理指定区域中的所有缓存
    @Test
    public void clear() {
        cacheChannel.clear(region);
        System.out.println("clear success");
    }

    // 检查指定的缓存数据是否存在
    @Test
    public void exists() {
        boolean exists = cacheChannel.exists(region, key);
        System.out.println("exists: " + exists);
    }

    // 检查指定的缓存数据是从哪一级缓存获取到的
    @Test
    public void check() {
        int level = cacheChannel.check(region, key);
        System.out.println("level: " + level);
    }
}

Tips: 由于当前第二级缓存使用的是 redis,所以需要启动 redis 服务才能正常运行入门案例。

运行测试用例,可以发现 redis 中已经缓存了数据:

由于 j2cache 的一级缓存(caffeine)是进程级缓存,重启应用或者测试用例结束后一级缓存消失。但是二级缓存(redis)的数据还存在,通过 debug 断点调试可以看到程序从 redis 中获取了缓存数据。

因为使用测试用例的方式,所以不好查看一级与二级缓存的效果,只能执行 getInfos 测试方法结束前直接调用 API 查看当前缓存级别,运行完后,再执行 check 测试方法,查看当前缓存级别

模块的使用

pd-tools-j2cache 其实就是一个 starter,直接引入其 maven 坐标并配置 j2cache 的配置文件就可以将 CacheChannel 对象直接注入到程序中进行缓存数据操作了。

具体使用过程和入门案例一致,只需要更换 j2cache 的 maven 坐标为 pd-tools-j2cache 坐标即可。

其他模块简介

pd-tools-core 模块

pd-tools-core 是所有模块的基础,定义了一些基础父类供其他模块继承。

pd-tools-common 模块

pd-tools-common 模块中定义了一些公共类,例如 BaseConfig 基础配置类、DefaultGlobalExceptionHandler 全局异常处理类、各种类型转换器等。

异常处理介绍

软件开发过程中不可避免的需要处理各种异常,代码中会出现大量的try {...} catch {...} finally {...} 代码块,不仅有大量的冗余代码,而且还影响代码的可读性。

Spring 从 3.2 版本开始增加了一个注解 @ControllerAdvice,可以与 @ExceptionHandler@InitBinder@ModelAttribute 等注解配套使用,可以统一进行异常处理。

异常处理入门案例

注:此章节只是笔记,无写源码

  • 第一步:创建 maven 工程 exception-handler-demo 并配置 pom.xml 文件引入相关依赖
xml
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.2.RELEASE</version>
    <relativePath/>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>
  • 第二步:编写 UserController
java
@RestController
@RequestMapping("/user")
public class UserController {
    @GetMapping("/get")
    public String get(){
        int i = 1 / 0;
        return "success";
    }
}

可以看到异常信息直接显示到了页面上,接下来需要进行异常处理。

  • 第四步:创建异常处理类,统一进行异常处理
java
/**
 * 全局异常处理
 */
@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {
    // 异常处理方法,Controller发生异常后会执行此方法,在此进行统一处理
    @ExceptionHandler(Exception.class)
    public String handleException(Exception e){
        System.out.println("统一处理异常信息:" + e.getMessage());
        return "系统错误";
    }
}

重新启动项目,访问地址:http://localhost:9000/user/get

可以看到页面中不再显示异常信息,而是展示在异常处理类中返回的提示信息。

模块的使用

基于前面入门案例的基础上做简单修改即可。

  • 第一步:修改 pom.xml 文件,引入 pd-tools-common 的 maven 坐标
xml
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.2.RELEASE</version>
    <relativePath/>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>com.moon</groupId>
        <artifactId>pd-tools-common</artifactId>
        <version>1.0-SNAPSHOT</version>
        <exclusions>
            <exclusion>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
</dependencies>
  • 第二步:修改全局异常处理类,只需要继承 pd-tools-common 中提供的抽象异常处理类 DefaultGlobalExceptionHandler 即可
java
/**
 * 全局异常处理
 */
@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler extends DefaultGlobalExceptionHandler {
}

重新启动项目,访问地址:http://localhost:9000/user/get

pd-tools-databases 模块

pd-tools-databases 模块中提供的都是跟数据库操作相关的类。其他模块可以直接引入 maven 坐标并继承相关父类就可以复用其提供的基础配置。

I know where dry desert ends, green grass grows · MooN