ruoyi-vue-plus-框架篇
# 整合 MyBaits Plus
官方文档 : MyBaits Plus (opens new window)
# 元数据自动填充
实体类 @TableField(fill = FieldFill.xxx) 填充策略
通过 CreateAndUpdateMetaObjectHandler
类 实现元数据操作填充策略 , 重写 插入/更新 方法实现
- insertFill()
- updateFill()
点击代码展开
@Slf4j
public class CreateAndUpdateMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
try {
if (ObjectUtil.isNotNull(metaObject) && metaObject.getOriginalObject() instanceof BaseEntity) {
BaseEntity baseEntity = (BaseEntity) metaObject.getOriginalObject();
Date current = ObjectUtil.isNotNull(baseEntity.getCreateTime())
? baseEntity.getCreateTime() : new Date();
baseEntity.setCreateTime(current);
baseEntity.setUpdateTime(current);
String username = StringUtils.isNotBlank(baseEntity.getCreateBy())
? baseEntity.getCreateBy() : getLoginUsername();
// 当前已登录 且 创建人为空 则填充
baseEntity.setCreateBy(username);
// 当前已登录 且 更新人为空 则填充
baseEntity.setUpdateBy(username);
}
} catch (Exception e) {
throw new ServiceException("自动注入异常 => " + e.getMessage(), HttpStatus.HTTP_UNAUTHORIZED);
}
}
@Override
public void updateFill(MetaObject metaObject) {
try {
if (ObjectUtil.isNotNull(metaObject) && metaObject.getOriginalObject() instanceof BaseEntity) {
BaseEntity baseEntity = (BaseEntity) metaObject.getOriginalObject();
Date current = new Date();
// 更新时间填充(不管为不为空)
baseEntity.setUpdateTime(current);
String username = getLoginUsername();
// 当前已登录 更新人填充(不管为不为空)
if (StringUtils.isNotBlank(username)) {
baseEntity.setUpdateBy(username);
}
}
} catch (Exception e) {
throw new ServiceException("自动注入异常 => " + e.getMessage(), HttpStatus.HTTP_UNAUTHORIZED);
}
}
/**
* 获取登录用户名
*/
private String getLoginUsername() {
LoginUser loginUser;
try {
loginUser = LoginHelper.getLoginUser();
} catch (Exception e) {
log.warn("自动注入警告 => 用户未登录");
return null;
}
return ObjectUtil.isNotNull(loginUser) ? loginUser.getUsername() : null;
}
}
# 逻辑删除
自行 在库添加 删除标识字段 , 对象实体类 删除成员变量 需要 @TableLogic
注解 生效
官方文档 : MyBaits Plus 逻辑删除 (opens new window)
# 乐观锁
乐观锁根据 库字段进行判断是否修改 , 每次修改仅一个线程操作
官方文档 : MyBatis Plus 乐观锁 (opens new window)
应用
添加配置插件 ,
MybatisPlusConfig
配置类@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 数据权限处理 interceptor.addInnerInterceptor(dataPermissionInterceptor()); // 分页插件 interceptor.addInnerInterceptor(paginationInnerInterceptor()); // 乐观锁插件 interceptor.addInnerInterceptor(optimisticLockerInnerInterceptor()); return interceptor; } /** * 乐观锁插件 */ public OptimisticLockerInnerInterceptor optimisticLockerInnerInterceptor() { return new OptimisticLockerInnerInterceptor(); }
自行 在库添加 版本标识字段 , 对象实体类 版本成员变量 需要
@Version
注解 生效
# 条件构造器
执行SQL 可以根据约束得到预期想要的结果 , MyBatis Plus 提供了如下两个约束的类 :
- QueryWrapper<T>
- LambdaQueryWrapper<T>
两者用法差不多 , 但唯独不同的就是 , 指定约束字段的参数 , LambdaQueryWrapper可根据 实体类的方法指定 , 而另一个通过 库字段名 指定 , 剩下的懂得都懂
官方文档 : MyBatis Plus 条件构造器 (opens new window)
对象属性
- SqlSegmet (SQL约束片段)
- TargetSql (简写 目标SQL)
- ParamAlias (参数别名)
- ParamNameValuePairs (参数变量&变量值)
- CustomSqlSegment (完整SQL)
条件构造器转化
有些情况 LambdaQueryWrapper 不能满足更多功能的应用 , 因此采用 QueryWrapper , 可以通过以下方式 两种混合应用
QueryWrapper<T> qw = new QueryWrapper<>();
...
LambdaQueryWrapper<T> law = qw.lambda();
自定义Select拓展
在默认情况下查询仅查询实体类 , select 拓展其他字段应用则会查不到 , 解决方案 :
- 在实体类添加成员变量 , 且SQL字段的 别名和该类的成员变量名一致
- 采用Maps形式查询接收变量 , 但要自行解决对象转化环节
# 整合 Redis
采用Redisson通信 , 其优点就不用多说了
依赖
- redisson-spring-boot-starter
- lock4j-redisson-spring-boot-starter
- redisson-spring-data-27
配置
映射配置文件 : RedissonProperties
配置类 : RedisConfig
应用 :
RedissonClient CLIENT = SpringUtils.getBean(RedissonClient.class);
工具类 : RedisUtils
(应用实例点击跳转)
监听
订阅监听 生效 需要修改 Reids配置 notify-keyspace-events
, 其配置值 :
- K: 开启键空间事件通知 , 发布在
__keyspace@<db>__:*
频道上 - E: 开启键事件事件通知 , 发布在
__keyevent@<db>__:*
频道上 - g: 开启一般命令事件通知, 通常是不指定对象类型的命令 , 例如DEL , RENAME等等
- $: 开启操作字符串命令事件通知
- l: 开启操作列表对象事件通知
- s: 开启操作集合对象事件通知
- h: 开启操作散列对象事件通知
- z: 开启操作有序集合对象事件通知
- x: 开启键的过期事件通知
- e: 开启键的淘汰事件通知
- A: 等价于
g$lshzxe
, 如果notify-keyspace-events
被设置为AKE事件 , 则意味着开启所有键以及所有事件的通知。
修改配置后需要重启Reids服务生效配置
# 整合 Sa-Token
身份权限认证 等...
官方文档 : https://sa-token.cc (opens new window)
# 拦截器注册
com.ruoyi.framework.config.SaTokenConfig
对所有接口都进行登录拦截校验
@RequiredArgsConstructor
@Slf4j
@Configuration
public class SaTokenConfig implements WebMvcConfigurer {
private final SecurityProperties securityProperties;
/**
* 注册sa-token的拦截器
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册路由拦截器,自定义验证规则
registry.addInterceptor(new SaInterceptor(handler -> {
AllUrlHandler allUrlHandler = SpringUtils.getBean(AllUrlHandler.class);
// 登录验证 -- 排除多个路径
SaRouter
// 获取所有的
.match(allUrlHandler.getUrls())
// 对未排除的路径进行检查
.check(() -> {
// 检查是否登录 是否有token
StpUtil.checkLogin();
});
})).addPathPatterns("/**")
// 排除不需要拦截的路径
.excludePathPatterns(securityProperties.getExcludes());
}
}
重写 saTokenDao 应用Bean , 优化缓存 , 改为Redis
# 排除拦截
指定路径不受认证权限相关约束 , 配置类中 指定排除拦截 securityProperties.getExcludes()
application.yml
配置文件
security:
# 排除路径
excludes:
# 静态资源
- /*.html
- /**/*.html
- /**/*.css
- /**/*.js
# 公共路径
- /favicon.ico
- /error
# swagger 文档配置
- /*/api-docs
- /*/api-docs/**
# actuator 监控配置
- /actuator
- /actuator/**
# 用户权限管理
Sa-Token根据 重写 StpInterface
类 , 获取用户的权限信息 菜单权限标识 / 角色权限 , 以便在注解中校验使用
官方文档 : https://sa-token.cc/doc.html#/use/jur-auth (opens new window)
点击代码展开
public class SaPermissionImpl implements StpInterface {
/**
* 获取菜单权限列表
*/
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
LoginUser loginUser = LoginHelper.getLoginUser();
UserType userType = UserType.getUserType(loginUser.getUserType());
if (userType == UserType.SYS_USER) {
return new ArrayList<>(loginUser.getMenuPermission());
} else if (userType == UserType.APP_USER) {
// 其他端 自行根据业务编写
}
return new ArrayList<>();
}
/**
* 获取角色权限列表
*/
@Override
public List<String> getRoleList(Object loginId, String loginType) {
LoginUser loginUser = LoginHelper.getLoginUser();
UserType userType = UserType.getUserType(loginUser.getUserType());
if (userType == UserType.SYS_USER) {
return new ArrayList<>(loginUser.getRolePermission());
} else if (userType == UserType.APP_USER) {
// 其他端 自行根据业务编写
}
return new ArrayList<>();
}
}
# Dao缓存
Sa-Token 持久层缓存是基于 本机内存Map存储 , 一旦重启宕机则会丢失数据
因此整合了 Redis缓存应用
点击代码展开
public class PlusSaTokenDao implements SaTokenDao {
/**
* 获取Value,如无返空
*/
@Override
public String get(String key) {
return RedisUtils.getCacheObject(key);
}
/**
* 写入Value,并设定存活时间 (单位: 秒)
*/
@Override
public void set(String key, String value, long timeout) {
if (timeout == 0 || timeout <= SaTokenDao.NOT_VALUE_EXPIRE) {
return;
}
// 判断是否为永不过期
if (timeout == SaTokenDao.NEVER_EXPIRE) {
RedisUtils.setCacheObject(key, value);
} else {
RedisUtils.setCacheObject(key, value, Duration.ofSeconds(timeout));
}
}
/**
* 修修改指定key-value键值对 (过期时间不变)
*/
@Override
public void update(String key, String value) {
long expire = getTimeout(key);
// -2 = 无此键
if (expire == SaTokenDao.NOT_VALUE_EXPIRE) {
return;
}
this.set(key, value, expire);
}
/**
* 删除Value
*/
@Override
public void delete(String key) {
RedisUtils.deleteObject(key);
}
/**
* 获取Value的剩余存活时间 (单位: 秒)
*/
@Override
public long getTimeout(String key) {
long timeout = RedisUtils.getTimeToLive(key);
return timeout < 0 ? timeout : timeout / 1000;
}
/**
* 修改Value的剩余存活时间 (单位: 秒)
*/
@Override
public void updateTimeout(String key, long timeout) {
// 判断是否想要设置为永久
if (timeout == SaTokenDao.NEVER_EXPIRE) {
long expire = getTimeout(key);
if (expire == SaTokenDao.NEVER_EXPIRE) {
// 如果其已经被设置为永久,则不作任何处理
} else {
// 如果尚未被设置为永久,那么再次set一次
this.set(key, this.get(key), timeout);
}
return;
}
RedisUtils.expire(key, Duration.ofSeconds(timeout));
}
/**
* 获取Object,如无返空
*/
@Override
public Object getObject(String key) {
return RedisUtils.getCacheObject(key);
}
/**
* 写入Object,并设定存活时间 (单位: 秒)
*/
@Override
public void setObject(String key, Object object, long timeout) {
if (timeout == 0 || timeout <= SaTokenDao.NOT_VALUE_EXPIRE) {
return;
}
// 判断是否为永不过期
if (timeout == SaTokenDao.NEVER_EXPIRE) {
RedisUtils.setCacheObject(key, object);
} else {
RedisUtils.setCacheObject(key, object, Duration.ofSeconds(timeout));
}
}
/**
* 更新Object (过期时间不变)
*/
@Override
public void updateObject(String key, Object object) {
long expire = getObjectTimeout(key);
// -2 = 无此键
if (expire == SaTokenDao.NOT_VALUE_EXPIRE) {
return;
}
this.setObject(key, object, expire);
}
/**
* 删除Object
*/
@Override
public void deleteObject(String key) {
RedisUtils.deleteObject(key);
}
/**
* 获取Object的剩余存活时间 (单位: 秒)
*/
@Override
public long getObjectTimeout(String key) {
long timeout = RedisUtils.getTimeToLive(key);
return timeout < 0 ? timeout : timeout / 1000;
}
/**
* 修改Object的剩余存活时间 (单位: 秒)
*/
@Override
public void updateObjectTimeout(String key, long timeout) {
// 判断是否想要设置为永久
if (timeout == SaTokenDao.NEVER_EXPIRE) {
long expire = getObjectTimeout(key);
if (expire == SaTokenDao.NEVER_EXPIRE) {
// 如果其已经被设置为永久,则不作任何处理
} else {
// 如果尚未被设置为永久,那么再次set一次
this.setObject(key, this.getObject(key), timeout);
}
return;
}
RedisUtils.expire(key, Duration.ofSeconds(timeout));
}
/**
* 搜索数据
*/
@Override
public List<String> searchData(String prefix, String keyword, int start, int size, boolean sortType) {
Collection<String> keys = RedisUtils.keys(prefix + "*" + keyword + "*");
List<String> list = new ArrayList<>(keys);
return SaFoxUtil.searchList(list, start, size, sortType);
}
}
# 拦截原理
首先分析接口请求被拦截的过程 : (仅展示核心部分)
- 进入前置拦截器 SaInterceptor.preHandle()
- 判断方法是否注解了
@SaIgnore
(跳过整个Sa-Token拦截) SaStrategy.instance.isAnnotationPresent.apply(method, SaIgnore.class) - 鉴权权限注解 (所有相关注解校验)
SaStrategy.instance.checkMethodAnnotation.accept(method);
- 对 类/方法 的所有相关注解校验
- 逐个判断注解对象是否存在 , 存在则进行响应的注解校验
- 最后校验 auth.run(handler); , 此方法会调用会
SaInterceptor.auth
(构造方法中的lambda表达式) - 获取所有路径 AllUrlHandler.getUrls() (在Bean中调取) 并匹配当中获取的路径
- 校验是否有token StpUtil.checkLogin();
Sa-Token 自定义拦截类cn.dev33.satoken.interceptor.SaInterceptor
点击代码展开
public class SaInterceptor implements HandlerInterceptor {
public SaInterceptor(SaParamFunction<Object> auth) {
this.auth = auth;
}
/**
* 每次请求之前触发的方法
*/
@Override
@SuppressWarnings("all")
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
try {
// 这里必须确保 handler 是 HandlerMethod 类型时,才能进行注解鉴权
if(isAnnotation && handler instanceof HandlerMethod) {
// 获取此请求对应的 Method 处理函数
Method method = ((HandlerMethod) handler).getMethod();
// 如果此 Method 或其所属 Class 标注了 @SaIgnore,则忽略掉鉴权
if(SaStrategy.instance.isAnnotationPresent.apply(method, SaIgnore.class)) {
// 注意这里直接就退出整个鉴权了,最底部的 auth.run() 路由拦截鉴权也被跳出了
return true;
}
// 执行注解鉴权
SaStrategy.instance.checkMethodAnnotation.accept(method);
}
// Auth 路由拦截鉴权校验
auth.run(handler);
} catch (StopMatchException e) {
// StopMatchException 异常代表:停止匹配,进入Controller
} catch (BackResultException e) {
// BackResultException 异常代表:停止匹配,向前端输出结果
// 请注意此处默认 Content-Type 为 text/plain,如果需要返回 JSON 信息,需要在 back 前自行设置 Content-Type 为 application/json
// 例如:SaHolder.getResponse().setHeader("Content-Type", "application/json;charset=UTF-8");
if(response.getContentType() == null) {
response.setContentType("text/plain; charset=utf-8");
}
response.getWriter().print(e.getMessage());
return false;
}
// 通过验证
return true;
}
}
Sa-Token 策略对象 cn.dev33.satoken.strategy.SaStrategy
- 鉴定注解主要入口 SaStrategy.instance.checkMethodAnnotation.accept(method);
- 分别校验 类 和 方法 (类能够对当前Controller中的所有接口校验生效)
- 调用 checkElementAnnotation 进行处理不同的校验类型
- ...
点击代码展开
public final class SaStrategy {
/**
* 获取 SaStrategy 对象的单例引用
*/
public static final SaStrategy instance = new SaStrategy();
/**
* 对一个 [Method] 对象进行注解校验 (注解鉴权内部实现)
*/
public SaCheckMethodAnnotationFunction checkMethodAnnotation = (method) -> {
// 先校验 Method 所属 Class 上的注解
instance.checkElementAnnotation.accept(method.getDeclaringClass());
// 再校验 Method 上的注解
instance.checkElementAnnotation.accept(method);
};
/**
* 对一个 [元素] 对象进行注解校验 (注解鉴权内部实现)
*/
public SaCheckElementAnnotationFunction checkElementAnnotation = (element) -> {
// 校验 @SaCheckLogin 注解
SaCheckLogin checkLogin = (SaCheckLogin) SaStrategy.instance.getAnnotation.apply(element, SaCheckLogin.class);
if(checkLogin != null) {
SaManager.getStpLogic(checkLogin.type(), false).checkByAnnotation(checkLogin);
}
// 校验 @SaCheckRole 注解
SaCheckRole checkRole = (SaCheckRole) SaStrategy.instance.getAnnotation.apply(element, SaCheckRole.class);
if(checkRole != null) {
SaManager.getStpLogic(checkRole.type(), false).checkByAnnotation(checkRole);
}
// 校验 @SaCheckPermission 注解
SaCheckPermission checkPermission = (SaCheckPermission) SaStrategy.instance.getAnnotation.apply(element, SaCheckPermission.class);
if(checkPermission != null) {
SaManager.getStpLogic(checkPermission.type(), false).checkByAnnotation(checkPermission);
}
// 校验 @SaCheckSafe 注解
SaCheckSafe checkSafe = (SaCheckSafe) SaStrategy.instance.getAnnotation.apply(element, SaCheckSafe.class);
if(checkSafe != null) {
SaManager.getStpLogic(checkSafe.type(), false).checkByAnnotation(checkSafe);
}
// 校验 @SaCheckDisable 注解
SaCheckDisable checkDisable = (SaCheckDisable) SaStrategy.instance.getAnnotation.apply(element, SaCheckDisable.class);
if(checkDisable != null) {
SaManager.getStpLogic(checkDisable.type(), false).checkByAnnotation(checkDisable);
}
// 校验 @SaCheckBasic 注解
SaCheckBasic checkBasic = (SaCheckBasic) SaStrategy.instance.getAnnotation.apply(element, SaCheckBasic.class);
if(checkBasic != null) {
SaBasicUtil.check(checkBasic.realm(), checkBasic.account());
}
// 校验 @SaCheckOr 注解
SaCheckOr checkOr = (SaCheckOr) SaStrategy.instance.getAnnotation.apply(element, SaCheckOr.class);
if(checkOr != null) {
SaStrategy.instance.checkOrAnnotation.accept(checkOr);
}
};
}
# 整合 日志监听
# Slf4j
SLF4j官方文档 :
- https://springdoc.cn/spring-boot/features#features.logging (opens new window)
- https://www.slf4j.org/manual.html (opens new window)
- https://www.slf4j.org/apidocs/index.html (opens new window)
Maven依赖
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
</dependency>
日志配置
# 日志配置
logging:
level:
com.ruoyi: @logging.level@
org.springframework: warn
com.bozhu: trace
config: classpath:logback-plus.xml
环境变量配置
点击展开 **pom.xml**
<!-- 其他省略 -->
<profiles>
<profile>
<id>local</id>
<properties>
<!-- 环境标识,需要与配置文件的名称相对应 -->
<profiles.active>local</profiles.active>
<logging.level>debug</logging.level>
</properties>
</profile>
<profile>
<id>dev</id>
<properties>
<!-- 环境标识,需要与配置文件的名称相对应 -->
<profiles.active>dev</profiles.active>
<logging.level>debug</logging.level>
</properties>
<activation>
<!-- 默认环境 -->
<activeByDefault>true</activeByDefault>
</activation>
</profile>
<profile>
<id>prod</id>
<properties>
<profiles.active>prod</profiles.active>
<logging.level>warn</logging.level>
</properties>
</profile>
</profiles>
日志打印样式 , 以及文件存储路径
点击展开 **resources/logback-plus.xml**
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 存储路径 -->
<property name="log.path" value="./logs"/>
<!-- 红色日期 绿色线程 高亮级别(5个字符空间) 洋红色类名 日志信息 -->
<property name="console.log.pattern"
value="%red(%d{yyyy-MM-dd HH:mm:ss}) %green([%thread]) %highlight(%-5level) %boldMagenta(%logger{36}%n) - %msg%n"/>
<!-- 去掉高亮颜色 -->
<property name="log.pattern" value="%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"/>
<!-- appender日志输出组件 -->
<!-- 控制台输出 -->
<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
<!-- 控制台输出格式(引用上面格式) -->
<encoder>
<pattern>${console.log.pattern}</pattern>
<charset>utf-8</charset>
</encoder>
</appender>
<!-- 控制台输出 -->
<appender name="file_console" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${log.path}/sys-console.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 日志文件名格式 -->
<fileNamePattern>${log.path}/sys-console.%d{yyyy-MM-dd}.log</fileNamePattern>
<!-- 日志最大 1天 -->
<maxHistory>1</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${log.pattern}</pattern>
<charset>utf-8</charset>
</encoder>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<!-- 过滤的级别 -->
<level>INFO</level>
</filter>
</appender>
<!-- 系统日志输出 -->
<appender name="file_info" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${log.path}/sys-info.log</file>
<!-- 循环政策:基于时间创建日志文件 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 日志文件名格式 -->
<fileNamePattern>${log.path}/sys-info.%d{yyyy-MM-dd}.log</fileNamePattern>
<!-- 日志最大的历史 60天 -->
<maxHistory>60</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${log.pattern}</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<!-- 过滤的级别 -->
<level>INFO</level>
<!-- 匹配时的操作:接收(记录) -->
<onMatch>ACCEPT</onMatch>
<!-- 不匹配时的操作:拒绝(不记录) -->
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<appender name="file_error" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${log.path}/sys-error.log</file>
<!-- 循环政策:基于时间创建日志文件 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- 日志文件名格式 -->
<fileNamePattern>${log.path}/sys-error.%d{yyyy-MM-dd}.log</fileNamePattern>
<!-- 日志最大的历史 60天 -->
<maxHistory>60</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${log.pattern}</pattern>
</encoder>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<!-- 过滤的级别 -->
<level>ERROR</level>
<!-- 匹配时的操作:接收(记录) -->
<onMatch>ACCEPT</onMatch>
<!-- 不匹配时的操作:拒绝(不记录) -->
<onMismatch>DENY</onMismatch>
</filter>
</appender>
<!-- info异步打印追加日志 -->
<appender name="async_info" class="ch.qos.logback.classic.AsyncAppender">
<!-- 不丢失日志.默认的,如果队列的80%已满,则会丢弃TRACT、DEBUG、INFO级别的日志 -->
<discardingThreshold>0</discardingThreshold>
<!-- 更改默认的队列的深度,该值会影响性能.默认值为256 -->
<queueSize>512</queueSize>
<!-- 添加附加的appender,最多只能添加一个 -->
<appender-ref ref="file_info"/>
</appender>
<!-- error异步打印追加日志 -->
<appender name="async_error" class="ch.qos.logback.classic.AsyncAppender">
<!-- 不丢失日志.默认的,如果队列的80%已满,则会丢弃TRACT、DEBUG、INFO级别的日志 -->
<discardingThreshold>0</discardingThreshold>
<!-- 更改默认的队列的深度,该值会影响性能.默认值为256 -->
<queueSize>512</queueSize>
<!-- 添加附加的appender,最多只能添加一个 -->
<appender-ref ref="file_error"/>
</appender>
<!-- 整合 skywalking 控制台输出 tid -->
<!-- <appender name="console" class="ch.qos.logback.core.ConsoleAppender">-->
<!-- <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">-->
<!-- <layout class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.TraceIdPatternLogbackLayout">-->
<!-- <pattern>[%tid] ${console.log.pattern}</pattern>-->
<!-- </layout>-->
<!-- <charset>utf-8</charset>-->
<!-- </encoder>-->
<!-- </appender>-->
<!-- 整合 skywalking 推送采集日志 -->
<!-- <appender name="sky_log" class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.log.GRPCLogClientAppender">-->
<!-- <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">-->
<!-- <layout class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.TraceIdPatternLogbackLayout">-->
<!-- <pattern>[%tid] ${console.log.pattern}</pattern>-->
<!-- </layout>-->
<!-- <charset>utf-8</charset>-->
<!-- </encoder>-->
<!-- </appender>-->
<!--系统操作日志-->
<root level="info">
<!-- 分别引用上面的日志输出组件 -->
<appender-ref ref="console" />
<appender-ref ref="async_info" />
<appender-ref ref="async_error" />
<appender-ref ref="file_console" />
<!-- <appender-ref ref="sky_log"/>-->
</root>
</configuration>
# p6spy
日志打印通过 p6spy实现日志打印 , 通过数据源层级进行代理监听SQL , 代理驱动 com.mysql.cj.jdbc.Driver
maven依赖
<dependency>
<groupId>p6spy</groupId>
<artifactId>p6spy</artifactId>
<version>3.9.1</version>
</dependency>
配置
数据源配置 spring.datasource.dynamic.p6spy 设置为 true
(生产环境不建议启用该功能)
更多细节配置 spy.properties配置文件
点击展开
# p6spy 性能分析插件配置文件
# mybatis-plus 也实现了支持p6spy的日志打印
modulelist=com.baomidou.mybatisplus.extension.p6spy.MybatisPlusLogFactory,com.p6spy.engine.outage.P6OutageFactory
# 自定义日志打印
logMessageFormat=com.baomidou.mybatisplus.extension.p6spy.P6SpyLogger
# 日志输出到控制台
appender=com.baomidou.mybatisplus.extension.p6spy.StdoutLogger
# 使用日志系统记录 sql
#appender=com.p6spy.engine.spy.appender.Slf4JLogger
# 设置 p6spy driver 代理
#deregisterdrivers=true
# 取消JDBC URL前缀
useprefix=true
# 配置记录 Log 例外,可去掉的结果集有error,info,batch,debug,statement,commit,rollback,result,resultset.
excludecategories=info,debug,result,commit,resultset
# 日期格式
dateformat=yyyy-MM-dd HH:mm:ss
# SQL语句打印时间格式
databaseDialectTimestampFormat=yyyy-MM-dd HH:mm:ss
# 实际驱动可多个
#driverlist=org.h2.Driver
# 是否开启慢SQL记录 (超过2秒记录问题)
outagedetection=true
# 慢SQL记录标准 2 秒
outagedetectioninterval=2
# 是否过滤 Log
filter=true
# 过滤 Log 时所排除的 sql 关键字,以逗号分隔 (排除检测数据库存活的SQL)
exclude=SELECT 1
# 整合 Undertow
Undertow是Java开发的高性能Web容器 , SpringBoot框架内置有Web容器 (默认采用的是 Tomcat)
Undertow对比文章 : https://cloud.tencent.com (opens new window)
引入依赖
依赖排除 Tomcat , 并且引入 undertow 依赖
ruoyi-framework模块中的 pom.xml
<!-- SpringBoot Web容器 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<artifactId>spring-boot-starter-tomcat</artifactId>
<groupId>org.springframework.boot</groupId>
</exclusion>
</exclusions>
</dependency>
<!-- web 容器使用 undertow 性能更强 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-undertow</artifactId>
</dependency>
配置
ruoyi-admin模块中的 application.yml
# 开发环境配置
server:
# 服务器的HTTP端口,默认为8080
port: ${sys.port:8080}
servlet:
# 应用的访问路径
context-path: /
# undertow 配置
undertow:
# HTTP post内容的最大大小。当值为-1时,默认值为大小是无限的
max-http-post-size: -1
# 以下的配置会影响buffer,这些buffer会用于服务器连接的IO操作,有点类似netty的池化内存管理
# 每块buffer的空间大小,越小的空间被利用越充分
buffer-size: 512
# 是否分配的直接内存
direct-buffers: true
threads:
# 设置IO线程数, 它主要执行非阻塞的任务,它们会负责多个连接, 默认设置每个CPU核心一个线程
io: 8
# 阻塞任务线程池, 当执行类似servlet请求阻塞操作, undertow会从这个线程池中取得线程,它的值设置取决于系统的负载
worker: 256
ruoyi-framework模块中的 UndertowConfig
配置类
@Configuration
public class UndertowConfig implements WebServerFactoryCustomizer<UndertowServletWebServerFactory> {
/**
* 设置 Undertow 的 websocket 缓冲池
*/
@Override
public void customize(UndertowServletWebServerFactory factory) {
// 默认不直接分配内存 如果项目中使用了 websocket 建议直接分配
factory.addDeploymentInfoCustomizers(deploymentInfo -> {
WebSocketDeploymentInfo webSocketDeploymentInfo = new WebSocketDeploymentInfo();
webSocketDeploymentInfo.setBuffers(new DefaultByteBufferPool(false, 512));
deploymentInfo.addServletContextAttribute("io.undertow.websockets.jsr.WebSocketDeploymentInfo", webSocketDeploymentInfo);
});
}
}
# 整合 OpenApi
采用 SpringDoc框架 , 以 OpenAPI规范生成接口信息 , 基于 Javadoc无注解形式实现
OpenAPI文档 : https://openapi.apifox.cn (opens new window)
SpringDoc文档 : https://springdoc.org/ (opens new window)
若依文档 : https://plus-doc.dromara.org (opens new window)
javadoc使用方式 (基础应用注释即可 , 更多功能注解实现)
springdoc | javadoc |
---|---|
@Tag(name = "xxx") | java类注释第一行 |
@Tag(description= "xxx") | java类注释 |
@Operation | java方法注释 |
@Hidden | 无 |
@Parameter | java方法@param参数注释 |
@Parameter | java方法@param参数注释 |
@Parameters | 多个@param参数注释 |
@Schema | java实体类注释 |
@Schema | java属性注释 |
@Schema(accessMode = READ_ONLY) | 无 |
@ApiResponse | java方法@return返回值注释 |
# 源码
涉及类
类 | 说明 |
---|---|
SpringDocProperties / SpringDocConfigProperties | 配置属性类 (重叠配置) |
SpringDocConfig | 配置类 |
OpenApiHandler | 自定义OpenAPI处理 |
pom.xml
依赖
<!-- 核心依赖 -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-webmvc-core</artifactId>
<version>1.6.15</version>
</dependency>
<!-- 注释生成文档 -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-javadoc</artifactId>
<version>1.6.15</version>
</dependency>
pom.xml
插件处理
点击展开
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.9.0</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<encoding>${project.build.sourceEncoding}</encoding>
<!-- 注解处理路径 (一旦使用上 annotationProcessorPaths 就一定要为引用中写上 , 如:lombok) -->
<annotationProcessorPaths>
<path>
<groupId>com.github.therapi</groupId>
<artifactId>therapi-runtime-javadoc-scribe</artifactId>
<version>0.15.0</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<version>${spring-boot.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<!-- 单元测试使用 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.2</version>
<configuration>
<argLine>-Dfile.encoding=UTF-8</argLine>
<!-- 根据打包环境执行对应的@Tag测试方法 -->
<groups>${profiles.active}</groups>
<!-- 排除标签 -->
<excludedGroups>exclude</excludedGroups>
</configuration>
</plugin>
</plugins>
application.yml
配置文件
属性对应的类属性是重叠两个类使用 : SpringDocProperties
、SpringDocConfigProperties
点击展开
springdoc:
api-docs:
# 是否开启接口文档
enabled: true
# swagger-ui:
# # 持久化认证数据
# persistAuthorization: true
info:
# 标题
title: '标题:${ruoyi.name}后台管理系统_接口文档'
# 描述
description: '描述:用于管理集团旗下公司的人员信息,具体包括XXX,XXX模块...'
# 版本
version: '版本号: ${ruoyi-vue-plus.version}'
# 作者信息
contact:
name: Lion Li
email: crazylionli@163.com
url: https://gitee.com/dromara/RuoYi-Vue-Plus
components:
# 鉴权方式配置
security-schemes:
apiKey:
type: APIKEY
in: HEADER
name: ${sa-token.token-name}
#这里定义了两个分组,可定义多个,也可以不定义
group-configs:
- group: 1.演示模块
packages-to-scan: com.ruoyi.demo
- group: 2.系统模块
packages-to-scan: com.ruoyi.web
- group: 3.代码生成模块
packages-to-scan: com.ruoyi.generator
SpringDocCqonfig
配置类
点击展开
@RequiredArgsConstructor
@Configuration
@AutoConfigureBefore(SpringDocConfiguration.class)
@ConditionalOnProperty(name = "springdoc.api-docs.enabled", havingValue = "true", matchIfMissing = true)
public class SpringDocConfig {
private final ServerProperties serverProperties;
/**
* 注册 OpenAPI Bean
* @param properties 参数注入Bean
*/
@Bean
@ConditionalOnMissingBean(OpenAPI.class)
public OpenAPI openApi(SpringDocProperties properties) {
OpenAPI openApi = new OpenAPI();
// 设置 文档基本信息
SpringDocProperties.InfoProperties infoProperties = properties.getInfo();
Info info = convertInfo(infoProperties);
openApi.info(info);
// 设置 扩展文档信息
openApi.externalDocs(properties.getExternalDocs());
openApi.tags(properties.getTags());
openApi.paths(properties.getPaths());
openApi.components(properties.getComponents());
// 设置 安全信息
Set<String> keySet = properties.getComponents().getSecuritySchemes().keySet();
List<SecurityRequirement> list = new ArrayList<>();
SecurityRequirement securityRequirement = new SecurityRequirement();
keySet.forEach(securityRequirement::addList);
list.add(securityRequirement);
openApi.security(list);
return openApi;
}
private Info convertInfo(SpringDocProperties.InfoProperties infoProperties) {
Info info = new Info();
info.setTitle(infoProperties.getTitle());
info.setDescription(infoProperties.getDescription());
info.setContact(infoProperties.getContact());
info.setLicense(infoProperties.getLicense());
info.setVersion(infoProperties.getVersion());
return info;
}
/**
* 自定义 openapi 处理器
*/
@Bean
public OpenAPIService openApiBuilder(Optional<OpenAPI> openAPI,
SecurityService securityParser,
SpringDocConfigProperties springDocConfigProperties,
PropertyResolverUtils propertyResolverUtils,
Optional<List<OpenApiBuilderCustomizer>> openApiBuilderCustomisers,
Optional<List<ServerBaseUrlCustomizer>> serverBaseUrlCustomisers,
Optional<JavadocProvider> javadocProvider) {
return new OpenApiHandler(openAPI, securityParser, springDocConfigProperties, propertyResolverUtils, openApiBuilderCustomisers, serverBaseUrlCustomisers, javadocProvider);
}
/**
* 对已经生成好的 OpenApi 进行自定义操作
* 对含有前缀的路径进行拼接处理
*/
@Bean
public OpenApiCustomiser openApiCustomiser() {
// 获取 server.servlet.context-path 配置指
String contextPath = serverProperties.getServlet().getContextPath();
String finalContextPath;
if (StringUtils.isBlank(contextPath) || "/".equals(contextPath)) {
finalContextPath = "";
} else {
finalContextPath = contextPath;
}
// 对所有路径增加前置上下文路径
return openApi -> {
Paths oldPaths = openApi.getPaths();
if (oldPaths instanceof PlusPaths) {
return;
}
PlusPaths newPaths = new PlusPaths();
oldPaths.forEach((k,v) -> newPaths.addPathItem(finalContextPath + k, v));
openApi.setPaths(newPaths);
};
}
/**
* 单独使用一个类便于判断 解决springdoc路径拼接重复问题
*
* @author Lion Li
*/
static class PlusPaths extends Paths {
public PlusPaths() {
super();
}
}
}
# 重写功能
问题 : 导出的接口信息 , 默认采用当前类名进行作为标识名称 . 在一些情况下查阅会显得费劲
解决思路 : 继承类并重写方法 OpenAPIService#buildTags()
, 替换默认形式的名称即可
OpenApiHandler
类
点击展开
@SuppressWarnings("all")
public class OpenApiHandler extends OpenAPIService {
/**
* The constant LOGGER.
*/
private static final Logger LOGGER = LoggerFactory.getLogger(OpenAPIService.class);
/**
* The Context.
*/
private ApplicationContext context;
/**
* The Security parser.
*/
private final SecurityService securityParser;
/**
* The Mappings map.
*/
private final Map<String, Object> mappingsMap = new HashMap<>();
/**
* The Springdoc tags.
*/
private final Map<HandlerMethod, io.swagger.v3.oas.models.tags.Tag> springdocTags = new HashMap<>();
/**
* The Open api builder customisers.
*/
private final Optional<List<OpenApiBuilderCustomizer>> openApiBuilderCustomisers;
/**
* The server base URL customisers.
*/
private final Optional<List<ServerBaseUrlCustomizer>> serverBaseUrlCustomizers;
/**
* The Spring doc config properties.
*/
private final SpringDocConfigProperties springDocConfigProperties;
/**
* The Open api.
*/
private OpenAPI openAPI;
/**
* The Cached open api map.
*/
private final Map<String, OpenAPI> cachedOpenAPI = new HashMap<>();
/**
* The Is servers present.
*/
private boolean isServersPresent;
/**
* The Server base url.
*/
private String serverBaseUrl;
/**
* The Property resolver utils.
*/
private final PropertyResolverUtils propertyResolverUtils;
/**
* The javadoc provider.
*/
private final Optional<JavadocProvider> javadocProvider;
/**
* The Basic error controller.
*/
private static Class<?> basicErrorController;
static {
try {
//spring-boot 2
basicErrorController = Class.forName("org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController");
} catch (ClassNotFoundException e) {
//spring-boot 1
try {
basicErrorController = Class.forName("org.springframework.boot.autoconfigure.web.BasicErrorController");
} catch (ClassNotFoundException classNotFoundException) {
//Basic error controller class not found
LOGGER.trace(classNotFoundException.getMessage());
}
}
}
/**
* Instantiates a new Open api builder.
*
* @param openAPI the open api
* @param securityParser the security parser
* @param springDocConfigProperties the spring doc config properties
* @param propertyResolverUtils the property resolver utils
* @param openApiBuilderCustomizers the open api builder customisers
* @param serverBaseUrlCustomizers the server base url customizers
* @param javadocProvider the javadoc provider
*/
public OpenApiHandler(Optional<OpenAPI> openAPI, SecurityService securityParser,
SpringDocConfigProperties springDocConfigProperties, PropertyResolverUtils propertyResolverUtils,
Optional<List<OpenApiBuilderCustomizer>> openApiBuilderCustomizers,
Optional<List<ServerBaseUrlCustomizer>> serverBaseUrlCustomizers,
Optional<JavadocProvider> javadocProvider) {
// 保留父类具有的信息
super(openAPI, securityParser, springDocConfigProperties, propertyResolverUtils, openApiBuilderCustomizers, serverBaseUrlCustomizers, javadocProvider);
if (openAPI.isPresent()) {
this.openAPI = openAPI.get();
if (this.openAPI.getComponents() == null)
this.openAPI.setComponents(new Components());
if (this.openAPI.getPaths() == null)
this.openAPI.setPaths(new Paths());
if (!CollectionUtils.isEmpty(this.openAPI.getServers()))
this.isServersPresent = true;
}
this.propertyResolverUtils = propertyResolverUtils;
this.securityParser = securityParser;
this.springDocConfigProperties = springDocConfigProperties;
this.openApiBuilderCustomisers = openApiBuilderCustomizers;
this.serverBaseUrlCustomizers = serverBaseUrlCustomizers;
this.javadocProvider = javadocProvider;
if (springDocConfigProperties.isUseFqn())
TypeNameResolver.std.setUseFqn(true);
}
@Override
public Operation buildTags(HandlerMethod handlerMethod, Operation operation, OpenAPI openAPI, Locale locale) {
Set<Tag> tags = new HashSet<>();
Set<String> tagsStr = new HashSet<>();
buildTagsFromMethod(handlerMethod.getMethod(), tags, tagsStr, locale);
buildTagsFromClass(handlerMethod.getBeanType(), tags, tagsStr, locale);
if (!CollectionUtils.isEmpty(tagsStr))
tagsStr = tagsStr.stream()
.map(str -> propertyResolverUtils.resolve(str, locale))
.collect(Collectors.toSet());
if (springdocTags.containsKey(handlerMethod)) {
io.swagger.v3.oas.models.tags.Tag tag = springdocTags.get(handlerMethod);
tagsStr.add(tag.getName());
if (openAPI.getTags() == null || !openAPI.getTags().contains(tag)) {
openAPI.addTagsItem(tag);
}
}
if (!CollectionUtils.isEmpty(tagsStr)) {
if (CollectionUtils.isEmpty(operation.getTags()))
operation.setTags(new ArrayList<>(tagsStr));
else {
Set<String> operationTagsSet = new HashSet<>(operation.getTags());
operationTagsSet.addAll(tagsStr);
operation.getTags().clear();
operation.getTags().addAll(operationTagsSet);
}
}
if (isAutoTagClasses(operation)) {
if (javadocProvider.isPresent()) {
String description = javadocProvider.get().getClassJavadoc(handlerMethod.getBeanType());
if (StringUtils.isNotBlank(description)) {
io.swagger.v3.oas.models.tags.Tag tag = new io.swagger.v3.oas.models.tags.Tag();
// 自定义部分 修改使用java注释当tag名
List<String> list = IoUtil.readLines(new StringReader(description), new ArrayList<>());
// tag.setName(tagAutoName);
// 类中的第一行作为方法的tag
tag.setName(list.get(0));
operation.addTagsItem(list.get(0));
// 描述信息全部保留
tag.setDescription(description);
if (openAPI.getTags() == null || !openAPI.getTags().contains(tag)) {
openAPI.addTagsItem(tag);
}
}
} else {
String tagAutoName = splitCamelCase(handlerMethod.getBeanType().getSimpleName());
operation.addTagsItem(tagAutoName);
}
}
if (!CollectionUtils.isEmpty(tags)) {
// Existing tags
List<io.swagger.v3.oas.models.tags.Tag> openApiTags = openAPI.getTags();
if (!CollectionUtils.isEmpty(openApiTags))
tags.addAll(openApiTags);
openAPI.setTags(new ArrayList<>(tags));
}
// Handle SecurityRequirement at operation level
io.swagger.v3.oas.annotations.security.SecurityRequirement[] securityRequirements = securityParser
.getSecurityRequirements(handlerMethod);
if (securityRequirements != null) {
if (securityRequirements.length == 0)
operation.setSecurity(Collections.emptyList());
else
securityParser.buildSecurityRequirement(securityRequirements, operation);
}
return operation;
}
private void buildTagsFromMethod(Method method, Set<io.swagger.v3.oas.models.tags.Tag> tags, Set<String> tagsStr, Locale locale) {
// method tags
Set<Tags> tagsSet = AnnotatedElementUtils
.findAllMergedAnnotations(method, Tags.class);
Set<io.swagger.v3.oas.annotations.tags.Tag> methodTags = tagsSet.stream()
.flatMap(x -> Stream.of(x.value())).collect(Collectors.toSet());
methodTags.addAll(AnnotatedElementUtils.findAllMergedAnnotations(method, io.swagger.v3.oas.annotations.tags.Tag.class));
if (!CollectionUtils.isEmpty(methodTags)) {
tagsStr.addAll(methodTags.stream().map(tag -> propertyResolverUtils.resolve(tag.name(), locale)).collect(Collectors.toSet()));
List<io.swagger.v3.oas.annotations.tags.Tag> allTags = new ArrayList<>(methodTags);
addTags(allTags, tags, locale);
}
}
private void addTags(List<io.swagger.v3.oas.annotations.tags.Tag> sourceTags, Set<io.swagger.v3.oas.models.tags.Tag> tags, Locale locale) {
Optional<Set<io.swagger.v3.oas.models.tags.Tag>> optionalTagSet = AnnotationsUtils
.getTags(sourceTags.toArray(new io.swagger.v3.oas.annotations.tags.Tag[0]), true);
optionalTagSet.ifPresent(tagsSet -> {
tagsSet.forEach(tag -> {
tag.name(propertyResolverUtils.resolve(tag.getName(), locale));
tag.description(propertyResolverUtils.resolve(tag.getDescription(), locale));
if (tags.stream().noneMatch(t -> t.getName().equals(tag.getName())))
tags.add(tag);
});
});
}
}
# 使用方式
# 初始应用
大致流程
# 模块引用
大致流程
- 新建模块 , 关联模块 (opens new window) 并引入 common模块依赖
- 配置模块 , admin模块配置文件中 , 配置 ==springdoc.group-configs==包路径指定模块
- 启用访问 https://localhost:8080/v3/api-docs/{模块名} (opens new window) https://localhost:8080/v3/api-docs/1.演示模块 (opens new window)
# apifox自动导入
大致流程
打开 apifox (opens new window) -> 进入项目 -> 项目设置 -> 定时导入 -> 新建
根据 模块URL形式进行定时导入
这种形式模块化分类明确!