若依

管理员:admin,admin123
普通用户:ry,admin123

Ruoyi-Vue

1 权限管理

ruoyi 的权限管理是用 @PreAuthorize 来实现的,它是 SpringSecurity 的一个注解
这个注解用在方法上,当这个注解里的值是 true 的时候代表可以访问
注解支持 EL 表达式

1
2
3
4
5
6
7
8
9
10
11
12
13
@RestController
@RequestMapping("/monitor/server")
public class ServerController
{
@PreAuthorize("@ss.hasPermi('monitor:server:list')")
@GetMapping()
public AjaxResult getInfo() throws Exception
{
Server server = new Server();
server.copyTo();
return AjaxResult.success(server);
}
}

其中,@ss 表示作者自定义的一个 Service 方法,取了别名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service("ss")
public class PermissionService
{
/**
* 验证用户是否具备某权限
*
* @param permission 权限字符串
* @return 用户是否具备某权限
*/
public boolean hasPermi(String permission)
{
if (StringUtils.isEmpty(permission))
{
return false;
}
LoginUser loginUser = SecurityUtils.getLoginUser();
if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions()))
{
return false;
}
PermissionContextHolder.setContext(permission);
return hasPermissions(loginUser.getPermissions(), permission);
}
}

其中 @PreAuthorize("@ss.hasPermi('monitor:server:list')") 调用了 PermissionService 的 hasPermi 方法,传入了一个权限列表

ruoyi 的权限管理是在数据表 sys_menu 里的,其中有一列 perms 定义了各种各样的权限

然后在代码里有一个类 LoginUser 里的一个属性 Set<String> permissions 对权限做了封装,判断一个用户是否有目标资源的权限,就看这个 Set 集合里有没有对应的权限列表

ruoyi 的用户对应的菜单权限是在:系统管理 / 角色管理里修改的,对应的表是 sys_role_menu

2 事务管理

@Transactional 注解只能应用到 public 可见度的方法上(因为 @Transactional 基于 AOP 实现的事务管理,AOP 只会代理 public 的方法)

  1. 检查性异常,事务不会回滚;正确做法是,在 @Transactional 中加入 rollbackFor
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    @Transactional(rollbackFor = Exception.class)
    public int insertUser(User user) throws Exception{
    // 新增用户信息
    int rows = userMapper.insertUser(user);
    // 新增用户岗位关联
    insertUserPost(user);
    // 新增用户与角色管理
    insertUserRole(user);
    // 模拟抛出SQLException异常
    boolean flag = true;
    if (flag){
    throw new SQLException("发生异常了..");
    }
    return rows;
    }
  2. try-catch 自己手动捕获异常,不会回滚;正确做法是在业务层统一抛出异常,全局异常处理器处理

3 系统日志

定义自定义注解 @Log 添加到被记录日志的 Controller 方法上

1
2
3
4
5
6
7
8
9
10
11
12
13
public @interface Log{
String title() default ""; // 模块
// 功能
BusinessType businessType() default BusinessType.OTHER;
// 操作人类别
OperatorType operatorType() default OperatorType.MANAGE;
// 是否保存请求参数
boolean isSaveRequestData() default true;
// 是否保存响应参数
boolean isSaveResponseData() default true;
// 排除指定请求参数
String[] excludeParamNames() default {};
}

其中 BusinessType 和 OperatorType 是枚举类型

1
2
3
4
5
6
7
8
9
10
11
12
public enum BusinessType{
OTHER, // 其他
INSERT, // 新增
UPDATE, // 更新
DELETE, // 修改
...
}
public enum OperatorType{
OTHER, // 其他
MANAGE, // 后台用户
MOBILE // 手机端用户
}

使用方式

1
2
3
4
5
6
7
8
@PreAuthorize("@ss.hasPermi('system:dict:add')")
@Log(title = "字典数据", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@Validated @RequestBody SysDictData dict)
{
dict.setCreateBy(getUsername());
return toAjax(dictDataService.insertDictData(dict));
}

