点评项目总结

项目需要用到的编程知识

Redis Stream

Redis Stream | 菜鸟教程 (runoob.com)


短信登录功能实现

首先,为什么要使用 Redis 来存储 Session 呢?

首先一个服务器只有一个 Session,但是在分布式的环境下,各个服务器之间的 Session 就无法实现共享,且一但服务器重启的话 Session 也会失效。所以我们需要将 Session 存储到 Redis 中。

下面讲解一些重点部分内容以及实现:

这里虽然提到共享 session 但是这里没有使用到 session 来存储用户数据,而是用 key + token : user_info 的形式来存储到 Redis 中的。session 只是存储 token 然后去 Redis 中查询数据。

1.手机号码登录功能
  • 先校验验证码是否正确。
  • 如果验证码正确,在数据库中查询用户。
  • 用户不存在,创建一个新用户加入数据库中。
  • 生成随机 token 作为登录令牌,用于登录校验。使用 key + token 作为 Redis 的 key,将用户对象存储到 Redis 中,用于后面登录的验证和用户信息获取。并将 token 返回给前端。
 @Override
    public Result phoneLogin(LoginFormDTO loginForm, HttpSession session) {
        // 校验手机号
        String phone = loginForm.getPhone();
        if(RegexUtils.isPhoneInvalid(phone)) {
            return Result.fail("手机号码格式错误");
        }
        // 校验验证码
        Object cacheCode = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY + phone);
        String code = loginForm.getCode();
        if(cacheCode == null || !cacheCode.toString().equals(code)) {
            return Result.fail("验证码错误");
        }
        // 根据手机号查询用户 : select * from tb_user where phone = ?
        User user = query().eq("phone",phone).one();

        // 判断用户是否存在
        if(user == null) {
            // 用户不存在, 创建一个新用户, 这里相当于注册
            user = createNewUser(phone);
        }
        // 将用户信息保存到 Redis 中, 随机生成 token, 作为登录令牌
        String token = UUID.randomUUID().toString();
        // 将 User 对象转换为 HashMap 进行存储
        // BeanUtil.copyProperties(user, UserDTO.class) : 将 User 对象转换为一个 UserDTO 的对象
        UserDTO dto = BeanUtil.copyProperties(user, UserDTO.class);
        // 将 UserDto 转换为哈希表, 属性字段作为 key, 属性值作为 value
        // 将字段的所有值都转换为 String 才能存入 StringRedisTemplate 中
        // Map 的 key 是 UserDTO 的字段,而 value 则是 UserDTO 的值
        Map<String, Object> userMap = BeanUtil.beanToMap(dto, new HashMap<>(),
                CopyOptions.create().setIgnoreNullValue(true)
                        .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));

        // 将 token 存储到 Redis 中, 值为用户信息的哈希表
        String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
        stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
        // 设置 token 的有效期为 30 分钟
        stringRedisTemplate.expire(tokenKey, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 将 token 返回给前端
        return Result.ok(token);
    }

前端先将返回的 token 存入到 Session 中,通过 axios 请求拦截器让接下来的请求头都带上 token ,通过拦截器完成登录校验 (该代码位于 vue 的 main.js 中)。后面通过拦截器获取到请求头中的 token 判读用户是否登录.

login(){
      if(!this.form.phone || !this.form.code){
        this.$message.error("手机号和验证码不能为空!");
        return
      }
      axios.post("/user/phone-login", this.form)
          .then(({data}) => {
            if(data){
              // 保存用户信息到 session
              sessionStorage.setItem("token",data.data);
              console.log("login:" + sessionStorage.getItem("token"));
            }
            // 跳转到个人中心页面
            this.$router.push("/center")
          })
          .catch(err => this.$message.error(err))
    }
// 添加请求拦截器
axios.interceptors.request.use(
    (config) => {
      const token = sessionStorage.getItem('token'); // 从 sessionStorage 中获取 token
      if (token) {
        config.headers['authorization'] = token; // 给请求头新增一个字段 authorization, 添加 token 到请求头中
      } else {

      }
      return config;
    },
    (error) => {
      return Promise.reject(error);
    }
);
2.拦截器功能实现

