1 Star 7 Fork 3

十八 / springboot3-study

加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
克隆/下载
贡献代码
同步代码
取消
提示: 由于 Git 不支持空文件夾,创建文件夹后会生成空的 .keep 文件
Loading...
README
MIT

源码地址:https://gitee.com/sparklers/springboot3-study

依赖版本

依赖 版本 描述
Java(JDK) 17 Java开发环境
Spring Boot 3.2.0 Spring Boot版本
MySQL 8.x 数据库
Redisson 3.25.0 Redis工具库
MyBaits-Plus 3.5.4.1 Mybatis 增强工具
java-jwt 4.4.0 JWT 工具库
SpringDoc 2.3.0 接口文档

集成项

  • 集成 MyBatis-Plus
  • 集成 Redis 【Redisson】
  • 集成 Spring Security + JWT
  • 日志框架定制
  • 集成SpringDoc 接口文档

集成MyBatis-Plus

  1. 导入依赖
<properties>
    <mybatis-spring.version>3.0.3</mybatis-spring.version>
    <mybatis-plus.version>3.5.4.1</mybatis-plus.version>
</properties>
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>${mybatis-plus.version}</version>
        <exclusions>
            <exclusion>
                <groupId>org.mybatis</groupId>
                <artifactId>mybatis-spring</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
    <dependency>
        <groupId>org.mybatis</groupId>
        <artifactId>mybatis-spring</artifactId>
        <version>${mybatis-spring.version}</version>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
</dependencies>
  1. 编写application.yml配置文件
# ======== 数据源配置 ========
spring:
  datasource:
    driver-class-name: org.h2.Driver
    username: root
    password: test
  sql:
    init:
      schema-locations: classpath:db/schema-h2.sql
      data-locations: classpath:db/data-h2.sql

# ======== MybatisPlus配置 ========
mybatis-plus:
  # 搜索指定包别名
  type-aliases-package: com.yiyan.study.domain
  # 配置mapper的扫描,找到所有的mapper.xml映射文件
  mapper-locations: classpath:mapper/**/*xml
  # 配置驼峰
  configuration:
    # 是否开启自动驼峰命名规则(camel case)映射。默认值:true
    map-underscore-to-camel-case: true
    # 开启 Mybatis 二级缓存,默认为 true。
    cache-enabled: true
    # 日志输出实现。StdOutImpl 标准日志控制台输出
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    db-config:
      # 全局默认主键类型
      id-type: assign_id
      # 表名是否使用驼峰转下划线命名,只对表名生效。 默认true
      table-underline: true
  1. 编写mybatis plus文件
-- data-h2.sql
DELETE
FROM `user`;

INSERT INTO `user` (id, name, age, email)
VALUES (1, 'Jone', 18, 'test1@baomidou.com'),
       (2, 'Jack', 20, 'test2@baomidou.com'),
       (3, 'Tom', 28, 'test3@baomidou.com'),
       (4, 'Sandy', 21, 'test4@baomidou.com'),
       (5, 'Billie', 24, 'test5@baomidou.com');
       

-- schema-h2.sql
DROP TABLE IF EXISTS `user`;

CREATE TABLE `user`
(
    id    BIGINT      NOT NULL COMMENT '主键ID',
    name  VARCHAR(30) NULL DEFAULT NULL COMMENT '姓名',
    age   INT         NULL DEFAULT NULL COMMENT '年龄',
    email VARCHAR(50) NULL DEFAULT NULL COMMENT '邮箱',
    PRIMARY KEY (id)
);

实体类

import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

import java.io.Serial;
import java.io.Serializable;

@TableName(value = "`user`")
@Data
public class User implements Serializable {

    @TableId
    private Long id;
    private String name;
    private Integer age;
    private String email;

    @Serial
    @TableField(exist = false)
    private static final long serialVersionUID = 1L;
}

DAO(mapper)

import com.yiyan.study.domain.User;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface IUserDao extends BaseMapper<User> {

}

Service

import com.yiyan.study.domain.User;
import com.baomidou.mybatisplus.extension.service.IService;

public interface IUserService extends IService<User> {

}

ServiceImpl

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yiyan.study.domain.User;
import com.yiyan.study.service.IUserService;
import com.yiyan.study.dao.IUserDao;
import org.springframework.stereotype.Service;

@Service
public class UserServiceImpl extends ServiceImpl<IUserDao, User>
    implements IUserService{

}

mapper.xml

<?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="com.yiyan.study.dao.IUserDao">

</mapper>
  1. 编写测试用例
package com.yiyan.study;

import com.yiyan.study.dao.IUserDao;
import com.yiyan.study.domain.User;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

@Slf4j
@SpringBootTest
@DisplayName("MyBatis Plus Test")
public class MyBatisPlusTest {

    @Autowired
    private IUserDao userDao;

    @Test
    @DisplayName("MyBatis Plus Select Test")
    public void testSelect() {
        log.info(("----- selectAll method test ------"));
        List<User> userList = userDao.selectList(null);
        for (User user : userList) {
            log.info(user.toString());
        }
    }

    @Test
    @DisplayName("MyBatis Plus Insert Test")
    public void testInsert() {
        log.info(("----- insert method test ------"));
        User user = new User();
        user.setName("Alex");
        user.setAge(18);
        user.setEmail("alex@gmail.com");
        int result = userDao.insert(user);
        log.info("insert result: {}", result);
        User selectedById = userDao.selectById(user.getId());
        log.info("insert user info: {}", selectedById);
    }

    @Test
    @DisplayName("MyBatis Plus Update Test")
    public void testUpdate() {
        log.info(("----- update method test ------"));
        User user = userDao.selectList(null).get(0);
        log.info("before update: {}", user);
        user.setName("Updated Name");
        int result = userDao.updateById(user);
        log.info("update result: {}", result);
        User selectedById = userDao.selectById(user.getId());
        log.info("after update: {}", selectedById);
    }

    @Test
    @DisplayName("MyBatis Plus Delete Test")
    public void testDelete() {
        log.info("----- delete method test ------");
        List<User> users = userDao.selectList(null);
        log.info("before delete user list size: {}", users.size());
        User user = users.get(0);
        userDao.deleteById(user.getId());
        log.info("delete user id: {}", user.getId());
        List<User> usersAfterDelete = userDao.selectList(null);
        log.info("after delete user list size: {}", usersAfterDelete.size());
    }
}

springboot3-mybatis-plus

集成Redis【Redisson】

  1. 导入依赖
<properties>
    <redisson.version>3.25.0</redisson.version>
</properties>
<dependencies>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>
    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson-spring-boot-starter</artifactId>
        <version>${redisson.version}</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
  1. 编写配置文件
# application.yml
spring:
  # ======== Redis配置 ========
  redis:
    redisson:
      file: classpath:redisson.yaml
# redisson.yaml