定义一个切面类,其中前置请求把当前时间存入到 ThreadLocal中,为了计算操作消耗时间
其中一个参数是 Log controllerLog,这个参数相当于 Log 注解的一个实例,有了这个参数就可以用@Before(value = "@annotation(controllerLog)") 这种方式表示所有带 @Log 注解的方法

1
2
3
4
@Before(value = "@annotation(controllerLog)")
public void boBefore(JoinPoint joinPoint, Log controllerLog){
TIME_THREADLOCAL.set(System.currentTimeMillis());
}

@AfterReturning 是正常执行完返回的情况,其中注解中的 returning 表示的是代理方法的返回值,写到了参数中

1
2
3
4
@AfterReturning(pointcut = "@annotation(controllerLog)", returning = "jsonResult")
public void doAfterReturning(JoinPoint joinPoint, Log controllerLog, Object jsonResult){
handleLog(joinPoint, controllerLog, null, jsonResult);
}

拦截异常操作,throwing 表示抛出的异常

1
2
3
4
@AfterThrowing(value = "@annotation(controllerLog)", throwing = "e")
public void doAfterThrowing(JoinPoint joinPoint, Log controllerLog, Exception e){
handleLog(joinPoint, controllerLog, e, null);
}

4 数据校验

用@Validated 注解可以帮忙校验前端传过来的数据,引入以下的包

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

@NotNull 不能为null
@NotBlank 不能为空,常用于检查空字符串
@NotEmpty 不能为空,多用于检测list是否size是0
@Max 该字段的值只能小于或等于该值
@Min 该字段的值只能大于或等于该值
@Past 检查该字段的日期是在过去
@Future 检查该字段的日期是否是属于将来的日期
@Email 检查是否是一个有效的email地址
使用方法:

1
2
3
4
5
6
7
8
9
10
11
@PostMapping("/test")
public String test(@Validated @RequestBody UserDto userDto){
...
}
// 在 UserDto 的 get 方法上或者字段上可以声明规则
@Data
public class UserDto {
private String name;
@NotNull(message = "用户密码不能为空")
private String password;
}

还可以自定义注解校验器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/**
* 自定义xss校验注解
*
* @author ruoyi
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(value = { ElementType.METHOD, ElementType.FIELD, ElementType.CONSTRUCTOR, ElementType.PARAMETER })
@Constraint(validatedBy = { XssValidator.class })
public @interface Xss
{
String message()

default "不允许任何脚本运行";

Class<?>[] groups() default {};

Class<? extends Payload>[] payload() default {};
}

/**
* 自定义xss校验注解实现
*
* @author ruoyi
*/
public class XssValidator implements ConstraintValidator<Xss, String>
{
private final String HTML_PATTERN = "<(\\S*?)[^>]*>.*?|<.*? />";

@Override
public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext)
{
return !containsHtml(value);
}

public boolean containsHtml(String value)
{
Pattern pattern = Pattern.compile(HTML_PATTERN);
Matcher matcher = pattern.matcher(value);
return matcher.matches();
}
}

// 使用自定义注解
@Xss(message = "登录账号不能包含脚本字符")
@NotBlank(message = "登录账号不能为空")
@Size(min = 0, max = 30, message = "登录账号长度不能超过30个字符")
public String getLoginName()
{
return loginName;
}

5 数据权限

