好友关注

HeJin大约 8 分钟项目实战Redis项目实战

01.关注和取关

需求

在探店图文的详情页面中,可以关注发布笔记的作者:

image-20230205100852627
image-20230205100852627

需求:基于该表数据结构,实现两个接口:

  • 关注和取关接口。
  • 判断是否关注的接口。

关注是User之间的关系,是博主与粉丝的关系,数据库中有一张tb_follow表来标示:

CREATE TABLE `tb_follow` (
    `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
    `user_id` bigint unsigned NOT NULL COMMENT '用户id',
    `follow_user_id` bigint unsigned NOT NULL COMMENT '关联的用户id',
    `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=COMPACT;

实现

Follow

@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_follow")
public class Follow implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 主键
     */
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    /**
     * 用户id
     */
    private Long userId;

    /**
     * 关联的用户id
     */
    private Long followUserId;

    /**
     * 创建时间
     */
    private LocalDateTime createTime;


}

FollowController

/**
 * 关注与取关用户
 * @param followUserId 关注的用户id
 * @param isFollow 是否关注
 * @return R
 */
@PutMapping("/{id}/{isFollow}")
public Result follow(@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFollow){
    return followService.follow(followUserId, isFollow);
}

/**
 * 是否已关注用户
 * @param followUserId 关注用户id
 * @return R
 */
@GetMapping("/or/not/{id}")
public Result isFollow(@PathVariable("id") Long followUserId){
    return followService.isFollow(followUserId);
}

FollowServiceImplopen in new window

@Override
public Result follow(Long followUserId, Boolean isFollow) {
    // 1.获取用户id
    Long userId = UserHolder.getUser().getId();
    // 判断是关注还是取关
    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 = ?
        LambdaQueryWrapper<Follow> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(Follow::getUserId, userId);
        queryWrapper.eq(Follow::getFollowUserId, followUserId);
        remove(queryWrapper);
    }
    return Result.ok();
}

@Override
public Result isFollow(Long followUserId) {
    // 查询是否关注 select * from tb_follow where user_id = ? and follow_user_id = ?
    Long userId = UserHolder.getUser().getId();
    Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();
    return Result.ok(count > 0);
}

02.共同关注

需求

点击博主头像,可以进入博主首页:

image-20230205101319363
image-20230205101319363

博主个人首页依赖两个接口:

  • 根据id查询user信息:

    UserController

    @GetMapping("/{id}")
    public Result queryUserById(@PathVariable("id") Long userId){
        // 查询详情
        User user = userService.getById(userId);
        if (user == null) {
            return Result.ok();
        }
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        // 返回
        return Result.ok(userDTO);
    }
    
  • 根据id查询博主的探店笔记:

    BlogController

    @GetMapping("/of/user")
    public Result queryBlogByUserId(
            @RequestParam(value = "current", defaultValue = "1") Integer current,
            @RequestParam("id") Long id) {
        // 根据用户查询
        Page<Blog> page = blogService.query()
                .eq("user_id", id).page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
        // 获取当前页数据
        List<Blog> records = page.getRecords();
        return Result.ok(records);
    }
    

需求:利用Redis中恰当的数据结构,实现共同关注功能。在博主个人页面展示出当前用户与博主的共同好友。

image-20230205101905645
image-20230205101905645

实现

FollowController

/**
 * 共同关注
 * @param id 用户id
 * @return R
 */
@GetMapping("/common/{id}")
public Result followCommons(@PathVariable("id") Long id){
    return followService.followCommons(id);
}

FollowServiceImplopen in new window

@Override
public Result followCommons(Long id) {
    // 1.获取当前登录用户
    Long userId = UserHolder.getUser().getId();
    String key = RedisConstants.FOLLOWS_KEY + userId;
    // 2.求交集
    String key2 = RedisConstants.FOLLOWS_KEY + id;
    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);
}

在Redis中使用set集合存储关注用户id,使用set的交集获取共同关注。

