weblog/doc/5、整合 SaToken 实现 JWT 登录功能/5.15 同步【角色-权限集合】数据到 Redis 中.md
2025-02-17 10:05:44 +08:00

22 KiB
Raw Blame History

在之前小节中,当新用户登录成功后,系统会默认为该手机号注册好用户,并分配一个角色,同时将用户-角色的关联关系,同步到了 Redis 缓存中。如下图所示:

但是,光有角色 ID 是不够的,因为每个角色对应的权限数据,还没有同步到 Redis 中。这块的工作,可以放到项目启动后,同时也将角色-权限数据同步到 Redis 中。

项目启动时,做些事情!

在 Spring Boot 项目中,可以通过多种方式在项目启动时执行初始化工作。以下是一些常见的方法:

1. 使用 @PostConstruct 注解

@PostConstruct 注解可以用于在 Spring 容器初始化 bean 之后立即执行特定的方法。

import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;

@Component
public class MyInitializer {

    @PostConstruct
    public void init() {
        // 初始化工作
        System.out.println("初始化工作完成");
    }
}

2. 实现 ApplicationRunner 接口

ApplicationRunner 接口提供了一种在 Spring Boot 应用启动后执行特定代码的方式。

import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;

@Component
public class MyApplicationRunner implements ApplicationRunner {

    @Override
    public void run(ApplicationArguments args) throws Exception {
        // 初始化工作
        System.out.println("初始化工作完成");
    }
}

3. 实现 CommandLineRunner 接口

CommandLineRunner 接口类似于 ApplicationRunner,可以在应用启动后执行代码。

import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

@Component
public class MyCommandLineRunner implements CommandLineRunner {

    @Override
    public void run(String... args) throws Exception {
        // 初始化工作
        System.out.println("初始化工作完成");
    }
}

4. 使用 @EventListener 注解监听 ApplicationReadyEvent

通过监听 ApplicationReadyEvent 事件,可以在 Spring Boot 应用完全启动并准备好服务请求时执行初始化工作。

import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.boot.context.event.ApplicationReadyEvent;

@Component
public class MyApplicationReadyListener {

    @EventListener(ApplicationReadyEvent.class)
    public void onApplicationReady() {
        // 初始化工作
        System.out.println("初始化工作完成");
    }
}

5. 使用 SmartInitializingSingleton 接口

SmartInitializingSingleton 接口提供了一种在所有单例 bean 初始化完成后执行代码的方式。

import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.stereotype.Component;

@Component
public class MySmartInitializingSingleton implements SmartInitializingSingleton {

    @Override
    public void afterSingletonsInstantiated() {
        // 初始化工作
        System.out.println("初始化工作完成");
    }
}

6. 使用 Spring Boot 的 InitializingBean 接口

通过实现 InitializingBean 接口的 afterPropertiesSet 方法,可以在 bean 的属性设置完成后执行初始化工作。

import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Component;

@Component
public class MyInitializingBean implements InitializingBean {

    @Override
    public void afterPropertiesSet() throws Exception {
        // 初始化工作
        System.out.println("初始化工作完成");
    }
}

7. 总结

以上这些方法各有优缺点,可以根据具体的初始化需求选择合适的方法。

  • @PostConstruct:适合简单的初始化逻辑,执行时机较早。
  • ApplicationRunner 和 CommandLineRunner:适合需要访问命令行参数的初始化逻辑,执行时机在 Spring Boot 应用启动完成后。
  • ApplicationReadyEvent 监听器:适合在整个应用准备好后执行的初始化逻辑。
  • SmartInitializingSingleton:适合需要在所有单例 bean 初始化完成后执行的初始化逻辑。
  • InitializingBean:适合需要在 bean 属性设置完成后执行的初始化逻辑。

开始编码

在认证服务中,新建 /runner 包,用于统一放置项目启动时的逻辑类,并创建 PushRolePermissions2RedisRunner, 表示推送角色权限数据到 Redis 中,代码如下:

package com.quanxiaoha.xiaohashu.auth.runner;

import cn.hutool.core.collection.CollUtil;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.quanxiaoha.framework.common.util.JsonUtils;
import com.quanxiaoha.xiaohashu.auth.constant.RedisKeyConstants;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.PermissionDO;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.RoleDO;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.RolePermissionDO;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.PermissionDOMapper;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.RoleDOMapper;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.RolePermissionDOMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

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

/**
 * @author: 犬小哈
 * @date: 2024/6/4 16:41
 * @version: v1.0.0
 * @description: 推送角色权限数据到 Redis 中
 **/
@Component
@Slf4j
public class PushRolePermissions2RedisRunner implements ApplicationRunner {

    @Override
    public void run(ApplicationArguments args) {
        log.info("==> 服务启动,开始同步角色权限数据到 Redis 中...");

		// todo
        
        log.info("==> 服务启动,成功同步角色权限数据到 Redis 中...");
    }
}