用户只能看到自己部门的数据,这种情况一般被称为数据权限
在 SysRole 类中,有一个字段,dataScope 表示数据范围

  1. 所有数据权限
  2. 自定义数据权限
  3. 本部门数据权限
  4. 本部门及以下数据权限
  5. 仅本人数据权限
    核心是利用自定义注解,@DataScope 和 处理 AOP 的类 DataScopeAspect 来实现
    @DataScope 注解里定义了三个属性
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface DataScope
    {
    /**
    * 部门表的别名
    */
    public String deptAlias() default "";

    /**
    * 用户表的别名
    */
    public String userAlias() default "";

    /**
    * 权限字符(用于多个角色匹配符合要求的权限)默认根据权限注解@ss获取,多个权限用逗号分隔开来
    */
    public String permission() default "";
    }
    其中在 DataScopeAspect 类中可以收到这三个属性
    这个注解一般放在 Service 层的方法上,传进来的一般是 pojo 对象
    1
    2
    3
    4
    5
    @Override
    @DataScope(deptAlias = "d")
    public List<SysDept> selectDeptList(SysDept dept){
    return deptMapper.selectDeptList(dept);
    }
    传进来的 pojo 对象,比如 SysDept 集成了 BaseEntity,BaseEntity 中有一个属性 Map<String, Object> params;
    在 AOP 的过程中,会取出来这个字段,根据不同角色的权限,往里面动态添加一些 SQL 语句
    1
    2
    3
    4
    5
    6
    7
    if (StringUtils.isNotBlank(sqlString.toString())){
    Object params = joinPoint.getArgs()[0];
    if (StringUtils.isNotNull(params) && params instanceof BaseEntity){
    BaseEntity baseEntity = (BaseEntity) params;
    baseEntity.getParams().put(DATA_SCOPE, " AND (" + sqlString.substring(4) + ")");
    }
    }
    最后在 mapper.xml 文件中,会把添加的内容拼接到查询语句最后 ${params.dataScope}
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    <select id="selectDeptList" parameterType="SysDept" resultMap="SysDeptResult">
    <include refid="selectDeptVo"/>
    where d.del_flag = '0'
    <if test="deptId != null and deptId != 0">
    AND dept_id = #{deptId}
    </if>
    <if test="parentId != null and parentId != 0">
    AND parent_id = #{parentId}
    </if>
    <if test="deptName != null and deptName != ''">
    AND dept_name like concat('%', #{deptName}, '%')
    </if>
    <if test="status != null and status != ''">
    AND status = #{status}
    </if>
    <!-- 数据范围过滤 -->
    ${params.dataScope}
    order by d.parent_id, d.order_num
    </select>

6 异常处理

首先需要自定义异常,比如这里的登录异常,继承了 RuntimeException,保证 Spring 事务不失效,因为 Spring 事务默认处理的是运行时异常,如果抛出检查性异常就不会进行回滚

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.ruoyi.common.exception;

/**
* 登录异常
*
* @author ruoyi
*/
public class LoginException extends RuntimeException{
private static final long serialVersionUID = 1L;

protected final String message;

public LoginException(String message){
this.message = message;
}

@Override
public String getMessage(){
return message;
}
}

创建全局异常处理器,在类上加一个注解,@RestControllerAdvice = @ControllerAdvice + @ResponseBody

1
2
3
4
5
6
7
8
9
10
11
12
13
@RestControllerAdvice
public class GlobalExceptionHandler{
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

/**
* 登录异常
*/
@ExceptionHandler(LoginException.class)
public AjaxResult loginException(LoginException e){
log.error(e.getMessage(), e);
return AjaxResult.error(e.getMessage());
}
}

7 数据导入导出

7.1 导出

8 登录管理

8.1 SpringSecurity 前置知识

SpringSecurity 认证流程

Filter->构造Token->AuthenticationManager->转给Provider处理->认证处理成功后续操作或者不通过抛异常

登录逻辑

登录👉拿到token👉请求带上token👉JWT过滤器拦截👉校验token👉将从缓存中查出来的对象放到上下文中

重要概念

  • SecurityContext:上下文对象,Authentication 对象会放在里面。
  • SecurityContextHolder:用于拿到上下文对象的静态工具类。
  • Authentication:认证接口,定义了认证对象的数据形式。
  • AuthenticationManager:用于校验 Authentication ,返回一个认证完成后的 Authentication 对象
    • Authentication 是个接口, 常用的实现类是 UsernamePasswordAuthenticationToken
    • 没认证的 UsernamePasswordAuthenticationTokenthis.setAuthenticated(false);
    • 认证之后是 super.setAuthenticated(true);
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
      super((Collection)null);
      this.principal = principal;
      this.credentials = credentials;
      this.setAuthenticated(false);
      }
      public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
      super(authorities);
      this.principal = principal;
      this.credentials = credentials;
      super.setAuthenticated(true);
      }
  1. SecurityContext
    上下文对象, 认证后的数据就放在这里

    1
    2
    3
    4
    5
    6
    7
    public interface SecurityContext extends Serializable {
    // 获取Authentication对象
    Authentication getAuthentication();

    // 放入Authentication对象
    void setAuthentication(Authentication authentication);
    }
  2. SecurityContextHolder
    可以说是SecurityContext的工具类,用于get or set or clear SecurityContext,默认会把数据都存储到当前线程中

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public class SecurityContextHolder {

    public static void clearContext() {
    strategy.clearContext();
    }

    public static SecurityContext getContext() {
    return strategy.getContext();
    }

    public static void setContext(SecurityContext context) {
    strategy.setContext(context);
    }

    }
  3. AuthenticationManager
    将一个未认证的Authentication传入,返回一个已认证的Authentication,默认使用的实现类为:ProviderManager

    1
    2
    3
    4
    5
    public interface AuthenticationManager {
    // 认证方法
    Authentication authenticate(Authentication authentication)
    throws AuthenticationException;
    }