# 编码。默认值: org.redisson.codec.JsonJacksonCodec
codec: !<org.redisson.codec.Kryo5Codec> {}
# 线程池数量。默认值: 当前处理核数量 * 2
threads: 16
# Netty线程池数量。默认值: 当前处理核数量 * 2
nettyThreads: 32
# 传输模式。默认值: NIO
transportMode: "NIO"
# 监控锁的看门狗超时,单位:毫秒。默认值: 30000
lockWatchdogTimeout: 30000
# 是否保持订阅发布顺序。默认值: true
keepPubSubOrder: true

# Redisson 单实例配置
singleServerConfig:
  # 节点地址。格式:redis://host:port
  address: "redis://127.0.0.1:6379"
  # 密码。默认值: null
  password: null
  # 数据库编号。默认值: 0
  database: 0
  # 客户端名称(在Redis节点里显示的客户端名称)。默认值: null
  clientName: null
  # 连接超时,单位:毫秒。默认值: 10000
  connectTimeout: 10000
  # 命令等待超时,单位:毫秒。默认值: 3000
  timeout: 3000
  # 命令失败重试次数。默认值: 3
  retryAttempts: 3
  # 命令重试发送时间间隔,单位:毫秒。默认值: 1500
  retryInterval: 1500
  # 最小空闲连接数。默认值: 32
  connectionMinimumIdleSize: 24
  # 连接池大小。默认值: 64
  connectionPoolSize: 64
  # 单个连接最大订阅数量。默认值: 5
  subscriptionsPerConnection: 5
  # 发布和订阅连接的最小空闲连接数。默认值: 1
  subscriptionConnectionMinimumIdleSize: 1
  # 发布和订阅连接池大小。默认值: 50
  subscriptionConnectionPoolSize: 50
  # DNS监测时间间隔,单位:毫秒。默认值: 5000
  dnsMonitoringInterval: 5000
  # 连接空闲超时,单位:毫秒。默认值: 10000
  idleConnectionTimeout: 10000
  1. 编写Redis操作工具
package com.yiyan.study;

import lombok.RequiredArgsConstructor;
import org.redisson.api.RAtomicDouble;
import org.redisson.api.RAtomicLong;
import org.redisson.api.RBucket;
import org.redisson.api.RList;
import org.redisson.api.RMap;
import org.redisson.api.RScoredSortedSet;
import org.redisson.api.RSet;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;

/**
 * Redis工具类
 */
