实战篇
针对黑马点评项目的一些功能编写。
探店功能
本功能主要实现用户对店铺的评价功能,也可以称为探店笔记
主要涉及到两个表:
- tb_blo:探店笔记表,包含笔记中的标题、文字、图片等
- tb_blog_comments:其他用户对探店笔记的评价(即其他用户可以评价该笔记)
项目原型:
点击+号进入编写笔记页面
主要涉及到两个接口:
- 文件上传接口:主要用于上传图片,不仅可以在这里使用,本项目的所有图片上传都可以使用
- 笔记发布接口:在编写完笔记后,将笔记写入到数据库中
文件上传接口
接口设计:
请求路径:http://localhost:8080/api/blog
携带参数:MultipartFile格式的图片信息
返回:文件名,即文件访问路径
代码开发:
UploadController 中的uploadImage方法实现上传文件
SystemConstants.IMAGE_UPLOAD_DIR为图片的本地存储路径,这里我们指定图片上传到前端服务器上,以便前端直接能够调用。路径改为:本地前端防止位置的:hm_dp\nginx-1.18.0\html\hmdp\imgs目录下
java"> public static final String IMAGE_UPLOAD_DIR = "D:\\Code\\Java\\Object\\hm_dp\\nginx-1.18.0\\html\\hmdp\\imgs\\";
java">@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);}}
}
图片文件名生成方法:用于图片上传
java"> private String createNewFileName(String originalFilename) {// 获取后缀String suffix = StrUtil.subAfter(originalFilename, ".", true);// 生成目录String name = UUID.randomUUID().toString();int hash = name.hashCode();int d1 = hash & 0xF;int d2 = (hash >> 4) & 0xF;// 判断目录是否存在File dir = new File(SystemConstants.IMAGE_UPLOAD_DIR, StrUtil.format("/blogs/{}/{}", d1, d2));if (!dir.exists()) {dir.mkdirs();}// 生成文件名return StrUtil.format("/blogs/{}/{}/{}.{}", d1, d2, name, suffix);}
功能测试:
成功上传图片到本地文件夹下
探店笔记上传接口
接口设计:
请求路径:api/blog
请求携带参数:店铺id,笔记名称,上传的图片路径,笔记内容
无返回值 。
代码开发:
直接在Controller层实现保存笔记功能,需要将当前用户信息存入Blog对象中
java"> @PostMappingpublic Result saveBlog(@RequestBody Blog blog) {// 获取登录用户UserDTO user = UserHolder.getUser();blog.setUserId(user.getId());// 保存探店博文blogService.save(blog);// 返回idreturn Result.ok(blog.getId());}
功能测试:
成功上传笔记。
根据笔记Id查询笔记信息
接口设计:
代码开发:
Controller层:
接收前端的blogID,调用Service层的查询方法
java"> @GetMapping("/{id}")public Result QueryBlogBuId(@PathVariable("id") Long id) {return blogService.queryBlogById(id);}
Service层:
根据BlogID查询对应的Blog信息,并根据用户id查询必要的用户信息封装到Blog中返回
java"> @Overridepublic Result queryBlogById(Long id) {// 1. 根据id查询Blog信息Blog blog = getById(id);// 2. 获取Blog对应的用户信息queryBlogUser(blog);// 返回blog信息return Result.ok(blog);}private void queryBlogUser(Blog blog) {Long userId = blog.getUserId();User user = userService.getById(userId);blog.setName(user.getNickName());blog.setIcon(user.getIcon());}
功能测试:
成功查看笔记详情
用户点赞Blog功能
接口设计
请求路径:api/blog/like/{id}
请求参数:blog的id
无返回值
在目前的代码中,简单编写了对blog进行点赞的功能,但有缺陷,未记录已点赞用户,导致用户能够重复进行点赞。
之前的代码:
java"> @PutMapping("/like/{id}")public Result likeBlog(@PathVariable("id") Long id) {// 修改点赞数量blogService.update().setSql("liked = liked + 1").eq("id", id).update();return Result.ok();}
基于此,我们提出下面的需求:
- 同一个用户只能点赞一次,当已点赞时再次点赞会取消点赞
- 如果当前用户已经点赞,则点赞按钮会高亮以提示用户已点赞(前端已实现,只需要在Blog类中增加isLike属性)
修改步骤:
- 给Blog类添加isLike字段,用于标记blog是否被当前用户点赞
- 修改点赞功能,利用Redis的set集合来标记用户是否点赞,并实现点赞功能
- 修改根据id查询Blog信息的功能,需要给isLike字段赋值
- 修改分页查询Blog的业务,同样需要对isLike字段赋值
给Blog类添加isLike字段
在Blog类中添加下面的字段即可,@TableFiled表示是否为数据库字段
java"> /*** 是否点赞过了*/@TableField(exist = false)private Boolean isLike;
修改点赞功能
Controller层:不实现业务,只调用Service层即可
java"> @PutMapping("/like/{id}")public Result likeBlog(@PathVariable("id") Long id) {return blogService.likeBlogById(id);}
Service层:
根据在Redis中查询的结果来判断当前用户是否点赞,然后进行点赞和取消点赞操作。
java"> @Overridepublic Result likeBlogById(Long id) {// 1. 获取当前用户Long userId = UserHolder.getUser().getId();// 2. 判断当前用户是否已经点赞String key = RedisConstants.BLOG_LIKED_KEY + id;Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());// 3. 如果未点赞,进行点赞操作if (BooleanUtil.isFalse(isMember)) {// 3.1 数据库点赞数+1boolean success = update().setSql("liked = liked + 1").eq("id", id).update();if (success) {// 点赞成功// 3.2 保存用户id到Redis中,表示用户已经点赞stringRedisTemplate.opsForSet().add(key, userId.toString());}else {log.error("点赞失败");}}else {// 4. 如果已点赞,取消点赞// 4.1 数据库点赞数-1boolean success = update().setSql("liked = liked - 1").eq("id", id).update();if (success) {// 4.2 从Redis中移除用户id,表示用户取消点赞stringRedisTemplate.opsForSet().remove(key, userId.toString());}else {log.error("取消点赞失败");}}return Result.ok();}
增加isLike字段赋值操作,根据笔记id为isLike字段赋值
java"> private void isBlogLiked(Blog blog) {// 1. 获取当前用户Long userId = UserHolder.getUser().getId();// 2. 判断当前用户是否已经点赞String key = RedisConstants.BLOG_LIKED_KEY + blog.getId();Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());blog.setIsLike(BooleanUtil.isTrue(isMember));}
queryBlogById和queryHotBlog方法调用该方法即可。
注意:这里的代码有Bug,当用户未登录时,获取不到用户,或造成查询用户是否点赞有空指针异常,因此这里需要进行修改。
我们获取用户id前先查询用户是否登录,即UserHolder中是否存在用户,不存在则直接结束(未登录则未点赞),存在才进行查询。
java"> private void isBlogLiked(Blog blog) {// 判断当前用户是否登录,未登录不需要查询是否点赞UserDTO user = UserHolder.getUser();if (user == null) {return;}// 1. 获取当前用户Long userId = UserHolder.getUser().getId();// 2. 判断当前用户是否已经点赞String key = RedisConstants.BLOG_LIKED_KEY + blog.getId();Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());blog.setIsLike(BooleanUtil.isTrue(score !=null));}
功能测试:
成功实现用户只能点赞一次和点赞提示功能
该功能有一个缺陷:如果Redis中的数据被删除,但数据库中的点赞数不变,还是能够同一个用户多次点赞。
点赞排行榜功能
对于用户发表的笔记,我们实现一个点赞用户排行榜功能,展示前几个先点赞的用户基本信息
接口设计:
修改之前的点赞功能
在之前的点赞功能中,我们使用set数据结构来存储点赞用户,但由于set不支持 排序,因此查找前几名点赞用户较麻烦,而Redis中的zset数据结构,能够实现排序,我们可以通过将时间戳作为分数对用户id进行排序,从而实现前几个用户信息的查询。下面对之前的点赞代码进行改造。
选择Zset结果进行数据存储,使用score方法,通过查询对应value值的分数来判断数据是否存在,点赞时将时间戳作为分数进行存储。
java"> @Override@Transactionalpublic Result likeBlogById(Long id) {// 1. 获取当前用户Long userId = UserHolder.getUser().getId();// 2. 判断当前用户是否已经点赞String key = RedisConstants.BLOG_LIKED_KEY + id;Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());// 3. 如果未点赞,进行点赞操作if (score == null) {// 3.1 数据库点赞数+1boolean success = update().setSql("liked = liked + 1").eq("id", id).update();if (success) {// 点赞成功// 3.2 保存用户id到Redis中,表示用户已经点赞,保存时间戳作为分数stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());}else {log.error("点赞失败");}}else {// 4. 如果已点赞,取消点赞// 4.1 数据库点赞数-1boolean success = update().setSql("liked = liked - 1").eq("id", id).update();if (success) {// 4.2 从Redis中移除用户id,表示用户取消点赞stringRedisTemplate.opsForZSet().remove(key, userId.toString());}else {log.error("取消点赞失败");}}return Result.ok();}
isBlogLiked方法做相应的修改。
java"> private void isBlogLiked(Blog blog) {// 1. 获取当前用户Long userId = UserHolder.getUser().getId();// 2. 判断当前用户是否已经点赞String key = RedisConstants.BLOG_LIKED_KEY + blog.getId();Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());blog.setIsLike(BooleanUtil.isTrue(score ==null));}
测试:同样能够实现点赞功能
点赞排行榜代码:
Controller层:
获取前端传入的笔记id,然后执行对应的方法
java"> @GetMapping("/likes/{id}")public Result queryBlogLikesById(@PathVariable("id") Long id) {return blogService.queryBlogLikesById(id);}
Service层:
根据key查询前5个点赞用户id,查询不到则直接返回,查询到了需要将id解析为Long型的列表
通过将id组合成字符串用于SQl查询
注意,这里的SQL语句要自己拼接一部分,直接使用listByIds方法,查询的结果会按照数据库自动排序,而不是我们希望的顺序,因此通过last拼接最后一条SQL语句,来保证结果与isStr的顺序相同。
java"> @Overridepublic Result queryBlogLikesById(Long id) {String key = RedisConstants.BLOG_LIKED_KEY + id;//1. 查询top5的点赞用户信息Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);if (top5 == null || top5.isEmpty()) {// 没人点赞return Result.ok();}// 2. 解析出其中的用户idList<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());// 3. 将用户ids列表连接成字符串,用于后面SQL语句String idStr = StrUtil.join(",", ids);// 4. 根据id列表查询用户信息,并将User封装为UserDTO返回List<UserDTO> userDTOS = userService.query().in("id", ids)// 根据id查询用户信息.last("ORDER BY FIELD (id," + idStr + ")")// 根据id列表进行排序,保证得到结果跟传入的id顺序一致.list().stream().map(user -> BeanUtil.copyProperties(user, UserDTO.class))// 转换User为UserDTO.collect(Collectors.toList());return Result.ok(userDTOS);}
测试:
成功实现前5个点赞用户信息展示,这里显示7人点赞是因为之前数据库中存在点赞数据,但在Redis中未建立。
好友关注功能
关注和取关
基于该产品原型,我们需要实现两个功能:
- 关注和取关功能
- 判断是否关注功能
关注和取关功能
接口设计:
请求路径:api/follow/{id}/{true|false}
携带参数:用户id和关注还是取关操作
返回值:无返回值
代码开发:
Controller层:
接收前端传入的用户id和关注或取关操作,调用Service层的方法进行相应的操作
java"> /*** 根据用户id关注或取关用户* @param followUserId* @param isFollow* @return*/@PutMapping("/{id}/{isFollow}")public Result followById(@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFollow) {return followService.followById(followUserId, isFollow);}
Service层:
这里提前判断一下用户未登录的情况,防止出现空指针异常,然后根据isFollow的值进行相应的关注和取关操作。
java"> /*** 根据用户id关注或取关用户* @param followUserId* @param isFollow* @return*/@Overridepublic Result followById(Long followUserId, Boolean isFollow) {// 防止未登录,出现空指针异常UserDTO user = UserHolder.getUser();if (user == null) {return Result.ok();}// 1. 查询当前登录用户Long userId = UserHolder.getUser().getId();// 2. 根据操作为取关还是关注进行相应的操作if (isFollow) {// 2.1 关注操作,往follow表中新增数据Follow follow = new Follow();follow.setUserId(userId);follow.setFollowUserId(followUserId);save(follow);}else {// 2.2 取关操作,删除follow表中的数据remove(new QueryWrapper<Follow>().eq("user_id", userId).eq("follow_user_id", followUserId));}return Result.ok();}
查询关注状态功能
接口设计:
请求路径:api/follow/or/not/{id}
携带参数:路径传入id
返回参数:返回true或false
代码开发:
Controller层:
java"> /*** 根据id判断是否关注* @param followUserId* @return*/@GetMapping("/or/not/{id}")public Result isFollow(@PathVariable("id") Long followUserId) {return followService.isFollow(followUserId);}
Service层:
同样进行未登录判定,然后查询当前对应的信息
java"> /*** 根据id查询关注状态* @param followUserId* @return*/@Overridepublic Result isFollow(Long followUserId) {// 防止未登录,出现空指针异常UserDTO user = UserHolder.getUser();if (user == null) {return Result.ok();}// 1. 查询当前登录用户Long userId = UserHolder.getUser().getId();// 2. 查询是否关注Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();// 3. 返回结果return Result.ok(count > 0);}
功能测试
成功实现关注和取关功能,也能查询关注状态。
点击查询用户信息
需求分析:
点击对应的用户,需要返回用户基本信息和笔记信息,因此需要两个接口:
查询用户信息和查询用户笔记两个
查询用户信息功能
接口设计:
请求路径:api/user/{id}
请求携带参数:路径传参用户id
返回数据:将用户信息封装为UserDTO返回
代码开发:
Controller层:
java"> /*** 根据id查询用户信息* @param userId* @return*/@GetMapping("/{id}")public Result queryUserById(@PathVariable("id") Long userId) {return userService.queryUserById(userId);}
Service层:
java"> /*** 根据id查询用户信息* @param userId* @return*/@Overridepublic Result queryUserById(Long userId) {// 1. 查询用户信息User user = getById(userId);if (user == null) {// 没有用户,直接返回return Result.ok();}UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);return Result.ok(userDTO);}
查询用户笔记功能
接口设计:
请求路径:api/blog/of/user?&id=2¤t=1
请求参数:URL传参,传入用户id和分页数(第几页)
返回数据:返回笔记信息的List列表
代码开发
Controller层:
@RequestParam接收URL传参,调用Service层方法
java"> /*** 分页查询用户的博客* @param current* @param id* @return*/@GetMapping("of/user")public Result queryBlogByUserId(@RequestParam(value = "current", defaultValue = "1") Integer current,@RequestParam("id") Long id) {return blogService.queryBlogByUserId(current, id);}
Service层:
分页查询用户笔记信息并封装返回
java"> /*** 查询用户的博客信息* @param current* @param id* @return*/@Overridepublic Result queryBlogByUserId(Integer current, Long id) {// 根据用户查询博客信息Page<Blog> page = query().eq("user_id", id).page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));// 获取当前页数据List<Blog> records = page.getRecords();return Result.ok(records);}
功能测试
成功通过用户头像进入用户信息详情。
共同关注功能
对于共同关注功能,我们需要得到两个用户的关注列表,因次我们用Redis的set结构来存储用户的关注列表,它具有求交集的功能。首先对之前的关注功能进行修改,在关注成功和失败时对Redis进行相应的操作。
修改之前的关注功能
根据关注和取关操作的结果,进行相应的Redis操作。
java"> /*** 根据用户id关注或取关用户* @param followUserId* @param isFollow* @return*/@Overridepublic Result followById(Long followUserId, Boolean isFollow) {// 防止未登录,出现空指针异常UserDTO user = UserHolder.getUser();if (user == null) {return Result.ok();}// 1. 查询当前登录用户Long userId = UserHolder.getUser().getId();// 2. 根据操作为取关还是关注进行相应的操作String key = RedisConstants.USER_FOLLOWS_KEY + userId; // 用户关注列表keyif (isFollow) {// 2.1 关注操作,往follow表中新增数据Follow follow = new Follow();follow.setUserId(userId);follow.setFollowUserId(followUserId);boolean success = save(follow);if (success) {// 关注成功,将用户id保存到redis中,表示用户已经关注stringRedisTemplate.opsForSet().add(key, followUserId.toString());}}else {// 2.2 取关操作,删除follow表中的数据boolean success = remove(new QueryWrapper<Follow>().eq("user_id", userId).eq("follow_user_id", followUserId));if (success) {// 取关成功,从redis中移除用户id,表示用户已经取关stringRedisTemplate.opsForSet().remove(key, followUserId.toString());}}return Result.ok();}
共同关注功能实现接口设计
请求路径:api/follow/common/{id}
携带参数:路径传参传入用户id
返回数据:返回共同关注的用户列表
代码开发:
Controller层:
接受前端传入的用户id,调用Service方法
java"> /*** 根据id查询共同关注用户* @param id* @return*/@GetMapping("/common/{id}")public Result followCommons(@PathVariable("id") Long id) {return followService.followCommons(id);}
Service层:
使用Redis的set查询共同关注好友id,然后查询对应的信息封装返回
java"> /*** 根据id查询共同关注用户* @param id* @return*/@Overridepublic Result followCommons(Long id) {if (UserHolder.getUser() == null){// 防止未登录空指针异常return Result.ok(Collections.emptyList());}// 1. 查询当前登录用户Long userId = UserHolder.getUser().getId();// 2. 查询共同关注String key1 = RedisConstants.USER_FOLLOWS_KEY + userId;String key2 = RedisConstants.USER_FOLLOWS_KEY + id;// 2.1 获取两个集合的交集Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key1, key2);if (intersect == null || intersect.isEmpty()) {// 没有共同关注return Result.ok(Collections.emptyList());}// 2.2 将Set集合转为List集合,即用户id集合List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());// 3. 查询用户信息List<UserDTO> userDTOS = userService.listByIds(ids).stream().map(user -> BeanUtil.copyProperties(user, UserDTO.class)).collect(Collectors.toList());return Result.ok(userDTOS);}
功能测试:
在Redis中成功保存关注用户id
成功查询得到共同关注用户信息
关注推送功能
我们需要对用户关注的用户笔记进行推送
关注推送也叫做Feed流,直译为投喂,它为用户持续提供沉浸式的体验。
主要有两种模式:
传统模式是用户去寻找内容,通常用于对关注的用户的内容进行推送,如微信朋友圈11
另一种模式是Feed模式,它是内容去匹配可能需要的用户,也就是现在的大数据推送。它不需要用户关注当前内容就可推送看见。
Feed流的实现方案
方案1 :拉模式(读扩散)
即发送方把发送的内容放到一个发件箱中,接收方在需要的时候去发件箱中获取信息放入收件箱中展示,展示完以后删除收件箱。
好处是:占用内存较少
缺陷是:接收方每次都要去发件箱获取信息,耗时较长
方案2:推模式(写扩散)
即发送方直接将消息发送到接受方的收件箱中,接收方可随时查看
优点:接收方不需要额外查询,速度较快
缺点:内存浪费,当接收方过多时,发送占用空间较大,且许多接收方不会查看消息,因此造成资源的浪费。
方案3:推拉结合模式(读写混合)
通过将推拉结合,对于接收方较多的发送发,采用拉模式,发送到收件箱中,用户使用时去发件箱中获取。对于活跃的接受方或者接收方较少的发送方,在发送时采用推模式,将信息发送到收件箱中。
Feed流产品常见模式
- Timeline:不做内容筛选,简单的按照内容的发布顺序进行排序,常用于好友或关注,如微信朋友圈,QQ空间
- 优点:信息全面,不会有缺失,实现相对简单
- 缺点:信息噪音较多,用户不一定感兴趣,有用内容的获取效率较低
- 智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容,推送用户感兴趣的内容以吸引用户。
- 优点:投喂用户感兴趣的信息,用户粘度较高,收益较高
- 缺点:如果算法不精准可能带来反效果,即对算法精准度要求较高
推送消息到粉丝收件箱
对于关注推动功能,我们需要完成下面的需求:
- 修改新增探店笔记额业务,在保存blog到数据库的同时,推送到粉丝的收件箱中
- 使用Redis实现收件箱功能,要求满足按照时间戳排序
- 查询收件箱数据时,可以实现分页查询
对Redis中的数据结构分析,发现Lis和Zset可以实现分页查询功能,但由于Feed流中的数据会不断地更新,如果使用List结构,由于数据的角标在不断的更新,可能出现重读的情况,即
因此我们采用Zset数据结构作为收件箱,然后在分页的时候,记录上一次读取数据的位置,保证下一次读取从上一次读取数据之后开始读取,从而防止重复读取的情况。
修改新增探店笔记业务
修改之前的新增笔记业务
Controller层:
java"> /*** 新建Blog笔记* @param blog* @return*/@PostMappingpublic Result saveBlog(@RequestBody Blog blog) {// 返回idreturn blogService.saveBlog(blog);}
Service层:
在新增笔记完成后,将笔记ID发送给所有笔记发布用户的粉丝
这里的时间戳,教程是在发送每个粉丝收件箱是给定,我觉得应该保证每个笔记在粉丝那发送时间一直,因此使用的同一个时间戳。
java"> /*** 新增探店笔记* @param blog* @return*/@Overridepublic Result saveBlog(Blog blog) {// 1.获取登录用户UserDTO user = UserHolder.getUser();blog.setUserId(user.getId());// 2.保存探店博文boolean isSuccess = save(blog);// 根据新增成功与否进行下一步if (!isSuccess) {// 3. 失败,返回错误信息return Result.fail("新增笔记失败");}// 记录当前时间戳long currentTimeMillis = System.currentTimeMillis();// 新增成功,推送笔记id给所有粉丝// 4.查询当前用户粉丝List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();if (follows == null || follows.isEmpty()) {// 没有粉丝,直接返回return Result.ok(blog.getId());}// 5. 推送笔记id给所有的粉丝for (Follow follow : follows) {// 5.1 获取粉丝idLong userId = follow.getUserId();// 5.2 推送String key = RedisConstants.FEED_KEY + userId;// 这里我认为同一个blog在不同收件箱中的时间戳是否应该为同一个,表示发布时间一致stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), currentTimeMillis);}// 返回idreturn Result.ok(blog.getId());}
测试:成功在新增笔记时发送笔记id到粉丝收件箱中
分页查询收件箱数据
滚动分页查询
由Feed流会不断的更新收件箱中的消息,因此,我们不能使用传统的分页查询模式,需要使用滚动分页查询的方式,即Zset结构的ZREVRANGEBYSCORE方法来实现score从大到小排序
ZREVRANGEBYSCORE参数介绍:
- max:查询开始位置,通常为当前时间戳(无上一次时间戳输入)|上一次查询的最小时间戳
- min:0,最小时间戳,不需要管,靠max和count来管理记录个数
- offset:偏移量,0表示从max开始|从上一次的结果中,与最小值一样的元素的个数,保证不会漏查和重复查
- count:需要查询的数据量
接口设计
注意返回值:需要本次的返回值用于下一次的数据查询
代码开发:
Controller层:
获取前端传入的开始时间戳和偏移量,偏移量不传时默认为0
java"> /*** 分页查询关注用户笔记* @param max* @param offset* @return*/@GetMapping("/of/follow")public Result queryBlogOfFollow(@RequestParam("lastId") Long max, @RequestParam(value = "offset", defaultValue = "0") Integer offset){return blogService.queryBlogOfFollow(max,offset);}
Service层:
使用Zset的reverseRangeByScoreWithScores查询收件箱中的信息
通过解析查询的数据,得到blogId,minTine(时间戳),offset,然后查询对应的Blog信息,最后进行数据封装并返回。
java"> /*** 分页查询用户的关注者的博客信息* @param max* @param offset* @return*/@Overridepublic Result queryBlogOfFollow(Long max, Integer offset) {if (UserHolder.getUser() == null) return Result.fail("请先登录");// 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, 2);// 3. 非空判断if (typedTuples == null || typedTuples.isEmpty()) {return Result.ok();}// 4. 解析查询的数据,得到blogId,minTine(时间戳),offsetList<Long> ids = new ArrayList<>(typedTuples.size());// 设置大小,避免扩容影响系统性能long minTine = 0; // 记录本次最小时间戳int os = 1; // 记录最小时间戳数据个数,即下一次的偏移量for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {// 4.1 获取blog的idids.add(Long.valueOf(typedTuple.getValue()));// 4.2 获取分数long time = typedTuple.getScore().longValue();// 记录最小分数和个数if (minTine == time) {os++;}else {// 更新最小分数minTine = time;os = 1;}}// 5. 根据blog的id查询详细的信息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(minTine);return Result.ok(r);}
功能测试:
成功实现分页查询收件箱信息。
附近店铺功能
GEO介绍