Controller 的请求到 SysLoginController#login

1
2
3
4
5
6
7
8
9
10
@PostMapping("/login")
public AjaxResult login(@RequestBody LoginBody loginBody)
{
AjaxResult ajax = AjaxResult.success();
// 生成令牌
String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),
loginBody.getUuid());
ajax.put(Constants.TOKEN, token);
return ajax;
}

调用 Service 的 login 方法

其中 uuid 来验证输入的验证码与 Redis 中的验证码是否匹配

1
authentication = authenticationManager.authenticate(authenticationToken);

SpringSecurity 的验证方法鉴权,这个语句是核心语句
下面就 authenticationManager 对象创建
authenticate 方法参数,来解析这个登录鉴权过程

  1. 传入用户名和密码创建了一个UsernamePasswordAuthenticationToken对象,这是我们前面说过的Authentication的实现类,传入用户名和密码做构造参数,这个对象就是我们创建出来的未认证的Authentication对象。
  2. 使用我们先前已经声明过的Bean-authenticationManager调用它的authenticate方法进行认证,返回一个认证完成的Authentication对象。
  3. 认证完成没有出现异常,使用SecurityContextHolder获取SecurityContext之后,将认证完成之后的Authentication对象,放入上下文对象。
  4. 从Authentication对象中拿到我们的UserDetails对象,之前我们说过,认证后的Authentication对象调用它的getPrincipal()方法就可以拿到我们先前数据库查询后组装出来的UserDetails对象,然后创建token。