@Component
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class RedisService {

    private final RedissonClient redissonClient;

    // ============================= String类型操作 ============================

    /**
     * 将值存储到Redis中
     *
     * @param key   键
     * @param value 值
     */
    public <T> void setString(String key, T value) {
        RBucket<T> bucket = redissonClient.getBucket(key);
        bucket.set(value);
    }

    /**
     * 将值存储到Redis中
     *
     * @param key      键
     * @param value    值
     * @param timeout  过期时间
     * @param timeUnit 时间单位
     */
    public <T> void setString(String key, T value, long timeout, TimeUnit timeUnit) {
        RBucket<T> bucket = redissonClient.getBucket(key);
        bucket.set(value, timeout, timeUnit);
    }

    /**
     * 根据键获取Redis中的值
     *
     * @param key 键
     * @return 值
     */
    public <T> T getString(String key) {
        RBucket<T> bucket = redissonClient.getBucket(key);
        return bucket.get();
    }

    // ============================= Hash类型操作 ============================

    /**
     * 将值存储到Redis中
     *
     * @param key   键
     * @param field hash键
     * @param value 值
     */
    public <T> boolean addToHash(String key, Object field, T value) {
        RMap<Object, T> hash = redissonClient.getMap(key);
        return hash.fastPut(field, value);
    }

    /**
     * 将值存储到Redis中
     *
     * @param key      键
     * @param field    hash键
     * @param value    值
     * @param timeout  过期时间
     * @param timeUnit 时间单位
     */
    public <T> boolean addToHash(String key, Object field, T value, long timeout, ChronoUnit timeUnit) {
        RMap<Object, T> hash = redissonClient.getMap(key);
        boolean fastPut = hash.fastPut(field, value);
        boolean expire = hash.expire(Instant.now().plus(timeout, timeUnit));
        return fastPut && expire;
    }

    /**
     * 根据键和Hash键获取Redis中的值
     *
     * @param key   键
     * @param field hash键
     * @return 值
     */
    public <T> T getFromHash(String key, Object field) {
        RMap<Object, T> hash = redissonClient.getMap(key);
        return hash.get(field);
    }

    /**
     * 根据键获取Redis中的值
     *
     * @param key 键
     * @return 值
     */
    public <T> Map<Object, T> getFromHash(String key) {
        RMap<Object, T> hash = redissonClient.getMap(key);
        return hash.readAllMap();
    }

    /**
     * 根据键和Hash键更新Redis中的值
     *
     * @param key   键
     * @param field hash键
     * @param value 值
     * @return 更新成功返回true,否则返回false
     */
    public <T> boolean updateToHash(String key, Object field, T value) {
        RMap<Object, T> hash = redissonClient.getMap(key);
        return hash.fastReplace(field, value);
    }

    /**
     * 根据Key,删除Hash类型的数据
     *
     * @param key      键
     * @param hashKeys hash键
     * @return 删除成功的数量
     */
    public <T> long removeFromHash(String key, T... hashKeys) {
        RMap<Object, T> hash = redissonClient.getMap(key);
        return hash.fastRemove(hashKeys);
    }

    // ============================= List类型操作 ============================

    /**
     * 向List数据类型中添加值
     *
     * @param key   键
     * @param value 值
     */
    public <T> boolean addToList(String key, T value) {
        RList<T> list = redissonClient.getList(key);
        return list.add(value);
    }

    /**
     * 向List数据类型中添加值
     *
     * @param key   键
     * @param value 值
     */
    public <T> boolean addToList(String key, List<T> value) {
        RList<T> list = redissonClient.getList(key);
        return list.addAll(value);
    }

    /**
     * 向List数据类型中添加值
     *
     * @param key      键
     * @param value    值
     * @param timeout  过期时间
     * @param timeUnit 时间单位
     */
    public <T> boolean addToList(String key, T value, long timeout, ChronoUnit timeUnit) {
        RList<T> list = redissonClient.getList(key);
        list.add(value);
        return list.expire(Instant.now().plus(timeout, timeUnit));
    }

    /**
     * 从List数据类型中获取值
     *
     * @param key   键
     * @param start 起始位置
     * @param end   结束位置
     * @return 值
     */
    public <T> List<T> getFromList(String key, int start, int end) {
        RList<T> list = redissonClient.getList(key);
        return list.range(start, end);
    }

    /**
     * 获取List数据类型中的所有值
     *
     * @param key 键
     * @return 值
     */
    public <T> List<T> getFromList(String key) {
        RList<T> list = redissonClient.getList(key);
        return list.readAll();
    }


    /**
     * 移除集合左侧第一个元素
     *
     * @param key 键
     */
    public void removeListLeft(String key) {
        RList<Object> list = redissonClient.getList(key);
        list.fastRemove(0);
    }

    /**
     * 移除集合右侧第一个元素
     *
     * @param key 键
     */
    public void removeListRight(String key) {
        RList<Object> list = redissonClient.getList(key);
        list.fastRemove(list.size() - 1);
    }

    /**
     * 移除集合指定位置元素
     *
     * @param key   键
     * @param index 索引
     */
    public void removeFromList(String key, int index) {
        RList<Object> list = redissonClient.getList(key);
        list.fastRemove(index);
    }

    /**
     * 移除集合指定元素
     *
     * @param key   键
     * @param value 值
     */
    public <T> boolean removeFromList(String key, T value) {
        RList<T> list = redissonClient.getList(key);
        return list.removeIf(o -> o.equals(value));
    }

    // ============================= Set类型操作 ============================

    /**
     * 添加值到Set数据类型中
     *
     * @param key   键
     * @param value 值
     */
    public <T> boolean addToSet(String key, T value) {
        RSet<T> set = redissonClient.getSet(key);
        return set.add(value);
    }

    /**
     * 添加值到Set数据类型中
     *
     * @param key      键
     * @param value    值
     * @param timeout  过期时间
     * @param timeUnit 时间单位
     * @return 是否成功
     */
    public <T> boolean addToSet(String key, T value, long timeout, ChronoUnit timeUnit) {
        RSet<T> set = redissonClient.getSet(key);
        boolean add = set.add(value);
        boolean expire = set.expire(Instant.now().plus(timeout, timeUnit));
        return add && expire;
    }

    /**
     * 添加值到Set数据类型中
     *
     * @param key    键
     * @param values 值
     * @return 是否成功
     */
    public <T> boolean addToSet(String key, List<T> values) {
        RSet<T> set = redissonClient.getSet(key);
        return set.addAll(values);
    }

    /**
     * 添加值到Set数据类型中
     *
     * @param key      键
     * @param values   值
     * @param timeout  过期时间
     * @param timeUnit 时间单位
     * @return 是否成功
     */
    public <T> boolean addToSet(String key, List<T> values, long timeout, ChronoUnit timeUnit) {
        RSet<T> set = redissonClient.getSet(key);
        set.addAllCounted(values);
        return set.expire(Instant.now().plus(timeout, timeUnit));
    }


    /**
     * 获取Set的所有元素。
     *
     * @param key 键
     * @return 所有值
     */
    public <T> Set<T> getFromSet(String key) {
        RSet<T> set = redissonClient.getSet(key);
        return set.readAll();
    }

    /**
     * 从Set数据类型中删除值
     *
     * @param key    键
     * @param values 值
     */
    public <T> void removeFromSet(String key, List<T> values) {
        RSet<T> set = redissonClient.getSet(key);
        values.forEach(set::remove);
    }

    /**
     * 从Set数据类型中删除值
     *
     * @param key   键
     * @param value 值
     */
    public <T> boolean removeFromSet(String key, T value) {
        RSet<T> set = redissonClient.getSet(key);
        return set.remove(value);
    }

    // ============================= ZSet类型操作 ============================

    /**
     * 添加值到ZSet数据类型中
     *
     * @param key   键
     * @param value 值
     * @param score 分值
     */
    public <T> void addToZSet(String key, T value, double score) {
        RScoredSortedSet<T> sortedSet = redissonClient.getScoredSortedSet(key);
        sortedSet.add(score, value);
    }

    /**
     * 在ZSet数据类型中添加值
     *
     * @param key      键
     * @param value    值
     * @param score    分值
     * @param timeout  过期时间
     * @param timeUnit 时间单位
     */
    public <T> void addToZSet(String key, T value, double score, long timeout, ChronoUnit timeUnit) {
        RScoredSortedSet<T> sortedSet = redissonClient.getScoredSortedSet(key);
        sortedSet.add(score, value);
        sortedSet.expire(Instant.now().plus(timeout, timeUnit));
    }

    /**
     * 获取ZSet的范围元素。
     *
     * @param key   键
     * @param start 起始位置
     * @param end   结束位置
     * @return Set类型的值
     */
    public <T> Set<Object> getFromZSet(String key, int start, int end) {
        RScoredSortedSet<T> sortedSet = redissonClient.getScoredSortedSet(key);
        return new HashSet<>(sortedSet.valueRange(start, end));
    }

    /**
     * 删除ZSet数据类型中的值
     *
     * @param key    键
     * @param values 值
     */
    public <T> void removeFromZSet(String key, List<T> values) {
        RScoredSortedSet<T> sortedSet = redissonClient.getScoredSortedSet(key);
        sortedSet.removeAll(values);
    }

    /**
     * 删除ZSet数据类型中的值
     *
     * @param key   键
     * @param value 值
     */
    public <T> void removeFromZSet(String key, T value) {
        RScoredSortedSet<T> sortedSet = redissonClient.getScoredSortedSet(key);
        sortedSet.remove(value);
    }

    // ============================= Common ============================

    /**
     * 判断Key是否存在
     *
     * @param key 键
     * @return 存在返回true,否则返回false
     */
    public boolean exists(String key) {
        return redissonClient.getBucket(key).isExists();
    }

    /**
     * 删除Key
     *
     * @param key 键
     */
    public boolean remove(String key) {
        long delete = redissonClient.getKeys().delete(key);
        return delete > 0;
    }

    /**
     * 设置Key的过期时间
     *
     * @param key      键
     * @param timeout  过期时间
     * @param timeUnit 时间单位
     * @return 设置成功返回true,否则返回false
     */
    public boolean expire(String key, long timeout, ChronoUnit timeUnit) {
        return redissonClient.getBucket(key).expire(Instant.now().plus(timeout, timeUnit));
    }

    /**
     * 获取Key的过期时间
     *
     * @param key 键
     * @return 过期时间
     */
    public Long getExpire(String key) {
        return redissonClient.getBucket(key).getExpireTime();
    }

    /**
     * 递增操作
     *
     * @param key   键
     * @param delta 增加的值
     * @return 递增后的值,如果键不存在,则返回-1
     */
    public long increment(String key, long delta) {
        RAtomicLong atomicLong = redissonClient.getAtomicLong(key);
        return atomicLong.addAndGet(delta);
    }

    /**
     * 递减操作
     *
     * @param key   键
     * @param delta 减少的值
     * @return 递减后的值,如果键不存在,则返回-1
     */
    public long decrement(String key, long delta) {
        RAtomicLong atomicLong = redissonClient.getAtomicLong(key);
        return atomicLong.decrementAndGet();
    }

    /**
     * 递增操作
     *
     * @param key   键
     * @param delta 增加的值
     * @return 递增后的值,如果键不存在,则返回-1
     */
    public double increment(String key, double delta) {
        RAtomicDouble atomicDouble = redissonClient.getAtomicDouble(key);
        return atomicDouble.addAndGet(delta);
    }

    /**
     * 递减操作
     *
     * @param key   键
     * @param delta 减少的值
     * @return 递减后的值,如果键不存在,则返回-1
     */
    public double decrement(String key, double delta) {
        RAtomicDouble atomicDouble = redissonClient.getAtomicDouble(key);
        return atomicDouble.decrementAndGet();
    }
}
  1. 编写测试用例
package com.yiyan.study;