总共有两个拦截器,第一个拦截器执行完执行第二个拦截器。 

第一个拦截器的作用主要是刷新登录的过期时间,通过获取 token 重置 Redis 中的登录记录过期时间, 并将数据信息保存带 ThreadLocal 中。

public class RefreshTokenInterceptor implements HandlerInterceptor {
    private final StringRedisTemplate stringRedisTemplate;

    public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //  获取到请求头中的 token
        String token = request.getHeader(SystemConstants.USER_LOGIN_TOKEN);
        if(StrUtil.isBlank(token)) {
           return true; // 直接放行,到第二个登录拦截器处理拦截
        }
        // 基于 token 获取 redis 中的用户
        String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
        // .entries 的作用是获取哈希中的所有键值对
        Map<Object,Object> userMap = stringRedisTemplate.opsForHash()
                .entries(tokenKey);
        // 判断用户是否存在
        if(userMap.isEmpty()) {
            return true; // 直接放行
        }
        // 将查询到的 Hash 数据转化为 UserDTO 对象
        UserDTO dto = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        // 将用户信息保存到 ThreadLocal
        UserHolder.saveUser(dto);
        // 刷新 token 有效期
        stringRedisTemplate.expire(tokenKey, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 放行
        return true;
    }

    /* 该方法在请求处理完之后被调用, 无论是正常还是异常现象 */
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //  将用户从 ThreadLocal 中移除
        UserHolder.removeUser();
    }
}

第二个拦截器作用主要是判断 ThreadLocal 中是否有用户对象,以此来拦截未登录情况下未被放行的请求。

public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // TODO 从 ThreadLocal 中获取用户, 如果没有则拦截
        if(UserHolder.getUser() == null) {
            response.setStatus(401);
            return false;
        }
        // TODO 放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
       // TODO 将用户从 ThreadLocal 中移除
       UserHolder.removeUser();
    }
}
3.ThreadLocal 实现同一浏览器多个页面多个用户登录

首先一个浏览器只有一个 Session,将用户信息只是存储在 Session 的话,在多线程情况下会产生问题,所以可以采用 ThreadLocal 来存储每个线程所拥有的变量。

首先我们的用户每打开一个浏览器页面操作,都是一个单线程操作,而 ThreadLocal 都存储的是当前线程的变量。当用户进行登录操作后。拦截器会使用从 Session 获得的 token,使用 key + token 获取到 Redis 中的用户对象,并将其存入到 ThreadLocal 中。接下来该线程获取的用户对象就直接从 ThreadLocal 中获取,这样的话多个用户之间的内容就不会冲突了。

package com.hmdp.utils;

import com.hmdp.dto.UserDTO;

public class UserHolder {
    // ThreadLocal 是 Java 中的一个类,用于创建线程本地变量。
    private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();

    public static void saveUser(UserDTO user){
        tl.set(user);
    }
    public static UserDTO getUser(){
        return tl.get();
    }
    public static void removeUser(){
        tl.remove();
    }
}

优惠券秒杀功能实现

1.Lua 脚本实现对 Redis 的原子性操作(下单和扣减库存操作)

lua脚本执行流程图:主要是对 Redis 的秒杀相关操作,lua脚本可以保证操作的原子性,解决了 Redis 的命令不是原子性的问题。

-- 1. 参数列表,参数在 seckillVoucher() 函数内调用后传入
-- 1.1 优惠券 id 
local voucherId = ARGV[1] 
-- 1.2 用户 id
local userId = ARGV[2]
-- 1.3 订单 id
local orderId = ARGV[3]

-- 2. 数据 key
-- 2.1 库存 key (lua 脚本连接字符串 ..)
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2 订单 key
local orderKey = 'seckill:order:' .. userId

--3. lua 脚本业务
--3.1 判断库存是否充足
if(tonumber(redis.call('get',stockKey)) <= 0) then
    -- 3.2 库存不足, 返回 1
    return 1
end
-- 3.2 判断用户是否已经下单
-- sismember <key><value> 判断集合 <key> 是否为含有该 <value> 值,有 1,没有 0
if(redis.call('sismember',orderKey,userId) == 1) then
    -- 3.3 重复下单, 返回 2
    return 2
