黑马点评项目学习笔记--(8)好友关注

文章介绍了如何实现社交应用中的关注功能,包括关注和取关的接口,以及利用Redis处理共同关注。同时,提出了TimeLine模式下的关注推送实现,讨论了拉模式、推模式和推拉结合模式的优缺点,并详细阐述了采用推模式下的分页查询策略,利用Redis的sortedset实现滚动分页,避免重复读取数据的问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

需求一:实现好友关注

实现两个接口:

  • 关注和取关的接口
  • 打开页面时需要查询是否关注

具体实现

数据库表 tb_follow 记录谁关注了谁
image.png

@RestController
@RequestMapping("/follow")
public class FollowController {

    @Resource
    private IFollowService followService;

    @PutMapping("/{id}/{isFollow}")
    public Result follow(@PathVariable("id") Long followUserId,@PathVariable("isFollow") Boolean isFollow){
        return followService.follow(followUserId,isFollow);
    }

    @GetMapping("/or/not/{id}")
    public Result follow(@PathVariable("id") Long followUserId){
        return followService.isFollow(followUserId);
    }
}
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {

    @Override
    public Result follow(Long followUserId, Boolean isFollow) {
        Long userId = UserHolder.getUser().getId();
        //1.判断到底是关注还是取关
        if (isFollow) {
            //2.关注,新增数据
            Follow follow = new Follow();
            follow.setUserId(userId);
            follow.setFollowUserId(followUserId);
            save(follow);
        }else {
            //3.取关,删除 delete from tb_follow where user_id = ? and follow_user_id = ?
            remove(new QueryWrapper<Follow>()
                    .eq("user_id",userId).eq("follow_user_id",followUserId));
        }
        return Result.ok();
    }

    @Override
    public Result isFollow(Long followUserId) {
        //1.获取登录用户
        Long userId = UserHolder.getUser().getId();
        //2.查询是否关注 select count(*) form tb_follow where user_id = ? and follow_user_id = ?
        Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();
        //3.判断
        return Result.ok(count > 0);
    }
}

需求二:实现共同关注

实现方法

image.png
通过 redis 中的 sinter 命令,可以求两个set集合的交集

这样一来,我们需要改造原来的实现的关注和取关的接口,在进行关注和取关操作时,不仅要修改数据库表,还要将当前用户关注的用户的id存进redis中

改造原来的关注和取关接口

@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result follow(Long followUserId, Boolean isFollow) {
        Long userId = UserHolder.getUser().getId();
        String key = "follows:" + userId;
        //1.判断到底是关注还是取关
        if (isFollow) {
            //2.关注,新增数据
            Follow follow = new Follow();
            follow.setUserId(userId);
            follow.setFollowUserId(followUserId);
            boolean isSuccess = save(follow);
            //数据库更新成功了才修改redis
            if (isSuccess) {
                //把关注用户的id放入redis的set集合 sadd userId followerUserId
                stringRedisTemplate.opsForSet().add(key,followUserId.toString());
            }
        }else {
            //3.取关,删除 delete from tb_follow where user_id = ? and follow_user_id = ?
            boolean isSuccess = remove(new QueryWrapper<Follow>()
                    .eq("user_id", userId).eq("follow_user_id", followUserId));
            if (isSuccess) {
                //把关注用户的id从redis集合中移除
                stringRedisTemplate.opsForSet().remove(key,followUserId.toString());
            }
        }
        return Result.ok();
    }

    @Override
    public Result isFollow(Long followUserId) {
        //1.获取登录用户
        Long userId = UserHolder.getUser().getId();
        //2.查询是否关注 select count(*) form tb_follow where user_id = ? and follow_user_id = ?
        Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();
        //3.判断
        return Result.ok(count > 0);
    }
}

新增共同关注接口

@GetMapping("/common/{id}")
public Result followCommons(@PathVariable("id") Long id){
    return followService.followCommons(id);
}
@Resource
private StringRedisTemplate stringRedisTemplate;

@Resource
private IUserService userService;

@Override
public Result followCommons(Long id) {
    //1.获取当前用户并得到两个用户对应的关注用户集合的key
    Long userId = UserHolder.getUser().getId();
    String key = "follows:" + userId;
    String key2 = "follows:" + id;
    //2.求交集
    Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, key2);
    if (intersect == null || intersect.isEmpty()) {
        //无交集
        return Result.ok(Collections.emptyList());
    }
    //3.解析id集合
    List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
    //4.查询用户
    List<UserDTO> users = userService.listByIds(ids)
            .stream()
            .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
            .collect(Collectors.toList());
    return Result.ok(users);
}

需求三:实现关注推送

关注推送也叫 Feed 流,直译为“投喂”,可以通过无限下拉刷新获取新的信息
Feed流产品有两种常见模式:

  • TimeLine(时间线): 不做内容筛选,只按照发布时间的先后排序
  • 智能排序:采用智能算法,推送用户喜欢的信息

我们现在选择的是TimeLine模式

TimeLine模式的三种实现方案

  • 拉模式(读扩散)

image.png
每个博主的消息只保存一份在自己的发件箱,在粉丝需要读取的时候才会复制副本到其收件箱,然后对这些消息按照时间戳进行排序,读取完后清空收件箱
优点:节省内存
缺点:当拉取的消息过多时,排序很耗时。而且每次读取都要重新拉取也很费时

  • 推模式(写扩散)