import jakarta.annotation.Resource;
import jodd.util.ThreadUtil;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.util.Lists;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import java.time.LocalDateTime;
import java.util.List;

@Slf4j
@SpringBootTest
@DisplayName("Redis 集成测试类")
public class RedisApplicationTest {

    @Resource
    private RedisService redisService;

    private static final String STRING_KEY = "redis:string";
    private static final String LIST_KEY = "redis:list";
    private static final String SET_KEY = "redis:set";
    private static final String HASH_KEY = "redis:hash";

    @Test
    @DisplayName("Redis String 数据类型测试")
    public void redisStringTest() {
        log.info("Redis String 数据类型测试");
        redisService.setString(STRING_KEY, LocalDateTime.now().toString());
        String redisGetStringData = redisService.getString(STRING_KEY);
        log.info("Redis String Get:{}", redisGetStringData);
        boolean remove = redisService.remove(STRING_KEY);
        log.info("Redis String Remove:{}", remove);
        redisGetStringData = redisService.getString(STRING_KEY);
        log.info("Redis String Get After Delete:{}", redisGetStringData);
    }

    @Test
    @DisplayName("Redis List 数据类型测试")
    public void redisListTest() {
        log.info("Redis List 数据类型测试");
        // 填充数据
        List<Integer> list = Lists.newArrayList();
        for (int i = 0; i < 5; i++) {
            list.add(LocalDateTime.now().getNano());
            ThreadUtil.sleep(5);
        }

        boolean addItemResult = redisService.addToList(LIST_KEY, LocalDateTime.now().getNano());
        log.info("Redis List Add item:{}", addItemResult);
        redisService.getFromList(LIST_KEY)
                .forEach(s -> log.info("Redis List Get After Add Item:{}", s));

        boolean addListDataResult = redisService.addToList(LIST_KEY, list);
        log.info("Redis List Add List:{}", addListDataResult);
        redisService.getFromList(LIST_KEY)
                .forEach(s -> log.info("Redis List Get After Add List:{}", s));

        redisService.getFromList(LIST_KEY, 0, 2)
                .forEach(s -> log.info("Redis List Get By Index:{}", s));

        log.info("Redis List Size Before Delete:{}", redisService.getFromList(LIST_KEY).size());
        redisService.removeFromList(LIST_KEY, 0);
        log.info("Redis List Size After Delete:{}", redisService.getFromList(LIST_KEY).size());

        boolean remove = redisService.remove(LIST_KEY);
        log.info("Redis List Remove:{}", remove);
    }

    @Test
    @DisplayName("Redis Set 数据类型测试")
    public void redisSetTest() {
        log.info("Redis Set 数据类型测试");
        // 填充数据
        List<Integer> list = Lists.newArrayList();
        for (int i = 0; i < 5; i++) {
            list.add(LocalDateTime.now().getNano());
            ThreadUtil.sleep(5);
        }

        boolean addItemResult = redisService.addToSet(SET_KEY, LocalDateTime.now().getNano());
        log.info("Redis Set Add item:{}", addItemResult);
        redisService.getFromSet(SET_KEY)
                .forEach(s -> log.info("Redis Set Get After Add Item:{}", s));

        boolean addListDataResult = redisService.addToSet(SET_KEY, list);
        log.info("Redis Set Add List:{}", addListDataResult);
        redisService.getFromSet(SET_KEY)
                .forEach(s -> log.info("Redis Set Get After Add List:{}", s));

        log.info("Redis Set Size Before Delete:{}", redisService.getFromSet(SET_KEY).size());
        redisService.removeFromSet(SET_KEY, LocalDateTime.now().getNano());
        log.info("Redis Set Size After Delete:{}", redisService.getFromSet(SET_KEY).size());

        boolean remove = redisService.remove(SET_KEY);
        log.info("Redis Set Remove:{}", remove);
    }

    @Test
    @DisplayName("Redis Hash 数据类型测试")
    public void redisHashTest() {
        log.info("Redis Hash 数据类型测试");
        Integer key = LocalDateTime.now().getNano();

        boolean addItemResult = redisService
                .addToHash(HASH_KEY, key, LocalDateTime.now().toString());
        log.info("Redis Hash Add item:{}", addItemResult);
        redisService.getFromHash(HASH_KEY)
                .forEach((k, v) -> log.info("Redis Hash Get After Add Item:{} - {}", k, v.toString()));

        log.info("Redis Hash Get By Key:{}", redisService.getFromHash(HASH_KEY, key).toString());

        log.info("Redis Hash Size Before Delete:{}", redisService.getFromHash(HASH_KEY).size());
        redisService.removeFromHash(HASH_KEY, key);
        log.info("Redis Hash Size After Delete:{}", redisService.getFromHash(HASH_KEY).size());

        boolean remove = redisService.remove(HASH_KEY);
        log.info("Redis Hash Remove:{}", remove);
    }
}

springboot3-redis

Spring Security 实现动态鉴权

依赖版本

  • JDK 17
  • Spring Boot 3.2.0
  • Spring Security 6.2.0

为了能够不需要额外配置就能启动项目,看到配置效果。用例采用模拟数据,可自行修改为对应的ORM操作

编写Spring Security基础配置

导入依赖

<properties>
    <java-jwt.version>4.4.0</java-jwt.version>
    <guava.version>33.0.0-jre</guava.version>
</properties>

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

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</dependency>
<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>${java-jwt.version}</version>
</dependency>
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>${guava.version}</version>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
</dependency>
</dependencies>

测试Spring Security

默认配置下,Spring Security form表单登录的用户名为user,密码启动时在控制台输出。

编写测试Controller

package com.yiyan.study.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * 测试接口
 */
@RestController
public class SecurityController {

    @GetMapping("/hello")
    public String hello() {
        return "hello spring security";
    }
}

访问接口测试

springboot3-security-default-test

编写Spring Security基础文件

创建Spring Security模拟数据

package com.yiyan.study.config;

import lombok.Getter;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.access.SecurityConfig;

import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * Spring Security 模拟数据
 */
public class SecurityConstant {
    /**
     * 模拟用户数据。key:用户名,value:密码
     */
    public static final Map<String, String> USER_MAP = new ConcurrentHashMap<>();
    /**
     * 模拟权限数据。key:接口地址,value:所需权限
     */
    public static final Map<String, ConfigAttribute> PERMISSION_MAP = new ConcurrentHashMap<>();
    /**
     * 用户权限数据。key:用户名,value:权限
     */
    public static final Map<String, List<PERMISSION>> USER_PERMISSION_MAP = new ConcurrentHashMap<>();
    /**
     * 白名单
     */
    public static final String[] WHITELIST = {"/login"};