end
-- 3.4 扣减库存
redis.call('incrby', stockKey, -1)
-- 3.5 下单 (保存用户) Redis 中的 set 可以将一个或多个元素存储在集合 key 中
-- 这里是将用户 id 和 订单 id 保存到 orderKey 的集合中
redis.call('sadd', orderKey, userId, orderId)
-- 3.6 将消息发送到消息队列中, XADD stream.orders * k1 v1 k2 v2 ......
-- 注意这里要写 id , 因为最后是要存入数据库的, 数据库表字段是 id
redis.call('xadd','stream.orders','*','userId',userId,'voucherId',voucherId,'id',orderId)
return 0

优惠券秒杀过程执行流程图

  • 先获取到当前的用户 id 和订单 id。
  • 传入参数开始执行 lua 脚本操作 Redis,返回 0 表示执行成功,订单已经被加入到 Stream 消息队列中。
  • 给代理对象赋值,并且返回订单 id 给前端。

秒杀优惠券代码实现:

    /**
     * 秒杀优惠券
     */
    @Override
    public Result seckillVoucher(Long voucherId) {
        // 获取当前用户 id
        Long userId = UserHolder.getUser().getId();
        // 获取订单 id
        Long orderId = redisWorker.generateId("order");
        // 执行 lua 脚本, 进行判断, 削减库存和将订单信息加入消息队列操作
        // 消息加入到消息队列后消息队列就会开始消费,执行创建订单等一系列操作
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT,
                Collections.emptyList(), // 这里传入一个空的列表,不要传 null
                voucherId.toString(),userId.toString(),orderId.toString() // 传入三个参数
        );
        // 判断结果是否为 0
        int res = result.intValue();
        if(res != 0) {
            // 不为 0,为 1 是库存不足, 否则是用户已经下单
            return Result.fail(String.valueOf(res));
        }
        //  获取到代理对象
        proxy = (IVoucherOrderService) AopContext.currentProxy();
        //  返回订单 id
        return Result.ok(orderId);
    }

这里具体讲一下异步下单的一个流程:

首先这里先讲解一下为什么要使用消息队列来处理我们的异步订单。

  • 消息队列有持久化的功能,假如系统在运行中突然崩溃,此时消息队列中还可以暂存消息,等系统恢复后再进行处理。
  • 消息队列的确认机制,每处理完一条消息都会有确认通知。这样就保证了消息的可靠性,只有在确认处理后才会被删除。

要注意的是,一旦消息被确认,它并不会被立即从Stream中删除,而是会在后台根据配置的规则进行自动修剪(trimming)。修剪可以通过配置Stream的最大长度或最大时间来执行。一旦Stream的长度达到或超过指定的最大长度,或者消息的时间戳超过指定的最大时间,Redis会自动删除一些消息以保持Stream的大小在可接受范围内。

2. 单线程实现对 Stream 消息队列中任务的消费
  • 从消息队列中获取消息,并转化为 VoucherOrder 对象。
  • 调用 handleVoucherOrder() 方法来处理订单。
  • 对消息队列消息进行确认。
private class VoucherOrderHandler implements Runnable {
        @Override
        public void run() {
            try {
                while (true) {
                    // 获取消息队列中的消息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STRAMS stream.orders >
                    List<MapRecord<String,Object,Object>> list = stringRedisTemplate.opsForStream().read(
                            Consumer.from("g1","c1"), // 组名和消费者名
                            StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)), // 读取消息个数和阻塞等待时间
                            StreamOffset.create("stream.orders", ReadOffset.lastConsumed()) //
                    );
                    // 判断消息是否获取成功
                    if(list == null || list.isEmpty()) {
                        // 获取失败, 说明没有消息, 则继续循环
                        continue;
                    }
                    // 解析消息中的订单信息
                    MapRecord<String,Object,Object> record = list.get(0);
                    Map<Object,Object> values = record.getValue();
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
                    // 获取成功, 下单
                    handleVoucherOrder(voucherOrder);
                    // ACK 确认 SACK stream.orders g1 id
                    stringRedisTemplate.opsForStream().acknowledge("stream.orders", "g1", record.getId());
                }
            }catch (Exception e) {
                log.error("消息队列读取出现异常:" + e);
                //  处理 PendingList 队列
                handlePendingList();
            }
        }
    }