其中 authenticationManager 对象是配置类配置的 bean
定义了一个配置类 SecurityConfig 继承了 WebSecurityConfigurerAdapter 定义了 AuthenticationManager 对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter{
/**
* 解决 无法直接注入 AuthenticationManager
*
* @return
* @throws Exception
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception
{
return super.authenticationManagerBean();
}
}

核心代码中调用的这个方法 authenticate 传入的参数是 Authentication 类型的,所以需要在前面创建出 Authentication 类的对象

1
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);

其中 UsernamePasswordAuthenticationTokenAuthentication 类的子类,需要把用户名和密码传递进去
这是 authenticate 方法传递的参数来源

之后 authenticate 方法会执行一个过滤器链(SpringSecurity 自动调用), 最终会调用到 UserDetailsService 这个接口的 loadUserByUsername 方法, 这里有一个实现类 UserDetailsServiceImpl 实现了这个接口接口重写了方法
所以最终 authenticate 会调用 UserDetailsServiceImpl.loadUserByUsername

SysLoginService.login 方法, 核心代码之前还有一个, 把 Authentication 对象存到 ThreadLocal 的操作

1
2
3
4
5
// 给用户名密码封装到这里
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
AuthenticationContextHolder.setContext(authenticationToken);
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
authentication = authenticationManager.authenticate(authenticationToken);

其中第二行把 Authentication 保存到了 ThreadLocal 中, AuthenticationContextHolder 底层封装了 ThreadLocal
UserDetailsServiceImpl.loadUserByUsername

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException
{
SysUser user = userService.selectUserByUserName(username);
if (StringUtils.isNull(user))
{
log.info("登录用户:{} 不存在.", username);
throw new ServiceException(MessageUtils.message("user.not.exists"));
}
else if (UserStatus.DELETED.getCode().equals(user.getDelFlag()))
{
log.info("登录用户:{} 已被删除.", username);
throw new ServiceException(MessageUtils.message("user.password.delete"));
}
else if (UserStatus.DISABLE.getCode().equals(user.getStatus()))
{
log.info("登录用户:{} 已被停用.", username);
throw new ServiceException(MessageUtils.message("user.blocked"));
}

passwordService.validate(user);

return createLoginUser(user);
}

根据 username 查询了数据库, 得到了 SysUser 对象
调用了 passwordService.validate() 方法, 进入 validate 方法内
这个方法内

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public void validate(SysUser user)
{
Authentication usernamePasswordAuthenticationToken = AuthenticationContextHolder.getContext();
String username = usernamePasswordAuthenticationToken.getName();
String password = usernamePasswordAuthenticationToken.getCredentials().toString();

Integer retryCount = redisCache.getCacheObject(getCacheKey(username));

if (retryCount == null)
{
retryCount = 0;
}

if (retryCount >= Integer.valueOf(maxRetryCount).intValue())
{
throw new UserPasswordRetryLimitExceedException(maxRetryCount, lockTime);
}

if (!matches(user, password))
{
retryCount = retryCount + 1;
redisCache.setCacheObject(getCacheKey(username), retryCount, lockTime, TimeUnit.MINUTES);
throw new UserPasswordNotMatchException();
}
else
{
clearLoginRecordCache(username);
}
}

这个方法首先从之前的 AuthenticationContextHolder (本质上是一个 ThreadLocal) 从取出之前存的 UsernamePasswordAuthenticationToken 对象(里面是用户输入的用户名和密码)

username 和 password 是他从 UsernamePasswordAuthenticationToken 里取出来的
跟数据库查出来的密码进行比较(加密之后比较, 因为数据库里是存储的密文, SpringSecurity 自动加密后的, SpringSecurity 默认是用 BCryptPasswordEncoder 来进行加密的)

有一个 retryCount 变量, 如果
如果不匹配, 把 retryCount 放到 Redis 里默认设置 10 分钟, 如果超过最大重试次数设置的是 5 次, 就抛出异常

如果期间内匹配成功, 就从 Redis 中删除 key

最后 UserDetailsServiceImpl.loadUserByUsername 是 SpringSecurity 提供的方法, 它的返回值是 UserDetails 类型的, ruoyi 创建了一个 LoginUser 类继承了 UserDetails
所以 UserDetailsServiceImpl.loadUserByUsername 方法返回了 LoginUser 对象
权限字符是在 UserDetailsServiceImpl.createLoginUser 方法中通过构造方法存入 LoginUser 中的, 有个调用 permissionService 的行为

1
2
3
public UserDetails createLoginUser(SysUser user){
return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
}

前面 UserDetailsServiceImpl.loadUserByUsername 执行成功之后会把 UserDetails 对象存储到 authentication 对象中
回到 SysLoginService 方法中, 第二行得到 getPrincipal 对象可以强转为 LoginUser

1
2
3
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
recordLoginInfo(loginUser.getUserId());

image.png

第一行是异步执行方法, todo
最后一行是记录用户信息, 登录的 ip 和登录的时间

9 异步任务管理器

他定义了一个类, 用来管理异步任务调用
其中这个成员变量获取 ThreadPoolTaskExecutor 对象

1
private ScheduledExecutorService executor = SpringUtils.getBean("scheduledExecutorService");

其中 SpringUtils 是 ruoyi 封装的一个工具类实现了 BeanFactoryPostProcessor 和 ApplicationContextAware 两个接口

  • BeanFactoryPostProcessor 接口是为了拿到 ConfigurableListableBeanFactory 对象
  • ApplicationContextAware 接口是为了拿到 ApplicationContext
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    @Component
    public final class SpringUtils implements BeanFactoryPostProcessor, ApplicationContextAware
    {
    /** Spring应用上下文环境 */
    private static ConfigurableListableBeanFactory beanFactory;

    private static ApplicationContext applicationContext;

    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException
    {
    SpringUtils.beanFactory = beanFactory;
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException
    {
    SpringUtils.applicationContext = applicationContext;
    }
    /**
    * 获取对象
    *
    * @param name
    * @return Object 一个以所给名字注册的bean的实例
    * @throws org.springframework.beans.BeansException
    *
    */
    @SuppressWarnings("unchecked")
    public static <T> T getBean(String name) throws BeansException
    {
    return (T) beanFactory.getBean(name);
    }
    // 下面还有方法, 感兴趣可以翻看 ruoyi 源码
    }

回到 AsyncManager 对象里, 得到了 ScheduledExecutorService 这个 bean, 这个 bean 定义在ThreadPoolConfig

1
private ScheduledExecutorService executor = SpringUtils.getBean("scheduledExecutorService");
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 执行周期性或定时任务
*/
@Bean(name = "scheduledExecutorService")
protected ScheduledExecutorService scheduledExecutorService()
{
return new ScheduledThreadPoolExecutor(corePoolSize,
new BasicThreadFactory.Builder().namingPattern("schedule-pool-%d").daemon(true).build(),
new ThreadPoolExecutor.CallerRunsPolicy())
{
@Override
protected void afterExecute(Runnable r, Throwable t)
{
super.afterExecute(r, t);
Threads.printException(r, t);
}
};
}

这里 new 了一个 ScheduledThreadPoolExecutor 对象
并且重写了一个 afterExecute 方法, 这个方法是 ScheduledThreadPoolExecutor 类继承自 ThreadPoolExecutor 类的
这种写法第一次见

回到 AsyncManager 采用单例模式生成对象

1
2
3
4
5
6
7
8
9
10
11
/**
* 单例模式
*/
private AsyncManager(){}

private static AsyncManager me = new AsyncManager();

public static AsyncManager me()
{
return me;
}

有个方法执行的函数, 传入任务, 延时时间, 延时时间的单位

1
2
3
4
public void execute(TimerTask task)
{
executor.schedule(task, OPERATE_DELAY_TIME, TimeUnit.MILLISECONDS);
}

10 异步工厂(产生任务用)

这个工厂是专门记录登录日志的, 存储到 sys_logininfor 表中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 异步工厂(产生任务用)
*
* @author ruoyi
*/
public class AsyncFactory
{
private static final Logger sys_user_logger = LoggerFactory.getLogger("sys-user");

/**
* 记录登录信息
*
* @param username 用户名
* @param status 状态
* @param message 消息
* @param args 列表
* @return 任务task
*/
public static TimerTask recordLogininfor(final String username, final String status, final String message,
final Object... args)
}

11 日志记录 LogAspect 类

首先创建一个日志记录对象, 其中 LogAspect.class 是为了标识这个日志对象, 相当于给这个日志对象起了个名字

1
private static final Logger log = LoggerFactory.getLogger(LogAspect.class);

getLogger 创建对象跟 @Slf4j 有什么区别

  • getLogger 创建对象是直接使用 SLF4J 的 api 创建一个 Logger 对象, 用于记录日志, LogAspect.class 是这个 Logger 的名字, 通常用于区分不同类
  • @Slf4j 是 lombok 提供的一个注解, 用于在编译时自动在类中声明一个名为 log 的 Logger 对象, 创建方式与上述代码相同

日志类中定义了一个 ThreadLocal, 用于计算操作消耗的时间, 在方法执行前 set 当前时间, 方法返回时用当前时间减去之前 set 的时间, 最后在 finally 代码块中 remove 掉

1
2
3
4
5
6
7
8
9
10
11
12
/** 计算操作消耗时间 */
private static final ThreadLocal<Long> TIME_THREADLOCAL = new NamedThreadLocal<>("Cost Time");

/**
* 处理请求前执行
*/
// 代表有上面有 @Log 注解的方法,需要代理
@Before(value = "@annotation(controllerLog)")
public void boBefore(JoinPoint joinPoint, Log controllerLog)
{
TIME_THREADLOCAL.set(System.currentTimeMillis());
}

处理完请求执行
其中注解上的 returning = jsonResult 应该是为了跟方法参数中的 jsonResult 绑定, 为了获取目标方法的执行结果
注解上的 pointcut = "@annotation(controllerLog)" 应该是为了跟方法参数中 Log controllerLog 绑定, 只要是方法上有 @Log 注解的, 都要被代理

1
2
3
4
5
6
7
8
9
10
11
/**
* 处理完请求后执行
*
* @param joinPoint 切点
*/
// returning 是为了获取目标方法的执行结果
@AfterReturning(pointcut = "@annotation(controllerLog)", returning = "jsonResult")
public void doAfterReturning(JoinPoint joinPoint, Log controllerLog, Object jsonResult)
{
handleLog(joinPoint, controllerLog, null, jsonResult);
}

将切点, @Log 注解, 返回值都传递给 handleLog 方法

handleLog 方法接收 4 个参数

  • 切点
  • 注解
  • 异常
  • 返回值
    1
    protected void handleLog(final JoinPoint joinPoint, Log controllerLog, final Exception e, Object jsonResult)

创建数据库日志对象, 封装属性
先给操作状态设置成成功,

1
2
SysOperLog operLog = new SysOperLog();
operLog.setStatus(BusinessStatus.SUCCESS.ordinal());
  • BusinessStatus 是一个枚举类型, 里面有两个值
  • 枚举类型本身里面有属性
    • name 枚举的名字, 比如 BusinessStatus.SUCCESS.name() 得到的就是字符串 SUCCESS
    • ordinal 是枚举的序数, 从 0 开始
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      public enum BusinessStatus
      {
      /**
      * 成功
      */
      SUCCESS,

      /**
      * 失败
      */
      FAIL,
      }

RequestContextHolder 是 Spring 的一个类, 用于存储当前线程的 RequestAttributes 对象
RequestAttributes 对象包含了与当前 HTTP 请求相关的属性,例如请求参数、请求头、会话属性等

1
2
3
4
5
public abstract class RequestContextHolder {
// ... 一些属性
private static final ThreadLocal<RequestAttributes> requestAttributesHolder = new NamedThreadLocal("Request attributes");
// ... 一些方法
}

他得到请求参数之后, 这条语句可以得到 ip

1
String ip = request.getHeader("x-forwarded-for");

如果获取不到, 下面还有判断语句

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip))
{
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip))
{
ip = request.getHeader("X-Forwarded-For");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip))
{
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip))
{
ip = request.getHeader("X-Real-IP");
}