image.png
博主将自己的消息复制许多副本推送到每个粉丝的收件箱,收件箱会对不同的消息进行排序
优点:粉丝读取耗时少
缺点:一个消息会有很多副本,占用内存

  • 推拉结合模式

image.png
对于普通博主,因为他的粉丝不多,就不用担心消息副本过多,所以可以采用推模式
对于大V,要分情况而定:
如果是活跃的粉丝,那么采用推模式,这样延迟低,用户体验好
如果是普通的粉丝,那么采用拉模式,看的时候再从大V的发件箱中拿走消息到自己的收件箱进行排序,因为偶尔看一次,有点延时没有关系

对比TimeLine的三种实现方案

image.png

基于推模式实现关注推送功能

需求:
1)修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
2)收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现
3)查询收件箱数据时,可以实现分页查询

采用传统的分页方式有什么问题

image.png
因为Feed流中的数据会不断变化,所以数据对应的角标也会变化,如果采用传统的分页方式,可能出现重复读取的清空

如何解决这个问题?

image.png
为了解决传统分页的问题,我们采用滚动分页的方式,每次记录上一次读取至的数据的位置,然后下一次查询就可以从后面开始查询,避免了重复读取消息的情况

为了采用滚动分页的方式,收件箱我们应该采用sortedset来实现,因为和list相比,他不仅能够保证消息的先后顺序,还可以按照score值进行范围查询,我们只需要每次记录读取的最后一个数据的score值,下一次读取时查询该值之后的score对应的消息即可

实现步骤1:推送博客到粉丝收件箱

@Resource
private IBlogService blogService;

@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
    return blogService.saveBlog(blog);
}
 @Override
    public Result saveBlog(Blog blog) {
        // 1.获取登录用户
        UserDTO user = UserHolder.getUser();
        blog.setUserId(user.getId());
        // 2.保存探店笔记
        boolean isSuccess = save(blog);
        if (!isSuccess) {
            return Result.fail("笔记保存失败");
        }
        // 3.查询笔记作者的所有粉丝    select * from tb_follow where follow_user_id = ?
        List<Follow> follows = followService.query().eq("follow_user_id",user.getId()).list();
        // 4.推送笔记id给所有粉丝
        for(Follow follow : follows){
            //4.1获取粉丝id
            Long userId = follow.getUserId();
            //4.2推送
            String key = "feed:" + userId;
            stringRedisTemplate.opsForZSet().add(key,blog.getId().toString(),System.currentTimeMillis());
        }
        // 5.返回id
        return Result.ok(blog.getId());
    }

实现步骤2:实现关注推送页面的分页查询

滚动分页实现原理:
image.png
这样设置offset还是存在一个问题:
image.png
所以我们应该统计出和上一次读取的最后一条记录的分数一样的数据有多少个(指的是在上一次查询的所有记录中),offset取这个值,才能够避免重复读取数据

因此我们可以总结出滚动分页查询的参数取值规律:

  • max

如果是第一次查询,值为当前时间戳
如果不是第一次查询,值为上一次查询的最小时间戳

  • min

固定取最小值0

  • offset

如果是第一次查询,值为0
如果不是第一次查询,值为上一次查询结果中,与分数最小值一样的元素的个数

  • count

取决与pagesize

@Data
public class ScrollResult {
    private List<?> list;
    private Long minTime;
    private Integer offset;
}
@GetMapping("/of/follow")
public Result queryBlogOfFollow(
    //第一次查询的时候,没有带参数offset,取默认值为0
    @RequestParam("lastId") Long max,@RequestParam(value = "offset",defaultValue = "0") Integer offset){
    return blogService.queryBlogOfFollow(max,offset);
}
@Override
    public Result queryBlogOfFollow(Long max, Integer offset) {
        //1.获取当前用户
        Long userId = UserHolder.getUser().getId();
        //2.查询收件箱
        String key = FEED_KEY + userId;
        Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
                .reverseRangeByScoreWithScores(key, 0, max, offset, 2);
        //3.非空判断
        if (typedTuples == null || typedTuples.isEmpty()) {
            return Result.ok();
        }
        //4.解析数据:blogId、minTime(时间戳)、offset(计算在上一次查询中,和最小元素一样分数的数据的个数)
        //ArrayList默认数组大小是16,为了避免数组扩容消耗性能,我们给它指定好大小
        List<Long> ids = new ArrayList<>(typedTuples.size());
        long minTime = 0;
        int os = 1;
        for(ZSetOperations.TypedTuple<String> tuple : typedTuples){
            //4.1获取id
            ids.add(Long.valueOf(tuple.getValue()));
            //4.2获取分数(时间戳)
            long time = tuple.getScore().longValue();
            if(time == minTime){
                os++;
            }else{
                minTime = time;
                os = 1;
            }
        }
        //5.根据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){
            //5.1查询blog有关的用户
            queryBlogUser(blog);
            //5.2查询blog是否被点赞
            isBlogLiked(blog);
        }
        //6.封装并返回
        ScrollResult r = new ScrollResult();
        r.setList(blogs);
        r.setOffset(os);
        r.setMinTime(minTime);
        return Result.ok(r);
    }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值