如果在处理消息的时候出现异常,消息会进入 PendingList 队列,根据同样的方式处理消息并确认。

3. 实现对数据库的库存扣减和下单操作

注意这里是异步下单操作数据库的过程,操作 Redis 已经由 Lua 脚本完成了,接下来的功能主要是实现数据库的扣减库存和新增订单操作。

  • 通过 VoucherOrder 对象获取用户 id。此时不能用 ThreadLocal 实现的 UserHolder.getUserId(); 来获取 id 了,因为这个操作是由子线程操作的。
  • 使用 Redission 创建锁对象。
  1. 这里讲一下 Redission 是如何加锁的,首先使用 ReddisionClient 获取 RLock 对象,对象用用户 ID 保证唯一性。
  2. 使用 tryLock() 尝试获取锁。
  3. false 表示获取成功,否则获取失败。
  4. 最后再使用 unlock 方法释放锁。
  • 尝试获取锁对象。失败打印错误日志。
  • 获取锁对象成功,通过代理对象调用创建订单函数 createVoucher() 创建订单。
   /**
     * 用来处理订单
     * @param voucherOrder
     */
    private void handleVoucherOrder(VoucherOrder voucherOrder) {

        // Can't use this way to get the userId, because there is not the main thread but a child thread
        /* Long userId = UserHolder.getUser().getId(); // 获取当前用户 id */
        Long userId = voucherOrder.getUserId();

        // 创建锁对象
        // SimpleRedisLock redisLock = new SimpleRedisLock("order:" + userId,stringRedisTemplate);
        // 这里改用 Redisson 创建锁对象
        RLock redisLock = redissonClient.getLock("lock:order:" + userId);
        // 尝试获取锁 ( 没有参数表示如果获取不到直接失败 )
        boolean lock = redisLock.tryLock();
        // 这里应该打印完错误日志后 Return 才对??
        if(!lock) { // 获取锁失败, 直接返回错误信息
           log.error("不允许重复下单");
        }
        //  This locking method does not work in distributed or clustered environment
        /*
        //  给每个用户 ID 进行加锁, 只有相同用户才会阻塞
        //  intern() 保证是安装字符串的值来进行加锁的, 去字符串线程池查找有没有相同值的字符串
        synchronized (userId.toString().intern()) {
            //  这里存在事务失效的问题, 需要用到代理对象调用该方法
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            //  返回订单 id
            return proxy.createVoucherOrder(voucherId);
        }
        */
        try {
            // Can't get proxy here like this, because now is a child thread but not a main thread,so init it in seckillVoucher(Long voucherId) method
            /*
            这里存在事务失效的问题, 需要用到代理对象调用该方法
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); */
            // 这边按道理应该需要一个 Catch,如果 Proxy 没有被初始化就会有空指针异常 ????
            proxy.createVoucherOrder(voucherOrder); // 通过代理对象调用创建订单方法
        } finally {
            // 释放锁
            redisLock.unlock();
        }
    }
3.1 加锁问题分析
  •  为什么要加锁:加锁主要是为了解决超卖问题和一人一单的校验问题。

超卖问题:

超卖问题即当我们去执行扣减库存的时候是多线程操作的,而线程有两步需要执行,分别是判断库存是否大于 0,扣减库存。假如库存为 1,那么当第一个线程还没有执行扣减库存操作的时候,第二个线程此时判断库存是大于 0,也进行了扣减库存操作,这样就会出现超卖问题。

 一人一单问题:

也以我们的代码为例子,当我们抢购一个订单后,会在数据库中创建一条订单记录,证明该用户已经抢购过该订单,避免重复下单。但在多线程环境下,线程 1 还没有进行插入订单记录的时候,此时线程 2 来判断是否已经下单,由于数据库中没有该记录,就会造成重复下单的问题。

  • 为什么使用 Redis 作为分布式锁

       主要是为了解决在分布式和集成环境下 JVM 自带的锁不起作用的问题。

       当在集成环境下的时候,JVM 的锁监视器只能监视单个环境下的锁,在集群环境下还是有线   程安全问题。 

       

  • 为什么使用 Redission 来实现 Redis 分布式锁 

       传统的 Redis 实现分布式锁主要是通过 setnx 的互斥功能实现的,setnx 有以下几个问题:

  • 锁的不可重入。
  • 锁的不可重试。
  • 锁的超时释放:即虽然超时释放可以避免死锁,但是也考虑到某一些业务的执行时间过长导致提前释放,这里主要说的是锁的误删的问题。