image-20230205102454541
image-20230205102454541

因为用到了Redis中的set数据结构,所以需要再用户关注的时候把关注信息存到set,用户取关的时候从Redis中移除关注信息。key是用户id,value存储关注id。

image-20230205102832091
image-20230205102832091

修改关注实现:

@Override
public Result follow(Long followUserId, Boolean isFollow) {
    // 1.获取用户id
    Long userId = UserHolder.getUser().getId();
    String key = RedisConstants.FOLLOWS_KEY + userId;
    // 判断是关注还是取关
    if (isFollow){
        // 2.关注,新增数据
        Follow follow = new Follow();
        follow.setUserId(userId);
        follow.setFollowUserId(followUserId);
        boolean isSuccess = save(follow);
        if (isSuccess){
            // 把关注用户的id,存入Redis中的set
            stringRedisTemplate.opsForSet().add(key, followUserId.toString());
        }
    } else {
        // 3.取关,删除数据 delete from tb_follow where user_id = ? and follow_user_id = ?
        LambdaQueryWrapper<Follow> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(Follow::getUserId, userId);
        queryWrapper.eq(Follow::getFollowUserId, followUserId);
        boolean isSuccess = remove(queryWrapper);
        // 把关注用户的id从redis中的set移除
        if (isSuccess){
            stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
        }
    }
    return Result.ok();
}

03.关注推送

Feed流实现方案分析

关注推送也叫做Feed流,直译为投喂。为用户持续的提供“沉浸式”的体验,通过无限下拉刷新获取新的信息。

image-20230205103033800
image-20230205103033800

Feed流产品有两种常见模式:

  • Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈。
    • 优点:信息全面,不会有缺失。并且实现也相对简单。
    • 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低。
  • 智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户。
    • 优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷。
    • 缺点:如果算法不精准,可能起到反作用。

本例中的个人页面,是基于关注的好友来做Feed流,因此采用Timeline的模式。该模式的实现方案有三种:

  • 拉模式:也叫读扩散。

    image-20230205104019207
    image-20230205104019207
  • 推模式:也叫做写扩散。

    image-20230205104050924
    image-20230205104050924
  • 推拉结合:也叫做读写混合,兼具推和拉两种模式的优点。

    image-20230205104138932
    image-20230205104138932

三种方案对比:

拉模式推模式推拉结合
写比例
读比例
用户读取延迟
实现难度复杂简单很复杂
使用场景很少使用用户量少、没有大V过千万的用户量,有大V

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

需求:

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

推送到粉丝收件箱

BlogController

/**
 * 保存探店笔记
 * @param blog 探店笔记实体
 * @return R
 */
@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
    return blogService.saveBlog(blog);
}

BlogServiceImplopen in new window

/**
 * 保存探店笔记,并推送笔记id到Redis
 * @param blog 探店笔记实体
 * @return R
 */
@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.查询笔记作者的所有粉丝
    List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();
    // 4.推送笔记id给所有粉丝
    double score = System.currentTimeMillis();
    for (Follow follow : follows) {
        // 4.1获取粉丝id
        Long userId = follow.getUserId();
        // 4.2推送
        String key = RedisConstants.FEED_KEY + userId;
        stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), score);
    }
    // 5.返回id
    return Result.ok(blog.getId());
}

查看Redis:

image-20230205110311573
image-20230205110311573

1号用户关注了博主,当博主发布新笔记的时候,就会把笔记id推送到Redis中。

滚动分页查询收件箱的思路

Feed流中的数据会不断更新,所以数据的角标也在变化,因此不能采用传统的分页模式。

实现关注推送页面的分页查询

需求:在个人主页的“关注”卡片中,查询并展示推送的Blog信息:

image-20230205105259953
image-20230205105259953