if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip))
{
ip = request.getRemoteAddr();
}

return "0:0:0:0:0:0:0:1".equals(ip) ? "127.0.0.1" : getMultistageReverseProxyIp(ip);

通过这个获取 URI

1
ServletUtils.getRequest().getRequestURI()

后续的一些操作

1
2
3
4
5
6
7
8
9
10
// 设置方法名称
String className = joinPoint.getTarget().getClass().getName();
String methodName = joinPoint.getSignature().getName();
operLog.setMethod(className + "." + methodName + "()");
// 设置请求方式
operLog.setRequestMethod(ServletUtils.getRequest().getMethod());
// 处理设置注解上的参数
getControllerMethodDescription(joinPoint, controllerLog, operLog, jsonResult);
// 设置消耗时间
operLog.setCostTime(System.currentTimeMillis() - TIME_THREADLOCAL.get());

最后异步插入数据库

1
2
AsyncManager.me().execute(AsyncFactory.recordOper(operLog));

调用异步工厂里的一个方法, 把日志记录到数据库里
他这个 TimerTask 继承了 Runnable 接口, 所以要重写里面的 run 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 操作日志记录
*
* @param operLog 操作日志信息
* @return 任务task
*/
public static TimerTask recordOper(final SysOperLog operLog)
{
return new TimerTask()
{
@Override
public void run()
{
// 远程查询操作地点
operLog.setOperLocation(AddressUtils.getRealAddressByIP(operLog.getOperIp()));
SpringUtils.getBean(ISysOperLogService.class).insertOperlog(operLog);
}
};
}