4. createVoucher() 详解(创建订单函数,在 handleVoucherOrder() 中被调用)
  • 根据 voucher_id 和 user_id 在优惠券订单表中查询是否已经抢购过了。
  • 扣减数据库库存。
  • 将该优惠券订单信息加入优惠券订单表。
/**
     * 用来创建订单对象
     * @param voucherOrder
     */
    @Transactional
    public void createVoucherOrder(VoucherOrder voucherOrder) {
        // TODO 实现一人一单功能, 即每个用户只能抢购一次
        Long userId = voucherOrder.getUserId(); // 这里是子线程,不能用 UserHolder 获取
        int count = query().eq("voucher_id",voucherOrder.getVoucherId()).eq("user_id",userId).count();
        if(count >= 1) {
            log.error("当前用户已经抢购了");
            return;
        }

        // TODO 扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherOrder.getVoucherId())
                // NOTE 这里用 stock 当作版本号, 相当于实现了乐观锁, 只需要库存大于 0 即可以修改
                .gt("stock",0)
                .update();

        if(!success) {
            log.error("库存不足");
            return;
        }
        save(voucherOrder);
    }

点赞功能实现

1. 点赞功能实现
  • 获取到当前用户 id。
  • 通过存储在 Redis 中的点赞记录判断该用户是否已经点赞过该笔记。
  • 如果用户没有点赞,数据库点赞数 +1,将点赞记录保存到 Redis 的 Zset 集合。
  • 如果用户已经点赞过,数据库点赞数 -1,将点赞记录从 Redis删除。
@Override
    public Result likeBlog(Long id) {
       // TODO 获取当前用户 id
       Long userId = UserHolder.getUser().getId();
       // TODO 判断当前用户是否已经点赞
       String key = RedisConstants.BLOG_LIKED_KEY + id;
       Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
       // 当前用户没有点过赞
       if(score == null) {
          // 数据库点赞数 + 1
          boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
          if(!isSuccess) {
              return Result.fail("数据库点赞 + 1 操作失败");
          }
          // 将点赞记录保存到 Redis 的集合
          // System.currentTimeMillis(): 这是成员的分数。在有序集合中,每个成员都有一个分数,用于确定成员的排名顺序
          stringRedisTemplate.opsForZSet().add(key,userId.toString(),System.currentTimeMillis());
       } else { // 当前用户已经点赞
            // 数据库点赞数量 - 1
            boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
            if(!isSuccess) {
                return Result.fail("数据库点赞 - 1 操作失败");
            }
            // 清楚 Redis 中的点赞记录
            stringRedisTemplate.opsForZSet().remove(key,userId.toString());
       }
        return Result.ok();
    }

使用 Zset 存储元素主要是为了后面实现点赞排行榜的功能。给点赞记录进行排序。这里我们使用当前的时间戳作为分数进行排序,时间越早排在越前。

2. 点赞排行榜功能
  • 根据 key 从 Zset 中取出前五个点赞记录 (使用了 Zset 的 range 方法)。
  • 使用 stram 流操作将 Set<String> 转化为 List<Long> 。
  • 将 List<Long> 转换为用逗号分割的字符串 ids。
  • 通过 ids 查询查询每个用户对象,并将其转换为 UserDTO 对象。
  • 最后将得到的 UserDTO 对象返回即可。