导入店铺经纬度信息到Redis中
因为我们使用Redis的数据结构;来实现地理位置的查询,因此我们需要先将店铺的经纬度信息保存在Redis中,以便后续使用。
这里我们将每一个Shop写入一次Redis改成每一个店铺类型写入一次Redis,减少了对Redis的访问次数,提高整体效率。
java"> /*** 将店铺经纬度信息上传到Redis中*/@Testvoid loadShopData(){// 1. 查询店铺信息List<Shop> list = shopService.list();// 2. 对店铺信息按照typeId分组,将同一个类型的店铺放在同一个集合中Map<Long, List<Shop>> map = list.stream().collect(Collectors.groupingBy(Shop::getTypeId));// 3. 分组写入到Redis中for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {// 3.1 获取类型idLong typeId = entry.getKey();//3.2 获取同类型店铺集合List<Shop> value = entry.getValue();String key = RedisConstants.SHOP_GEO_KEY + typeId;List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());// 3.3 把店铺信息转换为RedisGeoCommands.GeoLocation对象集合for (Shop shop : value) {locations.add(new RedisGeoCommands.GeoLocation<>(shop.getId().toString(),new Point(shop.getX(), shop.getY())));}// 3.4 将当前类型shop集合写入Redis中,每种类型只写入一次stringRedisTemplate.opsForGeo().add(key, locations);
// // 3.3 写入Redis
// for (Shop shop : value) {
// // 传统的每次写入一个shop信息,对系统负担大
// stringRedisTemplate.opsForGeo()
// .add(key, new Point(shop.getX(), shop.getY()), shop.getId().toString());
// }}}
实现附近商铺功
接口设计
请求路径:api/shop/type
携带参数:typeId、current,经纬度想x,y(经纬度可能不传)
代码实现:
Controller层:
添加接收经纬度的参数,但不是必须的
java"> /*** 根据商铺类型分页查询商铺信息* @param typeId 商铺类型* @param current 页码* @param x 经度 不可不要* @param y 纬度 不可不要* @return 商铺列表*/@GetMapping("/of/type")public Result queryShopByType(@RequestParam("typeId") Integer typeId,@RequestParam(value = "current", defaultValue = "1") Integer current,@RequestParam(value = "x", required = false) Double x,@RequestParam(value = "y", required = false) Double y) {return shopService.queryShopByType(typeId, current, x, y);}
Service层:
根据经纬度是否传入,来判断是否需要根据位置查询,未传入则直接在数据库中分页查询即可
这里注意,由于geo的search功能是在Redis6.x之后才有的,所以本功能实现需要对Redis进行升级操作才能完成。
java"> /*** 根据店铺类型和经纬度分页查询店铺信息* @param typeId* @param current* @param x* @param y* @return*/@Overridepublic Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {// 1. 判断需不需要经纬度查询if (x == null || y == null) {// 不传入经纬度时直接根据店铺类型查询Page<Shop> page = query().eq("type_id", typeId).page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));// 返回数据return Result.ok(page.getRecords());}// 2. 计算分页的参数int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;int end = current * SystemConstants.DEFAULT_PAGE_SIZE;// 3. 查询redis、按照距离排序、分页。结果:shopId、distanceString key = RedisConstants.SHOP_GEO_KEY + typeId;GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo().search(key, GeoReference.fromCoordinate(x, y),// 根据经纬度查询new Distance(5000),// 半径范围,以米为单位,可以传入单位RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end));// 这里limit是分页查询从0到end,后续需要自己调整// 判空if (results == null) {return Result.ok(Collections.emptyList());}// 4. 解析店铺idList<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();if(list.size() <= from) {// 没有下一页了,结束return Result.ok(Collections.emptyList());}// 4.1 截取form到end的数据List<Long> ids = new ArrayList<>(list.size());Map<String, Distance> distanceMap = new HashMap<>(list.size());list.stream().skip(from).forEach(result -> {// skip会跳过form之前的数据// 4.2 获取店铺idString shopIdStr = result.getContent().getName();ids.add(Long.valueOf(shopIdStr));// 4.3 获取距离Distance distance = result.getDistance();distanceMap.put(shopIdStr, distance);});// 5. 根据id查询shop信息String idStr = StrUtil.join(",", ids);List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();for (Shop shop : shops) {// 设置每个店铺的距离信息shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());}return Result.ok(shops);}
功能测试:
成功根据位置查询得到附近商铺。
用户签到功能
BitMap(位图)用法
通过位图的方式,能够在记录用户签到情况的同时,大大降低内存消耗。
Redis中利用String类型数据结构实现BitMap,但它的命令是用BitMap的新命令
一些常见的命令如下:
实现用户签到
接口设计:
这里无请求参数是因为可以直接在后台获取当前登录用户
代码开发:
Controller层:
java"> /*** 用户当日签到功能* @return*/@PostMapping("/sign")public Result sign(){return userService.sign();}
Service层:
这里增加对用户未登录的判断和重复签到的判定。
java"> /*** 用户当日签到功能* @return*/@Overridepublic Result sign() {// 1. 查询当前登录用户if (UserHolder.getUser() == null){// 防止未登录return Result.fail("请先登录");}Long userId = UserHolder.getUser().getId();// 2. 获取日期LocalDateTime now = LocalDateTime.now();// 3. 拼接keyString keySuffix = now.format(DateTimeFormatter.ofPattern("yyyyMM"));// 某年某月String key = RedisConstants.USER_SIGN_KEY + userId + keySuffix;// 4. 获取今天是本月第几天int day = now.getDayOfMonth();// 5. 判断今天是否已经签到Boolean isSign = stringRedisTemplate.opsForValue().getBit(key, day - 1);if (isSign) {// 已签到return Result.fail("请勿重复签到");}// 6. 未签到,写入RedisstringRedisTemplate.opsForValue().setBit(key, day-1, true);return Result.ok();}
功能测试
成功实现签到功能
连续签到功能
接口设计
代码开发
Controller层:
java"> /*** 连续签到次数统计* @return*/@GetMapping("/sign/count")public Result signCount(){return userService.signCount();}
Service层:
按照正常的逻辑来说,当天如果签到了,我们需要算到连续签到,如果当天还没有签到,但前一天签到了,应该从前一天开始统计连续签到次数,而不是当前没有签到就断签。
java"> /*** 连续签到次数统计* @return*/@Overridepublic Result signCount() {// 1. 查询当前登录用户if (UserHolder.getUser() == null){// 防止未登录return Result.fail("请先登录");}Long userId = UserHolder.getUser().getId();// 2. 获取日期LocalDateTime now = LocalDateTime.now();// 3. 拼接keyString keySuffix = now.format(DateTimeFormatter.ofPattern("yyyyMM"));// 某年某月String key = RedisConstants.USER_SIGN_KEY + userId + keySuffix;// 4. 获取今天是本月第几天int day = now.getDayOfMonth();// 5. 判断今天是否已经签到Boolean isSign = stringRedisTemplate.opsForValue().getBit(key, day - 1);// 6.获取本月截止到今天为止的所有签到记录,返回的是一个十进制得到数字List<Long> results = stringRedisTemplate.opsForValue().bitField(key, BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(day)).valueAt(0));if (results == null || results.isEmpty()) {return Result.ok(0);}// 获取签到的次数Long num = results.get(0);if (num == null || num == 0) {return Result.ok(0);}// 7. 循环遍历,获取连续签到次数int count = 0;if (!isSign) {// 如果今天未签到,不应该考虑今天,右移一位从前一天开始统计num >>>= 1;}while (true) {// 7.1 让num与1做与运算,得到数字的最后一个bit位if ((num & 1) == 0) {// 不是1,说明未签到,结束break;}else {// 不为0,说明签到,计数器加一count++;}// 7.2 将数字右移一位,相当于num/2,抛弃最后一个bit位,继续下一个bit位num >>>= 1;}return Result.ok(count);}
功能测试
测试了当天签到,前一天也签到的情况,当前未签到但前一天签到了的情况,当前未签到且前一天也未签到的情况。都成功拿到了理想的结果。
UV统计
UV:全称为Unique Visitor,也叫独立访问量,是指互联网访问。浏览这个网页的自然人,一天内同一个用户多次访问该网站,只记录一次。
PV:全称为Page View,也叫页面访问量或点击量,用户每访网站的一个页面,则记录1次PV,用户多次打开页面,则记录多次PV。往往被用来衡量网站的流量。
UV统计在服务端做会比较麻烦,因为需要判断用户是否已经通过了,需将统计过的用户信息保存,但如果每访问的用户都信息都保存在Redis中,造成的数据集量会非常恐怖。因此HLL算法出现了。
HyperLogLog
HyperLogLog(HLL)是从LogLog算法派生出的概率算法,用户确定非常大的集合的基数,而不需要存储其所有的值。
Redis中的HLL是基于String结构实现的,他能保证单个HLL存储的内存永远小于16KB,当然,作为代价,其测量的结果时概率性的,有哦小于0.81%的误差,但=对于大部分UV统计来说,这完成可以忽略。
测试百万数据的UV
通过向Redis中插入HLL的百万的数据,测试一下HLL算法的存储代价和精确度。
记录目前的Redis内存消耗:575724
测试代码:
java"> @Testvoid testHyperLogLog(){// 准备数组,装测试数据String[] values = new String[1000];// 数组下标int index = 0;for (int i = 0; i < 1000000; i++) {// 赋值values[index++] = "user_" + i;if (i % 1000 == 0) {// 每一千条发送一次index = 0;stringRedisTemplate.opsForHyperLogLog().add("hl1", values);}}// 统计数据Long size = stringRedisTemplate.opsForHyperLogLog().size("hl1");System.out.println("size=" + size);}
测试结果:
只增加了十几k的数据
十万数据得到九万九千多。