12 配置多数据源

首先 ruoyi 用的是 Druid 数据源
下面主从库是 Druid 主从库的配置方式

首先 Spring Boot 的 application.yml 中激活了 druid, 它的意思是把名为 application-druid.yml 的配置文件加载进来

1
2
3
spring:
profiles:
active: druid

其中 application-druid.yml 中可以配主从数据库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 数据源配置
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver
druid:
# 主库数据源
master:
url: jdbc:mysql://localhost:3306/ruoyi-vue?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: root
password: sudong8016
# 从库数据源
slave:
# 从数据源开关/默认关闭
enabled: false
url:
username:
password:

在 DruidConfig 中会有对应的 bean
@ConfigurationProperties("spring.datasource.druid.slave") ,用于将配置文件中的属性值绑定到Java对象上
它将 application-druid.yml 配置文件中以 spring.datasource.druid.slave 为前缀的属性值绑定到了当前的Bean(即DataSource)上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Configuration
public class DruidConfig
{
@Bean
@ConfigurationProperties("spring.datasource.druid.master")
public DataSource masterDataSource(DruidProperties druidProperties)
{
DruidDataSource dataSource = DruidDataSourceBuilder.create().build();
return druidProperties.dataSource(dataSource);
}

@Bean
@ConfigurationProperties("spring.datasource.druid.slave")
@ConditionalOnProperty(prefix = "spring.datasource.druid.slave", name = "enabled", havingValue = "true")
public DataSource slaveDataSource(DruidProperties druidProperties)
{
DruidDataSource dataSource = DruidDataSourceBuilder.create().build();
return druidProperties.dataSource(dataSource);
}
}