到这里,小伙伴们可以重启一下项目,观察控制台日志,看看能否执行 run() 方法中的日志打印,逻辑代码先暂时不写。

业务逻辑分析

控制台成功打印日志后,我们来分析一下同步角色-权限集合的业务逻辑,如下图所示:

编写 Mapper 查询方法

想好代码逻辑要如何实现后,开始封装 run() 方法中需要用到的查询方法。

查询所有被启用的角色

首先是,查询出所有被启用的角色,编辑 RoleDOMapper 接口,声明方法如下:

package com.quanxiaoha.xiaohashu.auth.domain.mapper;

import com.quanxiaoha.xiaohashu.auth.domain.dataobject.RoleDO;

import java.util.List;

public interface RoleDOMapper {
	// 省略...

    /**
     * 查询所有被启用的角色
     *
     * @return
     */
    List<RoleDO> selectEnabledList();


}

接着,在其 xml 映射文件中编写具体的查询 SQL , 代码如下:

  // 省略...
  
  <select id="selectEnabledList" resultMap="BaseResultMap">
    select id, role_key, role_name
    from t_role
    where status = 0 and is_deleted = 0
  </select>
  
  // 省略...

Tip

: 只查询需要的字段,而不是 select * , 以提升查询性能。

根据角色 ID 集合批量查询

获取到所有角色后,再来编写一个根据角色 ID 集合批量查询 t_role_permission_rel 表的方法,用于将对应的权限 ID 查询出来,代码如下:

package com.quanxiaoha.xiaohashu.auth.domain.mapper;

import com.quanxiaoha.xiaohashu.auth.domain.dataobject.RolePermissionDO;
import org.apache.ibatis.annotations.Param;

import java.util.List;

public interface RolePermissionDOMapper {

	// 省略...

    /**
     * 根据角色 ID 集合批量查询
     *
     * @param roleIds
     * @return
     */
    List<RolePermissionDO> selectByRoleIds(@Param("roleIds") List<Long> roleIds);

}

编辑对应的 xml 文件,代码如下:

  // 省略...
  
  <select id="selectByRoleIds" resultMap="BaseResultMap">
    select role_id, permission_id
    from t_role_permission_rel
    where role_id in
    <foreach collection="roleIds" item="roleId" separator="," open="(" close=")">
      #{roleId}
    </foreach>
  </select>
  
  // 省略...

上面的代码用于批量查询,以实现 where role_id in (1, 2, 3) 的效果。

代码解析

<foreach collection="roleIds" item="roleId" separator="," open="(" close=")">
  #{roleId}
</foreach>
  • <foreach>MyBatis 提供的一个标签,用于在 SQL 语句中循环处理集合(如 List、数组等。它可以动态地生成 SQL 片段。
  • collection="roleIds":指定要循环处理的集合名称。在这个例子中,roleIds 是传递给 MyBatis 映射方法的一个参数,它是一个包含多个角色 ID 的集合。
  • item="roleId":指定在循环过程中每次迭代的当前项的变量名。在每次迭代中,集合中的当前元素会赋值给 roleId
  • separator=",":指定在生成的 SQL 片段中,每个元素之间的分隔符。在这里,每个 roleId 之间会用逗号分隔。
  • open="("close=")":指定生成的 SQL 片段的开头和结尾。在这里,生成的 SQL 片段会被括号括起来。

查询 APP 端所有被启用的权限

5.9 小节 中,我们已经定下了方案,网关中只对普通用户的操作进行鉴权,其他角色,如管理员等等,到时候在具体的管理后台服务中再鉴权,以保证网关做最少的工作,实现最大的吞吐量。编辑 PermissionDOMapper 接口,声明一个查询 APP 端所有被启用的权限方法,代码如下:

package com.quanxiaoha.xiaohashu.auth.domain.mapper;

import com.quanxiaoha.xiaohashu.auth.domain.dataobject.PermissionDO;

import java.util.List;

public interface PermissionDOMapper {
	// 省略...

    /**
     * 查询 APP 端所有被启用的权限
     *
     * @return
     */
    List<PermissionDO> selectAppEnabledList();

}

在对应的 xml 文件中,带上 type = 3 条件3 表示按钮权限,因为普通用户目前来看,只有按钮权限需要控制,如笔记发布、评论发布等。只同步这块的数据到 Redis 缓存中:

  // 省略... 
  
  <select id="selectAppEnabledList" resultMap="BaseResultMap">
    select id, name, permission_key from t_permission
    where status = 0 and type = 3 and is_deleted = 0
  </select>
  
  // 省略...

定义角色-权限 Redis Key

接着,编辑 RedisKeyConstants 常量类,定义角色-权限的 Redis Key代码如下

package com.quanxiaoha.xiaohashu.auth.constant;

public class RedisKeyConstants {

	// 省略...

    /**
     * 角色对应的权限集合 KEY 前缀
     */
    private static final String ROLE_PERMISSIONS_KEY_PREFIX = "role:permissions:";


    /**
     * 构建角色对应的权限集合 KEY
     * @param roleId
     * @return
     */
    public static String buildRolePermissionsKey(Long roleId) {
        return ROLE_PERMISSIONS_KEY_PREFIX + roleId;
    }
}

编写 Runner 业务逻辑

业务层需要的查询方法封装完毕后,开始编写 PushRolePermissions2RedisRunner 的具体逻辑。最终要实现的 Redis 数据存储效果,如下图所示,每个角色 ID 下,保存其对应的权限 DO 数据,并且是以 JSON 字符串格式存储的:

具体代码如下:

package com.quanxiaoha.xiaohashu.auth.runner;

import cn.hutool.core.collection.CollUtil;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.quanxiaoha.framework.common.util.JsonUtils;
import com.quanxiaoha.xiaohashu.auth.constant.RedisKeyConstants;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.PermissionDO;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.RoleDO;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.RolePermissionDO;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.PermissionDOMapper;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.RoleDOMapper;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.RolePermissionDOMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

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

/**
 * @author: 犬小哈
 * @date: 2024/6/4 16:41
 * @version: v1.0.0
 * @description: 推送角色权限数据到 Redis 中
 **/
@Component
@Slf4j
public class PushRolePermissions2RedisRunner implements ApplicationRunner {

    @Resource
    private RedisTemplate<String, String> redisTemplate;
    @Resource
    private RoleDOMapper roleDOMapper;
    @Resource
    private PermissionDOMapper permissionDOMapper;
    @Resource
    private RolePermissionDOMapper rolePermissionDOMapper;

    @Override
    public void run(ApplicationArguments args) {
        log.info("==> 服务启动,开始同步角色权限数据到 Redis 中...");

        try {
            // 查询出所有角色
            List<RoleDO> roleDOS = roleDOMapper.selectEnabledList();

            if (CollUtil.isNotEmpty(roleDOS)) {
                // 拿到所有角色的 ID
                List<Long> roleIds = roleDOS.stream().map(RoleDO::getId).toList();

                // 根据角色 ID, 批量查询出所有角色对应的权限
                List<RolePermissionDO> rolePermissionDOS = rolePermissionDOMapper.selectByRoleIds(roleIds);
                // 按角色 ID 分组, 每个角色 ID 对应多个权限 ID
                Map<Long, List<Long>> roleIdPermissionIdsMap = rolePermissionDOS.stream().collect(
                        Collectors.groupingBy(RolePermissionDO::getRoleId,
                                Collectors.mapping(RolePermissionDO::getPermissionId, Collectors.toList()))
                );

                // 查询 APP 端所有被启用的权限
                List<PermissionDO> permissionDOS = permissionDOMapper.selectAppEnabledList();
                // 权限 ID - 权限 DO
                Map<Long, PermissionDO> permissionIdDOMap = permissionDOS.stream().collect(
                        Collectors.toMap(PermissionDO::getId, permissionDO -> permissionDO)
                );

                // 组织 角色ID-权限 关系
                Map<Long, List<PermissionDO>> roleIdPermissionDOMap = Maps.newHashMap();

                // 循环所有角色
                roleDOS.forEach(roleDO -> {
                    // 当前角色 ID
                    Long roleId = roleDO.getId();
                    // 当前角色 ID 对应的权限 ID 集合
                    List<Long> permissionIds = roleIdPermissionIdsMap.get(roleId);
                    if (CollUtil.isNotEmpty(permissionIds)) {
                        List<PermissionDO> perDOS = Lists.newArrayList();
                        permissionIds.forEach(permissionId -> {
                            // 根据权限 ID 获取具体的权限 DO 对象
                            PermissionDO permissionDO = permissionIdDOMap.get(permissionId);
							if (Objects.nonNull(permissionDO)) {
								perDOS.add(permissionDO);
							}
                        });
                        roleIdPermissionDOMap.put(roleId, perDOS);
                    }
                });

                // 同步至 Redis 中,方便后续网关查询鉴权使用
                roleIdPermissionDOMap.forEach((roleId, permissionDO) -> {
                    String key = RedisKeyConstants.buildRolePermissionsKey(roleId);
                    redisTemplate.opsForValue().set(key, JsonUtils.toJsonString(permissionDO));
                });
            }
            
            log.info("==> 服务启动,成功同步角色权限数据到 Redis 中...");
        } catch (Exception e) {
            log.error("==> 同步角色权限数据到 Redis 中失败: ", e);
        }
        
    }
}

集群部署Runner 多次同步的问题

因为咱们的项目是微服务架构,在生产环境中,子服务必然是以集群的方式部署,那么,就会带来一个问题,每个服务启动后,都会跑一次 PushRolePermissions2RedisRunner , 就来带来多次同步 Redis 缓存的问题。虽然说,咱们这个业务场景下,多次同步问题也不大。但是,多少还是得控制一下,保证认证服务在一段时间内,如果多个服务多次启动,只能有一个服务去同步权限数据到 Redis 中。

Redis 分布式锁

分布式锁是确保在分布式系统中多个节点能够协调一致地访问共享资源的一种机制。Redis 分布式锁通过 Redis 的原子操作,确保在高并发情况下,对共享资源的访问是互斥的。

实现思路

  • 可以使用 Redis 的 SETNX 命令来实现。如果键不存在,则设置键值并返回 1表示加锁成功如果键已存在则返回 0表示加锁失败
  • 多个子服务同时操作 Redis , 第一个加锁成功,则可以同步权限数据;后续的子服务都会加锁失败,若加锁失败,则不同步权限数据;
  • 另外,结合 EXPIRE 命令为锁设置一个过期时间,比如 1 天,防止死锁。则在 1 天内,无论启动多少次认证服务,均只会同步一次数据。

开始实现

编辑 PushRolePermissions2RedisRunner 类,添加加锁控制,代码如下:

package com.quanxiaoha.xiaohashu.auth.runner;

import cn.hutool.core.collection.CollUtil;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.quanxiaoha.framework.common.util.JsonUtils;
import com.quanxiaoha.xiaohashu.auth.constant.RedisKeyConstants;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.PermissionDO;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.RoleDO;
import com.quanxiaoha.xiaohashu.auth.domain.dataobject.RolePermissionDO;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.PermissionDOMapper;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.RoleDOMapper;
import com.quanxiaoha.xiaohashu.auth.domain.mapper.RolePermissionDOMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

/**
 * @author: 犬小哈
 * @date: 2024/6/4 16:41
 * @version: v1.0.0
 * @description: 推送角色权限数据到 Redis 中
 **/
@Component
@Slf4j
public class PushRolePermissions2RedisRunner implements ApplicationRunner {

    @Resource
    private RedisTemplate<String, String> redisTemplate;
    @Resource
    private RoleDOMapper roleDOMapper;
    @Resource
    private PermissionDOMapper permissionDOMapper;
    @Resource
    private RolePermissionDOMapper rolePermissionDOMapper;

    // 权限同步标记 Key
    private static final String PUSH_PERMISSION_FLAG = "push.permission.flag";

    @Override
    public void run(ApplicationArguments args) {
        log.info("==> 服务启动,开始同步角色权限数据到 Redis 中...");

        try {
        	// 是否能够同步数据: 原子操作,只有在键 PUSH_PERMISSION_FLAG 不存在时,才会设置该键的值为 "1",并设置过期时间为 1 天
            boolean canPushed = redisTemplate.opsForValue().setIfAbsent(PUSH_PERMISSION_FLAG, "1", 1, TimeUnit.DAYS);

			// 如果无法同步权限数据
            if (!canPushed) {
                log.warn("==> 角色权限数据已经同步至 Redis 中,不再同步...");
                return;
            }

            // 查询出所有角色
            List<RoleDO> roleDOS = roleDOMapper.selectEnabledList();

            // 省略...

            log.info("==> 服务启动,成功同步角色权限数据到 Redis 中...");
        } catch (Exception e) {
            log.error("==> 同步角色权限数据到 Redis 中失败: ", e);
        }

    }
}

自测一波

至此,项目初始化时,同步角色-权限数据到 Redis 中的功能就开发完毕了。重启项目,并查看 Redis 中的数据,来校验一下功能是否好使吧~

重构一下之前的代码

由于本小节中定义的 PUSH_PERMISSION_FLAG Redis Key 是使用 . 来连接的,而之前的小哈书全局 ID 生成器,又是使用 _ 下划线来连接的,这里统一改为 . 连接,保证命名的一致性。

Tip

: 个人对于 Redis Key 的命名,如果有上下级的关系,在 Redis 中,能够以文件夹的形式,如用户的角色,就会以 : 分隔;否则以 . 分隔。

修改 RedisKeyConstants 全局常量类,如下图标注所示:

xiaohashu.id.generator

同时,查看之前 xiaohashu_id_generator 存储的 value 值,我这里的全局 ID 已经自增到了 10013。复制这个值再以 xiaohashu.id.generatorkey , 将这个值保存一下,防止到时候新用户注册时,全局 ID 错乱:

set xiaohashu.id.generator 10013

存储成功后,将老的删除掉,如下图所示:

本小节源码下载

https://t.zsxq.com/dYp1c