接口设计:

  • 请求地址:/blog/of/follow

  • 请求方式:GET

  • 请求参数:

    参数说明
    lastId上一次查询的最小时间戳
    offset偏移量
  • 响应格式:

    参数说明
    List<Blog>小于指定时间戳的笔记集合
    minTime本次查询的推送的最小时间戳
    offset偏移量
    {
        "success": true,
        "data": {
            "list": [
                {
                    "id": 29,
                    "shopId": 5,
                    "userId": 1010,
                    "icon": "",
                    "name": "user_aahh9auvpj",
                    "isLike": false,
                    "title": "可可不吃肉第100次发",
                    "images": "/imgs/blogs/7/12/f9d5f2cd-8ca7-43ae-b2b6-d7cdcd077a1c.jpg",
                    "content": "环境:\n环境挺优雅的,然后自己在这边逛街,然后累了有点饿,就随便进了一家餐厅外观看着挺好看的适合下午茶呀,风景都很漂亮,地方也很好找,就是在电梯旁边。\n服务:\n服务员是一个叔叔,挺热情的,他说我一个人点了太多菜吃不完很浪费,让后让我去掉几个菜,他说我不应该点七八个菜。蛮好的,吃了这么多页才四百多。",
                    "liked": 0,
                    "createTime": "2023-02-04T19:29:31",
                    "updateTime": "2023-02-04T19:29:31"
                },
                {
                    "id": 28,
                    "shopId": 4,
                    "userId": 1010,
                    "icon": "",
                    "name": "user_aahh9auvpj",
                    "isLike": false,
                    "title": "可爱多第5次发",
                    "images": "/imgs/blogs/3/6/1d5fdf4f-c589-466f-a7d8-d8d6a34114fd.jpg",
                    "content": "5555555555555555",
                    "liked": 0,
                    "createTime": "2023-02-03T17:42:22",
                    "updateTime": "2023-02-03T17:42:22"
                },
                {
                    "id": 27,
                    "shopId": 8,
                    "userId": 1010,
                    "icon": "",
                    "name": "user_aahh9auvpj",
                    "isLike": false,
                    "title": "可爱多第4次发",
                    "images": "/imgs/blogs/7/8/e42d4ccf-1a80-4de1-83bc-38dff19b8306.jpg",
                    "content": "44444444444444",
                    "liked": 0,
                    "createTime": "2023-02-03T17:37:17",
                    "updateTime": "2023-02-03T17:37:17"
                }
            ],
            "minTime": 1675417037362,
            "offset": 1
        }
    }
    

BlogController

/**
 * 滚动分页查询关注收件箱信息
 * @param max 当前时间戳
 * @param offset 偏移量
 * @return R
 */
@GetMapping("/of/follow")
public Result queryBlogOfFollow(
        @RequestParam("lastId") Long max,
        @RequestParam(value = "offset", defaultValue = "0") Integer offset) {
    return blogService.queryBlogOfFollow(max, offset);
}

BlogServiceImplopen in new window

/**
 * 滚动分页查询关注收件箱信息
 *
 * @param max    当前时间戳
 * @param offset 偏移量
 * @return R
 */
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
    // 1.获取当前用户
    Long userId = UserHolder.getUser().getId();
    // 2.查询收件箱
    String key = RedisConstants.FEED_KEY + userId;
    Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
            .reverseRangeByScoreWithScores(key, 0, max, offset, 3);
    if (typedTuples == null || typedTuples.isEmpty()){
        return Result.ok();
    }
    // 3.解析数据: blogId、minTime(时间戳)、offset
    // 探店笔记id集合
    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()));
        long time = tuple.getScore().longValue();
        if (time == minTime){
            os++;
        } else {
            minTime = time;
            os = 1;
        }
    }

    // 4.根据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) {
        // 4.1查询blog有关的用户
        queryBlogUser(blog);
        // 4.2.查询blog是否被点赞过
        isBlogLiked(blog);
    }

    // 5.封装并返回
    ScrollResult r = new ScrollResult();
    r.setList(blogs);
    r.setMinTime(minTime);
    r.setOffset(os);
    return Result.ok(r);
}

测试: