gateway网关基于redis实现分布式会话

442 阅读6分钟

一、需求场景

微服务架构下的第一个难题便是数据同步,单机版的Session在分布式环境下一般不能正常工作,为此我们需要对框架做一些特定的处理。

首先我们要明白,分布式环境下为什么Session会失效?因为用户在一个节点对会话做出的更改无法实时同步到其它的节点, 这就导致一个很严重的问题:如果用户在节点一上已经登录成功,那么当下一次的请求落在节点二上时,对节点二来讲,此用户仍然是未登录状态。

解决方案:建立会话中心,将Session存储在专业的缓存中间件上,使每个节点都变成了无状态服务,例如:Redis,再由网关层进行统一鉴权,从redis中读取数据进行鉴权处理。

二、使用docker进行redis部署

先在创建/data/redis目录,并在该目录下创建redis.conf文件,将配置文件复制过来,然后再进行redis镜像拉取。

docker pull redis
docker run -p 6379:6379 --name redis -v /data/redis/redis.conf:/etc/redis/redis.conf  -v /data/redis/data:/data -d redis redis-server /etc/redis/redis.conf --appendonly yes

解释:

  1. -v /data/redis/redis.conf:/etc/redis/redis.conf:将主机路径 /data/redis/redis.conf 挂载到容器路径 /etc/redis/redis.conf。这样可以使用主机上的 Redis 配置文件。
  2. -v /data/redis/data:/data:将主机路径 /data/redis/data 挂载到容器路径 /data。这样 Redis 数据会存储在主机的这个目录下,以便数据持久化。
  3. -d:以守护进程模式运行容器,即容器在后台运行。
  4. redis:指定要使用的 Docker 镜像,默认是从 Docker Hub 拉取最新的 Redis 镜像。
  5. redis-server /etc/redis/redis.conf:在容器中执行 redis-server 命令,并指定使用 /etc/redis/redis.conf 作为配置文件启动 Redis 服务。
  6. --appendonly yes:附加参数,启用 Redis 的 AOF(Append-Only File)持久化模式,这将确保 Redis 每次写操作后都会将数据追加到文件中,以确保数据的持久性。

三、网关统一鉴权

3.1 引入依赖

<!-- Sa-Token 权限认证(Reactor响应式集成), 在线文档:https://sa-token.cc -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-reactor-spring-boot-starter</artifactId>
    <version>1.39.0</version>
</dependency>

<!-- Sa-Token 整合 Redis (使用 jackson 序列化方式) -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-redis-jackson</artifactId>
    <version>1.39.0</version>
</dependency>
<dependency><!-- 提供Redis连接池 -->
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

3.2 添加网关配置文件

  # redis配置
  redis:
    # Redis数据库索引(默认为0)
    database: 1
    # Redis服务器地址
    host: 117.72.118.73
    # Redis服务器连接端口
    port: 6379
    # Redis服务器连接密码(默认为空)
    password: ssm030927
    # 连接超时时间
    timeout: 10s
    lettuce:
      pool:
        # 连接池最大连接数
        max-active: 200
        # 连接池最大阻塞等待时间(使用负值表示没有限制)
        max-wait: -1ms
        # 连接池中的最大空闲连接
        max-idle: 10
        # 连接池中的最小空闲连接
        min-idle: 0
sa-token:
  # token 名称(同时也是 cookie 名称)
  token-name: satoken
  # token 有效期(单位:秒) 默认30天,-1 代表永久有效
  timeout: 2592000
  # token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
  active-timeout: -1
  # 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
  is-concurrent: true
  # 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token)
  is-share: true
  # token 风格(默认可取值:uuid、simple-uuid、random-32、random-64、random-128、tik)
  token-style: random-32
  # 是否输出操作日志
  is-log: true
  token-prefix: ssm

3.3 重写redisTemplate

redis默认jdk序列化方式,会导致序列化和反序列化过程中出现乱码

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        //把redis键key的值序列化为string字符串类型
        RedisSerializer<String> redisSerializer = new StringRedisSerializer();
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        //把String、Hash类型的key序列化设置为String类型
        redisTemplate.setKeySerializer(redisSerializer);
        redisTemplate.setHashKeySerializer(redisSerializer);
        //把value的序列化也设为String类型
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer());
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer());
        return redisTemplate;
    }

    private Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer() {
        Jackson2JsonRedisSerializer<Object> jsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
        jsonRedisSerializer.setObjectMapper(objectMapper);
        return jsonRedisSerializer;
    }
}

代码解读:

  • redisConnectionFactory:是 Spring Data Redis 用于创建与 Redis 数据库连接的工厂类
  • Jackson2JsonRedisSerializer:可以将 Java 对象序列化为 JSON 格式的字符串存储到 Redis中
  • ObjectMapper: 控制 JSON序列化和反序列化
  • objectMapper.setVisibility: 设置可见性规则(避免因为默认的可见性限制导致部分属性无法正确序列化或反序列化的问题):
    • PropertyAccessor.ALL:所有属性
    • JsonAutoDetect.Visibility.ANY:在序列化和反序列化时都能够被检测到(可见性为任意,即都可处理)
  • objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false):在反序列化时,当json数据没有对应的java实体属性时,会忽略,不抛出异常
  • objectMapper.enableDefaultTyping: 启用了默认类型信息的处理:
    • ObjectMapper.DefaultTyping.NON_FINAL:指定了对于非最终类型(NON_FINAL)的对象
    • As.PROPERTY:在序列化时会将对象的类型信息作为一个属性(As.PROPERTY)添加到 JSON 数据中,方便在反序列化时能够准确地还原对象类型,尤其在处理复杂的继承结构或者多态场景下非常有用。

3.4 定义redisUtil工具类

@Component
public class RedisUtil {
    @Resource
    private RedisTemplate redisTemplate;

    private static final String CACHE_KEY_SEPARATOR = ".";

    /**
     * 构建缓存key
     * String... : 可变参数,参数个数为0个或多个
     * Stream.of 字符数组转化为stream流
     * collect:收集器,终止操作;通过Collectors类把字符数组内的元素以.进行拼接
     */
    public String buildKey(String... strObjs) {
        return Stream.of(strObjs).collect(Collectors.joining(CACHE_KEY_SEPARATOR));
    }

    /**
     * 是否存在key
     * @param key
     * @return
     */
    public boolean exist(String key) {
        return redisTemplate.hasKey(key);
    }

    /**
     * 删除key
     * @param key
     * @return
     */
    public boolean del(String key) {
        return redisTemplate.delete(key);
    }

    /**
     * set(无过期时间)
     * @param key
     * @param value
     */
    public void set(String key, String value) {
        redisTemplate.opsForValue().set(key, value);
    }

    /**
     * setNx
     * @param key
     * @param value
     * @param time
     * @param timeUnit
     * @return
     */
    public boolean setNx(String key, String value, Long time, TimeUnit timeUnit) {
        return redisTemplate.opsForValue().setIfAbsent(key, value, time, timeUnit);
    }

    /**
     * get
     * @param key
     * @return
     */
    public String get(String key) {
        return (String) redisTemplate.opsForValue().get(key);
    }

缓存构建:

public static void main(String[] args) {
    RedisUtil redisUtil = new RedisUtil();
    System.out.println(redisUtil.buildKey("1", "auth.role"));
}

输出:1.auth.role

3.5 自定义权限验证接口扩展

只有外部代码执行到检验角色、权限时,才会执行对应部分的代码

@Component
public class StpInterfaceImpl implements StpInterface {
    @Resource
    private RedisUtil redisUtil;

    private String authPermissionPrefix = "auth.permission";  //权限key
    private String authRolePrefix = "auth.role";  //角色key


    @Override
    public List<String> getPermissionList(Object loginId, String loginType) {
        // 返回此 loginId 拥有的权限列表
        return getAuth(loginId.toString(), authPermissionPrefix);
    }

    @Override
    public List<String> getRoleList(Object loginId, String loginType) {
        // 返回此 loginId 拥有的角色列表
        return getAuth(loginId.toString(), authRolePrefix);
    }

    private List<String> getAuth(String loginId, String prefix) {
        String authKey = redisUtil.buildKey(prefix, loginId);
        String authValue = redisUtil.get(authKey);
        if(StringUtils.isBlank(authValue)) {
            return Collections.emptyList();
        }
        List<String> authList = new Gson().fromJson(authValue, List.class);
        return authList;
    }
}

3.6 编写Satoken配置类用来定义全局过滤器

@Configuration
public class SaTokenConfigure {
    // 注册 Sa-Token全局过滤器
    @Bean
    public SaReactorFilter getSaReactorFilter() {
        return new SaReactorFilter()
                // 拦截地址
                .addInclude("/**")    /* 拦截全部path */
                // 鉴权方法:每次访问进入
                .setAuth(obj -> {
                    // auth权限校验 -- 拦截所有auth微服务相关路由,并排除/auth/user/doLogin 用于开放登录, 只有具有admin角色才通行
                    SaRouter.match("/auth/**", "/auth/user/doLogin", r -> StpUtil.checkRole("admin"));
                    // oss权限校验 -- 拦截所有oss微服务相关路由, 只有登录成功才能上传图片
                    SaRouter.match("/oss/**", r -> StpUtil.checkLogin());
                    // 新增题目权限校验 -- 拦截所有/subject/subject/add路由,只有具有新增题目权限才可通行
                    SaRouter.match("/subject/subject/add",  r -> StpUtil.checkPermission("subject:add"));
                    // 题目权限路由 -- 拦截所有subject微服务相关路由,只有登录才可通行
                    SaRouter.match("/subject/**", r -> StpUtil.checkLogin());
                })
//                // 异常处理方法:每次setAuth函数出现异常时进入
//                .setError(e -> {
//                    return SaResult.error(e.getMessage());
//                })
                ;
    }
}