DataSourceAspect

定义了一个切点

1
2
3
@Pointcut("@annotation(com.ruoyi.common.annotation.DataSource)"
+ "|| @within(com.ruoyi.common.annotation.DataSource)")
public void dsPointCut() {}

@within(com.ruoyi.common.annotation.DataSource) 将会匹配所有在类上标注了 @DataSource 注解的类中的所有方法

在这里把注解传入的 DataSourceType 设置到 ThreadLocal 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Around("dsPointCut()")
public Object around(ProceedingJoinPoint point) throws Throwable
{
DataSource dataSource = getDataSource(point);

if (StringUtils.isNotNull(dataSource))
{
DynamicDataSourceContextHolder.setDataSourceType(dataSource.value().name()); // 设置到 ThreadLocal 中
}

try
{
return point.proceed();
}
finally
{
// 销毁数据源 在执行方法之后
DynamicDataSourceContextHolder.clearDataSourceType();
}
}

通过 AbstractRoutingDataSource 用于动态路由数据源。这个类的主要作用是根据某种策略,动态地选择一个数据源,然后将当前的数据库操作路由到这个数据源上
这种机制在实现数据库读写分离、多数据源切换等场景时非常有用

AbstractRoutingDataSource 类本身并不知道应该选择哪个数据源,这个决定由子类通过实现 determineCurrentLookupKey() 方法来做出。这个方法应该返回一个能唯一标识数据源的 key,然后 AbstractRoutingDataSource 就会使用这个 key 来查找并选择对应的数据源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class DynamicDataSource extends AbstractRoutingDataSource
{
public DynamicDataSource(DataSource defaultTargetDataSource, Map<Object, Object> targetDataSources)
{
super.setDefaultTargetDataSource(defaultTargetDataSource);
super.setTargetDataSources(targetDataSources);
super.afterPropertiesSet();
}

@Override
protected Object determineCurrentLookupKey()
{
return DynamicDataSourceContextHolder.getDataSourceType();
}
}

13 注解

@PostConstruct

在初始化之后会执行这里的代码, 通常用于执行一些初始化工作, 比如加载缓存, 启动某个后台线程等

1
2
3
4
5
6
7
8
/**
* 项目启动时,初始化参数到缓存
*/
@PostConstruct
public void init()
{
loadingConfigCache();
}

若依
http://showyoubug.cn/2024/11/23/若依/
作者
Dong Su
发布于
2024年11月23日
许可协议