    static {
        // 填充模拟用户数据
        USER_MAP.put("admin", "$2a$10$KOvypkjLRv/iJo/hU5GOSeFsoZzPYnh2B4r7LPI2x8yBTBZhPLkhy");
        USER_MAP.put("user", "$2a$10$KOvypkjLRv/iJo/hU5GOSeFsoZzPYnh2B4r7LPI2x8yBTBZhPLkhy");
        // 填充用户权限
        USER_PERMISSION_MAP.put("admin", List.of(PERMISSION.ADMIN, PERMISSION.USER));
        USER_PERMISSION_MAP.put("user", List.of(PERMISSION.USER));
        // 填充接口权限
        PERMISSION_MAP.put("/user", new SecurityConfig(PERMISSION.USER.getValue()));
        PERMISSION_MAP.put("/admin", new SecurityConfig(PERMISSION.ADMIN.getValue()));
    }

    /**
     * 模拟权限
     */
    @Getter
    public enum PERMISSION {
        ADMIN("admin"), USER("user");

        private final String value;

        private PERMISSION(String value) {
            this.value = value;
        }

    }

}

实现 UserDetails

package com.yiyan.study.config;

import lombok.Builder;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;

/**
 * Spring Security用户信息
 */
@Data
@Builder
public class SecurityUserDetails implements UserDetails {

    private String username;

    private String password;

    private List<SecurityConstant.PERMISSION> permissions;

    public SecurityUserDetails(String username, String password, List<SecurityConstant.PERMISSION> permissions) {
        this.username = username;
        this.password = password;
        this.permissions = permissions;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return permissions.stream()
                .map(permission -> new SimpleGrantedAuthority(permission.getValue()))
                .collect(Collectors.toList());
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

实现UserDetailsService,重写loadUserByUsername()方法

package com.yiyan.study.config;

import io.micrometer.common.util.StringUtils;
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;

import java.util.List;

@Service
public class SecurityUserDetailsService implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 获取用户信息
        String password = SecurityConstant.USER_MAP.get(username);
        if (StringUtils.isBlank(password)) {
            throw new UsernameNotFoundException("用户名或密码错误");
        }
        // 获取用户权限
        List<SecurityConstant.PERMISSION> permission = SecurityConstant.USER_PERMISSION_MAP.get(username);
        // 返回SecurityUserDetails
        return SecurityUserDetails.builder()
                .username(username)
                .password(password)
                .permissions(permission)
                .build();
    }
}

创建自定义过滤器,用于实现对TOKEN进行鉴权

JWT工具类

package com.yiyan.study.utils;

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import lombok.Data;
import org.apache.commons.lang3.StringUtils;
import org.springframework.util.CollectionUtils;

import java.util.Collections;
import java.util.Date;
import java.util.List;

/**
 * JWT工具类
 */
public class JwtUtils {

    /**
     * 默认JWT标签头
     */
    public static final String HEADER = "Authorization";
    /**
     * JWT配置信息
     */
    private static JwtConfig jwtConfig;

    private JwtUtils() {
    }

    /**
     * 初始化参数
     *
     * @param header         JWT标签头
     * @param tokenHead      Token头
     * @param issuer         签发者
     * @param secretKey      密钥 最小长度:4
     * @param expirationTime Token过期时间 单位:秒
     * @param issuers        签发者列表 校验签发者时使用
     * @param audience       接受者
     */
    public static void initialize(String header, String tokenHead, String issuer, String secretKey, long expirationTime, List<String> issuers, String audience) {
        jwtConfig = new JwtConfig();
        jwtConfig.setHeader(StringUtils.isNotBlank(header) ? header : HEADER);
        jwtConfig.setTokenHead(tokenHead);
        jwtConfig.setIssuer(issuer);
        jwtConfig.setSecretKey(secretKey);
        jwtConfig.setExpirationTime(expirationTime);
        if (CollectionUtils.isEmpty(issuers)) {
            issuers = Collections.singletonList(issuer);
        }
        jwtConfig.setIssuers(issuers);
        jwtConfig.setAudience(audience);
        jwtConfig.setAlgorithm(Algorithm.HMAC256(jwtConfig.getSecretKey()));
    }

    /**
     * 初始化参数
     */
    public static void initialize(String header, String issuer, String secretKey, long expirationTime) {
        initialize(header, null, issuer, secretKey, expirationTime, null, null);
    }

    /**
     * 初始化参数
     */
    public static void initialize(String header, String tokenHead, String issuer, String secretKey, long expirationTime) {
        initialize(header, tokenHead, issuer, secretKey, expirationTime, null, null);
    }


    /**
     * 生成 Token
     *
     * @param subject 主题
     * @return Token
     */
    public static String generateToken(String subject) {
        return generateToken(subject, jwtConfig.getExpirationTime());
    }

    /**
     * 生成 Token
     *
     * @param subject        主题
     * @param expirationTime 过期时间
     * @return Token
     */
    public static String generateToken(String subject, long expirationTime) {
        Date now = new Date();
        Date expiration = new Date(now.getTime() + expirationTime * 1000);

        return JWT.create()
                .withSubject(subject)
                .withIssuer(jwtConfig.getIssuer())
                .withAudience(jwtConfig.getAudience())
                .withIssuedAt(now)
                .withExpiresAt(expiration)
                .sign(jwtConfig.getAlgorithm());
    }

    /**
     * 获取Token数据体
     */
    public static String getTokenContent(String token) {
        if (StringUtils.isNotBlank(jwtConfig.getTokenHead())) {
            token = token.substring(jwtConfig.getTokenHead().length()).trim();
        }
        return token;
    }

    /**
     * 验证 Token
     *
     * @param token token
     * @return 验证通过返回true,否则返回false
     */
    public static boolean isValidToken(String token) {
        try {
            token = getTokenContent(token);
            Algorithm algorithm = Algorithm.HMAC256(jwtConfig.getSecretKey());
            JWTVerifier verifier = JWT.require(algorithm).build();
            verifier.verify(token);
            return true;
        } catch (JWTVerificationException exception) {
            // Token验证失败
            return false;
        }
    }

    /**
     * 判断Token是否过期
     *
     * @param token token
     * @return 过期返回true,否则返回false
     */
    public static boolean isTokenExpired(String token) {
        try {
            token = getTokenContent(token);
            Algorithm algorithm = Algorithm.HMAC256(jwtConfig.secretKey);
            JWTVerifier verifier = JWT.require(algorithm).build();
            verifier.verify(token);

            Date expirationDate = JWT.decode(token).getExpiresAt();
            return expirationDate != null && expirationDate.before(new Date());
        } catch (JWTVerificationException exception) {
            // Token验证失败
            return false;
        }
    }

    /**
     * 获取 Token 中的主题
     *
     * @param token token
     * @return 主题
     */
    public static String getSubject(String token) {
        token = getTokenContent(token);
        return JWT.decode(token).getSubject();
    }

    /**
     * 获取当前Jwt配置信息
     */
    public static JwtConfig getCurrentConfig() {
        return jwtConfig;
    }

    @Data
    public static class JwtConfig {
        /**
         * JwtToken Header标签
         */
        private String header;
        /**
         * Token头
         */
        private String tokenHead;
        /**
         * 签发者
         */
        private String issuer;
        /**
         * 密钥
         */
        private String secretKey;
        /**
         * Token 过期时间
         */
        private long expirationTime;
        /**
         * 签发者列表
         */
        private List<String> issuers;
        /**
         * 接受者
         */
        private String audience;
        /**
         * 加密算法
         */
        private Algorithm algorithm;
    }
}

配置JWT

application.yml 添加配置

server:
  port: 8080

# ======== JWT配置 ========
jwt:
  secret: 1234567890123456
  expirationTime: 604800
  issuer: springboot3-security
  header: Authorization
  tokenHead: Bearer

配置JWT启动时加载配置项

package com.yiyan.study.config;


import com.yiyan.study.utils.JwtUtils;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

/**
 * JWT 配置
 */
@Slf4j
@Component
public class JwtConfig {
    @Value("${jwt.secret}")
    private String secretKey;
    @Value("${jwt.issuer}")
    private String issuer;
    @Value("${jwt.expirationTime}")
    private long expirationTime;
    @Value("${jwt.header}")
    private String header;
    @Value("${jwt.tokenHead}")
    private String tokenHead;

    @PostConstruct
    public void jwtInit() {
        JwtUtils.initialize(header, tokenHead, issuer, secretKey, expirationTime);
        log.info("JwtUtils初始化完成");
    }
}

自定义拦截器

package com.yiyan.study.config;

import com.yiyan.study.utils.JwtUtils;
import jakarta.annotation.Resource;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

/**
 * 自定义过滤器
 */
@Component
public class MyAuthenticationFilter extends OncePerRequestFilter {

    @Resource
    private SecurityUserDetailsService securityUserDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String requestToken = request.getHeader(JwtUtils.getCurrentConfig().getHeader());
        // 读取请求头中的token
        if (StringUtils.isNotBlank(requestToken)) {
            // 判断token是否有效
            boolean verifyToken = JwtUtils.isValidToken(requestToken);
            if (!verifyToken) {
                filterChain.doFilter(request, response);
            }

            // 解析token中的用户信息
            String subject = JwtUtils.getSubject(requestToken);
            if (StringUtils.isNotBlank(subject) && SecurityContextHolder.getContext().getAuthentication() == null) {

                SecurityUserDetails userDetails = (SecurityUserDetails) securityUserDetailsService.loadUserByUsername(subject);
                // 保存用户信息到当前会话
                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(
                                userDetails,
                                null,
                                userDetails.getAuthorities());
                // 将authentication填充到安全上下文
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }
        filterChain.doFilter(request, response);

    }
}

修改Controller 的登录接口

package com.yiyan.study.controller;

import com.yiyan.study.utils.JwtUtils;
import jakarta.annotation.Resource;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

/**
 * 测试接口
 */
@RestController
public class SecurityController {

    @Resource
    private AuthenticationManager authenticationManager;


    @GetMapping("/hello")
    public String hello() {
        return "hello spring security";
    }

    @GetMapping("/user")
    public String helloUser() {
        return "Hello User";
    }

    @GetMapping("/admin")
    public String helloAdmin() {
        return "Hello Admin";
    }

    @PostMapping("/login")
    public String doLogin(@RequestParam("username") String username,
                          @RequestParam("password") String password) {
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
        Authentication authentication = authenticationManager.authenticate(authenticationToken);
        // 判断是否验证成功
        if (null == authentication) {
            throw new UsernameNotFoundException("用户名或密码错误");
        }
        return JwtUtils.generateToken(username);
    }
}

编写Spring Security配置文件

Spring Security 升级到6.x后,配置方式与前版本不同,多个旧的配置类被启用。新版本采用lambda表达式的方式进行配置,核心配置项没变化。

package com.yiyan.study.config;

import jakarta.annotation.Resource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.PathMatcher;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;

/**
 * Spring Security配置类
 */
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {

    @Resource
    private UserDetailsService userDetailsService;
    @Resource
    private MyAuthenticationFilter myAuthenticationFilter;

    /**
     * 鉴权管理类
     */
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }

    /**
     * 加密类
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * Spring Security 过滤链
     */
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                // 禁用明文验证
                .httpBasic(AbstractHttpConfigurer::disable)
                // 关闭csrf
                .csrf(AbstractHttpConfigurer::disable)
                // 禁用默认登录页
                .formLogin(AbstractHttpConfigurer::disable)
                // 禁用默认登出页
                .logout(AbstractHttpConfigurer::disable)
                // 禁用session
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                // 配置拦截信息
                .authorizeHttpRequests(authorization -> authorization
                        // 允许所有的OPTIONS请求
                        .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                        // 放行白名单
                        .requestMatchers(SecurityConstant.WHITELIST).permitAll()
                        // 根据接口所需权限进行动态鉴权
                        .anyRequest().access((authentication, object) -> {
                            // 获取当前的访问路径
                            String requestURI = object.getRequest().getRequestURI();
                            PathMatcher pathMatcher = new AntPathMatcher();
                            // 白名单请求直接放行
                            for (String url : SecurityConstant.WHITELIST) {
                                if (pathMatcher.match(url, requestURI)) {
                                    return new AuthorizationDecision(true);
                                }
                            }
                            // 获取访问该路径所需权限
                            Map<String, ConfigAttribute> permissionMap = SecurityConstant.PERMISSION_MAP;
                            List<ConfigAttribute> apiNeedPermissions = new ArrayList<>();
                            for (Map.Entry<String, ConfigAttribute> config : permissionMap.entrySet()) {
                                if (pathMatcher.match(config.getKey(), requestURI)) {
                                    apiNeedPermissions.add(config.getValue());
                                }
                            }
                            // 如果接口没有配置权限则直接放行
                            if (apiNeedPermissions.isEmpty()) {
                                return new AuthorizationDecision(true);
                            }
                            // 获取当前登录用户权限信息
                            Collection<? extends GrantedAuthority> authorities = authentication.get().getAuthorities();
                            // 判断当前用户是否有足够的权限访问
                            for (ConfigAttribute configAttribute : apiNeedPermissions) {
                                // 将访问所需资源和用户拥有资源进行比对
                                String needAuthority = configAttribute.getAttribute();
                                for (GrantedAuthority grantedAuthority : authorities) {
                                    if (needAuthority.trim().equals(grantedAuthority.getAuthority())) {
                                        // 权限匹配放行
                                        return new AuthorizationDecision(true);
                                    }
                                }
                            }
                            return new AuthorizationDecision(false);
                        })
                )
                // 注册重写后的UserDetailsService实现
                .userDetailsService(userDetailsService)
                // 注册自定义拦截器
                .addFilterBefore(myAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .build();
    }
}

测试

springboot3-security

SpringBoot 基于Logback定制日志框架

依赖版本

  • JDK 17

  • Spring Boot 3.2.0

日志门面和日志实现

日志门面(如Slf4j)就是一个标准,同JDBC一样来制定“规则”,把不同的日志系统的实现进行了具体的抽象化,只提供了统一的日志使用接口。而Logback、log4j等具体的日志系统就如同MySQL驱动、PGSQL驱动一样,才是日志功能的真正实现。

img

SpringBoot默认日志框架

SpringBoot使用Slf4j作为日志门面,Logback作为默认的日志实现。在SpringBoot的pom.xml中,依赖为:

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

此依赖在SpringBoot Starter包中包含,所以在SpringBoot项目中导入 spring-boot-starter 后可直接使用:

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

打印日志

  1. 基于LoggerFactory创建日志记录器实例
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class LoggingApplication {

    private static final Logger logger = LoggerFactory.getLogger(LoggingApplication.class);

    public static void main(String[] args) {
        logger.info("LoggingApplication start...");
        SpringApplication.run(LoggingApplication.class, args);
        logger.info("LoggingApplication end...");
    }
}

springboot3-logging-factory

  1. 引入lombok依赖,通过注解@Slf4j创建日志记录器实例
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
</dependencies>
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@Slf4j
@SpringBootApplication
public class LoggingApplication {
    public static void main(String[] args) {
        log.info("LoggingApplication start...");
        SpringApplication.run(LoggingApplication.class, args);
        log.info("LoggingApplication end...");
    }
}

springboot3-logging-lombok

配置Logback

默认配置

SpringBoot推荐将配置文件名称命名为logback-spring.xml表示这是SpringBoot下Logback专用的配置,可以使用SpringBoot 的高级Profile功能,它的内容类似于这样:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <!-- 配置信息 -->
</configuration>

最外层由configuration包裹,一旦编写,那么就会替换默认的配置,所以如果内部什么都不写的话,那么会导致我们的SpringBoot项目没有配置任何日志输出方式,控制台也不会打印日志。

也可在配置文件中指定要使用的日志配置,如application.yml中通过配置 logging.config指定要使用的日志配置。

logging:
  config: classpath:logback-spring.xml

springboot3-logging-noneconfig

基于SpringBoot默认Logback配置进行定制

SpringBoot 的默认Logback文件可在依赖项中找到。路径为:org/springframework/boot/logging/logback/defaults.xml

springboot3-logging-logback-config-default

在配置文件中对默认配置进行引用,及编写定制配置覆盖默认配置。

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <!-- 引用Spring Boot 默认日志配置 -->
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>

    <!-- 控制台日志打印格式 -->
    <property name="CONSOLE_LOG_PATTERN"
              value="MyLogPattern %clr(%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd HH:mm:ss.SSS}}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr(${LOGGED_APPLICATION_NAME:-}[%15.15t]){faint} %clr(${LOG_CORRELATION_PATTERN:-}){faint}%clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}"/>

    <!-- 日志输出到控制台 -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>${CONSOLE_LOG_THRESHOLD}</level>
        </filter>
        <encoder>
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
            <charset>${CONSOLE_LOG_CHARSET}</charset>
        </encoder>
    </appender>

    <!-- 指定日志输出级别 -->
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
    </root>
</configuration>

springboot3-logging-config-my

配置日志信息输出到文件

logback-spring.xml 添加如下内容:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <!-- 引用Spring Boot 默认日志配置 -->
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <!-- 日志文件输出位置 -->
    <property name="LOG_PATH" value="./logs"/>
    <!-- 日志文件名 -->
    <property name="LOG_FILE" value="${LOG_PATH}/spring-boot.log"/>

    <!-- 日志输出到文件 -->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>${FILE_LOG_THRESHOLD}</level>
        </filter>
        <encoder>
            <pattern>${FILE_LOG_PATTERN}</pattern>
            <charset>${FILE_LOG_CHARSET}</charset>
        </encoder>
        <file>${LOG_FILE}</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>${LOGBACK_ROLLINGPOLICY_FILE_NAME_PATTERN:-${LOG_FILE}.%d{yyyy-MM-dd}.%i.gz}
            </fileNamePattern>
            <cleanHistoryOnStart>${LOGBACK_ROLLINGPOLICY_CLEAN_HISTORY_ON_START:-false}</cleanHistoryOnStart>
            <maxFileSize>${LOGBACK_ROLLINGPOLICY_MAX_FILE_SIZE:-10MB}</maxFileSize>
            <totalSizeCap>${LOGBACK_ROLLINGPOLICY_TOTAL_SIZE_CAP:-0}</totalSizeCap>
            <maxHistory>${LOGBACK_ROLLINGPOLICY_MAX_HISTORY:-7}</maxHistory>
        </rollingPolicy>
    </appender>

    <!-- 指定日志输出级别,以及启动的Appender -->
    <root level="INFO">
        <appender-ref ref="FILE"/>
    </root>
</configuration>

springboot3-logging-logback-config-tofile

MDC机制

Slf4j官方解释

MDC全称Mapped Diagnostic Context(映射诊断上下文),“映射诊断上下文” 本质上是由日志记录框架维护的映射,其中应用程序代码提供键值对,然后可以由日志记录框架将其插入日志消息中。MDC数据在过滤消息或触发某些操作时也非常有用。

通俗的说就是可以将特定的信息(如请求Id,链路Id),通过MDC机制注入到当前日志线程的上下文中,将信息在日志中记录、输出。

image-20231225104700128

MDC的使用

MDC 常用的三个方法:

  • MDC.put(key,value) 注入值
  • MDC.remove(key) 移除指定的MDC注入信息
  • MDC.clear() 移除所有当前线程的MDC注入信息

MDC注入属性配置:

logback-spring.xml 文件的输出格式中添加需要注入的KEY信息与代码注入的信息相对应。假定我需要注入当前用户的Id信息,且设置key为user-id

代码:

import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

import java.util.UUID;

@Slf4j
@SpringBootApplication
public class LoggingApplication {
    public static void main(String[] args) {
        SpringApplication.run(LoggingApplication.class, args);
        String mdcKey = "user-id";
        log.info("Logging before MDC put...");
        MDC.put("mdcKey", UUID.randomUUID().toString());
        log.info("Logging after MDC put...");
        MDC.clear();
        log.info("Logging after MDC clear...");
    }
}

logback-spring.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <!-- 引用Spring Boot 默认日志配置 -->
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>

    <!-- 控制台日志打印格式 -->
    <property name="CONSOLE_LOG_PATTERN"
              value="[%X{user-id}] %clr(%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd HH:mm:ss.SSS}}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr(${LOGGED_APPLICATION_NAME:-}[%15.15t]){faint} %clr(${LOG_CORRELATION_PATTERN:-}){faint}%clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}"/>

    <!-- 日志输出到控制台 -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>${CONSOLE_LOG_THRESHOLD}</level>
        </filter>
        <encoder>
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
            <charset>${CONSOLE_LOG_CHARSET}</charset>
        </encoder>
    </appender>

    <!-- 指定日志输出级别 -->
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
    </root>
</configuration>

springboot3-logging-logback-mdc

使用方式:

  1. 通过注册拦截器或过滤器,在业务开始前获取到相关信息(请求Id,操作人Id),将相关信息通过MDC自动注入到当前线程中,实现关键信息记录。
  2. 在创建子线程时,需要通过MDC.getCopyOfContextMap()方法获取到当前线程的MDC注入相关信息,传递给子线程,才能在子线打印日志信息时获取到父线程MDC的注入信息。

Logback参考配置

