Redis实战—达人探店、好友关注

  本博客为个人学习笔记,学习网站与详细见:黑马程序员Redis入门到实战 P78 - P87

目录

达人探店

发布探店笔记

查看探店笔记

点赞功能

点赞排行榜

好友关注

关注与取关

共同关注

关注推送

Timeline

具体实现


达人探店

发布探店笔记

需求:实现以下上传图片与发布笔记两个接口。

上传图片接口代码如下。

@Slf4j
@RestController
@RequestMapping("upload")
public class UploadController {

    @PostMapping("blog")
    public Result uploadImage(@RequestParam("file") MultipartFile image) {
        try {
            // 获取原始文件名称
            String originalFilename = image.getOriginalFilename();
            // 生成新文件名
            String fileName = createNewFileName(originalFilename);
            // 保存文件
            image.transferTo(new File(SystemConstants.IMAGE_UPLOAD_DIR, fileName));
            // 返回结果
            log.debug("文件上传成功,{}", fileName);
            return Result.ok(fileName);
        } catch (IOException e) {
            throw new RuntimeException("文件上传失败", e);
        }
    }
}

发布笔记接口代码如下。

@RestController
@RequestMapping("/blog")
public class BlogController {

    @Resource
    private IBlogService blogService;

    @PostMapping
    public Result saveBlog(@RequestBody Blog blog) {
        //获取登录用户
        UserDTO user = UserHolder.getUser();
        blog.setUpdateTime(user.getId());
        //保存探店博文
        blogService.saveBlog(blog);
        //返回id
        return Result.ok(blog.getId());
    }
}

查看探店笔记

接口方法的具体实现如下。

@Override
public Result queryBlogById(Long id) {
    // 1.查询blog
    Blog blog = getById(id);
    if (blog == null) {
        return Result.fail("笔记不存在");
    }
    // 2.为blog添加作者信息
    queryBlogUser(blog);
    return Result.ok(blog);
}

点赞功能

接口如下图所示。

需求如下。
1. 同一个用户对同一篇笔记只能点赞一次,再次点击则视为取消点赞;
2. 如果当前用户已经点赞笔记,则点赞按钮高亮显示(前端通过判断Blog类的isLike属性实现高亮功能,因此后端需要为Blog类的isLike属性赋值);

实现步骤如下。
1. 给Blog类中添加一个isLike字段,表示是否被当前用户点赞,前端可根据该字段实现高亮功能;
2. 修改点赞功能,利用Redis的set集合判断笔记是否被当前用户点赞过,若未点赞过则点赞数+1,若已点赞过则点赞数-1;
3. 修改根据id查询Blog的业务:在查询到Blog后,判断当前用户是否点赞过,为Blog的isLike字段赋值;
4. 修改分页查询Blog业务:在查询到Blog后,判断当前登录用户是否点赞过,为每个Blog的isLike字段赋值。


1. 给Blog类中添加一个isLike字段

@TableField(exist = false)
private Boolean isLike;

2.全部代码如下

@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {

    @Resource
    private IUserService userService;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override //分页查询
    public Result queryHotBlog(Integer current) {
        // 根据用户查询
        Page<Blog> page = query()
                .orderByDesc("liked")
                .page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
        // 获取当前页数据
        List<Blog> records = page.getRecords();
        // 查询用户
        records.forEach(blog -> {
            this.queryBlogUser(blog);
            this.isBlogLiked(blog);
        });
        return Result.ok(records);
    }

    @Override
    public Result queryBlogById(Long id) {
        // 1.查询blog
        Blog blog = getById(id);
        if (blog == null) {
            return Result.fail("笔记不存在");
        }
        // 2.为blog添加作者信息
        queryBlogUser(blog);
        // 3.查询当前blog是否被点赞
        isBlogLiked(blog);
        return Result.ok(blog);
    }

    //为Blog的isLike字段赋值
    private void isBlogLiked(Blog blog) {
        // 1.获取当前登录用户
        UserDTO user = UserHolder.getUser();
        if (user == null) {
            //若用户未登录,则返回空
            return;
        }
        Long userId = user.getId();
        // 2.判断当前登录用户是否已经点赞
        String key = "blog:liked:" + blog.getId();
        Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
        blog.setIsLike(BooleanUtil.isTrue(isMember));
    }

    @Override //点赞接口方法具体实现
    public Result likeBlog(Long id) {
        // 1.获取当前登录用户
        Long userId = UserHolder.getUser().getId();
        // 2.判断当前登录用户是否已经点赞
        String key = "blog:liked:" + id;
        Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
        // 3.如果未点赞
        if (BooleanUtil.isFalse(isMember)) {
            // 3.1数据库点赞数+1
            boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
            // 3.2保存用户到Redis的 set集合
            if (isSuccess)
                stringRedisTemplate.opsForSet().add(key, userId.toString());
        } else {
            // 4.如果已点赞
            // 4.1数据库点赞数-1
            boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
            // 4.2将该用户从Redis的set集合中移除
            if (isSuccess)
                stringRedisTemplate.opsForSet().remove(key, userId.toString());
        }
        return Result.ok();
    }

    //为笔记添加作者信息,以便前端展示
    private void queryBlogUser(Blog blog) {
        Long userId = blog.getUserId();
        User user = userService.getById(userId);
        blog.setName(user.getNickName());
        blog.setIcon(user.getIcon());
    }
}


点赞排行榜

需求与接口信息如下。

分析:在点赞功能的实现中,我们使用的是set集合,但是set集合没有排序功能,因此,我们需要采用一个可以排序的set集合,即sortedSet集合,用时间戳作为分数。

redis各种集合对比如下。

代码修改与添加如下。

@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {

    @Resource
    private IUserService userService;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override //分页查询
    public Result queryHotBlog(Integer current) {
        // 根据用户查询
        Page<Blog> page = query()
                .orderByDesc("liked")
                .page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
        // 获取当前页数据
        List<Blog> records = page.getRecords();
        // 查询用户
        records.forEach(blog -> {
            this.queryBlogUser(blog);
            this.isBlogLiked(blog);
        });
        return Result.ok(records);
    }

    @Override
    public Result queryBlogById(Long id) {
        // 1.查询blog
        Blog blog = getById(id);
        if (blog == null) {
            return Result.fail("笔记不存在");
        }
        // 2.为blog添加作者信息
        queryBlogUser(blog);
        // 3.查询当前blog是否被点赞
        isBlogLiked(blog);
        return Result.ok(blog);
    }

    //为Blog的idLike字段赋值
    private void isBlogLiked(Blog blog) {
        // 1.获取当前登录用户
        UserDTO user = UserHolder.getUser();
        if (user == null) {
            //若用户未登录,则返回空
            return;
        }
        Long userId = user.getId();
        // 2.判断当前登录用户是否已经点赞
        String key = "blog:liked:" + blog.getId();
        Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
        blog.setIsLike(score != null);
    }

    @Override
    //点赞接口方法具体实现
    public Result likeBlog(Long id) {
        // 1.获取当前登录用户
        Long userId = UserHolder.getUser().getId();
        // 2.判断当前登录用户是否已经点赞
        String key = "blog:liked:" + id;
        Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
        // 3.如果未点赞
        if (score == null) {
            // 3.1数据库点赞数+1
            boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
            // 3.2保存用户到Redis的 set集合
            if (isSuccess)
                stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
        } else {
            // 4.如果已点赞
            // 4.1数据库点赞数-1
            boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
            // 4.2将该用户从Redis的set集合中移除
            if (isSuccess)
                stringRedisTemplate.opsForZSet().remove(key, userId.toString());
        }
        return Result.ok();
    }

    @Override //点赞排行榜
    public Result queryBlogLikes(Long id) {
        String key = "blog:liked:" + id;
        // 1.查询top5的点赞用户
        Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
        if (top5 == null || top5.isEmpty()) { //如果没有用户点赞该Blog,则返回空集合
            return Result.ok(Collections.emptyList());
        }
        // 2.解析出top5中的用户id
        List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
        // 3.根据用户id查询用户
        List<UserDTO> userDTOS = userService.listByIds(ids)
                .stream()
                .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
                .collect(Collectors.toList());
        // 4.返回
        return Result.ok(userDTOS);
    }

    //为笔记添加作者信息,以便前端展示
    private void queryBlogUser(Blog blog) {
        Long userId = blog.getUserId();
        User user = userService.getById(userId);
        blog.setName(user.getNickName());
        blog.setIcon(user.getIcon());
    }
}

代码修改后,在测试时发现bug,即点赞排行榜中用户排序并没有按照预期的时间先后顺序排序。原因是我们使用的查询是MP的listByIds(ids),其底层逻辑是用IN关键字查询,而IN查询是不会按照ids中元素的顺序排序的,因此我们需要添加order by自定义查询结果顺序。

修改后代码如下。

@Override //点赞排行榜
public Result queryBlogLikes(Long id) {
    String key = "blog:liked:" + id;
    // 1.查询top5的点赞用户
    Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
    if (top5 == null || top5.isEmpty()) { //如果没有用户点赞该Blog,则返回空集合
        return Result.ok(Collections.emptyList());
    }
    // 2.解析出top5中的用户id
    List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
    // 3.根据用户id查询用户
    String idStr = StrUtil.join(",", ids);
    List<UserDTO> userDTOS = userService.query()
            .in("id", ids).last("ORDER BY FIELD(id," + idStr + ")")
            .list()
            .stream()
            .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
            .collect(Collectors.toList());
    // 4.返回
    return Result.ok(userDTOS);
}

好友关注

关注与取关


接口信息如下。

Controller层代码如下。

@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 interface IFollowService extends IService<Follow> {

    Result follow(Long followUserId, Boolean isFollow);

    Result isFollow(Long followUserId);
}


//实现类代码
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {

    @Override
    public Result follow(Long followUserId, Boolean isFollow) {
        // 1.获取当前登录用户id
        Long userID = UserHolder.getUser().getId();

        // 2.判断是关注还是取关
        if (isFollow) {
            // 关注,新增数据
            Follow follow = new Follow();
            follow.setUserId(userID);
            follow.setFollowUserId(followUserId);
            save(follow);
        } else {
            remove(new QueryWrapper<Follow>()
                    .eq("user_id", userID).
                    eq("follow_user_id", followUserId));
        }
        return Result.ok();
    }

    @Override //判断是否关注
    public Result isFollow(Long followUserId) {
        Long userId = UserHolder.getUser().getId();
        //查询数据库是否存在该条数据
        Integer count = query().eq("userId", userId).eq("follow_user_id", followUserId).count();
        return Result.ok(count > 0);
    }
}

共同关注

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

Redis的set集合为我们提供了交集并集补集的api,我们可以为每个用户提供一个set集合,并将各用户关注的用户存入到各自的set集合中。当我们需要找出两个用户的共同关注时,我们只需要对这两个用户的set集合求并集即可。

因此,我们首先需要对关注与取关的代码进行修改,在关注与取关的同时,需要同步redis的set集合数据,修改代码如下。

@Override
public Result follow(Long followUserId, Boolean isFollow) {
    // 1.获取当前登录用户id
    Long userID = UserHolder.getUser().getId();
    String key = "follows:" + userID;
    // 2.判断是关注还是取关
    if (isFollow) {
        // 关注,新增数据
        Follow follow = new Follow();
        follow.setUserId(userID);
        follow.setFollowUserId(followUserId);
        boolean isSuccess = save(follow);
        // 同时将关注信息保存到redis的set集合中
        if(isSuccess)
            stringRedisTemplate.opsForSet().add(key, followUserId.toString());
    } else {
        boolean isSuccess = remove(new QueryWrapper<Follow>()
                .eq("user_id", userID).
                eq("follow_user_id", followUserId));
        // 同时将关注信息从redis的set集合中删除
        if(isSuccess)
            stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
    }
    return Result.ok();
}

共同关注接口方法具体实现代码如下。

@Override
public Result followCommons(Long id) {
    // 1.获取当前登录用户
    Long userId = UserHolder.getUser().getId();
    // 2.求交集
    String key1 = "follows:" + userId;
    String key2 = "follows:" + id;
    Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key1, 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.查询用户并转换成UTO
    List<UserDTO> users = userService.listByIds(ids)
            .stream()
            .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
            .collect(Collectors.toList());
    return Result.ok(users);
}

关注推送

        当一个用户发布了笔记,我们会将该笔记推送给他的粉丝,这个需求我们称为Feed流,直译为投喂。为用户持续的提供“沉浸式”的体验,通过无限下拉刷新获取新的信息。  


Timeline

        该模式的核心含义是:当张三、李四和王五发了动态后,会将动态信息保存到自己的发件箱中。如果赵六要读取信息,那么他会读取自己收件箱中的信息,此时系统会从他关注的人群中,把他关注人的动态信息全部拉取到收件箱,然后再进行排序。
        优点:节省空间,因为赵六在查看信息时不会重复获取,而且查看完后可以清理收件箱。
        缺点:存在延迟,只有当用户读取数据时才进行信息拉取。如果用户关注了大量博主,会导致服务器在拉取大量内容时承受巨大压力。


        推模式不存在发件箱,当张三发了动态后,系统会主动将动态推送到其粉丝的收件箱中。当粉丝读取动态时,将不再需要临时拉取数据。
        优点:响应速度快,无需临时拉取数据。
        缺点:内存压力大,例如一个知名用户发布信息时,由于粉丝众多,系统需要将大量数据写入粉丝的收件箱。



        推拉模式是一个折中的解决方案。从发件博主的角度来看,对于普通博主,系统采用写扩散的方式,直接将动态信息写入他们粉丝的收件箱,因为普通博主的粉丝数量少,因此这样做不会造成太大压力。对于大V博主,系统会先将动态信息写入他们的发件箱,然后再写入到活跃粉丝的收件箱。现在从粉丝的角度来看,对于活跃粉丝,无论是大V博主还是普通博主发布的内容都会直接写入到他们的收件箱。而对于普通粉丝,由于他们上线不频繁,所以系统会等到他们上线时,再从他们关注博主的发件箱中临时拉取信息。



具体实现

本案例我们采用推模式实现Feed流推送,即博主发布笔记时,会自动将该笔记推送到其粉丝的收件箱中,当粉丝查看自己关注博主的笔记时,程序会在该粉丝的收件箱中查询笔记并返回前端进行展示。

        我们使用Feed流的滚动分页代替传统分页。我们需要记录每次操作获取到的最后一条笔记,然后下次获取数据时从这条笔记以下的数据开始读取。如下图所示,举例来说,从t1时刻开始,我们获取第一页数据,例如10到6的笔记,然后记录下当前页的最后一条笔记,即6。在t2时刻,博主发布了新的记录,笔记11出现在最顶上,但不影响我们后续的数据获取。在t3时刻,开始获取第二页数据,根据之前记录的笔记6,我们从6后面的笔记5开始获取,从而得到了5到1的笔记数据。我们可以利用sortedSet来实现这个功能,它支持sorce范围查询,我们可以为每条笔记的sorce赋值时间戳,从而实现滚动分页。


首先对发布笔记的代码进行修改如下,在博主发布笔记后,需要将该笔记推送到其粉丝的收件箱中。

@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给所有粉丝
    for (Follow follow : follows) {
        // 4.1获取粉丝id
        Long userId = follow.getUserId();
        // 4.2推送给粉丝的收件箱(zset)
        String key = "feed:" + userId;
        stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());
    }
    return Result.ok(blog.getId());
}

我们采用的是按分数查询,而不是按角标查询,查询命令如下图所示。

注意!!!:分数范围查询是从第一个socre值与最大值参数相同的数据开始查询的。
举例:设查询条数为3,偏移量固定为0,数据的Value值与score值如下:
m1、8
m2、6
m3、6
m4、6
m5、3
m6、2
        第一次查询到的3条数据为m1、m2、m3,得到的最小时间戳为6
        第二次查询时,我们将score最大值参数设置为上一次查询结果的最小时间戳,即6,由于偏移量为0,因此查询会从第一个score值等于6的数据开始查询,得到的结果是m2、m3、m6,此时查询到的部分数据与第一次查询得到的数据重复。因此我们应该在查询时设置偏移量,而偏移量的值应该为上一次查询结果中,时间戳与最小时间戳相同的笔记数量。
        上一次查询结果为m1、m2、m3,最小时间戳为6,因此查询结果中时间戳与最小时间戳相同的笔记为m2、m3,数量为2。因此第二次查询命令的偏移量参数为2。当第二次查询偏移量参数为2时,起点将从m2向下偏移两位到m4,于是第2次查询得到的结果为m4、m5、m6,查询结果不再与第一次查询得到的结果重复。具体参数填写如下。

参数说明
score最大值查询第一页:传入当前时间戳
查询其它页:传入上一页查询得到的笔记中最小的时间戳
score最小值固定为0
偏移量上一页查询得的笔记中,时间戳与最小时间戳相同的笔记数量
查询条数自定义


 

具体操作如下:
1、每次查询完成后,我们需要分析出查询到的笔记的最小时间戳,这个值会作为下一次查询的条件;
2、我们需要在上一次查询结果中,计算时间戳与最小时间戳相同的笔记数量,该数量将作为下一次查询的偏移量值。

综上:我们的请求参数中需要携带lastId和offset,即上一次查询到的最小时间戳和偏移量这两个参数。这两个参数在第一次查询时会由前端来指定(即当前时间戳和0),之后的查询会根据后台结果作为条件,再次传递到后台。

首先定义返回结果类如下。

@Data
public class ScrollResult {
    private List<?> list;
    private Long minTime;
    private Integer offset;
}

Controller层代码 

@GetMapping
public Result queryBlogOfFollow(@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.查询收件箱 ZREVRANGEBYSCORE Key Max Min LIMIT offset count
    String key = "feed:" + userId;
    Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
            .reverseRangeByScoreWithScores(key, 0, max, offset, 3);
    // 3.非空判断
    if (typedTuples == null || typedTuples.isEmpty())
        return Result.ok();

    // 4.解析数据:blogId、minTime(时间戳)、offset
    List<Long> ids = new ArrayList<>(typedTuples.size());
    long minTime = 0; //用于记录最小分数(时间戳)
    int off = 1; //计算typedTuples中分数与最小分数值相同的笔记个数
    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)
            off++;
        else {
            minTime = time;
            off = 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(off);
    r.setMinTime(minTime);
    return Result.ok(r);
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值