@Override
    public Result queryBlogLikes(Long id) {
        // TODO 查询该博客的用户 id 前五个
        Set<String> topFive = stringRedisTemplate.opsForZSet().range(RedisConstants.BLOG_LIKED_KEY + id,0,4);
        if(topFive == null) {
            return Result.ok();
        }
        // TODO 将其转换为 Long 整数列表
        // .collect(Collectors.toList()): 这是一个终结操作, 它将流中的元素收集到一个新的 List 中
        List<Long> ids = topFive.stream().map(Long :: valueOf).collect(Collectors.toList());
        // NOTE 注意这里不能把空数组传入 userService.listByIds(ids)
        if(ids.size() == 0) {
            return Result.ok();
        }
        // 将 id 用逗号分割并转换为一个字符串
        String idStr = StrUtil.join(",",ids);
        // TODO 根据用户 id 查询用户
        // NOTE 这里不能用原来的 listByIds 去查询, listByIds 不是按顺序查询的, 需要自己实现 SQL 语句
        List<UserDTO> users = userService.query().in("id",ids)
                .last("ORDER BY FIELD(id," + idStr + ")").list()
                // .map(user -> BeanUtil.copyProperties(user, UserDTO.class)): 这是一个流操作,它将每个用户对象转换为 UserDTO 对象。
                // 在这里使用了一个 lambda 表达式,对每个用户对象执行了 BeanUtil.copyProperties 方法,将用户对象的属性复制到一个新的 UserDTO 对象中
                .stream()
                .map(user -> BeanUtil.copyProperties(user,UserDTO.class))
                .collect(Collectors.toList());
        // TODO 返回用户数据
        return Result.ok(users);
    }

关注功能实现

1.关注博主功能实现
  • 获取当前用户的 id。
  • 如果用户没有关注:
  1. 创建新的 Follow 对象并赋值,将其存入到数据库中。
  2. 然后通过 key 将 followUserId( 被关注用户 id ) 存入 Redis 集合中。
  • 如果用户已经关注:
  1. 通过 user_id 和 follow_user_id 将关注记录从数据库中移除。
  2. 通过 key 将 Redis 中的关注记录也移除。
  @Override
    public Result follow(Long followUserId, Boolean isFollow) {
        // TODO 获取到当前用户
        UserDTO user = UserHolder.getUser();

        // TODO 判断当前用户是否已经被关注
        if(!isFollow) {
            // 没有关注,新增关注数据
            Follow follow = new Follow();
            // 设置用户 id 和被关注用户 id
            follow.setUserId(user.getId());
            follow.setFollowUserId(followUserId);
            // 将新增数据加入数据库中
            boolean isSuccess = save(follow);
            String key = RedisConstants.FOLLOWS_KEY + user.getId();
            // TODO 将关注用户数据保存到 Redis 中
            if(isSuccess) {
                stringRedisTemplate.opsForSet().add(key, followUserId.toString());
            }
        } else {
            // 已经关注,取消关注
            // QueryWrapper<Follow>():这是一个查询包装器, 用于构建数据库查询条件
            boolean isSuccess = remove(new QueryWrapper<Follow>().eq("user_id",user.getId()).eq("follow_user_id",followUserId));
            // TODO 将关注数据从 Redis 中移除
            String key = RedisConstants.FOLLOWS_KEY + user.getId();
            if(isSuccess) {
                stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
            }
        }
        return Result.ok();
    }
2.关注推送功能实现
  • 如果用户已经关注某个博主,当该博主发送博客的时候,会将该博客推送给所有关注他的粉丝。
  • 通过 user_id 查询到所有关注该博主的关注记录。
  • 通过循环遍历关注记录获取粉丝用户 id。
  • 通过 FEED_KEY + 粉丝用户 id 作为 key 将博客 id 加入到 Zset 中。按照时间进行排序。(后续也是通过这个 key 来查询推送的博客)。
@Override
    public Result saveBlog(Blog blog) {
        // TODO 获取登录用户
        UserDTO user = UserHolder.getUser();
        blog.setUserId(user.getId());
        // TODO 保存探店博文
        boolean isSuccess = save(blog);
        if(!isSuccess) {
            return Result.fail("保存探店笔记失败");
        }
        // TODO 查询该笔记作者的所有关注记录
        List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();
        // TODO 推送该笔记给所有粉丝
        for(Follow follow : follows) {
           // 获取粉丝 id
           Long userId = follow.getUserId();
           // 推送笔记给粉丝(Redis)
           String key = RedisConstants.FEED_KEY + userId;
           // 按时间排序,所以加入 System.currentTimeMillis()
           stringRedisTemplate.opsForZSet().add(key,blog.getId().toString(),System.currentTimeMillis());
        }
        // 返回 id
        return Result.ok(blog.getId());
    }

3.查询推送的博客功能实现

在将推送功能实现之前,先讲一下如何实现滚动查询。

首先 Zset 中有六条数据如图所示

当我们逆序查询 0 - 2 条数据的时候,结果是 6,5,4,当我们下次查询的时候期望的数据应该是3,2,1。

就在这个时候突然插入一条数据 7,当我们再次查询的时候数据就变成了4,3,2,显然不是我们需要的数据。这个时候就不能用角标进行查询了,需要修改代码。

为了实现滚动查询,我们不使用 ZREVRANGE 而是使用 ZREVRANGEBYSCORE,即用分数最大值和最小值进行查询。

开始时候我们设置最大值为 1000,最小值为 0,查询前三条数据。

第二次查询前插入第新数据 8。

再次查询时,分数最大值设置为上一次查询的分数最小值,最小值仍然为 0,因为查询结果仍包含上一次分数最小值,所以偏移量要设置为 1。这样得到的查询结果就是我们需要的结果。

但是还是有一个问题,就是当存在分数相同的时候,我们的 offset(偏移量)就不能单单只设置为 1 了。

如下图所示,如果偏移量还是 1 还是会查找到重复的 m6,所以此时的偏移量应该是 2 才对。即 offset 应该为上一次查询结果中与最小值相同的元素个数。

总结下来,滚动查询重要的两个参数的计算方法为:

  • offset:0(第一次) | 与上一次查询最小值值相同的元素个数。
  • max: 时间戳最大值(第一次查询) | 上一次查询的最小值。

在理解完什么是滚动查询后,接下来实现博客推送查询功能就很简单啦。下面是实现代码。

  • 首先获取当前用户 id。
  • 使用 ZREVRANGEBYSCORE 查询到对应的结果,赋值给Set<ZSetOperations.TypedTuple<String>>(这是一个元组,存储的是值和分数,值的类型为 String)。
  • 遍历 Set<ZSetOperations.TypedTuple<String>>,统计最小时间和偏移量。
  • 根据博客 id 到数据库中查询博客。
  • 将内容封装成 ScrollResult 对象后返回。
@Override
    public Result queryBlogOfFollow(Long max, Integer offset) {
        // TODO 获取当前用户 id
        Long userId = UserHolder.getUser().getId();
        // TODO 查询当前用户收件箱 ZREVRANGEBYSCORE key Max(上一次的最小值) Min LIMIT offset count
        String key = RedisConstants.FEED_KEY + userId;
        Set<ZSetOperations.TypedTuple<String>> typedTuples =
                // 关键字为 key, 最小值为 0, 最大值为 max, 偏移量为 offset, 每页数量为 3
                stringRedisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, 0,max,offset,3);
        // 如果得到的集合为空,直接返回即可
        if(typedTuples == null || typedTuples.isEmpty()) {
            return Result.ok();
        }

        // TODO 对集合数据进行解析
        List<Long> ids = new ArrayList<>(typedTuples.size());
        long minTime = 0; // 最小时间
        int os = 1; // 偏移量
        for(ZSetOperations.TypedTuple<String> tuple : typedTuples) {
            ids.add(Long.valueOf(tuple.getValue())); // 获取 id
            long time = tuple.getScore().longValue(); // 获取时间戳 (score)
            // TODO 统计要返回给前端的偏移量
            if(time == minTime) {
                os++;
            } else {
                minTime = time;
                os = 1;
            }
        }
        // TODO 根据 id 查询 blog
        String idStr = StrUtil.join(",", ids);
        List<Blog> blogs = query().in("id",ids)
                .last("ORDER BY FIELD(id," + idStr + ")").list();
        for(Blog blog : blogs) {
            // TODO 查询博客相关用户信息
            queryUserById(blog);
            // TODO 判断博客是否被点赞过, 即对 isLiked 进行初始化
            isBlogLiked(blog);
        }

        // TODO 封装并返回
        ScrollResult result = new ScrollResult();
        result.setList(blogs);
        result.setOffset(os);
        result.setMinTime(minTime);

        return Result.ok(result);
    }

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值