<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
    <!-- Spring Boot 默认日志配置 -->
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>
    <!-- 日志文件输出位置 -->
    <property name="LOG_PATH" value="./logs"/>
    <!-- 日志文件名 -->
    <property name="LOG_FILE" value="${LOG_PATH}/spring-boot.log"/>
    <!-- 归档日志名 -->
    <property name="FILE_NAME_PATTERN" value="${LOG_FILE}-%d{yyyyMMdd}.%i.gz"/>
    <!-- 在启动时清除历史日志 -->
    <property name="LOGBACK_ROLLINGPOLICY_CLEAN_HISTORY_ON_START" value="false"/>
    <!-- 单个日志文件最大大小 -->
    <property name="LOGBACK_ROLLINGPOLICY_MAX_FILE_SIZE" value="100MB"/>
    <!-- 日志总大小 -->
    <property name="LOGBACK_ROLLINGPOLICY_TOTAL_SIZE_CAP" value="0"/>
    <!-- 日志保留天数 -->
    <property name="LOGBACK_ROLLINGPOLICY_MAX_HISTORY" value="7"/>
    <!-- 控制台日志编码格式 -->
    <property name="CONSOLE_LOG_CHARSET" value="UTF-8"/>
    <!-- 控制台日志打印格式 -->
    <property name="CONSOLE_LOG_PATTERN"
              value="%clr(%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd HH:mm:ss.SSS}}){faint} [%X{user-id}] [%X{request-id}] [%X{client-ip}] %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr(${LOGGED_APPLICATION_NAME:-}[%15.15t]){faint} %clr(${LOG_CORRELATION_PATTERN:-}){faint}%clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}"/>
    <!-- 控制台日志输出级别 -->
    <property name="CONSOLE_LOG_THRESHOLD" value="INFO"/>
    <!-- 日志文件输出打印格式 -->
    <property name="FILE_LOG_PATTERN"
              value="%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd HH:mm:ss.SSS}} [%X{user-id}] [%X{request-id}] [%X{client-ip}] ${LOG_LEVEL_PATTERN:-%5p} ${PID:- } --- ${LOGGED_APPLICATION_NAME:-}[%t] ${LOG_CORRELATION_PATTERN:-}%-40.40logger{39} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}"/>
    <!-- 日志文件输出编码格式 -->
    <property name="FILE_LOG_CHARSET" value="UTF-8"/>
    <!-- 日志文件输出级别 -->
    <property name="FILE_LOG_THRESHOLD" value="INFO"/>


    <!-- 日志输出到控制台 -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>${CONSOLE_LOG_THRESHOLD}</level>
        </filter>
        <encoder>
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
            <charset>${CONSOLE_LOG_CHARSET}</charset>
        </encoder>
    </appender>

    <!-- 日志输出到文件 -->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>${FILE_LOG_THRESHOLD}</level>
        </filter>
        <!-- 日志打印配置 -->
        <encoder>
            <pattern>${FILE_LOG_PATTERN}</pattern>
            <charset>${FILE_LOG_CHARSET}</charset>
        </encoder>
        <!-- 日志文件名 -->
        <file>${LOG_FILE}</file>
        <!-- 日志输出配置 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <!-- 日志归档名 -->
            <fileNamePattern>${FILE_NAME_PATTERN}</fileNamePattern>
            <!-- 在启动时清除日志 -->
            <cleanHistoryOnStart>${LOGBACK_ROLLINGPOLICY_CLEAN_HISTORY_ON_START}</cleanHistoryOnStart>
            <!-- 单个文件大小 -->
            <maxFileSize>${LOGBACK_ROLLINGPOLICY_MAX_FILE_SIZE}</maxFileSize>
            <!-- 日志总大小 -->
            <totalSizeCap>${LOGBACK_ROLLINGPOLICY_TOTAL_SIZE_CAP}</totalSizeCap>
            <!-- 日志保留天数 -->
            <maxHistory>${LOGBACK_ROLLINGPOLICY_MAX_HISTORY}</maxHistory>
        </rollingPolicy>
    </appender>

    <!-- 指定日志输出级别,以及启动的Appender -->
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="FILE"/>
    </root>
</configuration>

接入 SpringDoc 实现接口文档

依赖版本

  • JDK 17
  • Spring Boot 3.2.0
  • SpringDoc 2.3.0

工程源码:Gitee

导入依赖


<properties>
    <maven.compiler.source>17</maven.compiler.source>
    <maven.compiler.target>17</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <springdoc.version>2.3.0</springdoc.version>
</properties>

<dependencies>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>${springdoc.version}</version>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</dependency>
</dependencies>

创建SpringDoc配置文件

application.yml 配置

server:
  port: 8080

# ======== SpringDocs文档配置 ========
springdocs:
  title: SpringBoot3.2.0 API Docs
  description: SpringBoot3 + OpenAPI Docs
  version: 0.0.1
  scheme: Bearer
  header: Authorization

OpenAPI配置文件

import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties(prefix = "springdocs")
@Setter
@Getter
public class SpringDocsProperties {
    private String title;
    private String description;
    private String version;
    private String header;
    private String scheme;
}
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * SpringDoc配置文件
 */
@Configuration
public class SpringDocConfig {
    @Resource
    private SpringDocsProperties springDocsProperties;

    @Bean
    public OpenAPI openApi() {
        return new OpenAPI()
                // 文档描述信息
                .info(new Info()
                        .title(springDocsProperties.getTitle())
                        .description(springDocsProperties.getDescription())
                        .version(springDocsProperties.getVersion())
                )
                // 添加全局的header参数
                .addSecurityItem(new SecurityRequirement()
                        .addList(springDocsProperties.getHeader()))
                .components(new Components()
                        .addSecuritySchemes(springDocsProperties.getHeader(), new SecurityScheme()
                                .name(springDocsProperties.getHeader())
                                .scheme(springDocsProperties.getScheme())
                                .bearerFormat("JWT")
                                .type(SecurityScheme.Type.HTTP))
                );
    }
}

OpenAPI 与 Swagger的 API对应

  • @Api@Tag
  • @ApiIgnore@Parameter(hidden = true) or @Operation(hidden = true) or @Hidden
  • @ApiImplicitParam@Parameter
  • @ApiImplicitParams@Parameters
  • @ApiModel@Schema
  • @ApiModelProperty(hidden = true)@Schema(accessMode = READ_ONLY)
  • @ApiModelProperty@Schema
  • @ApiOperation(value = "foo", notes = "bar")@Operation(summary = "foo", description = "bar")
  • @ApiParam@Parameter
  • @ApiResponse(code = 404, message = "foo")@ApiResponse(responseCode = "404", description = "foo")

演示

springboot3-springdoc

MIT License Copyright (c) 2023 Sparkler Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

简介

暂无描述 展开 收起
Java
MIT
取消

发行版

暂无发行版

贡献者

全部

近期动态

加载更多
不能加载更多了
1
https://gitee.com/sparkle3021/springboot3-study.git
git@gitee.com:sparkle3021/springboot3-study.git
sparkle3021
springboot3-study
springboot3-study
main

搜索帮助